數據結構與算法--從平衡二叉樹(AVL)到紅黑樹

數據結構與算法--從平衡二叉樹(AVL)到紅黑樹

上節學習了二叉查找樹。算法的性能取決于樹的形狀,而樹的形狀取決于插入鍵的順序。在最好的情況下,n個結點的樹是完全平衡的,如下圖“最好情況”所示,此時樹的高度為?log2 n? + 1,所以時間復雜度為O(lg n)當我們將鍵以升序或者降序插入的時候,得到的是一棵斜樹,如下圖中的“最壞情況”,樹的高度為n,時間復雜度也變成了O(n)

在最壞情況下,二叉查找樹的查找和插入效率很低。為了解決這個問題,引出了平衡二叉樹(AVL)。

平衡二叉樹介紹

平衡二叉樹,首先是一棵二叉查找樹,但是它滿足一點重要的特性:每一個結點的左子樹和右子樹的高度差最多為1。這個高度差限制就完全規避了上述的最壞情況,因此查找、插入和刪除的時間復雜度都變成了O(lg n)。

為了反映每個結點的高度差,在二叉查找樹的結點中應該增加一個新的域——被稱為平衡因子(BF),它的值是某個根結點的左子樹深度減右子樹深度的值。易知,對于一棵平衡二叉樹,每個結點的平衡因子只可能是-1、0、1三種可能。

上圖中圖1和圖4是平衡樹。圖2根本不是二叉查找樹,因為59大于58卻是58的左子結點;圖3中結點58的左子樹高度為3而右子樹的高度為0,不滿足平衡二叉樹的定義。不過將圖3稍作改變,得到圖4,它就是一棵平衡二叉樹了。

將每個結點的平衡因子控制在-1、0、1三個值是靠一種稱為旋轉(Rolate)的操作保證的,視情況分為左旋轉右旋轉

如圖插入1的時候,發現根結點3的平衡因子變成了2(正數),對結點3進行右旋轉修正成上圖2的樣子。

而當插入5時,發現結點3的平衡因子為-2(負數),所以需要對結點3進行左旋轉修正成上圖5的樣子。

再看插入結點9的情況,結點7的平衡因子變成了-2,按理說應該對7進行左旋轉(上圖11),然而得到的確實圖11虛線框中的子樹,9位于10的右子結點這明顯就是錯的。究其原因,主要是因為不平衡結點7和它的子樹10的平衡因子符號相反(一正一負),這種情況出現在新結點插入在根結點的左孩子的右子樹、或者根結點的右孩子的左子樹。后者情況下(即上圖情況),需要先對根結點7的子結點10先作右旋轉處理再對根結點7進行左旋處理。再回頭看前兩種插入情況,都是在根結點的左孩子的的左子樹或者根結點的右孩子的右子樹上插入的,根結點的平衡因子符號和它子結點的平衡因子符號相同。

接下來看看這個旋轉處理是怎么用代碼表示的,以下所說的“根結點”指的是任意子樹的根。

public void rotateLeft(Node h) {
    Node x = h.right; // 根結點的右孩子保存為x
    h.right = x.left; // 根結點右孩子的左孩子掛到根結點的右孩子上
    x.left = h; // 根結點掛到根結點右孩子的左孩子上
    h = x; // 根結點的右孩子代替h稱為新的根結點
}

public void rotateRight(Node h) {
    Node x = h.left; // 根結點的左孩子保存為x
    h.left = x.right; // 根結點左孩子的右孩子掛到根結點的左孩子上
    x.right = h; // 根結點掛到根結點左孩子的右孩子上
    h = x; // 根結點的左孩子代替h稱為新的根結點
}

建議在紙上畫畫加深理解,其實旋轉操作沒那么難。

插入的話就是以下四種情況

  • 在根結點的左孩子的左子樹上插入,對根結點進行右旋轉。調用rotateRight
  • 在根結點的右孩子的右子樹上插入,對根結點進行左旋轉。調用rotateLeft
  • 在根結點的左孩子的右子樹上插入,先對根結點的左孩子進行左旋轉,再對根結點進行右旋轉。調用rotateLeft(h.left);rotateRight(h);
  • 在根結點的右孩子的左子樹上插入,先對根結點的右孩子進行右旋轉,再對根結點進行左旋轉。調用rotateRight(h.right);rotateLeft(h);

