贪心的直觉,为什么在此处失效?
给定一个整数数组 coins 表示不同面额的硬币,以及一个整数 amount 表示总金额。计算凑成总金额所需的最少硬币数。如果无法凑出,则返回 -1。你可以认为每种硬币的数量是无限的。
生活中的直觉常常告诉我们:尽可能先找最大面额的钱。例如面额 [1, 2, 5] 凑 11 元,先拿 5,再拿 5,最后拿 1,共 3 枚,确实是最优解。但这仅仅是因为特定面额恰好满足贪心选择性质。
若面额为 [1, 3, 4],目标金额为 6 元:
- 贪心算法:优先选最大的
4,剩下2只能用1 + 1,总共需4 + 1 + 1 = 3枚; - 全局最优:选择两枚
3元硬币,仅需3 + 3 = 2枚!
贪心每一步只顾眼前的最大面额,从而错失了全局更紧凑的组合。必须通过动态规划(DP)穷尽所有合法的子问题最优解。
逐面额填表,步进推演最优转移。
观察 dp[j] 如何随着硬币面额逐步被测试与更新。高亮方块展示了当前计算的目标金额 j 与来源前置金额 j - coin 之间的差额对比。
- 当前计算目标 j
- 依赖前置 j - coin
- 已有有效解
- 暂时不可达 (∞)
—
—
1 / 1
初始化状态:dp[0]=0,其余设为 ∞。
因为硬币可以无限次使用,所以每种硬币内层金额从 coin 到 amount 正序递增。
三个关键细节:为什么正序,怎么判负?
- 01
完全背包必须“从小到大(正序)”遍历
在 0/1 背包(如 LeetCode 416)中,每个物品只能用一次,所以内层循环必须从大到小逆序,确保计算
dp[j]时用到的dp[j - weight]尚未包含当前物品;而零钱兑换硬币可以无限次重复使用,所以内层循环必须从小到大正序推进,使得前面刚使用过的硬币在后续更大金额时能够自然复用。 - 02
初始化不能只设 dp[0],其余要设为极大值
因为求的是最小值(
min),如果数组默认全为 0,那么后续任何比较都会直接被 0 覆盖,完全无法得到正确答案。因此除dp[0] = 0外,dp[1..amount]必须初始为一个不可能被超越的上限,如amount + 1。 - 03
用 dp[amount] == amount + 1 判定不可达
所有硬币遍历完毕后,检查目标金额位。若
dp[amount]依然保持初始的极大值,证明没有任何面额组合能够拼出该金额,严格返回-1。
真实 C 语言实现节选
选自 CSP 练习记录中已通过评测的源码。纯静态数组,无动态堆分配,运行极速。
int coinChange(int *coins, int coinsSize, int amount) {
int dp[MAXN];
for (int i = 0; i <= amount; i++) {
dp[i] = amount + 1; // 初始化为不可能达到的极大值
}
dp[0] = 0; // 凑出 0 元所需硬币为 0
for (int i = 0; i < coinsSize; i++) {
int num = coins[i];
for (int j = num; j <= amount; j++) { // 正序遍历:完全背包允许重复使用
if (dp[j - num] + 1 < dp[j]) {
dp[j] = dp[j - num] + 1;
}
}
}
if (dp[amount] == amount + 1) {
return -1; // 无法拼出该金额
}
return dp[amount];
}
复杂度与空间压缩
N 为硬币种数(coinsSize),M 为目标金额(amount)。外层遍历每种面额,内层遍历对应金额区间,总迭代次数严格可控。
滚动数组优化,仅需维护大小为 amount + 1 的一维 DP 表,无需二维矩阵,极大节约栈空间。