C++——关联式容器(5):哈希表

news2024/11/16 1:34:39

7.哈希表

7.1 哈希表引入

        哈希表的出现依旧是为了查找方便而设计的。在顺序结构中,查询一个值需要一一比较,复杂度为O(N);在平衡树中,查询变为了二分查找,复杂度为O(logN);而对于哈希表,我们可以通过特殊的映射关系,将其查询的复杂度变为O(1)。

        因此,哈希(散列)的主要思想是用于查找,希望可以不通过比较,一次性拿到要搜索的元素。为了实现这个目的,可以考虑将带存储的元素按照某种哈希方法(散列方法)映射到指定位置,这样就在存储元素和存储位置之间产生了一一映射关系。通过这种关系,我们可以根据待搜索的元素通过哈希函数直接计算出存储位置。类比于数学中一种特殊的映射关系——函数:给定一个自变量x(给一个带存储的元素的key),通过函数关系f(通过哈希函数Hash),得到一个因变量y(得到key对应的值,作为存储位置的标识)。

        通过以上的方法构建出来的结构即为哈希表(散列表)

        哈希函数用于计算不同的元素应该存储的位置。常见的哈希函数 Hash(key) :

①直接定址法:即线性关系确定位置 Hash(key) = A*key+B

②除留余数法:即对哈希表的长度取模 Hash(key) = key%m

        如同函数一样,一个多个自变量可能映射到同一个因变量。哈希函数虽然决定了映射关系,但问题也很明显,即无法避免位置的冲突,我们一般将不同关键码而具有相同哈希地址的现象称为哈希冲突。为了解决哈希冲突,可以采取闭散列或者开散列两种方法。

7.2 闭散列(开放地址法)

        闭散列规定,当发生哈希冲突时,可以把当前的key存储到冲突位置的“下一个”位置。

7.2.1 结构设计

        对于开放地址法,哈希表采用数组的方式进行哈希表的组织,即数组的每个位置存放一个元素。当发生冲突时就按照既定的规则,找寻数组合适的空位。

        当存在这样“寻找”的操作时,因为单纯依靠数组中的值没有办法判断当前位置是否可用,因此需要通过一个标记来指明对应位置的状态。哈希表中的元素包括:空、占用、删除三种状态。空——位置没有元素,可以存放新元素;占用——当前位置已存在有效元素;删除——当前位置元素无效。给定这三种状态将使得我们的插入、删除、查找操作变得容易起来。

        给出如下基本结构,使用结构体将值和状态组织起来,存在数组中构成哈希表,_num成员记录存入的元素数量。

	//因为哈希冲突的存在,我们在占用对应位置时需要先对哈希表中对应位置的数据进行性质判断
	//哈希表中的元素包括 有效、已删除、空 三种状态,通过枚举常量来定义出这三种状态
	enum state{
		EMPTY,
		DELETE,
		EXIST
	};

	//哈希表元素既要包含key-value,还需要包含状态,因此使用结构体
	template <class K, class V>
	struct HashData
	{
		pair<K, V> _kv;
		state _state = EMPTY;
	};

	template<class K, class V, class HashFunc = HashFunc<K>>
	class HashTable {
	public:
		//构造函数
		HashTable()
		{
			_table.resize(10);
		}
	private:
		vector<HashData<K, V>> _table;
		size_t _num = 0;
	};

