算法——凑零钱问题
题目描述
给你k中面值的硬币,面值分别为c1, c2, … , ck,每种硬币的数量无限,再给一个总金额amount,问你最少需要多少枚硬币能凑出这个总金额。如果不能凑出返回-1。
示例
k=3,面值分别为1、2、5,总金额为11。
那么结果返回3。
暴力递归
这类问题,最简单的办法就是穷举,把所有的可能都列举出来,就可以得到最终目标。
我们可以假设一下,我们自己求上面的示例是怎么一个思考过程。
- 拿出一个5元硬币,此时还需要凑6元。
- 再拿出一个5元硬币,此时还需要凑出1元。
- 最后拿出一个1元硬币。
因为该示例很简单,我们一眼就可以得出上述结论。
那么计算机也是可以采取同样的方式,来得出上述答案。但是计算机肯定是需要把所有情况都列举出来,经过比较,然后才能得到最终的结论。不像我们可以一眼就知道要先用数值最大的硬币去凑。
比如:
- 计算机首先拿出面值为1的硬币,然后发现还差10,于是又拿出一枚1的硬币,最终得出需要11枚。
- 第二轮拿出一枚1的硬币,然后拿出面值为2的硬币,发现还差8,于是又拿出4枚面值为2的硬币,最终需要6枚硬币。
- 经过一系列计算和比较,得出只需要两枚5 + 一枚1就可以凑出11,最终得出结论3。
暴力递归就可以做到上面的过程。
// 定义一个最终返回的数,默认为最大值,因为最后结果不可能是最大值。
int res = Integer.MAX_VALUE;
public int coinChange(int[] coins, int amount) {
if (coins.length == 0) {
return -1;
}
coinRecursion(coins, amount, 0);
if (res == Integer.MAX_VALUE) {
return -1;
}
return res;
}
public void coinRecursion(int[] coins, int amount, int count) {
if (amount < 0) {
return;
}
if (amount == 0) {
res = Math.min(res, count);
}
for (int i = 0; i < coins.length; i++) {
// 每次求出剩余金额需要多少枚硬币,进行递归操作。
coinRecursion(coins, amount - coins[i], count + 1);
}
}
带备忘录的递归
不难看出递归会重复计算很多数据的值。比如当一开始计算机用面值1去凑总金额的时候,凑到还剩7元的时候,会继续计算。后来用面值为2的硬币凑总金额的时候,凑到还剩7元,依然会继续计算最少需要多少枚硬币能凑成7元。所以这个里面包含了大量的重复工作。
那么有没有什么办法可以解决掉这个问题呢。很明显,如果我们计算的时候能够记录下每一种情况最少需要多少枚硬币的数量,当再次凑到这个剩余金额的时候,我们就可以直接去备忘录里面查询一下,这个效率岂不是大幅度提升。
// 定义备忘录
int[] memo;
public int coinChangeHelper(int[] coins, int amount) {
if (coins.length == 0) return -1;
memo = new int[amount];
return coinHelper(coins, amount);
}
public int coinHelper(int[] coins, int amount) {
if (amount < 0) {
return -1;
}
if (amount == 0) {
return 0;
}
// 每次进行递归之前,先看看备忘录中是否已经存在对应剩余金额需要的最少数量,如果有直接返回即可。
if (memo[amount - 1] != 0) return memo[amount - 1];
int min = Integer.MAX_VALUE;
for (int i = 0; i < coins.length; i++) {
int res = coinHelper(coins, amount - coins[i]);
if (res >= 0 && res < min) min = res + 1;
}
return memo[amount - 1] = (min == Integer.MAX_VALUE ? -1 : min);
}
不用递归的解决方案
可以看出,递归是从金额最大值开始算,比如上述示例就是从11开始算,然后依次算出金额10最少需要的数量,金额9需要的数量,直到算到1。
那么自然可以得到一个需求金额的时候,我们直接从1开始算,直到算到需求金额-1,计算最终需求金额的时候岂不是类似于上述的备忘录方式一样,直接查前面已有的数据不就可以得出结论了嘛。
public int coinDP(int[] coins, int amount) {
if (coins.length <= 0) return -1;
if (amount < 0) return -1;
if (amount == 0) return 0;
int[] memo = new int[amount + 1];
memo[0] = 0;
// 记录凑成金额为n的时候,最少需要几个钱币。
for (int i = 1; i <= amount; i++) {
int min = Integer.MAX_VALUE;
for (int j = 0; j < coins.length; j++) {
if (i - coins[j] >= 0 && memo[i - coins[j]] < min)
min = memo[i - coins[j]] + 1;
}
memo[i] = min;
}
return memo[amount] == Integer.MAX_VALUE ? -1 : memo[amount];
}
状态转移方程:【min = memo[i - coins[j]] + 1】这个公式是怎么来的?
根据暴力递归和备忘录递归,我们可以清楚得看到当前所求金额的最少钱币=备忘录中当前金额-币值。
所以该for循环就是依次拿当前金额减去coins[]的值,然后取最小值+1,就得到了我们的目标结果。
PS:如果实在是不能理解,可以使用编译器进行debug走走看,去看清每一个情况,就可以得到上述结论。
总结
对于动态规划问题(通常为求最值),分两步走,第一步:写出暴力递归(得到状态转移方程)。第二步:使用备忘录或者dp table(第三种自底向上)来对代码进行简化,达到用空间换时间的目的。
上述问题暴力递归的时间复杂度为O(kn^k),后两种时间复杂度直接变成了O(kn),可以算得上是降维打击了。