用填充表格法-继续吃透完全背包和多重背包及其变形

72 阅读41分钟

用填充表格法-继续吃透完全背包及其变形

动态规划中的「完全背包问题」是算法学习的核心考点之一,其衍生的「计数、最值、布尔判断」等变形题更是频繁出现在面试和算法竞赛中。很多初学者容易被「一维优化」「遍历顺序」等细节绕晕,本文将严格遵循「5步万能钥匙」架构,从二维DP解法入手(直观填充表格),再到一维优化(空间压缩),并附上每道题的LeetCode链接,让你彻底吃透这一经典问题。

一、纯完全背包原型(二维DP解法)

完全背包是01背包的扩展——核心区别是「每种物品可无限次选取」,我们先从二维DP解法入手,完整演示表格填充过程。

示例:有3种物品,重量数组w = [2,3,4],价值数组v = [3,4,5],背包最大容量C = 8,求能放入背包的最大价值(预期输出:12)。

表格动态演示

dpw-1.gif

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

定义二维数组dp[i][j]:表示「前i个物品放入容量为j的背包中,能获得的最大价值」(物品可无限选)。

对应表格维度:i(行)表示物品数量(从0到3,0代表无物品),j(列)表示背包容量(从0到8,0代表容量为0),表格共4行9列(i:0-3,j:0-8)。

1.2 步骤2:确定递推公式

对于第i个物品(重量w[i-1]、价值v[i-1],数组索引从0开始,i从1开始),有两种核心决策:选或不选。

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

  2. 选第i个物品:需保证背包容量j ≥ 第i个物品的重量,此时最大价值 = 前i个物品放入容量j-w[i-1]的背包的最大价值 + 第i个物品的价值(区别于01背包的核心:选后仍能选当前物品,依赖本行前序结果),即dp[i][j] = dp[i][j - w[i-1]] + v[i-1]

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


if (j < w[i - 1]) {
  // 容量不足,无法选第i个物品
  dp[i][j] = dp[i - 1][j];
} else {
  // 容量充足,选或不选取最大值(选则依赖本行结果,支持无限选)
  dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - w[i - 1]] + v[i - 1]);
}

这是完全背包与01背包的核心差异:01背包选后依赖i-1行,完全背包选后依赖i行,因此支持「无限次选取同一物品」。

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个物品\背包容量j012345678
0(无物品)000000000
1(物品1:w=2,v=3)0待填待填待填待填待填待填待填待填
2(物品2:w=3,v=4)0待填待填待填待填待填待填待填待填
3(物品3:w=4,v=5)0待填待填待填待填待填待填待填待填

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

完全背包二维解法的遍历顺序与01背包一致,有两种可行方式:

  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]时,仅依赖「上一行同列」或「本行左侧列」的结果,这两个位置都已提前填充。实际解题中更常用「先遍历物品,再遍历容量」的顺序,符合「逐个考虑物品是否放入」的思考逻辑。

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

通过逐步填充表格、打印中间状态,验证每一步是否符合递推规则:

1.5.1 填充第1行(i=1,物品1:w=2,v=3)

填充后第1行:[0,0,3,3,6,6,9,9,12]

  • j=1:容量<2,无法选,dp[1][1] = dp[0][1] = 0;

  • j=2:容量≥2,选则dp[1][0]+3=3,不选则0,取max=3;

  • j=3:选则dp[1][1]+3=3,不选则0,取max=3;

  • j=4:选则dp[1][2]+3=6,不选则0,取max=6;

  • j=5:选则dp[1][3]+3=6,不选则0,取max=6;

  • j=6:选则dp[1][4]+3=9,不选则0,取max=9;

  • j=7:选则dp[1][5]+3=9,不选则0,取max=9;

  • j=8:选则dp[1][6]+3=12,不选则0,取max=12;

1.5.2 填充第2行(i=2,物品2:w=3,v=4)

填充后第2行:[0,0,3,4,6,7,9,10,12]

  • j=1-2:容量<3,dp[2][j] = dp[1][j](0,3);

  • j=3:选则dp[2][0]+4=4,不选则3,取max=4;

  • j=4:选则dp[2][1]+4=4,不选则6,取max=6;

  • j=5:选则dp[2][2]+4=3+4=7,不选则6,取max=7;

  • j=6:选则dp[2][3]+4=4+4=8,不选则9,取max=9;

  • j=7:选则dp[2][4]+4=6+4=10,不选则9,取max=10;

  • j=8:选则dp[2][5]+4=7+4=11,不选则12,取max=12;

1.5.3 填充第3行(i=3,物品3:w=4,v=5)

填充后第3行:[0,0,3,4,6,7,9,10,12]

  • j=1-3:容量<4,dp[3][j] = dp[2][j](0,3,4);

  • j=4:选则dp[3][0]+5=5,不选则6,取max=6;

  • j=5:选则dp[3][1]+5=5,不选则7,取max=7;

  • j=6:选则dp[3][2]+5=3+5=8,不选则9,取max=9;

  • j=7:选则dp[3][3]+5=4+5=9,不选则10,取max=10;

  • j=8:选则dp[3][4]+5=6+5=11,不选则12,取max=12;

最终填充完成的表格:

前i个物品\背包容量j012345678
0(无物品)000000000
1(物品1:w=2,v=3)0033669912
2(物品2:w=3,v=4)00346791012
3(物品3:w=4,v=5)00346791012

表格右下角dp[3][8] = 12,与预期结果一致(选4个重量为2的物品,价值3×4=12)。

1.6 纯完全背包二维DP完整代码(JavaScript)


/**
 * 纯完全背包原型(二维DP解法)
 * @param {number[]} w - 物品重量数组
 * @param {number[]} v - 物品价值数组
 * @param {number} c - 背包最大容量
 * @returns {number} - 背包能容纳的最大价值
 */
function completeKnapsack_2d(w, v, c) {
  const n = w.length;
  // 1. 初始化二维dp数组:dp[i][j]表示前i个物品放入容量j的背包的最大价值
  const dp = new Array(n + 1).fill(0).map(() => new Array(c + 1).fill(0));

  // 2. 遍历顺序:先遍历物品(i从1到n),再遍历容量(j从1到c)(逐行填充)
  for (let i = 1; i <= n; i++) {
    for (let j = 1; j <= c; j++) {
      // 3. 递推公式:容量不足则不选,容量充足则选或不选取最大值(选则依赖本行)
      if (j < w[i - 1]) {
        dp[i][j] = dp[i - 1][j];
      } else {
        dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - w[i - 1]] + v[i - 1]);
      }
    }
  }

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

  // 最终答案:前n个物品放入容量c的背包的最大价值
  return dp[n][c];
}

// 测试用例
const w = [2, 3, 4];
const v = [3, 4, 5];
const c = 8;
console.log('最大价值:', completeKnapsack_2d(w, v, c)); // 输出:12

