D

当前:LC211 · 添加与搜索单词 · 首次出现于 Day 33 · 路径:顶栏「56天打卡」→ 点击 LC 题号 → 逐题动画

LC211

添加与搜索单词

给词典加上通配搜索:'.' 匹配任意一个字符,让「按字符走一条路」变成「在岔路口对每个孩子分别试」。

只支持精确查询用 HashSet 就够;难点是 search 里的 . —— 它匹配任意单字符。用 Trie 存词,普通字符沿唯一边走,遇到 . 就对所有非空孩子 DFS 扇出。下面两幕分别讲「直觉」和「机制」。

Trie / 前缀树DFS / 回溯通配匹配设计数据结构
from problem

这道题到底在问什么

设计一个支持通配搜索的词典,要实现两个能力:

  • addWord(word)把 word 加入词典。
  • search(word)查询;word 里可能含 .,匹配任意一个字符。

只支持精确查询的话,HashSet 就够了。问题出在那个 .——它能匹配任意单个字符,你没法拿一个含通配的串去查字面 key。

示例(已存入 4 个词)
addWord: bad / dad / mad / pad
search("bad") true
search(".ad") true
search("b..") true
search("ba") false
为什么 search("ba") 是 false?

路径 b→a 存在,但 ba 节点没有 isEnd 印章——它只是 bad 的前缀,不是被加入过的完整单词。模式读完还得落点 isEnd=true 才算命中。

60 sec preview

开搜之前 · 5 个概念

. = 任意一个字符

通配符 '.' 是什么

'.' 匹配「恰好一个」任意小写字母——不是跳过,也不是任意多个。

".ad" 长度仍是 3,匹配 bad/dad/mad/pad。

set.has(".ad") → 必然 false

为什么 HashSet 不行

精确查询用 HashSet 就够;但带 '.' 时你不知道要查哪个 key。

".ad" 不等于任何字面 key,HashSet 直接漏掉。

root → 字符路径

Trie 复习

把单词拆成「从 root 逐字符的路径」,相同前缀共享一段路。

bad/dad/mad/pad 在 root 下分成 4 条 →a→d 链。

试一条 → 不行 → 退回试下一条

DFS + 回溯

遇到 '.' 就对每个孩子递归深入,失败就回溯换下一个孩子。

只要有一条成功就立刻返回 true(短路)。

走到 ≠ 是单词

落点要 isEnd

模式读完只说明路径存在,还要落点 isEnd=true 才是完整单词。

"ba" 能走到,但 ba 无 isEnd → false。

to model

从 HashSet 走到 Trie + 通配 DFS

想法 1

HashSet 精确匹配

addWord 存进集合,search 直接 set.has。精确查询 O(1),但 '.' 一来就废——你没法用一个含通配的串去查字面 key。

想法 2

按长度分桶 + 逐个比

把单词按长度分桶,search 时拿同长度的逐个字符比对('.' 处跳过比较)。能work,但最坏要扫该长度下的所有单词,没利用「共享前缀」。

想法 3

Trie 逐字符走

用 Trie 存单词。普通字符沿唯一边走,天然共享前缀;这正是 LC208 的结构,addWord 与 insert 完全一样。

想法 4

'.' 处分叉 DFS

唯一新增:search 遇 '.' 不再是单指针,而是对当前节点所有非空孩子递归深入,任一成功即 true。这就是本题的全部增量。

建模结论
addWord= LC208 insert,终点 isEnd=true
普通字符单指针沿唯一边走
'.' 通配对所有非空孩子 DFS
命中条件某条路径读完 && 落点 isEnd

最该记住的一张反例卡

'.' 不是「跳过」、也不是正则 '*'

跳过 → 长度会少 1,".ad" 变成匹配 2 字符;正则 '*' → 匹配任意多个字符。这两种都不对。

'.' = 恰好一个任意字符

它消费模式里一格、也消费树里一条边,只是不在乎走的是哪条边——于是从单指针裂成「对每个孩子各试一次」。

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

1/7Scene 1
当前步骤

Scene 1:要在档案馆里搜 ".ad"

词典里已存入 bad / dad / mad / pad。现在 search(".ad"),从 root 出发,普通字符只能走对应那条边,'.' 则要对所有存在的孩子分别试。

pattern=".ad" · 待搜索
DFS 调用栈 · 越下越深
dfs(root, i=0)栈顶

图例

  • 琥珀 · DFS 当前活路(栈上)
  • 紫色 · 正在对孩子扇出的节点
  • 蓝色 · 候选 / 待试的孩子
  • 绿色 · 命中(路径走完且 isEnd)
  • 灰色 · 试过失败 / 断路

前缀树档案馆 · DFS 调用视角

