阶段 10 · 字符串 · 第 49 章提高组 S

字符串哈希:把子串变成一个数,以及它会在哪儿撞

★ 关键一步是把子串变成一个数,取子串靠的还是第 6 章那一减。⚠ 而这一章的主课是「它会错」:自然溢出被 Thue–Morse 序列卡死(长度 1024 实测五个 base 全撞),单模数被生日攻击造到第 36819 个串就撞出一对。

需要先学:第 47 章 字符串基础:读进来、切开、比对例题:找出模式串在文本里所有出现的位置,再回答 q 个「这两个子串相等吗」建议用时:135 分钟

1一句话问题

给文本串 t 和模式串 p(都只含小写字母,|t| ≤ 10⁶|p| ≤ 10⁵)。

① 第一行输出 pt 里出现了几次,然后是所有出现的起始下标(0 基,空格隔开,允许重叠);

② 然后是 q 个询问,每个给两对下标 l1 r1 l2 r2(0 基闭区间), 问 t[l1..r1]t[l2..r2] 这两个子串是不是同一个串,是就输出 Y,不是就输出 N。 ⚠ 两段不保证等长

输入

ababcabab
abab
3
0 3 5 8
1 2 6 7
0 1 0 2

输出

2 0 5
Y
Y
N

第一行和第 48 章那道题一模一样:abab 出现在下标 0 和 5。

后面三行是第二问:abababab(Y)、baba(Y)、ababa(长度都不一样,N)。

输入

aaaaa
aa
3
0 1 3 4
0 0 0 1
1 3 2 4

输出

4 0 1 2 3
Y
N
Y

★ 中间那一行是这一章的一个眼:aaa —— 它们当然不是同一个串,可 wrongBase.cpp 会说是。

输入

abcdef
xy
2
0 2 3 5
1 1 4 4

输出

0
N
N

一次都没出现:第一行只有一个 0(后面没有位置),询问照常回答。

★ 第二问才是这一章存在的理由

第一问和第 48 章是同一道题 —— 那一章用 KMP 解的。既然解过了,为什么还要一章?

因为第二问 KMP 做不了

KMP 会的事情是「拿一个固定的模式串去扫文本」。可这里的每个询问都在问两段任意的子串q 个询问就是 q 对不同的串 —— 没有哪个模式串是固定的,next 数组无从谈起。

★ 而哈希对这件事是 O(1):两个子串各变成一个数,比一下就完了。

⇒ 这一章和上一章不是重复,是「同一道题 + 一个只有哈希能答的问题」。 第 13 步会把两者正面对照一次(谁快、谁省内存、谁更容易写错)。

2暴力:一个字符一个字符比

brute.cpp暴力:第一问每个位置对一遍,第二问逐字符比
// 标准答案:一个哈希都不用,全靠逐字符比 —— O(nm + q × |t|)
//
// 它是这道题最直白的翻译,也是对拍的标准答案:
// · 第一问:文本串每个位置都对一遍(和第 48 章 brute.cpp 同一份思路);
// · 第二问:两个子串先比长度,再一个字符一个字符比过去。
//
// ★ 它和正解**没有任何共同的想法** —— 正解那边一次字符比较都不做,只比两个整数。
// 于是它们能互相对拍:一边错了,另一边不会跟着错。
//
// ⚠ 而这一章的暴力是真的慢,慢在第二问:q 个询问、每个子串 10⁶ 长,就是 10¹¹ 次字符比较。
// (第一问反倒不一定慢 —— 第 48 章第 3 步已经量过了。)
#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';
int q;
cin >> q;
for (int k = 0; k < q; k++) {
int l1, r1, l2, r2;
cin >> l1 >> r1 >> l2 >> r2;
bool same = (r1 - l1 == r2 - l2);
for (int d = 0; same && d <= r1 - l1; d++) same = (t[l1 + d] == t[l2 + d]);
cout << (same ? "Y" : "N") << '\n';
}
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

它是这道题最直白的翻译,也是这一章的标准答案 —— ★ 注意它和正解没有一个共同的想法: 正解那边一次字符比较都不做,只比两个整数。

3⚠ 慢在哪:这一次要先量清楚「是谁慢」

第 48 章第 3 步量过一件很反直觉的事:朴素匹配在随机数据上一点都不慢。 那个结论在这里照样成立,所以这一章要先把两问分开量(第 22、29、34 章那条:先量到主语)。

本机实测(A 机:WSL2 6.18 / 8 核 / 8 GB,2026-08-26,独占;数据来自 ./genBig):

数据形状 n m q 暴力 哈希
随机,只问第一问 10⁶ 1 000 0 0.00 秒 0.01 秒
最坏(全 a),只问第一问 10⁶ 1 000 0 0.24 秒 0.02 秒
随机 10⁶ 1 000 10⁴ 1.19 秒 0.02 秒
随机 10⁶ 1 000 10⁵ 11.68 秒 0.03 秒
最坏(全 a) 10⁶ 1 000 10⁵ 11.93 秒 0.03 秒
⚠ 第一行:只问第一问的话,暴力比哈希还快

0.00 秒对 0.01 秒 —— 暴力赢了。因为哈希要先花一趟把前缀表建出来,而暴力在随机文本上 每个位置平均比不到 1.1 次就失配(第 48 章算过这笔账)。

⇒ 所以这一章的「慢」必须由第二问逼出来q 个询问,每次都要把两段子串 一个字符一个字符比过去,10⁵ × 5×10⁵ 就是 5×10¹⁰ 次比较。

⚠ 而且这里还有一个坑:如果询问的两段一开头就不一样,暴力比一次就走了 —— 那它照样不慢。所以 genBig.cpp 造询问的时候,是先把一段抄到另一处再问的, 逼着暴力每次都比到底。(第 25、30、31 章那条:先确认暴力真的把该做的活都做了。)

