Trie 前缀树
每个节点存一个字符,从根到叶的路径就是一个完整字符串。共同前缀的字符串共用前面的节点 — 这就是为什么叫”前缀树”。插入/查询都是
直觉 — 公共前缀共用节点
哈希表能 app 开头的所有词”。Trie 把字符摊到树上:共享前缀的字符串走同一段路径,分叉只发生在第一次出现不同字符的位置。
插入 apple, app, ant, bee 之后:
1 | |
apple和app共用a -> p -> p这三个节点;ant在a之后才分叉;bee完全独立另开一支。
三件事一次看清:
- 每条边代表一个字符,每个节点代表”走到这里时的前缀”。 节点本身不存整个字符串。
is_end(图里的#)是必需的。 不加就分不清”app是真的插入过”还是”app只是apple路径上的某个点”。- 查询代价是
, 是查询串的长度。 字典里有 1 万还是 1 亿个词,完全不影响。
对比一下:
| 操作 | HashSet | Trie |
|---|---|---|
| 精确查找 | ||
| 前缀查询 | 不支持 | |
| 列出所有以 P 开头的词 | 走到 P,DFS 子树 | |
| 内存 | 每个词独立存 | 共享前缀,字典越密越省 |
Trie 不是为了”更快地查一个词”,而是为了”按前缀批量操作”。 这是它存在的唯一理由。
动画 — 跟着字符落子
下面的动画依次插入 apple, app, ant, bee 四个词,再演示 search("app") 和 search("ap")。重点盯三件事:
- 共享前缀:
apple已经把a-p-p三个节点建好,插入app时不再新建任何节点,只在最后那个p上挂一个#。 #标记:app的#加上之后,search("app")返回 True;但search("ap")路径虽然能走通,因为p上没#,所以是 False — 它只是前缀,不是被插入过的词。- 节点数 ≠ 字符总数:四个词共 13 个字符,但树上只有 11 个节点(共用了
a-p-p那段)。词典越密,节省越多。
模板 — Python class Trie
字典版,字符集不限,Python 首选:
1 | |
三处细节:
search和starts_with共用_walk。 区别只在终点要不要检查#。#用任何字符集外的 key 都行,只是约定。换成node.is_end = True一样。- 不要在
_walk里写递归,Python 递归慢且会爆栈,直接 for 循环。
数组版(竞赛/C++ 常用)
固定字符集时,预分配节点池,用整数 ID 代替 dict 引用,内存紧凑、缓存友好:
1 | |
选哪个:Python 刷题用 dict 版;字符集只有小写字母、追求常数、或要在 LeetCode 上卡进前 5% 时换数组版。
进阶 — 01-Trie 解决异或最大值
LC 421. 数组中两个数的最大异或值:给一组整数,求
。
朴素 {0, 1},所以叫 01-Trie。
为什么能解最大异或?异或的特性:对应位不同结果为 1。从高位开始贪心,每一位都试着走”和当前位相反”的分支 — 走得通就这一位拿到 1,走不通才退而求其次。高位优先于低位,所以贪心最优。
1 | |
这道题是 01-Trie 的招牌应用,记住”从高位到低位 + 贪心走反向”这一个套路,变种题(异或在某个范围内的对数、子数组最大异或等)都是它的变形。
应用
不只是面试题,工程里到处都是:
- 自动补全 / 搜索框联想:输入
自动,走到该前缀的子树 DFS,把所有词收上来按热度排。 - IP 路由的最长前缀匹配:路由表本质是二进制 Trie,数据包来了从根往下走,匹配最长前缀那条路由。
- 拼写检查 / 模糊匹配:Trie 上 DFS 时允许少量字符替换/插入/删除(编辑距离),比对全词表快得多。
- 敏感词过滤(Aho-Corasick):多模式串匹配,在 Trie 上加 fail 指针,一次扫描查所有敏感词。
- 字符串异或/位运算极值:见上面的 01-Trie。
- 后缀树/后缀数组的同族:索引整个字符串所有后缀,做子串查询。
判断要不要上 Trie:只要题面里出现”前缀””字典””多模式”,先想 Trie。
记忆法
一句话:字符在边上,前缀在路径上,# 在词尾上。
三件事必须刻进肌肉记忆:
| 要点 | 内容 |
|---|---|
| 节点设计 | children: dict[char, Node] + is_end: bool |
| 时间复杂度 | 插入/查询都是 |
| 易错点 | 结尾标记 # 不能省,否则前缀和完整词分不开 |
判题口诀:
- 看到”前缀” → Trie
- 看到”多个字符串一起查” → Trie
- 看到”异或最大/最小” → 01-Trie
- 看到”敏感词/多模式匹配” → AC 自动机(Trie + fail 指针)
写模板的时候默念:新建节点 → 走过去 → 走完打标记。 三步,永远不会写错。
思考题
不加 # 会出什么具体问题?
插入 apple 后,search("app") 会返回 True — 因为路径 a-p-p 确实存在。但 app 这个词并没有被插入过,这就是误判。# 的作用是区分”路径上的节点”和”被显式插入过的词的终点”。
01-Trie 为什么从高位开始而不是低位?
异或结果是按位的二进制数,高位的权重远大于所有低位之和(
Trie 的内存开销和 HashSet 比如何?
Trie 每个节点要存一个 dict(或 26 长度数组),开销不小。但词典越密(共享前缀越多)越省:1 万个英文单词,共享前缀后节点数远小于字符总数。稀疏字典(每个词都不同前缀)反而比 HashSet 费内存。
LeetCode 练习
热身:
- 208. 实现 Trie(前缀树) — 模板裸题
- 1268. 搜索推荐系统 — 自动补全
- 648. 单词替换 — 最短前缀匹配
进阶:
- 212. 单词搜索 II — Trie + 网格 DFS 剪枝
- 421. 数组中两个数的最大异或值 — 01-Trie 招牌题
- 336. 回文对 — 反向插入 + 回文判断
- 677. 键值映射 — 节点上挂权重
- 745. 前缀和后缀搜索 — 两棵 Trie 或后缀拼接