定義與記號
涉及常見或可能用到的概念的定義。關於更多,見參考資料。
基本定義
- 圖:一張圖 \(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}\) 演算法)的方法。這裡,我將描述基於入度表的方法,這種方法利用佇列來實現:
- 初始化入度表:遍歷圖中所有的邊,統計每個頂點的入度(即指向該頂點的邊的數量)。
- 將入度為 \(0\) 的頂點入隊:所有在圖中入度為 \(0\) 的頂點,都可以作為拓撲排序的起點,將它們加入到一個佇列中。
- 迴圈執行以下步驟,直到佇列為空:
- 從佇列中取出一個頂點 \(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 演算法是一種計算圖中所有頂點對之間最短路徑的演算法。
演算法流程
- 初始化距離矩陣,對角線為0,其他為兩點之間的邊權重,若無直接邊則為無窮大。
- 對每個頂點 $k $,更新所有頂點對 $ (i, j) $ 的距離:
dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j])
。 - 重複步驟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}\) 演算法用於在加權圖中找到一個頂點到其他所有頂點的最短路徑。
演算法流程
- 初始化距離陣列,源點距離為 \(0\),其餘為無窮大。
- 使用優先佇列(或堆)來儲存所有節點,優先順序為節點的當前距離。
- 從佇列中取出距離最小的節點,更新其相鄰節點的距離。
- 重複步驟3,直到佇列為空或找到目標節點。
適用場景
適用於無負權邊的圖。
\(\texttt{SPFA}\) 演算法
關於 \(\texttt{SPFA}\), 他 __ 了。
基本原理:
\(\texttt{SPFA}\) 是 \(\texttt{Bellman-Ford}\) 演算法的一種改進,用於求解單源最短路徑問題。它透過使用佇列最佳化了演算法的效率。
演算法流程:
- 初始化距離陣列,源點距離為0,其餘為無窮大。
- 將源點入隊。
- 當佇列非空時,取出隊首元素,遍歷其所有出邊。
- 如果透過當前點可以使得到達某個點的距離更短,則更新距離並將該點入隊(如果它當前不在佇列中)。
- 重複步驟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)
,並更新x
的low
值。 - 如果
i
已在棧中(vis[i]
),則更新x
的low
值。
- 如果
- 如果
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\) 個條件仍成立。
題解
- 對於每一條邊,如果 \(x_i = y_i + 1\) 則把 \(x_i\) 向 \(y_i\) 連一條邊
- 縮點
- \(\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}\) 縮點
演算法描述
- 求出所有的 \(\texttt{SCC}\)。
- 對於每個 \(\texttt{SCC}\),把所有的點縮成一個點。並求出其權值(這個是要根據題意來的,比如例題是求 \(\texttt{SCC}\) 的權值和)。
- 對於原圖中的每一條邊,如果這條邊連線的兩個點不在同一個 \(\texttt{SCC}\) 中,則把這條邊連到兩個 \(\texttt{SCC}\) 上。
- 對於縮點後的圖,形成了一個 \(\texttt{DAG}\)。
例1: P3387
題意
給定一個 \(n\) 個點 \(m\) 條邊有向圖,每個點有一個權值,求一條路徑,使路徑經過的點權值之和最大。你只需要求出這個權值和。
允許多次經過一條邊或者一個點,但是,重複經過的點,權值只計算一次。
題解
- 求出所有的 \(\texttt{SCC}\)。
- 對於每個 \(\texttt{SCC}\),把所有的點縮成一個點,並求出其權值和。
- 對於原圖中的每一條邊,如果這條邊連線的兩個點不在同一個 \(\texttt{SCC}\) 中,則把這條邊連到兩個 \(\texttt{SCC}\) 上。
- 對於縮點後的圖,形成了一個 \(\texttt{DAG}\)。
- 在 \(\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