用填充表格法-吃透01背包及其变形

用填充表格法吃透01背包及其变形

01背包问题是动态规划(Dynamic Programming, DP)领域最经典、最基础的模型之一,后续很多复杂的DP问题都可看作是它的变形或延伸。本文将从"表格可视化"核心思路出发,先通过空表格建立解题框架,再用DP解题5步"万能钥匙"逐步填充表格,最终覆盖基础01背包解法、空间优化技巧,以及4类经典变形的完整拆解(含代码实现),帮你彻底吃透01背包问题。

动态规划零基础的话,推荐先看从经典问题入手,吃透动态规划核心(DP五部曲实战)

核心前置认知: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问题的通用拆解思路,每一步都对应表格填充的关键环节:

  1. 确定dp数组及下标的含义 :定义表格中每个单元格的核心意义(即dp[i][j]代表什么),这是填充表格的基础;

  2. 确定递推公式 :明确单元格dp[i][j]的数值如何通过其他已填充单元格推导得出(即"选或不选"的决策逻辑),这是表格填充的核心规则;

  3. dp数组如何初始化:确定表格的初始状态(如无物品、容量为0时的单元格值),这是表格填充的起点;

  4. 确定遍历顺序(表格填充顺序):明确按什么顺序逐个填写表格中的单元格(如先逐行填、再逐列填),确保推导时依赖的单元格已提前填充;

  5. 打印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开始),有两种核心决策:选或不选。

  1. 不选第i个物品 :前i个物品的最大价值 = 前i-1个物品的最大价值,即dp[i][j] = dp[i-1][j]

  2. 选第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数组如何初始化

初始化的核心是确定表格的"边界条件",即无需推导就能直接确定的单元格值。

  1. i=0(无物品) :无论背包容量j多大,放入0个物品的最大价值都是0,因此dp[0][j] = 0(表格第0行全为0);

  2. 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:确定遍历顺序(表格填充顺序)

遍历顺序直接对应二维表格的填充顺序------即「按什么顺序逐个填写表格中的单元格」,这里有两种可行方式:

  1. 先遍历物品(i从1到n),再遍历容量(j从1到C):逐行填充表格,先填完第1个物品对应的所有容量(第1行),再填第2个物品对应的所有容量(第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. 填充第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. 填充第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. 填充第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. 填充第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

2.3 步骤3:dp数组如何初始化

初始化逻辑与二维一致:容量为0时,最大价值为0,因此dp[0] = 0;其他容量的初始值也为0(因为初始无物品可放,最大价值为0),即dp = new Array(capacity + 1).fill(0)

初始化后的单行表格:[0,0,0,0,0,0,0,0,0](j从0到8)

2.4 步骤4:确定遍历顺序(表格填充顺序)

一维DP的遍历顺序有严格要求,核心是「倒序遍历容量」,对应单行表格的「从右往左填充」------明确单行表格的填充顺序是避免重复选择物品的关键:

  1. 必须先遍历物品,再遍历容量:逐个处理每个物品,每次处理时更新整个单行表格(覆盖上一行结果);

  2. 容量必须倒序遍历(j从C到weights[i-1]) :从最大容量往小容量填充,确保计算dp[j]时,dp[j - weights[i-1]]仍是上一行(未处理当前物品)的旧值,避免同一物品被多次选择。

关键原因:一维DP的核心是用单行表格复用二维表格的空间,表格中每个位置的数值都依赖"上一轮未更新的旧值"(对应二维的dp[i-1][j - w[i]])。若正序遍历容量,dp[j - w[i]]会被提前更新(相当于二维的dp[i][j - w[i]]),导致同一个物品被多次选择(变成完全背包);倒序遍历能保证计算dp[j]时,dp[j - w[i]]仍是上一行(未选当前物品)的结果,对应单行表格从右往左填充,完美契合01背包「每个物品选一次」的规则。

反例(正序遍历容量):若j从weights[i-1]到C正序遍历,处理物品1(w=2,v=3)时,j=2会更新dp[2]=3,j=4时会用到dp[2]的新值(3),计算dp[4] = dp[4] + 3 = 3,相当于把物品1放入了两次,违背01背包规则。

2.5 步骤5:打印dp数组(验证)

通过打印单行表格的滚动更新过程,验证填充规则的正确性。仍用测试用例 weights = [2,3,4,5]values = [3,4,5,6]capacity = 8,演示一维DP数组(单行表格)的填充变化:

  1. 初始状态:dp = [0,0,0,0,0,0,0,0,0]

  2. 处理物品1(w=2,v=3),j从8到2倒序

    更新后:dp = [0,0,3,3,3,3,3,3,3]

    • j=8:dp[8] = max(0, dp[8-2]+3) = max(0,0+3)=3;

    • j=7:dp[7] = max(0, dp[5]+3)=3;

    • ...(j=2到6同理);

    • j=2:dp[2] = max(0, dp[0]+3)=3;

  3. 处理物品2(w=3,v=4),j从8到3倒序

    更新后:dp = [0,0,3,4,4,7,7,7,7]

    • j=8:max(3, dp[5]+4)=max(3,3+4)=7;

    • j=7:max(3, dp[4]+4)=max(3,3+4)=7;

    • j=6:max(3, dp[3]+4)=max(3,3+4)=7;

    • j=5:max(3, dp[2]+4)=max(3,3+4)=7;

    • j=4:max(3, dp[1]+4)=max(3,0+4)=4;

    • j=3:max(3, dp[0]+4)=max(3,0+4)=4;

  4. 处理物品3(w=4,v=5),j从8到4倒序

    更新后:dp = [0,0,3,4,5,5,8,9,9]

    • j=8:max(7, dp[4]+5)=max(7,4+5)=9;

    • j=7:max(7, dp[3]+5)=max(7,4+5)=9;

    • j=6:max(7, dp[2]+5)=max(7,3+5)=8;

    • j=5:max(7, dp[1]+5)=max(7,0+5)=7;

    • j=4:max(4, dp[0]+5)=max(4,0+5)=5;

  5. 处理物品4(w=5,v=6),j从8到5倒序

    更新后:dp = [0,0,3,4,5,6,8,9,10]

    • j=8:max(9, dp[3]+6)=max(9,4+6)=10;

    • j=7:max(9, dp[2]+6)=max(9,3+6)=9;

    • j=6:max(8, dp[1]+6)=max(8,0+6)=8;

    • j=5:max(5, dp[0]+6)=max(5,0+6)=6;

最终单行表格dp[8] = 10,与二维DP结果一致,验证了优化的正确性。

2.6 一维DP空间优化完整代码(JavaScript)

javascript 复制代码
/**
 * 基础01背包(一维DP空间优化解法)
 * @param {number[]} weights - 物品重量数组
 * @param {number[]} values - 物品价值数组
 * @param {number} capacity - 背包最大容量
 * @returns {number} - 背包能容纳的最大价值
 */
function knapsack_1d(weights, values, capacity) {
  const n = weights.length;
  // 1. 初始化一维dp数组:dp[j]表示容量j的背包的最大价值,初始值0
  const dp = new Array(capacity + 1).fill(0);

  // 2. 遍历顺序:先遍历物品(i从0到n-1),再倒序遍历容量(j从capacity到weights[i])(从右往左填充)
  for (let i = 0; i < n; i++) {
    // 倒序遍历避免重复选择当前物品
    for (let j = capacity; j >= weights[i]; j--) {
      // 3. 递推公式:不选当前物品的最大价值 vs 选当前物品的最大价值
      dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]);
    }
    // 打印每次处理物品后的dp数组(单行表格更新过程)
    console.log(`处理完物品${i + 1}后,dp数组:`, [...dp]);
  }

  // 最终答案:容量为capacity的背包的最大价值
  return dp[capacity];
}

