322. 零钱兑换(Coin Change)

难度:中等 | 主题:动态规划、完全背包

题目

给你一个整数数组 coins 表示不同面额的硬币,以及一个整数 amount 表示总金额。计算并返回可以凑成总金额所需的最少硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。每种硬币的数量是无限的。

示例

 
coins = [1,2,5], amount = 11 → 输出 3
 
解释:11 = 5 + 5 + 1(3 枚硬币)
 

思路

先讲个故事:自动售货机的烦恼

你买了一瓶 3 块钱的水,投了一张 20 元。机器要找你 17 元,但它的硬币箱里有 1 元、2 元、5 元三种硬币(每种无限多)。

机器想:“怎么用最少枚硬币凑出 17 元?少吐几个硬币,我就少出故障。”

这就是零钱兑换——从无限硬币中选,凑出目标金额,要求硬币数最少。


引导式推导:四条路,越走越快

第 1 条路(暴力 DFS):从 17 元开始,每次减一个面额,直到 0。画一下搜索树:

 
        17
 
    /   |   \
 
  16   15   12        ← 减1、减2、减5
 
  /|\  /|\  /|\
 
 ...  ...  ...
 

重复计算多到爆炸——凑 12 的方式可以从 17→5→12、17→2→15→5→10→5→… 无数路径走到同一个子问题。

第 2 条路(BFS 最短路):把 amount 看成起点,0 看成终点,每种硬币就是一条边。BFS 逐层扩展,第一次到达 0 时的层数就是最少硬币数。


graph TD

    subgraph bfs["BFS 最短路 (coins=[1,2,5], amount=11)"]

        direction LR

        amt11["11"] -->|"-1"| amt10["10"]

        amt11 -->|"-2"| amt9["9"]

        amt11 -->|"-5"| amt6["6"]

        amt10 -->|"-1"| amt9_2["9"]

        amt10 -->|"-2"| amt8["8"]

        amt10 -->|"-5"| amt5["5"]

        amt9 -->|"-1"| amt8_2["8"]

        amt9 -->|"-2"| amt7["7"]

        amt9 -->|"-5"| amt4["4"]

    end

    style amt11 fill:#f96

    style amt0 fill:#ffd700

BFS 避免了无效枚举,但队列可能很大。

第 3 条路(DP 递推):核心洞察——“凑 11 的最少硬币数”和”凑 10、凑 9、凑 6 的最少硬币数”之间有明确关系:

 
dp[i] = 凑成金额 i 的最少硬币数
 
dp[i] = min(dp[i-1] + 1, dp[i-2] + 1, dp[i-5] + 1)
 
      = min(dp[i - coin] + 1)  for each coin
 

意思就是:凑 11 的最后一步,要么是凑 10 后再加 1 个 1 元,要么凑 9 加 1 个 2 元,要么凑 6 加 1 个 5 元——取最小。


graph LR

    subgraph 三层递进

        A["暴力 DFS<br/>指数级"]

        B["BFS 最短路<br/>O(2ⁿ) 最坏"]

        C["DP 完全背包<br/>O(n × amount)"]

    end

    A -->|发现重复子问题| B

    B -->|转化为递推| C

第 4 条路(完全背包细节):这是一个完全背包问题(硬币无限用)。有两个常见写法:

 
# 写法 A:外层金额,内层硬币 → 求最少硬币数
 
for i in range(1, amount + 1):
 
    for coin in coins:
 
        ...
 
# 写法 B:外层硬币,内层金额 → 求组合数(不重复计数顺序)
 
for coin in coins:
 
    for i in range(coin, amount + 1):
 
        ...
 

求最少硬币数时两种写法结果一样。求方案数时,A 是排列数(考虑顺序),B 是组合数(不考虑顺序)。


代码

 
def coinChange(self, coins, amount):
 
    dp = [float('inf')] * (amount + 1)     # dp[i] = 凑成 i 元的最少硬币数
 
    dp[0] = 0                              # 凑 0 元需要 0 个硬币
 
    for i in range(1, amount + 1):         # 从小到大算每个金额
 
        for coin in coins:                 # 试每种硬币
 
            if coin <= i:
 
                dp[i] = min(dp[i], dp[i - coin] + 1)
 
    return dp[amount] if dp[amount] != float('inf') else -1
 

