圖論基礎

kimi0705發表於2024-08-18

定義與記號

涉及常見或可能用到的概念的定義。關於更多,見參考資料。

基本定義

  • :一張圖 \(G\) 由若干個點和連線這些點的邊構成。稱點的集合為 點集 \(V\),邊的集合為 邊集 \(E\),記 \(G = (V, E)\)
  • :圖 \(G\) 的點數 \(|V|\) 稱為 ,記作 \(|G|\)
  • 無向圖:若 \(e \in E\) 沒有方向,則稱 \(G\)無向圖。無向圖的邊記作 \(e = (u, v)\)\(u, v\) 之間無序。
  • 有向圖:若 \(e \in E\) 有方向,則稱 \(G\)有向圖。有向圖的邊記作 \(e = u \to v\)\(e = (u, v)\)\(u, v\) 之間有序。無向邊 \((u, v)\) 可以視為兩條有向邊 \(u \to v\)\(v \to u\)
  • 重邊:端點和方向(有向圖)完全相同的邊稱為 重邊
  • 自環:連線相同點的邊稱為 自環

相鄰相關

  • 相鄰:在無向圖中,稱 \(u,v\) 相鄰 當且僅當存在 \(e=(u,v)\)
  • 鄰域:在無向圖中,點 \(u\)鄰域 為所有與之相鄰的點的集合,記作 \(N(u)\)
  • 鄰邊:在無向圖中,與 \(u\) 相連的邊 \((u, v)\) 稱為 \(u\)鄰邊
  • 出邊 / 入邊:在有向圖中,從 \(u\) 出發的邊 \(u \to v\) 稱為 \(u\)出邊,到達 \(u\) 的邊 \(v \to u\) 稱為 \(u\)入邊
  • 度數:一個點的 度數 為與之關聯的邊的數量,記作 \(d(u)\)\(d(u) = \sum_{e \in E} ([u = eu] + [u = ev])\)。每個點的自環對其度數產生 2 的貢獻。
  • 出度 / 入度:在有向圖中,從 \(u\) 出發的邊的數量稱為 \(u\)出度,記作 \(d^+(u)\);到達 \(u\) 的邊的數量稱為 \(u\)入度,記作 \(d^-(u)\)

路徑相關

  • 途徑:連線一串結點的序列稱為 途徑,用點序列 \(v_0 \cdots v_k\) 和邊序列 \(e_1 \cdots e_k\) 描述,其中 \(e_i = (v_{i-1}, v_i)\)。通常寫為 \(v_0 \to v_1 \to \cdots \to v_k\)
  • :不經過重複邊的途徑稱為
  • 迴路\(v_0 = v_k\) 的跡稱為 迴路
  • 路徑:不經過重複點的跡稱為 路徑,也稱 簡單路徑。不經過重複點比不經過重複邊強,所以不經過重複點的途徑也是路徑。注意題目中的簡單路徑可能指跡。
  • :除 \(v_0 = v_k\) 外所有點互不相同的途徑稱為 ,也稱 簡單環

連通性相關

  • 連通:對於無向圖的兩點 \(u, v\),若存在途徑使得 \(v_0 = u\)\(v_k = v\),則稱 \(u, v\) 連通
  • 弱連通:對於有向圖的兩點 \(u, v\),若將有向邊改為無向邊後 \(u, v\) 連通,則稱 \(u, v\) 弱連通
  • 連通圖:任意兩點連通的無向圖稱為 連通圖
  • 弱連通圖:任意兩點弱連通的有向圖稱為 弱連通圖
  • 可達:對於有向圖的兩點 \(u, v\),若存在途徑使得 \(v_0 = u\)\(v_k = v\),則稱 \(u\) 可達 \(v\),記作 \(u \Rightarrow v\)
  • 關於點雙連通 / 邊雙連通 / 強連通,見對應章節。