// 测试用例
const weights1 = [2, 3, 4, 5];
const values1 = [3, 4, 5, 6];
const capacity1 = 8;
console.log('最大价值:', knapsack_1d(weights1, values1, capacity1)); // 输出:10

三、01背包的经典变形

01背包的核心是「选/不选」,实际考题中很少直接考查基础模型,更多是结合具体场景转化为变形问题。但无论场景如何变化,只要抓住「每个物品最多选一次」的本质,就能用DP解题5步「万能钥匙」轻松破解。以下是4类最经典的01背包变形:

3.1 变形1:目标和(分割子集和/是否能装满背包)

LeetCode 链接494. 目标和

问题描述 :给定一个非负整数数组nums和一个目标数target,向数组中每个整数前添加+-,使得所有整数的和等于target,求有多少种不同的添加符号的方法。

核心转化:设添加+的数的和为left,添加-的数的和为right,则有:

scss 复制代码
left - right = target
left + right = sum(nums)
两式相加得:left = (target + sum(nums)) / 2

问题转化为:从nums中选择若干元素,使得其和恰好为left,求这样的选择方案数------这是「01背包求方案数」的典型场景(每个元素选或不选,选则计入和,不选则不计)。

目标和问题核心表格(空表,后续逐步填充):

处理阶段\和为j 0 1 2 ...(和递增) left(目标和)
初始状态 待填充 待填充 待填充 待填充 待填充
处理第1个元素 待填充 待填充 待填充 待填充 待填充
处理第2个元素 待填充 待填充 待填充 待填充 待填充

表格说明:表格中每个单元格dp[i][j]代表「处理前i个元素后,能凑出和为j的方案数」,最终右下角dp[n][left]即为目标和的解法总数(n为nums数组长度)。

3.1.1 步骤1:确定dp数组及下标的含义

定义二维数组dp[i][j]:表示「处理前i个元素,能凑出和为j的方案数」。后续可优化为一维数组dp[j](空间优化思路与基础01背包一致),这里先从直观的二维数组入手。

对应表格维度:i(行)表示处理的元素个数(从0到n,0代表未处理任何元素),j(列)表示要凑的和(从0到left,0代表和为0),表格共n+1行、left+1列。

3.1.2 步骤2:确定递推公式

对于第i个元素(值为nums[i-1],数组索引从0开始,i从1开始),核心决策仍是「选或不选」,方案数为两种决策的总和:

  1. 不选第i个元素 :凑出和为j的方案数 = 处理前i-1个元素凑出和为j的方案数,即dp[i][j] += dp[i-1][j]

  2. 选第i个元素 :需保证j ≥ nums[i-1](当前元素值不大于目标和j),此时方案数 = 处理前i-1个元素凑出和为j-nums[i-1]的方案数,即dp[i][j] += dp[i-1][j - nums[i-1]]

最终递推公式(两种决策方案数相加):

javascript 复制代码
if (j >= nums[i - 1]) {
  dp[i][j] = dp[i - 1][j] + dp[i - 1][j - nums[i - 1]];
} else {
  dp[i][j] = dp[i - 1][j];
}
3.1.3 步骤3:dp数组如何初始化

初始化核心是确定边界条件,即无需推导就能直接确定的方案数:

  1. i=0(未处理任何元素),j=0(和为0) :不选任何元素即可凑出和为0,因此方案数为1,即dp[0][0] = 1

  2. i=0(未处理任何元素),j>0(和大于0) :没有元素可选,无法凑出任何正和,方案数为0,即dp[0][j] = 0(j>0);

  3. j=0(和为0),i>0(处理过元素):初始时可先设为1(后续通过递推更新),表示不选当前及之前元素的基础方案。

结合示例理解:假设nums = [1,1,1,1],target = 2,先计算sum(nums) = 4,left = (2 + 4)/2 = 3。初始化后的表格(第0行已填充):

处理阶段\和为j 0 1 2 3
初始状态(i=0) 1 0 0 0
处理第1个元素(1) 1 待填 待填 待填
处理第2个元素(1) 1 待填 待填 待填
处理第3个元素(1) 1 待填 待填 待填
处理第4个元素(1) 1 待填 待填 待填
3.1.4 步骤4:确定遍历顺序(表格填充顺序)

与基础01背包二维解法一致:先遍历元素(i从1到n),再遍历和(j从0到left),即逐行填充表格 。原因:计算dp[i][j]时,仅依赖上一行(i-1行)的dp[i-1][j]dp[i-1][j - nums[i-1]],逐行填充可确保依赖的单元格已提前计算完成。

3.1.5 步骤5:打印dp数组(验证)

以示例nums = [1,1,1,1]target = 2(left=3)为例,逐步填充表格验证逻辑:

  1. 填充第1行(i=1,元素1:1)

    • j=0:不选元素1,方案数=dp[0][0]=1;
    • j=1:j≥1,方案数=dp[0][1](不选)+ dp[0][0](选)=0+1=1;
    • j=2:j>1,无法选,方案数=dp[0][2]=0;
    • j=3:j>1,无法选,方案数=dp[0][3]=0;
  2. 填充第2行(i=2,元素2:1)

    • j=0:方案数=dp[1][0]=1;
    • j=1:j≥1,方案数=dp[1][1](不选)+ dp[1][0](选)=1+1=2;
    • j=2:j≥1,方案数=dp[1][2](不选)+ dp[1][1](选)=0+1=1;
    • j=3:j>1,无法选,方案数=dp[1][3]=0;
  3. 填充第3行(i=3,元素3:1)

    • j=0:方案数=dp[2][0]=1;
    • j=1:j≥1,方案数=dp[2][1](不选)+ dp[2][0](选)=2+1=3;
    • j=2:j≥1,方案数=dp[2][2](不选)+ dp[2][1](选)=1+2=3;
    • j=3:j≥1,方案数=dp[2][3](不选)+ dp[2][2](选)=0+1=1;
  4. 填充第4行(i=4,元素4:1)

    • j=0:方案数=dp[3][0]=1;
    • j=1:j≥1,方案数=dp[3][1](不选)+ dp[3][0](选)=3+1=4;
    • j=2:j≥1,方案数=dp[3][2](不选)+ dp[3][1](选)=3+3=6;
    • j=3:j≥1,方案数=dp[3][3](不选)+ dp[3][2](选)=1+3=4;

最终填充完成的表格:

处理阶段\和为j 0 1 2 3
初始状态(i=0) 1 0 0 0
处理第1个元素(1) 1 1 0 0
处理第2个元素(1) 1 2 1 0
处理第3个元素(1) 1 3 3 1
处理第4个元素(1) 1 4 6 4

表格右下角dp[4][3] = 4,即该示例的目标和解法总数为4,与实际情况一致(+1+1+1-1、+1+1-1+1、+1-1+1+1、-1+1+1+1)。

3.1.6 目标和问题完整代码(二维+一维优化)
javascript 复制代码
/**
 * 目标和(二维DP解法)
 * @param {number[]} nums - 非负整数数组
 * @param {number} target - 目标和
 * @returns {number} - 不同的添加符号方法数
 */
function findTargetSumWays_2d(nums, target) {
  const sum = nums.reduce((a, b) => a + b, 0);
  // 边界条件:target的绝对值大于sum,或(target + sum)为奇数,均无可行方案
  if (Math.abs(target) > sum || (target + sum) % 2 !== 0) return 0;
  const left = (target + sum) / 2;
  const n = nums.length;
  // 初始化二维dp数组:dp[i][j]表示处理前i个元素凑出和为j的方案数
  const dp = new Array(n + 1).fill(0).map(() => new Array(left + 1).fill(0));
  dp[0][0] = 1; // 未处理元素时,凑出和为0的方案数为1

  // 遍历顺序:先遍历元素,再遍历和(逐行填充)
  for (let i = 1; i <= n; i++) {
    for (let j = 0; j <= left; j++) {
      // 递推公式
      if (j >= nums[i - 1]) {
        dp[i][j] = dp[i - 1][j] + dp[i - 1][j - nums[i - 1]];
      } else {
        dp[i][j] = dp[i - 1][j];
      }
    }
  }

  // 打印dp数组验证
  console.log('目标和二维DP数组(表格):');
  for (let i = 0; i <= n; i++) {
    console.log(dp[i].join('\t'));
  }

  return dp[n][left];
}

/**
 * 目标和(一维DP空间优化解法)
 * @param {number[]} nums - 非负整数数组
 * @param {number} target - 目标和
 * @returns {number} - 不同的添加符号方法数
 */
function findTargetSumWays_1d(nums, target) {
  const sum = nums.reduce((a, b) => a + b, 0);
  if (Math.abs(target) > sum || (target + sum) % 2 !== 0) return 0;
  const left = (target + sum) / 2;
  // 初始化一维dp数组:dp[j]表示凑出和为j的方案数
  const dp = new Array(left + 1).fill(0);
  dp[0] = 1; // 基础方案:不选任何元素凑出和为0

  // 遍历顺序:先遍历元素,再倒序遍历和(避免重复选择)
  for (let num of nums) {
    for (let j = left; j >= num; j--) {
      dp[j] += dp[j - num]; // 递推公式简化(复用数组)
    }
    console.log(`处理完元素${num}后,dp数组:`, [...dp]);
  }

  return dp[left];
}

