登录
OAmaster
动态规划

动态规划 · 序列 DP

从 LC 300 出发,看单序列 LIS、双序列 LCS 与编辑距离的状态定义和转移。

Medium预计阅读 50 分钟更新于 2026-05-19

1. 核心原理

序列 DP 是动态规划里最常见的一族,处理"在序列上选 / 不选 / 配对 / 删改"等子结构问题:最长递增子序列(LIS)、最长公共子序列(LCS)、最大子段和、编辑距离、不同子序列数等都属于这一类。它和"决策序列长成树"的回溯不同——序列 DP 不要求枚举所有可能性,只要把"以某个位置为终点 / 前缀考虑到某个位置"的最优值记录下来,转移时复用即可。

朴素思路是枚举所有子序列。n 个元素的子序列共 2^n 个,再判断递增 / 配对,复杂度 O(2^n × n)n = 30 已不可行。序列 DP 的核心 invariant 是子序列的最优结构可以挂在某个"端点"上:以 nums[i] 结尾的最长递增子序列长度,等于"前面所有可接的 jf[j] 的最大值再加一"。把所有可能的端点用 O(n²) 枚举完,就得到全局答案。

状态定义的两种约定

序列 DP 的状态定义有两类常用约定:

  • 约定 A:以 i 结尾f[i] 表示"以 nums[i] 结尾的最优值",强制 nums[i] 被选进序列。用在 LIS、最大子段和等"子数组 / 单序列必含末位元素"的场景。最终答案常常是 max(f),因为最优解不一定恰好以最后一个元素结尾。
  • 约定 B:前 if[i] 表示"考虑 nums[0..i-1] 的最优值",是否使用 nums[i - 1] 不强制。用在 LCS、编辑距离、打家劫舍等"双序列 / 不强制末位"的场景。最终答案就是 f[n],边界 f[0] 是"空序列"的解。

子数组类(连续段)多用约定 A;子序列类(不必连续)和双序列类多用约定 B。两者在转移与边界上各有取舍。

转移:枚举端点 / 配对

约定 A 下,f[i] 通常由"前面某个 f[j]j < i)"加上一步贡献得到。LIS 的转移是 f[i] = 1 + max(f[j]),其中 j < inums[j] < nums[i]。这是一种"枚举上一个端点"的形式,内层循环扫一遍 [0, i),整体 O(n²)

约定 B 下,双序列 DP 的转移看两个端点 nums1[i - 1]nums2[j - 1] 的关系:相等就走对角线(同时使用),不等就在"放弃 nums1[i - 1]" / "放弃 nums2[j - 1]"中取最优。状态空间 O(mn),整体 O(mn)

LIS 的二分优化

LIS 的 O(n²) 转移每个 i 都要扫前缀里的"最大 f[j]",这个最大值可以用辅助数据结构 tails[] 维护——tails[k] 表示"所有长度为 k + 1 的递增子序列里、最后一个元素的最小值"。对每个新进来的 x,在 tails[] 里二分找位置:能扩展就追加,不能就替换。复杂度降到 O(n log n),是 LIS 的"经典加速",常被称为 patience sort。本章模板用 O(n²) 版本,二分优化的具体写法留到 §3 母题里。

三类速查:单序列 / 双序列 / 子数组 vs 子序列

序列 DP 再抽象一层有三类。单序列:状态只跟"当前考虑到第几个元素"有关。

状态定义典型转移
LC 300 LISf[i] = 以 nums[i] 结尾的 LIS 长度f[i] = max(f[j]) + 1j<i 且满足条件
LC 53 最大子段和f[i] = 以 nums[i] 结尾的最大子段和f[i] = max(f[i-1] + nums[i], nums[i])
LC 198 打家劫舍f[i] = 前 i 个房屋能偷的最大金额f[i] = max(f[i-1], f[i-2] + nums[i])

双序列:两个指针 + 二维状态。