同题对比:⚠ 暴力:逐字符比 vs ✓ 哈希:只比两个数
询问个数默认是 n/20,而且每个询问的两段是真的相等的 —— 逼着左边比到底。先跑 400000 看一眼,再改成 1000000。
⚠ 暴力:逐字符比
✓ 哈希:只比两个数

4★ 关键的一步:把子串变成一个数

一个只含小写字母的串,可以直接当成一个 b 进制的数来读:

   把字符映射成数字:  a=1  b=2  c=3  ...  z=26
   串 "abc" 就是:     1 * b^2  +  2 * b^1  +  3

这个数唯一地代表这个串(只要 b 比字母表大)。⚠ 但它会大到装不下 —— 10⁶ 个字符的串是一个百万位的数。所以再取一个模数 M

h(s) = (s 按 b 进制读出来的那个数) mod M

于是「两个串相不相等」就退化成「两个数相不相等」。

★★ 子串怎么 O(1) 取出来:它就是第 6 章的前缀和

先把前缀哈希一次性算好(h[i] = 前 i 个字符的哈希,h[0] = 0):

   h[i + 1] = h[i] * b + s[i]      (每一步只是「往后接一位」)

那么子串 t[l..r] 的哈希是:

   sub(l, r) = h[r + 1] - h[l] * b^(r - l + 1)

这和第 6 章的前缀和是同一个形状:那里是 s[r] - s[l-1],这里也是「后面那个减前面那个」, 只是因为每往后走一位整个数就左移一位(乘了一次 b),所以减之前要先把前面那截 补齐到同一个位数,也就是乘上 b^(r-l+1)

这一句说透了,「为什么能 O(1)」就一眼看穿了:一次乘法、一次减法,和子串有多长毫无关系。

