【C++深度探索】哈希表介绍与实现

news2024/11/15 23:55:01
🔥 个人主页:大耳朵土土垚
🔥 所属专栏:C++从入门至进阶

这里将会不定期更新有关C/C++的内容,欢迎大家点赞,收藏,评论🥳🥳🎉🎉🎉

文章目录

  • 1. 哈希概念
  • 2. 哈希冲突
    • ✨哈希冲突原因
  • 3. 解决哈希冲突
    • ✨闭散列
    • ✨开散列
  • 4. 结语

1. 哈希概念

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

  而理想的搜索方法是可以不经过任何比较,一次直接从表中得到要搜索的元素。这就要借助我们的哈希函数。

  如果能构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的值之间能够建立一 一映射的关系,那么在查找时通过该函数可以很快找到该元素的位置。

  C++中的哈希(hash)就是一种将任意大小的数据映射为固定大小值的函数。这样我们就可以直接根据元素的值通过哈希映射找到它的存储位置了。

哈希函数将输入数据转化为哈希值,这个哈希值通常是一个整数,用来表示原始数据。通过将数据的哈希值与存储空间进行映射,可以使得数据的存储和访问更加高效。


例如:

  • 数据集合{1,7,6,4,5,9};
  • 我们将哈希函数设置为:hash(key) = key % capacity; capacity为存储元素底层空间总的大小。
  • 这样我们就可以将数据集合的值通过哈希函数得到它存储的位置存储到容器中:

数据1通过哈希函数得到它的存储位置是1,就存储到容器位置为1的地方


  该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)(或者称散列表)

✨插入元素:
  根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放。

✨搜索元素:
  对要搜索的元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功。


用该方法进行搜索不必进行多次关键码的比较,因此搜索的速度比较快。


2. 哈希冲突


  如果按照上述哈希方式,向集合中插入元素11,我们就会发现与之前插入元素1的位置发生冲突,这就叫哈希冲突,即不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。

  把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”。

✨哈希冲突原因

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

 哈希函数设计原则:

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

 常见哈希函数:

  1. 直接定址法–(常用)

  取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B

  • 优点:简单、均匀
  • 缺点:需要事先知道关键字的分布情况
  • 使用场景:适合查找比较小且连续的情况
  1. 除留余数法–(常用)

  设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址。

  1. 平方取中法–(了解)

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

  1. 折叠法–(了解)

  折叠法是将关键字从左到右分割成位数相等的几部分(最后一部分位数可以短些),然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。折叠法适合事先不需要知道关键字的分布,适合关键字位数比较多的情况。

  1. 随机数法–(了解)

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

  1. 数学分析法–(了解)

  设有n个d位数,每一位可能有r种不同的符号,这r种不同的符号在各位上出现的频率不一定相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只有某几种符号经常出现。可根据散列表的大小,选择其中各种符号分布均匀的若干位作为散列地址。数字分析法通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布较均匀的情况。

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

3. 解决哈希冲突


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

✨闭散列

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

  我们可以使用线性探测和二次探测这两种方法找到“下一个” 空位置。

  • 线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。

  例如前面的哈希表中插入元素11,先通过哈希函数计算哈希地址,hashAddr为1,因此11理论上应该插在该位置1,但是该位置已经放了值为1的元素,即发生哈希冲突。然后我们就可以从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置2插入与元素11即可:


 代码如下:

// 哈希函数采用除留余数法
template<class K>
struct HashFunc
{
	size_t operator()(const K& key)
	{
		return (size_t)key;
	}
};


// 以下采用开放定址法,即线性探测解决冲突
namespace open_address
{
	enum State
	{
		EXIST,
		EMPTY,
		DELETE

	};

	template<class K, class V>
	struct HashData
	{
		pair<K, V> _kv;
		State _state = EMPTY;
	};

	template<class K, class V, class Hash = HashFunc<K>>
	class HashTable
	{
	public:
		HashTable()
		{
			_tables.resize(10);
		}

