0题目原文(本地存了一份)
原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。
转录自洛谷 P2580,日期见页头。两边不一致时信原站。
题目背景
XS 中学化学竞赛组教练是一个酷爱炉石的人。
他会一边搓炉石一边点名以至于有一天他连续点到了某个同学两次,然后正好被路过的校长发现了 然后就是一顿欧拉欧拉欧拉(详情请见已结束比赛 CON900)。
题目描述
这之后校长任命你为特派探员,每天记录他的点名。校长会提供化学竞赛学生的人数和名单, 而你需要告诉校长他有没有点错名。(为什么不直接不让他玩炉石。)
输入格式
第一行一个整数 n,表示班上人数。
接下来 n 行,每行一个字符串表示其名字(互不相同,且只含小写字母,长度不超过 50)。
第 n+2 行一个整数 m,表示教练报的名字个数。
接下来 m 行,每行一个字符串表示教练报的名字(只含小写字母,且长度不超过 50)。
输出格式
对于每个教练报的名字,输出一行。
如果该名字正确且是第一次出现,输出 OK;如果该名字错误,输出 WRONG;
如果该名字正确但不是第一次出现,输出 REPEAT。
数据范围
- 对于 40% 的数据,
n ≤ 1000,m ≤ 2000。 - 对于 70% 的数据,
n ≤ 10⁴,m ≤ 2 × 10⁴。 - 对于 100% 的数据,
n ≤ 10⁴,m ≤ 10⁵。
upd 2022.7.30:新增加一组 Hack 数据。
时限 1 秒,内存 131072 KB(128 MiB)。
输入输出样例
输入
5 a b c ad acd 3 a a e
输出
OK REPEAT WRONG
名单是 a / b / c / ad / acd。第一次点 a ⇒ OK;再点一次 a ⇒ REPEAT;
点 e 名单里没有 ⇒ WRONG。
⚠ 记住这组数据的形状 —— 第 ⑤ 步会说明它为什么挡不住这一页两个最要命的错法。
1★★★ 换一道题,挂在节点上的东西就换一样
第 50 章第 4 步给了两个计数:cntPass(路过的有几个)和 cntEnd(在这儿结束的有几个)。
可题单这几道题,没有一道是照抄那两个数的:
| 节点上挂什么 | |
|---|---|
| P8306 | 一个计数(cntPass) |
| 这道题 | ★ 一个三态:0 不是名字 / 1 是名字没点过 / 2 点过了 |
⇒ ★★★ Trie 的骨架只有一句话「让公共前缀只存一份」,剩下的全是每道题自己的事。
这道题要的既不是「路过几个」也不是「结尾几个」,而是「结尾那个记号还在不在」——
cntEnd 是个计数,而这里要的是一个会被改写的状态。
// P2580 于是他错误的点名开始了 —— 正解:Trie,节点上挂一个**三态**//// ★ 这道题和 [P8306](/sol/p8306/) 的骨架一模一样,可**挂在节点上的东西换了**:// P8306 挂的是「路过的有几个」(一个计数),这道题挂的是一个**状态**://// 0 = 这儿不是任何一个名字的结尾// 1 = 是名字,还没被点到过// 2 = 是名字,而且已经点过了//// 查询 = 顺着走到底,读那个状态,然后**把 1 改成 2**。// ⇒ 本章那两个计数(cntPass / cntEnd)在这道题上一个都用不上 ——// 用得上的是「cntEnd 是不是 0」这半句,再加一位「点过没有」。//// ⚠ 两处最容易漏:// ① 走不下去要立刻 WRONG(不然 u 掉回根,答的是某个后缀的状态);// ② 走到了,但那个节点的状态是 0(它只是别人的**前缀**)⇒ 也是 WRONG。// ★ 官方样例里名单有 `a`、`ad`、`acd`,而问的正好是 `a` —— **它测不出第 ② 条**。
#include <bits/stdc++.h>using namespace std;
/* 名单总长上限 = n × 50 = 10⁴ × 50 = 5×10⁵,再加一个根 */const int MAXN = 500005;
int ch[MAXN][26]; // 26 × 5×10⁵ × 4 字节 = 49.6 MiB(题面给 128 MiB)int st[MAXN]; // 0 / 1 / 2 三态int tot;
void insertName(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]; } st[u] = 1; // ★ 只在结尾那个节点上做记号}
/** 返回 0 = WRONG,1 = OK,2 = REPEAT */int askName(const string& s) { int u = 0; for (char c : s) { int k = c - 'a'; if (!ch[u][k]) return 0; // ⚠ ① 走不下去 ⇒ 名单里没有 u = ch[u][k]; } if (st[u] == 0) return 0; // ⚠ ② 走到了,但它只是别人的前缀 if (st[u] == 1) { st[u] = 2; return 1; } // 第一次点到 return 2; // 点过了}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; if (!(cin >> n)) return 0; for (int i = 0; i < n; i++) { string s; cin >> s; insertName(s); } int m; cin >> m; for (int i = 0; i < m; i++) { string s; cin >> s; int r = askName(s); cout << (r == 0 ? "WRONG" : r == 1 ? "OK" : "REPEAT") << '\n'; } return 0;}点「运行 ▶」看结果
2第一版:把名单存成 vector,挨个比一遍 —— 而它稳拿 70 分
| 数据档 | n / m |
✗ 逐个比 | ★ map<string,int> |
★ Trie |
|---|---|---|---|---|
| 40% 档 | 1000 / 2000 | 0.00 秒 | 0.00 | 0.00 |
| 70% 档 | 10⁴ / 2×10⁴ | 0.22 秒 | 0.00 | 0.04 |
| 100% | 10⁴ / 10⁵ | ⚠ 1.17 秒 | 0.02 | 0.11 |
| 100%(名字共用 40 字符前缀) | 10⁴ / 10⁵ | ⚠ 1.41 秒 | 0.02 | 0.03 |
⇒ ★★★ 第一版稳拿 70 分,而离满分只差 1.17 倍。
⚠ 这是本书第 N 次撞见「肯定超时」需要打个折 —— 它确实超时了,可只超出 17%。⇒ 而这个数字顺手解释了题面末尾那一行 「upd 2022.7.30:新增加一组 Hack 数据」:一个只超出一点点的写法, 换一组数据就是过与不过的差别。★ 至于那组 Hack 数据到底卡的是什么,题面没说,这一页也不猜。
3★★★ 而选 Trie 的理由不是快 —— 实测它比 map 慢 5.5 倍
// P2580 换一把尺子:三条路各**摸了多少个字符**(机器无关,换台机器一个数不变)//// 用法:./p2580Count <csv|table> < 一份输入//// 三条路摸字符的方式完全不同:// ✗ 逐个比:对每次点名,从头扫名单,`==` 先比长度、长度相同再逐字节比,第一个不同就停;// ★ map :红黑树里 O(log n) 次字符串比较,每次同样是「比到第一个不同为止」;// ★ Trie :**建树时每个名字的每个字符走一步,查询时点名的每个字符走一步**,仅此而已。//// ⚠ 而这三个数**和秒表指向的赢家不一样**(解析页第 ③ 步那张表)——// Trie 摸的字符最少,可它要碰一片 49.6 MiB 的数组,而另外两条路只碰几 MB。
#include <bits/stdc++.h>using namespace std;
static long long mapChars = 0;
/** 一个会数「比了多少个字符」的字符串比较器 */struct CountingLess { bool operator()(const string& a, const string& b) const { size_t i = 0; while (i < a.size() && i < b.size() && a[i] == b[i]) i++; mapChars += (long long)i + 1; // 比到第一个不同(或到头)为止 if (i == a.size() || i == b.size()) return a.size() < b.size(); return a[i] < b[i]; }};
int main(int argc, char** argv) { string mode = argc > 1 ? argv[1] : "table";
int n; if (!(scanf("%d", &n) == 1)) return 0; vector<string> name(n); static char buf[105]; for (int i = 0; i < n; i++) { if (scanf("%104s", buf) != 1) return 0; name[i] = buf; } int m; if (scanf("%d", &m) != 1) return 0; vector<string> call(m); for (int i = 0; i < m; i++) { if (scanf("%104s", buf) != 1) return 0; call[i] = buf; }
long long nameLen = 0, callLen = 0; for (const string& s : name) nameLen += (long long)s.size(); for (const string& s : call) callLen += (long long)s.size();
/* ① 逐个比:数「看了多少个名字」和「摸了多少个字符」 */ long long bruteVisits = 0, bruteChars = 0; { vector<int> used(n, 0); for (const string& q : call) { for (int j = 0; j < n; j++) { bruteVisits++; if (name[j].size() != q.size()) { bruteChars += 1; continue; } size_t i = 0; while (i < q.size() && name[j][i] == q[i]) i++; bruteChars += (long long)i + 1; if (i == q.size()) { used[j] = used[j] ? 2 : 1; break; } } } }
/* ② map:拿会计数的比较器跑一遍 */ long long mapInsert = 0; { mapChars = 0; map<string, int, CountingLess> st; for (const string& s : name) st[s] = 1; mapInsert = mapChars; for (const string& q : call) { map<string, int, CountingLess>::iterator it = st.find(q); if (it != st.end() && it->second == 1) it->second = 2; } } long long mapTotal = mapChars;
/* ③ Trie:建树 + 查询,每个字符正好一步(查询走不下去就提前停) */ long long trieSteps = nameLen; { /* 只为数步数,用 map 存边就够了 —— 数出来的步数和真 Trie 一模一样 */ vector<map<char, int> > ch(1); vector<char> isEnd(1, 0); for (const string& s : name) { int u = 0; for (char c : s) { map<char, int>::iterator it = ch[u].find(c); if (it == ch[u].end()) { ch.push_back(map<char, int>()); isEnd.push_back(0); ch[u][c] = (int)ch.size() - 1; u = (int)ch.size() - 1; } else u = it->second; } isEnd[u] = 1; } for (const string& q : call) { int u = 0; for (char c : q) { map<char, int>::iterator it = ch[u].find(c); if (it == ch[u].end()) break; u = it->second; trieSteps++; } } (void)isEnd; }
vector<pair<string, long long> > out; out.push_back(make_pair("n", n)); out.push_back(make_pair("m", m)); out.push_back(make_pair("name_len", nameLen)); out.push_back(make_pair("call_len", callLen)); out.push_back(make_pair("brute_visits", bruteVisits)); out.push_back(make_pair("brute_chars", bruteChars)); out.push_back(make_pair("map_insert", mapInsert)); out.push_back(make_pair("map_chars", mapTotal)); out.push_back(make_pair("trie_steps", trieSteps));
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;}点「运行 ▶」看结果
| 摸了多少个字符 | 秒表 | 峰值内存 | |
|---|---|---|---|
| ✗ 逐个比 | 783 470 595 | 1.17 秒 | 4.8 MiB |
★ map<string,int> |
8 222 716 | ★ 0.02 秒 | 5.6 MiB |
| ★ Trie(正解) | ★ 3 123 856 | 0.11 秒 | ⚠ 53.3 MiB |
⇒ ★★★ 按「摸了多少字符」Trie 赢 map 2.6 倍,按秒表 map 反过来赢 Trie 5.5 倍。
★ 而这一次原因说得特别清楚,因为它就写在最右边那一列上:
ch[500005][26] 是 49.6 MiB,而 Trie 那 0.11 秒几乎全花在第一次碰到这片内存上。
证据在下面那一行:把名字改成「共用 40 个字符的前缀」之后,Trie 的节点数塌下去、
峰值内存从 53.3 掉到 12.1 MiB ⇒ 秒表跟着从 0.11 掉到 0.03 秒,而它摸的字符反倒多了 1.6 倍。
⇒ ★★ 这和隔壁那道 P8306 是同一条结论,同一张题单里连着成立两次: Trie 的时间账,很大一块根本不在「走了几步」上,在「那片数组有多大」上。
要 —— 但理由得说对。它不是更快,它是「不依赖任何一次字符串比较」:
map 那条路每次查询是 O(log n) 次字符串比较,最坏一次比 50 个字符;
Trie 是「点名有多长就走多少步」,和名单里有多少人、名字长什么样一点关系都没有。
★ 这一点在下面那张表的最后一行看得最清楚:把名字改成共用长前缀之后,
map 摸的字符从 822 万涨到 6905 万(8.4 倍),逐个比涨到 308 亿(39.4 倍),
而 Trie 只从 312 万涨到 512 万(1.6 倍,而且涨的那部分是点名走得更深了)。
| 形状 | ✗ 逐个比摸的字符 | ★ map 摸的字符 | ★ Trie 步数 |
|---|---|---|---|
| 100% 档(随机名字) | 783 470 595 | 8 222 716 | 3 123 856 |
| 100% 档(共用 40 字符前缀) | ⚠ 30 836 000 372 | 69 052 344 | 5 121 639 |
| 倍数 | ×39.4 | ×8.4 | ★ ×1.6 |
⚠ 而这道题的 n ≤ 10⁴、名字 ≤ 50 —— 三条路里有两条都过得去,
所以上面那句「要不要」在这道题上是选型偏好,不是分数线。
(第 34 章 P1546 那条:「这个技巧在这道题上成立」和「这道题该用它」是两句话。)
4⚠ 三个错法,而它们全都长在「三态」那三个字上
| 它其实在算什么 | 白送的推论 | |
|---|---|---|
| ①Prefix | 「这个串是不是某个名字的前缀」 | 名单里没有前缀关系时它一分不扣 |
| ②Once | 「这个名字在不在名单里」(三个答案塌成两个) | 点名互不重复时是结构性的精确的 0 |
| ③Miss | 走不下去就从根重来 ⇒ 读的是点名某个后缀的状态 | ⚠ 而后缀多半也不是名字 ⇒ 它多半还是答 WRONG |
★★ 最后那一格是这一页最反直觉的地方,第 ⑤ 步专门量它。
⚠ 而 ③Miss 在这道题上还多干一件事:它会把别人的状态从 1 改成 2 —— 于是后面那些本该 OK 的点名跟着变成 REPEAT,一处漏判污染一整串输出。
5★★★ 同一句漏掉的 return,在两道题上的抓获率差 150 倍
| 档位 | ①Prefix | ②Once | ③Miss | 试金石 |
|---|---|---|---|---|
| 0 顺手写法(名字和点名都是随机小写串) | ⚠ 181 | 0 | 2 | ★ 0 |
| 1 + 点名一半直接抄名单(允许重复) | 109 | 247 | 0 | 299 |
| 2 + 名字从短前缀派生 + 点名取真前缀 | 299 | 236 | 16 | 299 |
| 3 + 专为 ③Miss 造的形状 | 101 | 0 | ★ 300 | ★ 0 |
| 4 最终档 = 1 + 2 + 3 | 273 | 208 | 295 | 299 |
★★★ 这张表最值钱的一行是第 0 行,而它和隔壁 P8306 那张表正好反过来:
| 同一个 bug(走不下去没 return) | 顺手档抓获 |
|---|---|
| P8306(问「有几个」) | 296 / 300 |
| 这道题(问「在不在名单里」) | ⚠ 2 / 300 |
⇒ ★★★ 主语是「这道题问的是什么」 ——
P8306 掉回根之后读到的是某个后缀的计数,而那个计数几乎必然不等于 0;
这道题掉回根之后读到的是某个后缀的状态,而随机后缀多半根本不是名字
⇒ 它照样答 WRONG,答案一个字都没变。
⇒ ⚠ 「这个 bug 好不好抓」从来不是这个 bug 的属性。
③Miss 要的形状是精确的:点名走到中间断掉,而剩下那一截又恰好是一个名字。 照着这句话直接造(档 3):拿一个「谁都不以它开头」的字符打头,后面接一个真名字 ——
点名 = 'z' + "abc" (名单里有 "abc",而没有名字以 'z' 开头)
⇒ 第一个字符没路 ⇒ u 掉回根 ⇒ 剩下 "abc" 从根走到底 ⇒ 它答 OK,正解答 WRONG⇒ 那一列当场从 2 / 300 变成 300 / 300,而加多少轮都换不来这个结果 (第 32 章 P1462 那条)。
档 0 里试金石(一律输出 WRONG)被抓 0 次 —— 精确的 0。
道理一句话:随机点名串几乎不可能正好等于某个名字,正解本来就一路 WRONG。
⇒ 「一致有两种:都算对了,和都没算」在这一页是「都答 WRONG」。 ★ 而档 3 那一列也是 0,原因完全不同:那一档的点名全是「干扰字符 + 名字」, 正解照样一路 WRONG —— ⚠ 同一个 0,一个是「生成器太随手」,一个是「这一档专为别人造的」。
⇒ ★★ 所以这张表只有档 1 / 2 / 4 那三行的试金石(299 / 299 / 299)能证明数据在问问题。
| ①Prefix | ②Once | ③Miss | 试金石 | |
|---|---|---|---|---|
官方样例(a b c ad acd / 点 a a e) |
★ 放过 | 死 | ★ 放过 | 死 |
★ 两个「放过」都能说清原因:
- ①Prefix:名单里确实有前缀关系(
a是ad、acd的前缀),可点的那个a本身就在名单里 ⇒ 走到底那个节点的状态本来就不是 0,那句漏掉的if一次都没被用到; - ③Miss:
a和e都是一个字符的点名 —— 要么第一步就走通,要么第一步就没路 ⇒ 「走到中间才断」这件事结构上不可能发生。
⇒ 「这组样例在结构上问不出这个问题」的又一次, ★ 而这次连样例里名字最长只有 3 个字符这一条都在帮倒忙。
6★ 哪一版就已经能过了
| 版本 | 能过吗 | 数字 |
|---|---|---|
| ✗ 逐个比 | 70 分 | 100% 档 1.17 秒 / 时限 1 秒(只超 17%) |
★ map<string,int> |
✓ 满分 | 0.02 秒,5.6 MiB,代码最短 |
| ★ Trie(正解) | ✓ 满分 | 0.11 秒,53.3 MiB —— 慢的那一截全是那片数组 |
⇒ ★★★ 这道题的功课一个字都不在「怎么查得快」上,全在那三个答案上:
OK 要求「是名字」并且「第一次」,WRONG 有两种来路(走不下去 / 走到了但不是结尾),
而 REPEAT 是唯一一个要把状态写回去的。
三个错法各拆掉其中一件事,而官方样例只挡得住其中一件。
Trie 的骨架第 50 章已经写完,这一页换的是「节点上挂什么」 ——
从一个计数换成一个会被改写的三态;
⇒ 而这一换带来的最值钱的一条是:同一句漏掉的 return,在隔壁那道题上顺手就被抓 296 次,
在这道题上顺手只抓 2 次 —— bug 好不好抓,取决于这道题在问什么。