字典树学习笔记
字典树:把"前缀"变成一条路
约定:本文用
trie数组存节点,trie[0]为根;字符集默认小写字母a-z,用ch[26];每个节点带end标记和cnt计数。
先说结论:字典树不神秘
字典树(Trie,读作"try")只是在回答一个问题——一堆字符串里,怎么快速知道"以某个前缀开头的有哪些"。
哈希表能 O(1) 判一个完整字符串在不在,但对"前缀"无能为力:给你 app,问所有以 app 开头的串,哈希表只能全表扫。Trie 的答案是:把公共前缀合并成同一条路。走到 app 这个节点,它底下的整棵子树就是答案。
一句话:Trie 是用空间换前缀查询的结构。它不存字符串本身,存的是字符串之间的共享前缀关系。
长什么样
插入 app、apple、apply、apt、bat 之后:
root
/ \
a b
| |
p a
/ \ |
p t t
|
l
/ \
e y
几个要点:
- 每条边是一个字符,从根到某节点的路径拼起来就是一个前缀。
- 节点本身不存字符(字符在边上),节点存的是"有没有字符串在这里结束"和"子节点指针"。
app、apple、apply共享app这条前缀路径,只在分叉处分开。- 一个字符串的结尾必须打标记,否则分不清
app是"存在"还是"只是apple的前缀"。
节点怎么定义
struct Node {
int ch[26]; // 子节点下标,-1 表示没有;字符集大就用 map/unordered_map
bool end; // 是否有字符串在此结束
int cnt; // 经过此节点的字符串数(可选,用于统计前缀出现次数)
};
vector<Node> trie(1); // trie[0] 是根,初始化为空
用数组存节点、用下标当指针,是竞赛里最常见的写法——比 new 快、比指针省心、还能整体清空。注意 Node 默认构造要保证 ch 全为 -1、end=false、cnt=0,否则新节点是脏的。
插入
从根出发,沿字符走;没有路就开新节点;走完在终点打 end 标记。
void insert(const string& s) {
int p = 0; // 当前节点,从根开始
for (char c : s) {
int x = c - 'a';
if (trie[p].ch[x] == -1) { // 没有这条路
trie[p].ch[x] = trie.size();
trie.emplace_back(); // 开新节点
}
p = trie[p].ch[x];
trie[p].cnt++; // 经过次数 +1(统计前缀用)
}
trie[p].end = true; // 标记字符串结束
}
复杂度 O(|s|),跟已经插了多少字符串无关——这是 Trie 最爽的地方。
坑:trie.emplace_back() 之后 trie 可能扩容,之前拿到的引用/指针会失效。所以上面全程用下标 p 而不是 Node*,这是好习惯。
查询:完整字符串在不在
沿字符走,走不通就说明不存在;走通了还要看终点有没有 end 标记。
bool search(const string& s) {
int p = 0;
for (char c : s) {
int x = c - 'a';
if (trie[p].ch[x] == -1) return false; // 路断了
p = trie[p].ch[x];
}
return trie[p].end; // 走完了,但可能只是别人的前缀
}
注意 end 这一下:如果只插了 apple,查 app 会走通整条路,但 app 节点 end == false,所以返回 false。这正是 end 标记存在的意义。
查询:前缀在不在
跟上面几乎一样,只是不检查 end:
bool startsWith(const string& prefix) {
int p = 0;
for (char c : prefix) {
int x = c - 'a';
if (trie[p].ch[x] == -1) return false;
p = trie[p].ch[x];
}
return true; // 只要走得通,就存在这个前缀
}
这就是 Trie 相对哈希表的核心优势:前缀查询是 O(|prefix|),不用扫全表。哈希表要回答同样的问题,得把所有字符串都过一遍。
三种常见变体
1. 统计前缀出现次数
插入时每个节点 cnt++,查询前缀时走到终点返回 cnt 即可。cnt 就是"有多少个字符串以这个前缀开头"。
int countPrefix(const string& prefix) {
int p = 0;
for (char c : prefix) {
int x = c - 'a';
if (trie[p].ch[x] == -1) return 0;
p = trie[p].ch[x];
}
return trie[p].cnt;
}
注意:cnt 统计的是"经过该节点的字符串数",如果允许重复插入同一个串,cnt 会重复计数;要统计"不同字符串数"得在 end 处判断是否已存在,或者插入前先 search 一下。
2. 01-Trie(处理异或)
把整数按二进制从高位到低位插入,每条边是 0 或 1。经典用途:最大异或对——给一堆数,求两个数异或的最大值。
思路:对每个数,从高位到低位贪心,尽量走"相反位"的边(0 走 1、1 走 0),走不通才走同向。因为高位权重大,贪心正确。
void insert01(int x, int B = 30) { // B 是最高位
int p = 0;
for (int b = B; b >= 0; b--) {
int bit = (x >> b) & 1;
if (trie[p].ch[bit] == -1) {
trie[p].ch[bit] = trie.size();
trie.emplace_back();
}
p = trie[p].ch[bit];
}
trie[p].end = true;
}
int maxXor(int x, int B = 30) { // 返回与 x 异或最大的已插入数的异或值
int p = 0, ans = 0;
for (int b = B; b >= 0; b--) {
int bit = (x >> b) & 1;
int want = bit ^ 1; // 想要相反位
if (trie[p].ch[want] != -1) {
ans |= (1 << b); // 这一位能异或出 1
p = trie[p].ch[want];
} else {
p = trie[p].ch[bit]; // 只能同向
}
}
return ans;
}
01-Trie 还能做可持久化版本,支持区间最大异或(主席树那套思路搬到 Trie 上),是进阶内容。
3. 字典树 + 失败指针 = AC 自动机
Trie 的短板是:多模式匹配时,文本扫到一个位置,只能沿当前路径走,走不通就凉。AC 自动机在 Trie 的每个节点上加一个失败指针(类似 KMP 的 pi),失配时跳到"最长真后缀对应的节点"接着匹配。这是 Trie 最重要的扩展,下一篇可以单独讲。
复杂度与空间
- 插入/查询:O(L),L 是字符串长度,与集合大小无关。
- 空间:O(总字符数 × 字符集大小) 最坏。26 个字母、10^5 个长度 10 的串,最坏约 2.6×10^6 个
int,还能接受;但字符集大(比如 Unicode)或串很长时,int ch[26]这种定长数组会爆。 - 省空间的办法:
map<char,int>/unordered_map<char,int>存子节点,或者压缩字典树(把只有一个儿子的链压成一条边,存整个子串),后者就是 Radix Tree / 前缀树压缩。
容易踩的坑
-
end标记和"前缀"混淆。 查完整串必须看end,查前缀不能看。这是最高频的错。 -
节点引用失效。 用
vector<Node>时,emplace_back可能触发扩容,之前保存的Node*或Node&全废。全程用下标。 -
字符集大小别写死。 题目给的是小写字母就用 26;给的是 ASCII 就用 128;给的是任意字符就上
map。写死 26 却遇到大写字母,直接越界。 -
多组数据要清空。 全局
vector不清,上一组的节点会污染下一组。最省事的办法是每组重新trie.assign(1, Node()),或者记录用过的节点下标逐个清。 -
空间预估。 定长
ch[26]的节点很大(约 100+ 字节),节点数 = 总字符数 + 1。10^6 个字符就要 ~100MB,容易 MLE。字符集大或串多时优先考虑map或压缩。 -
01-Trie 的位数。 值域到 10^9 用 30 位(
2^30 > 10^9),到 10^18 用 60 位。位数选少了高位丢失,选多了浪费空间。 -
cnt的语义。 是"经过次数"还是"以该节点结尾的字符串数",取决于你怎么加。统计前缀出现次数用"经过次数";统计某前缀作为完整串出现几次,得在end处单独计数。
The End
Trie 的美在于它把"字符串集合"变成了一棵按前缀组织的树,于是"前缀相关"的查询从"扫全表"变成"走一条路"。骨架就三件事:
- 节点:子节点指针 +
end标记(+ 可选的cnt)。 - 插入/查询:沿字符走,没有就开/断,走完看
end。 - 变体:
cnt统计前缀、01-Trie 处理异或、加失败指针变 AC 自动机、压缩变 Radix Tree。
建议手画一遍 app / apple / apply / apt / bat 的树,再对着代码走一遍插入和查询,对"共享前缀 = 共享路径"就有直觉了。然后做两道题(LeetCode 208 实现 Trie、LeetCode 421 最大异或对),基本就掌握。

浙公网安备 33010602011771号