最长公共子序列(LCS):从"Git 差异对比"到"DNA 基因比对"的核心算法
关键词:最长公共子序列、动态规划、字符串匹配、算法面试、LCS
📑 目录
- 为什么 Git 能一眼看出两段代码的不同?
- 子序列 ≠ 子串:一字之差,天壤之别
- 暴力破解为什么不行?------指数级灾难
- 动态规划登场:从小问题推导大答案
- 状态定义与状态转移方程(核心公式)
- 手动填表:一张图看懂 LCS 的计算过程
- Python 代码实现(三个版本,由浅入深)
- 7.1 基础二维 DP 版
- 7.2 空间优化版(一维数组)
- 7.3 回溯法:把最长公共子序列"找"出来
- LCS vs 最长公共子串:一字之差,算法迥异
- 复杂度分析与优化思路
- 总结与学习路线
1. 为什么 Git 能一眼看出两段代码的不同?
当你在 Git 中执行 git diff 时,它会输出这样的结果:
diff
- def add(a, b):
- return a + b
+ def sum_numbers(x, y):
+ return x + y
Git 是怎么知道"add 对应 sum_numbers","a 和 b 对应 x 和 y"的?为什么它不认为这是两段完全不同的代码?
答案是:Git 底层用到了 LCS(最长公共子序列)算法。
LCS 找到两个字符串(或文本行序列)之间"最长的、按顺序出现的、但不一定连续"的共同部分。Git 把相同的行作为"锚点",把不同的行标记为删除和新增,从而生成我们看到的直观差异报告。
类似的场景还有很多:
- DNA 序列比对:生物学家用 LCS 判断两个物种的基因相似度
- 论文查重:检测两篇文章的相似段落
- 版本控制:SVN、Git 的差异对比引擎
- 面试算法题:这是 LeetCode 1143 题,也是动态规划入门必刷题
💡 一句话理解:LCS 是衡量两个序列"相似度"的一把精密尺子------找出它们之间"最长的那段共同骨架"。
2. 子序列 ≠ 子串:一字之差,天壤之别
这是初学者最容易踩的坑,必须一开始就讲清楚。
| 概念 | 含义 | 是否要求连续? | 举例(字符串 "ABCD") |
|---|---|---|---|
| 子串 | 原字符串中连续的一段 | ✅ 必须连续 | "AB"、"BC"、"CD"、"ABC";"AC" ❌ 不是子串 |
| 子序列 | 原字符串中按顺序 出现,但不要求连续 | ❌ 不要求连续 | "AB"、"AD"、"ACD"、"ABCD" 都是子序列 |
举个例子:
原字符串:"ABCDE"
- 子串必须是连续的:
"ABC"是子串,"ACE"不是 子串(中间隔了B、D) - 子序列只要保证相对顺序:
"ACE"是子序列(A在第1位,C在第3位,E在第5位,顺序一致)
LCS 要找的就是 :在两个字符串中,找出一个共同子序列,这个子序列的长度是所有共同子序列中最长的。
比如:
str1 = "ABCBDAB"str2 = "BDCABA"- LCS 长度为 4,其中一个结果是
"BCBA"(不一定唯一)
3. 暴力破解为什么不行?------ 指数级灾难
如果不用动态规划,最直观的方法是:
- 找出
str1的所有子序列 - 检查每个子序列是否也在
str2中 - 记录最长的那个
问题出在哪里?
一个长度为 n n n 的字符串,子序列的数量是 2 n 2^n 2n(每个字符可以选择"取"或"不取")。
如果 n = 100 n=100 n=100,子序列数量是 2 100 ≈ 1.27 × 10 30 2^{100} \approx 1.27 \times 10^{30} 2100≈1.27×1030------全宇宙的原子数量大约是 10 80 10^{80} 1080,虽然宇宙原子多一点,但你的电脑绝对算不完。
所以,我们需要动态规划。
4. 动态规划登场:从小问题推导大答案
LCS 具有最优子结构性质,这意味着:大问题的最优解,可以由小问题的最优解组合而成。
核心思想 :比较两个字符串的最后一个字符,分两种情况讨论。
假设字符串 X X X 和 Y Y Y,长度分别为 m m m 和 n n n:
情况一:最后一个字符相同
如果 X m − 1 = = Y n − 1 Xm-1 == Yn-1 Xm−1==Yn−1,那这个字符一定属于 LCS。问题就变成了:
L C S ( X , Y ) = L C S ( X : − 1 , Y : − 1 ) + X m − 1 LCS(X, Y) = LCS(X:-1, Y:-1) + Xm-1 LCS(X,Y)=LCS(X:−1,Y:−1)+Xm−1
情况二:最后一个字符不同
如果 X m − 1 ≠ Y n − 1 Xm-1 \neq Yn-1 Xm−1=Yn−1,那 LCS 要么在 X : − 1 X:-1 X:−1 和 Y Y Y 之间,要么在 X X X 和 Y : − 1 Y:-1 Y:−1 之间,取两者中较长的一个:
L C S ( X , Y ) = max ( L C S ( X : − 1 , Y ) , L C S ( X , Y : − 1 ) ) LCS(X, Y) = \max(LCS(X:-1, Y), LCS(X, Y:-1)) LCS(X,Y)=max(LCS(X:−1,Y),LCS(X,Y:−1))
这个递归关系,就是动态规划的状态转移方程。
5. 状态定义与状态转移方程(核心公式)
状态定义:
设 d p i j dpij dpij 表示:字符串 X X X 的前 i i i 个字符 与 字符串 Y Y Y 的前 j j j 个字符 的 LCS 长度。
- i i i 的范围: 0 ∼ m 0 \sim m 0∼m
- j j j 的范围: 0 ∼ n 0 \sim n 0∼n
- d p 0 j = 0 dp0j = 0 dp0j=0(空字符串与任何字符串的 LCS 长度为 0)
- d p i 0 = 0 dpi0 = 0 dpi0=0(任何字符串与空字符串的 LCS 长度为 0)
状态转移方程:
d p i j = { d p i − 1 j − 1 + 1 if X i − 1 = = Y j − 1 max ( d p i − 1 j , d p i j − 1 ) if X i − 1 ≠ Y j − 1 dpij = \begin{cases} dpi-1j-1 + 1 & \text{if } Xi-1 == Yj-1 \\ \max(dpi-1j, dpij-1) & \text{if } Xi-1 \neq Yj-1 \end{cases} dpij={dpi−1j−1+1max(dpi−1j,dpij−1)if Xi−1==Yj−1if Xi−1=Yj−1
💡 记忆口诀 :末尾相同就加一,末尾不同取最大。
6. 手动填表:一张图看懂 LCS 的计算过程
我们手动计算一个例子: X = "ABCBDAB" X = \text{"ABCBDAB"} X="ABCBDAB", Y = "BDCABA" Y = \text{"BDCABA"} Y="BDCABA"。
先看一个更简单的例子帮助理解: X = "ABC" X = \text{"ABC"} X="ABC", Y = "AC" Y = \text{"AC"} Y="AC"。
表格的行是 X X X 的字符(前面加一个空字符),列是 Y Y Y 的字符(前面加一个空字符)。
| dp | ∅ | A | C |
|---|---|---|---|
| ∅ | 0 | 0 | 0 |
| A | 0 | 1 | 1 |
| B | 0 | 1 | 1 |
| C | 0 | 1 | 2 |
填表逻辑:
- 第1行第1列(
AvsA):相等 → 左上角0 + 1 = 1 - 第3行第2列(
CvsC):相等 → 左上角1 + 1 = 2 - 结果: d p 3 2 = 2 dp32 = 2 dp32=2,LCS 是
"AC"
接下来是完整的 Python 代码实现,它会自动帮你填好这张表。
7. Python 代码实现(三个版本)
7.1 基础二维 DP 版(最直观)
python
def lcs_length(text1: str, text2: str) -> int:
"""
计算两个字符串的最长公共子序列长度
LeetCode 1143 标准解法
"""
m, n = len(text1), len(text2)
# 创建 (m+1) x (n+1) 的 DP 表,全部初始化为 0
dp = [[0] * (n + 1) for _ in range(m + 1)]
# 填充 DP 表
for i in range(1, m + 1):
for j in range(1, n + 1):
if text1[i - 1] == text2[j - 1]:
# 字符匹配:左上角 + 1
dp[i][j] = dp[i - 1][j - 1] + 1
else:
# 字符不匹配:取上方和左方的最大值
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
return dp[m][n]
# 测试
text1 = "ABCBDAB"
text2 = "BDCABA"
print(f"LCS 长度: {lcs_length(text1, text2)}") # 输出: 4
7.2 空间优化版(一维数组,面试加分)
二维 DP 的空间复杂度是 O ( m × n ) O(m \times n) O(m×n)。但仔细观察可以发现,计算 d p i j dpij dpij 时,只需要当前行 和上一行的数据。
我们可以用一个长度为 n + 1 n+1 n+1 的一维数组来优化,空间复杂度降为 O ( n ) O(n) O(n)。
python
def lcs_length_optimized(text1: str, text2: str) -> int:
"""空间优化版 LCS:只保留一行 DP 数据"""
m, n = len(text1), len(text2)
# 确保 n 是较短的,以节省空间(可选优化)
if m < n:
text1, text2 = text2, text1
m, n = n, m
# dp[j] 表示当前行的第 j 列的值
dp = [0] * (n + 1)
for i in range(1, m + 1):
# prev 相当于 dp[i-1][j-1](左上角的值)
prev = 0
for j in range(1, n + 1):
# 保存当前 dp[j] 作为下一轮的左上角值
temp = dp[j]
if text1[i - 1] == text2[j - 1]:
dp[j] = prev + 1
else:
# dp[j] 更新前是 dp[i-1][j](上方),dp[j-1] 是 dp[i][j-1](左方)
dp[j] = max(dp[j], dp[j - 1])
# 更新左上角缓存
prev = temp
return dp[n]
# 测试
print(f"优化版 LCS 长度: {lcs_length_optimized(text1, text2)}") # 输出: 4
💡 为什么要用
prev变量? 因为当j递增时,dp[j-1]已经被更新为当前行 的值,而我们需要的是上一行 的dp[j-1]作为"左上角"的候选值。prev保存了上一轮循环中的旧dp[j](即上一行的dp[j-1])。
7.3 回溯法:把最长公共子序列"找"出来
有时候,我们不仅要知道长度,还要知道那个具体的子序列是什么。
python
def lcs_with_backtracking(text1: str, text2: str) -> tuple:
"""
返回 LCS 长度和具体的 LCS 字符串
"""
m, n = len(text1), len(text2)
dp = [[0] * (n + 1) for _ in range(m + 1)]
# 1. 先填表
for i in range(1, m + 1):
for j in range(1, n + 1):
if text1[i - 1] == text2[j - 1]:
dp[i][j] = dp[i - 1][j - 1] + 1
else:
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
# 2. 回溯法从 DP 表中重建 LCS
def backtrack(i: int, j: int) -> str:
if i == 0 or j == 0:
return ""
if text1[i - 1] == text2[j - 1]:
# 匹配到字符,向左上角回溯
return backtrack(i - 1, j - 1) + text1[i - 1]
elif dp[i - 1][j] > dp[i][j - 1]:
# 上方值更大,向上回溯
return backtrack(i - 1, j)
else:
# 左方值更大或相等,向左回溯
return backtrack(i, j - 1)
lcs_str = backtrack(m, n)
return dp[m][n], lcs_str
# 测试
text1 = "ABCBDAB"
text2 = "BDCABA"
length, sequence = lcs_with_backtracking(text1, text2)
print(f"LCS 长度: {length}")
print(f"LCS 序列: {sequence}") # 输出可能是 "BCBA" 或 "BDAB"(不唯一)
输出:
LCS 长度: 4
LCS 序列: BCBA
⚠️ 注意 :LCS 的结果可能不唯一。
"ABCBDAB"和"BDCABA"的 LCS 可以是"BCBA",也可以是"BDAB"。回溯算法输出的只是其中一个结果。
8. LCS vs 最长公共子串:一字之差,算法迥异
这是面试中经常被追问的问题:
| 对比维度 | 最长公共子序列(LCS) | 最长公共子串 |
|---|---|---|
| 连续性 | 不要求连续 | 必须连续 |
| 状态转移 | 不匹配时取 max | 不匹配时直接归零 |
| DP 公式(不匹配时) | d p i j = max ( d p i − 1 j , d p i j − 1 ) dpij = \max(dpi-1j, dpij-1) dpij=max(dpi−1j,dpij−1) | d p i j = 0 dpij = 0 dpij=0 |
| 结果 | 长度可能很大 | 长度通常较短 |
| 应用 | Git diff、DNA 序列比对 | 字符串查找、病毒特征匹配 |
python
# 最长公共子串的 DP 核心代码(对比参考)
def longest_common_substring(text1, text2):
m, n = len(text1), len(text2)
dp = [[0] * (n + 1) for _ in range(m + 1)]
max_len = 0
for i in range(1, m + 1):
for j in range(1, n + 1):
if text1[i-1] == text2[j-1]:
dp[i][j] = dp[i-1][j-1] + 1
max_len = max(max_len, dp[i][j])
# 注意:不匹配时,dp[i][j] = 0(保持默认)
return max_len
💡 记忆口诀 :子串断了就归零,子序列断了取最大。
9. 复杂度分析与优化思路
| 版本 | 时间复杂度 | 空间复杂度 | 说明 |
|---|---|---|---|
| 基础二维 DP | O ( m × n ) O(m \times n) O(m×n) | O ( m × n ) O(m \times n) O(m×n) | 最直观,适合初学者 |
| 空间优化版(一维) | O ( m × n ) O(m \times n) O(m×n) | O ( min ( m , n ) ) O(\min(m, n)) O(min(m,n)) | 面试首选,节省内存 |
| 回溯版 | O ( m × n ) O(m \times n) O(m×n) | O ( m × n ) + O ( m + n ) O(m \times n) + O(m+n) O(m×n)+O(m+n) | 需要具体序列时使用 |
优化思路:
- 空间优化 :上面已经实现了一维 DP,空间复杂度降至 O ( min ( m , n ) ) O(\min(m, n)) O(min(m,n))。
- 对称优化 :如果 m ≪ n m \ll n m≪n,可以互换 t e x t 1 text1 text1 和 t e x t 2 text2 text2,让列数更短(空间优化版中已实现)。
- 剪枝:如果两个字符串完全相同,直接返回其中一个(提前退出),但实际中这种情况较少。
10. 总结与学习路线
核心知识点回顾
✅ LCS = 找出两个序列中最长的、按顺序出现但不一定连续的共同子序列
✅ 子序列 ≠ 子串 :子串必须连续,子序列只要求相对顺序
✅ 动态规划状态 : d p i j dpij dpij 表示 X : i X:i X:i 和 Y : j Y:j Y:j 的 LCS 长度
✅ 状态转移方程 :字符相同 → 左上角 + 1;字符不同 → max(上方,左方)
✅ 三种代码实现:基础二维、空间优化一维、回溯重建序列
学习路线图
| 阶段 | 目标 | 实践任务 |
|---|---|---|
| 第 1 天 | 理解概念与递推关系 | 在纸上手动为 "ABC" 和 "AC" 填一次 DP 表 |
| 第 3 天 | 熟练默写基础代码 | 手写基础二维 DP 代码,并通过 LeetCode 1143 |
| 第 1 周 | 掌握空间优化版 | 用一维数组重写 LCS,理解 prev 变量的作用 |
| 第 2 周 | 掌握回溯和变种 | 实现回溯重建 LCS,并对比最长公共子串的差异 |
| 第 3 周 | 实际应用 | 用 LCS 实现一个简单的 Git diff 行对比工具 |
推荐资源
- LeetCode 1143. 最长公共子序列
- LeetCode 718. 最长重复子数组(最长公共子串的变种)
- Git 源码:xdiff 库(LCS 在 Git 中的实际应用)