最长公共子序列(LCS)

最长公共子序列(LCS):从"Git 差异对比"到"DNA 基因比对"的核心算法

关键词:最长公共子序列、动态规划、字符串匹配、算法面试、LCS

📑 目录

  1. 为什么 Git 能一眼看出两段代码的不同?
  2. 子序列 ≠ 子串:一字之差,天壤之别
  3. 暴力破解为什么不行?------指数级灾难
  4. 动态规划登场:从小问题推导大答案
  5. 状态定义与状态转移方程(核心公式)
  6. 手动填表:一张图看懂 LCS 的计算过程
  7. Python 代码实现(三个版本,由浅入深)
    • 7.1 基础二维 DP 版
    • 7.2 空间优化版(一维数组)
    • 7.3 回溯法:把最长公共子序列"找"出来
  8. LCS vs 最长公共子串:一字之差,算法迥异
  9. 复杂度分析与优化思路
  10. 总结与学习路线

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","ab 对应 xy"的?为什么它不认为这是两段完全不同的代码?

答案是: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" 不是 子串(中间隔了 BD
  • 子序列只要保证相对顺序:"ACE" 是子序列(A 在第1位,C 在第3位,E 在第5位,顺序一致)

LCS 要找的就是 :在两个字符串中,找出一个共同子序列,这个子序列的长度是所有共同子序列中最长的。

比如:

  • str1 = "ABCBDAB"
  • str2 = "BDCABA"
  • LCS 长度为 4,其中一个结果是 "BCBA"(不一定唯一)

3. 暴力破解为什么不行?------ 指数级灾难

如果不用动态规划,最直观的方法是:

  1. 找出 str1所有子序列
  2. 检查每个子序列是否也在 str2
  3. 记录最长的那个

问题出在哪里?

一个长度为 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列(A vs A):相等 → 左上角 0 + 1 = 1
  • 第3行第2列(C vs C):相等 → 左上角 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) 需要具体序列时使用

优化思路

  1. 空间优化 :上面已经实现了一维 DP,空间复杂度降至 O ( min ⁡ ( m , n ) ) O(\min(m, n)) O(min(m,n))。
  2. 对称优化 :如果 m ≪ n m \ll n m≪n,可以互换 t e x t 1 text1 text1 和 t e x t 2 text2 text2,让列数更短(空间优化版中已实现)。
  3. 剪枝:如果两个字符串完全相同,直接返回其中一个(提前退出),但实际中这种情况较少。

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 行对比工具

推荐资源

相关推荐
梓䈑17 小时前
【算法题攻略】BFS 解决FloodFill 算法、最短路问题、多源BFS 和 拓扑排序
c++·算法·宽度优先
Adios79419 小时前
设置交集大小至少为2
数据结构·算法·leetcode
love530love20 小时前
OpenClaw Windows Companion 桌面客户端 连接 LM Studio 完整配置指南
人工智能·windows·python·openclaw
cui_ruicheng1 天前
Python从入门到实战(十六):多进程编程
开发语言·python
_Jimmy_1 天前
FastAPI + SQLAlchemy全局事务管理
python·fastapi
用户8356290780511 天前
如何使用 Python 在 Excel 中添加、编辑和删除超链接
后端·python
AC赳赳老秦1 天前
企业工商公开信息采集分析:OpenClaw 批量查询企业工商信息,生成企业画像报告
大数据·开发语言·python·自动化·php·deepseek·openclaw
来一碗刘肉面1 天前
栈的应用(表达式求值)
数据结构·算法
FoldWinCard1 天前
D6 Python 基础语法 --- 保留关键字
开发语言·python