已经好久没深刻研究过算法相关的东西,毕竟平常少用,就算死记硬背也是没有实施场景致使容易淡忘。最近在作一个脱敏数据和明文数据匹配的需求的时候,用到了一个算法叫Levenshtein Distance Algorithm
,本文对此算法原理作简单的分析,而且用此算法解决几个常见的场景。java
Levenshtein Distance
,通常称为编辑距离(Edit Distance
,Levenshtein Distance
只是编辑距离的其中一种)或者莱文斯坦距离,算法概念是俄罗斯科学家弗拉基米尔·莱文斯坦(Levenshtein · Vladimir I)在1965年提出。此算法的概念很简单:Levenshtein Distance
指两个字串之间,由一个转换成另外一个所需的最少编辑操做次数,容许的编辑操做包括:git
Substitutions
)。Insertions
)。Deletions
)。下文开始简称
Levenshtein Distance
为LD
github
这个数学公式最终得出的数值就是LD
的值。举个例子:算法
将kitten
这个单词转成sitting
的LD
值为3:数组
可使用动态规划的方法去测量LD
的值,步骤大体以下:ide
LD
矩阵(M,N)
,M
和N
分别是两个输入字符串的长度。[i][j]
位置的两个字符串相等,则从[i][j]
位置左加1,上加1,左上加0,而后从这三个数中取出最小的值填充到[i][j]
。[i][j]
位置的两个字符串
[i][j]
位置左、左上、上三个位置的值中取最小值,这个最小值加1(或者说这三个值都加1而后取最小值),而后填充到[i][j]
。LD
矩阵(M,N)
填充完毕后,最终矩阵右下角的数字就是两个字符串的LD
值。这里不打算证实上面动态规划的结论(也就是默认这个动态规划的结果是正确的),直接举两个例子说明这个问题:学习
son
和sun
。doge
和dog
。例子一:code
初始化LD
矩阵(3,3)
:orm
s |
o |
n |
||
---|---|---|---|---|
0 |
1 |
2 |
3 |
|
s |
1 |
|||
u |
2 |
|||
n |
3 |
计算[0][0]
的位置的值,由于's' = 's'
,因此[0][0]的值 = min(1+1, 1+1, 0+0) = 0
。blog
s |
o |
n |
||
---|---|---|---|---|
0 |
1 |
2 |
3 |
|
s |
1 |
|
||
u |
2 |
|||
n |
3 |
按照这个规则计算其余位置的值,填充完毕后的LD
矩阵`以下:
s |
o |
n |
||
---|---|---|---|---|
0 |
1 |
2 |
3 |
|
s |
1 |
0 | 1 | 2 |
u |
2 |
1 | 1 | 2 |
n |
3 |
2 | 2 |
|
那么son
和sun
的LD
值为
例子二:
初始化LD
矩阵(4,3)
:
d |
o |
g |
||
---|---|---|---|---|
0 |
1 |
2 |
3 |
|
d |
1 |
|||
o |
2 |
|||
g |
3 |
|||
e |
4 |
接着填充矩阵:
d |
o |
g |
||
---|---|---|---|---|
0 |
1 |
2 |
3 |
|
d |
1 |
0 |
1 |
2 |
o |
2 |
1 |
0 |
1 |
g |
3 |
2 |
1 |
0 |
e |
4 |
3 |
2 |
1 |
那么doge
和dog
的LD
值为
依据前面提到的动态规划方法,能够相对简单地实现LD
的算法,这里选用Java
语言进行实现:
public enum LevenshteinDistance { // 单例 X; /** * 计算Levenshtein Distance */ public int ld(String source, String target) { Optional.ofNullable(source).orElseThrow(() -> new IllegalArgumentException("source")); Optional.ofNullable(target).orElseThrow(() -> new IllegalArgumentException("target")); int sl = source.length(); int tl = target.length(); // 定义矩阵,行列都要加1 int[][] matrix = new int[sl + 1][tl + 1]; // 首行首列赋值 for (int k = 0; k <= sl; k++) { matrix[k][0] = k; } for (int k = 0; k <= tl; k++) { matrix[0][k] = k; } // 定义临时的编辑消耗 int cost; for (int i = 1; i <= sl; i++) { for (int j = 1; j <= tl; j++) { if (source.charAt(i - 1) == target.charAt(j - 1)) { cost = 0; } else { cost = 1; } matrix[i][j] = min( // 左上 matrix[i - 1][j - 1] + cost, // 右上 matrix[i][j - 1] + 1, // 左边 matrix[i - 1][j] + 1 ); } } return matrix[sl][tl]; } private int min(int x, int y, int z) { return Math.min(x, Math.min(y, z)); } /** * 计算匹配度match rate */ public BigDecimal mr(String source, String target) { int ld = ld(source, target); // 1 - ld / max(len1,len2) return BigDecimal.ONE.subtract(BigDecimal.valueOf(ld) .divide(BigDecimal.valueOf(Math.max(source.length(), target.length())), 2, BigDecimal.ROUND_HALF_UP)); } }
算法的复杂度为O(N * M)
,其中N
和M
分别是两个输入字符串的长度。这里的算法实现彻底参照前面的动态规划方法推论过程,实际上不必定须要定义二维数组(矩阵),使用两个一维的数组便可,能够参看一下java-string-similarity中Levenshtein算法的实现。之前面的例子运行一下:
public static void main(String[] args) throws Exception { String s = "doge"; String t = "dog"; System.out.println("Levenshtein Distance:" +LevenshteinDistance.X.ld(s, t)); System.out.println("Match Rate:" +LevenshteinDistance.X.mr(s, t)); } // 输出 Levenshtein Distance:1 Match Rate:0.75
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
能够认为笔者创做的歌词是彻底抄袭的。固然,对于大文本的抄袭侦测(如论文查重等等)须要考虑执行效率的问题,解决的思路应该是相似的,可是须要考虑如何分词、大小写等等各类的问题。
本文仅仅对Levenshtein Distance
作了一点皮毛上的分析而且列举了一些简单的场景,其实此算法在平常生活中是十分常见的,笔者猜想词典应用的单词拼写检查、论文查重(抄袭判别)均可能和此算法相关。算法虽然学习曲线比较陡峭,可是它确实是一把解决问题的利刃。
参考资料: