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

目录

前言

一、哈希

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中;有兴趣的可以查看;

容器封装代码

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

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

Nginx解决文件服务器文件名显示不全的问题

Nginx可以搭建Http文件服务器&#xff0c;但默认的搭建会长文件名显示不全&#xff0c;比如如下&#xff1a; 问题&#xff1a;显示不全&#xff0c;出现...&#xff0c;需要进行解决 这里使用重新编绎nginx的方式&#xff0c;见此文&#xff1a; https://unix.stackexchange…

刷题笔记 day4

力扣 611 有效三角形的个数 首先需要知道如何判断 三个数是否能构成三角形。 假如 存在三个数 a < b < c&#xff0c;如果要构成三角形&#xff0c;需要满足&#xff1a; ab > c ; a c > b ; b c > a ; 任意两个数大于第三个数就可构成三角形。 其实不难…

网络编程 IO多路复用 [select版] (TCP网络聊天室)

//head.h 头文件 //TcpGrpSer.c 服务器端 //TcpGrpUsr.c 客户端 select函数 功能&#xff1a;阻塞函数&#xff0c;让内核去监测集合中的文件描述符是否准备就绪&#xff0c;若准备就绪则解除阻塞。 原型&#xff1a; #include <sys/select.…

Codeforces Round 889 (Div. 2)(视频讲解A——D)

文章目录 A Dalton the TeacherB Longest Divisors IntervalC2 Dual (hard Version)D Earn or Unlock Codeforces Round 889 (Div. 2)&#xff08;视频讲解A——D&#xff09; A Dalton the Teacher #include<bits/stdc.h> #define endl \n #define INF 0x3f3f3f3f us…

设计模式大白话——装饰者模式

装饰者模式 文章目录 装饰者模式一、概述二、应用场景三、代码示例四、小结 一、概述 ​ 装饰者模式&#xff0c;此模式最核心之处在于装饰二字&#xff0c;之所以需要装饰&#xff0c;是因为基础的功能无法满足需求&#xff0c;并且装饰是临时的&#xff0c;并不是永久的&…

idea调节文字大小、日志颜色、git改动信息

idea调节菜单栏文字大小&#xff1a; 调节代码文字大小&#xff1a; 按住ctrl滚动滑轮可以调节代码文字大小&#xff1a; 单击文件即可在主窗口上打开显示&#xff1a; idea在控制台对不同级别的日志打印不同颜色 &#xff1a; “grep console”插件 点击某一行的时候&#x…

二叉树(C语言)

文章目录 1.树1.1概念1.2相关定义1.3 表示&#xff08;左孩子右兄弟&#xff09; 2.二叉树2.1概念2.2特殊的二叉树1. 满二叉树&#xff1a;2. 完全二叉树&#xff1a; 2.3二叉树的性质2.4练习 3.二叉树的存储结构1. 顺序存储2. 链式存储 4.完全二叉树的代码实现4.1堆的介绍1.堆…

Java+SpringBoot+Mybaties-plus+Vue+ElementUI 基于协同过滤算法商品推荐系统

一.项目介绍 协同过滤算法商品推荐系统分为两类角色 普通用户以及超级管理员 普通用户&#xff1a; 查看推荐商品、加入购物车、收藏、评论、个人中心、查看订单状态、编辑收货地址 超级管理员&#xff1a; 维护个人信息、维护用户管理、维护商品类型管…

解决构建maven工程时,配置了阿里云的前提下,依旧使用中央仓库下载依赖导致失败的问题!!!

问题描述&#xff1a; 在使用spring进行构建项目时&#xff0c;出现下载依赖迟迟不成功&#xff0c;显示maven wrapper 下载失败的问题。 Maven wrapper Cannot download ZIP distribution from https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.8.7/ap…

【Linux命令200例】patch 用于将补丁文件应用到源码中

&#x1f3c6;作者简介&#xff0c;黑夜开发者&#xff0c;全栈领域新星创作者✌&#xff0c;阿里云社区专家博主&#xff0c;2023年6月csdn上海赛道top4。 &#x1f3c6;本文已收录于专栏&#xff1a;Linux命令大全。 &#x1f3c6;本专栏我们会通过具体的系统的命令讲解加上鲜…

