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 3;naruto 只在第二篇 ⇒ 2。
⚠ 注意第一篇里 ha 出现了两次 —— 可这五个询问词里没有 ha
⇒ 样例里有那个结构,但没有被问到(第 ⑤ 步会用到这一条)。
1★ 这一章题单走到第四道,节点上挂的东西又换了
| 节点上挂什么 | |
|---|---|
| P8306 | 一个计数(路过的有几个) |
| P2580 | 一个会被改写的三态 |
| P10471 | ★ 什么都不挂(只剩「这条边在不在」) |
| 这道题 | ★★ 一串文章号 |
⇒ 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;}点「运行 ▶」看结果
2第一版:把 (单词, 篇号) 全存下来,每次询问扫一遍
题面的顶格条件是一句乘出来的话: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。
| 顶格形状 | ✗ 逐个扫 | ★ 正解 | ★ 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
// 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 那条:对拍验的是「算得对不对」,从来不验「装不装得下」。
第 50 章第 7 步列了三条:只开用得到的字符集 / 用 map 存儿子 / 儿子-兄弟表示法,还写着「⚠ 这一步要在动笔前算,不能试」。 这道题就是那一天 —— 三条里第一条用不上(题面就是 26 个小写字母), 后两条都能用,而正解选了第三条:
26 叉:每个节点 26 个 int = 104 字节
儿子-兄弟:左儿子 + 右兄弟 + 一个字符 = 13 字节 ★ 省 8 倍代价写在那个 walk() 里:找一个字符要沿着兄弟链扫,最多 26 步。
⇒ 实测它反而更快(0.31 vs 0.48 秒)—— 因为省下来的那 466 MiB 缺页,比多走的那几步贵得多。
⇒ ★★ 和隔壁两道题是同一条:Trie 的时间账,很大一块在「那片数组有多大」上。
本章第 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 万个 vector 在 main 之前被逐个构造,
那一趟就把整片内存都碰了一遍。
⇒ ⚠ 「不碰就不占」只对平凡类型成立,写下 vector<X> a[N]; 之前先乘一遍 N × sizeof(vector)。
4⚠ 三个和算法无关的坑,全在题面那两句话里
「序号不应有重复」→ ①Dup 「如果该单词一直没出现过,则输出一个空行」→ ②Blank 「注意第一个序号的前面不应有空格」→ ③Space
★ 顺带一个白送的细节:答案天然就是升序的。
文章是按 1..N 顺序读进来的,所以每个单词的那串文章号就是按顺序追加的
⇒ 不用排序、不用 set,一句 lastSeen 就同时解决了「去重」和「升序」。
5★ 对拍:三个错法 + 一份「一律空行」
| 档位 | ①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。 ⇒ ★★ 两列数字相同不等于触发线相同,得把交集数出来。
同一批 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★ 哪一版就已经能过了
| 版本 | 能过吗 | 数字(顶格最坏形状) |
|---|---|---|
| ✗ 逐个扫 | 一分不给 | 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。
节点上第一次挂了一个「长度不定」的东西,于是这道题第一次把内存变成了关卡。 ⇒ 而三个和算法无关的坑(去重 / 空行 / 行末空格)全写在题面的输出格式里, 官方样例只挡得住其中一个,另外两个要靠「逐字节比」和「专门造一档」。