Convert one string into another using insert, delete, and replace — minimize the number of edits (Levenshtein distance). The archetypal 2D dynamic program: a table where every cell leans on three neighbors, and the answer crystallizes in the bottom-right corner.
Given word1 = "horse" and word2 = "ros", return the minimum number of single-character edits to turn the first into the second. Three operations, each cost 1:
For "horse" → "ros" the answer is 3: replace h→r, delete r, delete e. This metric — Levenshtein distance — powers spell-check, DNA alignment, and diff tools.
The entire DP hinges on one clean definition:
The full answer is dp[m][n]. To build it we ask: what's the last operation that aligns word1[i-1] with word2[j-1]? It must be exactly one of four cases — and each points to a smaller, already-solved subproblem.
dp[i-1][j-1] — both strings shrink by one (match or replace).dp[i-1][j] — word1 shrinks only ⇒ we deletedword1's char.dp[i][j-1] — word2 shrinks only ⇒ we insertedword2's char.dp[i][0] = i (delete all i chars to reach the empty string). dp[0][j] = j (insert all j chars from empty).dp[m][n].(m,n) following which neighbor produced each value — that recovers the actual operation sequence.Each row depends only on the current and previous row, so you can keep just two rows (or one row + a saved diagonal) and roll them — dropping space to O(min(m,n)). The tradeoff: you lose the full table needed to backtrace the edit sequence. Keep the whole table when the interviewer asks for the operations.
dp[i][j]= min edits to convert the first i chars of "horse" into the first j chars of "ros".1▶function minDistance(word1: string, word2: string): number {2▶ const m = word1.length, n = word2.length;3 // dp[i][j] = min edits to turn word1[0..i) into word2[0..j)4▶ const dp: number[][] = Array.from({ length: m + 1 }, () =>5▶ new Array(n + 1).fill(0));67 // base cases: convert to / from the empty string8 for (let i = 0; i <= m; i++) dp[i][0] = i;9 for (let j = 0; j <= n; j++) dp[0][j] = j;1011 for (let i = 1; i <= m; i++) {12 for (let j = 1; j <= n; j++) {13 if (word1[i - 1] === word2[j - 1]) {14 dp[i][j] = dp[i - 1][j - 1]; // match → free15 } else {16 dp[i][j] = 1 + Math.min(17 dp[i - 1][j - 1], // replace18 dp[i - 1][j], // delete from word119 dp[i][j - 1] // insert into word120 );21 }22 }23 }24 return dp[m][n];25}
function minDistance(word1: string, word2: string): number {
const m = word1.length, n = word2.length;
// dp[i][j] = min edits to turn word1[0..i) into word2[0..j)
const dp: number[][] = Array.from({ length: m + 1 }, () =>
new Array(n + 1).fill(0));
// base cases: convert to / from the empty string
for (let i = 0; i <= m; i++) dp[i][0] = i;
for (let j = 0; j <= n; j++) dp[0][j] = j;
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (word1[i - 1] === word2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1]; // match → free
} else {
dp[i][j] = 1 + Math.min(
dp[i - 1][j - 1], // replace
dp[i - 1][j], // delete from word1
dp[i][j - 1] // insert into word1
);
}
}
}
return dp[m][n];
}(m+1) × (n+1) matrix. The +1 reserves row 0 and column 0 for the empty-string base cases — the trick that makes the recurrence uniform with no special-casing inside the loop.i prefix into "" costs i deletions; building a length-j prefix from "" costs j insertions. These seed the first row and column.dp[m][n] is the cost of aligning the full strings.dp[i-1][*] and dp[i][j-1]are already final when you read them. Reverse the loops and you'd read garbage.(m,n) choosing the neighbor that produced each value — exactly what the Visualize tab animates.1 + with per-op weights inside the min. The structure is unchanged.m + n − 2·LCS. Same grid, different recurrence.function minDistance(word1: string, word2: string): number {
const m = word1.length, n = word2.length;
// dp[i][j] = min edits to turn word1[0..i) into word2[0..j)
const dp: number[][] = Array.from({ length: m + 1 }, () =>
new Array(n + 1).fill(0));
// base cases: convert to / from the empty string
for (let i = 0; i <= m; i++) dp[i][0] = i;
for (let j = 0; j <= n; j++) dp[0][j] = j;
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (word1[i - 1] === word2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1]; // match → free
} else {
dp[i][j] = 1 + Math.min(
dp[i - 1][j - 1], // replace
dp[i - 1][j], // delete from word1
dp[i][j - 1] // insert into word1
);
}
}
}
return dp[m][n];
}dp[i-1][*] and dp[i][j-1]are already final when you read them. Reverse the loops and you'd read garbage.Same recurrence, but only the previous and current rows are ever needed — roll them to collapse the table down to a single row plus a saved diagonal.
function minDistance(word1: string, word2: string): number {
// Iterate over the longer string in the outer loop so the
// rolling rows have length min(m, n) + 1 — minimal space.
if (word1.length < word2.length) {
[word1, word2] = [word2, word1];
}
const m = word1.length, n = word2.length;
// prev[j] = edits to turn word1[0..i-1) into word2[0..j)
let prev: number[] = Array.from({ length: n + 1 }, (_, j) => j);
for (let i = 1; i <= m; i++) {
const curr: number[] = new Array<number>(n + 1);
curr[0] = i; // delete i chars of word1 to reach the empty prefix
for (let j = 1; j <= n; j++) {
if (word1[i - 1] === word2[j - 1]) {
curr[j] = prev[j - 1]; // match → diagonal, free
} else {
curr[j] = 1 + Math.min(
prev[j - 1], // replace (diagonal)
prev[j], // delete (up)
curr[j - 1] // insert (left)
);
}
}
prev = curr; // roll: this row becomes the previous row
}
return prev[n];
}dp[i-1][j-1] lives at prev[j-1], the up neighbor at prev[j], and the left neighbor at the freshly written curr[j-1] — read order matters. This trades away the full table, so you can no longer backtrace the actual edit sequence.Recurse on the suffix lengths (i, j) and cache each result — the same recurrence read top-down, often the easiest version to derive on the spot.
function minDistance(word1: string, word2: string): number {
const m = word1.length, n = word2.length;
// memo[i][j] = edits to turn word1[i..) into word2[j..); -1 = unset
const memo: number[][] = Array.from({ length: m + 1 }, () =>
new Array<number>(n + 1).fill(-1));
function solve(i: number, j: number): number {
// one string exhausted → insert or delete the rest
if (i === m) return n - j;
if (j === n) return m - i;
if (memo[i][j] !== -1) return memo[i][j];
let result: number;
if (word1[i] === word2[j]) {
result = solve(i + 1, j + 1); // match → consume both, free
} else {
result = 1 + Math.min(
solve(i + 1, j + 1), // replace
solve(i + 1, j), // delete from word1
solve(i, j + 1) // insert into word1
);
}
memo[i][j] = result;
return result;
}
return solve(0, 0);
}(i, j) pairs get computed, but worst case fills the whole memo so the bound matches the table. Recursion depth is O(m + n) — for very long strings the bottom-up versions avoid any stack-overflow risk.The same recurrence, but with no cache: on a mismatch try all three of insert, delete, and replace and take the cheapest. A correctness baseline before adding memoisation.
function minDistance(word1: string, word2: string): number {
function solve(i: number, j: number): number {
// One string exhausted → insert or delete whatever's left.
if (i === word1.length) return word2.length - j;
if (j === word2.length) return word1.length - i;
if (word1[i] === word2[j]) {
return solve(i + 1, j + 1); // match → consume both, free
}
return 1 + Math.min(
solve(i + 1, j + 1), // replace
solve(i + 1, j), // delete from word1
solve(i, j + 1) // insert into word1
);
}
return solve(0, 0);
}O(3^(m+n)) and unusable beyond tiny strings. Every overlapping (i, j) pair is recomputed many times; caching them (the memoised version above) drops it to O(m·n).| convert string A to B with edits | 2D DP grid (m+1)×(n+1) |
| insert / delete / replace, minimize ops | Levenshtein recurrence |
| min cost over a pair of prefixes | dp[i][j] on prefix lengths |
| recover the actual operations | backtrace from (m,n) to (0,0) |
const m = word1.length, n = word2.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; // base: delete-to-empty
for (let j = 0; j <= n; j++) dp[0][j] = j; // base: insert-from-empty
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (word1[i - 1] === word2[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]);
}
}
return dp[m][n];dp[i][j] mean?dp[i][j] represent?word1[i-1] === word2[j-1], what is dp[i][j]?dp[i-1][j] — corresponds to which operation?word1="horse", word2="ros", the edit distance is: