全書最長的一章,也是所有線索收束的地方。第 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.2 的兩個搜尋樹。第二個樹最多需要 3 次的比較來決定所要找尋的識別字是否在樹中。第一個二元樹可能需要 4 次的比較,因為字母順序在 for 之後且在 void 之前的識別字須測試四個節點。所以,第二個二元樹比第一個樹有較好的最差狀況搜尋時間。」
「在第一個樹中找尋一個識別字所需的比較次數,對 for 需要一次,do 和 while 各需要二次,void 需三次,if 則需要四次。」
「內部路徑長度(internal path length)為根到所有內部節點的路徑長度的加總。……外部路徑長度(external path length)為根到所有外部節點的路徑長度的加總。」對圖 10.3(a) 的樹:
「習題 1 証明有 $n$ 個內部節點的二元樹之內部和外部路徑長度之關係式為 $E = I + 2n$。因此,有最大 $E$ 值的二元樹也會有最大的 $I$ 值。」
「很明顯地,最差狀況發生在樹為傾斜的,即樹的深度為 $n$。在此情況下,」
「要找出具有最小 $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$ 值為:」
「圖 10.4 顯示識別字集合 $(a_1,a_2,a_3) = (\textbf{do}, \textbf{if}, \textbf{while})$ 所有可能的二元搜尋樹。」
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$),且
代表 $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) 式變成:
因 $T_{ij}$ 是最佳的,由公式 (10.3) 可知 $r_{ij} = k$ 使得:
或
「公式 (10.4) 說明我們如何由已知 $T_{ii}=0$ 且 $c_{ii}=0$ 開始,求得 $T_{0n}$ 和 $c_{0n}$。」
直接照 (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) 要你證明這一點。
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}$ 建立最佳二元搜尋樹。第 1(a) 題(歸納法):
第 1(b) 題:成功搜尋的比較次數 = 該內部節點的深度 $+1$,所以 $s = I/n + 1$。失敗搜尋的比較次數 = 該外部節點的深度,共 $n+1$ 個外部節點,所以 $u = E/(n+1)$。代入 $E = I+2n$:
第 4 題的直觀:$|w_{0,k-1} - w_{k,n}|$ 最小,就是讓左右子樹的「總機率重量」儘量相等 —— 這是一個貪婪的近似,不保證最佳,但每層只要 $O(\log n)$(二分搜尋找那個平衡點)、共 $\log n$ 層、$n$ 個節點,總計 $O(n\log n)$。相對於 obst 的 $O(n^2)$,這在 $p,q$ 本來就只是估計值時是很划算的取捨。
| 插入順序 | 最大比較次數 | 平均比較次數 |
|---|---|---|
| January…December(依月份順序) | 6(找 November) | $42/12 = 3.5$ |
| July, February, May, August, January, March, October, April, December, June, November, September | 4 | $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$,不會失衡。
| 插入 | 結果 |
|---|---|
| March | 單一節點,bf = 0 |
| May | Mar 的 bf 變 −1 |
| November | Mar 的 bf 變 −2 ⇒ RR 旋轉,May 成為根 |
| August, April | 不需重新平衡 |
| January | May 的 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 後樹仍為平衡的。」 |
「令 $N_h$ 為高度 $h$ 之高度平衡樹中的最小節點數。在最差情況下,子樹中有一個高度為 $h-1$,其他的為 $h-2$。這兩種子樹也都是高度平衡的。因此,」
「請留意 $N_h$ 的遞迴定義與費氏數值得定義 $F_n = F_{n-1} + F_{n-2}$,$F_0 = 0$,且 $F_1 = 1$ 之間的相似性。事實上,我們可以証明(習題 2)」
「由費氏數值定理可知 $F_h \approx \Phi^h/\sqrt5$ 其中 $\Phi = (1+\sqrt5)/2$。因此 $N_h \approx \Phi^{h+2}/\sqrt5 - 1$。這表示如果樹中有 $n$ 個節點,則其高度 $h$ 最多為」
「所以,在有 $n$ 個節點的高度平衡樹中,最差狀況的插入時間為 $O(\log n)$。」
「Karlton 等人的論文提供了高度平衡樹中刪除元素實驗結果。他們的研究指出隨機插入而不需要重新平衡的機率為 .5349;需要一個單旋轉(LL 或 RR)的機率為 .2324;而需要雙旋轉(LR 或 RL)的機率為 .2324。」
也就是說:超過一半的插入根本不必旋轉,剩下的一半平均分給單旋轉和雙旋轉。這解釋了為什麼 AVL 樹在實務上的常數因子不算差。
| 運算 | 循序串列 | 鏈結串列 | 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 樹的價值在於「全面沒有壞情況」 —— 這正是所有平衡搜尋樹的共同賣點。
right_rotation 函數以完成 avl_insert。avl_insert,畫出如圖 10.11 之高度平衡樹:December, January, April, March, July, August, October, February, September, November, May, June。寫出各種旋轉之型態。lsize。對任一節點 $a$,a->lsize 為其左子節的節點個數加 1。設計一個演算法 avl-find(t,k) 以找出子樹 $t$ 中第 $k$ 個最小的識別字。如果 $t$ 中有 $n$ 個節點,證明這可以在 $O(\log n)$ 時間內完成。第 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 題(兩個漂亮的下限論證):
這兩題示範了一個重要的思考方式:用已知問題的下限,去推導新結構的下限。
「一內部節點的分支度為 2 或 3。分支度為 2 的節點稱為 2-節點,而分支度為 3 的節點稱為 3-節點。」
2-3 樹可能是空的樹,或者是滿足下列特性的樹:
left_child 和 middle_child 代表 2-節點的子節點。令 data_l 為此節點中的元素,data_l.key 為其鍵值。在以 left_child 為根節點的 2-3 子樹中的所有元素之鍵值均小於 data_l.key,而在以 middle_child 為根節點的 2-3 子樹中的所有元素之鍵值均大於 data_l.key。left_child、middle_child 和 right_child 代表 3-節點的子節點。令 data_l 和 data_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。AVL 樹允許左右高度差 1,靠旋轉維持;2-3 樹則要求所有外部節點嚴格在同一階層,靠「節點可以有 2 或 3 個子節點」這個彈性來吸收變化。樹的高度因此完全確定:$n$ 個元素的 2-3 樹高度介於 $\log_3(n+1)$ 與 $\log_2(n+1)$ 之間。
從根節點開始,在目前的節點內比較鍵值:2-節點有一個元素,比較後決定走 left_child 或 middle_child;3-節點有兩個元素,比較後決定走三個子節點之一。因為所有外部節點在同一階層(特性 4),搜尋路徑的長度就是樹的高度。
「當一個節點中要找尋的鍵值個數小時,可使用循序搜尋(如同在 2-3 樹或 2-3-4 樹的狀況下)。」—— 每個節點最多兩個鍵值,直接比較即可。
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 樹也看得到。
當相鄰兄弟是 3-節點(有多餘的元素)時,向它借一個:兄弟的一個元素上移到父節點,父節點的一個元素下移到缺元素的節點。做完旋轉,刪除即結束。
當相鄰兄弟是 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$ 的左子節點時的合併
「個別的旋轉或合併作業所需時間為 $O(1)$ 是很明顯的。如果執行旋轉,刪除即結束。如果執行合併,$p$ 在 2-3 樹中向上移動一個階層。所以,在一次刪除期間可以執行的合併次數不會超過 2-3 樹的高度。所得到的結討是從 $n$ 個元樹的 2-3 樹中刪除元素需要 $O(\log n)$ 時間。□」
「2-3-4 樹由 2-3 擴充,使其容許有 4-節點(4-節點最多有四個子節點)。」
2-3-4 樹為一搜尋樹,它可能是空的,或者滿足下列的特性:
left_child、left_mid_child、right_mid_child 和 right_child 表示 4-節點的子節點。令 data_l、data_m 和 data_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 樹
注意程式的結構:往下走的路上,一遇到 4-節點就立刻分裂它(if (four_node(p)) split_...),不等到真的需要。
這樣做的回報是:當我們抵達葉節點時,它一定不是 4-節點,所以插入必定成功、不必往上傳播。因此 2-3-4 樹的插入是由上而下的一趟(one-pass),不需要堆疊 —— 對照 2-3 樹的插入必須由下而上、需要堆疊。
多一種節點型態,換來的是演算法結構上的簡化。這在並行環境下特別重要(不必持有整條路徑的鎖)。
「(2) 一個 3-節點 $p$ 以兩個用紅指標連接的 red_black 節點表示。有兩種方式可以完成它(見圖 10.26,其中 color 欄位未畫出)。(3) 一個 4-節點以三個 red_black 節點表示,其中有一個以紅指標和另外兩個連接(見圖 10.27)。」
| 2-3-4 樹的節點 | 紅黑樹的表示 |
|---|---|
| 2-節點 | 1 個節點 |
| 3-節點 | 2 個節點,用一條紅指標連接(兩種畫法皆可) |
| 4-節點 | 3 個節點,中間是黑,兩側各用一條紅指標連接 |
「直觀上而言,2-3-4 樹 $T$ 中的每一個節點 $X$ 以對應的紅-黑中節點的集合來表示。在此集合中的所有節點的等級與 $\text{height}(T) - \text{level}(X) + 1$ 相同。所以,每當從紅-黑樹的根節點出來的一國路徑中有等級改變時,在相對的 2-3-4 樹中就有階層改變。黑指標由某一等級的節點指向等級少 1 的節點,而紅指標連接兩個等級相同的節點。」
簡言之:rank 就是「這個節點在原本的 2-3-4 樹裡位於第幾層」。紅指標是 2-3-4 節點內部的連接(不跨層),黑指標才是真正的層間連接。
每一有 $n$ 個(內部)節點的紅-黑樹 $RB$ 滿足下列各式:
(1) 就是紅黑樹的核心保證:高度不超過完美平衡樹的兩倍。理由是 (2)+(3):rank(黑高度)最多 $\log_2(n+1)$,而每一層 rank 之間最多夾一條紅指標,所以實際高度最多是 rank 的兩倍。
「插入可用兩種方式來執行:由上而下或由下而上。在由上而下插入中,在紅-黑樹中只做了一次由根到葉節點的處理過程。由下而上的插入則做了由根到葉節點與由葉節點到根的二次處理過程。」
| 由上而下 | 由下而上 | |
|---|---|---|
| 對應的 2-3-4 樹作法 | 預先分裂 4-節點 | 插入後再往上修復 |
| 需要堆疊/父指標 | 不需要 | 需要 |
| 旋轉型態 | — | LLb, LRb(圖 10.34)等 |
2-3-4 樹的節點有三種不同的大小(1、2、3 個元素,2、3、4 個子指標),記憶體配置與程式分支都很麻煩。紅黑樹把它壓成統一大小的二元節點 + 一個顏色位元,程式碼雖然旋轉的情況變多,但資料結構本身極為規整。這就是 C++ 的 std::map、Java 的 TreeMap、Linux 核心的排程器都選擇紅黑樹的原因。
「在分支度為 $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-樹。」
「當一個節點中要找尋的鍵值個數小時,可使用循環搜尋(如同在 2-3 樹或 2-3-4 樹的狀況下)。當這種個數很大時,可使用二分搜尋。」
度數(order)為 $m$ 的 B-樹是一個 m-路搜尋樹,它可能是空的,或者滿足下列特性:
「在定義 B-樹時,習慣重新提出失敗節點的觀念。請記住失敗節點代表在搜尋時,如果要找尋的值 $x$ 不在樹中才可到達的節點。」
$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+ 樹。
「如果索引有 $n$ 項,則度數為 $m = n+1$ 的 B-樹只有一層。這個 $m$ 的選擇顯然不合理,因為我們已假設索引太大而不能全部存入記憶體中。結果是以一個節點表示的索引無法讀入記憶體中處理。」
「為了達到比較合理的 $m$ 的選擇,我們必須記住我們真正的目的在使得搜尋 B-樹中的一個鍵值 $x$ 所需全部時間為最小。這個時間有兩個組成因素,其一,從磁碟讀取一個節點的時間,其二,找尋有 $x$ 值的節點。」
假設一個節點的大小約為 $m(\alpha + 2\beta)$ 字元,則讀取一個節點所需的時間 $t_i$ 為:
其中 $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$ 值與磁碟參數有關」是完全相同的權衡結構。
「[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$ —— 樹更矮、空間利用更好。
「如果有 $n$ 個鍵值在樹中,就有 $n$ 個葉節點。」—— B+ 樹把所有資料都放在葉節點,內部節點只存索引鍵。「因所有鍵值都是葉節點,它相當於永遠從葉節點刪除。」
好處:葉節點可以串成一條鏈結串列,範圍查詢(「找出所有 $50 \le x \le 100$ 的記錄」)只要找到起點再順著走 —— 這就是 B+ 樹主宰資料庫索引的原因。
「使用大小不同的節點並不值得推薦,因為它需要更複雜的記憶體管理系統。更重要的,使用大小不同的節點會造成插入的效率退化,因為插入元素到一個節點會要求我們去取得一個更大的節點來適應新插入的鍵值。結論是我們應使用大小相同的節點。其大小應該可以容納至少 $m-1$ 個最長的鍵值。但是,在插入期間我們可放鬆每一節點有 $\le m-1$ 個鍵值的要求。將它改成一個節點容許存放可以放入其內的最多個且至少 $\lceil m/2\rceil - 1$ 個鍵值。」
對付超長鍵值的方法:「另一種方法是改用其他的鍵值取樣方法,以減少鍵值長度,使它不會超過一個預定的大小 $d$。一些可能的取樣法為去掉字首,字尾,刪除母音等。不論使用何種方法,必須提供一些處理同義字(即不同的鍵值有相同的取樣值)的方法。」
第 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)$。
第 5 題:最簡單的作法是把 $U$ 的所有鍵值逐一插入 $T$,複雜度 $O(n_U \log n_T)$。較好的作法:若 $T$ 和 $U$ 的鍵值範圍不重疊(例如 $T$ 全部小於 $U$),可以像 2-3 樹的合併一樣,把較矮的樹接到較高的樹的適當位置,$O(|h_T - h_U| + 1)$。若範圍重疊則無法避免逐一插入。
「AVL,2-3,2-3-4,和紅-黑樹允許我們在 $O(\log n)$ 最差狀況時間內完成每一個搜尋樹的運算:插入,刪除和搜尋。在優先權佇列的例子中我們已看到,如果我們對分攤成本而不是對最差狀況複雜度有興趣,則可使用較簡單的結構。這對搜尋樹也是成立的。使用外張樹時,每一搜尋樹運算可以在 $O(\log n)$ 分攤時間內完成。」
「外張樹(splay tree)是一種二元搜尋樹,其中每一搜尋,插入,和刪除的方法都與原始的二元的搜尋樹(見第 5 章)相同。但是,在每一運算之後跟著一個向外張開(splay)運算。外張運算由一連串的旋轉構成。」
沒有平衡因子、沒有顏色、沒有等級 —— 節點結構與第 5 章的 BST 完全相同。全部的智慧都在「每次操作後把剛碰過的節點旋轉到根」這一條規則裡。
「外張樹分析則使用一種潛能(potential)技巧。令 $p_0$ 為搜尋樹最初的潛能,$p_i$ 為在一連串 $n$ 個運算的第 $i$ 個運算後的潛能。定義第 $i$ 個運算的分攤時間為:」
「就是說,分攤時間為實際時間加上潛能的改變。重新排列各項,可發現第 $i$ 個運算的實際時間為:」
「因此,執行連續 $n$ 個運算所需的實際時間為:」
| 第 9 章 9.4.1 節 | 本節 | |
|---|---|---|
| 方法 | 成本轉移(把昂貴運算的成本收給便宜的運算) | 潛能函數(定義一個「儲存的能量」$p_i$) |
| 直觀 | 「預付」 | 「銀行帳戶餘額」 |
| 優點 | 直觀易懂 | 更系統化,適用於複雜結構 |
關鍵在於 $p_0 - p_n$ 這一項:只要保證潛能永遠非負且初始潛能有界,總實際時間就被總分攤時間所控制。對外張樹,潛能定義為 $\sum_x \log_2(\text{size}(x))$,可以證明每個運算的分攤成本是 $O(\log n)$。
「因為每一運算之後跟著一個外張運算,其實際複雜度與全部運算的複雜度之次方相同,故只考慮執行外張所需的時間也就足夠了。」
外張樹的隱藏好處:局部性(locality)。剛存取過的節點會被移到根附近,所以反覆存取同一批鍵值時,外張樹會比 AVL 樹更快 —— 它自動適應了實際的存取模式。代價是單次操作可能是 $O(n)$,且每次讀取都會修改樹結構(對並行不友善)。
「數位搜尋樹的搜尋,插入和刪除程序式與二元樹的相關程序式非常的類似。基本的差別在於要移到那個子樹由搜尋鍵值的位元來決定,而不是由搜尋鍵值與現行節點的鍵值比較結果來決定。」
「各個上述搜尋樹運算可以在 $O(h)$ 時間內完成,其中 $h$ 為數位搜尋樹的高度。假設數位搜尋樹中的每一鍵值有 keysize 位元,則此數位搜尋樹的高度至多為 keysize + 1。」
數位搜尋樹的高度由鍵值長度決定,與元素個數 $n$ 無關。這和第 7 章基底排序打破 $\Omega(n\log n)$ 排序下限是同一個道理:不比較鍵值,就不受比較模型的下限約束。
「假設我們有很長的鍵值,則鍵值比較的成本是很高的。我們可以將鍵值比較次數降為 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 為一種二元搜尋樹,它有兩種節點分支節點(branch node)和元素節點(element node)。分支節點有兩個欄位 left_child 和 right_child。它沒有資料欄位。一個元素節點有一個 data 欄位。我們以分支節點來建立類似數位搜尋樹的二元樹搜尋結構。這種搜尋結構指向元素節點。」
Binary trie 的問題是會產生大量只有一個子節點的分支節點(當某段位元前綴只有一個鍵值符合時,還是要一位元一位元往下走)。Compressed binary trie 把這些單子節點的鏈壓縮成一個節點,並記錄「跳過了幾個位元」。Patricia 再進一步把元素節點與分支節點合而為一。結果是:$n$ 個鍵值恰好需要 $n$ 個節點,而且整個搜尋過程只做一次完整的鍵值比較(在最後確認時)。
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
if (!t) return NULL; —— 走到空的分支,鍵值不在樹中。if (t->tag == data) —— 到達元素節點,做唯一的一次 strcmp 確認。get_index(key,i) 取出第 $i$ 層的取樣值,往對應的分支繼續。「此函數使用函數 get_index(key,i),它執行第 $i$ 階層的鍵值取樣。在由左至右,單一字元的取樣中,此函數抽取鍵值的第 $i$ 個字元,並將它轉成一個整數值索引,以指出要使用分支節點的那一個指標欄位。」
「搜尋函數是直接的,我們可以驗証其最差狀況搜尋時間為 $O(l)$ 中 $l$ 是 trie 中的階層數(包括分支和元素兩種節點在內)。□」
注意「呼叫它以前,已經將一個空白附加在搜尋鍵值之後」—— 空白當作字串結尾的哨兵,確保短的鍵值能正確終止。
「對於一個索引的全部節點都儲存在磁碟的情況而言,搜尋時最多需要 $l$ 次的存取。給予一組要以索引表示的鍵值,trie 中的階層數根據用來決定每一階層的分歧之鍵值取樣方法而定。我們可使用取樣函數 sample(x,i) 來定義」取樣策略。
常見的取樣策略:由左至右取單一字元(最直觀);由右至左(對有共同字首的鍵值更好);挑選特定位置的字元(如果知道某些位置區分度高,可大幅降低樹高)。這與第 8 章 8.2.2 節的「數位分析法」是完全相同的思路。
| 主題 | 文獻 |
|---|---|
| $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 章的 $O$/$\Omega$/$\theta$ 是每一節分析的語言;第 5 章的二元搜尋樹是全章的起點,卡塔蘭數 $N(n)$ 說明了為何不能窮舉,公設 5.1 用於路徑長度分析;第 7 章的排序下限被用來證明「建立 AVL 樹至少要 $\Omega(n\log n)$」(習題 7);第 8 章的雜湊是本章的對照組,而 8.3 節的動態雜湊與 10.6 節的 B 樹解決的是同一個磁碟問題;第 9 章的成本分攤在 10.7 節以潛能法的形式再次出現。
如果要用一句話總結這本書:資料結構的設計,就是在「時間、空間、最差保證、實作複雜度」這四個軸上反覆做取捨,而每一次取捨都必須能被量化地說清楚。