Outline — 點擊展開各節
5.1 簡介
5.2 二元樹
5.3 二元樹之尋訪
5.4 其他的二元樹運算
5.5 引線樹
5.6 累堆 heaps
5.7 二元搜尋樹
5.8 / 5.9 選取樹與樹林
5.10 集合的表示法
5.11 計算二元樹之數目
Chapter 5 · Trees

第 5 章 樹狀結構

前四章的結構都是線性的 —— 一個元素最多一個後繼者。一旦允許多個後繼者,演算法的型態就整個改變:尋訪有了三種順序,搜尋降到 $O(\log n)$,而第 4 章的等價問題空間從 $O(m+n)$ 掉到 $O(n)$。

本章的三條主線
  1. 樹是什麼、怎麼存、怎麼走(5.1–5.5)—— 從一般樹化約到二元樹,三種尋訪順序,以及用引線把浪費的空指標變成有用的鏈結。
  2. 兩種「有規則」的二元樹(5.6–5.7)—— 累堆管的是「最大/最小在哪」,二元搜尋樹管的是「任一個鍵在哪」。兩者剛好互補,各自擅長的運算不同。
  3. 樹當成工具用(5.8–5.11)—— 用樹合併多個行程(選取樹)、用樹表示集合(union-find),最後發現「$n$ 個節點有幾種二元樹」「$n$ 個矩陣有幾種乘法順序」「堆疊能產生幾種排列」是同一個問題

最值得留意的一條伏筆:5.10 節的 union-find 把第 4 章 4.6 節的等價問題重做一次,空間從 $O(m+n)$ 降到 $O(n)$

5.1 簡介

5.1.1 專有名詞

直觀上而言,樹狀結構的觀念暗示了我們要組織資料,使其資料項的關係因分支而定。舉例而言,當我們要調查族譜時可應用樹狀結構。一般而言,有兩種族系圖可用來表示我們的資料,即血統表直系表

為什麼血統表一定是二元樹

「圖 5.1(a) 的血統表代表某人的祖先…… Dusty 的父母為 Honey Bear 和 Brandy。她的外祖父母為 Brunhilde 和 Terry,而祖父母為 Coyote 和 Nugget。如果我們禁止近親通婚,血統表就只能畫出二個分支。這樣的樹狀結構,稱為二元樹(binary tree),它有許多重要的應用。

定義 — 樹 tree

樹是一個或多個節點的有限集合,使得:(1) 有一個特別指定的節點稱為根(root);(2) 其餘的節點分成 $n \ge 0$ 個互斥的集合 $T_1, \ldots, T_n$,其中每一個集合也是一棵樹,稱為根的子樹(subtree)。

注意這是遞迴定義

注意,這是遞迴定義的一個實例,因為我們以樹狀結構來定義子樹。」因為 $T_1, \ldots, T_n$ 必須是獨立的集合,我們禁止子樹連接在一起,亦即,沒有同父異母或同母異父的情形發生。我們的定義也暗示了樹中每一個節點都是某些子樹的根

A BCD EFG HIJ KLM Level 1234
圖 5.2:樹狀結構範例 —— 13 個節點,根為 A,深度為 4。
名詞定義圖 5.2 中的例子
節點 node包含了資訊項目與可到其他節點的分支共 13 個
分支度 degree此節點的子樹個數A 為 3,C 為 1,F 為 0
樹的分支度此樹中最大的節點分支度3
葉節點 leaf / terminal分支度為的節點K, L, F, G, M, I, J
父節點 parent具有子樹的節點,為其子樹的根之父節點B 為 E 和 F 的父節點
子節點 children子樹的根為此節點的子節點E 和 F 為 B 的子節點
兄弟 siblings具有同一父節點的節點H, I, J 為兄弟
祖先 ancestors從根節點到此節點路徑上之所有節點M 的祖先有 A, D, H
後代 descendants在它的子樹上之所有節點B 的後代有 E, F, K, L
階層 level根節點位在階層 1;其下每層加 1M 在階層 4
高度/深度 height / depth樹中任一個節點最大的階層數4

5.1.2 樹狀結構表示法

串列表示法

圖 5.2 中的樹可寫成一個串列,其中的每一子樹也是一個串列。使用這個表示法,圖 5.2 的樹狀結構可寫成:

$$(A\ (B\ (E\ (K,L),\ F),\ C(G),\ D(\ H\ (M),\ I,\ J)\ )\ )$$

注意,在根節點中的資訊首先寫出,其後是此節點的子樹之串列。

為什麼鏈結表示法會有麻煩

「如果要以鏈結串列表示,則一個節點會有不同的欄位個數,視其分支的個數而定。」—— 圖 5.3 是 data | link 1 | link 2 | ... | link n。節點大小不固定,記憶體管理就困難。

左子-右弟表示法 (left child-right sibling)

關鍵觀察 —— 把任意分支度壓成 2

通常具有固定大小的節點比較容易運算,我們為樹狀結構設計這樣的表示法。」節點只有兩個鏈結欄位:left childright sibling

「要將圖 5.2 中的樹轉換成此種表示法,首先,我們注意到每一個節點僅有一個最左(left most)子節點,以及一個近右(closest right)兄弟節點。例如,圖 5.2 中 A 的最左子節點為 B,D 的最左子節點為 H。同樣地,B 的近右兄弟節點為 C,H 的近右兄弟節點為 I。因為子節點在樹中的順序並不重要,一個節點的任一子節點的均可以是它的最左子節點,且其任一兄弟節點均可以是它的近右兄弟節點。

再把它「旋轉 45 度」 —— 就變成二元樹

把左子-右弟樹順時鐘旋轉 45°,右弟鏈結就變成右子鏈結,得到圖 5.6 的左子-右子表示法這證明了「任何樹狀結構均可用一個二元樹來表示」 —— 這就是為什麼本書其餘部分幾乎只談二元樹。

5.2 二元樹

5.2.1 抽象資料型態

定義 — 二元樹 binary tree

二元樹是節點的有限集合,它可能是空的,或者包含一個根節點及二個獨立的二元樹,分別稱為左子樹和右子樹。

二元樹 vs. 樹 —— 兩個關鍵差別

「讓我們仔細地檢討一下二元樹和樹的差別:

  1. 首先,沒有樹可以是 0 個節點的,但可以有空的二元樹。
  2. 其次,在二元樹中我們要區別子樹的順序,而樹則不必。

因此,在圖 5.8 中的兩個二元樹是不相同的,因第一個二元樹的右子樹是空的,而第二個二元樹的左子樹是空的。如果看成一般樹,則它們是相同的,而不必考慮它們以稍微不同的方式畫出。」

structure Binary_Tree (abbreviated BinTree) is
  objects: a finite set of nodes either empty or consisting of a root node,
  left Binary_Tree, and right Binary_Tree.
  functions:
    for all bt, bt1, bt2 in BinTree, item in element

    BinTree Create()             ::= creates an empty binary tree
    Boolean IsEmpty(bt)          ::= if (bt == empty binary tree)
                                     return TRUE else return FALSE
    BinTree MakeBT(bt1,item,bt2) ::= return a binary tree whose left
                                     subtree is bt1, whose right subtree
                                     is bt2, and whose root node
                                     contains the data item.
    BinTree Lchild(bt)           ::= if (IsEmpty(bt)) return error else
                                     return the left subtree of bt.
    element Data(bt)             ::= if (IsEmpty(bt)) return error else
                                     return the data in the root node of bt.
    BinTree Rchild(bt)           ::= if (IsEmpty(bt)) return error else
                                     return the right subtree of bt.

end Binary_Tree

結構 5.1:抽象資料型態 Binary Tree —— 「此結構僅定義一組最少的二元樹之運算,它們可作為建立其他的運算之基礎。」

5.2.2 二元樹的特性

公設 5.1 — 節點的最多個數

(1) 在二元樹的階層 $i$ 上最多的節點個數為 $2^{i-1}$,$i \ge 1$。
(2) 在深度為 $k$ 的二元樹中最多的節點個數為 $2^k - 1$,$k \ge 1$。

證明 (1):根據 $i$,以歸納法來証明。

  • 歸納法的基礎:根是在階層 $i=1$ 的唯一節點。所以,在階層 $i=1$ 上最大的節點個數為 $2^{i-1}=2^0=1$。
  • 歸納法的假設:對於所有的 $j$,$1 \le j \lt i$,在階層 $j$ 上最多的節點個數為 $2^{j-1}$。
  • 歸納法的步驟:根據假設,在階層 $i-1$ 上最多的節點個數為 $2^{i-2}$。因為二元樹中每個節點的分支度最大為 2,則在階層 $i$ 上最多的節點個數為階層 $i-1$ 上最多的節點個數之兩倍,即 $2^{i-1}$。

證明 (2):

$$\sum_{i=1}^{k}(\text{在階層 } i \text{ 上最多的節點個數}) = \sum_{i=1}^{k} 2^{i-1} = 2^k - 1$$
公設 5.2 — 葉節點個數與分支度為 2 的節點個數之關係

對任一非空的二元樹 $T$,如果 $n_0$ 為葉節點的個數,$n_2$ 為分支度為 2 的節點個數,則

$$\boxed{\,n_0 = n_2 + 1\,}$$

證明:令 $n_1$ 為分支度為 1 的節點個數,$n$ 為全部的節點個數。因為 $T$ 中所有節點的分支度最大為 2,故:

$$n = n_0 + n_1 + n_2 \tag{5.1}$$

如果要計算二元樹中分枝的個數,可以發現除了根以外的每一個節點都有一個分枝可以導向它。如果 $B$ 是分枝的個數,則 $n = B+1$。所有的分枝均由分支度為 1 或 2 的節點伸出。所以 $B = n_1 + 2n_2$。因此,可得到:

$$n = 1 + n_1 + 2n_2 \tag{5.2}$$

從 (5.1) 式減去 (5.2) 式,並重新安排式子,可得 $n_0 = n_2 + 1$。□

完全二元樹 full binary tree

一深度為 $k$ 的完全二元樹即是深度為 $k$ 且具 $2^k-1$ 個節點,$k \ge 0$ 的二元樹。(即每一層都填滿。)

完整二元樹 complete binary tree

一個二元樹有 $n$ 個節點且深度為 $k$,若且唯若它的節點和一個深度為 $k$ 的完全二元樹中編號從 1 到 $n$ 的節點一致時,就稱它為完整二元樹。(即最後一層可以不滿,但必須靠左填滿。)

5.2.3 二元樹表示法

公設 5.3 — 陣列表示法的定址

如果一個完整二元樹有 $n$ 個節點(深度 $= \lfloor \log_2 n\rfloor + 1$)以循序法表示,則任一節點 $i$,$1 \le i \le n$,我們可得到:

  1. parent(i) 位在 $\lfloor i/2 \rfloor$,若 $i \ne 1$。如果 $i=1$,$i$ 為根節點,且沒有父節點。
  2. left_child(i) 位在 $2i$,若 $2i \le n$。如果 $2i \gt n$,則 $i$ 沒有左子節點。
  3. right_child(i) 位在 $2i+1$,若 $2i+1 \le n$。如果 $2i+1 \gt n$,則 $i$ 沒有右子節點。

證明:我們只證明 (2)。(3) 可由 (2) 獲得立即的結論,且在同一階層上的節點由左而右編號。(1) 則遵照 (2) 和 (3) 的規定。我們根據 $i$,以歸納法來證明 (2)。對於 $i=1$,明顯地左子節點在 2,除非 $2 \gt n$,此情況下 $i$ 沒有左子節點。□

陣列表示法什麼時候好,什麼時候糟

對於完整二元樹而言,循序表示法是可接受的,但對其他的多種二元樹而言,它則浪費空間。此外,這種表示法受困於循序表示法共同的缺點。即,在一樹狀結構中插入或刪除一個節點可能需要移動多個節點以反應這些節點在階層上的改變。」

最壞的例子是傾斜樹(skewed tree):深度 $k$ 的傾斜樹只有 $k$ 個節點,卻要 $2^k-1$ 個陣列位置。

typedef struct node *tree_pointer;
typedef struct node {
        int data;
        tree_pointer left_child, right_child;
        };

鏈結表示法 —— 「採用鏈結表示法即可輕易地克服這個問題。」若必須知道任一節點的父節點,可以加入第四個欄位 parent

習題 1 — 二元樹的特性(5.2 節習題精選)
  1. 一個深度為 $k$ 的完全二元樹有幾個葉節點?幾個分支度為 2 的節點?用公設 5.2 驗證你的答案。
  2. 證明:一個有 $n$ 個節點的完整二元樹,其深度為 $\lfloor \log_2 n \rfloor + 1$。
  3. 一個有 $n$ 個節點的二元樹,它的鏈結表示法中有幾個空指標?
點擊展開解題要點

第 1 題:深度 $k$ 的完全二元樹有 $2^k-1$ 個節點,最底層(階層 $k$)有 $2^{k-1}$ 個葉節點,其餘 $2^{k-1}-1$ 個全是分支度 2 的內部節點。驗證公設 5.2:$n_0 = 2^{k-1}$,$n_2 = 2^{k-1}-1$,確實 $n_0 = n_2+1$ ✓(注意完全二元樹中 $n_1 = 0$)。

第 2 題:深度 $d$ 的完整二元樹,節點數 $n$ 滿足 $2^{d-1} \le n \le 2^d - 1$。取 $\log_2$:$d-1 \le \log_2 n \lt d$,所以 $d = \lfloor \log_2 n \rfloor + 1$。

第 3 題(5.5 節的伏筆):$n$ 個節點共有 $2n$ 個指標欄位,其中 $n-1$ 個指向實際的子節點(每個非根節點被恰好一個指標指到),所以空指標有 $2n-(n-1) = \boxed{n+1}$ 個。「在全部 $2n$ 個指標中有 $n+1$ 個空指標」 —— 這正是 5.5 節引線樹要利用的資源。

5.3 二元樹之尋訪

在樹狀結構上可執行多種運算,但常用的一種是尋訪樹狀結構(traversing a tree),即走訪樹中的每一個節點,且每個節點恰好被尋訪一次。完全的尋訪可產生儲存在樹裏的資訊之線性序列。

為什麼只有三種順序

假設我們以 LVR 代表向左移動、拜訪節點(例如,印出資料欄位)和向右移動,則有六種可能的尋訪組合:LVR、LRV、VLR、VRL、RVL 和 RLV。

假設我們接受一種慣用法,先尋訪左邊,後尋訪右邊,則只剩下三種尋訪順序:

順序名稱意義
LVR中序法 inorder先左子樹、再自己、後右子樹
LRV後序法 postorder兩個子節點都走完才拜訪自己
VLR先序法 preorder先拜訪自己,再走子樹
名稱怎麼來的

它們是根據 V 和 L,R 相對的位置來命名的。舉例而言,在後序法中,當我們已經尋訪過一個節點的左子樹和右子樹以後才拜訪此節點,而在先序法中,拜訪節點在尋訪子樹之前先執行。」

貫串全節的範例:運算式 A/B*C*D+E

「一個運算式與這三種走訪順序之間有一種自然的對應關係,並可產生中序,後序和先序的運算式表示法。」圖 5.15 的二元樹代表算術運算式(中序表式法)A/B*C*D+E

+*E *D /C AB 1217 314 411 58 中序  inorder  (LVR) A / B * C * D + E 後序 postorder (LRV) A B / C * D * E +
圖 5.15:以二元樹表示一個運算式(節點編號同課本;空節點未畫出)。中序尋訪的結果就是中序表示法,後序尋訪的結果就是後序表示法 —— 這正是第 3 章 3.4 節後序運算式的來源。

三個遞迴函數

void inorder(tree_pointer ptr)
/* inorder tree traversal */
{
   if (ptr) {
      inorder(ptr->left_child);
      printf("%d",ptr->data);
      inorder(ptr->right_child);
   }
}

程式 5.1:二元樹的中序尋訪 —— 輸出 A / B * C * D + E

void preorder(tree_pointer ptr)
/* preorder tree traversal */
{
   if (ptr) {
      printf("%d",ptr->data);
      preorder(ptr->left_child);
      preorder(ptr->right_child);
   }
}

程式 5.2:二元樹的先序尋訪 —— 輸出 + * * / A B C D E

void postorder(tree_pointer ptr)
/* postorder tree traversal */
{
   if (ptr) {
      postorder(ptr->left_child);
      postorder(ptr->right_child);
      printf("%d",ptr->data);
   }
}

程式 5.3:二元樹的後序尋訪 —— 輸出 A B / C * D * E +

三個函數只差一行的位置

注意三個函數的程式碼完全相同,只是 printf 那一行放在三個不同的位置。這就是「V 相對於 L、R 的位置」這句話在程式上的直接對應。因為樹中有 19 個節點(含空節點),一個完整的尋訪將會呼叫 inorder 19 次。

迴路式中序尋訪

「雖然我們以遞迴函數來設計中序,先序和後序尋訪,我們也可以設計對等的迴路式函數。要模擬遞迴呼叫,我們必須建立自己的堆疊。我們要使用和遞迴版本處理系統堆疊的相同方法對我們的堆疊加入或刪除節點。這可幫助我們完全地瞭解遞迴版的運算方式。

void iter_inorder(tree_pointer node)
{
   int top = -1; /* initialize stack */
   tree_pointer stack[MAX_STACK_SIZE];
   for (;;) {
      for (; node; node = node->left_child)
         add(&top, node); /* add to stack */
      node = delete(&top); /* delete from stack */
      if (!node) break; /* empty stack */
      printf("%d", node->data);
      node = node->right_child;
   }
}

程式 5.4:迴路式中序尋訪

分析 iter_inorder

令 $n$ 為樹中節點的個數。「如果仔細考慮 iter_inorder 的動作,我們發現樹中的每一個節點恰好被放入堆疊一次,由堆疊取出一次。因此,如果樹的節點個數為 $n$,其時間複雜度為 $O(n)$。空間的需求與樹的深度相同,也是 $O(n)$。

空間是 $O(n)$ 而非 $O(\log n)$,因為最差狀況是傾斜樹 —— 深度等於 $n$。

階序尋訪 (level order)

唯一需要「佇列」而非「堆疊」的尋訪

不論以迴路或遞迴方式設計程式,中序,先序和後序尋訪都需要一個堆疊。現在,讓我們討論一種需要一個佇列的尋訪方法。此尋訪方法稱為階序(level order),以圖 5.10 所建議的編號方法拜訪節點。所以,我們首先拜訪根節點,而後是根的左子節點,接著是根的右子節點。持續以這種方式拜訪每一階層中的節點,由最左邊的節點到最右邊的節點。」

void level_order(tree_pointer ptr)
/* level order tree traversal */
{
   int front = rear = 0;
   tree_pointer queue[MAX_QUEUE_SIZE];
   if (!ptr) return; /* empty tree */
   addq(front, &rear, ptr);
   for (;;) {
      ptr = deleteq(&front, rear);
      if (ptr) {
         printf("%d",ptr->data);
         if (ptr->left_child)
            addq(front,&rear,ptr->left_child);
         if (ptr->right_child)
            addq(front,&rear,ptr->right_child);
      }
      else break;
   }
}

程式 5.5:二元樹的階序尋訪 —— 圖 5.15 的結果為 + * E * D / C A B

習題 2 — 尋訪(5.3 節習題)
  1. 針對圖 5.14 的 (a),寫出其中序,先序,後序和階序尋訪結果。
  2. 以圖 5.14(b) 重做習題 1。
  3. 以圖 5.14(a) 模擬 iter_inorder 之動作。對每一步驟說明堆疊的內容以及所採取的行動,如果有的話(換言之,指出資料欄的內容是否印出)。
  4. 設計迴路版的 preorder
  5. 設計迴路版的 postorder
  6. 假設有一個姓名二元樹(圖 5.17)。證明中序尋訪恆以字母順序印出姓名。
點擊展開解題要點

第 4 題(迴路式 preorder,比中序簡單):先序的迴路版最乾淨 —— 直接用堆疊,右子先推、左子後推(因為堆疊是 LIFO,後推的先出):

void iter_preorder(tree_pointer node)
{
   int top = -1;
   tree_pointer stack[MAX_STACK_SIZE];
   if (!node) return;
   add(&top, node);
   while (top > -1) {
      node = delete(&top);
      printf("%d", node->data);
      if (node->right_child) add(&top, node->right_child);
      if (node->left_child)  add(&top, node->left_child);
   }
}

第 5 題(迴路式 postorder,最難的一個):後序需要知道「右子樹是否已經走過」。兩種標準作法:(a) 每個堆疊項附一個旗標;(b) 記住 last_visited 節點,若 node->right_child == last_visited 就表示右子樹剛走完,可以拜訪自己。投機取巧法:做「根-右-左」的先序尋訪,再把輸出反轉,就得到「左-右-根」的後序 —— 但這需要 $O(n)$ 額外空間存輸出。

第 6 題(重要的證明):「姓名二元樹」就是 5.7 節的二元搜尋樹。用歸納法:對任一節點,其左子樹所有鍵值 $\lt$ 該節點 $\lt$ 右子樹所有鍵值。中序尋訪先輸出整個左子樹(依歸納假設已排序且全部小於本節點)、再輸出本節點、再輸出整個右子樹(已排序且全部大於本節點)—— 三段串起來仍是遞增的。□ 這個性質就是 BST 的全部價值所在。

5.4 其他的二元樹運算

「利用二元樹的定義以及遞迴版的中序,先序和後序尋訪,我們可以很容易地產生其他的二元樹運算之 C 函數。」

tree_pointer copy(tree_pointer original)
/* this function returns a tree_pointer to an exact copy
of the original tree */
{
   tree_pointer temp;
   if (original) {
      temp = (tree_pointer) malloc(sizeof(node));
      if (IS_FULL(temp)) {
         fprintf(stderr, "The memory is full\n");
         exit(1);
      }
      temp->left_child = copy(original->left_child);
      temp->right_child = copy(original->right_child);
      temp->data = original->data;
      return temp;
   }
   return NULL;
}

程式 5.6:複製二元樹 ——「請留意此函數是 postorder(程式 5.3)稍作修改所得到的版本。」

int equal(tree_pointer first, tree_pointer second)
{
/* function returns FALSE if the binary trees first and
second are not equal, Otherwise it returns TRUE */
   return ((!first && !second) || (first && second &&
           (first->data == second->data) &&
           equal(first->left_child,second->left_child) &&
           equal(first->right_child, second->right_child)))
}

程式 5.7:測試二元樹是否對等 ——「以先序尋訪的改良版來測試」

對等的定義

對等的二元樹有相同的構造,且在對應的節點中之資訊也是相同的。相同的構造表示第一個樹狀結構中的每一個分枝對應於第二個樹狀結構中的每一個分枝,亦即兩樹的分枝完全相同。」注意 equal 的三個短路條件:兩者皆空 → TRUE;一空一非空 → FALSE;兩者皆非空 → 比資料再比兩棵子樹。

判斷命題是否成立 (satisfiability)

考慮一組可以用變數 $x_1, x_2, \ldots, x_n$ 和運算符號 $\land$ (and)、$\lor$ (or) 與 $\lnot$ (not) 構成的公式。變數值只能是 true 或 false 之一。運算式的定義規則:

  1. 變數是一個運算式
  2. 若 $x$、$y$ 為運算式,則 $\lnot x$、$x \land y$、$x \lor y$ 也是運算式
  3. 一般的計算順序為 $\lnot$ 在 $\land$ 之前,$\land$ 在 $\lor$ 之前,括號可用來改變一般的計算順序
為什麼這是個「困難」的問題

若運算式有 $n$ 個變數,則共有 $2^n$ 種 true/false 的組合要試。「演算法需時 $O(g\,2^n)$ 時間,其中 $g$ 為將變數 $x_1, x_2, x_3$ 以 true 和 false 取代,以計算運算式之值所需的時間。」—— 這就是著名的 SAT 問題,至今沒有已知的多項式時間演算法。回顧第 1 章圖 1.9:$2^n$ 在 $n \gt 40$ 就不可行了。

void post_order_eval(tree_pointer node)
{
/* modified post order traversal to evaluate a
propositional calculus tree */
   if (node) {
      post_order_eval(node->left_child);
      post_order_eval(node->right_child);
      switch(node->data) {
         case not:   node->value =
                !node->right_child->value;
                break;
         case and:   node->value =
                node->right_child->value &&
                node->left_child->value;
                break;
         case or:    node->value =
                node->right_child->value ||
                node->left_child->value;
                break;
         case true:  node->value = TRUE;
                break;
         case false: node->value = FALSE;
      }
   }
}

程式 5.9:post_order_eval 函數

為什麼用後序

「為了計算運算式之值,我們可用後序法,對每一個子樹作計算,直到整個運算式變成單一值。這種方法和算術運算式的後序計算方法相當。從樹狀結構表示法的觀點來看,對所到達的每一節點而言,我們已計算其子節點之值。

注意 not 的處理:「應注意包含 $\lnot$ 的節點只有單一的右分枝,因為 not 為單一運算元的運算符號。」所以 case not 只用 right_child

習題 — 其他運算(5.4 節習題)
  1. 以 C 函數來計算二元樹中葉節點的個數。決定此函數的計算時間。
  2. 設計一個 C 函數 swap_tree,它輸入一個二元樹,並將每一節點的左、右子節點調換。
  3. 函數 post_order_eval 的計算時間為何?
  4. § [Programming project] 設計一個命題學的公式之表示法。設計一個 C 函數來輸入這樣的公式,並建立一個代表它的二元樹。決定你的函數之計算時間。
點擊展開解題要點

第 1 題:

int leaf_count(tree_pointer ptr)
{
   if (!ptr) return 0;
   if (!ptr->left_child && !ptr->right_child) return 1;
   return leaf_count(ptr->left_child) + leaf_count(ptr->right_child);
}

時間 $\theta(n)$ —— 每個節點恰好拜訪一次。

第 2 題:swap_tree 也是後序尋訪的變形:先遞迴處理兩棵子樹,再交換兩個指標。時間 $\theta(n)$。注意:不能先交換再遞迴,否則會重複處理。(其實先交換再遞迴也對,只是遞迴的對象要跟著換 —— 容易寫錯,後序最安全。)

第 3 題:$\theta(n)$,$n$ 為樹的節點數。每個節點做一次 switch,是常數時間。但整個 satisfiability 演算法是 $O(g\,2^n)$,因為要對 $2^n$ 組變數值各呼叫一次 post_order_eval —— 別把「一次求值」和「整個演算法」搞混,這是本節最常見的誤答。

5.5 引線樹 (threaded binary trees)

出發點:$n+1$ 個被浪費的空指標

