编辑图模型

所有 Diff 算法都基于同一个基础模型:编辑图,其中找到最短路径等价于找到最小编辑脚本。

给定序列 A(长度 M)和 B(长度 N),构建 (M+1) × (N+1) 网格:

  • 水平边 (x, y) → (x+1, y):删除 A[x]
  • 垂直边 (x, y) → (x, y+1):插入 B[y]
  • 对角边 (x, y) → (x+1, y+1):匹配 A[x] = B[y](免费 — 无编辑代价)

最短编辑脚本 = 从 (0,0) 到 (M,N) 的路径中水平 + 垂直移动最少的路径。

code
示例:A = "ABCABBA", B = "CBABAC"

     C  B  A  B  A  C
  0──1──2──3──4──5──6
  |  |╲ |  |  |  |  |
A 1──.──.──╲──.──╲──.
  |  |  |╲ |  |  |  |
B 2──.──╲──.──╲──.──.
  |╲ |  |  |  |  |╲ |
C 3──╲──.──.──.──.──╲
  |  |  |╲ |  |╲ |  |
A 4──.──.──╲──.──╲──.
  |  |╲ |  |╲ |  |  |
B 5──.──╲──.──╲──.──.
  |  |╲ |  |╲ |  |  |
B 6──.──╲──.──╲──.──.
  |  |  |╲ |  |╲ |  |
A 7──.──.──╲──.──╲──.

对角线 = 免费匹配
水平 = 从 B 插入
垂直 = 从 A 删除

Myers Diff:O(ND) 算法

Eugene Myers 1986 年的论文 "An O(ND) Difference Algorithm and Its Variations" 是大多数 Diff 工具的基础。其核心洞见:如果编辑距离 D 相对于输入大小较小(比较相似文件时典型情况),算法运行时间为 O(ND) — 对于差异较少的文件接近线性。

贪心策略

Myers 尽可能延伸对角线(免费匹配),然后每次将编辑前沿推进一个编辑操作:

  1. 从对角线 k=0、位置 x=0 开始
  2. 对每个编辑距离 d = 0, 1, 2, ...:
    • 对每条对角线 k = -d, -d+2, ..., d:
      • 选择更好的前驱(来自 k-1 或 k+1)
      • 沿对角线尽可能延伸匹配
  3. 到达 (M, N) 时停止

关键数据结构是按对角线 k 索引的向量 V,存储该对角线上到达的最远 x 坐标。

实现

python
def myers_diff(a: list, b: list) -> list:
    n, m = len(a), len(b)
    max_d = n + m
    
    v = {1: 0}
    trace = []
    
    for d in range(max_d + 1):
        trace.append(dict(v))
        for k in range(-d, d + 1, 2):
            if k == -d or (k != d and v.get(k - 1, 0) < v.get(k + 1, 0)):
                x = v.get(k + 1, 0)
            else:
                x = v.get(k - 1, 0) + 1
            
            y = x - k
            
            while x < n and y < m and a[x] == b[y]:
                x += 1
                y += 1
            
            v[k] = x
            
            if x >= n and y >= m:
                return _backtrack(trace, a, b, n, m, d)
    
    return []


def _backtrack(trace, a, b, n, m, d_final):
    edits = []
    x, y = n, m
    
    for d in range(d_final, 0, -1):
        v = trace[d]
        k = x - y
        
        if k == -d or (k != d and v.get(k - 1, 0) < v.get(k + 1, 0)):
            prev_k = k + 1
        else:
            prev_k = k - 1
        
        prev_x = v.get(prev_k, 0)
        prev_y = prev_x - prev_k
        
        while x > prev_x and y > prev_y:
            edits.append(('equal', a[x - 1]))
            x -= 1
            y -= 1
        
        if x == prev_x:
            edits.append(('insert', b[y - 1]))
            y -= 1
        else:
            edits.append(('delete', a[x - 1]))
            x -= 1
    
    while x > 0 and y > 0:
        edits.append(('equal', a[x - 1]))
        x -= 1
        y -= 1
    
    edits.reverse()
    return edits

复杂度分析

场景 时间 空间
最好情况(完全相同) O(N) O(N)
典型情况(少量更改) O(N + D²) O(D²) 或使用线性优化 O(N)
最坏情况(完全不同) O(N × M) O(N × M)

对于版本控制(比较相似的修订版),D 通常很小,使 Myers 在实践中接近线性。

Patience Diff:处理重复行

