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]
如果您觉得博文对您有用,请随意打赏。您的鼓励是我前进的动力!