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 61 24°C PM2.5 6
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 19

最小生成樹與 Union-Find

Kruskal 演算法、合併與路徑壓縮
Interaction實作 30 minute(s)

Learning Objectives

  • 說明生成樹與最小生成樹的定義,以及 n 個節點的生成樹剛好有 n - 1 條邊
  • 用 parent 陣列實作 Union-Find 的 find 與 union,判斷兩個元素是否已經在同一組
  • 說明路徑壓縮與依秩合併為什麼能讓樹保持很矮
  • 追蹤 Kruskal 演算法:邊依權重排序,用 Union-Find 跳過會形成環的邊
  • 說明 Prim 演算法從一個節點往外長的做法,並比較它和 Kruskal 的差別

用最少的線把每棟大樓連起來

學校要在五棟大樓之間鋪網路線。不是每兩棟之間都能拉線,能拉的路線各有不同的施工成本(萬元):

路線 A-B A-C B-C B-D C-D D-E C-E
成本 4 3 1 2 2 5 6

要求是每兩棟大樓之間都要能連通(可以經過別棟轉接),而且總成本越低越好。

把大樓看成節點、路線看成有權重的邊,這就是一張圖(第 16 課)。符合要求的選法有兩個特徵:

  • 連通:每個節點都接上了
  • 沒有環:如果選出來的線繞成一圈,拿掉圈上任何一條,大家仍然連通,成本卻更低

連通又沒有環的子圖叫做生成樹(spanning tree);所有生成樹裡總權重最小的,叫做最小生成樹(minimum spanning tree,MST)。 n 個節點的生成樹一定剛好有 n - 1 條邊:少一條就有節點接不上,多一條就一定會出現環。

這一課介紹兩個找最小生成樹的方法:Kruskal 演算法與 Prim 演算法。Kruskal 需要一個小工具,隨時回答「這兩個節點是不是已經連通了」,所以先從這個工具開始。

Union-Find:記錄誰和誰同一組

Union-Find(又叫併查集、disjoint set)管理「分組」:一開始每個元素自成一組,之後可以把兩組合併,也可以問兩個元素是不是同一組。

它把每一組存成一棵樹,只用一個陣列 parent:parent[x] 是 x 在樹上的上一層。根的 parent 是自己,根就是這一組的代表。

操作 做法
find(x) 從 x 沿著 parent 一直往上走,走到根,回傳根
union(a, b) 找出 a、b 各自的根;不同的話,把其中一個根的 parent 改成另一個根
a、b 同一組嗎? find(a) == find(b)

注意 union 不是把 b 掛到 a 底下,而是把兩組的根接起來;如果只改 parent[b],b 原本那一組的其他成員就會被留在原地。

Union-Find 的 find 與 union

上方把 parent 陣列畫成森林:每一棵樹是一組,根在最上面。一開始 6 棵樹各只有一個節點。看最後一次 union(1, 3):它先找出 1 的根 0、3 的根 2,再把 2 掛到 0 底下,兩組合成一組。在 find 裡面時,框起來的節點是 x 目前走到的位置。

讓樹保持很矮:路徑壓縮與依秩合併

find 的成本等於從 x 走到根的層數。如果合併的順序不好,樹可能長成一條長鏈,例如 5 → 4 → 3 → 2 → 1 → 0, 每次找 5 的根都要走 5 層,最壞情況是 O(n),和線性搜尋一樣慢。有兩個常用的改良:

路徑壓縮(path compression):find 找到根之後,順路把經過的每個節點直接掛到根上。下次再找它們,一步就到。

def find(parent, x):
    if parent[x] != x:
        parent[x] = find(parent, parent[x])   # 遞迴找到根,回來時把 parent[x] 改成根
    return parent[x]

依秩合併(union by rank):再多記一個陣列 rank,代表每棵樹高度的上限。合併時把矮的樹掛到高的樹底下, 這樣樹只有在兩棵一樣高時才會長高一層。

兩個一起用,每次操作的平均成本會小到幾乎是常數。Tarjan 在 1975 年證明了這個結果:m 次操作總共是 O(m·α(n)), α 是成長極慢的函數,在任何實際的資料量下都不超過 4。下面的範例先看路徑壓縮,依秩合併放在後面的 Kruskal 範例裡。

路徑壓縮:找一次,整條路都變短

一開始是一條 6 層的長鏈。第一次 find(5) 一路遞迴到根 0,回來的路上把 5、4、3、2 都直接掛到 0 底下,森林從一條長鏈變成一個根底下掛著五個子節點。第二次 find(5) 只呼叫了兩層。

想一想:Union-Find

1.

下面的程式會印出什麼?

parent = [0, 0, 1, 1, 3, 5]

def find(x):
    while parent[x] != x:
        x = parent[x]
    return x

print(find(4), find(5))
2.

同樣的 parent = [0, 0, 1, 1, 3, 5],用有路徑壓縮的遞迴 find(4) 之後,parent 變成什麼?

3.

依秩合併「把矮的樹掛到高的樹底下」,主要的好處是什麼?

第 1 次作答

Kruskal 演算法:從最便宜的邊開始挑

Kruskal 在 1956 年發表了這個方法,想法非常直接:

  1. 把所有邊依權重由小到大排序
  2. 依序看每一條邊 (u, v):如果 u 和 v 還不連通,就選這條邊,並把兩邊合併;如果已經連通,選了會形成環,跳過
  3. 選滿 n - 1 條邊就完成

「u 和 v 是否已經連通」正是 Union-Find 擅長的問題:find(u) == find(v) 就跳過,否則 union(u, v)。

