编辑图模型
所有 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) 的路径中水平 + 垂直移动最少的路径。
示例: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 尽可能延伸对角线(免费匹配),然后每次将编辑前沿推进一个编辑操作:
- 从对角线 k=0、位置 x=0 开始
- 对每个编辑距离 d = 0, 1, 2, ...:
- 对每条对角线 k = -d, -d+2, ..., d:
- 选择更好的前驱(来自 k-1 或 k+1)
- 沿对角线尽可能延伸匹配
- 对每条对角线 k = -d, -d+2, ..., d:
- 到达 (M, N) 时停止
关键数据结构是按对角线 k 索引的向量 V,存储该对角线上到达的最远 x 坐标。
实现
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 引入,使用不同的匹配策略:
- 找到在两个文件中都恰好出现一次的行(唯一行)
- 找到这些唯一匹配行的最长递增子序列(LIS)
- 将这些唯一匹配作为锚点
- 用 Myers 递归对比锚点之间的区域
为什么重要
// 文件 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"结果。
使用方式
# Git 中使用 patience diff
git diff --patience
git config diff.algorithm patience
Histogram Diff:Git 2012 年起的默认算法
Histogram Diff(由 Bram Cohen 设计,后为 JGit 优化)通过出现频率计数扩展了 Patience Diff,处理非唯一行:
- 为文件 A 中的行建立出现频率直方图
- 找到最低频率的匹配行作为锚点
- 递归对比锚点之间的区域
这处理了真正唯一行不存在但低频行仍能提供良好锚点的情况。
# Git 2012 年起的默认算法
git diff --histogram
git config diff.algorithm histogram
算法对比
| 算法 | 锚点策略 | 优势 | 劣势 |
|---|---|---|---|
| Myers | 贪心对角线扩展 | 保证最小编辑脚本 | 重复行时 diff 令人困惑 |
| Patience | 仅唯一行 | 代码 diff 更清晰 | 无唯一行时失败 |
| Histogram | 最低频率行 | 最佳通用代码对比 | 稍微更复杂 |
| LCS(经典) | 全矩阵 DP | 实现简单 | O(NM) 空间和时间 |
统一 Diff 格式
输出格式与算法同样重要。统一 diff 格式(diff -u、git diff 和 patch 文件使用)有精确的结构:
--- 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
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 的基础。
模型
Base(祖先)
/ \
Ours Theirs
(我们的更改) (他们的更改)
\ /
Merged
合并逻辑
对文件中的每个区域:
- 双方都未更改 → 保持原样
- 仅我方更改 → 取我方
- 仅对方更改 → 取对方
- 双方相同更改 → 取任一(结果相同)
- 双方不同更改 → 冲突
冲突标记
<<<<<<< HEAD(我方)
return validate_strict(token)
=======
return validate_lenient(token)
>>>>>>> feature-branch(对方)
语义 Diff:超越行比较
文本 diff 将文件视为行的序列。语义 diff 理解内容的结构:
基于 AST 的 Diff
对于源代码,比较抽象语法树而非文本,即使格式发生变化也能产生有意义的 diff:
# 文本 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 理解键顺序无关紧要:
// 文本 diff:2 行变化
- {"name": "Alice", "age": 30}
+ {"age": 30, "name": "Alice"}
// 结构化 diff:无变化(相同对象)
大文件性能优化
行哈希
行级 diff 中,比较完整字符串代价高。先对每行哈希,然后对哈希序列做 diff:
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 无贡献:
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) 动态规划(空间优化版):
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):
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 质量(输出的人类可读性)与计算成本之间的权衡。