0题目原文(本地存了一份)
原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。
转录自洛谷 P1308,日期见页头。两边不一致时信原站。
题目描述
一般的文本编辑器都有查找单词的功能,该功能可以快速定位特定单词在文章中的位置, 有的还能统计出特定单词在文章中出现的次数。
现在,请你编程实现这一功能,具体要求是:给定一个单词,请你输出它在给定的文章中出现的次数和 第一次出现的位置。注意:匹配单词时,不区分大小写,但要求完全匹配,即给定单词必须与文章中的 某一独立单词在不区分大小写的情况下完全相同(参见样例 1),如果给定单词仅是文章中某一单词的 一部分则不算匹配(参见样例 2)。
输入格式
共 2 行。
第 1 行为一个字符串,其中只含字母,表示给定单词;
第 2 行为一个字符串,其中只可能包含字母和空格,表示给定的文章。
输出格式
一行,如果在文章中找到给定单词则输出两个整数,两个整数之间用一个空格隔开,分别是单词在文章中
出现的次数和第一次出现的位置(即在文章中第一次出现时,单词首字母在文章中的位置,位置从 0 开始);
如果单词在文章中没有出现,则直接输出一个整数 -1。
注意:空格占一个字母位。
数据范围
1 ≤ 第一行单词长度 ≤ 10。
1 ≤ 文章长度 ≤ 10⁶。
noip2011 普及组第 2 题。时限 1 秒,内存 128000 KB(125 MB)。
输入输出样例
输入
To to be or not to be is a question
输出
2 0
to 出现两次,第一次的首字母在下标 0。⚠ 单词写的是 To、文章里是 to —— 不区分大小写。
输入
to Did the Ottoman Empire lose its power at that time
输出
-1
★ to 明明在 Ottoman 里出现过,可那不是一个完整的单词 ⇒ 一次都不算,输出一个 -1。
这道题考什么:读入一个单词和一篇文章,统计单词在文章中出现的次数和第一次出现的位置(不区分大小写,但要求完全匹配)。正解是第 47 章的字符串切词。
最容易错的三处
- 第二行用
cin >> t读:遇到空格就停,文章只剩第一个单词。 cin >> w >> ws:ws会把文章开头的空格也吃掉,位置偏移。- 没找到时打
0 -1:题面要求没找到直接输出一个整数-1。
能 AC 的:p1308.cpp(读一整行 → 全转小写 → 逐词切分)这一版能 AC。时限 1 秒。
下面每一步里灰色的「深入」块是折叠的:那是这些结论怎么实测出来的,第一遍可以跳过。
1★★ 这一页不再讲一遍「切词」—— 它去量正文没称过重量的那一关
这道题就是第 47 章正文那道题的原题,而且题面几乎一字不差 (不像第 46 章 P1469 那次 —— 那道题正文和真题的数据范围差了 32 倍)。 ⇒ 正文第 4、5、7、10 步已经把「切词 vs 补空格」「子串不是整词」「stringstream 的两个代价」 讲透了,这一页再讲一遍没有意义。
★ 所以这一页换一个角度:读入。正文第 2 步只用一张表提了三种写法,
其中第 ③ 行后面挂着一句没有数字的提醒(「>> ws 会把正文开头的空格也吃掉」),
第 7 步也挂着一句(「一旦问位置,stringstream 那一步很容易差一格」)。
⇒ 这一页把那两句提醒各量成了一个数,外加两个只跟输入输出有关的错法。
⚠⚠ 而这四个错法有一个共同点,它才是这一页真正的主线: 它们全都不是「算法写错了」,是「读进来的东西和题面说的不是一回事」。
// P1308 统计单词数 —— 正解:读一整行,全转小写,逐词切分//// ★★ 这道题就是[第 47 章正文](/ch/47-string-basics/)那道题的原题,题面几乎一字不差。// ⇒ 所以这一页**不再讲一遍「切词 vs 补空格」**(正文第 4、5、7 步已经讲透了),// 它去量正文只提了一句、没有称过重量的那一关:**读入**。//// ⚠ 这一份里和读入有关的有三处,每一处都对应这一页的一个错法:// ① 第二行必须 `getline` 读**一整行** —— `cin >> t` 只会读到第一个空格(见 p1308Word.cpp);// ② `cin >> w` 之后要先把那个残留的换行吃掉,而**不能用 `>> ws`** ——// `ws` 会连**文章开头的空格**一起吃掉,而题面说文章「只可能包含字母和空格」,// 开头就是空格是合法输入,位置会整体偏移(见 p1308Ws.cpp);// ③ 位置要从**原串的下标**来,别拿切出来的词长累加(见 p1308Stream.cpp)。#include <bits/stdc++.h>using namespace std;
int main() { string w, t; if (!(cin >> w)) return 0; getline(cin, t); // ★ 先吃掉第一行剩下的换行符(正文第 2 步那个坑) if (!getline(cin, t)) t = ""; // ⚠ 文章可能是空行 —— 这是合法输入
for (char& c : w) c = (char)tolower((unsigned char)c); for (char& c : t) c = (char)tolower((unsigned char)c);
long long cnt = 0; long long first = -1; size_t i = 0, n = t.size(); while (i < n) { while (i < n && t[i] == ' ') i++; // 跳过空格 -> 停在一个词的开头 if (i >= n) break; size_t j = i; while (j < n && t[j] != ' ') j++; // [i, j) 就是一个完整的词 if (j - i == w.size() && t.compare(i, j - i, w) == 0) { cnt++; if (first < 0) first = (long long)i; // ★ 位置直接就是原串的下标 } i = j; } if (cnt == 0) printf("-1\n"); else printf("%lld %lld\n", cnt, first); return 0;}点「运行 ▶」看结果
2⚠ 错法一:第二行用 cin >> t 读 —— 它在回答另一道题
cin >> t 遇到空格就停 ⇒ 读进来的「文章」只剩第一个单词。
于是这一版恒等于在回答另一道题:
「给定单词是不是文章的第一个词?是就输出
1 0,不是就输出-1。」
⇒ ★★ 说清楚这一句之后,它的所有表现都是白送的推论 (第 14 章 P1332 立的那条规矩):
- 它放过的输入只有两种:答案本来就是
-1,或者「w 只出现一次、而且就在开头(下标 0)」; - 别的输入它一定错 ⇒ 下面那张表里它是四档 159 / 179 / 153 / 180, 而这四个数和上面那句话数出来的轮数一个不差。
⚠ 它最难受的地方是:程序不崩、格式完全正常、-Wall 一声不吭,
而官方样例一(2 0 → 它打 1 0)当场就打死它 —— 这一个它躲不掉。
// P1308 ✗ 错法一:第二行用 `cin >> t` 读//// ⚠ 这是这道题上最常见的第一版 —— 它一个字都不错,只是**读少了**:// `cin >> t` 遇到空格就停,于是「文章」只剩下第一个单词。// ⇒ ★ 它的触发条件是一句话:**文章里不止一个词**(也就是几乎所有输入)。// 而 `-Wall` 不会响、程序不会崩、格式完全正常 —— 它只是**在回答另一道题**:// 「w 等不等于文章的第一个词」。#include <bits/stdc++.h>using namespace std;
int main() { string w, t; if (!(cin >> w)) return 0; if (!(cin >> t)) t = ""; // ⚠ 只读到第一个空格
for (char& c : w) c = (char)tolower((unsigned char)c); for (char& c : t) c = (char)tolower((unsigned char)c);
long long cnt = 0, first = -1; size_t i = 0, n = t.size(); while (i < n) { while (i < n && t[i] == ' ') i++; if (i >= n) break; size_t j = i; while (j < n && t[j] != ' ') j++; if (j - i == w.size() && t.compare(i, j - i, w) == 0) { cnt++; if (first < 0) first = (long long)i; } i = j; } if (cnt == 0) printf("-1\n"); else printf("%lld %lld\n", cnt, first); return 0;}点「运行 ▶」看结果
3⚠ 错法二:cin >> w >> ws —— 正文那句提醒,量成了一个数
ws 的语义是「跳过所有空白」,而换行是空白、空格也是。
⇒ 文章开头有几个空格,读到的就短几个字符。
★ 而这不是钻牛角尖:题面第 2 行写的是「其中只可能包含字母和空格」, 输出格式那一节还专门补了一句「注意:空格占一个字母位」—— ⇒ 开头就是空格,是完全合法的输入,而且题面提前替你把这条路封死了。
★★ 这个 bug 只污染输出的一半(第 31 章 P2712 那条): 次数永远是对的,只有位置偏了,而偏移量恰好等于文章开头的空格数 —— 下面那张表里「位置差 ≡ 行首空格数」在两个专门档上是 179 / 179 和 180 / 180,一轮不差。
4⚠ 错法三:stringstream 切词,位置靠「词长 + 1」累加
while (ss >> x) {
if (x == w) { ... first = pos; }
pos += x.size() + 1; // ⚠ 这个 +1 假设了「词之间恰好一个空格」
}stringstream 帮你切词,代价是它不告诉你位置。于是所有人都会顺手写上面那一行 ——
而它把「词与词之间恰好一个空格」当成了公理。题面允许的是「只可能包含字母和空格」,
⇒ 连续两个空格、行首空格,都是合法输入。
★ 和上一个错法一样,它也只污染一半:次数永远对,位置偏。 ⚠ 而两个 bug 的触发条件部分重叠(行首空格两个都吃)—— 所以下面那张表要三档分开量,才看得出谁是谁的(第 46 章 P2114 那条的又一次)。
5⚠ 错法四:没找到时打的是 0 -1
题面的输出格式写着两句话:找到了输出两个整数;没找到直接输出一个整数 -1。
顺手写成「不管找没找到都打两个数」,在「一次都没出现」的那些测试点上全 WA。
⇒ 被抓的轮数 ≡ 正解输出 -1 的轮数(四档 123 / 121 / 128 / 120,一个不差)。
★ 而官方样例二正好就是这种输入 ⇒ 这个错法一交样例就死。
6★★★ 对拍:四个错法 × 四个档位,十六格「触发 ≡ 抓获」一个不差
// P1308 的生成器:./p1308Gen 种子 [档位]//// ★ 动笔之前先写清楚「每个错法靠什么现形」([第 46 章第 13 步](/ch/46-bitwise/)那张清单):// · p1308Word(`cin >> t`) → **文章不止一个词**(几乎所有输入,一抓一个准);// · p1308Ws(`>> ws`) → **文章开头有空格**,而且答案不是 -1;// · p1308Stream(位置靠词长累加) → **第一次命中之前出现过多余的空格**(行首空格 或 连续空格);// · p1308Neg(没找到打 `0 -1`) → **答案正好是 -1**。//// ⚠⚠ 关键在于:后三条要的那些输入,**顺手写的生成器一个都不会造** ——// 谁会主动在文章开头放空格、在词之间放两个空格?可题面只说文章// 「只可能包含字母和空格」,这三种全是**合法输入**。// ⇒ 这正是[第 20 章 P1020](/sol/p1020/)那条「生成器该照抄题面」的另一面:// **照抄的不只是规模,还有题面允许的那些形状。**//// 档位:// 0 ★ 顺手写的:3~12 个词、词间恰好一个空格、开头不留空格// 1 ⚠ 开头留 1~3 个空格(p1308Ws 的专门档)// 2 ⚠ 词间 1~3 个空格(p1308Stream 的专门档)// 3 ★ 最终档:两个旋钮一起开// 9 ★ 顶格:文章正好 10⁶ 个字符(题面上限)—— 给 p1308Read.cpp 量读入用//// ⚠ rng() 一律先落到具名变量再传参([第 24 章 P1776](/sol/p1776/) 那一跤)。#include <bits/stdc++.h>using namespace std;
int main(int argc, char** argv) { unsigned seed = argc > 1 ? (unsigned)atoi(argv[1]) : 1; int mode = argc > 2 ? atoi(argv[2]) : 0; mt19937 rng(seed * 1000003u + 20260907u);
const bool leadSpace = (mode == 1 || mode == 3); const bool multiSpace = (mode == 2 || mode == 3); const bool huge = (mode == 9);
/* 词表:几个短词,其中一个是要找的 w —— 短词表才有足够的命中率 */ const char* pool[] = {"to", "the", "be", "or", "not", "is", "a", "an", "at"}; const int POOL = 9; unsigned r0 = rng() % (unsigned)POOL; string w = pool[r0];
unsigned r1 = rng() % 10u; int words = 3 + (int)r1; if (huge) words = 1 << 30; // ★ 顶格档:写满 10⁶ 个字符为止
string t; if (leadSpace) { unsigned r = rng() % 3u; t.append((size_t)(1 + r), ' '); } for (int i = 0; i < words; i++) { if (huge && t.size() >= 999990) break; // ★ 顶格档:留出最后一个词的余量 if (i) { int gap = 1; if (multiSpace) { unsigned r = rng() % 3u; gap = 1 + (int)r; } t.append((size_t)gap, ' '); } unsigned r = rng() % (unsigned)POOL; string x = pool[r]; /* 大小写随机翻转 —— 题面说「不区分大小写」*/ for (char& c : x) { unsigned f = rng() % 2u; if (f) c = (char)toupper((unsigned char)c); } t += x; } if (huge) while (t.size() < 1000000) t.push_back('x'); // 补到正好 10⁶
unsigned rw = rng() % 2u; if (rw) for (char& c : w) c = (char)toupper((unsigned char)c);
printf("%s\n%s\n", w.c_str(), t.c_str()); return 0;}点「运行 ▶」看结果
深入:四档对拍表与三条结论(进阶)
四档 × 300 轮(正解 vs 补空格版:1200 轮 0 组不一致):
| 档位 | ✗ cin >> t |
✗ >> ws |
✗ 累加位置 | ✗ 打 0 -1 |
|---|---|---|---|---|
| 0 ★ 顺手写的(词间恰好一个空格、开头不留空格) | 159 | ★ 0 | ★ 0 | 123 |
| 1 ⚠ 行首留 1~3 个空格 | 179 | 179 | 179 | 121 |
| 2 ⚠ 词间 1~3 个空格 | 153 | ★ 0 | 121 | 128 |
| 3 ★ 两个旋钮一起开 | 180 | 180 | 180 | 120 |
-
★★★ 十六格全部是「触发 ≡ 抓获」,而四条触发条件长得完全不一样:
错法 它的触发条件(一句话) cin >> t答案不是 -1,而且不是「w 只出现一次且就在下标 0」>> ws文章开头有空格,而且答案不是 -1累加位置 第一次命中之前出现过多余的空格(行首空格 或 连续两个空格),且答案不是 -1打 0 -1答案正好是 -1⇒ ★★ 四条都能写成一句精确的话,所以 ≡ 是必然的 (第 44 章那条:它是不是运气,取决于你能不能把「它算了什么」写成式子)。
-
★★★ 顺手写的那一档(档 0)把两个错法一起打成了精确的 0 —— 而那两个 0 不是概率低,是结构性的:谁会主动在文章开头放一个空格、在两个词之间放两个空格? ⇒ 而题面明写着文章「只可能包含字母和空格」,这两种形状全都合法。 ⇒ ★★ 第 22 章 P1020 那条规矩在这儿要再推一步: 生成器该照抄的不只是题面的规模和比值,还有题面允许的那些形状。
-
★ 两个「只污染一半」的 bug 各自的一半都验过了:
>> ws版和累加版的次数在 1200 轮里和正解完全相同, 而>> ws版的位置差逐轮等于「文章开头的空格数」。
7★★ 读入那笔账:这道题的读入关是「读对」,不是「读快」
// P1308 —— 四种读法读同一份输入,各自报「读进来花了多少毫秒」。// ./p1308Read sync [文件] 默认的 cin + getline(什么都不关)// ./p1308Read nosync [文件] ios::sync_with_stdio(false) + getline// ./p1308Read scanf [文件] scanf("%s") + scanf("%[^\n]")// ./p1308Read fread [文件] 一次 fread 把整份输入吞进来,自己切两行//// ⚠⚠⚠ 第二个参数不是可有可无的(2026-09-07 在 check:viz 里当场撞出来的):// 给了文件就 `freopen` 到 stdin 上 —— **评测机做的正是这件事**(把 stdin 重定向到一个文件)。// 不给文件时读的是**管道**,而管道那一头是谁、写得多快,**会被一起量进来**:// 同一个二进制、同一份数据,从文件读 0.7 毫秒,被 node 用管道喂是 3 毫秒以上// ——⇒ 关同步那一版快到「它在等喂数据的人」,量到的于是不再是它自己的速度。// ⇒ ★★ 这和[第 46 章 P1469](/sol/p1469/) 那条「量内存要量差值,因为起步那一截取决于谁 spawn 它」// 是同一件事的另一半:**量什么都要先问一句「这个数里有没有别人的份」。**//// ★ 为什么要单独量这一关:题面的文章长度是 **10⁶**,// 而[第 6 章 P2367](/sol/p2367/) / [第 38 章 P1972](/sol/p1972/) 那两页立过一条规矩 ——// **四种读法的倍数跨题几乎不变,变的是绝对时间,而分数线在绝对时间上。**// ⇒ 所以这道题该不该写读入优化,是一道要**乘一遍**才能回答的题,不能背。//// ⚠ 输出里也带上算出来的答案:四种读法**必须给出同一个答案**,// 否则量到的就是「读丢了一截」而不是「读得快」([第 12 章 P1923](/sol/p1923/) 那个 33.5 MB 的桶)。#include <bits/stdc++.h>using namespace std;
static string answer(string w, string t) { for (char& c : w) c = (char)tolower((unsigned char)c); for (char& c : t) c = (char)tolower((unsigned char)c); long long cnt = 0, first = -1; size_t i = 0, n = t.size(); while (i < n) { while (i < n && t[i] == ' ') i++; if (i >= n) break; size_t j = i; while (j < n && t[j] != ' ') j++; if (j - i == w.size() && t.compare(i, j - i, w) == 0) { cnt++; if (first < 0) first = (long long)i; } i = j; } char buf[64]; if (cnt == 0) snprintf(buf, sizeof(buf), "-1"); else snprintf(buf, sizeof(buf), "%lld %lld", cnt, first); return string(buf);}
static vector<char> big; // scanf / fread 两种模式要的落脚处
int main(int argc, char** argv) { string mode = argc > 1 ? argv[1] : "sync"; if (argc > 2 && !freopen(argv[2], "r", stdin)) { fprintf(stderr, "open failed\n"); return 1; } string w, t;
auto t0 = chrono::steady_clock::now(); if (mode == "nosync") { ios::sync_with_stdio(false); cin.tie(nullptr); cin >> w; getline(cin, t); if (!getline(cin, t)) t = ""; } else if (mode == "scanf") { big.assign(1 << 21, 0); if (scanf("%s", big.data()) == 1) w = big.data(); int c = getchar(); while (c != EOF && c != '\n') c = getchar(); big[0] = 0; if (scanf("%1048576[^\n]", big.data()) == 1) t = big.data(); else t = ""; } else if (mode == "fread") { big.clear(); char chunk[1 << 16]; size_t got; while ((got = fread(chunk, 1, sizeof(chunk), stdin)) > 0) big.insert(big.end(), chunk, chunk + got); size_t p = 0, n = big.size(); while (p < n && big[p] != '\n') p++; w.assign(big.begin(), big.begin() + (long)p); size_t q = ++p; while (q < n && big[q] != '\n') q++; if (p <= n) t.assign(big.begin() + (long)p, big.begin() + (long)min(q, n)); } else { cin >> w; // ★ 默认:sync_with_stdio 开着 getline(cin, t); if (!getline(cin, t)) t = ""; } auto t1 = chrono::steady_clock::now(); double readMs = chrono::duration<double, milli>(t1 - t0).count();
auto t2 = chrono::steady_clock::now(); string ans = answer(w, t); auto t3 = chrono::steady_clock::now(); double calcMs = chrono::duration<double, milli>(t3 - t2).count();
printf("%s,%.2f,%.2f,%zu,%s\n", mode.c_str(), readMs, calcMs, t.size(), ans.c_str()); return 0;}点「运行 ▶」看结果
深入:四种读法的毫秒表与「读对 vs 读快」(进阶)
本机实测(A 机:WSL2 6.18 / 8 核 / 8 GB,2026-09-07,独占;顶格输入:文章正好 10⁶ 个字符, 约 3.5 万个单词;每种读法跑 3 次取中位数):
| 读法 | 读进来 | 算答案 | 合计 | 相对最慢 |
|---|---|---|---|---|
默认 cin + getline(什么都不关) |
10.6 ms | 3.4 ms | 14.0 ms | 1.0× |
ios::sync_with_stdio(false) + getline |
★ 0.72 ms | 3.4 ms | 4.1 ms | 14.7 倍 |
scanf("%[^\n]") |
2.0 ms | 3.4 ms | 5.4 ms | 5.3 倍 |
一次 fread 吞进来 |
1.3 ms | 3.4 ms | 4.7 ms | 7.9 倍 |
四种读法之间差 14.7 倍 —— 和第 6 章 P2367、第 41 章 P3383 那几页量到的倍数是同一个量级。可这道题最慢的那一种也只要 10.6 毫秒,而时限是 1 秒。
⇒ ★★★ 这正是那条规矩的又一次现场: 四种读法的倍数跨题几乎不变,变的是绝对时间,而分数线在绝对时间上。
| 要读多少 | 时限 | 分数线在哪 | |
|---|---|---|---|
| 第 6 章 P2367 | 2 × 10⁷ 个整数 | 1 秒 | ⚠ 连 scanf 都不够(要快读) |
| 第 38 章 P1972 | 约 20 MB | 2 秒 | 默认 cin 够,余量只剩 2.9 倍 |
| ★ 本题 | 10⁶ 个字符(约 1 MB) | 1 秒 | ★ 四种全够,余量 94 倍 |
⚠ 内存那笔账更省事:w 和 t 加起来约 1 MB,限制 125 MB ⇒ 一句除法就问完了。
⇒ ★★ 所以这道题的读入关整个长在「读对」那一侧:
cin >> t 少读了一大截、>> ws 少读了开头几个空格 —— 两个都不是慢,是错。
上面那张表是从文件读量的(freopen)—— 评测机做的正是这件事:把 stdin 重定向到一个文件。
★ 第一版的断言图省事,直接让 check:viz 用管道把那 1 MB 喂进去。结果:
| 从文件读 | 从管道读(node 一边写一边被读) | 多出来 | |
|---|---|---|---|
默认 cin |
10.8 ms | 11.6 ms | +0.8 ms(+8%) |
| ★ 关同步 | 0.94 ms | 1.45 ms | +0.5 ms(★ +54%) |
scanf |
2.14 ms | 2.60 ms | +0.5 ms(+21%) |
fread |
1.63 ms | 1.89 ms | +0.3 ms(+16%) |
(这一趟是在 check:viz 里量的,每格 3 次取中位数。⚠ 那一列多出来的是亚毫秒的量,
换一趟能差两成 ⇒ 断言里只钉「四种读法多出来的都不到 2.5 毫秒」和「结论本身不受影响」这两件事,
百分比只当量级看 —— 第 38 章 P1177 那条:亚毫秒的秒表不能只跑一次。)
多出来的那一截四种读法几乎一样多(都不到 1 毫秒),因为它根本不是程序的属性 —— 它是喂数据那一头的速度。可它的占比从 8% 一路走到 50% 上下: 关同步那一版快到「它在等写数据的人」,量到的就不再是它自己了。
⇒ ★★★ 一个加性开销会把小数字整个吃掉,却在大数字上完全隐形 ——
和第 34 章 P3366(不减掉读入那 19 毫秒会得出相反结论)、
第 46 章 P1469(ru_maxrss 里「进程起步」那一截取决于谁 spawn 它)
是同一件事的第三次:⇒ 量什么都要先问一句「这个数里有没有别人的份」。
⚠ 代价很具体:第一版的断言写的是「关同步快 14.7 倍」,在管道上当场变成 8.0 倍, 正好卡在门槛上翻红。
8★ 官方两组样例:各打死一个,另外两个它们都碰不到
✗ cin >> t |
✗ >> ws |
✗ 累加位置 | ✗ 打 0 -1 |
|
|---|---|---|---|---|
样例一(2 0) |
★ 死(打 1 0) |
放过 | 放过 | 放过 |
样例二(-1) |
放过 | 放过 | 放过 | ★ 死(打 0 -1) |
★ 两组样例各打死一个,而剩下那两个两组都放过 —— 原因说得出来: 它俩要的输入形状是「行首空格」和「连续两个空格」, 而官方那两行文章都是干干净净的「一个词一个空格」。
⇒ ★★★ 这是「这组样例在结构上问不出这个问题」的又一次, 而这一次「结构」两个字特别具体:它就是题面允许、样例里却一次都没出现的那两种空格。 ⇒ 而顺手写的生成器和官方样例恰好犯同一个毛病(上一步档 0 那两个精确的 0)—— ⚠ 样例和对拍一起漏掉的那一格,正是最难自己想到的那一格。
9★ 哪一版就已经能过了
| 版本 | 结果 | 说明 |
|---|---|---|
★ p1308.cpp(切词) |
AC | 顶格 14 毫秒 / 时限 1 秒,余量 71 倍 |
★ p1308Pad.cpp(补空格) |
AC | 1200 轮和切词版逐字节相同;选哪条只看你顺手 |
✗ cin >> t |
WA | 样例一就死 |
✗ >> ws |
WA | ⚠ 样例全过,位置偏 |
| ✗ 累加位置 | WA | ⚠ 样例全过,位置偏 |
✗ 打 0 -1 |
WA | 样例二就死 |
⇒ ★★★ 一句话带走:这道题的四个坑一个都不在算法里 —— 两个在「有没有把整行读进来」,一个在「位置从哪儿数」,一个在「输出格式」。 ⇒ 而它们共用一句判据:把题面那两行「输入格式」逐字读一遍, 问问自己「这句话允许的输入,我的程序真的能读对吗」。
10★★★ 学完第 48 章之后回头看这道题:KMP 在这儿帮不上忙
第 48 章(KMP)的题单里也列着这道题,注解写的是:
「第 47 章那道题。回过头用 KMP 再写一遍,会发现『整词』那个条件 KMP 帮不上忙 —— 该切词还得切词。」
⇒ 这一节就是把那句话量出来。(★ 而这一页因此成了本书第七次
「命中登记表 ≠ 不用写」—— 拿第 48 章的关键词 grep 这一页,原来 0 处命中。)
深入:KMP 为什么帮不上忙:从「子串 vs 整词」到 1.00 倍(进阶)
学完 KMP 之后最自然的一版是「拿 w 在 t 里跑一遍 KMP」。
⚠ 那份代码可以一个字都不错,而答案还是错的:
the 在 theme of the theatre is the theme 里 |
|
|---|---|
| ★ 正解(切词) | 2 次,第一次在下标 9 |
✗ 直接对 w 跑 KMP |
5 次,第一次在下标 0 |
⇒ 它和第 47 章正文第 4 步那个 wrongFind 输出完全一样 ——
那一版用的是 string::find,这一版用的是 KMP,同一个读题错误,换个算法照样错。
⇒ ★★★ 换一个更强的算法,不会让一个读题错误自己变对。
⚠⚠ 而这个错法有意思的地方在谁抓得到它:
| 抓到没有 | |
|---|---|
★ 官方样例二(to / Did the Ottoman Empire…) |
★ 一测就死:正解 -1,它打出 1 10(Ottoman 里那个 to) |
| 上面第 ⑥ 步那个顺手写的对拍(四档 × 300 轮) | ⚠ 只抓到 23 / 23 / 27 / 25 |
★ 出题人早就为这件事准备了样例二 —— 题面里那句
「如果给定单词仅是文章中某一单词的一部分则不算匹配(参见样例 2)」就是明说的。
⚠ 而顺手写的生成器抓不到它,原因也很具体:那个词表里全是 to / the / be 这种短词,
它们很少互相包含 ⇒ 「子串」和「整词」大部分轮次给出同一个答案
(四格「触发 ≡ 抓获」一个不差:触发条件就是「子串的出现情况 ≠ 整词的」)。
⇒ ★★ 第 41 章 P1217 那条又一次:出题人是照着题面那句话挑样例的,随机生成器不会。
// P1308 ✗ 「学完 KMP 之后」最容易写出来的那一版:直接拿 w 跑 KMP//// ⚠⚠ 它把 KMP 写得一个字都不错 —— 而**答案还是错的**,// 因为 KMP 回答的是「w 作为**子串**出现了几次」,题面问的是「作为**整词**」。// ⇒ 它和[第 47 章正文第 4 步那个 wrongFind](/ch/47-string-basics/) **输出完全一样**:// `the` 在 `theme of the theatre is the theme` 里数出 **5** 个(正解是 2)。// ★★ ⇒ **换一个更强的算法,不会让一个读题错误自己变对。**#include <bits/stdc++.h>using namespace std;
int main() { string w, t; if (!(cin >> w)) return 0; getline(cin, t); if (!getline(cin, t)) t = ""; for (char& c : w) c = (char)tolower((unsigned char)c); for (char& c : t) c = (char)tolower((unsigned char)c);
int n = (int)t.size(), m = (int)w.size(); if (m == 0 || n < m) { printf("-1\n"); return 0; } vector<int> nxt((size_t)m, 0); for (int i = 1, j = 0; i < m; i++) { while (j > 0 && w[(size_t)i] != w[(size_t)j]) j = nxt[(size_t)(j - 1)]; if (w[(size_t)i] == w[(size_t)j]) j++; nxt[(size_t)i] = j; } long long cnt = 0, first = -1; for (int i = 0, j = 0; i < n; i++) { while (j > 0 && t[(size_t)i] != w[(size_t)j]) j = nxt[(size_t)(j - 1)]; if (t[(size_t)i] == w[(size_t)j]) j++; if (j == m) { // ⚠ 这里没有任何「两边是不是空格」的检查 cnt++; if (first < 0) first = i - m + 1; j = nxt[(size_t)(j - 1)]; } } if (cnt == 0) printf("-1\n"); else printf("%lld %lld\n", cnt, first); return 0;}点「运行 ▶」看结果
把边界编码进模式串:文章两端补空格、要找的词也补上空格 —— 于是「整词」变成了「子串」,KMP 这才有用武之地。
文章: " to be or not to be " <- 两端各补一个空格
模式: " to " <- 边界进了模式串⇒ 这正是第 ① 步那个 p1308Pad.cpp 走的路,只是把 string::find 换成了 KMP。
⚠ 而它有一个只有这条路才有的坑:相邻两个词共用一个空格
⇒ 命中之后 j 不能归零,要退到 nxt[j-1](否则连着的两个目标词会漏掉一个)。
11★★ 而换成 KMP 之后,它一分钱都不值 —— 比较次数 1.00 倍
// P1308 —— 「学完 KMP 之后该不该换掉它」这笔账。// ./p1308Race 人话版// ./p1308Race csv 给 check:viz 用//// ★★ 三条路在顶格(文章 10⁶ 个字符)上各做多少次**字符比较**(机器无关的尺子):// ① 切词(正文正解):每个字符看一遍,命中的词再逐字比一次;// ② 补空格 + 朴素 find:失配就回退,最坏 O(nm);// ③ 补空格 + KMP:i 一步都不退,O(n+m)。// ⚠ 而这道题的 `|w| ≤ 10` ⇒ ② 的最坏也只有 ~10n// ⇒ **KMP 在这道题上省的是一个「最多 10 倍」的常数**,不是一个数量级。// ★ 两种形状各量一遍:随机词 / 专门卡 ② 的形状(每个词都和 w 差最后一个字母)。#include <bits/stdc++.h>using namespace std;
/** ① 切词:跳空格 → 取一个词 → 长度对得上才逐字比 */static long long scanCmp(const string& w, const string& t) { long long cmp = 0; size_t i = 0, n = t.size(), m = w.size(); while (i < n) { while (i < n && t[i] == ' ') { cmp++; i++; } if (i >= n) break; size_t j = i; while (j < n && t[j] != ' ') { cmp++; j++; } if (j - i == m) for (size_t k = 0; k < m; k++) { cmp++; if (t[i + k] != w[k]) break; } i = j; } return cmp;}/** ② 补空格 + 朴素 find */static long long findCmp(const string& w, const string& t) { string s = " " + t + " ", p = " " + w + " "; long long cmp = 0; size_t n = s.size(), m = p.size(); for (size_t i = 0; i + m <= n; i++) for (size_t k = 0; k < m; k++) { cmp++; if (s[i + k] != p[k]) break; } return cmp;}/** ③ 补空格 + KMP */static long long kmpCmp(const string& w, const string& t) { string s = " " + t + " ", p = " " + w + " "; int n = (int)s.size(), m = (int)p.size(); vector<int> nxt((size_t)m, 0); long long cmp = 0; for (int i = 1, j = 0; i < m; i++) { while (j > 0 && (cmp++, p[(size_t)i] != p[(size_t)j])) j = nxt[(size_t)(j - 1)]; cmp++; if (p[(size_t)i] == p[(size_t)j]) j++; nxt[(size_t)i] = j; } for (int i = 0, j = 0; i < n; i++) { while (j > 0 && (cmp++, s[(size_t)i] != p[(size_t)j])) j = nxt[(size_t)(j - 1)]; cmp++; if (s[(size_t)i] == p[(size_t)j]) j++; if (j == m) j = nxt[(size_t)(j - 1)]; } return cmp;}
int main(int argc, char** argv) { bool csv = argc > 1 && string(argv[1]) == "csv"; const size_t N = 1000000;
/* 形状 ①:随机小写词,长度 2~8 */ mt19937 rng(20260908u); string w1 = "aaaaaaaaaa"; // 顶格的 w:10 个字母 string t1; while (t1.size() < N) { unsigned rl = rng() % 7u; int len = 2 + (int)rl; for (int i = 0; i < len; i++) { unsigned c = rng() % 26u; t1.push_back((char)('a' + c)); } t1.push_back(' '); } t1.resize(N);
/* 形状 ②:★ 专门卡「朴素 find」——每个词都和 w 只差最后一个字母 */ string t2; while (t2.size() < N) { t2 += "aaaaaaaaab"; t2.push_back(' '); } t2.resize(N);
long long sc1 = scanCmp(w1, t1), fd1 = findCmp(w1, t1), km1 = kmpCmp(w1, t1); long long sc2 = scanCmp(w1, t2), fd2 = findCmp(w1, t2), km2 = kmpCmp(w1, t2);
if (csv) { printf("scanRandom,%lld\nfindRandom,%lld\nkmpRandom,%lld\n", sc1, fd1, km1); printf("scanWorst,%lld\nfindWorst,%lld\nkmpWorst,%lld\n", sc2, fd2, km2); printf("ratioRandom,%.2f\nratioWorst,%.2f\n", (double)fd1 / (double)km1, (double)fd2 / (double)km2); return 0; } printf("顶格文章 10⁶ 个字符、w = 10 个字母,三条路各做多少次字符比较:\n"); printf(" %-28s %14s %14s\n", "", "随机词", "★ 卡 find 的形状"); printf(" %-28s %14lld %14lld\n", "① 切词(正文正解)", sc1, sc2); printf(" %-28s %14lld %14lld\n", "② 补空格 + 朴素 find", fd1, fd2); printf(" %-28s %14lld %14lld\n", "③ 补空格 + KMP", km1, km2); printf(" ⇒ ② / ③ = %.2f 倍(随机) / %.2f 倍(卡它的形状)—— 而 |w| ≤ 10 决定了它到不了一个数量级\n", (double)fd1 / (double)km1, (double)fd2 / (double)km2); return 0;}点「运行 ▶」看结果
顶格文章 10⁶ 个字符、w 取满 10 个字母,三条路的字符比较次数(机器无关的尺子):
| 随机词 | ★ 专门卡 find 的形状(每个词都和 w 只差最后一个字母) |
|
|---|---|---|
| ① 切词(正文正解) | 1 000 000 | 1 909 090 |
② 补空格 + 朴素 find |
1 173 500 | 1 909 081 |
| ③ 补空格 + KMP | 1 173 525 | 1 909 105 |
| ② / ③ | ★ 1.00 倍 | ★ 1.00 倍 |
★★★ 连专门造出来卡它的形状都是 1.00 倍 —— 而理由很具体:
补空格之后,模式串的第一个字符是空格,而空格在文章里是稀疏的(平均每 6~11 个字符才一个)。 ⇒ 绝大多数起始位置第一个字符就失配,朴素匹配根本退不了几步。 比较次数有个一眼的上界:
n + 空格个数 × (|w|+2)。
⇒ ★★ 这正是第 48 章第 3 步那条「朴素匹配在随机数据上一点都不慢」的现场,
而这道题更狠:不是随机数据碰不到最坏,是这条路上压根没有最坏 ——
|w| ≤ 10 已经把 O(nm) 里的 m 钉死了。
⇒ ★★★ 所以题单那句注解是对的:该切词还得切词。 切词版不但比较次数最少(1 000 000,正好一个字符看一遍), 而且「整词」这件事在它那儿是免费的 —— 一行特判都不用写。
「学了新工具就想用上」是一种真实的失误,而它有两种形状:
- ★ 用错地方:这道题问的是「整词」,KMP 答的是「子串」—— ⇒ 得先自己做一次「把边界编码进模式串」,KMP 才接得上;
- ★ 用对了地方,但一分钱不值:接上之后比较次数 1.00 倍。
⇒ 判据都很便宜:先问「这道题问的到底是什么」,再乘一句「省下来的那部分有多大」。 (同一章的 P1470 是这件事的另一面:那道题连「先做一次翻译」都不用 —— 它压根不是匹配问题。)