本章导读
Trie(读作 "try",来自 retrieval)是一种多叉树结构,每条边代表一个字符,从根到叶的路径拼出一个完整字符串。它的核心优势:前缀匹配 O(m)(m 为查询串长度),与存储的字符串总数 n 无关。这让它成为自动补全、拼写检查、IP 路由表的首选数据结构。
- 理解 Trie 的树形结构和字符→路径映射
- 实现插入、精确查找、前缀查找
- 实现自动补全(给定前缀,列出所有匹配词)
- 实现删除操作(含懒删除和物理删除)
- 实现词频统计
- 对比 Trie 与 dict/set 的性能差异
53.1 Trie 的原理
存储 {"cat", "car", "card", "dog"} 的 Trie:
// (root)
// / \
// c d
// / \ \
// a a o
// / \ \
// t* r* g*
// |
// d*
//
// * 表示"词尾"(isEnd = true)
// "car" 和 "card" 共享前缀 "car"
// "cat" 和 "car" 共享前缀 "ca"
关键观察:
- 共享前缀的字符串共享路径——节省空间
- 查找 "car" 只需走 3 步(c→a→r),与词典大小无关
- 前缀 "ca" 下的所有词就是 c→a 子树中的所有 isEnd 节点
53.2 节点设计
class TrieNode
dict<int, TrieNode> children // 字符(ASCII) → 子节点
bool isEnd // 是否为某个词的结尾
int count // 经过此节点的词数(用于统计)
func init()
children = dict<int, TrieNode>()
isEnd = false
count = 0
end
end
class Trie
TrieNode root
func init()
root = TrieNode()
root.init()
end
end
用 dict<int, TrieNode> 存储子节点映射(键为字符 ASCII 值)。对于纯小写字母,也可以用 array<TrieNode>(26) 获得 O(1) 查找,但浪费空间。
53.3 插入操作
func insert(string word)
TrieNode node = root
int i = 0
while i < word.len()
int ch = word[i]
if not node.children.has(ch) then
TrieNode child = TrieNode()
child.init()
node.children[ch] = child
end
node = node.children[ch]
node.count += 1
i += 1
end
node.isEnd = true
end
// 使用
Trie trie = Trie()
trie.init()
trie.insert("cat")
trie.insert("car")
trie.insert("card")
trie.insert("dog")
时间复杂度 O(m),m 为词长。每步检查子节点是否存在,不存在则创建。
53.4 查找操作
// 精确查找:词是否完整存在
func search(string word) as bool
TrieNode node = findNode(word)
return node != null and node.isEnd
end
// 前缀查找:是否有词以 prefix 开头
func startsWith(string prefix) as bool
return findNode(prefix) != null
end
// 辅助:沿路径走到末尾节点
func findNode(string s) as TrieNode
TrieNode node = root
int i = 0
while i < s.len()
int ch = s[i]
if not node.children.has(ch) then
return null
end
node = node.children[ch]
i += 1
end
return node
end
// 测试
assert trie.search("car") // true
assert trie.search("ca") == false // "ca" 不是完整词
assert trie.startsWith("ca") // true(有词以 "ca" 开头)
assert trie.search("dog") // true
assert trie.search("do") == false
search 和 startsWith 的区别:search 要求 isEnd == true(完整词),startsWith 只要求路径存在。
53.5 前缀搜索
找到所有以指定前缀开头的词:
func collectWords(TrieNode node, string prefix, list<string> result)
if node.isEnd then
result.push(prefix)
end
// 遍历所有子节点(按字符排序)
list<int> keys = node.children.keys()
int i = 0
while i < keys.len()
int ch = keys[i]
string newPrefix = prefix + string.char(ch)
collectWords(node.children[ch], newPrefix, result)
i += 1
end
end
func wordsWithPrefix(string prefix) as list<string>
list<string> result = list<string>()
TrieNode node = findNode(prefix)
if node == null then return result end
collectWords(node, prefix, result)
return result
end
// 测试
list<string> words = trie.wordsWithPrefix("car")
// → ["car", "card"]
list<string> allC = trie.wordsWithPrefix("c")
// → ["car", "card", "cat"]
53.6 自动补全
自动补全 = 前缀搜索 + 排序 + 限制数量:
func autocomplete(string prefix, int maxResults) as list<string>
list<string> all = wordsWithPrefix(prefix)
// 按词频排序(count 越大越热门)或按字母序
// 这里简单取前 maxResults 个
list<string> result = list<string>()
int i = 0
while i < all.len() and i < maxResults
result.push(all[i])
i += 1
end
return result
end
// 模拟搜索框
list<string> suggestions = trie.autocomplete("ca", 5)
int i = 0
while i < suggestions.len()
println(" " + suggestions[i])
i += 1
end
// 输出:
// car
// card
// cat
53.7 删除操作
删除需要处理共享前缀的情况:
// 懒删除:只标记 isEnd = false
func deleteLazy(string word)
TrieNode node = findNode(word)
if node != null then
node.isEnd = false
end
end
// 物理删除:回收无用节点
func deletePhysical(string word) as bool
return deleteHelper(root, word, 0)
end
func deleteHelper(TrieNode node, string word, int depth) as bool
if depth == word.len() then
if not node.isEnd then return false end
node.isEnd = false
return node.children.len() == 0 // 无子节点则可删除
end
int ch = word[depth]
if not node.children.has(ch) then return false end
bool shouldDelete = deleteHelper(node.children[ch], word, depth + 1)
if shouldDelete then
node.children.remove(ch)
return not node.isEnd and node.children.len() == 0
end
return false
end
// 测试
trie.deletePhysical("card")
assert trie.search("card") == false
assert trie.search("car") == true // "car" 不受影响
53.8 词频统计
记录每个词被插入的次数:
class TrieWithFreq
TrieNode root
dict<string, int> freq // 词 → 频率
func insert(string word)
// ... 同 Trie.insert ...
if freq.has(word) then
freq[word] = freq[word] + 1
else
freq[word] = 1
end
end
func getFreq(string word) as int
if freq.has(word) then return freq[word] end
return 0
end
// 热门补全:按频率降序
func hotComplete(string prefix, int n) as list<string>
list<string> all = wordsWithPrefix(prefix)
// 按 freq 降序排序(简单选择排序)
int i = 0
while i < all.len()
int maxIdx = i
int j = i + 1
while j < all.len()
if getFreq(all[j]) > getFreq(all[maxIdx]) then
maxIdx = j
end
j += 1
end
if maxIdx != i then
string tmp = all[i]
all[i] = all[maxIdx]
all[maxIdx] = tmp
end
i += 1
end
// 取前 n 个
list<string> result = list<string>()
int k = 0
while k < all.len() and k < n
result.push(all[k])
k += 1
end
return result
end
end
53.9 完整实现
// trie.xl — 完整测试
Trie trie = Trie()
trie.init()
// 插入词典
trie.insert("apple")
trie.insert("app")
trie.insert("application")
trie.insert("apply")
trie.insert("banana")
trie.insert("band")
trie.insert("bat")
// 精确查找
assert trie.search("app")
assert trie.search("apple")
assert not trie.search("ap")
assert trie.startsWith("ap")
// 前缀搜索
list<string> apps = trie.wordsWithPrefix("app")
println("app* → " + apps.len().toString() + " 个词")
// app, apple, application, apply
list<string> bas = trie.wordsWithPrefix("ba")
println("ba* → " + bas.len().toString() + " 个词")
// banana, band, bat
// 删除
trie.deletePhysical("app")
assert not trie.search("app")
assert trie.search("apple") // 共享前缀不受影响
// 自动补全
list<string> suggestions = trie.wordsWithPrefix("appl")
println("补全 appl: " + suggestions.len().toString() + " 个")
// apple, application, apply
53.10 性能对比
| 操作 | Trie | dict/set | 排序数组 |
|---|---|---|---|
| 精确查找 | O(m) | O(m) 平均 | O(m·log n) |
| 前缀搜索 | O(m + k) | O(n·m) | O(m·log n + k) |
| 插入 | O(m) | O(m) 平均 | O(n·m) |
| 空间 | O(n·m) 最坏 | O(n·m) | O(n·m) |
m = 字符串长度,n = 字符串数量,k = 匹配结果数。Trie 的核心优势在前缀搜索:O(m+k) vs 暴力扫描的 O(n·m)。
深入细节
普通 Trie 中,如果一条路径上每个节点都只有一个子节点(如 "application" 的 a→p→p→l→i→c→a→t→i→o→n),这些节点可以合并为一个边(存储子串 "application")。这就是 Radix Tree / Patricia Trie,大幅减少节点数和内存占用。Linux 内核的路由表就使用 Radix Tree。
每个节点存储一个 dict(子节点映射),对于大量短词(如所有英文单词),节点数可能达到数十万。优化方案:① 子节点用排序数组代替 dict(节省哈希表开销);② 双数组 Trie(DAT)用两个紧凑数组表示整棵树;③ LOUDS 编码用位序列表示树结构。
本章按字节(ASCII)建 Trie。对于中文等多字节字符,需要按 Unicode 码点建边。xlang 的字符串索引返回字节值,处理中文需要先解码为码点序列。另一种方案:按 UTF-8 字节建 Trie(每个汉字 3 条边),查找时同样按字节走,逻辑不变但树更深。
⚠️ 常见错误
search("app") 要求 isEnd==true("app" 是完整词);startsWith("app") 只要求路径存在。插入 "apple" 后,startsWith("app") 为 true 但 search("app") 为 false(除非也插入了 "app")。
删除 "card" 时不能删除 c→a→r 路径上的节点,因为 "car" 也在使用。物理删除只能回收"没有其他词经过且不是词尾"的节点。
insert("") 会将 root.isEnd 设为 true。search("") 返回 true。这通常是期望行为(空串是任何串的前缀),但需要明确处理。
本章小结
| 主题 | 核心要点 |
|---|---|
| 结构 | 多叉树,边=字符,根到 isEnd 节点=完整词 |
| 插入 | 逐字符走/建节点,末尾标记 isEnd |
| 查找 | 逐字符走,search 要求 isEnd,startsWith 只要求路径存在 |
| 前缀搜索 | 走到前缀节点,DFS 收集子树中所有 isEnd |
| 自动补全 | 前缀搜索 + 排序 + 限制数量 |
| 删除 | 懒删除(isEnd=false)或物理删除(递归回收无用节点) |
| 词频 | 额外 dict 记录频率,热门补全按频率排序 |
| 复杂度 | 所有操作 O(m),前缀搜索 O(m+k),与词典大小 n 无关 |
复习题
- Trie 中 "car" 和 "card" 如何存储?它们共享哪些节点?
- search("app") 和 startsWith("app") 的区别是什么?
- 前缀搜索的时间复杂度为什么与词典大小无关?
- 物理删除 "card" 时,为什么不能删除 c→a→r 路径?
- Trie 相比 dict 的核心优势是什么?劣势是什么?
- 压缩 Trie(Radix Tree)解决什么问题?
- 如何用 Trie 实现拼写检查(编辑距离 ≤ 1 的建议)?
- 对于中文词典,Trie 的边应该用什么作为键?
编程练习
给定一个词典(100 个英文单词),实现拼写检查:输入一个词,如果不在词典中,输出编辑距离 ≤ 1 的所有建议(插入一个字母、删除一个字母、替换一个字母)。
参考思路:对输入词的每个位置尝试 26 种替换、删除、插入,检查是否在 Trie 中。
实现 IP 路由表:插入 CIDR 前缀(如 "192.168.1"→"LAN"),查询 IP 时返回最长匹配的路由。如 "192.168.1.100" 匹配 "192.168.1" 而非 "192"。
参考思路:按 . 分割 IP 段建 Trie;查询时逐段走,记录最后一个 isEnd 节点的值。
读取一段文本(硬编码),将所有单词插入带频率的 Trie,然后输出:① 出现频率最高的 10 个词;② 给定前缀的热门补全(按频率排序)。
参考思路:split 分词 → 转小写 → insert;hotComplete 按频率排序。