哈希表(如何打造一个工业级的哈希表)

目录

哈希思想

哈希函数

 哈希冲突

1.开放寻址法

2、链表法

解决装载因子过大的问题

选择合适的哈希冲突解决方法


哈希思想


哈希表(hashtable)是数组的一一种扩展,由数组演化而来,底层依赖数组支持按下标快速
访问元素的特性。换句话说,如果没有数组, 就没有哈希表。我们举例来解释一下。

假如有69名选手参加学校运动会。为了方便记录成绩,每个选手胸前都会贴上自己的参赛号码。这69名选手依次编号为1~ 69。现在我们希望编程实现这样一个功能: 通过编号快速找到对应的选手信息。如何实现这个功能呢?
我们可以把这69名选手的信息放在数组里。对于编号为1的选手的信息,放到数组中下标为1的位置;对于编号为2的选手的信息,放到数组中下标为2的位置,依此类推,对于编号为k的选手的信息,放到数组中下标为k的位置。
参赛编号与数组下标一一对应,当需要查询参赛编号为x的选手信息时,只需要将下标为x的数组元素取出,时间复杂度就是0(1),效率非常高。
实际上,这个例子已经用到了哈希思想。在这个例子里,参赛编号是自然数,并且与数组下标形成一一映射关系, 因此,利用数组按下标访问元素的时间复杂度是0(1)这一特性,我们就可以实现按照编号快速查找选手信息这一功能。
不过,这个例子中蕴含的哈希思想还不够明显,我们把这个例子稍微改造一下。
假设校长提出要求,参赛编号不能这么简单,要加上年级、班级这些详细信息,于是,我们把编号的规则稍微修改一下, 用6位数字来表示,如01120639其中,前两位01表示年级,中间两位12表示班级,最后两位还是原来的编号1~ 69。这个时候,又该如何存储选手信息,才能支持通过编号快速查找选手信息呢?

问题的处理思路与之前的类似。尽管现在不能直接把参赛编号作为数组下标来使用,但我们可以截取参赛编号的后两位作为数组下标。当通过参赛编号查询选手信息时,我们用同样的方法,取参赛编号的后两位作为数组下标,从数组中取这个下标对应的选手信息。
这就是典型的哈希思想。其中,参赛选手的编号称为键(key) 或者关键字(keyword)。 我们用键来标识一个选手。我们把参赛编号转化为数组下标的映射方法称为哈希函数。哈希函数计算得到的值称为哈希值。

 哈希表利用的是数组按下标访间元素的时间复杂度是O(1)这一特性。 我们通过哈希函数把元素的键值映射为下标,然后,将对应的数据存储在数组中对应下标的位置。当按照键值查询元素时,我们使用同样的哈希函数,将键值转化为数组下标,从数组中这个下标对应的位置取数据。

哈希函数

哈希函数在哈希表中起着关键作用。哈希函数首先是一个函数,我们可以把它定义成hash(key),其中,key 表示元素的键值,hash(key) 的值表示经过哈希函数计算得到的哈希值。

在上面的例子中,哈希函数比较简单,代码很容易实现。但如果参赛选手的编号是随机生成的6位数字,又或者是a~ z的字符串,那么又该如何构造哈希函數呢?

下面总结了哈希函数设计时的3个基本要求:

  • 1)哈希函数计算得到的哈希值是一个非负整数;
  • 2)如果key1=key2,那么hash(key1)==hash(key2);
  • 3)如果keyl≠key2,那么hash(key1)≠hash(key2)。

其中,第一个基本要求理解起来应该没有任何问题,因为数组下标是从0开始的,因此,哈希函数生成的哈希值也必须是非负整数。第二个基本要求也很好理解,相同的key经过哈希函数得到的哈希值也应该是相同的。第三个基本要求理解起来可能有难度,这个要求看起来合情合理,但是,在实际情况中,要想找一一个没有冲突(key 不同对应的哈希值也不同)的哈希函数,几乎是不可能的。即便像业界著名的MD5、SHA、CRC等哈希算法,也无法完全避免哈希冲突。而且,因为数组的存储空间有限,所以也会加大哈希冲突的概率。

