C++--哈希表--散列--冲突--哈希闭散列模拟实现


哈希概念

unordered系列的关联式容器之所以效率比较高,是因为其底层使用了哈希结构。
顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即O( l o g 2 N log_2 N log2N),搜索的效率取决于搜索过程中元素的比较次数。

理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。 如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素

当向该结构中:
插入元素
根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放
搜索元素
对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置
取元素比较,若关键码相等,则搜索成功
该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)(或者称散列表)

例如:数据集合{1,7,6,4,5,9};
哈希函数设置为:hash(key) = key % capacity; capacity为存储元素底层空间总的大小。
在这里插入图片描述

问题:按照上述哈希方式,向集合中插入元素44,会出现什么问题?
哈希冲突
对于两个数据元素的关键字 k i k_i ki k j k_j kj(i != j),有 k i k_i ki != k j k_j kj,但有:Hash( k i k_i ki) == Hash( k j k_j kj),即:不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。
把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”。
发生哈希冲突该如何处理呢?
哈希函数:
引起哈希冲突的一个原因可能是:哈希函数设计不够合理。
哈希函数设计原则:

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

常见的哈希函数:

  1. 直接定址法–(常用)
    取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B
    优点:简单、均匀
    缺点:需要事先知道关键字的分布情况
    使用场景:适合查找比较小且连续的情况
    在这里插入图片描述
    在这里插入图片描述
  1. 除留余数法–(常用)
    设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,
    按照哈希函数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址

哈希冲突解决
解决哈希冲突两种常见的方法是:闭散列和开散列
闭散列
闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。那如何寻找下一个空位置呢?
线性探测插入:
在这里插入图片描述

上面这个场景,现在需要插入元素44,先通过哈希函数计算哈希地址,hashAddr为4,
因此44理论上应该插在该位置,但是该位置已经放了值为4的元素,即发生哈希冲突。
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
插入通过哈希函数获取待插入元素在哈希表中的位置。如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素

删除:
采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素
会影响其他元素的搜索。比如删除元素4,如果直接删除掉,44查找起来可能会受影
响。因此线性探测采用标记的伪删除法来删除一个元素。


提示:以下是本篇文章正文内容,下面案例可供参考

一、哈希表闭散列的模拟实现

哈希表中元素状态

enum State
{
	EMPTY,//空
	EXIST,//存在
	DELETE,//删除
};

存储类型用pair即可,但是数据中要包含状态,我们进行一次封装

//由于数据需要一个状态,所以需要将pair<K,V>封装一层
	template<class K,class V>
	struct HashDate
	{
		pair<K, V>_kv;
		State _state;
	};

构建哈希表的内容

template<class K,class V>
	class HashTable
	{
	public:
		bool Insert(const pair<K,V>& kv);
		HashDate<K, V>* find(const K& key);
		bool Erase(const K& key);
	private:
		vector<HashDate<K,V>> _table;
		size_t _n= 0;
	};

闭散列的插入:

		bool insert(const pair<K, V>& kv)
		{
			if (Find(kv.first))return false;
			if (_table.size() == 0 || 10 * _n / _table.size() >= 0.7) //如果hash表的负载因子 >= 0.7 || hash表一开始为空
			{
				size_t newsize = _table.size() == 0 ? 10 : _table.size() * 2;
				HashTable<K, V> NewHashTable;
				NewHashTable._table.resize(newsize);
				for (auto& e : _table)  //这里的e每一个vector里面的值-》pair AND _s
				{
					if (e._s == EXIST)
					{
						NewHashTable.insert(e._kv);  //对象不同,调用的成员函数里面的内容也不同
					}
				}
				//走到这里,说明已经将原来的vector里面的内容拷贝到现在newHashTable里面
				_table.swap(NewHashTable._table);  //vector析构的时候会调用HashData<K, V>的析构函数,但是HashData<K, V>里面没有动态开辟的内存,所以不需要在HashData里面写一个析构函数
			}
			HashFuni<K> hashfuni;
			size_t hashi = hashfuni(kv.first) % _table.size();  //计算表中的下标
			while (_table[hashi]._s == EXIST)
			{
				hashi++;
				hashi %= _table.size();
			}
			_table[hashi]._kv = kv;
			_table[hashi]._s = EXIST;
			_n++;
			return true;
		}

闭散列的查找:

		HashData<K, V>* Find(const K& key)
		{
			HashFuni<K> hashfuni;
			size_t hashi = hashfuni(key) % _table.size();
			while (_table[hashi]._s != EMPTY)
			{
				//这里必须使用两个条件,因为我们的HashTable的删除并不是真正的删除,仅仅只是修改状态_s和_n
				if (_table[hashi]._kv.first == key && _table[hashi]._s == EXIST)
				{
					return &_table[hashi];
				}
				hashi++;
				hashi %= _table.size();
			}
			return nullptr;
		}

