最近审阅:2026-10-09 · 先读:回溯与 DP

CAB 和 ACB 里,AB 都按同样顺序出现,但可以跳过字符。这是在找子序列,不是在找一段连续文本。最长公共子序列(LCS)长度为 2;CB 也是一个答案,所以“返回长度”和“返回唯一序列”不是一回事。

序列 DP 容易混,是因为状态常常只差几个字:以这个位置结尾,还是只看前这么多项?先把这一句写完整,递推式(recurrence relation)通常就有了落点。

LCS:两个前缀之间,最多能配上多少

$L(i,j)$ 表示第一个串前 $i$ 个字符、第二个串前 $j$ 个字符的 LCS 长度。空前缀的答案是 0。

  • 末尾字符相等:可以在两个更短前缀的 LCS 后接上这个字符,得到 $L(i-1,j-1)+1$。
  • 末尾不同:公共子序列不能同时使用这两个不同的末尾,至少跳过一个,取 $\max(L(i-1,j),L(i,j-1))$。

相等时为什么不需要额外比较跳过的两项?在任意最优匹配中,如果这个相等的末尾没有成对出现,可以将最后一对相同字符移到这两个末尾而不破坏此前顺序;若两边末尾都未使用,则还可以追加一对。因此总能选择一个使用这对末尾的最优解。

第一个串的前缀 \ 第二个串的前缀 空 A AC ACB
空 0 0 0 0
C 0 0 1 1
CA 0 1 1 1
CAB 0 1 1 2
动画 LCS:相同就斜着加一,不同就取上方或左方

这张图需要启用 JavaScript。

右下角是 2。表格中的 1 并不都来自同一个公共子序列;每个格子只保存它自己的最佳长度。

只求长度,两行就够

def lcs_length(first, second):
    previous = [0] * (len(second) + 1)
    for first_char in first:
        current = [0]
        for column, second_char in enumerate(second, start=1):
            if first_char == second_char:
                current.append(previous[column - 1] + 1)
            else:
                current.append(max(previous[column], current[-1]))
        previous = current
    return previous[-1]

previous[column] 是上方,current[-1] 是左方,previous[column-1] 是左上方。保留两行让这三种依赖很清楚,先别急着压成一行再保存“旧对角线”。

长度分别为 $n,m$,时间 $O(nm)$,辅助空间(auxiliary space) $O(m)$。可以把较短的串放第二个参数来减少空间。若要恢复一条公共子序列,保留全表,从右下角沿用到的转移回溯(backtracking);有并列时,制定一个规则即可,不必假装答案唯一。

LIS:状态不是“前面最好的”,而是“在这里结尾的”

最长严格递增子序列(LIS)的一种直接写法:

$$ D(i)=1+\max\bigl(\{D(j):j<i,\ a_j<a_i\}\cup\{0\}\bigr). $$

$D(i)$ 必须以 $a_i$ 结尾。这样才能判断新的数能否接上;最终答案才是所有 $D(i)$ 的最大值。双循环时间 $O(n^2)$、空间 $O(n)$,空数组返回 0。

[3,5,6,2,4] 对应 D=[1,2,3,1,2],答案 3。若只存“前面最长为 3”,却忘了结尾是 6,就可能误以为 4 可以直接接上。

用最小尾值压缩候选,但别把它当成答案序列

对每种长度,只留最小结尾:长度相同、结尾更小的递增序列,未来至少一样容易延长。tails[length-1] 就表示这个最小结尾。

新值 tails
3 [3]
5 [3,5]
6 [3,5,6]
2 [2,5,6]
4 [2,4,6]

最后的 [2,4,6] 不是原数组的子序列:6 在 2 和 4 前面。它是分别代表三种长度的摘要,长度 3 才是答案。

def lis_length(values):
    tails = []
    for value in values:
        position = bisect_left(tails, value)
        if position == len(tails):
            tails.append(value)
        else:
            tails[position] = value
    return len(tails)

先 from bisect import bisect_left。bisect_left 找第一个不小于当前值的位置,因此相等值不会把长度加一。对于非递减子序列,要改成 bisect_right 并相应改变题目条件;不是“两个函数都能用”。Python bisect 文档

每个元素做一次二分,时间 $O(n\log n)$,空间 $O(n)$。替换只是维护最小尾值,不是在原数组里交换元素。要恢复真实序列,还需记录每个长度对应的原下标与 predecessor。

相似题,别用同一套边界

问题 状态里的关键条件 不能照搬的地方
LCS 两个前缀,允许跳字 不相等时取上方 / 左方最大值
最长公共子串 当前两处结尾,要求连续 不相等时该格归零,不是沿用最大值
LIS 一个序列,按原顺序且严格递增 相等元素不能延长
编辑距离 两个前缀之间的最少操作数 空前缀边界是另一边的长度,不是全 0

试一下 [2,2,2]:严格 LIS 是 1,非递减版本是 3。再试空串与反序字符串。代码在 deeper_patterns.py,小输入用子序列枚举检查;推导练习可接着看 MIT 动态规划讲义。

本页术语 3
递推式 recurrence relation
说明一个子问题的答案怎样由其他子问题的答案得到
辅助空间 auxiliary space
除输入和输出外,算法执行时额外使用的空间,包括调用栈
回溯 backtracking
尝试一个选择,继续搜索,返回后撤销选择,再试其他分支