National Yang Ming Chiao Tung University
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 69 25°C PM2.5 20
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 15

堆積與優先佇列

往上浮、往下沉、堆積排序與前 k 大
Interaction實作 30 minute(s)

Learning Objectives

  • 說明堆積的性質,以及完全二元樹怎麼用陣列存(父 (i - 1) / 2、子 2i + 1 與 2i + 2)
  • 追蹤放入時的往上浮、取出時的往下沉,說明兩者都是 O(log n)
  • 說明由下而上建堆為什麼只要 O(n)
  • 寫出堆積排序,說明它的時間與空間複雜度
  • 使用 Python heapq、C++ priority_queue、Java PriorityQueue,分辨它們預設是最小堆積還是最大堆積
  • 用大小為 k 的最小堆積找出前 k 大

每次都拿出最優先的那一個

急診室依病情輕重決定看診順序,不是依掛號先後;作業系統挑優先權最高的程式先執行;外送平台把最近的訂單先派出去。 這些情境有兩個共同點:資料不斷加入,而且每次都要拿出最優先的那一個。這種資料結構叫優先佇列(priority queue)。

用學過的工具做做看:

做法 放入 拿出最小值
沒排序的陣列 O(1)(接在最後) O(n)(要全部看一遍)
排好序的陣列 O(n)(要挪出位置) O(1)
二元搜尋樹(第 14 課,平衡時) O(log n) O(log n)
堆積(heap) O(log n) O(log n),只看不拿是 O(1)

二元搜尋樹也做得到,但它維持的是「全部排好」,比需要的多。堆積只維持「最小的在最上面」,要求比較少,所以可以用一個普通的陣列存,程式也短得多。

完全二元樹放進陣列

最小堆積(min-heap)是一棵二元樹,滿足兩個條件:

  1. 形狀:是完全二元樹(complete binary tree),每一層都填滿,只有最下層可以不滿,而且由左往右排,中間不留空位
  2. 順序:每個節點都小於等於它的子節點,所以根是全部裡最小的

最大堆積(max-heap)反過來,每個節點都大於等於子節點,根是最大的。

因為形狀是完全二元樹,可以一層一層、由左到右把節點放進陣列,不需要指標,用索引算出父子關係(索引從 0 開始):

要找 索引
節點 i 的父節點 (i - 1) / 2(整數除法)
節點 i 的左子節點 2i + 1
節點 i 的右子節點 2i + 2

例如陣列 [1, 3, 5, 9, 8, 6]:索引 1 的 3,子節點是索引 3 的 9 和索引 4 的 8;索引 5 的 6,父節點是 (5 - 1) / 2 = 2,也就是 5。

注意堆積不是排好序的陣列。它只保證父節點比子節點小,兄弟之間、不同分支之間沒有大小關係(上面的 9 在 8 前面)。 n 個節點的完全二元樹高度約 log₂n,接下來的往上浮、往下沉最多走一條從根到葉的路,所以都是 O(log n)。

放入:接在最後面,再往上浮

依序放入 5、8、3、9、1、6。新元素先接在陣列最後面,也就是樹最下層最右邊的空位,這樣形狀仍然是完全二元樹;接著和父節點比,比父節點小就交換,一路往上浮,直到父節點比它小或到了根。放入 1 時交換了 2 次,一路浮到根。

取出:最後一個搬到根,再往下沉

最小值永遠在根 a[0],看一眼是 O(1)。要把它拿走,就得補上根的空位,而且形狀要維持完全二元樹:

  1. 記下根的值(等一下要回傳)
  2. 把陣列最後一個元素搬到根,刪掉最後一格;形狀沒問題了,但新的根很可能太大
  3. 往下沉:和兩個子節點裡比較小的那個比,自己比較大就交換,一路往下,直到比兩個子節點都小或到了葉節點

第 3 步為什麼一定要選比較小的子節點?換上來的那個會變成另一個子節點的父節點,它必須比另一個子節點小,堆積的性質才成立。

取出最小值三次

從上一個範例建好的堆積 [1, 3, 5, 9, 8, 6] 連續取出三次。第一次取出 1 之後,6 被搬到根,和 3、5 裡比較小的 3 交換;再和 9、8 裡比較小的 8 比,6 比較小,停下來。取出的順序是 1、3、5,由小到大。

想一想:堆積的形狀與上下移動

1.

下列哪一個陣列是最小堆積?

2.

用 0 開始的索引,索引 10 的父節點是幾號?索引 4 的兩個子節點是幾號?

3.

在最小堆積 [1, 3, 5, 9, 8, 6] 放入 0,放完之後陣列是什麼?

第 1 次作答

一次建好整個堆積:由下而上

手上已經有一整個陣列,要把它變成堆積,最直接的方法是一個一個放入,每次 O(log n),總共 O(n log n)。 有更快的做法:由下而上建堆(bottom-up heapify),1964 年 Floyd 在發表的堆積排序程式(Treesort 3)裡用的就是這個方法。

  1. 葉節點自己就是一個合格的堆積,不用處理。索引 n / 2 之後全部是葉節點
  2. 從最後一個有子節點的位置 n / 2 - 1 開始,往前到 0,每個位置做一次往下沉
  3. 處理到位置 k 時,它底下的兩棵子樹都已經是堆積,往下沉之後以 k 為根的子樹也變成堆積

為什麼只要 O(n)?往下沉的距離最多是節點到葉的高度:大約一半的節點是葉子,沉 0 層;四分之一在倒數第二層,最多沉 1 層; 八分之一最多沉 2 層……越往上節點越少。把每層「節點數 × 最多沉幾層」加起來,總和小於 n。 一個一個放入則相反:多數節點在下層,往上浮的距離最長,所以比較慢。

由下而上建堆,數交換次數