1.7 一维DP优化(空间压缩)

完全背包的一维DP优化核心是「正序遍历容量」(区别于01背包的逆序),复用本行前序结果实现「无限选」。其优化思路并非凭空设计,而是基于二维DP解法的状态依赖逻辑进行空间压缩,具体可拆解为以下3个关键步骤:

1. 优化基础:明确二维DP的状态依赖特性

在纯完全背包的二维DP解法中,递推公式为:当容量j ≥ 物品重量w[i-1]时,dp[i][j] = max(dp[i-1][j], dp[i]j - w[i-1]] + v[i-1])。观察该公式可发现,计算第i行的dp[i][j]时,仅依赖两个位置的状态:① 上一行同列的dp[i-1][j](不选当前物品的情况);② 本行左侧列的dp[i]j - w[i-1]](选当前物品的情况)。

这一依赖特性意味着,我们无需保留完整的二维数组。因为计算第i行数据时,仅需用到上一行的“历史数据”和本行已计算出的“前序数据”,可以用一个一维数组滚动存储这些状态,从而将空间复杂度从O(n×c)优化为O(c)(n为物品数量,c为背包容量)。

2. 核心设计:正序遍历容量实现“无限选”

一维DP数组的定义为dp[j],表示“容量为j的背包能容纳的最大价值”,对应二维数组中当前行的dp[i][j]。为了实现完全背包“物品可无限选取”的特性,内层容量遍历必须采用「正序」(从curW到c,curW为当前物品重量),具体原因如下:

当正序遍历容量时,计算dp[j]时,dp[j - curW]已经是本次遍历物品i时更新过的“本行前序结果”(而非上一轮物品i-1的历史结果)。例如,遍历物品1(w=2,v=3)时,先计算dp[2] = dp[0]+3=3;继续遍历j=4时,dp[4] = dp[2]+3=6,此时复用的dp[2]是已纳入物品1的结果,相当于在背包中再次放入了物品1,实现了“无限选”的效果。

这里需要特别区分与01背包的差异:01背包要求物品只能选一次,因此内层需逆序遍历容量,确保dp[j - curW]使用的是上一轮的历史数据(未纳入当前物品);而完全背包的正序遍历,正是通过复用本轮已更新的数据,达成“重复选取当前物品”的核心需求。

3. 遍历逻辑:外层物品、内层容量的固定顺序

一维DP的遍历顺序必须是「外层遍历物品,内层正序遍历容量」。外层遍历物品保证每个物品都被考虑到,内层正序遍历容量则保证每个物品可以被多次选取。若颠倒遍历顺序(外层容量、内层物品),会导致同一容量下多次重复计算同一物品的贡献,最终结果错误(相当于变成了排列数问题,而非背包最值问题)。

具体遍历流程:① 初始化一维dp数组为全0(对应二维数组第0行的边界条件,无物品时所有容量的最大价值为0);② 逐个遍历每个物品,获取当前物品的重量curW和价值curV;③ 对每个物品,正序遍历容量从curW到c,通过dp[j] = max(dp[j], dp[j - curW] + curV)更新状态;④ 所有物品遍历完成后,dp[c]即为最终答案。

4. 与二维解法的结果一致性验证

以示例中的物品(w=[2,3,4], v=[3,4,5])和容量c=8为例,一维DP的计算过程与二维数组的填充过程完全匹配:

遍历物品1(w=2,v=3)时,正序更新dp[2]~dp[8],得到dp=[0,0,3,3,6,6,9,9,12](对应二维数组第1行);遍历物品2(w=3,v=4)时,正序更新dp[3]~dp[8],得到dp=[0,0,3,4,6,7,9,10,12](对应二维数组第2行);遍历物品3(w=4,v=5)时,正序更新dp[4]~dp[8],最终dp=[0,0,3,4,6,7,9,10,12](对应二维数组第3行),dp[8]=12与二维解法结果一致,验证了优化思路的正确性。


/**
 * 纯完全背包原型(一维DP优化版)
 * @param {number[]} w - 物品重量数组
 * @param {number[]} v - 物品价值数组
 * @param {number} c - 背包最大容量
 * @returns {number} - 背包能容纳的最大价值
 */
function completeKnapsack_1d(w, v, c) {
  // 1. 初始化一维dp数组:dp[j]表示容量为j的背包的最大价值
  const dp = new Array(c + 1).fill(0);

  // 2. 遍历顺序:外层物品,内层正序遍历容量(允许重复选)
  for (let i = 0; i < w.length; i++) {
    const curW = w[i];
    const curV = v[i];
    // 正序遍历:复用本行前序结果,实现无限选
    for (let j = curW; j <= c; j++) {
      dp[j] = Math.max(dp[j], dp[j - curW] + curV);
    }
  }

  // 打印一维dp数组验证
  console.log('纯完全背包一维DP数组:', dp);

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

// 测试用例
console.log('一维优化版最大价值:', completeKnapsack_1d(w, v, c)); // 输出:12

二、完全背包变形1:最值类(完全平方数)

LeetCode链接:leetcode.cn/problems/pe…

题目描述

给定正整数 n,找到若干完全平方数(1,4,9...)使其和等于 n,要求个数最少。

示例:n=12 → 输出3(12=4+4+4);n=13 → 输出2(13=4+9)。

表格动态演示

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

定义二维数组dp[i][j]:表示「前i个完全平方数(1²,2²,...,i²)凑出和为j的最少个数」。

对应表格维度:i(行)表示完全平方数的个数(从0到m,m=Math.floor(Math.sqrt(n))),j(列)表示目标和(从0到n)。

2.2 步骤2:确定递推公式

对于第i个完全平方数(值curPow = i²),有两种决策:选或不选:

  1. 不选第i个完全平方数dp[i][j] = dp[i-1][j]

  2. 选第i个完全平方数:需保证j ≥ curPow,此时dp[i][j] = dp[i][j - curPow] + 1(选后仍能选当前数,+1表示个数加1)。

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


if (j < curPow) {
  dp[i][j] = dp[i - 1][j];
} else {
  dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - curPow] + 1);
}

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

最值类问题初始化核心是「默认值设为无穷大(表示不可达),边界设为0」:

  1. i=0(无完全平方数):除j=0外,其余dp[0][j] = Infinity(无数字无法凑出和);

  2. j=0(凑0):所有i的dp[i][0] = 0(凑0需要0个数字);

  3. 其余单元格初始化为Infinity(默认不可达)。

2.4 步骤4:确定遍历顺序

先遍历完全平方数(i从1到m),再遍历目标和(j从1到n),逐行填充表格。

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

n=12为例(m=3,对应1²、2²、3²),最终表格右下角dp[3][12] = 3,与预期一致。

