1. 程式人生 > >ConcurrentHashMap與紅黑樹實現分析Java8

ConcurrentHashMap與紅黑樹實現分析Java8

本文學習知識點

1、二叉查詢樹,以及二叉樹查詢帶來的問題。
2、平衡二叉樹及好處。
3、紅黑樹的定義及構造。
4、ConcurrentHashMap中紅黑樹的構造。

在正式分析紅黑樹之前,有必要了解紅黑樹的發展過程,請讀者耐心閱讀。

二叉查詢樹

紅黑樹的起源得從二叉查詢樹(二叉排序樹)說起。先來看二叉查詢樹的定義:

1、要麼為一顆空樹,要麼就是一顆具有如下特性的二叉樹。
2、左子節點的值必須小於等於父節點的值。
3、右子節點的值必須大於等於父節點的值。

每個節點都符合這個特性,所以易於查詢,如下圖:

二叉查詢樹-平衡

 

但二叉查詢樹會出現不平衡的情況,即左子樹和右子樹的深度相差很大,如果一顆二叉查詢樹,只有右子樹,就演變成一個連結串列了,查詢效率就變的很差,如下圖:

不平衡的二叉查詢樹

 

對於查詢而言,如果一棵二叉樹的高度是N,那麼最多可以在N步內完成查詢。也就是說,樹的高度要儘可能矮查詢才會更快。考慮到查詢的平均情況,葉子節點到根節點的距離不能差別太大,所以我們都希望二叉查詢樹是一顆矮胖樹,而不是一條鏈路的二叉樹。為了優化因深度的不穩定性對查詢效率的影響,於是就出現了平衡二叉樹。

時間複雜度
1.在一棵二叉查詢樹上,執行查詢、插入、刪除等操作,的時間複雜度為O(lgn)。因為,一棵由n個節點,隨機構造的二叉查詢樹的高度為lgn,所以順理成章,一般操作的執行時間為O(lgn)。至於n個節點的二叉樹高度為lgn的證明,可參考演算法導論 第12章 二叉查詢樹 第12.4節。


2.但若是一棵具有n個節點的線性鏈,則此些操作最壞情況執行時間為O(n)。

平衡二叉樹

定義:

1、要麼為一顆空樹,要麼就是一顆具有如下特性的二叉樹。
2、它的左子樹和右子樹都是平衡二叉樹。
3、它的左子樹和右子樹的深度差的絕對值不超過1。

兩顆平衡的二叉樹

在構造平衡二插樹時,失衡調整主要是通過旋轉最小失衡子樹來實現的。有必要弄清楚幾個概念:

1、平衡因子:左子樹的高度減去右子樹的高度。由平衡二叉樹的定義可知,平衡因子的取值只可能為0,1,-1.分別對應著左右子樹等高,左子樹比較高,右子樹比較高。
2、最小失衡子樹:在新插入的節點向上查詢,以第一個平衡因子的絕對值超過1的節點為根的子樹稱為最小失衡子樹。也就是說,一棵失衡的樹,是有可能有多棵子樹同時失衡的。而這個時候,我們只要調整最小的不平衡子樹,就能夠將不平衡的樹調整為平衡的樹。

在圖1中,例如插入節點5,那麼2節點(左子樹樹高-右子樹樹高)的的平衡因子為-2。同理,3節點的平衡因子也為-2。此時同時存在了兩棵不平衡子樹,但按節點5往上查詢,4節點的平衡因子為-1,3節點的平衡因子為-2,因此3節點是第一個最小的不平衡子樹。所以我們將以3節點為中心,將最小不平衡樹向左旋轉,即可得到平衡二叉樹,如圖2。

調整過程

 

平衡二叉樹失衡的全部調整過程和程式碼就不詳述了,重點在於描述紅黑樹的調整過程。

紅黑樹

紅黑樹是一種特殊的二叉查詢樹,在滿足二叉查詢樹的特性外,在每個節點上增加了儲存顏色的標識,顏色要麼是紅色,要麼是黑色,定義:

1、每個節點要麼是黑色,要麼是紅色。
2、根節點是黑色。
3、所有葉子節點是黑色,即空節點(NIL)。
4、如果一個節點是紅色的,則它的兩個子節點必須是黑色的,也就是父子節點不能都為紅色。
5、從一個節點到其所有葉子節點的所有路徑上包含相同數目的黑節點。

注意:
(1) 特性3中的葉子節點,是隻為空(NIL或null)的節點。
(2) 特性5,確保沒有一條路徑會比其他路徑長出倆倍。因而,紅黑樹是相對是接近平衡的二叉樹。因此在最壞情況下,紅黑樹能保證時間複雜度為O( lgn )

紅黑樹示意圖

樹的旋轉知識

當我們對紅黑樹進行插入和刪除操作時,對樹做了結構性修改,那麼可能會違背紅黑樹的5條性質。

為了保持紅黑樹的性質,我們可以通過對樹進行旋轉,即修改樹中某些節點的顏色及父子節點的指標結構,以維持紅黑樹的性質。
樹的旋轉,分為左旋右旋,以下藉助圖來做形象的解釋和介紹。

1、左旋

 

左旋


如上圖所示:要對節點X進行左旋,其右子節點必定不能為NULL。左旋以X到Y之間的鏈為“支軸”進行,它使Y成為該孩子樹新的根,而Y的左孩子β則成為X的右孩子。
再看個例項:

 

左旋例項

2、右旋

右旋和左旋類似,只看例項,理解一個就可以了:

 

右旋例項


左旋右旋總結
樹的旋轉,能保持不變的只有樹的二叉查詢性質,而原樹的紅黑性質則不能保持,在紅黑樹的資料插入和刪除後,可利用旋轉顏色重塗來恢復樹的紅黑性質。

 

3、紅黑樹的插入

向一棵含有n個節點的紅黑樹插入一個新節點的操作可以在O(lgn)時間內完成。
在繼續插入操作分析前,再來複習下紅黑樹的特性:

1、每個節點要麼是黑色,要麼是紅色。
2、根節點是黑色。
3、所有葉子節點是黑色,即空節點(NIL)。
4、如果一個節點是紅色的,則它的兩個子節點必須是黑色的,也就是父子節點不能都為紅色。
5、從一個節點到其所有葉子節點的所有路徑上包含相同數目的黑節點

規則約定
(1)在紅黑樹中插入節點時,節點的初始顏色都是紅色。因為這樣可以在插入過程中儘量避免對樹的結構進行調整(參考第5點性質)。
(2)初始插入按照二叉查詢樹的性質插入,即找到合適大小的節點,在其左邊或右邊插入子節點。

我們插入一個節點後,可能會使原樹的哪些性質改變呢?
(1)由於是以二叉查詢樹的性質插入,因此節點的查詢性質不會破壞。
(2)如果插入空樹中,成為根節點,則性質2會被破壞,需要重新塗色。
(3)如果插入節點的父節點是紅色,則性質4會被破壞,需要以插入的當前節點為中心進行旋轉或重新塗色來恢復紅黑樹的性質。執行旋轉或重新塗色後有可能紅黑樹仍然不滿足性質,則需要將當前節點變換回溯到其父節點或祖父節點,以父節點或祖父節點為中心繼續旋轉或重新塗色,如此迴圈到根節點直到滿足紅黑樹的性質。

恢復紅黑樹性質的策略
根據上面說到的性質改變,對應的恢復策略其實就簡單很多。
(1)把出現違背紅黑樹性質的結點向上移(通過旋轉操作或變換當前節點到父節點或祖父節點後再旋轉達到向上移動的目的),如果能移到根結點,那麼很容易就能通過直接修改根結點的顏色,或旋轉根節點來恢復紅黑樹的性質。
(2)旋轉或塗色處理可分5種情況進行處理。

情況1:空樹中插入根節點。
情況2:插入節點的父節點是黑色。
情況3:當前節點的父節點是紅色,且叔叔節點(祖父節點的另一個子節點)也是紅色。
情況4:當前節點的父節點是紅色,叔叔節點是黑色,當前節點是右子節點。
情況5:當前節點的父節點是紅色,叔叔節點是黑色,當前節點是左子節點。

情況1:空樹中插入根節點
違反:性質2
恢復策略:初始插入的節點均為紅色,因此簡單將紅色重塗為黑色即可。

情況2:插入節點的父節點是黑色
違反:插入的紅色節點,未違反任何性質。
恢復策略:什麼也不做,無需調整。

情況3:當前節點的父節點是紅色,且叔叔節點也是紅色
違反:性質4
此時祖父節點一定存在,否則插入前就已不是紅黑樹。
與此同時,又分為父節點是祖父節點的左子還是右子,由於對稱性,我們只要解開一個方向就可以了。在此,我們只考慮父節點為祖父左子的情況。
同時,還可以分為當前結點是其父結點的左子還是右子,但是處理方式是一樣的。我們將此歸為同一類。
恢復策略:將當前節點的父節點和叔叔節點塗黑,祖父結點塗紅,把當前結點指向祖父節點,以祖父節點為中心重新開始新一輪的旋轉或塗色。
以插入節點4為例,按照恢復策略,做如下圖的塗色:

 

情況3——塗色


以插入節點4為當前節點,判斷父節點和叔叔節點是否都為紅色,如果為紅色,則將祖父節點7的顏色改為紅色,父節點5和叔叔節點8的顏色改為黑色。同時當前節點移動到祖父節點7。此時,當前節點7的父節點也為紅色,出現父子節點都為紅色的情況,且叔叔節點為黑色,因此適用於情況4:當前節點的父節點是紅色,叔叔節點是黑色,當前節點是右子節點,那麼按照情況4的恢復策略,進行新一輪的旋轉或塗色,如下看情況4如何進行調整。

 

情況4:當前節點的父節點是紅色,叔叔節點是黑色,當前節點是右子節點
違反:性質4
恢復策略:以當前節點的父節點作為新的當前節點,以新的當前節點為支撐,進行左旋操作。旋轉操作後再按新的情況進行旋轉或塗色。

情況4——左旋

這裡作的操作為:當前節點由原來的7變換為其父節點2,以新的當前節點2,作左旋操作,如上圖。操作完成後,發現父子節點仍都是紅色,繼續進行旋轉或塗色。這裡適用於情況5:當前節點的父節點是紅色,叔叔節點是黑色,當前節點是左子節點來進行再次調整,請看下面的情況5如何進行調整。

情況5:當前節點的父節點是紅色,叔叔節點是黑色,當前節點是左子節點
違反:性質4
恢復策略:父節點改變為黑色,祖父節點改變為紅色,然後再以祖父節點為新的當前節點,做右旋操作。

情況5——塗色和旋轉

 

此時,樹已經滿足紅黑樹的性質,如果仍不滿足,則仍按照情況1——情況5的方式進行旋轉和重新塗色。

紅黑樹的刪除操作就不介紹了,塗色和旋轉和這個類似。如何刪除節點請看二叉查詢樹的刪除即可。

為什麼不用平衡二叉樹作為底層實現

那是因為平衡二叉是高度平衡的樹, 而每一次對樹的修改, 都要 rebalance, 這裡的開銷會比紅黑樹大. 如果插入一個node引起了樹的不平衡,平衡二叉樹和紅黑樹都是最多隻需要2次旋轉操作,即兩者都是O(1);但是在刪除node引起樹的不平衡時,最壞情況下,平衡二叉樹需要維護從被刪node到root這條路徑上所有node的平衡性,因此需要旋轉的量級O(logN),而紅黑樹最多隻需3次旋轉,只需要O(1)的複雜度, 所以平衡二叉樹需要rebalance的頻率會更高,因此紅黑樹在大量插入和刪除的場景下效率更高

ConcurrentHashMap二叉樹的構造過程

前面講了一大堆,終於來到ConcurrentHashMap二叉樹的構造過程了,構造過程和前面講的一樣。我們先分析原始碼,然後以一個實際的例子進行分析。

Java集合-ConcurrentHashMap工作原理和實現JDK8這篇文章中提到,連結串列的長度超過8時,會呼叫treeifyBin(tab , i)方法將連結串列結構轉換為紅黑樹。
先複習下ConcurrentHashMap中節點的型別和繼承關係:

ConcurrentHashMap幾個核心內部類關係圖


注意點:Node是連結串列中的元素,而TreeBin和TreeNode也繼承自Node節點,也自然繼承了next屬性,同樣擁有連結串列的性質,其實真正在儲存時,紅黑樹仍然是以連結串列形式儲存的,只是邏輯上TreeBin和TreeNode多了支援紅黑樹的root,first, parent,left,right,red屬性,在附加的屬性上進行邏輯上的引用和關聯,也就構造成了一顆樹。

 

所以理解了上面的紅黑樹其實也是一個連結串列,再來看原始碼就不難理解:

/**
 * Replaces all linked nodes in bin at given index unless table is
 * too small, in which case resizes instead.
 * @param tab table表
 * @param index 轉換為紅黑樹的連結串列在table中的索引下標
 */
private final void treeifyBin(Node<K,V>[] tab, int index) {
    Node<K,V> b; int n, sc;
    if (tab != null) {
        // 一開始並非直接轉換為紅黑樹,而是通過擴容table到2倍的方式,
        // 只有table的長度大於64之後,才會將超過8個元素的連結串列轉紅黑樹
        if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
            tryPresize(n << 1);
        // b.hash >= 0即為普通的Node連結串列節點
        else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
            synchronized (b) {// 鎖住連結串列頭
                if (tabAt(tab, index) == b) {
                    TreeNode<K,V> hd = null, tl = null;
                    // 將原Node連結串列轉換成以TreeBin節點為元素的連結串列
                    for (Node<K,V> e = b; e != null; e = e.next) {
                        TreeNode<K,V> p =
                            new TreeNode<K,V>(e.hash, e.key, e.val, null, null);
                        if ((p.prev = tl) == null)
                            hd = p;
                        else
                            tl.next = p;
                        tl = p;
                    }
                    // TreeBin的構造方法構造樹,根據TreeBin連結串列構造
                    setTabAt(tab, index, new TreeBin<K,V>(hd));
                }
            }
        }
    }
}

從原始碼可以看出,一開始並非直接轉換為紅黑樹,而是通過擴容table到2倍的方式,只有table的長度大於64之後,才會將超過8個元素的連結串列轉紅黑樹。紅黑樹的構造過程是在TreeBin的構造方法中完成的。

紅黑樹的構造過程

假設待構造的紅黑樹TreeNode連結串列如下,節點中的數值代表元素的hash值:

原始碼如下:

/**
 * Creates bin with initial set of nodes headed by b.
 */
TreeBin(TreeNode<K,V> b) {
    super(TREEBIN, null, null, null);
    this.first = b;
    TreeNode<K,V> r = null;
    // 遍歷TreeNode連結串列進行構造
    for (TreeNode<K,V> x = b, next; x != null; x = next) {
        next = (TreeNode<K,V>)x.next;
        x.left = x.right = null;
        if (r == null) {
            x.parent = null;
            x.red = false;
            r = x;
        }
        else {
            K k = x.key;
            int h = x.hash;
            Class<?> kc = null;
            for (TreeNode<K,V> p = r;;) {
                // 執行插入,dir為比對節點hash值大小的標識,決定插入時在左還是在右
                int dir, ph;
                K pk = p.key;
                if ((ph = p.hash) > h)
                    dir = -1;
                else if (ph < h)
                    dir = 1;
                else if ((kc == null &&
                          (kc = comparableClassFor(k)) == null) ||
                         (dir = compareComparables(kc, k, pk)) == 0)
                    dir = tieBreakOrder(k, pk);
                    TreeNode<K,V> xp = p;
                if ((p = (dir <= 0) ? p.left : p.right) == null) {
                    x.parent = xp;
                    if (dir <= 0)
                        xp.left = x;
                    else
                        xp.right = x;
                    // 插入後,執行恢復操作:重新塗色或旋轉
                    r = balanceInsertion(r, x);
                    break;
                }
            }
        }
    }
    this.root = r;
    assert checkInvariants(root);
}

原始碼中,balanceInsertion方法為恢復操作。所以根據上述原始碼和紅黑樹的恢復策略,依次遍歷連結串列節點插入到紅黑樹中,我們構造如下:

(1)節點80
第一個節點80,插入到空樹中,設定為根節點,併為黑色:

連結串列中紅色框節點表示已經完成插入紅黑樹

(2)節點60
節點60按二叉樹插入後,未違反任何紅黑樹的性質,不做任何動作。

紅黑樹中虛線框為當前節點

(3)節點50
節點50插入後,違反了性質4,按照情況5:當前節點的父節點是紅色,叔叔節點是黑色,當前節點是左子節點進行恢復。

節點50違反紅黑樹性質4


按照情況5的恢復策略調整如下:
把當前節點的父節點變為黑色,祖父節點變為紅色,將祖父節點更新為當前節點,以新的當前節點為支點進行右旋操作。

先塗色後恢復

 

(4)節點70
節點70插入後,違反紅黑樹性質5,按照情況3:當前節點的父節點是紅色,且叔叔節點也是紅色進行調整。

節點70違反紅黑樹性質4

 

調整如下,需要經過兩次塗色調整,將當前節點70的父節點和叔叔節點改為黑色,祖父節點改為紅色。由於祖父節點為根節點,根節點只能為黑色,因此在此將根節點改為黑色,調整完成。

 

塗色和再塗色


(5)節點20
節點20插入後未違反任何特性,無需調整。

 

節點20插入後未違反任何特性,無需調整

(6)節點65
節點65插入後違反性質4,按照情況5:當前節點的父節點是紅色,叔叔節點是黑色,當前節點是左子節點進行恢復。

節點65插入後違反性質4

 

恢復調整如下,需要經過兩個步驟,當前節點65的父節點改為黑色,祖父節點改為紅色,然後將祖父節點設為最新的當前節點。塗色後的新樹違反了性質5,因此還要以最新的當前節點為支點進行右旋操作:

 

塗色和右旋


(7)節點40
節點40插入後,違反紅黑樹性質4:父子節點不能都為紅色,插入後的紅黑樹見下圖:

 

插入節點40後,違反紅黑樹性質4:父子節點不能都為紅色

根據前文的調整策略,此處當前節點為紅色,叔叔節點NIL為黑色,且當前節點為右子節點,按情況4進行調整恢復:
步驟一:以當前節點40的父節點20為新的當前節點(見下圖1);
步驟二:以圖1中新的當前節點20為支點,左旋(見下圖2);

旋轉完成後,發現當前節點20和父節點40都為紅色,仍然違反了紅黑樹的性質4,需要繼續回溯當前節點再次旋轉或塗色。此時,當前節點是左子節點,按情況5進行調整恢復:
步驟一:將當前節點的父節點40重塗為黑色,祖父節點50重塗為紅色(見下圖3);得到的紅黑樹發現不滿足紅黑樹的性質5:從一個節點到其所有葉子節點的所有路徑上包含相同數目的黑節點,繼續執行步驟二的調整。
步驟二:以當前節點20的祖父節點50為新的當前節點,進行右旋(見下圖5);

到此,成功將節點40插入紅黑樹,滿足所有紅黑樹的性質。

(8)節點10
節點10插入後違反性質4,按照情況3:當前節點的父節點是紅色,且叔叔節點(祖父節點的另一個子節點)也是紅色進行恢復。

節點10插入後違反紅黑樹的性質4

恢復調整如下,當前節點10的父節點和叔叔節點改為黑色,祖父節點40重塗為紅色,調整就完成了:

父節點、叔叔節點、祖父節點重新塗色

至此,紅黑樹的構造完成。



作者:Misout
連結:https://www.jianshu.com/p/b7dda385f83d
來源:簡書
簡書著作權歸作者所有,任何形式的轉載都請聯絡作者獲得授權並註明出處。