7.2.2 哈希表模板

    template<class K, class V, class HashFunc = HashFunc<K>>

         对于这个模板参数,重点解释一下其中的HashFunc的作用。我们的哈希表采用除留取余的方法计算映射位置,采取这个哈希方法就表明我们的key必须要是可以取模的值,也就是整型。但是key不一定都是整型,例如string类型的key也是完全可以的。所以设定了HashFunc这个模板参数,用于接受一个仿函数,这个仿函数可以将key转化为可以进行取模运算的形式。

	//需要注意,当前哈希表的哈希函数是直接对key取模
	//但是当哈希表元素的key是其他类型,如string时,很明显这种取模运算就会产生错误,因此我们需要想办法将string对象转化为可以取模运算的数值
	//我们通过模板参数HashFunc来处理,这是一个仿函数类,通过这个仿函数我们可以得到到对应对象的可进行模运算的数字

	//这是哈希表缺省仿函数类,当key类型可以转换成size_t时即可不传参采取默认的仿函数
	template<class K>
	struct HashFunc {
		size_t operator()(const K& key)
		{
			return (size_t)key;
		}
	};
	//类模板的特化,这样string类型也可以采取默认的仿函数了
	template<>
	struct HashFunc<string> {
		size_t operator()(const string& s)
		{
			size_t ret = 0;
			for (const auto& e : s)
			{
				ret = ret * 31 + e;	//string对象采取每次乘31再加下一个字符的策略
			}
			return ret;
		}
	};

        上述代码给出了HashFunc的模板类,对于一般的key直接尝试强制类型转换。另外可以对一些特殊情况,如string写出其模板特化,人为的将其转化为可计算的形式。

        使用了特化的形式就可以在哈希表的模板参数处给定缺省值,这样可以在实例化模板时不需要考虑string的HashFunc问题,自动使用缺省值的特化。

7.2.3 insert插入

        插入元素的操作有多个注意点,我们一一列举。

        ①哈希冲突处理方式

        在插入元素的过程中,势必会出现哈希冲突,而闭散列规定,当发生哈希冲突时,把当前的值存储到冲突位置的“下一个”位置。所以问题就转化为了如何去找这“下一个位置”。

        处理的方案有很多,此处只介绍两种方法。一般常用的方法是线性探测,即当发现当前位置冲突时,就+1尝试存在后一个位置,如果仍然冲突就继续向后+1寻找,本文采用的即为这种方法。另一种是很相似的二次探测,其冲突后所加的值为二次序列,即1 4 9 16...。

        当状态是EXIST的时候就说明被占用发生冲突,需要持续寻找直到找到非EXIST的位置。

        ②扩容

        如此向哈希表中存储肯定有容量满的时刻,因此我们还需要处理扩容逻辑。规定负载因子(实际就是数组占用率)大于等于0.7的时候就进行扩容。

        因为我们使用的是除留余数法,是根据哈希表的长度来进行取模映射的,所以当哈希表长度变化时,也就代表我们需要重新映射元素位置了。重新映射实际上也就是将原来的元素依次插入新扩容的表,于是我们可以复用Insert函数,代替我们完成插入操作。虽然效率没有变化,但是少写了部分代码,还是很棒的。

        ③不允许重复元素

        哈希表和set、map一样不允许重复元素的出现,因此需要通过Find来排除重复元素的情况。

		//插入
		bool Insert(const pair<K,V>& kv)
		{
			//使用find函数排除重复的情况
			if (Find(kv.first))
			{
				return false;
			}

			//处理扩容的情况
			//当负载因子(已存储元素/哈希表总长度)>= 0.7时就进行扩容
			//已存储元素由成员_num记录,每次成功insert后_num++
			if (_num * 10 / _table.size() >= 7)
			{
				//因为哈希函数计算中参考了哈希表长度,所以在扩容后会使得映射关系发生变化,所以需要一个一个重新调整
				//扩容方法:定义一个原先size大小二倍的数组,遍历原来的哈希表,将值一一映射到新的哈希表中,最后将这个局部变量与旧表交换,出函数作用域销毁旧表留下新表
				//我们发现上述方法中,将原值重新映射到新的哈希表中实际上就是对新表的insert,于是我们可以复用insert逻辑
				//由于在此阶段,新表不会涉及到重复或扩容问题,所以实际复用的是之后正常的插入逻辑

				HashTable<K, V> newtable;
				newtable._table.resize(2 * _table.size());
				for (int i = 0; i < _table.size(); i++)
				{
					if (_table[i]._state == EXIST)
					{
						newtable.Insert(_table[i]._kv);
					}
				}
				_table.swap(newtable._table);
			}

			HashFunc hfs;
			size_t hashi = hfs(kv.first) % _table.size();
			//当发生哈希冲突时,采取线性探测的方法找寻下一个空位置
			//线性探测:从发生冲突的位置开始,依次向后逐个检查是否有空位置
			//与之相似的探测方法还有二次探测:从发生冲突的位置开始,按二次方的规律找后1、4、9、16…的位置是否有空位置
			while (_table[hashi]._state == EXIST)
			{
				hashi = (hashi + 1) % _table.size();
			}
			//找到空位(DELETE、EMPTY),修改对应地址下的数据
			_table[hashi]._kv = kv;
			_table[hashi]._state = EXIST;
			_num++;
			return true;
		}
		

