📌 本頁使用方式(cppds Ch.9|講義 08)
① 照節次讀 :每一節先讀說明、動手玩互動元件,預測結果再按按鈕驗證。
② 對照講義 :頁上 §徽章對應 cppds 章節;細節與完整程式請回講義 08 與 cppds 原文。
③ 每節做 quiz :答錯就回到該節重讀,不要往下跳。
④ 最後翻 關鍵詞彙卡(29 張) 自測術語(中文為主、術語附英文),並用 REF 總覽區當速查表。標「官方選讀」的節屬 cppds 正文,但課程第一輪可略過。
PROLOGUE · 開場
圖是什麼?先把術語都講清楚 cppds §9.1–9.2
圖(graph) 可以用來描述許多現實世界的關係:道路系統、航班、網際網路連線、課程先修關係等等。一旦我們把問題用圖表示出來,就可以套用標準的圖演算法 解決原本看似困難的問題!
三個核心元素
頂點(Vertex / Node): 圖的基本單位,有一個 key(名稱),可選擇性地附帶 value 或 payload。
邊(Edge / Arc): 連接兩個頂點,表示兩者之間有關係。邊可以是單向 (有向圖 digraph)或雙向 。
權重(Weight): 邊上可附加成本,例如兩城市間的距離、網路延遲等。
形式上我們把圖寫成 $G = (V, E)$:$V$ 是頂點集合,$E$ 是邊的集合。每條邊是一個 tuple $(v, w)$,其中 $v, w \in V$;如果有權重就寫成 $(v, w, c)$。
路徑、循環、樹、DAG
› 這是一個簡單的有向加權圖 ,有 6 個頂點與 9 條邊。下方按鈕可以高亮不同術語。
高亮 Path: v3→v4→v0→v1
高亮 Cycle: v5→v2→v3→v5
↺ 清除
關鍵概念
Path 由邊串聯的頂點序列 $w_1, w_2, \ldots, w_n$,每個相鄰對 $(w_i, w_{i+1}) \in E$。
Cycle 起點=終點的 path 。
DAG Directed Acyclic Graph:沒有 cycle 的有向圖;可解決許多排程/依賴問題。
Tree 連通且無 cycle 的圖;下一章專題討論。
頂點的三色狀態(後續演算法都會用到)
BFS 與 DFS 都會用「白/灰/黑」三色追蹤每個頂點被造訪的程度。請先記住這套語義,後續每一節都會直接套用:
White:尚未發現
Gray:已發現、尚未完成
Black:已完全探索
Current:目前處理中
Start:起點
原始邊
樹邊(搜尋樹)
正在檢查
每一節版面都採用左「圖視覺化+控制列」、右「即時統計+虛擬碼+複雜度」的形式。請按 ▶ 開始 跑完整動畫,或按 → 單步 一步一步觀察。
PART 01 · 圖的表示
怎麼把圖塞進電腦?相鄰矩陣 vs 相鄰串列 cppds §9.3–9.6
本課程的 Graph 以 setVertex(key)、addEdge(u, v, w) 建圖,並透過公開的 vertices map 查詢與迭代。setVertex 對既有 key 是 no-op,不會清掉原有 edges 或 traversal state;vertices.count(key) 檢查頂點是否存在。
› 點擊任意頂點,看對應的列/鄰居清單會被高亮
點選頂點:
v0
v1
v2
v3
v4
v5
↺ 清除
何時用矩陣?
適合稠密圖 (dense,邊數接近 $|V|^2$)。
✅ 直接 $O(1)$ 查詢任兩點是否相連
❌ 空間 $O(|V|^2)$
❌ 對稀疏圖極度浪費
空間 $O(|V|^2)$
查邊 $O(1)$
列鄰居 $O(|V|)$
何時用串列?
適合稀疏圖 (sparse,邊數遠小於 $|V|^2$)。
✅ 空間 $O(|V|+|E|)$
✅ 列出鄰居只需 $O(\deg(v))$
❌ 查特定邊需走過鄰居清單
空間 $O(|V|+|E|)$
查邊(std::map) $O(\log\deg(v))$
列鄰居 $O(\deg(v))$
真實世界:稀疏為主
以 Word Ladder 為例:5,110 個字的矩陣有 26,112,100 格;實作儲存 53,286 條有向 adjacency entries(26,643 組無向單字對),約占 0.20% 。
C++ 實作:Vertex 與 Graph 類別 pythonds3/cppds/graph.hpp
每個 Vertex 用 map 紀錄它的鄰居及邊權重;Graph 再用一個 map 把 key 對應到 Vertex 物件:
class Vertex {
public :
string key;
map<string, int> neighbors; // 鄰居 key → 權重
string color = "white" ;
Vertex (string k) { key = k; }
};
class Graph {
public :
map<string, Vertex> vertices;
void setVertex (string key) { if (vertices.count (key) == 0 ) vertices.emplace (key, Vertex (key)); }
void addEdge (string fromVert, string toVert, int weight = 0 ) {
if (vertices.count (fromVert) == 0 ) setVertex (fromVert);
if (vertices.count (toVert) == 0 ) setVertex (toVert);
vertices[fromVert].neighbors[toVert] = weight;
}
};
小提醒
本章後續所有演算法(BFS/DFS/Dijkstra/Prim)都建立在相鄰串列 實作之上。Vertex 類別在不同節中會擴充不同欄位(如 color, distance, previous, discovery_time, closing_time),但底層結構不變。
講義完整範例:Graph 類別的使用畫面
PART 02 · 廣度優先搜尋
BFS:一層一層往外擴散 cppds §9.9–9.10
給定起點 $s$,廣度優先搜尋(Breadth-First Search) 會找出圖中所有從 $s$ 可達的頂點。它最迷人的性質是:會先找到所有距離 $s$ 為 $k$ 的頂點,才開始找距離 $k+1$ 的 。換句話說,BFS 自動產生「最少邊數路徑」(在無權圖上就是最短路徑)。
核心資料結構
BFS 用 Queue(先進先出) 決定下一個頂點。每次執行前先把所有頂點重設為 white、distance = INT_MAX(尚不可達)、空 previous;起點在 enqueue 前設為 gray 與 distance 0,避免被鄰居重複加入。
› 選擇起點,按「開始」執行 BFS
起點
A B C
D E F
G H
速度
▶ 開始
→ 單步
↺ 重置
Queue:
empty
虛擬碼 CODE
#include "pythonds3/cppds/graph.hpp"
#include <climits>
#include <queue>
#include <stdexcept>
void bfsPanel (Graph& g, const string& startKey) {
if (!g.vertices.count (startKey)) { throw invalid_argument("missing start" ); } for (auto & [key, v] : g.vertices) { v.color="white" ; v.distance=INT_MAX; v.previous="" ; }
Vertex& start = g.vertices.at (startKey); start.distance = 0 ;
start.color = "gray" ;
queue<string> pending;
pending.push (startKey);
while (!pending.empty ()) {
string currentKey = pending.front (); pending.pop ();
Vertex& current = g.vertices.at (currentKey); for (auto & [neighborKey, weight] : current.neighbors) {
Vertex& neighbor = g.vertices.at (neighborKey); if (neighbor.color == "white" ) {
neighbor.color = "gray" ;
neighbor.distance = current.distance + 1 ;
neighbor.previous = currentKey;
pending.push (neighborKey);
} } current.color = "black" ; } }
複雜度
時間 $O(\lvert V\rvert + \lvert E\rvert)$
空間 $O(\lvert V\rvert)$
最短路徑 無權圖 ✓
為什麼是 $O(|V|+|E|)$?
外層 while 每個頂點最多進 queue 一次(白色才會被加),所以是 $O(|V|)$。內層 for 走每個被 dequeue 出來的點的所有鄰居,整體加總就是每條邊被檢查一次 ,也就是 $O(|E|)$。兩者相加:$O(|V|+|E|)$。
BFS 的兩個重要性質
(1) 最短路徑樹: 沿著 previous 指標往回走,可以重建從任何可達頂點回到起點的最少邊數路徑 。
(2) 一次解多個問題: 一次 BFS 同時得到起點到「所有可達頂點」的最短距離,不需要重新跑 $|V|$ 次!
範例:在真實地圖上跑 BFS
Google Maps Explained(secretsofmaps.com)
把本節的 BFS 搬到
真實道路網 上:地圖上左鍵點起點、右鍵點終點(或按
Select 2 Random Points),再按播放,就能看到 BFS 像
水面漣漪一樣一圈一圈往外擴散 ,直到碰到終點。
網站的頂點是 OpenStreetMap 的路口、邊是路段,資料透過 Overpass API 即時抓取,和本頁的
Graph/
Vertex 是同一套抽象。
建議玩法: 先跑 BFS,觀察它如何逐層搜尋,而不優先朝終點前進;接著依序解鎖 DFS(一頭鑽進去、找到的不一定最短)、Greedy(只看離終點多遠,遇到河就繞遠路)、A*(同時考慮已走成本與到終點的估計成本)、Bidirectional BFS/A*(從起點與終點兩側搜尋,實際縮減幅度依路網與位置而異)、最後的 A* + 查表(觀察預先整理路網資訊如何減少查詢工作)。
看完再回頭讀 PART 06:A* 在已走成本上加入到終點的啟發式估計,決定搜尋優先順序。 路段距離不同時,BFS 找到的最少邊數不一定是最短路程;這裡先比較搜尋方式。
小提醒:介面為英文;地圖資料第一次載入可能要一、兩分鐘,Overpass 偶爾會限流,請稍等再試;廣告阻擋器可能讓地圖載不出來。演算法預設隨導覽角色 Geo 的對話逐一解鎖,想直接玩可按 Unlock All Algorithms。
作者 Adam Kulikowski,改作自 honzaap/Pathfinding,MIT 授權;
原始碼 。
講義完整範例:Word Ladder 的實際執行
PART 03 · Word Ladder
Word Ladder:把 BFS 應用到「換字謎題」 cppds §9.7–9.8
Word Ladder :把一個單字一次改一個字母變成另一個單字,每一步都必須是有效單字。經典題目:FOOL → SAGE,最少要幾步?
FOOL → POOL → POLL → POLE → PALE → SALE → SAGE
把每個單字當頂點、把「只差一個字母」的單字對連邊,問題就變成「在這張圖上找最短路徑」 ,正是 BFS 的拿手好戲!
建圖技巧:bucket 法(避免 $O(n^2)$ 比對)
若有 $n=5{,}110$ 個四字母單字,兩兩比對需要約 $\binom{n}{2} \approx 1.3 \times 10^7$ 次,太慢。聰明的做法:用 bucket 標籤 ,將每個字母位置依次替換成底線 _,把所有共用同一個 bucket 的單字連起來。
Bucket 構造範例
POPE、POPS 都會落入 bucket POP_;
FOOL、POOL、COOL、TOOL 都落入 bucket _OOL。
每個 bucket 內部的所有單字兩兩相連 → 所有「只差一個字母」的單字對都被連上了!
› 輸入起點與終點,按「找最短路徑」執行 BFS
起點
終點
速度
▶ 找最短路徑
→ 單步
↺ 重置
Queue:
empty
演算法複雜度
建圖(bucket) $O(n \cdot L)$
BFS $O(\lvert V\rvert + \lvert E\rvert)$
$n$ = 字數, $L$ = 字長
單字集合:fool, cool, pool, poll, pole, pall, fall, fail, foil, foul, pope, pale, sale, sage, page。圖中只畫出 bucket 法產生的邊(同 bucket 內兩兩相連);BFS 從起點往外擴散時會以橘色標記正在處理的頂點,綠色表示已被加入搜尋樹。
PART 04 · 深度優先搜尋
DFS:一條路走到黑,碰壁再回頭 cppds §9.15–9.16
BFS 一層一層擴散,深度優先搜尋(Depth-First Search) 則是沿一條分支盡可能往深走 ,走不下去才回退(backtrack)到上一個還有未探索鄰居的頂點。BFS 用 queue,DFS 自然用遞迴(隱式 stack) 。
DFS 的兩個新欄位
除了 color 與 previous,DFS 還記錄:
discovery_time(發現時間): 頂點從 white 變 gray 的時間步。
closing_time(結束時間): 頂點從 gray 變 black 的時間步。
這兩個時間滿足括號性質(parenthesis property) :每個子節點的 [discovery, closing] 區間都完全嵌套在父節點的區間內。
› 按「開始」跑完整 DFS(會走遍整個 forest)
起點
A B C
D E F
速度
▶ 開始
→ 單步
↺ 重置
Recursion stack(隱式):
empty
時間計數:
0
虛擬碼 CODE
#include "pythonds3/cppds/graph_algos.hpp"
void dfsVisitPanel(DFSGraph&, const string&);
void dfsPanel (DFSGraph& graph) {
for (auto & [key, v] : graph.vertices) { v.color="white" ; v.previous="" ; }
graph.time = 0 ; graph.discovery.clear (); graph.closing.clear ();
for (auto & [key, v] : graph.vertices) {
if (v.color == "white" ) {
dfsVisitPanel (graph, key);
} } }
void dfsVisitPanel (DFSGraph& graph, const string& uKey) {
graph.vertices.at (uKey).color = "gray" ;
graph.discovery[uKey] = ++graph.time;
for (auto & [neighborKey, weight] : graph.vertices.at (uKey).neighbors) {
if (graph.vertices.at (neighborKey).color == "white" ) {
graph.vertices.at (neighborKey).previous = uKey;
dfsVisitPanel (graph, neighborKey);
} } graph.vertices.at (uKey).color = "black" ;
graph.closing[uKey] = ++graph.time; }
複雜度
時間 $O(\lvert V\rvert + \lvert E\rvert)$
空間 $O(\lvert V\rvert)$ 遞迴
為什麼要走訪所有頂點?(line 4)
從單一起點 DFS 可能無法走遍整張圖(特別是有向圖或不連通圖)。外層 for v : graph 確保每個白色頂點都會啟動一次 dfsVisit,最終得到一片深度優先森林(DF forest) 。
BFS vs DFS 的程式碼對照
DFS 與 BFS 的程式碼幾乎一模一樣 ,差別只在內層迴圈最後一行:
BFS:queue.enqueue(neighbor):先把鄰居塞 queue,等以後處理。
DFS:dfsVisit(neighbor):立刻遞迴下去處理鄰居。
這個小差異就決定了「廣度優先 vs 深度優先」的搜尋順序!
PART 05 · 騎士巡邏
Knight's Tour:DFS + Warnsdorff 啟發式 cppds §9.11–9.14
騎士巡邏 :在 $n \times n$ 棋盤上,騎士能否走 $n^2 - 1$ 步、恰好造訪每格一次?這是 DFS 的經典應用:把每格當頂點、把合法走法當邊,問題變成「找出長度為 $n^2 - 1$ 的簡單路徑」。
在 $8\times8$ 棋盤上,adjacency map 儲存 336 條有向 adjacency arcs ;每個合法移動的無向關係會以兩個方向儲存,所以等價於 168 條無向邊。
指數爆炸
在 $8 \times 8$ 棋盤上,平均分支因子 $k = 5.25$,搜尋樹約有 $5.25^{64} \approx 1.2 \times 10^{46}$ 個節點!純 DFS 在 8x8 棋盤上會跑數分鐘到永遠跑不完。但只要加上一個簡單的啟發式排序 ,速度可以快上千萬倍。
Warnsdorff 啟發式
下一步永遠選「可走鄰居最少」的格子 。
› 選擇棋盤大小與是否使用 Warnsdorff,按「開始」
大小
5×5
6×6
7×7
8×8
起點
(0,0)
速度
使用 Warnsdorff: 開
▶ 開始
↺ 重置
虛擬碼 CODE
#include "pythonds3/cppds/graph_algos.hpp"
bool knightTourPanel (int n, vector<string>& path, const string& uKey, int limit, Graph& g) {
g.vertices.at (uKey).color = "gray" ;
path.push_back (uKey);
if (n >= limit) return true ;
for (const string& neighborKey : orderByAvail (g, uKey)) {
if (g.vertices.at (neighborKey).color == "white" &&
knightTourPanel (n + 1 , path, neighborKey, limit, g)) return true ;
}
path.pop_back (); g.vertices.at (uKey).color = "white" ;
return false ;
}
複雜度
純 DFS $O(k^N)$
Warnsdorff 啟發式;最壞仍指數
$N=n^2$, $k$≈分支因子
為什麼選「鄰居最少」而不是「最多」?
若每次選鄰居最多的格子,騎士會傾向往中間擠(中間格鄰居多)。一旦中間都用完,四角的孤立格子就再也走不到 。反過來「先解決鄰居最少的」,等於先掃完邊角這些「燙手山芋」,中間格子鄰居多、機動性高,留到後段當跳板使用:這就是 Warnsdorff 規則奏效的根本原因。
PART 06 · Dijkstra 最短路徑
Dijkstra:加權圖上的「貪婪式 BFS」 cppds §9.19–9.21
Dijkstra 演算法 解決單源最短路徑 問題:給定起點 $s$,求 $s$ 到所有其他頂點的最小權重總和路徑 。它的精神類似 BFS,但用優先佇列(priority queue) 取代普通 queue,每次從未處理頂點中挑出當前 distance 最小者。
前提:邊權必須非負
Dijkstra 不適用於有負邊權的圖 。負邊會讓「拿出最小者就確定其最終距離」這個貪婪假設失效,詳見練習題 1。對於有負邊但無負環的情況請改用 Bellman-Ford 。
› 選擇起點,按「開始」執行 Dijkstra
起點
u v w
x y z
速度
▶ 開始
→ 單步
↺ 重置
Priority Queue:
empty
虛擬碼 CODE
#include "pythonds3/cppds/graph.hpp"
#include <climits> #include <functional> #include <queue> #include <stdexcept> #include <vector>
void dijkstraPanel (Graph& g, const string& startKey) {
if (!g.vertices.count (startKey)) { throw invalid_argument("missing start" ); } priority_queue<pair<int ,string>, vector<pair<int ,string>>, greater<pair<int ,string>>> pq;
for (auto & [key,v] : g.vertices) { v.distance=INT_MAX; v.previous="" ; for (auto & edge : v.neighbors) if (edge.second < 0 ) throw invalid_argument("negative edge" ); }
g.vertices.at (startKey).distance = 0 ; pq.push ({0 , startKey});
while (!pq.empty ()) {
auto [distance, uKey] = pq.top (); pq.pop ();
if (distance > g.vertices.at (uKey).distance) continue ;
for (auto & [vKey, weight] : g.vertices.at (uKey).neighbors) {
int newDistance = distance + weight;
if (newDistance < g.vertices.at (vKey).distance) {
g.vertices.at (vKey).distance = newDistance;
g.vertices.at (vKey).previous = uKey;
pq.push ({newDistance, vKey}); } } } }
複雜度
時間(heap) $O((|V|{+}|E|)\log|V|)$
初始化 PQ $O(1)$
最多 entries $O(|E|)$
關鍵概念:邊鬆弛 (relaxation)
第 9–13 行稱為邊鬆弛 :對 $u$ 的每個鄰居 $v$,檢查是否可以透過 $u$ 走到 $v$ 比目前已知更近:
$$\text{new\_d} = d(u) + w(u,v),\quad \text{若 new\_d} < d(v)\text{,更新 } d(v) \text{ 並把 }u\text{ 設為 previous}.$$
本課程採 lazy priority queue:改善距離時直接 push 新 pair;舊 pair 出列時若距離較大就略過。最多 $O(|E|)$ 次 push/pop;簡單圖上 $\log|E|=O(\log|V|)$,總時間 $O((|V|+|E|)\log|V|)$。
範例圖
這是 PDF 上的標準範例(雙向邊):頂點 $\{u, v, w, x, y, z\}$,邊權如圖所示。從 $u$ 出發,正確的最短路徑樹如下:
u→v: 2 u→x: 1 u→y: 2 (經 x) u→w: 3 (經 y) u→z: 3 (經 y)
PART 07 · Prim 最小生成樹
Prim:用最小邊權連通整張圖 cppds §9.22
想像線上遊戲要把同一筆訊息傳給所有玩家。最直覺的做法(Host 對每位玩家各送一份)會讓某些路由器重複收到同一個封包。一個更聰明的做法是:找出一棵最小生成樹(Minimum Spanning Tree, MST) :一個無循環的子圖,連通所有頂點,且總邊權最小。
MST 的定義
對無向加權連通圖 $G = (V, E)$,最小生成樹 $T$ 是 $E$ 的一個無循環子集,滿足:
(1) $T$ 連通所有 $V$ 中的頂點;(2) $T$ 中的邊權總和最小。
Prim 是貪婪演算法
Prim 每一步都選「安全邊(safe edge)」 :一條跨越「已加入樹的頂點集合」與「尚未加入的頂點」的最小權重邊 。每次加入一條,直到所有頂點都進樹為止。
(在實作中我們用 PQ 維護「每個未加入頂點到已加入集合的最小距離」,跟 Dijkstra 結構非常類似!)
連通性前提
不連通圖不存在涵蓋所有頂點的 spanning tree。課程的 void prim() 在 PQ 清空後檢查已入樹頂點數;若不足便丟出 invalid_argument,不會靜默回傳 forest。
› 選擇起點,按「開始」逐步建立 MST
起點
A B C
D E F G
速度
▶ 開始
→ 單步
↺ 重置
Priority Queue:
empty
即時統計 LIVE
已加入節點 0/0
MST 總權重 0
虛擬碼 CODE
#include "pythonds3/cppds/graph.hpp" #include <climits> #include <functional> #include <queue> #include <set> #include <stdexcept> #include <vector>
void primPanel (Graph& g, const string& startKey) {
if (!g.vertices.count (startKey)) { throw invalid_argument("missing start" ); } priority_queue<pair<int ,string>, vector<pair<int ,string>>, greater<pair<int ,string>>> pq; set<string> inTree;
for (auto & [key,v] : g.vertices) v.distance = INT_MAX;
for (auto & [key,v] : g.vertices) v.previous = "" ;
g.vertices.at (startKey).distance = 0 ;
pq.push ({0 , startKey});
while (!pq.empty ()) {
auto [key,uKey]=pq.top (); pq.pop (); if (inTree.count (uKey)) continue ; inTree.insert (uKey);
for (auto & [vKey,weight] : g.vertices.at (uKey).neighbors) {
int newDistance = weight;
if (!inTree.count (vKey) && newDistance < g.vertices.at (vKey).distance) {
g.vertices.at (vKey).previous = uKey;
g.vertices.at (vKey).distance = newDistance;
pq.push ({newDistance,vKey}); } } } if (inTree.size () != g.vertices.size ()) throw invalid_argument("disconnected graph" ); }
複雜度
時間(heap) $O((|V|{+}|E|)\log|V|)$
Prim vs Dijkstra:一個關鍵差異
結構幾乎一樣,但更新規則不同 :
Dijkstra: newD = u->distance + w(u,v):累加從起點走到 v 的整條路徑 權重。
Prim: newD = w(u,v):只看 u 到 v 的單一邊權 。
為什麼?因為 MST 不在乎「離起點多遠」,只在乎「下一個要連入樹的最便宜邊」。
PART 08 · 拓撲排序
拓撲排序:把 DAG 攤平成一條合法的待辦順序 cppds §9.17 · 官方選讀
這是 cppds 正文章節;課程標為 Optional,但保留完整可見摘要供自學。
講義用煎鬆餅開場:食譜很簡單(1 顆蛋、1 杯鬆餅粉、1 匙油、3/4 杯牛奶),
難的是先做哪一步 。加熱煎鍋和打蛋都可以先做,但「倒麵糊」一定要等鍋熱了、糊攪好了。
把每個步驟當頂點、「必須先做」當有向邊,就得到一張 DAG (有向無環圖)。
拓撲排序 (topological sort)吃進一張 DAG,吐出一個線性順序,
保證圖中每條邊 $(v, w)$ 的 $v$ 都排在 $w$ 前面。凡是「事件有先後依賴」的問題都用得上:
軟體專案排程、資料庫查詢最佳化、矩陣連乘的順序安排。
演算法:DFS 的一個小改裝
① 對圖跑 dfs(g),目的是替每個頂點算出 closing time (結束時間)。
② 把頂點依 closing time 遞減 排成一列。
③ 這一列就是拓撲排序的結果。
› 按「① 跑 DFS」開始:節點上會標出 start/close 時間。
▶ ① 跑 DFS 標 closing time
② 依 closing 遞減排出順序
→ 單步
⏸ 暫停
速度
↺ 重置
節點代號
奶=3/4 杯牛奶|蛋=1 顆蛋|油=1 匙油
鍋=加熱煎鍋|糊=攪 1 杯鬆餅糊
漿=加熱糖漿|倒=倒 1/4 杯麵糊
翻=冒泡就翻面|吃=開動
怎麼讀動畫
節點下方的 s/f 是 DFS 的 start/closing time。
灰=已發現、黑=已完成。第 ② 步只是把黑點照 f 由大到小唸出來,
合法的做事順序就出現了。
QUIZ · 為什麼「closing time 遞減」就是答案?
對 DAG 跑完 DFS 後,依 closing time 遞減排序,為什麼保證每條邊 $(v,w)$ 的 $v$ 都排在 $w$ 前面?
(A) 因為 DAG 上有邊 $(v,w)$ 時,$v$ 的 closing time 一定比 $w$ 大
(B) 因為 closing time 大代表這個點的鄰居比較多
(C) 因為 DFS 一定會先造訪 $v$ 再造訪 $w$
PART 09 · 強連通元件
強連通元件(SCC):找出「彼此都到得了」的頂點群 cppds §9.18 · 官方選讀
這是 cppds 正文章節;課程標為 Optional,但保留完整可見摘要供自學。
把全球資訊網當成一張超大的有向圖:網頁是頂點、超連結是邊。Google 這類搜尋引擎正是在這張圖上做演算法。
觀察這種圖會發現:內容相近的網站常常互相連來連去 ,形成高度互連的群集。
抓出這種群集的工具就是強連通元件 演算法。
正式定義:SCC 是頂點的最大子集 $C \subseteq V$,使得其中任兩點 $v, w$ 都互相可達
($v$ 到得了 $w$,$w$ 也到得了 $v$)。找到 SCC 之後,把每個 SCC 縮成一顆大點,
就得到一張簡化的「元件圖」,原圖的巨觀結構一眼可見。
先備定義:轉置圖 $G^T$
把 $G$ 的每條邊反向就得到 $G^T$(A→B 變成 B→A)。
關鍵觀察:$G$ 和 $G^T$ 的 SCC 一模一樣 (「互相可達」反向後仍互相可達),
但「跨 SCC」的單向連結會反向。演算法要鑽的正是這個縫。
SCC 演算法四步(Kosaraju)
① 對 $G$ 跑 dfs(),算每個頂點的 closing time。
② 算出轉置圖 $G^T$。
③ 對 $G^T$ 跑 dfs(),但主迴圈依 closing time 遞減 的順序挑起點。
④ 步驟 ③ 長出的森林中,每一棵樹就是一個 SCC 。
› 照按鈕順序 ①→②→③ 走一遍演算法。
▶ ① DFS on G
② 轉置成 Gᵀ
③ Gᵀ 上依 closing 遞減 DFS
→ 單步
⏸ 暫停
速度
↺ 重置
這張例圖
8 個頂點、3 個 SCC:A B E 一圈、C F G 一圈、H I 一圈,
圈與圈之間只有單向通道。跑到第 ③ 步會用三種顏色框出三棵樹。
為什麼跑不出去?
第 ③ 步從「closing time 最大」的點出發。在 $G^T$ 裡,
通往其他 SCC 的邊全被反向了,DFS 只能在自己的 SCC 裡打轉:
一棵樹剛好框住一個元件。
QUIZ · 第二次 DFS 的兩個講究
SCC 演算法的第二次 DFS 為什麼要(i)換到 $G^T$ 上跑、(ii)依 closing time 遞減挑起點?
(A) 兩個條件合起來,讓每棵 DFS 樹恰好「困」在一個 SCC 裡
(B) $G^T$ 的邊比較少,跑起來比較快
(C) 只是慣例,從任何點開始、在原圖上跑也一樣對
EXERCISES · 練習題
動手驗證:兩個經典考點
EXERCISE 1 · Dijkstra 與負邊權
下列圖含負邊權。傳給課程的 dijkstra(g, "A") 時會發生什麼事?
(A) 回傳 A → B → D
(B) 回傳 A → C → D
(C) 自動刪除負邊後繼續
(D) 在 PQ traversal 前丟出 invalid_argument
EXERCISE 2 · Prim 從 F 出發
下列無向加權圖中,從節點 F 開始 執行 Prim 演算法,最後一條被加入 MST 的邊 ,其權重為何?
REFERENCE · 總覽比較
所有圖演算法一覽
核心演算法比較表
演算法
解決問題
關鍵 DS
時間複雜度
適用條件
BFS
無權最短路徑、連通性
Queue
$O(|V|+|E|)$
有向/無向皆可
DFS
連通分量、拓撲排序、強連通
Stack(遞迴)
$O(|V|+|E|)$
有向/無向皆可
Knight's Tour(純 DFS)
Hamiltonian path(特例)
Stack(遞迴)+ backtrack
$O(k^N)$
$N=$ 棋盤格數
+ Warnsdorff 啟發式
同上
同上
啟發式;最壞仍 $O(k^N)$
沒有理論保證
Dijkstra
單源加權最短路徑
Min-heap PQ
$O((|V|{+}|E|)\log|V|)$
邊權必須非負
Prim
最小生成樹
Min-heap PQ
$O((|V|{+}|E|)\log|V|)$
無向連通圖
BFS vs DFS 行為對照
面向
BFS
DFS
探索順序 按距離由近到遠(一層一層) 沿一條分支走到底,再回退
資料結構 FIFO Queue LIFO Stack(隱式遞迴)
找最短路徑 無權圖直接得到 不保證
記憶體 $O(|V|)$(最寬層) $O(|V|)$(最深路徑)
典型應用 最短跳數、層次結構 拓撲排序、SCC、循環偵測、回溯
產生的樹 BFS 樹 DFS forest(多棵樹)
Dijkstra vs Prim:細節差異
面向 Dijkstra Prim
解決問題 單源最短路徑 最小生成樹
更新規則 d(u) + w(u,v) 累加w(u,v) 只看單邊
結果意義 每點到起點的最短距離 連通所有頂點的最小邊集合
邊權限制 不可有負邊 無限制
有向/無向 有向/無向皆可 無向(有向需特殊處理)
學習路線建議
1. 先牢記三色語義(white/gray/black),這是 BFS 與 DFS 共通的骨架。
2. 動手追幾遍 BFS 與 DFS 的虛擬碼:它們
程式碼幾乎一樣 ,差別只在 queue/stack。
3. Dijkstra 與 Prim 同樣是 PQ-based 演算法,只差在
更新規則 ,一起記憶事半功倍。
4. Knight's Tour 是欣賞「啟發式排序」威力的最佳範例:同樣的 DFS 骨架,加上 Warnsdorff 規則就能把 8x8 從「跑不完」變「秒解」。
5. 讀完本頁去
Google Maps Explained 把 BFS、DFS、A* 在真實地圖上各跑一次:觀察加入到終點的估計後,搜尋順序如何改變,再回頭對照本章的距離與優先佇列模型。
QUIZ · 自我檢測
自我檢測:圖演算法 課程題庫 ch8 · 6 題
題目取自課程題庫(已譯為繁體中文),每個選項都有解說:選錯也點開看看為什麼錯。全對之後再往下翻詞彙卡。
Q1. 為什麼廣度優先搜尋(BFS)在無權重圖上保證找到最短路徑?
它會先探索完距離起點 k 的所有頂點,才前進到距離 k+1 的頂點。
它總是先選數值權重最小的邊。
它用貪婪策略剪掉太長的路徑。
它只在最短路徑確認後才把頂點標成黑色。
Q2. 廣度優先搜尋(BFS)與深度優先搜尋(DFS)在「追蹤下一個要探索的頂點」上,主要的結構差異是什麼?
BFS 用堆疊,DFS 用佇列。
BFS 用佇列逐層探索,DFS 用堆疊(常透過遞迴)盡量往深處走。
兩者都必須用優先佇列才能保證正確的搜尋順序。
BFS 用 map 追蹤頂點,DFS 只用簡單的 vector。
Q3. 下列哪一項是 Dijkstra 演算法的已知限制或特性?
它能正確處理含負權重邊的圖。
它是疊代式演算法,能給出單一起點到所有可達頂點的最短路徑。
它的時間複雜度與圖的邊數無關。
用在路由環境時,它不需要完整的網路地圖。
Q4. 關於圖的實作方式,下列哪個敘述符合教材內容?
相鄰矩陣是表示稀疏圖最省空間的方式。
對連接稀疏的圖來說,相鄰串列更省空間。
相鄰矩陣中,兩頂點沒有相連就不會配置那一格的記憶體。
找出某頂點的所有鄰居,相鄰矩陣比相鄰串列快。
Q5. Prim 演算法建最小生成樹(MST)時,什麼是「安全邊」(safe edge)?
任何加入目前的樹之後不會形成循環的邊。
跨越樹內與樹外頂點之 cut 的最小權重邊。
權重低於全圖平均邊權重的邊。
對圖做深度優先搜尋時最先遇到的邊。
Q6. 課程的 prim(g, start) 遇到不連通圖時會怎麼做?
安靜地回傳 minimum spanning forest。
自行加入不存在的邊來連接 components。
發現並非所有頂點都進樹後,丟出 invalid_argument。
永遠等待下一個 priority-queue entry。
CARDS · 關鍵詞彙卡
關鍵詞彙卡:點卡片翻面 題庫 ch8.json · 29 張
詞彙卡取自本章課程題庫,已譯為繁體中文,正面附英文原名。先看正面術語,心中默想定義再翻面對答案;洗牌後再過一輪,直到每張都能不假思索說出來。
🔀 洗牌
全部翻面
全部翻回