DFS+剪枝、DP(完全背包)、BFS:零钱兑换

LeetCode 322. 零钱兑换

  • 这道题是综合性特别强的一个问题,在初次写的时候没多想,立马就暴力DFS,之后看了题解+剪枝,才通过;
  • 之后看了AcWing的背包视频,发现可以参考完全背包的思路;
  • 然后来了兴趣,认真看了下评论区和题解区,发现能够使用BFS的思路,真是妙啊!遂记录一下。

  其实总结一下发现,除了dp方法可用完全背包的思路,这题的dfs就是完全背包问题的搜索解法,推荐看一下这篇博文——背包九讲——全篇详细理解与代码实现,不仅仅是其中的完全背包问题,还有其中的附录一:背包问题的搜索解法。

关于背包问题及模板见:背包问题汇总(模板,思路,LC例题)
类似题目:139. 单词拆分


一、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

完全背包问题:有 N N N种物品和一个容量为 T T T的背包,每种物品都就可以选择任意多个(01背包每种物品要么选要么不选),第 i i i种物品的价值为 P [ i ] P[i] P[i],体积为 V [ i ] V[i] V[i],求解:选哪些物品放入背包,可使得这些物品的价值最大,并且体积总和不超过背包容量。

假设每种硬币是一个物品,每种硬币对应一种物品,每种硬币的面值对应物品的重量,且所有物品的价值都是1,求解把背包容量装满的所有方案中,价值最少的方案。是不是就是定制化的完全背包问题?

时间复杂度 O ( c o i n s . s i z e ( ) ∗ a m o u n t ) O(coins.size() * amount) O(coins.size()amount),空间复杂度使用了一维化为 O ( a m o u n t ) O(amount) O(amount).

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,...,cn1]是可选的 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,...n1minF(amountci)+1,其中 a m o u n t − c i > 0 amount - c_i > 0 amountci>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 迭代解法,自底向上

仍定义 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(i1)的答案。 则 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,...i1minF(amountcj)+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);
	//对于完全背包问题,在在初始化时除了f[0]为0其它f[1...V]均设为−∞,表示没有合法解
	//这里不取[0, amount]即可
	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

具体在纸上画一下,就知道这其实是一个图的最短路径问题,广度优先遍历是求解这一类问题的算法。广度优先遍历借助队列实现。

代码实现略…

  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值