# [322] 零钱兑换
给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。
示例 1:
输入: coins = [1, 2, 5], amount = 11
输出: 3
解释: 11 = 5 + 5 + 1
示例 2:
输入: coins = [2], amount = 3
输出: -1
说明:
你可以认为每种硬币的数量是无限的。
# 带回溯的贪心法
这道题粗略一看,一般都会想到使用贪心法来解。每次选取尽可能多的最大面额硬币去逼近总金额,之后使用第二大面额硬币逼近,直到面值和等于总金额或者所有可用硬币种类逼近完后停止。
但是这样做是不对的。试着考虑这样一种情况,coins=[1, 6, 10], amount=18。很明显6+6+6的组合是要优于10+6+1+1的。这就是我们使用贪心法时经常会遇到的:贪心法求得的时局部最优解,而局部最优不一定等于全局最优。
因此我们还得对该算法进行进一步的改进。现在将最大面额的硬币减少一枚,剩余值从第二大面额的硬币开始逼近,直到得到该种情况下的最优解,再继续减少一枚当前最大面额的硬币,从当前第二大面额的硬币开始逼近……直到最后只有最小面额的硬币逼近取得的最优解。将这些局部最优解取出取最小值,即可得到全局最优解了。
不难看出,这样的做法实际上就是回溯上一层的一次操作,向另一个分支遍历。实际上就是一次数的深度优先遍历(DFS)了。
当然,这样做虽然能够顺利得出答案,但是时间复杂度会非常高,因此我们需要作出优化。那么最简单的优化就是,当每次贪心求局部最优的过程中,若当前逼近硬币数已经超过了其他情况下的局部最优解了,那么显然该次贪心算法不是全局最优解,将后面的运算全部剪枝即可。
let res = Number.MAX_SAFE_INTEGER;
var coinChange = function(coins, amount) {
if (amount === 0) return 0;
// 从大到小排序
coins.sort((a, b) => b - a);
dfs(coins, 0, amount, 0);
return res < Number.MAX_SAFE_INTEGER ? res : -1;
};
function dfs(coins, index, amount, count) {
if (index >= coins.length) return;
// 当剩余值为0,说明找到了一组最优解
if (amount === 0) {
res = Math.min(count, res);
return;
}
for (let i = Math.floor(amount / coins[index]); i >= 0; i--) {
const newCount = count + i;
const rest = amount - i * coins[index];
// 当前硬币数已经超过最优解了,剪枝
if (newCount >= res) break;
dfs(coins, index + 1, rest, newCount);
}
}
# 动态规划
当然,我们也可以用动态规划的思路来解决这个问题,可以将其转化为一个不限制背包容量的背包问题。设立二维数组,coins[i]
为硬币的种类,j为背包容量(在本题即为目标金额),dp[i][j]
表示当目标金额amount为j时,最少需要的coins[i]
种类的硬币多少个。
经过上面的分析可以得知,我们每一次做选择时,对于任意一枚硬币时都有取或者不取两种情况。当取该枚硬币时,最少硬币的情况应该等于组成j-coins[i]
元的最少硬币组成个数 + 1,即dp[i][j] = dp[i, j-coins[i] + 1
;当不取该枚硬币时,相当于没有遍历过i这一位置,因此该情况下个数等价于dp[i-1, j]
时的个数。
这里为dp数组设置最大初始值是amount+1
,是因为硬币是正整数,那么组成amount的硬币个数就不可能超过amount个1元硬币。
状态转移方程:dp[i, j] = min(dp[i - 1, j], dp[i, j - coins[i]] + 1)
var coinChange = function(coins, amount) {
// 初始化dp二维数组与第一行、第一列的值
const dp = new Array(coins.length).fill(0).map(x => new Array(amount + 1).fill(Number.MAX_SAFE_INTEGER));
for (let i = 0; i < dp.length; i++) {
dp[i][0] = 0;
}
for (let i = 0; i < dp[0].length; i++) {
dp[0][i] = i % coins[0] === 0 ? Math.floor(i / coins[0]) : Number.MAX_SAFE_INTEGER;
}
for (let j = 1; j <= amount; j++) {
for (let i = 1; i < coins.length; i++) {
if (j >= coins[i]) {
dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - coins[i]] + 1);
} else {
dp[i][j] = dp[i - 1][j];
}
}
}
return dp[coins.length - 1][amount] < Number.MAX_SAFE_INTEGER ? dp[coins.length - 1][amount] : -1;
};
我们也可以用一维表代替二维表,从而节省空间复杂度。此时dp[i]表示,当目标金额为i时的硬币组成最小个数。
举个例子来说,假设 coins = [1, 2, 5, ...], 那么,该算法的递推式为:min(f(n-1), f(n-2), f(n-5), ...) + 1。(若n < i 则f(n-i)不计入在内)
因此对每一种硬币coin,有:dp[i] = min(dp[i], dp[i - coin] + 1)
function coinChange(coins: number[], amount: number): number {
const dp = Array(amount + 1).fill(Number.MAX_SAFE_INTEGER);
dp[0] = 0;
// Sn = MIN(S[n-coin] + 1)
for (let i = 1; i <= amount; i++) {
coins.forEach(coin => {
if (i - coin >= 0) {
dp[i] = Math.min(dp[i], dp[i - coin] + 1);
}
});
}
return dp[amount] === Number.MAX_SAFE_INTEGER ? -1 : dp[amount];
}