题单 · 习题解析

洛谷 P2580 于是他错误的点名开始了

★★★ 同一张题单里 Trie 的骨架一个字没变,换的是**节点上挂什么** —— [P8306](/sol/p8306/) 挂一个计数,这道题挂一个**会被改写的三态**(不是名字 / 是名字没点过 / 点过了),而 `cntPass` / `cntEnd` 两个计数一个都用不上;★★★ 而这一页最值钱的一条是**同一句漏掉的 `return`(走不下去没立刻返回),在 P8306 上顺手档就被抓 296/300,在这道题上顺手档只抓 2/300** ⇒ **主语是「这道题问的是什么」**:那道题读到的是某个后缀的**计数**(几乎必然 ≠ 0),这道题读到的是某个后缀的**状态**(随机后缀多半根本不是名字 ⇒ 照样答 WRONG,答案一个字没变)⇒ **bug 好不好抓从来不是这个 bug 的属性**;★★ 而照着那条线直接造(点名 = 一个树上没有的字符 + 一个真名字)当场 300/300;★★★ 第二条:**两把尺子指向相反的赢家** —— 顶格上 Trie 摸的字符比 `map<string,int>` 少 2.6 倍,秒表却慢 **5.5 倍**(0.11 vs 0.02 秒),而原因就写在峰值内存那一列上:`ch[500005][26]` 是 **49.6 MiB**,Trie 那 0.11 秒几乎全花在第一次碰到它上(把名字改成共用长前缀 ⇒ 内存掉到 12.1 MiB、秒表跟着掉到 0.03 秒,**而它摸的字符反倒多了 1.6 倍**)⇒ 和隔壁 P8306 是同一条结论、同一张题单里连着成立两次;★★ 选 Trie 的理由因此不是快,是「**不依赖任何一次字符串比较**」(名字共用 40 字符前缀时:逐个比 ×39.4、map ×8.4、Trie 只 ×1.6);★ 顺带把题面那两行分档量成了「暴力值多少分」:40% 档 0.00 秒、70% 档 0.22 秒、100% 档 **1.17 秒 / 时限 1 秒** ⇒ **稳拿 70 分,而离满分只差 17%**;⚠ 官方那组样例**只挡得住三个错法里的一个**(两个「放过」都能说清:点的 `a` 本身就在名单里 / 点名都只有一个字符 ⇒「走到中间才断」结构上不可能);⚠⚠ 而顺手那一档在**验零**:随机点名几乎不可能等于名字 ⇒ 正解 300 轮一路 WRONG,「一律输出 WRONG」的试金石是**精确的 0**

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

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

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

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

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

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

题目背景

XS 中学化学竞赛组教练是一个酷爱炉石的人。

他会一边搓炉石一边点名以至于有一天他连续点到了某个同学两次,然后正好被路过的校长发现了 然后就是一顿欧拉欧拉欧拉(详情请见已结束比赛 CON900)。

题目描述

这之后校长任命你为特派探员,每天记录他的点名。校长会提供化学竞赛学生的人数和名单, 而你需要告诉校长他有没有点错名。(为什么不直接不让他玩炉石。)

输入格式

第一行一个整数 n,表示班上人数。

接下来 n 行,每行一个字符串表示其名字(互不相同,且只含小写字母,长度不超过 50)。

n+2 行一个整数 m,表示教练报的名字个数。

接下来 m 行,每行一个字符串表示教练报的名字(只含小写字母,且长度不超过 50)。

输出格式

对于每个教练报的名字,输出一行。

如果该名字正确且是第一次出现,输出 OK;如果该名字错误,输出 WRONG; 如果该名字正确但不是第一次出现,输出 REPEAT

数据范围

  • 对于 40% 的数据,n ≤ 1000m ≤ 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。第一次点 aOK;再点一次 aREPEAT; 点 e 名单里没有 ⇒ WRONG

⚠ 记住这组数据的形状 —— 第 ⑤ 步会说明它为什么挡不住这一页两个最要命的错法

1★★★ 换一道题,挂在节点上的东西就换一样

★★ 这一章题单里,Trie 的骨架从来没变过,变的一直是「节点上放什么」

第 50 章第 4 步给了两个计数:cntPass(路过的有几个)和 cntEnd(在这儿结束的有几个)。 可题单这几道题,没有一道是照抄那两个数的

节点上挂什么
P8306 一个计数(cntPass
这道题 ★ 一个三态0 不是名字 / 1 是名字没点过 / 2 点过了

⇒ ★★★ Trie 的骨架只有一句话「让公共前缀只存一份」,剩下的全是每道题自己的事。 这道题要的既不是「路过几个」也不是「结尾几个」,而是「结尾那个记号还在不在」—— cntEnd 是个计数,而这里要的是一个会被改写的状态。

p2580.cpp★ 正解:Trie,节点上挂一个三态
// 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

2第一版:把名单存成 vector,挨个比一遍 —— 而它稳拿 70 分

p2580Brute.cpp✗ 第一版:线性查找 + 一个 used 数组
p2580GenBig.cpp★ 顶格 / 分档生成器:full / share / p40 / p70
★★ 题面那两行分档,就是「暴力值多少分」的答案(本机实测,2026-09-10)
数据档 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 倍

p2580Map.cpp★ 另一条路:一个 map<string,int> 就完了
p2580Count.cpp★ 换尺子:三条路各摸了多少个字符(机器无关)
它把一份输入喂给三条路,各数各的:逐个比 / map(拿会计数的比较器)/ Trie。
// 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;
}
点一下即可编辑
输出
点「运行 ▶」看结果
★★★ 两把尺子,两个相反的赢家(顶格 100% 档)
摸了多少个字符 秒表 峰值内存
✗ 逐个比 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 的时间账,很大一块根本不在「走了几步」上,在「那片数组有多大」上。

★ 那这道题还要不要写 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⚠ 三个错法,而它们全都长在「三态」那三个字上

p2580Prefix.cpp✗ 错法①:走得到就算 OK(忘了「它得是个结尾」)
p2580Once.cpp✗ 错法②:忘了把「点过了」写回去 ⇒ 永远不打 REPEAT
p2580Miss.cpp✗ 错法③:走不下去了没 return,悄悄掉回根
★ 三个错法各自「算了什么」,一句话一个
它其实在算什么 白送的推论
①Prefix 「这个串是不是某个名字的前缀 名单里没有前缀关系时它一分不扣
②Once 「这个名字在不在名单里」(三个答案塌成两个) 点名互不重复时是结构性的精确的 0
③Miss 走不下去就从根重来 ⇒ 读的是点名某个后缀的状态 ⚠ 而后缀多半也不是名字 ⇒ 它多半还是答 WRONG

★★ 最后那一格是这一页最反直觉的地方,第 ⑤ 步专门量它。

⚠ 而 ③Miss 在这道题上还多干一件事:它会把别人的状态从 1 改成 2 —— 于是后面那些本该 OK 的点名跟着变成 REPEAT,一处漏判污染一整串输出

5★★★ 同一句漏掉的 return,在两道题上的抓获率差 150 倍

p2580All.cpp★ 试金石:一律输出 WRONG
p2580Gen.cpp★ 生成器:五个档位,每一档只拧一个旋钮
★★★ 300 轮 × 五档(本机实测,2026-09-10)
档位 ①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 那条)。

⚠⚠ 而顺手那一档在验零:正解 300 轮一路 WRONG

档 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:名单里确实有前缀关系(aadacd 的前缀),可点的那个 a 本身就在名单里 ⇒ 走到底那个节点的状态本来就不是 0,那句漏掉的 if 一次都没被用到;
  • ③Miss:ae 都是一个字符的点名 —— 要么第一步就走通,要么第一步就没路 ⇒ 「走到中间才断」这件事结构上不可能发生

「这组样例在结构上问不出这个问题」的又一次, ★ 而这次连样例里名字最长只有 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 好不好抓,取决于这道题在问什么。