背包问题,包含01背包和完全背包

动归五部曲

1、dp数组的定义

2、递推公式

3、dp数组的初始化

4、迭代顺序

5、打印dp数组检查

        一般动态规划都是按照这5步来考虑,接下来笔者将以这五部的顺序来解释解释01背包问题完全背包问题

01背包(二维)

        有n种物品,需要装满容量为bagsize的背包。每种物品都有对应的价值value[i]和对应的重量weight[i]。其中每件物品只能使用一次,求解背包所能装的最大value

1、dp数组的含义

/**
    weight.length表示物品的数量
    bagSize 表示背包容量
    dp[weight.length][bagSize] 表示从下标0到下标weight.length的物品中任取,每种物品只能使用一            
    次的情况下,放进容量为bagSize的背包中的最大价值
    
    为什么要定义bagSize+1呢?因为需要考虑背包容量为0的情况
**/
int[][] dp = new int[weight.length][bagSize + 1];

2、递推公式

/**
    取下标0-i的物品放入容量为j的背包,每个物品来说,都有取或者不取两个状态

    1、当背包容量不够时,不考虑第i个物品时,即选择取下标为0~i-1的物品放入容量为j的背包,
    按照dp数组的定义可以表示为dp[i-1][j]
        即:dp[i][j] = dp[i-1][j]
    2、背包容量够时,考虑取第i个物品时,可以取也可以不取
        取的话,那么就把物品i放进去,那么背包容量还剩下j-weight[i],剩下的背包容量只能在0~i-1中        
        取物品,按照定义,此时前i-1个物品的价值为即dp[i-1][j-weight[i]],加上物品i的价值
        value[i],即dp[i-1][j-weight[i]] + value[i]
       如果不取的话,同上dp[i-1][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]);
}

3、数组初始化

/**
    二维dp数组dp[i][j]由dp[i-1][j]、dp[i-1][j-weight[i]]两种状态推得。
    以i为纵轴,j为横轴画的dp数组
    dp[i-1][j]在dp[i][j]的上方,dp[i-1][j-weight[i]]在dp[i][j]的左上方
    因此递推方向由左上角和上方递推,应当初始化上方(即最上一行)和左边(最左列)

    两种考虑初始化情况:
        背包容量j为0,不管选择哪些物品,所得到的物品最大价值一定为0,所以dp[i][0] = 0;
        物品选取下标为0时,即第一个物品,背包容量从weight[0]开始,不论背包容量多大,所得到的物品
        最大价值一定为value[0],所以dp[0][j] = value[0];
**/
for (int j = weight[0]; j <= bagSize; j++) {
    dp[0][j] = value[0];
}

4、递推顺序

        关于迭代顺序这里先说二维的,第三部初始化说了,递推是由左上角上方开始递推的,因此在递推dp[ i ][ j ]时,需要考虑其左上角和上方是否有值,因此遍历顺序一定是从左到右,从上到下。可以参考代码随想录的这张图。具体遍历顺序是先物品后背包还是先背包后物品,在这里则没什么影响。为什么呢?

        因为将背包重量的 j 轴和物品种类的 i 轴交换,遍历顺序还是一样能保证在dp[ i ][ j ]之前初始化左上角和上方。换句话说,迭代顺序保证的是我们的dp数组有序的填上对应的值,只要能保证dp数组在对应值处的上一时刻被填上值,那么我们的迭代顺序就是正确的。举个例子,dp[ i ][ j ]这个对应值的求解就依赖上一个时刻的dp[i-1][j-weight[i]] 和 dp[i-1][j],只要保证这两个在dp[ i ][ j ]之前被填上值就可以了。

5、打印dp数组

01背包(一维)

1、dp数组的含义

        由于递推公式中dp[ i ][ j ]都是用到了dp[i - 1]即上一层的数据,所以可以将dp[ i ][ j ]优化为

dp[ j ]。

想象将dp二维数组压缩成一排。

        这里dp[ j ]数组的表示容量为 j 的背包中所得到的物品的最大价值。

2、递推公式

递推公式变为如下:

dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i])。

3、数组初始化

        由于二维中dp数组是由上方和左上角递推的,所以初始化最上行和最左列。

        当压缩称为一行之后,上方的值本身已经在迭代的过程中压缩进下方了,那么初始化左边即可。

        即dp[ 0 ] = 0;

4、递推顺序

        由于dp[ j ]的值依赖左边,那么在填入dp[ j ]的值之前,dp[ j ]的左边不能被覆盖,即应当保持dp[ j ]处的对应值在上一个时刻的值被填上,因此我们如果从左往右遍历,则会导致dp[ j ]被算出来之前,他所依赖的上一个时刻 i-1 的值已经被覆盖成时刻 i ,那么dp[ j ]就无法被正确算出来了。因此遍历顺序应该是从右边到左边,即倒序遍历。

        那么是先物品再背包呢?还是先背包再物品呢?直接给出结论,只能先物品再背包且不能交换。原因很简单,因为我们首先要保证数组从右边倒序遍历,先背包再物品的情况下,我们无法保证“保持dp[ j ]处的对应值在上一个时刻的值被填上”这个原则。

