万字长文,必须看懂的哈希表总结

以前给你们介绍了链表栈和队列今天咱们来讲一种新的数据结构散列(哈希)表,散列是应用很是普遍的数据结构,在咱们的刷题过程当中,散列表的出场率特别高。因此咱们快来一块儿把散列表的内些事给整明白吧。文章框架以下程序员

脑图

说散列表以前,咱们先设想如下场景。算法

袁厨穿越回了古代,凭借从现代学习的作饭手艺,开了一个袁记菜馆,正值开业初期,店里生意十分火爆,可是顾客结帐时就犯难了,每当结帐的时候,老板娘老是按照菜单一个一个找价格(遍历查找),每次都要找半天,因此结帐的地方老是排起长队,顾客们表示用户体验不咋滴。袁厨一想这不是办法啊,让顾客总是等着,太影响客户体验啦。因此袁厨就先把菜单按照首字母排序(二分查找),而后查找的时候根据首字母查找,这样结帐的时候就能大大提升检索效率啦!可是呢?工做日顾客很少,老板娘彻底应付的过来,可是每逢节假日,仍是会排起长队。那么有没有什么更好的办法呢?对呀!咱们把全部的价格都背下来不就能够了吗?每一个菜的价格咱们都了如指掌,结帐的时候咱们只需简单相加便可。因此袁厨和老板娘加班加点的进行背诵。下次再结帐的时候一说吃了什么菜,咱们立马就知道价格啦。自此之后收银台再也没有出现过长队啦,袁记菜馆开着开着一不当心就成了天下第一饭店了。数组

下面咱们来看一下袁记菜馆老板娘进化史。微信

image-20201117132633797

上面的后期结帐的过程则模拟了咱们的散列表查找,那么在计算机中是如何使用进行查找的呢?数据结构

散列表查找步骤

散列表-------最有用的基本数据结构之一。是根据关键码的值儿直接进行访问的数据结构,散列表的实现经常叫作散列(hasing)。散列是一种用于以常数平均时间执行插入、删除和查找的技术,下面咱们来看一下散列过程。框架

咱们的整个散列过程主要分为两步dom

(1)经过散列函数计算记录的散列地址,并按此散列地址存储该记录。就比如麻辣鱼咱们就让它在川菜区,糖醋鱼,咱们就让它在鲁菜区。可是咱们须要注意的是,不管什么记录咱们都须要用同一个散列函数计算地址,再存储。函数

(2)当咱们查找时,咱们经过一样的散列函数计算记录的散列地址,按此散列地址访问该记录。由于咱们存和取得时候用的都是一个散列函数,所以结果确定相同。性能

刚才咱们在散列过程当中提到了散列函数,那么散列函数是什么呢?学习

咱们假设某个函数为 f,使得

存储位置 = f (关键字)

输入:关键字 输出:存储位置(散列地址)

那样咱们就能经过查找关键字不须要比较就可得到须要的记录的存储位置。这种存储技术被称为散列技术。散列技术是在经过记录的存储位置和它的关键字之间创建一个肯定的对应关系 f ,使得每一个关键字 key 都对应一个存储位置 f(key)。见下图

image-20201117145348616

这里的 f 就是咱们所说的散列函数(哈希)函数。咱们利用散列技术将记录存储在一块连续的存储空间中,这块连续存储空间就是咱们本文的主人公------散列(哈希)表

上图为咱们描述了用散列函数将关键字映射到散列表,可是你们有没有考虑到这种状况,那就是将关键字映射到同一个槽中的状况,即 f(k4) = f(k3) 时。这种状况咱们将其称之为冲突k3k4则被称之为散列函数 f同义词,若是产生这种状况,则会让咱们查找错误。幸运的是咱们能找到有效的方法解决冲突。

首先咱们能够对哈希函数下手,咱们能够精心设计哈希函数,让其尽量少的产生冲突,因此咱们建立哈希函数时应遵循如下规则

(1)必须是一致的,假设你输入辣子鸡丁时获得的是在看,那么每次输入辣子鸡丁时,获得的也必须为在看。若是不是这样,散列表将毫无用处。

