算法有趣的地方在于,可以用不同的方式处理同一个问题,并且总有更好的方法。比如拼写检查。
拼写检查大体是这样的,给出一个字典文件,给出一个比对文件。比对文件里的单词,如果某个单词不在字典文件里的话,就认为拼写错误。要做的就是找出比对文件里所有拼写错误的单词。
最直观的方法,就讲文件里的单词一一同字典里的单词做比较,如果不在字典里,就输出。最简单,也最费时。
假设字典文件一共有 nd 个字符,用于比对的文件有 nt 字符,复杂度则为 O(nd * nt)。
我们看一下运行速度,测试用的字典里大约有 14 万个单词,用作比对的文件大约有 12 万个单词。 用时将近 3 分钟。显然速度太慢,需要优化。
我们知道,如果给出的单词以 a 开头,那么字典里以其他字母开头单词,就不需要我们做比较了。就好比二叉树查找,每次查找,总是可以丢掉不符合要求的那一半。 收到二叉树的启发,我们可以使用一种叫做 Trie 的多叉树来实现。
假设字典里有 ab, ac, c, d 这四个单词,生成的 trie 会是如下形状(t1、t2 只是表述,方便做讨论):
root(t1)
/ \ \
a(t2) c(t3) d(t4)
/ \
b(t5) c(t6)
做检查的时候,需要从跟节点,一级一级的向下找。 假设我们查找"ad",根节点的子节点 t2 的值就是 a,第一个字母匹配成功了。我们再匹配第二个字节 d,t2 的子树里,没有 d 这个字母,匹配失败。则 ad 为一个错误的拼写。 只有当我们匹配了单词的每一个字母,并且最后一个字母是结尾字母,那么这个单词才是在字典中存在的。
使用同样输入只花了不到 1.3 秒的时间,快了上百倍。
拼写查找,要求的是插入和查找速度快,Hash Table 刚好符合要求。 同样的数据,只用了 0.14 秒,比 trie 的实现快了将近 10 倍。 具体实现
Hash Table 从速度上来说,已经是极限了,看起来是最适合的算法。但在某些场景下,依然有更好的方法 -- Bloom Filter!
Bloom Filter 相对于 Hash Table 来说,最大的优势就是,Bloom Filter 不需要存完整的 key (比如单词),会更节约存储空间。 Bloom Filter 将一组 key(比如单词)通过多个哈希方程映射到一个数组内,并可以根据这个数组来判断这个单词是否已经被插入了。
算法的大体思路如下: 初始化一个 n 个 bit 的数组 arr,插入某个值时,我们通过哈希方程,得到对应的 index,将数组这个位置设为 1。 假设我们使用 2 个哈希方程 h1, h2,我们会得到两个 index(可能是 1 个),我们就将数组的这两个位置设为 1。
查找的时候,用所有的哈希方程,得到对应的 index,然后检查数组在这些位置上,是否均为 1,如果均为 1,则认为这个值被插入过。
伪代码如下:
require 'bitarray'
class BloomFilter
attr_accessor :arr
def initialize
self.arr = BitArray.new(n)
end
def insert(key)
arr[h1(key)] = 1
arr[h2(key)] = 1
end
def include?(key)
arr[h1(key)] == 1 && arr[h2(key)] = 1
end
# BloomFilter 会有多个哈希函数,2 个只是为了说明算法的工作原理。
def h1(key)
# xxx
end
def h2(key)
# xxx
end
end
Bloom Filter 虽然大大的节省了空间,但却有一定的概率出错 (false positive),以为哈希方程有一定的概率会产生冲突 (collision),既当查找时,一个值没有被插入,但却被误认为是插入过了。可以通过更长的数组和更多的哈希方程来解决这个问题。
Bloom Filter 还有一个弊端,就是不能做删除操作。不过可以通过给每一位加上个 counter 来解决,就是所谓的 Counting Bloom Filter,具体可以看这篇文章,这里就不详述了。
同样的输入,需要 0.11 秒。
SpellChecker
user system total real
brute_force 162.660000 1.560000 164.220000 (168.961906)
by_trie 1.160000 0.100000 1.260000 ( 1.305419)
by_hash_table 0.140000 0.000000 0.140000 ( 0.145828)
by_bloom_filter 0.100000 0.010000 0.110000 ( 0.108583)