力扣hot100之72:编辑距离
力扣hot100之72:编辑距离

LeetCode 72:编辑距离(Edit Distance)题解

Edit Distance 是 LeetCode Hot 100 里面一道非常经典的字符串动态规划题。

这道题在动态规划专题里非常有代表性,因为它不仅考察最基础的状态定义和状态转移,还会让人真正理解:

两个字符串之间的“最优操作过程”,是如何被动态规划一步步拆出来的。

题目看起来像是在做字符串编辑: 给你两个单词,允许你进行插入、删除、替换三种操作,问最少多少步可以把 word1 变成 word2

但真正做题时你会发现,这并不是简单地“模拟操作”,而是一个非常标准的二维动态规划问题。

你给出的这份 Java 代码,就是这道题最经典、最标准的一种写法:

二维 DP

它的核心思想是:

  • f[i][j] 表示一个前缀变成另一个前缀的最少操作数

  • 然后根据最后一个字符的关系,去考虑插入、删除、替换三种选择

本文就结合这段代码,系统地讲一下这道题的思路。


题目介绍

给定两个字符串 word1word2,请你计算出将 word1 转换成 word2 所使用的最少操作数。

你可以进行的操作有三种:

  1. 插入一个字符

  2. 删除一个字符

  3. 替换一个字符

例如:

word1 = "horse"
word2 = "ros"

一个可行的转换过程是:

horse -> rorse   (将 h 替换成 r)
rorse -> rose    (删除一个 r)
rose  -> ros     (删除一个 e)

总共需要 3 步,所以答案是:

3

这道题要求的是 最少操作次数,不是随便给出一个转换过程。


示例

示例 1

输入:word1 = "horse", word2 = "ros"
输出:3

解释如上,总共 3 次操作。

示例 2

输入:word1 = "intention", word2 = "execution"
输出:5

这是这道题另一个非常经典的例子,最少需要 5 步。


最直接的想法是什么?

如果最开始不去想动态规划,很多人会下意识地想:

  • word1 开始,一步步尝试插入、删除、替换

  • 看看怎么改成 word2

  • 在所有操作路径里找最短的一条

但这个思路一旦真的写下去,会发现复杂度非常高。 因为每一步都可能分叉出多种操作方式,而且很多中间状态会被重复计算。

例如某个前缀变成另一个前缀的最少步数,可能会在不同递归分支中反复出现。 这正是动态规划最适合出场的信号:

  • 存在重复子问题

  • 存在最优子结构

所以这道题最核心的突破口,就是把“大字符串转换问题”拆成“前缀之间的转换问题”。


核心思路:定义状态

这道题最关键的一步,就是定义状态。

我们定义:

f[i][j] = 将 word1 的前 i 个字符转换成 word2 的前 j 个字符的最少操作数

注意这里说的是:

  • word1 的前 i 个字符

  • word2 的前 j 个字符

例如:

  • f[0][0] 表示空串变空串,需要 0 步

  • f[3][2] 表示把 word1 前 3 个字符变成 word2 前 2 个字符的最少操作数

一旦状态定义清楚,整道题就会变得非常规范。


状态转移怎么推?

假设我们现在要求:

f[i][j]

也就是把 word1[0..i-1] 转换成 word2[0..j-1] 的最少操作数。

我们重点看最后一个字符:

  • word1.charAt(i - 1)

  • word2.charAt(j - 1)

这时候有三种典型操作:

1. 插入

如果最后一步是“插入一个字符”,那意味着我们先把:

word1 前 i 个字符 -> word2 前 j-1 个字符

做好,然后再插入 word2 的最后一个字符。

所以代价是:

f[i][j-1] + 1

2. 删除

如果最后一步是“删除一个字符”,那意味着我们先把:

word1 前 i-1 个字符 -> word2 前 j 个字符

做好,然后删除 word1 的最后一个字符。

所以代价是:

f[i-1][j] + 1

3. 替换 / 不操作

如果最后一步考虑的是两个字符串最后一个字符对应:

  • 如果这两个字符相同,就不需要额外操作

  • 如果不同,就需要替换一次

所以代价是:

f[i-1][j-1] + cost

其中:

cost = 0   当最后字符相同
cost = 1   当最后字符不同

所以最终状态转移方程就是:

