用填充表格法-继续吃透完全背包及其变形
动态规划中的「完全背包问题」是算法学习的核心考点之一,其衍生的「计数、最值、布尔判断」等变形题更是频繁出现在面试和算法竞赛中。很多初学者容易被「一维优化」「遍历顺序」等细节绕晕,本文将严格遵循「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开始),有两种核心决策:选或不选。
-
不选第i个物品 :前i个物品的最大价值 = 前i-1个物品的最大价值,即
dp[i][j] = dp[i-1][j]; -
选第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数组如何初始化
初始化核心是确定表格的"边界条件",即无需推导就能直接确定的单元格值:
-
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 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 | 待填 |
1.4 步骤4:确定遍历顺序(表格填充顺序)
完全背包二维解法的遍历顺序与01背包一致,有两种可行方式:
-
先遍历物品(i从1到n),再遍历容量(j从1到C):逐行填充表格,先填完第1个物品对应的所有容量(第1行),再填第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²),有两种决策:选或不选:
-
不选第i个完全平方数 :
dp[i][j] = dp[i-1][j]; -
选第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」:
-
i=0(无完全平方数) :除j=0外,其余
dp[0][j] = Infinity(无数字无法凑出和); -
j=0(凑0) :所有i的
dp[i][0] = 0(凑0需要0个数字); -
其余单元格初始化为
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:确定递推公式
-
不选第i种硬币 :
dp[i][j] = dp[i-1][j]; -
选第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数组如何初始化
-
j=0(凑0) :所有i的
dp[i][0] = 1(不选任何硬币是唯一方式); -
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数组如何初始化
-
j=0(凑0) :
total[0] = 1(不选任何数),dp[0][k] = 0; -
其余
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数组如何初始化
-
j=0(空字符串) :
canBreak[0] = true(空字符串可拆分),dp[0][k] = false; -
其余
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」的结合 | 外层容量+内层物品 | 或运算(存在一种即可) |
学习建议
-
先二维,后一维:二维数组能直观体现状态转移逻辑,一维优化是空间压缩,不要跳过二维直接学一维;
-
手动填小表格:对易混淆的排列/组合数,用小例子手动填充表格,快速理解遍历顺序的影响;
-
抓「最后一步」核心:所有完全背包变形的递推公式,本质都是「最后一步选什么」;
-
区分遍历顺序:物品外层=组合(顺序无关),容量外层=排列(顺序有关)。
完全背包的所有变形本质都是「状态表格的填充规则变化」,掌握了填充表格法,无论题型如何变形,都能快速拆解核心逻辑!
有N种物品和⼀个容量为V 的背包。第i种物品最多有Mi件可⽤,每件耗费的空间是Ci ,价值是Wi 。求解将哪些物品装⼊背包可使这些物品的耗费的空间 总和不超过背包容量,且价值总和最⼤。