当大量行相同时(如代码中的右花括号 }),Myers 可能产生令人困惑的结果。Patience Diff 由 Bram Cohen 引入,使用不同的匹配策略:

  1. 找到在两个文件中都恰好出现一次的行(唯一行)
  2. 找到这些唯一匹配行的最长递增子序列(LIS)
  3. 将这些唯一匹配作为锚点
  4. 用 Myers 递归对比锚点之间的区域

为什么重要

c
// 文件 A
void func_a() {
    int x = 1;
}

void func_b() {
    int x = 2;
}

// 文件 B(删除了 func_a)
void func_b() {
    int x = 2;
}

Myers 可能将 func_a}func_b} 匹配,产生令人困惑的 diff(显示修改 func_a 而非删除它)。Patience Diff 以唯一的函数签名为锚点,产生更清晰的"完整删除 func_a"结果。

使用方式

bash
# Git 中使用 patience diff
git diff --patience
git config diff.algorithm patience

Histogram Diff:Git 2012 年起的默认算法

Histogram Diff(由 Bram Cohen 设计,后为 JGit 优化)通过出现频率计数扩展了 Patience Diff,处理非唯一行:

  1. 为文件 A 中的行建立出现频率直方图
  2. 找到最低频率的匹配行作为锚点
  3. 递归对比锚点之间的区域

这处理了真正唯一行不存在但低频行仍能提供良好锚点的情况。

bash
# Git 2012 年起的默认算法
git diff --histogram
git config diff.algorithm histogram

算法对比

算法 锚点策略 优势 劣势
Myers 贪心对角线扩展 保证最小编辑脚本 重复行时 diff 令人困惑
Patience 仅唯一行 代码 diff 更清晰 无唯一行时失败
Histogram 最低频率行 最佳通用代码对比 稍微更复杂
LCS(经典) 全矩阵 DP 实现简单 O(NM) 空间和时间

统一 Diff 格式

输出格式与算法同样重要。统一 diff 格式(diff -ugit diff 和 patch 文件使用)有精确的结构:

diff
--- a/src/auth.py
+++ b/src/auth.py
@@ -15,7 +15,9 @@ class AuthHandler:
     def validate_token(self, token: str) -> bool:
         """Validate JWT token."""
-        decoded = jwt.decode(token, self.secret)
-        return decoded is not None
+        try:
+            decoded = jwt.decode(token, self.secret, algorithms=["HS256"])
+            return not self._is_expired(decoded)
+        except jwt.InvalidTokenError:
+            return False
 
     def refresh_token(self, token: str) -> str:

格式解剖

组件 含义
--- a/path 原始文件路径
+++ b/path 修改后文件路径
@@ -15,7 +15,9 @@ Hunk 头:原文从第 15 行开始(7 行),新文从第 15 行开始(9 行)
(空格前缀) 上下文行(未变)
- 前缀 删除的行
+ 前缀 添加的行
@@ ... @@ function_name 可选:最近的函数/类名作为上下文

编程生成统一 Diff

python
import difflib

def unified_diff(text_a: str, text_b: str, filename: str = "file") -> str:
    lines_a = text_a.splitlines(keepends=True)
    lines_b = text_b.splitlines(keepends=True)
    
    diff = difflib.unified_diff(
        lines_a, lines_b,
        fromfile=f"a/{filename}",
        tofile=f"b/{filename}",
        lineterm=""
    )
    return "".join(diff)

三路合并

双向 diff 展示什么发生了变化。三路合并确定如何将两个分叉的编辑相对于共同祖先合并 — 这是 git merge 的基础。

模型

code
     Base(祖先)
      /         \
   Ours          Theirs
(我们的更改)  (他们的更改)
      \         /
       Merged

合并逻辑

对文件中的每个区域:

  1. 双方都未更改 → 保持原样
  2. 仅我方更改 → 取我方
  3. 仅对方更改 → 取对方
  4. 双方相同更改 → 取任一(结果相同)
  5. 双方不同更改冲突

冲突标记

code
<<<<<<< HEAD(我方)
    return validate_strict(token)
=======
    return validate_lenient(token)
>>>>>>> feature-branch(对方)

语义 Diff:超越行比较

文本 diff 将文件视为行的序列。语义 diff 理解内容的结构:

基于 AST 的 Diff

对于源代码,比较抽象语法树而非文本,即使格式发生变化也能产生有意义的 diff:

python
# 文本 diff 看到 3 行变化:
- x = foo(a, b, c)
+ x = foo(
+     a,
+     b,
+     c
+ )