// 测试用例
const nums = [1, 1, 1, 1];
const target = 2;
console.log('二维DP解法:', findTargetSumWays_2d(nums, target)); // 输出:4
console.log('一维DP解法:', findTargetSumWays_1d(nums, target)); // 输出:4

3.2 变形2:分割等和子集(是否能装满背包)

LeetCode 链接416. 分割等和子集

问题描述 :给定一个只包含正整数的非空数组nums,判断是否可以将这个数组分割成两个子集,使得两个子集的和相等。

核心转化:两个子集和相等,即每个子集的和为数组总和的一半(记为target)。问题转化为:从nums中选择若干元素,使得其和恰好为target------这是「01背包判断可行性」的典型场景(每个元素选或不选,判断是否能装满容量为target的背包)。

核心表格(空表):

处理阶段\和为j 0 1 2 ...(和递增) target(目标和)
初始状态 待填充 待填充 待填充 待填充 待填充
处理第1个元素 待填充 待填充 待填充 待填充 待填充
处理第2个元素 待填充 待填充 待填充 待填充 待填充

表格说明:表格中每个单元格dp[i][j]代表「处理前i个元素后,能否凑出和为j」(布尔值),最终右下角dp[n][target]即为问题答案。

3.2.1 步骤1:确定dp数组及下标的含义

定义二维布尔数组dp[i][j]:表示「处理前i个元素,能否凑出和为j」。可优化为一维布尔数组dp[j],空间复杂度从O(n*target)降至O(target)

对应表格维度:i(行)表示处理的元素个数(从0到n,0代表未处理任何元素),j(列)表示要凑的和(从0到target,0代表和为0),表格共n+1行、target+1列。

3.2.2 步骤2:确定递推公式

对于第i个元素(值为nums[i-1]),决策为「选或不选」,可行性为两种决策的或运算:

  1. 不选第i个元素 :能否凑出j = 处理前i-1个元素能否凑出j,即dp[i][j] = dp[i-1][j]

  2. 选第i个元素 :需j ≥ nums[i-1],能否凑出j = 处理前i-1个元素能否凑出j-nums[i-1],即dp[i][j] = dp[i-1][j - nums[i-1]]

最终递推公式(两种决策有一个可行则整体可行):

javascript 复制代码
if (j >= nums[i - 1]) {
  dp[i][j] = dp[i - 1][j] || dp[i - 1][j - nums[i - 1]];
} else {
  dp[i][j] = dp[i - 1][j];
}
3.2.3 步骤3:dp数组如何初始化

初始化核心是确定边界条件,即无需推导就能直接确定的可行性:

  1. i=0(未处理任何元素),j=0(和为0) :不选任何元素可凑出和为0,因此dp[0][0] = true

  2. i=0(未处理任何元素),j>0(和大于0) :没有元素可选,无法凑出任何正和,可行性为false,即dp[0][j] = false(j>0);

  3. j=0(和为0),i>0(处理过元素) :初始时可先设为true(后续通过递推更新),表示不选当前及之前元素的基础方案。

结合示例理解:假设nums = [1,5,11,5],sum = 22,target = 11。初始化后的表格(第0行已填充):

处理阶段\和为j 0 1 2 3 ...(和递增) 11(目标和)
初始状态(i=0) true false false false false false
处理第1个元素(1) true 待填 待填 待填 待填 待填
处理第2个元素(5) true 待填 待填 待填 待填 待填
处理第3个元素(11) true 待填 待填 待填 待填 待填
处理第4个元素(5) true 待填 待填 待填 待填 待填
3.2.4 步骤4:确定遍历顺序(表格填充顺序)

与基础01背包二维解法一致:先遍历元素(i从1到n),再遍历和(j从0到target),即逐行填充表格 。原因:计算dp[i][j]时,仅依赖上一行(i-1行)的dp[i-1][j]dp[i-1][j - nums[i-1]],逐行填充可确保依赖的单元格已提前计算完成。

一维解法:先遍历元素,再倒序遍历和(避免重复选择),与基础01背包空间优化逻辑一致。

3.2.5 步骤5:打印dp数组(验证)

以示例nums = [1,5,11,5]sum = 22target = 11为例,逐步填充表格验证逻辑:

  1. 填充第1行(i=1,元素1:1)

    • j=0:不选元素1,dp[1][0] = dp[0][0] = true;
    • j=1:j≥1,dp[1][1] = dp[0][1](不选)|| dp[0][0](选)= false || true = true;
    • j=2-11:j<1,无法选,dp[1][j] = dp[0][j] = false;
  2. 填充第2行(i=2,元素2:5)

    • j=0:dp[2][0] = dp[1][0] = true;
    • j=1-4:j<5,无法选,dp[2][j] = dp[1][j](继承上一行);
    • j=5:j≥5,dp[2][5] = dp[1][5](不选)|| dp[1][0](选)= false || true = true;
    • j=6:j≥5,dp[2][6] = dp[1][6](不选)|| dp[1][1](选)= false || true = true;
    • j=7-11:j≥5,dp[2][j] = dp[1][j](不选)|| dp[1][j-5](选),其中j=11时,dp[2][11] = false || false = false;
  3. 填充第3行(i=3,元素3:11)

    • j=0-10:j<11,无法选,dp[3][j] = dp[2][j](继承上一行);
    • j=11:j≥11,dp[3][11] = dp[2][11](不选)|| dp[2][0](选)= false || true = true;
  4. 填充第4行(i=4,元素4:5)

    • j=0-4:j<5,无法选,dp[4][j] = dp[3][j](继承上一行);
    • j=5-11:j≥5,dp[4][j] = dp[3][j](不选)|| dp[3][j-5](选),其中j=11时,dp[4][11] = true || false = true;

