1一句话问题
多组数据:第一行一个
T,接下来T组。每组第一行两个数
n和q;接下来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
第一组:app 是 apple / 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
ca 是 cat 的前缀(1 0);z 一个都不沾;catx 比 cat 还长,走到一半就没路了。
两条都是第 35 章那句「题面多问一句,对拍就多一条腿」:
- 多问「恰好等于的有几个」 ⇒ 逼你把 Trie 上的两个计数分开 (路过 / 结尾)。只问前缀的话,把两个计数混成一个的写法能一路蒙过去。
- 做成多组数据 ⇒ 收得到「多测不清空」这个坑,以及「清空该怎么写」这一课 —— 它们是 Trie 在考场上真实的翻车点,而单组数据的题面根本收不到。
2暴力:每次询问把所有单词挨个比一遍
// 标准答案:一棵树都不建,每次询问把 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;}点「运行 ▶」看结果
它是这道题最直白的翻译,也是这一章的标准答案 —— ★ 它和正解没有一个共同的想法: 正解那边一次字符串比较都不做,只是顺着一棵树往下走。
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 真正的取舍点。
4★ 关键的一步:把一堆字符串摆成一棵树
暴力慢在哪儿?它每次询问都要把每个单词从头看一遍,可那些单词之间明明有大量重复的开头:
apple、app、apply 的前三个字符是同一段,暴力把它比了三遍。
★ 关键的一步:让公共的前缀只存一份。
把每个单词看成一条从根出发的路径,每条边是一个字符。前缀相同,就走同一段路:
(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正解
// 正解: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;}点「运行 ▶」看结果
6动画一:树自己长出来
★ 盯两件事:① 插到一半发现「这条边已经有了」就直接走过去 —— 前缀就是这么被共用掉的; ② 每个节点上那两个数是分开涨的。
默认那一组(apple / app / apply / banana)一共 19 个字符,
可树上只长出 12 条边 —— 有 7 个字符是共用的,这就是 Trie 省下来的东西。
★ 换到「查一个不存在的」那一组,看它走到一半没路了是怎么停的。
7⚠⚠ 第二个考点:这棵树占多少内存
账很好算:ch[节点数][26],一个 int 4 字节 ⇒ 26 × 节点数 × 4 字节。
可「节点数」不是个常数。Trie 的节点数 = 所有单词的不同前缀的个数 —— 而那取决于数据长什么样:
// ★★ 这一章的第二个考点: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 |
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
两份代码打出来的答案一个字节都不差。 可是:
// 换一把尺子:多组数据之间「清空」到底碰了多少格//// ★ 为什么需要它: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;}点「运行 ▶」看结果
本机实测(./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 各存各的)。
// 正解: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 | 单词之间有前缀关系 |
③ wrongOffByOne:cntPass++ 加错位置 |
250 / 300 | 询问正好等于某个单词 |
④ wrongClear:多组之间忘了清空 |
290 / 300 | T ≥ 2 且组间有共用前缀 |
② wrongMiss:走不下去时没有立刻 return |
192 / 300 | 随机就抓 |
⑤ wrongMemset:清空时 memset 整个数组 |
★★ 0 / 300 | 对拍原理上抓不到它 |
wrongMemset.cpp 把「多测记得清空」这件事做到了,而且做得很彻底 ——
它的输出和正解逐字节相同,三百轮、三万轮都一样。
⇒ 这是第 36 章那条第四盲区在这一章的现场:
所有只影响复杂度、不影响答案的写法,对拍原理上全都抓不到。
(第 48 章的 wrongSlow 是同一个位置上的东西。)
抓它只有一条路:换尺子 —— 第 8 步那张表就是答案(24 627 倍)。
★ 每一份的文件头都写着「它靠什么现形」—— 那不是注释,那是下一步调生成器的依据。
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 轮蒙对。)
看 ② wrongMiss 那一列:档位 0 是 134,加上「让询问真的命中」之后掉到 64;
而对照档 6(把「命中」撤掉)它冲到 271 —— 全表最高。
道理说得清:② 靠的是「询问走到一半没路了」。你让询问更容易命中,就等于让它更少走投无路。
⇒ 这是第 31 章那条「每一支都要有,而且都不能多到吃掉别人」的一次干净现场, 而且它比前几章更刺眼:同一处改动,对一个 bug 是从 0 到有,对另一个是腰斩。 账要照第 30 章那条算:0 → 174 是「从测不到变成测得到」,134 → 64 只是量的变化,所以留。
★ 小字母表那一处也一样两头有账:它把 ④ 从 17 抬到 112(对照档 5 撤掉它,② 又从 192 掉到 76)。
11★ 01-Trie:一个整数,也是一个字符串
第 46 章那句话是:一个整数就是一排开关。既然是一排 0 和 1, 那它就是一个只有两个字母的字符串 —— 于是它能进 Trie。
小题:给
n个非负整数(都小于2³⁰),求任意两个异或起来最大是多少。
输入
5 3 10 5 25 2
输出
28
5 ^ 25 = 28,是这五个数里最大的一对。
// 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;}点「运行 ▶」看结果
把每个数按二进制位插进一棵只有两个儿子的 Trie(ch[u][0] / ch[u][1])。
⚠ 必须从最高位开始:异或结果哪一位是 1 更值钱,完全由位号决定 ——
第 29 位的一个 1,比后面 29 位全是 1 加起来还大。
于是拿 x 去找「和它异或最大的那个」就是一路贪心:
这一位我是 k -> 想走 k^1(那样异或出来是 1)
那边有路 -> 走,这一位记 1
那边空着 -> 只能走同边,这一位只好是 0⚠ 后面那半句是最容易漏的(漏了就是 xorWrongGreedy.cpp):
树上不一定有你想要的那种数,走到空节点之后后面全是乱走的。
// 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^30const 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;}点「运行 ▶」看结果
本机实测(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,于是前几位只能走「同」,一位都抢不到。
两个错法,300 轮实测(./xorGen):
| 故意写错的地方 | 顺手档(值域 2³⁰) |
对照档(值域压到 32) |
|---|---|---|
① xorWrongOrder:从最低位开始贪心 |
147 / 300 | ⚠ 75 / 300 |
② xorWrongGreedy:不检查相反那边有没有路 |
★ 300 / 300 | ★ 300 / 300 |
★ 动笔前我的预判是「② 要 n 小、值域窄,相反那边才空得出来」—— 实测把这条预判整个打掉了:它在每一档上都是 300 / 300。 30 位的树,最上面几层本来就只有一条路,随机数据一插就现形。
⇒ 于是这一节最终档就是顺手写法,而窄值域那一档只剩一个用途: 说明「① 依赖高位真的有分歧」(147 → 75)。
★★ 而这才是这一章最该带走的一句话: 同一章里两个部分,前半段(前缀统计)顺手写的数据三个 bug 藏了两个、连「什么都不做」都打不假; 后半段(01-Trie)顺手写的数据两个 bug 一个都藏不住。 ⇒ 「顺手写的生成器不行」不是定律,是一个每次都要重新问的问题。
13这一章没讲的
| 没讲的 | 一句话 |
|---|---|
| AC 自动机 | Trie 和第 48 章 KMP 的合体:在 Trie 上建 fail 指针,多个模式串一起匹配。S 组进阶 |
| 可持久化 Trie | 每次插入只新建一条链,于是能问「区间里和 x 异或最大」。提高组+ |
| Trie 上跑 DP | 比如「用给定单词拼出一个句子有几种拼法」,转移沿着树走 |
| 压缩 Trie / 后缀树 | 把只有一个儿子的链压成一条边,节点数从「总长」降到「单词数」量级 |
★ 顺带一句考场上的实话:单词不多的时候,map<string,int> 或者排序 + 二分就够了。
Trie 真正不可替代的场合是「要按前缀问问题」——
排序 + 二分也能做前缀统计(前缀相同的单词在字典序里是连续一段),
但一旦要在「走的过程中」做事(DP、贪心、01-Trie 那种),就只有 Trie 了。
14自测
- 洛谷 P8306 【模板】字典树 —— ★ 就是这一章那道题的原题,而且也是多组数据 —— 「多测不清空」这个坑它是真会卡人的
- 洛谷 P2580 于是他错误的点名开始了 —— 入门难度:查一个名字有没有出现过、是不是第二次点到。★ 正好练「结尾计数」那一个数
- 洛谷 P4551 最长异或路径 —— ★★ 01-Trie 的经典题:先把每个点到根的异或和求出来,问题就变成本章那道小题了
- 洛谷 P10471 最大异或对 The XOR Largest Pair —— 就是本章第 11 步那道小题的原题,n 到 10⁵ —— 两两枚举正好过不了
- 洛谷 P3879 [TJOI2010] 阅读理解 —— ⚠ 每个单词要记「在哪些文章里出现过」。★ 它逼你想清楚:节点上除了计数,还能挂别的东西
- 洛谷 P2922 [USACO08DEC] Secret Message —— ⚠ 提高组:两个方向的计数一起用(路过 + 结尾)。★ 本章那两个数在这道题里同时登场
- ★ 把一堆字符串摆成一棵「每条边是一个字符」的树 —— 公共前缀只存一份,
查前缀就是顺着走
|p|步。和单词有多少个一点关系都没有。 ⚠ 节点上要记两个数(路过 / 结尾),它们不是一回事。 - ⚠⚠ Trie 的第二个考点是内存:
26 × 节点数 × 4字节,而节点数 = 不同前缀的个数 —— 同样是总长10⁶,实测能从 93.1 MiB 一直缩到 0.1 MiB。 多组数据之间只清用过的节点,memset整个数组会 TLE(碰的格子差 24 627 倍), 而且对拍看不见这件事。 - ★★★ 「顺手写的生成器不行」不是定律。 同一章里:前半段顺手数据连「什么都不做」都打不假 (300 轮蒙对 241 轮),后半段顺手数据两个 bug 一个都藏不住(一个 300 / 300)。 ⇒ 每一节都要重新问一遍,而不是背结论。