给你一个整数数组 coins
,表示不同面额的硬币;以及一个整数 amount
,表示总金额。
计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1
。
你可以认为每种硬币的数量是无限的。
示例 1:
ini
输入: coins = [1, 2, 5], amount = 11
输出: 3
解释: 11 = 5 + 5 + 1
示例 2:
ini
输入: coins = [2], amount = 3
输出: -1
示例 3:
ini
输入: coins = [1], amount = 0
输出: 0
提示:
1 <= coins.length <= 12
1 <= coins[i] <= 231 - 1
0 <= amount <= 104
🧩 解法一:记忆化搜索(DFS + 递归 + 备忘录)
思路
- 自顶向下,递归定义函数
dfs(amount)
:凑出金额amount
需要的最少硬币数。 - 枚举每一枚硬币
coin
,递归计算dfs(amount - coin)
,取最小值。 - 使用 备忘录 memo 避免重复计算,减少指数级爆炸。
代码
ini
import java.util.Arrays;
class Solution {
// memo[a] 保存凑出金额 a 所需的最少硬币数:
// -2 表示还没计算过(unknown)
// -1 表示无法凑出(impossible)
// >=0 表示最少硬币数(可行解)
private int[] memo;
public int coinChange(int[] coins, int amount) {
if (amount == 0) return 0;
memo = new int[amount + 1];
Arrays.fill(memo, -2); // -2 表示尚未计算
return dfs(coins, amount);
}
private int dfs(int[] coins, int amount) {
if (amount == 0) return 0; // base case: 0 元需要 0 枚硬币
if (amount < 0) return -1; // base case: 无效金额
if (memo[amount] != -2) return memo[amount];
int res = Integer.MAX_VALUE;
for (int coin : coins) {
int sub = dfs(coins, amount - coin);
if (sub >= 0 && sub < res) {
res = sub + 1;
}
}
memo[amount] = (res == Integer.MAX_VALUE) ? -1 : res;
return memo[amount];
}
}
特点
- 优点:递归直观,写法简洁,带备忘录性能大幅提升。
- 缺点 :递归深度可能很大(当
amount
较大且硬币最小为 1 时),可能栈溢出。
🧩 解法二:广度优先搜索(BFS)
思路
- 将
amount
看作图中的目标节点,0
为起点,每个硬币coin
相当于一条边。 - BFS 一层一层扩展,每扩展一层意味着多用一枚硬币。
- 第一次到达目标金额时,一定是最少硬币数。
代码
ini
import java.util.*;
class Solution {
public int coinChange(int[] coins, int amount) {
if (amount == 0) return 0;
boolean[] visited = new boolean[amount + 1];
Queue<Integer> q = new ArrayDeque<>();
q.add(0);
visited[0] = true;
int steps = 0;
while (!q.isEmpty()) {
steps++;
int size = q.size();
for (int i = 0; i < size; i++) {
int cur = q.poll();
for (int coin : coins) {
int nxt = cur + coin;
if (nxt == amount) return steps;
if (nxt < amount && !visited[nxt]) {
visited[nxt] = true;
q.add(nxt);
}
}
}
}
return -1;
}
}
特点
- 优点:天然保证第一次找到解就是最优解;避免深递归问题。
- 缺点:需要维护队列和 visited 数组,可能会占用较多空间。
🧩 解法三:动态规划(自底向上 DP)
思路
-
定义
dp[i]
= 凑出金额i
所需的最少硬币数。 -
初始值:
dp[0] = 0
,其他设为一个大数。 -
转移方程:
dp[i]=mincoin∈coins(dp[i−coin]+1)dp[i] = \min_{coin \in coins}(dp[i - coin] + 1)
-
最终返回
dp[amount]
。
代码
ini
import java.util.*;
class Solution {
public int coinChange(int[] coins, int amount) {
if (amount < 0) return -1;
if (amount == 0) return 0;
int[] dp = new int[amount + 1];
Arrays.fill(dp, amount + 1);
dp[0] = 0;
for (int i = 1; i <= amount; i++) {
for (int coin : coins) {
if (i - coin >= 0) {
dp[i] = Math.min(dp[i], dp[i - coin] + 1);
}
}
}
return dp[amount] > amount ? -1 : dp[amount];
}
}
特点
- 优点:迭代过程无递归,稳定可靠;最常用,面试友好。
- 缺点 :需要
O(amount * n)
时间,当amount
很大时可能较慢。
🔎 三种方法总结对比
方法 | 思路 | 时间复杂度 | 空间复杂度 | 优缺点 |
---|---|---|---|---|
记忆化 DFS | 自顶向下递归 + 备忘录 | O(n * amount) | O(amount)(+递归栈深度) | 写法直观,但可能栈溢出 |
BFS | 图的层次遍历 | O(n * amount) | O(amount)(队列+visited) | 找到解即最优,不会递归爆栈 |
DP(自底向上) | 状态转移,迭代计算 | O(n * amount) | O(amount) | 稳定高效,面试/比赛最推荐 |
一、统一的建模视角(出发点)
把题目统一抽象成两种常见模型,会帮助理解三种算法之间的关系:
-
最短路径 / 最短步数问题(无权图)
- 把每个金额
x
(0..amount)看作图中的一个节点;从节点x
可以通过一条边到达x + coin
(对于所有coin
),边权为 1(使用一枚硬币)。 - 问题变为:从起点
0
到终点amount
的最短路径长度(最少用多少条边)------这是 BFS 的自然模型。
- 把每个金额
-
动态规划(重叠子问题 + 最优子结构)
- 定义
dp[i]
= 凑出金额i
的最少硬币数,则有递推:dp[i] = min(dp[i - coin] + 1)
(遍历所有 coin)。 - 这是典型的「自底向上」DP 模型,对应自顶向下的记忆化递归。
- 定义
理解了这两个视角,三种方法就是同一问题的三种等价/相关求解策略:BFS 是最短路、DP/记忆化是动态规划(状态压缩与缓存)。
二、方法一 --- 记忆化递归(自顶向下 DP / DFS + memo)
1) 思路(非常细)
- 设函数
f(x)
为 凑出金额 x 最少需要的硬币数(若凑不出则为 -1)。 - 枚举任意一枚硬币
c
:如果f(x - c)
可行(≥0),则f(x)
可通过f(x - c) + 1
得到。对所有c
取最小。 - 直接递归会产生大量重复子问题(例如
f(6)
可能被多条路径重复计算),因此用memo
把f(k)
的结果缓存起来:第一次计算f(k)
时写入memo[k]
,后续直接返回缓存值。
2) 递归方程(数学式)
scss
f(0) = 0
f(x) = -1, x < 0
f(x) = min_{c in coins} { f(x - c) + 1 } (若所有 f(x - c) 都为 -1 则 f(x) = -1)
3) 正确性与不变式
- 不变式 :在计算
f(x)
时,memo[y]
(y < x)保存正确的最少硬币数或不可达状态。通过数学归纳法可证明:当递归返回时f(x)
是正确的最优值(因为它是基于所有更小子问题的最小值构成的)。 - 记忆化只是把重复子问题的计算缓存,不改变递归分解本身,因此正确性不受影响。
4) 复杂度推导
- 每个
amount
(0..amount)最多 会被实际计算一次并写入memo
。 - 对于每次计算
f(x)
,需要枚举n = coins.length
个硬币来比较,所以总体时间复杂度约为O(amount * n)
。 - 空间复杂度:
O(amount)
用于memo
;另外递归栈深度在最坏情况下可能达O(amount / min_coin)
(当最小面额为 1 时接近O(amount)
),会有栈溢出风险。
5) 实现细节 & 常见坑(Java)
- memo 的初始化与哨兵值 :不要用
Integer.MAX_VALUE
做未计算标记然后在比较时做step+1
(会溢出)。常用-2
表示未计算,-1
表示不可达,>=0
表示可达最优值。 - 递归深度 :当
amount
很大、coins 包含 1 时递归深度可能接近amount
,Java 会抛StackOverflowError
。若平台限制,考虑改成自底向上或 BFS。 - 排序 coins 降序:在递归里先尝试大面额能更快找到较小解,从而更早剪枝(启发式)。
6) 可扩展的优化(工程化)
-
使用一个**上界(upper bound)**来强化剪枝。比如先用贪心(尽量用大面额)得到一个可行解
best
,再在递归中如果当前步数 >= best 就剪掉该分支。- 说明:贪心得到的
best
不是最优的通用证明,但作为上界用于剪枝是安全的(只会加速,不会破坏正确性)。
- 说明:贪心得到的
-
用
HashMap<Integer,Integer>
替代数组memo
:当amount
很大但实际可达金额稀疏时节省内存。
7) 手工运行示例(coins = [1,2,5], amount = 11)
-
调用
f(11)
:- 访问
f(10)
(coin=1)、 f(9)
(coin=2)、f(6)
(coin=5)
- 访问
-
假设先计算
f(6)
,其下继续递归到f(5), f(4), f(1)
,f(5)
可能直接得 1(因为 coin=5),memo[5]=1
,之后f(6)
可以得到f(6)=f(5)+1=2
,写入memo[6]
。 -
当之后
f(11)
再次需要f(6)
时直接读memo[6]
,避免重复展开。最终memo
会将 0..11 的必要子问题值都填好,每个f(k)
实际计算一次。
8) 何时使用
- 喜欢递归写法或需结合启发式剪枝时。适合
amount
不太大或能通过排序/贪心快速得到较小上界的情况。
三、方法二 --- 广度优先搜索(BFS)
1) 思路(非常细)
- 把金额看成图的节点,边为
+coin
(权重均为 1)。 - 从起点
0
做层序遍历(BFS):第一层代表用 1 枚硬币能达到的金额集合,第二层代表用 2 枚硬币能达到的金额集合,依次类推。 - 第一次遇到
amount
的层数即为最少硬币数,因为所有边权相同且 BFS 逐层扩展保证最短路径顺序。
2) 正确性(形式化说明)
- 在无向/有向、且边权相同为 1 的图中,BFS 求得的层次数就是最短路径长度(经典图论结论)。这里每一步把金额加上某个 coin,对应一条边(有向,但无权),因此 BFS 可以直接得到最短步数。
3) 复杂度
- 时间:最坏
O(amount * n)
------ 每个金额(0..amount)最多入队一次,每次处理枚举n
个 coin。 - 空间:
O(amount)
------ visited 数组 + 队列(极端情况下队列收集大量节点)。
4) 实现细节 & 常见坑(Java)
visited
数组大小为amount + 1
,确保next <= amount
才访问并入队。- 队列建议用
ArrayDeque<Integer>
(比LinkedList
性能好)。 - 小心
nxt == amount
的检查要放在nxt <= amount
之前,或正确处理nxt==amount
的返回逻辑以避免越界。 - 当
amount
很大且内存受限时,visited
带来的内存压力可能成为瓶颈;可改用BitSet
(Java 的BitSet
更紧凑)或HashSet<Integer>
(稀疏时更节省)。
5) 强化技巧与扩展
-
双向 BFS(bidirectional BFS) :从
0
向上扩展,同时从amount
以amount - coin
向下扩展,两边交汇时终止。通常能大幅降低搜索空间(约平方根级别的缩小),但实现复杂度稍高(需要两个 visited、两层交换扩展方向以保证扩展更小的一端)。- 注意:从
amount
向下扩展时,边为-coin
,但要保证amount - coin >= 0
。
- 注意:从
-
排序 coins(降序) :先尝试大硬币可以使 BFS 在早期层快速覆盖大步长,可能在很多实例上先遇到
amount
(启发式,不改变最坏复杂度)。
6) 还原方案路径(如果需要)
- 如果需要知道最少硬币的具体组成,可以在 BFS 中维护
parent
映射(child -> parent
或amount -> (prev_amount, coin_used)
),当到达amount
时就可以回溯出具体的硬币序列。
7) 手工运行示例(coins=[1,2,5], amount=11)
- step=1(用 1 个硬币能到达): {1,2,5}
- step=2: 扩展 {1,2,5} 得到 {2,3,6,7,10}(去重后)
- step=3: 扩展这些会产生 11 ------ 第一次到达 11 时 step=3,返回 3。
8) 何时使用
- 当你希望尽早得到最短步数且不想用递归(避免栈溢出)时用 BFS。特别适合最优步数通常较小的场景。
四、方法三 --- 自底向上动态规划(迭代 DP)
1) 思路(非常细)
- 建立数组
dp[0..amount]
,dp[i]
表示凑成金额i
的最少硬币数。 - 初始:
dp[0] = 0
,其余dp[i] = INF
(例如amount + 1
,因为最坏也不会超过amount
枚硬币)。 - 递推:对
i
从 1 到amount
,dp[i] = min(dp[i], dp[i - coin] + 1)
(若i - coin >= 0
)。 - 最后若
dp[amount] == INF
则返回 -1,否则返回dp[amount]
。
2) 归纳证明(正确性)
- 基础:
dp[0] = 0
正确。 - 归纳:假设对于所有
k < i
,dp[k]
为最优解。求dp[i]
时,任何最优方案对于i
的最后一步必然是使用某枚coin
,即存在一个 coin 使得该方案在金额i - coin
时也为最优。因此dp[i]
=min(dp[i - coin] + 1)
覆盖所有可能的最后一步,取最小即为最优。由归纳可得正确性。
3) 复杂度
- 时间:
O(amount * n)
(外层amount
,内层n
)。 - 空间:
O(amount)
(dp 数组)。 - 常数因子相对较小(紧凑的数组操作,函数/队列开销小)。
4) 实现细节 & 常见坑(Java)
- 选用
INF = amount + 1
作为哨兵(比Integer.MAX_VALUE
更安全,便于做+1
操作而不溢出)。 dp
数组初始化为amount + 1
,最后dp[amount] > amount
判为不可达。- 注意
amount
可能为 0 的边界直接返回 0。
5) 路径重建(如果需要)
- 若想输出组成硬币序列,可在计算
dp[i]
时记录choice[i]
(记录使dp[i]
达到最优的coin
),最终从amount
回溯得到硬币序列(每次i -= choice[i]
)。
6) 空间优化
- 这里已经用的是一维
dp
(最小空间),无法进一步压缩(因为本来就是 1D 解)。对不同问题(比如计数组合数)可能需要二维,但本题一维足够。
7) 手工演示(coins=[1,2,5], amount=11)
建立 dp[0..11]
:
less
dp[0]=0
i=1: dp[1]=min(dp[1], dp[0]+1)=1
i=2: dp[2]=min(dp[2], dp[1]+1, dp[0]+1)=1
i=3: dp[3]=min(dp[3], dp[2]+1, dp[1]+1)=2
i=4: dp[4]=2
i=5: dp[5]=1 (直接 coin=5)
i=6: dp[6]=dp[1]+1=2
...
i=11: dp[11]=3
最终 dp[11]=3
。