Outline — 點擊展開各節
10.1 最佳二元搜尋樹
10.2 AVL 樹
10.3 2-3 樹
10.4 / 10.5 2-3-4 樹與紅黑樹
10.6 B 樹
10.7 外張樹
10.8 / 10.9 數位搜尋結構
回顧
Chapter 10 · Search Structures

第 10 章 搜尋結構

全書最長的一章,也是所有線索收束的地方。第 5 章的二元搜尋樹最差是 $O(n)$;本章用六種方法把它壓回 $O(\log n)$ —— 靠旋轉(AVL)、靠分裂(2-3、2-3-4、B 樹)、靠染色(紅黑)、靠分攤(外張樹),或者乾脆不比較鍵值(trie)。

本章的六條路線
結構平衡的手段最差搜尋
10.1 最佳 BST已知機率時用動態規劃算出最佳形狀(靜態)最小化平均成本
10.2 AVL 樹旋轉(LL / LR / RR / RL),維持左右高度差 $\le 1$$O(\log n)$
10.3 2-3 樹節點分裂,所有外部節點同一階層$O(\log n)$
10.4 2-3-4 樹同上,但容許 4-節點 ⇒ 可以一趟完成插入$O(\log n)$
10.5 紅黑樹把 2-3-4 樹染色成二元樹$O(\log n)$
10.6 B 樹提高分支度 $m$ ⇒ 把樹壓扁,減少磁碟存取$O(\log_m n)$ 次磁碟存取
10.7 外張樹不維護平衡資訊,每次存取後旋轉到根分攤 $O(\log n)$
10.8/10.9 Trie不比較鍵值,按位元/字元分歧$O(\text{鍵值長度})$

留意 10.6 節的定位:它解決的是第 8 章 8.3 節同一個問題(大量資料在磁碟上),但走「保持有序」的路線。把兩節並讀,就是資料庫索引設計的全貌。

10.1 最佳二元搜尋樹

出發點:同樣的鍵值,不同的樹形,成本差很多

「考慮圖 10.2 的兩個搜尋樹。第二個樹最多需要 3 次的比較來決定所要找尋的識別字是否在樹中。第一個二元樹可能需要 4 次的比較,因為字母順序在 for 之後且在 void 之前的識別字須測試四個節點。所以,第二個二元樹比第一個樹有較好的最差狀況搜尋時間。

「在第一個樹中找尋一個識別字所需的比較次數,對 for 需要一次,dowhile 各需要二次,void 需三次,if 則需要四次。」

內部與外部路徑長度

內部路徑長度(internal path length)為根到所有內部節點的路徑長度的加總。……外部路徑長度(external path length)為根到所有外部節點的路徑長度的加總。」對圖 10.3(a) 的樹:

$$I = 0+1+1+2+3 = 7, \qquad E = 2+2+4+4+3+2 = 17$$

「習題 1 証明有 $n$ 個內部節點的二元樹之內部和外部路徑長度之關係式為 $E = I + 2n$。因此,有最大 $E$ 值的二元樹也會有最大的 $I$ 值。」

$I$ 的最大值與最小值

很明顯地,最差狀況發生在樹為傾斜的,即樹的深度為 $n$。在此情況下,」

$$I = \sum_{i=0}^{n-1} i = \frac{n(n-1)}{2}$$

「要找出具有最小 $I$ 值的樹,我們必須儘可能地讓愈多的內部節點靠近根節點。在距離 1 的階層可以有 2 個節點,距離 2 可以有 4 個,距離 3 可以有 8 個,以此類推。所以,$I$ 的最小值為 $0 + 2\cdot1 + 4\cdot2 + 8\cdot3 + \cdots$」

具有最小內部路徑長度是樹為完整二元樹,它定義於 5.2 節。如果以 5.2 節的方式對完整二元樹中的節點加以編號,則可發現節點 $i$ 與根節點的距離為 $\lfloor\log_2 i\rfloor$。因此,最小的 $I$ 值為:」

$$\sum_{i=1}^{n}\lfloor\log_2 i\rfloor = O(n\log_2 n)$$
範例 10.1 機率改變,最佳樹就改變

「圖 10.4 顯示識別字集合 $(a_1,a_2,a_3) = (\textbf{do}, \textbf{if}, \textbf{while})$ 所有可能的二元搜尋樹。」

等機率時($p_i = q_j = 1/7$)
cost(tree a) = 15/7    cost(tree b) = 13/7
cost(tree c) = 15/7    cost(tree d) = 15/7
cost(tree e) = 15/7

如所預期的,樹 b 是最佳的。」(b 就是最平衡的那一棵。)

不等機率時

$p_1=.5$、$p_2=.1$、$q_0=.15$、$q_1=.1$、$q_2=.05$、$q_3=.05$:

cost(tree a) = 2.65    cost(tree b) = 1.9
cost(tree c) = 1.5     cost(tree d) = 2.05
cost(tree e) = 1.6

對此 $p$、$q$ 的分配值而言,樹 c 是最佳的。」—— 最平衡的樹不再是最佳的。

這個對照就是整節的重點:當各鍵值被搜尋的機率不同時,「平衡」與「最佳」是兩回事。

為什麼不能窮舉

「我們可以如範例 10.1 一般,確實地產生所有可能的二元搜尋樹。這樣,我們可以計算每一樹的成本而找出最佳的樹。對於有 $n$ 個節點的樹,我們可以在 $O(n)$ 時間內決定每一二元搜尋樹的成本。如果 $N(n)$ 為 $n$ 個識別字全部的不同的二元搜尋樹之個數,則演算法的複雜度為 $O(nN(n))$。從 5.10 節,可知 $N(n) = O(4^n/n^{3/2})$,這使得這種只用蠻力的演算法對 $n$ 很大時非常不實際。

($N(n)$ 就是第 5 章 5.11 節的卡塔蘭數。)

動態規劃的遞迴式

符號定義

令 $a_1 \lt a_2 \lt \cdots \lt a_n$ 為二元搜尋樹中的 $n$ 個識別字。令 $T_{ij}$ 表示 $a_{i+1},\ldots,a_j$($i \lt j$)的最佳二元搜尋樹。對於 $0 \le i \le n$,令 $T_{ii}$ 為空的樹,而對 $i \gt j$,$T_{ij}$ 未定義。令 $c_{ij}$ 表示搜尋樹 $T_{ij}$ 的成本($c_{ii}=0$),$r_{ij}$ 代表 $T_{ij}$ 之根節點($r_{ii}=0$),且

$$w_{ij} = q_i + \sum_{k=i+1}^{j}(q_k + p_k)$$

代表 $T_{ij}$ 的權數($w_{ii}=q_i$,$0 \le i \le n$)。$T_{0n}$ 為 $a_1,\ldots,a_n$ 的最佳樹。

關鍵的最佳子結構論證