例如有一个数据集合{11,8,6,14,5,9};

哈希函数hash(key) = key%capacity,capacity为存储元素底层空间的总大小。
若我们将该集合存储在capacity为10的哈希表中,则各元素存储位置对应如下:

用该方法进行存储,在搜索时就只需通过哈希函数判断对应位置是否存放的是待查找元素,而不必进行多次关键码的比较,因此搜索的速度比较快。

按照上述哈希方式,向集合中插入元素44,会出现什么问题?

 哈希冲突

再好的哈希函数也无法避免哈希冲突。那么,究竟该如何解决哈希冲突呢?常用的方法有两类:开放寻址法和链表法。

1.开放寻址法

开放寻址法的核心思想:一旦出现哈希冲突,就通过重新探测新位置的方法来解决冲突。如何重新探测新位置呢?
最简单的探测方法是线性探测法

比如上述情景,现在需要插入元素44,先通过哈希函数计算哈希地址,hashAddr为4, 因此44理论上应该插在下标为4位置,但是该位置已经放了值为4的元素,即发生哈希冲突。

插入:当向哈希表中插入数据时,如果某个数据经过哈希函数计算之后,对应的存储位置已经被占用了,我们就从这个位置开始,在数组中依次往后查找,直到找到空闲位置为止。

 删除:哈希表不仅支持插入、查找操作,还支持删除操作。对于使用线性探测法解决冲突的哈希表,删除操作稍微有些特别,不能单纯地把待删除元素所在位置设置为空(NULL)。在查找数据的时候,一旦通过线性探测法遍历到空闲位置,我们就认定哈希表中不存在这个数据。但是,如果这个空闲位置是后来删除的,就会导致原来的查找算法失效。本来存在的数据有可能被认定为不存在。比如删除元素4,如果直接删除掉,44查找起来可能会受影响。因此线性探测采用标记的伪删除法来删除一个元素。将待删除数据的存储空间特殊标记为“deleted"。当利用线性探测法查找数据的时候,遇到标记为“deleted" 的空间,并不会停下来,而是会继续往下探测。

// 哈希表每个空间给个标记
// EMPTY此位置空, EXIST此位置已经有元素, DELETE元素已经删除
enum State{

        EMPTY,

        EXIST,

        DELETE

     }; 

对于线性探测法,当哈希表中插入的数据越来越多时,空闲位置会越来越少,哈希冲突发生的概率就会越来越大,线性探测的时间就会越来越长。在极端情况下,需要探测整个哈希表才能能找到空闲位置并将数据插入,因此,最坏情况下的时间复杂度为0(n)。同理,在删除数据和查找数据时,也有可能线性探测整个哈希表。

对于开放寻址法,除线性探测法之外,还有另外两种经典的探测方法:二次探测法双重哈希法

