C++数据结构——哈希表

前言:本篇文章将继续进行C++数据结构的讲解——哈希表。


目录

一.哈希表概念

二.哈希函数

1.除留取余法

三.哈希冲突

1.闭散列

线性探测

 (1)插入

(2)删除

 2. 开散列

开散列概念

四.闭散列哈希表

1.基本框架 

2.插入

3.寻找

4.删除

5.数据类型问题

五.开散列哈希表

1.基本框架

2.插入

3.寻找

4.删除

总结


一.哈希表概念

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

比如说:要统计一个全是小写英文字母的字符串中各个英文字母出现的个数,该怎么做???

很容易,因为小写英文字母有26个,所以我们直接创建一个大小为26的int数组并将值全部初始化为0让数组的每一位都代表一个小写英文字母该字母每出现一次,就让该怎么对于位置的值++,最终每个位置的值便是对应小写英文字母的个数

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


二.哈希函数

有时候我们可能不能像上边一样有多少种数据就创建多大的哈希表,比如说虽然我们只有1,12,103,1004,10005四个数要统计个数,难道要创建一个10005大小的数组吗??? 


1.除留取余法

实际上只需要创建大小为6的数组即可,此时我们可以使用哈希函数:除留取余法

找到一个合适的除数,能够让上述数字取余之后分别为不同的值,从而拉进各个数字之间的距离,创建更小的哈希表

比如说让上述数字均取余上10,得到的就会是1,2,3,4,5,刚好对应数组各个位置


三.哈希冲突

虽然上述函数可以解决大部分问题,但不妨有些时候会出现像1,11,111,1111这样的数字,它们%10之后得到的数字均为1,这样就会导致不同的值映射到相同的位置,从而导致哈希冲突

 那么为了解决哈希冲突,有两种常用的方法:闭散列和开散列


1.闭散列

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


线性探测

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

 (1)插入

通过哈希函数获取待插入元素在哈希表中的位置

如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素

(2)删除

采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素若直接删除元素会影响其他元素的搜索。比如删除元素4,如果直接删除掉,44查找起来可能会受影响。因此线性探测采用标记的伪删除法来删除一个元素。

哈希表的每个空间都给上一个标记
EMPTY此位置空, EXIST此位置已经有元素, DELETE元素已经删除,通过枚举实现:
enum State{EMPTY, EXIST, DELETE};  


 2. 开散列

开散列概念

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

从上图可以看出,开散列中每个桶中放的都是发生哈希冲突的元素。 由于其形状很像一个桶,所以开散列哈希表又叫哈希桶


四.闭散列哈希表

1.基本框架 

 下面来看代码,我们先给出哈希表的基本框架:

namespace close_address
{
    enum State
    {
    	EMPTY,
    	EXIST,
    	DELETE
    };
    template<class K,class V>
    struct HashData
    {
    	pair<K, V> _kv;
    	State _state = EMPTY;
    };
    
    template<class K, class V>
    class HashTable
    {
    public:
    
    private:
    	vector<HashData<K, V>> _tables;
    	size_t _n = 0;
    };
}

这里的_n用来记录哈希表中数据的个数。 


2.插入

	bool Insert(const pair<K, V> kv)
	{
		if (Find(kv.first))
			return false;
		size_t hashi = kv.first % _tables.size();
		//线性探测
		while (_tables[hashi]._state == EXIST)
		{
			++hashi;
			hashi %= _tables.size();
		}
		_tables[hashi]._kv = kv;
		_tables[hashi]._state = EXIST;
		++_n;
        return true;
	}

插入步骤还是容易实现的,值得注意的是,哈希表也不允许相同数据存在,所以需要提前判断,这里直接调用后边的Find()函数来判断

除此之外还有一个非常关键的点在于——扩容

 因为虽然我们的哈希表是用vector作为底层,但是实际上填入数据时并一定是挨着存放的,所以我们需要在插入之前,提前创造空间

 那么哈希表也要等数据存满的时候才扩容吗???并不是。

