面試題:HashSet、TreeSet 和HashMap 的實現與原理


file

說下 TreeSet 和 HashSet 什么區別呢?

file

它們的區別點主要在他們的底層數據結構不同,HashSet 使用的是 HashMap 來實現,而 TreeSet 使用的是 TreeMap 來實現的。

file

哦?那你了解 HashMap 和 TreeMap 的區別嗎?

file

HashMap 是一個最常用的數據結構,它主要用於我們有通過固定值(key)獲取內容的場景,時間復雜度可以最快優化到 O(1) 哈,當然效果不好的時候時間復雜度是 O(logN) 或者O(n)。雖然固定值查找提高了速度,但是 HashMap 不能保證固定值,也就是 key 的順序,所以這個時候 TreeMap 就出現了,雖然它的查找、刪除、更新的時間復雜度都是 O(logN),但是他可以保證 key 的有序性。

file

恩恩,掌握的還不錯,那你和我說一下 HashMap 和 TreeMap 的底層實現有什么不同,才導致的他們有這么大的差異?

file

這個原因主要是它們底層用的實現不同,HashMap 使用的是數組(桶)和哈希的方式實現,巧妙通過 key 的哈希路由到每一個數組用於存放內容,這時候通過 key 獲取 value 的時間復雜度就是 O(1),當然因為 key 的哈希可能碰撞,所以就需要針對碰撞的時候做處理,HashMap 里面每一個數組(桶)里面存的其實是一個鏈表,key 的哈希沖突以后會追加到鏈表上面,這時候再通過 key 獲取 value 的時候時間復雜度就變成了 O(n),那么數據碰撞越來越多的時候豈不是查詢很慢?最后呢為了優化這個時間復雜度,HashMap 當一個 key 碰撞次數超過 TREEIFY THRESHOLD 的時候就會把鏈表轉換成紅黑樹,這樣雖然插入的時候也增加了時間復雜度,但是對於頻繁哈希碰撞的問題的查詢效率有很大的提高,使得查詢的時間復雜度變成了 O(logN)。

哈,說到紅黑樹就把 HashMap 和 TreeMap 聯系到了一起,因為 TreeMap 的底層實現就是紅黑樹。

file

恩恩,既然你說到了紅黑樹,那么我想問下為什么采用的是紅黑樹,而不是二叉樹搜索樹呢?

file

恩,通常情況當我們聽到二叉搜索樹的時候以為它是平衡樹,其實不是。它只是左子樹的值小於根節點,右子樹的值大於根節點,如果構建根節點以后插入的數據是有序的,那么構造出來的二叉搜索樹就不是平衡樹,而是一個鏈表,那么它的時間復雜度就是 O(n),如下圖。然而紅黑樹呢?就是通過每個節點標色的方式,每次更新數據以后再進行平衡,以此來保證其查找效率。

file

file

恩好的,那既然你說到了這里,那么你再展開說明一下它是怎么做到的每次插入都平衡。

file

恩,紅黑樹因為它每個節點都有黑色或者紅色兩種顏色,當然它也有一些特性,比如
1、根節點是黑色的
2、紅色節點的子節點必須是黑色並且父節點也是黑色,
3、任何一條路徑的黑色節點個數相同。

它通過這些特性再重新插入的時候做着色處理,配合左旋,右旋來達到最終的平衡。所以可以理解黑色紅色其實是為了更好的輔助平衡。當有了這個着色以后配合紅黑樹的性質,就可以定義出來一個平衡的公式如下,首先插入的元素必須是紅色,因為黑色破壞他的性質的幾率更大。

假定 X 是新插入的節點,P是父節點,Y是叔父節點,G是祖父節點,P 為 G 的左孩子
當 Y 為紅色 -> P、Y 變黑,G 變紅,X 變 G
當 Y 為黑色,X 是右孩子 -> 左旋 P,X 變 P
當 Y 為黑色,X 為左孩子 -> G 變紅,P變黑,右旋 G
當 P 為 G 的右孩子的時候,直接做鏡像操作即可。

https://mmbiz.qpic.cn/mmbiz_gif/zoa6DXqcuUib4mphsLL0YC5FKje7VMbbuQD2jAKl6k1tRgiaiaDDWMQO9qWqcib4TfNwc4m8tAasHh7U1yHomLWj2A/640?wx_fmt=gif&tp=webp&wxfrom=5&wx_lazy=1