状态定义转移核心
LC 1143 LCSf[i][j] = s[0..i]t[0..j] 的 LCS 长度字符相等就 +1,不等取 max
LC 72 编辑距离f[i][j] = s[0..i] 改成 t[0..j] 的最少操作三选一:插入 / 删除 / 替换
LC 583 删除操作f[i][j] = 让 s[0..i]t[0..j] 相同的最少删除s[i]t[j]s[i]==t[j] 跳过

子数组(连续) vs 子序列(不必连续) 是常见踩坑点:子数组必须连续(LC 53、LC 718),状态通常用约定 A "以 i 结尾"——因为子数组要连续;子序列可以跳着选(LC 300、LC 1143),两种约定都能写,但 LIS 这种"严格递增"约束下"以 i 结尾"更自然,LCS 这种"不要求末位必选"则"前 i 个"更顺。口诀:子数组用 A,子序列 / 双序列 用 B,80% 情况成立。


2. 通用模板

序列 DP 的最小骨架按"单 / 双序列"分两类。单序列以"枚举端点 j < i"为主,复杂度 O(n²);双序列以"两层下标 i, j"为主,复杂度 O(mn)。下面给两类的最小可运行模板,覆盖 LIS / LCS / 编辑距离三种代表题的状态机。

def lis_n2(nums):
    """单序列 DP(约定 A):以 i 结尾的最长递增子序列"""
    n = len(nums)
    if n == 0:
        return 0
    f = [1] * n                                      # 每个位置至少自成长度 1
    for i in range(1, n):
        for j in range(i):                           # 枚举上一个端点
            if nums[j] < nums[i]:                    # 严格递增条件
                f[i] = max(f[i], f[j] + 1)
    return max(f)                                    # LIS 不一定以末位结尾


def lcs(s, t):
    """双序列 DP(约定 B):最长公共子序列长度"""
    m, n = len(s), len(t)
    f = [[0] * (n + 1) for _ in range(m + 1)]        # 多开一行一列存边界
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if s[i - 1] == t[j - 1]:                 # 字符相等:走对角线 +1
                f[i][j] = f[i - 1][j - 1] + 1
            else:                                    # 不等:放弃 s[i-1] 或 t[j-1]
                f[i][j] = max(f[i - 1][j], f[i][j - 1])
    return f[m][n]
// 单序列 DP(约定 A):LIS 的 O(n²) 版本
static int lisN2(int[] nums) {
    int n = nums.length;
    if (n == 0) return 0;
    int[] f = new int[n];
    Arrays.fill(f, 1);                               // 每个位置自成长度 1
    int ans = 1;
    for (int i = 1; i < n; i++) {
        for (int j = 0; j < i; j++) {                // 枚举上一个端点
            if (nums[j] < nums[i]) {                 // 严格递增条件
                f[i] = Math.max(f[i], f[j] + 1);
            }
        }
        ans = Math.max(ans, f[i]);
    }
    return ans;
}

// 双序列 DP(约定 B):最长公共子序列
static int lcs(String s, String t) {
    int m = s.length(), n = t.length();
    int[][] f = new int[m + 1][n + 1];               // 多开一行一列存边界
    for (int i = 1; i <= m; i++) {
        for (int j = 1; j <= n; j++) {
            if (s.charAt(i - 1) == t.charAt(j - 1)) {
                f[i][j] = f[i - 1][j - 1] + 1;       // 对角线 +1
            } else {
                f[i][j] = Math.max(f[i - 1][j], f[i][j - 1]);
            }
        }
    }
    return f[m][n];
}
// 单序列 DP(约定 A):LIS 的 O(n²) 版本
int lisN2(const vector<int>& nums) {
    int n = (int)nums.size();
    if (n == 0) return 0;
    vector<int> f(n, 1);                             // 每个位置自成长度 1
    int ans = 1;
    for (int i = 1; i < n; i++) {
        for (int j = 0; j < i; j++) {                // 枚举上一个端点
            if (nums[j] < nums[i]) {                 // 严格递增条件
                f[i] = max(f[i], f[j] + 1);
            }
        }
        ans = max(ans, f[i]);
    }
    return ans;
}