插入之后還要調整每個結點的平衡因子,看起來比較麻煩,代碼量不小。刪除操作也是比較麻煩。由于我們的重點在于講解紅黑樹,平衡查找樹只是拋磚引玉。所以對于平衡二叉樹的介紹就到此為止。

2-3樹介紹

為了保證查找樹的平衡性,我們允許樹中一個結點保存多個鍵 。標準二叉查找樹中的結點只能保存一個鍵,擁有兩條鏈接,這種結點被稱為2-結點;如果某個結點可以存儲兩個鍵,擁有3條鏈接,這種結點被稱為3-結點。

  • 2-結點,左鏈接指向的2-3樹中的鍵都小于該結點,右鏈接指向的2-3樹中的鍵都大于該結點。
  • 3-結點,左鏈接指向的2-3樹中的鍵都小于該結點,中鏈接指向的2-3樹中的鍵都位于該結點的兩個鍵之間,右鏈接指向的2-3樹中的鍵都大于該結點。

我們規定,一個2-結點要么擁有兩個子結點,要么沒有子結點;一個3-結點要么擁有三個子結點,要么沒有子結點。這樣的保證使得2-3樹的所有葉子結點位于同一層,也就是說所有葉子結點到根結點的路徑長度是一樣的,達到了所謂的完美平衡。如下是一棵2-3樹

2-3樹的查找

2-3樹的查找和標準的二叉查找樹如出一轍,只是多了在中鏈接的遞歸查找。具體來說:先將要查找的key與2-3樹的根結點比較,若和根結點中任意一個鍵相等則查找命中;否則,若key小于根結點中的較小鍵,在根結點的左子樹中遞歸查找;若key大于根結點中的較大者,在根結點的右子樹中遞歸查找;若key在根結點兩個鍵的之間,則在根結點的中子樹中遞歸查找...下面分別展示了查找成功和失敗的軌跡。

2-3樹的插入

插入操作,肯定是查找未命中時。如果未命中的查找結束于一個2-結點,直接插入到該結點中,使其變成3-結點就好了。可如果查找結束于一個3-結點該怎么辦呢?2-3樹中并不允許4-結點啊。

有幾種情況,我們一一來看。

向一棵只含有一個3-結點的樹中插入新鍵

考慮一種最簡單的情況,一棵2-3樹中只有一個3-結點,此時插入一個新鍵。我們可以這樣做:先讓該鍵暫時存放于3-結點中,隨即將3個鍵中排名中間的鍵向上移(因此樹的高度增加了1),左邊的鍵成為上移鍵的左子結點,右邊的鍵成為上移鍵的右子樹,最后這個臨時的4-結點被分解成了3個2-結點。如下圖

向一個父結點是2-結點的3-結點中插入新鍵

如果樹比較復雜,其實也沒關系,和上面的簡單情況是同樣的處理方法。

如圖,排名中間的鍵X上移和R合并稱為了3-結點。

向一個父結點是3-結點的3-結點插入新鍵

一樣的處理方法,無非就是再向上移,如下左圖所示,在樹的底部插入D,將排名中間的C上移和EJ合并成4-結點,繼續將排名中間的E上移,和根結點M合并成為3-結點。

如果到根結點還是4-結點呢,那就按照第一種情況處理——向一棵只含有3-結點的樹中插入新鍵,只需將4-結點分解成3個2-結點即可,同時樹的高度增加了1。

局部變換與全局性質

4-結點的分解是局部的:除了相關的結點和鏈接之外,樹的其他所有結點的狀態都不會被修改。即每次變換,不是整棵樹都變化了。下圖能比較直觀理解這種變換的局部性。

這些局部變換不會影響樹的全局有序性和平衡性:任意葉子結點到根結點的路徑長度都是相等的。

2-3樹的刪除

2-3樹的插入分好幾種情況,但還算不難理解。刪除操作的話就更難了。這里只介紹簡單的情況,刪除最小最大鍵。刪除任意鍵在紅黑樹中會有介紹。

如果要刪除的結點是一個3-結點,最簡單,直接刪除掉,因此3-結點變成了2結點。

如果刪除的是一個2-結點呢?

刪除最小鍵

先看最小鍵的刪除。如果當前要被刪除的結點是一個2-結點,那就想辦法把它變成一個3-結點或者4-結點,然后直接刪除即可。

如上圖中的5種變換:

  • 當前的結點左子結點和右子結點都是2-結點,見圖中第1、4種變換,它們是將這三個結點合并成了一個4-結點。
  • 當前結點的左子結點是2-結點,但是右子結點不是2-結點。見圖中第2、3種情況,它們的做法是從左子結點的兄弟結點中借一個最小的鍵到當前結點(它們的父結點),再將當前結點中最小的鍵移動到左子結點中。
  • 一旦要被刪除結點不是2-結點就可以執行刪除了,這保證了2-3樹的有序性和平衡性。見圖中第5種變換。

刪除最大鍵

和刪除最小鍵的處理方法類似。如下圖

也是當前結點的左右子結點都是2-結點就將這三個結點合并成4-結點,如圖左邊的combine siblings;當右子結點是2-結點,左子結點不是2-結點,那么從右子結點的兄弟結點中借一個最大結點到當前結點(它們的父結點),然后將當前結點中最大的鍵移動到右子結點,如圖中borrow from siblings。

紅黑樹

2-3樹理解不難,而且和平衡二叉樹比討論情況有所減少。而接下來介紹的左傾紅黑樹(Left leaning Red-Black Tree)就是為了用簡單的方法實現2-3樹,進一步減少討論的情況和代碼量。2-3樹中2-結點就是標準二叉查找樹中的結點,為了表達3-結點需要附加額外的信息。這里講的紅黑樹可能有別于常規的定義方法。接下來你會看到,我們在結點與結點的鏈接上著色(而不是著色結點)。左傾紅黑樹必須滿足以下幾點:

  • 紅鏈接均是紅鏈接,即不存在有某個右鏈接是紅色的,這可以保證更少的討論情況從而減少代碼量。
  • 沒有任何一個結點同時和兩條紅鏈接相連,也就是不允許連續的兩條紅鏈接、或者一個結點的左右鏈接都是紅色。
  • 該樹是完美黑色平衡的,也就是說任意葉子結點到根結點的路徑上黑色鏈接數量相同。
  • 根結點始終是黑色的。

我們將兩個用紅色鏈接相連的結點表示為一個3-結點。

如圖,加粗的黑線(沒找到彩圖...)是被著色為紅色的鏈接,a和b被紅鏈接相連,因此a、b其實是一個3-結點。

上圖是個彩圖了...同樣的我們可以定義4-結點:某結點的左右鏈接都是紅的,和這兩條紅鏈接相連的三個結點就是一個4-結點,這里只是提一下,左傾紅黑樹不會用到4-結點。下面我們如果提到“紅黑樹”那它指代就是“左傾紅黑樹”。

因此我們完全可以用附帶了顏色信息的二叉查找樹來表示2-3樹。而且標準二叉查找樹中的get(Key key)方法無需修改直接就能用于左傾紅黑樹!容易知道,紅黑樹既是二叉查找樹,又是2-3樹。因此它結合了兩者的優勢:二叉查找樹中高效的查找方法和2-3樹中高效的平衡插入算法。

看到一棵紅黑樹,如果將其直觀地表示成2-3樹呢?我們只需將所有左鏈接畫平,并將與紅鏈接相連的結點合并成一個3-結點即可。如下所示,加粗的黑色鏈接是紅鏈接

之前一直說鏈接的紅黑,表達的是指向某個結點的鏈接的顏色。

