题单 · 习题解析

洛谷 P3879 [TJOI2010] 阅读理解

★★★ 这一章题单第四道,节点上挂的东西又换了 —— 计数 → 三态 → 什么都不挂 → **一串文章号**,而这是第一次挂上去的东西**长度不定**,于是它捅出了一笔全新的账:**内存**;★★★ 照抄本章那份 26 叉静态数组会 **MLE**(顶格 `fat` 档实测峰值 **533.3 MiB / 限制 512 MiB**,而它的答案**一个字节都不差** ⇒ [第 46 章 P1469](/sol/p1469/) 那条:对拍验的是「算得对不对」,从来不验「装不装得下」,四档 1200 轮那一列全是精确的 0);⇒ ★★ 这道题是[本章第 7 步](/ch/50-trie/)那三条出路**第一次真的用得上**的地方:**儿子-兄弟表示法**(每节点 104 字节 → **13 字节**,省 8 倍),而它反而更快(0.31 vs 0.48 秒)—— **省下来的 466 MiB 缺页比多走的那几步贵得多**;★★★ 而这一页自己撞出一条给本章第 8 步补边界的:**「全局数组不碰就不占物理内存」只对平凡类型成立** —— `vector<int> where_[4760005]` 有构造函数 ⇒ 476 万个 vector 在 `main` 之前被逐个构造,于是树只有 **26** 个节点的那一档,它照样吃掉 **113.1 MiB**(= 108.9 的 vector 数组 + 4.2 的进程起步,一分不差);★★★ 第三条:**「顶格」在这道题上有两个相反的方向** —— 每篇 2500 个**单字母**词让暴力最难受(**79.50 秒**)却让 Trie 最舒服(26 个节点),每篇 238 个**长度 20 的互不相同**的词把 Trie 撑爆却让暴力轻松 8 倍 ⇒ **你造哪一种,就只看得见哪一个 bug**;★★ 外加两条格式课:**③「行末多一个空格」和试金石(一律空行)四档逐格相同、交集就是全部**(两行能证:触发条件都是「至少有一行不是空的」—— 比[第 46 章 P2114](/sol/p2114/) 那次更干净,而[第 47 章 P1598](/sol/p1598/) 那次是两列都 284、交集只有 268),以及**去掉行末空格再比 ⇒ 那一列 243/299/227/295 当场全变 0**;⚠ 官方样例只挡得住三个错法里的一个,而「忘了去重」那个放过得最有意思:样例第一篇里**确实**有 `ha ha`,**可五个询问词一个都不是 `ha`** ⇒ **数据里有那个结构,不等于那个结构被问到了**

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

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

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

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

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

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

题目描述

英语老师留了 N 篇阅读理解作业,但是每篇英文短文都有很多生词需要查字典, 为了节约时间,现在要做个统计,算一算某些生词都在哪几篇短文中出现过。

输入格式

第一行为整数 N,表示短文篇数,其中每篇短文只含空格和小写字母。

按下来的 N 行,每行描述一篇短文。每行的开头是一个整数 L,表示这篇短文由 L 个单词组成。 接下来是 L 个单词,单词之间用一个空格分隔。

然后为一个整数 M,表示要做几次询问。后面有 M 行,每行表示一个要统计的生词。

输出格式

对于每个生词输出一行,统计其在哪几篇短文中出现过,并按从小到大输出短文的序号, 序号不应有重复,序号之间用一个空格隔开(注意第一个序号的前面不应有空格)。 如果该单词一直没出现过,则输出一个空行

数据范围

  • 对于 30% 的数据,1 ≤ M ≤ 10³
  • 对于 100% 的数据,1 ≤ M ≤ 10⁴1 ≤ N ≤ 10³

每篇短文长度(含相邻单词之间的空格)≤ 5 × 10³ 字符,每个单词长度 ≤ 20 字符。

感谢 @钟梓俊 添加的一组数据。

时限 1 秒,内存 524288 KB(512 MiB)。

输入输出样例

输入

3
9 you are a good boy ha ha o yeah
13 o my god you like bleach naruto one piece and so do i
11 but i do not think you will get all the points
5
you
i
o
all
naruto

输出

1 2 3
2 3
1 2
3
2

you 在三篇里都出现了 ⇒ 1 2 3naruto 只在第二篇 ⇒ 2

⚠ 注意第一篇里 ha 出现了两次 —— 可这五个询问词里没有 ha样例里有那个结构,但没有被问到(第 ⑤ 步会用到这一条)。

