LeetCode 面试题 08.11. 硬币
题目描述
题意分析
面额固定为 1、5、10、25 四种,每种可以无限使用,问凑出金额
n有多少种方式,结果对 $10^9 + 7$ 取模。「要取模」这一条本身就是最强的信号:它说明答案是个天文数字,因此不可能靠枚举所有方案来数,只能用递推把方案数累加出来。
n的上界是 $10^6$,进一步限定了可接受的量级——线性或者常数倍线性的递推可以,任何随n呈平方增长的做法都会超时。另一个必须先看清的点是:要数的是组合,不是排列。硬币没有顺序,
25 + 1与1 + 25是同一种凑法,只能算一次。这个区别不会体现在状态定义上,只会体现在递推的顺序上,是本题唯一真正的坑。边界:
n = 0时答案是 1,即「一枚都不拿」也算一种凑法;n < 5时只有全用 1 元这一种。
解法:一维完全背包计数组合
核心思路
因为面额只有四种,最朴素的写法是三重循环枚举 25、10、5 各用几枚,剩下的用 1 补齐。它的正确性没问题,但循环次数是 $\frac{n}{25} \times \frac{n}{10} \times \frac{n}{5}$,
n取 $10^6$ 时是 $3 \times 10^{15}$ 量级,直接出局;而且这个写法完全依赖「面额只有四种」,换成任意面额就写不出来了。递推的自然想法是
f(i) = f(i-1) + f(i-5) + f(i-10) + f(i-25),可它数出来的是排列数:f(6)会同时从f(5)加一枚 1 和从f(1)加一枚 5 得到,而这两条路径拼出的其实是同一个多重集合。瓶颈就在这里——同一个组合被不同的凑取顺序重复计数了。消除重复的标准办法是给每个组合规定唯一的书写顺序:规定必须先决定用几枚 1,再决定几枚 5,然后 10,最后 25。这样一来,每个组合与「按面额下标从小到大做决策的路径」一一对应,重复自然消失。
把这句话翻译成状态定义:
dp[j][i]表示只允许使用前j种面额时,凑出金额i的组合数。转移分两种决策——第j种面额一枚都不用,方案数是dp[j-1][i];至少用一枚,剩下i - coins[j]仍然允许继续使用第j种,方案数是dp[j][i - coins[j]]。第二项引用的是同一行而不是上一行,这正是「无限次使用」在公式里的样子。第一维可以滚动掉。外层按面额循环、内层金额从小到大递增时,
dp[i - coin]读到的已经是本轮更新过的新值,恰好等于二维式子里的dp[j][i - coin];而dp[i]在被写入之前保存的还是上一轮的值,正是dp[j-1][i]。于是dp[i] += dp[i - coin]这一行同时完成了两种决策的求和。循环不变量:外层第
j轮结束后,dp[i]恒等于「只用前j种面额凑出金额i的组合数」。初值dp[0] = 1表示空组合,它不是为了避免除零之类的补丁,而是「凑出 0 元有且只有一种方式」这一事实。
解题步骤
- 准备面额数组与 dp 数组:
coins = {1, 5, 10, 25},dp长度取n + 1,因为金额 0 到n都要有位置。dp[0] = 1,其余为 0:其余为 0 的含义是「在还没考虑任何面额时,除了 0 元,别的金额都凑不出来」,这与不变量在j = 0时的表述一致。- 外层遍历面额:面额必须在外层。它就是上面那条「唯一书写顺序」的实现,一旦挪到内层就变成数排列。
- 内层金额从
coin递增到n:起点取coin而不是 0,是因为金额小于面额时这枚硬币根本用不上,dp[i - coin]也会越界;递增而不是递减,是因为要允许同一面额被重复使用(递减是 01 背包的写法,会把每种硬币限制成最多一枚)。- 累加并取模:
dp[i] = (dp[i] + dp[i - coin]) % MOD。取模要贴着累加做,方案数在n较大时远超 32 位。- 返回
dp[n]:此时外层四轮都跑完,按不变量它就是允许使用全部四种面额时的组合数。以
n = 10走一遍(答案应为 4:十枚 1、五枚 1 加一枚 5、两枚 5、一枚 10):
- 初始:
dp = [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]coin = 1跑完:dp = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],含义是只用 1 元时每个金额都恰好一种凑法coin = 5时,i = 5处dp[5] += dp[0]得 2;i = 6..9各自加上dp[1..4]都变成 2;i = 10处dp[10] += dp[5],读到的dp[5]已是本轮的新值 2,于是dp[10] = 1 + 2 = 3。跑完得dp = [1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 3]coin = 10时只有i = 10进循环:dp[10] += dp[0],得 4coin = 25时25 > 10,内层一次都不进,dp不变- 返回
dp[10] = 4注意
i = 10、coin = 5那一步:读到的dp[5] = 2里已经包含了「一枚 5」,配上当前这枚 5 得到「两枚 5」;如果内层递减,读到的就是旧值 1,「两枚 5」这种凑法会被漏掉。反过来把两层循环调换(外层金额、内层面额),同样是
n = 10,递推变成f(i) = f(i-1) + f(i-5) + f(i-10),算出的是f(10) = f(9) + f(5) + f(0) = 6 + 2 + 1 = 9——多出来的 5 种全部是顺序不同、内容相同的重复计数。
代码实现
class Solution {
// 用完全背包的一维 DP:dp[i] 表示凑出金额 i 的方案数,dp[0] = 1 表示什么都不选也是一种基础方案。
private static final int MOD = 1_000_000_007;
public int waysToChange(int n) {
int[] coins = {1, 5, 10, 25};
int[] dp = new int[n + 1];
dp[0] = 1;
for (int coin : coins) {
for (int i = coin; i <= n; i++) {
dp[i] = (dp[i] + dp[i - coin]) % MOD;
}
}
return dp[n];
}
}
func waysToChange(n int) int {
// 用完全背包的一维 DP:dp[i] 表示凑出金额 i 的方案数,dp[0] = 1 表示什么都不选也是一种基础方案。
coins := []int{1, 5, 10, 25}
const mod = 1000000007
dp := make([]int, n+1)
dp[0] = 1
for _, coin := range coins {
for i := coin; i <= n; i++ {
dp[i] = (dp[i] + dp[i-coin]) % mod
}
}
return dp[n]
}
复杂度分析
- 时间复杂度:$O(n)$,外层是 4 次固定循环,内层至多
n次,总操作数不超过 $4n$,面额种数是常数所以不进渐进式。- 空间复杂度:$O(n)$,只保留滚动后的一维数组,长度为
n + 1;二维写法的 $O(4n)$ 被压掉了一维,代价是必须把内层顺序写对。
关键点总结
- 组合还是排列,完全由两层循环的顺序决定:面额在外层数的是组合,金额在外层数的是排列。这是完全背包计数类问题被追问得最多的一句话,答不上来基本就废了。
- 完全背包内层正序、01 背包内层倒序。正序意味着
dp[i - coin]已经包含了当前面额,等价于「这枚硬币可以再拿一次」;能讲出这层等价关系,比背口诀有用得多。dp[0] = 1是空组合的语义,不是边界补丁。凡是计数型 dp,先想清楚「什么都不选」算不算一种方案。- 面试时先写二维状态定义和转移,再当场压成一维,能直接证明你知道一维是怎么来的;上来就写一维,很容易在被追问循环顺序时讲不清。
- 取模贴着累加做,别攒到最后。方案数增长极快,晚一步就已经溢出了。
易错点总结
- 外层枚举金额、内层枚举面额:
n = 10时返回 9 而不是 4,多出来的都是顺序不同的重复方案。这是本题第一大错误。- 内层倒序(照抄 01 背包):
n = 10时每种面额最多被用一次,只剩「一枚 10」这一种,返回 1。- 内层起点写成 0 或 1:
i = 0、coin = 5时访问dp[-5],直接抛数组越界异常。dp数组开成new int[n]:n = 1000时最后一句dp[n]越界。- 忘记
dp[0] = 1:整张表恒为 0,任何n都返回 0。- 只在返回时取模:
n = 1000000的中间结果远超 int 上界,累加过程中就已经回绕成负数,最后取模得到负值。- 把状态定义记成「最少硬币数」(与 322 零钱兑换记混):
n = 10会返回 1,而题目要的是方案数 4。- 写成递归枚举每一枚硬币:
n = 1000000时递归深度就有 $10^6$,分支数更是爆炸,必然栈溢出或超时。- 以为面额数组必须先排序:写成
{25, 10, 5, 1},n = 10同样返回 4——组合数与面额的枚举顺序无关,把「必须升序」当前提会在别的背包题里误导自己。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 518. 零钱兑换 II | 中等 | 与本题同型的组合计数,面额改由入参给出,本题则写死为四种 |
| 322. 零钱兑换 | 中等 | 同样是完全背包但求最少硬币数,初值改成无穷大、转移由求和变取 min |
| 377. 组合总和 Ⅳ | 中等 | 要的恰恰是排列数,正好把本题两层循环的顺序调过来 |
| 279. 完全平方数 | 中等 | 面额是 1、4、9…… 由 n 现场生成,且求的是最少个数 |
| 416. 分割等和子集 | 中等 | 01 背包,每个数只能用一次,内层必须倒序,是本题正序的反面教材 |
| 1449. 数位成本和为目标值的最大数字 | 困难 | 恰好装满的完全背包,价值不是计数而是字符串大小,比较规则要自定义 |
| LCR 103. 零钱兑换 | 中等 | 与 322 同题,可直接套用 |
| LCR 104. 组合总和 Ⅳ | 中等 | 与 377 同题,可直接套用 |