LeetCode 322. 零钱兑换
- 这道题是综合性特别强的一个问题,在初次写的时候没多想,立马就暴力DFS,之后看了题解+剪枝,才通过;
- 之后看了AcWing的背包视频,发现可以参考完全背包的思路;
- 然后来了兴趣,认真看了下评论区和题解区,发现能够使用BFS的思路,真是妙啊!遂记录一下。
一、DFS+剪枝
看到题,感觉和之前的子集、全排列、电话号码的字母组合等题目相近,二话不说直接开写。
int coinChange(vector<int>& coins, int amount) {
if(amount == 0) return 0; //如果amount为0,则最少0个硬币就可以得到
sort(coins.begin(), coins.end(), cmp); //升序排列整个coins数组
dfs(coins, 0, 0, 0, amount);
return res;
}
int res = -1;
static bool cmp(int a, int b) {return a > b;} //自定义升序比较器
// 定义规则可参照 sort函数及其元素排序方式定制:
// https://blog.csdn.net/yueguangmuyu/article/details/112713755?spm=1001.2014.3001.5501
void dfs(vector<int>& coins, int cnt, int cur, int sum, int amount) {
if(cur == coins.size()) return; //若是当前硬币的索引值大于coins数组的长度,则返回
for(int i = cur; i < coins.size(); ++i) { //从每个硬币开始作为起点进行深度搜索
if(sum + coins[i] > amount) continue; //如果当前硬币与之前的累计和大于目标面额则跳过
if(sum + coins[i] == amount) { //如果等于则说明是一种零钱兑换方法
if(res == -1) res = cnt + 1; //如果之前没有找到,则找到了第一种
else res = min(res, cnt + 1); //如果之前找到了,则比较哪个硬币数少,取较小值
}
dfs(coins, cnt + 1, i, sum + coins[i], amount); //仍选择当前面值的硬币
dfs(coins, cnt + 1, i + 1, sum + coins[i], amount); //选择之后其他面值的硬币
}
}
…然鹅,第
32
32
32个用例就给超时了,用例为[3, 7, 405, 436] 8839
。简单分析一下,
a
m
o
u
n
t
amount
amount数值相较
c
o
i
n
s
coins
coins数组中的硬币面额大很多,会导致递归层数比较大,实际上这个测试数据的结果应当是
25
25
25,则说明其递归深度肯定大于
25
25
25了。
分析一下暴力DFS的时间复杂度,每个硬币 c i c_i ci的最大数量是 ⌊ a m o u n t c i ⌋ \lfloor \frac{amount}{c_i} \rfloor ⌊ciamount⌋,暴力递归就是枚举每个硬币的数量子集,其时间复杂度为 O ( ∑ i = 0 c o i n s . s i z e ( ) − 1 a m o u n t c i ) = O ( a m o u n t n ) O(\sum_{i=0}^{coins.size()-1}\frac{amount}{c_i}) = O(amount^n) O(∑i=0coins.size()−1ciamount)=O(amountn),这个复杂度…,说明暴力DFS是不可行。
既然暴力不行,那就剪枝,抖了个激灵,贪心思想:从左到右撸硬币,面额大的硬币越多则兑换方案中的硬币数越少,那么降序排序后的
c
o
i
n
s
coins
coins数组,从最大面值的硬币出发,找到的第一个解决方案的硬币数是不是就是答案呢?感觉挺对,然后就试了一手,第
32
32
32个用例给过了,快乐提交,结果卡在了第92个用例[186, 419, 83, 408] 6249
上。说明这个贪心策略是不对的,想了老半天发现了个反例[1, 7, 10] 14
这样升序排列后
c
o
i
n
s
coins
coins数组为[10, 7, 1]
按照这样的贪心策略所得结果是
10
+
1
+
1
+
1
+
1
10+1+1+1+1
10+1+1+1+1是
5
5
5个硬币,而实际答案应该为
7
+
7
7+7
7+7是
2
2
2个硬币。这不恶心人么…
那简单运用这种贪心思想不得行,隐约觉得这道题必须把所有的结果都给遍历一遍,求最符合的兑换方案的硬币数最少的方案才行。然后看到题解【零钱兑换】贪心 + dfs = 8ms,确实这个想法是对的,依照文中思路可以避免把所有的结果都遍历一遍。把他的思路消化再复述一下。
- 上面提到的贪心思想是对的,但不能简单运用。要注意到
92
92
92用例的情况,所以最先找到的不一定是最优解,但是可以作为接下来策略的依据。既然大面值硬币越多所得兑换方案硬币数越小,那么首先丢最大个数
amount / coins[c_index]
的当前面值的硬币进去:
①若是找到了(剪枝核心),则得到的兑换方案是有这个面值硬币的兑换方案中硬币数最少的方案,也即是以最快速度找到含当前硬币的最少硬币数方案就返回;
②若是没找到,则是丢多了导致最后无法凑出 a m o u n t amount amount面额,则再回溯减少大面值硬币的数量,k--
;
有了如上思路,剪枝后的DFS代码为:
void coinChange(vector<int>& coins, int amount, int c_index, int count, int& ans)
{
if (amount == 0) {
ans = min(ans, count);
return;
}
if (c_index == coins.size()) return;
//k = amount / coins[c_index] 计算最大能投几个
//amount - k * coins[c_index] 减去扔了 k 个硬币
//count + k 加 k 个硬币
for (int k = amount / coins[c_index]; k >= 0 && k + count < ans; k--) {
//第一次先投k个c_index面值的硬币,之后若是凑不到amount面额,则减少k
coinChange(coins, amount - k * coins[c_index], c_index + 1, count + k, ans);
}
}
int coinChange(vector<int>& coins, int amount) {
if (amount == 0) return 0;
sort(coins.rbegin(), coins.rend()); //使用逆向迭代器可以实现对vector的从大到小排列
int ans = INT_MAX;
coinChange(coins, amount, 0, 0, ans);
return ans == INT_MAX ? -1 : ans;
}
二、动态规划DP
2.1 记忆化递归,自顶向下
上图是暴力DFS的过程,以[1,2,3] 6
为例。可以发现递归树中,我们可以看到许多子问题被多次计算。例如,
F
(
1
)
F(1)
F(1)被计算了
13
13
13次。为了避免重复的计算,我们将每个子问题的答案存在一个数组中进行记忆化,如果下次还要计算这个问题的值直接从数组中取出返回即可,这样能保证每个子问题最多只被计算一次。
对于该问题,设 F ( a m o u n t ) F(amount) F(amount)是组成金额 a m o u n t amount amount所需要的最少硬币数, [ c 0 , c 1 , . . . , c n − 1 ] [c_0, c_1, ..., c_{n-1}] [c0,c1,...,cn−1]是可选的 n n n枚硬币面值, n = c o i n s . s i z e ( ) n=coins.size() n=coins.size()。则有:
转移方程:
F
(
a
m
o
u
n
t
)
=
m
i
n
i
=
0
,
1
,
.
.
.
n
−
1
F
(
a
m
o
u
n
t
−
c
i
)
+
1
F(amount) = \mathop{min} \limits_{i=0,1,...n-1} F(amount-c_i) + 1
F(amount)=i=0,1,...n−1minF(amount−ci)+1,其中
a
m
o
u
n
t
−
c
i
>
0
amount - c_i > 0
amount−ci>0
Base Case:
F
(
a
m
o
u
n
t
)
=
0
,
当
a
m
o
u
n
t
=
0
;
F
(
a
m
o
u
n
t
)
=
−
1
,
当
a
m
o
u
n
t
<
0
;
F(amount) = 0, 当amount = 0; F(amount) = -1, 当amount < 0;
F(amount)=0,当amount=0;F(amount)=−1,当amount<0;
实现代码如下:
int coinChange(vector<int>& coins, int amount) {
if(amount < 1) return 0;
count.resize(amount);
return dp(coins, amount);
}
vector<int> count;
int dp(vector<int>& coins, int remain) {
if(remain < 0) return -1;
if(remain == 0) return 0;
if(count[remain - 1] != 0) return count[remain - 1];
int Min = INT_MAX;
for(int coin : coins) {
int res = dp(coins, remain - coin);
if(res >= 0 && res < Min) Min = res + 1;
}
count[remain - 1] = Min == INT_MAX ? -1 : Min;
return count[remain - 1];
}
2.2 动态规划(完全背包),自底向上
完全背包问题:有 N N N种物品和一个容量为 T T T的背包,每种物品都就可以选择任意多个(01背包每种物品要么选要么不选),第 i i i种物品的价值为 P [ i ] P[i] P[i],体积为 V [ i ] V[i] V[i],求解:选哪些物品放入背包,可使得这些物品的价值最大,并且体积总和不超过背包容量。
假设每种硬币是一个物品,硬币的面值对应物品的种类,求解如何把背包容量装满的所有方案中,物品数目最少的方案。是不是和完全背包相似?
仍定义 F ( i ) F(i) F(i) 为组成金额 i i i 所需最少的硬币数量,假设在计算 F ( i ) F(i) F(i) 之前,我们已经计算出 F ( 0 ) − F ( i − 1 ) F(0) - F(i-1) F(0)−F(i−1)的答案。 则 F ( i ) F(i) F(i) 对应的:
转移方程:
F
(
i
)
=
m
i
n
j
=
0
,
.
.
.
i
−
1
F
(
a
m
o
u
n
t
−
c
j
)
+
1
F(i) = \mathop{min} \limits_{j=0,...i-1} F(amount-c_j) + 1
F(i)=j=0,...i−1minF(amount−cj)+1
Base Case:
F
(
0
)
=
0
F(0) = 0
F(0)=0
实现代码如下:
int coinChange(vector<int>& coins, int amount) {
vector<int> dp = vector<int>(amount + 1, amount + 1);
dp[0] = 0;
for(int cur_amount = 1; cur_amount <= amount; ++cur_amount) {
// for (int j = 0; j < (int)coins.size(); ++j)
// if (coins[j] <= i) {
// dp[i] = min(dp[i], dp[i - coins[j]] + 1);
for(int coin : coins) {
if(cur_amount >= coin)
dp[cur_amount] = min(dp[cur_amount - coin] + 1, dp[cur_amount]);
}
}
return dp[amount] > amount ? -1 : dp[amount];
}
三、BFS
具体在纸上画一下,就知道这其实是一个图的最短路径问题,广度优先遍历是求解这一类问题的算法。广度优先遍历借助队列实现。
代码实现略…