為什麼每次挑最便宜、又不會成環的邊,最後的總和就是最小?直覺是:任何時候把節點分成兩群,橫跨兩群的邊裡最便宜的那一條, 一定可以放進某一棵最小生成樹(如果某棵最小生成樹沒用它,把它換進去、拿掉另一條橫跨兩群的邊,總成本不會變大)。 Kruskal 每次選的邊,都是它兩端所在的兩群之間最便宜的邊。下一課會再談這種「每一步選當下最好的」想法。

同樣權重時的順序:這張圖有兩條權重 2 的邊(B-D、C-D)。排序時權重相同再依 u、v 比較,三種語言才會選出同一棵樹。 Python 的元組 (權重, u, v) 排序時本來就依序比較每一個欄位;C++ 和 Java 要在比較函式裡寫清楚。

時間複雜度:排序 E 條邊是 O(E log E),之後每條邊兩次 find,幾乎是常數,所以整體是 O(E log E)。

Kruskal:排序、挑邊、跳過會成環的邊

圖的面板用粗線標出已經選的邊,另一個面板是 Union-Find 的森林。B-C、B-D 選進來之後,B、C、D 已經在同一棵樹裡,所以 C-D 被跳過;A-B 也一樣。選滿 4 條邊(5 個節點)就停,總成本 11,最後的 C-E 不必再看。

Prim 演算法:從一個節點往外長

另一個常用的方法是 Prim 演算法。Prim 在 1957 年發表,處理的正是電話網路的連線成本; 後來大家發現捷克數學家 Jarník 在 1930 年就提出過同樣的方法,所以也叫 Jarník–Prim 演算法。

Kruskal 是在整張圖裡到處挑便宜的邊,同時長出好幾棵小樹,最後才接成一棵。Prim 則從一個起點開始,只維護一棵樹:

  1. 起點的成本設成 0,其他節點設成 ∞
  2. 從還沒進樹的節點裡,挑「接到樹上最便宜」的節點 u,把它加進樹
  3. 看 u 的每個鄰居 v:如果邊 u-v 比 v 目前記錄的成本便宜,就更新成這條邊
  4. 重複 2、3,直到所有節點都進樹

這個步驟和第 18 課的 Dijkstra 幾乎一樣,差別只在第 3 步比的東西:Dijkstra 比的是「從起點到 v 的總距離」,Prim 比的是「單一條邊 u-v 的權重」。 下面的範例每一輪掃過所有節點找最小值,是 O(n²) 的寫法,適合邊很多的圖;邊比較少時,可以像 Dijkstra 一樣用優先佇列,變成 O(E log n)。

Prim:一次加入一個最便宜的節點

節點上方的數字是 dist:這個節點目前接到樹上最便宜的成本,還接不到的顯示 ∞。程式裡節點用編號 0~4 代表 A~E。從 A 開始,依序加入 C、B、D、E。注意它選的是 C-D 而不是 Kruskal 選的 B-D:兩條都是 2,所以兩棵樹的總成本一樣是 11。

Kruskal 和 Prim 怎麼選

Kruskal Prim
一次處理 一條邊(全圖最便宜、不成環的) 一個節點(最便宜接到樹上的)
需要的工具 排序+Union-Find 陣列掃描或優先佇列
時間複雜度 O(E log E) O(n²);用優先佇列是 O(E log n)
適合 邊比較少的圖、邊本來就存成一張清單 邊很多的圖、用鄰接矩陣存的圖

兩個演算法都保證得到最小的總成本。範例圖的兩條權重 2 的邊讓兩者選出不同的樹,這說明最小生成樹不一定只有一棵; 所有邊的權重都不相同時,最小生成樹才保證唯一。

最小生成樹也常用在分群:先建出最小生成樹,再拿掉最貴的 k - 1 條邊,剩下的 k 塊就是一種分成 k 群的方法,群和群之間的距離盡量大。

互動練習:Kruskal 與 Prim

用本課的五棟大樓圖,按「下一步」一步一步看兩種演算法怎麼挑邊:Kruskal 從整張圖最便宜的邊開始,會形成環的邊跳過;Prim 從一個節點往外長,每次加入最便宜的那條連出去的邊。點邊上的數字可以改權重。

想一想:最小生成樹

1.

一張連通的圖有 8 個節點、15 條邊,它的最小生成樹有幾條邊?

2.

Kruskal 看到一條邊 (u, v),發現 find(u) == find(v),為什麼要跳過它?

3.

範例圖上 Kruskal 選了 B-D,Prim 選了 C-D。哪個說法正確?

4.

Kruskal 演算法的時間主要花在哪裡?

第 1 次作答

實作:7 個節點的最小生成樹

find 和 union 已經寫好了。請把邊排序,依序檢查每一條邊,能合併就選它,印出 A-D 5 這樣的一行,最後印出總成本。權重相同時依 u、v 排序,輸出才會和右邊的預期輸出一樣。

你的輸出
預期輸出

重點整理

  • 生成樹:連通、沒有環,n 個節點剛好 n - 1 條邊;最小生成樹是總權重最小的生成樹,可能不只一棵
  • Union-Find:parent 陣列把每一組存成一棵樹,find 找根,union 把兩個根接起來
  • 路徑壓縮讓 find 經過的節點直接掛到根上;依秩合併把矮的樹掛到高的樹底下;兩者一起用,每次操作幾乎是常數
  • Kruskal:邊依權重排序,用 Union-Find 跳過會成環的邊,O(E log E)
  • Prim:從一個節點往外長,每次加入接到樹上最便宜的節點,O(n²) 或用優先佇列 O(E log n)

下一課:貪婪演算法。Kruskal 和 Prim 每一步都選「當下最便宜」的,這種策略叫做貪婪;下一課看它什麼時候行得通、什麼時候會失敗。

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