編輯距離,BK樹
搜尋引擎裡有一個很重要的話題,就是文字糾錯,主要有兩種做法,一是從詞典糾錯,一是分析使用者搜尋日誌,今天我們探討使用基於詞典的方式糾錯,核心思想就是基於編輯距離,使用BK樹。下面我們來逐一探討:
編輯距離
1965年,俄國科學家Vladimir
Levenshtein給字串相似度做出了一個明確的定義叫做Levenshtein距離,我們通常叫它“編輯距離”。
字串A到B的編輯距離是指,只用插入、刪除和替換三種操作,最少需要多少步可以把A變成B。例如,從FAME到GATE需要兩步(兩次替換),從GAME到ACM則需要三步(刪除G和E再新增C)。Levenshtein給出了編輯距離的一般求法,就是大家都非常熟悉的經典動態規劃問題。
class LevenshteinDistanceFunction { private final boolean isCaseSensitive; public LevenshteinDistanceFunction(boolean isCaseSensitive) { this.isCaseSensitive = isCaseSensitive; } public int distance(CharSequence left, CharSequence right) { int leftLength = left.length(), rightLength = right.length(); // special cases. if (leftLength == 0) return rightLength; if (rightLength == 0) return leftLength; // Use the iterative matrix method. int[] currentRow = new int[rightLength + 1]; int[] nextRow = new int[rightLength + 1]; // Fill first row with all edit counts. for (int i = 0; i <= rightLength; i++) currentRow[i] = i; for (int i = 1; i <= leftLength; i++) { nextRow[0] = i; for(int j = 1; j <= rightLength; j++) { int subDistance = currentRow[j - 1]; // Distance without insertions or deletions. if (!charEquals(left.charAt(i - 1), right.charAt(j - 1), isCaseSensitive)) subDistance++; // Add one edit if letters are different. nextRow[j] = Math.min(Math.min(nextRow[j - 1], currentRow[j]) + 1, subDistance); } // Swap rows, use last row for next row. int[] t = currentRow; currentRow = nextRow; nextRow = t; } return currentRow[rightLength]; } }
BK樹
編輯距離的經典應用就是用於拼寫檢錯,如果使用者輸入的詞語不在詞典中,自動從詞典中找出編輯距離小於某個數n的單詞,讓使用者選擇正確的那一個,n通常取到2或者3。
這個問題的難點在於,怎樣才能快速在字典裡找出最相近的單詞?可以像 使用貝葉斯做英文拼寫檢查(c#) 裡是那樣,通過單詞自動修改一個單詞,檢查是否在詞典裡,這樣有暴力破解的嫌疑,是否有更優雅的方案呢?
1973年,Burkhard和Keller提出的BK樹有效地解決了這個問題。BK樹的核心思想是:
令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的步數)
最後這一個性質叫做三角形不等式。就好像一個三角形一樣,兩邊之和必然大於第三邊。
BK建樹
首先我們隨便找一個單詞作為根(比如GAME)。以後插入一個單詞時首先計算單詞與根的Levenshtein距離:如果這個距離值是該節點處頭一次出現,建立一個新的兒子節點;否則沿著對應的邊遞迴下去。例如,我們插入單詞FAME,它與GAME的距離為1,於是新建一個兒子,連一條標號為1的邊;下一次插入GAIN,算得它與GAME的距離為2,於是放在編號為2的邊下。再下次我們插入GATE,它與GAME距離為1,於是沿著那條編號為1的邊下去,遞迴地插入到FAME所在子樹;GATE與FAME的距離為2,於是把GATE放在FAME節點下,邊的編號為2。
BK查詢
如果我們需要返回與錯誤單詞距離不超過n的單詞,這個錯誤單詞與樹根所對應的單詞距離為d,那麼接下來我們只需要遞迴地考慮編號在d-n到d+n範圍內的邊所連線的子樹。由於n通常很小,因此每次與某個節點進行比較時都可以排除很多子樹。
可以通過下圖(來自 超酷演算法(1):BK樹 (及個人理解))理解:
BK 實現
知道了原理實現就簡單了,這裡從github找一段程式碼
建樹:
public boolean add(T t) {
if (t == null)
throw new NullPointerException();
if (rootNode == null) {
rootNode = new Node<>(t);
length = 1;
modCount++; // Modified tree by adding root.
return true;
}
Node<T> parentNode = rootNode;
Integer distance;
while ((distance = distanceFunction.distance(parentNode.item, t)) != 0
|| !t.equals(parentNode.item)) {
Node<T> childNode = parentNode.children.get(distance);
if (childNode == null) {
parentNode.children.put(distance, new Node<>(t));
length++;
modCount++; // Modified tree by adding a child.
return true;
}
parentNode = childNode;
}
return false;
}
查詢:
public List<SearchResult<T>> search(T t, int radius) {
if (t == null)
return Collections.emptyList();
ArrayList<SearchResult<T>> searchResults = new ArrayList<>();
ArrayDeque<Node<T>> nextNodes = new ArrayDeque<>();
if (rootNode != null)
nextNodes.add(rootNode);
while(!nextNodes.isEmpty()) {
Node<T> nextNode = nextNodes.poll();
int distance = distanceFunction.distance(nextNode.item, t);
if (distance <= radius)
searchResults.add(new SearchResult<>(distance, nextNode.item));
int lowBound = Math.max(0, distance - radius), highBound = distance + radius;
for (Integer i = lowBound; i <= highBound; i++) {
if (nextNode.children.containsKey(i))
nextNodes.add(nextNode.children.get(i));
}
}
searchResults.trimToSize();
Collections.sort(searchResults);
return Collections.unmodifiableList(searchResults);
}
使用BK樹做文字糾錯
準備詞典,18萬的影視名稱:
測試程式碼:
static void outputSearchResult( List<SearchResult<CharSequence>> results){
for(SearchResult<CharSequence> item : results){
System.out.println(item.item);
}
}
static void test(BKTree<CharSequence> tree,String word){
System.out.println(word+"的最相近結果:");
outputSearchResult(tree.search(word,Math.max(1,word.length()/4)));
}
public static void main(String[] args) {
BKTree<CharSequence> tree = new BKTree(DistanceFunctions.levenshteinDistance());
List<String> testStrings = FileUtil.readLine("./src/main/resources/act/name.txt");
System.out.println("詞典條數:"+testStrings.size());
long startTime = System.currentTimeMillis();
for(String testStr: testStrings){
tree.add(testStr.replace(".",""));
}
System.out.println("建樹耗時:"+(System.currentTimeMillis()-startTime)+"ms");
startTime = System.currentTimeMillis();
String[] testWords = new String[]{
"湄公河凶案",
"葫蘆絲兄弟",
"少林足球"
};
for (String testWord: testWords){
test(tree,testWord);
}
System.out.println("測試耗時:"+(System.currentTimeMillis()-startTime)+"ms");
}
結果:
詞典條數:18513
建樹耗時:421ms
湄公河凶案的最相近結果:
湄公河大案
葫蘆絲兄弟的最相近結果:
葫蘆兄弟
少林足球的最相近結果:
少林足球
笑林足球
測試耗時:20ms
參考:
http://blog.csdn.net/tradymeky/article/details/40581547
https://github.com/sk-scd91/BKTree
https://www.cnblogs.com/data2value/p/5707973.html
作者:Jadepeng
出處:jqpeng的技術記事本--http://www.cnblogs.com/xiaoqi
您的支援是對博主最大的鼓勵,感謝您的認真閱讀。
本文版權歸作者所有,歡迎轉載,但未經作者同意必須保留此段宣告,且在文章頁面明顯位置給出原文連線,否則保留追究法律責任的權利。