LeetCode 518:零钱兑换||(完全背包)—— 题解

👋 欢迎阅读

🎯 欢迎来到「零钱兑换 II」题解之旅! 本文将带你从"凑出指定金额有多少种组合方式"这一计数需求出发,深入理解完全背包求方案数 的 DP 模型,并掌握组合数与排列数的关键区别

在开始之前,建议你先:

  • 了解题目背景 :这是 LeetCode 518 题,给定若干种无限数量 的硬币面额 coins 和目标金额 amount,求可以凑成总金额的硬币组合数 (不同顺序视为同一种组合);若无法凑出则返回 0。本质上,这是完全背包的计数变种 ,且硬币面额互不相同

  • 明确学习目标 :掌握如何将"组合数"转化为完全背包求方案数 ,理解外层遍历物品、内层遍历金额(正序) 这一写法如何保证组合而非排列 ,并熟练使用 dp[0] = 1 的初始化技巧。

  • 准备好环境 :建议在本地 IDE 或 LeetCode 在线编辑器中打开代码,边看边运行,亲手验证每个示例(如 amount = 5, coins = [1,2,5] 输出 4)。

本文将从问题转化、状态定义、转移方程、初始化、填表顺序 到代码实现,层层递进。即使你对完全背包还不熟悉,我们也会从 322「零钱兑换」对比出发,让你轻松抓住核心思想 ------求最值求方案数 的异同,以及先物品后金额如何避免重复计数。现在,让我们一起数出凑成金额的所有组合方式,解开零钱兑换 II 的 DP 密码吧! 🪙🔢


一、题目

518. 零钱兑换 II - 力扣(LeetCode)

二、做题思路

问题转化(前置分析)

本题是典型的完全背包求方案数 问题:每种硬币数量无限,目标是凑出总金额 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 范围 0n0 表示不使用任何硬币),j 范围 0amount。该状态能覆盖所有前缀硬币和金额的组合,满足无后效性,是解决本问题的出发点


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] = 0j > 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)遍历。

特别说明j0 开始是因为 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 的道路上越走越稳,早日攻克每一道难题!下次见 🚀✨

相关推荐
To_OC1 小时前
LC 42 接雨水:暴力超时卡半天?前后缀数组一用就通了
javascript·算法·leetcode
Henry Zhu1232 小时前
C++中的特殊成员函数与智能指针
c++
delishcomcn2 小时前
AI视觉识别+分切算法:电化铝缺陷检测与裁切一体化解锁
人工智能·算法
触底反弹3 小时前
深入理解大模型采样:Temperature、Top-K、Top-P 的原理与实战
人工智能·算法·面试
雪碧聊技术3 小时前
力扣 LCR 091. 粉刷房子 —— 动态规划入门详解
算法·动态规划
_wyt0014 小时前
多重背包问题详解
c++·背包dp
Ljwuhe5 小时前
C++——多态
开发语言·c++
越甲八千5 小时前
STL stack为何没有迭代器
c++