1. 程式人生 > >編輯距離演算法詳解:Levenshtein Distance演算法——動態規劃問題

編輯距離演算法詳解:Levenshtein Distance演算法——動態規劃問題

目錄

背景:

求編輯距離演算法:

圖解過程:

C++程式碼如下:

總結:


背景:

我們在使用詞典app時,有沒有發現即使輸錯幾個字母,app依然能給我們推薦出想要的單詞,非常智慧。它是怎麼找出我們想要的單詞的呢?這裡就需要BK樹來解決這個問題了。在使用BK樹之前我們要先明白一個概念,叫編輯距離,也叫Levenshtein距離。詞典app是怎麼判斷哪些單詞和我們輸入的單詞很相似的呢?我們需要知道兩個單詞有多像,換句話說就是兩個單詞相似度是多少。1965年,俄國科學家Vladimir Levenshtein給字串相似度做出了一個明確的定義叫做Levenshtein距離,我們通常叫它“編輯距離”。字串A到B的編輯距離是指,只用插入、刪除和替換三種操作,最少需要多少步可以把A變成B。例如,從aware到award需要一步(一次替換),從has到have則需要兩步(替換s為v和再加上e)。Levenshtein給出了編輯距離的一般求法,就是大家都非常熟悉的經典動態規劃

問題。這裡給出Levenshtein距離的性質。設d(x,y)表示字串x到y的Levenshtein距離,那麼顯然:

  1. d(x,y) = 0 當且僅當 x=y  (Levenshtein距離為0 <==> 字串相等)
  2. d(x,y) = d(y,x)     (從x變到y的最少步數就是從y變到x的最少步數)
  3. d(x,y) + d(y,z) >= d(x,z)  (從x變到z所需的步數不會超過x先變成y再變成z的步數) 最後這一個性質叫做三角形不等式。就好像一個三角形一樣,兩邊之和必然大於第三邊。

在自然語言處理中,這個概念非常重要,比如在詞典app中:如果使用者馬虎輸錯了單詞,則可以列出字典裡與它的Levenshtein距離小於某個數n的單詞,讓使用者選擇正確的那一個。n通常取到2或者3,或者更好地,取該單詞長度的1/4等等。這裡主要講編輯距離如何求?至於怎麼實現列出詞典中相似的單詞,詳見寫檢查程式設計題詳解-BK樹演算法

求編輯距離演算法:

這裡需要有動態規劃的思想,如果之前沒有聽過動態規劃演算法,請參考最少錢幣數(湊硬幣)詳解-2-動態規劃演算法(初窺)動態規劃演算法通常基於一個遞推公式及一個或多個初始狀態。 當前子問題的解將由上一次子問題的解推出。所以我們首要目標是找到某個狀態和一個地推公式。假設我們可以使用d[ x,y ]個步驟(可以使用一個二維陣列儲存這個值),表示將串x[1...i]轉換為 串y [ 1…j ]所需最少步驟數。

在最簡單的情況下,即在i=0時,也就是說串x為空,那麼對應的d[0,j] 就是x增加j個字元,即需要j步,使得x轉化為y;在j等於0時,也就是說串y為空,那麼對應的d[i,0] 就是x減少 i個字元,即需要i步,使得x轉化為y。這是需要的最少步驟數了。

然後我們再進一步,如果我們想要將x[1...i]經過最少次數的增、刪、改 操作轉換為y[1...j],可以考慮三種情況:

1)假設我們可以在最少a步內將x[1...i]轉換為y[1...j-1],這時我們只需要將x[1...i]加上y[j]就可以完成將x[1...i]轉化為y[1...j],這樣x轉換為y就需要a+1步。

2)假設我們可以在最少b步內將x[1...i-1]轉換位y[1...j],這時我們只需要將x[i]刪除就可以完成將x[1...i]轉換為y[1...j],這樣x轉換為y就需要b+1步。

3)假設我們可以在最少k步內將x[1...i-1]轉換為y[1...j-1],這時我們就需要判斷x[i]和y[j]是否相等,如果相等,那麼我們只需要k步就可以完成將x[1...i]轉換為y[1...j];如果x[i]和y[j]不相等,那麼我們需要將x[i]替換為y[j],這樣需要k+1步就可以將x[1...i]轉換為y[1...j]。

這三種情況是在前一個狀態可以以最少次數的增加,刪除或者替換操作,使得現在串x和串y只需要再做一次操作或者不做就可以完成x[1..i]到y[1..j]的轉換。最後,我們為了保證目前這個狀態(x[1..i]轉換為y[1..j])下所需的步驟最少,我們需要從上面三種情況中選擇步驟最少的一種作為將x[1...i]轉換為y[1...j]所需的最少步驟數。即min(a+1,b+1,k+eq),其中x[i]和y[j]相等,則eq=0,否則eq=1。

具體演算法步驟如下(可以結合者下邊的圖來理解):

1、構造 行數為m+1 列數為 n+1 的陣列,用來儲存完成某個字串轉換所需最少步數,將串x[1..m] 轉換到 串y[1…n] 所需要最少步數為levenST[m][n]的值;

2、初始化levenST第0行為0到n,第0列為0到m。

levenST[0][j]表示第0行第j-1列的值,這個值表示將串x[1…0]轉換為y[1..j]所需最少步數,很顯然將一個空串轉換為一個長度為j的串,只需要j次的add操作,所以levenST[0][j]的值應該是j,其他的值類似。這是最簡單的情形。

