LCS(最长公共子序列)详解

1. 先说说 LCS(Longest Common Subsequence)到底是什么

最长公共子序列(Longest Common Subsequence,简称 LCS)

给定两条序列(最常见的是两条字符串)A = a1 a2 ... anB = b1 b2 ... bm

在不改变原来序列中字符顺序的前提下,挑选出两条序列中都出现且顺序相同的子序列(相当于"删词",不允许插词)。

这个子序列的长度最大,就叫做这两条序列的 最长公共子序列长度 ,对应的子序列本身就是 最长公共子序列
形式化:

cpp 复制代码
  N(A, B) = max |C|
  其中 C 是 A、B 的公共子序列,|C| 表示长度

常见的实例:

cpp 复制代码
  N(A, B) = max |C|
  其中 C 是 A、B 的公共子序列,|C| 表示长度

两者的 LCS 是 "BCAB"(或 "BDAB"),长度为 4。

为什么我们要关心 LCS?

  1. 文本比较diff 工具内部就是用 LCS 算法来找出两份文件之间的差距。
  2. 生物信息学:序列比对(DNA、蛋白质)常用 LCS 作为相似度量。
  3. 数据压缩:对字符串进行差异编码时,需要知道是否另存已出现的公共子序列。
  4. 算法教学:LCS 是经典的动态规划(DP)题目,适合作为 DP 的入门案例。

2. 直观理解:像扑克匹配

假设你有两副扑克牌(牌面不相同,但都有点数、花色的一一对应关系)。

你想从第一副牌中挑几张牌(顺序不能乱),让这几张牌在第二副牌中也能按同样顺序找到。

  • 你只能删除牌(不能插入、移动或替换)。
  • 如果你把两副牌照着各自的顺序一个接一个地往前扫描,
    当当前牌在两副牌中都出现且顺序兼容时,你就可以把它记下来。

最能"互通"的那一连串牌,就是这两副牌的 LCS。


3. 动态规划推导

核心思想

对于 A 的前 i 个字符(A[1..i])与 B 的前 j 个字符(B[1..j]),

dp[i][j] 表示它们的最长公共子序列长度。

那么 dp[i][j] 可以从它的子问题(更小的前缀)推导出来。

  • 边界情况

    • i = 0j = 0,表示其中一条序列为空,它与另一条序列的公共子序列长度一定是 0。
    • 因此 dp[0][j] = 0dp[i][0] = 0
  • 状态转移(递推方程)

    1. A[i] == B[j]
      i 位和第 j 位字符相等,可以把它们也放进公共子序列。

      cpp 复制代码
      dp[i][j] = dp[i-1][j-1] + 1
    2. A[i] != B[j]
      i 位和第 j 位字符不相等,最多只能选其一或都不选。
      于是有两种情况:

      • A[i] 视作不参与 LCS,转到 dp[i-1][j]
      • B[j] 视作不参与 LCS,转到 dp[i][j-1]
        以上两种情况取最大值:
      cpp 复制代码
      dp[i][j] = max(dp[i-1][j], dp[i][j-1])
  • 稳态结论

    cpp 复制代码
    if (A[i-1] == B[j-1]) dp[i][j] = dp[i-1][j-1] + 1;
    else dp[i][j] = max(dp[i-1][j], dp[i][j-1]);

这里的下标注意:在 C++ 或者 Python 等语言里,字符串索引是从 0 开始,A[i-1] 表示 A 的第 i 个字符。


4. 递推的可视化(DP 矩阵)

假设 A = "ABCBDAB"B = "BDCAB"

打个差不多的 ASCII 表格来展示 DP 表(行是 A 的前缀,列是 B 的前缀):

cpp 复制代码
      ""  B  D  C  A  B""
A  0  0  0  0  0  0
B  0  1  1  1  1  1
C  0  1  1  2  2  2
B  0  1  1  2  3  3
D  0  1  2  2  3  3
A  0  1  2  2  3  4
B  0  1  2  2  3  4
  • A[5] = AB[4] = B 为例:
    A[5] != B[4],所以取 max(dp[4][5], dp[5][4]),即 max(3, 3) = 3

  • A[6] = BB[5] = B 为例:
    A[6] == B[5],所以 dp[6][5] = dp[5][4] + 1 = 3 + 1 = 4

  • 你可以把 DP 表想成梯形地图,从左上角(空前缀)往右下角(完整字符串)移动,每一步决定是否向右(增加列)或向下(增加行)或斜向下右(相等字符时)移动。


