Levenshtein Distance(編輯距離)演算法與使用場景
阿新 • • 發佈:2020-03-09
## 前提
已經很久沒深入研究過演算法相關的東西,畢竟日常少用,就算死記硬背也是沒有實施場景導致容易淡忘。最近在做一個脫敏資料和明文資料匹配的需求的時候,用到了一個演算法叫`Levenshtein Distance Algorithm`,本文對此演算法原理做簡單的分析,並且用此演算法解決幾個常見的場景。
## 什麼是Levenshtein Distance
`Levenshtein Distance`,一般稱為編輯距離(`Edit Distance`,`Levenshtein Distance`只是編輯距離的其中一種)或者萊文斯坦距離,演算法概念是俄羅斯科學家弗拉基米爾·萊文斯坦(Levenshtein · Vladimir I)在1965年提出。此演算法的概念很簡單:`Levenshtein Distance`指**兩個字串之間,由一個轉換成另一個所需的最少編輯操作次數**,允許的編輯操作包括:
- 將其中一個字元替換成另一個字元(`Substitutions`)。
- 插入一個字元(`Insertions`)。
- 刪除一個字元(`Deletions`)。
> 下文開始簡稱`Levenshtein Distance`為`LD`
### Levenshtein Distance公式定義
![](https://img2020.cnblogs.com/blog/1412331/202003/1412331-20200308213419929-2005882212.png)
這個數學公式最終得出的數值就是`LD`的值。舉個例子:
將`kitten`這個單詞轉成`sitting`的`LD`值為3:
1. kitten → sitten (k→s)
2. sitten → sittin (e→i)
3. sittin → sitting (insert a 'g')
### Levenshtein Distance動態規劃方法
可以使用動態規劃的方法去測量`LD`的值,步驟大致如下:
- 初始化一個`LD`矩陣`(M,N)`,`M`和`N`分別是兩個輸入字串的長度。
- 矩陣可以從左上角到右下角進行填充,每個水平或垂直跳轉分別對應於一個插入或一個刪除。
- 通過定義每個操作的成本為1,如果兩個字串不匹配,則對角跳轉的代價為1,否則為0,簡單來說就是:
- 如果`[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`。
**例子一:**
初始化`LD`矩陣`(3,3)`:
|||`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`。
|||`s`|`o`|`n`|
|:-:|:-:|:-:|:-:|:-:|
||`0`|`1`|`2`|`3`|
|`s`|`1`|0 |||
|`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|1 |
那麼`son`和`sun`的`LD`值為1 。
**例子二:**
初始化`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`值為1 。
## Levenshtein Distance演算法實現
依據前面提到的動態規劃方法,可以相對簡單地實現`LD`的演算法,這裡選用`Java`語言進行實現:
```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演算法的實現](https://github.com/tdebatty/java-string-similarity#levenshtein)。以前面的例子執行一下:
```java
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
```
## Levenshtein Distance演算法一些使用場景
`LD`演算法主要的應用場景有:
- `DNA`分析。
- 拼寫檢查。
- 語音識別。
- 抄襲偵測。
- 等等......
其實主要就是"字串"匹配場景,這裡基於實際遇到的場景舉例。
### 脫敏資料和明文資料匹配
最近有場景做脫敏資料和明文資料匹配,有時候第三方匯出的檔案是脫敏檔案,格式如下:
|姓名|手機號|身份證|
|:-:|:-:|:-:|
|`張*狗`|`123****8910`|`123456****8765****`|
己方有明文資料如下:
|姓名|手機號|身份證|
|:-:|:-:|:-:|
|`張大狗`|`12345678910`|`123456789987654321`|
要把兩份資料進行匹配,得出上面兩條資料對應的是同一個人的資料,原理就是:當且僅當兩條資料中手機號的`LD`值為4,身份證的`LD`值為8,姓名的`LD`值為1,則兩條資料完全匹配。
使用前面寫過的演算法:
```java
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`值比較小)的單詞進行提示(實際上為了滿足效率有可能並不是這樣實現的)。舉個例子:
```java
public static void main(String[] args) throws Exception {
String target = "throwab";
// 模擬一個單詞庫
List words = Lists.newArrayList();
words.add("throwable");
words.add("their");
words.add("the");
Map 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`。
### 抄襲偵測
抄襲偵測的本質也是字串的匹配,可以簡單認為匹配度高於某一個閾值就是屬於抄襲。例如《我是一隻小小鳥》裡面的一句歌詞是:
> 我是一隻小小小小鳥,想要飛呀飛卻飛也飛不高
假設筆者創作了一句歌詞:
> 我是一條小小小小狗,想要睡呀睡卻睡也睡不夠
我們可以嘗試找出兩句詞的匹配度:
```java
System.out.println(LevenshteinDistance.X.mr("我是一隻小小小小鳥,想要飛呀飛卻飛也飛不高", "我是一條小小小小狗,想要睡呀睡卻睡也睡不夠"));
// 輸出如下
0.67
```
可以認為筆者創作的歌詞是完全抄襲的。當然,對於大文字的抄襲偵測(如論文查重等等)需要考慮執行效率的問題,解決的思路應該是類似的,但是需要考慮如何分詞、大小寫等等各種的問題。
## 小結
本文僅僅對`Levenshtein Distance`做了一點皮毛上的分析並且列舉了一些簡單的場景,其實此演算法在日常生活中是十分常見的,筆者猜測詞典應用的單詞拼寫檢查、論文查重(抄襲判別)都可能和此演算法相關。演算法雖然學習曲線比較陡峭,但是它確實是一把解決問題的利刃。
參考資料:
- [維基百科 - Levenshtein distance](https://en.wikipedia.org/wiki/Levenshtein_distance)
- [java-string-similarity](https://github.com/tdebatty/java-string-similarity)
- [The Levenshtein Algorithm](https://www.cuelogic.com/blog/the-levenshtein-al