从《零钱兑换》开始的《背包问题》

1. 零钱兑换

Leetcode里有这样一个问题,LeetCode322. 零钱兑换

“给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。计算并返回可以凑成总金额所需的最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1 。你可以认为每种硬币的数量是无限的。”

对于每一枚硬币,我们都有两种选择:选or不选。而对于amount而言,它并不关心我们选择硬币的顺序,只要x+y能都等于amount,我们先选x还是先选y是没有区别的。因此,我们可以不管硬币的选择顺序,只要能满足其和等于amount,就算一个解,而问题的答案在于,求所有能达成amount的解中,硬币数量最少的组合。

一种比较容易想到的思路是:我们每次都选择面额最大又不超过amount的硬币,就可以达到硬币数最少。这是一种贪心思路,用最快的速度达到amount,就能减少使用硬币的个数。然而,这个思路存在一个问题,即:如果amount-coin[i]之后剩余的部分没有硬币可以兑换,则会返回-1。比如,target=6,coins=[2,5],如果我们按照上面的贪心思想,先取5,剩余1,我们会发现没有可以匹配的面额。此时,我们可能会想到,“那我退一步,选2不就行了?”,这就是本题记忆化搜索解法思路的基础,但记忆化搜索考虑的是所有的可能性,而非每次只选择面额最大且不超过amount的硬币。下面开始介绍记忆化搜索的解法。

A. 记忆化搜索

1. 使用深度优先搜索(DFS),即不断的尝试,遇到失败或者一个解之后,返回上一步,继续探索别的选择。我们设置一个变量remain表示我们还需要凑多少的面额,初始化为amount。之后,我们枚举每一个硬币的面额,用remain减去选择的硬币,然后继续DFS。当remain<0时,说明遇到了一个错误的组合,直接返回;当remian==0时,说明遇到了一个可能的解,记录使用的硬币个数,并取与之前遇到的解的硬币个数中的最小值。但是,如果只是这样搜索的话,会出现大量重复搜索,导致效率大打折扣,比如,remain=5,coins=[1,2,3]。那么,如果我们枚举两次1,得到remain=3,在这个基础上继续搜索,会得到解。而之后我们枚举2的时候,remain=3,之后的搜索与我们枚举两次1后的搜索是完全一致的。因此,我们可以记录每一个remain对应的最小硬币数,当搜索遇到之前已经探索过的remain时,直接返回它的值,就能大大提高搜索效率了。

class Solution {
public:
    vector<int> visit;

    int dfs(vector<int>&coins, int remain){
        if(remain<0) return -1;
        if(remain==0) return 0;
        if(visit[remain]!=0) return visit[remain];
        int ans = INT_MAX;
        for(int i=0;i<coins.size();i++){
            int temp = dfs(coins, remain-coins[i]);
            if(temp>=0&&temp<ans) ans = temp+1;
        }
        visit[remain] = ans==INT_MAX?-1:ans;
        return visit[remain];
    }

    int coinChange(vector<int>& coins, int amount) {
        visit.resize(amount+1);
        return dfs(coins, amount);
    }
};

2. 另一种方法是使用广度优先搜索(BFS)。由于BFS是逐层遍历,因此访问到的节点一定是最短距离。我们将amount加入队列,之后逐层遍历,每一层,都代表上一层的基础上再添加一个硬币的结果。为了避免重复访问,我们设置一个数组,标记访问过的值。如果当前队首的值为0,那么我们已经找到了一个组合,且这个组合用的硬币数一定是最少的。如果队首的值非0,则继续BFS,用p依次减去每个硬币面额,如果大于等于0,则加入队列并标记,否则跳过。

class Solution {
public:

    int coinChange(vector<int>& coins, int amount) {
        queue<int> q;
        q.push(amount);
        int ans = 0;
        vector<int> visit(amount+1);
        while(!q.empty()){
            int size=q.size();
            while(size--){
                auto p = q.front();
                q.pop();
                if(p==0){
                    return ans;
                }
                for(int i=0;i<coins.size();i++){
                    if(p-coins[i]>=0&&!visit[p-coins[i]]){
                        q.push(p-coins[i]);
                        visit[p-coins[i]]=1;
                    }
                }
            }
            ans++;
        }
        return -1;
    }
};

B. 动态规划

如果说记忆化搜索是自上而下,从amount出发寻找0的过程,那么动态规划就是从0出发,一步一步走到amount的自下而上的过程。动态规划总是从一个小问题出发,将多个小问题汇总,最后凑成答案的解。对于amount,有太多的可能,我们可以思考其前一步,假设我们还差一枚硬币coins[i],总和就能到达amount,那么到达amount所需要的硬币数为达到amount-coins[i]所需要的硬币数+1,我们枚举每一个面额的硬币作为最后一枚硬币,并从中选择硬币数最小的解,由此,我们获得了状态转移方程:

dp[i] = min(dp[i], dp[ i-coins[j] ]+1),j=0~n-1

由状态转移公式可得,想知道amount=i时所需要的硬币数,必须先知道amount=i-coins[j]时所需要的硬币数,因此我们从amount=1时开始枚举,从小到大计算各个amount所需要的硬币数,一步步得到最初始的大问题结果。

边界条件:当amount=0时,我们不需要任何硬币就能到达,因此dp[0]=0。因为我们取最小值,所以dp需要初始化为足够大的值,比如amount+1,INT_MAX,etc...

class Solution {
public:
    int coinChange(vector<int>& coins, int amount) {
        int n = coins.size();
        vector<int> dp(amount+1,amount+1);
        dp[0]=0;
        for(int i=1;i<=amount;i++){
            for(int j=0;j<n;j++){
                if(coins[j]<=i){
                    dp[i] = min(dp[i],dp[i-coins[j]]+1);
                }
            }
        }
        return dp[amount]==amount+1?-1:dp[amount];
    }
};

2. 零钱兑换II

同样是零钱兑换,Leetcode里有另一个问题,LeetCode518. 零钱兑换II

“给你一个整数数组 coins 表示不同面额的硬币,另给一个整数 amount 表示总金额。请你计算并返回可以凑成总金额的硬币组合数。如果任何硬币组合都无法凑出总金额,返回 0 。假设每一种面额的硬币有无限个。 题目数据保证结果符合 32 位带符号整数。”

LeetCode322. 零钱兑换中,我们只需要知道达到amount最少需要多少个硬币,所以并不关心硬币的选择顺序,而在本问题中,我们不再关心最少需要多少硬币,而是达到amount的方式有多少种。只要采用的硬币面额相同,即使硬币的顺序不同,则视为同一种组合。比如【2,2,1】和【1,2,2】和【2,1,2】属于同一种组合。

如果我们采用和零钱兑换中相同的策略,即枚举每一个面额的硬币作为最后一枚硬币,然后从amount=1开始枚举总和,计算每个amount的组合数的话,会发现结果中存在着大量重复解。比如,amount=4,coins = [1,2,3],结果如下:

  • amount=1,方案:【1】
  • amount=2,方案:【1,1】,【2】
  • amount=3,方案:
    【1,1,1】,【1,2】【2,1】,【3】
  • amount=4,方案:【1,1,1,1】,【1,1,2】,【1,2,1】,【2,1,1】【1,3】,【3,1】,【2,2】

下划线的部分都是重复解,我们发现,重复解的出现是因为我们枚举一个硬币之后,又枚举了之前枚举过的硬币,比如我们使用了硬币2之后,又使用了硬币1。为了解决这个问题,我们需要限定方案中枚举硬币的顺序,在我们计算amount的过程中,只能使用一种硬币。即先枚举硬币,再枚举amount。如何理解这个过程呢?我们依然沿用上面的例子,amount=4,coins=[1,2,3]。

  • 枚举硬币1:
    • amount=1,dp[1] += dp[i-coints[j]] = dp[i-1] = dp[0] = 1,即【1】
    • amount=2,dp[2] += dp[i-coints[j]] = dp[i-1] = dp[1] = 1,
      即在dp[1]的基础上添一枚硬币1,得到:【1,1】
    • amount=3,dp[3] += dp[i-coints[j]] = dp[i-1] = dp[2] = 1,
      即在dp[2]的基础上添一枚硬币1,得到:【1,1,1】
    • amount=4,dp[4] += dp[i-coints[j]] = dp[i-1] = dp[3] = 1,
      即在dp[3]的基础上添一枚硬币1,得到:【1,1,1,1】
  • 枚举硬币2:
    • amount=1,因为coins[j]>amount,无法添加,故dp[1]=1
    • amount=2,dp[2] += dp[2-coints[j]] += dp[2-2] += dp[0] = 2,
      即在dp[0]的基础上添一枚硬币2,得到:【1,1】,【2】
    • amount=3,dp[3] += dp[3-coints[j]] += dp[3-2] += dp[1] = 2,
      即在dp[1]的基础上添一枚硬币2,得到:【1,2】,【1,1,1】
    • amount=4,dp[4] += dp[4-coints[j]] += dp[4-2] += dp[2] = 3,
      即在dp[2]的基础上添一枚硬币2,得到:【1,1,1,1】,【1,1,2】,【2,2】
  • 枚举硬币3:
    • amount=1,因为coins[j]>amount,无法添加,故dp[1]=1
    • amount=2,因为coins[j]>amount,无法添加,故dp[2]=2
    • amount=3,dp[3] += dp[3-coints[j]] += dp[3-3] += dp[0] = 3,
      即在dp[0]的基础上添一枚硬币3,得到:【1,2】,【1,1,1】,【3】
    • amount=4,dp[4] += dp[4-coints[j]] += dp[4-3] += dp[1] = 4,
      即在dp[1]的基础上添一枚硬币3,得到:【1,1,1,1】,【1,1,2】,【2,2】,【1,3】

通过观察,我们发现,通过先枚举硬币再枚举amount的策略,我们可以使得硬币加入各方案的顺序保持固定,从而避免“先加入硬币2,再加入硬币1”与“先加入硬币1,再加入硬币2”造成的重复解。

class Solution {
public:
    int change(int amount, vector<int>& coins) {
        int n = coins.size();
        vector<int> dp(amount+1,0);
        dp[0]=1;
        sort(coins.begin(),coins.end());
        for(int i=0;i<n;i++){
            for(int j=1;j<=amount;j++){
                if(coins[i]<=j){
                    dp[j]+=dp[j-coins[i]];
                }
            }
        }
        return dp[amount];
    }
};

3. 背包问题

“零钱兑换”问题有一个更为泛化的名字——完全背包问题。背包问题指的是,有一个背包,背包的称重W是有限的,现在需要从一系列物品中选取若干个放入背包内,每个物品都有自己的重量wi,价值vi,求:

  • 背包能装下的物品的最大价值和为多少?
  • 恰好能装满的方案总数?或所有的方案?或最少物品量?

根据物品的可选量分为三个大类:

  1. 完全背包问题:物品数量不限
  2. 0-1背包问题 :每个物品只有一个
  3. 多重背包问题:每个物品有有限个

A. 完全背包问题:物品数量不限的背包问题

完全背包问题是指,每个物品的数量是无限的,求在限重W内可以达到的最大价值和。对于每一个物品,有下面两种情况:

  • 当w[i]>W时,我们没法放入包内,跳过
  • 当w[i]≤W时,我们都有两种选择:放入或不放入
    • 放入,则我们达到的收益为排除物品i的重量时能达到的最大收益+第i个物品的价值,即:dp[i][W] = dp[ i ][ W-w[i] ]+v[i]
    • 不放入,则我们达到的收益为跳过物品i时能达到的最大收益,即:dp[i][W] = dp[ i-1 ][ W ]
    • 为了达到最大收益,我们取放入与不放入的收益的最大值,再取所有物品放入/不放入的最大值

由此,我们得到了状态转移方程:

dp[i][W] = max( dp[i][W], max(dp[ i ][ W-w[i] ]+v[i], dp[ i-1 ][ W ] ) )

