Cairn
algorithms · dynamic programming · O(m·n)

back to Dynamic Programming

Edit Distance

The site's second dynamic programming entry, and a direct generalization of the first. Longest Common Subsequence asks how much of two strings already lines up if all you're allowed to do is skip characters. Edit distance (also called Levenshtein distance) asks a sharper question: what's the fewest single-character edits — insert, delete, or substitute — needed to turn one string into the other? "kitten" becomes "sitting" in exactly three edits: substitute ks, substitute ei, insert g at the end. No two-edit sequence manages it — that's the number this page's table computes and then proves, by reconstructing the actual edit script.

Try it

Two fixed strings, A = "kitten" and B = "sitting" — the standard textbook pair. Press Step or Run to watch the table fill in: each cell dp[i][j] holds the edit distance between the first i characters of A and the first j characters of B. Once the table is full, the demo backtracks from the bottom-right corner, recovering the actual sequence of edits — watch the highlighted path, then the alignment strip below once it's done.

Switch the mode dropdown to space-optimized to see the two-row trick from Pitfalls run for real: instead of a full (m+1)×(n+1) table, only a prev row and a curr row ever exist at once — with one extra wrinkle this page's recurrence needs that Longest Common Subsequence's didn't: curr[0] has to be set to i by hand before the inner loop starts, the rolling equivalent of this page's base column. The stats line tracks exactly how many cells that saves. The trade is real too — this mode reports the edit distance only, never the edit script itself, because once a row is overwritten there's nothing left to backtrack through.

Switch it to top-down (memoized recursion) to see the other fix Pitfalls names: the same recurrence, called as ordinary recursion starting from dp[m][n], with each answer cached in a map keyed by (i, j) the first time it's computed. Unfilled cells show as · until the recursion actually reaches them — this mode still reconstructs the full edit script (the memo is a complete substitute for the table), but computes strictly fewer cells than the version above, though — as Pitfalls checks honestly — not nearly as few as Longest Common Subsequence's own top-down mode saves on a table the same size.

step 0
Press Step or Run.

Why it works

The base cases come first: turning i characters into nothing takes i deletes, so dp[i][0] = i; turning nothing into j characters takes j inserts, so dp[0][j] = j. That's the table's top row and left column, filled before any real comparison happens — it's why the table here needs a base row and column of actual counts, not the all-zero border Longest Common Subsequence's table had (there, an empty prefix trivially shares nothing with anything; here, an empty prefix still costs something to reach).

From there, each cell has two cases. If the two current characters already match — A[i-1] === B[j-1] — no edit is needed for this position at all: dp[i][j] = dp[i-1][j-1], exactly what it cost to align everything before this matching pair. If they don't match, one of three edits has to happen, and the recurrence tries all three and keeps the cheapest: substitute A[i-1] for B[j-1] (dp[i-1][j-1] + 1), delete A[i-1] (dp[i-1][j] + 1), or insert B[j-1] (dp[i][j-1] + 1). Each option reduces the problem to one already-solved smaller one plus one guaranteed edit, so dp[i][j] = 1 + min(dp[i-1][j-1], dp[i-1][j], dp[i][j-1]) — the same optimal-substructure and overlapping-subproblems argument Longest Common Subsequence's Why it works section makes, just with three predecessor cells to compare instead of two.

Reference implementation

Builds the table bottom-up, then walks it backward from the last cell to recover the actual edit script — not just the count:

function editDistance(a, b) {
  const m = a.length, n = b.length;
  const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));

  for (let i = 0; i <= m; i++) dp[i][0] = i; // delete all i characters
  for (let j = 0; j <= n; j++) dp[0][j] = j; // insert all j characters

  for (let i = 1; i <= m; i++) {
    for (let j = 1; j <= n; 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 - 1], dp[i - 1][j], dp[i][j - 1]);
      }
    }
  }

  // backtrack from the bottom-right corner to recover the edits, not just the count
  let i = m, j = n;
  const ops = [];
  while (i > 0 || j > 0) {
    if (i > 0 && j > 0 && a[i - 1] === b[j - 1] && dp[i][j] === dp[i - 1][j - 1]) {
      ops.push({ op: 'match', ch: a[i - 1] });
      i--; j--;
    } else if (i > 0 && j > 0 && dp[i][j] === dp[i - 1][j - 1] + 1) {
      ops.push({ op: 'sub', from: a[i - 1], to: b[j - 1] });
      i--; j--;
    } else if (i > 0 && dp[i][j] === dp[i - 1][j] + 1) {
      ops.push({ op: 'del', ch: a[i - 1] });
      i--;
    } else {
      ops.push({ op: 'ins', ch: b[j - 1] });
      j--;
    }
  }
  ops.reverse();

  return { distance: dp[m][n], ops };
}

Pitfalls

Longest Common Subsequence is edit distance with only two of the three edits allowed, each still costing one. Restrict this page's recurrence to insert and delete only — no substitute — and minimizing edits to transform A into B becomes exactly "maximize the shared subsequence, delete everything else from A, insert everything else from B." That's not a coincidence worth glossing over: it's the same table, the same backtracking shape, and the same O(m·n) cost, just with a narrower edit set and a different objective (maximize matches vs. minimize edits) that happen to be two views of the same underlying subproblem structure.

Ties in the three-way min are broken by an arbitrary priority, not a rule the problem forces. This page's backtrack step always checks substitute before delete before insert when more than one option is equally cheap — the same kind of arbitrary tie-break Longest Common Subsequence's Pitfalls section already flagged for its own two-way tie. The distance is never ambiguous, but a version that checked delete or insert first would recover a different (equally short) edit script for the same pair of strings.

This table costs real memory, and — same as Longest Common Subsequence — there's a cheaper option if only the distance is needed. Computing dp[i][j] only ever reads row i-1 and the current row i, so the full O(m·n) table can be trimmed to two rows, dropping space to O(min(m, n)). The catch is identical too: recovering the actual edit script, the way this page's demo does, needs the full table to walk back through. Try it live above by switching to space-optimized mode: on this page's own 6×7 example, that's 16 cells kept at any moment instead of 56 for the full table, the same saving Longest Common Subsequence's own space-optimized mode demonstrates on the identical-sized table.

Top-down memoization saves less here than it does for Longest Common Subsequence — and the reason is the extra edit this page's recurrence allows. LCS's mismatch case only ever recurses into two neighbors (up, left); this page's mismatch case recurses into three (substitute's diagonal, delete's up, insert's left), so a lot more of the table ends up genuinely needed regardless of where the recursion starts. On this page's own "kitten"/"sitting" example, top-down computes 50 of the 56 possible cells (48 of those are cache hits) — nowhere near Longest Common Subsequence's own 32-of-56 saving on a table the same size, because most cells here are mismatches that still need all three neighbors. The saving does show up where it's structurally possible: two identical 6-character strings (checked separately, not shown in the demo above) recurse along a single diagonal of matches and touch just 7 of 49 cells, since a match short-circuits straight past the other two neighbors a mismatch would need — the same short-circuit LCS's own Pitfalls names, just triggered less often here because a plain mismatch, not only a non-match and a non-diagonal move, is what forces the extra branches.

Complexity

Time: O(m·n) — one constant-time three-way comparison per cell, (m+1)(n+1) cells total. Space: O(m·n) for the full table (needed to reconstruct the edit script), or O(min(m, n)) if only the distance is needed — see Pitfalls above.

Edit distance shows up anywhere "how similar are these two strings" needs a real number instead of a guess: spell checkers ranking correction candidates, DNA sequence alignment, fuzzy string matching, and diff-style tools that assign a substitution cost instead of only recognizing exact matches (plain diff, as Longest Common Subsequence's own Complexity section notes, uses just insert and delete). For a dynamic-programming table shaped by something other than two strings, see the 0/1 knapsack problem — items and a capacity instead of string prefixes, same underlying idea. This same recurrence also has a bit-parallel form: see Bitap with Edit Distance (Wu–Manber), which packs one row of this exact table into a machine word (changing one base case along the way, to search a pattern inside a longer text instead of comparing two whole strings) and updates it with a handful of shifts instead of walking the grid cell by cell. For a version that keeps the table shape but restricts which cells get filled, see Banded Edit Distance (Ukkonen's Algorithm), which skips any cell provably too far off the main diagonal to matter once only answers within a fixed budget k are of interest. For a version that keeps this exact table shape but adds a fourth edit operation, see Damerau–Levenshtein Distance, which lets swapping two adjacent characters count as a single edit instead of two substitutions.

This site's guide, Choosing a Dynamic Programming Approach, compares this entry against the other ten Dynamic Programming entries side by side.