☰
最长公共子串长度求解:从动态规划到滚动数组优化与工程应用
2026/10/15 17:14:17 网站建设 项目流程

“求两个字符串的最长公共子串长度”,这个题目我估计很多人在面试和实际工程里都撞到过。名字和最长公共子序列(LCS)只差两个字,但解法思路和代码细节完全是两码事,很多新手上来就套LCS的模板,结果死在“连续性”这个关键点上。这篇文章就专门聊这个题目:从最直观的暴力解法讲到标准动态规划,再到滚动数组优化,最后进入多字符串扩展和真实应用场景。全文不只是贴代码,更重要的是把每一步“为什么这么做”讲透,配合我踩过的坑和排查思路,让你看完能直接写出一份既正确又有含金量的实现。

1. 先把题意拆清楚:子串与子序列的致命区别

1.1 看似微小的定义差异,直接决定算法走向

给定两个字符串 A 和 B,最长公共子串要求的是在原字符串中连续出现的一段字符;而最长公共子序列只要求保持相对顺序,允许中间跳过其他字符。

举个例子说明:

A = "abcdf" B = "abcf"
  • 最长公共子序列 LCS 是 "abcf",长度 4,字符可以跳着取。
  • 最长公共子串长度是 3,也就是 "abc",因为 B 里的 'f' 在 'c' 之后没错,但在 A 里 'c' 与 'f' 之间隔了一个 'd',连续段不成立。

这个例子我几乎每次讲这块都会用,因为它足够小,又能把“连续性”的约束暴露得清清楚楚。很多人一看名字相近,第一反应就是“这不就是LCS吗?改一个条件就行”,结果真动手改的时候,又发现差分数组、状态方程都有微妙变化,很容易改出一半对一半错的东西。

1.2 为什么工程上更常算“长度”而非“具体子串”

实际业务里,比如代码相似度检测、重复文本定位、日志模式提取、基因序列比对,第一步往往只需要知道“最长匹配了多长”。

  • 如果一个匹配长度低于阈值(比如 5 个字符),基本可以忽略,当作噪声。
  • 如果匹配长度超过阈值,才会定位到具体位置,再做上下文分析。

先算长度还有一个好处:长度量级小,便于设定过滤条件,避免存储大量临时子串。真要输出具体内容,也要先确认最长长度到底是多少,再回源字符串去截取,避免在多处重复匹配时输出多个同样的子串造成数据膨胀。所以这个题“长度优先”的设计是非常贴合生产需求的。

2. 从暴力法开始:理解问题的最小可行实现

2.1 所有起点组合都拆一遍的代价

暴力法的核心思路很直接:枚举 A 中每一个子串的起点,再看这个子串是否出现在 B 里。

伪代码如下:

maxLen = 0 for i in 0..lenA-1: for j in 0..lenB-1: curLen = 0 while i+curLen < lenA && j+curLen < lenB && A[i+curLen] == B[j+curLen]: curLen++ // 字符相等就继续向右延伸 maxLen = max(maxLen, curLen)

说白了就是把 A 的每一个位置都当成“可能的起点”,和 B 的每一个位置对齐,然后拼命往右走,直到字符对不上为止。这个做法能在几秒钟之内写完,也绝对正确,但性能很差。

2.2 暴力法的时间复杂度推演

对于长度分别为 m 和 n 的字符串:

  • 外层两个循环枚举起点,复杂度是 O(m·n)。
  • 内层 while 在极端情况下(两个字符串完全相同),每个起点组合都要跑出 O(min(m,n)) 的长度。
  • 总的时间复杂度是 O(m·n·min(m,n))。

如果两个字符串都是 1000 长度,外层组合数就是 100 万,每个组合还要再延伸接近 1000 次,最坏情况就是 10 亿次字符比较。在一般语言里,这已经需要几秒甚至几十秒了。要是字符串升到 1 万长度,这个暴力法基本是跑不完的。

暴力法存在的价值不是让你交付,而是让你验证动态规划实现的正确性。我工程上经常先写一个暴力版做基准,再拿它对比优化版跑随机数据,只要结果一致,说明状态转移逻辑没写偏,这是一个非常实用的自测思路。

3. 动态规划标准解法:二维表的构建与状态推导

3.1 DP 定义和状态转移方程

定义状态:

dp[i][j] = 以 A 的第 i 个字符结尾、且以 B 的第 j 个字符结尾的公共子串的最大长度

注意这里的描述,**“以当前字符结尾”**是这个题的重点。它意味着dp[i][j]只描述那些从某个位置开始、恰好延伸到(i,j)的连续匹配段。

相应的状态转移方程:

if A[i] == B[j]: dp[i][j] = dp[i-1][j-1] + 1 else: dp[i][j] = 0

这个方程的含义非常直观:

  • 如果当前字符相等,就说明可以在前一个字符的公共子串后面多接一个字符,所以是dp[i-1][j-1] + 1。
  • 如果当前字符不等,那么“以这两个位置结尾的公共连续段”根本不存在,所以直接置为 0。

其中dp[i-1][j-1]表示以 A 的前一个字符和 B 的前一个字符结尾的公共子串长度,这是保证连续性的关键。一旦出现“不等”的情况,就把这一格清零,而不是像最长公共子序列那样保留前面的候选值。

这里必须补一个细节:真正两个字符串的下标是从 0 开始的,但为了编写代码方便,通常把 dp 数组尺寸设为(m+1) x (n+1),第 0 行第 0 列作为辅助边界,值恒为 0。这样dp[i][j]才对应A[i-1]和B[j-1]的比较。

3.2 二维 DP 表的可视化

以A = "abcde"、B = "abfce"为例,构造 DP 表:

abfce
000000
a010000
b002000
c000010
d000000
e000001

可以看到,匹配到 "ab" 的时候,dp[2][2]等于 2,因为它是从dp[1][1]递推过来的;而 c 和 e 的匹配长度只有 1,因为它们的对角线前缀并不连续,没有形成更长的链条。

这张表做出来以后,你会发现一个规律:最长公共子串长度就是整个 dp 表里的最大值。因为每个格子都描述一个“以某对字符结尾的”子串长度,最长的那一段必然对应某个结尾位置。这个结论也让我们实现的时候只需要全程维护一个全局最大值变量即可。

3.3 标准代码实现(Python)

def longest_common_substring_len(A: str, B: str) -> int: m, n = len(A), len(B) if m == 0 or n == 0: return 0 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 A[i - 1] == B[j - 1]: dp[i][j] = dp[i - 1][j - 1] + 1 if dp[i][j] > max_len: max_len = dp[i][j] # 字符不等时 dp[i][j] 本来就是 0,无需额外赋值 return max_len

这里有个地方可以提一下性能:当m和n都在 1000 以内时,这个二维 DP 是秒出的;但如果两边都到 10000,二维数组光是存储就需要 1 亿个整数,内存开销就是数百 MB。所以下一步的滚动数组优化几乎是必然要讲的。

3.4 各语言实现要点差异

用 Java 实现时,二维数组要用int[][] dp = new int[m+1][n+1],初始值默认全是 0,不需要额外初始化辅助行列。用 C++ 则建议使用vector<vector<int>>,既方便又安全,避免裸指针在长度相等或为空时的越界问题。

还有一个容易被忽略的边界:如果两个字符串里有一个是空串,直接返回 0。这个判断放在函数最开始,能省掉一堆后续的越界问题和特殊分支判断。很多现场写代码的人,一上来就开二维数组,结果空串输入直接崩在访问s[0]上,这种低级错误在面试中非常致命。

4. 进阶核心:空间压缩与滚动数组实现

4.1 为什么可以压缩成一维数组

观察状态转移方程会发现:dp[i][j]只依赖dp[i-1][j-1]。这是一个对角线依赖,也就是当前格只与左上方的格子有关。

如果按普通的从左到右、从上到下的遍历顺序,更新dp[j]时,原来的dp[j-1]已经被这一行覆盖了,此时要拿它代表上一行的dp[i-1][j-1]就不对了。所以遍历时必须从右往左更新,这样dp[j-1]还是上一行的旧值,还没有被覆盖。

这种从右往左的更新方式,也让我联想到背包问题里的滚动数组优化。两者本质上都是“在状态只依赖上一行某位置时,用一维数组反向遍历来保留旧值”的经典套路。理解了这一层,后面无论遇到什么DP压缩题,都能触类旁通。

4.2 滚动数组版本代码

def longest_common_substring_len_optimized(A: str, B: str) -> int: m, n = len(A), len(B) if m == 0 or n == 0: return 0 # 选择较短的字符串作为列方向,进一步减少内存 if m < n: A, B = B, A m, n = n, m dp = [0] * (n + 1) max_len = 0 for i in range(1, m + 1): for j in range(n, 0, -1): if A[i - 1] == B[j - 1]: dp[j] = dp[j - 1] + 1 max_len = max(max_len, dp[j]) else: dp[j] = 0 return max_len

这里我把较短字符串放到列方向n,数组大小只跟它有关,进一步压缩了内存。两个字符串长度差很大的时候(比如一个是几万行日志片段,一个是几百字符的关键字),这个优化效果非常明显。

4.3 时间与空间复杂度对比

方案时间复杂度空间复杂度适用场景
暴力法O(m·n·min(m,n))O(1)极短字符串、验证正确性
二维 DPO(m·n)O(m·n)m,n ≤ 2000 左右
滚动数组 DPO(m·n)O(min(m,n))一个字符串很长,需要控内存
后缀数组/后缀自动机O(m+n) 构建、O(m+n) 查询O(m+n)超长文本、重复模式挖掘

后缀数组和 SAM 属于竞赛和工程进阶方案,普通业务需求用滚动数组 DP 就已经非常够用。真到了基因序列几十万上百万长度的时候,再考虑这些复杂结构不迟。

5. 多字符串扩展:从二元到多元的转变

5.1 多字符串最长公共子串的朴素思路

如果问题从“两个字符串”变成“k 个字符串”,要求找出这些字符串中共同出现的最长连续片段长度,思路并不复杂,主要有两种扩展方向:

方向一:逐对合并
先求第 1、2 个字符串的公共子串集合,再把结果作为虚拟字符串,与第 3 个字符串求公共子串,依此类推。但这里必须小心,多字符串的交集结果可能会断裂,每一轮合并不一定保留完整连续段,所以这种思路需要额外维护“候选子串集合”,而非只留一个长度。

方向二:枚举种子并验证
取最短字符串,枚举它的所有子串(或每个后缀的前缀),去其他 k-1 个字符串里逐一看是否是子串。这个思路实现简单,但时间复杂度不低,适合 k 比较小的场景。

方向三:基于后缀数组 + 二分长度
把所有字符串拼接成一个长串,中间用特殊分隔符隔开,然后构建后缀数组,通过检查连续区间里是否包含所有原始字符串的后缀前缀,再二分公共长度,这是竞赛里处理多字符串 LCS 的经典手段。这个方案实现复杂度高,但性能和稳定性都很优秀。

5.2 一个实际可用的多串 DP 扩展

如果 k 很小且字符串都不长,可以用多维 DP 直接求解。三字符串的转移方程变成:

if A[i] == B[j] == C[k]: dp[i][j][k] = dp[i-1][j-1][k-1] + 1 else: dp[i][j][k] = 0

三维 DP 的空间是 O(m·n·p),三个字符串各 500 长度时就会到 1.25 亿个格,内存压力很大,所以实际工程中我更推荐“取最短串、枚举后缀前缀”的思路。用 KMP 或字符串哈希去快速判断子串是否在其他字符串中出现,编码量适中,也能扛住数百长度的输入。

我做一个多语言文本查重的小工具时,就是先对所有文档按长度排序,取最短的文档枚举“连续片段候选”,再用滚动哈希快速去其他文档中验证,整体效果不错。这类“以最小集合为基准、哈希加速验证”的思路在相似度计算和重复内容检测里非常通用。

6. 实际应用场景与案例复盘

6.1 场景一:代码相似度检测

检测两个源码文件是否有抄袭嫌疑,最直接的特征就是寻找较长的公共连续片段。

  • 把源码做简单预处理:去掉空行、注释、把变量名归一化。
  • 对 Token 序列跑最长公共子串算法。
  • 只要最长匹配长度占总代码长度的比例超过阈值(比如 20%),就标记为高危。

我在一个内部代码审计工具里实践过这个小功能。单文件代码几千 Token 规模,滚动数组 DP 只需几十毫秒,完全够用。而如果用完整的语法树比较,反而功能冗余、开销大,不适合做粗筛,最长公共子串长度作为“第一道闸门”非常合适。

6.2 场景二:基因与序列比对

生物信息学里常见的局部比对场景,就是找两条序列中最相似的连续片段。直接使用最长公共子串长度,能快速判断两条序列是否存在同源片段。

DNA 序列由 A/T/C/G 组成,长度动不动就是几万甚至百万。在这种规模下,普通 O(m·n) DP 就不太够看了,一般会结合后缀自动机或 BWT 索引算法。不过如果数据是经过分段截取的“局部序列片段”,长度几千以内,DP 方案依然可以胜任。

6.3 场景三:日志异常与重复模式发现

运维场景里,大量重复日志往往意味着某类异常正在高频发生。把日志逐行切分后,连续相同片段就是重复模式:

  • 预定义最小匹配长度(如 20 字符),低于阈值直接忽略。
  • 求最长公共子串长度,如果多次出现且长度稳定,说明是周期性输出,需要重点关注。
  • 再结合时间戳和频率做根因分析。

这种玩法在日志聚类、监控告警压缩上非常有效。我遇到过一个案例,线上服务疯狂打印同一段堆栈,单靠关键字抓取很容易漏,而用公共子串长度计算所有日志两两之间的相似度,然后聚合最相似的日志簇,很快就定位到了问题点。

6.4 场景四:抄袭检测与内容查重

查重系统内部,通常不会拿原始全文直接匹配,而是先提取“指纹序列”,再做局部最长匹配。最长公共子串长度可以量化“连续抄袭片段”的严重程度。

比如处理新闻报道类文本时,“连续抄袭 100 字以上”比“零散搬运 300 字”更有判定说服力,因为前者意味着大段复制粘贴,后者可能只是引用不当。这个思路也是很多内容平台原创度检测背后的基础逻辑。

7. 常见问题与排查技巧实录

7.1 现象:结果总是比预期小很多

原因:多半是把“子串”和“子序列”的状态定义混淆了,在字符不等时依然沿用了max(dp[i-1][j], dp[i][j-1])的写法。

解决方法:字符不等时直接置 0,不做任何“继承”。我刚开始写这个题也犯过这个错,后来通过随机暴力对比才发现问题,所以强烈建议写完先跑一轮暴力基准验证。

7.2 现象:滚动数组更新后结果不对

原因:遍历方向弄错。如果从左往右更新,dp[j-1]已经是本轮覆盖过的新值,对角依赖被破坏,结果必然错误。

解决方法:内层循环必须从n到1反向遍历。这个坑非常高发,甚至很多面经答案都没写对,写代码时一定要盯着遍历方向。

7.3 现象:内存占用过高甚至 OOM

原因:直接上二维 DP,且两个字符串都很长。

解决方法:切换到滚动数组版本;再把较短的字符串放到列方向,这样空间复杂度直接降为 O(min(m,n))。

7.4 现象:边界字符匹配不出来

原因:下标处理不一致。常见错误是dp[i][j]对应A[i]和B[j],但 i 或 j 正好是 0,访问到字符串开头的前一个字符,导致越界。

解决方法:统一约定“dp[i][j] 表示以 A[i-1] 与 B[j-1] 结尾的公共子串长度”,用n+1列多出来的一列全是 0 作为哨兵,这样可以彻底规避负下标问题。

7.5 现象:多字符串扩展后结果包含断裂片段

原因:求长度时没有维护连续性,子串在中间断开,结果错误地连到了一起。

解决方法:多字符串场景下,要么使用三维 DP 保证连续性,要么维护候选连续段并逐串验证,不能仅靠两两 LCS 拼接。

8. 参数选择与工程调优经验

8.1 如何确定“算长度”还是“回溯源字符串”

在实践中,具体子串非常重要,但通常不需要把所有匹配段都记下来,只需要记录最大长度和对应的结束下标。当全局最大值更新时,把(i, j)记下来;算完后再用A[i-maxLen:i]回溯出结果。这样既避免了大数组存储,又能在事后随时取出内容。

def longest_common_substring_with_result(A: str, B: str) -> str: m, n = len(A), len(B) dp = [[0] * (n + 1) for _ in range(m + 1)] max_len = 0 end_idx = 0 for i in range(1, m + 1): for j in range(1, n + 1): if A[i - 1] == B[j - 1]: dp[i][j] = dp[i - 1][j - 1] + 1 if dp[i][j] > max_len: max_len = dp[i][j] end_idx = i - 1 else: dp[i][j] = 0 return A[end_idx - max_len + 1: end_idx + 1]

这个带返回值的版本比单纯返回长度更实用。很多真实业务场景,比如日志定位和文本查重,拿到长度以后还是得知道具体是哪一段内容,才好做后续分析。

8.2 阈值设置技巧

工程中很少直接输出“最长公共子串长度”,一般都是配合阈值过滤噪声。比如文本指纹检测时,长度小于 10 的匹配几乎没什么意义;基因序列片段比对时,匹配长度至少要大于 30 个碱基才认为是有意义的同源片段。阈值需要结合具体领域的数据分布来定,先统计分析一批真实数据,再根据结果设定合理分位点,比拍脑袋靠谱得多。

遇到超长文本时,可以先对其中一个字符串做滑动窗口截取,把大问题切成小块分别求解,再合并判断,这样既能控内存又能并行加速。多路并行时每个分片独立计算局部最大匹配长度,最后汇总取全局最大值,逻辑简单可靠。

