这本书到第 46 章为止,字符串一次都没有正经讲过 —— 而它是 CSP-J 每年都考的东西。 阶段 10 补的就是这一块,一共四章:
| 章 | 讲什么 | 组别 |
|---|---|---|
| 47(本章) | string 怎么读进来、怎么切开、怎么比 —— 以及三个最容易栽的坑 |
J |
| 48 | KMP:暴力匹配为什么慢,next 数组怎么救它 |
S |
| 49 | 字符串哈希:把子串变成一个数 —— ★ 以及它为什么会错 | S |
| 50 | Trie(字典树):一堆字符串摆成一棵树 | S |
★ 本章的定位很清楚:把 J 组能考的字符串题写对。 它一个算法都不讲(KMP / 哈希 / Trie 全在后面三章),但它讲的三个坑, 是每一道字符串题都会碰到的。
1一句话问题
给一个单词
w(只含字母,长度 ≤ 10)和一整行文章t(字母和空格,长度 ≤ 10⁶)。 不区分大小写,问w作为完整单词在t里出现了几次,以及第一次出现时 它首字母在整行里的下标(从 0 开始)。一次都没出现就输出-1。输入两行:第一行
w,第二行t。输出:出现次数和第一次的位置,空格隔开;没有则只输出-1。
输入
To to be or not to be is a question
输出
2 0
to 出现两次(to be or not to be 里的第 1 个词和第 5 个词),第一次的首字母在下标 0。
⚠ 注意 w 是 To、文章里是 to —— 不区分大小写,所以算命中。
输入
to Did the Ottoman Empire lose its power at that time
输出
-1
★ 这一组一次都没有。可 to 明明在 Ottoman 里出现过 ——
但那不是一个完整的单词,所以不算。这是这道题最容易丢分的地方。
输入
the theme of the theatre is the theme
输出
2 9
★★ 把上一条推到极端:theme、theatre 里都含着 the,但只有单独站着的那两个 the 算数。
⇒ 答案是 2,第一次在下标 9。
⚠ 如果你的程序输出的是 5 0,那它数的是子串,不是单词 —— 第 4 步就在讲这个。
2⚠ 第一个坑就在读入:getline 读到了一个空行
第一行是一个单词,第二行是一整行(含空格)。所以第一行用 cin >> w,第二行必须用 getline ——
可这么写出来的程序,读到的文章是空的:
cin >> w; // 只取走了单词那几个字符
getline(cin, t); // 一上来就撞见上一行剩下的换行符 -> 读到一个空行
// ★★ 这一章的开场白:`cin >> w` 之后直接 getline,读到的是**空行**//// 为什么:`cin >> w` 只把单词那几个字符取走,**行尾那个换行符还留在输入里**。// 下一句 `getline` 一上来就撞见它,于是「读到了一行,内容是空的」——// 程序不报错、不崩溃,只是后面全错。J 组最常见的一个坑,而且最难自己看出来。//// 这一份把两种写法**并排跑给你看**:同一份输入,只差一句 getline。// ⚠ 实现上它先把整个输入读进内存,再用两个 stringstream 各演一遍 ——// 这样两种写法吃的是**同一份字节**,对比才算数。//// ⇒ 三种活得下去的写法(正文第 3 步有表):// ① `cin >> w;` 之后**先来一句 getline 吃掉换行**,再 getline 正文(本章正解用的就是它);// ② 全程 getline,自己从第一行里把单词取出来;// ③ `cin >> ws` 跳过所有空白再 getline —— ⚠ 但它会把**正文开头的空格**也吃掉。
#include <bits/stdc++.h>using namespace std;
int main() { string all((istreambuf_iterator<char>(cin)), istreambuf_iterator<char>());
{ stringstream ss(all); string w, t; ss >> w; getline(ss, t); // ⚠ 少了一句,读到的是上一行剩下的换行 printf("① 不吃换行: w = [%s] t = [%s] (t 的长度 %zu)\n", w.c_str(), t.c_str(), t.size()); } { stringstream ss(all); string w, t; ss >> w; getline(ss, t); // 这一句专门用来吃掉换行,读到的东西直接扔掉 getline(ss, t); printf("② 先吃换行: w = [%s] t = [%s] (t 的长度 %zu)\n", w.c_str(), t.c_str(), t.size()); } { stringstream ss(all); string w, t; ss >> w >> ws; // ws 跳过所有空白(换行也算) getline(ss, t); printf("③ 用 cin >> ws:w = [%s] t = [%s] (t 的长度 %zu)\n", w.c_str(), t.c_str(), t.size()); }
printf("\n★ ① 那一行的 t 是空的 —— 程序不报错,只是后面全算错。\n"); printf("⚠ ③ 看着最省事,但它连**正文开头的空格**也一起吃掉了;正文开头有空格的题会栽在这儿。\n"); return 0;}点「运行 ▶」看结果
| 写法 | 怎么写 | 注意 |
|---|---|---|
| ① 先吃掉换行 | cin >> w; 之后来一句 getline(cin, t); 把残留吃掉,再 getline(cin, t); |
本章正解用的就是它 |
| ② 全程 getline | 第一行也用 getline 读,再自己从里面取单词 |
最不容易出错,但多几行 |
③ cin >> ws |
cin >> w >> ws; 再 getline |
⚠ ws 会把正文开头的空格也吃掉 |
⚠ 这个坑最难受的地方是:程序不报错、不崩溃,只是后面全算错。 而你的样例如果第二行开头没空格、又正好没被这一步坑到,你会一直以为是别的地方错了。
3不区分大小写:字符本来就是整数
第 46 章那句话在这里直接能用:字符存的就是它的编码。
// 第三件事:字符就是整数(第 46 章那句话在字符串上的样子)//// `char` 存的本来就是一个整数(它的 ASCII 编码)。这一章要用到的三条:// ① `'a' - 'A' == 32`,而 32 = 2⁵ ⇒ **大小写只差第 5 位**(第 46 章的 flip);// ② 数字字符转数字要减 `'0'`;// ③ 判断字母 / 数字用 `isalpha` / `isdigit`,⚠ 参数要先转成 `unsigned char`。//// ⚠ ③ 那个 `unsigned char` 不是讲究:`char` 在 x86 上是**有符号**的,// 遇到中文这类字节会变成负数,而 `tolower(负数)` 是未定义行为。
#include <bits/stdc++.h>using namespace std;
int main() { printf("① 大小写差多少\n"); printf(" 'a' = %d 'A' = %d 差 %d = 2^5\n", 'a', 'A', 'a' - 'A'); printf(" 'a' ^ 32 = %c ← 翻转第 5 位就换了大小写(第 46 章那个 flip)\n", 'a' ^ 32); printf(" 'Q' ^ 32 = %c\n", 'Q' ^ 32); printf(" ⚠ 但考场上请写 tolower / toupper:^ 32 只对字母成立,对别的字符是乱来的\n"); printf(" '1' ^ 32 = %d ← 变成了一个不可打印的控制字符,不是别的数字\n", '1' ^ 32);
printf("\n② 数字字符转数字\n"); string num = "2026"; int v = 0; for (char c : num) v = v * 10 + (c - '0'); printf(" \"%s\" 逐位减 '0' 再累加 = %d\n", num.c_str(), v); printf(" ⚠ 反过来:数字转字符是 + '0',比如 7 -> %c\n", (char)(7 + '0'));
printf("\n③ 判字母 / 数字\n"); const char* samples = "aZ3 _"; for (const char* p = samples; *p; p++) printf(" '%c':isalpha %d isdigit %d isspace %d\n", *p, isalpha((unsigned char)*p) ? 1 : 0, isdigit((unsigned char)*p) ? 1 : 0, isspace((unsigned char)*p) ? 1 : 0); printf(" ⚠ 参数要先转成 unsigned char —— char 在 x86 上是有符号的,\n"); printf(" 遇到非 ASCII 字节会变成负数,而这几个函数吃到负数是未定义行为。\n"); return 0;}点「运行 ▶」看结果
- 统一大小写要在最前面做一次(
w和t都转小写),别在比较的地方一个个转 —— 那样迟早漏一处。 tolower的参数要先转成unsigned char:char在 x86 上是有符号的, 遇到非 ASCII 字节会变成负数,而tolower(负数)是未定义行为。
4★ 「子串」不是「整词」—— 这道题最经典的那个 WA
看到「数出现次数」,第一反应多半是 find 一路找下去。它是错的:
// 错法一:直接拿 find 数「子串」//// 一眼看上去这道题就是「数子串出现几次」,于是顺手写了个 find 循环。// ★ 可题目要的是**完整单词**:`the` 不该在 `theme`、`theatre` 里被数到。//// ⇒ 靠什么现形:文章里要有**把目标词包在里面的更长的词**。// ⚠ 而「顺手写的生成器」只会随机拼几个不相干的词,撞上这种情况的概率很低 ——// 本章 gen.cpp 的档位 1 就是专门造它的。//// ★ 这也是这道题最经典的一个 WA:样例往往过得去(样例里正好没有那种词),测试点过不去。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
string w, t; cin >> w; getline(cin, t); getline(cin, t);
for (char& c : w) c = (char)tolower((unsigned char)c); for (char& c : t) c = (char)tolower((unsigned char)c);
int cnt = 0, first = -1; size_t pos = t.find(w); while (pos != string::npos) { // ⚠ 错在这里:数的是子串,不是完整单词 cnt++; if (first < 0) first = (int)pos; pos = t.find(w, pos + 1); }
if (cnt == 0) cout << -1 << '\n'; else cout << cnt << ' ' << first << '\n'; return 0;}点「运行 ▶」看结果
theme of the theatre is the theme 里,它会数到 5 个 the(theme 里一个、the 一个、
theatre 里一个、the 一个、theme 里一个),而正确答案是 2。
一个词是完整的,当且仅当它两边要么是空格,要么是行首 / 行尾。
⇒ 两条路,第 5 步和第 10 步各走一条:
- 切词:把文章切成一个个词,再逐个和
w比 —— 那么「完整」是免费的(切出来的本来就是完整词); - 补空格:给文章两端各补一个空格,要找的词也补上空格,然后当普通子串找 —— 空格挡住了两头,于是「完整」被编码进了要找的串里。
★ 这两条路对「单词边界」的理解完全不同,所以它们正好可以互相对拍(第 10 步)。
5★ 关键的一步:逐词切分
i 一路往右走:
先跳过所有空格 -> 此刻 i 停在一个词的开头
再往右走到下一个空格(或行尾) -> [i, j) 就是一个完整的词
判一次,然后 i = j,继续⚠ 一个词要到遇见空格(或行尾)的那一刻才判得出来 —— 在那之前你不知道它有多长。 这句话看着废,但它正是下一步那个动画要你看的东西。
// 正解:手写扫描,自己切单词 —— O(n),一遍过//// 这道题真正难的地方不是算法(它只有一个循环),是**三件很容易写错的小事**:// ① 读入:第一行是一个单词,第二行是**一整行**(含空格)—— 必须用 getline,// 而且 `cin >> w` 之后要先把残留的换行吃掉(见 readTrap.cpp);// ② 大小写:题目说不区分,所以两边都要先统一(字符就是整数,见 charInt.cpp);// ③ ★ **「完整单词」不是「子串」**:`the` 不该在 `theme` 里被数到。// 逐词切分之后这一条是免费的 —— 因为切出来的每一段本来就是完整单词。//// ⚠ 位置的口径:**第一次出现的那个单词,它首字母在整行里的下标(从 0 开始)**。// 不是「第几个单词」(那是 wrongPos.cpp 的错法)。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
string w, t; cin >> w; getline(cin, t); // ← 先把 w 那一行剩下的换行吃掉,否则下面读到的是空行 getline(cin, t);
for (char& c : w) c = (char)tolower((unsigned char)c); for (char& c : t) c = (char)tolower((unsigned char)c);
int cnt = 0, first = -1; int n = (int)t.size(); int i = 0; while (i < n) { while (i < n && t[i] == ' ') i++; // 跳过空格 if (i >= n) break; int j = i; while (j < n && t[j] != ' ') j++; // [i, j) 是一个完整单词 if ((int)w.size() == j - i && t.compare(i, j - i, w) == 0) { cnt++; if (first < 0) first = i; } i = j; }
if (cnt == 0) cout << -1 << '\n'; else cout << cnt << ' ' << first << '\n'; return 0;}点「运行 ▶」看结果
6动画:一个词是怎么被切出来、判出来的
★ 换到「子串陷阱」那一组看一眼:theme 和 theatre 都被红着判掉了 ——
它们含着目标词,但那不算。而这件事在切词的写法里是免费的,你一行特判都不用写。
7第二种做法:交给 stringstream
stringstream 能把一行字符串当成输入流,ss >> word 就自动按空格切词:
// 做法二:交给 stringstream 逐词切分//// `stringstream` 把一行字符串当成输入流,`ss >> word` 就自动按空格切词 ——// 三行就写完了,比手写扫描短得多。//// ⚠ 但它有两个代价,正文第 7 步会实测:// ① **它不是免费的**:每读一个词都要走一遍流的那套机制,比手写扫描慢几倍;// ② ★ **它不告诉你位置** —— 切出来的是单词本身,不是它在原串里的下标。// 所以要回答「第一次出现在第几个字符」,还得自己一边切一边累加长度,// 而那一步**很容易差一格**(词与词之间那个空格算谁的)。//// ⇒ 结论:只要问「有没有 / 有几个」,`stringstream` 很好用;一旦问**位置**,手写扫描更稳。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
string w, t; cin >> w; getline(cin, t); getline(cin, t);
for (char& c : w) c = (char)tolower((unsigned char)c); for (char& c : t) c = (char)tolower((unsigned char)c);
stringstream ss(t); string word; int cnt = 0, first = -1, pos = 0; while (ss >> word) { // ⚠ 这一句是「切词」之外多出来的活:从 pos 起找到这个词真正的起点 pos = (int)t.find(word, pos); if (word == w) { cnt++; if (first < 0) first = pos; } pos += (int)word.size(); }
if (cnt == 0) cout << -1 << '\n'; else cout << cnt << ' ' << first << '\n'; return 0;}点「运行 ▶」看结果
- 它不是免费的:每读一个词都要走一遍流的那套机制。
- ★ 它不告诉你位置 —— 切出来的是单词本身,不是它在原串里的下标。 要回答「第一次出现在第几个字符」,还得自己一边切一边找回位置,而那一步很容易差一格。
⇒ 结论:只问「有没有 / 有几个」时 stringstream 很好用;一旦问位置,手写扫描更稳。
// 换一把尺子:stringstream 到底比手写扫描慢多少//// 两种做法答案完全一样(对拍验过 300 轮),所以秒表是这里唯一看得见差别的东西 ——// 第 36 章那个第四盲区:**只影响常数、不影响答案的差别,对拍原理上看不见。**//// ⚠ 为了量的是「切词」本身而不是读入,这一份**自己造文章**(不吃 stdin),// 两种做法跑的是同一个字符串(第 29、34 章那条「量之前先确认你量的就是它」)。//// 用法:./race [单词数,默认 200000] [csv]
#include <bits/stdc++.h>#include <chrono>using namespace std;using namespace std::chrono;
static volatile long long sink = 0;
int main(int argc, char** argv) { int n = (argc > 1) ? atoi(argv[1]) : 200000; bool csv = (argc > 2 && string(argv[2]) == "csv"); if (n < 1) n = 1;
mt19937 rng(20260825u); string t; t.reserve((size_t)n * 6); for (int i = 0; i < n; i++) { if (i) t += ' '; if (rng() % 20 == 0) t += "the"; else { int len = 2 + (int)(rng() % 7); for (int k = 0; k < len; k++) t += (char)('a' + rng() % 26); } } const string w = "the";
/* ① 手写扫描 */ auto t0 = steady_clock::now(); long long c1 = 0; { int m = (int)t.size(), i = 0; while (i < m) { while (i < m && t[i] == ' ') i++; if (i >= m) break; int j = i; while (j < m && t[j] != ' ') j++; if ((int)w.size() == j - i && t.compare(i, j - i, w) == 0) c1++; i = j; } } double ms1 = duration<double, milli>(steady_clock::now() - t0).count();
/* ② stringstream */ t0 = steady_clock::now(); long long c2 = 0; { stringstream ss(t); string word; while (ss >> word) if (word == w) c2++; } double ms2 = duration<double, milli>(steady_clock::now() - t0).count();
sink += c1 + c2;
if (csv) { printf("scan,%.3f\nstream,%.3f\nsame,%d\ncount,%lld\n", ms1, ms2, c1 == c2 ? 1 : 0, c1); return 0; } printf("%d 个单词、%zu 个字符,找 \"the\"(本机实测)\n\n", n, t.size()); printf(" 手写扫描 %8.1f 毫秒\n", ms1); printf(" stringstream %8.1f 毫秒 慢 %.1f 倍\n", ms2, ms2 / ms1); printf("\n两种做法数出来的都是 %lld 个%s\n", c1, c1 == c2 ? "(一致 ✓)" : "(居然不一样,有 bug)"); printf("★ 答案一样,差别只在秒表上 —— 这正是对拍看不见的那一类差别(第 36 章第四盲区)。\n"); printf("⚠ 秒数换台机器就变,**倍数**才是要记住的东西。\n"); return 0;}点「运行 ▶」看结果
本机实测(A 机:WSL2 6.18 / 8 核 / 8 GB,2026-08-25,独占;20 万个词、118 万个字符):
| 做法 | 耗时 | |
|---|---|---|
| 手写扫描 | 2.2 毫秒 | |
stringstream |
5.7 毫秒 | 慢 2.6 倍 |
★ 两种做法的答案逐字节相同(300 轮对拍验过),差别只在秒表上 —— 这正是第 36 章那个第四盲区:只影响常数、不影响答案的差别,对拍原理上看不见。
8⚠ 第二个坑:s.size() 是无符号的
string::size() 返回的是 size_t(64 位无符号)。于是空串上 s.size() - 1 不是 −1,
而是 2⁶⁴ − 1。那么这一句:
for (int i = 0; i < s.size() - 1; i++) // ⚠ 空串时它要跑 1.8 x 10^19 次在空文章上会直接卡死 —— 而你的样例永远发现不了它,因为非空串上它跟正确写法没有任何差别。
// ★★ 第二个坑:`s.size()` 是**无符号**的//// `string::size()` 返回的是 `size_t`(64 位无符号)。于是空串上:// s.size() - 1 不是 -1,是 18446744073709551615// 而 `for (int i = 0; i < s.size() - 1; i++)` 这种写法,在空串上会试图跑 1.8×10¹⁹ 次 ——// 表现是「程序卡死」或者「乱七八糟的越界」,而**数据里只要有一组空串就会中招**。//// ⚠ 更阴的是:`i` 是 int、`s.size()` 是无符号,比较时 **int 会被转成无符号** ——// 所以哪怕 i 是负数,比较结果也可能和你想的相反。//// ⇒ 三种活得下去的写法:// ① `for (size_t i = 0; i + 1 < s.size(); i++)` —— 把减法换成加法,永远不下溢;// ② `for (int i = 0; i + 1 < (int)s.size(); i++)` —— 显式转成有符号;// ③ 干脆 `if (s.empty()) return ...;` 先把空串挡在外面。//// 本章 wrongSize.cpp 犯的就是这个错,而**只有「空文章」那一档数据能把它照出来**。
#include <bits/stdc++.h>using namespace std;
int main() { string s = ""; printf("空串:s.size() = %zu\n", s.size()); printf("★ s.size() - 1 = %zu ← 不是 -1\n", s.size() - 1); printf(" 也就是 %.1f × 10^19 次循环 —— 看起来就是「卡死」\n", (double)(s.size() - 1) / 1e19);
printf("\n当场就能看见的一个后果:\n"); try { string bad = s.substr(s.size() - 1); printf(" substr 居然没抛异常?拿到 [%s]\n", bad.c_str()); } catch (const std::out_of_range& e) { printf(" s.substr(s.size() - 1) 抛了 out_of_range:%s\n", e.what()); }
printf("\n两种写法在空串上的差别:\n"); int a = 0, b = 0; for (size_t i = 0; i + 1 < s.size(); i++) a++; // ✓ 加法,不下溢 for (int i = 0; i + 1 < (int)s.size(); i++) b++; // ✓ 显式转成有符号 printf(" i + 1 < s.size() 跑了 %d 次\n", a); printf(" i + 1 < (int)s.size() 跑了 %d 次\n", b); printf(" i < s.size() - 1 会跑 %zu 次 —— 就不真跑给你看了\n", s.size() - 1);
string t = "abc"; printf("\n非空串上它们没有任何差别(所以你的样例永远发现不了):\n"); a = b = 0; for (size_t i = 0; i + 1 < t.size(); i++) a++; for (size_t i = 0; i < t.size() - 1; i++) b++; printf(" \"abc\":两种写法都跑 %d / %d 次\n", a, b); return 0;}点「运行 ▶」看结果
for (size_t i = 0; i + 1 < s.size(); i++) // 把减法换成加法,永远不下溢
for (int i = 0; i + 1 < (int)s.size(); i++) // 显式转成有符号
if (s.empty()) { ... } // 干脆先把空串挡在外面⚠ 还有一条更阴的:i 是 int、s.size() 是无符号,比较时 int 会被转成无符号 ——
所以 i 哪怕是负数,比较结果也可能和你想的相反。
⇒ 和 size() 比大小时,要么两边都无符号,要么显式转成 int。
9string 的小抄(这一章用得到的全在这儿)
| 想干什么 | 怎么写 | 注意 |
|---|---|---|
| 读一个词 | cin >> s |
读到空格就停 |
| 读一整行 | getline(cin, s) |
⚠ 前面有 cin >> 的话先吃掉换行(第 2 步) |
| 长度 | s.size() |
⚠ 无符号(第 8 步) |
| 取一段 | s.substr(起点, 长度) |
只给起点 = 取到结尾;起点越界会抛异常 |
| 找子串 | s.find(t) |
找不到返回 string::npos,不是 −1 |
| 从某处往后找 | s.find(t, 从哪开始) |
数所有出现要用它 |
| 比一段 | s.compare(起点, 长度, t) |
相等返回 0;比 substr 省一次拷贝 |
| 拼接 | s += t / s.push_back(c) |
⚠ 循环里用 + 造新串会很慢 |
| 比大小 | s < t |
★ 字典序,不是长度序("z" > "abc") |
| 空吗 | s.empty() |
比 s.size() == 0 清楚 |
⚠ find 找不到时返回的 string::npos 是个无符号的最大值 ——
写成 if (s.find(t) < 0) 永远不成立。⇒ 一律写 if (s.find(t) == string::npos)。
10★ 对拍:四个写错的版本
标准答案用 brute.cpp —— 它走的是第 4 步那条另一条路:给两端补空格,然后当普通子串找。
两份代码对「单词边界」的理解完全不同,一边写歪了另一边一定对不上。
// 正解:手写扫描,自己切单词 —— O(n),一遍过//// 这道题真正难的地方不是算法(它只有一个循环),是**三件很容易写错的小事**:// ① 读入:第一行是一个单词,第二行是**一整行**(含空格)—— 必须用 getline,// 而且 `cin >> w` 之后要先把残留的换行吃掉(见 readTrap.cpp);// ② 大小写:题目说不区分,所以两边都要先统一(字符就是整数,见 charInt.cpp);// ③ ★ **「完整单词」不是「子串」**:`the` 不该在 `theme` 里被数到。// 逐词切分之后这一条是免费的 —— 因为切出来的每一段本来就是完整单词。//// ⚠ 位置的口径:**第一次出现的那个单词,它首字母在整行里的下标(从 0 开始)**。// 不是「第几个单词」(那是 wrongPos.cpp 的错法)。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
string w, t; cin >> w; getline(cin, t); // ← 先把 w 那一行剩下的换行吃掉,否则下面读到的是空行 getline(cin, t);
for (char& c : w) c = (char)tolower((unsigned char)c); for (char& c : t) c = (char)tolower((unsigned char)c);
int cnt = 0, first = -1; int n = (int)t.size(); int i = 0; while (i < n) { while (i < n && t[i] == ' ') i++; // 跳过空格 if (i >= n) break; int j = i; while (j < n && t[j] != ' ') j++; // [i, j) 是一个完整单词 if ((int)w.size() == j - i && t.compare(i, j - i, w) == 0) { cnt++; if (first < 0) first = i; } i = j; }
if (cnt == 0) cout << -1 << '\n'; else cout << cnt << ' ' << first << '\n'; return 0;}300 轮实测(种子 1..300,最终档 4):
| 故意写错的地方 | 被抓 | 靠什么现形 |
|---|---|---|
④ wrongPos:输出的是第几个单词 |
144 / 300 | 不挑数据(只要目标词不在开头) |
② wrongCase:忘了统一大小写 |
★ 143 / 300 | 大小写混合的数据 |
① wrongFind:直接数子串 |
★ 85 / 300 | 把目标词包住的更长的词 |
③ wrongSize:i < s.size() - 1 |
★ 37 / 300 | 空文章(外加一条意外的路,见下) |
★ 每一份的文件头都写着「它靠什么现形」—— 那不是注释,那是下一步调生成器的依据。
11★★ 生成器:两笔账,外加一条「一个 bug 有两条现形路径」
| 档位 | 相对上一档拧了什么 | ④ Pos | ② Case | ① Find | ③ Size |
|---|---|---|---|---|---|
| 0(顺手写法) | 3~8 个小写词,从一张小词表里随机取 | 92 | ★ 0 | ★ 7 | 23 |
| 1 | ★ 三成概率塞一个「包住目标词」的长词 | 151 | 0 | 97 | 6 |
| 2 | ★ 三成概率写成大写 / 混合大小写 | 92 | 222 | 7 | 23 |
| 3 | ★ 一成概率整篇文章是空的 | 102 | 183 | 9 | 47 |
| 4 ★ 最终档 | = 1 + 2 + 3 | 144 | 143 | 85 | 37 |
| 5 | 对照 = 4 − 子串陷阱 | 102 | 183 | ★ 9 | 47 |
| 6 | 对照 = 4 − 空文章 | 151 | 172 | 97 | ★ 6 |
① 「大小写混合」这个旋钮,单独决定 wrongCase 的死活。
顺手档(全小写):0 / 300,精确的 0;打开它:222 / 300。 ⇒ 顺手写生成器时最容易全用小写 —— 而题面明写着「不区分大小写」,这一条就永远测不到。
② 「子串陷阱」这个旋钮,单独决定 wrongFind 的死活。
撤掉它(档位 5):9 / 300;加回去:85 / 300。 ⚠ 注意顺手档不是 0 而是 7 —— 词表里本来就有互相包含的词(
to⊂into), 靠运气能撞上几次。但 7 / 300 意味着跑 43 轮才碰一次,而多数人对拍只跑 20 轮。
wrongSize(i < s.size() - 1)的那一列,撤掉「空文章」旋钮之后是 6,不是 0。
按前两笔账的逻辑,它「应该」归零 —— 可它没有。
去查那 6 轮长什么样,答案很清楚:
a
iS to tO a A <- 正解 "2 9",wrongSize 给 "1 9"⇒ 这个 bug 有第二条现形路径:循环少看最后一个字符,所以当最后一个词只有一个字母、
而它正好就是目标词时,计数会少一。它和「空文章」是同一个 -1 造成的,
但触发条件完全不同 —— 一个是下溢,一个是少扫一格。
★ 能带走的那句话:「我知道这个 bug 靠什么现形」经常只知道了一半。 ⇒ 对照档跑出来不是 0 的时候,别急着说旋钮没用 —— 先去看那几轮到底长什么样。 (第 39 章那条「预判被实测打掉」的另一面:这次预判没错,只是不全。)
12这一章没讲的,和接下来三章
| 没讲的 | 在哪一章 | 一句话 |
|---|---|---|
| KMP | 第 48 章 | 匹配失败时,i 一步都不用退 —— 已经匹配上的那一段自己知道该退到哪 |
| 字符串哈希 | 第 49 章 | 把子串变成一个数,比较 O(1);★ 以及它为什么会错 |
| Trie(字典树) | 第 50 章 | 一堆字符串摆成一棵树,查前缀只看串长,和串的数量无关 |
| 回文 / 后缀数组 / AC 自动机 | 都没讲 | CSP-J 用不到;S 组的进阶内容 |
★ 但先把这一章的三个坑记牢:读入、size() 的符号、整词不是子串。
后面三章的代码再漂亮,栽在这三条上一样是 0 分。
13自测
- 洛谷 P1308 [NOIP 2011 普及组] 统计单词数 —— ★ 就是这一章那道题的原题。三个坑一个不少:读入、大小写、整词不是子串
- 洛谷 P5015 [NOIP 2018 普及组] 标题统计 —— ★ 数一行里有几个非空格字符。⚠ 坑全在读入上:标题可能以空格开头,也可能是空行 —— 正好练第 2、8 步
- 洛谷 P1055 [NOIP 2008 普及组] ISBN 号码 —— 字符转数字(减 '0')、取模、最后一位可能是 X。第 3 步那一节的直接应用
- 洛谷 P1200 [USACO1.1] 你的飞碟在这儿 —— 把字母变成数字再连乘取模 —— 「字符就是整数」最直白的一道题
- 洛谷 P1598 垂直柱状图 —— ⚠ 要读若干行(含空格)、统计字母、再竖着输出。★ 输出格式是这道题真正的考点,末尾多一个空格就 WA
- 洛谷 P1071 [NOIP 2009 提高组] 潜伏者 —— ⚠ 提高组,但用到的只有这一章的东西:字符映射 + 一致性检查。★ 三种「无解」的情况都要判到,是练边界的好题
- ★ 切词写法里,「完整单词」是免费的 —— 因为切出来的每一段本来就是完整单词。
而
find数的永远是子串,这两件事从一开始就不是一回事。 - ⚠
cin >>之后的getline会读到空行。 程序不报错,只是后面全错。 - ⚠⚠
s.size()是无符号的。s.size() - 1在空串上是 18446744073709551615, 而你的样例永远发现不了它 —— 只有「空文章」那一档数据能。