根据状态转移方程,我们发现,求解dp[i][W]之前,我们需要先知道dp[i][W-w[i]] 和 dp[i-1][W]的结果,因此我们从限重W=1开始枚举,自下向上计算。

边界条件:dp[i][0]=0,因为限重为0时,我们不可能放入任何物品,所以价值都为0。

优化:我们发现dp[i][W]只与dp[i-1][W]和dp[i][W-w[i]]有关,因此我们可以用一维数组代替二维数组,并从1到W正向枚举限重W,并更新dp[W]:

dp[W] = max(dp[W], dp[W-w[i]]+v[i])
 

B. 0-1背包问题:每个物品唯一

0-1背包问题是指,每个物品是唯一的,求在限重W内可以到达的最大价值和。对于每个物品,有下面两种情况:

  • 当w[i]>W时,我们无法将物品放入包内,因此跳过
  • 当w[i]≤W时,我们有两种选择:放入或不放入
    • 放入,则我们达到的收益为排除物品i的重量时能达到的最大收益+第i个物品的价值,即:dp[i][W] = dp[ i-1 ][ W-w[i] ]+v[i]
       
    • 不放入,则我们达到的收益为跳过物品i时能达到的最大收益,即:dp[i][W] = dp[i-1][W]
       
    • 为了达到最大收益,我们取放入与不放入的收益的最大值,再取所有物品放入/不放入的最大值

由此,我们得到了状态转移方程:

dp[i][W] = max( dp[i][W], max(dp[ i-1 ][ W-w[i] ]+v[i], dp[ i-1 ][ W ] ) )

边界条件:dp[i][0]=0,因为限重为0时,我们不可能放入任何物品,所以价值都为0。

优化:我们发现dp[i][W]只与dp[i-1][W]和dp[i-1][W-w[i]]有关,因此我们可以用一维数组代替二维数组,并从W到1逆向枚举限重W,并更新dp[W]:

dp[W] = max(dp[W], dp[W-w[i]]+v[i])

这里会发现,0-1背包和完全背包的区别:

当我们选择装入物品时,完全背包的dp[i][W] = dp[i][W-w[i]]+v[i],而0-1背包的dp[i][W] = dp[i-1][W-w[i]]+v[i]。

这是因为0-1背包的物品唯一,如果我们选择放入背包,那么对于剩余空间W-w[i],我们只能用0~i-2号物品去填补。

而完全背包因为物品无限,即使我们选择把i号物品放入背包后,依然可以继续选择把i号物品放入背包,可选择空间为0~i-1。

C. 多重背包问题:每个物品有限

多重背包问题是指,每个物品的数量是有限的(可能是1个,也可能多于1个,每个物品的数量也可能不同),对于每个物品(数量为K),有下面这个情况:

  • k*w[i]>W,我们无法将k个i号物品放入背包,因此跳过
     
  • k*w[i]≤W,我们可以选择将k个i号物品放入/不放入背包:
    • 放入:则我们得到的最大收益就是排除k个i物品的重量时能达到的最大收益+k个i物品的收益,dp[i][W] = dp[i-1][W-k*w[i]] + k*v[i]
       
    • 不放入:则我们得到的最大收益就是跳过i号物品时能达到的最大收益,dp[i][W] = dp[i-1][W]
  • 取放入与不放入的最大收益,再取每个i物品收益的最大值

由此,我们得到了状态转移方程:

dp[i][W] = max( dp[i][W], max( dp[i-1][W-k*w[i]]+k*v[i], dp[i-1][W]) )

边界条件:dp[i][0]=0,因为限重为0时,我们不可能放入任何物品,所以价值都为0。

优化:我们发现dp[i][W]只与dp[i-1][W]和dp[i][W-w[i]]有关,因此我们可以用一维数组代替二维数组,并从W到1逆向枚举限重W从k=0到k=K正向枚举k,并更新dp[W]:

dp[W] = max(dp[W], dp[W-k*w[i]]+k*v[i])

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值