数据结构和算法之——散列表中

darlingtangli 2019-06-28

散列表的查询效率并不能笼统地说成是 $O(1)$,它和散列函数、装载因子、散列冲突等都有关系。如果散列函数设计得不好,或者装载因子过高,都可能会导致散列冲突发生的概率升高,查询效率下降。

1. 如何设计散列函数?

散列函数设计的好坏,决定了散列冲突发生的概率,也直接决定了散列表的性能。那什么才是好的散列函数呢?

首先,散列函数的设计不能太复杂。过于复杂的散列函数,势必会消耗很多计算时间,也就间接地影响到散列表的性能。

其次,散列函数生成的值要尽可能随机并且均匀分布。这样才能避免或者最小化散列冲突,而且即便出现冲突,散列在每个槽内的数据也比较平均,不会出现某一个槽内数据特别多的情况。

手机号码前面几位重复的可能性很大,但是后面几位就比较随机,我么可以取手机号的后四位数作为散列值;对运动会参赛成员统计成绩的时候,选手后两位的号码就可以作为散列值。这种散列函数的设计方法,我们一般叫作“数据分析法”。

散列表上 实现 Word 中拼写检查功能时,我们可以这样设计:将单词中每个字母的 ASCII 值“进位”相加,然后再和散列表的大小求余、取模,作为散列值。比如,英文单词 nice,转化出来的散列值就是:

hash("nice")=(("n" - "a") *26*26*26 + ("i" - "a")*26*26 + ("c" - "a")*26+ ("e"-"a")) / 78978

事实上,散列函数的设计方法还有很多,比如直接寻址法、平方取中法、折叠法、随机数法等。

2. 装载因子过大了怎么办?

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

针对散列表,当装载因子过大时,我们可以进行动态扩容,重新申请一个更大的散列表,将数据搬移到这个新散列表中。

但是,针对散列表的扩容,数据搬移要复杂很多,因为散列表的大小变了,数据的存储位置也变了,所以我们需要散列函数重新计算每个数据的存储位置。

数据结构和算法之——散列表中

插入一个数据,最好情况下,不需要扩容,最好时间复杂度是 $O(1)$,最坏情况下,启动扩容,我们需要重新申请内存空间,重新计算哈希位置,并且搬移数据,所以时间复杂度为 $O(n)$。用摊还分析法,均摊情况下,时间复杂度接近于最好情况,就是 $O(1)$。

实际上,对于动态散列表,随着数据的删除,散列表越来越小,我们还可以在装载因子小于某个值之后,启动动态缩容。

装载因子阈值的设定需要权衡时间、空间复杂度。如果内存空间不紧张,对执行效率要求很高,可以降低装载因子的阈值;相反,如果内存空间紧张,对执行效率要求又不高,可以增加装载因子的值,甚至可以大于 1。

3.如何避免低效地扩容?

我们刚刚分析到,大部分情况下,动态扩容的散列表插入数据都很快,但是在特殊情况下,当装载因子达到阈值时,需要先进行扩容,再插入数据 ,这时候,插入数据就会很慢,尤其是在数据量已经非常大的情况下。

因此,我们可以考虑不要一次性把数据全部都搬移过去。当装载因子达到阈值时,我们申请新的空间,但并不将老的数据搬移到新散列表中。当有新的数据要插入时,我们不仅将新数据插入到新散列表中,而且同时从老的散列表中拿出一个数据放到新散列表中。这样,经过多次插入操作后,我们就一点一点地完成了数据搬移,插入操作也变得更快了。

数据结构和算法之——散列表中

至于这期间的查询操作,我们先从新散列表中查找,如果没有找到,再去老的散列表中查找。

通过这样的均摊方法,任何情况下,插入一个数据的时间复杂度都为 $O(1)$。

4.如何选择冲突解决方法?

4.1. 开放寻址法

  • 优点

    • 数据都存储在数组中,可以有效地利用 CPU 缓存加快查询速度
    • 没有指针,序列化起来比较简单
  • 缺点

    • 删除数据需要特殊标记,比较麻烦
    • 冲突的代价更高,一般装载因子上限不能太大,更浪费内存

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

4.2. 链表法

  • 优点

    • 内存利用率比开放寻址法要高,链表结点可以在需要的时候再创建
    • 对大装载因子容忍度更高,只要散列函数的值随机均匀,即使装载因子变成 10,也就是链表的长度变长了而已
  • 缺点

    • 存储小对象需要额外的指针,比较耗内存,但对于大对象则可以忽略
    • 链表分散存储,无法利用 CPU 缓存

另外,我们还可以对链表法加以改造,将链表改造成其他更高效的动态数据结构,比如跳表、红黑树。这样,即使出现散列冲突,也可以保证查找的时间复杂度为 $O(logn)$。

数据结构和算法之——散列表中

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

5.工业级散列表举例分析?

让我们来看一下 Java 中的 HashMap 是怎么实现的。

  1. 初始大小

HashMap 的初始默认大小为 16,如果我们事先知道大概的数据量有多大,可以修改默认初始化大小的值。

  1. 装载因子和动态扩容

最大装载因子默认是 0.75,当超过这个阈值时,就会启动动态扩容,每次扩容都会扩容为原来的两倍大小。

  1. 散列冲突解决方法

HashMap 底层采用链表法来解决冲突,在 JDK 1.8 版本中,当链表长度太长时(默认超过 8),链表就会转化为红黑树。

6.如何设计一个工业级散列表?

一个工业级的散列表应该具有那些特性?

  • 支持快速地查询、插入和删除操作
  • 内存占用合理,不能浪费过多的内存空间
  • 性能稳定,极端情况下,散列表的性能也不会退化到无法接受的程度

如何实现这样一个散列表,可以从以下三方面来考虑设计思路

  • 设计一个合适的散列函数
  • 定义装载因子阈值,并且设计动态扩容策略
  • 选择合适的散列冲突解决方法

参考资料-极客时间专栏《数据结构与算法之美》

获取更多精彩,请关注「seniusen」!
数据结构和算法之——散列表中

相关推荐