重识背包问题(上)

4,105 阅读5分钟

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

在最近几场外企的面试中发现,面试官们又开始对背包问题“乐此不疲”了,真是“你的背包,对我沉重的审判”。背包问题是动态规划中一个分支问题,依然要使用动归口诀“六脉神剑”来解决(参考 重识动态规划)。

💥外企面试中,最重要的两类背包问题:

  • 0-1背包 :一个物品只能放入背包一次,

  • 完全背包 :一个物品可以多次放入背包里。

本篇从0-1背包讲起...

0-1 背包

问题形态

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

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

问题分析

每一件物品只有两个状态——不取

1、确定 dp 数组及含义

💥 dp[i][j]dp[i][j] 代表从第 00 个到第 ii 个(即 [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]

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

  • 被动不放入:这种情况下受限于背包的容量,不能再将 weights[i]weights[i] 的物品放入背包,此时 j<weights[i]j<weights[i],即 dp[i][j]=dp[i1][j]dp[i][j] = dp[i-1][j]

综上所述,令 z=jweights[i]z=j - weights[i]

dp[i][j]={dp[i1][j]z<0max(dp[i1][j],dp[i1][z]+value[i])z0 dp[i][j] = \begin{cases} dp[i-1][j] & z < 0 \\ max(dp[i-1][j], dp[i-1][z] + value[i]) & z \ge0 \end{cases}

3、确定 dp 初始状态

首先,如果背包的容量为 00,所承载的最大价值必定为 00,即

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]=1,values[0]=15weights[0] = 1, values[0] = 15),不同容量的背包所承载的最大价值为(因为不能重复取物品,所以当只有一个物品时,只要背包容量大于等于该物品的重量,那么最大价值就是该物品的价值,否则为 00),

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

4、确定遍历顺序

外循环(第一层)是遍历物品:因为第 00 件物品已经放到背包中了(初始值),那么从第 11 件遍历到第 N1N-1 件即可;对于每件物品,即内循环(第二层),要尝试将其放入不同容量的背包,那么从容量为 11 的背包遍历到容量为 WW 的背包即可。

  • 第一层循环:从 i=1i = 1i=N1i = N - 1
  • 第二层循环:从 j=1j = 1j=Wj = W

5、确定返回值

返回值应为,从 [0,N1][0,N-1] 中取任意不重复物品,放进容量为 WW 的背包后的最大价值,即 dp[N1][W]dp[N-1][W]

6、递推求解与代码

1️⃣ i=1i=1 时,weights[i]=3weights[i]=3values[i]=20values[i] =20,此时 z=j3z=j-3

