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

数值哈希函数构造与 冲突处理入门

把「查询」从 O(log n) 甚至 O(n) 压到 O(1),靠的不是魔法,而是一个取模运算和一条链表 —— 这是「空间换时间」最经典的一次胜利。

4 种哈希构造法 3 个完整案例 2 处交互动画 6 大模块 12 道洛谷原题
本讲 · 第 21/52 讲数值哈希函数构造
模块 01

情景导航

先弄明白一件事:哈希为什么能「一步定位」?答案藏在一个很朴素的比喻里。

故事图书馆的借书卡

学校图书馆要录入 10 万本图书的编号。管理员小林遇到了两只拦路虎。

第一个问题:找书太慢。如果所有书都堆在书架上,要找「编号 20250315」这本书,只能一本一本翻,平均要翻 5 万次。小林想:能不能一进门就知道它在第几层?

第二个问题:编号不连续。有的书编号是 1,有的书编号是 987654321。如果按编号直接排位置,中间会出现巨大的空档,书架根本不够摆。

老图书馆员递给他一张纸,上面写着一个办法:「把编号除以 9973 取余数,余数就是书架的层号。」

小林一试:编号 20250315 ÷ 9973 余 3471,那就放到第 3471 层。下次找书时再算一次余数,直接奔第 3471 层,一步到位!

但很快他又发现问题:编号 3471 和编号 9973 + 3471 = 13444 算出来的余数都是 3471,它们要抢同一个位置。这就是「哈希冲突」。

哈希 = 用一个函数把「巨大的键空间」压进「有限的地址空间」;冲突不可避免,只能被管理。

💡 故事背后的算法思想

哈希的本质是「空间换时间」:多开一个表(空间),换来 O(1) 的查找(时间)。这是竞赛里最划算的交易之一。

哈希函数的关键:它必须是「确定性」的 —— 同一个键永远算出同一个地址;同时要尽可能「均匀」,让不同的键尽量分散到不同的桶里。

冲突为什么不可避免:键空间是无穷(或极大)的,而地址空间是有限的。由抽屉原理,一定存在两个不同的键映射到同一个地址。所以真正的工程问题不是「消除冲突」,而是「高效处理冲突」。

本讲的重点:数值哈希函数怎么设计(直接定址、除留余数、乘法散列、平方取中),以及如何用桶 / 链地址法把冲突管理好。字符串版本的哈希留到下一讲 s4-04。

①生活类比
📮

快递柜 = 桶数组

凭手机号后四位开柜门,不用逐个试,直接定位到对应格子。

→ H(x) = x 的某几位
📚

书架分层 = 除留余数

书号除以固定数取余得到层号,把大编号压进小范围。

→ H(x) = x % p
🎫

候诊叫号 = 冲突排队

同一个诊室来了两位病人,只能排队等 —— 这就是链地址法。

→ 同桶挂链

🏫

按姓氏分班 = 数字分析

不按全名排座,只取姓氏首字母分区,简单又能均匀分散。

→ 抽取部分数字
②核心定义

哈希(Hash / 散列):通过一个函数 H,把关键字 key 映射到一个较小的、固定的地址区间 [0, M),使得查找可以近似 O(1) 完成。

哈希函数 (Hash Function):H: Key → [0, M)。要求计算快、分布均匀、确定性。

哈希冲突 (Collision):若 key₁ ≠ key₂ 但 H(key₁) = H(key₂),称发生冲突。冲突一定存在,无法根除。

散列表 / 哈希表 (Hash Table):存放数据并支持按 H(key) 快速访问的结构。其查找复杂度平均 O(1),最坏 O(n)。

负载因子 (Load Factor):α = 已存元素个数 / 表长 M。α 越接近 1,冲突越频繁。一般建议控制在 0.75 以下。

📌 本讲核心知识地图

板块核心内容对应大纲
直接定址法H(x) = x,值域小直接开桶数值哈希函数构造【5】
除留余数法H(x) = x mod p,p 取大质数数值哈希函数构造【5】
乘法散列 / 平方取中H(x) = ⌊M·frac(kx)⌋ 等数值哈希函数构造【5】
数字分析抽取分布均匀的数位哈希函数设计原则
冲突处理开放寻址 / 链地址(详见 s4-04)为下一讲铺垫
③核心口诀
🔑 哈希总纲

值域小就直接开桶,值域大就对质数取模;冲突不可避免,链上挂着就好。

🔑 模数选择

模数取大质数(如 1e9+7、100003、99991),远离 2 的幂与常见因子,分布最均匀。

🔑 复杂度

哈希表平均 O(1),最坏 O(n);排序 + 二分 O(log n);std::map O(log n)。要常数小就手写哈希。

模块 02

知识探究

四种经典哈希构造法 + 一次落位动画 + 三个由浅入深的案例。

1哈希思想:空间换时间 核心

假设有 n 个元素需要支持「判断某个数是否存在」这个操作。三种典型方案:

1
🔍
线性查找

逐个比较,O(n)。n = 1e5、q = 1e5 时共 1e10 次,必超时。

2
🔎
排序 + 二分

预处理 O(n log n),每次查询 O(log n)。适合静态数据。

3
⚡
哈希表

平均 O(1) 插入与查询。适合动态且查询极多的场景。

STEP 1取关键字 key
STEP 2计算地址
STEP 3查该地址
STEP 4冲突处理
STEP 5完成操作
鼠标悬停节点查看说明
✅ 优势

平均 O(1) 插入 / 删除 / 查询;不受元素范围限制;实现轻量,常数小。

⚠️ 代价

失去顺序性(不能求前驱后继、不能区间查询);需要额外空间;最坏情况退化到 O(n);哈希值可被针对性构造攻击(竞赛少见)。

2直接定址法:值域小就开桶 最简

定义:H(key) = a · key + b(a、b 为常数),最常用的形式就是 H(key) = key。桶数组 cnt[key] 直接以键值作下标。

📐 适用条件与代价

适用:值域 ≤ 1e7 且非负、整数、分布不太稀疏。例如「统计 1~100000 中每个数出现的次数」。

