添加与搜索单词:Trie 走路,'.' 用 DFS 试遍
普通字母仍走唯一孩子。点号匹配任意一个字母,当前节点没有路标,只能对每个非空孩子递归;任一成功即成功,全失败才失败。
Trie 存词,addWord 与 LC208 相同。search 用 dfs(node, i):普通字符走 children[c],没有则假;'.' 枚举所有非空孩子 dfs(child, i+1),一个真则真;i 走到词尾时返回 isEnd。最坏 O(26^m),m 是点号个数。
设计一个结构,支持 addWord(word) 和 search(word)。单词只含小写字母。搜索串可以含 '.',一个点号匹配任意一个字母。
主例先插入 bad、dad、mad。search("pad") 没有 p 这条路,假。search(".ad") 第一个字符是点号,根的孩子有 b、d、m,沿 d 再走 a、d,落到 dad 的词尾,真。search("b..") 也会真。
没有点号时这就是普通 Trie 查找。缺的是点号这一步没有唯一后继。本文要回答:为什么必须 DFS 试遍孩子,以及走到末尾为什么还要看 isEnd。
点号没有路标,只能把孩子试完
addWord 和前缀树完全一样:沿字母往下走,缺节点就建,最后一个字母标 isEnd。主例插完之后,根下面有 b、d、m 三条边,再往下各自是 a→d,三棵分叉在第二层又汇到字母 a。查找普通串 pad:根没有 p,直接假。这一步不需要回溯。
search 的缺口出在 '.'。点号不是某一个字母,当前节点的每一个非空孩子都可能对。若只走第一个孩子,主例 ".ad" 若先试 b,b→a→d 能命中 bad,碰巧对;若实现写成「找不到就假」而不枚举,碰到只有 dad 能配的模式就会漏。正确做法是 DFS:对每个孩子调用 dfs(child, i+1),谁先返回真,整次搜索就可以停;所有孩子都假,这个点号失败。
还有一个缺口在词尾。字符走完不等于单词在字典里。Trie 里 "ad" 是 dad 的后缀,根走到 a 再走到 d,节点在,但那是某条边的中途,isEnd 为假。所以 i 等于单词长度时,返回的是当前节点的 isEnd,不是「节点非空」。点号在末尾同样遵守这条:"." 匹配单字母单词,必须落到一个 isEnd 为真的孩子上。
用手走主例 search(".ad")。i=0 是点号,根的孩子分支是 b、d、m,演示第一帧。试 b:下一个要的是 a,b 下面是 a,再要 d,落到 bad 的词尾,isEnd 为真,已经可以返回真。演示走的是 d 分支:d→a→d,命中 dad,同样真。两种走法都合法,动画标出其中一条。若三个孩子下面都接不上 "ad",才返回假。
最坏情况全是点号,每个位置 26 路,时间 26 的单词长度次方。实际字典稀疏,多数孩子是空,循环里跳过即可。插入仍是线性于单词长。不要把点号当成「跳过一层任意前缀」——一个点号只吃一个字符。
一个点号只匹配一个字母'.' 不是通配剩余全部,也不是可空。试错失败就回到这个点号,换下一个孩子。词尾必须看 isEnd,否则前缀会被当成单词。
Go:递归 + 通配
func (t *Trie) search(word string) bool {var dfs func(*TrieNode, int) booldfs = func(n *TrieNode, i int) bool {if i == len(word) { return n.isEnd }c := word[i]if c != '.' {if n.children[c-'a'] == nil { return false }return dfs(n.children[c-'a'], i+1)}for _, child := range n.children {if child != nil && dfs(child, i+1) { return true }}return false}return dfs(t.root, 0)}
1字符用完:只认 isEnd,不认「节点还在」。
2普通字母走唯一槽位,空槽直接假。
3点号枚举所有非空孩子,谁先配上谁返回真。
4孩子全失败才假。主例三个分支里 d 能走到 dad。
总结
插词走 Trie;搜索遇 '.' 对每个孩子 DFS。主例 ".ad" 经 d 命中 dad。
- addWord 与 LC208 相同。点号只出现在 search。
- 一个点号吃一个字母,要试遍当前孩子,不是跳过剩余串。
- 走到末尾看 isEnd。前缀 ad 在 dad 里,单独搜 "ad" 为假。