Skip to content
返回博客
教程

在线文本对比完全指南:LCS/Myers 算法 + 6 大场景 + diff 选型

在线对比两段文本:side-by-side 与 unified diff 双视图,掌握 LCS/Myers 算法,理清 text vs JSON diff 选型场景。

14 分钟

在线文本对比完全指南:LCS/Myers 算法 + 6 大场景 + diff 选型

在线文本对比(text diff)工具要回答的问题很简单:A 版和 B 版到底改了什么。把两段文本粘贴进去,工具用最长公共子序列(LCS)算法跑一遍,就能在并排视图或统一 diff 视图中看到每一处插入、删除和修改,用时通常不到一毫秒。

本文面向做代码审查的开发者、对比日志片段的 SRE、给合同划红线的律师、审稿的写作者。内容覆盖:算法(LCS、Myers、Patience)、两种标准视图、能解决 95% 「全都变了」抱怨的忽略选项、什么时候该改用 JSON diff、六个可复制粘贴的实战场景,以及那些用算法本身就能解释的坑。

想直接对比两段文本?打开文本对比工具,全部计算在浏览器本地完成,文本不上传。

1. 什么是文本对比(text diff)?

文本对比就是用最少的插入与删除操作,把一段文本变成另一段文本,并在每行上标注「新增」「删除」或「未变」。常用的 diff 还会在词或字符级别做第二轮扫描,这样改动一个字符时只会高亮那个 token,而不是整行。

1.1 为什么字符相等(===)远远不够

在一个 200 行的配置文件最上面插入一行,朴素的字符比对会把插入点之后的每一个字节都报告为不同。文本本身没变,只是位置移了。diff 算法必须能识别出「后面 199 行还是原来那些行,只是整体下移了一行」,并把它报告为一次插入。这就是 LCS 提供的能力,也是 git、GitHub 以及每一个代码审查工具都自带 diff 引擎的原因。

1.2 side-by-side 与 unified diff

side-by-side(并排视图)把两个版本放在两栏中并行排列,用颜色给单元格上色:绿色代表新增,红色代表删除,黄色代表修改。Unified diff(统一 diff 格式)是 GNU diff 留下来的老牌文本格式,单栏排版,行首用 -+ 标记,每个 hunk 上下各保留三行上下文。同一份对比结果,两种呈现方式。第 4 节会讲什么时候用哪种。

1.3 文本对比的应用场景

GitHub 和 GitLab 上的代码审查。本地 git diff 输出。粘贴到 Slack 里的 patch。合同划红线。翻译稿审校。CI 中那些用 +/- 报错的快照测试。事故时间线日志排查。比较两份 .env 文件。任何需要逐行匹配两块文本的场景。

打开文本对比工具粘贴两段文本,就能看到上述所有场景实际运转的样子,每一次比对都在浏览器本地完成。

2. 文本对比背后的算法(LCS + Myers + Patience)

2.1 最长公共子序列(LCS)

给定两个行序列 A 和 B,最长公共子序列就是在保持原有顺序、不要求相邻的前提下,同时出现在两者中的最长行列表。一旦有了 LCS,diff 就很直白:在 A 中而不在 LCS 中的行就是删除,在 B 中而不在 LCS 中的行就是新增,在 LCS 中的行就是未变。

经典 LCS 用一张 N × M 的动态规划表来跑。单元格 (i, j) 存的是 A 的前 i 行与 B 的前 j 行的 LCS 长度。从左到右、从上到下填完表,再从右下角往回走一遍,就能还原出编辑脚本。时间和空间复杂度都是 O(N×M),对两个一千行的文件没问题,对十万行日志就慢了。

2.2 Myers(1986)

Eugene Myers 1986 年的论文《An O(ND) Difference Algorithm and Its Variations》把问题重新表述为一张编辑图上的最短路径:节点是两份输入中的位置 (i, j),横向移动代表删除,纵向移动代表插入,对角线移动代表匹配。最短路径就是最小编辑脚本。

Myers 的时间复杂度是 O((N+M)D),其中 D 是编辑脚本的大小。当两段文本相近时,也就是 diff 的常态,D 很小,算法基本是线性的。它是 git diff、GNU diff 和 GitHub PR 渲染器的默认算法。对 99% 的 Web 输入来说,这就是正确答案。

