题目描述
给定一个整数数组 coins,表示不同面额的硬币;再给定一个整数 amount,表示总金额。
返回可以凑成总金额所需的最少硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。
每种硬币的数量可以认为是无限的。
例如:
输入:coins = [1,2,5], amount = 11
输出:3
解释:11 = 5 + 5 + 1
原始思路:递归选或不选
最开始的方向是用递归搜索:站在第 i 个硬币的位置,考虑两种选择。
- 选
coins[i]:金额减少coins[i],因为硬币可以重复使用,所以仍然停留在i。 - 不选
coins[i]:跳到i + 1,后续只考虑剩下的硬币。
因此核心结构是:
dfs(i, amount) = min(
dfs(i, amount - coins[i]) + 1,
dfs(i + 1, amount)
)
这个方向是成立的,但原始代码把"无解"和"刚好凑完"都返回成了 0,导致无解分支也会被 Math.min 当成很优的答案。
问题出在哪里
用这个测试用例手动模拟:
coins = [2]
amount = 3
如果只有硬币 2,显然不能凑出 3,答案应该是 -1。
但原始递归里有这样的出口:
如果 i 越界 或 amount == 0:
返回 0
这会让两种完全不同的状态混在一起:
amount == 0:刚好凑完,返回0是合理的。i >= coins.length且amount != 0:硬币用完但没凑成,应该表示无解。
如果无解也返回 0,递归就会误以为"不需要硬币也能凑成",最终得到错误答案。
状态定义
定义 dfs(i, amount) 为:只使用 coins[i...] 中的硬币,凑成 amount 所需的最少硬币数。
这个状态必须同时包含 i 和 amount。
原因是同样要凑 amount = 6,下面两个状态的含义不同:
dfs(0, 6)
dfs(1, 6)
它们剩余可选的硬币范围不同,所以不能只用一维 memo[amount] 缓存结果,而要使用二维 memo[i][amount]。
状态转移
当 amount == 0 时,表示已经刚好凑完,返回 0。
当 i >= coins.length 且 amount != 0 时,表示所有硬币都尝试完了仍然凑不出目标金额,返回 INF。
如果 coins[i] > amount,当前硬币不能选,只能跳过:
dfs(i, amount) = dfs(i + 1, amount)
否则有两种选择:
dfs(i, amount) = min(
dfs(i, amount - coins[i]) + 1,
dfs(i + 1, amount)
)
这里选当前硬币后仍然是 dfs(i, ...),不是 dfs(i + 1, ...),因为每种硬币可以无限使用。
记忆化搜索
伪代码:
INF <- 一个足够大的数
函数 coinChange(coins, amount):
memo <- 二维数组,大小为 coins.length * (amount + 1)
ans <- dfs(0, amount)
如果 ans >= INF,返回 -1
否则返回 ans
函数 dfs(i, amount):
如果 amount == 0:
返回 0
如果 i 已经越过最后一个硬币:
返回 INF
如果 memo[i][amount] 已经计算:
返回 memo[i][amount]
如果 coins[i] > amount:
memo[i][amount] <- dfs(i + 1, amount)
返回 memo[i][amount]
memo[i][amount] <- min(
dfs(i, amount - coins[i]) + 1,
dfs(i + 1, amount)
)
返回 memo[i][amount]
对应 Java 代码:
class Solution {
private static final int INF = Integer.MAX_VALUE / 2;
int[][] memo;
public int coinChange(int[] coins, int amount) {
memo = new int[coins.length][amount + 1];
int ans = dfs(0, amount, coins);
return ans >= INF ? -1 : ans;
}
public int dfs(int i, int amount, int[] coins) {
if (amount == 0) {
return 0;
}
if (i >= coins.length) {
return INF;
}
if (memo[i][amount] != 0) {
return memo[i][amount];
}
if (coins[i] > amount) {
return memo[i][amount] = dfs(i + 1, amount, coins);
}
return memo[i][amount] = Math.min(
dfs(i, amount - coins[i], coins) + 1,
dfs(i + 1, amount, coins)
);
}
}
易错点
1. 无解状态不能返回 0
0 只应该表示"已经凑出目标金额后,还需要 0 枚硬币"。如果硬币用完仍未凑成,应该返回一个很大的数,让它在 Math.min 中表现为很差的选择。
2. 记忆化状态不能只看 amount
dfs(i, amount) 的结果不仅取决于剩余金额,也取决于当前还能使用哪些硬币。因此 memo[amount] 会混淆状态,应该使用 memo[i][amount]。
3. 选当前硬币后 i 不变
因为每种硬币数量无限,选择 coins[i] 后仍然可以继续选它,所以递归到 dfs(i, amount - coins[i])。
4. INF 不能直接用 Integer.MAX_VALUE
递归式里有 dfs(...) + 1。如果无解状态用 Integer.MAX_VALUE,再加 1 会溢出。因此可以使用 Integer.MAX_VALUE / 2 作为 INF。
5. 最终判断不要只依赖相等
最终把无解转成 -1 时,用 ans >= INF 更稳,不依赖结果刚好等于某个固定的 INF 值。
建议测试用例
coins = [2], amount = 3 -> -1
coins = [1,2,5], amount = 11 -> 3
coins = [1], amount = 0 -> 0
coins = [2,5,10,1], amount = 27 -> 4
复杂度分析
- 时间复杂度:
O(n * amount),其中n是硬币种类数,每个(i, amount)状态最多计算一次。 - 空间复杂度:
O(n * amount),主要来自二维memo数组;递归调用栈额外占用O(n + amount)级别空间。
复盘
这题的关键不是"能不能递归",而是递归状态和返回值语义要清楚。
本轮最核心的三个检查点是:
amount == 0表示成功,返回0。- 硬币用完但
amount != 0表示无解,返回INF。 memo必须覆盖完整状态i和amount。
最终递推式为:
dfs(i, amount) = min(dfs(i, amount - coins[i]) + 1, dfs(i + 1, amount))