2.6 二维DP完整代码(JavaScript)


/**
 * 完全平方数(二维DP解法)
 * @param {number} n - 目标数
 * @returns {number} - 最少完全平方数个数
 */
function numSquares_2d(n) {
  const m = Math.floor(Math.sqrt(n)); // 最大完全平方数的底数
  // 1. 初始化二维dp数组:dp[i][j]前i个完全平方数凑和j的最少个数,默认Infinity
  const dp = new Array(m + 1).fill(0).map(() => new Array(n + 1).fill(Infinity));

  // 2. 边界条件:凑0需要0个
  for (let i = 0; i <= m; i++) {
    dp[i][0] = 0;
  }

  // 3. 遍历顺序:先遍历完全平方数,再遍历目标和
  for (let i = 1; i <= m; i++) {
    const curPow = i * i; // 第i个完全平方数
    for (let j = 1; j <= n; j++) {
      // 4. 递推公式
      if (j < curPow) {
        dp[i][j] = dp[i - 1][j];
      } else {
        dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - curPow] + 1);
      }
    }
  }

  // 打印dp数组验证
  console.log('完全平方数二维DP数组:');
  for (let i = 0; i <= m; i++) {
    console.log(dp[i].join('\t'));
  }

  return dp[m][n];
}

// 测试用例
console.log('最少个数(n=12):', numSquares_2d(12)); // 输出:3
console.log('最少个数(n=13):', numSquares_2d(13)); // 输出:2

2.7 一维DP优化


/**
 * 完全平方数(一维DP优化版)
 * @param {number} n - 目标数
 * @returns {number} - 最少完全平方数个数
 */
function numSquares_1d(n) {
  // 1. 初始化一维dp数组:dp[j]凑和j的最少个数
  const dp = new Array(n + 1).fill(Infinity);
  dp[0] = 0; // 边界:凑0需要0个

  const m = Math.floor(Math.sqrt(n));
  // 2. 遍历顺序:外层完全平方数,内层正序遍历和
  for (let i = 1; i <= m; i++) {
    const curPow = i * i;
    for (let j = curPow; j <= n; j++) {
      dp[j] = Math.min(dp[j], dp[j - curPow] + 1);
    }
  }

  console.log('完全平方数一维DP数组:', dp);
  return dp[n];
}

// 测试用例
console.log('一维优化版最少个数(n=12):', numSquares_1d(12)); // 输出:3

三、完全背包变形2:计数类(组合数/排列数)

计数类是完全背包最易混淆的变形,核心区别是「是否考虑顺序」:

  • 组合数:顺序无关(零钱兑换II)→ 外层物品,内层容量;

  • 排列数:顺序有关(组合总和IV)→ 外层容量,内层物品。

3.1 子变形2.1:组合数(零钱兑换II)

LeetCode链接:leetcode.cn/problems/co…

题目描述

给定硬币数组 coins 和总金额 amount,求凑成总金额的组合数(硬币可重复选)。

示例:coins=[1,2,5], amount=5 → 输出4(5=5/2+2+1/2+1+1+1/1×5)。

表格动态演示

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

定义二维数组dp[i][j]:表示「前i种硬币凑出金额j的组合数」。

3.1.2 步骤2:确定递推公式
  1. 不选第i种硬币dp[i][j] = dp[i-1][j]

  2. 选第i种硬币:j ≥ coins[i-1]时,dp[i][j] += dp[i][j - coins[i-1]]

最终:


if (j < coins[i-1]) {
  dp[i][j] = dp[i-1][j];
} else {
  dp[i][j] = dp[i-1][j] + dp[i][j - coins[i-1]];
}
3.1.3 步骤3:dp数组如何初始化
  1. j=0(凑0):所有i的dp[i][0] = 1(不选任何硬币是唯一方式);

  2. i=0(无硬币):j>0时dp[0][j] = 0(无硬币无法凑金额)。

3.1.4 步骤4:确定遍历顺序

先遍历硬币(i从1到len),再遍历金额(j从1到amount),逐行填充。

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

coins=[1,2,5], amount=5为例,最终dp[3][5] = 4,与预期一致。

3.1.6 二维DP完整代码

/**
 * 零钱兑换II(二维DP解法)
 * @param {number} amount - 总金额
 * @param {number[]} coins - 硬币数组
 * @returns {number} - 组合数
 */
function change_2d(amount, coins) {
  const len = coins.length;
  // 1. 初始化二维dp数组:dp[i][j]前i种硬币凑金额j的组合数
  const dp = new Array(len + 1).fill(0).map(() => new Array(amount + 1).fill(0));

  // 2. 边界条件:凑0有1种方式
  for (let i = 0; i <= len; i++) {
    dp[i][0] = 1;
  }

  // 3. 遍历顺序:先遍历硬币,再遍历金额
  for (let i = 1; i <= len; i++) {
    const curCoin = coins[i - 1];
    for (let j = 1; j <= amount; j++) {
      // 4. 递推公式
      if (j < curCoin) {
        dp[i][j] = dp[i - 1][j];
      } else {
        dp[i][j] = dp[i - 1][j] + dp[i][j - curCoin];
      }
    }
  }

  // 打印dp数组验证
  console.log('零钱兑换II二维DP数组:');
  for (let i = 0; i <= len; i++) {
    console.log(dp[i].join('\t'));
  }

  return dp[len][amount];
}

// 测试用例
console.log('组合数(amount=5):', change_2d(5, [1,2,5])); // 输出:4
console.log('组合数(amount=3):', change_2d(3, [2])); // 输出:0
3.1.7 一维DP优化

/**
 * 零钱兑换II(一维DP优化版)
 * @param {number} amount - 总金额
 * @param {number[]} coins - 硬币数组
 * @returns {number} - 组合数
 */
function change_1d(amount, coins) {
  // 1. 初始化一维dp数组:dp[j]凑金额j的组合数
  const dp = new Array(amount + 1).fill(0);
  dp[0] = 1; // 边界:凑0有1种方式

  // 2. 遍历顺序:外层硬币(保证顺序无关),内层正序遍历金额
  for (const curCoin of coins) {
    for (let j = curCoin; j <= amount; j++) {
      dp[j] += dp[j - curCoin];
    }
  }

  console.log('零钱兑换II一维DP数组:', dp);
  return dp[amount];
}

// 测试用例
console.log('一维优化版组合数:', change_1d(5, [1,2,5])); // 输出:4

3.2 子变形2.2:排列数(组合总和IV)

LeetCode链接:leetcode.cn/problems/co…

题目描述

给定数组 nums 和目标 target,求总和为 target 的排列数(元素可重复选)。

示例:nums=[1,2], target=3 → 输出3(1+1+1/1+2/2+1)。

表格动态演示

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

