Outline — 點擊展開各節
7.1 搜尋與串列驗証
7.2 定義
7.3 插入排序
7.4 快排
7.5 最佳的排序時間
7.6 合併排序
7.7 累堆排序
7.8 基底排序
7.9 / 7.10 串列表格排序與摘要
7.11 外部排序
Chapter 7 · Sorting

第 7 章 排序

「估計大約有超過全部計算時間的 25% 用在排序上,有些機構更使用了其計算時間 50% 以上來排序串列。」—— 這一章把前六章建立的每一個工具都用上,並證明了基於比較的排序不可能快過 $O(n\log n)$。

本章最重要的一句話

對所有想要排序的串列之原始順序與大小而言,沒有任何一種排序方法是『最好的』。所以,我們要討論數種排序方法,並指出何時一種方法會優於其他的方法。

這句話定調了整章:不是找「最強的演算法」,而是建立一張選擇的地圖。7.10 節的實測表格(圖 7.26)就是這張地圖的結論 —— $n \lt 20$ 用插入排序、$20 \lt n \lt 45$ 用快排、更大用合併排序。

7.1 搜尋與串列驗証

「因為不同的應用可以使用相同的串列,故鍵值欄位也是根據不同的應用而定。舉例而言,如果我們有一電話號碼串列,如果要找到電話號碼 456-1023,電話號碼欄即為鍵值。反之,如果想要找出 Joan Smith 是否在目錄中,姓名欄即為鍵值。」

為什麼「搜尋」是「排序」這一章的開場

搜尋方法的效率則根據串列中記錄的排列方式而定。如果記錄以鍵值欄依序排列,我們可以很有效率地搜尋串列。反之,如果記錄以鍵值欄的任意順序排列,我們就必須從串列的一端開始,逐一檢查每一記錄,直到我們找到想要的鍵值或到達串列的另一端為止。」—— 排序的第一個價值,就是讓搜尋變快。

7.1.2 循序搜尋 (Sequential Search)

#define MAX_SIZE 1000 /*maximum size of list plus one*/
typedef struct {
        int key;
        /* other fields */
        } element;
element list[MAX_SIZE];
int seqsearch(int list[], int searchnum, int n)
{
/*search an array, list, that has n numbers. Return i, if
list[i] = searchnum.  Return -1, if searchnum is not in
the list */
   int i;
   list[n] = searchnum;
   for (i = 0; list[i] != searchnum; i++)
      ;
   return ((i < n ) ? i : -1);
}

程式 7.1:循序搜尋 —— 注意 list[n] = searchnum; 這個哨兵寫法(第 2 章 2.6.2 節已出現過)

分析 seqsearch

「在開始搜尋之前,先將 searchnum 放入 list[n].key這個位置作為串列結尾的記號。少了測試串列的結尾,即 $i \gt n-1$,可以簡化迴圈結構。

  • 不成功的搜尋:需要 $n+1$ 次的鍵值比較,最差狀況計算時間為 $O(n)$。
  • 成功的搜尋:若鍵值均不相同,且 $\texttt{searchnum} = \texttt{list}[i].\texttt{key}$,則做了 $i+1$ 次的鍵值比較。平均比較次數為:
$$\sum_{i=0}^{n-1}\frac{i+1}{n} = \frac{n+1}{2}$$

7.1.3 二分搜尋 (Binary Search)

「循序搜尋對於鍵值欄的順序不做任何假設,與這不同的是,二分搜尋假設串列依鍵值大小排列,使得 $\texttt{list[0].key} \le \texttt{list[1].key} \le \cdots \le \texttt{list[n-1].key}$。」

決策樹 —— 分析二分搜尋的工具

「索引在節點之外。從根到樹中任一節點的路徑表示找到 searchnum 或判斷它未在串列中 binsearch 所做的比較順序。由這樹狀結構的深度,可以很容易地發現 binsearch 所做的比較次數不大於 $O(\log n)$。

圖 7.1 是 12 個元素(4, 15, 17, 26, 30, 46, 48, 56, 58, 82, 90, 95)的決策樹,根節點是 [5](46)—— 這正是 middle = (0+11)/2 = 5決策樹這個工具在 7.5 節會再度出現,用來證明排序的下限。

插入搜尋 (interpolation search) —— 人類查電話簿的方式

「再回到電話號碼的例子上,我們會發現人們在找尋電話號碼目錄時並未實際採用循序搜尋或二分搜尋方法。如果要找尋開頭為字母 w 的姓名,我們會從接近目錄尾端的部份開始找尋,而不從中間開始。

$$i = \frac{k - f[\ell].\texttt{key}}{f[u].\texttt{key} - f[\ell].\texttt{key}} \times n$$

其中 $f[\ell].\texttt{key}$ 和 $f[u].\texttt{key}$ 為檔案中最小和最大的鍵值。「插入搜尋僅在檔案順序排列時才能使用。這類的搜尋法之行為顯然和檔案中鍵值的分佈情形有關。

7.1.4 串列驗証

為什麼這個問題值得一節

「因為機構通常從數個不同的來源接收重複的資料,串列驗証是一個經常會發生的問題。例如,Internal Revenue Service(IRS)收到僱主送來的報表,報表証明每一員工的薪水和社會福利扣除額。同樣地,員工必須繳所得稅報表申報他們的收入和扣除額。

驗証過程辨識並印出三種錯誤:

  1. 鍵值為 list1[i].key 的記錄存在第一個串列中,但在第二個串列(list2)中沒有相同鍵值的記錄。
  2. 鍵值為 list2[j].key 的記錄存在第二個串列中,但在第一個串列(list1)中沒有相同鍵值的記錄。
  3. list1[i].key = list2[j].key,但兩個記錄中至少有一個其他欄位相同。
排序值不值得? —— 本節的結論
函數前提最差狀況時間
verify1兩個串列均隨機排列$O(mn)$
verify2先排序再驗証$O(\texttt{tsort}(n)+\texttt{tsort}(m)+m+n)$

因為我們將在本章證明排序可以在 $O(n\log n)$ 完成,verify2 的總時間是 $O(n\log n + m\log m + m + n)$ —— 對於大的 $m$、$n$,遠優於 $O(mn)$。這就是「排序作為搜尋的輔助」最具體的量化。

7.2 定義

排序問題的正規定義

「給予一個記錄串列 $(R_0, R_1, \ldots, R_{n-1})$,其中每一記錄 $R_i$ 有一鍵值 $K_i$。此外,鍵值具有順序關係 $(\lt)$,使得任意兩個鍵值 $x$ 和 $y$ 可能是 $x=y$,或 $x \lt y$,或 $x \gt y$。這個順序關係有遞移性,即任意三個鍵值 $x$、$y$ 和 $z$,$x \lt y$ 且 $y \lt z$ 則 $x \lt z$。」

定義排序問題為找到一個排列 $\sigma$,使得 $K_{\sigma(i-1)} \le K_{\sigma(i)}$,$0 \lt i \lt n-1$。所要的順序則為 $(R_{\sigma(0)}, R_{\sigma(1)}, \ldots, R_{\sigma(n-1)})$。

穩定性 (stable)

定義 — 穩定排序

「因為串列中可以數個相同的鍵值,排列不是唯一的。有些運算中,我們可能對找出唯一的排列 $\sigma_s$ 有興趣,它有下列的特性:」

  1. [sorted] $K_{\sigma(i-1)} \le K_{\sigma(i)}$,$0 \lt i \le n-1$
  2. [stable] 若在輸入串列中 $i \lt j$ 且 $K_i = K_j$,則在已排序的串列中 $R_i$ 在 $R_j$ 之前。

