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

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. 可以表示为弱对象。
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,793评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,567评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,342评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,825评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,814评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,680评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,033评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,687评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,175评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,668评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,775评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,419评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,020评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,978评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,206评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,092评论 2 351
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,510评论 2 343

推荐阅读更多精彩内容