01背包与完全背包

13 阅读15分钟

01背包

情景:有n件物品和一个容量为bagweight的背包,第i件物品的重量为weight[i],其价值为value[i],每件物品只能用一次。求装入背包的物品的最大价值总和。

暴力解法:每个物品要么被选,要么不被选。使用回溯即可。时间复杂度为O(2^n)。

因此我们使用动态规划进行优化。先明确dp数组的维度及其含义。由于要考虑物品及其重量,因此先使用二维数组。dp[i][j]中,i表示物品编号,j表示背包容量,dp[i][j]表示使用容量为j的背包,从下标为0-i的物品里任意取,所能得到的最大价值总和。

接着,确定递推公式。

以推导dp[2][3]为例:

dp[2][3]的意思是:使用容量为3的背包,从下标为0、1、2的物品里任意取,所能得到的最大价值总和。那么要求解它,就有两种情况:放入物品2、不放入物品2.

如果不放入物品2,那么这个物品2有没有都不会产生影响,因此dp[2][3]=dp[1][3].

如果放入物品2,那么就必须要给物品2留出足够的空间。如果物品2的重量已经超过了3,那么这个背包是不能放入物品2的。如果没有超过3,假设它的重量是2,那么对于容量为3的背包来说,放入物品2后还剩的容量为1,所以只需要看容量为1的背包从下标为0、1的物品里面取,所能得到的最大价值总和是多少就可以了。此时dp[2][3]=dp[1][1]+value[2].

抽象来看,求解dp[i][j]的过程为:

不放入物品i:物品i没发挥作用,dp[i][j]=dp[i-1][j].

放入物品i:要给它留位置,放入物品i后背包容量为j-weight[i],因此dp[i][j]=dp[i-1][j-weight[i]]+value[i].

两者取最大值即可。

然后看如何初始化dp数组(因为i是从i-1推导出来,因此必须要初始化):

dp[i][0]:背包容量为0,因此不能放入任何物品,所以其值初始化为0 .

dp[0][j]:只能选择物品0,那么就需要判断j是否不小于weight[0],如果小于就不选择,其值初始化为0,反之初始化为value[0] .

由递推公式可见,其它下标都可以从左上方数值推导出来,因此其它位置的元素默认初始化为0即可。

所以,初始化工作实际上只需要对第0行进行处理即可,其它都初始化为0 .

再看dp数组的遍历顺序:

是先遍历物品还是先遍历背包?其实都可以,因为要么从正上方推导出结果,要么从左上方推导出结果。

先遍历物品再遍历背包相当于先定下来列,再一行一行去填,代码如下:

for(int i=1;i<weight.size();i++) {
    for(int j=0;j<=bagweight;j++) {
        if (j<weight[i]) dp[i][j]=dp[i-1][j];
        else dp[i][j]=max(dp[i-1][j],dp[i-1][j-weight[i]]+value[i]);
    }
}

反之,先遍历背包再遍历物品相当于先定下来行,再一列一列去填,代码如下:

for(int j=0;j<=bagweight;j++) {
    for(int i=1;i<weight.size();i++) {
        //同上
    }
}

滚动数组

从二维数组的递推公式可以看到,dp[i][j]只和dp[i-1][j]以及dp[i-1][j-weight[i]]有关系,而上一行(即i-1)又可以通过滚动数组拷贝,因此考虑优化为一维数组。

优化后的递推公式为:dp[j]=max(dp[j-weight[i]]+value[i],dp[j]) .

理解:括号内的dp[j]是上一行的dp[j],相当于是dp[i-1][j].

接着看如何初始化这个一维数组。dp[j]表示:容量为j的背包所能容纳物品的最大价值,那么dp[0]=0,而其它位置都会由前面位置的值推导出来,因此只需要将每个位置都初始化为0即可。

再看遍历顺序:先遍历物品再倒序遍历背包容量。

如果使用滚动数组,那么内层循环就必须要倒序遍历。如果是正序遍历,同一个物品可能会被放入多次。因为我们使用的是滚动数组,每一次更新dp数组,都是看是否要将物品i加入背包中,所以如果前面有一个容量的背包已经加入了物品i,那么后面的位置再判断是否要将物品i加入背包就已经没有意义了。而倒序遍历就避免了这个问题,因为dp[j]是依赖前面的元素求出来的。

