背包问题
背包问题一般有以下几类:
掌握01背包和完全背包即可。
先理解01背包。完全背包可以看作是01背包问题的变形。
01背包
什么是01背包问题?
有n件物品和一个最多能背重量为w的背包。第i件物品的重量是weight[i],得到的价值是value[i]。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。
举这样一个例子:
背包的最大重量为4。
物品为:
问:背包能背的物品最大价值是多少?
01背包 二维
我们依旧使用动态规划的五部曲来分析。
1、确定dp数组以及下标的含义。
定义一个dp[i][j]
这里直接写了dp[i][j],不明白为什么能这么写?是怎么想出来要这么定义的?为什么会有一个j?
该数组的含义是什么?
含义:任取下标为[0,i]之间的物品放入容量为j的背包里
或者可以理解为:
dp[i][j]表示背包容量为 j 时,从下标0至i的物品中选取,可以获得的最大价值。
2、确定递推公式
我们要思考,dp[i][j]
这个结果可以从哪里得到?
背包的状态取决于放不放物品i。
对于任意一个物品,都只有两种状态,放和不放,物品i同样如此。
(1)不放物品i
不放物品i,从前i-1个物品中就得到了最优解。
即:背包容量为 j 时,从下标0至i-1的物品中选取,就能获得最大价值。
此时结果为:dp[i-1][j]
(2)放物品i
先写出表达式:
dp[i-1][j-weight[i]]+value[i]
其中,weights[i]表示第i个物品的重量,value[i]是第i个物品的价值。
我们现在要放物品i。因为要放物品i,那就不需要再遍历到i了。因为i已经确定要放入了,相当于一个前提条件,只需要从剩下的i-1个物品中再选即可,所以不需要遍历到i,只需要遍历到i-1。即任取物品的范围为[0, i-1]。
这种情况下物品i已经放入了背包中,背包的容量也要发生变化。此时我们要求的应该是已经放入物品i之后,剩余的重量还能放多少。因此背包的重量为j-weight[i]。
就有表达式:dp[i-1][j-weight[i]]
含义是:在背包容量为j-weight[i]的情况下,从下标为[0,i-1]的物品中任意选取,得到的最大价值。
dp[i][j]是从0至i的物品中选取,现在物品i已经放进去了,就要包括物品i的价值,因此要加上物品i的价值。
综上分析,就可以得到表达式:
dp[i-1][j-weight[i]]+value[i]
针对于情况(1)和情况(2),我们最终求的结果是最大的价值,因此最终的结果应该是两种情况中取得的最大值,谁的结果大就选哪一种情况。
因此递推公式为:
dp[i][j]=max(dp[i-1][j],dp[i-1][j-weight[i]]+value[i])
3、初始化dp数组
对于这一部分,我是看了另外一个博主的文章讲解,这里就不写了。直接去看该博主的原文即可。
参考链接: https://www.cnblogs.com/DAYceng/p/17258797.html
4、确定遍历顺序
有两个遍历的维度:物品与背包重量。
先遍历谁都行。
先遍历物品再遍历背包重量更简单。
(1)先遍历物品再遍历背包
方向就是从左向右
固定物品0,去遍历背包,看看能不能放下?最大价值是多少。
只有背包容量为0的时候放不下,最大价值为0;背包容量1,2,3,4的时候都能放下物品0,最大价值均为15;
再固定物品1,去遍历背包。背包容量为0,1,2的时候放不下物品1,最大价值不变。背包容量为3,物品1可以替换原来的物品0,最大价值由原来的15变成了20。背包容量为4,这个时候物品0和物品1都可以放下,最大价值就更新为20+15=35。
其他位置的遍历分析同理。
(2)先遍历背包再遍历物品
方向就是从上到下
固定背包容量为0。所有的物品都装不下,最大价值为0。
固定背包容量为1。物品0可以装下,物品1和物品2都装不下,因此背包容量为1的时候最大价值为15。
固定背包容量为2。物品0可以装下,物品1和物品2都装不下,因此背包容量为1的时候最大价值为15。
固定背包容量为3。遍历到物品0,可以装下,此时最大价值为15;再遍历到物品1,发现物品1可以装下,就把物品0替换为物品1,最大价值也由15变成了20;遍历到物品2,无法装下,此时的最大价值还是20.
其他位置的遍历分析同理。
5、举例推导dp数组
如下图所示:
最优解(最大价值)是dp[2][4]。
代码实现
void test_2_wei_bag_problem1() {
vector<int> weight = {1, 3, 4};
vector<int> value = {15, 20, 30};
int bagweight = 4;
// 二维数组
vector<vector<int>> dp(weight.size(), vector<int>(bagweight + 1, 0));
// 初始化
for (int j = weight[0]; j <= bagweight; j++) {
dp[0][j] = value[0];
}
// weight数组的大小 就是物品个数
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]);
}
}
cout << dp[weight.size() - 1][bagweight] << endl;
}
int main() {
test_2_wei_bag_problem1();
}
01背包 一维滚动数组
动态规划五部曲。
1、确定dp数组以及下标的含义。
d[j]:容量为j的背包,所背的物品的最大价值为dp[j]
2、确定递归公式
与二维时的情况类似,也分为放入物品i和不放入物品i这两种情况。
(1)不放入物品i
二维表达式为:dp[i-1][j]
一维表达式:dp[j]
dp[j]就还是取上一层自身的值
(2)放入物品i
二维表达式:
dp[i-1][j-weight[i]]+value[i]
一维表达式:
d[j-weight[i]]+value[i]
综上,递推公式为:
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
相对于二维dp数组的写法,就是把dp[i][j]中i的维度去掉了。
3、初始化dp数组
背包容量为0,则背包内的最大价值为0。
即dp[0]=0
其他位置如何初始化?
根据递推公式,我们总是去取最大值。因此,如果题目给的物品值均为正数,那dp[0]以外的位置应该初始化为0,这样才可以保证在递推过程中,判断累加所得的最大值不会被初始值覆盖
举个例子,当j移动到5处时,即dp[5],如果此前所背物品价值累加为10,而当前dp[5]的初始值是100,就会把之前的值覆盖掉。
所以,在创建dp数组的时候,把所有的元素都初始化为0就行。
4、确定遍历顺序
一维的遍历顺序和二维的有很大的区别。
一维的遍历,需要先遍历物品再遍历背包,同时遍历背包需要倒序遍历。
为什么需要倒序遍历?
倒序遍历是为了保证物品只被放入了一次。
比如文章开头的例子:
物品0的重量为weight[0] = 1,价值value[0] = 15
如果使用正序遍历:
dp[0] = 0;---初始化是0
dp[1] = dp[1 - weight[0]] + value[0] = 15;
dp[2] = dp[2 - weight[0]] + value[0] = 30;
当j为1时,表示容量为1,此时能够放下一个物品0,根据递推公式我们应该让 dp[j]等于dp[j - weight[i]] + value[i],即需要放入物品,因此有了上述式子。
当j为2时,容量为2,根据递推公式此时确实需要放入物品,因为当前层容量够。
但是,由于遍历顺序是正序遍历,在计算dp[2]时会把dp[1]的结果累加进来,这显然是错误的,因为每个物品只能放一次。
所以正序遍历有问题。
如果使用倒序遍历:
dp[2] = dp[2 - weight[0]] + value[0] = 15;
dp[1] = dp[1 - weight[0]] + value[0] = 15;
dp[0] = 0;---初始化是0
结果是正常的。
个人理解是后面的结果需要前面的原值来更新,后序遍历可以保证前面的值不变,前序遍历会让前面的值变。
5、举例推导dp数组
如下图所示:
代码实现
void test_1_wei_bag_problem() {
vector<int> weight = {1, 3, 4};
vector<int> value = {15, 20, 30};
int bagWeight = 4;
// 初始化
vector<int> dp(bagWeight + 1, 0);
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]);
}
}
cout << dp[bagWeight] << endl;
}
int main() {
test_1_wei_bag_problem();
}
题目:416. 分割等和子集
题目描述
给你一个只包含正整数的非空数组nums。
请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
题目链接/讲解链接:
https://programmercarl.com/0416.%E5%88%86%E5%89%B2%E7%AD%89%E5%92%8C%E5%AD%90%E9%9B%86.html
思路
解题
class Solution {
public:
bool canPartition(vector<int>& nums) {
int sum = 0;
// dp[i]中的i表示背包内总和
// 题目中说:每个数组中的元素不会超过 100,数组的大小不会超过 200
// 总和不会大于20000,背包最大只需要其中一半,所以10001大小就可以了
vector<int> dp(10001, 0);
for (int i = 0; i < nums.size(); i++) {
sum += nums[i];
}
// 也可以使用库函数一步求和
// int sum = accumulate(nums.begin(), nums.end(), 0);
if (sum % 2 == 1) return false;
int target = sum / 2;
// 开始 01背包
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]);
}
}
// 集合中的元素正好可以凑成总和target
if (dp[target] == target) return true;
return false;
}
};