2.3 Patience diff(Bram Cohen,2005)

Patience diff 走了另一条路:找出在每份输入中都只出现一次的行(称为「唯一锚点行」),把它们对齐,再在锚点之间的空隙里递归处理。数学上没有优势,最坏情况依然差,但在代码上读起来效果好得多。

为什么?Myers 最小化的是编辑距离,从数学上看最优,但当最优对齐穿过无关的大括号或空行时,视觉上就糟糕了。Patience 拒绝在常见的样板内容上对齐(每个文件都有 } 行,每个文件都有空行),所以函数边界保持完整。Bram Cohen 当年是为 Bazaar 发明的;Git 里通过 git diff --patience 提供。与它密切相关的 Histogram 算法(git diff --histogram)速度略快,输出质量相近。

设想同一个文件的两个版本,其中一个函数被挪了位置。Myers 可能会把函数 A 的右大括号和函数 B 的右大括号对齐,然后把两个函数体报告为完全不同。Patience 会锚定在唯一的函数名上,把它报告为一次移动。同样的输入,审查体验完全不同。

2.4 算法对比

属性Myers(默认)PatienceHistogram
时间复杂度O((N+M)D)常见情况 ~O(N log N)与 Patience 相近
编辑距离最优是,脚本最短否,可能更长否,可能更长
代码上可读性偶尔会错位对齐大括号和空行较好,以唯一行为锚点较好
使用方git 默认、GNU diff、GitHub UIgit diff --patience、Bazaargit diff --histogram
适用场景大多数输入的速度与正确性代码审查、重构 diff同 Patience,速度略快

2.5 本工具采用的方案

文本对比工具使用经典动态规划 LCS,配合两项优化:公共前后缀裁剪,以及行内词级 diff 的第二轮 token 级 LCS。两个两千行的配置文件、只改了一行的 diff,裁剪后会塌缩成 1×1 的 DP 表,渲染耗时不到一毫秒。对典型 Web 输入而言,Myers 还是 DP 的选择并不可见,两者都比浏览器绘制结果还快。

3. 行内词级 diff:为什么改一个字符整行都高亮

你在一行里只改了一个标识符,整行却被染成红绿一片。Bug?不,这是设计。

diff 先在行级别跑 LCS:「第 14 行被替换了。」然后对每一对被替换的行再跑一轮 token 级 LCS。token 由 Unicode 词边界切分得来,连续的字母与数字保持在一起,空白与标点各自成 token。第二轮 LCS 给出该行内最小的 token 级编辑脚本。

渲染器会用高亮色把整行涂满,方便视线定位,但只在真正变化的 token 上施加明亮背景色。变化 token 周围未变的部分则用同一色系的弱化版本,存在感弱但不显眼。视线会精准落到那处编辑上。

示例 1:标识符重命名。 function getUser(id) 改成 function getUser(userId)。整行被标为修改。行内,只有 id(红色删除线)和 userId(明亮绿色)带有强高亮,其余部分保持弱化。

示例 2:日志延迟变化。 POST /api/orders 201 88ms 变成 POST /api/orders 201 4200ms。行被标为修改。行内只有 884200 是明亮的,路径、method、状态码都保持弱化,这正是事故时间线读者需要的视觉效果。

当一行里变化的 token 太多时,词级高亮反而成了噪声。这时工具会回退到「删除 + 新增」配对呈现:原始行整行删除,新行整行新增,不再做行内着色。阈值大致是「超过一半的 token 不同」。

总结:行级 diff 告诉你哪一行变了,词级 diff 告诉你那一行上具体哪些字符承载了变化。在文本对比工具里点 Sample,可以在同一份输入上看到这两种视图。

4. side-by-side 与 unified diff:两种视图,一份 diff

4.1 并排(side-by-side)视图

两栏:左边是原始版本,右边是修改后版本。匹配的行水平对齐。新增的行只出现在右栏,背景为绿色;删除的行只出现在左栏,背景为红色;修改对则并排放置,中间一道黄色 gutter 标识,配合行内词级高亮。

side-by-side 适用于「人来看 diff」的场景:PR 审查、教学、演示、和非技术干系人一起过一份合同变更。这是给眼睛看的视图。

