基本的字典树应用
定义
字典树又称单词查找树、Trie 树、键树,是一种用于实现字符串快速检索的多叉树结构,也是哈希树的一个变种。字典树在很多字符串问题中有着十分广泛的应用,比如存储字典、字符串的快速检索、求最长公共前缀、快速统计和排序大量字符串等,最典型的应用就是被搜索引擎系统用于文本词频统计。它的优点是能最大限度地减少无谓的字符串比较,查询效率比哈希表高,一般情况下也更节约空间。
当然,维护字符串集合的基本信息可以简单地用 堆(map)、哈希map(unordered_map)、哈希表等去维护,代码实现更是简单,但是,Trie 树可以用来实现其他更为高级的数据结构,并且也能通过修改代码细节来实现更为复杂的功能(比如求最长公共前缀)。
字典树有三个基本性质:
- 根结点不包含字符,或者我们认为所有单词都有一个共同的开头------空字符(这么定义是为了使得开头不同的单词可以在同一棵树上,方便后面处理)。除根结点外每一个结点都只包含一个字符;
- 从根结点到某一结点,路径上经过的字符连接起来,为该结点对应的字符串;
- 每个结点的所有子结点包含的字符都不相同。
举个例子,假设现在有一个单词集合,如下图右侧所列单词,其对应的字典树如下图:

可以发现,这棵字典树用边来代表字母,而从根结点到树上某一结点的路径就代表了一个字符串。举个例子, 1→4→8→121→4→8→12 表示的就是字符串 caa
。
Trie 的结构非常好懂,有时我们需要标记 Trie 树中的存储了哪些字符串,出现了多少次等信息,只需在每次插入完成时在这个字符串所代表的节点处打上标记即可(即上图中结点右侧的红色标记)。
代码实现
典型的 Trie 树的基本操作分为以下几个:
初始化
一棵空的 Trie 树仅包含一个根节点,该点的字符指针均指向空。
插入
当需要插入一个字符串 SS 时,我们令一个指针 PP 起初指向根节点。然后,依次扫描 SS 中的每个字符 cc :
- 若 PP 的 cc 字符指针指向一个已经存在的节点 QQ ,则令 P=QP=Q 。
- 若 PP 的 cc 字符指针指向空,则新建一个节点 QQ ,令 PP 的 cc 字符指针指向 QQ ,然后令 P=QP=Q 。
当 SS 中的字符扫描完毕时,在当前节点 PP 上做一个标记,表示它是一个字符串的末尾,从根节点到 PP 结点的路径上的字符就构成了字符串 SS 。
如果我们需要维护路径信息,那么每次跳转 PP 时,我们都应该维护好路径上的信息。
查询
当需要检索一个字符串 SS 在 Trie 中是否存在时,我们令一个指针 PP 起初指向根节点,然后依次扫描 SS 中的每个字符 cc :
-
若 PP 的 cc 字符指针指向空,则说明 SS 没有被插入过,结束检索。
-
若 PP 的 cc 字符指针指向一个已经存在的节点 QQ ,则令 P=QP=Q 。
当 SS 中的字符扫描完毕时,若当前节点 PP 被标记为一个字符串的末尾,则说明 SS 在 Trie 中存在,否则说明 S没有被插入过 Trie 中。
同学们可以自行以上图中右侧的字符串序列为例,模拟这些字符串插入 Trie 树中的过程来理解上述操作过程。
模板题: P6732 Trie字符串统计 - TopsCoding
本问题中,单词仅包含小写字母,且由于单词字符分布未知,所有单词总长度不超过 106106 。所以,Trie 树的每个结点最多有 2626 个子结点,分别表示字符 a∼za∼z 。可以用下面结构体封装的代码实现:
cpp
#include <bits/stdc++.h>
using namespace std;
const int N = 1e6+5; // Trie 树的最多结点数(最坏情况下为单词总长度)
int n;
char op;
string s;
class Trie{
int son[N][26]; // son[i][j]: 结点 i 这个字符后,接着字符 'a'+j 时的树上结点编号
int cnt[N], idx; // cnt[i]: 以 i 结点结尾的字符串出现过几次
public:
void insert(const string& s) { // 插入字符串
int p = 0;
for(int i = 0; s[i]; i++) {
int d = s[i]-'a';
if(!son[p][d]) son[p][d] = ++idx; // 如果没有,就添加结点
p = son[p][d];
}
cnt[p]++;
}
int query(const string& s) { // 查找字符串
int p = 0;
for(int i = 0; s[i]; i++) {
int d = s[i]-'a';
if(son[p][d]) p = son[p][d];
else return 0;
}
return cnt[p];
}
} tr;
int main(){
cin >> n;
while(n--) {
cin >> op >> s;
if(op == 'I') tr.insert(s);
else printf("%d\n", tr.query(s));
}
return 0;
}
时空复杂度分析
显然,在字典树中插人一个单词和查询一个单词的时间复杂度都是线性的,和单词的长度相关,也即 O(∣S∣)O(∣S∣) 。
字典树的空间复杂度也是线性的,在使用数组标记的情况下, 为 O(NC)O(NC) ,其中 NN 是节点个数, CC 是字符集的大小。
不过如果 CC 很大,或者 NN 很大,那么就有必要考虑优化了,因此字典树在时间和空间复杂度上的常数就十分重要。在实际编程时,要具体问题具体分析,一般可以考虑从树的深度(单词的最大长度)、宽度(字符集大小)和总结点数(单词中字符的分布)去分析。比如:
- 字符集很小,比如只有小写字母时:可以用上面模板代码实现,只需稍注意字典树的结点总数的范围即可。
- 如果单词很多,字符集很大:用上面模板实现会爆空间,此时可以采用" 孩子兄弟表示法 ",每个结点存储至少个数据域和两个指针域,一个指针指向该结点的第一个孩子结点,另一个指针指向该结点的下一个兄弟结点。因为这种方法适合对兄弟结点依次进行处理,很多情况下往往有着空间小、初始化快等优势,特别是在字母表比较大的时候。
当然,具体问题要作具体分析,比如字符集是不是比较小,查找的次数是不是远大于插人的次数等,要比较不同存储结构的区别,再决定具体使用何种存储结构。在字符集特别大的时候,还可以考虑 二叉排序树 来维护每个结点的子节点字符集,C++还可以 用 map 维护 等等。
很多情况下,字典树中字母的个数远大于单词的个数,此时字典树中就会有很多结点只有一个孩子,如果能将连续的只有一个孩子的结点合并起来,无疑能够大大地减少结点个数,起到节约空间和时间的效果。" 压缩的字典树 (Compressed Trie)"就采用了这种思想,压缩的字典树的每个结点不是仅仅记录一个字母,而是一个连续的字符串,实际处理时把所有字符串放在一个大数组中,每个结点只记录该结点的字符串在数组中的起始位置和终止位置。当然,在使用这种方法时应在插入单词时动态地扩展字典树,而不是将字典树建好后再压缩。通过压缩算法,树中将不存在只有一个孩子的结点,可以将树的总结点数控制在 O(单词个数)O(单词个数) 。
应用
检索字符串
字典树最基础的应用------查找一个字符串是否在「字典」中出现过。
给你 nn 个名字串,然后进行 mm 次点名,每次你需要回答「名字不存在」、「第一次点到这个名字」、「已经点过这个名字」之一。
cpp
const int N = 5e5+5;
char s[60];
int n, m, ch[N][26], tag[N], tot = 1;
int main() {
scanf("%d", &n);
for (int i = 1; i <= n; ++i) {
scanf("%s", s + 1);
int u = 1;
for (int j = 1; s[j]; ++j) {
int c = s[j] - 'a';
if (!ch[u][c])
ch[u][c] =
++tot; // 如果这个节点的子节点中没有这个字符,添加上并将该字符的节点号记录为++tot
u = ch[u][c]; // 往更深一层搜索
}
tag[u] = 1; // 最后一个字符为节点 u 的名字未被访问到记录为 1
}
scanf("%d", &m);
while (m--) {
scanf("%s", s + 1);
int u = 1;
for (int j = 1; s[j]; ++j) {
int c = s[j] - 'a';
u = ch[u][c];
if (!u) break; // 不存在对应字符的出边说明名字不存在
}
if (tag[u] == 1) {
tag[u] = 2; // 最后一个字符为节点 u 的名字已经被访问
puts("OK");
} else if (tag[u] == 2) // 已经被访问,重复访问
puts("REPEAT");
else
puts("WRONG");
}
return 0;
}
检索前缀
把这 NN 个字符串插入一棵 Trie 树,Trie 的每个节点上存储一个整数 cntcnt 记录该节点是多少个字符串的末尾节点。(为了处理插入重复字符串的情况,这里要记录个数,而不能只做结尾标记)
对于每个询问,在 Trie 树中检索 TT 在检索过程中累加途径的每个节点的 cntcnt 值就是该询问的答案。
处理异或问题
因为数字在计算机底层是用二进制存储的,且 int 最大 3232 位,如果用 Trie 树构造去维护一些数字的集合,那么此时 Trie 树最深 3232 层,会比 map 等更为高效。
我们把这种将数字从二进制角度构造成点权只有 {0,1}{0,1} 两种值的 Trie 树叫做 01-Trie 树。
我们可以把每个整数看作长度为 3232 二进制 0101 (值较小时在前边补 00 ),并且把 A1∼Ai−1A1∼Ai−1 对应的 3232 位制插入一棵 Trie (最低二进制位为叶子节点接下来,对于 AiAi 对应的 3232 位二进制,我们在 Trie 中进行一次与检索类似的过程每一步都尝试沿着"与 AiAi 的当前位相反的字符指针"向下访问。若"与 AiAi 的当前位相反的字符指针"指向空节点,则只好访问与 AiAi 当前位相同的字符指针。根据 xor 运算"相异为 11 "的性质,该方法即可找出与 AiAi 做 xor 运算结果最大的 AjAj 。
设 D[x]D[x] 表示根节点到 xx 的路径上所有边权的 xor 值,显然有:
D[x]=D[father(x)]xorweight(x,father(x))D[x]=D[father(x)]xorweight(x,father(x))
根据上式,我们可以对树进行一次深度优先遍历,求出所有的 D[..]D[..] 。不难发现树上 xx 到 yy 的路径上所有边权的 xor 结果就等于 D[x]⊕D[y]D[x]⊕D[y] 。这是因为根据 xor 运算的归零律性质 ( a⊕a=0a⊕a=0 )," xx 到根"和" yy 到根"这两条路径重叠的部分恰好抵消掉。
所以,问题就变成了从 D[1]∼D[N]D[1]∼D[N] 这 NN 个数中选出两个,xor 的结果最大,即上一道例题。可以用 Trie 树来快速求解。
求公共前缀(用孩子兄弟表示法)
本题采用 Trie结构处理起来很直观,也比较简单。只需要在每个字母结点上多记录个值 cnt 表示有多少个单词经过该结点,然后在插人单词时,顺便统计一下当前的深度与经过该结点的单词的数量的乘积,不断更新最大值,最后这个最大值就是答案。主要的问题在于 Trie 结构的具体实现方式。
实现 Trie 结构的一种比较常用的方式是为每个结点(字母)创建一个大小为需要处理的字符集大小的数组(比如说要处理 26 个小写字母,就创建一个长度为 26 的数组)来存放它的每一个后继结点(后面一个字母)是否存在;如果存在,在哪个位置。对于这样一种结构,我们一般这样定义一个结点:
#define SIZE 26//字符集大小
struct letter {
letter * son[SIZE];
bool finished;
};
然而,对于本题来说这种方式并不太合适。因为本题中的字母有 106106 个,在极端情况下,Trie 树中结点的数量也可能到达这个数量级,字符集也没有规定,所以应该默认为 256,如果按照这样一种处理方式,所需要的内存可能达到 9000MB,这显然大大超出了题中所给的限制,初始化一遍都会超时,如果把结点数开得少一些又有可能只得部分分。显然,我们需要优化存储方式,还是采用"孩子兄弟表示法",每个结点存放 44 个值,分别是:data(该结点是什么字母)、son(有无孩子,如果有在什么位置)、bro(有无兄弟,如有在什么位置)、finished(当前是否有结束的单词)。
就本题而言,还要存放经过当前结点的单词的个数,但可以没有 finished。下面给出这样一个结点的一般定义方法:
struct letter{
letter *son, *bro;
char data;
bool finished;
};
经过这样的优化(见参考程序),所需要的存储空间就变小了,就本题而言大约只需要 150MB 左右。而且,时间效率也大为改观,经过测试最慢的数据也只要 1.3 秒左右。
孩子兄弟表示法能用较少的空间来存储一棵 Trie 树,下面我们来简单地分析一下这两种结构在时间复杂度上的差别。有人说 Trie 树的孩子兄弟表示法只是简单的"时间换空间",因为一般的 Trie 树可以直接查出某个结点是否有指定字母的孩子,然而孩子兄弟表示法的 Trie 树可能要检索整个孩子列表,最坏的情况下检索的长度会达到字符集的大小,Trie 树所用的时间可能达到普通字典树所用的时间乘以字符集大小,实际上不可能到这么大,因为不是每个结点下面的孩子都是满的。此外忽略 Trie 树初始化的时间,因为普通的 Trie 树的孩子列表很大,而且必须初始化,否则可能会出现"野指针"情况,而初始化的时间也是Trie 树所用时间乘以字符集大小,这个时间是固定的。所以,它们的速度与树的结点个数结点的平均度数以及具体的插入查询顺序都有关。
参考代码: 重点理解 insert 函数和 query 函数。
cpp
#include <bits/stdc++.h>
using namespace std;
const int N = 1e7+5; // Trie 树的最多结点数(最坏情况下为单词总长度)
int n, ans;
string s;
struct Node{
char d;
int son, bro, cnt; // 用数组模拟链表
};
class Trie{
Node tr[N];
int idx; // cnt[i]: 结点 i 往下有多少个单词(公共前缀相同)
public:
void insert(const string& s) { // 插入字符串
int p = 0;
for(int i = 0; s[i]; i++) {
tr[p].cnt++;
ans = max(ans, tr[p].cnt*i);
if(!tr[p].son) { // 当前结点没有子结点,更没有兄弟结点,新结点链接到 p 作为子结点
tr[++idx] = {s[i], 0, 0, 0};
tr[p].son = idx;
p = idx;
} else { // 有子结点,找兄弟
p = tr[p].son;
while(tr[p].d != s[i] && tr[p].bro) { // 找子结点和其兄弟结点中是否有出现过 s[i]
p = tr[p].bro;
}
if(tr[p].d != s[i]) { // 没有 s[i], 还需新建兄弟结点
tr[++idx] = {s[i], 0, 0, 0};
tr[p].bro = idx;
p = idx;
}
}
}
tr[p].cnt++;
ans = max(ans, tr[p].cnt*int(s.size()));
}
int query(const string& s) { // 查找字符串作为前缀出现过几次
int p = 0;
for(int i = 0; s[i]; i++) {
if(tr[p].son) {
p = tr[p].son;
while(tr[p].d != s[i] && tr[p].bro) { // 找子结点和其兄弟结点中是否有出现过 s[i]
p = tr[p].bro;
}
if(tr[p].d != s[i]) return 0; // 没找到 s[i]
} else return 0;
}
return tr[p].cnt;
}
} tree;
int main(){
cin >> n;
getchar();
while(n--) {
getline(cin, s);
tree.insert(s);
}
cout << ans << endl;