(2)计算简单,假设咱们设计了一个算法,能够保证全部关键字都不会冲突,可是这个算法计算复杂,会耗费不少时间,这样的话就大大下降了查找效率,反而得不偿失。因此我们散列函数的计算时间不该该超过其余查找技术与关键字的比较时间,否则的话咱们干吗不使用其余查找技术呢?

(3)散列地址分布均匀咱们刚才说了冲突的带来的问题,因此咱们最好的办法就是让散列地址尽可能均匀分布在存储空间中,这样即保证空间的有效利用,又减小了处理冲突而消耗的时间。

如今咱们已经对散列表,散列函数等知识有所了解啦,那么咱们来看几种经常使用的散列函数构造规则。这些方法的共同点为都是将原来的数字按某种规律变成了另外一个数字。

散列函数构造方法

直接定址法

若是咱们对盈利为0-9的菜品设计哈希表,咱们则直接能够根据做为地址,则 f(key) = key;

即下面这种状况。

直接定址法

有没有感受上面的图很熟悉,没错咱们常常用的数组其实就是一张哈希表,关键码就是数组的索引下标,而后咱们经过下标直接访问数组中的元素。

另外咱们假设每道菜的成本为50块,那咱们还能够根据盈利+成原本做为地址,那么则 f(key) = key + 50。也就是说咱们能够根据线性函数值做为散列地址。

f(key) = a * key + b a,b均为常数

优势:简单、均匀、无冲突。

应用场景:须要事先知道关键字的分布状况,适合查找表较小且连续的状况

数字分析法

该方法也是十分简单的方法,就是分析咱们的关键字,取其中一段,或对其位移,叠加,用做地址。好比咱们的学号,前 6 位都是同样的,可是后面 3 位都不相同,咱们则能够用学号做为键,后面的 3 位作为咱们的散列地址。若是咱们这样仍是容易产生冲突,则能够对抽取数字再进行处理。咱们的目的只有一个,提供一个散列函数将关键字合理的分配到散列表的各位置。这里咱们提到了一种新的方式,抽取,这也是在散列函数中常常用到的手段。

image-20201117161754010

优势:简单、均匀、适用于关键字位数较大的状况

应用场景:关键字位数较大,知道关键字分布状况且关键字的若干位较均匀

折叠法

其实这个方法也很简单,也是处理咱们的关键字而后用做咱们的散列地址,主要思路是将关键字从左到右分割成位数相等的几部分,而后叠加求和,并按散列表表长,取后几位做为散列地址。

好比咱们的关键字是123456789,则咱们分为三部分 123 ,456 ,789 而后将其相加得 1368 而后咱们再取其后三位 368 做为咱们的散列地址。

优势:事先不须要知道关键字状况

应用场景:适合关键字位数较多的状况

除法散列法

在用来设计散列函数的除法散列法中,经过取 key 除以 p 的余数,将关键字映射到 p 个槽中的某一个上,对于散列表长度为 m 的散列函数公式为

f(k) = k mod p (p <= m)

例如,若是散列表长度为 12,即 m = 12 ,咱们的参数 p 也设为12,那 k = 100时 f(k) = 100 % 12 = 4

因为只须要作一次除法操做,因此除法散列法是很是快的。

由上面的公式能够看出,该方法的重点在于 p 的取值,若是 p 值选的很差,就可能会容易产生同义词。见下面这种状况。咱们哈希表长度为6,咱们选择6为p值,则有可能产生这种状况,全部关键字都获得了0这个地址数。image-20201117191635083

那咱们在选用除法散列法时选取 p 值时应该遵循怎样的规则呢?

  • m 不该为 2 的幂,由于若是 m = 2^p ,则 f(k) 就是 k 的 p 个最低位数字。例 12 % 8 = 4 ,12的二进制表示位1100,后三位为100。
  • 若散列表长为 m ,一般 p 为 小于或等于表长(最好接近m)的最小质数或不包含小于 20 质因子的合数。

合数:合数是指在大于1的整数中除了能被1和自己整除外,还能被其余数(0除外)整除的数。

质因子:质因子(或质因数)在数论里是指能整除给定正整数的质数。

质因子

这里的2,3,5为质因子

仍是上面的例子,咱们根据规则选择 5 为 p 值,咱们再来看。这时咱们发现只有 6 和 36 冲突,相对来讲就行了不少。

image-20201117192738889

优势:计算效率高,灵活