可以產生排列 $\sigma_s$ 的排序方法是穩定的(stable)。

內部排序 vs 外部排序

內部排序 internal sort

串列很小可以整個在主記憶體中排序者。本章 7.3–7.10 節的主題:插入排序、快排、累堆排序、合併排序、基底排序。

外部排序 external sort

用於資訊太多而不適用於主記憶體時。「在第二種狀況下,檔案必須分段放入主記憶體,直到整個檔案已經排序為止。」7.11 節的主題。

7.3 插入排序 (Insertion Sort)

直觀來源

插入排序類似玩牌的人在排列一手新牌時所做的動作:一次發一張牌,每一張牌在拿下一張之前放入排好的順序中。同樣地,我們假裝串列中的記錄一次只能看到一個。」

void insertion_sort(element list[], int n)
/* perform a insertion sort on the list */
{
   int i,j;
   element next;
   for (i = 1; i < n; i++) {
      next = list[i];
      for (j = i-1; j >= 0 && next.key < list[j].key; j--)
         list[j+1] = list[j];
      list[j+1] = next;
   }
}

程式 7.5:insertion_sort ——「因每次的插入保持插入後的序列順序排列,我們可以在 $n-1$ 次插入後排好 $n$ 個記錄。」

分析 insertion_sort

「在最差狀況下,在插入之前內層的迴圈做了 $i$ 次的比較。所以在有序串列中插入一個記錄的計算時間為 $O(i)$。外層的迴圈在 $i=1,2,\ldots,n-1$ 時被呼叫,全部的最差時間為:」

$$O\Big(\sum_{i=0}^{n-1} i\Big) = O(n^2)$$
範例 7.1 最差狀況:$n=5$,輸入為 $(5,4,3,2,1)$
i[0][1][2][3][4]
54321
145321
234521
323451
412345

因序列為相反的順序,當插入每一個新記錄 $R_i$ 到已排序的串列 $R_0, \ldots$」每次都要搬動全部 $i$ 個元素 —— 這就是 $O(n^2)$ 的來源。

LOO:一個更精確的分析

定義 — left out of order (LOO)

「我們也可以從檢查輸入串列的相對脫序來估計插入排序的計算時間。為表示相對脫序,我們計算每一記錄的 left out of order(LOO)的程度。它的定義為:」

$$R_i \text{ 為 LOO} \iff R_i \lt \max_{0 \le j \lt i}\{R_j\}$$

明顯地,插入的部驟只針對 LOO 記錄才會執行。如果 $k$ 為 LOO 記錄的數目,則計算時間為 $O((k+1)n)$,但最差狀況時間 $O(n^2)$。我們也可以証明平均時間為 $O(n^2)$。

$O((k+1)n)$ 這個界限的實用意義

若串列已經幾乎排好($k$ 很小),插入排序就接近 $O(n)$ —— 這就是 7.10 節說「插入排序法在串列已經部份排序時效果良好」的精確版本,也是它適合當作混合排序演算法(大陣列用快排、小分段轉插入排序)收尾步驟的理由。

7.4 快排 (Quick Sort)

快排與插入排序的關鍵差別

「現在將注意力轉移到一個有良好的平均行為的排序法。快排由 C. A. R. Hoare 提出,在我們將要學習的各種排序方法中,它的平均行為是最好的

在插入排序中,目前控制著插入的鍵值 $K_i$(稱為基準鍵,Pivot key)被放在已排序的子檔 $(R_0, \ldots, R_{i-1})$ 的右邊。快排與插必排序不同的是將控制處理過程的基準鍵放在整個檔案的右邊。

所以,若鍵值 $K_i$ 放在位置 $S(i)$,則對於 $j \lt S(i)$,$K_j \le K_{S(i)}$,且 $j \gt S(i)$,$K_j \ge K_{S(i)}$。故,當這個位置決定了以後,原始檔案被分成兩個子檔,一個包含記錄 $R_0, \ldots, R_{S(i)-1}$,另一個包含記錄 $R_{S(i)+1}, \ldots, R_{n-1}$。因為在最後排好的序列中,在第一個子檔上的所有記錄會出現在 $S(i)$ 的左邊,第二個子檔則在 $S(i)$ 的右邊,這兩個子檔可以獨自排序

void quicksort(element list[], int left, int right)
/* sort list[left], ... , list[right] into nondecreasing
order on the key field. list[left].key is arbitrarily
chosen as the pivot key.  It is assumed that
list[left].key <= list[right+1].key. */
{
   int pivot,i,j;
   element temp;
   if (left < right) {
      i = left;    j = right + 1;
      pivot = list[left].key;
      do {
      /* search for keys from the left and right sublists,
      swapping out-of-order elements until the left and
      right boundaries cross or meet */
         do
            i++;
         while (list[i].key < pivot);
         do
            j--;
         while (list[j].key > pivot);
         if (i < j)
            SWAP(list[i],list[j],temp);
      } while (i < j);
      SWAP(list[left],list[j],temp);
      quicksort(list,left,j-1);
      quicksort(list,j+1,right);
   }
}

程式 7.6:quicksort 函數(Hoare 的快排演算法之遞迴版本)

注意那個前提條件

程式註解寫明:「It is assumed that list[left].key <= list[right+1].key」—— 這代表 list[n] 必須存在且是一個足夠大的哨兵值,否則右邊的 do j--; while (list[j].key > pivot); 可能越界。這和 7.1 節 seqsearch 的哨兵是同一個手法。

範例 7.3 quicksort 模擬

輸入檔案有 10 個記錄,其鍵值為 $(26, 5, 37, 1, 61, 11, 59, 15, 48, 19)$。中括號用來標示還要排序的子檔。

