Day34 力扣动态规划 : 01背包问题 二维 |01背包问题 一维 |416. 分割等和子集
正式开始背包问题,背包问题还是挺难的,虽然大家可能看了很多背包问题模板代码,感觉挺简单,但基本理解的都不够深入。
如果是直接从来没听过背包问题,可以先看文字讲解慢慢了解 这是干什么的。
如果做过背包类问题,可以先看视频,很多内容,是自己平时没有考虑到位的。
背包问题,力扣上没有原题,大家先了解理论,今天就安排一道具体题目。
0-1背包,每个物品只有一个
完全背包,每种物品无限个
多重背包,每种物品个数不一样
01背包问题 二维
https://programmercarl.com/%E8%83%8C%E5%8C%85%E7%90%86%E8%AE%BA%E5%9F%BA%E7%A1%8001%E8%83%8C%E5%8C%85-1.html
视频讲解:https://www.bilibili.com/video/BV1cg411g7Y6
第一印象
直接看题解学一下 纯净背包问题
看完题解的思路
文字题解甚至看不进去的程度
看完视频我理解了,都11点多了我还在学习,太酬勤了。
例子, 背包容量为4
重量 价值
物品0 1 15
物品1 3 20
物品2 4 30
1.dp数组:
dp[i物品][j背包空间]二维数组。
dp[i][j] 代表,任取[0, i]个物品,在背包空间j的时候,价值最大是多少。
背包问题,数组的含义挺晦涩难懂的,这里要谨记他的含义,就是这么回事。
2.递推公式:
物品只有两种状态,装包里和放外头。
对于第 i 个物品来说
如果给它放外头,包里物品还是原来那些,价值不变。 所以这种情况,dp[i][j] = dp[i - 1][j].
因为物品没有放进去,容量是不变的,j 还是原来的 j。
如果给它装包里,那么肯定总价值要加上这个物品的价值 value[i]。
这时候我就产生疑问了,那我这个物品一定能放进去吗?
这就要注意了,因为我们假设物品状态就是装包里,说明他必须在里面。而且说明它没装之前,背包的容量是不算它自己的,也就是 j - weight[i]。那么 dp[i][j] = dp[i - 1][j - weight[i]] + value[i]
最后选择装包里和放外头最大的那种情况。
3.初始化:
这道题的初始化是有讲究的,不能随便初始化称0 或者 1.
我们好好看这个二维数组
每个位置都是由上面格子,或者左上方区域的某个格子推出来的。是不是很像机器人走步的那道题。 所以为了每个格子都有能算出来,第一行和第一列就要初始化。第一列肯定都是 0 ,但是第一行是有意义的,物品0 在背包容量是 0 1 2 3 4的时候,最大价值是多少。写一个for循环就可以了
4.遍历顺序:
两层for循环,背包和物品谁在外层内层都可以。我没在遍历顺序碰过钉子,我觉得按逻辑思维去想就可以,等会写的时候试试吧
实现中的困难
在kama网写,力扣没有这道题,kama网AC模式,我根本写不出最基础的样子,这里记录一下吧。
例子, 背包容量为4
重量 价值
物品0 1 15
物品1 3 20
物品2 4 30
public class BagProblem {
}
dp数组
因为背包容量为 0,是有意义的,所以我们背包容量声明的时候要 + 1.
dp[][0]就是背包容量为0 ,满了。
dp[][bagSzie]就是背包容量是bagSize,空的。
//在[0, i]的物品里任选,最大的价值是 dp[i][j]
int[][] dp = new int[weight.length][bagSize + 1];
初始化
也可以不初始化第一列,因为本来就是0
//初始化
//当背包容量j=0的时候,一个物品也装不进去,所以第一列全是0
for (int i = 0; i < weight.length; i++) {
dp[i][0] = 0;
}
//初始化第一个物品的情况
for (int j = 0; j < dp[0].length; j++) {
//物品超过背包容量了
if (weight[0] > j) {
dp[0][j] = 0;
} else {
dp[0][j] = value[0];
}
}
递推公式
理解思路之后,逻辑不难,实现就不难。
//递推公式 我选择先遍历物品再遍历背包
for (int i = 1; i < weight.length; i++) {
for (int j = 1; j < dp[0].length; j++) {
//如果这个东西 放不进去了
if (weight[i] > j) {
dp[i][j] = dp[i - 1][j];
} else {
//能放进去就比大小,看放还是不放更值钱
int inBag = dp[i - 1][j - weight[i]] + value[i];
int notInBag = dp[i - 1][j];
dp[i][j] = Math.max(inBag, notInBag);
}
}
}
遍历顺序
大家可以看出,虽然两个for循环遍历的次序不同,但是dp[i][j]所需要的数据就是左上角,根本不影响dp[i][j]公式的推导!
但先遍历物品再遍历背包这个顺序更好理解。
其实背包问题里,两个for循环的先后循序是非常有讲究的,理解遍历顺序其实比理解推导公式难多了。
代码随想录里这么写的 ⬆️
感悟
我觉得如果只是理解这个递推公式我可以
但是为什么要这么递推呢?或者说,我一想到背包容量 j 在变化,我就不理解。为什么要看 j = 2的时候,第二件物品放进去和不放进去的的价值???
我再捋一捋
代码随想录里这么写的⬇️
讲了这么多才刚刚把二维dp的01背包讲完,这里大家其实可以发现最简单的是推导公式了,推导公式估计看一遍就记下来了,但难就难在如何初始化和遍历顺序上。
可能有的同学并没有注意到初始化 和 遍历顺序的重要性,我们后面做力扣上背包面试题目的时候,大家就会感受出来了。
下一篇
还是理论基础,我们再来讲一维dp数组实现的01背包(滚动数组),分析一下和二维有什么区别,在初始化和遍历顺序上又有什么差异,敬请期待!
代码
public class bagProblem {
public static void main(String[] args) {
int[] weight = {1,3,4};
int[] value = {15,20,30};
int bagSize = 4;
dynamic(weight,value,bagSize);
}
public static void dynamic(int[] weight, int[] value, int bagSize) {
//在[0, i]的物品里任选,最大的价值是 dp[i][j]
int[][] dp = new int[weight.length][bagSize + 1];
//初始化
//当背包容量j=0的时候,一个物品也装不进去,所以第一列全是0
for (int i = 0; i < weight.length; i++) {
dp[i][0] = 0;
}
//初始化第一个物品的情况
for (int j = 0; j < dp[0].length; j++) {
//物品超过背包容量了
if (weight[0] > j) {
dp[0][j] = 0;
} else {
dp[0][j] = value[0];
}
}
//递推公式 我选择先遍历物品再遍历背包
for (int i = 1; i < weight.length; i++) {
for (int j = 1; j < dp[0].length; j++) {
//如果这个东西 放不进去了
if (weight[i] > j) {
dp[i][j] = dp[i - 1][j];
} else {
//能放进去就比大小,看放还是不放更值钱
int inBag = dp[i - 1][j - weight[i]] + value[i];
int notInBag = dp[i - 1][j];
dp[i][j] = Math.max(inBag, notInBag);
}
}
}
// 打印dp数组
for (int i = 0; i < dp.length; i++) {
for (int j = 0; j < dp[0].length; j++) {
System.out.print(dp[i][j] + "\t");
}
System.out.println("\n");
}
}
}
01背包问题 一维
https://programmercarl.com/%E8%83%8C%E5%8C%85%E7%90%86%E8%AE%BA%E5%9F%BA%E7%A1%8001%E8%83%8C%E5%8C%85-2.html
视频讲解:https://www.bilibili.com/video/BV1BU4y177kY
第一印象
直接看题解,怎么用一维dp数组就做出来
看完题解的思路
重量 | 价值 | |
---|---|---|
物品0 | 1 | 15 |
物品1 | 3 | 20 |
物品2 | 4 | 30 |
bagsize = 4
1. 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](一维数组,也可以理解是一个滚动数组)。
所以,在一维dp数组中,dp[j]表示:容量为j的背包,所背的物品价值可以最大为dp[j]。
2. 递推公式
上面提到了
dp[i][j] = max(dp[i][j], dp[i][j - weight[i]] + value[i]);
就是把 i 的维度去掉了。但仍然需要两层 for 循环
3. 初始化
dp[0] 一定是 0. 因为背包容量j为0的时候,什么也装不进去,价值自然是0.
那剩下的呢?我要看递推公式,每次用计算出的结果和自己相比较,取最大的。所以初始化的数字一定是 <= 0 的。而价值不能是负的,更符合实际意义,所以初始化成 0 就好了。
4.遍历顺序(我觉得最重要)
在二维的时候,我们正序遍历。
但在一维的时候,我们要倒序遍历。先给出结论
记住公式 dp[i][j] = max(dp[i][j], dp[i][j - weight[i]] + value[i]);
举上面的例子,
物品0在 j=1 的时候,dp[1] = max( dp[1], dp[0] + value[0] ) = max (0, 0+15) = 15. 合理
物品0在 j=2 的时候,dp[2] = max ( dp[2], dp[1] + value[0] ) = max (0, 30) = 30. 这就不合理了
这道题每个物品只能用一次
物品0大小是 1,那么背包容量j=2的时候,应该还是1个物品0的价值15。
但是正序遍历会让递推公式在计算价值的时候,把物品0的价值在 j=1 的时候和 j=2 的时候加在一起。
数值上看确实是不合理的,但是逻辑上呢?
我觉得是这样的,包现在是空的,我们把物品0 往里放,算包的容量如果是 1 2 3 4时候的最大价值。如果能放进去,就是这个物品不放包里时(也就是j - weight[i])包里的价值 + 这件物品的价值。
而这个不放包里时包里的价值应该是 上一件物品在这个 j 的最大价值。如果顺序遍历,比如 j=1 的时候是15,j = 2的时候是15 + 物品的价值15.
这里的第一个15代表容量为 1 时,物品0的最大价值。而不是什么也不放时的最大价值。
举个数量多的例子。
也就是对于重量为 1 的物品 4 来说,他在容量为j=5 的时候,计算的结果应该是,物品0~3 在 j=4 时的最大价值 + 物品4 的最大价值。而不是物品0~4在 j=4 时的最大价值 + 物品4 的价值。
对于物品 i, dp[j] 的数值是基于 0~ i-1 件物品在 j 和 j-weight[i] 的最大价值,也就是这个dp[j] 是用自己和滚动前它左侧的某个数算出来的,所以不能正序遍历,不然它就是基于滚动后左侧的某个数 和 自己算出来的了。就不对了。滚动后左侧的数会让它自己反复的加了多次,感到疑惑的话就拿题解里的例子画一画就行了
所以要倒序遍历背包容量 j。这样dp[j] 就可以基于滚动前 左侧数值求出了。
0-1背包的究极遍历和初始化结论,我觉得也是dp问题的一个经验。
上面是我从产生疑问 到 解决疑问的思路过程。
总之,我基于我和滚动前左边的数算出来的,那么求我之前,我左面的数不能求。所以倒序
我好像找到了规律,首先要画出来方便理解
1.机器人格子那道题,每个格子由上面和左边的格子求出来,所以初始化要第一行和第一列。而遍历的时候无所谓,一行一行,一列一列都可以。如图
2.二维dp的背包,每个数字由上面的和左上方的某个求出来,所以初始化的时候要第一行和第一列。而遍历的时候,也无所谓,每个数都是由上一行和左上方数求出来的。如图
3.一维dp的背包,每个数字基于自己和滚动前左面的某个(就是二维压缩后的结果)。所以初始化要控制自己很小,和初始化dp[0]。而遍历的时候,只能从后向前才能达到图里的样子。
实现中的困难
实现的时候没有什么困难。
for (int j = bagSize; j >= 1; j--) {
if (j >= weight[i]) {
dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i]);
} else {
//第i个放不进去时,0~i个的最大价值和0~i-1的最大价值相同
//dp[j] = dp[j];
}
}
注意这里 j >= weight[i]
第一次写成 > 了
感悟
思考完这些,我就知道背包问题和之前做的动态规划相同的地方了。
之前做的动态规划, 1和2的时候一般很简单,3开始就是找规律了。会有那种数量关系或者推导关系,比如3是依靠12,4依靠23. 或者我依靠上面和左面的格子。或者我是通过比较两个数值最大的那个得来的,一个数值比较直观,一个数值是依靠我之前的哪个dp数组的数求来的。
他们的关系往往比较简单,体现在数字上。
而背包问题就不一样了。容量在变,物品在变。做2维数组dp背包的时候我想不出来这种逻辑关系。。。我不理解为什么要看不同容量 j 时候的最大值?包容量不变的啊。
做到1维的时候我就明白了,0~i个物品放进容量 j 的背包的最大值取决于,放包里时 0~i-1个物品放进容量 j - 第i个物品大小的背包的最大值,和搁外头时0~i-1 个物品放进容量 j 的背包的最大值。 总之就是0~i 和 0~i-1 有关系。由于 容量 j - 第i个物品大小的背包的最大值 是不确定的,要把 0~i-1 在每个 容量j 的最大值都求出来。这就是为什么要有不同的 容量 j 。
代码
public class bagProblem {
public static void main(String[] args) {
int[] weight = {1, 3, 4};
int[] value = {15, 20, 30};
int bagWight = 4;
dynamic2(weight, value, bagWight);
}
// 二维dp数组解决 0-1 背包问题
public static void dynamic(int[] weight, int[] value, int bagSize) {
//在[0, i]的物品里任选,最大的价值是 dp[i][j]
int[][] dp = new int[weight.length][bagSize + 1];
//初始化
//当背包容量j=0的时候,一个物品也装不进去,所以第一列全是0
for (int i = 0; i < weight.length; i++) {
dp[i][0] = 0;
}
//初始化第一个物品的情况
for (int j = 0; j < dp[0].length; j++) {
//物品超过背包容量了
if (weight[0] > j) {
dp[0][j] = 0;
} else {
dp[0][j] = value[0];
}
}
//递推公式 我选择先遍历物品再遍历背包
for (int i = 1; i < weight.length; i++) {
for (int j = 1; j < dp[0].length; j++) {
//如果这个东西 放不进去了
if (weight[i] > j) {
dp[i][j] = dp[i - 1][j];
} else {
//能放进去就比大小,看放还是不放更值钱
int inBag = dp[i - 1][j - weight[i]] + value[i];
int notInBag = dp[i - 1][j];
dp[i][j] = Math.max(inBag, notInBag);
}
}
}
// 打印dp数组
for (int i = 0; i < dp.length; i++) {
for (int j = 0; j < dp[0].length; j++) {
System.out.print(dp[i][j] + "\t");
}
System.out.println("\n");
}
}
//一维dp数组解决 0-1 背包问题
public static void dynamic2(int[] weight, int[] value, int bagSize) {
int number = weight.length;
//dp数组,大小是背包的大小 + 1
int[] dp = new int[bagSize + 1];
//初始化
//都是0 省略了
//递推公式
for (int i = 0; i < number; i++) {
for (int j = bagSize; j >= 1; j--) {
if (j >= weight[i]) {
dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i]);
} else {
//第i个放不进去时,0~i个的最大价值和0~i-1的最大价值相同
//dp[j] = dp[j];
}
}
}
for(int k = 0; k <= bagSize; k++) {
System.out.println(dp[k]);
}
}
}
416. 分割等和子集
本题是 01背包的应用类题目
https://programmercarl.com/0416.%E5%88%86%E5%89%B2%E7%AD%89%E5%92%8C%E5%AD%90%E9%9B%86.html
视频讲解:https://www.bilibili.com/video/BV1rt4y1N7jE
第一印象
我觉得得排序,排序了方便求和大小。还能剪枝优化。
但是我怎么看不出来和背包有啥关系呢。感觉回溯也能做?
我排序了,然后让 到第i个数的和是dp[i] 。初始化dp[0] = 0;
然后
for (int i = 1; i < dp.length; i++) {
//到第 i 个数的总和是dp[i]
dp[i] = dp[i - 1] + nums[i - 1];
//剩下的数的和是rest
int rest = totalSum - dp[i];
if (rest == dp[i]) return true;
}
炸一下想没毛病。但其实 1122这样的就不行了,12 = 12 排序了去看部分和,1 2 4 6 都不是总和6的一半,所以不行。
那换个思路,找几个数字,让他们的和是总和totalSum的一半呢?
还是没法往背包上靠,看题解了。
记录一下失败的思路代码
class Solution {
public boolean canPartition(int[] nums) {
//对原数组排序
Arrays.sort(nums);
//到第 i 个数的总和是dp[i]
int[] dp = new int[nums.length + 1];
dp[0] = 0;
int totalSum = 0;
for (int i = 0; i < nums.length; i++) {
totalSum += nums[i];
}
for (int i = 1; i < dp.length; i++) {
//到第 i 个数的总和是dp[i]
dp[i] = dp[i - 1] + nums[i - 1];
//剩下的数的和是rest
int rest = totalSum - dp[i];
if (rest == dp[i]) return true;
}
return false;
}
}
看完题解的思路
就是我上面说的找几个数字,让他们的和是总和totalSum的一半
思路是对的
比如 1 5 5 11,就是找数字的和为11,数字相当于物品,11相当于背包容量。
问题变成能不能找到几个物品塞满背包,而之前的背包问题是每个物品还有价值,问往背包里放物品,最大价值是多少,不一定是塞满的。
所以我联系不上背包问题,因为我没找到两个数组。
这道题很巧妙啊,物品重量看做 1 5 5 11,价值也看做1 5 5 11.
拿物品放在包里,如果dp[11] = 11 就说明这个容量为11的背包被塞满了,而塞满他的物品的重量也正好是11.
诶还是有点绕,为什么要看最大价值啊?背包问题不是每次都求最大价值吗?
看题解学习一下思路吧。
dp数组
01背包中,dp[j] 表示: 容量为j的背包,所背的物品价值最大可以为dp[j]
本题中每一个元素的数值既是重量,也是价值。
套到本题,dp[j]表示 背包总容量(所能装的总重量)是j,放进物品后,背的最大重量为dp[j]。
所以dp[target] = target 就代表放入物品的重量 等于 背包的容量。
这里容易困惑,背包还能装不满吗?想象打游戏时候,三级包确实可以装不满。
递推公式
物品i的重量是nums[i],其价值也是nums[i]。
所以递推公式:dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]);
初始化
和背包是一样的
遍历顺序
和背包是一样的
但我还是不理解为什么要求最大的重量,但是两个都是重量我理解了。
那么到底为什么每次要看最大的属性B,最大的value,最大的重量呢?
之前的题因为题里说了要最大的value
而这道题,容量为 j 的包,最大的重量都不够 j(塞满背包),就不可能了啊。肯定不会超过的 j 的。所以去看最大的属性B 就行。
背包问题再次深入思考
背包问题就是,一堆东西有不同大小(大小属性A),我要往一个大小为size的包里装。
在动态规划算法的过程中,我能求出这些东西的另一个属性(B)的最大值,在这里我把另一个属性也当做这些东西的大小(也就是上面说的属性A)。
我能求出**dp[j] 含义是这些东西在包容量 j 时候的属性B的最大值。**在0-1背包里就是价值,在这里仍为大小。
而我最后希望容量 j 是size ,也就是背包的总容量,求这个情况时候的属性B最大值。但是dp[size] 依赖 dp[size - 1] ,依赖dp[size - 2] …… ,dp[j] , dp[j -1]……dp[0]。
在这个过程中,并不控制属性A:大小,一定是恰好塞满背包容量size,只是保证能装进去,且属性B的结果是最大的。
但是这道题属性AB是相同的,我想求背包是否被塞满,也就是看背包容量是满的时候 j = target,背包里的东西 dp[target] 有没有 target 这么多。
这里的有没有target这么多,用的是属性B,因为上面说了,dp[j] 含义是这些东西在包容量 j 时候的属性B的最大值。
那么属性A用在哪里呢?用来生成这个数组。但数组里面存的是属性B。这道题恰巧都是重量而已。
那么到底为什么每次要看最大的属性B,最大的value,最大的重量呢?
之前的题因为题里说了要最大的value
而这道题,容量为 j 的包,最大的重量都不够 j(塞满背包),就不可能了啊。肯定不会超过的 j 的。所以去看最大的属性B 就行。
实现中的困难
我先回家做饭,tmd牛羊肉我求你别下班啊
思路清晰之后就是套用背包。
感悟
我觉得把背包套用,还可以吧
但是理解逻辑,还是会磕巴
代码
class Solution {
public boolean canPartition(int[] nums) {
//数组求和
int sum = 0;
for (int i = 0; i < nums.length; i++) {
sum += nums[i];
}
//如果和是奇数,分不出两个相等的
if (sum % 2 != 0) return false;
//算出选出的数的和应该是多少
int target = sum / 2;
//dp数组
int[] dp = new int[target + 1];
//初始化 都是0
//递推公式
for (int i = 0; i < nums.length; i++) {
for (int j = target; j >= 1; j--) {
if (j >= nums[i]) {
dp[j] = Math.max(dp[j], dp[j - nums[i]] + nums[i]);
}
}
}
for (int i = 0; i < dp.length; i++) {
System.out.println(dp[i]);
}
return dp[target] == target;
}
}