「由 (10.2) 式可明白,如果 $\text{cost}(L) = c_{i,k-1}$ 且 $\text{cost}(R) = c_{kj}$ 時,$c_{ij}$ 為最小。否則,我們可將 $L$ 或 $R$ 以具有較低成本的子樹取代之,以形成 $a_{i+1},\ldots,a_j$ 的二元搜尋樹,其成本低於 $c_{ij}$。這與假設 $T_{ij}$ 是最佳的不符。」因此 (10.2) 式變成:

$$c_{ij} = p_k + c_{i,k-1} + c_{kj} + w_{i,k-1} + w_{kj} = w_{ij} + c_{i,k-1} + c_{kj} \tag{10.3}$$

因 $T_{ij}$ 是最佳的,由公式 (10.3) 可知 $r_{ij} = k$ 使得:

$$w_{ij} + c_{i,k-1} + c_{kj} = \min_{i \lt l \le j}\{\,w_{ij} + c_{i,l-1} + c_{lj}\,\}$$

$$c_{i,k-1} + c_{kj} = \min_{i \lt l \le j}\{\,c_{i,l-1} + c_{lj}\,\} \tag{10.4}$$

公式 (10.4) 說明我們如何由已知 $T_{ii}=0$ 且 $c_{ii}=0$ 開始,求得 $T_{0n}$ 和 $c_{0n}$。

obst 的複雜度 —— 以及 Knuth 的改良

直接照 (10.4) 做,對每一對 $(i,j)$ 都要試 $j-i$ 個 $k$,總計 $O(n^3)$

Knuth 的關鍵觀察(習題 3 的 Knuth-min 函數):最佳根節點滿足單調性 $r_{i,j-1} \le r_{ij} \le r_{i+1,j}$,所以 $k$ 的搜尋範圍可以大幅縮小。這把複雜度降到 $O(n^2)$。習題 3(b) 要你證明這一點。

習題 1 — 路徑長度與近似最佳樹(10.1 節習題)
  1. (a) 以歸納法証明如果 $T$ 是有 $n$ 個內部節點的二元樹,其內部路徑長度 $I$,外部路徑長度為 $E$,則 $E = I + 2n$,$n \ge 0$
    (b) 以 (a) 的結果,證明成功的搜尋之平均比較次數 $s$,和失敗的搜尋之平均比較次數 $u$ 的關係式為:$s = (1+1/n)u - 1$,$n \ge 1$。
  2. 使用函數 obst,對識別字集合 $(a_1,a_2,a_3,a_4) = (\textbf{else}, \textbf{malloc}, \textbf{printf}, \textbf{scanf})$,其 $p_1=1/20$、$p_2=1/5$、$p_3=1/10$、$p_4=1/20$、$q_0=1/5$、$q_1=1/10$、$q_2=1/5$、$q_3=1/20$、$q_4=1/20$,計算 $w_{ij}, r_{ij}$ 和 $c_{ij}$。使用 $r_{ij}$ 建立最佳二元搜尋樹。
  3. 4. 因為通常只知道 $p$ 和 $q$ 的近似值,本題探討一個 $O(n\log n)$ 的演算法,它可建立幾乎已是最佳的二元搜尋樹。推論為:選擇根節點 $a_k$,使得 $|w_{0,k-1} - w_{k,n}|$ 儘可能為最小。重複這個函數以找出 $a_k$ 的左子樹和右子樹。
點擊展開解題要點

第 1(a) 題(歸納法):

  • 基礎:$n=0$(只有一個外部節點),$I=0$、$E=0$,$E = I+2\cdot0$ ✓
  • 步驟:在有 $n$ 個內部節點的樹中,把某個深度 $d$ 的外部節點換成一個內部節點(它帶兩個新的外部節點)。則 $I$ 增加 $d$;$E$ 失去一個深度 $d$ 的外部節點、獲得兩個深度 $d+1$ 的,淨增 $-d + 2(d+1) = d+2$。所以 $E - I$ 增加 $2$,而 $n$ 增加 $1$ ⇒ $E = I+2n$ 維持。□

第 1(b) 題:成功搜尋的比較次數 = 該內部節點的深度 $+1$,所以 $s = I/n + 1$。失敗搜尋的比較次數 = 該外部節點的深度,共 $n+1$ 個外部節點,所以 $u = E/(n+1)$。代入 $E = I+2n$:

$$u = \frac{I+2n}{n+1} \;\Rightarrow\; I = u(n+1) - 2n \;\Rightarrow\; s = \frac{u(n+1)-2n}{n} + 1 = \left(1+\frac1n\right)u - 1$$

第 4 題的直觀:$|w_{0,k-1} - w_{k,n}|$ 最小,就是讓左右子樹的「總機率重量」儘量相等 —— 這是一個貪婪的近似,不保證最佳,但每層只要 $O(\log n)$(二分搜尋找那個平衡點)、共 $\log n$ 層、$n$ 個節點,總計 $O(n\log n)$。相對於 obst 的 $O(n^2)$,這在 $p,q$ 本來就只是估計值時是很划算的取捨。

10.2 AVL 樹

