Bootstrap

c++的哈希表、哈希桶的介绍与实现

目录

前言

哈希概念

 哈希冲突

哈希函数

哈希冲突解决

闭散列 —— 开放定址法

开散列 —— 链地址法(拉链法、哈希桶)

哈希表的闭散列实现

哈希表的结构

哈希表的仿函数

哈希表的插入

哈希表的查找

 哈希表的删除

哈希表的开散列实现(哈希桶)

哈希桶的结构

哈希桶的插入

哈希桶的查找

哈希桶的删除

哈希的大小为什么建议是素数


前言

这一篇文章大致实现详细介绍什么是哈希,然后再介绍什么是哈希表,怎么代码实现哈希表,然后再介绍哈希桶,怎么代码实现哈希桶,最后再介绍他俩有什么细节上的差别,与代码的一些细节优化。

哈希概念

在c++学习之前肯定听说过哈希,亦或是从别人口中,亦或是刷题中题解经常提及,那么到底什么是哈希呢?

在学习c语言时我们都了解一个知识点:程序设计三种结构为顺序结构、分支结构(也称为选择结构)和循环结构

顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。搜索的效率取决于搜索过程中元素的比较次数,因此顺序结构中查找的时间复杂度为O ( N ),平衡树中查找的时间复杂度为树的高度 O(logN)

而最理想的搜索方法是,可以不经过任何比较,一次直接从表中得到要搜索的元素,即查找的时间复杂度为O ( 1 )

如果构造一个结构,使得该结构可以通过某种函数,使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时就能通过该函数很快找到该元素。

所以哈希就是为此而诞生得,也就是弥补了红黑树其查找慢,插入慢得缺点。

那么这里就举一个最基础得哈希来简单描述一下。

例如,集合{1, 7, 6, 4, 5, 9}

在进行插入前,我们要明确的就是要知道其元素的关键码,用此函数计算出该元素的存储位置,并将元素存放到此位置。

所以插入就分为两个步骤

  • 插入元素: 根据待插入元素的关键码,用此函数计算出该元素的存储位置,并将元素存放到此位置。
  • 搜索元素: 对元素的关键码进行同样的计算,把求得的函数值当作元素的存储位置,在结构中按此位置取元素进行比较,若关键码相等,则搜索成功。

该方式即为哈希(散列)方法, 哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(散列表)。

上面的集合的size=6;在一般的认知情况下我们首先要开辟一个大小大于size的空间,但是哈希不同,哈希底层其实为vector,在默认情况下会开辟10个数据空间(在linux下会有不同)(这里以vs为例),搜索函数就被特定名为hash(key)=key%capacity;

其中capacity为存储元素底层空间的总大小。
若我们将该集合存储在capacity为10的哈希表中,则各元素存储位置对应如下:

在搜索与插入时就只需通过哈希函数判断对应位置是否存放的是待查找元素,而不必进行多次关键码的比较,因此搜索的速度比较快,使得插入与查找的时间复杂度都降到了0(1)

 哈希冲突

不同关键字通过相同哈希函数计算出相同的哈希地址,这种现象称为哈希冲突或哈希碰撞。我们把关键码不同而具有相同哈希地址的数据元素称为“同义词”。

就比如上面的案例,如果我们在进行插入一个11,那么就会产生哈希冲突,因为11通过哈希函数得到的哈希地址与元素1相同,都是下标为1的位置,那么这时候就会产生冲突,这种冲突就被名为哈希冲突。hash(11)=11%10=1;

那么这时候就需要解决这种冲突,那么就需要对我们一开始假象的哈希函数进行修改,那么下面介绍一下库函数里面的哈希函数吧。

哈希函数

在库函数的哈希里面的哈希表肯定不会遇见我们上面说的问题,那么就说明我们设计的哈希函数是不合理的。

那么什么样的哈希函数又是合理的呢?