比如上圖中C、E之間的鏈接是紅色的,這條鏈接指向C,因此這條鏈接的顏色是屬于結點C的,我們也可以簡單地說“(指向)C結點(的鏈接)是紅色的”;那么對于結點J,指向它的鏈接顏色是黑的。葉子結點也有左右鏈接,雖然它們都是空,約定(指向null的)空鏈接的顏色是黑色的。如A的左子結點的鏈接顏色A.left.color = BLACK。哦對了,還有指向根結點的鏈接(雖然這么說很奇怪,因為事實上并沒有鏈接指向根結點,為了保持結點性質的一致性,我們還是這么叫了),上面左傾紅黑樹的定義中有說到其顏色必須是黑色的,因為根結點的左孩子有可能是紅鏈接,如果根結點也是紅鏈接,就違反了定義的第二條——沒有任何一個結點同時和兩條紅鏈接相連??傊厦嫣岬搅艘恍┘s定,這些都是為了我們實現時更加方便,所以在代碼中要時刻保證這些約定。

說了這么多,來試著用代碼實現吧。

package Chap8;

public class LLRB<Key, Value> {

    private static final boolean RED = true;
    private static final boolean BLACK = false;
    private Node root;

    private class Node {
        private Key key;
        private Value value;
        private Node left, right;
        private int N; // 結點計數器,以該結點為根的子樹結點總數
        private boolean color; // 指向該結點的鏈接顏色

        public Node(Key key, Value value, int N, boolean color) {
            this.key = key;
            this.value = value;
            this.N = N;
            this.color = color;
        }
    }

    public boolean isRed(Node x) {
        // 約定空鏈接為黑色
        if (x == null) {
            return BLACK;
        } else {
            return x.color == RED;
        }
    }
}

先給出了左傾紅黑樹的基本實現,在標準二叉查找樹中新增了color域,表示指向該結點的鏈接顏色。對應的isRed(Node x)判斷指向該結點的鏈接是不是紅色的,如果x == null表示這是條空鏈接,出于之前的約定,應該返回黑色。

旋轉

為了保證紅黑樹的特性——不存在右鏈接是紅色的、以及沒有任何一個結點同時和兩條紅鏈接相連,在對紅黑樹進行操作時,比如插入或者刪除,難免會出現紅色右鏈接或者連續的兩條紅鏈接,應該確保每次操作完成之前這些情況已經被修正。這種對鏈接顏色的修正靠的是一種稱為旋轉的操作完成的,和上述平衡樹中的旋轉操作基本類似,不過這里加入了對鏈接顏色信息的修正。

旋轉操作會改變紅鏈接的指向,比如一條紅色的右鏈接需要轉換為紅色的左鏈接,這個操作被稱為左旋轉,右旋轉和左旋轉是對稱的。如下圖所示。

上面兩張圖,從紅色右鏈接變到紅色左鏈接,是左旋轉

上面兩張圖,從紅色左鏈接變到紅色右鏈接,是右旋轉。

旋轉操作也是局部的,只會影響旋轉相關的結點,樹中其他結點不受影響,而且旋轉操作不會破壞整棵樹的有序性和平衡性,如圖中小于a、位于a和b之間、大于b這些大小關系在旋轉前后沒有改變!

由圖可寫出旋轉操作的實現

private Node rotateLeft(Node h) {
    Node x = h.right; // 根結點的右子結點保存為x
    // 其實就是h和x互換位置
    h.right = x.left; // 根結點的右子結點的左孩子掛到根結點的右子結點上
    x.left = h; // 根結點掛到根結點右子結點的左子結點上
    x.color = h.color; // 原來h是什么顏色,換過去的x也應該是什么顏色
    h.color = RED;     // 將紅色右鏈接變成紅色左鏈接,因此x是紅色的,h和x互換位置所以換過去的h也應該是RED
    x.N = h.N;  // x的結點數和h保持一致
    h.N = size(h.left) + size(h.right) + 1; // 這里不能用原x.N賦值給h.N,因為旋轉操作后原來x的子樹和現在h的子樹不一樣
    // 返回取代h位置的結點x,h = rotateLeft(Node h)就表示x取代了h
    return x;
}

private Node retateRight(Node h) {
    Node x = h.left;
    h.left = x.right;
    x.right = h;
    x.color = h.color;
    h.color = RED; // x原來是紅色的
    x.N = h.N;
    h.N = size(h.left) + size(h.right) + 1;

    return x;
}

