考慮以下問題:
有 座城市,以及 條道路。每條道路連接兩個城市 。請問從城市 走到城市 ,最少必須經過幾個城市?
這道題目是一道標準的圖論問題,本章節將探討如何解決類似的題目。
一個圖(Graph)包含一些節點(Vetex)和一些邊(Edge)。我們可以一個由節點集合 和邊集合 構成的圖 ,表示為 。圖用來描述一些節點之間的連接關係。例如節點可能可以代表一些城市、車站。而每一條邊表示了兩個節點的連接關係,就像道路和鐵軌。
一張由 4 個節點和 4 條邊構成的圖
以下是圖論領域使用的名詞和基本概念的介紹:
有向圖、無向圖:
無向圖中的邊為「無向邊」。無向邊 代表從 可以到達 ,反之也可到達。 也可記為 。
有向圖中的邊為「有向邊」。有向邊 代表從 可以到達 。
有向圖
相鄰(adjacent):當節點 間存在一條邊,則稱 相鄰
度數:點 的度數為相鄰節點的數量
邊權:一條邊可以有數值 ,稱 為該邊的「邊權」。邊權可能代表兩點間的距離、花費、最大流量等,其意義視用途而定。
有邊權的無向圖
點權:一個節點可以有數值 ,稱 為該點的「點權」。點權可能代表該點的花費等,其意義視用途而定。
重邊:當點 間有不只一條邊,稱 間有「重邊」。
路徑:有起點 和終點 ,從 走到 途中經過的邊的序列。以下圖為例, 為 到 的路徑之一。
環:一個路徑的起點和終點相同,則該路徑稱為「環」。
迴路:一個路徑經過了所有的點洽一次,且起點和終點相同,則該路徑稱為「迴路」。
自環:一條邊的兩端為相同節點,稱為「自環」
連通:若點 存在一條路徑到點 ,則稱 和 「連通」。
各為一個連塊
常見圖的儲存有三種方式:直接存邊、鄰接矩陣和鄰接串列。
無向圖中的邊 代表 和 可以互相抵達,因此儲存時我們會將邊無向邊 拆解成兩條有向邊 和 。
用一個 的陣列 表示圖。若存在邊 ,記 ,否則 。若 有邊權 ,可記 。
加入一條邊 u -> v
:
遍歷 u
的相鄰節點,複雜度:
實際存法可以依照需求調整,例如若有 且邊權為 ,記 ;否則 。
用一個陣列 表示圖, 為所有和 相鄰的點。通常 為一個可變長度陣列,實作上使用 vector
。
加入一條邊 u -> v
:
遍歷 u
的相鄰節點:
直接將每一條邊的儲存在陣列裡。
加入一條邊 u -> v
:
我們通常不使用此方法來尋找 u
的相鄰節點。
最常見的需求是將一張圖的每個點、每條邊遍歷過一次。若使用鄰接矩陣,對於每個點都需要 的時間,總複雜度為 。若使用鄰接矩陣,儘管對於一個點,遍歷所有鄰居的最糟時間為 ,但經觀察可發現,每條邊至多被存取 1 到 2 次,因此總複雜度為 。大部分情況下,我們使用鄰接串列來儲存一張圖。
遍歷一張圖有很多種方法:從節點 到 、先奇數再偶數等等…。然而以上的遍歷方法對我們幫助不大。在絕大部分情況下,我們使用兩種方法來遍歷一張圖:深度優先搜尋(Depth-First Search, DFS)和廣度優先搜尋(Breadth-First Seach, BFS)。
考慮下面一張圖。我們將從 開始,經過每一條邊,走訪所有的點:
我們先從 走到 ,接著會面臨許多岔路。DFS 的精神是深度優先,因此會優先把一條分支走死,再回到岔路口走把下一條分支走死。例如:造訪 ,遇到岔路,先往下造訪 ,接著回到 ,往下造訪 ,接著回到 ,往下造訪 。
BFS 的精神則是廣度優先,當遇到岔路時,會同時走訪所有分支,每條分支從近到遠。例如:造訪 ,遇到岔路,造訪 ;接著造訪 。
下面將深入介紹 DFS 和 BFS 的特性和實作。
DFS 的核心精神:盡可能的探索一條分支,可以簡單地用遞迴來實作:
上述的函數在遇到岔路時,會往其中一個 DFS 下去,而下一層的遞迴亦復如是。當其中一條岔路 v
探索完畢, dfs(v)
會 return
回來,接著便進行下一次的迴圈,造訪下一條岔路。
BFS 會從離起點近的開始造訪。更正式的說,我們將造訪分為第 回合。而第 回合,造訪起點;下個回合,造訪所有距離為 的節點;再下個回合,造訪所有距離為 的節點……。如此,就能從近到遠造訪過所有節點,且所有分支同時進行。
BFS 的演算法概念如下:
BFS 圖示
上述演算法,可以確保我們在第 回合造訪起點 ,若且唯若 距離起點為 。正確性可用歸納法證明,在此略過。
事實上,我們可以不用為每個回合需要遍歷的節點開一個陣列來維護。善用「佇列(queue)」的特性,我們可以達成一樣的效果。假設有一佇列 儲存了第 回合的所有節點:我們依序從 拿出節點 ,並將相鄰於 、屬於回合 的節點加入 ,直到 中沒有第 回合的節點為止。
此時會發現,佇列 中包含且僅包含所有屬於回合 的節點。這是因為所有屬於回合 的節點,都是「只會且一定會」被屬於回合 的節點發現。加上佇列「先進後出」的特性,我們便能依序遍歷每個回合。而當某個回合的點都被拿出 ,卻沒有發現下個回合的節點時,演算法結束。
以下為 BFS 的實作範例:
「無根樹」為無向圖的一個特例。當一個連通無向圖沒有環,便稱「樹」。無根樹還有以下的性質,並且這些性質同時可以為樹的定義:
我們可以為樹定義一個根,稱為「有根樹」,當規定了根節點,節點間就會產生上下階層的關係。下圖是根節點為 的有根樹,我們以節點 為例說明有根樹的概念和名詞:
走訪一顆樹時,我們從根節點開始。若該樹沒有根,則使任一節點為根。樹是圖一個特例,走訪、儲存樹的程式碼和圖一樣,但我們可以稍做優化:在圖中,我們使用 陣列確保每個節點只會被造訪到一次,在樹當中則只要不往父節點走,就可以避免造訪到重複節點,因為剩下的節點一定是子節點、沒有被造訪過。
定義樹深度 :根節點到 的最短距離。給定一顆有根樹,求每個節點的樹高度。
我們考慮使用動態規劃來解決此問題。由於在樹上做 DP,我們稱這個技巧為樹 DP:
由於基底在根節點處,我們需要從自己把答案推廣至子節點。範例程式碼如下:
定義樹高度 :離 最遠的子孫節點的距離。給定一顆有根樹,求每個節點的樹高度。
我們一樣可以使用樹 DP 來解決此問題:
由於基底在葉節點處,我們必須要從子節點的答案求出自己的答案:
給定一顆無根樹,求最遠點對的距離。
我們設任一點為根。有一點 ,若要最大化以 作為最淺的節點的一條路徑的長度,一定是來自不同子節點的子樹中最遠和次遠的節點形成的點對。以下圖為例:樹直徑為以 為兩端的路徑。而 作為該路徑上深度最淺的節點,其來自不同子節點的最遠的點分別為 跟 。
因此,我們對於每個節點求 代表 的高度, 代表來自高度次高的子節點的高度 。只要遍歷過所有 ,找到最大的 便是答案。
有向無環圖(Directed acyclic graph),故名思義,為無環的有向圖。有向無環圖和樹一樣,都是重要的特例。圖上許多的問題在 DAG 和樹上,都會有更好、更優雅或更簡單的演算法。
DAG 一重要特性,就是在 DAG 上可以 DP。考慮 , 為 的轉移來源,若圖中有環則會造成循環依賴;反之在 DAG 上,可以順利完成 DP,而出度為 0 的節點便是 DP 中的「基底」(不必遞迴求的狀態)。
而若將 DP 的不同狀態,都當成一個節點,轉移視為有向邊,那也會形成一張 DAG。
拓樸排序的定義:有一張有向圖 ,求一個節點到排序,使得若有有向邊 ,則 在 之前。考慮以下問題:
有 堂課,每堂課都可能必須先修某些課後才能修。請找出任一種合法的修課順序,使得每堂課修到恰一次。
我們可以把每堂課當做一個節點,而當修 前,必須先修 ,則我們建一條邊 。求一種合法的修課順序,便是拓樸排序。
拓樸排序示例
例如在上圖中, 和 都是一種合法的修課順序,也就是拓樸排序。而顯然若圖中存在環,我們便沒有辦法求出拓樸排序;反之,若該圖是 DAG,則一定能求出一種拓樸排序。
以下講解拓墣排序的第一種求法。我們可以很自然地想到,對於那些沒有入度的節點,我們可以把他們拔除,並加入排序裡。若產生了新的沒有入度的節點,我們可以再把他們拔除,如此持續下去,直到所有點被拔除。
以上圖為例:我們可以先拔除 ;此時 和 已經沒有入度,我們可以自由選擇拔除何者,這裡拔除 ;然後依序拔除 、 、 、 。我們就可以得到拓樸排序 。
若在演算法的過程,找不到可以拔除(入度為零)的節點,但還有節點沒有被拔除,則該圖不存在拓樸排序,也就是該圖有環。
具體上,我們先求出 代表 的入度,再把所有 的 加入待移除的行列。而在拔除一個節點時,則必須拔除該節點連出去的所有邊 ,我們做 。若此時 ,則將 也加入帶移除的行列。
拓樸排序也可以用 DFS 來求。我們可以將拓樸排序的逆序,當成 DP 的計算順序。因此求拓樸排序時,可以直接在圖上 Top-Down DP。
用 DFS 來求拓樸排序的好處是,當需要再 DAG 上 DP 時,不需要先求一次拓樸排序再做 Bottom-UP,直接以上述的程式碼修改即可。