【动态规划】编辑距离
编辑距离
编辑距离(Minimum Edit Distance,MED),由俄罗斯科学家 Vladimir Levenshtein 在1965年提出,也因此而得名 Levenshtein Distance(莱文斯坦距离)。
在信息论、语言学和计算机科学领域,Levenshtein Distance 是用来度量两个序列相似程度的指标。通俗地来讲,编辑距离指的是在两个单词之间,由其中一个单词转换为另一个单词所需要的最少单字符编辑操作次数。
在这里定义的单字符编辑操作有且仅有三种:
- 插入(Insertion)
- 删除(Deletion)
- 替换(Substitution)
例如,"kitten" 和 "sitting" 这两个单词,由 "kitten" 转换为 "sitting" 需要的最少单字符编辑操作有:
- kitten → sitten (substitution of "s" for "k")
- sitten → sittin (substitution of "i" for "e")
- sittin → sitting (insertion of "g" at the end)
因此,"kitten" 和 "sitting" 这两个单词之间的编辑距离为 3 。
力扣上涉及的题目如下:
序号 | 题目 |
---|---|
1 | 72. 编辑距离 |
2 | 712. 两个字符串的最小ASCII删除和 |
定义
设 \(dp[i][j]\) 表示字符串 \(a[:i]\)、\(b[:j]\) 的莱文斯坦距离,假设,\(m\), \(n\)分别表示字符串 a、b 的长度,那么:
初始条件
当字符串 a、b 其中一个字符串为空字符时,需要编辑的次数就是不为零的字符串的长度,即:
状态转移
对于任意一个字符串,我们都可以对其进行三种操作:
- 插入一个字符;
- 删除一个字符;
- 替换一个字符。
由于在 \(a\) 中删除一个字符,与在 \(b\) 中插入一个字符是等价的,所以,我们只需要以其中一个字符串为基准,计算一次就行了。
假设两个字符串分别为:a = "applrviz"
,b = "horsez"
,同时假设,我们已经知道其中的一个状态为 \(dp[i - 1][j - 1]\) ,即:
也就是说,字符串 \(a[:i-1]\) 与字符串 \(b[:j-1]\) 经过 \(dp[i - 1][j - 1]\) 次编辑操作就相等了。
那么,对于下一个状态,字符 \(a[i]\) 和 \(b[j]\) 存在两种场景:
-
场景一:\(a[i] = b[j]\)
如果字符 \(a[i]\) 和 \(b[j]\) 相等,那么,这时,不需要做任何操作,即\(dp[i][j] = dp[i - 1][j - 1]\)。
-
场景二: \(a[i] \ne b[j]\)
通过下面三种编辑,可以使 \(a[:i-1]\) 和 \(b[:j-1]\) 相等:
-
删除操作
删除字符串 \(a\) 中的字符 \(l\) ,\(a[i]\) 与 \(b[j]\) 就相等了,即状态 \(dp[i][j]\) 就可以通过经过 \(dp[i][j - 1] + 1\) 转移而来:
-
插入操作
在字符串 \(a\) 中的插入字符 \(r\) ,\(a[i]\) 与 \(b[j]\) 就相等了,即状态 \(dp[i][j]\) 就可以通过经过 \(dp[i - 1][j] + 1\) 转移而来:
-
替换操作
将字符串 \(a\) 中的字符 \(l\) 替换为字符 \(r\),\(a[i]\) 与 \(b[j]\) 就相等了,即状态 \(dp[i][j]\) 就可以通过经过 \(dp[i - 1][j - 1] + 1\) 转移而来:
因此,当字符 \(a[i] \ne b[j]\) 时,只需要取上述三种情况的最小值即可,即
-
所以,综上,对于上面两种场景,可以得到状态转移方程:
代码实现
递归实现
def LevenshteinDistance(str1: str, str2: str):
if len(str1) == 0:
return len(str2)
elif len(str2) == 0:
return len(str1)
elif str1 == str2:
return 0
# 如果两个字符的最后一个字符相等
if str1[len(str1) - 1] == str2[len(str2) - 1]:
return LevenshteinDistance(str1[:-1], str2[:-1])
else:
return min(
LevenshteinDistance(str1, str2[:-1]) + 1,
LevenshteinDistance(str1[:-1], str2) + 1,
LevenshteinDistance(str1[:-1], str2[:-1]) + 1
)
迭代实现
def LevenshteinDistance(str1: str, str2: str):
m, n = len(str1), len(str2)
if m * n == 0:
return m + n
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(m + 1):
dp[i][0] = i
for i in range(n + 1):
dp[0][i] = i
for i in range(1, m + 1):
for j in range(1, n + 1):
if str1[i - 1] == str2[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
)
return dp[m][n]
应用
应用1:Leetcode 72. 编辑距离
题目
解题思路
参考前面的分析。
代码实现
class Solution {
public int minDistance(String word1, String word2) {
if (word1.length() == 0) {
return word2.length();
}
if (word2.length() == 0) {
return word1.length();
}
return editDistance(word1, word2);
}
private int editDistance(String a, String b) {
// 设dp[i][j]表示使字符串 a[0...i] 和字符串 b[0...j] 相同的最少编辑次数
int [][] dp = new int[a.length() + 1][b.length() + 1];
for (int i = 0; i <= a.length(); i++) {
dp[i][0] = i;
}
for (int j = 0; j <= b.length(); j++) {
dp[0][j] = j;
}
for (int i = 1; i <= a.length(); i++) {
for (int j = 1; j <= b.length(); j++) {
if (a.charAt(i - 1) == b.charAt(j - 1)) {
dp[i][j] = dp[i - 1][j - 1];
} else {
dp[i][j] = Math.min(Math.min(dp[i][j - 1], dp[i - 1][j]), dp[i - 1][j - 1]) + 1;
}
}
}
return dp[a.length()][b.length()];
}
}
应用2:Leetcode 712. 两个字符串的最小ASCII删除和
题目
解题思路
设 \(dp[i][j]\) 表示将两个字符串 \(a[0 \cdots i - 1]\)、\(b[0 \cdots j - 1]\) 相同所需要删除的字符的 ASCII 值的最小和,其中,\(m\)、 \(n\) 分别表示字符串 a、b 的长度,那么:
注意:
- 边界条件是从 \(0\) 开始的,所以需要长度为 \(n + 1\) 的数组记录所有状态,即状态 \(dp[1][1]\) 对应子串 \(a[0]\) 和 \(b[0]\) ;
- Python中求字符的 ASCII 码:
ord(char:str)
,将 ASCII 码转换为字符:chr(code:int)
;
>>> ord("a")
97
>>> chr(97)
'a'
边界条件
- 当两个字符串都为空字符时,所需要删除的字符的 ASCII 值的和为零,即
- 当其中一个字符串不为空时,所需要删除的字符,就是不为空的字符串的所有字符,即
例如,当字符串 \(b\) 长度为零时,\(dp[i][0]\) 的上一个状态为 \(dp[i - 1][0]\),需要删除当前字符 \(a[i - 1]\),其对应的ASCII码为 \(ord(a[i - 1])\) 。
状态转移方程
考虑一般场景,对于两个字符串中的任意字符 \(a[i]\) 和 \(b[j]\) 存在两种场景:
-
场景一:\(a[i] = b[j]\)
此时,它们属于公共字符,所以不需要删除这两个字符串,因此,当前状态可以由前一个状态 \(dp[i - 1][j - 1]\) 直接转移而来,即
-
场景二:\(a[i] \ne b[j]\)
此时,需要删除 \(a[i]\) 或者 \(b[j]\) ,才能使子串 \(a[:i]\) 和 \(b[:j]\) 相等,即
所以,综上,对于上面两种场景,可以得到状态转移方程:
代码实现
class Solution:
def minimumDeleteSum(self, s1: str, s2: str) -> int:
m, n = len(s1), len(s2)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(1, m + 1):
dp[i][0] = dp[i - 1][0] + ord(s1[i - 1])
for j in range(1, n + 1):
dp[0][j] = dp[0][j - 1] + ord(s2[j - 1])
for i in range(1, m + 1):
for j in range(1, n + 1):
if s1[i - 1] == s2[j - 1]:
dp[i][j] = dp[i - 1][j - 1]
else:
dp[i][j] = min(
dp[i - 1][j] + ord(s1[i - 1]),
dp[i][j - 1] + ord(s2[j - 1])
)
return dp[m][n]
总结
编辑距离是NLP基本的度量文本相似度的算法,可以作为文本相似任务的重要特征之一,其可应用于诸如拼写检查、论文查重、基因序列分析等多个方面。
但是其缺点也很明显,算法基于文本自身的结构去计算,并没有办法获取到语义层面的信息。