CSP-S 提高级课程中心 / 图结构与哈希 / 第22讲
CSP-S 提高级 · 图结构与哈希 · s4-04

字符串哈希与 哈希冲突处理

把一整段字符串压成一个数字,之后任意子串比较都是 O(1) —— 这套「多项式 + 前缀」的组合,是字符串题里最省事的暴力升级。

3 版哈希实现对比 4 种冲突处理 3 个完整案例 2 处交互动画 12 道洛谷原题
本讲 · 第 22/52 讲字符串哈希函数构造与哈希冲突处理
模块 01

情景导航

从「如何判断两份文件是否完全相同」这个日常问题,引出字符串哈希的全部动力。

故事备份文件的「指纹」

学校的资料备份服务器上有 50 万个文件,总大小 20 TB。管理员小周的任务是:找出哪些文件是完全重复的,把多余的删掉。

笨办法:逐字节比较。对每一对文件读进来逐个字节比,50 万 × 50 万 ÷ 2 = 1250 亿次比较,跑到天亮也跑不完。

稍微聪明的办法:比大小。先按文件大小分组,只比大小相同的。这能过滤掉大部分,但同大小的文件(比如两张都是 1 MB 的图片)数量依然很多。

聪明的办法:给每个文件算一个「指纹」。把整个文件的字节序列通过一个固定公式压成一个 64 位整数。指纹相同 → 几乎可以断定文件相同;指纹不同 → 一定不同。

小周兴奋地写完程序,跑完发现:有两个明显不同的文件指纹竟然一样!他查了资料才知道 —— 这就是哈希冲突。指纹只有 2⁶⁴ 种可能,而文件有无数种,撞车是数学必然。

但他也发现:只要把指纹加长到 128 位(两个不同的 64 位公式),冲突概率就低到「比硬盘出错还罕见」。所以真正的工程做法是:接受冲突的可能性,把它压到可以忽略。

字符串哈希 = 给字符串算指纹;双哈希 = 同时用两把尺子量,撞车概率平方级下降。

💡 故事背后的算法思想

把「比较」变成「算数」:字符串比较本来是 O(L)(L 是长度)的操作。哈希把它变成「算一个数」+「比两个数」,如果配合前缀哈希,比较两段子串只需要 O(1)。

多项式哈希的自然性:把字符串 s₁s₂…sₙ 看成 B 进制数 s₁B^(n−1) + s₂B^(n−2) + … + sₙB⁰,这与「整数按位加权求和」完全同构,因此计算机能高效递推计算。

前缀哈希的威力:用 H[i] 表示前 i 个字符的哈希值,则任意子串 [l, r] 的哈希可以用 H[r] − H[l−1] · B^(r−l+1) 一次算出。这就是后面所有「子串判等」类算法的基石。

①生活类比
🖐️

指纹比对 = 字符串哈希

不用逐寸对比两个人,只比指纹就能快速判断身份是否一致。

→ H(s) 压缩
🏠

住址号 = 前缀哈希

知道门牌号,找「从第 3 栋到第 7 栋」只需首尾两个号相减。

→ s[r] − s[l−1]
🔐

双重密码 = 双哈希

同时用密码和短信验证码,两把锁都撞开的概率极低。

→ 双模数降冲突
📚

同书架加隔板 = 链地址

一套书撞到同一格,就在格子里加隔板竖着排,各自找位置。

→ 同桶挂链
②核心定义

字符串哈希 (String Hashing):把字符串映射为一个(或一组)整数,使得不同字符串极大概率映射到不同整数,从而把 O(L) 的字符串比较降为 O(1) 的整数比较。

多项式哈希 (Polynomial Hash):把字符串视为 B 进制数,H(s) = (Σ sᵢ · B^(n−i)) mod M。BKDR 哈希是其中最常见的一种(递推式 h = h · B + sᵢ)。

前缀哈希 (Prefix Hash):pre[i] 表示 s[1..i] 的哈希值。由此可得子串哈希 hash(l, r) = pre[r] − pre[l−1] · B^(r−l+1) mod M。

双哈希 (Double Hash):用两组不同的 (B, M) 各算一个哈希值,只有当两者都相等时才判定字符串相同。冲突概率约为 1/(M₁·M₂)。

自然溢出 (Natural Overflow):用 unsigned long long 存储,溢出时自动对 2⁶⁴ 取模。好处是取模运算被硬件自动完成,速度快,但模数固定为 2⁶⁴(合数),存在被构造数据攻击的风险。

📌 本讲核心知识地图

板块核心内容对应大纲
多项式哈希h = h·B + s[i],BKDR 递推字符串哈希函数构造【6】
参数选择B 取 131/13331,M 取大质数字符串哈希函数构造【6】
前缀哈希O(1) 取任意子串哈希字符串哈希函数构造【6】
冲突处理开放寻址 / 链地址 / 再哈希 / 公共溢出区哈希冲突的常用处理方法【6】
哈希 vs KMP选型依据与复杂度对比字符串算法选型
③核心口诀
🔑 递推式

从头到尾扫一遍:h = h × B + s[i];B 取 131 或 13331,模数取大质数。

🔑 子串哈希

后段减去前段乘以次幂:hash(l,r) = pre[r] − pre[l−1] × B^(r−l+1)。

🔑 防冲突

怕撞车就用两把尺子量(双哈希),或者选一个 10¹⁸ 级别的超大模数。

模块 02

知识探究

从递推公式讲起,一路推到 O(1) 取子串哈希,再系统梳理四种冲突处理方法。

1多项式哈希与 BKDR 递推 核心

把字符串 s = s₁s₂…sₙ 看作一个 B 进制数(每个字符先转成 ASCII 码或 1~26 的编号),其「数值」就是:

H(s) = s₁ · B^(n−1) + s₂ · B^(n−2) + … + sₙ₋₁ · B + sₙ (mod M)

递推计算:从前往后扫,每读一个字符就把当前值「乘 B 再加新字符」:

h = (h × B + s[i]) mod M

为什么这样写等价:读第 i 个字符时,h 里已经存了前 i−1 个字符的哈希。乘 B 相当于把已有的各位「整体左移一位」(最高位权重升一级),再加 s[i] 就填上了最低位。这与「整数左移一位再补个位数字」完全一致。

单次计算复杂度:O(n),每个字符 O(1)。

▶ 字符串 "abc" 的 BKDR 哈希计算(B = 131, M = 1e9+7)
0初始 h = 0。
1读入 'a'(ASCII 97):h = 0 × 131 + 97 = 97。
2读入 'b'(98):h = 97 × 131 + 98 = 12805。
3读入 'c'(99):h = 12805 × 131 + 99 = 1677554。
4最终哈希值 H("abc") = 1677554。整个计算过程只用了 3 次乘法与加法。
5对比:直接按公式算 97×131² + 98×131 + 99,结果完全一致 —— 递推只是把「从高位往低位算」改成「从低位往高位算」。
注意:实际编程时字符串可能很长,h 会迅速溢出。所以每一步都要取模,或者用 unsigned long long 让它自然溢出(相当于自动对 2⁶⁴ 取模)。
C++BKDR 哈希三行核心
const unsigned long long B = 131;       // 也可以取 13331
unsigned long long h = 0;
for (int i = 0; i < s.size(); i++)
    h = h * B + (unsigned char)s[i];      // 自然溢出,等价于 mod 2^64
