本文介紹圖的幾種基本操作:BFS,DFS,求有向圖連通分量的Tarjan算法以及拓撲排序。
圖的表示
一張圖是由若干頂點和頂點之間的邊組成的,可以形式化為G(V, E),V代表頂點集合,E代表邊集,本文中為了書寫方便,定義頂點數|V|=n,邊數|E|=m。根據邊是否有方向,可以分為有向圖和無向圖,本文討論的內容都是有向圖。下面是一個無向圖的示例。
在編碼中,圖的存儲方式常見的有兩種:鄰接表和鄰解矩陣,在C++中的寫法是:
// adjacency matrix
const int MAXV = 1e5;
int G1[MAXV][MAXV];
//adjacency lists
struct Edge1 {
int dst, dis;
};
std::vector<std::vector<Edge1> > G2;
這兩種存儲方法對應的基本操作時間復雜度如下:
空間復雜度 | 查看u,v之間邊的權值 | 遍歷所有的邊 | |
---|---|---|---|
鄰接表 | O(m+n) | O(degree(u)) | O(m + n) |
鄰接矩陣 | O(n^2) | O(1) | O(n^2) |
一般來說,鄰接表的方式使用的多一些,因為稠密圖并不是很常見,在圖的遍歷上鄰接表有優勢。但也有特定場景鄰接矩陣會更方便,如Floyd算法的實現。
圖的遍歷(BFS & DFS)
圖算法中最基礎的就是圖的遍歷了,基本方法有兩種:廣度優先搜索(BFS)和深度優先搜索(DFS)。為了方便描述,我們定義圖上從頂點i到頂點j的一條簡單路徑為一系列的點i, k1, k2, ..., j,其中沒有重復出現的頂點,連續出現的點之間有邊相連,路徑上點的個數減1代表路徑的長度。
BFS的大致思想就是先遍歷和起點最短路徑長度為0的點(起點本身),再遍歷長度為1的點(從起點出發1步可達的點),再遍歷長度為2的點....直到所有點都被訪問過。時間復雜度O(m+n),下圖是一個更形式化的描述:
在C++中,BFS常配合隊列(queue)這一數據結構實現:
std::vector<std::vector<Edge> > g;
bool vis[MAXV];
// implementation of breath-first search
std::vector<int> bfs(int s) {
std::vector<int> seq;
std::queue<int> q;
memset(vis, 0, sizeof(vis)); // initialize data
q.push(s); vis[s] = true; // insert starting node into queue
while(!q.empty()) {
int hd = q.front();
q.pop();
seq.push_back(hd);
for(int i = 0; i < g[hd].size(); i++) {
int next = g[hd][i];
if(vis[next]) continue;
q.push(next); vis[next] = true;
}
}
return seq;
}
DFS的大致思想是從當前點出發,沿著一條簡單路徑走到沒有點可以訪問,再回溯到之前訪問過的節點,沿著另一條簡單路徑走下去。直到所有點都被訪問一遍,時間復雜度同樣是O(m+n)。DFS通常通過遞歸實現。
std::vector<std::vector<Edge> > g;
std::vector<int> seq; // store the visiting order
bool vis[MAXV];
// implementation of depth-first search
void dfs(int s) {
if(vis[s]) return;
seq.push_back(s); vis[s] = true;
for(int i = 0; i < g[s].size(); i++)
dfs(g[s][i]);
}
回顧BFS/DFS的搜索過程,除了起點,每個點都是通過父節點指向它的一條邊被引入的,如果把這個過程建圖,那么這個圖中一共有n個節點和n-1條邊,且整個圖是連通的(假設圖中的邊是無向的),滿足這兩條性質的無向圖稱之為樹,通過BFS/DFS得到的樹被稱為搜索樹。
最后補充一點,在狀態數很多的搜索問題中,BFS被認為是完備的,即解如果存在,一定可以搜到,DFS則不是,可能需要和迭代加深這些策略配合。
Tarjan算法
在一次BFS或DFS中,我們其實并不能保證一定訪問到圖中的所有節點,因為有些圖可能是不連通的。我們把從一個點出發,所有可達點的集合稱為這個點所在的連通分量。給定一個無向圖,我們找所有連通分量的方法叫做灌水法(Flood Fill),其實就是對當前未訪問過的點做BFS/DFS,直到所有的點都被訪問過1次。
Tarjan算法是為了解決有向圖中類似的問題提出的。只不過有向圖中我們可以定義強連通分量,有向圖中一個強連通分量中的任意兩個點u,v都是強連通的,即存在從u到v的路徑,也存在從v到u的路徑。
很明顯,Flood Fill并不能用來求強連通分量。但只使用BFS/DFS,我們可以給出一個求給定點所在強連通分量的方法:1) 從該點出發做一次BFS/DFS;2) 把所有邊反向,再從這個點做一次BFS/DFS;3) 把兩次搜索訪問的頂點集合做一次交,就可以得到該點所在的極大強連通分量。如果用這種方法求所有強連通分量的話,需要對每個點做兩次BFS/DFS,時間復雜度為O(n^2)。更好的方法是Kosaraju算法或Tarjan算法,這里只介紹Tarjan。
Tarjan算法中,圖中每個節點維護兩個屬性:
- dfu(i):節點i在DFS中第dfu(i)個被訪問到(時間戳);
- low(i):DFS搜索樹中,以節點i為根節點的子樹中的節點集合記為T(i)。T(i)中的點在原圖中所指向的點的集合記為S(i)。S(i)中最小的時間戳就是low(i),low(i)可以用下面的遞歸式表示:
low(i) = min(dfn(i), dfn(j), low(j)) (j為i的子節點)
Tarjan算法的描述如下:
- 初始化一個空的棧和一個每訪問一個節點加1的計時器
- 對圖做DFS,每次訪問新的頂點時,設定dfn(i)為當前時間,把該節點壓棧,接著求low(i):對于還沒被訪問的后繼節點j,遞歸訪問j,
low(i) = min(dfn(i), low(j))
,對于已經訪問過的后繼節點k,low(i) = min(low(i), dfn(j))
。如果最終得到的low(i) = dfn(i)
,就把棧中當前節點以上的節點全部彈出,這些節點就是一個極大強連通分量
Tarjan算法只需要做一遍DFS,所以一定會終止,時間復雜度O(m+n)。
C++實現:
void tarjan(int u) {
dfn[u] = low[u] = ++cnt;
st[++top] = u;
instack[u] = true;
for(Edge *p = e[u]; p; p = p->next) {
int v = p->dst;
if(!dfn[v]) {
tarjan(v);
low[u] = std::min(low[u], low[v]);
}
else if(instack[v])
low[u] = std::min(low[u], dfn[v]);
}
if(dfn[u] == low[u]) {
cluster++;
int hd;
do {
hd = st[top--];
instack[hd] = false;
belong[hd] = cluster;
}
while(hd != u);
}
}
(PS:一道經典的求強連通分量的題 Networks of School
拓撲排序
在圖論中,我們經常討論有向無環圖DAG(Directed Acyclic Graph),這類圖常用來描述節點之間的依賴關系(先修課程、軟件包的安裝依賴)。對于DAG,我們可以對其進行拓撲排序。
一個DAG的拓撲排序是圖中所有頂點的一個排列:v1, v2, ..., vn,對于原圖中每條邊(vi, vj)都有i < j。下圖就展示了一個拓撲排序的例子:
拓撲排序可以通過一個很直觀的策略求得:選取當前所有入度為0的點,把它們加入拓撲序列中,再把這些點的出邊刪去,反復這兩個操作,直到所有的點都加入拓撲序列中。對應的C++實現如下:
std::vector<int> topsort(const std::vector<std::vector<int> > &g) {
int n = g.size();
std::vector<int> d(n);
for(int i = 0; i < n; i++)
for(int j = 0; j < g[i].size(); j++)
d[g[i][j]]++;
std::queue<int> q;
std::vector<int> seq;
for(int i = 0; i < n; i++)
if(!d[i]) q.push(i);
while(!q.empty()) {
int hd = q.front();
q.pop();
seq.push_back(hd);
for(int i = 0; i < g[hd].size(); i++) {
int dst = g[hd][i];
d[dst]--;
if(!d[dst]) q.push(dst);
}
}
return seq;
}
正確性證明:
結論1:算法一定會終結。隊列中最多會壓入n個頂點,每次循環都會取出第一個元素,因此最多循環n次,同時,每次循環內會訪問當前隊列第一個點的所有出邊,這個點不會再加入隊列,因此每條邊最多被訪問一次,算法總的復雜度O(m+n)。
結論2:對于連通的DAG,算法一定會返回一個長度為n的序列。如果當前序列長度不足n,說明連通圖中還有點沒有被加入序列,而此時如果隊列為空,則說明沒有入度為0的點了,這在無環圖是不可能的。這同時提醒我們,對于沒有保證連通性的圖,需要多次的拓撲排序已確保每個連通分量中的點都加入了序列。同時,如果考慮所有連通分量后,最終返回序列的長度小于n,那么說明原圖中有環。
結論3:對于返回的序列v1, v2, ..., vn,原圖中不存在邊(vi, vj)使得i > j。假如有這樣一條邊從vi指向vj,且在拓撲序列中vj在vi之前。算法中vj在被刪除時,vi還沒被刪除,因此vj的入度不會為0,矛盾!
(PS:一道不錯的拓撲排序的題 All Discs Considered
附
本文圖片來自 Lecture Slides for Alogorithm Design by Jon Kleinberg and éva Tardos.