背包问题与动态规划
从经典背包问题入手,彻底掌握动态规划的核心思维
什么是动态规划
动态规划(Dynamic Programming,DP)是一种通过把复杂问题分解为更小的重叠子问题来求解的算法范式。它的核心思想是:记住已经求解过的子问题答案,避免重复计算。
一个问题能用 DP 求解,必须满足两个条件:
- 最优子结构:问题的最优解可以由子问题的最优解推导出来
- 重叠子问题:同一个子问题会被多次计算(这是 DP 区别于分治法的关键)
DP 有两种实现方式:自顶向下(记忆化搜索,递归 + 缓存)和自底向上(递推填表,迭代)。两者本质相同,只是计算顺序不同。
0-1 背包问题
问题描述:有 N 件物品和一个容量为 W 的背包。第 i 件物品的重量为 wt[i],价值为 val[i]。每件物品只能选或不选(0 或 1),求能装入背包的最大总价值。
DP 思路
定义 dp[i][w] 表示:对于前 i 件物品,背包容量为 w 时能获得的最大价值。对于第 i 件物品,有两种选择:
- 不选:
dp[i][w] = dp[i-1][w] - 选择:
dp[i][w] = dp[i-1][w-wt[i]] + val[i](前提是 w ≥ wt[i])
取两者最大值即可。最终答案在 dp[N][W]。
function knapsack01(wt, val, W) {
const N = wt.length;
const dp = Array(N+1).fill(0).map(() => Array(W+1).fill(0));
for (let i = 1; i <= N; i++) {
for (let w = 0; w <= W; w++) {
if (w < wt[i-1]) {
dp[i][w] = dp[i-1][w]; // 装不下,不选
} else {
dp[i][w] = Math.max(
dp[i-1][w], // 不选
dp[i-1][w-wt[i-1]] + val[i-1] // 选择
);
}
}
}
return dp[N][W];
}
如果用二维表格来可视化,dp[i][w] 的值仅依赖于上一行 dp[i-1][...]。这个性质正是后面空间优化的基础。
完全背包问题
区别:每件物品可以选无限次,而非只能选一次。状态转移方程变为:
dp[i][w] = max(dp[i-1][w], dp[i][w-wt[i]] + val[i])
注意第二个参数是 dp[i][w-wt[i]](当前行),而不是 dp[i-1][w-wt[i]](上一行)。这意味着选择第 i 件物品后,仍然可以再选同一件物品。
function knapsackComplete(wt, val, W) {
const N = wt.length;
const dp = Array(W+1).fill(0);
for (let i = 0; i < N; i++) {
for (let w = wt[i]; w <= W; w++) { // 正序遍历!
dp[w] = Math.max(dp[w], dp[w-wt[i]] + val[i]);
}
}
return dp[W];
}
注意这里使用了一维数组优化(见下文),且内层循环是正序遍历,这是完全背包与 0-1 背包的关键区别。
DP 解题五步法
面对任何 DP 问题,按以下五个步骤思考:
- 定义状态:明确
dp[i]或dp[i][j]代表什么含义。这是最关键的一步,定义准确了后面水到渠成。 - 推导状态转移方程:找出当前状态和之前状态的关系。问自己:要得到当前最优解,可以从哪些子问题转移过来?
- 确定初始值:DP 表格的第一行/第一列(或
dp[0])的值是什么?这些是递推的起点。 - 确定遍历顺序:正序还是倒序?外层循环是物品还是容量?顺序不同,结果可能完全错误。
- 返回结果:答案在 DP 表格的哪个位置?
dp[n]?dp[n][W]?还是需要遍历取最大值?
经典例题:零钱兑换
问题:给定不同面额的硬币 coins 和一个总金额 amount,计算可以凑成总金额的最少硬币个数。如果无法凑出,返回 -1。
这是完全背包的变种——每枚硬币可以用无限次,"物品重量"是面额,"物品价值"统一为 1(每个硬币计 1 个数),目标是求恰好装满时的最小价值。
function coinChange(coins, amount) {
const dp = Array(amount+1).fill(Infinity);
dp[0] = 0;
for (const coin of coins) {
for (let i = coin; i <= amount; i++) {
dp[i] = Math.min(dp[i], dp[i-coin] + 1);
}
}
return dp[amount] === Infinity ? -1 : dp[amount];
}
这里 dp[i] 表示凑出金额 i 所需的最少硬币数。dp[0] = 0 是边界条件——凑 0 元需要 0 个硬币。其余初始化为 Infinity 表示"暂时不可达"。
空间优化技巧
当 dp[i] 只依赖于 dp[i-1] 时,可以用滚动数组把二维压成一维,将空间复杂度从 O(NW) 降到 O(W)。
关键规则:
- 0-1 背包用倒序遍历:
for (w = W; w >= wt[i]; w--)— 保证每件物品只选一次 - 完全背包用正序遍历:
for (w = wt[i]; w <= W; w++)— 允许重复选择
记忆口诀:"零一倒着走,完全正着来"。
常见陷阱
- 遍历顺序搞反:0-1 背包倒序遍历容量,完全背包正序。搞反了答案就对不上
- 初始化错误:求最大值初始化为 0 或 -Infinity,求最小值初始化为 Infinity。想清楚
dp[0]的含义 - 状态定义过于复杂:不是维度越多越好。先尝试最简单的定义,不行再加维度
- 忘记返回值的位置:填完表之后确认答案在
dp[n][W]、dp[amount]还是需要额外计算