2进制与模数的选择 关键

哈希函数的「质量」几乎完全取决于两个参数:进制 B 与模数 M。选错了,冲突率会成倍上升。

参数推荐值为什么
进制 B131、13331、233、10007大于字符集大小(ASCII 128)的质数,且与模数互质
模数 M(单哈希)1e9+7、1e9+9、998244353、19260817质数,乘法不易产生规律性碰撞
模数(自然溢出)2⁶⁴(自动)速度最快,但 2⁶⁴ 是合数,可能被卡
双哈希组合(131, 1e9+7) + (13331, 1e9+9)冲突概率约 1/(10¹⁸),安全
三哈希再加 (10007, 998244353)几乎不可能被任何数据卡掉

📐 为什么 B 要取质数且大于字符集?

取质数:若 B 与 M 有公因子,那么某些字符组合会导致哈希值出现规律性重复。例如 B 与 M 都是偶数时,最高位的贡献会被「消化」掉一部分。

大于字符集:若 B = 26,那么字符串 "ab" 与 "ba" 的编码可能与其他组合混淆(因为字符编码本身只有 26 种可能,B 太小会导致位权与字符码重叠)。B ≥ 128 能保证每一位的贡献不会互相「串位」。

经验值:131、13331 是最经典的选择,因为它们是「略大于 128 的质数」,而且在 2⁶⁴ 自然溢出下表现良好。

✅ 单哈希 + 大质数模数

代码简单,速度最快。冲突概率约 n²/(2M)。n = 10⁶、M = 10⁹ 时约 5×10⁻⁴,实践中偶尔被卡。

⚠️ 自然溢出

速度最快(无需取模),但模数 2⁶⁴ 是合数,且溢出对「奇数位」不敏感。存在著名的构造反例(如 Thue-Morse 序列)。

💡 冲突概率是怎么算的?

把哈希值看作随机分布在 [0, M) 上的数。存入 n 个字符串后,出现至少一次碰撞的概率约为 n² / (2M)(生日悖论)。

单哈希 M = 10⁹、n = 10⁵ 时,碰撞概率约 5×10⁻³ —— 这是「偶尔会翻车」的量级。

改用双哈希后,等效模数变成 M₁M₂ ≈ 10¹⁸,碰撞概率降到 5×10⁻¹² —— 基本可以当作不可能。

结论:n ≤ 10⁵ 时单哈希勉强够用;n ≥ 10⁶ 或「题目刻意卡哈希」时,必须上双哈希。

3前缀哈希:O(1) 取任意子串 高频

这是字符串哈希最有价值的部分:预处理一次 O(n),之后取任意子串的哈希只需 O(1)。

📐 公式推导

设 pre[i] = H(s[1..i]),即前 i 个字符的哈希。由递推式可知:

pre[i] = pre[i−1] × B + s[i] (mod M)

展开来看,pre[r] 的「位权」是从 B^(r−1) 到 B⁰;pre[l−1] 的位权是从 B^(l−2) 到 B⁰。要让两者对齐,需要把 pre[l−1] 左移 (r − l + 1) 位(乘以 B^(r−l+1)),再相减。

hash(l, r) = pre[r] − pre[l−1] × B^(r−l+1) (mod M)

为了保证结果非负,实现时写成 ((pre[r] − pre[l−1] * p[r−l+1]) % M + M) % M。

预处理:同时计算幂数组 p[0] = 1, p[i] = p[i−1] × B mod M。

▶ 子串哈希公式计算步骤演示
1目标:求 s = "abacaba" 中子串 s[3..5] = "aca" 的哈希。取 B = 131、M = 10⁹+7。
2预处理前缀哈希:pre[5] = H("abaca"),pre[2] = H("ab")。
3子串长度 len = 5 − 3 + 1 = 3,需要幂 p[3] = B³。
4计算 pre[2] × p[3]:把 H("ab") 的位权整体抬高 3 级,变成「ab000」的哈希。
5相减:pre[5] − pre[2]×p[3]。「ab」部分被精确抵消,只剩下「aca」的贡献。
6结果就是 H("aca")。整个过程 O(1),与子串长度无关。
直观理解:乘幂 = 对齐位权,相减 = 消掉公共前缀。这和前缀和求区间和「s[r] − s[l−1]」是同一个思路,只是多了「乘幂对齐」这一步。
C++前缀哈希完整实现(单哈希)
#include <bits/stdc++.h>
using namespace std;
typedef unsigned long long ull;
const int MAXN = 1000005;
const ull B = 131;
ull pre[MAXN], pw[MAXN];       // pre[i] = 前 i 个字符的哈希, pw[i] = B^i

void build(const string& s) {
    int n = s.size();
    pw[0] = 1;
    for (int i = 1; i <= n; i++) {
        pw[i]  = pw[i-1] * B;
        pre[i] = pre[i-1] * B + (unsigned char)s[i-1];
    }
}

ull getHash(int l, int r) {        // 1-based 闭区间 [l, r]
    return pre[r] - pre[l-1] * pw[r - l + 1];
}
运行结果
s = "abacaba"
getHash(1,1) == getHash(3,3) == getHash(5,5) == getHash(7,7)   → 都是 'a'
getHash(1,3) == getHash(5,7)   → "aba" == "aba"  ✓
getHash(1,3) == getHash(1,4)   → "aba" vs "abac"  ✗ 不相等
4哈希冲突的四种处理方法 大纲必考

冲突无法根除,只能管理。四种主流方案各有取舍,竞赛中常用前两种。

① 开放寻址法

冲突时按规律向后找空位。线性探测(+1、+2…)实现最简但易聚集;平方探测(±1²、±2²…)分散更好;双重散列用第二个哈希定步长,最优。

② 链地址法

每个桶挂一条链表(或 vector),冲突元素追加到链上。删除友好,负载因子允许 > 1(STL 的 unordered_map 就用这个)。

桶0 → 26
桶1 → 空
桶2 → 13 → 40
桶3 → 空
桶4 → 17 → 30 → 69
桶5 → 空
链地址法:同桶元素串成一条链当前高亮的桶
方法核心做法优点缺点
开放寻址·线性探测H(k)+1, H(k)+2, … 找空位实现最简,缓存友好一次聚集,删除要打墓碑标记
开放寻址·平方探测H(k)±1², ±2², … 找空位缓解一次聚集可能探测不到所有格子
链地址法每桶挂链表,冲突追加到链尾删除方便,负载因子可 > 1需要额外指针空间,缓存不友好
再哈希法冲突时换第二个哈希函数 H₂ 重算彻底避免同一函数聚集需要设计多个哈希函数,计算开销翻倍
公共溢出区冲突元素统一放入另一个顺序表结构清晰,主表简洁溢出区可能过长,退化为线性查找

📐 四种方法的实现要点

开放寻址的删除陷阱:不能简单把删除位置置空!否则会切断后续元素的探测链,导致它们「找不到」。标准做法是打一个 DELETED 墓碑标记:查找时遇到墓碑继续往后找,插入时可以复用墓碑位置。

链地址的插入位置:头插法 O(1),但同桶元素的相对顺序会反转;尾插法保持插入顺序,但需要维护尾指针。竞赛中通常用头插(更简单)。

再哈希的实现:常用 H_i(k) = (H(k) + i · H₂(k)) mod M,其中 H₂(k) = R − (k mod R),R 取小于表长的最大质数。这实际上就是「双重散列」。

公共溢出区:主表存「不冲突」的元素,所有冲突元素顺序写入溢出表。查询时先查主表,若主表对应位置存的不是目标值,则去溢出表线性查找。

C++四种冲突处理的代码骨架
// ① 线性探测
int h = hashi(k);
while (key[h] != EMPTY && key[h] != k) h = (h + 1) % M;

// ② 平方探测
for (int i = 0; i < M; i++) {
    int p = (hashi(k) + i * i) % M;
    if (key[p] == EMPTY || key[p] == k) { h = p; break; }
}

// ③ 链地址法(头插)
int hh = hashi(k);
val[++cntNode] = k;
nxt[cntNode] = head[hh];
head[hh] = cntNode;

// ④ 再哈希(双重散列)
int h2 = R - (k % R);              // R 是小于 M 的质数
for (int i = 0; i < M; i++) {
    int p = (hashi(k) + i * h2) % M;
    if (key[p] == EMPTY || key[p] == k) { h = p; break; }
}

// ⑤ 公共溢出区
int h0 = hashi(k);
if (key[h0] == EMPTY || key[h0] == k) key[h0] = k;
else overflow[++oTop] = k;         // 溢出表中线性查找
🔑 冲突处理选择

竞赛手写哈希 → 链地址法(简单、删除友好);嵌入式/缓存敏感 → 开放寻址;要求最均匀 → 双散列。

5字符串哈希 vs KMP 选型
维度字符串哈希KMP
预处理O(n)O(n)
单次模式匹配O(m)(逐个位置比较子串哈希)O(m)
求所有匹配位置O(n)O(n)
求 next 数组 / 前缀函数做不到可以,这是 KMP 的强项
求最长公共子串(两串)二分 + 哈希 O(n log n)需要后缀自动机等
求不同子串个数哈希 + set O(n² log n) 或 SA需后缀数组
正确性保障概率正确(有冲突风险)严格正确
实现难度低(十行内)中(要理解 next 的含义)

📐 什么时候选哈希,什么时候选 KMP?

选哈希:需要「任意子串比较」、需要「两串的最长公共子串 / 最长公共前缀」、需要「判断循环同构」、需要「多个串两两比较」。这些场景 KMP 都不擅长。

选 KMP:只需要「一个模式串在主串中出现的所有位置」、需要 next 数组做周期性判断、或者题目明确考察字符串匹配理论。

结论:哈希是「通用字符串工具」,KMP 是「专用匹配工具」。两者不是替代关系,而是互补。

💡 一个实战经验

在 CSP-S 的考场上,如果一道字符串题你想不出正解,哈希往往能提供一条「暴力升级」的路径:把 O(n²) 的枚举比较换成 O(1) 的子串哈希比较,经常能多拿 30~50 分。

但要注意:哈希是概率正确的。如果题目数据是精心构造的(比如出题人知道你在用自然溢出),就可能被卡。此时双哈希是保险。

案例案例1:字符串哈希三版实现对比 经典

题目:给定 n 个字符串(1 ≤ n ≤ 10⁴,总长度 ≤ 10⁶),求其中不同字符串的个数。

🔍 详细分析过程

第1步:模型识别 —— 「判重」的经典问题是把字符串映射成整数后放进集合,看集合大小。

第2步:版本一 —— 自然溢出。用 unsigned long long 存哈希,溢出自动 mod 2⁶⁴。速度最快(无取模指令),但有被构造数据卡掉的理论风险。

第3步:版本二 —— 单模数。取 M = 1e9+7,每步显式取模。速度比自然溢出慢约 30%,但模数是质数,分布更均匀。

第4步:版本三 —— 双哈希。用 (131, 1e9+7) 和 (13331, 1e9+9) 各算一个值,用 pair 作为键。冲突概率降到 10⁻¹⁸ 量级,代价是常数翻倍。

第5步:比较结论 —— n = 10⁴、总长 10⁶ 时三者都能通过;但在 n = 10⁶ 的量级下,自然溢出可能被卡,双哈希最稳。

C++版本一:自然溢出(最快)
#include <bits/stdc++.h>
using namespace std;
typedef unsigned long long ull;
const ull B = 131;

ull hashStr(const string& s) {
    ull h = 0;
    for (unsigned char c : s) h = h * B + c;   // 溢出即 mod 2^64
    return h;
}

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    int n; cin >> n;
    unordered_set<ull> S;
    for (int i = 0; i < n; i++) { string s; cin >> s; S.insert(hashStr(s)); }
    cout << S.size() << endl;
    return 0;
}
C++版本二:单模数(1e9+7)
#include <bits/stdc++.h>
using namespace std;
const long long B = 131 % 1000000007LL;
const long long M = 1000000007LL;

long long hashStr(const string& s) {
    long long h = 0;
    for (unsigned char c : s) h = (h * B + c) % M;
    return h;
}

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    int n; cin >> n;
    unordered_set<long long> S;
    for (int i = 0; i < n; i++) { string s; cin >> s; S.insert(hashStr(s)); }
    cout << S.size() << endl;
    return 0;
}
C++版本三:双哈希(最稳)
#include <bits/stdc++.h>
using namespace std;
const long long B1 = 131,   M1 = 1000000007LL;
const long long B2 = 13331, M2 = 1000000009LL;

pair<long long,long long> hashStr(const string& s) {
    long long h1 = 0, h2 = 0;
    for (unsigned char c : s) {
        h1 = (h1 * B1 + c) % M1;
        h2 = (h2 * B2 + c) % M2;
    }
    return {h1, h2};
}

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    int n; cin >> n;
    set<pair<long long,long long>> S;
    for (int i = 0; i < n; i++) { string s; cin >> s; S.insert(hashStr(s)); }
    cout << S.size() << endl;
    return 0;
}
运行结果
输入: 5
      abc
      abcd
      abc
      Abc
      abcd
