Java TreeMap 原始碼解析

liujiacai發表於2015-09-16

上篇文章介紹完了HashMap,這篇文章開始介紹Map系列另一個比較重要的類TreeMap。 大家也許能感覺到,網路上介紹HashMap的文章比較多,但是介紹TreeMap反而不那麼多,這裡面是有原因:一方面HashMap的使用場景比較多;二是相對於HashMap來說,TreeMap所用到的資料結構更為複雜。 廢話不多說,進入正題。

簽名(signature)

public class TreeMap<K,V>
       extends AbstractMap<K,V>
       implements NavigableMap<K,V>, Cloneable, java.io.Serializable

可以看到,相比HashMap來說,TreeMap多繼承了一個介面NavigableMap,也就是這個介面,決定了TreeMap與HashMap的不同:

HashMap的key是無序的,TreeMap的key是有序的

介面NavigableMap

首先看下NavigableMap的簽名

public interface NavigableMap<K,V> extends SortedMap<K,V>

發現NavigableMap繼承了SortedMap,再看SortedMap的簽名

SortedMap

public interface SortedMap<K,V> extends Map<K,V>

SortedMap就像其名字那樣,說明這個Map是有序的。這個順序一般是指由Comparable介面提供的keys的自然序(natural ordering),或者也可以在建立SortedMap例項時,指定一個Comparator來決定。 當我們在用集合視角(collection views,與HashMap一樣,也是由entrySet、keySet與values方法提供)來迭代(iterate)一個SortedMap例項時會體現出key的順序。 這裡引申下關於Comparable與Comparator的區別(參考這裡):

  • Comparable一般表示類的自然序,比如定義一個Student類,學號為預設排序
  • Comparator一般表示類在某種場合下的特殊分類,需要定製化排序。比如現在想按照Student類的age來排序

插入SortedMap中的key的類類都必須繼承Comparable類(或指定一個comparator),這樣才能確定如何比較(通過k1.compareTo(k2)comparator.compare(k1, k2))兩個key,否則,在插入時,會報ClassCastException的異常。 此為,SortedMap中key的順序性應該與equals方法保持一致。也就是說k1.compareTo(k2)comparator.compare(k1, k2)為true時,k1.equals(k2)也應該為true。 介紹完了SortedMap,再來回到我們的NavigableMap上面來。 NavigableMap是JDK1.6新增的,在SortedMap的基礎上,增加了一些“導航方法”(navigation methods)來返回與搜尋目標最近的元素。例如下面這些方法:

  • lowerEntry,返回所有比給定Map.Entry小的元素
  • floorEntry,返回所有比給定Map.Entry小或相等的元素
  • ceilingEntry,返回所有比給定Map.Entry大或相等的元素
  • higherEntry,返回所有比給定Map.Entry大的元素

設計理念(design concept)

紅黑樹(Red–black tree)

TreeMap是用紅黑樹作為基礎實現的,紅黑樹是一種二叉搜尋樹,讓我們在一起回憶下二叉搜尋樹的一些性質

二叉搜尋樹

先看看二叉搜尋樹(binary search tree,BST)長什麼樣呢?

Java TreeMap 原始碼解析
二叉搜尋樹
相信大家對這個圖都不陌生,關鍵點是:

左子樹的值小於根節點,右子樹的值大於根節點。

二叉搜尋樹的優勢在於每進行一次判斷就是能將問題的規模減少一半,所以如果二叉搜尋樹是平衡的話,查詢元素的時間複雜度為log(n),也就是樹的高度。 我這裡想到一個比較嚴肅的問題,如果說二叉搜尋樹將問題規模減少了一半,那麼三叉搜尋樹不就將問題規模減少了三分之二,這不是更好嘛,以此類推,我們還可以有四叉搜尋樹,五叉搜尋樹……對於更一般的情況:

n個元素,K叉樹搜尋樹的K為多少時效率是最好的?K=2時嗎?

K 叉搜尋樹

如果大家按照我上面分析,很可能也陷入一個誤區,就是

三叉搜尋樹在將問題規模減少三分之二時,所需比較操作的次數是兩次(二叉搜尋樹再將問題規模減少一半時,只需要一次比較操作)

我們不能把這兩次給忽略了,對於更一般的情況:

n個元素,K叉樹搜尋樹需要的平均比較次數為k*log(n/k)

對於極端情況k=n時,K叉樹就轉化為了線性表了,複雜度也就是O(n)了,如果用數學角度來解這個問題,相當於:

n為固定值時,k取何值時,k*log(n/k)的取值最小?

k*log(n/k)根據對數的運算規則可以轉化為ln(n)*k/ln(k)ln(n)為常數,所以相當於取k/ln(k)的極小值。這個問題對於大一剛學高數的人來說再簡單不過了,我們這裡直接看結果

當k=e時,k/ln(k)取最小值。

自然數e的取值大約為2.718左右,可以看到二叉樹基本上就是這樣最優解了。在Nodejs的REPL中進行下面的操作

function foo(k) {return k/Math.log(k);}
> foo(2)
2.8853900817779268
> foo(3)
2.730717679880512
> foo(4)
2.8853900817779268
> foo(5)
3.1066746727980594

貌似k=3時比k=2時得到的結果還要小,那也就是說三叉搜尋樹應該比二叉搜尋樹更好些呀,但是為什麼二叉樹更流行呢?後來在萬能的stackoverflow上找到了答案,主旨如下:

現在的CPU可以針對二重邏輯(binary logic)的程式碼做優化,三重邏輯會被分解為多個二重邏輯。

這樣也就大概能理解為什麼二叉樹這麼流行了,就是因為進行一次比較操作,我們最多可以將問題規模減少一半。 好了這裡扯的有點遠了,我們再回到紅黑樹上來。

紅黑樹性質

先看看紅黑樹的樣子:

Java TreeMap 原始碼解析
紅黑樹示例
上圖是從wiki截來的,需要說明的一點是:

葉子節點為上圖中的NIL節點,國內一些教材中沒有這個NIL節點,我們在畫圖時有時也會省略這些NIL節點,但是我們需要明確,當我們說葉子節點時,指的就是這些NIL節點。

紅黑樹通過下面5條規則,保證了樹是平衡的:

  1. 樹的節點只有紅與黑兩種顏色
  2. 根節點為黑色的
  3. 葉子節點為黑色的
  4. 紅色節點的位元組點必定是黑色的
  5. 從任意一節點出發,到其後繼的葉子節點的路徑中,黑色節點的數目相同

滿足了上面5個條件後,就能夠保證:根節點到葉子節點的最長路徑不會大於根節點到葉子最短路徑的2倍。 其實這個很好理解,主要是用了性質4與5,這裡簡單說下:

假設根節點到葉子節點最短的路徑中,黑色節點數目為B,那麼根據性質5,根節點到葉子節點的最長路徑中,黑色節點數目也是B,最長的情況就是每兩個黑色節點中間有個紅色節點(也就是紅黑相間的情況),所以紅色節點最多為B-1個。這樣就能證明上面的結論了。

紅黑樹操作

Java TreeMap 原始碼解析
紅黑樹旋轉示例(沒有畫出NIL節點)
關於紅黑樹的插入、刪除、左旋、右旋這些操作,我覺得最好可以做到視覺化,文字表達比較繁瑣,我這裡就不在獻醜了,網上能找到的也比較多,像v_July_v的《教你透徹瞭解紅黑樹》。我這裡推薦個swf教學視訊(視訊為英文,大家不要害怕,重點是看圖??),7分鐘左右,大家可以參考。 這裡還有個互動式紅黑樹的視覺化網頁,大家可以上去自己操作操作,插入幾個節點,刪除幾個節點玩玩,看看左旋右旋是怎麼玩的。

原始碼剖析

由於紅黑樹的操作我這裡不說了,所以這裡基本上也就沒什麼原始碼可以講了,因為這裡面重要的演算法都是From CLR,這裡的CLR是指Cormen, Leiserson, Rivest,他們是演算法導論的作者,也就是說TreeMap裡面演算法都是參照演算法導論的虛擬碼。 因為紅黑樹是平衡的二叉搜尋樹,所以其put(包含update操作)、get、remove的時間複雜度都為log(n)

總結

到目前為止,TreeMap與HashMap的的實現算是都介紹完了,可以看到它們實現的不同,決定了它們應用場景的不同:

  • TreeMap的key是有序的,增刪改查操作的時間複雜度為O(log(n)),為了保證紅黑樹平衡,在必要時會進行旋轉
  • HashMap的key是無序的,增刪改查操作的時間複雜度為O(1),為了做到動態擴容,在必要時會進行resize。

另外,我這裡沒有解釋具體程式碼,難免有些標題黨了,請大家見諒,後面理解的更深刻了再來填坑。

相關文章