cv.cm

編碼 · 演算法

堆積排序法教學:原理、程式碼與時間複雜度

閱讀約 7 分鐘

簡答

堆積排序法是一種比較式排序:先把陣列排成最大堆積(max-heap,即存放在陣列中的二元樹,其中每個父節點都不小於它的子節點),然後反覆把最大的元素(根節點)交換到陣列尾端,將堆積縮小一格,並藉由讓新的根節點下沉來恢復堆積性質。它在任何情況下都是 O(n log n) 時間,並以 O(1) 額外空間原地排序,但不是穩定排序,實務上通常比快速排序慢。

開啟行排序

用白話說明原理

堆積排序就是用更快的方法找最大值的選擇排序。選擇排序每次都掃描整個未排序的部分,成本是 O(n)。堆積排序把未排序的部分維持成堆積,所以最大值永遠在索引 0,移除它之後恢復順序只需 O(log n)。

二元堆積可以不用指標,直接存放在普通陣列中。對索引 i 的元素:

  • 左子節點在 2*i + 1,右子節點在 2*i + 2;
  • 父節點在 (i - 1) // 2;
  • 在最大堆積中,每個父節點都大於或等於它的子節點,所以最大的元素在索引 0。
  1. 從陣列建立最大堆積:由最後一個非葉節點開始,讓每個非葉節點下沉,一路處理到索引 0。
  2. 把根節點(最大值)與堆積的最後一個元素交換。這個元素現在已在最終位置。
  3. 將堆積縮小一格,並讓新的根節點下沉,直到堆積性質再度成立。
  4. 重複步驟 2 和 3,直到堆積只剩一個元素。

逐步範例

將 [4, 10, 3, 5, 1] 排序。直線之後的元素已排序並為最終位置。

步驟動作陣列
開始[4, 10, 3, 5, 1]
建堆 1讓索引 1(10)下沉:比子節點 5 和 1 都大,不變[4, 10, 3, 5, 1]
建堆 2讓索引 0(4)下沉:與 10 交換,再與 5 交換[10, 5, 3, 4, 1] 最大堆積
取出 1把 10 交換到尾端,讓 1 下沉:與 5 交換,再與 4 交換[5, 4, 3, 1 | 10]
取出 2把 5 交換到尾端,讓 1 下沉:與 4 交換[4, 1, 3 | 5, 10]
取出 3把 4 交換到尾端,讓 3 下沉:沒有更大的子節點[3, 1 | 4, 5, 10]
取出 4把 3 交換到尾端[1 | 3, 4, 5, 10] 已排序

Python 實作

def sift_down(a, root, end):
    """Restore the max-heap property for a[root..end-1]."""
    while True:
        child = 2 * root + 1
        if child >= end:
            return
        if child + 1 < end and a[child + 1] > a[child]:
            child += 1                      # pick the larger child
        if a[root] >= a[child]:
            return
        a[root], a[child] = a[child], a[root]
        root = child

def heapsort(a):
    """Sort list a in place, ascending."""
    n = len(a)
    for i in range(n // 2 - 1, -1, -1):     # build the max-heap
        sift_down(a, i, n)
    for end in range(n - 1, 0, -1):
        a[0], a[end] = a[end], a[0]         # move the max to the end
        sift_down(a, 0, end)
    return a

print(heapsort([4, 10, 3, 5, 1]))   # [1, 3, 4, 5, 10]
print(heapsort([2, 2, 1]))          # [1, 2, 2]

Python 的 heapq 模組在普通串列上提供最小堆積。把每個元素依序彈出,就得到已排序的串列,這是一個簡短(但非原地)的堆積排序:

import heapq

def heapsort_heapq(items):
    h = list(items)
    heapq.heapify(h)                       # O(n)
    return [heapq.heappop(h) for _ in range(len(h))]

print(heapsort_heapq([4, 10, 3, 5, 1]))     # [1, 3, 4, 5, 10]
print(heapq.nlargest(2, [4, 10, 3, 5, 1]))  # [10, 5]

時間與空間複雜度

情況時間原因
建堆O(n)大多數節點靠近底部,只需下沉很短的距離
最佳O(n log n)n − 1 次取出,每次下沉最多 log₂ n 層(鍵值互異時)
平均O(n log n)不論順序,工作量都相同
最差O(n log n)樹的高度永遠約為 log₂ n
額外空間O(1)堆積就存在輸入陣列內部,下沉用迴圈實作

由下而上建堆是 O(n),不是 O(n log n),因為完全二元樹中所有節點高度的總和是 O(n)。整體時間由取出階段主導。

堆積排序與其他排序法的比較

演算法最佳平均最差額外空間穩定
堆積排序O(n log n)O(n log n)O(n log n)O(1)否
快速排序O(n log n)O(n log n)O(n²)O(log n)否
合併排序O(n log n)O(n log n)O(n log n)O(n)是
選擇排序O(n²)O(n²)O(n²)O(1)否
插入排序O(n)O(n²)O(n²)O(1)是
Timsort(Python 的 sorted)O(n)O(n log n)O(n log n)O(n)是

堆積排序不是穩定的:把根節點交換到尾端,可能讓元素越過其他鍵值相同的元素。它的主要優點,是同時擁有保證的 O(n log n) 最差情況與 O(1) 額外空間,這是快速排序和合併排序都做不到的組合。它的弱點在記憶體存取:在索引 i 與 2*i + 1 之間跳來跳去,對 CPU 快取很不友善,所以通常比實作良好的快速排序慢。內省排序(introsort)在快速排序遞迴太深時,就以堆積排序作為後備。

變體與常見錯誤

  • 部分排序:若只要最大的 k 個元素,建立堆積後取出 k 次,成本為 O(n + k log n)。Python 的 heapq.nlargest 與 heapq.nsmallest 就是這樣做的。
  • 從資料串流取前 k 名:維持一個大小為 k 的最小堆積,對每個新元素做推入或取代,時間 O(n log k)、記憶體 O(k)。
  • 由下而上的堆積排序先把空位一路下沉到葉節點,再往上調整,可以省下比較次數。
  • 平滑排序(smoothsort)是 Dijkstra 提出的堆積變體,對已排序的輸入可達 O(n),但因為很複雜,很少使用。

何時使用堆積排序

需要保證 O(n log n) 的最差情況且不能使用額外記憶體時,例如嵌入式或即時系統,或作為其他排序法內部的安全後備,就適合使用堆積排序。在日常的 Python 程式碼中,請使用 sorted(),它是穩定的,而且在典型資料上更快。堆積這個資料結構本身比排序更有用:優先佇列、Dijkstra 最短路徑、排程器、合併 k 個已排序串列(heapq.merge)以及找出最大的 k 個值,全都建立在它之上。面試常問堆積的索引公式、為何建堆是 O(n),以及前 k 名問題。

常見問題

堆積排序是穩定的嗎?

不是。把根節點移到尾端並讓元素下沉,可能改變相等鍵值的相對順序。

堆積排序的時間複雜度是多少?

最佳、平均與最差情況都是 O(n log n)。建堆是 O(n),n − 1 次取出的每一次成本為 O(log n)。

堆積排序是原地排序嗎?

是的。堆積存放在被排序的陣列內部,已排序的部分從尾端往前增長,所以只需要 O(1) 的額外空間。

為什麼堆積排序在實務上比快速排序慢?

兩者平均都是 O(n log n),但堆積排序會在相距很遠的陣列位置之間跳動,造成更多快取未命中,而且每個元素的比較次數也比快速排序多。

在 Python 中如何用 heapq 排序?

呼叫 heapq.heapify(list),然後反覆呼叫 heapq.heappop,或用 heapq.nsmallest(k, items) 取得最小的 k 個。若要完整排序,sorted() 更簡單也更快。

全部編碼教學