1. 先说说 LCS(Longest Common Subsequence)到底是什么
最长公共子序列(Longest Common Subsequence,简称 LCS)
给定两条序列(最常见的是两条字符串)
A = a1 a2 ... an与B = b1 b2 ... bm,在不改变原来序列中字符顺序的前提下,挑选出两条序列中都出现且顺序相同的子序列(相当于"删词",不允许插词)。
这个子序列的长度最大,就叫做这两条序列的 最长公共子序列长度 ,对应的子序列本身就是 最长公共子序列。
形式化:
cppN(A, B) = max |C| 其中 C 是 A、B 的公共子序列,|C| 表示长度
常见的实例:
cpp
N(A, B) = max |C|
其中 C 是 A、B 的公共子序列,|C| 表示长度
两者的 LCS 是 "BCAB"(或 "BDAB"),长度为 4。
为什么我们要关心 LCS?
- 文本比较 :
diff工具内部就是用 LCS 算法来找出两份文件之间的差距。- 生物信息学:序列比对(DNA、蛋白质)常用 LCS 作为相似度量。
- 数据压缩:对字符串进行差异编码时,需要知道是否另存已出现的公共子序列。
- 算法教学:LCS 是经典的动态规划(DP)题目,适合作为 DP 的入门案例。
2. 直观理解:像扑克匹配
假设你有两副扑克牌(牌面不相同,但都有点数、花色的一一对应关系)。
你想从第一副牌中挑几张牌(顺序不能乱),让这几张牌在第二副牌中也能按同样顺序找到。
- 你只能删除牌(不能插入、移动或替换)。
- 如果你把两副牌照着各自的顺序一个接一个地往前扫描,
当当前牌在两副牌中都出现且顺序兼容时,你就可以把它记下来。
最能"互通"的那一连串牌,就是这两副牌的 LCS。
3. 动态规划推导
核心思想 :
对于
A的前i个字符(A[1..i])与B的前j个字符(B[1..j]),设
dp[i][j]表示它们的最长公共子序列长度。那么
dp[i][j]可以从它的子问题(更小的前缀)推导出来。
-
边界情况:
- 当
i = 0或j = 0,表示其中一条序列为空,它与另一条序列的公共子序列长度一定是 0。 - 因此
dp[0][j] = 0,dp[i][0] = 0。
- 当
-
状态转移(递推方程)
-
当
A[i] == B[j]:
第i位和第j位字符相等,可以把它们也放进公共子序列。cppdp[i][j] = dp[i-1][j-1] + 1 -
当
A[i] != B[j]:
第i位和第j位字符不相等,最多只能选其一或都不选。
于是有两种情况:- 把
A[i]视作不参与 LCS,转到dp[i-1][j] - 把
B[j]视作不参与 LCS,转到dp[i][j-1]
以上两种情况取最大值:
cppdp[i][j] = max(dp[i-1][j], dp[i][j-1]) - 把
-
-
稳态结论:
cppif (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] = A与B[4] = B为例:
A[5] != B[4],所以取max(dp[4][5], dp[5][4]),即max(3, 3) = 3。 -
以
A[6] = B与B[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};
}
注意点
- 当
dp[i-1][j] == dp[i][j-1]时,两个方向都可以选择,通常都能得到等长 LCS。- 如果你想求 所有 最长公共子序列,就不再用单个回溯,而是用 DFS 搜索多条路径。
- 记得
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.cpp 与 b.cpp 的差异与对齐,常用的 统一 diff (git diff、diff -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 序列 AGCTATG 和 GATATC 的 LCS 为 ATAT,说明它们共享的最长核苷酸序列长度为 4。
但在实际应用中,人们通常更关注**"编辑距离"(Levenshtein 距离)与"对齐"**问题,因为它们更能捕捉插入/删除/替换操作。这些往往在 LCS 的基础上做了权重调整。
10. 常见误区 & 调试技巧
| # | 误区 | 原因 | 解决方案 |
|---|---|---|---|
| 1 | 直接用递归暴力实现 | 递归会产生指数级重复子问题 | 用 DP(记忆化)或 STL vector 迭代 |
| 2 | 只计算长度,却想回溯 | 回溯需要完整 dp 表 |
同时记录方向或单独标记矩阵 |
| 3 | dp[i][j] 用 int 但字符串非常长 |
int 可能溢出 |
用 size_t 或 long long |
| 4 | 代码只跑一次,推断复杂度 | 若要对多组数据再跑,记得清空 dp |
用 vector 的 assign 或重新初始化 |
| 5 | 将字符串直接 push_back 到 stack |
这样会出现 字母 而非 字符 | 手动 string 复制或使用 char 数组 |
调试技巧
-
Print DP 片段 :在小例子中打印
dp矩阵,可以验证递推逻辑。cppfor (auto &row: dp) { for (int x: row) cout << setw(3) << x; cout << '\n'; } -
检查边界 :确认 DP 的行列都从 1 开始,且
dp[0][*]、dp[*][0]均为 0。 -
手动验证回溯 :在回溯前把
dp复制到一个二维数组,然后逐步跟踪i, j的变化,验证是否真的走到dp[0][0]。 -
使用断言 :在回溯阶段加入
assert(dp[i][j] == 0 || dp[i][j] > 0),避免陷入死循环。 -
比较两个实现:用一个已成功实现的算法跑相同数据,如果输出不一致,说明实现错误。
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 位操作。 |
| 稀疏矩阵 | 当 x 与 y 长度相差很大,且字符分布稀疏时,只需要记录匹配的位置 。参考Hunt--Szymanski 算法。 |
| 并行 | DP 矩阵是二维独立列块 或对角线,可用多线程计算。C++17 的多线程或 OpenMP 可行。 |
| 外存 | 对于亿级字符串,DP 矩阵大到内存无力,需把一行写入磁盘或使用外部排序技术。 |
| 哈希+Fast Jaccard | 在某些相似度计算中,直接求 LCS 与哈希比较即可,充分利用预处理的 Suffix Array 或 Rolling Hash。 |
突出的 HUNT--SZIMANSKI 算法(1961) 更进一步:
- 只对出现相同字符的索引做匹配,后面再用最长递增子序列(LIS)求解。
- 时间复杂度接近
O((n + r) log r),其中r为匹配对数,理论上可以实时到O((n+m) log (n+m))。 - 这套方法在 "文档去重 " 开放源代码项目中大放异彩,比 DP 更优。
如果你只需要 大约 LCS 长度 ,而不是确切值,编辑距离 (Levenshtein)或 Hamming 距离 会更简单。
14. 总结
-
LCS 是一个经典 的动态规划案例,也是差异检测 、序列比对 、文本比较等实用问题的核心。
-
DP 的三条基本方程:
cppdp[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" |
需要一张图时,可以用
matplotlib或gnuplot生成 DP 矩阵热力图,展示 LCS 匹配路径的可视化。
结束语 :
LCS 的学习既简单又深刻,它帮你更好地掌握 DP 思维,也能融入各种实际工程场景。尝试把它实现为
std::function<int(const std::string&, const std::string&)>并在自己的项目里使用,你会发现它既优雅又实用。祝你编码愉快 🚀!