7.2.4 Find查找

        查找逻辑需要依照哈希函数找到开始查找的位置,也需要安装哈希冲突的处理方法既定的逻辑,按图索骥寻找可能的位置。在此过程中只有碰到了EMPTY才说明查找完毕,因为DELETE是被删除位置,它的“下一个位置”仍可能是通过哈希冲突占用的元素。

		//查找
		HashData<K, V>* Find(const K& key)
		{
			HashFunc hsf;
			size_t hashi = hsf(key) % _table.size();
			while (_table[hashi]._state != EMPTY)	//注意DELETE不能作为判断查找完毕的标志
			{
				if (_table[hashi]._kv.first == key)
				{
					return &_table[hashi];
				}
				hashi = (hashi + 1) % _table.size();
			}
			return nullptr;
		}

 7.2.5 Erase删除

        删除操作只需要找到位置后,将状态置为DELETE即可。

		//删除
		//删除只需要将对应位置的状态置为删除即可
		bool Erase(const K& key)
		{
			if (HashData<K, V>* dst = Find(key))
			{
				dst->_state = DELETE;
				_num--;
				return true;
			}
			else
			{
				return false;
			}
		}

7.3 开散列(链地址法/拉链法)

        开散列在哈希表的每个位置定义一个哈希桶(也就是链表),在发生冲突时将后来的结点链接在当前位置的链表后,即一个位置可以有多个由链表链接起来的元素。

7.3.1 结构设计

        拉链法的哈希表每个位置由一个哈希桶组成,因此按照单链表的方法定义链表节点即可。由于每个元素均是new的结点,因此需要一个析构函数遍历整个哈希表的所有哈希桶来析构结点。

	template <class K, class V>
	struct HashNode
	{
		//结点的构造函数
		HashNode(const pair<K, V>& kv)
			:_kv(kv)
			,_next(nullptr)
		{}

		pair<K, V> _kv;
		HashNode<K, V>* _next;
	};

	template<class K, class V, class HashFunc = HashFunc<K>>
	class HashTable {
	public:
		typedef HashNode<K, V> Node;
		//构造函数
		HashTable()
		{
			_table.resize(10, nullptr);
		}
		//析构函数
		~HashTable()
		{
			for (int i = 0; i < _table.size(); i++)
			{
				Node* cur = _table[i];
				while (cur)
				{
					Node* tmp = cur;
					cur = cur->_next;
					delete tmp;
				}
				_table[i] = nullptr;
			}
		}
	private:
		vector<Node*> _table;
		size_t _num = 0;
	};

        拉链法的哈希表模板参数与开放地址法相同,不再解释。

