Outline — 點擊展開各節
9.1 最小-最大累堆
9.2 Deaps
9.3 左傾樹
9.4 二系累堆
9.5 費氏累堆
回顧
Chapter 9 · Heap Structures

第 9 章 累堆結構

第 5 章的累堆只支援「取最小」或「取最大」之一。本章一路擴充:兩端都能取(最小-最大累堆、deap)、兩個累堆能合併(左傾樹)、合併還要夠快(二系累堆、費氏累堆)—— 並在過程中建立本書最重要的分析工具:成本分攤

本章的擴充路線
結構新增的能力代價
第 5 章累堆插入、刪除最小(最大)
9.1 最小-最大累堆刪除最小最大(雙向優先佇列)演算法較複雜
9.2 Deap同上,但「較快一個常數因子,且其演算法較簡單
9.3 左傾樹結合(combine)兩個優先佇列,$O(\log n)$放棄陣列表示,改用指標
9.4 二系累堆插入與結合降到分攤 $O(1)$單次刪除可能 $O(n)$,要用分攤分析
9.5 費氏累堆再加上刪除任一節點減少鍵值多兩個欄位、階段切割規則

最後一節的回報很具體:用費氏累堆,第 6 章的 Prim 最小成本生成樹與 Dijkstra 最短路徑都獲得改善 —— 這正是第 6 章兩度預告過的事。

9.1 最小-最大累堆 (Min-Max Heaps)

9.1.1 定義

最小節點與最大節點

「令 $x$ 為最小-最大累堆中任一節點。如果 $x$ 位在最小階層,則 $x$ 中的元素是在以 $x$ 為根的子樹的所有元素中具有最小鍵值者。稱此為最小節點(min node)。同樣地,如果 $x$ 位在最大階層,則 $x$ 中的元素是在以 $x$ 為根的子樹的所有元素中具有最大鍵值者。稱此為最大節點(max node)。

7 7040 309 1015 4550 3020 12 min max min max 最小階層與最大階層交錯;根節點 7 是全域最小
圖 9.1:有 12 個元素的最小-最大累堆 ——「每一節點中的數值是該節點中元素的鍵值。注意,我們用的是 5.2 節所討論的陣列表示法。

9.1.2 插入元素到最小-最大累堆

插入的關鍵洞察 —— 只需要修一半的節點

「假設要將鍵值為 5 的元素插入這個最小-最大累堆。……比較鍵值 5 和 $j$ 的父節點中的鍵值 10,我們會發現因鍵值為 10 的節點位在最小階層,且 $5 \lt 10$,則可保証 5 小於位在最大階層上和位在從 $j$ 到根的路徑上的所有節點之鍵值。所以,最小-最大累堆的特性只需要對位在從 $j$ 到根路徑上的最小節點」做檢查。

對照課本給的另一個例子:若插入的是 80 而非 5,比較 80 與父節點 10(最小階層),因 $80 \gt 10$,則要改走最大階層那一條鏈 —— 這就是程式裡 verify_minverify_max 兩個分支的由來。

#define MAX_SIZE 100 /* maximum size of heap plus 1 */
#define FALSE 0
#define TRUE 1
#define SWAP(x,y,t) ((t) = (x), (x) = (y), (y) = (t))
typedef struct {
        int key;
        /* other fields */
        } element;
element heap[MAX_SIZE];
void min_max_insert(element heap[], int *n, element item)
{
/* insert item into the min-max heap */
   int parent;
   (*n) ++;
   if (*n == MAX_SIZE) {
      fprintf(stderr,"The heap is full\n");
      exit(1);
   }
   parent = (*n)/2;
   if (!parent)
   /* heap is empty, insert item into first position */
      heap[1] = item;
   else switch(level(parent)) {
      case FALSE: /* min level */
            if (item.key < heap[parent].key) {
               heap[*n] = heap[parent];
               verify_min(heap,parent,item);
            }
            else
               verify_max(heap,*n,item);
            break;
      case TRUE: /* max level */
            if (item.key > heap[parent].key) {
               heap[*n] = heap[parent];
               verify_max(heap,parent,item);
            }
            else
               verify_min(heap,*n,item);
   }
}

程式 9.1:插入元素到最小-最大累堆的程序

level(i) 是什麼

習題 2 要求「設計 level(i) 函數,使其可以判斷最小-最大累堆中的節點 $i$ 位在最小或最大階層」。因為用的是第 5 章公設 5.3 的陣列表示法,節點 $i$ 的階層就是 $\lfloor\log_2 i\rfloor+1$,所以只要判斷 $\lfloor\log_2 i\rfloor$ 的奇偶即可 —— 根節點($i=1$)在最小階層。

9.1.3 刪除最小的元素

「在一般情況下,我們要將元素 item 重新插入最小-最大累堆 heap 中,它的根節點是空的。考慮兩種情形:(1) 根節點沒有子節點。在此情況下,item 插入根節點中。

element delete_min(element heap[], int *n)
{
/* delete the minimum element from the min-max heap */
   int i, last, k, parent;
   element temp, x;

   if (!(*n)) {
      fprintf(stderr, "The heap is empty\n");
      heap[0].key = INT_MAX; /* error key in heap[0] */
      return heap[0];
   }
   heap[0] = heap[1]; /* save the element */
   x = heap[(*n)--];
   /* find place to insert x */
   for (i = 1, last = (*n) /2; i <= last;) {
      k = min_child_grandchild(i, *n);
      if (x.key <= heap[k].key) break;
      /* case 2(b) or 2(c) */
      heap[i] = heap[k];
      if (k <= 2*i+1) {    /* 2(b) */
         i = k;
         break;
      }
      /* case 2(c), k is a grandchild of i */
      parent = k/2;
      if (x.key > heap[parent].key)
         SWAP(heap[parent], x, temp);
      i = k;
   } /* for */
   heap[i] = x;
   return heap[0];
}

程式 9.3:刪除具有最小鍵值元素的函數

分析 delete_min

