Levenshtein Distance(編輯距離)算法與使用場景

前提

已經好久沒深刻研究過算法相關的東西,畢竟平常少用,就算死記硬背也是沒有實施場景緻使容易淡忘。最近在作一個脫敏數據和明文數據匹配的需求的時候,用到了一個算法叫Levenshtein Distance Algorithm,本文對此算法原理作簡單的分析,而且用此算法解決幾個常見的場景。java

什麼是Levenshtein Distance

Levenshtein Distance,通常稱爲編輯距離(Edit DistanceLevenshtein Distance只是編輯距離的其中一種)或者萊文斯坦距離,算法概念是俄羅斯科學家弗拉基米爾·萊文斯坦(Levenshtein · Vladimir I)在1965年提出。此算法的概念很簡單:Levenshtein Distance兩個字串之間,由一個轉換成另外一個所需的最少編輯操做次數,容許的編輯操做包括:git

  • 將其中一個字符替換成另外一個字符(Substitutions)。
  • 插入一個字符(Insertions)。
  • 刪除一個字符(Deletions)。

下文開始簡稱Levenshtein DistanceLDgithub

Levenshtein Distance公式定義

這個數學公式最終得出的數值就是LD的值。舉個例子:算法

kitten這個單詞轉成sittingLD值爲3:數組

  1. kitten → sitten (k→s)
  2. sitten → sittin (e→i)
  3. sittin → sitting (insert a 'g')

Levenshtein Distance動態規劃方法

可使用動態規劃的方法去測量LD的值,步驟大體以下:ide

  • 初始化一個LD矩陣(M,N)MN分別是兩個輸入字符串的長度。
  • 矩陣能夠從左上角到右下角進行填充,每一個水平或垂直跳轉分別對應於一個插入或一個刪除。
  • 經過定義每一個操做的成本爲1,若是兩個字符串不匹配,則對角跳轉的代價爲1,不然爲0,簡單來講就是:
    • 若是[i][j]位置的兩個字符串相等,則從[i][j]位置左加1,上加1,左上加0,而後從這三個數中取出最小的值填充到[i][j]
    • 若是[i][j]位置的兩個字符串 相等,則從[i][j]位置左、左上、上三個位置的值中取最小值,這個最小值加1(或者說這三個值都加1而後取最小值),而後填充到[i][j]
  • 按照上面規則LD矩陣(M,N)填充完畢後,最終矩陣右下角的數字就是兩個字符串的LD值。

這裏不打算證實上面動態規劃的結論(也就是默認這個動態規劃的結果是正確的),直接舉兩個例子說明這個問題:學習

  • 例子一(兩個等長字符串):sonsun
  • 例子二(兩個非等長字符串):dogedog

例子一: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) = 0blog

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

那麼sonsunLD值爲 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

那麼dogedogLD值爲 1

Levenshtein Distance算法實現

依據前面提到的動態規劃方法,能夠相對簡單地實現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),其中NM分別是兩個輸入字符串的長度。這裏的算法實現徹底參照前面的動態規劃方法推論過程,實際上不必定須要定義二維數組(矩陣),使用兩個一維的數組便可,能夠參看一下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

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

能夠認爲筆者創做的歌詞是徹底抄襲的。固然,對於大文本的抄襲偵測(如論文查重等等)須要考慮執行效率的問題,解決的思路應該是相似的,可是須要考慮如何分詞、大小寫等等各類的問題。

小結

本文僅僅對Levenshtein Distance作了一點皮毛上的分析而且列舉了一些簡單的場景,其實此算法在平常生活中是十分常見的,筆者猜想詞典應用的單詞拼寫檢查、論文查重(抄襲判別)均可能和此算法相關。算法雖然學習曲線比較陡峭,可是它確實是一把解決問題的利刃。

參考資料:

相關文章
相關標籤/搜索