定义二维数组dp[j][k]:表示「凑金额j,最后一步选nums[k]的排列数」,总排列数total[j] = sum(dp[j][k])

3.2.2 步骤2:确定递推公式

对于金额j、数字nums[k]:

  • j < nums[k]:dp[j][k] = 0(金额不足);

  • j ≥ nums[k]:dp[j][k] = total[j - nums[k]](最后一步选nums[k],前面凑j-nums[k]的总排列数)。

3.2.3 步骤3:dp数组如何初始化
  1. j=0(凑0)total[0] = 1(不选任何数),dp[0][k] = 0

  2. 其余total[j]初始化为0,dp[j][k]初始化为0。

3.2.4 步骤4:确定遍历顺序

先遍历金额(j从1到target),再遍历数字(k从0到len-1),逐列填充。

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

nums=[1,2], target=3为例,最终total[3] = 3,与预期一致。

3.2.6 二维思路完整代码

/**
 * 组合总和IV(二维思路版)
 * @param {number[]} nums - 可选数组
 * @param {number} target - 目标和
 * @returns {number} - 排列数
 */
function combinationSum4_2d(nums, target) {
  const len = nums.length;
  // 1. 初始化dp表格:dp[j][k]凑j,最后一步选nums[k]的排列数
  const dp = new Array(target + 1).fill(0).map(() => new Array(len).fill(0));
  // 2. 总排列数数组:total[j]凑j的总排列数
  const total = new Array(target + 1).fill(0);
  total[0] = 1; // 边界:凑0有1种方式

  // 3. 遍历顺序:外层金额,内层数字(保证顺序有关)
  for (let j = 1; j <= target; j++) {
    for (let k = 0; k < len; k++) {
      const num = nums[k];
      // 4. 递推公式
      if (j >= num) {
        dp[j][k] = total[j - num];
      } else {
        dp[j][k] = 0;
      }
    }
    // 总排列数 = 所有最后一步的情况求和
    total[j] = dp[j].reduce((sum, val) => sum + val, 0);
  }

  // 打印验证
  console.log('组合总和IV dp表格:');
  for (let j = 0; j <= target; j++) {
    console.log(`j=${j}: `, dp[j].join('\t'), '→ 总排列数:', total[j]);
  }

  return total[target];
}

// 测试用例
console.log('排列数(target=3):', combinationSum4_2d([1,2], 3)); // 输出:3
console.log('排列数(target=4):', combinationSum4_2d([1,2,3], 4)); // 输出:7
3.2.7 一维DP优化

/**
 * 组合总和IV(一维DP优化版)
 * @param {number[]} nums - 可选数组
 * @param {number} target - 目标和
 * @returns {number} - 排列数
 */
function combinationSum4_1d(nums, target) {
  // 1. 初始化一维dp数组:dp[j]凑j的排列数
  const dp = new Array(target + 1).fill(0);
  dp[0] = 1; // 边界:凑0有1种方式

  // 2. 遍历顺序:外层金额(保证顺序有关),内层数字
  for (let j = 1; j <= target; j++) {
    let count = 0;
    for (const num of nums) {
      if (j >= num) {
        count += dp[j - num];
      }
    }
    dp[j] = count;
  }

  console.log('组合总和IV一维DP数组:', dp);
  return dp[target];
}

// 测试用例
console.log('一维优化版排列数:', combinationSum4_1d([1,2], 3)); // 输出:3

四、完全背包变形3:进阶类(单词拆分)

LeetCode链接:leetcode.cn/problems/wo…

题目描述

判断字符串 s 能否被字典 wordDict 中的单词拼接(单词可重复用)。

示例:s="leetcode", wordDict=["leet","code"] → 输出true;s="catsandog", wordDict=["cats","dog","sand","and","cat"] → 输出false。

表格动态演示

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

定义二维数组dp[j][k]:表示「前j个字符,最后一步拼接wordDict[k]是否可行」,总结果canBreak[j] = any(dp[j][k])(只要有一个k可行则为true)。

4.2 步骤2:确定递推公式

对于前j个字符、单词wordDict[k](长度len):

  • j < len:dp[j][k] = false(长度不足);

  • j ≥ len:dp[j][k] = canBreak[j - len] && (s.slice(j-len,j) === wordDict[k])(前面可拆分 + 子串匹配)。

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

  1. j=0(空字符串)canBreak[0] = true(空字符串可拆分),dp[0][k] = false

  2. 其余canBreak[j]初始化为false,dp[j][k]初始化为false。

4.4 步骤4:确定遍历顺序

先遍历字符长度(j从1到len(s)),再遍历单词(k从0到len(wordDict)-1),逐列填充。

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

s="leetcode", wordDict=["leet","code"]为例,最终canBreak[8] = true,与预期一致。

4.6 二维思路完整代码


/**
 * 单词拆分(二维思路版)
 * @param {string} s - 待拆分字符串
 * @param {string[]} wordDict - 单词字典
 * @returns {boolean} - 是否可拆分
 */
function wordBreak_2d(s, wordDict) {
  const targetLen = s.length;
  const len = wordDict.length;
  // 1. 初始化dp表格:dp[j][k]前j个字符,最后一步拼接wordDict[k]是否可行
  const dp = new Array(targetLen + 1).fill(0).map(() => new Array(len).fill(false));
  // 2. 总结果数组:canBreak[j]前j个字符是否可拆分
  const canBreak = new Array(targetLen + 1).fill(false);
  canBreak[0] = true; // 边界:空字符串可拆分

  // 3. 遍历顺序:外层字符长度,内层单词
  for (let j = 1; j <= targetLen; j++) {
    for (let k = 0; k < len; k++) {
      const word = wordDict[k];
      const wordLen = word.length;
      // 4. 递推公式
      if (j >= wordLen) {
        const subStr = s.slice(j - wordLen, j);
        dp[j][k] = canBreak[j - wordLen] && (subStr === word);
      } else {
        dp[j][k] = false;
      }
    }
    // 只要有一个单词可行,前j个字符就可拆分
    canBreak[j] = dp[j].some(val => val === true);
  }

  // 打印验证
  console.log('单词拆分dp表格:');
  for (let j = 0; j <= targetLen; j++) {
    console.log(`j=${j}: `, dp[j].join('\t'), '→ 是否可拆分:', canBreak[j]);
  }

  return canBreak[targetLen];
}

// 测试用例
console.log('是否可拆分(leetcode):', wordBreak_2d("leetcode", ["leet","code"])); // 输出:true
console.log('是否可拆分(catsandog):', wordBreak_2d("catsandog", ["cats","dog","sand","and","cat"])); // 输出:false

4.7 一维DP优化


/**
 * 单词拆分(一维DP优化版)
 * @param {string} s - 待拆分字符串
 * @param {string[]} wordDict - 单词字典
 * @returns {boolean} - 是否可拆分
 */
function wordBreak_1d(s, wordDict) {
  const targetLen = s.length;
  // 1. 初始化一维dp数组:dp[j]前j个字符是否可拆分
  const dp = new Array(targetLen + 1).fill(false);
  dp[0] = true; // 边界:空字符串可拆分

  // 2. 遍历顺序:外层字符长度,内层单词
  for (let j = 1; j <= targetLen; j++) {
    const curStr = s.slice(0, j);
    let can = false;
    for (const word of wordDict) {
      const wordLen = word.length;
      if (wordLen > j) continue;
      // 3. 递推公式:后缀匹配 + 前面可拆分
      can = can || (dp[j - wordLen] && curStr.endsWith(word));
      if (can) break; // 提前终止
    }
    dp[j] = can;
  }

  console.log('单词拆分一维DP数组:', dp);
  return dp[targetLen];
}

// 测试用例
console.log('一维优化版是否可拆分:', wordBreak_1d("leetcode", ["leet","code"])); // 输出:true

五、多重背包(数量限制版背包)

多重背包是背包问题的核心变种,区别于完全背包(物品无限选)和01背包(物品选1次),每个物品有明确的数量限制(最多选m[i]次),是实战中最常用的背包类型之一。

5.1 核心定义

  • 问题原型:有N种物品和一个容量为V的背包。第i种物品最多有Mi件可用,每件耗费的空间是Ci,价值是Wi。求解将哪些物品装入背包,可使耗费的空间总和不超过背包容量,且价值总和最大。

  • 核心差异:物品可选次数从「无限」(完全背包)/「1次」(01背包)变为「有限次」(Mi次)。

状态转移思路详细解析

要想清楚多重背包的状态转移,核心是先锚定「01背包」和「完全背包」的基础逻辑,再针对「数量限制」做适配,步骤如下:

1. 先回顾基础:01背包与完全背包的状态转移逻辑

无论是哪种背包,核心都是解决「选或不选当前物品」的决策问题,状态定义均围绕「前i个物品+容量j」展开(二维DP):

  • 01背包(选1次):状态转移为 dp[i][j] = max(dp[i-1][j], dp[i-1][j-Ci] + Wi)。核心逻辑:选当前物品时,只能从「前i-1个物品+剩余容量j-Ci」的状态转移(因为每个物品只能选1次,不能重复用当前物品的状态)。

  • 完全背包(无限选):状态转移为 dp[i][j] = max(dp[i-1][j], dp[i][j-Ci] + Wi)。核心逻辑:选当前物品时,可从「前i个物品+剩余容量j-Ci」的状态转移(因为物品能无限选,当前物品的状态可重复利用)。

2. 多重背包的核心矛盾:如何限制「可选次数≤Mi」

多重背包的关键是「不能无限选(排除完全背包逻辑),也不能只选1次(排除01背包逻辑)」,需要精准控制可选次数为「0~Mi」次。那怎么把「次数限制」融入状态转移呢?

思路拆解:对于第i个物品,我们可以把它拆成「0个选、1个选、2个选、...、Mi个选」这Mi+1种情况,然后在这些情况中选最大值。

3. 推导多重背包的状态转移公式

基于上述拆解,结合二维DP的核心定义(dp[i][j]:前i个物品、容量j的最大价值),推导过程如下:

  1. 基础情况(不选当前物品):如果不选第i个物品,价值直接继承前i-1个物品的状态,即 dp[i][j] = dp[i-1][j]

  2. 可选情况(选k个当前物品,1≤k≤Mi):选k个第i个物品的前提是「kCi ≤ j」(容量足够装k个),此时价值为「前i-1个物品在剩余容量j-kCi的价值 + k个物品的总价值」,即 dp[i-1][j - k*Ci] + k*Wi

  3. 取最大值dp[i][j] 是「不选」和「选1~Mi个」所有情况中的最大值,最终状态转移公式为: dp[i][j] = max(dp[i-1][j], dp[i-1][j - Ci] + Wi, dp[i-1][j - 2*Ci] + 2*Wi, ..., dp[i-1][j - k*Ci] + k*Wi)(k≤Mi且k*Ci≤j)

4. 关键思考:为什么不能直接复用完全背包的「正序容量」逻辑?

完全背包用「正序遍历容量」实现无限选,本质是让当前物品的状态可以重复叠加(比如选了1个后,再选1个时复用已选1个的状态)。但多重背包有Mi的次数限制,若用正序遍历,会导致「选的次数超过Mi」(比如Mi=2,却可能选到3个),违背题意。

因此,多重背包必须复用01背包的「倒序遍历容量」逻辑(一维DP优化时),确保每个物品的状态只能从「前i-1个物品」的状态转移,避免重复选超次数。

5. 总结:状态转移的核心逻辑链

01背包(1次)→ 完全背包(无限次)→ 多重背包(有限次),本质是「可选次数」的逐步限制,状态转移的思考逻辑可归纳为:「是否选当前物品」→ 「选几个当前物品」→ 「在次数限制内选最大值」

5.2 二维DP实现(直观版)


/**
 * 多重背包问题(二维DP版本)
 * 核心功能:计算给定容量的背包,在物品数量限制下能装的最大价值
 * @param {number[]} weightArr - 物品重量数组(每个元素对应一个物品的重量)
 * @param {number[]} valueArr - 物品价值数组(每个元素对应一个物品的价值)
 * @param {number[]} limitArr - 物品数量限制数组(每个元素对应一个物品最多能选的数量)
 * @param {number} capacity - 背包的最大容量
 * @returns {number} - 背包能装的最大价值
 */
