最长公共子序列(LCS)
最长公共子序列(LCS)
核心结论
最长公共子序列(Longest Common Subsequence,LCS)要求元素的相对顺序保持不变,但不要求它们在原序列中连续。
对长度分别为 m 和 n 的两个序列,经典动态规划算法的时间复杂度为 ,空间复杂度为 。
1. 问题定义
给定两个序列:
寻找一个最长序列:
使得 Z 同时是 X 和 Y 的子序列。
1.1 子序列
若存在严格递增的下标序列:
使得:
则称 Z 是 X 的子序列。
例如,对于 X = ABCBDAB,BCBA 是 X 的子序列,可以依次选择:
B(2) → C(3) → B(4) → A(6)
子序列与子串
- 子序列只要求相对顺序一致,不要求连续。
- 子串要求字符在原字符串中连续出现。
2. 基本思想
LCS 具有动态规划的两个典型性质:
- 最优子结构:原问题的最优解可以由规模更小的子问题最优解构成。
- 重叠子问题:直接递归时,同一对前缀会被重复求解。
考虑两个前缀:
比较它们的最后一个元素 和 。
2.1 当前元素相等
若 ,则该元素可以作为当前公共子序列的最后一个元素。
删除这个相同元素后,问题转化为两个更短前缀的 LCS:
2.2 当前元素不相等
若 ,则 与 不可能同时作为 LCS 的最后一个匹配元素,因此至少要舍弃其中一个:
- 舍弃 ,得到子问题 ;
- 舍弃 ,得到子问题 。
应保留两种方案中的较优者:
3. 状态定义与递推关系
定义:
即, 表示 X 的前 i 个元素与 Y 的前 j 个元素的最长公共子序列长度。
3.1 边界条件
只要有一个前缀为空,公共子序列长度就是 0:
3.2 状态转移方程
记忆方式
- 当前元素相等:取左上角加 1。
- 当前元素不相等:取上方和左方的较大值。
4. 具体过程
考虑示例:
X = ABCBDAB
Y = BDCABA
其中 ,。
4.1 初始化
创建一个大小为 的二维数组 c。
将第 0 行和第 0 列全部初始化为 0,因为空序列与任意序列的 LCS 长度都为 0。
4.2 按子问题规模填表
按照 和 的顺序计算每个状态:
- 比较 与 ;
- 若相等,使用左上角状态;
- 若不相等,比较上方状态和左方状态。
得到动态规划表:
| c(i, j) | ∅ | B | D | C | A | B | A |
|---|---|---|---|---|---|---|---|
| ∅ | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| A | 0 | 0 | 0 | 0 | 1 | 1 | 1 |
| B | 0 | 1 | 1 | 1 | 1 | 2 | 2 |
| C | 0 | 1 | 1 | 2 | 2 | 2 | 2 |
| B | 0 | 1 | 1 | 2 | 2 | 3 | 3 |
| D | 0 | 1 | 2 | 2 | 2 | 3 | 3 |
| A | 0 | 1 | 2 | 2 | 3 | 3 | 4 |
| B | 0 | 1 | 2 | 2 | 3 | 4 | 4 |
右下角状态为:
所以最长公共子序列的长度为:
5. 从动态规划表回溯
动态规划表给出了 LCS 的长度。要恢复一个具体的 LCS,需要从右下角状态 逆向追踪状态转移。
5.1 回溯规则
当前位于状态 。
规则一:当前元素相等
若 :
- 将该元素加入结果;
- 向左上角移动到 。
规则二:当前元素不相等
比较:
- 若上方较大,向上移动到 ,相当于舍弃 ;
- 若左方较大,向左移动到 ,相当于舍弃 ;
- 若两者相等,两个方向都可能通向某个 LCS。
平局与多解
只要求一个 LCS 时,可以规定固定的平局策略,例如优先向上。
若要求所有 LCS,则需要同时搜索两个方向,并对结果去重。
5.2 本例回溯
约定平局时优先向上。
| 状态 | 字符比较 | 操作 |
|---|---|---|
| (7, 6) | B ≠ A | 上、左均为 4,向上 |
| (6, 6) | A = A | 记录 A,向左上 |
| (5, 5) | D ≠ B | 上方较大,向上 |
| (4, 5) | B = B | 记录 B,向左上 |
| (3, 4) | C ≠ A | 左方较大,向左 |
| (3, 3) | C = C | 记录 C,向左上 |
| (2, 2) | B ≠ D | 左方较大,向左 |
| (2, 1) | B = B | 记录 B,向左上 |
回溯过程中依次记录:
A B C B
由于回溯方向是从序列末尾向前,因此需要反转:
B C B A
所以得到一个最长公共子序列:
本例的 LCS 不唯一,BCAB 和 BDAB 也都是长度为 4 的最长公共子序列。
6. 伪代码
6.1 构造动态规划表
LCS-LENGTH(X, Y)
m ← length(X)
n ← length(Y)
创建二维数组 c[0..m][0..n]
for i ← 0 to m
c[i][0] ← 0
for j ← 0 to n
c[0][j] ← 0
for i ← 1 to m
for j ← 1 to n
if X[i] = Y[j]
c[i][j] ← c[i-1][j-1] + 1
else
c[i][j] ← max(c[i-1][j], c[i][j-1])
return c
6.2 回溯恢复一个 LCS
RECONSTRUCT-LCS(X, Y, c)
i ← length(X)
j ← length(Y)
result ← empty list
while i > 0 and j > 0
if X[i] = Y[j]
append X[i] to result
i ← i - 1
j ← j - 1
else if c[i-1][j] ≥ c[i][j-1]
i ← i - 1
else
j ← j - 1
reverse(result)
return result
6.3 完整算法
LCS(X, Y)
c ← LCS-LENGTH(X, Y)
sequence ← RECONSTRUCT-LCS(X, Y, c)
return c[length(X)][length(Y)], sequence
7. 正确性要点
7.1 当前元素相等
若 ,则存在一个当前前缀的 LCS 以该相同元素结尾。
删除这个末尾元素后,剩余部分必须是两个更短前缀的 LCS,否则可以用更长的公共子序列替换它,从而与当前解的最优性矛盾。
因此:
7.2 当前元素不相等
若 ,则任意公共子序列不可能同时以这两个不同元素结尾。
因此,一个最优解至少满足以下情况之一:
- 不使用 ;
- 不使用 。
于是:
这两个分支覆盖了所有可能的最优解。
8. 时间与空间复杂度
设两个序列的长度分别为 m 和 n。
8.1 时间复杂度
动态规划表共有 个状态,每个状态只进行常数次操作,因此填表时间为:
回溯过程中,每一步至少使 i 或 j 减少 1,所以回溯时间为:
总时间复杂度由填表阶段主导:
8.2 空间复杂度
保存完整动态规划表需要:
若只求 LCS 长度,可以使用滚动数组,将空间复杂度优化为:
空间优化的限制
普通滚动数组不会保存完整的状态转移路径,因此不能直接用于回溯恢复具体 LCS。
若希望在线性空间内恢复 LCS,可以进一步学习 Hirschberg 算法。
9. 与最长公共子串的区别
| 对比项 | 最长公共子序列 | 最长公共子串 |
|---|---|---|
| 是否要求连续 | 否 | 是 |
| 当前元素相等 | 左上角加 1 | 左上角加 1 |
| 当前元素不相等 | 取上方、左方最大值 | 置 0 |
| 最终答案位置 | 右下角状态 | 整张表的最大值 |
| 本例结果 | BCBA 等 | AB 或 BD |
详见 最长公共子串。
10. 易错点
- 混淆子序列与子串:子序列不要求连续,子串必须连续。
- 字符不等时向左上移动:字符不等时只能根据上方和左方状态决定方向。
- 将整张表最大值当作答案:LCS 的答案是右下角状态 。
- 回溯后忘记反转:回溯得到的字符顺序与最终 LCS 相反。
- 认为 LCS 必然唯一:平局状态可能对应多个不同的 LCS。
- 使用滚动数组后仍直接回溯:滚动数组没有保存完整路径。
一句话总结
当前元素相等时,选择该元素并向左上移动;当前元素不相等时,沿着较大的状态向上或向左移动。
11. 关联笔记
- 动态规划
- 最长公共子串
- Hirschberg 算法
12. 参考资料
- Cormen, Thomas H.; Leiserson, Charles E.; Rivest, Ronald L.; Stein, Clifford. Introduction to Algorithms, 3rd ed., Section 15.4, “Longest Common Subsequence”.
- Erik D. Demaine and Charles E. Leiserson. Introduction to Algorithms, Lecture 12: Dynamic Programming, 2005.
- 课程讲义:lec12.pdf