特殊圖

  • 簡單圖:不含重邊和自環的圖稱為 簡單圖
  • 基圖:將有向圖的所有有向邊替換為無向邊得到的圖稱為該有向圖的 基圖
  • 有向無環圖:不含環的有向圖稱為 有向無環圖,簡稱 \(\texttt{DAG}\)\(\texttt{Directed Acyclic Graph}\))。
  • 完全圖:任意不同的兩點之間恰有一條邊的無向簡單圖稱為 完全圖\(n\) 階完全圖記作 \(K_n\)
  • :不含環的無向連通圖稱為 。樹是簡單圖,滿足 \(|V|=|E|+1\)。若干棵(包括一棵)樹組成的連通塊稱為 森林。相關知識點見 “樹論”。
  • 稀疏圖 / 稠密圖\(|E|\) 遠小於 \(|V|^2\) 的圖稱為 稀疏圖\(|E|\) 接近 \(|V|^2\) 的圖稱為 稠密圖。這兩個概念沒有嚴格定義,用於討論時間複雜度為 \(O(|E|)\)\(O(|V|^2)\) 的演算法。

子圖相關

  • 子圖:滿足 \(V' \subseteq V\)\(E' \subseteq E\) 的圖 \(G' = (V', E')\) 稱為 \(G = (V, E)\)子圖,記作 \(G' \subseteq G\)
  • 匯出子圖:選擇若干個點以及兩端都在該點集的所有邊構成的子圖稱為該圖的 匯出子圖。匯出子圖的形態僅由選擇的點集 \(V'\) 決定,稱點集為 \(V'\) 的匯出子圖為 \(V'\) 匯出的子圖,記作 \(G[V']\)
  • 生成子圖\(|V'| = |V|\) 的子圖稱為 生成子圖
  • 極大子圖(分量):在子圖滿足某性質的前提下,稱子圖 \(G'\)極大 的,當且僅當不存在同樣滿足該性質的子圖 \(G''\)\(G' \subset G'' \subseteq G\)。稱 \(G'\) 為滿足該性質的 分量,如連通分量,點雙連通分量。極大子圖不能再擴張。例如,極大的連通的子圖稱為原圖的連通分量,也就是我們熟知的連通塊。

約定

  • 一般記 \(n\) 表示點集大小 \(|V|\)\(m\) 表示邊集大小 \(|E|\)

拓撲排序

計算方法

常用的拓撲排序演算法包括基於深度優先搜尋(\(\texttt{DFS}\))的方法和基於入度表(\(\texttt{Kahn}\) 演算法)的方法。這裡,我將描述基於入度表的方法,這種方法利用佇列來實現:

  1. 初始化入度表:遍歷圖中所有的邊,統計每個頂點的入度(即指向該頂點的邊的數量)。
  2. 將入度為 \(0\) 的頂點入隊:所有在圖中入度為 \(0\) 的頂點,都可以作為拓撲排序的起點,將它們加入到一個佇列中。
  3. 迴圈執行以下步驟,直到佇列為空
    • 從佇列中取出一個頂點 \(u\)(即當前排序的下一個頂點),並將其輸出為結果序列的一部分。
    • 遍歷從頂點 \(u\) 出發的所有邊 \((u, v)\),將每個相鄰頂點 \(v\) 的入度減 \(1\)(表示邊 $ (u, v) $ 被移除)。如果某個頂點 \(v\) 的入度降為 \(0\),則將 \(v\) 入隊。

\(\texttt{DAG}\) 的拓撲序性質很好,常用於解決建圖題或圖論型別的構造題,常常會將圖轉化為 \(\texttt{DAG}\),進行 \(\texttt{dp / dfs}\) 求解。

例 1: B3644 【模板】拓撲排序 / 家譜樹

題目描述

有個人的家族很大,輩分關係很混亂,請你幫整理一下這種關係。給出每個人的後代的資訊。輸出一個序列,使得每個人的後輩都比那個人後列出。

\(1\) 行一個整數 \(N\)\(1 \le N \le 100\)),表示家族的人數。接下來 \(N\) 行,第 \(i\) 行描述第 \(i\) 個人的後代編號 \(a_{i,j}\),表示 \(a_{i,j}\)\(i\) 的後代。每行最後是 \(0\) 表示描述完畢。

輸出一個序列,使得每個人的後輩都比那個人後列出。如果有多種不同的序列,輸出任意一種即可。

程式碼

// B3644 【模板】拓撲排序 / 家譜樹
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 10000;                                     // 最大頂點數,根據需要修改
int n, x;                                                   // 頂點數
vector<int> Edge[MAXN];                                     // 鄰接表表示圖
int in_degree[MAXN];                                        // 入度陣列
void toposort() {
    queue<int> Q;
    for(int i = 1; i <= n; i++) 
        for(int j : Edge[i]) in_degree[j]++;                // 初始化入度表
    for(int i = 1; i <= n; i++) 
        if(in_degree[i] == 0)  Q.push(i);                   // 將所有入度為0的頂點入隊
    while(!Q.empty()) {                                     // 進行拓撲排序
        int u = Q.front(); Q.pop();
        cout << u << " ";                                   // 輸出頂點
        for(int i : Edge[u]) {                              // 遍歷u的所有鄰接點
            in_degree[i]--;
            if(in_degree[i] == 0) 
                Q.push(i);
        }
    }
    cout << endl;
}
int main() {
    cin >> n;
    for (int i = 1; i <= n; i++)
        while (cin >> x && x) 
            Edge[i].push_back(x);
    toposort();
    return 0;
}

最短路問題演算法

\(\texttt{Floyd}\) 演算法

基本原理

Floyd-Warshall 演算法是一種計算圖中所有頂點對之間最短路徑的演算法。

演算法流程

  1. 初始化距離矩陣,對角線為0,其他為兩點之間的邊權重,若無直接邊則為無窮大。
  2. 對每個頂點 $k $,更新所有頂點對 $ (i, j) $ 的距離:dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j])
  3. 重複步驟2,直到所有點都被考慮過。

