双数组前缀树(Double-Array Trie)

前缀树又称为字典树,在搜索提示,分词,httprouter等领域都有广泛使用。其原理就像查字典一样,比如要从字典中查 tea,首先翻到以 t 开头的那几页,然后从那几页里找到第二个字母是 e 的,在此范围内找到第三个字母是 a 并且没有后续字母的单词。

我们可以这样实现一棵前缀树,每个节点都是完整的索引。这样实现起来非常简单,但正如上图可看出的,仅索引4个单词就消耗了大量的空间。这还是纯英文字母的情况,如果是中文,那每个节点几万个字节,可以说是相当浪费了。


图片:www.semanticscholar.org


为了节省空间,我们可以用链表节点替代上面的数组节点,这样只是额外消耗了一点指针的空间,相比数组节点能省下很多空间。但这样每次进入一个节点就要从头开始一个一个地搜相匹配的字符,然后再跳到下一个节点。是一种时间换空间的操作。

图片:www.semanticscholar.org


使用 Hash 结构作为前缀树的节点是上述两个方案的折中,找不到图,这里就不说了,下面重点说说由日本 Jun-ichi Aoe 提出的双数组前缀树(Double-Array Trie)简称 DAT。


Double-Array Trie


创建

假设有一个字符集仅有 {a, b, c} 有单词 a, ab, bbc, bc 构建一个 DAT,首先给字符集编码 a: 1, b: 2, c: 3

DAT 的起始状态如下,有一个 base 数组,一个 check 数组。

  • base 和 check 数组的索引表示一个状态

比如5可以表示在搜索 bbc 时搜索出了 bb 的这个中间状态, 这个5并不是 base[5] 的值,理解这个非常重要,并且 base 数组的索引并不都是有效的状态,会有浪费。

  • base 数组里存的数据称为 offset
  • check 数组里存的数据是父状态的索引

假设索引7表示的是 bbc,它是从状态5(bb)跳过来的,那么 check[7] 的值应该为 5。从这里也应该看出状态7和状态5之间并没有什么数学关系。
从 check 这个名字可以看出,它是用于验证的。

  • 刚开始 base[0] = 0

这时添加单词 a,只有一个字母,我们可以将其看作state(a),state(a) 的值通过下面式子算出来:

state(a) = base(state(empty)) + index(a)
         = base(0) + 1
         = 0 + 1
         = 1

知道了 state(a) 后设置 check(state(a)) 的值为 state(empty), 得到下图

第二个单词是 ab,我们知道 ab 是 a 之后的状态,尝试用之前的式子:

state(ab) = base(state(a)) + index(b)

然而 base(state(a)) 并没有设置任何值,在DAT中,只要索引没有关联的状态,那个索引就可以用。也就是我们可以给 state(ab) 确定一个值后,反过来设置 base(stat(ab)) 的值。2 还没有关联的状态,那么可以算出

state(ab) = 2
base(state(a)) = state(ab) - index(b)
               = 2 - 2
               = 0

同理 check(state(ab)) = state(a) = 1, 可得下图

第三个单词 bbc, 第一个字母 b, state(b) 可以算出等于2...,而 2 已经被 state(ab) 占了!state(empty) 是不可能变的了,它变了 state(a), state(ab) 也会跟着变,所以考虑挪一下 state(ab), 比如 7,玩大一点不那么容易起冲突。这样添加了 state(b)后可得下图:

继续添加 bb, bbc 状态可得下图,我故意让bbc状态不连着bb状态,免得后面还要挪

最后添加单词 bc, 可得:

搜索

现在,一个还有很多缺陷的DAT就创建好了,当需要查看 bbc 是否在字典里时分为下面几步:

  1. state(b) = base(state(empty)) + index(b) = 2;
  2. 检查发现 check(state(b)) = 0 等于 state(empty),可以继续往下找 bb
  3. state(bb) = base(state(b)) + index(b) = 3
  4. 检查发现 check(state(bb)) = 2 等于 state(b) 可以继续往下找 bbc
  5. state(bbc) = base(state(bb)) + index(c) = 5
  6. 检查发现 check(state(bbc)) = 3 等于 state(bb) 表示字典里有 bbc

存在问题

DAT的基本原理就这样,实际实现过程中会有相当多的问题,比如:

  1. 有没有基本套路解决上面的状态冲突问题
  2. 上面查找过程中可以得到 b 也在字典中,然而 b 并不是一个单词
  3. 对于中文这种超大字符集应该怎样处理
  4. 如果有超长的单词,而且它的前几个字母就可以确定这个单词的了,那么还对后面的字母做索引,会影响效率,会占用多不少空间
  5. 如何删除一个单词


问题1:实时解决冲突效率是非常低的,不应该无序地,不定时地加入单词,一个建议的做法是首先将单词排序好,然后一层一层地去决定状态,比如有排序好的单词 a aa ab ba bba

3         a
2   a b a b
1 a a a b b

这样知道第一层有a b两个字母了,那预先占了状态1和2,到后面 aa 的时候就不会选到预先占的位置了。

问题2:创建DAT时可添加 0x00 作为单词结尾,只有有结尾的才算完整的单词。

问题3:可将中文用UTF8编码,每个字节当作一个字符。

问题4:可以做一个后缀压缩的功能,详细可看 linux.thai.net

问题5:可看 linux.thai.net


Darts-clone 的实现

darts-clone 是 double-array trie 的一个 C++ 实现,基本思路和上面描述的一致,但需要注意以下一些细节:

  1. DAT的生成如上说的只能对排序好的单词进行构建
  2. 将两个数组通过位运算合并成一个整型数组了
  3. check 存放的并不是上层状态,而是“字母”(似乎失去了回溯的能力,虽然也是用不到)
  4. 通过异或得到下一个状态,而不是通过加法
  5. 使用循环链表加快对空置位置的查找
  6. 对最终空置位置进行设置使其无效


C++ 版的 darts-clone 有个小问题,之前声称用32位数组实现 double-array trie 在这个64位的时代已经变成64位数组了,而其实只用了32位,另外32位空着。

我用 Go 实现了 darts-clone 的 double-array trie,详细可看 github.com/euclidr/dart

参考:

  1. An Efficient Digital Search Algorithm by Using a Double-Array Structure
  2. github.com/s-yata/darts
  3. An Implementation of Double-Array Trie
编辑于 2018-04-02