14 哈希表和哈希桶


一、哈希表

哈希表(Hash table,也叫散列表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,通过这种方式,可以不经过任何比较,一次直接从表中得到要搜索的元素,即查找的时间复杂度为 O(1)。
这个映射函数叫做哈希(散列)函数,存放记录的数组叫做哈希(散列)表。

最典型的例子是计数排序,将要排序的数组每个元素和新开辟的数组的下标进行映射。

向哈希表中插入和搜索元素的过程如下:
先用哈希函数将被要插入或查找的键值转化为数组的一个索引。

  • 插入元素: 根据索引位置,将元素存放到此位置。
  • 搜索元素: 根据索引,找到存储在该索引位置的元素。

在理想情况下,不同的键都能转化为不同的索引值。当然,这只是理想情况,所以我们需要面对两个或者多个键都会散列到相同的索引值的情况。这就是哈希冲突的问题。

二、哈希函数

2.1. 直接定址法(常用)

取关键字的某个线性函数作为散列地址:hash(key)=A*key + B
优点:简单、均匀
缺点:实现需要知道关键字的分布情况,并且只适合查找比较小,且连续分布的情况
适用场景:查找字符串中,第一次出现的单词:构建一个数组 hash[ch-‘a’] 即为对应的地址
不适用场景:给一批数据, 1 5 8 100000 像这数据跨度大,数据元素不连续,很容易造成空间浪费

2.2. 除留余数法(常用)

设散列表中允许的地址数为m,通常是取一个不大于m,但是最接近或者等于m的质数num,作为除数,按照哈希函数进行计算hash(key)= key%num, 将关键码转换成哈希地址
优点:使用场景广泛,不受限制。
缺点:存在哈希冲突,需要解决哈希冲突,哈希冲突越多,效率下降越厉害。

2.3. 几种不常用的方法

  1. 平方取中法

hash(key)=key*key 然后取函数返回值的中间的几位,作为哈希地址
比如 25^2 = 625 取中间的一位 2 作为哈希地址

比较适合不知道关键字的分布,而位数又不是很大的情况

  1. 折叠法

将关键字从左到右分割成位数相等的几部分(最后一部分可以短些),然后将这几部分叠加求和,并且按照散列表长度,取最后几位作为散列地址

适用于不知道关键字分布,关键字位数比较多的情况

  1. 随机数法

选取一个随机函数,取关键字的随机函数值,作为它的哈希地址,hash(key) = random(key),random为随机函数

通常用于关键字长度不等的情况

  1. 数学分析法

通过实现分析关键字,来获取哈希地址

比如用每个人的手机号码充当关键字,如果采用前三位作为哈希地址,那么冲突的概率是非常大的。如果采用的是中间3位那么冲突的概率要小很多

常用于处理关键字位数比较大的情况,且事前知道关键字的分布和关键字的若干位的分布情况


三、哈希冲突

不同关键字通过相同哈希函数计算出相同的哈希映射地址,这种现象称为哈希冲突或哈希碰撞。
在这里插入图片描述

解决哈希冲突通常有两种方法:闭散列(开放地址发)和开散列(链地址法)。


四、闭散列

闭散列也叫做开放地址法,当发生哈希冲突的时候,如果哈希表未被填满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置的"下一个"空位置中去,寻找下一个空位置的方法有线性探测法和二次探测法

4.1. 线性探测

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

优点:实现非常简单
缺点:一旦发生哈希冲突,所有的冲突连在一起,容易产生数据"堆积",即不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要进行多次比较,导致搜索效率降低。
在这里插入图片描述

4.2. 负载因子

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

散列表的载荷因子定义为 α = 填入表中的元素 / 散列表的长度

α是散列表装满程度的标志因子,α越大表明装入表中的元素越多,产生冲突的可能性也就越大,反之填入表中的元素越少,冲突可能性越低,空间利用率也就越低

闭散列:一般将载荷因子控制在 0.7-0.8以下,超过0.8查表时候的缓存不中率会按照指数曲线上升(哈希可能性冲突越大),因此一般hash库中,都将α设置在0.8以下。 闭散列,千万不能为满,否则在插入的时候会陷入死循环

开散列/哈希桶:一般将载荷因子控制在1。超过1,那么链表就挂得越长,效率也就越低

4.3. 二次探测

线性探测的缺陷是产生哈希冲突,容易导致冲突的数据堆积在一起,这是因为线性探测是逐个的找下一个空位置.
二次探测为了缓解这种问题(不是解决),对下一个空位置的查找进行了改进(跳跃式查找):
POS = (H+i2)%m
其中:i=1、2、3…
H是发生哈希冲突的位置
m是哈希表的大小
在这里插入图片描述

4.4. 插入和删除操作

插入操作比较简单:通过哈希函数插入元素在哈希表中的位置,如果发生了哈希冲突,则使用线性探测或二次探测寻找下一个空位置插入元素。
但是删除操作比较麻烦,采用闭散列处理哈希冲突时,不能随便删除哈希表中已有的元素,如果直接删除元素,会影响其他元素的搜索(比如原来下标为40的元素因为前面删除了一个元素下标变成了39)。
因此线性探测采用标记的伪删除法来删除下一个元素。

4.5. 扩容操作

为了减少冲突,哈希表的大小最好是素数。为了能够获取每次增容后的大小,将需要用到的素数序列提前用一个数组存储起来,当我们需要增容时就从该数组当中进行获取。

4.6. 代码实现

namespace CloseHash//闭散列
{
	enum State
	{
		EMPTY,
		EXIT,
		DELETE
	};
	template<class K,class V>
	struct  HashData
	{
		pair<K, V> _kv;
		State _state=EMPTY;//节点的状态默认为空
	};
	template<class K>
	struct  Hash
	{
		size_t operator()(const K& key)
		{
			return key;
		}
	};
	template<>
	struct  Hash<string>
	{
		size_t operator()(const string& s)
		{
			size_t value = 0;
			for (auto ch : s)
			{
				value += ch;
				value *= 131;
			}
			return value;
		}
	};
	template<class K, class V,class HashFunc=Hash<K>>
	struct  HashTable
	{
	public:
		size_t GetNextPrime(size_t prime)
		{
			static 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, 429496729ul
			};

			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)
		{
			HashData<K, V>* ret = Find(kv.first);
			if (ret) //哈希表中已经存在该键值的键值对(不允许数据冗余)
			{
				return false; //插入失败
			}

			//进行扩容检测
			if (_n == 0 || (_n / _table.size() * 10 > 7))//当前个数为0或者载荷因子超过了,则进行扩容
			{
				//size_t newsize = _size == 0 ? 10 : 2 * _tables.size();//初始化给10,后续扩容两倍

				//选取素数
				size_t newsize = GetNextPrime(_table.size());

				//扩容之后,需要重新计算元素的位置

				HashTable<K, V, HashFunc> newHT;
				newHT._table.resize(newsize);

				for (auto& e : _table)
				{
					if (e._state == EXIT)
						newHT.Insert(e._kv);
				}
				_table.swap(newHT._table);//进行交换
			}
	
			HashFunc hf;
			size_t start= hf(kv.first) % _table.size();
			size_t index = start;
			//探测后面的位置,线性探测或二次探测
			size_t i = 1;
			while (_table[index]._state == EXIT)
			{
				index =start+i;
				index %= _table.size();
				++i;
			}
			_table[index]._kv = kv;
			_table[index]._state = EXIT;
			++_n;
			return true;
		}
		HashData<K,V>* Find(const K& key)
		{
			if (_table.size() == 0) return nullptr;

			HashFunc hf;
			size_t start = hf(key) % _table.size();
			size_t index = start;
			size_t i = 1;
			while (_table[index]._state != EMPTY)
			{
				if (_table[index]._state== EXIT
					&&_table[index]._kv.first == key)
				{
					return &_table[index];
				}
				index = start + i;
				index %= _table.size();
				++i;
			}
			return nullptr;
		}

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

	private:
		vector<HashData<K,V>> _table;
		size_t _n = 0;
	};
}

