【C++】哈希表

文章目录

  • 哈希概念
  • 哈希冲突
  • 哈希函数
  • 哈希表
    • 闭散列
    • 开散列
  • 开散列与闭散列比较

正文开始前给大家推荐个网站,前些天发现了一个巨牛的 人工智能学习网站, 通俗易懂,风趣幽默,忍不住分享一下给大家。 点击跳转到网站。

哈希概念

顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即O( l o g 2 N log_2 N log2N),搜索的效率取决于搜索过程中元素的比较次数。
理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。

当向该结构中:
插入元素
根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放。
搜索元素
对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功。

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

哈希冲突

对于两个数据元素的关键字 k i k_i ki k j k_j kj(i != j),有 k i k_i ki != k j k_j kj,但有:Hash( k i k_i ki) == Hash( k j k_j kj),即:不同关键字通过相同哈希函数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。

哈希函数

哈希函数就是把关键字转化为对应哈希地址。引起哈希冲突的一个原因可能是:哈希函数设计不够合理。
哈希函数设计原则:

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

常用的哈希函数:

  1. 直接定址法
    取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B
    优点:简单、均匀
    缺点:需要事先知道关键字的分布情况
    使用场景:适合查找比较小且连续的情况

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

哈希表

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

闭散列

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

怎么找下个空位置呢?

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

线性探测和二次探测实现方法类似,只有找空位置的地方有点区别,我们以实现线性探测为例。我们使用的是除留余数法。每个Key对表的大小取余,确定对应的哈希地址。

一个位置的状态可能有三种,空、存在、删除,有很多人有一个误区,觉得空状态和删除状态是一样的,其实他们差别挺大的,为什么这么说呢?我们看下面场景

在这里插入图片描述
我们把6删除了。
在这里插入图片描述

此时如果找44,遇到空就会停止,那44我们就无法找到,所以删除状态是必须的,我们找查找时是遇到空就停止,并且删除44也是无法成功的,与查找同理。

结构
我们需要枚举,来表示三个状态,哈希表本质就是用数组实现的,我们这里可以直接使用vector,我们把状态和需要存储的值用一个结构体封起来,存到vector中就可以了。

enum Status
{
	EMP,//空
	DEL,//删除
	EXI//存在
};


template <class K, class V>
struct HashNode
{
	//表示状态
	Status _s;
	pair<K, V> _kv;
};

template <class K, class V, class Hash = HashFunc<K>>
class HashTable
{
private:
	vector<HashNode<K, V>> _v;
	//表示存储了多少个数据
	size_t _n = 0;
	//为了解决不能被取余的值
	Hash hs;
};

插入

首先需要通过哈希映射,所以要对表的大小取余,然后在这个对应的位置去把值放进去,并且把状态改为存在。但是有一个问题,这个位置有可能已经被有的值映射过了,这是就需要使用线性探测,我们要从这个位置开始找到一个状态为空或者删除的位置,然后才能把值放进去。还有一个问题就是,我们什么时候扩容呢?

有一个东西叫做负载因子,负载因子 = 数据的个数 / 表的大小,我们通过控制负载因子来控制扩容的时机,如果负载因子控制的太大,那么出现哈希冲突的概率就太大了,但是如果负载因子太小了,那么对于空间的浪费太多了,所以负载因子的选择对哈希表的影响还是挺大的。闭散列我们一般把负载因子控制在0.7.

那么怎么扩容呢?
我们不能再原表直接扩容,因为扩容会打乱映射关系,所以我们需要建立一个新表,然后把新表的大小初始化成旧表的二倍,然后遍历旧表,把旧表的数据重新插入映射到新表,新表的内容就是我们需要的,所以我们此时可以考虑直接swap两个新旧表中的vector,这样就可以完美的符合我们的需求。

bool Insert(const pair<K, V>& kv)
{
	if (Find(kv.first))
	{
		return false;
	}

	if (_n * 10 / _v.size() == 7)
	{
		int newsize = _v.size() * 2;
		HashTable<K, V> newTable;
		newTable._v.resize(newsize);

		for (size_t i = 0; i < _v.size(); i++)
		{
			if (_v[i]._s == EXI)
			{
				newTable.Insert(_v[i]._kv);
			}
		}

		_v.swap(newTable._v);
	}

	size_t hashi = hs(kv.first) % _v.size();
	//寻找下个是删除或者空的位置
	while (_v[hashi]._s == EXI)
	{
		hashi++;
		//防止越界
		hashi %= _v.size();
	}

	_v[hashi]._kv = kv;
	_v[hashi]._s = EXI;
	_n++;
	return true;
}

