字典树学习笔记

字典树:把"前缀"变成一条路

约定:本文用 trie 数组存节点,trie[0] 为根;字符集默认小写字母 a-z,用 ch[26];每个节点带 end 标记和 cnt 计数。

先说结论:字典树不神秘

字典树(Trie,读作"try")只是在回答一个问题——一堆字符串里,怎么快速知道"以某个前缀开头的有哪些"。

哈希表能 O(1) 判一个完整字符串在不在,但对"前缀"无能为力:给你 app,问所有以 app 开头的串,哈希表只能全表扫。Trie 的答案是:把公共前缀合并成同一条路。走到 app 这个节点,它底下的整棵子树就是答案。

一句话:Trie 是用空间换前缀查询的结构。它不存字符串本身,存的是字符串之间的共享前缀关系。

长什么样

插入 app、apple、apply、apt、bat 之后:

root
   /  \
   a   b
   |   |
   p   a
  / \  |
  p t  t
  |  
  l 
 / \
 e  y

几个要点:

  • 每条边是一个字符,从根到某节点的路径拼起来就是一个前缀。
  • 节点本身不存字符(字符在边上),节点存的是"有没有字符串在这里结束"和"子节点指针"。
  • app、apple、apply 共享 app 这条前缀路径,只在分叉处分开。
  • 一个字符串的结尾必须打标记,否则分不清 app 是"存在"还是"只是 apple 的前缀"。

节点怎么定义

struct Node {
    int ch[26]; // 子节点下标,-1 表示没有;字符集大就用 map/unordered_map
    bool end; // 是否有字符串在此结束
    int cnt; // 经过此节点的字符串数(可选,用于统计前缀出现次数)
};
vector<Node> trie(1); // trie[0] 是根,初始化为空

用数组存节点、用下标当指针,是竞赛里最常见的写法——比 new 快、比指针省心、还能整体清空。注意 Node 默认构造要保证 ch 全为 -1、end=false、cnt=0,否则新节点是脏的。

插入

从根出发,沿字符走;没有路就开新节点;走完在终点打 end 标记。

void insert(const string& s) {
	int p = 0; // 当前节点,从根开始
	for (char c : s) {
		int x = c - 'a';
		if (trie[p].ch[x] == -1) { // 没有这条路
			trie[p].ch[x] = trie.size();
			trie.emplace_back(); // 开新节点
		}
		p = trie[p].ch[x];
		trie[p].cnt++; // 经过次数 +1(统计前缀用)
	}
	trie[p].end = true; // 标记字符串结束
}

复杂度 O(|s|),跟已经插了多少字符串无关——这是 Trie 最爽的地方。

坑:trie.emplace_back() 之后 trie 可能扩容,之前拿到的引用/指针会失效。所以上面全程用下标 p 而不是 Node*,这是好习惯。

查询:完整字符串在不在

沿字符走,走不通就说明不存在;走通了还要看终点有没有 end 标记。

bool search(const string& s) {
	int p = 0;
	for (char c : s) {
		int x = c - 'a';
		if (trie[p].ch[x] == -1) return false; // 路断了
		p = trie[p].ch[x];
	}
	return trie[p].end; // 走完了,但可能只是别人的前缀
}

注意 end 这一下:如果只插了 apple,查 app 会走通整条路,但 app 节点 end == false,所以返回 false。这正是 end 标记存在的意义。

查询:前缀在不在

跟上面几乎一样,只是不检查 end:

bool startsWith(const string& prefix) {
	int p = 0;
	for (char c : prefix) {
		int x = c - 'a';
		if (trie[p].ch[x] == -1) return false;
		p = trie[p].ch[x];
	}
	return true; // 只要走得通,就存在这个前缀
}

这就是 Trie 相对哈希表的核心优势:前缀查询是 O(|prefix|),不用扫全表。哈希表要回答同样的问题,得把所有字符串都过一遍。

三种常见变体

1. 统计前缀出现次数

插入时每个节点 cnt++,查询前缀时走到终点返回 cnt 即可。cnt 就是"有多少个字符串以这个前缀开头"。

int countPrefix(const string& prefix) {
	int p = 0;
	for (char c : prefix) {
		int x = c - 'a';
		if (trie[p].ch[x] == -1) return 0;
		p = trie[p].ch[x];
	}
	return trie[p].cnt;
}

注意:cnt 统计的是"经过该节点的字符串数",如果允许重复插入同一个串,cnt 会重复计数;要统计"不同字符串数"得在 end 处判断是否已存在,或者插入前先 search 一下。

2. 01-Trie(处理异或)

把整数按二进制从高位到低位插入,每条边是 0 或 1。经典用途:最大异或对——给一堆数,求两个数异或的最大值。

思路:对每个数,从高位到低位贪心,尽量走"相反位"的边(0 走 1、1 走 0),走不通才走同向。因为高位权重大,贪心正确。

void insert01(int x, int B = 30) { // B 是最高位
	int p = 0;
	for (int b = B; b >= 0; b--) {
		int bit = (x >> b) & 1;
		if (trie[p].ch[bit] == -1) {
			trie[p].ch[bit] = trie.size();
			trie.emplace_back();
		}
		p = trie[p].ch[bit];
	}
	trie[p].end = true;
	}
int maxXor(int x, int B = 30) { // 返回与 x 异或最大的已插入数的异或值
	int p = 0, ans = 0;
	for (int b = B; b >= 0; b--) {
		int bit = (x >> b) & 1;
		int want = bit ^ 1; // 想要相反位
		if (trie[p].ch[want] != -1) {
			ans |= (1 << b); // 这一位能异或出 1
			p = trie[p].ch[want];
		} else {
			p = trie[p].ch[bit]; // 只能同向
		}
	}
	return ans;
}

01-Trie 还能做可持久化版本,支持区间最大异或(主席树那套思路搬到 Trie 上),是进阶内容。

3. 字典树 + 失败指针 = AC 自动机

Trie 的短板是:多模式匹配时,文本扫到一个位置,只能沿当前路径走,走不通就凉。AC 自动机在 Trie 的每个节点上加一个失败指针(类似 KMP 的 pi),失配时跳到"最长真后缀对应的节点"接着匹配。这是 Trie 最重要的扩展,下一篇可以单独讲。

复杂度与空间

  • 插入/查询:O(L),L 是字符串长度,与集合大小无关。
  • 空间:O(总字符数 × 字符集大小) 最坏。26 个字母、10^5 个长度 10 的串,最坏约 2.6×10^6 个 int,还能接受;但字符集大(比如 Unicode)或串很长时,int ch[26] 这种定长数组会爆。
  • 省空间的办法:map<char,int> / unordered_map<char,int> 存子节点,或者压缩字典树(把只有一个儿子的链压成一条边,存整个子串),后者就是 Radix Tree / 前缀树压缩。

容易踩的坑

  1. end 标记和"前缀"混淆。 查完整串必须看 end,查前缀不能看。这是最高频的错。

  2. 节点引用失效。 用 vector<Node> 时,emplace_back 可能触发扩容,之前保存的 Node* 或 Node& 全废。全程用下标。

  3. 字符集大小别写死。 题目给的是小写字母就用 26;给的是 ASCII 就用 128;给的是任意字符就上 map。写死 26 却遇到大写字母,直接越界。

  4. 多组数据要清空。 全局 vector 不清,上一组的节点会污染下一组。最省事的办法是每组重新 trie.assign(1, Node()),或者记录用过的节点下标逐个清。

  5. 空间预估。 定长 ch[26] 的节点很大(约 100+ 字节),节点数 = 总字符数 + 1。10^6 个字符就要 ~100MB,容易 MLE。字符集大或串多时优先考虑 map 或压缩。

  6. 01-Trie 的位数。 值域到 10^9 用 30 位(2^30 > 10^9),到 10^18 用 60 位。位数选少了高位丢失,选多了浪费空间。

  7. cnt 的语义。 是"经过次数"还是"以该节点结尾的字符串数",取决于你怎么加。统计前缀出现次数用"经过次数";统计某前缀作为完整串出现几次,得在 end 处单独计数。

The End

Trie 的美在于它把"字符串集合"变成了一棵按前缀组织的树,于是"前缀相关"的查询从"扫全表"变成"走一条路"。骨架就三件事:

  1. 节点:子节点指针 + end 标记(+ 可选的 cnt)。
  2. 插入/查询:沿字符走,没有就开/断,走完看 end。
  3. 变体:cnt 统计前缀、01-Trie 处理异或、加失败指针变 AC 自动机、压缩变 Radix Tree。

建议手画一遍 app / apple / apply / apt / bat 的树,再对着代码走一遍插入和查询,对"共享前缀 = 共享路径"就有直觉了。然后做两道题(LeetCode 208 实现 Trie、LeetCode 421 最大异或对),基本就掌握。

posted @ 2026-10-20 17:55  华沙白鹰  阅读(9)  评论(0)    收藏  举报