代价:空间 = 值域大小。若值域是 10⁹,直接开数组需要 4 GB,不可行。

优点:O(1) 且常数极小,无需冲突处理,代码最短。

坑点:值域含负数时,需要加偏移量,如 cnt[x + 1000000]。

C++直接定址的两种常见写法
// 写法一:布尔桶 —— 判断某个数是否出现过
const int MAXV = 10000005;
bool exist[MAXV];
exist[x] = true;                  // O(1) 标记
if (exist[y]) { /* ... */ }          // O(1) 查询

// 写法二:计数桶 —— 统计每个值出现次数
int cnt[MAXV];
cnt[x]++;                          // O(1) 累加
cout << cnt[y] << endl;              // O(1) 取值

// 值域含负数时加偏移
const int OFF = 1000000;
int cnt2[MAXV];
cnt2[x + OFF]++;
1:0
2:1
3:0
4:2
5:1
6:0
7:3
8:0
下标 = 键值格内数字 = 出现次数
3除留余数法:模大质数 主力

定义:H(key) = key mod p,其中 p 通常取一个不大于表长 M 的质数。这是竞赛中最常用的数值哈希函数。

📐 为什么一定要取质数?

假设 p = 12(合数)。键值 12、24、36、48 都映射到 0 号桶,因为它们都能被 12 整除 —— 只要键值本身带有因子 2、3、4、6,就会与 p 的因子产生「共振」,导致严重聚集。

若 p = 13(质数),键值只有恰好是 13 的倍数时才会撞到 0 号桶,分布显著均匀。这就是「质数模数」的经验依据。

常见好模数:100003、99991、19260817、998244353、1e9+7、1e9+9。回避 2^k、10^k 这类「有大量因子的数」。

表长建议:M 取 2~3 倍的元素数量,负载因子 α 控制在 1/3 到 1/2,冲突概率最低。

▶ 除留余数法落位过程(p = 13)
1插入 26:26 % 13 = 0 → 落到 0 号桶。此时桶空,直接放入。
2插入 39:39 % 13 = 0 → 也落到 0 号桶。冲突!需要冲突处理策略。
3插入 7:7 % 13 = 7 → 落到 7 号桶,空位,直接放入。
4插入 20:20 % 13 = 7 → 又撞到 7 号桶,第二次冲突。
5插入 100:100 % 13 = 9 → 落到 9 号桶,直接放入。
6最终分布:桶 0 有 {26, 39},桶 7 有 {7, 20},桶 9 有 {100}。冲突通过「同桶挂链」消化掉。
观察:只有当两个键值之差是 p 的倍数时才会冲突。p 取质数能把这种「倍数关系」的出现频率降到最低。
C++除留余数法 + 线性探测(开放寻址预览)
const int M = 100003;   // 表长取质数
const int EMPTY = -1;
long long key[M];

// 插入:找第一个空位(线性探测)
void insert(long long k) {
    int h = (k % M + M) % M;      // 处理负数取模
    while (key[h] != EMPTY && key[h] != k)
        h = (h + 1) % M;               // 往后找一格
    key[h] = k;
}

// 查询:沿同样的探测路径找
bool find(long long k) {
    int h = (k % M + M) % M;
    while (key[h] != EMPTY) {
        if (key[h] == k) return true;
        h = (h + 1) % M;
    }
    return false;
}
注意:(k % M + M) % M 是处理负数取模的标准写法。C++ 中 −5 % 3 的结果是 −2(不是 1),直接用作下标会越界。这个坑在洛谷上让无数人 RE。
4乘法散列与平方取中 拓展
✖️ 乘法散列 (Multiplicative)
  • 公式:H(k) = ⌊M · ((k · A) mod 1)⌋
  • A 常取黄金比 0.6180339887…
  • 乘法对小变化的键值放大后取小数部分,分布均匀
  • 不受 M 是否为质数影响,M 可取 2 的幂,便于用位运算加速
  • 实战中用得少,但理念常见于 STL 的 hash<double>
🔢 平方取中法 (Mid-Square)
  • 做法:把键值平方,取中间若干位作为地址
  • 例:k = 4321,k² = 18671041,取中间 3 位 671
  • 平方后中间位的值受原数所有数位影响,分散性好
  • 适合位数不太多的整数键
  • 缺点是当键值很大时需要高精度乘法
▶ 平方取中法演示(k = 4321)
1输入键值 k = 4321,它有 4 位数字。
2求平方:4321² = 18671041,得到 8 位中间结果。
38 位结果记为 1 8 6 7 | 1 0 4 1,取正中间的 4 位 6710。
4把 6710 对表长取模(如 % 1000 → 710),即得最终地址。
5换个键验证:k = 4322 时 k² = 18679684,取中间 4 位 6796 —— 与 4321 的结果差别明显,说明平方把「小差异」放大了。
6观察:中间位由原数的所有数位共同贡献,即使键值高位相同(如学号前缀),中间位也能拉开差距。
平方取中的代价:键值大时平方会溢出/需要高精度,所以竞赛里更多用除留余数法。

📐 数字分析法 (Digit Analysis)

思路:当所有键值都是已知的、位数固定的整数时,可以统计每一位的分布,挑出分布最均匀的几位组合成地址。

例子:一批学生学号类似 2025030101、2025030102……前 6 位(年份月份)完全相同,用它们作地址会造成严重聚集;而后 3 位(流水号)分布均匀,用它最合适。

实战意义:这就是「为什么桶数组的下标要尽量取值的低位」的直觉来源。不过竞赛中更常用除留余数法,因为它不需要预先知道全部数据。

5三种方案对比 选型
方案查询复杂度预处理空间适用场景
暴力线性查找O(n)无O(n)n ≤ 1000 的小数据
排序 + 二分O(log n)O(n log n)O(n)静态数据、需要顺序性
std::map / setO(log n)O(n log n)O(n)需要前驱后继、区间查询
std::unordered_map平均 O(1)O(n)O(n)只需判存在/计数,不卡常数
手写哈希表平均 O(1)O(n)O(M)常数敏感、需严格掌控性能
直接定址桶O(1)O(值域)O(值域)值域 ≤ 1e7 且整数
🔑 选型口诀

