不占空間的排序演算法:堆積排序

筆記不占空間的排序演算法:堆積排序

堆積排序(heap­sort)是一個能在 $O(1 + nL(n))$ 時間內排序 $n$ 個元素的排序演算法,其中 $L(n) = \lfloor \log_2 (2n \varplus 1) \rfloor \period$本文簡述堆積排序的步驟及其時間複雜度的分析。

$ \gdef\twodots{\mathinner{\ldotp\ldotp} \allowbreak} \gdef\sbrack#1{[#1]} \gdef\sbrackc#1#2{[\mkern#1mu#2]} \gdef\cbrack#1{\{#1\}} \gdef\cbrackc#1#2{\{\mkern#1mu#2\}} \gdef\paren#1{(#1)} \gdef\parenc#1#2{(\mkern#1mu#2)} $

備註 以下我們所使用的漸近記號採用較嚴格的定義:若我們稱一演算法在輸入大小為 $n$ 時的花費時間為 $O(\.T(n)) \comma$則代表存在一正數 $C \comma$使得在任意輸入大小為 $n$ 的狀況下該演算法的執行時間皆不超過 $C \cdot T(n) \period$

1 準備工作

設 $A[0 \twodots n \varminus 1]$ 為一陣列。以下對任意整數 $a \jcomma b \comma$我們定義 \[\begin{split}[a \twodots b] = \{k \in \ZZ: a \leq k \leq b\} \period\end{split}\]

1.1 最大堆積性質

對任意整數 $m \in \{0 \cm 1 \cm \ldots \cm n\} \comma$子陣列 $A[0 \twodots m \varminus 1]$ 稱為 $A[0 \twodots n \varminus 1]$ 的一個前綴

給定前綴 $A[0 \twodots m \varminus 1]$ 與一索引值 $i \in [0 \twodots m \varminus 1] \comma$若下列敘述成立,則我們稱 $i$ 在前綴 $A[0 \twodots m \varminus 1]$ 中滿足最大堆積性質(maximum-heap prop­er­ty)

  1. $2i + 1 < m \comma$則 $A[2i \varplus 1] \leq A[i] \period$
  2. $2i + 2 < m \comma$則 $A[2i \varplus 2] \leq A[i] \period$

1.2 最大堆積

若每個索引值 $i \in [0 \twodots m \varminus 1]$ 均在 $A[0 \twodots m \varminus 1]$ 中滿足最大堆積性質,則我們稱前綴 $A[0 \twodots m \varminus 1]$ 為一最大堆積(maximum heap)

若 $A[0 \twodots m \varminus 1]$ 為最大堆積且 $m \geq 1 \comma$則 $A[0]$ 為 $A[0 \twodots m \varminus 1]$ 中的最大值。

2 演算法

2.1 向後插入

演算法 A(向後插入) 給定前綴 $A[0 \twodots m \varminus 1]$ 與一索引值 $i \in [0 \twodots m \varminus 1] \comma$其中 $\sbrack{i \varplus 1 \twodots m \varminus 1}$ 中的索引值在演算法執行前均在前綴 $A[0 \twodots m \varminus 1]$ 中滿足最大堆積性質,則演算法 A 會將此前綴重排,使 $\sbrack{i \twodots m \varminus 1}$ 中的索引值均在此前綴中滿足最大堆積性質。

  1. $j \gets i$ 與 $k \gets i \period$
  2. $2j + 1 < m$ 且 $A[2j \varplus 1] > A[k] \comma$則設 $k \gets 2j + 1 \period$
  3. $2j + 2 < m$ 且 $A[2j \varplus 2] > A[k] \comma$則設 $k \gets 2j + 2 \period$
  4. $k \neq j \comma$則交換 $A\sbrackc2{j} \leftrightarrow A\sbrack{k} \comma$接著設 $j \gets k \comma$並回到步驟 2;否則終止演算法。

正確性 首先我們驗證每次進入步驟 2 與離開步驟 4 時,以下敘述恆成立:

  1. $j = k \period$
  2. $[i \twodots m \varminus 1]$ 中除了 $j$ 以外的索引值均滿足最大堆積性質。

首次進入步驟 2 時即有 $k = i = j \period$接著進入步驟 4 時若有 $j \neq k \comma$則我們會設 $k \gets j \comma$故 (a) 成立。