举个例子:weight[0]=1,value[0]=20

如果正序遍历,那么在第一轮遍历中,dp[1]=dp[1-weight[0]]+value[0]=20

dp[2]=dp[2-weight[0]]+value[0]=40

但dp[2]的含义是:选择物品0,在容量为2的背包中所能容纳的最大价值。很显然只能选择物品0,最大价值应该是20,但这里因为算多了一次,所以变成了40.

通过倒序遍历,我们才真正找到了dp[i-1][j-weight[i]],因为要看上一行的元素,因此就不能先覆盖掉它。

如果先遍历背包容量再遍历物品,那么代码应该是这样的:

for(int j=bagweight;j>=1;j--) {
    for(int i=0;i<weight.size();i++) {
        if(j>=weight[i]) dp[j]=max(dp[j],dp[j-weight[i]]+value[i]);
    }
}

假设bagweight=5,有三个物品,重量分别为1、2、3,价值分别为1、2、3:

一开始,j=5,进入内层循环:

i=0,j>=weight[0]=1,dp[5]=dp[5-1]+value[0]=0+1=1

i=1,j>=weight[1]=2,dp[5]=max(dp[5],dp[5-2]+2)=2

走到这里我们就发现不对了,明明容量为5的背包是可以同时放入重量分别为1和2的物品的,总价值应该为3,但是i=1时却为2.

如果是先遍历物品,再遍历背包容量:

for(int i=0;i<weight.size();i++) {
    for(int j=bagweight;j>=weight[i];j--) {
        dp[j]=max(dp[j],dp[j-weight[i]]+value[i]);
    }
}

还是假设bagweight=5,有三个物品,重量分别为1、2、3,价值分别为1、2、3:

第一轮,i=0,进入内层循环

j=5,dp[5]=dp[4]+1=1.

j=4,dp[4]=dp[3]+1=1.

以此类推,这一轮除了dp[0]=0之外,其它都为1,因为只能选择物品0,选了肯定比不选的价值高

第二轮,i=1,进入内层循环

j=5,dp[5]=max(1,dp[3]+2)=3

可以看到,这时候背包是真正放入了物品0和物品1

抽象地说,因为必须使用倒序遍历,所以如果是先遍历背包容量再遍历物品,那么由于前面的背包都还没被处理过,所以相当于你每次只能放入一个物品。

因此,必须要先遍历物品,再遍历背包容量。

完全背包

情景:有N件物品和一个容量为bagweight的背包,第i件物品的重量是weight[i],其价值为value[i],每件物品有无限个(即可以多次放入背包),求背包容纳的物品的最大价值总和。

dp数组的含义和01背包是一样的。直接看递推公式:

欲求dp[i][j],有两种情况:

1.不选物品i,那么其值等于dp[i-1][j]

2.选物品i,空出物品i的重量后,背包容量还剩j-weight[i],由于物品可以多次放入背包中,因此空出容量后背包内可能还有物品1,所以dp[i][j]=dp[i][j-weight[i]]+value[i]

两者取最大的那个即可。

再看如何初始化:

dp[i][0]一定是0,因为背包容量为0,什么都放不了。

对于dp[0][j],其含义是:存放物品0时,各个容量的背包所能存放的最大价值。那么当j<weight[0]时,dp[0][j]=0,因为容量不足以放入一个物品0.当j>=weight[0]时,就一直往背包里装入物品0,直到不能再放入为止。

至于其它位置,同01背包初始化dp数组,直接初始化为0即可,因为都是从左上或正上推出来的。

初始化代码如下:

vector<vector<int>>dp(weight.size(),vector<int>(bagweight+1,0));
​
for(int j=weight[0];j<=bagweight;j++) {
    dp[0][j]=dp[0][j-weight[0]]+value[0];
}

遍历顺序也是既可以先遍历背包再遍历物品,也可以先遍历物品再遍历背包。

滚动数组

使用二维数组的递推公式为:dp[i][j]=max(dp[i-1][j],dp[i][j-weight[i]]+value[i])

压缩为一维数组后,递推公式为:dp[j]=max(dp[j],dp[j-weight[i]]+value[i])

