在刷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