~/dynamic-programming/edit-distance
Edit distance
The fewest single-letter inserts, deletes and replacements that turn one word into another, from a table over prefix pairs.
dp[i][j] is the fewest edits that turn a[:i] into b[:j]. Matching letters copy the diagonal; otherwise take 1 + the cheapest of above, left and diagonal.
Two strings, and you want how different they are, the best alignment, or the edits between them.
O(n·m)
O(n·m), or O(min(n, m)) with two rows
You’ll recognise it when
- There are two strings and you want to know how far apart they are: typos, DNA reads, two versions of a line.
- The allowed moves are single-character edits: insert one, delete one, replace one.
- You’re asked for the fewest edits, or for the edits themselves (a diff, an alignment).
- A greedy left-to-right comparison goes wrong as soon as one insertion shifts every later letter.
Its sibling is the longest common subsequence (LCS): same table, same three neighbours, but it counts letters kept instead of edits paid for. See Variations.
The idea
You typed star and meant cart. You could fix it letter by letter from the left, but that goes wrong fast: s against c, t against a, a against r, r against t, four replacements. The cheaper fix is to see that ar is already in both. Delete the s, turn t into c, keep ar, and add a t at the end: three edits.
Finding that by hand means lining the words up cleverly. The DP instead asks a smaller question for every pair of prefixes: how many edits turn the first i letters of a into the first j letters of b? Look only at the last letter of each prefix. If they’re the same, keep it for free, and the answer is whatever the shorter prefixes cost. If they differ, the last edit was one of three things: delete a’s last letter, insert b‘s last letter, or replace one with the other. Each leaves a smaller pair of prefixes you’ve already solved, so pay 1 and take the cheapest.
How it works
Put word a down the side and word b across the top, with an extra row and column for the empty prefix, the same kind of 2-D table as in knapsack. dp[i][j] is the fewest edits that turn a[:i] into b[:j], and the answer is the bottom-right corner. The graphic fills the table for star → cart as you scroll.
- The edges are free to fill. Turning the empty string into
b[:j]takesjinserts, and turninga[:i]into nothing takesideletes. - Letters differ: three candidates. Moving down from the cell above means deleting
a[i-1]. Moving right from the cell to the left means insertingb[j-1]. Moving along the diagonal means replacing one letter with the other. - Pay 1 plus the cheapest. For
sagainstc, the diagonal holds 0, so replacing wins anddp[1][1] = 1. - Ties are fine. Two or three moves often cost the same; any of them gives the same number. The graphic remembers one arrow (diagonal first) for the walk back.
- Letters match.
a[1]andb[3]are botht, so no edit is needed for them. - Copy the diagonal. Whatever turned
a[:1]intob[:3]also turnsa[:2]intob[:4]: just keep thet.dp[2][4] = dp[1][3]. - Deleting wins. At
dp[4][2], turningstaintocaalready costs 2 (the cell above), so deleting thercosts 3, cheaper than replacing or inserting. - Inserting wins. At the corner,
star→carcosts 2 (the cell to the left), and inserting the finaltmakes 3. - Read the answer in the corner:
dp[4][4] = 3. - Walk back along the arrows. A left step is an insert, an up step a delete, a diagonal step a replace or, when the letters match, a keep.
- Read the script top to bottom: delete
s, replacetwithc, keepaandr, insertt.
Why it’s correct: any way of editing a[:i] into b[:j] ends by dealing with the last letters, and there are only four ways to do that: keep them (if equal), delete a[i-1], insert b[j-1], or replace. Each one leaves a smaller pair of prefixes, and the best way to finish that smaller job is exactly the cell above, left or diagonal. Those are filled before dp[i][j] because the table goes row by row, left to right. When the letters match, keeping them is never worse than paying for an edit, which is why the match case skips the min.
def edit_distance(a, b):
"""Fewest single-letter inserts, deletes and replacements that turn a into b."""
n, m = len(a), len(b)
# dp[i][j] = fewest edits that turn a[:i] into b[:j]
dp = [[0] * (m + 1) for _ in range(n + 1)]
for i in range(n + 1):
dp[i][0] = i # delete all i letters
for j in range(m + 1):
dp[0][j] = j # insert all j letters
for i in range(1, n + 1):
for j in range(1, m + 1):
if a[i - 1] == b[j - 1]:
dp[i][j] = dp[i - 1][j - 1] # keep it, free
else:
delete = dp[i - 1][j] # drop a[i-1]
insert = dp[i][j - 1] # add b[j-1]
replace = dp[i - 1][j - 1] # swap the two
dp[i][j] = 1 + min(delete, insert, replace)
return dp[n][m]#include <algorithm>
#include <string>
#include <vector>
using namespace std;
// Fewest single-letter inserts, deletes and replacements that turn a into b.
int editDistance(const string& a, const string& b) {
int n = a.size(), m = b.size();
// dp[i][j] = fewest edits that turn a[:i] into b[:j]
vector<vector<int>> dp(n + 1, vector<int>(m + 1, 0));
for (int i = 0; i <= n; i++) dp[i][0] = i; // delete all i letters
for (int j = 0; j <= m; j++) dp[0][j] = j; // insert all j letters
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
if (a[i - 1] == b[j - 1]) {
dp[i][j] = dp[i - 1][j - 1]; // keep it, free
} else {
int del = dp[i - 1][j]; // drop a[i-1]
int ins = dp[i][j - 1]; // add b[j-1]
int rep = dp[i - 1][j - 1]; // swap the two
dp[i][j] = 1 + min({del, ins, rep});
}
}
}
return dp[n][m];
}class EditDistance {
// Fewest single-letter inserts, deletes and replacements that turn a into b.
static int solve(String a, String b) {
int n = a.length(), m = b.length();
// dp[i][j] = fewest edits that turn a[:i] into b[:j]
int[][] dp = new int[n + 1][m + 1];
for (int i = 0; i <= n; i++) dp[i][0] = i; // delete all i letters
for (int j = 0; j <= m; j++) dp[0][j] = j; // insert all j letters
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
if (a.charAt(i - 1) == b.charAt(j - 1)) {
dp[i][j] = dp[i - 1][j - 1]; // keep it, free
} else {
int delete = dp[i - 1][j]; // drop a[i-1]
int insert = dp[i][j - 1]; // add b[j-1]
int replace = dp[i - 1][j - 1]; // swap the two
dp[i][j] = 1 + Math.min(delete, Math.min(insert, replace));
}
}
}
return dp[n][m];
}
}Row i only reads row i - 1 and the cell just to its left, so two rows are enough: O(min(n, m)) memory if you put the shorter word across the top. Like the one-row knapsack, you give up the walk back, so keep the full table when you need the edits themselves.
prev = list(range(len(b) + 1))
for i in range(1, len(a) + 1):
cur = [i] + [0] * len(b)
for j in range(1, len(b) + 1):
if a[i - 1] == b[j - 1]:
cur[j] = prev[j - 1]
else:
cur[j] = 1 + min(prev[j], cur[j - 1], prev[j - 1])
prev = cur
# the distance is prev[-1]
Why it’s O(n·m)
The table has (n + 1) × (m + 1) cells, and each one looks at three neighbours, so time and memory are both O(n·m). The walk back takes at most n + m steps. With two rows the memory drops to O(min(n, m)); the time stays the same.
| n | m | cells |
|---|---|---|
| 7 | 7 | 64 |
| 1,000 | 1,000 | 10⁶ |
| 10⁵ | 10⁵ | 10¹⁰: too many for a plain table |
For two long strings that are almost the same, faster methods exist that only explore the cells near the diagonal, but the plain table is what interviews and most contest problems expect.
Common mistakes
Forgetting the base row and column
dp[i][0] must be i and dp[0][j] must be j. Left at 0, they claim that turning abc into nothing is free, and every cell built on them comes out too small.
dp = [[0] * (m + 1) for _ in range(n + 1)] # ✗ edges stay 0
for i in range(n + 1): dp[i][0] = i # ✓ i deletes
for j in range(m + 1): dp[0][j] = j # ✓ j inserts
Off by one between the table and the strings
The table has an extra row and column for the empty prefix, so cell dp[i][j] is about the letters a[i-1] and b[j-1]. Comparing a[i] with b[j] reads the wrong letters and runs off the end on the last row.
if a[i] == b[j]: # ✗ one letter too far
if a[i - 1] == b[j - 1]: # ✓ the last letters of a[:i] and b[:j]
Adding 1 on a match
A matching letter costs nothing. Writing 1 + dp[i-1][j-1] for matches counts every shared letter as an edit, so two identical words get a distance equal to their length.
dp[i][j] = 1 + dp[i - 1][j - 1] # ✗ pays for a letter you keep
dp[i][j] = dp[i - 1][j - 1] # ✓ keep it for free
Using an LCS formula instead
len(a) + len(b) - 2 · lcs(a, b) is the distance only when replace isn’t allowed. With replace, abc → xbc is 1 edit, but the formula says 2: delete a, insert x.
Variations
- Longest common subsequence. Same table, but count letters kept: on a match
dp[i][j] = 1 + dp[i-1][j-1], otherwisemax(dp[i-1][j], dp[i][j-1]). The edges are 0. It’s the idea behinddiff: lines outside the common subsequence show up as deleted or added. - Only insert and delete. Without replace, the distance is
len(a) + len(b) - 2 · LCS: keep the common subsequence, delete the rest ofa, insert the rest ofb. Delete Operation for Two Strings is exactly this. - One edit away. To check whether two strings are at most one edit apart, you don’t need the table. Walk both with two pointers; at the first mismatch, skip one letter in the longer string (or one in each if they’re the same length) and require the rest to match. O(n) time, O(1) memory.
- Weighted costs. Give each operation its own price, like a cheap replace between keys that sit next to each other on a keyboard, or a per-letter cost as in Minimum ASCII Delete Sum. Replace the three
1s with the costs; the table is unchanged. - Spell checkers and fuzzy search. Suggest the dictionary words within distance 1 or 2 of a typo. To avoid filling a full table per word, stop a row early once every value in it exceeds the limit, or walk a trie of the dictionary and share rows between words with the same prefix.
Check yourself
5 quick questions. Pick an answer to see why it's right or wrong.
-
1
A search box should suggest dictionary words that are close to what the user typed, where “close” means few single-letter insertions, deletions or substitutions. What do you compute for each candidate word?
That’s exactly the Levenshtein distance, and the prefix table computes it in O(n·m). Counting mismatched positions looks tempting but breaks as soon as one missing letter shifts everything after it:
helovshellodiffers in two positions yet is one insert away. -
2
What does this print?
def edit(a, b):prev = list(range(len(b) + 1))for i in range(1, len(a) + 1):cur = [i] + [0] * len(b)for j in range(1, len(b) + 1):if a[i - 1] == b[j - 1]:cur[j] = prev[j - 1]else:cur[j] = 1 + min(prev[j], cur[j - 1], prev[j - 1])prev = curreturn prev[-1]print(edit("horse", "ros"), edit("", "abc"), edit("flaw", "lawn"))horse→rorse(replace) →rose(delete r) →ros(delete e) is 3. The empty word needs 3 inserts: the first rowprevstarts as[0, 1, 2, 3].flaw→law→lawnis a delete plus an insert, 2. It isn’t 1: no single edit fixes both ends. -
3
This version prints
0 1, butedit("abc", "")should be 3. What’s the bug?def edit(a, b):n, m = len(a), len(b)dp = [[0] * (m + 1) for _ in range(n + 1)]for i in range(1, n + 1):for j in range(1, m + 1):if a[i - 1] == b[j - 1]:dp[i][j] = dp[i - 1][j - 1]else:dp[i][j] = 1 + min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1])return dp[n][m]print(edit("abc", ""), edit("abc", "abcd"))Column 0 means turning
a[:i]into the empty string, which takesideletes, and row 0 takesjinserts. Left at 0, they claim those edits are free, soedit("abc", "")reads 0 straight fromdp[3][0]. Starting the loops at 0 would indexa[-1]and still not set the edges correctly. -
4
In
dp[i][j] = 1 + min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]), turninga[:i]intob[:j], which term stands for an insert?Stepping from the left neighbour adds one letter of
bwithout using up a letter ofa: an insert. The cell above uses upa[i-1]without producing anything, so it’s the delete, and the diagonal is the replace. Row 0 is all inserts, but inserts happen everywhere in the table. -
5
Someone computes the distance as
len(a) + len(b) - 2 * lcs(a, b). What does this print, and what’s the true edit distance?def lcs(a, b):dp = [[0] * (len(b) + 1) for _ in range(len(a) + 1)]for i in range(1, len(a) + 1):for j in range(1, len(b) + 1):if a[i - 1] == b[j - 1]:dp[i][j] = dp[i - 1][j - 1] + 1else:dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])return dp[-1][-1]a, b = "kitten", "sitting"print(len(a) + len(b) - 2 * lcs(a, b))The LCS of
kittenandsittingisittn, length 4, so the formula gives 6 + 7 − 8 = 5. That counts only inserts and deletes: each replaced letter costs a delete plus an insert. With replace allowed,k→s,e→iand insertinggmake 3.
Practice problems
- easy Is Subsequence leetcode.com
- medium Longest Common Subsequence leetcode.com
- medium Edit Distance leetcode.com
- medium Edit Distance (CSES) cses.fi
- medium Delete Operation for Two Strings leetcode.com
- medium Minimum ASCII Delete Sum for Two Strings leetcode.com
- hard Distinct Subsequences leetcode.com