对散列冲突的处理——开放定址法
之前聊了聊用分离链接法解决散列冲突的问题,这次就来聊聊对散列冲突的另一种解决办法:开放定址法。开放定址法分为线性探测,平方探测,双散列三种方法。
开放定址法
分离链接法是有一些不足的,它使用了一些链表,给新单元分配地址是需要时间的,这就导致算法的速度有些慢,同时这种方法实际上还要求对第二种数据结构的实现。而开放地址法不使用链表。开放定址法的思想是当发生冲突时,在表内尝试一些另外的单元,直到找到空单元为止。用这种方式产生的表,所有的元素都在表内,所以对于相同规模的元素,开放定址法所需要散列表要比分离链接法大。我们把这样的表叫做探测散列表。
线性探测法
在线性探测法中,探测函数的常用选择是f(i)=i。很明显,它的意思是从冲突位置开始逐一探测相继单元(允许循环)。例如,一个tablesize=10的散列表,它的散列函数是key mod tablesize。元素以整数为例,表长不是素数是为了好算。19已经被散列到9索引的位置,接下来对49进行散列,49也应该被散列到9索引的位置,为了解决这个冲突,从索引9开始探测之后单元,把49放入探测到的第一个空单元,因为9索引位置是散列表的随后一个单元,所以回绕,探测从0索引开始,0索引位置为空,所以把49放入0索引位置。
使用这种方法处理冲突,只要表不满,总能找到一个空单元,但是时间开销是比较大的。而且,就算表相对较空,以这种方式散列元素所占据的单元会形成一些区块(单元连成一片),这就是一次聚集。也就是说,之后只要是散列到这些区块的元素都必须经过多次探测才能解决冲突,然后这些元素还会增加区块长度。
介绍一个概念。填装因子,用符号λ表示。它是散列表中元素个数和tablesize的比值,可以了理解为表的饱和程度,就是满没满,装了多少元素了。然后给出两个公式,使用线性探测的预期探测次数(冲突次数)对于插入和不成功的查找来说大约是(1+1/(1-λ)^2)/2,对于成功的查找来说则是(1+1/(1-λ))/2。具体计算很复杂,意思就是我特娘的也不会,但是不妨碍我们直接拿来用。用Excel画个图来看一下,话说Office真的是越用越感觉他牛逼。
横轴代表填装因子λ,纵轴代表冲突次数,S是使用线性探测的预期探测次数对于插入和不成功的查找的探测曲线。I是使用线性探测的预期探测次数对于成功的查找的探测曲线。可以看到对于插入和不成功的查找来说,从λ=0.75开始,探测次数,也就是冲突次数急剧上升。当λ=0.75时,一次插入仅需要8.5次探测,当λ=0.9时,就已经需要50.5次探测了。对于成功的查找来说,探测次数都不高。
然后来看看标准库中对开放定址的实现。标准库中对散列的实现常用的有HasSet,HashMap,HashTable之类的,HashMap是用分离链接法实现的,之前的博文介绍过分离链接法。而HashTable是用开放定址法实现的。
首先可以明确的是它的确是由数组实现的,Entry<?,?>类型的数组,Entry<?,?>是Hashtable的一个内部类,它存储的就是我们常说的键值对。
Hashtable的填装因子λ被定义为浮点型,private float loadFactor(就是这货),看下源码
HashTable提供带参构造可以指定表的大小和填装因子数值,空参构造调用带参构造指定默认值。
表的默认大小是11,填装因子默认是0.75。之前分析过了0.75时8.5次的平均探测次数,所以没啥事就别乱指定这个值233333333333333
然后是它的具体实现,put方法。
原谅色标出的是它的散列函数,利用哈希值对表长取余来得到散列位置,然后把键值关系存入对应索引。for循环在判断当前键值对的键是否重复,重复则覆盖值,不重复添加进数组。addEntry方法就不啰嗦了。
关于λ=0.75也是hashMap的默认值,HashMap的初始大小是16,最大2^30。也就是说当散列表长为1<<30时,不会进行在散列,即扩容。在散列不管对于分离链表法还是开放定址法或是其他方法来说都是一种开销非常大的操作,阿里巴巴java开发手册建议在使用散列有关操作时,初始化散列表大小,就像这样HashMap(int)。否则不断扩容影响性能。
平方探测和双散列
这两个方法和线性探测的方法在思想上大同小异,平方探测就还是冲突函数为二次的探测方法,流行的选择是f(i)=i^2。平方探测消除了一次聚集,但是会带来二次聚集。二次聚集是理论上的小缺憾。
而双散列可以消除二次聚集,它流行的选择是f(i)=i*hash(x)。但是对于hash(x)的选取需要格外慎重。
还有其他一些花里胡哨的散列,例如,布谷鸟散列,跳房子散列等,有机会再聊。