查找和插入

查找操作直接使用標準二叉查找樹的get方法,改都不用改的。

// 非遞歸get
public Value get(Key key) {
    Node cur = root;
    while (cur != null) {
        int cmp = key.compareTo(cur.key);
        if (cmp < 0) {
        cur = cur.left;
        } else if (cmp > 0) {
        cur = cur.right;
        } else {
        return cur.value;
        }
    }
  return null;
}

插入就稍微麻煩一點了。由于紅黑樹也是2-3樹,所以插入情況請參考上述對2-3樹插入的探討。

向2-結點中插入新鍵

這是最簡單的情況了,按照2-3樹插入的思路,直接使這個2-3結點變成3-結點。對應到紅黑樹中,如果新鍵小于父結點,只需將該鍵掛到父結點的左邊且鏈接是紅色;如果新鍵大于父結點,只需將該鍵掛到老鍵的右邊且鏈接是紅色,但這就違反了紅黑樹的特性(右鏈接不能是紅色),因此上面的旋轉操作就派上用場了,只需對其進行左旋轉即可。

向3-結點中插入一個新鍵

如果樹只由一個3-結點構成。插入有三種情況,分別是新鍵最大插入到結點右邊、新鍵最小插入到結點的左邊、新鍵位于兩者之間插入到中間。

回憶2-3樹中往3-結點中插入的情況,我們的做法是先將新鍵存在一個臨時的4-結點中,然后將排名中間的鍵往上移,4-結點分解成了3個2-結點,同時樹高增加1。這在紅黑樹中很好實現,4-結點也就是一個結點擁有兩條紅色鏈接,至于排名中間的鍵上移,只需將鏈接的顏色反轉即可。如下是結點鏈接反色的示意圖

左圖是一個4-結點,通過將h的兩個子結點的顏色變成BLACK、將h變成RED就達到了上移的目的,而且4-結點正確地被分解成了三個2-結點,h變紅正好可以和上一層的2-結點合并成3-結點;或者和3-結點合并成4-結點后繼續執行分解操作,如此這般一直到遇到一個2-結點為止。這完全符合2-3樹中的插入操作!反轉結點鏈接顏色的代碼非常簡單,但是又相當重要,我們將看到,向3-結點中插入的種種情況最終都會轉換成上面的情況。

private void flipColors(Node h) {
    h.color = !h.color;
    h.left.color = !h.left.color;
    h.right.color = !h.right.color;
}

向一棵只有3-結點的樹中插入新鍵分以下三種情況:

  • 新鍵大于3-結點中的兩個鍵,那么直接連接到3-結點較大鍵的右鏈接且顏色為紅色。此時直接調用flipColors方法即可;
  • 新鍵小于3-結點中的兩個鍵,那么該鍵會連接到3-結點較小鍵的的左鏈接且顏色為紅色,此時出現了連續兩條的紅鏈接,是不允許的,通過右旋轉變成了情況1,再調用flipColors
  • 新鍵位于3-結點的兩個鍵之間,那么該鍵會鏈接到3-結點較小鍵的右鏈接上且顏色為紅色,此時出現紅色右鏈接,是不允許的,通過左旋轉修正后變成了情況2,于是右旋轉,變成情況1,最后調用flipColors.

如果是在樹底部的某個3-結點插入新鍵,有可能包含以上全部三種情況!

如果你回頭看各種情況的插入操作,我們總是用紅鏈接將新結點和它的父結點相連。這么做是為了符合2-3樹中各種插入情況。而且因為三種情況里有些情況會進行其他情況的處理,在實現時一定要注意處理的順序。比如情況3里包含了情況2和情況1的處理,情況2中包含了情況1的處理,那么在處理時應該先判斷情況3,再判斷情況2,最后判斷情況1。

總結一下:

  • 如果右子結點是紅色的而左子結點是黑色的,進行左旋轉,目的是將紅色右鏈接變成紅色左鏈接。
  • 如果右子結點是紅色的而左子結點是黑色的,進行左旋轉。
  • 如果左右子結點均為紅色,進行顏色反轉。

上面的表述按順序翻譯成代碼就可以實現put方法了。