// 双序列 DP(约定 B):最长公共子序列
int lcs(const string& s, const string& t) {
    int m = (int)s.size(), n = (int)t.size();
    vector<vector<int>> f(m + 1, vector<int>(n + 1, 0));
    for (int i = 1; i <= m; i++) {
        for (int j = 1; j <= n; j++) {
            if (s[i - 1] == t[j - 1]) {
                f[i][j] = f[i - 1][j - 1] + 1;       // 对角线 +1
            } else {
                f[i][j] = max(f[i - 1][j], f[i][j - 1]);
            }
        }
    }
    return f[m][n];
}

四个要注意的点:

  • 边界值:单序列约定 A 的 f 全初始化为 1(每个位置自成长度 1 的子序列)。双序列约定 B 的 f[0][*] = f[*][0] = 0(空串的 LCS 长度为 0)。编辑距离的边界则不同——f[i][0] = i, f[0][j] = j。边界写错整张表都跟着错。
  • 下标偏移:双序列 DP 里 f[i][j] 表示"前 i 个字符 / 前 j 个字符",所以对应原串位置是 s[i - 1]t[j - 1]。写成 s[i] 是常见 off-by-one 错。
  • 最终答案位置:约定 A 通常 max(f);约定 B 通常 f[m][n]。LIS 是约定 A、LCS 是约定 B,记错就 WA。
  • 空间优化:双序列 DP 的 f[i][j] 只依赖上一行和当前行的左侧值,可滚动到 O(min(m, n))。但代码可读性下降,工程上先写二维再压。

3. 母题精讲

LeetCode 300. 最长递增子序列

链接:LeetCode 300

题意:给一个整数数组 nums,求最长严格递增子序列的长度。子序列不要求连续,只要元素相对次序不变即可。

数据范围:

  • 1 ≤ nums.length ≤ 2500
  • -10⁴ ≤ nums[i] ≤ 10⁴

示例:

输入:nums = [10, 9, 2, 5, 3, 7, 101, 18]
输出:4
解释:最长递增子序列是 [2, 3, 7, 101],长度 4。注意 [2, 5, 7, 18] 也是合法答案之一,长度同样 4。

进阶:能不能在 O(n log n) 内做?

暴力思路:枚举所有子序列

最朴素的解法是枚举所有 2ⁿ 个子序列,逐个检查是不是严格递增。n = 2500 时这个量级是天文数字,连样例都跑不出来。

进一步剪枝:用回溯枚举所有递增子序列,每一步要么"跳过当前元素"、要么"接到当前递增前缀后面"。这相当于在一棵深度 n 的二叉决策树上搜索,复杂度仍是 O(2ⁿ)。题目给到 n = 2500,朴素枚举无解。

O(n²) DP:以 i 结尾

转换视角:与其枚举"全局子序列",不如枚举"以哪个位置结尾"。

定义 f[i] = "以 nums[i] 结尾的最长严格递增子序列的长度"。这个定义有个关键约束——nums[i] 必须被选进序列。

转移:要让 nums[i] 接在某个更短的递增子序列后面,只能接在某个 f[j]j < inums[j] < nums[i])后面。所以:

f[i] = 1 + max(f[j] for j < i if nums[j] < nums[i])

如果前面没有任何 j 满足 nums[j] < nums[i],那就 f[i] = 1(只有它自己)。

边界:f[0] = 1(数组里第一个元素自成一个长度为 1 的子序列)。

最终答案是 max(f[i] for i in range(n))——因为题目问"最长",而 LIS 不一定以最后一个元素结尾。

下面走一遍 nums = [10, 9, 2, 5, 3, 7, 101, 18] 的填表:

i        0   1   2   3   4   5   6   7
nums[i]  10  9   2   5   3   7   101 18
f[i]     1   1   1   2   2   3   4   4

f[3] = 2:因为 nums[3] = 5 > nums[2] = 2,能接在 f[2] = 1 后面,得 1 + 1 = 2

f[5] = 3:从 f[2](值 1,2 < 7)、f[3](值 2,5 < 7)、f[4](值 2,3 < 7)三个里取最大,2 + 1 = 3

f[6] = 4f[5] = 3nums[5] = 7 < 101,所以 3 + 1 = 4

最大值是 f[6] = f[7] = 4,答案 4。

下面用 ArrayViz 走一遍前几步的填表:

LIS 填表过程(前 6 步)

f[i] = 1 + max(f[j]),j<i 且 nums[j]<nums[i]

Step 1 / 6i=0: 边界 f[0]=1
index
012345
value
10
9
2
5
3
7
f[0]
已填好当前填max 来源未处理
01/06

复杂度 O(n²) 时间、O(n) 空间。n = 2500 时大概 6 × 10⁶ 次基本操作,C++ / Java / Go 跑得轻松,Python 也基本能压在 1s 内。

class Solution:
    def lengthOfLIS_n2(self, nums: list[int]) -> int:
        n = len(nums)
        f = [1] * n                               # 每个位置至少自成长度 1
        for i in range(1, n):
            for j in range(i):
                if nums[j] < nums[i]:             # 严格递增
                    f[i] = max(f[i], f[j] + 1)
        return max(f)

注意几个细节:

  • f = [1] * n 把所有边界都设好——任何位置至少能自成一个长度 1 的子序列。
  • nums[j] < nums[i] 是严格小于。如果题目改成"非严格递增"(允许相等),改成 <= 即可,但 LC 300 是严格的。
  • 最后返回 max(f) 而不是 f[-1],因为 LIS 不一定以最后一个元素结尾。

O(n log n) 优化:二分 + tails 数组

n = 2500 还能扛,但如果 n 涨到 10⁵O(n²) = 10¹⁰ 就跑不动了。换一个思路。

定义 tails[k] = "所有长度为 k + 1 的递增子序列里,最后一个元素的最小值"。这个数组有两个性质:

  1. 严格递增tails[0] < tails[1] < tails[2] < ...。证明:长度为 k + 1 的递增子序列尾部 x 一定来自某个长度为 k 的递增子序列尾部 y,且 y < x,所以最小化 tails[k + 1] 也得 > tails[k]
  2. tails 的长度就是当前 LIS 的长度

扫一遍 nums,对每个 x = nums[i]

  • 用二分在 tails 里找第一个 ≥ x 的位置 pos(即左边界二分)。
  • 如果 pos == len(tails):说明 x 比所有 tail 都大,可以"开新长度"——tails.append(x)
  • 否则用 x 替换 tails[pos]——更小的 tail 让后续更容易接上新元素。

走一遍 nums = [10, 9, 2, 5, 3, 7, 101, 18]

读 10:  tails = []        → append      → tails = [10]
读 9:   tails = [10]      → 替换 pos=0  → tails = [9]
读 2:   tails = [9]       → 替换 pos=0  → tails = [2]
读 5:   tails = [2]       → append      → tails = [2, 5]
读 3:   tails = [2, 5]    → 替换 pos=1  → tails = [2, 3]
读 7:   tails = [2, 3]    → append      → tails = [2, 3, 7]
读 101: tails = [2, 3, 7] → append      → tails = [2, 3, 7, 101]
读 18:  tails = [2, 3, 7, 101] → 替换 pos=3 → tails = [2, 3, 7, 18]

最终 len(tails) = 4,答案 4。注意 tails 数组不是 LIS 本身——它只是个辅助结构,长度等于答案。比如最后一步 tails = [2, 3, 7, 18],但 18 在原数组里在 101 之后,真实 LIS 是 [2, 3, 7, 101][2, 5, 7, 101]

为什么"替换"是对的?想象长度 2 的递增子序列里,尾部分别是 53——两个都有效,但 3 更小,未来更容易被某个新元素接上(接的条件是 > tail)。所以保留最小的那个永远不亏。

from bisect import bisect_left

class Solution:
    def lengthOfLIS(self, nums: list[int]) -> int:
        tails = []
        for x in nums:
            pos = bisect_left(tails, x)            # 第一个 ≥ x 的下标
            if pos == len(tails):
                tails.append(x)                    # 开新长度
            else:
                tails[pos] = x                     # 替换为更小的尾部
        return len(tails)

