學習圖時要分清兩件事:哪些關係存在,以及電腦如何儲存這些關係。 深度優先搜尋(DFS)和廣度優先搜尋(BFS)可以作用於同一幅圖,但兩者管理 前沿的規則不同,所以探索次序也不同。嚴謹的執行軌跡必須交代表示法、檢查 鄰點的次序,以及頂點在甚麼時候算作已發現。
動機
道路、通訊連線、先修關係和程式狀態都可用圖表示。圖不像陣列般有唯一的 「下一個」元素;一個頂點可以有零條、一條或多條離開它的邊。因此遍歷既要 有前沿管理待處理頂點,也要記錄哪些頂點已被發現。
- DFS 沿一條尚未完成的路徑盡量深入,適合用遞歸或堆疊(stack)表示。
- BFS 先處理最早被發現的頂點,用隊列(queue)按距離層向外擴展。
若目的只是訪問頂點,兩者都毋須使用邊權。但當每條邊成本相同時, BFS 還會計算由起點到每個可達頂點的最少邊數。
定義
定義
圖與邊的種類
圖寫成 G = (V,E),其中 V 是頂點集合,E 是邊集合。有向圖中的
(u,v) 由 u 指向 v,並不自動容許由 v 走回 u;無向圖的一條邊則可
向兩邊通行。加權圖為每條邊附上數值權重;無權圖不以成本區分各邊。
依課程用語,**路徑(path)**是相鄰兩項之間都有邊的頂點序列,可以重複 頂點;**簡單路徑(simple path)**才不重複頂點。無權路徑的長度是邊數, 不是頂點數。 **迴路(cycle)**由同一頂點開始和結束;簡單迴路除首尾外不重複頂點。 沒有迴路的圖稱為無環圖。
若存在由 u 到 v 的有向路徑,便稱 v 從 u 可達。有向圖的可達性
不一定對稱。在無向圖中,兩頂點之間有路徑便是連通;每對頂點都連通的圖
稱為連通圖,而極大的連通部分稱為連通分量。有向頂點的出度數離開它的邊,
入度數進入它的邊。
選擇表示法
設頂點編號為 0,1,...,|V|-1。
定義
鄰接矩陣
鄰接矩陣(adjacency matrix)是 |V| × |V| 陣列;M[u][v] 記錄 (u,v)
是否存在。加權圖可在表項儲存權重,但必須另有不含糊的「沒有邊」表示。無向
圖的矩陣是對稱的。
定義
鄰接表
鄰接表(adjacency list)為每個頂點 u 儲存一個串列 Adj[u],列出可由
u 的出邊直接到達的頂點。加權鄰接表則為每條邊儲存鄰點與權重的一對資料。
表示法會直接改變取得鄰點的成本:
| 操作 | 鄰接矩陣 | 鄰接表 |
|---|---|---|
| 儲存整幅圖 | `Theta( | V |
檢查 (u,v) 是否存在 | Theta(1) | O(out-degree(u)) |
列舉 u 的所有出邊鄰點 | `Theta( | V |
| 遍歷整幅圖 | `Theta( | V |
稠密圖或大量直接邊查詢可適合用矩陣;鄰接表不用掃描不存在的邊,因此較適合 下文的線性時間遍歷。無向邊會在兩個鄰接表各出現 一次,只多一個常數因子,不改變漸近上界。
深度優先搜尋
DFS 發現頂點後,選第一個未發現鄰點,把目前工作暫停,再深入該鄰點。遞歸 呼叫保存被暫停的工作;當某頂點沒有未發現鄰點,呼叫完成並回溯。
DFS-VISIT(u):
discovered[u] = true
process u
for each v in Adj[u], in the stated order:
if not discovered[v]:
parent[v] = u
DFS-VISIT(v)
DFS-FOREST(G):
把所有頂點設為未發現,所有 parent 設為 null
按既定次序逐一考慮頂點 u:
if not discovered[u]:
DFS-VISIT(u)
單次 DFS-VISIT(s) 恰好探索由 s 可達的頂點;DFS-FOREST 的外層迴圈
則會覆蓋非連通圖,每個新根開始另一棵 DFS 樹。顯式堆疊可取代遞歸,但若要
得到相同軌跡,必須小心 push 次序:一次把多個鄰點推入後進先出的堆疊,可能
令處理次序反轉。
父邊組成 DFS 樹;若使用外層迴圈,便得到 DFS 森林。它記錄每個頂點 首次如何被發現,並不代表原圖的其他邊消失。
廣度優先搜尋與前沿不變量
BFS 使用先進先出的隊列。隊列內是已發現、但出邊鄰接表尚未完全處理的頂點。 安全的發現規則是:
頂點在入隊時立即標記為已發現,不可等到日後出隊才標記。
BFS(G, s):
for each vertex v:
discovered[v] = false
distance[v] = infinity
parent[v] = null
discovered[s] = true
distance[s] = 0
enqueue(Q, s)
while Q is not empty:
u = dequeue(Q)
process u
for each v in Adj[u], in the stated order:
if not discovered[v]:
discovered[v] = true
distance[v] = distance[u] + 1
parent[v] = u
enqueue(Q, v)
discovered[v] 的賦值在 enqueue 之前。若另一個前沿頂點也有邊通往 v,
它會看到 v 已被認領,不能重複入隊。「已發現」不等於「已完成」:隊列中
的頂點雖未出隊,仍然已發現。
單次 BFS 也只處理由起點可達的部分。要遍歷無向圖的每個連通分量,便在 仍未發現的頂點重新開始 BFS,直至沒有剩餘頂點。
定理 / 命題
定理
BFS 的無權最短路徑性質
BFS 由 s 開始時,每個可達頂點 v 首次被發現所得的 distance[v],等於
任何 s 至 v 路徑的最少邊數。由 v 逆向跟隨 parent pointers 可重建
一條這樣的最短路徑。
這結論要求每條邊成本相同。若權重不同,較少邊的路徑可以有較大總權重;普通 BFS 仍最小化邊數,卻不一定最小化加權成本。
定理
鄰接表遍歷的上界
當搜尋覆蓋整幅鄰接表圖,DFS 和 BFS 的時間均為 O(|V|+|E|)。除原圖外,
visited、parent 與前沿所需空間為 O(|V|)。
單一起點搜尋可用實際到達的頂點和被掃描的出邊寫出更緊上界;慣常的全圖 上界對應會遍歷所有連通分量的版本。
證明思路
先證 BFS 的最短路徑性質。開始時只有 s 的距離是 0。若處理 u
時首次發現 v,算法設定 distance[v]=distance[u]+1。由於隊列是先進
先出,距離 k 的頂點一定在新發現的距離 k+1 頂點之前處理。假如 v 有
更短路徑,該路徑應從更早的層到達並更早發現 v,造成矛盾。因此首次
距離最小;每沿一個 parent 距離恰減一,parent chain 便重建最短路徑。
再證時間上界。初始化和外層迴圈是 O(|V|)。每個頂點只會首次發現一次,
所以只進遞歸/堆疊或隊列一次。所有鄰接表合計掃描每條
有向邊一次;無向邊兩次,故總時間是 O(|V|+|E|)。若用矩陣,每處理一個
頂點都要掃描 |V| 個表項,全圖遍歷便是
Theta(|V|^2)。
例題
考慮課程中的 A,B,C,D,E,F 有向圖,固定鄰接表次序如下:
A: B, D
B: C, D
C: F, D, E
D: E
E: —
F: —
例題
在兩種表示法之間轉換
按 row/column 次序 A,B,C,D,E,F,同一幅圖的鄰接矩陣是
A B C D E F
A 0 1 0 1 0 0
B 0 0 1 1 0 0
C 0 0 0 1 1 1
D 0 0 0 0 1 0
E 0 0 0 0 0 0
F 0 0 0 0 0 0
矩陣不對稱:A -> B 存在,但 B -> A 不存在。鄰接表只儲存八條出邊,
不用保存其餘二十八個不存在的有序對。
例題
由 A 追蹤遞歸 DFS
按固定鄰接表次序,發現次序是 A,B,C,F,D,E。正在使用的遞歸堆疊變化如下:
| 事件 | 事件後的活動堆疊 |
|---|---|
發現 A | [A] |
發現 B | [A,B] |
發現 C | [A,B,C] |
發現 F | [A,B,C,F] |
完成 F | [A,B,C] |
發現 D | [A,B,C,D] |
發現 E | [A,B,C,D,E] |
| 完成並逐層回溯 | 最終成為 [] |
返回 C 後,E 已經由 D 發現,所以不會再遞歸。改變鄰接表次序可以產生
另一個正確 DFS 次序,但不會改變哪些頂點可達。
例題
由 A 追蹤 BFS 層、parents 與 distances
下表的隊列是處理目前頂點之後的狀態。
| 出隊 | 新發現 | 其後的隊列 | 新 distance/parent |
|---|---|---|---|
A | B,D | [B,D] | d(B)=d(D)=1,parents 是 A |
B | C | [D,C] | d(C)=2,parent 是 B |
D | E | [C,E] | d(E)=2,parent 是 D |
C | F | [E,F] | d(F)=3,parent 是 C |
E | 沒有 | [F] | 不變 |
F | 沒有 | [] | 不變 |
出隊次序為 A,B,D,C,E,F。例如 parent pointers 可重建最短路徑
A -> B -> C -> F,共有三條邊。雖然 C 也有邊到 E,路徑
A -> B -> C -> E 有三條邊;BFS 已經由較短的 A -> D -> E 兩邊路徑發現
E。
例題
為何到出隊才標記會製造重複項
假設 A 只在出隊時才標記。處理 A 後,B、D 已入隊但仍未標記,隊列
是 [B,D]。接着 B 出隊並檢查 C、D;先前那份 D 仍未標記,於是
B 再把 D 入隊,得到 [D,C,D]。
圖沒有改變,實作卻失去「每個頂點只入隊一次」的不變量。它不但浪費工作,
還可能覆寫 parent 或 distance。若 A 把 D 入隊時立即標記,B 便會跳過
重複項;因此入隊時標記是 BFS 正確性的一部分。
常見錯誤
常見錯誤
把遍歷次序當成唯一
DFS/BFS 分別服從堆疊/隊列紀律,但同一層或分支的並列選擇由鄰接表次序決定。 改變該次序,實際軌跡可以不同。
常見錯誤
混淆已發現與已處理完
BFS 頂點在入隊時已發現;到它出隊並掃描完鄰接表才算完全處理。延遲至出隊 才標記會容許隊列出現重複頂點。
- 單一起點搜尋不會自動訪問不可達頂點;完整遍歷要有外層迴圈。
- 有向矩陣不必對稱,有向可達性也不必雙向成立。
- BFS 保證最少邊數,不保證任意加權成本最小。
- 一面掃描鄰接矩陣的整個 row,一面聲稱
O(|V|+|E|),是混合了兩種表示法。 - DFS/BFS 的 parent tree 只含發現邊,不含原圖所有邊。
總結
- 圖可分為有向或無向、加權或無權。
- 路徑、迴路、可達性、連通性描述抽象圖,與儲存法分開。
- 矩陣查一條邊快但空間與 row scan 均為平方量級;鄰接表只儲存實際鄰點。
- DFS 用遞歸/堆疊先深入再回溯。
- BFS 用隊列按非遞減的邊距離層擴展。
- 穩健 BFS 在入隊時標記,保證每個頂點最多入隊一次。
- BFS 的 parent 和 distance arrays 給出無權最短路徑。
- 鄰接表 DFS/BFS 的全圖時間為
O(|V|+|E|)。
練習
- 對
A–F圖,寫出C的出邊鄰點和鄰接矩陣對應 row,並解釋鄰接表 成本為何取決於 out-degree,而矩陣 row scan 不會。 - 先由
D做遞歸 DFS,再由D做 BFS。按固定鄰接表次序寫出兩個發現 次序,並指出哪些頂點不可達。 - 原本由
A的 BFS 中,C也有邊到E,為何E仍由D得到距離2?重建所儲存的 parent path。 - 若 visited 標記延遲到出隊,寫出最早含重複項的隊列狀態,並 指出是哪兩條邊造成。
- 一幅圖有
10,000個頂點和20,000條有向邊。比較全圖矩陣遍歷概念上 掃描的位置數與鄰接表的漸近工作量。
解答
解答 · 引導解答
-
Adj[C]=[F,D,E];按A,B,C,D,E,F的 column order,rowC是0 0 0 1 1 1。鄰接表掃描三個已存鄰點;矩陣必須檢查六個 columns。 -
由
D開始,DFS 和 BFS 都依次發現D,E;E沒有出邊。A,B,C,F均不可達,因為有向圖不能沿入邊倒行。 -
D比C早出隊,故先發現E,設定distance[E]=2、parent[E]=D。 到處理C時E已發現。所存 path 是A -> D -> E。 -
延遲標記下,處理
A後 queue 是[B,D]。處理B時,A -> D放入的D仍未標記,B -> D再放一份,得到[D,C,D]。 -
矩陣約掃描
|V|^2=100,000,000個位置;鄰接表做O(|V|+|E|)=O(30,000)的頂點加邊工作(忽略常數)。差別在於後者只掃描 實際儲存的邊。