C++进阶第二十一篇——哈希(概念+哈希函数+哈希冲突+哈希表+哈希桶+代码实现)

Posted 呆呆兽学编程

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了C++进阶第二十一篇——哈希(概念+哈希函数+哈希冲突+哈希表+哈希桶+代码实现)相关的知识,希望对你有一定的参考价值。

⭐️今天我要和大家介绍一种新的算法思想——哈希,其中哈希中会用到的转换函数称为哈希函数,构造出来的结构叫哈希表(散列表)
⭐️博客哈希表和哈希桶完整代码已上传至gitee:https://gitee.com/byte-binxin/cpp-class-code

目录


🌏概念

概念: 不经过任何比较,一次直接从表中得到要搜索的元素。 如果构造一种存储结构,通过某种函数(HashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素,其中哈希方法中用到的转换函数称为哈希函数,构造出来的结构叫哈希表(散列表)

下面是该结构中插入元素和搜索元素的方法(时间复杂度都可以达到O(1)):

  • 插入元素: 根据待插入元素的关键码,通过哈希函数计算出该元素的存储位置,并按此位置进行存放
  • 查找元素: 对要查找的元素的关键码用样的计算方法得出该元素的存储位置,然后与该位置的元素进行比较,相同就表示查找成功

其实之前数据结构中的计数排序也用到了哈希的思想。

🌏哈希函数

常见的有以下几种:

  1. 直接定制法: 取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B,其中A和B为常数
    优点: 简单,均匀
    缺点: 需要事先知道关键字的分布情况,如果关键字分布很散(范围很大),就需要浪费很多的空间
    使用范围: 关键字分布范围小且最好连续的情况
  2. 除留余数法: 取关键字被某个不大于散列表表长m的数p除后所得的余数为散列地址。即 H(key) = key % p,p<=m(p的选择很重要,一般取素数或m)
    优点: 可以将范围很大的关键字都模到一个范围内
    缺点: 对p的选择很重要
    使用范围: 关键字分布不均匀
  3. 平方取中法(不常用): 取关键字平方后的中间几位作为散列地址
  4. 随机数法(不常用): 选择一随机函数,取关键字作为随机函数的种子生成随机值作为散列地址,通常用于关键字长度不同的场合
  5. 折叠法(不常用): 将关键字分割成位数相同的几部分,最后一部分位数可以不同,然后取这几部分的叠加和(去除进位)作为散列地址

总结: 前面两种方法是用的比较多的,后面的几种方法了解即可。但是这些函数都无法避免的就是哈希冲突(下面介绍)的问题,如果哈希函数设计越精妙,那么哈希冲突的概率就会越低

🌏哈希冲突

看下面一个例子:
有一组元素0,1,3,15,9用哈希的方式存放,其中哈希函数是Hash(key)=key%10 (存放后的结果如下)

用这种方式存储和查找数据显然很快,但是如果此时插入一个元素5,它应该放在那个位置?
Hash(5) = 5%10 = 5,但是3这个位置中已经有元素5,难道我们要选择覆盖元素9吗?
显然这样是不妥的。(后面有解决的方法)
总结: 不同关键字通过相同的哈希函数计算出相同的哈希地址, 这里的这种现象称为哈希冲突或哈希碰撞

🌏哈希冲突的解决

🌲闭散列

🍯概念

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

  1. 线性探测: 从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。在上面哈希冲突的场景中,插入元素3时,因为此时的位置被占了,所以元素3选择下一个空位置,就是下标为4的位置。

看下面几个问题:

  • 如何实现插入元素?
    先通过哈希函数确定待插入元素的位置,如果该位置为空,直接插入,如果不为空就需要通过线性探测寻找下一个位置,如下面动图所示:

  • 如何实现删除元素?
    先通过哈希函数确定待删除元素的起始位置,然后线性探测往后找到要删除元素,此时不可以直接把这个元素删除,否则会影响到其它元素的搜索。所以这里对每个位置状态进行了标记,EMPTY(空)EXITS(存在)DELETE(删除) 三种状态,用DELETE标记删除的位置(这是一种伪删除的方式)
    下面用图解的方式解释为什么不能直接删除:

    显然,这种删除方式会影响后期元素的查找,所以我们采用三种状态记录每个位置的状态,只有为空才结束元素的查找,具体操作如下。

  • 如何查找元素?
    先通过哈希函数确定待查找元素的起始位置,然后线性探测往后找,如果当前位置不为DELETE 就继续往后找,直到当前位置为EMPTY,就停止查找表示该元素不存在;当前位置为EXIT 就进行比较,一样就查找成功,否则去下一个位置;如果当前位置为DELETE,就继续往下探测。

  • 何时增容?
    要注意的是,哈希表不能满了才增容,这样会导致哈希冲突的概率增大。哈希表中有一个衡量哈希表负载的量,叫负载因子负载因子(Load Factor) = 数据个数/哈希表大小
    一般我们选择负载因子为0.7-0.8的时候开始增容,如果这个值选取太小,会导致空间浪费;如果这个值选取太大,会导致哈希冲突的概率变大

  1. 二次探测: 线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法为:H(i) = H(0) + i^2。其中:i = 1,2,3…, 是通过散列函数Hash(x)对元素的关键码 key 进行计算得到的位置,m是表的大小。

    增容问题: 当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出必须考虑增容。

总结: 线性探测的优点是实现起来很简单,缺点就是会有累加效应(我的位置如果被占了,那么我就占别人的位置);二次探测的优点减轻了累加效应,因为哈希冲突的时候抢占的位置会在相对远一点的地方,这样元素排列就相对稀疏了。闭散列最大的缺陷就是空间利用率不高,这同样也是哈希的缺陷。

🍯哈希表闭散列的实现(采用线性探测)

🐚哈希表整体框架

概述: 这里采用线性探测的方式构建哈希表,下面是整体框架,其中模板参数第一个是key关键字,第二个是哈希表存储的元素的数据类型,可以是K,也可以是pair<K,V>类型,主要就是为了同时实现K模型KV模型。第三个参数就是一个仿函数,为了获取T中K的值,这里要实现两个仿函数,一个是对K模型,一个是对KV模型。这里其实和上一篇博客中通过改造红黑树同时实现map和set容器的方式是一样的。哈希表底层我们借用vector容器来实现。
哈希表数据存什么?
用一个类组织起来,里面有每个位置的状态和每个位置存放的元素

template<class K, class V>
struct KeyOfValue

	const K& operator()(const K& key)
	
		return key;
	
	const K& operator()(const pair<K, V>& kv)
	
		return kv.first;
	
;
enum State

	EMPTY,
	EXITS,
	DELETE
;
template<class T>
struct HashData

	T _data;
	State _state;
;

template<class K, class T, class KOFV>
class HashTable

	typedef HashData<T> HashData;
private:
	vector<HashData> _tables;
	size_t _num = 0;// 记录已经存放了多少个数据 
;

🐚插入元素

有以下几个步骤:

  1. 先判断负载因子是否大于0.7,如果大于0.7,就要考虑增容(下面详细介绍);否则就直接插入
  2. 用哈希函数计算出要插入的元素的起始位置,然后找空位置(状态为EMPTYDELETE)。然后进行插入,并把状态改为EXITS(这里不用担心没有空位置,因为哈希表不可能满,他不是满了才增容的)
  3. 如果此过程中发现要插入的元素存在,则返回FALSE代表元素插入失败;否则返回TRUE

增容问题: 我们需要把原来空间中的元素全部转移到新的空间中,此过程相当于往新空间重新插入元素,且要对它们进行重新定位
一般有两种方法:

  1. 直接开一个新的vector(大小为增容后空间的大小),然后一个元素一个元素地进行转移,最后把哈希表中的vector和性的vector进行交换,让这个新的vector带走旧空间,并清理资源

  2. 创建一个临时的哈希表,然后把vector成员的空间设置为增容后空间的大小,然后复用insert函数方法,对旧表中元素进行转移,最后新表和旧表的vector进行交换。(这里其实和上面的方法区别就在这里对insert进行了复用,且都用到了利用临时对象的析构函数清理旧空间的资源)

代码实现如下:

bool Insert(const T& data)

	KOFV kofv;
	// 哈希表不能满了在增容,这样会导致哈希冲突的概率增大
	// 不能太小,太小会导致空间浪费;也不能太大,太大会导致哈希冲突的概率很大
	// 负载因子(Load Factor)等于0.7就增容  _num/_tables.size()>=0.7
	// 负载因子 = 数据个数/哈希表大小


	if (_tables.size() == 0 || 10 * _num / _tables.size() >= 7)
	
		vector<HashData> newtables;
		size_t newsz = _tables.size() == 0 ? 10 : _tables.size() * 2;
		newtables.resize(newsz);
		// 先把旧表的数据重新放到新表中
		// 因为表的大小发生变化,所以数据在旧表中的位置和新表的位置不一样,需要重新调整
		// 写法1
		for (size_t i = 0; i < _tables.size(); ++i)
		
			if (_tables[i]._state == EXITS)
			
				int index = kofv(_tables[i]._data) % newsz;
				while (newtables[index]._state == EXITS)
				
					// 不会存在重复数据,因为旧表中不可能右重复的数据
					++index;
					if (index == newsz)
						index = 0;
				

				newtables[index] = _tables[i];
			
		
		_tables.swap(newtables);// 把临时空间和旧空间进行交换,交换后,旧空间的将由临时对象的析构函数来释放
		// 
		// 写法2
		/*HashTable<K, T, KOFV> newht;
		size_t newsz = _tables.size() == 0 ? 10 : _tables.size() * 2;
		newht._tables.resize(newsz);
		for (size_t i = 0; i < _tables.size(); ++i)
		
			if (_tables[i]._state == EXITS)
			
				newht.Insert(_tables[i]._data);
			
		
		_tables.swap(newht._tables);*/
	
	
	int index = kofv(data) % _tables.size();
	//int start = index;
	//int i = 1;
	while (_tables[index]._state == EXITS)
	
		if (_tables[index]._data == data)
			return false;
		// 二次探测
		/*index = start + pow(i, 2);
		index %= _tables.size();
		++i;*/
		++index;
		// 走到末尾置0
		if (index == _tables.size())
			index = 0;
	
	// DELETE和EMPTY的位置都可以插入数据
	_tables[index]._data = data;
	_tables[index]._state = EXITS;
	++_num;

	return true;

🐚查找元素

前面介绍过了,先通过哈希函数确定待查找元素的起始位置,然后线性探测往后找,如果当前位置不为DELETE 就继续往后找,直到当前位置为EMPTY,就停止查找表示该元素不存在;当前位置为EXIT 就进行比较,一样就查找成功,否则去下一个位置;如果当前位置为DELETE,就继续往下探测。

代码实现如下:

HashData* Find(const K& key)

	KOFV kofv;
	int index = key % _tables.size();
	// int start = index;// 不用标记,因为表不会满,所以一定会遇到空 
	while (_tables[index]._state != EMPTY)
	
		if (kofv(_tables[index]._data) == key)
		
			if (_tables[index]._state == EXITS)
				return &_tables[index];
			else// _tables[index]._state == DELETE
				return nullptr;
		
		++index;
		if (index == _tables.size())
			index = 0;
		// 找完一遍没有就退出  这里其实是不必要的,这里面一定有空的位置,所以一定会退出
		/*if (index == start)
			return nullptr;*/
	
	return nullptr;

🐚删除元素

前面介绍过了,这里不多说,比较简单。
代码实现如下:

bool Erase(const K& key)

	HashData* ret = Find(key);
	if (ret != nullptr)
	
		ret->_state = DELETE;
		--_num;
		return true;
	
	else
	
		return false;
	

🌲开散列

🍯概念

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

注意: 开散列中每个桶放的都是哈希冲突的元素。哈希桶下面挂着的是一个一个的节点(一条链表),如果该位置哈希冲突的元素过多时,我们会选择在这里挂一颗红黑树(Java中是这样实现的)

🍯哈希表开散列实现(哈希桶)

🍯整体框架

哈希桶下面挂着的是一个一个的节点(一条链表),也就是每个位置存放链表头节点的地址。这里和开散列一样,我们还是用vector来存放元素。模板参数列表中前三个就不过多介绍,和闭散列是一样的,第四个参数后面介绍。

template<class T>
	struct HashNode
	
		T _data;
		HashNode<T>* _next;

		HashNode(const T& data)
			:_data(data)
			,_next(nullptr)
		
	;
template<class K, class T, class KOFV, class Hash>
class HashBucket

	typedef HashNode<T> Node;
private:
	vector<Node*> _tables;
	int _num = 0;// 记录表中的数据个数
;

🐚插入元素

有以下几个步骤:

  1. 先根据元素个数考虑增容问题(下面详细介绍)
  2. 再通过哈希函数确定关键字的位置,然后把节点挂到这个桶下面(可以是链表的头,也可以是链表的尾部)

增容问题: 当哈希桶中元素个数打的一定个数时,就要增容,否则哈希冲突的概率会变得,且时间复杂度会下降的很快。所以,哈希桶一般是在元素个数等于桶的大小,也就是负载因子为1时,就开始增容。

  1. 先遍历一遍哈希桶的每个位置,然后对旧桶上的元素节点进行转移
  2. 最后插入新节点

代码实现如下:(这里的哈希函数后面介绍)

bool Insert(const T& data)

	KOFV kofv;
	// 负载因子为1时就增容
	if (_num == _tables.size())
	
		vector<Node*> newtables;
		size_t newsize = _tables.size() == 0 ? 10 : 2 * _tables.size();
		newtables.resize(newsize);

		for (size_t i = 0; i < _tables.size(); ++i)
		
			Node* prev = nullptr;
			Node* cur = _tables[i];
			
			// 把一个位置的所有节点转移,然后换下一个位置
			while (cur)
			
				// 记录下一个节点的位置
				Node* next = cur->_next;

				int index = HashFunc(kofv(cur->_data)) % newtables.size();
				// 把cur连接到新的表上
				cur->_next = newtables[index];
				newtables[index] = cur;

				cur = next;// cur会发生变化,需要提前记录next
			
		
		_tables.swap(newtables);
	
	int index = HashFunc(kofv(data)) % _tables.size();
	// 先查找该条链表上是否有要插入的元素
	Node* cur = _tables[index];
	while (cur)
	
		if (kofv(cur->_data) == kofv(data))
			return false;
		cur = cur->_next;
	
	// 插入数据,选择头插(也可以尾插)
	Node* newnode = new Node(data);
	newnode->_next = _tables[index];
	_tables[index] = newnode;
	++_num;

	return true;

🐚查找元素

步骤:

  1. 先确定要查找的元素在哪个桶
  2. 然后在该桶下的链表对元素进行查找

代码实现如下:

HashNode*Find(const K& key)

	KOFV kofv;
	int index = HashFunc(key) % _tables.size();
	Node* cur = _tables[index];

	while (cur)
	
		if (key == kofv(cur->_data))
		
			return cur;
		
		cur = cur->_next;
	
	return nullptr;

🐚删除元素

步骤:

  1. 先找到元素
  2. 然后对元素节点进行删除,没找到就删除失败

代码实现如下:

bool Erase(const K& key)

	KOFV kofv;
	int index = HashFunc(key) % _tables.size();
	
	Node* prev = nullptr;
	Node* cur = _tables[index];

	while (cur)
	
		if (key == kofv(cur->_data))
		
			// 删第一个节点时
			if (prev == nullptr)
			
				_tables[index] = cur->_next;
			
			else
			
				prev->_next = cur->_next;
			

			--_num;
			delete cur;
			return true;
		
		prev = cur;
		cur = cur->_next;
	
	return false;

🐚字符串哈希

在上面的哈希桶中,只能存放key为整形的元素,这个问题应该如何解决呢?

我们上面哈希函数采用除留余数法,key必须为整形才可以进行处理。所以我们需要采取一些措施,将这些key转为整形。

下面是一些字符串转换整数的Hash函数的比较: 戳这里

我们选择上面的一种,来进行使用。
实现如下: 因为较多情况下,key都是可以取模的,所以哈希桶的模板参数列表中选择直接返回key的函数作为缺省参数。有因为字符串哈希用的也比较多,所以这里对key为string类型进行一个特化

template<class K>
struct _Hash

	// 大多树的类型就是是什么类型就返回什么类型
	const K& operator()(const K& key)
	
		return key;
	
;

// 特化string
template<>
struct _Hash<string>

	size_t operator()(const string& key)
	
		size_t hash = 0;
		// 把字符串的所有字母加起来   hash = hash*131 + key[i]
		for (size_t i = 0; i < key.size(); ++i)
		
			hash *= 131;
			hash += key[i];
		
		return hash;
	
;

我们再实现一个哈希函数,里面是对key进行对应地转换,然后返回整形。
实现如下:

size_t HashFunc(const K& key)

	Hash hash;
	return hash(key);

🐚构建一个扩容数组

研究表明:当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。所以除留余数法最好模上一个素数

代码实现如下:

const int PRIMECOUNT = 28;
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, 4294967291ul
;
size_t GetNextPrime(size_t prime)

	size_t i = 0;
	for (; i < PRIMECOUNT; ++i)
	
		if (primeList[i] > prime)
			return primeList[i];
	
	return primeList[i];

改造增容部分代码:

//改造前
//size_t newsize = _tables.size() == 0 ? 10 : 2 * _tables.size();
//改造后
size_t newsize = GetNextPrime(_tables.size());

🌐总结

哈希算法是一种将一个数据转换为一个关键值,这个关键值和原数据具有映射关系,这样数据的增删查改的时间复杂度就都可以达到O(1)。闭散列开散列 是解决哈希冲突的两个方法,其中通过哈希方法可以构造哈希表和哈希桶两种结构。今天就先介绍到这里了,后面还会用哈希表来实现unordered_map和unorded_set两种容器,喜欢的话,欢迎点赞、支持和收藏~

以上是关于C++进阶第二十一篇——哈希(概念+哈希函数+哈希冲突+哈希表+哈希桶+代码实现)的主要内容,如果未能解决你的问题,请参考以下文章

C++从青铜到王者第二十六篇:哈希

C++进阶第二十二篇——unordered_map和unordered_set(容器接口介绍和使用+底层代码实现)

C++进阶哈希表

C++进阶哈希表

哈希算法——沐众发现(二十七)

《算法零基础100讲》(第56讲) 哈希表进阶