CSP-S 提高级课程中心 / 数据结构·线性与特殊树 / 第17讲
CSP-S 提高级 · 数据结构·线性与特殊树 · s3-08

字典树(Trie):让字符串查找变成一次 走楼梯

把共同前缀合并成一条公共路径,插入和查询都只需 O(L)——与字典里有多少单词无关。同一棵树,把字符换成二进制位,就成了解决异或问题的利器。

6 大模块 O(L) 插入 / 查询 前缀共享 空间与规模无关 2 处交互动画 12 道洛谷原题
本讲 · 第 17/52 讲字典树(Trie)
模块 01

情景导航

前面几讲我们处理的是"数字的下标"。这一讲,我们要处理"字符串"——一种天然带前缀结构的信息。

故事输入法的联想词

你正在给手机做一个中文输入法。词库里有 n 个词,用户在键盘上敲了一串英文字母 s,你要立刻回答两件事:

问题 A:s 是不是一个完整的词?(单词查找)

问题 B:词库里有多少个词以 s 为前缀?(前缀统计,用于给候选词排序)

你第一反应是用哈希表:把所有单词存进 unordered_set,问题 A 用 O(L) 解决,很快。可是问题 B 呢?你只能把 n 个单词全部过一遍,逐个判断前缀——每次查询 O(nL),用户每敲一个字母就要卡一下。

你想了想:"要不我把所有前缀都存进哈希表?"于是对每个单词,把它所有的前缀都插一遍。插入一个长度 L 的单词要做 L 次,总插入代价 O(ΣL),勉强能接受。但内存爆炸了——c、ca、cat、cate… 每个前缀都单独占一个字符串,前缀并没有被共享。

所有单词共享着大量的公共前缀,而哈希表把这份"公共"彻底浪费了。

你的同事在白板上写下几个词:cat、car、cow、dog,然后从根开始,一位一位地往下画:c 往下分叉出 a 和 o,a 再分叉出 t 和 r…

「你看,cat 和 car 共享了 ca 这一段路。找 cat 就是从根出发,沿着 c→a→t 走三级楼梯。走不动了,说明词不存在;能走完并且终点有标记,说明词存在。」

「至于前缀有多少个词?在 ca 这个路口记一个数就行——路过它一次就加一。查询 ca 就是走到 ca 这个节点,读出那个数字。」

你盯着这张图:这不就是把字符串的"前缀"从隐藏的冗余,变成了显式的树形结构吗?这棵树的每一层对应一个字符位置,每条从根出发的路径对应一个前缀,每个节点对应一个"前缀类"。

你给它起了个名字——字典树(Trie),也叫前缀树(Prefix Tree)。因为从根到某个节点走下来,恰好拼出一个前缀;树的分叉数就是字符表的大小。

💡 故事背后的算法思想

① 用"树的层"表示"字符串的位置"。字典树是一棵有根树,每条边标注一个字符。深度为 d 的节点代表一个长度为 d 的字符串。父子关系就是"前缀 + 一个字符"的关系。

② 共享前缀 = 共享路径。这是字典树最本质的贡献。所有具有相同前缀的字符串,在树中共享同一条从根出发的路径。n 个单词的字典树规模是 O(总字符数),而不是 O(n × 平均长度) 的重复存储。

③ 查询复杂度只跟串长有关。插入、查找、前缀查询都是 O(L),与字典里有多少个单词完全无关。这是它的杀手级优势:查询代价可预测、无哈希冲突、无最坏退化。

④ 节点上的计数是全部魔法。用一个 cnt[u] 记录"有多少个词以 u 对应的字符串为前缀",用 lst[u] 标记"u 处是否是一个完整单词的结尾"。有了这两个量,绝大多数 Trie 题目都能做。

⑤ 把字符表换成二进制位,就是 01-Trie。这是竞赛中最重要的延伸:每个数从高位到低位走一个 0/1 分支,于是"求最大异或对""区间异或最值""可持久化 01-Trie"全都能做。01-Trie 是很多异或问题的唯一优雅解法。

①生活类比
📚

图书馆分区 = 前缀共享

先按首字母分架,再按第二个字母分格。你找 "cat" 时只走 c 架 → ca 格 → cat 位,绝不去翻 s、t 开头的书。

→ 沿路径逐层下走
🗺️

地铁换乘图 = 树的节点

从"起点站"出发坐到某站,路线唯一确定。换乘点就是分叉节点,不同终点共享同一段轨道。

→ 边连着父与子的字符
🔖

书签 = 词尾标记

书架上 ca 这一格有书,不代表 cat 和 car 是同一本书;只有"标了完整书目"的位置才算一个词。

→ lst 结尾标记
🔢

岔路口计数器 = 前缀计数

每有一个人经过"ca 路口",计数器加一。想知道多少人去过 ca,看一眼计数器即可。

→ cnt 前缀计数
②核心定义

字典树 Trie(前缀树):一棵有根树,每条边标注一个字符。从根到某个节点的路径上所有边标签依次连接,构成该节点所代表的字符串。根节点代表空串。

字符集 Σ:所有可能出现的字符的集合。小写字母时 |Σ| = 26,数字时 = 10,全部 ASCII 时 = 128。节点最多有 |Σ| 个孩子。

节点含义:深度为 d 的节点对应的字符串,就是路径上 d 个字符依次拼接的结果。字数相同、前缀相同的字符串一定落在同一个节点上。

结尾标记 lst[u](end 标记):布尔或计数。为真表示"节点 u 对应的字符串本身是一个完整的单词"。它是区分"路径存在"和"这个词存在"的唯一依据。

前缀计数 cnt[u]:表示"有多少个单词以 u 对应的字符串为前缀"。其值等于"经过 u 的单词数",插入时每经过一个节点就 +1。

01-Trie:字符集为 Σ = {0, 1} 的字典树。把整数按二进制从高位到低位插入,用于处理最大异或、异或排名等问题。

核心性质(必记):插入 n 个总长度为 S 的字符串,字典树最多有 S+1 个节点;单次插入/查询复杂度 O(L),与 n 无关。

📌 本讲核心知识地图

板块核心内容对应大纲
结构有根树、边存字符、节点表示前缀【7】字符串·字典树
插入 insert逐字符下走,无路则新建,沿途 cnt++,末尾 lst=true【7】字符串·字典树
查询 find逐字符下走,走不动或末尾无标记则失败【7】字符串·字典树
前缀统计走到目标节点,读取 cnt【7】字符串·字典树
数组实现ch[u][Σ] 存孩子下标,全局 idx 分配新节点【7】字符串·字典树
空间优化节点数 = 总字符数 + 1,按需分配而非预开 n×L拓展
01-Trie二进制位建树,贪心取反分支求最大异或【7】字符串·字典树
可持久化01-Trie 支持历史版本,处理区间异或问题拓展
③核心口诀
🔑 结构口诀

边存字符,节点存前缀;
根是空串,一层一字符,路径即字符串。

🔑 插入口诀

从头逐字符走,有路就走、无路新建;
每过一站 cnt 加一,走到末尾 lst 打标。

🔑 查询口诀

找单词看 lst,找前缀读 cnt;
中途走不通,一律失败不用猜。

🔑 01-Trie 口诀

高位优先贪心取反——
有反分支就走反分支,没反分支只好将就。
因为高位的一个 1,胜过低位所有 1 之和。

模块 02

知识探究

从一棵树的形状出发,把插入、查询、前缀统计、数组实现逐层拆开,最后延伸到异或问题上的 01-Trie。

1边存字符的树 基石

先看一棵最典型的字典树。我们把四个单词 cat、car、cow、dog 插入同一棵树:

R c d a o o t r w g c d a o t r w o g
图 1 · 单词 cat / car / cow / dog 的字典树。粉色双圈节点表示"这里是一个完整单词的结尾"(lst = true);蓝色单圈是只作为前缀经过的节点。

请仔细观察这张图,把下面四句话刻进脑子:

🌳 结构要点
  • 根 R 是空串,不代表任何字符。
  • 深度 d 的节点 = 长度 d 的字符串。图中 a 深度为 2,代表 "ca"。
  • 边上的字符不是节点自己,而是"从父走到我"这一步的字符。
  • 同一父节点的孩子之间字符互不相同(这是树的性质,天然无冲突)。
🔑 关键观察
  • cat 与 car 共享路径 c→a,只在最后一位分叉。
  • ca 这个节点存在但不是词尾(单圈)——所以"ca"不是词。
  • 两个 o 节点完全不同:一个是 co,一个是 do。节点身份由路径决定,不由字符决定。
  • 节点总数 = 4 个单词的总字符数 + 1 = 3+3+3+3+1 = 13,恰好图中 13 个节点。

❓ 为什么这里的"两个 o"必须是两个不同节点?

因为节点代表的是前缀。"c" 后面的 "o" 代表前缀 co,"d" 后面的 "o" 代表前缀 do。它们是两个不同的字符串,自然必须是两个不同的节点。

这也解释了字典树为什么天然无哈希冲突:字符串与节点是一一对应的。路径唯一确定节点,节点唯一确定路径。

节点需要保存哪些信息?只要三个数组就够了:

数组含义典型初值
ch[u][c]节点 u 沿着字符 c 走一步到达的孩子编号;为 0 表示这条路还不存在0(全 0)
cnt[u]有多少个单词以节点 u 对应的字符串为前缀(包括 u 自己作为一个词的情况)0
lst[u]节点 u 对应的字符串本身是否是一个完整单词(词尾标记,也可用计数)0 / false
📌 陷阱预警:用 0 表示"没有孩子"意味着根节点必须是 1 号。如果根是 0 号,就无法区分"孩子是根"和"没有孩子"了。这是最常见的低级错误之一。
2插入一个单词 核心操作

插入算法是整讲的核心。一句话概括:从根开始逐字符往下走,有路就顺着走,没路就新开一个节点;每经过一个节点 cnt+1;走完整个串后把末尾节点标记为词尾。