5. 用 C++ 实现 DP(含时间复杂度、空间复杂度说明)

下面给出最直接的实现方式:两层循环填表。代码注释详尽,帮助你理解每一步。

cpp 复制代码
#include <bits/stdc++.h>
using namespace std;

/**
 * @brief 计算两字符串的 LCS 长度
 * @param s1 First string
 * @param s2 Second string
 * @return Length of LCS
 */
int lcsLength(const string &s1, const string &s2)
{
    size_t m = s1.size(), n = s2.size();
    // dp[i][j] 表示 s1 的前 i 个字符与 s2 的前 j 个字符的 LCS 长度
    vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));

    for (size_t i = 1; i <= m; ++i) {
        for (size_t j = 1; j <= n; ++j) {
            if (s1[i - 1] == s2[j - 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];
}

时间复杂度O(m · n)

每个格子只被访问一次,且只执行常量级运算。
空间复杂度O(m · n)

vector 存完整表格。


6. 从 DP 表得到 LCS 本身

单纯得到长度不够,还想找具体的子序列

我们可以从 dp[m][n] 开始回溯:

cpp 复制代码
i = m, j = n
while (i > 0 && j > 0) {
    if (s1[i-1] == s2[j-1]) {
        // 这两字符在 LCS 中
        ans.push_back(s1[i-1]);
        --i; --j;
    } else if (dp[i-1][j] > dp[i][j-1]) {
        --i;
    } else {
        --j;
    }
}
reverse(ans.begin(), ans.end());

完整示例:

cpp 复制代码
/**
 * @brief 计算两字符串的 LCS 并返回具体子序列
 * @return pair(len, seq)
 */
pair<int, string> lcsString(const string &s1, const string &s2)
{
    size_t m = s1.size(), n = s2.size();
    vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));

    for (size_t i = 1; i <= m; ++i)
        for (size_t j = 1; j <= n; ++j)
            if (s1[i-1] == s2[j-1])
                dp[i][j] = dp[i-1][j-1] + 1;
            else
                dp[i][j] = max(dp[i-1][j], dp[i][j-1]);

    // 回溯
    size_t i = m, j = n;
    string seq;
    while (i > 0 && j > 0) {
        if (s1[i - 1] == s2[j - 1]) {
            seq.push_back(s1[i - 1]);
            --i; --j;
        } else if (dp[i - 1][j] > dp[i][j - 1]) {
            --i;
        } else {
            --j;
        }
    }
    reverse(seq.begin(), seq.end());
    return {dp[m][n], seq};
}

注意点

  1. dp[i-1][j] == dp[i][j-1] 时,两个方向都可以选择,通常都能得到等长 LCS。
  2. 如果你想求 所有 最长公共子序列,就不再用单个回溯,而是用 DFS 搜索多条路径。
  3. 记得 reverse,因为回溯得到的顺序是反向的。

7. 空间优化(只保留两行)

DP 表占用 O(m·n) 的空间,对于长度几十千(或上万)时会吃掉大量内存。