插入順序決定一切 —— 課本的三組對照
插入順序最大比較次數平均比較次數
January…December(依月份順序6(找 November)$42/12 = 3.5$
July, February, May, August, January, March, October, April, December, June, November, September4$37/12$
依英文字母順序12(退化成鏈結串列)6.5

所以,在最差的狀況下,二元搜尋樹相當於在有序串中的循序搜尋。但是,當識別字以隨機順序插入時,樹結構傾向於平衡,如圖 10.9。如果所有的排列有相同的機率,則可以証明有 $n$ 個節點的二元搜尋樹的平均搜尋和插入時間為 $O(\log n)$。

為什麼不乾脆維持完整二元樹

「由我們所學過的二元樹之各種討論,已知如果隨時將二元搜尋樹保持為完整二元樹,我們可令平均搜尋時間與最大搜尋時間為最小。不幸地,因為環境是動態的,在我們建立樹的過程中,也需要找尋識別字。這使得維持它是完整二元樹,而增加新元素所需的時間沒有明顯地增加變得很困難。」—— AVL 樹就是「放寬完整性要求,換取可負擔的維護成本」的產物。

平衡因子與四種旋轉

四種旋轉

圖 10.12 以抽象的二元樹來表示 LL, LR, RR, 和 RL 旋轉。RL 旋轉與 LR 旋轉類似。圖中每一樹的根節點代表插入以後平衡因子變成 $\pm2$ 的最近的祖先。

型態觸發條件修正
LL插入在 $A$ 的子樹的子樹單旋轉
RR插入在 $A$ 的子樹的子樹單旋轉
LR插入在 $A$ 的子樹的子樹旋轉
RL插入在 $A$ 的子樹的子樹旋轉

在圖 10.11 的範例及圖 10.12 的旋轉中,未包含在旋轉中的子樹之高度維持不變。這表示旋轉之後不必再往上傳播 —— 每次插入最多只需要一次旋轉。

怎麼找到要旋轉的節點

要執行旋轉,首先必須找出最近插入的節點最靠近的祖先 $A$,其平衡因子變成 $\pm2$。一個節點的平衡因子不可能變成 $\pm2$,除非在插入之前它已是」$\pm1$。

這給了實作上的做法:沿著插入路徑往下走時,記住最後一個平衡因子非零的節點 —— 那就是 $A$。從 $A$ 到插入點之間的節點,平衡因子原本都是 0,插入後變成 $\pm1$,不會失衡。

圖 10.11 的完整追蹤(依月份順序插入)
插入結果
March單一節點,bf = 0
MayMar 的 bf 變 −1
NovemberMar 的 bf 變 −2 ⇒ RR 旋轉,May 成為根
August, April不需重新平衡
JanuaryMay 的 bf 變 +2 ⇒ LR 旋轉,Mar 成為根
December, July「插入 December 和 July 不需要重新平衡。」
February「但是,當插入 February 時,樹再度發生不平衡。……不平衡發生在 August。在此情況下,令 December 為子樹的根。August 及其左子樹成為 December 的左子樹。January 及其右子樹成為 December 的右子樹。令 February 為 January 之左子樹。若 December 有左子樹,它則成為 August 的右子樹」
June「插入 June 需要如同 10.11(f) 的旋轉。」
October「插入 October 也需要重新平衡。在此情況下,旋轉的方式與插入 November 以後所用的方法相同(RR)。」
September「插入 September 後樹仍為平衡的。」

費氏數與高度界限

AVL 樹為什麼一定是 $O(\log n)$ 高

「令 $N_h$ 為高度 $h$ 之高度平衡樹中的最小節點數。在最差情況下,子樹中有一個高度為 $h-1$,其他的為 $h-2$。這兩種子樹也都是高度平衡的。因此,

$$N_h = N_{h-1} + N_{h-2} + 1, \qquad N_0 = 0,\ N_1 = 1,\ N_2 = 2$$

請留意 $N_h$ 的遞迴定義與費氏數值得定義 $F_n = F_{n-1} + F_{n-2}$,$F_0 = 0$,且 $F_1 = 1$ 之間的相似性。事實上,我們可以証明(習題 2)

$$N_h = F_{h+2} - 1, \qquad h \ge 0$$

「由費氏數值定理可知 $F_h \approx \Phi^h/\sqrt5$ 其中 $\Phi = (1+\sqrt5)/2$。因此 $N_h \approx \Phi^{h+2}/\sqrt5 - 1$。這表示如果樹中有 $n$ 個節點,則其高度 $h$ 最多為

$$h \le \log_\Phi(\sqrt5(n+1)) - 2$$

所以,在有 $n$ 個節點的高度平衡樹中,最差狀況的插入時間為 $O(\log n)$。

實驗數據:旋轉其實不常發生

Karlton 等人的論文提供了高度平衡樹中刪除元素實驗結果。他們的研究指出隨機插入而不需要重新平衡的機率為 .5349;需要一個單旋轉(LL 或 RR)的機率為 .2324;而需要雙旋轉(LR 或 RL)的機率為 .2324。

也就是說:超過一半的插入根本不必旋轉,剩下的一半平均分給單旋轉和雙旋轉。這解釋了為什麼 AVL 樹在實務上的常數因子不算差。

圖 10.13:三種結構的最差狀況比較

運算循序串列鏈結串列AVL 樹
找尋 $x$$O(\log n)$$O(n)$$O(\log n)$
找尋第 $k$ 項$O(1)$$O(k)$$O(\log n)$
刪除 $x$$O(n)$$O(1)$1$O(\log n)$
刪除第 $k$ 項$O(n-k)$$O(k)$$O(\log n)$
插入 $x$$O(n)$$O(1)$2$O(\log n)$
依序輸出$O(n)$$O(n)$$O(n)$

圖 10.13:比較不同的資料結構 1 雙重鏈結串列和 $x$ 的位置已知 2 如果插入的位置已知

怎麼讀這張表

AVL 樹沒有任何一欄是最快的,但它也沒有任何一欄是 $O(n)$。循序串列和鏈結串列各有擅長(一個查得快、一個改得快),但各自都有 $O(n)$ 的罩門。AVL 樹的價值在於「全面沒有壞情況」 —— 這正是所有平衡搜尋樹的共同賣點。

習題 2 — AVL 樹(10.2 節習題)
  1. 以歸納法證明在高度為 $h$ 的 AVL 樹中,最小的節點數為 $N_h = F_{h+2}-1$,$h \ge 0$。
  2. 寫出 right_rotation 函數以完成 avl_insert
  3. 以下列的插入順序,由空的樹開始,使用演算法 avl_insert,畫出如圖 10.11 之高度平衡樹:December, January, April, March, July, August, October, February, September, November, May, June。寫出各種旋轉之型態。
  4. 假設在 AVL 樹 $t$ 中的每一節點有一個欄位 lsize。對任一節點 $a$,a->lsize 為其左子節的節點個數加 1。設計一個演算法 avl-find(t,k) 以找出子樹 $t$ 中第 $k$ 個最小的識別字。如果 $t$ 中有 $n$ 個節點,證明這可以在 $O(\log n)$ 時間內完成。
  5. 寫一程式以鍵值之升冪順序印出 AVL 樹 $T$ 中的節點。如果 $T$ 有 $n$ 個節點,證明可以在 $O(n)$ 時間內完成。
  6. 已知任何將兩個大小分別為 $n$ 和 $m$ 的已排序串列合併成一個的演算法,在最差狀況下,至少需要 $n+m-1$ 次比較。對於將兩個分別有 $n$ 和 $m$ 個元素的 AVL 樹合併,以比較為基礎的演算法而言,這個結果對其時間複雜度有何暗示?
  7. 在第 7 章,我們曾証明每一種以比較為基礎的演算法,在最差狀況時,排序 $n$ 個元素需要 $O(n\log n)$ 比較。對建立一個有 $n$ 個元素的 AVL 樹而言,這個結果對其複雜度有何暗示?
點擊展開解題要點

第 4 題(lsize 的用法):這是「順序統計樹」的標準技巧。

avl_find(t, k):
   if (k == t->lsize) return t;            /* 就是這一個 */
   if (k <  t->lsize) return avl_find(t->left_child, k);
   return avl_find(t->right_child, k - t->lsize);  /* 扣掉左子樹與自己 */

每一步往下一層,樹高 $O(\log n)$ ⇒ $O(\log n)$。習題 6 要你在插入時一併維護 lsize(沿插入路徑遞增,旋轉時修正)。

第 5 題:中序尋訪,每個節點拜訪一次 ⇒ $O(n)$。(第 5 章 5.3 節。)

第 6、7 題(兩個漂亮的下限論證):

  • 第 6 題:若能在 $o(n+m)$ 時間合併兩棵 AVL 樹,那麼「合併兩樹 → 中序輸出」就在 $o(n+m)$ 時間完成了兩個已排序串列的合併 —— 與已知的 $n+m-1$ 次比較下限矛盾。所以 AVL 樹的合併至少需要 $\Omega(n+m)$。(這正是第 9 章要用左傾樹而非平衡樹來做「結合」的理由。)
  • 第 7 題:若能在 $o(n\log n)$ 時間建立 $n$ 個元素的 AVL 樹,那麼「建樹 → 中序輸出($O(n)$)」就在 $o(n\log n)$ 完成了排序 —— 與第 7 章定理 7.1 矛盾。所以建立 AVL 樹至少需要 $\Omega(n\log n)$,而逐一插入正好是 $O(n\log n)$,所以逐一插入已經是最佳的

這兩題示範了一個重要的思考方式:用已知問題的下限,去推導新結構的下限。

10.3 2-3 樹

10.3.1 定義

定義 — 2-3 樹

「一內部節點的分支度為 2 或 3。分支度為 2 的節點稱為 2-節點,而分支度為 3 的節點稱為 3-節點。

2-3 樹可能是空的樹,或者是滿足下列特性的樹:

  1. 每一內部節點可能是 2-節點或是 3-節點。2-節點有一個元素,而 3-節點有兩個元素。
  2. left_childmiddle_child 代表 2-節點的子節點。令 data_l 為此節點中的元素,data_l.key 為其鍵值。在以 left_child 為根節點的 2-3 子樹中的所有元素之鍵值均小於 data_l.key,而在以 middle_child 為根節點的 2-3 子樹中的所有元素之鍵值均大於 data_l.key
  3. left_childmiddle_childright_child 代表 3-節點的子節點。令 data_ldata_r 為此節點中的兩個元素。則 data_l.key < data_r.key;在 left_child 的子樹中所有元素之鍵值均小於 data_l.key;在 middle_child 的子樹中所有元素之鍵值均小於 data_r.key 且大於 data_l.key;在 right_child 的子樹中所有元素之鍵值均大於 data_r.key
  4. 所有的外部節點在同一階層上。
特性 (4) 是整個結構的關鍵

AVL 樹允許左右高度差 1,靠旋轉維持;2-3 樹則要求所有外部節點嚴格在同一階層,靠「節點可以有 2 或 3 個子節點」這個彈性來吸收變化。樹的高度因此完全確定:$n$ 個元素的 2-3 樹高度介於 $\log_3(n+1)$ 與 $\log_2(n+1)$ 之間。

10.3.2 搜尋一個 2-3 樹

搜尋與二元搜尋樹幾乎相同

從根節點開始,在目前的節點內比較鍵值:2-節點有一個元素,比較後決定走 left_childmiddle_child;3-節點有兩個元素,比較後決定走三個子節點之一。因為所有外部節點在同一階層(特性 4),搜尋路徑的長度就是樹的高度。

$$\log_3(n+1) \le h \le \log_2(n+1) \;\Longrightarrow\; \text{搜尋時間} = O(\log n)$$

「當一個節點中要找尋的鍵值個數小時,可使用循序搜尋(如同在 2-3 樹或 2-3-4 樹的狀況下)。」—— 每個節點最多兩個鍵值,直接比較即可。

10.3.3 插入與分裂

void insert23(two_three_ptr *t, element y)
{
/* insert the element y into the 2-3 tree */
   two_three_ptr q, p, temp;

   if (!(*t))   /* tree is empty */
      new_root(t, y, NULL);
   else {
   /* insert into a non-empty tree */
     p = find_node(*t,y);
     if (!p) {
         fprintf(stderr, "The key is currently in the
                          tree\n");
         exit(1);
     }
     q = NULL;
     for(;;) {
        if (p->data_r.key == INT_MAX) { /*2-node */
           put_in(&p,y,q);
           break;
        }
        else {   /* 3-node */
           split(p,&y,&q);
           if (p == *t) { /* split the root */
              new_root(t,y,q);
              break;
           }
           else
              /*remove a node from stack */
              p = delete();
        }
     }
   }
}

程式 10.5:插入元素到 2-3 樹

為什麼需要堆疊

我們需要這個堆疊是因為在節點分裂以後,我們必須處理被分裂的節點之父節點。」—— 2-3 樹的插入是由下而上的:先往下找到葉節點,插入;若該節點已是 3-節點就分裂,中間元素上推給父節點;父節點若也滿了就繼續分裂……這個往上傳播的過程需要記住來時路,所以要堆疊。

INT_MAX 當「這個欄位沒有元素」的標記p->data_r.key == INT_MAX 表示是 2-節點)—— 這是本章反覆使用的技巧,在 10.4 節的 2-3-4 樹也看得到。

10.3.4 刪除:旋轉與合併

兩種修復手段
旋轉 (rotation)

當相鄰兄弟是 3-節點(有多餘的元素)時,向它借一個:兄弟的一個元素上移到父節點,父節點的一個元素下移到缺元素的節點。做完旋轉,刪除即結束。

合併 (combine)

當相鄰兄弟是 2-節點(借不出來)時,把父節點的一個元素拉下來,與兄弟合併成一個 3-節點。父節點因此少一個元素,缺元素的問題往上傳播一層

/* rotation when p is left child of r */
p->data_l = r->data_l;
r->data_l = q->data_l;
q->data_l = q->data_r;
q->data_r.key = INT_MAX;
p->middle_child  = q->left_child;
q->left_child = q->middle_child;
q->middle_child = q->right_child;

程式 10.8:當 $p$ 為 $r$ 的左子節點時的旋轉

/* p is the left child of r */
p->data_l = r->data_l;
p->data_r = q->data_l;
p->middle_child = q->left_child;
p->right_child = q->middle_child;
if (r->data_r.key == INT_MAX)
   /* r was a two node */
   r->data_l.key = INT_MAX;
else {
   r->data_l = r->data_r;
   r->data_r.key = INT_MAX;
   r->middle_child = r->right_child;
}

程式 10.9:當 $p$ 為 $r$ 的左子節點時的合併

分析 deletion

個別的旋轉或合併作業所需時間為 $O(1)$ 是很明顯的。如果執行旋轉,刪除即結束。如果執行合併,$p$ 在 2-3 樹中向上移動一個階層。所以,在一次刪除期間可以執行的合併次數不會超過 2-3 樹的高度。所得到的結討是從 $n$ 個元樹的 2-3 樹中刪除元素需要 $O(\log n)$ 時間。□

10.4 2-3-4 樹

定義

「2-3-4 樹由 2-3 擴充,使其容許有 4-節點(4-節點最多有四個子節點)。」

2-3-4 樹為一搜尋樹,它可能是空的,或者滿足下列的特性:

  1. 每一內部節點為 2-, 3-, 或 4-節點。2-節點有一個元素,3-節點有二個元素,而 4-節點有三個元素。
  2. (2-節點的子樹順序規則,同 2-3 樹)
  3. (3-節點的子樹順序規則,同 2-3 樹)
  4. left_childleft_mid_childright_mid_childright_child 表示 4-節點的子節點。令 data_ldata_mdata_r 為此節點中的三個元素。則 data_l.key < data_m.key < data_r.key,且四棵子樹的鍵值範圍依序被這三個元素分隔。

為什麼多一種節點型態反而簡單

void insert234(two34pointer *t, element y)
{
/* insert y into the 2-3-4 tree t */
   two34pointer p, r;
   if (!*t)
      new_root(t, y);
   else {
      if (four_node(*t))
         split_root(t);
      p = *t;   r = NULL;
      for (;;) {
         if (four_node(p)) {
            if (node_type(r) == two_node)
               split_child_of2(&p,&r);
            else
               split_child_of3(&p,&r);
            p = r;
         }
         r = p;
         switch (compare(y,p)) {
            case equal:   fprintf(stderr,"The key is in the
                          tree\n");
                          exit(1);
            case leaf:    put_in(y, &p);
                          return;
            case lchild:  p = p->left_child;
                          break;
            case lmchild: p = p->left_mid_child;
                          break;
            case rmchild: p = p->right_mid_child;
                          break;
            case rchild:  p = p->right_child;
         }
      }
   }
}

程式 10.10:插入元素到 2-3-4 樹

關鍵差異:預先分裂 (preemptive split)

注意程式的結構:往下走的路上,一遇到 4-節點就立刻分裂它if (four_node(p)) split_...),不等到真的需要。

這樣做的回報是:當我們抵達葉節點時,它一定不是 4-節點,所以插入必定成功、不必往上傳播。因此 2-3-4 樹的插入是由上而下的一趟(one-pass),不需要堆疊 —— 對照 2-3 樹的插入必須由下而上、需要堆疊。

多一種節點型態,換來的是演算法結構上的簡化。這在並行環境下特別重要(不必持有整條路徑的鎖)。

10.5 紅黑樹

紅黑樹 = 用二元樹表示的 2-3-4 樹

「(2) 一個 3-節點 $p$ 以兩個用紅指標連接的 red_black 節點表示。有兩種方式可以完成它(見圖 10.26,其中 color 欄位未畫出)。(3) 一個 4-節點以三個 red_black 節點表示,其中有一個以紅指標和另外兩個連接(見圖 10.27)。」

2-3-4 樹的節點紅黑樹的表示
2-節點1 個節點
3-節點2 個節點,用一條紅指標連接(兩種畫法皆可
4-節點3 個節點,中間是黑,兩側各用一條紅指標連接
五條性質 (Q1)–(Q5)
  • (Q2) 每一外部節點的等級為 0
  • (Q3) 一個內部節點,它是外部節點的父節點時,等級為 1
  • (Q4) 一個有父節點 $p(x)$ 的內部節點 $x$,其 $\text{rank}(x) \le \text{rank}(p(x)) \le \text{rank}(x)+1$。
  • (Q5) 一個有祖父節點 $qp(x)$ 的內部節點 $x$,其 $\text{rank}(x) \lt \text{rank}(qp(x))$。
等級 (rank) 是什麼 —— 直觀解釋

直觀上而言,2-3-4 樹 $T$ 中的每一個節點 $X$ 以對應的紅-黑中節點的集合來表示。在此集合中的所有節點的等級與 $\text{height}(T) - \text{level}(X) + 1$ 相同。所以,每當從紅-黑樹的根節點出來的一國路徑中有等級改變時,在相對的 2-3-4 樹中就有階層改變。黑指標由某一等級的節點指向等級少 1 的節點,而紅指標連接兩個等級相同的節點。

簡言之:rank 就是「這個節點在原本的 2-3-4 樹裡位於第幾層」。紅指標是 2-3-4 節點內部的連接(不跨層),黑指標才是真正的層間連接。

公設 10.1

每一有 $n$ 個(內部)節點的紅-黑樹 $RB$ 滿足下列各式:

  1. $\text{height}(RB) \le 2\lceil \log_2(n+1)\rceil$
  2. $\text{height}(RB) \le 2\,\text{rank}(RB)$
  3. $\text{rank}(RB) \le \lceil \log_2(n+1)\rceil$

(1) 就是紅黑樹的核心保證:高度不超過完美平衡樹的兩倍。理由是 (2)+(3):rank(黑高度)最多 $\log_2(n+1)$,而每一層 rank 之間最多夾一條紅指標,所以實際高度最多是 rank 的兩倍。

10.5.3 由上而下插入

兩種插入策略

插入可用兩種方式來執行:由上而下或由下而上。在由上而下插入中,在紅-黑樹中只做了一次由根到葉節點的處理過程。由下而上的插入則做了由根到葉節點與由葉節點到根的二次處理過程。

由上而下由下而上
對應的 2-3-4 樹作法預先分裂 4-節點插入後再往上修復
需要堆疊/父指標不需要需要
旋轉型態LLb, LRb(圖 10.34)等
為什麼紅黑樹比 2-3-4 樹更常被實作

2-3-4 樹的節點有三種不同的大小(1、2、3 個元素,2、3、4 個子指標),記憶體配置與程式分支都很麻煩。紅黑樹把它壓成統一大小的二元節點 + 一個顏色位元,程式碼雖然旋轉的情況變多,但資料結構本身極為規整。這就是 C++ 的 std::map、Java 的 TreeMap、Linux 核心的排程器都選擇紅黑樹的原因。

10.6 B 樹

10.6.2 搜尋一個 m-路搜尋樹

高分支度為何重要

「在分支度為 $m$ 高度為 $h$ 的樹中,最大的節點個數為 $\sum_{0 \le i \le h-1} m^i = (m^h-1)/(m-1)$。因每一節點最多有 $m-1$ 個鍵值,在高度為 $h$ 的 m-路樹索引中最大的鍵值個數為 $m^h - 1$。

$h=3$ 時最大鍵值個數
二元樹($m=2$)$2^3-1 = \mathbf{7}$
200-路樹($m=200$)$200^3-1 = \mathbf{8\times10^6-1}$

明顯地,高分支度的搜尋樹之潛力比低分支度的搜尋樹大得多。想要得到一個效率,使它非常接近 $n$ 個鍵值的 m-路搜尋樹之最佳效率時,這個搜尋樹必須是平衡的。我們將要討論的一種平衡的 m-路搜尋樹的特別變化稱為 B-樹。

搜尋一個節點內部:循序 vs 二分

當一個節點中要找尋的鍵值個數小時,可使用循環搜尋(如同在 2-3 樹或 2-3-4 樹的狀況下)。當這種個數很大時,可使用二分搜尋。

10.6.3 B-樹的定義與特性

定義 — 度數為 $m$ 的 B-樹

度數(order)為 $m$ 的 B-樹是一個 m-路搜尋樹,它可能是空的,或者滿足下列特性:

  1. 根節點至少有兩個子節點。
  2. 其餘的各個非失敗節點至少有 $\lceil m/2 \rceil$ 個子節點。
  3. 所有失敗節點在同一階層上。

在定義 B-樹時,習慣重新提出失敗節點的觀念。請記住失敗節點代表在搜尋時,如果要找尋的值 $x$ 不在樹中才可到達的節點。

2-3 樹與 2-3-4 樹是 B-樹的特例

$m=3$ 的 B-樹就是 2-3 樹($\lceil 3/2\rceil = 2$,所以每個節點有 2 或 3 個子節點);$m=4$ 的 B-樹就是 2-3-4 樹($\lceil 4/2\rceil = 2$,2 到 4 個子節點)。參考文獻中明說:「2-3 樹與 2-3-4 樹為 B-樹的特例。

實際的數字有多驚人

「的索引之 $l \le \log_{100}\{(n+1)/2\} + 1$。因 $l$ 為整數,可得到 $l \le 3$。對 $n \le 2\times10^8 - 2$,可得到 $l \le 4$。所以,使用高度數的 B-樹所得到的結果是,即使資料項的個數非常多,一個樹結構索引可以用很少的磁碟存取次數來搜尋。

兩億筆資料,四次磁碟存取。這就是為什麼幾乎所有的資料庫索引都是 B 樹或 B+ 樹。

$m$ 的選擇 —— 一個真正的工程權衡

為什麼不把 $m$ 取到最大

如果索引有 $n$ 項,則度數為 $m = n+1$ 的 B-樹只有一層。這個 $m$ 的選擇顯然不合理,因為我們已假設索引太大而不能全部存入記憶體中。結果是以一個節點表示的索引無法讀入記憶體中處理。

成本模型

「為了達到比較合理的 $m$ 的選擇,我們必須記住我們真正的目的在使得搜尋 B-樹中的一個鍵值 $x$ 所需全部時間為最小。這個時間有兩個組成因素,其一,從磁碟讀取一個節點的時間,其二,找尋有 $x$ 值的節點。

假設一個節點的大小約為 $m(\alpha + 2\beta)$ 字元,則讀取一個節點所需的時間 $t_i$ 為:

$$t_i = t_s + t_l + m(\alpha+2\beta)t_c = a + bm$$

其中 $a = t_s + t_l =$ 找尋的時間 + 延遲時間,$b = (\alpha+2\beta)t_c$,$t_c =$ 每一字元的傳送時間。

如果以二分搜尋法找尋 B-樹的每一節點,則每一節點的內部處理時間為 $c\log_2 m + d$,$c$ 和 $d$ 為常數值。」所以每一節點全部的處理時間為 $a + bm + c\log_2 m + d$,而總時間為這個乘上樹的高度 $\approx \log_{\lceil m/2\rceil} n$。

這個模型的結論

總時間 $\approx \dfrac{(a+bm+c\log_2 m+d)\log_2 n}{\log_2 \lceil m/2\rceil}$。$m$ 太小 → 樹太高 → 存取次數多;$m$ 太大 → 每個節點太大 → 讀一個節點太慢。中間有一個最佳值,實務上由磁碟區塊大小決定 —— 讓一個 B 樹節點剛好等於一個磁碟頁(典型為 4 KB 或 8 KB)。

這和第 8 章 8.3 節「$k$ 太大則緩衝區變小」以及第 7 章 7.11 節「最佳的 $k$ 值與磁碟參數有關」是完全相同的權衡結構。

B* 樹與 B+ 樹

B* 樹(習題 9、10)

[Bayer and McCreight] 當 $P$ 的最近兄弟 $Q$ 已經有 $m-1$ 個鍵值,則我們可將 $P$ 和 $Q$ 兩者都分裂,產生三個節點 $P$,$Q$ 和 $R$,每一節點包含了 $\lfloor(2m-2)/3\rfloor$、$\lfloor(2m-1)/3\rfloor$ 和 $\lfloor 2m/3\rfloor$ 個鍵值。

效果:節點的最低填充率從 $1/2$ 提高到 $2/3$ —— 樹更矮、空間利用更好。

B+ 樹(習題 13–18)

如果有 $n$ 個鍵值在樹中,就有 $n$ 個葉節點。」—— B+ 樹把所有資料都放在葉節點,內部節點只存索引鍵。「因所有鍵值都是葉節點,它相當於永遠從葉節點刪除。」

好處:葉節點可以串成一條鏈結串列,範圍查詢(「找出所有 $50 \le x \le 100$ 的記錄」)只要找到起點再順著走 —— 這就是 B+ 樹主宰資料庫索引的原因。

關於節點大小的實務建議

使用大小不同的節點並不值得推薦,因為它需要更複雜的記憶體管理系統。更重要的,使用大小不同的節點會造成插入的效率退化,因為插入元素到一個節點會要求我們去取得一個更大的節點來適應新插入的鍵值。結論是我們應使用大小相同的節點。其大小應該可以容納至少 $m-1$ 個最長的鍵值。但是,在插入期間我們可放鬆每一節點有 $\le m-1$ 個鍵值的要求。將它改成一個節點容許存放可以放入其內的最多個且至少 $\lceil m/2\rceil - 1$ 個鍵值。」

對付超長鍵值的方法:「另一種方法是改用其他的鍵值取樣方法,以減少鍵值長度,使它不會超過一個預定的大小 $d$。一些可能的取樣法為去掉字首,字尾,刪除母音等。不論使用何種方法,必須提供一些處理同義字(即不同的鍵值有相同的取樣值)的方法。

習題 3 — B 樹(10.6 節習題)
  1. 証明所有度數為 2 的 B-樹為完全二元樹。
  2. 一次插入一個鍵值 62, 5, 85, 75 到圖 10.39 度數為 5 的 B-樹中。畫出每一鍵值插入以後的新的樹結構。
  3. 對度數為 $m$ 的 B'-樹,設計一個函數以插入 $x$。所需的磁碟存取次數為何?
  4. 設計一個演算法從度數為 $m$ 的 B'-樹 $t$ 中刪除 $x$。所需的磁碟存取次數為何?
  5. 令 $T$ 和 $U$ 為兩個度數為 $m$ 的 B'-樹。令 $V$ 為包含了 $T$ 和 $U$ 中所有鍵值的度數為 $m$ 之 B'-樹。設計一個 C 函數由 $T$ 和 $U$ 建立 $V$。你的演算法之複雜度為何?
  6. § [Programming Project] 評估 B-樹,B*-樹和 B'-樹相對的效率,它們所需要的運算為搜尋 $x$,插入 $x$ 和刪除 $x$。
點擊展開解題要點

第 1 題:度數 $m=2$ 時,$\lceil m/2\rceil = 1$,但每個節點最多 $m-1 = 1$ 個鍵值、$m = 2$ 個子節點。根節點至少 2 個子節點、其餘節點至少 1 個…… 加上「所有失敗節點在同一階層」這個條件,每個內部節點都必須恰好有 2 個子節點,且所有葉在同一層 —— 這正是完全二元樹的定義(第 5 章)。

第 3、4 題(磁碟存取次數):設樹高 $h = O(\log_{\lceil m/2\rceil} n)$。

  • 搜尋:$h$ 次讀取。
  • 插入:$h$ 次讀取(往下找)+ 最多 $h$ 次寫入(分裂往上傳播)= $O(h)$
  • 刪除:同樣 $O(h)$(合併往上傳播)。

第 5 題:最簡單的作法是把 $U$ 的所有鍵值逐一插入 $T$,複雜度 $O(n_U \log n_T)$。較好的作法:若 $T$ 和 $U$ 的鍵值範圍不重疊(例如 $T$ 全部小於 $U$),可以像 2-3 樹的合併一樣,把較矮的樹接到較高的樹的適當位置,$O(|h_T - h_U| + 1)$。若範圍重疊則無法避免逐一插入。

10.7 外張樹 (Splay Tree)

為什麼還需要另一種結構

AVL,2-3,2-3-4,和紅-黑樹允許我們在 $O(\log n)$ 最差狀況時間內完成每一個搜尋樹的運算:插入,刪除和搜尋。在優先權佇列的例子中我們已看到,如果我們對分攤成本而不是對最差狀況複雜度有興趣,則可使用較簡單的結構。這對搜尋樹也是成立的。使用外張樹時,每一搜尋樹運算可以在 $O(\log n)$ 分攤時間內完成。

外張樹的定義

外張樹(splay tree)是一種二元搜尋樹,其中每一搜尋,插入,和刪除的方法都與原始的二元的搜尋樹(見第 5 章)相同。但是,在每一運算之後跟著一個向外張開(splay)運算。外張運算由一連串的旋轉構成。

沒有平衡因子、沒有顏色、沒有等級 —— 節點結構與第 5 章的 BST 完全相同。全部的智慧都在「每次操作後把剛碰過的節點旋轉到根」這一條規則裡。

潛能法 (potential technique)

分攤分析的第二種工具

外張樹分析則使用一種潛能(potential)技巧。令 $p_0$ 為搜尋樹最初的潛能,$p_i$ 為在一連串 $n$ 個運算的第 $i$ 個運算後的潛能。定義第 $i$ 個運算的分攤時間為:

$$\text{第 } i \text{ 個運算的分攤時間} = (\text{第 } i \text{ 個運算的實際時間}) + p_i - p_{i-1}$$

就是說,分攤時間為實際時間加上潛能的改變。重新排列各項,可發現第 $i$ 個運算的實際時間為:」

$$(\text{第 } i \text{ 個運算的分攤時間}) + p_{i-1} - p_i$$

「因此,執行連續 $n$ 個運算所需的實際時間為:」

$$\sum_i (\text{第 } i \text{ 個運算的分攤時間}) + p_0 - p_n$$
潛能法 vs 第 9 章的成本轉移法
第 9 章 9.4.1 節本節
方法成本轉移(把昂貴運算的成本收給便宜的運算)潛能函數(定義一個「儲存的能量」$p_i$)
直觀「預付」「銀行帳戶餘額」
優點直觀易懂更系統化,適用於複雜結構

關鍵在於 $p_0 - p_n$ 這一項:只要保證潛能永遠非負且初始潛能有界,總實際時間就被總分攤時間所控制。對外張樹,潛能定義為 $\sum_x \log_2(\text{size}(x))$,可以證明每個運算的分攤成本是 $O(\log n)$。

外張樹的實務價值

「因為每一運算之後跟著一個外張運算,其實際複雜度與全部運算的複雜度之次方相同,故只考慮執行外張所需的時間也就足夠了。」

外張樹的隱藏好處:局部性(locality)。剛存取過的節點會被移到根附近,所以反覆存取同一批鍵值時,外張樹會比 AVL 樹更快 —— 它自動適應了實際的存取模式。代價是單次操作可能是 $O(n)$,且每次讀取都會修改樹結構(對並行不友善)。

10.8 數位搜尋結構

10.8.1 數位搜尋樹

與二元搜尋樹的根本差別

「數位搜尋樹的搜尋,插入和刪除程序式與二元樹的相關程序式非常的類似。基本的差別在於要移到那個子樹由搜尋鍵值的位元來決定,而不是由搜尋鍵值與現行節點的鍵值比較結果來決定。

各個上述搜尋樹運算可以在 $O(h)$ 時間內完成,其中 $h$ 為數位搜尋樹的高度。假設數位搜尋樹中的每一鍵值有 keysize 位元,則此數位搜尋樹的高度至多為 keysize + 1。

這打破了第 7 章的下限

數位搜尋樹的高度由鍵值長度決定,與元素個數 $n$ 無關。這和第 7 章基底排序打破 $\Omega(n\log n)$ 排序下限是同一個道理:不比較鍵值,就不受比較模型的下限約束。

10.8.2 Binary Tries 與 Patricia

為什麼需要 Patricia

假設我們有很長的鍵值,則鍵值比較的成本是很高的。我們可以將鍵值比較次數降為 1,只要利用一種相關的結構即可,稱其為 Patricia(Practical algorithm to retrieve information coded in alphanumeric)。

三個發展步驟:「我們以三個步驟來發展這個結構。首先,我們介紹一種結構稱為 binary trie。接著將 binary trie 轉換成 compressed binary trie。最後,由 compressed binary trie 得到 Patricia。因為 binary trie 和 compressed binary trie 僅是做為達成 Patricia 的方法,我們並未深入討論如何處理這些結構。」

Binary trie 的兩種節點

Binary trie 為一種二元搜尋樹,它有兩種節點分支節點(branch node)和元素節點(element node)。分支節點有兩個欄位 left_childright_child。它沒有資料欄位。一個元素節點有一個 data 欄位。我們以分支節點來建立類似數位搜尋樹的二元樹搜尋結構。這種搜尋結構指向元素節點。

「壓縮」在壓什麼

Binary trie 的問題是會產生大量只有一個子節點的分支節點(當某段位元前綴只有一個鍵值符合時,還是要一位元一位元往下走)。Compressed binary trie 把這些單子節點的鏈壓縮成一個節點,並記錄「跳過了幾個位元」。Patricia 再進一步把元素節點與分支節點合而為一。結果是:$n$ 個鍵值恰好需要 $n$ 個節點,而且整個搜尋過程只做一次完整的鍵值比較(在最後確認時)。

10.9 Tries

10.9.2 搜尋一個 trie

trie_pointer search(trie_pointer t, char *key, int i)
{
/* search the trie t */
   if (!t) return NULL; /* not found */
   if (t->tag == data)
      return ((strcmp(t->u.key,key)) ? NULL : t);
   return search(t->u.letters[get_index(key,i)], key, i+1);
}

程式 10.13:搜尋一個 Trie

search 的三行邏輯
  1. if (!t) return NULL; —— 走到空的分支,鍵值不在樹中。
  2. if (t->tag == data) —— 到達元素節點,做唯一的一次 strcmp 確認。
  3. 否則,用 get_index(key,i) 取出第 $i$ 層的取樣值,往對應的分支繼續。

此函數使用函數 get_index(key,i),它執行第 $i$ 階層的鍵值取樣。在由左至右,單一字元的取樣中,此函數抽取鍵值的第 $i$ 個字元,並將它轉成一個整數值索引,以指出要使用分支節點的那一個指標欄位。

分析 search

搜尋函數是直接的,我們可以驗証其最差狀況搜尋時間為 $O(l)$ 中 $l$ 是 trie 中的階層數(包括分支和元素兩種節點在內)。□

注意「呼叫它以前,已經將一個空白附加在搜尋鍵值之後」—— 空白當作字串結尾的哨兵,確保短的鍵值能正確終止。

10.9.3 取樣策略

取樣函數決定了 trie 的形狀

對於一個索引的全部節點都儲存在磁碟的情況而言,搜尋時最多需要 $l$ 次的存取。給予一組要以索引表示的鍵值,trie 中的階層數根據用來決定每一階層的分歧之鍵值取樣方法而定。我們可使用取樣函數 sample(x,i) 來定義」取樣策略。

常見的取樣策略:由左至右取單一字元(最直觀);由右至左(對有共同字首的鍵值更好);挑選特定位置的字元(如果知道某些位置區分度高,可大幅降低樹高)。這與第 8 章 8.2.2 節的「數位分析法」是完全相同的思路。

10.11 精選參考文獻

主題文獻
$O(n^2)$ 最佳二元搜尋樹D. Knuth, "Optimum Binary Search Trees", Acta Informatica, 1, 1, 1971, pp. 14–25
$O(n\log n)$ 近似最佳K. Melhorn, "Nearly Optimal Binary Search Trees", Acta Informatica, 5, 1975, pp. 287–295
J. Nievergelt, "Binary Search Trees and File Organization", ACM Computing Surveys, Vol. 6, No. 3, Sept. 1974, pp. 195–207
AVL 樹(原始論文)G. M. Adelson-Velskii and E. M. Landis, Dokl. Acad. Nauk. SSSR (Soviet Math), 3, 1962, pp. 1259–1263
AVL 樹的其他演算法C. Crane, "Linear list and priority queues as balanced binary trees", STAN-CS-72-259, Stanford University, Feb. 1972
D. Knuth, The Art of Computer Programming: Sorting and Searching, Addison-Wesley, 1973(section 6.2.3)
高度平衡樹實驗結果P. L. Karlton, S. H. Fuller, R. E. Scroggs and E. B. Koehler, CACM, 19, 1, Jan. 1976, pp. 23–28
2-3 與 2-3-4 樹D. Knuth, The Art of Computer Programming: Sorting and Searching, vol. 3, 1973(「2-3 樹與 2-3-4 樹為 B-樹的特例」)
習題中的 2-3 樹之改良摘自 A. Aho, The design and analysis of Computer algorithms
B* 樹Bayer and McCreight

本章重點回顧

八種搜尋結構的完整對照
結構搜尋插入刪除額外欄位
二元搜尋樹(第 5 章)$O(n)$$O(n)$$O(n)$
最佳 BST(靜態)最小平均建構 $O(n^2)$
AVL 樹$O(\log n)$$O(\log n)$$O(\log n)$平衡因子
2-3 樹$O(\log n)$$O(\log n)$$O(\log n)$節點大小不一
2-3-4 樹$O(\log n)$$O(\log n)$$O(\log n)$節點大小不一
紅黑樹$O(\log n)$$O(\log n)$$O(\log n)$1 個顏色位元
B 樹(度數 $m$)$O(\log_m n)$ 磁碟同左同左為磁碟設計
外張樹$O(\log n)$ 分攤同左同左
Trie / Patricia$O(l)$$O(l)$$O(l)$不比較鍵值

由下而上,需要堆疊。   由上而下一趟完成,不需要堆疊。

一定要記住的清單
  1. $E = I + 2n$;$s = (1+1/n)u - 1$。傾斜樹 $I = n(n-1)/2$,完整二元樹 $I = O(n\log n)$。
  2. 最佳 BST:$c_{ij} = w_{ij} + c_{i,k-1} + c_{kj}$,遞迴式 (10.4),$O(n^3)$;用 Knuth 的單調性降到 $O(n^2)$。
  3. AVL:四種旋轉(LL/RR 單、LR/RL 雙),每次插入最多一次旋轉。$N_h = F_{h+2}-1$,$h \le \log_\Phi(\sqrt5(n+1))-2$。
  4. Karlton 實驗:53.49% 不需旋轉、23.24% 單旋轉、23.24% 雙旋轉。
  5. 2-3 樹插入由下而上(要堆疊);2-3-4 樹預先分裂,由上而下一趟(不要堆疊)。
  6. 2-3 樹刪除:兄弟是 3-節點就旋轉(結束);是 2-節點就合併(往上傳播)。
  7. 紅黑樹:紅指標連接同等級節點(2-3-4 節點內部),黑指標降一級。$\text{height}(RB) \le 2\lceil\log_2(n+1)\rceil$。
  8. B 樹:根至少 2 個子節點,其餘至少 $\lceil m/2\rceil$ 個,所有失敗節點同一階層。$m=3$ 是 2-3 樹、$m=4$ 是 2-3-4 樹。$m$ 由磁碟區塊大小決定。
  9. 外張樹:節點結構與普通 BST 相同,每次運算後 splay 到根,用潛能法證明分攤 $O(\log n)$。
  10. Trie:位元/字元分歧而非比較鍵值,高度由鍵值長度決定,與 $n$ 無關
最容易答錯的六個點
  1. 最平衡的樹不一定是最佳的(機率不等時,範例 10.1 的樹 c)。
  2. AVL 插入最多一次旋轉;刪除則可能要 $O(\log n)$ 次。
  3. 2-3 樹要堆疊,2-3-4 樹不要
  4. 紅黑樹的 3-節點有兩種畫法(圖 10.26)。
  5. B 樹的 $m$ 不是越大越好
  6. 外張樹單次可能 $O(n)$,只有分攤是 $O(\log n)$。
怎麼選
  • 資料在記憶體、要最差保證 → 紅黑樹或 AVL(AVL 更矮、查詢快;紅黑旋轉少、寫入快)。
  • 資料在磁碟B+ 樹(範圍查詢靠葉節點鏈結)。
  • 存取有局部性 → 外張樹。
  • 鍵值很長/字串 → Trie 或 Patricia。
  • 鍵值固定、機率已知 → 最佳 BST。
  • 不需要順序 → 回頭用第 8 章的雜湊(平均 $O(1)$)。
全書的收束

本章把前面九章的線索全部收攏:第 1 章的 $O$/$\Omega$/$\theta$ 是每一節分析的語言;第 5 章的二元搜尋樹是全章的起點,卡塔蘭數 $N(n)$ 說明了為何不能窮舉,公設 5.1 用於路徑長度分析;第 7 章的排序下限被用來證明「建立 AVL 樹至少要 $\Omega(n\log n)$」(習題 7);第 8 章的雜湊是本章的對照組,而 8.3 節的動態雜湊與 10.6 節的 B 樹解決的是同一個磁碟問題;第 9 章的成本分攤在 10.7 節以潛能法的形式再次出現。

如果要用一句話總結這本書:資料結構的設計,就是在「時間、空間、最差保證、實作複雜度」這四個軸上反覆做取捨,而每一次取捨都必須能被量化地說清楚。