复杂度 O(n log n)——每个元素一次 O(log n) 二分。空间 O(n)tails 最长不超过 n)。

一个常见疑问:严格 vs 非严格对应 bisect_left 还是 bisect_right

  • 严格递增(<)→ bisect_left(找第一个 ≥ x 的位置,把它替换成 x)。
  • 非严格递增(,允许相等)→ bisect_right(找第一个 > x 的位置)。

LC 300 是严格的,用 bisect_left。这个区别很微妙,记错就 WA。

小结:朴素枚举 vs O(n²) DP vs O(n log n) 二分

思路时间空间写法
枚举所有子序列O(2ⁿ)O(n) 递归栈n ≤ 20 才能跑
O(n²) DP(以 i 结尾)O(n²)O(n)容易写,能输出方案
O(n log n) 二分 + tailsO(n log n)O(n)写起来要小心 bisect_left/right

工程上,n ≤ 2500O(n²) 已经足够;n 大于 10⁴ 时上 O(n log n)。两份代码都要会写,面试时面试官常追问"能不能更快"。


4. 例题(三道)

按"单序列 → 双序列匹配 → 双序列三选一"递进。

4.1 LC 300 最长递增子序列

回到本章开头那道题。上面已经讲完两种解法。下面给三语言版本。

from bisect import bisect_left

class Solution:
    def lengthOfLIS(self, nums: list[int]) -> int:
        # O(n log n) 版本:tails 数组 + 二分
        tails = []
        for x in nums:
            pos = bisect_left(tails, x)            # 严格递增用 bisect_left
            if pos == len(tails):
                tails.append(x)
            else:
                tails[pos] = x
        return len(tails)

    def lengthOfLIS_n2(self, nums: list[int]) -> int:
        # O(n²) DP 版本:以 i 结尾
        n = len(nums)
        f = [1] * n
        for i in range(1, n):
            for j in range(i):
                if nums[j] < nums[i]:
                    f[i] = max(f[i], f[j] + 1)
        return max(f)
class Solution {
    public int lengthOfLIS(int[] nums) {
        // O(n log n) 版本
        int[] tails = new int[nums.length];
        int size = 0;
        for (int x : nums) {
            int lo = 0, hi = size;
            while (lo < hi) {                       // 手写 bisect_left
                int mid = (lo + hi) >>> 1;
                if (tails[mid] < x) lo = mid + 1;
                else hi = mid;
            }
            tails[lo] = x;
            if (lo == size) size++;
        }
        return size;
    }
}
class Solution {
public:
    int lengthOfLIS(vector<int>& nums) {
        // O(n log n) 版本:tails + lower_bound 等价 bisect_left
        vector<int> tails;
        for (int x : nums) {
            auto it = lower_bound(tails.begin(), tails.end(), x);
            if (it == tails.end()) {
                tails.push_back(x);
            } else {
                *it = x;
            }
        }
        return (int)tails.size();
    }
};

复杂度 O(n log n) 时间、O(n) 空间。

Follow-up:LIS 是"单序列"。如果题目给两个序列,问公共子序列的最长长度呢?


4.2 LC 1143 最长公共子序列

链接:LeetCode 1143

题意:给两个字符串 text1text2,返回它们的最长公共子序列(LCS)的长度。子序列要求相对次序保持,但不必连续。没有公共子序列时返回 0。

数据范围:

  • 1 ≤ text1.length, text2.length ≤ 1000
  • 全为小写英文字母

示例:

输入:text1 = "abcde", text2 = "ace"
输出:3
解释:LCS 是 "ace",长度 3。

状态定义

定义 f[i][j] = text1[0..i-1]text2[0..j-1] 的 LCS 长度(约定 B,"前 i 个" / "前 j 个")。

边界:f[0][*] = f[*][0] = 0——任一串为空时 LCS 长度为 0。

转移

考虑 f[i][j] 怎么得来。看 text1[i - 1]text2[j - 1] 这两个字符:

  1. 相等text1[i - 1] == text2[j - 1]):把它们都"用掉",加入 LCS。f[i][j] = f[i - 1][j - 1] + 1
  2. 不相等:要么不用 text1[i - 1]、要么不用 text2[j - 1],取两者最大。f[i][j] = max(f[i - 1][j], f[i][j - 1])