適用場景

適用於計算任意兩點間的最短路徑,特別是點數量不是很大時效果好。

程式碼

void floydWarshall() {
    for (int k = 1; k <= n; k++) 
        for (int i = 1; i <=n; i++) 
            for (int j = 1; j <= n; j++) 
                if (dist[i][k] + dist[k][j] < dist[i][j]) 
                    dist[i][j] = dist[i][k] + dist[k][j];
}

\(\texttt{Dijkstra}\) 演算法

基本原理

\(\texttt{Dijkstra}\) 演算法用於在加權圖中找到一個頂點到其他所有頂點的最短路徑。

演算法流程

  1. 初始化距離陣列,源點距離為 \(0\),其餘為無窮大。
  2. 使用優先佇列(或堆)來儲存所有節點,優先順序為節點的當前距離。
  3. 從佇列中取出距離最小的節點,更新其相鄰節點的距離。
  4. 重複步驟3,直到佇列為空或找到目標節點。

適用場景

適用於無負權邊的圖。

\(\texttt{SPFA}\) 演算法

關於 \(\texttt{SPFA}\), 他 __ 了。

基本原理
\(\texttt{SPFA}\)\(\texttt{Bellman-Ford}\) 演算法的一種改進,用於求解單源最短路徑問題。它透過使用佇列最佳化了演算法的效率。

演算法流程

  1. 初始化距離陣列,源點距離為0,其餘為無窮大。
  2. 將源點入隊。
  3. 當佇列非空時,取出隊首元素,遍歷其所有出邊。
  4. 如果透過當前點可以使得到達某個點的距離更短,則更新距離並將該點入隊(如果它當前不在佇列中)。
  5. 重複步驟3和4,直到佇列為空。

適用場景
適用於含負權邊但無負權迴路的圖。

\(\texttt{Tarjan}\) 演算法

\(\texttt{Trajan}\)\(\texttt{SCC}\)

演算法描述

  • \(\texttt{Tarjan}\) 演算法用於在有向圖中尋找強連通分量(\(\texttt{SCC}\))。演算法透過深度優先搜尋(\(\texttt{DFS}\))遍歷圖,並利用棧維護訪問過的頂點,從而在回溯時能夠識別並構成強連通分量。

程式碼解釋

  • s.push(x), vis[x] = 1;:當前頂點 x 入棧,並標記為已訪問。
  • dfn[x] = low[x] = ++tim;:為頂點 x 分配一個訪問編號和最小可回溯編號。
  • 遍歷 x 的每個鄰接頂點 i
    • 如果 i 未被訪問(!dfn[i]),遞迴呼叫 tarjan(i),並更新 xlow 值。
    • 如果 i 已在棧中(vis[i]),則更新 xlow 值。
  • 如果 dfn[x] == low[x],說明找到了一個強連通分量的根節點:
    • 透過迴圈將棧中的元素出棧,直到遇到 x,同時為出棧的頂點分配相同的強連通分量編號,並累加對應的值。

