字典树(trie树),是一种前缀树,可以在O(m)的时间复杂度匹配目标字符串(m为目标字符串的长度),字典树广泛应用在NLP领域作为词典,具体应用有:分词、词频统计、字符串查询、字符串排序等。
虽然字典树有较低的时间复杂度,但当词典较大时(如中文分词词典),字典树占用的空间非常大,常常难以满足工业应用的需求,因此需要在保持字典树优异的时间复杂度的前提下,尽可能的优化字典树的空间。本文由浅入深,讲解经典字典树、链表字典树、Hash表字典树、双数组字典树、单数组字典树5种字典树实现及各种实现的优缺点。

经典字典树实现

经典字典树是一种单数组+树形结构的实现方法,即字典树的每个节点保存一个指向孩子节点的指针数组,由于要在O(1)的时间复杂度查询到孩子节点,所以数组的长度是所有孩子节点的可能值,如果是英文词典,则该数组长度可以控制在128左右(ascii字符数量),但如果是中文词典,每个节点里的数组长度要达到几千,可想而知,这种字典树的空间消耗是极大的。
优点:简单易实现
缺点:空间占用极大

链表字典树实现

既然经典字典树的空间消耗很大程度上来自每个节点里的定长数组,那可以考虑用可变长链表替代定长数组,从而减小空间消耗。
具体来说,每个节点保存一个链表,链表的每个节点除了保存链表的后继节点指针,还要保存一个指向孩子节点的指针,一旦要添加新的孩子节点,只需要添加一个链表节点在链表尾部即可,而不需要提前为孩子节点预分配足够的数组空间。但由于链表的查询复杂度不再是O(1),而是O(n),所以在查询孩子节点的时候必须遍历链表,这样无疑增加了字典树的查询复杂度,丢失了字典树本身的查询优点。
优点:简单易实现,空间消耗较小
缺点:查询复杂度高

Hash表字典树实现

除了数组和链表外,还有一种数据结构可以很好的兼顾时间和空间复杂度,那就是hash表。如果将字典树里的孩子节点的查询用hash表实现,则可以在O(1)的时间定位到孩子节点。具体实现方法为:字典树的每个节点均保存一个hash表,hash表的key为词典的某一个字,val为这个字对应的节点的指针,在执行字典树查询的时候,只需要查询hash表即可快速定位孩子节点,整个字典树的查询复杂度为O(m)。
但hash表的实现很大程度上依赖其hash算法,如果hash算法设计得不好,亦或者key的数量较大,则会存在很多key冲突的情况,这种情况下hash表很难兼顾时间复杂度和空间复杂度。而且一旦冲突变多,查询的时间复杂度也难以保证O(1)。
优点:简单易实现,时间和空间较平衡
缺点:冲突较多时时间和空间难以平衡
以上三种实现方法的本质其实是以空间换时间,而小白详解 Trie 树中提到其实字典树的本质其实是以信息换时间,所以上述三中实现方式都不是最优的实现方式,下面讲解另外两种实现,既能达到优异的时间复杂度,其空间消耗又在可接收范围内,是目前工业场景中的主流实现方法。

双数组字典树

