跳到主要内容

322. 零钱兑换

题目描述

给你一个整数数组 coins,表示不同面额的硬币;以及一个整数 amount,表示总金额。

计算并返回可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1

你可以认为每种硬币的数量是无限的。

示例 1:

输入:coins = [1,2,5], amount = 11
输出:3
解释:11 = 5 + 5 + 1

示例 2:

输入:coins = [2], amount = 3
输出:-1

示例 3:

输入:coins = [1], amount = 0
输出:0

提示:

  • 1 <= coins.length <= 12
  • 1 <= coins[i] <= 2^31 - 1
  • 0 <= amount <= 10^4

解题思路

第一步:理解问题本质

这是一个经典的完全背包问题:

  • 物品:硬币,每种硬币可以无限次使用
  • 背包容量:amount
  • 目标:用最少的物品(硬币)填满背包

与 01 背包的区别:每种硬币可以选多次。

第二步:暴力解法 - 回溯

枚举所有可能的组合,记录使用硬币数最少的方案。

from math import inf

def coinChange_brute(coins, amount):
coins.sort(reverse=True)
res = inf

def dfs(start, count, remaining):
nonlocal res
if remaining < 0:
return
if count >= res: # 剪枝
return
if remaining == 0:
res = min(res, count)
return
for i in range(start, len(coins)):
dfs(i, count + 1, remaining - coins[i])

dfs(0, 0, amount)
return -1 if res == inf else int(res)

为什么不够好?

  • 时间复杂度指数级,会超时
  • 本质是回溯,没有利用子问题的重叠性质

第三步:优化解法 - 记忆化搜索

定义 dfs(i, c):从前 i 种硬币中选,组成金额 c 的最少硬币数。

状态转移:

dfs(i, c) = min(
dfs(i-1, c), # 不选第 i 种硬币
dfs(i, c-coins[i]) + 1 # 选第 i 种硬币(可以重复选)
)

使用 @cache 避免重复计算。

第四步:最优解法 - 完全背包 DP

使用自底向上的动态规划。

定义: f[c] 表示组成金额 c 所需的最少硬币数

初始化:

  • f[0] = 0(组成 0 需要 0 个硬币)
  • f[1..amount] = inf(初始不可达)

状态转移:

对于每种硬币 coin:
对于每个金额 cur 从 coin 到 amount:
f[cur] = min(f[cur], f[cur-coin] + 1)

为什么外层循环是硬币?

  • 确保每种硬币可以被重复使用
  • 如果外层是金额,内层是硬币,会变成排列问题(顺序不同算不同方案)

完整代码实现

from typing import List
from math import inf
from functools import cache


class Solution:
"""
322. 零钱兑换 - 完全背包问题

问题本质:
给定不同面额的硬币 coins 和总金额 amount,求组成该金额所需的最少硬币个数。
每个硬币可以无限次使用,这是一个经典的"完全背包"问题。

解法一:记忆化搜索(自顶向下)
- 定义 dfs(i, c):从前 i 种硬币中选,组成金额 c 的最少硬币数
- 状态转移:min(不选 coin[i], 选 coin[i] + 1)
- 使用 @cache 避免重复计算
- 缺点:递归有额外开销,缓存访问不连续

解法二:动态规划/完全背包(自底向上)
- f[c] 表示组成金额 c 所需的最少硬币数
- 初始化:f[0] = 0, f[1..amount] = inf
- 转移:对于每种硬币,遍历所有可以组成的金额
f[cur] = min(f[cur], f[cur - coin] + 1)
- 外层循环硬币,内层循环金额(完全背包的标准写法)

为什么外层循环是硬币?
- 确保每种硬币可以被重复使用
- 如果外层是金额,内层是硬币,会超时(变成排列问题)

时间复杂度: O(n * amount) - n 为硬币种类数
空间复杂度: O(amount) - DP 数组空间
"""

# 解法一:记忆化搜索
def coinChange_memo(self, coins: List[int], amount: int) -> int:
"""
记忆化搜索解法
dfs(i, c) 表示从前 i 种硬币中选,组成金额 c 的最少硬币数
"""
@cache
def dfs(i: int, c: int) -> int:
if i < 0:
# 没有硬币可选,只有金额为 0 时才可行
return 0 if c == 0 else inf
if c < coins[i]:
# 当前硬币面值太大,只能不选
return dfs(i - 1, c)
# 状态转移:不选当前硬币 vs 选当前硬币(可以继续选,所以是 dfs(i, ...))
return min(dfs(i - 1, c), dfs(i, c - coins[i]) + 1)

ans = dfs(len(coins) - 1, amount)
return ans if ans < inf else -1

