0%

散列表扩展

散列函数、装载因子、散列冲突等都会影响散列表的查询效率。
极端情况,所有的数据经过散列函数之后,都散列到同一个槽里,如果使用基于链表的冲突解决方法,散列表就会退化为链表,查询的时间复杂度从O(1)急剧退化为O(n)。

散列函数

散列函数设计的好坏,决定了散列表冲突的概率大小,也直接决定了散列表的性能。

  • 散列函数的设计不能太复杂
    过于复杂的撒捏函数,会消耗很多计算时间,间接影响到散列表的性能。
  • 散列函数生成的值要尽可能随机并且均匀分布
    避免或者最小化散列冲突,即便出现冲突,散列到每个槽里的数据也会比较平均,不会出现某个槽内数据特别多的情况。
  • 考虑关键字的长度、特点、分布、散列表的大小等其他因素

数据分析法
直接寻址法
平方取中法
折叠法
随机数法
。。。

装载因子

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

  • 静态数据
    没有频繁的插入和删除的静态数据集合。
  • 动态散列表
    数据集合是频繁变动的,事先无法预估将要加入的数据个数,无法事先申请一个足够大的散列表。随着数据慢慢加入,装载因子就会慢慢变大。当装载因子过大时,需要进行动态扩容。
    散列表的大小变了,数据的存储位置也变了,需要通过散列函数重新计算每个数据的存储位置。
    插入一个数据,最好情况下不需要扩容,最好时间复杂度为O(1);最坏情况下,散列表装载因子过高启动扩容,需要重新申请内存空间,重新计算哈希位置,并且搬移数据,时间复杂度为O(n)。用摊还分析法,均摊情况下,时间复杂度接近最好情况O(1)。
    随着数据的删除,散列表中的数据会越来越少,空闲空间会越来越多。如果对空间消耗非常敏感,可以在装载因子小于某个值之后,启动动态缩容。
    装载因子阈值的设置要权衡时间、空间复杂度。同事要避免数据的插入和删除导致频繁的扩容和缩容。

动态扩容

大部分情况下,动态扩容的散列表插入一个数据都很快,当装载银子啊已经达到阈值,需要先进行扩容,再插入数据,插入数据就会变得很慢。
解决一次性扩容耗时过多的情况,可以将扩容操作穿插在插入操作的过程中,分批完成。当装载时因子触达阈值之后,只申请新空间,但并不将老的数据搬移到新散列表中。
当新数据要插入时,将新数据插入新散列表中,并从老散列表中拿出一个数据放入到新散列表。每次插入一个数据到散列表,重复上述过程,避免了集中一次性数据搬移。任何情况下,插入一个数据的时间复杂度都是O(1)。
查询操作,兼容新、老散列表的数据,先从新散列表中查找,如果没有找到,再去老散列表中查找。

冲突解决

  • 开放寻址法
    数据量小、装载因子小。ThreadLocalMap

    优点:
    散列表的数据存储在数组中,可以有效利用CPU缓存加快查询速度,序列化起来比较简单。

    缺点:
    删除数据比较麻烦,需要特殊标记已经删掉的数据。所有的数据存储在一个数组中,冲突的代价高。装载因子的上限不能太大,浪费内存空间。

  • 链表法
    大对象、大数据量。LinkedHashMap

    优点:
    内存利用率高。链表结点可以在需要的时候再创建,并不需要事先申请好。
    对大装载因子的容忍度高。只要散列函数的值随机均匀,更大的装载因子也就是链表的长度变长了而已,虽然查找效率有所下降,但是比顺序查找快。

    缺点:
    链表要存储指针,对于比较小的对象的存储比较消耗内存。如果存储的是大对象,要存储的对象的大小远远大于一个指针的大小(4个字节或者8个字节),指针的内存消耗在大对象面前可以忽略。
    链表中的结点是零散分布在内存中的,不是连续的,对CPU缓存是不友好的,对执行效率有一定影响。

    将链表法中的链表改造为其他高效的动态数据结构:跳表、红黑树。极端情况下,所有的数据都散列到同一个桶内,查找的时间复杂度为O(logn),能避免散列碰撞攻击。

HashMap

  • 初始大小
    默认初始大小16。

  • 装载因子&动态扩容
    默认0.75。当hashMap中元素个数超过0.75*capacity的时候,就会启动自动扩容,每次扩容都会扩容为原来的两倍大小。

  • 散列冲突
    链表法。会出现拉链过长的情况,影响HashMap的性能。
    JDK1.8中,引入了红黑树,当链表长度太长(TREEIFY_THRESHOLD超过8且数组长度-哈希表容量MIN_TREEIFY_CAPACITY大于64)时,链表就转换为红黑树。当红黑树的结点个数小于6时(UNTREEIFY_THRESHOLD),会将红黑树转化为链表。避免频繁插入/删除导致数据结构频繁转换发生抖动。

  • 散列函数

    
    int hash(Object key) {
        int h = key.hashCode();
        return (h ^ (h >>> 16)) & (capicity -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;
    }

    hashcode是32位整型值,4294967296,获取对象的hashcode后,先进行移位运算,再和自己做异或运算=>hashcode ^ (hashcode >>> 16),将高16位移到16位,混合原哈希码的高位和低位,计算出来的整型值具有高位和低位的性质,并加大了低位的随机性。
    用hash表当前的容量减一,再和计算出来的整型值做位于运算A % B = A & (B - 1),当B是2的指数时等式成立。即(h ^ (h >>> 16)) & (capitity -1) = (h ^ (h >>> 16)) % capitity,即除留余数法保证位置分布均匀。