1. 零钱兑换问题概述
零钱兑换是LeetCode上经典的动态规划问题(编号322),也是面试中高频出现的算法题目。给定不同面额的硬币coins和一个总金额amount,计算可以凑成总金额所需的最少硬币个数。如果没有任何一种硬币组合能组成总金额,则返回-1。
这个问题看似简单,却蕴含着动态规划的核心思想。我在面试候选人时发现,超过70%的初级开发者虽然能写出解法,但对其中的状态转移逻辑理解不够透彻。本文将结合Java实现,从问题本质出发,逐步拆解这个动态规划经典案例。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 问题分析与暴力解法
2.1 问题特征识别
首先我们需要识别这个问题适合用动态规划解决的几个关键特征:
- 最优子结构:要计算amount的最优解,需要先计算amount-coin的最优解
- 重叠子问题:在递归求解过程中会重复计算相同的子问题
- 无后效性:当前状态只与之前的状态有关,与之后的状态无关
2.2 暴力递归解法
最直观的解法是使用递归枚举所有可能的硬币组合:
java复制public int coinChange(int[] coins, int amount) {
if (amount == 0) return 0;
if (amount < 0) return -1;
int minCoins = Integer.MAX_VALUE;
for (int coin : coins) {
int subProblem = coinChange(coins, amount - coin);
if (subProblem == -1) continue;
minCoins = Math.min(minCoins, subProblem + 1);
}
return minCoins == Integer.MAX_VALUE ? -1 : minCoins;
}
这个解法虽然直观,但时间复杂度高达O(S^n)(S是金额,n是硬币种类数),在LeetCode上会超时。我曾在面试中让候选人分析这个解法的问题,发现很多人对递归树的理解不够深入。
3. 动态规划解法详解
3.1 自顶向下记忆化搜索
在暴力解法基础上加入备忘录,避免重复计算:
java复制public int coinChange(int[] coins, int amount) {
int[] memo = new int[amount + 1];
Arrays.fill(memo, -2); // 特殊值标记未计算
return dp(coins, amount, memo);
}
private int dp(int[] coins, int amount, int[] memo) {
if (amount == 0) return 0;
if (amount < 0) return -1;
if (memo[amount] != -2) return memo[amount];
int minCoins = Integer.MAX_VALUE;
for (int coin : coins) {
int subProblem = dp(coins, amount - coin, memo);
if (subProblem == -1) continue;
minCoins = Math.min(minCoins, subProblem + 1);
}
memo[amount] = (minCoins == Integer.MAX_VALUE) ? -1 : minCoins;
return memo[amount];
}
这种解法的时间复杂度降为O(S*n),空间复杂度O(S)。在实际编码面试中,我建议先写出这种解法,因为它更接近原始思路,容易解释。
3.2 自底向上迭代解法
更标准的动态规划解法是使用DP表:
java复制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 >= 0) {
dp[i] = Math.min(dp[i], dp[i - coin] + 1);
}
}
}
return dp[amount] > amount ? -1 : dp[amount];
}
这个版本有几个关键点需要注意:
- DP数组初始化为amount+1(因为最多需要amount个1元硬币)
- 外层循环遍历所有金额,内层循环遍历所有硬币
- 状态转移方程:dp[i] = min(dp[i], dp[i-coin] + 1)
我在实际项目中使用这种解法时,发现很多人容易犯两个错误:一是初始化值设置不当,二是边界条件处理不完整。
4. 算法优化与变种
4.1 提前终止优化
当硬币面额包含1时,可以提前终止循环:
java复制if (dp[amount] != amount + 1) {
break; // 已经找到最优解
}
4.2 硬币排序优化
先对硬币排序,从大到小尝试:
java复制Arrays.sort(coins);
for (int i = coins.length - 1; i >= 0; i--) {
int coin = coins[i];
// ...其余逻辑相同
}
这样可以在某些情况下更快找到解。
4.3 完全背包问题视角
零钱兑换实际上是完全背包问题的变种:
- 背包容量:amount
- 物品:硬币
- 物品价值:1(求最小数量)
- 物品数量:无限
理解这个对应关系有助于解决其他类似问题。
5. 常见错误与调试技巧
5.1 初始化陷阱
常见错误包括:
- 忘记初始化dp[0] = 0
- 初始值设置过小导致溢出
- 使用Integer.MAX_VALUE导致+1后变为负数
5.2 边界条件处理
需要特别注意:
- amount为0时应返回0
- 没有任何解时应返回-1
- 硬币面额可能大于当前amount
5.3 调试技巧
我常用的调试方法:
- 打印DP表观察中间状态
- 对特殊测试用例手动推导
- 使用小规模数据验证
例如测试用例coins=[2], amount=3时,DP表应该是:
[0, 4, 1, 4]
6. 复杂度分析与实际应用
6.1 时间复杂度分析
两种DP解法的时间复杂度都是O(S*n),其中:
- S是金额amount
- n是硬币种类数
6.2 空间复杂度优化
可以将空间复杂度优化到O(S),使用一维DP数组即可。
6.3 实际应用场景
这类算法在实际中有广泛应用:
- 自动售货机找零系统
- 金融系统中的最小交易单位计算
- 资源分配的最优组合问题
7. 相关题目拓展
掌握零钱兑换后,可以解决以下类似问题:
- LeetCode 518 零钱兑换II(求组合数)
- LeetCode 377 组合总和IV(求排列数)
- LeetCode 279 完全平方数
以完全平方数问题为例,其解法与零钱兑换几乎相同,只是硬币变成了平方数:
java复制public int numSquares(int n) {
int[] dp = new int[n + 1];
Arrays.fill(dp, n + 1);
dp[0] = 0;
for (int i = 1; i <= n; i++) {
for (int j = 1; j * j <= i; j++) {
dp[i] = Math.min(dp[i], dp[i - j * j] + 1);
}
}
return dp[n];
}
8. 面试实战建议
根据我的面试官经验,在考察这类题目时会关注:
- 能否识别出动态规划适用性
- 状态定义是否准确
- 边界条件处理是否完整
- 能否进行空间优化
建议在面试中:
- 先讨论暴力解法
- 引出重叠子问题
- 提出记忆化方案
- 最终给出DP解法
- 讨论可能的优化
一个高质量的答案应该包含对算法复杂度的分析和对边界条件的全面考虑。我在面试中遇到过不少候选人能够写出代码,但解释不清楚为什么这样设计DP数组,这是需要特别注意的。
