第一次在力扣上刷到"实现 Trie(前缀树)"的时候,我不太服气——不就是一棵树吗?HashMap 也能存字符串,何必折腾。直到后来负责公司评论过滤模块,几十万条敏感词用字符串匹配硬顶,生产环境 CPU 被干到报警,我才彻底想明白一件事:前缀树的价值,不在于它能存什么,而在于它怎么存。力扣热题 100 把这道题排在第 49 位,原题号是 208,要求实现一个 Trie 类,支持 insert、search 和 startsWith 三个方法。看起来简单,却是后面一整套 Trie 类高频题的地基:带通配符的搜索、单词搜索 II、异或最大值、单词替换,全都从这里长出来。这篇文章,我想把一个刷题人从"看懂题解"到"真正会写"的过程完整记录下来。
1. 为什么刷过几百道题,我仍然把这道题当重点
1.1 一次被"自动补全"问倒的面试经历
我记得很清楚,有一场面试,面试官让我现场写一个自动补全的雏形。我理所当然地打开编辑器,打算用一个 HashMap 把所有历史搜索词存起来,然后写个方法遍历 key,凡是 key.startsWith(prefix) 就返回。写了两行,面试官就问了一句:"如果用户输入一个字母,你就全表扫一遍,qps 一上来,扛得住吗?"
那一瞬间我意识到,我从来没有认真想过前缀匹配的性能问题。HashMap 最擅长回答的是"这个完整字符串有没有出现过",但它完全没有办法回答"哪些字符串以某个前缀开头"。你可以把 scan 当成方案,但在真实场景里,那是最笨的一种方案。那次之后,我把 Trie 从头到尾研究了一遍,也终于理解了为什么热题 100 会专门给它留一个位置。
1.2 前缀树到底解决了什么问题,和哈希表比强在哪
Trie 的英文全称是 retrieval tree,意译过来就有"检索"的意思。它的核心思路很简单:把单词拆成一个个字符,逐层存到树上。所有单词共享公共前缀,前缀只存一次。比如插入 apple 和 apply,前面四个字符 a-p-p-l 走的是同一段路径,到最后一个字符才分叉,一个走向 e,一个走向 y。
拿哈希表一对比就非常直观了。HashMap 存的是"完整单词",Trie 存的是"前缀路径"。所以你要查一个完整字符串,两者都能回答;但你要查一个前缀,HashMap 只能把所有 key 全拉出来遍历,Trie 却可以顺着路径一路往下,把这个前缀下面所有候选词全部收集出来。
有人会说:那我建一个 HashSet<String>,把所有单词的每个前缀都存进去,startsWith 不也能 O(1) 吗?理论上可以,但代价是存储爆炸。一个长度为 L 的单词,会产生 L 个前缀副本。100 万个平均长度 10 的单词,就要多存 1000 万个字符串对象。而 Trie 里,公共前缀对应的节点在整棵树中只维护一份。这就是所谓的"空间换时间",但换的其实是时间,省的也是空间——关键就在于对公共前缀的复用。
1.3 它在热题100里的位置,决定了刷题顺序
热题 100 我刷过好几遍,观察下来有个规律:前 35 题基本是数组、链表、哈希这些基础数据结构轮换着考,到 40 到 55 这个区间,就开始出现树、堆、Trie 这类"结构 + 算法"结合得比较紧的题目。208 放在第 49 位,不是因为它最难,而是因为它最基础。后面的 211(添加与搜索单词)、212(单词搜索 II)、648(单词替换)全都要用 Trie,你不把这一题吃透,后面连题解都看不懂。
我自己的建议是,不要一上来就贪多,先把这一题练到肌肉记忆。等你可以 10 分钟之内盲写出一个不报错的 Trie 时,再往前走,你会发现后面那些题的核心障碍根本不是 Trie,而是递归、DFS、剪枝这些跟 Trie 组装起来的上层逻辑。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 手写 Trie 的第一步:节点结构怎么设计
2.1 节点里存"字符"还是不存?先想清楚这个问题
这是很多初学者栽的第一个跟头。设计 TrieNode 的时候,会天然觉得"每个节点代表一个字符,那我得有个 char val 字段吧"。
实际上完全不需要。父节点通过数组下标或者哈希表的 key 就已经知道子节点是谁了。比如 children[0] 约定映射到 'a',那么这个槽位下的子节点天然就代表字符 'a',根本不需要再存一份。如果非要在节点里加一个 char val,一开始没事,等到做删除、做打印、做节点复用的时候,就会面临"实际路径"和"节点保存字符"不一致的隐患,纯粹给自己找麻烦。
可以这么理解:Trie 的每个节点是一排抽屉,抽屉的编号就是字符。你只需要关心抽屉里放的是什么东西,抽屉本身不需要贴名字,因为编号已经被约定好了。代码里需要记录当前路径是什么字符时,用一个 StringBuilder 在外部维护就行,节点自身保持精简。
2.2 数组 vs 哈希表:两种 children 写法的取舍
children 的存储我见过两种主流写法,力扣场景我更推荐数组,但哈希表的适用面更广。
| 维度 | 数组 Trie[26] |
哈希表 Map<Character, Trie> |
|---|---|---|
| 适用字符集 | 固定且小的字符集(如小写字母) | 任意字符集,包括中文、Unicode |
| 访问速度 | O(1),没有哈希开销 | O(1) 均摊,但有哈希计算成本 |
| 内存占用 | 每个节点固定 26 个槽位,大量 null 也会占引用空间 | 按需创建子节点,空间紧凑 |
| 遍历子节点 | 用 for 循环扫 0 到 25 | 用 values() 或 entrySet() |
| 力扣 208 | 推荐 | 也可以 |
数组写法的关键是字符到下标的映射:int idx = c - 'a'。这个减 'a' 的操作很多人一开始记不住,但它就是整个数组写法的灵魂。哈希表写法则完全没有这个困扰,直接用字符本身当 key,所以如果你用 Python 刷题,我默认你会选择字典版本,那真的很直观。
实际项目中怎么选?如果只能处理小写英文字母,数组简单粗暴;如果做国际化产品,要支持中文输入法搜索,那就必须用哈希表,因为中文字符数量是几千个,你不可能每个节点都开一个几千长度的数组。但核心思想完全一样,理解了数组版,哈希表版五分钟就能改出来。
2.3 isEnd 为什么要单独拎出来占据一个布尔值
isEnd 是 Trie 里最容易被忽视、却最要命的一个字段。它的作用是标记"从根节点到当前节点的这段路径,是不是一个被完整插入过的单词结尾"。
举个例子。插入 apple 之后,路径 a-p-p-l-e 上的所有节点都存在了。此时如果调用 search("app"),你会发现路径 a-p-p 也能走到,那它到底算不算一个单词?答案是不算,因为 app 没有被完整插入过。如果不区分这一点,那 search 和 startsWith 就完全一样了,这显然不对。isEnd 就是用来打这个标记的:走到第二个 p 节点时,它的 isEnd 是 false,说明这里只是路过;走到 e 节点时,isEnd 是 true,说明 apple 这个单词到这里完整结束了。
有些等长字符串场景会用结束符 '$' 来代替 isEnd 字段,本质是同一个思路。力扣的写法里,isEnd 更干净,用 boolean 就够。这个字段也是 3.2 节里 search 和 startsWith 出现行为差异的根本原因。
3. insert / search / startsWith 三个核心方法拆解
3.1 insert:指针跟着字符走,最后一步别漏
上代码,Java 数组版:
java复制class Trie {
private Trie[] children;
private boolean isEnd;
public Trie() {
children = new Trie[26];
isEnd = false;
}
public void insert(String word) {
Trie node = this;
for (int i = 0; i < word.length(); i++) {
int idx = word.charAt(i) - 'a';
if (node.children[idx] == null) {
node.children[idx] = new Trie();
}
node = node.children[idx];
}
node.isEnd = true;
}
}
插入的核心逻辑就是:从根节点出发,每个字符算出对应的下标,如果这个槽位还没有节点,就 new 一个;然后把指针移动到子节点,继续处理下一个字符。循环结束后,把当前节点的 isEnd 置为 true。
注意最后一行的 node.isEnd = true 千万不能漏。很多人就是写完循环手一滑,忘了这一行,后面 search 就会全部返回 false,而且代码不报错、不崩溃,排查起来特别痛苦。我见过不下五个初学者因为这个问题问我"为什么我 insert 了却 search 不到",回答都是同一个原因。
另外,插入过程完全不需要递归。路径是单向向下的,用循环足够。递归不仅不会让代码更优雅,反而会增加出错面。
3.2 search:找到路径还不够,还要确认"这是个完整单词"
再看 search 和它复用的公共方法:
java复制 public boolean search(String word) {
Trie node = searchPrefix(word);
return node != null && node.isEnd;
}
public boolean startsWith(String prefix) {
return searchPrefix(prefix) != null;
}
private Trie searchPrefix(String prefix) {
Trie node = this;
for (int i = 0; i < prefix.length(); i++) {
int idx = prefix.charAt(i) - 'a';
if (node.children[idx] == null) {
return null;
}
node = node.children[idx];
}
return node;
}
我把"沿着前缀往下走"的逻辑抽成了 searchPrefix,这样 search 和 startsWith 两个方法共用一份代码。抽出来之后,好处是不用在两个方法里各写一遍相同的遍历逻辑,改了这边忘了那边的问题也自然消失。
search 的判定条件是 node != null && node.isEnd。意思是:前缀路径存在,而且路径末端确实是一个完整单词的结尾。如果你只判断 node != null,那 search("app") 会错误返回 `
