🔬 Saturday Deep Dive: Edit Distance — 编辑距离 (15 min read)
📊 NeetCode: 109/150 · SysDesign: 60/60 · Behavioral: 60/60 · Frontend: 37/50 · AI: 30/30
🔥 Day 141 — Expert Phase
Overview / 概述
中文: 本周我们完成了一维动态规划的全部 12 道题。今天,我们跨越到二维 DP 的核心——编辑距离(Levenshtein Distance)。这是面试中最经典的 Hard 级别 DP 题之一,也是无数现实系统的基石:Git diff、拼写检查、DNA 序列比对、模糊搜索。
English: This week we finished all 12 problems in the 1-D DP block. Today we cross into 2-D DP with its defining problem: Edit Distance (Levenshtein Distance). It's one of the most elegant Hard DP problems in interviews and powers real systems you use daily — git diff, spell checkers, DNA alignment, Elasticsearch fuzzy queries.
The problem: Given two strings word1 and word2, return the minimum number of operations (insert, delete, replace) to transform word1 into word2.
🔗 LeetCode #72: https://leetcode.com/problems/edit-distance/
📹 NeetCode Video: https://www.youtube.com/watch?v=XYi2-LPrwm4
Part 1: Theory / 理论基础 (5 min)
1.1 从递归思维开始 / Start with Recursive Thinking
中文: DP 的关键是找到「子问题」。对于编辑距离,子问题是:
dp[i][j]= 将word1的前i个字符变成word2的前j个字符所需的最少操作数
比较最后一个字符 word1[i-1] 和 word2[j-1],只有两种情况:
English: The key to DP is finding the subproblem:
dp[i][j]= min operations to transformword1[:i]intoword2[:j]
Compare the last character of each prefix — two cases:
Case 1: Characters match → word1[i-1] == word2[j-1]
中文: 最后一个字符已经匹配,无需操作,问题缩小为子问题 dp[i-1][j-1]
dp[i][j] = dp[i-1][j-1]
No operation needed. The answer for this pair equals the answer for the pair without these characters.
Case 2: Characters differ → word1[i-1] != word2[j-1]
Mental model for each operation:
- Replace
dp[i-1][j-1]: Both pointers move back — you replacedword1[i-1]withword2[j-1] - Delete
dp[i-1][j]: Onlyimoves back — you deletedword1[i-1](so nowword1[:i-1]must reachword2[:j]) - Insert
dp[i][j-1]: Onlyjmoves back — you insertedword2[j-1]at the end ofword1[:i]
中文关键洞察: "插入" 和 "删除" 是对称的。从 word1 视角插入 = 从 word2 视角删除,反之亦然。
Key insight: Insert and Delete are symmetric. "Insert into word1" is equivalent to "delete from word2."
1.2 Base Cases / 边界条件
dp[i][0] = i # word2 is empty → delete all i chars from word1
dp[0][j] = j # word1 is empty → insert all j chars from word2
1.3 DP Table Visualization / DP 表格可视化
For word1 = "horse", word2 = "ros":
中文: 读懂这张表是理解编辑距离的关键。每个格子 dp[i][j] 代表将 word1 前 i 个字符变成 word2 前 j 个字符的最小代价。右下角就是答案。
English: Every cell dp[i][j] is the minimum cost to transform the first i chars of word1 into the first j chars of word2. Bottom-right = answer.
Let's trace a few key cells manually:
dp[2][2]:word1[:2]="ho",word2[:2]="ro". Last chars:'o' == 'o'→dp[1][1] = 1✓dp[3][1]:word1[:3]="hor",word2[:1]="r". Last chars:'r' == 'r'→dp[2][0] = 2✓dp[5][3]:'e' != 's'→1 + min(dp[4][2]=3, dp[4][3]=2, dp[5][2]=4) = 1+2 = 3✓
Part 2: Step-by-Step Implementation / 一步一步实现 (8 min)
Approach 1: Recursive + Memoization (Top-Down) / 递归 + 记忆化
中文: 先写递归,理解清楚再优化。
English: Write recursion first to understand, then optimize.
from functools import lru_cache
def minDistance_memo(word1: str, word2: str) -> int:
m, n = len(word1), len(word2)
@lru_cache(maxsize=None)
def dp(i, j):
# Base cases: one string exhausted
if i == 0: return j # insert j chars from word2
if j == 0: return i # delete i chars from word1
if word1[i-1] == word2[j-1]:
# Characters match — no operation needed
return dp(i-1, j-1)
# Characters differ — try all 3 operations, pick cheapest
replace = dp(i-1, j-1) # replace word1[i-1] with word2[j-1]
delete = dp(i-1, j) # delete word1[i-1]
insert = dp(i, j-1) # insert word2[j-1] after word1[:i]
return 1 + min(replace, delete, insert)
return dp(m, n)
Time: O(m × n) — each subproblem computed once
Space: O(m × n) for the cache + O(m+n) call stack
Approach 2: Bottom-Up 2-D DP (Optimal Clarity) / 自底向上二维 DP
中文: 把递归改成迭代,消除递归栈开销。这是面试中最推荐写法——思路清晰,代码简洁。
English: Convert recursion to iteration. This is the preferred interview answer — clean and readable.
def minDistance_2d(word1: str, word2: str) -> int:
m, n = len(word1), len(word2)
# dp[i][j] = min ops to transform word1[:i] into word2[:j]
# Table is (m+1) x (n+1) to accommodate empty string base cases
dp = [[0] * (n + 1) for _ in range(m + 1)]
# Base case: transform to/from empty string
for i in range(m + 1):
dp[i][0] = i # delete i chars to get empty string
for j in range(n + 1):
dp[0][j] = j # insert j chars from empty string
# Fill table row by row
for i in range(1, m + 1):
for j in range(1, n + 1):
if word1[i-1] == word2[j-1]:
# Characters match — no operation needed, inherit diagonal
dp[i][j] = dp[i-1][j-1]
else:
# Take cheapest of the 3 operations
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
)
return dp[m][n]
Time: O(m × n)
Space: O(m × n)
Approach 3: Space-Optimized 1-D DP / 空间优化一维 DP
中文: 关键观察:填表时每行只需要上一行的数据。因此可以压缩到两行,甚至一行(用 prev 变量记录对角线值)。
English: Key observation: filling row i only needs row i-1. We can compress to O(n) space.
def minDistance_optimized(word1: str, word2: str) -> int:
m, n = len(word1), len(word2)
# prev[j] represents dp[i-1][j] from the 2D version
prev = list(range(n + 1)) # base case: dp[0][j] = j
for i in range(1, m + 1):
# curr[j] = dp[i][j] for the current row
curr = [0] * (n + 1)
curr[0] = i # base case: dp[i][0] = i
for j in range(1, n + 1):
if word1[i-1] == word2[j-1]:
# Match: take diagonal (prev row, prev column)
curr[j] = prev[j-1]
else:
curr[j] = 1 + min(
prev[j-1], # replace (diagonal in 2D)
prev[j], # delete (cell above in 2D)
curr[j-1], # insert (cell left in 2D)
)
prev = curr # shift rows
return prev[n]
Time: O(m × n)
Space: O(n) — only one row at a time
Complexity Comparison / 复杂度对比
| Approach | Time | Space | Notes |
|---|---|---|---|
| Brute-force recursion | O(3^(m+n)) | O(m+n) | TLE for large inputs |
| Memoization | O(m×n) | O(m×n) | Good starting point |
| Bottom-up 2-D | O(m×n) | O(m×n) | Best readability |
| Space-optimized | O(m×n) | O(n) | Best for production |
Part 3: Edge Cases & Gotchas / 边界情况 (2 min)
中文: 这道题的常见错误和陷阱:
English: Common pitfalls:
1. Off-by-one: DP table is (m+1) × (n+1), not (m × n)
# ❌ WRONG: missing base cases
dp = [[0] * n for _ in range(m)]
# ✅ CORRECT: includes empty string rows/columns
dp = [[0] * (n + 1) for _ in range(m + 1)]
中文: 表格的行列数各多 1,是为了容纳空字符串的 base case。dp[0][j] 表示空串变成 word2[:j] 的代价。
2. Forgetting base case initialization
# ❌ WRONG: only creates zero table
dp = [[0] * (n+1) for _ in range(m+1)]
# Missing: dp[i][0] = i and dp[0][j] = j !
# ✅ CORRECT
for i in range(m + 1): dp[i][0] = i
for j in range(n + 1): dp[0][j] = j
3. Index confusion: word1[i-1] not word1[i]
# ❌ WRONG
if word1[i] == word2[j]: # IndexError when i=m
# ✅ CORRECT: i is 1-indexed in dp, 0-indexed in string
if word1[i-1] == word2[j-1]:
中文: dp 表格是 1-indexed(第 i 行代表前 i 个字符),但字符串本身是 0-indexed。所以访问字符要用 word1[i-1]。
4. What counts as one operation?
中文: 题目默认 insert / delete / replace 各算一步操作,代价相同。
如果题目改成代价不同(比如 insert=2, delete=1),只需修改 +1 的部分。
English: All three operations cost 1. If costs differ (weighted edit distance),
just change the +1 to +cost for each operation type.
5. Empty string edge case
minDistance("", "ros") # → 3 (insert 3 chars)
minDistance("horse", "") # → 5 (delete 5 chars)
minDistance("", "") # → 0
# All handled correctly by base case initialization
Part 4: Real-World Application / 实际应用 (2 min)
中文: 编辑距离不只是面试题——它在工业级系统中无处不在。
English: Edit distance isn't just an interview problem — it's in production systems you use daily.
1. Git Diff / Unix Diff
$ git diff HEAD~1 HEAD -- file.py
中文: Git diff 使用编辑距离的变体(Myers diff algorithm)计算两个文件版本之间的差异,以最小操作数展示变更。
English: Git uses a variant (Myers diff algorithm) to compute the minimum edit script between file versions. Every + and - line in a diff represents insert/delete operations.
2. Spell Checkers / 拼写检查
# Simplified spell checker using edit distance
def suggest_corrections(word: str, dictionary: list[str], max_dist: int = 2) -> list[str]:
return [
w for w in dictionary
if minDistance(word, w) <= max_dist
]
suggest_corrections("recieve", ["receive", "relieve", "retrieve"])
# → ["receive"] (edit distance = 1: swap i and e)
中文: 拼写检查器预计算词典中所有词与输入词的编辑距离,距离 ≤ 2 的作为候选建议。
3. DNA Sequence Alignment / DNA 序列比对
中文: 基因组学中,Needleman-Wunsch 和 Smith-Waterman 算法是编辑距离的变体,用于比对 DNA/蛋白质序列,找到进化上的同源序列。
English: Bioinformatics uses weighted edit distance variants to align DNA sequences. The "alignment score" you see in BLAST results is essentially an edit distance computation.
4. Elasticsearch Fuzzy Search / 模糊搜索
{
"query": {
"fuzzy": {
"name": {
"value": "recieve",
"fuzziness": 1 ← max edit distance of 1
}
}
}
}
中文: Elasticsearch 的 fuzziness 参数直接对应编辑距离阈值。内部使用 Levenshtein automaton 高效执行。
English: Elasticsearch's fuzziness parameter is literally the edit distance threshold. Behind the scenes it uses a Levenshtein automaton for efficient execution across millions of terms.
5. Autocorrect on Mobile / 手机自动更正
中文: iOS/Android 键盘的自动更正功能将编辑距离与打字错误模式(键盘邻键)结合,给出概率最高的修正建议。
English: Mobile keyboards combine edit distance with keyboard-proximity weights (adjacent keys get lower cost). Hitting 'j' when you meant 'h' costs less than a random key swap.
Part 5: Interview Simulation / 面试模拟 (3 min)
中文: 面试官最常追问的 5 个问题:
English: The 5 most common follow-ups:
Q1: "What's the time and space complexity? Can you improve the space?"
A: Time is O(m×n) — we compute each of the m×n cells once.
Space is O(m×n) for the 2D table, reducible to O(n) by keeping
only the current and previous rows. We can even do O(min(m,n)) by
iterating over the shorter string in the inner loop.
中文: 时间 O(m×n) 无法进一步优化(每个子问题至少算一次)。
空间可从 O(m×n) 优化到 O(n),只保留当前行和上一行。
Q2: "How would you reconstruct the actual sequence of edits, not just the count?"
# Backtrack through the DP table from dp[m][n] to dp[0][0]
def reconstruct_ops(word1, word2, dp):
ops = []
i, j = len(word1), len(word2)
while i > 0 or j > 0:
if i > 0 and j > 0 and word1[i-1] == word2[j-1]:
i -= 1; j -= 1 # no op, diagonal
elif i > 0 and j > 0 and dp[i][j] == dp[i-1][j-1] + 1:
ops.append(f"replace word1[{i-1}] with '{word2[j-1]}'")
i -= 1; j -= 1
elif i > 0 and dp[i][j] == dp[i-1][j] + 1:
ops.append(f"delete word1[{i-1}]='{word1[i-1]}'")
i -= 1
else:
ops.append(f"insert '{word2[j-1]}' at position {i}")
j -= 1
return list(reversed(ops))
# 中文: 从右下角反推,每步判断当前值来自哪个方向(对角/上/左),还原操作序列。
Q3: "How does this differ from Longest Common Subsequence (LCS)?"
A: They're related! Edit distance and LCS both use a 2D DP table with
similar recurrence. The connection:
editDistance(s1, s2) = m + n - 2 * LCS(s1, s2)
(Where LCS length tells you the max chars you DON'T need to change)
中文: 编辑距离和 LCS 本质相通。LCS 越长,需要修改的字符越少。
如果操作只有 insert/delete(无 replace),公式成立。
有 replace 时稍有不同,但直觉相同。
Q4: "What if operations have different costs — e.g., insert=2, delete=1, replace=3?"
# Just change the constants:
def weightedEditDistance(word1, word2, ins=2, dlt=1, rep=3):
# ...same structure, replace +1 with +ins, +dlt, +rep
dp[i][j] = min(
dp[i-1][j-1] + rep, # replace
dp[i-1][j] + dlt, # delete
dp[i][j-1] + ins, # insert
)
# 中文: 改变代价系数即可。这也是生物信息学中 alignment scoring matrix 的基础。
Q5: "What are some limitations or when would edit distance NOT be the right metric?"
A: Edit distance treats every character equally — it doesn't understand
that 'o' and '0' look similar, or that keyboard proximity matters.
Better alternatives:
- Jaro-Winkler: optimized for names/short strings (rewards prefix matches)
- BK-trees: efficient data structure for edit distance lookups at scale
- Word-level edit distance: for NLP, compare words not characters
- Phonetic distance: Soundex/Metaphone for audio similarity
中文: 编辑距离把所有字符等价对待,不考虑视觉相似性、发音相似性或键盘邻近性。
实际系统通常组合多种相似度指标,而非单用编辑距离。
Step 4: Cross-Check / 交叉验证
中文: 验证 ASCII 表格与代码实现一致性。
English: Verifying the ASCII diagram matches the code.
Input: word1 = "horse", word2 = "ros"
Tracing dp[5][3] through the DP table diagram:
Tracing dp[5][3] through the code:
# i=5 (word1[4]='e'), j=3 (word2[2]='s')
# 'e' != 's' → take minimum of:
# dp[4][2] (replace) = 3
# dp[4][3] (delete) = 2 ← minimum
# dp[5][2] (insert) = 4
# dp[5][3] = 1 + 2 = 3 ✓
# Verify dp[4][3]: i=4 (word1[3]='s'), j=3 (word2[2]='s')
# 's' == 's' → dp[3][2] = 2 ✓
# Verify dp[3][2]: i=3 (word1[2]='r'), j=2 (word2[1]='o')
# 'r' != 'o' → 1 + min(dp[2][1]=2, dp[2][2]=1, dp[3][1]=2) = 1+1 = 2 ✓
Result: Diagram and code agree — dp[5][3] = 3. ✓
Summary / 总结
中文要点:
- 子问题定义:
dp[i][j]= 将word1[:i]变成word2[:j]的最少操作数 - 状态转移:字符相同取对角,字符不同取三方向最小值 +1
- 三种操作:replace(对角)、delete(上方)、insert(左方)
- 空间优化:O(m×n) → O(n),只保留两行
- 现实意义:git diff、拼写检查、DNA 比对、模糊搜索都用这个思想
English Takeaways:
- Subproblem:
dp[i][j]= min ops to transformword1[:i]→word2[:j] - Transition: Match → take diagonal; mismatch → take min of 3 neighbors + 1
- Three ops map to three directions: replace=diagonal, delete=up, insert=left
- Space optimization: Compress 2D table to 1 row with a
prevvariable - Real-world: This algorithm (or its variants) runs inside tools you use every day
Pattern to remember / 记忆口诀:
两串对比找距离,dp 表格填满你
字符相同取对角,字符不同三选一
删插替换各 +1,右下角就是答案
📬 byte-by-byte · Day 141 · Saturday Deep Dive
🔗 Day 141 · Saturday Deep Dive