阶段 10 · 字符串 · 第 47 章普及组 J

字符串基础:读进来、切开、比对

★ 关键一步是「逐词切分」—— 切出来的每一段本来就是完整单词,于是「the 不该在 theme 里被数到」这件事变成免费的。⚠ 而这一章真正咬人的是三件小事:cin 读完单词之后 getline 会读到空行、size() 是无符号的、以及「子串」不是「整词」。

需要先学:第 5 章 枚举与模拟例题:统计一个单词在一行文章里作为完整单词出现了几次建议用时:100 分钟
这一章开一个新阶段:字符串

这本书到第 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。

⚠ 注意 wTo、文章里是 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

★★ 把上一条推到极端:themetheatre 里都含着 the,但只有单独站着的那两个 the 算数。 ⇒ 答案是 2,第一次在下标 9。

⚠ 如果你的程序输出的是 5 0,那它数的是子串,不是单词 —— 第 4 步就在讲这个。

2⚠ 第一个坑就在读入:getline 读到了一个空行

第一行是一个单词,第二行是一整行(含空格)。所以第一行用 cin >> w,第二行必须用 getline —— 可这么写出来的程序,读到的文章是空的

cin >> w;             // 只取走了单词那几个字符
getline(cin, t);      // 一上来就撞见上一行剩下的换行符 -> 读到一个空行
readTrap.cpp⚠ 同一份输入,三种写法并排跑
它先把整个输入读进内存,再用三个 stringstream 各演一遍 —— 这样三种写法吃的是同一份字节,对比才算数。
// ★★ 这一章的开场白:`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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ 三种活得下去的写法
写法 怎么写 注意
① 先吃掉换行 cin >> w; 之后来一句 getline(cin, t); 把残留吃掉,再 getline(cin, t); 本章正解用的就是它
② 全程 getline 第一行也用 getline 读,再自己从里面取单词 最不容易出错,但多几行
cin >> ws cin >> w >> ws;getline ws 会把正文开头的空格也吃掉

⚠ 这个坑最难受的地方是:程序不报错、不崩溃,只是后面全算错。 而你的样例如果第二行开头没空格、又正好没被这一步坑到,你会一直以为是别的地方错了。

3不区分大小写:字符本来就是整数

第 46 章那句话在这里直接能用:字符存的就是它的编码

charInt.cpp三条这一章要用的
'a' 和 'A' 差 32 = 2⁵,所以翻转第 5 位就换了大小写 —— 第 46 章那个 flip。⚠ 但考场上请写 tolower。
// 第三件事:字符就是整数(第 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;
}
点一下即可编辑
输出
点「运行 ▶」看结果
⚠ 两句话记住就够
  • 统一大小写要在最前面做一次wt 都转小写),别在比较的地方一个个转 —— 那样迟早漏一处。
  • tolower 的参数要先转成 unsigned charchar 在 x86 上是有符号的, 遇到非 ASCII 字节会变成负数,而 tolower(负数) 是未定义行为。

4★ 「子串」不是「整词」—— 这道题最经典的那个 WA

看到「数出现次数」,第一反应多半是 find 一路找下去。它是错的

wrongFind.cpp⚠ 数的是子串,不是单词
// 错法一:直接拿 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

theme of the theatre is the theme 里,它会数到 5thetheme 里一个、the 一个、 theatre 里一个、the 一个、theme 里一个),而正确答案是 2

★ 那怎么才算「完整单词」

一个词是完整的,当且仅当它两边要么是空格,要么是行首 / 行尾

⇒ 两条路,第 5 步和第 10 步各走一条:

  • 切词:把文章切成一个个词,再逐个和 w 比 —— 那么「完整」是免费的(切出来的本来就是完整词);
  • 补空格:给文章两端各补一个空格,要找的词也补上空格,然后当普通子串找 —— 空格挡住了两头,于是「完整」被编码进了要找的串里。

★ 这两条路对「单词边界」的理解完全不同,所以它们正好可以互相对拍(第 10 步)。

5★ 关键的一步:逐词切分

★ 切词只有一个循环,两句话
   i 一路往右走:
     先跳过所有空格                      -> 此刻 i 停在一个词的开头
     再往右走到下一个空格(或行尾)      -> [i, j) 就是一个完整的词
     判一次,然后 i = j,继续

⚠ 一个词要到遇见空格(或行尾)的那一刻才判得出来 —— 在那之前你不知道它有多长。 这句话看着废,但它正是下一步那个动画要你看的东西。

fast.cpp正解:手写扫描,O(n)
// 正解:手写扫描,自己切单词 —— 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

6动画:一个词是怎么被切出来、判出来的

★ 换到「子串陷阱」那一组看一眼:themetheatre 都被红着判掉了 —— 它们含着目标词,但那不算。而这件事在切词的写法里是免费的,你一行特判都不用写。

一个词,要到遇见空格那一刻才判得出来
第 1 / 21 步
t
o
b
e
o
r
n
o
t
t
o
b
e
命中 0
第一次在下标 -1
此刻不在词里
■ 当前字符 ■ 判定为命中的词 ■ 判定为不是的词 ␣ 表示空格
要找的词是「to」。从左往右一个字符一个字符地看:碰到非空格就开始攒一个词,碰到空格就把它判一次。

