👋 欢迎阅读

🎯 欢迎来到「零钱兑换 II」题解之旅! 本文将带你从"凑出指定金额有多少种组合方式"这一计数需求出发,深入理解完全背包求方案数 的 DP 模型,并掌握组合数与排列数的关键区别。
在开始之前,建议你先:
-
了解题目背景 :这是 LeetCode 518 题,给定若干种无限数量 的硬币面额
coins和目标金额amount,求可以凑成总金额的硬币组合数 (不同顺序视为同一种组合);若无法凑出则返回0。本质上,这是完全背包的计数变种 ,且硬币面额互不相同。 -
明确学习目标 :掌握如何将"组合数"转化为完全背包求方案数 ,理解外层遍历物品、内层遍历金额(正序) 这一写法如何保证组合而非排列 ,并熟练使用
dp[0] = 1的初始化技巧。 -
准备好环境 :建议在本地 IDE 或 LeetCode 在线编辑器中打开代码,边看边运行,亲手验证每个示例(如
amount = 5, coins = [1,2,5]输出4)。
本文将从问题转化、状态定义、转移方程、初始化、填表顺序 到代码实现,层层递进。即使你对完全背包还不熟悉,我们也会从 322「零钱兑换」对比出发,让你轻松抓住核心思想 ------求最值 与 求方案数 的异同,以及先物品后金额如何避免重复计数。现在,让我们一起数出凑成金额的所有组合方式,解开零钱兑换 II 的 DP 密码吧! 🪙🔢
一、题目

二、做题思路
问题转化(前置分析)
本题是典型的完全背包求方案数 问题:每种硬币数量无限,目标是凑出总金额 amount,求不同组合的总数(组合不区分硬币顺序)。
-
背包容量 =
amount -
物品体积 =
coins[i] -
目标:恰好装满 容量
amount,统计所有可能的选取方案数。由于是求方案数,不可达状态方案数为
0。
1. 状态表示(核心基础,包含推导思路)
为什么这样定义 dp[i][j]?
-
我们需要记录处理到第几种硬币 以及当前已经凑出的金额。
-
对于每种硬币,由于数量无限,可以选择 0 枚、1 枚、2 枚......,这符合完全背包的特征。
-
为了避免组合顺序的重复计数 (例如
[1,2]和[2,1]视为同一种组合),我们按硬币种类逐种处理,外层循环为硬币类型,内层循环为金额,这样每种硬币的选取次数是逐步累加的,天然保证了组合的无序性。
因此,定义 dp[i][j] 表示使用前 i 种硬币(coins[0..i-1]),凑出金额 j 的不同组合数 。
i 范围 0 到 n(0 表示不使用任何硬币),j 范围 0 到 amount。该状态能覆盖所有前缀硬币和金额的组合,满足无后效性,是解决本问题的出发点。
2. 状态转移方程(关键难点)
对于第 i 种硬币(面额 coins[i-1]),有两种决策:
-
不选 :组合数为
dp[i-1][j](继承上一行的结果)。 -
选(至少一枚) :前提是
j >= coins[i-1],组合数为dp[i][j - coins[i-1]]。注意此处使用的是
dp[i][...]而非dp[i-1][...],因为完全背包允许重复选取当前硬币,dp[i][j - coins[i-1]]已经包含了使用当前硬币多次的组合,这体现了无限取用的特性。
3. 初始化(边界防护)
-
dp[0][0] = 1:不使用任何硬币,凑出金额0有且仅有 1 种方案------空组合。 -
dp[0][j] = 0(j > 0):没有硬币可用时,无法凑出任何正金额,方案数为0(默认初始化即可)。其余
dp[i][j]初始为0,在递推中累加。
4. 填表顺序(递推方向)
dp[i][j] 依赖上一行 dp[i-1][j] 和当前行左侧 dp[i][j - coins[i-1]],因此必须按行从上到下 (i 从 1 到 n),列从左到右 (j 从 0 到 amount)遍历。
特别说明 :j 从 0 开始是因为 dp[i][0] 表示凑出金额 0 的方案数,始终为 1(空组合),需要保留该状态以便后续转移(例如当 coins[i-1] 恰好等于 j 时,会用到 dp[i][0])。
5. 返回值(目标映射)
最终 返回 dp[n][amount],即使用所有硬币凑成总金额的组合总数。
三、代码
cpp
class Solution
{
public:
int change(int amount, vector<int>& coins)
{
int n = coins.size();
// 1. 创建dp表
// dp[i][j] 表示使用前 i 种硬币凑成金额 j 的组合数
// 使用 double 类型避免中间结果溢出(但最终结果在 int 范围内)
vector<vector<double>> dp(n + 1, vector<double>(amount + 1, 0));
// 2. 初始化:不使用任何硬币时,凑成金额 0 有 1 种方案(空组合),
// 凑成其他金额(>0)有 0 种方案(dp[0][j] 默认为 0)
dp[0][0] = 1;
// 3. 填表顺序:外层遍历硬币种类(i 从 1 到 n),
// 内层遍历金额(j 从 0 到 amount),正序(完全背包特性)
// 因为完全背包允许重复使用同种硬币,内层正序使得 dp[i][j - coin] 已经考虑过当前硬币,
// 从而累加多次使用的方案。
for (int i = 1; i <= n; i++)
{
for (int j = 0; j <= amount; j++)
{
// 4. 状态转移方程:
// 不选第 i 种硬币:方案数继承上一行 dp[i-1][j]
dp[i][j] += dp[i - 1][j];
// 选第 i 种硬币(至少一枚):前提是 j >= coins[i-1],
// 此时方案数为 dp[i][j - coins[i-1]](即凑成剩余金额的方案数),
// 累加到当前 dp[i][j] 中,体现"完全背包"的组合累加。
if (j >= coins[i - 1])
{
dp[i][j] += dp[i][j - coins[i - 1]];
}
}
}
// 5. 返回值:dp[n][amount] 即为用所有硬币凑成总金额的组合数
return (int)dp[n][amount];
}
};
四、流程图