对于散列表,存在一个荷载因子的定义:

α = 填入表中的元素 / 散列表的长度

当表中的元素足够多但并未满时,此时如果继续插入数据,发生哈希冲突的可能性就会极大,导致插入时间变长。所以这里我们规定,当荷载因子的值 >= 0.7时就进行扩容

	HashTable()
	{
		_tables.resize(10);
	}

在扩容之前,应该给表一个初始的大小,这里通过构造函数使哈希表的初始长度为10

		//扩容
		if (_n * 10 / _tables.size() >= 7)
		{
			HashTable<K, V> newHT;
			newHT.tables.resize(_tables.size() * 2);
			for (size_t i = 0; i < _tables.size(); i++)
			{
				if (_tables[i]._state == EXIST)
				{
					newHT.Insert(_tables[i]._kv);
				}
			}
			_tables.swap(newHT._tables);
		}

随后进行扩容判断,这里有一个细节, 因为荷载因子是一个浮点数,如果直接进行比较,我们还需进行类型转换,所以我们直接让哈希表数据个数乘10再来进行计算。

判断成立则进行扩容,注意,哈希表的扩容并非像顺序表那样进行复制粘贴,因为哈希表扩容,代表着哈希函数的分母发生变化,所以其对应的取余后的下标位置也会发生变化

这里我们通过新建一个二倍大小的哈希表遍历原表数据并在新表中调用插入函数进行插入,最后在通过交换即可。


3.寻找

	//寻找
	HashData<K, V>* Find(const K& key)
	{
		size_t hashi = key % _tables.size();
		//线性探测
		while (_tables[hashi]._state == EXIST &&_tables[hashi]._state != EMPTY)
		{
			if (_tables[hashi]._kv.first == key)
			{
				return &_tables[hashi];
			}
			++hashi;
			hashi %= _tables.size();
		}
		return nullptr;
	}

寻找就比较简单了, 通过要寻找的值计算出其在哈希表中的下标判断是否存在且是否为空存在且不为空则进行判断,相等返回如果不相等,因为可能存在哈希冲突,所以循环往后遍历,直至遇到空位置仍然没有找到,说明其不在哈希表中,返回nullptr

这里有一个细节,返回值为哈希表中对应位置的地址,这是为后边的删除做伏笔。


4.删除

	//删除
	bool Erase(const K& key)
	{
		HashData<K, V>* ret = Find(key);
		if (ret == nullptr)
		{
			return false;
		}
		else
		{
			ret->_state = DELETE;
			--_n;
			return true;
		}
	}

删除我们直接借用Find函数去找到对应位置,无需在通过哈希函数去寻找。

如果要删除的元素不存在,则返回失败,反之,将其位置的标记改为DELETE,即伪删除

下面来理解一下伪删除

因为我们在寻找和插入时的判断条件均为标记位EMPTY,所以删除时只需将该位置的标记改为DELETE,这样就不会影响该位置对应的冲突位的寻找以及新的插入了


5.数据类型问题

我们上述的哈希表实现能够采用哈希函数进行取余操作的前提是数据为int类型,那如果是其他类型的数据,不能进行取余操作,又该如何使用哈希函数呢???

这里的方法是,通过建立仿函数将其他类型转换成int类型

struct HashFunc
{
	size_t operator()(const K& key)
	{
		return (size_t)key;
	}
};

 此外,有一个特例,string类型不能直接转换为int类型,所以想要满足string类型,我们需要为其单独创造一个仿函数

struct HashStringFunc
{
	size_t operator()(const string& s)
	{
		size_t hash = 0;
		for (auto e : s)
		{
			hash += e;
		}
		return hash;
	}
};

思想为,将string类型的每个字符的ascll码值加起来作为其int类型的数据

随后需要在模板中进行添加:

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

这里采用了缺省参数默认情况下为其他类型,当为string类型时,在传入独有的模版。 


