Cairn
algorithms · approximate match · O((N+M)·D)

back to Approximate Match

Myers Diff Algorithm

Edit Distance answers "what's the fewest edits, allowing substitution?" by filling every cell of an (m+1)×(n+1) table. Real diff tools ask a narrower question, and answer it without substitution at all: only insert and delete are allowed, and the two sequences being compared (source lines, not characters, for git diff) are usually mostly the same — a handful of real edits buried in a lot of unchanged material. Myers' algorithm (1986) exploits that directly: instead of filling a table sized by the input, it searches outward by number of edits, D = 0, 1, 2, …, and stops the instant a D-edit path reaches the end — so the cost scales with how different the inputs actually are, not with their raw size.

Try it

The classic pair from Myers' own 1986 paper: A = "ABCABBA", B = "CBABAC". Picture an edit graph: a grid of points (x, y) for every prefix-length pair of A and B, with a free diagonal move wherever A[x] = B[y] (a "snake") and a one-edit-cost move right (delete A[x]) or down (insert B[y]) everywhere else. Press Step or Run — the demo tries D = 0, then D = 1, and so on, and for each D sweeps every relevant diagonal k = x − y, extending the furthest point already reached one step further and then sliding it along any matching run it lands on. The moment a point reaches (7, 6) — the bottom-right corner — that D is the answer, and the demo backtracks to recover the actual edit script.

Switch mode to buggy (skip the snake) to run the broken variant Pitfalls describes, live: the search still expands by D and still visits the same diagonals, but every free diagonal move through a matching run is skipped. Watch the grid — the diagonal runs through matching characters (shown as connected dots) never appear, and the search needs every one of D = 13 steps (one per character of both strings) instead of the true 5, because it never notices that eight of the thirteen positions already agree.

step 0
Press Step or Run.

Why it works

Every point in the edit graph lies on exactly one diagonal k = x − y, and a key fact makes the search cheap: a D-edit path can only ever reach diagonals k ∈ {−D, −D+2, …, D−2, D} (each edit moves one diagonal left or right, so D edits land on a diagonal whose parity matches D's), and on any one diagonal, only the furthest-reaching point any D-edit path can reach matters — a shorter reach on the same diagonal can never do anything a longer reach couldn't already do, since both face the same remaining grid. So the algorithm keeps exactly one number per diagonal, V[k] = the furthest x reached on diagonal k so far, and updates it as D grows: reaching diagonal k with D edits means arriving from diagonal k − 1 (one delete, moving right) or diagonal k + 1 (one insert, moving down) using only D − 1 edits — take whichever of those two predecessors reached further, since it can only help.

After that one edit move, the point slides diagonally through every immediately-following matching character for free — that's the "snake." Snakes are what make the algorithm cheap on realistic inputs: each one shortcuts past however many characters happen to already agree, at zero edit cost, so D only counts genuine differences. In the worst case (no two characters ever match) D = N + M and the algorithm degrades to checking every diagonal at every step — no better than the full table — but that worst case is rare for real diffs, which is exactly why diff and git diff use this algorithm instead of the plain O(N·M) table Longest Common Subsequence fills.

Reference implementation

Returns the shortest edit distance D and a trace of every intermediate V array, which the backtrack step below walks in reverse to recover the actual script:

function shortestEdit(a, b) {
  const N = a.length, M = b.length;
  const MAX = N + M;
  const offset = MAX; // V is indexed -MAX..MAX; offset shifts it into a plain array
  const V = new Array(2 * MAX + 1).fill(0);
  const trace = [];

  for (let D = 0; D <= MAX; D++) {
    trace.push(V.slice());
    for (let k = -D; k <= D; k += 2) {
      let x;
      if (k === -D || (k !== D && V[offset + k - 1] < V[offset + k + 1])) {
        x = V[offset + k + 1]; // came from k+1: an insert (down)
      } else {
        x = V[offset + k - 1] + 1; // came from k-1: a delete (right)
      }
      let y = x - k;
      while (x < N && y < M && a[x] === b[y]) { x++; y++; } // snake
      V[offset + k] = x;
      if (x >= N && y >= M) return { D, trace };
    }
  }
}

function backtrack(a, b, trace) {
  const N = a.length, M = b.length, offset = N + M;
  let x = N, y = M;
  const ops = [];
  for (let D = trace.length - 1; D >= 0; D--) {
    const V = trace[D];
    const k = x - y;
    const prevK = (k === -D || (k !== D && V[offset + k - 1] < V[offset + k + 1])) ? k + 1 : k - 1;
    const prevX = V[offset + prevK], prevY = prevX - prevK;
    while (x > prevX && y > prevY) { ops.push({ op: 'match', ax: --x, by: --y }); }
    if (D > 0) {
      if (x === prevX) ops.push({ op: 'ins', by: prevY });
      else ops.push({ op: 'del', ax: prevX });
    }
    x = prevX; y = prevY;
  }
  return ops.reverse();
}

Pitfalls

Skip the snake and the algorithm doesn't get the wrong answer — it gets the slowest possible right one. Checked against the shipped page's own buggy (skip the snake) mode on "ABCABBA"/"CBABAC": without the diagonal slide through matching runs, every point only ever advances by exactly one row or column per edit, so the search never discovers any of the eight positions where the two strings actually agree. It still terminates and still returns a technically-valid edit script — just the worst one, D = 13 (delete all seven characters of A, insert all six of B) instead of the true minimum, 5. Unlike Banded Edit Distance's sentinel bug, this failure isn't a wrong number masquerading as a right one — it's a correct-shaped answer that's needlessly, silently, worse.

The diagonal step has to be 2, not 1 — and getting it wrong produces an answer smaller than what's mathematically possible. A D-edit path can only land on diagonals whose parity matches D's (each edit shifts the diagonal by exactly one, so D shifts always land D steps, not D − 1 or D + 1, from diagonal 0). Loop k from −D to D in steps of 1 instead of 2, and half of every iteration reads V[k − 1] and V[k + 1] at slots that belong to the wrong diagonal's leftover value from an earlier, unrelated D — checked in a scratch variant on the same pair: it terminates at D = 3, not 5. That number isn't just wrong, it's impossible on its face — the identity D = N + M − 2·L (where L is the two strings' true longest common subsequence length, independently confirmed at 4 by the DP table) puts a hard floor of 5 under any genuine answer for this pair. A caller that only checks "did it return a number" instead of "is this number even reachable" would never notice.