应用场景:不知道关键字分布状况

乘法散列法

构造散列函数的乘法散列法主要包含两个步骤

  • 用关键字 k 乘上常数 A(0 < A < 1),并提取 k A 的小数部分
  • 用 m 乘以这个值,再向下取整

散列函数为

f (k) = ⌊ m(kA mod 1) ⌋

这里的 kA mod 1 的含义是取 keyA 的小数部分,即 kA - ⌊kA⌋

优势:对 m 的选择不是特别关键,通常选择它为 2 的某个幂次(m = 2 ^ p ,p为某个整数)

应用场景:不知道关键字状况

平方取中法

这个方法就比较简单了,假设关键字是 321,那么他的平方就是 103041,再抽取中间的 3 位就是 030 或 304 用做散列地址。再好比关键字是 1234 那么它的平方就是 1522756 ,抽取中间 3 位就是 227 用做散列地址.

优势:灵活,适用范围普遍

适用场景:不知道关键字分布,而位数又不是很大的状况。

随机数法

故名思意,取关键字的随机函数值为它的散列地址。也就是 f(key) = random(key)。这里的random是 随机函数。

优势:易实现

适用场景:关键字的长度不等时

上面咱们的例子都是经过数字进行举例,那么若是是字符串可不能够做为键呢?固然也是能够的,各类各样的符号咱们均可以转换成某种数字来对待,好比咱们常常接触的ASCII 码,因此是一样适用的。

以上就是经常使用的散列函数构造方法,其实他们的中心思想是一致的,将关键字通过加工处理以后变成另一个数字,而这个数字就是咱们的存储位置,是否是有一种间谍传递情报的感受。

一个好的哈希函数能够帮助咱们尽量少的产生冲突,可是也不能彻底避免产生冲突,那么遇到冲突时应该怎么作呢?下面给你们带来几种经常使用的处理散列冲突的方法。

处理散列冲突的方法

咱们在使用 hash 函数以后发现关键字 key1 不等于 key2 ,可是 f(key1) = f(key2),即有冲突,那么该怎么办呢?不急咱们慢慢往下看。

开放地址法

了解开放地址法以前咱们先设想如下场景。

袁记菜馆内,铃铃铃,铃铃铃 电话铃响了

大鹏:老袁,给我订个包间,我今天要去带几个客户去你那谈生意。

袁厨:大鹏啊,你经常使用的那个包间被人订走啦。

大鹏:老袁你这不仗义呀,咋没给我留住呀,那你给我找个空房间吧。

袁厨:好滴老哥

哦,穿越回古代就没有电话啦,那看来穿越的时候得带着几个手机了。

上面的场景其实就是一种处理冲突的方法-----开放地址法

开放地址法就是一旦发生冲突,就去寻找下一个空的散列地址,只要列表足够大,空的散列地址总能找到,并将记录存入,为了使用开放寻址法插入一个元素,须要连续地检查散列表,或称为探查,咱们经常使用的有线性探测,二次探测,随机探测

线性探测法

下面咱们先来看一下线性探测,公式:

f,(key) = ( f(key) + di ) MOD m(di = 1,2,3,4,5,6....m-1)

咱们来看一个例子,咱们的关键字集合为{12,67,56,16,25,37,22,29,15,47,48,21},表长为12,咱们再用散列函数 f(key) = key mod 12。

咱们求出每一个 key 的 f(key)见下表

image-20201118121740324

咱们查看上表发现,前五位的 f(key) 都不相同,即没有冲突,能够直接存入,可是到了第六位 f(37) = f(25) = 1,那咱们就须要利用上面的公式 f(37) = f (f(37) + 1 ) mod 12 = 2,这其实就是咱们的订包间的作法。下面咱们看一下将上面的全部数存入哈希表是什么状况吧。

image-20201118121801671

咱们把这种解决冲突的开放地址法称为线性探测法。下面咱们经过视频来模拟一下线性探测法的存储过程。

线性探测法

另外咱们在解决冲突的时候,会遇到 48 和 37 虽然不是同义词,却争夺一个地址的状况,咱们称其为堆积。由于堆积使得咱们须要不断的处理冲突,插入和查找效率都会大大下降。

