"你的背包,让我走得好缓慢"
在最近几场外企的面试中发现,面试官们又开始对背包问题“乐此不疲”了,真是“你的背包,对我沉重的审判”。背包问题是动态规划中一个分支问题,依然要使用动归口诀“六脉神剑”来解决(参考 重识动态规划)。
💥外企面试中,最重要的两类背包问题:
-
0-1背包 :一个物品只能放入背包一次,
-
完全背包 :一个物品可以多次放入背包里。
本篇从0-1背包讲起...
0-1 背包
问题形态
有 N 件物品和一个最多能承受重量为 W (如 W=4) 的背包,第 i 件物品的重量为 weights[i],价值为 values[i],每件物品只能用一次 。 求解该背包承载物品的最大价值为多少?
i012weights[i]134values[i]152030
问题分析
每一件物品只有两个状态——取与不取。
1、确定 dp 数组及含义
💥 dp[i][j] 代表从第 0 个到第 i 个(即 [0,i])物品中中取任意不重复物品,放进容量为 j 的背包后的最大价值,其中 i∈[0,N),j∈[0,W](纵轴表示物品序号,横轴表示背包容量大小)。
dp[i,j]01201234
2、确定 dp 状态方程
对于第 i 件物品,有两种状态,即放入背包或不放入背包。
-
主动不放入:主动放弃第 i 个物品,并不受限于背包的容量,即,dp[i][j]=dp[i−1][j]
-
主动放入:那么当前背包容量一定存在 j≥weights[i],而且要先从背包里腾出这个空间,才能将重量为 weights[i],价值为 values[i] 的物品放入背包,即,dp[i][j]=dp[i−1][j−weights[i]]+values[i]
-
被动不放入:这种情况下受限于背包的容量,不能再将 weights[i] 的物品放入背包,此时 j<weights[i],即 dp[i][j]=dp[i−1][j]
综上所述,令 z=j−weights[i]
dp[i][j]={dp[i−1][j]max(dp[i−1][j],dp[i−1][z]+value[i])z<0z≥0
3、确定 dp 初始状态
首先,如果背包的容量为 0,所承载的最大价值必定为 0,即
dp[i,j]01200001234
其次,只有第一件物品时(weights[0]=1,values[0]=15),不同容量的背包所承载的最大价值为(因为不能重复取物品,所以当只有一个物品时,只要背包容量大于等于该物品的重量,那么最大价值就是该物品的价值,否则为 0),
dp[i,j]0120000115215315415
4、确定遍历顺序
外循环(第一层)是遍历物品:因为第 0 件物品已经放到背包中了(初始值),那么从第 1 件遍历到第 N−1 件即可;对于每件物品,即内循环(第二层),要尝试将其放入不同容量的背包,那么从容量为 1 的背包遍历到容量为 W 的背包即可。
- 第一层循环:从 i=1 到 i=N−1
- 第二层循环:从 j=1 到 j=W
5、确定返回值
返回值应为,从 [0,N−1] 中取任意不重复物品,放进容量为 W 的背包后的最大价值,即 dp[N−1][W]
6、递推求解与代码
1️⃣ i=1 时,weights[i]=3,values[i]=20,此时 z=j−3
dp[1][j]={dp[0][j]max(dp[0][j],dp[0][j−3]+20)z<0z≥0
计算出,
-
j=1 时 z=−2,故 dp[1][1]=dp[0][1]=15
-
j=2 时 z=−1,故 dp[1][2]=dp[0][2]=15
-
j=3 时 z=0, 故 dp[1][3]=max(dp[0][3],dp[0][0]+20)=20
-
j=4 时 z=1, 故 dp[1][4]=max(dp[0][4],dp[0][1]+20)=35
故,dp[i,j]012000011515215153152041535
2️⃣ i=2 时,weights[i]=4,values[i]=30,此时 z=j−4
dp[2][j]={dp[1][j]max(dp[1][j],dp[1][j−4]+30)z<0z≥0
计算出,
-
j=1 时 z=−3,故 dp[2][1]=dp[1][1]=15
-
j=2 时 z=−2,故 dp[2][2]=dp[1][2]=15
-
j=3 时 z=−1,故 dp[2][3]=dp[1][3]=20
-
j=4 时 z=0, 故 dp[2][4]=max(dp[1][4],dp[1][0]+30)=35
故,dp[i,j]01200001151515215151531520204153535
最终结果:dp[N−1][W]=dp[2][4]=35
3️⃣ 代码示例
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];
}
状态压缩
我们从“递推关系”可以发现,物品维度的 i 仅与 i−1 相关,可以在 i 维度上进行状态压缩。
1、确定 dp 数组及含义
💥 dp[j] 代表容量为 j 的背包所能承载的最大价值,其中 j∈[0,W]。
2、确定 dp 对应的状态方程
dp[j]=max(dp[j],dp[j−weights[i]]+values[i]),其中 i∈[0,N)
3、确定 dp 初始状态
容量为 0 的背包,最大承载价值也恒为 0,即 dp[0]=0,其他背包容量所承载的最大价值也均初始化为 0。
jdp[j]0010203040
💥NOTE:dp 初始值之所以能取 0,是因为要求总价值最大,故初始化时应取一个“局部最小值”,防止后续计算被初始值所覆盖。又由于物品的价值均为正整数,故这个局部最小值取 0 即可。
4、确定遍历顺序
这里留个思考,为何要倒序遍历背包❓❓❓ (参考 总结与分析)
5、确定返回值
容量为 W 的背包所能承载的最大价值为 dp[W],故 dp[W] 为最终返回值
6、递推求解与代码
1️⃣ i=0 时,weights[i]=1,values[i]=15,dp[j]=max(dp[j],dp[j−1]+15)
计算出,
-
j=4 时,故 dp[4]=max(dp[4],dp[3]+15)=15
-
j=3 时,故 dp[3]=max(dp[3],dp[2]+15)=15
-
j=2 时,故 dp[2]=max(dp[2],dp[1]+15)=15
-
j=1 时,故 dp[1]=max(dp[1],dp[0]+15)=15
故,jdp[j]00115215315415
2️⃣ i=1 时,weights[i]=3,values[i]=20,dp[j]=max(dp[j],dp[j−3]+20)
计算出,
-
j=4 时,dp[4]=max(dp[4],dp[1]+20)=max(15,15+20)=35
-
j=3 时,dp[3]=max(dp[3],dp[0]+20)=max(15,0+20)=20
故,jdp[j]00115215320435
3️⃣ i=2 时,weights[i]=4,values[i]=30,dp[j]=max(dp[j],dp[j−4]+30)
计算出,
- j=4时,dp[4]=max(dp[4],dp[0]+20)=max(35,20)=35
故,jdp[j]00115215320435
4️⃣ 代码示例
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 情况(状态压缩)中,对于背包容量为何要倒序遍历?
先说原因:倒叙遍历时为了保证物品 i 只会被放入一次(0-1背包的特点)。如何保证?倒序遍历最先计算的是 dp[W],从后往前遍历递推公式,每次取得的状态不会和之前取得的状态重合,这样每种物品就只会取一次。
我们尝试一下正序遍历背包:i=0 时,weights[i]=1,values[i]=15,dp[j]=max(dp[j],dp[j−1]+15),计算出,
-
j=1 时,故 dp[1]=max(dp[1],dp[0]+15)=15
-
j=2 时,故 dp[2]=max(dp[2],dp[1]+15)=30
-
j=3 时,故 dp[3]=max(dp[3],dp[2]+15)=45
-
j=4 时,故 dp[4]=max(dp[4],dp[3]+15)=60
故,jdp[j]00115230345460 ❌
已经发现问题了吧!在计算 dp[2] 时,已经将第 0 件物品装入背包 2 次了,这已经违反了 0−1 原则。
那 0−1 背包的二维 dp 为何无需倒序? 因为对于 dp[i][j] 都是通过上一层 dp[i−1][j]计算而来的,本层的 dp[i][j] 并不会被覆盖。
Q2:0-1 背包的 2 维 dp 情况中的两层循环里为何要先遍历物品再遍历背包?遍历顺序可否颠倒,即先遍历背包再遍历物品?
先说结论:可以!先遍历物品再遍历背包这个顺序比较好理解。完全可以先遍历背包再遍历物品,理由是:dp[i−1][j] 和 dp[i−1][j−weight[i]] 都在 dp[i][j] 的左上方向,不管循环遍历的方向如何,dp[i][j] 的数据都来自二维矩阵的左上角,并不影响 dp[i][j] 状态方程的递推,故遍历物品和遍历背包的顺序可相互置换。
Q3:0-1 背包的 1 维 dp 情况中,是先遍历物品再遍历背包,是否可以倒置遍历顺序,即先遍历背包再遍历物品?
先说结论:不可以💥。因为对于 1 维 dp 情况,背包容量一定要倒叙遍历,如果背包容量遍历放在第一层,那么每个 dp[j] 就是只会有一个物品。
参考
416、分割等和子集
494、目标和