前阵子维护一批服务器配置,每次做环境间差异核对,眼睛都要在标准 diff 的输出里翻半天。明明只想看哪几行改了,结果大段相同内容跟着一起滚屏,看得人又累又容易漏。后来我索性在终端里写了个脚本:从零实现一个自定义的文件差异对比工具,把删除行标红、新增行标绿,只把有变化的重要上下文留在屏幕上。这篇文章就是那个脚本从思路到落地的完整记录,包含 LCS 算法的动态规划拆解、Shell 与 awk 组合实现的过程,以及我在实测中踩过的几个坑,适合有一定 Shell 基础、想弄懂 diff 背后原理的同学参考。
1. 为什么用 Shell 重写 diff:标准工具不够用的几个瞬间
1.1 标准 diff 很强,但定制化的需求很难塞进去
先说明白,标准 diff 不是不好用,它太强了,而且强到我们没法轻易按自己的口味调整输出。系统自带的 diff 能输出普通格式、上下文格式、合并格式,也能忽略空白字符、递归对比目录,配合 git 使用时甚至能用上更复杂的差异算法。但你试试让它只输出差异块小结、用自定义颜色高亮某个特定区域的变更、或者在输出里加一段业务词法的上下文标注?要么做不到,要么得写一堆后处理管道把 diff 的结果再解析一遍。
我自己的触发点是有一个配置对比需求:每次上线前要核对旧配置和新配置之间的差异,我只关心“修改了哪些参数、删了哪些项、新增了哪些项”,标准 diff 的-u合并格式虽然已经很紧凑,但依然会输出大量上下文,而且把横线、加号、空格混在一起,终端默认配色下并不直观。我想要的其实是类似代码评审工具里的那种效果:一眼看过去,红色表示删除,绿色表示新增,相同行弱化显示,重点全在变化上。
所以自己写脚本的核心动机不是“造轮子”,而是定制输出边界。当你需要把 diff 结果嵌入到自己维护的自动化体系里,比如脚本巡检、配置审计、上线前的变更摘要,你会发现标准工具的默认输出格式并不总能满足需求。自定义脚本胜在可控:我想让它输出什么格式、保留多少上下文、加什么颜色,完全由我说了算。
1.2 选型:为什么是 Shell + awk,而不是 Python
很多人会问,写个 diff 工具用 Python 不是更快吗?确实,Python 的 difflib 库非常强大,三五行代码就能输出 HTML 或 unified diff。但这里有个前提:这个系列的主题就是 Shell 实战,而且很多运维场景下的目标是零额外依赖。服务器上未必有 Python 环境,但一定有 awk。awk 处理文本流是天然优势,而且它原生支持关联数组,这意味着可以用它来模拟二维动态规划表,正好用来实现 LCS 算法。
我最终选定的方案是:Bash 负责流程控制、文件读取、参数校验,awk 负责中间差异块的 LCS 求解与回溯输出。这样分层的理由很简单:Bash 的数组操作和字符串比较适合做逐行收敛的判断,但复杂的二维 DP 表用 Bash 关联数组去写,语法繁琐且性能很差;awk 的行处理模型天然适合“逐行扫描+关联数组缓存中间结果”这个模式。让每种工具做自己最擅长的事,这也是 Shell 脚本设计里很重要的一个思路。
对比一下其他方案的代价:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 纯 Bash 实现 LCS | 无额外依赖,逻辑统一 | 关联数组做二维 DP 非常别扭,行数稍多就慢 |
| Python difflib | 开发效率极高,功能全面 | 依赖 Python 环境,与系统工具链集成不如 awk 自然 |
| 直接调用标准 diff + grep | 零开发成本 | 输出定制空间小,高亮逻辑受制于原格式 |
| Bash + awk(我采用) | 依赖极低,逻辑清晰,性能可接受 | 需要理解 awk 的关联数组与字符处理细节 |
实际写下来的体会是:这个组合在大多数服务器上是开箱即用的,而且 awk 那段代码写完你会对“为什么 awk 适合处理文本挖潜”这件事有非常深的体感。
2. 拆解 diff 核心算法:LCS 动态规划与前后缀收敛
2.1 人眼对比文件的直觉,怎么变成计算机能执行的逻辑
对比两个文件时,人眼会先快速扫一遍开头,把相同的部分跳过,然后找到第一个不同的位置,再从结尾往回扫,把相同的尾巴也跳过,剩下的就是真正需要仔细看的改动区域。这个直觉其实已经包含了 diff 算法最核心的策略:公共前缀和公共后缀可以先收敛掉,中间那一小段才算真正需要计算差异的部分。
但中间那一段要怎么处理呢?假设旧文件中间段是三行,新文件中间段是四行,它们之间可能有一行是相同的、只是周围的顺序变了,也可能完全不同。计算机要找出哪些行属于“没有改动、只是位置相同”的公共部分,就需要一种能描述“两个序列最长共同顺序”的算法,这就是 LCS(Longest Common Subsequence)。
LCS 的严格定义是:给定两个序列,求一个最长的子序列,使得它同时出现在两个序列中,并且保持各自的相对顺序。举个例子,旧文件片段是A B C D,新文件片段是B C A D,那么一个公共子序列是B C D(长度3),也可能取A D(长度2)。最长公共子序列在这里是B C D,因为它最长且在原序列和新序列里都按相同顺序出现。diff 工具判定“哪些行是没变的”时,本质上就是找这个 LCS,然后把 LCS 之外的行标记为删除或新增。
2.2 动态规划递推公式:一张表算出最长公共子序列
LCS 的经典解法是动态规划。定义dp[i][j]表示序列 X 的前 i 个元素和序列 Y 的前 j 个元素的最长公共子序列长度,那么递推关系可以写成:
- 当
X[i] == Y[j]时:dp[i][j] = dp[i-1][j-1] + 1 - 当
X[i] != Y[j]时:dp[i][j] = max(dp[i-1][j], dp[i][j-1])
这个公式的直觉很好理解。如果两个位置上的行内容相同,说明这一行可以作为公共子序列的一部分,所以在两个序列都去掉这行的基础上加 1;如果不同,就意味着这一行不能同时被两个序列共享,只能选择“丢掉 X 的这一行”或者“丢掉 Y 的这一行”中的较优结果。
我拿一个非常小的例子手动推演一下。假设旧片段是["a", "b", "c"],新片段是["a", "c", "d"]。初始时dp[0][*]和dp[*][0]都是 0。逐行填表:
i=1,X[1]="a",Y[1]="a",相等,dp[1][1] = dp[0][0] + 1 = 1。dp[1][2]:"a"和"c"不等,取max(dp[0][2], dp[1][1]) = 1。dp[1][3]:"a"和"d"不等,取max(dp[0][3], dp[1][2]) = 1。- 继续推下去,最终
dp[3][3] = 2,对应公共子序列["a", "c"]。
这张表不仅告诉我们最长公共子序列有多长,还保留了回溯的路径。从dp[n1][n2]开始往回走:如果X[i] == Y[j],说明这一行是公共行,记录为“相同”,然后往左上角走;否则看dp[i-1][j]和dp[i][j-1]哪个更大,大的方向就是当时选择的“丢弃”方向,丢弃 X 行就标记该行为“删除”,丢弃 Y 行就标记为“新增”。回溯结束后,我们就得到了一串带类型标记的行,这就是自定义 diff 最核心的输出素材。
2.3 前后缀收敛:让 LCS 只处理真正有差异的区域
理论上只做 LCS 也能完成 diff,但实际文件往往有一个特点:共同的头部和尾部很长,真正修改的部分集中在中间。比如配置文件里 100 行内容只有第 30 到 35 行变了,如果对整个文件跑 LCS,计算量是 100×100 的动态规划表,虽然不算大,但文件到了几千行时,这个开销就非常可观了,而且输出里会带上大量没必要的相同行。
所以我在脚本里先做前后缀收敛,这个过程非常简单:从第一个元素开始逐个比较两个数组的元素,遇到不同的就停止,得到公共前缀长度i;然后从数组尾部开始同步往前比较,直到遇到不同或与公共前缀区间重合,得到收缩后的尾部边界。收敛后,公共前缀和公共后缀直接输出即可,中间剩下的区间才是真正需要交给 LCS 处理的片段。
这个优化的收益在真实文件上非常明显。我在实测中拿两个各 2000 行的配置文件做对比,其中仅第 400 到 410 行之间修改了几行。未做收敛时 awk 要建 2000×2000 的 DP 表,内存和耗时都不可接受;做收敛后,中间段只有 10 行左右,awk 瞬间完成。理解了这一步,你也就明白了为什么 diff 命令在实际使用中那么快——它在底层同样会做类似的边界压缩。
3. 手写高亮 diff 脚本:参数读取、LCS 计算与颜色输出
3.1 参数校验与把文件读进内存:mapfile 的正确用法
脚本的第一步是接收两个文件名,校验文件是否存在、是否可读。这一步虽然简单,但也是实用工具和演示脚本的差别所在:真正的脚本不应该因为用户传入一个不存在的路径就直接崩溃报一堆 Python traceback 式的错误信息。
参数校验通过后,需要用mapfile把文件内容逐行读入数组。mapfile -t lines1 < "$1"这一行会把文件按行分割并存到数组lines1中,-t表示去掉每行末尾的换行符。为什么不直接用while read line循环?因为后面需要随机访问数组的任意位置,比如从尾部往前比较,或者取出某个区间,数组显然比流式读取更合适。
这里有一个值得注意的细节:mapfile会把文件末尾没有换行符的最后一行也正常读入,这比某些按行分割的命令更宽容。我用它读配置类文件时,基本没有遇到过因为末尾缺换行导致的行丢失问题。
3.2 前缀与后缀收敛的 Bash 实现
这段逻辑用 Bash 写起来很直白。前缀收敛用一个循环从下标 0 开始同步比较两个数组的元素,遇到不同就停,记录收敛位置i。后缀收敛从数组长度减 1 开始,同步向前比较,记录收缩后的尾部位置j1和j2。
i=0 while (( i < ${#lines1[@]} && i < ${#lines2[@]} )); do [[ "${lines1[i]}" == "${lines2[i]}" ]] || break ((i++)) done j1=${#lines1[@]} j2=${#lines2[@]} while (( j1 > i && j2 > i )); do [[ "${lines1[j1-1]}" == "${lines2[j2-1]}" ]] || break ((j1--)) ((j2--)) done注意后缀循环的条件里加了j1 > i && j2 > i,这是为了避免后缀收敛把前缀部分也覆盖掉。如果两个文件完全相同,前缀会一直推进到数组末尾,此时j1和j2不需要再往前收。收敛完成后,如果i等于两个数组的长度,说明文件一模一样,直接提示并退出,避免白白输出一遍所有行。这一步我在调试时才发现,两文件完全相同的情况下脚本会输出全部行,看着很傻,加上这个短路判断后体验才正常。
3.3 中间差异块的 LCS 求解:awk 里的二维动态规划
中间段提取出来之后,就进入全片的核心:用 awk 计算 LCS 并完成回溯。
为什么一定要用 awk?因为它支持关联数组,而且下标可以用i SUBSEP j这样的复合键。在 awk 里,dp[i,j]会被解释为dp[i SUBSEP j],SUBSEP默认是\034,所以可以用dp[i,j]直接写出二维 DP 表的逻辑,非常优雅。如果用纯 Bash,就得自己拼"i,j"作为关联数组的键,写出来超级啰嗦。
我这里的数据交互方式是通过管道把中间段的两段内容一次性喂给 awk,同时用-v传入两个数组的长度。awk 里前n1行算第一个文件,后面的算第二个文件:
{ for ((idx=0; idx<n1; idx++)); do printf '%s\n' "${mid1[idx]}" done for ((idx=0; idx<n2; idx++)); do printf '%s\n' "${mid2[idx]}" done } | awk -v n1="$n1" -v n2="$n2" ' ... '这里解释一下为什么是printf循环而不是printf '%s\n' "${mid1[@]}":如果mid1是空数组,后一种写法会因为格式字符串里的\n直接输出一个空行,导致 awk 把空行当成一个真实行参与比较,这是我在测试里踩过的坑之一。用循环逐行输出则完全不受空数组影响,虽然代码长一点,但行为稳定得多。
awk 内部的 DP 逻辑:
NR <= n1 { A[NR-1] = $0; next; } { B[NR-1-n1] = $0; } END { for (i = 1; i <= n1; i++) { for (j = 1; j <= n2; j++) { if (A[i-1] == B[j-1]) { dp[i,j] = dp[i-1,j-1] + 1; } else { left = dp[i-1,j]; up = dp[i,j-1]; dp[i,j] = (left > up ? left : up); } } } i = n1; j = n2; k = 0; while (i > 0 && j > 0) { if (A[i-1] == B[j-1]) { rt[k] = "="; txt[k] = A[i-1]; k++; i--; j--; } else if (dp[i-1,j] >= dp[i,j-1]) { rt[k] = "-"; txt[k] = A[i-1]; k++; i--; } else { rt[k] = "+"; txt[k] = B[j-1]; k++; j--; } } while (i > 0) { rt[k] = "-"; txt[k] = A[i-1]; k++; i--; } while (j > 0) { rt[k] = "+"; txt[k] = B[j-1]; k++; j--; } ... }回溯是从dp[n1][n2]出发往dp[0][0]方向走的,所以产生的rt和txt数组是倒序的。这也解释了为什么我最后要用for (idx = k-1; idx >= 0; idx--)反转输出。很多第一次写 LCS 回溯的人会在这里困惑:为什么结果顺序和原始顺序对不上?就是因为回溯方向本身是逆序的,不是你写错了。
3.4 ANSI 颜色与输出格式:怎么让差异一眼可见
输出格式我设计为:相同行前面加两个空格,删除行前面加一个红色短横线,新增行前面加一个绿色加号。颜色用 ANSI 转义序列实现,直接在 awk 的 BEGIN 块里初始化:
BEGIN { red = "\033[31m"; green = "\033[32m"; reset = "\033[0m"; }然后在输出时用printf "%s-%s%s\n", red, txt[idx], reset把删除行包成红色,用printf "%s+%s%s\n", green, txt[idx], reset把新增行包成绿色。这里要注意reset必须放在行尾,否则颜色会一直污染后面所有输出。终端里如果忘记 reset,后续普通文本也会变成红色,这个细节我见过不少回。
实用的脚本还应该考虑非终端场景:如果把输出重定向到文件或者管道里,颜色转义序列混进去反而是噪声。一个简单的做法是检测标准输出是否是终端,如果不是就把颜色变量置空,类似很多命令的--color=auto行为:
if [[ ! -t 1 ]]; then red="" green="" reset="" fi这个判断放在脚本主体开头,awk 里就不再写颜色,而是从环境变量读。我实测下来,配合自动化巡检脚本使用非常有必要,否则你重定向到日志文件后,cat 出来全是^[[31m这种乱码。
4. 完整可运行的 mydiff 脚本与实测效果
4.1 完整脚本源码
下面给出我最终整理好的完整脚本,逻辑顺序依次是:参数校验、文件读取、前缀后缀收敛、相同判断、中间段 LCS 求解、高亮输出。
#!/usr/bin/env bash # mydiff.sh - 自定义高亮 diff # 用法: ./mydiff.sh 文件1 文件2 usage() { echo "用法: $0 <文件1> <文件2>" echo "输出格式: ' ' 表示相同行,'-' 表示删除行(红色),'+' 表示新增行(绿色)" exit 1 } [[ $# -ne 2 ]] && usage [[ -f "$1" && -r "$1" ]] || { echo "无法读取文件: $1"; exit 1; } [[ -f "$2" && -r "$2" ]] || { echo "无法读取文件: $2"; exit 1; } mapfile -t lines1 < "$1" mapfile -t lines2 < "$2" # 非终端输出时禁用颜色 if [[ ! -t 1 ]]; then red="" green="" reset="" else red=$(printf '\033[31m') green=$(printf '\033[32m') reset=$(printf '\033[0m') fi # 前缀收敛 i=0 while (( i < ${#lines1[@]} && i < ${#lines2[@]} )); do [[ "${lines1[i]}" == "${lines2[i]}" ]] || break ((i++)) done # 完全相同则直接提示退出 if (( i == ${#lines1[@]} && i == ${#lines2[@]} )); then echo "两个文件完全相同。" exit 0 fi # 后缀收敛 j1=${#lines1[@]} j2=${#lines2[@]} while (( j1 > i && j2 > i )); do [[ "${lines1[j1-1]}" == "${lines2[j2-1]}" ]] || break ((j1--)) ((j2--)) done # 中间差异区间 mid1=("${lines1[@]:i:j1-i}") mid2=("${lines2[@]:i:j2-i}") n1=${#mid1[@]} n2=${#mid2[@]} # 输出公共前缀 for ((idx=0; idx<i; idx++)); do printf ' %s\n' "${lines1[idx]}" done # 用 awk 求解中间段的 LCS 并输出差异 { for ((idx=0; idx<n1; idx++)); do printf '%s\n' "${mid1[idx]}" done for ((idx=0; idx<n2; idx++)); do printf '%s\n' "${mid2[idx]}" done } | awk -v n1="$n1" -v n2="$n2" -v r="$red" -v g="$green" -v z="$reset" ' NR <= n1 { A[NR-1] = $0; next; } { B[NR-1-n1] = $0; } END { for (i = 1; i <= n1; i++) { for (j = 1; j <= n2; j++) { if (A[i-1] == B[j-1]) { dp[i,j] = dp[i-1,j-1] + 1; } else { left = dp[i-1,j]; up = dp[i,j-1]; dp[i,j] = (left > up ? left : up); } } } i = n1; j = n2; k = 0; while (i > 0 && j > 0) { if (A[i-1] == B[j-1]) { rt[k] = "="; txt[k] = A[i-1]; k++; i--; j--; } else if (dp[i-1,j] >= dp[i,j-1]) { rt[k] = "-"; txt[k] = A[i-1]; k++; i--; } else { rt[k] = "+"; txt[k] = B[j-1]; k++; j--; } } while (i > 0) { rt[k] = "-"; txt[k] = A[i-1]; k++; i--; } while (j > 0) { rt[k] = "+"; txt[k] = B[j-1]; k++; j--; } for (idx = k-1; idx >= 0; idx--) { if (rt[idx] == "=") { printf " %s\n", txt[idx]; } else if (rt[idx] == "-") { printf "%s-%s%s\n", r, txt[idx], z; } else { printf "%s+%s%s\n", g, txt[idx], z; } } }' # 输出公共后缀 for ((idx=j1; idx<${#lines1[@]}; idx++)); do printf ' %s\n' "${lines1[idx]}" done4.2 实测演示与输出解读
我准备了两份典型的配置文件来实测。第一个文件代表旧配置:
port=8080 host=localhost debug=false workers=4 theme=default第二个文件代表新配置:
port=9090 host=localhost debug=true workers=8 theme=default cache=on两个文件第一行就不同,所以前缀收敛长度i=0。后缀方面,最后一行theme=default相同,所以后缀收敛只认到这一行。中间差异区间分别是旧文件的port=8080到workers=4,和新文件的port=9090到cache=on,正好覆盖了全部改动点。
执行脚本后,终端里的实际效果是这样:
- port=8080 + port=9090 host=localhost - debug=false + debug=true - workers=4 + workers=8 theme=default + cache=on在终端里,带-的行整体是红色,带+的行整体是绿色,普通行是默认颜色。扫一眼就能看出改动集中在端口、调试开关和 worker 数量,新配置还多了一个cache=on参数。这个可读性比标准 diff 的默认输出要直观不少,尤其是当你只需要给同事或自动化系统一个“变化摘要”的时候。
值得说明的是,LCS 回溯并不总是把删除和新增两两配对输出。把我这里的例子稍作变化,旧文件是A B C,新文件是B C D,LCS 是B C,那么输出顺序会是-A、B、C、+D。这种“删除多一项、新增多一项”的形态很常见,看到输出时不要觉得是脚本 bug。
5. 避坑记录:文件编码、空数组与终端颜色的那些坑
5.1 CRLF 与 LF 差异:看起来一模一样的行却不相等
我在一次对比 Windows 环境生成的配置文件和 Linux 环境配置时遇到一个典型的坑:文件内容在屏幕上看起来完全一样,但脚本把每一行都标记为差异。排查半天发现原因是换行符不同——Windows 文本是\r\n,Linux 文本是\n。mapfile -t只会去掉\n,不会去掉行尾的\r,所以同一行内容在 Windows 文件里就多了一个看不见的\r。
最简单的规避办法是在读取文件后做一次清洗:
mapfile -t lines1 < <(sed 's/\r$//' "$1") mapfile -t lines2 < <(sed 's/\r$//' "$2")用进程替换的方式,把文件内容先经过一次sed处理再进入数组,既不污染原文件,又能统一行尾符。如果你是日常在 Linux 上对比两个 Linux 文件,基本用不上这一步,但脚本要给别人用或要跨平台处理时,加上这个处理可以少接很多报障电话。
5.2 行首尾空格差异:肉眼看不见,算法分得清清楚楚
类似的陷阱还有行尾空格。debug=false和debug=false在编辑器里可能看不出区别,但在逐行字符串比较时它们就是不相等。这种差异在高亮输出里会表现为:某一行同时出现在-和+两个区域,但内容看起来一模一样。
如果你希望忽略这类空白差异,可以在 awk 比较前先做一次归一化。比如在读取行时去掉行尾空白,或者比较时用gsub(/[ \t]+$/, "", line)处理后比。我自己在写比对工具时倾向于默认保持严格比较,因为很多配置项的行尾空格其实是有意义的,但会提供-w参数作为可选项,以便在巡检场景里跳过纯空白噪声。
5.3 空数组输出空行:printf 格式串带来的隐蔽坑
前文提到过,printf '%s\n' "${mid1[@]}"在数组为空时,会直接输出一个空行。这个坑我在第一版脚本里就踩到了:对比一个文件新增了全部内容、另一个文件为空时,awk 收到一个多余的空行,导致输出最前面多了一个无意义的空行,或者在某些情况下让 LCS 结果里混入一个“相同空行”。
解决方案是用 for 循环逐行输出。虽然代码看上去不如单行 printf 简洁,但行为是无条件的稳健。这个细节很小,但它解释了为什么在 Shell 脚本里,“看起来等价”的写法未必等价,空数组的边界行为才是真正的分水岭。
5.4 颜色代码污染重定向文件:加一个 tty 检测就对了
脚本最初版本里,颜色转义序列是写死的,我在终端里跑得很爽。后来把它接进一个定时巡检任务,差异结果重定向到日志文件,结果日志文件里到处都是^[[31m和^[[32m,不仅看不了,用 grep 去过滤时还被这些字符串干扰。
解决办法就是前面脚本里已经体现的[[ -t 1 ]]检测。这是 Bash 判断标准输出是否为终端的最常用手段,重定向到文件或管道时条件不成立,颜色变量就置空。保持这个习惯后,脚本既能交互式高亮使用,也能无痛嵌入自动化链路。如果你还想更规范一些,还可以检查NO_COLOR环境变量,很多现代命令行工具都会遵守这个约定。
5.5 大文件性能与内存:LCS 不是万能钥匙
虽然前后缀收敛已经大幅缩小了 LCS 的计算范围,但并不意味着可以无脑对比超大文件。中间差异区很大时,DP 表的大小是n1 × n2,内存占用和计算时间都是 O(n×m)。我测试过一个双方各 5000 行、且从头到尾都不同的极端文件,awk 直接跑了十几秒,内存也吃掉不少。
遇到这种情况,有几个务实的方向:一是把 awk 的 DP 从存长度扩展到只存必要信息,减少关联数组的规模;二是引入更高级的 diff 算法,比如 Myers 的 O(ND) 算法,这也是标准 diff 在底层使用的思路;三是让脚本具备“分块”能力,比如把文件先按空行切块,对每个块单独求解。对于日常配置文件对比,第一个方向完全够用;真要对比几十万行的日志文件,还是直接用系统 diff 并发扬它的优化能力更明智。
6. 后续可以怎么扩展
脚本目前已经能完成核心的差异对比和高亮显示,但在真实工作流里还可以继续加深。比如增加忽略空行的-B选项,增加忽略行首尾空白的-w选项,甚至支持递归目录的对比。如果要把结果发送到 Web 页面渲染,还可以把输出改成 HTML 片段,用<span style="color:red">代替 ANSI 转义,这样直接嵌入内部巡检页面就能做可视化展示。
我自己后续打算加的一个功能是差异摘要统计:在输出末尾打印“共修改 X 处、删除 Y 行、新增 Z 行”,这样在自动化检查时只需要读取最后一行就能判断变更规模,不需要逐行解析输出。这几个扩展都不难,代码基座已经摆好,往里面加分支选项就是水磨工夫。
写这个脚本最大的收获其实不是脚本本身,而是对 LCS 算法突然有了身体记忆。以前看 diff 命令的文档,知道它用了动态规划,但“知道”和“亲手把 DP 表在 awk 里排出来、再回溯一遍”是两码事。现在再看到任何 diff 工具的彩色输出,我脑子里会自动浮现那张表的填充过程。这种感觉挺奇妙的,也算是我把这个工具从想法变成成品过程中最值回票价的部分。