第 10 讲

查找与哈希表

「查找(search)」是计算机里被调用次数最多的操作,没有之一:数据库的索引、编译器的符号表、 路由器的转发表、Python 的字典、Java 的 HashMap,本质都在回答同一个问题—— 给定一个关键字,怎么最快地找到它? 本讲从最朴素的顺序查找一路推到 O(1) 的哈希表,把「平均查找长度 ASL」这把尺子贯穿始终; 哈希冲突的四种处理方法与 ASL 手推,是 408 与期末考试的必考大题。

预计 150 分钟 前置:第 01 讲大 O、第 02 讲线性表、第 06 讲数组 关键词:ASL · 判定树 · 折半查找 · 哈希函数 · 冲突 · 装填因子
本章导读
  • 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),通常返回一个特殊标记(-1nullptrend())。
平均查找长度 ASL
Average Search Length。查找过程中关键字比较次数的期望值,是衡量查找算法优劣的核心指标(而不是简单的「最坏情况」)。
必须先建立的观念:失败也是查找的一种结果 很多同学做 ASL 题目时只算「成功」,这是最常见的丢分点。 查找成功与查找不成功是两种不同的查找,必须分别计算、分别写出两个 ASL。 考试问「求该查找结构的 ASL」时,标准答案通常写成两行: ASL成功 = …ASL不成功 = …。 只写一个,扣一半分。

10.1.3 ASL 的定义与公式

设查找表有 n 个元素,查找第 i 个元素的概率为 pi, 找到它所需要的关键字比较次数ci,那么查找成功的平均查找长度定义为:

等概率假设下(即 pi = 1/n,绝大多数考题都这么假设),公式退化为:

查找不成功的 ASL 是另一个式子:把「失败」的每种可能情形(折半查找里是每个外部结点、 哈希表里是每个探测起点、分块查找里是每个索引块)当作一次「等可能的查找」, 若共有 m 种失败情形、第 j 种需要 dj 次比较,则

查找方法成功 ASL不成功 ASL关键约定说明
顺序查找(无序表)(n+1)/2n失败时要把 n 个元素全部比完
顺序查找(有序表)(n+1)/2n/2 + n/(n+1)一旦发现当前元素 > 目标就能提前停
折半查找≈ log2(n+1) − 1≈ log2(n+1)失败情形数 = 外部结点数 = n+1
分块查找≈ √n + 1需单独分析块长取 √n 时最优
哈希表(开放定址)与装填因子 α 有关与 α 有关,且 α < 1失败情形数 = 表长 m

上表是「先建立全局印象」用的,每个数字的推导都在后面的小节里,请务必自己推一遍再回来对照。

考点:ASL 的比较次数怎么数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 逐个与表中元素的关键字比较,相等就成功,扫完全表都找不到就失败。 它不需要数据有序、不需要顺序存储(链表也能用),是唯一「什么结构都能上」的查找方法。

① 从前往后扫(最朴素) 17 25 39 42 58 66 73 0 1 2 3 4 5 6 查 key = 39:比较 3 次(下标 0、1、2) ② 从后往前扫 17 25 39 42 58 66 73 查 key = 39:比较 6 次 同样的数据,方向不同 c_i 就不同 ③ 带哨兵(把 key 放到 a[0]) 39 0 哨兵 17 25 42 1 2 3 从 n 往 1 扫,命中哨兵即失败 循环里不必写 "i > 0" 判断 → 常数更小 哨兵版失败必然比较 n+1 次 这正是"不同教材 n 与 n+1 之争"的来源
图 10-1 顺序查找的三种扫描方式:方向影响 ci,哨兵消除边界判断

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 套到朴素写法上 两者差的那一次,就是「与哨兵自身的那一次比较」。 考试里如果题目写了「设置哨兵」,失败 ASL 就写 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] > key11
落在 (a[1], a[2])比到 a[2] 停21
落在 (a[n−1], a[n])比到 a[n] 停n1
大于 a[n]必须比完全部 n 个才发现都不小于它n1

于是(注意最后一种情况的比较次数也是 n):

n 较大时,n/(n+1) → 1,所以 ASL失败 ≈ n/2 + 1 —— 相比无序表的 n,几乎砍掉了一半。这非常符合直觉:有序表里 key 一旦比当前元素小, 就知道后面不用看了,「平均只要看一半」。

考点:有序表顺序查找的判定树 有序表的顺序查找也可以画判定树圆形结点是表中的元素(内部结点), 方形结点是失败区间(外部结点)。
• 内部结点:第 i 个元素在下标为 i 的层上 → 成功比较次数 ci = iASL成功 = (n+1)/2
• 外部结点:第一个失败区间在第 1 层(与 a[1] 比一次就能判定),后面每个失败区间依次加深, 最后一个失败区间在第 n 层(注意:不是 n+1 层!这是最经典的陷阱)→ 上式的 n+n 就是从这来的。
如果你把「判定树」画成折半查找那样的完全平衡形态,那 ASL失败 就完全不同了—— 因为有序表顺序查找的判定树是一条链,不是平衡树。

10.2.5 链式存储上的顺序查找

链表只能顺序查找,而且缺点被放大了:每访问一个结点都要解引用一次指针, cache 命中率极低(CPU 预取器对随机地址无能为力),常数因子通常是数组的 3~10 倍。 链表做顺序查找的 ASL 与顺序表完全一样((n+1)/2n), 因为比较次数只取决于链表长度,与存储方式无关。

唯一的「优势」是:如果要在有序链表上找 key,找到第一个 > key 的结点即可停, 与有序顺序表同理;而且链表上的插入删除是 O(1)(已知前驱时), 所以「频繁增删 + 查找不多」的场景,链表 + 顺序查找反而合适。

链式存储的顺序查找:只能顺着 next 一路走下去 17 25 39 42 58 查 39 需要走 3 步、解引用 3 次指针;无法随机访问 → 不能用折半查找 优点:插入删除 O(1)(已知前驱);缺点:cache 不友好,常数大,无法跳跃
图 10-2 链式存储上的顺序查找示意

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 对比三种写法的失败代价。

看完动画要能回答的三个问题 ① 三种写法的成功 ASL 是不是一样?(是,都是 (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」, 这只有在有序时成立。无序数据上折半查找会给出错误结果(不是慢,是)。

易错:折半查找的「下标」与「比较次数」 折半查找的比较次数不是「循环跑了几轮」那么简单,而是「与多少个元素比过」。 因为一次循环里可能只做 1 次比较,也可能做 2 次(先判相等、再判大小)。 本讲统一约定:一次 a[mid] 与 key 的比较算 1 次, 一趟 while 循环恰好做 1 次有效比较,所以「比较次数 = 判定树上的层数」。 这也是为什么 ASL 可以直接用判定树的层号来算。

10.3.2 算法流程与 C++ 实现

用闭区间 [left, right] 表示还在候选范围内的下标, 每轮取 mid = left + (right − left) / 2不要写 (left + right) / 2!当 leftright 都接近 INT_MAX 时它会在加法处溢出,得到负数下标,程序直接崩溃。 这是 LeetCode 上都写进题解的老坑)。

每轮三分支:

重复直到 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;
}
易错:把「元素值」当成「下标」来数层号 很多人手推 ASL 时会顺手写成「10 在第 3 层、20 在第 4 层……」,然后按值的大小去排层号, 结果算不出 33。正确的做法是按下标逐层统计:判定树的形状只由 mid 的下标序列决定, 与元素的值毫无关系。
请务必亲手把上表的 11 个层号推一遍(提示:每层先定 mid,再递归左右子区间), 能独立推出 1×1 + 2×2 + 3×4 + 4×4 = 33,这个考点就彻底拿下了。

