Diff 是怎么工作的:Unified Diff 格式、Myers 算法与流畅读懂补丁
2026-08-13
git diff、代码评审、patch 文件、合并冲突——开发者的工作日常就是被 diff 框起来的。大多数人每天读 diff 却从没问过它是怎么生成的,这一直没问题,直到出问题的某一天:diff 坚持说你重写了一个其实只是挪了位置的函数;一个毫无道理的合并冲突;或者改了一个词却产生了一万行的补丁。这篇文章讲清楚背后到底在发生什么:找差异的算法、编码差异的 unified 格式,以及为什么有些 diff 看起来”错了”其实技术上是对的。
Diff 真正在解决的问题
给定两个文件,“差异”听起来显而易见,直到你试图定义它。中间改了一行——简单。但把一段从开头挪到结尾,立刻出现许多同样成立的描述:这里删 40 行、那里插 40 行——或者把它上面那 3 行删掉再插回它上方——如此种种,数量是指数级的。diff 工具必须选一个,而合理的标准是:尽可能小的编辑脚本——把文件 A 变成文件 B 所需的最少行插入和删除。
用数学语言重述,这就是最长公共子序列(LCS) 问题。找出在两个文件中以相同顺序出现(不要求连续)的最长行序列,剩下的就是 diff:在 A 中但不在 LCS 中的行是删除,在 B 中但不在 LCS 中的行是插入。编辑最少 = 公共行最多。
Myers:Git Diff 背后的算法
朴素地计算 LCS 时间和内存都是平方级——对一段文字没问题,对五万行的文件就很痛苦。Git 使用的算法由 Eugene Myers 在 1986 年发表,能在 O((N+M)·D) 时间内找到最小编辑脚本,其中 D 是差异本身的大小。实践中最关键的推论是:diff 的开销跟文件的差异程度成正比,而不只是跟文件大小成正比。两个几乎相同的巨型文件 diff 很快;两个完全不同的小文件也很快;最坏情况是大文件加大改动。
Myers 的洞察很优雅:构建一个网格,X 轴是文件 A、Y 轴是文件 B,找最小 diff 就变成找从一角到对角的最短路径——向右走(删除)、向下走(插入)、或走对角线(匹配的行,零成本)。Git 的实现在此之上加了启发式——比如把变更块对齐到空行和函数边界——这就是为什么它的输出读起来通常很自然。
读懂 Unified Diff:格式解码
Unified diff 格式(git diff 和 diff -u 的输出)把编辑脚本压缩成紧凑、可读的文本。一个变更块(hunk)的解剖:
--- a/src/config.js
+++ b/src/config.js
@@ -10,7 +10,7 @@ function loadConfig() {
const env = getEnv();
- const timeout = 3000;
+ const timeout = 5000;
return { env, timeout };
}
--- a/.../+++ b/...—— “改前”和”改后”的文件(a/、b/前缀是 Git 的约定)。@@ -10,7 +10,7 @@—— hunk 头:这一块从旧文件第 10 行开始(跨 7 行),新文件同样从第 10 行开始(也是 7 行)。两边数字不同时,比如-10,7 +10,9,说明这一块净增了两行。第二个@@后面的文字是上下文提示,通常是所在的函数。(空格)—— 上下文行,两个文件里都有。-—— 删除的行;+—— 新增的行。一行”被修改”永远显示为一对-/+:不存在”修改”操作,只有删除加插入。
默认每个变更上下各带 3 行上下文;git diff -U10 可以加宽。而 Diff 对比工具把同样的信息并排渲染加高亮,对长篇文字或配置文件的对比往往比裸补丁格式更友好。
为什么有些 diff 看起来是错的
理解算法就能解释那些经典的恼火时刻:
- “我只是挪了个函数,diff 却显示重写了。” 基于行的 diff 没有”移动”概念——移动字面就是删除加插入。有些工具用启发式检测移动块(Git 有
git diff --color-moved),但底层编辑脚本无法表达”移动”。 - 重新格式化带来的 diff 噪音。 格式化工具重排缩进后,每一行被碰过的行都是变更——最小编辑脚本忠实地报告:是的,这 2000 行不一样了。忽略空白模式(
git diff -w)就是为这个存在的。 - 存在更好的 diff 但没被找到。 最短路径搜索锚定在完全匹配的行上。如果你把每行都改了一点(比如每行都升了个版本号),LCS 崩塌,diff 退化成”全删全插”——正确,但没用。
- JSON 等结构化数据。 美化后的 JSON 做行级 diff 尚可,但只要调换一个键的位置,噪音就会淹没真正的变化。结构化感知的工具比较的是解析后的值而不是文本:我们的 JSON Diff展示的是哪些键和值真正变了,与键顺序和格式无关——数据是结构化的时候这才是对的工具。
实用建议
- 小而聚焦的提交不只是卫生习惯——更是 diff 人体工程学。评审者(和未来的你)读的是编辑脚本;D 越小,故事越清楚。
- 重命名检测(
git status显示的renamed:)是 diff 之后应用的相似度启发式,不是魔法:Git 把删除项和所有新增项逐一比对,配对最相似的。 - 对散文和文档,词级 diff(或 Git 的
--word-diff)通常比行级更可读。 - 逆操作是
patch/git apply:一份 unified diff 加上干净的上下文匹配,就足以重放这个变更——这正是格式里存在上下文行的原因。它们是让小补丁在目标文件轻微漂移后仍能存活的锚点。
速查表
- Diff = 最小插入/删除脚本 = 最长公共子序列的补集。
- Myers 算法:开销与差异大小成正比,编辑网格上的最短路径;
git diff跑的就是它。 @@ -a,b +c,d @@= 旧起点,行数 / 新起点,行数;-删除,+插入,空格是上下文;“被改”的行是一对-/+。- Diff 里没有移动——移动 = 删除 + 插入;重新格式化与重写不可区分。
- 结构化数据值得结构化 diff:比较解析后的 JSON,而不是文本行。