动态规划把指数级的重复枚举压成多项式,核心不是背方程,而是先定义出一个可递推的状态。
做优惠券最优组合时,暴力枚举所有搭配在十几张券就会超时,而按面额拆成子问题后一次遍历就能出结果。文本折行、阶梯计价、路径计数也是同一类结构:当前决策只依赖少数几个更小的子问题。
DP 与普通递归的差别是重叠子问题。斐波那契的朴素递归会把 f(3) 算很多遍,复杂度 O(2ⁿ);把结果存下来后每个状态只算一次,降到 O(n)。
DP 与贪心的差别是无后效性要求。贪心每步取局部最优且不回头,零钱兑换里"尽量用大面额"在 [1,3,4] 凑 6 时会得到 4+1+1,而最优是 3+3;DP 保留所有子问题的最优值,所以能得到正确答案。无后效性指当前状态一旦确定,后续决策不再受之前路径影响,这是能写出转移方程的前提。
dp[i] 或 dp[i][j] 的含义,必须包含"前 i 个""以 i 结尾"这类限定。以斐波那契为例,三种写法是同一个状态方程的三种求值顺序。
// 1. 暴力递归:时间 O(2ⁿ),空间 O(n)
function fib(n) {
if (n <= 1) return n;
return fib(n - 1) + fib(n - 2);
}
// 2. 记忆化搜索:时间 O(n),空间 O(n)
function fibMemo(n, memo = new Map()) {
if (n <= 1) return n;
if (memo.has(n)) return memo.get(n);
const res = fibMemo(n - 1, memo) + fibMemo(n - 2, memo);
memo.set(n, res);
return res;
}
// 3. 递推 + 滚动变量:时间 O(n),空间 O(1)
function fibDP(n) {
if (n <= 1) return n;
let prev = 0, cur = 1;
for (let i = 2; i <= n; i++) [prev, cur] = [cur, prev + cur];
return cur;
}
记忆化保留了递归的思考方式,只是加了缓存;递推则要求先想清楚依赖方向。做题时先用记忆化把答案写对,再改写成递推,是最稳的路径。
二维背包 dp[i][j] 表示前 i 个物品、容量 j 的最优值,它只依赖上一行,所以可以压成一维。压维之后,遍历方向决定了 dp[j - w] 读到的是上一行还是本行。
// 01 背包:每件物品最多一次,容量必须倒序
function knapsack01(weights, values, capacity) {
const dp = new Array(capacity + 1).fill(0);
for (let i = 0; i < weights.length; i++) {
for (let j = capacity; j >= weights[i]; j--) {
dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]);
}
}
return dp[capacity];
}
// 完全背包:每件物品无限次,容量正序
function knapsackComplete(weights, values, capacity) {
const dp = new Array(capacity + 1).fill(0);
for (let i = 0; i < weights.length; i++) {
for (let j = weights[i]; j <= capacity; j++) {
dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]);
}
}
return dp[capacity];
}
倒序时 dp[j - w] 还没被本轮更新,等价于上一行的值,物品只会被选一次;正序时它已被本轮更新,等价于同一行,物品可以再选一次。这就是"01 背包倒序、完全背包正序"的全部原因。
| 类型 | 状态定义 | 代表题 |
|---|---|---|
| 线性 DP | 前 i 个元素的最优值 | 爬楼梯、打家劫舍、最大子数组和 |
| 序列 DP | 以 i 结尾的最优值 | 最长递增子序列、最长公共子序列、编辑距离 |
| 区间 DP |
区间 [i, j] 的最优值 |
| 最长回文子串、戳气球、石子合并 |
| 背包 | 容量 j 下的最优值 | 01 背包、完全背包、分割等和子集 |
| 树形 DP | 以节点为根的子树 | 打家劫舍 III、二叉树最大路径和 |
| 状态压缩 | 用二进制位表示集合 | 旅行商问题、状压棋盘 |
很多人以为 DP 就是套公式,实际上状态定义错了一切都错,先把 dp[i] 用一句人话讲清楚再写转移。
很多人以为记忆化和递推是两种算法,实际上是同一个状态方程的两种求值顺序,复杂度完全一致。
很多人以为滚动数组只是省空间,实际上它改变了遍历方向的约束,01 背包写成正序会变成完全背包。
dp[i] 是爬到第 i 阶的方案数,最后一步只能跨 1 阶或 2 阶,所以 dp[i] = dp[i-1] + dp[i-2]。时间 O(n),空间 O(1)。
function climbStairs(n) {
if (n <= 2) return n;
let prev = 1, cur = 2;
for (let i = 3; i <= n; i++) [prev, cur] = [cur, prev + cur];
return cur;
}
dp[i] 是前 i 间房能偷到的最大金额,第 i 间偷则加上 dp[i-2],不偷则继承 dp[i-1]。时间 O(n),空间 O(1)。
function rob(nums) {
let prev = 0, cur = 0; // prev = dp[i-2],cur = dp[i-1]
for (const x of nums) {
[prev, cur] = [cur, Math.max(cur, prev + x)];
}
return cur;
}
dp[i] 是以 nums[i] 结尾的最长递增子序列长度,向前找比它小的状态取最大值,时间 O(n²)。贪心加二分的写法维护 tails:tails[k] 是长度为 k+1 的递增子序列的最小结尾,新元素替换第一个大于等于它的位置,时间 O(n log n)。
// 时间 O(n²),空间 O(n)
function lengthOfLIS(nums) {
const dp = new Array(nums.length).fill(1);
let ans = 1;
for (let i = 0; i < nums.length; i++) {
for (let j = 0; j < i; j++) {
if (nums[j] < nums[i]) dp[i] = Math.max(dp[i], dp[j] + 1);
}
ans = Math.max(ans, dp[i]);
}
return ans;
}
// 贪心 + 二分:时间 O(n log n),空间 O(n)
function lengthOfLISFast(nums) {
const tails = [];
for (const x of nums) {
let left = 0, right = tails.length;
while (left < right) {
const mid = left + ((right - left) >> 1);
if (tails[mid] < x) left = mid + 1;
else right = mid;
}
tails[left] = x;
}
return tails.length;
}
问题等价于"能否从数组中选出若干数凑出 sum/2",dp[j] 表示容量 j 是否可达,容量倒序保证每个数只用一次。时间 O(n·sum),空间 O(sum)。
function canPartition(nums) {
const sum = nums.reduce((a, b) => a + b, 0);
if (sum % 2) return false;
const target = sum / 2;
const dp = new Array(target + 1).fill(false);
dp[0] = true;
for (const x of nums) {
for (let j = target; j >= x; j--) dp[j] = dp[j] || dp[j - x];
}
return dp[target];
}
dp[i][j] 是 word1 前 i 个字符转成 word2 前 j 个字符的最少操作数。字符相同则继承左上角;否则取删除、插入、替换三者最小值加一。时间 O(m·n),空间 O(m·n)。
function minDistance(word1, word2) {
const m = word1.length, n = word2.length;
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
for (let i = 0; i <= m; i++) dp[i][0] = i;
for (let j = 0; j <= n; j++) dp[0][j] = j;
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (word1[i - 1] === word2[j - 1]) dp[i][j] = dp[i - 1][j - 1];
else dp[i][j] = 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
}
}
return dp[m][n];
}