		bool Insert(const pair<K, V>& kv)
		{
			//1.先找是否已经插入过相同的值
			if (Find(kv.first))
				return false;
			//2.判断是否需要扩容
			//如果已经插入了70%就扩容
			if (_n * 10 / _tables.size() >= 7)
			{
				HashTable<K, V> h;
				h._tables.resize(2 * _tables.size());
				for (auto& e : _tables)
				{
					if(e._state==EXIST)
						h.Insert(e._kv);
				}
				_tables.swap(h._tables);
			}
			//3.通过Hash函数找到插入位置
			Hash hs;
			size_t addr = hs(kv.first) % _tables.size();

			//4.通过插入位置的状态来插入		
			while (_tables[addr]._state == EXIST)
			{
				addr++;
				addr %= _tables.size();
			}
			_tables[addr]._kv = kv;
			_tables[addr]._state = EXIST;
			_n++;
			return true;
		}

		HashData<K, V>* Find(const K& key)
		{
			//先找到key对应的Hash值
			Hash hs;
			size_t ht = hs(key) % _tables.size();
			while (_tables[ht]._state != EMPTY)
			{
				if(_tables[ht]._state == EXIST && _tables[ht]._kv.first == key)
					return &_tables[ht];
				++ht;
				ht %= _tables.size();//防止越界访问
			}
			return nullptr;
		}

		bool Erase(const K& key)
		{
			//1.先找到删除的位置
			HashData<K, V>* phd = Find(key);
			if (phd == nullptr)
				return false;
			//2.删除,将该节点的状态设成删除即可
			phd->_state = DELETE;
			_n--;
			return true;
		}

	private:
		vector<HashData<K, V>> _tables;
		
		size_t _n = 0;  // 表中存储数据个数
	};


因为上述哈希表是使用数组来实现的,删除一个数据是将该位置的状态置成DELETE状态,我们不能简单的置为EMPTY,这是因为查找时,如果该位置是空状态我们没办法确定后面有没有值,因为该位置可能被删除了,后面可能还存了值,所以需要DELETE状态;如果查找时状态为DELETE就需要继续往后查找,直到找到或者出现状态为EMPTY时就没找到。

对于插入函数,当插入的数据占总容量70%时就需要进行扩容

线性探测优点:实现非常简单
线性探测缺点:一旦发生哈希冲突,所有的冲突连在一起,容易产生数据“堆积”,即:不同
关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降
低。


  • 二次探测:通过使用一个二次函数来计算下一个探测位置,例如: h ( k , i ) = ( h ( k ) + c 1 ∗ i + c 2 ∗ i 2 ) m o d M h(k,i) = (h(k) + c1 * i + c2 * i^2) mod M h(k,i)=(h(k)+c1i+c2i2)modM其中h(k)为元素的哈希值,i为探测序列号,c1和c2是用于探测的常数,M是哈希表的大小。通过不断增加i的值,可以在哈希表中依次探测下一个位置,直到找到一个空槽或者遍历完所有槽。

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


总之,闭散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷。

✨开散列

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

如下图所示:


从上图可以看出,开散列中每个桶中放的都是发生哈希冲突的元素。

实现代码如下:

// 哈希函数采用除留余数法
template<class K>
struct HashFunc
{
	size_t operator()(const K& key)
	{
		return (size_t)key;
	}
};

// 哈希表中支持字符串的操作
template<>
struct HashFunc<string>
{
	size_t operator()(const string& key)
	{
		size_t hash = 0;
		for (auto e : key)
		{
			hash *= 31;
			hash += e;
		}

		return hash;
	}
};


//哈希桶类
namespace hash_bucket
{
	template<class K, class V>
	struct HashNode
	{
		pair<K, V> _kv;
		HashNode<K, V>* _next;

		HashNode(const pair<K, V>& kv)
			:_kv(kv)
			, _next(nullptr)
		{}
	};

	template<class K,class V, class Hash = HashFunc<K>>
	class HashTable {
	public:

		typedef HashNode<K,V> Node;
	
			HashTable()
			{
				_tables.resize(10,nullptr);
			}
			
