設計文本差異比對 — LCS、Myers Diff 演算法與 Patch 生成實戰 | 資料結構與演算法
文本差異比對(Text Diff) 是計算兩份文字之間最小編輯操作的核心技術——從 Git 的
git diff顯示程式碼變更、GitHub 的 Pull Request 審查介面、到 Google Docs 的版本歷史與 Wikipedia 的編輯紀錄,背後都是 Diff 演算法在運作。本文將從需求分析出發,深入比較 LCS(最長公共子序列)、編輯距離(Edit Distance) 與 Myers Diff 演算法,實作完整的 Myers Diff 引擎 與 Unified Diff Patch 生成器,搭配 JavaScript/TypeScript 與 C++ 雙語言完整程式碼,帶你徹底掌握文本差異比對系統的設計與實戰。
前言
在上一篇文章中,我們實作了
推薦系統(Recommendation System),學會如何用協同過濾、Bloom Filter 與排序管線打造個人化推薦引擎。今天我們要解決一個每位開發者每天都在使用、卻鮮少深究其原理的系統——文本差異比對(Text Diff)。
你每天都在跟 Diff 打交道:在終端機執行 git diff 查看哪些程式碼被修改了;在 GitHub 上審查 Pull Request 時,綠色代表新增行、紅色代表刪除行;在 Google Docs 中查看「版本紀錄」時,系統高亮標記了每位協作者的修改。這些功能的背後,都是 Diff 演算法在比對兩份文本之間的差異。
Diff 的核心挑戰在於:如何在 線性或接近線性 的時間內,找出兩份文本之間的 最短編輯腳本(Shortest Edit Script, SES)?暴力嘗試所有編輯組合是指數級的——我們需要精心設計的動態規劃與圖搜尋演算法。答案是我們在
第 020 篇動態規劃基礎中學過的 LCS 與編輯距離概念,再進化為 Eugene Myers 於 1986 年提出的 O(ND) Diff 演算法。
本文你將學到:
- 文本差異比對系統的完整需求分析與規模估算
- 三種核心方案比較:LCS-based Diff、Edit Distance、Myers Diff
- 完整可運行的 JavaScript/TypeScript 與 C++ 雙語言實作(Myers Diff 引擎、Unified Diff 生成器)
- 生產環境考量:大檔案策略、二進位 Diff、三方合併、OT 與 CRDT
- 5 道 LeetCode 相關練習題
1. 需求分析
功能性需求(Functional Requirements)
- 兩版本比對:給定文字 A 與 B,輸出最小編輯腳本(insertions / deletions)
- Unified Diff 格式:輸出
git diff風格的@@差異區塊,包含上下文行 - Patch 生成與套用:從 diff 結果生成 patch 檔案,並能將 patch 套用到原始檔案
- 多粒度支援:支援行級(line-level)與字元級(character-level)差異比對
- 差異統計:輸出新增行數、刪除行數、未變更行數的摘要
非功能性需求(Non-Functional Requirements)
| 需求 | 目標 | 說明 |
|---|---|---|
| 低延遲 | 單次 diff < 100ms | 10,000 行以內的檔案 |
| 記憶體效率 | O(N+M) 線性空間 | Myers 優化版,避免 O(N×M) 的 DP 表格 |
| 大檔案支援 | 數萬行不 OOM | 分塊策略 + 行雜湊優化 |
| 可讀性 | Unified Diff 格式 | 與 git diff 相容,開發者熟悉 |
| 正確性 | 最短編輯腳本 | 保證找到最小差異,不產生冗餘操作 |
規模估算
以 GitHub 規模的程式碼審查平台為參考:
流量估算:
- DAU(日活躍用戶):5000 萬
- 每日 Pull Request 數:200 萬
- 平均 diff 大小:500 行(新增 250 行 + 刪除 250 行)
Diff QPS:200 萬 PR × 10 次刷新 / 86,400 秒 ≈ 231 QPS(平均)
峰值 QPS(× 10)≈ 2,310 QPS
計算資源:
- 每次 Myers Diff(500 行,D ≈ 50):O(N×D) ≈ 25,000 次操作
- 在現代 CPU 上 < 1ms
- 所需 CPU:2,310 QPS × 1ms ≈ 2.31 cores(加 3 倍餘裕)≈ 8 cores
儲存估算(diff 結果快取):
- 每個 diff 結果:500 行 × 200 bytes ≈ 100KB
- 每日新增:200 萬 × 100KB = 200 GB
- 保留 30 天:6 TB diff 快取
- 快取命中率(相同 PR 多次瀏覽):~70%
結論:Diff 計算本身非常輕量(< 1ms),核心瓶頸在 快取策略 與 大檔案的記憶體控制。使用內容定址快取(SHA256(A) + SHA256(B) 作為 key)可以大幅降低重複計算。
2. 方案設計
2.1 Diff 演算法全景
文本差異比對演算法分類:
Text Diff
│
┌──────────────┼──────────────┐
│ │ │
LCS-based Diff Edit Distance Myers Diff
(DP 基礎) (Levenshtein) (Git 預設)
O(N×M) O(N×M) O(N×D)
│ │
│ ┌──────┴──────┐
│ │ │
│ 基本版 Myers 線性空間
│ (回溯法) (分治法)
│ O(N×D) 時間 O(N×D) 時間
│ O(N×D) 空間 O(N) 空間
│
延伸演算法:
├── Patience Diff(以唯一行為錨點)
├── Histogram Diff(Git 4.0+ 預設)
└── AST Diff(語法樹級別)
2.2 三種方案比較
| 演算法 | 時間複雜度 | 空間複雜度 | 優點 | 缺點 | 適用場景 |
|---|---|---|---|---|---|
| LCS DP | O(N×M) | O(N×M) | 概念簡單、輸出 LCS 序列 | 大檔案記憶體爆炸 | 教學、短文本 |
| Edit Distance | O(N×M) | O(N×M) | 支援替換操作 | 不直接輸出 diff | 拼字檢查、模糊搜尋 |
| Myers Diff | O(N×D) | O(N) | D 小時極快、Git 預設 | D 大時退化 | 版本控制、code review |
| Patience Diff | O(N×D) | O(N) | 語義更好的 diff 結果 | 需要唯一行存在 | 大型重構場景 |
2.3 關鍵概念——LCS 與 Diff 的關係
LCS(Longest Common Subsequence,最長公共子序列) 是 Diff 的數學基礎。給定兩個序列 A 和 B,LCS 是同時存在於 A 和 B 中、順序一致但不需要連續的最長子序列。
文字 A:A B C B D A B
文字 B:B D C A B
LCS = B, C, A, B(長度 4)
直覺:LCS 就是兩份文本中「沒有被修改」的部分
刪除所有不在 LCS 中的元素 → 就是最短編輯腳本
編輯距離 = len(A) + len(B) − 2 × len(LCS)
= 7 + 5 − 2 × 4 = 4(需要 4 次 insert/delete)
2.4 Myers Diff 的核心直覺——編輯圖
Myers Diff 將 diff 問題轉換為 編輯圖(Edit Graph) 上的最短路徑問題:
編輯圖(Edit Graph):
A = "ABCABBA" → 橫軸(x)
B = "CBABAC" → 縱軸(y)
A B C A B B A
┌───┬───┬───┬───┬───┬───┬───┐
C │ │ │ ╲ │ │ │ │ │
├───┼───┼───┼───┼───┼───┼───┤
B │ │ ╲ │ │ │ ╲ │ ╲ │ │
├───┼───┼───┼───┼───┼───┼───┤
A │ ╲ │ │ │ ╲ │ │ │ ╲ │
├───┼───┼───┼───┼───┼───┼───┤
B │ │ ╲ │ │ │ ╲ │ ╲ │ │
├───┼───┼───┼───┼───┼───┼───┤
A │ ╲ │ │ │ ╲ │ │ │ ╲ │
├───┼───┼───┼───┼───┼───┼───┤
C │ │ │ ╲ │ │ │ │ │
└───┴───┴───┴───┴───┴───┴───┘
→ 向右移動(cost 1):從 A 中刪除一個字元
↓ 向下移動(cost 1):插入 B 中的一個字元
╲ 對角線移動(cost 0):A[i] == B[j],字元匹配(snake)
目標:找從 (0,0) 到 (N,M) 的最短路徑
最短路徑長度 = D = 最小編輯距離
Myers 的洞察:使用 BFS 按編輯距離 d 逐層擴展,每層在所有 k 對角線(k = x − y)上找最遠到達點,並盡量沿對角線延伸(snake)。當某個前端到達 (N, M) 時,d 就是最短編輯距離。
3. 核心實作——JavaScript / TypeScript
3.1 LCS 動態規劃(Diff 的理論基礎)
// ================================================================
// LCS 動態規劃 — Diff 的數學基礎
// ================================================================
/**
* 計算兩個序列的最長公共子序列(LCS)
* 時間複雜度:O(N×M),空間複雜度:O(N×M)
*/
function lcs(a: string[], b: string[]): string[] {
const N = a.length;
const M = b.length;
// dp[i][j] = a[0..i-1] 與 b[0..j-1] 的 LCS 長度
const dp: number[][] = Array.from({ length: N + 1 }, () =>
new Array(M + 1).fill(0)
);
for (let i = 1; i <= N; i++) {
for (let j = 1; j <= M; j++) {
if (a[i - 1] === b[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
// 回溯找出 LCS 序列
const result: string[] = [];
let i = N, j = M;
while (i > 0 && j > 0) {
if (a[i - 1] === b[j - 1]) {
result.unshift(a[i - 1]);
i--; j--;
} else if (dp[i - 1][j] >= dp[i][j - 1]) {
i--;
} else {
j--;
}
}
return result;
}
// ─── 使用範例 ────────────────────────────────────────────
const seqA = ["A", "B", "C", "B", "D", "A", "B"];
const seqB = ["B", "D", "C", "A", "B"];
console.log(lcs(seqA, seqB));
// 輸出:["B", "C", "A", "B"](長度 4)
// 編輯距離 = 7 + 5 - 2×4 = 4
3.2 編輯距離(Levenshtein Distance)
// ================================================================
// 編輯距離 — 支援插入、刪除、替換三種操作
// ================================================================
function editDistance(a: string, b: string): number {
const N = a.length;
const M = b.length;
// dp[i][j] = a[0..i-1] 轉換為 b[0..j-1] 的最小操作數
const dp: number[][] = Array.from({ length: N + 1 }, () =>
new Array(M + 1).fill(0)
);
// 基底案例:空字串的轉換
for (let i = 0; i <= N; i++) dp[i][0] = i; // 刪除 i 個字元
for (let j = 0; j <= M; j++) dp[0][j] = j; // 插入 j 個字元
for (let i = 1; i <= N; i++) {
for (let j = 1; j <= M; j++) {
if (a[i - 1] === b[j - 1]) {
dp[i][j] = dp[i - 1][j - 1]; // 字元相同,無需操作
} else {
dp[i][j] = 1 + Math.min(
dp[i - 1][j], // 刪除 a[i-1]
dp[i][j - 1], // 插入 b[j-1]
dp[i - 1][j - 1] // 替換 a[i-1] → b[j-1]
);
}
}
}
return dp[N][M];
}
console.log(editDistance("kitten", "sitting"));
// 輸出:3(k→s、e→i、插入 g)
3.3 Myers Diff 完整實作
// ================================================================
// Myers Diff Algorithm — TypeScript 完整實作
// 輸出:unified diff 格式(git diff 風格)
// ================================================================
type DiffOperation = "equal" | "insert" | "delete";
interface DiffChange {
op: DiffOperation;
line: string;
lineNumA?: number; // 在 A 中的行號(1-indexed)
lineNumB?: number; // 在 B 中的行號(1-indexed)
}
interface DiffHunk {
startA: number;
startB: number;
lines: DiffChange[];
}
interface DiffStats {
additions: number;
deletions: number;
unchanged: number;
}
// ────────────────────────────────────────────────────────
// 核心:Myers Diff(行級)
// 時間複雜度:O(N×D),D = 實際編輯距離
// 空間複雜度:O(N×D)(存 trace 用於回溯)
// ────────────────────────────────────────────────────────
function myersDiff(a: string[], b: string[]): DiffChange[] {
const N = a.length;
const M = b.length;
if (N === 0 && M === 0) return [];
if (N === 0) {
return b.map((line, i) => ({ op: "insert" as const, line, lineNumB: i + 1 }));
}
if (M === 0) {
return a.map((line, i) => ({ op: "delete" as const, line, lineNumA: i + 1 }));
}
const MAX = N + M;
// V[k] = 在對角線 k 上能到達的最大 x 座標
const V: number[] = new Array(2 * MAX + 1).fill(0);
// 追蹤每個 d 步的 V 快照,用於回溯路徑
const trace: number[][] = [];
// Forward pass:找最短編輯距離 D
for (let d = 0; d <= MAX; d++) {
trace.push([...V]);
for (let k = -d; k <= d; k += 2) {
let x: number;
// 選擇方向:向下(插入)或向右(刪除)
if (k === -d || (k !== d && V[k - 1 + MAX] < V[k + 1 + MAX])) {
x = V[k + 1 + MAX]; // 向下移動(插入 B[y])
} else {
x = V[k - 1 + MAX] + 1; // 向右移動(刪除 A[x])
}
let y = x - k;
// 沿對角線延伸 snake(連續匹配,cost = 0)
while (x < N && y < M && a[x] === b[y]) {
x++;
y++;
}
V[k + MAX] = x;
// 到達終點 (N, M)
if (x >= N && y >= M) {
return backtrack(trace, a, b, N, M, MAX);
}
}
}
throw new Error("Myers diff failed to converge");
}
// ────────────────────────────────────────────────────────
// 回溯:從 trace 還原編輯腳本
// ────────────────────────────────────────────────────────
function backtrack(
trace: number[][],
a: string[],
b: string[],
N: number,
M: number,
MAX: number
): DiffChange[] {
const changes: DiffChange[] = [];
let x = N;
let y = M;
for (let d = trace.length - 1; d >= 0; d--) {
const V = trace[d];
const k = x - y;
let prevK: number;
if (k === -d || (k !== d && V[k - 1 + MAX] < V[k + 1 + MAX])) {
prevK = k + 1; // 之前是向下移動(插入)
} else {
prevK = k - 1; // 之前是向右移動(刪除)
}
const prevX = V[prevK + MAX];
const prevY = prevX - prevK;
// snake 部分(對角線匹配,倒退還原)
while (x > prevX && y > prevY && x > 0 && y > 0 && a[x - 1] === b[y - 1]) {
changes.unshift({
op: "equal", line: a[x - 1], lineNumA: x, lineNumB: y,
});
x--;
y--;
}
// 本次編輯操作
if (d > 0) {
if (prevK === k + 1) {
// 插入:從 B 取一行
if (y > 0) {
changes.unshift({ op: "insert", line: b[y - 1], lineNumB: y });
y--;
}
} else {
// 刪除:從 A 取一行
if (x > 0) {
changes.unshift({ op: "delete", line: a[x - 1], lineNumA: x });
x--;
}
}
}
}
return changes;
}
// ────────────────────────────────────────────────────────
// 建構 Hunk(差異區塊)
// ────────────────────────────────────────────────────────
function buildHunks(changes: DiffChange[], contextLines: number): DiffHunk[] {
// 標記哪些 equal 行需要顯示(作為 context)
const needed = new Set<number>();
for (let i = 0; i < changes.length; i++) {
if (changes[i].op !== "equal") {
for (
let j = Math.max(0, i - contextLines);
j < Math.min(changes.length, i + contextLines + 1);
j++
) {
needed.add(j);
}
}
}
const hunks: DiffHunk[] = [];
let currentHunk: DiffHunk | null = null;
let lineA = 1;
let lineB = 1;
for (let i = 0; i < changes.length; i++) {
const change = changes[i];
if (change.op === "equal" && !needed.has(i)) {
if (currentHunk) {
hunks.push(currentHunk);
currentHunk = null;
}
lineA++;
lineB++;
continue;
}
if (!currentHunk) {
currentHunk = { startA: lineA, startB: lineB, lines: [] };
}
currentHunk.lines.push({ ...change, lineNumA: lineA, lineNumB: lineB });
if (change.op !== "insert") lineA++;
if (change.op !== "delete") lineB++;
}
if (currentHunk) hunks.push(currentHunk);
return hunks;
}
// ────────────────────────────────────────────────────────
// Unified Diff 格式化器
// ────────────────────────────────────────────────────────
function formatUnifiedDiff(
changes: DiffChange[],
fileA: string = "a/file",
fileB: string = "b/file",
contextLines: number = 3
): string {
const hunks = buildHunks(changes, contextLines);
if (hunks.length === 0) return "";
const lines: string[] = [`--- ${fileA}`, `+++ ${fileB}`];
for (const hunk of hunks) {
const addCount = hunk.lines.filter((l) => l.op !== "delete").length;
const delCount = hunk.lines.filter((l) => l.op !== "insert").length;
lines.push(`@@ -${hunk.startA},${delCount} +${hunk.startB},${addCount} @@`);
for (const change of hunk.lines) {
if (change.op === "equal") lines.push(` ${change.line}`);
if (change.op === "delete") lines.push(`-${change.line}`);
if (change.op === "insert") lines.push(`+${change.line}`);
}
}
return lines.join("\n");
}
// ────────────────────────────────────────────────────────
// 統計函數
// ────────────────────────────────────────────────────────
function getDiffStats(changes: DiffChange[]): DiffStats {
return {
additions: changes.filter((c) => c.op === "insert").length,
deletions: changes.filter((c) => c.op === "delete").length,
unchanged: changes.filter((c) => c.op === "equal").length,
};
}
// ────────────────────────────────────────────────────────
// 便利函數:直接比較兩個多行字串
// ────────────────────────────────────────────────────────
function diffStrings(
textA: string,
textB: string,
fileA: string = "a/file",
fileB: string = "b/file"
): string {
const linesA = textA.split("\n");
const linesB = textB.split("\n");
const changes = myersDiff(linesA, linesB);
return formatUnifiedDiff(changes, fileA, fileB);
}
// ────────────────────────────────────────────────────────
// Patch 套用器
// ────────────────────────────────────────────────────────
function applyPatch(original: string[], changes: DiffChange[]): string[] {
const result: string[] = [];
for (const change of changes) {
if (change.op === "equal") result.push(change.line);
if (change.op === "insert") result.push(change.line);
// delete 的行不加入結果(跳過)
}
return result;
}
// ================================================================
// 完整使用示範
// ================================================================
const textA = `function hello() {
console.log("Hello");
return true;
}`;
const textB = `function hello(name: string) {
console.log(\`Hello, \${name}\`);
console.log("Done");
return true;
}`;
// 1. 產生 diff
const diff = diffStrings(textA, textB, "a/hello.ts", "b/hello.ts");
console.log("=== Unified Diff ===");
console.log(diff);
// 輸出:
// --- a/hello.ts
// +++ b/hello.ts
// @@ -1,4 +1,5 @@
// -function hello() {
// +function hello(name: string) {
// - console.log("Hello");
// + console.log(`Hello, ${name}`);
// + console.log("Done");
// return true;
// }
// 2. 取得統計
const linesA = textA.split("\n");
const linesB = textB.split("\n");
const changes = myersDiff(linesA, linesB);
const stats = getDiffStats(changes);
console.log("\n=== Diff 統計 ===");
console.log(stats);
// 輸出:{ additions: 3, deletions: 2, unchanged: 2 }
// 3. 套用 patch
const patched = applyPatch(linesA, changes);
console.log("\n=== Patch 後的內容 ===");
console.log(patched.join("\n"));
// 輸出:與 textB 相同
// 4. 驗證 patch 正確性
console.log("\nPatch 正確:", patched.join("\n") === textB);
// 輸出:Patch 正確:true
4. 核心實作——C++
C++ 版本採用 Myers Diff 的線性空間優化版(分治法),使用 middle snake 搜尋實現 O(N) 空間複雜度,適合大檔案的高效能比對場景。
4.1 Myers Diff 線性空間優化
// ================================================================
// Myers Diff Algorithm — C++ 高效實作
// 線性空間分治版:O(N×D) 時間,O(N+D) 空間
// ================================================================
#include <iostream>
#include <vector>
#include <string>
#include <sstream>
#include <algorithm>
#include <tuple>
#include <chrono>
struct DiffChange {
enum class Op { EQUAL, INSERT, DELETE };
Op op;
std::string line;
int lineNumA = -1; // -1 表示不適用
int lineNumB = -1;
};
class MyersDiff {
public:
struct EditScript {
std::vector<DiffChange> changes;
int additions = 0;
int deletions = 0;
int unchanged = 0;
};
// 主要入口:比較兩個字串向量
static EditScript diff(
const std::vector<std::string>& a,
const std::vector<std::string>& b
) {
EditScript result;
const int N = static_cast<int>(a.size());
const int M = static_cast<int>(b.size());
if (N == 0 && M == 0) return result;
if (N == 0) {
for (int i = 0; i < M; i++) {
result.changes.push_back(
{DiffChange::Op::INSERT, b[i], -1, i + 1});
result.additions++;
}
return result;
}
if (M == 0) {
for (int i = 0; i < N; i++) {
result.changes.push_back(
{DiffChange::Op::DELETE, a[i], i + 1, -1});
result.deletions++;
}
return result;
}
// 分治法計算 diff
auto changes = computeDiff(a, b, 0, N, 0, M);
// 填充行號
int lineA = 1, lineB = 1;
for (auto& c : changes) {
c.lineNumA = (c.op != DiffChange::Op::INSERT) ? lineA : -1;
c.lineNumB = (c.op != DiffChange::Op::DELETE) ? lineB : -1;
if (c.op != DiffChange::Op::INSERT) lineA++;
if (c.op != DiffChange::Op::DELETE) lineB++;
}
result.changes = std::move(changes);
for (const auto& c : result.changes) {
if (c.op == DiffChange::Op::INSERT) result.additions++;
else if (c.op == DiffChange::Op::DELETE) result.deletions++;
else result.unchanged++;
}
return result;
}
// 輸出 unified diff 格式
static std::string formatUnified(
const EditScript& script,
const std::string& fileA = "a/file",
const std::string& fileB = "b/file",
int context = 3
) {
std::ostringstream oss;
oss << "--- " << fileA << "\n";
oss << "+++ " << fileB << "\n";
const auto& changes = script.changes;
std::vector<bool> needed(changes.size(), false);
for (int i = 0; i < static_cast<int>(changes.size()); i++) {
if (changes[i].op != DiffChange::Op::EQUAL) {
for (int j = std::max(0, i - context);
j < std::min((int)changes.size(), i + context + 1);
j++) {
needed[j] = true;
}
}
}
int lineA = 1, lineB = 1;
int i = 0;
while (i < static_cast<int>(changes.size())) {
if (!needed[i]) {
if (changes[i].op != DiffChange::Op::INSERT) lineA++;
if (changes[i].op != DiffChange::Op::DELETE) lineB++;
i++;
continue;
}
int startA = lineA, startB = lineB;
std::vector<int> hunkIdx;
while (i < (int)changes.size() && needed[i]) {
hunkIdx.push_back(i++);
}
int delCount = 0, addCount = 0;
for (int idx : hunkIdx) {
if (changes[idx].op != DiffChange::Op::INSERT) delCount++;
if (changes[idx].op != DiffChange::Op::DELETE) addCount++;
}
oss << "@@ -" << startA << "," << delCount
<< " +" << startB << "," << addCount << " @@\n";
for (int idx : hunkIdx) {
const auto& c = changes[idx];
if (c.op == DiffChange::Op::EQUAL) oss << " " << c.line << "\n";
if (c.op == DiffChange::Op::DELETE) oss << "-" << c.line << "\n";
if (c.op == DiffChange::Op::INSERT) oss << "+" << c.line << "\n";
if (c.op != DiffChange::Op::INSERT) lineA++;
if (c.op != DiffChange::Op::DELETE) lineB++;
}
}
return oss.str();
}
// 比較兩個多行字串(便利函數)
static std::string diffStrings(
const std::string& textA,
const std::string& textB,
const std::string& fileA = "a/file",
const std::string& fileB = "b/file"
) {
auto script = diff(splitLines(textA), splitLines(textB));
return formatUnified(script, fileA, fileB);
}
private:
// 分治法計算 diff(線性空間)
static std::vector<DiffChange> computeDiff(
const std::vector<std::string>& a, const std::vector<std::string>& b,
int aLo, int aHi, int bLo, int bHi
) {
// 基底情況:一方為空
if (aLo == aHi) {
std::vector<DiffChange> result;
for (int j = bLo; j < bHi; j++) {
result.push_back({DiffChange::Op::INSERT, b[j], -1, -1});
}
return result;
}
if (bLo == bHi) {
std::vector<DiffChange> result;
for (int i = aLo; i < aHi; i++) {
result.push_back({DiffChange::Op::DELETE, a[i], -1, -1});
}
return result;
}
// 找中間蛇(middle snake)
auto [x, y, u, v] = findMiddleSnake(a, b, aLo, aHi, bLo, bHi);
// 分治:左半部 + middle snake + 右半部
auto left = computeDiff(a, b, aLo, x, bLo, y);
// 中間 snake(equal 部分)
for (int i = x, j = y; i < u; i++, j++) {
left.push_back({DiffChange::Op::EQUAL, a[i], -1, -1});
}
auto right = computeDiff(a, b, u, aHi, v, bHi);
left.insert(left.end(), right.begin(), right.end());
return left;
}
// 找中間蛇(Myers 分治核心)
static std::tuple<int, int, int, int> findMiddleSnake(
const std::vector<std::string>& a, const std::vector<std::string>& b,
int aLo, int aHi, int bLo, int bHi
) {
const int N = aHi - aLo;
const int M = bHi - bLo;
const int delta = N - M;
const int MAX = (N + M + 1) / 2 + 1;
std::vector<int> Vf(2 * MAX + 2, 0); // 前向 V
std::vector<int> Vr(2 * MAX + 2, 0); // 反向 V
Vr[MAX + 1] = N;
for (int d = 0; d <= (N + M + 1) / 2; d++) {
// 前向搜尋
for (int k = -d; k <= d; k += 2) {
int x;
if (k == -d || (k != d && Vf[k - 1 + MAX] < Vf[k + 1 + MAX])) {
x = Vf[k + 1 + MAX];
} else {
x = Vf[k - 1 + MAX] + 1;
}
int y = x - k;
int sx = x, sy = y;
while (x < N && y < M && a[aLo + x] == b[bLo + y]) {
x++; y++;
}
Vf[k + MAX] = x;
if ((delta % 2 != 0) &&
(k - delta) >= -(d - 1) && (k - delta) <= (d - 1)) {
if (Vf[k + MAX] + Vr[k - delta + MAX] >= N) {
return {aLo + sx, bLo + sy, aLo + x, bLo + y};
}
}
}
// 反向搜尋
for (int k = -d; k <= d; k += 2) {
int x;
if (k == -d || (k != d && Vr[k - 1 + MAX] > Vr[k + 1 + MAX])) {
x = Vr[k + 1 + MAX] - 1;
} else {
x = Vr[k - 1 + MAX];
}
int y = x - (k + delta);
int ex = x, ey = y;
while (x > 0 && y > 0 &&
a[aLo + x - 1] == b[bLo + y - 1]) {
x--; y--;
}
Vr[k + MAX] = x;
if ((delta % 2 == 0) &&
(k + delta) >= -d && (k + delta) <= d) {
if (Vf[k + delta + MAX] + Vr[k + MAX] >= N) {
return {aLo + x, bLo + y, aLo + ex, bLo + ey};
}
}
}
}
return {aLo, bLo, aLo, bLo}; // 不應到達
}
static std::vector<std::string> splitLines(const std::string& text) {
std::vector<std::string> lines;
std::istringstream stream(text);
std::string line;
while (std::getline(stream, line)) {
lines.push_back(line);
}
return lines;
}
};
// ================================================================
// 效能基準測試
// ================================================================
void benchmarkMyersDiff() {
// 產生測試資料:兩份 5000 行的程式碼,差異約 5%
std::vector<std::string> fileA, fileB;
for (int i = 0; i < 5000; i++) {
fileA.push_back("line " + std::to_string(i) +
": original content here");
}
fileB = fileA; // 複製
// 修改 5% 的行(模擬小幅修改)
for (int i = 0; i < 5000; i += 20) {
fileB[i] = "line " + std::to_string(i) + ": MODIFIED content here";
}
// 插入 50 行
for (int i = 0; i < 50; i++) {
fileB.insert(fileB.begin() + i * 100, "NEW LINE inserted");
}
auto t0 = std::chrono::high_resolution_clock::now();
auto script = MyersDiff::diff(fileA, fileB);
auto t1 = std::chrono::high_resolution_clock::now();
double ms = std::chrono::duration_cast<std::chrono::microseconds>(
t1 - t0).count() / 1000.0;
std::cout << "\n=== 效能基準 ===\n";
std::cout << "檔案 A:" << fileA.size() << " 行\n";
std::cout << "檔案 B:" << fileB.size() << " 行\n";
std::cout << "新增:" << script.additions << " 行\n";
std::cout << "刪除:" << script.deletions << " 行\n";
std::cout << "未變更:" << script.unchanged << " 行\n";
std::cout << "耗時:" << ms << " ms\n";
}
// ================================================================
// 主程式示範
// ================================================================
int main() {
std::cout << "=== Myers Diff C++ 演示 ===\n\n";
std::string textA = R"(function hello() {
console.log("Hello");
return true;
})";
std::string textB = R"(function hello(name) {
console.log("Hello, " + name);
console.log("Done");
return true;
})";
std::string result = MyersDiff::diffStrings(
textA, textB, "a/hello.js", "b/hello.js");
std::cout << result << std::endl;
// 輸出:
// --- a/hello.js
// +++ b/hello.js
// @@ -1,4 +1,5 @@
// -function hello() {
// +function hello(name) {
// - console.log("Hello");
// + console.log("Hello, " + name);
// + console.log("Done");
// return true;
// }
// 效能基準
benchmarkMyersDiff();
// 輸出:
// === 效能基準 ===
// 檔案 A:5000 行
// 檔案 B:5050 行
// 新增:~300 行
// 刪除:~250 行
// 未變更:~4750 行
// 耗時:< 50 ms
return 0;
}
5. 效能分析
時間複雜度
| 操作 | 複雜度 | 說明 |
|---|---|---|
| LCS DP | O(N×M) | N、M 為兩個序列的長度 |
| Edit Distance | O(N×M) | 同上,支援替換操作 |
| Myers Diff(基本版) | O(N×D) | D = 實際編輯距離,D 小時極快 |
| Myers Diff(線性空間) | O(N×D) | 分治法,空間降至 O(N) |
| Unified Diff 格式化 | O(C) | C = changes 數量,線性掃描 |
| Patch 套用 | O(C) | 線性掃描 changes 產生結果 |
空間複雜度
| 結構 | 空間 | 說明 |
|---|---|---|
| LCS DP 表格 | O(N×M) | 完整 DP 表格,大檔案不可行 |
| LCS DP 空間優化 | O(min(N,M)) | 滾動陣列,但無法回溯 LCS |
| Myers Diff(基本版) | O(N×D) | 存 trace 快照用於回溯 |
| Myers Diff(分治版) | O(N+D) | 只需前向 / 反向兩個 V 陣列 |
| Unified Diff 輸出 | O(C) | C = 有差異的行數 |
效能基準
| 場景 | 檔案大小 | 差異量 D | 預期延遲 | 記憶體 |
|---|---|---|---|---|
| 小型 commit(日常修改) | 500 行 | D ≈ 20 | < 1ms | < 100 KB |
| 中型 PR(功能開發) | 5,000 行 | D ≈ 200 | < 10ms | < 1 MB |
| 大型重構 | 50,000 行 | D ≈ 5,000 | < 200ms | < 50 MB |
| 超大型遷移 | 500,000 行 | D ≈ 50,000 | 1-5s | 需分塊策略 |
關鍵洞察:Myers Diff 的效能與 差異量 D 成正比,而非檔案大小 N。在版本控制的典型場景中(相鄰 commit 差異很小),D 遠小於 N,因此 Myers Diff 的實際速度遠優於 LCS DP 的 O(N×M)。
6. 生產環境考量
6.1 大檔案 Diff 策略
檔案大小 策略
──────────────────────────────────────────────────
< 10K 行 Myers Diff(直接計算)
10K - 100K 行 行雜湊優化:先將每行文字計算雜湊值(FNV-1a)
比較雜湊值而非完整字串,減少字串比較開銷
> 100K 行 分塊 + Patience Diff:
以唯一行(函數簽名等)為錨點
遞迴分治,大幅減少比較量
二進位檔案 bsdiff / xdelta3:
基於滑動視窗與壓縮的 binary diff
行雜湊優化的具體實作思路:
// 行雜湊優化:將字串比較轉為數字比較
function hashLine(line: string): number {
// FNV-1a 雜湊
let hash = 0x811c9dc5;
for (let i = 0; i < line.length; i++) {
hash ^= line.charCodeAt(i);
hash = (hash * 0x01000193) >>> 0;
}
return hash;
}
function optimizedDiff(a: string[], b: string[]): DiffChange[] {
// 第一步:計算每行的雜湊值
const hashA = a.map(hashLine);
const hashB = b.map(hashLine);
// 第二步:用雜湊值做 Myers Diff(數字比較遠快於字串比較)
// 第三步:碰撞驗證——對 diff 結果中的 equal 行驗證原始字串是否真的相同
// 略:實作與 myersDiff 類似,但比較 hashA[x] === hashB[y]
return myersDiff(a, b); // 簡化示範
}
6.2 三方合併(Three-Way Merge)
三方合併是版本控制中處理分支合併的核心技術。給定 base(共同祖先)、ours(本地版本)、theirs(遠端版本),合併流程如下:
三方合併流程:
Base ──────── Ours(你的修改)
│
└────────── Theirs(別人的修改)
1. 計算 diff(Base, Ours) → 你的修改集 Δ1
2. 計算 diff(Base, Theirs) → 別人的修改集 Δ2
3. 合併 Δ1 和 Δ2:
- Δ1 和 Δ2 修改不同行 → 自動合併
- Δ1 和 Δ2 修改相同行但內容相同 → 去重
- Δ1 和 Δ2 修改相同行但內容不同 → 衝突!
衝突標記格式(git merge conflict):
<<<<<<< HEAD
你的版本
=======
別人的版本
>>>>>>> feature-branch
6.3 即時協作——OT 與 CRDT
當多位使用者同時編輯同一份文件時,單純的 Diff + Merge 不夠——需要 Operational Transformation(OT) 或 CRDT 來保證一致性。
OT(Operational Transformation):
場景:用戶 A 和 B 同時編輯 "Hello"
用戶 A:在位置 5 插入 " World" → Op1: insert(5, " World")
用戶 B:在位置 0 插入 "Say " → Op2: insert(0, "Say ")
如果 A 先收到 B 的操作:
B 在位置 0 插入了 4 個字元 → A 的操作位置需要右移 4
轉換後的 Op1': insert(9, " World")
結果:"Say Hello World"(兩端一致)
CRDT(Conflict-free Replicated Data Type):
| 維度 | OT | CRDT |
|---|---|---|
| 收斂保證 | 需正確實作轉換函數 | 數學保證(交換律 + 結合律) |
| 中央伺服器 | 需要(序列化操作順序) | 不需要(可 P2P) |
| 離線支援 | 有限 | 天生支援 |
| 代表實作 | Google Docs(早期) | Yjs、Automerge |
| 效能 | 較好(操作小) | 略差(metadata 開銷) |
生產環境推薦使用成熟的 CRDT 函式庫(如 Yjs)而非自製:
// 生產環境推薦:使用 Yjs CRDT 實現即時協作
import * as Y from "yjs";
import { WebsocketProvider } from "y-websocket";
const doc = new Y.Doc();
const text = doc.getText("content");
// 透過 WebSocket 自動同步(底層使用 CRDT)
const provider = new WebsocketProvider(
"wss://your-sync-server.com",
"document-id",
doc
);
// 本地編輯(自動廣播並合併)
text.insert(0, "Hello, World!");
// 監聽遠端變更
text.observe((event) => {
console.log("Document changed:", text.toString());
});
6.4 Diff 快取策略
分散式 Diff 快取架構:
請求雜湊策略:
key = SHA256(content_A) + "-" + SHA256(content_B)
快取層次:
L1:Process 記憶體(LRU,最近 100 次 diff) → < 0.1ms
L2:Redis(TTL 24h,命中率 ~70%) → < 2ms
L3:物件儲存(永久保存,PR diff 從不過期) → < 50ms
好處:
1. 相同內容不論請求順序,總是命中快取(內容定址)
2. 多個 PR 引用同一個 diff(如 cherry-pick),自動去重
3. 可存入 CDN(邊緣快取),降低延遲
6.5 語法感知 Diff(AST Diff)
普通行級 diff 在重構場景下可讀性差——函數移動會被顯示為大量刪除 + 插入。AST Diff 在語法樹層面計算差異:
行級 diff(可讀性差): AST Diff(語義清晰):
-function foo() { MOVED: function foo
- return 1; FROM: line 1-3
-} TO: line 20-22
...20 行不相關程式碼...
+function foo() {
+ return 1;
+}
AST Diff 的流程:先將文字解析為 AST(Abstract Syntax Tree),在樹節點層面計算 Tree Edit Distance,識別 MOVE、RENAME、UPDATE 等高層操作。工具如 GumTree 和 tree-sitter diff 已經提供成熟的語法感知比對。
7. LeetCode 練習
以下是與文本差異比對核心演算法相關的練習題目:
題目一:LeetCode 1143 — Longest Common Subsequence
| 項目 | 內容 |
|---|---|
| 難度 | Medium |
| 連結 | LeetCode 1143 |
| 核心 | 計算兩個字串的最長公共子序列長度 |
| 提示 | 這就是 Diff 的數學基礎——LCS 長度決定了最小編輯距離。使用 2D DP 表格,dp[i][j] 表示 text1[0..i-1] 與 text2[0..j-1] 的 LCS 長度。本文的 LCS 實作可以直接作為解題模板 |
題目二:LeetCode 72 — Edit Distance
| 項目 | 內容 |
|---|---|
| 難度 | Medium |
| 連結 | LeetCode 72 |
| 核心 | 計算將 word1 轉換為 word2 所需的最少操作數(插入、刪除、替換) |
| 提示 | Edit Distance 是 Diff 的另一個視角——Myers Diff 計算的是只允許插入和刪除的編輯距離。本題額外允許替換操作,DP 轉移方程多一個 dp[i-1][j-1] + 1 的替換選項 |
題目三:LeetCode 583 — Delete Operation for Two Strings
| 項目 | 內容 |
|---|---|
| 難度 | Medium |
| 連結 | LeetCode 583 |
| 核心 | 計算使兩個字串相同所需的最少刪除次數 |
| 提示 | 這正是 Diff 的核心問題!答案 = len(A) + len(B) - 2 × LCS(A, B)。可以先算 LCS 再推導,也可以直接用 DP 計算最小刪除數 |
題目四:LeetCode 712 — Minimum ASCII Delete Sum for Two Strings
| 項目 | 內容 |
|---|---|
| 難度 | Medium |
| 連結 | LeetCode 712 |
| 核心 | 使兩個字串相同所需刪除字元的最小 ASCII 總和 |
| 提示 | 比題目三更進一步——不僅考慮操作次數,還考慮操作代價。類似於 weighted edit distance,對應生產環境中「優先保留重要行、刪除不重要行」的 diff 啟發式策略 |
題目五:LeetCode 97 — Interleaving String
| 項目 | 內容 |
|---|---|
| 難度 | Medium |
| 連結 | LeetCode 97 |
| 核心 | 判斷 s3 是否是 s1 和 s2 的交錯組合 |
| 提示 | 與三方合併(Three-Way Merge)的概念相關——將兩個來源的修改交錯合併為最終結果。使用 2D DP,dp[i][j] 表示 s1[0..i-1] 和 s2[0..j-1] 能否交錯組成 s3[0..i+j-1] |
練習建議:先完成 1143(LCS 是 Diff 的理論基礎),再做 72 理解編輯距離的完整定義。583 是 Diff 問題的直接建模,712 加入代價權重。最後用 97 練習多源合併的思維,串起從 LCS、編輯距離到三方合併的完整知識鏈。
8. 總結
在這篇文章中,我們從需求分析出發,完整設計並實作了一套文本差異比對系統:
LCS 動態規劃:建立了 Diff 的數學基礎——最長公共子序列決定了最小編輯距離。理解
dp[i][j]的遞推公式是掌握所有 Diff 演算法的前提。Myers Diff 演算法:Git 和 GNU diff 的預設演算法。透過編輯圖上的 BFS 搜尋,在 O(N×D) 時間內找到最短編輯腳本。當差異量 D 遠小於檔案大小 N 時(版本控制的典型場景),效能遠優於 LCS DP 的 O(N×M)。
Unified Diff 格式化:將 Myers Diff 的編輯腳本轉換為
git diff風格的@@差異區塊,包含上下文行(context lines),產生開發者熟悉的可讀格式。Patch 生成與套用:從 diff 結果生成可套用的 patch,實現「計算差異 → 傳輸 patch → 套用還原」的完整工作流程。
生產環境擴展:大檔案的行雜湊 + 分塊策略、三方合併(git merge)、即時協作(OT / CRDT)、Diff 快取策略、語法感知 AST Diff——這些是從原型到大規模生產的必經之路。
文本差異比對是 LCS、動態規劃、圖搜尋與系統架構設計的綜合應用。理解了 Myers Diff 的設計原理後,你不僅能在面試中從容解答 LCS 與編輯距離相關問題,還能在工作中理解 Git 底層的 diff 機制,甚至設計自己的程式碼審查工具。
在下一篇文章中,我們將迎來本系列的最終篇——
設計負載均衡器(Load Balancer),學習如何用一致性雜湊與加權輪詢演算法打造高可用的流量分配系統,為整個「資料結構與演算法」系列畫上圓滿的句號,敬請期待!
FAQ
Q: Myers Diff 和 LCS DP 有什麼差異?什麼時候該用哪一種?
兩者的本質關係是:LCS DP 計算最長公共子序列,而 Myers Diff 計算最短編輯腳本(SES),兩者的結果可以互相轉換——刪除所有不在 LCS 中的元素就等於最短編輯腳本。差異在於效能特性:LCS DP 的時間與空間複雜度固定為 O(N×M),無論兩份文本差異大小都一樣慢;Myers Diff 的時間複雜度為 O(N×D),其中 D 是實際編輯距離,當兩份文本差異很小(D 遠小於 N)時,Myers Diff 遠快於 LCS DP。這也是為什麼 Git 和 GNU diff 都選擇 Myers 作為預設演算法——程式碼版本控制中,相鄰版本的差異通常很小。如果你需要的是 LCS 序列本身(如生物資訊學中的序列比對),用 LCS DP;如果你需要生成 diff 輸出(如 git diff、code review),用 Myers Diff。
Q: Unified Diff 格式中的 @@ 標記代表什麼意思?
Unified Diff 的 @@ 標記稱為 hunk header,格式為 @@ -a,b +c,d @@,其中 -a,b 表示這個差異區塊在原始檔案中從第 a 行開始、共 b 行;+c,d 表示在新檔案中從第 c 行開始、共 d 行。例如 @@ -10,7 +10,9 @@ 表示原始檔案從第 10 行取 7 行、新檔案從第 10 行取 9 行(新增了 2 行)。每個 hunk 內部的行以三種前綴區分:空格表示兩個版本相同的上下文行(context line),減號 - 表示只存在於原始版本的刪除行,加號 + 表示只存在於新版本的新增行。@@ 標記的設計讓 patch 工具能精確定位修改位置,即使檔案其他部分有變動,只要上下文行仍然匹配就能正確套用。預設的 context 為 3 行,這是 git diff 和 GNU diff 的標準設定。
Q: 如何處理超大檔案(數萬行以上)的 Diff 計算?
超大檔案的 Diff 計算需要分層策略。第一層是 行雜湊優化:將每一行的文字內容先計算雜湊值(如 FNV-1a),比較雜湊值而非完整字串,大幅減少字串比較的開銷。第二層是 分塊策略:先用 Rabin Fingerprint 或 rolling hash 將檔案切成固定大小的區塊(如每 100 行一塊),快速識別完全相同的區塊後跳過,只對有差異的區塊做精細 Myers Diff。第三層是 Patience Diff(Git 4.0+ 支援):先找出兩份檔案中唯一出現的行(如函數簽名),以這些唯一行作為錨點對齊,再對錨點之間的區段遞迴做 diff,避免大規模的無意義匹配。對於超過 100K 行的檔案,還可以考慮 xdelta3 等基於滑動視窗的增量編碼演算法。記憶體方面,Myers Diff 的線性空間優化版只需 O(N+D) 空間,搭配分治策略可以處理任意大小的檔案而不會 OOM。