零钱兑换是动态规划中的经典问题,也是面试中的高频考点。这个问题看似简单,却蕴含着深刻的算法思想。想象一下你是一个收银员,现在需要找零11元,手头有1元、2元和5元的硬币,如何用最少数量的硬币完成找零?这就是零钱兑换问题的现实场景。
在实际开发中,类似的问题随处可见:比如游戏中的道具合成系统、网络传输中的分包策略等。理解这个问题的解法,不仅能帮助我们通过算法面试,更能培养解决实际工程问题的思维模式。
给定不同面额的硬币数组coins和一个总金额amount,计算凑成总金额所需的最少硬币个数。每种硬币可以使用无限次,如果无法凑出目标金额则返回-1。
最直观的解法是暴力递归:对于目标金额amount,尝试所有可能的硬币组合,选择硬币数量最少的方案。这种方法虽然简单,但时间复杂度高达O(S^n),其中S是金额,n是硬币种类数,在amount较大时完全不可行。
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 res = coinChange(coins, amount - coin);
if (res >= 0 && res < minCoins) {
minCoins = res + 1;
}
}
return minCoins == Integer.MAX_VALUE ? -1 : minCoins;
}
动态规划是解决这类问题的利器。我们可以定义一个数组dp,其中dp[i]表示凑出金额i所需的最少硬币数。状态转移方程为:
dp[i] = min(dp[i - coin] + 1) for coin in coins
初始条件为dp[0] = 0,表示凑出金额0需要0个硬币。
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 (coin <= i) {
dp[i] = Math.min(dp[i], dp[i - coin] + 1);
}
}
}
return dp[amount] > amount ? -1 : dp[amount];
}
这个解法的时间复杂度为O(S*n),空间复杂度为O(S),其中S是金额,n是硬币种类数。
在实际编码中,有几个边界条件需要特别注意:
dp数组初始值设置为amount+1,这是一个巧妙的选择。因为最多需要amount个1元硬币,所以amount+1可以表示"不可能"的状态。
我们可以先对coins数组进行排序,这样在内层循环中可以提前终止不必要的计算:
java复制Arrays.sort(coins);
for (int i = 1; i <= amount; i++) {
for (int coin : coins) {
if (coin > i) break; // 提前终止
dp[i] = Math.min(dp[i], dp[i - coin] + 1);
}
}
零钱兑换算法在实际中有广泛的应用:
让我们比较几种解法的性能:
| 解法类型 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 暴力递归 | O(S^n) | O(n) | 小规模问题 |
| 记忆化递归 | O(S*n) | O(S) | 中等规模 |
| 动态规划 | O(S*n) | O(S) | 大规模问题 |
| BFS解法 | O(S*n) | O(S) | 求最少硬币数 |
在实际面试中,动态规划解法是最常被考察的,因为它平衡了理解难度和实现复杂度。
让我们仔细分析提供的Java代码实现:
java复制class Solution {
public int coinChange(int[] coins, int amount) {
int[] ans = new int[amount+1];
for(int i = 1;i<=amount;i++){
int minn = amount+1;
for(int j = 0;j<coins.length;j++){
if(i-coins[j]>=0){
minn = Math.min(minn,ans[i-coins[j]]);
}
}
ans[i] = minn+1;
}
if(ans[amount]>amount){
return -1;
}
return ans[amount];
}
}
这段代码有几个值得注意的地方:
注意:这段代码有一个小问题,当amount为0时,会返回ans[0]的默认值0,虽然结果正确,但最好显式处理这个边界条件。
在面试中遇到这个问题时,可以按照以下步骤进行:
记住要向面试官展示你的思考过程,而不仅仅是给出最终答案。
虽然我们主要讨论了Java实现,但这个问题在其他语言中的解法也值得了解:
Python实现:
python复制def coinChange(coins, amount):
dp = [float('inf')] * (amount + 1)
dp[0] = 0
for coin in coins:
for i in range(coin, amount + 1):
dp[i] = min(dp[i], dp[i - coin] + 1)
return dp[amount] if dp[amount] != float('inf') else -1
C++实现:
cpp复制int coinChange(vector<int>& coins, int amount) {
vector<int> dp(amount + 1, amount + 1);
dp[0] = 0;
for (int i = 1; i <= amount; i++) {
for (int coin : coins) {
if (coin <= i) {
dp[i] = min(dp[i], dp[i - coin] + 1);
}
}
}
return dp[amount] > amount ? -1 : dp[amount];
}
不同语言的实现思路基本相同,主要区别在于语法细节和初始化方式。
为了更好地理解这个算法,我们可以想象在填一个表格。以coins = [1,2,5], amount = 11为例:
| 金额 | 最少硬币数 | 组成方式 |
|---|---|---|
| 0 | 0 | - |
| 1 | 1 | 1 |
| 2 | 1 | 2 |
| 3 | 2 | 1+2 |
| ... | ... | ... |
| 11 | 3 | 5+5+1 |
通过填表的过程,我们可以直观地看到每个子问题的最优解是如何构建出来的。
在实际编写代码时,有几个细节需要特别注意:
提示:在Java中,使用Arrays.fill(dp, amount + 1)来初始化比使用Integer.MAX_VALUE更安全,可以避免整数溢出问题。
对于已经掌握基础解法的同学,可以思考以下几个进阶问题:
这些问题可以帮助你更深入地理解动态规划的思想。
为了验证我们的算法性能,可以进行一些测试:
测试用例1:coins = [1,2,5], amount = 100
测试用例2:coins = [2], amount = 3
测试用例3:coins = [1,3,4], amount = 10000
从测试中可以看出,动态规划解法在大规模数据下仍然表现良好。
零钱兑换问题与以下动态规划问题有相似之处:
理解这些问题的联系,可以帮助我们建立更系统的动态规划知识体系。
从数学角度看,零钱兑换问题属于整数线性规划问题。我们可以将其形式化为:
最小化:∑x_i
约束条件:∑(x_i * c_i) = S
其中x_i ≥ 0且为整数,c_i是硬币面额,S是目标金额。
这种形式化表达帮助我们理解问题的本质,但在实际求解中,动态规划仍然是更实用的方法。
有些同学可能会想到用贪心算法:每次选择最大面额的硬币。这种方法在某些情况下有效,如coins = [1,5,10], amount = 28,但在coins = [1,3,4], amount = 6时会失败(贪心:4+1+1=3,最优:3+3=2)。
因此,贪心算法不适用于一般情况的零钱兑换问题,这也是动态规划的必要性所在。
除了迭代的动态规划,我们还可以用记忆化递归来解决这个问题:
java复制public int coinChange(int[] coins, int amount) {
int[] memo = new int[amount + 1];
Arrays.fill(memo, -2); // -2表示未计算
return helper(coins, amount, memo);
}
private int helper(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 res = helper(coins, amount - coin, memo);
if (res >= 0 && res < minCoins) {
minCoins = res + 1;
}
}
memo[amount] = minCoins == Integer.MAX_VALUE ? -1 : minCoins;
return memo[amount];
}
记忆化递归更容易理解,但在实际应用中,迭代的动态规划通常性能更好。
除了动态规划,这个问题还可以用BFS的思想来解决。我们可以将问题建模为图的最短路径问题,其中节点代表金额,边代表硬币的使用。
java复制public int coinChange(int[] coins, int amount) {
if (amount == 0) return 0;
Queue<Integer> queue = new LinkedList<>();
boolean[] visited = new boolean[amount + 1];
queue.offer(0);
visited[0] = true;
int level = 0;
while (!queue.isEmpty()) {
int size = queue.size();
for (int i = 0; i < size; i++) {
int curr = queue.poll();
if (curr == amount) return level;
for (int coin : coins) {
int next = curr + coin;
if (next <= amount && !visited[next]) {
visited[next] = true;
queue.offer(next);
}
}
}
level++;
}
return -1;
}
BFS解法的时间复杂度也是O(S*n),在某些情况下可能比动态规划更快。
在实际工程中实现零钱兑换算法时,还需要考虑:
这些考量使得工程实现比算法竞赛或面试中的解法更加复杂。
想要深入理解零钱兑换及相关算法,可以参考以下资源:
掌握这个问题后,可以继续学习更复杂的动态规划问题,如背包问题、股票买卖问题等。