上文说过,在01背包中,使用一维数组时,之所以要先遍历物品再遍历背包,是因为必须要倒序遍历背包。之所以要倒序遍历背包,是因为要避免物体重复被放入。而在完全背包中,物体是可以多次放入的,所以不必倒序遍历背包,因此对遍历顺序也没有严格的限制。

for(int i=0;i<weight.size();i++) {
    for(int j=0;j<=bagweight;j++) {
        if(j>=weight[i]) dp[j]=max(dp[j],dp[j-weight[i]]+value[i]);
    }
}

当然也可以倒序遍历,这样就不需要if判断了。

题目1

题目链接:416. 分割等和子集 - 力扣(LeetCode)

给你一个 只包含正整数非空 数组 nums 。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。

示例 1:

输入:nums = [1,5,11,5]
输出:true
解释:数组可以分割成 [1, 5, 5][11]

示例 2:

输入:nums = [1,2,3,5]
输出:false
解释:数组不能分割成两个元素和相等的子集。

提示:

  • 1 <= nums.length <= 200
  • 1 <= nums[i] <= 100

此题显然是可以使用回溯法的,但是太慢了。

实际上,我们只需要找到总和为sum/2的子集即可。也就是说,我们现在有一个容量为sum/2的背包,我们只需要关注是否存在这么一堆数字能够将这个背包装满。这里数字就是物品,物品的价值和重量都是数字的值。

那么我们还是要求背包所能容纳的最大价值,然后看这个最大价值是否等于背包容量即可。这就基本回到了前面的模板。

1.确定dp数组的含义

dp[j]表示:容量为j的背包所能容纳物品的最大价值。

当dp[k]==k时,背包装满。

2.确定递推公式

dp[j]=max(dp[j],dp[j-weight[i]]+value[i])

这里weight[i]==value[i]==nums[i],所以:

dp[j]=max(dp[j],dp[j-nums[i]]+nums[i])

3.初始化

值初始化为0即可。sum最大为20000,因此大小初始化为10005即可。

4.确定遍历顺序

由前面的模板可知,这里要先遍历物品,再倒序遍历背包。

代码如下:

class Solution {
public:
    bool canPartition(vector<int>& nums) {
        int sum=accumulate(nums.begin(),nums.end(),0);
        if(sum%2==1) return false;
        int target=sum/2;
        vector<int>dp(10005,0);
        for(int i=0;i<nums.size();i++) {
            for(int j=target;j>=nums[i];j--) {
                dp[j]=max(dp[j],dp[j-nums[i]]+nums[i]);
            }
        }
        return dp[target]==target;
    }
};

可见,01背包除了可以求最大价值,还可以用来判断是否装满。

题目2

题目链接:518. 零钱兑换 II - 力扣(LeetCode)

给你一个整数数组 coins 表示不同面额的硬币,另给一个整数 amount 表示总金额。

请你计算并返回可以凑成总金额的硬币组合数。如果任何硬币组合都无法凑出总金额,返回 0

假设每一种面额的硬币有无限个。

题目数据保证结果符合 32 位带符号整数。

示例 1:

输入:amount = 5, coins = [1, 2, 5]
输出:4
解释:有四种方式可以凑成总金额:
5=5
5=2+2+1
5=2+1+1+1
5=1+1+1+1+1

示例 2:

输入:amount = 3, coins = [2]
输出:0
解释:只用面额 2 的硬币不能凑成总金额 3 。

示例 3:

输入:amount = 10, coins = [10] 
输出:1

提示:

  • 1 <= coins.length <= 300
  • 1 <= coins[i] <= 5000
  • coins 中的所有值 互不相同
  • 0 <= amount <= 5000

这和上一题很像,都是处理给出一个目标数和一些数值,问能否找到一堆数值使得其和为这个目标数。典型的背包问题。只不过这题是完全背包,一个数可以多次放入背包。

确定数组含义:dp[i][j]表示使用硬币0-i凑成总金额j的方式数量

突然觉得这题和爬楼梯基本上一样。爬楼梯是一次只能爬1个台阶或2个台阶,这里每次能爬的台阶数是coins数组里面的元素值,终点就是amount,问你有几种爬楼梯方式。