为什么不考虑"两个都不用"?因为 f[i - 1][j - 1] ≤ f[i - 1][j] ≤ f[i][j],所以"两个都不用"这个情况已经被 f[i - 1][j]f[i][j - 1] 覆盖了。

填表演示

下面交互演示走 text1 = "abcde", text2 = "ace" 的关键填表步骤——金色格子是当前在算的,蓝色是它依赖的来源:

LCS("abcde", "ace") 填表演示

左上到右下逐行;相等取对角线 +1,不等取上/左的较大值

Step 1 / 6初始:第 0 行和第 0 列全为 0(任一空串的 LCS 长度是 0)
ace
0
0
0
0
a
0
·
·
·
b
0
·
·
·
c
0
·
·
·
d
0
·
·
·
e
0
·
·
·
当前填的格依赖来源未填
01/06

填表方向:从左上到右下逐行扫描,因为 f[i][j] 依赖 f[i-1][j-1]f[i-1][j]f[i][j-1],全在它的左上方向。

代码

class Solution:
    def longestCommonSubsequence(self, text1: str, text2: str) -> int:
        m, n = len(text1), len(text2)
        f = [[0] * (n + 1) for _ in range(m + 1)]
        for i in range(1, m + 1):
            for j in range(1, n + 1):
                if text1[i - 1] == text2[j - 1]:
                    f[i][j] = f[i - 1][j - 1] + 1
                else:
                    f[i][j] = max(f[i - 1][j], f[i][j - 1])
        return f[m][n]
class Solution {
    public int longestCommonSubsequence(String text1, String text2) {
        int m = text1.length(), n = text2.length();
        int[][] f = 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)) {
                    f[i][j] = f[i - 1][j - 1] + 1;
                } else {
                    f[i][j] = Math.max(f[i - 1][j], f[i][j - 1]);
                }
            }
        }
        return f[m][n];
    }
}
class Solution {
public:
    int longestCommonSubsequence(string text1, string text2) {
        int m = text1.size(), n = text2.size();
        vector<vector<int>> f(m + 1, vector<int>(n + 1, 0));
        for (int i = 1; i <= m; i++) {
            for (int j = 1; j <= n; j++) {
                if (text1[i - 1] == text2[j - 1]) {
                    f[i][j] = f[i - 1][j - 1] + 1;
                } else {
                    f[i][j] = max(f[i - 1][j], f[i][j - 1]);
                }
            }
        }
        return f[m][n];
    }
};

复杂度 O(mn) 时间、O(mn) 空间。

滚动数组优化到 O(n) 空间

f[i][j] 只依赖 f[i - 1][*]f[i][j - 1],即"上一行 + 当前行已填部分"。所以只需两行:

class Solution:
    def longestCommonSubsequence(self, text1: str, text2: str) -> int:
        m, n = len(text1), len(text2)
        prev = [0] * (n + 1)
        curr = [0] * (n + 1)
        for i in range(1, m + 1):
            for j in range(1, n + 1):
                if text1[i - 1] == text2[j - 1]:
                    curr[j] = prev[j - 1] + 1
                else:
                    curr[j] = max(prev[j], curr[j - 1])
            prev, curr = curr, [0] * (n + 1)
        return prev[n]

空间 O(n)。可以进一步压成单行,但要小心更新顺序(需要先存 prev[j - 1]),代码反而不清晰,工程上两行版可读性更好。

Follow-up:LCS 是"两串都不修改,找最长公共"。如果允许在 s 上做"插入 / 删除 / 替换"操作把它变成 t,最少多少步?


4.3 LC 72 编辑距离

链接:LeetCode 72

题意:给两个字符串 word1word2,返回将 word1 改成 word2 所需的最少操作数。允许的操作是:插入一个字符、删除一个字符、替换一个字符。

数据范围:

  • 0 ≤ word1.length, word2.length ≤ 500
  • 全为小写英文字母

示例:

输入:word1 = "horse", word2 = "ros"
输出:3
解释:
horse → rorse  (把 'h' 替换成 'r')
rorse → rose   (删除 'r')
rose  → ros    (删除 'e')

状态定义

定义 f[i][j] = 把 word1[0..i-1] 改成 word2[0..j-1] 的最少操作数。约定 B。

边界:

  • f[0][j] = j:把空串改成长度 j 的字符串,需要 j 次插入。
  • f[i][0] = i:把长度 i 的字符串改成空串,需要 i 次删除。

转移:三选一

考虑 f[i][j]。看最后一步操作是什么:

  1. word1[i - 1] == word2[j - 1]:当前字符已经一样,不用动,f[i][j] = f[i - 1][j - 1]

  2. 字符不同时,三选一

    • 替换:把 word1[i - 1] 换成 word2[j - 1],前 i - 1j - 1 已对齐。f[i][j] = f[i - 1][j - 1] + 1
    • 删除:把 word1[i - 1] 删掉,对齐 word1[0..i-2]word2[0..j-1]f[i][j] = f[i - 1][j] + 1
    • 插入:在 word1 末尾插入 word2[j - 1],对齐 word1[0..i-1]word2[0..j-2]f[i][j] = f[i][j - 1] + 1

三种取最小:f[i][j] = 1 + min(f[i - 1][j - 1], f[i - 1][j], f[i][j - 1])

填表演示

word1 = "horse", word2 = "ros",下面 6 步动画展示关键格——金色当前格、蓝色来源、formula 显式列出 min 取哪个:

Edit Distance("horse", "ros") 填表演示

相等取对角线;不等三选一(替换 / 删除 / 插入)

Step 1 / 6边界:第 0 行 = j(全插),第 0 列 = i(全删)
ros
0
1
2
3
h
1
·
·
·
o
2
·
·
·
r
3
·
·
·
s
4
·
·
·
e
5
·
·
·
当前填的格依赖来源未填
01/06

把每一格的"来源箭头"画出来就能恢复最优操作序列——这就是 DP 路径恢复的常见做法。

代码

class Solution:
    def minDistance(self, word1: str, word2: str) -> int:
        m, n = len(word1), len(word2)
        f = [[0] * (n + 1) for _ in range(m + 1)]
        for i in range(m + 1):
            f[i][0] = i                            # 全删
        for j in range(n + 1):
            f[0][j] = j                            # 全插
        for i in range(1, m + 1):
            for j in range(1, n + 1):
                if word1[i - 1] == word2[j - 1]:
                    f[i][j] = f[i - 1][j - 1]      # 无操作
                else:
                    f[i][j] = 1 + min(
                        f[i - 1][j - 1],           # 替换
                        f[i - 1][j],               # 删除
                        f[i][j - 1],               # 插入
                    )
        return f[m][n]
class Solution {
    public int minDistance(String word1, String word2) {
        int m = word1.length(), n = word2.length();
        int[][] f = new int[m + 1][n + 1];
        for (int i = 0; i <= m; i++) f[i][0] = i;
        for (int j = 0; j <= n; j++) f[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)) {
                    f[i][j] = f[i - 1][j - 1];
                } else {
                    f[i][j] = 1 + Math.min(
                        f[i - 1][j - 1],
                        Math.min(f[i - 1][j], f[i][j - 1])
                    );
                }
            }
        }
        return f[m][n];
    }
}
class Solution {
public:
    int minDistance(string word1, string word2) {
        int m = word1.size(), n = word2.size();
        vector<vector<int>> f(m + 1, vector<int>(n + 1, 0));
        for (int i = 0; i <= m; i++) f[i][0] = i;   // 全删
        for (int j = 0; j <= n; j++) f[0][j] = j;   // 全插
        for (int i = 1; i <= m; i++) {
            for (int j = 1; j <= n; j++) {
                if (word1[i - 1] == word2[j - 1]) {
                    f[i][j] = f[i - 1][j - 1];      // 无操作
                } else {
                    f[i][j] = 1 + min({
                        f[i - 1][j - 1],            // 替换
                        f[i - 1][j],                // 删除
                        f[i][j - 1]                 // 插入
                    });
                }
            }
        }
        return f[m][n];
    }
};