		~HashTable()
		{
			// 依次把每个桶释放
			for (size_t i = 0; i < _tables.size(); i++)
			{
				Node* cur = _tables[i];
				while (cur)
				{
					Node* next = cur->_next;
					delete cur;
					cur = next;
				}
				_tables[i] = nullptr;
			}
		}

			bool Insert(const pair<K, V>& kv)
			{
				//1.先找是否已经插入过相同的值
				if (Find(kv.first))
					return false;
				Hash hs;
				//2.判断是否需要扩容
				//如果负载因子为1就扩容
				if (_n ==  _tables.size())
				{
					HashTable<K, V> h;
					h._tables.resize(2 * _tables.size(),nullptr);
					//只需要将哈希桶插入即可
					for (size_t i = 0; i < _tables.size(); i++)
					{
						Node* cur = _tables[i];
						while (cur)
						{
							size_t hash = hs(cur->_kv.first) % h._tables.size();
							Node* Next = cur->_next;
							cur->_next = h._tables[hash];
							h._tables[hash] = cur;
							cur = Next;
						}
						_tables[i] = nullptr;
					}
					
					_tables.swap(h._tables);
				}

				//3.通过Hash函数找到插入位置
				
				size_t addr = hs(kv.first) % _tables.size();

				//4.头插到新表
				if (_tables[addr] == nullptr)//如果是空,_n就需要++
					_n++;
				Node* newnode = new Node(kv);
				newnode->_next = _tables[addr];
				_tables[addr] = newnode;
				return true;
			}

			Node* Find(const K & key)
			{
				//先找到key对应的Hash值
				Hash hs;
				size_t ht = hs(key) % _tables.size();
				Node* cur = _tables[ht];
				while (cur)
				{
					if (cur->_kv.first == key)
						return cur;
					cur = cur->_next;
				}
				return nullptr;
			}

			bool Erase(const K & key)
			{
				//1.先找到删除的位置
				Hash hs;
				size_t ht = hs(key) % _tables.size();
				Node* cur = _tables[ht];
				Node* parent = nullptr;
				while (cur)
				{
					if (cur->_kv.first == key)
						break;
					parent = cur;
					cur = cur->_next;
				}
				if (cur == nullptr)
					return false;
				
				//2.删除对应节点
				if (parent)
					parent->_next = cur->_next;
				else
					_tables[ht] = cur->_next;
				
				//修改_n
				if (_tables[ht] == nullptr)
					_n--;

				//3.释放原节点
				delete cur;
				return true;
			}

	private:
		vector<Node*> _tables;
		size_t _n;//记录存储数据的位置个数
	};

哈希桶的实现与上述线性探测类似,但是哈希桶不再使用状态来标记位置,而是使用链表的一个一个节点,插入就开辟一个新的节点,删除就释放旧节点;同样哈希桶如果满了也需要扩容,所以哈希桶类中也有一个内置类型_n来记录存储数据的位置个数;最后因为使用的是链表的节点,所以析构需要将节点一个一个释放才不会造成内存泄漏。


✨开散列与闭散列比较:

  应用链地址法处理溢出,需要增设链接指针,似乎增加了存储开销。事实上:由于开放定址法必须保持大量的空闲空间以确保搜索效率,如二次探查法要求装载因子a <= 0.7(也就是空间占用率小于等于0.7),而表项所占空间又比指针大的多,所以使用链地址法反而比开地址法节省存储空间。


4. 结语