「如果仔細查看任何二元樹的鏈結表示法,將會發現其中的空指標比實際的指標多。更明白地說,在全部 $2n$ 個指標中有 $n+1$ 個空指標。A. J. Perlis 與 C. Thornton 設計了一種聰明的方法來利用這些空指標。他們將空指標以一個指向其他節點的指標替代,稱其為引線(threads)。

建立引線的兩條規則
  1. 如果 ptr->left_child 為空的,將 ptr->left_child 以指向一個節點的指標取代,此節點是中序尋訪時在 ptr 之前拜訪的節點。亦即,我們將空指標替換成指向 ptr中序前行節點(inorder predecessor)之指標。
  2. 如果 ptr->right_child 為空的,將 ptr->right_child 以指向一個節點的指標取代,此節點是中序尋訪時在 ptr 之後拜訪的節點。亦即,我們將空指標替換成指向 ptr中序後續節點(inorder successor)之指標。
typedef struct threaded_tree *threaded_pointer;
typedef struct threaded_tree {
        short int left_thread;
        threaded_pointer left_child;
        char data;
        threaded_pointer right_child;
        short int right_thread;
        };

「當我們將樹狀結構儲存在記憶體時,必須能夠區別引線和一般的指標。這可以在節點構造中再加入兩個欄位來達成,即 left_threadright_thread。」

條件意義
ptr->left_thread == TRUEptr->left_child 包含一個引線
ptr->left_thread == FALSEptr->left_child 包含指向左子節點的指標
ptr->right_thread == TRUEptr->right_child 包含一個引線
ptr->right_thread == FALSEptr->right_child 包含指向右子節點的指標
懸盪的引線 —— 為什麼引線樹一定要有標頭節點

中序尋訪的第一個節點沒有前行者最後一個節點沒有後續者,這兩條引線無處可指。「明顯地,我們不願失去樹中的引線。所以,假設每一引線二元樹都有一個標頭節點。這表示空的引線樹永遠包含一個標頭節點。」—— 「應注意,我們已經令懸盪的指標向指標頭節點,root,來處理失去引線的問題。」

引線二元樹的中序尋訪

找中序後續節點的規則

「觀查引線二元樹中的任一節點 ptr如果 ptr->right_thread == TRUE,則根據引線的定義 ptr 的中序後續節點為 ptr->right_child。否則,ptr 的中序後續節點可沿著 ptr 的右子節點之左子節點鏈結路徑,直到遇見 left_thread == TRUE 時獲得。

threaded_pointer insucc(threaded_pointer tree)
{
/* find the inorder sucessor of tree in a threaded binary
tree */
   threaded_pointer temp;
   temp = tree->right_child;
   if (!tree->right_thread)
      while (!temp->left_thread)
         temp = temp->left_child;
   return temp;
}

程式 5.10:找尋一個節點的中序後續節點 ——不須使用堆疊

void tinorder(threaded_pointer tree)
{
/* traverse the threaded binary tree inorder */
   threaded_pointer temp = tree;
   for (;;) {
      temp = insucc(temp);
      if (temp == tree) break;
      printf("%3c", temp->data);
   }
}

程式 5.11:引線二元樹的中序尋訪

引線樹買到了什麼

「此函數假設標頭節點之左子節點指向樹結構,標頭節點右引線為 FALSE。函數 tinorder 對具有 $n$ 個節點的引線二元樹的計算時間仍為 $O(n)$,但其常數因子小於 iter_inorder 的常數因子。

iter_inordertinorder
時間$O(n)$$O(n)$,常數因子較小
額外空間$O(n)$(堆疊)$O(1)$
節點成本2 個指標2 個指標 + 2 個布林旗標

真正的收穫是空間從 $O(n)$ 降到 $O(1)$ —— 用每個節點兩個位元換掉整個堆疊。

在引線二元樹中插入節點

「我們只討論插入新的節點作為現有的節點 parent 之右子節點之狀況,插入左子節點則留作習題。」

情況一:parent 的右子樹是空的

假設有一節點 parent,其右子樹是空的。我們希望將 child 插入,當成 parent 的右子節點。要這樣做,必須:

  1. 改變 parent->right_thread 為 FALSE
  2. 設定 child->left_threadchild->right_thread 為 TRUE
  3. 設定 child->left_child 指向 parent
  4. 設定 child->right_child 指向 parent->right_child
  5. 改變 parent->right_child 指向 child
情況二:parent 的右子樹不是空的

「插入稍微困難一些,因為 parent 的右子樹在插入以後變成 child 的右子樹。當這工作完成後,child 變成原來是 parent 的中序後續者的節點之中序前行節點。」—— 所以必須用 insucc(child) 找到那個節點,把它的 left_child 引線改指向 child

void insert_right(threaded_pointer parent,
                                threaded_pointer child)
{
/* insert child as the right child of parent in a threaded
binary tree */
   threaded_pointer temp;
   child->right_child = parent->right_child;
   child->right_thread = parent->right_thread;
   child->left_child = parent;
   child->left_thread = TRUE;
   parent->right_child = child;
   parent->right_thread = FALSE;
   if (!child->right_thread) {
      temp = insucc(child);
      temp->left_child = child;
   }
}

程式 5.12:在引線二元樹中插入右子節點 —— 最後三行就是處理「情況二」

習題 — 引線樹(5.5 節習題)
  1. 畫出圖 5.14(a) 的二元樹之引線二元樹表示法。
  2. 使用圖 5.14(b) 的二元樹,重做習題 1。
  3. 設計一個函數 insert_left,它插入一個新的節點 child 當成引線二元樹中的節點 parent 之左子節點。parent 的左子節點指標變成指向 child 的左子節點指標。
  4. 設計一個函數以後序法尋訪一個引線二元樹。對於你的方法,其時間和空間需求為何?
  5. 設計一個函數以先序法尋訪一個引線二元樹。對於你的方法,其時間和空間需求為何?
點擊展開解題要點

第 3 題:insert_right 完全對稱,把每個 right 換成 leftinsucc 換成 inpred(中序前行節點)。

第 5 題(先序,較容易):在引線樹中先序的後續節點很好找:
• 若有左子節點(left_thread == FALSE)→ 後續就是左子節點;
• 否則若有右子節點 → 後續是右子節點;
• 否則沿著右引線往上走,直到找到一個有右子節點的祖先,其右子節點即為後續。
時間 $O(n)$、空間 $O(1)$。

第 4 題(後序,困難得多):後序尋訪需要從一個節點找到它的父節點(因為要判斷自己是左子還右子),而引線樹的引線指的是中序前行/後續,不保證能到達父節點。結論:用標準的引線無法在 $O(1)$ 空間內做後序尋訪;必須額外加 parent 欄位(空間 $O(n)$ 但不是堆疊),或退回使用堆疊($O(n)$)。這題的重點就是要你發現「引線對中序最自然,對先序還行,對後序幫不上忙」。

5.6 累堆 (heaps)

5.6.1 累堆抽象資料型態

四個定義
名稱定義
最大樹 max tree一種樹,其中每一節點的鍵值不小於它的子節點(如果存在時)的鍵值
最大累堆 max heap一種也是最大樹的完整二元樹
最小樹 min tree一種樹,其中每一節點的鍵值不大於它的子節點(如果存在時)的鍵值
最小累堆 min heap一種也是最小樹的完整二元樹

「從累堆的定義可知,在最小樹的根節點包含樹中最小的鍵值,而最大樹的根節點包含樹中最大的鍵值。

為什麼累堆用陣列而不用指標

「注意,我們以陣列來表示一個累堆,雖然我們沒有使用到位置 0。這樣使我們可利用公設 5.3 中的定址方法。」—— 累堆是完整二元樹,所以陣列表示法沒有浪費空間,而且 parent(i) = i/2 是一個位移運算,比追指標快得多。

structure MaxHeap is
  objects: a complete binary tree of n > 0 elements organized so that
  the value in each node is at least as large as those in its children
  functions:
    for all heap in MaxHeap, item in Element, n, max_size in integer

    MaxHeap Create(max_size)     ::= create an empty heap that can hold a
                                     maximum of max_size elements.
    Boolean HeapFull(heap, n)    ::= if (n == max_size) return TRUE
                                     else return FALSE
    MaxHeap Insert(heap, item, n)::= if (!HeapFull(heap, n)) insert item
                                     into heap and return the resulting
                                     heap else return error.
    Boolean HeapEmpty(heap, n)   ::= if (n > 0) return TRUE
                                     else return FALSE
    Element Delete(heap, n)      ::= if (!HeapEmpty(heap, n)) return one
                                     instance of the largest element in
                                     the heap and remove it from the heap
                                     else return error.

end MaxHeap

結構 5.2:抽象資料型態 MaxHeap

優先佇列表示法的比較

累堆不是唯一的選擇 —— 但它是最平衡的

累堆是製作優先佇列的唯一方法。所以,在我們討論不同的累堆運算之前,首先討論一些別的表示法。」(原文如此;由下表可見其他表示法也能做,只是各有極端的偏向。)