10.3.3 判定树(比较树):折半查找的灵魂

折半查找的执行过程可以完整地用一棵二叉树表示,这棵树叫判定树(decision tree)比较树(comparison tree)。构造规则只有三条,务必背下来:

  1. 取整个区间的 mid 作为根结点(根上写的是元素值,但它的身份是「下标 mid」);
  2. 左子树递归地由左半区间 [left, mid−1] 构造,右子树由右半区间 [mid+1, right] 构造;
  3. 区间为空的地方挂一个方形外部结点(失败结点),表示查找不成功。

因为 mid 总是取中点,左右子区间的长度最多相差 1, 所以:折半查找的判定树一定是一棵平衡二叉树 (更准确地说:任一结点的左右子树高度差不超过 1,且所有外部结点只出现在最后两层)。 这个性质可以推出树高:

推导思路:层数为 h 的二叉树最多有 2h − 1 个结点, 要让 n 个内部结点都放得下,需要 2h − 1 ≥ n, 即 h ≥ log2(n+1),取整得 h = ⌈log2(n+1)⌉。 而折半查找的判定树恰好是「尽量填满」的形状,所以它取到了这个下界—— 这就是折半查找最优的根本原因。

n = 11 的折半查找判定树(内部结点 = 元素,层号 = 查找成功所需比较次数) 60 30 90 10 40 80 110 20 50 35 70 75 85 100 结点上写的是下标 mid 对应的元素值(这里 a[i] = (i+1)*10,所以值 = (下标+1)*10) 每层结点数:1 / 2 / 4 / 4 → ASL = (1×1 + 2×2 + 3×4 + 4×4) / 11 = 33/11 = 3
图 10-3 n = 11 的折半查找判定树(必考图):每层结点数 1、2、4、4
层号(= 比较次数)该层结点数对应的下标(mid 序列)元素值贡献 = 层号 × 个数
115601 × 1 = 1
222、830、902 × 2 = 4
340、3、7、1010、40、80、1103 × 4 = 12
441、4、6、920、50、70、1004 × 4 = 16
合计(等概率,每个元素查找概率 1/11)33

10.3.4 不成功 ASL:把外部结点数清楚

查找不成功时,算法会一路走到某个空区间,对应判定树上的一个外部结点(方形结点)。 有几个空区间?n + 1 个——因为 n 个内部结点把有序数组切成了 n+1 段空隙, 每段空隙对应一个外部结点。等概率假设下,ASL失败 就是所有外部结点深度的平均值。

外部结点的深度怎么数?规则是:外部结点的深度 = 它的父结点(内部结点)的层号 + 1, 也就是「走到那个死胡同之前,一共和多少个元素比过」。 对 n = 11 逐个统计(内部结点分四层,个数 1、2、4、4):

父结点所在层父结点个数每个父结点挂的外部结点数外部结点深度贡献
第 4 层(叶子)42(左右各一个空区间)54 × 2 × 5 = 40
第 3 层41(只有一侧是空的:10 与 40、80 与 110 之间没有元素)44 × 1 × 4 = 16
第 1、2 层300
合计:8 + 4 = 12 个外部结点,深度总和 5656
考点:成功 ASL 与失败 ASL 的关系 折半查找里有一条漂亮的结论:失败时最多比较 ⌈log2(n+1)⌉ 次, 因为外部结点只会出现在最后一层或倒数第二层,最深的那个外部结点深度就是树高。 一般地: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,判定树的最下面一层没填满,结论仍是:

nh = ⌈log2(n+1)⌉各层结点数ASL成功 精确值log2(n+1) − 1
11110
321, 2(1+2×2)/3 = 5/3 ≈ 1.671
731, 2, 4(1+4+12)/7 = 17/7 ≈ 2.432
1141, 2, 4, 433/11 = 32.585
1541, 2, 4, 8(1+4+12+32)/15 = 49/15 ≈ 3.273

观察: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)

易错:折半查找的常数因子被低估 虽然都是 O(log n),但折半查找每轮的「随机访问 + 分支预测失败」代价不小; 而且它对 cache 不友好(每次跳到相隔很远的地址)。 实践中对 n ≤ 64 的小数组,顺序查找往往比折半查找更快—— C++ 标准库的 std::find 就是纯线性扫描。 这也是为什么 std::sort 在小区间切换成插入排序。
O(log n) 的两种推法:区间长度每轮减半(左) / 判定树每比较一次下降一层(右) ① 区间长度法 第 0 轮:候选区间长度 L₀ = n 第 1 轮:L₁ = n/2 L₂ = n/4 n/8 1 每轮砍半,第 t 轮后 L_t = n / 2^t 区间缩到 1(或 0)时结束: n / 2^t ≤ 1 ⟹ t ≥ log₂n 所以最多 ⌈log₂(n+1)⌉ 轮,O(log n) ② 判定树高度法(n = 7 的满判定树) 4 2 6 1 3 5 7 层数 h 的二叉树最多 2^h − 1 个结点 要装下 n 个内部结点: 2^h − 1 ≥ n ⟹ h ≥ ⌈log₂(n+1)⌉
图 10-4 折半查找 O(log n) 的两种推导方式

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。

同一组数据,两种分割点的差异(要查 key = 33) 均匀分布 a[i] = 1 + 10i: 1 11 21 31 41 51 61 71 81 91 101 下标 0 插值 mid=3 → 命中 31? 折半 mid=5 查 31:插值 mid = 0 + (31−1)/(101−1)×10 = 3.0 → 直接命中 a[3]=31,1 次比较; 折半要先比 a[5]=51(太大),再比 a[2]=21(太小),比到 a[3] 才中,3 次 指数分布 a[i] = 2^i: 1 2 4 8 16 32 64 128 256 512 1024 查 1024(最后一个元素!):插值 mid = 0 + (1024−1)/(1024−1)×10 = 10 → 一次命中,这里很漂亮。 但查 3(应失败):mid = 0 + (3−1)/(1024−1)×10 = 0.02 → mid = 0 → 只排除 1 个元素 → 退化! 第二步 low=1,mid = 1 + (3−2)/(1024−2)×9 = 1.008 → mid = 1 → 又只排除 1 个元素…… → 每次只前进一格,退化成顺序查找 O(n)。这就是"分布不均匀时插值查找会翻车"的具体机制。 根因:分母 a[high] − a[low] 被右侧的巨值撑爆,比例恒接近 0,mid 永远贴着 low 走。
图 10-5 插值查找与折半查找的分割点对比:均匀数据上快,偏斜数据上会退化

10.4.2 为什么均匀分布下能接近 O(log log n)