五.开散列哈希表

1.基本框架

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 HashTable
	{
		typedef HashNode<K, V> Node;
	public:
		HashTable()
		{
			_tables.resize(10, nullptr);
			_n = 0;
		}
	private:
		vector<Node*> _tables;//指针数组
		size_t _n;
	};
}

不同于闭散列的哈希表vector中存放的是数据本身,哈希桶中vector存放的为节点指针


2.插入

因为哈希桶可能会在一个位置下面插入很多的数据如果采用尾插,就必须找到尾结点才能进行插入,效率会很低,所以我们采用头插的方式:

		//插入
		bool Insert(const pair<K, V>& kv)
		{
            //判断是否存在
			if (Find(kv.first))
				return false;
			//扩容
            //......
			size_t hashi = kv.first % _tables.size();
			Node* newnode = new Node(kv);
			//头插
			newnode->_next = _tables[hashi];
			_tables[hashi] = newnode;
			++_n;
		}

创造一个新节点,让新节点去指向原来的头结点,再让新节点成为头结点

下面我们来关注扩容问题,虽然哈希桶是通过链表的方式进行插入,原则上不用进行扩容就可以满足所有数据的存放。但是如果数据过大,会导致每个桶中的数据量过于庞大导致寻找操作的效率大大降低。所以规定,当数据个数等于哈希表的大小,即荷载因子α为1时,进行扩容

那么哈希桶的扩容又该如何进行呢???

因为是节点的缘故,如果像闭散列那样复用插入去扩容,那样就等于同一个节点又创建了一份,而原节点最后还需要进行销毁,这样未免过于麻烦和浪费时间。

所以更好一点的做法是,新建立一个哈希桶,遍历原桶的节点,让其按照哈希函数去直接指向新桶,最后再将两个桶进行交换

			//扩容
			if (_n == _tables.size())
			{
				vector<Node*> newtables; (_tables.size() * 2, nullptr);
				for (size_t i = 0; i < _tables.size(); i++)
				{
					Node* cur = _tables[i];
					while (cur)
					{
						Node* next = cur->_next;
						size_t hashi = kv.first % newtables.size();
						cur->_next = newtables[hashi];
						newtables[hashi] = cur;
						cur = next;
					}
					_tables[i] = nullpter;
				}
				_tables.swap(newtables);
			}

3.寻找

		//寻找
		Node* Find(const K& key)
		{
			size_t hashi = key % _tables.size();
			Node* cur = _tables[hashi];
			while (cur)
			{
				if (cur->_kv.first == key)
					return cur;
				cur = cur->_next;
			}
			return nullptr;
		}

寻找操作较为简单,就不在过多分享,注意返回值为节点类型


4.删除

链表的删除无非就三种情况,删除的是头结点,或者是中间节点,尾结点。其中尾结点可以和中间节点共用一种方式

那么如果删除中间节点,就必须提前记录该节点的前一个节点

此外就是头结点,提前定义一个prev,并置空如果cur不为头结点,就让prev继承cur,cur在往后,所以如果首次循环prev就为空,说明要删除的节点即为头结点。 

		bool Erase(const K& key)
		{
			size_t hashi = key % _tables.size();
			Node* cur = _tables[hashi];
			Node* prev = nullptr;
			while (cur)
			{
				if (cur->_kv.first == key)
				{
					//删除的是第一个节点
					if (prev == nullptr)
					{
						_tables[hashi] = cur->_next;
					}
					else
					{
						prev->_next = cur->_next;
					}
					delete cur;
				}
				else
				{
					prev = cur;
					cur = cur->_next;
				}
			}
			return false;
		}

最后我们仍需使用仿函数来解决数据类型的问题,因方法与闭散列完全相同,这里不再重复


总结

关于哈希表就分享这么多,喜欢本篇文章记得一键三连,我们下期再见!

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

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

相关文章

一.ffmpeg 将内存中的H264跟PCM 数据流合成多媒体文件

