题单 · 习题解析

洛谷 P8306 【模板】字典树

★★★ 骨架[第 50 章](/ch/50-trie/)第 5 步已经写完了,而这一页的三个关卡**全躺在「数据规模与约定」那三行里**:**字符集是 62 不是 26**(`c - 'a'` 会算出 −49 = 负下标写数组,而题面专门加粗「大小写敏感」⇒ 顺手 `tolower` 一下,官方样例二一测就死)/ **内存 62 × 3×10⁶ × 4 = 709.5 MiB**(题面给 1024 MiB —— **那不是慷慨,是刚好够**,换成常见的 256 MiB 当场 MLE)/ **`T` 到 10⁵**(`memset` 清空 ⇒ **74.4 TB**,比只清用过的多 **96 774 倍**,实测 400 字节的输入照样花掉半秒、占掉 725 MiB);★★★ 而这一页最值钱的是**两个关卡完全互补**:官方样例挡住四个错法、**唯独放过「没路了不 return」**,而顺手写的对拍 300 轮**只抓得到那一个**(296/300),另外三个全是能证的精确的 0(全小写 ⇒ tolower 是恒等 / 随机长串不共享前缀 ⇒ 两个计数处处相等 / `T = 1` ⇒ 根本没有第二组)—— 三对一,比[第 49 章 P2957](/sol/p2957/) 那次的一对一干净得多;★★ 内存那张表还量出一条:**三种形状总长一样,耗时差 29 倍、内存差 164 倍,而两列几乎成正比** ⇒ **正解那 0.59 秒几乎全花在「第一次碰到那 673 MiB」上**,顶格余量只剩 1.7 倍而吃掉它的是内存不是算法;★★★ 外加一条把[第 48 章 P3375](/sol/p3375/) 翻面的:**同一句「把 `endl` 换成 `'\n'`」,关了同步值 5.4 倍、没关同步只值 1.01 倍** ⇒ **那句话的主语是「有没有关同步」**(而这张表必须配两行「一个字都不打」的对照,否则 389 毫秒里有 164 毫秒是读入的份)

原题:洛谷 P8306出自 第 50 章 Trie(字典树):一堆字符串摆成一棵树 的题单题面本地存档:2026-09-10
⚠ 先自己写一遍,再往下看

这一页不是标准答案,是一条阶梯:把大多数人真实会写出的第一版摆出来, 看它错在哪、慢在哪,再一步一步改。所以它对「已经自己动手撞过一次墙」的人最有用。
还没写过的话,先去写 —— 写出来的哪怕是错的,也比直接看这一页收获大。

0题目原文(本地存了一份)

为什么要在这儿抄一份题面

原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。

转录自洛谷 P8306,日期见页头。两边不一致时信原站。

题目描述

给定 n 个模式串 s₁, s₂, …, sₙq 次询问,每次询问给定一个文本串 tᵢ, 请回答 s₁ ~ sₙ 中有多少个字符串 sⱼ 满足 tᵢsⱼ前缀

一个字符串 ts 的前缀当且仅当从 s 的末尾删去若干个(可以为 0 个)连续的字符后与 t 相同。

输入的字符串大小写敏感。例如,字符串 Fusu 和字符串 fusu 不同。

输入格式

本题单测试点内有多组测试数据

输入的第一行是一个整数,表示数据组数 T

对于每组数据,格式如下: 第一行是两个整数,分别表示模式串的个数 n 和询问的个数 q。 接下来 n 行,每行一个字符串,表示一个模式串。 接下来 q 行,每行一个字符串,表示一次询问。

输出格式

按照输入的顺序依次输出各测试数据的答案。 对于每次询问,输出一行一个整数表示答案。

数据规模与约定

对于全部的测试点,保证 1 ≤ T, n, q ≤ 10⁵,且输入字符串的总长度不超过 3 × 10⁶。 输入的字符串只含大小写字母和数字,且不含空串。

说明:std 的 IO 使用的是关闭同步后的 cin/cout,本题不卡常。

时限 1 秒,内存 1048576 KB(= 1024 MiB)

输入输出样例

输入

3
3 3
fusufusu
fusu
anguei
fusu
anguei
kkksc
5 2
fusu
Fusu
AFakeFusu
afakefusu
fusuisnotfake
Fusu
fusu
1 1
998244353
9

输出

2
1
0
1
2
1

第一组:fusufusufusufusu 的前缀 ⇒ 2anguei 只有它自己 ⇒ 1kkksc 一个都没有 ⇒ 0

★ 第二组就是题面那句「大小写敏感」的现场:问 Fusu 只有 Fusu 一个(1), 问 fusufusufusuisnotfake 两个(2)—— 而 AFakeFusu / afakefusu 一个都不算。

1★★★ 这道模板题的骨架,本章正文已经写完了 —— 会咬人的三处全在题面的数字里

★★ 先把「这一页要讲什么」说清楚