3、然後我們考慮一般的情況,如果我們想要將x[1...i]經過最少次數的增、刪、改 操作轉換為y[1...j],就需要將串x和串y的每一個字元兩兩進行比較,如果相等,則eq=0,如果不等,則eq=1。例如,我們可以從x的第一個字母x[0]開始依次和y中的字母(y[0],y[1],y[2],......y[n])進行比較,然後得出相應位置(levenST[1][j])上的最少轉換步驟數。需要考慮三種情況(也就是三個初始的狀態):

  • 1)這時levenST[i][j-1]的值a的含義就是最少a步將x[1...i]轉換為y[1...j-1],這時我們只需要將x[1...i]加上y[j]就可以完成將x[1...i]轉化為y[1...j],這樣x轉換為y就需要a+1步。
  • 2)levenST[i-1][j]的值b的含義就是在最少b步內將x[1...i-1]轉換為y[1...j],這時我們只需要將x[i]刪除就可以完成將x[1...i]轉換為y[1...j],這樣x轉換為y就需要b+1步。
  • 3)而levenS[i-1][j-1]的值k的含義就是在最少k步內將x[1...i-1]轉換為y[1...j-1],這時我們就需要判斷x[i]和y[j]是否相等,如果相等,那麼我們只需要k步就可以完成將x[1...i]轉換為y[1...j];如果x[i]和y[j]不相等,那麼我們需要將x[i]替換為y[j],這樣需要k+1步就可以將x[1...i]轉換為y[1...j]。

最後,我們為了保證目前這個狀態(x[1..i]轉換為y[1..j])下所需的步驟最少,我們需要從上面三種狀態中選擇步驟最少的一種作為將x[1...i]轉換為y[1...j]所需的最少步驟數。即min( levenST[i-1][j] + 1, levenST[i][j-1] + 1, levenST[i-1][j-1] + eq ),其中x[i]和y[j]相等,則eq=0,否則eq=1。

於是我們就可以得出遞推公式

levenST[i][j] = minOfTreeNum( levenST[i-1][j] + 1, levenST[i][j-1] + 1, levenST[i-1][j-1] + eq );

(遞推公式需要三個初始狀態,即 levenST[i-1][j], levenST[i][j-1]和 levenST[i-1][j-1] ,所以我們需要對陣列 levenST[][] 事先進行初始化,先求出最簡單的狀態下的levenshtein距離)

最後,我們將兩個字串中所有字母都遍歷對比完成之後,將x轉換為y所需最少步驟數就是levenST[m][n]。其中m為字串x的長度,n為字串y的長度。


圖解過程:

1、構造初始化二維陣列levenST[4][5]

2、從字串has第一個字母開始,依次和y中的字母(y[1...j])進行比較,然後得出相應位置(levenST[1,j])上的最少轉換步驟數。

如果兩個字母相等,則在從此位置的左+1,上+1,左上+0三個數中獲取最小的值存入;若不等,則在從此位置的左,上,左上三個位置中獲取最小的值再加上1。如下圖,首先對比字串x中第一個字母h和字串y中第一個字母h,發現兩個字母相等,所以對比左、上、左上三個位置得出最小值0存入levenST[1][1],接著依次對比‘h'\rightarrow'a',‘h'\rightarrow'v',‘h'\rightarrow'e'。得出h字串和h,ha,hav,have四個字串的編輯距離。

3、接著將字母a依次和have中字母對比,得出ha字串和h,ha,hav,have四個字串的編輯距離。

4、接著將字母s依次和have中字母h,a,v,e對比,得出has字串和h,ha,hav,have四個字串的編輯距離。

最後一個即為單詞has和have的編輯距離,

求出編輯距離,就可以得到兩個字串的相似度 Similarity = (Max(x,y) - Levenshtein)/Max(x,y),其中 x,y 為源串和目標串的長度。

x/y   h a v e
  0 1 2 3 4
h 1 0 1 2 3
a 2 1 0 1 2
s 3 2 1 1 2

C++程式碼如下:

#include <iostream>
#include <string>

using namespace std;
int minOfTreeNum(int a, int b, int c)  //返回a,b,c三個數中最小值
{
    int minNum = a;
    if(minNum > b )
    {
        minNum = b;
    }
    if(minNum > c )
    {
        minNum = c;
    }
    return minNum;
}

int levenSTDistance(string x, string y)  //計算字串x和字串y的levenshtein距離
{
    int lenx = x.length();
    int leny = y.length();
    int levenST[lenx+1][leny+1];  //申請一個二維陣列存放編輯距離
    int eq = 0;                   //存放兩個字母是否相等
    int i,j;

    //初始化二維陣列,也就是將最簡單情形的levenshtein距離寫入
    for(i=0; i <= lenx; i++)
    {
        levenST[i][0] = i;
    }
    for(j=0; j <= leny; j++)
    {
        levenST[0][j] = j;
    }

    //將串x和串y中的字母兩兩進行比較,得出相應字串的編輯距離
    for(i=1; i <= lenx; i++ )
    {
        for(j=1; j <= leny; j++)
        {
            if(x[i-1] == y[j-1])
            {
                eq = 0;
            }else{
                eq = 1;
            }
            levenST[i][j] = minOfTreeNum(levenST[i-1][j] + 1, levenST[i][j-1] + 1, levenST[i-1][j-1] + eq);
        }
    }
    return levenST[lenx][leny];
}
int main()
{
    string a,b;
    int levenDistance;
    cin >> a;
    cin >> b;
    levenDistance = levenSTDistance(a,b);
    cout << "Levenshtein Distance:" << levenDistance << endl;
    return 0;
}

總結:

動態規劃演算法通常基於一個遞推公式及一個或多個初始狀態。 當前子問題的解將由上一次子問題的解推出。關鍵是找到這個遞推公式。需要多加練習。

參考資料: (這是java版程式碼 編輯距離演算法詳解:Levenshtein Distance演算法