范范 2020-07-04
通过上一节的学习,我们知道,散列表的查询效率并不能简单说成是O(1)。它跟散列函数、装载因子、散列冲突等地都有关系。
今天我们来学一下,如何设计一个可以应对各种异常情况的工业级散列表,来避免在散列冲突的情况下,散列表性能的急剧下降,并且能抵抗散列碰撞攻击?
下面我们从散列函数、装载因子、散列冲突等方面介绍一下如何设计。
如何设计散列函数?
1. 散列函数的设计不能太复杂。
过于复杂的散列函数,会消耗很多计算时间,间接影响到散列表的性能。
2. 散列函数生成的值要尽可能随机并且均匀分布。
这样能避免或最小化散列冲突,即便出现冲突,散列到每个槽的数据也会比较平均。
常用的、简单的散列函数设计方法有数据分析法、ASCII码法、直接寻址法、平方取中法、折叠法、随机数法等等。这些只要了解就行了,不需要全都掌握。
装载因子过大了怎么办?
上一节讲到,散列表的装载因子越大,说明散列表中的元素越多,空闲位置越少,散列冲突的概率越大。
对于动态散列表来说,数据集合是频繁变动的,无法事先申请一个足够大的散列表。随着数据慢慢加入,装载因子就会慢慢变大,到一定程度后,散列冲突就会变得不可接受。这个时候可能通过“动态扩容”来解决。
针对散列列,当装载因子过大时,重新申请一个更大的散列表,将数据搬移到这个新散列表中。假设原来的散列表的装载因子是0.8,扩容2倍空间后,新散列表的装载因子就变成0.4了。
装载因子阈值需要选择得当。如果太大,会导致冲突过多;如果太小,会导致内存浪费严重。
装载因子阈值的设置要权衡时间、空间复杂度。
如何避免低效地扩容?
大部分情况下,动态扩容的散列表插入一个数据都很快,但在特殊情况下,当装载因子已经到达阈值,需要先进行扩容,再插入数据。这时候,插入数据就会变得很慢,甚至会无法接受。
举个极端例子,如果散列表当前大小为1GB,要想扩容为原来的两倍大小,就需要对1GB的数据重新计算哈希值,并从原来的散列表搬移到新的散列表,相当耗时。
为了解决一次性扩容耗时过多的情况,可以将扩容操作穿插在插入操作的过程中,分批完成。当装载因子触达阈值之后,只申请新空间,但并不将老的数据搬移到新的散列表中。
通过这样均摊的方法,将一次性扩容的代价,均摊到多次插入操作中,就避免了一次性扩容耗时过多的情况。这种实现方式,插入一个数据的时间复杂度是O(1)。
如何选择冲突解决方法?
1. 开放寻址法
当数据量比较小、装载因子小的时候,适合采用开放寻址法。这也是Java中的ThreadLocalMap使用开放寻址法解决散列冲突的原因。
2. 链表法
基于链表的散列冲突处理方法比较适合存储大对象、大数据量的散列表,而且比起开放寻址法,它更加灵活,支持更多的优化策略,比如用红黑代替链表。
工业级散列表举例分析
以Java中的HashMap举例分析一下,工业级的散列表是怎么做的。
1. 初始大小
HashMap默认的初始大小是16,如果事先知道大概的数据量,可以设置。通过修改默认初始大小,减少动态扩容次数。
2. 装载因子和动态扩容
最大装载因子默认是0.75,当HashMap中元素个数超过0.75 * capacity(capacity表示散列表的容量)的时候,就会扩容,每次扩容为原来的两倍大小。
3. 散列冲突解决方法
HaspMap采用链表法来解决冲突。在JDK1.8中,引入了红黑树进行优化。
4. 散列函数
散列函数的设计并不复杂,追求的是简单高效、分布均匀。
int hash(Object key) { int h = key.hashCode(); return (h ^ (h >>> 16)) & (capitity -1); //capicity表示散列表的大小 } public int hashCode() { int var1 = this.hash; if(var1 == 0 && this.value.length > 0) { char[] var2 = this.value; for(int var3 = 0; var3 < this.value.length; ++var3) { var1 = 31 * var1 + var2[var3]; } this.hash = var1; } return var1; }