Trie 树, 又称字典树,单词查找树。它来源于retrieval(检索)中取中间四个字符构成(读音同try)。用于存储大量的字符串以便支持快速模式匹配。主要应用在信息检索领域。
Trie 有三种结构: 标准trie (standard trie)、压缩trie、后缀trie(suffix trie) 。最后一种将在《字符串处理4:后缀树》中详细讲,这里只将前两种。
1. 标准Trie (standard trie)
标准 Trie树的结构: 所有含有公共前缀的字符串将挂在树中同一个结点下。实际上trie简明的存储了存在于串集合中的所有公共前缀。 假如有这样一个字符串集合X{bear,bell,bid,bull,buy,sell,stock,stop}。它的标准Trie树如下图:
上图(蓝色圆形结点为内部结点,红色方形结点为外部结点),我们可以很清楚的看到字符串集合X构造的Trie树结构。其中从根结点到红色方框叶子节点所经历的所有字符组成的串就是字符串集合X中的一个串。
注意这里有一个问题:如果X集合中有一个串是另一个串的前缀呢?比如,X集合中加入串bi。那么上图的Trie树在绿色箭头所指的内部结点i 就应该也标记成红色方形结点。这样话,一棵树的枝干上将出现两个连续的叶子结点(这是不合常理的)。
也就是说字符串集合X中不存在一个串是另外一个串的前缀 。如何满足这个要求呢?我们可以在X中的每个串后面加入一个特殊字符$(这个字符将不会出现在字母表中)。这样,集合X{bear$、bell$、.... bi$、bid$}一定会满足这个要求。
总结:一个存储长度为n,来自大小为d的字母表中s个串的集合X的标准trie具有性质如下:
(1) 树中每个内部结点至多有d个子结点。
(2) 树有s个外部结点。
(3) 树的高度等于X中最长串的长度。
(4) 树中的结点数为O(n)。
标准 Trie树的查找
对于英文单词的查找,我们完全可以在内部结点中建立26个元素组成的指针数组。如果要查找a,只需要在内部节点的指针数组中找第0个指针即可(b=第1个指针,随机定位)。时间复杂度为O(1)。
查找过程:假如我们要在上面那棵Trie中查找字符串bull (b-u-l-l)。
(1) 在root结点中查找第('b'-'a'=1)号孩子指针,发现该指针不为空,则定位到第1号孩子结点处——b结点。
(2) 在b结点中查找第('u'-'a'=20)号孩子指针,发现该指针不为空,则定位到第20号孩子结点处——u结点。
(3) ... 一直查找到叶子结点出现特殊字符'$'位置,表示找到了bull字符串
如果在查找过程中终止于内部结点,则表示没有找到待查找字符串。
效率:对于有n个英文字母的串来说,在内部结点中定位指针所需要花费O(d)时间,d为字母表的大小,英文为26。由于在上面的算法中内部结点指针定位使用了数组随机存储方式,因此时间复杂度降为了O(1)。但是如果是中文字,下面在实际应用中会提到。因此我们在这里还是用O(d)。查找成功的时候恰好走了一条从根结点到叶子结点的路径。因此时间复杂度为O(d*n)。
但是,当查找集合X中所有字符串两两都不共享前缀时,trie中出现最坏情况。除根之外,所有内部结点都只有一个子结点。此时的查找时间复杂度蜕化为O(d*(n^2))
标准 Trie树的Java代码实现:
package net.hr.algorithm.stroper;
import java.util.ArrayList;
enum NodeKind{LN,BN};
/**
* Trie结点
*/
class TrieNode{
char key;
TrieNode[] points=null;
NodeKind kind=null;
}
/**
* Trie叶子结点
*/
class LeafNode extends TrieNode{
LeafNode(char k){
super.key=k;
super.kind=NodeKind.LN;
}
}
/**
* Trie内部结点
*/
class BranchNode extends TrieNode{
BranchNode(char k){
super.key=k;
super.kind=NodeKind.BN;
super.points=new TrieNode[27];
}
}
/**
* Trie树
* @author heartraid
*/
public class StandardTrie {
private TrieNode root=new BranchNode(' ');
/**
* 想Tire中插入字符串
*/
public void insert(String word){
//System.out.println("插入字符串:"+word);
//从根结点出发
TrieNode curNode=root;
//为了满足字符串集合X中不存在一个串是另外一个串的前缀
word=word+"$";
//获取每个字符
char[] chars=word.toCharArray();
//插入
for(int i=0;i
if(chars[i]=='$'){
curNode.points[26]=new LeafNode('$');
// System.out.println(" 插入完毕,使当前结点"+curNode.key+"的第26孩子指针指向字符:$");
}
else{
int pSize=chars[i]-'a';
if(curNode.points[pSize]==null){
curNode.points[pSize]=new BranchNode(chars[i]);
// System.out.println(" 使当前结点"+curNode.key+"的第"+pSize+"孩子指针指向字符: "+chars[i]);
curNode=curNode.points[pSize];
}
else{
// System.out.println(" 不插入,找到当前结点"+curNode.key+"的第"+pSize+"孩子指针已经指向字符: "+chars[i]);
curNode=curNode.points[pSize];
}
}
}
}
/**
* Trie的字符串全字匹配
*/
public boolean fullMatch(String word){
//System.out.print("查找字符串:"+word+"\n查找路径:");
//从根结点出发
TrieNode curNode=root;
//获取每个字符
char[] chars=word.toCharArray();
for(int i=0;i
System.out.println('&');
// System.out.println(" 【成功】");
return true;
}else{
System.out.print(chars[i]+" -> ");
int pSize=chars[i]-'a';
if(curNode.points[pSize]==null){
// System.out.println(" 【失败】");
return false;
}else{
curNode=curNode.points[pSize];
}
}
}
// System.out.println(" 【失败】");
return false;
}
/**
* 先根遍历Tire树
*/
private void preRootTraverse(TrieNode curNode){
if(curNode!=null){
System.out.print(curNode.key+" ");
if(curNode.kind==NodeKind.BN)
for(TrieNode childNode:curNode.points)
preRootTraverse(childNode);
}
}
/**
* 得到Trie根结点
*/
public TrieNode getRoot(){
return this.root;
}
/**
* 测试
*/
public static void main(String[] args) {
StandardTrie trie=new StandardTrie();
trie.insert("bear");
trie.insert("bell");
trie.insert("bid");
trie.insert("bull");
trie.insert("buy");
trie.insert("sell");
trie.insert("stock");
trie.insert("stop");
trie.preRootTraverse(trie.getRoot());
trie.fullMatch("stoops");
}
}
中文词语的 标准Trie树
由于中文的字远比英文的26个字母多的多。因此对于trie树的内部结点,不可能用一个26的数组来存储指针。如果每个结点都开辟几万个中国字的指针空间。估计内存要爆了,就连磁盘也消耗很大。
一般我们采取这样种措施:
(1) 以词语中相同的第一个字为根组成一棵树。这样的话,一个中文词汇的集合就可以构成一片Trie森林。这篇森林都存储在磁盘上。森林的root中的字和root所在磁盘的位置都记录在一张以Unicode码值排序的有序字表中。字表可以存放在内存里。
(2) 内部结点的指针用可变长数组存储。
特点:由于中文词语很少操作4个字的,因此Trie树的高度不长。查找的时间主要耗费在内部结点指针的查找。因此将这项指向字的指针按照字的Unicode码值排序,然后加载进内存以后通过二分查找能够提高效率。
标准Trie树的应用和优缺点
(1) 全字匹配:确定待查字串是否与集合的一个单词完全匹配。如上代码fullMatch()。
(2) 前缀匹配:查找集合中与以s为前缀的所有串。
注意:Trie树的结构并不适合用来查找子串。这一点和前面提到的PAT Tree以及后面专门要提到的Suffix Tree的作用有很大不同。
优点: 查找效率比与集合中的每一个字符串做匹配的效率要高很多。在o(m)时间内搜索一个长度为m的字符串s是否在字典里。
缺点:标准Trie的空间利用率不高,可能存在大量结点中只有一个子结点,这样的结点绝对是一种浪费。正是这个原因,才迅速推动了下面所讲的压缩trie的开发。
2. 压缩Trie (compressed trie)
压缩Trie类似于标准Trie,但它能保证trie中的每个内部结点至少有两个子节点(根结点除外)。通过把单子结点链压缩进叶子节点来执行这个规则。
压缩Trie的定义
冗余结点(redundant node):如果T的一个非根内部结点v只有一个子结点,那么我们称v是冗余的。
冗余链(redundant link):如上标准Trie图中,内部结点e只有一个内部子结点l,而l也只有一个叶子结点。那么e-l-l就构成了一条冗余链。
压缩(compressed):对于冗余链 v1- v2- v3- ... -vn,我们可以用单边v1-vn来替代。
对上面标准Trie的图压缩之后,形成了Compressed Trie的字符表示图如下:
压缩Trie的性质和优势:
与标准Trie比较,压缩Trie的结点数与串的个数成正比了,而不是与串的总长度成正比。一棵存储来自大小为d的字母表中的s个串的结合T的压缩trie具有如下性质:
(1) T中的每个内部结点至少有两个子结点,至多有d个子结点。
(2) T有s个外部结点。
(3) T中的结点数为O(s)
存储空间从标准Trie的O(n)降低到压缩后的O(s),其中n为集合T中总字符串长度,s为T中的字符串个数。
压缩Trie的压缩表示
上面的图是压缩Trie的字符串表示。相比标准Trie而言,确实少了不少结点。但是细心的读者会发现,叶子结点中的字符数量增加了,比如结点ell,那么这种压缩空间的效率当然会打折扣了。那么有什么好办法呢,这里我们介绍一种压缩表示方法。即把所有结点中的字符串用三元组的形式表示如下图:
其中三元组(i,j,k)表示S[i]的从第j个位置到第k个位置间的子串。比如(5,1,3,)表示S[5][1...3]="ell"。
这种压缩表示的一个巨大的优点就是:无论结点需要存储多长的字串,全部都可以用一个三元组表示,而且三元组所占的空间是固定有限的。但是为了做到这一点,必须有一张辅助索引结构(如上图右侧s0—s7所示)。
3. Trie树与其他数据结构优缺点比较
对于二叉搜索树(BST)
1. 查找key更快,对于一个长度为m的key,最差时间复杂度是O(m)。而BST需要用O(logN)来比较key,N是树上元素的个数。由于查找依赖于树的深度,因此最差的情况下BST需要O(m log N)的时间。
2. Trie树需要更小的存储空间。
3. Trie树可以方便地用来做最长前缀匹配(LPM)。
对于hash表
1. 在最糟情况下,Trie的查找时间是O(m),它高于hash表的(N),因为hash表可能会遇到key冲突。不过典型的hash表查找时间是O(1).
2. Trie树中没有不同key的冲突问题。
3. 在新增key的情况下不需要像hash表那样增加或者修改hash函数。
4. Trie树可以提供key字母序的值。
Trie树缺点
1. Trie查找要比hash表来的要慢
2. 很多key是无法用字串来表示的,只能用多个字串来表示,例如浮点数。
Trie通常用来存储字典,比如说手机上的字典,它利用了Trie的快速查找和添加删除功能。但是,如果存储是最重要的考量的话,无环DFA是将会比Trie更节省空间。
Tire也能很好的应用在模糊匹配算法方面,包括一些拼写检查的软件。
4. double-array Trie(双数组字典树)
Trie在ACM中已经十分普及,它的本质就是一个确定的有限状态自动机(DFA),关于它的实现也是有好几种,ACM中用的最多也是最容易实现的就是多路查找树。
但是Trie最大的缺点就是占用空间过大,很容易爆内存,当然在ACM里对Trie树也有相应的优化,如限定高度,对分支较少的节点使用非随机访问的结构(减少宽度),但这些都是牺牲部分查找效率换取的。
这里介绍一种实现,Double-Array Trie(双数组字典树),其实它就是双数组,跟树结构没啥关系。他能在一定程度上减少内存的浪费。
两个数组,一个是base[],另一个是check[]。设数组下标为i ,如果base[i], check[i]均为0,表示该位置为空。如果base[i]为负值,表示该状态为终止态(即词语)。check[i]表示该状态的前一状态。
定义 1. 对于输入字符 c, 从状态 s 转移到状态 t, 双数组字典树满足如下条件:
check[base[s] + c] = s
base[s] + c = t
|
从定义1中,我们能得到查找算法,对于给定的状态 s 和输入字符 c :
t := base[s] + c;
if
check[t] = s
then
next state := t
else
fail
endif
|
插入的操作,假设以某字符开头的 base 值为i,第二个字符的字符序列码依次为c1, c2, c3…cn,则肯定要满足base[i+c1],check[i+c1], base[i+c2], check[i+c2],base[i+c3], check[i+c3]…base[i+cn],check[i+cn]均为0。
我们知道双数组的实现方法是当状态有新转移时才分配空间给新状态,或可以表述为只分配需要转移的状态的空间。当遇到无法满足上述条件时再进行调整,使得其 base值满足上述条件,这种调整只影响当前节点下一层节点的重分配,因为所有节点的地址分配是靠 base 数组指定的起始下标所决定的。
我们先得到重分配算法:
Procedure
Relocate(s : state; b : base_index)
{ Move base for state s to a new place beginning at b }
begin
foreach input character c
for
the state s
{ i.e. foreach c such that check[base[s] + c]] = s }
begin
check[b + c] := s;
{ mark owner }
base[b + c] := base[base[s] + c];
{ copy data }
{ the node base[s] + c is to be moved to b + c;
Hence, for any i for which check[i] = base[s] + c, update check[i] to b + c }
foreach input character d
for
the node base[s] + c
begin
check[base[base[s] + c] + d] := b + c
end
;
check[base[s] + c] := none
{ free the cell }
end
;
base[s] := b
end
如:有两个单词ac和da,先插入单词ac,这时的 base数组
如:有两个单词ac和da,先插入单词ac,这时的 base数组
从上图可见a和d的位置重新分配了, base值从0变成了1。
假设,Tire里有n个节点,字符集大小为m,则DATrie的空间大小是n+cm,c是依赖于Trie稀疏程度的一个系数。而多路查找树的空间大小是nm。
注意,这里的复杂度都是按离线算法(offline algorithm)计算的,即处理时已经得到整个词库。在线算法(online algorithm)的空间复杂度还和单词出现的顺序有关,越有序的单词顺序空间占用越小。
查找算法的复杂度和被查找的字符串长度相关的,这个复杂度和多路查找树是一样的。
插入算法中,如果出现重分配的情况,我们要附加上扫描子节点的时间复杂度,还有新base值确定的算法复杂度。假如这儿我们都是用暴力算法(for循环扫描),那插入算法时间复杂度是O(nm + cm2)。。
实际编码过程中,DATrie代码难度大过多路查找树,主要是状态的表示不如树结构那样的清晰,下标很容易搞混掉。
有个地方需要注意的是,base值正数表示起始偏移量,负数表示该状态为终止态,所以在查找新base值时,要保证查到的值是正数。
如:空Trie状态下,插入d时,因为第一个空地址是1,所以得到base=1-4=-3,这样base正负的含义就被破坏了。
关于优化:
base[i], check[i]均为0,表示该位置为空。我们可以把这部分给利用起来,全为0的标记所包含的信息实在太少了。我们利用base和check数组组成一个双向链表。
定义 2. 设 r1, r2, … ,rcm 为空闲地址有序序列,则我们的双向链表可定义为 :
check[
0
] = -r[
1
]
check[r[i]] = -r[i]+
1
;
1
<= i <= cm-
1
check[r[cm]] =
0
base[
0
] = -r[cm]
base[r[
1
]] =
0
base[r[i+
1
]] = -r[i] ;
1
<= i <= cm-
1
|
由于我们把base[0]作为根节点,所以初始化时就可以把base[0]排除在链表之外,而check[0]可以被作为链表的头节点。
设节点的状态转移集为P = {c1, c2, …, cp},依靠链表我们能得到新的空地址查找算法:
{find least free cell s such that s > c[1]}
s := -check[
0
];
while
s <>
0
and
s <= c[
1
]
do
s := -check[s]
end
;
if
s =
0
then
return FAIL;
{or reserve some additional space}
{continue searching for the row, given that s matches c[1]}
while
s <>
0
do
i :=
2
;
while
i <= p
and
check[s + c[i] - c[
1
]] <
0
do
i := i +
1
end
;
if
i = p +
1
then
return s - c[
1
];
{all cells required are free, so return it}
s := -check[-s]
end
;
return FAIL;
{or reserve some additional space}
|
优化后的空地址查找算法时间复杂度为O(cm2),而重分配算法的时间复杂度为O(m2),则总的时间复杂度为O(cm2 +m2) = O(cm2)。
重分配或删除节点后,原先的地址被作废,可以重新加入链表,这样如果遇到原状态转移集的子集时,就可以派上用场了。
其实这部分的优化就是使用了闲置信息域做成了链表,所以这部分的插入和删除优化原理就很容易理解了,时间复杂度为O(cm)。
t := -check[
0
];
while
check[t] <>
0
and
t < s
do
t := -check[t]
end
;
{t now points to the cell after s' place}
check[s] := -t;
check[-base[t]] := -s;
base[s] := base[t];
base[t] := -s;
|
当有节点删除时,我们不仅可以把它加回到链表中,还可以重新为最大非空节点的状态重新确定base值,因为删除可能导致它的前面有空间容纳下它的状态转移集。这样我们可能又得以删除一些空值状态,使得数组长度有希望被压缩。
这个思想借鉴于后缀树,我们可以将没有分支的后缀单独存放,但这个结构肯定独立于DATrie,所以在这就不详述了。详情见[Aoe1989]。
整体而言,在ACM中,DATrie略高的编码复杂度和过低的插入效率,应用面不会太广。但现实问题中,词库大小一般比较稳定,在离线算法也有很大的优化余地,此时DATrie的空间优势就会比较明显。毕竟Trie高效的检索效率这一优点是值得研究探讨的。
这篇日志写的够长了,等有空再把DATrie测试报告给整理下吧。唉,发现自己语言组织能力越来越差了,写的连自己有时都看不下去,要多坚持记下技术日志了~~
以下是只加入空地址查找优化后的DATrie代码,对于字符集表的映射结构也是个需要持续讨论的问题,在这个代码里只支持英文字母。
#define ALPHASIZE 30
#define MAX 10000
#define ALPHAID(x) (1+x-'a')
#define IDALPHA(x) (x-1+'a')
#define EMPTY(x) (basei[x] < 0 && check[x] < 0)
#define DELETE_FREE_NODE(x) check[-basei[x]] = check[x]; \
basei[-check[x]] = basei[x]; \
maxsize = max(maxsize, x)
#define ADD_FREE_NODE(x) basei[x] = MAX; \
check[x] = MAX; \
abnodes ++
class
DATire
{
public:
void
init() {
// double circular linked list (except 0)
for
(int
i = 1; i < MAX; i ++) {
check[i] = -(i+1);
basei[i] = -(i-1);
}
basei[1] = 0; // so check[0] can be updated
check[MAX-1] = 1;
// basei[0] is root-index
// check[0] point to first free cell
basei[0] = 0;
check[0] = -1;
// stat
diffwords = 0;
maxsize = 0;
nodes = 1;
abnodes = 0;
}
void
print(int
s, char* buf, int
d) {
if
(basei[s] < 0)
puts(buf);
int
si = abs(basei[s]);
for
(int
i = si+1; i <= si + ALPHASIZE; i ++) {
if
(check[i] == s) {
buf[d] = IDALPHA(i-si); buf[d+1] = '\0';
print(i, buf, d+1);
buf[d] = '\0';
}
}
}
bool
insert(string word) {
int
s = 0, t;
for
(int
i = 0; i < word.length(); i ++) {
char
ch = word[i];
t = abs(basei[s]) + ALPHAID(ch);
if
(s == check[t])
s = t;
else
if
(EMPTY(t)) {
DELETE_FREE_NODE(t);
basei[t] = t;
check[t] = s;
s = t;
nodes ++;
}
else
{
int
newb = findb(s, ALPHAID(ch));
if
(newb == -1)
return
false;
else
{
relocate(s, newb);
i --;
}
}
}
if
(basei[s] > 0)
diffwords ++;
basei[s] = -abs(basei[s]);
return
true;
}
bool
find(string word) {
int
s = 0, t;
int
i;
for
(i = 0; i < word.length(); i ++) {
char
ch = word[i];
t = abs(basei[s]) + ALPHAID(ch);
if
(s == check[t])
s = t;
else
break;
}
return
(i == word.length() && basei[s] < 0);
}
protected:
int
findb(int
s, int
newc) {
ns = 0;
int
i, j;
int
si = abs(basei[s]);
for
(i = si+1; i <= si + ALPHASIZE; i ++) {
if
(check[i] == s)
sonc[ns ++] = i - si;
}
sonc[ns ++] = newc;
int
minson = min(sonc[0], newc);
// i < si, the new place must be after old place
// i < minson, the negative base value has other meaning
for
(i = -check[0]; i != 0 && (i < si || i < minson); i = -check[i]) ;
for
(; i != 0; i = -check[i]) {
for
(j = 0; j < ns; j ++) {
if
(! EMPTY(i + sonc[j] - minson))
break;
}
if
(j == ns) {
ns --;
assert(i - minson >= 0);
return
i - minson;
}
}
return
-1;
}
void
relocate(int
s, int
b) {
for
(int
i = ns-1; i >= 0; i --) {
int
news = b + sonc[i];
int
olds = abs(basei[s]) + sonc[i];
DELETE_FREE_NODE(news);
check[news] = s;
basei[news] = basei[olds];
int
isi = abs(basei[olds]);
for
(int
j = isi+1; j <= isi + ALPHASIZE; j ++) {
if
(check[j] == olds)
check[j] = news;
}
ADD_FREE_NODE(olds);
}
basei[s] = (basei[s] < 0 ? -1 : 1) * b;
}
protected:
int
basei[MAX];
int
check[MAX];
// helper
int
sonc[ALPHASIZE];
int
ns;
public:
// stat
int
maxsize; // used memory size
int
nodes; // trie nodes
int
abnodes; // abandoned trie nodes
int
diffwords; // diff words
// free nodes = maxsize-nodes-abnodes
};