8.3 语言选型对性能的影响

同一个 O(m·n) 算法,用 Python 处理两个 10000 长度的字符串可能要几秒,用编译型语言则只花几十毫秒。原因是 Python 解释执行和类型动态化带来的常数因子较大。如果追求极致性能,可以考虑:

  • C/C++:数组访问零开销,内存可控。
  • Java:JIT 优化后性能非常可观,字节码也能比 Python 快很多倍。
  • Rust:既有 C++ 的性能,又通过所有权限定避免内存安全问题。

调度到业务场景时,如果只是工具脚本,Python 完全够用;如果要在服务端高频调用,建议核心计算逻辑用编译型语言或加一层 C 扩展,否则性能瓶颈很快会成为负担。

9. 扩展到“最长公共子序列”时的关键区别

9.1 为什么两个题目总被放在一起比较

最长公共子序列往往被缩写为 LCS,最长公共子串因为缩写也是 LCS,两者名称撞车,所以社区里讨论时经常混在一起。

它们的核心区别我已经在前面强调过:子序列允许跳过字符,子串必须连续。看图对比:

A = "abcdef" B = "abxdfe" 最长公共子序列: "abde" (4 个字符,允许跳) 最长公共子串: "ab" (2 个字符,必须连续)

这个差异直接反映在 DP 方程上:

题型字符不等时字符相等时
最长公共子序列max(dp[i-1][j], dp[i][j-1])dp[i-1][j-1]+1
最长公共子串置 0dp[i-1][j-1]+1

两者在字符相等时的转移规则一模一样,最核心的差别就是“字符不相等时如何处理”。很多人没有意识到这一点,导致两个算法混着写,结果必然是错误输出。

9.2 实际选型建议

某些场景两者会互相补充:

  • 判断两个文本是否同一主题,可以使用最长公共子序列长度占全文比例,因为它对微小插入删除不敏感。
  • 判断两个代码文件是否连续抄袭大段代码,必须使用最长公共子串长度,因为内部插入空行都会使子串断裂,从而降低误报率。

所以了解和掌握这两个算法并不是重复造轮子,而是针对不同场景准备不同的武器。

10. 实测记录:一组随机样例的长度与性能观察

我用 Python 做了个小实验,生成了不同规模的随机字符串对比多种解法的表现:

样例1: m=500, n=500 耗时: 二维DP 约 0.08s / 滚动数组 约 0.06s / 暴力法 约 0.65s 样例2: m=1000, n=1000 耗时: 二维DP 约 0.32s / 滚动数组 约 0.28s / 暴力法 约 5.9s 样例3: m=5000, n=5000 耗时: 二维DP 约 8.1s / 滚动数组 约 7.6s / 暴力法 未测(太慢)

从数据上可以清楚看到:暴力法在长度超过 1000 后就几乎不可用,二维 DP 和滚动数组的差距没有数量级那么大,但滚动数组的内存优势在更大的长度下会越来越明显。

我还试过一个极端场景:m=20000, n=20000,二维 DP 需要约 4 亿格子,内存直接飙到 1.6GB 以上,几乎直接把进程拖垮;滚动数组则只占 20001 个 int,内存几十 KB,运行耗时也稳定在几十秒内。由此可见,生产环境优先使用滚动数组,不只是礼貌性优化,而是保命级别的要求。

11. 最后留几个自己常用的小技巧

第一个技巧:写 DP 表的时候,故意多用几个不同的随机样例去验证,尤其是包含相同前缀、完全相等、全不相等、空字符串这些极端情形。这种用例能一次性覆盖绝大多数边界问题。

第二个技巧:在代码里临时加一行print(dp),人肉观察表的变化,特别适合在初学阶段排查状态转移写错的问题。等逻辑稳定了再删掉调试输出,回归到干净版本。

第三个技巧:如果最终结果只需要长度,就别把dp数组暴露在函数外面,把它封装成局部变量。这样既能防止意外修改状态,也方便后续改成滚动数组版本时不动函数签名。

第四个技巧:遇到“连续片段”相关题目时,先想清楚“以当前位置结尾”的状态词,再用这个状态描述去推转移方程。这招比死记硬背哪道题用什么方程要可靠得多,因为题目千变万化,状态描述才是那个不变的内核。

对我来说,最长公共子串长度不仅是一个算法题,更是一种思维训练:它教会我如何识别“连续性约束”、如何用一维数组压状态、如何在工程里设定阈值过滤噪声。希望这篇内容能帮你在面试和日常开发中都少踩几个坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询