The Edit Graph Model
All diff algorithms operate on the same foundational model: an edit graph where finding the shortest path corresponds to finding the minimum edit script.
Given sequences A (length M) and B (length N), construct an (M+1) × (N+1) grid:
- Horizontal edge (x, y) → (x+1, y): delete A[x]
- Vertical edge (x, y) → (x, y+1): insert B[y]
- Diagonal edge (x, y) → (x+1, y+1): match A[x] = B[y] (free — no edit cost)
The shortest edit script = the path from (0,0) to (M,N) with the fewest horizontal + vertical moves.
Example: A = "ABCABBA", B = "CBABAC"
C B A B A C
0──1──2──3──4──5──6
| |╲ | | | | |
A 1──.──.──╲──.──╲──.
| | |╲ | | | |
B 2──.──╲──.──╲──.──.
|╲ | | | | |╲ |
C 3──╲──.──.──.──.──╲
| | |╲ | |╲ | |
A 4──.──.──╲──.──╲──.
| |╲ | |╲ | | |
B 5──.──╲──.──╲──.──.
| |╲ | |╲ | | |
B 6──.──╲──.──╲──.──.
| | |╲ | |╲ | |
A 7──.──.──╲──.──╲──.
Diagonal = free match
Horizontal = insert from B
Vertical = delete from A
Myers Diff: The O(ND) Algorithm
Eugene Myers' 1986 paper "An O(ND) Difference Algorithm and Its Variations" is the foundation of most diff tools. Its key insight: if the edit distance D is small relative to the input size (which is typical for comparing similar files), the algorithm runs in O(ND) time — nearly linear for files with few differences.
The Greedy Strategy
Myers extends diagonals as far as possible (free matches), then advances the edit frontier by one edit operation at a time:
- Start at diagonal k=0, position x=0
- For each edit distance d = 0, 1, 2, ...:
- For each diagonal k = -d, -d+2, ..., d:
- Choose the better predecessor (from k-1 or k+1)
- Extend the diagonal as far as matches allow
- For each diagonal k = -d, -d+2, ..., d:
- Stop when (M, N) is reached
The critical data structure is a vector V indexed by diagonal k, storing the furthest x-coordinate reached on that diagonal.
Implementation
def myers_diff(a: list, b: list) -> list:
n, m = len(a), len(b)
max_d = n + m
v = {1: 0}
trace = []
for d in range(max_d + 1):
trace.append(dict(v))
for k in range(-d, d + 1, 2):
if k == -d or (k != d and v.get(k - 1, 0) < v.get(k + 1, 0)):
x = v.get(k + 1, 0)
else:
x = v.get(k - 1, 0) + 1
y = x - k
while x < n and y < m and a[x] == b[y]:
x += 1
y += 1
v[k] = x
if x >= n and y >= m:
return _backtrack(trace, a, b, n, m, d)
return []
def _backtrack(trace, a, b, n, m, d_final):
edits = []
x, y = n, m
for d in range(d_final, 0, -1):
v = trace[d]
k = x - y
if k == -d or (k != d and v.get(k - 1, 0) < v.get(k + 1, 0)):
prev_k = k + 1
else:
prev_k = k - 1
prev_x = v.get(prev_k, 0)
prev_y = prev_x - prev_k
while x > prev_x and y > prev_y:
edits.append(('equal', a[x - 1]))
x -= 1
y -= 1
if x == prev_x:
edits.append(('insert', b[y - 1]))
y -= 1
else:
edits.append(('delete', a[x - 1]))
x -= 1
while x > 0 and y > 0:
edits.append(('equal', a[x - 1]))
x -= 1
y -= 1
edits.reverse()
return edits
Complexity Analysis
| Scenario | Time | Space |
|---|---|---|
| Best case (identical) | O(N) | O(N) |
| Typical (few changes) | O(N + D²) | O(D²) or O(N) with linear refinement |
| Worst case (completely different) | O(N × M) | O(N × M) |
For version control (comparing similar revisions), D is typically small, making Myers nearly linear in practice.
Patience Diff: Better Matching for Repeated Lines
Myers diff can produce confusing results when many lines are identical (like closing braces } in code). Patience diff, introduced by Bram Cohen, uses a different matching strategy:
- Find lines that appear exactly once in both files (unique lines)
- Find the Longest Increasing Subsequence (LIS) of these unique matching lines
- Use these unique matches as anchors
- Recursively diff the regions between anchors using Myers
Why It Matters
// File A
void func_a() {
int x = 1;
}
void func_b() {
int x = 2;
}
// File B (func_a deleted)
void func_b() {
int x = 2;
}
Myers might match the } from func_a with the } from func_b, producing a confusing diff that shows modifications to func_a rather than its deletion. Patience diff anchors on the unique function signatures, producing a cleaner "delete func_a entirely" result.
When to Use
# Git with patience diff
git diff --patience
git config diff.algorithm patience
Histogram Diff: Git's Default Since 2012
Histogram diff (by Bram Cohen and later refined for JGit) extends patience diff by handling non-unique lines through occurrence counting:
- Build a histogram of line occurrences in file A
- Find the lowest-occurrence matching lines as anchors
- Recursively diff regions between anchors
This handles the case where truly unique lines don't exist but low-frequency lines still provide good anchors.
# Git's default algorithm since 2012
git diff --histogram
git config diff.algorithm histogram
Algorithm Comparison
| Algorithm | Anchor strategy | Strength | Weakness |
|---|---|---|---|
| Myers | Greedy diagonal extension | Minimal edit script guaranteed | Confusing diffs for repeated lines |
| Patience | Unique lines only | Clean diffs for code | Fails when no unique lines exist |
| Histogram | Lowest-frequency lines | Best general-purpose for code | Slightly more complex |
| LCS (classic) | DP on full matrix | Simple to implement | O(NM) space and time |
The Unified Diff Format
The output format matters as much as the algorithm. The unified diff format (used by diff -u, git diff, and patch files) has precise anatomy:
--- a/src/auth.py
+++ b/src/auth.py
@@ -15,7 +15,9 @@ class AuthHandler:
def validate_token(self, token: str) -> bool:
"""Validate JWT token."""
- decoded = jwt.decode(token, self.secret)
- return decoded is not None
+ try:
+ decoded = jwt.decode(token, self.secret, algorithms=["HS256"])
+ return not self._is_expired(decoded)
+ except jwt.InvalidTokenError:
+ return False
def refresh_token(self, token: str) -> str:
Format anatomy
| Component | Meaning |
|---|---|
--- a/path |
Original file path |
+++ b/path |
Modified file path |
@@ -15,7 +15,9 @@ |
Hunk header: original starts at line 15 (7 lines), new starts at line 15 (9 lines) |
(space prefix) |
Context line (unchanged) |
- prefix |
Deleted line |
+ prefix |
Added line |
@@ ... @@ function_name |
Optional: nearest function/class name for context |
Generating Unified Diff Programmatically
import difflib
def unified_diff(text_a: str, text_b: str, filename: str = "file") -> str:
lines_a = text_a.splitlines(keepends=True)
lines_b = text_b.splitlines(keepends=True)
diff = difflib.unified_diff(
lines_a, lines_b,
fromfile=f"a/{filename}",
tofile=f"b/{filename}",
lineterm=""
)
return "".join(diff)
Three-Way Merge
Two-way diff shows what changed. Three-way merge determines how to combine changes from two divergent edits against a common ancestor — the foundation of git merge.
The Model
Base (ancestor)
/ \
Ours Theirs
(our changes) (their changes)
\ /
Merged
Merge Logic
For each region in the file:
- Neither changed → keep as-is
- Only ours changed → take ours
- Only theirs changed → take theirs
- Both changed identically → take either (same result)
- Both changed differently → CONFLICT
Conflict Markers
<<<<<<< HEAD (ours)
return validate_strict(token)
=======
return validate_lenient(token)
>>>>>>> feature-branch (theirs)
Implementation Sketch
def three_way_merge(base: list, ours: list, theirs: list):
diff_ours = myers_diff(base, ours)
diff_theirs = myers_diff(base, theirs)
result = []
conflicts = []
# Walk both diffs simultaneously, detecting overlapping changes
# Regions where both modify the same base lines → conflict
# Non-overlapping changes → auto-merge
...
return result, conflicts
Semantic Diff: Beyond Line Comparison
Text diff treats files as sequences of lines. Semantic diff understands the structure of the content:
AST-Based Diff
For source code, comparing Abstract Syntax Trees instead of text produces meaningful diffs even when formatting changes:
# Text diff sees 3 changed lines:
- x = foo(a, b, c)
+ x = foo(
+ a,
+ b,
+ c
+ )
# AST diff sees: no change (same function call, same arguments)
Tools:
- GumTree — language-agnostic AST diff (Java, Python, JavaScript, C)
- difftastic — structural diff using tree-sitter parsers
- semantic (GitHub) — multi-language semantic diff
JSON/YAML Structural Diff
For configuration files, structural diff understands that key order doesn't matter:
// Text diff: 2 lines changed
- {"name": "Alice", "age": 30}
+ {"age": 30, "name": "Alice"}
// Structural diff: no change (same object)
Performance for Large Files
Line Hashing
For line-level diff, comparing entire strings is expensive. Hash each line first, then diff the hash sequences:
import hashlib
def diff_large_files(path_a: str, path_b: str):
def hash_lines(path):
with open(path) as f:
return [hashlib.md5(line.encode()).digest() for line in f]
hashes_a = hash_lines(path_a)
hashes_b = hash_lines(path_b)
# Diff the hash sequences (integer comparison, not string)
edits = myers_diff(hashes_a, hashes_b)
return edits
Preprocessing: Common Prefix/Suffix Trimming
Before running the O(ND) algorithm, strip matching prefix and suffix — they contribute nothing to the diff:
def trim_common(a: list, b: list):
prefix = 0
while prefix < len(a) and prefix < len(b) and a[prefix] == b[prefix]:
prefix += 1
suffix = 0
while (suffix < len(a) - prefix and suffix < len(b) - prefix
and a[-(suffix + 1)] == b[-(suffix + 1)]):
suffix += 1
core_a = a[prefix:len(a) - suffix] if suffix else a[prefix:]
core_b = b[prefix:len(b) - suffix] if suffix else b[prefix:]
return prefix, suffix, core_a, core_b
This alone can reduce a 10,000-line file comparison to diffing only the 50 modified lines in the middle.
Algorithm Selection by Input Size
| Input size | Recommended approach |
|---|---|
| < 1,000 lines | Myers (simple, optimal) |
| 1,000–100,000 lines | Histogram diff with prefix/suffix trimming |
| > 100,000 lines | Line hashing + trimming + histogram |
| Binary/structured | Specialized (AST diff, rsync rolling hash) |
The LCS and Edit Distance Foundation
Longest Common Subsequence (LCS)
LCS is the dual of the shortest edit script: edit_distance = len(A) + len(B) - 2 * LCS_length.
The classic O(NM) dynamic programming:
def lcs_length(a: list, b: list) -> int:
m, n = len(a), len(b)
# Space-optimized: only two rows
prev = [0] * (n + 1)
curr = [0] * (n + 1)
for i in range(1, m + 1):
for j in range(1, n + 1):
if a[i - 1] == b[j - 1]:
curr[j] = prev[j - 1] + 1
else:
curr[j] = max(prev[j], curr[j - 1])
prev, curr = curr, [0] * (n + 1)
return prev[n]
Levenshtein Edit Distance
Levenshtein distance adds substitution as a primitive operation (cost 1), while LCS-based diff only uses insert + delete (substitution = delete + insert, cost 2):
def levenshtein(a: str, b: str) -> int:
m, n = len(a), len(b)
prev = list(range(n + 1))
for i in range(1, m + 1):
curr = [i] + [0] * n
for j in range(1, n + 1):
if a[i - 1] == b[j - 1]:
curr[j] = prev[j - 1]
else:
curr[j] = 1 + min(prev[j], curr[j - 1], prev[j - 1])
prev = curr
return prev[n]
| Metric | Operations | Use case |
|---|---|---|
| LCS distance | Insert, Delete | Diff/patch (no in-place edit concept) |
| Levenshtein | Insert, Delete, Replace | Spell checking, fuzzy matching |
| Damerau-Levenshtein | + Transposition | Typo detection (ab → ba) |
Summary
Text diff is a mature field with clear algorithm choices:
- Myers for minimum edit scripts with provably optimal results
- Patience for cleaner code diffs when unique lines exist
- Histogram (Git's default) for best general-purpose code comparison
- AST/semantic diff when formatting changes should be invisible
- Three-way merge for integrating concurrent edits against a common ancestor
The unified diff format is the universal interchange format. Line hashing and prefix/suffix trimming make large-file comparison practical. The choice between algorithms is a tradeoff between diff quality (human readability of the output) and computational cost.