我们观察到 DP 转移仅依赖 前一行(i-1 的值,故可以把整张表压成 两行

cpp 复制代码
/**
 * @brief 仅用两行来计算 LCS 长度(空间 O(min(m,n)))
 * 这里把较短的字符串放在列上,保证 vector 的长度不会过大
 */
int lcsLengthOptimized(const string &s1, const string &s2)
{
    const string *p = &s1, *q = &s2;
    if (s1.size() > s2.size()) swap(p, q);

    int m = q->size(); // q 更短(或相等)
    vector<int> prev(m + 1, 0), cur(m + 1, 0);

    for (size_t i = 1; i <= s1.size(); ++i) {
        for (size_t j = 1; j <= m; ++j) {
            if ((*p)[i - 1] == (*q)[j - 1])
                cur[j] = prev[j - 1] + 1;
            else
                cur[j] = max(prev[j], cur[j - 1]);
        }
        swap(prev, cur); // 交换行
    }
    return prev[m];
}
  • 解释:
    • prev[j] 表示上一行(i-1)的值。
    • cur[j-1] 表示当前行已计算到 j-1 的值。
    • 结束一行时,用 swap 快速把 cur 变成新的 prev,而 cur 底层重用。

时间保持不变O(m · n)
空间缩减到O(min(m, n))(只保留两行)
为什么不用单行

对于 dp[i][j] 的转移,需要 dp[i][j-1](当前行上一列)以及 dp[i-1][j](上一行同列)。

单行无法利用 dp[i-1][j],因此只能用两行或更复杂的技巧(双指针)。


8. 进一步压缩空间:只记最短路径

在实际应用中,往往只需要 LCS 长度 ,或者需要 烟出 LCS 本身 ,而不必关心整张表。

如果只需要长度,可以进一步改用 哈希 或者 位集合 做优化,但通常实现更复杂,难以推广。
大多数实现会停留在两行/行压缩,已满足绝大多数需求。


9. 如何将 LCS 应用于实际(案例)?

9.1 代码差异检测(diff)

两份代码文件 a.cppb.cpp 的差异与对齐,常用的 统一 diffgit diffdiff -u)都会先把文件按行拆分,然后用 LCS 方法求出最长公共子序列。

得到公共行后,不同的行 被标记为 新增删除

下面给出一个极简版本:

cpp 复制代码
// 指定行分隔符 \n
vector<string> splitByLines(const string &text) {
    vector<string> lines;
    string cur;
    for (char c: text) {
        if (c == '\n') { lines.push_back(cur); cur.clear(); }
        else cur.push_back(c);
    }
    lines.push_back(cur);
    return lines;
}

// 对两数组做 LCS,返回指向 LCS 的位置对
vector<pair<int,int>> lcsIndices(const vector<string> &a, const vector<string> &b) {
    int m = a.size(), n = b.size();
    vector<vector<int>> dp(m+1, vector<int>(n+1, 0));
    for (int i=1;i<=m;i++)
        for (int j=1;j<=n;j++)
            if (a[i-1]==b[j-1]) dp[i][j]=dp[i-1][j-1]+1;
            else dp[i][j]=max(dp[i-1][j], dp[i][j-1]);

    int i=m, j=n;
    vector<pair<int,int>> path; // 记录 LCS 的 index
    while (i>0 && j>0) {
        if (a[i-1]==b[j-1]) { path.push_back({i-1,j-1}); i--; j--; }
        else if (dp[i-1][j]>dp[i][j-1]) i--;
        else j--;
    }
    reverse(path.begin(), path.end());
    return path;
}
  • lcsIndices 返回的是 LCS 的位置对 {``{x1,y1},{x2,y2},...}
  • 基于这些位置对,你可以一次性遍历两边,输出 差异

9.2 DNA 序列比对

在生物信息学里,LCS 常被用来衡量 DNA 片段之间的相似度。

两条 DNA 序列 AGCTATGGATATC 的 LCS 为 ATAT,说明它们共享的最长核苷酸序列长度为 4。

但在实际应用中,人们通常更关注**"编辑距离"(Levenshtein 距离)与"对齐"**问题,因为它们更能捕捉插入/删除/替换操作。这些往往在 LCS 的基础上做了权重调整。


10. 常见误区 & 调试技巧

# 误区 原因 解决方案
1 直接用递归暴力实现 递归会产生指数级重复子问题 用 DP(记忆化)或 STL vector 迭代
2 只计算长度,却想回溯 回溯需要完整 dp 同时记录方向或单独标记矩阵
3 dp[i][j]int 但字符串非常长 int 可能溢出 size_tlong long
4 代码只跑一次,推断复杂度 若要对多组数据再跑,记得清空 dp vectorassign 或重新初始化
5 将字符串直接 push_backstack 这样会出现 字母 而非 字符 手动 string 复制或使用 char 数组

