Files

11 KiB
Raw Permalink Blame History

tags, create time
tags create time
算法/动态规划
完全背包
基础DP
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,你一定会拿起最后一枚硬币。枚枚举这枚硬币是什么,剩下交给子问题。

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 ← 答案
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(推荐 ✅)

// 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:枚举"最后一枚硬币可能是哪个",从多个可能的前驱状态中选最小值
  • 空间顺序:先金额后硬币(也可以反过来,因为是完全背包,两种遍历顺序都正确)

版本二:精简写法

// 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),属于典型的完全背包最小化问题