删除和查找

查找同样需要映射,然后从当前位置开始查找,只要不为空就一直找,如果找到了Key并且状态为存在,我们就可以直接返回当且的节点,如果到空都没找到,就返回nullptr。
删除需要先查找,查找到直接改状态就可以,不然就返回false。

HashNode<K,V>* Find(const K& k)
{
	size_t  hashi = hs(k) % _v.size();
	while (_v[hashi]._s != EMP)
	{
		if (_v[hashi]._s == EXI && _v[hashi]._kv.first == k)
		{
			return &_v[hashi];
		}
		hashi++;
		hashi %= _v.size();
	}

	return nullptr;
}

bool Erase(const K& k)
{
	HashNode<K, V>* f = Find(k);

	if (f)
	{
		f->_s = DEL;
		_n--;
		return true;
	}
	else
	{
		return false;
	}
}

以上情况如果我们存储整型的话当然是没问题的,但是如果我们存储的是string等不能直接被取余的类型怎么办?

这是我们就可以传一个仿函数,来把不能不能直接被取余的类型转化为整型。

template<class K>
struct HashFunc
{
	size_t operator()(const K& k)
	{
		return (size_t)k;
	}
};

template<>
struct HashFunc<string>
{
	size_t operator()(const string& s)
	{
		size_t sum = 0;
		for (auto e : s)
		{
			sum = sum * 31 + e;
		}
		return sum;
	}
};

一般我们会特化一个string的,因为字符串是最常见的。我们这里字符串转化为整型时BKDR法。

开散列

开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。开散列中每个桶中放的都是发生哈希冲突的元素。
这里实现我们直接使用vector就可以,因为每个桶都是哈希冲突的元素,所以结构需要一个链接下一个节点的指针。

结构

template <class K, class V>
struct HashNode
{
	pair<K, V> _kv;
	HashNode* _next;

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

template <class K, class V, class Hash = HashFunc<K>>
class HashTable
{
	typedef HashNode<K, V> Node;
private:
	vector<Node*> _tables;
	size_t _n;
	Hash hs;
};

这里的插入删除查找都是直接映射然后都是单链表的操作,插入因为哈希本来就是无序的所以我们直接头插就行了,只有扩容时需要我们重点说一下,我们可以跟上面闭散列一样,建立一个新表,然后把节点重新插入一下,但是这里会产生很多的浪费,因为原来表的节点重现插入以后,就会被释放了,有没有一种办法把原来的节点直接拿到新表呢?
我们遍历原表时,再拿到一个节点后直接通过映射把他的链接关系连接到新表中去就可以实现这样的效果,只不过需要我们提前记录一下下一个节点,不然会找不到下一个节点。

桶的个数是一定的,随着元素的不断插入,每个桶中元素的个数不断增多,极端情况下,可能会导致一个桶中链表节点非常多,会影响的哈希表的性能,因此在一定条件下需要对哈希表进行增容,那该条件怎么确认呢?
开散列最好的情况是:每个哈希桶中刚好挂一个节点,再继续插入元素时,每一次都会发生哈希冲突,因此,在元素个数刚好等于桶的个数时,可以给哈希表增容。

开散列

template<class K>
struct HashFunc
{
	size_t operator()(const K& k)
	{
		return (size_t)k;
	}
};

template<>
struct HashFunc<string>
{
	size_t operator()(const string& s)
	{
		size_t sum = 0;
		for (auto e : s)
		{
			sum = sum * 31 + e;
		}
		return sum;
	}
};
template <class K, class V>
struct HashNode
{
	pair<K, V> _kv;
	HashNode* _next;

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

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

	~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)
	{
		if (Find(kv.first))
		{
			return false;
		}

		if (_n == _tables.size())
		{
			//需要扩容
			vector<Node*> newtables;
			newtables.resize(2 * _tables.size());

			for (size_t i = 0; i < _tables.size(); i++)
			{
				Node* cur = _tables[i];
				while (cur)
				{
					Node* next = cur->_next;
					size_t hashi = cur->_kv.first % newtables.size();
					cur->_next = newtables[hashi];
					newtables[hashi] = cur;

					cur = next;
				}
				_tables[i] = nullptr;
			}

			_tables.swap(newtables);
		}

		size_t hashi = hs(kv.first) % _tables.size();
		Node* cur = new Node(kv);
		cur->_next = _tables[hashi];
		_tables[hashi] = cur;
		_n++;
		return true;
	}