它們互相轉換的關系如下圖所示

對了還有一點,顏色反轉有可能導致根結點的顏色也變成紅色,但是我們約定根結點總是黑色的。所以每次put操作后,記得手動將root.color置為黑色。

public void put(Key key, Value value) {
    root = put(root, key,value);
    // 保證根結點始終為黑色
    root.color = BLACK;
}

private Node put(Node h, Key key, Value value) {
    if (h == null) {
        return new Node(key, value, 1, RED);
    }
    int cmp = key.compareTo(h.key);
    if (cmp < 0) {
        h.left = put(h.left, key, value);
    } else if (cmp > 0){
        h.right = put(h.right, key, value);
    } else {
        h.value = value;
    }

    /*
     下面連續三個判斷是和標準二叉查找樹put方法不同的地方,目的是修正紅鏈接
     */
  // 如果右子結點是紅色的而左子結點是黑色的,進行左旋轉
  // 之后返回值賦給h是讓x取代原h的位置,不可少
    if (isRed(h.right) && !isRed(h.left)) {
        h = rotateLeft(h);
    }
    // 如果左子結點是紅色的且左子結點的左子節點是紅色的,進行右旋轉
    if (isRed(h.left) && isRed(h.left.left)) {
        h = rotateRight(h);
    }
    // 如果左右子結點均為紅色,進行顏色反轉
    if (isRed(h.left) && isRed(h.right)) {
        flipColors(h);
    }

    h.N = size(h.left) + size(h.right) + 1;
    return h;
}

刪除

紅黑樹的刪除和上面提到的2-3樹的刪除是一致的。對照著上述2-3樹刪除的各種情況來實現紅黑樹的刪除,理解起來就不那么復雜了。

還是先從簡單的入手。

刪除最小鍵

如果要刪除的是一個3-結點,那么直接刪除。如果要刪除的是一個2-結點,說明h.left == BLACK && h.left.left ==BLACK,逆向思考我們保證h.lefth.left.left任意一個是RED就說明要刪除的結點是一個3-結點,之后再刪除就簡單了。

如圖當前結點B,B.left = BLACK && B.left.left = BLACK,此時只需flipColor將ABC合并成4-結點即可執行刪除。

反轉顏色后使得h.left = RED

還有種更難的情況,在滿足上述兩個結點鏈接都是黑色的情況下,如果h.right.left = RED呢?如下,當前結點h = E

按照2-3樹刪除方法,應該從A的兄弟結點借一個最小鍵到當前結點,再將當前結點中最小鍵移到A中合并成一個3-結點,再執行刪除。

經過一系列的變換,從圖中可看出先是rotateRight(h.right),再rotateLeft(h),然后filpColors(h)最終使得h.left.left = RED。

其他情況如當前結點為D,D.left.left = RED,BC中可以直接刪除B。或者如果遞歸到了C是當前結點,C.left = RED也能直接刪除而無需其他操作。

在遞歸自頂而下的過程中,我們對若干結點都進行了顏色反轉及旋轉操作,這些操作都可能影響數的有序性和平衡性,所以在返回的自下而上的過程中,要對樹進行修正,修正的方法和put方法中的修正方法完全一樣,抽取出來作為一個方法,如下

private Node fixUp(Node h) {
    if (isRed(h.right) && !isRed(h.left)) {
        h = rotateLeft(h);
    }
    // 如果右子結點是紅色的而左子結點是黑色的,進行左旋轉
    if (isRed(h.left) && isRed(h.left.left)) {
        h = rotateRight(h);
    }
    // 如果左右子結點均為紅色,進行顏色反轉
    if (isRed(h.left) && isRed(h.right)) {
        flipColors(h);
    }

    h.N = size(h.left) + size(h.right) + 1;
    return h;
}

有了上面講解的基礎,實現deleteMin就不難了。

private Node moveRedLeft(Node h) {
    // 當此方法被調用時,h是紅色的,h.left和h.left.left都是黑色的
    // 整個方法結束后h.left或者h.left.left其中之一被變成RED
    flipColors(h);
    if (isRed(h.right.left)) {
        h.right = rotateRight(h.right);
        h = rotateLeft(h);
        flipColors(h);
    }
    return h;
}

