题目要求
给定一个整数数组 coins,其中 coins[i] 是一种硬币的面额;再给定一个非负整数 amount,要求凑出刚好等于 amount 的金额。
- 每种硬币都可以使用任意次。
- 返回凑出
amount所需的最少硬币数量。 - 如果无法凑出,返回
-1。 - 如果
amount = 0,不需要使用硬币,答案是0。
约束是:1 <= coins.length <= 12、1 <= coins[i] <= 2^31 - 1、0 <= amount <= 10^4。硬币面额互不相同。
例如:
输入:coins = [1, 2, 5], amount = 11
输出:3
解释:5 + 5 + 1 一共使用 3 枚硬币。
输入:coins = [2], amount = 3
输出:-1
解释:只使用面额 2 无法凑出 3。
输入:coins = [1], amount = 0
输出:0
解释:目标金额已经是 0,不需要再选硬币。
Step 1:先把目标金额拆成更小的问题
先看一个会逼出问题结构的小例子:
coins = [1, 3, 4]
amount = 6
如果只凭“优先拿更大面额”的直觉,可能先拿 4,剩下 2,最后得到 4 + 1 + 1,一共 3 枚硬币。但更好的方案是 3 + 3,只需要 2 枚。
当前 baseline 是“每次挑一枚看起来大的硬币”,它在这个例子上失败:同一个目标金额存在不同组合,局部较大的面额不保证总枚数最少。
这个 break 逼出一个具体问题:如果最后选的是某一枚硬币,剩下的金额是多少?我们必须比较不同的最后一枚硬币;选定一枚之后,剩下的金额又是一个更小、形式相同的问题。
本步只加入这个较小的问题定义,先记成:
best(x) = 凑出金额 x 所需的最少硬币数
这一步只是在明确要反复询问什么。现在还没有定义 best(0) 的完成条件,也没有写出如何从 best(x - coin) 得到 best(x) 的转移。
检查这一步:4 + 1 + 1 使用 3 枚,3 + 3 使用 2 枚;因此只看单次选币无法得到答案,必须继续处理剩余金额。
当前版本能做到:
- 说清楚输入、输出和“每种硬币可重复使用”的边界。
- 用
[1, 3, 4], 6证明直接贪心不够。 - 把大金额问题改写成“先理解更小金额”的问题。
它还缺:
- 金额为 0 时什么时候停止。
- 正金额如何尝试每一种硬币并比较结果。
Step 2:先处理金额 0
先问一个更小的问题:如果还需要凑出的金额已经是 0,还要再拿几枚硬币?答案不是“无法凑出”,而是 0 枚。这个完成条件必须先固定下来,后面的每条选币路径才知道什么时候结束。
当前 baseline 是上一节定义的 best(x),但它还没有任何停止规则。它会在 x = 0 时失去含义;如果连这个最小金额都没有答案,正金额的处理也无法接上。
在上一版的 best(x) 上,只加入金额为 0 的分支:
def best(x: int) -> int:
if x == 0:
return 0
raise NotImplementedError("正金额的候选将在下一步加入")
这里的 raise 是刻意留下的缺口:本步只验证完成条件,不假装已经解决正金额。
检查这一步:把 x = 0 代入,函数直接返回 0;把 x = 1 代入,则明确暴露“正金额还没有候选转移”,不会把一个临时数字误当成答案。
现在这个版本能做到:
- 明确
best(0) = 0,表示目标已经完成。 - 把正金额分支和完成条件分开,后续可以在缺口处接入选币逻辑。
它还缺:
- 对
x > 0,如何逐个尝试不超过x的硬币。 - 每个候选如何接回另一个更小金额的
best。
Step 3:枚举最后一枚硬币,先得到一个正确版本
现在要填上的缺口是:对正金额 x,最后一枚硬币可能是 coins 中的任意一种。选了 coin 之后,前面还需要凑出 x - coin;如果这个更小金额能凑出,就在它的硬币数上再加 1。
当前 baseline 是上一节的 best(x):它已经知道 x = 0 时返回 0,但 x > 0 会直接抛出异常。这个 break 很具体:没有候选转移,任何正金额都无法回答。
在上一版的 best 函数中,替换掉 NotImplementedError,加入“尝试每枚硬币并取最小值”的逻辑:
def coin_change_recursive(coins: list[int], amount: int) -> int:
def best(x: int) -> int:
if x == 0:
return 0
minimum = float("inf")
for coin in coins:
if coin > x:
continue
rest = best(x - coin)
if rest == -1:
continue
minimum = min(minimum, rest + 1)
return -1 if minimum == float("inf") else minimum
return best(amount)
这里的 minimum 只是当前 x 的候选最小值;float("inf") 表示“到目前为止还没有找到可达方案”。只有递归结果不是 -1 时,才把当前硬币计入候选。
检查这一步:对 coins = [1, 3, 4]、amount = 6,最后一枚取 4 时,剩余 2 至少需要 2 枚,于是这一分支共 3 枚;最后一枚取 3 时,剩余 3 只需要 1 枚,于是这一分支共 2 枚,函数会保留后者。运行小例子还应得到:
[1, 2, 5], 11 -> 3
[2], 3 -> -1
现在这个版本能做到:
- 对每个正金额尝试所有合法的最后一枚硬币。
- 正确处理更小金额无解的分支。
- 在小规模输入上得到第一个完整、正确的答案。
它还缺:
- 同一个
best(x)会从不同路径被反复计算。 - 金额较大时,递归树会迅速变大,当前版本只是正确 baseline,不是最终复杂度。
Step 4:缓存已经求过的金额
先看 Task 3 的递归会怎样重复询问金额。对 [1, 3, 4]、6,一条路径会出现 best(6) -> best(5) -> best(2),另一条路径也会出现 best(6) -> best(3) -> best(2)。两条路径对 best(2) 的答案完全相同,却会把同一棵子树再算一遍。
当前 baseline 是“转移正确的递归版本”;它的 break 不是答案错误,而是重复工作:金额越大,重复的递归分支越多。
在上一版的 best 定义前只加入一个按金额缓存的装饰器,函数内部的候选转移保持不变:
from functools import cache
def coin_change_memo(coins: list[int], amount: int) -> int:
@cache
def best(x: int) -> int:
if x == 0:
return 0
minimum = float("inf")
for coin in coins:
if coin > x:
continue
rest = best(x - coin)
if rest == -1:
continue
minimum = min(minimum, rest + 1)
return -1 if minimum == float("inf") else minimum
return best(amount)
缓存的键只有金额 x:同一个 x 的最少硬币数不会因为到达路径不同而改变。于是最多只会有 amount + 1 个不同金额状态被求解,原来的“每条路径都重算”被压成“每个状态一次”。
检查这一步:用同一组输入运行缓存版本,答案仍应是 [1, 2, 5], 11 -> 3、[2], 3 -> -1、[1, 3, 4], 6 -> 2,并检查每组的状态数不超过 amount + 1。对于确实包含重复子问题的 [1, 3, 4], 6,再查看 cache_info(),命中次数应大于 0;像 [2], 3 这种只走一条失败路径的输入,命中为 0 也符合预期。
现在这个版本能做到:
- 保留 Task 3 的正确转移和无解处理。
- 对同一个金额只保留一份递归结果,状态数量至多为
amount + 1。
它还缺:
- 递归调用栈仍然随金额增长。
- 还没有把同一转移改写成从小金额到大金额的迭代表格。
Step 5:把递归改成从小到大的表
Task 4 已经证明:每个金额状态只需要求一次。但它仍然通过函数调用向下追问,金额很大时,调用栈本身就会成为负担。
当前 baseline 是带缓存的 best(x)。它的 break 不是状态含义不清,而是状态仍藏在递归调用里;我们需要让“金额 0 到 amount 的每个结果”显式排成一张表。
在上一版的候选转移基础上,只把“递归得到更小金额”替换成已填好的 dp 表:
def coin_change_table(coins: list[int], amount: int) -> int:
dp = [float("inf")] * (amount + 1)
dp[0] = 0
for x in range(1, amount + 1):
for coin in coins:
if coin > x or dp[x - coin] == float("inf"):
continue
dp[x] = min(dp[x], dp[x - coin] + 1)
return -1 if dp[amount] == float("inf") else dp[amount]
这里的状态含义是:dp[x] 表示“刚好凑出金额 x 所需的最少硬币数”;dp[0] = 0 延续了上一节已经验证过的完成条件;其余位置先用无穷大表示“当前还没有找到方案”。处理 x 时,所有更小的 dp[x - coin] 都已经填好。
用 coins = [1, 2, 5]、amount = 11 慢速走表,得到:
x | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
dp[x] | 0 | 1 | 1 | 2 | 2 | 1 | 2 | 2 | 3 | 3 | 2 | 3 |
所以 dp[11] = 3。这张表是递归版本的同一条转移,只是把“调用更小金额”改成“读取已经完成的表项”。
检查这一步:运行表版本应得到 [1, 2, 5], 11 -> 3、[2], 3 -> -1、[1], 0 -> 0 和 [1, 3, 4], 6 -> 2,并且 [1, 2, 5], 11 的整张表与上面的手算一致。
现在这个版本能做到:
- 用一个显式数组保存每个金额的最优值。
- 在不依赖递归调用栈的情况下得到第一个完整的迭代正确版本。
它还缺:
- 为什么金额要按正序处理,以及这如何体现“同一种硬币可以使用任意次”。
- “完全背包”这个名称与当前更新顺序之间的对应关系。
Step 6:为什么这是完全背包
现在看一个不能回避重复使用的例子:
coins = [2]
amount = 6
答案必须是 2 + 2 + 2,也就是 3 枚同样的硬币。当前 baseline 的 dp[x] 转移已经能表达这个答案,但还没有解释:为什么更新顺序允许刚刚用过的 2 再用一次?如果把它误当成“每种硬币只能选一次”的 0/1 问题,就会漏掉 4 和 6 这些连续状态。
这个 break 逼出遍历顺序的含义:处理面额 coin 时,金额必须从小到大走。这样算出 dp[2] = 1 后,继续算 dp[4] 时可以读取这个已经包含当前面额的状态;再算 dp[6] 时又可以读取 dp[4]。
在上一版表格代码中,替换两个循环的组织方式,让“每种硬币可无限次使用”显式可见:
def coin_change_complete(coins: list[int], amount: int) -> int:
dp = [float("inf")] * (amount + 1)
dp[0] = 0
for coin in coins:
for x in range(coin, amount + 1):
if dp[x - coin] == float("inf"):
continue
dp[x] = min(dp[x], dp[x - coin] + 1)
return -1 if dp[amount] == float("inf") else dp[amount]
对 [2]、6 的更新轨迹是:x = 2 时 dp[2] = 1,x = 4 读取 dp[2] 得到 dp[4] = 2,x = 6 再读取 dp[4] 得到 dp[6] = 3。这就是“完全背包”:一个物品(这里是一种面额)可以被取任意次,所以内层金额使用正序。0/1 背包则要用倒序,避免同一轮再次读到当前物品;两者的方向不能混用。
本题优化目标是“最少数量”,不是“不同排列的数量”。因此这里每次只做 min,不会因为 2 + 5 和 5 + 2 的顺序不同而把答案重复计数。
检查这一步:运行新循环应得到 coins = [2], amount = 6 -> 3,以及 coins = [2, 3, 5], amount = 7 -> 2;前面三个官方/压力例子的答案也应保持不变。
现在这个版本能做到:
- 把“可重复使用”落实为硬币外层、金额正序的完全背包更新。
- 解释为什么
dp[x - coin]可以来自本轮已经更新的状态。 - 保持求最少数量,而不是改成排列计数。
它还缺:
- LeetCode
class Solution的最终接口。 - 统一的不可达返回、正确性不变量和复杂度收束。
Step 7:冻结最终实现
前一节已经把“硬币可以重复使用”落实为硬币外层、金额正序内层;现在只做交付层收束:把同一段逻辑放进 LeetCode 要求的 class Solution,并把无解的无穷大转换成 -1。这里不再引入新的状态或转移。
当前 baseline 是 coin_change_complete 的可运行函数。它的 break 是接口还不能直接提交,而且需要把 amount = 0、无解金额和普通金额的返回约定放到同一个方法里。
把上一版函数整合成唯一的最终代码:
class Solution:
def coinChange(self, coins: list[int], amount: int) -> int:
unreachable = amount + 1
dp = [unreachable] * (amount + 1)
dp[0] = 0
for coin in coins:
for current in range(coin, amount + 1):
dp[current] = min(dp[current], dp[current - coin] + 1)
return -1 if dp[amount] == unreachable else dp[amount]
unreachable = amount + 1 是一个足够大的有限哨兵:任何可行方案至多使用 amount 枚面额至少为 1 的硬币,所以它不会与合法答案混淆。dp[0] = 0 仍是已审查的完成条件;每次更新仍读取当前面额在本轮正序更新过的 dp[current - coin],因此无限复用语义没有改变。
正确性可以沿着一个不变量检查:处理完某个 coin 后,对每个 current,dp[current] 是只使用已经处理的面额、且每种面额可重复使用时凑出 current 的最少硬币数;正序扫描让当前面额可以再次接到本轮刚得到的更小金额结果上。处理完全部硬币后,dp[amount] 就是题目要求的最少数量;若仍为哨兵,则没有任何组合能凑出目标金额,返回 -1。
复杂度:状态数是 amount + 1,每个状态最多被每种硬币检查一次,因此时间复杂度为 O(amount * len(coins)),额外空间复杂度为 O(amount)。
边界回归:
coins = [1, 2, 5], amount = 11 -> 3
coins = [2], amount = 3 -> -1
coins = [1], amount = 0 -> 0
coins = [2], amount = 6 -> 3
coins = [1, 3, 4], amount = 6 -> 2
现在这个版本能做到:
- 直接符合 LeetCode 的
class Solution方法契约。 - 覆盖可达、不可达、零金额和同一面额重复使用的边界。
- 让最终代码、正确性说明和复杂度都来自前面已经审查过的 checkpoint。
本题的增量构建到这里冻结;不再追加会引入新逻辑的 Reference Answer。