经过上面的视频咱们应该了解了线性探测的执行过程了,那么咱们考虑一下这种状况,如果咱们的最后一位不为21,为 34 时会有什么事情发生呢?

image-20201118133459372

此时他第一次会落在下标为 10 的位置,那么若是继续使用线性探测的话,则须要经过不断取余后获得结果,数据量小还好,要是很大的话那也太慢了吧,可是明明他的前面就有一个空房间呀,若是向前移动只需移动一次便可。不要着急,前辈们已经帮咱们想好了解决方法

二次探测法

其实理解了咱们的上个例子以后,这个一下就能整明白了,根本不用费脑子,这个方法就是更改了一下di的取值

线性探测: f,(key) = ( f(key) + di ) MOD m(di = 1,2,3,4,5,6....m-1)

二次探测: f,(key) = ( f(key) + di ) MOD m(di =1^2 , -1^2 , 2^2 , -2^2 .... q^2, -q^2, q<=m/2)

注:这里的是 -1^2 为负值 而不是 (-1)^2

因此对于咱们的34来讲,当di = -1时,就能够找到空位置了。

image-20201118142851095

二次探测法的目的就是为了避免让关键字汇集在某一块区域。另外还有一种有趣的方法,位移量采用随机函数计算获得,接着往下看吧.

随机探测法

你们看到这是不又有新问题了,刚才咱们在散列函数构造规则的第一条中说

(1)必须是一致的,假设你输入辣子鸡丁时获得的是在看,那么每次输入辣子鸡丁时,获得的也必须为在看。若是不是这样,散列表将毫无用处。

咦?怎么又是在看哈哈,那么问题来了,咱们使用随机数做为他的偏移量,那么咱们查找的时候岂不是查不到了?由于咱们 di 是随机生成的呀,这里的随机实际上是伪随机数,伪随机数含义为,咱们设置随机种子相同,则不断调用随机函数能够生成不会重复的数列,咱们在查找时,用一样的随机种子它每次获得的数列是相同的,那么相同的 di 就能获得相同的散列地址

随机种子(Random Seed)是计算机专业术语,一种以随机数做为对象的以真随机数(种子)为初始条件的随机数。通常计算机的随机数都是伪随机数,以一个真随机数(种子)做为初始条件,而后用必定的算法不停迭代产生随机数

image-20201118154853554

image-20201118205305792

经过上面的测试是否是一下就秒懂啦,为何咱们可使用随机数做为它的偏移量,理解那句,相同的随机种子,他每次获得的数列是相同的。

下面咱们再来看一下其余的函数处理散列冲突的方法

再哈希法

这个方法其实也特别简单,利用不一样的哈希函数再求得一个哈希地址,直到不出现冲突为止。

f,(key) = RH,( key ) (i = 1,2,3,4.....k)

这里的RH,就是不一样的散列函数,你能够把咱们以前说过的那些散列函数都用上,每当发生冲突时就换一个散列函数,相信总有一个可以解决冲突的。这种方法能使关键字不产生汇集,可是代价就是增长了计算时间。是否是很简单啊。

链地址法

下面咱们再设想如下情景。

袁记菜馆内,铃铃铃,铃铃铃电话铃又响了,那个大鹏又来订房间了。

大鹏:老袁啊,我一会去你那吃个饭,仍是上回那个包间

袁厨:大鹏你下回能不能早点说啊,又没人订走了,这回是老王订的

大鹏:老王这个老东西啊,反正也是熟人,你再给我整个桌子,我拼在他后面吧

很差意思啊各位同窗,信鸽最近太贵了还没来得及买。上面的情景就是模拟咱们的新的处理冲突的方法链地址法。

上面咱们都是遇到冲突以后,就换地方。那么咱们有没有不换地方的办法呢?那就是咱们如今说的链地址法。

还记得咱们说过得同义词吗?就是 key 不一样 f(key) 相同的状况,咱们将这些同义词存储在一个单链表中,这种表叫作同义词子表,散列表中只存储同义词子表的头指针。咱们仍是用刚才的例子,关键字集合为{12,67,56,16,25,37,22,29,15,47,48,21},表长为12,咱们再用散列函数 f(key) = key mod 12。咱们用了链地址法以后就不再存在冲突了,不管有多少冲突,咱们只需在同义词子表中添加结点便可。下面咱们看下链地址法的存储状况。

