兩三個星期沒有釋出新文章了,今天再來講一個新的資料結構:圖。
何為圖論
見名知意,圖論 (Graph Theory) 就是研究 圖 (Graph) 的數學理論和方法。圖是一種抽象的資料結構,由 節點 (Node) 和 連線這些節點的 邊 (Edge) 組成。圖論在電腦科學、網路分析、物流、社會網路分析等領域有廣泛的應用。
如下,這就是一個圖,可以看到這個圖有 \(5\) 個頂點,分別編號為 \(\{0, 1, 2, 3, 4\}\)。同時這個圖有 \(4\) 條邊,例如,在頂點 \(2\) 和 頂點 \(4\) 之間存在著一條邊。
圖的基本概念
在詳細講解圖論和有關圖論演算法之前,先來了解一下在圖論中的一些基本表述和規範。
- 圖 (Graph):圖是一種由一組頂點和一組邊組成的資料結構,記做 \(G = (V, E)\),其中 \(V\) 代表頂點集合,\(E\) 代表邊集合。
- 頂點 (Vertex):頂點是圖的基本單位,也稱為節點。
- 邊 (Edge):一條邊是連線兩個頂點的線段或弧。可以是無向的,也可以是有向的。一條邊可以記做為 \((u, v)\)。在無向圖中,若存在一條\((u, v)\),表示可以從 \(u\) 點直接走到 \(v\) 點,反之亦然。但若在有向圖中,存在一條邊 \((u, v)\),表示可以從 \(u\) 節點直接走向 \(v\) 節點,如果不存在一條邊 \(v, u\),那麼 \(v\) 節點就沒有辦法直接走向 \(u\) 節點。
- 無向圖 (Undirected Graph):圖中的邊沒有方向,即 \((u, v)\) 和 \((v, u)\) 是同一條邊。
- 有向圖 (Directed Graph/ Digraph):圖中的邊有方向,即 \((u, v)\) 和 \((v, u)\) 不是同一條邊。
- 簡單圖 (Simple Graph):表示含有重邊(兩個頂點之間的多條邊)和自環(頂點到自身的邊)的圖。
- 多重圖 (Multigraph):允許有重邊和自環的圖。
- 邊權 (Weight of an Edge):一般表示經過這一條邊的代價(代價一般是由命題人定義的)。
如下圖,就是一個有向的簡單圖(通常來說,在有向圖中邊的方向用箭頭來表示):
如下圖,就是一個無向的多重圖,其中存在兩條邊可以從頂點 \(5\) 到頂點 \(2\):
與此同時,為了方便起見,對於無向圖的處理,我們只需要在兩個頂點之間建立兩個方向相反的無向邊就可以表示一個無向圖,具體如下:
圖的表示方法
在計算機中,圖可以透過許多方式來構建和表示。總的可以分成圖的鄰接矩陣和鄰接表兩種方法(關於鏈式前向星本文不過多展開敘述,有興趣的可以自行查閱相關文件)。
圖的鄰接矩陣 (Adjacency Matrix)
若一個圖中有 \(N\) 個頂點,那麼我們就可以用一個 \(N \times N\) 的矩陣來表示這個圖。我們一般定義,若矩陣的元素 \(A_{i, j} \neq -\infty\) 表示從節點 \(i\) 到 \(j\) 有一條有向邊,其中邊的權值為 \(A_{i, j}\)。
假設存在一個有 \(3\) 個頂點的圖,並且有三條有向邊 \(E = \{(1, 2), (2, 3), (3, 2)\}\),那麼就可以用鄰接矩陣表示為:
畫成視覺化的圖就長這個樣子:
在 C++ 中,我們可以簡單地用一個二維陣列來表示:
// 定義一個矩陣。
int map[50][50];
// 將所有的邊初始化為負無窮大。
for (int i=1; i<=50; i++)
for (int j=1; j<=50; j++)
map[i][j] = -0x7f7f7f7f;
// 建邊,其中所有的邊權為1。
map[1][2] = map[2][3] = map[3][2] = 1;
圖的鄰接表 (Adjacency List)
鄰接表本質上就是用連結串列表示圖。陣列的每個元素表示一個頂點,元素的值是一個連結串列,連結串列中儲存該頂點的所有鄰接頂點。假設存在一個有 \(4\) 個頂點的圖,並且有四條有向邊 \(E = \{(1, 2), (2, 3), (3, 2), (3, 4)\}\),那麼就可以用鄰接表表示為:
畫成視覺化的圖就長這個樣子:
在 C++ 中,我們可以使用 STL模板庫 中的 vector
來實現:
#include <vector>
vector<int> G[50]; // 建圖。
G[1].push_back(2);
G[2].push_back(3);
G[3].push_back(2);
G[3].push_back(4);
一般情況下,推薦使用鄰接表的方式來存圖,因為使用鄰接矩陣比較浪費空間。在頂點數量非常多但邊非常少的圖中,\(N^2\) 的時空複雜度會導致 MLE 或 TLE 等問題。
圖的各種性質
- 度數 (Degree):一個頂點的度是連線該頂點的邊的數量。在有向圖中,有 入度 (Indegree) 和 出度 (Outdegree) 之分(具體例子見後文)。
- 路徑 (Path):從一個頂點到另一個頂點的頂點序列,路徑上的邊沒有重複。
- 迴路 (Cycle):起點和終點相同的路徑。
- 連通圖 (Connected Graph):任意兩個頂點之間都有路徑相連的無向圖。
- 強連通圖 (Strongly Connected Graph):任意兩個頂點之間都有路徑相連的有向圖。
對於下面這個無向圖不連通圖,頂點 \(1\) 的度數為 \(1\);頂點 \(2\) 的度數為 \(2\);頂點 \(3\) 的度數為 \(1\);頂點 \(4\) 的度數為 \(0\)。同時,由於 \(4\) 號頂點沒有度數,所以該頂點沒有辦法到達任何一個其他的頂點,所以這個圖是一個不連通圖:
如下圖,就是一個有向不強連通圖。其中,頂點 \(1\) 的入度為 \(0\),出度為 \(2\);頂點 \(2\) 的入度為 \(1\),出度也為 \(1\);頂點 \(3\) 的入度為 \(2\),但出度為 \(0\)。由於頂點 \(1\) 和頂點 \(2\) 可以走到頂點 \(3\),但頂點 \(3\) 沒有辦法走到頂點 \(1\) 或頂點 \(2\),因此下面的圖不是一個強連通圖:
對於下圖來說,\(1\to 2\to 3\to 4\) 是一條從頂點 \(1\) 到頂點 \(4\)的路徑。\(2\to 3\to 4 \to 2\to 3\) 就不是一個路徑,因為相同的邊 \((2, 3)\) 被多次走到了。\(1\to 2\to 3\to 1\) 就是一個迴路,因為這個路徑的起點和終點相同:
圖的遍歷
圖通常採用 深度優先搜尋/ 廣度優先搜尋 這兩個演算法來遍歷。其中深度優先演算法是最常見的遍歷演算法。
對於一個用 鄰接矩陣 儲存的圖,其深度優先搜尋遍歷的 C++ 程式碼如下:
int vis[105], map[105][105];
void dfs(int node){
if (vis[node]) return ;
vis[node] = 1;
cout << node << endl;
for (int i=1; i<=n; i++)
if (map[node][i] != -0x7f7f7f7f)
dfs(i);
return ;
}
// 函式呼叫:dfs(1); 表示從1號頂點開始遍歷。
對於一個用 鄰接表 儲存的圖,其深度優先搜尋遍歷的 C++ 程式碼如下:
#include <vector>
vector<int> G[105];
int vis[105];
void dfs(int node){
if (vis[node]) return ;
vis[node] = 1;
cout << node << endl;
for (int to : G[node])
dfs(to);
return ;
}
// 函式呼叫:dfs(1); 表示從1號頂點開始遍歷。
廣度優先搜尋的方式也類似:
#include <queue>
vector<int> G[105];
int vis[105];
void bfs(int node){
queue<int> que;
que.push(node);
while(!que.empty()){
int t = que.front();
cout << t << endl;
que.pop();
for (int to : G[node]){
if (!vis[to]) {
vis[to] = 1;
que.push(to);
}
}
}
return ;
}
// 函式呼叫:bfs(1); 表示從1號頂點開始遍歷。
對於判斷無向圖的連通性,我們只需要從任意一個點開始跑一遍深搜或者廣搜就行了。如果所有頂點的 vis
都被標記了,則證明圖是聯通的,否則圖就是不連通的。
例題講解
P3916 圖的遍歷
模板題目,從每一個頂點開始用深搜遍歷一遍就可以了。但從每一個點考慮能走到的最大點比較麻煩,一個更優的解決辦法是反向建邊,從最大的點開始遍歷,這樣子就可以一次性計算出多個結果。
#include <iostream>
#include <algorithm>
#include <vector>
#include <cstring>
using namespace std;
const int N = 10005;
int n, m, ans, vis[N];
vector<int> G[N];
void dfs(int node, int d){
if (vis[node]) return ;
vis[node] = d;
ans = max(node, ans);
for (int to : G[node])
dfs(to, d);
return ;
}
int main(){
cin >> n >> m;
for (int i=0, u, v; i<m; i++){
cin >> u >> v;
G[v].push_back(u); // 反向建邊。
}
for (int i=n; i>=1; i--) dfs(i, i);
for (int i=1; i<=n; i++)
cout << vis[i] << ' ';
return 0;
}
P5318 【深基18.例3】查詢文獻
也是一道模板題目,正常遍歷即可。
#include <iostream>
#include <algorithm>
#include <vector>
#include <queue>
using namespace std;
const int MAXN = 100005;
int n, m;
int vis1[MAXN], vis2[MAXN];
queue<int> que;
vector<int> G[MAXN];
void dfs(int node, int current){
vis1[node] = 1;
cout << node << ' ';
if (current == n) return ;
for (int i=0; i<G[node].size(); i++){
if (vis1[G[node][i]]) continue;
dfs(G[node][i], current+1);
}
return ;
}
void dfs(int node){
vis2[node] = 1;
que.push(node);
while(que.size()){
int t = que.front();
cout << t << " ";
for (int i=0; i<G[t].size(); i++){
if (vis2[G[t][i]]) continue;
vis2[G[t][i]] = 1;
que.push(G[t][i]);
}
que.pop();
}
return ;
}
int main(){
cin >> n >> m;
for (int i=0; i<m; i++){
int t1, t2;
cin >> t1 >> t2;
G[t1].push_back(t2);
}
for (int i=1; i<=n; i++)
sort(G[i].begin(), G[i].end());
dfs(1, 0), cout << endl, dfs(1);
return 0;
}
番外 - 圖的常見演算法
更多關於圖論的演算法,請持續關注後續更新。
- 深度優先搜尋 (DFS):適用於遍歷圖和檢測圖中的迴路。
- 廣度優先搜尋 (BFS):適用於尋找最短路徑(無權圖)。
- Dijkstra 演算法:適用於加權圖中尋找單源最短路徑。
- Bellman-Ford 演算法:適用於有負權邊的圖中尋找單源最短路徑。
- Floyd-Warshall 演算法:適用於尋找所有頂點對之間的最短路徑。
- Kruskal 演算法:用於求解最小生成樹 (MST - Minimum Spanning Tree)。
- Prim 演算法:另一種求解最小生成樹的方法。
- 拓撲排序 (Topological Sorting):適用於有向無環圖 (DAG),用於任務排程等應用。
- Tarjan 演算法:用於求解圖中的強連通分量、割點、橋。