学习笔记:字符串-Trie树
Trie树,字典树,前缀树
Trie树的出现主要是用于查找多个字符串是否出现或者统计出现次数。或者从它的另一个名字入手会更好理解一点:Trie就是在构造一个包含多个字符串的字典,所有能够类比查字典进行的操作都可以通过Trie来实现,比如说看一个字符串有没有出现过,就是在查字典,看有没有这个字符串。
当然这里的查字典肯定不是暴力的从第一个字符串一直遍历到最后一个字符串(那干嘛还要搞一个这个Trie)。想一下平时查字典的样子,是不是先找第一个字母、再找第二个字母直到最后一个字母。那Trie就是类似的查询操作。
Trie除了处理查询以外,还必须先实现的一个操作是构建字典。而在构建字典的时候如果使用树形的结构,树上的一个节点表示一个字母,而通过子节点和父节点的关系来表示字母出现的前后顺序,就能很方便通过树的遍历完成查字典的操作。同时一个父节点又可以有多个子节点,这样就提高了连续的查询效率。
建树
也就是先完成写好字典这个步骤。
先根据我们刚才的想法定义一下树的节点,它应该需要记录它的所有子节点(相当于是字典中这个字符串的存在的下一位字符有哪些),还需要一个标记来标出这个字符是不是某一个字符串的结尾。那么这个节点自己的值呢?我们只需要利用当前节点中记录好的子节点信息来确定下一个节点,所以下一个节点的值可以直接通过父节点确定子节点的这个过程中得出了,所以就不需要再存当前节点的值了。(或者直接说是把子节点的值放在了边上)
在这里先假设所有字符串都是小写字母:
struct Node {
int son[26];//全小写的字符串里下一个字符最多有26种(如果包含大小写,就应该是52,以此类推)
//如果当前节点链接了字母x,那么son[x-'a']就表示字母x对应的子节点的编号
//如果当前节点没链接字母x,那么son[x-'a']就等于0
bool end;
//表示当前节点是不是某一个字符串的结尾
Node() {//初始化
end = 0;
for (int i = 0; i < 26; i++)
son[i] = 0;
}
}tr[1000010];
当我们确定了使用树结构后,首先需要确定树的根,但由于字符串的第一个字符可能有很多种,如果一个字符建一棵树就会很麻烦,所以我们在这里使用一个不表示任何字母的虚根,每一个字符的第一个字符都是这个虚根的子节点(最简单的设置虚根的做法就是拿编号为0的节点作为虚节点)。在那之后,我们只需要顺着树找子节点,如果有对应下一个字符的子节点就从这个子节点接着遍历字符串;如果没有呢?就新建一个子节点来对应当前字符就行啦:
int tr_cnt = 0;//统计当前Trie树中共有多少节点
void insert(string& x) {
int u = 0;//表示当前节点编号,从根(0)开始遍历
int v;//表示目标节点的值,就是下一位字符
for (int i = 0; i < x.length(); i++) {
v = x[i] - 'a';
if (!tr[u].son[v])//字典里不存在下一个字符为v的子节点
tr[u].son[v] = ++tr_cnt;//创建新的子节点,记录编号
u = tr[u].son[v];//移动到对应的子节点,接着加入后面的字符
}
tr[u].end = 1;//遍历到的最后一个子节点就是这个字符串的结尾
}
查询
现在我们建好了Trie树,剩下的就是查询了。查询是求给定的字符串在不在字典中,查询的过程其实和创建的过程类似,只是如果字典里不存在某一个子节点的时候,我们需要的不是像建树那样的创建新节点,而是应该直接返回这个字符串不在字典中。以及还有可能有这种情况:字典里有以给定的字符串为前缀的字符串但是却没有这个字符串(比如字典里有abcd但是没有abc),这样的话每一个子节点都是存在的,所以我们还需要判断一下字符串的结尾在字典中的节点有没有结尾标记:
bool check(string& x) {
int u = 0, v;//与insert()里含义相同
for (int i = 0; i < x.length(); i++) {
v = x[i] - 'a';
if (!tr[u].son[v])//字典里不存在下一个字符为v的子节点
return 0;//与建树的不同点:直接判断字典里没有这个字符串
u = tr[u].son[v];
}
//检测给定字符串的结尾在字典中的end标记
return tr[u].end;
}
查询函数是Trie里最能整活的,比如说可以通过修改循环变量的初值与终止条件来达到判断子串的目标等等。
不同形式的模板
数组型
刚才我们所写的在每个节点中开了一个数组来存放子节点信息,这种做法的好处就是简便与方便调试。但数组型也有一个缺点,就算占用空间很大。每一个节点要有对应的26个的子节点,占用当然大。完整模板如下:
struct Node {
bool end;
int son[26];
Node() {
end = 0;
for (int i = 0; i < 26; i++)
son[i] = 0;
}
}tr[50010];
int tr_cnt=0;
void insert(string& x) {
int u=0, v, lenx = x.length();
for (int i = 0; i < lenx; i++) {
v = x[i] - 'a';
if (!tr[u].son[v])
tr[u].son[v] = ++tr_cnt;
u = tr[u].son[v];
}
tr[u].end = 1;
}
bool check(string& x) {
int u = 0, v;
for (int i = 0; i < x.length(); i++) {
v = x[i] - 'a';
if (!tr[u].son[v])
return 0;
u = tr[u].son[v];
}
return tr[u].end;
}
指针型
为了解决数组型的内存困扰,改用了动态分配内存(new)的方法来消除了数组型结构体到底应该开多大的问题,大幅减少了内存的消耗。有一点麻烦的就是不太方便调试,而且指针操作容易出问题。改动后的模板如下:
struct Node {
bool end;
Node *son[26];
Node() {
end = 0;
for (int i = 0; i < 26; i++)
son[i] = NULL;
}
}*root;//这里的root需要在执行insert前new一下,相当于是创建虚根
void insert(string& x) {
Node* u=root;
int v;
for (int i = 0; i < x.length(); i++) {
v = x[i] - 'a';
if (u->son[v]==NULL)
u->son[v] = new Node();
u = u->son[v];
}
u->end = 1;
}
bool check(string& x) {
Node* u = root;
int v;
for (int i = 0; i < x.length(); i++) {
v = x[i] - 'a';
if (u->son[v] == NULL)
return 0;
u = u->son[v];
}
return u->end;
}
复杂度
时间复杂度:\(O(n)\) n为所有待查询字符串的字符个数
空间复杂度:最坏 \(O(k^m)\) k为字典中的字符种数,m为字典中最长字符串长度
非常明显的可以看到,Trie树是拿空间换时间的做法,所以在使用时一定格外小心有没有MLE