面試還在被紅-黑樹虐?看完這篇動圖文章輕鬆反虐面試官

網上有很多紅-黑樹的段子,很多人都說,紅-黑樹只會存在於段子裏,不會在面試中或者實際項目中讓你實現。來看看網友都是怎麼說的:

通常,如果有面試官問我紅黑數這種問題。 我一般扭頭就走。 不是因爲,這個職位用不到還問這個。 而是因爲。 我 tmd 真的不會啊 - -|||

很多人看着這個網友說的,感覺很扎心。別急,還有更扎心的:

這有什麼難的! Map map = new TreeMap(); 手動斜眼,已經寫完了

如果這樣,面試官一定也是一臉懵逼啊~ 不過也沒錯,TreeMap 內部的確就是用紅-黑樹實現的。學紅-黑樹不僅僅是用來應付面試官,武俠小說裏說:招式只是形式,要練神功,必須要懂心法。這篇文章就帶你慢慢撥開紅-黑樹的面紗,特別是文章中的動態圖會讓你很直觀的感受紅-黑樹的旋轉。當然咯,理解了這篇文章,面試也能輕鬆搞定啦~

我們知道,二叉搜索樹是個很好的數據結構,可以快速地找到一個給定關鍵字的數據項,並且可以快速地插入和刪除數據項。但是二叉搜索樹有個很麻煩的問題,如果樹中插入的是隨機數據,則執行效果很好,但如果插入的是有序或者逆序的數據,那麼二叉搜索樹的執行速度就變得很慢。因爲當插入數值有序時,二叉樹就是非平衡的了,排在一條線上,其實就變成了一個鏈表……它的快速查找、插入和刪除指定數據項的能力就喪失了。

爲了能以較快的時間 O(logN) 來搜索一棵樹,需要保證樹總是平衡的(或者至少大部分是平衡的),這就是說對樹中的每個節點在它左邊的後代數目和在它右邊的後代數目應該大致相等。紅-黑樹的就是這樣的一棵平衡樹,對一個要插入的數據項,插入例程要檢查會不會破壞樹的特徵,如果破壞了,程序就會進行糾正,根據需要改變樹的結構,從而保持樹的平衡。那麼紅-黑樹都有哪些特徵呢?

1. 紅-黑樹的特徵

它主要有兩個特徵: 1.節點都有顏色;2.在插入和刪除的過程中,要遵循保持這些顏色的不同排列的規則。首先第一個特徵很好解決,在節點類中添加一個數據字段,例如 boolean 型變量,以此來表示節點的顏色信息。第二個特徵比較複雜,紅-黑樹有它的幾個規則,如果遵循這些規則,那麼樹就是平衡的。紅-黑樹的主要規則如下:

  • 1.每個節點不是紅色就是黑色的;
  • 2.根節點總是黑色的;
  • 3.如果節點是紅色的,則它的子節點必須是黑色的(反之不一定);
  • 4.從根節點到葉節點或空子節點的每條路徑,必須包含相同數目的黑色節點(即相同的黑色高度)。

在紅-黑樹中插入的節點都是紅色的,這不是偶然的,因爲插入一個紅色節點比插入一個黑色節點違背紅-黑規則的可能性更小。原因是:插入黑色節點總會改變黑色高度(違背規則4),但是插入紅色節點只有一半的機會會違背規則3。另外違背規則3比違背規則4要更容易修正。當插入一個新的節點時,可能會破壞這種平衡性,那麼紅-黑樹是如何修正的呢?

2. 平衡性的修正

紅-黑樹主要通過三種方式對平衡進行修正,改變節點顏色、左旋和右旋。這看起來有點抽象,我們分別來介紹它們。

2.1 變色

改變節點顏色比較容易理解,因爲它違背了規則3。假設現在有個節點E,然後插入節點A和節點S,節點A在左子節點,S在右子節點,目前是平衡的。如果此時再插一個節點,那麼就出現了不平衡了,因爲紅色節點的子節點必須爲黑色,但是新插的節點是紅色的。所以這時候就必須改變節點顏色了。所以我們將根的兩個子節點從紅色變爲黑色(至於爲什麼都要變,下面插入的時候會詳細介紹),將父節點會從黑色變成紅色。可以用如下示意圖表示一下:

