跳到主内容

编辑距离实战:从 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 操作路径,则仍建议保留完整二维表。

子问题定义dp[i][j]字符相同继承左上角字符不同三种操作取最小完整 DP 表支持回溯操作滚动数组只保留相邻行

状态转移推导:每一格到底表示什么 ​

设源字符串为 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 的变体中保持稳定判断。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。