2、核心算法原理:最长公共子序列(LCS)算法、Myers差分算法、编辑距离(Levenshtein Distance)算法

聊差分补丁,绕不开三个核心算法。它们就像三把不同的刀,各有各的脾气。我最早接触这些算法是在做代码版本管理工具的时候,那时候为了搞清楚「为什么 git diff 这么快」,硬啃了好几天的论文。今天我把这些心得掰开揉碎讲给你听。

2.1 最长公共子序列(LCS)算法

LCS 要解决什么问题?说白了,就是找两个序列里「最长的那个共同子序列」。注意,子序列不要求连续,但顺序必须一致。

举个例子:ABCBDAB 和 BDCABA,它们的 LCS 是 BCBA 或 BDAB。你想想看,这不就是找两个文件里「没改动的那些行」吗?

2.1.1 动态规划思路

LCS 的经典解法是动态规划。我习惯用一个二维表格来推演:

// 伪代码:LCS 长度计算
function LCSLength(X, Y):
    m = len(X), n = len(Y)
    dp = new int[m+1][n+1]  // 全部初始化为 0
    for i = 1 to m:
        for j = 1 to n:
            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[i][j] 表示 X[0..i-1] 和 Y[0..j-1] 的 LCS 长度。回溯的时候,从右下角往左上角走,就能还原出具体的子序列。

核心要点:LCS 算法的时间复杂度是 O(mn),空间复杂度也是 O(mn)。对于大文件,这个开销不小。我在项目中遇到过 10 万行的日志对比,直接用 LCS 内存就爆了,后来不得不改用分块策略。

2.1.2 从 LCS 到差分

有了 LCS,怎么生成补丁?很简单:LCS 里的字符就是「不变」的部分,剩下的就是「删除」和「插入」。

  • 在 X 中但不在 LCS 中的 → 删除
  • 在 Y 中但不在 LCS 中的 → 插入

但 LCS 有个问题:它只保证长度最长,不保证「看起来最自然」。比如两个文件只有一行变了,LCS 可能给出一个很别扭的匹配结果。嗯,这里就要引出 Myers 了。

2.2 Myers 差分算法

Myers 算法是 git diff 的默认算法。它比 LCS 高明在哪?它追求的是「最短的编辑脚本」——也就是让增删操作的总数最少,而且尽量让删除和插入「对齐」。

我个人觉得,Myers 最漂亮的地方在于它把问题转化成了「在图里找最短路径」。你想想看:

  • 把两个字符串放在一个网格里,横轴是旧序列,纵轴是新序列
  • 向右走一步 = 删除一个旧字符
  • 向下走一步 = 插入一个新字符
  • 向右下走对角线 = 字符匹配,不产生操作

我们要找的就是从左上角到右下角,对角线走得最多、横竖走得最少的那条路径。

2.2.1 贪心 + 回溯

Myers 用了一个很聪明的贪心策略:它从 (0,0) 出发,每次尝试走尽可能多的对角线,然后才考虑横竖移动。它维护一个「k 线」的概念——k = x - y,也就是当前路径偏离对角线的程度。

// 伪代码:Myers 核心逻辑(简化版)
function shortestEditScript(old, new):
    max = len(old) + len(new)
    v = new int[2*max + 1]  // 记录每个 k 线能到达的最远 x
    v[1] = 0
    for d = 0 to max:  // d 是编辑距离
        for k = -d to d step 2:
            // 决定从哪条路径延伸
            if k == -d or (k != d and v[k-1] < v[k+1]):
                x = v[k+1]
            else:
                x = v[k-1] + 1
            y = x - k
            // 尽量走对角线
            while x < len(old) and y < len(new) and old[x] == new[y]:
                x++, y++
            v[k] = x
            if x >= len(old) and y >= len(new):
                return reconstructPath(v, d, k)

避坑指南:我曾经在实现 Myers 时踩过一个坑——k 线的索引偏移。因为 k 可能是负数,数组下标不能为负,所以需要加一个偏移量。这个细节很容易被忽略,但错了就是段错误。

