現實生活中有很大一類問題可以用簡潔明了的圖論語言來描述,可以轉化為圖論問題。
相關定義
圖可以表示為G=(V, E)
- 頂點集合V(非空但有限)
- 連接頂點的邊的集合E(可以為空)
通常用|V|表示頂點的數量,用|E|表示邊的數量
對于頂點x,y
- 無向邊的表示:{x, y}(對稱,無序)
- 有向邊的表示:(x, y) 與(y, x)(有序)
有時邊具有權值(weight/ cost)
路徑
路徑的長 = 該路徑上的邊數
- 如果路徑不包含邊,則路徑的長為0,如一個頂點到其自身
- 環:路徑x->x長至少為1
- 圈:對于有向圖,路徑x->x長至少為1
無圈的有向圖DAG - Directed Acyclic Graph
連通
- 連通圖(無向圖):無向圖每個頂點到其他每個頂點都存在一條路徑
- 強連通(有向圖):有向圖每個頂點到其他每個頂點都存在一條路徑
- 弱連通(有向圖):有向圖的基礎圖(即其邊去掉方向所形成的的圖)是連通的
- 完全圖:每一對頂點間都存在一條邊的圖
- 連通部件:每個都是原圖的一個子圖,并且內部頂點間彼此相連通,但卻與部件外,即原圖中的其他頂點間沒有邊可達,如下圖的
{A, B, E, I, J} {C, D, G, H, K, L} {F}
度
- x的入度:以頂點x為終點的邊數目
- x的出度:以頂點x為起點的邊數目
圖的表示
1. 鄰接矩陣
用一維數組存儲圖中頂點的信息,用二維數組A表示途中各頂點之間的鄰接關系或者權值。
對于每條邊(x, y),
- A[x, y] = 1/ weight(如果存在該條邊)
- A[x, y] = 0/ 很大或很小的權值 (如果不存在該條邊)
優點:簡單,在常量時間內確定某條邊是否存在
缺點:空間需求O(|V|^2),適合稠密圖(E = O(|V|^2)),然而大部分應用情況并非如此
2. 鄰接表(標準方法)
對于每個頂點,使用一個表存放所有鄰接的頂點。
優點:空間需求O(|E| + |V|);若邊有權,那么附加信息也可以存儲在鄰接表中。
缺點:確定某條邊是否存在的操作需要遍歷一個頂點的鄰接表
對于非常稀疏的圖,當使用ArrayList時程序員可能需要從一個比默認容量更小的容量開始ArrayList,否則可能造成明顯的空間浪費。
鄰接表的實現:1. 鄰接表作為Vetex的映射(Map); 鄰接表作為Vetex的數據成員
3. 其他
鄰接多重表、十字鏈表法、邊集數組
深度優先搜索
基本問題:給定圖中的一個頂點,從該頂點出發,圖中那些部分是可達的?
procedure explore(G, v)
Input: G={V, E} is a graph; v in V
Output: visited(v) is set to true for all nodes u reachable from v
visited(v) = true
previsit(v)
for each edge(u, v) in E:
if not visited(u):explore(u)
postvisit(v)
procedure dfs(G)
for all v \in V:
visited(v) = false
for all v \in V:
if not visited(v):explore(v)
previsit/postvisit 當一個頂點被首次訪問/最后一次訪問時對其進行的操作
每次在procedure explore中調用procedure explore一次,就能夠生成一個連通部件。若僅有一個連通部件,則為連通圖
效率:由于對已訪問頂點的維護,因此每個頂點僅調用procedure explore
一次,在這其中進行了以下步驟:
- 一些常量時間的操作 - 標記一個頂點被訪問,以及previsit/postvisit操作
- 一個對鄰邊進行遍歷的循環,以確定該遍歷過程能否將整個搜索過程引向未曾訪問的區域
T = T(Step 1) + T(Step 2) = T(|V|) + T(|E|)
線性時間
編號
若對頂點訪問添加時間信息:
procedure previsit(v)
pre[v] = clock;
clock = clock + 1;
procedure postvisit(v)
post[v] = clock;
clock = clock + 1;
顯然,因為棧的特征,對于任意頂點u和v,[pre[u],post[u]]和[pre[v],post[v]]兩個區間要么彼此分離,要么一個包含另一個
對于有向圖,我們可以對上圖的邊進行分類:
- 樹邊:DFS樹的實際組成部分;
- 前向邊:DFS樹從一個頂點指向該頂點的一個非子頂點后裔的邊;
- 回邊:DFS樹從一個頂點指向其祖先的邊;
- 橫跨邊:從一個頂點指向一個已經完全訪問過(已進行過postvisit)的頂點。
邊(u, v) | 邊的類型 |
---|---|
pre(u)<pre(v)<post(v)<post(u) | 樹邊/前向邊 |
pre(v)<pre(u)<post(u)<post(v) | 回邊 |
pre(v)<post(v)<pre(u)<post(u) | 橫跨邊 |
基于上述定義,可得,有向圖含有一個環當且僅當深度優先搜索過程中探測到一條回邊
無環性、可線性化以及無回邊性實際上是一回事
see also graph.dfs.DeepFirstSearch@judgeBackEdge
迷宮探索(無向圖/深度優先搜索)
從一個給定的地點開始迷宮行走,一旦到達某個關節點(相當于頂點),您將面對一些路徑(相當于邊)供您選擇。如果選擇不慎,結果可能使您不停地兜圈子,或是使您忽視迷宮中一些可行的路徑。
迷宮探索問題 轉化-> 可達性問題
關鍵點:
物品 | 作用 | 真實表現 |
---|---|---|
粉筆 | 標記訪問過的頂點 | 每個頂點維護一個布爾量 |
細繩 | 將您帶回出發位置 | 使用棧/或者通過遞歸隱含地使用棧:延伸以到達一個新頂點;回溯到曾經訪問的頂點 |
方式1
利用粉筆與細繩
see also graph.ExploreMaze
方式2
假設迷宮的起始頂點和出口頂點分別標記為start
和end
,并將無向圖視作有向圖。那么,我們可以得知路徑(u, v)
是一條樹邊/前向邊,組成該條路徑的也必是若干條樹邊/前向邊。
那么可從pre[]與post[]數組中得出:
- pre/post[start/end]判斷是否為樹邊/前向邊 - 迷宮是否探索成功
- 從start和end兩端,夾逼中間的樹邊/前向邊 - 得出中間路徑。
特別地,可直接在剛找到end的時間點進行
深度優先實現: TODO
拓撲排序(有向無圈圖)
如果存在一條x到y的路徑,那么在排序中y就出現在x的后面。
聯想: 課程先修結構
方式1
基本思想:
?
1. find a vertex of zero indegree
2. assign the vertex a topo num
3. decrease the indegree of vertexes which are adjacent to the vertex.
4. jump to step 1
until the topo num runs out
step 1處潛在地查看了所有頂點,可利用隊列存儲所有入度為0的頂點進行優化。
1. work out the queue containing all the vertexes of zero indegree
2. dequeue the queue to get a vertex, and assign the vertex a topo num
3. decrease the indegree of vertexes which are adjacent to the vertex, `if the indegree decreased to be zero, than add this vertex to the queue`
4. jump to `step 2`
see also graph.TopologySort
無圈通過是否有入度為0的頂點判斷
方式2
基本思想:
簡單對圖頂點執行深度優先搜索,按照頂點的post值降序
進一步,我們可知道,post值最小的頂點一定出現在拓撲排序末尾,一定是匯點 - 出度為0。 對稱地,post值最大的一定是源點 - 入度為0。
see also graph.dfs.DfsSolveTopoSort@topoSort
無圈通過回邊判斷
廣度優先搜索(最短路徑樹)
類似于對樹的層序遍歷(level-order traversal)
procedure bfs(G, s)
Input: Graph G = {V, E}, s \in V
Output: For all vertexes u reachable form s, dist(u) is set to tht distance from s to u
for all u in V
dist(u) = \infty
dist(s) = 0
Q = [s](queue containing just s)
while Q is not empty
u = eject(Q)
for all edges (u, v) in E
dist(v) = \infty:
inject(Q, v)
dist(v) = dist(u) + 1;
T = 2|V|次隊列操作 + 訪問邊 = O(|V| + |E|)
局限性:邊均為單位長度
see also graph.bfs.BreadthFirstSearch
單源最短路徑
當前,還不存在找出從s到一個頂點的路徑比找出從s到所有頂點路徑更快(快得超出一個常數因子)的算法
邊為正整數
引入虛頂點
通過在G中的邊引入“虛”頂點,分解為單位長度的小段。此時可以通過運行bfs計算最短路徑,然而存在效率問題。
對感興趣的頂點分別設置鬧鐘(該頂點的預計到達時間)。事實上,隨著廣度優先搜索的進行,會發現一些捷徑,相應頂點的預計到達時間會發生松弛。
- s設定的鬧鐘為時刻0,從s到u的距離是T
- 對于G中u的每個鄰居v:
1. 如果尚未給v設定鬧鐘,則為其設定鬧鐘時刻為T+l(u, v)
2. 如果v的鬧鐘時刻設定比T+l(u, v)要晚,則將它設定為T+l(u, v)
Dijkstra算法
上述思想即可自然引出Dijkstra算法,該算法可用于解決單源無負邊最短路徑問題。
數據結構:優先隊列,并支持以下操作
- 插入新的元素
- 減少某個特定元素的鍵值
- 返回并刪除鍵值最小的元素
- 用給定的元素以及給定的元素鍵值構建一個隊列
額外數組prev
存儲該頂點之前的頂點
通過這些后向指針,從而方便地重建所需的最短路徑
procedure dijkstra(G, l, s)
Input: Graph G=(V, E), s \in V)
positive edge lengths{l_e:e \in E}
Output: For all vertexes u reachable form s, dist(u) is set to tht distance from s to u
for all u \in V
dist(u) = \infty
pre(u) = nil
dist(s) = 0
PQ = makequeue(V) (using dist-values as keys)
while PQ is not empty
u = deletemin(PQ)
for all edges (u, v) \in E
if dist(v) > dist(u) + l(u, v)
dist(v) = dist(u) + l(u, v)
pre(v) = u
decreasekey(PQ, v)
Notice: 因初始時pre(u) = nil,因此makequeue時不能搶占該邊界次數(即直接將與s相鄰的頂點直接加入)。
T = |V|次插入操作 + |V|次deletemin操作 + |E|次decreasekey操作
T嚴重依賴于優先隊列所用的實現方法,例如數組、二分堆、d堆、Fibonacci堆。
若采用數組實現優先隊列,則T = O(|V|^2 + E)
see also 數組實現graph.bfs.DijkstraDfsWithArray
若采用二分堆實現優先隊列,則T = O((|V|+|E|)log|V|)
堆的高度為
log|V|
see also 二分堆實現graph.bfs.DijistraDfsWithHeap
二分堆由最小堆實現,decreasekey操作首先需要找到v在二分堆中的位置。考慮到存在value
相同的元素,則找位置需要耗費O(|V|)復雜度,則將使得T從原來的O((|V|+|E|)log|V|)
降為O(|V|log|V|+|V||E|)
。
- 一種好的做法是
記錄(v, dist(v))在堆中的位置,并在優先隊列改變時更新
可以考慮使用配對堆(pairing heap)
- 另一種方法是在每次dist(v)變化時把(v, dist(v))插入到優先隊列中去。這樣,在優先隊列中的每個頂點可能有多于一個的代表。當deletemin操作把最小的頂點從優先隊列中刪除時,必須檢查以肯定它不是known的。如果它是,則忽略它,并執行另一次deletemin。
因隊列的大小可能達到|E|+|V|。由于|E|<=|V|^2, log|E|<=2log|V|。另外,可能需要|E|次而不是|V|次deletemin操作。
T = (|V|+|E|)*log(|E|+|V|) = O((|V|+|E|)log|V|)
。時間減慢,時間復雜度不變。
Bellman-Ford算法(可處理負邊、可檢測負環)
Dijkstra按照正確的順序更新所有邊。對于標記過的點不在進行更新,即使有負權導致最短路徑的改變也不會進行重新計算。
而Bellman-Ford算法更新所有邊,每條邊更新|V|-1次.
考慮到對每條邊進行第1次松弛操作的時候,實際上是考慮至多經過(不考慮源點終點)0個點的路徑;對每條邊進行第2次松弛操作的時候,實際上是考慮至多經過1個點的路徑...而對于無負邊的圖而言,最短路徑至多經過n-2個點,因此在|V|-1次迭代后,可以得到最短路徑
procedure BellmanFord(G, l, s)
Input: Graph G=(V, E), s \in V
edge lengths{l_e:e \in E}
Output: For all vertexes u reachable form s, dist(u) is set to tht distance from s to u
for all u \in V
dist(u) = \infty
pre(u) = nil
dist(s) = 0
repeat |V|-1 times:
for all e(u, v) \in E:
if(dist(v) > dist(u) + e(u,v))
dist(v) = dist(u) + e(u,v)
pre(v) = u
可檢測負環
- 在|V|-1次迭代后再執行一次迭代過程。若這最后一次迭代中有某個dist的值被減少,那么圖中存在一個負環。
- 圖中出現負環,最短路徑問題本身就沒有意義
T = O(|V||E|)
優化
- 若在該輪,所有的邊都沒有relax的話,算法便可結束。因此有時候無需經過|V|-1次迭代
- 每次僅對最短路徑估計值發生變化了的頂點的所有出邊執行松弛操作
可用于解m個n元約束組,即相當于n個頂點m條邊。
復雜度為O(m*n)
TODO 差分約束系統和SPAF
基于拓撲排序的最短路徑(有向無圈圖)
通過深度優先搜索線性化有向無環圖,然后按照得到的頂點順序訪問頂點,對從當前訪問頂點出發的邊執行更新操作。
procedure dag-shortest-paths(G, l, s)
Input: Graph G=(V, E), s \in V
edge lengths{l_e:e \in E}
Output: For all vertexes u reachable form s, dist(u) is set to tht distance from s to u
for all u \in V
dist(u) = \infty
pre(u) = nil
dist(s) = 0
Linearize G
for each u \in V in linearized order:
for all edges (u, v) \in E:
if(dist(v) > dist(u) + e(u,v))
dist(v) = dist(u) + e(u,v)
pre(v) = u
當一個頂點v被選取以后,按照拓撲排序的法則它沒有從unknown頂點發出的進入邊,因此它的距離dist(v)不再被降低。
T = O(|V| + |E|)
see also graph.TopoSortSolveShortPath
單源最長路徑(有向無圈圖)
對于一般的圖,最長路徑問題通常沒有意義,因為可能有正值圈(等價于最短路徑問題中的負值圈)存在。對于DAG而言,將每條邊的長度設為其負值,使用前述的拓撲排序方法,即可得到單源最長路徑。
see also graph.TopoSortSolveLongPath
應用
- 最早完成時間:從節點1開始,按照拓撲順序,計算單源最長路徑
- 最晚完成時間:從節點n開始,按照反序的拓撲順序,計算單源最短路徑
網絡流問題
給定有向圖G=(V, E),其邊容量為e(u, v)。
容量代表一個管道允許通過的水的最大流量。
有兩個頂點,發點s和收點t,最大流問題就是確定從s到t可以通過的最大流量。
必須滿足流守恒:進入的都必然流出
方法:構造構造流圖(算法終止時包含最大流)、殘余圖(表示對于每條邊的剩余流容量)。尋找增長通路,構造反向弧。當沒有增長通路時,算法終止。
不需要保證是無圈圖
增長通路:殘余圖中從s到t的一條路徑。該路徑上的最小邊值就是可以添加到路徑每一邊上的流的量。
反向弧:對于流圖中具有流f(u, v)的每一邊(u, v),我們在殘余圖中添加一條容量為f(u, v)的反向邊(v, u)。
使算法可以解除/部分解除它原來的決定,使得可以找到最優解
通過bfs尋找增廣路徑,T = O(|E|^2*|V|)
see also graph.bfs.NetWorkFlowEk
see more
參考文獻
寫在最后
- 立個Flag,
TODO
will be done some day。 - 渣代碼,且輕噴?:worried:?。