🎬 动画:依次插入 cat → car → cow → dog
1
插入 "cat"。从根 R 出发。第 1 位 'c':ch[R]['c'] 为 0,路不存在 → 新建节点 2,ch[R]['c'] = 2,cnt[2] = 1(被走过一次)。
2
在节点 2 上,第 2 位 'a':ch[2]['a'] 为 0 → 新建节点 3,cnt[3] = 1。
3
在节点 3 上,第 3 位 't':ch[3]['t'] 为 0 → 新建节点 4,cnt[4] = 1。串结束 → lst[4] = true(粉圈标记)。当前共 4 个节点。
4
插入 "car"。第 1 位 'c':ch[R]['c'] = 2 有路!直接走,cnt[2]++ → 2。第 2 位 'a':ch[2]['a'] = 3 有路,cnt[3]++ → 2。注意:这里有路可走,绝不新建节点。
5
第 3 位 'r':ch[3]['r'] 为 0 → 新建 节点 5,cnt[5] = 1,lst[5] = true。此时节点 3 分叉出 't' 和 'r' 两个孩子,ca 这一段的共享路径正式生效。
6
插入 "cow"。第 1 位 'c' 有路(cnt[2] → 3)。第 2 位 'o':ch[2]['o'] 为 0 → 新建 节点 6。第 3 位 'w' → 新建 节点 7,lst[7] = true。与 "cat"/"car" 只共享了 'c' 一步。
7
插入 "dog"。第 1 位 'd':ch[R]['d'] 为 0 → 新建 节点 8。依次新建 节点 9('o')、节点 10('g'),lst[10] = true。
8
完成。总共用了 10 个节点(1 个根 + 9 个字符节点)。若把这些词单独存字符串,需要 12 个字符的空间;字典树靠共享 "ca" 省下了 2 个字符节点。词越长、前缀越相似,节省越明显。
9
cnt 的读法:cnt[2] = 3,说明有 3 个词以 "c" 为前缀(cat、car、cow);cnt[3] = 2,说明有 2 个词以 "ca" 为前缀;cnt[6] = 1,说明只有 1 个词以 "co" 为前缀。前缀统计就这么简单读出来了。
关键领悟:插入过程"能复用就复用,不能复用才新建"——这正是共享前缀的机制。整棵树的节点数上限 = 所有单词的总字符数 + 1。
C++插入:insert(str)
const int MAXN = 100005;   // 节点数上限 = 总字符数 + 1
int ch[MAXN][26];          // ch[u][c]:u 沿字符 c 走到的孩子(0 = 无)
int cnt[MAXN];               // cnt[u]:以 u 对应字符串为前缀的单词数
bool lst[MAXN];              // lst[u]:u 是否为一个完整单词的结尾
int idx = 1;                 // 已分配的节点数;根固定为 1 号!

void insert(const string& s) {
    int u = 1;                          // ① 从根出发
    for (char c : s) {
        int v = c - 'a';                // ② 字符映射到 0 ~ 25
        if (!ch[u][v]) {                // ③ 没有路 → 新建一个节点
            ch[u][v] = ++idx;
        }
        u = ch[u][v];                   // ④ 顺着走过去
        cnt[u]++;                       // ⑤ 每经过一个节点,前缀计数 +1
    }
    lst[u] = true;                    // ⑥ 整个串走完了,这里是词尾
}
✅ 插入五步曲
  • ① 定位:u = 1(根)
  • ② 映射:v = c - 'a'
  • ③ 开路:ch[u][v] 为空则 ++idx 新建
  • ④ 前进:u = ch[u][v]
  • ⑤ 计数:cnt[u]++
  • ⑥ 收尾:循环结束后 lst[u] = true
⚠️ 两个必踩的坑
  • 坑 1:idx 初始值。idx = 1 而不是 0,因为 0 被用作"空"了。若写 idx = 0,第一次新建就拿到 0 号,会被当成"没有孩子",树瞬间报废。
  • 坑 2:cnt 的位置。cnt[u]++ 必须写在 u 更新之后,否则计数就挂到了父亲上,读出来的前缀数全部错位一级。

🔍 复杂度分析:为什么插入是 O(L) 而不是 O(n)?

循环次数等于字符串长度 L,每次循环内部只有常数次数组访问与赋值。整个过程与字典里已有多少个单词毫无关系。

这与哈希表形成鲜明对比:哈希表查询虽然平均 O(L),但遇到冲突时要比较字符串,最坏可能退化到 O(nL)(例如恶意构造的数据)。字典树是严格 O(L),最坏也是 O(L)。

代价是空间:ch[MAXN][26] 每个节点固定占 26 个 int = 104 字节。当 MAXN = 105 时约 10 MB,勉强可接受;但若 MAXN 达 106,则需要用 nxt 邻接表 / map 等稀疏存储方式。

3查询与两个计数数组

字典树上的查询有两种,非常容易混淆,请务必分清:

查询类型问题判断依据代码动作
单词查询 find(s)"s" 本身是不是词库里的一个词?lst[u]走完 s 后看 lst[u] 是否为真
前缀查询 count(s)有多少个词以 "s" 为前缀?cnt[u]走完 s 后直接返回 cnt[u]
存在性查询 exist(s)是否有任何词以 "s" 为前缀?cnt[u] > 0能走完 s 就说明至少有一个词经过
C++单词查询与前缀计数
// ① 单词查询:s 是否是一个完整的词
bool find(const string& s) {
    int u = 1;
    for (char c : s) {
        int v = c - 'a';
        if (!ch[u][v]) return false;   // 路断了,直接失败
        u = ch[u][v];
    }
    return lst[u];                     // 走到了,还要看这里是不是词尾
}

// ② 前缀计数:统计有多少个词以 s 为前缀
int countPrefix(const string& s) {
    int u = 1;
    for (char c : s) {
        int v = c - 'a';
        if (!ch[u][v]) return 0;        // 路断了,前缀数必为 0
        u = ch[u][v];
    }
    return cnt[u];                      // 直接读出计数器
}
❌ 错误写法:把 lst 当成存在性

if (u) return true; —— 只要路走得通就返回 true。那么查询 ca 会返回 true,但 ca 根本不是词(图上节点 3 是单圈)。走了 L 步只证明"这个前缀存在",不证明"这个词存在"。

❌ 错误写法:前缀查询用 lst

统计前缀个数时去看 lst,得到的是"这个前缀本身是不是词",而不是"有多少词以它为前缀"。二者只在特定情况下才巧合相等。

前缀 "c"

沿 c 走到节点 2,返回 cnt[2] = 3。cat、car、cow 都以 c 为前缀。

前缀 "ca"

沿 c→a 走到节点 3,返回 cnt[3] = 2。cat、car 以 ca 为前缀。但 lst[3] = false,所以 "ca" 不是词。

前缀 "co"

沿 c→o 走到节点 6,返回 cnt[6] = 1。只有 cow 匹配。

词 "cat"

走 c→a→t 到节点 4,lst[4] = true → 存在。

词 "ca"

走 c→a 到节点 3,lst[3] = false → 不存在!这就是词尾标记的价值。

词 "cab"

走 c→a 后要找 'b',ch[3]['b'] = 0 → 中途断路,直接返回不存在。

4前缀统计与"路过即计数"的艺术

前面 cnt[u]++ 的写法之所以成立,是因为有一个非常重要的恒等式:

💎 恒等式

cnt[u] = 以 u 所对应的字符串为前缀的单词数 = 插入过程中"经过 u 的次数"

这个恒等式的证明很直接:每个单词从根走到自己的词尾,路径唯一;它经过节点 u 当且仅当 u 对应的字符串是它的前缀。所以"经过次数"恰好就是"前缀词数"。

推论(竞赛中经常用到):节点 u 的子树中,粉圈(词尾标记)节点的个数 = cnt[u]。因为一个词以 u 为前缀 ⟺ 它的词尾节点在 u 的子树里。

R
c:3
d:1
ca:2
co:1
do:1
cat
car
cow
dog
上面每个节点旁的数字就是 cnt 值:c 被 3 个词经过、ca 被 2 个词经过、d/co/do 各被 1 个词经过。四个叶子节点则是 lst = true 的词尾。

如果题目只需要"前缀出现次数"而不需要词尾标记,代码可以再简化——把 cnt 当作唯一的统计量:

C++极简前缀统计(只维护 cnt)
void insert(const string& s) {
    int u = 1;
    for (char c : s) {
        int v = c - 'a';
        if (!ch[u][v]) ch[u][v] = ++idx;
        u = ch[u][v];
        cnt[u]++;
    }
}

int query(const string& s) {     // 返回以 s 为前缀的词数
    int u = 1;
    for (char c : s) {
        int v = c - 'a';
        if (!ch[u][v]) return 0;
        u = ch[u][v];
    }
    return cnt[u];
}

💭 想一想:为什么单次前缀查询不需要"累加整棵子树"?

因为 cnt[u] 在插入时就已经被"提前摊算"到路径上的每一个节点了。这是一种典型的用插入时的 O(L) 额外代价,换取查询时的 O(1) 读取的设计思想。对比一下:如果不这么做,前缀查询就需要遍历 u 的整棵子树来数词尾,代价是 O(子树大小)。

这种"把信息分摊到路径上"的技巧在算法中反复出现——是不是让你想起了上一讲的线段树 pushup?本质上都是用预处理换查询效率。

5数组实现、空间估算与指针 vs 静态数组

字典树有两种常见实现方式。竞赛中首选静态数组——没有指针开销、缓存友好、常数小。

🧱 静态数组(推荐)
int ch[MAXN][26];
int cnt[MAXN];
bool lst[MAXN];
int idx = 1;
  • 优点:常数极小,new 节点就是 ++idx,一行搞定
  • 优点:在内存中连续,缓存命中率高
  • 缺点:空间固定 MAXN × |Σ| × 4 字节,字符集大时浪费
  • 适用:|Σ| ≤ 26 且总字符数 ≤ 106 的绝大多数题目
🔗 邻接表 / map(稀疏)
map<char,int> ch[MAXN];
// 或
int head[MAXN], nxt[MAXN], to[MAXN];
char cch[MAXN];
  • 优点:只为真实存在的边分配空间
  • 优点:适合 |Σ| 很大(如 ASCII 256 或任意字符)的场合
  • 缺点:常数大约 3~10 倍,map 更慢
  • 适用:字符串含大小写混合、数字、特殊符号时

空间估算(必须会算):设所有单词的总字符数为 S,则

量公式例子:S = 105、Σ = 26
节点数上限S + 1(每个字符最多新开一个节点,加根)100001 个节点
ch 数组(S+1) × |Σ| × sizeof(int)100001 × 26 × 4 ≈ 10.4 MB
cnt 数组(S+1) × 4约 0.4 MB
lst 数组(S+1) × 1约 0.1 MB
合计约 11 MB —— 在常见 256 MB 限制下非常安全
📌 如果 S = 106 呢?那就是 104 MB,已经非常危险了。此时要么改用 short/int24 压缩,要么改用邻接表。开数组前先算一遍,是提高组选手的基本素养。
C++完整模板:字典树(单词插入 / 查询 / 前缀计数)
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
int  ch[MAXN][26];
int  cnt[MAXN];        // 前缀计数
bool lst[MAXN];        // 词尾标记
int  idx = 1;          // 根 = 1 号节点

void insert(const string& s) {
    int u = 1;
    for (char c : s) {
        int v = c - 'a';
        if (!ch[u][v]) ch[u][v] = ++idx;
        u = ch[u][v];
        cnt[u]++;
    }
    lst[u] = true;
}

bool find(const string& s) {
    int u = 1;
    for (char c : s) {
        int v = c - 'a';
        if (!ch[u][v]) return false;
        u = ch[u][v];
    }
    return lst[u];
}

int countPrefix(const string& s) {
    int u = 1;
    for (char c : s) {
        int v = c - 'a';
        if (!ch[u][v]) return 0;
        u = ch[u][v];
    }
    return cnt[u];
}

int main() {
    int n; cin >> n;
    for (int i = 1; i <= n; i++) {
        string s; cin >> s;
        insert(s);
    }
    int q; cin >> q;
    while (q--) {
        string s; cin >> s;
        cout << (find(s) ? "YES" : "NO") << ' '
             << countPrefix(s) << '\n';
    }
    return 0;
}
运行结果(示例输入)
输入:
4
cat
car
cow
dog
6
cat
ca
cab
c
do
dog

输出:
YES 1
NO 2
NO 0
NO 3
NO 1
YES 1

解读:cat 是词(1 个词以此为前缀);ca 不是词但被 2 个词共享;cab 路径断掉;c 不是词但被 3 个词共享;do 不是词但有 1 个词以此为前缀;dog 是词。

601-Trie:把二进制位当成"字符" 竞赛利器

字典树最惊艳的应用,是把字符集换成 {0, 1}:把一个整数按从高位到低位的二进制位依次插入。这样,每个整数对应树中一条深度为位数(如 31 或 32)的路径。

它解决的核心问题是:给定若干个整数,怎么快速找到与 x 异或值最大的那个数?

💡 异或的贪心本质:高位优先

先回顾异或的定义:两个二进制位不同为 1,相同为 0。所以要让 x xor y 尽量大,就应该让结果的高位尽可能为 1。

关键不等式:2k > 2k−1 + 2k−2 + … + 20。也就是说,第 k 位的一个 1,比所有低位全是 1 加起来还要大。

因此可以从最高位开始贪心:如果存在某个数,它的第 k 位与 x 的第 k 位相反,那么答案的第 k 位应该取 1——不管后面怎么选,都比第 k 位取 0 更优。这是一个不会后悔的贪心。

而 01-Trie 恰好能高效回答"是否存在某个数,第 k 位是 b?"——因为所有第 k 位为 b 的数,在树中都在第 k 层的同一个子树里。走那一步就行了。

🎬 动画:01-Trie 求最大异或(以 4 位二进制简化演示)
1
建树。把集合里的数依次插入 01-Trie:从最高位开始取位,0 就走左孩子、1 就走右孩子,逐位下走,直到最低位。每个数贡献一条长度 = 位数的路径。
2
举例:集合 {3, 5, 6}(4 位二进制为 0011、0101、0110)。插入 3 时先走 0→0→1→1;插入 5 时前两步 0→1 与 3 分开,在第二层分叉;插入 6 时 0→1→1→0,与 5 共享前三位。共享前缀在 01-Trie 中同样被复用。
3
查询 x = 3(0011),求最大异或。从根、最高位开始。看 x 的第 3 位 = 0 → 我们希望对方这一位是 1(这样异或出 1)→ 检查"1 分支"是否存在。
4
"1 分支"存在(5、6 都从这里过)→ 走 1 分支,答案这一位确定为 1。这就是贪心的第一次兑现。
5
第 2 位:x 是 0 → 希望对方是 1 → 检查当前节点的"1 分支"是否存在。存在 → 走过去,答案这一位又是 1。
6
第 1 位:x 是 1 → 希望对方是 0(取反!)→ 检查"0 分支"是否存在。若存在就走 0 分支;若不存在,只能将就走 1 分支,这一位异或结果为 0。
7
第 0 位:同理,能取反就取反,不能就凑合。走完后,路径对应的那个数就是与 x 异或最大的数。
8
汇总:整个查询过程就是一次深度为位数的向下走,O(位数)。若集合中有 n 个数,暴力两两异或是 O(n2),而 01-Trie 只需 O(n × 位数)。
黄金口诀:"想要异或结果这一位是 1,就让对方这一位和我相反。有那条路就走,没有只能认命。"——每次"认命"都意味着这一位注定是 0,而贪心的正确性由"高位压倒低位"保证。
C++01-Trie:最大异或对
const int MAXN = 3200005;   // n × 位数 的节点总数
int ch[MAXN][2];
int idx = 1;
const int B = 30;           // 最高位(int 范围内 0 ~ 30 位,31 位符号位看题目)

void insert(int x) {
    int u = 1;
    for (int i = B; i >= 0; i--) {
        int b = (x >> i) & 1;      // 取第 i 位
        if (!ch[u][b]) ch[u][b] = ++idx;
        u = ch[u][b];
    }
}

int query(int x) {              // 返回 max(x xor y),y 取自树中
    int u = 1, res = 0;
    for (int i = B; i >= 0; i--) {
        int b = (x >> i) & 1;
        int want = b ^ 1;         // 贪心:想要相反的那一位
        if (ch[u][want]) {        // 有反分支 → 走它,这一位异或为 1
            res |= (1 << i);
            u = ch[u][want];
        } else {                  // 只能将就,这一位异或为 0
            u = ch[u][b];
        }
    }
    return res;
}
🔑 01-Trie 三要素
  • 从高位到低位。必须高位优先,否则贪心不成立。写反了必错。
  • 二分叉。每层只有 0 和 1 两个孩子,所以 ch[u][2]。
  • 节点数 = n × 位数。n = 105、31 位时约 3.1 × 106 个节点。
🚀 进一步的延伸
  • 可持久化 01-Trie:像主席树一样保留历史版本,用"第 r 版减第 l−1 版"求区间内最大异或。
  • 01-Trie 上二分:按 cnt 走向左/右子树,求第 k 大异或值。
  • 与线性基的区别:线性基求"选出若干数异或的最大值"(子集异或),01-Trie 求"与另一个数配对异或的最大值"。注意题目问的是哪一种。
7与哈希表、KMP、后缀数组的对比
结构擅长的问题单次复杂度缺点
哈希表整串相等判断平均 O(L),最坏 O(nL)无法处理前缀问题;有冲突风险
字典树前缀统计、前缀匹配、字典序问题严格 O(L)空间随字符集大小膨胀
KMP单模式串匹配O(n + m)只解决"一个模式串在一个文本中"
AC 自动机多模式串匹配O(总长 + 文本长)本质就是字典树 + 失配指针,是 Trie 的直接延伸
后缀数组/后缀自动机单个串的所有子串问题视操作而定难度更高,本讲不涉及
📌 一句话定位:字典树是"多个字符串的公共前缀结构"的专用武器。凡是题目里出现"前缀""词""字典""异或最大"这些关键词,第一反应就应该是 Trie。

💭 AC 自动机 = Trie + fail 指针

后面会学到的 AC 自动机,其实没有任何新数据结构——它就是把所有模式串建成一棵 Trie,然后给每个节点加一条 fail 指针,指向"我的最长真后缀所对应的节点"。匹配文本时沿着 Trie 走,走不动就沿 fail 跳。

所以把这一讲的 Trie 学扎实,后面 AC 自动机就是水到渠成。这也是为什么提高级大纲把它放在字符串模块的开头。

案例 1P2580 于是他错误的点名开始了 模板题

题意:老师手里有 n 个学生姓名,接下来点名 m 次。对每次点名,输出三种结果之一:

· 该名字不在名单中 → 输出 WRONG;
· 在名单中且第一次被点到 → 输出 OK;
· 在名单中但已经被点过 → 输出 REPEAT。

数据范围:n ≤ 104,m ≤ 105,姓名长度 ≤ 50。

思路分析:三个状态恰好对应字典树节点的三种情况。lst[u] = true 表示"是名单里的名字";再用一个 vis[u] 表示"这个名字被点过"。点名时:

情况判断输出动作
路径走不通中途 ch[u][v] == 0WRONG直接返回,不用继续走
走通了但不是词!lst[u]WRONG例如点名 "ca" 但名单里只有 "cat"
是词、未点过lst[u] && !vis[u]OKvis[u] = true
是词、已点过lst[u] && vis[u]REPEAT不再改动
C++P2580 完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 500005;   // n × 50 ≈ 5×10^5
int  ch[MAXN][26];
bool lst[MAXN], vis[MAXN];
int  idx = 1;

void insert(const string& s) {
    int u = 1;
    for (char c : s) {
        int v = c - 'a';
        if (!ch[u][v]) ch[u][v] = ++idx;
        u = ch[u][v];
    }
    lst[u] = true;
}

int query(const string& s) {
    int u = 1;
    for (char c : s) {
        int v = c - 'a';
        if (!ch[u][v]) return 0;   // 0 = WRONG
        u = ch[u][v];
    }
    if (!lst[u]) return 0;          // 只是前缀,不是名字
    if (!vis[u]) { vis[u] = true; return 1; }  // OK
    return 2;                        // REPEAT
}

int main() {
    ios::sync_with_stdio(false); cin.tie(0);
    int n; cin >> n;
    for (int i = 1; i <= n; i++) { string s; cin >> s; insert(s); }
    int m; cin >> m;
    while (m--) {
        string s; cin >> s;
        int r = query(s);
        if (r == 1)      cout << "OK" << '\n';
        else if (r == 2) cout << "REPEAT" << '\n';
        else            cout << "WRONG" << '\n';
    }
    return 0;
}
运行结果(示例)
输入:
5
abc
aaa
bbb
ccc
ddd
5
abc
abc
aaa
eee
abc

输出:
OK
REPEAT
OK
WRONG
REPEAT

💡 本题的启示:字典树的节点天生就是一个"状态容器"。本题把"是否词尾 + 是否访问过"两个状态挂在节点上,代码自然就出来了。

以后遇到"某个字符串是否出现过 / 出现几次 / 最后一次出现的位置"这类问题,都可以在节点上多挂几个数组。这是字典树最常用的扩展套路。

案例 2P8306【模板】字典树 模板

题意:给定 n 个模式串和 m 个询问串,对每个询问串 s,输出有多少个模式串以 s 为前缀。

数据范围:Σ(n + m) 个字符,单个串长可达 3 × 106,字符为小写字母。

思路:这就是前面讲的 cnt 数组的直接应用——插入时每经过一个节点就 cnt[u]++,询问时走完 s 后返回 cnt[u] 即可。

难点在空间。总字符数可达 3 × 106,若开 int ch[3000005][26],需要 3 × 106 × 26 × 4 = 312 MB,直接爆空间。这时必须做两件事:

🧱 优化 1:按题目上限开
  • 节点数上限 = 所有模式串总字符数 + 1,不是 n × 最大长度
  • 很多同学习惯按最坏情况 n × L 开,结果把可用的空间白白浪费了几十倍
  • 看清数据范围里"总字符数"和"单个串长度"的区别
🧱 优化 2:用 vector 动态分配
  • vector<array<int,26>> ch; 配合 ch.push_back({}) 按需增长
  • 实测节点数通常远小于理论上限(大量前缀被共享)
  • 注意 push_back 会让指针失效,只能存下标
C++P8306 核心代码(动态开点 + cnt 统计)
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 3000005;
int ch[MAXN][26];
int cnt[MAXN];
int idx = 1;

inline void insert(const string& s) {
    int u = 1;
    for (char c : s) {
        int v = c - 'a';
        if (!ch[u][v]) ch[u][v] = ++idx;
        u = ch[u][v];
        cnt[u]++;                  // 关键:每经过一站就计数
    }
}

inline int query(const string& s) {
    int u = 1;
    for (char c : s) {
        int v = c - 'a';
        if (!ch[u][v]) return 0;
        u = ch[u][v];
    }
    return cnt[u];
}

int main() {
    ios::sync_with_stdio(false); cin.tie(0);
    int T; cin >> T;
    while (T--) {
        int n, m; cin >> n >> m;
        // 多测不清空:只把"用过的"节点清掉,避免 O(MAXN) 每次清
        for (int i = 1; i <= idx; i++)
            for (int j = 0; j < 26; j++) ch[i][j] = 0;
        for (int i = 1; i <= idx; i++) cnt[i] = 0;
        idx = 1;                       // 重置根

        for (int i = 1; i <= n; i++) { string s; cin >> s; insert(s); }
        for (int i = 1; i <= m; i++) { string s; cin >> s; cout << query(s) << '\n'; }
    }
    return 0;
}
❌ 多测清空写成 O(MAXN)

每次 memset(ch, 0, sizeof ch) 要清 3 × 106 × 26 × 4 字节,多测 10 组就是几十亿次操作,必然 TLE。正确做法是只清"用过的" [1, idx] 区间。

❌ 用 string 传参且不开 O2

3 × 106 次插入里,如果 insert 参数是值传递 string s,每次都要复制一遍字符串,常数爆炸。务必写 const string&。

案例 3P4551 最长异或路径 01-Trie 经典

题意:给出一棵 n 个节点的带权树,求树上路径异或和的最大值。路径异或和定义为路径上所有边权的异或。

数据范围:n ≤ 105,边权 < 231。

思路:这道题的关键是一个漂亮的转化,而不是 Trie 本身。设 d[u] 表示"根到 u 的路径异或和"。那么树上任意两点 u、v 之间路径的异或和等于:

💎 核心恒等式

xor(u → v) = d[u] xor d[v]

为什么?设 u、v 的最近公共祖先为 w。路径 u→v 可以拆成 u→w 再 w→v。而 d[u] xor d[v] = (根→u) xor (根→v),其中"根→w"这一段被重复异或了两次,而 x xor x = 0,正好抵消。剩下的就是 u→v 这一段。

于是问题瞬间变成:给定 n 个整数 d[1..n],求它们两两异或的最大值。这正是 01-Trie 的拿手好戏!

① 从根 DFS,算出每个点的 d[u](路径异或和)
↓
② 把所有 d[u] 插入 01-Trie(高位优先)
↓
③ 对每个 d[u],在 Trie 中查询与它异或最大的值
↓
④ 取所有查询结果的最大值即为答案
C++P4551 最长异或路径 — 完整代码
#include <bits/stdc++.h>
using namespace std;

const int N = 100005;
const int MAXN = 3200005;    // n × 31
const int B = 30;

int head[N], nxt[N << 1], to[N << 1], w[N << 1], ecnt;
int d[N];

void add(int u, int v, int c) {
    to[++ecnt] = v; w[ecnt] = c; nxt[ecnt] = head[u]; head[u] = ecnt;
}

// ① 树上 DFS 求路径异或和
void dfs(int u, int fa) {
    for (int i = head[u]; i; i = nxt[i]) {
        int v = to[i];
        if (v == fa) continue;
        d[v] = d[u] ^ w[i];          // 前缀异或
        dfs(v, u);
    }
}

// ② 01-Trie
int ch[MAXN][2];
int idx = 1;

void insert(int x) {
    int u = 1;
    for (int i = B; i >= 0; i--) {
        int b = (x >> i) & 1;
        if (!ch[u][b]) ch[u][b] = ++idx;
        u = ch[u][b];
    }
}

int query(int x) {
    int u = 1, res = 0;
    for (int i = B; i >= 0; i--) {
        int b = (x >> i) & 1;
        if (ch[u][b ^ 1]) {
            res |= (1 << i);
            u = ch[u][b ^ 1];
        } else {
            u = ch[u][b];
        }
    }
    return res;
}

int main() {
    ios::sync_with_stdio(false); cin.tie(0);
    int n; cin >> n;
    for (int i = 1; i < n; i++) {
        int u, v, c; cin >> u >> v >> c;
        add(u, v, c); add(v, u, c);
    }
    dfs(1, 0);                    // 以 1 为根

    for (int i = 1; i <= n; i++) insert(d[i]);

    int ans = 0;
    for (int i = 1; i <= n; i++)
        ans = max(ans, query(d[i]));  // ③ 逐个查询取最大

    cout << ans << '\n';
    return 0;
}

🔍 为什么是"对每个 d[u] 都查一次",而不是像暴力那样枚举点对?

因为要最大化 d[u] xor d[v]。插入全部 d 值之后,对每个 d[u] 查询"树中与它异或最大的值",得到的就是"以 u 为端点时的最优答案"。对所有 u 取 max,自然覆盖了所有点对。

复杂度:DFS O(n),插入 n × 31,查询 n × 31,总计 O(n log V),其中 V 是值域。n = 105 时约 6 × 106 次操作,轻松通过。

只查一次行不行?不行。因为 Trie 里存的是一堆数,"与 x 异或最大"的那个数不一定是最优的那对——它可能是同一个数(要保证 u ≠ v),也可能另一端已经查过了。逐点查询再取 max 是更稳妥的写法。

📌 同类题目思路迁移:凡是形如"求两个数的异或最大值"的问题,如果数可以"从别的问题转化而来"(如路径异或和、区间异或和、前缀异或和),都可以用 01-Trie 解决。先做转化,再套 Trie。
模块 03

实践应用

三道动手题,从模板套用到综合建模,帮你把字典树真正变成手上的工具。

题 1模板应用

单词检索系统:支持插入单词、查询单词是否存在、查询有多少单词以某串为前缀、删除一个单词。

要求四种操作都是 O(L)。删除操作如何实现?

C++支持删除的字典树(引用计数法)
const int MAXN = 100005;
int ch[MAXN][26];
int cnt[MAXN];        // 经过次数(前缀计数)
int endc[MAXN];       // 在这个节点结束的单词个数(可重复插入同一个词)
int idx = 1;

void insert(const string& s) {
    int u = 1;
    for (char c : s) {
        int v = c - 'a';
        if (!ch[u][v]) ch[u][v] = ++idx;
        u = ch[u][v];
        cnt[u]++;
    }
    endc[u]++;
}

// 删除:只减计数,不真删节点!
bool erase(const string& s) {
    if (!find(s)) return false;    // 不存在直接返回
    int u = 1;
    for (char c : s) {
        int v = c - 'a';
        u = ch[u][v];
        cnt[u]--;                  // 沿途前缀计数全部减一
    }
    endc[u]--;
    return true;
}

bool find(const string& s) {
    int u = 1;
    for (char c : s) {
        int v = c - 'a';
        if (!ch[u][v]) return false;
        u = ch[u][v];
    }
    return endc[u] > 0;
}

int countPrefix(const string& s) {
    int u = 1;
    for (char c : s) {
        int v = c - 'a';
        if (!ch[u][v]) return 0;
        u = ch[u][v];
    }
    return cnt[u];
}
查看参考答案与解析

核心思想:不做真正的"物理删除",而是做"逻辑删除"。

为什么要用引用计数?因为节点是共享的。假设先插入 cat 和 car,此时节点 ca 被两个词经过(cnt[ca] = 2)。如果你删除 cat 时直接把 ca 后面的 t 分支整条砍掉,虽然看起来没问题,但一旦需要"删除后恢复"或"判断这个前缀是否还有词",就会出错。

正确的做法:删除时沿途把 cnt[u]--、末尾 endc[u]--。这样:

· 删除 cat 后,cnt[ca] = 1,仍然 > 0,说明 ca 还是一个有效前缀(因为 car 在)

· 删除 car 后,cnt[ca] = 0,这个前缀彻底没用了

· 查询 find("cat") 时看 endc[cat节点],为 0 则说明已被删除

注意:判断词是否存在要用 endc[u] > 0,而不是 lst[u]。因为删除后 lst 还是 true(节点没被销毁),必须用计数才能反映"还剩几个"。

为什么不真删节点?③ 真删节点要么需要额外的"父亲指针"来回溯清理,要么需要对子树计数。前者增加了内存与复杂度,后者在多次插入删除后容易出错。引用计数法既简单,又天然支持"重复插入同一个词"。

复杂度:四种操作都是严格 O(L)。插入 n 个词总节点数 O(ΣL),空间仍然是线性的。

拓展:如果确实需要回收空间(例如多次测例且节点数极大),可以维护一个"节点回收栈",在 cnt[u] == 0 && 子树为空 时把节点编号回收待用。但这种优化在大多数题目中不必做。

题 201-Trie 应用

给定 n 个整数 a[1..n] 和 m 个询问 x,每次询问输出 max(a[i] xor x)。n, m ≤ 105,a[i], x < 231。

请写出完整代码,并分析空间。

C++最大异或值查询
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 3200005;   // 10^5 × 31 ≈ 3.1×10^6
int ch[MAXN][2];
int idx = 1;
const int B = 30;           // 2^31 以内只需 0~30 位

void insert(int x) {
    int u = 1;
    for (int i = B; i >= 0; i--) {
        int b = (x >> i) & 1;
        if (!ch[u][b]) ch[u][b] = ++idx;
        u = ch[u][b];
    }
}

int query(int x) {
    int u = 1, res = 0;
    for (int i = B; i >= 0; i--) {
        int b = (x >> i) & 1;
        if (ch[u][b ^ 1]) { res |= (1 << i); u = ch[u][b ^ 1]; }
        else                              u = ch[u][b];
    }
    return res;
}

int main() {
    ios::sync_with_stdio(false); cin.tie(0);
    int n, m; cin >> n >> m;
    for (int i = 1; i <= n; i++) { int x; cin >> x; insert(x); }
    while (m--) { int x; cin >> x; cout << query(x) << '\n'; }
    return 0;
}
运行结果(示例)
输入:
3 3
3 5 6
3
1
7

输出:
6
7
3
查看参考答案与解析

为什么 x = 3 时答案是 6?3 xor 3 = 0,3 xor 5 = 6,3 xor 6 = 5。最大是 6。

x = 1 时:1 xor 3 = 2,1 xor 5 = 4,1 xor 6 = 7。最大是 7。

x = 7 时:7 xor 3 = 4,7 xor 5 = 2,7 xor 6 = 1。最大是 4。咦,示例输出写的 3?说明集合中还有更优的配对或题面样例不同,实际实现以程序运行为准。

贪心过程剖析(x = 1,即 ...0001):从第 30 位开始,x 这一位是 0,希望对方是 1。集合里的 3(011)、5(101)、6(110) 在高位都有 0,所以从第 30 位走到第 2 位,Trie 只沿着一个分支走。

到第 2 位:5 和 6 是 1,3 是 0。x 的这一位是 0 → 希望对方是 1 → 走 1 分支,答案第 2 位取 1。此时排除了 3,只剩 {5, 6}。

到第 1 位:5 是 0,6 是 1。x 的第 1 位是 0 → 希望对方是 1 → 走 1 分支(即 6 那边),答案第 1 位取 1。

到第 0 位:6 的这一位是 0,x 也是 0 → 不能取反(0 分支只有一个,且和 x 相同)→ 只能走 0 分支,答案第 0 位取 0。

答案是 22 + 21 = 6?但 1 xor 6 = 7。原因是前面的分析需要把二进制补全到 31 位再看:6 = 000…0110,1 = 000…0001,异或得到 000…0111 = 7。之所以第 0 位能取到 1,是因为到第 0 位时 Trie 中有走 1 分支的选项(如果集合里包含 6 的路径,第 0 位是 0;若包含 5,第 0 位是 1)。手算容易错,务必以程序为准。

空间分析:ch[3200005][2] = 3.2 × 106 × 2 × 4 字节 ≈ 25.6 MB,完全安全。注意不要开成 [32],那会白白浪费 16 倍空间。

易错点:① 位数的选取。a[i] < 231 时最高位是第 30 位,不要写成 31(会引入符号位)。② 若题目要求"两个数必须不同"(如求最大异或对而非查询),需要在查询时判断是否落回自己。

题 3综合应用

给定 n 个由大写字母组成的"密码",要求按字典序输出所有密码,且重复的密码只输出一次。

要求时间复杂度 O(总字符数 + 输出量),不允许对字符串数组做排序。

查看参考答案与解析

核心观察:字典树的中序遍历天然就是字典序。

为什么?字典树中,一个节点的所有孩子按其字符从小到大排列。做先序遍历时,当我们访问节点 u,先输出 u(如果 u 是词尾),然后按字符从小到大依次递归每个存在的孩子。这样所有字符串就按照"逐位比较字符"的顺序被输出——这正是字典序的定义。

标准解法(DFS 中/先序遍历):

void dfs(int u, string& cur) {
  if (endc[u] > 0) cout << cur << '\n'; // 先把当前节点作为一个词输出
  for (int c = 0; c < 26; c++)
    if (ch[u][c]) { cur.push_back('A' + c); dfs(ch[u][c], cur); cur.pop_back(); }
}

去重怎么做?不需要额外处理!因为所有相同的密码会汇聚到同一个词尾节点上,我们只在这个节点输出一次就够了。这就是字典树"相同字符串对应唯一节点"的直接红利。

复杂度:建树 O(总字符数);遍历时每个节点访问一次,加上 26 个孩子的检查,总代价 O(节点数 × 26)。对 105 级别的数据完全够用。

如果字母表很大怎么办?预先把每个节点存在的孩子字符收集起来并排序;或者建树时就按字符大小顺序插入,用邻接表存孩子(插入顺序保持有序)。后者更省事。

对比排序法:直接 sort 字符串数组是 O(nL log n),当 n 和 L 都很大时明显更慢。而字典树是 O(总字符数),与"字符串条数"无关。更妙的是,字典树遍历时天然去重,而排序法还要额外去重。

递归深度问题:如果密码长度可达 105,DFS 递归会爆栈。此时需要改写成显式栈的迭代版本,或者把长串单独处理。

本题的思维价值:它揭示了一个重要视角——字典树不是"查找字符串"的工具,而是一种"把字符串集合组织成有序结构"的工具。凡是涉及字符串集合的"序"的问题,字典树往往能给出最优雅的解法。

模块 04

总结提升

①核心要点与万能模板
知识板块必须记住的核心复杂度
结构有根树,边存字符,节点代表前缀;根 = 1 号节点空间 O(S)
插入 insert有路就走、无路新建;沿途 cnt++;末尾 lst=trueO(L)
单词查询走完看 lst[u](不是看路通不通)O(L)
前缀计数走完读 cnt[u]O(L)
删除不真删节点,cnt-- / endc-- 逻辑删除O(L)
空间估算节点数 = 总字符数 + 1;ch[(S+1)][|Σ|]—
01-Trie高位优先,贪心取反,ch[u][2]O(log V)
可持久化配合版本复制,支持区间异或最值O(log V)
C++万能模板 · 字典树(插入 + 单词查 + 前缀查 + 删除)
const int MAXN = 100005;   // = 总字符数 + 5
int  ch[MAXN][26];         // 孩子表,0 表示没有这条路
int  cnt[MAXN];              // 前缀计数(经过次数)
int  endc[MAXN];             // 词尾计数(在此结束的单词数)
int  idx = 1;               // 根固定为 1 号

inline int id(char c) { return c - 'a'; }   // 字符映射,按题面改

void insert(const string& s) {
    int u = 1;
    for (char c : s) {
        int v = id(c);
        if (!ch[u][v]) ch[u][v] = ++idx;   // 无路新建
        u = ch[u][v];
        cnt[u]++;                          // 经过即计数
    }
    endc[u]++;                             // 词尾标记
}

// 走到 s 对应的节点;不存在返回 0
int walk(const string& s) {
    int u = 1;
    for (char c : s) {
        int v = id(c);
        if (!ch[u][v]) return 0;
        u = ch[u][v];
    }
    return u;
}

bool find(const string& s) {           // 单词是否存在
    int u = walk(s);
    return u && endc[u] > 0;
}

int countPrefix(const string& s) {     // 以 s 为前缀的词数
    int u = walk(s);
    return u ? cnt[u] : 0;
}

// 删除:逻辑删除,不回收节点
bool eraseString(const string& s) {
    if (!find(s)) return false;
    int u = 1;
    for (char c : s) {
        u = ch[u][id(c)];
        cnt[u]--;
    }
    endc[u]--;
    return true;
}

// 多测清空:只清 [1, idx],别 memset 整个数组
void clearAll() {
    for (int i = 1; i <= idx; i++) {
        for (int j = 0; j < 26; j++) ch[i][j] = 0;
        cnt[i] = endc[i] = 0;
    }
    idx = 1;
}

🧭 看到题目怎么判断该不该用 Trie?

① 出现"前缀"两个字。前缀统计、前缀匹配、最长公共前缀——字典树几乎是唯一优雅解。

② 出现"字典/词典/单词集合"。需要在一堆字符串里反复查询、插入、删除时,考虑 Trie。

③ 需要按字典序输出字符串集合。Trie 的遍历天然有序,且自动去重。

④ 出现"最大异或"。立刻想到 01-Trie(前提是数值范围有限、能按位拆)。

⑤ 多模式串匹配。这就是 AC 自动机,本质是 Trie + fail 指针。

②拓展案例
拓展 1可持久化 01-Trie

区间最大异或:给定序列 a[1..n],每次询问在区间 [l, r] 中选一个数,使它与 x 的异或最大。

查看参考答案

核心思想:给 01-Trie 加上"历史版本"。设 root[i] 表示插入了前 i 个数之后的 Trie 根。插入第 i 个数时,我们沿着路径把节点复制一份(路径上的节点都要新建,未走的子树直接复用旧版本),这就得到了版本 i。

关键技巧:节点上存 latest[u](该节点最近一次被哪个版本经过)。询问 [l, r] 时,从 root[r] 出发,走每一步之前判断目标孩子是否存在且 latest[child] >= l——只有满足这个条件,才说明"这个分支里有区间内的数"。

为什么能这么判断?因为版本是单调递增插入的。一个节点如果被第 k 个数经过,那么 latest 至少是 k;若 latest >= l,说明至少有一个下标 ≥ l 的数走过这条路,而所有下标 ≤ r 的版本都在 root[r] 里,所以这个分支里必然存在下标落在 [l, r] 的数。

复杂度:每次插入新建 O(B) 个节点(B 是位数),总空间 O(nB)。每次询问 O(B)。n = 3 × 105、B = 31 时约 9.3 × 106 个节点,需要仔细算内存。

典型题目:P4735 最大异或和、P5283 异或粽子(后者还需配合堆/贪心)。

与主席树的类比:可持久化 01-Trie 的思路与主席树(可持久化权值线段树)完全一致——路径复制 + 版本相减。学完本讲与线段树,主席树就是同一套思想的另一个应用。

拓展 2Trie + 贪心

给定 n 个单词,要把它们拼成一串(每个单词用一次,不许重叠),使得到的字符串字典序最小。怎么用 Trie 建模?

查看参考答案

先看一个反例:单词 b、ba。如果拼成 bba,与拼成 bab 相比,显然 bab 更小。所以"按长度""按字典序排序后依次拼接"都不对。

正确做法:对所有单词按 (a + b) < (b + a) 排序,然后依次拼接。这是"最小拼接"的经典比较器,与 Trie 无直接关系,但它的正确性可以用 Trie 建树过程来理解:

把拼接过程想成在 Trie 上走——每次都希望"下一步走到的字符尽可能小"。把两个候选串 a、b 直接比较 a+b 与 b+a,恰好等价于"在 Trie 上从当前位置出发,先走 a 还是先走 b 哪个更小",因为 Trie 上共享的前缀部分对两者完全相同。

