hash哈希

Posted 可乐不解渴

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了hash哈希相关的知识,希望对你有一定的参考价值。

hash哈希


人间四月芳菲尽,山寺桃花始盛开。

哈希概念

顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。搜索的效率取决于搜索过程中元素的比较次数,因此顺序结构中查找的时间复杂度为O (N) ,AVL树与红黑树中查找的时间复杂度为树的高度O(logN)。

而我们想要最理想的搜索方法是,可以不经过任何比较,一次直接从表中得到要搜索的元素,即查找的时间复杂度为O(1)。
哈希是指构造一种储存结构,通过某种函数,使得其元素的储存位置与他的关键码之间能够建立一一映射关系,那么在查找时通过该函数很快找到相应元素。简言之,就是设定某一固定函数(hashFunc),通过此函数来使插入元素的值与元素位置相对应,往后我们需要查找此元素时就可以通过此函数(hashFunc)找到该值。

哈希冲突

不同元素通过相同的哈希函数计算出相同的哈希地址,该种现象被称为哈希冲突或者哈希碰撞
我们把具有不同关键码而具有相同哈希地址的数据元素称为“同义词“。

哈希函数

散列函数(英语:Hash funcTIon)又称散列算法、哈希函数,是一种从任何一种数据中创建小的数字“指纹”的方法。散列函数把消息或数据压缩成摘要,使得数据量变小,将数据的格式固定下来。该函数将数据打乱混合,重新创建一个叫做散列值(hash values,hash codes,hash sums,或hashes)的指纹。散列值通常用一个短的随机字母和数字组成的字符串来代表。哈希函数使得计算出来的地址均匀分布在整个空间。

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

其中哈希函数设计原则如下:

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

常用的哈希函数如下:

1、直接定值法
取关键字的某个线性函数为散列地址:Hash(Key) = A*Key + B;
优点:简单、均匀,并且每一个值都有唯一的一个位置来进行一一对应映射,不会造成哈希冲突;
缺点:需要事先知道关键字的分布情况;
使用场景:适合查找比较小且关键字连续的情况;

2、除留余数法
设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数:Hash(key) = key % p(p <= m),将关键码转换成哈希地址;
优点:简单、使用场景广泛;
缺点:会发生哈希冲突,且一但发生哈希冲突会导致一连篇的哈希冲突发生,从而导致效率大大的降低;

3、平方取中法
假设关键字为关键字为4321,对它平方就是18671041,抽取中间的3位671(或710)作为哈希地址。
平方取中法适用场景:不知道关键字的分布,而位数又不是很大的情况;

4、折叠法
折叠法是将关键字从左到右分为位数相同的几部分(最后一部分位数可以短些),然后取这几部分的叠加和(舍去进位)作为散列地址。
折叠法适用场景:用于关键字位数较多,并且关键字中每一位上数字分布大致均匀;

5、随机数法
选择一个随机函数,取关键字的随机函数值为它的哈希地址,即 H(key) = random(key) , 其中random为随机数函数。
随机数法适用场景:通常应用于关键字长度不等时采用此法;

6、数字分析法
当关键字的位数大于地址的位数,对关键字的各位分布进行分析,选出分布均匀的任意几位作为散列地址。
数字分析法适用场景:仅适用于所有关键字都已知的情况下,根据实际应用确定要选取的部分,尽量避免发生冲突;

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

哈希冲突解决

解决哈希冲突两种常见的方法是:闭散列开散列

闭散列

闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个”空位置中去。

那么我们如何去寻找到下一个空位置呢?其中我们常用的方法如下:

1、线性探测法
线性探测法的地址增量di = 1, 2, … , m-1,其中,i为探测次数。该方法依次探测下一个地址,直到有空的地址后插入,若整个空间都找不到空余的地址,则产生溢出。

通过上图我们可以看到,随着数据的增多,产生冲突的可能性会增加,而当哈希表中的元素越多,那么再查找时的效率也会越来越低。
介于此,我们引入了负载因子的概念。并且我们发现当负载因子越大,产出冲突的概率也就会越高,查询的效率越低。相反,当负载因子越小,产出冲突的概率越低,查找的效率也就相应越高。

其中负载因子的计算方式如下:

负载因子 = 表中元素个数 / 哈希表的空间大小

例如,我们将哈希表的容量扩容为之前的二倍,也就是容量大小变为20,此时我们可以看到再插入相同的映射位置时,产生的哈希冲突有所降低:

但负载因子越小,也就意味着空间的利用率越低,此时大量的空间实际上都被浪费了。
线性探测的缺点:一旦发生冲突,所有的冲突连在一起,容易产生数据“堆积”,即不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要多次比较(踩踏效应),导致搜索效率降低。