	Node* Find(const K& k)
	{
		size_t hashi = hs(k) % _tables.size();
		Node* cur = _tables[hashi];
		while (cur)
		{
			if (cur->_kv.first == k)
			{
				return cur;
			}

			cur = cur->_next;
		}

		return nullptr;
	}

	bool Erase(const K& k)
	{
		size_t hashi = hs(k) % _tables.size();

		Node* cur = _tables[hashi];
		Node* prev = nullptr;
		while (cur)
		{
			if (cur->_kv.first == k)
			{
				if (prev==nullptr)
				{
					_tables[hashi] = cur->_next;
				}
				else
				{
					prev->_next = cur->_next;
				}
				delete cur;
				return true;
			}
			cur = cur->_next;
		}

		return false;
	}
private:
	vector<Node*> _tables;
	size_t _n;
	Hash hs;
	};

开散列与闭散列比较

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

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

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

相关文章

微服务项目部署

启动rabbitmq \RabbitMQ\rabbitmq_server-3.8.2\sbin 找到你的安装路径 找到\sbin路径下执行这些命令即可 rabbitmqctl status //查看当前状态 rabbitmq-plugins enable rabbitmq_management //开启Web插件 rabbitmq-server start //启动服务 rabbitmq-server stop //停止服务…

不需要联网的ocr项目

地址 GitHub - plantree/ocr-pwa: A simple PWA for OCR, based on Tesseract. 协议 mit 界面 推荐理由 可以离线使用&#xff0c;隐私安全

python自动化测试实战 —— 自动化测试框架的实例

软件测试专栏 感兴趣可看&#xff1a;软件测试专栏 自动化测试学习部分源码 python自动化测试相关知识&#xff1a; 【如何学习Python自动化测试】—— 自动化测试环境搭建 【如何学习python自动化测试】—— 浏览器驱动的安装 以及 如何更…

【XR806开发板试用】基于FreeRTOS的SoftAp配网实现

1.环境搭建 由于电脑上之前就有开发其他设备用的ubuntu18.06虚拟机环境&#xff0c;就在此环境基础上进行开发。基本环境搭建参考官方文档进行&#xff1a; 全志XR806开发板开发环境搭建 2.功能实现 2.1设计思路 从官方下载的SDK开发包project/example目录下有基本功能实现…

扫盲运动—字节序

1 大端、小端字节序 术语“大端”和“小端”表示多个字节值的哪一端&#xff08;小端或大端&#xff09;存储在该值的起始地址。 大端&#xff1a;将高序字节存储在起始地址&#xff0c;这称为大端&#xff08;big-endian&#xff09;字节序小端&#xff1a;将低序字节存储在…

03-详解Nacos注册中心的配置步骤和功能

Nacos注册中心 服务注册到Nacos Nacos是SpringCloudAlibaba的组件也遵循SpringCloud中定义的服务注册和服务发现规范,因此使用Nacos与使用Eureka对于微服务来说并没有太大区别 主要差异就是依赖不同,服务地址不同 第一步: 在父工程cloud-demo模块的pom.xml文件中引入Spring…

现代信号处理实验:MATLAB实现LD算法进行AR估计

MATLAB实现LD算法进行AR估计 利用给定的一组样本数据估计一个平稳随机信号的功率谱密度称为功率谱估计&#xff0c;又称谱估计。谱估计的方法可以分成经典谱估计和现代谱估计。 经典谱估计又称为非参数化的谱估计&#xff0c;分为直接法和间接法。直接法是指直接计算样本数据…

C# WPF上位机开发(增强版绘图软件)

【 声明&#xff1a;版权所有&#xff0c;欢迎转载&#xff0c;请勿用于商业用途。 联系信箱&#xff1a;feixiaoxing 163.com】 前面我们写过一个绘图软件&#xff0c;不过那个比较简单&#xff0c;主要就是用鼠标模拟pen进行绘图。实际应用中&#xff0c;另外一种使用比较多的…

MySQL笔记-第18章_MySQL8其它新特性

视频链接&#xff1a;【MySQL数据库入门到大牛&#xff0c;mysql安装到优化&#xff0c;百科全书级&#xff0c;全网天花板】 文章目录 第18章_MySQL8其它新特性1. MySQL8新特性概述1.1 MySQL8.0 新增特性1.2 MySQL8.0移除的旧特性 2. 新特性1&#xff1a;窗口函数2.1 使用窗口…

