【算法】动态规划之零钱兑换

389 阅读3分钟

零钱兑换问题深度解析(动态规划视角)

题目描述

leetcode.cn/problems/co…

image.png

题目本质与核心难点

给定不同面额的硬币数组 coins 和总金额 amount,寻找组成该金额的最少硬币数。其动态规划本质是完全背包最值问题,难点在于理解「状态转移的数学关系」和「初始化的边界条件」。


一、动态规划四步法详解

1. 定义状态数组

  • dp[i] :组成金额 i 所需的最少硬币数
  • 物理意义:每个金额对应一个独立子问题

2. 初始化规则

  • dp[0] = 0:零金额不需要硬币
  • dp[1..amount] = +∞:初始化不可达状态(可用 amount+1 代替)

3. 状态转移方程

image.png

  • 数学逻辑:当前金额的最优解 = 所有「当前金额-硬币面额」的最优解 + 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)

四、同类问题延伸

  1. 零钱兑换II(求组合数):状态转移方程改为累加
  2. 完全平方数:硬币隐含为平方数集合
  3. 组合总和IV:排列问题需调整遍历顺序