• Hashmap的实现


    HashMap的数据结构

      数据结构中有数组和链表来实现对数据的存储,但这两者基本上是两个极端。

      在java编程语言中,最基本的结构就是两种,一个是数组,另外一个是模拟指针(引用),所有的数据结构都可以用这两个基本结构来构造的,HashMap也不例外。HashMap实际上是一个“链表的数组”的数据结构,每个元素存放链表头结点的数组,即数组和链表的结合体。

    数组

      数组存储区间是连续的,占用内存严重,故空间复杂的很大。但数组的二分查找时间复杂度小,为O(1);数组的特点是:寻址容易,插入和删除困难;

    链表

      链表存储区间离散,占用内存比较宽松,故空间复杂度很小,但时间复杂度很大,达O(N)。链表的特点是:寻址困难,插入和删除容易。

    哈希表

       那么我们能不能综合两者的特性,做出一种寻址容易,插入删除也容易的数据结构?答案是肯定的,这就是我们要提起的哈希表。哈希表((Hash table)既满足了数据的查找方便,同时不占用太多的内容空间,使用也十分方便。

      注:HashMap 是基于 HashTable 的一种数据结构,在普通哈希表的基础上,它支持多线程操作以及空的 key 和 value。

    哈希表有多种不同的实现方法,我接下来解释的是最常用的一种方法—— 拉链法,我们可以理解为“链表的数组” ,如图:

    这里写图片描述

    从上图我们可以发现哈希表HashMap底层就是一个数组结构(Entry<K,V>[] table),数组中的每一项又是一个链表。

    源代码如下

    transient Entry<K,V>[] table;   
      
    static class Entry<K,V> implements Map.Entry<K,V> {  
            final K key;  
            V value;  
            Entry<K,V> next;
            int hash;  
      
            /** 
             * Creates new entry. 
             */  
            Entry(int h, K k, V v, Entry<K,V> n) {  
                value = v;  
                next = n;  
                key = k;  
                hash = h;  
            }  
      
            public final K getKey() {  
                return key;  
            }  
      
            public final V getValue() {  
                return value;  
            }  
            .............  
    }  
    

    HashMap的性能参数:

    HashMap 包含如下几个构造器:

    • HashMap():构建一个初始容量为 16,负载因子为 0.75 的 HashMap。
    • HashMap(int initialCapacity):构建一个初始容量为 - initialCapacity,负载因子为 0.75 的 HashMap。
    • HashMap(int initialCapacity, float loadFactor):以指定初始容量、指定的负载因子创建一个 HashMap。
    • HashMap的基础构造器HashMap(int initialCapacity, float loadFactor)带有两个参数,它们是初始容量initialCapacity和加载因子loadFactor。
    • initialCapacity:HashMap的最大容量,即为底层数组的长度。
    • loadFactor:负载因子loadFactor定义为:散列表的实际元素数目(n)/ 散列表的容量(m)。

      负载因子衡量的是一个散列表的空间的使用程度,负载因子越大表示散列表的装填程度越高反之愈小。对于使用链表法的散列表来说,查找一个元素的平均时间是O(1+a),因此如果负载因子越大,对空间的利用更充分,然而后果是查找效率的降低;如果负载因子太小,那么散列表的数据将过于稀疏,对空间造成严重浪费。

      HashMap的实现中,通过threshold字段来判断HashMap的最大容量:

    	threshold = (int)(capacity * loadFactor); 
    

      结合负载因子的定义公式可知,threshold就是在此loadFactor和capacity对应下允许的最大元素数目,超过这个数目就重新resize,以降低实际的负载因子(也就是说虽然数组长度是capacity,但其扩容的临界值确是threshold)。默认的的负载因子0.75是对空间和时间效率的一个平衡选择。当容量超出此最大容量时, resize后的HashMap容量是容量的两倍:

    if (size++ >= threshold)   
        resize(2 * table.length); 
    

    哈希表的存储过程

    哈希表的存储过程如下:

      根据 key 计算出它的哈希值 h。

      假设箱子的个数为 n,那么这个键值对应该放在第 (h % n) 个箱子中。
      如果该箱子中已经有了键值对,就使用开放寻址法或者拉链法解决冲突。

      在使用拉链法解决哈希冲突时,每个箱子其实是一个链表,属于同一个箱子的所有键值对都会排列在链表中。

      哈希表还有一个重要的属性: 负载因子(load factor),它用来衡量哈希表的 空/满 程度,一定程度上也可以体现查询的效率,计算公式为:

    • 负载因子 = 总键值对数 / 箱子个数

      负载因子越大,意味着哈希表越满,越容易导致冲突,性能也就越低。因此,一般来说,当负载因子大于某个常数(可能是 1,或者 0.75 等)时,哈希表将自动扩容。

      哈希表在自动扩容时,一般会创建两倍于原来个数的箱子,因此即使 key 的哈希值不变,对箱子个数取余的结果也会发生改变,因此所有键值对的存放位置都有可能发生改变,这个过程也称为重哈希(rehash)

      哈希表的扩容并不总是能够有效解决负载因子过大的问题。假设所有 key 的哈希值都一样,那么即使扩容以后他们的位置也不会变化。虽然负载因子会降低,但实际存储在每个箱子中的链表长度并不发生改变,因此也就不能提高哈希表的查询性能。

      基于以上总结,细心的读者可能会发现哈希表的两个问题:

      如果哈希表中本来箱子就比较多,扩容时需要重新哈希并移动数据,性能影响较大。
      
      如果哈希函数设计不合理,哈希表在极端情况下会变成线性表,性能极低。
      我们分别通过 Java 和 Redis 的源码来理解以上问题,并看看他们的解决方案。

    HashMap的存取实现

      既然是线性数组,随机存取的原因是:这里HashMap用了一个小算法,大致是这样实现:

    // 存储时:
    int hash = key.hashCode(); // 这个hashCode方法这里不详述,只要理解每个key的hash是一个固定的int值
    int index = hash % Entry[].length;
    Entry[index] = value;
    
    // 取值时:
    int hash = key.hashCode();
    int index = hash % Entry[].length;
    return Entry[index];
    

    1)put

      疑问:如果两个key通过hash%Entry[].length得到的index相同,会不会有覆盖的危险?
      
      这里HashMap里面用到链式数据结构的一个概念。上面我们提到过Entry类里面有一个next属性,作用是指向下一个Entry。打个比方, 第一个键值对A进来,通过计算其key的hash得到的index=0,记做:Entry[0] = A。一会后又进来一个键值对B,通过计算其index也等于0,现在怎么办?HashMap会这样做:B.next = A,Entry[0] = B,如果又进来C,index也等于0,那么C.next = B,Entry[0] = C;这样我们发现index=0的地方其实存取了A,B,C三个键值对,他们通过next这个属性链接在一起。所以疑问不用担心。也就是说数组中存储的是最后插入的元素。到这里为止,HashMap的大致实现,我们应该已经清楚了。

    解决hash冲突的办法


    • 开放定址法(线性探测再散列,二次探测再散列,伪随机探测再散列)

    • 再哈希法

    • 链地址法

     建立一个公共溢出区

    public V put(K key, V value) {
            if (key == null)
                return putForNullKey(value); //null总是放在数组的第一个链表中
            int hash = hash(key.hashCode());
            int i = indexFor(hash, table.length);
            //遍历链表
            for (Entry<K,V> e = table[i]; e != null; e = e.next) {
                Object k;
                //如果key在链表中已存在,则替换为新value
                if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                    V oldValue = e.value;
                    e.value = value;
                    e.recordAccess(this);
                    return oldValue;
                }
            }
            modCount++;
            addEntry(hash, key, value, i);
            return null;
        }
    
     
    
    void addEntry(int hash, K key, V value, int bucketIndex) {
        Entry<K,V> e = table[bucketIndex];
        table[bucketIndex] = new Entry<K,V>(hash, key, value, e); //参数e, 是Entry.next
        //如果size超过threshold,则扩充table大小。再散列
        if (size++ >= threshold)
                resize(2 * table.length);
    }
    

      当然HashMap里面也包含一些优化方面的实现,这里也说一下。比如:Entry[]的长度一定后,随着map里面数据的越来越长,这样同一个index的链就会很长,会不会影响性能?HashMap里面设置一个因子,随着map的size越来越大,Entry[]会以一定的规则加长长度。

    get
     public V get(Object key) {
            if (key == null)
                return getForNullKey();
            int hash = hash(key.hashCode());
            //先定位到数组元素,再遍历该元素处的链表
            for (Entry<K,V> e = table[indexFor(hash, table.length)];
                 e != null;
                 e = e.next) {
                Object k;
                if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
                    return e.value;
            }
            return null;
    }
     
    

    3)null key的存取

       null key总是存放在Entry[]数组的第一个元素。

       private V putForNullKey(V value) {
            for (Entry<K,V> e = table[0]; e != null; e = e.next) {
                if (e.key == null) {
                    V oldValue = e.value;
                    e.value = value;
                    e.recordAccess(this);
                    return oldValue;
                }
            }
            modCount++;
            addEntry(0, null, value, 0);
            return null;
        }
     
        private V getForNullKey() {
            for (Entry<K,V> e = table[0]; e != null; e = e.next) {
                if (e.key == null)
                    return e.value;
            }
            return null;
        }
     
    

     
    4)确定数组index:hashcode % table.length取模
      
      HashMap存取时,都需要计算当前key应该对应Entry[]数组哪个元素,即计算数组下标;算法如下:

       /**
         * Returns index for hash code h.
         */
        static int indexFor(int h, int length) {
            return h & (length-1);
        }
    

      按位取并,作用上相当于取模mod或者取余%。
      这意味着数组下标相同,并不表示hashCode相同。

  • 相关阅读:
    Lua 学习之基础篇七<Lua Module,Package介绍>
    Lua 学习之基础篇六<Lua IO 库>
    Lua 学习之基础篇五<Lua OS 库>
    Lua 学习之基础篇四<Lua table(表)>
    Lua 学习之基础篇三<Lua 字符串操作>
    时空穿梭
    【集训队作业】line
    动态几何问题
    博弈论与概率统计
    wombats
  • 原文地址:https://www.cnblogs.com/yangliguo/p/7685430.html
Copyright © 2020-2023  润新知