1. 哈希概念
顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。而顺序查找时间复杂度为 O ( N ) O(N) O(N),在平衡树中查找的时间复杂度为树的高度即 O ( l o g ) O(log) O(log),搜索的效率取决于搜索过程中元素的比较次数。
最为理想的搜索方法是,可以不经过任何比较,一次直接从表中得到要搜索的元素,即查找的时间复杂度为 O ( 1 ) O(1) O(1)。
如果构造一种存储结构,能通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一种映射的关系,那么在查找时通过该函数可以很快找到该元素。
向该结构当中插入和搜索元素的过程如下:
- 插入元素: 根据待插入元素的关键码,用此函数计算出该元素的存储位置,并将元素存放到此位置。
- 搜索元素: 对元素的关键码进行同样的计算,把求得的函数值当作元素的存储位置,在结构中按此位置取元素进行比较,若关键码相等,则搜索成功。
该方式即为哈希(散列)方法, 哈希方法中使用的 转换函数 称为 哈希(散列)函数,构造出来的结构称为 哈希表(散列表)。
🍑 举例说明
现在有这样一组数据集合 {1, 7, 6, 4, 5, 9}
。
并且把哈希函数设置为:hash(key) = key % capacity
(其中 capacity 为存储元素底层空间总的大小)。
然后我们把该集合存储在 capacity 为 10 的哈希表中,则各元素存储位置对应如下:
用该方法进行搜索不必进行多次关键码的比较,因此搜索的速度比较快,但是也会存在一些问题。
如果现在向集合中插入元素 66,会出现什么问题?
2. 哈希冲突
不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为 哈希冲突 或 哈希碰撞,把具有不同关键码而具有相同哈希地址的数据元素称为 同义词。
如果此时再将元素 66 插入到上面的哈希表,因为元素 66 通过该哈希函数计算得到的哈希地址与元素 6 相同,都是下标为 6 的位置,那么就会产生哈希冲突。
发生哈希冲突该如何处理呢?
3. 哈希函数
引起哈希冲突的主要原因可能是:哈希函数设计不够合理。
哈希函数设计原则:
- 哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有 m 个地址时,其值域必须在 0 到 m-1 之间。
- 哈希函数计算出来的地址能均匀分布在整个空间中。
- 哈希函数应该比较简单。
🍑 常见哈希函数
(1)直接定址法(常用)
取关键字的某个线性函数为散列地址:Hash(Key) = A*Key + B
优点:简单、均匀(每个值都有一个唯一位置,效率很高,每个都是一次就能找到)
缺点:需要事先知道关键字的分布情况
使用场景:适合查找数据比较小且连续的情况
(2)除留余数法(常用)
设散列表中允许的地址数为 m,取一个不大于 m,但最接近或者等于 m 的质数 p 作为除数,按照哈希函数:Hash(key) = key % p
(p <= m),将关键码转换成哈希地址。
优点:使用场景广泛,不受限制。
缺点:存在哈希冲突,需要解决哈希冲突,哈希冲突越多,效率下降越厉害。
(3)平方取中法(了解)
假设关键字为 1234,对它平方就是 1522756,抽取中间的 3 位 227 作为哈希地址。
使用场景:不知道关键字的分布,而位数又不是很大的情况。
(4)折叠法(了解)
折叠法是将关键字从左到右分割成位数相等的几部分(最后一部分位数可以短些),然后将这几部分叠加求和,并按哈希表表长,取后几位作为哈希地址。
使用场景:折叠法适合事先不需要知道关键字的分布,或关键字位数比较多的情况。
(5)随机数法(了解)
选择一个随机函数,取关键字的随机函数值为它的哈希地址,即 Hash(Key) = random(Key)
,其中 random 为随机数函数。
使用场景:通常应用于关键字长度不等时。
(6)数学分析法(了解)
设有 n 个 d 位数,每一位可能有 r 种不同的符号,这 r 种不同的符号在各位上出现的频率不一定相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,或者在某些位上分布不均匀只有某几种符号经常出现。此时,可根据散列表的大小,选择其中各种符号分布均匀的若干位作为散列地址。
举个例子:假设要存储某家公司员工登记表,如果用手机号作为关键字,那么极有可能前 7 位都是相同的,那么我们可以选择后面的四位作为散列地址。
如果这样的抽取方式还容易出现冲突,还可以对抽取出来的数字进行反转 (如 1234 改成 4321)、右环位移 (如 1234 改成 4123)、左环移位、前两数与后两数叠加 (如 1234 改成 12+34=46 ) 等方法。
数字分析法通常适合处理关键字位数比较大的情况,或事先知道关键字的分布且关键字的若干位分布较均匀的情况。
注意:哈希函数设计的越精妙,产生哈希冲突的可能性越低,但是无法避免哈希冲突。
4. 哈希冲突解决
解决哈希冲突有两种常见的方法是:闭散列 和 开散列。
🍑 闭散列(开放定址法)
闭散列,也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表种必然还有空位置,那么可以把产生冲突的元素存放到冲突位置的 下一个 空位置中去。
那如何寻找下一个空位置呢?常见的方式有以下两种。
🍅 线性探测
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
查找公式:hashi = hash(key) % N + i
。
其中:
-
hashi
:冲突元素通过线性探测后得到的存放位置。 -
hash(key) % N
:通过哈希函数对元素的关键码进行计算得到的位置。 -
N
:哈希表的大小 -
i
从 1、2、3、4…一直自增 - 注意:表里面可以存 key,也可以存储 key/value 的 pair 对象。
举个例子,现在有这样一组数据集合 {10, 25, 3, 18, 54, 999}
我们用除留余数法将它们插入到表长为 10 的哈希表中。
现在需要插入新元素 44,先通过哈希函数计算哈希地址,hashAddr 为 4,因此 44 理论上应该插在该位置,但是该位置已经放了值为 4 的元素,即发生哈希冲突。
然后我们使用线性探测的计算公式 hashi = 44 % 10 + 1 = 5
但是下标为 5 的位置已经被占用了,那么继续计算 hashi = 44 % 10 + 2 = 6
下标为 6 的位置没有被占用,那么就把 44 插入到该位置。
如果随着哈希表中数据的增多,产生哈希冲突的可能性也随着增加,假设现在要把 33 进行插入,那么会连续出现四次哈希冲突。
我们将数据插入到有限的空间,那么空间中的元素越多,插入元素时产生冲突的概率也就越大,冲突多次后插入哈希表的元素,在查找时的效率必然也会降低。因此,哈希表当中引入了负载因子(载荷因子):
- 散列表的载荷因子定义为:α=填入表中的元素个数/散列表的长度
- 负载因子越大,产出冲突的概率越高,增删查改的效率越低。
- 负载因子越小,产出冲突的概率越低,增删查改的效率越高。
假设我们现在将哈希表的大小改为 20,再把上面的数据重新插入,可以看到完全没有产生的哈希冲突:
但是,如果负载因子越小,也就意味着空间的利用率越低,此时大量的空间实际上都被浪费了。对于开放定址法,荷载因子是特别重要因素,应严格限制在 0.7~0.8
以下。超过 0.8 时,查表时的 CPU 缓存不命中(cache missing)按照指数曲线上升。
因此,一些采用开放定址法的 hash 库,如 Java 的系统库限制了荷载因子为 0.75,超过此值将 resize(增容) 散列表。
总结:
- 线性探测优点:实现非常简单,
- 线性探测缺点:一旦发生哈希冲突,所有的冲突连在一起,容易产生数据 堆积,即:不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降低。
如何缓解呢?
🍅 二次探测
线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法为:hashi = hash(key) % N + i^2
(i = 1、2、3、4…)
-
hashi
:冲突元素通过线性探测后得到的存放位置。 -
hash(key) % N
:通过哈希函数对元素的关键码进行计算得到的位置。 -
N
:哈希表的大小 -
i
从 1、2、3、4…一直自增,每次加的是 i 2 i^2 i2 - 注意:表里面可以存 key,也可以存储 key/value 的 pair 对象。
举个例子,现在有这样一组数据集合 {10, 21, 25, 18, 999}
我们用除留余数法将它们插入到表长为 10 的哈希表中。
现在需要插入新元素 60,先通过哈希函数计算哈希地址,hashAddr 为 0,因此 60 理论上应该插在该位置,但是该位置已经放了值为 10 的元素,即发生哈希冲突。
然后我们使用线性探测的计算公式 hashi = 60 % 10 + 0^2 = 0
但是下标为 0 的位置已经被占用了,那么继续计算 hashi = 60 % 10 + 1^2 = 1
下标为 1 的位置也被占用,那么继续计算 hashi = 60 % 10 + 2^2 = 4
下标为 4 的位置没有被占用,那就把 60 插入到该位置。
采用二次探测为产生哈希冲突的数据寻找下一个位置,相比线性探测而言,采用二次探测的哈希表中元素的分布会相对稀疏一些,不容易导致数据堆积。
和线性探测一样,采用二次探测也需要关注哈希表的负载因子,例如,采用二次探测将上述数据插入到表长为 20 的哈希表种,产生冲突的次数也会有所减少:
研究表明:当表的长度为质数且表装载因子不超过 0.5 时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子不超过 0.5,如果超出必须考虑增容。
因此:闭散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷。
🍑 开散列(链地址法)
开散列,又叫链地址法(开链法或者哈希桶),首先对关键码集合用哈希函数计算哈希地址,把具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
举个例子,现在有这样一组数据集合 {40, 5, 10, 21, 66, 55, 50, 18, 27}
我们用除留余数法将它们插入到表长为 10 的哈希表中,当发生哈希冲突时我们采用开散列的形式,将哈希地址相同的元素都链接到同一个哈希桶下,插入过程如下:
闭散列解决哈希冲突,采用的是一种报复的方式,“我的位置被占用了我就去占用其他位置”。而开散列解决哈希冲突,采用的是一种乐观的方式,“虽然我的位置被占用了,但是没关系,我可以挂在这个位置下面”。
与闭散列不同的是,这种将相同哈希地址的元素通过单链表链接起来,然后将链表的头结点存储在哈希表中的方式,不会影响与自己哈希地址不同的元素的增删查改的效率,因此开散列的负载因子相比闭散列而言,可以稍微大一点。
- 闭散列的开放定址法,负载因子不能超过 1,一般建议控制在
0.0 ~ 0.7
之间。 - 开散列的哈希桶,负载因子可以超过 1,一般建议控制在
0.0 ~ 1.0
之间。
在实际中,开散列的哈希桶结构比闭散列更实用,主要原因有两点:
- 哈希桶的负载因子可以更大,空间利用率高。
- 哈希桶在极端情况下还有可用的解决方案。
哈希桶的极端情况就是,所有元素全部产生冲突,最终都放到了同一个哈希桶中,此时该哈希表增删查改的效率就退化成 O ( N ) O(N) O(N):
这时,我们可以考虑将这个桶中的元素,由单链表结构改为红黑树结构,并将红黑树的根结点存储在哈希表中。
在这种情况下,就算有十亿个元素全部冲突到一个哈希桶中,我们也只需要在这个哈希桶中查找 30 次左右,这就是所谓的 桶里种树 。
为了避免出现这种极端情况,当桶当中的元素个数超过一定长度,有些地方就会选择将该桶中的单链表结构换成红黑树结构。
在JDK1.7中,HashMap 由 数组 + 链表 组成,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的。
在 JDK1.8 中,HashMap 由 数组 + 链表 + 红黑树 组成。当链表过长,则会严重影响 HashMap 的性能,红黑树搜索时间复杂度是 O ( l o g N ) O(logN) O(logN),而链表是 O ( n ) O(n) O(n)。因此,JDK1.8 对数据结构做了进一步的优化,引入了红黑树,链表和红黑树在达到一定条件会进行转换:当链表超过 8 且数组长度(数据总量)超过 64 才会转为红黑树。
将链表转换成红黑树前会判断,如果当前数组的长度小于64,那么会选择先进行数组扩容,而不是转换为红黑树,以减少搜索时间。
但有些地方也会选择不把桶中的单链表结构换成红黑树结构,因为随着哈希表中数据的增多,该哈希表的负载因子也会逐渐增大,最终会触发哈希表的增容条件,此时该哈希表当中的数据会全部重新插入到另一个空间更大的哈希表,此时同一个桶当中冲突的数据个数也会减少,因此不做处理问题也不大。
5. 闭散列实现
🍑 定义结构
思考一下哈希表的结构应该定义成什么样子呢?用什么样的方式来存储呢?
举个例子,有一组集合 {10, 25, 18, 999}
我现在要用 除留余数法 的方式把它们插入到哈希表中:
接着我们又插入一组数据 {20, 30, 50}
,用除留余数法计算的时候,发现这 3 个数都应该插入到下标为 0 的位置,但是该位置已经被占用了呀,所以我们选择 线性探测 的方式,插入到哈希表中:
接着我们判断哈希表中是否存在元素 50:
- 先通过除留余数法求得元素 50 在该哈希表中的哈希地址为 0
- 然后从 0 下标开始向后进行查找,若找到了 50 则说明存在
- 在向后查找的过程中发现元素 50 在 3 下标的位置,查找成功,说明元素 50 存在
接着我们继续判断哈希表中是否存在元素 60:
- 先通过除留余数法求得元素 60 在该哈希表中的哈希地址为 0
- 然后从 0 下标开始向后进行查找,若找到了 60 则说明存在
- 在向后查找的过程中发现了一个空位置,说明这个空位置的后面不会再有从下标 0 位置开始冲突的元素了,即查找失败,说明元素 60 不存在
所以我们可以总结一下:在哈希表中查找元素 x
时,先计算 x
在哈希表中的存储位置,如果该位置上存的不是要查找的元素 x
,那么就从该地址开始向后查找,在查找的过程中,如果遇到了空位置,说明该 x
不存在;反之,在向后查找的过程中,一定会找到元素 x
。
这个结论是这样吗?一定能找得到吗?
现在我们把元素 20 从哈希表中删除,然后再查找元素 50,通过计算求得元素 50 在该哈希表中的哈希地址为 0,从 0 下标开始向后查找,但是在 1 下标就遇到了空位置,如果按照刚刚的结论,查找就应该停下来,说明并没有找到元素 50,但是元素 50 却在哈希表中存在。
基于此,我们可以为哈希表中的每一个位置设置一个状态,并且每个位置的状态应该有三种可能:
- 哈希表的初始状态为 EMPTY(无数据的空位置)
- 把一个元素插入到哈希表中时,把该元素存储的位置的状态设置为 EXIST(已存储数据)
- 把一个元素从哈希表中删除时,把该元素存储的位置的状态设置为 DELETE(原本有数据,但现在被删除了)。
这样一来,当我们在哈希表中查找元素的过程中,若当前位置的元素与待查找的元素不匹配,但是当前位置的状态是 EXIST 或是 DELETE,那么我们都应该继续往后进行查找,而当我们插入元素的时候,可以将元素插入到状态为 EMPTY 或是 DELETE 的位置。
那么我们可以用枚举定义这三个状态。
// 哈希表每个空间给个标记
enum State
{
EMPTY, // 空
EXITS, // 存在
DELETE // 删除
};
因此,闭散列的哈希表中的每个位置存储的结构,应该包括所给数据和该位置的当前状态,那么就可以定义一个结构体。
// 每个位置存储的结构
template<class K, class V>
struct HashData
{
pair<K, V> _kv; // KV结构
State _state = EMPTY; // 数据的状态(默认设为空)
};
对于哈希表的结构用数组来存在再合适不过了,所以我们选择用前面学过的 vector 来实现。
在介绍闭散列的时候,提到过负载因子,而负载因子的计算是用 填入表中的元素个数/散列表的长度,所以在定义哈希表的时候,还应该增加一个变量,用来存储整个哈希表中的有效元素个数,这样一来,当负载因子过大时,就应该进行哈希表的增容。
// 哈希表
template<class K, class V>
class HashTable
{
typedef HashData<K, V> Data;
public:
// 插入函数
bool Insert(const pair<K, V>& kv);
// 查找函数
Data* Find(const K& key);
// 删除函数
bool Erase(const K& key);
private:
vector<Data> _tables; // vector里面存的是一个结构体数组
size_t _n = 0; // 存储关键字的个数
};
🍑 哈希函数
大家有没有发现,我们上面举的例子都是整型,那么如果出现字符串,该怎样计算哈希地址呢?比如我们使用 unordered_map 容器统计水果出现的次数时,就需要用各个水果的名字作为键值。
字符串并不是整型,也就意味着字符串不能直接用于计算哈希地址,我们需要通过某种方法将字符串转换成整型后,才能代入哈希函数计算哈希地址。
但是我们无法找到一种能实现字符串和整型之间一对一转换的方法,因为在计算机中,整型的大小是有限的,比如用无符号整型能存储的最大数字是 4294967295,而众多字符能构成的字符串的种类却是无限的。所以,无论我们用什么方法将字符串转换成整型,都会存在哈希冲突,只是产生冲突的概率不同而已。
所谓 “前人栽树,后人乘凉”,大佬们早已经研究发明出各种将字符串转为整型的 hash 函数,我们只需要拿过来使用即可,具体可以参考这篇文章 字符串哈希函数
因此,我们需要在哈希表的模板参数中再增加一个仿函数,用于将键值 key 转换成对应的整型。
template<class K, class V, class HashFunc = DefaultHash<K>>
class HashTable
如果 key 是一个整型,我们则使用默认的仿函数,该默认仿函数直接返回键值 key 即可;
如果 key 是一个字符串,我们则使用字符串仿函数,该仿函数就会根据算法返回一个对应的整型,但是用字符串作为键值 key 是比较常见的,因此我们可以针对 string 类型写一个类模板的特化,也就是说 DefaultHash<K>
是特化版本的哈希。
// 默认是无符号整型的仿函数
// 整形数据不需要转化
template<class K>
struct DefaultHash
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
// 字符串仿函数
// 当key是string的时候,default就是特化版本的哈希
// key为字符串类型,需要将其转化为整形
template<>
struct DefaultHash<string>
{
size_t operator()(const string& key)
{
// 如果是一个字符串,需要先把它转成整数
size_t hash = 0;
for (auto ch : key)
{
hash = hash * 131 + ch;
}
return hash;
}
};
🍑 插入函数
哈希表的插入很简单,步骤如下:
- 在插入前,先判断哈希表中是否存在该键值的键值对,若存在,则插入失败。
- 然后通过负载因子判断是否需要调整哈希表的大小,若哈希表的大小为 0 或负载因子过大都需要对哈希表的大小进行调整。
- 接着,将键值对插入到哈希表中。
- 最后哈希表中的有效元素个数加一。
对于负载因子的调整,方法如下:
- 如果若哈希表的大小为 0,则将哈希表的初始大小设置为 10。
- 如果哈希表的负载因子大于 0.7,说明哈希表不为空,那么就扩大到原来的 2 倍:
- 接着创建一个新的哈希表,该哈希表的大小为原哈希表扩到 2 倍以后的大小
- 然后遍历原哈希表,将原哈希表中的数据插入到新哈希表
- 最后将原哈希表与新哈希表交换即可。
- 在将原哈希表的数据插入到新哈希表的过程中,不能只是简单的将原哈希表中的数据直接挪到新哈希表中,而是需要根据新哈希表的大小重新计算每个数据在新哈希表中的存储位置,然后再进行插入。
将元素插入哈希表的具体步骤如下:
- 通过哈希函数计算出对应的哈希地址
- 若产生哈希冲突,则从哈希地址处开始,采用线性探测向后寻找一个状态为 EMPTY 或 DELETE 的位置
- 将键值对插入到该位置,并将该位置的状态设置为 EXIST
🍅 动图演示
先插入元素 35:
再插入元素 66:
🍅 代码实现
代码示例
// 插入函数
bool Insert(const pair<K, V>& kv)
{
// 1.判断哈希表中是否存在相同的键值对
if (Find(kv.first))
{
return false; // 插入失败
}
// 2.调整负载因子,如果大于等于0.7,就扩容
if (_tables.size() == 0 || _n * 10 / _tables.size() >= 7)
{
// 如果是空哈希表,那么初始化为10
// 如果不是空哈希表,那么就扩大到原来的2倍
size_t newSize = _tables.size() == 0 ? 10 : _tables.size() * 2;
// 扩容以后,需要重新映射
HashTable<K, V, HashFunc> newHT;
newHT._tables.resize(newSize);
// 遍历旧表,将原哈希表当中的数据插入到新哈希表中
for (auto& e : _tables)
{
if (e._state == EXITS)
{
newHT.Insert(e._kv);
}
}
// 最后再交换新表和旧表中的数据
newHT._tables.swap(_tables);
}
// 3.将键值对插入哈希表
// a.先取模计算插入的位置
HashFunc hf; // 用仿函数来判断key的类型
size_t starti = hf(kv.first); // 拿到第一个数据
starti %= _tables.size(); // 去模上表的容量(除数不能是capacity)
size_t hashi = starti;
size_t i = 1;
// b.找到一个状态为EMPTY或DELETE的位置
while (_tables[hashi]._state == EXITS)
{
hashi = starti + i; // 线性探测
//hashi = starti + i * i; // 二次探测
++i;
hashi %= _tables.size(); // 防止下标超出哈希表范围
}
// c.将数据插入该位置,并将该位置的状态设置为EXIST
_tables[hashi]._kv = kv;
_tables[hashi]._state = EXITS;
_n++; // 哈希表中的有效元素个数加一
// 插入成功
return true;
}
刚刚说到,在将原哈希表的数据插入到新哈希表的过程中时,不能只是简单的将原哈希表中的数据直接挪到新哈希表中,而是需要根据新哈希表的大小重新计算每个数据在新哈希表中的存储位置,然后再进行插入。
上面代码中用到的方法是 newHT.Insert(e._kv)
,也就是让新的哈希表去调用 insert
函数,那么这种方法对吗?
当然没问题,因为新哈希表的大小已经是原哈希表的 2 倍了,就算重新插入原哈希表中的所有数据,也不会再去扩容了。
🍑 查找函数
在哈希表中查找数据的步骤如下:
- 先判断哈希表的大小是否为 0,若为 0 则查找失败。
- 然后通过哈希函数计算出查找元素对应的哈希地址。
- 最后从哈希地址处开始,采用线性探测的方式向后进行查找:
- 在查找的过程中,如果找到位置状态为 EXIST,并且 key 值匹配的元素,说明查找成功
- 如果只是 key 值匹配,但该位置当前状态为 DELETE,说明该位置的元素已经被删除了,那么需要继续进行查找
- 如果找到一个状态为 EMPTY 的位置,说明查找失败
🍅 动图演示
查找元素 14(存在)
查找元素 64(不存在),可以看到它是先计算 64 的哈希地址为 4,然后从下标 4 的位置开始向后查找
🍅 代码实现
代码示例
// 查找函数
Data* Find(const K& key)
{
// 1.如果哈希表大小为0,则查找失败
if (_tables.size() == 0)
{
return nullptr;
}
// 2.开始查找
HashFunc hf; // 用仿函数来判断key的类型
size_t starti = hf(key);
starti %= _tables.size();
size_t hashi = starti;
size_t i = 1;
// 直到找到空位置就停下来
while (_tables[hashi]._state != EMPTY)
{
// 如果该位置的状态不是DELETE,并且key值匹配,则查找成功
if (_tables[hashi]._state != DELETE && _tables[hashi]._kv.first == key)
{
return &_tables[hashi]; // 那么直接返回该位置的地址
}
hashi = starti + i; //线性探测
//hashi = starti + i * i; // 二次探测
++i;
hashi %= _tables.size(); // 防止下标超出哈希表范围
}
// 直到找到空位置时,还没有找到目标元素,说明查找失败
return nullptr;
}
🍑 删除函数
删除哈希表中的元素非常简单,我们只需要进行伪删除即可,也就是将待删除元素所在位置的状态设置为 DELETE。
在哈希表中删除数据的步骤如下:
- 先查看哈希表中是否存在该键值的键值对:
- 若存在,则将该键值对所在位置的状态改为 DELETE 即可。
- 若不存在,则删除失败。
- 然后把哈希表中的有效元素个数减一。
🍅 动图演示
删除元素 63
删除元素 64
删除元素 84(不存在)
🍅 代码实现
代码示例
// 删除函数
bool Erase(const K& key)
{
// 1.查看哈希表中是否存在该键值的键值对
Data* ret = Find(key);
if (ret) // 如果存在
{
ret->_state = DELETE; // 则将该键值对所在位置的状态改为DELETE即可
--_n; // 哈希表中的有效元素个数减一
return true;
}
else // 如果不存在
{
return false; // 返回false
}
}
那么这种删除方式会存在空间的浪费吗?
答案是不会的。因为我们在插入数据时,是把数据插入到状态为 DELETE 或者 EMPTY 的位置上去的,当插入成功以后,就会把该数据覆盖。
6. 开散列实现
🍑 定义结构
在开散列的哈希表中,哈希表的每个位置存储的实际上是某个单链表的头结点,即每个哈希桶中存储的数据实际上是一个结点类型,该结点类型除了存储所给数据之外,还需要存储一个结点指针用于指向下一个结点。
// 每个哈希桶中存储数据的结构
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)
{}
};
与闭散列的哈希表不同的是,在实现开散列的哈希表时,我们不用为哈希表中的每个位置设置一个状态字段,因为在开散列的哈希表中,我们将哈希地址相同的元素都放到了同一个哈希桶中,并不需要经过探测寻找所谓的 下一个位置。
哈希表的开散列实现方式,在插入数据时也需要根据负载因子判断是否需要增容,所以我们也应该时刻存储整个哈希表中的有效元素个数,当负载因子过大时就应该进行哈希表的增容。
同样,我们还是在哈希表的模板参数中再增加一个仿函数,用于将键值 key 转换成对应的整型。
// 哈希表
template<class K, class V, class HashFunc = DefaultHash<K>>
class HashTable
{
typedef HashNode<K, V> Node;
public:
// 析构函数
~HashTable();
// 插入函数
bool Insert(const pair<K, V>& kv);
// 查找函数
Node* Find(const K& key);
// 删除函数
bool Erase(const K& key);
private:
// 指针数组
vector<Node*> _tables; // 哈希表
size_t _n = 0; // 哈希表中的有效元素个数
};
🍑 插入函数
哈希表的插入很简单,步骤如下:
- 在插入前,先判断哈希表中是否存在该键值的键值对,若存在,则插入失败。
- 然后判断如果哈希表的元素个数刚好等于哈希桶的个数时,需要对哈希表进行增容。
- 接着,将键值对插入到哈希表中。
- 最后哈希表中的有效元素个数加一。
注意:这里和闭散列不同的时,增容不需要判断负载因子,为什么呢?
因为桶的个数是一定的,随着元素的不断插入,每个桶中元素的个数不断增多,极端情况下,可能会导致一个桶中链表节点非常多,会影响的哈希表的性能,因此在一定条件下需要对哈希表进行增容,开散列最好的情况是:每个哈希桶中刚好挂一个节点,再继续插入元素时,每一次都会发生哈希冲突,因此,在元素个数刚好等于桶的个数时,可以给哈希表增容。
哈希表的增容方式如下:
- 若哈希表的大小为 0,则将哈希表的初始大小设置为 10。
- 若哈希表的大小不为 0,则先创建一个新的哈希表,该哈希表的大小为原哈希表的 2 倍,之后遍历原哈希表,将原哈希表中的数据插入到新哈希表,最后将原哈希表与新哈希表交换即可。
注意:在将原哈希表的数据插入到新哈希表的过程中,不能通过 复用插入函数 将原哈希表中的数据插入到新哈希表,如果复用 insert
的话,在这个过程中我们需要创建相同数据的结点插入到新哈希表,在插入完毕后还需要将原哈希表中的结点进行释放,有点太冗余了。
实际上,我们只需要 遍历原哈希表的每个哈希桶,通过哈希函数将每个哈希桶中的结点重新找到对应位置头插入到新哈希表即可,不用进行结点的创建与释放。
将键值对插入哈希表的具体步骤如下:
- 通过哈希函数计算出对应的哈希地址。
- 若产生哈希冲突,则直接将该结点头插到对应单链表即可。
🍅 动图演示
插入元素 55
再插入元素 65 和 75
🍅 代码实现
代码示例
// 插入函数
bool Insert(const pair<K, V>& kv)
{
// 1.查看哈希表中是否存在该键值的键值对
if (Find(kv.first))
{
return false; // 如果存在,则插入失败
}
// 2.判断是否需要调整哈希表的大小
if (_tables.size() == _n) // 如果哈希表的大小等于表中的元素个数
{
// 如果哈希表大小为 0,则将哈希表的初始大小设置为 10
// 然后创建一个新的哈希表,新哈希表的大小设置为原哈希表的2倍,并初始化为nullptr
size_t newSize = _tables.size() == 0 ? 10 : _tables.size() * 2;
vector<Node*> newTable;
newTable.resize(newSize, nullptr);
// 将原哈希表当中每个位置存储的单链表插入到新哈希表
HashFunc hf; // 用仿函数来判断key的类型
for (size_t i = 0; i < _tables.size(); ++i)
{
Node* cur = _tables[i]; // 记录原哈希表中第一个哈希桶的节点(记录单链表的头节点)
while (cur) // 哈希桶不为空,进入循环(头节点不为空,也就是单链表不为空,进入循环)
{
Node* next = cur->_next; // 记录cur的下一个结点
// 通过哈希函数,找到原哈希表中,第一个哈希桶里面的第一个节点,然后通过哈希函数把节点数据转换成整型
// 接着计算出这个整型的哈希地址,也就是对应的哈希桶编号hashi
size_t hashi = hf(cur->_kv.first) % newSize;
cur->_next = newTable[hashi]; // 将该结点头插到新哈希表中编号为hashi的哈希桶中
newTable[hashi] = cur;
cur = next; // 取原哈希表中该桶的下一个结点
}
// 该桶取完后将该桶置空
_tables[i] = nullptr;
}
// 交换这两个哈希表
newTable.swap(_tables);
}
//3.将键值对插入哈希表
HashFunc hf; // 用仿函数来判断key的类型
size_t hashi = hf(kv.first); // 先通过哈希函数把key转为整型
hashi %= _tables.size(); // 然后计算出对应的哈希桶编号
// 头插到对应的桶即可
Node* newnode = new Node(kv); // 新开辟一个待插入结点
newnode->_next = _tables[hashi]; // 将该结点头插到新哈希表中编号为hashi的哈希桶中
_tables[hashi] = newnode; // 把哈希桶中第一个节点更新为刚刚插入的节点(更新链表头节点)
// 4.哈希表中的有效元素个数加一
++_n;
return true; // 插入成功
}
🍑 查找函数
在哈希表中查找数据的步骤如下:
- 先判断哈希表的大小是否为 0,若为 0 则查找失败。
- 然后通过哈希函数计算出对应的哈希地址。
- 最后通过哈希地址找到对应的哈希桶中的单链表,遍历单链表进行查找即可。
🍅 动图演示
查找元素 12(存在)
查找元素 88(不存在)
🍅 代码实现
代码示例
// 查找函数
Node* Find(const K& key)
{
// 1.如果哈希表大小为0,则查找失败
if (_tables.size() == 0)
{
return nullptr;
}
// 2.开始查找
HashFunc hf; // 用仿函数来判断key的类型
size_t hashi = hf(key); // 先通过哈希函数把key转为整型
hashi %= _tables.size(); // 然后计算出对应的哈希桶编号
Node* cur = _tables[hashi]; // 记录哈希桶里面的第一个节点,也就是单链表的头节点
// 开始遍历整个哈希桶
while (cur)
{
if (cur->_kv.first == key) // 如果key值匹配,则查找成功
{
return cur; // 返回节点指针
}
cur = cur->_next;
}
// 如果把哈希桶全部遍历完还没有找到目标元素,则查找失败
return nullptr;
}
🍑 删除函数
在哈希表中删除数据的步骤如下:
- 通过哈希函数计算出对应的哈希桶编号。
- 遍历对应的哈希桶,寻找待删除结点。
- 若找到了待删除结点,则将该结点从单链表中移除并释放。
- 删除结点后,将哈希表中的有效元素个数减一。
🍅 动图演示
删除元素 40(该元素是单链表的尾节点)
删除元素 10(该元素是单链表的头节点)
删除元素 27(该元素是链表的中间节点)
🍅 代码实现
代码示例
// 删除函数
bool Erase(const K& key)
{
// 1.如果哈希表的大小为0,则删除失败
if (_tables.size() == 0)
{
return false;
}
// 2.在编号为hashi的哈希桶中寻找待删除结点
HashFunc hf; // 用仿函数来判断key的类型
size_t hashi = hf(key); // 先通过哈希函数把key转为整型
hashi %= _tables.size(); // 然后计算出对应的哈希桶编号
Node* prev = nullptr; // 定义前驱指针,初始化空
Node* cur = _tables[hashi]; // 记录哈希桶里面的第一个节点,也就是单链表的头节点
// 开始遍历整个哈希桶
while (cur)
{
if (cur->_kv.first == key) // 如果key值匹配,说明找到了待删除结点,则删除该结点
{
if (prev == nullptr) // 如果待删除结点是哈希桶中的第一个结点
{
_tables[hashi] = cur->_next; // 将第一个结点从该哈希桶中移除
}
else // 如果待删除结点不是哈希桶的第一个结点
{
prev->_next = cur->_next; // 则让cur的前驱节点指向cur的下一个节点
}
// 然后释放掉cur
delete cur;
// 删除成功
return true;
}
prev = cur; // 把前驱节点更新为cur
cur = cur->_next; // cur指向它的下一个节点
}
// 如果哈希桶全部遍历完毕还没有找到待删除元素,则删除失败
return false;
}
🍑 析构函数
拷贝构造对于 vector 来说是深拷贝,但是对于 vector 里面的链表是浅拷贝,因为链表是我们自己实现的内置类型,所以完成的是浅拷贝。
也就是说,如果我们要对哈希表进行拷贝构造的话,那么两个 vector 就会指向同一个哈希桶,必定会存在析构两次的问题,所以我们这里要对每个哈希桶里的单链表进行析构。
🍅 代码实现
代码示例
// 析构函数
~HashTable()
{
// 遍历整个哈希表
for (size_t i = 0; i < _tables.size(); ++i)
{
Node* cur = _tables[i]; // 记录哈希表中哈希桶的节点(记录单链表的头节点)
while (cur) // 遍历哈希桶中的单链表
{
Node* next = cur->_next; // 先记录cur的下一个节点
delete cur; // 释放cur
cur = next; // 再把next赋值给cur
}
// 当哈希桶中的单链表全部被删除时,还要将哈希桶置空
_tables[i] = nullptr;
}
}
7. 改进
除留余数法,最好模一个素数,因为如果给我们随机的数列放到哈希表中,如何保障它能尽量减少冲突呢,就需要模的因子最少,而因子最少的就是素数了,这就是为什么哈希表取模为素数的原因了。
如何每次快速取一个类似两倍关系的素数?
下面给出了一个素数表,最开始哈希表的大小为 0,传给 GetNextPrime(_tables.size())
然后去找比 0 大的素数,以此类推。
同时我们的插入函数也需要进行修改
// ul表示unsigned long
size_t GetNextPrime(size_t prime)
{
const int PRIMECOUNT = 28;
static const size_t primeList[PRIMECOUNT] =
{
53ul, 97ul, 193ul, 389ul, 769ul,
1543ul, 3079ul, 6151ul, 12289ul, 24593ul,
49157ul, 98317ul, 196613ul, 393241ul, 786433ul,
1572869ul, 3145739ul, 6291469ul, 12582917ul,25165843ul,
50331653ul, 100663319ul, 201326611ul, 402653189ul,805306457ul,
1610612741ul, 3221225473ul, 4294967291ul
};
// 获取比prime大的那一个素数
size_t i = 0;
for (; i < PRIMECOUNT; ++i)
{
if (primeList[i] > prime)
return primeList[i];
}
return primeList[i];
}
// 插入函数
bool Insert(const pair<K, V>& kv)
{
// 1.查看哈希表中是否存在该键值的键值对
if (Find(kv.first))
{
return false; // 如果存在,则插入失败
}
// 2.判断是否需要调整哈希表的大小
if (_tables.size() == _n) // 如果哈希表的大小等于表中的元素个数
{
// 把哈希表的大小设置为素数
// 当我们需要增容时,就在该素数数组中找到下一个素数作为哈希表增容后的大小即可
size_t newSize = GetNextPrime(_tables.size());
vector<Node*> newTable;
newTable.resize(newSize, nullptr);
// 将原哈希表当中每个位置存储的单链表插入到新哈希表
HashFunc hf; // 用仿函数来判断key的类型
for (size_t i = 0; i < _tables.size(); ++i)
{
Node* cur = _tables[i]; // 记录原哈希表中第一个哈希桶的节点(记录单链表的头节点)
while (cur) // 哈希桶不为空,进入循环(头节点不为空,也就是单链表不为空,进入循环)
{
Node* next = cur->_next; // 记录cur的下一个结点
// 通过哈希函数,找到原哈希表中,第一个哈希桶里面的第一个节点,然后通过哈希函数把节点数据转换成整型
// 接着计算出这个整型的哈希地址,也就是对应的哈希桶编号hashi
size_t hashi = hf(cur->_kv.first) % newSize;
cur->_next = newTable[hashi]; // 将该结点头插到新哈希表中编号为hashi的哈希桶中
newTable[hashi] = cur;
cur = next; // 取原哈希表中该桶的下一个结点
}
// 该桶取完后将该桶置空
_tables[i] = nullptr;
}
// 交换这两个哈希表
newTable.swap(_tables);
}
//3.将键值对插入哈希表
HashFunc hf; // 用仿函数来判断key的类型
size_t hashi = hf(kv.first); // 先通过哈希函数把key转为整型
hashi %= _tables.size(); // 然后计算出对应的哈希桶编号
// 头插到对应的桶即可
Node* newnode = new Node(kv); // 新开辟一个待插入结点
newnode->_next = _tables[hashi]; // 将该结点头插到新哈希表中编号为hashi的哈希桶中
_tables[hashi] = newnode; // 把哈希桶中第一个节点更新为刚刚插入的节点(更新链表头节点)
// 4.哈希表中的有效元素个数加一
++_n;
return true; // 插入成功
}
8. 开散列与闭散列比较
链地址法处理溢出,需要增设链接指针,似乎增加了存储开销。文章来源:https://www.toymoban.com/news/detail-427644.html
事实上:由于开地址法必须保持大量的空闲空间以确保搜索效率,如二次探查法要求装载因子 a <= 0.7
,而表项所占空间又比指针大的多,所以使用链地址法反而比开地址法节省存储空间。文章来源地址https://www.toymoban.com/news/detail-427644.html
到了这里,关于数据结构中常见的哈希表,到底是什么?的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!