哈希函数设计的原则:

  1. 哈希函数的定义域必须包括需要存储的全部关键码,且如果散列表允许有m个地址,其值域必须在0到m-1之间。
  2. 哈希函数计算出来的地址能均匀分布在整个空间中。
  3. 哈希函数应该比较简单。

常见的哈希函数如下:

 一、直接定址法(常用)

取关键字的某个线性函数为哈希地址:Hash(key)=A*key+B;(A,B都会因不同的哈希而变)

优点:每个值都有一个唯一位置,效率很高,每个都是一次就能找到。
缺点:使用场景比较局限,通常要求数据是整数,范围比较集中。
使用场景:适用于整数,且数据范围比较集中的情况。

不存在哈希冲突!!!

二、除留余数法(常用)

设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数:Hash(Key)=Key%p(p<=m),将关键码转换成哈希地址。

优点:使用场景广泛,不受限制。
缺点:存在哈希冲突,需要解决哈希冲突,哈希冲突越多,效率下降越厉害。

存在哈希冲突!!!

三、平方取中法

假设关键字为1234,对它平方就是1522756,抽取中间的3位227作为哈希地址。

使用场景:不知道关键字的分布,而位数又不是很大的情况。

缺点也就是:存在哈希冲突。

四、折叠法

折叠法是将关键字从左到右分割成位数相等的几部分(最后一部分位数可以短些),然后将这几部分叠加求和,并按哈希表表长,取后几位作为哈希地址。

使用场景:折叠法适合事先不需要知道关键字的分布,或关键字位数比较多的情况。

五、随机数法
选择一个随机函数,取关键字的随机函数值为它的哈希地址,即Hash(Key)=random(Key),其中random为随机数函数。

使用场景:通常应用于关键字长度不等时。

注意:哈希函数设计的越精妙,产生哈希冲突的可能性越低,但是无法完美的避免哈希冲突。

哈希冲突解决

解决哈希冲突有两种常见的方法:闭散列开散列

闭散列 —— 开放定址法

闭散列,也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表种必然还有空位置,那么可以把产生冲突的元素存放到冲突位置的“下一个”空位置中去。

寻找“下一个位置”的方式多种多样,常见的方式有以下两种:

线性探测法

当发生哈希冲突时,从发生冲突的位置开始,依次向后探测,直到找到下一个空位置为止。

Hi=(H0+i)%m ( i = 1 , 2 , 3 , . . . )

H0:通过上文的哈希函数对元素的关键码进行计算得到的位置。 

Hi:冲突元素通过线性探测后得到的存放位置。

m:表的大小(也就是上文的capacity)。

例如,我们用除留余数法将序列{1, 6, 8,9,99,69,2}插入到表长为10的哈希表中,当发生哈希冲突时我们采用闭散列的线性探测找到下一个空位置进行插入,插入过程如下:

哈希冲突-线性探测

通过上图可以看到,随着哈希表中数据的增多,产生哈希冲突的可能性也随着增加,最后在69进行插入的时候更是连续出现了三次哈希冲突。

我们将数据插入到有限的空间,那么空间中的元素越多,插入元素时产生冲突的概率也就越大,冲突多次后插入哈希表的元素,在查找时的效率必然也会降低。介于此,哈希表当中引入了负载因子(载荷因子):

负载因子=表中有效数据个数 / 空间的大小

  • 负载因子越大,产出冲突的概率越高,增删查改的效率越低。
  • 负载因子越小,产出冲突的概率越低,增删查改的效率越高。

例如,我们将哈希表的大小改为20,可以看到在插入相同序列时,产生的哈希冲突会有所减少:

其中就69产生了哈希冲突,但也就一次哈希冲突。

 

但负载因子越小,也就意味着空间的利用率越低,此时大量的空间实际上都被浪费了。对于闭散列(开放定址法)来说,负载因子是特别重要的因素,一般控制在0.7~0.8以下,超过0.8会导致在查表时CPU缓存不命中(cache missing)按照指数曲线上升。 

因此,一些采用开放定址法的hash库,如JAVA的系统库限制了负载因子为0.75,当超过该值时,会对哈希表进行增容。

