重识背包问题(下)

3,916 阅读3分钟

"你的背包,让我走得好缓慢"

上文重识了 0-1背包问题,本文开始 完全背包 问题。

完全背包

问题形态

问题描述:NN 件物品和一个最多能承受重量为 WW(W=4W=4) 的背包,第 ii 件物品的重量为 weights[i]weights[i],价值为 values[i]values[i]每件物品可使用无限次。求解该背包承载物品的最大价值为多少?

iweightsvalues011513202430\begin{array}{c|cc} i & \text{weights} & \text{values} \\ \hline 0 & 1 & 15 \\ 1 & 3 & 20 \\ 2 & 4 & 30 \end{array}

问题分析

对于 010-1 背包,相同物品只能取 00 次或 11 次,而对于完全背包,相同物品可以取任意次(次数 0\ge 0)。

完全背包还有一个特殊的地方就是,选取物品时是否存在排列与组合的关系。举个例子,从集合 a, b, c 中选取,

  • 对于组合 (a,a,b)(a,b,a) 是完全一样的排列,因为 组合 强调整体,不强调顺序;

  • 对于排列 (a,a,b)(a,b,a) 是两种不同的排列,因为 排列 既强调整体,又强调顺序。

具体场景详解文末题解部分。

1、确定 dp 数组及含义

💥dp[i][j]dp[i][j] 代表从 [0,i][0,i] 中取任意物品,放进容量为 jj 的背包后的最大价值,其中 i[0,N)i \in [0, N)j[0,W]j \in [0, W](纵轴表示物品序号,横轴表示背包容量大小)。

dp[i,j]01234012\begin{array}{c|cc} dp[i,j] & \text{0} & \text{1} & \text{2} & \text{3}& \text{4}\\ \hline 0 \\ 1 \\ 2 \end{array}

2、确定 dp 状态方程

对于第 ii 件物品,有两种状态,即放入背包或不放入背包。

主动不放入:主动放弃第 ii 个物品,并不受限于背包的容量,即,dp[i][j]=dp[i1][j]dp[i][j] = dp[i-1][j]

被动不放入:这种情况下受限于背包的容量,不能再将 weights[i]weights[i] 的物品放入背包,此时 j<k×wights[i]j< k \times wights[i],即 dp[i][j]=dp[i1][j]dp[i][j] = dp[i-1][j],其中 k1k \ge 1,代表当前物品所取的个数

主动放入:那么当前背包容量一定存在 jk×wights[i]j \ge k \times wights[i],而且要先从背包里腾出这个空间,才能将重量为 weights[i]weights[i],价值为 values[i]values[i] 的物品放入背包,即,dp[i][j]=dp[i1][jk×weights[i]]+k×values[i]dp[i][j] = dp[i-1][j-k \times weights[i]] + k \times values[i]

综上所述,令 z=jk×weights[i]z=j - k \times weights[i]

dp[i][j]={dp[i1][j]z<0max(dp[i1][j],dp[i1][z]+k×values[i])z0dp[i][j] = \begin{cases} dp[i-1][j] & z < 0 \\ max(dp[i-1][j], dp[i-1][z] + k \times values[i]) & z \ge0 \end{cases}

3、确定 dp 初始状态

首先,如果背包的容量为 00,所承载的最大价值必定为 00dp[i][0]=0dp[i][0] = 0,即

dp[i,j]01234001020\begin{array}{c|cc} dp[i,j] & \text{0} & \text{1} & \text{2} & \text{3}& \text{4}\\ \hline 0 & 0 \\ 1 & 0 \\ 2 & 0 \\ \end{array}

其次,只有第一件物品时(weights[0]=1weights[0] = 1values[0]=15values[0] = 15),不同容量的背包所承载的最大价值为,dp[0][j]=floor(j/weights[0])×values[0] dp[0][j] = floor(j / weights[0]) \times values[0] (尽可能装入物品),即

dp[i,j]0123400153045601020\begin{array}{c|cc} dp[i,j] & \text{0} & \text{1} & \text{2} & \text{3}& \text{4}\\ \hline 0 & 0 & 15 & 30 & 45 & 60 \\ 1 & 0 \\ 2 & 0 \\ \end{array}

递推说明:

  • 💥背包容量为 22 时,选择两件 weight[0]weight[0],此时的总价值为 2×value[0]=302\times value[0] = 30

  • 💥背包容量为 33 时,选择 33weight[0]weight[0],此时的总价值为 3×value[0]=453 \times value[0] = 45

  • 💥背包容量为 44 时,选择 44weight[0]weight[0],此时的总价值为 4×value[0]=604\times value[0] = 60

4、确定遍历顺序

  • 外循环,遍历物品数量:从 i=1i =1 N1N - 1

  • 内循环,遍历背包容量:从 j=1j = 1WW

5、确定返回值

[0,N)[0,N) 中取任意物品,放进容量为 WW 的背包后的最大价值,即为 dp[N1][W]dp[N - 1][W]

6、代码示例

/**
 * 空间复杂度 O(weights.length * bagWeight)
 * 时间复杂度 O(weights.length * bagWeight)
 */
function maxValue(weights: number[], values: number[], bagWeight: number) {
    const length = weights.length;
    const dp = Array.from({ length }, () => new Array(bagWeight + 1).fill(0));

    for (let j = 1; j <= bagWeight; j++) {
        dp[0][j] = Math.floor(j / weights[0]) * values[0];
    }

    for (let i = 1; i < length; i++) {
        for (let j = 1; j <= bagWeight; j++) {
            dp[i][j] = dp[i - 1][j];
            for (let k = 1; k * weights[i] <= j; k++) {
                dp[i][j] = Math.max(dp[i][j], dp[i - 1][j - k * weights[i]] + k * values[i])
            }
        }
    }

    return dp[length - 1][bagWeight];
}

状态压缩

1、确定 dp 数组以及其含义

💥 dp[j]dp[j] 代表容量为 jj 的背包所能承载的最大价值,其中 j[0,W]j \in [0, W]

2、确定 dp 对应的状态方程

dp[j]=max(dp[j],dp[jweights[i]]+values[i])dp[j] = max(dp[j],dp[j-weights[i]]+values[i])

其中 i[0,N)i \in [0, N)。💥NOTE:可以发现完全背包问题使用一维 dpdp 状态数组求解时的状态转移方程与 010-1 背包的状态转移方程完全一致。这是因为完全背包与 010-1 背包仅在物品的重复使用上是不同的,010-1 背包是靠 倒序遍历背包容量 来保证每一个物品最多被使用一次,如果正序遍历背包,那就变成了完全背包。(详见4、确定遍历顺序)

3、确定 dp 初始状态

容量为 00 的背包,最大承载价值也恒为 00,即 dp[0]=0dp[0] = 0

💥NOTE:dpdp 初始值之所以能取 00,是因为要求总价值最大,故初始化时应取一个“局部最小值”,防止后续计算被初始值所覆盖。又由于物品的价值均为正整数,故这个局部最小值取 00 即可。(其他位置的初始值也可设置为“局部最小值”)

j01234dp[j]00000\begin{array}{c|cc} j & \text{0} & \text{1} & \text{2} & \text{3}& \text{4}\\ \hline dp[j] & 0 & 0 & 0 & 0 & 0 \\ \end{array}

4、确定遍历顺序

组合问题,不强调放入顺序

  • 外循环,遍历物品数量:从 i=0i=0N1N - 1

  • 内循环,遍历背包容量:从 到 j=weight[i]j=weight[i]j=Wj=W (💥 这里与 010-1 背包使用一维 dpdp 时的遍历顺序正好相反)

5、确定返回值

容量为 WW 的背包所能承载的最大价值为 dp[W]dp[W],即为返回值

6、递推求解与代码

i=0i=0 时,weights[i]=1weights[i]=1values[i]=15values[i] =15dp[j]=max(dp[j],dp[j1]+15)dp[j] = max(dp[j],dp[j-1]+15)

jjweights[0]weights[0] (值为 00) 开始遍历,计算出:

  • j=1j = 1 时,dp[1]=max(dp[1],dp[0]+15)=max(0,15)=15dp[1] = max(dp[1],dp[0]+15) =max(0,15) = 15

  • j=2j = 2 时,dp[2]=max(dp[2],dp[1]+15)=max(0,15+15)=30dp[2] = max(dp[2],dp[1]+15)=max(0,15+15) = 30

  • j=2j = 2 时,dp[3]=max(dp[3],dp[2]+15)=max(0,30+15)=45dp[3] = max(dp[3],dp[2]+15) =max(0,30+15)= 45

  • j=3j = 3 时,dp[4]=max(dp[4],dp[3]+15)=max(0,45+15)=60dp[4] = max(dp[4],dp[3]+15) = max(0,45+15)=60

故,

j01234dp[j]015304560\begin{array}{c|cc} j & \text{0} & \text{1} & \text{2} & \text{3}& \text{4}\\ \hline dp[j] & 0 & 15 & 30 & 45 & 60 \\ \end{array}

i=1i=1 时,weights[i]=3weights[i]=3values[i]=20values[i] =20dp[j]=max(dp[j],dp[j3]+20)dp[j] = max(dp[j],dp[j-3]+20)

jjweights[1]weights[1] (值为 33) 开始遍历,计算出:

  • j=3j = 3 时,dp[3]=max(dp[3],dp[0]+20)=max(45,0+20)=45dp[3] = max(dp[3],dp[0]+20) = max(45,0+20)=45
  • j=4j = 4 时,dp[4]=max(dp[4],dp[1]+20)=max(60,15+20)=60dp[4] = max(dp[4],dp[1]+20) = max(60,15+20)=60

故,

j01234dp[j]015304560\begin{array}{c|cc} j & \text{0} & \text{1} & \text{2} & \text{3}& \text{4}\\ \hline dp[j] & 0 & 15 & 30 & 45 & 60 \\ \end{array}

i=2i=2 时,weights[i]=4weights[i]=4values[i]=30values[i] =30dp[j]=max(dp[j],dp[j4]+30)dp[j] = max(dp[j],dp[j-4]+30)

jjweights[2]weights[2] (值为 44) 开始遍历,计算出:

  • j=4j = 4 时,dp[4]=max(dp[4],dp[0]+20)=max(60,20)=60dp[4] = max(dp[4],dp[0]+20)=max(60,20)=60

故,

j01234dp[j]015304560\begin{array}{c|cc} j & \text{0} & \text{1} & \text{2} & \text{3}& \text{4}\\ \hline dp[j] & 0 & 15 & 30 & 45 & 60 \\ \end{array}

示例代码

/**
 * 空间复杂度 O(bagWeight)
 * 时间复杂度 O(weights.length * bagWeight)
 */
function maxValue(weights: number[], values: number[], bagWeight: number) {
    const dp = Array.from({ length: bagWeight + 1 }, () => 0);

    for(let i = 0, len = weights.length; i < len; i++) {
        for(let j = weights[i]; j <= bagWeight; j++) {
            dp[i] = Math.max(dp[i], dp[j - weights[i]] + values[i])
        }
    }

    return dp[bagWeight];
}

题解

518. 零钱兑换 II

完全背包中的 组合 问题:

  • 使用二维 dpdp 时,可先遍历物品,再遍历背包,也可先遍历背包,再遍历物品;

  • 🎡 使用一维 dpdp 时,只能先遍历物品,再遍历背包。

377. 组合总和 Ⅳ

完全背包中的 排列 问题

  • 🎡 推荐使用一维 dpdp,只能先遍历背包,再遍历物品。

分析与总结

Q4:完全背包压缩算法中,是先遍历物品再遍历背包,是否可以倒置遍历顺序,即先遍历背包再遍历物品?

先说结论:💥可以但要区分情况。可以的原因是因为对于 11dpdp 情况,两层循环的顺序并不影响 dp[j]dp[j] 的计算,是因为 dp[j]dp[j] 是根据下标 jj 之前对应的 dp[k]dp[k](其中 k<jk \lt j)计算得到的,只要保证 dp[k]dp[k] 都是经过计算的即可。区分情况的原因是要看问题的本质是组合还是排列,组合问题 只能先遍历物品,再遍历背包;排列问题只能先遍历背包,再遍历物品。

总结

一维 dpdp二维 dpdp
0-1 背包必须先遍历物品,再 倒序 遍历背包,遍历顺序不可颠倒 (否则会重复加入物品)可以先遍历物品,再遍历背包,遍历顺序可颠倒,但前者方式易于理解
完全背包组合问题:先遍历物品,再遍历背包;
排列问题:先遍历背包,再遍历物品
更适合 组合问题:先遍历物品,再遍历背包,亦可先遍历背包,再遍历物品

参考

一篇文章吃透背包问题

宫水三叶-那就从 0-1 背包问题开始讲起吧 ...