LeetCode Hot 100 · 多维动态规划篇
「多维动态规划」这个名字听起来唬人,其实本质就是状态需要两个或更多下标来描述的 DP——最常见的是 dp[i][j] 这种二维状态。它和普通一维 DP 的唯一区别是:状态转移时可能同时依赖「上方」和「左方」,所以你需要格外注意初始化边界和遍历顺序。
这一篇覆盖 Hot 100 里的 5 道经典题:
- 62. 不同路径 —— 路径计数,二维 DP 入门
- 64. 最小路径和 —— 路径最值,原地 DP 空间优化
- 5. 最长回文子串 —— 中心扩展 + 区间 DP
- 1143. 最长公共子序列 —— 双串 DP 模板
- 72. 编辑距离 —— 双串 DP 天花板
前两题是「网格路径」模型,后三题是「字符串」模型。吃透这 5 道,二维 DP 的骨架就立起来了。每道题我都按「题意 → 难点易错点 → 暴力 → 最优 → 多解法对比 → CodeTop 变体」的结构来拆,重点放在状态怎么定义、转移怎么推、边界怎么初始化这三件事上。
#62 不同路径
题意
机器人在一个 m x n 网格的左上角,每次只能向右或向下移动一步,问到达右下角总共有多少条不同的路径。
输入:m = 3, n = 7
输出:28
难点与易错点
- 第一行、第一列的初始化:第一行的每个格子只能从左边走来(只有 1 条路径),第一列的每个格子只能从上边走来(也只有 1 条路径)。很多人漏掉这一行/列的边界,导致
dp数组越界或结果全为 0。 - 一维滚动数组的遍历顺序:优化到一维后,
dp[j]的旧值代表「上方」,dp[j-1]的新值代表「左方」,所以j必须从左到右遍历,否则会拿到没更新过的脏数据。 - 数值溢出:当
m、n较大时,答案会非常大(本质是组合数C(m+n-2, m-1)),int会溢出。LeetCode 本题保证答案不超过2e9,但面试官可能追问「如果网格很大怎么办」,需要会切long或组合数取模。
题目本质:二维 DP 路径计数,dp[i][j] = dp[i-1][j] + dp[i][j-1](从上方来 + 从左方来)。
现实类比:迷宫计数。到达每个格子的路数 = 从上边来的路数 + 从左边来的路数,就像分叉路口的汇流——每条路都把自己的「流量」累加进当前路口。
解法一:暴力 / 直观
最直观的想法是递归 DFS:从 (0,0) 出发,每一步都尝试向下和向右两个方向,走到终点就记 1 条。
class Solution {
public int uniquePaths(int m, int n) {
return dfs(0, 0, m, n);
}
// 从 (i, j) 走到右下角的路径数
private int dfs(int i, int j, int m, int n) {
if (i == m - 1 && j == n - 1) return 1; // 到达终点,算一条路径
if (i >= m || j >= n) return 0; // 越界,此路不通
return dfs(i + 1, j, m, n) + dfs(i, j + 1, m, n); // 向下 + 向右
}
}
- 时间复杂度:每个格子都有向下/向右两个分支,递归树高度是
m + n - 2,最坏是指数级O(2^(m+n))。 - 空间复杂度:递归栈深度
O(m + n)。
瓶颈在哪:存在海量重复子问题——dfs(i, j) 会被从不同路径多次访问并重复计算,例如 dfs(1,1) 同时被 dfs(0,1) 和 dfs(1,0) 调用。这正是可以用 DP 记忆化的信号。
解法二:优化 / 最优
二维 DP 推导(先想清楚状态,再谈空间优化):
-
状态定义:
dp[i][j]表示从左上角到达(i, j)的不同路径数。 -
状态转移方程:到达
(i, j)只有两条路——从上边(i-1, j)下来,或从左边(i, j-1)过来,所以dp[i][j] = dp[i-1][j] + dp[i][j-1] -
初始化:第一行
dp[0][j] = 1(只能从左边一路走来),第一列dp[i][0] = 1(只能从上边一路走下来)。 -
遍历顺序:
i、j都从小到大递增即可——因为dp[i][j]只依赖「上方」dp[i-1][j]和「左方」dp[i][j-1],递增顺序保证依赖项先被算出来。
空间优化:观察发现计算第 i 行时只用到第 i-1 行,于是可以用一维数组滚动。更新时 dp[j] 的旧值就是「上方」dp[i-1][j],dp[j-1] 刚被更新的值就是「左方」dp[i][j-1],一行代码 dp[j] += dp[j-1] 就完成了转移。
class Solution {
public int uniquePaths(int m, int n) {
int[] dp = new int[n];
// 初始化:第一行每个格子只有 1 条路径
for (int j = 0; j < n; j++) dp[j] = 1;
// 从第 2 行开始滚动
for (int i = 1; i < m; i++) {
// 从左到右遍历,dp[j] 旧值 = 上方,dp[j-1] 新值 = 左方
for (int j = 1; j < n; j++) {
dp[j] += dp[j - 1];
}
}
return dp[n - 1];
}
}
复杂度逐步推导:
- 时间复杂度:外层循环
i从 1 到m-1,共m-1次;内层循环j从 1 到n-1,共n-1次。两重循环总共执行(m-1) × (n-1)次常数级操作,因此是O(m × n)。 - 空间复杂度:只用一个长度
n的一维数组,即O(n)。对比二维dp[m][n]的O(m×n),空间从「面积」降到了「一条边」。
💭 思考:二维 DP 为什么能滚成一维,
dp[j] += dp[j-1]这一行凭什么同时代表「上方 + 左方」?——先看二维转移dp[i][j] = dp[i-1][j] + dp[i][j-1],它只依赖上一行和本行的前一个。滚动时用一维数组复写:dp[j]还没更新前的旧值就是「上一行的dp[i-1][j]」(上方),dp[j-1]刚更新过的新值就是「本行的dp[i][j-1]」(左方)。所以一行dp[j] += dp[j-1]就把两个方向都加上了。看到「网格路径 DP 且只依赖上一行」这个信号,就想到「滚动数组 + 从左到右遍历」——但必须正序,否则dp[j-1]还是旧值就错。
多解法对比
| 解法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 递归 DFS | O(2^(m+n)) | O(m+n) | 仅供理解递归思想,不可用于实际 |
| 二维 DP | O(m×n) | O(m×n) | 直观易懂,适合讲解状态转移 |
| 一维滚动 DP | O(m×n) | O(n) | 本题最优,面试推荐 |
| 组合数 C(m+n-2, m-1) | O(min(m,n)) | O(1) | 无障碍、只求路径数时的数学捷径 |
本题约束下(m, n ≤ 100,答案保证在 int 内),一维滚动 DP 的 O(m×n) 时间完全够用,且代码短、不易错,是面试首选。组合数解法虽然更快,但一是容易溢出,二是无法扩展到「有障碍物」的变体,属于锦上添花,可作为加分项口述。
CodeTop 变体
- 63. 不同路径 II(字节、腾讯高频):网格里有障碍物,有障碍的格子路径数为 0。变体核心:初始化第一行/列时遇到障碍物直接断掉(后面全为 0),转移时若当前格是障碍则
dp[i][j] = 0。 - 追问 1:如何把空间优化到
O(min(m, n))?——若m < n就按行滚动(数组长度取n),否则转置网格按列滚动,始终让一维数组取较短的边长。 - 追问 2:组合数公式是什么?——
C(m+n-2, m-1),等价于在m+n-2步里选m-1步向下。面试官常用来验证你理解「路径与组合」的对应关系。
#64 最小路径和
题意
给一个 m x n 的网格 grid,每个格子是一个非负整数。从左上角走到右下角(只能向右或向下),求路径上所有数字之和的最小值。
输入:grid = [[1,3,1],[1,5,1],[4,2,1]]
输出:7(路径 1→3→1→1→1)
难点与易错点
- 边界初始化要「累加」而不是「赋值」:第一行的每个格子只能从左边来,所以
grid[0][j] += grid[0][j-1]是累加,不是直接取原值;第一列同理。很多人把边界和内部格子的处理混在一起,导致起点值被错误计入。 - 原地修改会破坏输入:把
grid当 DP 数组是空间最优,但面试官可能追问「如果输入不可修改怎么办」,需要能当场改成额外一维数组的写法。 - 起点
grid[0][0]不能重复加:第一行累加从j=1开始、第一列累加从i=1开始,恰好跳过了起点,避免起点值被算两次。
题目本质:二维 DP 最优路径,dp[i][j] = grid[i][j] + min(dp[i-1][j], dp[i][j-1]),边界独立处理。
现实类比:最省钱路线导航。到达每个路口的最低消费 = 这个路口本身的费用 + 从上方或左方过来的最小消费中取较小者。
解法一:暴力 / 直观
暴力做法同样是递归枚举所有路径,取最小的路径和。
class Solution {
public int minPathSum(int[][] grid) {
return dfs(grid, 0, 0);
}
private int dfs(int[][] grid, int i, int j) {
int m = grid.length, n = grid[0].length;
if (i == m - 1 && j == n - 1) return grid[i][j]; // 到终点,直接返回本格值
if (i >= m || j >= n) return Integer.MAX_VALUE; // 越界视为不可达
return grid[i][j] + Math.min(dfs(grid, i + 1, j), dfs(grid, i, j + 1));
}
}
- 时间复杂度:
O(2^(m+n)),指数级。 - 空间复杂度:
O(m+n),递归栈。
瓶颈在哪:和 62 一样,dfs(i, j) 被大量重复调用,每条从起点到 (i,j) 的前缀路径都会重新递归一遍子树。需要用 DP 把「到每个格子的最小和」缓存下来。
解法二:优化 / 最优
二维 DP 推导(让 grid 本身充当 dp 数组,原地修改):
-
状态定义:
dp[i][j]表示从左上角到达(i, j)的最小路径和。 -
状态转移方程:到达
(i, j)只能从上方或左方来,取其中较小者,再加上本格权值:dp[i][j] = grid[i][j] + min(dp[i-1][j], dp[i][j-1]) -
初始化:
- 第一行
dp[0][j]:只能从左方来,所以dp[0][j] = dp[0][j-1] + grid[0][j](累加)。 - 第一列
dp[i][0]:只能从上方来,所以dp[i][0] = dp[i-1][0] + grid[i][0](累加)。
- 第一行
-
遍历顺序:
i、j从小到大递增。dp[i][j]依赖上、左两项,递增顺序保证它们已就绪。
class Solution {
public int minPathSum(int[][] grid) {
int m = grid.length, n = grid[0].length;
// 初始化第一行:只能从左方来,累加
for (int j = 1; j < n; j++) grid[0][j] += grid[0][j - 1];
// 初始化第一列:只能从上方来,累加
for (int i = 1; i < m; i++) grid[i][0] += grid[i - 1][0];
// 内部格子:取上方与左方的较小者
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
grid[i][j] += Math.min(grid[i - 1][j], grid[i][j - 1]);
}
}
return grid[m - 1][n - 1];
}
}
复杂度逐步推导:
- 时间复杂度:第一行累加
n-1次、第一列累加m-1次、内部两重循环(m-1) × (n-1)次,三者数量级之和约为m×n,所以总时间O(m × n)。 - 空间复杂度:全程原地修改
grid,没有额外开辟任何与m、n相关的数组,因此是O(1)(不算输入本身)。
多解法对比
| 解法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 递归 DFS | O(2^(m+n)) | O(m+n) | 理解用,不可上生产 |
| 二维 DP(新开数组) | O(m×n) | O(m×n) | 不允许修改输入时使用 |
| 原地 DP | O(m×n) | O(1) | 本题最优,默认推荐 |
本题约束下,时间上 O(m×n) 已经是最优(每个格子至少要访问一次),空间上用原地修改做到 O(1),无可再压。选原地 DP 既快又省,代码还短。唯一的代价是破坏输入 grid,如果面试官要求不改输入,退回到「新开一维滚动数组」即可,复杂度变为 O(n) 空间。
CodeTop 变体
- 120. 三角形最小路径和(字节、腾讯高频):把矩形网格换成三角形,每步只能走到正下方或右下方。套路相同:
dp[i][j] = triangle[i][j] + min(dp[i+1][j], dp[i+1][j+1]),从底向上递推可免去边界特判。 - 63. 不同路径 II 的最小和版:网格带障碍,有障碍的格子不可经过,取
Integer.MAX_VALUE或直接跳过。 - 追问:如果允许上下左右四个方向走(不再是单调路径),还能用 DP 吗?——不能,因为会出现环,必须改用 Dijkstra / BFS,这是「最短路径」与「网格 DP」的分界点,面试官爱用这题检验你是否理解 DP 的「无后效性」。
#5 最长回文子串
题意
给一个字符串 s,返回其中最长的回文子串(回文即正读反读都相同)。
输入:s = "babad"
输出:"bab"("aba" 也是合法答案)
难点与易错点
- 回文长度分奇偶:长度为奇数时中心是一个字符,偶数时中心是两个相邻字符,两种情况都要尝试,漏掉偶数回文会直接错(例如
"abba"的最长回文是偶数长度)。 - 扩展结束后的长度计算:扩展循环退出时,
l和r都停在「不相等」或「越界」的位置,实际回文区间是[l+1, r-1],长度为r - l - 1,很多人会写成r - l + 1而多算两个。 - 起点反推公式:由中心
i和长度len反推回文起点是i - (len - 1) / 2,这个/2向下取整同时兼容了奇偶两种长度,写错容易导致截取的子串偏移一位。
题目本质:中心扩展法——以每个字符(奇数长度)和每两个相邻字符(偶数长度)为中心,向两边扩展找最长回文。
现实类比:水波扩散找最大镜像区。在每个位置投石激起波纹,从中心向两侧只要字符相等就继续扩,记录下最大的波纹范围。
解法一:暴力 / 直观
枚举所有子串的起点 i 和终点 j,逐一判断是否回文,取最长。
class Solution {
public String longestPalindrome(String s) {
int n = s.length();
String ans = "";
for (int i = 0; i < n; i++) {
for (int j = i; j < n; j++) {
// 只判断比当前答案更长的子串,剪枝
if (j - i + 1 > ans.length() && isPalindrome(s, i, j)) {
ans = s.substring(i, j + 1);
}
}
}
return ans;
}
private boolean isPalindrome(String s, int l, int r) {
while (l < r) {
if (s.charAt(l) != s.charAt(r)) return false;
l++; r--;
}
return true;
}
}
- 时间复杂度:枚举子串起点
i、终点j是O(n²)种,每种判断回文要O(n),合计O(n³)。 - 空间复杂度:
O(1)。
瓶颈在哪:一是判断回文做了大量重复比较,二是把时间浪费在大量「不可能成为答案」的短子串上。实际上一个更长的回文必然以某个中心向两侧对称展开,我们应该抓住「中心」这个更本质的变量,而不是「起点 + 终点」。
解法二:优化 / 最优
中心扩展法:遍历每个可能的中心,向两侧贪心扩展,直到不再对称。奇数回文的中心是单个字符 (i, i),偶数回文的中心是两个相邻字符 (i, i+1),一共 2n - 1 个中心。
class Solution {
public String longestPalindrome(String s) {
int n = s.length();
int start = 0, maxLen = 1; // 单个字符本身也是回文
for (int i = 0; i < n; i++) {
int odd = expand(s, i, i); // 奇数长度:中心是 i
int even = expand(s, i, i + 1); // 偶数长度:中心是 i 与 i+1
int len = Math.max(odd, even);
if (len > maxLen) {
maxLen = len;
start = i - (len - 1) / 2; // 由中心和长度反推起点
}
}
return s.substring(start, start + maxLen);
}
// 从 (l, r) 向两侧扩展,返回最长回文长度
private int expand(String s, int l, int r) {
while (l >= 0 && r < s.length() && s.charAt(l) == s.charAt(r)) {
l--;
r++;
}
return r - l - 1; // 合法回文区间为 (l, r),长度为 r - l - 1
}
}
复杂度逐步推导:
- 时间复杂度:中心共有
n + (n-1) = 2n - 1个;每个中心向外扩展,最坏情况(如全相同字符"aaaa")要扩到字符串边界,约n/2步。总操作量约为(2n-1) × (n/2),即O(n²)。 - 空间复杂度:只用了
start、maxLen、l、r等常数个变量,O(1)。
补充:二维 DP 写法(面试官可能追问「用 DP 怎么做」):
- 状态定义:
dp[i][j]表示子串s[i..j]是否为回文(布尔值)。 - 状态转移:
dp[i][j] = (s[i] == s[j]) && (j - i < 2 || dp[i+1][j-1])。即两端相等,且去掉两端后(若还剩 0 或 1 个字符则天然回文)仍是回文。 - 初始化:长度为 1 的子串都是回文。
- 遍历顺序:
dp[i][j]依赖dp[i+1][j-1](左下方),所以i必须从大到小、j必须从小到大(或按区间长度len从 1 到n递增遍历)。
DP 的时间也是 O(n²),但空间要 O(n²)(可用滚动数组优化到 O(n)),在空间上劣于中心扩展的 O(1),所以本题面试首选仍是中心扩展。
💭 思考:暴力枚举子串是 O(n³),怎么一步步想到「中心扩展」?——暴力版枚举「起点 + 终点」O(n²) 种,每种判断回文 O(n),合计 O(n³)。观察:一个回文串必然以某个中心向两侧对称,所以我们真正该枚举的变量是「中心」而不是「起点 + 终点」。中心只有
2n - 1个(奇数中心是单个字符、偶数中心是相邻两个字符),每个中心向外扩展直到不对称,总操作量 O(n²)。看到「最长回文子串 / 只求最值不需求所有区间」这个信号,就选中心扩展(空间 O(1));只有当题目需要「任意区间是否回文」的布尔信息(如分割回文串)时,才值得开区间 DP 表。
多解法对比
| 解法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 暴力枚举 | O(n³) | O(1) | 仅供对比,不可用 |
| 二维区间 DP | O(n²) | O(n²) | 需要「是否回文」的布尔信息时(如回文分割、计数) |
| 中心扩展 | O(n²) | O(1) | 本题最优,只求最长回文子串 |
| Manacher 算法 | O(n) | O(n) | 字符串极长、要求严格线性时的进阶算法 |
本题约束下(n ≤ 1000),中心扩展的 O(n²) 已足够,空间 O(1) 是最省的,代码也最简洁,是面试首选。Manacher 是加分项,能说出「可以线性」即可,不必现场手写。
CodeTop 变体
- 647. 回文子串(字节、腾讯高频):不求最长,改为统计「回文子串的总个数」。中心扩展框架完全复用,每次
expand返回「能扩几步」累加即可,等价于len/2上取整。 - 516. 最长回文子序列:子串变子序列,允许删字符。这是标准二维 DP:
dp[i][j] = s[i]==s[j] ? dp[i+1][j-1]+2 : max(dp[i+1][j], dp[i][j-1]),与本题的回文子串是两码事,常被放在一起追问以区分「子串」和「子序列」。 - 131. 分割回文串:把
s分割成若干回文子串的所有方案。先预处理出dp[i][j](是否回文)做记忆,再做回溯枚举切割点——正是「二维区间 DP」的典型应用场景。 - 追问:Manacher 的思想是什么?——利用已求出的回文半径避免重复比较,插入分隔符把奇偶统一,做到
O(n)。
#1143 最长公共子序列
题意
给定两个字符串 text1 和 text2,返回它们最长公共子序列(LCS)的长度。子序列不要求连续,但相对顺序必须保持一致;不存在公共子序列时返回 0。
输入:text1 = "abcde", text2 = "ace"
输出:3("ace")
难点与易错点
- 「子序列」≠「子串」:子序列可以跳着取字符(
"ace"在"abcde"里隔着取),子串必须连续。如果把题意理解成最长公共子串,转移方程就完全错了。 dp数组下标偏移:用dp[i][j]表示text1[0..i-1]和text2[0..j-1]的 LCS,数组大小开(m+1) × (n+1),实际比较字符时要写charAt(i-1)、charAt(j-1)。下标少 1 是本题最高频的 bug。- 初始化第 0 行第 0 列:空串和任何串的 LCS 都是 0,Java 里
int数组默认就是 0,但必须心里清楚这一层边界的存在,不能只记得主循环。
题目本质:经典二维 DP,dp[i][j] 表示 text1[0..i-1] 与 text2[0..j-1] 的 LCS;末尾字符相等则 dp[i-1][j-1]+1,否则 max(dp[i-1][j], dp[i][j-1])。
现实类比:两段 DNA 序列比对。逐步看每对字符是否相同,相同就把公共链延伸一位,不同就看舍弃哪条链的末尾更划算——保留公共部分更长的那个方向。
解法一:暴力 / 直观
递归枚举:从两个字符串末尾往前看,末尾相同就都取、不同就分别舍弃一个取较大。
class Solution {
public int longestCommonSubsequence(String text1, String text2) {
return dfs(text1, text2, text1.length(), text2.length());
}
// 返回 text1[0..i-1] 与 text2[0..j-1] 的 LCS
private int dfs(String a, String b, int i, int j) {
if (i == 0 || j == 0) return 0; // 任一为空串,LCS 为 0
if (a.charAt(i - 1) == b.charAt(j - 1)) {
return dfs(a, b, i - 1, j - 1) + 1; // 末尾相同,一起取
}
return Math.max(dfs(a, b, i - 1, j), dfs(a, b, i, j - 1)); // 舍弃一个末尾
}
}
- 时间复杂度:每个位置最多产生两个递归分支,递归树高度
m + n,最坏O(2^(m+n))。 - 空间复杂度:递归栈
O(m+n)。
瓶颈在哪:dfs(i, j) 会被大量重复计算——不同的舍弃路径会收敛到同一个 (i, j) 子问题。这正是「记忆化 / 递推」的经典入场时机。
解法二:优化 / 最优
二维 DP 推导:
-
状态定义:
dp[i][j]表示text1的前i个字符(text1[0..i-1])与text2的前j个字符(text2[0..j-1])的最长公共子序列长度。 -
状态转移方程:看两个前缀的最后一个字符是否相等——
若 text1[i-1] == text2[j-1]:dp[i][j] = dp[i-1][j-1] + 1 (两个末尾都取,公共序列 +1) 否则: dp[i][j] = max(dp[i-1][j], dp[i][j-1]) (舍弃其中一个末尾) -
初始化:
dp[0][j] = 0(text1为空),dp[i][0] = 0(text2为空)。 -
遍历顺序:
i从 1 到m、j从 1 到n递增即可。dp[i][j]依赖左上、上方、左方三项,递增顺序保证它们都已计算。
class Solution {
public int longestCommonSubsequence(String text1, String text2) {
int m = text1.length(), n = text2.length();
// dp[i][j]:text1 前 i 个字符 与 text2 前 j 个字符 的 LCS 长度
int[][] dp = new int[m + 1][n + 1];
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (text1.charAt(i - 1) == text2.charAt(j - 1)) {
dp[i][j] = dp[i - 1][j - 1] + 1; // 末尾匹配,公共序列延伸
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); // 舍弃一个末尾
}
}
}
return dp[m][n];
}
}
复杂度逐步推导:
- 时间复杂度:外层
i循环m次、内层j循环n次,两重循环共m × n次,每次是常数级的比较和取max,所以是O(m × n)。 - 空间复杂度:二维数组
dp的大小为(m+1) × (n+1),即O(m × n)。因为dp[i][j]只依赖上一行i-1,可以只用两行滚动(或一行 + 一个左上角临时变量)把空间降到O(n);进一步若n > m则交换两串,取较短者做一维长度,空间为O(min(m, n))。
💭 思考:两个字符串的问题,为什么状态天然是
dp[i][j]?——先看单串 DP 用dp[i]描述「前 i 个字符」就够了;但 LCS 涉及两个字符串的「前缀对」,一个下标表达不了「两边各走到哪」,所以状态必须是二维的dp[i][j]= 两串前 i、前 j 个字符的 LCS。转移也只看「末尾是否相等」:相等就dp[i-1][j-1]+1(两个末尾都取),不相等就max(dp[i-1][j], dp[i][j-1])(舍弃其中一个末尾)。看到「双字符串 + 比较/匹配/对齐」这个信号,第一反应就是「二维前缀 DP」,这是编辑距离、LCS 一整族的统一骨架。
多解法对比
| 解法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 递归 DFS | O(2^(m+n)) | O(m+n) | 理解用 |
| 二维 DP | O(m×n) | O(m×n) | 需要回溯构造 LCS 串时(保留完整表) |
| 一维滚动 DP | O(m×n) | O(min(m,n)) | 只求长度时的最优空间 |
本题约束下,O(m×n) 时间是下界(两个字符串的每个字符对几乎都要比较一次),无法更优;空间上二维 → 一维滚动即可。面试时先写二维(清晰、不易错),再口述「可以滚动到一维」,是稳妥节奏。若面试官要求输出具体 LCS 串,则需要保留二维表并回溯。
CodeTop 变体
- 583. 两个字符串的删除操作(明确由 72 延伸而来,也是本题的直接变体):求让两串相等所需删除的最少字符数。结论:
m + n - 2 × LCS,先求 LCS 再套公式即可。 - 1035. 不相交的线:两个数组间连线且线不相交,本质就是 LCS,只是把「字符相等」换成「数字相等」,几乎原题换皮。
- 最长公共子串(腾讯高频,与本题一字之差):要求连续。转移改为
dp[i][j] = (s1[i-1]==s2[j-1]) ? dp[i-1][j-1]+1 : 0(不相等直接清零),答案是整个表的最大值而非dp[m][n]。 - 392. 判断子序列:
s是否为t的子序列。可用双指针O(n)贪心,也可用本题 DP 判断LCS(s, t) == s.length(),面试官常用来对比「子序列判断」与「LCS」的复杂度差异。
#72 编辑距离
题意
给两个单词 word1 和 word2,返回把 word1 转换成 word2 所需的最少操作次数。操作有三种:插入一个字符、删除一个字符、替换一个字符。
输入:word1 = "horse", word2 = "ros"
输出:3(horse → rorse(替换 h 为 r)→ rose(删除 r)→ ros(删除 e))
难点与易错点
- 三种操作的转移方向容易记混:
dp[i-1][j-1]是替换、dp[i-1][j]是删除、dp[i][j-1]是插入。建议死记一句口诀「左上替换、上方删除、左方插入」,并理解每个方向对应的字符串长度变化。 - 边界初始化不是 0:
dp[i][0] = i(把word1前 i 个全删光,需要删 i 次),dp[0][j] = j(从空串插入 j 个字符)。这一点和 LCS 的「边界全 0」完全不同,是两题最容易混淆的地方。 - 字符相等时是「跳过」而非「取 min+1」:
word1[i-1] == word2[j-1]时,直接dp[i][j] = dp[i-1][j-1],不要套用min(替换,删,插)+1,否则会多算一次操作。
题目本质:经典二维 DP(Levenshtein 距离),dp[i][j] 表示 word1[0..i-1] 变为 word2[0..j-1] 的最少操作数;字符相等时 dp[i-1][j-1],否则 min(替换, 删除, 插入) + 1。
现实类比:文字自动纠错。把 word1 变成 word2,逐字符对比看是删、增还是改最划算,用记忆化把每个子问题算一次,避免重复计算。
解法一:暴力 / 直观
递归模拟三种操作:末尾字符相同就一起跳过,不同就分别尝试替换、删除、插入,取最小。
class Solution {
public int minDistance(String word1, String word2) {
return dfs(word1, word2, word1.length(), word2.length());
}
// 返回 word1[0..i-1] 变 word2[0..j-1] 的最少操作数
private int dfs(String a, String b, int i, int j) {
if (i == 0) return j; // a 为空,插入 j 次
if (j == 0) return i; // b 为空,删除 i 次
if (a.charAt(i - 1) == b.charAt(j - 1)) {
return dfs(a, b, i - 1, j - 1); // 末尾相同,跳过
}
return 1 + Math.min(dfs(a, b, i - 1, j - 1), // 替换
Math.min(dfs(a, b, i - 1, j), // 删除
dfs(a, b, i, j - 1))); // 插入
}
}
- 时间复杂度:每个位置最多三个分支,递归树高度
m + n,最坏O(3^(m+n))。 - 空间复杂度:递归栈
O(m+n)。
瓶颈在哪:三个分支会产生大量重叠子问题,且指数级增长极快,m、n 稍大就不可行。必须用 DP 把 dp[i][j] 缓存起来。
解法二:优化 / 最优
二维 DP 推导:
-
状态定义:
dp[i][j]表示把word1的前i个字符(word1[0..i-1])转换成word2的前j个字符(word2[0..j-1])所需的最少操作数。 -
状态转移方程:
-
若
word1[i-1] == word2[j-1]:末尾字符相同,无需操作,直接继承dp[i][j] = dp[i-1][j-1]。 -
若不同,三种操作取最小再加 1:
dp[i][j] = min( dp[i-1][j-1], // 替换:把 word1[i-1] 换成 word2[j-1] dp[i-1][j], // 删除:删掉 word1[i-1] dp[i][j-1] ) // 插入:在 word1 末尾插入 word2[j-1] + 1
-
-
初始化:
dp[i][0] = i(word1前 i 个字符变空串,需删 i 次);dp[0][j] = j(空串变word2前 j 个字符,需插 j 次)。 -
遍历顺序:
i从 1 到m、j从 1 到n递增。dp[i][j]依赖左上、上方、左方,递增顺序保证依赖项已就绪。
class Solution {
public int minDistance(String word1, String word2) {
int m = word1.length(), n = word2.length();
// dp[i][j]:word1 前 i 个字符变 word2 前 j 个字符的最少操作数
int[][] dp = new int[m + 1][n + 1];
// 边界初始化:与空串互转的编辑距离
for (int i = 0; i <= m; i++) dp[i][0] = i; // word1 全删
for (int j = 0; j <= n; j++) dp[0][j] = j; // 空串全插
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (word1.charAt(i - 1) == word2.charAt(j - 1)) {
dp[i][j] = dp[i - 1][j - 1]; // 相同,无需操作
} else {
dp[i][j] = 1 + Math.min(dp[i - 1][j - 1], // 替换
Math.min(dp[i - 1][j], // 删除 word1[i-1]
dp[i][j - 1])); // 插入 word2[j-1]
}
}
}
return dp[m][n];
}
}
复杂度逐步推导:
- 时间复杂度:外层
i循环m次、内层j循环n次,共m × n次,每次做常数级的charAt比较和Math.min,因此是O(m × n)。 - 空间复杂度:二维数组
dp为(m+1) × (n+1),即O(m × n)。同样因为每行只依赖上一行,可用滚动数组降到O(min(m, n))(用两个一维数组交替,或一个数组 + 临时变量保存左上角)。
💭 思考:编辑距离的三种操作,为什么对应「左上 / 上方 / 左方」三个方向?——把状态想成「
word1前 i 个 →word2前 j 个」,转移就是「怎么缩小到更小的子问题」:替换是把word1[i-1]换成word2[j-1],两边末尾都消耗掉 → 左上dp[i-1][j-1];删除是删掉word1[i-1],word1短一位 → 上方dp[i-1][j];插入是在word1末尾插一个word2[j-1],word2短一位 → 左方dp[i][j-1]。记住口诀「左上替换、上方删除、左方插入」,再配合「相等时直接跳过dp[i-1][j-1],不要 min+1」和「边界dp[i][0]=i、dp[0][j]=j」,这套双串 DP 就稳了。
多解法对比
| 解法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 递归 DFS | O(3^(m+n)) | O(m+n) | 理解三种操作如何映射到递归 |
| 二维 DP | O(m×n) | O(m×n) | 本题标准答案,需回溯具体操作序列时 |
| 一维滚动 DP | O(m×n) | O(min(m,n)) | 只求距离、追求极致的空间 |
本题约束下(m, n ≤ 500),O(m×n) 时间是最优,空间用二维表即可(25 万个 int 也就 1MB 级别),无需强行滚动。选择二维 DP 是因为它把三种操作的语义写得最清晰、最不易错,面试时先把二维写对,再主动说「可以滚动到一维」展示优化意识即可。
CodeTop 变体
- 583. 两个字符串的删除操作(最直接的延伸,字节、腾讯高频):只允许删除操作(不能替换/插入),求让两串相等的最少删除次数。有两种做法:一是只保留
dp[i-1][j](删)和dp[i][j-1](删)两条转移;二是利用 LCS 结论m + n - 2 × LCS。 - 392. 判断子序列:编辑距离的「弱化版」,只允许删除
word1的字符,判断能否得到word2。可用双指针贪心O(n),也可用 DP。 - 115. 不同的子序列:统计
s中有多少个不同的子序列等于t。这是编辑距离的「计数版」,转移变成累加而非取 min:dp[i][j] = s[i-1]==t[j-1] ? dp[i-1][j-1] + dp[i-1][j] : dp[i-1][j]。 - 追问:三种操作代价不同(例如替换代价为 2,删除/插入为 1)怎么改?——把转移式里的
+1改成各自的操作代价即可,框架完全不变,考察你对「DP 转移是可参数化」的理解。
总结:五题一张表
| 题目 | 状态定义 | 转移核心 | 边界 | 空间优化 |
|---|---|---|---|---|
| 62. 不同路径 | dp[i][j] 到该格的路径数 | dp[i-1][j] + dp[i][j-1] | 第一行/列全 1 | 一维 O(n) |
| 64. 最小路径和 | dp[i][j] 到该格的最小和 | grid[i][j] + min(上, 左) | 第一行/列累加 | 原地 O(1) |
| 5. 最长回文子串 | dp[i][j] 区间是否回文 | 中心扩展 / 区间 DP | 单字符天然回文 | 中心扩展 O(1) |
| 1143. LCS | dp[i][j] 两前缀 LCS | 相等 +1,不等取 max | 第 0 行/列全 0 | 一维 O(n) |
| 72. 编辑距离 | dp[i][j] 两前缀最少操作 | 相等跳过,不等 min(替换,删,插)+1 | dp[i][0]=i、dp[0][j]=j | 一维 O(n) |
一个万能套路:二维 DP 手撕时,永远按「状态定义 → 转移方程 → 边界初始化 → 遍历顺序 → 空间优化」五步走,先写二维保证正确,再滚到一维展示优化。把「网格路径」(62/64)和「双字符串」(1143/72)这两条主线打通,绝大多数二维 DP 面试题都能套进去。
章末提问
-
什么时候需要把状态定义成二维
dp[i][j]? —— 结论先行:当子问题需要「两个独立维度」才能唯一描述时。因为单下标无法同时表达两条主线——LCS/编辑距离的两个字符串前缀、网格的行和列,硬压成一维会丢信息,二维状态天然对应「前 i 个 / 前 j 个」。 -
边界初始化到底怎么定,而不是照抄 0? —— 结论先行:边界是「退化成空串或单边」时的答案,要按题意手工算。因为第一行/列(62/64)只能从一个方向累加、空串的 LCS=0、空串变
word2要插 j 次(dp[0][j]=j),这些都不靠转移方程推,错了整张表全错。 -
遍历顺序为什么有的递增、有的要倒着来? —— 结论先行:由
dp[i][j]依赖的邻居位置决定。因为最长回文子串的区间 DP 依赖左下方dp[i+1][j-1],所以 i 要从大到小(或按区间长度递增);而网格路径、LCS、编辑距离只依赖上、左、左上,所以 i、j 递增即可,保证依赖项先算出来。 -
二维 DP 怎么降到一维滚动? —— 结论先行:因为当前行只依赖上一行,所以只需一行数组,用「旧值=上方、新值=左方」原地更新。例如 LCS、编辑距离都能用一行 + 一个保存左上角的临时变量,把空间从
O(mn)降到O(min(m,n))。 -
最长回文子串为什么选中心扩展而不是区间 DP? —— 结论先行:因为只求「最长回文子串」时中心扩展空间
O(1),区间 DP 却要O(n²)。因为中心扩展把「起点+终点」的O(n³)枚举转化为「中心+扩展」的O(n²),且不存表;只有需要「任意区间是否回文」的布尔信息(如分割回文串)时,才值得开 DP 表。