均匀分布时,a[i] 与下标 i 近似成线性关系, 插值公式算出的 mid 落点与 key 真实位置的偏差服从一个均值为 0 的分布, 标准差的量级是 O(√n)。 每轮把「位置误差」从 n 降到 √n,再降到 n1/4…… 要做多少次才能降到 1?解 n1/2t = 1t = 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 很小或很大时)算完 midclamp[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 划分。两个理由:

换来的代价是:需要预先算一张斐波那契表,还要把数组「补齐」到特定长度,实现更啰嗦。 所以工程里几乎没人用它——它是典型的「考试 / 教材知识点」。

考点:斐波那契查找的结论 必须记住四句话:
① 斐波那契查找的平均性能略优于折半查找
② 但它的最坏情况时间复杂度仍然是 O(log n),与折半同阶,只是常数略小;
③ 它同样要求顺序存储 + 有序
④ 它不需要除法,只需要加减法。这就是它存在的全部理由。

10.5.2 算法步骤与「补齐」过程

完整流程分三步,第二步「补齐」是理解的关键:

  1. 找 F[k]:找出满足 F[k] − 1 ≥ n最小斐波那契数 F[k]
  2. 补齐数组:把数组长度从 n 扩到 F[k] − 1, 多出来的位置全部填原数组的最后一个元素(因为有序,填最后一个不会破坏有序性);
  3. 迭代:令 mid = low + F[k−1] − 1,比较后按分支调整 low / highk(下面详述)。
斐波那契查找的「补齐」过程(n = 11 → F[7] − 1 = 12) ① 原数组,n = 11: 10 20 30 40 50 60 70 80 90 100 110 下标 0 1 2 3 4 5 6 7 8 9 10 ② 补齐到 F[7] − 1 = 12 个: 10 20 30 40 50 60 70 80 90 100 110 110 补的第 11 号位直接复制原数组最后一个值 110(有序性不受影响) ③ 分割点 mid = low + F[k−1] − 1: 10 20 30 40 50 60 70 80 90 100 110 110 ← 左块 F[6] − 1 = 7 个 → ↑ mid = 0 + 8 − 1 = 7 ← 右块 F[5] − 1 = 4 个 → 斐波那契数列(F[1]=1, F[2]=1): F[5]=5 F[6]=8 F[7]=13 F[8]=21 F[9]=34 长度关系(Fibonacci 恒等式): F[k] − 1 = ( F[k−1] − 1 ) + 1 + ( F[k−2] − 1 ) 也就是说:mid 自己占 1 个,左边恰好 F[k−1]−1 个,右边恰好 F[k−2]−1 个 —— 递归结构完美闭合
图 10-6 斐波那契查找的补齐与分割点:n = 11 补到 12 = F[7] − 1,mid = 0 + F[6] − 1 = 7

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!
易错:往右找是 k −= 2,不是 k −= 1 这是斐波那契查找唯一的记忆难点。记忆法:mid 自己「吃掉」了一个斐波那契下标—— 往左走,区间变成 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), 它的本质是一句话:把表切成若干块,块与块之间有序,块内部无序; 先查索引表定位到块,再在块内顺序查找。

它要求把数据组织成两部分:

块间有序」的准确含义是:第 i 块中的所有关键字都小于第 i+1 块中的最小关键字。 注意它不要求块内有序——这正是分块查找区别于「归并」的地方, 也是它能兼顾「插入方便」的原因:往块内随便一塞即可(只要不破坏块间有序)。

分块查找的结构:索引表 + 分块主表(n = 16,块长 s = 4,共 b = 4 块) 索引表(有序,可以用折半查找): max=22 max=48 max=86 max=99 ↑ 只要 key ≤ 22 就去第 1 块找,以此类推(索引项 = 该块最大关键字) 主表(每块 4 个,块内无序): 22 12 13 8 33 48 25 30 60 86 71 55 99 95 88 90 下标 0 1 2 3  4 5 6 7  8 9 10 11  … 查 key = 48: ① 在索引表里定位:48 ≤ 22?否;48 ≤ 48?是 → 落在第 2 块 ② 在第 2 块内顺序查找:33(1) 48(2) → 命中!总比较 = 2 + 2 = 4 ASL 分解: ASL = ASL索引 + ASL块内 (两段相互独立,可以分别优化) 最优块长 s = √n 时:ASL ≈ √n + 1 (本图 n=16 → s=4 → ASL ≈ 5)
图 10-7 分块查找的索引表 + 主表结构,以及「先定块、再块内找」的两段式查找

10.6.2 ASL 推导:为什么最优块长是 √n

设表长 n,均分为 b 块,每块 s 个元素,则 n = b × s。 在「各元素等概率被查找」以及「key 落在各块的概率相等」的假设下,查找代价由两段组成:

b = n/s 代入,得到一个关于 s 的函数,求最小值:

由均值不等式 n/s + s ≥ 2√n(等号在 n/s = ss = √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 ≠ key2H(key1) == H(key2)。这是必然会发生的——由鸽巢原理,只要关键字空间比地址空间大,就一定有两个不同关键字落到同一地址。
同义词(synonym)
发生冲突的那些关键字互称同义词。注意是「互相」的,是一个等价关系。
装填因子(load factor)α
α = 表中已存元素个数 n / 哈希表长度 m。它衡量表的「拥挤程度」,是决定哈希表性能的唯一核心参数。
最重要的观念转变:冲突不可避免,只能「处理」 很多人第一次学哈希表会想「设计一个没有冲突的哈希函数不就行了」。 不行——因为关键字集合通常远大于地址空间(比如 109 个可能的学号映射到 104 个槽位)。 所以哈希表的设计永远是两件事的组合
① 设计一个「算得均匀、算得快」的哈希函数,让冲突尽可能少、尽可能随机;
② 设计一个「冲突了怎么办」的处理方法,让冲突发生后依然能正确、高效地存取。
考试里这两件事经常分开考,别混为一谈。

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

取关键字的某个线性函数作为哈希地址。ab 是常数(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 怎么选,这里有三个必须知道的结论:

③ 数字分析法:挑出「随机」的那几位

如果关键字是多位数(如学号 2023011307),而我们已经知道全体关键字, 就可以分析每一位的分布:把分布均匀的位抽出来组成地址,把取值集中、重复率高的位扔掉。

例:某班级 30 人的学号形如 2023 01 13 07(年级 + 学院 + 班级 + 序号)。 年级位全是 2023,学院位只有两种取值——这两段扔掉; 班级位和序号位变化丰富——取这两段拼成 1307,再对表长取模。

优点:计算几乎为零(只是取位拼接),针对特定数据能做得非常均匀。 缺点:完全依赖「事先知道全体关键字」,是静态方法;一旦新增的关键字破坏了原来均匀的那几位, 性能立刻崩掉。适用:数据仓库、统计报表这类一次性建表的场景。

④ 平方取中法:让每一位都参与运算

先算 k2,再取中间的若干位作为地址。 例:k = 4731k2 = 22382361, 取中间 3 位(第 3~5 位)得 382

为什么取中间位?因为一个数的平方,中间部分受所有输入位的影响—— 低位只受 k 的低位影响,高位只受高位影响,都不够「混合」。 取中间位相当于做了一次廉价的「雪崩」。 适用:关键字位数不多、分布未知,又不想做昂贵的除法。经典应用是 早期编译器符号表的哈希、以及某些随机数发生器(middle-square method)。

⑤ 折叠法:把长关键字「折叠」成短地址

把关键字按固定位数(比如 3 位)切成若干段,然后把各段相加,取和的后几位作为地址。 按叠加方式分两种:

例:k = 123456789,按 3 位分段得 123 | 456 | 789。 移位折叠:123 + 456 + 789 = 1368,取后 3 位 368; 分界折叠:把中间段反向得 123 | 654 | 789123 + 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_mapstd::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,又称聚集 / 二次聚集)是它最致命的缺陷: 不同的关键字因为探测路径互相重叠,会连成一大片连续的占用区。 一旦形成连续块,任何哈希到这块内任意位置的关键字,都要一路探测到块尾, 于是块越滚越长、越长越慢,形成正反馈。

线性探测的堆积现象(m = 11,H(k) = k mod 11) 哈希地址 → 22 1 46 13 67 42 41 53 30 012 345 678 910 堆积区 A:0~5 连续 6 个(本该只放 22 一个) 堆积区 B:7~10 连续 4 个 ① 插入 22,41,53,46,30,13,1,67,42 的探测过程(✓ 表示放入,✗ 表示冲突): 22→0✓ 41→8✓ 53→9✓ 46→2✓ 30→8✗9✗10✓ 13→2✗3✓ 1→1✓ 67→1✗2✗3✗4✓ 42→9✗10✗0✗1✗2✗3✗4✗5✓ → 落到 5(探测 8 次!) ② 堆积的恶性循环: 0~5 已经连成一片 → 任何哈希到 0/1/2/3/4/5 的关键字都要一路探到 6 → 连续块继续变长 → 越来越慢 ③ 后果:ASL 急剧上升,且「与本该无关的关键字」互相牵连(一次聚集 / 二次聚集) 注意空位只剩 6、7 两个:装填因子 α = 9/11 ≈ 0.82 时线性探测的失败代价非常高
图 10-8 线性探测法的插入结果与堆积区(关键字序列 22,41,53,46,30,13,1,67,42,m = 11)

下面动画逐帧演示这 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, … 一定能探测到表中的每一个位置。
原因(直观版):当 m4j+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,实际跑起来不一定快。

删除操作:必须使用墓碑标记

最经典的易错点:删除不能直接把位置清空 在开放定址法里,「空」是一个有语义的状态——它意味着「探测链到此结束,后面不可能有该 key」。 如果你删除时把槽位直接置空,会把它后面那些因为冲突而绕过来的元素「藏起来」, 查找就会提前终止,返回「不存在」。

具体例子:依次插入 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):扫一遍表把所有有效元素重新插入一个新表, 顺便把墓碑全部清掉、把表扩容或缩容。

删除为什么必须打墓碑:把 9 号位「置空」会截断 30 的探测链(m = 11,H(k) = k mod 11) ① 依次插入 41、53、30 之后的局部: 41 53 30 下标 8(H(41)=8) 下标 9(H(53)=9) 下标 10 ← 30 真正的家是 8 30 因为 8、9 都被占了才落到 10, 它的探测链必须穿过 9 号位 ✗ 错误做法:删除 53 时把 9 号位置成「空」 41 30 查找 30:8(41) 不等 → 9 是空的 → 判定「不存在」 可 30 明明住在 10 号位 —— 数据「人间蒸发」 ✓ 正确做法:把 9 号位标记为「墓碑」(tombstone / DELETED) 41 墓碑 30 墓碑在查找时算「占用」:继续往下探 → 找到 10 号位的 30。 墓碑在插入时算「空位」:新元素可直接复用,顺手清掉墓碑。 一句话记忆:墓碑挡查找、不挡插入。
图 10-10 开放定址法删除必须使用墓碑:直接置空会截断探测链

开放定址法完整 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
12200 空 → 放入01
24188 空 → 放入81
35399 空 → 放入91
44622 空 → 放入21
53088 占用(41) → 9 占用(53) → 10 空 → 放入103
61322 占用(46) → 3 空 → 放入32
7111 空 → 放入11
86711 占用(1) → 2 占用(46) → 3 占用(13) → 4 空 → 放入44
94299 占用(53) → 10 占用(30) → 0 占用(22) → 1 占用(1) → 2 占用(46) → 3 占用(13) → 4 占用(67) → 5 空 → 放入58

第二步:画出最终的表(这是计算失败 ASL 的依据)。

下标012345678910
关键字2214613 6742 415330

第三步:算查找成功的 ASL。等概率时每个关键字的查找概率是 1/9

第四步:算查找不成功的 ASL。这里是关键:失败时我们从每一个哈希地址 (下标 0 到 10,共 m = 11 种情形)出发做探测,一直探到第一个空槽为止。 因为空槽代表「探测链断了,后面不可能有」,所以比较次数 = 从起点走到空槽所经过的位置数 (包括最后那个空槽本身,因为我们访问了它并做了判定)。