image-20201118161354566

链地址法虽然可以不产生冲突,可是也带来了查找时须要遍历单链表的性能消耗,有得必有失嘛。

公共溢出区法

下面咱们再来看一种新的方法,这回大鹏又要来吃饭了。

袁记菜馆内.....

袁厨:呦,这是什么风把你给刮来了,咋没开你的大奔啊。

大鹏:哎呀妈呀,别那么多废话了,我快饿死了,你快给我找个位置,我要吃点饭。

袁厨:你来的,太不巧了,我们的店已经满了,你先去旁边的小屋看会电视,等有空了我再叫你。小屋里面还有几个和你同样来晚的,大家一块儿看吧。

大鹏:电视?看电视?

上面得情景就是模拟咱们的公共溢出区法,这也是很好理解的,你不是冲突吗?那冲突的各位我先给你安排个地方呆着,这样你就有地方住了。咱们为全部冲突的关键字创建了一个公共的溢出区来存放。

溢出区法

那么咱们怎么进行查找呢?咱们首先经过散列函数计算出散列地址后,先于基本表对比,若是不相等再到溢出表去顺序查找。这种解决冲突的方法,对于冲突不多的状况性能仍是很是高的。

散列表查找算法(线性探测法)

下面咱们来看一下散列表查找算法的实现

首先须要定义散列列表的结构以及一些相关常数,其中elem表明散列表数据存储数组,count表明的是当前插入元素个数,size表明哈希表容量,NULLKEY散列表初始值,而后咱们若是查找成功就返回索引,若是不存在该元素就返回元素不存在。

咱们将哈希表初始化,为数组元素赋初值。

第一行

插入操做的具体步骤:

(1)经过哈希函数(除法散列法),将 key 转化为数组下标

(2)若是该下标中没有元素,则插入,不然说明有冲突,则利用线性探测法处理冲突。详细步骤见注释

第二行

查找操做的具体步骤:

(1)经过哈希函数(同插入时同样),将 key 转成数组下标

(2)经过数组下标找到 key值,若是 key 一致,则查找成功,不然利用线性探测法继续查找。

第三张

下面咱们来看一下完整代码

第四张

散列表性能分析

若是没有冲突的话,散列查找是咱们查找中效率最高的,时间复杂度为O(1),可是没有冲突的状况是一种理想状况,那么散列查找的平均查找长度取决于哪些方面呢?

1.散列函数是否均匀

咱们在上文说到,能够经过设计散列函数减小冲突,可是因为不一样的散列函数对一组关键字产生冲突可能性是相同的,所以咱们能够不考虑它对平均查找长度的影响。

2.处理冲突的方法

相同关键字,相同散列函数,不一样处理冲突方式,会使平均查找长度不一样,好比咱们线性探测有时会堆积,则不如二次探测法好,由于链地址法处理冲突时不会产生任何堆积,于是具备最佳的平均查找性能

3.散列表的装填因子

原本想在上文中提到装填因子的,可是后来发现即便没有说明也不影响咱们对哈希表的理解,下面咱们来看一下装填因子的总结

装填因子 α = 填入表中的记录数 / 散列表长度

散列因子则表明着散列表的装满程度,表中记录越多,α就越大,产生冲突的几率就越大。咱们上面提到的例子中 表的长度为12,填入记录数为6,那么此时的 α = 6 / 12 = 0.5 因此说当咱们的 α 比较大时再填入元素那么产生冲突的可能性就很是大了。因此说散列表的平均查找长度取决于装填因子,而不是取决于记录数。因此说咱们须要作的就是选择一个合适的装填因子以便将平均查找长度限定在一个范围以内。

各位若是能感受到这个文章写的很用心的话,能给您带来一丢丢帮助的话,能麻烦您给这个文章点个赞吗?这样我就巨有动力写下去啦。

另外你们若是须要其余精选算法题的动图解析,你们能够微信关注下 【袁厨的算法小屋】,我是袁厨一个酷爱作饭因此本身考取了厨师证的菜鸡程序员,会一直用心写下去的,感谢支持!另外我给你们整理了一些我用过的和我认为不错的资料,你们能够回复相应关键字按取所需。
QQ截图20201214161931

相关文章
相关标签/搜索