多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

最长公共英文单词序列:从分词陷阱到动态规划实战

最长公共英文单词序列:从分词陷阱到动态规划实战 1. 从一道字符串题说起为什么最长公共英文单词值得单独拎出来讲很多人第一次看到最长公共英文单词这个标题会下意识觉得这不就是最长公共子序列LCS的变体吗把字符换成单词套个模板就完事了。我一开始也是这么想的直到真正动手写了一遍才发现坑远比想象中多。字符级别的 LCS 和单词级别的 LCS看似只差一个分词步骤实际上在边界处理、匹配规则、性能表现上完全是两码事。这个问题的核心定义其实很直白给定两段英文文本找出它们共有的、长度最长的那个单词序列。注意我这里说的是单词序列不是单个单词。比如一段文本是 the quick brown fox jumps over the lazy dog另一段是 a quick brown cat jumps over the fence那么它们的最长公共单词序列就是 quick brown长度为 2 个单词如果按字符算quick brown 和 jumps over 都是候选但按单词粒度jumps over 也是公共的长度同样是 2这时候就要看具体规则怎么定义最长——是按单词个数还是按字符总长度。这就是第一个容易被忽略的分歧点。最长到底以什么为单位是按单词数量计还是按这些单词拼起来的字符总数计两种定义会导出完全不同的结果。我在实际项目里两种都实现过后面会详细对比。它适合谁来参考我认为有三类人一是正在刷算法题、想从模板题里跳出来理解变体的开发者二是做文本比对、版本 diff、抄袭检测这类工具的同学单词级公共序列是绕不开的基础能力三是做自然语言处理入门、想搞清楚分词和序列对齐关系的学习者。哪怕你只是好奇两篇文章到底有多像这个算法也能给你一个可解释的量化答案。接下来我不打算给你贴一段教科书式的动态规划代码就完事而是把整个思考链路、分词陷阱、性能取舍、以及我踩过的真实坑一层层拆开讲。你看完之后应该能自己从零写出一个能扛住真实文本的版本而不是只能跑通样例。2. 单词级 LCS 和字符级 LCS 的本质差异2.1 分词这一步决定了问题的难度上限字符级 LCS 之所以简单是因为字符是天然切分好的一个中文句子、一段英文拆成字符数组没有任何歧义。但单词级不一样英文的分词本身就是个有争议的问题。最朴素的做法是按空格和标点切但现实文本里有一堆麻烦情况连字符词state-of-the-art 算一个单词还是四个缩写dont、its 算一个还是两个数字和单位3.14、10kg 怎么处理大小写The 和 the 算不算同一个单词我最初写的第一版就是简单split(/\s/)结果跑真实文本时发现 dog. 和 dog 被当成两个不同的词公共序列直接断掉。这个 bug 非常隐蔽因为样例数据往往很干净只有真实语料才会暴露。所以分词策略必须提前定死并且和你的业务目标对齐。如果是做文本相似度通常要统一转小写、剥离标点如果是做代码 diff那标点可能恰恰是关键信息不能随便丢。这一步没有标准答案只有适合你场景的答案。2.2 匹配粒度变了状态转移也跟着变字符级 LCS 的状态转移是如果a[i] b[j]则dp[i][j] dp[i-1][j-1] 1否则取max(dp[i-1][j], dp[i][j-1])。单词级在结构上完全一样只是把字符相等换成了单词相等。听起来只是换了个比较对象但实际影响很大。字符级里两个字符相等的概率相对高序列容易连续匹配单词级里两个单词完全相等的概率低得多尤其是长文本公共单词序列往往是断断续续的。这导致单词级 LCS 的结果对是否忽略停用词极其敏感。像 the、a、of 这种高频词如果不去掉它们会大量出现在公共序列里把真正有意义的匹配淹没掉。我做过一个对比实验两段各 500 词的技术文档不去停用词时最长公共单词序列长度是 47去掉停用词后降到 12但这 12 个词全是领域术语信息量远高于那 47 个。所以停用词处理不是可选项而是决定结果有没有意义的关键开关。2.3 复杂度直觉为什么单词级反而可能更快理论上两者时间复杂度都是 O(m×n)m、n 是序列长度。但单词级有个隐藏优势序列长度通常远小于字符长度。一段 1000 字符的英文字符级是 1000 个元素单词级可能只有 180 个词。O(1000²) 和 O(180²) 差了 30 倍。不过这个优势有个前提分词本身不能太慢。如果你用复杂的 NLP 分词器分词开销可能把省下来的时间又吃回去。所以我的经验是对性能敏感的场景用轻量正则分词 停用词表别一上来就上重型工具。下面这张表是我实测的粗略对比供你建立直觉维度字符级 LCS单词级 LCS序列长度1000字符英文约 1000约 180时间复杂度O(m×n)O(m×n)但 m、n 更小分词开销无有取决于策略结果可解释性低字符碎片高完整单词对停用词敏感度不适用极高这张表不是让你背而是让你在选型时有个判断依据。如果你的目标是给人看的结果单词级几乎总是更好的选择如果只是内部做哈希指纹字符级可能够用。3. 动态规划解法从二维表到可读的公共序列3.1 二维 DP 表的构建逻辑标准解法是建一个(m1) × (n1)的二维数组dp[i][j]表示第一段文本前 i 个单词和第二段文本前 j 个单词的最长公共单词序列长度。初始化第一行第一列为 0然后双重循环填表。我用一个具体例子走一遍你就明白为什么这么设计。假设A [the, quick, brown, fox]B [a, quick, brown, dog]去掉停用词后 A [quick, brown, fox]B [quick, brown, dog]。填表过程 quick brown dog 0 0 0 0 quick 0 1 1 1 brown 0 1 2 2 fox 0 1 2 2右下角dp[3][3] 2就是最长公共单词序列长度。这个表的好处是每个格子都能解释dp[2][2]2表示 quick brown 和 quick brown 完全匹配。为什么用二维表而不是一维滚动数组因为回溯需要完整信息。如果你只要长度一维数组省空间但你要输出具体是哪些单词就必须保留整张表从右下角往回走。这是很多人第一次实现时忽略的点——算出了长度却不知道怎么还原序列。3.2 回溯还原序列的完整步骤回溯的逻辑是从dp[m][n]出发如果A[i-1] B[j-1]说明这个单词是公共序列的一部分记录下来然后往左上角走i--, j--否则比较dp[i-1][j]和dp[i][j-1]往大的方向走。走到i0或j0停止最后把记录的结果反转。这里有个细节我必须提醒当dp[i-1][j] dp[i][j-1]时往哪边走两条路径可能对应不同的公共序列但长度相同。标准做法是任选一条但如果你需要字典序最小或最靠前的序列就得加额外规则。我在做文本比对工具时用户反馈为什么同样的输入两次结果不一样排查半天发现就是这里没定死规则导致回溯路径不稳定。后来我统一规定优先向上走结果就稳定了。下面是我实际用的 Python 实现注释写得比较细def longest_common_words(text_a, text_b, stopwordsNone): # 分词转小写按非字母数字切分过滤空串 import re def tokenize(text): words re.findall(r[a-zA-Z0-9], text.lower()) if stopwords: words [w for w in words if w not in stopwords] return words A tokenize(text_a) B tokenize(text_b) m, n len(A), len(B) # 建表 dp [[0] * (n 1) for _ in range(m 1)] 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 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 A[i-1] B[j-1]: result.append(A[i-1]) i - 1 j - 1 elif dp[i-1][j] dp[i][j-1]: i - 1 else: j - 1 result.reverse() return result, dp[m][n]这段代码可以直接跑。注意re.findall(r[a-zA-Z0-9], ...)这个正则它把连字符切开了但保留了撇号所以 dont 会作为一个整体。这是我权衡后的选择——连字符词在技术文本里往往是复合概念切开反而丢失语义而撇号保留能让缩写不被拆散。3.3 空间优化什么时候该用滚动数组如果你的文本很长比如两篇各 5000 词的文档二维表就是 2500 万个整数Python 里轻松吃掉几百 MB 内存。这时候如果只需要长度、不需要具体序列就可以用滚动数组把空间降到 O(n)。滚动数组的原理是dp[i][j]只依赖dp[i-1][j-1]、dp[i-1][j]、dp[i][j-1]也就是上一行和当前行左边的值。所以只需要保存两行用一个变量暂存左上角的值即可。但我要泼个冷水滚动数组和回溯还原序列是冲突的。你省了空间就丢了回溯所需的信息。所以选型时要先问自己我到底要不要输出具体是哪些单词如果只是算个相似度分数滚动数组很香如果要展示这两段文本共享了这些词老老实实开二维表或者用 Hirschberg 算法做分治这个后面提。4. 分词与预处理决定结果质量的关键环节4.1 停用词表怎么选别照搬网上的网上随便一搜就有英文停用词表 179 个很多人直接复制粘贴。我试过效果一般。原因是停用词表必须和你的语料领域匹配。通用停用词表包含 the、a、is 这些但在技术文档里system、data、user 这种词出现频率极高它们对区分度几乎没有贡献却不在通用停用词表里。我的做法是先跑一遍词频统计把两段文本里都高频出现的词手动加进停用词表。具体阈值可以这样定如果某个词在两段文本里的出现次数都超过总词数的 2%且它不是领域术语就考虑过滤。这个 2% 不是拍脑袋是我在几份不同长度的文档上试出来的经验值——太低会误杀有意义的词太高则过滤不干净。另外停用词过滤要在分词之后、建表之前做不要在建表过程中动态判断那样会拖慢内层循环。我见过有人把停用词判断写进 DP 的双重循环里性能直接掉一半。4.2 词形还原要不要做做到什么程度running 和 run、dogs 和 dog算不算同一个词如果不做词形还原它们会被当成不同单词公共序列就会断掉。但做词形还原有代价需要引入额外的库比如 NLTK 或 spaCy增加依赖和运行时间。我的建议是分场景做文本相似度、抄袭检测建议做用轻量的 Porter Stemmer 就够不需要完整的词形还原。它把 running 砍成 rundogs 砍成 dog虽然会产生一些奇怪的词干但对匹配来说够用。做代码 diff、配置比对不要做代码里的标识符是精确的userList 和 userLists 可能就是两个不同的变量。做教学演示可以先不做保持逻辑清晰等读者理解了核心算法再引入。Porter Stemmer 的实现很简单Python 里nltk.stem.PorterStemmer一行就能用。但注意它会把 university 和 universe 都砍成 univers造成误匹配。所以词形还原是双刃剑用之前想清楚你的场景能不能承受这种误匹配。4.3 标点与大小写的处理边界大小写统一转小写这个几乎没有争议除非你在做大小写敏感的比对比如密码、哈希。标点处理则要小心。我踩过的一个坑把标点全部删掉后end. 和 end 合并了这没问题但 U.S. 被拆成 u 和 se.g. 被拆成 e 和 g这些碎片会污染公共序列。后来我改成用正则保留字母数字撇号把其他字符当分隔符这样 U.S. 会变成 u 和 s 两个词——还是不对。最终的解决方案是加一个缩写白名单在分词前先把常见缩写替换成占位符分词后再还原。比如把 e.g. 替换成 egi.e. 替换成 ie。这个白名单不用很长二三十个常见缩写就够覆盖大部分场景。这个技巧我在多个文本处理项目里都用过非常实用。5. 性能实测从暴力递归到分治优化5.1 暴力递归为什么不能用最直观的解法是递归比较两个单词相等就加一继续不等就分别去掉一个再递归。这个解法的时间复杂度是指数级的 O(2^(mn))两段各 30 个词的文本就能让你等到天荒地老。我实测过mn25 时纯递归跑了将近 40 秒mn30 时直接超过 5 分钟。所以暴力递归只适合讲原理绝对不能上生产。记忆化搜索是它的直接优化用一个字典缓存(i, j)的结果复杂度降到 O(m×n)。但记忆化搜索的常数比迭代版大Python 里递归调用本身也有开销。所以我的选择是讲原理用递归写代码用迭代。5.2 二维 DP 的实测数据我在一台普通笔记本上跑了几组数据文本用随机英文词生成停用词过滤后统计文本长度词二维 DP 耗时内存占用100 × 100约 8 ms约 0.1 MB500 × 500约 180 ms约 2 MB1000 × 1000约 750 ms约 8 MB2000 × 2000约 3.1 s约 32 MB5000 × 5000约 19 s约 200 MB可以看到1000 词以内二维 DP 完全够用超过 2000 词就开始难受了。如果你的场景经常处理长文档就得考虑优化。5.3 Hirschberg 算法省空间又不丢序列Hirschberg 算法是分治版的 LCS核心思想是把问题从中点切开分别计算前半段和后半段的最长公共序列长度找到分割点后递归处理。它的空间复杂度是 O(min(m,n))时间复杂度仍是 O(m×n)但常数略大。这个算法的价值在于既能还原序列又不用开满二维表。代价是实现复杂度高不少回溯逻辑要改成递归分治。我在处理超长文档上万词时用过效果不错但代码调试花了大半天。如果你只是处理几百词的文本没必要上这个二维 DP 简单可靠。这里给一个判断标准文本长度超过 3000 词且需要输出具体序列才考虑 Hirschberg否则二维 DP 或滚动数组就够了。别为了炫技把简单问题复杂化这是我在多个项目里反复验证的教训。6. 那些只有真正跑过才会遇到的坑6.1 空序列和单元素序列的边界如果两段文本没有任何公共单词dp[m][n]是 0回溯时while循环一次都不进返回空列表。这个逻辑没问题但调用方要能处理空结果。我见过有人拿到空列表后直接result[0]程序崩溃。所以返回前最好加个判断或者文档里明确写清楚可能返回空。单元素序列也容易出问题。如果公共序列只有一个词回溯时记录一次就结束了但有些实现会在i或j归零后多记录一次导致结果重复。这个 bug 在样例里看不出来只有仔细检查边界才会发现。6.2 重复单词导致的幽灵匹配假设 A [cat, cat, dog]B [cat, dog, dog]。最长公共单词序列是什么答案是 [cat, dog]长度 2。但如果你在回溯时没处理好可能会匹配到第一个 cat 和第二个 dog结果看起来对但对应的位置信息是错的。更隐蔽的情况是同一个单词在文本里出现多次回溯路径选择不同得到的序列内容相同但位置不同。如果你后续要用位置信息做高亮显示这个差异就会暴露。我的做法是回溯时记录(单词, A中的索引, B中的索引)三元组而不是只记单词。这样无论路径怎么选位置信息都是准确的。6.3 大文本下的内存爆炸与应对前面提到 5000×5000 的二维表要 200 MB这还只是整数。如果每个格子存的是对象或字符串内存会翻好几倍。我遇到过一次线上事故用户上传了两篇各 8000 词的文章服务直接 OOM 挂掉。解决方案有三条按优先级排限制输入长度超过阈值就截断或拒绝这是最省事的。用滚动数组算长度只在需要时用 Hirschberg 还原序列兼顾内存和功能。用array模块或 NumPy 存表Python 的 list of list 内存开销大换成array(H)或 NumPy 的uint16数组能省一半以上。我最终采用的是方案 1 方案 3 组合限制单段文本不超过 5000 词同时用 NumPy 存表。实测 5000×5000 的内存从 200 MB 降到 50 MB 左右效果明显。6.4 结果不稳定回溯路径的确定性前面提过dp[i-1][j] dp[i][j-1]时的选择问题。除了影响序列内容它还会影响结果的稳定性。同样的输入如果两次运行走了不同路径输出的序列可能不同这在需要缓存或对比的场景里是灾难。我的做法是强制规定优先级相等时优先向上走i--。这样路径唯一结果确定。这个规则要写进注释避免后来维护的人改掉。另外如果你需要最靠前的序列可以改成优先向左走但一定要统一。7. 从算法到应用这个能力能解决什么真实问题7.1 文本相似度打分最长公共单词序列的长度可以直接作为一个相似度指标。最简单的归一化方式是除以两段文本长度的最大值similarity lcs_len / max(len(A), len(B))。这个值在 0 到 1 之间越大越相似。但我要提醒这个指标对长度差异大的文本不公平。一段 100 词的文本和一段 1000 词的文本即使前者完全包含在后者里相似度也只有 0.1。所以更合理的做法是用lcs_len / min(len(A), len(B))衡量短文本有多少比例被长文本覆盖。具体用哪个取决于你的业务语义。7.2 版本 diff 与变更检测两版文档之间公共单词序列就是没变的部分剩下的就是新增或删除的部分。这比字符级 diff 更符合人的阅读习惯——你不会想看到 th 和 e 被拆开显示。实现上拿到公共序列后用双指针分别扫描两段文本遇到公共词就跳过遇到非公共词就标记为增删。这个逻辑不复杂但要注意公共序列里的词在两段文本中可能出现多次双指针要按顺序匹配不能随便跳。7.3 抄袭检测的粗筛在正式做语义级抄袭检测之前可以用最长公共单词序列做粗筛。如果两篇文章的公共单词序列长度超过某个阈值比如各自长度的 60%就标记为可疑进入人工复核。这个粗筛速度快、可解释性强比直接上复杂模型更实用。不过要注意公共单词序列长不代表一定抄袭可能是同一领域的通用表述。所以它只能做粗筛不能做最终判定。我在实际使用中会把公共序列里的停用词和领域高频词过滤掉只看剩下的实词序列这样准确率高很多。8. 我个人的几个实操建议第一先把分词和停用词策略定死再写算法。我见过太多人算法写得漂亮结果因为分词没处理好输出一堆无意义的匹配。分词是地基地基不稳楼越高越危险。第二边界测试用例一定要覆盖空文本、单单词、完全无交集、完全包含、重复单词。这五类用例能帮你抓出 90% 的 bug。我现在的习惯是写完算法先跑这五组通过了再上真实数据。第三别迷信复杂度更低的算法。Hirschberg 理论上省空间但实现复杂、常数大短文本上反而不如二维 DP。选型要看实际数据规模不是看理论最优。第四结果要可解释。只返回一个数字相似度价值有限返回具体的公共单词序列用户才能理解为什么这两段文本被判为相似。这一点在做工具类产品时尤其重要。最后分享一个小技巧如果你需要频繁对同一批文本做两两比对可以先把每段文本的单词序列做哈希缓存避免重复分词。分词在整体耗时里占比不低缓存能省下可观的重复计算。这个优化我在一个文档管理工具里用过批量比对 200 篇文档的耗时从 40 秒降到 12 秒效果立竿见影。
返回列表