BK樹或者稱為Burkhard-Keller樹,是一種基於樹的數據結構,被設計於快速查找近似字符串匹配,比方說拼寫糾錯,或模糊查找,當搜索”aeek”時能返回”seek”和”peek”。
本文首先剖析了基本原理,並在后面給出了Java源碼實現。
BK樹在1973年由Burkhard和Keller第一次提出,論文在這《Some approaches to best match file searching》。這是網上唯一的ACM存檔,需要訂閱。更細節的內容,可以閱讀這篇論文《Fast Approximate String Matching in a Dictionary》。
在定義BK樹之前,我們需要預先定義一些操作。為了索引和搜索字典,我們需要一種比較字符串的方法。編輯距離( Levenshtein Distance)是一種標准的方法,它用來表示經過插入、刪除和替換操作從一個字符串轉換到另外一個字符串的最小操作步數。其它字符串函數也同樣可接受(比如將調換作為原子操作),只要能滿足以下一些條件。
現在我們觀察下編輯距離:構造一個度量空間(Metric Space),該空間內任何關系滿足以下三條基本條件:
- d(x,y) = 0 <-> x = y (假如x與y的距離為0,則x=y)
- d(x,y) = d(y,x) (x到y的距離等同於y到x的距離)
- d(x,y) + d(y,z) >= d(x,z)
上述條件中的最后一條被叫做三角不等式(Triangle Inequality)。三角不等式表明x到z的路徑不可能長於另一個中間點的任何路徑(從x到y再到z)。看下三角形,你不可能從一點到另外一點的兩側再畫出一條比它更短的邊來。
編輯距離符合基於以上三條所構造的度量空間。請注意,有其它更為普遍的空間,比如歐幾里得空間(Euclidian Space),編輯距離不是歐幾里得的。既然我們了解了編輯距離(或者其它類似的字符串距離函數)所表達的度量的空間,再來看下Burkhard和Keller所觀察到的關鍵結論。
假設現在我們有兩個參數,query表示我們搜索的字符串,n為待查找的字符串與query距離滿足要求的最大距離,我們可以拿任意字符串A來跟query進行比較,計算距離為d,因為我們知道三角不等式是成立的,則滿足與query距離在n范圍內的另一個字符轉B,其與A的距離最大為d+n,最小為d-n。
推論如下:
d(query, B) + d(B, A) >= d(query, A), 即 d(query, B) + d(A,B) >= d
--> d(A,B) >= d - d(query, B) >= d - n
d(A, B) <= d(A,query) + d(query, B), 即 d(A, B) <= d + d(query, B) <= d + n
其實,還可以得到 d(query, A) + d(A,B) >= d(query, B)
--> d(A,B) >= d(query, B) - d(query, A)
--> d(A,B) >= 1 - d >= 0 (query與B不等) 由於 A與B不是同一個字符串,所以d(A,B)>=1
所以, min{1, d - n} <= d(A,B) <= d + n,這是更為完整的結論。
由此,BK樹的構造就過程如下:
每個節點有任意個子節點,每條邊有個值表示編輯距離。所有子節點到父節點的邊上標注n表示編輯距離恰好為n。比如,我們有棵樹父節點是”book”和兩個子節點”rook”和”nooks”,”book”到”rook”的邊標號1,”book”到”nooks”的邊上標號2。
從字典里構造好樹后,無論何時你想插入新單詞時,計算該單詞與根節點的編輯距離,並且查找數值為d(neweord, root)的邊。遞歸得與各子節點進行比較,直到沒有子節點,你就可以創建新的子節點並將新單詞保存在那。比如,插入”boon”到剛才上述例子的樹中,我們先檢查根節點,查找d(“book”, “boon”) = 1的邊,然后檢查標號為1的邊的子節點,得到單詞”rook”。我們再計算距離d(“rook”, “boon”)=2,則將新單詞插在”rook”之后,邊標號為2。
查詢相似詞如下:
計算單詞與根節點的編輯距離d,然后遞歸查找每個子節點標號為d-n到d+n(包含)的邊。假如被檢查的節點與搜索單詞的距離d小於n,則返回該節點並繼續查詢。
BK樹是多路查找樹,並且是不規則的(但通常是平衡的)。試驗表明,1個查詢的搜索距離不會超過樹的5-8%,並且2個錯誤查詢的搜索距離不會超過樹的17-25%,這可比檢查每個節點改進了一大步啊!需要注意的是,如果要進行精確查找,也可以非常有效地通過簡單地將n設置為0進行。
英文原文:http://blog.notdot.net/2007/4/Damn-Cool-Algorithms-Part-1-BK-Trees
本文給出一個Java源碼如下,相當簡潔,注釋清楚:
BK樹的創建、添加、查詢:
package inteldt.todonlp.spellchecker; import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Set; /** * BK樹,可以用來進行拼寫糾錯查詢 * * 1.度量空間。 * 距離度量空間滿足三個條件: * d(x,y) = 0 <-> x = y (假如x與y的距離為0,則x=y) * d(x,y) = d(y,x) (x到y的距離等同於y到x的距離) * d(x,y) + d(y,z) >= d(x,z) (三角不等式) * * 2、編輯距離( Levenshtein Distance)符合基於以上三條所構造的度量空間 * * 3、重要的一個結論:假設現在我們有兩個參數,query表示我們搜索的字符串(以字符串為例), * n為待查找的字符串與query最大距離范圍,我們可以拿一個字符串A來跟query進行比較,計 * 算距離為d。根據三角不等式是成立的,則滿足與query距離在n范圍內的另一個字符轉B, * 其余與A的距離最大為d+n,最小為d-n。 * * 推論如下: * d(query, B) + d(B, A) >= d(query, A), 即 d(query, B) + d(A,B) >= d --> d(A,B) >= d - d(query, B) >= d - n * d(A, B) <= d(A,query) + d(query, B), 即 d(query, B) <= d + d(query, B) <= d + n * 其實,還可以得到 d(query, A) + d(A,B) >= d(query, B) * --> d(A,B) >= d(query, B) - d(query, A) * --> d(A,B) >= 1 - d >= 0 (query與B不等) 由於 A與B不是同一個字符串d(A,B)>=1 * 所以, min{1, d - n} <= d(A,B) <= d + n * * 利用這一特點,BK樹在實現時,子節點到父節點的權值為子節點到父節點的距離(記為d1)。 * 若查找一個元素的相似元素,計算元素與父節點的距離,記為d, 則子節點中能滿足要求的 * 相似元素,肯定是權值在d - n <= d1 <= d + n范圍內,當然了,在范圍內,與查找元素的距離也未必一定符合要求。 * 這相當於在查找時進行了剪枝,然不需要遍歷整個樹。試驗表明,距離為1范圍的查詢的搜索距離不會超過樹的5-8%, * 並且距離為2的查詢的搜索距離不會超過樹的17-25%。 * 參見: * http://blog.notdot.net/2007/4/Damn-Cool-Algorithms-Part-1-BK-Trees(原文) * @author yifeng * */ public class BKTree<T>{ private final MetricSpace<T> metricSpace; private Node<T> root; public BKTree(MetricSpace<T> metricSpace) { this.metricSpace = metricSpace; } /** * 根據某一個集合元素創建BK樹 * * @param ms * @param elems * @return */ public static <E> BKTree<E> mkBKTree(MetricSpace<E> ms, Collection<E> elems) { BKTree<E> bkTree = new BKTree<E>(ms); for (E elem : elems) { bkTree.put(elem); } return bkTree; } /** * BK樹中添加元素 * * @param term */ public void put(T term) { if (root == null) { root = new Node<T>(term); } else { root.add(metricSpace, term); } } /** * 查詢相似元素 * * @param term * 待查詢的元素 * @param radius * 相似的距離范圍 * @return * 滿足距離范圍的所有元素 */ public Set<T> query(T term, double radius) { Set<T> results = new HashSet<T>(); if (root != null) { root.query(metricSpace, term, radius, results); } return results; } private static final class Node<T> { private final T value; /** * 用一個map存儲子節點 */ private final Map<Double, Node<T>> children; public Node(T term) { this.value = term; this.children = new HashMap<Double, BKTree.Node<T>>(); } public void add(MetricSpace<T> ms, T value) { // value與父節點的距離 Double distance = ms.distance(this.value, value); // 距離為0,表示元素相同,返回 if (distance == 0) { return; } // 從父節點的子節點中查找child,滿足距離為distance Node<T> child = children.get(distance); if (child == null) { // 若距離父節點為distance的子節點不存在,則直接添加一個新的子節點 children.put(distance, new Node<T>(value)); } else { // 若距離父節點為distance子節點存在,則遞歸的將value添加到該子節點下 child.add(ms, value); } } public void query(MetricSpace<T> ms, T term, double radius, Set<T> results) { double distance = ms.distance(this.value, term); // 與父節點的距離小於閾值,則添加到結果集中,並繼續向下尋找 if (distance <= radius) { results.add(this.value); } // 子節點的距離在最小距離和最大距離之間的。 // 由度量空間的d(x,y) + d(y,z) >= d(x,z)這一定理,有查找的value與子節點的距離范圍如下: // min = {1,distance -radius}, max = distance + radius for (double i = Math.max(distance - radius, 1); i <= distance + radius; ++i) { Node<T> child = children.get(i); // 遞歸調用 if (child != null) { child.query(ms, term, radius, results); } } } } }
距離度量方法接口:
package inteldt.todonlp.spellchecker; /** * 度量空間 * * @author yifeng * * @param <T> */ public interface MetricSpace<T> { double distance(T a, T b); }
編輯距離:
package inteldt.todonlp.spellchecker; /** * 編輯距離, 又稱Levenshtein距離,是指兩個字串之間,由一個轉成另一個所需的最少編輯操作次數。 * 該類中許可的編輯操作包括將一個字符替換成另一個字符,插入一個字符,刪除一個字符。 * * 使用動態規划算法。算法復雜度:m*n。 * * @author yifeng * */ public class LevensteinDistance implements MetricSpace<String>{ private double insertCost = 1; // 可以寫成插入的函數,做更精細化處理 private double deleteCost = 1; // 可以寫成刪除的函數,做更精細化處理 private double substitudeCost = 1.5; // 可以寫成替換的函數,做更精細化處理。比如使用鍵盤距離。 public double computeDistance(String target,String source){ int n = target.trim().length(); int m = source.trim().length(); double[][] distance = new double[n+1][m+1]; distance[0][0] = 0; for(int i = 1; i <= m; i++){ distance[0][i] = i; } for(int j = 1; j <= n; j++){ distance[j][0] = j; } for(int i = 1; i <= n; i++){ for(int j = 1; j <=m; j++){ double min = distance[i-1][j] + insertCost; if(target.charAt(i-1) == source.charAt(j-1)){ if(min > distance[i-1][j-1]) min = distance[i-1][j-1]; }else{ if(min > distance[i-1][j-1] + substitudeCost) min = distance[i-1][j-1] + substitudeCost; } if(min > distance[i][j-1] + deleteCost){ min = distance[i][j-1] + deleteCost; } distance[i][j] = min; } } return distance[n][m]; } @Override public double distance(String a, String b) { return computeDistance(a,b); } public static void main(String[] args) { LevensteinDistance distance = new LevensteinDistance(); System.out.println(distance.computeDistance("你好","好你")); } }
有了以上三個類,下面寫一個main函數玩起糾錯功能:
package inteldt.todonlp.spellchecker; import java.util.Set; /** * 拼寫糾錯 * * @author yifeng * */ public class SpellChecker { public static void main(String args[]) { double radius = 1.5; // 編輯距離閾值 String term = "helli"; // 待糾錯的詞 // 創建BK樹 MetricSpace<String> ms = new LevensteinDistance(); BKTree<String> bk = new BKTree<String>(ms); bk.put("hello"); bk.put("shell"); bk.put("holl"); Set<String> set = bk.query(term, radius); System.out.println(set.toString()); } }
輸出:[hello]
如果您覺得博文對您有用,請隨意打賞。您的鼓勵是我前進的動力!