1一句话问题
给文本串
t和模式串p(都只含小写字母,|t| ≤ 10⁶,|p| ≤ 10⁵)。第一行输出
p在t里出现了几次,然后是所有出现的起始下标(0 基,空格隔开,允许重叠); ★ 第二行输出p的 next 数组。
输入
ababcabab abab
输出
2 0 5 0 0 1 2
abab 在 ababcabab 里出现在下标 0 和 5。
第二行 0 0 1 2 是 abab 的 next 数组 —— 它是什么、怎么算,第 5 步开始讲。
输入
aaaaa aa
输出
4 0 1 2 3 0 1
★ 重叠:aa 在 aaaaa 里出现在 0、1、2、3 —— 四次,它们互相重叠。
⚠ 这一组是专门放在这儿的:很多写法会漏掉重叠的那几次(本章 wrongNoJump.cpp 就是),
而随机数据一辈子撞不到这种情况。
输入
abcdef xy
输出
0 0 0
一次都没有:第一行只有一个 0(后面没有位置),第二行照样输出 next 数组。
第 35 章那条:题面多问一句,对拍就多一条腿。
这道题只问位置的话,「next 算错了但答案碰巧对」的写法能一路蒙过去 —— 而 next 恰恰是这一章唯一的新东西。多输出这一行,那一类 bug 就无处可藏。
2朴素匹配:每个位置都对一遍
// 朴素匹配:把模式串挨个位置对一遍 —— O(nm)//// 它是这道题最直白的翻译:文本串的每一个位置都试一次,从那儿开始逐字符比。// ★ 而这一章第 4 步会证明一件反直觉的事:**在随机数据上它一点都不慢**// (每个位置平均比不到 1.1 次就失配了)。想看到它的真面目,得自己造数据。//// 它同时是对拍的标准答案:思路和 KMP 完全不同 —— 它对「已经匹配过的那一段」// 没有任何记忆,失配了就把 i 退回去从头再来。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
string t, p; cin >> t >> p; int n = (int)t.size(), m = (int)p.size();
vector<int> pos; for (int i = 0; i + m <= n; i++) { int j = 0; while (j < m && t[i + j] == p[j]) j++; if (j == m) pos.push_back(i); }
cout << pos.size(); for (int x : pos) cout << ' ' << x; cout << '\n';
// 第二行:p 的 next 数组。⚠ 这一份用**定义**直接算(枚举所有可能的长度), // 和 fast.cpp 那个 O(m) 的递推是两条完全不同的路 —— 于是它们能互相对拍。 for (int i = 0; i < m; i++) { int best = 0; for (int len = i; len >= 1; len--) { // 从长到短试 if (p.compare(0, len, p, i + 1 - len, len) == 0) { best = len; break; } } cout << best << (i + 1 == m ? '\n' : ' '); } return 0;}点「运行 ▶」看结果
它对「已经比过的那一段」没有任何记忆:只要有一位对不上,就把 i 退回去,从下一个位置重来。
理论上界是 n × m —— 10⁶ × 10⁵ = 10¹¹,按第 45 章那张表,那是几十分钟。
3⚠⚠ 转折点:它在随机数据上一点都不慢
按惯例,这一步该实测「暴力有多慢」。可这一章量不出来。
// 换一把尺子:数「字符比较」做了多少次//// 这一章的秒表在小数据上完全没用(都是 0.00 秒),而**比较次数**是能数出来的、// 换台机器也不变的东西。两种做法各放一个计数器,数的是同一个动作:// **拿文本里的一个字符和模式里的一个字符比一次。**//// ⚠ 还多数了一个:KMP 里 j 一共**回退**了多少次 —— 这是理解「为什么是 O(n+m)」的关键,// 因为 j 每次 +1 最多 n 次,而每次回退至少让 j 减一,所以回退总数也不超过 n。//// 用法:./count < 输入 (和主程序一样的输入:第一行文本,第二行模式)// ./count csv < 输入 只打 `键,值`,给 check:viz 用
#include <bits/stdc++.h>using namespace std;
int main(int argc, char** argv) { ios::sync_with_stdio(false); cin.tie(nullptr); bool csv = (argc > 1 && string(argv[1]) == "csv");
string t, p; cin >> t >> p; int n = (int)t.size(), m = (int)p.size();
/* ① 朴素匹配 */ long long cmpBrute = 0; long long hitBrute = 0; for (int i = 0; i + m <= n; i++) { int j = 0; while (j < m) { cmpBrute++; if (t[i + j] != p[j]) break; j++; } if (j == m) hitBrute++; }
/* ② KMP(含算 next 的那一趟) */ long long cmpKmp = 0, back = 0; vector<int> nxt(m, 0); for (int i = 1; i < m; i++) { int j = nxt[i - 1]; while (j > 0) { cmpKmp++; if (p[i] == p[j]) break; j = nxt[j - 1]; back++; } if (j == 0) { cmpKmp++; } if (p[i] == p[j]) j++; nxt[i] = j; } long long hitKmp = 0; { int j = 0; for (int i = 0; i < n; i++) { while (j > 0) { cmpKmp++; if (t[i] == p[j]) break; j = nxt[j - 1]; back++; } if (j == 0) { cmpKmp++; } if (t[i] == p[j]) j++; if (j == m) { hitKmp++; j = nxt[j - 1]; back++; } } }
if (csv) { printf("n,%d\nm,%d\nbrute,%lld\nkmp,%lld\nback,%lld\nhit,%lld\nsame,%d\n", n, m, cmpBrute, cmpKmp, back, hitBrute, hitBrute == hitKmp ? 1 : 0); return 0; } printf("文本 %d 个字符,模式 %d 个字符,出现 %lld 次\n\n", n, m, hitBrute); printf(" 朴素匹配 比较 %12lld 次 (理论上界 n*m = %lld)\n", cmpBrute, (long long)n * m); printf(" KMP 比较 %12lld 次 (其中 j 回退了 %lld 次)\n", cmpKmp, back); printf("\n ★ 朴素 / KMP = %.1f 倍\n", cmpKmp ? (double)cmpBrute / cmpKmp : 0.0); printf(" ⚠ 两种做法找到的出现次数%s\n", hitBrute == hitKmp ? "一样(这一行是防作弊的)" : "居然不一样,有 bug"); return 0;}点「运行 ▶」看结果
本机实测(A 机:WSL2 6.18 / 8 核 / 8 GB,2026-08-25,独占;n = 10⁶、m = 1000,
数据来自 ./genBig 1000000 1000 <形状>):
| 数据形状 | 朴素比较次数 | KMP 比较次数 | 朴素 / KMP |
|---|---|---|---|
| 随机 26 个字母 | 1 039 887 | 1 039 354 | ★ 1.0 倍 |
文本全 a,模式 aaa…ab |
999 001 000 | 2 000 998 | 499 倍 |
文本全 a,模式全 a |
999 001 000 | 1 000 999 | 998 倍 |
随机数据上,朴素匹配比较了 1 039 887 次,KMP 比较了 1 039 354 次 —— 朴素还少比了 533 次(因为 KMP 得多花一趟去算 next)。
⇒ 「所以要学 KMP」这句话,在你顺手造的数据上是站不住的。
道理其实简单:随机文本里,从任意一个位置开始,第一个字符就对不上的概率是 25/26 ——
平均比 1.04 次就失配了,所以朴素匹配实际上是 O(n)。
O(nm) 那个上界存在,但随机数据碰不到它。
★ 这是第 33 章那条「上界证出来了不等于随机数据碰得到」在字符串上的现场, 而且这一章的倍数是全书最夸张的一次(1 倍 vs 998 倍)。
要让朴素匹配现出原形,得让它每次都比到最后一格才失配:
文本: a a a a a a a a a a ... a
模式: a a a ... a b
|<-- m-1 个 a -->|每个起始位置都要比完 m-1 个 a 才在最后一位撞上 b —— 一次都不能提前退出。
⇒ 这就是上面那张表的第二行:999 001 000 次,正好是 (n-m+1) × m 的 99.9%。
⚠ 而这种数据你必须自己写生成器造(./genBig 1000000 1000 worst),
它不会从天上掉下来 —— 第 12 步整节都在讲这件事。
秒表这时候才有话说(同一台机器、同样独占):
| 数据 | n |
m |
朴素 | KMP | 倍数 |
|---|---|---|---|---|---|
| 随机 | 10⁶ | 1 000 | 0.004 秒 | 0.003 秒 | 1× |
| 随机 | 10⁶ | 10 000 | 0.091 秒 | 0.003 秒 | 27× |
| ★ 最坏 | 10⁶ | 1 000 | 0.239 秒 | 0.004 秒 | 63× |
| ★ 最坏 | 10⁶ | 10 000 | 2.267 秒 | 0.005 秒 | 502× |
4★ 关键的一步:失配时,i 一步都不用退
看朴素匹配失配的那一刻,它扔掉了一件已经知道的事:
t: a b a b c ...
p: a b a b
^ 这里失配(t 的 c 对不上 p 的第 5 位)
朴素的做法:i 退回去,从 t[1] 重新开始对
⇒ 可我们**已经知道** t[0..3] = "abab" 了 —— 从 t[1] 开始对,等于把这件事忘掉重来
已经匹配上的是 p[0..j-1](也就是 t 上同样的一段)。现在要问的是:
把模式串往右滑,最少滑多少格,才可能重新对上?
滑到某个位置能对上,等价于:p 的某个前缀 == 已匹配那段的某个后缀。
⇒ 要滑得最少,就取最长的那个 —— 它有个名字:
border:一个串里,最长的、既是它的真前缀又是它的真后缀的那一段。 (真 = 不能是整个串自己。)
比如 abab 的 border 是 ab(长 2);aabaa 的 border 是 aa(长 2);abcde 没有 border(长 0)。
⇒ 于是失配时只要一句:
j = nxt[j - 1] // 退到「已匹配那段」的最长 border 长度而 i 一动不动 —— 因为退过去之后,前面那 nxt[j-1] 个字符是已经验过的(后缀 = 前缀)。
nxt[i] = 「p[0..i] 这个前缀里,最长的 border 有多长」· 长度语义(不是「指针该退到哪」)· 0 基下标 · nxt[0] 恒为 0。
⚠ 网上的模板两派都有:另一派把 next 整个往右挪一位,或者拿 -1 当哨兵。
两派的代码都对,但混着抄必然错。⇒ 拿别处的模板对照时,先看它的 next[0] 是 0 还是 −1。
5next 怎么算:让 p 和它自己匹配
要算 nxt[i],就是问「p[0..i] 的最长 border 多长」。而 border 的定义里,
前缀和后缀都是 p 自己的一段 —— 所以这件事就是拿 p 去匹配 p。
于是那个循环和匹配的循环长得一模一样:
j = nxt[i-1] // 上一位的答案,就是这一位的候选
while (j > 0 && p[i] != p[j]) // 接不上就退到更短的 border
j = nxt[j-1]
if (p[i] == p[j]) j++
nxt[i] = j⚠ 那句 j = nxt[j-1] 和匹配时是同一句 —— 整个 KMP 只有这一个动作,用了两遍。
6动画一:看 next 是怎么退出来的
★ 换到「aabaaab(要连退两次)」那一组,盯住红色的那一步:j 从 3 直接掉到 1,
中间那两格是跳过去的 —— 因为「退而求其次」不是退一格,而是退到那一段自己的 border。
(顺手写成 j-- 就是本章 wrongNextBack.cpp。)
7正解
// 正解:KMP,O(n + m)//// ★★ 全书的 next 约定(第三节写着,别换):// nxt[i] = 「p[0..i] 这个前缀里,最长的、既是真前缀又是真后缀的那一段有多长」// · 长度语义(不是「指针该退到哪」那一派)· 0 基下标 · nxt[0] 恒为 0。// ⚠ 网上的模板两派都有(另一派把 next 往右挪一位、或者拿 −1 当哨兵),混着抄必然错。//// 关键的一步只有一句话:**失配时 i 一步都不退**。// 已经匹配上的那一段 p[0..j-1] 是知道自己该退到哪的 —— 退到它的最长 border,// 也就是 j = nxt[j-1]。因为那一段的后缀 = 前缀,所以「前面那截已经对上了」这件事不用重新验。//// ⚠ 算 nxt 的那个循环,本身就是「p 和自己匹配」—— 同一套回退逻辑用了两遍。// 这一句说不透,后面就只能背代码了。
#include <bits/stdc++.h>using namespace std;
/** nxt[i] = p[0..i] 的最长 border 长度 */vector<int> buildNext(const string& p) { int m = (int)p.size(); vector<int> nxt(m, 0); for (int i = 1; i < m; i++) { int j = nxt[i - 1]; // 上一位的 border 长度,也是「下一个要比的位置」 while (j > 0 && p[i] != p[j]) j = nxt[j - 1]; // 对不上就退到更短的 border if (p[i] == p[j]) j++; nxt[i] = j; } return nxt;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
string t, p; cin >> t >> p; int n = (int)t.size(), m = (int)p.size(); vector<int> nxt = buildNext(p);
vector<int> pos; int j = 0; // 当前已经匹配上的长度 for (int i = 0; i < n; i++) { // ★ i 只往前走,一步都不退 while (j > 0 && t[i] != p[j]) j = nxt[j - 1]; if (t[i] == p[j]) j++; if (j == m) { pos.push_back(i - m + 1); j = nxt[j - 1]; // ⚠ 找到一个之后要继续,允许**重叠**的下一次出现 } }
cout << pos.size(); for (int x : pos) cout << ' ' << x; cout << '\n'; for (int i = 0; i < m; i++) cout << nxt[i] << (i + 1 == m ? '\n' : ' '); return 0;}点「运行 ▶」看结果
j 每轮最多 +1(对上一次加一),所以整个过程里 j 一共增加不超过 n 次。
而每次回退至少让 j 减一,且 j 从不为负 ⇒ 回退的总次数也不超过 n。
⇒ 内层那个 while 看着吓人,但它一辈子转不了几次。
本机实测(n = 10⁶、最坏数据):回退一共 999 999 次,和 n 同一个量级 —— 正好对上这个证明。
(count.cpp 把这个数也打出来了。)
8动画二:匹配时 i 一步都不退
盯住上面那行的 i:它从头走到尾,一次都没有往回。往右滑的是下面那行模式串。
★ 换到「重叠出现:aaaaa 里找 aa」那一组 —— 命中之后 j 不是归零,而是退到 nxt[j-1],
所以紧接着的那一次重叠出现不会被漏掉。
9★ 对拍:四个写错的版本
标准答案是 brute.cpp —— 它和 KMP 的思路完全不同(没有任何记忆,失配就整段重来),
而且它的 next 数组是按定义枚举算的(从长到短试每个长度),和 fast.cpp 那个 O(m) 递推是两条路。
// 正解:KMP,O(n + m)//// ★★ 全书的 next 约定(第三节写着,别换):// nxt[i] = 「p[0..i] 这个前缀里,最长的、既是真前缀又是真后缀的那一段有多长」// · 长度语义(不是「指针该退到哪」那一派)· 0 基下标 · nxt[0] 恒为 0。// ⚠ 网上的模板两派都有(另一派把 next 往右挪一位、或者拿 −1 当哨兵),混着抄必然错。//// 关键的一步只有一句话:**失配时 i 一步都不退**。// 已经匹配上的那一段 p[0..j-1] 是知道自己该退到哪的 —— 退到它的最长 border,// 也就是 j = nxt[j-1]。因为那一段的后缀 = 前缀,所以「前面那截已经对上了」这件事不用重新验。//// ⚠ 算 nxt 的那个循环,本身就是「p 和自己匹配」—— 同一套回退逻辑用了两遍。// 这一句说不透,后面就只能背代码了。
#include <bits/stdc++.h>using namespace std;
/** nxt[i] = p[0..i] 的最长 border 长度 */vector<int> buildNext(const string& p) { int m = (int)p.size(); vector<int> nxt(m, 0); for (int i = 1; i < m; i++) { int j = nxt[i - 1]; // 上一位的 border 长度,也是「下一个要比的位置」 while (j > 0 && p[i] != p[j]) j = nxt[j - 1]; // 对不上就退到更短的 border if (p[i] == p[j]) j++; nxt[i] = j; } return nxt;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
string t, p; cin >> t >> p; int n = (int)t.size(), m = (int)p.size(); vector<int> nxt = buildNext(p);
vector<int> pos; int j = 0; // 当前已经匹配上的长度 for (int i = 0; i < n; i++) { // ★ i 只往前走,一步都不退 while (j > 0 && t[i] != p[j]) j = nxt[j - 1]; if (t[i] == p[j]) j++; if (j == m) { pos.push_back(i - m + 1); j = nxt[j - 1]; // ⚠ 找到一个之后要继续,允许**重叠**的下一次出现 } }
cout << pos.size(); for (int x : pos) cout << ' ' << x; cout << '\n'; for (int i = 0; i < m; i++) cout << nxt[i] << (i + 1 == m ? '\n' : ' '); return 0;}300 轮实测(种子 1..300,最终档 4):
| 故意写错的地方 | 被抓 | 靠什么现形 |
|---|---|---|
② wrongNoJump:命中后 j = 0 |
86 / 300 | 重叠出现 |
④ wrongIfWhile:回退那句 while 写成 if |
60 / 300 | border 链够长(一次退不到位) |
① wrongNextBack:算 next 时退成 j-- |
★ 32 / 300 | border 长度 ≥ 2,而且要真的退 |
③ wrongSlow:失配时 i 也退回去 |
★★ 0 / 300 | 对拍原理上抓不到它 |
wrongSlow.cpp 保留了 next 数组、保留了 KMP 的外壳,唯独把「i 不退」那件事丢了 ——
于是它退化成了朴素匹配,只是外表还像 KMP。
⇒ 它的输出和正解逐字节相同,三百轮、三万轮都一样。对拍看不见它。
抓它只有一条路:换尺子,数比较次数。 本机实测(n = 10⁶ 最坏数据):
| 比较次数 | |
|---|---|
| 正解 KMP | 2 000 998 |
wrongSlow |
999 001 000(和朴素一模一样) |
★ 第 36 章那条第四盲区在这一章又出现了: 所有只影响复杂度、不影响答案的写法,对拍原理上全都抓不到。 ⇒ 而这一次它尤其危险 —— 因为那份代码看起来就是 KMP。
★ 每一份的文件头都写着「它靠什么现形」—— 那不是注释,那是下一步调生成器的依据。
10★★ 生成器:顺手写的那一档,一个 bug 都抓不到
| 档位 | 相对上一档拧了什么 | ① NextBack | ② NoJump | ④ IfWhile | ③ Slow |
|---|---|---|---|---|---|
| 0(顺手写法) | 26 个字母随机,文本 20 |
★ 0 | ★ 0 | ★ 0 | 0 |
| 1 | ★ 字母表压到 2 个(a / b) | 5 | 93 | 30 | 0 |
| 2 | ★ 周期模式(短):1 |
4 | 17 | 11 | 0 |
| 3 | ★ 周期模式(长):1 |
6 | 12 | 6 | 0 |
| 4 ★ 最终档 | = 1 + 3 | 32 | 86 | 60 | 0 |
| 5 | 对照 = 4 − 小字母表 | 6 | 12 | 6 | 0 |
| 6 | 对照 = 4 − 周期模式 | 5 | 93 | 30 | 0 |
顺手写的生成器(26 个字母随机),三个真 bug 一个都抓不到 —— 全是 0 / 300。
原因和第 3 步是同一个:随机的 26 字母串几乎处处 border = 0 (相邻两位相同的概率只有 1/26),于是
- 没有 border ⇒
j永远退不了 ⇒ ① 和 ④ 那两句退法怎么写都一样; - 模式串不会重复出现 ⇒ ② 那个「重叠」根本没机会发生。
⇒ KMP 的所有 bug 都活在「有 border」这件事上,而随机数据恰好一点 border 都没有。 这就是为什么这一章的生成器必须先把字母表压小。
看 ① NextBack 那一列:
| 抓到 | |
|---|---|
| 只开「小字母表」(档位 1) | 5 |
| 只开「周期模式」(档位 3) | 6 |
| ★ 两个一起开(档位 4) | 32 |
5 + 6 = 11,而实测是 32 —— 两个旋钮互相成就了。
道理说得清:小字母表让 border 存在,周期模式让 border 变长、成链;
而 j-- 和 j = nxt[j-1] 只有在「border 长度 ≥ 2 且真的要退」时才分得出来 ——
那需要两个条件同时成立。
⚠ 这和前面几章记了很多次的那条正好相反(第 32、34、35 章:「调优不可加,改动之间会互相吃掉」)。 ⇒ 收紧成一句:改动之间会互相影响,方向两头都有 —— 所以每加一处,都要在最终环境里重新量一次,而不是在它刚加进来的那个环境里。
② 那一列:只开小字母表是 93,加上周期模式之后掉到 86。
因为周期模式会把模式串造得更长(14 的块重复 36 遍,最长 24 个字符),
而模式串一长,它在 20~40 个字符的文本里能出现的次数就变少了 —— 重叠的机会跟着变少。
⇒ 它仍然留着,理由不是抓获率而是另外两列(① 5 → 32、④ 30 → 60)。 这笔账要明写,不许粉饰成「改了就是更好」(第 27 章那条)。
11这一章没讲的,和下一章
| 没讲的 | 一句话 |
|---|---|
| 字符串哈希 | 下一章(第 49 章):把子串变成一个数,同样 O(n) 解这道题 —— ★ 以及它为什么会错 |
| next 的另一个用途 | n − nxt[n-1] 就是这个串的最小循环节长度 —— 很多题真正考的是这个 |
| AC 自动机 | 多个模式串一起匹配,是 KMP 和 Trie(第 50 章)的合体,S 组进阶 |
| Z 函数 / 后缀数组 | 本书不讲;用到时按同样的路子(先暴力、再实测、再关键一步)自己补 |
★ 顺带一句考场上的实话:多数字符串匹配题用 string::find 就够了 ——
它在实现里做了优化,随机数据上快得很。真正需要 KMP 的,是「最坏数据 + 大 n」那一档,
或者题目要的其实是 next 数组本身(比如问循环节)。
12自测
- 洛谷 P3375 【模板】KMP —— ★ 就是这一章那道题的原题。⚠ 它的下标从 1 开始,next 的输出口径也和本书不同 —— 交之前先把两边的约定对一遍
- 洛谷 P4391 [BOI2009] Radio Transmission —— ★★ 求最小循环节:答案就是 n − nxt[n-1]。一行的题,但想通它等于真的懂了 border
- 洛谷 P2375 [NOI2014] 动物园 —— ⚠ 提高组+:把 next 往下再挖一层(不超过一半长度的 border 个数)。★ 它逼你看清 nxt 数组本身就是一棵树
- 洛谷 P3435 [POI2006] OKR-Periods of Words —— ⚠ 提高组:沿着 border 链一路跳到最短的那个。练「nxt[j-1] 一路退」的直觉
- 洛谷 P1470 [USACO2.3] 最长前缀 —— 字符串 + DP,不用 KMP 也能过。★ 放在这儿是想说明:不是所有字符串题都要上 KMP
- 洛谷 P1308 [NOIP 2011 普及组] 统计单词数 —— 第 47 章那道题。回过头用 KMP 再写一遍,会发现「整词」那个条件 KMP 帮不上忙 —— 该切词还得切词
- ★ 失配时 i 一步都不用退 —— 已经匹配上的那一段自己知道该退到哪:退到它的最长 border,
也就是
j = nxt[j-1]。而算 next 的那一趟,本身就是「p 和自己匹配」,用的是同一句。 - ⚠⚠ 「朴素匹配慢」在随机数据上复现不出来 —— 本机实测它甚至比 KMP 还少比一次。
要看见
O(nm),得自己造「文本全 a、模式 aaa…ab」那种数据。 - ★★ KMP 的 bug 全活在「有 border」这件事上,而随机的 26 字母串几乎处处没有 border —— 所以顺手写的生成器,三个真 bug 一个都抓不到(0 / 300)。