第 50 章第 5 步那份 fast.cpp就是这道题的原型(连「多组数据」都一样)。 所以这一页不重讲 Trie 是什么,它只做一件事:把真题题面上那几行数字,一行一行地乘一遍

乘完之后有三处和正文不一样,而三处都足以让你 0 分

正文那份 这道真题 后果
字符集 26 个小写 ⚠⚠ 62 个(大小写字母 + 数字),而且大小写敏感 c - 'a' 会算出 −49(负下标写数组 = UB)
内存 26 × 10⁶ × 4 = 99.2 MiB 62 × 3×10⁶ × 4 = 709.5 MiB 题面给 1024 MiB —— 刚好够,不是慷慨
组数 几组 T10⁵ memset 清空 ⇒ 74.4 TB 的抹写

★ 而这道题只问一个数(「以 t 为前缀的有几个」)⇒ 本章那两个计数,这里只用得上 cntPasscntEnd 一次都用不到 —— ⚠ 拿错了另一个,代码一样短、一样快,只是在回答另一道题

p8306.cpp★ 正解:62 列 Trie + 只清用过的节点
// P8306【模板】字典树 —— 正解:Trie,O(总长)
//
// ★ 骨架和本章第 5 步那份 fast.cpp 一模一样,可这道**真题**上有三处要重新算一遍:
//
// ① ⚠⚠ **字符集是 62 不是 26** —— 题面写着「只含大小写字母和数字」,
// 而且专门补了一句「大小写敏感,Fusu 和 fusu 不同」。
// ⇒ 拿 `c - 'a'` 配 26 列,'0' 会算出 -49(负下标写数组 = UB),'A' 算出 -32。
// ② ⚠⚠ **内存要按 62 列重算**:3×10⁶ × 62 × 4 字节 = **709.5 MiB**。
// 而这道题的内存限制是 **1048576 KB = 1024 MiB** —— 那不是慷慨,是刚好够。
// (本章正文那张表算的是 26 列 × 10⁶ = 104 MB,这里是它的 7.15 倍。)
// ③ ⚠ **多测**:T 可以到 10⁵,而 memset 整个 ch 是 709.5 MiB 的活 ——
// T 遍就是 71 TB。⇒ 只清用过的那 tot + 1 个节点(clearUsed)。
//
// ★ 而这道题只问一个数:「有多少个 sⱼ 以 t 为前缀」= cntPass。
// cntEnd 在这道题上一次都用不到 —— 本章那两个计数,这里只用了一个。
#include <bits/stdc++.h>
using namespace std;
/* 节点数上限 = 所有字符串的总长 + 1(每个字符最多新开一个节点,再加一个根) */
const int MAXN = 3000005;
const int SIGMA = 62;
int ch[MAXN][SIGMA]; // ⚠ 709.5 MiB —— 这道题给了 1024 MiB,刚好够
int cntPass[MAXN]; // 有多少个字符串路过这个节点
int tot; // 已经用掉的最大节点编号(0 号是根)
/** ★ 62 个字符 → 0..61:数字 0~9、大写 10~35、小写 36~61 */
inline int idx(char c) {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'A' && c <= 'Z') return c - 'A' + 10;
return c - 'a' + 36;
}
void insertWord(const string& s) {
int u = 0;
for (char c : s) {
int k = idx(c);
if (!ch[u][k]) ch[u][k] = ++tot;
u = ch[u][k];
cntPass[u]++;
}
}
/** 以 s 为前缀的字符串有几个 */
int ask(const string& s) {
int u = 0;
for (char c : s) {
int k = idx(c);
if (!ch[u][k]) return 0; // ⚠ 走不下去 ⇒ 一个都没有,立刻返回
u = ch[u][k];
}
return cntPass[u];
}
/** ★ 只清用过的那 tot + 1 个节点 —— 不是 memset 整个数组 */
void clearUsed() {
for (int i = 0; i <= tot; i++) {
memset(ch[i], 0, sizeof(ch[i]));
cntPass[i] = 0;
}
tot = 0;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int T;
if (!(cin >> T)) return 0;
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; cout << ask(p) << '\n'; }
clearUsed();
}
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

2第一版:一棵树都不建,挨个比一遍

所有人真实的第一反应,而且它没有任何一个 Trie 特有的坑:不用管字符集多大、不用算内存, 多测之间各存各的 vector 也天然不用清空。⇒ 正因为这样,它才配当对拍的标准答案。

p8306Brute.cpp✗ 第一版:每次询问把 n 个串挨个比一遍
⚠ 它慢在哪 —— 一句乘法就问完了

一次询问要看 n 个串一眼 ⇒ 顶格 n = q = 10⁵ 就是 10¹⁰ 次「看一眼」, 而 Trie 是「每个字符走一步」,插入 + 查询各一遍 ⇒ 3 × 10⁶ 步。