每當 delete_minfor 迴圈重複執行一次,它完成一定量的工作。而且在每次執行時(可能最後一次除外),$i$ 向下移動兩階層。因最小-最大累堆為完整二元樹,heap 有 $O(\log n)$ 階層。所以,delete_min 的複雜度為 $O(\log n)$。

「刪除最大鍵值元素的函數與 delete_min 類似。它的演算法設計留作習題。」

為什麼一次跳兩層 —— 以及 min_child_grandchild

在最小-最大累堆中,最小階層節點的孫子也在最小階層,而它的子節點在最大階層。所以要把一個元素往下沉,必須同時考慮子節點與孫子節點共 6 個候選 —— 這就是 min_child_grandchild(i, n) 的工作。程式裡的 if (k <= 2*i+1) 判斷「$k$ 是子節點(2(b))還是孫子節點(2(c))」,是子節點就可以直接結束,是孫子就要多做一次與父節點的比較

習題 1 — 最小-最大累堆(9.1 節習題)
  1. 設計在插入元素到最小-最大累堆的討論中所定義的 verify_min 函數。
  2. 設計 level(i) 函數,使其可以判斷最小-最大累堆中的節點 $i$ 位在最小或最大階層。
  3. 設計 min_child_grandchild(i,n) 函數,使其傳回最小-最大累堆中節點 $i$ 的一個具有最小鍵值的子節點或孫子節點。你可以假設 $i$ 至少有一個子節點。$n$ 為最小-最大累堆目前的大小。
  4. 設計 delete_max 函數,使其刪除最小-最大累堆中具有最大鍵值的元素。對於有 $n$ 個元素的最小-最大累堆,你的函數執行時間必須是 $O(\log n)$。
  5. 設計一個函數,它建立一個具有 $n$ 個元素的最小-最大累堆。正如最小(最大)累堆的建立一般(見 5.6 節),利用一連串的 adjust 來建立。證明你的函數需要 $O(n)$,而非 $O(n\log n)$ 時間。$O(n\log n)$ 為以一連串的插入到原始的空累堆來建立最小-最大累堆時所需的時間。
點擊展開解題要點

第 2 題:

int level(int i)
{
/* return TRUE if i is on a max level, FALSE if min level */
   int lev = 0;
   while (i > 1) { i /= 2; lev++; }
   return (lev % 2);      /* 0 = min level, 1 = max level */
}

根節點 $i=1$ 得 lev = 0 → FALSE → 最小階層 ✓。也可以用位元運算加速。

第 4 題:delete_maxdelete_min 對稱,但最大元素不在根節點 —— 它是根的兩個子節點中較大的那個(因為根在最小階層)。所以要先花 $O(1)$ 找出 heap[2]heap[3] 中較大者,再從那裡往下沉。

第 5 題(為什麼是 $O(n)$ 不是 $O(n\log n)$):從 $i = n/2$ 往下數到 1 逐一 adjust。深度 $d$ 的節點只需要 $O(\text{height}-d)$ 的工作,而深度越大的節點越多。總成本為

$$\sum_{h=0}^{\log n} \frac{n}{2^{h+1}} \cdot O(h) = O\!\left(n\sum_{h=0}^{\infty}\frac{h}{2^{h+1}}\right) = O(n)$$

因為 $\sum h/2^{h+1}$ 收斂到一個常數。這與第 5 章 heapsort 第一個迴圈是 $O(n)$ 的理由完全相同。

9.2 Deaps

9.2.1 定義

deap 是什麼,以及它為何值得另立一節

「deap 是一個雙向累堆,它支援雙向優先權佇列的運算,如插入,刪除最小的元素,和刪除最大的元素。就像最小-最大累堆一樣,在 deap 上這些運算所需時間為對數的函數。但是,deap 較快一個常數因子,且其演算法較簡單。

定義:deap 是一完整二元樹,它是空的或滿足下列特性:

  1. 根不包含元素。
  2. 左子樹為最小累堆。
  3. 右子樹為最大累堆。
  4. 如果右子樹不是空的,則令 $i$ 為左子樹中的任一節點。令 $j$ 為右子樹中相對的節點。如果這樣的節點 $j$ 不存在,則令 $j$ 為右子樹中與 $i$ 的父節點相對的節點。在節點 $i$ 中的鍵值小於或等於節點 $j$ 中的鍵值。
「相對的節點」怎麼算

課本給了一行程式:

if (j > n) j /= 2;

先算出左子樹節點 $i$ 在右子樹的鏡像位置 $j$;若 $j$ 超過目前的元素個數 $n$(那個位置還不存在),就退回它的父節點。

注意,若 deap 定義中的特性 (4) 對所有的最小累堆的葉節點 $i$ 均滿足,則對最小累堆的其他節點也是滿足的。」—— 這句話讓驗證工作只需要檢查葉節點,是實作上的重要簡化。

為什麼根節點是空的

根節點空著,是為了讓左子樹(最小累堆)與右子樹(最大累堆)在陣列中的位置完全鏡像對稱。若根節點有元素,左右子樹的大小就會差一,鏡像計算會變得很麻煩。犧牲一個陣列位置,換來整章最簡潔的對應關係 —— 這和第 5 章累堆不用 heap[0] 是同樣的取捨。

9.2.2 插入元素到 Deap

課本的兩個插入例子
插入 4(較小的值):「插入處理由比較鍵值 4 和 $j$ 在最小累堆中相對節點 $i$ 的鍵值開始。此節點之鍵值為 19。為符合特性 (4),將 19 移到節點 $j$。現在,如果以最小累堆插入演算法將 4 插入節點 $i$,可得到如圖 9.8 的 deap。」
插入 30(較大的值):「如果我們要在圖 9.6 的 deap 中插入 30 而不是插入 4,則所得到的 deap 形狀仍與圖 9.7 相同。比較 30 與相對節點 $i$ 的鍵值 19,可發現利用最大累堆插入演算法將 30 插入位置 $j$ 即可符合特性 (4)。