表示法插入時間刪除時間
未排序陣列$\Theta(1)$$\Theta(n)$
未排序鏈結串列$\Theta(1)$$\Theta(n)$
已排序陣列$O(n)$$\Theta(1)$
已排序鏈結串列$O(n)$$\Theta(1)$
最大累堆$O(\log_2 n)$$O(\log_2 n)$

圖 5.27:優先佇列表示法 —— 其他四種都在插入和刪除之間走極端,只有累堆兩邊都是對數時間

5.6.2 插入與刪除

插入到最大累堆

插入的策略:放到最後,然後往上冒

新元素先放在下一個可用的位置(即 heap[n+1],這保證了仍是完整二元樹),然後沿著到根節點的路徑往上比較:只要新元素比父節點大,就把父節點下移,自己繼續往上。

#define MAX_ELEMENTS 200 /* maximum heap size+1 */
#define HEAP_FULL(n) (n == MAX_ELEMENTS-1)
#define HEAP_EMPTY(n) (!n)
typedef struct {
        int key;
        /* other fields */
        } element;
element heap[MAX_ELEMENTS];
int n = 0;
void insert_max_heap(element item, int *n)
{
/*insert item into a max heap of current size *n */
   int i;
   if (HEAP_FULL(*n)){
      fprintf(stderr, "The heap is full. \n");
      exit(1);
   }
   i = ++(*n);
   while ((i != 1) && (item.key > heap[i/2].key)) {
      heap[i] = heap[i/2];
      i /= 2;
   }
   heap[i] = item;
}

程式 5.13:插入元素到最大累堆

分析 insert_max_heap

「這可以沿著從最大累堆新的葉節點到根節點的一個路徑移動,一直到遇到根節點,或遇到一個位置 $i$,其在 $i/2$ 的父節點之鍵值至少和插入的鍵一樣大為止。因累堆是具有 $n$ 個元素的完整二元樹,其高度為 $\lceil \log_2(n+1) \rceil$。這可示 while 迴圈重覆 $O(\log_2 n)$ 次。所以此插入函數的複雜度為 $O(\log_2 n)$。

注意這裡沒有真正的「交換」

程式裡是 heap[i] = heap[i/2];(父節點下移),最後才 heap[i] = item;只寫一次 item,而不是每層做一次三行的 SWAP —— 這和第 2 章 paddrear 指標的精神一樣:把重複的搬移攤平成一次。

從最大累堆刪除

刪除的策略:拿走根,把最後一個搬上來,然後往下沉

最大的元素永遠在 heap[1]。取走它之後,把最後一個元素 heap[n] 拿來當暫時的根(這維持了完整二元樹),然後往下沉:每層和較大的那個子節點比較,若暫存元素較小就把該子節點上移。

element delete_max_heap(int *n)
{
/* delete element with the highest key from the heap */
   int parent, child;
   element item, temp;
   if (HEAP_EMPTY(*n)) {
      fprintf(stderr, "The heap is empty\n");
      exit(1);
   }
   /* save value of the element with the highest key */
   item = heap[1];
   /* use last element in heap to adjust heap */
   temp = heap[(*n)--];
   parent = 1;
   child = 2;
   while (child <= *n) {
      /* find the larger child of the current parent */
      if ((child < *n) &&
          (heap[child].key < heap[child+1].key))
         child++;
      if (temp.key >= heap[child].key) break;
      /* move to the next lower level */
      heap[parent] = heap[child];
      parent = child;
      child *= 2;
   }
   heap[parent] = temp;
   return item;
}

程式 5.14:從最大累堆刪除節點 —— 同樣是 $O(\log_2 n)$

三個容易寫錯的地方
  1. if ((child < *n) && ...) —— 這個 child < *n 是檢查右子節點存在。少了它,當節點只有左子時會讀到陣列外。
  2. 必須和較大的子節點交換(最大累堆)。和較小的換會破壞累堆性質。
  3. temp = heap[(*n)--]; 一行同時取出最後元素縮小累堆 —— 順序不能顛倒。
習題 3 — 累堆(5.6 節習題)
  1. 另外兩種可能的優先佇列表示法為環狀,雙向鏈結,未排序串列與環狀,雙向鏈結,已排序串列。在圖 5.27 中加入這兩種表示法。說明你對它們的插入和刪除時間的估計。
  2. 假設有下列的鍵值:7, 16, 49, 82, 5, 31, 6, 2, 44。(a) 畫出在每一個鍵值插入後的最大累堆。(b) 畫出在每一個鍵值插入後的最小累堆
  3. 設計一個 C 函數來改變在最大累堆中任一元素的優先權。所形成的累堆必須符合最大累堆之定義。你的函數之計算時間為何?
  4. 設計一個 C 函數來刪除最大累堆中的任一元素(被刪除的元素可在累堆的任何位置)。(提示:將元素的優先權改成大於根節點的優先權,利用習題 3 的改變優先權函數後,再使用 delete_max_heap。)
  5. 設計一個 C 函數來搜尋最大累堆中的任一元素。你的函數之計算時間為何?
  6. 針對一個以鏈結二元樹表示的最大累堆,設計它的插入和刪除函數。假設每一個節點具有父節點欄位以及一般的左子節點,資料,和右子節點欄位。
點擊展開解題要點

第 1 題:環狀雙向鏈結未排序串列:插入 $\Theta(1)$(插在頭),刪除 $\Theta(n)$(要掃描找最大)。環狀雙向鏈結已排序串列:插入 $O(n)$(要找位置),刪除 $\Theta(1)$(最大在頭)。和陣列版時間相同 —— 雙向鏈結的好處是「已知節點就能 $O(1)$ 刪除」,但對「找最大」沒有幫助。

第 3 題:改變優先權後,若變大就往上冒(同 insert 的 while 迴圈),若變小就往下沉(同 delete 的 while 迴圈)。時間 $O(\log_2 n)$。

第 4 題:照提示做,總時間 $O(\log_2 n)$ + 找到那個元素的時間

第 5 題(關鍵認知):在最大累堆中搜尋任一元素需要 $O(n)$ —— 累堆保證父子之間的順序,兄弟之間完全沒有順序,所以搜尋時無法決定該往左還往右,必須全部掃描。這正是 5.7 節開頭那句話的意思:「從有 $n$ 個元素的累堆中刪除任一個元素需要 $O(n)$ 時間。這並沒有比從未排序的串列中刪除任一個元素所需的時間要好。同樣地,在累堆中搜尋任一個元素也需要 $O(n)$ 時間。」

5.7 二元搜尋樹

5.7.1 簡介

累堆的弱點,正是 BST 的起點

「雖然累堆非常適合於需要優先權佇列的應用,它卻不適合於必須刪除任意元素的應用。從有 $n$ 個元素的累堆中刪除任一個元素需要 $O(n)$ 時間。這並沒有比從未排序的串列中刪除任一個元素所需的時間要好。同樣地,在累堆中搜尋任一個元素也需要 $O(n)$ 時間。

當所要執行的運算為插入,刪除,與搜尋時,比較目前我們所學過的任何資料結構,二元搜尋樹有較好的效率。事實上,利用二元搜尋樹,我們可由鍵值(例如,刪除鍵值為 $x$ 的元素)和範圍(例如,刪除第 5 個最小的元素)兩方面來執行這些運算。」

定義 — 二元搜尋樹 binary search tree

二元搜尋樹是一種二元樹。它可能是空的。如果不是空的,它具有下列特性:

  1. 每一元素有一鍵值,而且每一元素的鍵值都不相同,即每一鍵值都是唯一的。
  2. 在非空的子樹上的鍵值必小於在該子樹的根節點中的鍵值。
  3. 在非空的子樹上的鍵值必大於在該子樹的根節點中的鍵值。
  4. 左子樹和右子樹也都是二元搜尋樹。
定義裡有冗餘

「在定義中有些是多餘的。性質 (2)、(3)、(4) 合在一起暗示了鍵值必須是不同的。因此,我們可將性質 (1) 改寫為:根節點有一鍵值。但上面的定義比較清楚。」

5.7.2 找尋一個二元搜尋樹

tree_pointer search2(tree_pointer tree, int key)
{
/* return a pointer to the node that contains key.  If
there is no such node, return NULL. */
   while (tree) {
      if (key == tree->data) return tree;
      if (key < tree->data)
         tree = tree->left_child;
      else
         tree = tree->right_child;
   }
   return NULL;
}

程式 5.16:二元搜尋樹的迴路式找尋

分析 search 和 search2

「如果二元搜尋樹的高度為 $h$,使用 searchsearch2 都可以在 $O(h)$ 時間內完成找尋。search(遞迴版)需要額外的 $O(h)$ 堆疊空間。」—— 這是本書第三次出現同一個對照(rsum vs sumbinsearch 遞迴 vs 迴路、現在是 BST):遞迴好讀,迴路省空間。

5.7.3 在二元搜尋樹中插入元素

「要插入一個新的元素 key,首先必須檢查此鍵值和現有元素的鍵值是不同的。為了驗証這一點,我們必須找尋此樹。如果找尋不成功,我們可以在找尋結束點插入此元素。

課本的範例