2.2 左旋

通常左旋操作用於將一個向右傾斜的紅色鏈接旋轉爲向左鏈接。示意圖如下:

左旋有個很萌萌噠的動態示意圖,可以方便理解:

.3 右旋

右旋可左旋剛好相反,這裏不再贅述,直接看示意圖:

當然咯,右旋也有個萌萌的動態圖:

這裏主要介紹了紅-黑樹對平衡的三種修正方式,大家有個感性的認識,那麼什麼時候該修正呢?什麼時候該用哪種修正呢?這將是接下來我們要探討的問題。

3. 紅-黑樹的操作

紅-黑樹的基本操作是添加、刪除和旋轉。對紅-黑樹進行添加或刪除後,可能會破壞其平衡性,會用到哪種旋轉方式去修正呢?我們首先對紅-黑樹的節點做一介紹,然後分別對左旋和右旋的具體實現做一分析,最後我們探討下紅-黑樹的具體操作。

3.1 紅-黑樹的節點

紅-黑樹是對二叉搜索樹的改進,所以其節點與二叉搜索樹是差不多的,只不過在它基礎上增加了一個boolean型變量來表示節點的顏色,具體看RBNode類:

public class RBNode<T extends Comparable<T>>{
    boolean color; //顏色
    T key; //關鍵字(鍵值)
    RBNode<T> left; //左子節點
    RBNode<T> right; //右子節點
    RBNode<T> parent; //父節點

    public RBNode(T key, boolean color, RBNode<T> parent, RBNode<T> left, RBNode<T> right) {
        this.key = key;
        this.color = color;
        this.parent = parent;
        this.left = left;
        this.right = right;
    }

    public T getKey() {
        return key;
    }

    public String toString() {
        return "" + key + (this.color == RED? "R" : "B");
    }
}

3.2 左旋的具體實現

上面對左旋的概念已經有了感性的認識了,這裏就不再贅述了,我們從下面的代碼中結合上面的示意圖,探討一下左旋的具體實現:

/*************對紅黑樹節點x進行左旋操作 ******************/
/*
 * 左旋示意圖:對節點x進行左旋
 *     p                       p
 *    /                       /
 *   x                       y
 *  / \                     / \
 * lx  y      ----->       x  ry
 *    / \                 / \
 *   ly ry               lx ly
 * 左旋做了三件事:
 * 1. 將y的左子節點賦給x的右子節點,並將x賦給y左子節點的父節點(y左子節點非空時)
 * 2. 將x的父節點p(非空時)賦給y的父節點,同時更新p的子節點爲y(左或右)
 * 3. 將y的左子節點設爲x,將x的父節點設爲y
 */
private void leftRotate(RBNode<T> x) {
    //1. 將y的左子節點賦給x的右子節點,並將x賦給y左子節點的父節點(y左子節點非空時)
    RBNode<T> y = x.right;
    x.right = y.left;

    if(y.left != null) 
        y.left.parent = x;

    //2. 將x的父節點p(非空時)賦給y的父節點,同時更新p的子節點爲y(左或右)
    y.parent = x.parent;

    if(x.parent == null) {
        this.root = y; //如果x的父節點爲空,則將y設爲父節點
    } else {
        if(x == x.parent.left) //如果x是左子節點
            x.parent.left = y; //則也將y設爲左子節點
        else
            x.parent.right = y;//否則將y設爲右子節點
    }

    //3. 將y的左子節點設爲x,將x的父節點設爲y
    y.left = x;
    x.parent = y;       
}

3.3 右旋具體實現

上面對右旋的概念已經有了感性的認識了,這裏也不再贅述了,我們從下面的代碼中結合上面的示意圖,探討一下右旋的具體實現:

/*************對紅黑樹節點y進行右旋操作 ******************/
/*
 * 左旋示意圖:對節點y進行右旋
 *        p                   p
 *       /                   /
 *      y                   x
 *     / \                 / \
 *    x  ry   ----->      lx  y
 *   / \                     / \
 * lx  rx                   rx ry
 * 右旋做了三件事:
 * 1. 將x的右子節點賦給y的左子節點,並將y賦給x右子節點的父節點(x右子節點非空時)
 * 2. 將y的父節點p(非空時)賦給x的父節點,同時更新p的子節點爲x(左或右)
 * 3. 將x的右子節點設爲y,將y的父節點設爲x
 */
private void rightRotate(RBNode<T> y) {
    //1. 將y的左子節點賦給x的右子節點,並將x賦給y左子節點的父節點(y左子節點非空時)
    RBNode<T> x = y.left;
    y.left = x.right;

    if(x.right != null) 
        x.right.parent = y;

    //2. 將x的父節點p(非空時)賦給y的父節點,同時更新p的子節點爲y(左或右)
    x.parent = y.parent;

    if(y.parent == null) {
        this.root = x; //如果x的父節點爲空,則將y設爲父節點
    } else {
        if(y == y.parent.right) //如果x是左子節點
            y.parent.right = x; //則也將y設爲左子節點
        else
            y.parent.left = x;//否則將y設爲右子節點
    }

    //3. 將y的左子節點設爲x,將x的父節點設爲y
    x.right = y;
    y.parent = x;       
}

3.4 插入操作

分析完了紅-黑樹中主要的旋轉操作,接下來我們開始分析常見的插入、刪除等操作了。這裏先分析插入操作。 由於紅-黑樹是二叉搜索樹的改進,所以插入操作的前半工作時相同的,即先找到待插入的位置,再將節點插入,先來看看插入的前半段代碼:

/*********************** 向紅黑樹中插入節點 **********************/
public void insert(T key) {
    RBNode<T> node = new RBNode<T>(key, RED, null, null, null);
    if(node != null) 
        insert(node);
}

//將節點插入到紅黑樹中,這個過程與二叉搜索樹是一樣的
private void insert(RBNode<T> node) {
    RBNode<T> current = null; //表示最後node的父節點
    RBNode<T> x = this.root; //用來向下搜索用的

    //1. 找到插入的位置
    while(x != null) {
        current = x;
        int cmp = node.key.compareTo(x.key);
        if(cmp < 0) 
            x = x.left;
        else
            x = x.right;
    }
    node.parent = current; //找到了位置,將當前current作爲node的父節點

    //2. 接下來判斷node是插在左子節點還是右子節點
    if(current != null) {
        int cmp = node.key.compareTo(current.key);
        if(cmp < 0)
            current.left = node;
        else
            current.right = node;
    } else {
        this.root = node;
    }

    //3. 將它重新修整爲一顆紅黑樹
    insertFixUp(node);
}

這與二叉搜索樹中實現的思路一模一樣,這裏不再贅述,主要看看方法裏面最後一步insertFixUp操作。因爲插入後可能會導致樹的不平衡,insertFixUp方法裏主要是分情況討論,分析何時變色,何時左旋,何時右旋。我們先從理論上分析具體的情況,然後再看insertFixUp方法的具體實現。

如果是第一次插入,由於原樹爲空,所以只會違反紅-黑樹的規則2,所以只要把根節點塗黑即可;如果插入節點的父節點是黑色的,那不會違背紅-黑樹的規則,什麼也不需要做;但是遇到如下三種情況時,我們就要開始變色和旋轉了:

  • 1.插入節點的父節點和其叔叔節點(祖父節點的另一個子節點)均爲紅色的;
  • 2.插入節點的父節點是紅色,叔叔節點是黑色,且插入節點是其父節點的右子節點;
  • 3.插入節點的父節點是紅色,叔叔節點是黑色,且插入節點是其父節點的左子節點。

下面我們先挨個分析這三種情況都需要如何操作,然後再給出實現代碼。

