题单 · 习题解析

洛谷 P1308 [NOIP 2011 普及组] 统计单词数

★★★ 它就是[第 47 章正文](/ch/47-string-basics/)那道题的**原题**,题面几乎一字不差 ⇒ 这一页不再讲一遍「切词 vs 补空格」,而是去量正文只提了两句、没有称过重量的那一关:**读入**;★★★ 四个错法**没有一个在算法里** —— `cin >> t` 只读到第一个空格(它恒等于在回答另一道题:「w 是不是文章的第一个词」)/ `cin >> w >> ws` 连**文章开头的空格**一起吃掉(正文第 2 步那句没有数字的提醒,这里量成了 179 / 300)/ `stringstream` 切词后 `pos += x.size() + 1` 把「词间恰好一个空格」当成了公理 / 没找到时打 `0 -1`;★★★ 十六格「触发 ≡ 抓获」**一个不差**,而四条触发条件形状完全不同;★★★ 顺手写的那一档把中间两个错法一起打成**结构性的精确的 0** —— 谁会主动在文章开头放空格、在两个词之间放两个空格?可题面写的是「只可能包含字母和空格」⇒ **生成器该照抄的不只是题面的规模和比值,还有题面允许的那些形状**;★★ 读入那笔账的结论是「不用管」:四种读法差 **14.7 倍**(默认 `cin` 10.6 ms / 关同步 0.72 ms / `scanf` 2.0 / `fread` 1.3),可顶格 10⁶ 个字符下最慢的也只用 10.6 毫秒 ⇒ **余量 94 倍** —— 倍数跨题几乎不变,分数线在绝对时间上;★ 官方两组样例各打死一个错法,而剩下两个**两组都碰不到**,因为它们要的形状(行首空格 / 连续空格)样例里一次都没出现;★★★ **2026-09-08 补了第 ⑩⑪ 步**(第 48 章题单也列了这道题)—— 这是本书**第七次**「命中登记表 ≠ 不用写」:**学完 KMP 回头写一遍,第一版照样错**(直接拿 `w` 跑 KMP 数的是**子串**,和正文那个 `wrongFind` 输出一模一样:`the` 数出 5 个)⇒ **换一个更强的算法,不会让一个读题错误自己变对**;要让 KMP 用得上得先做一件和 KMP 无关的事 —— **把边界编码进模式串**(两端补空格);★★★ 而接上之后它**一分钱都不值**:顶格 10⁶ 上「补空格 + 朴素 find」和「补空格 + KMP」的字符比较次数是 **1.00 倍**,连专门卡它的形状也是 1.00 倍 —— 因为**模式串的第一个字符是空格,而空格在文章里是稀疏的**,绝大多数位置第一个字符就失配(上界 `n + 空格数 × (|w|+2)`)⇒ **不是随机数据碰不到最坏,是这条路上压根没有最坏**;★ 而切词版比较次数最少(正好 10⁶,一个字符看一遍),且「整词」在它那儿是免费的 ⇒ **该切词还得切词**

⚠ 先自己写一遍,再往下看

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

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 章的字符串切词。

最容易错的三处

  1. 第二行用 cin >> t 读:遇到空格就停,文章只剩第一个单词。
  2. cin >> w >> ws:ws 会把文章开头的空格也吃掉,位置偏移。
  3. 没找到时打 0 -1:题面要求没找到直接输出一个整数 -1。

能 AC 的:p1308.cpp(读一整行 → 全转小写 → 逐词切分)这一版能 AC。时限 1 秒。

下面每一步里灰色的「深入」块是折叠的:那是这些结论怎么实测出来的,第一遍可以跳过。

1★★ 这一页不再讲一遍「切词」—— 它去量正文没称过重量的那一关

★★★ 「正文用过这道题」不等于「解析页没得写」

这道题就是第 47 章正文那道题的原题,而且题面几乎一字不差 (不像第 46 章 P1469 那次 —— 那道题正文和真题的数据范围差了 32 倍)。 ⇒ 正文第 4、5、7、10 步已经把「切词 vs 补空格」「子串不是整词」「stringstream 的两个代价」 讲透了,这一页再讲一遍没有意义。

★ 所以这一页换一个角度:读入。正文第 2 步只用一张表提了三种写法, 其中第 ③ 行后面挂着一句没有数字的提醒(「>> ws 会把正文开头的空格也吃掉」), 第 7 步也挂着一句(「一旦问位置,stringstream 那一步很容易差一格」)。 ⇒ 这一页把那两句提醒各量成了一个数,外加两个只跟输入输出有关的错法。