复杂度

指标解释
时间O(n × amount)n 是硬币种类数,amount 是目标金额
空间O(amount)一维 DP 数组

实战考量

频率分析

出现在:字节/腾讯/美团 一到约 35% 的 DP 面会考零钱兑换。常作为完全背包入门题,与 0/1 背包对比考察对”无限使用”的理解。

延伸思考

Q:外层循环金额和内层循环硬币有什么区别?

A:结果是相同的。但如果问你方案数,就有区别了。外层金额(A)得到的是排列数(1+2 和 2+1 算两种),外层硬币(B)得到的是组合数(1+2 和 2+1 算一种)。

Q:如果每种硬币只能用一次呢?(0/1 背包)

A:内层循环从 amount 往下走(倒序),避免同一个硬币被重复使用。

Q:输出具体用了哪些硬币怎么做?

A:额外维护一个 choice[i] 数组,记录凑 i 元最后用的硬币面额。然后从 amount 回溯到 0。

Q:和 BFS 解法对比?

A:BFS 适合硬币面额很小、amount 很大的情况(剪枝快);DP 适合硬币面额分散、amount 中等的情况。优先写 DP,简洁稳定。

Q:为什么初始化为 inf 而不是 -1?

A:inf 在后续 min 运算中会被忽略(不影响结果),-1 会干扰 min 计算。最后再检查 dp[amount] 是否还是 inf 来判断是否可行。

易错点

  • dp[0] = 0 不写的话全盘崩

  • float('inf') 初始化而不是 -1

  • 返回前要检查是否还是 inf,返回 -1

  • 完全背包和 0/1 背包的内层循环顺序不要混淆


生活类比

零钱兑换 → 完全背包

像自助餐厅选餐:同样的菜想吃几份都行(无限供应),但你要用最少的盘子装满指定热量。

钱就是盘子,硬币就是菜——怎么用最少盘子装出刚好 amount 卡路里?

用两个字概括:复用——硬币无限用,让 dp 可以反复扫描同一个面额。


相关题目

题目关系
279完全平方数同款 DP,硬币换成平方数,也是完全背包
494目标和0/1 背包计数版,每个数只能用一次
139单词拆分完全背包模板变体,硬币换成单词
416分割等和子集0/1 背包,判断能否凑 target
— 518. 零钱兑换 II求方案数,完全背包的组合计数版

→ 返回题单:LeetCode学习路线图 > 十三、动态规划

速记卡(面试闪卡)

Q1:一句话讲清「322. 零钱兑换(Coin Change)」到底是什么?

A:用无限枚硬币凑出目标金额所需的最少硬币数。

Q2:思路 —— 怎么理解?

A:像售货机找零:怎么用最少枚硬币凑出 17 元。本质是 Complete Knapsack(完全背包)/ 子问题 dp[i]=min(dp[i-coin]+1)。

Q3:代码 —— 怎么理解?

A:dp 数组初始化 inf,dp[0]=0;外层金额 i、内层硬币,dp[i]=min(dp[i], dp[i-coin]+1);最后 dp[amount] 还是 inf 就返 -1。

Q4:复杂度 —— 怎么理解?

A:时间 O(n×amount)(n 硬币种数),空间 O(amount)(一维 DP 数组)。

Q5:实战考量 —— 怎么理解?

A:字节/腾讯/美团常考完全背包入门。易错:dp[0]=0 必写、用 inf 非 -1、返回前查 inf;0/1 背包内层要倒序。

Q6:核心速记主线有哪些?

  • 完全背包:硬币无限用,dp[i]=min(dp[i-coin]+1)

  • dp 初始化 inf,dp[0]=0,最后查是否仍 inf

  • 时间 O(n·amount)、空间 O(amount)

  • 易错:dp[0]=0、inf 初始化、0/1 背包倒序

口诀

A:找零用背包

无限硬币装

dp取最小

inf先铺床

相关链接