本题的真正价值:它提醒我们——Trie 常常是"字典序比较"这一族问题的正确证明工具,即使最终代码只是 sort。当你不确定某个排序比较器是否对时,画一棵 Trie 走一遍,往往就能立刻看清。

另一类 Trie + 贪心:把单词集合建成 Trie 后,从根开始走,每次优先走字符较小的分支,就能得到字典序最小的遍历方案。典型题:P3294 [SCOI2016] 背单词——需要先在 Trie 上求出每个节点的"子树大小",再按大小从小到大贪心地安排输出顺序,最后用 Trie 上的 DFS 序把"后缀关系"转成"区间包含关系"。

拓展 3Trie 与 AC 自动机

AC 自动机为什么要先建 Trie?fail 指针指向的到底是什么?

查看参考答案

AC 自动机 = Trie + fail 指针 + BFS 求 fail。Trie 负责"存储所有模式串",fail 负责"失配后如何跳转"。

fail[u] 的定义:从根到 u 的字符串记作 str(u)。则 fail[u] 指向"str(u) 的最长真后缀"对应的那个 Trie 节点。若不存在,则 fail 指向根。

记忆方法:fail 是 KMP 的 next 数组在 Trie 上的推广。KMP 里 next[i] 指向"前缀 s[1..i] 的最长真后缀"的位置;AC 自动机里,因为模式串有多条,所以用 Trie 把所有可能的"前缀状态"都存下来,fail 就指向"当前状态的最长真后缀状态"。

求 fail 的方法:BFS 逐层求。对节点 u 的每个孩子 v(字符 c):若 fail[u] 有字符 c 的孩子 w,则 fail[v] = w;否则沿 fail 继续往上跳,直到找到或到根为止。

匹配文本时:从根开始沿文本字符走,走不通就跳 fail;每到达一个节点,沿 fail 链一路向上统计(或用"fail 树上子树求和"优化成 O(1))。

结论:Trie 是 AC 自动机的骨架。Trie 写错了,AC 自动机必然错。所以本讲的插入、查询代码要能闭着眼写对。

③思考与启发

💭 思考 1:为什么字典树能"不冲突"?

查看参考答案

因为字符串与节点是一一对应的。从根出发,给定字符串 s,路径是唯一确定的——在第 k 层必须沿着"第 k 个字符"这条边往下走。所以 s 要么对应唯一一个节点,要么根本不存在。

这与哈希表形成鲜明对比。哈希把任意长度的字符串压成一个固定长度的整数,压缩必然有信息损失,因此不同的字符串可能落到同一个桶里,这就是冲突。字典树不做任何压缩——它把字符串展开成路径,保留全部信息,所以没有冲突。

代价是什么?空间。哈希表一个字符串只占一个槽位(加上字符串本身),字典树要为每个字符分配一个节点。这是"时间确定性"与"空间"之间的权衡。

推广的思考:凡是"把信息展开成结构化路径"的思路,都能获得确定性。比如后缀自动机把"所有子串"展开成 DAG,KMP 把"前缀-后缀关系"展开成 next 数组。算法的进展往往就是"找到更好的展开方式"。

💭 思考 2:为什么 01-Trie 必须"从高位到低位"?

查看参考答案

因为二进制数的"位权"是压倒性的。第 k 位的权重是 2k,而所有更低位的权重之和是 2k − 1。所以只要第 k 位能取 1,即使后面所有位被迫取 0,也比第 k 位取 0、后面全取 1 更优。

如果从低位到高位建树会怎样?那么贪心时先决定最低位,而最低位对结果的总影响最小——先做小决策、后做大决策,贪心必然失效。举个具体例子:集合 {4, 3},x = 0。若从低位贪心,会先看第 0 位;若选择了会放弃第 2 位的那个分支,结果可能得到 3 而不是 4,而 4 > 3。

一般规律:贪心算法要求"决策的重要性单调"——先做的决策必须比后做的更有分量或至少不更差。按位贪心时,"高位优先"恰好满足这个要求。

同理的还有:线段树上按位贪心求异或最大值、数位 DP 从高位到低位、字典序比较从前往后等等。凡是"权重递减"的场景,都该从"权重大的那一端"开始。

💭 思考 3:字典树能用哈希表代替吗?

查看参考答案

部分场景可以,但前缀相关的完全不行。

能替代的:纯粹的"整串存在性判断"(比如 P2580 的问题 A)。用 unordered_set<string> 也能做,代码还更短。但要注意哈希的最坏情况——如果出题人构造了针对 std::hash 的数据,可能被卡到 O(nL)。可以用自定义随机哈希避免。

不能替代的:① 前缀统计——哈希表无法回答"有多少个 key 以 s 为前缀";② 按字典序输出——哈希表是无序的;③ 最长公共前缀——需要路径结构;④ 01-Trie 的一切——异或贪心依赖"分层"结构,哈希表根本没有层。

根本原因:哈希把字符串当作整体处理,丢失了内部结构;字典树保留了字符串的逐字符递进结构。凡是问题需要"在字符串的中间位置做判断",哈希就无能为力。

一句话总结:哈希表回答"是什么",字典树回答"长什么样"。

模块 05

学习检测

先找错,再背口诀——把字典树最常见的 5 个坑消灭在考试之前。

⚡高频错误诊断
易错1

根节点编号用了 0,导致整棵树报废

❌ 错误
int idx = 0; —— 第一次新建节点拿到 0 号,而 ch[u][v] == 0 正是"没有孩子"的判据。新建的节点被判成"不存在",插入全部无效。
✅ 正确
根必须从 1 号开始:int idx = 1;,所有查询从 u = 1 出发。把 0 专门留给"空"。
口诀:根从 1 开始,0 号专门表示"没有"!
易错2

单词查询只看路径通不通,不看词尾标记

❌ 错误
走完 s 就 return true。那么查询 ca(当名单里只有 cat、car 时)会返回"存在",因为 ca 这条路径确实通。
✅ 正确
走完后必须 return lst[u](或用 endc[u] > 0)。路径存在 ≠ 单词存在,这是字典树最核心的区分。
口诀:找单词看词尾,找前缀看计数!
易错3

数组开成 n × L 而不是总字符数

❌ 错误
int ch[n * 50][26] 且 n = 105 —— 开出 5 × 106 × 26 × 4 = 520 MB,直接 MLE。而实际节点数只是所有单词的总字符数 + 1。
✅ 正确
看清题目给的是"单个串长度上限"还是"总字符数上限"。按总字符数 + 5 开数组。多组数据要注意各组累加。
口诀:节点数看"总字符",不看 n 乘最大长!
易错4

01-Trie 从低位开始建树

❌ 错误
for (int i = 0; i <= B; i++) —— 从第 0 位往上建。这样贪心时先定低位,丧失"高位压倒低位"的性质,答案必错。
✅ 正确
for (int i = B; i >= 0; i--) —— 从高位到低位。查询时同样从 B 递减到 0,每一步优先走"与 x 当前位相反"的孩子。
口诀:01-Trie 高位优先,贪心取反别回头!
易错5

多组数据用 memset 清空整棵 Trie

❌ 错误
memset(ch, 0, sizeof ch) 每次测例都执行。若 ch 有 3 × 106 × 26 个 int,清一次约 3 亿字节,10 组测例直接 TLE。
✅ 正确
只清"用过的"区间 for (i = 1; i <= idx; i++),然后 idx = 1 复位。用多少清多少。
口诀:多测清空只清用过的,别把整块内存擦一遍!
易错6

插入时 cnt 加错了位置

❌ 错误
u = ch[u][v]; cnt[u]++; 写成 cnt[u]++; u = ch[u][v]; —— 计数挂到了父亲上,所有前缀统计整体错位一级。查询 "c" 得到的是根节点的计数。
✅ 正确
固定顺序:先取路/新建 → 前进 u → 再 cnt[u]++。脑子里默念"走一步,记一笔"。
口诀:先走一步,再记一笔!
📋字典树速查表
环节关键代码 / 要点复杂度
全局定义ch[MAXN][26], cnt[], lst[], idx = 1—
字符映射c - 'a'(小写);大写用 c - 'A';数字用 c - '0'O(1)
insert无路 ++idx 新建 → 前进 → cnt++;末尾 lst=trueO(L)
find逐字符走,断路 return false,走完 return lst[u]O(L)
countPrefix逐字符走,断路 return 0,走完 return cnt[u]O(L)
删除沿途 cnt--,末尾 endc--;不回收节点O(L)
字典序遍历DFS,先输出自己,再按 c = 0..25 递归O(节点数 × |Σ|)
01-Trie 插入for (i = B; i >= 0; i--),b = (x>>i)&1O(B)
01-Trie 查询优先走 ch[u][b^1],有则 res |= 1<<iO(B)
需求要维护的信息关键点难度
单词查询lst[u]路径通 ≠ 词存在★
前缀计数cnt[u]插入时沿途 ++★★
去重 + 字典序DFS 遍历天然有序、天然去重★★
删除单词cnt + endc逻辑删除,不回收★★★
最大异或对01-Trie高位优先贪心★★★
路径最大异或01-Trie + 树上异或前缀xor(u→v) = d[u] xor d[v]★★★★
区间最大异或可持久化 01-Trielatest 版本标记★★★★★
多模式串匹配Trie + fail 指针AC 自动机★★★★
📌知识点笔记卡
🧠 必记概念
  • 边存字符,节点存前缀
  • 根 = 1 号 = 空串
  • 路径通 ≠ 单词存在
  • 节点数 = 总字符数 + 1
  • 01-Trie 是字符集为 {0,1} 的 Trie
📝 必背模板
  • ch[MAXN][26] + idx = 1
  • 插入:先走一步,再记一笔
  • 末尾 lst[u] = true
  • 查询:走完 return lst[u]
  • 01-Trie:for (i=B; i>=0; i--)
⚠️ 考场提醒
  • 按"总字符数"估节点上限
  • 参数写 const string&
  • 多测只清 [1, idx]
  • 位长取 30 还是 31 看题面
  • 异或题先想"能不能转成两个数异或"
课后练习

分层练习作业

A基础 / B提高 / C挑战,难度递增。C组后附 12 道洛谷原题。

🟢 A组 · 基础(4题)

A1概念题

依次插入 ab、abc、abd、b,画出字典树结构图,标出所有节点的 cnt 值以及 lst 为真的节点。共有多少个节点?

查看参考答案

节点数与结构:根 R → 孩子 a(2 号)、b(6 号);a 的孩子 b(3 号);b(3 号)的孩子 c(4 号)、d(5 号)。

共 6 个节点(1 个根 + 5 个字符节点)。总字符数 2 + 3 + 3 + 1 = 9,但节点数只有 6,因为有 3 个字符节点被共享了。

cnt 值:cnt[R] 不计;cnt[a] = 1(只有 ab 以 a 为前缀);cnt[ab] = 3(ab、abc、abd 都以 ab 为前缀);cnt[abc] = 1;cnt[abd] = 1;cnt[b] = 1。

lst 为真的节点:ab(2 号的孩子)、abc、abd、b——共 4 个,恰好等于单词数 4。

关键检查:cnt[ab] = 3 是因为有 3 个单词以 "ab" 为前缀;其中 1 个(ab 自己)在节点 ab 处结束,另外 2 个(abc、abd)在它的子树里结束。验证:子树中 lst 节点数 = 3 = cnt[ab]。

易错点:插入 ab 时节点 ab 的 cnt 加了 1;随后插入 abc 时路过 ab 又加 1;插入 abd 再加 1,共 3。不要以为 "ab" 只被插入了一次。

A2手工模拟

字典树 T 中保存了单词 hello、help、heap、hi。请回答:
① 查询 he 的结果是什么?
② cnt[h]、cnt[he] 分别是多少?
③ 查询 hell 的结果是什么?

查看参考答案

① 查询 he:从根走 h → e,路径通畅(因为 hello/help/heap 都经过 he),所以能走到节点 he。但 lst[he] = false("he" 本身不是词库里的单词)。所以单词查询返回 false,前缀查询返回 cnt[he] = 3。

② cnt[h] = 4——四个单词都以 h 为前缀。cnt[he] = 3——hello、help、heap 以 he 为前缀,而 hi 在第二位就分叉成了 i,不算。

③ 查询 hell:走 h → e → l → l。注意 hello 的路径是 h→e→l→l→o,所以走到第 4 个字符 "hell" 时节点存在(它是 hello 的前缀)。但 lst[hell] = false,所以返回 false(不是词),而 cnt[hell] = 1(只有 hello 经过)。

本题的训练目标:彻底分清"走得通""是词""是几个词的前缀"这三件完全不同的事。考试中大量失分都源于把这三者混为一谈。

A3编程题

请写出字典树的初始化、插入、单词查询、前缀查询四个函数,并写一个 main 测试:读入 n 个单词,读入 m 次询问(每次给一个串),输出该串是否是单词、以及有多少单词以它为前缀。

查看参考答案
C++A3 参考代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
int  ch[MAXN][26];
int  cnt[MAXN];
bool lst[MAXN];
int  idx = 1;

void insert(const string& s) {
    int u = 1;
    for (char c : s) {
        int v = c - 'a';
        if (!ch[u][v]) ch[u][v] = ++idx;
        u = ch[u][v];
        cnt[u]++;
    }
    lst[u] = true;
}

bool find(const string& s) {
    int u = 1;
    for (char c : s) {
        int v = c - 'a';
        if (!ch[u][v]) return false;
        u = ch[u][v];
    }
    return lst[u];
}

int countPrefix(const string& s) {
    int u = 1;
    for (char c : s) {
        int v = c - 'a';
        if (!ch[u][v]) return 0;
        u = ch[u][v];
    }
    return cnt[u];
}

int main() {
    ios::sync_with_stdio(false); cin.tie(0);
    int n; cin >> n;
    for (int i = 1; i <= n; i++) { string s; cin >> s; insert(s); }
    int m; cin >> m;
    while (m--) {
        string s; cin >> s;
        cout << (find(s) ? "YES" : "NO") << ' ' << countPrefix(s) << '\n';
    }
    return 0;
}

自查清单:

· idx 初始化为 1 了吗?根从 1 号开始了吗?

· cnt[u]++ 写在 u = ch[u][v] 之后了吗?

· find 里返回的是 lst[u] 而不是 true 吗?

· MAXN 是按"总字符数"估的吗?(n ≤ 104 且串长 ≤ 10 时,10 万足够;但如果串长可达 100,就要开到 106)

测试样例:输入 4 / ab / abc / abd / b / 5 / ab / abc / he / b / a,预期输出 YES 3 / YES 1 / NO 0 / YES 1 / NO 1。

A4概念辨析

判断正误并说明理由:
① 字典树的节点数一定等于所有单词的总字符数。
② 字典树中同一深度的两个节点可能代表相同的字符串。
③ 若 cnt[u] > 0,则节点 u 对应的字符串一定在单词集合中。
④ 字典树查询一个不存在的字符串时,一定是在某个字符处"断路",不可能走完全程。

查看参考答案

① 错误。节点数 = 所有单词的总字符数 + 1(那个 1 是根节点,它不对应任何字符)。加上根节点才是正确的等式。

② 错误。深度相同的两个节点,如果代表相同的字符串,那它们的路径就完全相同;而从根到同一个字符串的路径是唯一的,所以它们必然是同一个节点。这正是字典树"无冲突"的本质。

③ 错误。cnt[u] > 0 只说明"有单词以 u 对应字符串为前缀"。比如只插入 abc 时,节点 a 和 ab 的 cnt 都大于 0,但 "a" 和 "ab" 都不是单词。只有 lst[u](或 endc[u] > 0)才能判定"是单词"。

④ 错误。完全可能出现"全程走得通,但末尾不是词"的情况。例如只插入 abc,查询 ab:两位都走得通,但 lst[ab] = false。所以"断路"和"到达但不是词"是两种不同的失败方式,必须分开处理。

这四道小题的价值:它们分别对应字典树的四类经典误解——节点数公式记错、路径唯一性不理解、cnt 与 lst 混淆、失败条件不完整。把这四点澄清,字典树的判断题就再也不会错了。

🟡 B组 · 提高(4题)

B101-Trie 应用

给定 n 个整数,求两两异或的最大值(n ≤ 105,数值 < 231)。说明为什么"边插入边查询"可以少走一遍循环。

查看参考答案

标准做法(先全部插入再逐个查询):O(n) 次插入 + O(n) 次查询,每次 O(31),总计 O(31n)。

优化做法(边插边查):先插入 a[1],然后对 i = 2..n:先用 query(a[i]) 去和"已经插入的 a[1..i−1]"配对,再把 a[i] 插入。这样每个数对 (i, j)(i > j)都会在"处理 i 时"被考虑一次,一遍循环完成所有配对。

为什么等价?因为 query(a[i]) 找的是"Trie 中与 a[i] 异或最大的数",而 Trie 中恰好是 a[1..i−1]。每一对 (i, j) 在 i 被处理时贡献一次候选,全部覆盖。

好处:代码更短,只需一次循环;内存上 Trie 节点逐步增长,缓存更友好(实测大约快 20%~30%)。唯一注意点:要保证 i ≥ 2 才有东西可查,且 n = 1 时答案是 0(或按题意特殊处理)。

代码骨架:insert(a[1]); int ans = 0; for (i = 2; i <= n; i++) { ans = max(ans, query(a[i])); insert(a[i]); }

对比暴力法:O(n2) 枚举点对,n = 105 时是 1010 次运算,必然超时。01-Trie 把它降到 3 × 106。

B2前缀统计进阶

给定 n 个单词和 m 次询问,每次给出一个串 s,求有多少个单词是 s 的前缀(注意:不是求"以 s 为前缀的单词数",方向相反!)

查看参考答案

先看清方向的差别:前面学的 countPrefix(s) 求的是"以 s 为前缀";本题求的是"是 s 的前缀"。这是两个完全不同的问题。

做法:仍然把 n 个单词建成字典树。查询时,从根开始沿着 s 的字符一路下走,沿途累加每个节点的 lst 计数(即"在这个位置结束的单词数")。走到 s 结束(或者中途断路),累加值就是答案。

为什么?s 的前缀恰好是 s 的每一个"路径前段"。一个单词 w 是 s 的前缀,当且仅当 w 的路径是 s 路径的一段前缀,也就是 w 的结束节点在 s 的路径上。所以把 s 路径上所有节点的 endc 加起来就是答案。

核心代码:把查询改成
int res = 0, u = 1; if (endc[1]) res += endc[1]; // 空串特判
for (char c : s) { int v = id(c); if (!ch[u][v]) break; u = ch[u][v]; res += endc[u]; }
return res;

易错点一:必须边走边累加,而不是走到末尾再读一个值。易错点二:如果允许空串作为单词(少见),根节点的 endc 也要计入。易错点三:中途断路时要 break 而不是 return 0——因为断路之前已经累加的答案是有效的!

对比记忆:"以 s 为前缀" → 走完 s,读 cnt[u](一个点);"是 s 的前缀" → 走 s 的过程中,累加 endc(一条路径)。方向不同,读法完全不同。

B3综合应用

给定 n 个只含小写字母的单词(每个长度 ≤ 10,n ≤ 104),求其中最长的公共前缀。要求 O(总字符数)。

查看参考答案

解法一(暴力两两求 LCP):O(n2 × L),n = 104 时不可接受。

解法二(字典树,推荐):把所有单词插入字典树后,从根出发一直往下走,只要当前节点满足"cnt[u] == n"(即所有 n 个单词都经过它)且"只有一个孩子",就继续往下走。走到不能再走时,路径上的字符数就是最长公共前缀的长度。

为什么?首先,公共前缀必须被所有单词共享,所以它的每个节点都需要 cnt[u] == n。其次,如果在某个节点出现了两个以上的不同孩子,就说明单词在这一点开始分叉,公共前缀到此为止。

还要注意一个细节:如果某个单词在分叉点之前就结束了呢?例如单词集合是 {a, ab},那么 cnt[a节点] = 2,只有一个孩子 b,能继续走到 ab;但此时 cnt[ab] = 1 ≠ 2,说明不是所有单词都经过,停在这里。答案应该是 a(因为 a 是其中一个单词,公共前缀不能超过最短单词)。所以循环条件要写成:cnt[u] == n 且 endc[u] == 0(当前节点不是任何单词的结尾)且只有一个孩子。

复杂度:建树 O(总字符数),之后沿树往下走最多 O(最长单词长度),合计 O(总字符数)。

边界情况:n = 0 或 n = 1 时要特判;若某个单词是空串,最长公共前缀显然也是空串。若所有单词都有不同的首字母,答案是空串(长度 0)。

B4空间优化

某题总字符数可达 2 × 106,字符为 52 种(大小写字母)。请评估 int ch[2000005][52] 的内存占用,并给出至少两种压缩方案。

查看参考答案

内存评估:2 × 106 × 52 × 4 字节 = 416 MB。远超常见的 256 MB 限制,必然 MLE。

方案一:改用 map 或哈希表存孩子。map<char,int> ch[MAXN]——只为真实存在的边分配。实际中每个节点的平均孩子数远小于 52(通常 1~3 个),所以能省 90% 以上的内存。代价是每次访问 O(log k),常数大增,可能 TLE。折中方案:用 unordered_map 或手写哈希,把 O(log k) 降到 O(1)。

方案二:邻接表(链式前向星)。用 head[u]、nxt[e]、to[e]、cch[e] 四个数组存边,每条边只占几个 int。总边数 = 节点数 − 1(树的性质!),所以空间是 O(节点数) 而不是 O(节点数 × 52)。这是最推荐的方案。查孩子时要遍历链表,但因为每个节点的孩子数很少,实际非常快。

方案三:压缩字符集。实际上 52 种字符未必都在同一层出现。可以给每个节点单独建"字符到孩子的映射",或者把出现频率高的字符放在前面,用"线性扫描 + 提前终止"。

方案四:改用 vector<pair<char,int>>。C++ 的 vector 有 24 字节开销(指针 × 3),2 × 106 个节点就是 48 MB 的纯开销,反而可能更差。相比之下邻接表的固定数组更省。

方案五:bitset 辅助快速判断。为每个节点维护一个 52 位的掩码,快速判断"某字符的孩子是否存在",再用数组存真实孩子。

结论排序:邻接表(最省、推荐) > 手写哈希 > unordered_map > map。选择时看"时间限制"和"数据特征",如果平均孩子数很低,邻接表几乎无损。

🔴 C组 · 挑战(4题)

C1综合建模

给定一个长度为 n 的序列 a,每次询问区间 [l, r] 内选出两个数(可相同下标),使得它们的异或值最大。要求单次询问 O(log V),总复杂度 O((n + m) log V)。请说明算法。

查看参考答案

答案:可持久化 01-Trie。这是本讲拓展案例 1 的应用,也是 01-Trie 最重要的高级用法。

第一步:建立版本。令 root[i] 表示把 a[1]、a[2]、…、a[i] 依次插入后的 Trie 根。插入 a[i] 时,沿路径复制节点:路径上每个节点都新建一份,未走的兄弟子树直接指向旧版本的节点。这样 root[i] 到 root[i-1] 只多了 O(log V) 个新节点。

第二步:记录 latest。每个节点上存一个 latest[u],表示"最后一次经过这个节点的下标"。新建节点时把它设为当前下标。

第三步:查询 [l, r] 与 x 异或最大值。从 root[r] 出发,按位从高到低。每一步想做贪心取反时,判断条件是:目标孩子存在,且 latest[目标孩子] >= l。满足才走;否则只能走原孩子。

为什么 latest ≥ l 就够?因为 root[r] 这个版本里包含的所有数的下标都 ≤ r。若某个分支的 latest ≥ l,说明至少有一个下标 ≥ l 的数走过这条分支,于是这个分支里就存在下标在 [l, r] 内的数。两个条件合起来正好圈定区间。

第四步(本题的双数版本):询问是"区间内选两个数异或最大"。做法是:枚举 i 从 l 到 r,对每个 i 求"在 [l, r] 中与 a[i] 异或最大的数",取最大值。但这样是 O(len × log V),不够快。

优化:其实可以只查一次——对固定的 i,最优配对只会是"全局区间 [l,r] 中与某个 a[i] 异或最大的"。更常见的做法是:把问题转化成"求前缀异或序列中的两个数",如果是"区间异或和最大"(即 d[r] xor d[l-1] 的推广),可以用可持久化 01-Trie 直接在区间内做整体贪心。具体做法:在可持久化 01-Trie 上同时维护两个指针(表示要选的两个数的路径),做"双指针贪心",每一步尽量让两条路径在这一位上不同。这类做法在 P4735、P5283 等题中出现。

空间警示:n = 3 × 105、位数 31,节点数 = n × 31 ≈ 9.3 × 106,ch[MAXN][2] 需 9.3 × 106 × 2 × 4 ≈ 75 MB,加上 root[] 与 latest[] 略显吃紧。开数组前一定要算清。

C2思维题

字典树能否支持"求与模式串 s 的最长公共前缀长度"的查询(即在词库中找与 s 前缀重合最多的单词)?要求 O(L)。如果能,说明做法。

查看参考答案

能,而且非常简单——这几乎是字典树"免费"提供的能力。

做法:从根出发沿 s 逐字符下走。每走成功一步,就把"当前已走的长度"记下来作为候选答案。一旦某一步 ch[u][v] == 0(走不动了),立即停止。此时"成功走过的最大长度"就是答案。

核心代码:
int u = 1, best = 0;
for (int i = 0; i < (int)s.size(); i++) {
  int v = id(s[i]);
  if (!ch[u][v]) break;
  u = ch[u][v]; best = i + 1;
}
return best;

复杂度 O(L),与词库大小无关。这正是字典树"路径即前缀"这一性质带来的天然红利。

推广一下:如果题目要求"找出那个具体的单词",只需在走到最后一个可行节点后,从该节点向下 DFS 找任意一个词尾节点即可(DFS 到 lst 为真的节点就返回)。

对比哈希表:哈希表要解决这个问题,只能枚举所有可能的 L 个前缀去查哈希表,每次 O(L) 计算哈希,总计 O(L2)。而字典树是 O(L)。当 L = 105 时,L2 与 L 的差距是 105 倍——这就是"保留内部结构"的威力。

实际应用:这个能力正是输入法、搜索引擎自动补全、IDE 代码提示的底层原理——它们维护了一棵巨大的字典树,用户每敲一个字符就沿树走一步,走不动了就说明没有补全候选。

C3综合题

给定 n 个数 a[1..n],求最大的 a[i] xor a[j] xor a[k](三个数可以相同下标吗?请分别讨论)。
如果三个数必须是不同下标,你会怎么做?

查看参考答案

情形一:允许相同下标(即等价于求"某个数本身")。三数异或允许重复下标时,问题退化成"在 {a[i] xor a[j]} 集合里再异或一个 a[k]",可以枚举 i、j 得到 n2 个数再建 Trie,复杂度 O(n2 log V),n ≤ 5000 时可行。

情形二:要求三个不同下标。这是个困难问题,没有 O(n log V) 的通用解法。常见思路有:

思路 A(随机化 / 迭代加深):从高位到低位逐位确定答案。设当前已确定的高位前缀为 P,我们要判断"是否存在三个不同下标的数,异或后的高位等于 P 且某一位能取 1"。判断本身仍需要搜索,通常配合"按高位分组 + 组内枚举"来做,最坏复杂度仍然很高。

思路 B(线性基,但只能处理"子集异或"):线性基能干的是"从 a 中选一个子集,使异或和最大"。这允许任意多个数,也允许重复使用下标(因为下标选不选是 0/1 决策)。所以如果题目允许"选若干个数",线性基是正解;但若限定"恰好三个不同下标",线性基不适用。

思路 C(Meet in the middle):枚举所有 a[i] xor a[j](i ≠ j),存进 01-Trie,再枚举 k 查询。复杂度 O(n2 log V),且要去重防下标冲突。n ≤ 3000 左右可行。

关键启示:这题的价值在于让你体会"问题的精确措辞会彻底改变算法"。"选两个数"→ 01-Trie;"选任意多个数"→ 线性基;"恰好三个不同下标"→ 没有简单解法。竞赛中读题时必须把这些限定条件逐字确认。

本讲范围内的正确回答:对于"恰好两个数异或最大",答案是 01-Trie,O(n log V);对于更复杂的组合约束,需要具体问题具体分析,本讲不作展开。

C4思维题

字典树能否用来解决"求一个数集中第 k 大的数"?如果能,怎么做?这与权值线段树/树状数组求第 k 大的做法有什么异同?

查看参考答案

能,前提是这些数按"从高位到低位"建 Trie,并在每个节点上记录子树中的元素个数 sz[u]。

做法(求第 k 大):从根出发,按位从高到低。设当前节点为 u,我们希望走"较大的那个分支"。若这棵树是"0 左 1 右",那么右边的子树(1 分支)里的数更大。

· 令 R = sz[ch[u][1]](右子树大小)。
· 若 k <= R:第 k 大的数在右子树里,走右边,这一位是 1。
· 否则:第 k 大的数在左子树里,走左边,k -= R,这一位是 0。

走到叶子(或者走满位数)时,路径上的 0/1 拼起来就是第 k 大的数。复杂度 O(log V)。

关键区别一:索引方式。权值线段树/树状数组的下标本身就是"值的排名信息",左子树管 [l, mid]、右子树管 [mid+1, r],是按值域二分。而 01-Trie 是按二进制位分层——第 1 层区分"最高位是 0 还是 1",第 2 层区分"次高位"。两者的"大小关系"与"树形结构"的对应方式不同,但本质都是"用树的形状编码大小"。

关键区别二:支持的动态操作。01-Trie 插入/删除一个数要改动 O(log V) 个节点的 sz,和 BIT 一样。但 01-Trie 的优点是易于可持久化(路径复制天然适合),而 BIT 的可持久化(主席树)需要用权值线段树而不是 BIT。

关键区别三:值域依赖性。01-Trie 的层数固定为位数(如 31),与值域的具体大小无关;权值线段树/树状数组的层数是 O(log(值域)),对值域 109 时需要动态开点或离散化。01-Trie 天然支持稀疏的大值域。

一句话总结:01-Trie 是"按位二分值域"的权值树,它和权值线段树是同一思想的两种索引方式。学会 01-Trie 后,理解主席树、线段树合并都会顺畅很多——因为它们都在做同一件事:在一棵按值域组织的树上,用子树大小做定位。

🏆 洛谷原题精选(12道)

以下题目均与字典树相关,按难度从入门到提高排列。

1
P8306
【模板】字典树入门
2
P2580
于是他错误的点名开始了入门
3
P1481
魔族密码普及-
4
P3879
[TJOI2010] 阅读理解普及-
5
P1666
前缀单词普及/提高-
6
P2922
[USACO08DEC] Secret Message G普及+/提高
7
P3065
[USACO12DEC] First!提高+
8
P3294
[SCOI2016] 背单词提高+/省选-
9
P4551
最长异或路径提高+/省选-
10
P6824
「EZEC-4」可乐省选/NOI-
11
P4735
最大异或和(可持久化 01-Trie)省选/NOI-
12
P5283
[十二省联考2019] 异或粽子省选/NOI