之前给大家介绍了链表,栈和队列今天我们来说一种新的数据结构散列(哈希)表,散列是应用非常广泛的数据结构,在我们的刷题过程中,散列表的出场率特别高。所以我们快来一起把散列表的内些事给整明白吧,文章框架如下。
说散列表之前,我们先设想以下场景。
袁厨穿越回了古代,凭借从现代学习的做饭手艺,开了一个袁记菜馆,正值开业初期,店里生意十分火爆,但是顾客结账时就犯难了,由于菜品太多,每当结账时,老板娘总是按照菜单一个一个找价格(遍历查找),每次都要找半天,所以结账的地方总是排起长队,顾客们表示用户体验很差。袁厨一想这不是办法啊,太浪费大家时间了,所以袁厨就先把菜单按照首字母排序(二分查找),然后查找的时候根据首字母查找,这样结账的时候就能大大提高检索效率啦!但是呢?工作日顾客不多,老板娘完全应付的过来,但是每逢节假日,还是会排起长队。那么有没有什么更好的办法呢?对呀!我们把所有的价格都背下来不就可以了吗?每个菜的价格我们都了如指掌,结账的时候我们只需把每道菜的价格相加即可。所以袁厨和老板娘加班加点的进行背诵。下次再结账的时候一说吃了什么菜,我们立马就知道价格啦。自此以后收银台再也没有出现过长队啦,袁记菜馆开着开着一不小心就成了天下第一饭店。
下面我们来看一下袁记菜馆老板娘进化史。
上面的后期结账的过程则模拟了我们的散列表查找,那么在计算机中是如何使用进行查找的呢?
散列表查找步骤
散列表,最有用的基本数据结构之一。是根据关键码的值直接进行访问的数据结构,散列表的实现常常叫做散列(hasing)。散列是一种用于以常数平均时间执行插入、删除和查找的技术,下面我们来看一下散列过程。
我们的整个散列过程主要分为两步
(1)通过散列函数计算记录的散列地址,并按此散列地址存储该记录。就好比麻辣鱼,我们就让它在川菜区,糖醋鱼,我们就让它在鲁菜区。但是我们需要注意的是,无论什么记录我们都需要用同一个散列函数计算地址,然后再存储。
(2)当我们查找时,我们通过同样的散列函数计算记录的散列地址,按此散列地址访问该记录。因为我们存和取的时候用的都是一个散列函数,因此结果肯定相同。
刚才我们在散列过程中提到了散列函数,那么散列函数是什么呢?
我们假设某个函数为 f,使得 存储位置 = f (key) 那样我们就能通过查找关键字不需要比较就可获得需要的记录的存储位置。这种存储技术被称为散列技术。散列技术是在通过记录的存储位置和它的关键字之间建立一个确定的对应关系 f ,使得每个关键字 key 都对应一个存储位置 f(key)。见下图
这里的 f 就是我们所说的散列函数(哈希)函数。我们利用散列技术将记录存储在一块连续的存储空间中,这块连续存储空间就是我们本文的主人公------散列(哈希)
上图为我们描述了用散列函数将关键字映射到散列表,但是大家有没有考虑到这种情况,那就是将关键字映射到同一个槽中的情况,即 f(k4) = f(k3) 时。这种情况我们将其称之为冲突,k3 和 k4 则被称之为散列函数 f 的同义词,如果产生这种情况,则会让我们查找错误。幸运的是我们能找到有效的方法解决冲突。
首先我们可以对哈希函数下手,我们可以精心设计哈希函数,让其尽可能少的产生冲突,所以我们创建哈希函数时应遵循以下规则
(1)必须是一致的,假设你输入辣子鸡丁时得到的是在看,那么每次输入辣子鸡丁时,得到的也必须为在看。如果不是这样,散列表将毫无用处。
(2)计算简单,假设我们设计了一个算法,可以保证所有关键字都不会冲突,但是这个算法计算复杂,会耗费很多时间,这样的话就大大降低了查找效率,反而得不偿失。所以咱们散列函数的计算时间不应该超过其他查找技术与关键字的比较时间,不然的话我们干嘛不使用其他查找技术呢?
(3)散列地址分布均匀我们刚才说了冲突的带来的问题,所以我们最好的办法就是让散列地址尽量均匀分布在存储空间中,这样即保证空间的有效利用,又减少了处理冲突而消耗的时间。
现在我们已经对散列表,散列函数等知识有所了解啦,那么我们来看几种常用的散列函数构造方法。这些方法的共同点为都是将原来的数字按某种规律变成了另一个数字。所以是很容易理解的。
散列函数构造方法
直接定址法
如果我们对盈利为0-9的菜品设计哈希表,我们则直接可以根据作为地址,则 f(key) = key;
即下面这种情况。
有没有感觉上面的图很熟悉,没错我们经常用的数组其实就是一张哈希表,关键码就是数组的索引下标,然后我们通过下标直接访问数组中的元素。
另外我们假设每道菜的成本为50块,那我们还可以根据盈利 成本来作为地址,那么则 f(key) = key 50。也就是说我们可以根据线性函数值作为散列地址。
f(key) = a * key b a,b均为常数
优点:简单、均匀、无冲突。
应用场景:需要事先知道关键字的分布情况,适合查找表较小且连续的情况
数字分析法
该方法也是十分简单的方法,就是分析我们的关键字,取其中一段,或对其位移,叠加,用作地址。比如我们的学号,前 6 位都是一样的,但是后面 3 位都不相同,我们则可以用学号作为键,后面的 3 位做为我们的散列地址。如果我们这样还是容易产生冲突,则可以对抽取数字再进行处理。我们的目的只有一个,提供一个散列函数将关键字合理的分配到散列表的各位置。这里我们提到了一种新的方式抽取,这也是在散列函数中经常用到的手段。
优点:简单、均匀、适用于关键字位数较大的情况
应用场景:关键字位数较大,知道关键字分布情况且关键字的若干位较均匀
折叠法
其实这个方法也很简单,也是处理我们的关键字然后用作我们的散列地址,主要思路是将关键字从左到右分割成位数相等的几部分,然后叠加求和,并按散列表表长,取后几位作为散列地址。
比如我们的关键字是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这个地址数。
那我们在选用除法散列法时选取 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 冲突,相对来说就好了很多。
优点:计算效率高,灵活
应用场景:不知道关键字分布情况
乘法散列法
构造散列函数的乘法散列法主要包含两个步骤
- 用关键字 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),即有冲突,那么该怎么办呢?不急我们慢慢往下看。
开放地址法
了解开放地址法之前我们先设想以下场景。
袁记菜馆内,铃铃铃,铃铃铃 电话铃响了 大鹏:老袁,给我订个包间,我今天要去带几个客户去你那谈生意。 袁厨:大鹏啊,你常用的那个包间被人订走啦。 大鹏:老袁你这不仗义呀,咋没给我留住呀,那你给我找个空房间吧。 袁厨:好滴老哥
哦,穿越回古代就没有电话啦,那看来穿越的时候得带着几个手机了。
上面的场景其实就是一种处理冲突的方法-----开放地址法
开放地址法就是一旦发生冲突,就去寻找下一个空的散列地址,只要列表足够大,空的散列地址总能找到,并将记录存入,为了使用开放寻址法插入一个元素,需要连续地检查散列表,或称为探查,我们常用的有线性探测,二次探测,随机探测。
线性探测法
下面我们先来看一下线性探测,公式:
我们来看一个例子,我们的关键字集合为{12,67,56,16,25,37,22,29,15,47,48,21},表长为12,我们再用散列函数 f(key) = key mod 12。
我们求出每个 key 的 f(key)见下表
我们查看上表发现,前五位的 f(key) 都不相同,即没有冲突,可以直接存入,但是到了第六位 f(37) = f(25) = 1,那我们就需要利用上面的公式 f(37) = f (f(37) 1 ) mod 12 = 2,这其实就是我们的订包间的做法。下面我们看一下将上面的所有数存入哈希表是什么情况吧。
注:蓝色为计算哈希值,红色为存入哈希表
我们把这种解决冲突的开放地址法称为线性探测法。下面我们通过视频来模拟一下线性探测法的存储过程。
另外我们在解决冲突的时候,会遇到 48 和 37 虽然不是同义词,却争夺一个地址的情况,我们称其为堆积。因为堆积使得我们需要不断的处理冲突,插入和查找效率都会大大降低。
通过上面的视频我们应该了解了线性探测的执行过程了,那么我们考虑一下这种情况,若是我们的最后一位不为21,为 34 时会有什么事情发生呢?
此时他第一次会落在下标为 10 的位置,那么如果继续使用线性探测的话,则需要通过不断取余后得到结果,数据量小还好,要是很大的话那也太慢了吧,但是明明他的前面就有一个空房间呀,如果向前移动只需移动一次即可。不要着急,前辈们已经帮我们想好了解决方法
二次探测法
其实理解了我们的上个例子之后,这个一下就能整明白了,根本不用费脑子,这个方法就是更改了一下di的取值
注:这里的是 -1^2 为负值 而不是 (-1)^2
所以对于我们的34来说,当di = -1时,就可以找到空位置了。
二次探测法的目的就是为了不让关键字聚集在某一块区域。另外还有一种有趣的方法,位移量采用随机函数计算得到,接着往下看吧.
随机探测法
大家看到这是不又有新问题了,刚才我们在散列函数构造规则的第一条中说
(1)必须是一致的,假设你输入辣子鸡丁时得到的是在看,那么每次输入辣子鸡丁时,得到的也必须为在看。如果不是这样,散列表将毫无用处。
咦?怎么又是在看哈哈,那么问题来了,我们使用随机数作为他的偏移量,那么我们查找的时候岂不是查不到了?因为我们 di 是随机生成的呀,这里的随机其实是伪随机数,伪随机数含义为,我们设置随机种子相同,则不断调用随机函数可以生成不会重复的数列,我们在查找时,用同样的随机种子,它每次得到的数列是相同的,那么相同的 di 就能得到相同的散列地址。
随机种子(Random Seed)是计算机专业术语,一种以随机数作为对象的以真随机数(种子)为初始条件的随机数。一般计算机的随机数都是伪随机数,以一个真随机数(种子)作为初始条件,然后用一定的算法不停迭代产生随机数
通过上面的测试是不是一下就秒懂啦,使用相同的随机种子,生成的数列是相同的。所以为什么我们可以使用随机数作为它的偏移量。
下面我们再来看一下其他的函数处理散列冲突的方法
再哈希法
这个方法其实也特别简单,利用不同的哈希函数再求得一个哈希地址,直到不出现冲突为止。
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。我们用了链地址法之后就再也不存在冲突了,无论有多少冲突,我们只需在同义词子表中添加结点即可。下面我们看下链地址法的存储情况。
链地址法虽然能够不产生冲突,但是也带来了查找时需要遍历单链表的性能消耗,有得必有失嘛。
公共溢出区法
下面我们再来看一种新的方法,这回大鹏又要来吃饭了。
袁记菜馆内….. 袁厨:呦,这是什么风把你给刮来了,咋没开你的大奔啊。 大鹏:哎呀妈呀,别那么多废话了,我快饿死了,你快给我找个位置,我要吃点饭。 袁厨:你来的,太不巧了,咱们的店已经满了,你先去旁边的小屋看会电视,等有空了我再叫你。小屋里面还有几个和你一样来晚的,你们一起看吧。 大鹏:电视?看电视?
上面的情景就是模拟我们的公共溢出区法,这也是很好理解的,你不是冲突吗?那冲突的各位我先给你安排个地方呆着,这样你就有地方住了。我们为所有冲突的关键字建立了一个公共的溢出区来存放。
那么我们怎么进行查找呢?我们首先通过散列函数计算出散列地址后,先于基本表对比,如果不相等再到溢出表去顺序查找。这种解决冲突的方法,对于冲突很少的情况性能还是非常高的。
散列表查找算法(线性探测法)
下面我们来看一下散列表查找算法的实现
首先需要定义散列列表的结构以及一些相关常数,其中elem代表散列表数据存储数组,count代表的是当前插入元素个数,size代表哈希表容量,NULLKEY散列表初始值,然后我们如果查找成功就返回索引,如果不存在该元素就返回元素不存在。
我们将哈希表初始化,为数组元素赋初值。
插入操作的具体步骤:
(1)通过哈希函数(除法散列法),将key转化为数组下标
(2)如果该下标中没有元素,则插入,否则说明有冲突,则利用线性探测法处理冲突。详细步骤见注释
查找操作的具体步骤:
(1)通过哈希函数(同插入时一样),将key转化成数组下标
(2)通过数组下标找到key值,如果key一致,则查找成功,否则利用线性探测法继续查找。
下面我们来看一下完整代码
散列表性能分析
如果没有冲突的话,散列查找是我们查找中效率最高的,时间复杂度为O(1),但是没有冲突的情况是一种理想情况,那么散列查找的平均查找长度取决于哪些方面呢?
1.散列函数是否均匀
我们在上文说到,可以通过设计散列函数减少冲突,但是由于不同的散列函数对一组关键字产生冲突可能性是相同的,因此我们可以不考虑它对平均查找长度的影响。
2.处理冲突的方法
相同关键字,相同散列函数,不同处理冲突方式,会使平均查找长度不同,比如我们线性探测有时会堆积,则不如二次探测法好,因为链地址法处理冲突时不会产生任何堆积,因而具有最佳的平均查找性能
3.散列表的装填因子
本来想在上文中提到装填因子的,但是后来发现即使没有说明也不影响我们对哈希表的理解,下面我们来看一下装填因子的总结
装填因子 α = 填入表中的记录数 / 散列表长度
散列因子则代表着散列表的装满程度,表中记录越多,α就越大,产生冲突的概率就越大。我们上面提到的例子中 表的长度为12,填入记录数为6,那么此时的 α = 6 / 12 = 0.5 所以说当我们的 α 比较大时再填入元素那么产生冲突的可能性就非常大了。所以说散列表的平均查找长度取决于装填因子,而不是取决于记录数。所以说我们需要做的就是选择一个合适的装填因子以便将平均查找长度限定在一个范围之内。
到这里咱们的哈希表总结就结束了,因为我们明天就开始哈希表模块的面试题总结,所以就写了一篇特别长的文章来对哈希表进行总结,希望能对初学数据结构的同学带来一点点帮助。
大家快来打卡哈希表呀!
参考图书:
《小灰的算法之旅》、《数据结构与算法分析》、《算法导论》、《大话数据结构》、《数据结构与算法》