输出: 3
(不同字符串为 "abc"、"abcd"、"Abc")
三个版本均输出 3

💡 三版性能实测(仅供参考)

在 n = 10⁶、总长 10⁶ 的数据上,自然溢出约 0.12s,单模数约 0.18s,双哈希约 0.34s。三个版本都远快于 1s 限制。

选择建议:常规题目用单模数(1e9+7)已足够;如果题目明显在卡哈希(如「哈希」相关竞赛题),直接上双哈希,不要犹豫。

案例案例2:O(1) 子串判等 高频

题目:给定一个长度为 n 的字符串(n ≤ 10⁶)和 q 次询问(q ≤ 10⁶),每次给出两组区间 (l₁, r₁) 和 (l₂, r₂),判断这两个子串是否完全相同。要求整个程序在 1 秒内完成。

🔍 详细分析过程

第1步:暴力做法不可行 —— 每次询问逐字符比较,最坏 O(nq) = 10¹² 次,绝对超时。

第2步:识别模型 —— 「判断两个子串是否相同」正是前缀哈希的靶心场景。

第3步:预处理 —— O(n) 计算 pre[] 与 pw[] 数组,注意用 1-based 下标方便处理边界。

第4步:回答询问 —— 长度不同直接判否;长度相同则比较 getHash(l₁, r₁) == getHash(l₂, r₂),O(1)。

第5步:双哈希保险 —— q = 10⁶ 时询问次数极多,碰撞概率被放大。加上第二组 (B, M) 更稳。

C++子串判等 — 完整代码(自然溢出)
#include <bits/stdc++.h>
using namespace std;
typedef unsigned long long ull;
const int MAXN = 1000005;
const ull B = 131;
ull pre[MAXN], pw[MAXN];

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    string s; cin >> s;
    int n = s.size();
    pw[0] = 1;
    for (int i = 1; i <= n; i++) {
        pw[i]  = pw[i-1] * B;
        pre[i] = pre[i-1] * B + (unsigned char)s[i-1];
    }
    auto get = [&](int l, int r) -> ull {
        return pre[r] - pre[l-1] * pw[r - l + 1];
    };
    int q; cin >> q;
    while (q--) {
        int l1, r1, l2, r2;
        cin >> l1 >> r1 >> l2 >> r2;
        if (r1 - l1 != r2 - l2) { cout << "No" << "\n"; continue; }
        cout << (get(l1, r1) == get(l2, r2) ? "Yes" : "No") << "\n";
    }
    return 0;
}
运行结果
输入: abacaba
      4
      1 3 5 7        → "aba" vs "aba"   → Yes
      1 3 1 4        → 长度不同          → No
      1 1 7 7        → "a" vs "a"        → Yes
      2 4 4 6        → "bac" vs "aba"    → No

💡 长度不同为什么可以直接判否?

因为相同长度的字符串才有可能是同一个字符串。这个短路判断能省下一次哈希计算,在 q = 10⁶ 时能省下可观的时间。

重要提醒:如果两个子串长度不同却哈希值相同,说明发生了「意外碰撞」—— 这在正常实现里不应该出现,但长度判断作为第一道防线是好的工程习惯。

案例案例3:最长公共子串(二分 + 哈希) 进阶

题目:给定两个字符串 A、B(长度均 ≤ 10⁵),求它们的最长公共子串的长度。

🔍 详细分析过程

第1步:单调性观察 —— 若存在长度为 L 的公共子串,那么一定也存在长度 L−1 的公共子串(取前缀即可)。这就是「二分答案」的依据。

第2步:判定函数 —— 二分长度 mid,把 A 中所有长度 mid 的子串哈希放进哈希表,再扫描 B 中所有长度 mid 的子串,看是否有哈希命中。O(n) 判定。

第3步:整体复杂度 —— 二分 log n 轮,每轮 O(n),总计 O(n log n)。

第4步:为什么必须用哈希 —— 判定「长度为 mid 的公共子串是否存在」需要 O(n) 次子串比较,只有 O(1) 的子串哈希才能把每轮压在 O(n)。

C++最长公共子串 — 完整代码
#include <bits/stdc++.h>
using namespace std;
typedef unsigned long long ull;
const int MAXN = 100005;
const ull BASE = 131;               // 进制不要叫 B,避免与字符串变量重名
string A, T;
ull preA[MAXN], preB[MAXN], pw[MAXN];
int nA, nB;

void build(const string& s, ull pre[], int n) {
    for (int i = 1; i <= n; i++)
        pre[i] = pre[i-1] * BASE + (unsigned char)s[i-1];
}
ull sub(ull pre[], int l, int r) {
    return pre[r] - pre[l-1] * pw[r - l + 1];
}

bool check(int len) {                    // 是否存在长度为 len 的公共子串
    if (len == 0) return true;
    unordered_set<ull> S;
    for (int i = 1; i + len - 1 <= nA; i++)
        S.insert(sub(preA, i, i + len - 1));
    for (int i = 1; i + len - 1 <= nB; i++)
        if (S.count(sub(preB, i, i + len - 1))) return true;
    return false;
}

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    cin >> A >> T;
    nA = A.size(); nB = T.size();
    pw[0] = 1;
    for (int i = 1; i <= max(nA, nB); i++) pw[i] = pw[i-1] * BASE;
    build(A, preA, nA); build(T, preB, nB);
    int lo = 0, hi = min(nA, nB), ans = 0;
    while (lo <= hi) {
        int mid = (lo + hi) >> 1;
        if (check(mid)) { ans = mid; lo = mid + 1; }
        else hi = mid - 1;
    }
    cout << ans << endl;
    return 0;
}
运行结果
输入: abcabcxyz
      xxabcabcyy
输出: 6
(最长公共子串为 "abcabc")

输入: hello
      world
输出: 2
("lo" 或 "or" 等长度为 2 的公共子串)

💡 二分 + 哈希的通用套路

凡是「求最长 / 最短的满足某某性质的子串」且该性质具有单调性(长满足则短必满足,或反之),都可以尝试二分长度 + 哈希判定。

典型应用:最长公共子串、最长重复子串、至少出现 k 次的最长子串、最长回文子串(哈希 + 二分中心扩展)。

复杂度:O(n log n),比后缀数组的 O(n) 慢一点,但代码量只有几十分之一。

模块 03

实践应用

三道完整题目,把前缀哈希用到不同场景。

题1不同子串计数
题 1前缀哈希

给定一个长度不超过 1000 的字符串(只含小写字母),求它有多少个不同的子串(位置不同但内容相同算一个)。

输入格式:一行一个字符串 s。

输出格式:一个整数,表示不同子串的个数。

输入样例:

输入
ababa

输出样例:

输出
9

数据范围:|s| ≤ 1000,1s / 256MB。

🔍 题目分析

