Topics › Dynamic Programming › DP on Strings
Edit distance
LeetCode ↗ Java source ↗ Edit this article ↗
Jump to code:
Problem
Given two strings s1 and s2, return the minimum number of operations needed to turn s1 into s2. Each operation acts on a single character of s1:
- Insert a character anywhere.
- Delete a character.
- Replace a character with another one.
This number is also called the Levenshtein distance. It is what spell checkers and diff-like tools use to measure how far apart two words are.
Constraints (LeetCode 72): 0 ≤ |s1|, |s2| ≤ 500, lowercase English letters.
Example 1
s1 = "horse", s2 = "ros"
answer = 3 // horse -> rorse (replace h with r) -> rose (delete r) -> ros (delete e)
Example 2
s1 = "intention", s2 = "execution"
answer = 5 // e.g. delete t, replace i->e, replace n->x, replace n->c, insert u
Thinking about the last characters
Write f(i, j) for the fewest operations that turn the prefix s1[0..i) into the prefix s2[0..j). Prefix lengths make the base cases easy to state:
f(0, j) = j: an empty string becomess2[0..j)only by inserting alljcharacters.f(i, 0) = i: a non-empty prefix becomes empty only by deleting allicharacters.
These base cases are not zero, which is the first thing that sets this problem apart from LCS.
For i, j > 0, picture the final alignment of the two strings: every character of s1 is either kept (possibly replaced) as some character of s2, or deleted, and every character of s2 not produced that way was inserted. Look at what happens to the last characters, s1[i - 1] and s2[j - 1]:
s1[i - 1]becomess2[j - 1]. If they are already equal this costs nothing, otherwise it is one replacement. Either way the rest of the problem isf(i - 1, j - 1).s1[i - 1]is deleted. One operation, and we still have to turns1[0..i - 1)into all ofs2[0..j):1 + f(i - 1, j).s2[j - 1]is inserted at the end. One operation, ands1[0..i)still has to becomes2[0..j - 1):1 + f(i, j - 1).
Every optimal edit script falls into one of these cases, so the minimum over them is the answer.
Why a match never needs the other two options. When s1[i - 1] == s2[j - 1], the code takes f(i - 1, j - 1) without looking at insert or delete. That is safe because adding or removing one character from either string changes the distance by at most one, so f(i - 1, j - 1) ≤ f(i - 1, j) + 1 and f(i - 1, j - 1) ≤ f(i, j - 1) + 1. The free diagonal move is never worse than paying for a delete or an insert.
So the recurrence is:
f(i, j) = f(i - 1, j - 1) if s1[i - 1] == s2[j - 1]
f(i, j) = 1 + min(f(i, j - 1), f(i - 1, j), f(i - 1, j - 1)) otherwise
insert delete replace
A useful way to remember the directions: insert shrinks s2 (we produced its last character), delete shrinks s1 (we removed its last character), replace shrinks both.
Approach 1: Recursion
Intuition. Code the recurrence directly. When the last characters disagree, try all three operations and keep the cheapest.
Algorithm.
- If
i == 0, returnj. Ifj == 0, returni. - If
s1[i - 1] == s2[j - 1], returnf(i - 1, j - 1). - Otherwise return
1 + min(f(i, j - 1), f(i - 1, j), f(i - 1, j - 1)).
static int recursive(String s1, String s2) {
return solve(s1.length(), s2.length(), s1, s2);
}
/** Fewest operations that turn s1[0..i) into s2[0..j). */
static int solve(int i, int j, String s1, String s2) {
if (i == 0) return j; // insert the j remaining characters of s2
if (j == 0) return i; // delete the i remaining characters of s1
if (s1.charAt(i - 1) == s2.charAt(j - 1)) return solve(i - 1, j - 1, s1, s2); // last characters agree: free
int insert = solve(i, j - 1, s1, s2); // append s2[j - 1]; s2 shrinks
int delete = solve(i - 1, j, s1, s2); // remove s1[i - 1]; s1 shrinks
int replace = solve(i - 1, j - 1, s1, s2); // overwrite s1[i - 1] with s2[j - 1]; both shrink
return 1 + Math.min(insert, Math.min(delete, replace));
}
Complexity. Time O(3^(n + m)) in the worst case: when no characters match, each call branches three ways and the depth can reach n + m. Space O(n + m) for the recursion stack.
Approach 2: Memoization
Intuition. Only (n + 1)(m + 1) distinct pairs (i, j) exist, and the three branches overlap heavily: “delete then insert” and “insert then delete” both reach f(i - 1, j - 1), as does a single replace. Caching every state in dp[i][j] removes all of that repeated work. The sentinel is -1, because a distance of 0 is a real answer.
Algorithm.
- Create
dp[n + 1][m + 1]filled with-1. - Run the recursion, returning
dp[i][j]if it is set, and storing each result before returning it.
static int memoization(String s1, String s2) {
int n = s1.length(), m = s2.length();
int[][] dp = new int[n + 1][m + 1];
for (int[] row : dp) Arrays.fill(row, -1);
return memo(n, m, s1, s2, dp);
}
static int memo(int i, int j, String s1, String s2, int[][] dp) {
if (i == 0) return j;
if (j == 0) return i;
if (dp[i][j] != -1) return dp[i][j];
if (s1.charAt(i - 1) == s2.charAt(j - 1)) return dp[i][j] = memo(i - 1, j - 1, s1, s2, dp);
int insert = memo(i, j - 1, s1, s2, dp);
int delete = memo(i - 1, j, s1, s2, dp);
int replace = memo(i - 1, j - 1, s1, s2, dp);
return dp[i][j] = 1 + Math.min(insert, Math.min(delete, replace));
}
Complexity. Time O(n · m): each state is computed once with constant work. Space O(n · m) for the table plus O(n + m) stack, which for 500-character strings is about 1000 frames.
Approach 3: Tabulation
Intuition. Each cell depends on the cell to its left (insert), the cell above (delete) and the cell above-left (replace or match). Filling row by row, left to right, makes all three available. Row 0 is 0, 1, …, m and column 0 is 0, 1, …, n, straight from the base cases.
Algorithm.
- Create
dp[n + 1][m + 1]. Setdp[i][0] = ianddp[0][j] = j. - For
i = 1 .. n,j = 1 .. m: copy the diagonal on a match, otherwise take1 +the minimum of left, above and diagonal. - Return
dp[n][m].
static int tabulation(String s1, String s2) {
int n = s1.length(), m = s2.length();
int[][] dp = new int[n + 1][m + 1];
for (int i = 0; i <= n; i++) dp[i][0] = i;
for (int j = 0; j <= m; j++) dp[0][j] = j;
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
if (s1.charAt(i - 1) == s2.charAt(j - 1)) dp[i][j] = dp[i - 1][j - 1];
else dp[i][j] = 1 + Math.min(dp[i][j - 1], Math.min(dp[i - 1][j], dp[i - 1][j - 1]));
}
}
return dp[n][m];
}
Dry run on Example 1 (s1 = "horse" down the side, s2 = "ros" across):
| ”” | r | o | s | |
|---|---|---|---|---|
| ”” | 0 | 1 | 2 | 3 |
| h | 1 | 1 | 2 | 3 |
| o | 2 | 2 | 1 | 2 |
| r | 3 | 2 | 2 | 2 |
| s | 4 | 3 | 3 | 2 |
| e | 5 | 4 | 4 | 3 |
Bold cells are matches, each copying its upper-left neighbour. For instance dp[2][2] = dp[1][1] = 1: turning ho into ro costs the same as turning h into r, one replacement. The final cell dp[5][3] = 3 comes from the cell above (dp[4][3] = 2, turning hors into ros) plus one deletion of e. Following the cheapest choices back from the corner recovers an actual edit script.
Complexity. Time O(n · m), space O(n · m).
Approach 4: Space optimized
Intuition. Row i reads only row i - 1 and the part of row i already filled. Keep two arrays, prev for row i - 1 and cur for row i, and swap them after each row. The one thing to remember is the base case of each new row: cur[0] must be set to i (delete all i characters). Because the arrays are swapped and reused, cur[0] would otherwise hold a stale value from two rows ago.
Algorithm.
prev[j] = jfor everyj(row 0).- For each
i = 1 .. n: setcur[0] = i, fillcur[1..m]fromprevandcur, then swapprevandcur. - After the last swap
prevholds rown; returnprev[m].
static int spaceOptimized(String s1, String s2) {
int n = s1.length(), m = s2.length();
int[] prev = new int[m + 1], cur = new int[m + 1];
for (int j = 0; j <= m; j++) prev[j] = j;
for (int i = 1; i <= n; i++) {
cur[0] = i;
for (int j = 1; j <= m; j++) {
if (s1.charAt(i - 1) == s2.charAt(j - 1)) cur[j] = prev[j - 1];
else cur[j] = 1 + Math.min(cur[j - 1], Math.min(prev[j], prev[j - 1]));
}
int[] tmp = prev; prev = cur; cur = tmp;
}
return prev[m];
}
Complexity. Time O(n · m), space O(m). Passing the shorter string as s2 gives O(min(n, m)); this is safe because edit distance is symmetric (swap the roles of insert and delete).
Edge cases and pitfalls
- Empty strings.
("", "abc")is 3 inserts,("abc", "")is 3 deletes,("", "")is 0. With prefix-length indexing these are the base cases. - Base row and column are not zero. Leaving
dp[i][0]ordp[0][j]at Java’s default 0 silently gives too small an answer. - The
cur[0] = iline. It is easy to forget in the two-row version, and the bug hides from simple tests:("a", "b")still comes out as 1, while("ab", "b")returns 0 instead of 1. - Replace is one operation, not two.
("abc", "xyz")is 3, not 6. Without the diagonal option you would be solving Minimum insertions or deletions to convert string A to B instead. - Do not add 1 on a match. Equal last characters cost nothing, so
("abc", "abc")is 0. - Testing. The fixed cases include
("kitten", "sitting") = 3, plus 200 seeded random pairs over{a, b, c}checked across all four methods.
Related problems
- Longest common subsequence uses the same “match the ends or drop one” structure, maximising instead of minimising.
- Minimum insertions or deletions to convert string A to B is edit distance without the replace operation, which reduces to
n + m - 2 · LCS. - Distinct subsequences is a counting DP over the same prefix-length table.
- Wildcard matching uses the same three neighbours, but as a yes/no question about a pattern.
- Shortest common supersequence also writes out unmatched characters from both strings, like the insert and delete moves here.