昨天写了解决动态规划的方法,今天写动态规划的经典问题——背包问题中的01背包问题。
什么是01背包问题
有N件物品和一个容量为W的背包,第i件物品的重量为weight[i],价值为value[i],每件物品只能用一次,将哪些物品装入背包使得价值总和最大。
示例:
weight = [1, 3, 4]
value = [15, 20, 30]
bagsize = 4
动态规划五部曲分析(不记得五部曲的朋友们请看如何解决动态规划问题_weixin_46388493的博客-CSDN博客):
确定dp数组及下标含义
dp[i][j]表示此时背包已选择性放入[0, i]的物品,容量为j时的最大价值
递推公式
如果不放入第i个物品,dp[i][j] = dp[i - 1][j]
如果放入第i个物品,dp[i][j] = dp[i - 1][j - weight[i]] + value[i]
综上,递推公式为dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i])
疑问1:为什么这里是j - weight[i]?第i个物品要能够放进去,背包容量不应该是j + weight[i]吗?
答:这个问题是对dp[i][j]的来源及含义没有想清楚。dp[i - 1][j - weight[i]]表示背包容量为j - weight[i],放入[0, i - 1]物品的最大价值,加上value[i],就是放入i后的背包容量减去第i个物品相应重量的最大价值。而且j + weight[i]很容易超过背包的最大容量吧,会出现下标越界的情况。
疑问2:为什么整数拆分的递推公式为dp[i] = max(dp[i], max((i - j) * j, dp[i - j] * j)),这里不用与dp[i][j]比较最大值?(回顾整数拆分,见如何解决动态规划问题_weixin_46388493的博客-CSDN博客)
答:注意整数拆分是相乘,这里是相加。比如4可以拆分为1和3、2和2、3和1,2和2的乘积是最大的,保存在dp[i]中。如果不与dp[i]比较大小的话,3和1可能就会将前面2和2的结果覆盖掉。而这里是相加,dp数组中左上角的值一定比右下角的值小,所以不需要与自身比较。即,不可能出现j变大,结果反而更小的情况。
初始化
根据递推关系,dp[i][j]是由其左上角的结果决定的。所以,dp[0][j]和dp[i][0]需要初始化,其他结果可以由递推公式得到。
dp[i][0]由于背包容量为0,不能放入物品,最大价值自然为0
dp[0][j]则分两种情况:
如果j小于weight[0],不能放入0号物品,dp[0][j] = 0
如果j大于等于weight[0],可以放入0号物品,dp[0][j] = value[0]
遍历顺序
先遍历物品再遍历背包,或者先遍历背包再遍历物品都可以。对于dp数组来说,先遍历物品就是先从左边找再向下找,先遍历背包
int bag_problem1(vector<int> weight, vector<int> value, int bagsize){
vector<vector<int>> dp(weight.size(), vector<int>(bagsize + 1, 0));
for(int j = 0; j <= bagsize; j++){
if(j >= weight[0])
dp[0][j] = value[0];
}
for(int i = 1; i < weight.size(); i++){ //先遍历物品再遍历背包
for(int j = 1; j <= bagsize; j++){
if(j >= weight[i])
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
}
}
return dp[weight.size() - 1][bagsize];
}
滚动数组
就是将二维的dp数组降为一维dp数组。需要满足的条件是上一层数组可以重复利用,直接拷贝到当前层。01背包使用二维数组的递推公式为dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]),将dp[i - 1][j]这一行的内容拷贝到dp[i][j]上,递推公式可以改为dp[i][j] = max(dp[i][j], dp[i][j - weight[i]] + value[i]),这时我们可以发现对于dp数组来说,i是没有使用的,所以可以降为一维数组。
动态规划五部曲分析:
dp数组及下标的含义
dp[j]表示容量为j的背包能够装物品的最大价值
递推公式
dp[j] = max(dp[j], dp[j - weight[i]] + value[i])
初始化
背包容量为0时,dp[0] = 0
其余下标的数组也初始化为0,因为物品的价值非负数,初始化为0可以避免max()的结果被初始值影响
遍历顺序(重点!!!与二维数组不同)
必须先遍历物品,再遍历背包
遍历背包时,必须从大到小
疑问1:为什么遍历顺序必须先物品再背包?
答:先背包后物品的代码如下:
for(int j = bagsize; j > 0; j--){
for(int i = 0; i < weight.size(); i++){
if(j >= weight[i])
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
j等于bagsize时,i = 0,dp[j] = value[0];i = 1,dp[j] = max(value[0], value[1]);i = 2,dp[j] = 物品0、1、2中的价值最大值
综上,我们可以看到先背包后物品,背包内就只能放一件物品。
疑问2:为什么遍历背包需要从大到小?
答:01背包要求所有物品只能用一次。从大到小,此时dp[j - weight[i]]还是初始值0;从小到大,dp[j - weight[i]]已经存了非0值,即某个(些)物品价值(和),再加value[i]就可能重复放入了第i个物品。
int bag_problem2(vector<int> weight, vector<int> value, int bagsize){
vector<int> dp(bagsize + 1, 0);
for(int i = 0; i < weight.size(); i++){
for(int j = bagsize; j > 0; j--){
if(j >= weight[i])
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
return dp[bagsize];
}
例子
分割等和子集
思路:weight和value都是输入数组,bagsize是数组和的一半,数组中的数只能放一次,即01背包问题
该题比较简单,这里不再详述
最后一块石头的重量2
题目:1049. 最后一块石头的重量 II - 力扣(LeetCode)
思路:我最初的思路是严格按照题目来的,所以递归公式如下:
不放入i,dp[i][j] = dp[i - 1][j]
放入i,int res;
if(dp[i - 1][j] == weight[i]) res = 0;
else res = abs(dp[i - 1][j] - weight[i]);
综上,dp[i][j] = min(dp[i - 1][j], res)
但是,更好的思路是先将石头分为两堆,再粉碎。最后一块石头的重量就是两堆石头的重量之差。让最后一块石头的重量最小的方法就是让两堆石头的重量尽可能相等,就转换为与分割等和子集相似的思路。
目标和
思路:这还是和分割等和子集类似,设左侧子集和为left,集合总和为sum,则由题意可得,left - (sum - left) = S,可以得到left = (sum + S) / 2,即背包容量
本题求的是排列数,递推公式有些不同:
如果我们已知dp[3]种方法,此时我们得到一个数字2,那么我们就可以知道dp[5] = dp[3]。所以dp[j] += dp[j - nums[i])
dp[0]应初始化1,不然就是加0,dp数组全为0;其余初始化为0,最开始的方法数应该为0。
int findTargetSumWays(vector<int>& nums, int target) {
int sum = 0;
for(int i = 0; i < nums.size(); i++)
sum += nums[i];
if(target > sum)
return 0;
if((sum + target) % 2 != 0)
return 0;
int bagsize = (sum + target) / 2;
vector<int> dp(bagsize + 1, 0);
dp[0] = 1;
for(int i = 0; i < nums.size(); i++){
for(int j = bagsize; j >= nums[i]; j--){
dp[j] += dp[j - nums[i]];
}
}
return dp[bagsize];
}
一和零
题目:Loading Question... - 力扣(LeetCode)
思路:把m和n看成两个背包,dp[i][j]表示能放i个0、j个1的背包最多能够存放物品的个数。设物品含zeronum个0,onenum个1
不放入物品,dp[i][j] = dp[i][j] (因为这里的i是背包,而不是物品,所以不是dp[i - 1][j])
放入物品,dp[i][j] = dp[i - zeronum][j - onenum] + 1
综上,递推公式为dp[i][j] = max(dp[i][j], dp[i - zeronum][j - onenum] + 1)
dp数组全部初始化为0,初始状态未放入任何物品,个数均为0
两个背包遍历的先后顺序不存在差异,都是从大到小
int findMaxForm(vector<string> &strs, int m, int n){
vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));
for(int i = 0; i < strs.size(); i++){
int zeronum = 0, onenum = 0;
for(int j = 0; j < strs[i].length(); j++){
if(strs[i][j] == '0')
zeronum++;
else
onenum++;
}
for(int a = m; a >= zeronum; a--){
for(int b = n; b >= onenum; b--){
dp[a][b] = max(dp[a][b], dp[a - zeronum][b - onenum] + 1);
}
}
}
return dp[m][n];
}
多重背包
把多重背包问题放这里的原因主要有两点:
多重背包问题可以转换为01背包来处理
笔试和面试基本上不会考察多重背包问题,掌握01背包和完全背包即可
什么是多重背包问题?
有N种物品和一个容量为V的背包,第i种物品最多有Mi件可用,每件重量为weight[i],价值为value[i]。求解哪些物品装入背包,可使背包装入物品的价值最大化。
求解方式
示例:
weight = [1, 3, 4]
value = [15, 20, 30]
nums = [2, 3, 2]
将个数不为1的物品,存入weight和value数组中,使得物件物品只能存放一次
for(int i = 0; i < nums.size(); i++){
while(nums[i] > 1){
weight.push_back(weight[i]);
value.push_back(value[i]);
nums[i]--;
}
}
把每种物品遍历的个数放在01背包里面遍历一遍
for(int i = 0; i < weight.size(); i++){
for(int j = bagsize; j >= weight[i]; j--){
for(int k = 1; k < nums[i] && (j - k * weight[i]) >= 0; k++){
dp[j] = max(dp[j], dp[j - k * weight[i]] + k * value[i]);
}
}
}