值域小 → 直接开桶;要顺序 → 排序二分;要快且无顺序需求 → 哈希表;卡常数 → 手写。

💡 为什么 CSP-S 里常推荐「手写哈希表」?

std::unordered_map 的常数较大(每次操作有哈希函数调用 + 链表/桶管理开销),在 10⁶ 量级的操作中可能比手写慢 2~3 倍。

而且 unordered_map 的默认哈希对某些关键字的分布不理想,容易被构造数据卡到 O(n)。

结论:能用桶就用桶;不能用桶的地方(值域大),优先考虑手写「除留余数 + 链地址」;只有在代码简洁性优先时才用 unordered_map。

案例案例1:值域小,直接开桶判重 入门

题目:给定 n 个整数(1 ≤ n ≤ 2×10⁵,0 ≤ a[i] ≤ 10⁶),输出去重后从小到大排序的结果。

🔍 详细分析过程

第1步:识别模型 —— 「去重 + 排序 + 值域小」是桶思想的经典信号。值域 10⁶ 完全放得下 int 数组(4 MB)。

第2步:标记 —— 读入每个数 x,令 exist[x] = true。重复出现时会被自动忽略,天然完成去重。

第3步:输出 —— 从 0 到 10⁶ 扫描一遍,输出所有 exist[i] 为真的 i,天然完成排序。

第4步:复杂度 —— 标记 O(n),扫描 O(值域)。总计 O(n + V)。注意最后输出个数不确定,需要用单独的计数器统计,不要在多解之间加多余空格。

C++桶判重 + 排序 — 完整代码
#include <bits/stdc++.h>
using namespace std;
const int MAXV = 1000001;
bool exist[MAXV];

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    int n; cin >> n;
    for (int i = 0; i < n; i++) {
        int x; cin >> x;
        exist[x] = true;             // O(1) 标记,重复自动忽略
    }
    int cnt = 0;
    for (int v = 0; v < MAXV; v++)
        if (exist[v]) cnt++;
    cout << cnt << "\n";
    for (int v = 0; v < MAXV; v++) {
        if (!exist[v]) continue;
        cout << v;
        if (--cnt) cout << ' ';              // 最后不输出尾空格
    }
    cout << endl;
    return 0;
}
运行结果
输入: 10
      20 40 32 67 40 20 89 300 400 15
输出: 8
      15 20 32 40 67 89 300 400
🔍 代码逐段解析 · 桶判重 + 排序
1const int MAXV = 1000001:值域上限 10⁶,数组必须开到 1000001,否则 x = 10⁶ 时越界。
2读入循环(第 7~10 行):每个 x 执行 exist[x] = true。bool 桶只记「有没有」,天然去重。
3第一遍扫描(第 12~15 行):数出不同值的个数 cnt,先输出 —— 满足「第一行是种数」的输出格式。
4第二遍扫描(第 16~20 行):v 从小到大枚举,命中就输出 —— 桶下标天然有序,无需 sort。
5if (--cnt) cout << ' ':用递减计数器控制「末尾不带空格」,cnt 减到 0 时不再输出空格。
6易错点:① 数组开 1000000 少一位;② 若值域含 0,第二遍扫描必须从 0 开始而不是 1。
标记 O(n),两遍扫描 O(V);总复杂度 O(n + V),比排序的 O(n log n) 在值域小的时候更快。

💡 桶思想为什么这么快?

因为它把「比较」换成了「地址计算」。排序需要 O(n log n) 次比较,而桶只需要把数放到它值对应的格子里 —— 放的动作和取的动作都是 O(1)。

代价:必须遍历整个值域。若值域是 10⁹ 而 n 只有 100,桶就完全不可用了,这时应该用「排序 + 去重」或「哈希表」。

案例案例2:手写哈希表(除留余数 + 链地址) 核心

题目:维护一个整数集合,支持 m 次操作(1 ≤ m ≤ 10⁶):1 x 插入 x,2 x 查询 x 是否在集合中。值域 |x| ≤ 10⁹。

🔍 详细分析过程

第1步:排除直接定址 —— 值域 10⁹ 意味着要开 10⁹ 个桶 ≈ 4 GB,绝无可能。

第2步:选择除留余数 —— 取 M = 100003(质数),H(x) = ((x % M) + M) % M。10⁶ 次操作分布在 10⁵ 个桶里,平均每桶 10 个元素,负载因子可控。

第3步:处理冲突 —— 用链地址法:每个桶挂一个 vector<long long>,冲突元素追加到链尾。查询时遍历链,比较实际值。

第4步:复杂度 —— 平均 O(1 + α),α ≈ 10 / 表长。最坏情况所有元素撞到一个桶,退化为 O(n)。

第5步:为什么不用 unordered_map —— 10⁶ 次操作时,手写版的常数约为 STL 的 1/3,实测能快 2~3 倍。

C++手写哈希表(链地址法)— 完整代码
#include <bits/stdc++.h>
using namespace std;
const int M = 100003;            // 质数表长
vector<long long> bucket[M];      // 每个桶挂一条链

int hashi(long long x) {
    return (int)((x % M + M) % M);   // 负数取模归一
}

void insertVal(long long x) {
    int h = hashi(x);
    for (long long v : bucket[h])
        if (v == x) return;             // 已存在则不重复插入
    bucket[h].push_back(x);
}

bool findVal(long long x) {
    int h = hashi(x);
    for (long long v : bucket[h])
        if (v == x) return true;
    return false;
}

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    int m; cin >> m;
    while (m--) {
        int op; long long x;
        cin >> op >> x;
        if (op == 1) insertVal(x);
        else cout << (findVal(x) ? "Yes" : "No") << "\n";
    }
    return 0;
}
运行结果
输入: 7
      1 1000000000
      1 -500000000
      2 1000000000
      2 999999999
      1 26
      1 1000029
      2 1000029
