背包基础知识
一、背包分类
面试掌握01背包,和完全背包,就够用了,最多可以再来一个多重背包。
完全背包又是也是01背包稍作变化而来,即:完全背包的物品数量是无限的。
二、01背包
有N件物品和一个最多能被重量为W 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次
,求解将哪些物品装入背包里物品价值总和最大。
比如:背包重量最大为4
物品为:
问背包能背的物品最大价值是多少?
2. 二维dp数组01背包
基本思路
这是最基础的背包问题,特点是:每种物品仅有一件,可以选择放或不放。
用子问题定义状态:即f[i][j]
表示前i件物品恰放入一个容量为j的背包可以获得的最大价值。
则其状态转移方程便是:
f[i][j] = max( f[i-1] [j], f[i-1][j-w[]] + o[i])
这个方程非常重要,基本上所有跟背包相关的问题的方程都是由它衍生出来的。
所以有必要将它详细解释一下:“将前i件物品放入容量为j的背包中”这个子问题,若只考虑第i件物品的策略(放或不放),那么就可以转化为一个只牵扯前i-1件物品的问题。
-
如果不放第i件物品,那么问题就转化为“
前i-1件物品放入容量为j的背包
中”,价值为f[i-1][j]
; -
如果放第i件物品,那么问题就转化为“
前i-1件物品放入剩下的容量为j一w[i]的背包中
”,此时能获得的最大价值就是f[i-1][j-wi]]再加上通过放入第i件物品获得的价值v[i]
具体求解
依然动规五部曲分析一波。
-
确定dp数组以及下标的含义
对于背包问题,有一种写法, 是使用二维数组,即
dp[i][j]
表示从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是多少。只看这个二维数组的定义,大家一定会有点懵,看下面这个图:
i表示物品,j表示背包容量
:
背包容量为j:表示可以放入容量为0、1、2、3、…、j的物品。
-
确定递推公式
再回顾一下dp[i][j]的含义:从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是多少。
那么可以有两个方向推出来dp[i][j]:
由d
p[i - 1][j]
推出,即背包容量为j,里面不放物品i的最大价值,此时dp[i][j]就是dp[i - 1][j]由
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]);
-
dp数组如何初始化
关于初始化,一定要和dp数组的定义吻合,否则到递推公式的时候就会越来越乱。
首先从dp[i][j]的定义触发,如果背包容量j为0的话,即
dp[i][0]
,无论是选取哪些物品,背包价值总和一定为0。如图:
再看其他情况。状态转移方程
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i])
; 可以看出i 是由 i-1 推导出来,那么i为0的时候就一定要初始化。dp[0][j]
,即:i为0,存放编号0的物品的时候,各个容量的背包所能存放的最大价值。 代码如下://根据递推公式,需要初始化dp[0][j]的情况 for (int j = bagV; j >= weight[0]; j--) { //编号为 0 的物品放入容量为 j 的背包的最大价值 dp[0][j] = dp[0][j - weight[0]] + value[0]; }
注意这个遍历,一定要是倒叙,顺序不行
例如dp[0][1]是15,到了dp[0][2]=dp[0][2-1]+15;也就是dp[0][2]=30了,那么就是物品0被重复放入了。 所以一定要倒叙遍历,保证物品只被放入一次!这一点对01背包很重要,后面在讲解滚动数组的时候, 还会用到倒叙遍历来保证物品使用一次!
或者可以这样初始化:
//放入第一件物品时,当背包容量j大于等于第一件物品的体积时,价值都是value[0] for (int j = weight[0]; j <= bagV; j++) { dp[0][j] = value[0]; }
此时dp数组初始化情况如图所示:
dp[0][j] 和 dp[i][0] 都已经初始化了,那么其他下标应该初始化多少呢?dp[i][j]
在推导的时候一定是取价值最大的数,如果题目给的价值都是正整数那么非0下标都初始化为0就可以了,因为0就是最小的了,不会影响取最大价值的结果。如果题目给的价值有负数,那么非0下标就要初始化为负无穷了。
例如:一个物品的价值是-2,但对应的位置依然初始化为0,那么取最大值的时候,就会取0而不是-2了, 所以要初始化为负无穷。
这样才能让dp数组在递归公式的过程中取最大的价值,而不是被初始值覆盖了。
-
确定遍历顺序
那么问题来了,先遍历 物品还是先遍历背包重量呢?
其实都可以!!但是先遍历物品更好理解。
for (int i = 1; i < weight.length; i++) { for (int j = 1; j <= bagV; j++) { if (j < weight[i]) { //第i件商品放不进去 dp[i][j] = dp[i - 1][j]; } else { //选取最优解 dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]); } } }
如果出现错误,可以打印出来dp数组进行验证
代码实现:
public class Test {
public static void main(String[] args) {
//商品的重量
int[] weight = {1, 3, 4};
//商品对应的价值
int[] value = {15, 20, 30};
//背包的大小
int bagV = 4;
//dp[i][j]表示容量为j时,前i件的最大价值
int[][] dp = new int[weight.length][bagV + 1];
//根据递推公式,需要初始化dp[0][j]的情况
// for (int j = bagV; j >= weight[0]; j--) {
// //编号为 0 的物品放入容量为 j 的背包的最大价值
// dp[0][j] = dp[0][j - weight[0]] + value[0];
// }
//放入第一件物品时,当背包容量j大于等于第一件物品的体积时,价值都是value[0]
for (int j = weight[0]; j <= bagV; j++) {
dp[0][j] = value[0];
}
for (int i = 1; i < weight.length; i++) {
for (int j = 1; j <= bagV; j++) {
if (j < weight[i]) {
//第i件商品放不进去
dp[i][j] = dp[i - 1][j];
} else {
//选取最优解
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
}
}
}
for (int i = 0; i < weight.length; i++) {
for (int j = 0; j <= bagV; j++) {
System.out.print(dp[i][j] + " ");
}
System.out.println();
}
}
}
找最优解组成
通过上面的方法可以求出背包问题的最优解,但还不知道这个最优解由哪些商品组成,故要根据最优解回溯找出解的组成,根据填表的原理可以有如下的寻解方式
V(i,j)=V(-1,j)时,说明没有选择第i个商品,则回到V(i-1,j);
V(i,j)=V(i-1,j-w(i))+v(i)时,说明装了第i个商品,该商品是最优解组成的一部分,随后我们得回到装该商品之前,即回到V(-1,j-());
一直遍历到i=0结束为止,所有解的组成都会找到。
比如:
最优解为V(4,8)=10,而V(4,8)!=V(3,8)却有V(4,8)=V(3,8-w(4))+v(4)=V(3,3)+6=4+6=10,所以第4件商品被选中,并且回到V(3,8-
w(4))=V(3,3);
有V(3,3)=V(2,3)=4,所以第3件商品没被选择,回到V(2,3);
而V(2,3)!=V(1,3)却有V(2,3)=V(1,3-w(2))+v(2)=V(1,0)+4=0+4=4,所以第2件商品被选中,并且回到V(1,3-w(2))=V(1,0);
有V(1,0)=V(0,0)=0,所以第1件商品没被选择。
代码实现:
import java.util.Arrays;
public class Test {
public static void main(String[] args) {
//商品的重量
int[] weight = {1, 3, 4};
//商品对应的价值
int[] value = {15, 20, 30};
//背包的大小
int bagV = 4;
//dp[i][j]表示容量为j时,前i件的最大价值
int[][] dp = new int[weight.length][bagV + 1];
//记录最优解的情况
int[] item = new int[weight.length];
for (int j = weight[0]; j <= bagV; j++) {
dp[0][j] = value[0];
}
//动态规划
findDp(dp, weight, value, bagV);
//查找最优解
findItem(dp, weight, value, weight.length - 1, bagV, item);
System.out.println(Arrays.toString(item));
}
private static void findItem(int[][] dp, int[] weight, int[] value, int i, int j, int[] item) {
if (i >= 1) {
//第 i 件商品没有选择
if (dp[i][j] == dp[i - 1][j]) {
item[i] = 0;
findItem(dp, weight, value, i - 1, j, item);
// 第 i 件商品选择了
} else if (j - weight[i] >= 0 && dp[i][j] == dp[i][j - weight[i]] + value[i]) {
item[i] = 1;
findItem(dp, weight, value, i - 1, j - weight[i], item);
}
// 处理编号为0的物品,也就是第一件物品
} else if (value[0] == dp[0][j]) {
item[0] = 1;
}
}
private static void findDp(int[][] dp, int[] weight, int[] value, int bagV) {
for (int i = 1; i < weight.length; i++) {
for (int j = 1; j <= bagV; j++) {
if (j < weight[i]) {
//第i件商品放不进去
dp[i][j] = dp[i - 1][j];
} else {
//选取最优解
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
}
}
}
}
}
优化空间复杂度
以上方法的时间和空间复杂度均为O(VN),其中时间复杂度已经不能再优化了,但空间复杂度却可以优化到O(N)。
先考虑上面讲的基本思路如何实现,肯定是有一个主循环i=1…N
每次算出来二维数组f[ij]0…]的所有值。
那么,如果只用一个数组f0…],能不能保证第i次循环结束后f[j]中表示的就是我们定义的状态f[i][j]呢?
f[i][j]是由f[i-1][j]和f[i-1][j-w[i]两个子问题递推而来,能否保证在推f[i][j]时(也即在第i次主循环中推f[j]时)能够得到
f[i-1][i]和f[i-1][j-w[i]的值呢?
事实上,这要求在每次主循环中我们以j=V…0的顺序推f[j],这样才能保证推f[j-w[]]保存的是状态f[i-1][j-w[i]]的值。
至于为什么下面有详细解释。代码如下:
for (int i = 1; i <= n; i++)
for (int j = V; j >= 0; j--)
f[j] = max(f[j], f[j - w[i]] + v[i]);
其中的f[ij]=mac(f[j],f[j-w[i])-句恰就相当于我们的转移方程f[i][j]=max(f[i-1][j],f[i-1][j-w[i]),因为现在的f[j-w[]]就相当于原来的f{[i-1][j-w[i]]。
如果将V的循环顺序从上面的逆序改成顺序的话,那么则成了f[i][j]由f[i][j-w[i]推知,与本题意不符
但它却是另一个重要的背包问题(完全背包)最简捷的解决方案,故学习只用一维数组解01背包问题是十分必要的。
下面再给出一段代码,注意和上面的代码有什么不同
for (int i = 1; i <= n; i++)
for (int j = V; j >= w[i]; j--)
f[j] = max(f[j], f[j - w[i]] + v[i]);
注意这个过程里的处理与前面给出的代码有所不同。前面的示例程序写成j=V…0是为了在程序中体现每个状态都按照方程求解了,避
免不必要的思维复杂度。而这里既然已经抽象成看作黑箱的过程了,就可以加入优化。费用为w[]的物品不会影响状态f0…j-1],这
是显然的。
优化后的背包求放入的最大价值
public class Test {
public static void main(String[] args) {
//商品的重量
int[] weight = {1, 3, 4};
//商品对应的价值
int[] value = {15, 20, 30};
//背包的大小
int bagV = 4;
//dp[j]表示容量为j时,前i件的最大价值
//这里之所以长度都加一,是因为被包容量可以取到4
//初始dp[0]=0,表示被包容量为0时,可容纳的最大价值为0
int[] dp = new int[bagV + 1];
//动态规划
findDp(dp, weight, value, bagV);
System.out.println(Arrays.toString(dp));
}
//查找动态规划表
private static void findDp(int[] dp, int[] weight, int[] value, int bagV) {
for (int i = 0; i < weight.length; i++) {
for (int j = bagV; j >= weight[i]; j--) {
//选取最优解
//每次都借助上一行的dp[j]
dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i]);
}
}
}
}
这里存在一个问题就是,可以找到最优解,但是,具体最优解的组成没法确定了
初始化时的细节问题
-
对于非零下标,因为题目都是正整数,所以初始化为0就可以,因为0就是最小的,不会影响取最大值结果
-
如果题目有负数,非零下标就要初始化为负无穷
例如:- 个物品的价值是-2,但对应的位置依然初始化为0,那么取最大值的时候,就会取0而不是-2了 所以要初始化为负无穷。
参考
[1] https://blog.csdn.net/qq_37767455/article/details/99086678
[2] https://blog.csdn.net/yandaoqiushenglarticle/details/84782655/
[3] https://mp.weixin.qq.com/s/FwliPPmR18_AJO5eiidT6w