最新鸿蒙HarmonyOS4.0开发登陆的界面1

下载deveco-studio 说明一下&#xff0c;本人只是学习中&#xff0c;现在只是拿着vue及uniapp的经验在一点一点的折腾&#xff0c;不过现在看来&#xff0c;鸿蒙入门并不是很难。也许是自己没有深入下去。 https://developer.harmonyos.com/cn/develop/deveco-studio#download…

谈谈常用的分布式ID的设计方案?

典型回答 首先&#xff0c;我们需要明确通常的分布式ID定义&#xff0c;基本的要求包括&#xff1a; 全局唯一&#xff0c;区别于单点系统的唯一&#xff0c;全局是要求分布式系统内唯一。 有序性&#xff0c;通常都需要保证生成的ID是有序递增的。例如&#xff0c;在数据库存…

循环神经网络-RNN记忆能力实验 [HBU]

目录 一、循环神经网络 二、循环神经网络的记忆能力实验 三、数据集构建 数据集的构建函数 加载数据并进行数据划分 构造Dataset类 四、模型构建 嵌入层 SRN层 五、模型训练 训练指定长度的数字预测模型 多组训练 损失曲线展示 六、模型评价 参考《神经网络与深度…

SpringCloud系列(一)| SpringCloud简介

上个系列中&#xff0c;我们已经介绍完了SpringBoot的用法&#xff0c;简单概述 springBoot Spring X, 就是对于Spring和其他技术的融合 进行了简化开发&#xff0c;所以x可以代表任何技术&#xff0c;比如 mybtis, mybatisPlus, redis.... 对于集成这些常用框架&#xff0c;…

SpringBoot之请求的详细解析

1. 请求 在本章节呢&#xff0c;我们主要讲解&#xff0c;如何接收页面传递过来的请求数据。 1.1 Postman 之前我们课程中有提到当前最为主流的开发模式&#xff1a;前后端分离 在这种模式下&#xff0c;前端技术人员基于"接口文档"&#xff0c;开发前端程序&…

电流测量原理

由于直接测量电流信号是很难的&#xff0c;但是测试电压信号比较容易&#xff0c;因此通常都是先将电流信号转换为电压信号&#xff0c;将电压信号进行调理后送至 CPU&#xff0c;CPU 通过 AD 转换得到一个码值&#xff0c;软件读出该码值&#xff0c;先根据主控的硬件设计参数…

【送书活动】探究AIGC、AGI、GPT和人工智能大模型

文章目录 前言01 《ChatGPT 驱动软件开发》推荐语 02 《ChatGPT原理与实战》推荐语 03 《神经网络与深度学习》推荐语 04 《AIGC重塑教育》推荐语 05 《通用人工智能》推荐语 后记赠书活动 前言 人工智能技术在过去几年中发展迅猛&#xff0c;得益于大数据、云计算、深度学习等…

爬虫 scrapy (十一)

目录 一、scrapy shell 1.什么是scrapy shell&#xff1f; 2.安装 ipython 3.使用scrapy shell 二、当当网案例 1.在items.py中定义数据结构 2.在dang.py中解析数据 3.使用pipeline保存 4.多条管道的使用 5.多页下载 参考 一、scrapy shell 1.什么是scrapy shell&am…

设计模式(2)--对象创建(3)--工厂方法

1. 意图 定义一个用于创建对象的接口&#xff0c;让子类决定实例化哪一个类。 工厂方法使一个类的实例化延迟到其子类。 2. 四种角色 抽象产品、具体产品、抽象构造者、具体构造者 3. 优点 3.1 仅处理抽象产品(Product)接口 3.2 给子类一个钩子(hook)以提供对象的扩展版本(父…

C/C++ 快乐数: 编写一个算法来判断一个数n是不是快乐数

题目&#xff1a; 编写一个算法来判断一个数n是不是快乐数。 快乐数的定义&#xff1a; 对于一个正整数&#xff0c;每一次将该数替换为它每个位置上的数字的平方和。 然后重复这个过程直到这个数变为 1&#xff0c;也可能是 无限循环 但始终变不到 1。 如果这个过…

面试必备的Linux常用命令

「作者主页」&#xff1a;士别三日wyx 「作者简介」&#xff1a;CSDN top100、阿里云博客专家、华为云享专家、网络安全领域优质创作者 「推荐专栏」&#xff1a;对网络安全感兴趣的小伙伴可以关注专栏《网络安全入门到精通》 Linux常用命令 1、文件及内容2、网络3、进程服务4、…