最终填充完成的表格:

处理阶段\和为j 0 1 2 3 4 5 6 7 8 9 10 11
初始状态(i=0) true false false false false false false false false false false false
处理第1个元素(1) true true false false false false false false false false false false
处理第2个元素(5) true true false false false true true false false false false false
处理第3个元素(11) true true false false false true true false false false false true
处理第4个元素(5) true true false false false true true false false false false true

表格右下角dp[4][11] = true,即该示例可以分割成两个和相等的子集(子集[1,5,5]和[11]),与预期结果一致。

3.2.6 分割等和子集完整代码
javascript 复制代码
/**
 * 分割等和子集(二维DP解法)
 * @param {number[]} nums - 正整数数组
 * @returns {boolean} - 是否可以分割成两个和相等的子集
 */
function canPartition_2d(nums) {
  const sum = nums.reduce((a, b) => a + b, 0);
  if (sum % 2 !== 0) return false; // 总和为奇数,无法分割
  const target = sum / 2;
  const n = nums.length;
  // 初始化二维dp数组:dp[i][j]表示处理前i个元素能否凑出和为j
  const dp = new Array(n + 1).fill(0).map(() => new Array(target + 1).fill(false));
  dp[0][0] = true; // 边界条件

  // 遍历顺序:先遍历元素,再遍历和
  for (let i = 1; i <= n; i++) {
    for (let j = 0; j <= target; j++) {
      if (j >= nums[i - 1]) {
        dp[i][j] = dp[i - 1][j] || dp[i - 1][j - nums[i - 1]];
      } else {
        dp[i][j] = dp[i - 1][j];
      }
    }
  }

  // 打印dp数组验证
  console.log('分割等和子集二维DP数组(表格):');
  for (let i = 0; i <= n; i++) {
    console.log(dp[i].map(val => (val ? 'true' : 'false')).join('\t'));
  }

  return dp[n][target];
}

/**
 * 分割等和子集(一维DP空间优化解法)
 * @param {number[]} nums - 正整数数组
 * @returns {boolean} - 是否可以分割成两个和相等的子集
 */
function canPartition_1d(nums) {
  const sum = nums.reduce((a, b) => a + b, 0);
  if (sum % 2 !== 0) return false;
  const target = sum / 2;
  // 初始化一维dp数组:dp[j]表示能否凑出和为j
  const dp = new Array(target + 1).fill(false);
  dp[0] = true; // 边界条件

  // 遍历顺序:先遍历元素,再倒序遍历和
  for (let num of nums) {
    for (let j = target; j >= num; j--) {
      dp[j] = dp[j] || dp[j - num];
    }
    console.log(
      `处理完元素${num}后,dp数组:`,
      dp.map(val => (val ? 'true' : 'false'))
    );
  }

  return dp[target];
}

// 测试用例
const nums1 = [1, 5, 11, 5];
console.log('二维DP解法:', canPartition_2d(nums1)); // 输出:true
console.log('一维DP解法:', canPartition_1d(nums1)); // 输出:true

const nums2 = [1, 2, 3, 5];
console.log('二维DP解法:', canPartition_2d(nums2)); // 输出:false
console.log('一维DP解法:', canPartition_1d(nums2)); // 输出:false

3.3 变形3:最后一块石头的重量II(最小背包剩余容量)

LeetCode 链接1049. 最后一块石头的重量 II

问题描述:有一堆石头,每块石头的重量都是正整数。每一回合,从中选出任意两块石头,然后将它们一起粉碎。假设石头的重量分别为x和y,且x ≤ y。那么粉碎的可能结果如下:如果x == y,那么两块石头都会被完全粉碎;如果x != y,那么重量为x的石头会被完全粉碎,而重量为y的石头会变成y - x的重量。最后,最多只会剩下一块石头。返回此石头的最小可能重量。

核心转化:要使最后剩余石头重量最小,需将石头尽可能分成两堆重量接近的石头------两堆重量差越小,剩余重量越小。设总重量为sum,目标是找到一堆石头的最大重量maxWeight(≤ sum/2),则剩余重量为sum - 2*maxWeight。问题转化为:从石头重量数组中选择若干元素,使得其和不超过sum/2的最大值------这是「01背包求最大价值(重量即价值)」的场景(背包容量为sum/2,物品重量和价值均为石头重量)。

最后一块石头的重量II核心表格(空表,后续逐步填充):

处理阶段\容量j 0 1 2 ...(容量递增) target(sum/2)
初始状态 待填充 待填充 待填充 待填充 待填充
处理第1块石头 待填充 待填充 待填充 待填充 待填充
处理第2块石头 待填充 待填充 待填充 待填充 待填充

表格说明:表格中每个单元格dp[j]代表「容量为j的背包能容纳的最大重量」(一维DP),最终dp[target]即为不超过sum/2的最大子集重量,剩余重量 = sum - 2*dp[target]。

3.3.1 步骤1:确定dp数组及下标的含义

定义一维数组dp[j]:表示「容量为j的背包,能容纳的最大重量」(即选若干石头的最大和)。

对应表格维度:仅保留"容量j"这一列维度(j从0到target,target = sum/2向下取整),形成单行表格,每次遍历石头时,滚动更新这一行的数值(覆盖上一行的结果)。

