【演算法】HashMap相關要點記錄

宋者為王發表於2020-10-31

        在刷leetcode的演算法題時,HashMap需要大量使用,而且也是面試的高頻問題。這裡記錄了HashMap一些增、刪、改、查的實現細節和時間複雜度,羅列了一些比較有用的方法,以及其它的一些細節。

 

1、底層資料結構
       HashMap在jdk1.7及之前的版本中,由陣列+連結串列的結構實現,從jdk1.8開始,由陣列+連結串列+紅黑樹的結構實現,這裡在jdk1.8的基礎上探討HashMap。
原始碼中維護了一個陣列:

1 transient Node<K,V>[] table;
2 static class Node<K,V> implements Map.Entry<K,V> {
3     final int hash;
4     final K key;
5     V value;
6     Node<K,V> next;
7 }

      這個陣列儲存的Node,就包含了我們put時的K與V,K的hash值,以及指向下一個節點的指標next。陣列中查詢節點的時間複雜度是O(1),但是插入、刪除的時間複雜度是O(n),所以執行插入和刪除操作比較耗時。HashMap中加入連結串列結構來解決這個問題。我們知道,解決hash衝突的一般方法有:開發地址法、二次hash法、拉鍊法等,這裡採用的就是拉鍊法,也就是這裡的陣列+連結串列結構了。查詢元素時,最好的情況是就在陣列中,時間複雜度為O(1),最壞的情況是在連結串列的末尾,時間複雜度是O(n)(當然,由於HashMap的擴容機制和良好的hash演算法,hash衝突發生得比較少);插入和刪除的時間複雜度就變成了O(1)了。

        jdk1.8加入了紅黑樹,當連結串列的長度達到8的時候就會由連結串列升維為紅黑樹,當紅黑樹減少到6時又由紅黑樹降到連結串列。這裡需要補充一點的是,紅黑樹的節點佔用的空間比連結串列要大,維護紅黑樹的空間成本比較大,但操作方便;而連結串列正好相反,所以這裡的8和6是一個平衡的值。在連結串列轉為紅黑樹時,還會判斷當前的Entry的數量是否小於64,小於64時會擴容,減少hash衝突,生成紅黑樹的可能性就小了很多。可見,只有當數量比較多時,維護紅黑樹的效率才比較明顯。

       紅黑樹的節點如下,實際上也Node的子類:

1 static final class TreeNode<K,V> extends LinkedHashMap.LinkedHashMapEntry<K,V> {
2      TreeNode<K,V> parent; // red-black tree links
3      TreeNode<K,V> left;
4      TreeNode<K,V> right;
5      TreeNode<K,V> prev; // needed to unlink next upon deletion
6      boolean red;
7 }

 

2、建構函式的選擇
      HashMap提供了4個建構函式,實際工作中可能會用到下面3個:

 1 public HashMap() {
 2      this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
 3 }
 4 public HashMap(int initialCapacity) {
 5      this(initialCapacity, DEFAULT_LOAD_FACTOR);
 6 }
 7 public HashMap(Map<? extends K, ? extends V> m) {
 8      this.loadFactor = DEFAULT_LOAD_FACTOR;
 9      putMapEntries(m, false);
10 }

這三個建構函式都使用了預設的擴容因子,

static final float DEFAULT_LOAD_FACTOR = 0.75f;

其值為0.75,當HashMap當前使用率達到整個容量(capacity)的75%時就會擴容。第一個建構函式使用得最頻繁,會分配預設大小的容量:

1 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

       第二個建構函式會指定初始容量,指定容量後通過計算,會分配比該初始值大的最近的2的n次方大小的容量,比如傳入的initialCapacity為12,實際上會分配16的容量,最大能分配的容量為;

1 static final int MAXIMUM_CAPACITY = 1 << 30;

       第三個可以用於複製指定的HashMap。由於擴容需要執行不少操作,所以肯定是會佔用一些資源的,如果平時開發比較明確需要使用多少容量,最好使用第二個,可以避免頻繁擴容影響效能。

3、元素的插入

      插入元素的方法是put(K,V),其基本步驟是:

  (1)根據Key算出hash值,(n-1)&hash來確定其在陣列中的index(這裡的n表示陣列的長度)

  (2)如果陣列的這個index位置為空,則直接插入,時間複雜度是O(1),如果達到擴容條件還會擴容。

  (3)如果陣列的這個index已經有值了,那就依次遍歷,比價Key來判斷是否已經存在,存在就修改該節點的Value,不存在就新建節點並插在鏈尾。
如果連結串列長度達到了8,此時會升維形成紅黑樹。如果還在連結串列階段,時間複雜度是O(1)+O(k),這裡O(1)是插入,O(k)是遍歷,由於不會超過8,所以也可以認為是O(1)。在形成紅黑樹時,還會判斷容量是否小於64,如果是,會擴容。

  (4)在第3步中,可能插入前已經是紅黑樹了,那就在紅黑樹中先查詢是否存在,存在則修改,不存在則新建並插入。這樣,時間複雜度是O(l)+O(logK)。所以綜合來看,可以理解為插入一個元素時時間複雜度最好是O(1),最壞是O(logn)


4、獲取元素
     獲取元素的方法是get(K),基本步驟是:
  (1)根據Key的hash值確定其在陣列中的index。
  (2)先判斷陣列的這個地方是否有節點,沒有則返回null。
  (3)如果有,則根據hash和Key判斷第一個節點是否為目標節點,是則返回其Value。否則繼續判斷,根據第一個節點是TreeNode例項來判斷當前是連結串列還是紅黑樹。 同樣根據hash值和Key來確定是否存在,存在則返回Value,否則返回null。所以時間複雜度也和插入時類似,最好時是O(1),最壞時是O(logn)。


5、刪除元素
       刪除元素的方法是remove(K),先和獲取元素一樣查詢該節點,刪除,然後調整結構。

 

6、Key為null時的處理
      HashMap的K和V均可以為null,當Key為null時有,其hash值定為0;

1 public V put(K key, V value) {
2      return putVal(hash(key), key, value, false, true);
3 }
4 static final int hash(Object key) {
5      int h;
6      return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
7 }

 

7、做演算法題時常用的方法

 1 Map<Object, Object> map = new HashMap<>();
 2 map.put(K,V); //存取KV對
 3 map.get(K); //如果不存在,則返回null
 4 map.getOrDefault(K,defaultValue); //相比get方法,會得到設定的預設值defaultValue。該方法很有用
 5 map.entrySet(); //獲取所有KV對的實體Set,其元素型別為Map.Entry<K, V>。HashMap中的Node,TreeNode都是其子類。
 6 map.keySet(); //獲取Key的集合Set
 7 map.values(); //獲取value的集合Collection,區別於Set
 8 map.containsKey(K); //判斷是否包含指定Key的Entry
 9 map.containsValue(V); //判斷是否包含指定Value的Entry
10 map.remove(K); //刪除指定Key的Entry
11 map.putAll(otherMap); //複製給定的map
12 map.size(); //Entry的數量
13 map.clear(); //清除所有Entry
14 map.isEmpty(); //判斷是否為空

相關閱讀

https://tech.meituan.com/2016/06/24/java-hashmap.html

相關文章