☰
动态规划完全背包实战:找零钱问题的最少硬币数与组合方案数详解
2026/10/16 1:10:45 网站建设 项目流程

1. 从找零钱说起:为什么这个经典模型值得反复练

找零钱这个问题,乍一看像是超市收银员才需要操心的事,但它其实是动态规划里最经典的完全背包变种。我第一次接触它的时候,觉得这不就是贪心吗,每次拿最大的硬币不就行了。结果被现实狠狠教育了一顿——当硬币面额是1、3、4,需要找零6元时,贪心会给出4+1+1三枚,而最优解是3+3两枚。这个反例让我意识到,找零钱的核心不是"每次拿最大",而是"在无限次使用硬币的前提下,凑出目标金额的最优方案"。

这个模型的价值远不止于收银台。资源分配、内存管理、任务调度、甚至游戏里的金币兑换系统,底层逻辑都和找零钱一模一样。你手里有一组"面额"(资源单位),需要凑出一个"目标值"(总需求),每个单位可以重复使用,问最少需要多少个或者有多少种凑法。这就是完全背包问题的本质。

我写这篇东西,是想把找零钱这个模型从状态定义、转移方程、初始化、遍历顺序到空间优化,完整地拆一遍。不管你是刚学动态规划的新手,还是刷题时总是搞不清遍历顺序的老手,都能从这里找到可以直接抄作业的模板和踩坑经验。文章里所有的代码都可以直接跑,所有的参数选择我都会解释为什么。

2. 找零钱问题的两种面孔:最少硬币数与组合方案数

2.1 两种问法的本质区别

找零钱在算法题里通常以两种形式出现,很多人做着做着就混了,因为它们的代码长得非常像,但遍历顺序和初始化完全不同。

第一种问法:给定硬币面额数组coins和目标金额amount,每种硬币可以用无限次,求凑出目标金额所需的最少硬币数量。如果凑不出来,返回-1。

第二种问法:同样的条件,求凑出目标金额的组合方案数。比如coins=[1,2,5],amount=5,方案有:5、2+2+1、2+1+1+1、1+1+1+1+1,共4种。

这两种问法的核心区别在于:最少硬币数关心的是"数量最优",组合方案数关心的是"路径计数"。前者在状态转移时取min,后者在状态转移时做累加。更关键的是,组合方案数必须考虑去重——[1,2]和[2,1]是同一种方案,不能算两次。这就直接决定了遍历顺序的选择。

2.2 为什么完全背包和01背包的遍历顺序不同

这是动态规划里最容易搞混的地方,我当初也是在这里卡了很久。01背包里,每种物品只能用一次,所以内层循环必须从大到小遍历,防止同一个物品被重复使用。而完全背包里,每种物品可以用无限次,内层循环从小到大遍历,这样在计算dp[j]的时候,dp[j-coins[i]]已经被当前物品更新过了,相当于允许重复使用。

用找零钱来理解:假设硬币面额是2,目标金额是6。从小到大遍历时,dp[2]先被更新,然后dp[4]会用到dp[2]的值,dp[6]会用到dp[4]的值,这样2就被用了三次。如果从大到小遍历,dp[6]用到dp[4]的时候,dp[4]还是上一轮的状态,2就只会被用一次。这就是完全背包和01背包在代码上的唯一区别,但背后的逻辑差异非常大。

注意:遍历顺序不是随便定的,它直接决定了每种硬币是否可以被重复使用。写代码之前先想清楚这个问题,比事后debug要高效得多。

2.3 状态定义的统一框架

不管是求最少硬币数还是方案数,状态定义都是一样的:dp[j]表示凑出金额j所需要的最少硬币数(或方案数)。这个定义看起来简单,但它是整个解法的基础。我见过很多人把dp[j]定义成"使用前i种硬币凑出金额j的最优解",然后写成二维数组,最后再优化成一维。这样做不是不行,但对于找零钱这个场景来说,一维数组已经足够了,因为完全背包的一维写法天然支持重复使用。

状态定义确定之后,转移方程就顺理成章了。对于最少硬币数:dp[j] = min(dp[j], dp[j - coins[i]] + 1)。对于方案数:dp[j] += dp[j - coins[i]]。这两个方程的结构完全一致,只是操作符不同。理解了这个统一框架,你就不需要死记硬背了。

3. 最少硬币数:从贪心反例到动态规划完整实现

3.1 贪心为什么失效:一个必须记住的反例

贪心策略在找零钱问题上的失效,是我建议每个学动态规划的人都亲手验证一遍的。硬币面额[1, 3, 4],目标金额6。贪心的思路是每次拿不超过剩余金额的最大硬币:先拿4,剩2,只能拿1和1,总共3枚。但最优解是3+3,只要2枚。这个反例说明,局部最优不等于全局最优,贪心在这里没有最优子结构的保证。

那什么时候贪心有效呢?当硬币面额满足"每个面额都是前一个面额的倍数"时,比如[1, 5, 10, 25],贪心确实能得到最优解。但题目不会给你这个保证,所以必须用动态规划。我个人的习惯是,看到找零钱问题,先想动态规划,不要浪费时间验证贪心是否可行。

3.2 状态转移方程的推导过程

设dp[j]为凑出金额j所需的最少硬币数。对于金额j,我们可以尝试用任意一种硬币coins[i]作为最后一枚硬币,那么凑出金额j-coins[i]所需的最少硬币数就是dp[j-coins[i]],再加上这一枚硬币,就是dp[j-coins[i]] + 1。遍历所有硬币,取最小值,就得到了dp[j]。

这个推导的关键在于"最后一枚硬币"的思路。它把一个大问题拆成了"凑出j-coins[i]"这个子问题,而子问题的最优解已经存储在dp数组里了。这就是动态规划的核心——用空间换时间,把重复计算的结果存下来。

初始化的选择也很关键。dp[0] = 0,因为凑出金额0不需要任何硬币。其他位置初始化为一个不可能的大值,通常用amount + 1或者float('inf')。用amount + 1的好处是避免溢出,而且如果最终dp[amount]还是这个值,说明凑不出来,直接返回-1。

3.3 完整代码实现与逐行注释

def coinChange(coins, amount): # dp[j]表示凑出金额j所需的最少硬币数 # 初始化为amount + 1,这是一个不可能达到的值 dp = [amount + 1] * (amount + 1) # 凑出金额0需要0枚硬币 dp[0] = 0 # 外层遍历硬币 for coin in coins: # 内层从小到大遍历金额,保证完全背包的重复使用 for j in range(coin, amount + 1): # 状态转移:用当前硬币或不用当前硬币,取较小值 dp[j] = min(dp[j], dp[j - coin] + 1) # 如果dp[amount]仍然是初始值,说明无法凑出 return dp[amount] if dp[amount] != amount + 1 else -1

这段代码的时间复杂度是O(amount * n),其中n是硬币种类数。空间复杂度是O(amount)。对于大多数题目来说,这个复杂度已经足够了。如果amount特别大,比如上百万,可能需要考虑其他优化,但那是另一个话题了。

实操心得:初始化用amount + 1而不是float('inf'),是因为在Python里整数运算比浮点数快,而且amount + 1这个值在状态转移中不会被误用,因为任何合法的硬币组合都不会超过amount枚硬币。

3.4 遍历顺序的验证与常见错误

我见过最常见的错误是把内外层循环写反了。如果外层遍历金额,内层遍历硬币,对于最少硬币数问题来说,结果其实是对的,因为min操作不关心顺序。但对于方案数问题,这个顺序会导致重复计数。所以我的建议是统一写成外层遍历硬币、内层遍历金额,这样两种问题都不会出错。

另一个常见错误是内层循环从0开始而不是从coin开始。从0开始虽然不会报错,但dp[j - coin]在j < coin时会变成负数索引,在Python里会取到数组末尾的元素,导致完全错误的结果。所以内层循环一定要从coin开始。

4. 组合方案数:去重逻辑与遍历顺序的深层关系

4.1 为什么方案数问题必须外层遍历硬币

这是找零钱问题里最微妙的地方。假设coins=[1,2],amount=3。正确的方案是:1+1+1、1+2,共2种。如果外层遍历金额、内层遍历硬币,代码会这样执行:dp[1] += dp[0](用1),dp[2] += dp[1](用1),dp[2] += dp[0](用2),dp[3] += dp[2](用1),dp[3] += dp[1](用2)。最终dp[3]会包含1+2和2+1两种顺序,算成了2种方案,但实际上它们是同一种。

外层遍历硬币、内层遍历金额时,硬币的使用顺序被固定了:先考虑所有只用硬币1的方案,再考虑用硬币1和2的方案。这样1+2和2+1就不会被重复计数。这个逻辑我当初理解了很久,后来用一句话总结:外层遍历硬币相当于给硬币排了序,方案数只关心每种硬币用了几个,不关心使用的先后顺序。

4.2 状态转移方程的累加逻辑

方案数的转移方程是dp[j] += dp[j - coin]。这个累加的含义是:凑出金额j的方案数,等于不使用当前硬币的方案数(dp[j]的旧值)加上使用当前硬币的方案数(dp[j - coin])。因为当前硬币可以用无限次,所以dp[j - coin]已经包含了使用当前硬币的情况。

初始化的选择也不同。dp[0] = 1,因为凑出金额0有一种方案:什么都不用。其他位置初始化为0。最终返回dp[amount],不需要判断是否凑不出来,因为如果凑不出来,dp[amount]自然就是0。

4.3 完整代码实现与对比分析

def change(amount, coins): # dp[j]表示凑出金额j的方案数 dp = [0] * (amount + 1) # 凑出金额0有一种方案:什么都不选 dp[0] = 1 # 外层遍历硬币,保证方案不重复 for coin in coins: # 内层从小到大遍历金额 for j in range(coin, amount + 1): # 累加方案数 dp[j] += dp[j - coin] return dp[amount]

对比最少硬币数的代码,你会发现结构几乎一样,只有三个地方不同:初始化dp[0]的值、转移方程的操作符、返回值的处理。这就是动态规划的魅力——理解了状态定义和转移逻辑,代码只是自然推导的结果。

对比项最少硬币数组合方案数
dp[0]初始化01
转移方程dp[j] = min(dp[j], dp[j-coin]+1)dp[j] += dp[j-coin]
返回值dp[amount]或-1dp[amount]
外层循环硬币或金额均可必须遍历硬币
内层循环从小到大从小到大

4.4 排列数与组合数的区分

有些题目问的是排列数,比如硬币顺序不同算不同方案。这时候遍历顺序就要反过来:外层遍历金额,内层遍历硬币。这样1+2和2+1会被算作两种不同的方案。这个区别在爬楼梯问题里也有体现——爬楼梯问的是排列数,因为先爬1阶再爬2阶和先爬2阶再爬1阶是不同的走法。

我个人的记忆方法是:外层遍历硬币得到组合数,外层遍历金额得到排列数。组合数不关心顺序,排列数关心顺序。这个规律在大多数完全背包问题里都适用。

5. 空间优化与边界处理:让代码更稳的几个细节

5.1 一维数组已经是最优空间

找零钱问题的一维数组写法已经是空间最优了,因为完全背包的二维写法可以优化成一维,而一维数组的大小只取决于amount。有些人会问能不能用滚动数组进一步优化,答案是不能,因为dp[j]依赖于dp[j-coin],这个依赖关系需要整个数组都保留。

如果amount特别大,比如10的7次方,一维数组可能会占用较多内存。这时候可以考虑用字典来存储状态,只记录被访问过的金额。但在大多数题目里,amount不会超过10的5次方,一维数组完全够用。

5.2 边界条件的处理清单

边界条件处理不好,代码就会在各种奇怪的地方出错。我整理了一份检查清单,每次写找零钱代码之前过一遍:

  • amount为0时,最少硬币数返回0,方案数返回1。这个要确保dp[0]的初始化正确。
  • coins为空时,最少硬币数返回-1,方案数返回0(除非amount也是0)。
  • coins中有大于amount的硬币时,内层循环会自动跳过,不需要特殊处理。
  • coins中有重复面额时,方案数会重复计算,需要先去重。这个在题目没有明确说明时要注意。
  • amount为负数时,直接返回0或-1,取决于题目要求。

注意:Python里负数索引不会报错,所以内层循环从coin开始这个细节一定要检查。我当初就因为写成range(amount+1)导致调试了半小时。

5.3 性能优化的几个实用技巧

虽然O(amount * n)已经不错了,但在某些场景下还可以优化。第一个技巧是对coins排序,这样在内层循环时可以提前终止。第二个技巧是如果coins中有1,那么所有金额都可以凑出,最少硬币数的返回值不需要判断-1。第三个技巧是用数组而不是列表来存储dp,在Python里array模块比list更省内存,但速度可能稍慢,需要根据实际情况权衡。

对于方案数问题,如果amount很大但coins很少,可以考虑用数学方法而不是动态规划。比如只有两种硬币时,可以用扩展欧几里得算法求解。但这种优化超出了本文的范围,感兴趣的话可以自行研究。

6. 常见问题与排查技巧实录

6.1 调试找零钱代码的通用思路

找零钱代码出问题时,我通常按以下顺序排查:先检查dp[0]的初始化是否正确,这是最常见的问题来源。然后检查内层循环的起始位置是不是coin,这个错误在Python里不会报错但结果全错。接着检查遍历顺序是否符合题目要求,最少硬币数不关心顺序,方案数必须外层遍历硬币。最后检查返回值处理,最少硬币数需要判断是否凑不出来。

如果以上都没问题,那就手动模拟一个小例子。比如coins=[1,2],amount=3,手动写出dp数组的每一步变化,和代码输出对比。这个方法虽然笨,但非常有效,能帮你快速定位问题。

6.2 常见错误速查表

错误现象可能原因解决方法
方案数偏大遍历顺序错误,导致重复计数改为外层遍历硬币
最少硬币数返回初始值无法凑出目标金额检查返回值处理,返回-1
数组越界内层循环从0开始改为从coin开始
结果为0dp[0]初始化错误方案数dp[0]=1,最少硬币数dp[0]=0
结果偏小内层循环从大到小完全背包必须从小到大

6.3 从找零钱到其他完全背包问题的迁移