3.3.2 步骤2:确定递推公式

对于第i块石头(重量stones[i],价值也为stones[i]),有两种核心决策:选或不选。

  1. 不选第i块石头 :容量为j的最大重量 = 不选当前石头时的最大重量,即dp[j] = dp[j](保持不变);

  2. 选第i块石头 :需保证背包容量j ≥ 第i块石头的重量,此时最大重量 = 容量j-stones[i]的最大重量 + 第i块石头的重量,即dp[j] = dp[j - stones[i]] + stones[i]

最终递推公式(取两种决策的最大值):

javascript 复制代码
if (j >= stones[i]) {
  dp[j] = Math.max(dp[j], dp[j - stones[i]] + stones[i]);
} else {
  dp[j] = dp[j]; // 容量不足,无法选
}

简化后(因为容量不足时dp[j]不变):

javascript 复制代码
for (let j = target; j >= stones[i]; j--) {
  dp[j] = Math.max(dp[j], dp[j - stones[i]] + stones[i]);
}
3.3.3 步骤3:dp数组如何初始化

初始化逻辑与基础01背包一维DP一致:容量为0时,最大重量为0,因此dp[0] = 0;其他容量的初始值也为0(因为初始无石头可放,最大重量为0),即dp = new Array(target + 1).fill(0)

初始化后的单行表格:[0,0,0,0,...](j从0到target)

结合示例理解:假设stones = [2,7,4,1,8,1],sum = 23,target = Math.floor(23/2) = 11。初始化后的表格:

容量j 0 1 2 3 4 5 6 7 8 9 10 11
初始 0 0 0 0 0 0 0 0 0 0 0 0
3.3.4 步骤4:确定遍历顺序(表格填充顺序)

一维DP的遍历顺序有严格要求,核心是「倒序遍历容量」,对应单行表格的「从右往左填充」:

  1. 必须先遍历石头,再遍历容量:逐个处理每块石头,每次处理时更新整个单行表格(覆盖上一行结果);

  2. 容量必须倒序遍历(j从target到stones[i]) :从最大容量往小容量填充,确保计算dp[j]时,dp[j - stones[i]]仍是上一行(未处理当前石头)的旧值,避免同一石头被多次选择。

3.3.5 步骤5:打印dp数组(验证)

通过打印单行表格的滚动更新过程,验证填充规则的正确性。仍用测试用例 stones = [2,7,4,1,8,1]sum = 23target = 11,演示一维DP数组(单行表格)的填充变化:

  1. 初始状态:dp = [0,0,0,0,0,0,0,0,0,0,0,0]

  2. 处理石头1(w=2),j从11到2倒序:更新后:dp = [0,0,2,2,2,2,2,2,2,2,2,2]

    • j=11:dp[11] = max(0, dp[9]+2) = max(0,0+2)=2;
    • j=10:dp[10] = max(0, dp[8]+2)=2;
    • ...(j=2到9同理);
    • j=2:dp[2] = max(0, dp[0]+2)=2;
  3. 处理石头2(w=7),j从11到7倒序:更新后:dp = [0,0,2,2,2,2,2,7,7,9,9,9]

    • j=11:max(2, dp[4]+7)=max(2,2+7)=9;
    • j=10:max(2, dp[3]+7)=max(2,2+7)=9;
    • j=9:max(2, dp[2]+7)=max(2,2+7)=9;
    • j=8:max(2, dp[1]+7)=max(2,0+7)=7;
    • j=7:max(2, dp[0]+7)=max(2,0+7)=7;
  4. 处理石头3(w=4),j从11到4倒序:更新后:dp = [0,0,2,2,4,4,6,7,7,9,9,11]

    • j=11:max(9, dp[7]+4)=max(9,7+4)=11;
    • j=10:max(9, dp[6]+4)=max(9,2+4)=9;
    • j=9:max(9, dp[5]+4)=max(9,2+4)=9;
    • j=8:max(7, dp[4]+4)=max(7,2+4)=7;
    • j=7:max(7, dp[3]+4)=max(7,2+4)=7;
    • j=6:max(2, dp[2]+4)=max(2,2+4)=6;
    • j=5:max(2, dp[1]+4)=max(2,0+4)=4;
    • j=4:max(2, dp[0]+4)=max(2,0+4)=4;
  5. 处理石头4(w=1),j从11到1倒序:更新后:dp = [0,1,2,3,4,5,6,7,8,9,10,11]

    • j=11:max(11, dp[10]+1)=max(11,9+1)=11;
    • j=10:max(9, dp[9]+1)=max(9,9+1)=10;
    • ...(其他位置类似更新);
  6. 处理石头5(w=8),j从11到8倒序:更新后:dp = [0,1,2,3,4,5,6,7,8,9,10,11]

    • j=11:max(11, dp[3]+8)=max(11,3+8)=11;
    • j=10:max(10, dp[2]+8)=max(10,2+8)=10;
    • j=9:max(9, dp[1]+8)=max(9,1+8)=9;
    • j=8:max(8, dp[0]+8)=max(8,0+8)=8;
  7. 处理石头6(w=1),j从11到1倒序:最终:dp = [0,1,2,3,4,5,6,7,8,9,10,11]

最终单行表格dp[11] = 11,剩余重量 = 23 - 2*11 = 1,与预期结果一致。

3.3.6 最后一块石头的重量II完整代码(一维DP)
javascript 复制代码
/**
 * 最后一块石头的重量II(一维DP解法)
 * @param {number[]} stones - 石头重量数组
 * @returns {number} - 最后剩余石头的最小可能重量
 */