function multiKnapsack2D(weightArr, valueArr, limitArr, capacity) {
  // 获取物品的总数量(数组长度)
  const itemCount = weightArr.length;

  /**
   * DP二维数组定义(核心)
   * dp[i][j] 表示:考虑前i个物品时,容量为j的背包能装的最大价值
   * 初始化:
   * - 第一行(i=0):前0个物品(无物品),所有容量的价值都是0
   * - 第一列(j=0):容量为0的背包,无论选多少物品,价值都是0
   */
  const dp = new Array(itemCount + 1).fill(0).map(() => new Array(capacity + 1).fill(0));

  // 遍历每个物品(i从1到itemCount,对应第i个物品)
  for (let i = 1; i <= itemCount; i++) {
    // 注意:数组索引从0开始,所以第i个物品对应数组的i-1位置(★易错点1★)
    const currentWeight = weightArr[i - 1]; // 当前物品的重量
    const currentValue = valueArr[i - 1]; // 当前物品的价值
    const currentLimit = limitArr[i - 1]; // 当前物品最多能选的数量

    // 遍历背包的所有容量(j从1到capacity)
    for (let j = 1; j <= capacity; j++) {
      // 情况1:当前容量j < 当前物品重量 → 装不下当前物品,价值继承上一个物品的结果
      if (j < currentWeight) {
        dp[i][j] = dp[i - 1][j];
        continue; // 跳过后续计算
      }

      /**
       * 情况2:当前容量能装下当前物品 → 计算选0~currentLimit个当前物品的最大价值
       * 初始值:选0个当前物品 → 价值 = 前i-1个物品在容量j时的最大价值
       */
      let maxValue = dp[i - 1][j];

      /**
       * 遍历当前物品的可选数量(k从1到currentLimit)
       * ★易错点2★:必须加 curWeight * k <= j 的判断
       * 原因:如果k太大,curWeight*k超过当前容量j,会导致 j - curWeight*k 为负数,访问dp数组越界
       */
      for (let k = 1; k <= currentLimit && currentWeight * k <= j; k++) {
        // 选k个当前物品的总价值 = 剩余容量的最大价值 + k个当前物品的价值
        // 剩余容量:j - currentWeight * k(装了k个当前物品后剩下的容量)
        // 剩余容量的价值:dp[i-1][j - currentWeight*k](前i-1个物品在剩余容量下的最大价值)
        // ★易错点3★:必须加 currentValue * k(容易漏加,导致价值计算错误)
        const selectKValue = dp[i - 1][j - currentWeight * k] + currentValue * k;

        // 更新最大价值(选0个 vs 选k个)
        maxValue = Math.max(maxValue, selectKValue);
      }

      // 将计算出的最大价值赋值给dp[i][j]
      dp[i][j] = maxValue;
    }
  }

  // 最终结果:考虑所有物品、容量为capacity时的最大价值
  return dp[itemCount][capacity];
}

// ------------------- 测试用例(验证代码正确性) -------------------
// 物品1:重量2,价值3,最多选2个;物品2:重量3,价值4,最多选1个;背包容量7
const weight = [2, 3];
const value = [3, 4];
const limit = [2, 1];
const capacity = 7;

// 预期结果:3*2 + 4*1 = 10(选2个物品1 + 1个物品2)
console.log(multiKnapsack2D(weight, value, limit, capacity)); // 输出 10

5.3 一维DP优化(空间压缩版)


/**
 * 多重背包问题(一维DP优化版)
 * 核心功能:在物品数量限制下,计算背包能装的最大价值
 * 一维优化核心:通过"容量倒序遍历"省略"前i个物品"维度,空间复杂度从O(n*w)降为O(w)
 * @param {number[]} weightArr - 物品重量数组
 * @param {number[]} valueArr - 物品价值数组
 * @param {number[]} limitArr - 物品数量限制数组(每个物品最多选几个)
 * @param {number} capacity - 背包最大容量
 * @returns {number} - 背包能装的最大价值
 */
function multiKnapsack1D(weightArr, valueArr, limitArr, capacity) {
  const itemCount = weightArr.length;

  /**
   * DP一维数组定义(核心)
   * dp[j] 表示:容量为j的背包能装的最大价值(无需区分"前i个物品",靠遍历顺序控制)
   */
  const dp = new Array(capacity + 1).fill(0);

  // 遍历每个物品(i从1到itemCount,和二维版保持一致的索引逻辑)
  for (let i = 1; i <= itemCount; i++) {
    // ★易错点1★:数组索引从0开始,第i个物品对应数组i-1位置
    const currentWeight = weightArr[i - 1]; // 当前物品重量
    const currentValue = valueArr[i - 1]; // 当前物品价值
    const currentLimit = limitArr[i - 1]; // 当前物品数量限制

    /**
     * ★易错点2(致命)★:容量必须倒序遍历(从capacity到currentWeight)
     * 为什么倒序?→ 避免同一物品被重复选择(和01背包逻辑一致)
     * 如果正序遍历 → 变成"完全背包"(物品可无限选),违背多重背包的数量限制
     * 为什么到currentWeight为止?→ j < currentWeight时装不下当前物品,无需计算
     */
    for (let j = capacity; j >= currentWeight; j--) {
      /**
       * 精简优化:无需定义maxValue中间变量,直接更新dp[j]
       */
      for (let k = 1; k <= currentLimit && currentWeight * k <= j; k++) {
        // ★易错点3★:必须加 currentValue * k(漏加会导致价值计算错误)
        // ★易错点4★:必须加 currentWeight * k <= j(避免j - currentWeight*k 为负数,数组越界)
        const selectKValue = dp[j - currentWeight * k] + currentValue * k;
        // 直接取"不选当前物品(dp[j])"和"选k个当前物品(selectKValue)"的最大值
        dp[j] = Math.max(dp[j], selectKValue);
      }
    }
  }

  // 最终结果:容量为capacity时的最大价值
  return dp[capacity];
}

// ------------------- 验证测试(确保结果正确) -------------------
const weight1D = [2, 3]; // 物品1重量2,物品2重量3
const value1D = [3, 4]; // 物品1价值3,物品2价值4
const limit1D = [2, 1]; // 物品1最多选2个,物品2最多选1个
const capacity1D = 7; // 背包容量7

console.log(multiKnapsack1D(weight1D, value1D, limit1D, capacity1D)); // 输出 10(正确结果)

5.4 实战例题:洛谷 P1802 五倍经验日

题目描述

n 个对手,你可以选择挑战或不挑战每个对手:

  • 挑战第 i 个对手但打输:无任何消耗,获得 lose[i] 点经验;

  • 挑战第 i 个对手并打赢:需要消耗 k 瓶药水(1 ≤ k ≤ limit[i]limit[i] 是打第 i 个对手最多能使用的药水数),获得 win[i] 点经验;

你总共有 m 瓶药水,求最终能获得的最大经验值。

状态转移思路详细拆解

这道题是多重背包的典型实战变种,核心是把「对手」当作「有数量限制的物品」、「药水」当作「背包容量」、「经验」当作「物品价值」,状态转移的核心是解决「打输/打赢」的二元决策,具体思路分4步:

1. 先明确DP数组的定义(锚定核心变量)

无论是二维还是一维DP,核心变量都是「对手数量」和「药水数量」,定义需紧扣“资源消耗”和“收益”的对应关系:

  • 二维DP定义(dp[i][j]):考虑前i个对手、使用j瓶药水时,能获得的最大经验值。解释:i代表“处理到第几个对手”,j代表“已消耗的药水资源”,数组值是“当前状态的最大收益”,符合多重背包「前i个物品+容量j」的经典定义框架。

  • 一维DP定义(dp[j]):使用j瓶药水时,能获得的最大经验值。解释:通过遍历顺序(先对手、后药水倒序)省略“前i个对手”的维度,本质是空间压缩,核心逻辑和二维完全一致。

2. 拆解每个对手的决策选项(核心矛盾:打输还是打赢)