哈希地址(失败起点)探测路径到空槽的比较次数 dj
00(22) → 1(1) → 2(46) → 3(13) → 4(67) → 5(42) → 6 空,停7
11(1) → 2(46) → 3(13) → 4(67) → 5(42) → 6 空,停6
22(46) → 3(13) → 4(67) → 5(42) → 6 空,停5
33(13) → 4(67) → 5(42) → 6 空,停4
44(67) → 5(42) → 6 空,停3
55(42) → 6 空,停2
66 空,停(第一个就是空的)1
77 空,停1
88(41) → 9(53) → 10(30) → 0(22) → 1(1) → 2(46) → 3(13) → 4(67) → 5(42) → 6 空,停
(从下标 8 出发要绕几乎一整圈才碰到空位!)
10
99(53) → 10(30) → 0(22) → 1(1) → 2(46) → 3(13) → 4(67) → 5(42) → 6 空,停9
1010(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
考点:三个必须写对的细节分母不同:成功 ASL 的分母是元素个数 n = 9;失败 ASL 的分母是表长 m = 11。 这是最常见的错误来源,很多同学两个都用 n。
失败起点是「哈希地址」而不是「已经存了元素的位置」。要枚举 0…m−1 全部 m 个起点, 哪怕某个下标本身是空的(那种情况下 dj = 1)。
失败的终止条件是「真空槽」。如果表里有墓碑,墓碑不算终止(要继续探), 这一点在有删除操作的题目里经常出现。
另外注意本题的 ASL失败 = 56/11 ≈ 5.09ASL成功 = 22/9 ≈ 2.44 大了一倍多,这正是 α = 9/11 ≈ 0.82 太高的后果——如果 α 降到 0.5 左右,两者都会明显下降。
关于「失败时最后那次与空槽的比较要不要计数」 不同教材有不同约定:有的把「访问空槽并判定为空」也算一次比较(本讲采用,得到 56/11), 有的只数「与已存关键字的比较」(得到 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)的头指针,所有哈希到同一地址的关键字串成一条链表。 这样冲突不再需要「另找位置」,而是「挂在同一个桶下面」。

链地址法结构图(m = 11,H(k) = k mod 11,同一组关键字) 桶 0 桶 1 桶 2 桶 3 桶 4 桶 5 桶 6 桶 7 桶 8 桶 9 桶 10 99 55 23 1 68 46 14 37 要点: • 桶数组长度 m,每个桶存一个链表头指针 • 同义词挂在同一条链上 → 冲突不再「抢占」别人的位置 • 插入最简单:头插 O(1)(本图即头插,链上顺序与插入顺序相反) • 删除:在链表里摘结点即可, 不需要墓碑!这是相对开放定址法的最大优势 • α = n/m 可以 > 1(链可以无限长),但 α 越大链越长、越慢 • 期望查找代价 = 1 + α/2(成功) / α(失败),均摊 O(1+α) 本图数据(m = 11,n = 9,插入顺序 99,1,23,14,55,68,11,37,46): 桶0: 99,55 | 桶1: 23,1 | 桶2: 68,46 桶3: 14 | 桶4: 37 | 桶5,6,7,8,9,10: 空 ASL_成功 = (1+2 + 1+2 + 1+2 + 1 + 1)/9 = 14/9 ≈ 1.56 对比同样 n=9、m=11 的开放定址线性探测:成功 ASL = 22/9 ≈ 2.44,失败 = 56/11 ≈ 5.09 → 链地址法在装填因子高时明显更稳,代价是每个结点多一个指针
图 10-9 链地址法的结构与同义词链表(m = 11)

动画演示同样的插入过程,并且包含一次「查找穿过长链」与一次「查找失败」:

链地址法的 ASL 手推

用图 10-9 的同一组数据(m = 11,头插法,插入顺序 99, 1, 23, 14, 55, 68, 11, 37, 46):

桶号链上元素(头插顺序)链长各元素查找比较次数本桶比较次数小计
099, 55299 是第 1 个 → 1;55 是第 2 个 → 23
123, 1223 → 1;1 → 23
268, 46268 → 1;46 → 23
314111
437111
500
600
700
800
900
1000
成功查找:总比较次数(n = 9 个元素)14
不成功查找:从每个桶出发都要把链走完(分母 = m = 11)9
易错:链地址法的失败 ASL 分母是 m,但空桶记 0 次比较 链地址法里,失败意味着「把整条链走完都没找到」。 如果桶是空的(头指针为 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(已存元素数 / 表长),它是决定哈希表平均查找长度的一阶因素, 而且注意一个反直觉的结论:

核心结论(必背) 哈希表的 ASL 只与装填因子 α 有关,而与表长 m 和元素个数 n 的绝对大小无关。
也就是说,「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−α): 当 α → 1 时分母趋近于 0,ASL 爆炸。而且 α = 1 意味着表全满, 插入直接失败——哈希表必须永远留有空槽,这不是效率问题,是正确性问题。
  2. 线性探测对 α 最敏感(分母是平方),所以生产环境普遍把阈值设在 α ≤ 0.75:超过就扩容(rehash)到大约 2 倍。 Java HashMap 的默认阈值就是 0.75,这是性能与内存的经典折中。
  3. 链地址法允许 α > 1,因为链表可以无限延长,公式 1 + α/2 里没有分母。 但它也不是免费的:α = 4 时平均要遍历 3 个结点,指针追逐的 cache 代价很高。
装填因子 α 与平均查找长度的关系(三种方法的理论曲线) 装填因子 α = n/m ASL 0 0.25 0.50 0.75 1.00 0.75:工程扩容阈值 线性探测 · 失败 线性探测 · 成功 平方探测 / 双散列 · 失败 链地址法 · 成功 ≈ 1 + α/2(几乎是直线) 三条结论: ① 开放定址的曲线在 α → 1 时「竖直向上」   (分母趋 0) ② 线性探测对 α 最敏感 ③ 链地址是一条直线   α > 1 也照样能跑 注:曲线按教科书的近似公式绘制(线性探测成功 ½(1+1/(1−α))、失败 ½(1+1/(1−α)²);平方/双散列失败 1/(1−α);链地址成功 1+α/2),仅示意变化趋势,非精确值。
图 10-11 装填因子 α 与 ASL 的关系:开放定址在 α → 1 时急剧恶化,链地址法近似线性
易错:把「表长 m」当成「元素个数 n」 哈希表题目里 mnα 三个量经常互相推导, 要牢记:m = 表长 = 数组长度 = 哈希地址的取值范围(0..m−1)n = 表中实际存放的元素个数α = n/m
求 ASL 时再强调一次:成功 ASL 除以 n,失败 ASL 除以 m。 看到题目问「平均查找长度」而只给了一个空,那它多半只想要成功 ASL; 但只要题目提到「查找不成功」,就必须写两个。

10.8 工程视角:哈希在真实系统里怎么用

10.8.1 C++ 的 unordered_map 是怎么实现的

std::unordered_mapstd::unordered_set 是 C++11 引入的哈希容器, 标准对实现的约束只有一条:平均 O(1)、最坏 O(n),并规定「桶(bucket)」这个接口。 具体实现由各家标准库自由发挥:

无论哪家,都有几个必须知道的接口与行为:

接口含义用途
load_factor()当前的 α = size / bucket_count监控哈希表拥挤程度
max_load_factor(x)设置 α 的上限,默认 1.0提前扩容,换取更快的查找
rehash(n)把桶数设为至少 n插入前预留,避免中途多次 rehash
reserve(n)预留能装 n 个元素的空间竞赛必备:一次性分配,避免反复扩容
bucket_count() / bucket(k)桶数 / 关键字 k 落在哪个桶调试哈希分布是否均匀
易错:迭代器失效与 rehash 当插入导致 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] 的哈希是:

两种常见的模数策略:

与第 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 / 后缀数组。
写在前面:从「会算 ASL」到「会选结构」 10.7 我们回答了「冲突了怎么办」;本节要回答的是更接近工作现场的问题—— 面对一个真实需求,为什么有人选链地址法、有人选开放定址、有人干脆不用哈希? 这五个落点(冲突方案、扩容、unordered_map、数据库索引、布隆过滤器与一致性哈希) 在面试和后端开发里出现的频率,比手推 ASL 高得多。 每个落点我们都按同一套三段式来讲:用什么结构 → 为什么这里必须用它 → 代价是什么。 凡是能用数字说话的地方,我们都给出数字,而不是「比较快」「比较省」这种空话。

10.8.4 查找方法总览:一张表选型

算法 / 结构前提条件成功 ASL失败 ASL插入 / 删除适用场景
顺序查找(n+1)/2n(有序 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)磁盘 / 数据库索引
哈希表(开放定址)α < 1O(1 + 1/(1−α))O(1/(1−α)²)期望 O(1)只要「秒查」,不要有序
哈希表(链地址)O(1 + α)O(α)期望 O(1)α 可能大于 1、元素较大、频繁删除

10.8.5 冲突处理在真实实现里怎么选:为什么拉链法是主流

10.7 里我们把四种冲突处理方法摆在一起做过对比,那是教科书视角; 现在换一副眼镜,看的是工程视角——主流语言的标准库和数据库到底选了谁,为什么。

一句话结论 工程上拉链法(链地址法)是主流:Java 的 HashMap、C++ 各家的 unordered_map、Go 的 map、Python 的 dict(早期版本)、 MySQL 的 HASH 索引,初版清一色是「桶数组 + 链表」。 只有追求极致速度的内存内哈希表(absl::flat_hash_map、Rust 的 hashbrown、 Python 3.6+ 的紧凑 dict)才改投开放定址。

