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

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

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

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

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

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

表格动态演示

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]

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

JavaScript 复制代码
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个物品\背包容量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 待填 待填 待填 待填 待填 待填 待填 待填

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个物品\背包容量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 6 6 9 9 12
2(物品2:w=3,v=4) 0 0 3 4 6 7 9 10 12
3(物品3:w=4,v=5) 0 0 3 4 6 7 9 10 12

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

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

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与二维解法结果一致,验证了优化思路的正确性。

JavaScript 复制代码
/**
 * 纯完全背包原型(一维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)。

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

JavaScript 复制代码
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)

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优化

JavaScript 复制代码
/**
 * 完全平方数(一维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]]

最终:

JavaScript 复制代码
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完整代码
JavaScript 复制代码
/**
 * 零钱兑换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优化
JavaScript 复制代码
/**
 * 零钱兑换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 二维思路完整代码
JavaScript 复制代码
/**
 * 组合总和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优化
JavaScript 复制代码
/**
 * 组合总和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 二维思路完整代码

JavaScript 复制代码
/**
 * 单词拆分(二维思路版)
 * @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优化

JavaScript 复制代码
/**
 * 单词拆分(一维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

五、核心总结

题型 LeetCode链接 二维DP核心意义 一维遍历顺序 状态转移核心
纯完全背包 无(经典原型) 直观体现「重复选」的状态转移 外层物品+正序容量 max(不选, 选)
完全平方数 leetcode.cn/problems/pe... 理解「最值类」初始化逻辑 外层物品+正序容量 min(不选, 选+1)
零钱兑换II leetcode.cn/problems/co... 理解「组合数」的状态继承 外层物品+正序容量 不选 + 选
组合总和IV leetcode.cn/problems/co... 理解「最后一步」的表格拆分 外层容量+内层物品 累加所有「最后一步」的可能
单词拆分 leetcode.cn/problems/wo... 理解「字符串匹配+DP」的结合 外层容量+内层物品 或运算(存在一种即可)

学习建议

  1. 先二维,后一维:二维数组能直观体现状态转移逻辑,一维优化是空间压缩,不要跳过二维直接学一维;

  2. 手动填小表格:对易混淆的排列/组合数,用小例子手动填充表格,快速理解遍历顺序的影响;

  3. 抓「最后一步」核心:所有完全背包变形的递推公式,本质都是「最后一步选什么」;

  4. 区分遍历顺序:物品外层=组合(顺序无关),容量外层=排列(顺序有关)。

完全背包的所有变形本质都是「状态表格的填充规则变化」,掌握了填充表格法,无论题型如何变形,都能快速拆解核心逻辑!

有N种物品和⼀个容量为V 的背包。第i种物品最多有Mi件可⽤,每件耗费的空间是Ci ,价值是Wi 。求解将哪些物品装⼊背包可使这些物品的耗费的空间 总和不超过背包容量,且价值总和最⼤。

相关推荐
共享家95271 小时前
搭建 AI 聊天机器人:”我的人生我做主“
前端·javascript·css·python·pycharm·html·状态模式
疯狂的喵1 小时前
C++编译期多态实现
开发语言·c++·算法
scx201310041 小时前
20260129LCA总结
算法·深度优先·图论
2301_765703141 小时前
C++中的协程编程
开发语言·c++·算法
m0_748708051 小时前
实时数据压缩库
开发语言·c++·算法
小魏每天都学习2 小时前
【算法——c/c++]
c语言·c++·算法
智码未来学堂2 小时前
探秘 C 语言算法之枚举:解锁解题新思路
c语言·数据结构·算法
Halo_tjn2 小时前
基于封装的专项 知识点
java·前端·python·算法
春日见3 小时前
如何避免代码冲突,拉取分支
linux·人工智能·算法·机器学习·自动驾驶
副露のmagic3 小时前
更弱智的算法学习 day59
算法