编辑距离.md 17.5 KB
Newer Older
L
labuladong 已提交
1 2
# 编辑距离

3 4
<p align='center'>
<a href="https://github.com/labuladong/fucking-algorithm" target="view_window"><img alt="GitHub" src="https://img.shields.io/github/stars/labuladong/fucking-algorithm?label=Stars&style=flat-square&logo=GitHub"></a>
L
labuladong 已提交
5
<a href="https://appktavsiei5995.pc.xiaoe-tech.com/index" target="_blank"><img class="my_header_icon" src="https://img.shields.io/static/v1?label=精品课程&message=查看&color=pink&style=flat"></a>
6 7 8 9
<a href="https://www.zhihu.com/people/labuladong"><img src="https://img.shields.io/badge/%E7%9F%A5%E4%B9%8E-@labuladong-000000.svg?style=flat-square&logo=Zhihu"></a>
<a href="https://space.bilibili.com/14089380"><img src="https://img.shields.io/badge/B站-@labuladong-000000.svg?style=flat-square&logo=Bilibili"></a>
</p>

L
labuladong 已提交
10 11
![](https://labuladong.github.io/algo/images/souyisou1.png)

L
labuladong 已提交
12
**通知:[数据结构精品课](https://aep.h5.xeknow.com/s/1XJHEO) 已更新到 V2.0;[第 13 期刷题打卡](https://mp.weixin.qq.com/s/eUG2OOzY3k_ZTz-CFvtv5Q) 最后一天报名!另外,建议你在我的 [网站](https://labuladong.github.io/algo/) 学习文章,体验更好。**
13 14 15



L
labuladong 已提交
16 17 18 19 20
读完本文,你不仅学会了算法套路,还可以顺便解决如下题目:

| LeetCode | 力扣 | 难度 |
| :----: | :----: | :----: |
| [72. Edit Distance](https://leetcode.com/problems/edit-distance/) | [72. 编辑距离](https://leetcode.cn/problems/edit-distance/) | 🔴
21 22 23

**-----------**

L
labuladong 已提交
24 25 26 27 28 29 30 31 32
> 本文有视频版:[编辑距离详解动态规划](https://www.bilibili.com/video/BV1uv411W73P/)

前几天看了一份鹅厂的面试题,算法部分大半是动态规划,最后一题就是写一个计算编辑距离的函数,今天就专门写一篇文章来探讨一下这个问题。

我个人很喜欢编辑距离这个问题,因为它看起来十分困难,解法却出奇得简单漂亮,而且它是少有的比较实用的算法(我承认很多算法问题都不太实用)。

力扣第 72 题「编辑距离」就是这个问题,先看下题目:

![](https://labuladong.github.io/algo/images/editDistance/title.png)
L
labuladong 已提交
33

L
labuladong 已提交
34
函数签名如下:
L
labuladong 已提交
35

L
labuladong 已提交
36 37 38
```java
int minDistance(String s1, String s2)
```
L
labuladong 已提交
39 40 41 42 43 44 45 46 47 48 49 50 51

为什么说这个问题难呢,因为显而易见,它就是难,让人手足无措,望而生畏。

为什么说它实用呢,因为前几天我就在日常生活中用到了这个算法。之前有一篇公众号文章由于疏忽,写错位了一段内容,我决定修改这部分内容让逻辑通顺。但是公众号文章最多只能修改 20 个字,且只支持增、删、替换操作(跟编辑距离问题一模一样),于是我就用算法求出了一个最优方案,只用了 16 步就完成了修改。

再比如高大上一点的应用,DNA 序列是由 A,G,C,T 组成的序列,可以类比成字符串。编辑距离可以衡量两个 DNA 序列的相似度,编辑距离越小,说明这两段 DNA 越相似,说不定这俩 DNA 的主人是远古近亲啥的。

下面言归正传,详细讲解一下编辑距离该怎么算,相信本文会让你有收获。

### 一、思路

编辑距离问题就是给我们两个字符串 `s1``s2`,只能用三种操作,让我们把 `s1` 变成 `s2`,求最少的操作数。需要明确的是,不管是把 `s1` 变成 `s2` 还是反过来,结果都是一样的,所以后文就以 `s1` 变成 `s2` 举例。

L
labuladong 已提交
52
前文 [最长公共子序列](https://labuladong.github.io/article/fname.html?fname=LCS) 说过,**解决两个字符串的动态规划问题,一般都是用两个指针 `i, j` 分别指向两个字符串的最后,然后一步步往前移动,缩小问题的规模**
L
labuladong 已提交
53

L
labuladong 已提交
54
> PS:其实让 `i, j` 从前往后移动也可以,改一下 `dp` 函数/数组的定义即可,思路是完全一样的。
L
labuladong 已提交
55

L
labuladong 已提交
56
设两个字符串分别为 `"rad"``"apple"`,为了把 `s1` 变成 `s2`,算法会这样进行:
57

L
labuladong 已提交
58 59 60
![](https://labuladong.github.io/algo/images/editDistance/edit.gif)

![](https://labuladong.github.io/algo/images/editDistance/1.jpg)
L
labuladong 已提交
61 62 63 64 65

请记住这个 GIF 过程,这样就能算出编辑距离。关键在于如何做出正确的操作,稍后会讲。

根据上面的 GIF,可以发现操作不只有三个,其实还有第四个操作,就是什么都不要做(skip)。比如这个情况:

L
labuladong 已提交
66
![](https://labuladong.github.io/algo/images/editDistance/2.jpg)
L
labuladong 已提交
67

L
labuladong 已提交
68
因为这两个字符本来就相同,为了使编辑距离最小,显然不应该对它们有任何操作,直接往前移动 `i, j` 即可。
L
labuladong 已提交
69 70 71

还有一个很容易处理的情况,就是 `j` 走完 `s2` 时,如果 `i` 还没走完 `s1`,那么只能用删除操作把 `s1` 缩短为 `s2`。比如这个情况:

L
labuladong 已提交
72
![](https://labuladong.github.io/algo/images/editDistance/3.jpg)
L
labuladong 已提交
73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96

类似的,如果 `i` 走完 `s1``j` 还没走完了 `s2`,那就只能用插入操作把 `s2` 剩下的字符全部插入 `s1`。等会会看到,这两种情况就是算法的 **base case**

下面详解一下如何将思路转换成代码,坐稳,要发车了。

### 二、代码详解

先梳理一下之前的思路:

base case 是 `i` 走完 `s1``j` 走完 `s2`,可以直接返回另一个字符串剩下的长度。

对于每对儿字符 `s1[i]``s2[j]`,可以有四种操作:

```python
if s1[i] == s2[j]:
    啥都别做skip
    i, j 同时向前移动
else:
    三选一
        插入insert
        删除delete
        替换replace
```

L
labuladong 已提交
97
有这个框架,问题就已经解决了。读者也许会问,这个「三选一」到底该怎么选择呢?很简单,全试一遍,哪个操作最后得到的编辑距离最小,就选谁。这里需要递归技巧,先看下暴力解法代码:
L
labuladong 已提交
98

L
labuladong 已提交
99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124
```java
int minDistance(String s1, String s2) {
    int m = s1.length(), n = s2.length();
    // i,j 初始化指向最后一个索引
    return dp(s1, m - 1, s2, n - 1);
}

// 定义:返回 s1[0..i] 和 s2[0..j] 的最小编辑距离
int dp(String s1, int i, String s2, int j) {
    // base case
    if (i == -1) return j + 1;
    if (j == -1) return i + 1;

    if (s1.charAt(i) == s2.charAt(j)) {
        return dp(s1, i - 1, s2, j - 1); // 啥都不做
    }
    return min(
        dp(s1, i, s2, j - 1) + 1,    // 插入
        dp(s1, i - 1, s2, j) + 1,    // 删除
        dp(s1, i - 1, s2, j - 1) + 1 // 替换
    );
}

int min(int a, int b, int c) {
    return Math.min(a, Math.min(b, c));
}
L
labuladong 已提交
125 126 127 128
```

下面来详细解释一下这段递归代码,base case 应该不用解释了,主要解释一下递归部分。

L
labuladong 已提交
129
都说递归代码的可解释性很好,这是有道理的,只要理解函数的定义,就能很清楚地理解算法的逻辑。我们这里 `dp` 函数的定义是这样的:
L
labuladong 已提交
130

L
labuladong 已提交
131 132 133
```java
// 定义:返回 s1[0..i] 和 s2[0..j] 的最小编辑距离
int dp(String s1, int i, String s2, int j) {
L
labuladong 已提交
134 135 136 137 138 139
```

**记住这个定义**之后,先来看这段代码:

```python
if s1[i] == s2[j]:
L
labuladong 已提交
140
    return dp(s1, i - 1, s2, j - 1); # 啥都不做
L
labuladong 已提交
141 142 143 144 145 146 147
# 解释:
# 本来就相等,不需要任何操作
# s1[0..i] 和 s2[0..j] 的最小编辑距离等于
# s1[0..i-1] 和 s2[0..j-1] 的最小编辑距离
# 也就是说 dp(i, j) 等于 dp(i-1, j-1)
```

L
labuladong 已提交
148
如果 `s1[i] != s2[j]`,就要对三个操作递归了,稍微需要点思考:
L
labuladong 已提交
149 150

```python
L
labuladong 已提交
151
dp(s1, i, s2, j - 1) + 1,    # 插入
L
labuladong 已提交
152 153 154 155 156 157
# 解释:
# 我直接在 s1[i] 插入一个和 s2[j] 一样的字符
# 那么 s2[j] 就被匹配了,前移 j,继续跟 i 对比
# 别忘了操作数加一
```

L
labuladong 已提交
158
![](https://labuladong.github.io/algo/images/editDistance/insert.gif)
L
labuladong 已提交
159 160

```python
L
labuladong 已提交
161
dp(s1, i - 1, s2, j) + 1,    # 删除
L
labuladong 已提交
162 163 164 165 166 167
# 解释:
# 我直接把 s[i] 这个字符删掉
# 前移 i,继续跟 j 对比
# 操作数加一
```

L
labuladong 已提交
168
![](https://labuladong.github.io/algo/images/editDistance/delete.gif)
L
labuladong 已提交
169 170

```python
L
labuladong 已提交
171
dp(s1, i - 1, s2, j - 1) + 1 # 替换
L
labuladong 已提交
172 173 174 175 176 177
# 解释:
# 我直接把 s1[i] 替换成 s2[j],这样它俩就匹配了
# 同时前移 i,j 继续对比
# 操作数加一
```

L
labuladong 已提交
178
![](https://labuladong.github.io/algo/images/editDistance/replace.gif)
L
labuladong 已提交
179 180 181

现在,你应该完全理解这段短小精悍的代码了。还有点小问题就是,这个解法是暴力解法,存在重叠子问题,需要用动态规划技巧来优化。

L
labuladong 已提交
182
**怎么能一眼看出存在重叠子问题呢**?前文 [动态规划之正则表达式](https://labuladong.github.io/article/fname.html?fname=动态规划之正则表达) 有提过,这里再简单提一下,需要抽象出本文算法的递归框架:
L
labuladong 已提交
183

L
labuladong 已提交
184 185 186 187 188 189
```java
int dp(i, j) {
    dp(i - 1, j - 1); // #1
    dp(i, j - 1);     // #2
    dp(i - 1, j);     // #3
}
L
labuladong 已提交
190 191 192 193 194 195
```

对于子问题 `dp(i-1, j-1)`,如何通过原问题 `dp(i, j)` 得到呢?有不止一条路径,比如 `dp(i, j) -> #1``dp(i, j) -> #2 -> #3`。一旦发现一条重复路径,就说明存在巨量重复路径,也就是重叠子问题。

### 三、动态规划优化

L
labuladong 已提交
196
对于重叠子问题呢,前文 [动态规划详解](https://labuladong.github.io/article/fname.html?fname=动态规划详解进阶) 详细介绍过,优化方法无非是备忘录或者 DP table。
L
labuladong 已提交
197 198 199

备忘录很好加,原来的代码稍加修改即可:

L
labuladong 已提交
200 201 202
```java
// 备忘录
int[][] memo;
L
labuladong 已提交
203
    
L
labuladong 已提交
204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236
public int minDistance(String s1, String s2) {
    int m = s1.length(), n = s2.length();
    // 备忘录初始化为特殊值,代表还未计算
    memo = new int[m][n];
    for (int[] row : memo) {
        Arrays.fill(row, -1);
    }
    return dp(s1, m - 1, s2, n - 1);
}

int dp(String s1, int i, String s2, int j) {
    if (i == -1) return j + 1;
    if (j == -1) return i + 1;
    // 查备忘录,避免重叠子问题
    if (memo[i][j] != -1) {
        return memo[i][j];
    }
    // 状态转移,结果存入备忘录
    if (s1.charAt(i) == s2.charAt(j)) {
        memo[i][j] = dp(s1, i - 1, s2, j - 1);
    } else {
        memo[i][j] =  min(
            dp(s1, i, s2, j - 1) + 1,
            dp(s1, i - 1, s2, j) + 1,
            dp(s1, i - 1, s2, j - 1) + 1
        );
    }
    return memo[i][j];
}

int min(int a, int b, int c) {
    return Math.min(a, Math.min(b, c));
}
L
labuladong 已提交
237 238 239 240
```

**主要说下 DP table 的解法**

L
labuladong 已提交
241
首先明确 `dp` 数组的含义,`dp` 数组是一个二维数组,长这样:
L
labuladong 已提交
242

L
labuladong 已提交
243
![](https://labuladong.github.io/algo/images/editDistance/dp.jpg)
L
labuladong 已提交
244

L
labuladong 已提交
245
有了之前递归解法的铺垫,应该很容易理解。`dp[..][0]``dp[0][..]` 对应 base case,`dp[i][j]` 的含义和之前的 `dp` 函数类似:
L
labuladong 已提交
246

L
labuladong 已提交
247 248 249
```java
int dp(String s1, int i, String s2, int j)
// 返回 s1[0..i] 和 s2[0..j] 的最小编辑距离
L
labuladong 已提交
250 251

dp[i-1][j-1]
L
labuladong 已提交
252
// 存储 s1[0..i] 和 s2[0..j] 的最小编辑距离
L
labuladong 已提交
253 254
```

L
labuladong 已提交
255
`dp` 函数的 base case 是 `i, j` 等于 -1,而数组索引至少是 0,所以 `dp` 数组会偏移一位。
L
labuladong 已提交
256

L
labuladong 已提交
257
既然 `dp` 数组和递归 `dp` 函数含义一样,也就可以直接套用之前的思路写代码,**唯一不同的是,DP table 是自底向上求解,递归解法是自顶向下求解**
L
labuladong 已提交
258 259 260 261

```java
int minDistance(String s1, String s2) {
    int m = s1.length(), n = s2.length();
L
labuladong 已提交
262
    // 定义:s1[0..i] 和 s2[0..j] 的最小编辑距离是 dp[i+1][j+1]
L
labuladong 已提交
263 264 265 266 267 268 269
    int[][] dp = new int[m + 1][n + 1];
    // base case 
    for (int i = 1; i <= m; i++)
        dp[i][0] = i;
    for (int j = 1; j <= n; j++)
        dp[0][j] = j;
    // 自底向上求解
L
labuladong 已提交
270 271 272
    for (int i = 1; i <= m; i++) {
        for (int j = 1; j <= n; j++) {
            if (s1.charAt(i-1) == s2.charAt(j-1)) {
L
labuladong 已提交
273
                dp[i][j] = dp[i - 1][j - 1];
L
labuladong 已提交
274
            } else {
L
labuladong 已提交
275 276 277
                dp[i][j] = min(
                    dp[i - 1][j] + 1,
                    dp[i][j - 1] + 1,
L
labuladong 已提交
278
                    dp[i - 1][j - 1] + 1
L
labuladong 已提交
279
                );
L
labuladong 已提交
280 281 282
            }
        }
    }
L
labuladong 已提交
283 284 285 286 287 288 289 290 291
    // 储存着整个 s1 和 s2 的最小编辑距离
    return dp[m][n];
}

int min(int a, int b, int c) {
    return Math.min(a, Math.min(b, c));
}
```

292
### 三、扩展延伸
L
labuladong 已提交
293 294 295

一般来说,处理两个字符串的动态规划问题,都是按本文的思路处理,建立 DP table。为什么呢,因为易于找出状态转移的关系,比如编辑距离的 DP table:

L
labuladong 已提交
296
![](https://labuladong.github.io/algo/images/editDistance/4.jpg)
L
labuladong 已提交
297

298
还有一个细节,既然每个 `dp[i][j]` 只和它附近的三个状态有关,空间复杂度是可以压缩成 `O(min(M, N))` 的(M,N 是两个字符串的长度)。不难,但是可解释性大大降低,读者可以自己尝试优化一下。
L
labuladong 已提交
299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321

你可能还会问,**这里只求出了最小的编辑距离,那具体的操作是什么**?你之前举的修改公众号文章的例子,只有一个最小编辑距离肯定不够,还得知道具体怎么修改才行。

这个其实很简单,代码稍加修改,给 dp 数组增加额外的信息即可:

```java
// int[][] dp;
Node[][] dp;

class Node {
    int val;
    int choice;
    // 0 代表啥都不做
    // 1 代表插入
    // 2 代表删除
    // 3 代表替换
}
```

`val` 属性就是之前的 dp 数组的数值,`choice` 属性代表操作。在做最优选择时,顺便把操作记录下来,然后就从结果反推具体操作。

我们的最终结果不是 `dp[m][n]` 吗,这里的 `val` 存着最小编辑距离,`choice` 存着最后一个操作,比如说是插入操作,那么就可以左移一格:

L
labuladong 已提交
322
![](https://labuladong.github.io/algo/images/editDistance/5.jpg)
L
labuladong 已提交
323 324 325

重复此过程,可以一步步回到起点 `dp[0][0]`,形成一条路径,按这条路径上的操作进行编辑,就是最佳方案。

L
labuladong 已提交
326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353
![](https://labuladong.github.io/algo/images/editDistance/6.jpg)



<hr>
<details>
<summary><strong>引用本文的文章</strong></summary>

 - [动态规划之子序列问题解题模板](https://labuladong.github.io/article/fname.html?fname=子序列问题模板)
 - [动态规划和回溯算法到底谁是谁爹?](https://labuladong.github.io/article/fname.html?fname=targetSum)
 - [最优子结构原理和 dp 数组遍历方向](https://labuladong.github.io/article/fname.html?fname=最优子结构)

</details><hr>




<hr>
<details>
<summary><strong>引用本文的题目</strong></summary>

<strong>安装 [我的 Chrome 刷题插件](https://mp.weixin.qq.com/s/X-fE9sR4BLi6T9pn7xP4pg) 点开下列题目可直接查看解题思路:</strong>

| LeetCode | 力扣 |
| :----: | :----: |
| [97. Interleaving String](https://leetcode.com/problems/interleaving-string/?show=1) | [97. 交错字符串](https://leetcode.cn/problems/interleaving-string/?show=1) |

</details>
L
labuladong 已提交
354

L
labuladong 已提交
355

J
jasper 已提交
356

357
**_____________**
358

L
labuladong 已提交
359
**《labuladong 的算法小抄》已经出版,关注公众号查看详情;后台回复关键词「**进群**」可加入算法群;回复「**全家桶**」可下载配套 PDF 和刷题全家桶**
L
labuladong 已提交
360 361

![](https://labuladong.github.io/algo/images/souyisou2.png)
L
labuladong 已提交
362 363


364
======其他语言代码======
C
Chenjie Xu 已提交
365

B
brucecat 已提交
366 367 368 369 370
### python

[ChenjieXu](https://github.com/ChenjieXu) 提供Python版本[72.编辑距离](https://leetcode-cn.com/problems/edit-distance)代码:  

```python
C
Chenjie Xu 已提交
371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393
def minDistance(word1, word2):
    m, n = len(word1), len(word2)
    # 创建 DP 数组
    dp = [[0] * (n + 1) for _ in range(m + 1)]

    # base case初始化
    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 word1[i - 1] == word2[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)
    # 储存着整个 word1 和 word2 的最小编辑距离
    return dp[m][n]
B
brucecat 已提交
394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482
````



### javascript

[SCUHZS](https://github.com/brucecat)提供[72.编辑距离](https://leetcode-cn.com/problems/edit-distance)

```javascript
let minDistance = function (s1, s2) {
    let m = s1.length, n = s2.length;

    // 初始化一个 (m+1) * (n+1)大小的数组
    let dp = new Array(m + 1);
    for (let i = 0; i < m + 1; i++) {
        dp[i] = new Array(n + 1).fill(0)
    }
    for (let i = 1; i <= m; i++) {
        dp[i][0] = i;
    }
    for (let j = 1; j <= n; j++)
        dp[0][j] = j;

    // 自底向上求解
    for (let i = 1; i <= m; i++) {
        for (let j = 1; j <= n; j++) {
            if (s1[i - 1] === s2[j - 1])
                dp[i][j] = dp[i - 1][j - 1]
            else
                dp[i][j] = Math.min(
                    dp[i - 1][j] + 1,  //  删除
                    dp[i][j - 1] + 1,       // 插入
                    dp[i - 1][j - 1] + 1     // 替换
                )
        }
    }
    // 储存着整个 s1 和 s2 的最小编辑距离
    return dp[m][n];
};
```

上面的代码还可以进行状态压缩优化,我们还需要一个额外的变量 pre 来时刻保存 (i-1,j-1) 的值。推导公式就可以从二维的:

```
dp[i][j] = min(dp[i-1][j] , dp[i-1][j-1] , dp[i][j-1]) + 1
```

转化为一维的:

```
dp[i] = min(dp[i-1], pre, dp[i]) + 1
```

完整代码如下:

```js
let minDistance = function (word1, word2) {
    let m = word1.length, n = word2.length;

    // 初始化一个数组
    let dp = new Array(Math.max(m,n) + 1)

    // dp[0...n]的初始值
    for (let j = 0; j <= n; j++)
        dp[j] = j;

    // dp[j] = min(dp[j-1], pre, dp[j]) + 1
    for (let i = 1; i <= m; i++) {
        let temp = dp[0];
        // 相当于初始化
        dp[0] = i;
        for (let j = 1; j <= n; j++) {
            // pre 相当于之前的 dp[i-1][j-1]
            let pre = temp;
            temp = dp[j];
            // 如果 word1[i] 与 word2[j] 相等。第 i 个字符对应下标是 i-1
            if (word1[i - 1] === word2[j - 1]) {
                dp[j] = pre;
            } else {
                dp[j] = Math.min(Math.min(dp[j - 1], pre), dp[j]) + 1;
            }
        }
    }
    
    return dp[n];
};

```