深入理解散列表

本贴最后更新于 1789 天前,其中的信息可能已经事过景迁

如何设计散列函数

散列函数决定了散列表冲突概率的大小,也决定了散列表对的性能。在进行散列表设计的时候,要注意的几点:

  • 散列函数的设计不能太复杂 负责的散列函数会消耗更多的计算时间,间接的影响大棚散列表的性能

  • 散列函数生成的值,要就可能随机并且均匀的分布 因为这样可以避免或者最小化散列冲突,即使出现冲突,散列到每个槽里的数据也会比较均匀的分布,不会出现某个槽数据特别多的情况

  • 还有一些其他的点,比如关键字的长度、特点、分布、散列表大小等

装载因子

装载因子用来表示散列表中的空位的多少,具体计算方法为; 散列表的装载因子=填入表中的元素个数/散列表的长度 装载因子越大,说明空闲文职越少,冲突越多,散列表的性能会下降,插入数据过程要多次寻址或者拉很长的链,因此会变得很慢。 对于一个动态散列表来说,数据集合是频发变动的,无法预估个数,随机数据的插入装载因子会慢慢变大,当到达一定程度后,散列冲突就无法接受了。那么这个时候就需要扩容了。

动态扩容

通过装载因子是否达到阈值来判断是否要进行扩容,阈值的确定需要根据实际对内存和执行效率的要求来决定,如果内存空间要求不紧张,对执行效率要求很高,可以降低负载因子的阈值,为什么呢? 因为在阈值低的话,就需要不断的扩容,扩容会申请更多的内存,这样会减少散列函数的计算时间,增加执行效率。同理我们也可以直接申请大一点的内存,减少扩容的次数。

如何进行有效的扩容

当装载因子达到阈值的时候,就要先进行扩容,然后再插入数据,那这个插入操作就会很耗时,虽然这种情况会很好,但是也会让用户崩溃,为了解决一次性扩容耗时过多的情况,我们可以将扩容操作穿插在插入操作的过程中,分批完成。当装载因子触达阈值之后,我们只申请新空间,但并不将老的数据搬移到新散列表中。

当有新数据要插入时,我们将新数据插入新散列表中,并且从老的散列表中拿出一个数据放入到新散列表。每次插入一个数据到散列表,我们都重复上面的过程。经过多次插入操作之后,老的散列表中的数据就一点一点全部搬移到新散列表中了。这样没有了集中的一次性数据搬移,插入操作就都变得很快了

对于查询和删除操作,先从新的散列表中查找,如果没有就去旧的散列表中查。

如何选择散列冲突解决办法

两种主要的散列冲突的解决办法,开放寻址法和链表法,这两种冲突解决办法在实际的软件开发中都非常常用。比如,Java 中 LinkedHashMap 就采用了链表法解决冲突,ThreadLocalMap 是通过线性探测的开放寻址法来解决冲突

1、开放寻址法

核心思想是,如果出现了散列冲突,我们就重新探测一个空闲位置,将其插入。那有什么探测方法呢?

线性探测
  • 插入

    当我们往散列表中插入数据时,如果某个数据经过散列函数散列之后,存储位置已经被占用了,我们就从当前位置开始,依次往后查找,看是否有空闲位置,直到找到为止。

  • 查找

    元素的过程有点儿类似插入过程。我们通过散列函数求出要查找元素的键值对应的散列值,然后比较数组中下标为散列值的元素和要查找的元素。如果相等,则说明就是我们要找的元素;否则就顺序往后依次查找。如果遍历到数组中的空闲位置,还没有找到,就说明要查找的元素并没有在散列表中

  • 删除

删除操作稍微有点特殊,在删除元素的时候,并不是单纯的把元素设置为空,而是标记为 deleted,这是因为与查找元素操作相配合,如果直接置为空,查找到这个位置就停下了,但是实际可能在后面存着,会产生错误的结果。

二次探测

二次探测,跟线性探测很像,线性探测每次探测的步长是 1,那它探测的下标序列就是 hash(key)+0,hash(key)+1,hash(key)+2……而二次探测探测的步长就变成了原来的“二次方”,也就是说,它探测的下标序列就是 hash(key)+0,hash(key)+1 平方,hash(key)+2 平方……

双重散列

双重散列,意思就是不仅要使用一个散列函数。我们使用一组散列函数 hash1(key),hash2(key),hash3(key)……我们先用第一个散列函数,如果计算得到的存储位置已经被占用,再用第二个散列函数,依次类推,直到找到空闲的存储位置

链表法

链表法是一种更加常用的散列冲突解决办法,在散列表中,每个“桶(bucket)”或者“槽(slot)”会对应一条链表,所有散列值相同的元素我们都放到相同槽位对应的链表中。当插入的时候,我们只需要通过散列函数计算出对应的散列槽位,将其插入到对应链表中即可,所以插入的时间复杂度是 O(1)。当查找、删除一个元素时,我们同样通过散列函数计算出对应的槽,然后遍历链表查找或者删除

总结对比

开放寻址法不像链表法,需要拉很多链表。散列表中的数据都存储在数组中,可以有效地利用 CPU 缓存加快查询速度。而且,这种方法实现的散列表,序列化起来比较简单。链表法包含指针,序列化起来就没那么容易。

解释一下利用 CPU 缓存加快查询速度这个说法,我们知道在查询的时候,会将数据读到内存中,在读取的时候,并不是一点一点读取,而是一次性读取一个连续的内存块,而数组这种数据结构是连续的,不像链表是零散的存储,所有数组形式的散列表,会被读取到缓存中,加快查询效率。

用开放寻址法解决冲突的散列表,删除数据的时候比较麻烦,需要特殊标记已经删除掉的数据。而且,在开放寻址法中,所有的数据都存储在一个数组中,比起链表法来说,冲突的代价更高。所以,使用开放寻址法解决冲突的散列表,装载因子的上限不能太大。这也导致这种方法比链表法更浪费内存空间,因为不设置太大就意味着有些空间是不会存储数据的。

当数据量比较小,装载因为比较小的时候,适合采用开放寻址法

链表法对内存的利用率比开放寻址法要高。因为链表结点可以在需要的时候再创建,并不需要像开放寻址法那样事先申请好。实际上,这一点也是我们前面讲过的链表优于数组的地方。链表法比起开放寻址法,对大装载因子的容忍度更高。开放寻址法只能适用装载因子小于 1 的情况。接近 1 时,就可能会有大量的散列冲突,导致大量的探测、再散列等,性能会下降很多。但是对于链表法来说,只要散列函数的值随机均匀,即便装载因子变成 10,也就是链表的长度变长了而已,虽然查找效率有所下降,但是比起顺序查找还是快很多。还记得我们之前在链表那一节讲的吗?链表因为要存储指针,所以对于比较小的对象的存储,是比较消耗内存的,还有可能会让内存的消耗翻倍。而且,因为链表中的结点是零散分布在内存中的,不是连续的,所以对 CPU 缓存是不友好的,这方面对于执行效率也有一定的影响。

如果我们存储的是大对象,也就是说要存储的对象的大小远远大于一个指针的大小(4 个字节或者 8 个字节),那链表中指针的内存消耗在大对象面前就可以忽略了。

如果对链表法稍加改造,可以实现一个更加高效的散列表。将链表法中的链表改造为其他高效的动态数据结构,比如跳表、红黑树。这样,即便出现散列冲突,极端情况下,所有的数据都散列到同一个桶内,那最终退化成的散列表的查找时间也只不过是 O(logn)。这样也就有效避免了前面讲到的散列碰撞攻击。

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

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...