# 解法二:完全背包(最优解法)
def coinChange(self, coins: List[int], amount: int) -> int:
"""
完全背包动态规划解法
f[c] 表示组成金额 c 所需的最少硬币数
"""
# 初始化:f[0] = 0(组成金额 0 需要 0 个硬币)
# 其余初始化为无穷大,表示暂时不可达
f = [0] + [inf] * amount

# 外层循环:每种硬币
for coin in coins:
# 内层循环:从 coin 到 amount
# 从 coin 开始是因为更小的金额无法使用该硬币
for cur in range(coin, amount + 1):
# 状态转移:不使用当前硬币 vs 使用当前硬币(数量 +1)
f[cur] = min(f[cur], f[cur - coin] + 1)

ans = f[amount]
return ans if ans < inf else -1

示例推演

coins = [1, 2, 5], amount = 11 为例:

初始化: f = [0, inf, inf, inf, inf, inf, inf, inf, inf, inf, inf, inf]

coin = 1:

cur=1:  f[1] = min(inf, f[0]+1) = 1
cur=2: f[2] = min(inf, f[1]+1) = 2
cur=3: f[3] = min(inf, f[2]+1) = 3
...
cur=11: f[11] = 11

coin = 2:

cur=2:  f[2] = min(2, f[0]+1) = 1      # 2 = 2
cur=3: f[3] = min(3, f[1]+1) = 2 # 3 = 2 + 1
cur=4: f[4] = min(4, f[2]+1) = 2 # 4 = 2 + 2
cur=5: f[5] = min(5, f[3]+1) = 3 # 5 = 2 + 2 + 1
...
cur=11: f[11] = min(11, f[9]+1) = 6 # 11 = 2*5 + 1

coin = 5:

cur=5:  f[5] = min(3, f[0]+1) = 1      # 5 = 5
cur=6: f[6] = min(3, f[1]+1) = 2 # 6 = 5 + 1
cur=7: f[7] = min(4, f[2]+1) = 2 # 7 = 5 + 2
cur=8: f[8] = min(4, f[3]+1) = 3 # 8 = 5 + 2 + 1
cur=9: f[9] = min(5, f[4]+1) = 3 # 9 = 5 + 2 + 2
cur=10: f[10] = min(5, f[5]+1) = 2 # 10 = 5 + 5
cur=11: f[11] = min(6, f[6]+1) = 3 # 11 = 5 + 5 + 1

最终结果: f[11] = 3


复杂度分析

解法时间复杂度空间复杂度说明
回溯O(amount^n)O(n)指数级,超时
记忆化搜索O(n * amount)O(n * amount)递归 + 缓存
完全背包 DPO(n * amount)O(amount)最优解法

易错点总结

1. 完全背包 vs 01 背包

错误做法(01 背包):

for coin in coins:
for cur in range(amount, coin - 1, -1): # 倒序
f[cur] = min(f[cur], f[cur - coin] + 1)

正确做法(完全背包):

for coin in coins:
for cur in range(coin, amount + 1): # 正序,可以重复选
f[cur] = min(f[cur], f[cur - coin] + 1)

2. 初始化值

f = [0] + [inf] * amount  # f[0] = 0,其余为无穷大

错误做法:

f = [inf] * (amount + 1)  # 错误!f[0] 应该是 0

3. 无解情况

ans = f[amount]
return ans if ans < inf else -1 # 注意返回 -1

4. 循环顺序

外层循环硬币,内层循环金额:

for coin in coins:              # 硬币
for cur in range(coin, amount + 1): # 金额
f[cur] = min(f[cur], f[cur - coin] + 1)

如果反过来,会变成排列问题(顺序不同算不同方案)。


扩展思考

1. 相关题目

2. 零钱兑换 II(求方案数)

def change(amount: int, coins: List[int]) -> int:
f = [1] + [0] * amount
for coin in coins:
for cur in range(coin, amount + 1):
f[cur] += f[cur - coin]
return f[amount]

注意:

  • 初始化 f[0] = 1(组成金额 0 有 1 种方案:什么都不选)
  • 状态转移用加法而非取最小值

3. 输出具体方案

使用回溯记录路径:

def coinChange_with_path(coins, amount):
f = [0] + [inf] * amount
parent = [-1] * (amount + 1) # 记录用了哪个硬币

for coin in coins:
for cur in range(coin, amount + 1):
if f[cur - coin] + 1 < f[cur]:
f[cur] = f[cur - coin] + 1
parent[cur] = coin

if f[amount] == inf:
return -1, []

# 重建路径
path = []
cur = amount
while cur > 0:
path.append(parent[cur])
cur -= parent[cur]

return f[amount], path

4. 空间优化

当前解法已经是最优空间复杂度 O(amount)。

如果 amount 很大但硬币种类很少,可以考虑使用 BFS 或 Dijkstra 算法。

加载评论中...