对于每个对手i,只有两种选择,且两种选择的“资源消耗”和“经验收益”完全不同,这是状态转移的关键分支:

  1. 选项1:打输当前对手
  • 资源消耗:0瓶药水(无成本);
  • 经验收益:lose[i](固定收益);
  • 状态转移来源:因为没消耗药水,收益直接继承「前i-1个对手使用j瓶药水的最大经验」,即 dp[i-1][j] + lose[i](二维)或 dp[j] + lose[i](一维)。
  1. 选项2:打赢当前对手
  • 资源消耗:1瓶药水(核心优化点!为什么不是k瓶?因为打赢的收益win[i]固定,消耗k瓶药水不如消耗1瓶划算——多消耗药水不会增加经验,反而浪费资源,所以最优解一定是“用最少的药水打赢”,即k=1);
  • 经验收益:win[i](比打输收益更高);
  • 状态转移来源:消耗了1瓶药水,所以需要从「前i-1个对手使用j-1瓶药水的最大经验」转移,即 dp[i-1][j-1] + win[i](二维)或 dp[j-1] + win[i](一维)。
3. 确定边界条件(避免逻辑漏洞)

有两种情况无法选择“打赢”,只能被迫选“打输”,这是状态转移的边界:

  • j=0(无药水可用):无法打赢任何对手,只能打输,收益直接叠加lose[i];

  • limit[i]=0(当前对手无法打赢,题目隐含条件):只能打输,收益叠加lose[i]。

4. 状态转移公式的最终推导

结合上述分析,状态转移的核心是“在两种选项中选最大经验值”,公式如下:

  • 二维DP:dp[i][j] = max(打输的收益, 打赢的收益) = max(dp[i-1][j] + lose[i], dp[i-1][j-1] + win[i])(j>0且limit[i]>0时);

  • 一维DP:dp[j] = max(dp[j] + lose[i], dp[j-1] + win[i])(j>0且limit[i]>0时);

  • 边界情况(j=0或limit[i]=0):dp[i][j] = dp[i-1][j] + lose[i](二维)或 dp[j] += lose[i](一维)。

关键思考:为什么这道题是多重背包?

可能有同学疑惑“没遍历k个可选数量啊”,其实是因为这道题的“数量限制”被简化了——每个对手的“可选次数”是1(要么打、要么不打),但“打赢需要消耗1~limit[i]瓶药水”本质是「物品的数量限制」(最多用limit[i]瓶药水打这个对手,但最优解只需要1瓶)。核心逻辑和多重背包一致:「资源(药水)有限 + 每个物品(对手)有使用限制 + 求最大收益(经验)」。

二维DP实现(实战版)

function d2(enemyCount, medicineCount, lose, win, limit) {
  // dp[i][j] 表示:考虑前i个对手、使用j瓶药水时能获取的最大经验值
  let dp = new Array(enemyCount + 1).fill(0).map(() => new Array(medicineCount + 1).fill(0));

  for (let i = 1; i <= enemyCount; i++) {
    const curLose = lose[i - 1]; // 当前对手打输的经验
    const curWin = win[i - 1]; // 当前对手打赢的经验
    const curLimit = limit[i - 1]; // 当前对手最多能用的药水数(打赢的前提)
    for (let j = 0; j <= medicineCount; j++) {
      // 边界条件:0瓶药水 或 无法打赢当前对手(curLimit=0)→ 只能打输
      if (j === 0 || curLimit === 0) {
        dp[i][j] = dp[i - 1][j] + curLose;
        continue;
      }
      // 状态转移:取「打输当前对手」和「用1瓶药水打赢当前对手」的最大值
      // 打输:前i-1个对手用j瓶药水的经验 + 当前打输的经验
      // 打赢:前i-1个对手用j-1瓶药水的经验 + 当前打赢的经验(优先用1瓶药水,最优选择)
      dp[i][j] = Math.max(dp[i - 1][j] + curLose, dp[i - 1][j - 1] + curWin);
    }
  }
  return dp[enemyCount][medicineCount];
}

// 测试用例验证
const enemyCount = 2;
const medicineCount = 3;
const lose = [10, 20];
const win = [20, 50];
const limit = [2, 1];
console.log(d2(enemyCount, medicineCount, lose, win, limit)); // 输出 70(完全正确)
一维DP优化(实战版)

/**
 * 五倍经验日(多重背包一维DP优化版)
 * @param {number} enemyCount - 对手总数
 * @param {number} medicineCount - 药水总数(背包容量)
 * @param {number[]} lose - 每个对手打输获得的经验数组
 * @param {number[]} win - 每个对手打赢获得的经验数组
 * @param {number[]} limit - 每个对手打赢最多能使用的药水数数组
 * @returns {number} - 使用所有药水时能获得的最大经验值
 */
function d1(enemyCount, medicineCount, lose, win, limit) {
  // 1. 一维DP数组定义:
  // dp[j] 表示「使用j瓶药水时能获取的最大经验值」
  // 省略二维版本中「前i个对手」的维度,通过倒序遍历实现状态压缩
  let dp = new Array(medicineCount + 1).fill(0);

  // 2. 遍历每个对手(i从1到enemyCount,对应第i个对手)
  for (let i = 1; i <= enemyCount; i++) {
    // 当前对手的核心属性(数组索引从0开始,所以取i-1)
    const curLose = lose[i - 1]; // 当前对手打输能获得的经验
    const curWin = win[i - 1]; // 当前对手打赢能获得的经验
    const curLimit = limit[i - 1]; // 当前对手打赢最多能使用的药水数(0表示无法打赢)

    // 3. 倒序遍历药水容量(核心!一维背包的关键优化)
    // 倒序原因:避免重复使用同一对手的药水(保证每个对手只被选一次,符合多重背包逻辑)
    // 遍历范围:j从最大药水数到0,覆盖所有可能的药水使用量
    for (let j = medicineCount; j >= 0; j--) {
      // 4. 合并边界条件判断(两种情况都只能打输):
      // - j === 0:无药水可用,必然打输
      // - curLimit === 0:当前对手无法打赢(最多可用药水数为0),只能打输
      if (j === 0 || curLimit === 0) {
        // 打输的经验计算:原有经验(前i-1个对手j瓶药水的经验) + 当前对手打输的经验
        dp[j] += curLose;
        continue;
      }
      // 5. 状态转移(有药水且能打赢时,选「打输」或「打赢」的最大值):
      // 选项1(打输):原有经验 + 当前对手打输的经验
      // 选项2(打赢):前i-1个对手用j-1瓶药水的经验 + 当前对手打赢的经验(优先用1瓶药水,最优选择)
      dp[j] = Math.max(
        dp[j] + curLose, // 打输的总经验
        dp[j - 1] + curWin // 打赢的总经验(消耗1瓶药水)
      );
    }
  }

  // 6. 返回最终结果:使用全部药水(medicineCount瓶)时的最大经验值
  return dp[medicineCount];
}