要做多少次基本动作(顶格 n = q = 10⁵,串长 15)
✗ 挨个比 n × q = 10 000 000 000
★ Trie (n + q) × 15 = 3 000 000
倍数 3333 倍

本机实测(把规模缩到 n = q = 10⁴,也就是 1% 的工作量):挨个比 0.21 秒、Trie 0.02 秒 ⇒ 按 O(n²) 外推回顶格是 21 秒,而时限 1 秒。

⚠ 而这道题一个部分分档都没有(题面只有「对于全部的测试点」那一行) ⇒ 第一版一分都拿不到

3★★★ 第一个关卡:字符集是 62,而且大小写敏感

⚠⚠ `c - 'a'` 在这道题上是负下标

本章正文那份代码里写的是 int k = c - 'a';,配 ch[MAXN][26]。搬到这道题上:

   'a' - 'a' =   0     ✓
   'A' - 'a' = -32     ✗ 负下标写数组 —— UB
   '0' - 'a' = -49     ✗ 同上

⇒ 这不是 WA,是未定义行为:它可能给出一个荒唐的答案,也可能一声不吭地跑对 (第 47 章 P1598 上撞过一模一样的事:cnt[-19] 写出去了,答案却完全正确)。 编译器不会说一个字-Wall 也不会。

★ 62 个字符怎么编号 —— 三段拼一拼就完了
   数字 '0'..'9'  →  0 .. 9
   大写 'A'..'Z'  → 10 .. 35
   小写 'a'..'z'  → 36 .. 61

千万别顺手 tolower 一下:题面专门加粗写着「大小写敏感,Fusufusu 不同」—— 而官方样例的第二组数据fusu / Fusu / AFakeFusu / afakefusu / fusuisnotfake) 就是照着这一条挑的,一测就死

p8306Nocase.cpp✗ 错法①:先 tolower 再进树(36 列)

4★★ 第二个关卡:这棵树占多少内存 —— 而 1024 MiB 不是慷慨,是刚好够