调试技巧

  1. Print DP 片段 :在小例子中打印 dp 矩阵,可以验证递推逻辑。

    cpp 复制代码
    for (auto &row: dp) {
        for (int x: row) cout << setw(3) << x;
        cout << '\n';
    }
  2. 检查边界 :确认 DP 的行列都从 1 开始,且 dp[0][*]dp[*][0] 均为 0。

  3. 手动验证回溯 :在回溯前把 dp 复制到一个二维数组,然后逐步跟踪 i, j 的变化,验证是否真的走到 dp[0][0]

  4. 使用断言 :在回溯阶段加入 assert(dp[i][j] == 0 || dp[i][j] > 0),避免陷入死循环。

  5. 比较两个实现:用一个已成功实现的算法跑相同数据,如果输出不一致,说明实现错误。


11. 所有 LCS 的枚举(进阶)

有时我们想 列举 所有最长公共子序列。

dp 如上,但是我们要在多个路径上往回走。

可以采用 DFS 或 BFS :

cpp 复制代码
// 记住 dp 计算完成后,再用递归寻找所有 LCS
void dfs(int i, int j, string &cur, vector<string> &res, const vector<vector<int>>& dp) {
    if (i==0 || j==0) {
        if (!cur.empty()) res.push_back(string(cur.rbegin(), cur.rend()));
        return;
    }
    if (s1[i-1] == s2[j-1]) {
        cur.push_back(s1[i-1]);
        dfs(i-1, j-1, cur, res, dp);
        cur.pop_back(); // backtracking
    } else {
        if (dp[i-1][j] == dp[i][j])  // 左上不可能有更长,因为 dp[i-1][j] > dp[i][j-1]
            dfs(i-1, j, cur, res, dp);
        if (dp[i][j-1] == dp[i][j])
            dfs(i, j-1, cur, res, dp);
    }
}
  • 对于大规模字符串,所有 LCS 可能数目指数级,不要在不需时使用。
  • 当需要在特定约束下输出一条 LCS(如最少变更或最短编辑距离),可以在 DFS 时加入一个 先序/后序 排序或权重。

12. 可选的"Space‑Optimized Path Reconstruction"

如果你想 仅返回 LCS 长度 ,使用两行 DP 已够了。

但如果你需要 返回 LCS ,虽然最基础的两行 DP 已经可以回溯记录路径,

但仍需一次完整矩阵或 方向矩阵 将路径信息存入。

方案 1:方向矩阵

cpp 复制代码
enum Dir { NONE, UP, LEFT, DIAG }; // 左右/斜
vector<vector<Dir>> dir(m+1, vector<Dir>(n+1, NONE));
...
if (s1[i-1]==s2[j-1]) { dp[i][j] = dp[i-1][j-1]+1; dir[i][j] = DIAG; }
else if (dp[i-1][j] > dp[i][j-1]) { dp[i][j] = dp[i-1][j]; dir[i][j] = UP; }
else { dp[i][j] = dp[i][j-1]; dir[i][j] = LEFT; }

回溯时直接根据 dir 表走即可。

方案 2:递归+记忆化回溯

如果你想在 不占整表 的情况下回溯,可以写一个 递归 函数:

cpp 复制代码
string dfsRec(int i, int j) {
    if (i==0 || j==0) return "";
    if (s1[i-1]==s2[j-1]) return dfsRec(i-1, j-1) + string(1, s1[i-1]);
    if (dp[i-1][j] > dp[i][j-1]) return dfsRec(i-1, j);
    return dfsRec(i, j-1);
}

注意递进会导致 O(m·n) 级累积时间,如果递归深度过大可能栈溢出,这是理论上可行但实际上不常用。


13. 进一步的空间与时间优化

虽然两行 DP 甚至更少都非常快,下面列出几种更激进的优化技巧,适用于极大规模或特定硬件:

技巧 说明
位图+Packed DP dp 行做 位级编码 ,适用于 LCS 长度不超 64。将行统一为 uint64_t,利用 CPU 位操作。
稀疏矩阵 xy 长度相差很大,且字符分布稀疏时,只需要记录匹配的位置 。参考Hunt--Szymanski 算法。
并行 DP 矩阵是二维独立列块对角线,可用多线程计算。C++17 的多线程或 OpenMP 可行。
外存 对于亿级字符串,DP 矩阵大到内存无力,需把一行写入磁盘或使用外部排序技术。
哈希+Fast Jaccard 在某些相似度计算中,直接求 LCS 与哈希比较即可,充分利用预处理的 Suffix ArrayRolling Hash

突出的 HUNT--SZIMANSKI 算法(1961) 更进一步:

  • 只对出现相同字符的索引做匹配,后面再用最长递增子序列(LIS)求解。
  • 时间复杂度接近 O((n + r) log r),其中 r 为匹配对数,理论上可以实时到 O((n+m) log (n+m))
  • 这套方法在 "文档去重 " 开放源代码项目中大放异彩,比 DP 更优

如果你只需要 大约 LCS 长度 ,而不是确切值,编辑距离 (Levenshtein)或 Hamming 距离 会更简单。


14. 总结

  • LCS 是一个经典 的动态规划案例,也是差异检测序列比对文本比较等实用问题的核心。

  • DP 的三条基本方程:

    cpp 复制代码
    dp[i][j] =  0                       (i==0 或 j==0)
    dp[i][j] =  dp[i-1][j-1] + 1           if s1[i-1] == s2[j-1]
    dp[i][j] =  max(dp[i-1][j], dp[i][j-1])  otherwise
  • 时间复杂度是 O(m·n),空间可压到 O(min(m, n))

  • 要得到 具体序列 ,只能在 DP 表或 方向矩阵 的帮助下进行回溯。

  • 对大规模数据,可使用 Hunt--Szymanski并行 DP稀疏矩阵 等技术。

经典: string a = "ACGTACGTGAC";

经典: string b = "GACAGACGTACG";

LCS 长度 = 8,LCS 例子 "ACGTACGT"(或其它等长子序列)。

你可以将这段代码直接拷贝进 main() (#include <bits/stdc++.h> 易于实验):

cpp 复制代码
int main() {
    string a, b;
    while (cin >> a >> b) { // 两个单词输入
        auto [len, seq] = lcsString(a, b);
        cout << "len=" << len << " seq='" << seq << "'\n";
    }
    return 0;
}

对于多行文本、文件对比,可分别把每行 \n 处理为一个"字符"再跑 LCS。


15. 进一步阅读

主题 推荐资源
HUNT--SZIMANSKI 《算法导论》 20.4 章节(Cormen 等)
进阶 LCS 变体 余辉《算法竞赛解决方案》中相似 DP 分析
与 Diff 算法对比 GitHub libgit2 源码,比对细节
LCS 在生物信息学中的应用 《生物信息学导论》,第 9 章
C++11/17 DP 代码优化 《Effective Modern C++》 第 15 章讨论容器使用
OpenMP 在 DP 上的并行化 论文 "Parallelizing the LCS Algorithm on GPUs"

需要一张图时,可以用 matplotlibgnuplot 生成 DP 矩阵热力图,展示 LCS 匹配路径的可视化。


结束语

LCS 的学习既简单又深刻,它帮你更好地掌握 DP 思维,也能融入各种实际工程场景。尝试把它实现为 std::function<int(const std::string&, const std::string&)> 并在自己的项目里使用,你会发现它既优雅又实用。祝你编码愉快 🚀!

相关推荐
m0_629494731 小时前
LeetCode 热题 100-----17.缺失的第一个正数
数据结构·算法·leetcode
Cando学算法1 小时前
鸽笼原理(抽屉原理)
c++·算法·学习方法
RPGMZ1 小时前
RPGMakerMZ 地图存档点制作 标题继续游戏直接读取存档
开发语言·javascript·游戏·游戏引擎·rpgmz·rpgmakermz
Tisfy2 小时前
LeetCode 0796.旋转字符串:暴力模拟
算法·leetcode·题解·模拟·字符串匹配
丑八怪大丑2 小时前
JDK8-17新特性
java·开发语言
BlockChain8882 小时前
AI+区块链深度探索:算法与账本的共生时代
人工智能·算法·区块链
书源丶2 小时前
三十五、Java 泛型——类型安全的「万能模板」
java·开发语言·安全
生成论实验室2 小时前
《源·觉·知·行·事·物:生成论视域下的统一认知语法》第一章 源:不可言说的生成之源
人工智能·科技·算法·生活·创业创新
EF@蛐蛐堂2 小时前
【js】浏览器滚动条优化组件OverlayScrollbars
开发语言·javascript·ecmascript