
请君浏览
-
- 前言
- [1. 哈希表的概念](#1. 哈希表的概念)
-
- [1.1 哈希函数(Hash Function):哈希表的 "地址映射引擎"](#1.1 哈希函数(Hash Function):哈希表的 “地址映射引擎”)
- [1.2 哈希冲突(Hash Collision):哈希函数的 "必然产物"](#1.2 哈希冲突(Hash Collision):哈希函数的 “必然产物”)
- [1.3 负载因子(Load Factor):衡量 "数据拥挤程度" 的核心指标](#1.3 负载因子(Load Factor):衡量 “数据拥挤程度” 的核心指标)
- [2. 哈希函数](#2. 哈希函数)
-
- [2.1 直接定址法(Direct Addressing)](#2.1 直接定址法(Direct Addressing))
- [2.2 除留余数法(Division Method)](#2.2 除留余数法(Division Method))
- [2.3 其他方法](#2.3 其他方法)
- [3. 哈希冲突](#3. 哈希冲突)
- [4. 小结](#4. 小结)
- 尾声
前言
今天,我们继续踏入追寻C++的冒险历程。前面我们讲了很多的树形数据结构,那么这一章我们来讲解另一类数据结构------哈希表。下面让我们一起来进入本章的学习。
1. 哈希表的概念
哈希表(又称散列表 )是一种基于「键值对(Key-Value)」存储的数据结构,其核心目标是通过哈希函数 将「键(Key)」直接映射到对应的存储位置,从而实现 O (1) 级别的平均查找、插入和删除效率,是计算机科学中效率最高的数据结构之一。
对于我们来说,哈希表并不陌生,我们先来了解一些概念性的东西。
1.1 哈希函数(Hash Function):哈希表的 "地址映射引擎"
哈希函数是哈希表的核心组件,其本质是一个数学函数,作用是将「任意类型、任意长度的关键字(Key)」转换为「固定范围、可直接作为底层数组索引的整数(哈希值 / Hash Value)」,从而实现 "通过 Key 快速定位存储位置" 的目标。
1.2 哈希冲突(Hash Collision):哈希函数的 "必然产物"
哈希冲突是指不同的 Key 经过哈希函数计算后,得到了相同的哈希值 的现象。它不是 "设计失误",而是数学上的必然结果。
1.3 负载因子(Load Factor):衡量 "数据拥挤程度" 的核心指标
假设哈希表中已经映射存储了N个值,哈希表的⼤⼩为M,那么 ,负载因⼦有些地⽅也翻译为载荷因⼦/装载因⼦等。负载因⼦越⼤,哈希冲突的概率越⾼,空间利⽤率越⾼;负载因⼦越⼩,哈希冲突的概率越低,空间利⽤率越低。负载因子的关键应用是触发哈希表进行扩容。
2. 哈希函数
哈希函数是哈希表的 "核心引擎",作用是:把任意类型的 "键(Key)"(比如整数、字符串、对象),转换成一个固定范围的整数(称为 "哈希值" 或 "索引"),这个索引直接对应底层存储数组的位置。
哈希函数的设计要求:
- 确定性:同一个 Key 每次输入哈希函数,必须得到相同的索引(若结果随机,则无法查找)。
- 均匀性:尽量将不同的 Key 映射到不同的索引,减少「哈希冲突」。
- 高效性:哈希函数的计算过程必须快速(如简单的取模、位运算),否则会抵消哈希表的效率优势。
下面让我们来认识一下常见的哈希函数:
2.1 直接定址法(Direct Addressing)
直接定址法是最直观的哈希函数构造方式,其核心是 "关键字与哈希地址直接关联",无需复杂计算,是理解哈希函数设计的基础。
直接定址法通过关键字本身或关键字的线性变换 直接作为哈希地址,它的本质是建立关键字与哈希地址的线性映射关系 :每个关键字通过公式计算后,会映射到唯一的哈希地址(数组索引),且不同关键字的哈希地址一定不同,这种 "一一对应" 的特性决定了:直接定址法不会产生哈希冲突(这是它与其他哈希函数的核心区别)。
理解映射过程:例如我们要统计一个字符串中每个字符出现的次数(确保字符串中都是小写字母),我们可以以字符的ASCII码值作为关键字,因为小写字母的ASCII码值是从97到123,所以我们可以通过简单的线性变换将其关键字映射到大小为26的数组中:
优点:
- 计算高效:仅需一次线性运算(或直接使用关键字),几乎无额外开销,是所有哈希函数中计算最快的;
- 无冲突:由于映射关系是一一对应,完全避免哈希冲突,无需设计冲突解决机制;
- 实现简单:无需复杂逻辑,直接通过公式映射,代码实现难度低。
缺点:
- 空间利用率极低 :仅适用于关键字范围小且连续 的场景。若关键字范围大(如
0~10^9
),哈希表数组容量需与关键字范围匹配,会导致大量空间浪费(例如存储 100 个数据,可能需要 10 亿大小的数组); - 灵活性差:仅支持整数关键字(非整数需先转为整数,且转换后范围仍需满足 "小而连续");
- 不适合动态数据:若关键字范围不固定(如新增超出原范围的关键字),哈希表需频繁调整数组大小,成本极高。
适用场景:
直接定址法的应用场景非常受限,仅适合关键字范围已知、固定且较小的场景,例如:
- 员工编号(如
1~1000
的整数,连续且范围明确); - 月份(
1~12
)、日期(1~31
)等有限范围的数值; - 数据库中固定前缀的自增 ID(如
5001~5100
,范围明确且连续)。
2.2 除留余数法(Division Method)
除留余数法(也叫除法散列法)是实际开发中最常用的哈希函数构造方法,其核心是通过 "取模运算" 将关键字压缩到固定范围的地址,能适应大多数关键字场景。
除留余数法通过关键字对 "数组大小" 取模 得到哈希地址,哈希函数为:H(key) = key mod m
其中:
key
为原始关键字(整数或经转换后的整数,如字符串转整数);m
为哈希表底层数组的大小;mod
为取模运算(即求key
除以m
的余数),结果范围为0~m-1
,恰好对应数组的索引范围。
因此当我们用哈希表来存储数据时的一个必须的要求就是数据的类型必须能通过某种方式转为整数。
除留余数法的本质是将任意范围的关键字通过取模运算,压缩到 0~m-1
的地址空间 (与数组大小匹配)。其核心是通过选择合适的 m
,使哈希值在 0~m-1
范围内均匀分布,从而减少哈希冲突。
例如:若 m=13
(数组大小 13),关键字 key=123
时,123 mod 13 = 6
(因 13×9=117,123-117=6),则哈希地址为 6,落在 0~12
范围内。
当使⽤除法散列法时,要尽量避免m为某些值,如2的幂,10的幂等。如果是 ,那么key %
本质相当于保留key的后x位,那么后x位相同的值,计算出的哈希值都是⼀样的,就冲突了。如:{63 , 31}看起来没有关联的值,如果m是16,也就是 ,那么计算出的哈希值都是15,因为63的⼆进制后8位是 00111111,31的⼆进制后8位是 00011111。如果是 ,就更明显了,保留的都是10进值的后x位,如:{112, 12312},如果M是100,也就是 ,那么计算出的哈希值都是12。 因此当使⽤除留余数法时,建议m取不太接近2的整数次幂的⼀个质数(素数)。
需要说明的是,实践中也是⼋仙过海,各显神通,Java的HashMap采⽤除法散列法时就是2的整数次幂做哈希表的⼤⼩m,这样的话就不⽤取模,可以直接位运算,相对⽽⾔位运算⽐取模更⾼效⼀些。但是他不是单纯的去取模,⽐如m是2^16次⽅,本质是取后16位,那么⽤
key' = key>>16
,然后把key和key' 异或的结果作为哈希值。也就是说我们映射出的值还是在[0,m)范围内,但是尽量让key所有的位都参与计算,这样映射出的哈希值更均匀⼀些即可。所以我们上⾯建议m取不太接近2的整数次幂的⼀个质数的理论是⼤多数数据结构书籍中写的理论,但是实践中需要灵活运⽤,抓住本质。
对于哈希表来说最常见的数据类型除了整数外还有字符串,那么对于字符串我们该如何处理呢?处理的方法有很多种,如ASCII码求和、多项式哈希等等,不过ASCII码求和法的冲突率太高,一般不会使用。(下面的方法了解即可)
多项式哈希法是实际开发中最常用的字符串哈希方法,核心是给字符串中不同位置的字符赋予不同权重(基于 "基数" 的幂次),让位置信息影响哈希值,大幅降低冲突率。
对于字符串
s = s[0]s[1]...s[n-1]
,哈希值计算为:hash = s[0] × base^(n-1) + s[1] × base^(n-2) + ... + s[n-2] × base^1 + s[n-1] × base^0
其中:
s[i]
表示第 i 个字符的 ASCII 值(或其他整数映射);base
是一个预设的基数(通常选大质数,如 31、37、10^9+7 等,避免与字符编码范围重叠);- 为防止哈希值过大导致溢出,通常会对一个大质数
mod
取模(如10^9+7
、2^61-1
),最终结果为hash % mod
。示例
以
base=31
,mod=10^9+7
为例,计算 "abc" 的哈希值:
s[0] = 'a' = 97
,权重31^2 = 961
→ 97×961 = 93217;s[1] = 'b' = 98
,权重31^1 = 31
→ 98×31 = 3038;s[2] = 'c' = 99
,权重31^0 = 1
→ 99×1 = 99;- 总和 = 93217 + 3038 + 99 = 96354 → 哈希值 = 96354 % (10^9+7) = 96354。
而 "cba" 的计算为:
- 99×961 + 98×31 + 97×1 = 95139 + 3038 + 97 = 98274 → 与 "abc" 的哈希值不同,无冲突。
除此之外还有经典的哈希函数DJB2 与 SDBM,这两种是工业界广泛使用的字符串哈希函数,由实践验证具有低冲突率和高计算效率,常用于哈希表、数据库索引等场景。
DJB2 哈希函数:
hash = 5381
(初始值);hash = hash * 33 + ASCII(s[i])
(迭代)特点:5381 是一个经过验证的优质初始值,33 是高效的乘数(33 = 32 + 1,可优化为
hash << 5 + hash + c
),冲突率极低。SDBM 哈希函数:
hash = 0
(初始值);hash = hash * 65599 + ASCII(s[i])
(迭代)特点:65599 是大质数,分布性优于小基数,适合长字符串,与 DJB2 并称 "工业级标准"。
优点:
- 适用范围广:可处理任意范围的关键字(整数、字符串、对象等,只需转为整数),无论关键字范围大小;
- 地址范围可控 :哈希地址固定在
0~m-1
,桶数组大小m
可灵活设置,避免空间浪费; - 实现简单 :取模运算在计算机中高效(尤其
m
为 2 的幂时,可用位运算key & (m-1)
替代,速度更快); - 可通过
m
优化冲突率 :选择合适的m
(如质数)可大幅降低冲突概率。
缺点:
- 存在哈希冲突 :由于关键字范围远大于
m
,必然存在不同关键字映射到同一地址的情况,需配合冲突解决方法(如链地址法); m
的选择敏感 :若m
选择不当(如偶数、小合数),会导致哈希值分布不均,冲突率激增;- 对字符串等非整数关键字需额外转换:需先将非整数关键字转为整数(如字符串哈希),增加少量计算开销。
适用场景
除留余数法是工业界最通用的哈希函数构造方法,几乎适用于所有场景,尤其是:
- 关键字范围大且不连续(如用户 ID、订单号,可能是
1~10^9
的整数); - 关键字为非整数类型(如字符串、对象,需先转为整数);
- 动态数据场景(数据量未知或会增长,可通过扩容调整
m
维持性能)。
典型应用:Java HashMap(底层用 key & (m-1)
替代取模,m
为 2 的幂)、Python dict、Redis 哈希表等。
2.3 其他方法
除了直接定址法与除留余数法外,还有许多成熟的哈希函数,这里再简单介绍几种:
-
乘法散列法:
乘法散列法对哈希表⼤⼩m没有要求,他的⼤思路第⼀步:⽤关键字 K 乘上常数 A (0<A<1),并抽取出 k * A 的⼩数部分。第⼆步:后再⽤m乘以k * A 的⼩数部分,再向下取整。
h(key) = floor(M × ((A × key)%1.0))
其中floor
表⽰对表达式进⾏下取整,A∈(0,1),这⾥最重要的是A的值应该如何设定,Knuth认为A = ( 5 - 1)/2 = 0.6180339887.... (⻩⾦分割点)
⽐较好。乘法散列法对哈希表⼤⼩M是没有要求的,假设m为1024,key为1234,A = 0.6180339887, A * key = 762.6539420558,取⼩数部分为0.6539420558, M×((A×key)%1.0) = 0.6539420558*1024 = 669.6366651392,那么h(1234) = 669。
-
全域散列法:
如果存在⼀个恶意的对⼿,他针对我们提供的散列函数,特意构造出⼀个发⽣严重冲突的数据集,⽐如,让所有关键字全部落⼊同⼀个位置中。这种情况是可以存在的,只要散列函数是公开且确定的,就可以实现此攻击。解决⽅法⾃然是⻅招拆招,给散列函数增加随机性,攻击者就⽆法找出确定可以导致最坏情况的数据。这种⽅法叫做全域散列法。
hab(key) = ((a × key + b)%P )%M,P需要选⼀个⾜够⼤的质数,a可以随机选[1,P-1]之间的任意整数,b可以随机选[0,P-1]之间的任意整数,这些函数构成了⼀个P * (P-1)组全域散列函数组。假设P=17,M=6,a = 3, b = 4, 则 h34>(8) = ((3 × 8 + 4)%17)%6 = 5。
需要注意的是每次初始化哈希表时,随机选取全域散列函数组中的⼀个散列函数使⽤,后续增删查改都固定使⽤这个散列函数,否则每次哈希都是随机选⼀个散列函数,那么插⼊是⼀个散列函数,查找⼜是另⼀个散列函数,就会导致找不到插⼊的key了。
上⾯的⼏种⽅法是《算法导论》中讲解的⽅法。《殷⼈昆 数据结构:⽤⾯向对象⽅法与C++语⾔描述 (第⼆版)》和 《[数据结构(C语⾔版)].严蔚敏_吴伟⺠》等教材型书籍上⾯还给出了平⽅取中法、折叠法、随机数法、数学分析法等,这些⽅法相对更适⽤于⼀些局限的特定场景,有兴趣可以去看看这些书籍。
3. 哈希冲突
哈希冲突的本质是 "Key 的取值范围" 与 "哈希表大小" 不匹配:
- Key 的取值范围是无限或极大的(如所有整数、所有字符串、所有对象);
- 哈希表的大小是有限的(受内存限制,不可能无限大,如初始大小 16、32、64)。
根据 "鸽巢原理"(n 个鸽子放入 m 个鸽巢,n>m 时至少有一个鸽巢有 2 只鸽子),当存储的 Key 数量超过哈希表大小时,必然出现多个 Key 映射到同一位置的情况。
实践中哈希表⼀般还是选择除法散列法作为哈希函数,当然哈希表⽆论选择什么哈希函数也避免不了冲突,那么插⼊数据时,如何解决冲突呢?主要有两种两种⽅法,开放寻址法和链地址法。
3.1 开放寻址法(Open Addressing)
不使用额外数据结构(如链表),当发生冲突时,**按固定规则在数组中寻找下一个空的位置 **,将 Key-Value 对存入空位置。查询时,若当前位置的 Key 不匹配,同样按规则继续查找,直到找到目标 Key 或空位置(表示 Key 不存在)。
常见的 "寻址规则":
-
线性探测(Linear Probing) :冲突时,依次检查下一个位置(
H_i(Key) = (H(Key) + i) % m,i=0,1,2...
)从发⽣冲突的位置开始,依次线性向后探测,直到寻找到下⼀个没有存储数据的位置为⽌,如果⾛到哈希表尾,则回绕到哈希表头的位置。
h(key) = hash0 = key % M
, hash0位置冲突了,则线性探测公式为:hc(key, i) = hashi = (hash0 + i) % M, i = {1, 2, 3, ..., M - 1}
,因为负载因⼦⼩于1,则最多探测M-1次,⼀定能找到⼀个存储key的位置。线性探测的⽐较简单且容易实现,线性探测的问题假设,hash0位置连续冲突,hash0,hash1,hash2位置已经存储数据了,后续映射到hash0,hash1,hash2,hash3的值都会争夺hash3位置,这种现象叫做群集/堆积。下⾯的⼆次探测可以⼀定程度改善这个问题。
下⾯演⽰ {19,30,5,36,13,20,21,12} 等这⼀组值映射到M=11的哈希表中的过程:
-
二次探测(Quadratic Probing) :冲突时,按平方规律寻找下一个位置(
H_i(Key) = (H(Key) + i²) % m
),避免线性探测的 "连续聚集";从发⽣冲突的位置开始,依次左右按⼆次⽅跳跃式探测,直到寻找到下⼀个没有存储数据的位置为⽌,如果往右⾛到哈希表尾,则回绕到哈希表头的位置;如果往左⾛到哈希表头,则回绕到哈希表尾的位置;
h(key) = hash0 = key % M
, hash0位置冲突了,则⼆次探测公式为:hc(key, i) = hashi = (hash0 ± i2) % M, i = {1, 2, 3, ..., }
⼆次探测当
hashi = (hash0 - i2) % M
时,当hashi<0
时,需要hashi += M
。 -
双重哈希(Double Hashing) :冲突时,用第二个哈希函数计算步长(
H_i(Key) = (H1(Key) + i×H2(Key)) % m
),进一步降低聚集概率。第⼀个哈希函数计算出的值发⽣冲突,使⽤第⼆个哈希函数计算出⼀个跟key相关的偏移量值,不断往后探测,直到寻找到下⼀个没有存储数据的位置为⽌。
**优点:**无需额外空间存储链表;缓存友好(数据存储在连续数组中,减少 IO)。
**缺点:**易产生 "聚集效应"(线性探测时,连续桶被占用,后续冲突概率更高);删除数据需标记 "已删除",否则会断裂查找链。
**典型应用:**Redis 字典(部分场景)、Clang 编译器的哈希表、早期哈希表实现。
简单的代码实现:
开放寻址法解决冲突不管使⽤哪种⽅法,占⽤的都是哈希表中的空间,始终存在互相影响的问题。所以对于开放寻址法,我们选择线性探测实现即可:
哈希表的结构:
cpp
enum State
{
EXIST,
EMPTY,
DELETE
};
template<class K, class V>
struct HashData
{
pair<K, V> _kv;
State _state = EMPTY;
};
template<class K, class V>
class HashTable
{
private:
vector<HashData<K, V>> _tables;
size_t _n = 0; // 表中存储数据个数
}
要注意的是这⾥需要给每个存储值的位置加⼀个状态标识,否则删除⼀些值以后,会影响后⾯冲突的值的查找。
如下图,我们删除30,会导致查找20失败,当我们给每个位置加⼀个状态标识{EXIST,EMPTY,DELETE}
,删除30就可以不⽤删除值,⽽是把状态改为 DELETE
,那么查找20时是遇到 EMPTY
才能,就可以找到20。

扩容:
这⾥我们哈希表负载因⼦控制在0.7,当负载因⼦到0.7以后我们就需要扩容了,但是扩容的同时我们要保持哈希表⼤小是⼀个质数,第⼀个是质数,如果扩容2倍后就不是质数了,那么该如何解决呢?,⼀种⽅案就是上⾯除法散列中我们讲的Java HashMap的使⽤2的整数幂,但是计算时不能直接取模的改进⽅法。另外⼀种⽅案是sgi版本的哈希表使⽤的⽅法,给了⼀个近似2倍的质数表,每次去质数表获取扩容后的⼤⼩。
cpp
inline unsigned long __stl_next_prime(unsigned long n)
{
// Note: assumes long is at least 32 bits.
static const int __stl_num_primes = 28;
static const unsigned long __stl_prime_list[__stl_num_primes] =
{
53, 97, 193, 389, 769,
1543, 3079, 6151, 12289, 24593,
49157, 98317, 196613, 393241, 786433,
1572869, 3145739, 6291469, 12582917, 25165843,
50331653, 100663319, 201326611, 402653189, 805306457,
1610612741, 3221225473, 4294967291
};
const unsigned long* first = __stl_prime_list;
const unsigned long* last = __stl_prime_list + __stl_num_primes;
const unsigned long* pos = lower_bound(first, last, n);
return pos == last ? *(last - 1) : *pos;
}
key不能取模的问题
当key是string/Date
等类型时,key不能取模,那么我们需要给HashTable
增加⼀个仿函数,这个仿函数⽀持把key转换成⼀个可以取模的整形,如果key可以转换为整形并且不容易冲突,那么这个仿函数就⽤默认参数即可,如果这个Key不能转换为整形,我们就需要⾃⼰实现⼀个仿函数传给这个参数,实现这个仿函数的要求就是尽量key的每值都参与到计算中,让不同的key转换出的整形值不同。string做哈希表的key⾮常常⻅,所以我们可以考虑把string特化⼀下。
cpp
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
// 特化
template<>
struct HashFunc<string>
{
//这⾥我们使⽤BKDR哈希的思路,⽤上次的计算结果去乘以⼀个质数,这个质数⼀般取31, 131等效果会⽐较好
size_t operator()(const string& key)
{
size_t hash = 0;
for (auto e : key)
{
hash *= 131;
hash += e;
}
return hash;
}
};
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
public:
private:
vector<HashData<K, V>> _tables;
size_t _n = 0; // 表中存储数据个数
}
完整代码实现:
cpp
namespace open_address
{
enum State
{
EXIST,
EMPTY,
DELETE
};
template<class K, class V>
struct HashData
{
pair<K, V> _kv;
State _state = EMPTY;
};
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
public:
inline unsigned long __stl_next_prime(unsigned long n)
{
// Note: assumes long is at least 32 bits.
static const int __stl_num_primes = 28;
static const unsigned long __stl_prime_list[__stl_num_primes] =
{
53, 97, 193, 389, 769,
1543, 3079, 6151, 12289, 24593,
49157, 98317, 196613, 393241, 786433,
1572869, 3145739, 6291469, 12582917, 25165843,
50331653, 100663319, 201326611, 402653189, 805306457,
1610612741, 3221225473, 4294967291
};
const unsigned long* first = __stl_prime_list;
const unsigned long* last = __stl_prime_list +
__stl_num_primes;
const unsigned long* pos = lower_bound(first, last, n);
return pos == last ? *(last - 1) : *pos;
}
HashTable()
{
_tables.resize(__stl_next_prime(0));
}
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
return false;
// 负载因⼦⼤于0.7就扩容
if (_n * 10 / _tables.size() >= 7)
{
// 这⾥利⽤类似深拷⻉现代写法的思想插⼊后交换解决
HashTable<K, V, Hash> newHT;
newHT._tables.resize(__stl_next_prime(_tables.size()+1));
for (size_t i = 0; i < _tables.size(); i++)
{
if (_tables[i]._state == EXIST)
{
newHT.Insert(_tables[i]._kv);
}
}
_tables.swap(newHT._tables);
}
Hash hash;
size_t hash0 = hash(kv.first) % _tables.size();
size_t hashi = hash0;
size_t i = 1;
while (_tables[hashi]._state == EXIST)
{
// 线性探测
hashi = (hash0 + i) % _tables.size();
// ⼆次探测就变成 +- i^2
++i;
}
_tables[hashi]._kv = kv;
_tables[hashi]._state = EXIST;
++_n;
return true;
}
HashData<K, V>* Find(const K& key)
{
Hash hash;
size_t hash0 = hash(key) % _tables.size();
size_t hashi = hash0;
size_t i = 1;
while (_tables[hashi]._state != EMPTY)
{
if (_tables[hashi]._state == EXIST
&& _tables[hashi]._kv.first == key)
{
return &_tables[hashi];
}
// 线性探测
hashi = (hash0 + i) % _tables.size();
++i;
}
return nullptr;
}
bool Erase(const K& key)
{
HashData<K, V>* ret = Find(key);
if (ret == nullptr)
{
return false;
}
else
{
ret->_state = DELETE;
--_n;
return true;
}
}
private:
vector<HashData<K, V>> _tables;
size_t _n = 0; // 表中存储数据个数
};
}
3.2 链地址法(Chaining)
开放寻址法中所有的元素都放到哈希表⾥,链地址法中所有的数据不再直接存储在哈希表中,哈希表中存储⼀个指针,没有数据映射这个位置时,这个指针为空,有多个数据映射到这个位置时,我们把这些冲突的数据链接成⼀个链表,挂在哈希表这个位置下⾯,链地址法也叫做拉链法 或者哈希桶。
例如{19,30,5,36,13,20,21,12,24,96}
这⼀组值映射到M=11的哈希表中:

如果遇到极端情况,某个桶很长,查找效率很低怎么办?这⾥在Java8的HashMap
中当桶的⻓度超过⼀定阀值(8)时就把链表转换成红⿊树。这样查找的效率就上来了。
**优点:**冲突处理简单,不易产生 "聚集效应"(多个冲突仅影响单个桶)。
**缺点:**链表过长时查询效率下降(遍历需 O (k),k 为链表长度);需额外空间存储链表节点。
**典型应用:**Java HashMap(链表长度 > 8 时转为红黑树)、Python dict、Redis Hash。
简单的代码实现:
扩容:
开放定址法负载因⼦必须⼩于1,链地址法的负载因⼦就没有限制了,可以⼤于1。负载因⼦越⼤,哈希冲突的概率越⾼,空间利⽤率越⾼;负载因⼦越⼩,哈希冲突的概率越低,空间利⽤率越低;stl中unordered_xxx
的最⼤负载因⼦基本控制在1,⼤于1就扩容,我们下⾯实现也使⽤这个⽅式。
cpp
namespace hash_bucket
{
template<class K, class V>
struct HashNode
{
pair<K, V> _kv;
HashNode<K, V>* _next;
HashNode(const pair<K, V>& kv)
:_kv(kv)
,_next(nullptr)
{}
};
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
typedef HashNode<K, V> Node;
inline unsigned long __stl_next_prime(unsigned long n)
{
static const int __stl_num_primes = 28;
static const unsigned long __stl_prime_list[__stl_num_primes] =
{
53, 97, 193, 389, 769,
1543, 3079, 6151, 12289, 24593,
49157, 98317, 196613, 393241, 786433,
1572869, 3145739, 6291469, 12582917, 25165843,
50331653, 100663319, 201326611, 402653189, 805306457,
1610612741, 3221225473, 4294967291
}
const unsigned long* first = __stl_prime_list;
const unsigned long* last = __stl_prime_list +
__stl_num_primes;
const unsigned long* pos = lower_bound(first, last, n);
return pos == last ? *(last - 1) : *pos;
}
public:
HashTable()
{
_tables.resize(__stl_next_prime(0), nullptr);
}
// 拷⻉构造和赋值拷⻉需要实现深拷⻉,有兴趣的同学可以⾃⾏实现
~HashTable()
{
// 依次把每个桶释放
for (size_t i = 0; i < _tables.size(); i++)
{
Node* cur = _tables[i];
while (cur)
{
Node* next = cur->_next;
delete cur;
cur = next;
}
_tables[i] = nullptr;
}
}
bool Insert(const pair<K, V>& kv)
{
Hash hs;
size_t hashi = hs(kv.first) % _tables.size();
// 负载因⼦==1扩容
if (_n == _tables.size())
{
/*HashTable<K, V> newHT;
newHT._tables.resize(__stl_next_prime(_tables.size()+1);
for (size_t i = 0; i < _tables.size(); i++)
{
Node* cur = _tables[i];
while(cur)
{
newHT.Insert(cur->_kv);
cur = cur->_next;
}
}
_tables.swap(newHT._tables);*/
// 这⾥如果使⽤上⾯的⽅法,扩容时创建新的结点,后⾯还要使⽤旧结点,浪费了
// 下⾯的⽅法,直接移动旧表的结点到新表,效率更好
vector<Node*>
newtables(__stl_next_prime(_tables.size()+1), nullptr);
for (size_t i = 0; i < _tables.size(); i++)
{
Node* cur = _tables[i];
while (cur)
{
Node* next = cur->_next;
// 旧表中节点,挪动新表重新映射的位置
size_t hashi = hs(cur->_kv.first) %
newtables.size();
// 头插到新表
cur->_next = newtables[hashi];
newtables[hashi] = cur;
cur = next;
}
_tables[i] = nullptr;
}
_tables.swap(newtables);
}
// 头插
Node* newnode = new Node(kv);
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
++_n;
return true;
}
Node* Find(const K& key)
{
Hash hs;
size_t hashi = hs(key) % _tables.size();
Node* cur = _tables[hashi];
while (cur)
{
if (cur->_kv.first == key)
{
return cur;
}
cur = cur->_next;
}
return nullptr;
}
bool Erase(const K& key)
{
Hash hs;
size_t hashi = hs(key) % _tables .size();
Node* prev = nullptr;
Node* cur = _tables[hashi];
while (cur)
{
if (cur->_kv.first == key)
{
if (prev == nullptr)
{
_tables[hashi] = cur->_next;
}
else
{
prev->_next = cur->_next;
}
delete cur;
--_n;
return true;
}
prev = cur;
cur = cur->_next;
}
return false;
}
private:
vector<Node*> _tables; // 指针数组
size_t _n = 0; // 表中存储数据个数
};
}
4. 小结
- 哈希函数是 "Key→地址" 的映射工具,其核心是 "确定性、均匀性、高效性",决定了哈希表的基础效率;
- 哈希冲突是 Key 范围远大于桶数组大小导致的必然结果,其解决方法(链地址法 / 开放寻址法)决定了哈希表在冲突后的性能上限;
两者共同构成了哈希表的核心逻辑 ------ 优秀的哈希函数 + 高效的冲突解决,才能实现哈希表 "平均 O (1) 效率" 的核心优势。
尾声
若有纰漏或不足之处欢迎大家在评论区留言或者私信,同时也欢迎各位一起探讨学习。感谢您的观看!