「當新的節點 $j$ 是最小累堆的一個節點時,其狀況與方才討論的狀態是對稱的。」

   if (*n == 2)
      deap[2] = x; /* insert into empty deap */
   else switch(max_heap(*n)) {
      case FALSE:  /* *n is a position on min side */
            i = max_partner(*n);
            if (x.key > deap[i].key)  {
               deap[*n] = deap[i];
               max_insert(deap,i,x);
            }
            else
               min_insert(deap,*n,x);
            break;
      case TRUE: /* *n is a position on max side */
            i = min_partner(*n);
            if (x.key < deap[i].key) {
               deap[*n] = deap[i];
               min_insert(deap,i,x);
            }
            else
               max_insert(deap,*n,x);
   }
}

程式 9.4:插入元素到 deap 的函數

deap_insert 用到的三個輔助函數
  1. max_heap(n):「若且唯若 $n$ 是在 deap 的最大累堆中的一個位置時,此函數傳回 TRUE。」
  2. max_partner(n) / min_partner(n):求出鏡像的相對節點(含 if (j > n) j /= 2; 的修正)。
  3. min_insert / max_insert:就是第 5 章 5.6 節的累堆插入,只是作用在 deap 的一半上。

「在 deap 中最後一個元素的位置為 $n$,當 $n=1$ 時代表一個空的 deap。」(因為根節點永遠空著,$n=1$ 表示只有那個空根節點。)

9.2.3 刪除最小的元素

刪除的三個步驟

「其方法是首先將從最小累堆的根節點刪除元素轉換成從最小累堆的葉節點位置刪除元素。這個工作沿著最小累堆中從根到葉節點的路徑進行,以保証最小累堆的特性在累堆的先前各階層中都是符合的。這個處理的結果是將原先在最小累堆根節點的空的位置移到葉節點 $p$。這個葉節點隨後以元素 $t$ 填入,這個元素原來在 deap 的最後一個位置。將 $t$ 插入最小累堆的位置 $p$ 以 deap_insert 來完成」,但 max_partner(i) 的定義改為……

課本的具體追蹤:「要填入這個空位,沿著從根到葉節點的路徑移動。在每次移動之前,我們將目前這個節點的子節點中較小的元素放到目前的節點中。然後移到被移動的節點原來佔用的位置。在此例中,首先將 8 移到節點 2。然後將 9 移到 8 原來佔用的節點。現在,我們有一個空的葉節點,然後將 20 插入這個節點。比較 20 和在其最大同伴之內的鍵值 40。因 $20 \lt 40$,則不需要交換,並且將 20 插入以空的位置開始的最小累堆中。

分析 deap_delete_min

「我們可以很容易地驗証 deap_delete_min 正確地工作,不論 deap 的最後位置是在最小或最大累堆中。因 deap 的高度為 $O(\log n)$,其複雜度為 $O(\log n)$。deap_delete_max 運算以類似的方法執行。」

習題 — Deap(9.2 節習題)
  1. 設計 deap_insert 函數所使用的函數,以完成此函數。在一電腦上執行它,以便測試插入函數。建立你自己的測試資料。
  2. 設計 deap_delete_main 函數所使用的函數,以完成此函數。
  3. 設計一函數建立有 $n$ 個元素的累堆。你的程式執行時間必須為 $O(n)$。証明它確實有這個執行時間。(提示:正如 5.6 節所討論的方式,使用一連串的調整函數。)
  4. 設計一函數,針對一個最小-最大累堆和一個 deap 執行所有的雙向優先權佇列運算。(b) 建立一個有 $n$ 個元素的隨機串列,以及插入,刪除最小元素和刪除最大元素運算的隨機順序,其長度為 $m$。建立後一個順序時,使得插入運算的機率約為 .5,每一型式的刪除的機率約為 .25。以第一個隨機串列建立有 $n$ 個元素的一個最小-最大累堆及一個 deap。以最小-最大累堆和 deap 來計算執行 $m$ 個運算所需的時間。將此時間除以 $m$ 以求得每一運算的平均時間。對 $n=100,500,1000,2000,\ldots,5000$ 進行此實驗。令 $m$ 為 5000。(c) 根據你的實驗,你能說出這兩種雙向優先權佇列相對的優點為何?
  5. 當使用最小-最大累堆時,找出在每一個雙向優先權佇列運算中,最差狀況時可能做的鍵值比較之確實數目。對使用 deap 的狀況,進行同樣的工作。對這兩種方法,你能說出預期的最差狀況效率為何?你是否可以想出一種方法,利用二分搜尋來減少最差狀況的比較次數(這不會改變移動的元素個數)?
點擊展開解題要點

第 4(c) 題(課本要你自己實驗得出的結論):deap 的優勢在常數因子。原因是最小-最大累堆的 delete_min 每一層要比較最多 6 個候選(2 個子節點 + 4 個孫子節點),而 deap 只在一半的樹上做標準的累堆下沉,每層只比 2 個。兩者都是 $O(\log n)$,但 deap 的每一步便宜得多,且程式碼簡單許多 —— 這正是 9.2.1 節開頭那句「deap 較快一個常數因子,且其演算法較簡單」的實驗驗證。

第 5 題的「二分搜尋」提示:在累堆中把一個元素往下沉時,它最終落腳的深度是單調的 —— 沿著從根到葉的那條路徑,比它大的節點都在上面、比它小的都在下面。所以可以在那條長度 $\log n$ 的路徑上做二分搜尋,把比較次數從 $\log n$ 降到 $\log\log n$。但元素移動次數仍是 $\log n$(題目特別註明「這不會改變移動的元素個數」)—— 當鍵值比較很昂貴(例如長字串)時這個技巧才划算。

9.3 左傾樹 (Leftist Tree)

為什麼需要「結合」運算

「在前一節中我們將雙向優先權佇列的定義擴充,使其容許刪除最大元素和刪除最小元素運算。本節我們討論另一種擴充。假設除了一般的優先權佇列運算以外,還需要有結合(Combine)運算。這個運算將兩個優先權佇列結合成一個優先權佇列。

一個很具體的應用場景:當有一個優先權佇列的伺服程式(server)當掉時為這種結構的一種應用。此時必須將它的優先權佇列與另一仍正常工作的伺服程式之優先權佇列結合。

