
leetcode的“零钱兑换”Coin Change大概是很多人动态规划入门时绕不开的一道题它常驻热门100题也在各类算法面试里反复出现。题目本身描述起来非常简单给定一组硬币面额和一个目标金额问最少用几枚硬币凑出这个金额凑不出就返回 -1。但恰恰是这句“最少用几枚”让无数人栽在贪心思路上又在动态规划的状态定义里绕圈子。我自己的刷题过程也经历了“以为自己懂了——被反例打脸——真正理清转移方程——延伸到一系列变体”的完整阶段。这篇文章想把零钱兑换从题目拆解、错解复盘、DP推导到各类变体完整讲一遍帮正在刷题的朋友少走几步弯路。1. 零钱兑换这道题到底在考什么题目拆解与考点定位1.1 先看题目的准确描述零钱兑换的原始题意大致是有一个整数数组coins表示不同面额的硬币另有一个整数amount表示总金额。要求计算并返回可以凑成总金额所需的最少的硬币个数如果没有任何一种硬币组合能组成总金额返回-1。你以为这就完了题目还藏着几个关键条件每种硬币的数量是无限的也就是说这是一道标准的“完全背包”问题返回的是最少的硬币个数不是方案数也不是具体方案目标金额amount可能为0此时返回0不需要任何硬币。这些条件在题目描述里都有但很多人读题时一扫而过只记住了“硬币无限、求最少”结果实现时要么用了贪心要么把内外层循环顺序搞反要么在“不可达”判断上出错。1.2 为什么它是“一看就会、一做就错”的典型题这类题被归为“动态规划入门经典”但它的隐蔽性在于暴力枚举的思路非常直观贪心的错误解法也极具迷惑性。很多第一次接触的人包括我自己第一反应都是“先用大面额硬币尽量多凑”然后在对拍时被自定义用例击穿才意识到问题没那么简单。从考点上看零钱兑换考察的其实是三层能力问题建模能力能否把“凑金额”的问题抽象成“每个金额是一种状态、状态之间能互相转移”的图论/DP结构贪心与DP的判别能力能否识别出当前问题的最优子结构并不支持贪心的局部最优策略完全背包的内层循环顺序硬币无限使用为什么内层要正序遍历这也是完全背包与01背包的重要分水岭。所以别看它难度标记是 Medium它的信息密度完全够得上高频面试题。1.3 刷这道题之前最好先想清楚三个问题我建议在动手写代码前先问自己三个问题穷举所有组合可行吗暴力递归的时间复杂度是指数级的金额一大必然超时贪心“大面额优先”在什么条件下成立任给一组硬币面额时它还成立吗如果定义一个数组dp[i]表示“凑出金额 i 所需的最少硬币数”那dp[i]怎么从前面的状态推出来把这三个问题想清楚零钱兑换的代码其实就几行。真正的难点从来不在于代码本身而在于你是否理解为什么这样写。2. 贪心解法为什么在零钱兑换上翻车一组反例的完整复盘2.1 多数人的第一反应大面额硬币优先使用先承认一件事贪心思路在直觉上特别有吸引力。因为现实世界里的硬币体系比如 1、5、10、25 这样的常见面额用“从大到小尽量取”的策略确实能拿到最少硬币数。比如凑 30 美分先取 25再取 5正好 2 枚怎么组合都是最优。于是很多人会写出这样的代码def coin_change_greedy(coins, amount): coins.sort(reverseTrue) count 0 for coin in coins: count amount // coin amount % coin return count if amount 0 else -1这个思路隐含了一个假设当前选择最大的硬币不会妨碍后续更小面额完成目标。这个假设在币值互相成倍数关系时成立比如 1、2、4、8因为大面额永远可以被若干个次大面额替换而替换后硬币数量不会减少。但题目并没有保证coins数组来自现实世界它只是任意一组整数。2.2 反例拆解coins [1, 3, 4]amount 6最经典的翻车用例是coins [1, 3, 4] amount 6按贪心策略先取最大的 4剩余 2只能用两个 1 补齐总共需要 3 枚硬币4 1 1。但正确答案是两枚 33 3一共 2 枚硬币。问题出在哪因为选择 4 这个“最大面额”之后剩余的 2 只能靠大量 1 来填充。而如果当时放弃 4、选择两个 3虽然单次“即时收益”看起来更小从 6 减到 3比从 6 减到 2 剩下更多金额但总的硬币数量反而更优。再给一个更直观的反例coins [1, 6, 7]amount 12。贪心拿 7剩 5需要 5 个 1总共 6 枚正确最优解是两个 6总共 2 枚。这类反例说明每一步局部最优并不能保证全局最优而贪心要求每一步的最优选择不依赖后续选择这个性质在任意硬币面额下并不成立。2.3 贪心成立的条件币值之间的倍数关系深入想一层什么时候贪心才是安全的关键在“可替代性”。假设硬币面额按升序排列为c1 c2 ... cn如果每个大面额都能被若干个小面额完整替换而且替换后数量不会更少那么贪心就安全。典型例子是人民币 1、5、10、20、50、100 这种体系5 是 1 的五倍10 是 5 的两倍整体结构满足一定的倍数关系替换不会变差。但一旦面额之间不存在这种整除或倍数约束比如 1、3、4贪心就可能在不同分支之间做出错误取舍。所以处理任意输入的算法题不能用现实货币体系的特殊性来赌。2.4 贪心不行那就换个思路把问题变成“状态”问题当贪心被否定后下一步就是动态规划。这里的关键转变是视角变化不再想“当前选哪个硬币”而是想“凑出金额 i 这件事可以由哪几个更小金额的状态转移而来”。比如coins [1, 3, 4]凑 6 的最优解是 2 枚硬币。倒推一步金额 6 可以由金额 5 加一枚 1 得到也可以由金额 3 加一枚 3 得到还可以由金额 2 加一枚 4 得到。如果我已经知道凑 5、凑 3、凑 2 分别需要多少个硬币那么凑 6 的最少硬币数就是这些“子答案 1”里的最小值。这正是动态规划的状态转移思想也自然引出了下一章的推导。3. 动态规划推导全过程状态定义、转移方程与手算验证3.1 状态定义dp[i] 表示凑出金额 i 的最少硬币数动态规划的第一步永远是定义状态。零钱兑换的状态非常自然dp[i] 凑出金额 i 所需的最少硬币数量边界条件是dp[0] 0因为金额为 0 时不需要任何硬币。对于其他i在计算出真实答案之前我们先初始化成一个“绝对不可达”的大数等转移方程来更新它。这里有个细节这个数组只关心“凑出某个金额最少要几枚”不关心具体是哪几枚。如果后续面试官追问具体方案我们再额外记录选择路径。3.2 转移方程dp[i] min(dp[i], dp[i-coin] 1)假设当前要计算金额i且存在一枚面额为coin的硬币coin i。如果我从“凑金额i - coin的最优状态”出发再添加这一枚硬币就能凑出金额i硬币数自然就是dp[i - coin] 1。面对多种可能的coin取最小值即可dp[i] min(dp[i], dp[i - coin] 1) for each coin i这个式子之所以正确依赖两个基础最优子结构凑出金额i的最优解去掉最后一枚硬币后剩余部分也必然是对应子金额的最优解。如果剩余部分不是最优的我完全可以用更少硬币替换它从而得到更优的全局解矛盾。无后效性金额i的答案只依赖比它更小的金额不依赖更大的金额。所以从小往大填表每个状态一旦确定就固定不变。3.3 用手算走一遍 dp 表确认方程没有漏洞拿coins [1, 2, 5]amount 11来手算。初始化dp [0, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12]按金额从小到大计算dp[1]只能来自dp[0] 1用 1 分币所以dp[1] 1dp[2]可以来自dp[1] 111也可以来自dp[0] 1直接用 2 分币取最小值dp[2] 1dp[3]来自dp[2] 1 2或dp[1] 1 2用 2 分币所以dp[3] 2dp[4]来自dp[3] 1 3或dp[2] 1 2所以dp[4] 22 2dp[5]来自dp[4] 1 3dp[3] 1 3或dp[0] 1 1所以dp[5] 1直接用 5 分币dp[6]dp[5]12、dp[4]13、dp[1]12取dp[6] 251dp[7]dp[6]13、dp[5]12、dp[2]12取dp[7] 252dp[8]dp[7]13、dp[6]13、dp[3]13取dp[8] 3dp[9]dp[8]14、dp[7]13、dp[4]13取dp[9] 3522dp[10]dp[9]14、dp[8]14、dp[5]12取dp[10] 255dp[11]dp[10]13、dp[9]14、dp[6]13取dp[11] 3551。最终答案dp[11] 3。这个手算过程虽然啰嗦但能非常直观地验证转移方程确实在起作用“无后效性”也在计算中体现得清清楚楚。3.4 为什么硬币可以无限使用决定了内外层循环的顺序如果你熟悉背包问题会知道完全背包和 01 背包在代码上的最大区别就是内层循环的遍历顺序。零钱兑换中硬币数量无限属于完全背包所以每种硬币可以在同一个金额状态上被多次使用。用“先遍历硬币、再正序遍历金额”的方式写def coinChange(coins, amount): dp [amount 1] * (amount 1) dp[0] 0 for coin in coins: for i in range(coin, amount 1): dp[i] min(dp[i], dp[i - coin] 1) return dp[amount] if dp[amount] amount else -1注意内层循环从coin开始往后正序遍历这样更新dp[i]时dp[i - coin]可能在本轮已经更新过相当于允许同一个硬币被连续使用多次。如果内层改成倒序遍历就会退化成“每种硬币用一次”的 01 背包对于零钱兑换是错误答案。这个点几乎每次面试都会被追问。4. 三种实现方式的取舍递归备忘录、迭代填表与BFS视角4.1 自顶向下递归 备忘录动态规划有两种实现方向。刚才填表是自底向上另一种是自顶向下也就是递归加备忘录def coinChange(coins, amount): from functools import lru_cache lru_cache(None) def dfs(i): if i 0: return 0 if i 0: return float(inf) res float(inf) for coin in coins: res min(res, dfs(i - coin) 1) return res ans dfs(amount) return -1 if ans float(inf) else ans递归备忘录的好处是只计算递归树上实际访问到的状态在amount很大而硬币选择不多时可能比全量填表更快。坏处是存在递归调用栈的额外开销Python 里递归深度可能触发RecursionError面试时还需要考虑sys.setrecursionlimit。我自己的习惯笔试和 LeetCode 提交首选自底向上的迭代填表只有当面聊思路时才会先用递归版本快速表达状态定义。4.2 自底向上两层循环的迭代填表主推迭代填表是零钱兑换最稳妥的写法代码短、无递归开销、行为可预测。前面已经给出完整代码。我补充几个细节初始化时dp [amount 1] * (amount 1)其中的amount 1不是随便写的它是一个“肯定超过任何可行解硬币数”的哨兵值后面细讲返回时判断dp[amount] amount说明不可达返回 -1外层循环硬币和内层循环金额的顺序可以互换只要内层正序遍历结果都一样。不过“外层硬币”的写法在思路上更贴合完全背包。如果不想用完全背包写法也可以“外层金额、内层硬币”本质上等价因为dp[i]的定义就是凑金额 i不区分硬币种类等式右侧的所有子状态都已经提前算好。4.3 BFS视角把凑硬币看成求最短路径还有一个容易被忽略的视角零钱兑换本质上是在一张“状态图”上求最短路径。每个状态是当前金额从金额i可以花费 1 步跳到i - coin问从amount跳到0至少要几步。既然是无权图最短路BFS 天然适用from collections import deque def coinChange(coins, amount): if amount 0: return 0 q deque([amount]) visited {amount} steps 0 while q: steps 1 for _ in range(len(q)): cur q.popleft() for coin in coins: nxt cur - coin if nxt 0: return steps if nxt 0 and nxt not in visited: visited.add(nxt) q.append(nxt) return -1这个写法的时间复杂度和 DP 填表类似但在“答案步数很小、amount 很大”的场景下BFS 可能更快因为它不需要填满整个 dp 表只要搜到目标层就能返回。代价是空间上要维护队列和 visited 集合。这个视角在面试中属于加分项可以在标准 DP 解法之后主动提一句。4.4 初始化值的学问为什么用 amount 1 而不是一个大整数很多人初始化dp时随手填float(inf)或者999999在大部分测试用例里也能通过但amount 1其实是更精确的选择。理由很简单凑出金额amount每枚硬币面额最小也是 1所以任何可行方案最多使用amount枚硬币不可能超过amount枚。如果某状态在上限为amount的硬币数下都无法达到那它一定是不可达状态。因此初始化成amount 1有两个好处这个值一定大于任何可行解所以min操作会正确覆盖它最后判断dp[amount] amount即可区分可行与不可达不需要引入浮点inf或魔数也避免了类型不一致带来的潜在问题。这个细节看似微小但面试官很吃这种“每个数字都有讲究”的理解。5. 零钱兑换的变体家族方案数、排列组合与路径回溯5.1 变体一零钱兑换II求方案总数LeetCode 518零钱兑换最著名的变体是 518 题。题目改成给定硬币面额和总金额计算凑出该金额的不同组合方式数。组合方式与顺序无关比如coins [1, 2]amount 312和21是同一种组合只算一次。状态定义变为dp[i] 凑出金额 i 的组合方案数转移方程变为dp[i] dp[i - coin]初始化dp[0] 1空组合代表凑出金额 0 的一种方式。关键还是循环顺序必须外层遍历硬币、内层遍历金额def change(amount, coins): dp [0] * (amount 1) dp[0] 1 for coin in coins: for i in range(coin, amount 1): dp[i] dp[i - coin] return dp[amount]外层硬币的含义是先考虑只使用第一种硬币能组成的所有金额再加入第二种硬币逐步扩展“允许使用的硬币集合”。这样累加出来的方案天然按硬币种类分组同一组硬币的不同排列不会重复计入。5.2 变体二Combination Sum IV求排列数LeetCode 377如果把“组合数”换成“排列数”也就是12和21算两种不同方案那就是 377 题 Combination Sum IV。此时内外层循环要调换def combinationSum4(nums, target): dp [0] * (target 1) dp[0] 1 for i in range(1, target 1): for num in nums: if num i: dp[i] dp[i - num] return dp[target]外层遍历金额内层遍历硬币意味着每种金额下都会分别考虑“最后一枚硬币是哪个”。因为最后一枚硬币不同即使整体硬币集合相同也被算作不同的排列。这个顺序上的微妙差别就是组合和排列的代码级分野。5.3 变体三打印具体的最优方案有些面试官会要求不仅给出最少硬币数还要输出具体用了哪些硬币。做法是在填 dp 表时记录“当前金额最后一次选择的面额”最后回溯def coinChangeWithPath(coins, amount): dp [amount 1] * (amount 1) dp[0] 0 last [-1] * (amount 1) for i in range(1, amount 1): for coin in coins: if coin i and dp[i - coin] 1 dp[i]: dp[i] dp[i - coin] 1 last[i] coin if dp[amount] amount: return -1, [] path [] cur amount while cur 0: c last[cur] path.append(c) cur - c return dp[amount], path打印结果时注意倒序输出因为回溯是从大金额往小金额走收集到的硬币顺序是反的。这个变体考察的是“动态规划不仅求值还能还原决策路径”的思维面试时能直接写出这个版本说服力很强。5.4 变体家族的区分口诀外层硬币是组合外层金额是排列我把这三个变体的核心区别浓缩成一句话求组合数外层遍历硬币求排列数外层遍历金额求最少个数只要确保内层正序即可。这个口诀背后是“是否区分选择顺序”的本质差异。组合不考虑选取顺序所以必须把硬币种类当外层分组排列考虑最后一枚硬币是谁所以必须把金额当外层枚举顺序。听到面试官说“combination”还是“permutation”先反应出对应的循环结构这道题就成功了一半。6. 面试与实战中的经验如何把零钱兑换讲出深度6.1 面试现场的标准答题节奏面试遇到零钱兑换不建议一上来就写 DP。一个更完整的答题节奏是先确认题意硬币是否无限返回最少个数还是方案amount 0怎么处理用暴力递归表达最原始的穷举思路指出指数级复杂度主动提出贪心再用coins [1, 3, 4], amount 6反例自我否定展示你理解贪心的适用边界引出动态规划清晰说出状态定义和转移方程写代码边写边解释初始化哨兵值的含义最后提几句完全背包循环顺序和打印方案的扩展方法。这一套组合拳下来即使这道题本身没有难倒面试官也能在沟通层面留下“这位候选人思路完整、理解透彻”的印象。6.2 面试官常见的追问方向根据我的经验这道题的追问通常集中在几个方向硬币数组有序吗能不能排序后剪枝比如硬币大于amount时直接跳过先摩擦一遍排序再计算会更快如果每种硬币变成有限数量怎么写那就退化成了多重背包需要多一层计数如果amount非常大比如到10^9DP 空间不够怎么办这时可以考虑数学优化或分支限界也可以针对大面额硬币做预处理能否在O(amount)额外空间下完成完全可以当前解法本身就是O(amount)。这些追问没有标准答案但每个都能引出你对算法边界的真实理解。与其背答案不如平时把每一问都自己推一遍。6.3 刷题层面的实操体会最后聊点实际的刷题经验。零钱兑换在 LeetCode 热门 100 题中的位置很典型它常常和周赛别的动态规划题搭配出现比如“周赛 430”这样的一周合集里可能会有至少一道完全背包类的题。我的建议是不要孤立地背这道题的代码而是把它当作理解动态规划的“基准点”来反复推演。实际操作中我会做三件事写一个暴力递归版本跑小数据然后在它基础上加备忘录观察从 TLE 到 AC 的变化强化“记忆化搜索”的直觉用自己构造的反例测试贪心解法把[1, 3, 4]这类用例存进本地测试文件每次复习顺手跑一遍把零钱兑换、零钱兑换II、Combination Sum IV 三题放在同一天做用同一套硬币数据去对比组合和排列的循环差异记忆会牢固得多。这样做一轮下来你会发现自己对动态规划的状态设计、循环顺序、初始化技巧都有了具象的理解再遇到别的“求最值、求方案数”题目时往往能在几分钟内找到状态定义。这就是刷经典题的真实收益。