前些时候碰到个问题:怎么把短信内容中的联系人找出来?正则表达式是很直接的想法,把所有联系人的姓名拼成一个正则表达式,然后对短信内容做匹配;另外之前听说过AhoCorasick算法,知道这个算法适合做多关键字查找,所以仔细看了一下这个算法,并给出了一个简单的Groovy实现。
关于AhoCorasick算法的介绍和实现很多,感觉还是Wikipedia上的介绍更容易理解一些,Aho–Corasick string matching algorithm ,后面的实现主要是基于Wikipedia上的介绍。
class AhoCorasick { AhoCorasick(words) { ... } def matches(text) { ... } }构建过程在构造函数里实现,传入一组关键词words,如果words不能构建有效的结果,抛IllegalArgumentException;查找功能由matches方法实现,输入要搜索的文本text,返回一个列表,列表中对象的结构如下:{start: 10, word: 'hello'},如果没有匹配,列表为空。
构建过程可以分解为两个步骤:构建Trie,和给节点加额外的链接。
private root AhoCorasick(words) { buildTrie(words) buildLinks() }
以Wikipedia提供的例子,words = ['a', 'ab', 'bc', 'bca', 'c', 'caa'],构造的Trie树如下:
这个过程很直观,代码如下:
private buildTrie(words) { root = [:] words.findAll { it.size() > 0 }.each { addWord(it) } if (!root) { throw new IllegalArgumentException('Invalid words: ' + words) } } private addWord(word) { def node = root word.each { ch -> node[(ch)] = node[(ch)] ?: [:] node = node[(ch)] } node.accept = word }出于简化的目的,一个node就是一个HashMap,这个实现里,node可能有4种类型的key:
链接包含蓝色的"suffix"和绿色的"dictionary suffix",这个链接建立过程是算法的难点。
蓝色的"suffix"链接也叫fail链接,如果节点n的fail链接指向节点f,那么f到root的字符串f->root,就是n到root的字符串n->root的最长后缀。在上面的Trie树上添加fail链接后的结果如下图:
除了根节点,所有的节点都有fail链接,仔细查看这张图,不难发现,f->root,都是n->root的最长后缀;另外,也可以看到,任何节点都可以通过fail链接最终到达root节点,即在节点与root间存在一个fail chain。怎样通过代码来确定一个节点的fail链接呢?
下面的函数在node到root形成的fail chain中查找第一个包含ch链接的节点ff,如果存在返回ff[ch],否则返回root。
private nextByFailLinks(node, ch) { def fail = node.fail while (fail) { if (fail[(ch)]) { return fail[(ch)] } fail = fail.fail } return root }绿色的"dictionary suffix",后面简称为dict,表示查找过程到达一个节点时,存在的成功匹配,比如这个例子里,走到节点ca时,已经可以匹配a了。这个例子加上dict链接后的图如下:
确定节点的fail和dict链接需要父节点的链接信息,所以添加链接的过程应该走一个广度优先的遍历,下面是添加链接的实现:
private buildLinks() { def nodes = [root] as Queue, node while (node = nodes.poll()) { node.findAll { it.key.size() == 1 }.each { ch, child -> nodes.offer(child) child.fail = nextByFailLinks(node, ch) child.dict = child.fail.accept ? child.fail : child.fail.dict } } }
搞明白了构建过程,查找过程就很容易理解了,根据输入的字符,选择下一个状态(节点),并根据状态获取可能匹配的所有结果。
def matches(text) { def results = [], current = root text.eachWithIndex { ch, index -> current = current[(ch)] ?: nextByFailLinks(current, ch) acceptCurrentMatched(results, current, index) } return results }
当前状态current,读取字符ch,如果current包含ch链接,那么下一个状态是current[ch];如果不包含,使用前面定义的nextByFailLinks函数,在current到root间的fail chain中查找,这也是为什么把蓝色的"suffix"链接叫做fail链接的原因。
根据状态添加匹配的结果,有两种可能:当前状态是否有accpet属性,dict链上的所有节点。
private acceptCurrentMatched(results, current, index) { current.accept && results << makeResult(current, index) def node = current while (node = node.dict) { results << makeResult(node, index) } } private makeResult(node, index) { def start = index - node.accept.size() + 1 return [start : start, word : node.accept] }
这里的dict链接可以用其他方式来完成同样的功能,在添加完fail链接时,可以把fail chain中所有已匹配的词都追加到accpet属性中,这样在查找过程中,只根据当前节点的accpet属性来生成结果就可以了。
另外accept属性可以不记录word,而记录word的长度,这样会省一点内存。
上面的实现和测试代码,可查看http://git.oschina.net/mononite/aho-corasick。