数据结构 哈希表
散列是应用非常广泛的数据结构。
散列(哈希)表 - 图1
说散列表之前,先设想以下场景。

袁厨穿越回了古代,凭借从现代学习的做饭手艺,开了一个袁记菜馆,正值开业初期,店里生意十分火爆,但是顾客结账时就犯难了,由于菜品太多,每当结账时,老板娘总是按照菜单一个一个找价格(遍历查找),每次都要找半天,所以结账的地方总是排起长队,顾客们表示用户体验很差。袁厨一想这不是办法啊,太浪费大家时间了,所以袁厨就先把菜单按照首字母排序(二分查找),然后查找的时候根据首字母查找,这样结账的时候就能大大提高检索效率啦!但是呢?工作日顾客不多,老板娘完全应付的过来,但是每逢节假日,还是会排起长队。那么有没有什么更好的办法呢?把所有的价格都背下来不就可以了吗?每个菜的价格都了如指掌,结账的时候只需把每道菜的价格相加即可。所以袁厨和老板娘加班加点的进行背诵。下次再结账的时候一说吃了什么菜,立马就知道价格了。自此以后收银台再也没有出现过长队啦,袁记菜馆开着开着一不小心就成了天下第一饭店。

下面来看一下袁记菜馆老板娘进化史。
散列(哈希)表 - 图2
上面的后期结账的过程则模拟了散列表查找,那么在计算机中是如何使用进行查找的呢?

散列表查找步骤

散列表,最有用的基本数据结构之一。是根据关键码的值直接进行访问的数据结构,散列表的实现常常叫做散列(hasing)。散列是一种用于以常数平均时间执行插入、删除和查找的技术,下面来看一下散列过程。
整个散列过程主要分为两步
(1)通过散列函数计算记录的散列地址,并按此散列地址存储该记录。就好比麻辣鱼,就让它在川菜区,糖醋鱼,就让它在鲁菜区。但是需要注意的是,无论什么记录都需要用同一个散列函数计算地址,然后再存储。
(2)当查找时,通过同样的散列函数计算记录的散列地址,按此散列地址访问该记录。因为存和取的时候用的都是一个散列函数,因此结果肯定相同。
刚才在散列过程中提到了散列函数,那么散列函数是什么呢?
假设某个函数为 f,使得 存储位置 = f (key) 那样就能通过查找关键字不需要比较就可获得需要的记录的存储位置。这种存储技术被称为散列技术。散列技术是在通过记录的存储位置和它的关键字之间建立一个确定的对应关系 f,使得每个关键字 key 都对应一个存储位置 f(key)。见下图
散列(哈希)表 - 图3
这里的 f 就是的散列函数(哈希)函数。利用散列技术将记录存储在一块连续的存储空间中,这块连续存储空间就是——散列(哈希)
上图描述了用散列函数将关键字映射到散列表,但是大家有没有考虑到这种情况,那就是将关键字映射到同一个槽中的情况,即 f(k4) = f(k3) 时。这种情况将其称之为冲突,k3 和 k4 则被称之为散列函数 f同义词,如果产生这种情况,则会查找错误。幸运的是能找到有效的方法解决冲突。
首先可以对哈希函数下手,可以精心设计哈希函数,让其尽可能少的产生冲突,所以创建哈希函数时应遵循以下规则
(1)必须是一致的,假设输入辣子鸡丁时得到的是在看,那么每次输入辣子鸡丁时,得到的也必须为在看。如果不是这样,散列表将毫无用处。
(2)计算简单,假设设计了一个算法,可以保证所有关键字都不会冲突,但是这个算法计算复杂,会耗费很多时间,这样的话就大大降低了查找效率,反而得不偿失。所以散列函数的计算时间不应该超过其他查找技术与关键字的比较时间
(3)散列地址分布均匀刚才说了冲突的带来的问题,所以最好的办法就是让散列地址尽量均匀分布在存储空间中,这样即保证空间的有效利用,又减少了处理冲突而消耗的时间。
现在已经对散列表,散列函数等知识有所了解,那么来看几种常用的散列函数构造方法。这些方法的共同点为都是将原来的数字按某种规律变成了另一个数字。所以是很容易理解的。

散列函数构造方法

直接定址法

