当前:LC208 · 实现 Trie (前缀树) · 首次出现于 Day 33 · 路径:顶栏「56天打卡」→ 点击 LC 题号 → 逐题动画

LC208 · Implement Trie · 设计 / 字符串

实现 Trie:字母当路标,前缀共用

每个节点是一个字符,根到某节点的路径拼出一个前缀。插入沿路走、缺了就建;search 与 startsWith 的差别,只在最后那一眼 isEnd。

节点结构 TrieNode{ children: [26]*TrieNode; isEnd bool }。插入与查询都从根出发逐字符行走:孩子存在则下移,不存在则新建(插入)或判定缺失(查询)。search 要求走到终点且 isEnd=true;startsWith 只要求路径存在。时间 O(len),空间 O(总字符数)。

时间 O(len)空间 O(总字符数)结论先行 · 全文约 6 节
导读

实现一个前缀树(Trie),支持 insert(word)、search(word)、startsWith(prefix) 三种操作。

为什么需要它?把单词逐字符展开成共享路径,公共前缀只存一份——比哈希集合省空间,还能高效回答「有哪些单词以某前缀开头」。

这道题真正的考点不是代码量,而是理解一个边界:路径存在 ≠ 单词存在。'app' 可以是 'apple' 的前缀,但只有当有人真正 insert 过 'app',它才算一个单词。isEnd 就是用来记录这一点的。

把单词翻译成路径

想象一座档案馆:中央是入口大厅(根节点),从大厅出发,每一个字母都是一条通往下一层的通道。

插入单词 'apple',就是在大厅里找到 'a' 的通道、走进去,再找 'p' 的通道、走进去……直到 'e' 为止。每个走过的房间就是一个节点,代表「从根到这里拼出的前缀」。

用代码表达:节点不存整个单词,只存 26 个孩子槽位(对应 a–z)和一个 isEnd 布尔标记。整个单词的信息分散在从根到该节点的路径上。

所以「节点」真正代表的是一段前缀,而不是一个单词——这是理解后面一切的基础。

路径即单词根到某节点的每一条边是一个字母,连起来就是这段前缀;isEnd 只在路径尽头是一个完整单词时点亮。

插入:沿路走,缺了就建

insert 的规则只有两条:从根出发逐字符走,孩子存在就下移,不存在就新建并下移;走到最后一个字符的房间,点亮 isEnd。

先插 'apple':a、p、p、l、e 全都要新建,最后给 'e' 房间点亮 isEnd。

再插 'app':前三个字符 a、p、p 的路已经存在(刚才建的),直接复用,不需要新建;走到第三个 'p' 房间,点亮它的 isEnd。

注意这里发生了什么:'app' 与 'apple' 共享了 a→p→p 三个房间,只在第 4 层分岔(一个去 l、一个在此结束)。共享前缀 = 共享节点,这正是 Trie 省空间的来源。

共享先插 apple 再插 app:app 不用新建任何节点,只要点亮第三个 p 的 isEnd 即可。
先插 apple 建路径,再插 app 复用路径并点亮 isEnd
ROOTappisEndleisEnd
apple
走 a:新建 a 节点

startsWith:只看路径

startsWith(prefix) 是 search 的放宽版:只要能从根走到 prefix 的最后一个字符,就返回 true——完全不检查 isEnd。

因为「是否存在以某前缀开头的单词」只关心前缀路径本身是否存在,不关心这个前缀是不是一个完整单词。

插过 'apple' 后:startsWith('app') → true(即使 'app' 不是单词);startsWith('apx') → false(走到 x 时发现孩子为空,路径断了)。

这刚好和 search 形成对照:search 严格(要 isEnd),startsWith 宽松(只要路径)。两个操作合起来,就是 Trie 对外提供的全部能力。

startsWith('app') → true;startsWith('apx') → false
ROOTappisEndleisEnd
app
true
startsWith(app):路径 a→p→p 存在 → true(不看 isEnd)

易错点与复杂度

第一个坑:忘掉 isEnd 检查。只比路径会导致 search('app') 在只插过 'apple' 时错误返回 true。

第二个坑:重复插入同一单词。insert 走既有路径、再次点亮 isEnd,无害但要注意幂等。

第三个坑:用 map[rune]*TrieNode 替代 [26] 数组。功能等价,但用固定数组能免去哈希开销,且便于讨论空间。

复杂度:时间 O(len)——insert/search/startsWith 都是遍历一次单词长度。空间 O(总字符数)——每个字符贡献一个节点,节点数等于所有插入单词的字符总数(去重后)。

LC211 把 '.' 通配符引入 search,需要在匹配到 '.' 时遍历全部孩子;LC648 用 Trie 做单词替换。掌握这道题的结构,那些都是小改。

面试表达先画一条 apple 的路径,再解释 app 复用前三层、点亮 isEnd——两个操作和一个边界就讲完了。

Go:Trie 结构

solution.goGo
type Trie struct {
children [26]*Trie
isEnd bool
}
func Constructor() Trie { return Trie{} }
func (t *Trie) Insert(word string) {
n := t
for _, ch := range word {
idx := ch - 'a'
if n.children[idx] == nil {
n.children[idx] = &Trie{}
}
n = n.children[idx]
}
n.isEnd = true
}
func (t *Trie) Search(word string) bool {
n := t
for _, ch := range word {
idx := ch - 'a'
if n.children[idx] == nil { return false }
n = n.children[idx]
}
return n.isEnd
}
func (t *Trie) StartsWith(prefix string) bool {
n := t
for _, ch := range prefix {
idx := ch - 'a'
if n.children[idx] == nil { return false }
n = n.children[idx]
}
return true
}

1节点:26 个孩子槽 + 词尾标记。

2从根出发。

3槽位为空则新建。

4下移一个节点。

5词尾打标记。

6Search 沿路径走。

7任何一步孩子为空即返回 false。

8终点必须 isEnd=true。

9StartsWith 同样沿路径走。

10只要路径存在就返回 true,不检查 isEnd。

总结

Trie = 字符路径 + isEnd 词尾标记;insert 建路,search 验标,startsWith 只看路。

  • 每个节点是一个前缀,路径拼出单词;公共前缀共享节点。
  • isEnd 区分「完整单词」与「只是前缀」,是 search 的边界。
  • insert 缺了建路、search 断路返回 false、startsWith 不查 isEnd。
  • 时间 O(len),空间 O(总字符数);LC211 用 '.' 通配符需遍历孩子。
同族题目
LC211添加与搜索单词(字典树)LC14最长公共前缀LC648单词替换