目录(?)[+]
Trie是一种常见的数据结够,可以实现前缀匹配(hash是不行的),而且对于词典搜索来说也是O(1)的时间复杂度,虽然比不上Hash,但是空间会省不少。
比如下图表示了包含“pool, prize, preview, prepare, product, progress"的一个Trie
Trie的逻辑结构:每个圆圈都表示一个状态,比如状态1,状态之间的边表示状态1遇到字符p就变成状态2。用两个圈画的状态表示终止状态,也就是表示匹配了一个单词。
这是DFA的表示方法,当然按照正规的定义,还得有个“吸收”所以非法字符的状态,比如状态1碰到p之外的任何字符都会匹配失败,也就是会进入这个“吸收”状态,这个状态就像
黑洞,进去之后就永远没有出头之日了------永远在那个状态跳转。
从上面可以知道,要表示一个Trie,关键就是一个跳转矩阵(DFA里的正式名字是状态转移表),比如上图可以这样表示
1 | 2 | 3 | 4 | .. | |
---|---|---|---|---|---|
p | 2 | X | X | X | |
r | X | X | X | X | |
o | X | 3 | X | X | |
e | X | X | 4 | X | |
.. |
X就表示那个“吸收”状态。
从上表看出,如果有N个状态,并且字母表的大小是M,那么逻辑上就是一个N*M的数组。M一般很容易知道,比如对于英文单词,M可能是26或者52,对于汉字,可能有好几千。
而N很显然和词典的大小有关系,词典越大,那么N一般也越大。另外也与词典数据有关,比如词典的词共有的前缀很多,那么N就越少;反之N就越大。
可以看出,一般这个二维数组会比较稀疏,所以可以压缩空间。
最容易想到的压缩方法当然是链表。比如把状态1可以接受的字符组成一个链表,但是链表的缺点是无法实现随机访问,这样效率会有问题。
我们也可以把链表换成树的结构,比如红黑树,这样可以log(n)的速度。但是还是比不上数组的o(1)的速度。
这时我们肯定想到了Hash,没错,使用Hash比不压缩的数组省空间(数组也可以理解为Hash),而且速度也慢不了很多。
但是Hash总会是有冲突的(当然可以构造Perfect Hash,但是如果数据经常变化,那么就不好处理),能不能既有数组般的随机访问性能,又能节省大量空间的方法呢?
这就是我们要讲到的Double Array Trie。不过先别急,我们先讨论Triple Array Trie。了解这种压缩的思路。
TAT的思想很简单,由于每个状态接受的字符很有限,大家可以共享一个数组。比如字母表是a-z这26个英文字母,我们可以用0-25这26个数组表示它们。
比如状态1接受“a,c,e",那么我们可以把找一个“base”。可以把这个“base”理解成这个状态的Hash值。然后base,base+2,base+4就分配给状态1了。
又假设状态2接受“b,d",那么状态2也可能Hash到和状态1相同的base,然后把base+1,base+3分配给状态2。这样它们能够相安无事的共存。
不过我怎么能知道base是属于状态1,而base+1是属于状态2呢?这就需要一个check的数组来标识了。
比如上图:状态s碰到字符c就变成状态t,那么首先从base里找到s的“hash地址”,这个地址指向base=base[s],然后base+c我们分配给c的地址,通过check[base+c]==s我们知道
这个地址确实是分配给了s,所以我们读取next[base],它的值就是t。这样你给我s和c,我通过上面的过程就能告诉下一个状态就是t。
我们来比较一下TAT和二维数组的时间和空间开销。
二维数组:你给我s和c,我立马就能告诉你t,array[s*字母表大小+c],当然需要一次乘法和加法算下标。内存读取一次。
TAT:给我s,首先读取base[s],然后计算base[s]+c,然后读取chk=check[base[s]+c],然后一次判断,如果chk==s,那么再读取一次next[base[s]+c]得到t。3次访问内存,一次加法
二维数组:M*N*4(有一个32bit的int表示)
TAT:状态个数+双数组的长度,这个值比较难估计,与词典的数据分布有关。我使用了一个随机生成的例子:字母表大小26,词典大小20,000,N=154825,使用DAT后next和check的大小是
168505(因为没有实现TAT,所以我这里只能用DAT来估计,但TAT应该和DAT是差不多的。而且我目前使用的DAT使用了check压缩,这样导致双数组的大小会稍微大一些,check数
组的压缩参考下面)。
我们简单的比较一下:二维数组 26*150k*4=15M; TAT 150K*4+170K*8=2M,可以看出空间节省了多少!!如果像汉字这样字母表更大的词典,那么会节省的更多。
从上面的分析我们看出,实现TAT的关键就是给每个状态一个合适的base,比如上面的例子,如果状态1的base是0,那么它就会占用next[0],next[2],next[4],如果我们不小心把状态1的
base弄成了1,那么它会占用next[2],next[4],这样就“冲突”了,所以要避免这种情况。如果出现了,我们就必须给某个状态,比如状态2分配一个新的base。
上图就展示了由于冲突,我们需要修改base[s]的例子。我们需要找到原来的base,然后遍历next[oldBase+0...字母表大小-1],如果next值为s,说明这个next是属于s的,那么需要
把它“搬”到合适的地方,然后原来的check从s变成none,新地址的check从none变成s。
看起来TAT已经很不错了,但是还是有冗余的信息。
不过之前需要说明这样一个前提:Trie是一颗树,构造Trie时,只会增加状态;删除单词时,首先删除孩子,然后才能删除父亲。
形式化一点:假设状态s遇到c变成状态t,那么就不会有另一个状态r遇到c变成状态t(否则一个节点有两个父亲,那就不是树了)。
这有什么用呢?如果s遇到c变成t,s是t的父亲,t是s的孩子,那么t只能从s过来,那么就没有必要在next数组里指向base里,而可以直接让t=base[s]+c
如果看上图,那么就是所有的next[i]=i,也就是不需要next数组了。
这个可能有的绕,需要这样理解:状态只是一个数字,叫1还是2并不重要,反正是个唯一的标识就行了,比如原来状态0遇到c变成状态1,状态1遇到d变成状态2,那么我把状态1改成状
态100完全是没有区别的:状态0遇到c变成状态100,状态100遇到d变成状态2。
状态本身并不重要,重要的是它的base(可以理解为hash地址)
它的搜索过程如下:给定s和c,直接检查chk=check[base[s]+c],如果chk==s,则t=base[s]+c,也就是把原来的base和next数组合并成为一个。
也许你会有这样的担心(细心的读者),万一base[s]+c被别人用了呢?当然可以调整base[s],这时t也跟着s变化。有没有怎么调整也冲突的情况呢?
考虑一下s遇到c变成t,已经r也遇到c变成t,这会怎么样?不论你怎么调整,因为base[s]=t-c=base[r],也就是s和r的base相同,这没什么,关键是check数组
只能一个,要么s,要么r,这种情况没法处理。不过想想前面,Trie是一颗树,所以t只能有一个父亲节点,所以上面的例子是不可能出现的。
同样的,如果给s增加一个孩子t(通过字符c),那么万一base[s]+c已经被别人使用了check[base[s]+c]=other,那么就必须给s的base换个地方,参考下图:
除了要修改t和t‘的check外,还需要把t’的base改成原来t的base。
比如前面的例子,pool,状态3的后代最多只有一个孩子,也就是一个链(没有更多分支),所以可以把状态4和5去掉,然后状态3做为叶子节点,用一个指针指向字符串“ol”。
注意:这里的DATrie是指有后缀压缩的DATrie。如果没有后缀压缩,其实也类似。
根据插入点的位置,可以分为两种情况。
首先我们找到插入点,也就是在Trie树上不停的走,直到在非叶子节点遇到不能接受的字符或者遇到叶子或者所有的字符都走完了。
第一和第三种情况可以合并成一种,它们唯一的不同时,前者的后缀不空,后缀的后缀为空(#)表示。
比如现在的trie树如上,
我们要插入“pooch”,那么就是第二种情况,我们需要在状态3增加一个状态t, 3经过o变成t,然后t分成两个分支,一个是l,一个是c。
如果要插入“poa“,那么是第一种情况,如果要插入”po“,那么是第三中情况。这两种情况都需要从3增加状态,但是原来的孩子不需要改变。
插入po,只需要给3增加一个孩子t,边上的字符是#,然后t是叶子,
插入poa,需要给3增加孩子t,边上的字符是a,a是叶子节点,指向#
也就是说,第二种情况需要修改原来的tail(后缀压缩部分)
删除一个词首先需要找到这个词的路径,然后反向一个一个删除状态,直到遇到某个状态------这个状态至少有两个分支(也就是删除当前分支后还有分支)。
如果有后缀压缩的话,那么可以再压缩后缀(当然也可以不压缩)。比如上面的例子,删除“produce”,那么首先删除状态14,然后可以压缩状态15,13,12,11,让
状态10直接指向ucer#
我们这里讨论的DAT是一种动态数据结构,会不停的往里面插入删除单词。
这个时候就需要动态管理双数组了。因为如果base和check被使用的话,那么它们的值会大于等于0,所以可以让没有使用的base和check的值为-1,比如需要找
空闲的base时,我们可以从头开始扫描base,碰到-1就找到一个空闲的空间。
这种办法简单容易实现,但缺点是时间复杂度比较高。如果对插入删除要求不高的话,那么这种方法就比较简单可行,比如后面我们讲到的Static的DAT【构建一次,永不修改】
就可以使用这种方法。
改进的办法就是把空闲的空间组织成链表。我们可以用负数代表空闲,然后它的绝对值代表下一个空闲单元的地址(下标)。
check[0] = -r1
check[ri] = -ri+1 ; 1 <= i <= cm-1
check[rcm] = -1
这里只使用了check来表示空闲单元,其实check空闲,那么对应的base也是空闲的。那么其实可以也利用上,来组织成一个循环链表:
check[0] = -r1 check[ri] = -ri+1 ; 1 <= i <= cm-1 check[rcm] = 0 base[0] = -rcm base[r1] = 0 base[ri+1] = -ri ; 1 <= i <= cm-1
对于英语来说,一般只有26个字母(或者52个,如果考虑大小写)+一些数字等,一般一个字节就可以表示下来。然后可以使用比较简单的算法把它们映射成0开始的连续整数。
比如只有字母和数字可以使用如下算法:
int getIndex(char c){ if(ch >='a' && ch <='z') return ch-'a'; else if(ch >='0' && ch <='9') return ch-'0'; else return -1; }
如果字母表很大,比如汉字,那么可能需要一个HashMap<Character,Integer>来保存了。不过这样的速度可能有问题,由于一般字符编码都会是连续的区域,所以也可以参考上面的方
法来实现,这样既省空间,又速度更快。
对于汉字这种“宽”字符,还有一种办法,那就是先把它转成多个单字节的数组。比如“北京”的unicode是“\u5317\u4eac“,那么可以把它看出4个字节。这样字母表最多256,正好可以
用一个字节表示。
http://linux.thai.net/~thep/datrie/datrie.html#AnImp 这里有个c语言的实现,使用了标准的DAT实现,有后缀压缩。可以嵌入到自己的c程序中,也可以做为独立的程序运行。
下面介绍一下做为独立程序使用的方法。
从网站下载,解压,标准的tar包,./configure && sudo make install安装。
默认程序安装在/usr/local/bin/trietool-0.2,so安装在/usr/lib/libdatrie.so.1,可以使用man trietool-0.2 查看用法。
要构造一个trie 名字叫test,首先需要告诉它我们的字母表,创建一个test.abm,比如我们的词典只有大小写的英文字母
check数组的压缩
- lili@lili-desktop:~/datrie/libdatrie-0.2.4$ cat test.abm
- [0x0041,0x005a]
- [0x0061,0x007a]
- lili@lili-desktop:~/datrie/libdatrie-0.2.4$ trietool-0.2 test add abcd 0
- lili@lili-desktop:~/datrie/libdatrie-0.2.4$ trietool-0.2 test add abce
- lili@lili-desktop:~/datrie/libdatrie-0.2.4$ trietool-0.2 test add abcf
- lili@lili-desktop:~/datrie/libdatrie-0.2.4$ trietool-0.2 test add abcg 1
- lili@lili-desktop:~/datrie/libdatrie-0.2.4$ trietool-0.2 test query ab
- query: Key 'ab' not found.
- lili@lili-desktop:~/datrie/libdatrie-0.2.4$ trietool-0.2 test query abce
- -1
- lili@lili-desktop:~/datrie/libdatrie-0.2.4$ trietool-0.2 test query abcg
- 1
- lili@lili-desktop:~/datrie/libdatrie-0.2.4$ trietool-0.2 test delete abcg
- lili@lili-desktop:~/datrie/libdatrie-0.2.4$ trietool-0.2 test query abcg
- query: Key 'abcg' not found.
- lili@lili-desktop:~/datrie/libdatrie-0.2.4$ trietool-0.2 test delete abcg
- No entry 'abcg'. Not deleted.
- 当然一个一个添加词典很麻烦,可以指定一个词典文件,这个文件的格式是一行一个词。
- 比如
- lili@lili-desktop:~/datrie/libdatrie-0.2.4$ trietool-0.2 test add-list /usr/share/dict/words
- lili@lili-desktop:~/datrie/libdatrie-0.2.4$ trietool-0.2 test query AOL
- -1
在DAT里,如果s遇到c变成t,那么就是base[s]+c=t,check[t]=s,如果我们能保证任意两个状态的base都不相同,那么我们可以不用在check数组存s,而只需要存c。
原来check数组里保存的是s,说明这个位置留给了s,base[s]+c=t,如果还有一个状态r,比如base[r]=base[s],那么根据check[t]=s可以判断是从s->t而不是r->t。
如果我们做一个限制,让所有的状态的base都不同,那么我们就可以在check[t]里保存c而不是s,因为t-c就是s。
这样做有什么好处呢?一般的应用中,字符数远远小于状态数。比如英语,字母数可能不到100,8位足以表示。比如汉语,字母数可能小于4k,12位就可以表示了。
这样由于base的限制,虽然会导致base和check数组增大一些(我的随机实验这两个数组会稍微大一些,但是不会超过5%),但是这两个数组的大小会从8个字节变成
5个字节(英文为例),那么节约的空间还是非常可观的。
这种方法一般用作静态的(构造一次不再修改)DAT里,因为如果总是插入删除的话要保证base不重复代价更大。
此外DAT除了用来判断前缀匹配之外,可能把它用作Map这样的数据结构,所以还可以用check节省下来的位数来保存一个下标(指针)。
1. http://linux.thai.net/~thep/datrie/datrie.html
2. http://www.chokkan.org/software/dastrie/