對於情況1:插入節點的父節點和其叔叔節點(祖父節點的另一個子節點)均爲紅色的。此時,肯定存在祖父節點,但是不知道父節點是其左子節點還是右子節點,但是由於對稱性,我們只要討論出一邊的情況,另一種情況自然也與之對應。這裏考慮父節點是祖父節點的左子節點的情況,如下左圖所示:

於這種情況,我們要做的操作有:將當前節點(4)的父節點(5)和叔叔節點(8)塗黑,將祖父節點(7)塗紅,變成上右圖所示的情況。再將當前節點指向其祖父節點,再次從新的當前節點開始算法(具體等下看下面的程序)。這樣上右圖就變成了情況2了。

對於情況2:插入節點的父節點是紅色,叔叔節點是黑色,且插入節點是其父節點的右子節點。我們要做的操作有:將當前節點(7)的父節點(2)作爲新的節點,以新的當前節點爲支點做左旋操作。完成後如左下圖所示,這樣左下圖就變成情況3了。

於情況3:插入節點的父節點是紅色,叔叔節點是黑色,且插入節點是其父節點的左子節點。我們要做的操作有:將當前節點的父節點(7)塗黑,將祖父節點(11)塗紅,在祖父節點爲支點做右旋操作。最後把根節點塗黑,整個紅-黑樹重新恢復了平衡,如右上圖所示。至此,插入操作完成!

我們可以看出,如果是從情況1開始發生的,必然會走完情況2和3,也就是說這是一整個流程,當然咯,實際中可能不一定會從情況1發生,如果從情況2開始發生,那再走個情況3即可完成調整,如果直接只要調整情況3,那麼前兩種情況均不需要調整了。故變色和旋轉之間的先後關係可以表示爲:變色->左旋->右旋。

至此,我們完成了全部的插入操作。下面我們看看insertFixUp方法中的具體實現(可以結合上面的分析圖,更加利與理解):

private void insertFixUp(RBNode<T> node) {  
    RBNode<T> parent, gparent; //定義父節點和祖父節點  

    //需要修整的條件:父節點存在,且父節點的顏色是紅色  
    while(((parent = parentOf(node)) != null) && isRed(parent)) {  
        gparent = parentOf(parent);//獲得祖父節點  

        //若父節點是祖父節點的左子節點,下面else與其相反  
        if(parent == gparent.left) {                  
            RBNode<T> uncle = gparent.right; //獲得叔叔節點  

            //case1: 叔叔節點也是紅色  
            if(uncle != null && isRed(uncle)) {  
                setBlack(parent); //把父節點和叔叔節點塗黑  
                setBlack(uncle);  
                setRed(gparent); //把祖父節點塗紅  
                node = gparent; //將位置放到祖父節點處  
                continue; //繼續while,重新判斷  
            }  

            //case2: 叔叔節點是黑色,且當前節點是右子節點  
            if(node == parent.right) {  
                leftRotate(parent); //從父節點處左旋  
                RBNode<T> tmp = parent; //然後將父節點和自己調換一下,爲下面右旋做準備  
                parent = node;  
                node = tmp;  
            }  

            //case3: 叔叔節點是黑色,且當前節點是左子節點  
            setBlack(parent);  
            setRed(gparent);  
            rightRotate(gparent);  
        } else { //若父節點是祖父節點的右子節點,與上面的完全相反,本質一樣的  
            RBNode<T> uncle = gparent.left;  

            //case1: 叔叔節點也是紅色  
            if(uncle != null & isRed(uncle)) {  
                setBlack(parent);  
                setBlack(uncle);  
                setRed(gparent);  
                node = gparent;  
                continue;  
            }  

            //case2: 叔叔節點是黑色的,且當前節點是左子節點  
            if(node == parent.left) {  
                rightRotate(parent);  
                RBNode<T> tmp = parent;  
                parent = node;  
                node = tmp;  
            }  

            //case3: 叔叔節點是黑色的,且當前節點是右子節點  
            setBlack(parent);  
            setRed(gparent);  
            leftRotate(gparent);  
        }  
    }  

    //將根節點設置爲黑色  
    setBlack(this.root);  
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章