在有一些嵌入式平台中&#xff0c;H264数据流一般来自芯片内部的硬编码器&#xff0c; AAC音频数据则是通过采集PCM进行软编码&#xff0c;但是如何对它实时进行封装多媒体文件 &#xff0c;参考ffmpeg example&#xff0c;花了一些时间终于实现了该功能。 流程图如下&#xf…

es问题汇总--待完善

1. 查询某个索引库中数据总量 方式一&#xff1a; CountRequest 鄙人喜欢这种方式 public long getTotalNum(String indexName) throws IOException {CountRequest countRequest new CountRequest(indexName);// 如果需要&#xff0c;你可以在这里添加查询条件// countReques…

内脏油脂是什么?如何减掉?

真想减的人&#xff0c;减胖是很容易的&#xff0c;但想要形体美又健康&#xff0c;还是得从减内脏油脂开始&#xff0c;那么&#xff0c;问题来了&#xff0c;什么是内脏油脂&#xff1f; 油脂它分部于身体的各个角落&#xff0c;四肢、腹部、腰、臀部、脸、脖子...等&#xf…

暴雨信息液冷计算解决方案亮相CCIG 2024

5月24日&#xff0c;2024中国图象图形大会&#xff08;CCIG&#xff09;在陕西西安正式开幕。作为涵盖图像图形各专业领域的综合性的全国性学术会议&#xff0c;CCIG面向开放创新、交叉融合的发展趋势&#xff0c;为图像图形相关领域的专家学者和产业界的同仁&#xff0c;搭建了…

深入用户内心:设计师如何通过可用性测试洞察用户需求

可用性测试是指让用户体验产品的原型或成品。设计师通过观察和分析用户的使用行为和感受&#xff0c;进一步合理地改进产品的设计方法。你可能会想知道我们可以用什么方法来测试可用性&#xff1f;随着互联网行业的快速迭代更新&#xff0c;可用性测试衍生出了许多类型和方法。…

【C++初阶】—— 类和对象 (下)

&#x1f4dd;个人主页&#x1f339;&#xff1a;EterNity_TiMe_ ⏩收录专栏⏪&#xff1a;C “ 登神长阶 ” &#x1f339;&#x1f339;期待您的关注 &#x1f339;&#x1f339; 类和对象 1. 运算符重载运算符重载赋值运算符重载前置和后置重载 2. 成员函数的补充3. 初始化列…

报错:找不到或无法加载主类 com.example.SpringbootApplication(idea)

OpenJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended 错误: 找不到或无法加载主类 com.example.SpringbootApplication 原因: java.lang.NoClassDefFoundError: com/example/SpringBootAppli…

C++---运算符重载

运算符重载介绍 在类中重新定义运算符&#xff0c;赋予运算符新的功能以适应类的运算&#xff0c;就称为运算符重载。 运算符重载是一种形式的C多态,它使得对象操作更直观,本质上也是属于函数重载。 实际上&#xff0c;我们已经在不知不觉之中使用了运算符重载。例如&#xff…

【JAVA |再谈接口、Object、内部类】Object类中子类重写,Cloneable 接口、比较器、内部类

✨✨谢谢大家捧场&#xff0c;祝屏幕前的小伙伴们每天都有好运相伴左右&#xff0c;一定要天天开心哦&#xff01;✨✨ &#x1f388;&#x1f388;作者主页&#xff1a; &#x1f388;丠丠64-CSDN博客&#x1f388; ✨✨ 帅哥美女们&#xff0c;我们共同加油&#xff01;一起…

python写页面自动截图

