美文网首页
数据结构与算法-java.util.HashMap源码分析

数据结构与算法-java.util.HashMap源码分析

作者: Shawn_Shawn | 来源:发表于2020-11-23 23:32 被阅读0次

    java.util.HashMap源码分析

    我刚开始找工作的时候,刷面试题,刷到最多的,同时确实也是面试官最喜欢的考察的一个知识点就是什么是HashMap,或者与Hashtable的区别等一系列相关的面试题。然而事实上这道题更多的还是考察对HashMap这个数据结构的知识点,那这个数据结构就是hash table。hash table是一个非常重要的数据结构,在日常coding中我们也会经常使用这样的数据结构,java中有HashMap,python中有字典,都是通过hash table实现的,所以学习hash table很重要。

    本篇主要从什么是hash table?如何实现一个工业级别的hashtable?以及hashtable的应用场景等方面讨论。

    什么是hash table?

    Hash table是通过一个hash函数来计算数据存储位置的数据结构,是一个键值对类型的数据结构,(key, value)。通常支持如下操作:

    • put(Key Value): 插入键值对(key,value)
    • get(key):如果存在键为key的键值对则返回其value,否则返回空值。
    • delete(key):删除键为key的键值对。

    hash table属于线性表,由一个直接寻址表和一个hash函数组成。hash函数hash(key)将key做为自变量,返回元素的存储下标,也就是直接寻址表的下标。

    我们回过头来,回想一下动态数组(列表)的插入,一般情况下咱都是直接调用list.add(value)方法实现的,所以都是追加到末尾的一种方式,那如果我们把key按照列表的方式插入,那就是直接插入到末尾,如果我们想要获取key,那也要从头开始遍历一遍,一个个key值比较,时间复杂度是O(n),即使这个key是数字,也插入的时候是有序插入的,使用二分查找算法,也需要O(logn)的时间复杂度。那有没有办法再进一步优化get(key)的时间复杂度呢?

    1. 直接寻址表

      当关键字的全域u比较小时,直接寻址时一种简单而有效的方法。

    hash-table-直接寻址表.png

    假设u的域为[0,9],那我们就建立一个长度为10的数组T。此时我们需要插入的key有(2,3,5,8),那我们就直接在下标为2,3,5,8里插入对应的key值即可。如果想获取key = 5的元素,只要返回T(5)的值即可,如果想要删除key = 8的元素,只要将T(8)置为空即可。

    直接寻址表的优点:

    实际上数组的优点就是直接寻址表的优点(其实也是hash table的优点)。

    • 根据key查找的时间复杂度为O(1)

    缺点:

    • 当U很大的时候,需要消耗大量的内存,很不实际。如果u的是一个[0, 100W]的域,但是key却只有很少的一部分,就会造成大量的空间被浪费。
    • 如果key不是数字,也不能使用直接寻址表。直接寻址表就是当key = k的时候,咱就直接把对应的value放入T(k)。
    1. 散列表

      直接寻址表是根据关键字k找到槽(k),并存入对应的对应的值。而散列表是根据关键字k通过一个hash函数(记为h(k))计算出槽的位置。这里的函数h将关键字的全域u映射到散列表的槽位上,那h(k)就是关键字k的散列值。

      假设u的域是[0,6],一个长度为7的数组。现需要插入的key有(2,14,19,24)。hash(key) = key%7。这样就是2对应的位置就是下标为2的位置,14对应的位置就是下标为0的位置,19对应的位置是下标为5,24对应的位置就是下标为3的位置。

    hash-table-散列表.png
    1. Hash函数

      散列表中关键的第一步就是根据hash函数,找出对应的槽位,那如何设计一个hash函数呢?本小节主要讨论就是如何设计一个hash函数。

      根据维基百科的定义:散列函数(英语:Hash function)又称散列算法、哈希函数,是一种从任何一种数据中创建小的数字“指纹”的方法。散列函数把消息或数据压缩成摘要,使得数据量变小,将数据的格式固定下来。该函数将数据打乱混合,重新创建一个叫做散列值(hash values,hash codes,hash sums,或hashes)的指纹。散列值通常用一个短的随机字母和数字组成的字符串来代表。那对于hash table来说,我们需要通过hash函数找到对应的槽位,所以这里的hash函数需要返回的散列值是一个自然数,即假设hash table的全域是一个自然数集(0,1,2,3,4,。。。。)

      设计hash函数的三个基本要求:

      1. 散列函数计算出来的散列值是一个自然数。
      2. 如果key1 == key2,则hash(key1) == hash(key2)
      3. 如果key1 != key2,则hash(key1) != hash(key2)

      分享几种常见的hash函数设计方法:

      1. 取模:h(k) = k mod m,比如上述案例就是通过取模散列,h(k) = k % 7。我们在选取m的值的时候需要注意尽量不要选择2或者靠近2的整数幂的素数。比如我们需要存储大约2000个字符串,散列函数可以设置为h(k) = k % 701
      2. 乘法hash法:h(k) = floor(m*(A*key%1))。用关键字k乘上常数A(0<A<1),并提取kA的小数部分,第二步,用m*这个值,再向下取整。
      3. 全域hash法:h(k) = ((a * key + b) mod p) mod m a,b = 1,2,....,p-1
    2. Hash冲突

      我们知道hash table都有一个全域u,这个全域可以看成是一个数组,数组的存储空间是有限的,然而我们需要存储的内容却是无限的。那这样就有可能会发生这样的情况,就是两个(多个)相同的key可能会被映射到同一个槽里,这种情形我们称之为hash冲突。在讨论hash函数的设计三要素的时候,第三点确实是很难完全保证,但是我们需要尽量保证,然而hash冲突确实无法完全避免,现业界里也是有方案去解决hash冲突的问题。

      解决方案:

      • 开放寻址法:如果hash函数返回的位置上已经有值,则可以向后探查新的位置来存储这个值。

        • 线性探查:如果位置i被占用,则探查i+1, i+2, ......,如果发现到数组的最后一位还没找到可以存储的位置,就从下标为0的位置上,继续寻找,直到找到为止。
        • 二次探查:如果位置i被占用,则探查i+1^2, i-1^2,i+2^2, i-2^2,....
        • 二度hash:有n个hash函数,当使用第一个hash函数发生冲突时,则尝试使用第二个hash。

        不管采用哪种探测方法,当散列表中空闲位置不多的时候,散列冲突的概率就会大大提高。为了尽可能保证散列表的操作效率,一般情况下,我们会尽可能保证散列表中有一定比例的空闲槽位。我们用装载因子来表示空位的多少。

        装载因子的计算公式是:

        loadFactor = 填入表中的元素个数 / 散列表的长度

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

        优点:

        1. 不需要像链表法一样,需要拉很多链表。
        2. 散列表中的数据都存储在数组中,可以有效地利用cpu缓存加快查询速度。
        3. 序列化简单

        缺点:

        1. 删除数据的时候比较麻烦,需要特殊标记已经删除的数据
        2. 比起拉链法更浪费内存空间,冲突的代价更高,装载因子的上限不能太大。

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

      • 拉链法:在散列表中,每个槽会对应一条链表,所有散列值相同的元素我们都放到相同槽位对应的链表中。当插入的时候,我们只需要通过散列函数计算出对应的散列槽位,将其插入到对应链表中即可,所以插入的时间复杂度是o(1)。当查找,删除一个元素的时候,我们同样通过散列函数计算出对应的槽,然后遍历链表查找或者删除,时间复杂度与链表的长度k成正比,也就是o(K)。

        基于链表的散列冲突处理方法比较适合存储大对象,大数据量的散列表,灵活,支持更多的优化策略,比如使用红黑树,跳表等数据结构替换链表。

    如何实现一个hashtable?

    针对散列表,当装载因子过大时,我们也可以进行动态扩容,重新申请一个更大的散列表,将数据搬移到这个新散列表中。每次扩容我们都申请一个原来散列表大小两倍的空间。如果原来散列表的装载因子时0.8,经过扩容以后就是0.4。扩容的时候需要注意,因为散列表的大小变了,数据的存储位置也变了,所以我们需要通过散列函数重新计算每个数据的存储位置。

    我们首先来分析一下java.util.HashMap的代码实现。

    1. 初始化大小HashMap的默认初始大小时16,也可以自己设置初始大小,如果事先知道大概的数据量大小,可以通过修改初始大小,减少动态扩容的次数,这样会大大提高HashMap的性能。

    2. 装载因子和动态扩容:装载因子默认是0.75,当HashMap中元素个数超过0.75*capacity(capacity表示散列表的容量)的时候,就会启动扩容,每次扩容都会扩容外原来的两倍大小。

    3. 链表法解决冲突:不管装载因子和散列函数设计的再合理,也免不了会出现链表过长的情况,一旦链表过长还是会影响性能。在jdk1.8引入红黑树,当链表的长度默认超过8时,链表就会转换为红黑树,当红黑树的结点个数少于8时,会退化为链表。

    4. hash函数

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

    设计一个散列表需要具备哪些特性?

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

    如何实现?

    1. 设计一个合理的hash函数;
    2. 定义装载因子,并且设计动态扩容策略;
    3. 选择合适的散列冲突解决方法。

    show me the code:仿java.util.HashMap

    public interface Map<K, V> {
    
      V put(K k, V v);
    
      V get(K k);
    
      Entry[] entry();
    
      interface Entry<K, V>{
        K getKey();
        V getValue();
    
        Entry<K, V> next();
      }
    
    }
    
    public class HashMap<K, V> implements Map<K, V> {
    
      // 默认的初始化大小
      private static final int DEFAULT_CAPACITY = 1 << 4;
    
      // 默认的装载因子
      private static final float DEFAULT_LOAD_FACTOR = 0.75f;
    
      private Node<K, V>[] table;
    
      // 实际元素大小
      private int size;
    
      private int use;
    
      private int capacity;
    
      private float loadFactor;
    
      public HashMap() {
        this(DEFAULT_CAPACITY, DEFAULT_LOAD_FACTOR);
      }
    
      public HashMap(int capacity, float loadFactor) {
        if (capacity < 0) {
          throw new IllegalArgumentException("Illegal capacity: " + capacity);
        }
        if (loadFactor <= 0 || Float.isNaN(loadFactor)) {
          throw new IllegalArgumentException("Illegal loadFactor: " + loadFactor);
        }
        this.capacity = capacity;
        this.loadFactor = loadFactor;
        table = new Node[capacity];
      }
    
      @Override
      public V put(K k, V v) {
        V oldValue = null;
        // 判断是否需要扩容?
        if (use >= capacity * loadFactor) {
          resize();
        }
        int index = hash(k);
        if (table[index] == null) { 
          table[index] = new Node<>(k, v, null);
        } else {
          Node<K, V> entry = table[index];
          Node<K, V> e = entry;
          while (e != null) {
            if (k == e.getKey() || k.equals(e.getKey())) {
              oldValue = e.value;
              e.value = v;
              return oldValue;
            }
            e = e.next;
          }
          table[index] = new Node<>(k, v, entry);
        }
        ++use;
        ++size;
        return oldValue;
      }
    
      private void resize() {
        capacity = capacity << 1;
        Node<K, V>[] newTable = new Node[capacity];
        use = 0;
        size = 0;
        rehash(newTable);
      }
    
      private void rehash(Node<K, V>[] newTable) {
        List<Node<K, V>> entryList = new ArrayList<Node<K, V>>();
        for (Node<K, V> entry : table) {
          if (entry != null) {
            do {
              entryList.add(entry);
              entry = entry.next;
            } while (entry != null);
          }
        }
    
        if (newTable.length > 0) {
          table = newTable;
        }
    
        for (Node<K, V> entry : entryList) {
          put(entry.getKey(), entry.getValue());
        }
      }
    
      @Override
      public V get(K k) {
        int index = hash(k);
        if (table[index] == null) {
          return null;
        } else {
          Entry<K, V> entry = table[index];
          do {
            if (k == entry.getKey() || k.equals(entry.getKey())) {
              return entry.getValue();
            }
            entry = entry.next();
          } while (entry != null);
        }
        return null;
      }
    
      @Override
      public Entry[] entry() {
        Node<K, V>[] tmp = new Node[size];
        int j = 0;
        for (int i = 0; i < capacity; i++) {
          if (table[i] != null) {
            tmp[j++] = table[i];
            if(j == size){
              break;
            }
          }
        }
        return tmp;
      }
    
      private static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
      }
    
      static class Node<K, V> implements Entry<K, V> {
    
        K key;
    
        V value;
    
        Node<K, V> next;
    
        Node(K key, V value, Node<K, V> next) {
          this.key = key;
          this.value = value;
          this.next = next;
        }
    
        @Override
        public K getKey() {
          return key;
        }
    
        @Override
        public V getValue() {
          return value;
        }
    
        @Override
        public Entry<K, V> next() {
          return next;
        }
      }
    }
    

    hashtable的应用场景

    1. 利用hashtable减少遍历层数,提高算法的时间复杂度。例如leetcode第一题two sum
    2. hashtable可以做为cache或者存储,hashtable + linkedlist可以实现LRU Cache。
    3. 可以表示为弱对象。

    相关文章

      网友评论

          本文标题:数据结构与算法-java.util.HashMap源码分析

          本文链接:https://www.haomeiwen.com/subject/ceubiktx.html