72. 编辑距离(Edit Distance)

难度:中等 | 主题:动态规划、字符串

题目

给你两个单词 word1 和 word2,请返回将 word1 转换成 word2 所使用的最少操作数。你可以对一个单词进行三种操作:插入一个字符、删除一个字符、替换一个字符。

示例

 
word1 = "horse", word2 = "ros" → 输出 3
 
horse → rorse (替换 h → r)
 
rorse → rose (删除 r)
 
rose → ros (删除 e)
 

思路

先讲个故事:改稿子

你写了一篇英文文章交给编辑,编辑说:“把 A 改成 B,最少改几处?”

你有三种修改手段:

  • :删掉一个多余的字母

  • :在某个位置插入一个漏掉的字母

  • :替换一个拼错的字母

这不就是编辑距离吗?增、删、改三种操作,把一个字符串变成另一个,求最小操作次数。

这其实是自然语言处理中最基础的问题——拼写纠错、DNA 序列比对都用它。


引导式推导:从末尾字符开始思考

第 1 步(只看最后一个字符):想把 word1[:i] 变成 word2[:j],先看两个字符串的最后一个字符

 
word1 = "horse" (i=5), word2 = "ros" (j=3)
 
最后一个字符:word1[4]='e', word2[2]='s'
 
不一样 → 需要操作。有 3 种选择:
 

第 2 步(三种操作对应三个子问题)

操作含义子问题
替换把 ‘e’ 改成 ‘s’,然后匹配剩下的dp[i-1][j-1] + 1
删除删掉 ‘e’,再匹配剩下的dp[i-1][j] + 1
插入在末尾插入 ‘s’(相当于 word2 少一个),再匹配剩下的dp[i][j-1] + 1

如果最后一个字符相同,什么都不用做,直接跳过:

 
dp[i][j] = dp[i-1][j-1]   # 字符相同,不用操作
 

第 3 步(完整状态转移)

 
dp[i][j] =  word1[:i] → word2[:j] 的最小编辑距离
 
如果 word1[i-1] == word2[j-1]:
 
    dp[i][j] = dp[i-1][j-1]
 
否则:
 
    dp[i][j] = 1 + min(
 
        dp[i-1][j-1],   # 替换
 
        dp[i-1][j],     # 删除
 
        dp[i][j-1]      # 插入
 
    )
 

边界:空字符串转任意长度 = 插入全部。dp[0][j] = jdp[i][0] = i


DP 填表演示


flowchart LR

    subgraph edit["编辑距离 DP (horse → ros)"]

        direction LR

        h_row[" "] --> h0["0"] --> h1["1"] --> h2["2"] --> h3["3"]

        e_row["h"] --> e1["1"] --> e2["1"] --> e3["2"] --> e4["3"]

        o_row["o"] --> o1["2"] --> o2["2"] --> o3["1<br/>(替换 h→r)"] --> o4["2"]

        r_row["r"] --> r1["3"] --> r2["3<br/>(删除 e)"] --> r3["2<br/>(替换 o→o=跳过)"] --> r4["2<br/>(替换 s→s=跳过)"]

        s_row["s"] --> s1["4"] --> s2["4"] --> s3["3"] --> s4["3 ✅"]

    end

    style s4 fill:#ffd700

每个格子的值是左上(替换)、上(删除)、左(插入)三个方向的最小值 + (字符不同 ? 1 : 0)。


三层递进


graph LR

    subgraph 三层递进

        A["暴力递归<br/>O(3^n)"]

        B["二维 DP<br/>O(mn) O(mn)"]

        C["一维滚动<br/>O(mn) O(n)"]

    end

    A -->|发现重叠子问题| B

    B -->|依赖三方向但二维可滚| C

暴力递归dp(i, j) = 1 + min(dp(i-1, j-1), dp(i-1, j), dp(i, j-1))。每步 3 个分支,指数爆炸。

二维 DPdp[i][j] 填表,O(mn) 时间 O(mn) 空间。

一维滚动dp[j] 依赖左上、上、左三个方向。

  • 左上(dp[i-1][j-1])→ 用 prev 变量提前保存

  • 上(dp[i-1][j])→ 更新前的 dp[j]

  • 左(dp[i][j-1])→ 已更新的 dp[j-1]


代码

 
def minDistance(self, word1, word2):
 
    m, n = len(word1), len(word2)
 
    dp = list(range(n + 1))               # 第一行:空 word1 → word2[:j] 需插入 j 次
 
    for i in range(1, m + 1):
 
        prev = dp[0]                      # 左上角 = dp[i-1][0](上一轮的 dp[0])
 
        dp[0] = i                         # 第一列:word1[:i] → 空需删除 i 次
 
        for j in range(1, n + 1):
 
            temp = dp[j]                  # 保存更新前的 dp[j](即 dp[i-1][j])
 
            if word1[i - 1] == word2[j - 1]:
 
                dp[j] = prev              # 字符相同,继承左上角
 
            else:
 
                dp[j] = 1 + min(
 
                    prev,                 # 替换(左上角)
 
                    dp[j],               # 删除(上方,未更新的 dp[j] = dp[i-1][j])
 
                    dp[j - 1]            # 插入(左方,已更新的 dp[j-1] = dp[i][j-1])
 
                )
 
            prev = temp                   # 下一轮的左上角 = 本轮未更新的 dp[j]
 
    return dp[n]
 

