編碼 · 演算法
合併排序法教學:原理、程式碼與時間複雜度
簡答
合併排序法是一種分而治之的排序演算法。它把串列分成兩半,遞迴地排序每一半,再藉由反覆取出兩邊最前面較小的元素,把兩個已排序的半邊合併成一個已排序的串列。它永遠以 O(n log n) 的時間執行,是穩定排序,合併時需要 O(n) 的額外記憶體。它是 Timsort(Python 的 sorted() 背後的演算法)的基礎,也是資料量大到記憶體放不下時,外部排序的基礎。
用白話說明原理
含零個或一個元素的串列本來就是已排序的。對較長的串列:
- 分割:把串列分成左半與右半。
- 征服:用合併排序分別排序每一半。
- 合併:合併兩個已排序的半邊。比較兩邊最前面的元素,把較小的移到輸出,重複直到其中一邊空了;然後把另一邊剩下的複製過去。
所有真正的工作都發生在合併步驟,合併兩個總長度為 n 的已排序串列,最多只需要 n − 1 次比較。
逐步範例
將 [38, 27, 43, 3, 9, 82, 10] 排序。
| 層級 | 子串列 |
|---|---|
| 分割 1 | [38, 27, 43] [3, 9, 82, 10] |
| 分割 2 | [38] [27, 43] [3, 9] [82, 10] |
| 分割 3 | [38] [27] [43] [3] [9] [82] [10] |
| 合併 1 | [38] [27, 43] [3, 9] [10, 82] |
| 合併 2 | [27, 38, 43] [3, 9, 10, 82] |
| 合併 3 | [3, 9, 10, 27, 38, 43, 82] |
最後一次合併比較兩邊的最前端:27 與 3 取 3,27 與 9 取 9,27 與 10 取 10,27 與 82 取 27,38 與 82 取 38,43 與 82 取 43,最後再複製 82。七個元素共六次比較。
Python 實作
def merge(left, right):
out, i, j = [], 0, 0
while i < len(left) and j < len(right):
if left[i] <= right[j]: # <= takes from left on ties: stable
out.append(left[i]); i += 1
else:
out.append(right[j]); j += 1
out.extend(left[i:]) # one of these is empty
out.extend(right[j:])
return out
def merge_sort(items):
"""Return a new sorted list."""
if len(items) <= 1:
return list(items)
mid = len(items) // 2
return merge(merge_sort(items[:mid]), merge_sort(items[mid:]))
print(merge_sort([38, 27, 43, 3, 9, 82, 10])) # [3, 9, 10, 27, 38, 43, 82]
print(merge_sort([])) # []這個由上而下的版本很清楚,但會複製切片。由下而上的版本則完全避開遞迴:先合併寬度為 1 的段落,再合併寬度 2、4,依此類推。
def merge_sort_bottom_up(items):
a = list(items)
width = 1
while width < len(a):
for lo in range(0, len(a), 2 * width):
mid, hi = lo + width, lo + 2 * width
a[lo:hi] = merge(a[lo:mid], a[mid:hi])
width *= 2
return a由下而上的版本重複使用上面的 merge。標準函式庫也有 heapq.merge,可以惰性地合併任意數量的已排序可迭代物件。
時間與空間複雜度
| 情況 | 時間 | 原因 |
|---|---|---|
| 最佳 | O(n log n) | 不論順序如何,它都會分割再合併 |
| 平均 | O(n log n) | 約 log₂ n 層,每層合併工作量為 O(n) |
| 最差 | O(n log n) | 相同的遞迴;沒有糟糕的輸入 |
| 額外空間 | O(n) | 合併用的緩衝區,加上 O(log n) 的遞迴堆疊 |
遞迴式為 T(n) = 2T(n/2) + O(n)。遞迴樹的每一層總共做 O(n) 的工作,約有 log₂ n 層,所以總計是 O(n log n)。對一百萬筆資料來說,大約是 20 層。
合併排序與其他排序法的比較
| 演算法 | 最佳 | 平均 | 最差 | 額外空間 | 穩定 |
|---|---|---|---|---|---|
| 合併排序 | O(n log n) | O(n log n) | O(n log n) | O(n) | 是 |
| 快速排序 | 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(1) | 否 |
| 插入排序 | O(n) | O(n²) | O(n²) | O(1) | 是 |
| 泡沫排序 | 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(n) 的額外記憶體,而且在陣列上通常要搬動更多資料。與堆積排序相比,它是穩定的,並且依序存取記憶體,對快取與磁碟都很友善。
變體與常見錯誤
- 自然合併排序(natural merge sort)會偵測已經排序好的順串並加以合併,對已排序的輸入可達 O(n)。Timsort 是改良過的自然合併排序,對短的順串使用二分插入排序。
- 對小的子串列(例如少於約 10 到 30 個元素)改用插入排序,以降低開銷。
- 在鏈結串列上,合併排序不需要額外緩衝區:合併只需重新連接節點,因此它通常是排序鏈結串列的首選。
- 外部合併排序先排序能放進記憶體的區塊,寫入磁碟,再進行 k 路合併。資料庫與 GNU
sort之類的工具,都用這個概念處理大型檔案。 - 計算逆序對:合併時,每當從右半取出一個元素,它就比左半所有剩餘的元素都小。
何時使用合併排序
需要穩定排序且保證 O(n log n) 時間、排序鏈結串列,或資料大到記憶體放不下時,請選擇合併排序。在 Python 中,直接呼叫 sorted() 或 list.sort() 即可:它們實作的是 Timsort,也就是合併排序的變體,所以你已經擁有這些特性。親手寫合併排序是經典的面試題,常延伸到計算逆序對或合併 k 個已排序的串列。
常見問題
合併排序是穩定的嗎?
是的,只要在元素相等時合併是從左半取(<=)。這樣相等的元素就會維持原本的順序。
合併排序的時間複雜度是多少?
最佳、平均與最差情況都是 O(n log n)。串列約被對半分 log₂ n 次,每一層合併的成本是 O(n)。
為什麼合併排序需要額外空間?
要有效率地原地合併陣列的兩半很困難,所以一般實作會把合併結果寫進另一塊大小為 n 的緩衝區。原地的變體雖然存在,但很複雜,實務上也比較慢。
合併排序比快速排序快嗎?
對記憶體中的陣列通常不會:快速排序平均來說往往更快,因為搬動的資料較少,也不需要緩衝區。需要穩定性、保證最差情況、處理鏈結串列或外部排序時,合併排序才佔優勢。
Python 使用合併排序嗎?
Python 的 sorted() 與 list.sort() 使用 Timsort,是合併排序與插入排序的混合。從 Python 3.11 起,合併策略改以 powersort 為基礎。