《数据结构与算法之美》15——散列表(二)如何实现工业级别的散列表

通过上一节的学习,我们知道,散列表的查询效率并不能简单说成是O(1)。它跟散列函数、装载因子、散列冲突等地都有关系。

今天我们来学一下,如何设计一个可以应对各种异常情况的工业级散列表,来避免在散列冲突的情况下,散列表性能的急剧下降,并且能抵抗散列碰撞攻击

下面我们从散列函数、装载因子、散列冲突等方面介绍一下如何设计。

如何设计散列函数?

1. 散列函数的设计不能太复杂。

过于复杂的散列函数,会消耗很多计算时间,间接影响到散列表的性能。

2. 散列函数生成的值要尽可能随机并且均匀分布。

这样能避免或最小化散列冲突,即便出现冲突,散列到每个槽的数据也会比较平均。

常用的、简单的散列函数设计方法有数据分析法、ASCII码法、直接寻址法、平方取中法、折叠法、随机数法等等。这些只要了解就行了,不需要全都掌握。

装载因子过大了怎么办?

上一节讲到,散列表的装载因子越大,说明散列表中的元素越多,空闲位置越少,散列冲突的概率越大。

对于动态散列表来说,数据集合是频繁变动的,无法事先申请一个足够大的散列表。随着数据慢慢加入,装载因子就会慢慢变大,到一定程度后,散列冲突就会变得不可接受。这个时候可能通过动态扩容来解决。

针对散列列,当装载因子过大时,重新申请一个更大的散列表,将数据搬移到这个新散列表中。假设原来的散列表的装载因子是0.8,扩容2倍空间后,新散列表的装载因子就变成0.4了。

装载因子阈值需要选择得当。如果太大,会导致冲突过多;如果太小,会导致内存浪费严重。

装载因子阈值的设置要权衡时间、空间复杂度。

如何避免低效地扩容?

大部分情况下,动态扩容的散列表插入一个数据都很快,但在特殊情况下,当装载因子已经到达阈值,需要先进行扩容,再插入数据。这时候,插入数据就会变得很慢,甚至会无法接受。

举个极端例子,如果散列表当前大小为1GB,要想扩容为原来的两倍大小,就需要对1GB的数据重新计算哈希值,并从原来的散列表搬移到新的散列表,相当耗时。

为了解决一次性扩容耗时过多的情况,可以将扩容操作穿插在插入操作的过程中,分批完成。当装载因子触达阈值之后,只申请新空间,但并不将老的数据搬移到新的散列表中。

 《数据结构与算法之美》15——散列表(二)如何实现工业级别的散列表

通过这样均摊的方法,将一次性扩容的代价,均摊到多次插入操作中,就避免了一次性扩容耗时过多的情况。这种实现方式,插入一个数据的时间复杂度是O(1)

如何选择冲突解决方法?

1. 开放寻址法

当数据量比较小、装载因子小的时候,适合采用开放寻址法。这也是Java中的ThreadLocalMap使用开放寻址法解决散列冲突的原因。

2. 链表法

基于链表的散列冲突处理方法比较适合存储大对象、大数据量的散列表,而且比起开放寻址法,它更加灵活,支持更多的优化策略,比如用红黑代替链表。

工业级散列表举例分析

Java中的HashMap举例分析一下,工业级的散列表是怎么做的。

1. 初始大小

HashMap默认的初始大小是16,如果事先知道大概的数据量,可以设置。通过修改默认初始大小,减少动态扩容次数。

2. 装载因子和动态扩容

最大装载因子默认是0.75,当HashMap中元素个数超过0.75 * capacitycapacity表示散列表的容量)的时候,就会扩容,每次扩容为原来的两倍大小。

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;
}

相关推荐