1一句话问题
给文本串
t和模式串p(都只含小写字母,|t| ≤ 10⁶,|p| ≤ 10⁵)。① 第一行输出
p在t里出现了几次,然后是所有出现的起始下标(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。
后面三行是第二问:abab 对 abab(Y)、ba 对 ba(Y)、ab 对 aba(长度都不一样,N)。
输入
aaaaa aa 3 0 1 3 4 0 0 0 1 1 3 2 4
输出
4 0 1 2 3 Y N Y
★ 中间那一行是这一章的一个眼:a 和 aa —— 它们当然不是同一个串,可 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暴力:一个字符一个字符比
// 标准答案:一个哈希都不用,全靠逐字符比 —— 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;}点「运行 ▶」看结果
它是这道题最直白的翻译,也是这一章的标准答案 —— ★ 注意它和正解没有一个共同的想法: 正解那边一次字符比较都不做,只比两个整数。
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 章那条:先确认暴力真的把该做的活都做了。)
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
于是「两个串相不相等」就退化成「两个数相不相等」。
先把前缀哈希一次性算好(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 | a 和 aa 的哈希都变成 0 |
第 11 步 ③ |
那个指数是 r - l + 1(子串长度) |
减错位,答案基本全乱 | 第 11 步 ④ |
减完要 + M 再取模 |
C++ 的 % 会留下负数,同一个串算出两个值 |
第 11 步 ⑤ |
⚠ 第一条最阴:等长的比较完全看不出毛病(那时它仍是一一对应的编码), 毒只在「比两段不等长的子串」时发作。
5先手算一遍(用一个小到看得见的模数)
拿 b = 31、M = 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 是同一个数 ——
可 van 和 fig 明明是两个不同的串。这就是这一章后半段全部的内容。
6正解
// 正解:字符串哈希(双模数),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;}点「运行 ▶」看结果
7动画一:前缀哈希填一遍,然后窗口一格一格滑
★ 前半段是填表:每一格只是「上一格乘 31,再加这个字符的值」。 后半段是滑窗:窗口每往右挪一格,只做一次乘法一次减法 —— 和窗口有多长没有关系。
⚠ 这里的模数只有 1009(为了让数字印得下)。
★ 换到「假命中」那一组:窗口会在 van 上亮起来,可那一段根本不是 fig。
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。一个看着很大的模数,开根号只有三万多。
⇒ 所以「我这么写从来没出过问题」这句话,要看你是在做哪一行的事。
// ★★ 把「单模数哈希会撞」量出来,以及把那一对撞上的串**真的造出来**//// 这一章正文第 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。 而第二个模数下它们是 217439685 和 516061528,分开了。
造出那一对花了不到一秒。也就是说,出题人想专门卡掉「base = 131、模 10⁹+7」这个
最流行的组合,成本约等于零 —— 而这个组合正是网上模板里抄得最多的那一个。
⇒ 结论有两条,缺一不可:
- 用双模数(或者随机 base / 随机模数)—— 要错就得同时在两个模数上撞,那是
1 / 10¹⁸; - ★ base 和模数别照抄模板。你抄的是模板,别人卡的也是模板。
9动画二:看两个串怎么撞到同一个数上
左边那一列是模 1009 的哈希,右边那一列是换一个模数之后的哈希。 ★ 盯住最后一步:左边两个数变成了同一个,右边没有 —— 「双模数」三个字的全部理由就在这儿。
10★★ 自然溢出:一个不用碰运气就能打假的写法
省事的写法是把模数整个省掉,直接用 unsigned long long 让它自然溢出(相当于模 2⁶⁴)。
代码更短、跑得更快,而且 2⁶⁴ ≈ 1.8×10¹⁹ 看着比 10⁹ 安全得多。
可它有一组固定的反例,而且不是撞出来的,是算出来的。
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–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 —— 它一次哈希都不算,纯逐字符比,和正解没有任何共同的想法。
// 正解:字符串哈希(双模数),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 章一路下来都是它): 两个随机就能抓,三个必须专门造。 而这一章的「专门造」比前面几章都硬 —— 前面造的是「一类形状」(小字母表、周期串),这里造的是两个具体的、算出来的反例。
★ 每一份的文件头都写着「它靠什么现形」—— 那不是注释,那是下一步调生成器的依据。
12★★ 生成器:顺手写的那一档,连「什么都不做」都打不假
| 档位 | 相对上一档拧了什么 | ①Single | ②Natural | ③Base | ④Sub | ⑤Neg | ★ 试金石 |
|---|---|---|---|---|---|---|---|
| 0(顺手写法) | 26 字母随机,文本 20 |
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 是「三百轮里抓得住」—— 两者不是量的差别。
同一个原因(Thue–Morse 轮把普通轮挤掉了四分之一)。它仍然留着,理由不是抓获率, 是②那一列从 0 变成了 68。
★ 这也是为什么最终档旁边一定要留对照档(6 和 7): 没有它们,你说不清 50 和 68 各自是谁的功劳,也说不清代价是谁付的。
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 | 中 | ★ 会 |
正解开了四个 long long 数组(两个模数各要「前缀哈希」和「base 的幂」),
一共 32n 字节。n = 10⁷ 就是 320 MB —— 直接撞穿 256 MB 的空间限制,第 45 章那节讲的 MLE。
⇒ 两个补救办法(都写进正文,是因为它们的代价不一样):
- 存成
unsigned int(哈希值都小于2³⁰,装得下)⇒ 砍一半,16n; base的幂只算用得到的那几个长度,或者干脆现算 ⇒ 再砍一半。
★ 而 KMP 那边只需要一个长 m 的 next 数组 —— 和文本多长毫无关系。
| 什么时候用哈希 | 什么时候别用 |
|---|---|
| ★ 要比任意两段子串(本章第二问)—— KMP 做不了 | 只是「找一个固定模式串」—— KMP 又快又稳又省内存 |
| 二分 + 哈希求最长公共前缀这类,写起来短得多 | 数据是别人出的,而你抄的是网上最流行的那套参数 |
| 多维 / 树上 / 二维矩阵的匹配,哈希几乎是唯一好写的 | 空间卡得紧(32n 不是小数目) |
⚠ 而这一章真正想让你记住的不是「哈希不好」,是:
它是本书第一个「会给出错误答案」的算法,而那个错误是可以被人专门造出来的。
用它,就得知道自己在赌什么、以及怎么把胜率提到 1 / 10¹⁸。
14这一章没讲的,和下一章
| 没讲的 | 一句话 |
|---|---|
| Trie(字典树) | 下一章(第 50 章):一堆串摆成一棵树,查前缀只和串长有关,和串的数量无关 |
| 二分 + 哈希求 LCP | 哈希最常见的第二个用法:两段的最长公共前缀,二分长度、每次 O(1) 判等 ⇒ O(log n) |
| 回文串 / 二维哈希 | 正着一套哈希、倒着一套,O(1) 判回文;二维就是行列各做一次。路子完全一样 |
| 后缀数组 / 后缀自动机 | 本书不讲。它们能做的事哈希多半也能做,只是常数和证明的严谨程度不同 |
★ 顺带一句:unordered_map 内部也是哈希,而它同样会被专门构造的数据卡
(那是另一种攻击,卡的是「同一个桶里挤太多」而不是「值撞上」)。
考场上被卡了就换成 map,或者给 key 异或一个随机数 —— 和这一章「随机化 base」是同一个念头。
15自测
- 洛谷 P3370 【模板】字符串哈希 —— ★ 就是「N 个串里有几个不同的」—— 正好是本章第 8 步那第三笔账(两两比)的现场,单模数在这道题上是真的会被卡
- 洛谷 P3375 【模板】KMP —— 第 48 章那道题。用哈希再写一遍,体会一下「同一道题两条路」
- 洛谷 P1368 【模板】最小表示法 —— ⚠ 提高组:哈希不是正解,但拿哈希 + 二分能写出一个好懂的版本,正好练「二分 + 哈希」这个套路
- 洛谷 P4551 最长异或路径 —— ⚠ 提高组:它其实是第 50 章 01-Trie 的题。放在这儿是想说明「把东西变成一个数」之后,路还能往哪儿走
- 洛谷 P1381 单词背诵 —— 哈希表 + 尺取。★ 这里用的是 unordered_map 那种哈希,和本章的字符串哈希是两件事 —— 分清楚它们
- 洛谷 P2957 [USACO09OCT] Barn Echoes —— 入门难度:求两个串的最长「首尾重叠」。数据很小,正好拿来练手写哈希(也正好说明小数据上什么写法都过)
- ★ 把子串变成一个数 —— 前缀哈希
h[i+1] = h[i]·b + s[i],取子串是h[r+1] − h[l]·b^(r−l+1)。这和第 6 章的前缀和是同一个形状,只是减之前要先补齐位数。 - ⚠⚠ 它是本书第一个会给出错误答案的算法,而且错法能被专门造出来: 单模数被生日攻击撞(3 万多次尝试),自然溢出被 Thue–Morse 序列对任何奇数 base 卡死。 ⇒ 双模数 / 随机 base;能用 KMP 就别赌哈希。
- ★★ 顺手写的数据,连一份「什么都不做」的程序都打不假(300 轮蒙对 258 轮)—— 因为「模式串没出现」「两段不相等」本来就是随机数据的默认答案。 调生成器的第一步不是造 bug,是把「有答案」造出来。