哈希表底层原理:从 unordered_set 到哈希冲突解决
摘要
本文从 STL 的 set / unordered_set 使用差异切入,逐层拆解哈希表的完整体系:set(红黑树)与 unordered_set(哈希表)在 Key 要求、迭代器类型、时间复杂度上的三个差异,以及 MSVC 文档中两个容器类模板的真实定义;接着推导直接定址法及其局限,引出哈希函数 h(key) = key % M 与"哈希冲突不可避免"的事实;再讨论负载因子 N/M 在冲突率与空间利用率之间的权衡、关键字转整数的必要性;重点展开除法散列法中"为什么 M 不建议取 2 的幂 / 10 的幂、实践中 Java HashMap 反而用 2 的幂 + 位运算"这对看似矛盾的设计;最后落到开放定址法的三种探测序列(线性 / 二次 / 双重),用 {19,30,5,36,13,20,21,12} 入 M=11 的表完整演示线性探测的冲突链、群集(堆积)现象,以及删除必须用 {EXIST, EMPTY, DELETE} 标记删除的原因。
本篇基于手写笔记整理。标注
【笔记】的是笔记直接出现的内容,【补充】的是笔记留白、补上才能闭环的部分;图示数字以手绘原图为准。
一、STL 有序 / 无序容器对照
1.1 Java 与 C++ 关联容器映射【笔记】
| Java | C++ | 底层实现 | 遍历特性 |
|---|---|---|---|
| HashMap | unordered_map |
哈希表 | 遍历无序 |
| HashSet | unordered_set |
哈希表 | 遍历无序 |
| TreeMap | map |
红黑树 | 遍历有序 |
| TreeSet | set |
红黑树 | 遍历有序 |
- 核心对照:
set/map底层是红黑树(有序);unordered_set/unordered_map底层是哈希表(无序)。 - 两侧功能属性一致,差异在底层结构。
1.2 unordered_set 和 set 的使用差异【笔记】
查看文档会发现
unordered_set的增删查接口跟set一模一样,使用层面不再赘述。真正不同的是三个层面:
差异一:对 Key 的要求不同(使用要求)
set:Key 只需支持小于比较 (less),本质是红黑树的排序要求。unordered_set:Key 需支持转成整数 (可哈希)且支持等于比较 (equal_to)。- 这两点本质是哈希表的要求,要理解它得结合哈希表底层实现。
差异二:迭代器类型不同(功能)
set的迭代器是双向迭代器 (bidirectional_iterator);unordered_set是单向迭代器(forward_iterator)。- 后果:
set底层是红黑树,中序遍历有序 → 迭代器遍历结果是有序 + 去重 ;unordered_set底层是哈希表 → 遍历结果是无序 + 去重。
差异三:性能不同(效率)
- 大多数场景下
unordered_set的增删查更快:红黑树 O(logN),哈希表平均 O(1)。 - 【补充】注意"平均"二字:哈希表最坏 O(N)(全部冲突到同一桶),而红黑树最坏也只是 O(logN),下限有保证。
1.3 类模板定义(MSVC 文档)【笔记】
cpp
// C++11 引入
template <class Key,
class Hash = hash<Key>,
class Pred = equal_to<Key>,
class Alloc = allocator<Key>
> class unordered_set;
// Key → unordered_set::key_type / value_type
// Hash → unordered_set::hasher
// Pred → unordered_set::key_equal
// Alloc → unordered_set::allocator_type
template <class T,
class Compare = less<T>,
class Alloc = allocator<T>
> class set;
// T → set::key_type / value_type
// Compare → set::key_compare / value_compare
// Alloc → set::allocator_type
- 对照两个模板参数列表,差异一目了然:
set要一个Compare(排序依据),unordered_set要一个Hash+ 一个Pred(定位依据 + 判等依据)。 - 示例:
set<int, greater<int>> s;------ 传入greater后集合按从大到小排列。 - 【补充】这也解释了 1.2 差异一:模板参数本身就是对 Key 的约束------
hash<Key>要求 Key 可哈希,equal_to<Key>要求 Key 可判等。
二、直接定址法
2.1 原理【笔记】
- 当关键字的范围比较集中 时,直接定址法非常简单高效:
- 关键字都在
[0, 99]→ 开一个 100 长度的数组,关键字的值直接作为存储位置下标。 - 关键字都是
[a, z]的小写字母 → 开一个 26 长度的数组,ascii(key) - ascii('a')就是下标。
- 关键字都在
- 本质:用关键字计算出一个绝对位置或相对位置,直接存取。
- 时间 O(1);该方法在计数排序和 string 章节的 OJ 题目中已经用过。
2.2 局限【笔记】
- 当关键字范围比较分散时,直接定址法非常浪费内存甚至内存不够用。
- 浮点数、string 这类无法直接当下标用的关键字,直接定址法不行。
- 例:N=100 个值,范围却是
[0, 9999],直接定址要开 10000 的空间存 100 个数,大量空位。
三、哈希冲突
3.1 引入哈希函数【笔记】
- 假设 N 个值的数据范围是
[0, 9999],要映射到 M 个空间的数组中(一般 M ≥ N):- 借助哈希函数 h,关键字 key 被放到
h(key)位置; - 约束:
h(key)的值必须在[0, M)之间。
- 借助哈希函数 h,关键字 key 被放到
3.2 冲突的定义与不可避免性【笔记】
- 两个不同的 key 映射到同一位置 → 哈希冲突 / 哈希碰撞。
- 示例:M = 200 个空间,哈希函数
h(key) = key % M,则3 % 200 == 3,203 % 200 == 3------ 3 和 203 冲突。 - 理想情况是找到一个好哈希函数避免冲突,但实际中冲突不可避免(定义域远大于值域)。
- 策略:设计出优秀的哈希函数减少冲突次数 + 设计出解决冲突的方案。
3.3 负载因子【笔记】
-
设哈希表中已映射存储 N 个值,哈希表大小为 M:
负载因子 = N / M
(英文 load factor,也译作载荷因子 / 装载因子。)
-
权衡:负载因子越大 ,哈希冲突概率越高 ,空间利用率越高 ;负载因子越小,冲突概率越低,空间利用率越低。
3.4 将关键字转为整数【笔记】
- 把关键字映射到数组位置,整数最好做映射计算;不是整数就先想办法转成整数(细节在后面代码实现中展示)。
- 约定:后面讨论哈希函数时,如果关键字不是整数,讨论的 Key 都指关键字转换成的整数。
四、哈希函数
4.1 设计目标【笔记】
- 好的哈希函数应让 N 个关键字等概率、均匀地散列分布到 M 个空间中。实际中很难做到,但设计时尽量往这个方向靠。
4.2 除法散列法(除留余数法)【笔记】
- 哈希表大小 M,哈希函数
h(key) = key % M。
M 的选取坑
- 避免 M 是 2 的幂 :
key % 2^X本质等价于保留 key 的后 X 位 ------后 X 位相同的值哈希值全一样,必然冲突。- 例:
{63, 31}看起来毫无关联,M=16(2^4)时哈希值都是 15:63 → 0011 1111(后 4 位1111)31 → 0001 1111(后 4 位1111)
- 例:
- 避免 M 是 10 的幂 :保留的是十进制后 X 位。
- 例:
{112, 12312},M=100(10^2),哈希值都是 12。
- 例:
- 建议:M 取一个不太接近 2 的整数次幂的质数(素数)。
实践中的变通【笔记】
- 实践八仙过海各显神通:Java 的 HashMap 用除法散列法时,M 反而取 2 的整数次幂------这样不用取模,可以位运算,位运算比模运算高效。
- 但它不是简单取模:M = 2^16 本质是取后 16 位,于是
key' = key >> 16,再把key与key'异或 的结果作为哈希值。映射结果仍在[0, M),但让 key 的所有位都参与计算,分布更均匀。 - 小结:"M 取质数"是多数数据结构书上的理论;实践中要灵活运用、抓住本质,不能死读书。
位运算实现【笔记】
cpp
// key = 123456
int hashi = key % 2^16; // 等价于保留后 16 位
int hashi = key & 0xfff; // 按位与掩码取低位(折叠/位运算思路)
int n = 16;
int hashi = key & (1 << n - 1); // 掩码取低 n 位
hashi = hashi ^ (key >> (32 - n)); // 高位折叠:与右移后的值异或,让高位参与
- 【补充】
key & (1<<n-1)取低 n 位,key >> (32-n)把高 n 位挪下来异或进去,这叫折叠(folding):位数全用上,且异或对分布比简单相加更均匀(异或每一位独立进位,相加会产生进位链)。
五、解决冲突:开放定址法
5.1 基本思想【笔记】
- 开放定址法中所有元素都放在哈希表里:key 的初始位置冲突时,按某种规则在表内找下一个空位存放。
- 约束:开放定址法负载因子一定小于 1(必须留空位,否则探测永远找不到位置)。
- 探测规则三种:线性探测 、二次探测 、双重哈希。
5.2 线性探测【笔记】
演示:{19, 30, 5, 36, 13, 20, 21, 12} 存入 M=11 的表,h(key) = key % 11
初始哈希值:
h(19)=8, h(30)=8, h(5)=5, h(36)=3, h(13)=2, h(20)=9, h(21)=10, h(12)=1
按序插入后的表(下标 0~10):
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 21 | 12 | 13 | 36 | | 5 | | | 19 | 30 | 20 |
- 冲突过程:
h(19)=8空,存入 8;h(30)=8被 19 占 → 线性探测到 9,存入 9;h(20)=9被 30 占 → 探测到 10,存入 10;h(21)=10被 20 占 → 依次探测 11%11=0,存入 0。- 即每个 key 的存放位置被前面的冲突链"推着走"。
线性探测公式【笔记】
h(key) = hash0 = key % M
hash0 冲突时:hc(key, i) = hash_i = (hash0 + i) % M, i = 1, 2, 3, ..., M-1
- 负载因子 < 1 ⇒ 表中至少有 1 个空位 ⇒ 最多探测 M-1 次一定能找到存放位置。
- 表尾放不下时回绕到表头继续探测。
群集 / 堆积(clustering)问题【笔记】
- 若 hash0、hash1、hash2 连续三个位置都已存数据,那么后续所有映射到 hash0、hash1、hash2 的值都会争抢 hash3 位置 ------ 冲突会像滚雪球一样把空位堵死。
- 这就是线性探测的主要缺陷,下面的二次探测可一定程度改善。
删除必须标记删除【笔记】
- 开放定址表里不能直接物理删除 :给每个位置一个状态标识
{EXIST, EMPTY, DELETE},删除只改状态为 DELETE。 - 反例(用上面的表):查找 20 的路径是 h(20)=9 占着(30)→ 探到 10 找到 20。若直接删掉 30,查找 20 探到 9 看到 EMPTY 就会提前终止,找不到 20。
- 改为标记删除后,查找 20 遇到 9 的 DELETE 状态会继续探测,遇 EMPTY 才停止,于是能越过删除位找到 20。
5.3 二次探测【笔记】
-
针对群集的改进:探测步长不再是 +1, +2, +3,而是平方序列。
hc(key, i) = (hash0 + i*i) % M, i = 1, 2, 3, ...
演示:在上述表中用二次探测插入 21
h(21) = 21 % 11 = 10,此时 10 已被 20 占用(删除 30 前表:1=12, 2=13, 3=36, 5=5, 8=19, 9=30, 10=20)
i=1: (10 + 1*1) % 11 = 11 % 11 = 0 → 空位,存入
最终 21 落在下标 0
- 图中红色数字 11 即首次探测的未取模值(10+1),取模后为 0;相比线性探测"逐个撞墙",二次探测跳跃更大,被局部冲突链堵死的概率低。
5.4 双重哈希【补充】
-
笔记到此为止,补上第三种规则使"三种探测"闭环:
hc(key, i) = (h1(key) + i * h2(key)) % M, i = 1, 2, 3, ...
-
关键点:
h2(key)的结果必须与 M 互质(M 取质数时自动满足),否则探测序列绕不满整张表,部分空位永远探不到。 -
效果最好:每个 key 的探测序列由自身决定,不同 key 几乎不会互相聚集。
5.5 开放定址 vs 链地址法【补充】
- 笔记只讲开放定址,补一段对照帮助定位:
- 开放定址:所有数据都在哈希表数组里,无额外指针;删除要标记删除;负载因子必须 < 1(常用 0.7);缓存友好(数据连续)。
- 链地址法 :冲突元素挂到该槽的链表上;删除简单(链表摘除);负载因子可 > 1;C++
unordered_map/ JavaHashMap(JDK8 后链表退化红黑树)用的就是链地址思路。 - C++
unordered_*系列底层其实是桶(bucket)+ 链表 的混合结构,bucket_count/load_factor成员就是它的 M 和 N/M。
六、知识串联
计数排序 / string 章节 ──用过──▶ 直接定址法(关键当下标)
│ 范围分散,空间爆炸
▼
哈希函数 h(key) ∈ [0, M)
│ 不同 key 同位置
▼
哈希冲突(不可避免)
/ \
开放定址法(表内找空位) 链地址法(挂链表)
线性/二次/双重探测 C++ unordered_* 底层
│ 删除必须标记
│ 负载因子 < 1
▼
set/map(红黑树, O(logN), 有序) vs unordered_*(哈希表, 平均 O(1), 无序)
- 前接:红黑树 / map-set 迭代器系列(set 底层);计数排序(直接定址法的应用)。
- 后接:unordered_map 完整代码实现(桶 + 链表 + 负载因子触发 rehash)。
七、易错点清单
- 删除直接清掉空位 → 查找断链:开放定址表删除必须标记 DELETE;查找遇 DELETE 继续、遇 EMPTY 才停。
- M 取 2 的幂导致低位碰撞 :
key % 2^X只看后 X 位;{63, 31}在 M=16 下同落 15。 - Java HashMap 用 2 的幂是"取模变位运算 + 高位折叠异或",不是裸取模------别把"理论取质数"和"实践取 2 的幂"记成矛盾。
- 线性探测群集:连续占用后冲突值全部争抢同一空位;二次 / 双重探测改善。
- 开放定址负载因子必须 < 1,且最多探测 M-1 次必中(前提有空位)。
- set 与 unordered_set 的 Key 要求不同 :前者
<(红黑树排序),后者 可哈希 +==(哈希表定位)。 - 迭代器类别不同:set 双向 / 有序遍历,unordered_set 单向 / 无序遍历。
- 双重哈希 h2 与 M 必须互质,否则探测序列覆盖不全。
八、主动回忆清单(费曼)
- 为什么 unordered_set 的 Key 要求"转整数 + 判等"而不是"小于比较"?
3 % 200 == 3和203 % 200 == 3说明了什么?冲突能避免吗?- 负载因子 N/M 变大会发生什么?代价是什么?
- M=16 时 63 和 31 为什么冲突?(二进制后 4 位)
- Java HashMap 为什么敢用 2 的幂?关键操作
key ^ (key >> 16)在做什么? - 线性探测插入 {19,30,5,36,13,20,21,12} 到 M=11,每个 key 最终落在哪?
- 为什么删除 30 会让查 20 失败?标记删除怎么解决?
- 二次探测和线性探测的探测序列各是什么?为什么能缓解群集?
- 双重哈希公式中 h2(key) 与 M 为什么必须互质?
- set / map 与 unordered_set / unordered_map 在迭代器类别和遍历顺序上的差异?
九、面试高频考点
- Q:哈希表的负载因子为什么一般取 0.7 左右?
冲突率随负载因子上升而陡增;0.7 是"空间利用率 vs 平均探测次数"的实用折中;开放定址还硬约束 < 1。 - Q:为什么 Java HashMap 容量是 2 的幂?
取模可用hash & (M-1)位运算替代;配合扰动函数(高低位异或)让高位也参与,兼顾速度与分布。 - Q:开放定址法怎么实现删除?
不能物理清空(会截断查找路径),用 DELETE 标记位;查找遇 DELETE 继续、遇 EMPTY 终止。 - Q:unordered_map 相比 map 快在哪?什么时候反而慢?
平均 O(1) vs O(logN);但小数据量、且只增不删、或哈希函数差(大量同桶)时哈希表常数劣势明显,且 map 有序遍历、迭代器稳定是 unordered 没有的。 - Q:线性探测的群集是什么?如何缓解?
冲突链把相邻空位抢光导致后续冲突集体堵死;二次探测 / 双重哈希打散探测序列。
任何的批评和建议欢迎指出,我们共同进步!