哈希表、哈希桶(C++实现)

  • Post author:
  • Post category:其他




哈希概念

在顺序结构和平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。

顺序查找时间复杂度为O(N)

,平衡树中为树的高度,

O(logN )

,搜索的效率取决于搜索过程中元素的比较次数。

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

向该结构中:

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

    该方式为

    哈希(散列)方法

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

    哈希表(Hash Table)(或者称散列表)

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

哈希函数设置为:hash(key) = key % capacity; capacity为存储元素底层空间总的大小。

我们将数据映射到capacity为10的哈希表中如下:

在这里插入图片描述

这种方法的搜索速度很快。



哈希函数

常用的2种方法:

1.直接定址法:取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B


优点:简单、均匀

缺点:1需要知道关键字的分布情况,如果给的一组数据范围很大,就会浪费空间

2.不能处理浮点数,字符串等数据

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


优点:数据范围很大

缺点:不同的值映射到同一个位置会冲突



哈希冲突

引起哈希冲突的一个原因可能是:哈希函数设计不够合理。

哈希函数设计原则:


1.哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0 到m-1之间

2.哈希函数计算出来的地址能均匀分布在整个空间中

3.哈希函数应该比较简单



解决哈希冲突



闭散列-开放定址法

闭散列:

也叫开放定址法

,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。那如何寻找下一个空位置呢?



线性探测

线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。

Hash(key)=key%len+i(i=0,1,2…)

len:哈希表的大小

如果+0后面有数据那么再+1…直到找到空位置

比如插入11,和1冲突就往后探测。

在这里插入图片描述

动图演示:插入44

在这里插入图片描述

通过上图发现:哈希表中的数据越多,产生哈希冲突的可能性会越大。此时我们引入了

负载因子

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


1.负载因子越大,产生冲突的概率越大,增删查改的效率低

2.负载因子越小,产生冲突的概率越小,增删查改的效率高

将哈希表的大小改为20在插入{1,7,6,4,5,9,11,13},产生的冲突变少:

在这里插入图片描述

对于闭散列来说,负载因子是特别重要的因素,一般控制在

0.7~0.8以下

,超过0.8会导致在查表时CPU缓存不命中按照曲线上升。

线性探测的优点:实现简单

缺点:发生冲突,容易产生数据堆积,出现踩踏,导致搜索效率低。

✨✨✨✨✨✨✨✨✨✨✨✨我是分割线✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨

二次探测

为了解决空位置是一个一个找,二次探测避免了这个问题。

二次探测不是再探测一次的意思,二次探测的方法


Hash(key)=key%len+i ^2(i=0,1,2…)

比如插入数据{1,4,5,9,11,13},为了测试我又加了44,54来看看效果:

在这里插入图片描述

比之前的探测好了很多,之前的54是插在44后面的现在到了下表16了,避免了踩踏。



闭散列的实现



哈希表的结构

在闭散列哈希表中不仅要存数据之外,还要存储当前位置的状态,有3种状态:

1.EMPTY(无数据的空位置)

2.EXIST(已存储数据)

3.DELETE(原本有数据,但是被删除了)

我们用枚举即可

	enum State//标识每个位置的状态
	{
		EMPTY,
		EXIST,
		DELETE
	};

为什么要加上状态?

那怎么标识一个空位置呢?用数字标识?例如1:

在这里插入图片描述

但是把11删除后就找不到21了。

在这里插入图片描述

它是和1冲突,从1开始找,1后面是空停止。但是21还在表中,不可能遍历一遍哈希表,这样就是去了哈希表的意义了。因此要在哈希表中加上状态。

当哈希表中删除一个元素后,我们不应该简单的将状态设为EMPTY,要将该位置的状态设为DELETE

。在查找的过程中,如果当前位置和查找的Key值不相等,但是当前位置的状态是EXIST或者是DELETE,我们都要往后查找,而当我们插入时,可以插入到状态EMPTY和DELETE上。

哈希表中的每个位置的存储结构包括状态和数据

	template<class K, class V>
	struct HashDate
	{
		pair<K, V> _kv;//数据
		State _state = EMPTY;//状态
	};

我们还要在哈希表中存入数据的个数来计算负载因子,当负载因子过大就要将哈希表增容。

template<class K, class V>
	class HashTable
	{  
	public:
	//...
	private:
	    vector<HashDate<K, V>> _table;
		size_t _n = 0;//哈希表中元素的个数
	};



哈希表的插入

插入的逻辑如下:


1.查看是否存在该键值对,如存在则返回false

2.判断哈希表的大小是否需要调整,为0或者是负载因子过大都需要调整

3.插入元素

4.哈希表大小加1

增容的逻辑:

不能原地扩容,那原来数据的映射关系就乱了。我们要新创建一个哈希表对象,是原来的2倍,把旧的数据插入到新表中,在交换2张表即可。

插入键值对的过程:


1.先计算出对应的位置

2.如果发生冲突,进行线性探测处理,向后找到一个状态为EMPTY或者DELETE的位置插入

3.插入到该位置,并将状态设为EXIST

代码如下:

bool insert(const pair<K, V>& kv)
		{
			//通过查找看看哈希表中是否存在
			HashDate<K,V>* ret = Find(kv.first);
			if (ret)
			{
				//如果存在返回false
				return false;
			}
			if (_table.size() == 0)
			{
				_table.resize(10);
			}
			else if ((double)_n /(double)_table.size() > 0.7)
			{
				//增容
				HashTable<K,V> newHT;//创建一个新的表
				newHT._table.resize(2 * _table.size());
				//将旧表的数据插入到新的哈希表
				for (auto& e : _table)
				{
					if (e._state == EXIST)
					{
						newHT.insert(e._kv);
					}
				}
				_table.swap(newHT._table);
			}
			//计算位置
			size_t start = kv.first % _table.size();
			size_t index = start;
			size_t i = 1;
			while (_table[index]._state == EXIST)
			{
				index = start + i;//线性探测
				index %= _table.size();//防止超出表的范围
				++i;
			}
			//找到可以插入的位置
			_table[index]._kv = kv;
			_table[index]._state = EXIST;//调整状态
			_n++;//哈希表元素++
			return true;
		}



哈希表的查找

查找的步骤:


1.先判断哈希表是否为0,等于0返回false,因为不能除0操作

2.除留余数算出映射位置

3.从映射的地址开始线性探测查找,找到返回成功,找到一个状态为EMPTY返回false



重点:

在查找时,必须找到位置状态为EXIST且Key值相等,才能查找成功。仅仅是Key值相等,但是当前位置的状态为DELETE,则还需要继续向后查找,因为该位置的值已经删除了。

代码如下:

		HashDate<K,V>* Find(const K& key)
		{
			//表为空返回false
			if (_table.size() == 0)
			{
				return nullptr;
			}
			size_t start = key % _table.size();//计算出映射的地址
			size_t index = start;
			size_t i = 1;
			while (_table[index]._state != EMPTY)
			{
				//该位置的状态为EXIST,且Key值相等,查找成功
				if (_table[index]._state == EXIST 
					&& _table[index]._kv.first == key)
				{
					return &_table[index];
				}
				//继续线性探测查找
				index = start + i;
				index %= _table.size();
				i++;
			}
			return nullptr;
		}



哈希表的删除

删除的过程很简单,我们只需要进行伪删除,将待删除元素的状态设为DELETE即可。

步骤如下:



1.先调用查找,找到该元素,若没有找到返回false

2.找到将状态设为DELETE

3.哈希表中的有效数据–

代码如下:

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

下面来测试测试:

void Test_Hash()
{
	int a[] = { 1,5,10,1000,100,18,15,7,40 };
	CloseHash::HashTable<int, int> ht;
	for (auto e : a)
	{
		ht.insert(make_pair(e,e));
	}
	auto ret = ht.Find(10);
	if (ret)
		cout << "找到了" << endl;	
	else
		cout << "没找到" << endl;	
	ht.Erase(10);
	ret = ht.Find(100);
	if (ret)
		cout << "找到了" << endl;	
	else	
		cout << "没找到" << endl;
	ret = ht.Find(10);
	if (ret)
		cout << "找到了" << endl;
	else
		cout << "没找到" << endl;
}

我先找10,在删除10,看看能不能找到100,在看看10删除后能不能找到。

在这里插入图片描述

是没有问题的。



开散列的实现-拉链法

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

例如:下面的数据

在这里插入图片描述

动图演示:

在这里插入图片描述

✨✨✨✨✨✨✨✨✨✨✨✨我是分割线✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨



哈希表的结构

哈希表的每个位置存某个单链表的头结点,所以每个哈希桶中存储的数据应该是一个结点的类型,还需要一个节点的指针指向它的下一个结点。

	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
	{
	public:
	  typedef HashNode<K,V> Node;
	  private:
		vector<Node*> _table;//哈希表
		size_t _n = 0;//缺省值
	};



哈希表的插入

插入的步骤:

1.它是不允许冗余的,所以先查找Key,存在返回false

2.检查负载因子,如果哈希表为空或者负载因子过大就需要进行增容处理

3.计算出在哈希表的位置进行头插

4.哈希表的有效数据在++


增容处理:




1.创建新的哈希表,大小是原来旧表的2倍

2.遍历旧表,算出在新表中的位置,在将结点头插到新表中

3.旧表位置的指针置空,交换2张哈希表


在这里插入图片描述

代码如下:

//插入
		bool insert(const pair<K,V>& kv)
		{
			//如果表中已经有该元素返回false,不能出现数据冗余
			HashNode<K, V>* ret = Find(kv.first);
			if (ret)
			{
				return false;
			}
			//检查负载因子,负载因子超过1或者table.szie=0需要调整
			if (_n == _table.size())
			{
				//进行增容,创建一个新的哈希表
				vector<Node*> newtable;
				//新表的大小等于旧表的2倍
				size_t newsize = _table.size() == 0 ? 10 : _table.size() * 2;

				newtable.resize(newsize, nullptr);//把newsize给新表,并将表的位置初始化
				//把旧表的结点插入到新表中
				for (size_t i = 0; i < _table.size(); ++i)
				{
					if (_table[i])//桶不为空,插到新表中
					{
						Node* cur = _table[i];
						while (cur)//桶中的结点不为空
						{
							//计算新表中的映射位置
							size_t index = kv.first % newtable.size();
							//保存cur的下1个结点,不保存头插的话找不到下个结点
							Node* next = cur->_next;
							cur->_next = newtable[index];
							newtable[index] = cur;

							cur = next;
						}
						_table[i] = nullptr;//旧桶置空
					}
				}
				_table.swap(newtable);//交换2个哈希表
			}
			//计算出映射的位置
			size_t index = kv.first % _table.size();
			Node* newnode = new Node(kv);
			//头插
			newnode->_next = _table[index];
			_table[index] = newnode;
			++_n;

			return true;
		}



哈希表的查找

查找的步骤:

1.计算出在哈希表中的映射位置

2.遍历结点在链表的位置

注意:

哈希表为0时处理一下,不能进行除0操作

		//查找
		HashNode<K, V>* Find(const K& key)
		{
			if (_table.size() == 0)
			{
				return nullptr;
			}
			//1.先计算出映射的位置
			size_t index = key % _table.size();
			//2.遍历链表的位置
			Node* cur = _table[index];
			while (cur)
			{
				//找到返回结点
				if (cur->_kv.first == key)
				{
					return cur;
				}
				else
				{
					cur = cur->_next;
				}
			}
			//没找到返回空
			return nullptr;
		}



哈希表的删除

删除的步骤:

1.还是先算出在哈希表中的映射位置

2.开始删除

3.哈希表中的数据–


删除的时候注意删除第1个结点

	//删除
		bool Erase(const K& key)
		{
			//1.先计算出映射的位置
			size_t index = key % _table.size();
			//2.开始删除
			Node* cur = _table[index];
			Node* prev = nullptr;//保存cur的前1个结点
			while (cur)
			{
				//找到了开始删除
				if (cur->_kv.first == key)
				{
					if (prev == nullptr)//删除第1个结点
					{
						_table[index] = cur->_next;//指向cur的下一个结点
					}
					else
					{
						prev->_next = cur->_next;
					}
					delete cur;//释放该结点
					_n--;//哈希表中数据--
					return true;
				}
				prev = cur;
				cur = cur->_next;
			}
			return false;//没有该结点返回false
		}

下面来测试一下,博主这里是没有实现迭代器的。等到封装unordered_set和unordered_map的时候在实现迭代器。

void Test_Hash1()
{
	int a[] = { 1,5,10,20,18,15,7,40 };
	OpenHash::HashTable<int,int> ht1;
	for (auto e : a)
	{
		ht1.insert(make_pair(e, e));
	}
	auto ret = ht1.Find(10);
	if (ret)
		cout << "找到了" << endl;
	else
		cout << "没找到" << endl;
	ht1.Erase(10);
	ret = ht1.Find(20);
	if (ret)
		cout << "找到了" << endl;
	else
		cout << "没找到" << endl;
	ret = ht1.Find(10);
	if (ret)
		cout << "找到了" << endl;
	else
		cout << "没找到" << endl;
}

插入好的效果如下图:

在这里插入图片描述

我们通过调试来看看:

在这里插入图片描述

看出是没有问题的。

在这里插入图片描述

查找也都可以。



小结

在实际中哈希桶更实用,在极端的情况下也能处理。比如下图,在下面挂了很多的结点,此时效率就会很低很低。

在这里插入图片描述

前辈们的办法是把挂多的结点放在红黑树中。

在这里插入图片描述

桶中种树

在这里插入图片描述

所以前辈还是很牛的。在JAVA中比较新的版本中结点超过8个就会将单链表改成红黑树,结点少于8个又会变回单链表的结构。这种结构我们知道即可,不用我们自己去实现。

但是有的地方没做这种处理也是可以的,当负载因子够大的时候就会触发增容的机制,就会开更大的表,映射位置也就随之改变而桶中的结点也会减少。



版权声明:本文为weixin_45599288原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。