LeetCode-Go 题解精讲:208. Implement Trie (Prefix Tree) —— 一份可复用的 Go 语言前缀树模板
【免费下载链接】LeetCode-Go✅ Solutions to LeetCode by Go, 100% test coverage, runtime beats 100% | LeetCode 题解项目地址: https://gitcode.com/GitHub_Trending/le/LeetCode-Go
前缀树(Trie)是字符串处理类题目中的高频核心数据结构。本文以 LeetCode 208 题「Implement Trie (Prefix Tree)」为载体,结合 LeetCode-Go 仓库中 leetcode/0208.Implement-Trie-Prefix-Tree 的完整实现与测试用例,逐行剖析insert、search、startsWith三个核心操作的底层原理,并展示这套实现如何作为通用 Trie 模板被仓库其他题目直接复用。读完本文,你既能透彻理解前缀树的 Go 实现细节,也能掌握一套可直接迁移到词频统计、自动补全、敏感词过滤等场景的代码骨架。
题目背景与核心要求
LeetCode 208 题要求设计一个支持以下三种操作的前缀树(Trie,又称字典树):
Insert(word):向树中插入一个单词;Search(word):判断某个单词是否完整存在于树中;StartsWith(prefix):判断树中是否存在以给定前缀开头的任意单词。
题目给出的操作示例(来自 网站题解文档):
Trie trie = new Trie(); trie.insert("apple"); trie.search("apple"); // returns true trie.search("app"); // returns false trie.startsWith("app"); // returns true trie.insert("app"); trie.search("app"); // returns true这里最值得注意的语义差异是:search("app")在未插入 "app" 之前返回false,而startsWith("app")返回true。这说明search 要求「完整单词」,而startsWith 只要求「前缀可达」,两者的唯一区别在于对终点节点isWord标记的判断,这正是本题实现的关键细节。
题目附带两条输入约束(实现时可据此简化边界处理):
- 所有输入仅包含小写字母
a-z; - 所有输入均为非空字符串。
前缀树的核心思想:以空间换前缀共享
在深入代码之前,先建立直观模型。Trie 的本质是一棵多叉树,但与二叉搜索树不同,它的边(edge)携带字符,从根节点到任意节点的路径拼接起来就是一个字符串前缀。多个单词共享相同前缀时,这些前缀路径在树中只存储一次。
以插入 "apple" 和 "app" 为例,共享的 "app" 路径只有一份:
root | a | p | p ── (isWord=true,表示 "app" 完整存在) | l | e ── (isWord=true,表示 "apple" 完整存在)正是这种「公共前缀只存一次」的路径复用,使得 Trie 在前缀查询、最长公共前缀、词频统计等场景中拥有远高于朴素遍历的查找效率——每次查询只需沿字符路径走O(L)步(L为字符串长度),与字典中已存单词的总数无关。
数据结构设计:map 驱动的子节点存储
仓库中的实现位于 208. Implement Trie (Prefix Tree).go,核心结构只有两个字段:
type Trie struct { isWord bool children map[rune]*Trie }isWord bool:标记从根节点到当前节点的路径是否构成一个完整单词。这是Search与StartsWith产生语义差异的关键开关;children map[rune]*Trie:以字符为键、子节点为值的哈希表,负责承载「边上的字符」。
与教科书常见的「定长 26 元素数组([26]*Trie)」方案相比,map[rune]*Trie有两个明显优势:
- 天然支持 Unicode:
map[rune]的键类型是 Go 的 rune(即 int32),配合代码中for _, ch := range word的按 rune 遍历,这套实现无需任何改动即可处理中文等多字节字符,而固定数组方案受限于 ASCII 字母表; - 按需分配内存:每个节点只为其实际存在的子字符分配 map 桶,避免了 26 元素数组带来的固定空间开销。
代价是 map 的哈希查找比数组索引略慢、常数更大,但在 LeetCode 的题目约束(小写字母a-z)下性能完全足够,且代码更通用、可读性更好。
构造函数同样简洁,为每个新建节点初始化空 map,并显式将isWord置为false:
func Constructor208() Trie { return Trie{isWord: false, children: make(map[rune]*Trie)} }插入操作 Insert:沿路径下沉,缺失即新建
func (this *Trie) Insert(word string) { parent := this for _, ch := range word { if child, ok := parent.children[ch]; ok { parent = child } else { newChild := &Trie{children: make(map[rune]*Trie)} parent.children[ch] = newChild parent = newChild } } parent.isWord = true }插入的算法流程可以拆解为三步:
- 游标初始化:从根节点
this开始,用局部变量parent记录当前所在节点; - 逐字符下沉:对单词的每个字符
ch,先在parent.children中查找是否已有对应子节点——已存在则直接沿该子节点继续走(共享前缀路径),不存在则新建一个Trie节点挂到parent.children[ch]上,再继续下沉; - 终点打标记:整条路径走完后,将最终节点(即单词最后一个字符对应的节点)的
isWord置为true。
最后一个步骤是 Insert 的灵魂:没有parent.isWord = true,就无法区分「路径恰好经过某字符」与「单词完整存在」两种状态。例如插入 "apple" 后,路径上 "a"、"ap"、"app"、"appl" 节点都真实存在,但它们都不是完整单词,只有isWord=true的 "apple" 终点才算数。
查询操作 Search:路径可达之外,还需 isWord 为真
func (this *Trie) Search(word string) bool { parent := this for _, ch := range word { if child, ok := parent.children[ch]; ok { parent = child continue } return false } return parent.isWord }Search 分两段完成:
- 路径检查:沿单词字符逐层下沉,若任何一步在
children中找不到对应子节点,说明该单词前缀路径尚未被任何已插入单词覆盖,直接返回false; - 终点检查:路径全部走通后,返回终点节点的
isWord值。
这解释了开头的示例——插入 "apple" 后执行search("app"):路径 "a"→"p"→"p" 全部可达,但终点节点isWord仍为false(因为 "app" 从未作为完整单词插入),于是返回false;而在insert("app")之后,同一查询返回true。
前缀查询 StartsWith:只查路径,不问终点
func (this *Trie) StartsWith(prefix string) bool { parent := this for _, ch := range prefix { if child, ok := parent.children[ch]; ok { parent = child continue } return false } return true }StartsWith 与 Search 的代码几乎逐行相同,唯一区别是最后一行直接返回true,不再检查isWord。因为「存在以某前缀开头的单词」只要求前缀路径被覆盖过,并不要求前缀本身是完整单词。例如插入 "apple" 后,startsWith("app")返回true,尽管 "app" 并非完整单词——只要路径 "a"→"p"→"p" 存在,后续就必然挂着至少一个完整单词。
三个方法共用的游标式循环结构,恰好构成一份易读、易记的 Trie 模板骨架。
复杂度分析
设插入/查询的字符串长度为L,字典中已插入单词总数为N:
| 指标 | 复杂度 | 说明 |
|---|---|---|
| Insert 时间复杂度 | O(L) | 每个字符一次 map 查找(或插入),与 N 无关 |
| Search 时间复杂度 | O(L) | 每个字符一次 map 查找 |
| StartsWith 时间复杂度 | O(L) | 每个字符一次 map 查找 |
| 空间复杂度 | O(所有单词字符总数) | 每个字符路径对应一个节点;公共前缀仅存一份 |
与传统哈希表对比:哈希表做前缀查询需要遍历全部单词做前缀匹配(O(N·L)),而 Trie 天然支持 O(L) 的前缀检索,这是它在自动补全、拼写检查、IP 路由等场景胜出的根本原因。
测试验证:覆盖通过与否的完整路径
仓库为本题配套了单元测试 208. Implement Trie (Prefix Tree)_test.go,用go test即可运行。测试用例严格对照题目示例,并额外覆盖了两类负例:
param5 := obj.Search("banana") if param5 { t.Fatalf("Search(\"banana\") = %v, want false", param5) } param6 := obj.StartsWith("ban") if param6 { t.Fatalf("StartsWith(\"ban\") = %v, want false", param6) }Search("banana")验证「从未插入的单词」返回false(路径在首字符b处即断裂);StartsWith("ban")验证「不存在的前缀」返回false。
加上前面对 "apple" / "app" 的搜索、前缀查询与插入后再查询的组合,测试形成了「正向命中 + 路径缺失 + isWord 区分」的完整覆盖,与仓库 README 声称的 100% test coverage 目标一致。
模板级复用:同一个 Trie 撑起多道题目
题目文档 明确指出「本题的实现可以作为 Trie 的模板」,仓库源码验证了这一说法——Trie结构与Constructor208被其他题目直接复用:
- 648. Replace Words(替换单词):解法二(
replaceWords1)直接调用trie := Constructor208(),将字典中的词根批量Insert,再对句子中每个单词从短到长做trie.Search(value[:i]),找到最短词根即替换。整棵前缀树零修改直接嵌入业务逻辑; - 211. Design Add and Search Words Data Structure(添加与搜索单词):其
WordDictionary结构与本题Trie如出一辙(children map[rune]*WordDictionary+isWord bool),AddWord的插入逻辑几乎逐行复刻本题Insert,仅因支持通配符.而在Search中加入了递归回溯分支。这从侧面印证了本题实现作为「标准 Trie 模板」的辐射能力。
从源码结构看,仓库的通用数据结构库 structures(如ListNode、TreeNode、Stack、Queue等)同样遵循「单题实现 + 跨题复用」的组织哲学:先在某一道典型题中沉淀出干净的基础实现,再被其他题目引用或改造。
小结:一份值得收藏的 Trie 模板
回顾整个实现,核心只有四个要点:
- 节点自指:
children map[rune]*Trie让每个节点都能作为下一层子树的根,天然递归; - isWord 开关:区分「前缀可达」与「完整单词」,是
Search/StartsWith语义差异的全部来源; - 统一游标循环:三个方法共享「沿字符路径下沉,缺失即返回」的骨架,仅终点判断不同;
- map 存储子节点:兼顾 Unicode 支持与按需分配,比定长数组更通用。
当你在面试或工程中需要实现自动补全、词频统计、最长公共前缀、敏感词过滤或 IP 路由匹配时,这份来自 LeetCode-Go 的 Trie 实现可以直接作为起点——它已经在 208 题的测试中验证过正确性,并在 648、211 等题目中证明了自己的可扩展性。
【免费下载链接】LeetCode-Go✅ Solutions to LeetCode by Go, 100% test coverage, runtime beats 100% | LeetCode 题解项目地址: https://gitcode.com/GitHub_Trending/le/LeetCode-Go
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考