HandyTools Hub

← 全部指南

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 diffdiff -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,而不是文本行。