C++ | 哈希表的实现与unordered_set/unordered_map的封装

news2024/9/24 17:15:52

目录

前言

一、哈希

1、哈希的概念

2、哈希函数

(1)直接定址法

(2)除留余数法

(3)平方取中法(了解)

(4)随机数法(了解)

3、哈希冲突

4、闭散列及其实现

(1)闭散列的查找

(2)闭散列的插入

(3)闭散列的删除

5、开散列及其实现

(1)开散列的查找

(2)开散列的插入

(3)开散列的删除

(4)其他函数

6、开散列与闭散列的一些其他问题

(1)对于自定义类型成员无法确定位置

(2)模素数优化 

二、unordered_set与unordered_map的封装


前言

        前面我们学习了unordered_set、unordered_map的使用,这里我们从底层来看看这两个容器,并对其封装,再封装之前,我们需要清楚者两个容器的底层是哈希结构,我们首先自己实现一个哈希表,再拿这个哈希表对容器进行封装;

一、哈希

1、哈希的概念

        前面的二叉搜索树我们想找到一个值就必须对这个值从根节点开始依次比较,知道找到这个数或者找到空姐点指针;但是我们想通过一种一 一映射的思想以最快的速度找到我们想要的值,这种将我们的关键码与位置建立关系的思想,我们称之为哈希;举个例子;

题目链接

        如上述这道题目,我们想要找出只出现一个只出现一次的字符,我们怎么处理呢?我们之前可通过类似计数排序的思想,将每个字母映射一个数字下标的位置(题目有说只会出现小写字母);如我们创建一个大小为26的数组,并都初始化为0,a映射到数组0下标的位置,b映射到数组1下标的位置,这样依次映射我们可以映射到下标25,z的位置;然后遍历字符串,每遇到一个字符,就将该字符对应的下标的数组值+1;如遇到a,我们将0下标对应的数组值+1;这样的思想也就是我们哈希的思想;

2、哈希函数

        哈希函数就是将我们关键码转化成对应的哈希值,上述题目中将小写字母转换成0到26这一过程我们就可以理解成我们用哈希函数将小写字母转换成特定的哈希值;接下来我们来看看了解一下常见的哈希函数;

(1)直接定址法

        所谓直接定址法就是去关键码作为哈希地址,如来了一个3,我们就将3放进数组下标为3的位置,来了一个13,就将13放进数组13的位置;

        但是这种方法有一个明显的缺陷,我们要是我们的数据并不是集中分布的呢?假如有三个数据,分别为3,5,10007;那么我们是不是要开辟10007这么大的空间存放数据呢??因此我们不使用这种方法;

(2)除留余数法

        我们在得到一个关键码时,我们将它余上某个数字得到储存位置;这种方法就是我们的除留余数法;我们后面实现哈希表就是采用这种方法;例如,还是上述三个数据,3,5,10007;

        我们将上述的值依次余上一个10,得到的余数就是对应的位置;后面还有一个写方法,我们了解即可;

(3)平方取中法(了解)

假设关键字为1234,对它平方就是1522756,抽取中间的3位227作为哈希地址;
再比如关键字为4321,对它平方就是18671041,抽取中间的3位671(或710)作为哈希地址

(4)随机数法(了解)

        选择一个随机函数,取关键字的随机函数值为它的哈希地址,即H(key) = random(key),其中random为随机数函数。

        当然还有很多很多的方法,大家感兴趣可以自行搜索;

3、哈希冲突

        在上述不同方法中,仍然会出现一些特殊状况,其中有一种被称为哈希冲突,如下图所示;

        上图采取的是除留取余法,目前数据存储并没有什么问题,但是接下来,当我们想存入一个13,这是我们对其取10的余数,即为3,但是此时我们3的位置已经存储了一个数据了,那这时我们应该怎么存储这个数据呢?这种存储数据位置冲突的现象就是我们的哈希冲突;那么如何解决这种哈希冲突呢?没错,就是我们接下来的闭散列和开散列两种方法;