2、二次探测法

线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法为:

H(i) = (H(0)+i^2) % m,或者:H(i) = ( H(0) - i ^2 )% m。其中:i = 1,2,3…,

是通过散列函数Hash(x)对元素的关键码 key 进行计算得到的位置,m是表的大小。(i代表第几次探测)

开散列

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

例如,我们将序列1, 6, 15, 60, 88, 7, 40, 5, 10插入到表长为10的哈希表中,哈希函数利用的是除留余数法,哈希函数为:Hash(key) = key % 10当发生哈希冲突时我们采用开散列的形式,将哈希地址相同的元素都链接到同一个哈希桶下,插入过程如下:

闭散列解决哈希冲突采用的是一种互相报复的方式,当我的位置被其他元素占用了我就去占用其他位置。
开散列解决哈希冲突采用的是一种较为乐观的方式,即使我的位置被其他元素占用了,但是没关系,我可以头插挂在这个单链表的头上。

但这种哈希桶的方式有一种极端的情况就是,当所有元素的都再同一个桶的位置,即都在一个单链表上,此时该哈希表增删查改的效率就退化成了O(n)。

如果发生这种情况,我们可以将单链表换成红黑树或者AVL树,并把根结点让入到哈希表中。

哈希表的闭散列实现

哈希表闭散列结构

哈希表的结点中的数据除了要有存储数据的data之外,还要存储对应位置在当前哈希表中的状态,如果不存储当前哈希中的状态,就会出现问题,以下图为例:

当1这个位置的元素被删除时,那么由于11这个元素线性探测到的位置是在2这个下标下,但是由于1下标这个位置并没有元素所占据,这就造成了元素11在哈希表当中,但是在查找的过程中查询不到。

我们利用枚举常量来枚举出所有的状态信息,其中状态的定义如下:

enum Status

	EMPTY,  //表示当前位置为空,没有被占用
	EXIST,  //表示当前位置已被占用
	DELETE  //表示当前位置原本有数据,但是被我们删除了
;

根据上述的描述,我们直到哈希表中的结点定义要包含存储数据以及结点状态,其中结点定义如下:

template<class K, class V>
struct HashNode

	pair<K, V>  m_kv;
	Status m_status = Status::EMPTY;
;

而为了在插入元素时要获取到当前哈希表的负载因子,此时我们还应该时刻存储负载因子以及哈希表中的元素的个数。

template<class K,class V>
class Hash

public:
	...
private:
	vector<HashNode<K, V>> m_hash;
	size_t m_num; //有效数据的个数
	double m_loadFactor; //负载因子
;

哈希表的闭散列插入

我们向哈希桶的闭散列插入数据要遵循以下的步骤:

  1. 查看哈希表中是否以及存在该键值对;
  2. 查看待插入元素根据哈希函数映射到哈希表中对应的映射位置是否以及被其他元素占用,即检查该位置的状态。若产生哈希冲突,则从哈希地址处开始,采用线性探测向后寻找一个状态为EMPTY或DELETE的位置,并将键值对插入到该位置,并将该位置的状态设置为EXIST。;
  3. 更新负载因子;

当负载因子超过我们规定的0.7时,我们就需要对哈希表进行增容。我们这里简单处理成每次都增加之前的2倍的容量。

注意: 在将原哈希表的数据插入到新哈希表的过程中,不能只是简单的将原哈希表中的数据对应的挪到新哈希表中,而是需要根据新哈希表的大小重新计算每个数据在新哈希表中的位置,然后再进行插入,因为此时的哈希函数发生了变化。

bool Insert(const pair<K,V>  kv)

	if (Find(kv.first) != nullptr)
	
		return  false;
	
	if (m_loadFactor > 0.7)
	
		Hash<K, V>newTable; //新哈希表
		newTable.m_hash.resize(m_hash.size()*2); //开辟2倍容量的新哈希表
		for (auto& e : m_hash) //遍历旧表
		
			if (e.m_status == EXIST) //把所有存在的元素插入到新表当中
			
				newTable.Insert(e.m_kv);
			
		
		m_hash.swap(newTable.m_hash);
	

	//这里取模为什么用的不是capacity呢?因为当比如使用reserve函数提前开好空间,但是后面的开好的空间并不给我们直接去访问
	//这样就会使得程序崩溃。想要 % 的是capacity就得必须保证 size与capacity一致。
	//否则建议还是使用 % size()
	size_t index = kv.first % m_hash.size();
	
	//探测后面的位置  --线性探测
	size_t i = 1;
	while (m_hash[index].m_status == EXIST)
	
		index += i;
		index %= m_hash.size();
	

	m_hash[index].m_kv = kv;
	m_hash[index].m_status = EXIST;
	++m_num;
	m_loadFactor=(double)m_num/m_hash.size();
	return true;