7.3.2 Insert插入 

        和开放地址法相似的逻辑,也不允许重复元素,除此之外还有一些值得注意的问题。

        ①扩容

        首先是负载因子,拉链法由于采取链表的方式,因此不会存在存不进去的问题。哈希表短,元素多只会导致链表过长效率变低,因此仍然使用负载因子,当其大于1时就扩容。

        如果扩容逻辑参照开放地址法,创建新表再复用Insert函数,看似方便,但实际存在效率问题。因为拉链法采取的是链表形式,所以Insert操作需要new结点,而在全部插入后又将原来的所有节点都释放了,相当于把旧有结点全部释放然后又重新申请。

        为了避免这个问题,可以考虑直接转移结点,即根据结点的值直接更改结点的链接_next,这样就避免了重复的new和delete。

        ②插入方法

        对于一个哈希桶,当做一个单链表即可,最方便的插入元素方式即为头插。

		//插入
		bool Insert(const pair<K, V>& kv)
		{
			//使用find函数排除重复的情况
			if (Find(kv.first))
			{
				return false;
			}

			HashFunc hfs;
			size_t hashi = hfs(kv.first) % _table.size();

			//处理扩容的情况
			//当负载因子(已存储元素/哈希桶总数量(也即哈希表长度))>= 1时就进行扩容,
			//已存储元素由成员_num记录,每次成功insert后_num++
			if (_num  / _table.size() >= 1)
			{
				//同样的,在扩容后会使得映射关系发生变化,需要一个一个重新调整
				//扩容方法:定义一个原先size大小二倍的数组,遍历原来的哈希表,将值一一映射到新的哈希表中,最后将这个局部变量与旧表交换,出函数作用域销毁旧表留下新表
				
				//我们采取老方法,将这个过程看作向新表插入数据,复用insert
				//但是这种方法需要将原哈希表的结点全部释放后再new一遍,效率低
				
				/*HashTable<K, V> newtable;
				newtable._table.resize(2 * _table.size(), nullptr);
				for (int i = 0; i < _table.size(); i++)
				{
					Node* cur = _table[i];
					while (cur)
					{
						newtable.Insert(cur->_kv);
						cur = cur->_next;
					}
				}
				_table.swap(newtable._table);*/

				//我们可以采取直接转移结点的方法,遍历原哈希表,将其中的结点直接链在新哈希表中,实际上就是把Insert的复用部分再写一遍
				vector<Node*> newtable;	//新建一个vector作为新的table
				newtable.resize(2 * _table.size(), nullptr);
				for (int i = 0; i < _table.size(); i++)
				{
					Node* cur = _table[i];
					while (cur)
					{
						Node* tmp = cur->_next;//记录cur的next

						size_t hashi = hfs(cur->_kv.first) % newtable.size();//根据新表计算地址
						//头插
						cur->_next = newtable[hashi];
						newtable[hashi] = cur;
						cur = tmp;
					}
					_table[i] = nullptr;//原表置空是好习惯,此处换出去的是vector,析构时析构的也是vector<Node*>不会调用到哈希表的析构函数,所以不会释放结点,不置空也不会有问题
				}
				_table.swap(newtable);
			}

			//找到了对应位置后直接头插即可
			Node* newnode = new Node(kv);
			newnode->_next = _table[hashi];
			_table[hashi] = newnode;
			_num++;

			return true;
		}

7.3.3 Find查找

        在拉链法下查找,只需要根据哈希函数找到对应位置,然后遍历哈希桶即可。

		//查找
		//哈希函数定位到位置,遍历哈希桶寻找
		Node* Find(const K& key)
		{
			HashFunc hsf;
			size_t hashi = hsf(key) % _table.size();
			Node* cur = _table[hashi];
			while (cur)
			{
				if (cur->_kv.first == key)
				{
					return cur;
				}
				cur = cur->_next;
			}
			return nullptr;
		}

7.3.4 Erase删除

        删除操作实际就只是单链表的删除操作,根据哈希函数确定单链表,然后进行删除即可,这是单链表的基操。

		//删除
		//因为是单链表的删除,需要遍历桶找到前驱节点,并且考虑头删的问题
		bool Erase(const K& key)
		{
			HashFunc hsf;
			size_t hashi = hsf(key) % _table.size();
			Node* cur = _table[hashi];
			Node* prev = nullptr;
			while (cur)
			{
				if (cur->_kv.first == key)
				{
					if (prev == nullptr)
					{
						_table[hashi] = cur->_next;
					}
					else
					{
						prev->_next = cur->_next;
					}
					delete cur;
					_num--;
					return true;
				}
				else
				{
					prev = cur;
					cur = cur->_next;
				}
			}
			return false;
		}

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

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

相关文章

BST-二叉搜索树

