用填充表格法-继续吃透完全背包及其变形
动态规划中的「完全背包问题」是算法学习的核心考点之一,其衍生的「计数、最值、布尔判断」等变形题更是频繁出现在面试和算法竞赛中。很多初学者容易被「一维优化」「遍历顺序」等细节绕晕,本文将严格遵循「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]。
最终递推公式(取两种决策的最大值):
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)
/**
* 纯完全背包原型(二维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²),有两种决策:选或不选:
-
不选第i个完全平方数:
dp[i][j] = dp[i-1][j]; -
选第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」:
-
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)
/**
* 完全平方数(二维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:确定递推公式
-
不选第i种硬币:
dp[i][j] = dp[i-1][j]; -
选第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数组如何初始化
-
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完整代码
/**
* 零钱兑换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数组如何初始化
-
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 二维思路完整代码
/**
* 组合总和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数组如何初始化
-
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 二维思路完整代码
/**
* 单词拆分(二维思路版)
* @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的最大价值),推导过程如下:
-
基础情况(不选当前物品):如果不选第i个物品,价值直接继承前i-1个物品的状态,即
dp[i][j] = dp[i-1][j]。 -
可选情况(选k个当前物品,1≤k≤Mi):选k个第i个物品的前提是「kCi ≤ j」(容量足够装k个),此时价值为「前i-1个物品在剩余容量j-kCi的价值 + k个物品的总价值」,即
dp[i-1][j - k*Ci] + k*Wi。 -
取最大值:
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:打输当前对手:
- 资源消耗:0瓶药水(无成本);
- 经验收益:lose[i](固定收益);
- 状态转移来源:因为没消耗药水,收益直接继承「前i-1个对手使用j瓶药水的最大经验」,即 dp[i-1][j] + lose[i](二维)或 dp[j] + lose[i](一维)。
- 选项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(不选/低收益, 选/高收益) |
七、学习建议
通用学习建议
-
先二维,后一维:二维数组能直观体现状态转移逻辑(清晰看到「前i个物品」「容量j」的关联),一维优化是空间压缩的技巧,不要跳过二维直接学一维,否则容易陷入「背代码」而不懂原理的误区;
-
手动填小表格:对易混淆的遍历顺序、状态转移(比如多重背包的k次选择、完全背包的无限选),用小例子(如2个物品、容量5)手动填充DP表格,快速理解「为什么选这个状态转移方向」;
-
抓「最后一步」核心:所有背包问题(包括多重背包)的递推公式,本质都是「最后一步选什么」——多重背包的最后一步就是「选0~Mi个第i个物品」,完全背包是「选任意个第i个物品」,01背包是「选或不选第i个物品」;
-
区分遍历顺序的本质:物品外层遍历=组合(选物品的顺序无关,如零钱兑换II),容量外层遍历=排列(选物品的顺序有关,如组合总和IV);而多重背包必须「物品外层+容量倒序」,核心是避免同一物品重复选超次数。
多重背包专项学习建议
-
先掌握朴素版,再学优化版:先吃透「二维DP+遍历k次可选数量」的朴素实现(5.2节),理解「数量限制」如何融入状态转移,再学习一维优化(5.3节),最后再接触二进制优化、单调队列优化(进阶内容),循序渐进避免混乱;
-
明确「k的遍历边界」:朴素版中k的遍历范围是「1≤k≤Mi且kCi≤j」,这两个条件缺一不可——前者限制选的数量不超过题目要求,后者保证容量足够(避免数组越界),可通过小例子(如Mi=2、Ci=3、j=5)验证:k只能取1(31≤5),不能取2(3*2=6>5);
-
对比三类背包的核心差异:用表格梳理01、完全、多重背包的关键区别,避免混淆:
| 背包类型 | 可选次数 | 核心遍历顺序 | 状态转移核心 |
|---|---|---|---|
| 01背包 | 1次 | 物品外层+容量倒序 | max(不选, 选1个) |
| 完全背包 | 无限次 | 物品外层+容量正序 | max(不选, 选任意个) |
| 多重背包 | 有限次(Mi次) | 物品外层+容量倒序 | max(不选, 选1~Mi个) |
-
实战题分类练习:先做「纯多重背包」(如洛谷P1776、LeetCode 322.零钱兑换),再做「多重背包变种」(如洛谷P1802五倍经验日、LeetCode 2218.从栈中取出K个硬币的最大面值和),感受「数量限制」在不同场景下的体现(资源消耗、收益决策等);
-
总结易错点,避免踩坑:多重背包的易错点集中在3处,需重点关注:
- 数组索引混淆:第i个物品对应数组i-1位置(如currentWeight = weightArr[i-1]);
- 容量遍历方向错误:一维优化时必须倒序,正序会变成完全背包;
- k的遍历边界遗漏:忘记判断k*Ci≤j,导致数组越界或选超容量。
进阶补充:多重背包的优化方法(朴素版进阶)
朴素版多重背包的时间复杂度是O(NVMi),当Mi较大时(如Mi=1e3、V=1e3),时间会超限,需学习以下优化方法:
- 二进制优化:
- 核心思路:将「最多Mi个第i个物品」拆成「若干个二进制组合的物品」(如Mi=5拆成1、2、2个,可组合出0~5的所有数量),把多重背包转化为01背包问题;
- 优势:时间复杂度优化为O(NVlogMi),适用于Mi较大的场景;
- 单调队列优化:
- 核心思路:通过数学变形,将状态转移转化为「滑动窗口的最大值」问题,用单调队列维护窗口内的最大值;
- 优势:时间复杂度优化为O(N*V),是多重背包的最优解法,适用于V和Mi都较大的场景。
核心总结
完全背包的所有变形、多重背包的核心,本质都是「状态表格的填充规则变化」——不同的「可选次数限制」对应不同的「状态转移方向」,不同的「问题目标」(最值、组合数、可行性)对应不同的「递推公式计算方式」。掌握了「二维表格填充法」和「最后一步拆解思路」,无论题型如何变形,都能快速拆解核心逻辑,写出正确的DP代码!