跳到主要内容
哈希表实现原理与代码详解 | 极客日志
C++ 算法
哈希表实现原理与代码详解 综述由AI生成 哈希表的概念、哈希函数设计方法(直接定址、除法散列、乘法散列等)、负载因子对性能的影响以及哈希冲突的两种主要处理方式(开放定址法和链地址法)。文中详细分析了线性探测、二次探测等冲突解决策略,并通过 C++ 模板代码实现了哈希表的插入、查找、删除及扩容功能,提供了具体的测试用例和极端场景分析。
灰度发布 发布于 2026/3/30 更新于 2026/5/24 27 浏览1. 哈希概念
哈希 (hash)又称散列 ,是一种组织数据的方式。从译名来看,有散乱排列的意思 。本质就是通过哈希函数把关键字 Key 跟存储位置建立一个映射关系,查找时通过这个哈希函数计算出 Key 存储的位置,进行快速查找 。
基于建立映射过程的思考,衍生出来许多方法·,这是下文着重讨论的部分,然后数据结构中基于哈希的思想,实现了叫做哈希表(散列表)的数据结构 。这里数据结构是数据结构,思想是思想,我们不能直接画等同
2 哈希冲突和哈希函数
假设我们只有数据范围是[0, 9999]的 N 个值,我们要映射到一个 M 个空间的数组中 (一般情况下 M >= N),那么就要借助好的映射方法,使关键字 key 被放到数组的 h(key) 位置 ,这里要注意的是h(key) 计算出的值必须在[0, M) 之间,即数据映射到数组上的位置必须在数组范围内 。这里存在的一个问题就是,两个不同的 key 可能会映射到同一个位置去,这种问题我们叫做哈希冲突,或者哈希碰撞 。
这里我们把数据与存储位置之间映射关系的表达式叫做哈希函数 (hash function)hf,一个好的哈希函数应该让 N 个关键字被等概率的均匀的散列分布到哈希表的 M 个空间中 ,但是实际中由于数据间冲突无法避免,很难做到等概率设计,但是我们要尽量往这个方向去考量设计。
3. 负载因子
假设哈希表中已经映射存储了 N 个值,哈希表的大小为 M,那么负载因子 ,负载因子有些地方也翻译为载荷因子/装载因子 等,他的英文为 load factor。负载因子越大,哈希冲突的概率越高,空间利用率越高;负载因子越小,哈希冲突的概率越低,空间利用率越低 ;
4. 将关键字转为整数
哈希函数中,我们将关键字映射到数组中位置,一般是使用整型 做映射计算,如果关键字不是整型,我们要想办法转换成整型 ,这个细节我们后面代码实现中再进行细节展示。下面哈希函数部分我们讨论时,如果关键字不是整数,那么我们讨论的 Key 是关键字转换成的整数。
5. 哈希函数
在实际的应用中,我们根据具体的哈希函数确定如何映射,求出**关键字 key 存放在数组的 h(key) 位置。**反过来说 h(key) 位置存放的就是 key 关键字,之后查找对应的 key 数据,我们直接数组下标 h(key) 就可以找到数据了,因此为了后续根据 h(key) 再找到数据,插入、查找一系列过程应该使用同一个哈希函数,不能更改 ,否则算出 h(key) 前后不一致。
5.1 直接定址法
当关键字的范围比较集中时 ,直接定址法就是非常简单高效的方法,比如一组关键字都在[0,99]之间,那么我们开一个 100 个数的数组,每个关键字的值直接就是存储位置的下标 。
比如一组关键字值都在[a,z]的小写字母,那么我们开一个 26 个数的数组,每个关键字 acsii 码-a ascii 码就是存储位置的下标(这里是通过以 a 为基准,每个字母映射到数组下标的位置,就是相对 a 的位置)。
也就是说直接定址法本质就是用关键字计算出一个绝对位置或者相对位置 。这个方法我们在计数排序部分已经用过了,其次在 string 章节的下面 OJ 也用过了。
387. 字符串中的第一个唯一字符 - 力扣(LeetCode)
这题最直接的思路是遍历每个数来统计出现的次数,如果使用前文的 map 和 set 来统计,那么就题目本身来说,使用容器本身的消耗相对较大,没必要用。
因为只会出现小写字母,所以我们创建大小为 26 的数组,根据距离'a'的距离确定相对位置,26 个小写字母每个都有对应的位置,遍历字符串,根据对应位置数据确定字母出现次数。
class Solution { public : int firstUniqChar (string s) {
直接定址法的缺点 也非常明显,直接定址法是根据数据本身的大小或者到最小值的距离来确定绝对/相对位置,当关键字的范围比较分散时,最大值与最小值间的距离就很大,就很浪费内存甚至内存不够用 。
比如最大值与最小值差值达到 10 亿,但是数据本身只有 20 个数,那么根据最大最小值的距离开辟 10 大小的数组,但是浪费也基本达到 10 亿。所以对于较分散的数据,我们需要再采取其他数据映射的方式
5.2 除法散列法/除留余数法
• 除法散列法 也叫做除留余数法 ,顾名思义,假设哈希表的大小为 M,那么通过 key 除以 M 的余数作为映射位置的下标 ,也就是哈希函数为:h(key) = key % M 。
• 当使用除法散列法时,要尽量避免 M 为某些值,如 2 的幂,10 的幂等 。如果是,**那么 key %**本质相当于保留 key 二进制形式的后 X 位 ,那么后 x 位相同的值,计算出的哈希值都是一样的,就冲突了。
如:{63 , 31}看起来没有关联的值,如果 M 是 16,也就是
,那么计算出的哈希值都是 15,因为 63 的二进制后 8 位是 00111111,31 的二进制后 8 位是 00011111。如果是
,就更明显了,保留的都是 10 进值的后 x 位,如:{112, 12312},如果 M 是 100,也就是
• 当使用除法散列法时,建议 M 取不太接近 2 的整数次幂的一个质数 (素数) 。
• 需要说明的是,实践中也是八仙过海,各显神通,Java 的 HashMap 采用除法散列法时就是 2 的整数次幂做哈希表的大小 M ,这样玩的话,就不用取模,而可以直接位运算,相对而言位运算比模更高效一些。
但是他不是单纯的去取模,比如 M 是 2^16 次方,本质是取后 16 位,那么用 key' =key>>16,然后再把 key 和 key' 异或的结果作为哈希值 。因为只取后 16 位,那么后 16 位相同,数据就会冲突,哈希冲突概念很大,为了避免冲突,我们要采取方式使得每一个数据尽可能具有唯一性,取后 16,再取前 16 位异或,这样相当于让原数据的每一数位上的数都参与计算,计算出 h(key)。
如果我们 M 是 2^4 次方,本质取后 4 位,这时候前 28 位是没利用上的,这时候异或,我们发现如下图蓝色区间的部分是多出来的,如果前 28 位对应蓝色部分二进制形式数位上有不为一处,那么异或之后的 h(key) 就会超过 M,溢出了。
因此对于如下图这种不对称的形式,我们要循环取位,每次从 28 位中取 4 位出来,与原四位异或,注意的是,我们这里循环按固定数位取数,只是希望让更多数位加入运算,尽可能使结果独一无二。
总的来说我们映射出的值还是在[0,M) 范围内,但是通过移位尽量让 key 所有的位都参与计算,这样映射出的哈希值更均匀一些即可 。
所以我们上面建议 M 取不太接近 2 的整数次幂的一个质数的理论是大多数数据结构书籍中写的理论,但是实践中,灵活运用,抓住本质,而不能死读书。
5.3 乘法散列法(了解)
• 乘法散列法对哈希表大小 M 没有要求 ,他的大思路第一步:用关键字 K 乘上常数 A (0<A<1),并抽取出 kA 的小数部分。第二步:后再用 M 乘以 k A 的小数部分,再向下取整 。
•h(key) = floor(M × ((A × key)%1.0)) ,其中floor 表示对表达式进行下取整 ,A∈(0,1) ,这里最重要的是 A 的值应该如何设定,Knuth 认为A = ( **- 1)/2 = 0.6180339887.... (黄金分割点])比较好,**这个数据是经过大量实验验证之后的可以使数据分布较为均匀的一种设计。
乘法散列法对哈希表大小 M 是没有要求的,假设 M 为 1024,key 为 1234,A = 0.6180339887, Akey= 762.6539420558,取小数部分为 0.6539420558, M×((A×key)%1.0) = 0.6539420558 1024 =669.6366651392,那么 h(1234) = 669。
5.4 全域散列法(了解) 基于哈希的各种数据结构被应用到各个领域,比如下图的分布式集群,我们根据哈希函数来确定数据存储到哪一台主机上,使得数据分布均匀存储,避免大量数据超出某一台主机存储上限,以及影响查找数据效率。
但是如果存在一个恶意的对手,他针对我们提供的散列函数,特意构造出一个发生会严重冲突的数据集,比如,让所有关键字经过散列函数运算后全部落入同一台主机上,进而导致严重后果。这种情况是可以存在的,只要散列函数是公开且确定的,就可以实现此攻击。
解决方法自然是见招拆招,给散列函数增加随机性 ,攻击者就无法找出确定可以导致最坏情况的数据 。这种方法叫做全域散列 。
•(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, 则
(8) = ((3 × 8 + 4)%17)%6 = 5。那么就从函数组中选取对应的哈希函数,然后使用哈希函数计算 h(key),因为这个哈希函数也是我们通过计算随机选取的,
• 需要注意的是每次初始化哈希表时,随机选取全域散列函数组中的一个散列函数使用,后续增删查改都固定使用这个散列函数 ,否则每次哈希都是随机选一个散列函数,那么插入是一个散列函数,查找又是另一个散列函数,就会导致找不到插入的 key 了 。
5.5 其他方法(了解) 《殷人昆 数据结构:用面向对象方法与 C++ 语言描述(第二版)》和 《[数据结构 (C 语言版)].严蔚敏_吴伟民》等教材型书籍上面还给出了平 方取中法、折叠法、随机数法、数学分析法 等,这些方法相对更适用于一些局限的特定场景,有兴趣可以去看看这些书籍。
6. 处理哈希冲突 实践中哈希表一般还是选择除法散列法作为哈希函数,当然相对于数据来说,哈希表的 M 永远小,永远都存在两个不同的数经过计算到了同一位置上,无论选择什么哈希函数也避免不了冲突 ,那么插入数据时,如何解决冲突呢?主要有两种两种方法,开放定址法和链地址法 。
6.1 开放定址法
在开放定址法中所有的元素都放到哈希表里,当一个关键字 key 用哈希函数计算出的位置冲突了,则按照某种规则找到一个没有存储数据的位置进行存储 ,开放定址法中负载因子一定是小于 1 的 。这里的规则有三种:线性探测、二次探测、双重探测 。
线性探测
• 从发生冲突的位置开始,依次线性向后探测,直到寻找到下一个没有存储数据的位置为止,如果走到哈希表尾,则回绕到哈希表头的位置,继续寻找 。
• h(key) = hash0 = key % M, hash0 位置冲突了,则线性探测公式为:****(key, i) = hashi = (hash0 + i) % M,i = {1, 2, 3, ..., M − 1} ,因为负载因子小于 1(存在空位),则最多探测 M-1 次,一定能找到一个存储 key 的位置。
a.线性探测的问题 线性探测的比较简单且容易实现,线性探测的问题是目前有多个值映射到哈希表的同一位置,假设 hash0 位置连续冲突,第一个冲突值向后找到空的 hash1 位置存放,第二个冲突值向后找到空的 hash2 位置存储数据,我们发现之前冲突的多个数据会抢占之后数据的存储位置,那么之后的数据就要从被抢占的位置向后不断遍历找到空位置,也就是说空位置越少,找到空位置需要遍历的次数就越多,效率越低,所以实际中一般我们会保持负载因子小于 0.7,负载因子大于 0.7 则进行扩容 。
hash0,hash1,hash2 位置已经存储数据了,后续映射到 hash0,hash1,hash2,hash3 的值都会争夺 hash3 位置,这种现象叫做群集/堆积 。下面的二次探测可以一定程度改善这个问题。
下面演示 {19,30,5,36,13,20,21,12} 等这一组值映射到 M=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
开放定址法代码实现 开放定址法在实践中,不如下面讲的链地址法,因为开放定址法解决冲突不管使用哪种方法,占用的都是哈希表中的空间,数据之间相互竞争空间,始终存在互相影响的问题 。再考虑到线性探测较为简单,所以开放定址法,我们选择线性探测的实现来详细说明开放定制法的各项细节。
开放定址法的哈希表结构和查找设计 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 ;
我们这里底层封装 vector 来存储数据,要注意的是,因为查找需要找到空位置,所以这里需要给每个存储值的位置加一个状态标识 ,状态表示需要有存在、空、删除三种状态 ,否则删除一些值以后,会影响后面冲突的值的查找。
如下图,我们删除 30,30 映射的位置就变空了,那么我们查找 20 时,因为线性探测的逻辑是冲突了,就往后找空位存放,反过来说如果找到空位置了,那空位置之后一定也是空的,没有数据,所以如果我们查找 20,那么查找到第 9 位为空,就不会继续往后查了,导致查找 20 失败。
当我们给每个位置加一个状态标识{EXIST,EMPTY,DELETE},删除 30 就可以不用删除值,而是把状态改为 DELETE,那么查找 20 时是遇到 EMPTY 才能停,我们就可以找到 20 了。
h(19) = 8,h(30) = 8,h(5) = 5,h(36) = 3,h(13) = 2,h(20) = 9,h(21) =10,h(12) = 1
HashData<K, V>* Find (const K& key) { Hash hs; size_t hashi = hs (key) % _tables.size ();
key 不能取模的问题
当 key 是 string/Date 等类型时,key 不能取模,那么我们需要给 HashTable 增加一个仿函数 ,
**这个仿函数支持把 key 转换成一个可以取模的整形。**这样这个 key 就与转换后的整形存在一层映射关系,然后这个转换后的整形由于哈希表存在映射关系,间接的,key 就与哈希表存在映射关系了 。
如果 key 可以转换为整形并且不容易冲突,那么这个仿函数就用默认参数即可(默认的仿函数,我们使用强转即可) ,如果这个 Key 不能转换为整形,我们就需要自己实现一个仿函数传给这个参数 ,实现这个仿函数的要求就是尽量 key 的每值都参与到计算中,让不同的 key 转换出的整形值不同 。
由于 string 做哈希表的 key 非常常见,所以 STL 库中 unordered 系列使用不需要我们传递仿函数,就是因为针对 string 类型特化了。
针对 string 类型的转换,方式有很多种,比如讲 string 中的每个字符加起来得到一个整形值,但这种方式容易出现字符一致、但字符顺序不一致字符串冲突。
经过科学家的大量实验研究,发现可以如下图,将字符串挨个取出,乘上某个固定数,再与下个字符相加,再乘上固定数…………直到结束,由于不同字母的 ASCII 不同,那乘固定数的结果就不同,这样字符串映射冲突的可能性就大大降低了。
template <class K > struct HashFunc { size_t operator () (const K& key) { return (size_t )key; } };
开放定址法的插入和删除 template <class K > struct HashFunc
扩容 这里我们哈希表负载因子控制在 0.7,当负载因子到 0.7 以后我们就需要扩容了,我们还是按照 2 倍扩容(简单点)。
之前文章中我们讲过扩容一般来说扩容两倍左右为宜,但是同时我们要保持哈希表大小是一个质数,第一个是质数,2 倍后就不是质数了。那么如何解决了,一种方案就是上面 1.4.1 除法散列中我们讲的 Java HashMap 的使用 2 的整数幂,但是计算时不能直接取模的改进方法 。
另外一种方案是 sgi 版本的哈希表使用的方法,给了一个近似 2 倍的质数表,每次去质数表获取扩容后的大小 。
inline unsigned long __stl_next_prime(unsigned long n) {
二次探测
• 从发生冲突的位置开始,依次左右按二次方跳跃式探测,直到寻找到下一个没有存储数据的位置为止,如果往右走到哈希表尾,则回绕到哈希表头的位置;如果往左走到哈希表头,则回绕到哈希表尾的位置 ;
• h(key) = hash0 = key % M, hash0 位置冲突了,则二次探测公式为:
(key, i) = hashi = (hash0 ±****) % M ,i = {1, 2, 3, ...,}
• 二次探测当 hashi = (hash0 −****)%M 时,当 hashi<0 时,需要 hashi += M,是映射位置绕回到哈希表内 。
• 下面 {19,30,52,63,11,22} 等这一组值映射到 M=11 的表中。
h(19) = 8, h(30) = 8, h(52) = 8, h(63) = 8, h(11) = 0, h(22) = 0
双重散列(了解)
• 第一个哈希函数计算出的值发生冲突,使用第二个哈希函数计算出一个跟 key 相关的偏移量值,不断往后探测,直到寻找到下一个没有存储数据的位置为止 。本质来说双重散列是通过跳跃映射来减少冲突堆积。
• , hash0 位置冲突了,则双重探测公式为:(key) = hash0 = key % M,(key, i) = hashi = (hash0 + i ∗ (key)) % M ,i = {1, 2, 3, ...,M}
• 要求**(key) < M )且****(key)和 M 互为质数**,有两种简单的取值方法:1、当 M 为 2 整数幂时,(key)从[0,M-1]任选一个奇数;2、当 M 为质数时, (key) = key % (M − 1) + 1
• 保证 (key)与 M 互质是因为根据固定的偏移量所寻址的所有位置将形成一个群,映射范围固定在这个群众,若最大公约数 p = gcd(M,****(key)) > 1,那么所能寻址的位置的个数为 M/P < M,使得对于一个关键字来说无法充分利用整个散列表 。举例来说,若初始探查位置为 1,偏移量为 3,整个散列表大小为 12,那么所能寻址的位置固定在为{1, 4, 7, 10},寻址个数为 12/gcd(12, 3) = 4
• 下面 {19,30,52,74} 等这一组值映射到 M=11 的表中,设 (key) = key%10 + 1
完整代码实现 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) {
6.2 链地址法
6.2.1 链地址法的探讨
解决冲突的思路
开放定址法中所有的元素都放到哈希表里,不管怎么处理数据都存在互相竞争同一块空间的可能性。
而链地址法中所有的数据不再直接存储在哈希表中,哈希表中存储一个指针 ,没有数据映射这个位置时,这个指针为空 ,有多个数据映射到这个位置时,我们把这些冲突的数据链接成一个链表,挂在哈希表这个位置下面 ,而哈希表(数组)中一个一个位置,我们也叫作哈希桶 ,链地址法 也叫做拉链法或者哈希桶 。
• 下面 {19,30,5,36,13,20,21,12,24,96} 等这一组值映射到 M=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,h(24) = 2,h(96) = 88
扩容
开放定址法负载因子必须小于 1,链地址法的负载因子就没有限制了,可以大于 1,但是过多因子过大也会导致单个数据链过长,效率也会损失。
所以总的来说负载因子越大,哈希冲突的概率越高,空间利用率越高;负载因子越小,哈希冲突的概率越低,空间利用率越低;stl 中 unordered_xxx 的最大负载因子基本控制在 1,大于 1 就扩容,我们下面实现也使用这个方式。
6.2.2 链地址法代码实现 链地址法与开放定址法不同是会开辟出节点,因此实现过程中涉及到拷贝(深拷贝)、析构等情况,我们需要自己实现相关函数,处理好资源
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 ) { } };
极端场景 那如果极端场景下,某个桶特别长怎么办?其实我们可以考虑使用全域散列法,这样就不容易被针对了。但是假设不是被针对了,偶然情况下,某个桶很长,查找效率很低怎么办?这里介绍在 Java8 的 HashMap 中,当桶的长度超过一定阀值 (8) 时就把链表转换成红黑树 。一般情况下,不断扩容,单个桶很长的场景还是比较少的,下面我们实现就不搞这么复杂了,这个解决极端场景的思路,大家了解一下。
相关免费在线工具 加密/解密文本 使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
Gemini 图片去水印 基于开源反向 Alpha 混合算法去除 Gemini/Nano Banana 图片水印,支持批量处理与下载。 在线工具,Gemini 图片去水印在线工具,online
Base64 字符串编码/解码 将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
Base64 文件转换器 将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
Markdown转HTML 将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
HTML转Markdown 将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online