322. 零钱兑换(C++)
题目
给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。
示例 1:
输入: coins = [1, 2, 5], amount = 11
输出: 3
解释: 11 = 5 + 5 + 1
示例 2:
输入: coins = [2], amount = 3
输出: -1
说明:
- 你可以认为每种硬币的数量是无限的。
分析与题解
与斐波那契数列类似,不过我们需要自行总结状态转移方程
。因为各种面值的硬币数量不限,所以我们认为在总数额变化时,各硬币数目的子问题间是相对独立的,符合最优子结构。
-
状态:原问题和子问题中变化的量,此处未金钱数额
常作为形参进行传递
-
选择与择优:对于每个状态,做出何种选择改变当前状态。对于此问题,从所有面额中选择一种硬币,然后目标金额会减少。
-
base case:目标金额为0时,硬币数量为0;目标金额小于0时,无解返回-1。
最终状态转移方程
如下:
暴力递归
事先说明这种方法提交会出现超时错误,这里抛砖引用,后面对其进行优化。
我们按照之前总结的状态转移方程
进行编写,代码如下:
class Solution {
public:
int coinChange(vector<int>& coins, int amount) {
//先确定base case的情况
if(amount==0)
return 0;
//对于剩余数额减去当前面值小于0的情况返回-1
//表示此时无解
if(amount<0)
return -1;
int ans = amount + 1;
for(int coin : coins){
int subpro = coinChange(coins, amount-coin);
//因为是取相对较小的值
//所以先排除无解返回-1的情况
//再与循环中当前最小硬币数比较
if(subpro==-1)
continue;
ans = min(1+subpro, ans);
}
//考虑ans自始至终未更新,仍未初始值的错误情况
return ans==amount+1?-1:ans;
// if(ans == amount+1)
// return -1;
// else return ans;
}
};
增加“状态表”的递归
同斐波那契数列,为了减少子问题重复计算增加时间复杂度的问题,我们尝试使用一个数值或者哈希表对已经计算的子问题硬币数量进行记录:
- 数值为0代表仍未计算该子问题
- 数值不为0表示已经计算子问题,并且具体值即为子问题结果
代码如下:
class Solution {
public:
int coinChange(vector<int>& coins, int amount) {
vector<int> dp(amount+1, 0);
return help(dp, amount,coins);
}
private:
int help(vector<int>& dp, int amount,vector<int>& coins){
if(amount == 0) return 0;
if(amount < 0) return -1;
if(dp[amount] != 0) return dp[amount];
int res = amount+1;
for(auto coin : coins){
int subproblem = help(dp,amount-coin,coins);
if(subproblem == -1) continue;
res = min(res, 1+subproblem);
}
dp[amount] = res == (amount+1)? -1 : res;
return dp[amount] ;
// if(res != amount+1){
// dp[amount] = res;
// return res;
// }else return -1;
}
};
代码中需要注意的是:
- 因为无解会返回-1,而最终结果是取较小值,因此在使用
min
判断总数额减去当前硬币的结果前,需要先排除返回值为-1的情况 - 我们将ans初始化为amount+1,也就是全换成1元硬币也不可能达到的硬币个数。经过循环后,可能所有的方案都无解,所以在返回ans前需要排除ans仍为初始值的情况,即无解的情况。
自底向上
同斐波那契方法,既然满足最优子结构。我们从base case开始,自底向上求得逐层子问题的硬币个数。有一点需要注意的是,记录子问题结果的vector容器初始化为amount+1,这是由于 dp[amount]
最大不可能超过 amount
,所以 amount + 1
就是一个无意义的数了。
最小面值为 1 元
class Solution {
public:
int coinChange(vector<int>& coins, int amount) {
vector<int> res(amount+1,amount+1);
//先确定特殊的base case
res[0]=0;
for(int i=0;i<=amount;i++){
for(int coin : coins){
if(i - coin < 0)
continue;
res[i] = min(res[i], 1+res[i-coin]);
}
}
return (res[amount]!=amount+1)?res[amount]:-1;
}
};
贪心+剪枝
从贪心算法的角度考虑,想要总硬币数最少,肯定是优先用大面值硬币.所以对 coins 按从大到小排序:
sort(coins.rbegin(), coins.rend());
以coins下标为回溯条件,根据硬币种类进行一一筛选。
for (int k = amount / coins[index]; k + count < ans && k >= 0; k--) {
// 回溯过程中进行剪枝
// 对于当前硬币个数已经和ans持平的情况直接剔除
dp(coins, amount - k * coins[index], index + 1, count + k, ans);
}
先丢大硬币,当当前选择面值的个数总和超过总额时,就可以递归下一层丢的是稍小面值的硬币。可以看到这里是从amount / coins[index]
进行反向遍历的。可以理解为乘法对完全背包问题的加速,用乘法算一下最多能丢几个。而之后的amount - k * coins[c_index]
表示减去扔了 k 个硬币后的剩余数额大小。count + k
表示当前硬币个数(又添加了k 个硬币)。
需要注意的是,贪心最先找到的不一定是最优解。考虑到有[1,7,10]
这种用例,按照贪心思路10 + 1 + 1 + 1 + 1
会比 7 + 7
更早找到。所以还是需要把所有情况都递归完。
这样进行递归其实运算量很大,因此采用剪枝进行优化。具体实现在for循环的条件里:
for (int k = amount / coins[index]; k + count < ans && k >= 0; k--)
其中k + count
表示当前还剩amount - k * coins[index]
数额的面值时,已经使用的硬币数量,我们将该当前硬币数量与最小硬币数量进行比较,如果k + count >= ans
,即使剩余的amount为0,也不会对最小的硬币数量进行更新,所以没有继续进行回溯的必要,直接进行剪枝即可。
完整代码如下:
class Solution {
public:
void dp(vector<int>& coins, int amount, int index, int count, int& ans) {
// 先设置回溯中止的条件
if (amount == 0) {
ans = min(ans, count);
return;
}
// 如果硬币种类遍历完,也需要停止回溯
if (index == coins.size())
return;
// 对于不同面额的硬币
// 直接乘法加速贪心过程
for (int k = amount / coins[index]; k + count < ans && k >= 0; k--) {
// 回溯过程中进行剪枝
// 对于当前硬币个数已经和ans持平的情况直接剔除
dp(coins, amount - k * coins[index], index + 1, count + k, ans);
}
}
int coinChange(vector<int>& coins, int amount) {
if (amount == 0) return 0;
// 先将coins降序排列
// 按照贪心思想从大面值钱币开始回溯
sort(coins.rbegin(), coins.rend());
int ans = INT_MAX;
dp(coins, amount, 0, 0, ans);
return ans != INT_MAX ? ans : -1;
}
};