← 目录 / 算法文档 · 模块十七 字符串算法 / 17.3 Trie 字典树

17.3 Trie 字典树

很多字符串挤在一起,公共前缀被反复存了很多遍——Trie 把这些公共前缀合并成共享的树枝,查询只和字符串长度有关,和字符串总数无关。

本页目录
① 为什么需要 Trie

如果有一个包含很多单词的词典,需要频繁判断"某个字符串是不是词典里的一个单词"或者"某个字符串是不是词典里某个单词的前缀"——如果把单词逐个存成普通字符串列表,每次查询都要挨个比较,效率和单词总数、单词长度都有关。

Trie(字典树 / 前缀树)把所有单词的公共前缀合并存储成一棵树,之后每次查询只需要沿着树"走"一遍待查字符串的长度那么多步,和词典里到底有多少个单词完全无关

② 核心思想:公共前缀共享同一段树枝

Trie 是一棵树:根节点代表"空字符串",从根节点出发的每一条边对应一个字符,从根走到某个节点所经过的字符连起来,就是该节点代表的一段前缀。如果两个单词有相同的前缀,它们在 Trie 里会共用从根节点开始的这一段路径,只在前缀分叉的地方才各自延伸出不同的树枝。每个节点还需要一个标记,记录"走到这里,是否恰好构成一个完整的单词"(因为一个单词的路径,可能正好是另一个更长单词路径的前半段)。

③ 图解:插入几个单词后的 Trie 结构

依次插入 "cat""car""do""dog" 这 4 个单词:

Trie 结构(带 ★ 的节点表示"走到这里恰好是一个完整单词")
root
c
d
a
o
"do" ✓
t
"cat" ✓
r
"car" ✓
g
"dog" ✓
"cat""car" 共用了 c→a 这一段公共前缀,只在第三个字符分叉成 tr 两条树枝。更值得注意的是 "do""dog""do" 本身是一个完整单词(o 节点带 ★),但它同时也是 "dog" 的前缀,所以这个节点既标记了 ★,又继续往下延伸了一条树枝到 g。这正是"完整单词"和"只是路径存在"必须分开判断的原因(见 ⑥ 陷阱)。
④ 完整代码

竞赛中通常用静态数组模拟树形结构(避免频繁 new 动态节点的开销),trie[cur][c] 表示节点 cur 沿着字符 c 这条边走到的下一个节点编号,0 表示"这条边不存在":

C++ · Trie(静态数组实现)
1int trie[MAXN][26], cnt; // trie[cur][c]:节点 cur 沿字符 c 走到的节点编号;cnt:已用节点数
2bool isEnd[MAXN]; // isEnd[node]:走到 node 是否恰好是一个完整单词
3
4void Insert(const string& word)
5{
6 int cur = 0; // 0 号节点是根节点
7 for (char c : word)
8 {
9 int idx = c - 'a';
10 if (!trie[cur][idx]) trie[cur][idx] = ++cnt; // ★ 这条边不存在就新建一个节点
11 cur = trie[cur][idx]; // 沿着这条边走过去
12 }
13 isEnd[cur] = true; // 单词插入完毕,标记终点
14}
15
16bool Search(const string& word) // 词典里是否存在这个完整单词
17{
18 int cur = 0;
19 for (char c : word)
20 {
21 int idx = c - 'a';
22 if (!trie[cur][idx]) return false; // 路径都走不通,一定不存在
23 cur = trie[cur][idx];
24 }
25 return isEnd[cur]; // ★ 路径走通了,还要看这里是不是一个"完整单词"的终点
26}
27
28bool StartsWith(const string& prefix) // 词典里是否存在以 prefix 为前缀的单词
29{
30 int cur = 0;
31 for (char c : prefix)
32 {
33 int idx = c - 'a';
34 if (!trie[cur][idx]) return false;
35 cur = trie[cur][idx];
36 }
37 return true; // ★ 只要路径存在就够了,不需要看 isEnd
38}
💡
SearchStartsWith 几乎是同一份代码,只差最后一行:Search 要求走到的节点必须恰好是某个单词的终点(isEnd 为真);StartsWith 只要求路径能走通,不关心走到的节点是不是某个单词的终点——这正对应 ③ 图解里 "do""dog" 的区别:查询前缀 "do" 应该返回 true(路径存在),但查询完整单词时,"do" 是词典里的词,"dog" 也是,两者都能在 isEnd 上得到确认。
⑤ 复杂度分析

插入或查询一个长度为 L 的字符串,只需要沿着树走 L 步,每一步是 O(1)(数组下标访问),所以单次操作是 O(L)——和词典里已经存了多少个单词完全无关。空间上,最坏情况下(所有单词没有公共前缀)需要的节点数是所有单词长度之和。

⑥ 常见陷阱
把"路径存在"当成"单词存在":这是最容易犯的错——查询 "do" 这个单词时,路径 root→d→o 确实存在(因为 "dog" 也经过这里),但如果词典里只插入了 "dog",没有插入 "do",此时 isEnd[o节点]false——查询完整单词 "do" 应该返回不存在,必须依赖 isEnd 判断,不能只看路径能不能走通。
数组大小 MAXN 开得不够:节点总数最坏情况下等于所有插入单词的长度之和(而不是单词的个数),如果词典很大且单词之间几乎没有公共前缀,需要的节点数会接近这个总长度,数组开小了会越界。
字符集较大时,26 个子节点浪费空间:本节假设字符集是小写字母(26 个),如果需要支持大小写字母、数字甚至更大的字符集,trie[MAXN][26] 这种固定大小的写法会浪费大量内存(大部分子节点其实是空的)。这种情况下可以考虑用 mapunordered_map 代替固定大小的数组存边,用空间换取灵活性,但访问速度会比数组慢一些。
🏆
模块小结:17.1~17.3 节的三种字符串工具分别解决不同的问题:字符串哈希把"子串比较"变成 O(1) 的数字比较;KMP 解决"一个模式串在文本里的所有出现位置";Trie 解决"很多字符串共享前缀"的场景,让前缀相关的查询和字符串总数无关。三者经常配合其他数据结构(比如 Trie 常和 DFS、动态规划结合)解决更复杂的字符串问题。