複雜度分析

  • 時間複雜度:\(O(V + E)\),其中 \(V\) 是頂點數,\(E\) 是邊數。
  • 空間複雜度:\(O(V)\),主要是用於儲存棧、訪問標記、時間戳等資訊。

透過這個函式實現,\(\texttt{Tarjan}\) 演算法能有效地在有向圖中識別所有的強連通分量,並能處理每個分量的累計值問題。希望這樣的筆記能幫助您更好地理解和使用 \(\texttt{Tarjan}\) 演算法。

程式碼

void tarjan(int x) {
	s.push(x), vis[x] = 1;
	dfn[x] = low[x] = ++tim;
	for (int i : Edge[x]) {
		if (!dfn[i]) {
			tarjan(i);
			low[x] = min(low[x], low[i]);
			low[x] = min(low[x], dfn[i]);
		} else if (vis[i]) {
			low[x] = min(low[x], dfn[i]);
			low[x] = min(low[x], low[i]);
		}
	}

	if (dfn[x] == low[x]) {
		++count_scc;
		while (s.top() != x) {
			color[s.top()] = count_scc;
			sum[count_scc] += val[s.top()];
			vis[s.top()] = false;
			s.pop();
		}
		color[s.top()] = count_scc;
		sum[count_scc] += val[s.top()];
		vis[s.top()] = false;
		s.pop();
	}
}

例 1: CF949C Data Center Maintenance

題意

題意 : \(n\) 個點,每個點有一個值 \(a_i\)\(m\) 條邊,每個條邊連結 \(2\) 個點 \(x,y\) 使得 \(a_x \not =a_y\)。選擇最少的 \(k(1 \le k \le n)\) 個點,使 \(a_i = (a_i + 1) \mod h\)\(m\) 個條件仍成立。

題解

  1. 對於每一條邊,如果 \(x_i = y_i + 1\) 則把 \(x_i\)\(y_i\) 連一條邊
  2. 縮點
  3. \(\texttt{DAG}\) 上跑沒有出度權值最小的點。

程式碼

#include <bits/stdc++.h>
#define int long long
#define debug(x) cerr << #x << " " << x << '\n';
#define multi false
using namespace std;
const int N = 1e5 + 10;
int t = 1, n, m, h, x, y, tim, scc_count, ansid;
int val[N], dfn[N], low[N], vis[N], color[N], siz[N];
stack <int> s;
vector <int> Edge[N];
vector <int> scc[N];
void tarjan (int x) {
	vis[x] = 1; s.push(x);
	dfn[x] = low[x] = ++tim;
	for (int i : Edge[x]) {
		if (!dfn[i]) {
			tarjan(i);
			low[x] = min(low[x], low[i]);
			low[x] = min(low[x], dfn[i]);
		} else if (vis[i]) {
			low[x] = min(low[x], low[i]);
			low[x] = min(low[x], dfn[i]);
		}
	}
	if (low[x] == dfn[x]) {
		scc_count++;
		while (s.top() != x) {
			color[s.top()] = scc_count;
			vis[s.top()] = 0;
			siz[scc_count]++; 
			s.pop();
		}
		color[s.top()] = scc_count;
		vis[s.top()] = 0;
		siz[scc_count]++; 
		s.pop();
	}
	return;
}
void solve() {
	cin >> n >> m >> h;
	for (int i = 1; i <= n; i++) cin >> val[i];
	for (int i = 1; i <= m; i++) {
		cin >> x >> y;
		if ((val[x] + 1) % h == val[y]) Edge[x].push_back(y);
		if (val[x] == (val[y] + 1) % h) Edge[y].push_back(x);
	}
	for (int i = 1; i <= n; i++) 
		if (!dfn[i]) tarjan(i);
	for (int i = 1; i <= n; i++)
		for (int j : Edge[i])
			if (color[i] != color[j])
				scc[color[i]].push_back(color[j]);
	for (int i = 1; i <= scc_count; i++) 
		if (scc[i].size() == 0 && (siz[i] < siz[ansid] || ansid == 0))
			ansid = i;
	cout << siz[ansid] << '\n';
	for (int i = 1; i <= n; i++)
		if (color[i] == ansid)
			cout << i << ' ';
    return;
}
signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cout.tie(0);
    if (multi) cin >> t;
    while (t--) solve();
	return 0;
}