p8306Count.cpp★ 三笔账:内存、清空、次数(全是机器无关的量)
./p8306Count input < 一份真输入 还能报出那份数据的名单字符数和 Trie 峰值节点数。
// P8306 的三笔账:内存、清空、次数 —— 全部用**机器无关**的量来数
//
// 用法:./p8306Count csv (给 check-viz.mjs 读的 key,value)
// ./p8306Count table (给人看的表)
//
// ★ 为什么这一份不真去建那棵 62 叉树:**用不着**。
// Trie 的节点数 = 所有字符串的**不同前缀个数**,而把字符串排好序之后
// 它就等于 `Σ (len_i − lcp(s_i, s_{i−1}))` —— 一句话就数完了,
// 不用为了数一个数去申请 709.5 MiB。
// ⇒ 而这也正好是本章第 7 步那句话的另一种说法:**空间是「不同前缀的个数」**。
#include <bits/stdc++.h>
using namespace std;
static mt19937 rng(20260910u);
static int ri(int lo, int hi) { return lo + (int)(rng() % (unsigned)(hi - lo + 1)); }
static const char* AL62 =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
/** 节点数 = Σ (len_i − lcp(排序后相邻两个)) */
static long long trieNodes(vector<string> v) {
sort(v.begin(), v.end());
long long nodes = 0;
string prev;
for (const string& s : v) {
size_t l = 0;
while (l < s.size() && l < prev.size() && s[l] == prev[l]) l++;
nodes += (long long)s.size() - (long long)l;
prev = s;
}
return nodes;
}
/** 造总长为 total 的一批串:shape = long / short / low3 / share */
static vector<string> makeShape(const string& shape, long long total) {
vector<string> v;
long long used = 0;
while (used < total) {
string s;
if (shape == "long") { // 长串、62 个字符随便抽
int len = (int)min<long long>(15, total - used);
for (int i = 0; i < len; i++) s += AL62[ri(0, 61)];
} else if (shape == "short") { // 短串、62 个字符
int len = (int)min<long long>(4, total - used);
for (int i = 0; i < len; i++) s += AL62[ri(0, 61)];
} else if (shape == "low3") { // 只有 3 个字母
int len = (int)min<long long>(15, total - used);
for (int i = 0; i < len; i++) s += (char)('a' + ri(0, 2));
} else { // share:都以同一段开头
int len = (int)min<long long>(15, total - used);
s = string("prefixprefix").substr(0, min<size_t>(12, (size_t)len));
while ((int)s.size() < len) s += AL62[ri(0, 61)];
}
used += (long long)s.size();
v.push_back(s);
}
return v;
}
/** ★ 从 stdin 读一份**真的输入**,报「名单一共多少字符 / Trie 多少节点 / 62 列要多少 MiB」 */
static int fromInput() {
int T;
if (!(scanf("%d", &T) == 1)) return 0;
long long chars = 0, nodes = 0, words = 0, maxNodes = 0;
static char buf[100005];
for (int t = 0; t < T; t++) {
int n, q;
if (scanf("%d %d", &n, &q) != 2) break;
vector<string> v;
for (int i = 0; i < n; i++) { if (scanf("%100004s", buf) != 1) break; v.push_back(buf); }
for (int i = 0; i < q; i++) { if (scanf("%100004s", buf) != 1) break; }
for (const string& s : v) chars += (long long)s.size();
words += (long long)v.size();
long long here = trieNodes(v);
nodes += here;
maxNodes = max(maxNodes, here); // ⚠ 多组之间树是清空的 ⇒ **峰值**是各组的最大值,不是总和
}
printf("words,%lld\nchars,%lld\nnodes,%lld\nmaxnodes,%lld\nmib,%.1f\n",
words, chars, nodes, maxNodes, (double)maxNodes * 62 * 4 / 1048576.0);
return 0;
}
int main(int argc, char** argv) {
string mode = argc > 1 ? argv[1] : "table";
if (mode == "input") return fromInput();
const long long TOTAL = 3000000; // 题面顶格:输入字符串总长 3×10⁶
const long long MAXN = 3000005, SIGMA = 62;
vector<pair<string, string>> out;
auto put = [&](const string& k, const string& v) { out.push_back({k, v}); };
auto num = [](long long x) { return to_string(x); };
/* ① 内存账:四种形状,节点数差多少 */
for (const string& sh : {string("long"), string("short"), string("low3"), string("share")}) {
vector<string> v = makeShape(sh, TOTAL);
long long nodes = trieNodes(v);
put(sh + "_words", num((long long)v.size()));
put(sh + "_nodes", num(nodes));
char buf[64];
snprintf(buf, sizeof(buf), "%.1f", (double)nodes * SIGMA * 4 / 1048576.0);
put(sh + "_mib", buf);
}
{
char buf[64];
snprintf(buf, sizeof(buf), "%.1f", (double)MAXN * SIGMA * 4 / 1048576.0);
put("array_mib", buf); // 静态数组本身开多大
put("limit_mib", num(1048576 / 1024)); // 题面给的 1048576 KB
snprintf(buf, sizeof(buf), "%.1f", (double)1000000 * 26 * 4 / 1048576.0);
put("chapter_mib", buf); // 本章正文那张表:26 列 × 10⁶
snprintf(buf, sizeof(buf), "%.2f",
((double)MAXN * SIGMA) / (1000000.0 * 26));
put("vs_chapter", buf); // 这道真题是正文那笔账的几倍
}
/* ② 清空账:顶格 T = 10⁵ 组,每组平均只有 30 个字符 */
{
long long T = 100000;
long long perGroup = TOTAL / T; // 每组 30 个字符 ⇒ 至多 30 个节点
long long goodCells = T * (perGroup + 1) * (SIGMA + 1); // 只清用过的:(tot+1) × (62 列 + 1 个计数)
long long badCells = T * MAXN * (SIGMA + 1); // memset 整个数组
put("clear_T", num(T));
put("clear_good", num(goodCells));
put("clear_bad", num(badCells));
put("clear_ratio", num(badCells / goodCells));
char buf[64];
snprintf(buf, sizeof(buf), "%.1f", (double)T * MAXN * SIGMA * 4 / 1e12);
put("clear_tb", buf); // memset 一共抹写多少 TB
}
/* ③ 次数账:顶格一组 n = q = 10⁵、串长 15 —— 暴力比几个字符,Trie 走几步 */
{
long long n = 100000, q = 100000, len = 15;
put("brute_steps", num(n * q)); // 每次询问至少要看 n 个串一眼
put("trie_steps", num((n + q) * len)); // 每个字符走一步,插入 + 查询各一遍
put("steps_ratio", num((n * q) / ((n + q) * len)));
}
/* ④ 输出账:题面一个字都没提,可它是真的大 */
{
/* 让答案尽量多位:n = 10⁵ 个 "a",q = 10⁵ 次问 "a" ⇒ 每行 "100000\n" = 7 字节。
一组用掉 2×10⁵ 个字符,总长 3×10⁶ ⇒ 最多 15 组。 */
long long groups = TOTAL / (100000 + 100000);
long long lines = groups * 100000;
put("out_groups", num(groups));
put("out_lines", num(lines));
put("out_bytes", num(lines * 7));
char buf[64];
snprintf(buf, sizeof(buf), "%.1f", (double)lines * 7 / 1048576.0);
put("out_mib", buf);
}
if (mode == "csv") {
for (auto& kv : out) printf("%s,%s\n", kv.first.c_str(), kv.second.c_str());
} else {
for (auto& kv : out) printf(" %-16s %s\n", kv.first.c_str(), kv.second.c_str());
}
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果
★★ 静态数组要开多大:一句乘法

节点数上限 = 所有字符串的总长 + 1(每个字符最多新开一个节点)⇒ MAXN = 3×10⁶ + 5

   ch[3000005][62] × 4 字节 = 744 001 240 字节 = 709.5 MiB
   cntPass[3000005] × 4 字节 =                    11.4 MiB
   ---------------------------------------------------------
   合计                                           720.9 MiB   (限制 1024 MiB)

余量 1.42 倍。 ★ 而这正是这道题为什么给 1024 MiB —— 换成常见的 256 MiB,这个写法当场 MLE(差 2.8 倍),只能改用 map 存儿子或者换儿子-兄弟表示法 (本章第 7 步那三条出路)。

★★ 而「真的会用到多少」取决于数据长什么样(本机实测,2026-09-10)

四种形状,输入字符串总长都是 3 × 10⁶(名单和询问各占多少不同):

形状 名单里的字符数 Trie 峰值节点数 算出来的 62 列 实测峰值内存 实测耗时
fat(3×10⁶ 个字符全在名单里 3 000 000 2 785 204 658.7 MiB 673.6 MiB 0.59 秒
long(名单和询问对半) 1 500 000 1 285 180 304.0 MiB 313.0 MiB 0.25 秒
share(都以同一段长前缀开头) 1 500 000 85 650 20.3 MiB 24.6 MiB 0.02 秒
many(10⁵ 个小组,每组 30 个字符) 1 500 000 15 0.0 MiB 4.1 MiB 0.03 秒

★ 算出来的 658.7 MiB 和实测的 673.6 MiB 差着 15 MiB,而那 15 MiB 是能对上账的: cntPass 被碰到的那 2 785 204 格是 10.6 MiB,进程起步本身 4.1 MiBmany 那一行) ⇒ 658.7 + 10.6 + 4.1 = 673.4

⚠⚠ 而最后一列最值钱:三行数据的总长一模一样,耗时差 29 倍、内存差 164 倍, 而两列几乎成正比(约 0.85 毫秒每 MiB) ⇒ ★★★ 这道题的正解,时间几乎全花在「第一次碰到那 673 MiB」上 —— fat 那 0.59 秒里,真正在走 Trie 的部分连零头都不到。 ⇒ 顶格余量只剩 1.7 倍,而吃掉它的是内存,不是算法。

★ 顺带把本章第 7 步那张表对了一遍

正文量的是「26 列 × 总长 10⁶」= 99.2 MiB(正文写的 104 是 MB,这里是 MiB,同一个数)。 这道真题是 62 列 × 3×10⁶7.15 倍

⇒ ★★ 「正文用过这道题」不等于「解析页没得写」 —— 要把题面上每一行数字对一遍 (第 46 章 P1469 那条:正文把题面改小了 32 倍, 于是正文里写着「✓ 能过」的版本在真题上是个 MLE)。

5★★★ 第三个关卡:多测清空 —— 而 memset 那一版的答案一个字节都不差

p8306Memset.cpp✗ 错法⑤:memset 整个数组(答案全对,只是跑不完)
⚠⚠ 74.4 TB —— 而它和「这一组数据有多大」毫无关系
   sizeof(ch) = 3000005 × 62 × 4 = 744 001 240 字节 = 709.5 MiB
   T 最大 10⁵                     ⇒  74.4 TB 的抹写
清空动作碰了多少格(顶格 T = 10⁵ 峰值内存 400 字节的输入上实测
★ 正解(只清用过的 tot+1 个) 195 300 000 4.1 MiB 0.00 秒
memset 整个数组 18 900 031 500 000 725 MiB 0.51 秒
倍数 96 774 倍 177 倍

★★ 最右边那一列才是最刺眼的:输入只有 400 字节,它照样花掉半秒、占掉 725 MiB。 再往上加 30 组,耗时从 0.51 涨到 0.84 秒 ⇒ 每组约 11 毫秒 ⇒ 顶格 T = 10⁵ 大约要 1100 秒(时限 1 秒)。

★ 而正解那个全局数组不碰就不占物理内存 —— many 那一档只用 4.1 MiB。 这是操作系统给的便宜,很多人不知道。

★★★ 这一份是「对拍是聋的」的现场

它和正解逐字节相同。300 轮也好、三万轮也好,对拍那一列永远是精确的 0

⇒ 唯一的出路是换一把尺子数「清空动作碰了多少格」(上面那张表的第一列)。 ★ 而那把尺子是机器无关的 —— 换台机器、换个编译器,96 774 这个数一个不变。

⚠ 顺带说清楚一件事:这一页的 300 轮对拍根本没让它跑 —— memset 一次 709.5 MiB,300 轮 × 每轮 6 组就是 1.3 TB。 ⇒ 它只在几组数据上验了「逐字节相同」,剩下的靠上面那句乘法。

p8306Noclear.cpp✗ 错法④:干脆不清空
★ 说清楚「不清空」算了什么

它算的是:前 i 组的所有字符串堆在一起之后的答案。

⇒ 于是一切都是白送的推论:第一组永远对(还没有别的组); T = 1 那一档是它结构性的精确的 0; 而官方样例第二组问 fusu 时它打出 4(把第一组的 fusufusu / fusu 也算了进去), 正解是 2 ⇒ 样例一测就死

6★ 第四个坑:走不下去的时候必须立刻停

p8306Miss.cpp✗ 错法③:没路了没有 return,悄悄掉回根

本章第 4 步那个 Callout 说过这件事:ch[u][k] 是 0,而 0 号节点就是根 —— 少了那句 return 0u 就掉回了根,后面的字符又从根往下走。 ⇒ ★ 它答出来的是 t 的某个后缀的答案。

⚠⚠ 而官方样例唯一放过的,正好就是它

把五个错法在官方样例上各跑一遍:

Nocase① End② Miss③ Noclear④ 试金石(恒输出 0)
官方那三组样例 放过
对拍的「顺手档」(300 轮) 0 0 296 0 1

⇒ ★★★ 两个关卡完全互补:样例挡住的三个,顺手写的对拍一个都抓不到; 而顺手写的对拍唯一抓得住的那个,正好是样例唯一放过的。

★ 样例为什么放过它:那三组数据里的每个询问,要么整条路都走得通fusuFusu9), 要么第一个字符就没路kkksc)—— 而这个 bug 要的是「走到中间才断, 而断掉之后剩下那一截又恰好在树上」。样例里一次都没出现。