找零钱学会了,其他完全背包问题就触类旁通了。比如零钱兑换II、完全平方数、单词拆分,本质上都是完全背包的变种。区别只在于状态定义和转移方程的具体形式。我的经验是,先把找零钱的模板写熟,遇到新问题时先判断是不是完全背包,然后套模板改转移方程。

以完全平方数为例,给定一个正整数n,求最少用几个完全平方数之和等于n。这其实就是硬币面额为1,4,9,16...的找零钱问题。代码结构完全一样,只需要把coins替换成完全平方数的生成逻辑。

6.4 面试中的高频追问与应对

面试官在问完找零钱之后,通常会追问几个问题。第一个是时间复杂度,答O(amount * n),并解释n是硬币种类数。第二个是空间复杂度,答O(amount),并说明一维数组已经是最优。第三个是如果要求输出具体方案而不是数量,怎么办?这时候需要额外维护一个数组记录每个金额是从哪个硬币转移过来的,最后回溯得到方案。

第四个追问是如果硬币数量有限制怎么办?这就变成了多重背包问题,需要额外处理每种硬币的数量限制。第五个追问是如果amount特别大怎么办?可以讨论数学方法或者近似算法。这些追问我在面试中遇到过多次,提前准备好答案能让你从容很多。

7. 从刷题到实战:找零钱模型的真实应用场景

7.1 资源分配中的完全背包思维

找零钱模型在资源分配里非常常见。假设你有一组任务,每个任务消耗一定的资源,可以重复执行,问达到目标产出最少需要执行多少次。这就是最少硬币数的翻版。或者问有多少种执行组合能达到目标产出,这就是方案数的翻版。

我在实际工作中遇到过一个日志压缩的场景:有一组压缩算法,每种算法能压缩一定比例的日志,可以重复使用,问达到目标压缩率最少需要几步。这就是标准的找零钱问题,用动态规划几分钟就解决了。

7.2 游戏经济系统中的找零钱逻辑

游戏里的金币兑换系统经常需要找零钱逻辑。玩家有若干种面额的金币,需要支付特定金额,系统要计算最少需要多少枚金币。如果允许重复使用同种面额,那就是完全背包。如果不允许,那就是01背包。这个逻辑在商城系统、交易系统里都很常见。

我参与过一个模拟经营游戏的经济系统设计,其中货币兑换模块就是用找零钱模型实现的。当时需要考虑的边界情况比刷题多得多,比如玩家金币不足、兑换比例动态变化等。但核心算法还是动态规划,只是在外层加了业务逻辑。

7.3 从模板到变体:你需要掌握的迁移能力

找零钱的模板掌握了,但题目不会直接考模板。常见的变体包括:硬币数量有限制(多重背包)、硬币有使用顺序要求(排列数)、需要输出具体方案(回溯)、目标金额有多个(多次查询)。这些变体都需要在模板基础上做调整。

我的建议是,先把基础模板写熟到闭着眼睛都能写出来,然后再去刷变体题。每道变体题做完之后,总结一下和基础模板的区别在哪里,是状态定义变了,还是转移方程变了,还是遍历顺序变了。这样积累下来,你就能形成自己的解题框架。

8. 我个人在实际操作中的体会

找零钱这个模型我前前后后刷了不下十遍,每次都有新的理解。最开始是死记硬背代码,后来理解了状态转移的逻辑,再后来能自己推导变体的解法。这个过程没有捷径,就是反复练、反复想。

如果让我给初学者一个建议,那就是不要急着写代码,先用纸笔把dp数组的每一步变化手动模拟出来。模拟三五个例子之后,你对状态转移的理解会深刻很多。另外,不要怕犯错,我当初把遍历顺序写反、把初始化搞错、把返回值弄混,各种错误都犯过。每犯一次错,就离真正理解近了一步。

最后分享一个小技巧:找零钱问题的代码可以写成一个通用模板,把最少硬币数和方案数封装成两个函数,共用同一个状态定义框架。这样在面试时如果需要切换,只需要改几行代码,非常高效。这个模板我放在下面,你可以直接拿去用。

def coin_change_template(coins, amount, mode='min'): """ mode='min' 返回最少硬币数 mode='count' 返回组合方案数 """ if mode == 'min': dp = [amount + 1] * (amount + 1) dp[0] = 0 for coin in coins: for j in range(coin, amount + 1): dp[j] = min(dp[j], dp[j - coin] + 1) return dp[amount] if dp[amount] != amount + 1 else -1 else: dp = [0] * (amount + 1) dp[0] = 1 for coin in coins: for j in range(coin, amount + 1): dp[j] += dp[j - coin] return dp[amount]

这个模板我用了很久,基本上覆盖了找零钱的所有基础场景。遇到变体时,只需要在这个基础上调整状态定义或转移方程。希望它也能帮到你。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询