01背包理论基础

其他背包,面试几乎不会问,都是竞赛级别的了,leetcode上连多重背包的题目都没有,所以题库也告诉我们,01背包和完全背包就够用了。
而完全背包又是也是01背包稍作变化而来,即:完全背包的物品数量是无限的。
01 背包问题描述
有n件物品和一个最多能背重量为w 的背包。第i件物品的重量是weighti,得到的价值是valuei 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。
递归五部曲
示例:

- 确定dp数组以及下标的含义
对于背包问题,有一种写法, 是使用二维数组,即dpij 表示从下标为0-i的物品里任意取,放进容量为j的背包,价值总和最大是多少。
- 确定递推公式
再回顾一下dpij的含义:从下标为0-i的物品里任意取,放进容量为j的背包,价值总和最大是多少。
那么可以有两个方向推出来dpij,
- 不放物品i:由dpi - 1j推出,即背包容量为j,里面不放物品i的最大价值,此时dpij就是dpi - 1j。(其实就是当物品i的重量大于背包j的重量时,物品i无法放进背包中,所以背包内的价值依然和前面相同。)
- 放物品i:由dpi - 1j - weight\[i]推出,dpi - 1j - weight\[i] 为背包容量为j - weighti的时候不放物品i的最大价值,那么dpi - 1j - weight\[i] + valuei (物品i的价值),就是背包放物品i得到的最大价值
所以递归公式: dpij = max(dpi - 1j, dpi - 1j - weight\[i] + valuei);
- 初始化dp数组
这一步很重要:
cpp
// 初始化 dp
vector<vector<int>> dp(weight.size(), vector<int>(bagweight + 1, 0));
for (int j = weight[0]; j <= bagweight; j++) {
dp[0][j] = value[0];
}

- 确定遍历顺序
外部循环是物品还是背包?其实都可以,但是放物品比较符合实际情况:
cpp
// 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]);
}
}
- 这里要判断j和weighti,看能不能装得下,不然后面dpi - 1j - weight\[i]会报错;
- 打印dp数组

最终代码:
cpp
public class BagProblem {
public static void main(String[] args) {
int[] weight = {1,3,4};
int[] value = {15,20,30};
int bagSize = 4;
testWeightBagProblem(weight,value,bagSize);
}
/**
* 动态规划获得结果
* @param weight 物品的重量
* @param value 物品的价值
* @param bagSize 背包的容量
*/
public static void testWeightBagProblem(int[] weight, int[] value, int bagSize){
// 创建dp数组
int goods = weight .length; // 获取物品的数量
int[][] dp = new int[goods][bagSize + 1];
// 初始化dp数组
// 创建数组后,其中默认的值就是0
for (int j = weight[0]; j <= bagSize; j++) {
dp[0][j] = value[0];
}
// 填充dp数组
for (int i = 1; i < weight.length; i++) {
for (int j = 1; j <= bagSize; j++) {
if (j < weight[i]) {
/**
* 当前背包的容量都没有当前物品i大的时候,是不放物品i的
* 那么前i-1个物品能放下的最大价值就是当前情况的最大价值
*/
dp[i][j] = dp[i-1][j];
} else {
/**
* 当前背包的容量可以放下物品i
* 那么此时分两种情况:
* 1、不放物品i
* 2、放物品i
* 比较这两种情况下,哪种背包中物品的最大价值最大
*/
dp[i][j] = Math.max(dp[i-1][j] , dp[i-1][j-weight[i]] + value[i]);
}
}
}
// 打印dp数组
for (int i = 0; i < goods; i++) {
for (int j = 0; j <= bagSize; j++) {
System.out.print(dp[i][j] + "\t");
}
System.out.println("\n");
}
}
}
0-1背包滚动数组优化
上面的方法用的是二维的dp数组,其实可以将其优化为一维的dp。例子还是这个:

在使用二维数组的时候,递推公式:dpij = max(dpi - 1j, dpi - 1j - weight\[i] + valuei);
其实可以发现如果把dpi - 1那一层拷贝到dpi上,表达式完全可以是:dpij = max(dpij, dpij - weight\[i] + valuei);
与其把dpi - 1这一层拷贝到dpi上,不如只用一个一维数组了,只用dpj(一维数组,也可以理解是一个滚动数组)。
注意的点:
- 递推公式:
other
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
- 遍历顺序:外层物品内层背包,背包从后往前倒序。
倒序遍历是为了保证物品i只被放入一次!。但如果一旦正序遍历了,那么物品0就会被重复加入多次!
举一个例子:物品0的重量weight0 = 1,价值value0 = 15
如果正序遍历
dp1 = dp1 - weight\[0] + value0 = 15
dp2 = dp2 - weight\[0] + value0 = 30
此时dp2就已经是30了,意味着物品0,被放入了两次,所以不能正序遍历。
为什么倒序遍历,就可以保证物品只放入一次呢?
倒序就是先算dp2
dp2 = dp2 - weight\[0] + value0 = 15 (dp数组已经都初始化为0)
dp1 = dp1 - weight\[0] + value0 = 15
所以从后往前循环,每次取得状态不会和之前取得状态重合,这样每种物品就只取一次了。
416. 分割等和子集
本题其实就是一个变种的0-1背包问题,关键是怎么套入0-1背包:
只有确定了如下四点,才能把01背包问题套到本题上来。
- 背包的体积为sum / 2
- 背包要放入的商品(集合里的元素)重量为 元素的数值,价值也为元素的数值
- 背包如果正好装满,说明找到了总和为 sum / 2 的子集。
- 背包中每一个元素是不可重复放入。
dp五部曲
- 确定dp数组以及下标的含义
01背包中,dpj 表示: 容量为j的背包,所背的物品价值最大可以为dpj。
套到本题,dpj表示 背包总容量(所能装的总重量)是j,放进物品后,背的最大重量为dpj。
- 确定递推公式
value和weight都是numsi,那么递推公式就从:
dpj = max(dpj, dpj - weight\[i] + valuei);
转换成了
dpj = max(dpj, dpj - nums\[i] + numsi);
- 初始化
全部默认初始化为0即可。
- 遍历顺序
注意0-1背包一维dp,外层物品内层背包,背包从后往前倒序。
- 举例推导。
最终代码:
cpp
class Solution {
public boolean canPartition(int[] nums) {
int sum = Arrays.stream(nums).sum();
if(sum % 2 != 0){
return false;
}
int target = sum / 2;
int n = nums.length;
//抽象成0-1背包问题
int dp[] = new int[target + 1];
// value和weight都是nums[i]
// dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]);
for(int i = 0 ; i < n; i++){
for(int j = target; j >= nums[i]; j--){
dp[j] = Math.max(dp[j], dp[j - nums[i]] + nums[i]);
}
//剪枝一下,提前返回
if(dp[target] == target)
return true;
}
if(dp[target] == target){
return true;
}
return false;
}
}
- Arrays.stream(nums).sum();(好像效率不如for循环累加)