「要在圖 5.30(b) 的樹中插入一個鍵值為 80 的元素,我們首先在樹中找尋 80。此找尋將不會成功,而最後檢查的節點之鍵值為 40。我們將新元素當成此節點的子節點插入。」

「這種策略以 insert_node(程式 5.17)實作。它使用了函數 modified_search,這是函數 search2(程式 5.16)稍加修改後的版本。此函數在二元搜尋樹 *node 中找尋 num 鍵值。如果樹是空的,或者 num 已存在,它將傳回 NULL。否則,它將傳回在找尋過程中最後所到達的節點。

5.7.4 從二元搜尋樹中刪除元素

三種情況
被刪節點的分支度作法
0(葉節點)直接把父節點對應的子指標設為 NULL,釋回節點
1用它唯一的子節點取代它
2以被刪除的節點之左子樹中最大的元素,或右子樹中最小的元素來取代它的位置,而後從這個取代元素所屬的子樹中將其刪除

課本的範例:「假設我們想從圖 5.33(a) 的樹中刪除 60。我們可用左子樹中最大的元素 (55),或用右子樹中最小的元素 (70) 來取代 60。假設我們選用左子樹中最大的元素來取代它。我們將 55 移到子樹的根節點。而後,我們將原來包含 55 的節點之左子節點變成包含 50 的節點之右子樹,而後釋回包含 55 的舊節點。」

為什麼「取代元素」的刪除一定簡單

我們可以驗証在子樹中的最大和最小的元素永遠是在一個分支度為 0 或 1 的節點之中。這個觀察結果簡化了刪除函數的程式碼。

理由:左子樹中最大的元素是一路往右走到底的那個 —— 它必定沒有右子節點,所以分支度 $\le 1$。所以情況 3 總是化約成情況 1 或 2。刪除可以在 $O(h)$ 時間內完成,其中 $h$ 是樹的高度。

5.7.5 二元搜尋樹的高度

BST 的致命弱點

除非是小心地處理,具有 $n$ 個元素的二元搜尋樹之高度可能高達 $n$。舉例而言,當我們使用 insert_node 函數來在原來為空的二元搜尋樹中依序插入鍵值 $1, 2, 3, \ldots, n$ 時就是一個實例。」(結果是一條向右的傾斜樹,搜尋退化成 $O(n)$。)

「但是,當使用上述函數隨意地插入與刪除時,平均而言,二元插尋樹的高度為 $O(\log_2 n)$。」

平衡搜尋樹 —— 第 10 章的預告

「具有最差狀況高度為 $O(\log_2 n)$ 的搜尋樹稱為平衡搜尋樹(balanced search tree)。平衡搜尋樹容許找尋,插入和刪除等運算在 $O(h)$ 的時間內完成。其中最值得注意的是 AVL 樹,2-3 樹與紅-黑樹(red-black tree)。我們將在第 10 章討論它們。

習題 — 二元搜尋樹(5.7 節習題)
  1. 假設修改二元搜尋樹的定義,使其容許相同的鍵值存在,並在節點構造中增加一個 count 欄位。(a) 重寫 insert_node,使其在發現重複的鍵值時,將 count 欄位遞增。否則,插入一個新的節點。(b) 重寫 delete,使其在鍵值被找到時,將 count 遞減。節點只有在它的 count 為 0 時才被刪除。
  2. 寫出程式 5.7 中所使用的函數 modified_search 的程式碼。
  3. 設計遞迴版本的 insert_node兩個版本中何者較有效率?何故?
  4. 設計一個遞迴的 C 函數來刪除二元搜尋樹中的一個鍵值。你的函數之時間和空間複雜度為何?
  5. 設計一個迴路式的 C 函數來刪除二元搜尋樹中的一個鍵值。你的函數空間複雜度必須是 $O(1)$。証明這是當然的狀況。你的函數之時間複雜度為何?
  6. 假設二元搜尋樹以引線二元搜尋樹來表示。設計它的找尋、插入和刪除函數。
點擊展開解題要點

第 3 題(「何者較有效率」):迴路版較有效率。兩者時間都是 $O(h)$,但遞迴版需要 $O(h)$ 的系統堆疊空間與函數呼叫負擔;迴路版空間 $O(1)$。不過遞迴版的程式碼較短、較易驗證正確性 —— 這是第 1 章「步驟少 ≠ 跑得快」的又一個實例。

第 5 題(「證明空間複雜度必須是 $O(1)$ 是當然的狀況」):刪除只需要沿著一條根到目標的路徑往下走,不需要回頭(只要邊走邊記住 parent 指標即可)。情況 3 的「找左子樹最大值」也是一路往右走到底,同樣不回頭。所以用固定數目的指標變數就夠了,$O(1)$。時間 $O(h)$。

第 1 題的陷阱:加了 count 之後,中序尋訪要輸出 count 次才是正確的排序結果。而且刪除時 count 遞減到 0 才真正移除節點,否則樹的結構會被不必要地擾動。

5.8 選取樹 (selection trees)

假設我們有 $k$ 個有順序之序列,要將它們合併成一個有順序之序列。每一序列中包含數個記錄,並依一個特定欄位 key 升冪順序排列。每一個有順序之序列稱為一個行程(run)。令 $n$ 為 $k$ 個行程中所有的記錄之個數。

為什麼需要選取樹

「合併的工作可藉由重覆地輸出具有最小鍵值的記錄來完成。最小的鍵值必須從 $k$ 個可能的序列中找尋,而且它可能是 $k$ 個行程中任一個的行程的第一個記錄。要合併 $k$ 個行程最直接的方法是經過 $k-1$ 次的比較來決定下一個要輸出的記錄。當 $k \gt 2$,利用選取樹的觀念,可以減少找到下一個最小的元素所需要的比較次數。

贏家樹 (winner tree)

選取樹是二元樹,其中每一個節點代表它的兩個子節點中的較小者。所以,根節點代表樹結構中最小的節點。「此選取樹的結構可以比喻成競賽的過程,其中贏家是具有較小鍵值的記錄。因而,樹中每一個內部節點代表比賽的贏家,根節點代表冠軍或是最小的鍵值。」

輸家樹 (tree of loser)

如果每一節點代表競賽的輸家而非贏家,可產生較快的演算法。」重整時,「競賽沿著節點 11 到根的路徑,在其中的兄弟節點之間進行。因這些兄弟節點代表先前進行的競賽中的輸家,藉著在每一個非樹葉節點中放入一個指標,此指標指向競賽的輸家而非贏家,我們即可簡化重整的過程。

重整只需要 $O(\log k)$

「在具有最小鍵值的記錄輸出以後,選取樹必須重整。因具有最小鍵值的記錄在行程 4 中,重整的工作包括了將這個行程的下一個記錄插入樹中。」重整只沿著一條從葉到根的路徑進行,長度 $\log_2 k$。

總結:合併 $k$ 個行程共 $n$ 筆記錄,用選取樹是 $O(n \log_2 k)$,直接法是 $O(nk)$。這是第 7 章外部排序(7.7 節)的核心工具。

5.9 樹林 (forests)

定義 — 樹林

樹林的觀念與樹的觀念非常接近,因為,我們若將一樹的根節點刪除,即可形成樹林。舉例而言,將任一個二元樹的根節點刪除,即產生具有兩個樹的樹林。」

5.9.1 將樹林轉換成二元搜尋樹

「假設有一樹林具有三個樹。要將此樹林轉換成單一的二元樹,我們首先要取得樹林中的每一個樹的二元樹表示法。而後,利用根節點的兄弟欄將所有的二元樹連接起來。

形式定義

如果 $T_1, \ldots, T_n$ 為樹林,則此樹林對應的二元樹以 $B(T_1, \ldots, T_n)$ 表示,則它將是:

  1. 空的,若 $n=0$
  2. 根節點與 $\text{root}(T_1)$ 相同;左子樹與 $B(T_{11}, T_{12}, \ldots, T_{1m})$ 相同,其中 $T_{11}, \ldots, T_{1m}$ 為 $\text{root}(T_1)$ 的子樹;右子樹為 $B(T_2, \ldots, T_n)$

5.9.2 樹林的尋訪

樹林的尋訪與對應二元樹的關係
先序與其對應的二元樹之先序尋訪產生相同的結果 ✓
中序與其對應的二元樹之中序尋訪產生相同的結果 ✓
後序不會產生相同的結果 ✗
後序是個例外 —— 要另外定義

樹林的後序尋訪與其對應的二元樹的後序尋訪之間沒有自然的對應關係。但是,我們可以定義 $F$ 的後序尋訪如下:

  1. 如果 $F$ 是空的,則結束。
  2. $F$ 的第一個樹之子樹以後序法尋訪。
  3. $F$ 其他的樹以後序法尋訪。
  4. 拜訪 $F$ 的第一個樹之根節點。

5.10 集合的表示法

本節我們將學習利用樹狀結構來表示集合。為簡化問題,我們假設集合中的元素為數值 $0, 1, 2, \ldots, n-1$。在實用上,這些數值可能是儲存元素實際名稱的符號表(symbol table)的索引。我們也假設所要表示的任意兩個集合是互斥的(disjoint)

