欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

13.散列表(中)

程序员文章站 2022-03-08 16:44:34
...


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

1. 如何设计散列函数

1.1 散列函数设计不能太复杂

​ 过于复杂的散列函数,势必会消耗很多的计算时间们也就间接地影响散列表的性能。

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

2.装载因子过大怎么办

装载因子越大,说明列表中的元素越多,空闲位置就越少,散列冲突的概率就越大,不仅插入数据的过程要多次寻址或者拉很长的链,查找的过程也会因此变得很慢。

​ 插入一个数据,最好情况下,不需要扩容,最好时间复杂度为O(1).

​ 最坏的情况,散列表装载因子过高,重新扩容,我们需要重新申请存储空间,涉及到三方面:

  • 重新扩容

  • 重新计算哈希位置

  • 搬移数据
    ,时间复杂度为O(1), 用摊还分析法,时间复杂度为O(1).

    装载因子阀值需要选择得当。如果太大,会导致冲突过多;如果太小,会导致内存浪费严重。需要权衡时空复杂度:

    • 如果空间紧张,对执行效率要求又不高,可以增加负载因子,设置可以大于1.
    • 反之,对执行效率要求很高,对内存空间不敏感,则可以降低负载因子.

3.如何避免低效地扩容

​ 大部分情况下,动态扩容的散列表插入一个数据都很快,但是在特殊情况下,当装载因子已经达到阈值,需要先进行扩容,再插入数据。这个时候,插入数据就会变得很慢, 甚至会无法接受。
​ 举一个极端例子,当散列表当前大小为1GB, 要想扩容为原来的两倍大小,那么就需要对1GB的数据重新计算哈希值,并且从原来的散列表版移到新的散列表,很耗时。

​ 为了解决这个问题,可以将扩容操作穿插在插入操作的过程中,分批完成。当装载因子触发阈值后,我们只重新申请空间,但并不将老的数据搬移到新的散列表中。

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

​ 对于这期间的查找,兼容,先从新散列表中查找,没有找到,再去老散列表中找。通过这样将操作均摊到多次的方法,避免了一次性扩容耗时过多的情况,因此时间复杂度为O(1).

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

  • 开放寻址法
    e.g. ThreadLocalMap
    • 优点
      • 数据全部在数组中,可以有效地利用CPU缓存加快查询速度
      • 序列化简单,链表法包含指针,序列化比较困难
    • 缺点
      • 删除数据麻烦,需要特殊标记
      • 冲突代价太高,所以装载因子上限不能太大,这也导致这种方法比链表法更浪费内存空间
    • 适用场景
      • 数据量小
      • 装载因子小
  • 链表发
    e.g. LinkedHashMap
    • 优点
      • 链表结点可以在需要时再创建,并不需要像开放寻址那样事先申请好,因此内存利用较开放寻址法高
      • 对大装载因子容忍度更高,只要散列函数值均匀分布,即便装载因子变成10,也就是链表的长度变长,虽然效率有所下降,但是比起顺序查找还是快和很多
    • 缺点
      • 需要存储指针,如果储存数据本身不大,会导致内存翻倍的情况出现
      • 链表结点零散分布,对CPU缓存不友好,对执行效率也有一定影响
    • 适用场景
      • 存储大对象、大数据量
      • 灵活,可以支持多种优化策略,比如用红黑树代替链表

5.工业级散列表的特性&设计

5.1 Java HashMap工业级链表实现

5.1.1 初始大小

​ 默认16,可调整。如果大概率知道数量有多大,可以修改默认值,避免resize数量,这样会大大提高性能.

5.1.2 装载因子和动态扩容

​ 默认装载因子为0.75, 达到阈值会扩容为原来2倍。

5.1.3 散列冲突解决方法

​ 使用链表法。Jdk1.8中,进行了进一步优化。当链表长度太长(默认超过8), 链表就转为红黑树. 可以利用红黑树快速增删改查的特点,提高HashMap的性能。当红黑树节点小于8个时,又退化为链表。因为在数据量较小的情况下,红黑树要维护平衡,比起链表,性能上优势并不明显。

5.1.4 散列行数

​ 设计并不复杂,最求的是简单高效,分布均匀。

static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

5.2 工业级散列表应该由哪些特性?

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

5.3 如何实现?

可以从下面三个方面来考虑设计思路:

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