力扣0-1背包问题
1-1: 问题描述
有N件物品和⼀个最多能被重量为W 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能⽤⼀次,求解将哪些物品装⼊背包⾥物品价值总和最⼤。
有点抽象,换一个具体的场景来描述:
题目描述:总重量 total weight7,共有4个物品,分别是:
weight | value |
---|---|
1 | 1 |
3 | 4 |
4 | 5 |
5 | 7 |
问怎么才能使得背包的总value最大,每个物品只能放一次。 不知道大家是否想过为什么叫0-1背包呢,所谓的0-1意思就是对于每一个物品我们只有放入和不放入两种选择。
1-2: 确定状态
dp[i][j]
代表从[0, i]中选物品放入到重量为j的背包中,背包的价值最大是多少。其中,[0, i]是闭区间的,就是说既包含0,又包含i。把物品从0开始编号,这边一共4个物品,i的取值是[0, 3]。要清楚,j是代表背包的重量。i是代表选择物品的情况。
1-3: 确定转移方程
对于任意一个物品有两种状态,放入和不放入。
- 放入:剩余重量=当前包的重量-放入的该物品重量,拿着剩余重量,从剩余物品中选取来尽可能使总价值最大化后,再加上本物品的价值。也就是,放入的最大总价值=剩余重量的最大价值+本物品的最大价值。
- 不放入:剩余重量=当前包的重量,拿着剩余重量,从剩余物品中选取来尽可能使总价值最大化。不放入的最大总价值=剩余重量的最大值。
这边:拿着剩余重量,从剩余物品中选取来尽可能使总价值最大化。其实就是这里的子问题,动规dp的精髓就是,记录下子问题的最优解。
从而可以得到dp[i][j] = Math.max(dp[i-1][j], dp[i-1][j-weight[i]] + value[i])
dp[i-1][j]
就是指,这个物品不放入的情况,剩余重量不变还是j,剩余物品,i-1很好理解,我这个不选,那么可选的剩余物品就是[0, i-1]。在这种情况下,获得的总价值,就是dp[i-1][j]
填入的值。
dp[i-1][j-weight[i]] + value[i]
就是指,那么就是从剩余物品中[0,i-1]中去选择,然后放入[j-weight]重量的背包中,使其总价值最大化,再加上我放入背包中的物品i的价值。
1-4: 不急着初始化,先看一个例子,模拟下整个过程
继续强调,i是物品的序号,j是背包剩余的重量。
-
i=0,对于重量为1的的0号物品,当背包中的重量分别为[0,7]的时候,从1开始,装入背包,总价值都只能是1,可以填入下面的表格,这里不用考虑,不放入的情况,因为i的顺序是从0到3,不放入0号物品,就等于没有价值了。
-
i=1,对于重量为3的{1号}物品,此时考虑从{0号,1号}[0,1]这两个物品中选择,进行装入背包。
- 重量j=0,很明显,都装不了,填入0。
- 重量j=1,明显这边重量不够装入3号,所以等于现在重量是1,{0号,1号}两个物品中,剔除了1号,还剩{0号},于是问题转变为子问题->从{0号}中选择物品,填入j=1的背包中,也就是
dp[0][1]
的值,也就是继承了这个值。
-
j=2,装不下1号,所以同上,继承上面的1
-
j=3,有变化,j=3时,是可以装入1号物品的,所以有两种情况分别是装入1号和不装入1号。注意,我们表格里面都是填写的各种情况的最优解。
- 装入1号物品,剩余重量=3-3=0,问题变成子问题:从{0号}(1号已经装入了,剩余物品就只有0号了)物品中,选择物品装入重量为0的背包中。很明显,子问题的最优解就是
dp[i-1][j-weight3]=dp[0][0]
。所以装入1号物品的总价值为dp[i-1][j-weight3]+value[1]=dp[0][0]+4=4
- 不装入1号物品,剩余重量=3,问题变为:从{0号}物品中,选择物品装入重量为3的背包中。子问题的最优解就是
dp[0][3]=1
的值。
由上面两种情况,我们取Math.max()=4就可以得到将{0号,1号},装入重量为3的背包的最优解。
- 装入1号物品,剩余重量=3-3=0,问题变成子问题:从{0号}(1号已经装入了,剩余物品就只有0号了)物品中,选择物品装入重量为0的背包中。很明显,子问题的最优解就是
-
j=4,5,6,7依旧按照上面的思路,去填写值。这边再写一个j=7的时候
- 装入1号物品,剩余重量=7-3=4,问题变成:从{0号}(1号已经装入了,剩余物品就只有0号了)物品中,选择物品装入重量为4的背包中。也就是
dp[0][4]
的值,所以这种情况的总价值=dp[0][4]+vale[1] = 1+4 = 5
- 不装入1号物品,剩余重量=7,从{0号}物品中,选择物品装入重量为3的背包中。子问题的最优解就是
dp[0][4]=1
的值。
取Math.max()=5,填表。
- 装入1号物品,剩余重量=7-3=4,问题变成:从{0号}(1号已经装入了,剩余物品就只有0号了)物品中,选择物品装入重量为4的背包中。也就是
- i=2,i=3时,按照上面的思路,依次填写表格。最后得到结果:
1-5: 初始化
由上面的一个例子,我们很容易发现,一个值会依赖上面一行,左上方的值,所以初始化的时候,应该将这两行进行初始化。如表:当然从状态转移方程也可以看出值之间的依赖关系。
1-6: 确定遍历顺序
很明显从左到右,从上到下的顺序。先遍物品好理解一点。
1-7:二维数组 代码
public static int bag(int[] weights, int[] value, int bagWeight) {
int n = weights.length;
int m = bagWeight;
// 物品编号:0~n-1 背包重量0~m
int[][] dp = new int[n][m + 1];
// initialization,初始化0的过程java可以省略,因为int数组默认初始化为0,二维数组中间部分也可以不手动初始化,因为自动初始化为0
// 并且所有的值后面会改动
for (int j = 0; j < m + 1; j++) {
if (j >= weights[0]) {
dp[0][j] = value[0];
} else {
dp[0][j] = 0;
}
}
for (int i = 0; i < n; i++) {
dp[i][0] = 0;
}
for (int i = 1; i < n; i++) {
for (int j = 1; j < m + 1; j++) {
// 放入i号物品的时候
if (j >= weights[i]) {
dp[i][j] = Math.max(dp[i - 1][j - weights[i]] + value[i], dp[i - 1][j]);
} else {
// 不放入i号物品
dp[i][j] = dp[i - 1][j];
}
}
}
System.out.println(Arrays.deepToString(dp));
return dp[n - 1][m];
}
public static void main(String[] args) {
int[] weights = new int[]{1, 3, 4, 5};
int[] value = new int[]{1, 4, 5, 7};
bag(weights, value, 7);
// int[] weights = new int[]{1, 3, 4};
// int[] value = new int[]{15, 20, 30};
// bag(weights, value, 4);
}
----output----
[[0, 1, 1, 1, 1, 1, 1, 1], [0, 1, 1, 4, 5, 5, 5, 5], [0, 1, 1, 4, 5, 6, 6, 9], [0, 1, 1, 4, 5, 7, 8, 9]]
- 代码讲解:
- 初始化,根据上面的分析中转移方程一般式中的值依赖关系,先对第一行和第一列初始化
- 两层循环,放入的前提条件就是背包重量要够,所以这个判断条件也好理解。可以放入的时候,就回到了状态转移方程的两种情况。重量不够放入的时候,就只有一种情况,用现有的重量和之前的物品,得出最大值。也就是继承
dp[i-1][j]
的值。大白话讲:- 拿到一个袋子,我们首先看袋子的重量,这个重量能不能放进我现在的这个物品
- 能放。能放,我不一定非要放呀。我可以选择不放。
- 情况1:放进去的价值更高呢?情况2: 不放进去的价值更高呢?
- 不能放。我都放不进去了,还考虑放进去的价值做啥呢?
- 情况1:不放进去的价值。
1-8:一维数组 代码对空间复杂度的优化(滚动数组)
什么是滚动数组?在我看来可能是复用空间的意思。
滚动数组是DP中的一种编程思想。简单的理解就是让数组滚动起来,每次都使用固定的几个存储空间,来达到压缩,节省存储空间的作用。起到优化空间,主要应用在递推或动态规划中(如01背包问题)。因为DP题目是一个自底向上的扩展过程,我们常常需要用到的是连续的解,前面的解往往可以舍去。所以用滚动数组优化是很有效的。利用滚动数组的话在N很大的情况下可以达到压缩存储的作用
/**
* 对空间复杂度进行优化,由2维数组优化成1维数组
*/
public static int bag2(int[] weights, int[] value, int bagWeight) {
int n = weights.length;
int m = bagWeight;
int[] dp = new int[m + 1];
// initialization
for (int j = 0; j < m + 1; j++) {
if (j > weights[0]) {
dp[j] = value[0];
}
}
for (int i = 0; i < n; i++) {
for (int j = m; j >= 0; j--) {
if (j >= weights[i]) {
dp[j] = Math.max(dp[j], dp[j - weights[i]] + value[i]);
}
// 不大于的时候,继承原来的值,不用改。
}
}
System.out.println(Arrays.toString(dp));
return dp[m];
}
为什么能这么优化,通过第一个案例,我们可以清楚发现,一个一般值依赖于自己上一行(头顶)简称x和上一行的左边部分简称y,也就是说,只和上一行有关系。
最终比较式是,value[i]+y>x?val[i]+y: x; 如果我们将数组换成一维,初始化之后,第二次遍历这个一维数组的时候,拿到的就是上一行的数据,读取值再修改,就变成了当前行的数据。那么为什么要从右向左遍历呢?因为二维数组当前行的数据,依赖于两个上一行的数据(x,y),先把左边的y改掉了,依赖于上一行的数据就丢失了。从右开始向左遍历,就可以保证保留了上一行的数据。可以动手试试,模拟一下就懂了。
看见一个很好的讲解视频,供大家学习。很好
https://www.bilibili.com/video/BV1C7411K79w?p=2