线性探测的优点:实现非常简单。
线性探测的缺点:一旦发生冲突,所有的冲突连在一起,容易产生数据“堆积”,即不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要多次比较(踩踏效应),导致搜索效率降低。

二次探测法

 线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法为:

Hi=(H0+i^2)%m ( i = 1 , 2 , 3 , . . . ) 

H0:通过哈希函数对元素的关键码进行计算得到的位置。
H i :冲突元素通过二次探测后得到的存放位置。
m :表的大小。

例如,我们用除留余数法将序列{1, 6, 8,9,99,69,2}}插入到表长为10的哈希表中,当发生哈希冲突时我们采用闭散列的二次探测找到下一个空位置进行插入,插入过程如下:

采用二次探测为产生哈希冲突的数据寻找下一个位置,相比线性探测而言,采用二次探测的哈希表中元素的分布会相对稀疏一些,不容易导致数据堆积。

但是还是无法避免哈希冲突的,这也很正常,但是哈希冲突相对与上面还是减少了,69就冲突了两次。

当然如果将其空间大小设置为20,负载因子还是不变,那么产生的冲突还是会减少的:

还是仅仅产生一个哈希冲突。

因此,闭散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷。

开散列 —— 链地址法(拉链法、哈希桶)

开散列,又叫链地址法(拉链法),首先对关键码集合用哈希函数计算哈希地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。

例如,我们用除留余数法将序列{1, 6, 8,9,99,69,2}插入到表长为10的哈希表中,当发生哈希冲突时我们采用开散列的形式,将哈希地址相同的元素都链接到同一个哈希桶下,插入后结果如下:

闭散列的哈希桶虽然解决哈希冲突,但是也存在弊端,那么就是如果“我的位置被占用了我就去占用其他位置”,但是这个位置还是与我本来的位置还是有关系的,而开散列解决哈希冲突,采用的是一种乐观的方式,“虽然我的位置被占用了,但是没关系,我可以‘挂’在这个位置下面,如果还被占了那就继续下一个”。

与闭散列不同的是,这种将相同哈希地址的元素通过单链表链接起来,然后将链表的头结点存储在哈希表中的方式,不会影响与自己哈希地址不同的元素的增删查改的效率,因此开散列的负载因子相比闭散列而言,可以稍微大一点。

  • 闭散列的开放定址法,负载因子不能超过1,一般建议控制在[0.0, 0.7]之间。
  • 开散列的哈希桶,负载因子可以超过1,一般建议控制在[0.0, 1.0]之间。

在实际中,开散列的哈希桶结构比闭散列更实用,主要原因有两点:

  1. 哈希桶的负载因子可以更大,空间利用率高。
  2. 哈希桶在极端情况下还有可用的解决方案。

哈希桶的极端情况就是,所有元素全部产生冲突,最终都放到了同一个哈希桶中,此时该哈希表增删查改的效率就退化成了O ( N ) :

但是针对这种情况可以将单链表转化为红黑树,使得其上面的结构转化为下面(这里的红黑颜色就不在这里区分了)这时候就算是极端情况下查找的时间复杂度也变为了0(logn):

那么在这种情况下,即使存在极端情况存储10亿个数据,那么查找也最多是30多次,这样就解决了在极端情况下的时间复杂度;

当然设计哈希桶的人肯定是聪明的,他肯定会想到这种情况,所以也就可以避免存在这种情况,当这个哈希桶的数据数量达到一定的数量的时候,他就会扩容,这时候的哈希函数也会改变,那么这时候的极端情况也会得到了缓解。但是也存在着别的设计,比如在JAVA中比较新一点的版本中,当桶当中的数据个数超过8时,就会将该桶当中的单链表结构换成红黑树结构,而当该桶当中的数据个数减少到8或8以下时,又会将该桶当中的红黑树结构换回单链表结构。

哈希表的闭散列实现

