哈希介绍、哈希表模拟实现
目录
哈希概念
哈希函数
哈希函数设计原则
常见哈希函数
1.直接定址法 (常用):适用于数据(key)范围集中的情况。
2.除留余数法 (常用):适用于数据(key)范围不集中、分布分散的情况。
3.平方取中法 (了解)
4.折叠法 (了解)
5.随机数法 (了解)
6.数学分析法 (了解)
哈希冲突
解决哈希冲突(碰撞)的方法
1.闭散列 (开放定址法,不建议)
1.1 线性探测(暴力查找)
1.1.1.线性探测介绍
1.1.2.线性探测代码实现
1.2.二次探测
1.2.1.二次探测介绍
1.2.2.二次探测代码实现
1.3.闭散列的缺陷
2.开散列(哈希桶法 / 拉链法 / 链地址法,建议)
2.1. 开散列介绍
2.2.开散列代码实现
闭散列(哈希表)实现
1.哈希表设计分析
2.哈希表存储位置标记方案对查找操作的影响分析
2.1.简单二元标记方式在哈希表查找操作中的缺陷 —— 以线性探测为例
2.2.解决方式
3.插入操作 Insert
3.1.实现过程中遇到的问题及对应解决方式
4.2.闭散列 Insert代码实现
4.删除操作 Erase
5.查找操作 Find
5.1.实现过程中遇到的问题及对应解决方式
5.2.正确代码
6.闭散列(哈希表)代码实现
开散列(哈希表)实现
1.哈希表设计分析
(1)开散列介绍
(2)注意事项
(3)开散列哈希表结构设计
2.插入操作 Insert
2.1.遇到问题及对应解决方式
2.2.Insert代码实现
3.析构函数 ~HashTable()
4.删除操作 Erase
5.查找操作 Find
6. 闭散列、开散列哈希表中处理非整型 key 的问题
6.1.情况 1:key 本身为整型或可隐式转换为整型
6.2 情况 2:key 为 string 类型
6.3. 情况 3:key 为结构体(自定义类型)
7.最大桶(链表)的长度 MaxBucketSize
8.开散列哈希表效率
9.对某个哈希桶(单链表)长度很长这种极端场景下的解决方式
(1) 解决方案 1:使用负载因子控制
(2) 解决方案 2:链表转红黑树
(3) 总结
10.保持哈希表大小为素数
11.开散列(哈希表)代码实现
注意事项:
- 哈希又称散列,本质是建立数据与哈希值的对应关系。哈希 / 散列是一种映射算法思想,通过哈希函数将输入数据(关键字
key
)转换为哈希值。但需注意,这种映射并非严格一一对应,可能存在不同输入数据产生相同哈希值的情况,即哈希冲突。 - 哈希表 / 散列表:通过哈希函数将存储的关键字
key
计算为哈希值,并依据哈希值将对应的数据映射到表中的特定存储位置,从而建立关键字key
与存储位置的关联关系。 - C++ 中
unordered
系列的关联式容器(如unordered_map
、unordered_set
)效率较高,原因在于其底层采用哈希结构。通过哈希函数快速定位数据存储位置,在理想情况下,插入、查找和删除操作的时间复杂度接近 O(1) 。 -
搜索树利用二叉树 “左子树结点位值小于根结点位值,右子树结点位值大于根结点位值” 的特性进行查找。然而,若树结构失衡,会导致查找效率严重退化,因此需要维护左右子树的平衡性以保证性能。
哈希表(散列表)通过哈希函数建立关键字
key
与存储位置的映射关系。但不同key
经同一哈希函数计算出的哈希值(存储地址)可能相等,从而引发哈希冲突,此时需要采用闭散列(如线性探测、二次探测)或开散列(拉链法)解决冲突。哈希本质是建立
key
与存储位置的映射,实现这种映射的哈希函数有多种计算方式,其中最常用的是直接定址法和除留余数法。直接定址法适用于数据key
范围集中的场景,可直接为每个key
分配固定存储位置;除留余数法则适用于数据key
范围分散或未知的情况,不依赖key
的具体取值范围 。
哈希概念
当存储元素为键值对pair<key, value>时:
- 在顺序结构(如数组)中,关键字key与存储位置不存在直接对应关系。查找元素时,需要对数组进行逐一遍历,将每个元素的关键字key与目标关键字进行比较,因此时间复杂度为 O (N)。
- 在平衡树(如红黑树、AVL 树)中,同样不存在关键字key与存储位置的直接映射关系。查找元素时,需依据树的层级结构,从根节点开始逐步比较关键字key,并决定搜索路径,其时间复杂度为树的高度,即 O (log N)。
- 由此可见,无论是顺序结构还是平衡树,搜索效率都直接取决于查找过程中元素关键字的比较次数。
理想的搜索方法是:不经过任何比较,一次直接从表中得到要搜索的元素。哈希结构通过哈希函数(hashFunc),将元素的关键字 key 进行计算,生成对应的哈希值,并以此为依据建立关键字 key 与存储位置之间的映射关系。理论上,只要哈希函数设计合理,不同的关键字 key 能够被映射到不同的存储位置,这样在查找元素时,只需对关键字 key 进行一次哈希计算,就能直接定位到元素的存储位置,从而实现高效查找。
- 注意事项:然而在实际应用中,由于关键字数量可能远超哈希表的存储单元数量,不同关键字经过哈希函数计算后难免会产生相同的哈希值,即出现哈希冲突。因此,哈希结构还需要结合相应的冲突解决策略(如开散列、闭散列等),以确保在冲突发生时仍能快速准确地找到目标元素 。
在哈希结构中:
- 插入元素:根据待插入键值对的关键字
key
,通过哈希函数计算出存储位置,并按此位置存放。 - 搜索元素:对目标元素的关键字
key
进行相同计算,将所得函数值作为存储位置,取出该位置元素进行关键字比较,若相等则搜索成功。
这种通过哈希函数构建映射关系的存储与查找方式,称为哈希(散列)方法。其中的转换函数称为哈希(散列)函数,构建出的数据结构称为哈希表 (Hash Table)(或散列表)。
例如:对于数据key集合 {1,7,6,4,5,9},若哈希函数为hash(key) = key % len
(len为哈希表大小/长度)
hash(1) = 1 % 10 = 1
,将键值对{1, value1}
存入哈希表索引为 1 的位置;hash(7) = 7 % 10 = 7
,将键值对{7, value7}
存入索引为 7 的位置;hash(6) = 6 % 10 = 6
,将键值对{6, value6}
存入索引为 6 的位置;hash(4) = 4 % 10 = 4
,将键值对{4, value4}
存入索引为 4 的位置;hash(5) = 5 % 10 = 5
,将键值对{5, value5}
存入索引为 5 的位置;hash(9) = 9 % 10 = 9
,将键值对{9, value9}
存入索引为 9 的位置。
若新增关键字key = 44
,计算hash(44) = 44 % 10 = 4
,此时与hash(4)
产生哈希冲突。这种情况下,可通过 开散列(链地址法)或 闭散列(开放定址法)策略处理:
- 开散列:在索引 4 位置维护一个单链表,将
{44, value44}
添加到该链表中,此时索引 4 对应的链表结构为{44, value44} -> {4, value4} -> NULL
; - 闭散列:按照既定规则(如线性探测、二次探测)重新计算存储位置,例如线性探测可能将
{44, value44}
存放到索引 5(若该位置空闲)。
通过哈希方法,在无冲突或冲突较少的情况下,只需一次哈希计算即可定位目标元素,无需像顺序查找或平衡树查找那样进行多次关键字比较,显著提升了搜索效率。
哈希函数
在哈希表这种数据结构中,哈希函数起着至关重要的作用,它是将数据元素的键值对 pair<key, value>
中的关键字 key
映射为哈希表中具体存储位置的核心工具。然而,若哈希函数设计不合理,就极易引发哈希冲突。
哈希函数设计原则
- 定义域完整性:哈希函数的定义域必须涵盖需要存储元素键值对
pair<key, value>
的全部关键字key
。这意味着对于任何可能要存入哈希表的关键字key
,哈希函数都能对其进行处理。若哈希表允许有 m 个地址,其值域必须在 0 到 m−1 之间,这样才能确保计算出的哈希地址能够正确对应到哈希表的有效存储位置上。 - 地址均匀分布:哈希函数计算出的地址应能均匀分布在整个空间中。均匀分布可以使哈希表中的各个存储位置被使用的概率大致相同,从而减少哈希冲突的发生概率。如果地址分布不均匀,就会导致某些位置被频繁使用,而其他位置很少被使用,进而增加冲突的可能性,降低哈希表的性能。
- 简单性:哈希函数应该尽量简单。简单的哈希函数计算速度快,能够减少哈希表操作的时间开销。复杂的哈希函数可能会在计算哈希地址时消耗过多的时间,影响哈希表的插入、查找和删除等操作的效率。
常见哈希函数
注意事项:
- 关键字 key 通过哈希函数计算出的哈希值即为哈希地址,该地址对应其在哈希表中的存储位置。
- 哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突。
1.直接定址法 (常用):适用于数据(key
)范围集中的情况。
(1) 介绍
- 哈希地址计算原理:通过建立键值对
pair<key, value>
中关键字key
与存储位置一一对应的直接或间接关系来确定哈希地址,通常采用线性函数的形式,即Hash(key) = A * key + B
(A、B 为常数)。这种方法保证了每个key
对应唯一的存储位置。 - 优点:简单直观,计算速度快,并且计算出的地址分布均匀,不会产生哈希冲突(前提是关键字范围符合要求)。
- 缺点:需要事先了解关键字
key
的分布情况。如果关键字的范围较大且不连续,会造成大量的存储空间浪费。 - 使用场景:适合查找范围较小且连续的情况,例如在一些小型、数据范围固定的索引场景中,如学生成绩表(学号连续且范围小)的存储和查询。
(2)案例:字符串中的第一个唯一字符
387. 字符串中的第一个唯一字符 - 力扣(LeetCode)
在 “字符串中的第一个唯一字符” 这道题中,若字符串中的字符范围仅为小写字母(题目限定),可将小写字母视为关键字 key
,此时直接定址法的使用场景如下:
- 字符频次统计:建立一个长度为 26 的数组(类似简单哈希表),用直接定址法将每个小写字母映射到数组的特定位置。因为小写字母共有 26 个,范围集中且连续(从
a
到z
),符合直接定址法要求。例如,设Hash(key) = key - 'a'
(这里A = 1
,B = -'a'
),这样字符'a'
会映射到数组索引 0,'b'
映射到索引 1,以此类推。通过遍历字符串,将每个字符对应位置的数组元素值加 1,就能统计出每个字符出现的频次。 - 查找第一个唯一字符:再次遍历字符串,对每个字符通过直接定址法找到其在数组中的位置,若该位置元素值为 1,说明此字符是唯一的。由于数组索引和字符有直接对应关系,可快速定位第一个唯一字符的索引。比如在字符串
"leetcode"
中,遍历到'l'
时,通过Hash('l') = 'l' - 'a' = 11
找到数组对应位置,若该位置值为 1,'l'
就是潜在的第一个唯一字符,继续遍历确认是否真的是第一个。
不过在实际应用中,虽然小写字母范围符合直接定址法要求,但如果字符串中字符种类变化大(比如增加大写字母、数字等),直接定址法就可能不再适用,因为需要大幅扩充数组来容纳新的关键字,会造成空间浪费 。
class Solution
{
public:int firstUniqChar(string s) {//哈希映射int countA[26] = { 0 };//出现次数就统计出来了for (auto ch : s){countA[ch - 'a']++;}for (int i = 0; i < s.size(); ++i){if (countA[s[i] - 'a'] == 1)return i;}return -1;}
};
2.除留余数法 (常用):适用于数据(key)范围不集中、分布分散的情况。
- 哈希地址计算原理:将分散的键值对 pair<key, value> 中的关键字 key 映射到一段固定空间。设哈希表大小(长度)为 len,取一个不大于 len 且最接近或者等于 len 的质数 p 作为除数,哈希函数为 Hash (key) = key % p(p <= len),从而将关键字 key 转换为哈希地址。选择质数作为除数可以使哈希地址更加均匀地分布。
- 示例:如对于键值对集合 {pair<3, v1>, pair<13, v2>, pair<2, v3>, pair<99, v4>, pair<1000, v5>, pair<102, v6>, pair<6, v7>},若 len = 10,取 p = 7,则通过 hashi = key % 7 计算哈希地址,像 3 % 7 = 3,13 % 7 = 6 等。不同 key 可能映射到同一位置,从而产生哈希冲突。
- 优点:实现简单,对关键字的范围和分布没有严格要求,适用于各种情况。
- 缺点:可能会产生较多的哈希冲突,尤其是当关键字的分布具有某种规律性时。
3.平方取中法 (了解)
- 原理:对键值对
pair<key, value>
中的关键字key
进行平方运算,然后取结果中间若干位作为哈希地址。通过平方运算,扩大关键字key
之间的差异,使中间位能更好地反映关键字的特征,从而实现哈希地址较为均匀的分布。因为关键字的每一位都会对平方结果的中间位产生影响,所以在一定程度上能减少哈希冲突。 - 哈希函数公式:
Hash(key) = Mid(Square(key), n)
(其中Square(key)
表示对key
进行平方运算,Mid
表示取中间位操作,n
为要取的中间位数 ) - 示例:假设关键字
key = 1234
,对其平方得到1234² = 1522756
,若根据哈希表情况决定取中间 3 位,则Hash(1234) = 227
。适用于不知道关键字分布,且位数不是很大的情况 。 - 优点:不依赖关键字的特定分布,能在一定程度上使哈希地址均匀分布。
- 缺点:需进行平方运算和中间位提取,计算相对复杂。
4.折叠法 (了解)
- 原理:将关键字
key
从左到右分割成位数相等的几部分(最后一部分位数可以不同),然后将这几部分叠加求和,并按哈希表容量(长度)capacity
,取后几位作为哈希地址。根据叠加方式不同,分为移位叠加(各部分最后一位对齐相加 )和间界叠加(从一端向另一端沿各部分分界来回折叠后相加 ) 。 - 哈希函数公式:设将关键字
key
分割为k1, k2, ..., kn
,则Hash(key) = Sum(k1, k2, ..., kn) % capacity
(其中Sum
表示求和操作) - 示例:若关键字
key = 23456789
,哈希表容量capacity = 1000
,将其分成23
、45
、67
、89
四部分,采用移位叠加方式,23 + 45 + 67 + 89 = 224
,则Hash(23456789) = 224
。适合事先不需要知道关键字分布,关键字位数较多的情况 。 - 优点:适用于关键字位数多且分布较均匀的情况,可将长关键字转换为合适哈希地址。
- 缺点:若关键字各部分间存在规律,可能导致哈希地址分布不均匀。
5.随机数法 (了解)
- 原理:选择一个随机函数,取关键字的随机函数值为它的哈希地址。
- 哈希函数公式:
Hash(key) = random(key)
(其中random
为随机数函数 ) - 使用场景:通常应用于关键字长度不等时。
- 优点:在关键字长度差异大时,提供灵活的哈希地址计算方式。
- 缺点:哈希地址随机性强,在需稳定映射场景中适用性欠佳。
6.数学分析法 (了解)
- 原理:假设关键字
key
是一个具有 n 位的十进制数(每一位从 0 - 9 这十个数字中取值 )。在明确知晓哈希表中所有可能出现的关键字的前提下,对关键字的各位数字展开分析。鉴于不同数位上数字(符号 )出现的频率不尽相同,部分数位上数字分布均匀,每个数字出现的概率基本一致;而另外一些数位上,往往只有少数几个数字频繁出现。基于此,依据哈希表的规模大小,挑选其中数字分布均匀且数值变化范围较大的若干数位,组合形成哈希地址。 - 哈希函数公式:
Hash(key) = SelectDigits(key)
(SelectDigits
表示选取特定数位操作 ) - 示例:假设要存储某公司员工登记表,以手机号作为关键字。从图中示例
{130xxxx1234, 130xxxx2345, 138xxxx4829, 138xxxx2396, 138xxxx8354}
可以看出,极有可能前 7 位都是相同的 ,这些位数字分布集中、缺乏变化 。而后几位数字相对分布均匀,可选择后面四位作为散列地址。比如对于手机号130xxxx1234
,Hash(130xxxx1234) = 1234
。如果这样抽取仍容易出现冲突,还可以对抽取出来的数字进行反转(如1234
改成4321
)、右环位移(如1234
改成4123
)、左环位移、前两数与后两数叠加(如1234
改成12 + 34 = 46
)等操作 。此方法适合处理关键字位数较多,且事先知道关键字分布,关键字有若干位分布较均匀的情况 。 - 优点:当关键字位数多且部分数位分布不均时,通过数字分析法可得到较为均匀的哈希地址,有效减少哈希冲突。
- 缺点:使用该方法需要事先知晓所有可能出现的关键字,这使得其适用范围相对较窄。
哈希冲突
1.哈希冲突概念:哈希冲突,又称哈希碰撞。在哈希表中,对于两个不同存储元素键值对 pair<key, value>
的关键字 keyi 和 keyj(i != j 且 keyi !=keyj ),但经哈希函数计算后,若出现 hash(keyi) = hash(keyj) 的情况 即不同关键字通过相同哈希函数得到相同哈希地址,该现象即为哈希冲突 。具有不同关键字却拥有相同哈希地址的键值对中,这些不同的关键字被称为 “同义词” 。
2.哈希冲突的影响:在哈希表等数据结构应用中,哈希冲突会使不同键值对被映射到同一存储位置。这会严重干扰哈希表的查询、插入、删除操作 。例如查询时可能误取其他元素,插入时难以找到合适空位,导致操作效率大幅降低甚至结果错误 。
3.哈希冲突解决方式
- 闭散列(开放地址法):当冲突发生,按特定规则在哈希表内寻找空闲位置存储冲突元素 。如线性探测,从冲突位置起顺序往后找;二次探测,按探测次数平方的步长找空闲位置存储冲突元素 。
- 开散列(拉链法):在哈希表每个存储单元关联一个链表 ,冲突时将元素作为结点加入对应链表 。但需要注意的是链表过长会影响查询效率。
解决哈希冲突(碰撞)的方法
哈希冲突指的是:由于每个存储元素 pair<key,value>
的键值 key
都是不同的,但不同键值 key
通过同一个哈希函数计算时,可能会得出同一个哈希值,进而映射到哈希表的同一个存储位置。
说明:哈希值是作为下标访问哈希表中对应存储元素 pair<key, value>。设哈希表大小为 len,每个存储元素 pair<key, value> 的键值 key 通过哈希函数计算出初始哈希值。由于该初始哈希值可能超出哈希表大小范围(即 >= len),因此需对其进行取模操作(如取模运算:初始哈希值 % len),将其转换为 [0, len-1] 范围内的合法下标。最终,用该合法下标定位每个存储元素 pair<key, value> 在哈希表中的存储位置,即通过哈希函数和取模操作让 key 绑定(映射)到哈希表对应存储位置。
1.闭散列 (开放定址法,不建议)
闭散列,又被称作开放定址法,是一种在哈希表自身空间范围内解决哈希冲突的策略,无需额外开辟新的存储空间。当出现哈希冲突,也就是不同的键值 key
通过哈希函数计算得到了相同的哈希值,进而映射到哈希表的同一个存储位置时,若哈希表尚未被装满,便会在哈希表内部寻找该冲突位置之后的空位置来存放产生冲突的数据元素。在闭散列方法中,寻找下一个可用于存放数据的位置主要有线性探测和二次探测这两种方式。
需要特别注意的是,哈希表中所存放的元素本质上是 pair<key, value>
形式的键值对。这意味着在解决哈希冲突的过程中,我们需要对整个键值对进行处理,而不仅仅是关注键 key
。无论是插入、查找还是删除操作,都要确保能够正确地定位和操作对应的 pair<key, value>
。
1.1 线性探测(暴力查找)
1.1.1.线性探测介绍
线性探测是一种最为基础且直接的解决哈希冲突的方法。当发生哈希冲突时,它会从当前访问冲突的位置开始,按照顺序依次往后查找下一个可以存放元素的位置,直到找到一个空位置为止。这种方法的核心思想是简单地在哈希表中进行线性遍历,逐个检查后续的位置是否可用。
(1)线性探测案例
①设有一个大小为 10 的哈希表,哈希表的存储元素为键值对 pair<key, value>,采用的哈希函数为 hash (key) = key % len(其中 len = 10)。对于数据 key 集合 {1, 7, 6, 4, 5, 9},插入哈希表的具体过程如下:
hash(1) = 1 % 10 = 1
,将键值对{1, value1}
存入哈希表索引为 1 的位置;hash(7) = 7 % 10 = 7
,将键值对{7, value7}
存入索引为 7 的位置;hash(6) = 6 % 10 = 6
,将键值对{6, value6}
存入索引为 6 的位置;hash(4) = 4 % 10 = 4
,将键值对{4, value4}
存入索引为 4 的位置;hash(5) = 5 % 10 = 5
,将键值对{5, value5}
存入索引为 5 的位置;hash(9) = 9 % 10 = 9
,将键值对{9, value9}
存入索引为 9 的位置。
②当插入键值对 {44, value44}
时,计算 hash(44) = 44 % 10 = 4
,由于索引 4 的位置已存储键值对 {4, value4}
,发生哈希冲突。按照线性探测方法,从索引 4 开始往后依次探测:
- 索引 5 已被键值对
{5, value5}
占用; - 索引 6 已被键值对
{6, value6}
占用; - 索引 7 已被键值对
{7, value7}
占用; - 索引 8 为空,于是将键值对
{44, value44}
存入索引 8 的位置。
③当插入键值对 {54, value54}
时,计算 hash(54) = 54 % 10 = 4
,再次在索引 4 位置发生冲突。从索引 4 开始向后探测:
- 索引 5 被键值对
{5, value5}
占用; - 索引 6 被键值对
{6, value6}
占用; - 索引 7 被键值对
{7, value7}
占用; - 索引 8 被键值对
{44, value44}
占用; - 由于探测到哈希表末尾仍未找到空位,此时从索引 0 继续探测,发现索引 0 为空,便将键值对
{54, value54}
存入索引 0 的位置。
(2)线性探测方式
在哈希表线性探测中,先通过 hashi = Key % len 计算初始哈希值确定存储位置。若发生冲突,采用 hashi + i (i 从 1 开始递增)寻找下一个位置。
因哈希表空间有限,hashi + i 可能超出范围。通过 (hashi + i) %= len 操作,能把位置重新映射到哈希表有效范围内,实现循环查找。这是由于冲突位置 hashi 不是起始位置,其之前也可能有空位,所以超范围后要回到初始位置再往后找。若绕一圈回到冲突位置仍无空位,对插入操作而言意味着哈希表大小不足,需先扩容再插入。
(3)线性探测问题
线性探测虽然实现起来非常简单,但其存在着较为明显的弊端。当相邻位置出现聚集性的连续冲突时,就会形成所谓的 “踩踏” 效应。这种效应会导致数据在哈希表中局部聚集,使得原本均匀分布的数据变得不再均匀。
例如,若后续还有多个元素(如 64 、74 等)经哈希函数计算后的位置都与已占用位置冲突,它们就会依次存放在冲突位置之后的连续位置上。这会使这些位置附近的数据高度聚集,形成一个数据密集区。在进行插入操作时,由于需要不断地向后探测空位置,会大大增加查找空位置的时间开销;在进行查找操作时,也需要遍历更多的位置才能找到目标元素或者确定元素不存在,这会显著降低哈希表的操作效率,增加操作的时间复杂度。在最坏的情况下,插入和查找操作的时间复杂度可能会退化为 O(n) ,其中 n 是哈希表中元素的数量,这与未使用哈希表时的线性查找效率相当,严重违背了哈希表设计的初衷。
(4)线性探测中的删除操作
在使用闭散列(开放定址法)里的线性探测处理哈希冲突时,直接对哈希表中的元素执行物理删除是不可取的。这是由于线性探测依靠顺序向后探测的机制来解决冲突并查找元素。若贸然物理删除某元素,会扰乱这种探测顺序,进而干扰其他元素的查找与插入操作。
以具体例子来说,假设哈希表中有元素 4 以及通过线性探测在冲突后插入的元素 44 。若直接将元素 4 从哈希表中物理删除,之后在查找元素 44 时,按照线性探测规则,探测到原本元素 4 所在位置为空后,可能就会错误地停止查找,导致无法找到 44 。
因此,线性探测采用标记的伪删除法来处理元素删除。具体而言,就是为哈希表的每个存储位置设置标记,通过定义枚举类型来明确这些标记的状态:
// 哈希表每个空间给个标记
// EMPTY表示此位置空, EXIST表示此位置已经有元素, DELETE表示元素已经删除
enum State{EMPTY, EXIST, DELETE};
在执行删除操作时,当要删除某个元素,并不是将其从物理存储空间中移除,而是将该元素所在位置的标记从 EXIST
修改为 DELETE
。
在后续的插入操作中,标记为 DELETE
的位置被视为可占用空间,新元素可以插入到该位置。因为新元素插入时,会按照线性探测规则依次检查位置状态,遇到 DELETE
标记时,就如同遇到空位置一样(只是后续可能需要额外处理该位置曾经有元素的情况),可以将新元素插入进去,并将标记更新为 EXIST
。
在查找操作中,当探测到标记为 DELETE
的位置时,程序不会将其判定为查找结束的标志,而是会继续按照线性探测规则,从该位置继续向后探测,直到找到目标元素或者遇到标记为 EMPTY
的位置(表示目标元素不存在)。这样一来,就有效保证了哈希表中其他元素的正常搜索与插入操作,维持了哈希表内部逻辑的完整性和数据的一致性。
1.1.2.线性探测代码实现
1.2.二次探测
注意事项:二次探测不是只探测两次,而是按照二次方的规则进行探测。二次探测能够在一定程度上缓解线性探测中因冲突元素集中而产生的 “聚集” 问题,但无法完全杜绝该问题。
1.2.1.二次探测介绍
线性探测存在明显的缺陷,它在处理哈希冲突时,由于采用挨着往后逐个寻找下一个空位置的方式,会导致产生冲突的数据堆积在一块,形成所谓的 “聚集效应”。这种聚集会使得后续插入和查找元素时,需要进行更多次的比较,从而降低哈希表的性能。
为了避免线性探测的这一问题,二次探测应运而生。二次探测在寻找下一个空位置时,采用了基于二次方的方式来确定探测序列。
假设哈希表的大小为 len,对于一个关键字 key,通过哈希函数计算得到初始哈希位置 h0 = hash (key) % len。若该位置已被占用,即发生哈希冲突,那么二次探测会按照以下规则依次寻找下一个可能的空位置:
- 第一次探测的位置为 h1 = (h0 + 1²) % len;
- 第二次探测的位置为 h2 = (h0 + 2²) % len;
- 第三次探测的位置为 h3 = (h0 + 3²) % len;
- 以此类推,第 i 次探测的位置为 hi = (h0 + i²) % len(其中 i 为正整数)。
案例:设哈希表大小 len = 10,哈希函数 hash (key) = key % len,已有元素 {1, 4, 5, 6, 7, 9, 44} 存入表中。插入 key = 64 时:
- 初始:h0 = hash (64) = 4,该位置被占,冲突。
- 第一次探测:h1 = (4 + 1²) % 10 = 5,被占。
- 第二次探测:h2 = (4 + 2²) % 10 = 8,被占。
- 第三次探测:h3 = (4 + 3²) % 10 = 3,被占。
- 第四次探测:h4 = (4 + 4²) % 10 = 0,若此位空,则存入 {64, value64}。
这种 “跳跃式” 的探测方式,使得冲突元素不会像线性探测那样集中在相邻位置,而是尽可能分散地分布在哈希表中,从而有效缓解了线性探测所带来的聚集问题,提高了哈希表的空间利用率和整体性能。
然而,二次探测也并非完美无缺。虽然它减少了线性聚集,但可能会出现二次聚集现象,即不同关键字的探测序列可能会重叠,导致仍然存在一定程度的元素聚集。并且,当哈希表的负载因子较高时,二次探测同样可能面临难以找到空位置的问题,使得插入和查找操作的效率下降。此外,对于某些特定的哈希表大小,二次探测可能无法遍历哈希表的所有位置,影响其有效性。因此,在实际应用中,需要根据具体情况选择合适的哈希表大小和负载因子,并结合动态扩容等策略,以充分发挥二次探测的优势,保障哈希表的高效运行。
1.2.2.二次探测代码实现
1.3.闭散列的缺陷
闭散列(开放定址法)用于解决哈希冲突时存在诸多局限性。从本质上看,它类似于在固定大小的哈希表空间内进行一场 “零和游戏” 。在这个固定空间中,空位置是有限的稀缺资源,不同元素都试图通过哈希函数映射到合适的位置进行存储。
当发生哈希冲突时,意味着多个元素经哈希函数计算后得到了相同的哈希值,指向了同一个位置。无论是采用线性探测(从冲突位置依次向后寻找下一个空位置)还是二次探测(按照特定的二次函数规律寻找下一个空位置)等闭散列方法,都是在当前有限的哈希表空间内寻找其他可用位置来存放冲突元素。
这就导致了一种位置 “抢夺” 的局面,某个元素原本通过哈希函数计算出的位置可能会被因冲突而进行探测的其他元素占用,而这个被占用位置的元素,又可能是因为在其他位置发生冲突后进行探测才占用此处的。例如在线性探测中,若元素 A 和元素 B 经哈希函数计算都应存放在位置 P ,元素 A 先到达,占据了位置 P ,元素 B 就只能从位置 P 开始往后探测寻找空位。若此时位置 P + 1 是元素 C 经哈希函数计算应存放的位置,那么元素 B 就会占用原本属于元素 C 的位置,进而元素 C 又得去寻找其他位置。
这种方式会带来一系列问题。一方面,随着哈希表中元素增多,冲突概率增大,会出现大量元素聚集在某些区域的情况,例如线性探测中的 “聚类” 现象,使得后续插入和查找操作需要探测更多位置,时间复杂度增加,效率大幅降低。另一方面,当哈希表接近装满时,找到可用空位置的难度急剧上升,可能导致插入操作耗时过长甚至无法插入,严重影响哈希表的性能和可用性。
2.开散列(哈希桶法 / 拉链法 / 链地址法,建议)
2.1. 开散列介绍
(1)开散列概念:开散列法,也称为链地址法(拉链法),是一种解决哈希冲突的有效策略。其基本原理是,首先针对关键字 key 集合,运用哈希函数计算出哈希值。那些具有相同哈希值的关键字 key 对应的键值对 pair<key, value> 会被归属于同一个子集合,每一个这样的子集合被称作一个桶。每个桶即单链表,用于存储这些键值对 pair<key, value> 。各个桶(单链表)中的元素通过用哈希表存储各个单链表头结点并通过头结点进行联系,也就是各个桶的元素是存储在单链表中的。
以哈希函数 hash (key) = key % capacity (capacity = 10 )为例,假设有一组数据 {1, 4, 5, 6, 7, 9, 44} 。通过哈希函数计算:
- hash (1) = 1 % 10 = 1 ,键值对 {1, value_1} 存于索引 1 对应的桶(单链表)中。
- hash (4) = 4 % 10 = 4 ,键值对 {4, value_4} 存于索引 4 对应的桶中。
- hash (5) = 5 % 10 = 5 ,键值对 {5, value_5} 存于索引 5 对应的桶中。
- hash (6) = 6 % 10 = 6 ,键值对 {6, value_6} 存于索引 6 对应的桶中。
- hash (7) = 7 % 10 = 7 ,键值对 {7, value_7} 存于索引 7 对应的桶中。
- hash (9) = 9 % 10 = 9 ,键值对 {9, value_9} 存于索引 9 对应的桶中。
- hash (44) = 44 % 10 = 4 ,此时 44 与 4 的哈希值相同,发生哈希冲突,键值对 {44, value_44} 会被添加到索引 4 对应的桶(单链表)中。
注:这里的 value_1、value_4 等只是示意,代表每个 key 对应的值。实际应用中可根据具体情况赋值。
(2)单链表存储冲突元素及插入策略:具体而言,哈希表的每个存储位置对应一个单链表,这个单链表专门用于存储哈希值相同的元素,也就是因哈希冲突而被分配到同一位置的元素。在处理这些冲突元素的插入操作时,由于单链表头插的效率较高(时间复杂度为 O (1)),而尾插效率较低(需遍历链表,时间复杂度为 O (n),n 为链表长度),所以对于该单链表,最好采用头插法来插入哈希冲突的元素。
(3)插入操作示例:例如,在给定的哈希表中,哈希函数为 hash (key) = key % len(len = 10)。对于元素 1,hash (1) = 1 % 10 = 1,它会被插入到哈希表索引为 1 对应的单链表中;对于元素 4,hash (4) = 4 % 10 = 4,会被插入到索引为 4 的单链表。当插入元素 44 时,hash (44) = 44 % 10 = 4,与元素 4 发生哈希冲突,此时按照头插法,将 44 插入到索引为 4 的单链表头部,使得该单链表变为 44 -> 4 -> NULL。
(4)查找操作流程:从图示中也能清晰看到,开散列中每个桶(即每个存储位置对应的单链表)中存放的都是发生哈希冲突的元素。这种结构使得在查找元素时,先通过哈希函数定位到对应的单链表,然后在单链表中进行遍历查找。比如查找元素 44,先计算 hash (44) 得到索引 4,然后在索引 4 对应的单链表中遍历,就能找到 44。
(5)删除操作步骤:在删除元素时,同样先通过哈希函数定位到对应的单链表,然后在单链表中找到要删除的元素并进行删除操作。例如要删除元素 4,先定位到索引 4 的单链表,然后将其从链表中移除。
(6)开散列优缺点及优化策略:开散列的优点在于其对哈希冲突的处理相对灵活,不会像闭散列那样出现元素聚集导致性能急剧下降的情况。但随着哈希表中元素增多,单链表可能会变长,导致查找、插入和删除操作的时间复杂度增加。因此,在实际应用中,有时会结合其他优化策略,比如当单链表长度超过一定阈值时,将单链表转换为平衡二叉树等更高效的数据结构,以提升整体性能。
2.2.开散列代码实现
闭散列(哈希表)实现
1.哈希表设计分析
在数据结构中,当向搜索树和哈希表不断插入键值对 pair<key, value>
时,其存储方式并非像普通顺序存储结构那样单纯依次顺序存储。
对于哈希表的删除操作而言,当执行删除操作时,哈希表并非像顺序表那样从头遍历查找目标元素。而是首先通过哈希函数,依据 key
计算出其在哈希表中的初始存储位置。然后,将该位置存储的键值对 pair<key, value>
中的 key
与待删除的目标 key
进行比对。若匹配,则找到目标元素;若不匹配,就需要利用线性探测或二次探测等方法,继续往后查找,直至找到目标元素或者确定目标元素不存在。
在闭散列实现的哈希表中,通常采用 vector
作为底层容器来存储键值对。但这里存在一个问题:由于 vector
是连续内存空间,其删除操作仅支持从起始位置开始删除整个空间元素,无法在空间内的任意位置进行删除,也不能只删除部分元素。这就导致当我们找到要删除的键值对时,不能直接进行物理删除。
为解决这个问题,我们采用标记法。具体来说,哈希表底层 vector
存储的数据类型不应仅仅是键值对 pair<key, value>
,而是一个包含键值对以及标记信息的结构体。同时我们定义了一个枚举类型 State
,用来表示哈希表中每个存储位置的状态:
-
//定义哈希表存储位置的状态标记,用于闭散列冲突处理 //EMPTY 表示此位置空 //EXIST 表示此位置已经有元素 //DELETE 表示元素已经删除 enum State { EMPTY, //空EXIST, //存在DELETE //删除 };//哈希表的存储单元结构,包含: // 1. 键值对数据 // 2. 位置状态标记(初始化为EMPTY) 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; //记录实际存储数据个数(仅包含EXIST状态)//定义存储有效数据个数size_t _n这个成员变量是为了//解决负载因子超过或等于0.7后要进行扩容的问题。 };
对哈希表执行插入操作时,每个空间(可用资源 )都有对应的标记。若空间被标记为 EXIST
,则表示该空间已被占用,不能用于存储新元素;若被标记为 EMPTY
或 DELETE
,则表示该空间可用。
当要删除某个键值对 pair<key, value>
时,只需将其所在存储位置的标记设置为 DELETE
。这样,从逻辑上看,该键值对已被删除,但实际上它仍占据着物理空间。这种 “伪删除” 方式既避免了因直接物理删除而影响其他元素搜索的问题(因为直接删除可能导致哈希表结构混乱,使得后续元素查找出现错误,比如若删除元素 4,直接物理删除后,44 查找起来可能会受影响 ),又保证了该空间后续可以被重新利用来存储新的键值对。
总的来说,通过这种将键值对与标记结合的方式,能够有效管理哈希表中每个存储位置的使用状态,确保在闭散列处理哈希冲突及进行删除操作时,哈希表的功能能够正确、稳定地实现 。
需要注意的是,由于标记法需要对每个存储位置进行状态标记,哈希表在初始化时必须分配非零大小的空间(即 size
和 capacity
均大于 0
) 。尽管哈希表初始时为固定长度,但后续可根据实际需求进行扩容,以适应数据量的变化。
2.哈希表存储位置标记方案对查找操作的影响分析
注意事项:对哈希表存储位置使用状态如何进行标记,关键在于解决删除存储元素后该位置的标记问题。查找是从映射位置开始找,直到遇到空就结束;而且查找方式是使用线性探测/二次探测。
2.1.简单二元标记方式在哈希表查找操作中的缺陷 —— 以线性探测为例
当前采用的标记方式为:存储元素(非空位置)标记为 -1 ,不存储元素(空位置)标记为 0 。这里的空闲位置标记为 0 存在两种情形:一是存储位置始终未存储过元素;二是存储位置曾存储元素,但删除后变为空闲位置。
这种仅用两种状态(0 表示空,-1 表示非空)来表示哈希表存储位置使用状态的方式,会对查找操作产生显著影响。
假设在哈希表中,使用线性探测或二次探测来处理哈希冲突。以线性探测为例,它会依次查找后续位置进行存储或查找,即按照 hash + i
(i = 1, 2, 3, 4...
)的方式进行探测。在查找过程中,一旦遇到标记为 0 的位置(空闲位置),线性探测或二次探测就会停止查找。
结合图片案例说明:
- 删除 33 之前:数据集合
key = { 2, 3, 33, 13, 5, 12, 1002 }
,在哈希表中存储。例如要查找 13 ,当哈希函数计算出的初始位置被占用(假设为hash
位置),会按照线性探测规则向后探测。若下一个位置(hash + 1
)也被占用(如存储 33 ),继续探测。但如果在探测过程中遇到标记为 0 的空闲位置,按照现有规则就会停止查找,可能导致找不到实际存在的 13 。同理,查找 23 时也会面临同样问题。 - 删除 33 之后:若将 33 所在位置标记为 0 (因为按照当前规则,删除元素后该位置变为空闲,标记为 0 ),当查找 13 时,线性探测到 33 原本所在位置(此时标记为 0 )就会停止,从而错误地认为 13 不存在于哈希表中,尽管 13 实际是存在于后续位置的。
这种标记方式的弊端在于,无法区分该空闲位置是一直未使用过,还是曾经有元素但被删除了。如果是后者,直接停止查找会导致漏查,影响哈希表查找功能的正确性。因此,简单地用 0 表示空位置、-1 表示非空位置,不能满足哈希表在处理删除操作及查找操作时的准确需求。
结论:
- 若采用线性探测 或 二次探测解决哈希冲突,查找过程中仅当遇到标记为「初始空闲」(即从未存储过元素)的位置时才能终止探测;若遇到标记为「已删除」的位置,必须继续向后探测,因为实际数据可能通过哈希冲突被存入了后续位置。直接将所有空闲位置视为查找终止点,会导致误判目标元素不存在,因此需明确区分「初始空闲」与「已删除」两种状态。
- 在哈希表闭散列实现中,存储位置必须严格区分 “从未存储过元素的空状态” 与 “元素被删除后的状态” ,否则会导致查找逻辑错误。
2.2.解决方式
对于简单二元标记方式在哈希表查找操作中的缺陷,可通过以下方式解决:
分析:核心问题在于解决删除元素后存储位置的标记问题。若仅使用 0(空)和 -1(非空)两种状态标记存储位置,当删除某个位置的元素并将其标记为 0 后,采用线性探测进行查找会出现错误 —— 线性探测一旦遇到标记为 0 的位置便会停止查找,导致后续存在的元素无法被找到。若为避免该问题而放弃线性探测,改为从头遍历整个哈希表,这将失去哈希表快速查找的意义。
因此,为解决这一问题,需引入三状态标记体系:EMPTY(空) 、EXIST(存在)、
DELETE(删除)。当存储元素被删除后,应将对应位置标记为 DELETE 而非 EMPTY,这样在使用线性探测或二次探测进行查找时,遇到 DELETE 状态的位置可继续向后探测,避免因误将已删除位置当作查找终止点而导致漏查目标元素。
3.插入操作 Insert
3.1.实现过程中遇到的问题及对应解决方式
注意事项:哈希表底层通过封装vector
实现,采用闭散列方式存储数据,其哈希函数基于除留余数法计算哈希值(即数据存储位置)。
3.1.1.问题 1:分析除留余数法中取模操作的除数选择,即应使用vector::capacity()
还是vector::size()
?
//使用除留余数法 hash (key) = key % len (注:len为哈希表大小)来计算哈希值 (存储位置)//计算哈希值(存储位置)
//正确写法:hash (key) = key % vector::size()
size_t hashi = kv.first % _tables.size();//错误写法:hash (key) = key % vector::capacity()
size_t hashi = kv.first % _tables.capacity();//线性探测
size_t i = 1;
size_t index = hashi;
while (_tables[index]._state == EXIST)
{index = hashi + i;index %= _tables.size();++i;
}//线性探测找到空位置后插入数据。复用vector::operator[]进行插入数据,
//因为vector::operator[]支持随机访问。
_tables[index]._kv = kv;
_tables[index]._state = EXIST;
_n++;
解析:由于哈希表插入操作复用了vector::operator[]
,该操作符以vector::size()
作为越界检查的基准。vector::operator[]
仅允许访问[0, size() - 1]
范围内的索引,一旦访问索引超过size()
,即使未超过capacity()
,也会触发未定义行为。
若取模操作以vector::capacity()
为除数,计算得到的哈希值范围是[0, capacity() - 1]
。当size() < capacity()
时,超出size()
的索引(即[size(), capacity() - 1]
范围)使用vector::operator[]
访问vector
容器中的数据,必然会触发越界访问,导致程序断言报错。因此,为确保插入操作的安全性,除留余数法中的取模操作必须以vector::size()
为除数,使计算出的哈希值(存储位置)始终处于vector::operator[]
的合法访问范围内。
同时,这一规则与顺序表的访问逻辑一致:顺序表要求数据连续存储,使用operator[]
或find
进行访问时,同样以size()
作为越界检查的基准,而非capacity()
。当size() == 0
时,任何索引访问均会越界,这也进一步印证了哈希表插入操作以size()
为取模基准的必要性。
3.1.2.问题2
问题描述:对于哈希表(底层由vector
容器实现),哈希冲突会影响其性能。若发生哈希冲突的位置增多,会出现 “踩踏” 现象(即占用本应属于其他元素的位置),导致查找效率恶化,极端情况下会退化为O(n)。当哈希表中大部分位置被占用时,新插入数据发生哈希冲突的概率会大幅增加。为解决该问题,引入负载因子(载荷因子)。
思考:闭散列的哈希表在什么情况下进行扩容?如何扩容?
负载因子(载荷因子)定义为填入表中的元素个数与散列表长度的比值,它本质上反映了哈希表的满度。负载因子越大,发生哈希冲突的可能性越高,但并非必然发生冲突。
对于闭散列的哈希表,扩容并非基于哈希表(底层vector
容器)容量已满,而是基于负载因子。当负载因子超过 / 等于 0.7 时,就需要进行扩容。
解决方式:在代码中判断负载因子是否达到扩容阈值时,需注意计算方式。若直接使用_n / _tables.size() >= 0.7
,由于/
操作符两端都是整数类型,计算结果会只取整数部分,导致表达式恒为假,无法触发扩容。以下是两种正确的写法:
-
//错误写法:因为 / 操作符的两端都是整形则最终计算结果只会取整数部分而去掉小数部分进而导致 //_n / _tables.size() >= 0.7表示式为假进而永远无法由于负载因子过大而触发扩容来减少哈希冲突的概率。 if (_n / _tables.size() >= 0.7)//正确写法 //解决方式1:强制类型转换成double,只需强制转换一个操作数即可, //但是最好 / 操作符的两个操作数都强制转换成double。 //if ((double)_n / (double)_tables.size() >= 0.7)//解决方式2: >=操作符的左操作数分数表达式的分子部分扩大10倍,则对应 >=操作符的右操作也要扩大10倍即由原来的0.7变成7. if (_n * 10 / _tables.size() >= 7)
- 解决方式 1:将操作数强制类型转换为
double
,只需转换一个操作数即可,但为保证精度,最好将/
操作符的两个操作数都强制转换为double
。即if ((double)_n / (double)_tables.size() >= 0.7)
。 - 解决方式 2:将
>=
操作符左操作数分数表达式的分子部分扩大 10 倍,同时右操作数也相应扩大 10 倍,由原来的 0.7 变为 7,即if (_n * 10 / _tables.size() >= 7)
。
3.1.3.问题3
(1)问题①:选择使用vector::reserve()
进行扩容 / 开空间,还是选择使用vector::resize()
进行扩容 / 开空间?
不应使用vector::reserve()
进行扩容。原因如下:
-
//错误写法:因为当除数_tables.size() 等于0(哈希表一开始为空表)时则整个 //表达式_n * 10 / _tables.size()最终结果是0,也是无法触发扩容的,所以 //为了防止这种情况我们也要判断_tables.size() == 0为空表这种情况取触发扩容。 if (_n * 10 / _tables.size() >= 7)//正确写法: if (_tables.size() == 0 || _n * 10 / _tables.size() >= 7) //解析: //_tables.size() == 0,当哈希表是空表时也要进行扩容。 //_n * 10 / _tables.size() >= 7,当哈希表负载因子超过0.7时也要进行扩容来降低哈希冲突概率。
- 当哈希表初始为空时,无法触发扩容机制。因为在计算负载因子判断是否扩容时,若除数
_tables.size()
为 0(哈希表为空表) ,_n * 10 / _tables.size()
表达式结果为 0,无法满足扩容条件。所以正确的扩容条件判断应包含哈希表为空表的情况,即if (_tables.size() == 0 || _n * 10 / _tables.size() >= 7)
。 vector::reserve()
只会改变capacity
(容量) ,而不会改变size
(实际元素个数)。这就导致即使扩展了容量,vector::operator[]
仍只能访问[0, size - 1]
范围,无法访问扩容部分空间,因为vector::operator[]
的越界检查是以size
为基准,而非capacity
。
相比之下,vector::resize()
不仅能改变容量,还能改变实际元素个数,更适合哈希表的扩容需求。
(2)问题②:为什么不在旧表(原vector
容器)进行扩容?
若在旧表(原vector
容器)进行扩容,会导致哈希函数hash(key) = key % vector::size()
发生变化(因为vector::size()
变大了) 。这会使得使用原key
通过变化后的哈希函数无法找到原来存储的值。
案例:假设初始时哈希表底层vector
容器大小size
为 10,哈希函数为hash(key) = key % 10
。对于元素13
,按照初始哈希函数计算,13 % 10 = 3
,所以它被存储在哈希表下标为 3 的位置。
当在旧表(原vector
容器)进行扩容,比如扩容后vector
容器大小size
变为 20 ,此时哈希函数变为hash(key) = key % 20
。当查找元素13
时,使用变化后的哈希函数计算,13 % 20 = 13
,会去下标为 13 的位置查找。但实际上13
存储在下标为 3 的位置,就会出现按照新哈希函数找不到原来存储值的情况 。 所以不能在旧表原位扩容,而需重新开辟空间,重新计算元素存储位置。
具体来说,扩容导致哈希表大小(长度)变大,即hash(key) = key % len
中len
变大,原有的key
与旧哈希值(存储位置)的映射关系不再满足新的哈希函数计算结果。由于哈希表中所有元素的key
与存储位置的映射关系需由同一个哈希函数确定,len
变化前后的哈希函数hash(key) = key % len
已不是同一个函数,所以已存储在哈希表中的所有元素,都必须基于新的哈希函数重新计算key
与存储位置的映射关系,然后按新位置存储。
即使使用resize
对旧表(原vector
容器)进行异地扩容(底层都是使用new
开新空间进行异地扩容) ,也无法改变扩容后哈希表变大带来的映射关系改变问题。因为已存储的键值对pair<key, value>
仍会按照旧的映射关系存放在相对旧表位置,无法自动适配新表。
哈希表扩容不能原地进行,需另辟空间。因扩容会改变哈希表大小,使哈希函数变化。若原地扩容,已存元素按旧哈希函数确定的位置与新函数计算结果不符,查找时就找不到元素。所以要开辟新空间建新哈希表,遍历原表元素,用新哈希函数重算存储位置并插入,保障扩容后操作正常。
(3)哈希表扩容后的映射及哈希冲突的变化
①映射位置关系改变
- 扩容前,哈希表底层
vector
容器大小假设为 10,哈希函数是hash(key) = key % 10
。比如13
通过13 % 10 = 3
,存放在下标 3 位置;33
通过33 % 10 = 3
,也存放在下标 3 位置,此时二者发生冲突。 - 扩容后,哈希表底层
vector
容器大小变为 20 ,哈希函数变为hash(key) = key % 20
。13
经计算13 % 20 = 13
,存放在下标 13 位置;33
经计算33 % 20 = 13
,也存放在下标 13 位置 ,它们的映射位置都发生了改变。
②冲突情况变化
- 原来冲突的值可能不冲突了:扩容前
13
和33
冲突,都映射到下标 3 。扩容后,若哈希表中元素较少,按照新哈希函数计算,它们可能被分配到不同位置,就不再冲突。比如若还有元素23
,扩容前23 % 10 = 3
,和13
、33
冲突;扩容后23 % 20 = 3
,但13
、33
位置改变,可能就不再冲突。 - 原来不冲突的值可能冲突了:假设扩容前有元素
2
(2 % 10 = 2
)存于下标 2 ,5
(5 % 10 = 5
)存于下标 5 ,二者不冲突。扩容后,若新加入元素22
(22 % 20 = 2
) ,就可能和2
冲突;若有元素25
(25 % 20 = 5
) ,就可能和5
冲突。
综上,哈希表扩容会改变元素映射位置,影响冲突情况,所以扩容时需重新计算元素存储位置,以保证哈希表正常工作。
(4)扩容后映射关系的改变有两种方式:
-
方式 1:创建一个新的哈希表(底层
vector
容器),容量为扩容后的大小。然后遍历旧表,对于状态为EXIST
(有效元素)的键值对,重新计算其在新表中的哈希位置并插入。
例如在下面代码中HashTable<K, V> newht; newht._tables.resize(newsize);
,接着遍历旧表for (auto& data : _tables)
,对data._state == EXIST
的元素执行newht.Insert(data._kv);
,最后通过_tables.swap(newht._tables);
完成新旧表交换。//扩容 if (_tables.size() == 0 || _n * 10 / _tables.size() >= 7) {size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;HashTable<K, V> newht;//创建新表newht._tables.resize(newsize);//(不会发生死循环的原因)//对新表进行扩容,而扩容后新表就不是空表(因为vector::resize会改变size大小使得size不为0),//且刚创建的新表是没有存放数据的使得负载因子为0,由于又不是空表且负载因子没有超过0.7则//对新表复用HashTable::Insert的代码就不会触发扩容而是复用使用线性探测找空闲位置插入数据//的那部分代码逻辑来成功完成将旧表元素按新哈希函数映射到新表的操作。//遍历旧表,重新映射到新表for (auto& data : _tables){if (data._state == EXIST){//注意,这个不是递归调用,而是使用新哈希表对象去调用而且//不会发生死循环,上面说明了不会发生死循环的原因。newht.Insert(data._kv);}}_tables.swap(newht._tables);//使用vector::swap交换新表和旧表的资源,而不是//使用std::swap,因为vector::swap效率高。//注意:swap后newht析构时会自动释放旧表内存 }
-
注意事项:方式 1 可以复用哈希表
HashTable::Insert
完成旧表数据通过新哈希函数重新映射到扩容后的新表。这是因为在复用HashTable::Insert
时,新表刚创建,其负载因子为 0(元素个数少,容量为新设置的较大值 )。此时调用Insert
,负载因子判断条件(if (_tables.size() == 0 || _n * 10 / _tables.size() >= 7)
)不满足扩容触发条件,不会再次进入扩容代码部分,也就不会触发死循环。实际上,复用的是HashTable::Insert
中通过线性探测寻找空闲位置插入数据的代码逻辑,从而顺利将旧表元素按新哈希函数映射到新表。 -
方式 2:不创建新的哈希表对象,而是直接在原哈希表对象上操作。先保存旧的
vector
容器,然后创建新的vector
容器并调整为扩容后的大小。再遍历旧容器中的有效元素,根据新的哈希函数重新计算位置插入到新容器中,最后用新容器替换旧容器。 这种方式相对复杂一些,但在某些场景下可以避免对象创建和销毁的额外开销。if (_tables.size() == 0 || _n * 10 / _tables.size() >= 7) {size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;vector<HashData> newtables(newsize);//遍历旧表,重新映射到新表for (auto& data : _tables){if (data._state == EXIST){//重新算在新表的位置size_t i = 1;size_t index = hashi;while (newtables[index]._state == EXIST){index = hashi + i;index %= newtables.size();++i;}newtables[index]._kv = data._kv;newtables[index]._state = EXIST;}}_tables.swap(newtables); }
4.2.闭散列 Insert代码实现
bool Insert(const pair<K, V>& kv)
{//若哈希表中已经存在插入数据kv,就不要继续插入了。if (Find(kv.first))return false;// 负载因子超过0.7就扩容//if ((double)_n / (double)_tables.size() >= 0.7)if (_tables.size() == 0 || _n * 10 / _tables.size() >= 7){//size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;//vector<HashData> newtables(newsize);// 遍历旧表,重新映射到新表//for (auto& data : _tables)//{// if (data._state == EXIST)// {// // 重新算在新表的位置// size_t i = 1;// size_t index = hashi;// while (newtables[index]._state == EXIST)// {// index = hashi + i;// index %= newtables.size();// ++i;// }// newtables[index]._kv = data._kv;// newtables[index]._state = EXIST;// }//}//_tables.swap(newtables);size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;HashTable<K, V> newht;//创建新表newht._tables.resize(newsize);//(不会发生死循环的原因)//对新表进行扩容,而扩容后新表就不是空表(因为vector::resize会改变size大小使得size不为0),//且刚创建的新表是没有存放数据的使得负载因子为0,由于又不是空表且负载因子没有超过0.7则//对新表复用HashTable::Insert的代码就不会触发扩容而是复用使用线性探测找空闲位置插入数据//的那部分代码逻辑来成功完成将旧表元素按新哈希函数映射到新表的操作。// 遍历旧表,重新映射到新表for (auto& data : _tables){if (data._state == EXIST){//注意,这个不是递归调用,而是使用新哈希表对象去调用而且//不会发生死循环,上面说明了不会发生死循环的原因。newht.Insert(data._kv);}}_tables.swap(newht._tables);//使用vector::swap交换新表和旧表的资源,而不是//使用std::swap,因为vector::swap效率高。//注意:swap后newht析构时会自动释放旧表内存}//注意:这里取模时一定不是用vector::capacity()去模,而是使用vector::size()//去模,因为vector::operator[]是以vector实际存储元素数量size()为基准来检查//vector::operator[]的访问是否发生越界。size_t hashi = kv.first % _tables.size();//hashi也用来表示(若存在)哈希冲突位置//线性探测size_t i = 1;size_t index = hashi;//若当前位置使用状态是存在EXIST,则必须继续往后走找空闲位置。//注意:空闲位置的状态包括EMPTY、DELETE。//通过判断index = hashi(哈希值)对应存储位置的使用状态是否存在元素来判断是否发生哈希冲突,//若冲突再进行线性探测来找下一个空闲位置进行插入数据。while (_tables[index]._state == EXIST){//二次探测写法:index = hashi + i * i;//线性探测写法:index = hashi + i;//注:线性探测从1开始不断对i++,若是从0开始没有必要因为0就是哈希冲突位置。//若索引(下标)index越界了,则必须取模进行回绕,从重新回到vector起始位置进行线性探测。index %= _tables.size();//通过模vector::size()来解决回绕问题++i;}//线性探测找到下一个空闲位置,则此时需要在该位置插入数据键值对pair<key,value> 且插入后要更新该存储位置使用状态。_tables[index]._kv = kv;//插入键值对_tables[index]._state = EXIST;//更新为存在_n++;//增加实际存储数据个数的数量return true;
}
测试:
-
- 扩容前:
- 扩容后:
4.删除操作 Erase
//伪删除法(没有真正删除元素,而是把删除目标元素所在存储位置标记成删除DELETE而已)
bool Erase(const K& key)
{HashData<K, V>* ret = Find(key);if (ret){ret->_state = DELETE;//如果找到就标记成删除--_n;return true;}else{return false;}
}
5.查找操作 Find
5.1.实现过程中遇到的问题及对应解决方式
(1)问题1:在使用HashTable::Erase
方法时,只是将目标元素所在存储位置标记为DELETE
,并非从哈希表中真正移除。这就使得在后续使用Find
方法查找已标记删除的key
时,仍会按照原查找逻辑(仅依据键值匹配和位置状态非空判断)找到该元素。但从数据逻辑层面,已删除的数据不应被检索到,这种情况不符合实际需求。
原因分析:原查找逻辑中,while (_tables[index]._state != EMPTY)
循环仅判断了位置状态非空,没有区分EXIST
和DELETE
状态。当遇到标记为DELETE
的元素时,由于其状态不为EMPTY
,且键值可能匹配,就会被误判为找到目标元素。问题代码如下:
解决代码:在查找逻辑的while
循环内,增加对元素状态的精确判断,修改为if (_tables[index]._state == EXIST && _tables[index]._kv.first == key)
。只有当元素状态为EXIST
(表示该位置存储着有效数据)且键值与要查找的key
相等时,才认定成功找到目标数据并返回相应指针。
(2)问题2:当使用HashTable::Find
进行查找时,哈希表可能为空表,此时查找必然失败。所以在实现代码时,需要先判断哈希表是否为空表。
解决代码:在函数开始处添加if (_tables.size() == 0)
的判断,若哈希表为空,直接返回nullptr
表示查找失败。
(3)问题3:
注意事项:
-
哈希表不会出现所有存储位置都是
DELETE
状态的情况。因为当负载因子超过 0.7 时会触发扩容机制。在扩容时,会创建一个新的哈希表,新表初始状态下所有存储位置的状态都是EMPTY
。将旧表数据重新映射到新表的过程中,只有实际插入数据的位置才会被标记为EXIST
,这就使得旧表中原本标记为DELETE
的状态不会延续到新表中。例如,假设初始哈希表大小为 10,当负载因子达到 0.7 以上,会创建一个大小为 20(假设扩容为 2 倍)的新哈希表,新表一开始没有任何DELETE
标识。
总的来说,扩容时,新哈希表会重新分配内存空间,仅将原表中状态为EXIST
的元素按新哈希函数映射到新表。由于新表初始状态全为EMPTY
,且迁移过程中忽略DELETE
状态的元素,因此扩容后所有DELETE
标记会被自动清除。 -
有一种特殊情况会让哈希表中所有存储位置仅呈现
EXIST
和DELETE
两种状态,而不存在EMPTY
状态。例如,当哈希表负载因子接近但未达到 0.7 ,本应在下一次插入数据时触发扩容。此时,不执行插入操作来触发扩容,而是先删除部分元素(如 3、33、13、12 ),使得哈希表出现被标记为DELETE的空位,此时表中存在标记为DELETE
的空位和原始的EMPTY
空位。随后插入新元素(如 19、29、39 ),这些新插入元素恰好填满标记为EMPTY的空位,如此一来,哈希表中便不存在EMPTY状态的位置,仅存在EXIST状态(存储有效数据)和DELETE状态(曾存储数据但已标记删除)的位置。
问题:当哈希表中所有存储位置仅存在EXIST
(存在有效数据)和DELETE
(已删除数据标记)两种状态时,会引发查找问题。因为在查找逻辑中,while (_tables[index]._state != EMPTY)
这个循环依赖于遇到EMPTY
(空)状态来终止查找过程。而此时哈希表中不存在EMPTY
状态,就会导致该循环条件始终成立,从而陷入死循环。问题代码如下:
解决方式:为解决上述极端情况下的死循环问题,采取的策略是限制查找范围,最多让查找操作进行一圈(即出现回绕,回到发生哈希冲突的起始位置)。在代码中通过if (index == hashi)
来判断是否已经查找了一圈。如果index
(当前探测位置)等于初始哈希值hashi
,说明已经绕着哈希表查找了一圈,此时无论是否找到目标数据,都通过break
语句跳出循环,停止查找。
从哈希冲突原理来看,哈希表采用线性探测法解决冲突。当发生哈希冲突时,新插入数据会依次向后(或回绕向前)寻找下一个空位置来插入,这就使得冲突位置到下一个空位置之间的数据是连续存储的。所以,要查找的数据只会出现在冲突位置到下一个空位置之间,且要么在冲突位置后面(正常顺序),要么在冲突位置前面(回绕情况) 。一旦绕了一圈还未找到目标数据,就可以确定哈希表中不存在该数据。比如在上述特殊状态的哈希表中查找一个不存在的键值,当查找一圈回到起始位置时,就可判定查找失败,避免无限循环查找。
5.2.正确代码
HashData<K, V>* Find(const K& key)
{//哈希表是空表也会查找失败if (_tables.size() == 0){return nullptr;//查找失败}size_t hashi = key % _tables.size();// 线性探测size_t i = 1;size_t index = hashi;while (_tables[index]._state != EMPTY){if (_tables[index]._state == EXIST&& _tables[index]._kv.first == key){return &_tables[index];//查找成功}index = hashi + i;index %= _tables.size();++i;// 如果已经查找一圈,那么说明全是存在+删除if (index == hashi){break;}}return nullptr;//查找失败
}
6.闭散列(哈希表)代码实现
注意事项:在哈希表中,二次探测主要用于解决线性探测所导致的 “拥堵”(也可称为 “聚集” 或 “践踏” )问题。线性探测在处理冲突时,会使后续冲突元素倾向于集中在已冲突位置附近,形成数据聚集,导致查找效率降低。二次探测通过采用二次方的步长(如index = hashi + i * i
,i
为探测次数)来进行位置探测,试图分散这种聚集情况。
然而在实际应用中,线性探测和二次探测都存在一定局限性。线性探测易产生聚集,影响查找和插入性能;二次探测虽然能缓解聚集问题,但可能会出现 “二次聚集” 现象,即某些位置被优先探测,而且无法遍历哈希表的所有位置,在极端情况下可能导致哈希表空间利用不充分。因此,实际中往往不单纯使用线性探测和二次探测,而是采用更复杂高效的方法,如链地址法(将冲突元素用链表链接起来)、再哈希法(使用多个哈希函数)等,以提升哈希表的整体性能。
//闭散列哈希表
namespace OpenAddress
{enum State{EMPTY,EXIST,DELETE};template<class K, class V>struct HashData{pair<K, V> _kv;State _state = EMPTY;};template<class K, class V>class HashTable{public:bool Insert(const pair<K, V>& kv){if (Find(kv.first))return false;//负载因子超过0.7就扩容//if ((double)_n / (double)_tables.size() >= 0.7)if (_tables.size() == 0 || _n * 10 / _tables.size() >= 7){//size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;//vector<HashData> newtables(newsize);遍历旧表,重新映射到新表//for (auto& data : _tables)//{// if (data._state == EXIST)// {// // 重新算在新表的位置// size_t i = 1;// size_t index = hashi;// while (newtables[index]._state == EXIST)// {// index = hashi + i;// index %= newtables.size();// ++i;// }// newtables[index]._kv = data._kv;// newtables[index]._state = EXIST;// }//}//_tables.swap(newtables);size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;HashTable<K, V> newht;newht._tables.resize(newsize);//遍历旧表,重新映射到新表for (auto& data : _tables){if (data._state == EXIST){newht.Insert(data._kv);}}_tables.swap(newht._tables);}size_t hashi = kv.first % _tables.size();//线性探测size_t i = 1;size_t index = hashi;while (_tables[index]._state == EXIST){index = hashi + i;index %= _tables.size();++i;}_tables[index]._kv = kv;_tables[index]._state = EXIST;_n++;return true;}HashData<K, V>* Find(const K& key){//哈希表是空表也会查找失败if (_tables.size() == 0){return nullptr;//查找失败}size_t hashi = key % _tables.size();//线性探测size_t i = 1;size_t index = hashi;while (_tables[index]._state != EMPTY){if (_tables[index]._state == EXIST && _tables[index]._kv.first == key){return &_tables[index];//查找成功}index = hashi + i;index %= _tables.size();++i;//如果已经查找一圈,那么说明全是存在+删除if (index == hashi){break;}}return nullptr;//查找失败}bool Erase(const K& key){HashData<K, V>* ret = Find(key);if (ret){ret->_state = DELETE;--_n;return true;}else{return false;}}private:vector<HashData<K, V>> _tables;size_t _n = 0; //存储的数据个数};void TestHashTable1(){int a[] = { 3, 33, 2, 13, 5, 12, 1002 };HashTable<int, int> ht;for (auto e : a){ht.Insert(make_pair(e, e));}ht.Insert(make_pair(15, 15));if (ht.Find(13)){cout << "13在" << endl;}else{cout << "13不在" << endl;}ht.Erase(13);if (ht.Find(13)){cout << "13在" << endl;}else{cout << "13不在" << endl;}}
}
开散列(哈希表)实现
1.哈希表设计分析
(1)开散列介绍
开散列法,也称为链地址法(拉链法/哈希桶法),是一种解决哈希冲突的有效策略。其基本原理是,首先针对关键字 key 集合,运用哈希函数计算出哈希值。那些具有相同哈希值的关键字 key 对应的键值对 pair<key, value> 会被归属于同一个子集合,每一个这样的子集合被称作一个桶。每个桶即单链表,用于存储这些键值对 pair<key, value> 。各个桶(单链表)中的元素通过用哈希表存储各个单链表头结点并通过头结点进行联系,也就是各个桶的元素是存储在单链表中的。
以哈希函数 hash (key) = key % capacity (capacity = 10 )为例,假设有一组数据 {1, 4, 5, 6, 7, 9, 44} 。通过哈希函数计算:
- hash (1) = 1 % 10 = 1 ,键值对 {1, value_1} 存于索引 1 对应的桶(单链表)中。
- hash (4) = 4 % 10 = 4 ,键值对 {4, value_4} 存于索引 4 对应的桶中。
- hash (5) = 5 % 10 = 5 ,键值对 {5, value_5} 存于索引 5 对应的桶中。
- hash (6) = 6 % 10 = 6 ,键值对 {6, value_6} 存于索引 6 对应的桶中。
- hash (7) = 7 % 10 = 7 ,键值对 {7, value_7} 存于索引 7 对应的桶中。
- hash (9) = 9 % 10 = 9 ,键值对 {9, value_9} 存于索引 9 对应的桶中。
- hash (44) = 44 % 10 = 4 ,此时 44 与 4 的哈希值相同,发生哈希冲突,键值对 {44, value_44} 会被添加到索引 4 对应的桶(单链表)中。
注:这里的 value_1、value_4 等只是示意,代表每个 key 对应的值。实际应用中可根据具体情况赋值。
(2)注意事项
①闭散列(开放定址法 )更能体现线性探测是在找下一个空位置存放数据。
闭散列的原理是当发生哈希冲突时,如果哈希表未被装满,就把key
存放到冲突位置的 “下一个” 空位置中。从发生冲突的位置开始,依次向后探测(即线性探测),直到找到下一个空位置。具体实现上,线性探测以步长为 1 的方式顺序探测。
- 例如,若初始哈希值
hash(key)
对应的位置已被占用,将依次尝试(hash(key)+1) % len
、(hash(key)+2) % len
等位置(len
为哈希表长度),其中%
运算确保探测位置在表内循环。当遇到状态为EMPTY
或DELETE
(已删除状态,可复用)的位置时,即可插入新数据。这种机制下,数据存储严格依赖哈希表自身空间,通过连续遍历寻找空位,因此 “寻找下一个空位置” 的过程清晰可见。
相比之下,开散列(链地址法)采用 “桶 + 链表” 结构:每个哈希值对应一个桶(链表头结点),发生哈希冲突时,新元素直接插入到对应桶的链表头部或尾部(注:由于单链表头插、头删效率高所以我们一般在单链表头部插入冲突元素),无需在哈希表的主存储区域(数组)内寻找空位。例如,多个关键字key通过哈希函数计算出相同地址,这些元素会被链接到同一链表中,其插入操作仅涉及链表结点的创建与连接,与哈希表的空闲位置无关。
综上所述,闭散列通过线性探测在哈希表内部循环查找空位置的特性,使其相较于开散列,更能清晰展现 “寻找下一个空位置存放数据” 的过程。
②开散列(拉链法)更能直观体现将冲突数据组织在单链表中的特性。
其核心机制是:当多个关键字通过哈希函数映射到同一存储地址(即发生哈希冲突)时,这些数据会被组织成一条单链表,悬挂在该地址对应的 “桶” 中。每个桶本质是链表的头结点,所有映射到该地址的元素通过头插法(效率最高)依次添加到链表中,形成 “冲突数据链”。
- 例如,假设哈希函数为
h(key) = key % 10
,当插入键值为 5、15、25 的元素时,它们均映射到地址 5。此时,开散列会创建一条以地址 5 为头结点的链表,依次将 5、15、25 头插到链表中,形成25 → 15 → 5
的结构。这种实现方式中,每条单链表专门用于存储因哈希冲突被映射到同一位置的数据,直观体现了 “冲突数据悬挂” 的特性。
相比之下,闭散列(如线性探测)通过在哈希表内部寻找下一个空位来存放冲突数据,数据仍存储在主数组中,并未显式创建链表结构。因此,开散列的 “桶 + 链表” 设计更清晰地展现了冲突数据的链式组织方式。
(3)开散列哈希表结构设计
template<class K, class V>
struct HashNode
{HashNode<K, V>* _next;pair<K, V> _kv;HashNode(const pair<K, V>& kv): _next(nullptr), _kv(kv){}
};template<class K, class V>
class HashTable
{typedef HashNode<K, V> Node;
private:vector<Node*> _tables; // 存储链表头指针的数组,每个元素指向一个单链表(链地址法)size_t _n = 0; // 存储有效数据个数,用于计算负载因子以触发扩容// 定义存储有效数据个数size_t _n这个成员变量是为了// 实时追踪哈希表中的元素数量,从而在负载因子(_n / _tables.size())// 超过或等于阈值(如0.7、1)时触发扩容,保证哈希表的性能。
};
注意:
- 在开散列哈希表中,底层采用
vector<Node*>
(链表原生指针数组)而非vector<list<pair<K,V>>>
(标准库链表容器),原因在于前者在扩容时仅需通过指针操作完成节点迁移,避免了后者因遍历链表元素并调用标准库接口带来的额外开销,因此建议优先选择封装原生指针Node*
的vector
结构。
总的来说,就是在对作为哈希表底层的 vector 进行扩容时,封装链表原生指针 Node*的处理细节比封装 list 更为便捷,因此建议使用 vector<Node*> 而非 vector<list>。
- 在链表类型的选择上,尽管双向链表能提供更灵活的删除操作,但单链表的头插法已足以满足开散列哈希表的插入、查找和删除需求,且无需额外存储前驱指针,节省内存空间。
- 开散列哈希表中
vector<Node*>
存储的NULL
表示空桶位置,即该哈希桶未存储任何节点,插入时可直接作为头插起点。 - 相较于闭散列哈希表的线性探测机制,开散列哈希表(拉链法)通过单链表存储冲突元素,从根本上避免了线性探测中 “探测链重叠导致非冲突元素占用位置” 的问题,从而降低了哈希冲突的实际影响概率。此外,单链表头插操作的时间复杂度为 O (1),无需像闭散列那样在连续空间中查找空位置,进一步提升了插入效率。这种设计使得开散列哈希表在处理高冲突场景时,能更高效地平衡时间与空间性能。
2.插入操作 Insert
2.1.遇到问题及对应解决方式
注意事项:开散列哈希表选择使用单链表来存储哈希冲突的元素,会相较于闭散列哈希表降低哈希冲突的实际影响。因为开散列中冲突元素都存放在同一条链表中,不占用哈希表 vector 的其他存储位置,避免了闭散列因线性探测导致的 “踩踏” 问题(即占用其他数据的位置),从而减少了后续插入时发生冲突的概率。而闭散列哈希表直接使用 vector 存储数据,发生冲突时需线性探测其他位置,这会导致冲突链的长度增加,进而提高后续插入时的冲突概率。
(1)问题1:在单链表插入数据时,是选择头插,还是尾插 ?
若哈希表 vector 中对应位置不存储插入数据,而哈希表 vector 通过存储链表头结点指针并使用单链表存储插入数据,则无论该位置是否已存在冲突元素,都选择将新节点头插到对应单链表中。由于单链表头插操作的时间复杂度为 O (1),效率显著高于尾插(需遍历链表找到尾节点,时间复杂度为 O (n)),因此执行插入操作时优先选择头插。虽然从功能上来说,开散列的单链表中头插和尾插均可,但头插无需遍历链表,实现更简洁。此外,插入前通常需判断待插入数据是否已存在于哈希表中(避免重复插入),而这一查找过程在极端情况下(所有元素均冲突到同一位置)仍需遍历整个链表,因此头插在整体效率上更具优势。对于哈希表 vector 同一位置冲突的元素,其在单链表中的前后顺序不影响哈希表的核心功能正确性。因为哈希表的查找、插入和删除操作均以 key 为索引,通过哈希函数定位到对应桶后,需遍历链表逐个比较 key 值,而链表节点的物理顺序不影响 key 的比较结果。因此,无论采用头插还是尾插,均不影响哈希表对元素的正确存取,但头插法在插入效率上更具优势。。头插法通过将新元素置于链表头部,可在频繁插入时减少通过找尾来完成插入操作进一步优化性能。
(2)问题2:如何进行扩容?开散列哈希表是否存在满了的概念?扩容时是否要考虑负载因子?
①解析:由于开散列哈希表不是直接使用 vector 存储数据键值对 pair<key,value>,而是通过 vector 存储链表头指针,由单链表来存储实际数据,因此理论上只要内存充足,单链表可以无限扩展。但当单链表过长时,会导致查找效率从 O (1) 退化为 O (n)。因此,虽然开散列哈希表不存在传统意义上的 “满” 概念,但为了保证查找效率,仍需通过负载因子(实际存储有效数据个数/哈希表大小)来触发扩容。
②对于负载因子的理解
- 负载因子越大,冲突的概率越高,查找效率越低,空间利用率越高。
- 负载因子越小,冲突的概率越低,查找效率越高,空间利用率越低。
解析:负载因子是衡量哈希表空间利用率与冲突概率的关键指标。负载因子越大,空间利用率越高,但冲突概率随之增加,导致链表长度增长,查找效率下降;反之,负载因子越小,冲突概率越低,查找效率越高,但空间利用率也越低。从概率角度看,负载因子越小意味着以更多空间换取更低的冲突概率,使得每个 key 对应的键值对更可能直接落在正确位置,减少冲突。
结论:负载因子的选择需根据实际场景权衡。对于闭散列哈希表,由于冲突时需线性探测其他位置,负载因子最好控制在 0.7 以下,否则冲突概率显著上升,严重影响性能。而开散列哈希表通过链表存储冲突元素,允许更高的负载因子。通常选择在负载因子等于 1 时扩容,即实际存储有效数据个数等于哈希表大小时。此时,理想情况下每个桶刚好挂一个节点,链表长度均为 1,查找效率最优。虽然开散列的负载因子可以大于 1(如 1.5),但超过 1 后链表平均长度将超过 1,导致查找效率下降。因此,选择负载因子等于 1 扩容,既能充分利用 vector 空间,又能避免链表过长影响性能。而闭散列哈希表的负载因子不能达到 1,因为此时 vector 已满,无法继续插入元素。
③思考在什么情况下进行扩容?
注:哈希表的桶包括空桶(对应头指针为nullptr
的空链表)和非空桶(对应存储节点的非空链表)。
桶的个数(即哈希表大小)是固定的,随着元素不断插入,每个桶对应的链表长度逐渐增加。极端情况下,可能导致某个桶的链表节点数量过多,使得查找、插入等操作的时间复杂度从 O (1) 退化为 O (n),严重影响哈希表性能。因此,需要通过合理的条件触发扩容,以平衡空间利用率与操作效率。
开散列哈希表的理想状态是每个桶的链表长度尽可能短,最佳情况是每个桶刚好挂一个节点(链表长度为 1)。此时,负载因子(元素个数 / 桶个数)为 1。若继续插入元素,必然会导致至少一个桶的链表长度超过 1,冲突概率上升。因此,选择在负载因子等于 1 时扩容是合理的:
- 当实际存储有效数据个数
_n
等于哈希表大小_tables.size()
时,说明平均每个桶对应 1 个节点,此时扩容可重新散列数据,避免链表进一步增长。 - 该条件既充分利用了哈希表空间(负载因子为 1 时空间利用率较高),又能防止链表过长导致性能下降。
扩容判断条件:
if (_n == _tables.size()) // 负载因子等于 1 时触发扩容
(3)扩容后调整映射关系的两种写法
注意事项:哈希表扩容后,其映射关系会发生改变。对于开散列哈希表而言,扩容后不能简单地将扩容前哈希表 vector
中存储的单链表头结点,直接拷贝到扩容后相同下标的 vector
存储位置。因为插入数据时,是依据每个插入数据的 key
通过哈希函数计算出哈希值(即存储位置),来决定将其插入到哪个哈希表存储位置对应的单链表中。一旦扩容,哈希函数会发生变化,旧表中存储的数据都必须基于新哈希函数重新计算存储位置。
- 注:为了方便展示,扩容后,图中采用的是尾插。
结论:只要哈希表大小发生改变,所有存储数据的映射关系都必须基于新哈希函数重新计算实际存储位置。
① 方式 1 - 效率欠佳的方法
此方法不直接对旧表扩容,而是创建新表并进行扩容,然后复用哈希表插入函数 HashTable::Insert
的代码逻辑,将旧表数据重新映射到扩容后的新表中,最后通过交换旧表和新表的 vector
资源,完成旧表的扩容。代码如下:
-
bool Insert(const pair<K, V>& kv) {if (Find(kv.first)){return false;}//负载因因子==1时扩容if (_n == _tables.size()){size_t newsize = _tables.size() == 0 ? 10 : _tables.size()*2;HashTable<K, V> newht;newht.resize(newsize);for (auto cur : _tables){while (cur){newht.Insert(cur->_kv);cur = cur->_next;}}_tables.swap(newht._tables);}//插入数据过程://计算插入数据存储位置size_t hashi = kv.first % _tables.size();//头插Node* newnode = new Node(kv);newnode->_next = _tables[hashi];_tables[hashi] = newnode;++_n;return true; }
缺点:
- 资源析构开销:新哈希表
newht
复用Insert
函数完成旧表数据迁移后,在旧表与新表交换vector
资源后,需要析构新哈希表newht
占用的资源。由于哈希表底层容器vector
存放的是结点地址,且每个非空结点地址指向一条单链表,若HashTable
类模板不自定义析构函数,编译器自动生成的默认析构函数只会释放vector
本身占用的资源,不会释放vector
中结点指针指向的单链表资源,从而导致内存泄漏。为解决此问题,必须自定义析构函数来手动释放单链表资源,这增加了额外开销。 - 额外空间开销:新哈希表
newht
在迁移旧表数据时,需要创建新结点来完成映射关系调整,会产生额外的空间占用,且后续还需销毁这些新创建的结点。
②方式2 - 对方式1的优化:
优化思路:方式 1 的不足在于创建新表后,新表与旧表资源交换时会导致旧表数据在新表析构时被释放。优化方法是,直接将旧表 vector<Node*> _tables
中的数据逐个取出,根据新哈希函数重新计算存储位置后,插入到新表(提前开辟好空间)中,取完数据后直接交换新表与旧表的资源,从而完成开散列哈希表扩容后映射关系的调整。这种方法类似于单链表的逆序思路,即依次将原链表中的结点取下,采用头插法插入到新链表中,从而实现原链表的逆序 。
优点:相较于方式 1,不需要删除旧表数据,避免了创建新结点带来的额外空间消耗,空间效率更高。
代码实现 — 注:当前哈希桶对应的单链表中,第一个结点(头结点)的指针存放在哈希表的vector 中。
-
bool Insert(const pair<K, V>& kv) {//若哈希表中已经存在插入数据kv,就不要继续插入了。if (Find(kv.first)){return false;}Hash hash;//负载因因子==1时扩容。即哈希表数据实际存储个数_n //与 哈希表大小 _tables.size()相等才进行扩容。//对于开散列哈希表来说,当负载因子等于1时就进行扩容。//注意:若哈希表使用编译器生成默认构造函数,则一定要在//成员变量size_t _n声明的地方给_n缺省值0,否则当//哈希表一开始为空_tables.size() = 0而成员变量_n为随机值//一定无法触发扩容操作而直接执行下面插入数据操作导致//非法访问内存风险。if (_n == _tables.size()){size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;//调用n个val构造函数初始化vector vector<Node*> newtables(newsize, nullptr);//新开个指针数组作为新表//for (Node*& cur : _tables)for (auto& cur : _tables)//遍历旧表{while (cur)//注:cur取到的是旧表单链表中结点{//把旧表单链表中结点取下来头插到新表映射的单链表中Node* next = cur->_next;//保存旧表的下一个结点//计算旧表数据在新表的存储位置(哈希值)size_t hashi = cur->_kv.first % newtables.size();//在旧表中取结点,头插到新表(注:cur指向旧表当前遍历到的结点)//旧表当前结点与新表单链表头结点进行链接cur->_next = newtables[hashi];//旧表当前结点取下来作为新表单链表新头结点存储到新表newtables中newtables[hashi] = cur;//移到到旧表下一个数据cur = next;}}//交换旧表和新表资源完成映射关系的调整操作_tables.swap(newtables);}//插入数据的操作//让数据的key通过哈希函数计算出哈希值找到插入数据在哈希表的存储位置,//然后把插入数据头插到存储位置对应的链表中。size_t hashi = kv.first % _tables.size();//头插Node* newnode = new Node(kv);//创建新结点newnode->_next = _tables[hashi];//新结点先与链表头结点进行链接_tables[hashi] = newnode;//新结点作为链表新头结点存放在哈希表的对应存储位置中 ++_n;return true; }
- 测试
-
2.2.Insert代码实现
bool Insert(const pair<K, V>& kv)
{if (Find(kv.first)){return false;}//负载因因子==1时扩容if (_n == _tables.size()){/*size_t newsize = _tables.size() == 0 ? 10 : _tables.size()*2;HashTable<K, V> newht;newht.resize(newsize);for (auto cur : _tables){while (cur){newht.Insert(cur->_kv);cur = cur->_next;}}_tables.swap(newht._tables);*/size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;vector<Node*> newtables(newsize, nullptr);//for (Node*& cur : _tables)for (auto& cur : _tables){while (cur){Node* next = cur->_next;size_t hashi = cur->_kv.first % newtables.size();//头插到新表cur->_next = newtables[hashi];newtables[hashi] = cur;cur = next;}}_tables.swap(newtables);}size_t hashi = kv.first % _tables.size();//头插Node* newnode = new Node(kv);newnode->_next = _tables[hashi];_tables[hashi] = newnode;++_n;return true;
}
3.析构函数 ~HashTable()
// 注意:即使显式编写开散列哈希表HashTable的析构函数,若不显式调用自定义成员变量
// vector的析构函数释放其占用的资源,编译器也会自动调用。因此,对于开散列哈希表
// HashTable的析构函数,仅需手动释放哈希表底层容器vector中存储的结点指针所指向的
// 非空单链表占用的资源即可。
~HashTable()
{// 此处使用引用是为了在删除哈希表vector中的单链表后,将vector// 每个存储位置设置为空指针,防止对野指针进行非法访问。for (auto& cur : _tables) { // 取哈希表底层容器vector中存放的数据结点指针赋值给curwhile (cur) // 注:cur是结点指针{ // 先记录下一个结点位置Node* next = cur->_next;// 再释放当前结点内存delete cur;// 最后移动到下一个结点cur = next;}cur = nullptr; // 将vector每个存储位置设置为空指针}
}
4.删除操作 Erase
注意事项:闭散列哈希表的删除操作需采用 “伪删除” 策略(标记为DELETE
),而非物理删除,因此可复用Find
接口定位目标位置。与闭散列不同,开散列哈希表的数据存储于单链表中,而单链表的删除操作需要知道目标结点的前驱结点才能修改指针关系。由于开散列的Find
接口仅返回目标结点指针,无法直接获取前驱结点,因此无法直接复用Find
完成删除,必须在遍历链表时同步记录前驱结点。
开散列哈希表的删除操作本质上等价于单链表的删除操作:
- 解析:开散列哈希表通过单链表存储冲突元素,删除目标数据时需先定位到对应哈希桶的单链表,再遍历链表找到目标结点并修改指针关系,其逻辑与单链表删除完全一致,需处理头结点和非头结点情况,并手动释放内存。
代码实现:
-
bool Erase(const K& key) {//计算待删除数据存储位置(哈希值)size_t hashi = key % _tables.size();//单链表删除Node* prev = nullptr;//记录当前结点的前一个结点Node* cur = _tables[hashi];//遍历当前结点//遍历单链表查找待删除结点while (cur){//若当前结点是待删除结点if (cur->_kv.first == key){//若prev为空说明当前删除结点没有前结点,则删除该结点就是头删if (prev == nullptr)//判断是否是头删{//注意:即使删除只有一个结点的单链表,删除后表中//存储位置会被cur->_next设置成空指针。_tables[hashi] = cur->_next;}else//不是头删{//当前删除结点的前一个结点和后一个结点进行链接prev->_next = cur->_next;}delete cur;//删除当前结点return true;//删除成功}else//若当前结点不是待删除结点就迭代往后走{prev = cur;//记录下一个结点的前一个结点cur = cur->_next;//指向下一个结点}}//遇到空,则说明删除失败,没有找到要删除的结点return false; }
5.查找操作 Find
开散列哈希表的查找操作 Find
本质上转化为对单链表的线性查找:
- 解析:开散列哈希表通过计算
key
的哈希值定位到对应桶的单链表后,需遍历该链表逐一比较结点的key
值,直至找到目标元素或遍历结束。此过程与单链表的查找逻辑完全一致,时间复杂度为 O (n),其中 n 为链表长度。
Node* Find(const K& key)
{//若哈希表是空表,则查找失败,返回空指针if (_tables.size() == 0)return nullptr;size_t hashi = key % _tables.size();Node* cur = _tables[hashi];//开散列哈希表查找方式变成链表查找方式while (cur){if (cur->_kv.first == key){return cur;}cur = cur->_next;}return nullptr;//查找失败返回空指针
}
6. 闭散列、开散列哈希表中处理非整型 key 的问题
分析:
- 在哈希表中,当存储的数据键值对
pair<key, value>
里的key
为string
类型而非整型时,无法直接运用除留余数法(取模操作符%
要求操作数为整型)来计算哈希值并确定键值对在哈希表中的存储位置。这是因为string
类用于管理字符数组,并非基本整型,不满足取模操作的要求。 - 虽然可以考虑取
string
类底层字符数组的第一个字符来进行取模,但哈希表是泛型模板,key
可能为多种类型,包括但不限于整型、string
以及其他自定义类型等。因此,不能仅针对string
类型来设计取模方式,而是需要一种通用的解决方案。 - 为解决不同类型的
key
如何通过取模计算存储位置的问题,可借助仿函数(类对象)。仿函数Hash
的作用是将各种类型的key
转换为可用于取模的整型,从而适配除留余数法,实现哈希值的计算与存储位置的确定 。
6.1.情况 1:key
本身为整型或可隐式转换为整型
若 key
本身是整型,或者能隐式类型转化成整型(如 char
、int
、float
、double
等,这些类型都能隐式转换为可用于取模的无符号整型 size_t
),则仿函数直接返回 key
本身作为除留余数法哈希函数可以取模的值。代码如下:
-
//该仿函数支持key转换成可以取模的无符号整型值。 //注意:由于下面提供了template<> struct HashFunc<string>关于K = string模板特化版本, //则若是普通类型就会走struct HashFunc,若K是string类型就会走模板特化版本struct HashFunc<string>。template<class K> struct HashFunc {size_t operator()(const K& key){return key;//在该仿函数中,返回值key可以是char、int、float、double等整型和浮点型, //对于返回值key是字符型char/浮点型loat、double都可以隐式类型转化成无符号//整型size_t。} };//类型模板参数Hash的缺省值是支持key可以(隐式类型)转 //换成可以取模的无符号整型的仿函数HashFunc<K>。 template<class K, class V, class Hash = HashFunc<K>> class HashTable {typedef HashNode<K, V> Node; private:vector<Node*> _tables; //指针数组size_t _n = 0; //存储有效数据个数 };
6.2 情况 2:key
为 string
类型
注意:当 key
本身为 string
类型时,则需显式编写仿函数,将 string
类型的 key
转换为可用于取模运算的无符号整型。
(1)类1:仿函数返回string key的第一个字符进行取模
注意事项:类 1 的写法存在缺陷。其一,若 string key
为空字符串,仿函数中 s[0]
的取值操作会导致越界访问,进而引发程序崩溃;其二,若所有存储数据键值对 pair<key, value>
中 string key
的第一个字符相同,按照该仿函数取第一个字符进行取模,会使这些键值对都发生哈希冲突。
简而言之,类 1 写法欠佳,原因在于 string key
可能为空字符串,或者所有 string key
的首字符相等,这两种情况均会引发哈希冲突问题。
struct HashStr
{size_t operator()(const string& s){return s[0];//取字符串key的第一个字符作为除留余数法哈希函数可以取模的值。}
};
(2)类2:改进方案
①写法1:仿函数直接返回string key所有字符相加后的结果作为可以取模的无符号整形
仿函数将 key
转换为可用于取模的整形值的原则:仿函数把不同类型的 key
转换成尽量不重复的无符号整形,以此作为除留余数法哈希函数中可进行取模运算的无符号整形值 。
当 key
为 string
类型时,哈希表会构建两层哈希(映射)关系 。首先,将字符串 string key
转换为可用于取模的整形,这一步建立的是值与值之间的映射(哈希)关系 ;随后,利用转换得到的整形值,通过哈希函数计算出在哈希表中的存储位置,这便建立起了值与存储位置之间的映射(哈希)关系,最终据此存放数据 。
注意:写法 1 存在一个问题:当字符种类和个数相同但字符顺序不同的string key
被转换成整形时,由于取模前的整形值相同,模运算后得到的哈希值也会相同,进而导致哈希冲突。例如,string key = "abcd"
和"bcda"
,其字符的 ASCII 码值之和均为97+98+99+100=394
,因此仿函数返回的供除留余数法取模的无符号整形值均为 394,最终会被映射到哈希表的同一个存储位置,引发冲突。
写法1应用场景:
- 写法 1 将字符串所有字符相加转换成整形,在实际应用中有诸多类似场景,以下以字符串比对为例进行说明:
在数据库中存储了大量 string 类型的家庭地址信息,当需要查找某个特定家庭地址是否存在于这些数据中时,若采用逐个字符比对的方式,对于较长的家庭地址,且数据库中地址数量众多的情况,时间复杂度会达到 O (N²),效率极低。
为提升查询效率,可引入字符串哈希的方法。其核心是将字符串映射为整形值,具体做法是把字符串中所有字符的 ASCII 码值累加起来,得到一个固定的整形值。这样,对于每个家庭地址字符串,都能转换为一个对应的整形值进行存储。
在查询时,先将目标家庭地址字符串转换为整形值,然后遍历数据库中的家庭地址字符串,每遍历一个,也将其转换为整形值,并与目标整形值进行比对。若两者不相等,则直接跳过,继续遍历下一个;若相等,再对这两个字符串进行逐个字符的精确比对,以确定是否完全一致。
例如,数据库中有 10000 个家庭地址字符串,当查找某个目标家庭地址时,若这 10000 个字符串转换后的整形值中,仅有 3 个与目标字符串转换后的整形值相等,那么只需对这 3 个字符串进行逐个字符的比对,而非对全部 10000 个字符串都进行精确比对,从而大幅提升了查询效率。
不过,这种方式存在整形值转换冲突的问题。即当不同字符串的字符种类和个数相同,但字符顺序不同时,转换得到的整形值可能相同。比如 “abcd” 和 “bcda”,它们字符的 ASCII 码值之和均为 97 + 98 + 99 + 100 = 394 。对于这类冲突,可在整形值比对相等后,再进行逐个字符的精确比对,以此来准确判断目标字符串是否存在。
这里将字符串转换成整形,本质上建立的是字符串值与整形值之间的映射关系,这就是哈希的概念。通过建立这种哈希(映射)关系,可以有效提高查询效率。而哈希表则是进一步建立值与存储位置之间的映射关系,其本质是将数据值映射到特定的存储位置,以便更高效地进行数据的存储和检索 。
②写法2:仿函数返回
注意:写法 1 在将字符串转换成整形时,会遇到冲突问题,即当字符串的字母相同且字母个数相同,但顺序不同时,其所有字母的 ASCII 码值之和可能恰好相同,从而引发哈希冲突。为减少此类冲突,可采用 BKDR 哈希法,具体实现如下:
把字符串转换成整形的相关哈希算法:
各种字符串Hash函数 - clq - 博客园 (cnblogs.com)
//下面仿函数使用模版全特化是为了显示实例化哈希表类模板HashTable定义对象时可以
//不用显示传仿函数HashFunc<string>就可以使用缺省值HashFunc<K>调用仿函数HashFunc<string>。
//例如:HashFunc<string int> hash这个哈希表对象传的模板参数K(对应key)是string,而模板
//参数V(对应value)是int,则hash哈希表对象的仿函数使用缺省值HashFunc<K>,由于是模版特化
//编译器会自动识别匹配缺省值HashFunc<K> 为 HashFunc<string>。
//注意事项,模板特化必须在普通模板template<K> struct HashFunc的基础上生成,即我们必须
//提供普通仿函数类模板编译器才会允许模板特化,若是没有提供直接写模板特化则编译器会直接报错。//该仿函数支持字符串类string的key可以转换成取模的无符号整形
template<>//模板特化
struct HashFunc<string>
{//BKDR哈希法size_t operator()(const string& s){size_t hash = 0;for (auto ch : s){hash += ch;//把string k所有字符加在一起hash *= 31;//每次计算出hash再乘以31。//也可以乘以31、131、1313、13131、131313.. }return hash;}
};template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{typedef HashNode<K, V> Node;
private:vector<Node*> _tables; //指针数组size_t _n = 0; //存储有效数据个数
};
结论:使用 BKDR 哈希法,对于字母和个数相同但顺序不同的字符串,转换得到的整形结果并不相同,且具有较好的散列性,能有效降低哈希冲突的概率。
注意事项:在哈希表中,常见的 key 类型为整形或字符串 string 。较少使用自定义类型(如结构体)作为 key,若使用自定义类型,需合理设计哈希函数。例如,当使用日期类作为 key 时,可将日期类的成员变量年、月、日相加,作为哈希取模的无符号整形值。
6.3. 情况 3:key 为结构体(自定义类型)
例如:key是个学生类(结构体),该类包含成员变量是string类型的学号,则我们就把学生类的学号所有字符都加起来作为仿函数返回可以取模的值。
总结:设计哈希函数的核心原则是:将不同类型的 key 尽可能转换成不重复的无符号整形值,作为除留余数法哈希函数可用于取模的输入,从而实现高效、均匀的哈希映射,减少哈希冲突,提升哈希表的性能和可靠性。
7.最大桶(链表)的长度 MaxBucketSize
该函数用于获取开散列哈希表中哈希桶(单链表)的最大长度。具体实现如下:
//最大哈希桶(单链表)的长度
size_t MaxBucketSize()
{size_t max = 0;for (size_t i = 0; i < _tables.size(); ++i){auto cur = _tables[i];size_t size = 0;while (cur){++size;cur = cur->_next;}//打印每个哈希桶(单链表)的长度//printf("[%d]->%d\n", i, size);if (size > max){max = size;}}return max;
}
该函数遍历哈希表的每个桶(单链表),统计每个桶中节点的数量,在遍历过程中记录下长度最大的桶的长度,并最终返回该最大长度。
8.开散列哈希表效率
增删查找的时间复杂度:
- 最坏情况:时间复杂度为 O (N)。当大部分数据在同一个位置产生冲突时,所有冲突的元素都集中在同一个单链表中,此时增删查找操作需要遍历该单链表,操作时间与链表长度(即数据量 N)成正比。但由于哈希表存在扩容机制,会重新调整数据的映射关系,使得数据更均匀地分布在各个桶中,因此这种最坏情况几乎不会发生。
- 平均情况:时间复杂度为 O (1) 。在理想情况下,哈希函数能将数据均匀地映射到各个桶中,每个桶中的元素数量较少,增删查找操作通常只需访问桶的头部或经过少量节点即可完成,操作时间近似为常数。
负载因子与扩容:哈希表设定只有当负载因子等于 1 时才进行扩容。当负载因子达到 1 时,最好的情况是所有单链表都只有一个结点,此时哈希表性能最优;但在正常情况下,平均每个单链表会有两三个结点 。
9.对某个哈希桶(单链表)长度很长这种极端场景下的解决方式
(1) 解决方案 1:使用负载因子控制
通过缩小扩容条件的负载因子,例如将其设置为小于 1 的值(如 0.75 ),可以在哈希表存储的元素数量相对较少时就触发扩容。扩容后,哈希表的桶数量增加,数据会被重新映射到更多的桶中,从而使每个桶中的元素数量减少,单链表长度缩短,以空间换时间,提升哈希表整体的操作效率。
(2) 解决方案 2:链表转红黑树
当单个桶(单链表)的长度超过一定阈值(如 8 )时,将该桶中的链表转换为红黑树。红黑树是一种自平衡二叉搜索树,其查找的时间复杂度最坏情况为 O (logN) ,相比链表在元素较多时查找效率更高。
要实现哈希表的存储位置既能挂单链表又能挂红黑树,可以采用以下设计思路:
定义一个联合体,将链表结点指针和树结点指针联合起来,使该联合体成为一个指针(指向链表结点或树结点)。然后,将哈希表底层容器vector
的存储类型改为一个结构体。该结构体的第一部分是上述联合体,联合体包含两个指针,一个指向树结点,一个指向链表结点;第二部分是记录桶长度的变量len
。当哈希表底层容器vector
中某个桶的长度len
超过 8 时,将该单链表中的值插入到红黑树中,并将构建好的红黑树挂在该存储位置。
(3) 总结
控制开散列哈希表存储位置对应单链表长度过长有两种主要方式:
- 缩小负载因子:更容易触发扩容,通过调整映射关系,使数据分布更均匀,从而缩短链表长度。
- 链表转红黑树:当某个存储位置的链表长度超过阈值(如 8 )时,将链表转换为红黑树,提升查找效率。
实际应用中,最好将这两种方式结合使用:一方面通过合理设置负载因子,提前预防链表过长;另一方面,对于因哈希冲突导致的个别过长链表,及时转换为红黑树,从而更有效地控制链表长度,提升哈希表的整体性能。
10.保持哈希表大小为素数
在哈希表中使用除留余数法时,若希望哈希函数模素数以降低冲突概率,关键在于确保哈希表大小始终为素数。
注意事项:直接通过倍数(如 2 倍)扩容难以保证哈希表大小始终为素数(例如初始素数为 53,2 倍扩容后为 106,是合数)。为解决这一问题,可借助预定义素数表实现动态素数扩容:
- 素数表中存储按一定规律递增的素数序列(如近似 2 倍递增),每次扩容时从表中选取大于当前表大小的最小素数作为新容量。
- 例如,素数表初始包含
53, 97, 193, ...
等素数,当当前表大小为 53 时,下次扩容选取 97(大于 53 的最小素数),确保新表大小仍为素数,从而持续发挥素数模的散列优势。
- 通过这一机制,既能避免倍数扩容导致的合数问题,又能通过素数的数学特性优化哈希函数的映射均匀性,最终降低冲突概率,提升哈希表性能。
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{typedef HashNode<K, V> Node;
public://获取当前哈希表需要扩容的大小//size_t newsize = GetNextPrime(_tables.size());//获取下次扩容大小(SGI版本)//参数prime表示当前哈希表大小,一开始哈希表初始状态是空表则prime初始为0。size_t GetNextPrime(size_t prime){//使用const定义常变量来定义数组大小static const int __stl_num_primes = 28;//定义无符号整形素数表(用const修饰数组就可以防止数组存储的数据被修改)static const unsigned long __stl_prime_list[__stl_num_primes] ={//注:下一个值是前一个值二倍的后面出现最近的素数。例如:第一个表大小为53,//第二个表大小97就是第一个值53二倍附近的素数。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//这个是无符号整形最大值};size_t i = 0;for (; i < __stl_num_primes; ++i){//找比当前素数值(即当前哈希表vector大小)要大的素数值作为下次扩容的大小if (__stl_prime_list[i] > prime)return __stl_prime_list[i];//返回比当前哈希表大小要大的素数作为下次哈希表需要扩容大小}return __stl_prime_list[i];}bool Insert(const pair<K, V>& kv){if (Find(kv.first)){return false;}Hash hash;//仿函数对象//负载因因子==1时扩容if (_n == _tables.size()){//从GetNextPrime接口获取当前哈希表需要扩容大小size_t newsize = GetNextPrime(_tables.size());vector<Node*> newtables(newsize, nullptr);//for (Node*& cur : _tables)for (auto& cur : _tables){while (cur){Node* next = cur->_next;size_t hashi = hash(cur->_kv.first) % newtables.size();//头插到新表cur->_next = newtables[hashi];newtables[hashi] = cur;cur = next;}}_tables.swap(newtables);}size_t hashi = hash(kv.first) % _tables.size();//头插Node* newnode = new Node(kv);newnode->_next = _tables[hashi];_tables[hashi] = newnode;++_n;return true;}private:vector<Node*> _tables; //指针数组size_t _n = 0; //存储有效数据个数
};
const 变量 可以定义数组大小,而普通变量却不行的原因:
- 静态数组定义格式:数据类型 数组名[常量表达式] = {初始化值列表};
const
修饰的变量具有常量属性 ,在编译阶段编译器就知道其值且确定不会改变,满足数组大小必须是常量表达式的要求。编译器能依据const
常变量的值在编译时准确为数组分配连续内存空间。- 而普通变量不行,是因为 C++ 规定数组大小必须是编译期就能确定值的常量表达式 。普通变量的值在运行时才确定,编译阶段其值不确定。若用普通变量定义数组大小,编译器无法提前知晓数组所需内存空间大小,不能正确分配内存,所以会编译报错。
11.开散列(哈希表)代码实现
namespace HashBucket
{template<class K, class V>struct HashNode{HashNode<K, V>* _next;pair<K, V> _kv;HashNode(const pair<K, V>& kv):_next(nullptr), _kv(kv){}};template<class K>struct HashFunc{size_t operator()(const K& key){return key;}};//特化template<>struct HashFunc<string>{//BKDR哈希算法 - 把字符串转换成整形size_t operator()(const string& s){size_t hash = 0;for (auto ch : s){hash += ch;hash *= 31;}return hash;}};template<class K, class V, class Hash = HashFunc<K>>class HashTable{typedef HashNode<K, V> Node;public:~HashTable(){for (auto& cur : _tables){while (cur){Node* next = cur->_next;delete cur;cur = next;}cur = nullptr;}}Node* Find(const K& key){if (_tables.size() == 0)return nullptr;Hash hash;//仿函数对象size_t hashi = hash(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 hash;//仿函数对象size_t hashi = hash(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;return true;}else{prev = cur;cur = cur->_next;}}return false;}//size_t newsize = GetNextPrime(_tables.size());size_t GetNextPrime(size_t prime)//SGI版本{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};size_t i = 0;for (; i < __stl_num_primes; ++i){if (__stl_prime_list[i] > prime)return __stl_prime_list[i];}return __stl_prime_list[i];}bool Insert(const pair<K, V>& kv){if (Find(kv.first)){return false;}Hash hash;//仿函数对象//负载因因子==1时扩容if (_n == _tables.size()){//扩容后,映射关系调整操作的两种写法//写法1:/*size_t newsize = _tables.size() == 0 ? 10 : _tables.size()*2;HashTable<K, V> newht;newht.resize(newsize);for (auto cur : _tables){while (cur){newht.Insert(cur->_kv);cur = cur->_next;}}_tables.swap(newht._tables);*///写法2://size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;//哈希表大小是个普通数size_t newsize = GetNextPrime(_tables.size());//哈希表大小是个素数vector<Node*> newtables(newsize, nullptr);//for (Node*& cur : _tables)for (auto& cur : _tables){while (cur){Node* next = cur->_next;size_t hashi = hash(cur->_kv.first) % newtables.size();//模的值是个素数//头插到新表cur->_next = newtables[hashi];newtables[hashi] = cur;cur = next;}}_tables.swap(newtables);}size_t hashi = hash(kv.first) % _tables.size();//头插Node* newnode = new Node(kv);newnode->_next = _tables[hashi];_tables[hashi] = newnode;++_n;return true;}size_t MaxBucketSize(){size_t max = 0;for (size_t i = 0; i < _tables.size(); ++i){auto cur = _tables[i];size_t size = 0;while (cur){++size;cur = cur->_next;}//printf("[%d]->%d\n", i, size);if (size > max){max = size;}}return max;}private:vector<Node*> _tables; //指针数组size_t _n = 0; //存储有效数据个数};void TestHashTable1(){int a[] = { 3, 33, 2, 13, 5, 12, 1002 };HashTable<int, int> ht;for (auto e : a){ht.Insert(make_pair(e, e));}ht.Insert(make_pair(15, 15));ht.Insert(make_pair(25, 25));ht.Insert(make_pair(35, 35));ht.Insert(make_pair(45, 45));}void TestHashTable2(){int a[] = { 3, 33, 2, 13, 5, 12, 1002 };HashTable<int, int> ht;for (auto e : a){ht.Insert(make_pair(e, e));}ht.Erase(12);ht.Erase(3);ht.Erase(33);}struct HashStr{//BKDRsize_t operator()(const string& s){size_t hash = 0;for (auto ch : s){hash += ch;hash *= 31;}return hash;}};void TestHashTable3(){//HashTable<string, string, HashStr> ht;HashTable<string, string> ht;ht.Insert(make_pair("sort", "排序"));ht.Insert(make_pair("string", "字符串"));ht.Insert(make_pair("left", "左边"));ht.Insert(make_pair("right", "右边"));ht.Insert(make_pair("", "右边"));HashStr hashstr;cout << hashstr("abcd") << endl;cout << hashstr("bcda") << endl;cout << hashstr("aadd") << endl;cout << hashstr("eat") << endl;cout << hashstr("ate") << endl;}void TestHashTable4(){size_t N = 900000;HashTable<int, int> ht;srand(time(0));for (size_t i = 0; i < N; ++i){size_t x = rand()+i;ht.Insert(make_pair(x, x));}cout << ht.MaxBucketSize() << endl;}
}