五、优化
状态转移方程
对于当前硬币面额
coin = coins[i-1](第i种),决策为选或不选 ,但由于可重复选,一维转移为:
dp[j] += dp[j - coin](当j >= coin时)。
等号右边的
dp[j]仍为上一轮(不选当前硬币)的组合数,但代码中直接使用+=进行累加,相当于将"不选"的方案(原值)和"至少选一枚"的方案(从dp[j-coin]转移)相加。
dp[j - coin]为本轮已更新的值,表示已经选过至少一枚当前硬币后,继续累加组合数,这允许无限次使用同一硬币。
填表顺序
外层循环遍历每种硬币(
i从 1 到n),内层循环必须正序遍历金额j(从 0 到amount)。为什么完全背包要正序(从左到右)?
因为完全背包允许无限次 选取当前硬币,
dp[j]需要利用同一硬币已更新过的较小金额状态dp[j - coin],即表示"已经选过一枚当前硬币后继续选"的累加方案数。
当
j从小到大遍历时,dp[j - coin]已经在本轮被更新过(因为j - coin < j,先被处理),所以它包含了当前硬币的多次使用信息,从而允许无限取用。若采用逆序(如01背包),
dp[j - coin]仍为上一轮状态,则每枚硬币最多被选一次,无法实现重复选取,组合数会漏算。
cpp
class Solution
{
public:
int change(int amount, vector<int>& coins)
{
int n = coins.size();
// 一维滚动数组 dp[j]:表示当前已考虑的部分硬币种类下,凑成金额 j 的组合数
// 初始状态:未使用任何硬币,金额0有1种方案(空组合),其余金额0种方案
vector<double> dp(amount + 1, 0);
dp[0] = 1;
// 外层遍历硬币种类,内层正序遍历金额(完全背包特性)
// 正序使得 dp[j - coin] 在本轮已被当前硬币更新过,允许多次选取同种硬币
// 累加方式:dp[j] += dp[j-coin] 表示将"选一枚当前硬币"的方案加入总数
for (int i = 1; i <= n; i++)
{
for (int j = 0; j <= amount; j++)
{
if (j >= coins[i - 1])
{
dp[j] += dp[j - coins[i - 1]];
}
}
}
return (int)dp[amount];
}
};
🎯 闭幕

🎉 恭喜你完成了「零钱兑换 II」问题的学习!
为了巩固知识并进一步拓展,建议你:
🚀 动手实践
在 LeetCode 上提交代码,尝试不同的测试用例。
💡 深入思考
-
本题要求的是 组合数 (不考虑顺序),而代码中外层循环是硬币种类,内层是金额,这样能保证 每种硬币只按顺序考虑一次,从而避免重复计数。如果把两层循环交换(先金额后硬币),结果会变成什么(是排列数还是组合数)?你可以用小例子验证一下。
-
dp[0][0] = 1,其他dp[0][j] = 0,这表示"不用任何硬币,凑成金额 0 有 1 种空方案"。 -
因为完全背包允许无限取用,内层金额采用 正序 遍历。如果改成 逆序,结果会变成什么?会退化为 01 背包的组合数吗?
-
dp数组使用double类型防止溢出,但最终答案保证在int范围内。你能否直接使用int并判断溢出?或者用long long是否足够?
📚 延伸挑战
-
将本题改为 求排列数 (即不同顺序视为不同方案),例如
amount=3, coins=[1,2],组合数只有 2 种(1+2 和 1+1+1),但排列数有 3 种(1+2、2+1、1+1+1)。你只需改动哪一处循环顺序就能实现? -
将本题与上一题(零钱兑换,求最少个数)对比:为什么求组合数时
dp[0]=1,而求最少个数时dp[0]=0,其余为INF?你能从语义上解释这两个初始化的区别吗?
如果你觉得本文对你有所帮助,欢迎:
👍 点赞 / 收藏
👤 关注作者,获取更多题解
💬 留言交流你的疑问或优化思路
祝你在 DP 的道路上越走越稳,早日攻克每一道难题!下次见 🚀✨