public void deleteMin(Key key) {
    // 這里將root設置為紅色是為了和moveRedLeft里的處理一致
    // 即當前結點h是紅色的,其兩個子結點都是黑色的,在反色后,當前結點h變成黑色,而它的兩個子結點變成紅色
    if (!isRed(root.left) && !isRed(root.right)) {
        root.color = RED;
    }
    root = deleteMin(root, key);
    // 根結點只要不為空,刪除操作后保持始終是黑色的
    if (!isEmpty()) {
        root.color = BLACK;
    }
}

private Node deleteMin(Node h, Key key) {
    if (h.left == null) {
    // 不像標準二叉查找樹那樣返回h.right, 因為put方法就決定了h.left和h.right要么同時為空要么同時不為空
        return null;
    }
    // 合并成4-結點或者從兄弟結點中借一個過來
    if (!isRed(h.left) && !isRed(h.left.left)) {
        h = moveRedLeft(h);
    }

    h.left = deleteMin(h.left, key);
    // 返回時,自下而上地修正路徑上的結點
    return fixUp(h);
}

看個刪除最小鍵的例子。

刪除最大鍵

刪除最大鍵和刪除最小鍵是對稱的,但有些不一樣。刪除最小鍵在自頂向下的過程中保證h.left或者h.left.left為紅色,類似地刪除最大鍵在自頂向下的過程中要保證h.right或者h.right.right為紅色,但是我們定義紅黑樹是左傾的!這意味著紅色鏈接默認就是左鏈接,因此要使用刪除最小鍵的方法來達到刪除最大鍵的目的,必須在處理之前將紅色鏈接變成右鏈接(右旋轉操作),之后就和刪除最小鍵的處理對稱了。

當前結點h.right = BLACK && h.right.left = BLACK,反轉顏色。

滿足上述情況的同時如果h.left.left = RED,說明需要從兄弟結點中借一個鍵過來,為此還要進行下面的變換,最后h.right.right變成紅色。

實現如下

private Node moveRedRight(Node h) {
    flipColors(h);
    // 從兄弟結點借一個鍵
    if (isRed(h.left.left)) {
        h =rotateRight(h);
        flipColors(h);
    }
    return h;
}

public void deleteMax(Key key) {
    if (!isRed(root.left) && !isRed(root.right)) {
        root.color = RED;
    }
    root = deleteMax(root, key);
    if (!isEmpty()) {
        root.color = BLACK;
    }
}

private Node deleteMax(Node h, Key key) {
    // 為了和deleteMin對稱處理,先將紅色左鏈接轉換成紅色右鏈接
    // 轉換為紅色右鏈接是最先處理的!
    if (isRed(h.left)) {
        h = rotateRight(h);
    }
    // 這個判斷不能再上句之前,因為可能旋轉前h.right是null,旋轉后可就不是null了
    if (h.right == null) {
        return null;
    }
    // 這里條件中不是h.right.right,因為3-結點是左鏈接表示的
    if (!isRed(h.right) && !isRed(h.right.left)) {
        h = moveRedRight(h);
    }
    h.right = deleteMax(h.right, key);
    return fixUp(h);
}

來看兩個刪除最大鍵的例子,其中第一個例子刪除后就已經平衡,無需修正;第二個例子中在自下而上的過程中有修正。

上面的例子無修正。

上面的例子有修正。

刪除任意鍵

最難的方法。我自己也沒太明白就來介紹這個方法可能不太妥當,只好盡力說個大概。至于代碼中的控制流程(if-else的順序)為什么是那樣,本人也不理解。

public void delete(Key key) {
    if (!contains(key)) {
        return;
    }

    if (!isRed(root.left) && !isRed(root.right)) {
        root.color = RED;
    }

    root = delete(root, key);

    if (!isEmpty()) {
        root.color = BLACK;
    }
}