為什麼陣列累堆做不到

「令 $n$ 為兩個要結合的優先權佇列中所有的元素之個數。如果累堆用來代表優先權佇列,則結合運算需要 $O(n)$ 時間。使用左傾樹,結合運算以及一般的優先權佇列運算需要的時間為對數函數。

根本原因:陣列累堆是完整二元樹,兩個完整二元樹拼在一起不是完整二元樹,只能整個重建($O(n)$)。左傾樹放棄「完整」這個要求,改用指標 + 一個平衡條件,才能在 $O(\log n)$ 內接起來。

擴充二元樹與 shortest

定義 — 擴充二元樹與 shortest(x)

擴充二元樹(extended binary tree)為二元樹,其所有的空的二元子樹以方形節點取代。……在擴充二元樹中的方形節點稱為外部節點(external node)。二元樹原有的(圓形)節點稱為內部節點(internal node)。」

「令 $x$ 為擴充二元樹的節點。令 left_child(x)right_child(x) 分別表示內部節點 $x$ 的左子節點與右子節點。定義 shortest(x) 為從 $x$ 到一外部節點的最短路徑之長度。我們很容易地可以發現 shortest(x) 滿足下列的遞迴公式:」

$$\textit{shortest}(x)=\begin{cases} 0 & \text{if } x \text{ is an external node}\\[4pt] 1 + \min\{\textit{shortest}(\textit{left\_child}(x)),\ \textit{shortest}(\textit{right\_child}(x))\} & \text{otherwise} \end{cases}$$
公設 9.1

令 $x$ 為有 $n$ 個(內部)節點的左傾樹之根節點。

(a) $n \ge 2^{\textit{shortest}(x)} - 1$
(b) 最右邊的根到外部節點路徑為最短的根到外部節點路徑。其長度為 $\textit{shortest}(x)$。

證明 (a):「根據 shortest(x) 的定義,在左傾樹第一個 shortest(x) 階層不會有外部節點。因此,左傾樹至少有

$$\sum_{i=1}^{\textit{shortest}(x)} 2^{i-1} = 2^{\textit{shortest}(x)} - 1 \quad\text{個內部節點}$$

證明 (b):「直接由左傾樹的定義,可知這個公設成立。□」

公設 9.1(a) 的意義 —— 這就是 $O(\log n)$ 的來源

由 $n \ge 2^{\textit{shortest}(x)}-1$ 得

$$\textit{shortest}(x) \le \log_2(n+1)$$

而 (b) 說最短路徑就是最右邊那條。結合運算只沿著兩棵樹最右邊的路徑走 —— 所以總步數不超過 $\log_2(n_a+1) + \log_2(n_b+1) = O(\log n)$。「左傾」這個名字的意義就在這裡:把樹的重量往左推,讓右邊的路徑保持短。

定義 — 最小左傾樹

最小左傾樹(min-leftist tree)(或最大左傾樹,max-leftist tree)為左傾樹,其中每一節點的鍵值不大於(不小於)其子節點(如果有)中的鍵值。換言之,最小(最大)左傾樹是一種也是最小(最大)樹的左傾樹。……插入,刪除最小元素(刪除最大之元素)以及結合等運算,在使用最小(最大)左傾樹時,可以在對數函數時間內完成。」

用「結合」實作插入與刪除

一個運算就夠了

利用結合運算,我們可以實作插入和刪除最小元素兩個運算。

  • 要插入一個元素 $x$ 到最小左傾樹 $a$,我們首先建立一個只包含元素 $x$ 的最小左傾樹 $b$。然後結合最小左傾樹 $a$ 和 $b$。
  • 要從一個非空的最小左傾樹 $a$ 中刪除最小元素,則結合最小左傾樹 $a \to \texttt{left\_child}$ 和 $a\_\texttt{right\_child}$,並刪除節點 $a$。

這是很漂亮的設計:整個資料結構只需要正確實作一個 min_union,其餘運算都是它的推論。

typedef struct {
        int key;
        /* other fields */
        } element;
typedef struct leftist *leftist_tree;
struct leftist {
        leftist_tree left_child;
        element data;
        leftist_tree right_child;
        int shortest;
        };

注意我們所介紹的外部節點觀念是為了清楚地定義左傾樹。外部節點在左傾樹的表示法中是絕不可能出現的。一個外部節點的父節點之相關子節點欄位被設定為 NULL。

void min_combine(leftist_tree *a, leftist_tree *b)
{
/* combine the two min leftist trees *a and *b.  The
resulting min leftist tree is returned in *a, and *b
is set to NULL */
   if (!*a)
      *a = *b;
   else if (*b)
      min_union(a,b);
   *b = NULL;
}

程式 9.6:結合兩個左傾樹

void min_union(leftist_tree *a, leftist_tree *b)
{
/* recursively combine two nonempty min leftist trees */
   leftist_tree temp;
   /* set a to be the tree with smaller root */
   if ((*a)->data.key > (*b)->data.key)
      SWAP(*a,*b,temp);
   /* create binary tree such that the smallest key in each
   subtree is in the root */
   if (!(*a)->right_child)
      (*a)->right_child = *b;
   else
      min_union(&(*a)->right_child, b);
   /*leftist tree property */
   if (!(*a)->left_child) {
      (*a)->left_child = (*a)->right_child;
      (*a)->right_child = NULL ;
   }
   else if ((*a)->left_child->shortest <
   (*a)->right_child->shortest)
      SWAP((*a)->left_child,(*a)->right_child, temp);
   (*a)->shortest = (!(*a)->right_child) ? 1 :
   (*a)->right_child->shortest + 1;
}

程式 9.7:結合兩個最小左傾樹

min_union 的三段結構
  1. 選根:if ((*a)->data.key > (*b)->data.key) SWAP(*a,*b,temp); —— 保證 *a 是鍵值較小的那一棵。
  2. 遞迴:min_union(&(*a)->right_child, b); —— 只往右邊遞迴,這就是為什麼複雜度受限於兩條最右路徑。
  3. 修復左傾性質:若左子樹的 shortest 比右子樹小就交換左右,然後更新 shortest這一步是「左傾」得以維持的全部機制。