當然這樣還是非常的晦澀,如果還是沒直觀,可以直接看一下下面的視頻講解版本。
https://www.bilibili.com/video/av23890827

file

提到了 Hash,那么如果我想自定義一個 Class 作為 key,那么應該注意什么呢?

file

我覺得應該是注意重寫 hashCode 和 equals 方法。以 put 方法為例,因為在 HashMap 內部 hash 的時候需要用到 hashCode,以此來判斷兩個 Class 實例的 hash 是否一致。equals 是用來在 hash 碰撞以后追加鏈表的時候比對看是否相同以便更新。

file

恩,那既然你提到了 hash 用到了 hashCode 方法,你來解釋一下 HashMap 里面的 hash 具體怎么實現的呢?

file

好的,這個我還專門研究過,打開 HashMap源碼,從 put 說起吧。它最先調用的是 hash 函數,然后和當前的 n - 1 做與運算得到 bucket 的下標,關鍵代碼如下:

(h = key.hashCode()) ^ (h >>> 16) // 338 行
tab[i = (n - 1) & hash]  // 692 行

具體邏輯,先是獲取 key 的 hashCode,然后把 hashCode 低位位移 16 位后和之前的 hashCode 做亦或運算得到最終的 hash 值, bucket 數量是有限的,所以需要和 n -1 與運算得到最終的下標

這么說可能比較晦澀,我簡單畫一個圖吧。以 key 是 “我是key” 為例,n 默認為 16,下圖是把十進制轉換為二進制進行計算。

String value = "我是key";
int hashCode = value.hashCode(); //817810155
int lowBit = hashCode >>> 16; //12478
int hash = hashCode ^ lowBit; //817822293
int index = (n - 1) & hash; //5

file

如圖,可以清楚的了解到 hash 的全過程,最后一步 (n -1) & hash 很好理解,就是為了把計算好的 hash 映射到所有的 bucket 槽位。那么 h ^ (h >>> 16) 是因為通常情況 bucket 的槽位很少,用於參與運算的只有 hashCode 低位,為了讓高位也可以參與運算,盡可能的在不影響性能的情況下避免沖突,所以做了一下高位右移 16 位然后亦或運算。

接下來的流程就相對比較好理解了,獲取到 index 以后沒有碰撞直接放入 bucket,如果碰撞了就追加到鏈表尾部,JDK8以前是頭部,JDK8是為了計算步長等於 8 的時候轉換為紅黑樹,所以每次都是遍歷鏈表插入到尾部。說到紅黑樹上次已經回答你 漫畫:HashSet 和 TreeSet 的實現與原理,最后如果 size 超過了 factor * capacity 就會 resize()。

file

那順便和我說說你理解的 resize 吧?

file

resize 就是自動擴容,當 size 達到閾值以后會擴容到原來的 2 倍,關鍵代碼 newCap = oldCap << 1。但是這里有一個非常巧妙的解決方法,因為擴容是擴充的 2 倍,n-1 轉換為二進制也就是高位變成了1,那么根據(n - 1) & hash 計算,如果 hash 高位是 1 那么新的 index 位置就是 oldIndex + 16,如果hash 的高位 是 0 ,那么 index 的位置就是原來的 oldIndex 的位置,這樣直接判斷高位就可以了,省去了重新計算hash。

file

通過 HashMap 源碼我們也可以清楚的看到,714-743 行:

file

關於HashMap擴容為什么總是2的次冪推薦閱讀:https://blog.csdn.net/u010841296/article/details/82832166

file

恩恩,掌握的還不錯嘛,對了,說了這么多 HashMap 終究不是線程安全的,那么怎么樣把它變成線程安全的你知道嗎?

file

有一個工具方法,java.util.Collections#synchronizedMap(Map<K,V> map),這個方法通過 synchronized 關鍵字使得 map 的每一個操作都變成了同步,這樣就可以做到線程安全了。

但是最好的還是使用ConcurrentHashMap

參考:
https://mp.weixin.qq.com/s/VKSMDFCA2RbCDQECmcvE8A

https://mp.weixin.qq.com/s/yqCcugeqzxCQD107tfXt0w


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM