动态规划
01背包问题的学习与思考
这里是通过学习 代码随想录 网站中的动态规划——01背包问题,提出关于个人的小小见解
由于是直接学自于代码随想录,所以我的示例就会类似于代码随想录的示例,主要用于个人学习,还请多多包涵。
01背包问题的概念
所谓纯01背包,就是有n件物品和一个最多能够背重量为w的背包。每个物品都有其各自的重量weight[i] 和 价值value[i],每个物品只能使用一次,一般来说是求解当前背包所能够装入的最大价值总和是多少。
这里仍然是通过动态规划五部曲来对01背包问题进行一一说明
动态规划五部曲(二维dp数组)
这里先简单说一下动态规划五部曲
- 确定dp数组以及其具体的含义
- 确定递推公式
- 确定dp数组如何进行初始化
- 确定遍历顺序
- 自己动手举例推导dp数组
下面就通过动态规划五部曲来一一对01背包问题进行讲解
这里我们先假设有如下物品:
| 重量 | 价值 | |
|---|---|---|
| 物品0 | 1 | 15 |
| 物品1 | 3 | 27 |
| 物品2 | 4 | 34 |
我们的背包最大容量是4
(一)确定dp数组及其含义
首先确定dp数组 以及其含义,这里我们设定dp数组为二维数组int[][] dp,因为分别有物品和背包容量两方面需要进行展示。
dp[i][j]的含义是,在容量为j时,下标从0~i 物品,我们任取,所能够承载的最大价值。
这里我们就先动手来按照上面dp数组的含义,来手动自己来试着填写一下。
(二)确定递推公式
先说结论,递推公式为dp[i][j]=Math.max(dp[i-1][j],dp[i-1][j-weight[i]]+value[i])
一般写上面的递推公式的时候,我自己就会碎碎念——遍历到下标为i的物品,在当前容量为j的情况下,在0~i物品之中任选,我所能够获得的最大价值为 A:不放入当前物品 和 B:放入当前物品之后,剩余容量所能承载最大值。 这A、B两者之间的较大者。
这里我们通过上面的图,来进行递推公式说明:
针对上图中,遍历到下标为1的物品,看绿色的框即
dp[1][3]。我们来探讨一下,为什么
dp[1][3]的值就是27而不是别的?实际上,上图中三个绿色的框已经一定程度有提示:
将递推公式写出来就是:
int data1 = dp[0][3] //不放入当前物品1,在容量为3的前提下所能获取的最大价值
int data2 = dp[0][3-3] + 27 //放入当前物品1,然后再在剩余容量为0时候,容量0所能获取到最大价值
dp[1][3] = Math.max(data1,data2);这时候
data1为15,data2为0+27=27 ; 故而dp[1][3]取值27对于上图中的黄色框框同理,试着推理一下吧。
(三)dp数组如何初始化
dp数组初始化的问题很重要! 哥们儿后面有道题吃了大亏。
实际上通过上面的推导,我们已经知道,dp数组某个中间的值,是可以通过其“左上角”推导出来的。
但是对于边界值呢?这也就意味着,我们的第一行和第一列一定是要在dp数组遍历之前来进行初始化的。
这里dp数组的初始化仍然是要牢牢紧扣住dp数组的含义
(四)确定遍历顺序
这里实际上究竟是先遍历物品还是先遍历背包都是一样的,实际上都没差。
为什么没差呢?
我们抓住本质,首先定好dp[i][j] 和上图一行,行是物品,列是容量。
那么我们如果先遍历物品再遍历容量(也就是正常的那种遍历方式啦~)
代码为:
for(int i=1;i<n;i++){
for(int j=1;j<=bagSize;j++){
……
}
}
填充数据的方式如图:
那如果是先遍历容量,再遍历物品呢?
for(int j=1;j<=bagSize;j++){
for(int i=1;i<n;i++){
……
}
}
那么遍历顺序就是
综上,实际上不管是什么遍历顺序,依据我们前面所写的那个递推公式。
每一个值都依赖于其“左上角方向”的值,从上面两个图我们能够明显看出来,不管是何种遍历方式,每个框其左上角的值,都已经显现,所以才会说没区别。
(五)自己动手举例推导dp数组
真的有用,助于理解吧反正是。
一维dp数组实现01背包问题
实际上,针对上面的二维数组,我们实际上可以这么理解:
针对二维数组的每一行(我们这里默认先遍历物品,再遍历容量),其上一行,就是它的历史
每个当前做出的选择,都要基于当前的状态以及历史的选择做出判断
比如下面这个图: 黄色行,实际上是蓝色行的历史,所以我们递推公式 dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]); 实际上只会是i-1行来作为历史而不会是i-2;
更比如绿色行,就只会用蓝色行来作为历史,而不会去考虑黄色行(实际上这样说并不准确);更加准确的说法是蓝色行中的每一个格子,都考虑过了黄色行。所以绿色行只需要考虑蓝色行。
(黄加蓝变绿?! hh)
那么我们讨论使用一维数组,就是这个状态可以进行压缩:
先直接写出递推过程:
for(int i = 0; i < weight.size(); i++) { // 遍历物品
for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
1、为什么可以使用一维dp数组 int[]dp
根据前面我们的说明,实际上使用二维数组,每次只用到了上一层的“历史”,然后再之前的“历史”我们实际上是没有再使用的。 那么我们实际上可以这么考虑,每次我们不另起一行,而是进行“覆盖”,这样就实现了状态空间的压缩,以此来减少空间复杂度。
2、一维dp数组怎么遍历?
直接说结论,先遍历物品,再遍历dp数组,但是遍历dp数组的时候,要求下标从大到小进行遍历。
一维dp数组的递推公式为 dp[j]= Math.max(dp[j], dp[j-weight[i]]+value[i]);
这里通过图来进行说明
黄色仍然是作为蓝色的历史:
如果我们是从小到大进行遍历,那么依据dp数组的递推公式,dp[1]只会依赖于dp[0],但是这时候dp[0]已经是蓝色的了! 它并不是历史,它是当前这一行的现阶段!
而从大到小进行遍历,那么依据dp数组的递推公式,也就是下面第一个图,dp[3]依赖的只会是前面黄色的部分,黄色仍然为历史。
换一个角度思考,实际上自个儿手推一下就行了。
如果从小到大进行遍历,那么比方说在dp[1]的时候,物品0会用一次,dp[2]的时候,
dp[2]=Math.max(dp[2],dp[1]+15)= 30 也就是说,物品0会用多次!这完全违背了01背包问题的原则(每个物品最多只能放一个)
题目实例:
这道题实际上是01背包的小小应用,说白了就是我给你一个背包,我希望你能够用这个背包,刚刚好把我当前所有物品的总价值的一半给装进去。能装进去,就返回true,不能就false;
/**
01背包问题
这道题每个物品实际上就是nums[i] 物品的数量就是nums.length
每个物品的重量也是nums[i] 价值也是nums[i]
*/
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 bagSize= sum/2;
//使用一维dp数组
int[] dp= new int[bagSize+1];
for(int i =0;i<nums.length;i++){
for(int j=bagSize;j>0;j--){
if(j<nums[i]){
dp[j]=dp[j];
}
else{
//不放当前物品
int data1=dp[j];
//放当前物品
int data2=dp[j-nums[i]]+nums[i];
int data = Math.max(data1,data2);
dp[j]=Math.max(dp[j],data);
}
}
}
return dp[bagSize]==bagSize;
}
}
第二道题,更加体现了初始化dp数组的重要性!!!!!!!
这道题本来我是使用回溯法来写,但是有这么个测试用例nums=[0,0,0,0,0,0,0,0,0,0,1] ,发现时间超时。
而更加有意思的是,这么个测试用例nums=[0,1],预期的结果是2。 这意味着什么?以为这两种情况分别是 -0+1 和 +0+1 。 我想表达的是什么呢? 是特别要注意这道题里面dp数组的初始化问题!!!在后面会有所说明。
首先分析这个题,这里我直接借用一下leetcode题解的说明(这写的真清楚)
记数组的元素和为 sum,添加 - 号的元素之和为 neg,则其余添加 + 的元素之和为 sum−neg,得到的表达式的结果为(sum−neg)−neg=sum−2*neg=target即neg=(sum−target)/2
由于数组 nums 中的元素都是非负整数,neg 也必须是非负整数,所以上式成立的前提是 sum−target 是非负偶数。若不符合该条件可直接返回 0。
直接看下面的代码可能会有很多疑问,这里一一来进行说明:
1、首先,dp数组设计为二维数组,应该没有什么问题,就是01背包常规操作。
2、初始化dp数组,无论是初始化第一行还是第一列,都比较奇怪,为什么要这么初始化?
牢牢把握住这道题dp数组的含义:
dp[i][j]表示在容量为j时,从物品0~i中任选,恰好能够把容量j填满,有多少种方案。 (两个关键—— 恰好,多少种方案)
- 也就意味着此时求的并不是最大容量,而是变种问题,多少种方案
那么对于第一行的初始化,
dp[0][j],肯定是只有nums[0]恰好等于容量j ,才有一个方案,否则无论是大于还是小于,都是0种方案对于第一列的初始化,
dp[i][0],诚然,此时容量确实是0,这没错。但是! 要考虑到nums[i]完完全全值是有可能为0的,比方说nums数组为{0,0,1},那么在容量为0时候,要将容量0填满,此时dp[0][0]就应该是2(表示两种方案,分别是-0 和 +0),此时dp[1][0]就应该是4,因为下标物品0和物品1的值都是0,可以是(-0、+0 或 -0、-0 或 +0、-0或+0、+0)3、dp的递推公式:
当
j<nums[i]时,这很好理解,当前物品i压根儿就没办法放进容量j,那么就只能使用历史数据dp[i-1][j]了当
j==nums[i]时,这时候满足放入第i个物品,容量j恰好被填满,那么就有dp[i][j]=dp[i-1][j]+dp[i-1][j-nums[i]];当
j>nums[i]时,这里我个人当时觉得很奇怪,此时为什么递推公式仍然是dp[i][j]=dp[i-1][j]+dp[i-1][j-nums[i]];
需要考虑两种情况:
不选择当前元素
num:
- 如果我们不选择当前元素,那么问题就简化为使用前
i-1个元素来达到和j的方案数,即dp[i-1][j]。选择当前元素
num:
- 如果我们选择当前元素,那么问题就变成使用前
i-1个元素来达到和j-num的方案数,即dp[i-1][j-num]。这里的关键是理解,
j > num并不意味着我们无法达到和j,而是我们正在考虑是否包括当前元素num。即使j大于num,我们仍然可能需要包括当前元素num来达到和j,或者我们可能不包括它。
class Solution {
public int findTargetSumWays(int[] nums, int target) {
int sum=0;
for(int num:nums){
sum+=num;
}
//依据公式判断是否存在解
int diff = sum - target;
if (diff < 0 || diff % 2 != 0) {
return 0;
}
//计算背包容量
int bagSize=(sum-target)/2;
//dp数组dp[i][j]的含义为,在容量为j的情况下
//从物品0~i中任选,有多少种方法填满容量j
int[][]dp = new int[nums.length][bagSize+1];
//初始化dp数组
//初始化第一行,只有刚刚好容量j==nums[0]重量,才能填满容量j
if(nums[0]<=bagSize){
dp[0][nums[0]]=1;
}
//初始化第一列
//这里很重要,为什么要初始化第一列?
//因为0,0的变换 -0 和 +0 是两种
int numZeros = 0;
for(int i = 0; i < nums.length; i++) {
if(nums[i] == 0) {
numZeros++;
}
dp[i][0] = (int) Math.pow(2, numZeros);
}
//遍历dp数组
for(int i=1;i<nums.length;i++){
for(int j=1;j<=bagSize;j++){
//当前容量j根本无法放入物品i 那么肯定只能使用历史
if(j<nums[i]){
dp[i][j]=dp[i-1][j];
}
/**
这里实际上是一个很令人疑惑的点。
我们进行拆分,如果j==nums[i] 下面这个代码肯定没问题,直接放进来就行了
但是j>nums[j]为什么还要这样子写?文章中有相吸说明
*/
else{
dp[i][j]=dp[i-1][j]+dp[i-1][j-nums[i]];
}
}
}
return dp[nums.length-1][bagSize];
}
}