散列数据结构以及在HashMap中的应用

2021年6月28日 32点热度 0条评论 来源: zhenjungan

1. 为什么需要散列表?

对于线性表和链表而言,访问表中的元素,时间复杂度均为O(n)。即便是通过树结构存储数据,时间复杂度也为O(logn)。那么有没有一种方式可以将这个时间复杂度降为O(1)呢?当然有,这就是接下来要介绍的散列表散列表是普通数组概念的推广。由于对于普通数组只要知道其下标位置就可以使用O(1)的时间内访问任意元素,如果存储空间允许,我们可以提供一个足够大的数组,为每个可能的关键字保留一个位置,这个位置也被称之“”,从而可以充分的利用直接寻址的技术优势,其实就是典型的空间换时间。

2. 散列函数

       既然散列表是对关键字进行计算,从而确定该关键字对应的数据在存储中的位置,在下文中统一称之为“槽”,那么又该通过什么方式进行计算呢?其实这个方式就是散列函数。散列函数的设计对于散列表的性能将起到决定性的作用。因为如果散列函数设计不当导致多个关键字计算出的结果都是同一个位置,即存在大量的散列冲突(也可以称为散列碰撞)。现如今存在的散列函数算法非常多,通常的散列算法都是将关键字转换为自然数,然后通过除法或是乘法进行散列。一些简单的散列算法,比如关键字是整数直接使用求余法;关键字是字符串的话,一种可行的算法是每个字符的ASCII码相加之后对表的长度进行取模。对于同一类型的关键字的散列算法是多种多样的,但无论如何应该尽可能的避免散列冲突并且保证其散列的结果是均匀分布的。之所以要尽可能的保证散列结果是均匀分布其实也是为了尽可能的避免散列冲突。     

3.散列冲突以及冲突解决

       但是无论散列算法设计的多么完美,散列冲突它都是一定存在的。因为对于散列表的大小而言它是固定的,一旦你初始化之后就不会改变。但是对于元素而言是可以无限制的添加的,换句话说就是散列表中的“槽”位,对于关键字来说总归是不够的,所以就会出现多个关键字通过散列函数计算出的“槽”位是相同的。

       当散列冲突出现的时候,主要通过开放寻址法完全散列法分离链接法等其他算法解决冲突

1.开放寻址法

在开放寻址法中,散列表中的每个槽位最多只会存储一个元素。当出现散列冲突的时候,就会从该槽位出发选择一个方向(向前或是向后)开始探测,(每次探测的距离为1则称之为线性探查,距离为某个数字的平方则称之为平方探查)只要散列表足够大,总归是可以找到一个可以存储的槽位,但是如此花费的时间是相当多的。更糟糕的是,即使散列表相对较空这样占据的槽位一旦开始形成,当后面出现本应该放到该槽位的关键字由于已被占据,而不得不进行探测寻找可以存储的槽位,这种现象也被称之为聚集。除此之外可以采用双重散列法,使用一组散列函数,知道找到空闲的位置为止,一种比较流行的做法是使用两个相对独立的散列函数hash1(),hash2()。当发生碰撞时,通过步长i进行探测。

(hash1(key) + i * hash2(key)) % TABLE_SIZE

这种双散列如果hash2()设计的不好将会是灾难性的。一个好的hash2()表现好的特征是:1.不会产生0索引、2.可以探测整个散列表

2.分离链接法

在分离链接法中,散列表中出现冲突时,可以通过链表的方式将元素连接起来,在对元素进行访问时,若发现该槽位中是一个链表则对该链表进行遍历。此种分离方式并不只是仅限于链表,比如一颗树或是另一个散列表都是可以的。比如即将在下文中提到的HashMap就是使用链表+红黑树来实现的。

3.再散列

如果散列表很多槽位已经被占据,name操作的运行时间将开始消耗过长,且插入操作可能失败。此时一种解决方法是建立另外一个大约两倍大的表,扫描整个原始散列表计算每个元素的新的槽位并将其插入到新的表中,整个操作就被称为再散列。其实本质上就是通过扩容减少冲突。

4.完全散列法

虽然全域散列和完全散列具有良好的理论性能,但实现起来不太方便,前提条件也多。在实际应用上,往往会更偏向其他方式解决冲突。

4.动态扩容

       因为散列表在创建的时候其大小是固定的,而关键字是不断被添加到但列表中,所以随着关键字的不断添加,产生散列冲突的概率就会越来越大。因此为了避免哈希冲突就需要扩大散列表的容量。当已被占据的“槽”的个数和散列表的大小的比例达到一定的阈值时,就开始执行散列表的扩容,而这个阈值也被称之为加载因子(或扩容因子)。在扩容的时候,往往需要对原来的关键字重新进行散列,但是通过某些技巧其实是可以避免再散列的情况,比如HashMap的源码中在扩容的时候就没有进行再散列,这一部分在下文将详细讲解。

5.散列在HashMap的应用

1、散列函数

 1 public int hashCode() {
 2     int h = hash;
 3     if (h == 0 && value.length > 0) {
 4         char val[] = value;
 5         for (int i = 0; i < value.length; i++) {
 6             h = 31 * h + val[i];
 7         }
 8         hash = h;
 9     }
10     return h;
11 }

在这里为什么选择31作为乘数,为什么不是偶数或其他奇质数3,5,…,33,37,97…等其他数字? 原因如下:
1. 31 是一个奇质数,如果选择偶数会导致乘积运算时数据溢出,造成数据丢失;
2.哈希碰撞:实验数据表明乘数为大于等于31的奇质数碰撞概率很小,基本稳定;
3.哈希分布:实验数据表明乘数为大于等于31的奇质数哈希分布相对来说较为均匀。
4.另外在二进制中,2的5次方是32,那么也就是 31 * i == (i << 5) -i。这主要是说乘积运算可以使用位移提升性能,同时JVM 也会位移操作的优化

2、扰动函数

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

在这里HashMap并没有直接将key的散列值返回,而是进行了一次干扰计算

(h = key.hashCode()) ^ (h >>> 16)

把哈希值右移16位,也就是自己长度的一般,之后再与原哈希值进行异或运算。这样做的目的就是混合哈希值中的高位和低位增大随机性,使得哈希分布更加均匀,减少碰撞。

3、初始化容量

 1 static final int MAXIMUM_CAPACITY = 1 << 30;
 2 
 3 static final int tableSizeFor(int cap) {
 4     int n = cap - 1;
 5     n |= n >>> 1;
 6     n |= n >>> 2;
 7     n |= n >>> 4;
 8     n |= n >>> 8;
 9     n |= n >>> 16;
10     return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
11 
12 }

在这里进行初始化容量的时候,会不断进行或运算将二进制数都填上1,目的就是去寻找2的次幂的最小值。如传入的cap值为9则返回距离9最小的2的次幂值即16。那在这里为什么需要寻找2的次幂的最小值呢?

4、插入、链表树化 、红黑树

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

通过源码分析,HashMap增加元素的过程如下:
1. 如果散列表不存在或是其长度为0则进行一次扩容操作
2. 通过key的哈希值对散列表的长度进行与计算获得槽位
   2.1 若该槽位对应的元素为空
         直接添加一个节点,添加节点后需要判断是否超过负载阈值,超过则进行扩容。
   2.2 该槽位存在值
      2.2.1 判断key是否与当前的key一致
             一致时,修改该元素,然后返回旧值。
      2.2.2 判断该槽位对应的元素是否为树节点,这个树其实是一颗红黑树为树节点时,则进入putTreeVal()方法,这个方法要做的事简单的说就是“根据哈希值遍历树的结构,是否可以找到该key,若是可以找到就返回该节点,若是找不到就会新增的一个节点,并且平衡该树,最终返回一个空值”。putTreeVal()方法在新增节点的是后续返回null最终需要判断是否超过负载阈值,超过则进行扩容;修改节点时返回该节点数据,则将该树节点对应的值修改为当前的value并直接返回。
     2.2.3 说明这个槽位对应的元素是一个链表
为链表时,则先对链表进行遍历,是否可以找到该key,若可以找到则将该元素,则将该节点的值修改为value并退出;找不到该key时,说明这是一个新增元素,所以会在链表的尾部在添加一个节点。添加完节点后还需要判断该链表的长度是否超过了阈值(默认是8),超过阈值后并且表的大小还要超过64,则会将该链表进行转成二叉树,然后在转成红黑树,在转换成树的时候也会记录各节点的在链表中的位置;否则也只会对该散列表进行扩容。最终判断是否超过负载阈值,超过则进行扩容。

4、负载因子

 1 static final float DEFAULT_LOAD_FACTOR = 0.75f;
 2  
 3 public HashMap(int initialCapacity) {
 4     this(initialCapacity, DEFAULT_LOAD_FACTOR);
 5 }
 6  
 7 final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
 8     int s = m.size();
 9     if (s > 0) {
10         if (table == null) { // pre-size
11             float ft = ((float)s / loadFactor) + 1.0F; 12             int t = ((ft < (float)MAXIMUM_CAPACITY) ?  (int)ft : MAXIMUM_CAPACITY); 13             if (t > threshold) 14                 threshold = tableSizeFor(t); 15         }
16         else if (s > threshold) 17  resize(); 18         for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
19             K key = e.getKey();
20             V value = e.getValue();
21             putVal(hash(key), key, value, false, evict);
22         }
23     }
24 }

负载因子是关键字与散列表大小的比值,它决定了数据量达到多少之后进行扩容,默认的负载因子为0.75。如果希望以更多的空间换时间,尽量避免散列碰撞,则可以手动指定更小的负载因子。

5、扩容元素拆分

当数组长度不足时,或是当前关键字与散列表大小的比值超过了负载因子则进行散列表的扩容。在jdk1.7中,散列表扩容时,需要进行再散列的操作,重新计算各个key在新表中的槽位。而在jdk1.8中,扩容机制进行了优化,已经不需要进行再散列了,而是通过该key新的哈希值与原来的散列表进行与运算【key.hash()&oldCap==0】,如果为0,则不需要修改槽位,否则将该槽位移动到原来的位置+oldCap的位置,即【j+oldCap】。当红黑树扩容后的节点数小于 UNTREEIFY_THRESHOLD(默认是6)即小于7个节点数时,红黑树则会进行链化,因为链表在转成红黑树的时候,是有记录各节点在链表中的位置的,所以红黑树在转成链表的时候会相对简单很多。

6、查找

HashMap查找元素的过程如下:
  1. 通过散列函数计算并且扰动后的哈希值
  2. 若散列表为空或其大小为0,则直接返回null;
  3. 根据计算出的哈希值与散列表的大小-1做与运算获得槽位【在表中的下标索引】
  4. 该槽位的元素是否为树节点
    4.1 不为树节点,按链表形式进行遍历
    4.2 为树节点,则按照红黑树形式进行遍历

10、删除

HashMap删除元素的过程如下:
  1. 通过散列函数计算并且扰动后的哈希值
  2. 若散列表为空或其大小为0,则直接返回null;
  3. 根据计算出的哈希值与散列表的大小-1做与运算获得槽位【在表中的下标索引】
  4. 该槽位的元素是否为树节点
    4.1 不为树节点,按链表形式进行删除
    4.2 为树节点,则按照红黑树形式进行删除,删除之后会进行红黑树的平衡

    原文作者:zhenjungan
    原文地址: https://www.cnblogs.com/CoderGan/p/14942449.html
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系管理员进行删除。