private Node delete(Node h, Key key) {
    if (key.compareTo(h.key) < 0) {
        if (!isRed(h.left) && !isRed(h.left.left)) {
        h = moveRedLeft(h);
        }
        h.left = delete(h.left, key);
    } else { // 要么在根結點或者右子樹,兩種情況包含在一起了
    // 要在右子樹處理,所以確保是紅色右鏈接
        if (isRed(h.left)) {
        h = rotateRight(h);
        }
      
        // 要刪除的結點在樹底
        if (key.compareTo(h.key) == 0 && (h.right == null)) {
        return null;
        }
        // 這個判斷必須在上個判斷之后,因為確保h.right不為空后才能調用h.right.left
        if (!isRed(h.right) && !isRed(h.right.left)) {
        h = moveRedRight(h);
        }
        // 要刪除的鍵不在樹底, 用它的后繼結點替代它后,刪除后繼結點
        if (key.compareTo(h.key) == 0) {
            Node x = min(h.right);
            h.key = x.key;
            h.value = x.value;
            h.right = deleteMin(h.right);
        // 沒有相等的鍵,在右子樹中遞歸
        } else {
        h.right = delete(h.right, key);
        }
    }
    // 自下而上的結點修正
    return fixUp(h);
}

公有delete方法中還是延續了deleteMin/deleteMax那一套,只是增加了判斷——如果key不在紅黑樹中,不進行任何操作直接返回?,F在看私有方法:

大概的思路是:從root開始查找,如果被刪除的鍵比根結點小,遞歸地在左子樹中查找;否則,被刪除的鍵和根結點相同或者比根結點大,這個條件分支是最難的地方。**進入else分支后,不管是不是和當前結點的鍵相同,首先就把紅色左鏈接轉換成紅色右鏈接,這之后才判斷當前結點的鍵是否和被刪除結點的鍵相同。 **被刪除的結點位置有兩種情況,在樹底和不在樹底,不在樹底時需要用它的后繼結點替代更新被刪除結點,之后再刪除后繼結點。兩種情況下鍵都不相同的話,就遞歸地在右子樹中查找。最后記得要自下而上地修正路徑上各個結點,保證刪除之后樹的有序性和平衡性。

看一個被刪除的鍵不在樹底的例子,如下圖刪除D。用D的后繼結點E替代了D的位置,之后刪除了E,最后修正結點顏色。

代碼中把“被刪除鍵和當前鍵相同”、“比當前鍵大”這兩種情況合并在一起討論了,我嘗試按照通常的思路,將這兩種情況分開,即else if (key.compareTo(h.key) == 0)else > 0;或者將if (!isRed(h.right) && !isRed(h.right.left))這個判斷放到最后一個else里面,結果在進行了幾次結點刪除后都會出錯。

按照上面的控制流程,執行刪除就不會出錯,不過如果你稍微改變下if-else語句的順序,在若干次刪除操作后就可能出現錯誤——多半是樹的平衡性被破壞了。

其他API

像min()/max()、select、rank、floor、ceiling和范圍查找等相關方法,不作任何修改,直接套用標準二叉查找樹的對應方法即可。


by @sunhaiyu

2017.10.21

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • 一. 算法之變,結構為宗 計算機在很多情況下被應用于檢索數據,比如航空和鐵路運輸業的航班信息和列車時刻表的查詢,都...
    Leesper閱讀 7,003評論 13 42
  • 數據結構與算法--二叉查找樹 上節中學習了基于鏈表的順序查找和有序數組的二分查找,其中前者在插入刪除時更有優勢,而...
    sunhaiyu閱讀 1,932評論 0 9
  • 前面介紹了基本的排序算法,排序通常是查找的前奏操作。這篇介紹基本的查找算法。 目錄: 1、符號表 2、順序查找 3...
    Alent閱讀 893評論 0 12
  • B樹的定義 一棵m階的B樹滿足下列條件: 樹中每個結點至多有m個孩子。 除根結點和葉子結點外,其它每個結點至少有m...
    文檔隨手記閱讀 13,336評論 0 25
  • 二叉搜索樹,平衡樹,B,b-,b+,b*,紅黑樹 二叉搜索樹 ? 1.所有非葉子結點至多擁有兩個兒子(Le...
    raincoffee閱讀 3,907評論 3 18