前言 从图的角度出发&#xff0c;树是一种特殊的图。图的大多数算法&#xff0c;树都可以适用。对树操作中&#xff0c;你可以发现有关图算法思想的体现。 不过&#xff0c; 本篇不是完全从图的角度解读树&#xff0c; 重点在初学者视角&#xff08;一般学习数据结构顺序是从树…

码点和码元的区别--Unicode标准的【码点】和【码元】

Unicode是通用字符编码标准是计算机科学领域里的一项业界标准&#xff0c;包括字符集、编码方案等。 Unicode标准定义了一个统一的多语言文本字符集&#xff08;即Unicode字符集&#xff09;。 Unicode标准定义了三种字符编码方案&#xff1a;UTF-8、UTF-16、UTF-32。 因此&…

【Java面向对象高级06】static的应用知识:代码块

文章目录 前言一、代码块概述二、代码块分2种 1、静态代码块2、实例代码块总结 前言 记录static的应用知识&#xff1a;代码块 一、代码块概述 代码块是类的5大成分之一&#xff08;成员变量&#xff0c;构造器&#xff0c;方法&#xff0c;代码块&#xff0c;内部类&#xf…

「Python教程」vscode的安装和python插件下载

粗浅之言&#xff0c;如有错误&#xff0c;欢迎指正 文章目录 前言Python安装VSCode介绍VSCode下载安装安装python插件 前言 Python目前的主流编辑器有多个&#xff0c;例如 Sublime Text、VSCode、Pycharm、IDLE(安装python时自带的) 等。个人认为 vscode 虽然在大型项目上有…

一个好用的MP3音乐下载网,我推荐给你(免费)

点击访问->https://www.gequbao.com/ 或用Bing搜索歌曲宝即可。 主页面长这样子~ 以最近大火的悲鸣海为例&#xff0c;搜索&#xff1b; 以第一个为例&#xff0c;点击&#xff1b; 它既支持下载.mp3格式的音乐文件&#xff0c;还支持下载.lrc的歌词文件。 非常好用&…

使用ChatGPT引导批判性思维,提升论文的逻辑与说服力的全过程

学境思源&#xff0c;一键生成论文初稿&#xff1a; AcademicIdeas - 学境思源AI论文写作 批判性分析&#xff08;Critical Analysis&#xff09; 是论文写作中提升质量和说服力的重要工具。它不仅帮助作者深入理解和评价已有研究&#xff0c;还能指导作者在构建自己论点时更加…

网络工程师学习笔记——网络互连与互联网(三)

TCP三次握手 建立TCP连接是通过三次握手实现的&#xff0c;采用三报文握手主要是为了防止已失效的连接请求报文突然又传送到了&#xff0c;因而产生错误 主动发起TCP连接建立的称为客户端 被动等待的为TCP服务器&#xff0c;二者之间需要交换三个TCP报文段 首先是客户端主动…

jQuery——对象的使用

1、理解&#xff1a;即执行 jQuery 核心函数返回的对象 2、jQuery 对象内部包含的是 dom 元素对象的伪数组&#xff08;可能只有一个元素&#xff09; 3、jQuery 对象是一个包含所有匹配的任意多个 dom 元素的伪数组对象 4、基本行为&#xff1a; ① size&#xff08;&#xf…

Java_Se 数组与数据的存储

数组是相同类型数据的有序集合。其中&#xff0c;每一个数据称作一个元素&#xff0c;每个元素可以通过一个索引&#xff08;下标&#xff09;来访问它们。 数组的四个基本特点&#xff1a; 1.长度是确定的。数组一旦被创建&#xff0c;它的大小就是不可以改变的。 2.其元素…

【Java 问题】基础——面相对象

面向对象 15. 面向对象和面向过程的区别&#xff1f;16. 面向对象的基本特征17.重载&#xff08;overload&#xff09;和重写&#xff08;override&#xff09;的区别&#xff1f;18.访问修饰符public、private、protected、以及不写&#xff08;默认&#xff09;时的区别&…

2024低代码大赛火热进行,豪礼抢先看~