\(\texttt{Trajan}\) 縮點

演算法描述

  1. 求出所有的 \(\texttt{SCC}\)
  2. 對於每個 \(\texttt{SCC}\),把所有的點縮成一個點。並求出其權值(這個是要根據題意來的,比如例題是求 \(\texttt{SCC}\) 的權值和)。
  3. 對於原圖中的每一條邊,如果這條邊連線的兩個點不在同一個 \(\texttt{SCC}\) 中,則把這條邊連到兩個 \(\texttt{SCC}\) 上。
  4. 對於縮點後的圖,形成了一個 \(\texttt{DAG}\)

例1: P3387

題意

給定一個 \(n\) 個點 \(m\) 條邊有向圖,每個點有一個權值,求一條路徑,使路徑經過的點權值之和最大。你只需要求出這個權值和。

允許多次經過一條邊或者一個點,但是,重複經過的點,權值只計算一次。

題解

  1. 求出所有的 \(\texttt{SCC}\)
  2. 對於每個 \(\texttt{SCC}\),把所有的點縮成一個點,並求出其權值和。
  3. 對於原圖中的每一條邊,如果這條邊連線的兩個點不在同一個 \(\texttt{SCC}\) 中,則把這條邊連到兩個 \(\texttt{SCC}\) 上。
  4. 對於縮點後的圖,形成了一個 \(\texttt{DAG}\)
  5. \(\texttt{DAG}\) 上跑 \(\texttt{DP}\),求出路徑經過的點權值之和的最大值。

程式碼

#include <bits/stdc++.h>
#define int long long
#define debug(x) cerr << #x << " " << x << '\n';
#define multi false
using namespace std;
const int N = 1e5 + 10;
const int M = 1e5 + 10;
int t = 1, n, m, tim, count_scc, ans;
int x[M], y[M], val[N], color[N], sum[N], f[N];
int vis[N], low[N], dfn[N];
vector <int> Edge[N];
vector <int> scc[N]; // scc edge
stack <int> s;
void tarjan(int x) {
	s.push(x), vis[x] = 1;
	dfn[x] = low[x] = ++tim;
	for (int i : Edge[x]) {
		if (!dfn[i]) {
			tarjan(i);
			low[x] = min(low[x], low[i]);
			low[x] = min(low[x], dfn[i]);
		} else if (vis[i]) {
			low[x] = min(low[x], dfn[i]);
			low[x] = min(low[x], low[i]);
		}
	}

	if (dfn[x] == low[x]) {
		++count_scc;
		while (s.top() != x) {
			color[s.top()] = count_scc;
			sum[count_scc] += val[s.top()];
			vis[s.top()] = false;
			s.pop();
		}
		color[s.top()] = count_scc;
		sum[count_scc] += val[s.top()];
		vis[s.top()] = false;
		s.pop();
	}
}
int dfs(int x) {
	if (f[x]) return f[x];
	f[x] = sum[x];
	for (int i : scc[x]) 
		f[x] = max(f[x], dfs(i) + sum[x]);
	return f[x];
}
void solve() {
	cin >> n >> m;
	for (int i = 1; i <= n; i++) cin >> val[i];
	for (int i = 1; i <= m; i++) {
		cin >> x[i] >> y[i];
		Edge[x[i]].push_back(y[i]);
	}
	for (int i = 1; i <= n; i++) 
		if (!dfn[i]) 
			tarjan(i);
	for (int i = 1; i <= m; i++) 
		if (color[x[i]] != color[y[i]])
			scc[color[x[i]]].push_back(color[y[i]]);
	for (int i = 1; i <= n; i++) 
		ans = max(ans, dfs(i));
	cout << ans << '\n';
    return;
}
signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cout.tie(0);
#ifndef ONLINE_JUDGE
    freopen("in.txt", "r", stdin);
#endif
    if (multi) cin >> t;
    while (t--) solve();
	return 0;
}

參考資料

  • 圖論 I
施工進度

相關文章