拉链法赢在哪两条?

  1. 负载因子 α 可以大于 1。回忆 10.7.8 的公式:链地址法的成功 ASL 是 1 + α/2,式子里没有分母,所以 α = 2 甚至 α = 4 都还能跑, 只是平均要顺着链找 2~3 个结点。 开放定址就绝对不行——它的公式里全是 1/(1−α)α = 1 意味着表全满,插入直接失败,这不是性能问题,是正确性问题: 开放定址法必须永远留有空槽
  2. 删除简单,不需要墓碑。链地址法删除一个元素,就是把结点从链上摘下来、 delete 掉,O(1)(双向链表)或 O(链长), 删完表的状态和「这个元素从没来过」完全一样。 开放定址法不行:直接把它占的槽置空会截断探测链, 让它后面的同义词全部找不到(图 10-10 画的就是这个事故), 所以必须留一个墓碑(tombstone)占着位置。 墓碑的代价是双重的:它仍然占着槽(α 只涨不跌), 而且查找时必须继续往后探,不能被墓碑骗停。 墓碑攒多了,表就「假满」了——内存里有空槽,性能却像满了, 唯一的解法是原地重建(rehash),又是一次 O(n)。

那开放定址靠什么活下来?靠两个字:缓存

开放定址的死穴:α 接近 1 时的性能悬崖 把 10.7.8 的公式代进去算,就能看到这不是「变慢一点」,而是断崖式塌方。 下面这张表用的是线性探测的经典近似式 (成功 ½(1 + 1/(1−α))、失败 ½(1 + 1/(1−α)²)), 数值精确到两位小数,可以直接和你的实验对上:
装填因子 α线性探测
成功 ASL
线性探测
失败 ASL
平方探测 / 双散列
成功 ASL
平方探测 / 双散列
失败 ASL
链地址法
成功 ASL
0.251.171.391.151.331.13
0.501.502.501.392.001.25
0.75(工程阈值)2.508.501.854.001.38
0.905.5050.502.5610.001.45
0.9950.505000.504.65100.001.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.75」这个阈值是怎么来的 把上面两个极端放在一起,答案就自己浮出来了:
① 空间那一侧:阈值定得越低(比如 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 HashMapDEFAULT_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) 的代价藏在哪里

为什么是翻倍?——这是第 02 讲动态数组扩容的同一个数学 先看反例。假设每次只多开一个槽(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 ≤ 2nn 次插入的总搬运量不超过 2n,平摊到每次插入只有 2 次—— 常数级。同样的 n = 106,翻倍策略的总搬运量是 2×106, 比「每次加一点」省了 25 万倍
这就是均摊分析(amortized analysis):单次操作可能很贵, 但只要「贵操作」之间的间隔按几何级数拉长,平均下来就是 O(1)。 C++ 的 std::vector::push_back、Java 的 ArrayList、 Go 的 slice、Redis 的字典,走的都是这套数学—— 第 02 讲讲动态数组扩容时推过的式子,在哈希表这里一字不改地又用了一遍
均摊 O(1) ≠ 每次都快:O(n) 的长尾延迟 「均摊 O(1)」是一个统计结论,它说的是「一亿次插入总共花 T 时间」, 它没有承诺「每一次插入都很快」。 恰恰相反:必然存在某一次插入,它要独自承担搬完整个表的 O(n) 代价。 当哈希表里有 100 万个元素时,这一次扩容就是百万级的搬运 + 一次大内存分配, 在真实系统里表现为一次几十毫秒到几百毫秒的卡顿

这个现象叫长尾延迟(tail latency)。对吞吐量敏感的服务(离线批处理、日志分析) 它无所谓——总量就那么多;但对延迟敏感的服务,它是必须专门处理的故障源:
  • 游戏服务器:一次扩容卡 100 ms,就是全服玩家一起卡一下, 表现为「技能放出去没反应」;
  • 实时交易 / 撮合引擎:行情推送上每一个 99 分位延迟都被严格监控, 一次扩容的抖动足以触发风控熔断;
  • 广告 / 推荐在线服务:SLA 通常按 P99 甚至 P999 考核, 平均值再好看,只要 P999 出现尖刺就算事故。
所以工程上对付它的办法,从来不是「让扩容变快」(办不到,搬家就是 O(n)), 而是别让它在你没准备的时候发生

工程上的四种应对,从便宜到复杂:

  1. 预留(reserve)—— 最便宜也最有效。如果能预知规模,就在插入前一次性 reserve(n),让扩容次数从 log n 次变成 0 次。 代价是多占内存(要按最终规模分配),而且预判错了照样要扩。 竞赛里这条是硬规矩:unordered_map 不写 reserve 经常就是 TLE 与 AC 的差别。
  2. 分批扩容(渐进式 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 倍), 而且每条操作路径都要写「两张表都要看」的分支,代码复杂度显著上升。
  3. 后台线程搬迁。Go 的 map 在触发扩容后会由一个后台 goroutine 逐步把旧桶搬到新桶(同时配合写屏障保证正确性)。 代价是语言运行时复杂度飙升,而且并发读写 map 本身仍然不安全。
  4. 提前预热。在线服务启动时先用一批假数据把哈希表「喂」到目标容量, 让扩容发生在上线之前。代价是启动变慢、需要维护预热数据。
要能说出这句话 「扩容的均摊代价是 O(1),但单次代价是 O(n),所以延迟敏感的系统要用渐进式 rehash 把它摊平,Redis 就是这么做的。」 这句话把「均摊复杂度」和「延迟分布」这两个面试官最爱的区分点一句话说清了。

10.8.7 std::unordered_map 的真实结构与防卡哈希

现在把镜头对准 C++ 里最常用的那个容器。std::unordered_mapstd::unordered_set 是 C++11 引入的哈希容器, 标准对实现的约束只有一条:平均 O(1)、最坏 O(n),并规定「桶(bucket)」这个接口。 具体实现由各家标准库自由发挥:

为什么要给 unordered_map 写自定义哈希 + 随机种子 因为默认哈希是可预测的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 大还是小」。 所以下面这几类查询,哈希索引一个都做不了(只能全表扫描):

所以数据库默认选 B+ 树(这本该是第 07 讲树结构的延伸话题,这里只做工程收口): 它是一棵多路平衡树,一个结点装几百个键, 树高在 3~4 层就能覆盖上亿行数据;更关键的是它的 叶子结点连成有序链表,把「等值查找 O(log n)」「范围扫描」「排序」 「最左前缀」四件事一次性全包了。 哈希在等值查询上确实更快(一次哈希 vs 三次磁盘 I/O), 但它只会这一件事——为一个能力付出一整棵树的代价,数据库不干。

考点:MySQL 的 MEMORY 引擎支持 HASH 索引,但不支持范围查询 MySQL 的 MEMORY 存储引擎允许显式写 CREATE TABLE t (...) ENGINE = MEMORY, 并且它的索引可以选 USING HASH—— 因为 MEMORY 表整个活在内存里,不需要考虑磁盘 I/O,哈希的等值优势就体现出来了。 但代价写得明明白白:HASH 索引只支持 =<=>, 不支持 <>BETWEENORDER BY, 也不支持最左前缀USING BTREE 才支持)。 更要注意:MEMORY 引擎的默认索引类型恰好就是 HASH, 很多人建完表写了范围查询发现用不上索引,就是踩了这个默认值。
所以哈希索引的生存空间只有一类场景:只做等值查找的内存表—— MySQL MEMORY 表的主键查找、Redis 的哈希键、以及各种进程内的 <key, value> 缓存。一旦查询里出现范围或排序,哈希就必须让位给树。