function lastStoneWeightII(stones) {
  const sum = stones.reduce((a, b) => a + b, 0);
  const target = Math.floor(sum / 2);
  // 初始化一维dp数组:dp[j]表示容量为j的背包能容纳的最大重量
  const dp = new Array(target + 1).fill(0);

  // 遍历顺序:先遍历石头,再倒序遍历容量
  for (let stone of stones) {
    for (let j = target; j >= stone; j--) {
      dp[j] = Math.max(dp[j], dp[j - stone] + stone);
    }
    console.log(`处理完石头${stone}后,dp数组:`, [...dp]);
  }

  // 剩余重量 = 总重量 - 2*最大子集重量
  return sum - 2 * dp[target];
}

// 测试用例
const stones = [2, 7, 4, 1, 8, 1];
console.log('最后剩余石头的最小重量:', lastStoneWeightII(stones)); // 输出:1

3.4 变形4:一和零(二维背包容量)

LeetCode 链接474. 一和零

问题描述 :给你一个二进制字符串数组strs和两个整数mn。请你找出并返回strs的最大子集的长度,该子集中最多有m个0和n个1。

核心转化:每个字符串是一个"物品",选择该物品会消耗"0的数量"和"1的数量"两种容量,目标是在两种容量均不超过限制(m、n)的前提下,选择最多的物品------这是「二维容量01背包求最大物品数」的场景(背包有两个维度的容量限制,价值为1,求最大价值即最大物品数)。

一和零问题核心表格(空表,后续逐步填充):

0的数量\1的数量j 0 1 2 ...(1的数量递增) n(最大1的数量)
0(0的数量为0) 待填充 待填充 待填充 待填充 待填充
1(0的数量为1) 待填充 待填充 待填充 待填充 待填充
...(0的数量递增) 待填充 待填充 待填充 待填充 待填充
m(最大0的数量) 待填充 待填充 待填充 待填充 待填充(最终答案:最多字符串数量)

表格说明:表格中每个单元格dp[i][j]代表「最多使用i个0和j个1时,能选择的最大字符串数量」,我们的目标是按规则填充表格,最终右下角dp[m][n]即为一和零问题的答案。

3.4.1 步骤1:确定dp数组及下标的含义

定义二维数组dp[i][j]:表示「最多用i个0和j个1能选择的最大字符串数量」。

对应表格维度:i(行)表示0的数量(从0到m,0代表0个0),j(列)表示1的数量(从0到n,0代表0个1),表格共m+1行、n+1列。

3.4.2 步骤2:确定递推公式

对于每个字符串(含zero个0、one个1),有两种核心决策:选或不选。

  1. 不选当前字符串 :最多用i个0和j个1的最大字符串数量 = 不选当前字符串时的最大数量,即dp[i][j] = dp[i][j](保持不变);

  2. 选当前字符串 :需保证i ≥ zero且j ≥ one(0和1的数量都足够),此时最大数量 = 用i-zero个0和j-one个1的最大数量 + 1(当前字符串),即dp[i][j] = dp[i - zero][j - one] + 1

最终递推公式(取两种决策的最大值):

javascript 复制代码
if (i >= zero && j >= one) {
  dp[i][j] = Math.max(dp[i][j], dp[i - zero][j - one] + 1);
} else {
  dp[i][j] = dp[i][j]; // 容量不足,无法选
}

简化后(因为容量不足时dp[i][j]不变,且使用倒序遍历):

javascript 复制代码
for (let i = m; i >= zero; i--) {
  for (let j = n; j >= one; j--) {
    dp[i][j] = Math.max(dp[i][j], dp[i - zero][j - one] + 1);
  }
}
3.4.3 步骤3:dp数组如何初始化

初始化逻辑:初始无字符串可选,无论有多少0和1,最大字符串数量都为0,因此dp[i][j] = 0(所有单元格初始化为0),即dp = new Array(m+1).fill(0).map(() => new Array(n+1).fill(0))

初始化后的表格(所有单元格为0):

0的数量\1的数量j 0 1 2 ... n
0 0 0 0 0 0
1 0 0 0 0 0
... 0 0 0 0 0
m 0 0 0 0 0

结合示例理解:假设strs = ["10","0001","111001","1","0"],m = 5,n = 3。初始化后的表格(5+1行,3+1列):

0的数量\1的数量j 0 1 2 3
0 0 0 0 0
1 0 0 0 0
2 0 0 0 0
3 0 0 0 0
4 0 0 0 0
5 0 0 0 0
3.4.4 步骤4:确定遍历顺序(表格填充顺序)

二维容量01背包的遍历顺序有严格要求:

  1. 必须先遍历字符串(物品),再遍历0的数量,最后遍历1的数量:逐个处理每个字符串,每次处理时更新整个二维表格;

  2. 0和1的数量都必须倒序遍历

    • 0的数量倒序遍历(i从m到zero):确保计算dp[i][j]时,dp[i - zero][j - one]仍是上一轮(未处理当前字符串)的旧值;
    • 1的数量倒序遍历(j从n到one):同样确保依赖的单元格是旧值。

倒序遍历避免同一字符串被多次选择,完美契合01背包「每个物品选一次」的规则。

3.4.5 步骤5:打印dp数组(验证)

