LeetCode 322. 零钱兑换

题目描述

给定一个整数数组 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))
相关推荐
朝朝辞暮i2 小时前
C++ 第 21 课:struct —— 把一组相关数据打包在一起
开发语言·c++·算法
青梅橘子皮2 小时前
Blue---二分算法(二分查找与二分答案)
算法
长河3 小时前
Java的GC(垃圾回收)中,标记无用对象的核心方法是可达性分析算法
java·jvm·算法
月光船幽幽3 小时前
加性偏移外推提升参数识别可靠性
python·算法
Ivanqhz4 小时前
层归一化、残差、前馈网络与激活函数简述
服务器·数据库·人工智能·深度学习·算法
不会就选b4 小时前
算法日常・每日刷题--<动态规划>3
数据结构·算法
-dzk-5 小时前
【堆】LC 347.前 K 个高频元素
算法·堆
H.莓飛5 小时前
【数据结构】栈_OJ题
linux·数据结构·算法·centos
Zootopia6265 小时前
多架 eVTOL集群排班调度方案思考
人工智能·算法·数学建模·matlab·动态规划·无人机·evtol
和裕6 小时前
年度框架直供 vs 零散按需采购:定制纸箱采购成本、交付与服务核心区别全对比
大数据·运维·网络·人工智能·算法