五、开散列(哈希桶)

开散列又名哈希桶/开链法,首先对关键码集合采用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表串联起来,各个链表的头节点存储在哈希表中
在这里插入图片描述

5.1. 开散列扩容

与闭散列不同,开散列需要负载因子达到1的时候才进行扩容。
因为在理想的情况下每个桶下面只有一个节点。哈希桶的载荷因子控制在1,当大于1的时候就进行扩容,这样平均下来,每个桶下面只有一个节点;

与闭散列进行比较: 看起来哈希桶之中存储节点的指针开销比较大,其实不然。闭散列的负载因子需要保证小于0.7,来确保有足够的空间降低哈希冲突的概率,而表项的空间消耗远远高于指针所占的空间效率,因此哈希桶更能节省空间。

5.2. 代码实现

namespace OpenHash//开散列
{
	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>
	struct  Hash
	{
		size_t operator()(const K& key)
		{
			return key;
		}
	};
	template<>
	struct  Hash<string>
	{
		size_t operator()(const string& s)
		{
			size_t value = 0;
			for (auto ch : s)
			{
				value += ch;
				value *= 131;
			}
			return value;
		}
	};
	template<class K, class V, class HashFunc = Hash<K>>
	struct HashTable
	{
	public:
		typedef HashNode<K, V> Node;
		Node* Find(const K& key)
		{
			HashFunc hf;
			if (_table.size() == 0) return nullptr;
			size_t index = hf(key) % _table.size();
			Node* cur = _table[index];
			//在桶中进行查找
			while (cur)
			{
				if (cur->_kv.first == key)
				{
					return cur;
				}
				else
				{
					cur = cur->_next;
				}
			}
			return nullptr;
		}

