--- tags: [算法/动态规划, 完全背包, 基础DP] create time: 2026-05-16 19:00 --- # 322. 零钱兑换(Coin Change) > [!quote] LeetCode 原题 · 中等 > 给你一个整数数组 `coins`,表示不同面额的硬币;以及一个整数 `amount`,表示总金额。 > > 计算并返回可以凑成总金额所需的 **最少的硬币个数**。如果没有任何一种硬币组合能组成总金额,返回 -1 。 > > 你可以认为每种硬币的数量是无限的。 > > - 输入范围:`1 <= coins.length <= 12`,`1 <= coins[i] <= 2³¹ - 1`,`0 <= amount <= 10⁴` ## 题面 **示例 1:** ``` 输入:coins = [1, 2, 5], amount = 11 输出:3 解释:11 = 5 + 5 + 1 ``` **示例 2:** ``` 输入:coins = [2], amount = 3 输出:-1 ``` **示例 3:** ``` 输入:coins = [1], amount = 0 输出:0 ``` ## 思路 ### 第一步:识别问题模型 > [!question] 💡 思考 > 题目说"每种硬币数量无限",目标是"最少硬币数凑出 amount"——这和之前哪道题的内核一致? 没错!**[[84-完全平方数]]** 和这道题的本质一模一样:都是给定一组"面额",每种可以无限取用,要求凑出目标值的最小件数。 我们可以把它看作一道**「最小物品数」版本的完全背包**: | DP 要素 | 含义 | |---------|------| | **物品集合** | 所有硬币面额 coins[0..m-1] | | **背包容量** | amount | | **每种物品数量** | 无限 | | **价值定义** | 每件物品的"代价"为 1(一枚硬币算一次) | | **优化目标** | 总代价最小 | ### 第二步:定义状态与转移方程 定义状态: > `dp[i]` = 凑出金额 i 所需的最少硬币个数 那状态怎么转移?考虑"最后一步拿的是哪枚硬币"——假设最后拿的是 `coins[j]`,那么之前的金额就是 `i - coins[j]`,需要的硬币数是 `dp[i - coins[j]]`。所以: > **`dp[i] = min(dp[i - coins[j]]) + 1`**,对所有满足 `coins[j] ≤ i` 的 j 取最小值 核心直觉很简单: > 要凑到金额 i,你一定会拿起最后一枚硬币。枚枚举这枚硬币是什么,剩下交给子问题。 ```mermaid flowchart TD subgraph "dp[11] 的状态转移 (coins=[1,2,5])" A["dp[11]\n= min(...) + 1"] --> B["最后拿 1:\ndp[10] + 1"] A --> C["最后拿 2:\ndp[9] + 1"] A --> D["最后拿 5:\ndp[6] + 1"] B --> E["dp[10]=2 → ans=3"] C --> F["dp[9]=2 → ans=3"] D --> G["dp[6]=2 → ans=3 ✓"] end style A fill:#e7f3ff,stroke:#3b82f6 style E fill:#dcfce7,stroke:#16a34a style F fill:#dcfce7,stroke:#16a34a style G fill:#dcfce7,stroke:#16a34a ``` > [!note] 📝 为什么这是正确的? > 因为"完全背包"有一个关键性质:**最优子结构**。凑出金额 i 的最优方案中,去掉最后一枚硬币后,剩余部分一定也是对应金额的最优方案。否则我们可以用更优的子方案替换它,得到更好的整体解——矛盾。 ### 第三步:确定边界条件 | 情况 | dp 值 | 理由 | |------|-------|------| | `dp[0]` | **0** | 凑出金额 0 不需要任何硬币,这是递推的"空起点" | | 其他 `dp[i]` | **+∞(不可达)** | 初始化为无穷大,表示还未找到可行方案 | > [!warning] ⚠️ 注意"不可达"的处理 > 如果最终 `dp[amount]` 仍然是无穷大,说明无法凑出该金额,应返回 **-1**。例如 coins = [2], amount = 3 时,dp[3] 永远不会被更新。 ### 第四步:自底向上填表 以 `coins = [1, 2, 5], amount = 11` 为例: | i | dp[i] 计算过程 | dp[i] | |---|--------------|-------| | 0 | — | **0** | | 1 | min(dp[0]+1) | **1** | | 2 | min(dp[1]+1, dp[0]+1) | **1** | | 3 | min(dp[2]+1, dp[1]+1) | **2** | | 4 | min(dp[3]+1, dp[2]+1, dp[-1]无效) | **2** | | 5 | min(dp[4]+1, dp[3]+1, dp[0]+1) | **1** ← 正好有面额 5! | | 6 | min(dp[5]+1, dp[4]+1, dp[1]+1) | **2** | | 7 | min(dp[6]+1, dp[5]+1, dp[2]+1) | **2** | | 8 | min(dp[7]+1, dp[6]+1, dp[3]+1) | **3** | | 9 | min(dp[8]+1, dp[7]+1, dp[4]+1) | **3** | | 10 | min(dp[9]+1, dp[8]+1, dp[5]+1) | **2** | | 11 | min(dp[10]+1, dp[9]+1, dp[6]+1) | **3** ← 答案 | ```mermaid flowchart LR subgraph "填表示意" A["dp[0]=0"] --> B["dp[1]=1"] B --> C["dp[2]=1"] C --> D["dp[3]=2"] D --> E["dp[4]=2"] E --> F["dp[5]=1 ★"] F --> G["dp[6]=2"] G --> H["dp[7]=2"] H --> I["dp[8]=3"] I --> J["dp[9]=3"] J --> K["dp[10]=2"] K --> L["dp[11]=3\n★答案"] end style A fill:#f3f4f6,stroke:#6b7280 style F fill:#fef3c7,stroke:#f59e0b style L fill:#dcfce7,stroke:#16a34a ``` > 表中 ★ 标记表示可以直接用一枚硬币凑出(amount 本身在硬币列表中)。 ## 代码提示 在动手写代码之前,想一想这些关键决策点: > [!question] 💡 思考 1 > Go 语言中没有"无穷大"常量——你会用什么值来表示不可达? 可以用 `amount + 1`:因为最多只需要 amount 个 1 元硬币就能凑出任意 amount,所以 **超过 amount 的硬币数一定是无意义的**。这个巧妙的设计让判断"是否可达"变得非常简单——只需检查 `dp[amount] > amount`。 > [!question] 💡 思考 2 > 内层循环中,如何避免负索引越界? 对每个硬币 `coin`,只有当 `i >= coin` 时才考虑从 `dp[i-coin]` 转移。这样天然避免了索引问题。 ## 技巧 ### "无穷大"的巧妙取值 > [!tip] ⚡ 小技巧:为什么选 amount+1 作为无穷大? > > 如果全部使用 1 元硬币,凑出 amount 需要恰好 amount 枚。**任何合理的方案都不会超过这个数字**。因此: > - `dp[i] > amount` → 说明没有找到可行方案(不可达) > - `dp[i] <= amount` → 这就是真正的最优解 这样既避免了引入额外常量,又简化了最终结果的判断逻辑。 ### BFS 替代方案 > [!abstract] 🔍 进阶视角:BFS 求最短路径 > > 另一种等价的建模方式是把金额看作节点、把每次加一枚硬币看作边。从 amount 开始做 BFS,第一次到达 0 时的层数就是答案。 > > - **优势**:BFS 天然保证第一层到达的就是最优解,无需全部填完表格 > - **劣势**:最坏空间复杂度 O(amount),不如 DP 稳定 > > 面试中推荐 DP 解法,但知道 BFS 思路可以增加灵活性。 ### 剪枝优化 > [!tip] ⚡ 小优化:先排序硬币 > > 将硬币按面额从大到小排序后,内层循环可以先尝试大面额硬币。虽然不影响渐近时间复杂度,但在实际测试数据中往往能更快找到较优的中间解,减少后续比较的次数。 | 方法 | 时间 | 空间 | 评价 | |------|------|------|------| | DP 填表(推荐) | O(amount × m) | O(amount) | 直观通用,面试首选 | | BFS | O(amount × m) | O(amount) | 第一层到达即最优,略快 | | 带记忆化的 DFS | O(amount × m) | O(amount) | 递归写法,代码简洁但有栈溢出风险 | 其中 m = coins.length。 ## 代码 ### Go 语言实现 #### 版本一:自底向上 DP(推荐 ✅) ```go // coinChange returns the minimum number of coins to make up the given amount. // If it's impossible, returns -1. func coinChange(coins []int, amount int) int { // maxCoins 充当"无穷大":最多只用 amount 个 1 元硬币 maxCoins := amount + 1 // dp[i] = 凑出金额 i 所需的最少硬币个数 dp := make([]int, amount+1) // 初始化:全部设为"不可达" for i := 1; i <= amount; i++ { dp[i] = maxCoins } // 边界:凑出金额 0 不需要硬币 dp[0] = 0 // 自底向上填表:先枚举金额,再枚举硬币 for i := 1; i <= amount; i++ { for _, coin := range coins { if i < coin { continue // 当前硬币面额大于目标金额,跳过 } // 选或不选这枚硬币:dp[i] = min(不用这枚, 用这枚 + 1) used := dp[i-coin] + 1 if used < dp[i] { dp[i] = used } } } // 如果 dp[amount] 仍为无穷大,说明无法凑出 if dp[amount] > amount { return -1 } return dp[amount] } ``` > [!summary] 💡 代码结构拆解 > - **外层 for i**:从左到右依次计算 dp[1]...dp[amount],确保每个子问题在用到时已解决 > - **内层 for coin**:枚举"最后一枚硬币可能是哪个",从多个可能的前驱状态中选最小值 > - **空间顺序**:先金额后硬币(也可以反过来,因为是完全背包,两种遍历顺序都正确) #### 版本二:精简写法 ```go // coinChange returns the minimum number of coins to make up the given amount. // Space and code simplified version. func coinChange(coins []int, amount int) int { dp := make([]int, amount+1) // Go 的 slice 零值初始化为 0,恰好 dp[0]=0 符合预期 // 其余位置需要手动标记为"不可达" for i := range dp { if i > 0 { dp[i] = amount + 1 } } for i := 1; i <= amount; i++ { for _, c := range coins { if i >= c { v := dp[i-c] + 1 if v < dp[i] { dp[i] = v } } } } if dp[amount] > amount { return -1 } return dp[amount] } ``` ### 执行过程演示(coins = [1, 2, 5], amount = 11) ``` 初始: dp[0]=0, 其余均为 ∞(=12) i=1: coin=1: dp[0]+1=1 → dp[1]=1 i=2: coin=1: dp[1]+1=2 → dp[2]=2 coin=2: dp[0]+1=1 → dp[2]=1 ★更新 i=3: coin=1: dp[2]+1=2 → dp[3]=2 coin=2: dp[1]+1=2 → 不更新(相等) i=4: coin=1: dp[3]+1=3 → dp[4]=3 coin=2: dp[2]+1=2 → dp[4]=2 ★更新 i=5: coin=1: dp[4]+1=3 → dp[5]=3 coin=2: dp[3]+1=3 → 不更新 coin=5: dp[0]+1=1 → dp[5]=1 ★更新(正好面额5!) i=6: coin=5: dp[1]+1=2 → dp[6]=2 ...(以此类推)... i=10: coin=5: dp[5]+1=2 → dp[10]=2 i=11: coin=5: dp[6]+1=3 → dp[11]=3 结果: dp[11] = 3 ✅ (11 = 5+5+1) ``` ### 复杂度分析 - **时间复杂度:O(amount × m)** — 外层循环 amount 次,内层循环 m 次(m 为硬币种类数),每次只做常数次操作。代入题目约束:最多 10⁴ × 12 = 1.2 × 10⁵ 次操作 - **空间复杂度:O(amount)** — dp 数组占用 amount+1 个整型空间 ## 举一反三 这道题是 **"完全背包"** 思想的第一道落地应用,也是 DP 入门必刷的经典: - [[84-完全平方数]] — 本质相同的"最小件数"问题,只是面额集合换成完全平方数 - [[83-打家劫舍]] — 同样是逐状态递推,但约束是"不相邻"而非"无限选取" - LeetCode 279. 完全平方数 — 同上一题 - LeetCode 518. 零钱兑换 II —— **变种**:改为求"组合数"而非"最少个数",转移方程变为累加而非取 min - LeetCode 377. 组合总和 Ⅳ —— **变种**:排列数和组合数的区别(外层枚举目标和 vs 外层枚举物品) > [!summary] 📌 本节要点 > 1. 定义 dp[i] = 凑出金额 i 的最少硬币个数 > 2. 状态转移:dp[i] = min(dp[i - coin]) + 1,枚举每枚可能的最后硬币 > 3. 边界 dp[0] = 0,初始化 dp[i] = amount + 1(表示不可达) > 4. 最终判断:若 dp[amount] > amount 则返回 -1 > 5. 复杂度 O(amount × m),属于典型的完全背包最小化问题