如果对盈利为0-9的菜品设计哈希表,则直接可以根据作为地址,则 **f(key) = key**;
即下面这种情况。
散列(哈希)表 - 图4
有没有感觉上面的图很熟悉,没错,平常用的数组其实就是一张哈希表,关键码就是数组的索引下标,然后通过下标直接访问数组中的元素。
另外假设每道菜的成本为50块,那还可以根据盈利+成本来作为地址,那么则 f(key) = key + 50。也就是说可以根据线性函数值作为散列地址。
散列(哈希)表 - 图5 a,b均为常数
优点:简单、均匀、无冲突。
应用场景:需要事先知道关键字的分布情况,适合查找表较小且连续的情况

数字分析法

该方法也是十分简单的方法,就是分析关键字,取其中一段,或对其位移,叠加,用作地址。比如学号,前 6 位都是一样的,但是后面 3 位都不相同,则可以用学号作为键,后面的 3 位作为散列地址。如果这样还是容易产生冲突,则可以对抽取数字再进行处理。目的只有一个,提供一个散列函数将关键字合理的分配到散列表的各位置。这里提到了一种新的方式抽取,这也是在散列函数中经常用到的手段。
散列(哈希)表 - 图6
优点:简单、均匀、适用于关键字位数较大的情况
应用场景:关键字位数较大,知道关键字分布情况且关键字的若干位较均匀

折叠法

其实这个方法也很简单,也是处理关键字然后用作散列地址,主要思路是将关键字从左到右分割成位数相等的几部分,然后叠加求和,并按散列表表长,取后几位作为散列地址。
比如关键字是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这个地址数。
散列(哈希)表 - 图7
那在选用除法散列法时选取 p 值时应该遵循怎样的规则呢?

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

    合数:合数是指在大于1的整数中除了能被1和本身整除外,还能被其他数(0除外)整除的数。 质因子:质因子(或质因数)在数论里是指能整除给定正整数的质数。

散列(哈希)表 - 图8
注:这里的2,3,5为质因子
还是上面的例子,根据规则选择 5 为 p 值,再来看。这时发现只有 6 和 36 冲突,相对来说就好了很多。
散列(哈希)表 - 图9
优点:计算效率高,灵活
应用场景:不知道关键字分布情况

乘法散列法

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

  • 用关键字 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),即有冲突,那么该怎么办呢?

开放地址法

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

袁记菜馆内,铃铃铃,铃铃铃 电话铃响了 大鹏:老袁,给我订个包间,我今天要去带几个客户去你那谈生意。 袁厨:大鹏啊,你常用的那个包间被人订走啦。 大鹏:老袁你这不仗义呀,咋没给我留住呀,那你给我找个空房间吧。 袁厨:好滴老哥

上面的场景其实就是一种处理冲突的方法——-开放地址法
开放地址法就是一旦发生冲突,就去寻找下一个空的散列地址,只要列表足够大,空的散列地址总能找到,并将记录存入,为了使用开放寻址法插入一个元素,需要连续地检查散列表,或称为探查,常用的有线性探测,二次探测,随机探测

线性探测法

下面先来看一下线性探测,公式:
散列(哈希)表 - 图10
来看一个例子,关键字集合为{12,67,56,16,25,37,22,29,15,47,48,21},表长为12,再用散列函数 f(key) = key mod 12。
求出每个 key 的 f(key)见下表
散列(哈希)表 - 图11
查看上表发现,前五位的 f(key) 都不相同,即没有冲突,可以直接存入,但是到了第六位 f(37) = f(25) = 1,那就需要利用上面的公式 f(37) = f (f(37) + 1 ) mod 12 = 2,这其实就是订包间的做法。下面看一下将上面的所有数存入哈希表是什么情况吧。
注:蓝色为计算哈希值,红色为存入哈希表
image.gif640.png
把这种解决冲突的开放地址法称为线性探测法。下面通过视频来模拟一下线性探测法的存储过程。
另外在解决冲突的时候,会遇到 48 和 37 虽然不是同义词,却争夺一个地址的情况,称其为堆积。因为堆积使得需要不断的处理冲突,插入和查找效率都会大大降低。
通过上面的应该了解了线性探测的执行过程了,那么考虑一下这种情况,若是最后一位不为21,为 34 时会有什么事情发生呢?
散列(哈希)表 - 图14
此时他第一次会落在下标为 10 的位置,那么如果继续使用线性探测的话,则需要通过不断取余后得到结果,数据量小还好,要是很大的话那也太慢了吧,但是明明他的前面就有一个空房间呀,如果向前移动只需移动一次即可。

二次探测法