		bool Insert(const pair<K, V>& kv)
		{
			HashFunc hf;
			if (Find(kv.first)) return false;//列表里已经存在kv

			//负载因子到1时进行增容
			if (_n == _table.size())
			{
				vector<Node*>newtable;
				size_t newSize = _table.size() == 0 ? 10 : _table.size() * 2;
				newtable.resize(newSize);

				//遍历旧表中的节点,重新计算映射位置,挂到新表中
				for (size_t i = 0; i < _table.size(); ++i)
				{
					if (_table[i])
					{
						Node* cur = _table[i];
						while (cur)
						{
							//记录原来cur后面的节点
							Node* next = cur->_next;
							size_t index = hf(cur->_kv.first) % newtable.size();
							//头插
							cur->_next = newtable[index];
							newtable[index] = cur;
							cur = next;
						}
						_table[i] = nullptr;
					}
				}
				_table.swap(newtable);
			}
			size_t index = hf(kv.first) % _table.size();
			Node* newnode = new Node(kv);
			newnode->_next = _table[index];
			_table[index] = newnode;
			++_n;
		}
		bool Erase(const K& key)
		{
			HashFunc hf;
			size_t index = hf(key) % _table.size();
			Node* prev = nullptr;
			Node* cur = _table[index];
			//在桶中找到对应的节点进行删除
			while (cur)
			{
				if (cur->_kv.first == key)
				{
					//头结点的情况
					if (_table[index] == cur)
					{
						_table[index] = cur->_next;
					}
					else
					{
						prev->_next = cur->_next;
					}
					--_n;
					delete cur;
					return true;
				}
				prev = cur;
				cur = cur->_next;
			}
			//要删的节点不存在
			return false;
		}
	private:
		vector<Node*> _table;
		size_t _n=0;//有效数据的个数
	};
}

六、闭散列和开散列的比较

  1. 开散列处理冲突简单,且无堆积现象,即非同义词决不会发生冲突,因此平均查找长度较短;
  2. 由于开散列中各链表上的结点空间是动态申请的,故它更适合于造表前无法确定表长的情况;
  3. 闭散列为减少冲突,要求负载因子α较小,故当结点规模较大时会浪费很多空间。而开散列中可取α≥1,且结点较大时,开散列中增加的指针域可忽略不计,因此节省空间;
  4. 在用开散列构造的散列表中,删除结点的操作易于实现。只要简单地删去链表上相应的结点即可。而对闭散列构造的散列表,删除结点不能简单地将被删结点的空间置为空,否则将截断在它之后填人散列表的同义词结点的查找路径。这是因为各种开放地址法中,空地址单元(即开放地址)都是查找失败的条件。因此在用开放地址法处理冲突的散列表上执行删除操作,只能在被删结点上做删除标记,而不能真正删除结点。实际删除操作待表格重新整理时在进行,这种方法也被称为惰性删除。

资料参考:
哈希表、哈希桶的实现
哈希表底层探索
数据结构之(一)Hash(散列)
哈希之开散列,闭散列

  • 6
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

今天也要写bug、

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值