哈希以及闭散列和开散列

一、哈希

1、概念

  • 哈希是一种把任意长度的输入通过散列算法变换成固定长度的输出的方法,也称为散列、杂凑。在计算机科学中,哈希数据结构是一种非常重要的数据存储和检索方法。
  • 哈希函数将数据的关键值映射到一个特定的位置,从而实现快速的数据查找、插入和删除。
  • 使用哈希构造出来的结构称为哈希(散列)表(Hash Table)。

2、哈希冲突

  • 不同关键字通过相同哈希函数计算出相同的哈希地址,进而导致不同的值映射到相同的位置上,即为哈希冲突或哈希碰撞。
  • 把具有不同关键码而具有相同哈希地址的数据元素称为同义词。
  • 解决哈希冲突两种常见的方法是闭散列和开散列。

二、哈希函数

1、设计原则

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

2、常见的哈希函数

(1)直接定址法

  • 取关键字的某个线性函数为散列地址。
  • 优点为简单、均匀。关键字和存储位置之间是一一对应的,不存在哈希冲突。
  • 缺点为需要事先知道关键字的分布情况。如果数据很分散,会导致所开的空间很大,即浪费资源。
  • 使用场景为关键字范围集中、数量少且连续的情况。

(2)除留余数法

  • 设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址。
  • 使用场景为关键字分散、数量多。关键字和存储位置之间是多对一的关系,存在哈希冲突。

(3)平方取中法

  • 取关键字平方值的中间三位数字作为哈希地址。
  • 假设关键字为1234,它的平方值是1522756,取中间的3位227作为哈希地址。
  • 假设关键字为4321,它的平方值是18671041,取中间的3位671或者710作为哈希地址。
  • 平方取中法适合用于不知道关键字的分布,而位数又不是很大的情况。

(4)折叠法

  • 将关键字从左到右分割成位数相等的几部分(最后一部分位数可以短些),然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。
  • 折叠法适合用于不知道关键字的分布,位数比较多的情况。

(5)随机数法

  • 选择一个随机函数,取关键字的随机函数值为它的哈希地址,即H(key) = random(key),其中random为随机数函数。
  • 随机数法通常用于关键字长度不相等的情况。

(6)数学分析法

  • 设有n个d位数,每一位可能有r种不同的符号,这r种不同的符号在各位上出现的频率不一定相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只有某几种符号经常出现。可根据散列表的大小,选择其中各种符号分布均匀的若干位作为散列地址。
  • 数字分析法通常适合处理关键字位数比较大,事先知道关键字的分布且关键字的若干位分布较均匀的情况。

3、注意

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

三、闭散列

1、概念

  • 闭散列是一种预先分配存储空间的方法。 在闭散列中,存储空间被划分为若干个固定大小的桶,每个桶中可以存储多个元素。 当插入新元素时,根据其哈希值确定其所属的桶,并将元素添加到该桶中。 如果发生哈希冲突,则将元素添加到下一个可用的桶中。
  • 这种方法简单易行,但可能会导致某些桶被过度使用,而其他桶却是空闲的。即空间利用率比较低。

2、操作

  • 采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。所以,可以采用标记的伪删除法删除元素。
  • 当查找特定元素时,需要查找到表位置为空为止,避免发生冲突的值查找不到。当再插入数据时,标记为删除的位置可以重新覆盖值。

3、线性探测

(1)操作

  • 在散列表中,每个单元存储一个元素。 当哈希函数对一个元素产生一个值时,这个值会指向散列表中的某个单元。 如果这个单元已经被另一个元素占用,就会发生冲突。
  • 查找散列表中离冲突单元最近的空闲单元,并将新的元素插入到这个空闲单元中。当发生冲突时,算法会按照一定的步长(通常为1)逐个检查邻近的单元,直到找到一个空闲单元或者达到某个终止条件(如到达散列表的末尾或者最开始冲突的位置)。

(2)优点与缺点

  • 优点为实现简单。
  • 缺点为一旦发生哈希冲突,所有的冲突连在一起,容易产生数据堆积。即不同的元素占据了可利用的空位置,使得寻找某元素的位置需要进行多次比较,即搜索效率降低。

(3)插入元素操作示意图

在这里插入图片描述

4、二次探测

(1)操作

  • 二次探测的方法是根据一个增量序列(通常是1,2,3,……)来探测相邻的位置,直到找到一个空闲位置。
  • 使用一个哈希函数将元素映射到哈希表中的一个位置。如果该位置已经被占用,则使用二次探测的方法来寻找一个空闲位置。
  • 如果探测到哈希表的末尾仍然没有找到空闲位置,则需要使用再散列的方法,即重新计算哈希值,并使用新的哈希值来查找空闲位置。