以示例strs = ["10","0001","111001","1","0"]m = 5n = 3为例,逐步填充表格验证逻辑:

  1. 处理字符串1("10":zero=1, one=1)

    • 更新dp[1][1]到dp[5][3]范围内所有满足i≥1且j≥1的位置
    • dp[1][1] = max(0, dp[0][0]+1) = max(0,0+1) = 1
    • dp[2][2] = max(0, dp[1][1]+1) = max(0,1+1) = 2
    • ...(其他位置类似)
  2. 处理字符串2("0001":zero=3, one=1)

    • 更新dp[3][1]到dp[5][3]范围内所有满足i≥3且j≥1的位置
    • dp[3][1] = max(dp[3][1], dp[0][0]+1) = max(0,0+1) = 1
    • dp[4][2] = max(dp[4][2], dp[1][1]+1) = max(0,1+1) = 2
    • ...(其他位置类似)
  3. 处理字符串3("111001":zero=2, one=4)

    • 由于one=4 > n=3,无法选择此字符串,dp数组不变
  4. 处理字符串4("1":zero=0, one=1)

    • 更新dp[0][1]到dp[5][3]范围内所有满足j≥1的位置
    • dp[0][1] = max(0, dp[0][0]+1) = max(0,0+1) = 1
    • dp[1][2] = max(dp[1][2], dp[1][1]+1) = max(0,1+1) = 2
    • ...(其他位置类似)
  5. 处理字符串5("0":zero=1, one=0)

    • 更新dp[1][0]到dp[5][3]范围内所有满足i≥1的位置
    • dp[1][0] = max(0, dp[0][0]+1) = max(0,0+1) = 1
    • dp[2][1] = max(dp[2][1], dp[1][1]+1) = max(0,1+1) = 2
    • ...(其他位置类似)

最终填充完成的表格(简化展示关键部分):

0的数量\1的数量j 0 1 2 3
0 0 1 1 1
1 1 2 2 2
2 1 2 3 3
3 1 2 3 3
4 1 2 3 4
5 1 2 3 4

表格右下角dp[5][3] = 4,即该示例的最大子集长度为4,与预期结果一致。

3.4.6 一和零完整代码(二维DP)
javascript 复制代码
/**
 * 一和零(二维DP解法)
 * @param {string[]} strs - 二进制字符串数组
 * @param {number} m - 最多允许的0的数量
 * @param {number} n - 最多允许的1的数量
 * @returns {number} - 最大子集长度
 */
function findMaxForm(strs, m, n) {
  // 初始化二维dp数组:dp[i][j]表示i个0和j个1能选的最大字符串数
  const dp = new Array(m + 1).fill(0).map(() => new Array(n + 1).fill(0));

  // 遍历每个字符串(物品)
  for (let str of strs) {
    // 统计当前字符串的0和1的数量
    let zero = 0,
      one = 0;
    for (let c of str) {
      c === '0' ? zero++ : one++;
    }

    // 倒序遍历0的数量,再倒序遍历1的数量(避免重复选择)
    for (let i = m; i >= zero; i--) {
      for (let j = n; j >= one; j--) {
        dp[i][j] = Math.max(dp[i][j], dp[i - zero][j - one] + 1);
      }
    }

    // 打印每次处理后的dp数组(简化打印,只打印部分关键行)
    console.log(`处理完字符串"${str}"后,dp数组(前5行前5列):`);
    for (let i = 0; i <= Math.min(m, 5); i++) {
      console.log(dp[i].slice(0, Math.min(n, 5)).join('\t'));
    }
  }

  return dp[m][n];
}

// 测试用例
const strs = ['10', '0001', '111001', '1', '0'];
const m = 5,
  n = 3;
console.log('最大子集长度:', findMaxForm(strs, m, n)); // 输出:4

四、01背包问题总结

01背包的核心是「选或不选」的二选一决策,所有变形都围绕这一核心逻辑,通过转化「物品」「容量」「目标」的含义,适配不同的实际场景。掌握以下关键点,可轻松破解所有01背包相关问题:

  1. 表格可视化核心:DP解题的本质是填充表格,先明确表格形态(dp数组含义),再按规则填充,表格填完即得答案;

  2. 5步万能钥匙:确定dp含义→递推公式→初始化→遍历顺序→验证,这是所有DP问题的通用拆解思路,尤其适用于背包问题;

  3. 空间优化技巧:二维DP可通过「倒序遍历容量」优化为一维DP,核心是复用数组空间,避免重复选择物品;

  4. 变形转化逻辑:无论场景如何变化,只要每个物品最多选一次,都可转化为01背包模型------关键是找到「物品」(待选择的元素/字符串等)、「容量」(限制条件,如重量、和、0/1数量等)、「目标」(最大价值、可行性、方案数等)。

通过基础模型+变形练习,熟练掌握表格填充逻辑和5步拆解方法,就能将复杂的DP问题转化为有序的表格填充过程,彻底攻克01背包这一DP核心模型。

相关推荐
困惑阿三4 小时前
2025 前端技术全景图:从“夯”到“拉”排行榜
前端·javascript·程序人生·react.js·vue·学习方法
iAkuya4 小时前
(leetcode)力扣100 34合并K个升序链表(排序,分治合并,优先队列)
算法·leetcode·链表
苏瞳儿4 小时前
vue2与vue3的区别
前端·javascript·vue.js
我是小狼君4 小时前
【查找篇章之三:斐波那契查找】斐波那契查找:用黄金分割去“切”数组
数据结构·算法
颜淡慕潇4 小时前
深度解析官方 Spring Boot 稳定版本及 JDK 配套策略
java·后端·架构
Victor3564 小时前
Hibernate(28)Hibernate的级联操作是什么?
后端
Victor3564 小时前
Hibernate(27)Hibernate的查询策略是什么?
后端
fengfuyao9855 小时前
基于MATLAB实现任意平面太阳辐射量计算
算法·matlab·平面
放荡不羁的野指针5 小时前
leetcode150题-字符串
数据结构·算法·leetcode
苦藤新鸡5 小时前
4.移动零
c++·算法·力扣