一、2-3查找樹
二叉查找樹可以使用於大多數應用場景,但是最壞情況下性能太差。
本節將介紹一種二分查找樹,它的運行時間可以保證在對數級別內。
1、定義
這里引進3-節點的概念,3-節點含有兩個鍵和三個鏈接。
2-節點是標准二叉查找樹中的節點,含有一個鍵和兩個鏈接。
Definition.
A 2-3 search tree is a tree that either is empty or:
- A 2-node, with one key (and associated value) and two links, a left link to a 2-3 search tree with smaller keys, and a right link to a 2-3 search tree with larger keys
- A 3-node, with two keys (and associated values) and three links, a left link to a 2-3 search tree with smaller keys, a middle link to a 2-3 search tree with keys between the node's keys and a right link to a 2-3 search tree with larger keys.
示意圖:
一棵完美平衡的2-3查找樹中所有空鏈接到根節點的距離應該都是相同的。
后續將用2-3樹來指代完美平衡的2-3查找樹。
2、查找操作
將二叉查找樹的查找方法一般化即可得到2-3查找樹的查找方法。
(1)先將鍵值跟根節點中的鍵進行比較,如果和任意一個鍵值相等,則查找命中。
(2)否則,根據比較的結果,找到相應區間的鏈接,並在其指定的子樹中繼續查找。
(3)如果這是個空鏈接,那么查找失敗。
3、向2-節點中插入新鍵
要在2-3樹中插入一個新的節點,我們可以向二叉查找樹一樣先進性一次未命中查找,然后把新節點掛在樹的底部。
但是不能保證完美平衡性,需要在插入之后采取操作保持完美平衡性。
如果未命中的節點是2-節點,那么將這個鍵加入2-節點中組成3-節點。
如果未命中的節點是3-節點,那么需要采取更多措施。
4、向一棵只有一個3-節點的樹中插入新鍵
第一步先把新鍵臨時存到該節點中,使之成為4-節點,有三個鍵和4個鏈接。
然后將其分解成3個2-節點組成的2-3樹,含有中間鍵值的2-節點作為根節點,含有最小鍵值的2-節點跟根節點的左鏈接相連,含有最大鍵值的2-節點跟根節點的右鏈接相連。
插入鍵前樹的高度為0,插入后樹的高度為1,這個例子比較特殊,這是樹增長的特殊案例。
5、向一個父節點是2-節點的3-節點中插入新鍵
同樣,先將新鍵臨時保存到該節點中,組成4-節點並將其分解,但此時不會為中鍵創建一個新節點。
而是將中鍵移到父節點中,指向舊的3-節點的鏈接將被中鍵兩邊的兩條鏈接替代,並分別指向兩個新的2-節點。
這次轉換並不影響2-3樹的主要性質,樹仍然是有序的,仍然是完美平衡的(空鏈接到根節點的距離仍然相同)。
6、向一個父節點是3-節點的3-節點中插入新鍵
同樣,先將新鍵臨時保存到該節點中,組成4-節點再將其分解,將中鍵插入到父節點中,同時指向舊的3-節點的鏈接將被中鍵兩邊的兩條鏈接替代,父節點此時成為臨時的4-節點。
對新的4-節點進行同樣的處理,即分解4-節點並將中鍵插入到父節點中。
推廣到一般情況,我們就這樣一直向上不斷分解臨時的4-節點直到遇到一個2-節點,並將其替換成一個3-節點,或者一直到3-節點的根。
7、分解根節點
如果從插入節點到根節點一路都是3-節點,根節點最終將變成一個臨時的4-節點。
這種情況可以照向一棵只有一個3-節點的樹中插入新鍵的方法處理這個問題。
將根節點分解為3個2-節點,使得樹高加1。
8、局部變換
將2-3樹中的一個4-節點分解分解,有六種情況。4-節點是根節點,4-節點是2-節點的左子節點,4-節點是2-節點的右子節點,4-節點是3-節點左子節點,4-節點是3-節點中子節點,4-節點是3-節點右子節點。
2-3插入算法的根本是所有這些都是局部的,除了相關的節點和鏈接之外不必修改或者檢查樹的其他部分。
每一次變換中,變更的鏈接個數不會超過一個很小的常數。每個變換都會將4-節點中的一個鍵送入它的父節點並重構相應的鏈接而不用改樹的其他部分。
9、全局性質
上述局部變換不會影響樹的全局有序性和平衡性:任何空鏈接到根節點的路徑長度都是相等的。
10、分析
結論:在一棵大小為N的2-3樹中,查找和插入操作訪問的節點必然不超過logN個。
證明:一棵大小為N的2-3樹中,其高度在logN和log3N之間。
2-3樹在最壞情況下仍有較好的性能,查找和插入的性能被保證在對數時間內。
但是這樣直接實現2-3樹不是很現實,因為有很多情況需要處理。
二、紅黑二叉查找樹
上述所述的2-3樹插入算法並不難理解,但也不難實現。這里用一種簡單的紅黑二叉查找樹數據結構來表達並實現。
1、替換3-節點
紅黑二叉查找樹背后的基本思想是利用標准的二叉查找樹(完全由2-節點構成)再通過添加一些額外的信息來表示3-節點從而實現2-3樹。
紅黑二叉查找樹的鏈接有兩種類型,紅鏈接和黑鏈接。紅鏈接將兩個2-節點鏈接起來構成一個3-節點。黑鏈接是2-3樹中的普通鏈接。
更加准確地表達是,3-節點是由一條左斜的紅色鏈接相連的兩個2-節點組成的,即其中一個2-節點是另一個2-節點的左子節點。
這樣表示的優點就是,可以復用標准二叉查找樹的方法,如get等。
用這種方式表示2-3樹的二叉查找樹稱為紅黑二叉查找樹,簡稱紅黑樹。
2、等價定義
紅鏈接均為左鏈接
沒有任何節點能有兩個紅鏈接
該樹是完美黑色平衡的:即任意空鏈接到根節點的路徑上的黑鏈接數量相同
3、一一對應
紅黑樹>>2-3樹
如果我們將紅鏈接都展平,那么所有的空鏈接到根節點的距離都是相同的。
再將紅鏈接相連的兩個節點合並,那么就得到了一棵2-3樹。
2-3樹>>紅黑樹
如果將一棵2-3樹中3-節點畫作由紅色左鏈接相連的兩個2-節點,那么就不會存在有兩個紅鏈接的節點,而且樹是完美黑色平衡的。
無論我們采用何種方式定義,紅黑樹都既是二叉查找樹和2-3樹。
只要能在保持一一對應關系的基礎上實現2-3樹的插入算法,就能結合兩個算法的優點:二叉查找樹中簡潔高效的查找方法和2-3樹中高效的平衡插入算法。
4、顏色表示
將鏈接的顏色保存節點數據類型中,用布爾類型來存儲。
private class Node { private Key key; // key private Value val; // associated data private Node left, right; // links to left and right subtrees private boolean color; // color of parent link private int size; // subtree count public Node(Key key, Value val, boolean color, int size) { this.key = key; this.val = val; this.color = color; this.size = size; } }
這里約定空鏈接為黑色鏈接。
用兩個常量來設定紅黑鏈接。
private static final boolean RED = true; private static final boolean BLACK = false;
實現一個判斷鏈接是否紅色的便捷方法,測試該節點的父鏈接是否為紅色。
// is node x red; false if x is null ? private boolean isRed(Node x) { if (x == null) return false; return x.color == RED; }
5、旋轉
在我們實現某個操作時,我們可以暫時允許出現紅色右鏈接或者兩條連續的紅鏈接,但在完成這些操作前這些都需要被修復。
旋轉操作會改變紅鏈接的指向。
首先,假設有一個紅色右鏈接,我們需要對其進行左旋轉轉化成左鏈接。
左旋轉方法接受一條指向紅黑樹中的某個節點的鏈接h作為參數,並且默認這個節點的右鏈接是紅色的,左鏈接是黑色的。
會對其進行相應的調整,返回一個鏈接,指向同一組鍵的子樹,且其左鏈接為紅鏈接,右鏈接為黑鏈接。
上述操作很好理解,只是將較小者作為根節點改為用較大者作為根節點。
步驟如下:
先用x保存較大節點
把h的右鏈接替換為x的左鏈接
把x的左鏈接替換為h
這時x作為即將返回的根節點
此時還需要對指向h和x的鏈接進行顏色調整
指向x的鏈接顏色繼承原來指向h的顏色,指向h的顏色改為紅色
此時還要對子樹的size進行更改
x的size繼承舊的h的size,h的size則重新計算
右旋轉很好實現,只需要將左右對調過來即可,如下所示:
6、旋轉重置父節點的鏈接
無論是左旋轉還是右旋轉,旋轉操作都會返回一個鏈接,在旋轉返回后算法需要用這個返回值來重置父節點中的相應鏈接。
h = rotateLeft(h);
這個操作可能會導致兩條連續的紅鏈接,算法會繼續用旋轉操作修正這種情況。
在插入新鍵時,我們可以使用旋轉操作幫助我們保證2-3樹和紅黑樹之間的一一對應關系。因為旋轉操作可以保持紅黑樹的兩個重要性質:有序性和完美平衡性。
旋轉操作可以保持紅黑樹不存在兩條連續的紅鏈接和不存在紅色的右鏈接,下邊對各種情況進行分析。
7、向2-節點插入新鍵
向一棵只有一個2-節點的紅黑樹中插入一個新的節點(紅鏈接),如果新鍵小於2-節點中的鍵值,那么不用多余的操作。
如果新鍵大於2-節點中的鍵值,那么需要進行左旋轉。
root = rotateLeft(root)
對根節點進行左旋轉,並修正根節點的鏈接,圖解:
8、向樹底部2-節點中插入新鍵
用和二叉查找樹相同的方法向一棵紅黑樹中插入一個新鍵,將會在底部新增一個節點。
但總是用紅鏈接將新節點和父節點相連。如果父節點是一個2-節點,那么第7中的處理方法仍然適用。
如果指向新節點是父節點的左鏈接,那么父節點直接成為了一個3-節點。
如果指向新節點是父節點的右鏈接,那么需要進行一次左旋轉。
9、向一個3-節點中插入新鍵
這種情況可以分為三種情況,新鍵小於3-節點中的兩個鍵,新鍵在3-節點兩個鍵之間,新鍵比3-節點兩個鍵都要大。
這三種情況都會使一個節點同時鏈接到兩個紅鏈接。
最簡單的是新鍵比3-節點兩個鍵都要大的情況:
新節點被鏈接到3-節點的右鏈接,此時只需要將中鍵節點的兩個紅色鏈接改為黑色即可。
新鍵最小的情況:
新節點被鏈接到3-節點的左鏈接,產生了兩條連續的紅色鏈接。
此時只需對上層紅色鏈接右旋轉即可得到第一種情況。
新鍵在3-節點兩鍵之間的情況:
新節點鏈接到3-節點中的中間鏈接上,又產生了兩條連續的紅色鏈接。
此時將下層紅鏈接左旋轉即可得到第二種情況。
總的來說,最終得到的效果同2-3樹中的切分臨時4-節點。
轉換成第一種情況之后,對中鍵節點進行顏色變換操作。
顏色變換的函數如下:
// flip the colors of a node and its two children private void flipColors(Node h) { // h must have opposite color of its two children // assert (h != null) && (h.left != null) && (h.right != null); // assert (!isRed(h) && isRed(h.left) && isRed(h.right)) // || (isRed(h) && !isRed(h.left) && !isRed(h.right)); h.color = RED; h.left.color = BLACK; h.right.color = BLACK; }
這里相當於將4-節點切分為3個2-節點,並將中鍵插入到父節點中。
顏色變換這個操作跟旋轉操作一樣都是局部變換,不會影響整棵樹的黑色平衡性。
10、根節點總是為黑色
上述的顏色變換操作可能會使根節點鏈接顏色變為紅色,所以每次插入后都需要將根節點鏈接變為黑色。
11、紅鏈接在樹中向上傳遞
向樹底部3-節點中插入新鍵,都會導致紅鏈接向上傳遞,相當於將中鍵插入父節點中。
可以繼續對父節點采取同樣的修正操作,直到遇到2-節點或者根節點。
這種向上傳遞的處理,最好的實現方式是用遞歸的方法。
總之,只要准確地使用左旋轉、右旋轉和顏色變換這三種操作,就能保證插入操作后紅黑樹和2-3樹的一一對應關系。
在沿着插入點到根節點路徑向上移動時所經過的每個節點中順序完成以下操作,就能完成插入操作:
如果右子節點是紅色的,左子節點是黑色的,則進行左旋轉
如果左子節點是紅色的,並且它的左子節點也是紅色的,則進行右旋轉
如果左子節點和右子節點都是紅色的,那么進行顏色變換
三、實現
因為保持樹的平衡性的操作是自下而上的,植入已有的實現非常簡單,只需要在遞歸調用之后執行這些操作即可。
/** * Inserts the specified key-value pair into the symbol table, overwriting the old * value with the new value if the symbol table already contains the specified key. * Deletes the specified key (and its associated value) from this symbol table * if the specified value is {@code null}. * * @param key the key * @param val the value * @throws IllegalArgumentException if {@code key} is {@code null} */ public void put(Key key, Value val) { if (key == null) throw new IllegalArgumentException("first argument to put() is null"); if (val == null) { delete(key); return; } root = put(root, key, val); root.color = BLACK; // assert check(); } // insert the key-value pair in the subtree rooted at h private Node put(Node h, Key key, Value val) { if (h == null) return new Node(key, val, RED, 1); int cmp = key.compareTo(h.key); if (cmp < 0) h.left = put(h.left, key, val); else if (cmp > 0) h.right = put(h.right, key, val); else h.val = val; // fix-up any right-leaning links 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.size = size(h.left) + size(h.right) + 1; return h; }
上述中的三個操作,都可以相應用一個if語句來完成。
代碼相當簡潔,但是如果沒有2-3樹和紅黑樹相關數據結構只是作為鋪墊,這段實現將會非常難以理解。
四、刪除
紅黑樹的 deleteMin(),deleteMax()和delete()函數實現比較麻煩。
要更好地描述刪除算法,需要重回2-3樹。
和插入操作一樣,我們可以定義一系列局部變換來使刪除一個節點的同時保持樹的完美平衡性。
刪除操作過程比插入過程稍微復雜一點,因為算法不僅要在沿着查找路徑向下的同時進行構造臨時4-節點,還要在沿着查找路徑向上的同時進行分解臨時4-節點(同插入操作)。
1、自頂向下的2-3-4樹
作為第一輪熱身,我們先了解一個沿查找路徑既能向上也能向下進行變換的稍簡單的插入算法。
2-3-4樹插入算法:2-3-4樹允許4-節點的存在,沿查找路徑向下進行變換是為了保證當前節點不是4-節點,而沿查找路徑向上進行變換是為了分解4-節點。
這個算法的實現:只需要移動一行代碼即可。
/** * Inserts the specified key-value pair into the symbol table, overwriting the old * value with the new value if the symbol table already contains the specified key. * Deletes the specified key (and its associated value) from this symbol table * if the specified value is {@code null}. * * @param key the key * @param val the value * @throws IllegalArgumentException if {@code key} is {@code null} */ public void put(Key key, Value val) { if (key == null) throw new IllegalArgumentException("first argument to put() is null"); if (val == null) { delete(key); return; } root = put(root, key, val); root.color = BLACK; // assert check(); } // insert the key-value pair in the subtree rooted at h private Node put(Node h, Key key, Value val) { if (h == null) return new Node(key, val, RED, 1); if (isRed(h.left) && isRed(h.right)) flipColors(h); int cmp = key.compareTo(h.key); if (cmp < 0) h.left = put(h.left, key, val); else if (cmp > 0) h.right = put(h.right, key, val); else h.val = val; // fix-up any right-leaning links if (isRed(h.right) && !isRed(h.left)) h = rotateLeft(h); if (isRed(h.left) && isRed(h.left.left)) h = rotateRight(h); h.size = size(h.left) + size(h.right) + 1; return h; }
2、刪除最小鍵
作為第二輪熱身,我們分析2-3樹中刪除最小鍵的操作。
從樹的底部的3-節點刪除鍵時很簡單的,但2-節點則很難,因為刪除2-節點之后會留下一個空節點。這樣會破壞樹的完美平衡性。
為了保證算法不會刪除2-節點,算法在沿着查找路徑向下的同時,維持當前節點不是2-節點(3-節點或者臨時的4-節點)。
(1)根節點
有兩種情況:
第一種情況:根節點以及其兩個子節點都是2-節點,可以直接將這三個2-節點變為4-節點。
第二種情況:不是上述情況,有必要則從右側兄弟節點中“借”一個鍵。
(2)沿着路徑向下的時候
必須保持:
如果當前節點的左子節點不是2-節點,完成。
如果當前節點的左子節點是2-節點,且其臨近節點不是2-節點,則將其臨近節點中的一個鍵移到左子節點中。
如果當前節點的左子節點是2-節點,且其臨近節點也是2-節點,則將左子節點、父節點最小鍵、臨近節點組合成一個4-節點。父節點由3-節點變為2-節點或者由4-節點變為3-節點。
(3)底部
遍歷這個過程,最終能得到一個含有最小鍵的3-節點或者4-節點。最后把最小鍵刪除,再沿着查找路徑向上分解所有臨時4-節點。、
(4)實現
/** * Removes the smallest key and associated value from the symbol table. * @throws NoSuchElementException if the symbol table is empty */ public void deleteMin() { if (isEmpty()) throw new NoSuchElementException("BST underflow"); // if both children of root are black, set root to red if (!isRed(root.left) && !isRed(root.right)) root.color = RED; root = deleteMin(root); if (!isEmpty()) root.color = BLACK; // assert check(); } // delete the key-value pair with the minimum key rooted at h private Node deleteMin(Node h) { if (h.left == null) return null; if (!isRed(h.left) && !isRed(h.left.left)) h = moveRedLeft(h); h.left = deleteMin(h.left); return balance(h); } // Assuming that h is red and both h.left and h.left.left // are black, make h.left or one of its children red. private Node moveRedLeft(Node h) { // assert (h != null); // assert isRed(h) && !isRed(h.left) && !isRed(h.left.left); flipColors(h); if (isRed(h.right.left)) { h.right = rotateRight(h.right); h = rotateLeft(h); flipColors(h); } return h; } // restore red-black tree invariant private Node balance(Node h) { // assert (h != null); if (isRed(h.right)) h = rotateLeft(h); if (isRed(h.left) && isRed(h.left.left)) h = rotateRight(h); if (isRed(h.left) && isRed(h.right)) flipColors(h); h.size = size(h.left) + size(h.right) + 1; return h; }
要特別注意的是,flipColors(Node h)是將三條鏈接的顏色反轉,如下所示:
// flip the colors of a node and its two children private void flipColors(Node h) { // h must have opposite color of its two children // assert (h != null) && (h.left != null) && (h.right != null); // assert (!isRed(h) && isRed(h.left) && isRed(h.right)) // || (isRed(h) && !isRed(h.left) && !isRed(h.right)); h.color = !h.color; h.left.color = !h.left.color; h.right.color = !h.right.color; }
刪除操作這里,是將三個節點組合成臨時4-節點,跟插入操作剛好反過來。
將上述沿着查找路徑向下的分析中的2-3樹一一對應到紅黑樹,可以更好地理解這段代碼。
3、刪除最大鍵
同樣的道理,為了保證算法不會刪除2-節點,算法在沿着查找路徑向下的同時,維持當前節點不是2-節點(3-節點或者臨時的4-節點)。
/** * Removes the largest key and associated value from the symbol table. * @throws NoSuchElementException if the symbol table is empty */ public void deleteMax() { if (isEmpty()) throw new NoSuchElementException("BST underflow"); // if both children of root are black, set root to red if (!isRed(root.left) && !isRed(root.right)) root.color = RED; root = deleteMax(root); if (!isEmpty()) root.color = BLACK; // assert check(); } // delete the key-value pair with the maximum key rooted at h private Node deleteMax(Node h) { if (isRed(h.left)) h = rotateRight(h); if (h.right == null) return null; if (!isRed(h.right) && !isRed(h.right.left)) h = moveRedRight(h); h.right = deleteMax(h.right); return balance(h); }
// Assuming that h is red and both h.right and h.right.left // are black, make h.right or one of its children red. private Node moveRedRight(Node h) { // assert (h != null); // assert isRed(h) && !isRed(h.right) && !isRed(h.right.left); flipColors(h); if (isRed(h.left.left)) { h = rotateRight(h); flipColors(h); } return h; }
這里跟刪除最小鍵有點不同,因為我們認為規定了3-節點是紅鏈接左斜的。
4、刪除
在向下查找路徑中:
如果在左樹,跟刪除最小鍵一樣的變換操作可以保證當前節點不是2-節點。
如果在右樹,跟刪除最大鍵一樣的變換操作可以保證當前節點不是2-節點。
如果被查找的鍵在樹的底部,可以直接刪除。
如果不是在底部,算法需要將它和它的繼承節點交換(和經典二叉查找樹一樣),問題轉換為在一棵根節點不是2-節點的子樹中刪除最小鍵。
// delete the key-value pair with the given key rooted at h private Node delete(Node h, Key key) { // assert get(h, key) != null; 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; 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.val = x.val; // h.val = get(h.right, min(h.right).key); // h.key = min(h.right).key; h.right = deleteMin(h.right); } else h.right = delete(h.right, key); } return balance(h); }
五、性質分析
性質1:一棵大小為N的紅黑樹的高度不會超過2logN。
簡略證明:最壞情況是其最左邊的節點全部是3-節點,而其余均為2-節點。
性質2:一棵大小為N的紅黑樹中,根節點到任意節點的平均路徑長度為~1.00logN。
紅黑樹最復雜的代碼僅限於put和delete,二叉查找樹中的其余方法可以不用改動就可以使用。
紅黑樹保證了所有的操作運行時間都是對數級別的。