(2)优点与缺点

  • 优点为二次探测法通过跳跃式探测避免了连续位置的占用,从而减少了聚集现象的发生;能够较好地保持哈希表的负载因子,提高哈希表的查找和插入效率。
  • 缺点为当哈希表较满时,二次探测法的探测过程可能会变长,甚至可能探测失败(即所有可能的位置都被占用)。
  • 与线性探测法相比,二次探测法在实现上较为复杂。

5、示例代码

enum State { EMPTY, EXIST, DELETE };

template<class K, class V>
class HashTable
{
	struct Elem
	{
		pair<K, V> _val;
		State _state = EMPTY;
	};
public:
	HashTable(size_t capacity = 10)
		: _ht(capacity), _size(0)
	{}
	bool Insert(const pair<K, V>& val)
	{
		if (Find(val.first))
			return false;
		if (_size * 10 / _ht.size() >= 7)
		{
			HashTable<K, V> newht;
			size_t newsize = _ht.size() * 2;
			newht._ht.resize(newsize);
			for (auto data : _ht)
			{
				if (data._state == EXIST)
				{
					newht.Insert(data._val);
				}
			}
			Swap(newht);
		}
		//如果K值类字符串等需要转换的,需实现一个仿函数HashFunc
		//HashFunc<K> hf;
		//size_t hashi = hf(val.first) % _ht.size();
		
		size_t hashi = val.first % _ht.size();
		
		size_t index = hashi;
		size_t i = 1;
		while (_ht[index]._state == EXIST)
		{
			index = hashi + i;
			index %= _ht.size();
			++i;
		}
		_ht[index]._val = val;
		_ht[index]._state = EXIST;
		_size++;

		return true;
	}
	Elem* Find(const K& key)
	{
		//如果K值类字符串等需要转换的,需实现一个仿函数HashFunc
		//HashFunc<K> hf;
		//size_t hashi = hf(key) % _ht.size();

		size_t hashi = key % _ht.size();

		size_t i = 1;
		size_t index = hashi;
		while ( _ht[index]._state != EMPTY)
		{
			if (_ht[index]._state == EXIST
				&& _ht[index]._val.first == key)
				return &_ht[index];
			index = hashi + i;
			index %= _ht.size();
			++i;
			if (index == hashi)
				break;
		}
		return nullptr;
}
private:
	vector<Elem> _ht;
	size_t _size;
};
  • 代码为线性探测,二次探测只需修改相应的i和index的值即可。

6、扩容

  • 随着在散列表中插入的元素增多,表中空闲的位置也会相应的减少。当表插满时,插入元素的操作就不能正常插入元素了,这时就需要进行扩容。
  • 散列表是否需要扩容可以用一个载荷因子来判断。它的定义为,α = 填入表中的元素个数/散列表的长度。α是散列表装满程度的标志因子。由于表长是定值,α与填入表中的元素个数成正比。所以,α越大表明填入表中的元素越多,产生冲突的可能性就越大,而效率就会降低。反之,α越小表明填入表中的元素越少,产生冲突的可能性就越小,但空间利用率就会降低。
  • 实际上,散列表的平均查找长度是载荷因子α的函数,只是不同处理冲突的方法有不同的函数。对于开放定址法,载荷因子是特别重要的因素,应严格限制在0.7-0.8以下。当其超过0.8时,查表时的CPU缓存不命中(cache missing)按照指数曲线上升。

四、开散列

1、操作

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

2、插入元素操作示意图

在这里插入图片描述

3、扩容

  • 因为哈希表中桶的个数是一定的,随着元素的不断插入,每个桶中元素的个数会不断增多。在极端情况下,可能会导致插入元素都在一个桶中,即该桶中链表节点非常多,这会影响哈希表的性能,因此在一定条件下需要对哈希表进行扩容。
  • 开散列最好的情况是每个哈希桶中刚好有一个节点,再继续插入元素时,每一次都会发生哈希冲突。因此,在元素个数刚好等于桶的个数时,可以给哈希表扩容。

更多哈希相关的内容参见位图、布隆过滤器和哈希切割

本文到这里就结束了,如有错误或者不清楚的地方欢迎评论或者私信
创作不易,如果觉得博主写得不错,请点赞、收藏加关注支持一下💕💕💕

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值