前段时间逛论坛,发现了一篇高效的字典树实现论文,很有意思。
常见的字典树实现方法
1 | class Node{ |
class Node{
uint node;
map<> next;
}
1 | 第一种保证了查找效率,但是对于字典树这种稀疏数组,空间利用率比较低,特别是遇到中文日文这种,会造成空间极大浪费。因此,部分选择了第二种实现,当然第二种可以保证空间,但是却是在牺牲了效率的基础上的改进。map的查找速度```O(logn)```肯定不如数组```O(1)```快。 |
对于base和check数组虽然1
2
而我那天看到的论文是在此基础上的改进。论文中配图下的小标题,作者取名为```ReducedTrie
它和原生的双数组字典树的最明显的区别在于,它多了一个tail数组,这个数组的含义就像它的英文含义一样。reducedTrie的base, check数组仅存储前缀部分,而非前缀部分全部放到tail数组中。
那么如何定位tail数组的位置呢?在base数组之中,每个字符串结尾的字符的base值为其后缀在tail的下标的负值。举例说1
2
3
4
5
6
7
8
9
10
好处很明显,节约了base, check的空间减少了非前缀部分的计算,同样也有缺点,最大的缺点个人觉得是在存储为文件上,由于之前只需要base, check数组,并且是两个数组成对出现,因此很容易将字典树压缩离线存储为一个文件,而tail数组和base, check数组并无关系,因此需要两个文件存储。另一点就是tail数组的碎片太多,这个后面说插入会讲到。
## 具体实现
其实讲了tail数组的作用之后,再结合双数组字典树,就能够很快理解这个结构了。
### insert
插入是比较复杂的部分。分为四种情况处理。论文原文如下
- Insertion of the new word when the double-array is empty.
- Insertion of the new word without any collisions.
- Insertion of the new word with a collision; in this case, additional characters must be added to the BASE and characters must be removed from the TAIL array to resolve the collision, but nothing already in the BASE array must be removed.
- When insertion of the new word with a collision as in case 3 occurs, values in the BASE array must be moved.
1
2
拙略地翻一下,大致是
1.当数组为空则直接插入新节点.(体现节点为空检查check数组为0即可)
2.没有任何冲突的插入.
3.插入新节点时发生了一种不需要修改base数组,但是必须修改tail和多添加base字符用以解决冲突的冲突。
4.插入新节点发生了类似3的情况,但是base数组的值必须被清空
1 |
|
其中xCheck(List list)是从1查找一个的值q,能够让list中的任何一个变量x都满足check[q+x]=0
moveTail(int x)即在x位置开始直到读到结束字符这段区间内,tail向左移动一位
writeTail(int[] value, int x)从value数组的x位置开始向tail数组写入。
put(int key, value) 缓存pre节点的孩子
初始化时候base[1] = 1 check[1] = 1
1 |
|
第二种冲突
- @param node1为当前节点位置
- @param node2为另一个已存在的与node1发生冲突的节点
- @param newNodeValue为需要插入的节点值
请注意巨坑点 :))))
1 | public int processConflict(int node1, int node2, int newNodeValue) { |
如果你还是有所疑惑,建议阅读An Efficient Implementation of Trie Structures这篇原文或者翻译版。
search
搜索就很简单了,按照文章最开始的
1 | public boolean search(int[] key) { |
delete
对于删除操作,只需要找到每个单词最后一个节点,释放它即可。
1 | public boolean delete(String key) { |
usage
字典树最常见的用途就是前缀匹配和前缀提示,trie树建立成功之后就可以根据输入的前缀去寻找从这个节点出发所有可能的字符串。这里采用深度优先遍历。
1 | private void find( int pre, StringBuilder builder, List<String> list) { |
总结
好了,还记得之前我所说过这种结构的缺点中的一点是建立过程tail数组的碎片化严重吗?为什么这么说呢,因为在处理第一种冲突的时候,会不断地移动的tail数组,比如bachelor和badge,原本tail数组中是achelor#由于对比a==a
成功,所以向左移动一位,chelor?#而这个’?’的部分,就无法被后续的节点插入使用,当然也是有解决办法的,需要在树建立成功之后整块移动。
我在论坛上看到一种看法说这种写法在插入时发生冲突概率这么大,有什么实用性呢?其实这种结构在插入过程慢一点是无所谓的,字典树用途主要看它的查找效率。我们完全可以用长时间建立然后存储每个节点的值,第二次直接读进内存,就可以直接使用,建立过程只需要一次即可。
//第一次码这么多字,好累啊
//不过双数组字典树性能真的很强
测试源代码:ReducedTrie