4、闭散列及其实现

        闭散列也叫开放定址法,当发生哈希冲突的时候,我们将当前数据存储到“下一个”位置存储;关于这下一个位置,我们有几种不同的定义;可以是+1的位置,也可以每次都加不同的数,主要看实现者想如何实现;以下我们依次分析;

        首先,我们分析一下,我们想实现我们的闭散列有哪些困难;

1、我们开辟一块空间后,当来了一个关键码以后,我们通过特定的算法函数,将这个关键码转换成我们的位置,然后我们需要查看数组的这个位置是否存储了数据,那我们如何确定这个位置是否存储了数据呢?

2、当我们删除一个元素后,我们应该如何表明该数据被删除了呢?

        我们可以将数组中存储一个结构化数据,而不是单个数据,结构化数据包括我们存储的数据,以及数据的状态,这里我设置了三种状态,分别是EMPT(此位置为空),EXIST(此位置有数据),DELETE(此位置目前没数据了,被删除了)

        注意:这里可能有很多同学看不懂这里为什么要设置DELETE这种状态,删除一个数据直接设置成EMPTY不可以吗?这里我们需要考虑删除的情况;假设如下;

        此时插入了数据如上图所示,我们使用的下一个位置是+1;我们查找数据的逻辑是先算出位置值,我们算出存入hashi中,然后我们从hashi位置开始查找,如果不是我们要查找的数值,我们就找下一个位置,知道我们加到的那个位置的值为EMPTY状态为止;这时,如果我们删除数据的逻辑是将数据对应位置设置为EMPTY就有坑了,假设我们删除15,此时下标5这个位置被设置成EMPTY,然后我们接着想查找12,我们从算出hashi值2,从2开始查找,发现不等于12,接着探测下一个位置下标3的值,对比还是不相等,一直到下标5的位置时,我们发现下标5为EMPTY状态,停止查找,可是我们数据明明存储在这个哈希表中;所以两个状态是不够的!

        根据如上分析,我们写出闭散列的大体框架,如下所示;

namespace ClosedHash
{
    // 状态表示
	enum State
	{
		EMPTY,
		EXIST,
		DELETE
	};
    // 哈希表中数据存储类型
	template<class K, class V>
	struct HashDate
	{
		std::pair<K, V> _kv;
		State _state = EMPTY;
	};
    // 哈希表
	template<class K, class V>
	class HashTable
	{
	public:
		
	private:
		std::vector<HashDate<K, V>> _hash_table;
		size_t _n = 0; // 哈希表中元素个数
	};
}
(1)闭散列的查找

        闭散列的查找需要先算出hashi,这里是余上这个vector的size;这里采用的是一次探测,即不断+1;

		HashDate<K, V>* find(const K& key)
		{
			if (_n == 0)
			{
				return nullptr;
			}
			size_t hashi = key % _hash_table.size();
			size_t i = 1;
			size_t index = hashi;
			// 若查找位置不为空,继续往后找
			while (_hash_table[index]._state != EMPTY)
			{
				if (_hash_table[index]._state == EXIST && _hash_table[index]._kv.first == key)
				{
					return &_hash_table[index];
				}
				else
				{
					// 下一个探测
					index = hashi + i;
					index %= _hash_table.size();
					i++;
				}
				// 防止都是DELETE状态造成死循环
				if (index == hashi)
					break;
			}
			return nullptr;
		}
(2)闭散列的插入

        插入的代码中,关于哈希碰撞时,我们可以选择一次探测,也可以选择二次探测;这里插入时,需要考虑扩容问题,这里涉及到什么时候扩容,关于什么时候扩容,我们这里引入了负载因子这一概念;