10.8.9 布隆过滤器:用一个位数组换掉 99% 的无效查询

前面几节讲的都是「怎么把元素存进哈希表」。布隆过滤器(Bloom Filter)反过来—— 它不存元素,只存「这个元素来过」的痕迹,用极小的空间换一个「大概率能判断」的能力。 它是哈希在工程里最漂亮的一次「降维使用」。

结构:k 个哈希函数 + 一个位数组
  • 开一个长度为 m位数组,每一位初始为 0;
  • 准备 k相互独立的哈希函数;
  • 插入 x:算出 k 个位置,把它们全部置 1(不去重、不记录是谁置的);
  • 查询 x:算出同样的 k 个位置,只要有一个是 0 → x 一定没插入过k 个全是 1 → x 可能插入过

这套规则推出布隆过滤器最重要的两条性质:

为什么布隆过滤器不能删除元素? 因为一个位被多个元素共享,你没有「这个 1 是谁置的」这个信息。 假设 x 和 y 都把第 7 位置成了 1,现在你要删除 x、把第 7 位清 0—— y 的「证据」就一起被抹掉了,于是查询 y 会返回 false。 这就制造出了假阴性,而「无假阴性」正是布隆过滤器唯一敢承诺的东西, 一旦破坏,整个结构的前提就塌了。
要支持删除,只能加信息量:计数布隆过滤器(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;
}

真实系统里它站在哪四个位置?

  1. 缓存穿透防护(最经典)。查询流程是「先查 Redis,没有就查数据库」。 但如果有恶意请求反复查询根本不存在的 key,Redis 每次都 miss, 请求就全部穿透到数据库——这就是缓存穿透。 解法:把数据库里所有存在的 key 预先塞进一个布隆过滤器, 请求进来先过一遍——布隆过滤器说「不存在」,就直接返回空,一次数据库都不查。 那 1% 的假阳性会漏到数据库,但 99% 的恶意流量被挡在门外, 而代价只是几十 MB 内存。
  2. 爬虫 URL 去重。爬虫要判断「这个 URL 是不是已经抓过」。 真实的 URL 库动辄上亿条,用哈希集合存要几十 GB; 用布隆过滤器,上亿条 URL 只要几百 MB。 假阳性在这里恰好是「安全的错误」:把没抓过的 URL 误判成抓过, 最多漏掉几个页面,不会让爬虫出错或重复入库—— 这正是布隆过滤器最适合的「错一边也无所谓」的语义。
  3. BigTable / LevelDB / RocksDB 的 SSTable 查询。 这些 LSM-Tree 存储引擎把数据按层组织成一个个 SSTable 文件。 如果每次查找都要逐个文件去磁盘上二分,I/O 会爆掉。 所以每个 SSTable 都附一个布隆过滤器,记录这个文件里有哪些 key; 查询时先用内存里的布隆过滤器筛一遍, 说「没有」的文件直接跳过,一次磁盘 I/O 都不产生。 这是布隆过滤器在存储引擎里最核心的用途——用内存换 I/O
  4. 比特币 SPV 轻节点。手机钱包不想下载几百 GB 的完整区块链, 只想要「和我有关的交易」。于是它把自己所有地址放进一个布隆过滤器 发给全节点,全节点用它过滤区块里的交易,只把可能相关的交易回传。 假阳性在这里反而是一种隐私保护:全节点无法准确知道你关心哪些地址, 因为过滤器里总会混进一些无关的「疑似地址」。
布隆过滤器的三个代价(面试要主动说) ① 假阳性无法消除,只能压低——要压到 0.1% 就要多花约 1.5 倍空间; ② 不能删除元素(位被共享),要删就得上计数布隆过滤器(4 倍空间)或 Cuckoo Filter; ③ 不能取出元素本身——它只回答「在不在」,不存值, 而且不支持范围查询,和哈希表一样是纯等值判定。 另外它还有一个隐形前提:必须能容忍「插入后不删除」, 所以它总是配着「定期重建」的运维策略一起出现。

10.8.10 一致性哈希:分布式缓存扩缩容时,怎么只搬 1/N 的数据

前面讲的都是一台机器内的哈希。现在把哈希表放大成一个集群: N 台缓存服务器(Redis Cluster、Memcached), 要决定「键 k 该存到哪台机器上」。最直觉的写法是取模:

单机时代这个式子没毛病,但到了集群里它有一个致命缺陷: N 一变,几乎所有键的归属都要重算。

灾难现场:N 从 4 变成 5,80% 的缓存同时失效 设集群有 N = 4 台机器、一共 10 万个键。 现在加一台机器(N = 5),逐个算一下:
  • 原本落在「第 4 台」上的键(约占 1/4 = 25%)需要重新分配;
  • 原本落在「第 1、2、3 台」上的键(占 75%), 其 hash(k) mod 5 的结果也不再等于原来的下标——
  • 算下来,只有 hash(k) mod 20 恰好落在 0~4 的那批键能留在原地, 占 1/5 = 20%。也就是说 80% 的键(8 万个)都要换机器
后果是缓存雪崩:8 万个本来命中的请求突然全部 miss, 同一瞬间压到数据库上。数据库扛不住,整个服务雪崩。 更糟的是,这些请求被重新写回缓存后, 下一次扩缩容又会再搬 80%——集群规模越大,这个比例越接近 100% (加到 N+1 台时迁移量约 N/(N+1),N = 100 时就是 99%)。
一致性哈希的核心把戏:把「取模」换成「沿环顺时针找」
  1. 把哈希值的整个取值空间 [0, 232) 首尾相接, 想象成一个圆环(hash ring)
  2. 每台机器(用它的 IP 或名字)也哈希一次,落在环上的某一点—— 这个点叫结点(node)
  3. 要定位一个键 k,先算 hash(k) 落在环上哪一点, 然后沿顺时针方向走,遇到的第一个结点就是它的归属;
  4. 换句话说:每个结点「负责」从它自己开始、逆时针到上一个结点之间的那段弧
这样一来,「N 变了要重算」就变成了「环上多了一个点,只有它前面那段弧的键换主人」。

迁移量为什么是 1/N?加进第 N+1 个结点时,它在环上随机落一个位置, 只会从某一个原有结点手里「抢走」一段弧。 平均而言这段弧占整个环的 1/(N+1), 所以只有约 1/(N+1) 的键需要迁移,其余 N/(N+1) 原封不动。 回到 N = 4 → 5 的例子:普通取模要搬 80%, 一致性哈希只搬 20%(约 2 万个键)——迁移量降到了 1/4。 下面图 10-12 把这个对比画了出来,左边是环上的迁移范围,右边是两种策略的迁移量直方图。