2024 网易低代码大赛正火热进行中&#xff0c;其中“网易云信低代码”专区吸引了众多开发者参与。 通过低代码高效、灵活的应用构建方式&#xff0c;结合云信的即时通讯和音视频能力&#xff0c;开发者既可以大幅缩短开发周期&#xff0c;还能提升应用的互动性和用户体验。 为…

AGV小车全双工通信应用-低延迟、8路并发全双工通信

随着智能制造和物流行业的不断发展&#xff0c;AGV小车&#xff08;自动导引车&#xff09;在工厂、仓库、物流中心的应用日益广泛。AGV小车凭借其自动化、高效、灵活的特点&#xff0c;逐渐成为物料搬运中的关键设备。在这种复杂多变的环境中&#xff0c;数据传输的可靠性、实…

支持云边协同的「物联网平台+边缘计算底座」

2024年9月20日&#xff0c;工信部发布《工业重点行业领域设备更新和技术改造指南》&#xff0c;旨在指导工业领域设备更新和技术改造工作。该指南设定目标&#xff0c;到2027年完成约200万套工业软件和80万台工业操作系统的更新换代任务。此外&#xff0c;计划实现80%规模以上制…

Python3自带HTTP服务:轻松开启与后台管理

Python3自带有http服务&#xff0c;可以在服务器&#xff0c;也可以在本地启动&#xff0c;并运行一些常用的网页程序。比如&#xff1a;我们可以把streamlit框架编写的网页放到服务器上&#xff0c;开启http服务&#xff0c;就可以通过网页来调用这个pythont程序了&#xff0c…

14、线程池ForkJoinPool实战及其工作原理分析

1. 由一道算法题引发的思考 算法题&#xff1a;如何充分利用多核CPU的性能&#xff0c;快速对一个2千万大小的数组进行排序&#xff1f; 1&#xff09;首先这是一道排序的算法题&#xff0c;而且是需要使用高效的排序算法对2千万大小的数组进行排序&#xff0c;可以考虑使用快…

重头开始嵌入式第四十二天(硬件 ARM体系架构)

目录 一&#xff0c;ARM是什么&#xff1f; 1.公司名称 ARM的主流架构&#xff1a; 2.处理器架构 二&#xff0c;什么是处理器架构&#xff1f;什么是处理器&#xff1f; 一、处理器 二、处理器架构 三&#xff0c;一个计算机由什么构成呢&#xff1f; 一、硬件系统 二…

SDK3(note上)

搞了举个窗口设置还有鼠标处理的信息 注释写在代码中了 #include <windows.h> #include<tchar.h> #include <stdio.h> #include <strsafe.h>/*鼠标消息 * 键盘消息 快捷键消息 菜单消息 控件消息 自定义消息 窗口消息 客户区域的概念(Client Aera) 非…

什么是 SIP 及 IMS 中的 Forking

目录 1. SIP 网络中 Forking 的定义 2. SIP Forking 的分类 2.1 Oaraller Forking 的分类 2.2 Sequential Forking 的分类 博主wx:yuanlai45_csdn 博主qq:2777137742 后期会创建粉丝群,为同学们提供分享交流平台以及提供官方发送的福利奖品~ 1. SIP 网络中 Forking 的定…

828华为云征文|华为云Flexus云服务器X实例部署immich相片管理系统

828华为云征文&#xff5c;华为云Flexus云服务器X实例部署immich相片管理系统 前言一、Flexus云服务器X实例介绍1.1 Flexus云服务器X实例简介1.2 Flexus云服务器X实例特点1.3 Flexus云服务器X实例使用场景 二、immich介绍2.1 immich简介2.2 immich注意事项2.3 主要特点2.4 使用…

AI预测体彩排3采取888=3策略+和值012路或胆码测试9月25日升级新模型预测第91弹

经过90多期的测试&#xff0c;当然有很多彩友也一直在观察我每天发的预测结果&#xff0c;得到了一个非常有价值的信息&#xff0c;那就是9码定位的命中率非常高&#xff0c;已到达90%的命中率&#xff0c;这给喜欢打私菜的朋友提供了极高价值的预测结果~当然了&#xff0c;大部…