9 個元素,k 從 3 往前到 0。k = 3 的 1 已經比子節點小,不用動;k = 0 的 9 一路沉到最下層,交換 3 次。總共只交換 6 次,比 9 個元素的 n log n 少很多。

堆積排序

堆積排序(heapsort)由 J. W. J. Williams 在 1964 年提出,同一篇文章也提出了二元堆積這個資料結構。做法分兩個階段:

  1. 把陣列建成最大堆積(由下而上,O(n)),最大值在 a[0]
  2. 重複 n - 1 次:把 a[0] 和堆積的最後一格 a[end] 交換,最大值就到了它該在的位置;堆積少一格,再讓新的根往下沉(O(log n))
項目 堆積排序
時間 最好、最壞都是 O(n log n)
額外空間 O(1),在原陣列裡排
穩定 不穩定(交換會跨越很遠的距離,相同的值可能換了先後)

和第 07 課的合併排序比,堆積排序不需要額外陣列;和第 08 課的快速排序比,它沒有 O(n²) 的最壞情況。 實際上快速排序通常比較快(存取陣列的位置比較集中),所以 C++ 的 sort 常見的實作是以快速排序為主,遞迴太深時才改用堆積排序當保險。

堆積排序:最大值一個一個換到後面

前半段是建最大堆積,9 浮到 a[0]。後半段每一輪把 a[0] 換到 end 的位置,右邊排好的區域就多一格,再讓換到根的小值往下沉。注意這裡的 sift_down 是最大堆積:和比較大的子節點比。

實驗:放入、取出、一次建堆

可以切換最小堆積或最大堆積。每一次比較會標出正在比的兩個節點,交換會一步一步播放,並數交換次數;樹和陣列畫的是同一份資料,可以對照索引。按「一次建堆」會把輸入框裡的數由下而上建成堆積。

三種語言的優先佇列

實際寫程式時不必自己寫堆積,三種語言都有內建的優先佇列,但預設方向不同,這是最容易出錯的地方:

語言 寫法 預設 放入/取出/看最前面
Python import heapq,用一般串列 最小堆積 heappush(h, x)/heappop(h)/h[0]
C++ priority_queue<int> 最大堆積 push(x)/pop()(不回傳值)/top()
C++ priority_queue<int, vector<int>, greater<int>> 最小堆積 同上
Java PriorityQueue<Integer> 最小堆積 add(x)/poll()/peek()

需要相反方向時:

import heapq

h = []
for x in [3, 1, 4]:
    heapq.heappush(h, -x)     # Python 沒有最大堆積:存負數,取出時再變號
print(-heapq.heappop(h))      # 4
priority_queue<int> maxq;                              // C++ 預設就是最大堆積
priority_queue<int, vector<int>, greater<int>> minq;   // 最小堆積要加 greater
PriorityQueue<Integer> maxq = new PriorityQueue<>(Comparator.reverseOrder());   // Java 最大堆積

依多個欄位決定優先順序時,Python 放元組 (優先度, 名稱),依第一個欄位比、相同再比第二個;C++ 用 pair 或自訂比較;Java 傳 Comparator。

前 k 大是優先佇列最常見的用法:維持一個最多 k 個元素的最小堆積,每放入一個數,超過 k 個就把最小的丟掉。 最後留下的就是最大的 k 個,而根就是第 k 大。每次操作 O(log k),n 個數總共 O(n log k),比整個排序 O(n log n) 省,而且資料可以一邊讀一邊處理。

用內建優先佇列找前 3 名

三種語言分別用 heapq、priority_queue 加 greater<int>、PriorityQueue,都是最小堆積,輸出相同。堆積裡永遠只留目前最高的 3 個分數,根是其中最低的;放入 99 時,堆積超過 3 個,最小的 79 被丟掉。三種語言內部陣列的排列可能不同(取出時整理的細節不一樣),但取出的順序一定相同。

想一想:建堆、排序與內建工具

1.

由下而上建堆時,為什麼從索引 n / 2 - 1 開始往前,而不是從 n - 1 開始?

2.

以下 C++ 程式會印出什麼?

priority_queue<int> pq;
for (int x : {3, 1, 4, 1, 5}) pq.push(x);
cout << pq.top();
3.

從 n 筆資料找前 k 大,用「最多 k 個的最小堆積」,時間複雜度是多少?

4.

關於堆積排序,下列哪一個敘述正確?

第 1 次作答

實作:合併檔案的最小成本

有四個已經排好序的檔案,長度是 4、3、2、6。合併兩個長度 a、b 的檔案要花 a + b(第 07 課的合併)。每次都挑目前最短的兩個合併,總成本最小。請用最小堆積完成合併,印出每一次合併和總成本。預期輸出在右邊。

你的輸出
預期輸出

重點整理

  • 堆積是完全二元樹,最小堆積的每個節點都小於等於子節點,根是最小值;只保證父子關係,不是排好序的陣列
  • 用陣列存:父節點 (i - 1) / 2,子節點 2i + 1、2i + 2
  • 放入:接在最後,往上浮;取出:最後一個搬到根,和較小的子節點交換往下沉;都是 O(log n),看根是 O(1)
  • 由下而上建堆從 n / 2 - 1 往前往下沉,只要 O(n)
  • 堆積排序:建最大堆積,再把根一個一個換到後面;O(n log n)、額外空間 O(1)、不穩定
  • 內建優先佇列:Python heapq 與 Java PriorityQueue 預設最小堆積,C++ priority_queue 預設最大堆積
  • 前 k 大:最多 k 個的最小堆積,O(n log k)

下一課:圖的表示與 BFS。到了第 18 課的 Dijkstra 最短路徑,優先佇列會再派上用場:每次取出目前距離最近的節點。

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