習題 2 — 傾斜累堆(9.3 節習題 5)

[Skewed heaps] Skewed heap(傾斜累堆)是一種最小樹,它支援最小左傾樹的運算插入,刪除最小元素和結合,且每個運算的分攤時間為 $O(\log n)$(分攤時間的定義見下一節)。正如最小左傾樹一般,利用結合運算來執行插入和刪除,結合運算沿著要結合的兩個累堆最右邊的路徑來進行。

但是,和最小左傾樹不同的是,對於所形成的累堆之最右邊路徑上的每一節點(最後一個節點除外),我們交換它的左和右子樹。

(a) 設計傾斜累堆的插入,刪除最小元素和結合函數。
(b) 比較上列運算的執行時間和最小左傾樹上相同的運算的執行時間。使用插入,刪除最小元素和結合運算的隨機順序。

點擊展開解題要點

傾斜累堆 vs 左傾樹 —— 一個乾淨的對照:

左傾樹傾斜累堆
額外欄位每節點一個 shortest不需要
交換左右子樹的時機只在 shortest 不合時交換無條件交換(最後一個節點除外)
複雜度保證每次運算最差 $O(\log n)$每次運算分攤 $O(\log n)$

傾斜累堆是「自我調整(self-adjusting)」資料結構的典型:它不維護任何平衡資訊,靠「每次都交換」這個簡單規則,讓壞情況無法連續發生。程式碼更短、空間更省,代價是單次運算可能很慢(但一連串運算的總成本仍然好)。這正是下一節「成本分攤」要處理的情境。

9.4 二系累堆 (Binomial Heaps)

9.4.1 成本分攤 (Cost Amortization)

為什麼需要分攤分析

「二系累堆是一種資料結構,它支援的運算和左傾樹所支援的運算相同(即插入,刪除最小或最大元素,以及結合)。在左傾樹中各別運算可以在 $O(\log n)$ 時間內完成,與這不同的是,在二系累堆上執行的一些運算可能需要 $O(n)$ 時間。如果我們分攤昂貴運算的部分成本到不昂貴的運算上,則個別運算的分攤複雜度可能是 $O(1)$ 或 $O(\log n)$,視運算的種類而定。

課本的分攤範例 —— 逐步跟著算一次

假設執行一連串的插入和刪除最小元素運算 I1, I2, D1, I3, I4, I5, I6, D2, I7

項目數值
每個插入的實際成本1(「這個假設表示每一個插入需要一個單位時間」)
D1 的實際成本8
D2 的實際成本10
全部的實際成本$7 \times 1 + 8 + 10 = \mathbf{25}$

分攤法則:我們收取一個運算的部份實際成本分攤給其他的運算。這樣會降低一些運算的收取成本,增加其他運算的收取成本。一個運算的分攤成本(amortized cost)是運算收取的全部成本。成本轉移(分攤)法則要求運算的分攤成本之加總大於或等於其實際成本的加總。

D1 的 8 個單位:「如果我們對一刪除最小元素運算收取一個單位成本給在最後一次刪除最小元素之後的每一插入運算(如果有時),則 D1 的兩個單位成本轉移給 I1 和 I2(每一插入的收取成本增加 1 個單位)。」
D2 的 10 個單位:「且 D2 的四個單位成本轉移到 I3–I6。
結果:「I1 到 I6 的各別分攤成本變成 2,而 I7 的分攤成本等於實際成本。分攤成本加總為 25,恰好與實際成本相同。

(D1 的分攤成本變成 $8-2=6$,D2 變成 $10-4=6$;總計 $6\times2 + 2\times6 + 1 = 12+12+1 = 25$ ✓)

分攤分析給了我們什麼 —— 更緊的上限

「假設我們可以証明無論插入和刪除最小元素運算執行的順序為何,我們可以用每一插入的分攤成本不超過 2,且每一刪除最小元素的分攤成本不超過 6 的方式來算成本。這使得我們可以聲明任何插入/刪除最小元素之順序的實際成本不會超過 $2i + 6d$,其中 $i$ 和 $d$ 分別為在順序中插入和刪除最小元素運算之次數。」

「假設刪除最小元素的實際成本不大於 10,而插入的實際成本不大於 1。根據實際成本,我們可推論順序之成本不大於 $i + 10d$。結合這兩個上限,可得到 $\min\{2i+6d,\ i+10d\}$ 為順序成本之上限。

$$\text{順序總成本} \le \min\{\,2i + 6d,\ \ i + 10d\,\}$$

因此,使用分攤成本表示法,我們可以獲得一連串的運算的複雜度更精密的上限值。我們將以成本分攤表示法來証明雖然在二系累堆上個別的刪除運算可能很昂貴,但二系累堆運算的任何順序之成本實際上非常小。

9.4.2 二系累堆定義

B-累堆的結構與節點欄位

「正如累堆和左傾樹一般,二系累堆也有兩種:最小和最大。最小二系累堆(min-binomial heap)為最小樹之集合,而最大二系累堆為最大樹之集合。本節將只討論最小二系累堆。它將以 B-累堆表示。

使用 B-累堆,一個插入和一個結合運算可以在 $O(1)$ 實際和分攤時間內完成,而一個刪除最小元素運算則在 $O(\log n)$ 分攤時間內完成。

欄位用途
degree「一個節點的 degree 是其子節點的個數」
child「用以指向它的子節點中的任一個(如果有時)」
left_link / right_link「用以維持在兄弟節點之間的雙向鏈結環形串列」
data元素本身

「一個節點的所有子節點形成一個雙向鏈結環形串列,而此節點指向其中的任一個子節點。此外,構成 B-累堆的最小樹之根節點也連接形成一個雙向鏈結環形串列。B-累堆則以一個指向有最小鍵值的最小樹之根節點的指標表示。

9.4.3 插入與結合

為什麼插入和結合是 $O(1)$

