动态规划入门
动态规划(Dynamic Programming,DP)是面试中最具区分度的算法题型。它不像排序/二分有现成模板,而是考察"建模能力"——能否把问题拆成子问题、定义状态、写出转移方程。掌握 DP 的标志不是背题,而是看到新题能想出状态定义。
三个核心概念
1. 重叠子问题
递归解法会反复求解相同的子问题。比如斐波那契 fib(5) 会计算 fib(3) 两次、fib(2) 三次。DP 通过记忆化或制表避免重复计算,把指数级降为多项式级。
2. 最优子结构
问题的最优解包含子问题的最优解。比如最短路径 A → C 经过 B,则 A → B 必须是最短路径。这是 DP 成立的前提。
反例:求最长简单路径(不能重复经过节点)不具有最优子结构——子问题的最优可能引入冲突。
3. 无后效性
"未来与过去无关":当前状态确定后,未来决策只依赖当前状态值,不依赖到达该状态的路径。这是状态能被压缩的前提。若不满足,需把路径信息编入状态。
两种实现方式
记忆化搜索(Top-Down,自顶向下)
从原问题出发递归求解,第一次算出的子问题结果存入缓存,后续直接读。
function fibMemo(n: number): number {
const memo = new Map<number, number>();
return helper(n);
function helper(n: number): number {
if (n <= 1) return n;
if (memo.has(n)) return memo.get(n)!;
const r = helper(n - 1) + helper(n - 2);
memo.set(n, r);
return r;
}
}
制表法(Bottom-Up,自底向上)
从最小子问题开始迭代填表,最终得到原问题答案。
function fib(n: number): number {
if (n <= 1) return n;
const dp = new Array(n + 1).fill(0);
dp[1] = 1;
for (let i = 2; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
// 空间优化:只依赖前两个状态
function fibOpt(n: number): number {
if (n <= 1) return n;
let prev = 0, cur = 1;
for (let i = 2; i <= n; i++) {
const next = prev + cur;
prev = cur;
cur = next;
}
return cur;
}
| 维度 | 记忆化 | 制表法 |
|---|---|---|
| 思路 | 自顶向下递归 | 自底向上迭代 |
| 子问题 | 只算用到的 | 可能算多余的 |
| 空间 | 递归栈 O(n) | O(n) 或优化到 O(1) |
| 实现 | 直觉自然 | 需确定计算顺序 |
| 风险 | 递归过深栈溢出 | 通常更快 |
工程上优先用制表法(无栈溢出、常数小)。但思考时往往先用记忆化理清思路,再改成迭代。
DP 解题四步法
- 定义状态:
dp[i]或dp[i][j]表示什么?通常是"考虑前 i 个/到位置 i 时的最优值"。 - 写出转移方程:
dp[i]如何由更小的子问题得到?枚举最后一步的选择。 - 确定初始条件和边界:
dp[0]、dp[1]的值,空数组/越界处理。 - 确定计算顺序:依赖关系决定从大到小还是从小到大,二维时通常从左到右、从上到下。
经典题:爬楼梯(LC 70)
每次能爬 1 或 2 阶,爬到第 n 阶有多少种方法?
- 状态:
dp[i]= 爬到第 i 阶的方法数 - 转移:
dp[i] = dp[i-1] + dp[i-2](最后一步爬 1 或 2) - 初始:
dp[0] = 1, dp[1] = 1
function climbStairs(n: number): number {
if (n <= 2) return n;
let prev = 1, cur = 2;
for (let i = 3; i <= n; i++) {
const next = prev + cur;
prev = cur;
cur = next;
}
return cur;
}
时间 O(n),空间 O(1)。本质就是斐波那契。