双数组字典树不使用树形结构保存词典,而采用两个数组来实现。
其中一个数组是base数组,保存当前节点i的base值,而孩子节点j在数组中的索引位置表示为(base[i]+encode(j)),由于encode(j)很容易找到O(1)时间的实现方案,因此这种字典树实现方式的查询复杂度是O(m)。可能有人会问,任何一个父节点都有可能通过(base[i]+encode(j))的计算定位到这个子节点,那么怎么确定这个子节点就是从正确的父节点转移过来的呢,为了解决这个问题,引入了第二个数组,check数组。
check数组的每个位置保存该节点的父节点索引,即j这个位置的节点是通过check[j]这个位置的节点转移而来的,这样在查询的时候,如果check[j]的值不等于i,说明i不是j的父节点,查询终止。
除了以树形结构的思维方式来理解双数组字典树外,还可以以有限状态自动机(Definite Automata, DFA)的方式来理解双数组字典树。数组中的每个位置表示一个状态,状态间的映射函数为(base[i]+encode(j)),而字符串的查询过程就是以目标字符串为输入,在DFA上动态转移的过程。由于DFA的状态映射在O(1)时间内完成,所以双数组字典树的查询复杂度为O(m)。其实,字典树的本质就是一个DFA。
在双数组字典树构建过程中,经常会遇到位置冲突的问题,即在构建一个新的状态时,(base[i]+encode(j))这个位置已经被占用,此时便发生了冲突,一旦冲突发生,只需要重新计算父节点的base值,并重新分配当前节点和兄弟节点的位置即可。为了尽可能的避免冲突,优先构造每个词的首字,再依次构造每个词的第二个字,以此类推。
双数组字典树保留了字典树的查询复杂度,还兼顾了空间复杂度,但如果在构建过程中冲突太多,则构建的时间和空间会大大增加,甚至会出现为了解决冲突导致空间浪费很多,最终空间复杂度变得很高。而由于冲突的存在,在构建字典树的时候,其时间复杂度不可估量,因此双数组字典树一般用在字典较固定的场景,一次构建完成后则不需要重新构建。而由于双数组字典树是用数组存储词典,则可以离线构建好双数组,在线直接加载提前构建好的二进制数组即可,极大的节省了初始化时间。
优点:时间和空间更平衡,离线构建在线加载,适用于词典较固定的场景
缺点:冲突,依然有空间的浪费
因为冲突的存在,上述双数组实现依然不适用于词典频繁变化的场景,即上述双数组字典树可以在词典固定场景中较好的兼顾时间和空间。但双数组字典树的实现中依然存在空间浪费情况,因为数组中的某些位置依然不保存任何状态,不能保证数组的每个位置均被填满,造成了一定的空间浪费。而我在工作期间接触到的另外一种单数组字典树实现方式,在词典固定的前提下,除了保留了双数组查询复杂度、离线构建在线加载等优点外,能够以更高的效率利用空间。

单数组字典树

单数组字典树只有一个数组entry,entry数组中保存孩子节点的终止索引位置,即entry[i]保存的是位置i上的节点的孩子节点的终止索引位置,而节点i的孩子节点的起始位置则是entry[i-1]+1,即上一个节点的孩子节点的终止位置加1,这样就可以完全定位节点i的孩子节点了,即[entry[i-1]+1, entry[i]]之间。
可能有人会问,既然[entry[i-1]+1, entry[i]]是一个数组,又没有像双数组字典树一样的转移映射关系,如何保证在短时间内定位节点i的任意孩子节点呢?答案是二分查找,在构建单数组字典树时,会将节点i的所有孩子节点按字典序排序,因此在查询的时候可以使用二分查找,查询复杂度为log(n),n为孩子节点的数量,时间上基本与O(1)相差无几。
单数组字典树真正吸引人的地方是它可以100%的利用空间,没有任何空间的浪费。在构建的时候,可以想象成树形字典树已经构建完成,并且该字典树中的兄弟节点是按字典顺序排列的,然后对构建好的字典树层序遍历,每遍历到一个节点就把该节点的所有孩子节点写入到数组中,该节点的entry值就是数组的结束索引,因为字典树里某个节点的孩子节点之间本身就按字典序排列,所以在构建的过程中只需从左到右填充数组即可。
单数组字典树针对词典固定的场景,很好的兼顾了时间复杂度和空间复杂度,并且依然可以离线构建在线加载,是一种非常优秀的实现。
优点:查询复杂度低,空间利用率高,离线构建在线加载
缺点:使用场景比较单一
单数组字典树的实现代码稍后给出

参考文献

小白详解 Trie 树