输出:
      Yes
      No
      Yes
(注:26 和 1000029 都落在桶 26,构成一条链)

💡 为什么 26 和 1000029 会撞在一起?

1000029 = 26 + 1000003 = 26 + 10 × 100003 + 0?我们来算:1000029 mod 100003。

100003 × 10 = 1000030,比 1000029 大 1,所以 1000029 = 100003 × 9 + 1000029 − 900027 = 100003 × 9 + 100002,不对。

重新计算:100003 × 9 = 900027,1000029 − 900027 = 100002。所以 1000029 % 100003 = 100002,落在桶 100002,与 26 不冲突。

真正会撞的例子:26 和 26 + 100003 = 100029。它们的哈希值都是 26,会挂在同一条链上。这就是「键值之差为 p 的倍数」触发冲突的直接体现。

案例案例3:A − B 数对(哈希计数) 经典

题目:给定 n 个整数和一个常数 C,求满足 a[i] − a[j] = C(i ≠ j)的数对个数。n ≤ 2×10⁵,0 ≤ a[i] ≤ 2³⁰。

🔍 详细分析过程

方法一(双重循环)O(n²) —— 枚举所有数对判断差值,n = 2×10⁵ 时是 4×10¹⁰ 次,超时。

方法二(排序 + 双指针)O(n log n) —— 先排序,用两个指针维护区间,统计每种值的出现次数。可行但需要处理边界。

方法三(哈希计数)O(n) —— 正解。对每个 a[i],我们要找的是 a[i] − C 出现了多少次。用一个哈希表 cnt 记录每个数出现的次数:先查询 cnt[a[i] − C],再把 a[i] 计入 cnt。顺序不能颠倒,否则会把 i = j 的情况算进去。

第4步:为什么先查后加 —— 若先把 a[i] 记入,则 a[i] − C 可能在本次查询时正好是 a[i] 自己(当 C = 0 时),造成错误自配对。先查后加天然排除了这种情况。

C++A − B 数对 — 完整代码
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 200005;
const int HMOD = 1000003;      // 哈希表长(质数)
int head[MAXN], nxt[MAXN], cntNode;
long long val[MAXN];
int num[MAXN];                     // 该键出现的次数

int hashi(long long x) { return (int)((x % HMOD + HMOD) % HMOD); }

int getCnt(long long x) {            // 查询 x 的出现次数
    int h = hashi(x);
    for (int i = head[h]; i; i = nxt[i])
        if (val[i] == x) return num[i];
    return 0;
}

void addCnt(long long x) {            // 把 x 的计数 +1
    int h = hashi(x);
    for (int i = head[h]; i; i = nxt[i])
        if (val[i] == x) { num[i]++; return; }
    val[++cntNode] = x; num[cntNode] = 1;
    nxt[cntNode] = head[h]; head[h] = cntNode;   // 头插法挂链
}

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    int n; long long C;
    cin >> n >> C;
    long long ans = 0;
    for (int i = 1; i <= n; i++) {
        long long x; cin >> x;
        ans += getCnt(x - C);       // 先查:x 前面有多少个 x - C
        addCnt(x);                  // 后加:把自己计入
    }
    cout << ans << endl;
    return 0;
}
运行结果
输入: 4 1
      1 1 2 3
输出: 3
(合法数对:(2,第2个1)、(2,第1个1)、(3,2),共 3 对)
逐步验证:i=1 查 cnt(0)=0;i=2 查 cnt(0)=0;i=3 查 cnt(1)=2;
          i=4 查 cnt(2)=1 → 总计 0+0+2+1 = 3

输入: 5 0
      1 1 1 2 2
输出: 4
逐步验证:i=1 查 cnt(1)=0;i=2 查 cnt(1)=1;i=3 查 cnt(1)=2;
          i=4 查 cnt(2)=0;i=5 查 cnt(2)=1 → 总计 0+1+2+0+1 = 4

💡 哈希计数 vs 排序双指针,怎么选?

哈希计数:O(n),但需要额外的哈希表空间,且常数依赖哈希函数质量。

排序 + 双指针:O(n log n),但空间更省,且不依赖哈希函数的均匀性,结果稳定。

经验法则:n ≤ 10⁶ 且值域很大时优先哈希;n ≤ 10⁵ 时排序双指针往往更省心。两者都能拿满分时,选代码更短、更不容易写错的那个。

模块 03

实践应用

三道完整题目,从桶到哈希表逐级加深。

题1成绩排名去重
题 1直接定址

某班 n 名学生的成绩(0~100 的整数,n ≤ 10⁵)。求:① 有多少种不同的成绩;② 每种成绩有多少人。

输入格式:第一行 n;第二行 n 个 0~100 的整数。

输出格式:第一行不同成绩的种数;接下来每行「成绩 人数」,按成绩升序输出。

输入样例:

输入
8
90 85 90 100 85 90 60 75

输出样例:

输出
5
60 1
75 1
85 2
90 3
100 1

数据范围:1s / 256MB。

🔍 题目分析

方法一(排序 + 遍历)O(n log n) —— 排序后相邻比较统计连续段,可行但略繁琐。

方法二(直接开桶)正解 —— 值域只有 0~100,开一个 cnt[101] 数组,读入时 cnt[x]++。最后遍历 0~100 输出。O(n + 101)。

结论:值域极小是桶思想的教科书场景,代码量与常数都碾压排序。

C++成绩统计 — 完整代码
#include <bits/stdc++.h>
using namespace std;
int cnt[101];

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    int n; cin >> n;
    for (int i = 0; i < n; i++) {
        int x; cin >> x;
        cnt[x]++;                       // O(1) 落桶
    }
    int kinds = 0;
    for (int v = 0; v <= 100; v++) if (cnt[v]) kinds++;
    cout << kinds << "\n";
    for (int v = 0; v <= 100; v++)
        if (cnt[v]) cout << v << " " << cnt[v] << "\n";
    return 0;
}
运行结果
输入: 8 / 90 85 90 100 85 90 60 75
输出: 5
      60 1
      75 1
      85 2
      90 3
      100 1