1★ 这一章题单走到第四道,节点上挂的东西又换了

★★ 四道题,四种「挂在节点上的东西」
节点上挂什么
P8306 一个计数(路过的有几个)
P2580 一个会被改写的三态
P10471 ★ 什么都不挂(只剩「这条边在不在」)
这道题 ★★ 一串文章号

Trie 的骨架从头到尾只有一句话:让公共前缀只存一份。 剩下的每一样都是这道题自己的事 —— 而这道题挂上去的东西第一次不是一个定长的小东西, 于是它捅出了一笔全新的账:内存

p3879.cpp★ 正解:儿子-兄弟表示法的 Trie + 每个词挂一串文章号
// P3879 [TJOI2010] 阅读理解 —— 正解:Trie,而**节点上挂的不再是一个数,是一串编号**
//
// ★ 这一章题单走到这里,Trie 的骨架一个字都没变过,变的一直是挂在节点上的东西:
// [P8306] 一个计数 → [P2580] 一个三态 → [P10471] 什么都不挂 → 这道题:**一串文章号**。
//
// ⚠⚠ 而这道题上,**「每个节点开 26 个儿子」这个写法本身过不去**:
// 不同单词的总长顶格 4 760 000 ⇒ `ch[节点][26] × 4 字节` = 472.3 MiB,
// 再加上挂在节点上的东西就撞穿了 512 MiB(p3879Array.cpp 实测峰值 533.2 MiB,**MLE**)。
// ⇒ 于是这道题成了[本章第 7 步](/ch/50-trie/)那三条出路**第一次真的用得上**的地方:
// **儿子-兄弟表示法** —— 每个节点只存「左儿子 + 右兄弟 + 这条边上的字符」,
// 26 个指针变成 2 个,代价是「找一个字符」要沿着兄弟链扫(最多 26 步)。
//
// ⚠ 另外两处会咬人的,都不在 Trie 上:
// ① **去重**:同一个单词在同一篇里可能出现好多次,题面写着「序号不应有重复」。
// 每个单词记住「上一次在哪篇见到」就够了 —— ★ 而这样得到的序号**天然是升序的**
// (文章按 1..N 顺序读进来),不用排序、不用 set。
// ② **输出格式**:序号之间一个空格、**第一个前面没有空格**,
// 而「一直没出现过」要输出**一个空行**(不是不输出)。
#include <bits/stdc++.h>
using namespace std;
/* 节点数上限 = 不同单词的总长。题面:每篇 ≤ 5000 字符、单词 ≤ 20 字符、N ≤ 1000
⇒ 每篇最多 238 个长度 20 的单词 ⇒ 238 × 20 × 1000 = 4 760 000 */
const int MAXN = 4760005;
int son[MAXN]; // 左儿子
int bro[MAXN]; // 右兄弟
char edge_[MAXN]; // 从父亲走到我这条边上的字符
int wordId[MAXN]; // 这个节点是不是某个单词的结尾(0 = 不是;否则是单词编号)
int tot;
vector<vector<int> > where_; // where_[单词编号] = 它出现在哪几篇
vector<int> lastSeen; // 这个单词上一次是在第几篇见到的
/** 从 u 往下找字符 c;create = true 时找不到就新开一个 */
int walk(int u, char c, bool create) {
for (int v = son[u]; v; v = bro[v])
if (edge_[v] == c) return v;
if (!create) return 0;
int v = ++tot;
edge_[v] = c;
bro[v] = son[u]; // ★ 新儿子挂到兄弟链的最前面,O(1)
son[u] = v;
return v;
}
void addWord(const string& s, int id) {
int u = 0;
for (char c : s) u = walk(u, c, true);
if (!wordId[u]) { // 第一次见到这个单词
wordId[u] = (int)where_.size() + 1;
where_.push_back(vector<int>());
lastSeen.push_back(0);
}
int w = wordId[u] - 1;
if (lastSeen[w] == id) return; // ⚠ ① 这一篇里已经记过了
lastSeen[w] = id;
where_[w].push_back(id);
}
int findWord(const string& s) {
int u = 0;
for (char c : s) { u = walk(u, c, false); if (!u) return 0; }
return wordId[u];
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
for (int i = 1; i <= n; i++) {
int L;
cin >> L;
for (int j = 0; j < L; j++) { string w; cin >> w; addWord(w, i); }
}
int m;
cin >> m;
for (int i = 0; i < m; i++) {
string w;
cin >> w;
int id = findWord(w);
if (id) {
const vector<int>& v = where_[id - 1];
for (size_t k = 0; k < v.size(); k++) {
if (k) cout << ' '; // ⚠ ② 第一个前面没有空格
cout << v[k];
}
}
cout << '\n'; // ⚠ ② 没出现过也要打这一行(空行)
}
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

2第一版:把 (单词, 篇号) 全存下来,每次询问扫一遍

p3879Brute.cpp✗ 第一版:题面的直译(也是对拍的标准答案)
p3879GenBig.cpp★ 顶格生成器:fat / share / rand / real 四种形状
★★★ 而「顶格」在这道题上有两个方向,它们指向相反的数据

题面的顶格条件是一句乘出来的话:N ≤ 1000 篇 × 每篇 ≤ 5000 字符。 可这 5000 个字符怎么切成单词,题面没管 —— 而这一刀切在哪儿,决定了谁难受:

顶格形状(都合法) 单词数 不同单词 Trie 节点数
fat:每篇 238 个长度 20 的互不相同的词 238 000 238 000 4 009 351
real:每篇 2500 个单字母 2 500 000 26 26

⇒ ★★★ 让暴力最难受的(词多、词短),正是让 Trie 最舒服的那一种; 反过来,把 Trie 撑爆的那一种(词长、互不相同),暴力反而轻松八倍。 ⇒ ⚠ 你造哪一种,就只看得见哪一个 bug。

★ 暴力值多少分(本机实测,2026-09-10)
顶格形状 ✗ 逐个扫 ★ 正解 map<string, vector<int>>
fat 9.59 秒 0.31 秒 0.33 秒
rand(3000 词的小词表) 13.40 秒 0.08 秒 0.10 秒
real(250 万个单字母词) 79.50 秒 0.32 秒 0.41 秒

⇒ 时限 1 秒 ⇒ 最坏形状上超时 79.5 倍。 ⚠ 而题面那个 30% 档管的是 M ≤ 10³(询问少 10 倍)⇒ 暴力在那一档也要 8 秒一分都拿不到 —— ★ 因为这道题的瓶颈是「单词总数 × 询问数」, 而那个分档只砍了后一半。

3★★★ 而这道题真正的关卡是内存 —— 照抄本章那份 26 叉数组会 MLE

p3879Array.cpp✗ 26 叉静态数组:答案一个字节不差,可它 MLE
p3879Count.cpp★ 数节点:Trie 到底有多少个节点,三种存法各要多少字节
// P3879 的两笔账:Trie 到底有多少个节点,以及三种存法各要多少内存
//
// 用法:./p3879Count <csv|table> < 一份输入
//
// ★ 节点数 = 所有**不同单词**的不同前缀个数 —— 把不同的单词排好序,
// 数「每个单词比上一个多出来的那几位」就行,不用真去建那棵树。
// ⇒ 而这道题的关键就是这个数:它一乘 26 再乘 4,就是 26 叉静态数组要占的字节。
#include <bits/stdc++.h>
using namespace std;
int main(int argc, char** argv) {
string mode = argc > 1 ? argv[1] : "table";
int n;
if (!(scanf("%d", &n) == 1)) return 0;
static char buf[64];
set<string> uniq;
long long totalWords = 0, totalChars = 0;
long long pairs = 0; // (单词, 篇号) 的不同组合数 = 所有答案加起来有多长
set<pair<string, int> > pr;
for (int i = 1; i <= n; i++) {
int L;
if (scanf("%d", &L) != 1) break;
for (int j = 0; j < L; j++) {
if (scanf("%60s", buf) != 1) break;
string w = buf;
totalWords++;
totalChars += (long long)w.size();
uniq.insert(w);
pr.insert(make_pair(w, i));
}
}
pairs = (long long)pr.size();
long long nodes = 0, uniqChars = 0;
{
string prev;
for (set<string>::iterator it = uniq.begin(); it != uniq.end(); ++it) {
const string& s = *it;
uniqChars += (long long)s.size();
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;
}
}
vector<pair<string, long long> > out;
out.push_back(make_pair("articles", n));
out.push_back(make_pair("words", totalWords));
out.push_back(make_pair("chars", totalChars));
out.push_back(make_pair("uniq_words", (long long)uniq.size()));
out.push_back(make_pair("uniq_chars", uniqChars));
out.push_back(make_pair("nodes", nodes));
out.push_back(make_pair("pairs", pairs));
/* 三种存法的字节数(×100 之后取整,好写成断言) */
out.push_back(make_pair("mib26_x100", (long long)(nodes * 26.0 * 4 * 100 / 1048576.0)));
out.push_back(make_pair("mibsib_x100", (long long)(nodes * 13.0 * 100 / 1048576.0)));
if (mode == "csv") for (size_t i = 0; i < out.size(); i++) printf("%s,%lld\n", out[i].first.c_str(), out[i].second);
else for (size_t i = 0; i < out.size(); i++) printf(" %-14s %lld\n", out[i].first.c_str(), out[i].second);
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果
⚠⚠ 动笔前先算这一笔(而算完你就不会写那一版了)

节点数上限 = 不同单词的总长。题面顶格: 每篇 5000 字符、单词 ≤ 20 ⇒ 一篇最多 238 个长度 20 的词(238 × 21 − 1 = 4997) ⇒ 4 760 000。于是静态数组要开:

   int ch[4760005][26]            × 4 字节  =  472.1 MiB
   vector<int> where_[4760005]    × 24 字节 =  108.9 MiB
   int lastSeen[4760005]          × 4 字节  =   18.2 MiB
   ---------------------------------------------------
   合计                                        599.2 MiB     (限制 512 MiB)

本机顶格实测fat 那一档,Trie 真的长到 4 009 351 个节点):

写法 峰值内存 耗时
✗ 26 叉静态数组 533.3 MiB 0.48 秒
★ 儿子-兄弟(正解) 67.2 MiB 0.31 秒
map<string, vector<int>> 40.2 MiB 0.33 秒

⇒ ★★★ 533.3 > 512 —— MLE,而它的答案一个字节都不差。 ⇒ 第 46 章 P1469 那条:对拍验的是「算得对不对」,从来不验「装不装得下」。

★★ 这是本章第 7 步那三条出路第一次真的用得上

第 50 章第 7 步列了三条:只开用得到的字符集用 map 存儿子儿子-兄弟表示法,还写着「⚠ 这一步要在动笔前算,不能试」。 这道题就是那一天 —— 三条里第一条用不上(题面就是 26 个小写字母), 后两条都能用,而正解选了第三条:

   26 叉:每个节点 26 个 int              = 104 字节
   儿子-兄弟:左儿子 + 右兄弟 + 一个字符  =  13 字节      ★ 省 8 倍

代价写在那个 walk() 里:找一个字符要沿着兄弟链扫,最多 26 步。 ⇒ 实测它反而更快(0.31 vs 0.48 秒)—— 因为省下来的那 466 MiB 缺页,比多走的那几步贵得多。 ⇒ ★★ 和隔壁两道题是同一条:Trie 的时间账,很大一块在「那片数组有多大」上。

★★★ 一条自己撞出来的:全局 `vector` 数组,程序一启动就把内存摸了一遍

本章第 8 步说过「那个全局数组不碰就不占物理内存 —— 这是操作系统给的便宜」。 这道题给这句话补了一个边界

real 那一档(Trie 只有 26 个节点) 峰值内存
✗ 26 叉静态数组 113.1 MiB
★ 儿子-兄弟(正解) 4.3 MiB

⇒ 树只有 26 个节点,可那一版照样吃掉 113 MiB —— 而这个数是能对上账的:

   vector<int> where_[4760005] × 24 字节 = 108.9 MiB
   + 进程起步                              4.2 MiB
   ------------------------------------------------
                                          113.1 MiB    ← 实测就是这个数

★★★ 原因:int 数组是平凡类型,放进 BSS,不碰就不占; 而 vector 有构造函数 —— 476 万个 vectormain 之前被逐个构造, 那一趟就把整片内存都碰了一遍。 ⇒ ⚠ 「不碰就不占」只对平凡类型成立,写下 vector<X> a[N]; 之前先乘一遍 N × sizeof(vector)

4⚠ 三个和算法无关的坑,全在题面那两句话里

p3879Dup.cpp✗ 错法①:忘了去重(同一篇里出现两次就打两遍)
p3879Blank.cpp✗ 错法②:没出现过就什么都不打(少一行)
p3879Space.cpp✗ 错法③:每个序号后面都跟一个空格(行末多空格)
★ 题面把这三件事都写出来了 —— 而每一句都对应一个错法

「序号不应有重复」→ ①Dup 「如果该单词一直没出现过,则输出一个空行」→ ②Blank 「注意第一个序号的前面不应有空格」→ ③Space

★ 顺带一个白送的细节:答案天然就是升序的。 文章是按 1..N 顺序读进来的,所以每个单词的那串文章号就是按顺序追加的 ⇒ 不用排序、不用 set,一句 lastSeen 就同时解决了「去重」和「升序」。

5★ 对拍:三个错法 + 一份「一律空行」

p3879Empty.cpp★ 试金石:每个询问一律输出一个空行
p3879Map.cpp★ 另一条路:map<string, vector<int>>(顶格对拍的参照物)
p3879Gen.cpp★ 生成器:四个档位
★★★ 300 轮 × 四档(本机实测,2026-09-10)
档位 ①Dup ②Blank ③Space 试金石 ✗26 叉数组
0 顺手写法(一篇里的词互不相同、询问随便造) 0 300 243 243 0
1 + 一半询问从文章里抽 0 287 299 299 0
2 + 允许同一篇里出现重复的词 124 300 227 227 0
3 最终档 = 1 + 2 244 289 295 295 0

★ ①Dup 在顺手档是结构性的精确的 0:一篇里的词互不相同 ⇒ 那句 if 一次都用不上。 ⚠ 而顺手写生成器时,「一篇里的词互不相同」几乎是本能(shuffle 之后取前 L 个)。

★★★ 而最后一列是这一页的主角:26 叉数组那一版在四个档上全是精确的 0 —— 它的答案永远对,对拍加多少轮都看不见它。⇒ 只能靠上面第 ③ 步那笔内存账

★★★ 两个完全不同的错法,共用同一条触发线 —— 而这次能证
档位 ③Space 被抓 试金石被抓 交集
0 243 243 243
1 299 299 299
2 227 227 227
3 295 295 295

⇒ 四档逐格相同,而且交集就是全部。两行能证:

  • ③Space 和正解不同 ⟺ 至少有一行不是空的(空行后面加不出空格来);
  • 试金石和正解不同 ⟺ 至少有一行不是空的(不然它就是对的)。

同一句话。

⇒ 这比第 46 章 P2114 那次(两个 bug 共用一条线,交集 32、两列都是 32)更干净; ⚠ 而第 47 章 P1598 那次正好相反 —— 两列都是 284,交集却只有 268。 ⇒ ★★ 两列数字相同不等于触发线相同,得把交集数出来。

★★★ 而 ③Space 这一列,把「对拍别 strip」量成了一整列 0

同一批 1200 轮数据,只换一件事 —— 比之前先把每行末尾的空格去掉:

③Space 被抓
逐字节比 243 / 299 / 227 / 295
去掉行末空格再比 0 / 0 / 0 / 0

不是「可能漏一点」,是一整类 bug 当场消失。 这和第 47 章 P1598 那次(284 → 0)是同一件事的第二次, ⚠ 而这道题同样最容易让人想去 strip:输出里全是空格和空行,肉眼看两份「明明一模一样」。

⚠ 官方那组样例只挡得住一个
①Dup ②Blank ③Space 试金石
官方样例 放过 放过

★ 两个「放过」都能说清原因,而第一个特别有意思:

  • ①Dup:样例第一篇里确实ha ha(一篇里重复的词), 可五个询问词(you / i / o / all / naruto)一个都不是 ha ⇒ ★★ 数据里有那个结构,不等于那个结构被问到了
  • ②Blank:五个询问词全都出现过 ⇒ 「空行」这件事一次都没发生。

「这组样例在结构上问不出这个问题」的又一次。

6★ 哪一版就已经能过了

★★ 结论:而最该写的那一版,一行 Trie 都没有
版本 能过吗 数字(顶格最坏形状)
✗ 逐个扫 一分不给 79.50 秒 / 时限 1 秒;30% 档也要 8 秒
✗ 26 叉静态数组 Trie MLE 533.3 MiB / 限制 512 MiB(答案完全正确)
儿子-兄弟 Trie(正解) 0.31 秒、67.2 MiB
map<string, vector<int>> 0.33 秒、40.2 MiB,而且代码最短

⇒ ★★★ 老实说:这道题最该交的是那份 map 选 Trie 的理由和第 34 章 P1546 那条一样 —— 「这个技巧在这道题上成立」和「这道题该用它」是两句话。 ⚠ 而如果你真要写 Trie,那就必须先做本章第 7 步那道算术题, 否则你写出来的是一份答案完全正确的 MLE

★ 一句话带走

节点上第一次挂了一个「长度不定」的东西,于是这道题第一次把内存变成了关卡。 ⇒ 而三个和算法无关的坑(去重 / 空行 / 行末空格)全写在题面的输出格式里, 官方样例只挡得住其中一个,另外两个要靠「逐字节比」和「专门造一档」。