2.2.2 LCS vs Myers 对比

维度 LCS Myers
目标 最长公共子序列 最短编辑脚本
时间复杂度 O(mn) O((m+n)D),D 为编辑距离
空间复杂度 O(mn) O(m+n)
输出 子序列 增删操作序列
适用场景 文本相似度、基因序列 版本控制、差分补丁

你看,Myers 在大多数情况下比 LCS 快得多,因为 D(编辑距离)通常远小于 m+n。但最坏情况下,D 接近 m+n,Myers 就退化到 O((m+n)²) 了。

2.3 编辑距离(Levenshtein Distance)算法

编辑距离,也叫 Levenshtein 距离。它衡量的是「把一个字符串变成另一个字符串,最少需要多少次操作」。操作有三种:插入、删除、替换。

你可能会问:这和差分补丁有什么关系?关系大了。编辑距离本身就是一种「最小代价补丁」的度量。如果你把每行当作一个字符,编辑距离就是「最少改多少行」。

2.3.1 动态规划实现

编辑距离的 DP 表格和 LCS 很像,但递推公式不同:

// 编辑距离 DP
function levenshteinDistance(s1, s2):
    m = len(s1), n = len(s2)
    dp = new int[m+1][n+1]
    // 初始化边界
    for i = 0 to m: dp[i][0] = i
    for j = 0 to n: dp[0][j] = j
    // 填表
    for i = 1 to m:
        for j = 1 to n:
            cost = 0 if s1[i-1] == s2[j-1] else 1
            dp[i][j] = min(
                dp[i-1][j] + 1,      // 删除
                dp[i][j-1] + 1,      // 插入
                dp[i-1][j-1] + cost  // 替换或匹配
            )
    return dp[m][n]

注意:编辑距离的替换操作代价是 1,但在某些场景下(比如基因序列),替换的代价可能不同。我在做代码相似度检测时,就把替换代价设成了 2,因为「改一行」比「删一行再加一行」更常见。这个调参经验,你可以记一下。

2.3.2 三种算法的关系

其实这三个算法是「一家人」:

  • LCS 长度 + 编辑距离 = 字符串总长(当替换代价为 2 时)
  • Myers 的 D 值 = 编辑距离(当替换代价为 2 时)
  • LCS 回溯 + 补集 = 差分补丁

说白了,它们从不同角度描述同一件事:两个序列的差异有多大、差异在哪。

2.4 算法对比与选型建议

我做了这么多年工具,选型经验就一句话:看数据规模和实时性要求。

场景 推荐算法 理由
小文件(<1000 行) LCS 或 Myers 性能差异不大,实现简单优先
大文件(>10000 行) Myers 空间友好,平均速度快
需要相似度分数 编辑距离 直接输出数值,方便排序
实时交互预览 Myers + 分块 避免全量计算,分段 diff

我的习惯:做通用差分工具时,默认用 Myers。如果用户需要「相似度百分比」,我再额外算一个归一化的编辑距离。这样既保证了 diff 质量,又提供了量化指标。

2.5 知识体系总览

下面这张图把三个算法的关系、输入输出、适用场景串起来了。我建议你多看几遍,理解它们之间的转换关系。

差分补丁核心算法知识体系 差分补丁核心算法知识体系 输入:旧序列 + 新序列 LCS 算法 最长公共子序列 Myers 差分算法 最短编辑脚本 编辑距离算法 Levenshtein Distance 输出:不变/删除/插入 输出:增删操作序列 输出:编辑距离数值 应用场景 版本控制 (git diff) | 代码审查 | 文件同步 | 日志对比 | 相似度检测 三者关系 LCS长度 + 编辑距离 = 字符串总长(替换代价为2时) | Myers的D值 = 编辑距离

这张图把三个算法的关系理清楚了。你从输入出发,经过不同的算法,得到不同的输出,最后都落到实际应用里。我个人建议你先把 LCS 和编辑距离的 DP 实现写一遍,再去看 Myers 的贪心策略,这样理解起来会顺很多。