dp[1][j]={dp[0][j]z<0max(dp[0][j],dp[0][j3]+20)z0 dp[1][j] = \begin{cases} dp[0][j] & z < 0 \\ max(dp[0][j], dp[0][j-3] + 20) & z \ge0 \end{cases}

计算出,

  • j=1j = 1z=2z=-2,故 dp[1][1]=dp[0][1]=15dp[1][1] = dp[0][1] = 15

  • j=2j = 2z=1z=-1,故 dp[1][2]=dp[0][2]=15dp[1][2] = dp[0][2] = 15

  • j=3j = 3z=0z=0, 故 dp[1][3]=max(dp[0][3],dp[0][0]+20)=20dp[1][3] = max(dp[0][3], dp[0][0] + 20) = 20

  • j=4j = 4z=1z=1, 故 dp[1][4]=max(dp[0][4],dp[0][1]+20)=35dp[1][4] = max(dp[0][4], dp[0][1] + 20) = 35

故,dp[i,j]012340015151515101515203520\begin{array}{c|cc} dp[i,j] & \text{0} & \text{1} & \text{2} & \text{3}& \text{4}\\ \hline 0 & 0 & 15 & 15 & 15 & 15 \\ 1 & 0 & 15 & 15 & 20 & 35 \\ 2 & 0 \\ \end{array}


2️⃣ i=2i=2 时,weights[i]=4weights[i]=4values[i]=30values[i] =30,此时 z=j4z=j-4

dp[2][j]={dp[1][j]z<0max(dp[1][j],dp[1][j4]+30)z0 dp[2][j] = \begin{cases} dp[1][j] & z < 0 \\ max(dp[1][j], dp[1][j-4] + 30) & z \ge0 \end{cases}

计算出,

  • j=1j = 1z=3z=-3,故 dp[2][1]=dp[1][1]=15dp[2][1] = dp[1][1] = 15

  • j=2j = 2z=2z=-2,故 dp[2][2]=dp[1][2]=15dp[2][2] = dp[1][2] = 15

  • j=3j = 3z=1z=-1,故 dp[2][3]=dp[1][3]=20dp[2][3] = dp[1][3] = 20

  • j=4j = 4z=0z=0, 故 dp[2][4]=max(dp[1][4],dp[1][0]+30)=35dp[2][4] = max(dp[1][4], dp[1][0] + 30) = 35

故,dp[i,j]01234001515151510151520352015152035\begin{array}{c|cc} dp[i,j] & \text{0} & \text{1} & \text{2} & \text{3}& \text{4}\\ \hline 0 & 0 & 15 & 15 & 15 & 15 \\ 1 & 0 & 15 & 15 & 20 & 35 \\ 2 & 0 & 15 & 15 & 20 & 35 \\ \end{array}

最终结果:dp[N1][W]=dp[2][4]=35dp[N-1][W] = dp[2][4] = 35

3️⃣ 代码示例

/**
 * 空间复杂度 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 = weights[0]; j <= bagWeight; j++) {
        dp[0][j] = values[0];
    }

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

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

状态压缩

我们从“递推关系”可以发现,物品维度的 ii 仅与 i1i - 1 相关,可以在 ii 维度上进行状态压缩。

1、确定 dp 数组及含义

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

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

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)

3、确定 dp 初始状态

容量为 00 的背包,最大承载价值也恒为 00,即 dp[0]=0dp[0] = 0,其他背包容量所承载的最大价值也均初始化为 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}

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

4、确定遍历顺序

  • 第一层循环遍历物品:从 i=0i=0i=N1i=N-1

  • 第二层循环遍历背包:从 j=Wj=Wj=weights[i]j=weights[i]

这里留个思考,为何要倒序遍历背包❓❓❓ (参考 总结与分析

5、确定返回值

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

6、递推求解与代码

1️⃣ 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)

计算出,

  • j=4j = 4 时,故 dp[4]=max(dp[4],dp[3]+15)=15dp[4] = max(dp[4],dp[3]+15) = 15

  • j=3j = 3 时,故 dp[3]=max(dp[3],dp[2]+15)=15dp[3] = max(dp[3],dp[2]+15) = 15

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

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

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


2️⃣ 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)

计算出,

  • j=4j = 4 时,dp[4]=max(dp[4],dp[1]+20)=max(15,15+20)=35dp[4] = max(dp[4],dp[1]+20)=max(15,15+20)=35

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

故,j01234dp[j]015152035\begin{array}{c|cc} j & \text{0} & \text{1} & \text{2} & \text{3}& \text{4}\\ \hline dp[j] & 0 & 15 & 15 & 20 & 35 \\ \end{array}


3️⃣ 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)

计算出,

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

故,j01234dp[j]015152035\begin{array}{c|cc} j & \text{0} & \text{1} & \text{2} & \text{3}& \text{4}\\ \hline dp[j] & 0 & 15 & 15 & 20 & 35 \\ \end{array}


4️⃣ 代码示例

/**
 * 空间复杂度 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 = bagWeight; j >= weights[i]; j--) {
            dp[i] = Math.max(dp[i], dp[j - weights[i]] + values[i])
        }
    }

    return dp[bagWeight];
}

总结与分析

Q1:0-1 背包的 1 维 dp 情况(状态压缩)中,对于背包容量为何要倒序遍历?

先说原因:倒叙遍历时为了保证物品 ii 只会被放入一次(0-1背包的特点)。如何保证?倒序遍历最先计算的是 dp[W]dp[W],从后往前遍历递推公式,每次取得的状态不会和之前取得的状态重合,这样每种物品就只会取一次。

我们尝试一下正序遍历背包: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),计算出,

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

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

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

  • j=4j = 4 时,故 dp[4]=max(dp[4],dp[3]+15)=60dp[4] = max(dp[4],dp[3]+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}

已经发现问题了吧!在计算 dp[2]dp[2] 时,已经将第 00 件物品装入背包 22 次了,这已经违反了 010-1 原则。

010-1 背包的二维 dpdp 为何无需倒序? 因为对于 dp[i][j]dp[i][j] 都是通过上一层 dp[i1][j]dp[i-1][j]计算而来的,本层的 dp[i][j]dp[i][j] 并不会被覆盖。


Q2:0-1 背包的 2 维 dp 情况中的两层循环里为何要先遍历物品再遍历背包?遍历顺序可否颠倒,即先遍历背包再遍历物品?

先说结论:可以!先遍历物品再遍历背包这个顺序比较好理解。完全可以先遍历背包再遍历物品,理由是:dp[i1][j]dp[i-1][j]dp[i1][jweight[i]]dp[i-1][j-weight[i]] 都在 dp[i][j]dp[i][j] 的左上方向,不管循环遍历的方向如何,dp[i][j]dp[i][j] 的数据都来自二维矩阵的左上角,并不影响 dp[i][j]dp[i][j] 状态方程的递推,故遍历物品和遍历背包的顺序可相互置换。


Q3:0-1 背包的 1 维 dp 情况中,是先遍历物品再遍历背包,是否可以倒置遍历顺序,即先遍历背包再遍历物品?

先说结论:不可以💥。因为对于 11dpdp 情况,背包容量一定要倒叙遍历,如果背包容量遍历放在第一层,那么每个 dp[j]dp[j] 就是只会有一个物品。

参考

416、分割等和子集

494、目标和