其实理解了上个例子之后,这个一下就能整明白了,这个方法就是更改了一下di的取值
散列(哈希)表 - 图15
注:这里的是 -1^2 为负值 而不是 (-1)^2
所以对于34来说,当di = -1时,就可以找到空位置了。
640.png
二次探测法的目的就是为了不让关键字聚集在某一块区域。另外还有一种有趣的方法,位移量采用随机函数计算得到,接着往下看。

随机探测法

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

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

那么问题来了,使用随机数作为他的偏移量,那么查找的时候岂不是查不到了?因为 di 是随机生成的,这里的随机其实是伪随机数,伪随机数含义为,设置随机种子相同,则不断调用随机函数可以生成不会重复的数列,在查找时,用同样的随机种子它每次得到的数列是相同的,那么相同的 di 就能得到相同的散列地址。 :::success 随机种子(Random Seed)是计算机专业术语,一种以随机数作为对象的以真随机数(种子)为初始条件的随机数。一般计算机的随机数都是伪随机数,以一个真随机数(种子)作为初始条件,然后用一定的算法不停迭代产生随机数 ::: 散列(哈希)表 - 图17

散列(哈希)表 - 图18
通过上面的测试是不是一下就秒懂了,使用相同的随机种子,生成的数列是相同的。所以为什么可以使用随机数作为它的偏移量。
下面再来看一下其他的函数处理散列冲突的方法

再哈希法

这个方法其实也特别简单,利用不同的哈希函数再求得一个哈希地址,直到不出现冲突为止。
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。用了链地址法之后就再也不存在冲突了,无论有多少冲突,只需在同义词子表中添加结点即可。下面看下链地址法的存储情况。
散列(哈希)表 - 图19
链地址法虽然能够不产生冲突,但是也带来了查找时需要遍历单链表的性能消耗,有得必有失。

公共溢出区法

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

袁记菜馆内….. 袁厨:呦,这是什么风把你给刮来了,咋没开你的大奔啊。 大鹏:哎呀妈呀,别那么多废话了,我快饿死了,你快给我找个位置,我要吃点饭。 袁厨:你来的,太不巧了,咱们的店已经满了,你先去旁边的小屋看会电视,等有空了我再叫你。小屋里面还有几个和你一样来晚的,你们一起看吧。 大鹏:电视?看电视?

上面的情景就是模拟公共溢出区法,这也是很好理解的,你不是冲突吗?那冲突的各位我先给你安排个地方呆着,这样你就有地方住了。为所有冲突的关键字建立了一个公共的溢出区来存放。
散列(哈希)表 - 图20
那么怎么进行查找呢?首先通过散列函数计算出散列地址后,先于基本表对比,如果不相等再到溢出表去顺序查找。这种解决冲突的方法,对于冲突很少的情况性能还是非常高的。

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

下面来看一下散列表查找算法的实现
首先需要定义散列列表的结构以及一些相关常数,其中elem代表散列表数据存储数组,count代表的是当前插入元素个数,size代表哈希表容量,NULLKEY散列表初始值,然后如果查找成功就返回索引,如果不存在该元素就返回元素不存在。
将哈希表初始化,为数组元素赋初值。
散列(哈希)表 - 图21
插入操作的具体步骤:
(1)通过哈希函数(除法散列法),将key转化为数组下标
(2)如果该下标中没有元素,则插入,否则说明有冲突,则利用线性探测法处理冲突。详细步骤见注释
散列(哈希)表 - 图22
查找操作的具体步骤:
(1)通过哈希函数(同插入时一样),将key转化成数组下标
(2)通过数组下标找到key值,如果key一致,则查找成功,否则利用线性探测法继续查找。
散列(哈希)表 - 图23
下面来看一下完整代码
散列(哈希)表 - 图24

散列表性能分析

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

1.散列函数是否均匀

上面说到,可以通过设计散列函数减少冲突,但是由于不同的散列函数对一组关键字产生冲突可能性是相同的,因此可以不考虑它对平均查找长度的影响。

2.处理冲突的方法

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

3.散列表的装填因子

来看一下装填因子的总结 :::info 装填因子 α = 填入表中的记录数 / 散列表长度 ::: 散列因子则代表着散列表的装满程度,表中记录越多,α就越大,产生冲突的概率就越大。上面提到的例子中,表的长度为12,填入记录数为6,那么此时的 α = 6 / 12 = 0.5 所以说当 α 比较大时再填入元素那么产生冲突的可能性就非常大了。所以说散列表的平均查找长度取决于装填因子,而不是取决于记录数。所以说需要做的就是选择一个合适的装填因子以便将平均查找长度限定在一个范围之内。