关键prev 变量保存左上角的值。每一行开始时 prev = dp[0](即上一行的 dp[0]),内层循环先 temp = dp[j] 存下上方值,更新完 dp[j] 后再赋给 prev 供下一列使用。


复杂度

指标解释
时间O(mn)遍历两个字符串
空间O(n)一维滚动数组
二维 DPO(mn)可提但非最优

实战考量

频率分析

出现在:字节/腾讯/阿里 二面~高频题,约 40% 的同类题会考编辑距离或其变体。这是字符串 DP 的标杆题,常以此判断候选人对二维 DP 空间优化的掌握程度。

延伸思考

Q:为什么替换对应左上角,删除对应上方,插入对应左方?

A:替换是同时处理 word1[i-1] 和 word2[j-1] 两个字符(所以子问题是 dp[i-1][j-1]),删除是只处理 word1[i-1](剩下的 word1[:i-1] 仍然要匹配 word2[:j],所以子问题是 dp[i-1][j]),插入是相当于 word2 少了一个字符需要匹配(word1[:i] 匹配 word2[:j-1],所以子问题是 dp[i][j-1])。

Q:如果操作代价不同呢?

A:加权编辑距离——替换权重可能高(如 2),增删权重低(如 1)。公式从 +1 变成 +cost。这就是 DNA 序列比对的 Smith-Waterman 算法的基础。

Q:如果只允许插入和删除呢?

A:变成**最长公共子序列(LCS)**问题:dp[i][j] = max(dp[i-1][j], dp[i][j-1])dp[i-1][j-1] + 1(字符相等时)。编辑距离和 LCS 是互通的——编辑距离中只留增删就是 LCS 的补集。

Q:如果要求输出具体操作序列呢?

A:额外用一个 prev 数组记录每个状态从哪转移来(替换/删除/插入),最后从 dp[m][n] 回溯到 dp[0][0]

Q:如何判断两个字符串相似度?

A:可以用编辑距离归一化:similarity = 1 - edit_distance / max(len1, len2)。这其实就是搜索引擎中”拼写纠错”的底层原理——“您是不是想找 XXX?“

易错点

  • dp[i][j] 对应 word1[:i]word2[:j],所以字符索引要 -1

  • 空间优化中 prev 的更新时机——在 替换 dp[j] 之前先存下旧值

  • 第一列 dp[0] = i 的赋值要在内层循环前

  • 替换和删除/插入的区别:替换同时处理两个字符,增删只处理一个


生活类比

编辑距离 → 改稿子 → 对齐

想象你在用 Git 做代码审查:你要把旧版本(word1)改成新版本(word2)。

  • 替换 = 改了一行代码
  • 删除 = 删了一行
  • 插入 = 加了一行

编辑距离就是最少的 diff 行数。Git diff 的算法底层用的就是类似的思想——两个文件的最小编辑距离。

或者说:编辑距离是两个字符串之间的 Git diff。 一个 diff,就是若干次替换、删除、插入操作的集合。


相关题目

题目关系
583两个字符串的删除操作只允许删除,编辑距离子集
1143最长公共子序列只允许增删时的等价问题
72编辑距离本题,编辑距离经典题

→ 返回题单:LeetCode学习路线图 > 十三、动态规划

速记卡(面试闪卡)

Q1:一句话讲清「72. 编辑距离(Edit Distance)」到底是什么?

A:给两个单词,返回把 word1 转换成 word2 的最少操作数,操作只有三种:插入、删除、替换一个字符。本质是字符串 DP 的标杆题。

Q2:题目与改稿 —— 怎么理解?

A:像用 Git 做 code review 把旧版本改到新版本——替换 = 改一行、删除 = 删一行、插入 = 加一行。编辑距离就是「最少的 diff 行数」,底层正是搜索引擎拼写纠错(「您是不是想找 XXX?」)和 DNA 序列比对的原理。

Q3:思路与三操作 —— 怎么理解?

A:看两个串的最后一个字符:相同则跳过(dp = 左上角);不同则 1 + min(替换=左上, 删除=上方, 插入=左方)。边界:dp[0][j]=j(空串变 j 长要插 j 次),dp[i][0]=i

Q4:代码与 prev —— 怎么理解?

A:一维滚动时 prev 变量保存左上角值——每行开始 prev = dp[0],内层先 temp = dp[j] 存上方面,更新完再 prev = temp 供下一列用。替换同时处理两个字符,增删只处理一个,这是三操作的根本区别。

Q5:复杂度与变体 —— 怎么理解?

A:时间 O(m×n)、空间 O(n)(一维滚动)。只留增删就是最长公共子序列(LCS);加权编辑距离是 Smith-Waterman 算法基础;归一化 1 - 距离/max(len) 可做相似度。

Q6:核心速记主线有哪些?

A:题目、三操作映射(增删替换)、末尾字符、一维滚动 prev、LCS 互通、相似度应用。

口诀

A:编辑距离三操作,增删替换各对应;

左上上左取最小,prev 存角莫忘记。

相关链接