一. 哈希表的基本概念
哈希(又称散列)是一种高效的数据组织方式。其名称暗示了数据的分散存储特性,其核心原理是通过哈希函数建立关键字Key与存储位置之间的映射关系。这种机制使得查找操作时,只需通过哈希函数快速计算出Key对应的存储位置即可完成检索
什么是哈希表?
哈希表是一种通过 哈希函数(Hash Function) 将特定的键映射到存储位置的数据结构
它的核心思想非常简单直接:下标访问
在计算机科学中,数组(Array)是查找最快的数据结构,只要知道下标,访问时间复杂度就是 O(1)。哈希表的本质,就是通过某种映射关系,将原本杂乱无章的 Key(比如字符串、复杂结构体)转化成数组的下标
我们可以用一个简单的数学公式来表达这个过程:
哈希表的三大核心要素
要理解哈希表,必须掌握以下三个基本概念,它们也是我们后续手动实现时的核心逻辑:
-
桶(Bucket/Slot):哈希表底层数组中的每一个存放单元
-
哈希冲突(Hash Collision):由于数组空间有限,不同的 Key 经过哈希函数计算后,可能会得到同一个索引位置。如何处理这种碰撞现象,是衡量一个哈希表好坏的关键
-
装载因子(Load Factor):衡量哈希表满不满的一个指标。这个数值直接决定了我们什么时候需要扩容
二. 直接定址法
如果我们要处理的数据范围非常有限且连续(比如字母、0-99 的数字),我们根本不需要复杂的哈希函数
字符串中的第一个唯一字符 (LeetCode. 387)
题目描述:给定一个字符串 s,找到它的第一个不重复的字符,并返回它的索引。如果不存在,则返回 -1
我们要统计每个字符出现的次数。由于英文字母只有 26 个('a' 到 'z'),我们完全没必要用复杂的 std::unordered_map。我们可以直接开辟一个长度为 26 的整数数组
-
映射规则:字符 'a' 对应下标 0,'b' 对应 1......以此类推。
-
计算公式:Index = char - 'a'
代码实现
cpp
int firstUniqChar(string s) {
int count[26] = 0; // 一个最简单的哈希表
// 1. 统计频率
for(auto& e : s)
count[e - 'a']++;
// 2. 按照字符串顺序查找第一个频次为 1 的字符
for(int i = 0; i < s.size(); i++)
{
if(count[s[i]] == 1)
return i;
}
return -1;
}
这种取关键字的某个线性函数值为散列地址的方法,就叫做直接定址法
其数学表达式通常为:
(在上面的例子中,A = 1, B = -'a')
优点:查找、插入的时间复杂度是严格的 O(1),且没有任何哈希冲突(Collision),因为每一个 Key 都有自己专属的坑位。不需要处理复杂的冲突逻辑,代码运行极快
缺点 :空间利用率低,如果你要存的 Key 是 {1, 10, 10000},为了这三个数,需要开辟一个长度为 10001 的数组,中间 99% 的空间都浪费了。并且它要求 Key 必须是整数(或能简单转为整数),且范围必须是已知且小的
哈希冲突与负载因子
在直接定址法中,每一个 Key 都有自己的专属领地。但在处理诸如身份证号、姓名或海量随机数时,我们不可能开辟一个无限大的数组
我们必须通过哈希函数将一个巨大的键值空间 (Key Space)映射到一个有限的存储空间(Bucket Array)中
哈希冲突:鸽巢原理
想象你有 10 个鸽笼,但飞来了 11 只鸽子。根据鸽巢原理(Pigeonhole Principle),无论你如何安排,至少有一个笼子里会挤进两只鸽子
在哈希表中,当两个不同的 Key(K1 != K2)经过哈希函数计算后,得到了相同的索引值
(f(K1) = f(K2)),这就是哈希冲突
哈希冲突是不可避免 的,除非 Key 的范围小于等于数组长度。一个好的哈希算法只能尽量减少冲突的概率,而不能完全杜绝
负载因子 (Load Factor)
既然冲突无法避免,我们如何衡量一个哈希表的拥挤程度呢?这就引入了负载因子的概念。
定义公式:
其中:n 是已存储的元素个数。m 是哈希表的总长度(桶的数量)
当 α 较小时,表空间较空闲,冲突概率低,查找速度快但内存利用率较差。当α增大时,表空间趋于饱和,冲突概率显著上升,查找性能下降,极端情况下可能退化为 O(N) 线性查找
当冲突无法避免时,我们的任务就变成了两件事:第一,设计高效的哈希函数以实现数据的均匀分布;第二,建立完善的冲突处理机制,确保即使发生碰撞也能快速定位备用存储位置
三. 哈希函数
一个优秀的哈希函数必须具备两个特质:计算极快 且分布极匀。下面介绍三种经典的设计方法
除法散列法(Division Method)
这是最直觉、也是代码实现中最常用的方法
公式:
其中 M 是哈希表的长度(桶的个数)
尽管公式看似简单,但 M 的取值却颇有讲究。特别需要注意避免选择 2 的幂次方或 10 的幂次方作为 M 值
-
10 的幂(如 M=100):本质上只保留了十进制的后两位。比如 112 和 12312,映射结果都是 12。这意味着 key 的高位信息完全丢失了
-
2 的幂(如 M=16 即 2^4):本质上只保留了二进制的后 4 位。比如 63 (00111111) 和 31 (00011111),虽然数看起来差很多,但后 4 位全是一样,结果都会撞在下标 15 上
在纯理论模型中,我们建议 M 取一个不接近 2 的整数幂的质数(素数)。这样能让 key 的每一位都更有机会参与到计算中,使分布更均匀
虽然书上建议取质数,但在工业级实现(如 Java 的 HashMap)中,为了极致的性能,M 反而经常取 2 的整数幂,当 M = 2 ^ n 时,取模运算 % 可以转化为位运算 & (M - 1)。位运算的效率远高于取模运算
那怎么解决冲突问题?
工程师们选择通过扰动函数 来弥补,将 key 的高 16 位与低 16 位进行异或运算
(key ^ (key >> 16)),这样即使 M 只取后 16 位,前面的高位信息也通过异或揉进了低位里
关键在于:理论上素数更稳妥,但实践中位运算更高效。只要算法能确保 key 的所有位都参与运算,M 是否为素数就不再是硬性要求
乘法散列法(Multiplication Method)
相比于除法散列法对哈希表大小 M(尤其是素数)的严苛要求,乘法散列法显得更加随和。它的核心思想是通过小数部分的分布来实现均匀映射
乘法散列法的计算过程可以分为两步:
-
用关键字 key 乘以一个常数 A (0 < A < 1),并提取出 key * A 的小数部分
-
用哈希表大小 M 乘以这个小数,最后向下取整得到索引
公式:
注:(keyA mod 1)在数学上表示取其小数部分
常数 A 的选取
乘法散列法的效果极大程度上取决于常数 A。计算机科学巨匠 Knuth 认为,取黄金分割点附近的值效果最好:
假设哈希表大小 M = 1024,我们要映射 key = 1234:
-
计算 A * key = 0.6180339887 * 1234 = 762.6539420558
-
提取小数部分:0.6539420558
-
乘以 M:1024 * 0.6539420558 = 669.6366651392
-
向下取整:h(1234) = 669
这种方法的优势在于能够自由设置哈希表的大小(例如为方便位运算设为2^n),同时保持良好的均匀性。尽管涉及浮点运算,但在现代处理器中可以通过定点数运算(位移和整数乘法)高效实现
全域散列法(Universal Hashing)
如果说前两种方法是追求分布均匀,那么全域散列法则是为了应对恶意攻击
如果你的哈希函数 h(key) 是公开且固定不变的,一个恶意的攻击者(或者极其特殊的数据集)可以专门构造出一组数据,让它们全部映射到同一个下标
后果 :哈希表会瞬间退化成一个低效的单向链表,原本 O(1) 的查找退化成 O(N)。这种安全漏洞在业内被称为 Hash Flooding Attack(哈希洪水攻击)
核心思路:
我们不只准备一个函数,而是准备一整组哈希函数
定义一个函数族,公式如下:
-
P:取一个足够大的质数。
-
a:在 [1, P-1] 之间随机选一个整数。
-
b:在 [0, P-1] 之间随机选一个整数。
这样,a 和 b 的不同组合就构成了成千上万个不同的哈希函数
这里要注意:不能插入时用 h(1,2),查找时却随机抽到了 h(3,4),那就像是存包时记错了柜子号,永远也找不回来了
全域散列法在普通的业务开发中不常见,但在高性能路由器、大型数据库内核以及高安全性系统等关键领域,却是确保系统鲁棒性的核心技术
四. 开放定址法**(Open Addressing)**
当哈希函数计算的位置已被占用时,**开放定址法(Open Addressing)**的核心思路是:就近寻找下一个可用位置。这种方法将所有元素都存储在哈希表数组中,无需额外链表空间
在开放定址法中,线性探测是最简单直观的解决策略
线性探测(Linear Probing)
线性探测的逻辑非常直接:如果 h(key) 的位置冲突了,就检查 h(key) + 1,如果还冲突,就检查 h(key) + 2,直到找到空位为止。如果走到数组尾部,就绕回头部继续找
公式:
其中:hash0 = key(mod M),M 为哈希表的大小
假设哈希表大小 M = 11,我们要插入这一组值:{19, 30, 5, 36, 13, 20, 21, 12}
最终布局图为:

线性探测虽然简单,但它有一个严厉的副作用:群集现象
如果下标 8, 9, 10 都已经满了,那么任何原本映射到 8、9、10 甚至是 7 的数据,都会跑去争夺 11 或更高的位置。这种一人占位,连累一片的连锁反应,会导致哈希表中出现大块连续的占用区域
后果:随着数据增多,冲突的概率会呈指数级上升,查找一个空位可能要探测半个数组, O(1) 直接退化
二次探测(Quadratic Probing)
既然线性探测一个挨一个找位子容易导致群集现象,那我们能不能 跳着找?这就是二次探测的核心逻辑。二次探测不再是 +1, +2, +3 地线性查找,而是以二次方(平方)的步长,左右交替进行探测。这样可以将冲突的元素均匀地散到表的各个角落,有效避免了线性探测中的堆积问题
设初始哈希地址为 hash0,探测序列为:
如果式子右边结果为负数,需要处理回绕。在代码中通常这样写:
cpp
hashi = (hash0 - i * i) % M;
if (hashi < 0) hashi += M; // 确保下标为正
假设哈希表大小 M = 11,我们要插入:{19, 30, 52, 63, 11, 22}
最终布局图为:

二次探测极大缓解了线性探测导致的数据聚集问题,使数据分布更为均匀。不过它也存在一些局限:首先,当不同键值具有相同的初始哈希值时,它们的探测路径仍然会重合(二次聚集);其次,如果哈希表大小 M 不是质数,即使表中仍有空闲位置,二次探测可能无法遍历所有存储单元
双重散列(Double Hashing)
线性探测的步长是固定的(始终为 1),二次探测的步长虽然在变,但仅仅取决于探测次数 i。这意味着初始哈希值相同的两个 Key,它们的探测路径依然是完全一样的。这种现象被称为二次群集
双重散列 通过引入第二个哈希函数,彻底解决了这个问题。它的核心思想是:步长不再是固定的,而是由 Key 本身决定的
设初始哈希函数为 h1(key),第二个哈希函数为 h2(key),探测序列为:
设计第二个哈希函数的原则
为了保证算法有效,其必须满足:
-
永不为 0:如果步长为 0,探测就会原地打转。
-
与 M 互质:为了确保探测能覆盖到表中的所有位置,步长最好与表大小 M 互质。通常做法是取 M 为质数,并让 h2(key) 返回一个比 M 小的正整数
插入模拟:
假设哈希表大小 M = 11,我们定义的两个哈希函数分别为:
我们要依次插入:{19, 30, 52, 74}。这四个数在 h1 下全部指向同一个位置,但最终却由于 h2 的不同而各得其所
1. 插入 19:通过哈希函数计算下标 8 为空,直接放入
2. 插入 30 :h1(30) = 8。冲突!计算步长:h2(30) = 1。探测 i = 1:(8 + 1 * 1) (mod 11) = 9。
下标 9 为空,放入
3. 插入 52 :h1(52) = 8。冲突!计算步长:h2(52) = 3。探测 i = 1:(8 + 1 * 3) (mod 11) = 0。
下标 0 为空,放入
4. 插入 74: h1(74) = 8。冲突!计算步长:h2(74) = 5。探测 i=1:(8 + 1 * 5) (mod 11) = 2。
下标 2 为空,放入
最终布局图为:

开放定址法的优缺点
-
优点:内存连续性好,所有数据都在一个数组里,对 CPU 缓存(Cache)非常友好,查询速度极快
-
缺点:容易堆积,当负载因子 α > 0.7 时,性能会急剧下降。并且删除复杂,不能直接把位置设为空(会导致查找断裂),必须使用特殊的状态位
五. 链地址法(Separate Chaining)
当两个 Key 发生冲突时,我们不再试图在数组中寻找下一个空位,而是直接在当前的冲突位置原地扎根,建立一个容器来存放所有映射到这里的元素
基本思想
在链地址法中,哈希表底层的数组不再直接存储数据本身,而是存储一个指针(或引用)。这个指针指向一个外部的容器(通常是单向链表)
-
插入:找到对应的桶,将新元素插入到该桶对应的链表中
-
查找:定位到桶,然后遍历该桶背后的链表,直到找到目标
-
删除:找到链表,利用链表的删除操作将其移除

链地址法的特点
1. 负载因子 α 可以大于 1
这是链地址法最显著的优势。在开放定址法中,当 α 接近 1.0 时,性能会彻底崩盘,因为根本找不到空位了。而在链地址法中,α 完全可以超过 1.0。比如 α = 2.0 意味着平均每个桶下面挂了 2 个节点。虽然查找速度会变慢,但系统依然能正常运转
2. 内存分配的灵活性
-
优点:不需要像开放定址法那样预留大量的连续空间来应对冲突。只有当真正有新数据进入时,才会在堆上申请链表节点的内存
-
缺点 :由于链表节点是离散分布在内存中的,这会导致 CPU 缓存不友好(Cache Miss 较多),在海量数据的极致性能表现上,有时会逊色于紧凑的开放定址法
3. 性能退化的极端情况
如果哈希函数设计得极差,导致成千上万个元素都掉进了同一个桶里,那么哈希表就会退化成一个超长单向链表
此时,查询的时间复杂度会从 O(1) 退化到 O(N)
现代工业级实现(如 Java 8 的 HashMap)为了解决这个问题,引入了红黑树化策略。当一个桶里的链表长度超过一定阈值 (8) 时,会自动转换成一棵红黑树,将最坏情况下的复杂度从 O(N) 压回到 O(log N)
开放定址法与链地址法对比
写到这里,我们已经看清了处理哈希冲突的两大主流解决方案:
| 特性 | 开放定址法 | 链地址法 |
|---|---|---|
| 存储位置 | 全在底层数组里 | 数组 + 外部链表/树 |
| 负载因子 | 必须严格控制(通常 < 0.7) | 容忍度高(可 > 1.0) |
| 内存开销 | 较低(无指针) | 较高(需存储指针 / 链表节点) |
| 缓存性能 | 极佳(内存连续) | 一般(内存离散) |
| 删除操作 | 复杂(需维护状态位) | 简单(常规链表操作) |
| 适用场景 | 数据量已知、追求极致缓存效率 | 数据量波动大、删除频繁 |
六. 理论篇总结
总的来说,哈希表是计算机科学中空间换时间思想的终极体现。它通过哈希函数将复杂的键空间映射为简单的数组下标,试图在平均情况下触碰 O(1) 的查找极限。在这一过程中,我们既要设计分布均匀的散列函数(如除法、乘法或随机化的全域散列)来减少碰撞,也要通过开放定址法 (利用连续内存的缓存优势)或链地址法(利用外部空间的灵活性)来化解不可避免的冲突。当负载因子 α 触及红线,或者单个桶的性能因极端冲突而退化时,及时的扩容与红黑树化等工业级优化则是保障系统鲁棒性的最后防线
在本文中,我们主要介绍了哈希表的基本原理、散列函数以及冲突产生的原因。下一篇中,我们将详细介绍两种主流的冲突解决策略 ------ 开放定址法与链地址法完整的 C++ 实现