關鍵的設計選擇:指標方向反過來

「因為樹中節點的編號從 0 到 $n-1$,我們可利用節點的編號作為索引。這表示每一節點只需要一個欄位,即它的父節點之索引,以便鏈結到其父節點。所以,所需要的資料僅是一個陣列 int parent[MAX_ELEMENTS]。」

注意這和前面所有的樹都相反:這裡的指標從子節點指向父節點,因為我們要做的運算是「找出我屬於哪一個集合」(往上走到根),而不是「走訪我的所有後代」。應注意,根節點的 parent 為 −1。

i[0][1][2][3][4][5][6][7][8][9]
parent−14−12−120004

圖 5.42:集合 $S_1=\{0,6,7,8\}$、$S_2=\{1,4,9\}$、$S_3=\{2,3,5\}$ 的陣列表示法

find(i)

「它只要從 $i$ 開始,根據其 parent 索引找尋,一直到索引是負的為止。舉例而言,find(5) 從 5 開始,而後移到 5 的父節點,2。因這個節點的索引是負的,我們已找到了根節點。」

union(i, j)

「也一樣地容易。我們傳入兩個樹狀結構的根節點 $i$ 和 $j$。要實作集合的聯集運算,我們只要將一個根節點的 parent 欄位設定成另一個根節點即可。

最單純的版本有多糟

最差狀況是產生退化樹(圖 5.43)—— 一條 $n$ 個節點的鏈。「則找到根節點所需的時間即為 $O(i)$。所以,完成 $n-1$ 個 find 運算所需的全部時間為:」

$$\sum_{i=2}^{n} i = O(n^2)$$

加權法則與折疊法則

定義 — 加權法則 weighting rule for union(i, j)

如果樹狀結構 $i$ 中的節點個數少於樹狀結構 $j$ 中的節點個數,則令 $j$ 為 $i$ 的父節點;否則,令 $i$ 為 $j$ 的父節點。

void union2(int i, int j)
{
/* union the sets with roots i and j, i != j, using
the weighting rule. parent[i] = -count[i] and
parent[j] = -count[j] */
   int temp = parent[i] + parent[j];
   if (parent[i] > parent[j]) {
      parent[i] = j; /* make j the new root */
      parent[j] = temp;
   }
   else {
      parent[j] = i; /*make i the new root */
      parent[i] = temp;
   }
}

程式 5.19:union 函數 —— 計數存在根節點的 parent 欄位裡(存成負數),所以不需要額外的 count 陣列

這個「負數兼作計數」的技巧很漂亮

因為樹中除了根節點以外的所有節點之 parent 欄位中均不為負數,我們可以利用根節點的 parent 欄位存放 $-\text{count}$。這樣一來 parent[i] < 0 同時表示「$i$ 是根」「這棵樹有 $-\texttt{parent}[i]$ 個節點」。注意程式中的比較 parent[i] > parent[j] —— 因為存的是負數,「大於」代表「節點數較少」

公設 5.4 與範例 5.1

「如果樹有 $m$ 個節點,最大的階層可以是 $\lfloor \log_2 m \rfloor + 1$。」範例 5.1 說明了這個上限值對某些 union 的順序是可達到的(圖 5.45 的完全平衡二元樹)。

「正如公設 5.4 的結果,在一具有 $n$ 個元素的樹中執行 find 所需的時間為 $O(\log_2 n)$。如果必須混合執行 $n-1$ 個 union 與 $m$ 個 find 運算,則時間變成 $O(n + m\log_2 n)$。」

定義 — 折疊法則 collapsing rule

如果 $j$ 是從 $i$ 到其根節點的路徑上的一個節點,則令 $j$ 為根節點的一個子節點。

int find2(int i)
{
/* find the root of the tree containing element i. Use the
collapsing rule to collapse all nodes from i to root */
   int root, trail, lead;
   for (root = i; parent[root] >= 0; root = parent[root])
      ;
   for (trail = i; trail != root; trail = lead) {
      lead = parent[trail];
      parent[trail] = root;
   }
   return root;
}

程式 5.20:find 函數 —— 第一個迴圈找根,第二個迴圈把路徑上所有節點直接掛到根下

範例 5.2 折疊法則值多少

以範例 5.1 中一連串的 unionunion2 所產生的樹狀結構為例。現執行 8 個 find 運算:find(7), find(7), ..., find(7)

舊版 find新版 find2(折疊)
第 1 次 find(7)向上經過 3 個 parent 鏈結向上 3 個鏈結,並重設兩個鏈結
其後 7 次每次仍 3 個鏈結每次只需向上經過 1 個鏈結
總移動次數2413

「(注意,雖然只有 2 鏈結需要改變,函數 find2 重設了三個鏈結,包括節點 4 的鏈結在內。)」「新的函數對於個別的 find 運算大約用了加倍的時間。但是,它可降低一連串的 find 運算之最差狀況時間。」

公設 5.5 與 Ackermann 函數

當執行一連串的 unionfind 時,union_find 演算法的最差行為在公設 5.5 中說明。在說明此公設之前,我們先介紹一個成長非常慢的函數 $\alpha(m,n)$ 的定義如下:

$$\alpha(m,n) = \min\{\,z \ge 1 \mid A(z,\ 4\lceil m/n\rceil) \gt \log_2 n\,\}$$

在此所用的 Ackermann 函數之定義為:

$$A(p,q)=\begin{cases} 2q & p=0\\ 0 & q=0 \text{ and } p \ge 1\\ 2 & q=1 \text{ and } p \ge 1\\ A(p-1,\ A(p,\ q-1)) & p \ge 1 \text{ and } q \ge 2 \end{cases}$$
$A(3,4)$ 有多大

「函數 $A(p,q)$ 為增長迅速的函數。你可以證明:

  1. $A(3,4) = 2^{2^{\cdot^{\cdot^{2}}}}$  }  65,536 個 2
  2. $A(p,q+1) \gt A(p,q)$
  3. $A(p+1,q) \ge A(p,q)$

假設 $m \ne 0$,則 (2) 和 (3) 與 $\alpha(m,n)$ 的定義共同暗示了當 $\log_2 n \lt A(3,4)$ 時,$\alpha(m,n) \le 3$。但從 (1),$A(3,4)$ 實際上是一個非常大的數!為了實用的目的,我們可以假設 $\log_2 n \lt A(3,4)$,且 $\alpha(m,n) \le 3$。

公設 5.5 [Tarjan]

令 $T(m,n)$ 為混合執行一連串 $m \ge n$ 個 find 與 $n-1$ 個 union 所需之最大時間。則:

$$k_1 m\,\alpha(m,n) \le T(m,n) \le k_2 m\,\alpha(m,n)$$

其中 $k_1$ 和 $k_2$ 為大於 0 的常數值。「即使函數 $\alpha(m,n)$ 為增長極為緩慢的函數,union_find 的複雜度並不是 find 的次數 $m$ 的線性函數。至於空間的需求,則是每一個元素需要一個位置。

5.10.2 等價類別

回到第 4 章 4.6 節 —— 這次用 $O(n)$ 空間

「讓我們利用 union_find 演算法來處理 4.6 節中的等價序對。我們可視要產生的等價類別為集合。這些集合是互斥的,因為沒有任何一個多邊形可以在兩個等價類別中。

演算法:「一開始,所有的 $n$ 個多邊形自成一個等價類別;即 parent[i] = -1,$0 \le i \lt n$。在處理一個等價的序對 $i \equiv j$ 之前,我們必須先確定包含了 $i$ 和 $j$ 的集合。如果它們是不同的,則以它們的聯集來取代兩個集合。如果兩個集合是相同的,則不需做任何處理,因關係 $i \equiv j$ 是多餘的。

兩章的正面對照
第 4 章 4.6 節(鏈結串列)第 5 章 5.10 節(union-find)
時間$O(m+n)$ (較好)$O(m\,\alpha(2m,n))$
空間$O(m+n)$$O(n)$ (較好)
要儲存序對嗎要 —— 全部 $2m$ 個節點不要 —— 讀進一對就合併,讀完就丟

「要處理每一個等價序對,我們必須執行兩個 find 以及至多一個 union。所以,如果有 $n$ 個多邊形及 $m \ge n$ 個等價序對,全部的處理時間最多為 $O(m\,\alpha(2m,n))$。雖然對非常大的 $n$ 而言,這比 4.6 節中的演算法較差一些,但它需要較少的空間。在第 6 章,我們將會看到 union_find 演算法的另一個應用。」(第 6 章:Kruskal 最小生成樹演算法。)

習題 4 — union-find(5.10 節習題)
  1. 利用範例 5.3 的結果,畫出執行了指令 union2(11, 9) 以後的樹狀架構。
  2. 利用 union2find2,設計一個完整的程式,它可以輸入等價關係並產生與印出等價類別。採用範例 5.3 為提示。
點擊展開解題要點

第 1 題的陷阱:union2(11, 9) 的兩個參數必須是根節點(程式註解寫明 "union the sets with roots i and j")。若 11 和 9 不是根,必須先 find2(11)find2(9)。範例 5.3 最後的森林中,三棵樹的根是 0、6、3;11 在以 0 為根的樹裡,9 在以 6 為根的樹裡。所以實際執行的是 union2(0, 6)

