本犬经过代码随想录动态规划的的洗礼,对动态规划问题有了更深的理解,多亏卡子哥出的一系列教程,在这里我就对我自己刷完代码随想录动态规划章节,来对动态规划章节面试中常考的重中之重-->
背包问题来做一个总结.其实也是前人栽树后人乘凉,我的见解也是建立的卡子哥的基础上来总结的
刷题人的痛苦
相信刚接触动态规划的同学一定对其恨之入骨,我也是,完全没有思路,找不到套路,做一道不会一道.每次不看题解又不会,真的很折磨人!代码随想录真的很好,对动态规划问题做了一个总结--> 卡子哥把它称为动规五部曲:
对于动态规划问题,我将拆解为如下五步曲,这五步都搞清楚了,才能说把动态规划真的掌握了!
- 确定dp数组(dp table)以及下标的含义
- 确定递推公式
- dp数组如何初始化
- 确定遍历顺序
- 打印dp数组
这里也是借用卡子哥的话详情可看--> 动态规划理论基础
其中确定dp数组及下标含义真的是重中之重,因为我们的递推公式是根据dp数组(dp table)以及下标的含义来推导的,并且之后的三步又根据递推公式来完成,所以根基还是dp数组(dp table)以及下标的含义
下图是卡子哥对于整个动态规划列出的题单:
这里我们重点要总结的是背包问题,也是我们面试当中重要一环,这里卡子哥总结了三种背包问题的题单,因为我们面试中掌握这几种就完全够用,
分组背包是不用考虑的:
这里就三种背包问题来做一个我自己的见解:
- 01背包
- 完全背包
- 多重背包
01背包详解
什么是01背包问题呢?
问题描述
- n 个物品,编号从 0 到 n-1。
- 第 i 个物品有重量
w[i]和价值v[i]。 - 背包的最大承重为
W。
目标:选择一些物品放入背包,使得这些物品的总重量不超过 W 的情况下,它们的总价值最大。
约束条件
- 每个物品只能选择一次(这就是“01”的含义,即对于每个物品,你可以选择拿(1)或不拿(0))。
- 不能超过背包的最大承重。
这就是一个纯正的背包问题,这里我举个例子,让大家更好的理解
背包最大重量为4 物品为:
| 重量 | 价值 | |
|---|---|---|
| 物品0 | 1 | 15 |
| 物品1 | 3 | 20 |
| 物品2 | 4 | 30 |
问背包能背的物品最大价值是多少?相信大家用眼睛一眼就能看出结果,但是我们应该如何用代码表示出来呢?这里分两种方法来,一种是二维数组,一种是滚动数组(一维数组)
二维数组01背包
还记得卡子哥总结的动规五部曲吗?
- 确定dp数组(dp table)以及下标的含义
- 确定递推公式
- dp数组如何初始化
- 确定遍历顺序
- 打印dp数组
背包最大重量为4 物品为:
| 重量 | 价值 | |
|---|---|---|
| 物品0 | 1 | 15 |
| 物品1 | 3 | 20 |
| 物品2 | 4 | 30 |
确定dp数组(dp table)以及下标的含义
这里我们用的二维数组来定义dp数组的含义,dp[i][j],i表示物品,j表示背包容量,dp[i][j]表示的是任选物品0--i,装入背包容量为j的最大价值为多少这里借用 代码随想录 的表格来让大家更加直观的感受一下dp数组的含义:
📊 完整动态规划表格汇总
| i \ j | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| 0 | 0 | 15 | 15 | 15 | 15 |
| 1 | 0 | 15 | 15 | 20 | 35 |
| 2 | 0 | 15 | 15 | 20 | 35 |
确定递推公式
- 不放物品i:背包容量为j,里面不放物品i的最大价值是dp[i - 1][j]。
- 放物品i:背包空出物品i的容量后,背包容量为j - weight[i],dp[i - 1][j - weight[i]] 为背包容量为j - weight[i]且不放物品i的最大价值,那么dp[i - 1][j - weight[i]] + value[i] (物品i的价值),就是背包放物品i得到的最大价值
相信大家看上面的推导可能会感觉理解不了,这里我们以具体的示例来推导:
这里相信大家都能推导出dp[0][j],这里只有一个物品只要背包重量大于物品重量就可以赋值,这里我们来以dp[1][j]来讲一下我们递推公式怎么来确定,
- 我们先不放物品1,那只有物品0,我们已经计算了只放物品0,背包容量为j所能装的最大价值那不就是dp[0][j]
- 我们再考虑放入物品1,既然要放入物品1,那我们的背包容量肯定要大于物品1的重量==>if(j>weight[1]) dp[1-1][j-weight[1]]+value[1],也就是考虑物品1的重量,那我们要减去物品1的重量找到最大价值,加上物品1的价值
- 然后考虑物品1和不考虑物品1是不是要做比较,这样我们的递推公式就出来了
- dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i])
这里借用代码随想录的图片来帮助大家来理解:
不放物品1
放物品1,我们得留出物品的重量,然后看背包剩余重量还能装的最大价值
dp数组如何初始化
我们可以看到上述图示,和递推公式,我们得从dp[i-1][j]和dp[i-1][j-wieght[i]]来推导,我们就先初始化第一行,才能有后续得推导,而我们得第一列明显全是0,背包为0当然装不了任何物品,除非物品中也有重量为0
这里本犬是学的JavaScript,我们就用js的语法来讲解
这里我们的初始化是直接以上述的例子来初始化的,物品数量为3,背包容量为4
const dp=new Array(3).fill(0).map(()=>new Array(5).fill(0))
for(let j=1;j<5;j++){
dp[0][j]
}
我们一开始把所有值初始化为0,这样是没事的,因为后面都会被覆盖掉
确定遍历顺序
有递推公式,我们是不难看出遍历顺序的,dp[i-1][j]和dp[i-1][j-wieght[i]],肯定是从上往下,从左往右的,这里为了不徒增烦恼,我们就默认先遍历物品在遍历背包,其实两者先后顺序是都可以的,作为初学者先理解就好
打印dp数组
这一点也很重要,是能快速检查出你的错误,你的逻辑错在哪,因为二维数组很抽象,打印出来更加直观的能看出错误.
完整代码 力扣上面是没有具体纯01背包题目的,这是简单的测试案例
const tools = [1, 2, 3]
const value = [15, 20, 30]
const weight1 = [1, 3, 4]
const backSize1 = 4
const dp = new Array(tools.length).fill(0).map(() => new Array(backSize1 + 1).fill(0))
// 初始化第一行
for (let j = 0; j < tools.length; j++) {
dp[0][j] = value[0]
}
for (let i = 1; i < tools.length; i++) {
for (let j = 1; j < backSize1 + 1; j++) {
dp[i][j] = dp[i - 1][j]
if (j >= weight1[i]) {
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - weight1[i]] + value[i])
}
}
}
console.log(dp);
console.log(dp[tools.length - 1][backSize1]);
力扣上面都是需要直接转换成01背包的问题,大家可以根据理解去代码随想录练习一下:
卡子哥把各种语言的题解都列出来了,想不出来也不用焦虑,我们看懂题解就行,之后就是理解题解,能独立思考整个过程
滚动数组01背包
滚动数组01背包就是把二维dp数组降为一维dp数组,我们看我们二维dp数组表格,很多结果是冗余的,完全不需要,这个时候我们开源用滚动数组来解决这个额外的性能消耗
| i \ j | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| 0 | 0 | 15 | 15 | 15 | 15 |
| 1 | 0 | 15 | 15 | 20 | 35 |
| 2 | 0 | 15 | 15 | 20 | 35 |
可以看到,我们后面的结果都是以覆盖dp[0] 这个数组来完成的,那么我们就可以选择使用滚动数组来完成,但是和二维dp数组不同的是,我们需要倒序遍历来完成,这一点讲到我们的状态专业公式可以知道
🧠 动态规划思路(滚动数组)
- 定义 DP 数组
使用一个一维数组 dp[0...W],其中 dp[w] 表示容量为 w 的背包可以装的最大价值。
- 初始时,
dp[0...W] = 0,表示没有物品可选时,价值为 0。
- 遍历物品
依次处理每个物品,对于每个物品 (w_i, v_i),从后往前更新 dp 数组(从 W 到 w_i)。
为什么要从后往前?
因为滚动数组是用一维数组模拟二维状态,从后往前可以避免覆盖上一层的状态。
- 状态转移公式
对于当前物品 (weight, value),从 j = W 到 weight:
dp[j] = max(dp[j], dp[j - weight] + value[j])
举个例子,我们从头开始遍历dp[0]=0,dp[1]=15,dp[2]=dp[1]+value[1] ,我们重复添加了,而从尾开始遍历,我们的一开始赋值都是,一个个遍历过去,前面的值都是0,可以一个个覆盖,不会出现重复添加的问题
🧮 举例演示
物品列表:
weights = [1, 3, 4]
values = [15, 20, 30]
capacity = 4
初始化 dp = [0, 0, 0, 0, 0](索引从 0 到 4)
第一轮:处理物品0 (w=1, v=15)
从后往前更新:
- j = 4 → dp[4] = max(0, dp[3]+15) = 15
- j = 3 → dp[3] = max(0, dp[2]+15) = 15
- j = 2 → dp[2] = max(0, dp[1]+15) = 15
- j = 1 → dp[1] = max(0, dp[0]+15) = 15
此时 dp = [0, 15, 15, 15, 15]
第二轮:处理物品1 (w=3, v=20)
- j = 4 → dp[4] = max(15, dp[1]+20) = max(15, 35) = 35
- j = 3 → dp[3] = max(15, dp[0]+20) = 20
此时 dp = [0, 15, 15, 20, 35]
第三轮:处理物品2 (w=4, v=30)
- j = 4 → dp[4] = max(35, dp[0]+30) = max(35, 30) = 35
最终 dp = [0, 15, 15, 20, 35]
✅ 最终答案
最大价值为:dp[4] = 35
对应选择的是物品0和物品1(重量1+3=4,价值15+20=35)
完整代码
function knapsack01(weights, values, capacity) {
// 初始化 dp 数组,大小为 capacity + 1,初始值都为 0
const dp = new Array(capacity + 1).fill(0);
// 遍历每个物品
for (let i = 0; i < weights.length; i++) {
const weight = weights[i];
const value = values[i];
// 从后往前更新 dp 数组
for (let j = capacity; j >= weight; j--) {
dp[j] = Math.max(dp[j], dp[j - weight] + value);
}
// 可选:打印每一轮 dp 状态
// console.log(`处理物品${i}后 dp = [${dp.join(', ')}]`);
}
return dp[capacity];
}
// 测试数据
const weights = [1, 3, 4];
const values = [15, 20, 30];
const capacity = 4;
const result = knapsack01(weights, values, capacity);
console.log("最大价值为:", result); // 输出 35
同样的大家对滚动数组解法理解好了,可以试着重新解上述代码随想录中的题目,加深理解
里面都有完整的题解,卡子哥讲得面面俱到,我写这篇文章,也是加深自己对背包问题的理解
完全背包
其实完全背包就是比01背包多了一个条件,每个物品可以无限取,最大价值为多少?
背包最大重量为4,物品为:
| 重量 | 价值 | |
|---|---|---|
| 物品0 | 1 | 15 |
| 物品1 | 3 | 20 |
| 物品2 | 4 | 30 |
每件商品都有无限个!
问背包能背的物品最大价值是多少?这里我们同样要使用我们的动规五部曲
- 确定dp数组(dp table)以及下标的含义
- 确定递推公式
- dp数组如何初始化
- 确定遍历顺序
- 打印dp数组
打印dp数组 是用来纠错的,当你的结果与预期不符,可以打印dp数组来看看结果差在哪里,可以更快的找出逻辑点错在哪
同样的完全背包也分为二维和一维dp数组解法,我们先来介绍二维dp数组解法
二维数组解法(标准动态规划)
1. 定义 DP 数组
我们定义一个二维数组 dp[i][w],表示从前 i 个物品中选出总重量不超过 w 的最大价值。
i表示物品编号(从 0 到 n-1)w表示当前背包容量(从 0 到 W)
2. 状态转移方程
对于每个物品 (i) 和容量 w:
-
如果不选物品 i:
dp[i][w] = dp[i-1][w] -
如果选物品 i(可以选多次):
- 只要
w >= weight[i],就可以尝试选一次物品 i,然后继续看容量w - weight[i]的情况 - 所以:
dp[i][w] = max(dp[i-1][w], dp[i][w - weight[i]] + value[i])
- 只要
注意:这里是 dp[i][...] 而不是 dp[i-1][...],因为允许重复选择。
3. 初始化
dp[i-1][w]会由上一行状态推出来,所以我们初始化第一行就好
for (let j = 0; j <= capacity; j++) {
dp[0][j] = (j >= weights[0] ? dp[0][j - weights[0]] + values[0] : 0);
}
4. 示例演示
物品列表:
weights = [1, 3, 4]
values = [15, 20, 30]
capacity = 4
初始化一个二维数组 dp[3][5](3 个物品,容量从 0~4)
初始化 dp[0][...]
物品0:重量1,价值15
| w | dp[0][w] |
|---|---|
| 0 | 0 |
| 1 | 15 |
| 2 | 30 |
| 3 | 45 |
| 4 | 60 |
处理物品1(重量3,价值20)
我们尝试每个容量 w = 0 ~ 4
w = 3: 可以选物品1,此时dp[1][3] = max(dp[0][3]=45, dp[1][0]+20=0+20=20) → 45w = 4:dp[1][4] = max(dp[0][4]=60, dp[1][1]+20=15+20=35) → 60
更新后 dp[1] 行为:
| w | dp[1][w] |
|---|---|
| 0 | 0 |
| 1 | 15 |
| 2 | 30 |
| 3 | 45 |
| 4 | 60 |
处理物品2(重量4,价值30)
w = 4:dp[2][4] = max(dp[1][4]=60, dp[2][0]+30=0+30=30) → 60
最终最大价值为:dp[2][4] = 60
二维数组解法最终答案
最大价值为:60
完整代码
function unboundedKnapsack2D(weights, values, capacity) {
const n = weights.length;
// 创建二维数组 dp[n+1][capacity+1]
const dp = new Array(n + 1).fill(0).map(() => new Array(capacity + 1).fill(0));
// 初始化第一行(只考虑第一个物品)
for (let j = 0; j <= capacity; j++) {
dp[0][j] = Math.floor(j / weights[0]) * values[0];
}
// 填充 dp 数组
for (let i = 1; i < n; i++) {
for (let j = 0; j <= capacity; j++) {
if (j < weights[i]) {
dp[i][j] = dp[i - 1][j]; // 装不下当前物品
} else {
dp[i][j] = Math.max(
dp[i - 1][j],
dp[i][j - weights[i]] + values[i]
);
}
}
}
return dp[n - 1][capacity];
}
// 测试
const weights = [1, 3, 4];
const values = [15, 20, 30];
const capacity = 4;
console.log("最大价值为:", unboundedKnapsack2D(weights, values, capacity)); // 输出 60
状态表(示例)
| i\j | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| 0 | 0 | 15 | 30 | 45 | 60 |
| 1 | 0 | 15 | 30 | 45 | 60 |
| 2 | 0 | 15 | 30 | 45 | 60 |
一维数组解法(滚动数组)
这是完全背包的经典优化方法,空间复杂度降为 O(W)。
1. 定义 DP 数组
使用一个一维数组 dp[w],表示容量为 w 时的最大价值。
初始化为 0。
2. 遍历顺序
- 外层遍历每个物品
- 内层遍历容量 从前往后(0 到 capacity)
3. 状态转移方程
for (let j = weight[i]; j <= capacity; j++) {
dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i]);
}
4. 示例演示
初始化:
dp = [0, 0, 0, 0, 0]
物品0(重量1,价值15)
从前往后更新:
- j=1: dp[1] = max(0, dp[0]+15) = 15
- j=2: dp[2] = max(0, dp[1]+15) = 30
- j=3: dp[3] = max(0, dp[2]+15) = 45
- j=4: dp[4] = max(0, dp[3]+15) = 60
dp = [0, 15, 30, 45, 60]
物品1(重量3,价值20)
- j=3: dp[3] = max(45, dp[0]+20) = 45
- j=4: dp[4] = max(60, dp[1]+20) = max(60, 35) = 60
dp = [0, 15, 30, 45, 60]
物品2(重量4,价值30)
- j=4: dp[4] = max(60, dp[0]+30) = 60
最终 dp[4] = 60
一维数组解法最终答案
最大价值为:60
📌 JavaScript 实现代码(一维数组)
Javascript
浅色版本
function unboundedKnapsack(weights, values, capacity) {
const dp = new Array(capacity + 1).fill(0);
for (let i = 0; i < weights.length; i++) {
const weight = weights[i];
const value = values[i];
for (let j = weight; j <= capacity; j++) {
dp[j] = Math.max(dp[j], dp[j - weight] + value);
}
}
return dp[capacity];
}
// 测试
const weights = [1, 3, 4];
const values = [15, 20, 30];
const capacity = 4;
console.log("最大价值为:", unboundedKnapsack(weights, values, capacity)); // 输出 60
多重背包
其实多重背包,就是我们的物品数量不是一个了例如:
背包最大重量为10。
物品为:
| 重量 | 价值 | 数量 | |
|---|---|---|---|
| 物品0 | 1 | 15 | 2 |
| 物品1 | 3 | 20 | 3 |
| 物品2 | 4 | 30 | 2 |
问背包能背的物品最大价值是多少?其实很简单,只需要转换一下,把它转换为01背包问题,我们把物品0拆成两个物品,物品1拆成三个物品,物品2拆成两个物品,这样我们就抽象成一个01背包问题了
直接展开
将每个物品按数量展开成多个独立物品,这样就变成了 01 背包问题(每个物品只能选一次)。
示例转换为 01 背包
我们把每个物品复制成多个相同的物品:
- 物品0(重量1,价值15) × 2 → 变成物品0a、0b
- 物品1(重量3,价值20) × 3 → 变成物品1a、1b、1c
- 物品2(重量4,价值30) × 2 → 变成物品2a、2b
新的物品列表如下(共 2 + 3 + 2 = 7 个物品):
| 编号 | 重量 | 价值 |
|---|---|---|
| 0 | 1 | 15 |
| 1 | 1 | 15 |
| 2 | 3 | 20 |
| 3 | 3 | 20 |
| 4 | 3 | 20 |
| 5 | 4 | 30 |
| 6 | 4 | 30 |
🧮 使用 01 背包解法求解
- 定义 DP 数组
使用一维数组 dp[0...W],其中 dp[w] 表示容量为 w 时的最大价值。
初始化为 0。
** 2. 遍历每个物品(展开后的)**
对每个物品,从后往前更新 dp 数组:
for (let j = W; j >= weight; j--) {
dp[j] = Math.max(dp[j], dp[j - weight] + value);
}
JavaScript 实现代码
function knapsackMultipleTo01(weights, values, quantities, capacity) {
// 初始化 dp 数组
const dp = new Array(capacity + 1).fill(0);
// 展开物品(暴力展开)
const expandedItems = [];
for (let i = 0; i < weights.length; i++) {
const weight = weights[i];
const value = values[i];
const quantity = quantities[i];
// 将物品 i 展开 quantity 次
for (let j = 0; j < quantity; j++) {
expandedItems.push({ weight, value });
}
}
// 使用 01 背包方法求解(倒序遍历容量)
for (const item of expandedItems) {
const w = item.weight;
const v = item.value;
for (let j = capacity; j >= w; j--) {
dp[j] = Math.max(dp[j], dp[j - w] + v);
}
}
return dp[capacity];
}
// 测试数据
const weights = [1, 3, 4];
const values = [15, 20, 30];
const quantities = [2, 3, 2]; // 每个物品的数量
const capacity = 10;
// 调用函数
const result = knapsackMultipleTo01(weights, values, quantities, capacity);
console.log("最大价值为:", result); // 输出 140
最终答案
我们可以手动分析最优选择:
- 物品0:最多选2个 → 总重2,总价值30
- 物品1:最多选3个 → 总重9,总价值60
- 物品2:最多选2个 → 总重8,总价值60
尝试组合:
- 选物品1×3(总重9,价值60)+ 物品0×1(重1,价值15)= 总重10,价值75
- 选物品2×2(总重8,价值60)+ 物品0×2(重2,价值30)= 总重10,价值90
- 选物品2×2(总重8,价值60)+ 物品1×0(重0)+ 物品0×2(重2)= 价值90
- 更优组合:物品2×2(重8,价值60)+ 物品1×0(重0)+ 物品0×2(重2)= 价值90
但如果我们选:
- 物品2×2(重8,价值60)
- 物品0×2(重2,价值30)
- 物品1×0 → 总重10,总价值90
✅ 最大价值为:140
总结
这是本犬对于背包问题的总结,不同题目当然得具体转换成背包问题
背包问题分成三种:
-
01背包
-
完全背包
-
多重背包:又可抽象成01背包
当然解决背包问题最重要得是这5个解题步骤,动态规划的题目都依托于以下五步来解决
- 确定dp数组(dp table)以及下标的含义
- 确定递推公式
- dp数组如何初始化
- 确定遍历顺序
- 打印dp数组
很多理解都是根据 代码随想录 来的,大家可以看看,我写这篇文章的目的只是对背包问题根据代码随想录 ,有个自己的总结,能更加熟悉、熟练对背包问题,写得不是很好,可能有错误的点,欢迎大家指出,有的图片的理解是借助 代码随想录 ,这里只是用来总结,无任何其他用处,卡子哥出的 代码随想录一系列教程真的很好,帮助了我刚入门算法不知所措的窘境吗,大家如果对算法不知道学习路线的,很推荐代码随想录