我们上面也已经提及一嘴,说是哈希表的底层其实还是用vector来实现的,其空间还是一个连续的数组的,数组中的每一个位置都会存储一个数据,这个数据是由我们定义其类型的,但是哈希表的存储顺序不是连续的,可能存一个搁几个空间,这就需要特定限制一下哈希表的结构,使得我们达到要求其真实的物理存储空间情况。

哈希表的结构

哈希表的仿函数

然而在上面说的哈希函数我们都是默认他是整形类型的来进行操作的,但是在实际上哈希表不仅仅存储整形,他还有可能存储string,字符串甚至还有可能存一些vector,那么对于这些我们都不能直接带入哈希函数,那么对于这些我们要做的就是强转类型转化为整形,但是强制类型转化显得很挫而且还有很多的弊端,所以就可以通过仿函数

//HashFunc<int>
template<class K>
struct HashFunc
{
	size_t operator()(const K& key)
	{
		return (size_t)key;
	}
};

//HashFunc<string>
template<>
struct HashFunc<string>
{
	size_t operator()(const string& key)
	{
		// BKDR
		size_t hashi = 0;
		for (auto e : key)
		{
			hashi *= 31;
			hashi += e;
		}
		return hashi;
	}
};

在闭散列的哈希表中,可能存在数据不连续的情况,所以每个位置除了存储数据外,还需要存储该位置的当前的状态,比如我们可以把哈希表的状态分为:

  1. EMPTY(无数据的空位置)。
  2. EXIST(已存储数据)。
  3. DELETE(原本有数据,但现在被删除了)。

为此我们可以通过枚举进行操作控制:

	//枚举:标识每个位置的状态
    enum Status
	{
		EMPTY,
		EXIST,
		DELETE
	};

为什么必须要标识哈希表中每个位置的状态?仅仅靠是否有数据不行么?

 若是不设置哈希表中每个位置的状态

从插入方面解释:

那么在哈希表中查找数据的时候可能是这样的。以除留余数法的线性探测为例,我们若是要判断下面这个哈希表是否存在元素10(此时capacity=10),步骤如下:

  1. 通过除留余数法求得元素10在该哈希表中的哈希地址是0。
  2. 从0下标开始向后进行查找,若找到了10则说明存在。

但是如果没有标识位置状态,那么如果仅仅靠是否有数据来判断这个位置是否为空,这样乍一听可以,但是仔细想想来看其实恰恰不行的,因为如果靠这样来判断,那么何为空?0(存的是int...)?还是\0(存的是char...)?其实都不行,因为我们如果恰恰插入的数据有一个是0或\0呢?

所以就需要用一个标识来描述这个位置的状态。

如果这个样理解不明白,那么这样在从查找上来举例说明

从查找方面解释:

就比如我们有下面一个简易的哈希表:

 

如果我们查找数据21是否存在,但是我们在寻找元素21时,不可能从0下标开始将整个哈希表全部遍历一次,这样就失去了哈希的意义。我们只需要先从通过除余方法算出关键码1,然后从1下标开始往后查找,直到找到元素21判定为存在,但是如果21不在哈希表是怎么样判断呢?

这就需要我们找到一个空位置判定为不存在即可。 那么这个空位置就不能通过特定的数据来判断,因为可能存在这个特定的数据刚好是我们插入的数据,所以就需要标识每个位置的状态来判断。

因此我们必须为哈希表中的每一个位置设置一个状态,并且每个位置的状态应该有三种可能,当哈希表中的一个元素被删除后,我们不应该简单的将该位置的状态设置为EMPTY,而是应该将该位置的状态设置为DELETE。
这样一来,当我们在哈希表中查找元素的过程中,若当前位置的元素与待查找的元素不匹配,但是当前位置的状态是EXIST或是DELETE,那么我们都应该继续往后进行查找,而当我们插入元素的时候,可以将元素插入到状态为EMPTY或是DELETE的位置。

因此,闭散列的哈希表中的每个位置存储的结构,应该包括所给数据和该位置的当前状态。

所以闭散列的每个位置设置情况如下:

template<class K, class V>
struct HashData
{
	pair<K, V> _kv;
	Status _s = EMPTY;          //状态
};

而为了在插入元素时好计算当前哈希表的负载因子,我们还应该时刻存储整个哈希表中的有效元素个数,当负载因子过大时就应该进行哈希表的增容。

//哈希表
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
public:
    HashTable()		
	{
		_tables.resize(10);
	}
	//...
private:
		vector<HashData<K, V>> _tables; // 哈希表
		size_t _n = 0; // 存储的关键字的个数
};

哈希表的插入

向哈希表中插入数据的步骤如下:

  1. 查看哈希表中是否存在该键值的键值对,若已存在则插入失败。
  2. 判断是否需要调整哈希表的大小,若哈希表的大小为0,或负载因子过大都需要对哈希表的大小进行调整。
  3. 将键值对插入哈希表。
  4. 哈希表中的有效元素个数加一。

其中的调整哈希表的大小分为两种情况:

  1. 若哈希表的大小为0,则将哈希表的初始大小设置为10(当然也可以设置为15或者20,自己规定,在vs上是10)。
  2. 若哈希表的负载因子大于0.7,则先创建一个新的哈希表,该哈希表的大小为原哈希表的两倍(同理可以设置为1.5倍在Linux中就是1.5倍),之后遍历原哈希表,将原哈希表中的数据插入到新哈希表,最后将原哈希表与新哈希表交换即可。

注意: 在将原哈希表的数据插入到新哈希表的过程中,不能只是简单的将原哈希表中的数据对应的挪到新哈希表中,而是需要根据新哈希表的大小重新计算每个数据在新哈希表中的位置,然后再进行插入。所以一般来说不要因为减少哈希冲突而使得哈希表的扩容次数变多,扩容的消耗还是比较大的。

将键值对插入哈希表的具体步骤如下:

  1. 通过哈希函数计算出对应的哈希地址。
  2. 若产生哈希冲突,则从哈希地址处开始,采用线性探测向后寻找一个状态为EMPTY或DELETE的位置。
  3. 将键值对插入到该位置,并将该位置的状态设置为EXIST。

注意: 产生哈希冲突向后进行探测时,一定会找到一个合适位置进行插入,因为哈希表的负载因子是控制在0.7以下的,也就是说哈希表永远都不会被装满。

bool Insert(const pair<K, V>& kv)
{
	if (Find(kv.first))
		return false;

	// 负载因子0.7就扩容
	if (_n * 10 / _tables.size() >= 7)//扩容
	{
		size_t newSize = _tables.size() * 2;
		HashTable<K, V, Hash> newHT;
		newHT._tables.resize(newSize);
		for (size_t i = 0; i < _tables.size(); i++)
		{
			if (_tables[i]._s == EXIST)
			{
				newHT.Insert(_tables[i]._kv);
			}
		}
		_tables.swap(newHT._tables);
	}
	Hash hf;
	// 线性探测
	size_t hashi = hf(kv.first) % _tables.size();
	while (_tables[hashi]._s==EXIST)
	{
		++hashi;
		hashi %= _tables.size();
	}

	_tables[hashi]._kv = kv;
	_tables[hashi]._s = EXIST;
	++_n;

	return true;
}

哈希表的查找

在哈希表中的查找其实大致就是插入的微改变

其步骤如下:

  1. 先判断哈希表的大小是否为0,若为0则查找失败。
  2. 通过哈希函数计算出对应的哈希地址。
  3. 从哈希地址处开始,采用线性探测向后向后进行数据的查找,直到找到待查找的元素判定为查找成功,或找到一个状态为EMPTY的位置判定为查找失败。

注意: 在查找过程中,必须找到位置状态为EXIST,并且key值匹配的元素,才算查找成功。若仅仅是key值匹配,但该位置当前状态为DELETE,则还需继续进行查找,因为该位置的元素已经被删除了。