负载因子 = 元素个数 / size;

        所以这里需要控制除零错误,第一次进去必须扩容;还有我们这的负载因子一般控制在0.7到0.8左右;大了就哈希碰撞的几率大,空间利用率高,搜索效率低;小了就哈希碰撞几率低,空降利用率低,搜索效率高;

		bool insert(const std::pair<K, V>& kv)
		{
			if (find(kv.first))
				return false;

			// 是否需要扩容
			if (_hash_table.size() == 0 || _n * 10 / _hash_table.size() == 7)
			{
				int newsize = _hash_table.size() == 0 ? 10 : _hash_table.size() * 2;
				HashTable<K, V> tmp;
				tmp._hash_table.resize(newsize);
				for (auto& e : _hash_table)
				{
					tmp.insert(e._kv);
				}
				_hash_table.swap(tmp._hash_table);
			}
			size_t hashi = kv.first % _hash_table.size();
			size_t i = 1;
			size_t index = hashi;
			// 若插入位置发生冲突,则继续探测
			while (_hash_table[index]._state == EXIST)
			{
				// 一次探测
				index = hashi + i;
				// 二次探测
				//index = hashi + i * i;
				i++;
				index %= _hash_table.size();
			}
			_hash_table[index]._kv = kv;
			_hash_table[index]._state = EXIST;
			_n++;

			return true;
		}
(3)闭散列的删除
		bool erase(const K& key)
		{
			auto pos = find(key);
			if (pos == nullptr)
				return false;
			pos->_state = DELETE;
			_n--;
			return false;
		}

5、开散列及其实现

        开散列又叫链地址法(开链法),当我们得到一个关键码时,我们将这个关键码求出对应的地址,我们称对应地址所在位置为一个哈希桶,我们将这个数据以链表的形式挂在对应的哈希桶下面;如下图所示;

        不难看出,每个哈希桶下面挂着发生哈希冲突的值,实际上,我们的unordered_set与unordered_map同样也是使用开散列的哈希桶进行封装;;我们根据上述,同样也可以实现出开散列类的基本结构,如下所示;

namespace OpenHash
{

	template<class K, class V>
	struct HashNode
	{
		typedef HashNode<K, V> Node;
		HashNode(const std::pair<K, V>& kv)
			:_kv(kv)
			,_next(nullptr)
		{}

		std::pair<K, V> _kv;
		Node* _next = nullptr;
	};

	template<class K, class V>
	class HashBucket
	{
	public:
		typedef HashNode<K, V> Node;

	private:
		std::vector<Node*> _hash_table;
		size_t _n = 0; // 存储的元素个数
	};
}
(1)开散列的查找

        查找并无难度,我们仅仅只需要算出对应的hashi,然后在对应桶下面的单链表中查找即可;

		Node* find(const K& key)
		{
			if (_hash_table.size() == 0)
				return nullptr;

			size_t hashi = key % _hash_table.size();
			Node* cur = _hash_table[hashi];
			while (cur)
			{
				if (cur->_kv.first == key)
					return cur;
				cur = cur->_next;
			}

			return nullptr;
		}
(2)开散列的插入

        开散列同样也要考虑扩容问题,开散列的负载因子我们可以略微增加,因为开散列的哈希碰撞几率明显降低了,这里我们把负载因子设置为了1;

		bool insert(const std::pair<K, V>& kv)
		{
			 // 扩容
			if (_hash_table.size() == 0 || _n * 10 / _hash_table.size() == 10)
			{
				int newsize = _hash_table.size() == 0 ? 10 : _hash_table.size() * 2;
				HashBucket<K, V> tmp;
				tmp._hash_table.resize(newsize, nullptr);
				for (auto& cur : _hash_table)
				{
					// 将该链表下所有结点放入新表中
					while (cur)
					{
						Node* next = cur->_next;
						size_t hashi = cur->_kv.first % tmp._hash_table.size();

						// 头插入新表中
						cur->_next = tmp._hash_table[hashi];
						tmp._hash_table[hashi] = cur;

						cur = next;
					}
				}
				_hash_table.swap(tmp._hash_table);
			}
			// 插入
			size_t hashi = kv.first % _hash_table.size();
			Node* newnode = new Node(kv);
			if (_hash_table[hashi] == nullptr)
			{
				_hash_table[hashi] = newnode;
			}
			else
			{
				// 头插
				newnode->_next = _hash_table[hashi];
				_hash_table[hashi] = newnode;
			}
			_n++;
			return true;
		}