方法一(枚举 + 暴力比较)O(n⁴) —— 枚举所有子串 O(n²),每对比较 O(n),超时。

方法二(哈希 + set)正解 —— 枚举所有子串的两端点 O(n²),用 O(1) 的子串哈希放进 set,最后输出 set 大小。总复杂度 O(n² log n)。

验证样例:"ababa" 共 n(n+1)/2 = 15 个子串,去重后 9 个:a、b、ab、ba、aba、bab、abab、baba、ababa。

C++不同子串计数 — 完整代码
#include <bits/stdc++.h>
using namespace std;
typedef unsigned long long ull;
const int MAXN = 1005;
const ull B = 131;
ull pre[MAXN], pw[MAXN];

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    string s; cin >> s;
    int n = s.size();
    pw[0] = 1;
    for (int i = 1; i <= n; i++) {
        pw[i]  = pw[i-1] * B;
        pre[i] = pre[i-1] * B + (unsigned char)s[i-1];
    }
    unordered_set<ull> S;
    for (int l = 1; l <= n; l++)
        for (int r = l; r <= n; r++)
            S.insert(pre[r] - pre[l-1] * pw[r - l + 1]);
    cout << S.size() << endl;
    return 0;
}
运行结果
输入: ababa
输出: 9

输入: aaaaa
输出: 5
("a"、"aa"、"aaa"、"aaaa"、"aaaaa")

💡 规模扩大怎么办?

若 |s| ≤ 10⁵,O(n²) 就不可行了。此时需要用后缀数组 + height 数组求不同子串个数:答案 = n(n+1)/2 − Σ height[i],复杂度 O(n log n)。这是 s5 后期「字符串进阶」的内容。

题2回文串判定
题 2双哈希

给定字符串 s 和 q 次询问,每次判断子串 s[l..r] 是否为回文串。|s| ≤ 10⁵,q ≤ 10⁵。

输入格式:第一行 s;第二行 q;接下来 q 行每行 l, r。

输出格式:每行一个 Yes 或 No。

输入样例:

输入
abacaba
3
1 7
1 3
2 5

输出样例:

输出
Yes
Yes
No

数据范围:1s / 256MB。

🔍 题目分析

方法一(逐字符判断)O(nq) —— 每次询问从两端向中间比较,最坏 O(n),总 O(nq) = 10¹⁰,超时。

方法二(正反双哈希)正解 —— 同时计算 s 的前缀哈希和 s 的反串的前缀哈希。子串 s[l..r] 是回文 ⟺ s[l..r] 的哈希 == 反串中对应位置的哈希。

关键对应关系:在反串 rev 中,位置 l..r 对应原串的 r..l 倒序。所以 s[l..r] 是回文 ⟺ hash_s(l, r) == hash_rev(n−r+1, n−l+1)。

为什么用双哈希:q = 10⁵ 次询问,每次都要比较,碰撞概率被放大。双哈希能压到可忽略。

C++回文子串判定 — 完整代码
#include <bits/stdc++.h>
using namespace std;
typedef unsigned long long ull;
const int MAXN = 100005;
const ull B = 131;
ull pre[MAXN], rpre[MAXN], pw[MAXN];
string s, rev;

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    cin >> s;
    int n = s.size();
    rev = s; reverse(rev.begin(), rev.end());
    pw[0] = 1;
    for (int i = 1; i <= n; i++) {
        pw[i]   = pw[i-1] * B;
        pre[i]  = pre[i-1]  * B + (unsigned char)s[i-1];
        rpre[i] = rpre[i-1] * B + (unsigned char)rev[i-1];
    }
    auto get = [&](ull pre[], int l, int r) -> ull {
        return pre[r] - pre[l-1] * pw[r - l + 1];
    };
    int q; cin >> q;
    while (q--) {
        int l, r; cin >> l >> r;
        // 原串 [l,r] ↔ 反串 [n-r+1, n-l+1]
        cout << (get(pre, l, r) == get(rpre, n - r + 1, n - l + 1) ? "Yes" : "No") << "\n";
    }
    return 0;
}
运行结果
输入: abacaba / 3 / 1 7 / 1 3 / 2 5
s   = a b a c a b a
rev = a b a c a b a
查询 1 7:(1,7) vs (1,7) → Yes
查询 1 3:"aba" vs "aba" → Yes
查询 2 5:"baca" 与反串 "acab" → No

💡 与 Manacher 的关系

Manacher 算法能在 O(n) 内求出所有回文子串的信息,比哈希 + 二分更快。但如果只需要「判断某段是不是回文」,哈希方案代码量更小、更好写。这是典型的「权衡」:Manacher 快但难写,哈希慢一点但好写。

题3循环同构判定
题 3哈希应用

给定字符串 A、B(长度相同,均为 n),判断 B 是否是 A 的某个循环移位。|A|, |B| ≤ 10⁶。

输入格式:两行,分别是 A 和 B。

输出格式:Yes 或 No。

输入样例:

输入
abcde
cdeab

输出样例:

输出
Yes

数据范围:1s / 256MB。

🔍 题目分析

方法一(枚举移位 + 比较)O(n²) —— 枚举 n 种移位,每种比较 O(n),n = 10⁶ 时超时。

方法二(构造双串 + 哈希)正解 —— 令 T = A + A,则 A 的所有循环移位都是 T 中长度为 n 的连续子串。只需在 T 上滚动比较每个长度 n 的子串哈希是否等于 H(B)。O(n)。

为什么不直接用 find —— string::find 的朴素实现是 O(nm),虽然 GCC 优化后用 Two-Way 算法可做到 O(n),但自己用哈希更可控、更通用。

C++循环同构判定 — 完整代码
#include <bits/stdc++.h>
using namespace std;
typedef unsigned long long ull;
const int MAXN = 2000005;
const ull B = 131;
ull pre[MAXN], pw[MAXN];

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    string A, Bs; cin >> A >> Bs;
    int n = A.size();
    if ((int)Bs.size() != n) { cout << "No" << endl; return 0; }
    string T = A + A;                 // 双串长度 2n
    pw[0] = 1;
    for (int i = 1; i <= 2 * n; i++) {
        pw[i]  = pw[i-1] * B;
        pre[i] = pre[i-1] * B + (unsigned char)T[i-1];
    }
    ull hb = 0;
    for (unsigned char c : Bs) hb = hb * B + c;
    for (int i = 1; i + n - 1 <= n + n; i++) {
        ull cur = pre[i + n - 1] - pre[i - 1] * pw[n];
        if (cur == hb) { cout << "Yes" << endl; return 0; }
    }
    cout << "No" << endl;
    return 0;
}
运行结果
输入: abcde / cdeab
T = "abcdeabcde"
扫描长度 5 的子串:abcde, bcdea, cdeab ← 命中
输出: Yes

输入: abcde / abced
输出: No
模块 04