V has to persist across the whole outer loop — reallocating it fresh for every D doesn't corrupt the answer, it erases the algorithm's only memory of progress. The entire point of carrying V forward is that diagonal k's furthest reach at D edits is built directly from where it (or its neighbor) already stood at D − 1; start every D from an all-zero array instead, and the search re-derives nothing from prior work, re-walking from the origin every round. Checked in a scratch variant on the same pair: it never terminates within the MAX = N + M bound at all — a different failure shape from either bug above, since it neither returns a plausible-looking wrong number nor a needlessly large right one, it just never returns.

Complexity

Time: O((N + M)·D), where D is the true shortest edit distance — the outer loop runs D + 1 times and each pass visits at most D + 1 diagonals, each in amortized constant time plus its snake (snakes across one run of the outer loop cover at most N + M total steps). When the inputs are mostly identical, D is small and this beats Edit Distance's O(m·n) table by a wide margin; in the worst case, D = N + M and the two become comparable. Space: O((N + M)·D) for the full trace needed to reconstruct the script (one V snapshot per D), or O(N + M) for the distance alone.

Unlike Banded Edit Distance, which needs a budget k chosen in advance and reports "impossible" if it guessed too small, this algorithm never needs to guess — it discovers the true D by construction, since it only stops once a path genuinely reaches the far corner. That's the shape every real line-oriented diff and git diff actually uses: no edit-cost budget is ever specified up front, because none is needed. For the DP-table view of the same insert/delete-only problem, and where the D = N + M − 2·L identity used above in Pitfalls comes from, see Longest Common Subsequence. A sixth entry, Jaro-Winkler Similarity, drops the edit-graph idea entirely — no diagonals, no minimum edit count, just a bounded matching window and a transposition tally turned into a similarity score, better suited to short strings like names than to the line-oriented text this algorithm targets. For a side-by-side comparison across all eleven approximate-match entries, see Choosing an Approximate String Matcher.