D

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

LC208

实现 Trie(前缀树)

把一批单词存进一棵「共享前缀的树」里,让完整单词查询和前缀查询都能按字符一步步走完。

这道题不是让你「查一个字符串」,而是让你设计一个可以不断插入单词、查询完整单词、查询前缀的数据结构。下面先讲清概念,再用 14 步动画逐字符演示 insert / search / startsWith。

Trie / 前缀树字符串设计数据结构insert / search / startsWith
from problem

这道题到底在问什么

这道题不是让你「查一个字符串」,而是让你设计一个可以不断插入单词、查询完整单词、查询前缀的数据结构。 给你一个 Trie 类,要实现三个能力:

  • insert(word)把 word 插入词典。
  • search(word)判断这个「完整单词」是否曾经插入过。
  • startsWith(prefix)判断是否存在某个单词以 prefix 开头。

必须分清:search 看的是「完整单词」startsWith 看的是「前缀路径」。这两个不能混淆。

示例操作序列
Trie()
insert("apple")
search("apple")true
search("app")false
startsWith("app")true
insert("app")
search("app")true
为什么 app 一开始不是完整单词?

插入 apple 后,路径 a→p→p 存在,但第二个 p 没有 isEnd=true,所以 app 只是 apple 的前缀,不是被插入过的完整单词。

startsWith("app") 只需要路径存在,所以返回 true; search("app") 必须路径存在 终点 isEnd=true,所以返回 false。

60 sec preview

默认你没学过 Trie · 5 个概念

root → a → p → p

Trie 是什么

一棵专门存字符串的树。每条边代表一个字符。

从 root 走过的路径拼起来就是一个字符串。

app ⊂ apple

什么是前缀

一个字符串是另一个字符串的开头部分,就是前缀。

app 是 apple 的前缀。

a→p→p→(l / ...)

为什么共享前缀

apple、app、apply 都有共同前缀 app。

只存一条 a→p→p,再在后面分叉,省空间也好查前缀。

[a][b]…[z]

children[26] 是什么

只含 a–z,所以每个节点有 26 个孩子槽位。

children[0]=a,children[1]=b,… children[25]=z。

node.isEnd = true

isEnd 是什么

从 root 到当前节点拼出的字符串,是否是完整插入过的单词。

没有它,就分不清 app 是完整单词还是只是 apple 的前缀。

to model

从暴力想法走到 Trie

Step 1

我们要存很多单词

例如 apple、app、apply、bat,并且要能反复插入、查完整单词、查前缀。

Step 2

如果用数组 / 列表

search("apple") 可以逐个比较;但 startsWith("app") 要扫描很多单词,效率差。

Step 3

如果用 HashSet

search("apple") 很快;但 startsWith("app") 仍要遍历所有单词看谁以 app 开头。

Step 4

Trie:把字符串拆成字符路径

apple = root→a→p→p→l→e,app = root→a→p→p。共享前缀路径,只在必要处分叉。

建模结论
节点children[26] + isEnd
一个字符
路径一个字符串
插入没路就建,有路就走
查完整单词路径存在 && 终点 isEnd=true
查前缀路径存在即可

边界测试 · 切换后整段动画会重算

操作序列 · 点击跳到对应阶段

1/14Scene 1
当前步骤

Scene 1:初始化 Trie

Trie 一开始只有 root。root 不代表任何字符,只是所有单词的起点。它的 children[26] 全为空,isEnd=false。

root.children = 26 个空槽 · isEnd=false

图例

  • 琥珀 · 本步新建的节点
  • 蓝色 · 复用 / 前缀命中(不看 isEnd)
  • 绿色 · search 命中 / isEnd 印章
  • 灰色 · 路径在但无 isEnd → false
  • 红色 · 路径断掉(缺节点)→ nil
  • 黄色虚线环 · 当前扫描光标

前缀树档案馆 · root 是入口大厅

Scene 1 · 初始化
ROOT▼ 扫描

这一步:new TrieNode():建立空的 root。

为什么:所有单词都从同一个 root 出发,共享前缀才成为可能。

机器状态 · machine state
operationinit
input
charIndex
char
currentPathroot
nodeExists
isEndfalse
result

代码 · 当前高亮行随帧切换

