目錄
背景:

我們在使用詞典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距離,那么顯然:
- d(x,y) = 0 當且僅當 x=y (Levenshtein距離為0 <==> 字符串相等)
- d(x,y) = d(y,x) (從x變到y的最少步數就是從y變到x的最少步數)
- 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)而levenST[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的長度。
圖解過程:
計算has和have的編輯距離:
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'->'a',‘h'->'v',‘h'->'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的編輯距離,

求出編輯距離,就可以得到兩個字符串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算法