⇒ 这是第 49 章 P2957那条「两个关卡各被一样东西挡住」的更干净版本: 那次是一对一,这次是三对一,而且完全互补

7★ 对拍:五个错法 + 一份「什么都不做」

p8306End.cpp✗ 错法②:记成 cntEnd(把「前缀」读成「相等」)
p8306Zero.cpp★ 试金石:一律输出 0
p8306Gen.cpp★ 生成器:五个档位,每一档只拧一个旋钮
★★★ 300 轮 × 五档(本机实测,2026-09-10)
档位 ①Nocase ②End ③Miss ④Noclear ⑤Memset 试金石
0 顺手写法T = 1、全小写、询问也是随机串) 0 0 296 0 0 1
1 + 多组数据(T = 3~6 0 2 300 7 0 2
2 + 只差大小写的复制品 + 数字 300 2 300 1 0 300
3 + 从短前缀派生 + 一半询问命中 0 300 283 223 0 300
4 最终档 = 1 + 2 + 3 300 300 241 221 0 300

★★★ 第一行是这张表的全部价值:顺手写的那一档,四个真错法里三个是精确的 0, 而「什么都不做」300 轮只被抓 1 次。

⇒ 三个 0 各有一行不用跑程序的证明:

  • ①Nocase:全小写 ⇒ tolower 是恒等变换;
  • ②End:随机长串几乎不共享前缀 ⇒ cntPasscntEnd 处处相等;
  • ④Noclear:T = 1根本没有第二组(结构性的 0,加多少轮都没用)。

⚠ 而试金石那个 1 更狠:顺手写的询问串本来就不在名单里,正解的答案本来就是 0 —— ⇒ 「一致有两种:都算对了,和都没算」在这一页是「都答 0」。

⚠ 而「多测不清空」这一列有点反直觉:光加多组数据只值 7 / 300

档 1 已经有 3~6 组了,可 ④Noclear 只被抓 7 次。 道理和上面那个试金石是同一个:上一组漏进来的字符串,只有在「被这一组问到」的时候才会露馅, 而顺手写的询问串谁也问不中。

⇒ 档 3 把「一半询问取自名单里某个串的前缀」加上去,同一列立刻变成 223。 ★★ 这是「抓不到时别加轮数,去想那条线在哪儿」的又一次: 那条线不是「有几组数据」,是「问得中问不中」。

8⚠ 最后一笔账:题面说「本题不卡常」—— 那句话有没有主语

p8306Io.cpp★ 读一侧四种、写一侧六种(一律 freopen 从文件读)
⚠ 两个 none 模式是对照:一模一样地做完这道题,只是一个字都不打。
// P8306 的 I/O 账:题面说「本题不卡常」—— 那句话有没有主语?
//
// 用法:./p8306Io read <sync|nosync|scanf|fread> <输入文件>
// ./p8306Io write <endl|nl|endlfast|nosync|printf|buf> <输入文件> [答案文件] [计时文件]
// (write 的六种:默认同步 + endl / 默认同步 + '\n' / **关同步 + endl** /
// 关同步 + '\n' / printf / 自写缓冲)
// ★★ 那个「关同步 + endl」不是凑数的 —— [第 48 章 P3375](/sol/p3375/) 量到
// 「endl → '\n' 值 8.8 倍」,而这道题上只值 1.1 倍。差别就藏在**有没有关同步**里。
// ⚠ 外加两个**对照**:none / nonefast —— 一模一样地做完这道题,只是**一个字都不打**。
// ★ 没有它们,这张表量的是「读 + 算 + 写」,而这道题的读入有 3×10⁶ 个 token,
// 默认 cin 在那一侧的开销比整个输出还大,会把 endl 那一项整个淹掉
// ([第 47 章 P1308](/sol/p1308/) 那条:**量什么都要先问一句「这个数里有没有别人的份」**)。
// 两条都打一行 `mode,毫秒,读到的串数,读到的字节数,写出的字节数`:
// 给了「计时文件」就写进那个文件,否则写 stderr。
// ⚠ 答案本身走 stdout(默认就是屏幕)—— 给了「答案文件」就 freopen 到那儿去,
// 这样 stdout 上那 10 MiB 才不会跟着计时一起被读走。
//
// ⚠ 一律用 freopen 从**文件**读 —— 评测机就是这么干的。
// (第 47 章 [P1308] 那一跤:从 node 的管道读会多出一截**加性**开销,
// 四种读法各自多出来的都不到 1 毫秒,可占比从 8% 到 50% 上下,
// **只有最快的那一版看得见它**。)
#include <bits/stdc++.h>
#include <sys/time.h>
using namespace std;
static double nowMs() {
struct timeval tv; gettimeofday(&tv, nullptr);
return tv.tv_sec * 1000.0 + tv.tv_usec / 1000.0;
}
const int MAXN = 3000005, SIGMA = 62;
static int ch[MAXN][SIGMA];
static int cntPass[MAXN];
static int tot;
inline int idx(char c) {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'A' && c <= 'Z') return c - 'A' + 10;
return c - 'a' + 36;
}
/* ---- 自写缓冲:读 ---- */
static char inbuf[1 << 22];
static size_t inLen = 0, inPos = 0;
static inline int gc() {
if (inPos == inLen) { inLen = fread(inbuf, 1, sizeof(inbuf), stdin); inPos = 0; if (!inLen) return -1; }
return inbuf[inPos++];
}
static bool readTokenFast(string& s) {
int c = gc();
while (c == ' ' || c == '\n' || c == '\r') c = gc();
if (c < 0) return false;
s.clear();
while (c > ' ') { s += (char)c; c = gc(); }
return true;
}
/* ---- 自写缓冲:写 ---- */
static char obuf[1 << 22];
static size_t oPos = 0;
static inline void flushOut() { fwrite(obuf, 1, oPos, stdout); oPos = 0; }
static inline void writeIntFast(int x) {
if (oPos + 16 > sizeof(obuf)) flushOut();
if (!x) obuf[oPos++] = '0';
else { char t[12]; int k = 0; while (x) { t[k++] = (char)('0' + x % 10); x /= 10; } while (k) obuf[oPos++] = t[--k]; }
obuf[oPos++] = '\n';
}
int main(int argc, char** argv) {
string what = argc > 1 ? argv[1] : "read";
string mode = argc > 2 ? argv[2] : "nosync";
if (argc > 3 && !freopen(argv[3], "r", stdin)) return 1;
if (argc > 4 && !freopen(argv[4], "w", stdout)) return 1;
const char* statPath = argc > 5 ? argv[5] : nullptr;
long long strs = 0, bytes = 0, outBytes = 0;
auto report = [&](const char* m, double ms) {
FILE* f = statPath ? fopen(statPath, "w") : stderr;
if (!f) return;
fprintf(f, "%s,%.1f,%lld,%lld,%lld\n", m, ms, strs, bytes, outBytes);
if (statPath) fclose(f);
};
double t0 = nowMs();
if (what == "read") {
/* 只读,什么都不算 —— 量的是纯读入 */
if (mode == "sync" || mode == "nosync") {
if (mode == "nosync") { ios::sync_with_stdio(false); cin.tie(nullptr); }
string s;
while (cin >> s) { strs++; bytes += (long long)s.size(); }
} else if (mode == "scanf") {
static char buf[10005];
while (scanf("%10004s", buf) == 1) { strs++; bytes += (long long)strlen(buf); }
} else {
string s;
while (readTokenFast(s)) { strs++; bytes += (long long)s.size(); }
}
report(mode.c_str(), nowMs() - t0);
return 0;
}
/* what == write:真做一遍这道题,只换「怎么把答案打出去」 */
if (mode != "endl" && mode != "nl" && mode != "none") { ios::sync_with_stdio(false); cin.tie(nullptr); }
int T;
if (!(cin >> T)) return 0;
while (T--) {
int n, q; cin >> n >> q;
for (int i = 0; i < n; i++) {
string s; cin >> s; strs++; bytes += (long long)s.size();
int u = 0;
for (char c : s) { int k = idx(c); if (!ch[u][k]) ch[u][k] = ++tot; u = ch[u][k]; cntPass[u]++; }
}
for (int i = 0; i < q; i++) {
string p; cin >> p; strs++; bytes += (long long)p.size();
int u = 0, ans = 0;
bool ok = true;
for (char c : p) { int k = idx(c); if (!ch[u][k]) { ok = false; break; } u = ch[u][k]; }
if (ok) ans = cntPass[u];
{ int x = ans; int d = 1; while (x >= 10) { x /= 10; d++; } outBytes += d + 1; }
if (mode == "none" || mode == "nonefast") { /* 对照:什么都不打 */ }
else if (mode == "endl" || mode == "endlfast") cout << ans << endl;
else if (mode == "printf") printf("%d\n", ans);
else if (mode == "buf") writeIntFast(ans);
else cout << ans << '\n';
}
for (int i = 0; i <= tot; i++) { memset(ch[i], 0, sizeof(ch[i])); cntPass[i] = 0; }
tot = 0;
}
if (mode == "buf") flushOut();
cout.flush();
fflush(stdout);
report(mode.c_str(), nowMs() - t0);
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果
p8306GenBig.cpp★ 顶格生成器:fat / long / share / many / out 五种形状
★ 读这一侧:3 MB,关同步值 9.0 倍(而绝对值只有 31 毫秒)
读 3 000 011 个字符(fat 那一档,10⁵ 个串) 毫秒(3 次取中位数)
默认 cin 31.5
关同步 cin 3.5
scanf 5.6
自写缓冲 6.8

⇒ 倍数是 9.0 倍,可最慢的那一种也只吃掉时限的 3.2%。 ★ 倍数跨题几乎不变,变的是绝对时间,而分数线在绝对时间上第 38 章 P1972 那条)—— 这道题的读入是真的不卡常。