f[i][j] = min(
    f[i][j-1] + 1,
    f[i-1][j] + 1,
    f[i-1][j-1] + (word1[i-1] == word2[j-1] ? 0 : 1)
)

这正是你代码里的核心公式。


Java 代码

下面是你给出的代码,我这里保留原始逻辑,并加上注释:

class Solution {
    public int minDistance(String word1, String word2) {
        int n = word1.length(), m = word2.length();
        int[][] f = new int[n + 1][m + 1];

        for (int i = 0; i <= n; i++) {
            for (int j = 0; j <= m; j++) {
                // word1 为空串,只能不断插入
                if (i == 0) {
                    f[i][j] = j;
                    continue;
                }

                // word2 为空串,只能不断删除
                if (j == 0) {
                    f[i][j] = i;
                    continue;
                }

                f[i][j] = Math.min(
                    f[i][j - 1] + 1,
                    Math.min(
                        f[i - 1][j] + 1,
                        f[i - 1][j - 1] + (word1.charAt(i - 1) == word2.charAt(j - 1) ? 0 : 1)
                    )
                );
            }
        }

        return f[n][m];
    }
}

代码拆解

这段代码是编辑距离问题最标准的二维 DP 模板。 如果你能真正理解它,后面很多字符串 DP 题会顺很多。


1. 为什么 f 要开成 (n+1) x (m+1)

int[][] f = new int[n + 1][m + 1];

因为状态 f[i][j] 表示的是“前 i 个字符”和“前 j 个字符”。

这里的 ij 都有可能等于 0,表示空前缀。

例如:

  • f[0][0]:空串变空串

  • f[0][j]:空串变成 word2j 个字符

  • f[i][0]word1i 个字符变成空串

所以数组必须多开一行一列,用来表示“空串”这一类状态。

这是字符串 DP 题里非常常见的做法。


2. 为什么当 i == 0 时,f[i][j] = j

if (i == 0) {
    f[i][j] = j;
    continue;
}

因为这表示:

空串 -> word2 的前 j 个字符

如果从空串变成一个长度为 j 的字符串,那唯一能做的就是不断插入字符。

插入 j 个字符,总代价就是:

j

例如:

  • "" -> "a":1 步

  • "" -> "abc":3 步

所以:

f[0][j] = j

3. 为什么当 j == 0 时,f[i][j] = i

if (j == 0) {
    f[i][j] = i;
    continue;
}

这表示:

word1 的前 i 个字符 -> 空串

如果把一个长度为 i 的字符串变成空串,那只能不断删除字符。

删除 i 次,总代价就是:

i

例如:

  • "a" -> "":1 步

  • "abc" -> "":3 步

所以:

f[i][0] = i

4. 为什么普通位置要取三者最小?

f[i][j] = Math.min(
    f[i][j - 1] + 1,
    Math.min(
        f[i - 1][j] + 1,
        f[i - 1][j - 1] + (word1.charAt(i - 1) == word2.charAt(j - 1) ? 0 : 1)
    )
);

因为当前位置对应的前缀转换,最后一步只可能属于三种情况:

插入

f[i][j-1] + 1

删除

f[i-1][j] + 1

替换 / 不操作

f[i-1][j-1] + cost

编辑距离问的是“最少操作次数”,所以当然要从这三种可能里取最小值。

这一步就是整个 DP 的核心。


5. 为什么字符比较是 word1.charAt(i - 1)word2.charAt(j - 1)

因为:

f[i][j]

表示的是“前 i 个字符”和“前 j 个字符”。

那么这两个前缀的最后一个字符,下标自然分别是:

  • i - 1

  • j - 1

这是很多字符串 DP 题中最容易混淆的地方。 一定要注意:状态是“长度”,字符访问是“下标”,所以要减一。


手动模拟一遍

我们用一个简单例子来走一下:

word1 = "horse"
word2 = "ros"

为了简化说明,我们先只看前几个字符。

设:

  • 行表示 word1 的前缀

  • 列表示 word2 的前缀

初始边界:

      ""  r  o  s
""    0   1  2  3
h     1
o     2
r     3
s     4
e     5

计算 f[1][1]"h" -> "r"

