Trie 是什么
一棵专门存字符串的树。每条边代表一个字符。
从 root 走过的路径拼起来就是一个字符串。
把一批单词存进一棵「共享前缀的树」里,让完整单词查询和前缀查询都能按字符一步步走完。
这道题不是让你「查一个字符串」,而是让你设计一个可以不断插入单词、查询完整单词、查询前缀的数据结构。下面先讲清概念,再用 14 步动画逐字符演示 insert / search / startsWith。
这道题不是让你「查一个字符串」,而是让你设计一个可以不断插入单词、查询完整单词、查询前缀的数据结构。 给你一个 Trie 类,要实现三个能力:
必须分清:search 看的是「完整单词」,startsWith 看的是「前缀路径」。这两个不能混淆。
插入 apple 后,路径 a→p→p 存在,但第二个 p 没有 isEnd=true,所以 app 只是 apple 的前缀,不是被插入过的完整单词。
startsWith("app") 只需要路径存在,所以返回 true; search("app") 必须路径存在 且 终点 isEnd=true,所以返回 false。
一棵专门存字符串的树。每条边代表一个字符。
从 root 走过的路径拼起来就是一个字符串。
一个字符串是另一个字符串的开头部分,就是前缀。
app 是 apple 的前缀。
apple、app、apply 都有共同前缀 app。
只存一条 a→p→p,再在后面分叉,省空间也好查前缀。
只含 a–z,所以每个节点有 26 个孩子槽位。
children[0]=a,children[1]=b,… children[25]=z。
从 root 到当前节点拼出的字符串,是否是完整插入过的单词。
没有它,就分不清 app 是完整单词还是只是 apple 的前缀。
例如 apple、app、apply、bat,并且要能反复插入、查完整单词、查前缀。
search("apple") 可以逐个比较;但 startsWith("app") 要扫描很多单词,效率差。
search("apple") 很快;但 startsWith("app") 仍要遍历所有单词看谁以 app 开头。
apple = root→a→p→p→l→e,app = root→a→p→p。共享前缀路径,只在必要处分叉。
Trie 一开始只有 root。root 不代表任何字符,只是所有单词的起点。它的 children[26] 全为空,isEnd=false。
这一步:new TrieNode():建立空的 root。
为什么:所有单词都从同一个 root 出发,共享前缀才成为可能。
1type Trie struct {2 children [26]*Trie3 isEnd bool4}56func Constructor() Trie {7 return Trie{}8}910func (this *Trie) Insert(word string) {11 node := this12 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 = true20}2122func (this *Trie) Search(word string) bool {23 node := this.searchPrefix(word)24 return node != nil && node.isEnd25}2627func (this *Trie) StartsWith(prefix string) bool {28 node := this.searchPrefix(prefix)29 return node != nil30}3132func (this *Trie) searchPrefix(s string) *Trie {33 node := this34 for _, c := range s {35 idx := int(c - 'a')36 if node.children[idx] == nil {37 return nil38 }39 node = node.children[idx]40 }41 return node42}
node != nil && node.isEnd路径存在 + 终点是完整单词
node != nil只要路径存在
唯一区别:是否检查 isEnd。
每个字符处理一次;最坏每个字符都新建一个节点,所以最坏新增 O(L) 个节点。
沿路径走一遍即可,只用常数个临时变量。
和 search 一样走路径,只是末尾不检查 isEnd。
用 children[26] 时每个节点有 26 个指针槽位,总空间约 O(节点数 × 26);节点数最多接近所有插入单词的总字符数。
不要再统一写成「空间 O(1)」——那只对 search / startsWith 的额外空间成立。
这题我会用 Trie,也就是前缀树。每个节点包含一个 children[26] 数组和一个 isEnd 标记。children 表示下一个字符路径,isEnd 区分当前路径是否是一个完整插入过的单词。insert 时从 root 开始逐字符走,字符节点不存在就创建,最后把终点节点标记为 isEnd=true。search 和 startsWith 都复用 searchPrefix:它负责判断路径是否存在,中途某个字符不存在就返回 nil。search 要求路径存在并且终点 isEnd=true;startsWith 只要求路径存在。所以三个操作都按字符串长度线性执行,时间复杂度 O(L)。Trie 的优势是可以共享公共前缀,而且前缀查询不需要扫描所有单词。
插入 apple 后 search("app") 不是 true。必须检查 app 终点节点的 isEnd 是否为 true。
如果 insert 最后不写 node.isEnd = true,所有 search 都无法判断完整单词。
search 要检查 isEnd;startsWith 不检查 isEnd。这是它们唯一的区别。
apple 里的两个 p 是路径上两层的两个不同节点,不是同一个。
search / startsWith 额外空间是 O(1),但 insert 可能新增节点,整个 Trie 也要存储所有路径。