★★★ 写这一侧:输出顶格 10.0 MiB,而「endl 值 8.8 倍」这句话在这儿翻面了

先算输出能有多大 —— 题面一个字都没提: 让每行答案都是 6 位数(n = 10⁵aq = 10⁵ 次问 a),一组用掉 2×10⁵ 个字符 ⇒ 总长 3×10⁶ 最多摆 15 组1 500 000 行 × 7 字节 = 10 500 000 字节(10.0 MiB)

本机实测(3 次取中位数,out 那一档):

打法 端到端 减去对照 = 净写
对照:默认同步、一个字都不打 164
对照:关同步、一个字都不打 50
默认同步 + endl 389 225
默认同步 + '\n' 386 222
关同步 + endl 265 215
关同步 + '\n' 90 40
printf 95 45
自写缓冲 64 14

⇒ ★★★ 同一句「把 endl 换成 '\n'」:关了同步值 5.4 倍,没关同步只值 1.01 倍。第 48 章 P3375 上量到的是 8.8 倍 —— ⇒ ★★ 那句话的主语是「你有没有关同步」,不是「这道题输出多大」。

★ 机理说得清:endl'\n' 多做的事只有一件 —— 每行冲一次缓冲, 而「冲一次」贵不贵,取决于那个缓冲里攒着多少东西。 关了同步,cout 自己攒着一大块,每行冲一次就等于把它拆成 150 万次小写; 同步开着的时候 cout 根本没在攒(每个字符直接交给 stdio),所以没什么可冲的 —— 这一点从表里也读得出来:同步开着时光是 '\n' 那一版就已经比关同步慢 5.6 倍

