动态规划——01背包的学习与思考

210 阅读12分钟

动态规划

01背包问题的学习与思考

这里是通过学习 代码随想录 网站中的动态规划——01背包问题,提出关于个人的小小见解

由于是直接学自于代码随想录,所以我的示例就会类似于代码随想录的示例,主要用于个人学习,还请多多包涵。

01背包问题的概念

所谓纯01背包,就是有n件物品和一个最多能够背重量为w的背包。每个物品都有其各自的重量weight[i] 和 价值value[i],每个物品只能使用一次,一般来说是求解当前背包所能够装入的最大价值总和是多少。

这里仍然是通过动态规划五部曲来对01背包问题进行一一说明

动态规划五部曲(二维dp数组)

这里先简单说一下动态规划五部曲

  1. 确定dp数组以及其具体的含义
  2. 确定递推公式
  3. 确定dp数组如何进行初始化
  4. 确定遍历顺序
  5. 自己动手举例推导dp数组

下面就通过动态规划五部曲来一一对01背包问题进行讲解

这里我们先假设有如下物品:

重量价值
物品0115
物品1327
物品2434

我们的背包最大容量是4

(一)确定dp数组及其含义

首先确定dp数组 以及其含义,这里我们设定dp数组为二维数组int[][] dp,因为分别有物品和背包容量两方面需要进行展示。

dp[i][j]的含义是,在容量为j时,下标从0~i 物品,我们任取,所能够承载的最大价值

未命名表单.png

这里我们就先动手来按照上面dp数组的含义,来手动自己来试着填写一下。

01背包变化图.png

(二)确定递推公式

先说结论,递推公式为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数组的含义

01背包初始化.png

(四)确定遍历顺序

这里实际上究竟是先遍历物品还是先遍历背包都是一样的,实际上都没差。

为什么没差呢?

我们抓住本质,首先定好dp[i][j] 和上图一行,行是物品,列是容量。

那么我们如果先遍历物品再遍历容量(也就是正常的那种遍历方式啦~)

代码为:

for(int i=1;i<n;i++){
    for(int j=1;j<=bagSize;j++){
        ……
    }
}

填充数据的方式如图:

遍历方式1.png

那如果是先遍历容量,再遍历物品呢?

for(int j=1;j<=bagSize;j++){
    for(int i=1;i<n;i++){
        ……
    }
}

那么遍历顺序就是

遍历顺序2.png

综上,实际上不管是什么遍历顺序,依据我们前面所写的那个递推公式。

每一个值都依赖于其“左上角方向”的值,从上面两个图我们能够明显看出来,不管是何种遍历方式,每个框其左上角的值,都已经显现,所以才会说没区别。

(五)自己动手举例推导dp数组

真的有用,助于理解吧反正是。

一维dp数组实现01背包问题

实际上,针对上面的二维数组,我们实际上可以这么理解:

针对二维数组的每一行(我们这里默认先遍历物品,再遍历容量),其上一行,就是它的历史

每个当前做出的选择,都要基于当前的状态以及历史的选择做出判断

比如下面这个图: 黄色行,实际上是蓝色行的历史,所以我们递推公式 dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]); 实际上只会是i-1行来作为历史而不会是i-2;

更比如绿色行,就只会用蓝色行来作为历史,而不会去考虑黄色行(实际上这样说并不准确);更加准确的说法是蓝色行中的每一个格子,都考虑过了黄色行。所以绿色行只需要考虑蓝色行。

(黄加蓝变绿?! hh)

一维dp讲解.png

那么我们讨论使用一维数组,就是这个状态可以进行压缩:

先直接写出递推过程:

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背包问题的原则(每个物品最多只能放一个)

一维dp遍历顺序.png

题目实例:

leetcode416. 分割等和子集

这道题实际上是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数组的重要性!!!!!!!

leetcode 494. 目标和

这道题本来我是使用回溯法来写,但是有这么个测试用例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=targetneg=(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]];

    • 需要考虑两种情况:

      1. 不选择当前元素 num

        • 如果我们不选择当前元素,那么问题就简化为使用前 i-1 个元素来达到和 j 的方案数,即 dp[i-1][j]
      2. 选择当前元素 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];
        
    }
}