TL;DR
Levenshtein DP over prefix pairs (i, j) β O(m Γ n) time, O(min(m, n)) space after rolling to two rows.
Approach 1 β Brute-force recursion
Intuition: compare the last characters of the two prefixes. If they match, drop both and recurse. If not, try all three edits β replace (drop both, +1), delete from word1 (drop one, +1), insert into word1 (drop one from word2, +1) β and take the cheapest.
class Solution:
def minDistance(self, word1: str, word2: str) -> int:
def dfs(i: int, j: int) -> int:
if i == 0:
return j # insert the remaining j chars
if j == 0:
return i # delete the remaining i chars
if word1[i - 1] == word2[j - 1]:
return dfs(i - 1, j - 1)
replace = dfs(i - 1, j - 1)
delete = dfs(i - 1, j)
insert = dfs(i, j - 1)
return 1 + min(replace, delete, insert)
return dfs(len(word1), len(word2))
Complexity: O(3^(m+n)) time in the worst case (three-way branch at every mismatch), O(m+n) recursion depth.
Why the constraints kill it: for two length-500 strings with no matches, the branching factor explodes far beyond any time limit.
Approach 2 β Top-down memoization
The insight: the recursion only depends on (i, j), and there are just (m+1)(n+1) such pairs. Cache each β the 2-D memo turns the exponential tree linear in the grid size.
from functools import lru_cache
class Solution:
def minDistance(self, word1: str, word2: str) -> int:
@lru_cache(maxsize=None)
def dfs(i: int, j: int) -> int:
if i == 0:
return j
if j == 0:
return i
if word1[i - 1] == word2[j - 1]:
return dfs(i - 1, j - 1)
return 1 + min(dfs(i - 1, j - 1), dfs(i - 1, j), dfs(i, j - 1))
return dfs(len(word1), len(word2))
Complexity: O(m Γ n) time and space.
Approach 3 β Bottom-up 2-D table
Table meaning: dp[i][j] = edit distance between word1[:i] and word2[:j] (the first i and first j characters).
2-D recurrence:
dp[i][0] = i, dp[0][j] = j # to/from empty string costs its length
if word1[i-1] == word2[j-1]:
dp[i][j] = dp[i-1][j-1] # free: characters already match
else:
dp[i][j] = 1 + min( dp[i-1][j-1], # replace
dp[i-1][j], # delete from word1
dp[i][j-1] ) # insert into word1
class Solution:
def minDistance(self, word1: str, word2: str) -> int:
m, n = len(word1), len(word2)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(m + 1):
dp[i][0] = i
for j in range(n + 1):
dp[0][j] = j
for i in range(1, m + 1):
for j in range(1, n + 1):
if word1[i - 1] == word2[j - 1]:
dp[i][j] = dp[i - 1][j - 1]
else:
dp[i][j] = 1 + min(dp[i - 1][j - 1],
dp[i - 1][j],
dp[i][j - 1])
return dp[m][n]
Walkthrough on word1 = "horse", word2 = "ros". The filled grid (rows = horse prefixes, cols = ros prefixes):
| "" | 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 |
dp[5][3] = 3 β matching the three edits from the examples.
Complexity: O(m Γ n) time, O(m Γ n) space.
Approach 4 β Space-optimized two rows
The insight: dp[i][j] reads only row i-1 and the current rowβs left neighbor. Keep the previous row and build the current one; that drops space to O(n).
class Solution:
def minDistance(self, word1: str, word2: str) -> int:
m, n = len(word1), len(word2)
prev = list(range(n + 1)) # dp[0][j] = j
for i in range(1, m + 1):
curr = [i] + [0] * n # dp[i][0] = i
for j in range(1, n + 1):
if word1[i - 1] == word2[j - 1]:
curr[j] = prev[j - 1]
else:
curr[j] = 1 + min(prev[j - 1], prev[j], curr[j - 1])
prev = curr
return prev[n]
Complexity: O(m Γ n) time, O(n) space. (Swap the strings so the shorter one drives n for O(min(m, n)) space.)
Common pitfalls
- Mixing up which neighbor is insert vs delete β
dp[i-1][j] shrinks word1 (delete), dp[i][j-1] shrinks word2 (insert into word1). They are symmetric in cost but get the direction of the walkthrough wrong if confused.
- Skipping the base row/column β an empty prefix must cost the length of the other, or the whole table is off.
- Adding 1 on a match β when characters are equal the cost carries over unchanged from the diagonal; adding 1 there overcounts.
- In the rolled version, reset
curr[0] = i every new row, or the base column drifts.
Pattern takeaway
Two-string alignment problems (edit distance, LCS, interleaving) all live on a (prefix of A, prefix of B) grid. Compare the two current characters: a match walks the diagonal for free, a mismatch pays a fixed cost and chooses among the three orthogonal/diagonal neighbors. Every cell depends only on the previous row, so O(n) space is always available.