ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

LeetCode 208:手写Trie前缀树,搞定搜索联想与自动补全核心逻辑

LeetCode 208:手写Trie前缀树,搞定搜索联想与自动补全核心逻辑 面试场上被问到“你项目里的搜索联想是怎么做的”很多人的第一反应是拉倒排索引。其实在数据规模不大、只想快速支持前缀匹配的场景里一棵前缀树Trie往往更直接。LeetCode 208这道题——实现Trie前缀树常年霸占热门100题榜单几乎所有刷题路线图都会把字符串专题的起点放在这里。它代码不长却把数据结构的几个关键设计一次性全考到节点怎么定义、孩子的映射用数组还是哈希、词尾怎么标记、search和startsWith到底哪里不同。写这篇文章时我一边复盘自己当年提交时翻过的车一边把原理、模板、边界测试都整理清楚。适合刚开始刷字符串题的新手也适合要把这题当模板往211、212进阶的读者。1. 面对前缀树题目先想清楚它在解决什么问题1.1 为什么普通集合做不到高效前缀匹配如果不考虑前缀树只要求“存单词”和“查单词”一个哈希集合就够了set()存下所有字符串插入和精确查找的时间都是 O(len(word))简单、直接、不容易出错。可一旦需求变成“查所有以某个前缀开头的单词”哈希集合的短板立刻暴露出来——你只能把词典里每一个单词都拿出来逐个调用word.startswith(prefix)整个查询耗时和词典规模成正比。打个比方哈希集合像一箱散装卡片每张卡片写着一个单词你要找所有以app开头的卡片只能一张张翻。而前缀树像一本按字母顺序排好的电话簿你把app三个字母按顺序走下来自然就到了所有app开头的单词所在的那一叠根本不需要碰其他卡片。Trie 的核心思想就是按字符拆分单词让拥有公共前缀的单词共享路径。apple和app会共享a - p - p这一段只有到第四个字符才分叉。application和apple同样先共享a - p - p - l到i和e才分开。整个结构相当于是把字符串集合的前缀信息压缩进了一棵多叉树里。这种结构带来的直接收益是查询一个前缀是否存在的复杂度只取决于前缀长度而不是词典里有多少个单词。对于一个 10 万词的词典哈希集合做一次前缀扫描最坏要比较 10 万个单词Trie 只需要走 prefix 长度那么多步。1.2 LeetCode 208 真正想考察的设计点LeetCode 208 的要求非常明确实现一个Trie类支持insert(word)、search(word)、startsWith(prefix)三个操作。因为题目给的是英文小写字母所以它本质上是在考察三件事节点对象怎么定义。每个节点需要保存什么信息才能让整棵树跑起来子节点集合怎么组织。定长数组、哈希表还是别的映射方式不同选择的成本在哪里词尾怎么标记。search(app)和startsWith(app)在 Trie 上走的路一模一样为什么返回的结果不一样答案就差在一个小小的标记位上。这题在热门 100 题里的地位很特殊它本身不涉及复杂的递归、贪心、动态规划但它是一个“地基题”。你会发现后续一堆高频题比如 211. 添加与搜索单词、212. 单词搜索 II、648. 单词替换都是在 Trie 的基础上叠加新的逻辑。基础结构没写对后面的题目寸步难行。还有一点值得注意面试官往往不满足于只让你把这版代码写对还会追加“如何支持删除”“如何统计词频”“如何返回联想词”这类扩展问题。如果一开始就对节点的设计理得够清楚这些追问都能顺手接住。2. 数据结构设计孩子节点与词尾标记2.1 孩子集合用定长数组还是哈希表先把最常见的两种节点结构摆出来大家感受一下区别。定长数组版适合字符集固定且已知class Trie: def __init__(self): self.children [None] * 26 # 每个位置对应一个字母 a-z self.is_end False哈希表版适合字符范围大或不确定class TrieNode: def __init__(self): self.children {} # 字符 - TrieNode self.is_end False这两个版本没有绝对的好坏完全看场景。定长数组的思路是已知只有 26 个小写字母那就给每个节点开一个长度为 26 的数组索引 0 对应a索引 25 对应z。要判断当前节点有没有孩子c只需要计算ord(c) - ord(a)得到下标然后看children[index]是不是None。访问速度是严格的 O(1)不会出现哈希冲突也不会因为字符串拼接产生额外的存储开销。代价是空间。哪怕当前节点只有一个孩子也要付出 26 个指针的空间。如果字符集扩大到大写字母、数字、中文甚至 URL 中的/、.、-定长数组就得跟着扩大扩大到几千上万个字符后绝大多数节点只用到其中几个位置内存浪费非常严重。哈希表的做法则是“用多少开多少”。children字典里只有实际存在的字符才有键字符集无论多大都能支持写起来也更通用。缺点是每个节点多了一个字典对象每次访问孩子都比数组下标慢一点而且哈希表本身也有额外的内存开销。所以当题目明确限制为 26 个小写字母时数组版本是绝对的首选代码清晰、查询快、面试官也爱看。在 Java/C 这类语言里数组版还能进一步用Trie[] children new Trie[26]来表示配合null判断语义非常直观。Python 里用[None] * 26也差不多。2.2 is_end 标记为什么省不得这是最容易被新手忽略的设计点Trie 的节点要区分“这中间路过的节点”和“这是一个完整单词的终点”。举个例子插入apple之后整棵树里肯定存在a - p - p - l - e这条路径。如果这时调用search(app)按字符串走也能走到一个节点总不能直接返回true吧app并不是我们插入过的单词只是apple的公共前缀。is_end标记就是为了解决这个问题。在插入流程的最后一步把结束节点的is_end设为True表示“这个节点对应的是一个完整单词的结尾”。如果后来又把app也插进去那么在第二次插入时走到原来apple路径的第三个节点把这个节点的is_end也设为True即可。这个设计很像书目录里的页码和正文的关系。app出现在目录索引里说明它被单独收录了app只是apple的中间几个字母说明它只是正文里的一部分不能当成独立词条。is_end就是维护这个“是否独立成词”信息的开关。另外如果你以后想支持“统计每个单词出现了多少次”可以把is_end从布尔值升级成整数count插入时每次都count 1查询时返回对应节点的count。原理完全一样只是把开关变成了计数器。3. 三个核心方法逐一落地3.1 insert沿着字符路径一路走到尾插入单词的流程只有三步从根节点出发逐个字符往下走遇到不存在的孩子节点就新建走完整个单词后打上is_end标记。class Trie: def __init__(self): self.children [None] * 26 self.is_end False def insert(self, word: str) - None: node self for ch in word: idx ord(ch) - ord(a) if node.children[idx] is None: node.children[idx] Trie() node node.children[idx] node.is_end True这里要特别留意node node.children[idx]这一步。很多第一次写的人容易在if里新建完节点后忘记把当前指针移过去导致后续字符全部挂在根节点上整棵树变成一个畸形的“星形结构”。实际上 Trie 的遍历逻辑和链表非常像链表通过node node.next前进Trie 通过node node.children[idx]前进只是在每个节点上按字符选择走哪条分支而已。重复插入同一个单词也没问题。第二次插入时路径上的节点都已存在循环直接一路走到底最后再设置一次is_end True结果幂等。插入一个新词也只需要创建它独有的那部分路径对已有公共前缀零影响。3.2 search、startsWith共同的查找逻辑与一处关键差异search和startsWith的前半段动作完全一样从根节点出发沿着单词字符往下走如果中间任何一个孩子节点不存在直接返回失败如果整个串都走完了说明前缀路径一定存在于树中。两者的差异只在最后一步search(word)还要确认当前位置是一个单词的结尾即node.is_end True。startsWith(prefix)不关心词尾只要能走完前缀就算成功。为了让逻辑不重复我把“按照字符串走到对应节点”的公共部分抽成一个辅助函数_find_node。这样搜索和前缀查询都能复用而且后续如果要加delete操作也可以直接调用这个 helper 定位目标节点。def _find_node(self, prefix: str): node self for ch in prefix: idx ord(ch) - ord(a) if node.children[idx] is None: return None node node.children[idx] return node def search(self, word: str) - bool: node self._find_node(word) return node is not None and node.is_end def startsWith(self, prefix: str) - bool: return self._find_node(prefix) is not Nonesearch和startsWith看似只差一个and node.is_end但漏掉这个判断是这题最常见的提交错误之一。后面我在常见问题里会专门展开。这里还有一个“为什么_find_node返回None就万事大吉”的细节当某个字符不存在时说明字典里根本没有任何以该字符串为开头的单词后续也不用继续查了。返回None给上层search和startsWith自然得到False。3.3 完整代码与本地测试把上面几段拼起来就是一份可以直接运行的 LeetCode 208 代码class Trie: def __init__(self): self.children [None] * 26 self.is_end False def insert(self, word: str) - None: node self for ch in word: idx ord(ch) - ord(a) if node.children[idx] is None: node.children[idx] Trie() node node.children[idx] node.is_end True def search(self, word: str) - bool: node self._find_node(word) return node is not None and node.is_end def startsWith(self, prefix: str) - bool: return self._find_node(prefix) is not None def _find_node(self, prefix: str): node self for ch in prefix: idx ord(ch) - ord(a) if node.children[idx] is None: return None node node.children[idx] return node本地验证的时候别只跑题目给的简单样例。我的习惯是自己手写一组覆盖典型边界的用例obj Trie() obj.insert(apple) assert obj.search(apple) is True # 完整词 assert obj.search(app) is False # 是前缀不是完整词 assert obj.startsWith(app) is True # 前缀查询成功 obj.insert(app) assert obj.search(app) is True # 插入后变成完整词 assert obj.search(apple) is True # 已有词不受影响 assert obj.startsWith(appl) is True # 中间前缀 assert obj.startsWith(b) is False # 不存在的分支这组细碎用例跑通了提交基本不会出问题。我当年刷这道题时先自己写了个 26 字母数组版然后故意写了几个错误版本来比较结果比如忘记is_end的版本、search直接复用startsWith的版本。通过这种“故意写错再对照”的训练对 Trie 的语义理解会深刻很多。4. 复杂度推导与工程里的应用真相4.1 时间、空间复杂度到底牛在哪里Trie 的时间复杂度非常简洁insertO(len(word))需要遍历单词的每个字符。searchO(len(word))同样只遍历一次。startsWithO(len(prefix))只看前缀长度。不论词典里有 100 个单词还是 1 亿个单词这些操作都只需要从根节点走一条路径跟总单词量无关。这是它对比哈希集合在“前缀查询”场景下的核心优势。空间上Trie 的最差情况是不存在任何公共前缀每个单词的每个字符都新建一个节点这时节点总数等于所有单词长度之和。但实际应用中因为大量单词共享前缀节点数会远小于这个上界。举例来说插入apple、app、application这三个词独立存储需要 18 个字符Trie 只需要a p p l ei c a t i o n这条路径上的节点总共 13 个节点中间还复用了 5 个节点。不过数组版 Trie 有一个不能忽略的工程问题每个节点固定存 26 个孩子指针无论它实际有几个孩子。一个字符串很长但每个节点只有一个孩子时内存开销就是“字符串长度 × 26 个指针”。面对海量数据压缩 Trie、Radix Tree 或者用哈希表省空间的方案会更实用。算法题里通常不会纠结这个但真实系统设计时一定要算清楚。4.2 Trie 在真实系统里不只是“词典”这题虽然以 LeetCode 题目的形式存在但 Trie 在工程中的出镜率相当高。最典型的就是输入框自动补全。搜索框、IDE 的代码提示、命令行的 tab 补全本质上都是“输入前缀返回候选词”。典型实现会在 Trie 节点上额外存储“以当前节点为前缀的 Top K 热词列表”用户每敲入一个字符就沿着 Trie 走一步然后直接取出候选列表非常快。拼写纠错也是经典场景。当用户输入的单词在字典里精确匹配失败时可以用 BFS 或动态规划在 Trie 上计算编辑距离找到相近的正确单词。比遍历全量词典再算编辑距离高效得多。网络领域里的路由表最长前缀匹配从思想上看也类似 Trie 的变体路由表把 IP 地址按二进制位展开成一棵二叉前缀树查找时按最长匹配规则选择出口。它和图论中的二叉 Trie 几乎是一回事。还有一类高频工程场景是敏感词过滤和词库匹配。经典的 AC 自动机Aho-Corasick本质上就是 Trie 加失败指针先在 Trie 中构建所有敏感词再给每个节点补充 fail 指针使得扫描文本时能在一次遍历内匹配出所有词条。这个算法广泛用于内容安全、日志关键字过滤等领域。理解 208对理解 AC 自动机是很大的助益。顺带一提把 Trie 的字符从英文换成二进制位就变成了01 字典树可以用来高效解决最大异或值匹配的问题比如 LeetCode 421 求数组中两个数的最大异或值。这样看下来Trie 确实是一块非常通用的结构底盘。5. 常见翻车点与进阶题串讲5.1 提交记录里出现最多的 5 类错误下面这几种错误我见过太多次也亲手犯过整理成一张速查表给大家参考错误类型具体表现正确处理忘记设置is_endinsert(app)后search(app)返回False插入循环结束后一定要执行node.is_end Truesearch误用startsWith逻辑只判断能不能走完路径不校验词尾search(app)误报Truesearch必须同时满足node is not None and node.is_end数组越界输入包含大写字母或其他字符时直接ord(ch) - ord(a)得负数或越界题目约束小写字母时默认安全扩展场景改用哈希表或先统一转小写节点指针没往下走插入时新建了孩子节点却忘了node node.children[idx]所有字符都挂在根节点下每次循环结尾必须更新node为当前字符对应的孩子节点把实例变量写成类变量在类属性里定义children [None] * 26导致多个 Trie 对象共享同一个孩子列表所有可变数据都在__init__中初始化第一类错误尤其隐蔽因为它的特征很明显却很难一眼看出来insert(apple)之后search(apple)正常返回True因为apple的最后一个节点恰好是循环结束时node停留的位置但由于没写is_end True这个节点始终是普通中间节点。如果只测试完整单词这个 Bug 会被掩盖直到你测试“先插入apple再查询app”这种场景问题才会暴露search(app)走到的节点明明存在但它不是词尾返回False才对——可没写is_end的代码里所有节点看起来都不是词尾于是search(apple)也会变成False。另一种让人头疼的情况是多个测试用例之间互相污染。如果你在 LeetCode 之外自己写测试几个独立的测试样例共用一个Trie对象前一个用例插入的词会影响后一个用例的查询结果。正确做法是每个测试用例都重新Trie()一次或者在类内部提供清空方法。5.2 掌握这题之后可以顺路干掉哪些题LeetCode 208 只是起点接下来的进阶路径非常清晰211. 添加与搜索单词在 Trie 的基础上支持.通配符搜索时遇到.就要遍历当前节点的所有孩子本质上是在树上做 DFS。理解了 208 的结构211 只需要一个递归函数就能搞定。212. 单词搜索 II给定一个字符网格和一组单词找出网格中能拼出的所有单词。做法是先拿所有单词建 Trie再在网格上做回溯 DFS递归过程中一旦发现当前路径不是任何 Trie 节点的前缀路径就立刻剪枝。这题如果没有 Trie暴力匹配会超时。648. 单词替换给定一个词典把句子中所有以词典词为前缀的单词替换成该前缀词。解法是先建 Trie再检查句子中的每个单词能否在 Trie 里找到第一个is_end为 true 的路径终点。745. 前缀和后缀搜索同时要求前缀和后缀匹配常见的处理方式是用两个 Trie一个正常插入单词另一个插入反转后的单词查询时把前缀和后缀分别在两棵树上匹配。这四道题只要刷完一遍你会发现它们全部绕不开一个核心能力在 Trie 上沿着字符路径移动同时根据is_end做判断。208 中的_find_nodehelper 在这些题里也会反复出现只是有的题需要把它扩展成递归版。如果你时间充裕还可以再补几道“Trie 思维迁移”题比如421. 数组中两个数的最大异或值它把字符 child 换成了二进制位 child0/1本质上还是同一套结构。1351之类的网格题就算了不是同一类路线。真正值得精力的是把 211、212、648、745 这四条分支走通比盲目刷题有用得多。最后分享一个我实际刷题时养成的小习惯写完 Trie不要只跑题里的样例至少在本地跑一遍insert(apple)、search(app)、search(apple)、startsWith(app)、insert(app)、search(app)这组用例再提交。我见过太多人把search和startsWith的差异想通了却在is_end上翻车。数据结构题拿到手后先在本地把“能走通但语义不同”的边界测一遍比直接提交等判题机反馈要省心得多。这就是我每次做字符串系列题都会保留的底线操作。
返回列表