7第二种做法:交给 stringstream

stringstream 能把一行字符串当成输入流,ss >> word 就自动按空格切词:

stream.cpp三行切词,但有两个代价
// 做法二:交给 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
⚠ 它的两个代价,第二个更要紧
  1. 它不是免费的:每读一个词都要走一遍流的那套机制。
  2. 它不告诉你位置 —— 切出来的是单词本身,不是它在原串里的下标。 要回答「第一次出现在第几个字符」,还得自己一边切一边找回位置,而那一步很容易差一格

⇒ 结论:只问「有没有 / 有几个」时 stringstream 很好用;一旦问位置,手写扫描更稳。

race.cpp两种切词,同一个字符串上比一次
⚠ 它自己造文章、不吃 stdin —— 为的是量「切词」本身,而不是把读入也量进去(第 29、34 章那条)。
// 换一把尺子: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() 是无符号的

⚠⚠ 空串上,s.size() - 1 是 18446744073709551615

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 次

在空文章上会直接卡死 —— 而你的样例永远发现不了它,因为非空串上它跟正确写法没有任何差别。

sizeTrap.cpp⚠ 把那个天文数字打出来给你看
顺带演示一个当场就能看见的后果:s.substr(s.size() - 1) 会抛 out_of_range。
// ★★ 第二个坑:`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()) { ... }                         // 干脆先把空串挡在外面

⚠ 还有一条更阴的:iints.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 += ts.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 步那条另一条路:给两端补空格,然后当普通子串找。 两份代码对「单词边界」的理解完全不同,一边写歪了另一边一定对不上。

brute.cpp标准答案:补空格,当子串找
对拍器
★ 生成器不给档位时跑的就是最终档(第 11 步那张表里的档位 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 把目标词包住的更长的词
wrongSizei < s.size() - 1 ★ 37 / 300 空文章(外加一条意外的路,见下)
四个错误版本(点开看)
wrongPos.cpp④ 输出第几个单词,不是第几个字符
wrongCase.cpp② 忘了统一大小写
wrongFind.cpp① 直接数子串
wrongSize.cpp③ i < s.size() - 1

★ 每一份的文件头都写着「它靠什么现形」—— 那不是注释,那是下一步调生成器的依据。

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 —— 词表里本来就有互相包含的词(tointo), 靠运气能撞上几次。但 7 / 300 意味着跑 43 轮才碰一次,而多数人对拍只跑 20 轮。

⚠⚠ 第三笔账没那么干净 —— 而不干净的那部分才是这一节最值钱的

wrongSizei < s.size() - 1)的那一列,撤掉「空文章」旋钮之后是 6,不是 0。 按前两笔账的逻辑,它「应该」归零 —— 可它没有。

去查那 6 轮长什么样,答案很清楚:

   a
   iS to tO a A          <- 正解 "2 9",wrongSize 给 "1 9"

这个 bug 有第二条现形路径:循环少看最后一个字符,所以当最后一个词只有一个字母、 而它正好就是目标词时,计数会少一。它和「空文章」是同一个 -1 造成的, 但触发条件完全不同 —— 一个是下溢,一个是少扫一格

★ 能带走的那句话:「我知道这个 bug 靠什么现形」经常只知道了一半。 ⇒ 对照档跑出来不是 0 的时候,别急着说旋钮没用 —— 先去看那几轮到底长什么样。 (第 39 章那条「预判被实测打掉」的另一面:这次预判没错,只是不全。)

gen.cpp(七个档位)三个旋钮,每一个都写清了它是为哪个 bug 拧的

12这一章没讲的,和接下来三章

⚠ 这一章一个字符串算法都没讲 —— 那是后面三章的事
没讲的 在哪一章 一句话
KMP 第 48 章 匹配失败时,i 一步都不用退 —— 已经匹配上的那一段自己知道该退到哪
字符串哈希 第 49 章 把子串变成一个数,比较 O(1);★ 以及它为什么会错
Trie(字典树) 第 50 章 一堆字符串摆成一棵树,查前缀只看串长,和串的数量无关
回文 / 后缀数组 / AC 自动机 都没讲 CSP-J 用不到;S 组的进阶内容

★ 但先把这一章的三个坑记牢:读入、size() 的符号、整词不是子串。 后面三章的代码再漂亮,栽在这三条上一样是 0 分。

13自测

自测清单0 / 13
配套练习
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)
这一章记住三句话
  1. 切词写法里,「完整单词」是免费的 —— 因为切出来的每一段本来就是完整单词。 而 find 数的永远是子串,这两件事从一开始就不是一回事。
  2. cin >> 之后的 getline 会读到空行。 程序不报错,只是后面全错。
  3. ⚠⚠ s.size() 是无符号的。 s.size() - 1 在空串上是 18446744073709551615, 而你的样例永远发现不了它 —— 只有「空文章」那一档数据能。