1. 从一道题说起:为什么最长公共子序列值得反复琢磨
如果你正在准备算法面试,或者刚上完算法设计与分析这门课,大概率绕不开“最长公共子序列”这道题。它几乎是动态规划入门之后第一个真正让人感到“有点意思”的问题。我第一次接触它的时候,觉得递推公式看起来也不复杂,但真让自己从头写一遍,边界条件、状态定义、回溯路径,处处都是坑。后来刷题、做项目、带新人,前前后后跟这个算法打了不下几十次交道,才慢慢把它吃透。
最长公共子序列,英文叫 Longest Common Subsequence,简称 LCS。注意,不是最长公共子串。子串要求连续,子序列不要求连续,只要求保持相对顺序。这个区别非常关键,直接决定了算法思路完全不同。LCS 要解决的问题是:给定两个序列,找出它们最长的公共子序列的长度,并且通常还需要把具体的子序列还原出来。它的应用场景比很多人想象的广得多,比如代码相似度检测、DNA 序列比对、版本差异比较、甚至输入法的联想纠错,底层都有 LCS 或者它的变体在支撑。
这篇文章我会从零开始,把 LCS 的动态规划解法彻底拆开讲清楚。包括状态怎么定义、递推怎么推导、二维表怎么填、路径怎么回溯、空间怎么优化,以及实际写代码时最容易翻车的几个地方。不管你是刚学动态规划的新手,还是想重新梳理一遍的老手,应该都能从中拿到一些实用的东西。
2. 核心思路拆解:动态规划为什么适合 LCS
2.1 暴力解法为什么不可行
在讲动态规划之前,先想一个最直接的问题:如果我用暴力法,把所有公共子序列都找出来,然后取最长的,行不行?理论上行得通,但实际完全不可行。假设两个序列长度分别是 m 和 n,第一个序列的所有子序列有 2^m 个,第二个序列的所有子序列有 2^n 个。哪怕 m 和 n 只有 20,这个数量级也已经到了百万级别。如果长度是 100,那就是天文数字,根本算不过来。
暴力法的问题在于大量重复计算。比如两个序列分别是 ABCBDAB 和 BDCABA,你在枚举子序列的时候,会发现很多子问题被反复求解。动态规划的核心价值就在这里:把大问题拆成小问题,把子问题的解存起来,避免重复计算。这也是所有动态规划问题的共同特征——最优子结构和重叠子问题。
2.2 最优子结构怎么理解
LCS 具有最优子结构,意思是:两个序列的最长公共子序列,可以由它们前缀的最长公共子序列推导出来。具体来说,假设有两个序列 X 和 Y,长度分别是 m 和 n。我们看它们的最后一个字符:
- 如果 X 的最后一个字符和 Y 的最后一个字符相同,那么这个字符一定在最长公共子序列里,问题就变成了求 X 去掉最后一个字符和 Y 去掉最后一个字符的 LCS,然后加一。
- 如果两个最后一个字符不同,那么最长公共子序列要么来自 X 去掉最后一个字符和 Y 的 LCS,要么来自 X 和 Y 去掉最后一个字符的 LCS,取两者中较大的那个。
这个逻辑看起来简单,但它是整个动态规划解法的根基。我第一次理解的时候,卡在一个点上:为什么最后一个字符相同就一定要选它?万一不选它能得到更长的呢?后来想明白了,如果两个序列的最后一个字符相同,那把它加到任何公共子序列的末尾,都不会破坏公共子序列的性质,而且长度一定增加一。所以选它一定不会亏。
2.3 状态定义与递推公式
基于上面的分析,我们可以定义状态。设 dp[i][j] 表示序列 X 的前 i 个字符和序列 Y 的前 j 个字符的最长公共子序列长度。注意这里的 i 和 j 是从 1 开始计数的,表示长度,而不是下标。这一点非常容易搞混,后面写代码的时候会详细说。
递推公式分两种情况:
- 当 X[i-1] == Y[j-1] 时,dp[i][j] = dp[i-1][j-1] + 1
- 当 X[i-1] != Y[j-1] 时,dp[i][j] = max(dp[i-1][j], dp[i][j-1])
边界条件是:dp[0][j] = 0,dp[i][0] = 0。意思是任何一个序列和空序列的 LCS 长度都是 0。
这个递推公式看起来简洁,但里面藏着一个很容易忽略的细节:当两个字符不同的时候,我们取的是 dp[i-1][j] 和 dp[i][j-1] 的较大值,而不是 dp[i-1][j-1]。为什么?因为 dp[i-1][j-1] 一定不会比另外两个大,它已经被包含在它们里面了。这个点后面在填表的时候会看得非常清楚。
3. 二维表填表过程:手把手推一遍
3.1 用一个具体例子来演示
光看公式容易晕,我们拿两个具体的序列来推一遍。假设 X = “ABCBDAB”,Y = “BDCABA”。长度分别是 7 和 6。我们建一个 8 行 7 列的二维表,行表示 X 的前 i 个字符,列表示 Y 的前 j 个字符。第 0 行和第 0 列全部填 0。
先列一下两个序列的字符,方便对照:
- X:下标 0 是 A,1 是 B,2 是 C,3 是 B,4 是 D,5 是 A,6 是 B
- Y:下标 0 是 B,1 是 D,2 是 C,3 是 A,4 是 B,5 是 A
填表的时候,从左上往右下,一行一行填。每个格子看三个位置:左上角、正上方、正左方。如果当前行字符和当前列字符相同,就取左上角加一;如果不同,就取正上方和正左方的较大值。
3.2 逐行填表与关键节点说明
第一行 i=1,X 的字符是 A。逐个和 Y 的字符比较:
- j=1,Y 是 B,A != B,取 max(dp[0][1], dp[1][0]) = max(0,0) = 0
- j=2,Y 是 D,A != D,取 max(dp[0][2], dp[1][1]) = max(0,0) = 0
- j=3,Y 是 C,A != C,取 max(dp[0][3], dp[1][2]) = max(0,0) = 0
- j=4,Y 是 A,A == A,取 dp[0][3] + 1 = 0 + 1 = 1
- j=5,Y 是 B,A != B,取 max(dp[0][5], dp[1][4]) = max(0,1) = 1
- j=6,Y 是 A,A == A,取 dp[0][5] + 1 = 0 + 1 = 1
第一行填完是:0, 0, 0, 1, 1, 1。
第二行 i=2,X 的字符是 B。继续填:
- j=1,Y 是 B,B == B,取 dp[1][0] + 1 = 0 + 1 = 1
- j=2,Y 是 D,B != D,取 max(dp[1][2], dp[2][1]) = max(0,1) = 1
- j=3,Y 是 C,B != C,取 max(dp[1][3], dp[2][2]) = max(0,1) = 1
- j=4,Y 是 A,B != A,取 max(dp[1][4], dp[2][3]) = max(1,1) = 1
- j=5,Y 是 B,B == B,取 dp[1][4] + 1 = 1 + 1 = 2
- j=6,Y 是 A,B != A,取 max(dp[1][6], dp[2][5]) = max(1,2) = 2
第二行填完是:1, 1, 1, 1, 2, 2。
按照同样的逻辑继续填下去,最终右下角的 dp[7][6] 就是两个序列的 LCS 长度。对于这个例子,最终结果是 4。具体的公共子序列可以是 BCBA、BDAB、BCAB 等,长度都是 4。
3.3 填表过程中的三个关键观察
填完整个表之后,回头看看,有几个规律非常明显。
第一,每个格子的值只依赖于它左边、上边、左上边的格子。这意味着我们可以按任意顺序填表,只要保证这三个方向先于当前格子计算即可。通常我们选择从左到右、从上到下,这样最自然。
第二,当两个字符相同时,格子的值一定等于左上角加一,而且这个值一定大于等于左边和上边的值。这保证了相同字符一定会被纳入公共子序列。
第三,当两个字符不同时,格子的值等于左边和上边的较大值。这说明 LCS 的长度在填表过程中是单调不减的,沿着任何一条从左上到右下的路径,值都不会减小。
注意:填表的时候一定要从 i=1 和 j=1 开始,第 0 行和第 0 列是边界,必须初始化为 0。很多新手写代码时忘了初始化边界,导致后面全部算错。
4. 代码实现:从伪代码到可运行版本
4.1 基础版本:二维数组完整实现
先给出最直观的二维数组版本,用 Python 写:
def lcs_length(X, Y): m = len(X) n = len(Y) # 创建 (m+1) x (n+1) 的二维数组,全部初始化为 0 dp = [[0] * (n + 1) for _ in range(m + 1)] for i in range(1, m + 1): for j in range(1, n + 1): if X[i-1] == Y[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]这段代码逻辑清晰,但有几个细节值得说。第一,dp 数组的大小是 (m+1) x (n+1),多出来的一行一列就是边界。第二,比较的时候用的是 X[i-1] 和 Y[j-1],因为 dp 的下标从 1 开始,而字符串下标从 0 开始。这个偏移量是新手最容易搞错的地方,我见过不少人写成 X[i] 和 Y[j],然后数组越界。
4.2 还原具体子序列:回溯路径
光求出长度还不够,很多时候我们需要知道具体的公共子序列是什么。这时候就需要从 dp[m][n] 往回走,还原路径。回溯的逻辑和填表正好相反:
- 如果 X[i-1] == Y[j-1],说明这个字符属于 LCS,记录下来,然后往左上角走,i 和 j 都减一。
- 如果 X[i-1] != Y[j-1],比较 dp[i-1][j] 和 dp[i][j-1],往较大的方向走。如果相等,往哪边走都行,但通常选择往上或往左其中一种,保证路径一致。
def lcs_string(X, Y): m = len(X) n = len(Y) dp = [[0] * (n + 1) for _ in range(m + 1)] for i in range(1, m + 1): for j in range(1, n + 1): if X[i-1] == Y[j-1]: dp[i][j] = dp[i-1][j-1] + 1 else: dp[i][j] = max(dp[i-1][j], dp[i][j-1]) # 回溯还原子序列 result = [] i, j = m, n while i > 0 and j > 0: if X[i-1] == Y[j-1]: result.append(X[i-1]) i -= 1 j -= 1 elif dp[i-1][j] >= dp[i][j-1]: i -= 1 else: j -= 1 return ''.join(reversed(result))回溯的时候,result 是倒序添加的,最后需要反转一下。这个细节看起来小,但忘了反转的话,输出的子序列顺序就是反的,比如本来应该是 BCBA,结果输出 ABCB,虽然长度对,但顺序错了。
4.3 空间优化版本:滚动数组
二维数组的空间复杂度是 O(m*n)。如果两个序列都很长,比如长度都是 10000,那二维数组就是一亿个格子,内存直接爆掉。这时候可以用滚动数组优化,把空间降到 O(n)。
观察递推公式,dp[i][j] 只依赖于 dp[i-1][j-1]、dp[i-1][j] 和 dp[i][j-1]。也就是说,计算第 i 行的时候,只需要第 i-1 行的数据和当前行左边的数据。所以我们可以只保留两行,或者更进一步,只保留一行加一个临时变量。
def lcs_length_optimized(X, Y): m = len(X) n = len(Y) # 保证 Y 是较短的序列,减少空间 if n > m: X, Y = Y, X m, n = n, m prev = [0] * (n + 1) curr = [0] * (n + 1) for i in range(1, m + 1): for j in range(1, n + 1): if X[i-1] == Y[j-1]: curr[j] = prev[j-1] + 1 else: curr[j] = max(prev[j], curr[j-1]) prev, curr = curr, prev # 清空 curr,为下一轮做准备 for j in range(n + 1): curr[j] = 0 return prev[n]这个优化版本的空间复杂度是 O(min(m,n)),时间复杂度不变,还是 O(m*n)。注意交换 X 和 Y 的那一步,是为了让较短的序列作为列,进一步减少空间。这个技巧在实际工程中很常用。
提示:滚动数组版本虽然省空间,但没法直接回溯还原子序列。如果需要还原路径,要么保留完整的二维数组,要么用额外的手段记录选择方向。这是一个典型的空间换时间或者时间换空间的权衡。
5. 常见问题与排查技巧实录
5.1 下标越界与偏移量错误
这是出现频率最高的问题。很多人写代码的时候,dp 数组定义成 m x n,然后循环从 0 开始,结果访问 dp[i-1][j-1] 的时候出现负数下标。正确的做法是 dp 数组定义成 (m+1) x (n+1),循环从 1 开始,比较字符的时候用 i-1 和 j-1。
还有一种情况是字符串下标和 dp 下标混用。比如在回溯的时候,判断条件写成 X[i] == Y[j],但 i 和 j 是 dp 的下标,范围是 1 到 m,直接访问 X[i] 会越界。必须统一用 i-1 和 j-1。
5.2 回溯路径不一致导致结果错误
回溯的时候,如果 dp[i-1][j] 和 dp[i][j-1] 相等,往哪边走?这个问题看起来无所谓,但实际上会影响还原出来的子序列。如果代码里一会儿往左一会儿往上,可能导致同一个字符被重复添加或者漏掉。解决办法是固定一个方向,比如统一选择往上走(i 减一),这样路径是确定的。
另外,回溯的终止条件是 i > 0 且 j > 0,不能写成 i >= 0 且 j >= 0,否则会多访问边界,虽然不会出错,但逻辑上不严谨。
5.3 空间优化版本的初始化陷阱
滚动数组版本里,每一轮结束后需要把 curr 清空。如果不小心忘了清空,下一轮计算的时候,curr 里还残留着上一轮的数据,结果就会错得莫名其妙。我见过有人调试了半天,最后发现是忘了重置数组。
还有一个坑是交换 X 和 Y 之后,m 和 n 也要跟着交换。如果只交换了序列,没交换长度,循环范围就错了,要么少算一行,要么数组越界。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| 结果比预期小 | 边界未初始化或下标偏移错误 | 检查 dp[0][j] 和 dp[i][0] 是否为 0 |
| 数组越界 | dp 大小不够或循环范围错误 | 确认 dp 是 (m+1) x (n+1),循环从 1 开始 |
| 回溯结果顺序反了 | 忘记反转结果数组 | 在返回前对 result 做 reverse |
| 空间优化版结果不稳定 | curr 数组未清空 | 每轮结束后重置 curr 所有元素为 0 |
| 两个序列交换后出错 | 长度未同步交换 | 交换序列的同时交换 m 和 n |
5.5 几个实用的调试技巧
第一个技巧是打印 dp 表。当结果不对的时候,把整个二维数组打印出来,和手推的结果对照,很快就能定位到哪一行哪一列开始出错。这个方法看起来笨,但极其有效。
第二个技巧是用小规模数据测试。先用长度为 2 和 3 的序列跑一遍,确认逻辑正确,再逐步增大规模。不要一上来就用长序列,出了问题很难定位。
第三个技巧是写单元测试。准备几组已知答案的输入输出,每次修改代码后跑一遍。比如 X=”ABC”,Y=”AC”,答案应该是 2,子序列是 “AC”。这种简单的用例能覆盖大部分边界情况。
6. 从 LCS 延伸出去:变体与实际应用
6.1 最长公共子串:连续性的约束
最长公共子串是 LCS 的变体,区别在于子串要求连续。递推公式变成:如果 X[i-1] == Y[j-1],dp[i][j] = dp[i-1][j-1] + 1;否则 dp[i][j] = 0。最终答案是整个表里的最大值,而不是右下角的值。这个变体在文本比对和抄袭检测里用得很多。
6.2 编辑距离:LCS 的近亲
编辑距离是另一个经典的动态规划问题,它和 LCS 有很深的联系。编辑距离允许插入、删除、替换三种操作,求把一个字符串变成另一个字符串的最少操作次数。实际上,如果只允许插入和删除,编辑距离和 LCS 长度满足一个关系:编辑距离 = m + n - 2 * LCS。这个关系在自然语言处理和拼写纠错里经常用到。
6.3 实际工程中的应用场景
在版本控制系统中,比较两个文件的差异,底层用的就是 LCS 的变体。Git 的 diff 算法会找出两个版本之间最长的公共行序列,然后只显示不同的部分。在生物信息学里,DNA 序列比对也是类似的问题,只不过字符集只有 A、T、C、G 四种,而且还会引入间隙惩罚等更复杂的规则。
在推荐系统里,用户行为序列的相似度计算也可以用 LCS。比如两个用户都看了某些商品,顺序也大致相同,那他们的兴趣相似度就比较高。当然实际系统会更复杂,但 LCS 提供了一个基础的相似度度量。
6.4 性能优化的几个方向
如果序列特别长,O(m*n) 的时间复杂度可能还是太慢。这时候可以考虑几个方向。一是用 Hirschberg 算法,把空间降到 O(min(m,n)) 的同时还能还原路径,代价是时间复杂度常数变大。二是用分块或者并行计算,把二维表分成多个块,多线程同时填。三是用近似算法,比如只计算前 k 个字符的 LCS,或者用哈希做快速筛选。
我在实际项目里遇到过一个场景,两个序列长度都在十万级别,直接跑 LCS 要好几秒。后来用了分块加剪枝的策略,先比较首尾字符,快速排除明显不相似的情况,再对剩下的部分跑完整算法,性能提升了十几倍。这个经验说明,算法优化不一定要改核心逻辑,有时候在输入层面做预处理,效果更明显。
6.5 一个容易忽略的细节:字符集大小的影响
LCS 的时间复杂度是 O(m*n),和字符集大小无关。但实际运行的时候,字符集大小会影响 CPU 缓存的命中率。如果字符集很小,比如 DNA 序列只有四种字符,那么相同字符的匹配概率高,填表时更多走 dp[i-1][j-1] + 1 的分支,整体速度会快一些。如果字符集很大,比如 Unicode 文本,匹配概率低,更多走 max 分支,速度会慢一些。这个差异在小规模数据上不明显,但在大规模数据上能差出百分之二三十。
注意:如果你的应用场景对性能极其敏感,可以考虑把字符映射成整数,减少比较开销。这个优化在 C++ 里效果明显,在 Python 里因为本身开销就大,提升有限。
7. 我踩过的坑和给你的建议
回过头看,LCS 这个算法本身并不复杂,但真正写好、写对、写高效,还是需要一些经验的。我最早学的时候,觉得递推公式记住了就行,结果第一次面试手写代码,边界条件写错了,直接挂掉。后来反复练习,才慢慢把细节都摸清楚。
第一个建议是不要死记公式,要理解每一步在做什么。尤其是填表的过程,自己拿纸笔画一遍,比看十遍代码都管用。第二个建议是写完代码一定要用几个小例子验证,包括空序列、完全相同、完全不同的情况。第三个建议是如果面试或者考试要求手写,一定要先写边界初始化,再写循环,最后写回溯,顺序不能乱。
还有一个心得是,LCS 的二维表其实可以可视化。如果你用 Python 的 matplotlib 把 dp 数组画成热力图,能很直观地看到公共子序列在哪些位置匹配。这个方法我用来给新人讲解的时候特别有效,比干讲公式强多了。
最后说一个实际工作中的体会:LCS 虽然经典,但并不是所有相似度问题都适合用它。如果序列很长而且差异很大,LCS 的结果可能并不符合直觉。这时候可能需要考虑其他度量方式,比如编辑距离、Jaccard 相似度、或者基于词向量的语义相似度。算法是工具,用对场景比用对参数更重要。