插入:把新元素當成一棵只有一個節點的最小樹,直接接到根節點的環形串列上,再比較一次鍵值決定是否更新「最小樹指標」。三個指標指派 + 一次比較 = $O(1)$。

結合:把兩個環形串列接起來(就是第 4 章 4.5.2 節習題 3 那個 $O(1)$ 的環狀串列合併),再比較兩個最小值。$O(1)$。

代價全部留給 delete_min —— 因為樹的數目可能累積得很多,刪除時必須把它們合併整理。

9.4.4 刪除最小元素

刪除的流程

刪除最小元素後,它的子節點全部變成新的最小樹。接著要反覆結合分支度相同的最小樹,直到所有最小樹的分支度都不相同為止。

課本的追蹤:「的最小樹變成根節點為 7 的最小樹之子樹。現在最小樹的集合如圖 9.18。在此集合中有三個最小樹的分支度為 2,如果選擇根節點 7 和 3 的兩個來合併,結果的最小樹之集合在圖 9.19。因此集合中的最小樹的分支度都不相同,最小樹結合過程結束。

「當我們完成了結合最小樹,將其根點鏈結以形成一個雙向鏈結環形串列。而且將 B-累堆的指標重設,使其指向有最小鍵值的最小樹之根節點。」

for (degree = p->degree; tree[degree]; degree++) {
   join_min_trees(p,tree[degree]);
   tree[degree] = NULL;
}
tree[degree] = p;

程式 9.9:在掃描串列 a 和 y 期間,處理所遇到的最小樹 p 的程式 —— tree[] 是一個以分支度為索引的暫存陣列,若該分支度已經有樹就合併(合併後分支度加一,繼續往上檢查),這和二進位加法的進位完全同構。

為什麼叫「二系」 —— 公設 9.2 與定理 9.1

關鍵觀察

「圖 9.15 的最小樹分別為 $B_1, B_2, B_3$。你會發現 $B_k$ 恰好有 $2^k$ 個節點。再者,如果我們由空的 B-累堆之集合開始並且僅執行插入,結合,與刪除最小元素運算,則在每一 B-累堆中的比最小樹均為二系樹。這個觀察使得我們可以証明,當只有執行插入,結合,與刪除最小元素運算時,我們可以分攤成本,使得每一插入和結合的分攤成本 $O(1)$,每一刪除最小元素的分攤成本為 $O(\log n)$。

公設 9.2

令 $a$ 為有 $n$ 個元素的 B-累堆,它由開始為空的 B-累堆,僅執行一連串的插入,結合,和刪除最小元素所形成。在 $a$ 中的每一最小樹的分支度 $\le \log_2 n$。結果 $\texttt{MAX\_DEGREE} \le \lfloor \log_2 n \rfloor$,且每一刪除最小元素的實際成本為 $O(\log n + s)$。

證明:「因 $a$ 的每一最小樹為二系樹,其節點最多為 $n$,所以沒有一個最小樹的分支度可以大於 $\lfloor\log_2 n\rfloor$。□」

定理 9.1

如果在開始為空的 B-累堆上執行一連串 $n$ 個插入,結合,刪除最小元素運算,則我們可以分攤成本,使得每一插入和結合的分攤時間複雜度為 $O(1)$,每一刪除最小元素的分攤時間複雜度為 $O(\log n)$。

證明的骨架:「對每一 B-累堆,根據下列方法,定義 #insertlast_size 兩個數值。」—— 用這兩個量當位能函數(potential function),證明每次刪除所做的額外工作,都已經預先由之前的插入付過了。

直觀理解:二進位計數器

B-累堆的樹集合與 $n$ 的二進位表示法一一對應:有 $B_k$ 這棵樹 $\iff$ $n$ 的第 $k$ 位元是 1。插入 = 二進位加 1;合併相同分支度的樹 = 進位。單次加 1 最壞要進位 $\log n$ 次,但$n$ 次加 1 的總進位次數只有 $O(n)$ —— 所以分攤下來每次是 $O(1)$。這就是定理 9.1 的本質。

9.5 費氏累堆 (Fibonacci Heap)

9.5.1 定義

F-累堆多支援的兩個運算

「費氏累堆(Fibonacci heap)是一種資料結構,它支援三種二系累堆的運算:插入,刪除最小或最大元素和結合,以及下列的運算:」

  1. 刪除,刪除指定的節點中之元素。
  2. 減少鍵值,將指定的節點之鍵值減去一個給定的正值。

第一個運算可以在 $O(1)$ 分攤時間內完成,第二個運算可在 $O(\log n)$ 分攤時間完成。使用費氏累堆時,二系累堆的運算可以如同使用二系累堆時,以相同的漸近時間完成。

F-累堆與 B-累堆的關係

B-累堆為 F-累堆的特例。所以前一節中 B-累堆的範例也是 F-累堆的範例。當然的本節參考這些範例當成 F-累堆的範例。要表示 F-累堆,在 B-累堆表示法中加入兩個欄位:parentchild_cut 到每一節點。parent 欄位指向一個節點的父節點(如果有時)。child_cut 欄位的意義稍後再說明。基本運算:插入,刪除最小元素,與結合執行方式如同在 B-累堆一般。」

9.5.2 從 F-累堆刪除一個元素

四個步驟

要從 F-累堆 $a$ 刪除任一節點 $b$,以下列方式完成:

  1. $a = b$,則執行一個刪除最小元素;否則執行下列步驟 2、3 和 4。
  2. 從所屬的雙向鏈結串列中刪除 $b$。
  3. 結合 $b$ 的子節點雙向鏈結串列和 $a$ 的最小樹根節的雙向鏈結串列,形成一個雙向鏈結串列。有相同分支度的樹並未如同刪除最小元素一般結合在一起。
  4. 廢棄節點 $b$。

除非要刪除最小的元素,任一刪除運算的實際成本為 $O(1)$。在此情況下,刪除運算的時間等於刪除最小元素運算的時間。

注意第 3 步的那句話