复杂度 O(mn) 时间、O(mn) 空间。和 LCS 一样可以滚动数组压到 O(n)

注意"插入"和"删除"在 DP 表上是对称的——给 word1 末尾插入一个字符,等价于在 word2 末尾删一个。所以 f[i - 1][j]f[i][j - 1] 二选一,两个方向。


5. 边界、易错与复杂度

索引偏移:s[i - 1] 还是 s[i]

双序列 DP 里 f[i][j] 通常表示"前 i 个字符",所以当前字符是 s[i - 1]t[j - 1]。新手最常踩的坑是把 s[i] 写到代码里——要么数组越界,要么少考虑了第一个字符。

口诀:DP 数组下标 i ∈ [0, m],原始字符串下标 i - 1 ∈ [0, m - 1]

边界初始化方向

LCS 第 0 行 / 第 0 列都是 0(空串和任何串的 LCS 是 0)。

编辑距离第 0 行是 0, 1, 2, ..., n(空串变 j 个字符要 j 次插入),第 0 列是 0, 1, 2, ..., mi 个字符变空串要 i 次删除)。

LIS 没有"第 0 个位置",所有 f[i] 都初始化为 1。

记错边界 → 整张表全错。写前先想清楚"f[0][*]f[*][0] 各代表什么意思"。

严格 vs 非严格 LIS

LC 300 是严格递增(<),用 bisect_left

如果题目改成"非严格"(,允许相等),用 bisect_right。LC 1671 让山形数组最少删除元素就要小心这个区别。

子数组 vs 子序列

LC 718 最长重复子数组(连续)和 LC 1143 LCS(不连续)状态转移完全不同——子数组要求连续,所以 f[i][j] 必须以 s[i-1]t[j-1] 同时结尾,不匹配就归 0;LCS 不要求连续,不匹配也能继承上一行 / 上一列。

f[i][j] 的定义要写清楚是"结尾"还是"前 i 个"。

Python 滚动数组的更新顺序

LCS / 编辑距离压到单行 f[j] 时,从左到右更新会覆盖 f[i - 1][j - 1](已经被新值替换)。两种解法:

  • 用两行 prevcurr,写起来最清晰。
  • 用单行 + 一个临时变量 pre = f[j - 1] 保存对角线值,每次更新后 pre = old_f_j。代码紧凑但容易出错。

工程上建议先写二维,跑通后再考虑压一维。

复杂度速查

时间空间
LC 300 LIS O(n²)O(n²)O(n)
LC 300 LIS O(n log n)O(n log n)O(n)
LC 1143 LCSO(mn)O(mn)O(n) 滚动
LC 72 编辑距离O(mn)O(mn)O(n) 滚动

Pro 会员

解锁完整北美 OA 题库

165 家公司 / 1000+ 道真题 · Python · Java · C++ 三语题解 · 月度更新


7. 小结

序列 DP 是动态规划里入门门槛最低、但变种最多的一族。掌握这一章后能搞定 LC 上一大半的字符串 / 数组 DP 题。三件事要记牢:

  • 状态定义:单序列常用"以 i 结尾"(约定 A),双序列 / 子序列用"前 i 个"(约定 B)。状态定义决定转移和边界。
  • 子数组 vs 子序列:连续的归 0,不连续的可继承。LCS 和最长重复子数组是经典对照。
  • LIS 优化:朴素 DP O(n²)n ≥ 10⁴ 时上 tails + 二分 O(n log n)。注意 bisect_left(严格)和 bisect_right(非严格)的区别。

下一节会进入划分型 DP——把序列切成若干段,每段独立最优。LC 132 分割回文串 II、LC 343 整数拆分都是这一族。划分型和序列型的区别在于"切点"成了新的枚举对象。


对应灵神题单:动态规划 · 单序列 / 双序列 / 编辑距离

进阶阅读:灵茶山艾府《序列 DP 题单》;LeetCode DP 标签 高频题

On this page