实现 Trie (前缀树) 图解题解
前缀树把共享前缀变成共享路径,查词和查前缀的区别只在终点。
字典树就像一棵单词地铁线网:从根出发,每个字母是一站,拼写相同前缀的单词共享前面同一段线路,到分叉处才各走各的。查一个词就是顺着字母一站站坐过去,看终点是否挂着「这里是个完整单词」的牌子;查前缀则只需确认路能走通,不看终点有没有牌子。
这道题到底在问什么
- 输入
- insert("cat") → search("cat")
- 输出
- true(cat 已存在)
- 输入
- search("ca") → startsWith("ca")
- 输出
- false / true(ca 不是完整词,但是前缀)
最优解:为什么这么做
一句话答案:LeetCode 208 实现 Trie(前缀树)只需给每个节点两样东西:children 映射(字母到子节点)和 isEnd 标记。insert 逐字母下行、缺边就新建、末尾打 isEnd;search 要求路走通且终点 isEnd 为真;startsWith 只看路通不通。三种操作时间都是 O(L),L 为单词长度,与已存单词数无关。
前缀树到底在解决什么问题
哈希集合能瞬间回答「这个完整单词存过没有」,却答不了「有没有单词以 ca 开头」——除非把整个词库翻一遍。前缀树(Trie)换一种存法:把每个单词拆成一条从根出发的字母路径,拥有相同前缀的单词共用同一段路。于是查前缀变成沿路径走一遍,走通即存在,耗时只和前缀长度有关,与词库大小无关。这也是它天然支持自动补全、前缀枚举的原因。
节点为什么只需要 children 和 isEnd
Trie 节点不存字母本身——字母藏在「父到子」这条边上,也就是 children 映射的键里。节点真正要记的只有两件事:往下还有哪些字母可走(children),以及有没有单词恰好在此结束(isEnd)。isEnd 是本题的灵魂:插入 cat 之后,c 到 a 这条路是通的,但词库里并没有 ca 这个词——不打标记,就区分不了「某个单词路过的中间点」和「某个单词的终点」。
insert 为什么缺边才新建节点
插入时逐字母下行:children 里已有这条边就直接复用,缺了才新建节点,走到单词末尾把该节点的 isEnd 设为 true。「有则复用、缺才新建」正是前缀共享的来源——先插 cat 再插 car,c、a 两步都在走老路,只在第三个字母处分叉新建。反过来,如果不判断存在与否、每个字母都无脑新建,会把原来挂在该位置下的子树连同 isEnd 标记整个顶掉,之前插入的单词就悄悄丢了。
search 和 startsWith 只差一个判断
两个查询共用同一段走路逻辑:从根出发逐字母找边,任何一步找不到就直接失败。差别只在终点处多问一句:search 还要求终点节点的 isEnd 为 true,startsWith 路通即可、不看 isEnd。所以插入 cat、car、card 之后,查完整单词 ca 返回 false——路能走通,但没有单词恰好叫 ca;查前缀 ca 返回 true——三个词都以它开头。把 isEnd 检查错加进 startsWith,会把合法前缀误判为不存在。
复杂度为什么只看单词长度
insert、search、startsWith 都是沿单词逐字母走一遍,时间 O(L),L 为单词长度——词库里存了多少词都不影响单次操作的步数,这是 Trie 相对线性扫描的核心优势。空间最坏 O(N·L),即 N 个单词的每个字母都新建节点,实际因公共前缀共享通常小得多。实现上,字符集只有 26 个小写字母时 children 用定长数组访问最快;字符集大或稀疏时用哈希表更省空间,两者复杂度相同。
▶ 动画逐步走查(共 30 步)——想跟着动画一帧帧对照就展开
- 3记牢三句话:insert 缺字母就补、终点打 isEnd;search 要到终点且 isEnd 为真;startsWith 只看路通不通。下面把每步沿树走给你看。
- 4insert("cat"):从根节点出发,等会儿一个字母一个字母地往下找路。
- 5第 1 个字母 'c':之前没有这条边,新建一个节点,指针走到 'c'。
- 6第 2 个字母 'a':之前没有这条边,新建一个节点,指针走到 'a'。
- 7第 3 个字母 't':之前没有这条边,新建一个节点,指针走到 't'。
- 8走到末尾字母 't',把它的 isEnd 标记设为 true(绿色),表示“cat”这个完整单词到这里结束。插入完成。
- 9insert("car"):从根节点出发,等会儿一个字母一个字母地往下找路。
- 10第 1 个字母 'c':这条边已经存在,直接复用,指针走到 'c'。
- 11第 2 个字母 'a':这条边已经存在,直接复用,指针走到 'a'。
- 12第 3 个字母 'r':之前没有这条边,新建一个节点,指针走到 'r'。
- 13走到末尾字母 'r',把它的 isEnd 标记设为 true(绿色),表示“car”这个完整单词到这里结束。插入完成。
- 14insert("card"):从根节点出发,等会儿一个字母一个字母地往下找路。
- 15第 1 个字母 'c':这条边已经存在,直接复用,指针走到 'c'。
- 16第 2 个字母 'a':这条边已经存在,直接复用,指针走到 'a'。
- 17第 3 个字母 'r':这条边已经存在,直接复用,指针走到 'r'。
- 18第 4 个字母 'd':之前没有这条边,新建一个节点,指针走到 'd'。
- 19走到末尾字母 'd',把它的 isEnd 标记设为 true(绿色),表示“card”这个完整单词到这里结束。插入完成。
- 20search("car"):从根节点出发,等会儿一个字母一个字母地往下找路。
- 21第 1 个字母 'c':从当前节点的孩子里能找到 'c',指针顺着走下去。
- 22第 2 个字母 'a':从当前节点的孩子里能找到 'a',指针顺着走下去。
- 23第 3 个字母 'r':从当前节点的孩子里能找到 'r',指针顺着走下去。
- 24路径走到末尾节点 'r',它的 isEnd 是 true,说明“car”确实作为完整单词存过,返回 true。
- 25search("ca"):还是从根出发,逐字母往下走。
- 26第 1 个字母 'c':孩子里有这条边,指针走到 'c'。
- 27第 2 个字母 'a':孩子里有这条边,指针走到 'a'。
- 28两个字母都走通了,但停在 'a' 节点——它的 isEnd 是 false(没有单词正好叫 “ca”),所以 search 返回 false。
- 29startsWith("ca"):同样从根出发,只要每个字母都能走通就行。
- 30第 1 个字母 'c':能走通,指针走到 'c'。
- 31第 2 个字母 'a':能走通,指针走到 'a'。
- 32“ca” 的两个字母都走通了。startsWith 不看 isEnd,路通就算数,返回 true——cat/car/card 都以 ca 开头。
⚠️ 容易写错的地方
✗ 错:search 只检查“能走到终点”,不看 isEnd
✓ 对:终点存在 且 isEnd 为 true 才算找到
否则 search("ca") 会把仅作为前缀的 “ca” 误判成已存的完整单词
✗ 错:insert 时遇到已存在的边又新建一个节点
✓ 对:先判断 children 里有没有,缺了才 new
重复新建会丢掉之前挂在该节点下的子树和 isEnd 标记
✗ 错:startsWith 也去判断 isEnd
✓ 对:startsWith 只看路是否走通,不看 isEnd
前缀本来就不要求是完整单词,查 isEnd 会把合法前缀误判为不存在
完整代码(Python / C++ / Java)
Python
class Trie:
def __init__(self):
self.children = {} # 字母 -> 子节点
self.isEnd = False # 是否有单词在此结束
def insert(self, word):
node = self
for ch in word:
if ch not in node.children:
node.children[ch] = Trie() # 缺就新建
node = node.children[ch]
node.isEnd = True # 终点打标记
def _walk(self, s):
node = self
for ch in s:
if ch not in node.children: return None
node = node.children[ch]
return node
def search(self, word):
node = self._walk(word)
return node is not None and node.isEnd
def startsWith(self, prefix):
return self._walk(prefix) is not NoneC++
class Trie {
Trie* children[26] = {};
bool isEnd = false;
Trie* walk(const string& s){
Trie* node = this;
for (char c : s){
int i = c - 'a';
if (!node->children[i]) return nullptr;
node = node->children[i];
}
return node;
}
public:
void insert(string w){
Trie* node = this;
for (char c : w){
int i = c - 'a';
if (!node->children[i]) node->children[i] = new Trie();
node = node->children[i];
}
node->isEnd = true;
}
bool search(string w){ Trie* n = walk(w); return n && n->isEnd; }
bool startsWith(string p){ return walk(p) != nullptr; }
};Java
class Trie {
private Trie[] children = new Trie[26];
private boolean isEnd = false;
private Trie walk(String s){
Trie node = this;
for (char c : s.toCharArray()){
int i = c - 'a';
if (node.children[i] == null) return null;
node = node.children[i];
}
return node;
}
public void insert(String w){
Trie node = this;
for (char c : w.toCharArray()){
int i = c - 'a';
if (node.children[i] == null) node.children[i] = new Trie();
node = node.children[i];
}
node.isEnd = true;
}
public boolean search(String w){ Trie n = walk(w); return n != null && n.isEnd; }
public boolean startsWith(String p){ return walk(p) != null; }复杂度
时间
O(L)
每个操作沿单词长度 L 逐字母走一遍,与已存单词数量无关
空间
O(N·L)
最坏情况每个单词各字母都新建节点;公共前缀共享后通常远小于此
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 实现 Trie (前缀树) 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
isEnd 这个标记到底解决什么问题?+
区分“一个节点只是某单词的中间字母”还是“某单词正好在此结束”。没有它,cat 存在时 search("ca") 会被误判为也存在。
children 用数组 [26] 还是哈希表更好?+
只含小写字母时数组 [26] 访问最快、实现最简;字符集大或稀疏时用哈希表更省空间。两者时间复杂度同为 O(L)。
为什么 Trie 查前缀比把单词丢进哈希集合强?+
哈希集合只能查“完整单词是否存在”,无法高效回答“有没有以某前缀开头的单词”;Trie 沿前缀走一遍就知道,还天然支持前缀枚举、自动补全。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 实现 Trie (前缀树) 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。