01背包理论基础
先了解背包问题的区别和分类:
由于所有的问题的原理都可以转化为01背包;通过纯01背包问题,把01背包原理讲清楚;
01背包
有n件物品和一个最多能背重量为w 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。
先考虑暴力解法:
每一件物品其实只有两个状态,取或者不取,所以可以使用回溯法搜索出所有的情况,那么时间复杂度就是 o ( 2 n ) o(2^n) o(2n),这里的n表示物品数量;
举例说明:
背包最大重量为4。
物品为:
重量 | 价值 | |
---|---|---|
物品0 | 1 | 15 |
物品1 | 3 | 20 |
物品2 | 4 | 30 |
问背包能背的物品最大价值是多少?
二维dp数组实现
依然动规五部曲分析一波。
1.确定dp数组以及下标的含义:
对于01背包问题,有两种写法, 这里先使用二维数组,即dp[i][j] 表示从下标为[0-i]的物品类里选择(此题是因为每个目标只有一个的01背包,所以不用考虑这个类,涉及到多个的问题时,物品类的这个“类”就很重要了),放进容量为j的背包,其最终价值总和最大是多少;只看这个二维数组的定义,一定会很懵,看下面这个图:
要时刻记着这个dp数组的含义,下面的一些步骤都围绕这dp数组的含义进行的;
如果哪里看懵了,就来回顾一下i代表什么,j又代表什么,dp[i][j]又代表什么;
2.确定递推公式:
考虑dp[i][j]的含义:从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是多少;
可以从两个方向推出来dp[i][j]:
已经放了前0到i - 1个物品,不放物品i:由dp[i - 1][j]推出,即背包容量为j,里面不放物品i的最大价值,此时dp[i][j]就是dp[i - 1][j]。(其实就是当物品i的重量大于背包j的重量时,物品i无法放进背包中,所以背包内的价值依然和前面相同)
已经放了前0到i - 1个物品,再放物品i:由dp[i - 1][j - weight[i]]推出,dp[i - 1][j - weight[i]] 为背包容量为j - weight[i]的时候不放物品i的最大价值,那么dp[i - 1][j - weight[i]] + value[i] (物品i的价值),就是背包放物品i得到的最大价值
所以递归公式: dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
3.dp数组如何初始化
关于初始化,一定要和dp数组的定义吻合,否则到递推公式的时候就会越来越乱。
首先从dp[i][j]的定义出发,如果背包容量j为0的话,即dp[i][0],无论是选取哪些物品,背包价值总和一定为0。
如图:
所以第一列必然是全为0,因为根本放不进东西进去;
再看递推公式:
dp[i][j] = max{dp[i - 1][j], dp[i - 1][j - weight[j]] +value[i]};
可以看出,dp[i][j]要么是由正上方,要么是由左上方推出来;
分析第一行:
dp[0][j],即:i为0,存放编号0的物品的时候,各个容量的背包所能存放的最大价值。
那么很明显当 j < weight[0]的时候,dp[0][j] 应该是 0,因为背包容量比编号0的物品重量还小。
当j >= weight[0]时,dp[0][j] 应该是value[0],因为背包容量放足够放编号0物品。
初始化代码即如下所示:
for (int j = 0 ; j < weight[0]; j++) {
// 当然这一步,如果把dp数组预先初始化为0了,这一步就可以省略,但很多同学应该没有想清楚这一点。
dp[0][j] = 0;
}
// 正序遍历
for (int j = weight[0]; j <= maxweight; j++) {
dp[0][j] = value[0];
}
如本例,初始化就应该如下:
至于其他部分的初始化,由于dp[i][j]的所有其他部分都可以由第一排和第一列退出,所以数组整体无论怎么初始化都行,只要是一个符合范围的合法值即可;为了代码简洁性,此处统一初始化为0;
所以最终初始化代码如下:
vector<vector<int>> dp(weight.size(), vector<int>(bagweight + 1, 0));//这一步其实已经包含了第一列的初始化
for (int j = weight[0]; j <= bagweight; j++) {
dp[0][j] = value[0];
}
4.确定遍历顺序
从上述条件可以看出,遍历维度有两个维度:
物品和背包重量;
那么就是一个考虑遍历先后顺序的问题;
假设先遍历物品,再遍历背包重量:
// 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]);
}
}
按照上述代码,实现如下:
先遍历背包,再遍历物品(注意仅针对二维dp数组01背包的情况实用):
// weight数组的大小 就是物品个数
for(int j = 0; j <= bagweight; j++) { // 遍历背包容量
for(int i = 1; i < weight.size(); i++) { // 遍历物品
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]);
}
}
实现如下:
5.打印dp数组
举例推导dp数组
#include<iostream>
#include<vector>
using namespace std;
int n, bagspace;
void test() {
vector<int> weight(n, 0);
vector<int> value(n, 0);
cout << "Please enter the weight array(size is n):" << endl;
for (int i = 0; i < n; ++i) {
cin >> weight[i];
}
cout << "Please enter the value array(size is n):" << endl;
for (int j = 0; j < n; ++j) {
cin >> value[j];
}
// dp数组, dp[i][j]代表行李箱空间为j的情况下,从下标为[0, i]的物品里面任意取,能达到的最大价值
vector<vector<int>> dp(weight.size(), vector<int>(bagspace + 1, 0));
//初始化,由于已经初始化了dp[i][0](第一列全为0)
//那么接下来只需要初始化第一行
//由于当j<bagsapce的时候dp[0][j]=0,所以j可以从weight[0]开取
for (int j = weight[0]; j < bagspace; j++) {
dp[0][j] = value[0];
}
for (int i = 1; i < weight.size(); i++) {
for (int j = 0; j <= bagspace; j++) {
// 如果装不下这个物品,那么就继承dp[i - 1][j]的值
if (j < weight[i]) dp[i][j] = dp[i - 1][j];
// 如果能装下,就将值更新为 不装这个物品的最大值 和 装这个物品的最大值 中的 最大值
// 装这个物品的最大值由容量为j - weight[i]的包任意放入序号为[0, i - 1]的最大值 + 该物品的价值构成
else dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
}
}
cout << dp[weight.size() - 1][bagspace] << endl;
}
int main() {
cout << "Please enter the numbers of objects and space of the bag:" << endl;
while (cin >> n >> bagspace) {
test();
}
return 0;
}
一维dp数组实现(滚动数组)
由于dp数组的状态其实是可以压缩的,由二维数组的递推公式:
dp[i][j] = max{dp[i - 1][j] , dp[i][j - weight[i]] +value[i]};
考虑将dp[i-1]这层拷贝到dp[i]这一层上,其实只用一维数组就可以实现了;
再次回顾dp[i][j] 的含义
i是物品,j是背包容量,dp[i][j]是任取第[0, i ]个物品,放进容量为j的背包能达到的价值最大值;
动态规划五部曲:
1.确定dp数组含义:
dp[j]即背包容量为j的背包,能达到的价值最大值dp[j];
2.dp数组的递推公式:
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]); 其实很好理解,就是把i的维度去掉了;
3.dp数组的初始化:
显然,dp[0] = 0;由递推公式,可以知道dp[j]是有一个比较取最大值的过程,所以为了避免获取值被初始值覆盖,此时取INT_32MIN是最合适的,这里假设背包容量均大于0,可以全部初始化为零;
4.dp数组遍历顺序:
注意,以为和二维最大的区别就在遍历顺序上:
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]);
}
}
首先是先遍历物品,再遍历背包;很显然这其实就是对于上面的二维数组的一层一层地遍历;
这里是不能更改for循环的嵌套顺序的;因为一旦先遍历背包,那么背包只会放进一个物品;
其次就是在遍历背包的时候,为了防止物品多次选取,需要倒叙遍历;
一旦正序遍历了,那么物品0就会被重复加入多次!
举例:物品0的重量weight[0] = 1,价值value[0] = 15
如果正序遍历
dp[1] = dp[1 - weight[0]] + value[0] = 15
dp[2] = dp[2 - weight[0]] + value[0] = 30
此时dp[2]就已经是30了,意味着物品0,被放入了两次,所以不能正序遍历;
由于这里所有dp[j]都等于0,正好满足倒序情况:
dp[2] = dp[2 - weight[0]] + value[0] = 15 (dp数组已经都初始化为0)
dp[1] = dp[1 - weight[0]] + value[0] = 15
5.打印dp数组:
void test02() {
vector<int> weight(n, 0);
vector<int> value(n, 0);
cout << "Please enter the weight array(size is n):" << endl;
for (int i = 0; i < n; ++i) {
cin >> weight[i];
}
cout << "Please enter the value array(size is n):" << endl;
for (int j = 0; j < n; ++j) {
cin >> value[j];
}
vector<int> dp(bagspace + 1, 0);//初始化已经完成
for (int i = 0; i < weight.size(); i++) {
cout << "This is current dp array:" << endl;
for (int j = bagspace; j >= weight[i]; j--) {
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
for (int k = 0; k < bagspace + 1; k++) {
cout << dp[k] << ' ';
}
cout << endl;
}
cout << dp[bagspace] << endl;
}
分割等和子集
给定一个只包含正整数的非空数组。是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
注意: 每个数组中的元素不会超过 100 数组的大小不会超过 200
示例 1:
- 输入: [1, 5, 11, 5]
- 输出: true
- 解释: 数组可以分割成 [1, 5, 5] 和 [11].
示例 2:
- 输入: [1, 2, 3, 5]
- 输出: false
- 解释: 数组不能分割成两个元素和相等的子集.
提示:
- 1 <= nums.length <= 200
- 1 <= nums[i] <= 100
直觉第一想法自然是回溯,但是0-1背包都学了,不能不用吧)
首先确定,本题的每个元素只能取一次,而不是可以重复取,所以考虑0-1背包;
其次背包的weight和value均是数值本身,且背包的体积大小为sum/2,只要当背包的体积刚好为满,则返回true;
思路明确,动规五部曲:
1.确定dp数组含义:
dp[j],表示当背包容量(正整数之和)为j的时候,背包所取的最大价值(其实还是正整数之和)
2.确定递推公式:
对于0-1背包,dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
带入此题就是,dp[j] = max(dp[j], dp[j - nums[i]] +nums[i]);
3.dp数组初始化:
dp[0]肯定是0;
如果题目给的价值都是正整数那么非0下标都初始化为0就可以了,如果题目给的价值有负数,那么非0下标就要初始化为负无穷;
这样才能让dp数组在递推的过程中取得最大的价值,而不是被初始值覆盖了;
本题题目中 只包含正整数的非空数组,所以非0下标的元素初始化为0就可以了;
即vector<int> dp(10001, 0);
4.遍历顺序:
从后向前
for(int i = 0; i < nums.size(); i++) {
for(int j = sum/2; j >= nums[i]; j--) { // 每一个元素一定是不可重复放入,所以从大到小遍历
dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]);
}
}
5.打印dp数组
以题例为例
class Solution {
public:
bool canPartition(vector<int>& nums) {
int sum = accumulate(nums.begin(), nums.end(), 0);
if(sum % 2 == 1) return false;//sum为奇数
int target = sum/2;
vector<int> dp(10001, 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]);
}
}
if(dp[target] == target) return true;
return false;
}
};