题2两数之和(哈希表查询)
题 2哈希表

给定 n 个互不相同的整数和一个目标值 T,判断是否存在两个数之和等于 T。n ≤ 2×10⁵,|a[i]| ≤ 10⁹。

输入格式:第一行 n, T;第二行 n 个整数。

输出格式:存在输出两个数的值(小的在前),否则输出 No。

输入样例:

输入
6 9
2 7 11 15 3 6

输出样例:

输出
2 7

数据范围:1s / 256MB。

🔍 题目分析

方法一(枚举)O(n²) —— 两两配对求和,n = 2e5 时 4e10 次,超时。

方法二(哈希表)正解 —— 边读边查:对每个 a[i],检查 T − a[i] 是否已出现过。命中即找到答案。O(n)。

关键顺序:先查后插,避免自己和自己配对(虽然本题保证互不相同,但好习惯要保持)。

易错:本题值域含负数,哈希函数必须用 (x % M + M) % M;否则下标为负,直接 RE。

C++两数之和 — 完整代码
#include <bits/stdc++.h>
using namespace std;
const int M = 100003;
vector<long long> bucket[M];

int hashi(long long x) { return (int)((x % M + M) % M); }

bool has(long long x) {
    for (long long v : bucket[hashi(x)]) if (v == x) return true;
    return false;
}
void put(long long x) { bucket[hashi(x)].push_back(x); }

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    int n; long long T; cin >> n >> T;
    for (int i = 1; i <= n; i++) {
        long long x; cin >> x;
        long long need = T - x;
        if (has(need)) {
            cout << min(x, need) << " " << max(x, need) << endl;
            return 0;
        }
        put(x);
    }
    cout << "No" << endl;
    return 0;
}
运行结果
输入: 6 9 / 2 7 11 15 3 6
输出: 2 7
(i=1 读入 2,need=7 未出现;i=2 读入 7,need=2 已出现 → 输出 2 7)
题3众数统计(出现次数最多的数)
题 3哈希计数

给定 n 个整数(|a[i]| ≤ 2³¹−1,n ≤ 10⁶),求出出现次数最多的数及其次数;若并列,输出最小的那个数。

输入格式:第一行 n;第二行 n 个整数。

输出格式:一行两个整数:数值 出现次数。

输入样例:

输入
9
3 1 3 2 3 1 4 3 1

输出样例:

输出
3 4

数据范围:1s / 256MB。

🔍 题目分析

方法一(排序)O(n log n) —— 排序后相同元素相邻,扫一遍统计最长连续段。但要处理「并列取最小值」,需要额外判断。

方法二(哈希计数)正解 —— 用哈希表统计每个数的次数,同时在线更新答案。n = 10⁶ 时 O(n) 明显优于排序,且不受值域限制。

并列处理:更新条件写成「次数更大,或(次数相等且数值更小)」。

C++众数统计 — 完整代码
#include <bits/stdc++.h>
using namespace std;
const int M = 2000003;              // 表长 ≈ 2 倍 n
int head[M], nxt[M], cntNode;
long long val[M];
int num[M];

int hashi(long long x) { return (int)((x % M + M) % M); }

int addAndGet(long long x) {
    int h = hashi(x);
    for (int i = head[h]; i; i = nxt[i])
        if (val[i] == x) return ++num[i];
    val[++cntNode] = x; num[cntNode] = 1;
    nxt[cntNode] = head[h]; head[h] = cntNode;
    return 1;
}

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    int n; cin >> n;
    long long bestVal = 0; int bestCnt = 0;
    for (int i = 1; i <= n; i++) {
        long long x; cin >> x;
        int c = addAndGet(x);
        if (c > bestCnt || (c == bestCnt && x < bestVal)) {
            bestCnt = c; bestVal = x;       // 在线更新,并列取小
        }
    }
    cout << bestVal << " " << bestCnt << endl;
    return 0;
}
运行结果
输入: 9 / 3 1 3 2 3 1 4 3 1
输出: 3 4
(3 出现 4 次,1 出现 3 次,故众数为 3)
模块 04

总结提升

①核心要点与万能模板
哈希函数公式适用注意
直接定址H(k) = k值域 ≤ 1e7 的整数负数要加偏移
除留余数H(k) = k mod p通用p 取大质数;负数归一
乘法散列H(k) = ⌊M·frac(kA)⌋M 为 2 的幂时A 取黄金比
平方取中H(k) = k² 的中间位键值位数少需要高精度乘法
数字分析取分布均匀的数位键值已知且规律需预先分析数据

万能模板:手写哈希表(链地址法)

C++可复用的手写哈希计数表
const int M = 2000003;              // 质数,约为元素数的 2 倍
int head[M], nxt[M], cntNode;
long long val[M];
int num[M];

int hashi(long long x) { return (int)((x % M + M) % M); }

int* findNode(long long x) {          // 找到返回指针,找不到返回 nullptr
    int h = hashi(x);
    for (int i = head[h]; i; i = nxt[i])
        if (val[i] == x) return &num[i];
    return nullptr;
}

void add(long long x) {               // 计数 +1(不存在则新建)
    int* p = findNode(x);
    if (p) { (*p)++; return; }
    int h = hashi(x);
    val[++cntNode] = x; num[cntNode] = 1;
    nxt[cntNode] = head[h]; head[h] = cntNode;
}

int query(long long x) {
    int* p = findNode(x);
    return p ? *p : 0;
}
②拓展案例

拓展1:用哈希表实现「离散化」

💡 思路:当值域极大但元素很少时,可以先用哈希表给每个出现的值分配一个连续编号,从而把大值域压缩成小值域。这与 s5-02 学的「排序 + unique + lower_bound」是同一目的的两种实现。

对比:排序离散化 O(n log n) 且保留大小关系;哈希离散化 O(n) 但不保留顺序。