以加權法則:以 0 為根的樹有 5 個節點(parent[0] = -5),以 6 為根的有 4 個(parent[6] = -4)。因為 $-5 \lt -4$,即 parent[0] < parent[6],所以 0 成為新的根parent[6] = 0parent[0] = -9

5.11 計算二元樹之數目

三個看似無關、其實相同的問題
  1. $n$ 個節點可以產生幾種不同的二元樹?
  2. 數字 1 到 $n$ 利用一個堆疊可以產生幾種排列?(就是第 3 章 3.1 節的鐵道交換問題!)
  3. $n$ 個矩陣相乘有幾種不同的加括號方式?
用 $n=3$ 驗證

「如果我們以數字 1, 2, 3 為例,則利用堆疊所產生的可能之排列為:」

$$(1,2,3)\quad(1,3,2)\quad(2,1,3)\quad(2,3,1)\quad(3,2,1)$$

要產生 (3, 1, 2) 是不可能的。這五個排列中的每一個分別對應於五個具有三個節點之不同的二元樹中的一個二元樹(圖 5.51)。

而 $n=3$ 個矩陣相乘有 2 種方式:$(M_1 * M_2) * M_3$ 與 $M_1 * (M_2 * M_3)$ —— 對應的是 $b_2 = 2$(矩陣問題的 $n$ 個矩陣對應二元樹問題的 $n-1$ 個節點)。

5.11.4 不同的二元樹之數目

「要得知 $n$ 個節點所產生的不同的二元樹之數目,我們必須解出遞迴方程式 (5.3)。首先我們令:」

$$B(x) = \sum_{i \ge 0} b_i x^i \tag{5.4}$$

這是二元樹數目的衍生函數(generating function)

其次,藉觀察遞迴關係式可得到下列的等式:

$$x B^2(x) = B(x) - 1$$

利用解一元二次方程式的公式和 $B(0)=b_0=1$ 的事實(5.3 式),可得知:

$$B(x) = \frac{1 - \sqrt{1-4x}}{2x}$$

我們可利用二項式定理來展開 $(1-4x)^{1/2}$,得到:

$$B(x) = \frac{1}{2x}\left[1 - \sum_{n \ge 0}\binom{1/2}{n}(-4x)^n\right] = \sum_{m \ge 0}\binom{1/2}{m+1}(-1)^m 2^{2m+1}x^m \tag{5.5}$$

比較 (5.4) 式與 (5.5) 式,可發現 $b_n$ 是在 $B(x)$ 式中 $x^n$ 的係數,其值為 $\binom{1/2}{n+1}(-1)^n 2^{2n+1}$。化簡以後可得到:

卡塔蘭數 Catalan number $$\boxed{\,b_n = \frac{1}{n+1}\binom{2n}{n}\,}$$

其近似值為:

$$b_n = O\!\left(\frac{4^n}{n^{3/2}}\right)$$
回頭看第 3 章

第 3 章 3.1 節習題 4(鐵道交換)的答案 $n=3$ 時是 5、$n=4$ 時是 14,正是 $b_3=5$、$b_4=14$。那一題和「$n$ 個節點有幾種二元樹」是同一個問題 —— 現在你有了封閉形式的答案。注意 $b_n$ 是指數級的($O(4^n/n^{3/2})$),所以窮舉所有二元樹永遠不是可行的演算法。

習題 — 尋訪順序能否唯一決定一棵樹(5.11 節習題)
  1. 證明每一二元樹可由其先序如中序尋訪順序來唯一決定。
  2. 二元樹的中序和後序尋訪順序可否唯一決定一個二元樹?證明之。
  3. 二元樹的中序和先序尋訪順序可否唯一決定一個二元樹?證明之。
  4. 二元樹的中序和階序尋訪順序可否唯一決定一個二元樹?證明你的答案。
  5. 設計一個演算法,使其根據已知的先序和中序尋訪順序產生二元樹。
  6. 以中序和後序尋訪順序重做第 5 題。
點擊展開解題要點

通則:中序 + 任一個「能指出根是誰」的順序 → 唯一決定。

第 1、3 題(先序 + 中序):先序的第一個元素就是根。在中序序列中找到這個根,它左邊的全部屬於左子樹、右邊的全部屬於右子樹,而且左子樹的大小也因此確定了。於是先序序列也能切成「根 + 左子樹的先序 + 右子樹的先序」。遞迴下去即可。□

第 2、6 題(後序 + 中序):完全對稱 —— 後序的最後一個元素是根,其餘同上。

第 4 題(階序 + 中序):也可以。階序的第一個元素是根;在中序中切開後,階序序列中屬於左子樹的元素保持它們的相對順序就是左子樹的階序,右邊同理。遞迴下去。(需要一次過濾,效率較差但可行。)

反例(為什麼一定要有中序):先序 + 後序不能唯一決定 —— 對一個只有根和一個子節點的樹,先序都是 AB、後序都是 BA,但 B 可能是左子也可能是右子。這正是圖 5.8 那兩個「不相同的二元樹」。

5.12 精選參考文獻

主題參考書目
其他的樹狀結構表示法D. Knuth, The Art of Computer Programming: Fundamental Algorithms, 2nd ed., Addison-Wesley, Reading, Mass., 1973

本章重點回顧

一定要記住的清單
  1. 公設 5.1:階層 $i$ 最多 $2^{i-1}$ 個節點;深度 $k$ 最多 $2^k-1$ 個。公設 5.2:$n_0 = n_2+1$。
  2. 公設 5.3(陣列定址):parent $=\lfloor i/2\rfloor$,left $=2i$,right $=2i+1$。只對完整二元樹划算。
  3. 三種尋訪只差 printf 的位置。中序、先序、後序用堆疊;階序用佇列。全部都是 $O(n)$ 時間。
  4. 空指標有 $n+1$ 個($2n$ 個欄位中)—— 引線樹就是拿它們來用,把中序尋訪的空間從 $O(n)$ 降到 $O(1)$。
  5. 累堆:插入「放最後、往上冒」,刪除「取根、最後一個搬上來、往下沉」,兩者都 $O(\log_2 n)$。但搜尋任一元素是 $O(n)$。
  6. BST:搜尋/插入/刪除都是 $O(h)$。中序尋訪 BST 得到排序結果。最差 $h=n$(依序插入),平均 $h=O(\log_2 n)$。
  7. 刪除分支度 2 的節點:用左子樹最大或右子樹最小取代 —— 那個元素的分支度必定 $\le 1$。
  8. union-find:加權法則使 find 變 $O(\log_2 n)$;再加折疊法則,$m$ 個 find 共 $O(m\,\alpha(m,n))$,實用上 $\alpha \le 3$。
  9. 卡塔蘭數:$b_n = \frac{1}{n+1}\binom{2n}{n} = O(4^n/n^{3/2})$。二元樹個數 = 堆疊排列數 = 矩陣加括號方式數。
本章各運算的複雜度
運算時間空間
遞迴尋訪$O(n)$$O(n)$
iter_inorder$O(n)$$O(n)$
tinorder(引線)$O(n)$$O(1)$
level_order$O(n)$$O(n)$
copy / equal$O(n)$$O(h)$
累堆插入/刪除$O(\log_2 n)$$O(1)$
累堆搜尋$O(n)$$O(1)$
BST 搜尋/插入/刪除$O(h)$$O(1)$(迴路)
選取樹合併 $k$ 行程$O(n\log_2 k)$$O(k)$
find2(折疊+加權)$O(\alpha)$ 攤提$O(n)$ 總計
satisfiability$O(g\,2^n)$$O(n)$
最容易答錯的六個點
  1. 樹不能是 0 個節點,二元樹可以。二元樹區分左右子樹順序。
  2. 累堆搜尋是 $O(n)$ 不是 $O(\log n)$ —— 兄弟間無序。
  3. 引線樹對後序幫不上忙(找不到父節點)。
  4. post_order_eval 一次是 $\theta(n)$,但整個 SAT 是 $O(g\,2^n)$。
  5. 樹林的後序與對應二元樹的後序不同(先序、中序相同)。
  6. 先序 + 後序不能唯一決定二元樹;必須有中序。
往後各章如何用到本章

第 6 章:DFS 就是樹的先序尋訪推廣到圖;BFS 就是階序尋訪;Kruskal 演算法直接用 5.10 節的 union-find。第 7 章:堆積排序(heap sort)就是反覆 delete_max_heap;外部排序用 5.8 節的選取樹合併行程。第 9 章:各種進階累堆(最小最大堆積、斜堆積、二項堆積)全部建立在 5.6 節之上,並用「成本分攤」分析 —— 那正是 5.10 節公設 5.5 那種攤提分析的延伸。第 10 章:AVL 樹、2-3 樹、紅黑樹、B 樹都是 5.7.5 節所預告的「平衡搜尋樹」,目的就是把 BST 的最差高度從 $n$ 壓回 $O(\log n)$。