• 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,//删除
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

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

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

    构建哈希表的内容

    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;
    	};
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    闭散列的插入:

    		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的析构函数,但是HashData里面没有动态开辟的内存,所以不需要在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;
    		}
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30

    闭散列的查找:

    		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;
    		}
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    闭散列的删除:

    		bool Erase(const K& key)
    		{
    			HashData<K, V>* ret = Find(key);
    			if (ret)
    			{
    				ret->_s = DELETE;
    				_n--;
    				return true;
    			}
    			else
    				return false;
    		}
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

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

    开散列:又叫链地址法(开链法),首先对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;
    		}
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
  • 相关阅读:
    Java学习Day017(第三章:流程控制—选择结构与循环结构笔记)
    程序员这个身份,比你想象的还值钱!
    【算法与数据结构】530、LeetCode二叉搜索树的最小绝对差
    【C++】对拷贝构造函数 深浅拷贝 的理解 拷贝构造函数作用及用途?什么时候需要自定义拷贝构造函数?
    Python 基础合集4:Python的数据结构(str、list、tuple、dict、set)
    Java线程安全
    B. Bin Packing Problem(线段树+multiset)
    Pytorch: Torchvision、torchaudio 和 torch的关系
    【NLP基础】英文关键词抽取RAKE算法
    FL Studio2023水果完整中文版音乐制作软件
  • 原文地址:https://blog.csdn.net/weixin_65660590/article/details/134490391