// ------------------- 测试用例(验证代码正确性) -------------------
const enemyCount1D = 2; // 对手数量
const medicineCount1D = 3; // 药水总数
const lose1D = [10, 20]; // 打输每个对手的经验
const win1D = [20, 50]; // 打赢每个对手的经验
const limit1D = [2, 1]; // 每个对手最多能用的药水数
console.log(d1(enemyCount1D, medicineCount1D, lose1D, win1D, limit1D)); // 输出 70(正确结果)
测试示例与解释

// 输入参数
n = 2    // 对手数量
m = 3    // 总药水数
lose = [10, 20]  // 打输每个对手的经验
win = [20, 50]   // 打赢每个对手的经验
limit = [2, 1]   // 每个对手最多使用的药水数

输出70

核心解释

  • 对手1使用2瓶药水打赢(得20经验,消耗2瓶);

  • 对手2使用1瓶药水打赢(得50经验,消耗1瓶);

  • 总药水消耗3瓶,总经验 20 + 50 = 70(是所有选择中最大的)。

六、核心总结

| 题型 | 核心链接/场景 | 二维DP核心意义 | 一维遍历顺序 | 状态转移核心 | | --- | --- | --- | --- | --- | --- | | 纯完全背包 | 无(经典原型) | 直观体现「重复选」的状态转移 | 外层物品+正序容量 | max(不选, 选) | | 完全平方数 | <leetcode.cn/problems/pe… | 理解「最值类」初始化逻辑 | 外层物品+正序容量 | min(不选, 选+1) | | 零钱兑换II | <leetcode.cn/problems/co… | 理解「组合数」的状态继承 | 外层物品+正序容量 | 不选 + 选 | | 组合总和IV | <leetcode.cn/problems/co… | 理解「最后一步」的表格拆分 | 外层容量+内层物品 | 累加所有「最后一步」的可能 | | 单词拆分 | <leetcode.cn/problems/wo… | 理解「字符串匹配+DP」的结合 | 外层容量+内层物品 | 或运算(存在一种即可) | | 纯多重背包 | 洛谷P1776 宝物筛选 | LeetCode相似题:<leetcode.cn/problems/on… | 直观体现「数量限制」的状态转移 | 外层物品+倒序容量 | max(不选, 选k个) | | 多重背包实战 | 洛谷P1802 五倍经验日 | LeetCode相似题:<leetcode.cn/problems/be… | 理解「资源消耗+收益」的决策 | 外层物品+倒序容量 | max(不选/低收益, 选/高收益) |



七、学习建议

通用学习建议

  1. 先二维,后一维:二维数组能直观体现状态转移逻辑(清晰看到「前i个物品」「容量j」的关联),一维优化是空间压缩的技巧,不要跳过二维直接学一维,否则容易陷入「背代码」而不懂原理的误区;

  2. 手动填小表格:对易混淆的遍历顺序、状态转移(比如多重背包的k次选择、完全背包的无限选),用小例子(如2个物品、容量5)手动填充DP表格,快速理解「为什么选这个状态转移方向」;

  3. 抓「最后一步」核心:所有背包问题(包括多重背包)的递推公式,本质都是「最后一步选什么」——多重背包的最后一步就是「选0~Mi个第i个物品」,完全背包是「选任意个第i个物品」,01背包是「选或不选第i个物品」;

  4. 区分遍历顺序的本质:物品外层遍历=组合(选物品的顺序无关,如零钱兑换II),容量外层遍历=排列(选物品的顺序有关,如组合总和IV);而多重背包必须「物品外层+容量倒序」,核心是避免同一物品重复选超次数。

多重背包专项学习建议

  1. 先掌握朴素版,再学优化版:先吃透「二维DP+遍历k次可选数量」的朴素实现(5.2节),理解「数量限制」如何融入状态转移,再学习一维优化(5.3节),最后再接触二进制优化、单调队列优化(进阶内容),循序渐进避免混乱;

  2. 明确「k的遍历边界」:朴素版中k的遍历范围是「1≤k≤Mi且kCi≤j」,这两个条件缺一不可——前者限制选的数量不超过题目要求,后者保证容量足够(避免数组越界),可通过小例子(如Mi=2、Ci=3、j=5)验证:k只能取1(31≤5),不能取2(3*2=6>5);

  3. 对比三类背包的核心差异:用表格梳理01、完全、多重背包的关键区别,避免混淆:

背包类型可选次数核心遍历顺序状态转移核心
01背包1次物品外层+容量倒序max(不选, 选1个)
完全背包无限次物品外层+容量正序max(不选, 选任意个)
多重背包有限次(Mi次)物品外层+容量倒序max(不选, 选1~Mi个)
  1. 实战题分类练习:先做「纯多重背包」(如洛谷P1776、LeetCode 322.零钱兑换),再做「多重背包变种」(如洛谷P1802五倍经验日、LeetCode 2218.从栈中取出K个硬币的最大面值和),感受「数量限制」在不同场景下的体现(资源消耗、收益决策等);

  2. 总结易错点,避免踩坑:多重背包的易错点集中在3处,需重点关注:

  • 数组索引混淆:第i个物品对应数组i-1位置(如currentWeight = weightArr[i-1]);
  • 容量遍历方向错误:一维优化时必须倒序,正序会变成完全背包;
  • k的遍历边界遗漏:忘记判断k*Ci≤j,导致数组越界或选超容量。

进阶补充:多重背包的优化方法(朴素版进阶)

朴素版多重背包的时间复杂度是O(NVMi),当Mi较大时(如Mi=1e3、V=1e3),时间会超限,需学习以下优化方法:

  1. 二进制优化
  • 核心思路:将「最多Mi个第i个物品」拆成「若干个二进制组合的物品」(如Mi=5拆成1、2、2个,可组合出0~5的所有数量),把多重背包转化为01背包问题;
  • 优势:时间复杂度优化为O(NVlogMi),适用于Mi较大的场景;
  1. 单调队列优化
  • 核心思路:通过数学变形,将状态转移转化为「滑动窗口的最大值」问题,用单调队列维护窗口内的最大值;
  • 优势:时间复杂度优化为O(N*V),是多重背包的最优解法,适用于V和Mi都较大的场景。

核心总结

完全背包的所有变形、多重背包的核心,本质都是「状态表格的填充规则变化」——不同的「可选次数限制」对应不同的「状态转移方向」,不同的「问题目标」(最值、组合数、可行性)对应不同的「递推公式计算方式」。掌握了「二维表格填充法」和「最后一步拆解思路」,无论题型如何变形,都能快速拆解核心逻辑,写出正确的DP代码!