# AST diff 看到:无变化(相同的函数调用、相同的参数)

工具:

  • GumTree — 语言无关的 AST diff(Java、Python、JavaScript、C)
  • difftastic — 使用 tree-sitter 解析器的结构化 diff
  • semantic(GitHub)— 多语言语义 diff

JSON/YAML 结构化 Diff

对于配置文件,结构化 diff 理解键顺序无关紧要:

json
// 文本 diff:2 行变化
- {"name": "Alice", "age": 30}
+ {"age": 30, "name": "Alice"}

// 结构化 diff:无变化(相同对象)

大文件性能优化

行哈希

行级 diff 中,比较完整字符串代价高。先对每行哈希,然后对哈希序列做 diff:

python
import hashlib

def diff_large_files(path_a: str, path_b: str):
    def hash_lines(path):
        with open(path) as f:
            return [hashlib.md5(line.encode()).digest() for line in f]
    
    hashes_a = hash_lines(path_a)
    hashes_b = hash_lines(path_b)
    
    # 对哈希序列做 diff(整数比较,非字符串)
    edits = myers_diff(hashes_a, hashes_b)
    return edits

预处理:公共前后缀裁剪

在运行 O(ND) 算法前,剥离匹配的前缀和后缀 — 它们对 diff 无贡献:

python
def trim_common(a: list, b: list):
    prefix = 0
    while prefix < len(a) and prefix < len(b) and a[prefix] == b[prefix]:
        prefix += 1
    
    suffix = 0
    while (suffix < len(a) - prefix and suffix < len(b) - prefix 
           and a[-(suffix + 1)] == b[-(suffix + 1)]):
        suffix += 1
    
    core_a = a[prefix:len(a) - suffix] if suffix else a[prefix:]
    core_b = b[prefix:len(b) - suffix] if suffix else b[prefix:]
    
    return prefix, suffix, core_a, core_b

仅此一步就能将 10,000 行文件对比缩减为仅 diff 中间的 50 行修改内容。

按输入规模选择算法

输入规模 推荐方案
< 1,000 行 Myers(简单、最优)
1,000–100,000 行 Histogram Diff + 前后缀裁剪
> 100,000 行 行哈希 + 裁剪 + Histogram
二进制/结构化 专用工具(AST diff、rsync 滚动哈希)

LCS 与编辑距离基础

最长公共子序列(LCS)

LCS 是最短编辑脚本的对偶:编辑距离 = len(A) + len(B) - 2 × LCS长度

经典 O(NM) 动态规划(空间优化版):

python
def lcs_length(a: list, b: list) -> int:
    m, n = len(a), len(b)
    prev = [0] * (n + 1)
    curr = [0] * (n + 1)
    
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if a[i - 1] == b[j - 1]:
                curr[j] = prev[j - 1] + 1
            else:
                curr[j] = max(prev[j], curr[j - 1])
        prev, curr = curr, [0] * (n + 1)
    
    return prev[n]

Levenshtein 编辑距离

Levenshtein 距离增加了替换作为原子操作(代价 1),而基于 LCS 的 diff 仅使用插入 + 删除(替换 = 删除 + 插入,代价 2):

python
def levenshtein(a: str, b: str) -> int:
    m, n = len(a), len(b)
    prev = list(range(n + 1))
    
    for i in range(1, m + 1):
        curr = [i] + [0] * n
        for j in range(1, n + 1):
            if a[i - 1] == b[j - 1]:
                curr[j] = prev[j - 1]
            else:
                curr[j] = 1 + min(prev[j], curr[j - 1], prev[j - 1])
        prev = curr
    
    return prev[n]
度量 操作 使用场景
LCS 距离 插入、删除 Diff/Patch(无原地编辑概念)
Levenshtein 插入、删除、替换 拼写检查、模糊匹配
Damerau-Levenshtein + 转位 打字错误检测(ab → ba)

总结

文本 Diff 是一个成熟的领域,算法选择清晰:

  • Myers 用于最小编辑脚本,可证明最优
  • Patience 用于有唯一行时更清晰的代码 diff
  • Histogram(Git 默认)用于最佳通用代码对比
  • AST/语义 diff 用于格式变化应不可见时
  • 三路合并 用于集成基于共同祖先的并发编辑

统一 diff 格式是通用交换格式。行哈希和前后缀裁剪使大文件对比切实可行。算法选择是 diff 质量(输出的人类可读性)与计算成本之间的权衡。