C++哈希离散化(不保序)
unordered_map<long long, int> id;
int tot = 0;
auto getId = [&](long long x) {
    auto it = id.find(x);
    if (it != id.end()) return it->second;
    return id[x] = ++tot;              // 新值分配新编号
};
运行结果
输入序列: 1000000000 5 -7 1000000000 5
映射: 1000000000→1, 5→2, -7→3
输出编号序列: 1 2 3 1 2

拓展2:开放寻址的三种探测方式对比

线性探测:H(k) + 1, H(k) + 2, … 实现最简单,但容易产生「一次聚集」(连续占用区域不断变长)。

平方探测:H(k) + 1², H(k) − 1², H(k) + 2², … 缓解一次聚集,但可能出现「二次聚集」,且不一定能探测到所有格子。

双重散列:步长由第二个哈希函数决定,H(k) + i·H₂(k)。分布最好,但需要两个哈希函数。

删除问题:开放寻址删除元素时不能简单置空,必须打「墓碑」标记,否则会切断后续元素的探测链。

C++平方探测写法
int probeSquare(long long k) {
    int h0 = hashi(k);
    for (int i = 0; i < M; i++) {
        int h = (h0 + i * i) % M;      // 只向一个方向平方
        if (key[h] == EMPTY || key[h] == k) return h;
    }
    return -1;                        // 表满
}
运行结果
表长 M = 11,依次插入 11、22、33(都映射到 0 号桶)
线性探测:0, 1, 2
平方探测:0, 1, 4   ← 分散更快

拓展3:在线 vs 离线——何时必须用哈希

必须用哈希的场景:数据是「动态」的 —— 一边插入一边查询,且要求 O(1)。例如「判断某个数是否出现过」在流式处理中。

可以用排序的场景:数据是「静态」的 —— 全部读入后只查询不修改。此时排序 + 二分更稳定。

典型题:A − B 数对既可以用哈希 O(n),也可以用排序 + 双指针 O(n log n)。前者更快,后者更稳。

C++排序 + 双指针替代方案(对比用)
sort(a + 1, a + n + 1);
long long ans = 0;
int l = 1, r = 1;
for (int l = 1; l <= n; l++) {
    while (r <= n && a[r] - a[l] < C) r++;
    while (r <= n && a[r] - a[l] == C) { ans++; r++; }
    // 注意:这里只统计了「值对」,不区分原始下标,视题目定义调整
}
运行结果
输入: n=4, C=1, a = [1, 1, 2, 3]
排序后: [1, 1, 2, 3]
统计值对: (1,2) 有 2 种下标组合 → 2 对;(2,3) 有 1 对 → 合计 3
③思考与启发

思考1:为什么模数取质数能减少冲突?

查看参考解答

冲突发生在「两个键值之差是模数的倍数」时。若模数 p 是合数,例如 p = 12 = 2²×3,那么只要两个键值之差是 12 的倍数就冲突 —— 而现实中很多数据的差值恰好是 2、3、4、6 的倍数,导致高频聚集。

若 p 是质数,两个随机整数的差恰好是 p 的倍数的概率约为 1/p,与键值本身的因子结构无关,因此分布最均匀。

推论:模数应避免 2^k(二进制数据低位相同会聚集)、避免 10^k(十进制数据低位相同会聚集)。100003、998244353 这类大质数是安全选择。

思考2:为什么负载因子要控制在 0.75 以下?

查看参考解答

负载因子 α = n / M。在链地址法中,链的平均长度为 α,所以查询的平均代价是 O(1 + α)。当 α = 1 时平均要比较 1~2 次;当 α 趋近 2 时,冲突显著增加。

在开放寻址法中,插入的期望探测次数约为 1/(1−α):α = 0.5 时约 2 次,α = 0.75 时约 4 次,α = 0.9 时约 10 次。所以工程上普遍取 0.75 作为阈值,超过就扩容。

竞赛建议:表长 M 取元素数的 2~3 倍,α 自然落在 1/3 ~ 1/2,性能最佳。

思考3:哈希表能否支持「求区间和」这类操作?

查看参考解答

不能(高效地)。哈希把键值打散到任意位置,相邻的键在表中不相邻,所以无法利用「局部性」做前缀和或区间查询。

如果需要区间查询,应该用下标连续的数据结构:前缀和、树状数组、线段树;如果需要「按 key 大小找前驱后继」,应该用 std::map / 平衡树。

一句话总结:哈希换来的是「快的等值查找」,付出的是「失去顺序性」。选数据结构,本质是在选你要保留哪种能力。

模块 05

学习检测

哈希表的错误集中在「负数取模」「表长太小」「忘查空位」三处,逐个击破。

⚡高频错误诊断
易错1

负数直接取模导致下标为负

❌ 错误
int h = x % M;,当 x = −7、M = 100003 时,h = −7,数组越界,运行时崩溃。
✅ 正确
写成 (x % M + M) % M。先模再补一个 M 再模一次,把结果归一到 [0, M)。
口诀:C++ 负数取模得负数,加 M 再模一次才安全!
易错2

表长取得太小或不是质数

❌ 错误
10⁶ 个元素只开 M = 10007 个桶,平均每桶 100 个元素,查询退化成 O(100),总复杂度 O(10⁸)。
✅ 正确
M 取元素数的 2~3 倍且为质数,例如 n = 10⁶ 时取 M = 2000003 或 3000017。
口诀:表长要开两三倍,还要挑个大质数!
易错3

开放寻址查询时忘判空位

❌ 错误
查询循环写成 while (key[h] != k) h++; ,遇到空位也不停,越过空位继续找,甚至死循环。
✅ 正确
循环条件必须是 while (key[h] != EMPTY):一旦碰到空位就说明「链断了」,该键必定不存在。
口诀:探测遇到空位就收手,这说明目标肯定没有!
易错4

值域大却强行开桶

❌ 错误
题目给 |a[i]| ≤ 10⁹,却写 bool exist[1000000000],需要约 1 GB,直接 MLE。
✅ 正确
先看值域再决定方案:值域 ≤ 1e7 用桶;更大就用除留余数哈希表或排序二分。
口诀:开桶前先估值域,超过一千万就换哈希!
易错5

A−B 数对先加后查

