1. 程式人生 > >文字糾錯,BK樹,詳細

文字糾錯,BK樹,詳細

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]