总结提升

①核心要点与万能模板
要点内容备注
递推公式h = h·B + s[i]从头到尾一遍,O(n)
参数选择B = 131 / 13331,M = 1e9+7 等大质数B 与 M 必须互质
子串哈希pre[r] − pre[l−1]·B^(r−l+1)需预处理幂数组
双哈希两组 (B, M) 同时算,都要相等才判等冲突概率约 1/(M₁M₂)
自然溢出unsigned long long 自动 mod 2⁶⁴快但可能被卡
冲突处理开放寻址 / 链地址 / 再哈希 / 公共溢出区竞赛首选链地址

万能模板:双哈希前缀哈希(可直接背诵)

C++双哈希前缀模板
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 1000005;
const long long B1 = 131,   M1 = 1000000007LL;
const long long B2 = 13331, M2 = 1000000009LL;
long long p1[MAXN], p2[MAXN], h1[MAXN], h2[MAXN];

void build(const string& s) {
    int n = s.size();
    p1[0] = p2[0] = 1;
    for (int i = 1; i <= n; i++) {
        p1[i] = p1[i-1] * B1 % M1;
        p2[i] = p2[i-1] * B2 % M2;
        h1[i] = (h1[i-1] * B1 + (unsigned char)s[i-1]) % M1;
        h2[i] = (h2[i-1] * B2 + (unsigned char)s[i-1]) % M2;
    }
}

pair<long long,long long> sub(int l, int r) {
    long long v1 = ((h1[r] - h1[l-1] * p1[r-l+1]) % M1 + M1) % M1;
    long long v2 = ((h2[r] - h2[l-1] * p2[r-l+1]) % M2 + M2) % M2;
    return {v1, v2};
}
②拓展案例

拓展1:最长重复子串(至少出现 k 次)

💡 思路:二分长度 L,用哈希统计每个长度 L 的子串出现次数,若存在某个子串出现 ≥ k 次,则 L 可行。

复杂度:O(n log n)(哈希表统计时用 sort 或 unordered_map)。

C++至少出现 k 次的最长子串
bool check(int len, int k) {
    if (len == 0) return true;
    vector<ull> v;
    for (int i = 1; i + len - 1 <= n; i++)
        v.push_back(pre[i+len-1] - pre[i-1] * pw[len]);
    sort(v.begin(), v.end());              // 排序后相同哈希相邻
    int run = 1;
    for (int i = 1; i < (int)v.size(); i++) {
        if (v[i] == v[i-1]) { if (++run >= k) return true; }
        else run = 1;
    }
    return false;
}
运行结果
输入: s = "banana", k = 2
长度 3:"ban","ana","nan","ana" → "ana" 出现 2 次
输出: 3

拓展2:哈希 + 线段树维护子串哈希

💡 场景:若字符串会被修改(单点改字符),前缀哈希就失效了(修改一点会影响后面所有前缀)。此时需要线段树维护区间哈希。

合并公式:对节点 [l, r],若左子区间哈希为 hL、右子区间为 hR、左区间长度为 lenL,则 h = hL · B^lenL + hR。

复杂度:单点修改 O(log n),区间查询 O(log n)。

C++线段树维护区间哈希(合并部分)
struct Node { ull h; int len; };
Node merge(const Node& L, const Node& R) {
    return { L.h * pw[R.len] + R.h, L.len + R.len };
}
// 单点修改:把叶子节点的字符更新,再一路向上 merge
// 区间查询:合并路径上所有被覆盖的节点(注意按左右顺序合并)
运行结果
s = "abcde"
把 s[3] 从 'c' 改成 'x' 后:
区间 [1,3] 哈希 = H("abx")
区间 [2,5] 哈希 = H("bxde")
每次修改与查询都是 O(log n)

拓展3:链地址法的完整实现(含删除)

💡 要点:删除时需要先找到节点,再摘链。用「指针的指针」或经典的「前驱 + 当前」双指针写法。

C++链地址法的删除操作
bool eraseVal(long long x) {
    int h = hashi(x);
    int prev = 0;                      // 前驱节点编号,0 表示表头
    for (int i = head[h]; i; i = nxt[i]) {
        if (val[i] == x) {
            if (prev == 0) head[h] = nxt[i];   // 删的是表头
            else nxt[prev] = nxt[i];            // 跳过当前节点
            return true;
        }
        prev = i;
    }
    return false;
}
运行结果
桶 4 的链:17 → 30 → 69
eraseVal(30) 后:17 → 69   ✓ 链完整
③思考与启发

思考1:为什么子串哈希公式里要「乘以 B^(r−l+1)」而不是别的次幂?

查看参考解答

关键是对齐位权。pre[r] 表示 s[1..r] 的哈希,其中 s[1] 的位权是 B^(r−1);pre[l−1] 表示 s[1..l−1] 的哈希,其中 s[1] 的位权是 B^(l−2)。

要让 pre[l−1] 的高位与 pre[r] 的高位对齐,必须把 pre[l−1] 整体「抬高」,抬高的级数 = r − (l − 1) = r − l + 1。所以乘以 B^(r−l+1)。

验证:相减后,s[1..l−1] 的贡献变成 哈希值 × B^(r−l+1) − 哈希值 × B^(r−l+1) = 0,完美抵消;剩下的正好是 s[l..r] 的贡献 × B^0 展开。

思考2:自然溢出为什么会被「构造数据卡掉」?

查看参考解答

自然溢出相当于模数 M = 2⁶⁴。由于 2⁶⁴ 是合数且具有特殊结构,存在已知的字符串对,它们的哈希值在 mod 2⁶⁴ 下必然相等。

典型构造是 Thue-Morse 序列:可以用同样的 B 生成两个长度不同(或相同)的字符串,使它们的哈希值在 2 的幂模数下完全一致。

此外,若 B 取偶数,则 B^k 在 k ≥ 64 时会被 2⁶⁴ 完全「吃掉」(变成 0),导致长字符串的高位全部失效 —— 这是更直接的漏洞。

防范:① 取 B 为奇数(如 131、13331);② 用大质数模数;③ 用双哈希。

思考3:哈希能不能完全替代字符串算法?

查看参考解答

不能。哈希是概率算法,存在极小的错误概率;而 KMP、Manacher、后缀数组都是严格正确的算法。

更重要的是,很多问题哈希结构上做不到:求 next 数组、求最小表示、求本质不同子串的字典序排名等,都需要真正的字符串结构知识。

正确的定位:哈希是「快速比较工具」,不是「字符串理论」。它能把很多 O(n²) 的暴力优化成 O(n log n),但不能替代基于字符串结构的线性算法。

考场上:能用哈希拿到 80 分就先拿,再想正解。这是最务实的策略。

模块 05

学习检测

哈希的坑集中在「溢出」「负下标」「次幂方向」三处,逐条排查。

⚡高频错误诊断
易错1

