哈希表底层原理解析,直接定址法,哈希冲突,负载因子,哈希函数,开放定址法

哈希表底层原理:从 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) 之间。

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 的选取坑

  1. 避免 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)
  2. 避免 M 是 10 的幂 :保留的是十进制后 X 位。
    • 例:{112, 12312},M=100(10^2),哈希值都是 12。
  3. 建议: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 / Java HashMap(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)。

七、易错点清单

  1. 删除直接清掉空位 → 查找断链:开放定址表删除必须标记 DELETE;查找遇 DELETE 继续、遇 EMPTY 才停。
  2. M 取 2 的幂导致低位碰撞 :key % 2^X 只看后 X 位;{63, 31} 在 M=16 下同落 15。
  3. Java HashMap 用 2 的幂是"取模变位运算 + 高位折叠异或",不是裸取模------别把"理论取质数"和"实践取 2 的幂"记成矛盾。
  4. 线性探测群集:连续占用后冲突值全部争抢同一空位;二次 / 双重探测改善。
  5. 开放定址负载因子必须 < 1,且最多探测 M-1 次必中(前提有空位)。
  6. set 与 unordered_set 的 Key 要求不同 :前者 <(红黑树排序),后者 可哈希 + ==(哈希表定位)。
  7. 迭代器类别不同:set 双向 / 有序遍历,unordered_set 单向 / 无序遍历。
  8. 双重哈希 h2 与 M 必须互质,否则探测序列覆盖不全。

八、主动回忆清单(费曼)

  1. 为什么 unordered_set 的 Key 要求"转整数 + 判等"而不是"小于比较"?
  2. 3 % 200 == 3 和 203 % 200 == 3 说明了什么?冲突能避免吗?
  3. 负载因子 N/M 变大会发生什么?代价是什么?
  4. M=16 时 63 和 31 为什么冲突?(二进制后 4 位)
  5. Java HashMap 为什么敢用 2 的幂?关键操作 key ^ (key >> 16) 在做什么?
  6. 线性探测插入 {19,30,5,36,13,20,21,12} 到 M=11,每个 key 最终落在哪?
  7. 为什么删除 30 会让查 20 失败?标记删除怎么解决?
  8. 二次探测和线性探测的探测序列各是什么?为什么能缓解群集?
  9. 双重哈希公式中 h2(key) 与 M 为什么必须互质?
  10. 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:线性探测的群集是什么?如何缓解?
    冲突链把相邻空位抢光导致后续冲突集体堵死;二次探测 / 双重哈希打散探测序列。

任何的批评和建议欢迎指出,我们共同进步!

相关推荐
xxwl5851 小时前
区域赛算法模版第五章---数学与数论
c++·笔记·算法
paopaokaka_luck1 小时前
民宿管理系统 (AI民宿总结、协同过滤推荐算法、WebSocket 在线咨询、ECharts 经营分析、房态与结算联动、 民宿预订业务闭环)
java·人工智能·spring boot·websocket·数据分析·echarts·推荐算法
java资料站1 小时前
十四、Spring AI Alibaba · 案例 · 基于 HumanInTheLoop 的内容审核系统
java·spring·状态模式
局部有解1 小时前
LeetCode Hot100 438 找到字符串中所有字母异位词
c++·算法·leetcode
David猪大卫2 小时前
【C++修炼】string类:string类的模拟实现
c语言·开发语言·数据结构·c++·经验分享·笔记·学习
SunnyDays10112 小时前
如何使用 Java 提取 PPT 中的文字:整份文档、指定幻灯片与表格内容
java·powerpoint
毕设单片机2 小时前
springboot志愿服务平台的设计与实现 36731
java·spring boot·mysql·志愿服务平台
IT_Octopus2 小时前
【零基础入门 LLM 开发 · Day 15】:LangChain 注册工具——告别手写 JSON Schema
java·langchain·json