(3)开散列的删除

        这里需要注意头删的特殊处理;

		bool erase(const K& key)
		{
			Node* pos = find(key);
			if (pos == nullptr)
				return false;
			
			size_t hashi = key % _hash_table.size();
			Node* prev = nullptr;
			Node* cur = _hash_table[hashi];
			while (cur)
			{
				if (cur->_kv.first == key)
				{
					// 头删
					if (prev == nullptr)
					{
						_hash_table[hashi] = cur->_next;
					}
					else // 中间尾部删除
					{
						prev->_next = cur->_next;
					}
					delete cur;
					return true;
				}
				else
				{
					prev = cur;
					cur = cur->_next;
				}
			}
			return false;
		}
(4)其他函数

        这里需要实现拷贝构造,因为vector里存的成员是链表,对于链表,我们要进行深拷贝,不然会内存泄露;

		// 默认构造
		HashBucket()
			:_n(0)
		{}
		// 拷贝构造
		HashBucket(const HashBucket<K, V>& hb)
		{
			// 深拷贝
			if (this != &hb)
			{
				_hash_table.resize(hb._hash_table.size(), nullptr);
				for (size_t i = 0; i < hb._hash_table.size(); i++)
				{
					Node* cur = hb._hash_table[i];
					Node* copytail = nullptr;
					while (cur)
					{
						Node* newnode = new Node(cur->_kv);
						if (_hash_table[i] == nullptr)
						{
							_hash_table[i] = newnode;
							copytail = newnode;
						}
						else
						{
							copytail->_next = newnode;
							copytail = copytail->_next;
						}
						cur = cur->_next;
					}
				}
			}
		}

		// 赋值重载(现代写法)
		HashBucket<K, V>& operator=(HashBucket<K, V> hb)
		{
			_hash_table.swap(hb._hash_table);
			std::swap(_n, hb._n);
			return *this;
		}

		// 析构函数
		~HashBucket()
		{
			clear();
		}

		size_t size()
		{
			return _n;
		}

		void clear()
		{
			for (size_t i = 0; i < _hash_table.size(); i++)
			{
				Node* cur = _hash_table[i];
				while (cur)
				{
					Node* del = cur;
					cur = cur->_next;
					delete del;
				}
				_hash_table[i] = nullptr;
			}
		}

6、开散列与闭散列的一些其他问题

(1)对于自定义类型成员无法确定位置

        前面不管是开散列还是闭散列,我们在求取hashi的时候,我们都是直接求余数的,假如我们的key是string类型呢?那不就都会报错吗?所以,此时我们必须多提供一个模板参数,也就是我们unordered_set/unordered_map第三个模板参数Hash,这个参数就是我们可以传入一个仿函数,控制如何将key转换成size_t类型;

	// 增加后的模板列表
    template<class K, class V, class HashFunc = Hash<K>>
	class HashBucket

    //增加的默认Hash函数
	template<class K>
	struct Hash
	{
		size_t operator()(const K& key)
		{
			return key;
		}
	};
	// 特化
	template<>
	struct Hash<std::string>
	{
		// BKDRHash
		size_t operator()(const std::string& s1)
		{
			size_t hash = 0;
			for (auto ch : s1)
			{
				hash = hash * 31 + ch;
			}
			return hash;
		}
	};

        关于字符串哈希算法下面有一篇博客进行了详细介绍,上述代码就是采用其中之一的BKDRHash; 字符串哈希函数

(2)模素数优化 

        经过研究发现,除留余数法最好模上一个素数,这样哈希冲突的概率比较低;因此,我们可以在每次扩容时,我们取比当前容量大两倍的一个素数,因此有了以下代码;

		size_t GetNextPrime(size_t prime)
		{
			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, 4294967291ul
			};
			size_t i = 0;
			for (; i < PRIMECOUNT; ++i)
			{
				if (primeList[i] > prime)
					return primeList[i];
			}
			return primeList[i];
		}

        我们每次扩容直接调用这个函数即可拿到扩容的大小;

二、unordered_set与unordered_map的封装

        这两个容器的封装与我们map、set封装差不多,我们修改一下我们之前写的哈希表,然后进行封装即可;代码提交至gitee中;有兴趣的可以查看;

容器封装代码

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/822955.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

.net 6升级.net7 容器报错is not supported on this platform.