最后字符不同,所以考虑三种操作:

  • 插入:f[1][0] + 1 = 2

  • 删除:f[0][1] + 1 = 2

  • 替换:f[0][0] + 1 = 1

所以:

f[1][1] = 1

计算 f[2][2]"ho" -> "ro"

最后字符 oo 相同,所以替换代价为 0:

  • 插入:f[2][1] + 1

  • 删除:f[1][2] + 1

  • 对齐:f[1][1] + 0

因为最后字符相同,通常第三项会更优。

一路推下去,最终:

f[5][3] = 3

也就是答案。


为什么这个方法是正确的?

因为对于任意两个前缀:

word1 前 i 个字符
word2 前 j 个字符

它们的最优编辑过程,最后一步一定属于下面三种之一:

  1. 插入

  2. 删除

  3. 替换 / 不操作

而在最后一步之前,问题规模都会缩小成一个更短前缀之间的编辑距离问题:

  • 插入对应 f[i][j-1]

  • 删除对应 f[i-1][j]

  • 替换 / 不操作对应 f[i-1][j-1]

这说明当前最优解可以由更小子问题的最优解推出来,满足动态规划的最优子结构。

因此,这个状态转移是完全正确的。


复杂度分析

时间复杂度

我们需要填满整个二维数组 f,大小为:

(n + 1) * (m + 1)

所以时间复杂度是:

O(n * m)

空间复杂度

二维数组 f 的大小同样是:

O(n * m)

所以空间复杂度也是:

O(n * m)

如果进一步优化,还可以压缩到一维 DP,但二维写法是最经典、最清晰的基础版本。


这道题真正想考什么?

这道题表面上是在做字符串编辑,但本质上考察的是字符串 DP 的基本功。

第一,是否能正确定义“前缀状态”

字符串 DP 里非常常见的套路就是:

先把问题变成前缀和前缀之间的关系。

如果状态定义不清楚,后面的转移就很容易乱。

第二,是否能从“最后一步操作”反推状态转移

编辑距离之所以经典,就是因为它非常适合用“最后一步是什么操作”来拆解问题。

很多动态规划题的关键都在于这一点:

  • 不要直接想完整过程

  • 要想最后一步怎么来的

第三,是否能处理好边界条件

这题里最容易出错的地方往往不是公式本身,而是:

  • 空串怎么处理

  • 下标和长度怎么对应

  • i - 1j - 1 会不会越界

所以这道题特别能检验代码细节是否扎实。


和最长公共子序列有什么区别?

这两题都属于字符串 DP,形式上有点像,但本质目标不同。

编辑距离

求的是:

把一个字符串变成另一个字符串的最少操作数

允许插入、删除、替换。

最长公共子序列

求的是:

两个字符串的最长公共子序列长度

重点是匹配,不是编辑。

所以虽然它们都用二维 DP,但状态含义和转移逻辑完全不同。

编辑距离更像是“操作成本型 DP”。


总结

这道题的核心思想可以概括成一句话:

f[i][j] 表示两个字符串前缀之间的最小编辑距离,再根据插入、删除、替换三种操作进行状态转移。

具体步骤就是:

  1. 定义状态:

f[i][j] = word1 前 i 个字符转换成 word2 前 j 个字符的最少操作数
  1. 初始化边界:

f[0][j] = j
f[i][0] = i
  1. 对于普通位置,取三种操作中的最小值:

插入:f[i][j-1] + 1
删除:f[i-1][j] + 1
替换/不操作:f[i-1][j-1] + cost
  1. 最终答案就是:

f[n][m]

这样就能在:

O(n * m)

的时间复杂度和:

O(n * m)

的空间复杂度下解决问题。

这是一道非常经典、也非常值得反复理解的字符串动态规划题。 因为它会让你真正体会到:

  • 前缀状态的定义方式

  • 最后一步分析法如何用于字符串问题

  • 二维 DP 表到底在记录什么

  • 为什么边界初始化在字符串 DP 中如此重要

把这道题真正吃透之后,再去看:

  • 不同的子序列

  • 最长公共子序列

  • 删除操作

  • 正则表达式匹配

这些字符串 DP 题时,你会明显更容易抓住套路。

因为“编辑距离”本身就是字符串动态规划里最经典的一道模板题。

如果对您有帮助的话,能否支持一下博主?
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