子串哈希减出负数不修正

❌ 错误
用 long long 取模,写成 h[r] - h[l-1]*p[len] 直接返回,结果可能为负,与另一个负值比较时误判相等。
✅ 正确
写成 ((v % M) + M) % M 归一;或者直接用 unsigned long long(自然溢出,减法结果自动回绕)。
口诀:取模减法要归一,先模再加再模一次!
易错2

进制定得太小

❌ 错误
取 B = 26(字母个数),遇到数字、大写字母时字符码超出 B,导致不同字符串哈希相同。
✅ 正确
B 必须大于字符集的最大编码。用 ASCII 时取 B ≥ 128,常用 131 或 13331。
口诀:进制要大于字符集,131 用起来最安心!
易错3

B 取偶数导致高位失效

❌ 错误
取 B = 128 或 256 等偶数。在 mod 2⁶⁴ 下,B^64 已经完全被 2⁶⁴ 整除变成 0,字符串前 64 位的贡献全部丢失。
✅ 正确
B 取奇数质数,如 131、13331。这样 B^k 永远不会被 2⁶⁴ 整除,高位贡献始终有效。
口诀:自然溢出配奇数,偶数进制高位置零!
易错4

幂数组下标与子串长度对不上

❌ 错误
写成 pre[r] - pre[l-1] * pw[r-l],次幂少了一位,得到的是错误的哈希。
✅ 正确
子串长度 = r − l + 1,次幂必须用 pw[r - l + 1]。可以用「长度为 1 时 l == r,pw[1]」来自检。
口诀:区间长度减加一,幂次就是这个数!
易错5

负下标字符串直接用哈希

❌ 错误
用 s[i] 直接参与哈希计算,遇到带符号的 char(值可能是负的)时 B 进制数被污染。
✅ 正确
统一转成 (unsigned char)s[i],保证每个字符贡献 [0, 255] 的非负值。
口诀:字符先转无符号,负 char 会让哈希崩!
📋速查表
操作公式 / 写法复杂度
单串哈希h = h*B + s[i]O(n)
前缀哈希pre[i] = pre[i-1]*B + s[i]O(n)
幂数组pw[i] = pw[i-1]*BO(n)
子串哈希pre[r] - pre[l-1]*pw[r-l+1]O(1)
双哈希判定两组 (B,M) 均相等O(1)
链地址插入nxt[++cnt] = head[h]; head[h] = cnt;平均 O(1)
开放寻址查找沿探测序列找,遇 EMPTY 停平均 O(1)
📌笔记卡
必记概念
  • h = h × B + s[i]
  • 子串 = pre[r] − pre[l−1]·B^(r−l+1)
  • B 取奇数质数,M 取大质数
  • 双哈希 ≈ 10¹⁸ 级安全性
必背模板
  • 前缀哈希 + 幂数组预处理
  • 子串哈希 O(1) 函数
  • 双哈希 pair 判等
  • 链地址哈希表(含删除)
考场提醒
  • 取模减法必须归一
  • 字符先转 unsigned char
  • 次幂用 r−l+1
  • 被卡就上双哈希
课后练习

分层练习作业

A基础 / B提高 / C挑战,难度递增。C组后附 12 道洛谷原题。

🟢 A组 · 基础(4题)

A1概念题

判断:① 字符串哈希是严格正确的算法;② 双哈希的冲突概率约是单哈希的平方;③ 自然溢出等价于对 2⁶⁴ 取模。

查看参考答案

① 错误。字符串哈希是概率正确的,存在极小概率的冲突。KMP、Manacher 等才是严格正确的。

② 正确。两组独立哈希同时碰撞的概率约为 (1/M₁)×(1/M₂),相比单哈希的 1/M₁ 下降了 M₂ 倍。

③ 正确。unsigned long long 运算溢出时按模 2⁶⁴ 回绕,与显式 % (1ULL << 64) 等价。

A2编程题

输入一个字符串,输出它的整体哈希值(自然溢出,B = 131)。

查看参考答案
#include <bits/stdc++.h>
using namespace std;
typedef unsigned long long ull;
int main() {
    string s; cin >> s;
    ull h = 0;
    for (unsigned char c : s) h = h * 131 + c;
    cout << h << endl;
}

思路:递推式一行搞定,字符必须转 unsigned char。

易错:用 int 存 h 会提前溢出且符号混乱;忘记转 unsigned char。

A3编程题

给定字符串 s 和区间 [l, r],输出该子串的哈希值。

查看参考答案
// 预处理 pre[] 与 pw[] 后
ull getHash(int l, int r) {
    return pre[r] - pre[l-1] * pw[r - l + 1];
}

思路:「后段减去前段乘以次幂」,次幂 = 子串长度 = r − l + 1。

易错:次幂写成 r - l;数组下标从 0 开始却按 1-based 写公式。

A4选择题

用哈希判断两个长度为 10⁵ 的子串是否相同,与直接逐字符比较相比,单次比较的复杂度从( )降到( )。

查看参考答案

答案:O(L) 降到 O(1)(L 为子串长度,预处理后)。

说明:逐字符比较需要访问每一个字符,O(L);哈希方案在预处理 O(n) 之后,每次比较只做两次子串哈希计算和一次整数比较,O(1)。

前提:必须已经完成前缀哈希与幂数组的预处理,否则单次比较仍需 O(L) 计算哈希。

🟡 B组 · 提高(4题)

B1哈希实现

给定 n 个字符串,求不同字符串的个数(n ≤ 10⁴,总长 ≤ 10⁶)。

查看参考答案

思路:每个字符串算一个哈希值,放进 unordered_set,输出集合大小。为稳妥起见用双哈希。

// 双哈希版本
pair<ull,ull> hashStr(const string& s) {
    ull h1 = 0, h2 = 0;
    for (unsigned char c : s) {
        h1 = h1 * 131 + c;
        h2 = h2 * 13331 + c;
    }
    return {h1, h2};
}
set<pair<ull,ull>> S;

易错:用 unordered_set<pair<...>> 需要自定义哈希函数,直接用 set 更省事(O(log n) 也能过)。

B2哈希应用

判断两个长度均为 n 的字符串是否循环同构(n ≤ 10⁶)。

查看参考答案

思路:构造 T = A + A,在 T 上滑动长度为 n 的窗口,比较窗口哈希与 H(B)。

string T = A + A;
ull hb = 0;
for (unsigned char c : B) hb = hb * 131 + c;
for (int i = 1; i + n - 1 <= 2 * n; i++)
    if (sub(T, i, i + n - 1) == hb) { cout << "Yes"; return 0; }

易错:双串数组要开 2n;若 A 长度与 B 不同应直接判否。

B3二分 + 哈希

求两个字符串的最长公共子串长度。

查看参考答案

思路:二分长度 L,判定「是否存在长度为 L 的公共子串」:把 A 的所有长度 L 子串哈希放进集合,再扫 B。