一、生成验证码报错 System.PlatformNotSupportedException: System.Drawing.Common is not supported on this platform.Tue, Aug 1 2023 9:57:37 pmat System.Drawing.Image..ctor()Tue, Aug 1 2023 9:57:37 pmat System.Drawing.Bitmap..ctor(Int32 width, Int32 height) 二…

keil使用printf函数重定串口输出,程序卡在Reset_Handler

最近在做国产芯片GD32F103项目&#xff0c;使用printf()函数重定向USART0串口输出&#xff0c;发现程序没有运行&#xff0c;单步调试发现&#xff0c;程序卡在startup_gd32f10x.s文件的Reset_Handler处&#xff0c;记录一下解决方法。 解决办法&#xff1a; 1、引用头文件#in…

Git rebase和merge区别详解

文章目录 变基的基础用法变基过程中的冲突解决冲突后无法push问题更新变基后的代码更有趣的变基用法变基的风险用变基解决变基变基 vs 合并 此文在阅读前需要有一定的git命令基础&#xff0c;若基础尚未掌握&#xff0c;建议先阅读这篇文章Git命令播报详版 在 Git 中整合来自不…

【ChatGPT辅助学Rust | 基础系列 | 函数,语句和表达式】函数的定义,使用和特性

文章标题 简介一&#xff0c;函数1&#xff0c;函数的定义2&#xff0c;函数的调用3&#xff0c;函数的参数4&#xff0c;函数的返回值 二&#xff0c;语句和表达式1&#xff0c;语句2&#xff0c;表达式 总结&#xff1a; 简介 在Rust编程中&#xff0c;函数&#xff0c;语句…

hadoop与HDFS交互

一、利用Shell命令与HDFS进行交互 在进行HDFS编程实践前&#xff0c;需要首先启动Hadoop。可以执行如下命令启动Hadoop&#xff1a; cd /usr/local/hadoop ./sbin/start-dfs.sh #启动hadoop Hadoop支持很多Shell命令&#xff0c;其中fs是HDFS最常用的命令&#xff0c;利用fs…

在矩池云使用Llama2-7B的具体方法

今天给大家分享如何在矩池云服务器使用 Llama2-7b模型。 硬件要求 矩池云已经配置好了 Llama 2 Web UI 环境&#xff0c;显存需要大于 8G&#xff0c;可以选择 A4000、P100、3090 以及更高配置的等显卡。 租用机器 在矩池云主机市场&#xff1a;https://matpool.com/host-m…

5.开发DAO组件 -- Spring Data JPA

开发DAO组件 作用&#xff1a;用来访问数据库 持久化技术&#xff1a;Spring Data, JPA, Mybaits&#xff0c;jOOQ 等 Spring Boot为常见持久化技术提供了支持。 现在使用 Spring Data JPA Spring Data JPA 使用Spring Data JPA来访问数据库&#xff0c;需要再项目添加两个…

探究Vue源码:mustache模板引擎(8) 了解nestTokens 手写梳理模板字符串井号循环嵌套结构tokens

上文 探究Vue源码:mustache模板引擎(7) 手写模板字符串转换tokens数组过程中 我们操作出了一个较为简单的 tokens数组 并简单处理了 井号反斜杠的特殊符号语法 那么 我们现在需要将零散的tokens嵌套起来 主要就体现在 我们 井号 到 反斜杠 中间的内容 显然是属于循环语句中的子…

Postman如何做接口测试1:如何导入 swagger 接口文档

在使用 postman 做接口测试过程中&#xff0c;测试工程师会往界面中填入非常多的参数&#xff0c;包括 url 地址&#xff0c;请求方法&#xff0c;消息头和消息体等一系列数据&#xff0c;在请求参数比较多的情况下非常花时间。 我们可以使用 postman 的文档导入功能&#xff…

Chapter 11: Tuples | Python for Everybody 讲义笔记_En

文章目录 Python for Everybody课程简介TuplesTuples are immutableComparing tuplesTuple assignmentDictionaries and tuplesMultiple assignment with dictionariesThe most common wordsUsing tuples as keys in dictionariesSequences: strings, lists, and tuples - Oh M…