准备
模式
.ad
未开始
bdmpaaaaddddROOTbdmpaaaadddd
活跃分支fanout = 1单指针 · 和 LC208 同款

这一步:调用 search(".ad"),转交 dfs(root, 0)。

为什么:搜索从 root 开始,逐字符消费模式串。

机器状态 · machine state
operationsearch
input.ad
patIndex
char
isDotfalse
fanout1
resultpending

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

1type Node struct {
2 children [26]*Node
3 isEnd bool
4}
5
6func (d *WordDictionary) AddWord(word string) {
7 node := d.root
8 for _, c := range word {
9 i := c - 'a'
10 if node.children[i] == nil {
11 node.children[i] = &Node{}
12 }
13 node = node.children[i]
14 }
15 node.isEnd = true
16}
17
18func (d *WordDictionary) Search(word string) bool {
19 return dfs(d.root, word, 0)
20}
21
22func dfs(node *Node, word string, i int) bool {
23 if i == len(word) {
24 return node.isEnd
25 }
26 c := word[i]
27 if c == '.' {
28 for _, child := range node.children {
29 if child != nil && dfs(child, word, i+1) {
30 return true
31 }
32 }
33 return false
34 }
35 next := node.children[c-'a']
36 if next == nil {
37 return false
38 }
39 return dfs(next, word, i+1)
40}

不变量 · 算法为什么对

  • · 普通字符只走唯一一条边(单指针),缺这条边就 false。
  • · '.' 对当前节点所有非空孩子分别 DFS,任一成功即 true。
  • · '.' 消费恰好一个字符——长度不变,不是跳过、不是任意多个。
  • · 命中 ⇔ 某条路径读完模式且落点 isEnd=true。
  • · 找到第一条成功路径就短路返回,不再探别的岔路。

时间线 · 点击跳转

go code

代码讲解 · 拆成 4 块

AddWord(同 LC208)

L615
  • ·逐字符找/建孩子节点
  • ·走到终点把 isEnd 置 true
  • ·和实现 Trie 的 insert 一字不差

Search 入口

L1820
  • ·把整件事交给递归 dfs
  • ·从 root、模式下标 0 开始

dfs 基例

L2225
  • ·i == len(word):模式读完
  • ·返回 node.isEnd——这就是 search vs startsWith 的区别

dfs 的 '.' 分支

L2733
  • ·对 children 里每个非空孩子递归
  • ·任一返回 true 立刻短路返回 true
  • ·全失败才 return false
复杂度 · 按操作拆开(L=词长,k='.' 的个数,Σ=节点总数
addWord(word)时间 O(L)空间 O(L)

逐字符插入,最多新建 L 个节点。

search 无 '.'时间 O(L)空间 O(L)

退化成一条路径下行,和 LC208 查询同级;空间是递归栈深。

search 含 k 个 '.'时间 O(26^k · L) 最坏空间 O(L)

每个 '.' 最坏分裂 26 路,叠加成指数级搜索树;上界也被节点总数 Σ 限制。

一句话

普通字符是单路 walk;'.' 是分叉搜索。通配越多、越靠前,扇出越猛,这正是动画要让你看见的代价。

面试这样说(约 60 秒)

用 Trie 存所有单词,addWord 和实现 Trie 的 insert 一样、终点置 isEnd。search 写成 dfs:普通字符沿唯一孩子走;遇到 '.' 就对当前节点所有非空孩子递归,任一成功即返回 true。命中条件是某条路径读完模式且落点 isEnd。复杂度:addWord 与无通配的 search 都是 O(L);含 k 个 '.' 时最坏 O(26^k·L),因为每个通配位都可能分裂成多路 DFS。

新手最容易错的 5 个地方
  • 把 '.' 当成「跳过这个字符」

    '.' 消费「恰好一个」字符,长度不变。跳过会让 ".ad" 去匹配长度 2 的词,错。

  • 把 '.' 当成正则的 '*'

    正则 '*' 是「任意多个」,'.' 只是「任意一个」。本题没有「零个或多个」的语义。

  • '.' 处枚举 26 个字母而非现有孩子

    应只对非空孩子递归。凭空枚举 'a'..'z' 会去访问大量 nil,既慢又易空指针。

  • 找到一条成功路径还继续找

    任一孩子返回 true 就该立刻 return true 短路;继续遍历是白做无用功。

  • 忘记基例的 isEnd 判断

    模式读完只代表路径存在。漏掉 node.isEnd 会把 "ba" 这种纯前缀误判成命中。

迁移练习

  • LC208 实现 Trie —— 本题的 addWord/普通字符就是它
  • LC212 单词搜索 II —— Trie + 网格 DFS,扇出思路同源
  • LC79 单词搜索 —— 网格上的回溯,体会「试—失败—回溯」