from selenium import webdriver def take_screenshot(url, file_path):driver webdriver.Chrome()driver.get(url)driver.save_screenshot(file_path)driver.quit() if __name__ __main__:take_screenshot(http://baidu.com, D:\桌面\wang.png)要安装selenium还要安装google…

React类组件生命周期详解

在React的类组件中&#xff0c;从组件创建到组件被挂载到页面中&#xff0c;这个过程react存在一系列的生命周期函数&#xff0c;最主要的生命周期函数是componentDidMount、componentDidUpdate、componentWillUnmount 生命周期图例如下 1. componentDidMount组件挂载 如果你…

网络安全资源和参考指南

由美国国防部&#xff08;DoD&#xff09;发布的《网络安全资源和参考指南》&#xff0c;旨在为美国政府、商业部门以及美国盟友和伙伴之间的安全合作提供有用的、现成的参考资料。文档涵盖了网络安全规范、最佳实践、政策和标准&#xff0c;这些都是由美国联邦政府、国防部以及…

KubeSphere 社区双周报|2024.05.09-05.23

KubeSphere 社区双周报主要整理展示新增的贡献者名单和证书、新增的讲师证书以及两周内提交过 commit 的贡献者&#xff0c;并对近期重要的 PR 进行解析&#xff0c;同时还包含了线上/线下活动和布道推广等一系列社区动态。 本次双周报涵盖时间为&#xff1a;2024.05.09-05.23…

CCF20230301——田地丈量

CCF20230301——田地丈量 代码如下&#xff1a; #include<bits/stdc.h> using namespace std; int main() {int n,a,b;cin>>n>>a>>b;int x1,x2,y1,y2,x,y,sum0;for(int i0;i<n;i){cin>>x1>>y1>>x2>>y2;xmin(x2,a)-max(x1,…

【科普知识】伺服电机中的内置制动器

在工业自动化和机器人技术快速发展的今天&#xff0c;伺服电机作为核心驱动元件&#xff0c;其性能与功能直接影响整个系统的运行效率与稳定性。 近年来&#xff0c;一体化伺服电机技术不断融合创新&#xff0c;并逐步加入了许多新的硬件和软件的功能&#xff0c;为工业自动化领…

SpringBoot+Vue开发记录(五)-- 数据库设计

我去&#xff0c;时隔这么久又开始了QAQ。主要是还是自己太懒了。 本篇文章的主要内容是数据库设计。 先简单创建个数据库&#xff1a; 这是创建好了的&#xff1a; 一、数据库设计 先就做一个很简单的设计&#xff0c;里面就只有用户和题。 大概就这样&#xff1a; 二、创…

GQL 来了!ISO/IEC 正式发布 GQL 数据库国际标准!

历时四年筹备&#xff0c;超过20个国家的标准和技术专家参与制定&#xff0c;ISO/IEC GQL &#xff08;图查询语言&#xff09;标准于2024年4月12日正式发布&#xff01; 作为国际标准化组织&#xff08;ISO&#xff09;继 1987年 发布SQL后&#xff0c;唯一发布的数据库查询语…

express路由的介绍与使用

一、什么是路由&#xff1f; 官方&#xff1a;路由确定了应用程序如何响应客户端对特定端点的请求 通俗来说&#xff1a;在Web开发中&#xff0c;路由是指根据不同的请求路径和请求方法&#xff0c;将请求分发到相应的处理函数、模块或中间件。简单来说&#xff0c;就是URL到…

【运维心得】双WAN配置的一个误区

目录 双WAN配置及优势 实际案例 解决之道 最后总结 双WAN配置及优势 什么是双WAN配置&#xff0c;这里就不多赘述&#xff0c;简单的说&#xff0c;首先你要有一台支持双WAN口的路由器&#xff0c;目前大多数企业级路由器都具备了这个功能。甚至有些家用路由器也有此类功能…

揭秘:水滴式粉碎机为何如此受欢迎

在粉碎机市场中&#xff0c;水滴式粉碎机以其D特的设计和G效的性能脱颖而出&#xff0c;成为众多用户的选择产品。那么&#xff0c;水滴式粉碎机究竟有何魅力&#xff0c;能够赢得如此广泛的赞誉呢&#xff1f; 首先&#xff0c;水滴式粉碎机的G效性能是其受欢迎的关键因素之一…