HashData<K, V>* Find(const K& key)
{
	Hash hf;
	size_t hashi = hf(key) % _tables.size();
	while (_tables[hashi]._s != EMPTY)
	{
		if (_tables[hashi]._s != DELETE
			&& _tables[hashi]._kv.first == key)
			return &_tables[hashi];
		++hashi;
		hashi%= _tables.size();
	}

	//没有找到
	return NULL;
}

 哈希表的删除

哈希表的删除并不像红黑树,AVL树的删除那么的复杂,其删除是十分简便的,其删除其实为伪删除,这也再次展示了我们设计每个位置的状态的妙处。其删除的步骤如下:

  1. 查看哈希表中是否存在该键值的键值对,若不存在则删除失败。
  2. 若存在,则将该键值对所在位置的状态改为DELETE即可。
  3. 哈希表中的有效元素个数减一。

注意: 虽然删除元素时没有将该位置的数据清0,只是将该元素所在状态设为了DELETE,但是并不会造成空间的浪费,因为我们在插入数据时是可以将数据插入到状态为DELETE的位置的,此时插入的数据就会把该数据覆盖。

// 伪删除法
bool Erase(const K& key)
{
	HashData<K, V>* ret = Find(key);
	if (ret)//存在
	{
		--_n;
		ret->_s = DELETE;
		return true;
	}
	else
	{
		return false;
	}
}

哈希表的开散列实现(哈希桶)

哈希桶的结构

在开散列的哈希中,

同样哈希桶的结构上也是有仿函数的,其与上面的哈希表完全一样

与之不同的就是在开散列的哈希中,其哈希桶的每个位置变为了单链表的头结点,即每个哈希桶中存储的数据实际上是一个结点类型,该结点类型除了存储所给数据之外,还需要存储一个结点指针用于指向下一个结点。

每个位置:

template<class K, class V>
struct HashNode
{
	HashNode* _next;
	pair<K, V> _kv;

	HashNode(const pair<K, V>& kv)
		:_next(nullptr)
		, _kv(kv)
	{}
};

为了让代码看起来更加清晰,在实现哈希表时,我们最好将结点的类型进行typedef。

		typedef HashNode<K, V> Node;

与闭散列的哈希表不同的是,在实现开散列的哈希时,我们不用为哈希表中的每个位置设置一个状态字段,因为在开散列的哈希中,我们哈希的每个元素变为了单链表的头结点,那么就会很好的辨别是否为空,如果为空那么这个指针就为nullptr,反之不为,同样也不需要经过探测寻找所谓的“下一个位置”。

哈希表的开散列实现方式,在插入数据时也需要根据负载因子判断是否需要增容,所以我们也应该时刻存储整个哈希表中的有效元素个数,当负载因子过大时就应该进行哈希表的增容。

//哈希桶
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
    typedef HashNode<K, V> Node;
public:
	HashTable()
	{
		_tables.resize(10);
	}
	//...
private:
	vector<Node*> _tables; // 哈希表
	size_t _n = 0; //哈希表中的有效元素个数
};

哈希桶的插入

向哈希表中插入数据的步骤如下:

  1. 查看哈希表中是否存在该键值的键值对,若已存在则插入失败。
  2. 判断是否需要调整哈希表的大小,若哈希表的大小为0,或负载因子过大都需要对哈希表的大小进行调整。
  3. 将键值对插入哈希表。
  4. 哈希表中的有效元素个数加一。

其中,哈希表的调整方式如下:

  • 若哈希表的大小为0,则将哈希表的初始大小设置为10。
  • 若哈希表的负载因子已经等于1了,则先创建一个新的哈希表,该哈希表的大小为原哈希表的两倍,之后遍历原哈希表,将原哈希表中的数据插入到新哈希表,最后将原哈希表与新哈希表交换即可。

重点: 在将原哈希桶的数据插入到新哈希桶的过程中,不要通过复用插入函数将原哈希表中的数据插入到新哈希桶,因为在这个过程中我们需要创建相同数据的结点插入到新哈希桶,在插入完毕后还需要将原哈希桶中的结点进行释放,多此一举。