❌ 错误
先 cnt[x]++ 再 ans += cnt[x - C],当 C = 0 时会把元素与自身配对,答案虚高。
✅ 正确
严格先查后加:ans += cnt[x - C]; cnt[x]++;,保证只和「之前出现过」的元素配对。
口诀:配对统计先查后加,自己不能和自己搭!
📋速查表
需求首选方案复杂度备注
值域小判重bool 桶数组O(1)值域 ≤ 1e7
值域小计数int 桶数组O(1)负数加偏移
值域大判重除留余数 + 链地址平均 O(1)模大质数
动态插入查询哈希表平均 O(1)不能用排序替代
需要前驱后继std::map / setO(log n)哈希做不到
静态 + 保序排序 + 二分O(log n)预处理 O(n log n)
求区间和前缀和 / 树状数组O(1)/O(log n)哈希做不到
📌笔记卡
必记概念
  • 哈希 = 空间换时间
  • 冲突不可避免(抽屉原理)
  • 模数取大质数
  • 负载因子 α ≤ 0.75
必背模板
  • (x % M + M) % M 归一
  • 链地址法头插挂链
  • 线性探测 + EMPTY 哨兵
  • 表长取元素数 2~3 倍的质数
考场提醒
  • 先看值域再定方案
  • 负数取模必须归一
  • 配对问题先查后加
  • 能开桶就别写哈希
课后练习

分层练习作业

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

🟢 A组 · 基础(4题)

A1概念题

判断:① 哈希表查询的最坏复杂度是 O(1);② 模数取 2 的幂比取质数更好;③ 负载因子越大冲突越多。

查看参考答案

① 错误。哈希表查询平均 O(1),最坏 O(n)(所有元素撞进同一个桶)。

② 错误。取 2 的幂时,键值的低位会直接决定桶号,遇到低位规律性强的数据(如都是偶数、都是 4 的倍数)会严重聚集。质数分布更均匀。

③ 正确。α = n / M 越大,链的平均长度越长,冲突处理的开销越大。

A2编程题

输入 n 个 0~1000000 的整数,输出其中有多少个不同的数。

查看参考答案
#include <bits/stdc++.h>
using namespace std;
bool ex[1000001];
int main() {
    ios::sync_with_stdio(0);
    int n, c = 0; cin >> n;
    for (int i = 0; i < n; i++) { int x; cin >> x; ex[x] = 1; }
    for (int i = 0; i <= 1000000; i++) if (ex[i]) c++;
    cout << c;
}

思路:bool 桶标记,重复自动忽略,最后统计 true 的个数。

易错:数组开成 int ex[1000000] 少一位,读到 1000000 时越界。

A3填空题

补全负数哈希函数:int hashi(long long x) { return ______; }(表长 M)

查看参考答案

答案:(int)((x % M + M) % M)

原理:C++ 的 % 运算符结果符号跟随被除数。x = −7、M = 100003 时 x % M = −7,加 M 后变成 99996,再 % M 仍是 99996,落在 [0, M) 内。

易错:只写 (x % M + M) 不加最后的 % M —— 当 x 为正数时会得到大于 M 的值,越界。

A4选择题

哈希表长 M = 10,用除留余数法依次插入 15、25、35,采用线性探测。35 最终落在哪个位置?

查看参考答案

答案:位置 7。

逐个模拟:15 % 10 = 5 → 桶 5 空,放在 5;25 % 10 = 5 → 桶 5 被占,探测 6 空,放在 6;35 % 10 = 5 → 桶 5、6 都被占,探测 7 空,放在 7。

考点:线性探测会连续占用后续空位,形成「一次聚集」—— 连续被占的区域越长,后续插入的探测代价越大。

易错:忘记「探测到表尾要回绕到表头」(本题未触发,但写代码时必须有 (h+1) % M)。

🟡 B组 · 提高(4题)

B1哈希计数

给定 n 个整数,统计每个数出现的次数并按数值升序输出。

查看参考答案

思路:若值域 ≤ 1e7 用桶;否则用哈希表 + 收集所有出现过的键后排序输出。

// 值域大时的做法
unordered_map<long long, int> cnt;
vector<long long> keys;
for (int i = 0; i < n; i++) {
    long long x; cin >> x;
    if (!cnt.count(x)) keys.push_back(x);
    cnt[x]++;
}
sort(keys.begin(), keys.end());
for (long long k : keys) cout << k << " " << cnt[k] << "\n";

易错:直接遍历 unordered_map 得到的顺序是随机的,必须把键收集起来单独排序。

B2哈希应用

给定 n 个整数,判断是否存在三个数 a, b, c 满足 a + b + c = 0。n ≤ 2000。

查看参考答案

思路:枚举 i、j(O(n²)),把 a[i] + a[j] 存进哈希表,再判断是否存在 −(a[i]+a[j])。

更常见的做法:排序 + 双指针,外层固定 i,内层用左右指针逼近 −a[i]。

sort(a, a + n);
for (int i = 0; i < n; i++) {
    int l = i + 1, r = n - 1;
    while (l < r) {
        long long s = a[i] + a[l] + a[r];
        if (s == 0) { cout << "Yes"; return 0; }
        if (s < 0) l++; else r--;
    }
}

易错:i、j、k 没保证互不相同(需要下标互异);或三数之和用 int 存导致溢出。

B3哈希 + 前缀

给定数组,求和为 0 的连续子数组的个数。n ≤ 10⁵。

查看参考答案

思路:子数组 [l, r] 和为 0 ⟺ 前缀和 s[r] == s[l−1]。用哈希表记录每个前缀和出现的次数,边遍历边累加。

unordered_map<long long, int> cnt;
cnt[0] = 1;                  // 空前缀,别忘!
long long s = 0, ans = 0;
for (int i = 0; i < n; i++) {
    s += a[i];
    ans += cnt[s];              // 前面出现过几次同样的前缀和
    cnt[s]++;
}
cout << ans;

易错:忘记初始化 cnt[0] = 1,导致从下标 0 开始的合法子数组全部漏掉。

B4哈希设计

