我的新课
《C2C 电商系统微服务架构120天实战训练营》
在公众号
儒猿技术窝
上线了,感兴趣的同学,可以长按扫描下方二维码了解课程详情:
课程大纲请参见文末
今天我们来说一种新的数据结构散列(哈希)表,散列是应用非常广泛的数据结构,在我们的刷题过程中,散列表的出场率特别高。所以我们快来一起把散列表的内些事给整明白吧,文章框架如下。
说
散列表之前,我们先设想以下场景。
袁厨穿越回了古代,凭借从现代学习的做饭手艺,开了一个袁记菜馆,正值开业初期,店里生意十分火爆,但是顾客结账时就犯难了
由于菜品太多,每当结账时,老板娘总是按照菜单一个一个找价格(遍历查找),每次都要找半天,所以结账的地方总是排起长队,顾客们表示用户体验很差。
袁厨一想这不是办法啊,太浪费大家时间了,所以袁厨就先把菜单按照首字母排序(二分查找),然后查找的时候根据首字母查找,这样结账的时候就能大大提高检索效率啦!
但是呢?工作日顾客不多,老板娘完全应付的过来,但是每逢节假日,还是会排起长队。那么有没有什么更好的办法呢?
对呀!我们把所有的价格都背下来不就可以了吗?每个菜的价格我们都了如指掌,结账的时候我们只需把每道菜的价格相加即可。所以袁厨和老板娘加班加点的进行背诵。下次再结账的时候一说吃了什么菜,我们立马就知道价格啦。自此以后收银台再也没有出现过长队啦,袁记菜馆开着开着一不小心就成了天下第一饭店。
下面我们来看一下袁记菜馆老板娘进化史。
上面的后期结账的过程则模拟了我们的散列表查找,那么在计算机中是如何使用进行查找的呢?
散列表查找步骤
散列表,最有用的基本数据结构之一。是根据关键码的值直接进行访问的数据结构,散列表的实现常常叫做
散列(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 冲突,相对来说就好了很多。
优点:计算效率高,灵活
应用场景:不知道关键字分布情况
乘法散列法
构造散列函数的乘法散列法主要包含两个步骤
散列函数为
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,就是不同的散列函数,你可以把我们之前说过的那些散列函数都用上,每当发生冲突时就换一个散列函数,相信总有一个能够解决冲突的。这种方法能使关键字不产生聚集,但是代价就是增加了计算时间。是不是很简单啊。
链地址法
下面我们再设想以下情景。
袁记菜馆内,铃铃铃,铃铃铃电话铃又响了,那个大鹏又来订房间了。
大鹏:老袁啊,我一会去你那吃个饭,还是上回那个包间
袁厨:大鹏你下回能不能早点说啊,又没人订走了,这回是老王订的
大鹏:老王这个老东西啊,反正也是熟人,你再给我整个桌子,我拼在他后面吧