算法篇 | 动态规划
前言
动态规划(Dynamic Programming,简称 DP)是算法面试中最让人头疼的话题之一。很多人觉得 DP 题目”每道都不一样,根本没法总结规律”。但实际上,DP 有一套非常清晰的解题框架。掌握了这套框架,面对大多数 DP 题目你都能找到突破口。
DP 的核心思想可以用一句话概括:把一个大问题拆解成若干个重叠的子问题,通过存储子问题的解来避免重复计算。
它和递归(分治)的区别在于:递归解决的子问题是独立的(比如归并排序),而 DP 解决的子问题是重叠的——同一个子问题会被反复用到。如果不把子问题的结果存起来,就会产生大量重复计算。
本章我们会从 DP 的两大核心概念讲起(最优子结构 + 重叠子问题),然后给出一套通用的解题五步法,最后通过四道经典题目帮你把这套方法论练到位。
诊断自测
Q1:爬楼梯问题——每次可以爬 1 级或 2 级,到达第 n 级台阶有多少种方法?你能写出状态转移方程吗?
点击查看答案
状态定义:dp[i] 表示到达第 i 级台阶的方法数。
转移方程:dp[i] = dp[i-1] + dp[i-2](从第 i-1 级爬 1 步,或从第 i-2 级爬 2 步)。
初始条件:dp[0] = 1(站在地面,一种方法),dp[1] = 1。
本质就是斐波那契数列。
Q2:什么是”最优子结构”?什么是”重叠子问题”?它们在 DP 中分别起什么作用?
点击查看答案
最优子结构:一个问题的最优解可以由其子问题的最优解推导出来。这是 DP 能够成立的前提——它保证了我们可以用子问题的解来构建原问题的解。
重叠子问题:在求解过程中,同一个子问题会被反复计算。这是 DP 优于暴力递归的原因——通过存储子问题的解(记忆化或建表),避免重复计算。
Q3:自顶向下(记忆化搜索)和自底向上(递推)有什么区别?各有什么优缺点?
点击查看答案
自顶向下:从原问题出发,递归分解为子问题,用 Map 或数组缓存已计算的结果。优点是只计算需要的子问题,代码和递归思路一致;缺点是有递归栈的开销。
自底向上:从最小的子问题开始,逐步构建到原问题的解。优点是没有递归开销,可以做空间优化(滚动数组);缺点是需要想清楚遍历顺序,有时候会计算不需要的子问题。
一、DP 的核心思想
1.1 最优子结构
如果一个问题的最优解包含了其子问题的最优解,就说这个问题具有最优子结构。
举个例子:从 A 到 C 的最短路径经过 B,那么 A→B 的那段路径一定也是 A 到 B 的最短路径。如果不是,我们换一条更短的 A→B 路径,整体路径就会更短——和”已经是最短”矛盾。
1.2 重叠子问题
斐波那契数列 f(n) = f(n-1) + f(n-2) 是最经典的例子。如果用递归计算 f(5):
f(5) = f(4) + f(3)
f(4) = f(3) + f(2)
f(3) = f(2) + f(1)
f(3) 被计算了两次,f(2) 被计算了三次。n 越大,重复计算越严重。暴力递归的时间复杂度是 O(2ⁿ),而用 DP 存储中间结果可以降到 O(n)。
二、解题五步法
拿到一道 DP 题,按下面五步走:
Step 1:定义状态
确定 dp[i](或 dp[i][j])表示什么含义。这是最关键也最难的一步。
技巧:通常状态定义和题目问的东西直接对应。题目问”到第 i 个位置的最小花费”,那 dp[i] 就是”到第 i 个位置的最小花费”。
Step 2:写出状态转移方程
dp[i] 如何由之前的状态推导出来?
技巧:想一想”最后一步”做了什么。比如爬楼梯,到达第 i 级的”最后一步”要么是从第 i-1 级上来(1 步),要么是从第 i-2 级上来(2 步)。
Step 3:确定初始条件
最小的子问题的解是什么?比如 dp[0]、dp[1] 等。
Step 4:确定遍历顺序
计算 dp[i] 时,它依赖的状态(如 dp[i-1]、dp[i-2])必须已经算好了。
Step 5:确定返回值
最终答案是 dp[n]?还是 dp[n-1]?还是 dp 数组中的最大值?
三、经典题目
3.1 爬楼梯(LeetCode 70)
每次可以爬 1 或 2 级台阶,到第 n 级有多少种方法?
五步法:
- 状态:
dp[i]= 到第 i 级台阶的方法数 - 转移方程:
dp[i] = dp[i-1] + dp[i-2] - 初始条件:
dp[0] = 1, dp[1] = 1 - 遍历顺序:从小到大
- 返回值:
dp[n]
function climbStairs(n) {
if (n <= 1) return 1;
const dp = new Array(n + 1);
dp[0] = 1;
dp[1] = 1;
for (let i = 2; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
空间优化:因为 dp[i] 只依赖 dp[i-1] 和 dp[i-2],可以用两个变量代替整个数组:
function climbStairs(n) {
if (n <= 1) return 1;
let prev2 = 1; // dp[i-2]
let prev1 = 1; // dp[i-1]
for (let i = 2; i <= n; i++) {
const curr = prev1 + prev2;
prev2 = prev1;
prev1 = curr;
}
return prev1;
}
复杂度:时间 O(n),空间 O(1)。
3.2 零钱兑换(LeetCode 322)
给定不同面额的硬币
coins和一个总金额amount,计算凑成总金额所需的最少硬币数。如果无法凑成返回 -1。
五步法:
- 状态:
dp[i]= 凑成金额 i 所需的最少硬币数 - 转移方程:
dp[i] = min(dp[i - coin] + 1)for each coin in coins - 初始条件:
dp[0] = 0(凑成 0 元需要 0 枚硬币),其余初始化为 Infinity - 遍历顺序:从小到大
- 返回值:
dp[amount],如果仍为 Infinity 则返回 -1
function coinChange(coins, amount) {
const dp = new Array(amount + 1).fill(Infinity);
dp[0] = 0;
for (let i = 1; i <= amount; i++) {
for (const coin of coins) {
if (i - coin >= 0 && dp[i - coin] !== Infinity) {
dp[i] = Math.min(dp[i], dp[i - coin] + 1);
}
}
}
return dp[amount] === Infinity ? -1 : dp[amount];
}
理解转移方程:凑金额 i 的”最后一步”是放入一枚面额为 coin 的硬币,那么之前需要凑 i - coin 的金额。在所有可能的”最后一步”中取最小值。
复杂度:时间 O(amount * coins.length),空间 O(amount)。
3.3 最长递增子序列(LeetCode 300)
给定一个整数数组
nums,找到其中最长严格递增子序列的长度。
注意:这里的”子序列”不要求连续。
五步法:
- 状态:
dp[i]= 以nums[i]结尾的最长递增子序列的长度 - 转移方程:
dp[i] = max(dp[j] + 1)for all j < i wherenums[j] < nums[i] - 初始条件:
dp[i] = 1(每个元素自身就是长度为 1 的子序列) - 遍历顺序:从小到大
- 返回值:
max(dp[0], dp[1], ..., dp[n-1])
function lengthOfLIS(nums) {
const n = nums.length;
const dp = new Array(n).fill(1);
for (let i = 1; i < n; i++) {
for (let j = 0; j < i; j++) {
if (nums[j] < nums[i]) {
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
}
return Math.max(...dp);
}
注意:返回值不是 dp[n-1],而是整个 dp 数组的最大值。因为最长递增子序列不一定以最后一个元素结尾。
复杂度:时间 O(n²),空间 O(n)。
还有一种更优的 O(n log n) 解法,使用贪心 + 二分:
function lengthOfLIS(nums) {
const tails = []; // tails[i] 表示长度为 i+1 的递增子序列的最小尾部元素
for (const num of nums) {
let lo = 0;
let hi = tails.length;
// 二分查找第一个 >= num 的位置
while (lo < hi) {
const mid = lo + Math.floor((hi - lo) / 2);
if (tails[mid] < num) {
lo = mid + 1;
} else {
hi = mid;
}
}
tails[lo] = num;
}
return tails.length;
}
3.4 0-1 背包问题
有 n 个物品,每个物品有重量
weights[i]和价值values[i]。背包容量为capacity。每个物品只能放一次,求最大价值。
0-1 背包虽然不是 LeetCode 原题,但它是很多 DP 问题的原型,面试中也经常考。
五步法:
- 状态:
dp[i][w]= 前 i 个物品、容量为 w 的背包能装的最大价值 - 转移方程:
- 不放第 i 个物品:
dp[i][w] = dp[i-1][w] - 放第 i 个物品(前提是放得下):
dp[i][w] = dp[i-1][w - weights[i]] + values[i] - 取两者的最大值
- 不放第 i 个物品:
- 初始条件:
dp[0][w] = 0(没有物品可选) - 遍历顺序:外层遍历物品,内层遍历容量
- 返回值:
dp[n][capacity]
function knapsack(weights, values, capacity) {
const n = weights.length;
const dp = Array.from({ length: n + 1 }, () =>
new Array(capacity + 1).fill(0)
);
for (let i = 1; i <= n; i++) {
for (let w = 0; w <= capacity; w++) {
// 不放第 i 个物品
dp[i][w] = dp[i - 1][w];
// 放第 i 个物品(如果放得下)
if (w >= weights[i - 1]) {
dp[i][w] = Math.max(
dp[i][w],
dp[i - 1][w - weights[i - 1]] + values[i - 1]
);
}
}
}
return dp[n][capacity];
}
空间优化:注意 dp[i][w] 只依赖 dp[i-1][...],可以压缩成一维数组。但内层循环必须从大到小遍历,防止同一轮中覆盖了还需要用到的值:
function knapsack(weights, values, capacity) {
const n = weights.length;
const dp = new Array(capacity + 1).fill(0);
for (let i = 0; i < n; i++) {
// 必须从大到小遍历!
for (let w = capacity; w >= weights[i]; w--) {
dp[w] = Math.max(dp[w], dp[w - weights[i]] + values[i]);
}
}
return dp[capacity];
}
复杂度:时间 O(n * capacity),空间 O(capacity)。
四、空间优化的通用思路
很多 DP 问题的空间可以从 O(n²) 优化到 O(n),甚至 O(1)。核心思路是:看 dp[i] 依赖哪些状态。
- 如果只依赖
dp[i-1]:可以用滚动数组(两行交替)或一维数组 - 如果只依赖
dp[i-1]和dp[i-2]:可以用两个变量 - 如果依赖当前行的之前列和上一行:需要小心遍历方向
一维数组优化的注意事项:
- 0-1 背包:内层从大到小遍历(确保每个物品只用一次)
- 完全背包:内层从小到大遍历(每个物品可以用多次)
常见误区
误区一:“DP 就是记忆化递归”
记忆化递归是 DP 的一种实现方式(自顶向下),但 DP 的核心是状态定义和转移方程。自底向上的递推也是 DP,而且在很多情况下更优(没有递归栈开销,可以做空间优化)。
误区二:“所有问题都可以用 DP 解决”
DP 要求问题具有最优子结构和重叠子问题。如果子问题之间没有依赖(比如快排的两个子数组),用分治更合适。如果没有最优子结构(比如最长路径问题在一般图上),DP 不适用。
误区三:“状态转移方程是最难的一步”
其实,状态定义比转移方程更关键。状态定义对了,转移方程通常是自然而然的。如果你发现转移方程写不出来,往往是因为状态定义有问题——比如维度不够(需要加一维状态)或者含义不对。
误区四:“空间优化就是把二维数组改成一维”
空间优化不只是”降维”这么简单。你必须确保优化后,计算 dp[i] 时它所依赖的值没有被提前覆盖。这通常需要调整内层循环的遍历方向(比如 0-1 背包必须从大到小遍历)。不理解背后的原因,贸然优化就会出 Bug。
小结
动态规划虽然题目种类多、变体多,但核心方法论是统一的。掌握了解题五步法,面对大多数 DP 题目你都能找到突破口。
核心要点
- 核心概念:最优子结构 + 重叠子问题
- 五步法:状态定义 → 转移方程 → 初始条件 → 遍历顺序 → 返回值
- 想”最后一步”:转移方程的突破口通常是”到达当前状态的最后一步做了什么”
- 空间优化:根据状态依赖关系,用滚动数组或变量代替完整的 DP 表
- 两种实现:自顶向下(记忆化递归)和自底向上(递推),各有优劣
本章思维导图
- 核心概念
- 最优子结构:大问题的最优解由子问题最优解构成
- 重叠子问题:同一子问题被反复计算
- 与分治的区别:子问题是否重叠
- 解题五步法
- Step 1:定义状态(dp[i] 表示什么)
- Step 2:状态转移方程(想"最后一步")
- Step 3:初始条件
- Step 4:遍历顺序(依赖关系决定)
- Step 5:返回值
- 两种实现
- 自顶向下(记忆化递归)
- 自底向上(递推 / 建表)
- 经典题
- 爬楼梯(LeetCode 70)
- 零钱兑换(LeetCode 322)
- 最长递增子序列(LeetCode 300)
- 0-1 背包问题
- 空间优化
- 看 dp[i] 依赖哪些状态
- 滚动数组、两个变量
- 0-1 背包:内层从大到小
- 完全背包:内层从小到大
- 常见题型
- 线性 DP:爬楼梯、打家劫舍
- 区间 DP:最长回文子串
- 背包 DP:零钱兑换、分割等和子集
- 序列 DP:最长递增子序列、编辑距离
练习挑战
第一题 ⭐:打家劫舍(LeetCode 198)
一个小偷沿街偷窃,每间房屋有一定金额,相邻房屋装有连通报警器。求在不触动报警器的情况下能偷到的最高金额。
// 示例
// 输入: [1, 2, 3, 1]
// 输出: 4(偷第 1 间和第 3 间,1 + 3 = 4)
点击查看答案
function rob(nums) {
if (nums.length === 0) return 0;
if (nums.length === 1) return nums[0];
let prev2 = 0; // dp[i-2]
let prev1 = 0; // dp[i-1]
for (const num of nums) {
const curr = Math.max(prev1, prev2 + num);
prev2 = prev1;
prev1 = curr;
}
return prev1;
}
状态:dp[i] = 前 i 间房能偷到的最大金额。转移方程:dp[i] = max(dp[i-1], dp[i-2] + nums[i])(偷或不偷第 i 间)。空间优化到两个变量。
时间复杂度 O(n),空间复杂度 O(1)。
第二题 ⭐⭐:最长回文子串(LeetCode 5)
给定一个字符串 s,找出其中最长的回文子串。
点击查看答案
function longestPalindrome(s) {
const n = s.length;
// dp[i][j] 表示 s[i..j] 是否是回文
const dp = Array.from({ length: n }, () => new Array(n).fill(false));
let start = 0;
let maxLen = 1;
// 单个字符都是回文
for (let i = 0; i < n; i++) {
dp[i][i] = true;
}
// 按长度从小到大遍历
for (let len = 2; len <= n; len++) {
for (let i = 0; i <= n - len; i++) {
const j = i + len - 1;
if (s[i] === s[j]) {
if (len === 2 || dp[i + 1][j - 1]) {
dp[i][j] = true;
if (len > maxLen) {
start = i;
maxLen = len;
}
}
}
}
}
return s.substring(start, start + maxLen);
}
也可以用中心扩展法(更直观,O(1) 空间):
function longestPalindrome(s) {
let start = 0;
let maxLen = 1;
function expand(left, right) {
while (left >= 0 && right < s.length && s[left] === s[right]) {
if (right - left + 1 > maxLen) {
start = left;
maxLen = right - left + 1;
}
left--;
right++;
}
}
for (let i = 0; i < s.length; i++) {
expand(i, i); // 奇数长度回文
expand(i, i + 1); // 偶数长度回文
}
return s.substring(start, start + maxLen);
}
时间复杂度 O(n²),空间复杂度 O(n²)(DP)或 O(1)(中心扩展)。
第三题 ⭐⭐⭐:编辑距离(LeetCode 72)
给定两个字符串 word1 和 word2,计算将 word1 转换成 word2 所使用的最少操作数。可以执行三种操作:插入、删除、替换。
点击查看答案
function minDistance(word1, word2) {
const m = word1.length;
const n = word2.length;
// dp[i][j] = word1 前 i 个字符变成 word2 前 j 个字符的最少操作数
const dp = Array.from({ length: m + 1 }, () =>
new Array(n + 1).fill(0)
);
// 初始条件
for (let i = 0; i <= m; i++) dp[i][0] = i; // 删除 i 个字符
for (let j = 0; j <= n; j++) dp[0][j] = 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] = Math.min(
dp[i - 1][j] + 1, // 删除
dp[i][j - 1] + 1, // 插入
dp[i - 1][j - 1] + 1 // 替换
);
}
}
}
return dp[m][n];
}
这是二维 DP 的经典题。状态 dp[i][j] 表示 word1 的前 i 个字符和 word2 的前 j 个字符之间的编辑距离。转移方程考虑三种操作。
时间复杂度 O(m * n),空间复杂度 O(m * n),可以优化到 O(n)。
自我检测
- 能解释最优子结构和重叠子问题的含义
- 能熟练运用解题五步法(状态→转移→初始→遍历→返回)
- 能解决爬楼梯问题,并做空间优化到 O(1)
- 能写出零钱兑换的状态转移方程,并解释”想最后一步”的思路
- 能区分 0-1 背包和完全背包的空间优化中遍历方向的不同
- 能用 DP 或中心扩展法解决最长回文子串
- 能说出自顶向下和自底向上两种实现的优缺点
- 遇到新的 DP 题时,能快速定义状态并尝试写出转移方程
购买课程解锁全部内容
大厂前端面试通关:71 篇构建完整知识体系
¥89.90