如果是只能爬1个或2个台阶,那么dp[n]=dp[n-1]+dp[n-2],而这里每次能爬的台阶数是一个数组,我们可以遍历它:

for(int j=1;j<=amount;j++) {
    for(int i=0;i<coins.size();i++) {
        if(j>=i) dp[j]+=dp[j-coins[i]];
    }
}

但是不要忘记了,如果amount=3,那么先2再1,和先1再2是两种不同的答案,也就是说,爬楼梯求的是排列数。而此题要求的是组合数。

不过这个联想也给予了我一点启发,欲求dp[i][j],那么要分两种情况:一种是使用硬币0-i已经凑出了j-coins[i],另一种是使用0-(i-1)就已经凑出了j .

我们还是以爬楼梯为例,你要爬14个台阶,每次可以爬1、2、3、4个台阶,前者是你爬了10个台阶,你只需要再爬4个台阶即可。后者是你爬了3+3+3+3+2个台阶,根本不需要一次爬4个台阶就已经爬到14个台阶了。

所以递推公式为:dp[i][j]=dp[i-1][j]+dp[i][j-coins[i]].

显然j还要卡一下范围。如果j小于coins[i],即你也许可以一次爬1、2、3、4个台阶,但现在你只需要爬到第2个台阶即可。那么dp[4][2]=dp[3][2],而你也不需要一次爬3个台阶,所以dp[3][2]=dp[2][2].

确定了递推公式后,再进行初始化。可见我们是从正上方和左侧推导出结果的,所以还是要初始化第一行和第一列。

第一行是dp[0][j],表示只使用硬币0凑出金额的方法数,那么如果j是coins[0]的倍数,方法数就为1,如果不是,方法数就为0.

第一列是dp[i][0],表示使用硬币0-i凑出金额0的方法数,显然为1,因为你可以什么硬币都不用。这么说似乎有点草率,还是以爬楼梯为例子。你可以一次爬1、2、3、4个台阶,现在需要求你爬4个台阶的方法数。如果i=4,那么j-coins[i]就是0,而你是可以一次爬4个台阶的。也就是说,如果第一列dp[i][0]初始化为0了,那么就相当于漏掉了一个硬币装满背包的情况。

这里遍历顺序无所谓,因为使用的是二维数组。

代码如下:

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

溢出了两次,一开始写的int,后面改成long long还不行,需要用uint64_t,因为可能数值加起来比较大。

考虑使用一维数组进行优化:

确定数组含义:dp[j]表示凑出金额j的方法总数

根据二维数组的递推公式,一维的递推公式可以写成:dp[j]+=dp[j-coins[i]]

初始化:dp[0]表示凑出金额0的方法总数,同上理解,初始化为1.

遍历顺序:我们假设coins={1,2},amount=3

如果先遍历硬币,再遍历背包容量:

for(int i=0;i<coins.size();i++) {
    for(int j=coins[i];j<=amount;j++) {
        dp[j]+=dp[j-coins[i]];
    }
}

第一轮i=0,coins[i]=1:

j=1,dp[1]+=dp[0],结果为1

j=2,dp[2]+=dp[1],结果为1

j=3,dp[3]+=dp[2],结果为1

都为1,因为只能使用面额为1的硬币

第二轮i=1,coins[i]=2: j=2,dp[2]+=dp[0],结果为2

j=3,dp[3]+=dp[1],结果为2

如果先遍历背包容量,再遍历硬币:

for(int j=0;j<=amount;j++) {
    for(int i=0;i<coins.size();i++) {
        if(j>=coins[i]) dp[j]+=dp[j-coins[i]];
    }
}

第一轮j=0:

由于j<coins[i],所以这一轮无效

第二轮j=1:

i=0,coins[i]=1,dp[1]+=dp[0],结果为1

i=1,保持不变

第三轮j=2:

i=0,coins[i]=1,dp[2]+=dp[1],结果为1

i=1,coins[i]=2,dp[2]+=dp[0],结果为2

第四轮j=3:

i=0,dp[3]+=dp[2],结果为2

i=1,dp[3]+=dp[1],结果为3

显然后者就变成了爬楼梯。可见,如果是先遍历背包容量,再遍历硬币,算的就是排列数而不是组合数了。

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