查找与哈希表
「查找(search)」是计算机里被调用次数最多的操作,没有之一:数据库的索引、编译器的符号表、 路由器的转发表、Python 的字典、Java 的 HashMap,本质都在回答同一个问题—— 给定一个关键字,怎么最快地找到它? 本讲从最朴素的顺序查找一路推到 O(1) 的哈希表,把「平均查找长度 ASL」这把尺子贯穿始终; 哈希冲突的四种处理方法与 ASL 手推,是 408 与期末考试的必考大题。
- 10.1 查找的基本概念与 ASL 的严格定义 —— 全章的度量尺,务必先掌握。
- 10.2 顺序查找 —— 哨兵写法、成功 / 失败两种 ASL、有序表为何能把失败 ASL 减半。
- 10.3 折半查找 —— 本章第一重点:判定树的构造、n=11 的 ASL 手推、失败 ASL 与变体。
- 10.4 / 10.5 插值查找与斐波那契查找 —— 两种「更聪明的分割点」,以及它们各自翻车的场景。
- 10.6 分块查找 —— 「块间有序、块内无序」,ASL 最优时块长取 √n。
- 10.7 哈希表 —— 本章第二重点:六种哈希函数、四种冲突处理、装填因子与 ASL 手推。
- 10.8 工程视角 —— 哈希在真实系统里怎么用:拉链法 vs 开放定址的取舍、扩容的「长尾延迟」、
unordered_map的真实结构与防卡哈希、数据库为什么偏爱 B+ 树、 布隆过滤器与一致性哈希,最后用一张表做五大结构的工程选型。
10.1 查找的基本概念与平均查找长度
10.1.1 什么是查找表
查找(search)的定义非常朴素:在由同一类型数据元素(或记录)构成的集合中, 找出一个关键字等于给定值的元素。这个集合就叫查找表(search table)。 注意查找表在逻辑上是一种集合——元素之间除了「同属一张表」之外没有别的约束, 但一旦落到存储结构上,它可能是顺序表、链表、树或者散列表, 而存储结构直接决定了你能用哪种查找算法。
按允许的操作,查找表分成两类,这是选择题的常客:
静态查找表(static)
只做两类操作:
- 查询某个关键字是否在表中;
- 检索某个特定关键字所对应的属性(如按学号查成绩)。
表建好之后不再改动 → 顺序查找、折半查找、分块查找都适用。
动态查找表(dynamic)
除了查询,还允许:
- 插入一个不存在的元素;
- 删除一个已存在的元素。
表的结构会动态变化 → 二叉排序树、平衡二叉树、B 树、散列表。 折半查找的静态数组在这里就力不从心了(插入删除要搬移 O(n) 个元素)。
10.1.2 关键字:主关键字与次关键字
关键字(key)是数据元素中用来标识该元素的那个数据项的值。 如果这个数据项能唯一标识一个元素,就称为主关键字(primary key); 若它可能标识出多个元素,就称为次关键字(secondary key)。
- 主关键字
- 能唯一区分每个记录。例如学号、身份证号、数据库主键。用主关键字查找,结果要么是恰好一个元素,要么「不存在」。
- 次关键字
- 可能重复。例如「姓名」「班级」「性别」。用次关键字查找,结果可能是一组元素——所以工程上叫「范围查询 / 多值查询」。
- 查找成功 / 不成功
- 表中存在关键字等于给定值的元素,叫查找成功(successful search);
找遍全表也不存在,叫查找不成功(unsuccessful search),通常返回一个特殊标记(
-1、nullptr、end())。 - 平均查找长度 ASL
- Average Search Length。查找过程中关键字比较次数的期望值,是衡量查找算法优劣的核心指标(而不是简单的「最坏情况」)。
ASL成功 = … 与 ASL不成功 = …。
只写一个,扣一半分。
10.1.3 ASL 的定义与公式
设查找表有 n 个元素,查找第 i 个元素的概率为 pi,
找到它所需要的关键字比较次数为 ci,那么查找成功的平均查找长度定义为:
在等概率假设下(即 pi = 1/n,绝大多数考题都这么假设),公式退化为:
而查找不成功的 ASL 是另一个式子:把「失败」的每种可能情形(折半查找里是每个外部结点、
哈希表里是每个探测起点、分块查找里是每个索引块)当作一次「等可能的查找」,
若共有 m 种失败情形、第 j 种需要 dj 次比较,则
| 查找方法 | 成功 ASL | 不成功 ASL | 关键约定说明 |
|---|---|---|---|
| 顺序查找(无序表) | (n+1)/2 | n | 失败时要把 n 个元素全部比完 |
| 顺序查找(有序表) | (n+1)/2 | n/2 + n/(n+1) | 一旦发现当前元素 > 目标就能提前停 |
| 折半查找 | ≈ log2(n+1) − 1 | ≈ log2(n+1) | 失败情形数 = 外部结点数 = n+1 |
| 分块查找 | ≈ √n + 1 | 需单独分析 | 块长取 √n 时最优 |
| 哈希表(开放定址) | 与装填因子 α 有关 | 与 α 有关,且 α < 1 | 失败情形数 = 表长 m |
上表是「先建立全局印象」用的,每个数字的推导都在后面的小节里,请务必自己推一遍再回来对照。
ci 时只数关键字比较的次数,不数下标运算、不数循环条件判断。
但要注意不同教材对「失败时最后那次与空位置的比较」是否计数约定不同。
本讲统一采用最通行的约定:只要是访问一个存储位置并做了判定,就算一次比较,
并且会在每个例子旁边明确写出约定,你在考场上也应当把约定写清楚。
10.1.4 查找算法的评价维度
比较两个查找算法时,按下面五个维度看,基本不会漏:
| 维度 | 要问的问题 | 典型结论 |
|---|---|---|
| 时间复杂度 | 成功 / 失败各自的最坏与平均比较次数? | 顺序 O(n)、折半 O(log n)、哈希 O(1) 期望 |
| 空间复杂度 | 是否需要额外的索引表 / 指针 / 哈希桶? | 折半 O(1)、分块 O(√n) 索引、哈希 O(m) |
| 对存储结构的要求 | 必须是顺序存储吗?必须有序吗? | 折半两者都要;哈希不要求有序 |
| 是否支持动态修改 | 插入 / 删除的代价? | 折半插入 O(n);哈希期望 O(1);链地址删除最方便 |
| 对数据分布的敏感度 | 数据分布变化时性能是否稳定? | 插值查找很敏感;折半查找很稳定 |
10.2 顺序查找:从哨兵到有序表
10.2.1 一句话本质与三种扫描方向
顺序查找(sequential search,又称线性查找 linear search)的本质是一句话:
从表的一端开始,用给定值 key 逐个与表中元素的关键字比较,相等就成功,扫完全表都找不到就失败。
它不需要数据有序、不需要顺序存储(链表也能用),是唯一「什么结构都能上」的查找方法。
10.2.2 顺序表的顺序查找与成功 ASL 推导
设表长为 n(下标 1..n,这是教材惯用的 1 基写法,方便写 ASL 公式),
从前往后扫。查找第 i 个元素需要比较 ci = i 次。
在等概率假设下 pi = 1/n:
这个结果要能脱口而出。直观理解:如果每次查的都是「第一个元素」要 1 次、「最后一个」要 n 次,
平均下来就是首尾的算术平均值 (1+n)/2。
注意一个细节:等概率假设一旦不成立,结论就变了。如果各元素的查找概率 pi 已知,
把概率大的元素放在靠前的位置可以让 ASL 变小——这正是「按访问频率排序的自组织线性表」的思想:
10.2.3 失败 ASL:n 还是 n+1?
这是顺序查找里最容易被扣分的地方。结论先给: 无序表的顺序查找,失败时平均比较次数是 n;带哨兵的实现是 n+1。 两者不是矛盾,而是数法约定不同。看下面这张逐次比较的拆解:
| 写法 | 循环条件 | 查找 key = 9(不存在)时的比较序列 | 失败比较次数 | ASL失败 |
|---|---|---|---|---|
| 朴素(下标 0..n−1) | for (i = 0; i < n; i++) |
a[0]、a[1]、…、a[n−1] 各比一次,循环条件再判一次不算比较 | n | n |
| 带哨兵(下标 1..n,a[0] 放 key) | for (i = n; a[i] != key; i--) |
a[n]、a[n−1]、…、a[1],最后一定在 a[0] 哨兵处停下,共 n+1 次 | n + 1 | n + 1 |
n+1;
没提哨兵、按教材的 for (i=1; i<=n; ++i) 朴素写法,就写 n。
关键是把约定写出来,阅卷老师看的是你的推导过程。
顺带说:哨兵写法在真实代码里更快——它把「下标越界判断」和「关键字比较」合并成一次判断,
每轮少一条指令,在 n 很大时收益可观。这是「用一点点空间换常数」的经典案例。
10.2.4 有序表的顺序查找:失败 ASL 为什么能减半
如果表是有序的(比如从小到大),顺序查找依然可以从前扫到后,
但它多了一个「提前刹车」的机会:一旦发现 a[i] > key,就可以立刻断定失败,
因为后面所有元素只会更大。
我们把「失败」的所有情形按 key 的落点分类。有序表 a[1..n] 把数轴切成了 n+1 个区间:
(−∞, a[1])、(a[1], a[2])、…、(a[n−1], a[n])、(a[n], +∞)。
假设 key 等可能地落在任意一个区间里(这是标准假设,也解释了分母为什么是 n+1):
| key 落点 | 扫描到哪里停下 | 比较次数 dj | 出现情形数 |
|---|---|---|---|
| 小于 a[1] | 与 a[1] 比一次就发现 a[1] > key | 1 | 1 |
| 落在 (a[1], a[2]) | 比到 a[2] 停 | 2 | 1 |
| … | … | … | … |
| 落在 (a[n−1], a[n]) | 比到 a[n] 停 | n | 1 |
| 大于 a[n] | 必须比完全部 n 个才发现都不小于它 | n | 1 |
于是(注意最后一种情况的比较次数也是 n):
当 n 较大时,n/(n+1) → 1,所以 ASL失败 ≈ n/2 + 1 ——
相比无序表的 n,几乎砍掉了一半。这非常符合直觉:有序表里 key 一旦比当前元素小,
就知道后面不用看了,「平均只要看一半」。
• 内部结点:第 i 个元素在下标为 i 的层上 → 成功比较次数
ci = i,ASL成功 = (n+1)/2。• 外部结点:第一个失败区间在第 1 层(与 a[1] 比一次就能判定),后面每个失败区间依次加深, 最后一个失败区间在第 n 层(注意:不是 n+1 层!这是最经典的陷阱)→ 上式的
n+n 就是从这来的。如果你把「判定树」画成折半查找那样的完全平衡形态,那
ASL失败 就完全不同了——
因为有序表顺序查找的判定树是一条链,不是平衡树。
10.2.5 链式存储上的顺序查找
链表只能顺序查找,而且缺点被放大了:每访问一个结点都要解引用一次指针,
cache 命中率极低(CPU 预取器对随机地址无能为力),常数因子通常是数组的 3~10 倍。
链表做顺序查找的 ASL 与顺序表完全一样((n+1)/2 与 n),
因为比较次数只取决于链表长度,与存储方式无关。
唯一的「优势」是:如果要在有序链表上找 key,找到第一个 > key 的结点即可停,
与有序顺序表同理;而且链表上的插入删除是 O(1)(已知前驱时),
所以「频繁增删 + 查找不多」的场景,链表 + 顺序查找反而合适。
10.2.6 优缺点、适用场景与 C++ 实现
| 维度 | 顺序查找 |
|---|---|
| 前提条件 | 无——不需要有序,顺序存储 / 链式存储都能用 |
| 成功 ASL | (n+1)/2 |
| 失败 ASL | 无序 n(哨兵 n+1);有序 n/2 + n/(n+1) |
| 时间复杂度 | O(n) |
| 空间 | O(1) |
| 插入 / 删除 | 直接放表尾 / 链表 O(1),无需维护有序性 |
| 适用场景 | 表很小(n ≤ 20);数据频繁变动、懒得维护有序;数据只存在链表 / 流式输入无法回退;查找次数远少于修改次数 |
#include <iostream>
#include <vector>
using namespace std;
/* =====================================================================
顺序查找的三种写法(本文件统一用 1 基下标,a[0] 空出或作哨兵)
约定:每次"访问一个存储位置并做关键字判定"记 1 次比较
===================================================================== */
/* ---------- ① 朴素版:从前往后扫(0 基) ---------- */
// 成功:c_i = i+1 → ASL_success = (n+1)/2
// 失败:n 次比较 → ASL_fail = n
int seqSearchPlain(const vector<int>& a, int key) {
for (int i = 0; i < (int)a.size(); ++i)
if (a[i] == key) return i; // 命中
return -1; // 扫完全表都没找到
}
/* ---------- ② 带哨兵版:从后往前扫(1 基,a[0] 是哨兵) ---------- */
// a[1..n] 存数据。把 key 放进 a[0],循环里就不必判断 i > 0:
// 即使全表都没有 key,也一定会在 a[0] 处停下 —— 这就是"哨兵"的作用。
// 成功:c_i = n-i+1 → ASL_success = (n+1)/2
// 失败:n+1 次比较(含与哨兵的那一次)→ ASL_fail = n+1
int seqSearchSentinel(vector<int>& a, int key) {
int n = (int)a.size() - 1; // a[0] 不存数据
a[0] = key; // 设置哨兵
int i = n;
while (a[i] != key) --i; // 没有 i >= 1 的判断!
return i; // i == 0 表示查找失败
}
/* ---------- ③ 有序表的顺序查找:可以提前刹车 ---------- */
// a[0..n-1] 严格递增。一旦 a[i] > key,后面只会更大,直接判定失败。
// 成功:仍为 (n+1)/2
// 失败:ASL_fail = n/2 + n/(n+1) (见正文推导)
int seqSearchOrdered(const vector<int>& a, int key) {
int n = (int)a.size();
for (int i = 0; i < n; ++i) {
if (a[i] == key) return i;
if (a[i] > key) return -1; // 提前刹车,省掉后面所有比较
}
return -1;
}
int main() {
vector<int> v = {17, 25, 39, 42, 58, 66, 73}; // 共 7 个元素,已有序
cout << seqSearchPlain(v, 39) << "\n"; // 2
cout << seqSearchPlain(v, 100) << "\n"; // -1
vector<int> w; // 1 基存储:w[0] 是哨兵位
w.push_back(0); // 占位
for (int x : v) w.push_back(x);
cout << seqSearchSentinel(w, 39) << "\n"; // 3(因为 39 在 w[3])
cout << seqSearchSentinel(w, 100) << "\n"; // 0 → 失败
cout << seqSearchOrdered(v, 39) << "\n"; // 2
cout << seqSearchOrdered(v, 40) << "\n"; // -1,只比了 4 次就刹车
/* 验证 ASL 公式:统计所有成功查找的总比较次数 */
int total = 0, n = (int)v.size();
for (int i = 0; i < n; ++i) total += (i + 1); // 第 i 个元素要 i+1 次
cout << "ASL_success = " << (double)total / n << "\n"; // (7+1)/2 = 4
return 0;
}
10.2.7 动手看三种写法的比较次数
下面这个动画把 ①朴素(从前往后)、②带哨兵(从后往前)、③有序表提前刹车 三种写法 放在同一组数据上同步演示:逐格高亮当前比较的元素,并分别统计比较次数。 先用一个能查到的 key 看成功路径,再用两个查不到的 key 对比三种写法的失败代价。
(n+1)/2,因为成功时比较次数只取决于元素位置)
② 三种写法的失败代价差多少?(无序表 n,哨兵 n+1,有序表 n/2 + n/(n+1))
③ 哨兵多花了一次比较,那它到底图什么?(图的是循环里少一条 i >= 0 判断,
每轮少一次分支,实际跑得更快——这是「空间 / 时间」与「比较次数 / 指令数」两套评价标准的区别)
10.3 折半查找:判定树与 ASL 手推
10.3.1 前提条件与一句话本质
折半查找(binary search,又称二分查找)的本质是一句话: 每次拿区间中点元素与 key 比较,一次比较就能砍掉一半的候选区间。 但天下没有免费的午餐,它有两个硬性前提,缺一不可:
前提 1:必须顺序存储
因为要能 O(1) 随机访问 a[mid]。链表做不到——在链表上「取第 mid 个结点」
本身就要走 mid 步,砍半带来的收益立刻被吃掉,复杂度退化成 O(n)。
所以「链表可以用折半查找」是错的。
前提 2:必须有序
因为「砍掉一半」的依据是「a[mid] < key ⟹ 左半边全部小于 key」,
这只有在有序时成立。无序数据上折半查找会给出错误结果(不是慢,是错)。
a[mid] 与 key 的比较算 1 次,
一趟 while 循环恰好做 1 次有效比较,所以「比较次数 = 判定树上的层数」。
这也是为什么 ASL 可以直接用判定树的层号来算。
10.3.2 算法流程与 C++ 实现
用闭区间 [left, right] 表示还在候选范围内的下标,
每轮取 mid = left + (right − left) / 2
(不要写 (left + right) / 2!当 left 与 right 都接近
INT_MAX 时它会在加法处溢出,得到负数下标,程序直接崩溃。
这是 LeetCode 上都写进题解的老坑)。
每轮三分支:
a[mid] == key→ 查找成功,返回mid;a[mid] < key→ 目标只可能在右半区间,令left = mid + 1;a[mid] > key→ 目标只可能在左半区间,令right = mid − 1。
重复直到 left > right(区间为空)→ 查找失败。
先看它一步步怎么收缩区间,注意右侧判定树是怎么同步长出来的:
#include <iostream>
#include <vector>
using namespace std;
/* ============ 折半查找:迭代版 ============
前提:a 已按升序排好,且支持 O(1) 随机访问
返回 key 所在下标,找不到返回 -1
约定:每执行一次 a[mid] 与 key 的比较记 1 次 */
int binarySearch(const vector<int>& a, int key, int* cmpCnt = nullptr) {
int left = 0, right = (int)a.size() - 1, cnt = 0;
while (left <= right) { // 闭区间 [left, right] 非空
int mid = left + (right - left) / 2; // 防溢出!不要写 (left+right)/2
++cnt;
if (a[mid] == key) { if (cmpCnt) *cmpCnt = cnt; return mid; }
else if (a[mid] < key) left = mid + 1; // 目标在右半边
else right = mid - 1; // 目标在左半边
}
if (cmpCnt) *cmpCnt = cnt;
return -1;
}
/* ============ 折半查找:递归版 ============
递归深度 = 判定树高度 = ⌈log2(n+1)⌉,所以空间 O(log n)(栈帧) */
int binarySearchRec(const vector<int>& a, int key, int left, int right) {
if (left > right) return -1; // 区间为空 → 失败
int mid = left + (right - left) / 2;
if (a[mid] == key) return mid;
if (a[mid] < key) return binarySearchRec(a, key, mid + 1, right);
return binarySearchRec(a, key, left, mid - 1);
}
/* ============ 用递归版数判定树层数与 ASL(就是"逐层统计") ============ */
// 在判定树上,比较次数 = 结点所在层号(根为第 1 层)
int cntAtLevel(const vector<int>& a, int key, int left, int right, int depth) {
if (left > right) return 0; // 落到外部结点,表示失败
int mid = left + (right - left) / 2;
if (a[mid] == key) return depth; // 成功:返回所在层号
if (a[mid] < key) return cntAtLevel(a, key, mid + 1, right, depth + 1);
return cntAtLevel(a, key, left, mid - 1, depth + 1);
}
int main() {
/* 造一个 n = 11 的有序表,方便和正文的判定树对照(值 10,20,...,110) */
vector<int> a;
for (int i = 1; i <= 11; ++i) a.push_back(i * 10);
int cmp = 0;
cout << binarySearch(a, 70, &cmp) << " 比较次数=" << cmp << "\n"; // 6 比较次数=1
cout << binarySearch(a, 110, &cmp) << " 比较次数=" << cmp << "\n"; // 10 比较次数=4
cout << binarySearch(a, 75, &cmp) << " 比较次数=" << cmp << "\n"; // -1 比较次数=4
cout << binarySearchRec(a, 10, 0, 10) << "\n"; // 0
/* ---- 逐元素统计"成功比较次数",用程序验证 ASL = 33/11 = 3 ----
注意:判定树的形状只由"下标"决定,与元素的值无关。
下面是本程序对 n = 11 实际算出的层号表(与正文判定树完全一致):
下标 : 0 1 2 3 4 5 6 7 8 9 10
层号 : 3 4 2 3 4 1 3 4 2 3 4
逐层汇总:第1层1个 + 第2层2个 + 第3层4个 + 第4层4个
比较次数总和 = 1*1 + 2*2 + 3*4 + 4*4 = 33 → ASL = 33/11 = 3 */
const int Lv[11] = {3, 4, 2, 3, 4, 1, 3, 4, 2, 3, 4};
int total = 0, cntLevel[5] = {0, 0, 0, 0, 0};
for (int i = 0; i < 11; ++i) {
int lv = cntAtLevel(a, a[i], 0, 10, 1); // 查"下标 i 上的值",就能拿到下标 i 的层号
total += lv;
++cntLevel[lv];
cout << "a[" << i << "]=" << a[i] << " 层号=" << lv
<< "(表里写的是 " << Lv[i] << ")\n";
}
for (int d = 1; d <= 4; ++d)
cout << "第 " << d << " 层有 " << cntLevel[d] << " 个结点\n";
cout << "ASL_success = " << total << "/11 = " << (double)total / 11 << "\n"; // 33/11 = 3
return 0;
}
mid 的下标序列决定,
与元素的值毫无关系。请务必亲手把上表的 11 个层号推一遍(提示:每层先定 mid,再递归左右子区间), 能独立推出
1×1 + 2×2 + 3×4 + 4×4 = 33,这个考点就彻底拿下了。
10.3.3 判定树(比较树):折半查找的灵魂
折半查找的执行过程可以完整地用一棵二叉树表示,这棵树叫判定树(decision tree) 或比较树(comparison tree)。构造规则只有三条,务必背下来:
- 取整个区间的
mid作为根结点(根上写的是元素值,但它的身份是「下标 mid」); - 左子树递归地由左半区间
[left, mid−1]构造,右子树由右半区间[mid+1, right]构造; - 区间为空的地方挂一个方形外部结点(失败结点),表示查找不成功。
因为 mid 总是取中点,左右子区间的长度最多相差 1,
所以:折半查找的判定树一定是一棵平衡二叉树
(更准确地说:任一结点的左右子树高度差不超过 1,且所有外部结点只出现在最后两层)。
这个性质可以推出树高:
推导思路:层数为 h 的二叉树最多有 2h − 1 个结点,
要让 n 个内部结点都放得下,需要 2h − 1 ≥ n,
即 h ≥ log2(n+1),取整得 h = ⌈log2(n+1)⌉。
而折半查找的判定树恰好是「尽量填满」的形状,所以它取到了这个下界——
这就是折半查找最优的根本原因。
| 层号(= 比较次数) | 该层结点数 | 对应的下标(mid 序列) | 元素值 | 贡献 = 层号 × 个数 |
|---|---|---|---|---|
| 1 | 1 | 5 | 60 | 1 × 1 = 1 |
| 2 | 2 | 2、8 | 30、90 | 2 × 2 = 4 |
| 3 | 4 | 0、3、7、10 | 10、40、80、110 | 3 × 4 = 12 |
| 4 | 4 | 1、4、6、9 | 20、50、70、100 | 4 × 4 = 16 |
| 合计(等概率,每个元素查找概率 1/11) | 33 | |||
10.3.4 不成功 ASL:把外部结点数清楚
查找不成功时,算法会一路走到某个空区间,对应判定树上的一个外部结点(方形结点)。
有几个空区间?n + 1 个——因为 n 个内部结点把有序数组切成了 n+1 段空隙,
每段空隙对应一个外部结点。等概率假设下,ASL失败 就是所有外部结点深度的平均值。
外部结点的深度怎么数?规则是:外部结点的深度 = 它的父结点(内部结点)的层号 + 1, 也就是「走到那个死胡同之前,一共和多少个元素比过」。 对 n = 11 逐个统计(内部结点分四层,个数 1、2、4、4):
| 父结点所在层 | 父结点个数 | 每个父结点挂的外部结点数 | 外部结点深度 | 贡献 |
|---|---|---|---|---|
| 第 4 层(叶子) | 4 | 2(左右各一个空区间) | 5 | 4 × 2 × 5 = 40 |
| 第 3 层 | 4 | 1(只有一侧是空的:10 与 40、80 与 110 之间没有元素) | 4 | 4 × 1 × 4 = 16 |
| 第 1、2 层 | 3 | 0 | — | 0 |
| 合计:8 + 4 = 12 个外部结点,深度总和 56 | 56 | |||
ASL失败 ≤ ⌈log2(n+1)⌉,
而 ASL成功 ≈ log2(n+1) − 1,
失败的 ASL 总比成功的 ASL 大一些(因为失败要多走一层到空结点)。
n = 11 的例子正好印证:3 < 3.73 < 4 = ⌈log212⌉。
做题时如果算出失败 ASL 比成功 ASL 小,说明你数错了。
10.3.5 任意 n 的 ASL 公式
设 h = ⌈log2(n+1)⌉ 为判定树层数。满二叉树情形(n = 2h − 1)
最容易推:第 i 层有 2i−1 个结点,于是
代入 n = 2h − 1 化简得 ASL ≈ h − 1 + 1/n → log2(n+1) − 1。
对一般的 n,判定树的最下面一层没填满,结论仍是:
| n | h = ⌈log2(n+1)⌉ | 各层结点数 | ASL成功 精确值 | log2(n+1) − 1 |
|---|---|---|---|---|
| 1 | 1 | 1 | 1 | 0 |
| 3 | 2 | 1, 2 | (1+2×2)/3 = 5/3 ≈ 1.67 | 1 |
| 7 | 3 | 1, 2, 4 | (1+4+12)/7 = 17/7 ≈ 2.43 | 2 |
| 11 | 4 | 1, 2, 4, 4 | 33/11 = 3 | 2.585 |
| 15 | 4 | 1, 2, 4, 8 | (1+4+12+32)/15 = 49/15 ≈ 3.27 | 3 |
观察:n = 2h−1(满树)时 ASL 恰好等于 h − 1 + 1/n,与 log2(n+1) − 1 几乎重合;
非满树时精确值略大于估算值。考试写 ASL ≈ log2(n+1) − 1 一般都给分。
10.3.6 折半查找的局限:为什么需要树和 B 树
折半查找 O(log n) 已经很快了,那为什么还要发明二叉排序树、平衡二叉树、B 树、跳表? 因为折半查找有三个致命限制:
① 插入删除 O(n)
在有序数组中间插一个元素,后面所有元素都要后移。删除同理。 动态查找表(增删频繁)用它是灾难。
② 必须整块连续内存
数据量达到内存放不下(几十 GB)时无法一次装进数组, 只能放到磁盘上——而磁盘按「块」读取,折半查找的访问模式跳跃太大。
③ 只能回答「在不在」
要支持范围查询、前驱后继、排名统计等操作, 数组需要额外结构,而平衡树天然支持。
对应的解决方案(本讲只做地图式介绍,细节留给后续章节):
| 结构 | 解决什么问题 | 查找 | 插入 / 删除 | 备注 |
|---|---|---|---|---|
| 二叉排序树 BST | 动态插入删除 | 平均 O(log n),最坏 O(n) | O(树高) | 会退化成链表,所以不够 |
| 平衡二叉树 AVL / 红黑树 | 防止退化 | O(log n) 稳定 | O(log n)(含旋转) | 内存中的有序容器(std::map) |
| B 树 / B+ 树 | 磁盘 I/O 次数最少 | O(logmn) | O(logmn) | 数据库索引、文件系统的事实标准 |
| 跳表 skip list | 用随机化代替旋转 | 期望 O(log n) | 期望 O(log n) | Redis 的 zset 用它;实现比红黑树简单 |
| 散列表 | 不要有序,只要「秒查」 | 期望 O(1) | 期望 O(1) | 不支持范围查询 / 有序遍历——本讲 10.7 |
10.3.7 复杂度 O(log n) 的推导
为什么折半查找是 O(log n)?两种等价的说法,都要会说:
说法一(区间长度):设第 t 轮结束后区间长度为 Lt,
初始 L0 = n,每轮砍半 Lt = Lt−1 / 2 = n / 2t。
区间长度为 0(或 1)时结束,即 n / 2t ≤ 1,
解得 t ≥ log2n,所以最多 ⌈log2(n+1)⌉ 轮。
说法二(判定树高度):每比较一次就下降一层,比较次数 = 结点层号 ≤ 树高
h = ⌈log2(n+1)⌉,因此最坏比较次数是 O(log n)。
std::find 就是纯线性扫描。
这也是为什么 std::sort 在小区间切换成插入排序。
10.3.8 折半查找的四个变体
折半查找真正的威力在于「二分」这个思想,而不是「找等于 key 的元素」这一件事。 下面四种变体在竞赛和工程中出现的频率远高于原版:
变体 1:lower_bound —— 第一个 ≥ x 的位置
二分时不提前返回:a[mid] < x 说明 mid 及其左边都不可能是答案,left = mid + 1;
否则 mid 本身可能就是答案,right = mid。循环到 left == right 为止。
这就是 C++ 的 std::lower_bound。
变体 2:upper_bound —— 第一个 > x 的位置
与 lower_bound 只差一个等号:把 a[mid] < x 改成 a[mid] <= x。
变体 3:旋转数组中的查找
数组原本递增,被从某处旋转(如 [4,5,6,7,0,1,2])。此时数组整体无序,
但 mid 一定把区间分成「一半有序、一半可能无序」。判断哪一半有序,再看 target 是否落在
那一半的值域内,就能决定去哪边。复杂度仍是 O(log n)。这个题是面试高频。
变体 4:二分答案
这是二分思想的终极形态:当答案具有单调性(即「x 可行 ⟹ 所有 > x 也可行」),
就可以不二分数组,而是二分答案本身:
写一个 check(x) 判定函数,然后在答案值域上二分。
经典例子:木棍切割(切出 k 段等长木棍的最大长度)、
「最小化最大值 / 最大化最小值」类问题、二分 + 图论判定。
第 13 讲会专门展开。
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
/* ================= 变体 1:lower_bound =================
返回第一个 >= x 的元素下标;若全部 < x,返回 n
写法要点:循环条件是 left < right,mid 不需要 +1(左闭右开区间) */
int lowerBound(const vector<int>& a, int x) {
int left = 0, right = (int)a.size(); // 区间 [left, right)
while (left < right) {
int mid = left + (right - left) / 2;
if (a[mid] < x) left = mid + 1; // mid 及其左边全部 < x
else right = mid; // mid 可能是答案,保留
}
return left;
}
/* ================= 变体 2:upper_bound =================
返回第一个 > x 的元素下标。与 lower_bound 只差一个 "=" */
int upperBound(const vector<int>& a, int x) {
int left = 0, right = (int)a.size();
while (left < right) {
int mid = left + (right - left) / 2;
if (a[mid] <= x) left = mid + 1;
else right = mid;
}
return left;
}
/* ================= 变体 3:旋转数组中的查找 =================
如 {4,5,6,7,0,1,2}:mid 两侧必有一侧是"升序"的,据此判断去哪边 */
int searchRotated(const vector<int>& a, int target) {
int left = 0, right = (int)a.size() - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (a[mid] == target) return mid;
if (a[left] <= a[mid]) { // 左半段 [left, mid] 有序
if (a[left] <= target && target < a[mid]) right = mid - 1;
else left = mid + 1;
} else { // 右半段 [mid, right] 有序
if (a[mid] < target && target <= a[right]) left = mid + 1;
else right = mid - 1;
}
}
return -1;
}
/* ================= 变体 4:二分答案 =================
例题:有 n 根木棍长度 L[i],要切出 k 段长度相等的木棍,求每段的最大长度(整数)
单调性:若长度 x 可行,则所有 < x 的长度也可行 → 可以二分 x
check(x):每根木棍能切出 L[i]/x 段,总和 >= k 即可行 */
bool check(const vector<int>& L, int k, int x) {
if (x <= 0) return false;
long long cnt = 0;
for (int len : L) cnt += len / x;
return cnt >= k;
}
int maxPieceLength(const vector<int>& L, int k) {
int lo = 1, hi = 0;
for (int len : L) hi = max(hi, len); // 答案上界:最长的那根
int ans = 0;
while (lo <= hi) { // 在"答案值域"上二分
int mid = lo + (hi - lo) / 2;
if (check(L, k, mid)) { ans = mid; lo = mid + 1; } // 可行 → 试试更长
else hi = mid - 1; // 不可行 → 缩短
}
return ans;
}
int main() {
vector<int> a = {1, 3, 3, 5, 5, 5, 7, 9}; // 有序,含重复
cout << lowerBound(a, 5) << "\n"; // 3(第一个 5)
cout << upperBound(a, 5) << "\n"; // 6(第一个 > 5)
cout << upperBound(a, 5) - lowerBound(a, 5) << "\n"; // 3:5 出现 3 次
cout << lowerBound(a, 8) << "\n"; // 7(值是 9 的位置)
cout << lowerBound(a, 100) << "\n"; // 8 = n,表示"不存在 >= 100 的元素"
/* 与标准库结果对照 */
cout << (lower_bound(a.begin(), a.end(), 5) - a.begin()) << "\n"; // 3
cout << (upper_bound(a.begin(), a.end(), 5) - a.begin()) << "\n"; // 6
vector<int> r = {4, 5, 6, 7, 0, 1, 2};
cout << searchRotated(r, 0) << "\n"; // 4
cout << searchRotated(r, 3) << "\n"; // -1
vector<int> L = {802, 743, 457, 539};
cout << maxPieceLength(L, 11) << "\n"; // 200
return 0;
}
10.4 插值查找:把 mid 按比例挪一挪
10.4.1 一句话本质
查英文词典时找 "zebra",你会直接翻到最后几页,而不是从中间开始二分。
为什么?因为你知道单词表是按字母均匀分布的,"z" 一定在后面。
插值查找(interpolation search)就是把这个直觉写成公式:
既然数据大致均匀,mid 就不该取中点,而应该按 key 在值域中的比例来定位置。
拆开看这个式子很好理解:(key − a[low]) / (a[high] − a[low]) 是
「key 在数值轴上位于区间里的第几成」,乘上区间长度 (high − low) 就得到了「下标轴上该走多远」。
当 key 靠近 a[low] 时比例接近 0 → mid 靠近 low;
靠近 a[high] 时比例接近 1 → mid 靠近 high。
10.4.2 为什么均匀分布下能接近 O(log log n)
均匀分布时,a[i] 与下标 i 近似成线性关系,
插值公式算出的 mid 落点与 key 真实位置的偏差服从一个均值为 0 的分布,
标准差的量级是 O(√n)。
每轮把「位置误差」从 n 降到 √n,再降到 n1/4……
要做多少次才能降到 1?解 n1/2t = 1 得
t = log2log2n,于是:
直观感受一下量级差:n = 109 时,
log2n ≈ 30 次,而 log2log2n ≈ 5 次。
代价是:这个好结论几乎只在「数据严格均匀」时才成立,
而真实数据(用户 ID、时间戳、ZIP 码)几乎从不均匀。所以工业界很少直接用插值查找,
它更多作为一个「知道有这回事」的知识点,以及在某些特化场景(如按时间均匀采样的日志)里的优化。
10.4.3 实现要点:除零、越界与 key 超范围
插值查找的代码只有几行,但坑特别多,四个必须处理:
| 坑 | 什么时候发生 | 处理办法 |
|---|---|---|
| 除零 | a[high] == a[low](区间内所有值相同,或有大量重复) | 先判 a[high] != a[low],相等时直接线性收尾或退化为 mid = low |
| mid 越界 | 公式算出负数或 > high(key 很小或很大时) | 算完 mid 后 clamp 到 [low, high] |
| key 超出值域 | key < a[low] 或 key > a[high] | 先判并直接返回失败,不要让它进公式 |
| 整数除法截断 | 比例小于 1/(high−low) 时结果恒为 0 | 先乘后除:(key−a[low]) * (high−low) / (a[high]−a[low]),且用 long long 防溢出 |
#include <iostream>
#include <vector>
using namespace std;
/* =====================================================================
插值查找:适用于"关键字大致均匀分布"的有序表
mid = low + (key − a[low]) / (a[high] − a[low]) × (high − low)
返回下标,找不到返回 -1
===================================================================== */
int interpolationSearch(const vector<long long>& a, long long key, int* cmpCnt = nullptr) {
int low = 0, high = (int)a.size() - 1, cnt = 0;
while (low <= high) {
/* ---- 坑 3:key 超出当前区间的值域,直接判定失败 ---- */
if (key < a[low] || key > a[high]) { if (cmpCnt) *cmpCnt = cnt; return -1; }
/* ---- 坑 1:所有值都相同会让分母为 0 ---- */
if (a[high] == a[low]) {
++cnt;
if (a[low] == key) { if (cmpCnt) *cmpCnt = cnt; return low; }
if (cmpCnt) *cmpCnt = cnt;
return -1;
}
/* ---- 坑 4:先乘后除 + long long,避免整数截断与溢出 ---- */
long long pos = low + (key - a[low]) * (long long)(high - low) / (a[high] - a[low]);
/* ---- 坑 2:clamp,防止浮点式的越界 ---- */
if (pos < low) pos = low;
if (pos > high) pos = high;
int mid = (int)pos;
++cnt;
if (a[mid] == key) { if (cmpCnt) *cmpCnt = cnt; return mid; }
else if (a[mid] < key) low = mid + 1;
else high = mid - 1;
}
if (cmpCnt) *cmpCnt = cnt;
return -1;
}
int main() {
/* ---------- 测试 1:均匀数据(等差),插值查找的主场 ---------- */
vector<long long> uni;
for (int i = 0; i < 11; ++i) uni.push_back(1 + 10LL * i); // 1,11,...,101
int c1 = 0, c2 = 0;
cout << "均匀数据查 31 → 下标 " << interpolationSearch(uni, 31, &c1)
<< ",比较 " << c1 << " 次\n"; // 3,1 次
/* 折半查找对照 */
{
int lo = 0, hi = 10; c2 = 0;
while (lo <= hi) {
int mid = lo + (hi - lo) / 2; ++c2;
if (uni[mid] == 31) break;
if (uni[mid] < 31) lo = mid + 1; else hi = mid - 1;
}
cout << "折半查找查 31 → 比较 " << c2 << " 次\n"; // 4 次
}
/* ---------- 测试 2:指数分布,插值查找的滑铁卢 ---------- */
vector<long long> expo;
for (int i = 0; i < 11; ++i) expo.push_back(1LL << i); // 1,2,4,...,1024
int c3 = 0;
cout << "指数数据查 3(不存在)→ " << interpolationSearch(expo, 3, &c3)
<< ",比较 " << c3 << " 次\n"; // -1,却要比较 2 次
/* 注意:这里因为"key > a[high] 就返回"和 clamp,比较次数看起来不多,
但请把 key 换成 513 这种"卡在中间"的值再试,你会看到 mid 每次只挪一格。 */
int c4 = 0;
cout << "指数数据查 513 → " << interpolationSearch(expo, 513, &c4)
<< ",比较 " << c4 << " 次\n";
/* ---------- 测试 3:全相同元素(除零坑) ---------- */
vector<long long> same(5, 7);
int c5 = 0;
cout << "全 7 的表查 7 → " << interpolationSearch(same, 7, &c5) << ",比较 " << c5 << " 次\n";
cout << "全 7 的表查 9 → " << interpolationSearch(same, 9, &c5) << "\n";
return 0;
}
下面动画把同一组数据上的插值查找与折半查找并排跑一遍,注意看两者的 mid 怎么走:
10.5 斐波那契查找:只用加减法的黄金分割
10.5.1 为什么用斐波那契数列划分
斐波那契查找(Fibonacci search)是折半查找的一个变体,它把「砍一半」换成 按黄金分割比 0.618 划分。两个理由:
- 数学上更优的分割点:斐波那契数列相邻两项之比
F[k−1]/F[k]收敛到1/φ ≈ 0.618。理论上可以证明,在最坏情况下黄金分割是「平均比较次数」意义下的最优划分比例 (0.618 与 0.5 的差别很小,所以提升有限)。 - 计算上更便宜:
mid = low + F[k−1] − 1只用加减法, 而折半查找要(low + high) / 2(一次加法 + 一次除法或移位), 插值查找更狠(乘除法各一次)。 在早期 CPU 上除法要几十个时钟周期;即使今天,在把除法做到很慢的嵌入式芯片上, 斐波那契查找仍有价值。
换来的代价是:需要预先算一张斐波那契表,还要把数组「补齐」到特定长度,实现更啰嗦。 所以工程里几乎没人用它——它是典型的「考试 / 教材知识点」。
① 斐波那契查找的平均性能略优于折半查找;
② 但它的最坏情况时间复杂度仍然是 O(log n),与折半同阶,只是常数略小;
③ 它同样要求顺序存储 + 有序;
④ 它不需要除法,只需要加减法。这就是它存在的全部理由。
10.5.2 算法步骤与「补齐」过程
完整流程分三步,第二步「补齐」是理解的关键:
- 找 F[k]:找出满足
F[k] − 1 ≥ n的最小斐波那契数F[k]; - 补齐数组:把数组长度从
n扩到F[k] − 1, 多出来的位置全部填原数组的最后一个元素(因为有序,填最后一个不会破坏有序性); - 迭代:令
mid = low + F[k−1] − 1,比较后按分支调整low / high与k(下面详述)。
10.5.3 mid 公式的推导与 k 的调整规则
为什么是 mid = low + F[k−1] − 1?因为我们要让 mid 恰好把长度
F[k] − 1 的区间切成 (F[k−1] − 1) + 1 + (F[k−2] − 1)。
mid 自己是第 1 个,左边要放 F[k−1] − 1 个元素,
所以 mid 相对 low 的偏移是 F[k−1] − 1,即
mid = low + F[k−1] − 1。用 n = 11、k = 7 验算:mid = 0 + F[6] − 1 = 0 + 8 − 1 = 7,
左块下标 0~6 共 7 = F[6] − 1 个,右块下标 8~11 共 4 = F[5] − 1 个。完美。
三分支与 k 的调整:
| 比较结果 | 新区间 | 新长度 | k 怎么变 | 为什么 |
|---|---|---|---|---|
key == a[mid] | — | — | 结束 | 命中 |
key < a[mid](往左) |
[low, mid−1] | F[k−1] − 1 |
k = k − 1 |
左块长度对应下标 k−1 |
key > a[mid](往右) |
[mid+1, high] | F[k−2] − 1 |
k = k − 2 |
右块长度对应下标 k−2(不是 k−1!) |
F[k−1] 那一档,所以 k−1;
往右走,mid 连同左边整块都被扔掉,只剩 F[k−2] 那一档,所以 k−2。
写代码时如果写成 k -= 1,程序不会报错但结果全错(会漏掉元素)。
10.5.4 完整 C++ 实现
#include <bits/stdc++.h>
using namespace std;
/* =====================================================================
斐波那契查找(Fibonacci search)—— 竞赛写法:全局数组 + 自由函数
1) 预处理斐波那契数列,找最小的 k 使 F[k] - 1 >= n
2) 把数组补齐到 F[k] - 1,多出的位置填原数组最后一个元素
3) mid = low + F[k-1] - 1;往左 k -= 1,往右 k -= 2(关键!不是 k -= 1)
前提:有序 + 顺序存储。平均性能略优于折半查找,最坏同为 O(log n)。
数组大小依据:MAXN = 1e5 + 5 够放 1e5 个元素;F[45] 已是 11 亿,MAXF 取 64 绰绰有余
===================================================================== */
const int MAXN = 100005; // 主表容量(按题目 n <= 1e5 开)
const int MAXF = 64; // 斐波那契表容量
int a[MAXN]; // 待查找的有序表,元素在 a[0..n-1]
int F[MAXF]; // F[0]=0, F[1]=1, F[2]=1, F[3]=2, F[4]=3, ...
int fsz = 0; // 斐波那契表实际长度
/* ---------- 预处理斐波那契数列,直到最后一项 >= maxN ---------- */
void buildFib(int maxN) {
F[0] = 0; F[1] = 1; fsz = 2;
while (F[fsz - 1] < maxN && fsz < MAXF - 1) {
F[fsz] = F[fsz - 1] + F[fsz - 2];
++fsz;
}
}
/* ---------- 斐波那契查找:返回下标,找不到返回 -1;cmpCnt 输出比较次数 ---------- */
int fibSearch(int n, int key, int *cmpCnt = nullptr) {
if (cmpCnt) *cmpCnt = 0;
if (n == 0) return -1;
/* ---- 第 1 步:找最小的 k 使 F[k] - 1 >= n ---- */
int k = 2;
while (k < fsz && F[k] - 1 < n) ++k;
if (k >= fsz || F[k] - 1 > MAXN) return -1; // 斐波那契表 / 数组不够大
/* ---- 第 2 步:补齐到 F[k] - 1,多出来的位置复制最后一个元素 ---- */
static int t[MAXN];
int len = F[k] - 1;
for (int i = 0; i < n; ++i) t[i] = a[i];
for (int i = n; i < len; ++i) t[i] = a[n - 1]; // 有序性不受影响
int low = 0, high = n - 1, cnt = 0; // high 始终是"原数组"的右界
while (low <= high) {
int mid = low + F[k - 1] - 1; // 核心公式:左边恰好留 F[k-1]-1 个
if (mid >= len) mid = len - 1; // 兜底,正常不会触发
++cnt;
if (key < t[mid]) {
high = mid - 1;
k -= 1; // 左块:长度 F[k-1]-1
} else if (key > t[mid]) {
low = mid + 1;
k -= 2; // 右块:长度 F[k-2]-1(关键!)
} else {
if (cmpCnt) *cmpCnt = cnt;
return mid < n ? mid : n - 1; // 落在补齐区 → 映射回最后一个元素
}
if (k < 1) break; // 防越界
}
if (cmpCnt) *cmpCnt = cnt;
return -1;
}
int main() {
buildFib(2000);
printf("F = ");
for (int i = 1; i <= 10; ++i) printf("%d ", F[i]); // 1 1 2 3 5 8 13 21 34 55
printf("\n");
int init[11] = {10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110}; // n = 11
int n = 11;
for (int i = 0; i < n; ++i) a[i] = init[i];
int c = 0, pos = 0;
pos = fibSearch(11, 10, &c);
printf("查 10 → 下标 %d,比较 %d 次\n", pos, c);
pos = fibSearch(11, 70, &c);
printf("查 70 → 下标 %d,比较 %d 次\n", pos, c);
pos = fibSearch(11, 110, &c);
printf("查 110 → 下标 %d,比较 %d 次\n", pos, c);
pos = fibSearch(11, 75, &c);
printf("查 75 → 下标 %d,比较 %d 次\n", pos, c);
pos = fibSearch(11, 5, &c);
printf("查 5 → 下标 %d,比较 %d 次\n", pos, c);
return 0;
}
10.5.5 三种查找方法的对比
| 对比项 | 折半查找 | 插值查找 | 斐波那契查找 |
|---|---|---|---|
| 分割点公式 | mid = low + (high−low)/2 |
mid = low + (key−a[low])/(a[high]−a[low])×(high−low) |
mid = low + F[k−1] − 1 |
| 分割比例 | 1 : 1(0.5) | 按 key 比例自适应 | 0.618 : 0.382(黄金分割) |
| 用到的运算 | 加法 + 除法 / 移位 | 减法 + 乘法 + 除法 | 只有加减法 |
| 适用数据分布 | 任意有序数据都稳定 | 必须近似均匀分布才快 | 任意有序数据都稳定 |
| 平均时间复杂度 | O(log n) | 均匀时 O(log log n),偏斜时 O(n) | O(log n),常数略小 |
| 最坏时间复杂度 | O(log n) | O(n) | O(log n) |
| 额外空间 | O(1) | O(1) | O(log n) 斐波那契表 + 补齐数组 O(n) |
| 实现难度 | ★ 简单 | ★★ 中等(除零 / 越界坑多) | ★★★ 较繁(补齐 + k 调整) |
| 典型应用 | 一切有序数组查找;std::lower_bound | 几乎均匀的键值(时间戳、自增 ID 的映射表) | 教科书 / 考试;除法代价极高的嵌入式环境 |
10.6 分块查找:块间有序、块内无序
10.6.1 一句话本质与索引表
分块查找(block search)又叫索引顺序查找(indexed sequential search), 它的本质是一句话:把表切成若干块,块与块之间有序,块内部无序; 先查索引表定位到块,再在块内顺序查找。
它要求把数据组织成两部分:
- 主表:n 个元素被均分成 b 块(最后一块可能不满),每块有 s 个元素(块长);
- 索引表:b 个表项,第 i 项记录「第 i 块的最大关键字」以及「第 i 块的起始下标」。
「块间有序」的准确含义是:第 i 块中的所有关键字都小于第 i+1 块中的最小关键字。 注意它不要求块内有序——这正是分块查找区别于「归并」的地方, 也是它能兼顾「插入方便」的原因:往块内随便一塞即可(只要不破坏块间有序)。
10.6.2 ASL 推导:为什么最优块长是 √n
设表长 n,均分为 b 块,每块 s 个元素,则 n = b × s。
在「各元素等概率被查找」以及「key 落在各块的概率相等」的假设下,查找代价由两段组成:
- 索引查找:如果索引表用顺序查找,找到第
i块平均要(b+1)/2次比较; 如果用折半查找,约⌈log2(b+1)⌉次。 - 块内查找:块内无序 → 只能用顺序查找,平均
(s+1)/2次。
把 b = n/s 代入,得到一个关于 s 的函数,求最小值:
由均值不等式 n/s + s ≥ 2√n(等号在 n/s = s 即 s = √n 时成立),所以:
例如 n = 10000:顺序查找要 5000 次,折半要 14 次,分块只要 √10000 + 1 = 101 次。
它不如折半,但分块查找的插入删除比折半便宜得多(只需在块内找位置搬移局部元素),
这就是它存在的价值。
如果把索引表也换成折半查找(索引表本身是有序的,完全可以):
这时最优块长不再是 √n,而应让「索引层数」与「块内长度」平衡,
理论最优满足 s ≈ ln 2 · log2n,但考试一般不深究,
记住「顺序索引时 s = √n、ASL ≈ √n + 1」就够了。
ASL = ASL索引 + ASL块内,两段分别算再相加;② 顺序查找索引时,最优块长 s = √n,此时
ASL = √n + 1;③ 「块间有序、块内无序」是它和折半查找、和归并排序的分水岭—— 块内若也有序,块内就能用折半,但插入就要搬移,就失去了分块的意义。
10.6.3 C++ 实现:索引表 + 块内顺序 / 折半查找
#include <bits/stdc++.h>
using namespace std;
/* =====================================================================
分块查找(索引顺序查找)—— 竞赛写法:全局数组 + 自由函数
- 主表分成 b 块,每块 s 个元素;块间有序(后一块的最小值 > 前一块的最大值)
- 块内无序 → 块内只能顺序查找;若块内也有序,可把 findInBlock 换成折半
- 索引表存每块的最大关键字 idxMax[] 与起始下标 idxStart[]
- 核心结论:ASL = ASL索引 + ASL块内;
顺序查找索引时最优块长 s = √n,此时 ASL ≈ √n + 1(两段各 √n/2 与 s/2 相加取最小)
返回 (块号, 块内偏移),失败返回 (-1, -1)
数组大小依据:MAXN = 1e5 + 5 放主表;MAXB = 1005 够放 √(1e5) ≈ 317 个块
===================================================================== */
const int MAXN = 100005; // 主表容量
const int MAXB = 1005; // 索引表(块数)容量
int dat[MAXN]; // 主表:各块首尾相接拼在一起
int idxMax[MAXB]; // 索引表:第 i 块的最大关键字
int idxStart[MAXB]; // 索引表:第 i 块在主表中的起始下标
int blen[MAXB]; // 第 i 块的实际长度
int bcnt = 0; // 块数 b
int n = 0; // 主表元素个数 n
/* ---------- 建索引表:把 dat[0..n-1] 每 s 个切成一块 ---------- */
void buildBlocks(int s) {
bcnt = 0;
for (int st = 0; st < n; st += s) {
int len = min(s, n - st); // 最后一块可能不满
int mx = dat[st];
for (int i = st + 1; i < st + len; ++i) mx = max(mx, dat[i]);
idxStart[bcnt] = st;
blen[bcnt] = len;
idxMax[bcnt] = mx; // 索引项 = 该块最大关键字
/* 块间有序的校验:第 i 块的最大值必须小于第 i+1 块的最小值 */
if (bcnt > 0 && idxMax[bcnt] < idxMax[bcnt - 1])
printf("警告:块 %d 破坏了块间有序!\n", bcnt);
++bcnt;
}
}
/* ---------- ① 索引表用顺序查找:ASL_index = (b+1)/2 ---------- */
int locateSeq(int key) {
for (int i = 0; i < bcnt; ++i)
if (key <= idxMax[i]) return i; // 第一个"能吃下"key 的块
return -1;
}
/* ---------- ② 索引表用折半查找:ASL_index = ⌈log2(b+1)⌉ ---------- */
int locateBinary(int key) {
int lo = 0, hi = bcnt - 1, ans = -1;
while (lo <= hi) {
int mid = lo + (hi - lo) / 2;
if (idxMax[mid] >= key) { ans = mid; hi = mid - 1; } // 记录并继续往左找
else lo = mid + 1;
}
return ans;
}
/* ---------- 块内顺序查找,返回"块内偏移",找不到返回 -1 ---------- */
int findInBlock(int b, int key) {
for (int i = idxStart[b]; i < idxStart[b] + blen[b]; ++i)
if (dat[i] == key) return i - idxStart[b];
return -1;
}
/* ---------- 两种索引方式的两段式查找:先定块,再块内找 ---------- */
pair<int, int> searchSeqIndex(int key) { // ① 顺序索引版
int b = locateSeq(key);
if (b < 0) return make_pair(-1, -1);
int off = findInBlock(b, key);
return off < 0 ? make_pair(-1, -1) : make_pair(b, off);
}
pair<int, int> searchBinIndex(int key) { // ② 折半索引版
int b = locateBinary(key);
if (b < 0) return make_pair(-1, -1);
int off = findInBlock(b, key);
return off < 0 ? make_pair(-1, -1) : make_pair(b, off);
}
void printIndex() {
printf("索引表(块间有序):\n");
for (int i = 0; i < bcnt; ++i)
printf(" 块%d: maxKey=%-3d start=%-3d len=%d\n", i, idxMax[i], idxStart[i], blen[i]);
}
int main() {
/* 块间有序、块内无序:n = 16, b = 4, s = 4 */
int init[16] = {22, 12, 13, 8, // 第 0 块,max = 22
33, 48, 25, 30, // 第 1 块,max = 48
60, 86, 71, 55, // 第 2 块,max = 86
99, 95, 88, 90}; // 第 3 块,max = 99
n = 16;
for (int i = 0; i < n; ++i) dat[i] = init[i];
buildBlocks(4);
printIndex();
int keys[5] = {48, 8, 99, 50, 100};
for (int i = 0; i < 5; ++i) {
int key = keys[i];
pair<int, int> r1 = searchSeqIndex(key);
pair<int, int> r2 = searchBinIndex(key);
printf("key=%-4d 顺序索引 → 块%d 内偏移%d 折半索引 → 块%d 内偏移%d\n",
key, r1.first, r1.second, r2.first, r2.second);
}
/* 理论 ASL:n = 16, s = 4, b = 4
ASL = (b+1)/2 + (s+1)/2 = 2.5 + 2.5 = 5,与最优块长 s = √n 时的 √n + 1 = 5 吻合 */
printf("理论 ASL(顺序索引) = (4+1)/2 + (4+1)/2 = %.1f\n", (4 + 1) / 2.0 + (4 + 1) / 2.0);
printf("理论 ASL(√n+1) = √16 + 1 = %.1f\n", sqrt(16.0) + 1);
return 0;
}
10.7 哈希表:用计算代替比较
10.7.1 一句话本质与核心术语
前面所有查找方法都建立在「比较」之上,所以有 O(log n) 的下界(基于比较的查找下界是
Ω(log n))。哈希表(散列表,hash table)跳出了这个框:
它不去比较,而是用哈希函数把关键字直接算成一个存储地址,一步到位。
先把术语钉死,后面的所有讨论都建立在这些词上:
- 哈希函数 / 散列函数 H(key)
- 把关键字映射为存储位置的函数。它把关键字空间(可能极大,如所有 18 位身份证号)压缩到地址空间
[0, m−1]。 - 哈希地址 / 散列地址
H(key)算出来的值,也就是数组下标。哈希表本身就是一个「以哈希地址为下标」的数组。- 冲突 / 碰撞(collision)
key1 ≠ key2但H(key1) == H(key2)。这是必然会发生的——由鸽巢原理,只要关键字空间比地址空间大,就一定有两个不同关键字落到同一地址。- 同义词(synonym)
- 发生冲突的那些关键字互称同义词。注意是「互相」的,是一个等价关系。
- 装填因子(load factor)α
α = 表中已存元素个数 n / 哈希表长度 m。它衡量表的「拥挤程度」,是决定哈希表性能的唯一核心参数。
① 设计一个「算得均匀、算得快」的哈希函数,让冲突尽可能少、尽可能随机;
② 设计一个「冲突了怎么办」的处理方法,让冲突发生后依然能正确、高效地存取。
考试里这两件事经常分开考,别混为一谈。
10.7.2 六种哈希函数的构造方法
下面六种方法全部要会写公式、会举例子、会说优缺点。先看总表,再逐个展开。
| 方法 | 公式 | 适用场景 | 是否会有冲突 | 计算量 |
|---|---|---|---|---|
| ① 直接定址法 | H(k) = a·k + b |
关键字连续且分布已知(如年龄 1~120、年份) | 不会(当 a ≠ 0 且地址空间够大时是一一映射) | 极小(一次乘加) |
| ② 除留余数法 | H(k) = k mod p |
最常用,几乎万能;p 取不大于表长的最大质数 | 会(但分布均匀,冲突少) | 小(一次取模) |
| ③ 数字分析法 | 抽取关键字中分布均匀的若干位 | 关键字位数多、且事先知道全体关键字(静态数据) | 会(取决于抽取的位) | 极小(取位 / 移位) |
| ④ 平方取中法 | 取 k2 的中间若干位 |
关键字位数少、不知道分布、希望结果与每一位都有关 | 会(较少) | 中(一次乘法) |
| ⑤ 折叠法 | 把 k 按固定位数分段后相加 |
关键字位数特别多(如超长身份证号、IP+端口) | 会(取决于分段与叠加方式) | 中 |
| ⑥ 随机数法 | H(k) = random(k)(伪随机函数) |
关键字长度不一、分布完全未知;常用于防对手构造 | 会(但对抗性最强) | 较大(一次伪随机变换) |
① 直接定址法:H(k) = a·k + b
取关键字的某个线性函数作为哈希地址。a、b 是常数(a ≠ 0)。
最朴素的形态是 H(k) = k(即 a = 1, b = 0),
例如「统计 1~120 岁的人口数」,直接开一个 cnt[121] 数组,年龄就是下标。
优点:绝对不会有冲突(一一映射),计算最快,查找就是数组随机访问。
缺点:要求关键字集合连续、紧凑;如果关键字是 {1, 1000000, 99999999} 这种,
数组就得开到一亿,空间爆炸。
适用:关键字基数小且连续分布——字符(ASCII 0~127)、月份、年龄段、身份证前 6 位的地区码。
② 除留余数法:H(k) = k mod p —— 最常用
这是工程与考试中出现频率最高的方法。它的思想是「取模把任意大的关键字折叠进 [0, p−1]」。
关键在 p 怎么选,这里有三个必须知道的结论:
- p 一般取不大于表长 m 的最大质数。
例如
m = 13时取p = 13;m = 16时取p = 13(不大于 16 的最大质数); 再比如m = 25取p = 23。这就是本章大量使用m = 11、m = 13作为表长的原因——它们本身就是质数。 - 为什么用质数?因为若
p有小的因子d, 那么所有「相差d的倍数」的关键字都会撞到一起。 最典型的是p取偶数:若p = 2t, 那么H(k)只取决于k的最低t位, 高位全部被丢掉。而真实数据(学号、编号、内存地址)的低位常常高度规律 (比如都是 0、都是 00 结尾),结果就是灾难性的聚集。 - 为什么特意避开 2 的幂?编译器把
k % 2t优化成k & (2t−1), 快是快,但只保留低位。Java 的HashMap就故意用 2 的幂做容量(为了用位运算加速), 代价是必须额外做一次扰动:h ^= (h >>> 16)把高位混进低位。 这正是「用质数」和「用 2 的幂 + 扰动」两条技术路线的分野。
③ 数字分析法:挑出「随机」的那几位
如果关键字是多位数(如学号 2023011307),而我们已经知道全体关键字,
就可以分析每一位的分布:把分布均匀的位抽出来组成地址,把取值集中、重复率高的位扔掉。
例:某班级 30 人的学号形如 2023 01 13 07(年级 + 学院 + 班级 + 序号)。
年级位全是 2023,学院位只有两种取值——这两段扔掉;
班级位和序号位变化丰富——取这两段拼成 1307,再对表长取模。
优点:计算几乎为零(只是取位拼接),针对特定数据能做得非常均匀。 缺点:完全依赖「事先知道全体关键字」,是静态方法;一旦新增的关键字破坏了原来均匀的那几位, 性能立刻崩掉。适用:数据仓库、统计报表这类一次性建表的场景。
④ 平方取中法:让每一位都参与运算
先算 k2,再取中间的若干位作为地址。
例:k = 4731,k2 = 22382361,
取中间 3 位(第 3~5 位)得 382。
为什么取中间位?因为一个数的平方,中间部分受所有输入位的影响——
低位只受 k 的低位影响,高位只受高位影响,都不够「混合」。
取中间位相当于做了一次廉价的「雪崩」。
适用:关键字位数不多、分布未知,又不想做昂贵的除法。经典应用是
早期编译器符号表的哈希、以及某些随机数发生器(middle-square method)。
⑤ 折叠法:把长关键字「折叠」成短地址
把关键字按固定位数(比如 3 位)切成若干段,然后把各段相加,取和的后几位作为地址。 按叠加方式分两种:
- 移位折叠(shift folding):各段对齐后直接相加。
- 分界折叠(folding at the boundaries):奇数段(或偶数段)反向后再相加, 让不同段的不同位也参与进位,混合更充分。
例:k = 123456789,按 3 位分段得 123 | 456 | 789。
移位折叠:123 + 456 + 789 = 1368,取后 3 位 368;
分界折叠:把中间段反向得 123 | 654 | 789,123 + 654 + 789 = 1566,
取后 3 位 566。
适用:关键字位数远大于地址位数——超长身份证号、UUID、哈希串、IP 地址 + 端口。 优点:不需要除法,也不需要事先知道全体数据; 缺点:分段长度和叠加方式需要调参,否则某一段的规律会直接传递到地址里。
⑥ 随机数法:用伪随机函数打散
H(k) = random(k),其中 random 是一个以 k 为种子的确定性伪随机函数
(同样的 k 必须给出同样的结果,否则找不到)。例如 H(k) = (k × 2654435761u) mod m
(这就是著名的 Knuth 乘法散列,乘的是一个接近 232/φ 的奇数)。
适用:关键字长度参差不齐、分布完全未知,尤其适合对抗性场景——
如果哈希函数是公开且确定的,攻击者可以精心构造一堆全部冲突的关键字,
把你的哈希表退化成链表,造成 DoS(这叫 hash flooding 攻击)。
解决办法就是每次程序启动随机换一个种子,让攻击者无法预测。
C++ 的 std::unordered_map 用 std::hash(确定性,会被卡),
而 Python / Rust 的字典默认使用随机种子(Python 的 PYTHONHASHSEED 就是这个)。
#include <iostream>
#include <string>
#include <cstdint>
using namespace std;
/* =====================================================================
六种哈希函数的构造方法,统一返回 [0, m-1] 的地址
===================================================================== */
/* ---------- ① 直接定址法:H(k) = a*k + b ---------- */
// 优点:绝无冲突,O(1);缺点:要求关键字连续紧凑,否则空间爆炸
int hDirect(int key, int a = 3, int b = 7) { return a * key + b; }
/* ---------- ② 除留余数法:H(k) = k mod p,p 取 ≤ m 的最大质数 ---------- */
bool isPrime(int x) {
if (x < 2) return false;
for (int i = 2; (long long)i * i <= x; ++i)
if (x % i == 0) return false;
return true;
}
int largestPrimeLE(int m) { // 不大于 m 的最大质数
for (int p = m; p >= 2; --p) if (isPrime(p)) return p;
return 2;
}
int hMod(int key, int p) { return ((key % p) + p) % p; } // 处理负数
/* ---------- ③ 数字分析法:抽取分布均匀的位 ---------- */
// 例:学号 2023 01 13 07,年级位与学院位是常量 → 只取"班级+序号"两位十进制
int hDigitAnalysis(int studentNo) {
int cls = (studentNo / 100) % 100; // 班级两位
int seq = studentNo % 100; // 序号两位
return cls * 100 + seq; // 得到 0..9999 的地址
}
/* ---------- ④ 平方取中法:取 k*k 的中间若干位 ---------- */
int hMidSquare(int key, int digits = 3) {
long long sq = (long long)key * key;
long long mod = 1;
for (int i = 0; i < digits; ++i) mod *= 10;
/* 去掉低 digits 位后再取 digits 位 → 等价于取"中间" */
return (int)((sq / mod) % mod);
}
/* ---------- ⑤ 折叠法:按 3 位分段相加 ---------- */
int hFoldShift(int key) { // 移位折叠
int sum = 0;
while (key > 0) { sum += key % 1000; key /= 1000; }
return sum % 1000;
}
int hFoldBoundary(int key) { // 分界折叠:交替反向
int seg[16], n = 0;
while (key > 0) { seg[n++] = key % 1000; key /= 1000; }
int sum = 0;
for (int i = 0; i < n; ++i) {
if (i % 2 == 1) { // 奇数段反向
int v = seg[i], rev = 0;
while (v > 0) { rev = rev * 10 + v % 10; v /= 10; }
sum += rev;
} else sum += seg[i];
}
return sum % 1000;
}
/* ---------- ⑥ 随机数法(Knuth 乘法散列,确定性的伪随机) ---------- */
uint32_t hRandom(uint32_t key, uint32_t seed = 0x9E3779B9u) {
uint32_t h = key * 2654435761u; // 2654435761 ≈ 2^32 / φ
h ^= h >> 16;
h *= seed; // 随机种子,防对手构造
h ^= h >> 13;
return h;
}
int main() {
cout << "① 直接定址 H(25) = " << hDirect(25) << "\n"; // 82
int p = largestPrimeLE(16);
cout << "② 表长 16 → 取质数 p = " << p << "\n"; // 13
cout << " H(41) = " << hMod(41, p) << " H(53) = " << hMod(53, p)
<< " H(30) = " << hMod(30, p) << "\n"; // 2 1 4
cout << "③ 数字分析 2023011307 → " << hDigitAnalysis(2023011307) << "\n"; // 1307
cout << "④ 平方取中 4731 → " << hMidSquare(4731) << "\n"; // 22382361 → 382
cout << " 平方取中 1234 → " << hMidSquare(1234) << "\n"; // 1522756 → 522
cout << "⑤ 移位折叠 123456789 → " << hFoldShift(123456789) << "\n"; // 123+456+789=1368 → 368
cout << " 分界折叠 123456789 → " << hFoldBoundary(123456789) << "\n"; // 123+654+789=1566 → 566
cout << "⑥ 随机数法 H(1000) = " << (hRandom(1000) % 100) << "\n";
cout << " 同一 key 结果稳定: " << (hRandom(1000) == hRandom(1000)) << "\n"; // 1
return 0;
}
② 不知道选什么 → 除留余数法 + 质数表长,90% 的场合它都对;
③ 关键字超长(字符串、UUID)→ 先折叠或滚动哈希压成整数,再取模;
④ 有对手(在线判题、公开服务)→ 加随机种子,别用固定的
std::hash。
10.7.3 处理冲突(一):开放定址法
开放定址法(open addressing)的核心思想:所有元素都直接存在哈希表数组里,
一旦 H(key) 被占了,就按照某个「探测序列」在表里另找一个空位。
地址公式统一写成:
不同的 di 就是不同的探测方法:
| 方法 | di 的取法 | 地址公式 | 特点 |
|---|---|---|---|
| 线性探测法 | 1, 2, 3, … |
Hi = (H(k) + i) mod m |
简单;会产生堆积(聚集) |
| 平方探测法 | 12, −12, 22, −22, … |
Hi = (H(k) ± i2) mod m |
缓解堆积;表长须为 4j+3 形式的质数才能探遍全表 |
| 再散列法(双散列) | i × H2(k) |
Hi = (H(k) + i·H2(k)) mod m |
用第二个哈希函数决定步长,几乎消除堆积;计算最贵 |
| 伪随机序列法 | di 取一个伪随机序列 |
Hi = (H(k) + di) mod m |
用同一个种子即可复现,对抗性好;局部性差 |
线性探测法与堆积现象
线性探测就是「这个位子被占了,就去下一个位子;还占着就再下一个」,
到表尾就绕回表头(mod m 自动完成回转)。
堆积(clustering,又称聚集 / 二次聚集)是它最致命的缺陷: 不同的关键字因为探测路径互相重叠,会连成一大片连续的占用区。 一旦形成连续块,任何哈希到这块内任意位置的关键字,都要一路探测到块尾, 于是块越滚越长、越长越慢,形成正反馈。
下面动画逐帧演示这 9 个关键字的每一次探测。注意插入 67 和 42 时那两条越接越长的探测链—— 42 一连失败了 7 次才找到空位,这正是「堆积」最直观的后果:
平方探测法:为什么表长要是 4j+3 的质数
平方探测(二次探测)的探测序列是 0, 1, −1, 4, −4, 9, −9, …,
即 di = ±i2。它不像线性探测那样只往一个方向挤,
而是左右跳着找,因此不会形成连续的堆积块,只是可能形成「同类关键字扎堆」的
二次聚集(secondary clustering)——因为探测序列只取决于起始地址 H(k)。
但它带来一个新问题:能不能保证探测到表里所有位置?答案取决于表长:
m 是形如 4j + 3 的质数
(如 7、11、19、23、31、43…),那么平方探测序列
0, ±12, ±22, … 一定能探测到表中的每一个位置。原因(直观版):当
m 是 4j+3 形式的质数时,
−1 是模 m 的非二次剩余(欧拉判别法可证),
这保证了 12, 22, …, ((m−1)/2)2 在模 m 下
两两不同,再加上 0,恰好能覆盖全部 m 个位置。反例:
m = 12(非质数)时刻意观察 i2 mod 12 只有
0,1,4,9 四个值,只能探到 4 个位置,其余 8 个永远探不到。
另外还有一个实用的「经验定理」:只要表长是质数且装填因子 α ≤ 0.5,
平方探测一定能插入成功(一定能找到空位)。
这就是为什么很多库把开放定址的阈值定得比 0.75 更低。
下面动画把同一串关键字分别用线性探测与平方探测插入,逐格对比探测路径:
再散列法与伪随机序列法
再散列法(双散列,double hashing)用第二个哈希函数决定探测步长:
Hi = (H1(k) + i·H2(k)) mod m。
关键要求是 H2(k) 必须与 m 互质,
否则探测序列会在某个真子集里循环。
常用做法:H2(k) = p − (k mod p),p 取小于 m 的质数。
它的好处是:不同的 key 有不同的步长,所以哪怕起始地址相同, 探测路径也会迅速岔开——这就同时消除了「一次聚集」和「二次聚集」, 是开放定址法里性能最好的一种,代价是每次探测要多算一个哈希函数。
伪随机序列法把 di 取成一个固定的伪随机序列
(如 d = {1, 5, 3, 9, 2, …})。因为序列是确定的,
所以插入和查找能用同一条路径,逻辑上是自洽的。
它的优点是「看起来毫无规律」,对手很难构造出全部冲突的输入;
缺点是丧失局部性,每次探测都可能跳到很远的 cache line,实际跑起来不一定快。
删除操作:必须使用墓碑标记
具体例子:依次插入 22、41、53。41 本来该放 8 号位,53 该放 9 号位都没冲突。 再插入 30:
30 mod 11 = 8,被 41 占了 → 探到 9,被 53 占了 → 探到 10,放进去。
现在 30 的「家」在 8,实际住在 10,中间夹着 41(8 号)和 53(9 号)。此时若直接清空 53(9 号位),再查 30:从
H(30) = 8 开始探测,
8 号是 41(不等)→ 9 号是空的 → 算法判定「30 不存在」,而它明明就住在 10 号位!正确做法:删除时把槽位标记为墓碑(tombstone,也叫已删除标记 DELETED)。 墓碑在查找时被当作「占用」(继续往下探),在插入时被当作「空位」(可以放新元素)。 一句话记忆:墓碑挡查找、不挡插入。
墓碑的代价是:长期运行后表里会积累大量墓碑,导致查找路径变长、真实负载升高。 工程上的解决办法是定期重建(rehash):扫一遍表把所有有效元素重新插入一个新表, 顺便把墓碑全部清掉、把表扩容或缩容。
开放定址法完整 C++ 实现(线性探测 + 平方探测)
#include <bits/stdc++.h>
using namespace std;
/* =====================================================================
开放定址 —— 线性探测法(linear probing)。竞赛写法:全局数组 + 自由函数
H_i = ( H(key) + i ) mod m ,i = 0,1,2,...(冲突就一个一个往后数)
槽位三态:EMPTY 从未用过 / OCCUPIED 有元素 / DELETED 墓碑
删除必须打墓碑(tombstone)!直接置空会截断别人的探测链。
缺点:堆积现象(一次聚集 primary clustering)—— 被占的槽会连成一片,
这片越长越容易"吸住"后面的冲突,α 一高 ASL 就急剧上升
(成功 ≈ ½(1 + 1/(1-α)),失败 ≈ ½(1 + 1/(1-α)²)),所以实践上要求 α ≤ 0.75。
表长 m 取质数:本段用 m = 11 方便和正文的 ASL 手推对齐;
竞赛里开大表常写成 const int M = 100003;(1e5 级别的质数)。
===================================================================== */
const int M = 11; // 表长 m,取质数(11 是质数)
const int EMPTY = 0, OCCUPIED = 1, DELETED = 2;
int h[M]; // 槽位里存的关键字
int st[M]; // 每个槽位的状态:EMPTY / OCCUPIED / DELETED
int cnt = 0; // 有效元素个数 n,装填因子 α = cnt / M
int tomb = 0; // 墓碑个数(墓碑会让查找路径变长)
bool verbose = false; // 打开后打印每一次探测的过程
/* ---------- 哈希函数:除留余数法,顺便把负数掰成正的 ---------- */
int hashOf(int k) { return ((k % M) + M) % M; }
double loadFactor() { return (double)cnt / M; }
/* ---------- 插入:返回最终落点下标;-1 表示探测一圈都没有空位 ---------- */
int insertKey(int k) {
int hh = hashOf(k);
for (int i = 0; i < M; ++i) {
int pos = (hh + i) % M; // 线性探测:d_i = i
if (verbose)
printf(" 探测 i=%d → pos %d%s\n", i, pos,
st[pos] == OCCUPIED ? "(占用,冲突)"
: st[pos] == DELETED ? "(墓碑,可复用)" : "(空,放入)");
if (st[pos] == OCCUPIED && h[pos] == k) return pos; // 已存在,不重复插
if (st[pos] != OCCUPIED) { // EMPTY 或 DELETED 都能放
if (st[pos] == DELETED) --tomb; // 顺手清掉一个墓碑
h[pos] = k; st[pos] = OCCUPIED; ++cnt;
return pos;
}
}
return -1; // 表满
}
/* ---------- 查找:返回下标,找不到返回 -1;cmpCnt 输出比较次数 ---------- */
int findKey(int k, int *cmpCnt = nullptr) {
int hh = hashOf(k), c = 0;
for (int i = 0; i < M; ++i) {
int pos = (hh + i) % M;
++c; // 访问一个槽位 = 一次比较
if (st[pos] == EMPTY) { // 真空位 → 探测链断了,肯定没有
if (cmpCnt) *cmpCnt = c;
return -1;
}
if (st[pos] == OCCUPIED && h[pos] == k) {
if (cmpCnt) *cmpCnt = c;
return pos;
}
/* DELETED 既不算匹配也不算终止,继续往下探 —— 这就是墓碑的作用 */
}
if (cmpCnt) *cmpCnt = c;
return -1;
}
/* ---------- 删除:打墓碑,绝不置 EMPTY ---------- */
bool eraseKey(int k) {
int pos = findKey(k);
if (pos < 0) return false;
st[pos] = DELETED; // 关键的一行!
++tomb; --cnt;
return true;
}
void dumpTable(const char *title) {
printf("%s(n=%d,α=%.2f,墓碑=%d)\n ", title, cnt, loadFactor(), tomb);
for (int i = 0; i < M; ++i) {
if (st[i] == OCCUPIED) printf("[%d]%d ", i, h[i]);
else if (st[i] == DELETED) printf("[%d]墓碑 ", i);
else printf("[%d]空 ", i);
}
printf("\n");
}
int main() {
int keys[9] = {22, 41, 53, 46, 30, 13, 1, 67, 42};
verbose = true;
for (int i = 0; i < 9; ++i) {
printf("插入 %d(H=%d):\n", keys[i], keys[i] % M);
insertKey(keys[i]);
}
verbose = false;
/* 实测最终表:0:22 1:1 2:46 3:13 4:67 5:42 6:空 7:空 8:41 9:53 10:30
42 插入时探测链为 9✗10✗0✗1✗2✗3✗4✗5✓,共比较 8 次(同一约定下即查找它的成功比较次数)
各 key 的成功比较次数:22:1 41:1 53:1 46:1 30:3 13:2 1:1 67:4 42:8
成功 ASL 的分母是 n(表中元素个数),所以 ASL_success = 22/9 ≈ 2.44 */
dumpTable("最终表:");
int c = 0, total = 0, n = 0;
for (int i = 0; i < 9; ++i)
if (findKey(keys[i], &c) >= 0) { total += c; ++n; }
printf("ASL_success = %d/%d = %.2f\n", total, n, (double)total / n);
/* ---------- 演示墓碑的必要性 ---------- */
printf("\n---- 删除 53(9 号位)----\n");
eraseKey(53);
dumpTable("删除后:"); // 9 号位变成墓碑
int p = findKey(30, &c); // 30 住在 10 号位,必须穿过墓碑才找得到
printf("查找 30 → 下标 %d,比较 %d 次\n", p, c);
printf("(如果删除时把 9 号位置成 EMPTY,这里会返回 -1,30 就「消失」了)\n");
/* ---------- 删除后重算成功 ASL:22:1 41:1 46:1 30:3 13:2 1:1 67:4 42:8 = 21/8 ≈ 2.62 ---------- */
total = 0; n = 0;
for (int i = 0; i < 9; ++i) {
if (keys[i] == 53) continue; // 53 已被删除
if (findKey(keys[i], &c) >= 0) { total += c; ++n; }
}
printf("删除 53 后 ASL_success = %d/%d = %.2f\n", total, n, (double)total / n);
/* ---------- 统计不成功 ASL:分母是 m,约定「从每个哈希地址出发探到 EMPTY 为止」 ---------- */
int failTotal = 0;
for (int start = 0; start < M; ++start) {
int i = 0;
while (true) {
int pos = (start + i) % M;
++i;
if (st[pos] == EMPTY) break; // 墓碑不算停
if (i > M) break;
}
failTotal += i;
}
printf("ASL_fail = %d/%d = %.2f\n", failTotal, M, (double)failTotal / M);
return 0;
}
#include <bits/stdc++.h>
using namespace std;
/* =====================================================================
开放定址 —— 平方探测法(quadratic probing)。竞赛写法:全局数组 + 自由函数
探测序列:d = 0, +1, -1, +4, -4, +9, -9, ...
地址:pos = ( H(key) + d ) mod m
重要结论:m 取 4j+3 形式的质数(7,11,19,23,31,43...)时,
前 m 个探测位置恰好覆盖全表,保证一定能插入;
若 m 是 4j+1 型质数(13,29...)则只能覆盖一半位置。
优点:跳跃式探测打散了堆积,不会出现线性探测那样越长越"吸冲突"的一次聚集;
但起点相同的 key 仍走同一条探测序列,这叫二次聚集(secondary clustering)。
删除同样必须打墓碑。
===================================================================== */
const int MAXM = 100005; // 表长上限(竞赛里表长常取 1e5 级别的质数)
const int EMPTY = 0, OCCUPIED = 1, DELETED = 2;
int m = 11; // 当前表长 m(要求是 4j+3 型质数)
int h[MAXM]; // 槽位里存的关键字
int st[MAXM]; // 槽位状态
int cnt = 0; // 有效元素个数 n
bool verbose = false; // 打开后打印每一次探测
/* ---------- 重置成一张长度为 size 的空表 ---------- */
void clearTable(int size) {
m = size; cnt = 0;
for (int i = 0; i < m; ++i) { h[i] = 0; st[i] = EMPTY; }
}
int hashOf(int k) { return ((k % m) + m) % m; }
/* ---------- 探测序列的第 i 项增量(i 从 0 开始):0, +1, -1, +4, -4, ... ---------- */
int delta(int i) {
if (i == 0) return 0;
int k = (i + 1) / 2; // 1,1,2,2,3,3,...
int sq = k * k;
return (i % 2 == 1) ? sq : -sq; // 奇数次取正,偶数次取负
}
/* ---------- 插入:返回落点下标;-1 表示表满 ---------- */
int insertKey(int k) {
int hh = hashOf(k);
for (int i = 0; i < m; ++i) {
int pos = ((hh + delta(i)) % m + m) % m;
if (verbose)
printf(" i=%d d=%d → pos %d%s\n", i, delta(i), pos,
st[pos] == OCCUPIED ? "(冲突)" : "(可放)");
if (st[pos] == OCCUPIED && h[pos] == k) return pos; // 已存在
if (st[pos] != OCCUPIED) { // EMPTY 或 DELETED 都能放
h[pos] = k; st[pos] = OCCUPIED; ++cnt;
return pos;
}
}
return -1; // 表满
}
/* ---------- 查找:返回下标,找不到返回 -1;cmpCnt 输出比较次数 ---------- */
int findKey(int k, int *cmpCnt = nullptr) {
int hh = hashOf(k), c = 0;
for (int i = 0; i < m; ++i) {
int pos = ((hh + delta(i)) % m + m) % m;
++c;
if (st[pos] == EMPTY) { if (cmpCnt) *cmpCnt = c; return -1; }
if (st[pos] == OCCUPIED && h[pos] == k) { if (cmpCnt) *cmpCnt = c; return pos; }
/* DELETED 继续往下探,这就是墓碑 */
}
if (cmpCnt) *cmpCnt = c;
return -1;
}
/* ---------- 删除:同样必须打墓碑 ---------- */
bool eraseKey(int k) {
int pos = findKey(k);
if (pos < 0) return false;
st[pos] = DELETED;
--cnt;
return true;
}
/* ---------- 验证:前 m 个探测位置是否恰好覆盖 0..m-1(只与 m 有关,不需要真的建表) ---------- */
bool probeCoversAll(int mm) {
bool seen[MAXM] = {false};
for (int i = 0; i < mm; ++i) {
int pos = ((0 + delta(i)) % mm + mm) % mm;
seen[pos] = true;
}
for (int i = 0; i < mm; ++i) if (!seen[i]) return false;
return true;
}
bool isPrime(int x) {
if (x < 2) return false;
for (int i = 2; (long long)i * i <= x; ++i) if (x % i == 0) return false;
return true;
}
void dumpTable(const char *title) {
printf("%s(n=%d)\n ", title, cnt);
for (int i = 0; i < m; ++i) {
if (st[i] == OCCUPIED) printf("[%d]%d ", i, h[i]);
else if (st[i] == DELETED) printf("[%d]墓碑 ", i);
else printf("[%d]空 ", i);
}
printf("\n");
}
int main() {
/* ---- 结论验证:m = 4j+3 的质数能覆盖全表,其它不行 ---- */
int ms[8] = {7, 11, 12, 13, 19, 23, 29, 31};
for (int i = 0; i < 8; ++i) {
int mm = ms[i];
printf("m=%-3d(4j+3? %s,质数? %s)探测序列覆盖全表? %s\n",
mm, (mm % 4 == 3) ? "是" : "否", isPrime(mm) ? "是" : "否",
probeCoversAll(mm) ? "能" : "不能");
}
/* 预期:7/11/19/23/31 能;12(非质数)、13(≡1 mod 4)、29(≡1 mod 4)不能 */
/* ---- 同一串关键字,和线性探测对比(m 必须取 4j+3 型质数) ---- */
printf("\n平方探测插入 22,41,53,46,30,13,1,67,42(m=11):\n");
clearTable(11);
int keys[9] = {22, 41, 53, 46, 30, 13, 1, 67, 42};
verbose = true;
for (int i = 0; i < 9; ++i) {
printf(" 插入 %d(H=%d)\n", keys[i], keys[i] % m);
insertKey(keys[i]);
}
verbose = false;
dumpTable("结果:");
int c = 0, total = 0;
for (int i = 0; i < 9; ++i) { findKey(keys[i], &c); total += c; }
printf("ASL_success = %d/9 = %.2f\n", total, total / 9.0);
return 0;
}
10.7.4 开放定址法的 ASL 手推(408 高频大题)
这是哈希表部分最必须动手练的题型。规则固定,只要按表格一步步来就不会错。
我们用本章反复出现的那组数据:表长 m = 11,哈希函数 H(k) = k mod 11,
线性探测法,依次插入 22, 41, 53, 46, 30, 13, 1, 67, 42。
第一步:逐个插入,记录每个关键字的探测过程与最终位置。
| 插入次序 | 关键字 | H(k) = k mod 11 | 探测过程(pos 与结果) | 落点 | 比较次数 ci |
|---|---|---|---|---|---|
| 1 | 22 | 0 | 0 空 → 放入 | 0 | 1 |
| 2 | 41 | 8 | 8 空 → 放入 | 8 | 1 |
| 3 | 53 | 9 | 9 空 → 放入 | 9 | 1 |
| 4 | 46 | 2 | 2 空 → 放入 | 2 | 1 |
| 5 | 30 | 8 | 8 占用(41) → 9 占用(53) → 10 空 → 放入 | 10 | 3 |
| 6 | 13 | 2 | 2 占用(46) → 3 空 → 放入 | 3 | 2 |
| 7 | 1 | 1 | 1 空 → 放入 | 1 | 1 |
| 8 | 67 | 1 | 1 占用(1) → 2 占用(46) → 3 占用(13) → 4 空 → 放入 | 4 | 4 |
| 9 | 42 | 9 | 9 占用(53) → 10 占用(30) → 0 占用(22) → 1 占用(1) → 2 占用(46) → 3 占用(13) → 4 占用(67) → 5 空 → 放入 | 5 | 8 |
第二步:画出最终的表(这是计算失败 ASL 的依据)。
| 下标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 关键字 | 22 | 1 | 46 | 13 | 67 | 42 | 空 | 空 | 41 | 53 | 30 |
第三步:算查找成功的 ASL。等概率时每个关键字的查找概率是 1/9:
第四步:算查找不成功的 ASL。这里是关键:失败时我们从每一个哈希地址
(下标 0 到 10,共 m = 11 种情形)出发做探测,一直探到第一个空槽为止。
因为空槽代表「探测链断了,后面不可能有」,所以比较次数 = 从起点走到空槽所经过的位置数
(包括最后那个空槽本身,因为我们访问了它并做了判定)。
| 哈希地址(失败起点) | 探测路径 | 到空槽的比较次数 dj |
|---|---|---|
| 0 | 0(22) → 1(1) → 2(46) → 3(13) → 4(67) → 5(42) → 6 空,停 | 7 |
| 1 | 1(1) → 2(46) → 3(13) → 4(67) → 5(42) → 6 空,停 | 6 |
| 2 | 2(46) → 3(13) → 4(67) → 5(42) → 6 空,停 | 5 |
| 3 | 3(13) → 4(67) → 5(42) → 6 空,停 | 4 |
| 4 | 4(67) → 5(42) → 6 空,停 | 3 |
| 5 | 5(42) → 6 空,停 | 2 |
| 6 | 6 空,停(第一个就是空的) | 1 |
| 7 | 7 空,停 | 1 |
| 8 | 8(41) → 9(53) → 10(30) → 0(22) → 1(1) → 2(46) → 3(13) → 4(67) → 5(42) → 6 空,停 (从下标 8 出发要绕几乎一整圈才碰到空位!) | 10 |
| 9 | 9(53) → 10(30) → 0(22) → 1(1) → 2(46) → 3(13) → 4(67) → 5(42) → 6 空,停 | 9 |
| 10 | 10(30) → 0(22) → 1(1) → 2(46) → 3(13) → 4(67) → 5(42) → 6 空,停 | 8 |
| 合计(分母 = 表长 m = 11) | 7+6+5+4+3+2+1+1+10+9+8 = 56 | |
② 失败起点是「哈希地址」而不是「已经存了元素的位置」。要枚举 0…m−1 全部 m 个起点, 哪怕某个下标本身是空的(那种情况下 dj = 1)。
③ 失败的终止条件是「真空槽」。如果表里有墓碑,墓碑不算终止(要继续探), 这一点在有删除操作的题目里经常出现。
另外注意本题的
ASL失败 = 56/11 ≈ 5.09 比 ASL成功 = 22/9 ≈ 2.44
大了一倍多,这正是 α = 9/11 ≈ 0.82 太高的后果——如果 α 降到 0.5 左右,两者都会明显下降。
45/11 ≈ 4.09,把每个起点到空槽之间已占用的格子数相加:
6+5+4+3+2+1+0+0+9+8+7 = 45)。两种约定都对,区别只在于是否把最后一次空判定计入。 考试时请先写出你的约定,再按约定算。本讲全章统一采用「访问即计数」, 所以正文、表格、动画与配套 C++ 程序的数字完全一致(配套程序实测输出正是 21/8 与 56/11)。
10.7.5 处理冲突(二):链地址法(拉链法)
链地址法(separate chaining,又称拉链法)的思想完全不同: 哈希表只存「桶」(bucket)的头指针,所有哈希到同一地址的关键字串成一条链表。 这样冲突不再需要「另找位置」,而是「挂在同一个桶下面」。
动画演示同样的插入过程,并且包含一次「查找穿过长链」与一次「查找失败」:
链地址法的 ASL 手推
用图 10-9 的同一组数据(m = 11,头插法,插入顺序 99, 1, 23, 14, 55, 68, 11, 37, 46):
| 桶号 | 链上元素(头插顺序) | 链长 | 各元素查找比较次数 | 本桶比较次数小计 |
|---|---|---|---|---|
| 0 | 99, 55 | 2 | 99 是第 1 个 → 1;55 是第 2 个 → 2 | 3 |
| 1 | 23, 1 | 2 | 23 → 1;1 → 2 | 3 |
| 2 | 68, 46 | 2 | 68 → 1;46 → 2 | 3 |
| 3 | 14 | 1 | 1 | 1 |
| 4 | 37 | 1 | 1 | 1 |
| 5 | — | 0 | — | 0 |
| 6 | — | 0 | — | 0 |
| 7 | — | 0 | — | 0 |
| 8 | — | 0 | — | 0 |
| 9 | — | 0 | — | 0 |
| 10 | — | 0 | — | 0 |
| 成功查找:总比较次数(n = 9 个元素) | 14 | |||
| 不成功查找:从每个桶出发都要把链走完(分母 = m = 11) | 9 | |||
nullptr),一次比较都不需要(直接判定为空),
所以该项记 0 而不是 1——这与开放定址法(空槽也要访问一次,记 1)不同。
这一点经常被混用,请特别注意。
链地址法的 C++ 实现
#include <bits/stdc++.h>
using namespace std;
/* =====================================================================
链地址法(拉链法)—— 竞赛写法:struct 描述结点 + 全局桶数组 + 自由函数
- 桶数组 + 每个桶一条单链表(指针域 nxt)
- 插入 O(1)(头插);查找 = 定位桶 + 遍历链
- 删除不需要墓碑,直接摘链;装填因子 α = n / m 可以大于 1
数组大小依据:M = 11 取质数;竞赛里桶数常取 1e5 级别的质数
===================================================================== */
const int M = 11; // 桶数 m,取质数
struct Node { // 链结点:数据域 + 指针域
int val;
Node *nxt;
};
Node *head[M]; // 桶数组:head[i] 是第 i 条链的表头指针
int n = 0; // 元素个数 n;α = n / M
int headInsert = 1; // 1 = 头插(O(1)),0 = 尾插(O(链长))
int hashOf(int k) { return ((k % M) + M) % M; }
double loadFactor() { return (double)n / M; }
/* ---------- 插入:同一关键字只留一个;头插 O(1),尾插 O(链长) ---------- */
bool insertKey(int k) {
int hh = hashOf(k);
for (Node *p = head[hh]; p; p = p->nxt)
if (p->val == k) return false; // 不允许重复
Node *p = new Node{k, nullptr}; // 竞赛里不回收内存,程序结束即归还系统
if (headInsert) { // 头插
p->nxt = head[hh];
head[hh] = p;
} else { // 尾插
if (!head[hh]) head[hh] = p;
else {
Node *q = head[hh];
while (q->nxt) q = q->nxt;
q->nxt = p;
}
}
++n;
return true;
}
/* ---------- 查找:成功返回 true;cmpCnt 输出比较次数(空桶时是 0) ---------- */
bool findKey(int k, int *cmpCnt = nullptr) {
int c = 0;
for (Node *p = head[hashOf(k)]; p; p = p->nxt) {
++c;
if (p->val == k) { if (cmpCnt) *cmpCnt = c; return true; }
}
if (cmpCnt) *cmpCnt = c; // 空桶时 c = 0,这是链地址法失败 ASL 的特点
return false;
}
/* ---------- 删除:直接摘链,无需墓碑 ---------- */
bool eraseKey(int k) {
int hh = hashOf(k);
Node *p = head[hh], *pre = nullptr;
while (p && p->val != k) { pre = p; p = p->nxt; }
if (!p) return false;
if (pre) pre->nxt = p->nxt; // 摘链
else head[hh] = p->nxt; // 删的是表头
--n;
return true;
}
/* ---------- 统计两种 ASL ---------- */
/* 成功 ASL:每个元素在链上的"名次"之和 / n(分母 n)
失败 ASL:把每条链走完的比较次数之和 / m(分母 m,空桶记 0 次比较) */
void statASL() {
int succTotal = 0;
for (int i = 0; i < M; ++i) {
int rank = 0;
for (Node *p = head[i]; p; p = p->nxt) { ++rank; succTotal += rank; }
}
int failTotal = 0;
for (int i = 0; i < M; ++i)
for (Node *p = head[i]; p; p = p->nxt) ++failTotal;
printf("n=%d, m=%d, α=%.3f\n", n, M, loadFactor());
printf("ASL_success = %d/%d = %.3f\n", succTotal, n, n ? (double)succTotal / n : 0.0);
printf("ASL_fail = %d/%d = %.3f\n", failTotal, M, (double)failTotal / M);
}
void dumpTable() {
for (int i = 0; i < M; ++i) {
if (!head[i]) { printf("桶%d: 空\n", i); continue; }
printf("桶%d: ", i);
for (Node *p = head[i]; p; p = p->nxt) printf("%d → ", p->val);
printf("∧\n");
}
}
int main() {
headInsert = 1; // m = 11,头插
int keys[9] = {99, 1, 23, 14, 55, 68, 11, 37, 46};
for (int i = 0; i < 9; ++i) insertKey(keys[i]);
dumpTable();
statASL();
/* 预期输出(m = 11,头插):
各关键字哈希:99→0, 1→1, 23→1, 14→3, 55→0, 68→2, 11→0, 37→4, 46→2
桶0: 11,55,99(头插,最后插的在最前) 桶1: 23,1
桶2: 46,68 桶3: 14 桶4: 37
ASL_success = (1+2+3 + 1+2 + 1+2 + 1 + 1) / 9 = 14/9 ≈ 1.556
ASL_fail = (3+2+2+1+1+0+0+0+0+0+0) / 11 = 9/11 ≈ 0.818 */
int c = 0;
findKey(99, &c);
printf("查找 99 → 比较 %d 次(在桶 0 的链尾)\n", c);
findKey(100, &c);
printf("查找 100 → 失败,比较 %d 次(桶 1 只有 2 个结点)\n", c);
eraseKey(99);
printf("删除 99 后:\n");
dumpTable();
statASL();
return 0;
}
10.7.6 处理冲突(三):再哈希法与公共溢出区
再哈希法(多哈希函数法)
思想:预先准备一组哈希函数 H1, H2, …, Hk。
用 H1(key) 算地址;如果冲突,就换 H2(key);
再冲突就换 H3(key)……直到找到空位。
与双散列的区别:双散列是「一个起始地址 + 由 H2 决定的步长」
(地址形成一个等差数列);再哈希法是「换一个完全独立的哈希函数重算地址」,
两者在聚集特性上略有差异,但都属于「用多个函数打散」的同一思路。
| 优点 | 缺点 | 适用场景 |
|---|---|---|
| 不易产生堆积;不同关键字走不同路径,抗构造性强 | 每次冲突都要多算一个哈希函数,计算时间增加;需要额外存放多个函数或参数;实现更复杂 | 对查找速度极其敏感、且愿意为抗聚集付出计算代价的场合; 布隆过滤器(Bloom filter)就是「多个哈希函数 + 位数组」的经典应用 |
建立公共溢出区
思想:把哈希表拆成两部分——基本表和溢出表。
所有 H(key) 不冲突的元素放进基本表;
一旦发生冲突,不管是谁,统统塞进公共溢出区(通常是顺序表)。
查找时:先按 H(key) 查基本表,若基本表该位置上的关键字与 key 相等 → 成功;
否则(要么位置为空,要么被别的关键字占了)就去溢出区顺序查找。
优点:实现非常简单,基本表里「一个萝卜一个坑」,
不需要探测、不需要墓碑、删除也简单(在溢出区做删除即可)。
缺点:一旦冲突元素变多,溢出区就退化成顺序表,查找代价 O(溢出元素个数),
性能急剧下降。所以它只适合冲突很少的场景——比如哈希函数设计得极好、
或者数据量远小于表长(α 很小)。它在教材里主要作为「第四种方法」出现,工程中少见。
#include <bits/stdc++.h>
using namespace std;
/* =====================================================================
方法三:再哈希法(准备一组哈希函数,冲突就换下一个)
方法四:建立公共溢出区(基本表 + 溢出表)
两套全局数组 + 自由函数放在一个文件里对照着看
数组大小依据:表长都按 1e5 级别开(MAX 常量),实际演示只用到 11 个槽
===================================================================== */
/* ------------------------- 方法三:再哈希法 ------------------------- */
const int RMAX = 100005; // 再哈希法的表容量
int rm = 11, rn = 0; // 表长 m、元素个数 n
int rt[RMAX]; // 槽位里存的关键字
bool rused[RMAX]; // 该槽位是否被占用
int funcUsed[8]; // 统计每个哈希函数被用了多少次
/* 一组互相独立的哈希函数:冲突了就换下一个重算地址 */
int hashByIdx(int idx, int k) {
switch (idx) {
case 0: return ((k % rm) + rm) % rm; // 除留余数
case 1: return (int)((((long long)k * 2654435761LL / 256) % rm + rm) % rm); // 乘法散列
case 2: return (int)((((long long)k * k / 16) % rm + rm) % rm); // 平方取中
case 3: return ((k / 7 + k % 7) % rm + rm) % rm;
default: return (int)((((long long)k * (idx * 2 + 1) + idx * 13) % rm + rm) % rm);
}
}
bool rhInsert(int k, bool verbose = false) {
for (int f = 0; f < 8; ++f) {
int pos = hashByIdx(f, k);
++funcUsed[f];
if (verbose)
printf(" H%d(%d) = %d%s\n", f + 1, k, pos,
rused[pos] ? "(冲突,换下一个函数)" : "(空,放入)");
if (!rused[pos]) { rt[pos] = k; rused[pos] = true; ++rn; return true; }
if (rt[pos] == k) return true; // 已经存过
}
return false; // 8 个函数都撞了(极小概率)
}
int rhFind(int k, int *cmpCnt = nullptr) {
int c = 0;
for (int f = 0; f < 8; ++f) {
int pos = hashByIdx(f, k);
++c;
if (rused[pos] && rt[pos] == k) { if (cmpCnt) *cmpCnt = c; return pos; }
if (!rused[pos]) break; // 该函数算出的位置是空的 → 说明没存进来过
}
if (cmpCnt) *cmpCnt = c;
return -1;
}
bool rhErase(int k) {
int pos = rhFind(k);
if (pos < 0) return false;
rused[pos] = false; --rn;
return true;
}
/* ------------------------- 方法四:公共溢出区 ------------------------- */
const int OMAX = 100005; // 基本表容量
int om = 11, on = 0; // 基本表表长 m、元素个数 n
int obase[OMAX]; // 基本表:一个萝卜一个坑
bool oused[OMAX]; // 基本表槽位是否被占用
int ovf[OMAX]; // 公共溢出区(顺序表),存所有冲突的元素
int ovfCnt = 0; // 溢出区元素个数
int ovHash(int k) { return ((k % om) + om) % om; }
void ovInsert(int k) {
int pos = ovHash(k);
if (!oused[pos]) { obase[pos] = k; oused[pos] = true; } // 基本表还空着
else if (obase[pos] == k) return; // 已存在
else { ovf[ovfCnt++] = k; } // 冲突 → 统统进溢出区
++on;
}
int ovFind(int k, int *cmpCnt = nullptr) {
int c = 0, pos = ovHash(k);
++c;
if (oused[pos] && obase[pos] == k) { if (cmpCnt) *cmpCnt = c; return pos; }
/* 基本表没有 → 去溢出区顺序查找(冲突一多就退化成顺序表,这是它的缺点) */
for (int i = 0; i < ovfCnt; ++i) {
++c;
if (ovf[i] == k) { if (cmpCnt) *cmpCnt = c; return om + i; } // 用 m+i 表示溢出区下标
}
if (cmpCnt) *cmpCnt = c;
return -1;
}
bool ovErase(int k) {
int pos = ovHash(k);
if (oused[pos] && obase[pos] == k) { oused[pos] = false; --on; return true; }
for (int i = 0; i < ovfCnt; ++i)
if (ovf[i] == k) { // 溢出区删除:后面的元素往前搬
for (int j = i; j + 1 < ovfCnt; ++j) ovf[j] = ovf[j + 1];
--ovfCnt; --on;
return true;
}
return false;
}
void ovDump() {
printf(" 基本表: ");
for (int i = 0; i < om; ++i) {
if (oused[i]) printf("[%d]%d ", i, obase[i]);
else printf("[%d]空 ", i);
}
printf("\n 溢出区: ");
if (!ovfCnt) printf("(空)");
for (int i = 0; i < ovfCnt; ++i) printf("%d ", ovf[i]);
printf("\n");
}
int main() {
int keys[9] = {22, 41, 53, 46, 30, 13, 1, 67, 42};
printf("======== 方法三:再哈希法 ========\n");
for (int i = 0; i < 9; ++i) {
printf(" 插入 %d:\n", keys[i]);
rhInsert(keys[i], true);
}
printf(" 各哈希函数被调用次数:");
for (int i = 0; i < 5; ++i) printf("H%d=%d ", i + 1, funcUsed[i]);
printf("\n");
int c = 0, pos = 0;
pos = rhFind(42, &c);
printf(" 查找 42 → %s,比较 %d 次\n", pos >= 0 ? "成功" : "失败", c);
printf("\n======== 方法四:公共溢出区 ========\n");
for (int i = 0; i < 9; ++i) ovInsert(keys[i]);
ovDump(); // 冲突的几个会落到溢出区
pos = ovFind(42, &c);
printf(" 查找 42 → %s,比较 %d 次(基本表 1 次 + 溢出区若干次)\n",
pos >= 0 ? "成功" : "失败", c);
pos = ovFind(999, &c);
printf(" 查找 999 → %s,比较 %d 次\n", pos >= 0 ? "成功" : "失败", c);
ovErase(42);
printf(" 删除 42 后:\n");
ovDump();
return 0;
}
10.7.7 四种冲突处理方法的综合对比
| 对比维度 | 开放定址 · 线性探测 | 开放定址 · 平方 / 双散列 | 链地址法 | 公共溢出区 |
|---|---|---|---|---|
| 空间利用率 | 高(无指针开销) | 高(无指针开销) | 较低(每元素至少一个指针;list 还要多一个) | 中(需要预留溢出区) |
| 查找效率 | 易堆积,α 高时急剧变差 | 堆积少,接近理论最优 | 稳定,α 高时也退化平缓 | 冲突多时退化为顺序查找 |
| 是否支持删除 | 支持,但必须用墓碑,长期会积累垃圾 | 同样必须用墓碑 | 天然支持,摘链即可,无副作用 | 支持(溢出区删除 / 基本表置空) |
| 装填因子 α 的影响 | 必须 α < 1,通常 < 0.75,超过 0.8 性能雪崩 | 必须 α < 1;平方探测建议 α ≤ 0.5 | α 可以 > 1;性能随 α 线性缓慢下降 | α 可以 > 1,但溢出区会变长 |
| 实现难度 | ★ 最简单 | ★★ 需要选好表长 / 第二个函数 | ★★ 需要处理指针与内存 | ★ 简单 |
| 缓存友好度 | 好(数组连续,探测也连续) | 中(探测跳跃,跨 cache line) | 差(指针追逐 pointer chasing) | 中 |
| 典型应用 | 教学;小规模数据;Python 早期字典 | 高性能哈希表(如某些 absl::flat_hash_map) |
Java HashMap(链 + 红黑树)、C++ std::unordered_map(多数实现)、Redis 字典 |
教材;冲突率极低的特化场景 |
- 要极致查找速度 + 内存紧 → 开放定址(平方 / 双散列 / Robin Hood 等变体),
代表:
absl::flat_hash_map、Rust 的hashbrown、Python 的 dict。 - 元素大、指针随便用、α 可能超过 1 → 链地址法,代表:Java
HashMap、 C++ 的多数unordered_map实现(libstdc++ 用「桶数组 + 单链表」)。 - Java HashMap 为什么还要红黑树化?因为纯链表在极端情况下(恶意构造或哈希函数差)
会退化成
O(n)。JDK 8 起规定:单条链长度 ≥ 8 且表长 ≥ 64 时, 把链转成红黑树,把最坏情况拉回O(log n)。这是「链地址法 + 平衡树」的混合结构。
10.7.8 装填因子 α:哈希表唯一的性能旋钮
装填因子 α = n / m(已存元素数 / 表长),它是决定哈希表平均查找长度的一阶因素,
而且注意一个反直觉的结论:
也就是说,「1000 个元素装进 2000 个槽」和「10 个元素装进 20 个槽」的平均查找长度几乎一样, 因为二者的 α 都是 0.5。这就是为什么哈希表能做到「期望 O(1)」—— 只要维持 α 是常数,查找代价就是常数,跟数据规模无关。
理论上的近似公式(了解即可,考试一般不要求推导):
| 方法 | 查找成功 ASL | 查找不成功 ASL | α = 0.5 时的数值 |
|---|---|---|---|
| 线性探测 | ½(1 + 1/(1−α)) |
½(1 + 1/(1−α)2) |
成功 1.5;失败 2.5 |
| 平方探测 / 双散列 | −(1/α)·ln(1−α) |
1/(1−α) |
成功 1.39;失败 2.0 |
| 链地址法 | 1 + α/2 |
α(或 α + e−α) |
成功 1.25;失败 0.5 |
从这些式子能读出三件事,这比记公式更重要:
- 开放定址法必须保证 α < 1,因为公式里都有
1/(1−α): 当α → 1时分母趋近于 0,ASL 爆炸。而且 α = 1 意味着表全满, 插入直接失败——哈希表必须永远留有空槽,这不是效率问题,是正确性问题。 - 线性探测对 α 最敏感(分母是平方),所以生产环境普遍把阈值设在
α ≤ 0.75:超过就扩容(rehash)到大约 2 倍。 Java HashMap 的默认阈值就是 0.75,这是性能与内存的经典折中。 - 链地址法允许 α > 1,因为链表可以无限延长,公式
1 + α/2里没有分母。 但它也不是免费的:α = 4 时平均要遍历 3 个结点,指针追逐的 cache 代价很高。
m、n、α 三个量经常互相推导,
要牢记:m = 表长 = 数组长度 = 哈希地址的取值范围(0..m−1);
n = 表中实际存放的元素个数;
α = n/m。求 ASL 时再强调一次:成功 ASL 除以 n,失败 ASL 除以 m。 看到题目问「平均查找长度」而只给了一个空,那它多半只想要成功 ASL; 但只要题目提到「查找不成功」,就必须写两个。
10.8 工程视角:哈希在真实系统里怎么用
10.8.1 C++ 的 unordered_map 是怎么实现的
std::unordered_map 与 std::unordered_set 是 C++11 引入的哈希容器,
标准对实现的约束只有一条:平均 O(1)、最坏 O(n),并规定「桶(bucket)」这个接口。
具体实现由各家标准库自由发挥:
- libstdc++(GCC):一条全局单链表把所有结点串起来,
桶数组里存的是「指向桶内第一个结点的指针」,桶内结点通过额外的指针相连。
好处是迭代器稳定、
rehash时不用搬动结点。 - libc++(Clang):与 libstdc++ 思路类似,也是桶数组 + 链表。
- MSVC STL:桶数组 + 每个桶一条双向链表。
无论哪家,都有几个必须知道的接口与行为:
| 接口 | 含义 | 用途 |
|---|---|---|
load_factor() | 当前的 α = size / bucket_count | 监控哈希表拥挤程度 |
max_load_factor(x) | 设置 α 的上限,默认 1.0 | 提前扩容,换取更快的查找 |
rehash(n) | 把桶数设为至少 n | 插入前预留,避免中途多次 rehash |
reserve(n) | 预留能装 n 个元素的空间 | 竞赛必备:一次性分配,避免反复扩容 |
bucket_count() / bucket(k) | 桶数 / 关键字 k 落在哪个桶 | 调试哈希分布是否均匀 |
load_factor() > max_load_factor() 时,容器会自动 rehash——
这会重新分配桶数组,所有迭代器失效(libstdc++ 因为结点不搬动,
引用和指针仍有效,但迭代器失效)。所以在遍历
unordered_map 时不要插入新元素,否则可能崩溃。
要边遍历边插入,先收集到 vector 里再统一插入。
#include <bits/stdc++.h>
using namespace std;
/* =====================================================================
std::unordered_map 的底层行为观察 + 自定义类型的哈希(这一段保留 STL 用法)
竞赛写法要点:自定义 key 时不再特化 std::hash,而是给容器传第三个参数 ——
一个重载了 operator() 的哈希仿函数,既不用写模板,也不污染 std 命名空间。
===================================================================== */
struct Point { // 自定义类型做 key:必须提供 == (容器靠它判等)
int x, y;
bool operator==(const Point &o) const { return x == o.x && y == o.y; }
};
/* 哈希仿函数:把两个 int 混合成一个 size_t,避免 x+y 这种会撞车的组合 */
struct PointHash {
size_t operator()(const Point &p) const {
size_t h1 = hash<int>()(p.x);
size_t h2 = hash<int>()(p.y);
/* 0x9e3779b9... 是黄金分割常数,配合移位异或能把两个分量彻底打散 */
return h1 ^ (h2 + 0x9e3779b97f4a7c15ULL + (h1 << 6) + (h1 >> 2));
}
};
/* 一个"慢哈希",用来观察哈希质量对性能的影响 */
struct SlowHash {
size_t operator()(int k) const {
size_t h = k;
for (int i = 0; i < 200; ++i) h = h * 1000003u + 7; // 故意做很多无用功
return h;
}
};
int main() {
/* ---------- 1. 观察桶数与装填因子 ---------- */
unordered_map<int, string> mp;
cout << "初始:bucket_count=" << mp.bucket_count()
<< " load_factor=" << mp.load_factor()
<< " max_load_factor=" << mp.max_load_factor() << "\n";
for (int i = 0; i < 1000; ++i) mp[i] = "v" + to_string(i);
cout << "插入 1000 个后:bucket_count=" << mp.bucket_count()
<< " load_factor=" << mp.load_factor() << "\n";
/* 观察:bucket_count 是质数序列(libstdc++)或 2 的幂,load_factor 被压在 max_load_factor 以下 */
/* ---------- 2. reserve 预分配,避免多次 rehash ---------- */
unordered_map<int, int> a, b;
auto t0 = chrono::steady_clock::now();
for (int i = 0; i < 200000; ++i) a[i] = i; // 不预留
auto t1 = chrono::steady_clock::now();
b.reserve(200000); // 预留
for (int i = 0; i < 200000; ++i) b[i] = i;
auto t2 = chrono::steady_clock::now();
cout << "不预留: " << chrono::duration_cast<chrono::milliseconds>(t1 - t0).count() << " ms\n";
cout << " 预留 : " << chrono::duration_cast<chrono::milliseconds>(t2 - t1).count() << " ms\n";
cout << "a.bucket_count=" << a.bucket_count() << " b.bucket_count=" << b.bucket_count() << "\n";
/* ---------- 3. 自定义类型的哈希:把 PointHash 作为第三个参数传进去 ---------- */
unordered_map<Point, int, PointHash> grid;
grid[{1, 2}] = 10;
grid[{3, 4}] = 20;
cout << "grid[{1,2}] = " << grid[{1, 2}] << "\n"; // 10
cout << "grid.count({9,9}) = " << grid.count({9, 9}) << "\n"; // 0
/* ---------- 4. 哈希函数质量直接影响性能 ---------- */
unordered_map<int, int> fast; // 默认 hash<int>:就是个恒等映射
unordered_map<int, int, SlowHash> slow; // 每次哈希都算 200 次乘法
auto t3 = chrono::steady_clock::now();
for (int i = 0; i < 100000; ++i) fast[i] = i;
auto t4 = chrono::steady_clock::now();
for (int i = 0; i < 100000; ++i) slow[i] = i;
auto t5 = chrono::steady_clock::now();
cout << "默认 hash<int>: " << chrono::duration_cast<chrono::milliseconds>(t4 - t3).count() << " ms\n";
cout << "SlowHash : " << chrono::duration_cast<chrono::milliseconds>(t5 - t4).count() << " ms\n";
/* 结论:哈希函数本身的计算量会被放大到每一次插入/查找上,别写"聪明但慢"的哈希 */
/* ---------- 5. 查看某个关键字落在哪个桶 ---------- */
unordered_map<int, int> m3;
m3.max_load_factor(0.7f); // 更低的 α → 更快的查找、更多的内存
m3.reserve(100);
for (int i = 0; i < 100; ++i) m3[i] = i;
cout << "bucket_count=" << m3.bucket_count()
<< " load_factor=" << m3.load_factor() << "\n";
for (int k = 0; k < 5; ++k)
cout << "key " << k << " → bucket " << m3.bucket(k) << "\n";
return 0;
}
10.8.2 竞赛中防哈希被卡
这是竞赛选手必须掌握的保命技巧。背景是这样:
std::unordered_map 的哈希函数(std::hash<int>)通常是「恒等映射」
(hash(x) = x)加上标准库自己的桶取模。
而桶数在很多实现里是质数序列或 2 的幂,都是公开可知的。
于是出题人可以故意构造一组全部同余的关键字(比如所有 k ≡ 0 (mod p)),
让它们全部落进同一个桶,哈希表退化成链表,O(1) 变 O(n),
你的程序 TLE。这个技巧叫 hash flooding / 卡哈希。
解决方案只有一招:自己写一个带随机种子的哈希函数,让攻击者无法预测落点。
#include <iostream>
#include <unordered_map>
#include <unordered_set>
#include <chrono>
#include <random>
using namespace std;
/* =====================================================================
防卡哈希(anti-hash-test)模板
原理:用一个程序启动时才确定的随机种子,把关键字彻底打散。
出题人无法预知种子 → 无法构造出全部冲突的数据。
用法:
unordered_map<int, int, custom_hash> mp;
unordered_set<long long, custom_hash_ll> st;
===================================================================== */
/* ---------- 通用版:适用于 int / long long 等整数类型 ---------- */
struct custom_hash {
static uint64_t splitmix64(uint64_t x) {
/* splitmix64:雪崩性极好的整数混合函数,被 splitmix64 / 各大赛事模板广泛使用 */
x += 0x9e3779b97f4a7c15ULL;
x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9ULL;
x = (x ^ (x >> 27)) * 0x94d049bb133111ebULL;
return x ^ (x >> 31);
}
static uint64_t& seed() {
/* 只在第一次调用时取一次随机数(用高精度时钟,无需 <random> 也很随机) */
static uint64_t s = chrono::steady_clock::now().time_since_epoch().count();
return s;
}
size_t operator()(uint64_t x) const {
return splitmix64(x + seed());
}
};
/* ---------- 字符串版:把字符串折成一个整数再混合 ---------- */
struct custom_hash_str {
size_t operator()(const string& s) const {
uint64_t h = 1469598103934665603ULL; // FNV 偏移基准
for (unsigned char c : s) {
h ^= c;
h *= 1099511628211ULL; // FNV 质数
}
return custom_hash::splitmix64(h + custom_hash::seed());
}
};
/* ---------- 极简版:一个随机偏移 + 取反,够用且短 ---------- */
struct simple_random_hash {
static uint64_t rnd() {
static uint64_t r = chrono::steady_clock::now().time_since_epoch().count();
return r;
}
size_t operator()(uint64_t x) const {
static const uint64_t FIXED_RANDOM = rnd();
x += FIXED_RANDOM;
x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9ULL;
x = (x ^ (x >> 27)) * 0x94d049bb133111ebULL;
return x ^ (x >> 31);
}
};
/* ---------- 反例:一个"看起来聪明其实会被卡"的哈希 ---------- */
struct bad_hash {
size_t operator()(uint64_t x) const { return x % 1000000007ULL; } // 固定模数 → 完全可预测
};
int main() {
/* ---------- 1. 基本用法 ---------- */
unordered_map<long long, int, custom_hash> mp;
mp[1234567890123LL] = 1;
cout << "mp[1234567890123] = " << mp[1234567890123LL] << "\n";
unordered_set<string, custom_hash_str> st;
st.insert("hello"); st.insert("world");
cout << "st.count(\"hello\") = " << st.count("hello") << "\n";
/* ---------- 2. 演示"卡哈希"攻击:构造全部同余的数据 ---------- */
const int N = 30000;
/* 2.1 用坏的哈希:所有 key 都是 P 的倍数 → 全落一个桶 */
{
unordered_map<long long, int, bad_hash> victim;
const long long P = 1000000007LL; // 与 bad_hash 的模数一致
auto t0 = chrono::steady_clock::now();
for (int i = 0; i < N; ++i) victim[(long long)i * P] = i;
long long sum = 0;
for (int i = 0; i < N; ++i) sum += victim[(long long)i * P];
auto t1 = chrono::steady_clock::now();
cout << "固定模数哈希,同余数据 " << N << " 个:"
<< chrono::duration_cast<chrono::milliseconds>(t1 - t0).count() << " ms\n";
cout << "(全部落进同一个桶,退化成链表 → 明显变慢)\n";
}
/* 2.2 用 custom_hash:同样的数据被彻底打散 */
{
unordered_map<long long, int, custom_hash> safe;
const long long P = 1000000007LL;
auto t0 = chrono::steady_clock::now();
for (int i = 0; i < N; ++i) safe[(long long)i * P] = i;
long long sum = 0;
for (int i = 0; i < N; ++i) sum += safe[(long long)i * P];
auto t1 = chrono::steady_clock::now();
cout << "随机化哈希,同余数据 " << N << " 个:"
<< chrono::duration_cast<chrono::milliseconds>(t1 - t0).count() << " ms\n";
cout << "(被 splitmix64 打散到各个桶)\n";
}
/* ---------- 3. 用随机种子重新运行本程序,可以看到时间基本不变 ---------- */
cout << "\n提示:把本程序编译后多跑几次,第 2.2 项的时间应该稳定;\n"
<< " 而如果把 custom_hash 换成固定种子的版本,攻击者就能复现并卡掉它。\n";
return 0;
}
custom_hash(用 splitmix64 + 时钟种子),
然后把所有 unordered_map<K, V> 写成
unordered_map<K, V, custom_hash>、
unordered_set<K> 写成 unordered_set<K, custom_hash>。额外两条经验:① 加
reserve(n * 2) 预分配,避免中途 rehash;
② 如果连 custom_hash 都不放心(比如出题人用了极端构造),
可以退回 map(红黑树,O(log n) 但绝不被卡),或者手写链地址法。
10.8.3 字符串哈希及其应用
字符串哈希(string hashing,也叫滚动哈希 rolling hash)是把「字符串」映射为「整数」的技术,
它让「判断两个子串是否相等」从 O(len) 降到 O(1)。
核心是把字符串看作一个 base 进制的大整数:
配合前缀哈希 pre[i] 与幂次表 pw[i],任意子串 s[l..r] 的哈希是:
两种常见的模数策略:
- 自然溢出:用
unsigned long long,让它自动对264取模。 代码最短、最快,但可以被构造出碰撞(Thue–Morse 序列是经典杀器), 有的出题人会专门卡这个。 - 双模数:用两个大质数(
109+7与109+9, 或998244353)各算一遍,用 pair 表示。 碰撞概率降到约1/(M1M2),实际比赛中几乎不可能被卡。
与第 05 讲 KMP 的联系:字符串哈希和 KMP 都在解决「子串匹配」, 但路子完全不同——KMP 用「最长相等前后缀」做确定性的线性匹配, 哈希用概率性的相等判定换取「任意子串 O(1) 比较」的灵活性。 两者的选择标准:
| 需求 | 推荐 | 原因 |
|---|---|---|
| 单次「模式串在主串中出现位置」 | KMP | 确定性 O(n+m),不会被卡 |
| 多次询问「任意两个子串是否相等」 | 字符串哈希 | KMP 做不到 O(1) 比较任意子串 |
| 最长公共子串 / 回文子串(二分 + 判定) | 字符串哈希 | 配合二分答案,每层 O(n) |
| 需要「最长相等前后缀」这类结构信息 | KMP / Z 函数 | 哈希只能判等,给不出边界 |
| 出题人明确卡哈希 | KMP / 后缀数组 | 确定性算法永远安全 |
#include <iostream>
#include <string>
#include <vector>
#include <algorithm>
using namespace std;
/* =====================================================================
字符串哈希:自然溢出版 + 双模数版
用途:O(1) 判断任意两个子串是否相等、求最长重复子串、回文判定等
===================================================================== */
/* ---------------- 版本一:自然溢出(unsigned long long 自动 mod 2^64) ---------------- */
struct HashULL {
static const unsigned long long B = 131; // 进制,通常取 131 或 13331
vector<unsigned long long> pre, pw;
explicit HashULL(const string& s) {
int n = (int)s.size();
pre.assign(n + 1, 0);
pw.assign(n + 1, 1);
for (int i = 0; i < n; ++i) {
pre[i + 1] = pre[i] * B + (unsigned char)s[i];
pw[i + 1] = pw[i] * B;
}
}
/* 子串 s[l..r] 的哈希,0 基,闭区间 */
unsigned long long get(int l, int r) const {
return pre[r + 1] - pre[l] * pw[r - l + 1];
}
};
/* ---------------- 版本二:双模数(更安全) ---------------- */
struct HashDouble {
static const long long M1 = 1000000007LL;
static const long long M2 = 1000000009LL;
static const long long B = 131;
vector<long long> p1, p2, w1, w2;
explicit HashDouble(const string& s) {
int n = (int)s.size();
p1.assign(n + 1, 0); p2.assign(n + 1, 0);
w1.assign(n + 1, 1); w2.assign(n + 1, 1);
for (int i = 0; i < n; ++i) {
p1[i + 1] = (p1[i] * B + (unsigned char)s[i]) % M1;
p2[i + 1] = (p2[i] * B + (unsigned char)s[i]) % M2;
w1[i + 1] = (w1[i] * B) % M1;
w2[i + 1] = (w2[i] * B) % M2;
}
}
pair<long long, long long> get(int l, int r) const {
long long h1 = ((p1[r + 1] - p1[l] * w1[r - l + 1]) % M1 + M1) % M1;
long long h2 = ((p2[r + 1] - p2[l] * w2[r - l + 1]) % M2 + M2) % M2;
return {h1, h2};
}
};
/* ---------------- 应用:哈希版模式匹配(对比第 05 讲的 KMP) ---------------- */
int matchByHash(const string& S, const string& P) {
int n = (int)S.size(), m = (int)P.size();
if (m == 0) return 0;
if (m > n) return -1;
HashDouble hs(S), hp(P);
auto target = hp.get(0, m - 1);
for (int i = 0; i + m <= n; ++i)
if (hs.get(i, i + m - 1) == target) return i; // O(1) 比较!
return -1;
}
/* ---------------- 应用:最长重复子串(二分长度 + 哈希判重) ---------------- */
int longestRepeatedSubstring(const string& s) {
int n = (int)s.size();
HashULL h(s);
auto ok = [&](int len) -> bool {
if (len == 0) return true;
vector<unsigned long long> v;
for (int i = 0; i + len <= n; ++i) v.push_back(h.get(i, i + len - 1));
sort(v.begin(), v.end());
for (int i = 1; i < (int)v.size(); ++i) if (v[i] == v[i - 1]) return true;
return false;
};
int lo = 1, hi = n - 1, ans = 0;
while (lo <= hi) { // 二分长度
int mid = lo + (hi - lo) / 2;
if (ok(mid)) { ans = mid; lo = mid + 1; } else hi = mid - 1;
}
return ans;
}
int main() {
string S = "ABABABCABABABCABA", P = "ABABCABA";
cout << "哈希匹配:" << matchByHash(S, P) << "(KMP 的结果也是 2)\n";
HashDouble hd("abcabc");
cout << "子串 [0,2] 与 [3,5] 相等? "
<< (hd.get(0, 2) == hd.get(3, 5)) << "\n"; // 1
cout << "最长重复子串长度 = " << longestRepeatedSubstring("abcabcbb") << "\n"; // 3("abc")
cout << "最长重复子串长度 = " << longestRepeatedSubstring("abcdefg") << "\n"; // 0
return 0;
}
pre[r+1] − pre[l]·pw[…] 在模意义下可能为负,
必须 (x % M + M) % M;自然溢出则天然没问题。② base 要与模数互质,且大于字符集:常用 131、13331、233。 如果
base 取成偶数而模数是偶数,会出现大量碰撞。③ 单模数不够安全:
109+7 单模在 n 上万时,
根据生日悖论碰撞概率已经不可忽略;而自然溢出可以被 Thue–Morse 序列刻意构造碰撞。
打比赛请默认写双模数,或者写 KMP / 后缀数组。
unordered_map、数据库索引、布隆过滤器与一致性哈希)
在面试和后端开发里出现的频率,比手推 ASL 高得多。
每个落点我们都按同一套三段式来讲:用什么结构 → 为什么这里必须用它 → 代价是什么。
凡是能用数字说话的地方,我们都给出数字,而不是「比较快」「比较省」这种空话。
10.8.4 查找方法总览:一张表选型
| 算法 / 结构 | 前提条件 | 成功 ASL | 失败 ASL | 插入 / 删除 | 适用场景 |
|---|---|---|---|---|---|
| 顺序查找 | 无 | (n+1)/2 | n(有序 n/2+n/(n+1)) | O(1) / O(n) | 小表、无序、链表、流式 |
| 折半查找 | 有序 + 顺序存储 | ≈ log₂(n+1) − 1 | ≈ log₂(n+1) | O(n)(要搬移) | 静态表、查多改少 |
| 插值查找 | 有序 + 顺序存储 + 近似均匀 | O(log log n)(均匀时) | 可能 O(n) | O(n) | 键值近似均匀(时间戳索引) |
| 斐波那契查找 | 有序 + 顺序存储 | O(log n),常数略小 | O(log n) | O(n) | 教科书;无除法指令的环境 |
| 分块查找 | 块间有序 | ≈ √n + 1(s = √n) | 需按块分析 | 块内 O(s)、索引 O(b) | 数据分块管理、插入不太频繁 |
| 二叉排序树 / 平衡树 | — | O(log n) | O(log n) | O(log n) | 需要动态有序、范围查询 |
| B 树 / B+ 树 | — | O(logmn) | O(logmn) | O(logmn) | 磁盘 / 数据库索引 |
| 哈希表(开放定址) | α < 1 | O(1 + 1/(1−α)) | O(1/(1−α)²) | 期望 O(1) | 只要「秒查」,不要有序 |
| 哈希表(链地址) | — | O(1 + α) | O(α) | 期望 O(1) | α 可能大于 1、元素较大、频繁删除 |
10.8.5 冲突处理在真实实现里怎么选:为什么拉链法是主流
10.7 里我们把四种冲突处理方法摆在一起做过对比,那是教科书视角; 现在换一副眼镜,看的是工程视角——主流语言的标准库和数据库到底选了谁,为什么。
HashMap、C++ 各家的
unordered_map、Go 的 map、Python 的 dict(早期版本)、
MySQL 的 HASH 索引,初版清一色是「桶数组 + 链表」。
只有追求极致速度的内存内哈希表(absl::flat_hash_map、Rust 的 hashbrown、
Python 3.6+ 的紧凑 dict)才改投开放定址。
拉链法赢在哪两条?
- 负载因子 α 可以大于 1。回忆 10.7.8 的公式:链地址法的成功 ASL 是
1 + α/2,式子里没有分母,所以 α = 2 甚至 α = 4 都还能跑, 只是平均要顺着链找 2~3 个结点。 开放定址就绝对不行——它的公式里全是1/(1−α), α = 1 意味着表全满,插入直接失败,这不是性能问题,是正确性问题: 开放定址法必须永远留有空槽。 - 删除简单,不需要墓碑。链地址法删除一个元素,就是把结点从链上摘下来、
delete掉,O(1)(双向链表)或O(链长), 删完表的状态和「这个元素从没来过」完全一样。 开放定址法不行:直接把它占的槽置空会截断探测链, 让它后面的同义词全部找不到(图 10-10 画的就是这个事故), 所以必须留一个墓碑(tombstone)占着位置。 墓碑的代价是双重的:它仍然占着槽(α 只涨不跌), 而且查找时必须继续往后探,不能被墓碑骗停。 墓碑攒多了,表就「假满」了——内存里有空槽,性能却像满了, 唯一的解法是原地重建(rehash),又是一次 O(n)。
那开放定址靠什么活下来?靠两个字:缓存。
- 缓存友好(cache-friendly)。开放定址的探测全部发生在同一个连续数组内, 线性探测的第 1、2、3 次探测大概率落在同一个 64 字节缓存行里, 一次内存访问能顺带把后面几个槽也拉进 L1。 拉链法则相反:每跳一个结点都是一次指针追逐(pointer chasing), 可能跨越整个堆内存,每次都是一次缓存未命中——这就是「同样 O(1), 实测差 3~10 倍」的根源。
- 无指针开销、无单独分配。拉链法每个元素都要额外存一个(或两个)next 指针,
而且每个结点都是一次
new——对int → int这种小元素, 指针本身比数据还大(数据 8 字节 + 指针 8 字节 + 分配器对齐与元数据, 实打实的开销常常是 2~3 倍)。开放定址把这笔钱全省了, 这也是flat_hash_map里「flat(扁平)」两个字的由来。
½(1 + 1/(1−α))、失败 ½(1 + 1/(1−α)²)),
数值精确到两位小数,可以直接和你的实验对上:
| 装填因子 α | 线性探测 成功 ASL | 线性探测 失败 ASL | 平方探测 / 双散列 成功 ASL | 平方探测 / 双散列 失败 ASL | 链地址法 成功 ASL |
|---|---|---|---|---|---|
| 0.25 | 1.17 | 1.39 | 1.15 | 1.33 | 1.13 |
| 0.50 | 1.50 | 2.50 | 1.39 | 2.00 | 1.25 |
| 0.75(工程阈值) | 2.50 | 8.50 | 1.85 | 4.00 | 1.38 |
| 0.90 | 5.50 | 50.50 | 2.56 | 10.00 | 1.45 |
| 0.99 | 50.50 | 5000.50 | 4.65 | 100.00 | 1.50 |
读这张表只需要看三行。α = 0.75 时,线性探测一次不成功查找平均要探 8.5 个槽,链地址法只要 1.38 个结点——还能忍。 α = 0.90 时,线性探测的失败代价跳到 50.5——一次查找要摸 50 个槽, 已经在崩溃边缘。而 α = 0.99 时失败代价是 5000.5: 一个 m = 100 的表,一次「查不到」平均要扫 50 遍全表。 注意线性探测成功 ASL 在 α = 0.99 时也涨到了 50.5—— 这时候哈希表已经没有资格叫哈希表了,它退化成了一条 O(n) 的链表, 还得额外付出哈希计算的钱。
① 空间那一侧:阈值定得越低(比如 0.5),空槽越多,浪费的内存越多; 定得越高(比如 0.9),探测次数爆炸。0.75 恰好是「浪费 25% 空间」换 「失败查找仍在 10 次探测以内」的折中点——α = 0.75 时线性探测失败 ASL = 8.5 < 10, 而 α = 0.90 时 = 50.5,已经超出一个数量级。
② 安全那一侧(这条常被忽略):当 α = 0.75 时, 随便两个元素撞进同一个桶的概率大约是
α × (1/m) 量级,
桶内链表几乎没有机会长到很长;而阈值放到 0.9 以上时,
恶意构造的同桶键更容易把链拉长,
哈希碰撞攻击(10.8.3 详述)的成功率和破坏力都会显著上升。③ 工程实证:Java
HashMap 把 DEFAULT_LOAD_FACTOR
硬编码为 0.75f;Go 的 map 在 α > 6.5 时触发扩容(它用的是拉链法,
所以阈值可以远大于 1);C++ 的 unordered_map 默认
max_load_factor() 是 1.0——这是标准给的下限,
工程上应当手动调到 0.75 左右。这几个数字背后是同一笔账。
顺带回答一个常见追问:Java 8 之后为什么还要把长链「树化」? 因为纯链表在极端情况下(哈希函数差,或者有人恶意构造)会退化到 O(n)。 所以 JDK 8 起规定:单条链长度 ≥ 8 且桶数组长度 ≥ 64 时, 把这条链转成红黑树,把最坏情况从 O(n) 拉回 O(log n)。 这是「拉链法 + 平衡树」的混合结构,也是对哈希碰撞攻击最直接的一种防御—— 就算你全塞进一个桶,我也只花 O(log n)。
10.8.6 扩容(rehash):为什么是翻倍,以及 O(n) 的长尾延迟
拉链法和开放定址法都躲不开同一件事:α 涨到阈值就得扩容。 扩容做的事很朴素——申请一个更大的桶数组,然后把所有元素重新算一遍落点搬过去。 两件事值得掰开讲:为什么一次扩到两倍,以及这一次 O(n) 的代价藏在哪里。
m → m+1),把 n 个元素依次插入:
第 1 次扩容搬 1 个、第 2 次搬 2 个……第 n 次搬 n 个,
总搬运量是 1 + 2 + … + n = n(n−1)/2。
取 n = 106,总搬运量约 5×1011 次,
平摊到每次插入是 50 万次搬运——插入从「期望 O(1)」直接变成 O(n)。
再看翻倍。桶数按
1, 2, 4, 8, …, 2k 增长,
第 i 次扩容搬 2i−1 个元素,总搬运量是
1 + 2 + 4 + … + 2k−1 < 2k ≤ 2n。
n 次插入的总搬运量不超过 2n,平摊到每次插入只有 2 次——
常数级。同样的 n = 106,翻倍策略的总搬运量是 2×106,
比「每次加一点」省了 25 万倍。
这就是均摊分析(amortized analysis):单次操作可能很贵, 但只要「贵操作」之间的间隔按几何级数拉长,平均下来就是 O(1)。 C++ 的
std::vector::push_back、Java 的 ArrayList、
Go 的 slice、Redis 的字典,走的都是这套数学——
第 02 讲讲动态数组扩容时推过的式子,在哈希表这里一字不改地又用了一遍。
这个现象叫长尾延迟(tail latency)。对吞吐量敏感的服务(离线批处理、日志分析) 它无所谓——总量就那么多;但对延迟敏感的服务,它是必须专门处理的故障源:
- 游戏服务器:一次扩容卡 100 ms,就是全服玩家一起卡一下, 表现为「技能放出去没反应」;
- 实时交易 / 撮合引擎:行情推送上每一个 99 分位延迟都被严格监控, 一次扩容的抖动足以触发风控熔断;
- 广告 / 推荐在线服务:SLA 通常按 P99 甚至 P999 考核, 平均值再好看,只要 P999 出现尖刺就算事故。
工程上的四种应对,从便宜到复杂:
- 预留(reserve)—— 最便宜也最有效。如果能预知规模,就在插入前一次性
reserve(n),让扩容次数从 log n 次变成 0 次。 代价是多占内存(要按最终规模分配),而且预判错了照样要扩。 竞赛里这条是硬规矩:unordered_map不写reserve经常就是 TLE 与 AC 的差别。 - 分批扩容(渐进式 rehash,incremental rehashing)—— Redis 的做法。
Redis 的字典在扩容时同时保留两张哈希表(
ht[0]旧表、ht[1]新表),但不一次性搬完:每次增删改查都顺手把ht[0]里的一个桶(连同它的链表)整体搬到ht[1], 查找时两张表都查(先查 ht[0] 再查 ht[1]),新增的元素只往 ht[1] 里放。 等ht[0]搬空,再释放它、把ht[1]改名成ht[0]。 这样一来,单次操作的额外代价被摊薄成常数,任何一次请求都不会出现长尖刺。 代价是:扩容期间内存要同时装下两张表(峰值接近 2 倍), 而且每条操作路径都要写「两张表都要看」的分支,代码复杂度显著上升。 - 后台线程搬迁。Go 的 map 在触发扩容后会由一个后台 goroutine 逐步把旧桶搬到新桶(同时配合写屏障保证正确性)。 代价是语言运行时复杂度飙升,而且并发读写 map 本身仍然不安全。
- 提前预热。在线服务启动时先用一批假数据把哈希表「喂」到目标容量, 让扩容发生在上线之前。代价是启动变慢、需要维护预热数据。
10.8.7 std::unordered_map 的真实结构与防卡哈希
现在把镜头对准 C++ 里最常用的那个容器。std::unordered_map 与
std::unordered_set 是 C++11 引入的哈希容器,
标准对实现的约束只有一条:平均 O(1)、最坏 O(n),并规定「桶(bucket)」这个接口。
具体实现由各家标准库自由发挥:
std::hash<int> 在主流实现里
基本就是「恒等映射」(hash(x) = x),再叠上标准库自己的桶取模;
桶数在很多实现里是公开的质数序列或 2 的幂。
于是攻击者(或者在竞赛里,出题人)可以故意构造一组全部同余的关键字
(比如所有 k ≡ 0 (mod p)),让它们全部落进同一个桶——
哈希表退化成一条链表,O(1) 变 O(n)。
这个攻击有专门的名字:哈希碰撞攻击(hash flooding / hash DoS)。
这是真实发生过的安全事故,不是理论玩具:攻击者只要往你的 HTTP 表单里 塞几十万个经过挑选的参数名,就能让服务端解析表单的哈希表集体退化, 把一台服务器打瘫。所以 Python、Ruby、Java、Perl 都先后把字符串哈希改成了 「每次进程启动随机一次种子」——Redis 也提供了
hash-seed 配置项。
本质都是一句话:让攻击者算不出落点。
这也是为什么本节的防卡模板里那个种子必须来自
chrono::steady_clock::now(),而不能写成固定常数。
加了随机种子之后,攻击者无法预知落点,构造不出「全部同桶」的数据集,
攻击就从「必然成功」变成「期望不成功」。完整的可编译模板(
custom_hash / custom_hash_str,含攻击演示对比)
见下面的代码块——它就是竞赛选手的保命工具。
10.8.8 数据库索引为什么主要用 B+ 树而不是哈希
这是「哈希这么快,为什么数据库不用它当索引」这个问题的答案。 一句话:哈希只支持等值查询,而真实的查询语句里大部分不是等值查询。
哈希函数的定义决定了它是一台只能回答「是 / 否」的判等机器: 它把键打散到桶里,打散之后原有的顺序信息被彻底销毁了。 一个键的哈希值是 0x8f3a… ,这完全不告诉你「它比 42 大还是小」。 所以下面这几类查询,哈希索引一个都做不了(只能全表扫描):
- 范围查询:
WHERE id BETWEEN 100 AND 200、WHERE created_at > '2024-01-01'。 这是索引最常见的用途(分页、时间窗口、区间统计),哈希索引直接失效。 - 排序:
ORDER BY id、ORDER BY name LIMIT 10。 B+ 树的叶子结点本身就是按顺序串起来的链表, 顺着叶子链一扫就是有序输出;哈希表里的元素在物理上毫无顺序, 要排序只能把所有命中行拉出来再排一次。 - 最左前缀匹配:联合索引
(a, b, c)上, B+ 树可以用WHERE a = 1、WHERE a = 1 AND b > 2这种「只用前几个列」的查询;哈希索引要求把索引的全部列都写全 才能算出哈希值,少一列就用不上。 - 分组与去重:
GROUP BY、DISTINCT也需要有序扫描, B+ 树的顺序性又赢一次。
所以数据库默认选 B+ 树(这本该是第 07 讲树结构的延伸话题,这里只做工程收口): 它是一棵多路平衡树,一个结点装几百个键, 树高在 3~4 层就能覆盖上亿行数据;更关键的是它的 叶子结点连成有序链表,把「等值查找 O(log n)」「范围扫描」「排序」 「最左前缀」四件事一次性全包了。 哈希在等值查询上确实更快(一次哈希 vs 三次磁盘 I/O), 但它只会这一件事——为一个能力付出一整棵树的代价,数据库不干。
CREATE TABLE t (...) ENGINE = MEMORY,
并且它的索引可以选 USING HASH——
因为 MEMORY 表整个活在内存里,不需要考虑磁盘 I/O,哈希的等值优势就体现出来了。
但代价写得明明白白:HASH 索引只支持 = 和 <=>,
不支持 <、>、BETWEEN、ORDER BY,
也不支持最左前缀(USING BTREE 才支持)。
更要注意:MEMORY 引擎的默认索引类型恰好就是 HASH,
很多人建完表写了范围查询发现用不上索引,就是踩了这个默认值。
所以哈希索引的生存空间只有一类场景:只做等值查找的内存表—— MySQL MEMORY 表的主键查找、Redis 的哈希键、以及各种进程内的
<key, value> 缓存。一旦查询里出现范围或排序,哈希就必须让位给树。
10.8.9 布隆过滤器:用一个位数组换掉 99% 的无效查询
前面几节讲的都是「怎么把元素存进哈希表」。布隆过滤器(Bloom Filter)反过来—— 它不存元素,只存「这个元素来过」的痕迹,用极小的空间换一个「大概率能判断」的能力。 它是哈希在工程里最漂亮的一次「降维使用」。
- 开一个长度为 m 的位数组,每一位初始为 0;
- 准备 k 个相互独立的哈希函数;
- 插入 x:算出 k 个位置,把它们全部置 1(不去重、不记录是谁置的);
- 查询 x:算出同样的 k 个位置,只要有一个是 0 → x 一定没插入过; k 个全是 1 → x 可能插入过。
这套规则推出布隆过滤器最重要的两条性质:
- 没有假阴性(no false negative)。插入过的元素,它的 k 个位置一定都被置成了 1, 所以查询必然返回 true。这条性质是逻辑保证,不是概率—— 代码里 1000 个元素全部命中、假阴性 0 个,就是这个道理。
- 有假阳性(false positive)。一个没插入过的元素, 它的 k 个位置可能恰好被别的元素们一起置成了 1, 于是被误判为「存在」。这不是 bug,是设计的一部分—— 位是被所有元素共享的,撞车不可避免,只能用 m 和 k 把概率压下去。
要支持删除,只能加信息量:计数布隆过滤器(Counting Bloom Filter) 把每个位换成一个 4 位计数器,插入 +1、删除 −1,代价是空间变成 4 倍; 或者换用 Cuckoo Filter(支持删除且空间更省), 但在「只增不删」的经典场景里,普通布隆过滤器依然是最省的选择。
空间到底省到什么程度?看下面这段程序的真实输出: 按 1% 的目标误判率,存 1000 个元素只需要 m = 9586 bit ≈ 1.17 KB,k = 7 个哈希函数; 实测 10000 个未插入元素中误判 96 个, 实测假阳性率 0.96%,与理论值 1.00% 吻合。 把规模放大到 800 万个元素:m = 76680468 bit ≈ 9.14 MB 就够了—— 而如果老老实实用 64 位整数存这 800 万个元素,光数据本身就是 800 万 × 8 B = 61 MB。也就是说, 布隆过滤器用大约 1/6.7 的空间,就买到了「过滤掉 99% 无效查询」的能力—— 代价是那 0.96% 的假阳性,以及彻底放弃「取出元素」和「删除元素」。
// bloom_filter.cpp —— 布隆过滤器完整实现:验证「无假阴性」并实测假阳性率
// 编译: g++ -std=c++17 -O2 -o bloom_filter bloom_filter.cpp
// 运行: ./bloom_filter
#include <bits/stdc++.h>
using namespace std;
/* =====================================================================
布隆过滤器 = 一个位数组 + k 个哈希函数
add(x) :把 k 个哈希位置全部置 1
query(x):k 个位置全是 1 → 返回 true("可能存在")
只要有一个是 0 → 返回 false("一定不存在")
关键性质:无假阴性(插入过的必然返回 true),但有假阳性。
===================================================================== */
struct BloomFilter {
vector<unsigned char> bit; // 位数组:每个字节装 8 个 bit
size_t m; // 位数组长度(bit 数)
int k; // 哈希函数个数
/* 由「预期元素个数 n」和「可容忍的误判率 p」反推 m 与 k:
m = -n·ln(p) / (ln2)^2 k = (m/n)·ln2 */
BloomFilter(size_t n, double p) {
double ln2 = log(2.0);
m = (size_t)ceil(-(double)n * log(p) / (ln2 * ln2));
k = (int)max(1.0, round(((double)m / (double)n) * ln2));
bit.assign((m + 7) / 8, 0);
}
/* ---------- 两个相互独立的 64 位字符串哈希函数 ---------- */
/* 1 号:FNV-1a 64 位(offset basis 与 prime 用标准值) */
static uint64_t fnv1a(const string& s) {
uint64_t h = 1469598103934665603ULL;
for (unsigned char c : s) {
h ^= (uint64_t)c;
h *= 1099511628211ULL;
}
return h;
}
/* 2 号:FNV-1a 变体(换一组质数)再叠 splitmix64 混合,落点与 1 号无关 */
static uint64_t fnv1b(const string& s) {
uint64_t h = 14695981039346656037ULL;
for (unsigned char c : s) {
h ^= (uint64_t)c;
h *= 1099511628211ULL + 2;
h ^= h >> 29;
}
h += 0x9e3779b97f4a7c15ULL; // splitmix64 的雪崩混合
h = (h ^ (h >> 30)) * 0xbf58476d1ce4e5b9ULL;
h = (h ^ (h >> 27)) * 0x94d049bb133111ebULL;
return h ^ (h >> 31);
}
/* ---------- 置位与查位 ---------- */
void setBit(size_t pos) { bit[pos >> 3] |= (unsigned char)(1u << (pos & 7)); }
bool getBit(size_t pos) const { return (bit[pos >> 3] >> (pos & 7)) & 1u; }
/* ---------- 核心接口 ---------- */
void add(const string& s) {
uint64_t h1 = fnv1a(s), h2 = fnv1b(s);
for (int i = 0; i < k; ++i) {
/* 双哈希技巧:h1 + i·h2 等价于又造出 k 个"独立"哈希函数 */
setBit((size_t)((h1 + (uint64_t)i * h2) % m));
}
}
bool query(const string& s) const {
uint64_t h1 = fnv1a(s), h2 = fnv1b(s);
for (int i = 0; i < k; ++i) {
/* 有一个位置是 0 → 必定没插入过(这条是逻辑保证,不是概率) */
if (!getBit((size_t)((h1 + (uint64_t)i * h2) % m))) return false;
}
return true; // k 个位置全 1 → 可能插入过(也可能是被别人"蹭"亮的)
}
double bits() const { return (double)m; }
double bytes() const { return (double)((m + 7) / 8); }
int hashes() const { return k; }
/* 插入 n 个元素后的理论误判率 (1 - e^(-kn/m))^k 的等价形式 */
double theoryFpr(size_t n) const {
double alpha = (double)k * (double)n / (double)m; // 位数组被填满的比例
return pow(1.0 - exp(-alpha), (double)k);
}
};
int main() {
const size_t N_ADD = 1000; // 插入 1000 个元素
const size_t N_QRY = 10000; // 再查 10000 个未插入的元素
const double P = 0.01; // 目标误判率 1%
BloomFilter bf(N_ADD, P);
printf("位数组 m = %zu bit = %.2f KB,哈希函数 k = %d\n",
(size_t)bf.bits(), bf.bytes() / 1024.0, bf.hashes());
printf("目标误判率 p = %.2f%%\n\n", P * 100);
/* ---------- 1. 插入 1000 个,验证「无假阴性」 ---------- */
for (size_t i = 0; i < N_ADD; ++i) bf.add("item-" + to_string(i));
size_t hit = 0;
for (size_t i = 0; i < N_ADD; ++i) if (bf.query("item-" + to_string(i))) ++hit;
printf("已插入 %zu 个元素,查询命中 %zu 个 —— 假阴性 %zu 个\n", N_ADD, hit, N_ADD - hit);
/* ---------- 2. 查询 10000 个未插入的元素,实测假阳性 ---------- */
size_t fp = 0;
for (size_t i = 0; i < N_QRY; ++i) if (bf.query("other-" + to_string(i))) ++fp;
printf("查询 %zu 个未插入元素,误判为「存在」%zu 个 —— 实测假阳性率 %.2f%%\n",
N_QRY, fp, 100.0 * (double)fp / (double)N_QRY);
/* ---------- 3. 与理论值对照 ---------- */
printf("理论假阳性率 (1-e^{-kn/m})^k = %.2f%%\n", bf.theoryFpr(N_ADD) * 100.0);
/* ---------- 4. 空间对照:表示 800 万个元素要多少空间 ---------- */
BloomFilter big(8000000, 0.01);
printf("\n对照:按 1%% 误判率,表示 800 万个元素需要 m = %zu bit = %.2f MB\n",
(size_t)big.bits(), big.bytes() / 1024.0 / 1024.0);
printf(" 而 800 万个 64 位整数本身就要 %.0f MB —— 省了约 %.1f 倍\n",
8000000.0 * 8 / 1024 / 1024, (8000000.0 * 8) / big.bytes());
return 0;
}
真实系统里它站在哪四个位置?
- 缓存穿透防护(最经典)。查询流程是「先查 Redis,没有就查数据库」。 但如果有恶意请求反复查询根本不存在的 key,Redis 每次都 miss, 请求就全部穿透到数据库——这就是缓存穿透。 解法:把数据库里所有存在的 key 预先塞进一个布隆过滤器, 请求进来先过一遍——布隆过滤器说「不存在」,就直接返回空,一次数据库都不查。 那 1% 的假阳性会漏到数据库,但 99% 的恶意流量被挡在门外, 而代价只是几十 MB 内存。
- 爬虫 URL 去重。爬虫要判断「这个 URL 是不是已经抓过」。 真实的 URL 库动辄上亿条,用哈希集合存要几十 GB; 用布隆过滤器,上亿条 URL 只要几百 MB。 假阳性在这里恰好是「安全的错误」:把没抓过的 URL 误判成抓过, 最多漏掉几个页面,不会让爬虫出错或重复入库—— 这正是布隆过滤器最适合的「错一边也无所谓」的语义。
- BigTable / LevelDB / RocksDB 的 SSTable 查询。 这些 LSM-Tree 存储引擎把数据按层组织成一个个 SSTable 文件。 如果每次查找都要逐个文件去磁盘上二分,I/O 会爆掉。 所以每个 SSTable 都附一个布隆过滤器,记录这个文件里有哪些 key; 查询时先用内存里的布隆过滤器筛一遍, 说「没有」的文件直接跳过,一次磁盘 I/O 都不产生。 这是布隆过滤器在存储引擎里最核心的用途——用内存换 I/O。
- 比特币 SPV 轻节点。手机钱包不想下载几百 GB 的完整区块链, 只想要「和我有关的交易」。于是它把自己所有地址放进一个布隆过滤器 发给全节点,全节点用它过滤区块里的交易,只把可能相关的交易回传。 假阳性在这里反而是一种隐私保护:全节点无法准确知道你关心哪些地址, 因为过滤器里总会混进一些无关的「疑似地址」。
10.8.10 一致性哈希:分布式缓存扩缩容时,怎么只搬 1/N 的数据
前面讲的都是一台机器内的哈希。现在把哈希表放大成一个集群: N 台缓存服务器(Redis Cluster、Memcached), 要决定「键 k 该存到哪台机器上」。最直觉的写法是取模:
单机时代这个式子没毛病,但到了集群里它有一个致命缺陷: N 一变,几乎所有键的归属都要重算。
- 原本落在「第 4 台」上的键(约占 1/4 = 25%)需要重新分配;
- 原本落在「第 1、2、3 台」上的键(占 75%),
其
hash(k) mod 5的结果也不再等于原来的下标—— - 算下来,只有
hash(k) mod 20恰好落在 0~4 的那批键能留在原地, 占 1/5 = 20%。也就是说 80% 的键(8 万个)都要换机器。
N/(N+1),N = 100 时就是 99%)。
- 把哈希值的整个取值空间
[0, 232)首尾相接, 想象成一个圆环(hash ring); - 把每台机器(用它的 IP 或名字)也哈希一次,落在环上的某一点—— 这个点叫结点(node);
- 要定位一个键 k,先算
hash(k)落在环上哪一点, 然后沿顺时针方向走,遇到的第一个结点就是它的归属; - 换句话说:每个结点「负责」从它自己开始、逆时针到上一个结点之间的那段弧。
迁移量为什么是 1/N?加进第 N+1 个结点时,它在环上随机落一个位置,
只会从某一个原有结点手里「抢走」一段弧。
平均而言这段弧占整个环的 1/(N+1),
所以只有约 1/(N+1) 的键需要迁移,其余 N/(N+1) 原封不动。
回到 N = 4 → 5 的例子:普通取模要搬 80%,
一致性哈希只搬 20%(约 2 万个键)——迁移量降到了 1/4。
下面图 10-12 把这个对比画了出来,左边是环上的迁移范围,右边是两种策略的迁移量直方图。
解法是虚拟结点(virtual node / vnode):不再让一台物理机器只占环上 1 个点, 而是给它生成 几百到上千个副本点,比如
hash("10.0.0.1#0")、hash("10.0.0.1#1")……,
每个副本点都算一个独立的结点,但都映射回同一台物理机器。
这样每台机器在环上占据的就不再是一段连续长弧,
而是几百段小弧,均匀撒在环上——
根据大数定律,各机器的总负载就均衡了。
常见配置是每台物理机 100~1000 个虚拟结点。
代价有三条: ① 内存——环上每个虚拟结点都要存一条记录,100 台机器 × 500 个 vnode 就是 5 万个点的路由表; ② 查找变慢——定位时要在一张更大的有序表上做二分, 从 O(log N) 变成 O(log(N × vnode)); ③ 迁移粒度变细但总量不变——迁移的仍然是「被抢走的那段弧」, 只是它现在由很多小弧拼成,落在多台机器上。
补一个真实的对照:Redis Cluster 没有用一致性哈希, 它用的是哈希槽(hash slot)——固定 16384 个槽,先算
CRC16(key) mod 16384 定槽,再由人把槽指派给结点。
本质上这是「手工指定的一致性哈希」,槽数够多所以天然均衡,
搬数据时以槽为单位搬,粒度清晰可控。
hash(k) mod N,N 一变,
几乎全部键要迁移(N/(N+1),N = 100 时是 99%),引发缓存雪崩;② 解法:结点和键都映射到一个哈希环上,键归属「顺时针第一个结点」, 扩缩容只影响相邻一段弧,迁移量降到
1/(N+1)(N = 4 时 20%);③ 补丁:结点少时会数据倾斜,用虚拟结点(每台几百个副本点)摊平负载, 代价是更大的路由表和更慢的查找。代表系统:Memcached 客户端、 Cassandra、DynamoDB、Nginx 的
hash ... consistent 指令。
10.8.11 工程选型对比表:五个结构,五个真实维度
把本节讲的东西压成一张表。这张表不比 ASL(那是 10.7 的事), 比的是工程师真正关心的现实维度:典型用途、空间开销、能不能删、能不能范围查、 谁在用。背着这张表去面试,比背公式有用。
| 结构 | 典型用途 | 空间开销 | 是否支持删除 | 是否支持范围查询 | 真实系统举例 |
|---|---|---|---|---|---|
| 链地址法 桶数组 + 链表 |
通用键值容器;元素较大或频繁增删的场合 | 高:每元素多 1~2 个指针 + 一次独立分配 小元素时开销可达数据本身的 2~3 倍 |
支持,O(1)(摘链即可) 无需墓碑 |
不支持 | Java HashMap(链长 ≥ 8 且表长 ≥ 64 转红黑树);C++ unordered_map(libstdc++ / libc++ / MSVC);Go map;MySQL HASH 索引 |
| 开放定址 线性探测 / 平方探测 / 双散列 |
追求极致查找速度、内存紧凑的内存内哈希表 | 低:只有连续数组,无指针 空槽必须留 25%~50%,α ≤ 0.75 |
麻烦:必须用墓碑,墓碑占槽且查找不能停 墓碑过多需原地 rehash 重建 |
不支持 | absl::flat_hash_map;Rust hashbrown;Python 3.6+ 的紧凑 dict;RocksDB / LevelDB 的 memtable 索引 |
| B+ 树索引 多路平衡树,叶子成链 |
数据库 / 文件系统的磁盘索引;一切需要范围与排序的查找 | 中:结点内有序数组,无逐元素指针 树高 3~4 层可覆盖上亿行 |
支持,O(logmn) 但页分裂/合并有写放大 |
支持:范围扫描、排序、最左前缀、GROUP BY 全能 | MySQL InnoDB 聚簇索引;PostgreSQL B-tree;Oracle;SQLite;文件系统目录(ext4 / NTFS 的 B+ 树变体) |
| 布隆过滤器 位数组 + k 个哈希 |
「这个元素一定不在」的快速否定判断;用内存换 I/O 或换 DB 压力 | 极低:1% 误判率下每元素约 1.2 B 800 万元素仅 9.14 MB(对照:64 位整数要 61 MB) |
不支持:位被多元素共享,清 0 会伪造假阴性 要删需换计数布隆过滤器(4 倍空间)或 Cuckoo Filter |
不支持 | Redis BF.ADD(RedisBloom 模块)做缓存穿透防护;Chrome 的恶意 URL 预检;LevelDB / RocksDB / BigTable 的 SSTable 查询;比特币 SPV 轻节点 |
| 一致性哈希 哈希环 + 顺时针归属 |
分布式缓存的键路由;扩缩容时控制数据迁移量 | 低:只需一张「虚拟结点 → 物理机」路由表 100 台 × 500 vnode = 5 万条 |
支持(删结点) 只迁移该结点负责的那段弧,约 1/N |
不适用(它解决的是「路由到哪台机器」,不是「查一个区间」) | Memcached 客户端(ketama);Cassandra / DynamoDB 的分区策略;Nginx hash consistent;Envoy / HAProxy 的负载均衡;对照:Redis Cluster 用 16384 个哈希槽 |
最后补一句选型心法:先问「查询长什么样」,再问「数据有多大」,最后才问「要多快」。 查询里有范围或排序 → 直接排除哈希,去找树(第 07 讲的 B 树 / B+ 树); 查询全是等值且数据能进内存 → 哈希; 只需要回答「在不在」且数据量巨大 → 布隆过滤器; 数据分散在多台机器上 → 一致性哈希负责路由。 结构选错,代码再漂亮也救不回来。
10.9 本章小结、易错点与自测
10.9.1 必须记住的十二件事
查找基础
- ASL = Σ pici;等概率时
= (Σci)/n。 - 成功 ASL 与失败 ASL 必须分开算:分母分别是 n 和 m(或 n+1)。
- 静态查找表只要查询;动态查找表还要插入删除。
折半查找
- 前提:顺序存储 + 有序,链表不行。
- 判定树是平衡二叉树,树高
⌈log2(n+1)⌉。 - n = 11 时
ASL成功 = 33/11 = 3,层结点数 1、2、4、4。 mid = left + (right − left)/2,防溢出。
三种变体查找
- 插值查找:均匀数据 O(log log n),偏斜数据退化成 O(n)。
- 斐波那契查找:只用加减法,平均略优于折半,最坏同为 O(log n);往右找 k −= 2。
- 分块查找:块间有序块内无序,
s = √n时ASL ≈ √n + 1。
哈希表
- 除留余数法最常用,
p取不大于表长的最大质数。 - 删除必须用墓碑;ASL 只与装填因子 α 有关,开放定址要求 α < 1。
10.9.2 易错点清单
mid = ⌊(low+high)/2⌋ 决定,与元素值无关。
画树时先写下标序列,再把值填进去。mid 向下取整还是向上取整会影响树形,
题目一般规定「向下取整」,若没规定请在答卷上写明你的约定。
k mod 2t 只保留 k 的最低 t 位,高位信息全部丢弃。
真实数据的低位往往高度规律(都是 0 结尾),会造成严重聚集。
所以要么用质数,要么像 Java HashMap 那样用 2 的幂 + 扰动函数。
4j+3 形式的质数(7、11、19、23、31…)才能保证探测到全表。
若取 m = 12,i2 mod 12 只有 0、1、4、9 四种取值,
有 8 个位置永远探不到,插不进去。
k -= 2。左块长度是 F[k−1]−1,右块是 F[k−2]−1,
mid 自己占掉一档。写错不会报错,只会静默算错结果。
α 越小查找越快,但空间浪费越大,而且 rehash 更频繁。
生产环境普遍取 0.75 左右,这是「查找速度 / 内存 / 扩容代价」的工程折中,
不是理论最优值。理论最优要看具体场景。
for (auto& p : mp) 的遍历顺序是不确定的。
要范围查询(找出所有 60 ≤ key ≤ 80 的元素)必须用平衡树或跳表。
这也是为什么「哈希表 O(1) 更快,却仍然需要 map」。
- 顺序查找:成功
(n+1)/2;失败n(哨兵 n+1);有序表失败n/2 + n/(n+1) - 折半查找:
h = ⌈log2(n+1)⌉;n=11 → 成功 3、失败 14/3;n=7 → 成功 17/7 - 分块查找:
s = √n时ASL = √n + 1 - 哈希表:
α = n/m;成功除以 n、失败除以 m;开放定址α < 1,实践 ≤ 0.75
10.9.3 自测题(6 题,答案折叠)
1. 对有序表 a[0..10](n = 11)做折半查找,请画出判定树,并求 ASL成功 与 ASL不成功。
判定树(约定 mid = ⌊(low+high)/2⌋,向下取整):
- 第 1 层:mid = 5
- 第 2 层:左 mid = 2,右 mid = 8
- 第 3 层:0、3、7、10(由 [0,1]、[3,4]、[6,7]、[9,10] 得到)
- 第 4 层:1、4、6、9
每层结点数依次为 1、2、4、4。
成功 ASL:
不成功 ASL:外部结点共 n+1 = 12 个。第 4 层的 4 个叶子各挂 2 个外部结点(深度 5),
第 3 层的 4 个结点各挂 1 个外部结点(深度 4),其余结点无外部结点。
所以深度总和 = 4×2×5 + 4×1×4 = 40 + 16 = 56。
检验:ASL失败 应介于 ASL成功 = 3 与树高 4 之间,
3 < 3.73 < 4 ✓。
2. 表长 m = 13,H(k) = k mod 13,线性探测。依次插入 19、14、23、1、68、20、84、27,请画出最终的表并求两种 ASL。
逐步插入:
19 mod 13 = 6→ 6 空,放 6(1 次)14 mod 13 = 1→ 1 空,放 1(1 次)23 mod 13 = 10→ 10 空,放 10(1 次)1 mod 13 = 1→ 1 占(14) → 2 空,放 2(2 次)68 mod 13 = 3→ 3 空,放 3(1 次)20 mod 13 = 7→ 7 空,放 7(1 次)84 mod 13 = 6→ 6 占(19) → 7 占(20) → 8 空,放 8(3 次)27 mod 13 = 1→ 1 占(14) → 2 占(1) → 3 占(68) → 4 空,放 4(4 次)
最终表:下标 0 空、1:14、2:1、3:68、4:27、5 空、6:19、7:20、8:84、9 空、10:23、11 空、12 空。
成功 ASL(分母 n = 8,比较次数分别是 1,1,1,2,1,1,3,4):
不成功 ASL(分母 m = 13,从每个哈希地址出发探到第一个空槽,空槽也计 1 次):
| 起点 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 探测路径 | 0 空 | 1→2→3→4→5 | 2→3→4→5 | 3→4→5 | 4→5 | 5 空 | 6→7→8→9 | 7→8→9 | 8→9 | 9 空 | 10→11 | 11 空 | 12 空 |
| dj | 1 | 5 | 4 | 3 | 2 | 1 | 4 | 3 | 2 | 1 | 2 | 1 | 1 |
例:起点 1 → 1(14)、2(1)、3(68)、4(27) 全部占用 → 5 空,共 5 次; 起点 6 → 6(19)、7(20)、8(84) → 9 空,共 4 次; 起点 10 → 10(23) → 11 空,共 2 次。(表中加粗的是那个终止探测的空槽。)
合计验算:1+5+4+3+2+1 = 16,4+3+2+1 = 10,2+1+1 = 4,总计 16+10+4 = 30。
若采用「只数已占用格子、不计最后那次空判定」的约定,则为
(0+4+3+2+1+0+3+2+1+0+1+0+0)/13 = 17/13 ≈ 1.31。
两种约定都对,但要写清约定。
3. 为什么开放定址法删除元素时必须打「墓碑」?请举一个具体反例说明直接置空的后果。
因为开放定址法中「空槽」是探测链的终止标志:查找算法一旦遇到空槽, 就断定「目标不存在」。如果删除时直接置空,就会在探测链中间制造一个假的终止点, 把它后面那些因冲突而绕过来的元素全部「藏起来」。
反例(m = 11,H(k) = k mod 11,线性探测):
- 插入 41:
41 mod 11 = 8→ 放 8 号位。 - 插入 53:
53 mod 11 = 9→ 放 9 号位。 - 插入 30:
30 mod 11 = 8→ 8 占 → 9 占 → 放 10 号位。
此时 30 的「家」在 8 号位,实际住在 10 号位,中间隔着 41、53。
现在直接清空 9 号位(删除 53),再查找 30:
从 H(30) = 8 出发 → 8 号是 41(不等)→ 9 号是空的 →
算法返回「30 不存在」。但 30 明明就在 10 号位!这是一个隐蔽的、只在「删除过」的表上出现的错误。
正确做法:把 9 号位标成墓碑 DELETED。 查找时墓碑不算终止(继续往下探),所以能顺利走过 9 号位找到 10 号位的 30; 而插入时墓碑可以复用(放新元素进去,顺便清掉墓碑)。 一句话:墓碑挡查找、不挡插入。
4. 有序表 {1, 11, 21, 31, 41, 51, 61, 71, 81, 91, 101}(n = 11)分别用折半查找和插值查找查 key = 31,各需要几次比较?并说明插值查找在什么数据上会失效。
折半查找(mid = ⌊(low+high)/2⌋,数据恰好等差所以 31 在下标 3):
- low=0, high=10 → mid=5,a[5]=51 > 31 → high=4(1 次)
- low=0, high=4 → mid=2,a[2]=21 < 31 → low=3(2 次)
- low=3, high=4 → mid=3,a[3]=31 == 31 → 命中(3 次)
共 3 次比较。(这里数据恰好对称,mid 向上取整会走
a[5] → a[2] → a[3],比较次数同样是 3;但换成别的 key,
取整方向就会改变比较次数,所以答题时务必写明你采用的约定。)
插值查找:
共 1 次比较。这就是插值查找在均匀数据上的威力。
失效场景:数据分布极不均匀时。例如 a[i] = 2i
(1, 2, 4, …, 1024),查 key = 3 时
mid = 0 + (3−1)/(1024−1) × 10 ≈ 0.02 → 0,
只排除掉 1 个元素;下一步 low=1,mid = 1 + (3−2)/(1024−2) × 9 ≈ 1.008 → 1,
又只排除 1 个元素……每次只前进一格,退化成顺序查找 O(n)。
根因是分母 a[high] − a[low] 被右侧巨值撑爆,比例恒接近 0,mid 永远贴着 low。
5. 链地址法中,为什么装填因子 α 可以大于 1,而开放定址法必须小于 1?两者的 ASL 与 α 的关系有何不同?
开放定址法必须 α < 1:因为所有元素都直接存在哈希表数组内部,
数组只有 m 个槽。若 n > m(即 α > 1),根据鸽巢原理必然无槽可放,插入直接失败。
这不是性能问题,而是正确性问题。而且 α 越接近 1,探测链越长,
失败 ASL 的公式 ½(1 + 1/(1−α)2) 会趋向无穷。
链地址法的 α 可以大于 1:因为元素存在表外的链表结点里,
桶数组只是「链表的头指针数组」,链可以无限延长。α = 3 时平均每条链 3 个结点,
依然能正常工作,只是变慢。ASL成功 = 1 + α/2 是关于 α 的线性函数,
没有分母、不会爆炸。
结论对比:
| 开放定址(线性探测) | 链地址法 | |
|---|---|---|
| α 的范围 | 必须 < 1,实践 ≤ 0.75 | 任意正数,可 > 1 |
| 成功 ASL | ½(1 + 1/(1−α)),α→1 时爆炸 | 1 + α/2,线性增长 |
| 失败 ASL | ½(1 + 1/(1−α)²),增长更快 | α(空桶记 0) |
| α 升高时的表现 | 非线性急剧恶化 | 平缓退化 |
6. 竞赛中 unordered_map 会被「卡哈希」卡到超时,原理是什么?请写出一个可复制的防卡方案。
原理:std::unordered_map 默认使用 std::hash<Key>,
对整数往往是恒等映射(hash(x) = x),再由标准库对桶数取模。
桶数在实现里是确定的(libstdc++ 用质数序列,MSVC / libc++ 用 2 的幂),
于是出题人可以算出哪些关键字会同余、全部落进同一个桶。
例如对 m = 13 的表,构造所有 k ≡ 0 (mod 13) 的 key
(13, 26, 39, …),它们全部撞进 0 号桶,链表长度变成 n,
每次插入 / 查找从 O(1) 退化成 O(n),总复杂度 O(n2) → TLE。
防卡方案:使用带随机种子的自定义哈希函数,让攻击者无法预知落点。
#include <chrono>
#include <cstdint>
#include <unordered_map>
#include <unordered_set>
/* 竞赛防卡哈希:直接复制粘贴即可 */
struct custom_hash {
static uint64_t splitmix64(uint64_t x) {
x += 0x9e3779b97f4a7c15ULL;
x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9ULL;
x = (x ^ (x >> 27)) * 0x94d049bb133111ebULL;
return x ^ (x >> 31);
}
static uint64_t& seed() {
static uint64_t s = std::chrono::steady_clock::now().time_since_epoch().count();
return s; // 每次运行都不同的随机种子
}
size_t operator()(uint64_t x) const { return splitmix64(x + seed()); }
};
// 用法:
// std::unordered_map<long long, int, custom_hash> mp;
// std::unordered_set<long long, custom_hash> st;
// mp.reserve(1 << 20); // 顺手预分配,避免中途 rehash
补充建议:① 加 reserve() 预分配;
② 极端情况下干脆改用 std::map(红黑树,O(log n) 但绝不被卡)
或自己手写链地址法 + 随机哈希;
③ 字符串哈希也要提防被卡,默认写双模数,不要只写自然溢出。
10.9.4 配套编程练习
| 练习 | 任务 | 提示 / 验收标准 |
|---|---|---|
| 练习 1 | 实现顺序查找(朴素 / 哨兵 / 有序提前刹车)三个版本,对 n = 100 的表统计所有成功与失败查找的比较次数,验证 (n+1)/2、n、n+1、n/2+n/(n+1) 四个数值 | 失败查找要用「不在表中的 key」逐个测试 |
| 练习 2 | 写程序打印 n = 1..20 的折半查找判定树各层结点数,并计算精确的成功 / 失败 ASL,与 log2(n+1)−1 对照 | 判定树用递归构造;注意 mid 取整方向 |
| 练习 3 | 实现 lower_bound / upper_bound,并在含大量重复元素的有序数组上验证 cnt = upper − lower | 与 std::lower_bound 结果比对 |
| 练习 4 | 用插值查找与折半查找在(a)等差、(b)等比、(c)随机三组数据上统计平均比较次数 | 直观感受「分布决定算法」 |
| 练习 5 | 实现斐波那契查找,并逐元素打印比较次数,与折半查找对比平均值 | 体会「平均略优但同阶」 |
| 练习 6 | 实现线性探测、平方探测、双散列、链地址法四种哈希表,在 α = 0.5 / 0.75 / 0.9 下统计成功与失败 ASL | 用随机数据跑 10000 次取平均,画成表格 |
| 练习 7 | 实现计数排序式「直接定址」哈希,对一个 106 个整数的数组做去重与频次统计 | 对比 std::unordered_map 的耗时 |
| 练习 8 | 用字符串哈希(双模数)实现「最长公共子串」的二分 + 哈希判定解法 | 与 KMP 的做法对比复杂度 |