哈希表的闭散列查找

在哈希表的闭散列查找数据的步骤如下:

  1. 数据通过哈希函数计算出对应的映射地址;
  2. 从映射地址出开始查询,按照哈希冲突的解决方案(线性探测或者二次探测等等)来进行数据的查找,直到找到要查找的数据则查找成功,或者找到一个状态为EMPTY的位置则查找失败。
HashNode<K,V>* Find(const K& key)

	size_t index = key % m_hash.size();
	size_t i = 1;
	while(m_hash[index].m_status != EMPTY)
	
		if (m_hash[index].m_kv.first == key && m_hash[index].m_status == EXIST)
		
			return &m_hash[index];
		
		index += i;
		index %= m_hash.size();
	
	return nullptr;

哈希表的闭散列删除

删除哈希表中的数据我们只需要采取伪删除的方式即可,即将要删除的位置的状态设置为DELETE即可。

其中删除数据的步骤如下:

  1. 查找当前的哈希表当中是否存在该元素,如果不存在则删除失败;
  2. 若存在,则将该位置的状态设置为DELETE;
  3. 更新负载因子;
bool Erase(const K& key)

	HashNode<K, V>* ret = Find(key);
	if (ret == nullptr)
	
		return false;
	
	else
	
		ret->m_status = DELETE;
		--m_num;
		return true;
	

哈希表的开散列实现

哈希表的开散列结构

在闭散列结构的哈希表中每一个位置存储的是一个结点,而在开散列哈希表中,哈希表的每一个位置存储的是一个个单链表的头结点,即这个哈希桶实际上是一个指针数组。

	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 hashfunc = hash<k>>
	class hashtable
	
		typedef hashnode<k, v> node;
	public:
		//...
	private:
		vector<node*> _table;
		size_t _n = 0;         // 有效数据的个数
	;

哈希表的开散列插入

向哈希表中插入数据的步骤如下:

  1. 查看哈希表中是否存在该键值的键值对,若已存在则插入失败;
  2. 根据负载因子来进行判断是否需要调整哈希表的大小,当负载因子过大需要对哈希表的大小进行调整;
  3. 将该数据插入到哈希表中,并将有效元素个数加一;

注意:在将原哈希表的数据插入到新哈希表的过程中,不要通过复用插入函数将原哈希表中的数据插入到新哈希表,因为在这个过程中我们需要创建相同数据的结点插入到新哈希表,在插入完毕后还需要将原哈希表中的结点进行释放,多此一举。

下面代码中为了降低时间复杂度,在增容时将原哈希表的数据插入到新哈希表的过程中取的结点都是从单链表的表头开始向后依次取的,在插入结点时也是直接将结点头插到对应单链表。
注意: 虽然这样降低了时间复杂度,但也导致了一个问题,即元素的相对位置发生了改变。

将数据插入哈希表的具体步骤如下:

  1. 通过哈希函数计算出对应的哈希地址;
  2. 将该结点头插到对应单链表即可;
bool insert(const pair<k, v>& kv)

	if (find(kv.first))
		return false;

	hashfunc hf;
	// 负载因子到1时,进行增容
	if (_n == _table.size())
	
		vector<node*> newtable;
		//size_t newsize = _table.size() == 0 ? 8 : _table.size() * 2;
		//newtable.resize(newsize, nullptr);
		newtable.resize(getnextprime(_table.size()));

		// 遍历取旧表中节点,重新算映射到新表中的位置,挂到新表中
		for (size_t i = 0; i < _table.size(); ++i)
		
			if (_table[i])
			
				node* cur = _table[i];
				while (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;

	return true;

哈希表的开散列查找

查找数据的步骤如下:

  1. 该数据通过哈希函数计算出对应的哈希映射地址。
  2. 并且通过哈希地址找到对应的哈希桶中的单链表的表头,然后进行依次O(n)的复杂度的遍历单链表进行查找即可。
node* find(const k& key)

	if (_table.size() == 0)
	
		return false;
	

	hashfunc hf;
	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;

哈希表的开散列删除

删除数据步骤如下所示:

  1. 首先通过哈希函数计算出对应的桶号(哈希映射地址);
  2. 遍历这个桶,来查找这个待删除元素;
  3. 找到删除元素后直接删除即可,并更新有效元素的个数。
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;

下期预告:unordered_map与unordered_set的模拟实现。

以上是关于hash哈希的主要内容,如果未能解决你的问题,请参考以下文章

Hash冲突的解决方法

区块链Hash算法

哈希冲突

如何优雅地将 ember 哈希位置重定向到历史位置

哈希表与哈希(Hash)算法

哈希算法(Hash)