阶段 10 · 字符串 · 第 50 章提高组 S

Trie(字典树):一堆字符串摆成一棵树

★ 关键一步是把一堆串摆成一棵「每条边是一个字符」的树 —— 查前缀只看串长,和串的数量无关。⚠ 而这一章的第二个考点是内存:26 × 节点数 × 4 字节,同样的总长实测能差三个数量级(93.1 MiB 对 0.1 MiB)。

需要先学:第 46 章 位运算:整数就是一排开关,以及 lowbit 为什么成立第 47 章 字符串基础:读进来、切开、比对例题:多组数据,每组给一堆单词和一串询问,问以某个串为前缀的有几个、恰好等于它的有几个建议用时:150 分钟

1一句话问题

多组数据:第一行一个 T,接下来 T 组。

每组第一行两个数 nq;接下来 n 行,每行一个只含小写字母的单词; 再接下来 q 行,每行一个询问串 p

对每个询问输出两个数:以 p前缀的单词有几个、恰好等于 p 的有几个。

规模:T ≤ 100,所有单词的总长不超过 10⁶

输入

2
4 3
apple
app
apply
banana
app
apple
x
3 3
ab
ab
abc
ab
abc
abcd

输出

3 1
1 1
0 0
3 2
1 1
0 0

第一组:appapple / app / apply 三个单词的前缀(第一个数 3),而恰好等于 app 的只有一个(第二个数 1)。

两个数不一样,这就是这一章第一个要分清的东西。

输入

1
5 4
a
ab
abc
abc
b
a
ab
abc
abcd

输出

4 1
3 1
2 2
0 0

★ 这一组里五个单词全是前缀关系:a / ab / abc / abc / b。 盯住第三行 2 2 —— abc 出现了两次,所以两个数一样;而上面两行都不一样。

输入

1
3 3
cat
dog
fish
ca
z
catx

输出

1 0
0 0
0 0

cacat 的前缀(1 0);z 一个都不沾;catxcat 还长,走到一半就没路了

★ 为什么题面要多问一个数、还要做成多组数据

两条都是第 35 章那句「题面多问一句,对拍就多一条腿」:

  • 多问「恰好等于的有几个」 ⇒ 逼你把 Trie 上的两个计数分开 (路过 / 结尾)。只问前缀的话,把两个计数混成一个的写法能一路蒙过去。
  • 做成多组数据 ⇒ 收得到「多测不清空」这个坑,以及「清空该怎么写」这一课 —— 它们是 Trie 在考场上真实的翻车点,而单组数据的题面根本收不到

2暴力:每次询问把所有单词挨个比一遍

brute.cpp暴力:O(qnL),每个询问都扫一遍全部单词
// 标准答案:一棵树都不建,每次询问把 n 个单词挨个比一遍 —— O(qnL)
//
// 它是这道题最直白的翻译,也是对拍的标准答案:
// · 「以 p 为前缀」就是 `s.compare(0, p.size(), p) == 0`(先确认 s 够长);
// · 「恰好等于 p」就是 `s == p`。
//
// ★ 它和正解**没有任何共同的想法**:正解那边一次字符串比较都不做,只是顺着树走 |p| 步。
// 而且它天然没有「多测不清空」这个问题(每组自己的 vector 各存各的)——
// ⇒ 于是 wrongClear.cpp 那个坑,它抓得到。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int T;
cin >> T;
while (T--) {
int n, q;
cin >> n >> q;
vector<string> w(n);
for (int i = 0; i < n; i++) cin >> w[i];
for (int i = 0; i < q; i++) {
string p;
cin >> p;
int pre = 0, same = 0;
for (int j = 0; j < n; j++) {
if (w[j].size() >= p.size() && w[j].compare(0, p.size(), p) == 0) {
pre++;
if (w[j].size() == p.size()) same++;
}
}
cout << pre << ' ' << same << '\n';
}
}
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

它是这道题最直白的翻译,也是这一章的标准答案 —— ★ 它和正解没有一个共同的想法: 正解那边一次字符串比较都不做,只是顺着一棵树往下走。

3它慢在哪

本机实测(A 机:WSL2 6.18 / 8 核 / 8 GB,2026-08-26,独占;数据来自 ./genBig):

数据 暴力耗时 暴力内存 Trie 耗时 Trie 内存
./genBig 20000 0.63 秒 4.6 MiB 0.01 秒 16.1 MiB
./genBig 50000 3.99 秒 5.5 MiB 0.03 秒 32.5 MiB

★ 注意最后一列:Trie 快了一百多倍,可它比暴力费内存。 这一章后面有整整两步在讲这件事 —— 它不是小事,是 Trie 真正的取舍点。

同题对比:⚠ 暴力:挨个比 vs ✓ Trie:顺着树走
询问数默认和 n 一样,单组。先跑 20000 看一眼,再改成 50000 —— 左边会明显卡住,右边纹丝不动。
⚠ 暴力:挨个比
✓ Trie:顺着树走

4★ 关键的一步:把一堆字符串摆成一棵树

暴力慢在哪儿?它每次询问都要把每个单词从头看一遍,可那些单词之间明明有大量重复的开头: appleappapply 的前三个字符是同一段,暴力把它比了三遍。

关键的一步:让公共的前缀只存一份。

把每个单词看成一条从根出发的路径,每条边是一个字符。前缀相同,就走同一段路:

              (root)
              /    \
           a /      \ b
            /        \
         [4/1]      [1/1]
           |
         b |
           |
         [3/1]
           |
         c |
           |
         [2/2]

(这是样例二那五个单词 a / ab / abc / abc / b 长出来的树。)

★★ 每个节点上要记两个数,而且它们不是一回事
   cntPass[u] = 有多少个单词**路过** u        —— 回答「以它为前缀的有几个」
   cntEnd[u]  = 有多少个单词**正好在 u 结束** —— 回答「恰好等于它的有几个」

上面那张图里每个节点写的就是 路过/结尾

节点 路过 结尾 为什么
a 4 1 a / ab / abc / abc 都路过它,但只有 a 在这儿结束
ab 3 1 ab / abc / abc 路过,只有 ab 结束
abc 2 2 两个 abc,既路过也结束 —— ★ 只有这种时候两个数才相等
b 1 1 只有 b

⇒ 于是查询就是一句话:顺着 p 的每个字符往下走 |p| 步,读出那个节点上的两个数O(|p|) —— 和单词有多少个一点关系都没有。这就是 Trie 全部的本事。

⚠ 而「只维护一个计数、两问都拿它答」就是这一章第一个错法(第 10 步 ①)—— 只要数据里没有前缀关系,两个数处处相等,你根本测不出来。

⚠ 走不下去的时候必须立刻停
   if (!ch[u][k]) return {0, 0};      // 这条边不存在 ⇒ 一个都没有

少了这一句,ch[u][k] 是 0,u悄悄掉回了根,后面的字符又从根往下走 —— 最后答出来的是 p 的某个后缀的答案(第 10 步 ②)。

★ 顺带解释一个设计:为什么根不记 cntPass? 因为空前缀谁都路过,那个数永远是 n,没有信息 —— 而且真记了的话,上面那个 bug 会变成「答出 n」,反倒更容易被发现。这里选的是「不记」,代价要知道。

5正解

fast.cpp正解:Trie,O(总长)
// 正解:Trie(字典树),O(总长)
//
// ★ 关键的一步只有一句话:**把一堆字符串摆成一棵「每条边是一个字符」的树**。
// 于是「以 p 为前缀的有几个」不再需要去看那 n 个单词长什么样 ——
// 只要顺着 p 的每个字符往下走 |p| 步,读出那个节点上的计数就行。
// ⇒ 查一次是 O(|p|),**和单词的数量 n 一点关系都没有**。
//
// ⚠⚠ 两个计数一定要分开,这是这一章第一个、也是最容易犯的错:
// cntPass[u] = 有多少个单词**路过**了 u —— 回答「以它为前缀的有几个」
// cntEnd[u] = 有多少个单词**正好在 u 结束** —— 回答「恰好等于它的有几个」
// 混起来的话,只要数据里没有「一个串是另一个串的前缀」就一模一样,测不出来(wrongEnd.cpp)。
//
// ⚠ 这一份是多组数据,于是有第二个坑:**组与组之间要清空**。
// 而清空的写法本身就是一课:`memset` 整个 ch[MAXN][26] 是 104 MB 的活,
// T 组就是 T 遍 —— 答案全对,但会 TLE(wrongMemset.cpp)。
// ⇒ 正确做法在下面 clearUsed():**只清用过的那 tot + 1 个节点**。
#include <bits/stdc++.h>
using namespace std;
/* 节点数上限 = 所有单词的总长 + 1(每个字符最多新开一个节点,再加一个根) */
const int MAXN = 1000005;
int ch[MAXN][26]; // ⚠ 26 × 10⁶ × 4 字节 = 104 MB,这一章第 9 步专门算这笔账
int cntPass[MAXN]; // 有多少个单词路过这个节点
int cntEnd[MAXN]; // 有多少个单词正好在这个节点结束
int tot; // 已经用掉的最大节点编号(0 号是根,代表空前缀)
void insertWord(const string& s) {
int u = 0; // 从根出发
for (char c : s) {
int k = c - 'a';
if (!ch[u][k]) ch[u][k] = ++tot; // 这条边还没有,就新开一个节点
u = ch[u][k];
cntPass[u]++; // ★ 路过就加(⚠ 根不加)
}
cntEnd[u]++; // ★ 只有走到头的那个节点才加
}
/** 返回 {以 s 为前缀的单词数, 恰好等于 s 的单词数} */
pair<int, int> ask(const string& s) {
int u = 0;
for (char c : s) {
int k = c - 'a';
if (!ch[u][k]) return make_pair(0, 0); // ⚠ 走不下去 ⇒ 一个都没有,必须立刻返回
u = ch[u][k];
}
return make_pair(cntPass[u], cntEnd[u]);
}
/** ★ 只清用过的那 tot + 1 个节点 —— 不是 memset 整个数组 */
void clearUsed() {
for (int i = 0; i <= tot; i++) {
memset(ch[i], 0, sizeof(ch[i]));
cntPass[i] = cntEnd[i] = 0;
}
tot = 0;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int T;
cin >> T;
while (T--) {
int n, q;
cin >> n >> q;
for (int i = 0; i < n; i++) {
string s;
cin >> s;
insertWord(s);
}
for (int i = 0; i < q; i++) {
string p;
cin >> p;
pair<int, int> r = ask(p);
cout << r.first << ' ' << r.second << '\n';
}
clearUsed();
}
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

6动画一:树自己长出来

★ 盯两件事:① 插到一半发现「这条边已经有了」就直接走过去 —— 前缀就是这么被共用掉的; ② 每个节点上那两个数是分开涨的。

默认那一组(apple / app / apply / banana)一共 19 个字符, 可树上只长出 12 条边 —— 有 7 个字符是共用的,这就是 Trie 省下来的东西。

★ 换到「查一个不存在的」那一组,看它走到一半没路了是怎么停的。

字典树:单词一个个插进去,每个节点上写着「路过 / 结尾」两个数
第 1 / 30 步
节点数 1
正在插
答案
一开始树上只有一个根,它代表「空前缀」—— 每个单词都从这儿出发。

7⚠⚠ 第二个考点:这棵树占多少内存

账很好算:ch[节点数][26],一个 int 4 字节 ⇒ 26 × 节点数 × 4 字节

可「节点数」不是个常数。Trie 的节点数 = 所有单词的不同前缀的个数 —— 而那取决于数据长什么样

mem.cpp同样的总长,四种形状,看节点数差多少
./mem long / rand26 / rand3 / share 可以单独跑一种,那时还会打印本进程的峰值内存。
// ★★ 这一章的第二个考点:Trie 到底占多少内存 —— 而且它取决于数据长什么样
//
// 账很好算:`ch[节点数][26]`,一个 int 4 字节 ⇒ **26 × 节点数 × 4 字节**。
// 可「节点数」不是常数:**Trie 的节点数 = 所有单词的不同前缀的个数**。
// · 随机 26 字母的单词几乎不共享前缀 ⇒ 节点数几乎等于总长,内存顶满;
// · 字母表一小、或者大家有共同的开头 ⇒ 一大片前缀被合并掉,能小几十倍。
// ⇒ **同样的总长,内存能差一个数量级** —— 这就是 Trie 真实的取舍点。
//
// 用法:
// ./mem table [总长] [seed] 三种形状各建一次,打表(节点数 / 字节数)
// ./mem <形状> [总长] [seed] 只做一种,并且打印本进程的峰值内存
// ./mem csv [总长] [seed] 同一张表,只打 `键,值`,给 check:viz 用
// 形状:long(长单词,随机 26 字母,前缀几乎不共享)/ rand26(短单词,随机 26 字母)
// / rand3(只有 3 个字母)/ share(都以同一段开头)
//
// ⚠ 峰值内存那一行要单独跑一种形状才准 —— 一个进程里连做三种,量到的是最费的那一种。
#include <bits/stdc++.h>
#include <sys/resource.h>
using namespace std;
const int MAXN = 1000005;
static int ch[MAXN][26];
static int tot;
static string padDisp(const string& s, int width) {
int disp = 0;
for (unsigned char c : s) {
if ((c & 0xC0) == 0x80) continue;
disp += (c < 0x80) ? 1 : 2;
}
return s + string(max(0, width - disp), ' ');
}
/** 造一批单词:总长约 total,形状由 shape 定 */
static vector<string> makeWords(const string& shape, int total, unsigned seed) {
mt19937 rng(seed);
vector<string> w;
const int LEN = (shape == "long") ? 40 : 8;
int K = (shape == "rand3") ? 3 : 26;
string common;
if (shape == "share") for (int i = 0; i < 6; i++) common += (char)('a' + rng() % 26);
int used = 0;
while (used < total) {
string s = common;
int rest = LEN - (int)common.size();
if (rest < 1) rest = 1;
for (int i = 0; i < rest; i++) s += (char)('a' + rng() % (unsigned)K);
used += (int)s.size();
w.push_back(s);
}
return w;
}
/** 建一棵 Trie,返回 {节点数(含根), 总长} */
static pair<long long, long long> build(const vector<string>& w) {
for (int i = 0; i <= tot; i++) memset(ch[i], 0, sizeof(ch[i]));
tot = 0;
long long len = 0;
for (const string& s : w) {
int u = 0;
len += (long long)s.size();
for (char c : s) {
int k = c - 'a';
if (!ch[u][k]) ch[u][k] = ++tot;
u = ch[u][k];
}
}
return make_pair((long long)tot + 1, len);
}
int main(int argc, char** argv) {
string cmd = (argc > 1) ? argv[1] : "table";
bool csv = (cmd == "csv");
bool table = (cmd == "table" || csv);
int total = (argc > 2) ? atoi(argv[2]) : 1000000;
unsigned seed = (unsigned)((argc > 3) ? atoi(argv[3]) : 1);
const char* SHAPES[4] = {"long", "rand26", "rand3", "share"};
if (!table) {
vector<string> w = makeWords(cmd, total, seed);
pair<long long, long long> r = build(w);
struct rusage ru;
getrusage(RUSAGE_SELF, &ru);
printf("形状 %s:%zu 个单词,总长 %lld\n", cmd.c_str(), w.size(), r.second);
printf(" Trie 节点数 %lld\n", r.first);
printf(" 26 x 节点数 x 4 %.1f MiB\n", 26.0 * (double)r.first * 4.0 / 1048576.0);
printf(" 本进程峰值内存 %.1f MiB\n", (double)ru.ru_maxrss / 1024.0);
return 0;
}
if (!csv) {
printf("总长都是 %d,只有「形状」不一样:\n\n", total);
cout << " " << padDisp("形状", 10) << padDisp("单词数", 10) << padDisp("Trie 节点数", 14)
<< padDisp("节点/总长", 12) << "26 x 节点 x 4\n";
cout << " " << padDisp(string(8, '-'), 10) << padDisp(string(8, '-'), 10)
<< padDisp(string(12, '-'), 14) << padDisp(string(10, '-'), 12) << "-------------\n";
}
for (int i = 0; i < 4; i++) {
vector<string> w = makeWords(SHAPES[i], total, seed);
pair<long long, long long> r = build(w);
double mib = 26.0 * (double)r.first * 4.0 / 1048576.0;
if (csv) {
printf("%s_nodes,%lld\n%s_words,%zu\n%s_len,%lld\n", SHAPES[i], r.first,
SHAPES[i], w.size(), SHAPES[i], r.second);
} else {
char buf[64];
cout << " " << padDisp(SHAPES[i], 10) << padDisp(to_string(w.size()), 10)
<< padDisp(to_string(r.first), 14);
snprintf(buf, sizeof(buf), "%.3f", (double)r.first / (double)r.second);
cout << padDisp(buf, 12);
snprintf(buf, sizeof(buf), "%.1f MiB", mib);
cout << buf << "\n";
}
}
if (!csv) {
printf("\n => 同样的总长,节点数差着三个数量级 —— Trie 省不省内存,全看前缀共享得多不多。\n");
printf(" => 第一行已经贴着 256 MiB 的空间限制了,而它只是「单词长一点」而已。\n");
}
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果

本机实测(总长都是 10⁶./mem table):

形状 单词数 Trie 节点数 节点 / 总长 26 × 节点 × 4
long(长单词,随机 26 字母) 25 000 938 382 0.938 93.1 MiB
rand26(短单词,随机 26 字母) 125 000 626 956 0.627 62.2 MiB
rand3(只有 3 个字母) 125 000 9 841 0.010 1.0 MiB
share(都以同一段开头) 125 000 709 0.001 0.1 MiB
⚠⚠ 同样的总长,节点数差了 1323 倍

938 382 对 709 —— 差 1324 倍,同一个总长、同一份代码,内存差三个数量级。

道理不难:随机 26 字母的长单词几乎不共享前缀,每个字符都要新开一个节点; 而共享得多的数据,一大片前缀被合并掉了。

⇒ 两句话都要记住:

  • Trie 的空间是「不同前缀的个数」,不是「单词数」,也不是「总长」
  • ★ 而最坏情况就贴在 总长 × 26 × 4 上 —— 10⁶ 的总长就是 104 MB, 直接撞穿 256 MB 的空间限制(第 45 章那节讲的 MLE)。

本机实测的峰值内存./mem long / ./mem rand26 各单独跑一次): 99.1 MiB / 69.8 MiB —— 和上面那张表算出来的字节数对得上(差的那点是存单词本身的开销)。

★ 那怎么办:三条真实的出路
办法 省下什么 代价
只开用得到的字符集(比如只有 a/b 就开 2 列) 按比例省,26 → 2 就是省 13 倍 题目得真的只有那几个字符
每个节点改用 map / 哈希存儿子 只存真正存在的边 常数大,查一次多一个 log 或哈希
换成儿子-兄弟表示法(左儿子右兄弟) 每节点只要 2 个指针 查一个字符要沿着兄弟链扫

⚠ 而静态数组的大小要按「总长 + 1」开(每个字符最多新开一个节点), 开小了就是 RE,开大了就是 MLE —— 这一步要在动笔前算,不能试。

8★★ 多组数据:清空这件事本身就是一课

多组数据之间必须把树清干净,否则上一组的计数会漏进下一组(第 10 步 ④)。 可清空的写法才是这一步真正要讲的:

   写法甲(正解):  只清用过的那 tot + 1 个节点
   写法乙:          memset(ch, 0, sizeof(ch))   —— 一次抹掉整个 104 MB

两份代码打出来的答案一个字节都不差。 可是:

clear.cpp换一把尺子:数「清空动作碰了多少格」
秒表在小数据上是 0.00 秒,而「碰了多少格」是能数出来的、换台机器也不变的东西。
// 换一把尺子:多组数据之间「清空」到底碰了多少格
//
// ★ 为什么需要它:wrongMemset.cpp 的**答案一个字节都不错**,对拍原理上看不见它
// (第 36 章第四盲区:只影响复杂度、不影响答案的写法)。
// 秒表在小数据上也全是 0.00 秒。⇒ 只剩一把尺子好使:**数动作**。
//
// 数的是同一个动作:**往 ch / cntPass / cntEnd 里写一个 int**。
// · 正解 clearUsed():只清用过的那 tot + 1 个节点 ⇒ Σ(tot_i + 1) × 28 格
// (28 = 26 个孩子 + cntPass + cntEnd)
// · wrongMemset():每组都抹掉整个数组 ⇒ T × MAXN × 28 格
//
// 用法:./clear < 输入 (和主程序同一份输入)
// ./clear csv < 输入 只打 `键,值`,给 check:viz 用
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 1000005;
static int ch[MAXN][26];
static int tot;
int main(int argc, char** argv) {
ios::sync_with_stdio(false);
cin.tie(nullptr);
bool csv = (argc > 1 && string(argv[1]) == "csv");
int T;
cin >> T;
long long usedNodes = 0;
long long totalLen = 0;
for (int t = 0; t < T; t++) {
int n, q;
cin >> n >> q;
tot = 0;
for (int i = 0; i < n; i++) {
string s;
cin >> s;
totalLen += (long long)s.size();
int u = 0;
for (char c : s) {
int k = c - 'a';
if (!ch[u][k]) ch[u][k] = ++tot;
u = ch[u][k];
}
}
for (int i = 0; i < q; i++) { string p; cin >> p; }
usedNodes += tot + 1;
for (int i = 0; i <= tot; i++) memset(ch[i], 0, sizeof(ch[i])); // 真的清一遍,下一组才干净
}
const long long PER = 28; // 26 个孩子 + cntPass + cntEnd
long long good = usedNodes * PER;
long long bad = (long long)T * MAXN * PER;
if (csv) {
printf("T,%d\nnodes,%lld\nlen,%lld\ngood,%lld\nbad,%lld\nratio,%lld\n",
T, usedNodes, totalLen, good, bad, bad / max(1LL, good));
return 0;
}
printf("%d 组数据,单词总长 %lld,一共用到 %lld 个节点\n\n", T, totalLen, usedNodes);
printf(" 只清用过的(正解) 碰 %14lld 格\n", good);
printf(" memset 整个数组 碰 %14lld 格\n", bad);
printf("\n ★ 差 %lld 倍 —— 而两份代码打出来的答案一个字节都不差。\n", bad / max(1LL, good));
printf(" ⚠ 组数越多差得越狠:memset 那一列是 T x %d x 28,和数据多大毫无关系。\n", MAXN);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

本机实测./genBig 5 5 200:200 组,每组只有 5 个单词):

清空碰的格子数 耗时 峰值内存
正解(只清用过的) 227 388 0.00 秒 4.1 MiB
memset 整个数组 5 600 028 000 0.51 秒 110.8 MiB
倍数 24 627 倍 51 倍 27 倍
⚠ 两把尺子又打架了,而且两张都得给

格子数差 24 627 倍,秒表只差 51 倍。这不是哪一个量错了 —— 「碰一格」根本不是等价的memset 是一大片连续内存,能向量化、能用非临时写, 每秒能抹几十 GB;而正解那个清空是散在各处的几百个小块

⇒ 和第 39 章那次(碰格数只差 1.60 倍、秒表差 30 倍)是同一个现场,只是方向反过来了。 格子数回答「做了多少活」(可复现、能写成断言),秒表回答「这台机器要跑多久」(不可复现)。

★ 还有第三列:memset 把整个 104 MB 真的写了一遍,于是峰值内存也上去了(110.8 MiB)。 而正解那个全局数组不碰就不占物理内存 —— 这是操作系统给的便宜,很多人不知道。

9★ 对拍:五个写错的版本,外加一份「什么都不做」

标准答案是 brute.cpp —— 它一棵树都不建,纯逐个比较,而且天然没有「多测不清空」的问题 (每组自己的 vector 各存各的)。

对拍器
★ 生成器不给档位时跑的就是最终档(第 11 步那张表里的档位 4):小字母表 + 前缀关系 + 询问真的命中 + 组间共享。
// 正解:Trie(字典树),O(总长)
//
// ★ 关键的一步只有一句话:**把一堆字符串摆成一棵「每条边是一个字符」的树**。
// 于是「以 p 为前缀的有几个」不再需要去看那 n 个单词长什么样 ——
// 只要顺着 p 的每个字符往下走 |p| 步,读出那个节点上的计数就行。
// ⇒ 查一次是 O(|p|),**和单词的数量 n 一点关系都没有**。
//
// ⚠⚠ 两个计数一定要分开,这是这一章第一个、也是最容易犯的错:
// cntPass[u] = 有多少个单词**路过**了 u —— 回答「以它为前缀的有几个」
// cntEnd[u] = 有多少个单词**正好在 u 结束** —— 回答「恰好等于它的有几个」
// 混起来的话,只要数据里没有「一个串是另一个串的前缀」就一模一样,测不出来(wrongEnd.cpp)。
//
// ⚠ 这一份是多组数据,于是有第二个坑:**组与组之间要清空**。
// 而清空的写法本身就是一课:`memset` 整个 ch[MAXN][26] 是 104 MB 的活,
// T 组就是 T 遍 —— 答案全对,但会 TLE(wrongMemset.cpp)。
// ⇒ 正确做法在下面 clearUsed():**只清用过的那 tot + 1 个节点**。
#include <bits/stdc++.h>
using namespace std;
/* 节点数上限 = 所有单词的总长 + 1(每个字符最多新开一个节点,再加一个根) */
const int MAXN = 1000005;
int ch[MAXN][26]; // ⚠ 26 × 10⁶ × 4 字节 = 104 MB,这一章第 9 步专门算这笔账
int cntPass[MAXN]; // 有多少个单词路过这个节点
int cntEnd[MAXN]; // 有多少个单词正好在这个节点结束
int tot; // 已经用掉的最大节点编号(0 号是根,代表空前缀)
void insertWord(const string& s) {
int u = 0; // 从根出发
for (char c : s) {
int k = c - 'a';
if (!ch[u][k]) ch[u][k] = ++tot; // 这条边还没有,就新开一个节点
u = ch[u][k];
cntPass[u]++; // ★ 路过就加(⚠ 根不加)
}
cntEnd[u]++; // ★ 只有走到头的那个节点才加
}
/** 返回 {以 s 为前缀的单词数, 恰好等于 s 的单词数} */
pair<int, int> ask(const string& s) {
int u = 0;
for (char c : s) {
int k = c - 'a';
if (!ch[u][k]) return make_pair(0, 0); // ⚠ 走不下去 ⇒ 一个都没有,必须立刻返回
u = ch[u][k];
}
return make_pair(cntPass[u], cntEnd[u]);
}
/** ★ 只清用过的那 tot + 1 个节点 —— 不是 memset 整个数组 */
void clearUsed() {
for (int i = 0; i <= tot; i++) {
memset(ch[i], 0, sizeof(ch[i]));
cntPass[i] = cntEnd[i] = 0;
}
tot = 0;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int T;
cin >> T;
while (T--) {
int n, q;
cin >> n >> q;
for (int i = 0; i < n; i++) {
string s;
cin >> s;
insertWord(s);
}
for (int i = 0; i < q; i++) {
string p;
cin >> p;
pair<int, int> r = ask(p);
cout << r.first << ' ' << r.second << '\n';
}
clearUsed();
}
return 0;
}
点一下即可编辑

300 轮实测(种子 1..300,最终档 4):

故意写错的地方 被抓 靠什么现形
wrongEnd:只维护结尾计数,前缀那一问也拿它答 299 / 300 单词之间有前缀关系
wrongOffByOnecntPass++ 加错位置 250 / 300 询问正好等于某个单词
wrongClear:多组之间忘了清空 290 / 300 T ≥ 2 且组间有共用前缀
wrongMiss:走不下去时没有立刻 return 192 / 300 随机就抓
wrongMemset:清空时 memset 整个数组 ★★ 0 / 300 对拍原理上抓不到它
⚠⚠ 第 ⑤ 行:它的答案永远是对的

wrongMemset.cpp 把「多测记得清空」这件事做到了,而且做得很彻底 —— 它的输出和正解逐字节相同,三百轮、三万轮都一样。

⇒ 这是第 36 章那条第四盲区在这一章的现场: 所有只影响复杂度、不影响答案的写法,对拍原理上全都抓不到。 (第 48 章的 wrongSlow 是同一个位置上的东西。)

抓它只有一条路:换尺子 —— 第 8 步那张表就是答案(24 627 倍)。

五个错误版本 + 一份试金石(点开看)
wrongEnd.cpp① 只有一个计数 —— 两问共用
wrongOffByOne.cpp③ cntPass++ 加在了往下走之前
wrongClear.cpp④ 多测不清空
wrongMiss.cpp② 走不下去没有立刻 return
wrongMemset.cpp⑤ ★ 答案永远对,只是会 TLE
wrongZero.cpp★ 试金石:每个询问都答 0 0

★ 每一份的文件头都写着「它靠什么现形」—— 那不是注释,那是下一步调生成器的依据。

10★★ 生成器:七个档位

档位 相对上一档拧了什么 ①End ②Miss ③Off ④Clear ⑤Memset ★ 试金石
0(顺手写法) 26 字母随机造单词、再随机造询问 59 134 0 23 0 59
1 ★ 让询问真的命中(从单词里截前缀) 269 64 174 17 0 292
2 ★ 字母表压到 3 个 285 132 188 112 0 296
3 ★ 直接造前缀关系 289 137 214 85 0 295
4 ★★ 最终档 ★ 组间共享(T ≥ 2,后一组抄前一组) 299 192 250 290 0 300
5 对照 = 4 − 小字母表 295 76 240 281 0 299
6 对照 = 4 − 让询问命中 279 271 141 271 0 286

⚠ 「4 − 组间共享」不用单列 —— 那就是档位 3 本身,梯子自己带着这个对照(④ 从 85 涨到 290)。

★★★ 第一行:顺手写的数据,连「什么都不做」都打不假

wrongZero.cpp 每个询问都答 0 0顺手写的那一档,它 300 轮里蒙对了 241 轮。

原因是这一章的数据有一个天然的偏心:随机造几个 26 字母的单词、再随机造几个询问串, 那个询问串几乎不可能是谁的前缀 —— 正确答案本来就是 0 0

同一行还有一个精确的 0:③ wrongOffByOne 一轮都没抓到,因为它要「询问正好等于某个单词」, 而随机串撞上这件事的概率约等于零。

调生成器的第一步不是造 bug,是先把「有答案」造出来。 档位 1 干的就是这件事,一档就把试金石从 59 抬到 292、把 ③ 从 0 抬到 174。 (第 49 章刚踩过一模一样的一次 —— 那一章是 258 轮蒙对。)

⚠⚠ 老实账:同一处改动,对 ③ 是 0 → 174,对 ② 是 134 → 64

看 ② wrongMiss 那一列:档位 0 是 134,加上「让询问真的命中」之后掉到 64; 而对照档 6(把「命中」撤掉)它冲到 271 —— 全表最高。

道理说得清:② 靠的是「询问走到一半没路了」。你让询问更容易命中,就等于让它更少走投无路。

⇒ 这是第 31 章那条「每一支都要有,而且都不能多到吃掉别人」的一次干净现场, 而且它比前几章更刺眼:同一处改动,对一个 bug 是从 0 到有,对另一个是腰斩。 账要照第 30 章那条算:0 → 174 是「从测不到变成测得到」,134 → 64 只是量的变化,所以留。

★ 小字母表那一处也一样两头有账:它把 ④ 从 17 抬到 112(对照档 5 撤掉它,② 又从 192 掉到 76)。

gen.cpp(七个档位)五个错法各要什么,文件头一条条写着

11★ 01-Trie:一个整数,也是一个字符串

第 46 章那句话是:一个整数就是一排开关。既然是一排 0 和 1, 那它就是一个只有两个字母的字符串 —— 于是它能进 Trie。

小题:给 n 个非负整数(都小于 2³⁰),求任意两个异或起来最大是多少。

输入

5
3 10 5 25 2

输出

28

5 ^ 25 = 28,是这五个数里最大的一对。

xorBrute.cpp暴力:两两枚举 O(n²)
// 01-Trie 那一节的标准答案:两两枚举,O(n²)
//
// 题面(章末那道小题):给 n 个非负整数(都小于 2³⁰),求任意两个数异或起来最大是多少。
// 输入:第一行 n,第二行 n 个整数。输出:一个数。
//
// ★ 它就是「把所有可能都试一遍」,n = 3 万时是 4.5 亿对 —— 慢,但绝对不会错。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
vector<unsigned> a(n);
for (int i = 0; i < n; i++) cin >> a[i];
unsigned best = 0;
for (int i = 0; i < n; i++)
for (int j = i + 1; j < n; j++)
best = max(best, a[i] ^ a[j]);
cout << best << '\n';
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★★ 关键的一步:每一位都贪心地往相反的那边走

把每个数按二进制位插进一棵只有两个儿子的 Trie(ch[u][0] / ch[u][1])。 ⚠ 必须从最高位开始:异或结果哪一位是 1 更值钱,完全由位号决定 —— 第 29 位的一个 1,比后面 29 位全是 1 加起来还大。

于是拿 x 去找「和它异或最大的那个」就是一路贪心:

   这一位我是 k  ->  想走 k^1(那样异或出来是 1)
                     那边有路  ->  走,这一位记 1
                     那边空着  ->  只能走同边,这一位只好是 0

后面那半句是最容易漏的(漏了就是 xorWrongGreedy.cpp): 树上不一定有你想要的那种数,走到空节点之后后面全是乱走的。

xor.cpp正解:01-Trie,O(n × 位数)
// 01-Trie:把整数按二进制位插进一棵 Trie,求最大异或对 —— O(n × 位数)
//
// ★ 这一节接的是第 46 章那句话:**一个整数就是一排开关**。
// 既然是一排 0/1,那它就是一个只有两个字母的字符串 —— 于是它能进 Trie。
// ⚠ 而且**必须从最高位开始**插、从最高位开始查:异或的结果哪一位是 1 更值钱,
// 完全由位号决定(第 30 位的一个 1,比后面 30 位全是 1 还大)。
//
// ★ 关键的一步:拿一个数 x 去树上找「和它异或最大的那个」时,
// **每一位都贪心地往相反的那边走** —— 那一位能凑出 1 就凑。
// ⚠ 但相反的那边可能空着(树上没有这样的数),那就只能走同边,这一位只好是 0。
// 这两句话缺一不可:漏了前一句就成了乱走(xorWrongOrder),漏了后一句会走到空节点(xorWrongGreedy)。
#include <bits/stdc++.h>
using namespace std;
const int BITS = 30; // 题面保证 a[i] < 2^30
const int MAXN = 100005 * (BITS + 1); // 每个数最多新开 BITS 个节点
int ch[MAXN][2];
int tot;
void insertNum(unsigned x) {
int u = 0;
for (int b = BITS - 1; b >= 0; b--) { // ★ 从最高位开始
int k = (x >> b) & 1;
if (!ch[u][k]) ch[u][k] = ++tot;
u = ch[u][k];
}
}
/** 树上已有的数里,和 x 异或最大是多少 */
unsigned askMax(unsigned x) {
int u = 0;
unsigned res = 0;
for (int b = BITS - 1; b >= 0; b--) {
int k = (x >> b) & 1;
if (ch[u][k ^ 1]) { // ★ 相反的那边有路 ⇒ 这一位能凑出 1
res |= 1u << b;
u = ch[u][k ^ 1];
} else { // ⚠ 没路,只能走同边,这一位只好是 0
u = ch[u][k];
}
}
return res;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
unsigned best = 0;
/* ★ 边插边查:第 i 个数只和「前面已经进树的那些」配对 ——
这样既不会拿自己和自己异或(那永远是 0,不影响最大值,但意思不对),
也不用先把所有数都读进来。 */
for (int i = 0; i < n; i++) {
unsigned x;
cin >> x;
if (i > 0) best = max(best, askMax(x)); // 只和「前面已经进树的那些」配对
insertNum(x);
}
cout << best << '\n';
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

本机实测(A 机,2026-08-26,独占;./xorGen 7 0 100000,也就是 n = 10⁵):

耗时 峰值内存
两两枚举 O(n²) 2.34 秒 4.3 MiB
01-Trie 0.04 秒 15.2 MiB

★ 又是同一个形状:快了 59 倍,内存多花了三倍多。

12动画二:看那条贪心的路

★ 盯右边那一列 异或凑到:一旦某一位抢到 1,后面所有位加起来都翻不过它 —— 这就是「必须从最高位开始」的全部理由。

★ 换到「都挤在低位」那一组:高位全是 0,于是前几位只能走「同」,一位都抢不到。

01-Trie:把整数当成 5 位的 0/1 串,每一位贪心往相反的那边走
第 1 / 34 步
树上已有:3(00011)
3=
0
0
0
1
1
走哪边
·
·
·
·
·
异或凑到
0
0
0
0
0
这一个数配出的最大 = 0
全场最大 = 0
把 3 也插进树里(00011,从高位往低位一位一个节点),下一个数就能和它配对了。
⚠⚠ 这一节的对拍,结论和前半章正好相反

两个错法,300 轮实测(./xorGen):

故意写错的地方 顺手档(值域 2³⁰ 对照档(值域压到 32)
xorWrongOrder:从最低位开始贪心 147 / 300 ⚠ 75 / 300
xorWrongGreedy:不检查相反那边有没有路 300 / 300 300 / 300

★ 动笔前我的预判是「② 要 n 小、值域窄,相反那边才空得出来」—— 实测把这条预判整个打掉了:它在每一档上都是 300 / 300。 30 位的树,最上面几层本来就只有一条路,随机数据一插就现形。

⇒ 于是这一节最终档就是顺手写法,而窄值域那一档只剩一个用途: 说明「① 依赖高位真的有分歧」(147 → 75)。

★★ 而这才是这一章最该带走的一句话: 同一章里两个部分,前半段(前缀统计)顺手写的数据三个 bug 藏了两个、连「什么都不做」都打不假; 后半段(01-Trie)顺手写的数据两个 bug 一个都藏不住。 ⇒ 「顺手写的生成器不行」不是定律,是一个每次都要重新问的问题。

两个错误版本 + 生成器(点开看)
xorWrongOrder.cpp① 从最低位开始贪心
xorWrongGreedy.cpp② 没检查相反那边有没有路
xorGen.cpp★ 两个档位,而最终档就是顺手写法

13这一章没讲的

⚠ 边界
没讲的 一句话
AC 自动机 Trie 和第 48 章 KMP 的合体:在 Trie 上建 fail 指针,多个模式串一起匹配。S 组进阶
可持久化 Trie 每次插入只新建一条链,于是能问「区间里和 x 异或最大」。提高组+
Trie 上跑 DP 比如「用给定单词拼出一个句子有几种拼法」,转移沿着树走
压缩 Trie / 后缀树 把只有一个儿子的链压成一条边,节点数从「总长」降到「单词数」量级

★ 顺带一句考场上的实话:单词不多的时候,map<string,int> 或者排序 + 二分就够了。 Trie 真正不可替代的场合是「要按前缀问问题」—— 排序 + 二分也能做前缀统计(前缀相同的单词在字典序里是连续一段), 但一旦要在「走的过程中」做事(DP、贪心、01-Trie 那种),就只有 Trie 了。

14自测

自测清单0 / 15
配套练习
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)
这一章记住三句话
  1. 把一堆字符串摆成一棵「每条边是一个字符」的树 —— 公共前缀只存一份, 查前缀就是顺着走 |p| 步。和单词有多少个一点关系都没有。 ⚠ 节点上要记两个数(路过 / 结尾),它们不是一回事。
  2. ⚠⚠ Trie 的第二个考点是内存26 × 节点数 × 4 字节,而节点数 = 不同前缀的个数 —— 同样是总长 10⁶,实测能从 93.1 MiB 一直缩到 0.1 MiB。 多组数据之间只清用过的节点memset 整个数组会 TLE(碰的格子差 24 627 倍), 而且对拍看不见这件事
  3. ★★★ 「顺手写的生成器不行」不是定律。 同一章里:前半段顺手数据连「什么都不做」都打不假 (300 轮蒙对 241 轮),后半段顺手数据两个 bug 一个都藏不住(一个 300 / 300)。 ⇒ 每一节都要重新问一遍,而不是背结论。