$R_0$$R_1$$R_2$$R_3$$R_4$$R_5$$R_6$$R_7$$R_8$$R_9$leftright
[265371611159154819]09
[11519115]26[59614837]04
[ 15]11[1915]26[5961483701
1511[1915]26[59614837]34
1511151926[59614837]69
1511151926[4837]59[61]67
1511151926374859[61]99
151115192637485961

圖 7.2:quicksort 模擬

分析 quicksort

最佳狀況的推導

「這個演算法的最差行為將在習題 2 中討論,並証明 $O(n^2)$。然而,如果我們夠幸運,每一次有一個記錄都會放在適當的位置,使得在其左邊和右邊的子檔的大小都相同。」在大小為 $n$ 的檔案中將一個記錄定位需要的時間為 $O(n)$:

$$\begin{aligned} T(n) &\le cn + 2T(n/2), \quad\text{對某一常數 } c\\ &\le cn + 2(cn/2 + 2T(n/4))\\ &\le 2cn + 4T(n/4)\\ &\ \ \vdots\\ &\le cn\log_2 n + nT(1) = O(n\log_2 n) \end{aligned}$$

「公設 7.1 說明快排的平均計算時間為 $O(n\log_2 n)$。此外,實驗的結果顯示只要考慮到平均計時間,它是我們將學習的最好的內部排序方法。

公設 7.1 及其證明

令 $T_{\text{avg}}(n)$ 為排列有 $n$ 個記錄的檔案時 quicksort 預期的時間。則存在一個常數 $k$,使得 $T_{\text{avg}}(n) \le kn\log_e n$,$n \ge 2$。

證明:在呼叫 quicksort(0, n-1) 時,$K_0$ 放在位置 $j$。然後我們要解決兩個大小分別是 $j$ 和 $n-j-1$ 的子檔的排序問題。其預期的時間為 $T_{\text{avg}}(j) + T_{\text{avg}}(n-j-1)$。演算法餘留的部份明顯地最最多需要 $cn$ 時間。因為 $j$ 可能從 0 到 $n-1$ 之間的任一值取得,且機率是均等的,可得到:

$$T_{\text{avg}}(n) \le cn + \frac{1}{n}\sum_{j=0}^{n-1}\big(T_{\text{avg}}(j) + T_{\text{avg}}(n-j-1)\big) = cn + \frac{2}{n}\sum_{j=0}^{n-1}T_{\text{avg}}(j),\quad n \ge 2 \tag{7.1}$$

假設對某一常數 $b$,$T_{\text{avg}}(0) \le b$ 且 $T_{\text{avg}}(1) \le 1$。現在,我們要証明 $T_{\text{avg}}(n) \le kn\log_e n$,$n \ge 2$ 且 $k = 2(b+c)$。證明是對 $n$ 的歸納法。

歸納基礎當 $n=2$,從 (7.1) 式可得到 $T_{\text{avg}}(2) \le 2c + 2b \le kn\log_e 2$
歸納假設設 $T_{\text{avg}}(n) \le kn\log_e n$,$1 \le n \lt m$
歸納步驟由 (7.1) 式和歸納假設
$$T_{\text{avg}}(m) \le cm + \frac{4b}{m} + \frac{2}{m}\sum_{j=2}^{m-1}T_{\text{avg}}(j) \le cm + \frac{4b}{m} + \frac{2k}{m}\sum_{j=2}^{m-1}j\log_e j \tag{7.2}$$

因 $j\log_e j$ 為 $j$ 的漸增函數,(7.2) 式變成:

$$T_{\text{avg}}(m) \le cm + \frac{4b}{m} + \frac{2k}{m}\int_2^m x\log_e x\,dx = cm + \frac{4b}{m} + \frac{2k}{m}\left[\frac{m^2\log_e m}{2} - \frac{m^2}{4}\right]$$
$$= cm + \frac{4b}{m} + km\log_e m - \frac{km}{2} \le km\log_e m,\quad \text{for } m \ge 2 \qquad\square$$
快排的空間代價 —— 別忘了堆疊

插入排序法所需要的額外空間只是一個記錄而已,快排與之不同,它需要實作遞迴所需的堆疊空間。在檔案以上述分析的方式分割大致相同的部份之情況下,最大的遞迴深度為 $\log n$,故需要 $O(\log n)$ 堆疊空間最差狀況發生在每一層的遞迴將檔案分割成左子檔的大小為 $n-1$,右子檔的大小為 0」—— 此時堆疊深度是 $O(n)$。

習題 1 — 快排(7.4 節習題)
  1. 寫出一個輸入實例,使得 quicksort 的執行時間為 $O(n^2)$。證明這是最差狀況。
  2. 設計迴路式的 quicksort。你的函數需要多少堆疊空間?
  3. 如何選擇基準鍵可以改善快排的最差狀況行為?
點擊展開解題要點

第 1 題:已經排好序的輸入(如 $1,2,3,\ldots,n$)。因為程式選 list[left].key 當基準鍵,它永遠是最小值,每次分割都得到大小 $n-1$ 和 $0$ 的兩個子檔。遞迴深度 $n$,總比較次數 $\sum_{i=1}^{n} i = O(n^2)$。諷刺的是:對插入排序最理想的輸入,正是對快排最糟的輸入。

第 2 題:用自己的堆疊模擬遞迴。關鍵技巧:每次先把較大的子檔推入堆疊、較小的直接迴圈處理 —— 這保證堆疊深度永遠是 $O(\log n)$,即使在最差分割下也是。

第 3 題(三種標準改良):
三數取中(median-of-three):取 list[left]list[middle]list[right] 的中位數當基準鍵。對已排序輸入立刻退化為最佳分割。
隨機基準鍵:隨機選一個位置與 list[left] 交換。使最差狀況的發生機率極低(但理論最差仍是 $O(n^2)$)。
Introsort:偵測遞迴深度超過 $2\log n$ 就改用累堆排序 —— 把最差狀況真正壓回 $O(n\log n)$。

7.5 最佳的排序時間

本節要回答的問題

「到目前為止所討論的兩種排序方法的最差狀況計算時間為 $O(n^2)$。此刻,你也許會開始想知道最佳計算時間,即『對有 $n$ 個物件之序列排序,我們可預期的最快時間為何?若將我們的問題局限在只能比較和交換鍵值的演算法上,則本節所要證明的定理顯示可能的最快時間為 $O(n\log_2 n)$。

決策樹 —— 再度登場

「我們的證明需要一個明確描述排序過程的決策樹。在此樹中的每一個節點代表兩個鍵值的比較,而每一分枝代表比較的結果。所以,在樹中的每一路徑代表排序演算法可能產生的一連串比較。

範例 7.4 insertion_sort 對 $R_0, R_1, R_2$ 的決策樹

「每一個節點以該節點的記錄之排列組合來標記。根節點標記為 $[0,1,2]$ 表示輸入的排列。在根節點中,我們指明了 insertion_sort 所做的第一個比較。若 $K_0 \le K_1$ 則採取左分枝,若 $K_0 \ge K_1$ 則採取右分枝。若選取左分枝,則記錄的排列維持不變,若選擇右分枝,則變成 $[1,0,2]$。」

葉節點排列輸出鍵值的排列
I0 1 2(7, 9, 10)
II0 2 1(7, 10, 9)
III2 0 1(9, 10, 7)
IV1 0 2(9, 7, 10)
V1 2 0(10, 7, 9)
VI2 1 0(10, 9, 7)

圖 7.4:7, 9, 10 的六種排列

「葉節點編號從 I 到 VI,它們是演算法可能的結束點。故只能由這個演算法找到輸序列的六種排列。因這六種排列都不相同且 $3!=6$,表示,這個演算法對三個記錄有足夠的葉節點來建立有效的排序法。此樹的最大高度為 3。……圖 7.3 的決策樹不是高度為 3 的完全二元樹,故其葉節點數目小於 $2^3=8$。

定理 7.1

任一排序 $n$ 個不同元素的決策樹的高度至少為 $\log_2(n!)+1$。

證明:將 $n$ 個元素排序會有 $n!$ 不同的可能結果。所以,任一決策樹必須至少有 $n!$ 個葉節點。由第 5 章公設 5.1,一個高度為 $k$ 的二元樹最多有 $2^{k-1}$ 個葉節點。所以需要 $2^{k-1} \ge n!$,取 $\log_2$ 得 $k \ge \log_2(n!)+1$。□

再用 Stirling 近似 $\log_2(n!) = \Theta(n\log_2 n)$,即得:

$$\boxed{\text{任何基於比較的排序演算法,最差狀況至少需要 } \Omega(n\log_2 n) \text{ 次比較}}$$
這個下限的適用範圍 —— 以及它為何不適用於基底排序

定理 7.1 的前提是「只能比較和交換鍵值的演算法」。7.8 節的基底排序不比較鍵值 —— 它直接用鍵值的「數字」當索引分配到儲槽,所以完全不受這個下限約束,可以做到 $O(n)$。這是本章兩個最重要的定理之間的關係,務必理解。

7.6 合併排序 (Merge Sort)

7.6.1 合併

「在探討將 $n$ 個記錄排序的合併排序演算法之前,讓我們先瞭解如何將兩個已排序的串列合併來形成一個排序串列。」第一個是程式 7.7,「它很簡單並使用額外的 $O(n)$ 空間」。它將已排好的串列 $(\texttt{list}[i], \ldots, \texttt{list}[m])$ 和 $(\texttt{list}[m+1], \ldots, \texttt{list}[n])$ 合併成一個排好順序的串列 $(\texttt{sorted}[i], \ldots, \texttt{sorted}[n])$。

分析 merge

「在每次 while 迴圈執行時,有一記錄被加入排序串列,即 $k$ 值加 1。加入排序串列的全部記錄的個數為 $n-i+1$。這表示 while 迴圈至多執行 $n-i+1$ 次。所以全部的計算時間為 $O(n-i+1)$。

「若記錄的長度為 $M$,這個時間實際為 $O(M(n-i+1))$。當 $M$ 大於 1,鏈結串列表示法減少 sorted 串列所需之額外的 $n-i+1$ 個記錄。但是,必須增加 $n-i+1$ 個鏈結欄位的空間。使用這種表示法,計算時間就不再 $M$ 有關;即僅為 $O(n-i+1)$ 而已。

$O(1)$ 空間的合併 —— 課本的進階討論

7.6.1 節花了不少篇幅討論如何用 $O(1)$ 額外空間做合併(圖 7.6 的 $\sqrt{n}$ 區塊法,以及習題中 Huang 和 Langston 的合併緩衝區方法)。結論是可行但常數因子很大 —— 這就是 7.7 節開頭說「使用 $O(1)$ 空間的合併演算法,所需的空間可減為 $O(1)$。所得到的排序演算法則比原先的演算法慢得多了」的由來,也是累堆排序值得存在的理由。

7.6.2 迴路式合併排序

由下而上的合併

我們兩兩合併這些串列可以得到長度為 2 的 $n/2$ 個串列。(若 $n$ 為奇數,則有一個串列長度為 1。)而後再將 $n/2$ 個串列兩兩合併,以此類推,直到只剩下一個串列為止。如果先設計一個可以執行單回合併的函數,則迴路式的演算法就很容易實作。」

void merge_pass(element list[], element sorted[], int n,
                                        int length)
{
/* perform one pass of the merge sort.  It merges adjacent
pairs of subfiles from list into sorted.  n is the
number of elements in the list. length is the length of the
subfile */
   int i,j;
   for (i = 0; i <= n - 2 * length; i += 2 * length)
      merge(list,sorted,i,i + length - 1,i + 2 * length - 1);
   if (i + length < n)
      merge(list,sorted,i,i + length - 1,n - 1);
   else
      for (j = i; j < n; j++)
         sorted[j] = list[j];
}

程式 7.9:merge_pass

分析 merge_sort

「合併排序對輸入記錄進行多回的排序。第一回合併大小為 1 的串列,第二回合併大小為 2 的串列,而第 $i$ 回合併大小為 $2^{i-1}$ 的串列。所以,所需的全部回數為 $\lceil\log_2 n\rceil$。正如 merge 所顯示的,我們可以在線性時間內合併兩個串列,這表示每一回需時 $O(n)$。因為有 $\lceil\log_2 n\rceil$ 回,故全部的計算時間為 $O(n\log_2 n)$。

7.6.3 遞迴式合併排序

由上而下 + 鏈結串列

「每一次遞迴呼叫時,目前處理的串列由左而右加上註標,且分成兩個子串列,它們的註標分別由 left 到 $\lfloor(\texttt{left}+\texttt{right})/2\rfloor$ 和 $\lfloor(\texttt{left}+\texttt{right})/2\rfloor$ 到 right。」

你會注意到所合併的子串列與迴路式實作(圖 7.7)所產生的不同。

int listmerge(element list[], int first, int second)
/* merge lists pointed to by first and second */
{
   int start = n;
   while (first != -1 && second != -1)
      if (list[first].key <= list[second].key) {
      /* key in first list is lower, link this element to
      start and change start to point to first */
         list[start].link = first;
         start = first;
         first = list[first].link;
      }
      else {
      /* key in second list is lower, link this element into
      the partially sorted list */
         list[start].link = second;
         start = second;
         second = list[second].link;
      }
   /* move remainder */
   if (first == -1)
      list[start].link = second;
   else
      list[start].link = first;
   return list[n].link; /* start of the new list */
}

程式 7.12:合併鏈結串列 —— 不移動任何記錄,只重接 link 欄位

分析 rmerge

你也許已經証明合併排序的鏈結串列版本結果是一個穩定的排序函數,且其計算時間為 $O(n\log n)$。

改良:自然合併排序 (natural merge sort)

修改 merge_sort 使其根據輸入串列事先計算的順序來排序。在此種實作法中,先對資料進行一次啟始階段,找出已經按順序排列的一連串記錄。合併排序則使用這些一開始已排序的子串列繼續其他的各回。」—— 這和插入排序的 LOO 分析是同一個精神:利用輸入資料已有的順序。對已經排好的輸入,自然合併排序只需要一回。

習題 2 — 合併排序(7.6 節習題)
  1. 使用 $O(n)$ 時間和 $O(1)$ 空間的函數來合併兩個任意長度的檔案。
  2. 是否 merge_sort 為穩定的?
  3. 假設以程式 7.8 來設計合併排序函數。則所產生的函數是否為穩定排序?
  4. 設計一個演算法來執行自然合併排序。此演算法在最初的排序串列上需要多少時間?此一新演算法的最差狀況計算時間為何?所需的額外空間有多少?
點擊展開解題要點

第 2 題:merge_sort 是穩定的,前提是 merge 在鍵值相等時優先取左邊子串列的元素(程式中的 if (list[first].key <= list[second].key) 用的是 <= 而非 < —— 這個等號就是穩定性的來源)。把它改成 <,排序仍然正確,但不再穩定。

第 4 題:
最初已排序的串列:啟始階段掃描一次發現整個串列已是一個「自然行程」,零回合併,總時間 $O(n)$
最差狀況:輸入完全逆序時,每個元素自成一個行程,共 $n$ 個行程,需要 $\lceil\log_2 n\rceil$ 回,$O(n\log n)$ —— 和一般合併排序相同。
額外空間:陣列版 $O(n)$,鏈結串列版 $O(n)$ 個 link 欄位。

本章各排序的穩定性總表:插入排序 ✓、合併排序 ✓、基底排序 ✓(LSD 版)、快排 ✗累堆排序 ✗、選擇排序 ✗。

7.7 累堆排序 (Heap Sort)

累堆排序存在的理由

「雖然前一節所討論的合併排序方法在最差狀況及平均行為的計算時間都是 $O(n\log n)$,它需要和所要排序的檔案中記錄個數成正比的額外空間。使用 $O(1)$ 空間的合併演算法,所需的空間可減為 $O(1)$。所得到的排序演算法則比原先的演算法慢得多了。

我們將要研討的另一個排序方法,累堆排序(heap sort),則僅需要一定的額外空間,且它的最差狀況和平均計算時間為 $O(n\log n)$。雖然累堆排序比使用 $O(n)$ 額外空間的合併排序稍為慢一些,它比採用 $O(1)$ 額外空間的合併排序來得快多了。

void adjust(element list[], int root, int n)
/* adjust the binary tree to establish the heap */
{
   int child,rootkey;
   element temp;
   temp = list[root];
   rootkey = list[root].key;
   child = 2 * root;           /* left child */
   while (child <= n) {
      if ((child < n) &&
      (list[child].key < list[child+1].key))
         child++;
      if (rootkey > list[child].key) /* compare root and
                                        max. child */
         break;
      else {
         list[child / 2] = list[child]; /* move to parent */
         child *= 2;
      }
   }
   list[child/2] = temp;
}

程式 7.13:調整一個最大累堆

adjust 的前提與複雜度

「使用函數 adjust,可以較快的速度建立有 $n$ 個記錄的累堆。這個函數接受一個二元樹 $T$,其左子樹和右子樹滿足累堆特性,但根可能不滿足,調整 $T$ 使得整個二元樹滿足累堆的特性。若根節點為 $i$ 的樹之高度為 $d$,則 while 迴圈最多執行 $d$ 次。故 adjust 的計算時間為 $O(d)$。

void heapsort(element list[], int n)
/* perform a heapsort on the array */
{
   int i,j;
   element temp;

   for (i = n/2; i > 0; i--)
      adjust(list,i,n);
   for (i = n-1; i > 0; i--) {
      SWAP(list[1],list[i+1],temp);
      adjust(list,1,i);
   }
}

程式 7.14:累堆排序

兩個 for 迴圈各做什麼
迴圈作用時間
第一個
for (i = n/2; i > 0; i--)
建立累堆。從最後一個非葉節點($n/2$)往上逐一 adjust葉節點本身已滿足累堆性質,所以從 $n/2$ 開始而非從 $n$。$O(n)$
第二個
for (i = n-1; i > 0; i--)
$n-1$ 回的排序過程。「在每一回中,將累堆的第一個記錄與最後一個記錄交換。因第一個記錄恆有最大鍵值,此記錄已在其排好順序後的位置。然後,減少累堆的大小並調整累堆。」$O(n\log n)$

「例如,在第一回,我們將具有最大鍵值的記錄放在第 $n$ 個位置上;第二回,將具有第二大鍵值的記錄放在第 $n-1$ 個位置上;而在第 $i$ 回,將具有第 $i$ 大的鍵值之記錄放在位置 $n-i+1$ 上。

為什麼累堆排序的額外空間是 $O(1)$

因為累堆就住在原陣列裡(第 5 章公設 5.3 的陣列定址),而「交換第一個和最後一個」也是就地進行的。整個演算法只需要一個 temp 變數。對照:合併排序需要 $O(n)$ 的 sorted 陣列,快排需要 $O(\log n)$ 的堆疊。累堆排序是本章唯一同時做到「最差 $O(n\log n)$」和「$O(1)$ 額外空間」的演算法。

習題 — 累堆排序(7.7 節習題)
  1. heapsort 是不穩定的。舉出一個輸入串列之實例,其相同鍵值的記錄之先後順序,在排序以後無法保持。
  2. 完成圖 7.13 的累堆排序之圖例說明。
點擊展開解題要點

第 1 題:取輸入 $(2_a, 2_b, 1)$,下標只是用來區分兩個相同鍵值的記錄。

  • 建堆之後陣列為 $(2_a, 2_b, 1)$(已是最大累堆)。
  • 第一回:交換 list[1]list[3] → $(1, 2_b, 2_a)$,累堆縮小為前 2 個,調整後 → $(2_b, 1, \mathbf{2_a})$。
  • 第二回:交換 list[1]list[2] → $(1, \mathbf{2_b}, \mathbf{2_a})$。

最終輸出為 $1, 2_b, 2_a$ —— $2_b$ 跑到了 $2_a$ 前面,原本的先後順序被破壞。根本原因是「交換第一個和最後一個」這個動作會把相等的元素跨越很遠的距離對調,任何做長距離交換的排序(快排、選擇排序、累堆排序)都因此不穩定。

7.8 基底排序 (Radix Sort)

「到目前為止,我們假設要排序的記錄只有一個鍵值。現在,讓我們考慮一些記錄有數個鍵值的排序問題。這些鍵值標記為 $K^0, K^1, \ldots, K^{r-1}$,其中 $K^0$ 為最重要的鍵值,$K^{r-1}$ 為最不重要的鍵值。

撲克牌的例子

「將一疊撲克牌排序的問題可看成有兩個鍵值,花色和點數,的排序問題,其中鍵值的先後關係為:」

$$K^0[\text{花色}]:\ \clubsuit \lt \diamondsuit \lt \heartsuit \lt \spadesuit$$ $$K^1[\text{點數}]:\ 2 \lt 3 \lt 4 \lt \cdots \lt 10 \lt J \lt Q \lt K \lt A$$

因此,排好的牌堆之順序為:$2\clubsuit, \cdots, A\clubsuit, \cdots, 2\spadesuit, \cdots, A\spadesuit$

MSD 最高有效數字排序

在花色 ($K^0$) 排序以後,將會形成四堆排,每堆排包含該花色的所有牌。然後根據點數,分別將四堆牌排序。最後,將四堆牌放在一起,使得黑桃牌堆在最底下,梅花堆則在最上面。」

LSD 最低有效數字排序

第二種方法則先由最低有效鍵值開始,稱為 LSD(Least Significant Digit)排序。在針對一個鍵值排序之後,各堆排放在一起形成一堆。」—— LSD 比較容易實作,因為不需要遞迴地處理每一堆。

#define MAX_DIGIT 3 /* numbers between 0 and 999*/
#define RADIX_SIZE 10
typedef struct list_node *list_pointer;
typedef struct list_node {
        int key[MAX_DIGIT];
        list_pointer link;
        };
list_pointer radix_sort(list_pointer ptr)
/*Radix Sort using a linked list */
{
   list_pointer front[RADIX_SIZE], rear [RADIX_SIZE];
   int i, j, digit;
   for (i = MAX_DIGIT-1; i >= 0; i--) {
      for (j = 0; j < RADIX_SIZE; j++)
         front[j] = rear[j] = NULL;
      while (ptr) {
         digit = ptr->key[i];
         if (!front[digit])
            front[digit] = ptr;
         else
            rear[digit]->link = ptr;
         rear[digit] = ptr;
         ptr = ptr->link;
      }
      /* reestablish the linked list for the next pass */
      ptr = NULL;
      for (j = RADIX_SIZE-1; j >= 0; j--)
         if (front[j]) {
            rear[j]->link = ptr;   ptr = front[j];
         }
   }
   return ptr;
}

程式 7.15:LSD 基底排序 —— 注意 for (i = MAX_DIGIT-1; i >= 0; i--)從最低位數開始

分析 radix_sort

「函數 radix_sort 對資料進行 MAX_DIGIT 回的排序,每一回需要 $O(\texttt{RADIX\_SIZE}+n)$ 時間。全部的時間計算時間為:」

$$O\big(\texttt{MAX\_DIGIT}\,(\texttt{RADIX\_SIZE}+n)\big)$$

MAX_DIGIT(記為 $d$)和 RADIX_SIZE(記為 $r$)都視為常數,這就是 $O(n)$ —— 突破了定理 7.1 的 $\Omega(n\log n)$ 下限,因為它不做鍵值比較。

基底的選擇有多要緊

基底的選擇影響了基底排序的計算時間。基底為 2,數值範圍在 1 到 100 萬之間的基底排序,其執行情形很嚇人,但基底為 10,數值範圍在 0 到 999 的基底排序,其執行情形則非常好。一般言之,我們必須謹慎地選擇基底,以 $n$ 值和最大鍵值之位數來監視最後的選擇。」

為什麼:基底 $r$ 越小,位數 $d$ 就越多($d = \lceil\log_r(\text{最大鍵值})\rceil$)。基底 2、最大值 $10^6$ 時 $d \approx 20$,要跑 20 回;基底 10、最大值 999 時 $d = 3$,只跑 3 回。時間 $O(d(r+n))$ 中 $d$ 和 $r$ 是一個消長關係,必須一起看。

範例 7.8 radix_sort 模擬

輸入順序為 $(179, 208, 306, 93, 859, 984, 55, 9, 271, 33)$。基底為 10,所有數值在範圍 $[0\ldots999]$ 之間,數字位數為 3。

階段排序依據結果
$i=2$個位數$271 \to 93 \to 33 \to 984 \to 55 \to 306 \to 179 \to 208 \to 859 \to 9$
$i=1$十位數$306 \to 208 \to 9 \to 33 \to 55 \to 859 \to 271 \to 179 \to 984 \to 93$
$i=0$百位數$9 \to 33 \to 55 \to 93 \to 179 \to 208 \to 271 \to 306 \to 859 \to 984$

圖 7.16:radix_sort 之模擬

LSD 能夠成立的關鍵是「每一回的分配必須是穩定的」 —— 程式用佇列(front/rear,從尾端加入)就保證了這一點。若改用堆疊,LSD 基底排序會完全錯誤。

習題 3 — 基底排序(7.8 節習題)
  1. 設計一個排序演算法,根據鍵值 $(K_0, \ldots, K_{r-1})$ 的編纂順序對記錄 $R_0, \ldots, R_{n-1}$ 排序,其中每一鍵值的範圍比 $n$ 值大得多。在此情況下,radix_sort 中每一鍵值的排序所採用的儲槽排序變得很沒有效率(何故)?如果我們想要一各演算法具有下列條件,你將會以何種方法對每一鍵值排序:(a) 好的最差狀況行為 (b) 好的平均行為 (c) $n$ 值很小,例如 $n \lt 15$?
  2. 假設有 $n$ 個記錄,其整數鍵值範圍在 $[0, n^2]$,則採用累堆排序或合併排序,它們可以在 $O(n\log n)$ 時間內完成排序。函數 radix_sort 對單一鍵值,即 MAX_DIGIT-1 = 1 且底數 $= n^2$,需要 $O(n^2)$ 時間。說明如何將鍵值解析為兩個次鍵值,使得基底排序僅需要 $O(n)$ 時間。
  3. 重寫 radix_sort,使其基底變成 2。使用 BIT 巨集來擷取每一位元。試以型態為 long int 的鍵值執行這個版本的排序。它又多快?
  4. 在何種狀況下 MSD 基底排序會比 LSD 基底排序更有效率?
點擊展開解題要點

第 1 題(「何故」):儲槽排序需要 RADIX_SIZE 個儲槽(front/rear 陣列)。若鍵值範圍遠大於 $n$,則 $r \gg n$,每一回都要花 $O(r)$ 時間去清空/掃描那 $r$ 個儲槽,而其中絕大多數是空的。時間 $O(d(r+n))$ 被 $r$ 主宰,完全失去優勢。
建議的替代:(a) 最差狀況好 → 合併排序或累堆排序;(b) 平均好 → 快排;(c) $n \lt 15$ → 插入排序(圖 7.26 的實測正好支持這一點)。

第 2 題(很漂亮的一題):鍵值 $K \in [0, n^2]$,把它寫成 $\mathbf{n}$ 進位的兩位數

$$K = q \cdot n + s, \qquad q = \lfloor K/n \rfloor,\quad s = K \bmod n$$

兩個次鍵值 $q, s$ 都在 $[0, n]$ 範圍內。於是 $d = 2$、$r = n+1$,時間為 $O(2(n + n)) = \boxed{O(n)}$。這就是「選對基底」的威力 —— 基底 $n^2$ 要 $O(n^2)$,基底 $n$ 只要 $O(n)$。

第 4 題:MSD 在鍵值分布不均、能提早分出勝負時較好 —— 一旦某個儲槽只剩一個元素就可以停止遞迴,不必再看剩下的位數。典型例子是變長字串排序(大多數字串在前幾個字元就分開了)。LSD 則必須掃完全部 $d$ 位,但實作簡單、不需遞迴、天生穩定。

7.9 串列和表格排序

問題:搬動大記錄很貴

除了基底排序和遞迴式合併排序以外,我們所討論過的所有排序方法需要大量的資料移動,因為在一些比較之後我們必須實際地移動記錄。如果記錄很大,將減慢排序過程。因此,當排序大的記錄之串列時,我們要修改排序法以便將資料移動減到最少。

串列排序 list_sort

在每個記錄中加一個 link 欄位,排序時只重接鏈結、不移動記錄。結果是一個鏈結串列(圖 7.17)。若需要實體重排,再加一個 linkb 欄位形成雙向鏈結(圖 7.18)。

表格排序 table_sort

用一個輔助表格 int table[MAX_SIZE]。「在排序一開始,$t[i]=i$,$0 \le i \le n-1$。若排序演算法需要交換 $R_i$ 和 $R_j$,則表格中的項目 $t[i]$ 和 $t[j]$ 互換;原始的串列則未改變。

table_sort 的實體重排 —— 環路分解

「根據排列 $t[0], t[1], \ldots, t[n-1]$ 來重排記錄的演算法是數學定理的一個非常有趣之應用:每一種排列根據不同的環路來決定。任一元素 $i$ 的環路包括 $i, t[i], t^2[i], \ldots, t^k[i]$,其中 $t^i[i]=t[t^{i-1}[i]]$,$t^0[i]=i$,而 $t^k[i]=i$。」

效率對照(課本的實測):「了 $3(n-1)$ 次記錄移動,而 table_sort 僅做了 $\lfloor 3n/2 \rfloor$ 次記錄移動。」—— 大約省了一半。

兩者何時用哪一個

課本給了明確建議:「對於較大的 $m$ 值,如果先對串列進行一回建立表格的程序,我們可以得到將鏈結串列排序排序的更有效率的方法。這各工作需要 $O(n)$ 時間。然後利用 table_sort 將記錄以表格中定義的順序重新排列。

簡言之:先用 list_sort 排出順序(不搬記錄),再轉成表格,最後用 table_sort 的環路法一次搬到定位。

7.10 內部排序摘要

課本的選擇建議(原文)

在我們所研習的數個排序方法中沒有一個是最好的。有些方法對小的 $n$ 值比較好,有些則對大的 $n$ 值比較好。插入排序法在串列已經部份排序時效果良好。因這個方法額外負擔少,它對小的 $n$ 值而言是最好的排序方法。

合併排序有最好的最差狀況行為,但它需要的空間比累堆排序為多,而且比快排有稍多的額外負擔。快排的平均行為最好,但其最差狀況行為是 $O(n^2)$。基底排序的行為根據鍵值大小和基底的選擇來決定。

Times in hundredths of a second
nquickmergeheapinsert
00.0410.0270.0340.032
101.0641.5241.4820.775
202.3433.7003.6802.253
303.7005.5876.1534.430
405.0857.8008.8157.275
506.5429.89211.58310.892
10014.27522.95026.77538.325
20030.77548.47560.550148.300
50084.400138.033174.100874.600
1000181.000289.450382.250

圖 7.26:排序方法之平均時間(粗體為該列最快者)

實測的結論

「正如所見的,當 $n$ 大約增加到 20,insertion_sort 最快。$n$ 值由 20 到 45,quicksort 最快。對於更大的 $n$ 值,merge_sort 最快。事實上,將 insertion_sortquicksort,和 merge sort 結合……」—— 這就是現代標準函式庫的混合排序(如 Timsort、Introsort)的思想原型。

課本給的實驗注意事項

如果你使用多元處理機的電腦,令所有的程式在大約相同得時間執行。在此計算機上,所計算得到的時間會因為電腦的工作負載而有很大的不同。拿插入排序在下午 2 點的執行時間與合併排序在凌晨 2 點的執行時間相互比較是沒有多大意義的。」—— 這條 1990 年代的建議在今天(多核心、動態頻率、雲端共享主機)只會更重要。

7.11 外部排序

外部排序的兩個階段

「在外部儲存體上最常用的排序方法為合併排序。這個方法基本上包含兩不同的階段(phase)。

  1. 首先,輸入檔案的一個區段(segment)以好的內部排序法加以排序。這些已排序的區段稱為行程(run)在形成時被寫到外部儲存體。
  2. 其次,在第一個階段所產生的行程根據圖 7.7 的合併樹之模式合併在一起,直到形成一個行程為止。

「因函數 merge(程式 7.7)在同一時間只需要正在合併的兩個行程之第一個記錄在記憶體內即可,它也可以用來合併大的行程。要修改本章其他的內部排序法以便適用在外部排序上則比較困難。」

課本的具體範例

「有一檔案包含 4500 個記錄 $A_1, \ldots, A_{4500}$,以一部內部記憶體最多可以排序 750 個記錄的電腦來排序。輸入檔案存放在磁碟上,其區塊大小為 250 個記錄。我們有另一部磁碟機當成工作磁碟機。輸入磁碟則不再被寫入。」

(1) 每次內部排序三個區塊(即 750 個記錄)以產生六個行程 $R_1$–$R_6$。「內部排序可用累堆排序或快排。這六個行程被寫到工作磁碟上。」
(2) 「畫定三個區塊的內部記憶體,每一區塊可以存放 250 個記錄。其中兩個當成輸入緩衝區,另一個當成輸出緩衝區。合併行程 $R_1$ 和 $R_2$。」
(3)(4) 繼續兩兩合併,直到只剩一個行程。
運算時間
(1) 讀取輸入的 18 個區塊,$18t_{IO}$,內部排序,$6t_{IS}$,寫入 18 個區塊,$18t_{IO}$$36t_{IO}+6t_{IS}$
(2) 兩兩合併行程 1–6$36t_{IO}+4500t_m$
(3) 合併兩個各有 1500 筆記錄的行程,各為 12 區塊$24t_{IO}+3000t_m$
(4) 合併有 3000 筆記錄的行程與有 1500 筆記錄的行程$36t_{IO}+4500t_m$
全部時間$132t_{IO} + 12000t_m + 6t_{IS}$

圖 7.30:磁碟排序範例之計算時間

其中 $t_{io} = t_s + t_l + t_{rw}$(找尋 + 延遲 + 讀寫),$t_{is}$ 為內部排序 750 個記錄所需的時間,$nt_m$ 為由輸入緩衝區合併 $n$ 個記錄到輸出緩衝區所需的時間。

為什麼 $132t_{IO}$

除了因內部排序而對資料所做的起始輸入回以外,合併形程對此資料需要 $2\frac{2}{3}$ 回(一回合併長度為 750 個記錄的六個行程,三分之一合併長度為 1500 的兩個行程,一回合併長度分別為 3000 和 1500 的兩個行程)。因為一個完整的行程包含 18 個區塊,則輸入和輸出時間為 $2 \times (2\frac{2}{3}+1) \times 18t_{io} = 132t_{io}$。最前面的 2 是因為每一記錄都會讀寫一次。

k-路合併與緩衝區

提高合併度數 $k$ 的取捨

合併大小為 $S_1, S_2, \ldots, S_k$ 的 $k$ 個行程不再能夠以 $O(\sum_1^k S_i)$ 時間於內部合併完成。……合併 $k$ 個行程最直接的方法是以 $k-1$ 個鍵值比較來決定下一個輸出記錄。這樣的計算時間為 $O((k-1)\sum_1^k S_i)$。因一共需要 $\log_k m$ 回,鍵值比較的全部次數為:」

$$n(k-1)\log_k m = n(k-1)\frac{\log_2 m}{\log_2 k}$$

因而,鍵值比較次數以 $(k-1)/\log_2 k$ 因數增加。當 $k$ 增加,在輸入/輸出時間上減少會被執行 $k$-路合併所增加的 CPU 時間所抵銷。對於大的 $k$ 值(如圖 $\ge 6$),使用具有 $k$ 個葉節點的輸家樹(loser tree,見第 5 章)來找出下一個最小鍵值元素,可以大大地降低所需的比較次數。此種情況下,對圖 7.32 的合併樹中每一階層所需的時間為 $O(n\log_2 k)$。因此樹的階層為 $O(\log_k m)$,內部處理所需的時間變成:」

$$O(n\log_2 k\,\log_k m) = O(n\log_2 m)$$
$k$ 不是越大越好

因可使用的內部記憶體大小固定且和 $k$ 無關,當 $k$ 增加時,緩衝區之大小一定會變小。這也表示磁碟上的區塊大小也要降低。降低區塊的大小,每回處理資料時會有更多的區塊被讀取或寫入。這表示輸入/輸出時間可能增加,因為包含在讀取一個資料區塊內的找尋和延遲時間也隨之增加。所以,超過某一 $k$ 值以後,不論處理回數降低了多少,輸入/輸出時間將會實質地增加。最佳的 $k$ 值明顯地與磁碟參數以及緩衝區可以使用的內部記憶體數量有關。

為何需要 $2k+2$ 個緩衝區

「因可使用的內部記憶體大小固定且和 $k$ 無關……雖然 $k+1$ 個緩衝區已足夠下一節我們將說明最好使用 $2k+2$ 個緩衝區。」—— $k$ 個輸入行程各需要兩個緩衝區(一個正在用、一個正在預先讀入),加上兩個輸出緩衝區(一個正在填、一個正在寫出),這樣 I/O 才能與 CPU 重疊。

定理 7.2 的兩項保證:「(1) 在 step 6,永遠有一各可用的緩衝區可以開始讀入下一各區塊;以及 (2) 在 $k$ 一路合併的 step 3 中,佇列中的下一個區塊在它需要時已經被讀入。」

課本還特別點出一個順序上的要求:「在 $k$-路合併過程中,測試『輸出緩衝區已滿載?』應該在測試『輸入緩出區空了?』之前執行,因為該行程的下一個輸入緩衝區可能尚未讀入,所以該佇列中可能沒有下一個緩衝區。」

行程產生與輸家樹

用輸家樹產生「更長的」行程

程式 7.21 tree_runs 用第 5 章的輸家樹來產生行程。關鍵的一段程式邏輯:

if (tree[winner].data.key < last_key_out)
   tree[winner].data.run = current_run + 1;   /* 屬於下一個行程 */
else
   tree[winner].data.run = current_run;       /* 仍屬於本行程 */

比較時先比 run 編號、再比 key

if (tree[loser].data.run < winner-run ||
   (tree[loser].data.run == winner-run &&
    tree[loser].data.key < tree[winner].data.key)) { ... }

這個技巧(稱為替換選擇,replacement selection)讓行程的平均長度達到記憶體容量的 2 倍,而不只是 1 倍 —— 行程變長一半,合併回數就少一回。「為了正確地建立輸家樹,我們建立一個虛擬的行程編號 0。」

最佳合併模式與 Huffman 演算法

問題:不同長度的行程該以什麼順序合併

「對於有 $n$ 個長度為 $q_i$,$1 \le i \le n$ 的行程的 $k$-路合併之成本可以利用具有最小的加權外部路徑長度,分支度為 $k$ 的合併樹加以極小化。雖然我們僅討論 $k=2$ 的狀況,我們可以很容易地產生 $k \gt 2$ 時的演算法(見習題)。」

typedef struct tree_node *tree_pointer;
typedef struct tree_node {
        tree_pointer left_child;
        int          weight;
        tree_pointer right_child;
);
tree_pointer tree;
int n;
huffman 怎麼用到前面幾章

「函數 huffman(程式 7.23)由 $n$ 個擴充二元樹開始,每一樹包含一個節點。它們存在陣列 heap[] 中。……huffman 函數使用了函數 leastinsertleast 找出 heap 中有最小加權的樹結構,並將它從 list 中刪除;insert 加入一個新的樹結構到 list。它們是在最小累堆上的刪除最小值和插入運算。函數 initialize 建立一個最小累堆。正如在 7.7 節所討論的,這些可以在線性時間內完成。

複雜度:$n-1$ 次合併,每次兩個 least(各 $O(\log n)$)加一個 insert($O(\log n)$),總計 $O(n\log n)$

Huffman 範例

「假設加權數 $q_1=2$、$q_2=3$、$q_3=5$、$q_4=7$、$q_5=9$ 和 $q_6=13$。」合併成本為:

$$2\cdot 4 + 3\cdot 4 + 5\cdot 3 + 7\cdot 2 + 9\cdot 2 + 13\cdot 2 \ \Rightarrow\ \text{加權外部路徑長度最小}$$

課本另舉的一組數字算出成本為 52:$2\cdot2 + 4\cdot2 + 5\cdot2 + 15\cdot2 = 52$。直觀原則:越短的行程應該被合併越多次(放在樹的越深處),因為每多合併一次就要多讀寫一次。

習題 — 外部排序(7.11 節習題)
  1. (a) $n$ 個記錄要在記憶體容量為 $S$ 記錄($S \ll n$)的電腦上排序。假設全部的 $S$ 記錄容量可以用作輸入/輸出緩衝區。輸入放在磁碟上,且包含 $m$ 個行程。假設每一次的磁碟存取所用的找尋時間為 $t_s$,延遲時間為 $t_l$。每一記錄的傳送時間為 $t_t$。如果 $k$-路合併配合著內部記憶體分成 I/O 緩衝區,以容許如演算法 buffering 一般的輸入,輸出和 CPU 處理重疊,則外部排序的第二個階段全部的輸入時間為何?
    (b) 令 $t_s = 80$ms、$t_l = 20$ms、$n = 200{,}000$、$m = 64$、$t_t = 10^{-3}$ sec/record、$S = 2000$。畫出一個全部的輸入時間 $t_{\text{input}}$ 對 $k$ 的函數圖。是否有一個 $k$ 值,使得 $t_{\text{cpu}} \approx t_{\text{input}}$?
  2. 修改 run_generation,使其起始的輸家樹以最前面的 $k$ 個記錄而非 $k$ 個虛擬記錄來建立。
  3. (a) 證明函數 huffman 正確地建立具有最小加權外部路徑長度的二元樹。
    (b) 當 $n$ 個行程以 $m$-路合併法合併時,Huffman 的演算法建立下列的規則:首先將長度為 0 的 $(1-n)\%(m-1)$ 個行程加入一組行程中。然後,重覆地與剩下得 $m$ 個最小行程合併,直到形成一個行程為止。證明這個規則可產生 $m$-路合併的最佳合併模式。
點擊展開解題要點

第 1 題的骨架:$k$-路合併需要 $2k+2$ 個緩衝區,所以每個緩衝區大小 $B = S/(2k+2)$ 個記錄。合併回數為 $\lceil\log_k m\rceil$。每一回要讀寫全部 $n$ 個記錄,即 $n/B$ 個區塊。每個區塊的存取時間為 $t_s + t_l + B\,t_t$。所以:

$$t_{\text{input}} = \lceil\log_k m\rceil \cdot \frac{n}{B}\big(t_s + t_l + B\,t_t\big),\qquad B = \frac{S}{2k+2}$$

(b) 的觀察:$k$ 增大 → $\lceil\log_k m\rceil$ 下降(好),但 $B$ 下降 → 區塊數 $n/B$ 上升 → 找尋與延遲時間 $(t_s+t_l)$ 被付出的次數上升(壞)。兩者相乘會有一個極小值,那就是最佳的 $k$。這正是 7.11 節那段警告的量化版本。

第 3(b) 題的關鍵:$m$-路合併的合併樹每個內部節點要有恰好 $m$ 個子節點才不浪費。$n$ 個葉節點時,需要 $n \equiv 1 \pmod{m-1}$ 才能剛好填滿。補上 $(1-n)\bmod(m-1)$ 個長度為 0 的虛擬行程,就把 $n$ 補到滿足這個同餘條件 —— 虛擬行程長度為 0,不增加任何成本。

本章重點回顧

五種內部排序的完整對照
排序法最佳平均最差額外空間穩定
插入排序$O(n)$$O(n^2)$$O(n^2)$$O(1)$
快排$O(n\log n)$$O(n\log n)$$O(n^2)$$O(\log n)$∼$O(n)$
合併排序$O(n\log n)$$O(n\log n)$$O(n\log n)$$O(n)$
累堆排序$O(n\log n)$$O(n\log n)$$O(n\log n)$$O(1)$
基底排序$O(d(r+n))$$O(r+n)$
一定要記住的清單
  1. 定理 7.1:任一排序 $n$ 個不同元素的決策樹高度至少 $\log_2(n!)+1$ ⇒ 基於比較的排序下限是 $\Omega(n\log n)$
  2. 基底排序不比較鍵值,所以不受此下限約束,可以做到 $O(n)$。
  3. 快排最差 $O(n^2)$ 的觸發條件是「輸入已排序」(因為取 list[left] 當基準鍵)。
  4. 累堆排序是唯一同時做到最差 $O(n\log n)$ 與 $O(1)$ 額外空間的
  5. 穩定的:插入、合併、基底。不穩定的:快排、累堆、選擇 —— 共同原因是長距離交換
  6. LSD 基底排序要求每一回的分配是穩定的,所以必須用佇列不能用堆疊。
  7. 實測結論:$n \lesssim 20$ 用插入排序,$20\sim45$ 用快排,更大用合併排序。
  8. 外部排序:兩階段(產生行程 + 合併行程);$k$-路合併用輸家樹把比較降到 $O(n\log_2 m)$;$2k+2$ 個緩衝區才能重疊 I/O 與 CPU;不等長行程用 Huffman 找最佳合併順序。
最容易答錯的六個點
  1. 對插入排序最好的輸入(已排序),對快排最糟。
  2. 合併排序的穩定性來自 <= 那個等號
  3. 累堆排序的第一個迴圈從 $n/2$ 開始(葉節點不用調整)。
  4. 基底排序時間是 $O(d(r+n))$,$d$ 與 $r$ 是消長關係,要一起看。
  5. 定理 7.1 只適用於比較式排序。
  6. 外部排序中 $k$ 不是越大越好 —— 緩衝區會變小。
本章用到的前面各章
  • 第 1 章:$O$ / $\Omega$ / $\theta$、選擇排序、效率估計的實驗方法(7.10 節的實驗設計幾乎是 1.5 節的翻版)。
  • 第 2 章:seqsearch 的哨兵技巧。
  • 第 4 章:合併兩個已排序串列不用額外節點(listmerge)。
  • 第 5 章:累堆(adjust)、決策樹用公設 5.1、選取樹/輸家樹用於 $k$-路合併與行程產生、Huffman 用最小累堆。
往後各章

第 8 章(雜湊)提供了另一種搜尋途徑 —— 不排序也能達到平均 $O(1)$ 的搜尋,代價是失去順序性(無法做範圍查詢、無法依序輸出)。第 9 章(累堆結構)把 7.7 節的累堆一般化成可合併的各種堆積。第 10 章(搜尋結構)則回到「維持排序狀態」這條路,用平衡樹讓插入、刪除、搜尋全部保持 $O(\log n)$ —— 本章排序一次要 $O(n\log n)$,第 10 章的結構讓你「一直保持排序」而每次操作只要 $O(\log n)$。