编辑距离的定义
编辑距离(Edit Distance)最常用的定义就是Levenstein距离,是由俄国科学家Vladimir Levenshtein于1965年提出的,所以编辑距离一般又称Levenshtein距离。它主要作用是测量两个字符串的差异化程度,表示字符串a至少要经过多少个操作才能转换为字符串b,这里的操作包括三种:增加、删除、替换。
举个例子:
(1)增加:对于字符串a:abc 和 字符串b:abcde,显然,只需要在字符串a的末尾增加字符'd'和'e'就能变成字符串b了,所以a和b的最短编辑距离为2。
(2)删除:对于字符串a:abcd 和字符串b:abc,显然,只需要在字符串a的末尾删除字符'd'就能变成字符串b了,所以a和b的最短编辑距离为1。
(3)替换:对于字符串a:abcd 和 字符串b:abce,显然,只需要把字符串a的'd'替换成'e'就可以了,此时二者的最短编辑距离是1。
一般字符串都是需要增加、删除、替换三者结合起来一起使用,因为字符串a到b可能存在多种变化的方法,而我们往往最关心的是最短的编辑距离,这样才能得出a和b的相似程度,最短编辑距离越小,表示a到b所需要的操作越少,a和b的相似度也就越高。因此,Levenstein距离的一个应用场景就是判断两个字符串的相似度,可以用在字符串的模糊搜索上面。
Levenshtein 算法原理
先从一个问题谈起:对于字符串"xyz"和"xcz",它们的最短距离是多少?我们从两个字符串的最后一个字符开始比较,它们都是'z',是相同的,我们可以不用做任何操作,此时二者的距离实际上等于"xy"和"xc"的距离,即d(xyz,xcz) = d(xy,xc)。也即是说,如果在比较的过程中,遇到了相同的字符,那么二者的距离是除了这个相同字符之外剩下字符的距离。即d(i,j) = d(i - 1,j-1)。
接着,我们把问题拓展一下,最后一个字符不相同的情况:字符串A("xyzab")和字符串B("axyzc"),问至少经过多少步操作可以把A变成B。
我们还是从两个字符串的最后一个字符来考察即'b'和'c'。显然二者不相同,那么我们有以下三种处理办法:
(1)增加:在A末尾增加一个'c',那么A变成了"xyzabc",B仍然是"axyzc",由于此时末尾字符相同了,那么就变成了比较"xyzab"和"axyz"的距离,即d(xyzab,axyzc) = d(xyzab,axyz) + 1。可以写成d(i,j) = d(i,j - 1) + 1。表示下次比较的字符串B的长度减少了1,而加1表示当前进行了一次字符的操作。
(2)删除:删除A末尾的字符'b',考察A剩下的部分与B的距离。即d(xyzab,axyzc) = d(xyza,axyzc) + 1。可以写成d(i,j) = d(i - 1,j) + 1。表示下次比较的字符串A的长度减少了1。
(3)替换:把A末尾的字符替换成'c',这样就与B的末尾字符一样了,那么接下来就要考察出了末尾'c'部分的字符,即d(xyzab,axyzc) = d(xyza,axyz) + 1。写成d(i,j) = d(i -1,j-1) + 1表示字符串A和B的长度均减少了1。
由于我们要求的是最短的编辑距离,所以我们取以上三个步骤得出的距离的最小值为最短编辑距离。由上面的步骤可得,这是一个递归的过程,因为除掉最后一个字符之后,剩下的字符串的最后一位仍然是最后一个字符,我们仍然可以按照上面的三种操作来进行,经过这样的不断递归,直到比较到第一个字符为止,递归结束。
按照以上思路,我们很容易写出下面的方程:
最短编辑距离方程
注释:该方程的第一个条件min(i,j) = 0,表示若某一字符串为空,转换成另一个字符串所需的操作次数,显然,就是另一个字符串的长度(添加length个字符就能转换)。这个条件可以看成是递归的出口条件,此时i或j减到了0。
根据以上方程,我们能快速写出递归代码,但由于递归包含了大量的重复计算,并且如果初始字符串过长,会造成递归层次过深,容易造成栈溢出的问题,所以我们这里可以用动态规划来实现。如果说递归是自顶向下的运算过程,那么动态规划就是自底向上的过程。它从i和j的最小值开始,不断地增大i和j,同时对于一个i和j都会算出当前地最短距离,因为下一个i和j的距离会与当前的有关,所以通过一个数组来保存每一步的运算结果来避免重复的计算过程,当i和j增加到最大值length时,结果也就出来了,即d[length][length]为A、B的最短编辑距离。
动态规划中,i和j的增加需要两层循环来完成,外层循环遍历i,内层循环遍历j,也即是,对于每一行,会扫描行内的每一列的元素进行运算。因此,时间复杂度为o(n²),空间复杂度为o(n²)。
图解动态规划求最短编辑距离过程
在写代码之前,为了让读者对动态规划有一个直观的感受,笔者以表格的形式,列出动态规划是如何一步步地工作的。
下面以字符串"xyzab"和"axyzc"为例来讲解。
图解
由上面可以看出,动态规划就是逐行逐列地运算,逐渐填满整个数组,最后得到结果恰好保存在数组的最后一行和最后一列的元素上。
代码实现:
//C++
/* 莱文斯坦距离(编辑距离) 动态规划状态转移实现 状态转移方程 记忆化递归最优解(局部最优子结构) */
//递归:自顶向下
//动态规划:自底向上
//LD算法事实上在实际生活中有较大的实际用处:
//脱敏数据与明文数据的匹配 错误侦测 搜索引擎的匹配推送 DNA分析生物应用 拼写检查 快速修改等
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int maxn = 1000 + 5;
int dp[maxn][maxn];
char s1[maxn];
char s2[maxn];
int main() {
cin >> s1 >> s2;
int len1 = strlen(s1);
int len2 = strlen(s2);
for (int i = 0; i <= len1; i++) {
dp[i][0] = i;
}
for (int i = 0; i <= len2; i++) {
dp[0][i] = i;
}
for (int i = 1; i <= len1; i++) {
for (int j = 1; j <= len2; j++) {
dp[i][j] = min(dp[i - 1][j] + 1, dp[i][j - 1] + 1);
dp[i][j] = min(dp[i][j], dp[i - 1][j - 1] + (s1[i - 1] != s2[j - 1]));
//删除,插入,替换
}
}
cout << dp[len1][len2] << endl;
return 0;
}
LD 在生活中的实际应用:
(上述代码块中已经标注)
Levenshtein Distance算法一些使用场景
LD
算法主要的应用场景有:
DNA
分析。- 拼写检查。
- 语音识别。
- 抄袭侦测。
- 等等......
其实主要就是"字符串"匹配场景,这里基于实际遇到的场景举例。
脱敏数据和明文数据匹配
最近有场景做脱敏数据和明文数据匹配,有时候第三方导出的文件是脱敏文件,格式如下:
姓名 | 手机号 | 身份证 |
---|---|---|
张*狗 | 123****8910 | 123456****8765**** |
己方有明文数据如下:
姓名 | 手机号 | 身份证 |
---|---|---|
张大狗 | 12345678910 | 123456789987654321 |
要把两份数据进行匹配,得出上面两条数据对应的是同一个人的数据,原理就是:当且仅当两条数据中手机号的LD
值为4,身份证的LD
值为8,姓名的LD
值为1,则两条数据完全匹配。
使用前面写过的算法:
public static void main(String[] args) throws Exception {
String sourceName = "张*狗";
String sourcePhone = "123****8910";
String sourceIdentityNo = "123456****8765****";
String targetName = "张大狗";
String targetPhone = "12345678910";
String targetIdentityNo = "123456789987654321";
boolean match = LevenshteinDistance.X.ld(sourceName, targetName) == 1 &&
LevenshteinDistance.X.ld(sourcePhone, targetPhone) == 4 &&
LevenshteinDistance.X.ld(sourceIdentityNo, targetIdentityNo) == 8;
System.out.println("是否匹配:" + match);
targetName = "张大doge";
match = LevenshteinDistance.X.ld(sourceName, targetName) == 1 &&
LevenshteinDistance.X.ld(sourcePhone, targetPhone) == 4 &&
LevenshteinDistance.X.ld(sourceIdentityNo, targetIdentityNo) == 8;
System.out.println("是否匹配:" + match);
}
// 输出结果
是否匹配:true
是否匹配:false
拼写检查
这个场景看起来比较贴近生活,也就是词典应用的拼写提示,例如输入了throwab
,就能提示出throwable
,笔者认为一个简单实现就是遍历t
开头的单词库,寻找匹配度比较高(LD
值比较小)的单词进行提示(实际上为了满足效率有可能并不是这样实现的)。举个例子:
public static void main(String[] args) throws Exception {
String target = "throwab";
// 模拟一个单词库
List<String> words = Lists.newArrayList();
words.add("throwable");
words.add("their");
words.add("the");
Map<String, BigDecimal> result = Maps.newHashMap();
words.forEach(x -> result.put(x, LevenshteinDistance.X.mr(x, target)));
System.out.println("输入值为:" + target);
result.forEach((k, v) -> System.out.println(String.format("候选值:%s,匹配度:%s", k, v)));
}
// 输出结果
输入值为:throwab
候选值:the,匹配度:0.29
候选值:throwable,匹配度:0.78
候选值:their,匹配度:0.29
这样子就可以基于输入的throwab
选取匹配度最高的throwable
。
抄袭侦测
抄袭侦测的本质也是字符串的匹配,可以简单认为匹配度高于某一个阈值就是属于抄袭。例如《我是一只小小鸟》里面的一句歌词是:
我是一只小小小小鸟,想要飞呀飞却飞也飞不高
假设笔者创作了一句歌词:
我是一条小小小小狗,想要睡呀睡却睡也睡不够
我们可以尝试找出两句词的匹配度:
System.out.println(LevenshteinDistance.X.mr("我是一只小小小小鸟,想要飞呀飞却飞也飞不高", "我是一条小小小小狗,想要睡呀睡却睡也睡不够"));
// 输出如下
0.67
可以认为笔者创作的歌词是完全抄袭的。当然,对于大文本的抄袭侦测(如论文查重等等)需要考虑执行效率的问题,解决的思路应该是类似的,但是需要考虑如何分词、大小写等等各种的问题。
2021.10.28
转载经自己整理