tire树(字典树) 模板
经典问题:
看以下几个题:
1、给出n个单词和m个询问,每次询问一个单词,回答这个单词是否在单词表中出现过。
答:简单!map,短小精悍。
好。下一个
2、给出n个单词和m个询问,每次询问一个前缀,回答询问是多少个单词的前缀。
答:map,把每个单词拆开。
judge:n<=200000,TLE!
这就需要一种高级数据结构——Trie树(字典树)
一、适用的问题
字典树最能够天然处理的,是这两种问题:
1:构建字典,并查找指定的“完整字符串”
2:构建字典,并查找指定的“前缀”(包括:这个前缀是否出现过,出现了多少次等等)
其他问题,如后缀问题、子串问题,都只能是在以上两种的基础上变形。
其中,又属问题2最适用字典树,如果是问题1,可以有其他很多替代方法(比如map)
二、字典树的原理
字典树,就是一棵存储多个字符串的树
其中,根绝点不包含字符,只是作为把所有字符串第一个字符串起来的导引
对于其他的每一个结点,从根遍历到他的过程就是一个单词
如果这个节点的is_end被标记为大于1的数,就表示这个单词存在,否则不存在
相同的字符串前缀共享同一条分支。
例如:
给出一组单词,inn, int, at, age, adv, ant, 我们可以得到下面的Trie:
- 每条边对应一个字母。
- 每个节点对应一项前缀。叶节点对应最长前缀,即单词本身。
- 单词inn与单词int有共同的前缀“in”, 因此他们共享左边的一条分支,root->i->in。
三、字典树的数据结构实现(单个节点的数据结构)
struct node{ int next[26]; int cnt; int is_end }; node arr[1000000]; //内存池 int used;
- next [26]:相当于,指向下面一个节点的指针。
例如,next[i]存储的是:对于所有下一个字母是i的字符串,它们下个节点所在的位置的下标(在总空间arr中);
也就是说,如果该字符串下一个字母是i,那么它的下一个结点所在的位置是:arr[this.next[i]]
- cnt:计数器,它的数值代表:这个节点对应的前缀的出现次数;也就是:经过这条路径的次数。
- is_end:字符串结尾标记。is_end只要大于0,就说明这个节点是某个字符串的结尾,也说明:当前路径的这个单词存在。is_end的具体数值,代表:这个单词在字典中出现的次数
- arr[1000000]:内存池。是预先分配出来的总空间,用于模拟动态分配,字典树所有节点所需要的空间都从这里面取
- used:用来标记,现在内存池arr数组被用到哪里了。
其实上,这个是完整版,并不是所有问题,都需要所有这些成员,比如并不是每次都需要cnt。但反正方便打板,每次都直接写这个完整版了
四、字典树的构造 (build函数)
原理:
调用一次build函数,就可以把一个单词插入字典树。
其实就把,这个单词里的每个字母,逐一插入到Trie里。
注意:
插入前,先看对应的分支是否已经存在。如果存在,就共享;不存在,就新建对应结点和边(从内存池中分配新的空间给它)。
如何判断是否存在:如果next指针等于0,就说明这条分支还没有被建立,不存在。因为下一个结点在arr中的所在位置显然不可能是0,0是根节点的位置
void build(char* str) { int pos = 0; //每次都从字典树的根节点开始找 int i = 0; while(str[i]!=0) { int tmp = str[i]-'a'; if(arr[pos].next[tmp] == 0)//如果该分支还不存在,就创建新结点。经过这个if分支,就可以保证,下一个结点一定存在了(不存在的已经创建好了) { used++; arr[pos].next[tmp] = used; } pos = arr[pos].next[tmp];//先移动到下一个结点 arr[pos].cnt++; //再改变cnt(改变的是,现在站在的这个节点的cnt) i++; //别忘了移动str字符串 } arr[pos].is_end++; //字符串str结束后,在str末尾结点,打上标记 }
注意代码的细节:
拿字符串“012”的构建来举例:
1:跳出while循环的时候,末尾字符“2”所在的结点,已经被处理过了,它的cnt已经被加过了。
因为while在遇到结尾符'\0'才跳出,而遇到末尾字符“2”,会进入循环进行处理
2:最后的 is_end 标记,是打在了,末尾字符“2”所在的结点上。因为跳出while之后,pos还没后移,pos还指向“2”所在节点。
也就是说,is_end 标记,永远都会被打在,字符串最后一个“有效字符”所在的节点上。
五、字典树的查找(大致分为两类问题:查找前缀,和查找完整的给定字符串)
例子1:查找字典中是否存在指定的完整字符串(专题一1007)
int find(char* str)
{
int i = 0;
int pos = 0;
while(str[i]!=0)
{
int tmp = str[i]-'a';
if(arr[pos].next[tmp] == 0)//一旦中途发现,有一个边,字典树根本没有,那就直接肯定不存在,返回
{
return 0;
}
pos = arr[pos].next[tmp];
arr[pos].cnt++;
i++;
}
if(arr[pos].is_end)
return 1;
else
return 0;
}
注意:
1:在移动pos前,一般都会先判断想要移动到的目标节点arr[pos].next[i]是否已经存在。这大概是个习惯了吧
2:因为是查找完整的字符串,所以最后的最后,一定要看到明确那个is_end标记,才算找到。
例子2:判断某个字符串,是否以字典内另一个字符串为前缀(专题一1001)
void find(string &str,int* flag)
{
int pos = 0;
int i = 0;
while(str[i+1]!=0) //注意这里,一定要是i+1。下面有解释
{
int tmp = str[i]-'0';
if(arr[pos].next[tmp] == 0)
return;
pos = arr[pos].next[tmp];
if(arr[pos].is_end)
{
*flag = 1;
break;
}
i++;
}
return;
}
对上面那个的解释:因为is_end记号是加在“01”中的“1”上,而不是末尾结束符号上;任何一个字符串到了最后一个字符,都会有is_end=1.所以不要看到最后一个字符,看到倒数第二个字符就可以停了。
例子3:查询前缀出现的次数(专题一1002)
int find(char* str)
{
int pos = 0;
int i = 0;
while(str[i]!=0)
{
int tmp = str[i]-'a';
if(arr[pos].next[tmp] == 0)//一旦发现有一个边,字典树根本没有,那就直接肯定,这个前缀根本不存在,直接返回0
{
return 0;
}
pos = arr[pos].next[tmp];
i++;
}
return arr[pos].cnt; //已经移动到了末尾,那么当前节点cnt的意义就是,当前子串总共出现的次数
}