实际上,我们只需要遍历原哈希表的每个哈希桶,通过哈希函数将每个哈希桶中的结点重新找到对应位置插入到新哈希桶即可,不用进行结点的创建与释放。

注意!!!扩容后一定要用新的哈希桶的哈希函数计算新的关键码。

将键值对插入哈希桶的具体步骤如下:

  1. 通过哈希函数计算出对应的哈希地址。
  2. 若产生哈希冲突,则直接将该结点头插到对应单链表即可。

bool Insert(const pair<K, V>& kv)
{
	if (Find(kv.first))
		return false;
	// 负载因子最大到1
	if (_n == _tables.size())
	{
		vector<Node*> newTables;
		newTables.resize(_tables.size() * 2, nullptr);
		Hash hf;
		for (size_t i = 0; i < _tables.size(); i++)
		{
			Node* cur = _tables[i];
			while (cur)
			{
				Node* next = cur->_next;

				// 挪动到映射的新表
				size_t hashi = hf(cur->_kv.first) % newTables.size();
				cur->_next = newTables[hashi];
				newTables[hashi] = cur;

				cur = next;
			}
			_tables[i] = nullptr;
		}
		_tables.swap(newTables);
	}
	Hash hf;
	size_t hashi = hf(kv.first) % _tables.size();
	Node* newnode = new Node(kv);
	newnode->_next = _tables[hashi];
	_tables[hashi] = newnode;
	++_n;

	return true;
}

哈希桶的查找

在哈希桶中查找数据的步骤如下:

  1. 先判断哈希桶的大小是否为0,若为0则查找失败。
  2. 通过哈希函数计算出对应的哈希地址。
  3. 通过哈希地址找到对应的哈希桶中的单链表,遍历单链表进行查找即可。
Node* Find(const K& key)
{
	Hash hf;
	size_t hashi = hf(key) % _tables.size();
	Node* cur = _tables[hashi];
	while (cur)
	{
		if (cur->_kv.first == key)
		{
			return cur;
		}
		cur = cur->_next;
	}
	return nullptr;
}

哈希桶的删除

在哈希桶中删除数据的步骤如下:

  1. 通过哈希函数计算出对应的哈希桶编号。
  2. 遍历对应的哈希桶,寻找待删除结点。
  3. 若找到了待删除结点,则将该结点从单链表中移除并释放。
  4. 删除结点后,将哈希桶中的有效元素个数减一。

注意: 不要先调用查找函数判断待删除结点是否存在,这样做如果待删除不在哈希表中那还好,但如果待删除结点在哈希桶,那我们还需要重新在哈希桶中找到该结点并删除,还不如一开始就直接在哈希桶中找,找到了就删除。

bool Erase(const K& key)
{
	Hash hf;
	size_t hashi = hf(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;
		}
		prev = cur;
		cur = cur->_next;
	}
	return false;
}

哈希的大小为什么建议是素数

相信了解哈希的你,一定听说过哈希的大小其实设置的全是素数,其实在STL库中也是如此,那么这是为什么呢?

有人说是:因为这样可以减少哈希冲突。

为什么呢?其实这其中有很多的争议

就比如《数据结构和算法分析》这本书,里面就建议大小设置为素数,但里面并没有详细说明为什么,只说了因为它在哈希表最小化集群,这又是为什么?

这个带有争议的问题我就给大家推荐几篇文章,自己了解了解

但是存在争议存在争议,该怎么设置代码还是要知道怎么写的:

我们如果每次增容时让哈希表的大小增大两倍,那么增容后哈希表的大小就不是素数了。因此我们可以将需要用到的素数序列提前用一个数组存储起来,当我们需要增容时就从该数组当中进行获取就行了。

例如,下面这些都是素数,且它们近似以2倍的形式进行增长,我们就可以将它们用一个数组存储起来。

		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
		};

就比如insert:

只需要将红色圈起来的对应修改即可

;