外观
编辑距离实战:从 O(mn) 到 O(n) 的推导与避坑
为什么编辑距离是文本工程的高频底座
编辑距离(Edit Distance)通常指莱文斯坦距离(Levenshtein Distance),用来衡量两个字符串之间,最少需要多少次插入、删除、替换才能互相转换。它看似是一道经典动态规划题,实际却频繁出现在搜索纠错、命令行模糊匹配、代码 diff、数据清洗、语音识别候选排序等场景。
例如用户输入 recieve,词典中有 receive,二者只需交换相邻字母的语义,但在标准编辑距离中会体现为两次替换或一次删除加一次插入;再比如文本 diff 中,把 kitten 变成 sitting,最少需要 3 次操作。问题的关键不只是“算出距离”,还要理解状态为什么这样转移、哪些信息可以丢弃,以及如何恢复具体操作路径。
本文从问题定义出发,对比递归记忆化与二维动态规划,再落地到滚动数组空间优化和操作回溯,最后总结拼写纠错与 diff 场景中的常见坑。
方案对比:递归、二维 DP 与滚动数组
解决编辑距离主要有三类思路。
第一类是自顶向下递归。若两个字符相同,则直接比较剩余前缀;否则在插入、删除、替换三种选择中取成本最小值。它的直觉最强,但会重复计算大量子问题,不做记忆化时时间复杂度可能指数级膨胀。
第二类是二维动态规划。定义 dp[i][j] 表示 word1 的前 i 个字符变成 word2 的前 j 个字符所需的最少操作数。状态依赖清晰,也便于回溯出完整编辑脚本,时间复杂度为 O(mn),空间复杂度为 O(mn)。
第三类是滚动数组。由于每一行只依赖上一行和当前行左侧状态,可以把二维表压缩为两行,甚至一行加一个对角线变量。时间复杂度仍为 O(mn),空间复杂度可降到 O(n)。如果只需要距离,这是工程中最常用的版本;如果需要输出 diff 操作路径,则仍建议保留完整二维表。
状态转移推导:每一格到底表示什么
设源字符串为 s,长度为 m;目标字符串为 t,长度为 n。建立二维数组 dp,其中 dp[i][j] 表示把 s[:i] 转换为 t[:j] 的最小成本。
边界条件非常重要:
dp[i][0] = i:把长度为i的前缀变成空串,只能删除i次;dp[0][j] = j:把空串变成长度为j的前缀,只能插入j次。
当 s[i-1] == t[j-1] 时,当前两个字符已经匹配,不需要额外操作:
text
dp[i][j] = dp[i-1][j-1]当两个字符不同时,可以从三种状态转移:
- 删除
s[i-1]:依赖dp[i-1][j],成本加 1; - 插入
t[j-1]:依赖dp[i][j-1],成本加 1; - 将
s[i-1]替换为t[j-1]:依赖dp[i-1][j-1],成本加 1。
因此:
text
dp[i][j] = min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) + 1下面是可直接运行的 Python 二维实现,同时支持替换成本配置。标准莱文斯坦距离中插入、删除、替换成本都为 1。
python
def edit_distance(s: str, t: str, replace_cost: int = 1) -> int:
"""计算 s 转成 t 的莱文斯坦距离。"""
m, n = len(s), len(t)
# dp[i][j]:s 的前 i 个字符转成 t 的前 j 个字符的最小成本
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 s[i - 1] == t[j - 1]:
dp[i][j] = dp[i - 1][j - 1]
else:
delete = dp[i - 1][j] + 1
insert = dp[i][j - 1] + 1
replace = dp[i - 1][j - 1] + replace_cost
dp[i][j] = min(delete, insert, replace)
return dp[m][n]
print(edit_distance("kitten", "sitting")) # 3
print(edit_distance("recieve", "receive")) # 2时间复杂度为 O(mn),空间复杂度为 O(mn)。字符串越长,二维表的内存压力越明显。例如两个长度均为 1 万的字符串,仅整数表格就可能占用上百 MB,实际业务中不能忽视。
空间优化:为什么一行加一个变量就够了
观察转移方程可以发现,计算第 i 行时只需要:
- 上一行同列:
dp[i-1][j],删除来源; - 当前行左侧:
dp[i][j-1],插入来源; - 上一行左上角:
dp[i-1][j-1],替换或匹配来源。
因此没有必要保存整个矩阵。使用一维数组 dp[j] 表示当前正在计算的行,在更新 dp[j] 之前:
dp[j]还是旧值,即上一行同列;dp[j-1]已被更新,即当前行左侧;- 用变量
prev_diag暂存更新前的dp[j],它正好是下一列的左上角。
python
def edit_distance_optimized(s: str, t: str, replace_cost: int = 1) -> int:
"""空间优化版:O(n) 空间计算编辑距离。"""
m, n = len(s), len(t)
# 让 t 保持为较短字符串,可进一步减少数组长度
if n > m:
s, t = t, s
m, n = n, m
dp = list(range(n + 1))
for i in range(1, m + 1):
prev_diag = dp[0] # 每一行开始时,左上角是 dp[i-1][0]
dp[0] = i
for j in range(1, n + 1):
old_dp_j = dp[j]
if s[i - 1] == t[j - 1]:
dp[j] = prev_diag
else:
delete = dp[j] + 1 # 上一行同列
insert = dp[j - 1] + 1 # 当前行左侧
replace = prev_diag + replace_cost
dp[j] = min(delete, insert, replace)
prev_diag = old_dp_j
return dp[n]
print(edit_distance_optimized("kitten", "sitting")) # 3该版本时间复杂度仍为 O(mn),但空间复杂度降为 O(min(m,n))。需要特别注意:交换两个字符串不会改变标准莱文斯坦距离,因为插入和删除互为对称操作,成本均为 1;但如果自定义了不对称成本,就不能随意交换。
拼写纠错:距离之外还要做归一化
在拼写纠错中,不能只在全量词典中暴力寻找编辑距离最小的词。假设输入为 app,候选词 apple 的距离为 1,看起来很接近;但输入本身只有 3 个字符,一次插入占比很高。实际系统通常会综合以下因素:
- 编辑距离或归一化相似度;
- 候选词词频、用户点击率、语言模型概率;
- 键盘相邻误触、发音混淆、业务词权重;
- 前缀匹配和完全匹配的优先级。
一个简单可运行的候选排序示例如下:
python
def similarity(s: str, t: str) -> float:
"""将编辑距离转换为 0 到 1 的相似度。"""
max_len = max(len(s), len(t))
if max_len == 0:
return 1.0
distance = edit_distance_optimized(s, t)
return 1 - distance / max_len
def correct(word: str, dictionary: list[str], top_k: int = 3) -> list[tuple[str, float]]:
"""基于编辑距离相似度的简单拼写纠错。"""
scored = [(candidate, similarity(word, candidate)) for candidate in dictionary]
scored.sort(key=lambda item: item[1], reverse=True)
return scored[:top_k]
words = ["receive", "recite", "relieve", "apple"]
print(correct("recieve", words))
# receive 与 relieve 都会比较接近,实际系统还应结合词频与上下文工程上,词典通常很大,不能对每个词都跑 O(mn)。常见加速方式包括:先按首字母、长度区间、语音编码、BK-Tree、前缀索引或 n-gram 倒排链召回一批候选,再用编辑距离精排。编辑距离负责“精确打分”,索引负责“减少计算量”。
文本 diff:只算距离不够,还要回溯操作
代码或文本 diff 不仅关心最少操作数,还关心哪里删除、哪里插入、哪里替换。此时需要保留完整二维表,从右下角 dp[m][n] 向左上回溯:
- 若当前字符相同,沿左上角移动,操作为“保留”;
- 若值等于左上角加替换成本,沿左上角移动,操作为“替换”;
- 若值等于上方加 1,向上移动,操作为“删除”;
- 若值等于左侧加 1,向左移动,操作为“插入”。
python
def diff_with_edit_distance(s: str, t: str) -> list[tuple[str, str]]:
"""返回字符级 diff 操作,仅用于演示编辑路径回溯。"""
m, n = len(s), len(t)
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 s[i - 1] == t[j - 1]:
dp[i][j] = dp[i - 1][j - 1]
else:
dp[i][j] = min(
dp[i - 1][j] + 1,
dp[i][j - 1] + 1,
dp[i - 1][j - 1] + 1,
)
operations = []
i, j = m, n
while i > 0 or j > 0:
if i > 0 and j > 0 and s[i - 1] == t[j - 1]:
operations.append(("keep", s[i - 1]))
i -= 1
j -= 1
elif i > 0 and j > 0 and dp[i][j] == dp[i - 1][j - 1] + 1:
operations.append(("replace", f"{s[i - 1]} -> {t[j - 1]}"))
i -= 1
j -= 1
elif i > 0 and dp[i][j] == dp[i - 1][j] + 1:
operations.append(("delete", s[i - 1]))
i -= 1
else:
operations.append(("insert", t[j - 1]))
j -= 1
operations.reverse()
return operations
for action, value in diff_with_edit_distance("abc", "axc"):
print(action, value)需要说明的是,最短编辑路径可能不止一条。比如连续插入和删除在某些位置可以互换解释,回溯时判断分支的先后顺序会影响 diff 的呈现,但不会改变最终最小距离。真实代码 diff 工具通常还会使用最长公共子序列、Myers Diff、行级与词级切分等策略,以生成更符合人类阅读习惯的结果。
避坑总结:这些错误在面试和业务里都很常见
1. 字符串索引与 DP 索引混用
常见错误是直接写 s[i] == t[j],但 dp[i][j] 表示的是前 i 个字符,对应字符串下标应为 i-1 和 j-1。错误现象通常是最后一个字符不参与比较,或者出现索引越界。修正方法是始终牢记:DP 维度比字符串下标多 1,边界第 0 行和第 0 列专门表示空串。
2. 滚动数组忘记保存左上角
很多人压缩空间时只写 dp[j] = min(dp[j], dp[j-1], dp[j-1]) + 1,这会把“左上角”和“左侧”混为一谈。现象是相同字符也可能被多算操作,结果比二维版本大。修正方法是在更新 dp[j] 前保存旧值到 prev_diag,更新结束后再把旧值赋给 prev_diag,供下一列使用。
3. 初始化被错误放入通用转移
如果把 dp[0][j] 和 dp[i][0] 留作默认 0,相当于认为任意字符串转空串都不需要成本,结果会明显偏小。正确做法是在填表前显式设置:源串前缀到空串全是删除成本,空串到目标串前缀全是插入成本。
4. 把编辑距离直接当作唯一纠错标准
编辑距离最小不代表最应该推荐。短字符串上距离为 1 的候选可能很多,低频误拼词可能压过高频正确词;业务专有名词也可能因为距离稍大而被过滤。修正方法是使用归一化相似度,并叠加词频、上下文语言模型、前缀匹配、键盘距离和业务权重。
5. 在需要 diff 路径时过早优化空间
一维滚动数组只能得到最终距离,无法还原完整路径。若业务需要展示增删改位置,却使用了 O(n) 空间版本,就会发现回溯缺少历史状态。修正方法是:只要距离,用滚动数组;要操作序列,保留 O(mn) 完整表,或使用专门的分块 diff 算法降低内存。
编辑距离的价值不止于记住一个状态转移方程,而在于根据业务目标选择“距离、路径、相似度、召回策略”的组合。理解二维表中每个格子的来源,才能在空间优化、拼写纠错和文本 diff 的变体中保持稳定判断。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。