一、为什么需要 Trie
你在搜索框里输入「sh」,下拉列表立刻弹出「shell」「shift」「short」——这种自动补全体验几乎无处不在。问题是,怎么做到的?
最直觉的方案是哈希表:把所有单词存进去,查找时 O(1) 搞定。但哈希表有个致命缺陷——它只支持精确匹配。想找所有以「sh」开头的词?只能遍历整张表,逐个检查前缀,时间复杂度 O(n)。10 万个词就得扫 10 万次。
排序数组加二分查找呢?先定位到第一个以「sh」开头的词,然后顺序扫描直到前缀不匹配。查找起点是 O(log n),但扫描结果的数量取决于匹配项的多少,最坏情况还是 O(n)。
我们需要一种数据结构,能直接利用前缀的公共部分,一次遍历就定位到所有匹配项。Trie(也叫前缀树、字典树)就是为这个问题而生的。
二、现实类比
想象一本老式电话簿。书页边缘印着一排字母标签——A、B、C……你翻到「W」那一截,再在 W 区内逐页找「Wang」。所有 W 开头的姓名都集中在同一片区域,不需要翻遍整本书。
Trie 做的事情一模一样:根节点相当于整本电话簿,第一层分支相当于边缘的字母标签,后续每一层都在进一步缩小范围。共享相同前缀的词共享同一条路径,就像「Wang」和「Wu」共享了 W 那一截,但之后各走各的分支。
三、核心思想
Trie 是一棵多叉树,每条边对应一个字符,从根到某节点的路径拼出的字符串就是该节点代表的前缀。标记为「终止」的节点表示一个完整的单词。
下面是一棵包含 he、her、him、his、it 五个单词的 Trie:
其中 ★ 标记的节点是单词终止节点。注意 he 和 her 共享了 h → e 这条路径——e 节点既是 he 的终止节点,又是 her 的中间节点。这就是 Trie 节省空间的关键:公共前缀只存一份。
3.1 核心操作与复杂度
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| 查找(Lookup) | O(k) | k 为目标字符串长度,与树中单词数量无关 |
| 插入(Insert) | O(k) | 逐字符沿路径走,不存在则创建节点 |
| 前缀搜索(Prefix Search) | O(k + m) | k 为前缀长度,m 为匹配结果数 |
| 删除(Delete) | O(k) | 从叶节点向上回收无分支的空闲节点 |
空间复杂度最坏情况 O(n × k),其中 n 是单词数量,k 是平均长度。因为每个字符都可能对应一个独立节点,100K 个单词可能产生 500K+ 个节点。但如果单词之间共享大量前缀(比如英文单词),实际节点数远小于最坏值。
3.2 前缀搜索的工作方式
前缀搜索分两步:先用 O(k) 定位到前缀对应的节点,再从该节点出发做一次子树遍历,收集所有终止节点。第二步的代价取决于匹配结果的数量 m,而不是树中单词的总数 n。这正是 Trie 的核心优势——前缀查询的代价与结果集大小成正比,与全量数据规模无关。
四、变体与对比
4.1 Trie vs 哈希表 vs 排序数组
| 特性 | Trie | 哈希表 | 排序数组 | Radix Tree |
|---|---|---|---|---|
| 精确查找 | O(k) | O(1) | O(log n) | O(k) |
| 前缀搜索 | O(k + m) | O(n) 全表扫描 | O(log n + m) | O(k + m) |
| 插入 | O(k) | O(1) 均摊 | O(n) 需移动元素 | O(k) |
| 空间开销 | 较大(节点多) | 中等 | 较小(紧凑存储) | 较小(压缩单分支) |
| 有序遍历 | 天然有序 | 无序 | 有序 | 天然有序 |
| 最长前缀匹配 | 原生支持 | 不支持 | 需多次二分 | 原生支持 |
Trie 的优势集中在「前缀操作」上。如果只需要精确查找,哈希表 O(1) 的性能更优;如果数据静态且需要紧凑存储,排序数组更合适。
Radix Tree(基数树) 是标准 Trie 的压缩变体,它把只有单个子节点的链路合并为一个节点。比如 h → e → l → l → o 这条没有分叉的路径,Radix Tree 会直接存成 hello 一个节点,省掉中间 4 个节点的指针开销。在键的公共前缀较长或存在大量单分支路径时,Radix Tree 的节点数通常能减少 5-10 倍,内存占用显著降低。Linux 内核的 IP 路由表和 Nginx 的路由匹配都用的是 Radix Tree。选择建议:如果内存敏感且键的前缀共享度高,优先选 Radix Tree;如果键的分支因子很高(每层多个子节点),标准 Trie 的实现更简单直接。
4.2 压缩 Trie(Radix Tree / Patricia Trie)
标准 Trie 有个明显的空间浪费:一条链上每个节点只有一个子节点时,整条链其实可以压缩成一个节点。比如 h → e → l → l → o 可以压缩为 hello 一个节点。
Redis 的 rax 就是这样做的——它把单分支链合并,节点数通常能减少 5-10 倍。这种变体叫 Radix Tree 或 Patricia Trie,在内存敏感的场景下是更好的选择。
五、多语言实现
5.1 Go 实现
package trie
// TrieNode 表示 Trie 的一个节点type TrieNode struct { children map[rune]*TrieNode // 子节点映射 isEnd bool // 是否为单词终止节点}
// Trie 前缀树type Trie struct { root *TrieNode}
func New() *Trie { return &Trie{root: &TrieNode{children: make(map[rune]*TrieNode)}}}
// Insert 插入一个单词,逐字符沿路径走,不存在则创建func (t *Trie) Insert(word string) { node := t.root for _, ch := range word { if node.children[ch] == nil { node.children[ch] = &TrieNode{children: make(map[rune]*TrieNode)} } node = node.children[ch] } node.isEnd = true}
// Search 精确查找,必须完整匹配且终止节点标记为 truefunc (t *Trie) Search(word string) bool { node := t.findByPrefix(word) return node != nil && node.isEnd}
// StartsWith 前缀搜索:返回所有以 prefix 开头的单词func (t *Trie) StartsWith(prefix string) []string { node := t.findByPrefix(prefix) if node == nil { return nil } var results []string // 从前缀节点出发,深度优先收集所有终止节点 t.collect(node, prefix, &results) return results}
// findByPrefix 沿前缀路径定位到目标节点func (t *Trie) findByPrefix(prefix string) *TrieNode { node := t.root for _, ch := range prefix { if node.children[ch] == nil { return nil } node = node.children[ch] } return node}
// collect 深度优先遍历,收集所有终止节点对应的完整单词func (t *Trie) collect(node *TrieNode, path string, results *[]string) { if node.isEnd { *results = append(*results, path) } for ch, child := range node.children { t.collect(child, path+string(ch), results) }}Go 版本用 map[rune]*TrieNode 存子节点,支持 Unicode 字符。Insert 和 Search 都是 O(k) 的单路径操作,StartsWith 在定位前缀节点后做一次 DFS 收集结果。
5.2 TypeScript 实现
// TrieNode 表示 Trie 的一个节点class TrieNode { children: Map<string, TrieNode> = new Map(); isEnd: boolean = false;}
// Trie 前缀树class Trie { private root: TrieNode = new TrieNode();
// 插入一个单词 insert(word: string): void { let node = this.root; for (const ch of word) { if (!node.children.has(ch)) { node.children.set(ch, new TrieNode()); } node = node.children.get(ch)!; } node.isEnd = true; }
// 精确查找 search(word: string): boolean { const node = this._findByPrefix(word); return node !== null && node.isEnd; }
// 前缀搜索:返回所有以 prefix 开头的单词 startsWith(prefix: string): string[] { const node = this._findByPrefix(prefix); if (node === null) return []; const results: string[] = []; this._collect(node, prefix, results); return results; }
// 沿前缀路径定位到目标节点 private _findByPrefix(prefix: string): TrieNode | null { let node = this.root; for (const ch of prefix) { const child = node.children.get(ch); if (child === undefined) return null; node = child; } return node; }
// 深度优先遍历,收集所有终止节点对应的完整单词 private _collect(node: TrieNode, path: string, results: string[]): void { if (node.isEnd) results.push(path); for (const [ch, child] of node.children) { this._collect(child, path + ch, results); } }}TypeScript 版本用 Map<string, TrieNode> 替代 Go 的 map[rune]*TrieNode,逻辑完全一致。两个实现都遵循同一个核心思路:查找沿路径走,前缀搜索定位后再 DFS。
六、生产验证
| 项目 | 用途 | 源码位置 |
|---|---|---|
| Linux Kernel | IP 路由表的 FIB 最长前缀匹配,使用 LC-trie(Level-Compressed trie) | fib_trie.c#L130-L165 |
| Redis | Streams 的消息索引与消费者组元数据管理,使用 rax(压缩 Radix Tree) | rax.h#L82-L130 |
Linux Kernel 的 fib_trie.c 实现了 IPv4 路由表的最长前缀匹配。IP 路由需要的是「最长匹配」而非精确匹配——比如目的地址 192.168.1.5 可能同时匹配 192.168.1.0/24 和 192.168.0.0/16,路由器要选更具体的 /24。用哈希表的话,最多需要尝试 32 次不同前缀长度的查找;而 Trie 只需一次遍历,沿途记录最深的匹配节点即可。
Redis 的 rax 是一棵压缩 Radix Tree,用于 Streams 数据结构。每个 Stream 节点内部用 rax 索引消息 ID(时间戳+序号),消费者组也用 rax 管理待确认消息列表。相比标准 Trie,rax 把单分支链压缩为单个节点,内存占用大幅降低。
七、小结
何时使用
- 自动补全 / 输入联想:搜索引擎、代码编辑器、命令行补全——Trie 的前缀搜索天然适配
- IP 路由最长前缀匹配:一次遍历即可找到最具体的路由条目,比哈希表多次查找高效
- 拼写检查:快速判断一个词是否在词典中,或找出编辑距离为 1 的候选词
- 去重与排序:Trie 天然按字典序组织,遍历即有序输出,同时自动去重
何时不用
- 只需精确查找:哈希表 O(1) 更快,Trie 的 O(k) 没有优势
- 键是纯数值:数值型键更适合用二叉搜索树或跳表,Trie 按字符拆分反而浪费
- 内存受限且键稀疏:键之间几乎没有公共前缀时,Trie 的节点数接近 n × k,空间开销远超哈希表
- 键很短且唯一性高:短键意味着每层分支少、共享前缀少,Trie 的空间优势无法发挥
八、参考资料
- Trie - Wikipedia - Trie 数据结构的历史与形式化定义
- Linux Kernel fib_trie.c - IPv4 FIB 的 LC-trie 实现,最长前缀匹配的核心代码
- Redis rax.h - Redis 压缩 Radix Tree 的数据结构定义
- Patricia Trie - Wikipedia - 压缩 Trie 变体的原理与对比
- Algorithm Visualizer - Trie - Trie 插入与查找的交互式可视化
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