因果推断(二)倾向匹配得分(PSM)

因果推断&#xff08;二&#xff09;倾向匹配得分&#xff08;PSM&#xff09; 前文介绍了如何通过合成控制法构造相似的对照组&#xff0c;除此之外&#xff0c;也可以根据倾向匹配得分&#xff08;PSM&#xff09;进行构造&#xff0c;即为每一个试验组样本在对照组中找对与…

dialog => :before-close的属性应用

在element-ui里面关闭弹窗的时候before-close会触发。 也就是点击X的时候回触发before-close这个属性, 代码实例: <el-dialogtitle"新增用户":visible.sync"dialogVisible"width"50%":before-close"handleClose"> handleClose…

【动态规划part15】| 392.判断子序列、115.不同的子序列

&#x1f388;LeetCode392.判断子序列 链接&#xff1a;392.判断子序列 给定字符串 s 和 t &#xff0c;判断 s 是否为 t 的子序列。 字符串的一个子序列是原始字符串删除一些&#xff08;也可以不删除&#xff09;字符而不改变剩余字符相对位置形成的新字符串。&#xff08;…

Linux端口与netstat使用

端口是设备与外界交流的通道&#xff0c;有物理端口和虚拟端口。 Linux有六万多端口&#xff0c;可以分为下面几类&#xff1a; 1.公认端口&#xff08;1~1023&#xff09;&#xff1a;用于系统内置与知名程序的预留使用 2.注册端口&#xff08;1024~49151&#xff09;&…

使用webdriver-manager解决浏览器与驱动不匹配所带来自动化无法执行的问题

1、前言 在我们使用 Selenium 进行 UI 自动化测试时&#xff0c;常常会因为浏览器驱动与浏览器版本不匹配&#xff0c;而导致自动化测试无法执行&#xff0c;需要手动去下载对应的驱动版本&#xff0c;并替换原有的驱动&#xff0c;可能还会遇到跨操作系统进行测试的时候&…

【autoresizing案例 Objective-C语言】

一、autoresizing案例 1.在介绍autoresizing之前,告诉大家,这个只是介绍,以后不要用这个东西,都用autolayout 还有一个非常重要的就是,使用autoresizing,就不能用autolayout,反之亦然 2.我们来看一个案例,看一个什么案例呢,看这么一个案例, 大家先看我这个的要求:…

如何安装、部署、启动Jenkins

一、测试环境 Linux系统 Centos 7 二、安装步骤&#xff1a; 1、安装jdk 我安装的是jdk8&#xff0c;此处就不多说了&#xff0c;自己百度哈&#xff0c;很简单 2、安装jenkins 首先依次执行如下三个命令&#xff1a; 2.1、导入镜像&#xff1a; [rootcentos7 ~]# sudo …

《吐血整理》进阶系列教程-拿捏Fiddler抓包教程(18)-Fiddler如何接口测试,妈妈再也不担心我不会接口测试了

1.简介 Fiddler最大的优势在于抓包&#xff0c;我们大部分使用的功能也在抓包的功能上&#xff0c;fiddler做接口测试也是非常方便的。 领导或者开发给你安排接口测试的工作任务&#xff0c;但是没有给你接口文档&#xff08;由于开发周期没有时间出接口文档&#xff09;&…

小程序商品如何上传视频

小程序商品展示的方式在不断创新&#xff0c;除了传统的图片展示&#xff0c;视频成为了吸引用户注意力的重要方式之一。今天就讲解一下&#xff0c;商家怎么上传商品视频。 1. 商家需要准备好商品视频。商家可以自己拍摄商品的使用演示视频、产品介绍视频等&#xff0c;也可以…

Linux部署jar包,隐藏命令行参数

Linux部署jar包&#xff0c;隐藏命令行参数 一、背景需求二、查阅资料三、实现隐藏库3.1、测试test.c3.2、设置隐藏库3.3、验证 四、应用jar启动命令五、直接应用结果 最新项目安全检测&#xff0c;发现配置文件中数据库密码&#xff0c;redis密码仍处理明文状态 于是整理了一篇…