bool check(int L) {
    unordered_set<ull> S;
    for (int i = 1; i + L - 1 <= nA; i++) S.insert(sub(preA, i, i+L-1));
    for (int i = 1; i + L - 1 <= nB; i++)
        if (S.count(sub(preB, i, i+L-1))) return true;
    return false;
}

单调性依据:若存在长度 L 的公共子串,取它的前 L−1 个字符就是长度 L−1 的公共子串。

易错:二分边界处理不当(L = 0 时必须返回 true);哈希表忘记清空。

B4哈希表设计

用链地址法实现一个支持插入、删除、查询的整数集合,值域 |x| ≤ 10¹⁸。

查看参考答案
const int M = 1000003;
int head[M], nxt[200005], cnt;
long long val[200005];
int hashi(long long x) { return (int)((x % M + M) % M); }

void insertVal(long long x) {
    int h = hashi(x);
    for (int i = head[h]; i; i = nxt[i]) if (val[i] == x) return;
    val[++cnt] = x; nxt[cnt] = head[h]; head[h] = cnt;
}
bool findVal(long long x) {
    for (int i = head[hashi(x)]; i; i = nxt[i]) if (val[i] == x) return true;
    return false;
}
bool eraseVal(long long x) {
    int h = hashi(x), prev = 0;
    for (int i = head[h]; i; i = nxt[i]) {
        if (val[i] == x) { prev ? nxt[prev] = nxt[i] : head[h] = nxt[i]; return true; }
        prev = i;
    }
    return false;
}

易错:删除时忘记处理「删的正好是表头」的情况;哈希函数没做负数归一。

🔴 C组 · 挑战(4题)

C1理论

证明:若两个长度为 L 的字符串哈希相同,则它们的「哈希差」是 M 的倍数(模数哈希情形)。

查看参考答案

设定:设两串为 s、t,长度均为 L。它们的多项式展开分别为 Σ sᵢB^(L−i) 与 Σ tᵢB^(L−i)。

证明:哈希相同意味着 Σ sᵢB^(L−i) ≡ Σ tᵢB^(L−i) (mod M)。移项得 Σ (sᵢ − tᵢ)B^(L−i) ≡ 0 (mod M)。

令 dᵢ = sᵢ − tᵢ(不全为 0),则 M 整除 Σ dᵢB^(L−i)。这就是「哈希冲突」的代数刻画。

推论:冲突对应于「多项式 P(x) = Σ dᵢx^(L−i) 在 x = B 处对 M 取模为 0」。要构造冲突,就是找这样一个非零多项式与 B、M 的取值。这就是为什么「B 和 M 都不能被人猜到」以及「双哈希有效」的原因 —— 两组参数同时满足的概率极低。

C2综合

给定字符串 s,求本质不同的子串个数(|s| ≤ 10⁵)。

查看参考答案

方法一(哈希 + set):枚举所有子串 O(n²) 个,各自算哈希放入 set,复杂度 O(n² log n)。n = 10⁵ 时不可行。

方法二(哈希 + 二分 + 排序)较优:对每个起始位置 i,所有以 i 开头的子串哈希是单调的(长度越长越不可能重复)。可以二分「最长的新增长度」……但这个方法实际上并不严谨。

方法三(后缀数组 + height)正解:不同子串数 = n(n+1)/2 − Σ height[i]。复杂度 O(n log n)。这是标准做法。

// 后缀数组(倍增版)求 height 后:
long long ans = 1LL * n * (n + 1) / 2;
for (int i = 2; i <= n; i++) ans -= height[i];
cout << ans << endl;

直觉:所有后缀的子串总数是 n(n+1)/2;每个后缀与「排名前一位的后缀」的公共前缀部分是被重复计数的,减去 height 即得本质不同数。

易错:以为哈希能在 O(n log n) 内解决 —— 哈希只是「比较工具」,去重仍需借助后缀数组或后缀自动机的结构。

C3困难

设计一个「防卡」的哈希方案,使得即使出题人知道你的代码,也无法构造数据让你的哈希冲突。

查看参考答案

方案一:随机化参数。在程序运行时用 chrono 或 mt19937 生成随机的 B 和 M(M 需为随机大质数)。出题人无法预知你的参数,因此无法构造针对性数据。

常见做法:B = mt19937_64(chrono::steady_clock::now().time_since_epoch().count())() % 100000 + 131,并强制为奇数。

方案二:双哈希 + 不同参数。两组参数独立随机,碰撞概率约为 1/(M₁M₂) ≈ 10⁻¹⁸。

方案三:换严格算法。若题目对正确性要求 100%,直接用 KMP / 后缀数组 / Z 函数替代哈希。

注意:随机化哈希有一个隐患 —— 随机种子若基于时间且评测机时间被固定,可能被预测。更安全的做法是结合 std::random_device。

C4初赛真题风格

哈希表长 M = 13,H(k) = k mod 13,链地址法。依次插入 {15, 28, 41, 15, 7, 20, 33}。画出最终结构,并说明查询 15 需要比较几次。

查看参考答案

逐个计算:

· 15 % 13 = 2 → 桶 2:{15}

· 28 % 13 = 2 → 桶 2:{15, 28}

· 41 % 13 = 2 → 桶 2:{15, 28, 41}

· 15 % 13 = 2 → 已存在,不重复插入(链地址法通常去重)

· 7 % 13 = 7 → 桶 7:{7}

· 20 % 13 = 7 → 桶 7:{7, 20}

· 33 % 13 = 7 → 桶 7:{7, 20, 33}

最终结构:桶 2 = [15, 28, 41],桶 7 = [7, 20, 33],其余为空。

查询 15:若用头插法,链为 [41, 28, 15],需要比较 3 次;若用尾插法,链为 [15, 28, 41],只需 1 次。所以答案取决于插入策略,尾插法为 1 次,头插法为 3 次。

易错:忽视「重复元素是否插入」的策略差异;或忘记链地址法的链长决定比较次数。

🏆 洛谷原题精选(12道)

以下 12 道题覆盖字符串哈希模板、哈希 + 二分、哈希与其他算法的结合,按难度从低到高排列。

1
P3370
【模板】字符串哈希普及+
2
P3879
[TJOI2010] 阅读理解普及+
3
P4391
[BOI2009] Radio Transmission 无线传输普及+
4
P3375
【模板】KMP字符串匹配普及+
5
P1368
【模板】最小表示法提高
6
P5410
【模板】扩展 KMP(Z 函数)提高
7
P4824
[USACO15FEB] Censoring G提高
8
P3763
[TJOI2017] DNA(哈希 + 枚举)提高
9
P2852
[USACO06DEC] Milk Patterns G提高
10
P3667
[USACO17OPEN] Bovine Genomics G提高
11
P3809
【模板】后缀排序提高
12
P2408
不同子串个数提高