缺点:它不可传输。你没法把一张 side-by-side 渲染粘贴到 Slack 里让别人应用它,也没法把它管道喂给 patch。要分享、要应用,就得用 unified。

4.2 unified diff 格式

unified diff 是 GNU diff 留下来的一种五十年历史的纯文本格式,已被 POSIX 标准化。一个完整示例:

--- original
+++ modified
@@ -1,3 +1,4 @@
 1. The service is provided as-is.
 2. Either party may terminate with 30 days notice.
+2a. Termination notice must be in writing.
 3. Disputes are resolved in California courts.

前两行是源文件名。@@ -L,C +L,C @@ 是 hunk 头:-L,C 表示从原始版本第 L 行开始,涉及 C 行;+L,C 对修改版同理。hunk 内部,以空格开头的行是上下文(未变),- 是删除,+ 是新增。

每个变更上下各保留三行上下文,是 GNU 的默认值。大多数工具允许用 -U n 调整:diff -U0 表示无上下文,diff -U10 表示十行上下文。hunk 头会跟随你的选择更新。

文本对比工具里点 Unified 标签切换视图,或者点 Copy unified diff 把 patch 复制到剪贴板。

4.3 unified diff 的可移植性

unified diff 的覆盖面很广,是文本变更最常用的交换格式。

