零钱兑换问题深度解析(动态规划视角)
题目描述
题目本质与核心难点
给定不同面额的硬币数组 coins 和总金额 amount,寻找组成该金额的最少硬币数。其动态规划本质是完全背包最值问题,难点在于理解「状态转移的数学关系」和「初始化的边界条件」。
一、动态规划四步法详解
1. 定义状态数组
- dp[i] :组成金额 i 所需的最少硬币数
- 物理意义:每个金额对应一个独立子问题
2. 初始化规则
- dp[0] = 0:零金额不需要硬币
- dp[1..amount] = +∞:初始化不可达状态(可用 amount+1 代替)
3. 状态转移方程
- 数学逻辑:当前金额的最优解 = 所有「当前金额-硬币面额」的最优解 + 1 枚硬币
- 遍历顺序:外层循环遍历金额(1 → amount),内层循环遍历硬币(可优化)
4. 终止条件
- 若 dp[amount] > amount → 返回 -1(无法凑出)
- 否则返回 dp[amount]
二、深度理解误区与陷阱
误区1:贪心算法优先选大面额
- 反例:coins=[1,3,4], amount=6
- 贪心错误路径:4+1+1=3枚
- 正确路径:3+3=2枚
误区2:硬币遍历顺序影响结果
- 完全背包问题中,硬币遍历顺序不影响最终结果,但会影响计算路径
- 优化技巧:可先排序硬币提前剪枝(当剩余金额无法用更大硬币时跳出)
误区3:初始化值的设定
- 若初始值设为-1,会导致状态转移时难以判断有效性
- 推荐用 amount+1 作为不可达标记(最大硬币数不超过 amount)
三、代码示例
var coinChange = function(coins: number[], amount: number) {
// dp[i] 表示凑成金额 i 所需的最少硬币个数
// 因为我们需要计算从 0 到 amount 的所有金额
// 所以数组长度需要是 amount + 1,这样才能包含 amount 这个下标
const dp = new Array(amount + 1).fill(Infinity);
dp[0] = 0;
for (let i = 1; i <= amount; i++) {
for (let coin of coins) {
// i -coin 当前金额扣除所选硬币后的剩余金额
// 如果剩余金额大于等于0,则更新dp[i]
if (i - coin >= 0) {
// +1 累积消耗的硬币个数
dp[i] = Math.min(dp[i], dp[i - coin] + 1);
}
}
}
// 如果dp[amount] === Infinity,则表示无法凑成该金额,返回-1
// 否则返回dp[amount],即凑成该金额所需的最少硬币个数
return dp[amount] === Infinity ? -1 : dp[amount];
};
** 举例**
- 假设 coins = [1, 2, 5], amount = 2, dp[0] = 0
- 选择 coin = 1元,dp[2] = dp[2-1] + 1 = dp[1] + 1 = 2
- 选择 coin = 2元,dp[2] = dp[2-2] + 1 = dp[0] + 1 = 1
- 不会选择 coin = 5元,i-coin = 2-5 = -3 < 0
计算 dp[1]
- 选择coin=1元,现在求 dp[1] = dp[0] + 1 = 0 + 1 = 1
- 不会选择 2 元和 5 元
所以 amount 为 2 的时候, dp[2] = 1
复杂度
- 时间复杂度:O(n * amount) , amount 是金额,n 是硬币数量
- 空间复杂度:O(amount)
四、同类问题延伸
- 零钱兑换II(求组合数):状态转移方程改为累加
- 完全平方数:硬币隐含为平方数集合
- 组合总和IV:排列问题需调整遍历顺序