for (int i = 0; i < wLen; i++){
    for (int j = bagWeight; j >= weight[i]; j--){
    dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i]);
    }
}

5、打印dp数组

完全背包(二维)

        完全背包和01背包的唯一区别就在于,完全背包的物品可以使用多次,无限使用。其他的均一样。

1、dp数组的含义

同01背包定义。

2、递推公式

        递推公式的唯一不同在于,01背包在考虑放入物品 i 时,放入物品 i 了背包容量只剩下了j-weight[i]的情况下,只能放入物品 0 ~ i-1,而完全背包可以放入物品 0 ~ i 。因此递推公式如下:

        只有dp[i][j-weight[i]] + value[i]这部分和01背包的dp[i-1][j-weight[i]] + value[i]不同。

if (j < weight[i]) {
    dp[i][j] = dp[i-1][j];
} else {
    dp[i][j] = Math.max(dp[i-1][j] , dp[i][j-weight[i]] + value[i]);
}

3、数组初始化

        同上,这里的dp[i][j]依赖的不再是左上角和上方了,而是左正方的dp[i][j-weight[i]] 和 上方的dp[i-1][j]。因此初始化还是同样初始最上行和最左列,虽然初始地方一样,但是要注意和01背包初始化原因的区别。

4、递推顺序

        同01背包的二维遍历顺序,无论是先物品后背包还是先背包后物品都可以,只需要保证dp[i][j]的值被算出来之前,正上方和左上方都有值即可,因此是顺序遍历从小到大

5、打印dp数组

完全背包(一维)

        其他的都和上面的01背包的一维一样。这里只说遍历顺序

        刚刚说01背包为了保证“保持dp[ j ]处的对应值在上一个时刻的值被填上”需要从右往左倒序遍历。其实这种情况保证了物品只放一次。但是如果是正序遍历呢?那么会保证这个物品被放入多次。接下来我将以两个方面来说一下为什么完全背包需要正序遍历。

        1、从二维递推公式入手。01背包的dp[ i ][ j ]依赖的是dp[ i - 1][ j ] 和 dp[ i -1 ][ j - weight[ i ]]。在考虑物品 i 的时候,我们需要考虑到 i -1 的左边和上面情况,也就是上面说的左上角上方。我们把考虑物品 i 称为 i 时刻,虑物品 i -1  称为 i-1 时刻。

        背包容量为 j 的情况下,当我们压缩到一维dp数组时,我们的 i 时刻的 dp[ j ] 求解需要用到   i - 1时刻的 dp[ j ] 的左方的值 i - 1时刻的 dp[ j ] 的值,所以我们是从右往左遍历,保证在 i 时刻的 dp[ j ] 被填入之前, 它所依赖的 i -1 时刻 的dp[ j ] 左方的值 不会被修改

        那么完全背包为什么是从右边遍历呢?二维完全背包来看,我们用到的是左正方上方。即我们用到的其实是:背包容量为 j 的情况下, i 时刻的dp[ j ] 的左方的值 i - 1时刻的dp[ j ]的值

        看出区别了吗?区别就在于01背包用的是i - 1时刻的 dp[ j ] 的左方的值, 而完全背包用的则是 i 时刻的dp[ j ] 的左方的值

        因此完全背包在更新 i 时刻的dp[ j ] 之前,需要有 i 时刻的dp[ j ] 的左方的值,因此是从左往右遍历。

        2、从含义入手。如果是从右往左遍历,还没遍历到的地方(即dp[ j ] 的左方)是第 i - 1 个时刻,物品 i 还没有被放入,遍历到了才考虑放不放物品 i ,因此物品 i 只被考虑放入了一次。

        而从左往右遍历的话,遍历到 i 时刻的 dp[ j ] 时,已经遍历过了 i 时刻的dp[ j ] 的左方,即在dp[ j ] ​​​​​​​的左方考虑过了是否加入物品 i ,在 dp[ j ] 又考虑,则考虑了多次,因此可能会重复加入物品 i 。

        至于完全背包是先物品还是先背包,这里直接给出结论。都可以。为什么呢?大家可以画图自行推导。(不过笔者更喜欢先物品再背包,因为这样始终不会出错。)

总结

        遍历顺序如果不懂的可以画图,遍历顺序一般都是通过递推公式推出来的,从哪个状态转移到那个状态是不同的,因此遍历顺序不是乱写的。初始化则是根据遍历顺序来进行弥补的,因此初始化可以通过dp数组的定义弥补,也可以通过遍历顺序来进行弥补(因为你想要递推你肯定得有第一个值吧?你自己定义符合dp数组的第一个值就是初始化)。

        最后动归五部曲也是思考的一个方式。先确定dp数组的含义,再考虑根据dp数组的含义如何进行dp数组的递推,然后是递推的第一个值(也不一定是第一个,也可能是第一批)应当如何选择(如何初始化),最后是思考以什么顺序根据第一个值往后递推(遍历顺序),最后再改bug的时候可以通过打印dp数组看看是否符合自己的思路来进行调整。

        代码随想录的一刷结束。记录一下。后续还会更新关于二叉树的遍历、回溯算法、双指针、KMP算法等一些心得体会。也祝大家周末愉快。

  • 11
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值