1type Trie struct {
2 children [26]*Trie
3 isEnd bool
4}
5
6func Constructor() Trie {
7 return Trie{}
8}
9
10func (this *Trie) Insert(word string) {
11 node := this
12 for _, c := range word {
13 idx := int(c - 'a')
14 if node.children[idx] == nil {
15 node.children[idx] = &Trie{}
16 }
17 node = node.children[idx]
18 }
19 node.isEnd = true
20}
21
22func (this *Trie) Search(word string) bool {
23 node := this.searchPrefix(word)
24 return node != nil && node.isEnd
25}
26
27func (this *Trie) StartsWith(prefix string) bool {
28 node := this.searchPrefix(prefix)
29 return node != nil
30}
31
32func (this *Trie) searchPrefix(s string) *Trie {
33 node := this
34 for _, c := range s {
35 idx := int(c - 'a')
36 if node.children[idx] == nil {
37 return nil
38 }
39 node = node.children[idx]
40 }
41 return node
42}

Search vs StartsWith

Search
node != nil && node.isEnd

路径存在 + 终点是完整单词

StartsWith
node != nil

只要路径存在

唯一区别:是否检查 isEnd

不变量 · 算法为什么对

  • · 从 root 到某节点的路径,拼出的就是该节点代表的字符串。
  • · children[idx] 要么指向下一个字符节点,要么为空。
  • · isEnd=true ⇔ 这条路径是一个完整插入过的单词。
  • · search = 路径存在 && 终点 isEnd;startsWith = 路径存在。
  • · 同一条路径上可以有多个 isEnd(app 与 apple 并存)。

时间线 · 点击跳转

go code

代码讲解 · 拆成 4 块

1 · 节点结构

L14
  • ·children [26]*Trie:保存下一个字符节点。
  • ·isEnd bool:标记完整单词结束。

2 · Insert

L1019
  • ·从 root 开始,逐字符向下。
  • ·字符不存在就创建,存在就复用。
  • ·最后 node.isEnd = true。

3 · searchPrefix

L3241
  • ·把「沿路径走」的逻辑抽出来。
  • ·中途缺节点 → 返回 nil。
  • ·顺利走完 → 返回终点节点。

4 · Search vs StartsWith

L2229
  • ·Search:node != nil && node.isEnd
  • ·StartsWith:node != nil
  • ·唯一区别就是是否检查 isEnd。
复杂度 · 按操作拆开(L = word 长度,P = prefix 长度
insert(word)时间 O(L)空间 最坏 O(L)

每个字符处理一次;最坏每个字符都新建一个节点,所以最坏新增 O(L) 个节点。

search(word)时间 O(L)空间 额外 O(1)

沿路径走一遍即可,只用常数个临时变量。

startsWith(prefix)时间 O(P)空间 额外 O(1)

和 search 一样走路径,只是末尾不检查 isEnd。

整个 Trie 的空间

用 children[26] 时每个节点有 26 个指针槽位,总空间约 O(节点数 × 26);节点数最多接近所有插入单词的总字符数。

不要再统一写成「空间 O(1)」——那只对 search / startsWith 的额外空间成立。

面试这样说(约 60 秒)

这题我会用 Trie,也就是前缀树。每个节点包含一个 children[26] 数组和一个 isEnd 标记。children 表示下一个字符路径,isEnd 区分当前路径是否是一个完整插入过的单词。insert 时从 root 开始逐字符走,字符节点不存在就创建,最后把终点节点标记为 isEnd=true。search 和 startsWith 都复用 searchPrefix:它负责判断路径是否存在,中途某个字符不存在就返回 nil。search 要求路径存在并且终点 isEnd=true;startsWith 只要求路径存在。所以三个操作都按字符串长度线性执行,时间复杂度 O(L)。Trie 的优势是可以共享公共前缀,而且前缀查询不需要扫描所有单词。

新手最容易错的 5 个地方
  • 误区 1 · 以为路径存在就代表单词存在

    插入 apple 后 search("app") 不是 true。必须检查 app 终点节点的 isEnd 是否为 true。

  • 误区 2 · 忘记设置 isEnd

    如果 insert 最后不写 node.isEnd = true,所有 search 都无法判断完整单词。

  • 误区 3 · search 和 startsWith 写成一样

    search 要检查 isEnd;startsWith 不检查 isEnd。这是它们唯一的区别。

  • 误区 4 · 把每个 p 当成同一个节点

    apple 里的两个 p 是路径上两层的两个不同节点,不是同一个。

  • 误区 5 · 复杂度统一写成空间 O(1)

    search / startsWith 额外空间是 O(1),但 insert 可能新增节点,整个 Trie 也要存储所有路径。

迁移练习

  • LC211 添加与搜索单词 — word 含 '.' 通配,需要在节点上 DFS 分支
  • LC212 单词搜索 II — Trie + 棋盘 DFS 剪枝
  • LC648 单词替换 / LC676 实现魔法字典 — 前缀树的直接应用