最小编辑距离算法 Edit Distance(经典DP)
展开
编辑距离(Edit Distance),又称Levenshtein距离,是指两个字串之间,由一个转成另一个所需的最少编辑操作次数。许可的编辑操作包括将一个字符替换成另一个字符,插入一个字符,删除一个字符。一般来说,编辑距离越小,两个串的相似度越大。
最小编辑距离模板:
int dp[1005][1005]; /*dp[i][j]表示表示A串从第0个字符开始到第i个字符和B串从第0个
字符开始到第j个字符,这两个字串的编辑距离。字符串的下标从1开始。*/
char a[1005],b[1005]; //a,b字符串从下标1开始
int EditDis()
{
int len1 = strlen(a+1);
int len2 = strlen(b+1);
//初始化
for(int i=1;i<=len1;i++)
for(int j=1;j<=len2;j++)
dp[i][j] = INF;
for(int i=1;i<=len1;i++)
dp[i][0] = i;
for(int j=1;j<=len2;j++)
dp[0][j] = j;
for(int i=1;i<=len1;i++)
{
for(int j=1;j<=len2;j++)
{
int flag;
if(a[i]==b[j])
flag=0;
else
flag=1;
dp[i][j]=min(dp[i-1][j]+1,min(dp[i][j-1]+1,dp[i-1][j-1]+flag));
//dp[i-1][j]+1表示删掉字符串a最后一个字符a[i]
//dp[i][j-1]+1表示给字符串添加b最后一个字符
//dp[i-1][j-1]+flag表示改变,相同则不需操作次数,不同则需要,用flag记录
}
}
return dp[len1][len2];
}
概念
字符串的编辑距离,又称为Levenshtein距离,由俄罗斯的数学家Vladimir Levenshtein在1965年提出。是指利用字符操作,把字符串A转换成字符串B所需要的最少操作数。其中,字符操作包括:
- 删除一个字符 a) Insert a character
- 插入一个字符 b) Delete a character
- 修改一个字符 c) Replace a character
例如对于字符串"if"和"iff",可以通过插入一个'f'或者删除一个'f'来达到目的。
一般来说,两个字符串的编辑距离越小,则它们越相似。如果两个字符串相等,则它们的编辑距离(为了方便,本文后续出现的“距离”,如果没有特别说明,则默认为“编辑距离”)为0(不需要任何操作)。不难分析出,两个字符串的编辑距离肯定不超过它们的最大长度(可以通过先把短串的每一位都修改成长串对应位置的字符,然后插入长串中的剩下字符)。
问题描述
给定两个字符串A和B,求字符串A至少经过多少步字符操作变成字符串B。
问题分析
1)首先考虑A串的第一个字符
假设存在两个字符串A和B,他们的长度分别是lenA和lenB。首先考虑第一个字符,由于他们是一样的,所以只需要计算A[2...lenA]和B[2...lenB]之间的距离即可。那么如果两个字符串的第一个字符不一样怎么办?可以考虑把第一个字符变成一样的(这里假设从A串变成B串):
- 修改A串的第一个字符成B串的第一个字符,之后仅需要计算A[2...lenA]和B[2...lenB]的距离即可;
- 删除A串的第一个字符,之后仅需要计算A[2...lenA]和B[1...lenB]的距离即可;
- 把B串的第一个字符插入到A串的第一个字符之前,之后仅需要计算A[1...lenA]和B[2...lenB]的距离即可。
2)接下来考虑A串的第i个字符和B串的第j个字符。
我们这个时候不考虑A的前i-1字符和B串的第j-1个字符。如果A串的第i个字符和B串的第j个字符相等,即A[i]=B[j],则只需要计算A[i...lenA]和B[j...lenB]之间的距离即可。如果不想等,则:
- 修改A串的第i个字符成B串的第j个字符,之后仅需要计算A[i+1...lenA]和B[j+1...lenB]的距离即可;
- 删除A串的第i个字符,之后仅需要计算A[i+1...lenA]和B[j...lenB]的距离即可;
- 把B串的第j个字符插入到A串的第i个字符之前,之后仅需要计算A[i...lenA]和B[j+1...lenB]的距离即可。
写到这里,自然会想到用递归求解或者动态规划求解,由于用递归会产生很多重复解,所以用动态规划。
建动态规划方程
用edit[i][j]表示A串和B串的编辑距离。edit[i][j]表示A串从第0个字符开始到第i个字符和B串从第0个字符开始到第j个字符,这两个字串的编辑距离。字符串的下标从1开始。
dis[0][0]表示word1和word2都为空的时候,此时他们的Edit Distance为0。很明显可以得出的,dis[0][j]就是word1为空,word2长度为j的情况,此时他们的Edit Distance为j,也就是从空,添加j个字符转换成word2的最小Edit Distance为j;同理dis[i][0]就是,word1长度为i,word2为空时,word1需要删除i个字符才能转换成空,所以转换成word2的最小Edit Distance为i。
则从上面的分析,不难推导出动态规划方程:
,其中
上式中的min()函数中的三个部分,对应三种字符操作方式:
edit[i-1][j]+1相当于给word2的最后插入了word1的最后的字符,插入操作使得edit+1,之后计算edit[i-1][j];
edit[i][j-1]+1相当于将word2的最后字符删除,删除操作edit+1,之后计算edit[i][j-1];
edit[i-1][j-1]+flag相当于通过将word2的最后一个字符替换为word1的最后一个字符。flag标记替换的有效次数。
算法分析:
也就是说,就是将一个字符串变成另外一个字符串所用的最少操作数,每次只能增加、删除或者替换一个字符。
首先我们令word1和word2分别为:michaelab和michaelxy(为了理解简单,我们假设word1和word2字符长度是一样的),dis[i][j]作为word1和word2之间的Edit Distance,我们要做的就是求出michaelx到michaely的最小steps。
首先解释下dis[i][j]:它是指word1[i]和word2[j]的Edit Distance。dis[0][0]表示word1和word2都为空的时候,此时他们的Edit Distance为0。很明显可以得出的,dis[0][j]就是word1为空,word2长度为j的情况,此时他们的Edit Distance为j,也就是从空,添加j个字符转换成word2的最小Edit Distance为j;同理dis[i][0]就是,word1长度为i,word2为空时,word1需要删除i个字符才能转换成空,所以转换成word2的最小Edit Distance为i。下面及时初始化代码:
for (int i = 0; i < row; i++) dis[i][0] = i;
for (int j = 0; j < col; j++) dis[0][j] = j;
下面来分析下题目规定的三个操作:添加,删除,替换。
假设word1[i]和word2[j](此处i = j)分别为:michaelab和michaelxy
如果b==y,
那么:dis[i][j] = dis[i-1][j-1]。
如果b!=y,
那么:添加:也就是在michaelab后面添加一个y,那么word1就变成了michaelaby,
此时 dis[i][j] = 1 + dis[i][j-1];
上式中,1代表刚刚的添加操作,添加操作后,word1变成michaelaby,word2为michaelxy。
dis[i][j-1]代表从word1[i]转换成word2[j-1]的最小Edit Distance,也就是michaelab转换成michaelx的最小
Edit Distance,由于两个字符串尾部的y==y,所以只需要将michaelab变成michaelx就可以了,而他们之间的最
小Edit Distance就是dis[i][j-1]。
删除:也就是将michaelab后面的b删除,那么word1就变成了michaela,此时dis[i][j] = 1 + dis[i-1][j];
上式中,1代表刚刚的删除操作,删除操作后,word1变成michaela,word2为michaelxy。dis[i-1][j]代表从
word[i-1]转换成word[j]的最小Edit Distance,也就是michaela转换成michaelxy的最小Edit Distance,所以
只需要将michaela变成michaelxy就可以了,而他们之间的最小Edit Distance就是dis[i-1][j]。
替换:也就是将michaelab后面的b替换成y,那么word1就变成了michaelay,此时dis[i][j] = 1 + dis[i-1][j-1];
上式中,1代表刚刚的替换操作,替换操作后,word1变成michaelay,word2为michaelxy。dis[i-1][j-1]代表从
word[i-1]转换成word[j-1]的最小Edit Distance,也即是michaelay转换成michaelxy的最小Edit Distance,由
于两个字符串尾部的y==y,所以只需要将michaela变成michaelx就可以了,而他们之间的最小Edit Distance就是
dis[i-1][j-1]。
举例:
比如要计算cafe和coffee的编辑距离。cafe→caffe→coffe→coffee
先创建一个6×8的表(cafe长度为4,coffee长度为6,各加2)
(1):
c | o | f | f | e | e | ||
c | |||||||
a | |||||||
f | |||||||
e | 表 | 1 |
接着,在如下位置填入数字(表2):
c | o | f | f | e | e | ||
0 | 1 | 2 | 3 | 4 | 5 | 6 | |
c | 1 | ||||||
a | 2 | ||||||
f | 3 | ||||||
e | 4 | 表 | 2 |
从3,3格开始,开始计算。取以下三个值的最小值:
- 如果最上方的字符等于最左方的字符,则为左上方的数字。否则为左上方的数字+1。(对于3,3来说为0)
- 左方数字+1(对于3,3格来说为2)
- 上方数字+1(对于3,3格来说为2)
因此为格3,3为0(表3)
c | o | f | f | e | e | ||
0 | 1 | 2 | 3 | 4 | 5 | 6 | |
c | 1 | 0 | |||||
a | 2 | ||||||
f | 3 | ||||||
e | 4 | 表 | 3 |
循环操作,推出下表
c | o | f | f | e | e | ||
0 | 1 | 2 | 3 | 4 | 5 | 6 | |
c | 1 | 0 | 1 | 2 | 3 | 4 | 5 |
a | 2 | 1 | 1 | 2 | 3 | 4 | 5 |
f | 3 | 2 | 2 | 1 | 2 | 3 | 4 |
e | 4 | 3 | 3 | 2 | 2 | 2 | 3 |
取右下角,得编辑距离为3
编辑距离
分解问题, 问题基础上退化一步,
想法
编辑距离算法被数据科学家广泛应用,是用作机器翻译和语音识别评价标准的基本算法。
最直观的方法是暴力检查所有可能的编辑方法,取最短的一个。所有可能的编辑方法达到指数级,但我们不需要进行这么多计算,因为我们只需要找到距离最短的序列而不是所有可能的序列。
方法一:动态规划
思路和算法
我们可以对任意一个单词进行三种操作:
- 插入一个字符;
- 删除一个字符;
- 替换一个字符。
题目给定了两个单词,设为 A
和 B
,这样我们就能够六种操作方法。
但我们可以发现,如果我们有单词 A
和单词 B
:
- 对单词
A
删除一个字符和对单词B
插入一个字符是等价的。例如当单词A
为doge
,单词B
为dog
时,我们既可以删除单词A
的最后一个字符e
,得到相同的dog
,也可以在单词B
末尾添加一个字符e
,得到相同的doge
; - 同理,对单词
B
删除一个字符和对单词A
插入一个字符也是等价的; - 对单词
A
替换一个字符和对单词B
替换一个字符是等价的。例如当单词A
为bat
,单词B
为cat
时,我们修改单词A
的第一个字母b -> c
,和修改单词B
的第一个字母c -> b
是等价的。
这样以来,本质不同的操作实际上只有三种:
- 在单词
A
中插入一个字符; - 在单词
B
中插入一个字符; - 修改单词
A
的一个字符。
这样以来,我们就可以把原问题转化为规模较小的子问题。我们用 A = horse
,B = ros
作为例子,来看一看是如何把这个问题转化为规模较小的若干子问题的。
- 在单词
A
中插入一个字符:如果我们知道horse
到ro
的编辑距离为a
,那么显然horse
到ros
的编辑距离不会超过a + 1
。这是因为我们可以在a
次操作后将horse
和ro
变为相同的字符串,只需要额外的1
次操作,在单词A
的末尾添加字符s
,就能在a + 1
次操作后将horse
和ro
变为相同的字符串; - 在单词
B
中插入一个字符:如果我们知道hors
到ros
的编辑距离为b
,那么显然horse
到ros
的编辑距离不会超过b + 1
,原因同上; - 修改单词
A
的一个字符:如果我们知道hors
到ro
的编辑距离为c
,那么显然horse
到ros
的编辑距离不会超过c + 1
,原因同上。
那么从 horse
变成 ros
的编辑距离应该为 min(a + 1, b + 1, c + 1)
。
注意:为什么我们总是在单词 A
和 B
的末尾插入或者修改字符,能不能在其它的地方进行操作呢?答案是可以的,但是我们知道,操作的顺序是不影响最终的结果的。例如对于单词 cat
,我们希望在 c
和 a
之间添加字符 d
并且将字符 t
修改为字符 b
,那么这两个操作无论为什么顺序,都会得到最终的结果 cadb
。
你可能觉得 horse
到 ro
这个问题也很难解决。但是没关系,我们可以继续用上面的方法拆分这个问题,对于这个问题拆分出来的所有子问题,我们也可以继续拆分,直到:
- 字符串
A
为空,如从 转换到ro
,显然编辑距离为字符串B
的长度,这里是2
; - 字符串
B
为空,如从horse
转换到 ,显然编辑距离为字符串A
的长度,这里是5
。
因此,我们就可以使用动态规划来解决这个问题了。我们用 D[i][j]
表示 A
的前 i
个字母和 B
的前 j
个字母之间的编辑距离。
如上所述,当我们获得 D[i][j-1]
,D[i-1][j]
和 D[i-1][j-1]
的值之后就可以计算出 D[i][j]
。
D[i][j-1]
为A
的前i
个字符和B
的前j - 1
个字符编辑距离的子问题。即对于B
的第j
个字符,我们在A
的末尾添加了一个相同的字符,那么D[i][j]
最小可以为D[i][j-1] + 1
;D[i-1][j]
为A
的前i - 1
个字符和B
的前j
个字符编辑距离的子问题。即对于A
的第i
个字符,我们在B
的末尾添加了一个相同的字符,那么D[i][j]
最小可以为D[i-1][j] + 1
;D[i-1][j-1]
为A
前i - 1
个字符和B
的前j - 1
个字符编辑距离的子问题。即对于B
的第j
个字符,我们修改A
的第i
个字符使它们相同,那么D[i][j]
最小可以为D[i-1][j-1] + 1
。特别地,如果A
的第i
个字符和B
的第j
个字符原本就相同,那么我们实际上不需要进行修改操作。在这种情况下,D[i][j]
最小可以为D[i-1][j-1]
。
那么我们可以写出如下的状态转移方程:
- 若
A
和B
的最后一个字母相同:
\begin{aligned} D[i][j] &= \min(D[i][j - 1] + 1, D[i - 1][j]+1, D[i - 1][j - 1])\\ &= 1 + \min(D[i][j - 1], D[i - 1][j], D[i - 1][j - 1] - 1) \end{aligned}D[i][j]=min(D[i][j−1]+1,D[i−1][j]+1,D[i−1][j−1])=1+min(D[i][j−1],D[i−1][j],D[i−1][j−1]−1) - 若
A
和B
的最后一个字母不同:
D[i][j] = 1 + \min(D[i][j - 1], D[i - 1][j], D[i - 1][j - 1])D[i][j]=1+min(D[i][j−1],D[i−1][j],D[i−1][j−1])
所以每一步结果都将基于上一步的计算结果,示意如下:
对于边界情况,一个空串和一个非空串的编辑距离为 D[i][0] = i
和 D[0][j] = j
,D[i][0]
相当于对 word1
执行 i
次删除操作,D[0][j]
相当于对 word1
执行 j
次插入操作。
综上我们得到了算法的全部流程。