⚠⚠ 而这张表必须有那两行对照:不减掉它们,「默认同步 + endl」那 389 毫秒里 有 164 毫秒是读入的份 —— 那是第 47 章 P1308 那条 「量什么都要先问一句:这个数里有没有别人的份」的第四次现场 (前三次是 P3366 的 19 毫秒读入、P1469 的进程起步、P1308 的管道)。

9★ 哪一版就已经能过了

★★ 结论:只有正解能过,而它的余量也只有 1.7 倍
版本 能过吗 为什么
✗ 挨个比 一分不给 顶格 10¹⁰ 次「看一眼」,外推 21 秒;⚠ 这道题一个部分分档都没有
memset 清空 一分不给 答案全对,T = 10⁵ 要 74.4 TB 的抹写(约 1100 秒)
✗ 26 列 / tolower 0 分 前者是负下标(UB),后者样例二一测就死
正解 顶格 fat 0.59 秒 / 1 秒673.6 MiB / 1024 MiB

⚠ 两个余量都不大(1.7 倍 / 1.5 倍),而吃掉它们的是同一件事:那 673 MiB。 ⇒ 想要更宽的余量,办法在本章第 7 步那张表里(map 存儿子 / 儿子-兄弟表示法), 代价是查一次多一个 log 或一条兄弟链 —— 这道题用不着,但要知道它在那儿。

★ 一句话带走

这道模板题真正在考的,是「把题面上那几行数字乘一遍」的习惯 —— Trie 本身第 50 章已经写完了,而这一页的三个关卡(62 列 / 709.5 MiB / T = 10⁵) 全部躺在「数据规模与约定」那三行里,一行都不在算法里。