介绍
拼写纠错功能常常出现在比较高级的文本编辑应用中,例如大家熟知的word,高级一点的IDE例如Jet Brains系列,在一些在线翻译上,也有自动校正拼写的功能,例如谷歌翻译。
原理
拼写纠正的实现方式有多种,这里使用的是一种名为BK树的数据结构,也叫作Burkhard-Keller树,是由Burkhard,Keller这两人提出来的,不过网上能找到的相关资料并不多,参见ACM文档https://dl.acm.org/citation.cfm?id=362003.362025。
最短编辑距离
在介绍BK树思想之前,要介绍一个概念——莱文斯坦距离,又称Levenshtein距离,是编辑距离的一种。指两个字串之间,由一个转成另一个所需的最少编辑操作次数。允许的编辑操作包括
- 将一个字符替换成另一个字符。
- 插入一个字符。
- 删除一个字符。
每经过一种操作编辑距离+1,当然也能够根据实际需求设定不同的权值。例如从 cute 到 cat,需要将u替换为a,删除e,因此它的的最短编辑距离为2。
定义dist(a, b)为字符串a到b的编辑距离,有如下数学性质
- dist(a, b) = 0 <==> a = b // 相同的字符串编辑距离当然为0
- dist(a, b) = dist(b, a) // a到b的编辑距离与b到a的编辑距离是一样的,因为添加-删除,替换-替换是互逆的操作
- dist(a, temp) + dist(temp, b) >= dist(a, b) // 这点很重要,这是一条三角不等式,对于三角形从a顶点到b顶点的距离必然小于a到c再从c到b的距离。
推论
在模糊匹配值,我们需要设定一个容错值,而这个值用最短编辑距离来衡量,例如我们设定容错值是1,字典中有 [cat, cute, candy],给出字符串cate,能够匹配到[cat, cute],因为这两个字符串与cate的编辑距离都小于等于1 ,而dist("cate", "candy") = 3>1,不能加入匹配列表。设容错值为 r, Q为待校正的字符串,拿它与任意字符串A比较最短距离为d,如果Q能够在容错范围内转化为B,A到B的编辑距离最小是d-r,最大是d+r,原因如下
dist(Q, A) + dist(Q, B) = dist(A, Q) + dist(Q, B) >= dist(A, B)
==> dist(A, B) <= dist(A, Q) + dist(Q, B) = d + r
dist(Q, A) + dist(A, B) >= dist(Q, B)
类似的也能推出 dist(Q, A) >= d - r,dist(Q, A) >= 1
因此max(1, d-r) <= dist(A ,B) <= d + r
BK树
BK树的构建
BK树的每个节点有任意个子节点,每个子节点均有一个编号,插入一个单词时,与当前节点比较最短编辑距离为k,如果当前节点还没有编号为k的子节点,将这个单词的节点插入,编号设置为k,否则,就递归向编号为k的节点插入单词。
BK树的查找
从根节点开始查找,对于任意一个节点,比较节点记录的单词与查找单词的最短距离d,根据上面推出的性质,只需要在 (max(1, d-r) , d+r) 编号的节点递归查找(前提是对应编号的节点存在)就可以了
代码实现
/**
* Created by YotWei on 2018/9/8.
* 定义计算编辑距离的接口
*/
public interface Metric<T> {
int getMetric(T t1, T t2);
}
/**
* Created by YotWei on 2018/9/8.
* 这是典型的动态规划计算最短编辑距离算法
*/
public class StringEditMetric implements Metric<String> {
@Override
public int getMetric(String s1, String s2) {
int[][] dp = new int[s1.length() + 1][s2.length() + 1];
for (int i = 1; i <= s1.length(); i++) {
dp[i][0] = i;
}
for (int i = 1; i <= s2.length(); i++) {
dp[0][i] = i;
}
for (int i = 1; i <= s1.length(); i++) {
for (int j = 1; j <= s2.length(); j++) {
dp[i][j] = Math.min(
dp[i - 1][j - 1] + (s1.charAt(i - 1) == s2.charAt(j - 1) ? 0 : 1),
Math.min(dp[i][j - 1], dp[i - 1][j]) + 1
);
}
}
return dp[s1.length()][s2.length()];
}
}
import java.util.*;
/**
* Created by YotWei on 2018/9/8.
*
* BK树的实现
*/
public class BKTree<T> {
private final int radius; // 模糊匹配的范围,如果值为0,就变成了精确匹配
private Node root;
private Metric<T> metric;
public BKTree(int radius, Metric<T> metric) {
this.radius = radius;
this.metric = metric;
}
public void add(T value) {
if (root == null)
root = new Node(value);
else {
root.add(value);
}
}
public void addAll(Collection<? extends T> collection) {
for (T val : collection) {
add(val);
}
}
public Set<T> search(T value) {
Set<T> result = new HashSet<>();
if (root != null)
root.search(value, result);
return result;
}
class Node {
private T value;
private Map<Integer, Node> childs;
Node(T v) {
this.value = v;
this.childs = new HashMap<>();
}
void add(T value) {
int distance = metric.getMetric(this.value, value);
if (this.childs.containsKey(distance)) {
this.childs.get(distance).add(value);
} else {
this.childs.put(distance, new Node(value));
}
}
void search(T value, Set<T> resultSet) {
int distance = BKTree.this.metric.getMetric(this.value, value);
if (distance <= radius) { // 编辑距离在匹配范围,接入结果集
resultSet.add(this.value);
}
for (int i = Math.max(distance - radius, 1); i <= distance + radius; i++) {
Node ch = this.childs.get(i);
if (ch != null)
ch.search(value, resultSet);
}
}
}
}
客户端
import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.io.IOException;
import java.util.HashSet;
import java.util.Scanner;
import java.util.Set;
/**
* Created by YotWei on 2018/9/8.
*/
public class Client {
public static void main(String[] args) throws IOException {
Scanner sc = new Scanner(System.in);
BKTree<String> bkTree = new BKTree<>(1, new StringEditMetric());
bkTree.addAll(getDictionary()); // 构建bk树
while (sc.hasNext()) {
String word = sc.next().trim();
System.out.printf("matching set: %s\n", bkTree.search(word));
}
}
/**
* 从文件中读入字典集,可自己定义
*/
private static Set<String> getDictionary() throws IOException {
Set<String> dictionary = new HashSet<>();
BufferedReader br = new BufferedReader(new FileReader(new File("bktree/src/words")));
String temp;
while ((temp = br.readLine()) != null) {
dictionary.add(temp);
}
br.close();
return dictionary;
}
}
运行结果
>> applo
matching set: [apple, apply]
>> absolutel
matching set: [absolute, absolutely]
>> humann
matching set: [human]
>> cat
matching set: [cut, car, cat]
>> animte
matching set: [animate]
>> edito
matching set: [editor, edit]