有相同分支度的樹並未如同刪除最小元素一般結合在一起。」—— 這就是 F-累堆能做到 $O(1)$ 的原因:把整理工作延後,全部推給下一次 delete_min。這是「懶惰(lazy)」資料結構設計的典型手法。

9.5.3 減少鍵值與階段切割

階段切割 (cascading cut)

減少某節點的鍵值後,它可能小於父節點,破壞最小樹性質。作法是把它從父節點切離,成為 F-累堆的一棵獨立最小樹。但若不加限制地切,樹會變得又瘦又長,破壞分支度的界限。

解法就是 child_cut每個節點記錄「自從它成為現在這個父節點的子節點以來,是否已經失去過一個子節點」。當一個節點失去第二個子節點時,它自己也要被切離 —— 這個連鎖反應就是階段切割。

課本的追蹤(圖 9.22)

「在減少鍵值運算中,根節點為 14 的最小樹從圖 9.22(a) 中的最小樹刪除,成為 F-累堆的一個最小樹。其根節點現在變成 10。這是圖 9.22(b) 中的第一個最小樹。在階段切割過程中,根據 12, 10, 8, 和 6 從根為 2 的最小樹切離。所以圖 9.22(a) 的一個最小樹變成所產生的 F-累堆的六個最小樹。節點 4 的 child_cut 變成 TRUE。其他節點的 child_cut 則不改變。

定理 9.2

如果一連串的 $n$ 個插入、結合、刪除最小元素,刪除和減少鍵值運算在一開始為空的 F-累堆上執行,則可以分攤成本,使得每一個插入、結合、刪除運算的分攤成本為 $O(\log n)$。整個順序的全部時間複雜度為順序中各別運算的分攤複雜度之加總。

證明要點(課本原文):「此定理之証明如同定理 9.1。#insert 之定義未改變。但 last_size 要改變。因為在每一刪除和刪除最小元素之後,last_size 則以 F-累堆中最小樹之個數為因數而改變(在圖 9.22 的範例中,last_size 增加了 5)。根據這個修改,可發現在刪除最小元素時,$s = \texttt{\#insert} \times \texttt{last\_size} + u - 1$。」

因全部的階段分割次數受限於全部的刪除和減少鍵值運算的次數(因它們是能夠設定 child_cut 為 TRUE 僅有的運算),這種分割的成本可以分攤在刪除和減少鍵值運算上,每一運算的分攤成本加 1。刪除一個不是最小元素的分攤成本變成 $O(\log n)$,其實際成本為 $O(1)$……減少鍵值運算的分攤成本為 $O(1)$,其實際成本為 $O(1)$。

費氏數從哪裡來

課本在公設中出現:「根據這個,我們可求得 $N_i = F_{i+2}$,$i \ge 0$。再者,因 $F_{i+2} \ge \Phi^i$,$N_i \ge \Phi^i$,所以 $i \le \log_\Phi m$。」

$$N_i = F_{i+2} \ge \Phi^{\,i}, \qquad \Phi = \frac{1+\sqrt5}{2}$$

$N_i$ 是「分支度為 $i$ 的最小樹中最少的節點個數」。階段切割規則保證了這個下限是費氏數 —— 這就是「費氏累堆」名稱的由來,而 $N_i \ge \Phi^i$ 正是分支度不超過 $\log_\Phi n$ 的保證。

應用:MST 與最短路徑

第 6 章兩次預告的兌現

精選參考文獻中寫明:「B-累堆和 F-累堆由 M. Fredman 和 R. Tarjan 提出。它們的工作發表在 "Fibonacci heaps and their uses in improved network optimization algorithms", JACM, 34, 3, July 1987, pp. 96–615。這篇論文也討論本章所提到的多種基本 F-累堆的改良,以及 F-累堆在指派問題(assignment problem),和找尋最小成生成樹的問題上之應用。

演算法原本(第 6 章)用 F-累堆
Prim MST$O(n^2)$$O(e + n\log n)$
Dijkstra 最短路徑$O(n^2)$$O(e + n\log n)$

關鍵就在「減少鍵值」這個運算:Prim 和 Dijkstra 的內層迴圈做的正是「更新某個頂點的距離估計」,而那在 F-累堆上是分攤 $O(1)$。對稀疏圖($e \ll n^2$),這是實質的改進。

習題 — 費氏累堆(9.5 節習題)
  1. 証明如果以空的 F-累堆開始,並僅執行插入,結合與刪除最小元素運算,則在 F-累堆中的最小樹為二系樹。
  2. 所有在 F-累堆上執行的運算是否全部可以用單向鏈結環形串列而不是雙向鏈結環形串列,在相同時間內完成?注意,從單向鏈結環形串列上刪除任一個節點 $x$,可以將下一個節點的資料複製過來,而後刪除下一個節點而不是節點 $x$。
  3. 証明若以空的 F-累堆開始,但不執行階段切割,則一連串的 F-累堆運算可以形成分支度為 $k$ 且只有 $k+1$ 個節點,$k \ge 1$ 之最小樹。
  4. 假設改變分段切割的規則,使得分割只有在節點失去第三個子節點而不是第二個子節點時才執行。對此,child_cut 欄位之值可以是 0, 1 和 2。(a) 找出 $N_i$ 的遞迴公式。(b) 解 (a) 中的遞迴公式以找出 $N_i$ 之下限。(c) 是否修改過的分段切割規則可以保証任一分支度為 $i$ 的最小樹中,其最小的節點個數為 $i$ 的指數函數?
點擊展開解題要點

第 3 題(為什麼一定要有階段切割):這題直接證明了階段切割不是可有可無的最佳化,而是正確性的必要條件。若不做階段切割,可以構造出一棵分支度 $k$ 卻只有 $k+1$ 個節點的「掃帚形」最小樹(根有 $k$ 個子節點,每個都是葉節點 —— 原本的子樹都被切光了)。此時 $N_k = k+1$ 是線性而非指數,公設中 $i \le \log_\Phi m$ 的界限就崩潰了,分攤複雜度不再成立。

第 4 題:改成「失去第三個子節點才切」,遞迴式變成

$$N_i = N_{i-1} + N_{i-3} + 1 \quad(\text{對照原規則的 } N_i = N_{i-1}+N_{i-2}+1)$$

(b) 解出的下限仍是指數,但底數變小(原本是 $\Phi \approx 1.618$,新規則的底數是 $x^3 = x^2+1$ 的實根 $\approx 1.4656$)。(c) 答案是「可以」 —— 仍是指數,只是分支度上限從 $\log_{1.618} n$ 放寬到 $\log_{1.466} n$,常數變差但漸近階不變。

第 2 題:可以。提示已經給了關鍵技巧 —— 單向串列刪除節點 $x$ 時,把 x->next 的資料複製到 $x$,然後刪除 x->next。唯一的麻煩是其他指向 x->next 的指標(如 parentchild)必須一併更新,所以實際上省下的空間可能被額外的指標維護抵銷。

9.6 精選參考文獻

結構原始文獻
最小-最大累堆M. Atkinson, J. Sack, N. Santoro and T. Strothotte, "Min-max heap and generalized priority", Communications of the ACM, pp. 996–1000, 29, 10, Oct. 1986(本篇論文中也提到最小-最大累堆之擴充)
DeapSvante Carlsson, "The deap — A double-ended heap to implement double-ended priority queues", Information Processing Letters, 26, pp. 33–36, 1987
左傾樹C. Crane, "Linear lists and priority queues as balanced binary trees", Technical report CS-72-259, Computer Science Dept., Stanford University, CA, 1972
左傾樹(進一步)R. Tarjan, Data Structure & Network Algorithms, SIAM, Philadelphia, PA, 1983
習題中的 lazy deletion 摘自 D. Cheriton and R. Tarjan, "Finding minimum spanning trees", SIAM Jr. on Computing, 5, 1976, pp. 724–742
B-累堆與 F-累堆M. Fredman and R. Tarjan, "Fibonacci heaps and their uses in improved network optimization algorithms", JACM, 34, 3, July 1987, pp. 596–615

本章重點回顧

五種結構的完整對照
結構插入刪除最小刪除最大結合減少鍵值
第 5 章累堆$O(\log n)$$O(\log n)$$O(n)$
最小-最大累堆$O(\log n)$$O(\log n)$$O(\log n)$$O(n)$
Deap$O(\log n)$$O(\log n)$$O(\log n)$$O(n)$
左傾樹$O(\log n)$$O(\log n)$$O(\log n)$
二系累堆$O(1)$ 分攤$O(\log n)$ 分攤$O(1)$ 分攤
費氏累堆$O(1)$ 分攤$O(\log n)$ 分攤$O(1)$ 分攤$O(1)$ 分攤

常數因子比最小-最大累堆小。   最小版不支援;需要最大版(max-leftist tree / max-binomial heap)。

一定要記住的清單
  1. 最小-最大累堆:階層交錯(min/max/min/max…),delete_min 每次下沉兩層,要比較子節點與孫子節點
  2. Deap:根節點空著、左子樹最小累堆、右子樹最大累堆、左邊每個節點 $\le$ 右邊的鏡像節點。$n=1$ 代表空。
  3. 左傾樹:$\textit{shortest}(x) \le \log_2(n+1)$(公設 9.1a),最右邊的路徑就是最短路徑(9.1b)。min_union 只往右遞迴,最後修復左傾性質。
  4. 插入與刪除都可以用「結合」實作 —— 只要寫對 min_union 一個函數。
  5. 成本分攤:分攤成本之加總 $\ge$ 實際成本之加總。用它得到更緊的上限 $\min\{2i+6d,\ i+10d\}$。
  6. B-累堆:$B_k$ 恰好有 $2^k$ 個節點;樹集合 $\leftrightarrow$ $n$ 的二進位表示;合併相同分支度 = 二進位進位。
  7. F-累堆:多了 parentchild_cut 兩個欄位。階段切割是正確性的必要條件(習題 3),它保證 $N_i = F_{i+2} \ge \Phi^i$。
  8. F-累堆的回報:Prim 與 Dijkstra 從 $O(n^2)$ 改進到 $O(e+n\log n)$。
最容易答錯的六個點
  1. deap 的根節點是空的,$n=1$ 就是空 deap。
  2. 左傾樹的 shortest 是到外部節點的距離,不是樹高。
  3. B-累堆的單次刪除可能 $O(n)$,只有分攤才是 $O(\log n)$。
  4. F-累堆刪除時合併相同分支度的樹(延後到 delete_min)。
  5. 沒有階段切割,F-累堆的複雜度分析整個失效
  6. 建堆用連續 adjust 是 $O(n)$,用連續插入是 $O(n\log n)$。
本章用到的前面各章
  • 第 4 章:雙向鏈結環形串列(B-累堆的兄弟串列與根串列);環狀串列 $O(1)$ 合併(B-累堆的結合運算)。
  • 第 5 章:累堆的陣列表示法與公設 5.3(最小-最大累堆、deap);adjust 建堆是 $O(n)$;擴充二元樹的外部節點觀念(也用於第 7 章 Huffman)。
  • 第 6 章:Prim 與 Dijkstra —— 本章 9.5 節兌現了它們的改進承諾。
  • 第 7 章:Huffman 用到最小累堆;本章的分攤分析與第 5 章 union-find 的 $\alpha(m,n)$ 分析是同一類工具。
本章最有價值的一課

成本分攤不只是一個分析技巧,它改變了資料結構的設計方式。B-累堆和 F-累堆之所以敢讓單次刪除變得昂貴,正是因為有分攤分析保證「昂貴的情況不會連續發生」。同樣的思路在習題 5 的傾斜累堆(不維護任何平衡資訊,靠無條件交換)也看得到。

第 10 章的伸展樹(splay tree)是這個思想的另一個高峰 —— 它同樣不維護平衡資訊,但每次存取都把節點旋轉到根,分攤下來每個運算仍是 $O(\log n)$。讀完第 10 章再回來對照本章 9.4.1 節,會對「分攤」有更完整的理解。