用填充表格法吃透01背包及其变形
01背包问题是动态规划(Dynamic Programming, DP)领域最经典、最基础的模型之一,后续很多复杂的DP问题都可看作是它的变形或延伸。本文将从"表格可视化"核心思路出发,先通过空表格建立解题框架,再用DP解题5步"万能钥匙"逐步填充表格,最终覆盖基础01背包解法、空间优化技巧,以及4类经典变形的完整拆解(含代码实现),帮你彻底吃透01背包问题。
核心前置认知:01背包的本质是"选或不选"的二选一决策------有n个物品,每个物品有重量和价值,背包有固定容量,要求选择若干物品放入背包,使得总重量不超过容量的前提下,总价值最大(基础模型)。后续所有变形都围绕"选或不选"的核心逻辑展开,只是"物品""容量""目标"的具体含义不同。
动态规划(Dynamic Programming, DP)解决问题的核心逻辑,本质是通过填充表格逐步推导最优解------把复杂的多阶段决策问题,转化为按规则填充表格的可视化过程。以01背包问题(最经典的DP模型)为例,我们先明确最终要填充的核心表格形态,后续所有解题步骤都是为了按规则完成这张表格,表格填完之时,就是问题解决之日。
01背包问题核心表格(空表,后续逐步填充):
| 前i个物品\背包容量j | 0(容量为0) | 1(容量为1) | 2(容量为2) | ...(容量递增) | C(背包最大容量) |
|---|---|---|---|---|---|
| 0(无物品) | 待填充 | 待填充 | 待填充 | 待填充 | 待填充 |
| 1(第1个物品) | 待填充 | 待填充 | 待填充 | 待填充 | 待填充 |
| 2(第2个物品) | 待填充 | 待填充 | 待填充 | 待填充 | 待填充 |
| ...(物品递增) | 待填充 | 待填充 | 待填充 | 待填充 | 待填充 |
| n(第n个物品) | 待填充 | 待填充 | 待填充 | 待填充 | 待填充(最终答案) |
表格说明:表格中每个单元格dp[i][j]代表「前i个物品放入容量为j的背包的最大价值」,我们的目标就是按规则填充所有单元格,最终右下角dp[n][C]即为01背包问题的最优解。
要有序、正确地填充这张表格,需要遵循DP解题的5步「万能钥匙」------这是贯穿所有DP问题的通用拆解思路,每一步都对应表格填充的关键环节:
-
确定dp数组及下标的含义 :定义表格中每个单元格的核心意义(即
dp[i][j]代表什么),这是填充表格的基础; -
确定递推公式 :明确单元格
dp[i][j]的数值如何通过其他已填充单元格推导得出(即"选或不选"的决策逻辑),这是表格填充的核心规则; -
dp数组如何初始化:确定表格的初始状态(如无物品、容量为0时的单元格值),这是表格填充的起点;
-
确定遍历顺序(表格填充顺序):明确按什么顺序逐个填写表格中的单元格(如先逐行填、再逐列填),确保推导时依赖的单元格已提前填充;
-
打印dp数组(验证):通过逐步填充表格、打印中间结果,验证填充规则的正确性,避免逻辑偏差。
后续所有01背包及变形问题的分析,都将围绕这5步「万能钥匙」展开,本质就是用这5步规则完成对应表格的填充,最终通过表格得到问题答案。
一、基础01背包(二维DP解法)
我们先从最直观的二维DP解法入手,严格按照5步「万能钥匙」拆解,完整演示基础表格的填充过程。
示例:有4个物品,重量数组weights = [2,3,4,5],价值数组values = [3,4,5,6],背包最大容量capacity = 8,求能放入背包的最大价值。
1.1 步骤1:确定dp数组及下标的含义
定义二维数组dp[i][j]:表示「前i个物品放入容量为j的背包中,能获得的最大价值」。
对应表格维度:i(行)表示物品数量(从0到4,0代表无物品),j(列)表示背包容量(从0到8,0代表容量为0),表格共5行9列(i:0-4,j:0-8)。
1.2 步骤2:确定递推公式
对于第i个物品(重量weights[i-1]、价值values[i-1],注意数组索引从0开始,i从1开始),有两种核心决策:选或不选。
-
不选第i个物品 :前i个物品的最大价值 = 前i-1个物品的最大价值,即
dp[i][j] = dp[i-1][j]; -
选第i个物品 :需保证背包容量j ≥ 第i个物品的重量,此时最大价值 = 前i-1个物品放入容量j-weights[i-1]的背包的最大价值 + 第i个物品的价值,即
dp[i][j] = dp[i-1][j - weights[i-1]] + values[i-1]。
最终递推公式(取两种决策的最大值):
javascript
if (j < weights[i - 1]) {
// 容量不足,无法选第i个物品
dp[i][j] = dp[i - 1][j];
} else {
// 容量充足,选或不选取最大值
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - weights[i - 1]] + values[i - 1]);
}
这就是表格中每个单元格的填充规则------每个单元格的值要么继承上一行同列的值,要么继承上一行左侧对应容量的的值并加上当前物品价值,取两者最大。
1.3 步骤3:dp数组如何初始化
初始化的核心是确定表格的"边界条件",即无需推导就能直接确定的单元格值。
-
i=0(无物品) :无论背包容量j多大,放入0个物品的最大价值都是0,因此
dp[0][j] = 0(表格第0行全为0); -
j=0(容量为0) :无论有多少物品,都无法放入背包,最大价值都是0,因此
dp[i][0] = 0(表格第0列全为0)。
初始化后的表格(第0行、第0列已填充):
| 前i个物品\背包容量j | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|---|
| 0(无物品) | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 1(物品1:w=2,v=3) | 0 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 |
| 2(物品2:w=3,v=4) | 0 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 |
| 3(物品3:w=4,v=5) | 0 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 |
| 4(物品4:w=5,v=6) | 0 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 |
1.4 步骤4:确定遍历顺序(表格填充顺序)
遍历顺序直接对应二维表格的填充顺序------即「按什么顺序逐个填写表格中的单元格」,这里有两种可行方式:
-
先遍历物品(i从1到n),再遍历容量(j从1到C):逐行填充表格,先填完第1个物品对应的所有容量(第1行),再填第2个物品对应的所有容量(第2行),直到填完所有物品;
-
先遍历容量(j从1到C),再遍历物品(i从1到n):逐列填充表格,先填完容量1对应的所有物品数量(第1列),再填容量2对应的所有物品数量(第2列),直到填完所有容量。
两种顺序都可行,因为计算dp[i][j]时,只依赖上一行(i-1行)的结果,无论先填行还是先填列,上一行的对应位置都已提前计算完成。这就像填充一张二维表格:先遍历物品再遍历容量,是逐行填充 (每一行对应一个物品的决策,填完一行再处理下一个物品);先遍历容量再遍历物品,是逐列填充(每一列对应一个固定容量,先确定所有物品在该容量下的最优解)。实际解题中更常用「先遍历物品,再遍历容量」的顺序,符合我们「逐个考虑物品是否放入」的思考逻辑。
1.5 步骤5:打印dp数组(验证)
这一步是直接验证表格填充结果的正确性------通过逐步填充表格、打印中间状态,确认每一步都符合递推规则,避免因规则理解偏差导致填充错误。以示例weights = [2,3,4,5]、values = [3,4,5,6]、capacity = 8为例,逐步填充核心表格验证逻辑:
-
填充第1行(i=1,物品1:w=2,v=3):
填充后第1行:[0,0,3,3,3,3,3,3,3]
-
j=1:容量<2,无法选,dp[1][1] = dp[0][1] = 0;
-
j=2:容量≥2,选则价值=dp[0][0]+3=3,不选则0,取max=3;
-
j=3-8:选则价值=dp[0][j-2]+3=3,不选则0,取max=3;
-
-
填充第2行(i=2,物品2:w=3,v=4):
填充后第2行:[0,0,3,4,4,7,7,7,7]
-
j=1-2:容量<3,dp[2][j] = dp[1][j](0,3);
-
j=3:选则dp[1][0]+4=4,不选则3,取max=4;
-
j=4:选则dp[1][1]+4=4,不选则3,取max=4;
-
j=5:选则dp[1][2]+4=3+4=7,不选则3,取max=7;
-
j=6-8:选则dp[1][j-3]+4=3+4=7,不选则3,取max=7;
-
-
填充第3行(i=3,物品3:w=4,v=5):
填充后第3行:[0,0,3,4,5,5,8,9,9]
-
j=1-3:容量<4,dp[3][j] = dp[2][j](0,0,3);
-
j=4:选则dp[2][0]+5=5,不选则4,取max=5;
-
j=5:选则dp[2][1]+5=5,不选则4,取max=5;
-
j=6:选则dp[2][2]+5=3+5=8,不选则7,取max=8;
-
j=7:选则dp[2][3]+5=4+5=9,不选则7,取max=9;
-
j=8:选则dp[2][4]+5=4+5=9,不选则7,取max=9;
-
-
填充第4行(i=4,物品4:w=5,v=6):
填充后第4行:[0,0,3,4,5,6,8,9,10]
-
j=1-4:容量<5,dp[4][j] = dp[3][j](0,0,3,4);
-
j=5:选则dp[3][0]+6=6,不选则5,取max=6;
-
j=6:选则dp[3][1]+6=6,不选则8,取max=8;
-
j=7:选则dp[3][2]+6=3+6=9,不选则9,取max=9;
-
j=8:选则dp[3][3]+6=4+6=10,不选则9,取max=10;
-
最终填充完成的表格:
| 前i个物品\背包容量j | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|---|
| 0(无物品) | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 1(物品1:w=2,v=3) | 0 | 0 | 3 | 3 | 3 | 3 | 3 | 3 | 3 |
| 2(物品2:w=3,v=4) | 0 | 0 | 3 | 4 | 4 | 7 | 7 | 7 | 7 |
| 3(物品3:w=4,v=5) | 0 | 0 | 3 | 4 | 5 | 5 | 8 | 9 | 9 |
| 4(物品4:w=5,v=6) | 0 | 0 | 3 | 4 | 5 | 6 | 8 | 9 | 10 |
表格右下角dp[4][8] = 10,即该示例的最大价值为10,与预期结果一致。
1.6 基础二维DP完整代码(JavaScript)
javascript
/**
* 基础01背包(二维DP解法)
* @param {number[]} weights - 物品重量数组
* @param {number[]} values - 物品价值数组
* @param {number} capacity - 背包最大容量
* @returns {number} - 背包能容纳的最大价值
*/
function knapsack_2d(weights, values, capacity) {
const n = weights.length;
// 1. 初始化二维dp数组:dp[i][j]表示前i个物品放入容量j的背包的最大价值
const dp = new Array(n + 1).fill(0).map(() => new Array(capacity + 1).fill(0));
// 2. 遍历顺序:先遍历物品(i从1到n),再遍历容量(j从1到capacity)(逐行填充)
for (let i = 1; i <= n; i++) {
for (let j = 1; j <= capacity; j++) {
// 3. 递推公式:容量不足则不选,容量充足则选或不选取最大值
if (j < weights[i - 1]) {
dp[i][j] = dp[i - 1][j];
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - weights[i - 1]] + values[i - 1]);
}
}
}
// 打印完整dp数组(表格)验证
console.log('基础01背包二维DP数组(表格):');
for (let i = 0; i <= n; i++) {
console.log(dp[i].join('\t'));
}
// 最终答案:前n个物品放入容量capacity的背包的最大价值
return dp[n][capacity];
}
// 测试用例
const weights = [2, 3, 4, 5];
const values = [3, 4, 5, 6];
const capacity = 8;
console.log('最大价值:', knapsack_2d(weights, values, capacity)); // 输出:10