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.

code
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:

  1. Start at diagonal k=0, position x=0
  2. 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
  3. 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

python
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:

  1. Find lines that appear exactly once in both files (unique lines)
  2. Find the Longest Increasing Subsequence (LIS) of these unique matching lines
  3. Use these unique matches as anchors
  4. Recursively diff the regions between anchors using Myers

Why It Matters

c
// 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

bash
# 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:

  1. Build a histogram of line occurrences in file A
  2. Find the lowest-occurrence matching lines as anchors
  3. Recursively diff regions between anchors

This handles the case where truly unique lines don't exist but low-frequency lines still provide good anchors.

bash
# 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:

diff
--- 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

python
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

code
     Base (ancestor)
      /         \
   Ours          Theirs
(our changes)  (their changes)
      \         /
       Merged

Merge Logic

For each region in the file:

  1. Neither changed → keep as-is
  2. Only ours changed → take ours
  3. Only theirs changed → take theirs
  4. Both changed identically → take either (same result)
  5. Both changed differentlyCONFLICT

Conflict Markers

code
<<<<<<< HEAD (ours)
    return validate_strict(token)
=======
    return validate_lenient(token)
>>>>>>> feature-branch (theirs)

Implementation Sketch

python
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:

python
# 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:

json
// 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:

python
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:

python
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:

python
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):

python
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.