典型的动态规划问题,需要求解的是最少的硬币个数,不能使用贪心算法。
这题的测试样例以及设置非常坑,给的硬币是未排序的,amount的值可能是0,也可能比最小的硬币还要小。。。
状态转移表/BFS(很暴力,不推荐使用)
先从基本的开始分析,可以一次遍历coins获取硬币最小值,那么动态规划的阶段数量最多就是amount/硬币最小值。
每个阶段我们决策不同的硬币是否选择,会达到不同的状态(状态用下标表示),以下为例子:
阶段1可达状态为3种,即为各个硬币值(状态计算过程就像BFS):广度遍历,就是每一次累加上所有硬币,因为是广度遍历,所得到一定是可以用最少硬币达到的。
阶段2在阶段1的基础上进行选择,可达状态为:2(1+1), 3(1+2),4(2+2),6(1+5),7(2+5),10(5+5)
…
输入: coins = [1, 2, 5], amount = 11
下标 i: 0 1 2 3 4 5 6 7 8 9 10 11
阶段1: 0 1 2 0 0 5 0 0 0 0 0 0
阶段2: 0 0 1 1 2 0 1 2 0 0 5 0
阶段3: 0 0 0 1 1 1 2 1 2 5 0 5
虽然阶段数最多为11,但是阶段3结束后,f[3][11]已经可达了,就可以break出去了。
class Solution {
public:
int coinChange(vector<int>& coins, int amount) {
if(amount == 0) return 0;
int min_idx = 0;
for(int i = 0; i < coins.size(); ++i){//先找到最小的硬币
if(coins[i] < coins[min_idx]) min_idx = i;
}
int level = amount/coins[min_idx]; //最大遍历的循环次数(动态规划的阶段个数)
if(level < 1) return -1;//amount小于最小的硬币值
vector<vector<int>> f(level, vector<int>(amount+1, 0));
for(int i = 0; i < coins.size(); ++i){//第一阶段放在外层特殊处理
if(coins[i] <= amount)
f[0][coins[i]] = coins[i];
}
if(f[0][amount] != 0) return 1;//硬币刚好有的金额,一个硬币就可以
int res = -1;
for(int i = 1; i < level; ++i){//每个阶段
for(int j = 0; j < amount; ++j){
if(f[i-1][j] != 0){
for(int k = 0; k < coins.size(); ++k){//每个coin
//if里面的写法是为了防止j + coins[k]溢出
if(coins[k] <= amount-j) f[i][j + coins[k]] = coins[k];
}
}
if(f[i][amount] != 0) return i + 1; //amount的状态已达
}
}
return res;
}
};
上面是初始代码,效率并不高。
虽然这题我们只需要计算最少的数量,但是这样写的好处是如果有需要我们可以输出选择的序列,比如某个硬币选了多少个,只需要加以下代码就可以:
int tmp = amount;
for(int i = res - 1; i >= 0; --i){//状态(i,j)从状态(i-1, j - f[i][j])而来
cout << f[i][tmp] << " ";
tmp -= f[i][tmp];
}
对于上面的例子,输出就是: 5,1,5。
如果要进行优化的话,我们知道二维数组一般是可以用一维数组代替的,只是有些时候需要逆序计算:
class Solution {
public:
int coinChange(vector<int>& coins, int amount) {
if(amount == 0) return 0;
int min_idx = 0;
for(int i = 0; i < coins.size(); ++i){//先找到最小的硬币
if(coins[i] < coins[min_idx]) min_idx = i;
}
int level = amount/coins[min_idx]; //最大遍历的循环次数(动态规划的最大阶段个数)
if(level < 1) return -1;//amount小于最小的硬币值
vector<int> f(amount+1, 0);//一维数组
for(int i = 0; i < coins.size(); ++i){//第一阶段放在外层特殊处理
if(coins[i] <= amount) f[coins[i]] = coins[i];
}
if(f[amount] != 0) return 1;//硬币刚好有的金额,一个硬币就可以
int res = -1;
for(int i = 1; i < level; ++i){//每个阶段
for(int j = amount - 1; j >= 0; --j){//经典逆序处理,避免值覆盖问题
if(f[j] != 0){
for(int k = 0; k < coins.size(); ++k){//每个coin
//if里面的写法是为了防止j + coins[k]溢出
if(coins[k] <= amount-j) f[j + coins[k]] = coins[k];
}
}
if(f[amount] != 0) return i + 1;//amount的状态已达
}
}
return res;
}
};
一维数组优化之后内存消耗直接减少了几十倍,不过执行效率还是一般。下面考虑状态转移方程。
状态转移方程dp
其实这就是一个完全背包问题(同种硬币不限选),只不过我们在求的是能够刚好装满容量amount的背包的硬币数量的最小值,硬币数量就相当于传统背包问题里面的价值,要求的是最小值而不是最大值,这儿只不过稍作修改。
所以我们只需要在初始化的时候也做一下修改就可以了。
class Solution {
public:
int coinChange(vector<int>& coins, int amount) {
vector<int> dp(amount+1, amount+1);//因为求得是最小值,所以初始化的很大
dp[0] = 0;
for(const auto &coin : coins){
for(int j = coin; j <= amount; ++j){//完全背包正序遍历
dp[j] = min(dp[j], dp[j-coin]+1);
}
}
return dp[amount] == amount+1 ? -1 : dp[amount];
}
};
到了这儿再看之前bfs打dp表的方法,真的笨死了。。。
完全背包问题两层for循环是可以颠倒的:
class Solution {
public:
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(const auto &coin : coins){
if(i >= coin) dp[i] = min(dp[i], dp[i-coin]+1);
}
}
return dp[amount] == amount+1 ? -1 : dp[amount];
}
};
不过不颠倒的效率会好很多。
DFS回溯加剪枝(贪心)
上面的dp还是不够快,虽然不能直接使用贪心算法,但直觉上应该是大面值的越多越好,但是怎么把这个思路加入回溯并且保证是最优解呢?
可以看这位同学的解释:
【零钱兑换】贪心 + dfs = 8ms - 零钱兑换 - 力扣(LeetCode)
DFS + 剪枝 2ms 击败100%,比 DP 还快? - 零钱兑换 - 力扣(LeetCode)
两个剪枝策略:
- 1、当剩余份额
rem_amount==0
时剪枝,因为大面值硬币需要更多小面值硬币替换,继续减少一枚或几枚大硬币搜索出来的答案(即使有),也肯定不会比现在的更好。 - 2、当
rem_amount!=0
,但当前已使用的硬币数 cur_cnt 满足:cur_cnt+1 >= ans
时剪枝,因为 rem_amount 不为 0,至少还要 1 枚硬币,则继续搜索得到的答案不会更优,就应该break出该层循环。因为少用几枚大(面值)硬币搜索出的方案(即使有)肯定需要更多枚小硬币来弥补少掉的大硬币(策略1),而使用这几枚大硬币都搜索不到更好的解,用小硬币弥补即使有解,硬币个数也只会比现在的更多,故应该 break。
class Solution {
public:
int res = INT_MAX;
//idx:此时选择到coins中的下标为idx的硬币
void backtrack(vector<int> &coins, int idx, int amount, int cnt){
if(idx < 0) return;
//amount/coins[idx]为能够选择的最多的coins[idx]个数
for(int i = amount/coins[idx]; i >= 0; --i){//i即表示选了多少个该硬币
int rem_amount = amount - i * coins[idx];//剩余amount
int cur_cnt = cnt + i;//当前硬币个数
if(rem_amount == 0){
res = min(res, cur_cnt);
break;
}
if(cur_cnt + 1 > res) break;
backtrack(coins, idx - 1, rem_amount, cur_cnt);
}
}
int coinChange(vector<int>& coins, int amount) {
sort(coins.begin(), coins.end());
//一定要从后往前
backtrack(coins, coins.size()-1, amount, 0);
return res == INT_MAX ? -1 : res;
}
};
不过标准解法还是dp,转化为背包问题之后很简单。