如何用python画一朵花,用python画彩色六边形

大家好&#xff0c;小编为大家解答用python画彩色六边形的问题。很多人还不知道如何用python画一朵花&#xff0c;现在让我们一起来看看吧&#xff01;

linux 学成之路(基础篇)(二十三)MySQL服务(下)

目录 一、用户权限管理概述 二、用户权限类型 三、用户赋予权限 四、删除权限 五、删除用户 一、用户权限管理概述 数据库用户权限管理是数据库系统中非常重要的一个方面&#xff0c;它用于控制不同用户访问和操作数据库的权限范围。数据库用户权限管理可以保护敏感数据和…

【phaser微信抖音小游戏开发004】往画布上增加文本以及文本的操作

我们在states中创建st004.js的类&#xff0c;或者将states中的index.js直接重命名为st004.js&#xff0c;把里面的类名也修改为st004.如下图 在main.js中&#xff0c;引入st004,并设置启用的state为st004。如下图 接下来到states/st004里面&#xff0c;在create里面将文本修改一…

Rust ESP32C3开发

Rust ESP32C3开发 系统开发逐步使用Rust语言&#xff0c;在嵌入式领域Rust也逐步完善&#xff0c;本着学习Rust和ESP32的目的&#xff0c;搭建了ESP32C3的环境&#xff0c;过程中遇到了不少问题&#xff0c;予以记录。 ESP-IDF开发ESP32 这一部分可跳过&#xff0c;是使用C开…

时序预测 | MATLAB实现GRNN广义回归神经网络时间序列预测(多指标,多图)

时序预测 | MATLAB实现GRNN广义回归神经网络时间序列预测(多指标,多图) 目录 时序预测 | MATLAB实现GRNN广义回归神经网络时间序列预测(多指标,多图)效果一览基本介绍程序设计参考资料效果一览 基本介绍 1.MATLAB实现GRNN广义回归神经网络时间序列预测(完整源码和数据) …

万界星空/推出生产制造执行MES系统/开源MES/免费下载

免费MES系统介绍 什么是MES系统呢&#xff1f;MES系统主要功能就是解决“如何生产”的问题。通过实施MES系统&#xff0c;一站式解决您所困扰的所有生产制作流程问题。 普通的免费MES系统只提供简单的基本功能让客户体验&#xff0c;而万界星空MES系统运用低代码的形式开发&a…

【Python】Web学习笔记_flask(2)——getpost

flask提供的request请求对象可以实现获取url或表单中的字段值 GET请求 从URL中获取name、age两个参数 from flask import Flask,url_for,redirect,requestappFlask(__name__)app.route(/) def index():namerequest.args.get(name)agerequest.args.get(age)messagef姓名:{nam…

【雕爷学编程】MicroPython动手做(02)——尝试搭建K210开发板的IDE环境4

7、使用串口工具 &#xff08;1&#xff09;连接硬件 连接 Type C 线&#xff0c; 一端电脑一端开发板 查看设备是否已经正确识别&#xff1a; 在 Windows 下可以打开设备管理器来查看 如果没有发现设备&#xff0c; 需要确认有没有装驱动以及接触是否良好 &#xff08;2&a…

C++设计模式之访问者模式

C访问者设计模式 文章目录 C访问者设计模式什么是设计模式什么是访问者设计模式该模式有什么优缺点优点缺点 如何使用 什么是设计模式 设计模式是一种通用的解决方案&#xff0c;用于解决特定的一类问题。它是一种经过验证的代码组织方式&#xff0c;可以帮助开发人员更快地实…

栈的压入,弹出序列

栈的压入弹出序列问题可以通过模拟栈的压入和弹出过程来解决。 具体思路如下&#xff1a; 定义一个辅助栈&#xff0c;用于模拟压栈和弹栈操作。遍历给定的压栈序列&#xff0c;在每一次循环中执行以下操作&#xff1a; 将当前元素压入辅助栈。循环检查辅助栈的栈顶元素是否与…