首次進入步驟 2 時,由於 $j = i$ 且 $\sbrack{i \varplus 1 \twodots m \varminus 1}$ 中的索引值均滿足最大堆積性質,故 (b) 成立。由 (a) 可知每次經過步驟 2、3 後,進入步驟 4 時有 \[\begin{split}A[k] \in \argmax_{k'\.\in\.K} A[k'] \comma\end{split}\] 其中 \[\begin{split}K = \cbrackc2{j, 2j \varplus 1, 2j \varplus 2} \cap [0 \twodots m \varminus 1] \period\end{split}\] 因此在步驟 4 中交換 $A\sbrackc2{j} \leftrightarrow A\sbrack{k}$ 後,索引值 $j$ 會滿足最大堆積性質,即 $[i \twodots m \varminus 1]$ 中除了 $k$ 以外的索引值均滿足最大堆積性質。故步驟 4 最後設 $j \gets k$ 之後,(b) 即成立。

可以注意到離開步驟 4 時,演算法會終止的充要條件為進入步驟 4 時 $j = k$ 成立,此時索引值 $j$ 必須滿足最大堆積性質。故由 (b) 可知演算法終止時 $\sbrack{i \twodots m \varminus 1}$ 中的索引值均滿足最大堆積性質。

最後我們確認演算法必定終止。由於每次執行步驟 2―4 後,若有跳回步驟 2,則 $j$ 至少會變為 $2j + 1 \comma$且進入步驟 2 時必須有 $j \leq m - 1 \comma$故演算法必定在有限步內結束。至此演算法 A 的正確性得$\textrm{證。}\mkern6mu\blacksquare$

時間複雜度 對非負整數 $t \comma$設 $j^{(t)}$ 為第 $t + 1$ 次進入步驟 2 時 $j$ 的值。若執行一次步驟 2―4 後有跳回步驟 2,則 $j$ 至少會變為 $2j + 1 \comma$即 $j^{(t)} \geq 2j^{(t-1)}+1 \period$若步驟 2 共執行 $T$ 次,則對任意 $0 \leq t \leq T-2$ 均有 \[\begin{split}\frac{j^{(t+1)}+1}{j^{(t)}+1} \geq \frac{(2j^{(t)} + 1)+1}{j^{(t)}+1} = 2 \period\end{split}\] 若 $T \geq 2 \comma$則由 $j^{(0)} = i$ 與 $j^{(T-1)} \leq m - 1$ 可知 \[\begin{split}T &\leq 1 + \biggl\lfloor\log_2\biggl(\frac{m}{i+1}\biggr)\biggr\rfloor \\[2ex] &\leq 1 + L\.(m) - L\.(i + 1)\period\end{split}\] 故演算法 A 的時間複雜度為 $O(1 + L(m) - L(i \varplus 1)) \period\;\blacksquare$

2.2 建造堆積

演算法 B(建造堆積) 給定陣列 $A[0 \twodots n \varminus 1]$,演算法 A 會將其重排為一最大堆積。

  1. $i \gets \lfloor n/2 \rfloor - 1 \comma$並跳至步驟 4。
  2. 整個陣列 $A[0 \twodots n \varminus 1]$ 與索引值 $i$ 執行演算法 A(向後插入)。
  3. $i \gets i - 1 \period$
  4. $i \geq 0 \comma$則回到步驟 2;否則終止演算法。

正確性 首次進入步驟 2 時,$\sbrack{i \varplus 1 \twodots m \varminus 1}$ 中的索引值均滿足最大堆積性質(因為此時 $i = \lfloor n/2 \rfloor - 1$ 且 $2(i \varplus 1) + 1 \geq n \rparen \mkern-9mu \period$

若每次進入步驟 2 時,$\sbrack{i \varplus 1 \twodots m \varminus 1}$ 中的索引值均滿足最大堆積性質,則由演算法 A 的正確性可知離開步驟 2 時,$\sbrack{i \twodots m \varminus 1}$ 中的索引值滿足最大堆積性質。

由此可知演算法結束時,$\sbrack{0 \twodots m \varminus 1}$ 中的索引值均滿足最大堆積性質,即正確性得$\textrm{證。}\mkern6mu\blacksquare$

時間複雜度 可知步驟 2 以外花費的總時間為 $O(1 + n) \comma$以下我們考慮步驟 2 花費的總時間。設存在一常數 $C \comma$使每次執行步驟 2 時,花費時間不超過 \[\begin{split}C(1 + L(n) - L(i \varplus 1)) \comma\end{split}\] 其中 $i \in \{\lfloor n/2 \rfloor \varminus 1 \cm \ldots \cm 1 \cm 0\} \period$對任意非負整數 $k \comma$若 \[\begin{split}2^k \varminus 1 \leq i < 2^{k+1} \varminus 1 \comma\end{split}\] 則我們有 $L(i \varplus 1) = k + 1 \period$因此步驟 2 花費的總時間不會超過 \[\begin{split} T &= C \cdot \sum_{i=0}^{\lfloor n/2 \rfloor - 1} (1 + L(n) - L(i \varplus 1)) \\ &\leq C \cdot \sum_{k=0}^{L(n)-1} \sum_{i=2^k-1}^{2^{k+1}-2} (1 + L(n) - L(i \varplus 1)) \\ &= C \cdot \sum_{k=0}^{L(n)-1} 2^k (1 + L(n) - (k \varplus 1)) \\ &= C \cdot \sum_{k=0}^{L(n)-1} 2^k (L(n) - k) \\ &= C \cdot \sum_{h=1}^{L(n)} 2^{L(n) - h}h \\ &\leq C \cdot 2^{L(n)} \sum_{h \geq 1} \frac{h}{2^h} \\ &\leq C \cdot 2n \cdot 2 \comma \end{split}\]

故時間複雜度為 $O(n) \period\;\blacksquare$

2.3 拆除堆積

演算法 C(拆除堆積) 給定最大堆積 $A[0 \twodots n \varminus 1]$,演算法 C 會將其重排為由小到大排列。

  1. $m \gets n - 1 \comma$並跳至步驟 5。
  2. 換 $A[0] \leftrightarrow A[m] \comma$並設 $i \gets 0 \period$
  3. 前綴 $A[0 \twodots m \varminus 1]$ 與索引值 $i$ 執行演算法 A(向後插入)。
  4. $m \gets m - 1 \period$
  5. $m \geq 1 \comma$則回到步驟 2;否則終止演算法。

正確性 首次進入步驟 2 時,$A\sbrack{0 \twodots m}$ 為最大堆積,故在交換 $A[0] \leftrightarrow A[m]$ 並進入步驟 3 後,$A[m]$ 即為 $A[0 \twodots m]$ 中的最大值,且在前綴 $A\sbrack{0 \twodots m \varminus 1}$ 中除了 0 以外的索引值均滿足最大堆積性質。

若進入步驟 3 時,前綴 $A\sbrack{0 \twodots m \varminus 1}$ 中除了 0 以外的索引值均滿足最大堆積性質,則由演算法 A 的正確性可知離開步驟 3 時,前綴 $A\sbrack{0 \twodots m \varminus 1}$ 會形成最大堆積。

由此可知演算法結束時,對任意 $i \in [0 \twodots n \varminus 1] \comma$$A[i]$ 均為 $A[0 \twodots i]$ 的最大值。故正確性得$\textrm{證。}\mkern6mu\blacksquare$

時間複雜度 步驟 3 以外花費的總時間為 $O(1 + n) \comma$且步驟 3 花費的總時間為 $O(nL\.(n)) \comma$故時間複雜度為 $O(1 + nL(n)) \period\;\blacksquare$

2.4 堆積排序

利用演算法 B(建造堆積)、演算法 C(拆除堆積)即可排序陣列。

演算法 D(堆積排序) 給定陣列 $A[0 \twodots n \varminus 1]$,演算法 D 會將其重排為由小到大排列。

  1. 陣列 $A[0 \twodots n \varminus 1]$ 執行演算法 B(建造堆積)。
  2. 陣列 $A[0 \twodots n \varminus 1]$ 執行演算法 C(拆除堆積)。

正確性 由演算法 B、C 的正確性得$\textrm{證。}\mkern6mu\blacksquare$

時間複雜度 步驟 1 花費時間為 $O(1 + n) \comma$且步驟 2 花費時間為 $O(1 + nL\.(n)) \comma$故時間複雜度為 $O(1 + nL\.(n)) \period\;\blacksquare$

3 程式碼實作

以下為 Python 的實作:

  1. 函式 sift_down 執行向後插入。
  2. 函式 build_heap 執行建造堆積。
  3. 函式 destroy_heap 執行拆除堆積。
  4. 函式 heapsort 執行堆積排序。
from collections.abc import Sequence

def sift_down(A: Sequence, m: int, i: int) -> None:
    ok = False
    j, k = i, i
    while not ok:
        if j * 2 + 1 < m and A[j * 2 + 1] > A[k]:
            k = j * 2 + 1
        if j * 2 + 2 < m and A[j * 2 + 2] > A[k]:
            k = j * 2 + 2
        if j != k:
            A[j], A[k] = A[k], A[j]
            j = k
        else:
            ok = True

def build_heap(A: Sequence) -> None:
    n = len(A)
    for i in range(n // 2 - 1, -1, -1):
        sift_down(A, n, i)

def destroy_heap(A: Sequence) -> None:
    n = len(A)
    for m in range(n - 1, 0, -1):
        A[0], A[m] = A[m], A[0]
        sift_down(A, m, 0)

def heapsort(A: Sequence) -> None:
    build_heap(A)
    destroy_heap(A)

參考資料

  1. Thomas H. Cormen, Charles E. Leiserson, Ronald L. Rivest, and Clifford Stein. Introduction to algorithms. MIT press, third edition, 2009.