目的地接受 unified diff?用法
GNU patchpatch -p1 < diff.patch
git applygit apply diff.patch
GitHub PR 评论是(放在 ```diff 代码块里)自动着色渲染
GitLab MR 评论同样的围栏代码块
Bitbucket / Azure DevOps PR同样的围栏代码块
Slack / Discord 粘贴部分在代码块中以纯文本渲染,无颜色
VS Code「Open Patch」通过 Source Control 应用 patch
Jira / Linear issue 正文部分代码块中可看,没有应用按钮

同样九行 ---/+++/@@ 文本,可以喂给 patch、喂给 git apply、在三个 PR 平台中正常渲染、还能在 Slack 粘贴中存活。其他 diff 格式很少能覆盖到这种程度。

4.4 何时选哪个

审查用 side-by-side,分享与应用用 unified。如果你自己在读 diff,两栏视图更快。如果下游有人或有工具需要消费它(审查者、工具、patch 命令),就复制 unified 格式。

5. 忽略选项:空白、大小写、空行、行尾符

绝大多数「全都变了」的抱怨其实是噪声。四个开关能解决 95%。

  1. 忽略大小写A 映射成 a。等价于 git diff -i。适用于环境变量对比、SQL 关键字风格审查,以及任何「大写 vs 小写但语义相同」的场景。
  2. 忽略所有空白 在比对前把每个空格、tab、换行都折叠掉。等价于 git diff -w。专治 tab ↔ 空格的重新格式化、缩进改写以及「我们切到 Prettier 了」这种把行数搞炸的 diff。这类改动开了忽略空白后,通常会从 87 处修改降到 4 处。
  3. 忽略行尾空格和 tab 仅剥掉行末的空白。等价于 git diff -b。专治 Windows 与 Unix 间拷贝后留下的 CRLF 噪声,行末的 \r 被过滤掉,真正的内容就能对齐。
  4. 忽略空行 比对前丢弃空行。专治散文 diff 中「我加了一处段落分隔,结果第 12 段看起来完全不一样了」。

一个报告「87 处修改」的 200 行配置文件,开启「忽略所有空白」后通常会降到「4 处修改」。一份从 Windows 拷到 Unix、每一行都标红的文件,开启「忽略行尾空格」后会降到零。每个开关相互独立,并在会话间持久化。

CRLF 与 LF。 Windows 的行尾符是 \r\n,Unix 是 \n,经典 Mac 是 \r。在不做归一化的 Unix 编辑器里打开 Windows 文件,行末的 \r 就会留下来。每一行都会被报告为「内容相同但行末多一个 \r」。开启「忽略行尾空格」可以让这种噪声消失,又不会掩盖真正的变化。

注意。 忽略选项是双刃剑。打开「忽略大小写」,把 LOG.error 重构成 log.Error 看起来就一样了。打开「忽略所有空白」,Python 的缩进 bug 就会隐身。按当下要回答的问题挑开关,问完就关掉。

6. 文本 diff、JSON diff、git diff:决策矩阵

文本 diff 是不带结构理解的行级与词级匹配。对散文来说这正是你要的,对 JSON 来说这正是你不要的。

6.1 决策矩阵

输入类型文本 diffJSON diffgit diff
散文 / Markdown / 合同最佳用错工具部分可用(仅限被 git 跟踪的文件)
单文件代码片段(粘贴)最佳用错工具部分可用(需要 repo)
repo 中的代码(多文件)部分可用用错工具最佳
API JSON 响应用错工具(key 顺序产生假阳性)最佳用错工具
YAML / TOML 配置部分可用(key 顺序产生假阳性)最佳(先转换)部分可用
CSV 逐行比对部分可用用错工具用错工具
日志 / heredoc最佳用错工具用错工具
二进制文件用错工具用错工具git diff --binary

6.2 文本 diff 派不上用场的时候

三个经典误区。

JSON 里 key 顺序变了。 {"a":1,"b":2}{"b":2,"a":1} 是同一份 JSON 文档。文本 diff 会把每一行都报告为变化,因为它们的确是不同的行。改用 JSON Diff,它理解 JSON 的 key 是无序的。

被重新格式化的 YAML 配置。 改一个值,把文件过一遍格式化器,缩进、key 顺序、引号风格全都会变。文本 diff 会报告整个文件被重写。先把两份文件转成 JSON,再用 JSON Diff 对比。

带重命名的多文件重构。 Git 会跟踪重命名,文本 diff 不会。如果你把两棵目录树各自拼成一大块来对比,跨文件的移动都会显示为「删除 + 新增」。这种场景请用 git diff(或 git diff --find-renames=80%)。

6.3 文本 diff 完全胜任的时候

散文。任何地方粘过来的代码片段。合同红线。日志切片。需要按自然语言句子匹配的翻译审校。.env 文件(顺序重要,因为 shell 从上往下读)。任何「行本身就承载语义」的输入。

想深入研究怎么把 JSON diff 里的噪声(timestamps、request IDs、自动生成的 UUID)过滤掉,请读如何在 JSON Diff 中忽略 Timestamps 和 IDs

7. 六个真实场景(附可复制粘贴输入)

7.1 代码审查片段:函数重命名

你在审一个 PR。作者把 id 改成了 userId,并加了一个保护性判断。粘贴两个版本:

// Original
function getUser(id) {
  const u = db.users.find(x => x.id === id);
  return u;
}
// Modified
function getUser(userId) {
  if (!userId) return null;
  const u = db.users.find(x => x.id === userId);
  return u;
}

diff 显示三行被修改、一行新增。行内词级高亮标出每一个 iduserId token;新加的保护语句以绿色背景出现。忽略选项保持关闭。在文本对比工具里试一下,把 unified 输出复制下来当评审评论。

7.2 合同或政策红线:插入一条款

五十段的合同,插了一条条款。把昨天的版本贴在左边,今天的贴在右边:

1. The service is provided as-is.
2. Either party may terminate with 30 days notice.
3. Disputes are resolved in California courts.
1. The service is provided as-is.
2. Either party may terminate with 30 days notice.
2a. Termination notice must be in writing.
3. Disputes are resolved in California courts.

diff 渲染出 49 行未变 + 1 行新增(+2a. Termination notice must be in writing.)。导出 unified diff,作为法务审查的留痕。

7.3 日志时间线排查

你怀疑出现了延迟回归。抓事故前与事故中各一段访问日志:

GET /api/users 200 14ms
POST /api/orders 201 88ms
GET /api/orders/42 200 21ms
GET /api/users 200 14ms
POST /api/orders 201 4200ms
GET /api/orders/42 500 21ms

行内高亮揪出 884200(50 倍延迟跳变)和 200500(订单详情接口开始报错)。如果你的日志是 JSON 格式,需要更深入的日志处理(抽字段、按接口分组、算分位数),可以把 diff 与 jq 速查手册搭配使用。

7.4 翻译审校:保留占位符

你换了一家翻译机构,想确认新文案在结构上和旧版一致。把旧译文贴在左、新译文贴在右。打开忽略行尾空格 / tab,因为译者经常在字符串末尾留个零散的空格。

diff 会确认每一个 {username}{count}%s 占位符都在原位,只有自然语言部分发生变化。漏掉的占位符会在行内 diff 中以删除 token 显现,上线前就能抓出来。如果你需要比对占位符格式本身,Regex 正则表达式速查覆盖了 \{\w+\} 之类的写法。在文本对比工具里试试。

7.5 配置或 .env 审计:生产 vs 预发

对比两份 .env 文件。打开忽略空行,避免按段落分组的写法让段落间错位。diff 会告诉你哪些 key 值不同、哪些 key 只在一边存在、哪里的注释已经不同步。五分钟时间能省掉一次「预发能跑、生产挂了」的排障会。

7.6 散文或草稿修订

编辑把稿子返给你。把原稿贴在左、改过的版本贴在右。行内词级 diff 会明确告诉你哪些句子被重写了、哪些原封不动、哪些段落是新插入的。逐处接受或拒绝改动,不需要 Track Changes 功能,不需要 Word 文档,也不需要任何专有格式。

8. 常见坑及其症状解读

算法行为能解释绝大多数用户痛点。五种常见抱怨,以及它们背后的真实含义。

坑 1:「从 Windows 拷到 Unix 后每一行都标红了。」 症状:内容看起来一模一样,但 diff 里每一行都显示为变化。原因:CRLF 行尾符留下的 \r。解决:打开「忽略行尾空格 / tab」开关,diff 立刻塌缩到真正的变化上。

坑 2:「我粘贴了 JSON,100% 的行都不一样。」 症状:两份应当等价的 JSON 对象显示为完全不同。原因:key 顺序被打乱了。文本 diff 把行顺序当作有意义的信息,JSON 不这么认为。解决:任何 JSON 输入都改用 JSON Diff

坑 3:「tab ↔ 空格的格式化把 diff 炸了。」 症状:87 处修改,全都是缩进。原因:你的格式化器改掉了每一行的前导空白。解决:「忽略所有空白」会把噪声压平,露出真正的语义变化。

坑 4:「diff 说一致,可是 cmp 不同意。」 症状:diff 报告没差异,但字节级比对说文件确实不同。原因:上一会话留下的某个忽略选项还开着,掩盖了真正的变化。解决:打开「忽略选项」面板,把每一个开关都关掉,再 diff 一次。

坑 5:「一处短小修改显示成了删除 + 新增。」 症状:一个小变化呈现为单独一行删除加单独一行新增,而不是行内高亮。原因:变化 token 的比例超过了行内阈值,渲染器回退到了配对呈现。这是设计,不是 bug。切到 Unified 视图就能看到 patch 工具期望的那种经典 -/+ 配对。

9. 隐私、性能,以及何时该用命令行

文本对比工具的每一次比对都在浏览器内的 JavaScript 中运行。无上传、无临时文件、无服务端日志、不会对你粘贴的文本做任何分析统计。对专有代码、内部合同、私有日志,任何你不会愿意贴进第三方服务器的内容,都是安全的。

实际上限:每边大约 5,000 行或 1 MB。合计超过 200 KB 时实时 diff 会自动关闭,切换到手动 Diff 按钮,避免打字阻塞页面。超过 5,000 行后输入会被截断并提示。这些限制存在,是因为 diff 运行在主线程上(没有走 web worker),而 worker 之间的传递和序列化在小输入上的代价已经超过 diff 本身。

当输入规模超出浏览器能力范围时,请落到命令行:

# Unified diff between two files
diff -u a.txt b.txt

# Same, but using git's diff engine (Patience, Histogram, color)
git diff --no-index a.txt b.txt
git diff --no-index --patience a.txt b.txt

# Streaming diff viewer for huge files (Rust, side-by-side, syntax-aware)
delta a.txt b.txt

当你要处理几 MB 的日志、二进制文件、多文件 repo diff,或者想要 delta 那种带语法高亮的着色,又或者需要把 diff 输出通过管道送给其他工具时,请切到命令行。

10. Unicode、CJK 与 RTL:国际化文本对比说明

tokenizer 按 Unicode 词边界拆分,分三类:词元(\p{L} 字母与 \p{N} 数字)、非词标点、空白。每一类各自产生 token,所以 hello, world! 会被拆成 hello, world! 五个 token。

对于 CJK 内容(中文、日文、韩文),每个汉字或假名各自成 token。改动中文句子中的一个字,只有那个字会带行内高亮,整行其余部分保持弱化。段落级结构仍然按行划分,所以一处加了换行的句子重写会被识别为行级编辑,而非 token 级编辑。

对于 RTL 语言(阿拉伯文、希伯来文),diff 使用逻辑方向的 CSS 属性(用 ms-me- 而不是 ml-mr-)。在 RTL 区域下,gutter 与行列会自然翻转;每个 diff 单元格内,文本方向跟随内容走,所以阿拉伯字符串从右向左渲染,而 +- 标记始终对齐到 start gutter。

行尾归一化识别三种:\r\n(Windows)、\n(Unix)、单独的 \r(老版 Mac OS 9 及更早)。三种都会被切成独立的行,所以跨平台转换过的文件不会塌缩成一行超长的内容。

11. FAQ

在线文本对比是怎么工作的?

文本对比先把两份输入按行切分,跑一个最长公共子序列算法(通常是 Myers 的 O((N+M)D))找出最小的插入与删除集合,然后把新增(绿)、删除(红)、未变(灰)的行标出来。第二轮 token 级 LCS 会标出每个被修改行内具体哪些词发生了变化。文本对比工具在浏览器本地完成整个比对。

文本 diff 和 JSON diff 有什么区别?

文本 diff 按行比对,适合散文、代码、日志和合同。JSON Diff 懂 JSON 的数据模型:key 顺序无关紧要,类型严格(1"1"),数组可以按 key 匹配。把 JSON 粘进文本 diff 里,key 顺序或空白都会冒出来变成变化,而 JSON Diff 会忽略它们。非结构化内容用文本 diff,API 响应和配置用 JSON Diff。

我只改了一个词,为什么 diff 把整行都标成变化了?

它没有。整行被高亮是因为这一行上有东西变了,但高亮内部只有变化的 token 带着明亮背景(新增是绿色,删除是红色带删除线)。这就是行内词级 diff:保持行的上下文可读,同时让视线精准落在变化处。当一行变得太多、词级高亮反而不实用时,diff 会回退到独立的「删除 + 新增」配对,让结构保持清爽。

怎么在 diff 中忽略空白、大小写或空行?

打开「忽略选项」面板。「忽略大小写」让 Aa 等价。「忽略所有空白」把每个空格、tab、换行都折叠掉,等价于 git diff -w。「忽略行尾空格和 tab」对应 git diff -b,让 CRLF 噪声消失。「忽略空行」会丢弃空行,避免段落重排打乱 diff 对齐。每个选项相互独立,并在会话间保留。

什么是 unified diff 格式?

unified diff 是 GNU diff 在 1980 年代后期引入的 ---/+++/@@ -L,C +L,C @@ 文本格式,被 git、GitHub、GitLab 以及 Unix patch 命令所采用。每个 hunk 在变化上下各展示三行上下文,- 是删除,+ 是新增。把 unified 输出粘进 PR 评论,喂给 git apply,或者运行 patch -p1 < diff.patch,都能干净地应用。

Myers 与 Patience:代码审查用哪个算法更好?

Myers 是 git diff 和 GNU diff 的默认选项,快速且数学上最小,但偶尔会把不相关的空行或右大括号对齐起来,让 diff「读起来怪怪的」。Patience(Bram Cohen,2005)以每份输入中只出现一次的行为锚点,并在锚点之间递归处理,函数边界因此保持完整。审查重构时使用 git diff --patience(或速度略快、输出相近的 --histogram)。

我粘贴的文本会被发送到任何服务器吗?

不会。文本对比工具的每一次比对都在浏览器内的 JavaScript 中本地运行。你的文本不会被上传、记录、写入磁盘,也不会发给任何第三方。只有你的 UI 偏好(视图模式与忽略选项开关)会写入 localStorage,让页面下次访问时记住设置,文本本身从不保存。可在 DevTools → Network 中验证:点击 Diff 时零网络请求。

两边输入最大能放多少?

实际上限大约是每边 5,000 行或 1 MB。合计超过 200 KB 后实时 diff 自动关闭,切换为手动 Diff 按钮。超过 5,000 行时输入会被截断并给出警告。处理几 MB 的文件,请切到 diff -u a.txt b.txtgit diff --no-index a.txt b.txtdelta,它们采用流式处理,能应付到 GB 级别。