⚠⚠ 而这四个错法有一个共同点,它才是这一页真正的主线: 它们全都不是「算法写错了」,是「读进来的东西和题面说的不是一回事」。

p1308.cpp★ 正解:读一整行 → 全转小写 → 逐词切分
// 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
p1308Pad.cpp★ 参照物:补空格版(对「单词边界」的理解完全不同)

2⚠ 错法一:第二行用 cin >> t 读 —— 它在回答另一道题

★★ 说清楚它算了什么,比说它错了有用得多

cin >> t 遇到空格就停 ⇒ 读进来的「文章」只剩第一个单词。 于是这一版恒等于在回答另一道题:

「给定单词是不是文章的第一个词?是就输出 1 0,不是就输出 -1。」

⇒ ★★ 说清楚这一句之后,它的所有表现都是白送的推论 (第 14 章 P1332 立的那条规矩):

  • 它放过的输入只有两种:答案本来就是 -1,或者「w 只出现一次、而且就在开头(下标 0)」;
  • 别的输入它一定错 ⇒ 下面那张表里它是四档 159 / 179 / 153 / 180, 而这四个数和上面那句话数出来的轮数一个不差。

⚠ 它最难受的地方是:程序不崩、格式完全正常、-Wall 一声不吭, 而官方样例一(2 0 → 它打 1 0)当场就打死它 —— 这一个它躲不掉。

p1308Word.cpp✗ 错法一:cin >> t 只读到第一个空格
// 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

3⚠ 错法二:cin >> w >> ws —— 正文那句提醒,量成了一个数

★★★ ws 吃掉的不只是换行,还有文章开头的空格

ws 的语义是「跳过所有空白」,而换行是空白、空格也是。 ⇒ 文章开头有几个空格,读到的就短几个字符。

★ 而这不是钻牛角尖:题面第 2 行写的是「其中只可能包含字母和空格」, 输出格式那一节还专门补了一句「注意:空格占一个字母位」—— ⇒ 开头就是空格,是完全合法的输入,而且题面提前替你把这条路封死了。

★★ 这个 bug 只污染输出的一半(第 31 章 P2712 那条): 次数永远是对的,只有位置偏了,而偏移量恰好等于文章开头的空格数 —— 下面那张表里「位置差 ≡ 行首空格数」在两个专门档上是 179 / 179 和 180 / 180,一轮不差。

p1308Ws.cpp✗ 错法二:>> ws 把行首空格也吃掉了

4⚠ 错法三:stringstream 切词,位置靠「词长 + 1」累加

★★ 正文第 7 步那句「很容易差一格」,差在哪儿
   while (ss >> x) {
       if (x == w) { ... first = pos; }
       pos += x.size() + 1;          // ⚠ 这个 +1 假设了「词之间恰好一个空格」
   }

stringstream 帮你切词,代价是它不告诉你位置。于是所有人都会顺手写上面那一行 —— 而它把「词与词之间恰好一个空格」当成了公理。题面允许的是「只可能包含字母和空格」, ⇒ 连续两个空格、行首空格,都是合法输入。

★ 和上一个错法一样,它也只污染一半:次数永远对,位置偏。 ⚠ 而两个 bug 的触发条件部分重叠(行首空格两个都吃)—— 所以下面那张表要三档分开量,才看得出谁是谁的(第 46 章 P2114 那条的又一次)。

p1308Stream.cpp✗ 错法三:位置靠词长累加

5⚠ 错法四:没找到时打的是 0 -1

★ 和算法一点关系都没有,而它的触发条件是一句等式

题面的输出格式写着两句话:找到了输出两个整数;没找到直接输出一个整数 -1。 顺手写成「不管找没找到都打两个数」,在「一次都没出现」的那些测试点上全 WA。

⇒ 被抓的轮数 ≡ 正解输出 -1 的轮数(四档 123 / 121 / 128 / 120,一个不差)。 ★ 而官方样例二正好就是这种输入 ⇒ 这个错法一交样例就死。

p1308Neg.cpp✗ 错法四:打出 0 -1

6★★★ 对拍:四个错法 × 四个档位,十六格「触发 ≡ 抓获」一个不差