闭散列的删除:

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

二、开散列(哈希桶)的模拟实现

开散列:又叫链地址法(开链法),首先对key值集合用哈希函数计算映射下标,具有相同下标的key值归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
在这里插入图片描述

如上图所示,此时的哈希表中存放的是一个单链表的头指针。

  • 不同的数据,根据哈希函数算出的映射位置发生哈希碰撞时,这些碰撞的数据会挂在哈希表对应位置指向的单链表中。这些单链表被形象称为桶。
  • 每个桶中放的都是发生哈希冲突的元素。
  • 当有新数据插入时,进行头插。

如上图中所示,7,27,57,根据哈希函数都映射到哈希表下标为7的位置,这几个数据按照头插的顺序以单链表的形式挂在哈希表下标为7的位置。

新插入的数据如果尾插的话,在找单链表的尾部时,会有效率损失,由于没有排序要求,所以头插是效率最高的。

闭散列的方法,通常被称为哈希桶,使用的也最广泛,能够解决闭散列中空间利用率不高的问题。

数据类型定义

在这里插入图片描述
采用哈希同的方式来解决哈希碰撞时,哈希表中存放的数据是单链表的头节点,如上图所示。

  • 链表节点中,有键值对,还有下一个节点的指针。
  • 仍然使用闭散列中转换整形的仿函数。

析构函数

在哈希桶的构造函数中,哈希表的初始大小是10个元素,每个元素都是nullptr,因为此时还没有桶。
在这里插入图片描述
哈希桶必须有析构函数,闭散列的方式,默认生成的析构函数就能满足要求,但是哈希桶不可以。
如果只使用默认生成的析构函数,在哈希桶销毁的时候,默认的析构函数会调用vector的析构函数。
vector的析构函数只会释放vector的本身,而不会释放vector上挂着的桶。

插入

在这里插入图片描述

  • 在经过哈希函数映射后,将键值对插入到哈希表对应位置除的桶中。
  • 插入到桶中时,使用头插,如上图中蓝色框所示,这俩步的不能反,必须按照这个顺序,否则无法实现头插。

插入的扩容

一般情况下,当哈希表的负载因子等于1的时候,发生扩容。
在这里插入图片描述

  • 当负载因子等于1时,也就是数据个数和哈希表大小相等的时候进行扩容。
  • 扩容和闭散列类似,将旧的哈希表中的数据插入到新哈希表中,复用Insert函数,然后旧表被释放,新表留下来。
    但是这种方式不是很好,有很大的开销,效率有所损失:
    在这里插入图片描述

在将旧表中的数据插入新表的时候,每插入一个,新表就需要new一个节点,旧表中的所有节点都会被new一遍。

然后将旧表中的所有节点再释放,这里做了没必要的工作。相同的一个节点,会先在新表中new一个,再释放旧表的。

新表中完全可以不再new新的节点,直接使用旧表中的节点。

  • 旧表中可以直接复用的节点是:改变了哈希表容量以后,映射关系不变的节点。
  • 比如节点27,哈希表的容量从10变成20,但是映射后的下标仍然是7,这样的节点就可以复用。

那些映射关系变了的节点就不可以直接复用了,需要改变所在桶的位置。

如节点18,哈希表的容量从10变成20,映射后的下标从8变成18,此时就需要改变18所在的桶了。

在这里插入图片描述

这里不用创建新的哈希桶结构,只创建底层的vector就可以,因为不再复用Insert了。将旧表中的数据一个个拿出来,通过哈希函数重新计算映射关系,并且头插到新新表的桶中。
旧表的每个桶中的数据处理完后,必须把表中的单链表头置空,因为此时新表和旧表都指向这些桶,否则在旧表析构的时候会析构掉所有桶,导致新表中没有数据。

查找

在这里插入图片描述

删除

在这里插入图片描述
如上图所示,使用Find先找到key值所在哈希表中的位置,然后删除。

哈希表挂的桶是单链表,只指定要删除节点是无法进行删除的,必须指定前一个节点,否则无法再链接。

所以上面的方法是不能用的,只能拿着key值通过哈希函数重新寻找哈希表中的key值,在这个过程中同时记录前一个节点prev。

		bool Erase(const K& key)
		{
			HashFuni<K> hashfuni;
			size_t hashi = hashfuni(key) % _table.size();  //计算表中的下标
			Node* cur = _table[hashi];
			Node* prev = nullptr;
			while (cur)
			{
				if (cur->_kv.first == key)
				{
					if (cur == _table[hashi])
					{
						_table[hashi] = cur->_next;
						delete cur;
					}
					else
					{
						prev->_next = cur->_next;
						delete cur;
					}
					return true;
				}
				else
				{
					prev = cur;
					cur = cur->_next;
				}

			}
			return false;
		}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值