一致性哈希环 vs 普通取模:增加 1 个结点时的迁移范围(N = 4 → 5,共 10 万个键) 左:哈希环(顺时针找第一个结点) 这段弧的键 改归 E 这段弧的键也改归 E B C D A E 新增结点 (插在 A、B 之间) k1 k2 k3 k4 k5 保留原结点(k1 k2 k3) 改归 E(k4 k5) 环上 5 个键里 2 个换主人,正好约 2/5 = 40% —— 就是新增结点「抢走」的那段弧。 对照:普通取模 hash(k) mod 4 → mod 5,10 个里有 8 个要换机器。 右:10 万个键的迁移量对比(N = 4 → 5) 普通取模 hash(k) mod N 保留 2 万 迁移 8 万 迁移比例 80% 一致性哈希 迁移 2 万 保留 8 万 迁移比例 20% 结论(要背下来的两个数) 普通取模:加 1 台机器要迁移 ≈ N/(N+1) N = 4 时 80%;N = 100 时 99% 一致性哈希:只迁移 ≈ 1/(N+1) N = 4 时 20%,正好降到 1/4 迁移量相差 N 倍 (N = 4 时省下 6 万个键的搬迁) 代价:数据倾斜时用 虚拟结点 补救(见正文) 注:直方图为等比例示意,按 10 万个键的真实比例绘制。
图 10-12 一致性哈希环 vs 普通取模的扩缩容影响:普通取模要迁移约 N/(N+1)(N = 4 时 80%),一致性哈希只迁移约 1/(N+1)(20%)
虚拟结点:一致性哈希必须打的补丁 朴素的一致性哈希有一个致命缺陷:数据倾斜(skew)。 只有 4 台机器时,它们在环上只是 4 个随机点, 这 4 段弧的长度期望相等,但方差极大—— 实测中经常出现「一台机器负责 50% 的环,另一台只负责 5%」的局面, 负载均衡彻底失效。

解法是虚拟结点(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 必须记住的十二件事

查找基础

  1. ASL = Σ pici;等概率时 = (Σci)/n
  2. 成功 ASL 与失败 ASL 必须分开算:分母分别是 n 和 m(或 n+1)。
  3. 静态查找表只要查询;动态查找表还要插入删除。

折半查找

  1. 前提:顺序存储 + 有序,链表不行。
  2. 判定树是平衡二叉树,树高 ⌈log2(n+1)⌉
  3. n = 11 时 ASL成功 = 33/11 = 3,层结点数 1、2、4、4。
  4. mid = left + (right − left)/2,防溢出。

三种变体查找

  1. 插值查找:均匀数据 O(log log n),偏斜数据退化成 O(n)。
  2. 斐波那契查找:只用加减法,平均略优于折半,最坏同为 O(log n);往右找 k −= 2
  3. 分块查找:块间有序块内无序,s = √nASL ≈ √n + 1

哈希表

  1. 除留余数法最常用,p 取不大于表长的最大质数
  2. 删除必须用墓碑;ASL 只与装填因子 α 有关,开放定址要求 α < 1。

10.9.2 易错点清单

易错点 1:ASL 的分母用错 成功 ASL 除以 n(元素个数);哈希表失败 ASL 除以 m(表长); 折半查找失败 ASL 除以 n+1(外部结点数);有序表顺序查找失败除以 n+1(区间数)。 这四个分母各不相同,考试前一定要列表默写一遍。
易错点 2:折半查找判定树按下标而非按值构造 判定树的分叉完全由 mid = ⌊(low+high)/2⌋ 决定,与元素值无关。 画树时先写下标序列,再把值填进去。mid 向下取整还是向上取整会影响树形, 题目一般规定「向下取整」,若没规定请在答卷上写明你的约定
易错点 3:开放定址删除时直接置空 置空会截断别人的探测链,使后面因冲突而绕过来的元素「查不到」。 必须使用墓碑标记:墓碑挡查找、不挡插入。 而且墓碑会让失败 ASL 变大,工程上要定期 rehash 清理。
易错点 4:除留余数法的 p 选了 2 的幂或偶数 k mod 2t 只保留 k 的最低 t 位,高位信息全部丢弃。 真实数据的低位往往高度规律(都是 0 结尾),会造成严重聚集。 所以要么用质数,要么像 Java HashMap 那样用 2 的幂 + 扰动函数。
易错点 5:平方探测的表长随便取 必须取 4j+3 形式的质数(7、11、19、23、31…)才能保证探测到全表。 若取 m = 12i2 mod 12 只有 0、1、4、9 四种取值, 有 8 个位置永远探不到,插不进去。
易错点 6:斐波那契查找往右找写成 k −= 1 应该是 k -= 2。左块长度是 F[k−1]−1,右块是 F[k−2]−1, mid 自己占掉一档。写错不会报错,只会静默算错结果。
易错点 7:链地址法的失败 ASL 把空桶记成 1 空桶意味着头指针为空,一次比较都不做,记 0。 而开放定址法里访问一个空槽记 1 次比较。两者容易混。
易错点 8:以为 α 越小越好 α 越小查找越快,但空间浪费越大,而且 rehash 更频繁。 生产环境普遍取 0.75 左右,这是「查找速度 / 内存 / 扩容代价」的工程折中, 不是理论最优值。理论最优要看具体场景。
易错点 9:以为哈希表支持范围查询 哈希表打散了顺序,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 = √nASL = √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 次):

起点0123456789101112
探测路径 0 空1→2→3→4→52→3→4→53→4→5 4→556→7→8→97→8→9 8→9910→111112
dj 1543214321211

例:起点 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 = 164+3+2+1 = 102+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,线性探测):

  1. 插入 41:41 mod 11 = 8 → 放 8 号位。
  2. 插入 53:53 mod 11 = 9 → 放 9 号位。
  3. 插入 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 = 3mid = 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)/2nn+1n/2+n/(n+1) 四个数值失败查找要用「不在表中的 key」逐个测试
练习 2写程序打印 n = 1..20 的折半查找判定树各层结点数,并计算精确的成功 / 失败 ASL,与 log2(n+1)−1 对照判定树用递归构造;注意 mid 取整方向
练习 3实现 lower_bound / upper_bound,并在含大量重复元素的有序数组上验证 cnt = upper − lowerstd::lower_bound 结果比对
练习 4用插值查找与折半查找在(a)等差、(b)等比、(c)随机三组数据上统计平均比较次数直观感受「分布决定算法」
练习 5实现斐波那契查找,并逐元素打印比较次数,与折半查找对比平均值体会「平均略优但同阶」
练习 6实现线性探测、平方探测、双散列、链地址法四种哈希表,在 α = 0.5 / 0.75 / 0.9 下统计成功与失败 ASL用随机数据跑 10000 次取平均,画成表格
练习 7实现计数排序式「直接定址」哈希,对一个 106 个整数的数组做去重与频次统计对比 std::unordered_map 的耗时
练习 8用字符串哈希(双模数)实现「最长公共子串」的二分 + 哈希判定解法与 KMP 的做法对比复杂度
洛谷练习建议 在洛谷题库中搜索以下关键词即可找到对应题目(题号请以站内搜索结果为准): 「【模板】二分查找」(lower_bound / upper_bound 的直接应用)、 「【模板】字符串哈希」(自然溢出与双模数的手感练习)、 「哈希冲突」(卡哈希与防卡的经典题)、 「二分答案」相关题单(如「砍树」「数列分段」类型,为第 13 讲铺垫)、 「玩具取名 / 生日礼物」类涉及哈希集合去重的题。 第 14 讲的洛谷题单会给出更完整的分层练习计划。
关于洛谷题号 本讲刻意不写具体题号——洛谷题库会持续更新,题号容易失效。 请用上面的关键词在站内搜索,或直接查看官方「能力提升」题单中的 「二分查找」「哈希」两个专题。