p1308Gen.cpp★ 生成器:两个旋钮 —— 行首空格 / 连续空格
⚠ 它自己不判对错,只造数据;下面那张表是 check:viz 里 4 档 × 300 轮跑出来的。
// 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
★★★ 三条读得出来的结论
  1. ★★★ 十六格全部是「触发 ≡ 抓获」,而四条触发条件长得完全不一样:

    错法 它的触发条件(一句话)
    cin >> t 答案不是 -1,而且不是「w 只出现一次且就在下标 0」
    >> ws 文章开头有空格,而且答案不是 -1
    累加位置 第一次命中之前出现过多余的空格(行首空格 或 连续两个空格),且答案不是 -1
    打 0 -1 答案正好是 -1

    ⇒ ★★ 四条都能写成一句精确的话,所以 ≡ 是必然的 (第 44 章那条:它是不是运气,取决于你能不能把「它算了什么」写成式子)。

  2. ★★★ 顺手写的那一档(档 0)把两个错法一起打成了精确的 0 —— 而那两个 0 不是概率低,是结构性的:谁会主动在文章开头放一个空格、在两个词之间放两个空格? ⇒ 而题面明写着文章「只可能包含字母和空格」,这两种形状全都合法。 ⇒ ★★ 第 22 章 P1020 那条规矩在这儿要再推一步: 生成器该照抄的不只是题面的规模和比值,还有题面允许的那些形状。

  3. ★ 两个「只污染一半」的 bug 各自的一半都验过了: >> ws 版和累加版的次数在 1200 轮里和正解完全相同, 而 >> ws 版的位置差逐轮等于「文章开头的空格数」。

7★★ 读入那笔账:这道题的读入关是「读对」,不是「读快」

p1308Read.cpp★ 四种读法读同一份顶格输入,各报各的毫秒
⚠ 它把读入和算答案分开计时 —— 不分开的话,读入那一截会被算答案那 3.4 毫秒盖住(第 34 章 P3366 那条)。
// 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 在这儿帮不上忙

这一节是 2026-09-08 补的

第 48 章(KMP)的题单里也列着这道题,注解写的是:

「第 47 章那道题。回过头用 KMP 再写一遍,会发现『整词』那个条件 KMP 帮不上忙 —— 该切词还得切词。」

⇒ 这一节就是把那句话量出来。(★ 而这一页因此成了本书第七次 「命中登记表 ≠ 不用写」—— 拿第 48 章的关键词 grep 这一页,原来 0 处命中。)

深入:KMP 为什么帮不上忙:从「子串 vs 整词」到 1.00 倍(进阶)
★★★ 第一步就错了:KMP 找的是「子串」,题目问的是「整词」

学完 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 那条又一次:出题人是照着题面那句话挑样例的,随机生成器不会。

p1308KmpSub.cpp✗ 学完 KMP 之后的第一版:数的还是子串
// 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★★ 要让 KMP 用得上,得先做一件和 KMP 无关的事

把边界编码进模式串:文章两端补空格、要找的词也补上空格 —— 于是「整词」变成了「子串」,KMP 这才有用武之地。

   文章:  " to be or not to be "        <- 两端各补一个空格
   模式:  " to "                        <- 边界进了模式串

⇒ 这正是第 ① 步那个 p1308Pad.cpp 走的路,只是把 string::find 换成了 KMP。 ⚠ 而它有一个只有这条路才有的坑:相邻两个词共用一个空格 ⇒ 命中之后 j 不能归零,要退到 nxt[j-1](否则连着的两个目标词会漏掉一个)。

p1308Kmp.cpp★ 补空格 + KMP:这一版是对的

11★★ 而换成 KMP 之后,它一分钱都不值 —— 比较次数 1.00 倍

p1308Race.cpp★ 三条路在顶格上各做多少次字符比较
// 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;
}
点一下即可编辑
输出
点「运行 ▶」看结果
★★★ 1.00 倍,而且理由能一句话说清

顶格文章 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,正好一个字符看一遍), 而且「整词」这件事在它那儿是免费的 —— 一行特判都不用写。

★ 把这一节的收获收成一句话

「学了新工具就想用上」是一种真实的失误,而它有两种形状:

  1. ★ 用错地方:这道题问的是「整词」,KMP 答的是「子串」—— ⇒ 得先自己做一次「把边界编码进模式串」,KMP 才接得上;
  2. ★ 用对了地方,但一分钱不值:接上之后比较次数 1.00 倍。

⇒ 判据都很便宜:先问「这道题问的到底是什么」,再乘一句「省下来的那部分有多大」。 (同一章的 P1470 是这件事的另一面:那道题连「先做一次翻译」都不用 —— 它压根不是匹配问题。)