⚠ 三个必须记住的写法细节,每一个都对应本章一个错误版本
写法 写错了会怎样 对应
字符映射成 1..26,不是 0..25 aaa 的哈希都变成 0 第 11 步 ③
那个指数是 r - l + 1(子串长度 减错位,答案基本全乱 第 11 步 ④
减完要 + M 再取模 C++ 的 % 会留下负数,同一个串算出两个值 第 11 步 ⑤

⚠ 第一条最阴:等长的比较完全看不出毛病(那时它仍是一一对应的编码), 毒只在「比两段不等长的子串」时发作。

5先手算一遍(用一个小到看得见的模数)

b = 31M = 1009,算 "fig"

   起点            h = 0
   吃进 f(=6)    h = (0 * 31 + 6)  mod 1009 = 6
   吃进 i(=9)    h = (6 * 31 + 9)  mod 1009 = 195
   吃进 g(=7)    h = (195 * 31 + 7) mod 1009 = 6052 mod 1009 = 1007

再拿前缀哈希取一个不是从头开始的子串验一遍。文本 "ourvanandfig",要 t[3..5](也就是 van):

   h[3] = 958     ("our" 的哈希)
   h[6] = 211     ("ourvan" 的哈希)
   b^3 = 31^3 mod 1009 = 29791 mod 1009 = 530

   sub(3, 5) = (211 - 958 * 530) mod 1009
             = (211 - 507740) mod 1009
             = 211 - 213                   (507740 mod 1009 = 213)
             = -2                          ⚠ 负数!这就是「加 M 再取模」那一步
             = -2 + 1009 = 1007
⚠ 手算要挑一段「不是从头开始」的

l = 0 的时候 h[l] = 0,那一项直接消失 —— 指数写错、减法写错,全都验不出来。 所以上面故意挑了 t[3..5]

★ 而这一段算出来的 1007,和上面直接算 "fig" 得到的 1007 是同一个数 —— 可 vanfig 明明是两个不同的串。这就是这一章后半段全部的内容。

6正解

fast.cpp正解:双模数哈希,O(n + m + q)
// 正解:字符串哈希(双模数),O(n + m + q)
//
// ★ 关键的一步只有一句话:**把子串变成一个数**。
// 把串看成一个 base 进制的数:h(s) = s[0]·b^(k-1) + s[1]·b^(k-2) + … + s[k-1]
// 再对它取模。于是「两个子串相不相等」就变成「两个数相不相等」—— O(1)。
//
// ⚠ 而「一个子串的哈希」能 O(1) 取出来,靠的是**前缀哈希**,
// 它和第 6 章的前缀和是同一个形状(那里是减一下,这里也是减一下,只多乘一个 b 的幂):
// h[i] = 前 i 个字符的哈希
// sub(l, r) = h[r+1] − h[l]·b^(r−l+1)
//
// ⚠⚠ 这一章真正的主课不在这几行,而在「**它会错**」:
// · 单模数会被生日攻击撞(本章 collide.cpp 三万多个串就撞出一对);
// · 自然溢出(unsigned long long)会被 Thue–Morse 序列**对任何奇数 base** 卡死(thue.cpp)。
// ⇒ 所以这里用**两个模数**:要错就得同时在两个模数上撞,概率约 1 / 10¹⁸。
//
// ⚠ 还有一处很容易写错、而且随机数据抓不到:字符要映射成 **1..26 而不是 0..25**。
// 映射成 0 的话 "a" 和 "aa" 的哈希都是 0 —— 见 wrongBase.cpp。
#include <bits/stdc++.h>
using namespace std;
const long long MOD1 = 1000000007, MOD2 = 998244353;
const long long B1 = 131, B2 = 13331;
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();
/* ① 前缀哈希 + base 的幂(两套,两个模数各一套) */
vector<long long> h1(n + 1, 0), h2(n + 1, 0), pw1(n + 1, 1), pw2(n + 1, 1);
for (int i = 0; i < n; i++) {
long long c = t[i] - 'a' + 1; // ⚠ +1:不许有字符映射成 0
h1[i + 1] = (h1[i] * B1 + c) % MOD1;
h2[i + 1] = (h2[i] * B2 + c) % MOD2;
pw1[i + 1] = pw1[i] * B1 % MOD1;
pw2[i + 1] = pw2[i] * B2 % MOD2;
}
/* ② 取子串 t[l..r] 的哈希 —— 就是前缀和那一减 */
auto sub = [&](int l, int r) {
int len = r - l + 1;
long long a = ((h1[r + 1] - h1[l] * pw1[len]) % MOD1 + MOD1) % MOD1; // ⚠ +MOD 再取模
long long b = ((h2[r + 1] - h2[l] * pw2[len]) % MOD2 + MOD2) % MOD2;
return make_pair(a, b);
};
/* ③ 第一问:模式串出现在哪些位置(允许重叠) */
long long g1 = 0, g2 = 0;
for (char c : p) {
g1 = (g1 * B1 + (c - 'a' + 1)) % MOD1;
g2 = (g2 * B2 + (c - 'a' + 1)) % MOD2;
}
vector<int> pos;
for (int i = 0; i + m <= n; i++) {
pair<long long, long long> s = sub(i, i + m - 1);
if (s.first == g1 && s.second == g2) pos.push_back(i);
}
cout << pos.size();
for (int x : pos) cout << ' ' << x;
cout << '\n';
/* ④ 第二问:q 个询问,问 t 的两个子串相不相等 —— ★ KMP 做不了这件事,哈希 O(1) 就答 */
int q;
cin >> q;
for (int k = 0; k < q; k++) {
int l1, r1, l2, r2;
cin >> l1 >> r1 >> l2 >> r2;
cout << (sub(l1, r1) == sub(l2, r2) ? "Y" : "N") << '\n';
}
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

7动画一:前缀哈希填一遍,然后窗口一格一格滑

★ 前半段是填表:每一格只是「上一格乘 31,再加这个字符的值」。 后半段是滑窗:窗口每往右挪一格,只做一次乘法一次减法 —— 和窗口有多长没有关系。

⚠ 这里的模数只有 1009(为了让数字印得下)。 ★ 换到「假命中」那一组:窗口会在 van 上亮起来,可那一段根本不是 fig

前缀哈希(base = 31,模 1009):填一遍,然后一格一格滑过去
第 1 / 18 步
t
a
b
a
b
c
a
b
a
b
h[i]
·
·
·
·
·
·
·
·
·
h[0] = 0(空串的哈希)
模式串的哈希 = 467
当前窗口 =
命中 0
假命中 0
先把前缀哈希一格格填出来。h[0] = 0(空串),然后每加一个字符就是「上一格乘 31,再加上这个字符的值」。

8⚠⚠ 这一章真正的主课:它会错

到这儿为止,哈希看着毫无破绽。可它是一个会给出错误答案的算法 —— 前面所有章节的算法都不会。这一节要把「它会错」讲成一件能算、能量、能当场复现的事。

★★ 生日悖论:同一个模数,问法一换,安全边际差一万倍

先算两笔完全不同的账。设模数 M ≈ 10⁹

你在做什么 撞的概率 / 次数 M = 10⁹
一次(两个特定的子串) 1 / M 十亿分之一,随便用
拿一个模式串扫一遍 n = 10⁶ 的文本 ≈ n / M 0.1% —— 一千道题错一道
N = 10⁵ 个子串两两比(去重、丢进 map…) ≈ N × (N-1) / 2 / M 期望撞 5 对

★ 第三行就是生日悖论10⁵ 个串两两配对是 4 999 950 000 对, 而 10⁹ 除下来正好是 5。一个看着很大的模数,开根号只有三万多。

⇒ 所以「我这么写从来没出过问题」这句话,要看你是在做哪一行的事

collide.cpp把上面那两笔账真的数一遍,并且把撞上的那一对造出来
三张表:① 10 万个串两两比撞了几对(单模 / 双模);② 换 16 个种子再来一遍;③ 生日攻击,造到第几个串撞出第一对。本地跑 ./collide count / sweep / find 可以单独看。
// ★★ 把「单模数哈希会撞」量出来,以及把那一对撞上的串**真的造出来**
//
// 这一章正文第 9 步要回答两个不一样的问题,它们的答案差着好几个数量级:
//
// ① 「我这道题会不会因为哈希撞了而 WA?」
// —— 匹配一遍是 n 次比较,每次撞的概率 1/M ⇒ 大约 n/M。10⁶ 个位置、M = 10⁹ ⇒ 0.1%。
// ② 「我把 N 个子串两两比呢?」(去重、放进 map、判有没有重复子串……)
// —— 那是 C(N,2) 对,期望撞 C(N,2)/M 次。N = 10⁵、M = 10⁹ ⇒ 期望 5 次,**几乎必撞**。
// ⇒ 这就是生日悖论:同一个模数,问法一换,安全边际差一万倍。
//
// ★ 而 ② 的「期望 5 次」不是印公式,是这份程序数出来的(第一档就在数它)。
//
// 用法(不给参数就是 all —— 三张表一起打):
// ./collide all 三件事一起做,排好版
// ./collide count [N] [seed] 造 N 个互不相同的 6 字母随机串,数单模 / 双模各撞了几对
// ./collide sweep 种子 1..16 各来一遍,看「期望 5 对」到底像不像话
// ./collide find [seed] 一个个造下去,直到撞出第一对 —— 打印用了多少个串、那两个串是什么
// ./collide csv [seed] 三件事一起做,只打 `键,值`,给 check:viz 用
//
// ⚠ 攻击的目标是 base = 131、模 = 10⁹+7 —— 和 wrongSingle.cpp 用的**完全一样**。
// 找到的那一对会被写进 gen.cpp 的「碰撞档」,于是对拍能真的打假单模哈希。
#include <bits/stdc++.h>
using namespace std;
const long long MOD1 = 1000000007, MOD2 = 998244353;
const long long B1 = 131, B2 = 13331;
const int LEN = 6; // 串长:26^6 ≈ 3.09×10^8 种,够撞了
// 按显示宽度补空格(含中文的列不能用 %-Ns,那个数的是字节)
static string padDisp(const string& s, int width) {
int disp = 0;
for (unsigned char c : s) {
if ((c & 0xC0) == 0x80) continue;
disp += (c < 0x80) ? 1 : 2;
}
return s + string(max(0, width - disp), ' ');
}
static long long h(const string& s, long long base, long long mod) {
long long x = 0;
for (char c : s) x = (x * base + (c - 'a' + 1)) % mod;
return x;
}
int main(int argc, char** argv) {
string cmd = (argc > 1) ? argv[1] : "all";
bool csv = (cmd == "csv");
bool all = (cmd == "all");
int N = (argc > 2 && !csv) ? atoi(argv[2]) : 100000;
unsigned seed = (unsigned)((argc > (csv ? 2 : 3)) ? atoi(argv[csv ? 2 : 3]) : 1);
mt19937 rng(seed);
auto make = [&]() {
string s(LEN, 'a');
for (int i = 0; i < LEN; i++) s[i] = (char)('a' + rng() % 26);
return s;
};
if (cmd == "count" || all || csv) {
/* ① 造 N 个互不相同的串(先去重,免得把「同一个串」当成碰撞) */
unordered_set<string> seen;
vector<string> all;
all.reserve(N);
while ((int)all.size() < N) {
string s = make();
if (seen.insert(s).second) all.push_back(s);
}
/* ② 数碰撞:单模数、双模数各一遍(排序之后看相邻的是不是一样) */
vector<long long> one(N), two(N);
for (int i = 0; i < N; i++) {
one[i] = h(all[i], B1, MOD1);
two[i] = h(all[i], B2, MOD2);
}
vector<int> idx(N);
for (int i = 0; i < N; i++) idx[i] = i;
sort(idx.begin(), idx.end(), [&](int a, int b) {
if (one[a] != one[b]) return one[a] < one[b];
return two[a] < two[b];
});
long long c1 = 0, c2 = 0;
for (int i = 1; i < N; i++) {
if (one[idx[i]] == one[idx[i - 1]]) {
c1++;
if (two[idx[i]] == two[idx[i - 1]]) c2++;
}
}
double exp1 = (double)N * (N - 1) / 2.0 / (double)MOD1;
double exp2 = (double)N * (N - 1) / 2.0 / ((double)MOD1 * (double)MOD2);
if (csv) {
printf("n,%d\nsingle,%lld\ndouble,%lld\n", N, c1, c2);
} else {
printf("%d 个互不相同的 %d 字母串,两两比一遍 —— 一共 %lld 对:\n\n",
N, LEN, (long long)N * (N - 1) / 2);
cout << " " << padDisp("模数", 26) << padDisp("期望撞几对", 14) << "实测撞了\n";
cout << " " << padDisp(string(24, '-'), 26) << padDisp(string(12, '-'), 14) << "--------\n";
char buf[64];
snprintf(buf, sizeof(buf), "%.2f", exp1);
cout << " " << padDisp("单模数 10^9+7", 26) << padDisp(buf, 14) << c1 << "\n";
snprintf(buf, sizeof(buf), "%.0e", exp2);
cout << " " << padDisp("双模数(乘起来约 10^18)", 26) << padDisp(buf, 14) << c2 << "\n";
printf("\n => 一个模数挡不住「两两比」:10^9 看着大,开根号才 3 万多。\n");
}
}
if (cmd == "sweep" || all || csv) {
/* ★ 一个种子不算数:把 16 个种子各跑一遍,看「期望 5 对」到底像不像话 */
vector<int> got;
long long sum = 0;
for (unsigned sd = 1; sd <= 16; sd++) {
mt19937 r2(sd);
unordered_set<string> seen2;
vector<long long> one2;
one2.reserve(100000);
while ((int)one2.size() < 100000) {
string x(LEN, 'a');
for (int i = 0; i < LEN; i++) x[i] = (char)('a' + r2() % 26);
if (seen2.insert(x).second) one2.push_back(h(x, B1, MOD1));
}
sort(one2.begin(), one2.end());
int c = 0;
for (size_t i = 1; i < one2.size(); i++) if (one2[i] == one2[i - 1]) c++;
got.push_back(c);
sum += c;
}
if (csv) {
printf("sweep,");
for (size_t i = 0; i < got.size(); i++) printf("%s%d", i ? " " : "", got[i]);
printf("\nsweepsum,%lld\n", sum);
} else {
printf("\n种子 1..16 各造 10 万个串(单模数 10^9+7,期望每次撞 5.00 对):\n\n ");
for (size_t i = 0; i < got.size(); i++) printf("%d ", got[i]);
printf("\n\n => 平均 %.2f 对。单看一个种子,0 到 9 都可能 —— 但「撞不撞」这件事本身,没有悬念。\n",
(double)sum / 16.0);
}
}
if (cmd == "find" || all || csv) {
rng.seed(seed); // ⚠ 重新播种:csv 档把两件事一起做,不重播的话
// 「造到第几个串撞上」会跟着上一档的随机数走,两边对不上
/* ★ 生日攻击:一个个造下去,直到有两个不同的串撞上同一个单模哈希 */
unordered_map<long long, string> box;
string a, b;
long long tried = 0;
while (true) {
string s = make();
tried++;
long long key = h(s, B1, MOD1);
auto it = box.find(key);
if (it != box.end() && it->second != s) { a = it->second; b = s; break; }
box.emplace(key, s);
}
long long ha = h(a, B1, MOD1);
if (csv) {
printf("tried,%lld\npair,%s %s\nha,%lld\nhb,%lld\nsame2,%d\n",
tried, a.c_str(), b.c_str(), ha, h(b, B1, MOD1),
h(a, B2, MOD2) == h(b, B2, MOD2) ? 1 : 0);
} else {
printf("\n生日攻击(base = 131,模 10^9+7):造到第 %lld 个串就撞上了。\n\n", tried);
printf(" \"%s\" 和 \"%s\" —— 两个串完全不同\n", a.c_str(), b.c_str());
printf(" 它们的单模哈希都是 %lld\n", ha);
printf(" 而第二个模数下:%lld vs %lld => %s\n",
h(a, B2, MOD2), h(b, B2, MOD2),
h(a, B2, MOD2) == h(b, B2, MOD2) ? "也撞了" : "分开了,双模数挡住了");
printf("\n => 找它花了不到一秒。出题人想卡你的单模哈希,成本就是这么低。\n");
}
}
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果

本机实测./collide count / sweep / find):

  • 10 万个互不相同的 6 字母串,单模数 10⁹+7:期望撞 5.00 对,种子 1 实测撞了 6 对; 换 16 个种子跑一遍是 6 6 3 0 0 1 2 7 9 4 4 5 2 6 4 7,平均 4.125 对。 ★ 同一批串在双模数下:期望 5×10⁻⁹ 对,实测 0 对。
  • 生日攻击:一个个随机造下去,造到第 36 819 个串就撞出了第一对 —— "rnjpnw""vwxtxa",单模哈希都是 576565069。 而第二个模数下它们是 217439685516061528,分开了。
⚠ 这件事的成本,就是出题人卡你的成本

造出那一对花了不到一秒。也就是说,出题人想专门卡掉「base = 131、模 10⁹+7」这个 最流行的组合,成本约等于零 —— 而这个组合正是网上模板里抄得最多的那一个。

⇒ 结论有两条,缺一不可:

  1. 用双模数(或者随机 base / 随机模数)—— 要错就得同时在两个模数上撞,那是 1 / 10¹⁸
  2. base 和模数别照抄模板。你抄的是模板,别人卡的也是模板。

9动画二:看两个串怎么撞到同一个数上

左边那一列是模 1009 的哈希,右边那一列是换一个模数之后的哈希。 ★ 盯住最后一步:左边两个数变成了同一个,右边没有 —— 「双模数」三个字的全部理由就在这儿

两个不同的串,一步步撞到同一个数上(base = 31 / 37)
第 1 / 5 步
f
i
g
模 1009:0
模 1013:0
v
a
n
模 1009:0
模 1013:0
这一个模数上:还没撞 另一个模数上:分开的
两个串都从 0 开始。左边是模 1009 的哈希,右边是换一个模数(1013)之后的哈希。

10★★ 自然溢出:一个不用碰运气就能打假的写法

省事的写法是把模数整个省掉,直接用 unsigned long long 让它自然溢出(相当于模 2⁶⁴)。 代码更短、跑得更快,而且 2⁶⁴ ≈ 1.8×10¹⁹ 看着比 10⁹ 安全得多。

可它有一组固定的反例,而且不是撞出来的,是算出来的。

★★ Thue–Morse 序列:对任何奇数 base 都成立

Thue–Morse 序列:t[i] = i 的二进制里 1 的个数的奇偶性,也就是 0 1 1 0 1 0 0 1 …

取它的前 2^k 位当串 A(0 写成 a、1 写成 b),把每一位取反得到串 B。 那么 A 和 B 的哈希之差,展开之后正好是

   +- (1 - b^1) * (1 - b^2) * (1 - b^4) * ... * (1 - b^(2^(k-1)))

b 是奇数时,1 - b^(2^j) 里 2 的因子至少有 j + 2 个(j ≥ 1)。全乘起来:

2^k 2 的因子至少有 够不够 64
512 1 + (3+4+…+10) = 53 差 11 个
1024 1 + (3+4+…+11) = 64 正好够

长度 1024 的那一对,模 2⁶⁴ 的哈希必然相等,和 base 取多少一点关系都没有。

thue.cpp把上面那张表实测一遍:五个 base × 四个长度
./thue gen 10 会直接吐出那两个长度 1024 的串 —— 拿去喂给任何一份自然溢出的哈希,它都会说这两个串一样。
// ★★ 把「自然溢出哈希」当场打假:Thue–Morse 序列
//
// 很多人图省事,把哈希的模数省掉,直接用 unsigned long long 让它自然溢出
// (相当于模 2⁶⁴)。这看着比取模还安全 —— 2⁶⁴ 那么大,怎么可能撞上?
//
// ★ 可它对**任何奇数 base** 都有一组固定的反例,而且不用碰运气去撞,是**算出来的**:
//
// Thue–Morse 序列 t[i] = i 的二进制里 1 的个数的奇偶性(0,1,1,0,1,0,0,1,…)。
// 取它前 2^k 位当串 A(0→'a'、1→'b'),把每一位取反得到串 B。
// ⇒ A 和 B 的哈希之差 = (b^0 ± …) 展开之后正好等于
// ± Π(j = 0..k−1) (1 − b^(2^j))
// 而 base 是奇数时,(1 − b^(2^j)) 里 2 的因子至少有 j+2 个(j ≥ 1),
// 全乘起来 2 的因子有 1 + Σ(j+2) ≥ 64 个 —— k = 10 时正好够。
// ⇒ **长度 1024 的那一对,模 2⁶⁴ 必然相等,和 base 取多少无关。**
//
// 用法:
// ./thue gen <k> 打印两个串(A 和 B),长度 2^k —— 拿去喂给任何自然溢出的哈希
// ./thue table k = 8..11 × 五个 base 的实测表(自然溢出 vs 取模 10⁹+7)
// ./thue csv 同一张表,只打 `键,值`,给 check:viz 用
#include <bits/stdc++.h>
using namespace std;
typedef unsigned long long ull;
// 按显示宽度补空格:ASCII 算 1 格,非 ASCII(汉字、★)算 2 格
// ⚠ 含中文的列不能用 printf 的 %-Ns 对齐 —— 那个数的是字节,一个汉字占 3 字节却只显示 2 格。
static string padDisp(const string& s, int width) {
int disp = 0;
for (unsigned char c : s) {
if ((c & 0xC0) == 0x80) continue; // UTF-8 续字节,不算宽度
disp += (c < 0x80) ? 1 : 2;
}
return s + string(max(0, width - disp), ' ');
}
/** Thue–Morse 的前 len 位:t[i] = popcount(i) & 1 */
static string thueMorse(int len, bool flip) {
string s(len, 'a');
for (int i = 0; i < len; i++) {
int bit = __builtin_popcount((unsigned)i) & 1;
s[i] = (char)('a' + (bit ^ (flip ? 1 : 0)));
}
return s;
}
static ull hashNatural(const string& s, ull base) {
ull h = 0;
for (char c : s) h = h * base + (ull)(c - 'a' + 1); // 不取模 —— 让它自然溢出
return h;
}
static long long hashMod(const string& s, long long base, long long mod) {
long long h = 0;
for (char c : s) h = (h * base + (c - 'a' + 1)) % mod;
return h;
}
int main(int argc, char** argv) {
string cmd = (argc > 1) ? argv[1] : "table";
const long long MOD = 1000000007;
const ull BASES[5] = {131, 13331, 233, 1000003, 20220617};
if (cmd == "gen") {
int k = (argc > 2) ? atoi(argv[2]) : 10;
if (k < 0) k = 0;
if (k > 20) k = 20;
int len = 1 << k;
printf("%s\n%s\n", thueMorse(len, false).c_str(), thueMorse(len, true).c_str());
return 0;
}
bool csv = (cmd == "csv");
const int LENS[4] = {256, 512, 1024, 2048};
if (!csv) {
printf("Thue-Morse 的前 len 位(串 A)和它逐位取反(串 B)—— 这两个串一个字符都不一样。\n");
printf("下面看它们的哈希撞不撞:\n\n");
cout << " " << padDisp("base", 12) << padDisp("自然溢出(模 2^64)", 36) << "模 10^9+7\n";
cout << " " << padDisp("", 12);
for (int i = 0; i < 4; i++) cout << padDisp("len=" + to_string(LENS[i]), 9);
cout << "len=1024\n";
cout << " " << padDisp(string(10, '-'), 12) << padDisp(string(34, 45), 36) << "--------\n";
}
for (int b = 0; b < 5; b++) {
if (!csv) cout << " " << padDisp(to_string(BASES[b]), 12);
for (int i = 0; i < 4; i++) {
int len = LENS[i];
bool same = hashNatural(thueMorse(len, false), BASES[b])
== hashNatural(thueMorse(len, true), BASES[b]);
if (csv) printf("nat%d_%llu,%d\n", len, BASES[b], same ? 1 : 0);
else cout << padDisp(same ? "撞上了" : "没撞", 9);
}
bool sameMod = hashMod(thueMorse(1024, false), (long long)BASES[b], MOD)
== hashMod(thueMorse(1024, true), (long long)BASES[b], MOD);
if (csv) printf("mod1024_%llu,%d\n", BASES[b], sameMod ? 1 : 0);
else cout << (sameMod ? "撞上了" : "没撞") << "\n";
}
if (!csv) {
printf("\n => len = 1024 那两列,五个 base 全部撞上 —— 这不是运气,是算出来的。\n");
printf(" => 512 还撞不上:2 的因子只攒到 53 个,离 64 还差 11 个。\n");
printf(" => 而同样这些串,取模 10^9+7 的哈希一次都没撞。\n");
}
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果

本机实测:长度 256 / 512 一次都没撞;长度 1024 和 2048,五个 base 全部撞上 (131、13331、233、1000003、20220617)。而同样这些串,取模 10⁹+7 的哈希一次都没撞

⚠ 这一条和上一条的性质完全不同
单模数 10⁹+7 自然溢出 2⁶⁴
反例怎么来的 撞出来的(生日攻击,3 万多次尝试) 算出来的(一条恒等式)
换个 base 还成立吗 不成立,要重新撞一遍 成立 —— 任何奇数 base 都躲不掉
出题人要多少成本 不到一秒 一行构造

★ 所以「自然溢出更安全」这句话是彻底反的:它不但不安全,而且是唯一一个可以被一劳永逸卡死的写法。 ⚠ 真要用自然溢出,就得随机化 base(每次跑取不同的奇数)—— 那时上面那条恒等式仍然成立, 但出题人不知道你的 base,只能赌。

11★ 对拍:五个写错的版本,外加一份「什么都不做」

标准答案是 brute.cpp —— 它一次哈希都不算,纯逐字符比,和正解没有任何共同的想法

对拍器
★ 生成器不给档位时跑的就是最终档(第 12 步那张表里的档位 5):小字母表 + 前导 a + Thue–Morse 段 + 那一对碰撞串。
// 正解:字符串哈希(双模数),O(n + m + q)
//
// ★ 关键的一步只有一句话:**把子串变成一个数**。
// 把串看成一个 base 进制的数:h(s) = s[0]·b^(k-1) + s[1]·b^(k-2) + … + s[k-1]
// 再对它取模。于是「两个子串相不相等」就变成「两个数相不相等」—— O(1)。
//
// ⚠ 而「一个子串的哈希」能 O(1) 取出来,靠的是**前缀哈希**,
// 它和第 6 章的前缀和是同一个形状(那里是减一下,这里也是减一下,只多乘一个 b 的幂):
// h[i] = 前 i 个字符的哈希
// sub(l, r) = h[r+1] − h[l]·b^(r−l+1)
//
// ⚠⚠ 这一章真正的主课不在这几行,而在「**它会错**」:
// · 单模数会被生日攻击撞(本章 collide.cpp 三万多个串就撞出一对);
// · 自然溢出(unsigned long long)会被 Thue–Morse 序列**对任何奇数 base** 卡死(thue.cpp)。
// ⇒ 所以这里用**两个模数**:要错就得同时在两个模数上撞,概率约 1 / 10¹⁸。
//
// ⚠ 还有一处很容易写错、而且随机数据抓不到:字符要映射成 **1..26 而不是 0..25**。
// 映射成 0 的话 "a" 和 "aa" 的哈希都是 0 —— 见 wrongBase.cpp。
#include <bits/stdc++.h>
using namespace std;
const long long MOD1 = 1000000007, MOD2 = 998244353;
const long long B1 = 131, B2 = 13331;
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();
/* ① 前缀哈希 + base 的幂(两套,两个模数各一套) */
vector<long long> h1(n + 1, 0), h2(n + 1, 0), pw1(n + 1, 1), pw2(n + 1, 1);
for (int i = 0; i < n; i++) {
long long c = t[i] - 'a' + 1; // ⚠ +1:不许有字符映射成 0
h1[i + 1] = (h1[i] * B1 + c) % MOD1;
h2[i + 1] = (h2[i] * B2 + c) % MOD2;
pw1[i + 1] = pw1[i] * B1 % MOD1;
pw2[i + 1] = pw2[i] * B2 % MOD2;
}
/* ② 取子串 t[l..r] 的哈希 —— 就是前缀和那一减 */
auto sub = [&](int l, int r) {
int len = r - l + 1;
long long a = ((h1[r + 1] - h1[l] * pw1[len]) % MOD1 + MOD1) % MOD1; // ⚠ +MOD 再取模
long long b = ((h2[r + 1] - h2[l] * pw2[len]) % MOD2 + MOD2) % MOD2;
return make_pair(a, b);
};
/* ③ 第一问:模式串出现在哪些位置(允许重叠) */
long long g1 = 0, g2 = 0;
for (char c : p) {
g1 = (g1 * B1 + (c - 'a' + 1)) % MOD1;
g2 = (g2 * B2 + (c - 'a' + 1)) % MOD2;
}
vector<int> pos;
for (int i = 0; i + m <= n; i++) {
pair<long long, long long> s = sub(i, i + m - 1);
if (s.first == g1 && s.second == g2) pos.push_back(i);
}
cout << pos.size();
for (int x : pos) cout << ' ' << x;
cout << '\n';
/* ④ 第二问:q 个询问,问 t 的两个子串相不相等 —— ★ KMP 做不了这件事,哈希 O(1) 就答 */
int q;
cin >> q;
for (int k = 0; k < q; k++) {
int l1, r1, l2, r2;
cin >> l1 >> r1 >> l2 >> r2;
cout << (sub(l1, r1) == sub(l2, r2) ? "Y" : "N") << '\n';
}
return 0;
}
点一下即可编辑

300 轮实测(种子 1..300,最终档 5):

故意写错的地方 被抓 靠什么现形
wrongSub:指数写成 r - l(差一位) 226 / 300 随机就抓
wrongNeg:减完忘了 + M 216 / 300 随机就抓,但数据里得真的有相等的东西
wrongNatural:自然溢出 68 / 300 只有 Thue–Morse 段抓得到
wrongBase:字符映射成 0..25 61 / 300 前导 a + 不等长的询问
wrongSingle:只用一个模数 ★★ 50 / 300 只有那一对造出来的碰撞串抓得到

★ 又是那个熟悉的形状(第 45、46、47、48 章一路下来都是它): 两个随机就能抓,三个必须专门造。 而这一章的「专门造」比前面几章都硬 —— 前面造的是「一类形状」(小字母表、周期串),这里造的是两个具体的、算出来的反例

五个错误版本 + 一份试金石(点开看)
wrongSub.cpp④ 指数差一位 —— 随机就抓
wrongNeg.cpp⑤ 减完忘了 + M —— 只会把相等判成不相等
wrongNatural.cpp② 自然溢出 —— 只有 Thue–Morse 抓得到
wrongBase.cpp③ 字符映射成 0..25 —— a 和 aa 同值
wrongSingle.cpp① 只用一个模数 —— 要专门造的碰撞串
wrongIdentity.cpp★ 试金石:第一问永远答 0,第二问永远答 N

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

12★★ 生成器:顺手写的那一档,连「什么都不做」都打不假

档位 相对上一档拧了什么 ①Single ②Natural ③Base ④Sub ⑤Neg ★ 试金石
0(顺手写法) 26 字母随机,文本 2040、模式 25,询问随便挑两段 0 0 0 9 6 42
1 ★ 让模式串真的出现 + 询问里真的造出「相等」 0 0 0 283 212 283
2 ★ 字母表压到 2 个(a / b) 0 0 11 298 285 298
3 ★ 前导 a + 不等长的询问 0 0 95 300 285 300
4 ★ 掺进 Thue–Morse 段 0 68 70 229 218 297
5 ★★ 最终档 ★ 掺进那一对碰撞串 50 68 61 226 216 299
6 对照 = 5 − Thue–Morse 68 0 70 288 277 297
7 对照 = 5 − 碰撞串 0 68 70 229 218 297
★★★ 第一行:顺手写的数据,连一份「什么都不做」的程序都打不假

wrongIdentity.cpp 第一问永远答「出现 0 次」,第二问永远答 N —— 它不是一个真实的 bug,它是用来验生成器的(第 31 章立的规矩)。

顺手写的那一档,它 300 轮里蒙对了 258 轮。

原因是这一章的数据有两个天然的偏心,而且它们都指向同一个方向:

  • 26 个字母随机的话,一个长度 3 的模式串在 30 个字符里几乎不可能出现 —— 第一问的答案本来就是 0;
  • 随机挑两段子串,它们相等的概率同样接近 0 —— 第二问的答案本来就全是 N

连「什么都不做」都打不假的数据,什么都证明不了。 所以档位 1 干的第一件事不是造 bug,而是把「有答案」这件事造出来: 把模式串真的埋进文本、把一段真的抄到另一处。它一档就把试金石从 42 抬到 283。

⚠ 老实账:两个「专门造的反例」会互相挤占

看 ①Single 那一列:档位 6(只有碰撞串)是 68,而最终档 5(碰撞串 + Thue–Morse)掉到 50。 ②Natural 那一列反过来看也一样:档位 7 是 68,最终档还是 68 —— 但它把 ④⑤ 从 288/277 压到了 226/216。

道理很直白:一轮数据只能长一个样子。Thue–Morse 轮占掉四分之一,碰撞轮就少了四分之一, 而这两种轮对 ③④⑤ 全是废的(它们的文本是写死的)。

⇒ 这是第 31 章那条「每一支都要有,而且都不能多到吃掉别人」在这一章的现场。 但这笔账该怎么算,第 30 章早就定了:把 0 变成非 0,掉多少抓获率都划算。 ①② 两列在档位 3 之前是精确的 0,那是「完全测不到」;50 和 68 是「三百轮里抓得住」—— 两者不是量的差别。

⚠ 再记一笔:档位 4 让 ③Base 从 95 掉到 70

同一个原因(Thue–Morse 轮把普通轮挤掉了四分之一)。它仍然留着,理由不是抓获率, 是②那一列从 0 变成了 68

★ 这也是为什么最终档旁边一定要留对照档(6 和 7): 没有它们,你说不清 50 和 68 各自是谁的功劳,也说不清代价是谁付的。

gen.cpp(八个档位)五个错法各要什么,文件头一条条写着

13★ KMP 和哈希,到底该用哪个

第一问两章都能解,那就正面比一次。 本机实测(A 机,2026-08-26,独占;./genBig 10000000 10000 0 rand,也就是 n = 10⁷m = 10⁴、不问第二问):

耗时 峰值内存 代码长度 会不会给错答案
暴力逐字符 23.45 秒 最短 不会
第 48 章 KMP 0.02 秒 19.9 MiB 不会
本章哈希 0.16 秒 318.7 MiB
⚠⚠ 那 318.7 MiB 是哪来的 —— 哈希的第二笔账

正解开了四个 long long 数组(两个模数各要「前缀哈希」和「base 的幂」), 一共 32n 字节。n = 10⁷ 就是 320 MB —— 直接撞穿 256 MB 的空间限制,第 45 章那节讲的 MLE

⇒ 两个补救办法(都写进正文,是因为它们的代价不一样):

  • 存成 unsigned int(哈希值都小于 2³⁰,装得下)⇒ 砍一半,16n
  • base 的幂只算用得到的那几个长度,或者干脆现算 ⇒ 再砍一半。

★ 而 KMP 那边只需要一个长 m 的 next 数组 —— 和文本多长毫无关系

★ 一句考场上的实话:能用 KMP 就别赌哈希
什么时候用哈希 什么时候别用
★ 要比任意两段子串(本章第二问)—— KMP 做不了 只是「找一个固定模式串」—— KMP 又快又稳又省内存
二分 + 哈希求最长公共前缀这类,写起来短得多 数据是别人出的,而你抄的是网上最流行的那套参数
多维 / 树上 / 二维矩阵的匹配,哈希几乎是唯一好写的 空间卡得紧(32n 不是小数目)

⚠ 而这一章真正想让你记住的不是「哈希不好」,是: 它是本书第一个「会给出错误答案」的算法,而那个错误是可以被人专门造出来的。 用它,就得知道自己在赌什么、以及怎么把胜率提到 1 / 10¹⁸

14这一章没讲的,和下一章

⚠ 边界
没讲的 一句话
Trie(字典树) 下一章(第 50 章):一堆串摆成一棵树,查前缀只和串长有关,和串的数量无关
二分 + 哈希求 LCP 哈希最常见的第二个用法:两段的最长公共前缀,二分长度、每次 O(1) 判等 ⇒ O(log n)
回文串 / 二维哈希 正着一套哈希、倒着一套,O(1) 判回文;二维就是行列各做一次。路子完全一样
后缀数组 / 后缀自动机 本书不讲。它们能做的事哈希多半也能做,只是常数和证明的严谨程度不同

★ 顺带一句:unordered_map 内部也是哈希,而它同样会被专门构造的数据卡 (那是另一种攻击,卡的是「同一个桶里挤太多」而不是「值撞上」)。 考场上被卡了就换成 map,或者给 key 异或一个随机数 —— 和这一章「随机化 base」是同一个念头。

15自测

自测清单0 / 14
配套练习
  • 洛谷 P3370 【模板】字符串哈希 —— ★ 就是「N 个串里有几个不同的」—— 正好是本章第 8 步那第三笔账(两两比)的现场,单模数在这道题上是真的会被卡
  • 洛谷 P3375 【模板】KMP —— 第 48 章那道题。用哈希再写一遍,体会一下「同一道题两条路」
  • 洛谷 P1368 【模板】最小表示法 —— ⚠ 提高组:哈希不是正解,但拿哈希 + 二分能写出一个好懂的版本,正好练「二分 + 哈希」这个套路
  • 洛谷 P4551 最长异或路径 —— ⚠ 提高组:它其实是第 50 章 01-Trie 的题。放在这儿是想说明「把东西变成一个数」之后,路还能往哪儿走
  • 洛谷 P1381 单词背诵 —— 哈希表 + 尺取。★ 这里用的是 unordered_map 那种哈希,和本章的字符串哈希是两件事 —— 分清楚它们
  • 洛谷 P2957 [USACO09OCT] Barn Echoes —— 入门难度:求两个串的最长「首尾重叠」。数据很小,正好拿来练手写哈希(也正好说明小数据上什么写法都过)
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)
这一章记住三句话
  1. 把子串变成一个数 —— 前缀哈希 h[i+1] = h[i]·b + s[i],取子串是 h[r+1] − h[l]·b^(r−l+1)这和第 6 章的前缀和是同一个形状,只是减之前要先补齐位数。
  2. ⚠⚠ 它是本书第一个会给出错误答案的算法,而且错法能被专门造出来: 单模数被生日攻击撞(3 万多次尝试),自然溢出被 Thue–Morse 序列对任何奇数 base 卡死。 ⇒ 双模数 / 随机 base;能用 KMP 就别赌哈希。
  3. ★★ 顺手写的数据,连一份「什么都不做」的程序都打不假(300 轮蒙对 258 轮)—— 因为「模式串没出现」「两段不相等」本来就是随机数据的默认答案。 调生成器的第一步不是造 bug,是把「有答案」造出来。