给定 n 组坐标 (x, y)(|x|, |y| ≤ 10⁹),判断是否有重复的坐标对。n ≤ 2×10⁵。

查看参考答案

思路:把二元组编码成一个 long long:key = x * BASE + y,再对 key 做哈希。BASE 要足够大且不能与 x、y 的范围重叠。

const long long BASE = 2000000003LL;   // > 2*1e9
const long long OFF  = 1000000000LL;   // 把负数平移到非负
auto encode = [](long long x, long long y) {
    return (x + OFF) * BASE + (y + OFF);
};

为什么先加偏移:把 [−1e9, 1e9] 平移到 [0, 2e9],保证编码结果非负且不重叠。

易错:BASE 取 1e9 而 x 的最大值是 1e9,会造成不同 (x, y) 编码到同一个值(碰撞);或忘记加偏移导致负溢出。

🔴 C组 · 挑战(4题)

C1理论分析

证明:在链地址法中,若哈希函数把 n 个键均匀随机地映射到 M 个桶,则查询一个已存在元素的期望比较次数约为 1 + α/2(α = n/M)。

查看参考答案

设定:n 个键独立均匀地散到 M 个桶,每桶的期望元素数为 α = n/M。

分析:目标元素 x 落在某个桶时,它在该链中的位置是随机的。链长为 k 时,平均需要比较 (k+1)/2 次(等概率出现在第 1 到第 k 位,再加上查找失败前的一次判断)。

对 k 取期望:E[(k+1)/2] = (E[k]+1)/2 = (α+1)/2 ≈ 1 + α/2(当 α ≥ 1 时)。

结论:只要 α 是常数,查询就是 O(1)。这解释了为什么「表长取元素数 2~3 倍」(α ≈ 1/3 ~ 1/2)能保证性能。

C2综合编程

给定 n 个整数和一个常数 C,求满足 |a[i] − a[j]| = C 的无序数对个数(i < j)。n ≤ 2×10⁵,|a[i]| ≤ 10⁹。

查看参考答案

思路:|a[i] − a[j]| = C 等价于 a[i] − a[j] = C 或 a[j] − a[i] = C。

做法一(两次哈希):正着扫一遍统计 a[i] − a[j] = C(先查后加),再反着扫一遍统计 a[j] − a[i] = C。两次结果相加即可(C > 0 时不会重复,C = 0 时第二次会重复计一次,需要除以 2 或特判)。

做法二(排序 + 双指针):排序后用双指针分别匹配差为 C 与差为 −C 的位置。

// 做法一:朴素两次扫描
auto countDiff = [&](long long d) {
    clearHash();
    long long res = 0;
    for (int i = 1; i <= n; i++) {
        res += query(a[i] - d);      // 前面有多少个 a[i]-d
        add(a[i]);
    }
    return res;
};
long long ans = countDiff(C) + countDiff(-C);
if (C == 0) ans /= 2;      // C=0 时两次统计的是同一批数对

易错:C = 0 时忘记除以 2;或哈希表没有清空就统计第二轮。

C3困难

设计一个哈希表,支持「插入、删除、查询、求当前集合中第 k 小元素」四种操作。说明设计思路与复杂度。

查看参考答案

结论:哈希表本身做不到「第 k 小」。因为哈希破坏了顺序性 —— 相邻的键在表中位置无关,无法快速定位第 k 小。

正确方案:

① 平衡树(std::set 或手写 Treap / Splay):插入、删除、查询 O(log n),求第 k 小通过维护子树大小在 O(log n) 内完成。

② 值域树状数组(若值域可离散化到 ≤ 1e6):插入/删除 O(log n),第 k 小用树状数组上二分 O(log n)。

③ 哈希 + 辅助结构:哈希负责快速判存在,另建一棵平衡树负责序关系。两者结合,各司其职。

启发:没有一种数据结构能同时做到「O(1) 等值查找」和「O(log n) 序查找」。选型取决于你的操作集合。

C4初赛真题风格

设哈希表长 M = 11,哈希函数 H(k) = k mod 11,用线性探测处理冲突。依次插入 {22, 41, 53, 46, 30, 13, 1, 67},画出最终的哈希表(下标 0~10)。

查看参考答案

依次计算并探测:

· 22 % 11 = 0 → 0 空,放 0。

· 41 % 11 = 8 → 8 空,放 8。 (41 = 33 + 8)

· 53 % 11 = 9 → 9 空,放 9。 (53 = 44 + 9)

· 46 % 11 = 2 → 2 空,放 2。 (46 = 44 + 2)

· 30 % 11 = 8 → 8 被 41 占;探 9 被 53 占;探 10 空,放 10。

· 13 % 11 = 2 → 2 被 46 占;探 3 空,放 3。

· 1 % 11 = 1 → 1 空,放 1。

· 67 % 11 = 1 → 1 被 1 占;探 2 被 46 占;探 3 被 13 占;探 4 空,放 4。

最终表(下标 0~10):

0:22   1:1   2:46   3:13   4:67   5:空   6:空   7:空   8:41   9:53   10:30

易错:探测时忘记「循环回绕」(本题恰好没触发);或遇到被占位置就覆盖而不是继续探测。

🏆 洛谷原题精选(12道)

以下 12 道题覆盖桶思想、哈希判重、哈希计数与哈希表的实际应用,按难度从低到高排列。

1
P1059
[NOIP2006 普及组] 明明的随机数入门
2
P1097
[NOIP2007 提高组] 统计数字普及-
3
P1102
A-B 数对(哈希计数)普及-
4
P3613
【深基15.例2】寄包柜普及-
5
P5266
【深基17.例6】学籍管理普及-
6
P5250
【深基17.例5】木材仓库普及+
7
P4305
[JLOI2011] 不重复数字普及+
8
P3405
[USACO16DEC] Cities and States S普及+
9
P3879
[TJOI2010] 阅读理解普及+
10
P3370
【模板】字符串哈希普及+
11
P1955
[NOI2015] 程序自动分析提高
12
P1381
单词背诵(哈希 + 双指针)提高