问题引入
假设你是一名自动售货机的开发者。机器需要给顾客找零,面额有 1 元、2 元、5 元等若干种硬币。给定一个目标金额 amount,请问最少需要多少枚硬币才能凑出这个金额?如果无法凑出,返回 -1。
这个问题在 LeetCode 上编号 322,被归类为经典的完全背包问题。它与 0-1 背包的区别在于:每种硬币可以无限次使用。本文将用 Java 从零实现三种核心解法,并对比它们的时空复杂度。
问题建模
输入与输出
- 输入:整数数组
coins表示可用硬币面额,整数amount表示目标金额。 - 输出:凑成
amount所需的最少硬币数,若无法凑成则返回 -1。
示例
输入: coins = [1, 2, 5], amount = 11
输出: 3
解释: 11 = 5 + 5 + 1,共 3 枚硬币
输入: coins = [2], amount = 3
输出: -1
解释: 无法用面额为 2 的硬币凑出 3
状态定义
设 dp[i] 表示凑出金额 i 所需的最少硬币数。我们的目标是求 dp[amount]。
状态转移方程
对于每一个金额 i,我们遍历所有硬币面额 coin:
如果 i >= coin:
dp[i] = min(dp[i], dp[i - coin] + 1)
初始条件:dp[0] = 0(凑出金额 0 需要 0 枚硬币),其余 dp[i] = ∞(初始表示不可达)。
解法一:记忆化搜索(自顶向下)
记忆化搜索是递归与动态规划的桥梁。它从目标金额 amount 出发,递归分解子问题,并将已计算的结果缓存起来避免重复计算。
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
public class CoinChangeMemo {
private Map<Integer, Integer> memo = new HashMap<>();
private int[] coins;
/**
* 主入口:计算凑成 amount 的最少硬币数
*/
public int coinChange(int[] coins, int amount) {
this.coins = coins;
// base case:凑出 0 元需要 0 枚硬币
memo.put(0, 0);
int result = dfs(amount);
return result == Integer.MAX_VALUE ? -1 : result;
}
/**
* 深度优先搜索 + 记忆化
* @param remain 剩余需要凑的金额
* @return 凑成 remain 所需的最少硬币数,不可达则返回 MAX_VALUE
*/
private int dfs(int remain) {
// 已经计算过,直接返回缓存结果
if (memo.containsKey(remain)) {
return memo.get(remain);
}
// 尝试每一种硬币
int minCoins = Integer.MAX_VALUE;
for (int coin : coins) {
if (remain >= coin) {
int subResult = dfs(remain - coin);
if (subResult != Integer.MAX_VALUE) {
minCoins = Math.min(minCoins, subResult + 1);
}
}
}
// 缓存结果
memo.put(remain, minCoins);
return minCoins;
}
public static void main(String[] args) {
CoinChangeMemo solution = new CoinChangeMemo();
int[] coins = {1, 2, 5};
int amount = 11;
System.out.println("记忆化搜索结果: " + solution.coinChange(coins, amount)); // 输出 3
}
}
复杂度分析
- 时间复杂度:
O(amount × n),其中n为硬币种类数。每个金额最多计算一次,每次计算遍历所有硬币。 - 空间复杂度:
O(amount),递归栈深度与记忆化哈希表各占O(amount)。
解法二:动态规划(自底向上)
自底向上的动态规划直接按照金额从小到大的顺序填充 dp 数组,避免了递归的栈开销。
public class CoinChangeDP {
/**
* 自底向上动态规划
* dp[i] = 凑成金额 i 所需的最少硬币数
*/
public int coinChange(int[] coins, int amount) {
// dp 数组,初始化为 amount + 1(表示无穷大,因为最多 amount 枚 1 元硬币)
int[] dp = new int[amount + 1];
Arrays.fill(dp, amount + 1);
dp[0] = 0; // 凑出 0 元需要 0 枚硬币
// 遍历每一个目标金额
for (int i = 1; i <= amount; i++) {
// 尝试每一种硬币
for (int coin : coins) {
if (i >= coin) {
// 状态转移:使用一枚 coin 后,剩余 i - coin 的最优解 + 1
dp[i] = Math.min(dp[i], dp[i - coin] + 1);
}
}
}
// 如果 dp[amount] 仍为初始值,说明不可达
return dp[amount] > amount ? -1 : dp[amount];
}
public static void main(String[] args) {
CoinChangeDP solution = new CoinChangeDP();
int[] coins = {1, 2, 5};
int amount = 11;
System.out.println("DP 结果: " + solution.coinChange(coins, amount)); // 输出 3
}
}
为什么初始化为 amount + 1?
在最坏情况下(只有 1 元硬币),凑成 amount 需要 amount 枚硬币。因此任何大于 amount 的值都可以作为”无穷大”的哨兵值。
解法三:BFS 最短路径视角
换个角度思考:每一种金额可以看作图中的一个节点,使用一枚硬币面额相当于走过一条边权为 1 的边。那么问题转化为从节点 0 到节点 amount 的最短路径。
import java.util.LinkedList;
import java.util.Queue;
public class CoinChangeBFS {
/**
* BFS 求解最短路径
* 每个节点代表一个金额,每条边代表使用一枚硬币
*/
public int coinChange(int[] coins, int amount) {
if (amount == 0) return 0;
boolean[] visited = new boolean[amount + 1];
Queue<Integer> queue = new LinkedList<>();
queue.offer(0);
visited[0] = true;
int steps = 0; // 已使用的硬币数(路径长度)
while (!queue.isEmpty()) {
int size = queue.size();
steps++;
// 按层遍历,当前层所有节点使用相同数量的硬币
for (int i = 0; i < size; i++) {
int current = queue.poll();
for (int coin : coins) {
int next = current + coin;
if (next == amount) {
return steps; // 找到目标,返回当前步数
}
if (next < amount && !visited[next]) {
visited[next] = true;
queue.offer(next);
}
}
}
}
return -1; // 遍历完所有可达节点仍未找到目标
}
public static void main(String[] args) {
CoinChangeBFS solution = new CoinChangeBFS();
int[] coins = {1, 2, 5};
int amount = 11;
System.out.println("BFS 结果: " + solution.coinChange(coins, amount)); // 输出 3
}
}
BFS 为什么有效?
BFS 天然按路径长度(硬币数)递增的顺序搜索,第一次到达目标金额时,所用的硬币数一定是最少的。这与 Dijkstra 算法在边权为 1 时的行为一致。
空间优化:滚动数组
观察动态规划的转移方程:dp[i] 只依赖于 dp[i - coin](即当前金额减去某枚硬币面额后的值)。由于 i - coin < i,我们在填充 dp 数组时已经是从小到大遍历,天然满足依赖顺序,因此不需要额外的滚动优化,一维数组本身就是最优空间方案。
但需要注意遍历顺序:外层循环遍历金额,内层循环遍历硬币。如果反过来(先遍历硬币再遍历金额),则变成 0-1 背包的处理方式,会导致错误。这是因为完全背包问题允许硬币重复使用,必须让每种硬币在每个金额下都被考虑。
三种解法对比
| 解法 | 时间复杂度 | 空间复杂度 | 特点 |
|---|---|---|---|
| 记忆化搜索 | O(amount × n) | O(amount) | 思路直观,递归有栈溢出风险 |
| 动态规划 | O(amount × n) | O(amount) | 无递归开销,常数更优 |
| BFS | O(amount × n) | O(amount) | 天然求最短路径,易于理解 |
在实际工程中,自底向上的动态规划是首选方案,因为它避免了递归深度限制,且常数因子最小。
完整可运行项目
下面是一个整合三种解法的完整 Java 项目,包含统一的接口定义与测试用例:
import java.util.*;
/**
* 零钱兑换问题统一接口
*/
interface CoinChangeSolver {
int coinChange(int[] coins, int amount);
}
/**
* 主类:运行三种解法并对比结果
*/
public class CoinChangeMain {
public static void main(String[] args) {
int[][] testCoins = {
{1, 2, 5},
{2},
{1},
{1, 3, 4},
{186, 419, 83, 408}
};
int[] testAmounts = {11, 3, 0, 6, 6249};
int[] expected = {3, -1, 0, 2, 20};
CoinChangeSolver[] solvers = {
new MemoSolver(),
new DPSolver(),
new BFSSolver()
};
String[] solverNames = {"记忆化搜索", "动态规划", "BFS"};
for (int i = 0; i < testCoins.length; i++) {
System.out.println("\n测试用例 " + (i + 1) + ": coins=" + Arrays.toString(testCoins[i])
+ ", amount=" + testAmounts[i]);
System.out.println("期望结果: " + expected[i]);
for (int j = 0; j < solvers.length; j++) {
long start = System.nanoTime();
int result = solvers[j].coinChange(testCoins[i], testAmounts[i]);
long end = System.nanoTime();
System.out.printf(" %s: %d (耗时 %.3f ms)%n",
solverNames[j], result, (end - start) / 1_000_000.0);
}
}
}
}
/**
* 记忆化搜索实现
*/
class MemoSolver implements CoinChangeSolver {
private Map<Integer, Integer> memo;
private int[] coins;
@Override
public int coinChange(int[] coins, int amount) {
this.coins = coins;
this.memo = new HashMap<>();
memo.put(0, 0);
int res = dfs(amount);
return res == Integer.MAX_VALUE ? -1 : res;
}
private int dfs(int remain) {
if (memo.containsKey(remain)) return memo.get(remain);
int min = Integer.MAX_VALUE;
for (int coin : coins) {
if (remain >= coin) {
int sub = dfs(remain - coin);
if (sub != Integer.MAX_VALUE) {
min = Math.min(min, sub + 1);
}
}
}
memo.put(remain, min);
return min;
}
}
/**
* 动态规划实现
*/
class DPSolver implements CoinChangeSolver {
@Override
public int coinChange(int[] coins, int amount) {
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) {
dp[i] = Math.min(dp[i], dp[i - coin] + 1);
}
}
}
return dp[amount] > amount ? -1 : dp[amount];
}
}
/**
* BFS 实现
*/
class BFSSolver implements CoinChangeSolver {
@Override
public int coinChange(int[] coins, int amount) {
if (amount == 0) return 0;
boolean[] visited = new boolean[amount + 1];
Queue<Integer> queue = new LinkedList<>();
queue.offer(0);
visited[0] = true;
int steps = 0;
while (!queue.isEmpty()) {
int size = queue.size();
steps++;
for (int i = 0; i < size; i++) {
int cur = queue.poll();
for (int coin : coins) {
int next = cur + coin;
if (next == amount) return steps;
if (next < amount && !visited[next]) {
visited[next] = true;
queue.offer(next);
}
}
}
}
return -1;
}
}
总结
零钱兑换问题将完全背包的核心思想展现得淋漓尽致。通过本文的三种实现,你可以对比理解:
- 记忆化搜索适合递归思路清晰、状态空间稀疏的场景;
- 动态规划是工程中最稳定高效的选择;
- BFS则揭示了问题与图论最短路径之间的深层联系。
掌握这一问题的建模与实现,是理解更复杂动态规划问题(如完全背包的扩展、多重背包、分组背包)的重要基石。