二次探测法与线性探测法很像线性探测法的探测步长是1,探测的下标序列是hash(key)+0、hash(key)+1、hash(key+2..... 而二次探测法的探测步长变成了原来的“二次方”,探测的下标序列是hash(key)+0、hash(key)+1^2、 hash(key)+2^2......
双重哈希法使用多个哈希函数: hash1(key)、 hash2(key)、 hash3(key)..... 如果第一个哈希函数计算得到的存储位置已经被占用,再用第二个哈希函数重新计算存储位置,依此类推,直到找到空闲的存储位置为止。

研究表明:当表的长度为质数且表装载因子(装载因子一哈希表中的元素个数 / 哈希表的长度(“槽”的个数))a不超过0.5时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出必须考虑增容。

因此:开放寻址法最大的缺陷就是空间利用率比较低,这也是哈希的缺陷。

2、链表法

链表法是一种更加常用的解决哈希冲突的方法,相比开放寻址法,它要简单得多。在哈希表中,每个“桶”或者“槽”(slot)会对应一个链表,我们把哈希值相同的元素放到相同槽位对应的链表中。
当插入数据的时候,我们只需要通过哈希函数计算出对应的“槽”,然后将数据插入到这个“槽”对应的链表中。插入数据的时间复杂度是O(1)。当要查找、删除数据时,我们同样通过哈希函数计算出对应的“槽",然后,遍历链表查找或删除数据。

对于基于链表法解决冲突的哈希表,查找、删除操作的时间复杂度与链表的长度k成正比,也就是O(k)。 对于哈希比较均匀的哈希函数,从理论上来讲,k=n/m, 其中n表示哈希表中数据的个数,m表示哈希表中““槽” 的个数。当k是一个不大的常量时,我们可以租略地认为,在哈希表中查找、刪除数据的时间复杂度是O(1)。

我们把上面的k称为装载因子。装载因子用公式表示出来就是:装载因子=哈希表中的元素个数/哈希表的长度(“槽”的个数)。装载因子越大,说明链表长度越长,哈希表的性能就会越低。

 

解决装载因子过大的问题

装载因子越大,说明哈希表中的元素越多,空闲位置越少,哈希冲突的概率就会越大,插入、删除和查找数时的性能都会随之降低。如果对于没有频繁插入和删除操作的静态数据集合,因为数据是静态已知的,我们容易根据数据的特点等,设计出很好的、冲突极少的哈希函数。但对于有频繁插入和删除除操作的动态数据集合,因为无法事先预估将要加入的数据个数,因此,无法事先申请一个足够大的哈希表。随着越来越多的数据的加入,装载因子会越来越大。当装载因子大到一-定程度之后,大量的哈希冲突就会导致哈希表性能急剧下降。这个时候该怎么办呢?
对于哈希表,当装载因子过大时,我们也可以进行动态扩容,重新申请一个 更大的哈希表,将原哈希表中的数据搬移到新哈希表。假设每次扩容都重新申请个原哈希表两倍大小的新哈希表。如果原哈希表的装载因子是0.8,经过扩容之后,新哈希表的装载因子就下降为原来的一半,变成了0.4。对数组、栈和队列的扩容,数据搬移操作比较简单。但对哈希表n表示哈希表大小的扩容,数据搬移操作要复杂很多。因为哈希表的大小变了,数据的存储位置也变了,所以,我们需要通过哈希函数重新计算每个数据在新哈希表中的存储位置。在大部分情况下,插入新数据不会触发扩容,因此,插入操作的最好时间复杂度是O(1)。如果装载因子过高,超过事先设置的阈值,那么,在插入新数据时,就会触发扩容,需要重新申请内存空间,重新计算每个数据的哈希值,并且将数据从原哈希表搬移到新哈希表,因此,最坏情况下插入操作的时间复杂度是O(n)。

对于支持动态护容的哈春表,在大部分情况下, 插入数据的速度很快,但是,在特殊情况下,当装教因子已经达到阈值时,插入数据前需要先进行扩容。这个时候,插入数据就会变得非常慢,甚至无法令人接受。
我们用一个极端的例子来解释一下。如果哈希表当前的大小为1GB,当启动扩容时,需要对1GB的数据重新计算哈希值,并且搬移到新的哈希表中。这样的操作听起来就很耗时。如果我们的项目的代码直接服务于用户,对响应时间要求比较高,尽管大部分情况下,插入数据的速度很快,但是,极个别速度非常慢的插入操作,也会让用户“崩溃”。这个时候,这种集中扩容的机制就不合适了。
为了解决集中扩容耗时过多的问题,我们将扩容操作穿插在多次插入操作的过程中,分批完成。当装载因子触达阈值之后,我们只创建新哈希表,但并不将原哈希表中的数据全部搬移到新哈希表中。
当有新数据要插入时,除将新数据插入新哈希表之外,还会从原哈希表中搬移一个数据到新哈希表中。每插入一个新数据到哈希表,我们都重复上面的过程。经过多次插入操作之后,原哈希表中的数据就一点点地被搬移到新哈希表中了。这样我们就将数据的搬移工作分散到了多次数据插入操作中,没有了集中的一次性大量数据搬移操作,所有的插入操作都变得很快。

 通过这个方法,将扩容的代价均摊到多次插入操作中,就避免了扩容耗时过多的问题。基于这种扩容方式,在任何情况下,插入数据的时间复杂度都是O(1)。
不过,在原哈希表中的数据没有完全搬移到新哈希表中之前,原哈希表占用的内存不会被释放,内存占用会比较多,而且,对于查询操作,为了兼容新、旧哈希表中的数据,我们需要同时在新、旧两个哈希表中查找。

选择合适的哈希冲突解决方法

开放寻址法
优点:
对于基于开放寻址法解决冲突的哈希表,数据存储在数组中,可以有效地利用CPU缓存加快查询速度。相对于基于链表法解决冲突的哈希表,基于开放寻址法解决冲突的哈希表不涉及链表和指针,方便序列化。
缺点:
基于开放寻址法解决冲突的哈希表,删除数据的操作会比较麻烦,需要特殊标记删除的数据。而且,在开放寻址法中,所有的数据都存储在一个数组中 比起链表法,发生冲突的概率更高。因此,基于开放寻址法解决冲突的哈希表,装载因子不能太大,必须小于1,而基于链表法解决冲突的哈希表,装载因子可以大于1。这就导致存储相同数量的数据,开放寻址法比链表法需要占用更多的存储空间。
综上所述,当数据最比较小。装载因子小的时候,适合采用开放寻址法。
链表法
基于链表法解决冲突的哈希表,数据存储在链表中。基于开放寻址法解决冲突的哈希表,数据存储在数组中。链表节点可以在用到时再创建,而数组必须事先创建好。因此,链表法对内存的利用率比开放寻址法要高。
链表法比起开放寻址法,对大装载因子的容忍度更高。开放寻址法只适用装载因子小于1的情况。在装载因子接近1时,就会有大量的哈希冲突,导致大量的探测、再哈希等,性能急剧下降。但对于链表法,只要哈希函数计算得到的值比较随机且均匀,即便装载因子变成10,也只是链表的长度变长了一点而已,性能下降并不多。不过,链表中的节点要存储next指针,因此,会消耗额外的内存空间,对于小对象的存储,有可能会让内存的消耗翻倍。而且,链表中的节点在内存中是零散分布的,不是连续的,对CPU缓存不友好,这也对哈希表的性能有一定的影响。
当然,如果我们存储的是大对象,也就是说,对象的大小远远大于一个指针的大小(4B或8B),那么链表中指针的内存消耗就可以忽略了。
实际上,我们可以将链表法中的链表改造为其他更高效的数据结构,如红黑树。这样,即便出现哈希冲突,在极端情况下,所有的数据都哈希到同一个“桶”内,最终哈希表也只是退化成一个红黑树,查询的效率也不会太差,时间复杂度是O(log N)。这样就能有效避免哈希碰撞。

 

哈希表的查询效率并不能笼统地认为是时间复杂度O(1),因为它与哈希函数、装载因子和哈希冲突等都有关系。如果哈希函数设计得不好,或者装载因子过高,都可能导致哈希冲突发生的概率升高,查询效率下降。
在极端情况下,有些恶意的攻击者,还有可能通过精心构造的数据,使得所有的数据经过哈希函数之后,全部哈希到同一个"槽”里。如果我们使用的是基于链表的冲突解决方法,那么这个时候,哈希表就会退化为链表,查询的时间复杂度就从O(1)急剧退化为O(n)。
如果哈希表中有10万个数据,那么退化后的哈希表的查询时间就变成了原来的10万倍。举例来说,如果之前执行100次查询只需要0.1s,那么现在就需要10000s。这样就有可能因为查询操作消耗大量CPU或线程资源,导致系统无法响应其他请求,从而让恶意攻击者达到“拒绝服务”攻击(DoS)的目的。这就是哈希表碰撞攻击的基本原理。

我们在设计哈希表时,哈希表应该具有以下特性:

  • 支持快速查询、插入和删除操作;
  • 内存占用合理,不能浪费过多的内存空间;
  • 性能稳定,在极端情况下,哈希表的性能也不会退化到无法接受的地步。

我们要设置一个合适的哈希函数,要设置合理的装载因子阈值,并且设计动态扩容策略,以及选择合适的哈希冲突解决方法。

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

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

相关文章

HTTP协议详解(二)

目录 1.HTTP 响应详解 1.1认识状态码(status code) 1.2 认识响应报头(header) 1.3 认识响应正文(body) 2.构造 HTTP 请求 2.1 通过form表单构造请求 2.2 通过ajax构造请求 2.3 使用第三方工具构造请求 开始之前我们先复习一下http协议格式 1.HTTP 响应详解 我们先抓包…

ChatGPT中文方式写作-chatgpt中文生成

ChatGPT是一种强大的自然语言处理技术,可以帮助人们进行各种语言任务,包括机器翻译、问答系统、自然语言生成等。在中文辅助写作上,ChatGPT也很有用武之地,下面我们将就如何通过ChatGPT实现中文辅助写作,提高文章质量和…

C语言预处理指令-宏定义、文件包含、条件编译

预处理指令简介 1.C语言在对源程序进行编译之前,会先对一些特殊的预处理指令作解释(比如之前使用的#include文件包含指令),产生一个新的源程序(这个过程称为编译预处理),之后再进行通常的编译 2.为了区分预处理指令和一般的C语句,所有预处理…

计算机科班与培训开发编程的区别在哪里?

科班、培训班、科班培训班的模式都培养了很多编程技术人员进入IT行业,有的成为某个技术领域的专家,有的成为领导层,有的一直在默默无闻的敲代码等待35岁的到来。不管那种方式入行,这些类似的情况都存在,并且未来还会一…

全链路监控:方案概述

问题背景 随着微服务架构的流行,服务按照不同的维度进行拆分,一次请求往往需要涉及到多个服务。互联网应用构建在不同的软件模块集上,这些软件模块,有可能是由不同的团队开发、可能使用不同的编程语言来实现、有可能布在了几千台…

java版工程项目管理系统-功能清单 图文解析

ava版工程项目管理系统 Spring CloudSpring BootMybatisVueElementUI前后端分离 功能清单如下: 首页 工作台:待办工作、消息通知、预警信息,点击可进入相应的列表 项目进度图表:选择(总体或单个)项目显示1…

哈希表(概念,冲突的解决,实现哈希桶)

目录 概念 冲突 如何尽量减少冲突? 负载因子 解决冲突的几种方案 冲突严重时的解决办法 哈希表的实现 基本类型哈希桶实现 泛型哈希桶实现 注意!!! 概念 构造出一种存储结构,通过某种函数使元素的存储位置(下标)与它的关键码之间能够建立一一的映射关系,那么在查找…

让ChatGpt可以看视频,看文档,帮你总结,并提供示例的github项目(附体验地址)

github地址:https://github.com/madawei2699/myGPTReader 演示 Stay updated with the latest news summaries daily with chatGPT. Use chatGPT to read and provide a summary of any webpage include the video(YouTube). 总之这个玩意有很多,可以…

【K8S系列】深入解析StatefulSet(一)

序言 那些看似不起波澜的日复一日,一定会在某一天让你看见坚持的意义。 文章标记颜色说明: 黄色:重要标题红色:用来标记结论绿色:用来标记一级论点蓝色:用来标记二级论点Kubernetes (k8s) 是一个容器编排平…

java毕业生就业信息管理系统servlet程序

1.系统登录:系统登录是用户访问系统的路口,设计了系统登录界面,包括用户名、密码和验证码,然后对登录进来的用户判断身份信息,判断是管理员用户还是普通用户。 2.系统用户管理:不管是…

第13章_约束

第13章_约束 🏠个人主页:shark-Gao 🧑个人简介:大家好,我是shark-Gao,一个想要与大家共同进步的男人😉😉 🎉目前状况:23届毕业生,目前在某公司…

一次压测遇到的问题和排查过程记录

文章目录问题 & 排查1、cpu使用率过高问题描述问题排查解决方案扩展内容2、504 Gateway Time-out问题描述问题排查解决方案3、限流对压测的影响问题描述问题排查解决方案jmeter相关1、beanShell 动态生成签名2、响应断言3、导出结果树请求和响应文件问题 & 排查 1、cp…

域名批量查询功能常用查询方法教程

一些用户在抱怨,要找到好域名怎么就那么不容易呢,能不能让我批量查下不含0的数字啊,能不能查下不含4的数字啊,能不能查下AABBB这样的域名啊…… 别着急,这就给您支招啦:通过西部数码强大的批量查询功能&am…

活动报名|SOFA 五周年,Live Long and Prosper!

2018 年 4 月 19 日,我们在北京启程,伴随种下希望的种子,举办了 SOFAStack 社区的第一个开放日。转眼来到 2023 年,瑞兔送福又逢春暖花开,怀揣着新的愿景,我们将于 4 月 15 日回到北京庆祝 SOFA 的五岁生日…

服务器磁盘又双叒叕爆满了?被/proc占满?

又双叒叕服务器前言排查分析前言 继上一次文章: MISCONF Redis is configured to save RDB snapshots, but it is currently not able to persist on disk. 通过删除tomcat下的catalina.out文件,解决了磁盘爆满的问题后。今天又双叒叕出现这个问题。。…

万字长文解读「新一代CMDB落地的困境及出路」

2023年3月21日春分时节,优维结合在CMDB技术领域的经验沉淀与洞察能力,梳理金融客户在数据运营中面临的问题和挑战,为了帮助到广大客户建立健全有效的方法参考,全新策划了一档“CMDB数据运营精准化专场公开课”线上直播课程。该系列…

2023-Python实现百度翻译接口调用

目录 👉1、目标网址 ​​​​​​​👉2、接口分析调试 ​​​​​​​👉3、python 代码实现 学习记录:百度翻译 ​​​​​​​👉1、目标网址 百度翻译:百度翻译-200种语言互译、沟通全世界&#xff0…

【LeetCode】二叉树的中序遍历(递归,迭代,Morris遍历)

目录 题目要求:给定一个二叉树的根节点 root ,返回 它的 中序 遍历 。 方法一:递归 方法二:迭代 思路分析: 复杂度分析 代码展示: 方法三:Morris 遍历 思路分析: 复杂度分析…

Vue3学习笔记(9.1)

Vue.js style&#xff08;内联样式&#xff09; 我们可以在v-bind:style直接设置样式&#xff0c;可以简写:style <!--* Author: RealRoad1083425287qq.com* Date: 2023-04-02 19:41:53* LastEditors: Mei* LastEditTime: 2023-04-03 15:41:44* FilePath: \vscode\Vue3_li…

打气球游戏-第14届蓝桥杯STEMA测评Scratch真题精选

[导读]&#xff1a;超平老师的《Scratch蓝桥杯真题解析100讲》已经全部完成&#xff0c;后续会不定期解读蓝桥杯真题&#xff0c;这是Scratch蓝桥杯真题解析第118讲。 蓝桥杯选拔赛现已更名为STEMA&#xff0c;即STEM 能力测试&#xff0c;是蓝桥杯大赛组委会与美国普林斯顿多…