  在C++中,哈希(Hash)是一种常用的数据结构技术,用于将数据转换为固定长度的哈希值。哈希值是唯一的,可以用于快速查找、比较和索引。以上就是今天所有的内容啦 ~ 完结撒花 ~ 🥳🎉🎉

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

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

相关文章

量化策略开发步骤系列(4)参数分析和过度拟合

量化策略开发步骤系列&#xff08;4&#xff09;参数分析和过度拟合 参数分析过度拟合 这是量化交易系列文章的第二系列——量化策略开发步骤&#xff0c;第一系列请参考专栏&#xff1a; 量化交易系统。很多朋友反馈最近的文章代码太多&#xff0c;看不懂。 这一部分将实现零…

2 C 语言开发工具选择、 MinGW 的安装与配置、VS Code 的安装与配置、插件推荐

目录 1 开发工具选择 1.1 Visual Studio 1.2 Code::Block 1.3 Clion 1.4 VS Code 1.5 在线编辑工具 2 开发工具安装 2.1 安装 MinGW-w64 2.1.1 MinGW-w64 介绍 2.1.2 解压 MinGW 2.1.3 将 MinGW 添加至环境变量 2.1.4 验证安装 2.2 安装 VS Code 2.2.1 下载安装包…

汉光BMF6450复印机简易安装说明手册

汉光BMF6450基本参数: 产品类型:激光数码复合机 颜色类型:黑白 速度类型:中速 复印速度:45cpm 涵盖功能:复印/打印/扫描 最大原稿尺寸:A3 处理器:800MHZ Quad Core (800MHz Dual Core+533MHz Dual Core) 内存容量:2GB 供纸容量:基本供纸量:1100页(550张2…

Windows平台RTSP|RTMP播放器如何叠加OSD文字

技术背景 我们在做Windows平台RTSP|RTMP播放器的时候&#xff0c;特别是多路播放场景下&#xff0c;开发者希望可以给每一路RTSP或RTMP流添加个额外的OSD台标&#xff0c;以区分不同的设备信息&#xff08;比如添加摄像头所在位置&#xff09;&#xff0c;本文主要探讨&#x…

手写qiankun-页面渲染

registerMicroApps配置子应用 start读取配置&#xff0c;拉取子应用并完成渲染 //全局变量 let _app [];//更好的获取全局变量_app export const getApps () > _app;//app为传递过来的子应用数组 export const registerMicroApps (app) > {_app app; };export cons…

http中get和post怎么选

5.4.2.怎么选择1.如果你是想从服务器上获取资源&#xff0c;建议使用GET请求&#xff0c;如果你这个请求是为了向服务器提交数据&#xff0c;建议使用POST请求。2.大部分的form表单提交&#xff0c;都是post方式&#xff0c;因为form表单中要填写大量的数据&#xff0c;这些数据…

RK3399平台开发系列讲解(内核入门篇)详解内联汇编

🚀返回专栏总目录 文章目录 一、C语言实现加法二、使用汇编函数实现加法三、内联汇编语法四、使用案例沉淀、分享、成长,让自己和他人都能有所收获!😄 📢要深入理解Linux内核中的同步与互斥的实现,需要先了解一下内联汇编:在C函数中使用汇编代码。 现代编译器已经足…

Linux系统调试课:CPUFreq 中央处理器频率调节技术

文章目录 一、CPUFreq组成二、用户接口三、设备树配置沉淀、分享、成长,让自己和他人都能有所收获!😄 📢中央处理器频率调节(Central Processing Unit frequency,CPUFreq)技术可以降低ARM芯片的功耗,例如在系统对任务压力较小时,通过调整处理器工作频率与输入电压的…

【一图学技术】9.OAuth2.0授权框架SSO单点登录图解及关系区别、使用场景

OAuth2.0原理&SSO单点登录图解 一、单点登录SSO 1.概述 ​ 单点登录&#xff08;全称Single Sign On&#xff0c;简称就是SSO)是一种身份验证和授权机制&#xff0c;它允许用户在多个相关但相互独立的系统或应用程序之间进行无缝切换&#xff0c;而无需重复登录。在多个…

【3】MySQL的安装即启动

目录 一.下载 二.安装 三.启动 一.下载 二.安装 安装MySQL时遇到的Initializing database错误&#xff1a;推荐下面的博客&#xff08;简单就是电脑名不要出现中文&#xff09; https://blog.csdn.net/m0_52775858/article/details/123705566 三.启动 PS&#xff1a;cmd要…

多台USB 3.0相机启动时部分相机无法打开

在使用多台USB 3.0相机时&#xff0c;遇到启动时部分相机无法打开的问题是较为常见的情况。这个问题通常与带宽、供电、驱动程序、或系统资源管理有关。以下是一些优化建议&#xff0c;帮助你提高相机启动的可靠性&#xff1a; 1. USB带宽管理 USB 3.0的带宽虽然比USB 2.0高很…

自训Transformer模型:识别图像是否由AI生成?

背景 随着AI生成图像技术的迅猛发展&#xff0c;特别是生成对抗网络&#xff08;GANs&#xff09;和深度学习的不断进步&#xff0c;生成的图像变得越来越逼真。 这项技术不仅催生了许多创新应用&#xff0c;也带来了潜在的风险和挑战。 Transformer模型在图像识别中的作用 …

PHP初级栈进阶篇

小刘小刘&#xff0c;下雨不愁 (收藏&#xff0c;关注不迷路) 这里我会更新一些php进阶知识点&#xff0c;新手想再进一步可以有个方向&#xff0c;也有个知识图谱的普及 当然本篇不止写技术 会涉及一些进阶路线 我也是在这里积累&#xff0c;希望和同行者一起进步为后来者…

网络协议四 物理层,数据链路层,数字信号,模拟信号,信道,CSMA/CD协议-以太网帧协议,PPP协议,网卡

从这一节开始学习 五层模型。学习方法是从最底层物理层开始学习 七层模型 五层模型 各个层用的协议&#xff0c;以及加上协议后的称谓 各个层的作用 应用层&#xff1a;可以认为是原始数据&#xff0c;该数据称为 报文&#xff0c;用户数据。 运输层&#xff1a;也叫传输层&am…

猫头虎 分享:Python库 Pytest 的简介、安装、用法详解入门教程

猫头虎 分享&#xff1a;Python库 Pytest 的简介、安装、用法详解入门教程 &#x1f680; 今天猫头虎带您深入了解 Python 测试框架 Pytest 的强大功能&#xff0c;手把手教您从安装到实际使用&#xff0c;助您轻松提升代码质量&#xff01;&#x1f63a; 摘要 &#x1f4cb; …

Windows蓝屏事件:深入分析与未来启示

引言 在2024年7月19日&#xff0c;一起引发全球范围蓝屏问题的事件&#xff0c;将安全领域领先的公司CrowdStrike推向了舆论的风口浪尖。尽管事后CrowdStrike发布了一份长达12页的根本原因分析&#xff08;RCA&#xff09;&#xff0c;试图解释并缓解这一问题&#xff0c;但该…

学习笔记 韩顺平 零基础30天学会Java(2024.8.14)

P500 集合体系图 单列集合是指自己只有一个值&#xff0c;双列集合是像键值对这样的 P501 Collection方法 对于第三点&#xff0c;像Set这样的&#xff0c;存放进去的和取出来的顺序可能不是一样的&#xff0c;所以就叫无序的 P502 迭代器遍历 在调用iterator.next()方法之前必…

新160个crackme - 030-Acid Bytes.4

运行分析 需要破解Name和Serial PE分析 upx壳&#xff0c;32位 linux系统upx -d 脱壳 脱壳后发现是Delphi程序 静态分析&动态调试 ida搜索字符串&#xff0c;找到Your Name must be at least 6 Chars long !&#xff0c;双击进入 发现地址为红色&#xff0c;即函数未定义 选…

grpc简单知识

目录 gRPC简介 RPC&#xff08;远程过程调用&#xff09;的定义与重要性 gRPC的设计目标与使用场景 ​编辑gRPC调用方式 Unary RPC&#xff1a;一元RPC Server-side streaming RPC&#xff1a;服务端流式RPC Client-side streaming RPC&#xff1a;客户端流式RPC Bidirecti…

Midjourney应用-用AI帮你做广告视频(动物走秀视频制作)

​ 前言/introduction 在之前的教程里我们讲过动物拟人化图片的生成。 这篇我们讲下这种图片的一种应用&#xff1a; 动物走秀视频广告制作方法 使用工具&#xff1a; MidjourneyAI视频生成工具&#xff08;即梦AI/可灵AI/runway&#xff09; 操作方法 step1-Midjourney出图 …