字節跳動面試題:HashSet、TreeSet 和HashMap 的實現與原理

字節跳動面試題:HashSet、TreeSet 和HashMap 的實現與原理

說下 TreeSet 和 HashSet 什麼區別呢?

字節跳動面試題:HashSet、TreeSet 和HashMap 的實現與原理

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

字節跳動面試題:HashSet、TreeSet 和HashMap 的實現與原理

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

字節跳動面試題:HashSet、TreeSet 和HashMap 的實現與原理

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

字節跳動面試題:HashSet、TreeSet 和HashMap 的實現與原理

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

字節跳動面試題:HashSet、TreeSet 和HashMap 的實現與原理

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

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

字節跳動面試題:HashSet、TreeSet 和HashMap 的實現與原理

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

字節跳動面試題:HashSet、TreeSet 和HashMap 的實現與原理

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

字節跳動面試題:HashSet、TreeSet 和HashMap 的實現與原理

字節跳動面試題:HashSet、TreeSet 和HashMap 的實現與原理

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

字節跳動面試題:HashSet、TreeSet 和HashMap 的實現與原理

恩,紅黑樹因為它每個節點都有黑色或者紅色兩種顏色,當然它也有一些特性,比如

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_g...

當然這樣還是非常的晦澀,如果還是沒直觀,可以直接看一下下面的視頻講解版本。

https://www.bilibili.com/vide...

字節跳動面試題:HashSet、TreeSet 和HashMap 的實現與原理

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

字節跳動面試題:HashSet、TreeSet 和HashMap 的實現與原理

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

字節跳動面試題:HashSet、TreeSet 和HashMap 的實現與原理

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

字節跳動面試題:HashSet、TreeSet 和HashMap 的實現與原理

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

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

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

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

<code>String value = "我是key";
int hashCode = value.hashCode(); //817810155
int lowBit = hashCode >>> 16; //12478
int hash = hashCode ^ lowBit; //817822293
int index = (n - 1) & hash; //5/<code>
字節跳動面試題:HashSet、TreeSet 和HashMap 的實現與原理

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

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

字節跳動面試題:HashSet、TreeSet 和HashMap 的實現與原理

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

字節跳動面試題:HashSet、TreeSet 和HashMap 的實現與原理

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

字節跳動面試題:HashSet、TreeSet 和HashMap 的實現與原理

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

字節跳動面試題:HashSet、TreeSet 和HashMap 的實現與原理

關於HashMap擴容為什麼總是2的次冪推薦閱讀: https://blog.csdn.net/u010841...

字節跳動面試題:HashSet、TreeSet 和HashMap 的實現與原理

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

字節跳動面試題:HashSet、TreeSet 和HashMap 的實現與原理

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

但是最好的還是使用ConcurrentHashMap


分享到:


相關文章: