Minghsin University of Science and Technology
Uedu Main Site
Explore Uedu
Student Console
Register as Member/Login
Research Informed Consent Center
Survey Center
Educational Big Data Challenge
Claim or create a course
Try a Scenario Task
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

1

Local AI

--

Uedu Code

--

明新科大 AQI 70 24°C PM2.5 19
AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

Unit 07

合併排序與分治

切一半、各自排好、再合併:O(n log n) 從哪裡來
Interaction實作 30 minute(s)

Learning Objectives

  • 用兩個指標把兩個已排序的串列合併成一個,說明最多比較幾次
  • 追蹤合併排序的遞迴:切成兩半、各自排好、再合併
  • 說明合併排序為什麼是 O(n log n):每一層合計搬 n 個元素,一共約 log₂n 層
  • 說明合併排序需要 O(n) 的額外空間,而且是穩定排序
  • 說出分治法的三個步驟,並用它寫出 O(log n) 的快速冪

兩疊已經排好的考卷

上一課的三種排序最壞都是 O(n²):一百萬筆資料要比較約五千億次。這一課換一個想法。

假設兩位助教各自把一半的考卷依分數排好了,你要把兩疊合成一疊。你不需要重新排序,只要每次看兩疊最上面那一張,拿比較小的放到新的一疊,直到兩疊都拿完。 每拿一張只比一次,n 張考卷最多比 n - 1 次就合完了。

這就是合併(merge)。把它和遞迴結合起來:

  1. 陣列太大?切成兩半
  2. 兩半各自用同樣的方法排好(遞迴)
  3. 把兩個排好的半邊合併

這就是合併排序(merge sort),通常歸功於 John von Neumann 在 1945 年寫下的程式。它最壞情況也只要 O(n log n)。

合併兩個已排序的串列:兩個指標往前走

每一輪只比較 a[i] 和 b[j] 兩個元素,把小的放進 res,那一邊的指標往前一格。a 先用完之後,b 剩下的 52 已經排好,而且一定比 res 裡的都大,直接接在後面,不用再比。9 個元素只比較了 8 次。

合併要比較幾次

兩邊合計 n 個元素時:

  • 每比較一次,就有一個元素放進結果,所以比較次數最多 n - 1 次(最後一個元素不用比)
  • 如果一邊很早就用完(例如左邊全部都比右邊小),比較次數會更少,最少是比較短的那一邊的長度
  • 每個元素都要放進結果一次,所以合併的工作量是 O(n)

相等時先拿左邊(a[i] <= b[j] 用 <=,不是 <):這樣原本在前面的元素合併後仍在前面,合併排序就是穩定的(穩定排序見第 06 課)。

合併排序:切兩半、各自排好、再合併

合併排序的函式 merge_sort(a, lo, hi) 負責排好 a[lo..hi)(含 lo、不含 hi,和第 04 課的半開區間相同):

def merge_sort(a, lo, hi):
    if hi - lo <= 1:          # 終止條件:0 或 1 個元素,本來就排好了
        return
    mid = (lo + hi) // 2
    merge_sort(a, lo, mid)    # 排好左半邊
    merge_sort(a, mid, hi)    # 排好右半邊
    merge(a, lo, mid, hi)     # 合併

merge 和上一個範例的做法相同,只是兩個半邊都在同一個陣列裡:左半邊是 a[lo..mid)、右半邊是 a[mid..hi)。 範例把三個 while 迴圈寫成一個:「右邊已經用完,或左邊還有而且比較小」就拿左邊,否則拿右邊。結果先放進暫存陣列 tmp,最後再抄回 a。

合併排序:遞迴樹與每一次合併

遞迴樹先一路往左切到只剩一個元素,回來時才開始合併:[38] 和 [27] 合成 [27, 38],[43] 和 [3] 合成 [3, 43],再把這兩段合成 [3, 27, 38, 43]……每一次合併都從兩個已經排好的半邊開始。8 個元素切了 3 層,樹的高度就是 log₂8 = 3。

為什麼是 O(n log n)

把遞迴樹一層一層看:

層 這一層有幾段 每段長度 這一層合併時一共搬幾個元素
0 1 n n
1 2 n/2 n
2 4 n/4 n
… … … n
最底層 n 1 不用合併

每往下一層,段數變成 2 倍、長度變成一半,所以每一層合計都是 n 個元素。長度從 n 一路對半到 1,要切 log₂n 次,一共 log₂n 層要合併。 總工作量就是 n × log₂n,也就是 O(n log n)。而且不管資料原本長什麼樣子,切法都一樣,所以最好、最壞、平均都是 O(n log n)。

下面的範例不排序,只依合併排序的切法走一遍,把每一層合併時要搬的元素個數加起來。

數一數每一層的工作量

16 個元素切成 4 層要合併(長度 16、8、4、2),第 5 層的 16 個單獨元素不用合併。看 work 的四格:最後每一格都是 16,總共 16 × 4 = 64。如果 n 是 1,000,000,大約是 20 層 × 一百萬 = 兩千萬,和 O(n²) 的五千億次差了兩萬多倍。

合併排序的代價:額外的空間

合併時要把結果先放進暫存陣列 tmp,再抄回原陣列,所以合併排序需要 O(n) 的額外空間(另外遞迴本身要 O(log n) 層的堆疊)。 這和第 06 課三種原地排序只用 O(1) 額外空間不同。資料很大、記憶體很緊的時候,這是要考慮的代價。

選擇/插入/氣泡 合併排序
最壞時間 O(n²) O(n log n)
最好時間 O(n²)/O(n)/O(n) O(n log n)
額外空間 O(1) O(n)
穩定 選擇不穩定,插入與氣泡穩定 穩定

合併排序的穩定性和「不怕資料長怎樣」,讓它被許多語言的內建排序採用或改良:Python 的 sorted()、Java 對物件陣列的 Arrays.sort 都使用以合併排序為基礎的 Timsort。

下面的排序賽跑加入了合併排序。資料量越大,O(n log n) 和 O(n²) 的差距越明顯;但在已經排好的資料上,插入排序反而比較快。

排序賽跑:合併排序對上 O(n²) 的排序

每一步是一次比較或一次交換(插入排序是移動、合併排序是寫回原陣列)。所有排序同時各走一步,操作越少越早完成。跑完之後看每個面板下方的次數,完成下面的任務。

想一想:合併與合併排序

1.

用本課的方法合併 [1, 3, 5] 和 [2, 4, 6],下面的程式會印出什麼?

a, b = [1, 3, 5], [2, 4, 6]
i = j = comparisons = 0
while i < len(a) and j < len(b):
    comparisons += 1
    if a[i] <= b[j]:
        i += 1
    else:
        j += 1
print(comparisons)
2.

用合併排序排 1,024 個元素,遞迴樹有幾層要合併?總共大約搬幾次元素?

3.

把 merge 裡的 a[i] <= a[j] 改成 a[i] < a[j],排序結果會怎樣?

第 1 次作答

分治法:同一招解別的問題

合併排序是分治法(divide and conquer)的代表。分治法的三個步驟:

  1. 分(divide):把問題切成幾個規模較小、形式相同的子問題
  2. 治(conquer):用遞迴解決子問題;子問題夠小時直接回答
  3. 合(combine):把子問題的答案組合成原問題的答案

第 05 課的遞迴版二分搜尋也是分治法的一種:切一半之後只需要處理其中一半,而且不用「合」。

快速冪:要算 xⁿ,直接連乘要做 n - 1 次乘法。分治的想法是:

  • n 是偶數:xⁿ = (x^(n/2))²
  • n 是奇數:xⁿ = (x^(n/2))² × x(這裡 n/2 是整數除法)
  • 終止條件:x⁰ = 1

x^(n/2) 只算一次,存在變數 half 裡再自己乘自己。每一層 n 變成一半,所以只要約 log₂n 層,每層一到兩次乘法,是 O(log n)。 範例用 long long/long 存結果,避免 C++、Java 的 int 溢位(3¹³ 和 2³⁰ 其實還在 int 範圍內,但次方稍大就會超過)。

快速冪:3 的 13 次方只要 5 次呼叫

遞迴樹是一條直線:13 → 6 → 3 → 1 → 0,每一層 n 減半。回來的路上,每一層把 half 自己乘自己,n 是奇數時再多乘一個 3。2³⁰ 也只要 6 次呼叫(30 → 15 → 7 → 3 → 1 → 0),連乘則要 29 次乘法。

想一想:快速冪的呼叫次數

1.

下面的程式數一數 power(2, 16) 呼叫了幾次 power,會印出什麼?

calls = 0


def power(x, n):
    global calls
    calls += 1
    if n == 0:
        return 1
    half = power(x, n // 2)
    if n % 2 == 0:
        return half * half
    return half * half * x


power(2, 16)
print(calls)
2.

如果把 half * half 寫成 power(x, n // 2) * power(x, n // 2)(不用變數存起來),算 2¹⁶ 會怎樣?

第 1 次作答

實作:用分治法找最大值

請用分治法完成 max_of(a, lo, hi)(C++/Java 叫 maxOf):回傳閉區間 a[lo..hi] 裡的最大值。只有一個元素時直接回傳;否則從中間切開,分別找出左右兩半的最大值,回傳比較大的那一個。

你的輸出
預期輸出

重點整理

  • 合併兩個已排序的串列:兩個指標各指一邊,每次取比較小的;n 個元素最多比較 n - 1 次,O(n)
  • 合併排序:切成兩半、各自遞迴排好、再合併;終止條件是只剩 0 或 1 個元素
  • 每一層合計搬 n 個元素,一共 log₂n 層,所以最好、最壞、平均都是 O(n log n)
  • 合併需要暫存陣列,額外空間 O(n);相等時先拿左邊,所以是穩定排序
  • 分治法三步驟:分、治、合。快速冪把 xⁿ 變成 (x^(n/2))²,子問題只算一次,O(log n)

下一課:快速排序。它也是分治法,但把功夫花在「切」的時候:選一個基準,把小的放左邊、大的放右邊,合併這一步就省掉了。

這一課完成了!
Next lesson:快速排序
你目前沒有登入,學習進度只存在這個瀏覽器。 Login 後,進度會跟著帳號,換電腦也看得到。