问题:有 N 件物品和一个最多能承受重量为 W 的背包。第 i 件物品的重量为weight[i],得到的价值是value[i]。每件物品只能用一次,求解将哪些物品装入背包后物品的价值总和最大。
例题:
暴力解法(回溯法):
每一件物品其实只有两个状态:取或者不取,所以可以用回溯法搜索出所有的情况,那么时间复杂度就是O(2^n),n表示物品数量。
所以暴力解法是指数级别的时间复杂度,因此需要动态规划的解法来进行优化。
回溯法代码:
public class Test{
static int res = 0;
static int sumOfValue = 0;
static int sumOfWeight = 0;
public static void main(String[] args) {
int[] weight = {
1, 3, 4};
int[] value = {
15, 20, 30};
int bagWeight = 4;
backtracking(weight, value, bagWeight, 0);
System.out.println(res);
}
public static void backtracking(int[] weight, int[] value, int bagWeight, int startIndex){
if(sumOfWeight <= bagWeight){
res = Math.max(res, sumOfValue);
}else{
return;
}
for(int i = startIndex; i < weight.length; i++){
sumOfValue += value[i];
sumOfWeight += weight[i];
backtracking(weight, value, bagWeight, i + 1);
sumOfValue -= value[i];
sumOfWeight -= weight[i];
}
}
}
动态规划:
思路:
1、确定dp数组下标以及下标的含义
dp[i][j]:表示从下标为 0 到 i 的物品里任意取,放进容量为 j 的背包,价值总和最大是多少。
如图所示:
2、确定递推公式
回顾以下dp[i][j]的含义:将前 i 件物品中任意个物品装入容量为 j 的背包所能得到的最大总价值。
那么dp[i][j]可以由两个方向推出来:
(1) 包的容量比该 物品i 的体积小,装不下物品 i,此时的价值与前 i-1 个物品的价值是一样的,即dp[i][j]就是dp[i - 1][j]。
(2) 还有足够的容量可以装下物品 i,但装了也不一定达到当前最优价值(装了有两层意思,第一层是背包能同时装下第 i 个物品和之前的物品;另一层是背包只能装下第 i 个物品或者之前的物品两者中的一个,如果第 i 个物品的价值大于之前的物品的价值,就把第 i 个物品装进去,把之前的物品拿出来,否则就不把第 i 个物品装进去)。所以在装与不装之间选择最优的一个,即 max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i])。
注:dp[i - 1][j]是不装的情况;dp[i - 1][j - weight[i]] + value[i]是把第 i 件物品装进去的情况:因此 j - weight[i] 相当于物品 i 已经装进去(所以还要加上物品 i 的价值value[i]),此时背包剩余的容量为 j - weight[i]。如果这些剩余容量装不下之前的物品,那么此时的dp[i - 1][j - weight[i]]就为0,否则dp[i - 1][j - weight[i]]就是从前 i - 1个物品中任意取,在当前容量为j - weight[i]时的最大价值。
所以递推公式:dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
3、dp数组如何初始化
首先从dp[i][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的物品的时候,各个容量的背包所能存放的最大价值。(注:每个物品只能放一次)
//倒序遍历
for(int j = bagWeight; j >= weight[0]; j--){
//为什么要加上dp[0][j - weight[0]]?
//因为weight[0] 和 value[0]是一起的,是一个物品的重量和价值
dp[0][j] = dp[0][j - weight[0]] + value[0];//初始化 i 为0时候的情况
}
注意:不能正序遍历,因为dp[0][j]表示的是容量为 j 的背包存放物品0时的最大价值,物品0的价值就是15,因为题目中说了每个物品只有一个!所以dp[0][j]如果不是初始值的话,就应该都是物品0的价值,也就是15。但如果一旦正序遍历了,那么物品0就会被重复加入多次,例如代码如下:
//正序遍历
for(int j = weight[0]; j <= bagWeight; 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被重复放入了。
所以一定要倒序遍历,保证物品0只被放入一次!
此时dp数组的初始化情况如图所示:
dp[i][0] 和 dp[0][j] 都已经初始化了,那么其他下标应该初始化为多少呢?
dp[i][j]在推导的时候一定是取价值最大的数,如果题目给的价值都是正整数的话,那么非0下标都初始化为0就可以了,因为0就是最小的,不会影响取最大价值的结果。
如果题目给的价值有负数,那么非0下标就要初始化为负无穷了。例如:一个物品的价值是-2,但对应的位置依然初始化为0,那么取最大值的时候,就会取0而不是-2了,所以要初始化为负无穷。
这样才能让dp数组在递归公式的过程中取最大的价值,而不是被初始值覆盖了。
最后初始化代码如下:
// 初始化 dp
int[][] dp = new int[weight.length][bagWeight + 1];
for(int j = bagWeight; j >= weight[0]; j--){
dp[0][j] = dp[0][j - weight[0]] + value[0];
}
4、确定遍历顺序
由下图可以看出,有两个遍历的维度:物品与背包容量
先遍历物品或者先遍历背包容量都可以,但先遍历物品更容易理解。
先遍历物品,再遍历背包容量:
for(int i = 1; i < weight.length; i++){
//遍历物品
for(int j = 0; j <= bagWeight; j++){
//遍历背包容量
if(j < weight[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 j = 0; j <= bagWeight; j++){
//遍历背包容量
for(int i = 1; i < weight.length; i++){
//遍历物品
if(j < weight[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]);
}
}
}
5、举例推导dp数组
public class Test{
public static void main(String[] args) {
int[] weight = {
1, 3, 4};
int[] value = {
15, 20, 30};
int bagWeight = 4;
int[][] dp = new int[weight.length][bagWeight + 1];
for (int j = bagWeight; j >= weight[0]; j--) {
dp[0][j] = dp[0][j - weight[0]] + value[0];
}
//weight数组的大小,就是物品个数
for (int i = 1; i < weight.length; i++) {
//遍历物品
for (int j = 0; j <= bagWeight; j++) {
//遍历背包容量(j也可以从1开始)
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]);
}
}
}
System.out.println(dp[weight.length - 1][bagWeight]);
}
}
这么写打印出来的dp数组如图:
最终结果就是dp[2][4]。
修改一下遍历过程:
public class Test{
public static void main(String[] args) {
int[] weight = {
1, 3, 4};
int[] value = {
15, 20, 30};
int bagWeight = 4;
int[][] dp = new int[weight.length][bagWeight + 1];
for (int j = bagWeight; j >= weight[0]; j--) {
dp[0][j] = dp[0][j - weight[0]] + value[0];
}
//weight数组的大小,就是物品个数
for (int i = 1; i < weight.length; i++) {
//遍历物品
for (int j = 0; j <= bagWeight; j++) {
//遍历背包容量(j也可以从1开始)
if(j >= weight[i]){
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
}
}
}
System.out.println(dp[weight.length - 1][bagWeight]);
}
}
这么写打印出来的dp数组如图:
一维dp数组(滚动数组):
对于背包问题其实状态都是可以压缩的。
在使用二维数组的时候,递推公式:dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
其实如果把dp[i - 1]那一层拷贝到dp[i]上,表达式完全可以是:dp[i][j] = max(dp[i][j], dp[i][j - weight[i]] + value[i]);
与其把dp[i - 1]这一层拷贝到dp[i]上,不如只用一个一维数组,只用dp[j](一维数组,也可以理解是一个滚动数组)。
这就是滚动数组的由来,需要满足的条件是上一层可以重复利用,直接拷贝到当前层。
动态规划五部曲:
1、确定dp数组以及下标的含义
在一维dp数组中,dp[j]表示:容量为 j 的背包,所背物品的最大价值为dp[j]。
2、确定递推公式
dp[j]可以通过dp[j - weight[j]]推导出来,dp[j - weight[j]]表示容量为 j - weight[i]的背包所北的最大价值。
dp[j - weight[j]] + value[i] 表示容量为 j 的背包,已经放入了物品 i 之后,剩余容量的最大价值加上物品 i 的价值。
此时dp[j]有两种选择:一种是取自己的dp[j],一种是取dp[j - weight[j]] + value[i]。
因此递推公式:dp[j] = max(dp[j], dp[j - weight[j]] + value[i]);
3、dp数组如何初始化
注:初始化时,一定要和dp数组的定义相吻合,否则到递推公式的时候就会越来越乱。
dp[j]表示:容量为 j 的背包,所背物品的最大价值是dp[j]。那么dp[0]就是0,因为背包容量为0的时候所背物品的最大价值就是0。
那么dp数组除了下标为0的位置初始为0,其他下标位置该如何初始化呢?
由递推公式:dp[j] = max(dp[j], dp[j - weight[j]] + value[i]);
dp数组在推导的时候一定是取价值最大的数,如果题目给的价值都是正整数,那么非0下标都初始化为0就可以了;如果题目给的价值有负数,那么非0下标就要初始化为负无穷。
这样才能让dp数组在递推公式的过程中取得最大的价值,而不是被初始值覆盖了。
在本例中物品价值都是大于0的,所以dp数组初始化的时候,都初始为0就行了。
4、确定遍历顺序
for(int i = 0; i < weight.length; i++){
for(int j = bagWeight; j >= weight[i]; j--){
dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i]);
}
}
这里和二维dp的写法中,遍历背包的顺序是不同的。
二维dp遍历的时候,背包容量是从小到大,但一维dp在遍历的时候,背包容量是从大到小。因为倒序遍历是为了保证物品 i 只被放入一次。
比如:物品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[2],dp[2] = dp[2 - weight[0]] + value[0] = 15; dp[1] = dp[1 - weight[0]] + value[0] = 15;
所以从后往前循环,每次取的状态不会和之前取的状态重合,这样每种物品就只取一次了。
那么为什么二维dp数组遍历的时候不用倒叙呢?
因为对于二维dp,dp[i][j]都是通过上一层即dp[i - 1][j]计算而来,本层的dp[i][j]不会被覆盖。
为什么要先遍历物品,嵌套遍历背包容量呢?可不可以先遍历背包容量,嵌套遍历物品?
不可以!因为一维dp的写法,背包容量一定是要倒叙遍历,如果遍历背包容量放在上一层,那么每个dp[j]就只会放入一个物品,即:背包里只放入了一个物品。比如:在计算背包容量j = 4的时候,前几个背包容量时的最大价值还未更新,都还是0,因此相当于在容量为4的时候只能是某一个物品的最大价值。
所以一维dp数组的背包在遍历顺序上和二维dp数组其实是有很大的差异的!
public class BeiBao {
public static void main(String[] args) {
int[] weight = {
1, 3, 4};
int[] value = {
15, 20, 30};
int bagWeight = 4;
int[] dp = new int[bagWeight + 1];
dp[0] = 0;
for(int i = 0; i < weight.length; i++){
for(int j = bagWeight; j >= weight[i]; j--){
dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i]);
}
}
System.out.println(dp[bagWeight]);
}