【學習筆記】圖論連通性
啊啊啊啊啊!
先引用一篇犇的:)))
縮點
弱連通:
對於有向圖中兩點 \(x\) 和 \(y\),它們在所有邊為無向時存在一個環使它們相連。
強連通:
對於有向圖中兩點 \(x\) 和 \(y\),存在一個環使它們相連。
強連通子圖:
對於有向圖 \(G = (V, E)\),如果對於一個 \(V\) 的子集 \(V_0\) 滿足,\({\forall x, y \in V_0}\),\(x\) 和 \(y\) 均滿足強連通,則稱 \(V_0\) 為一個強連通子圖。
強連通分量(SCC):
極大的強連通子圖。(一個圖中可以有多個)
- 極大的的含義:對於一個強連通子圖 \(V_0\),滿足 \(\forall V_0 \subset V_1\),且 \(V_1\) 都不是強連通子圖。
Tarjan 演算法
一些定義:
-
在 dfs 過程中遍歷的邊稱為樹邊,未遍歷的邊稱為非樹邊。
-
\(dfn_x\) 表示圖中 \(x\) 號節點在 dfs 演算法中是第 \(x\) 個遍歷到的。
-
\(low_x\) 表示 \(x\) 號節點經過若干條樹邊後再經過至多一條滿足條件的非樹邊能到達的 \(dfn\) 最小的點的值。
實現方法:
-
算出 \(dfn_x\) 並初始化 \(low_x\):可以全域性記錄一個時間戳 \(T\),並使
dfn[x] = low[x] = ++T
。 -
列舉 \(x\) 的所有出邊指向的點 \(y\)。有兩種情況:
-
\(y\) 還沒有被遍歷過。可以直接呼叫
dfs(y)
,因為該邊是樹邊,所以直接更新low[x] = min(low[x], low[y])
。 -
\(y\) 已經被遍歷過了。如果 \(y\) 和 \(x\) 在同一個 SCC(\(y\) 存在到 \(x\) 的路徑),則更新
low[x] = min(low[x], dfn[y])
。2.1. 如何知道是否在一個 SCC 裡呢?(判斷橫叉邊)
我們可以考慮開一個棧,在進入
dfs(x)
是將 \(x\) 入棧,並在找到 \(x\) 所在的 SCC 的所有點後將 \(x\) 彈出棧。所以,此時如果 \(y\) 在這個棧中,則證明 \(y\) 到 \(x\) 存在路徑,否則不存在。因此如果 \(y\) 此時在棧中則更新
low[x] = min(low[x], dfn[y])
。否則不用更新。
- 結束 dfs 時,判斷 \(x\) 是否為其所在 SCC 內 \(dfn\) 最小的點。
-
如果 \(dfn_x = low_x\),則 \(x\) 是 \(dfn\) 最小的點。此時將棧中 \(x\) 及以上的元素彈出。這些元素即是跟 \(x\) 處於同一 SCC 的點。
-
否則 \(x\) 不是 \(dfn\) 最小的點,直接結束 dfs。
程式碼如下:
vector<int> g[N];
vector<int> scc[N];
int w[N], dfn[N], low[N], T, cnt;
int st[N], top;
bool ins[N];
void tarjan(int u){
dfn[u] = low[u] = ++T;
st[++top] = u, ins[u] = 1;
for(int v : g[u]){
if(!dfn[v]){
tarjan(v);
low[u] = min(low[u], low[v]);
} else if(ins[v]){
low[u] = min(low[u], dfn[v]);
}
}
if(dfn[u] == low[u]){
cnt++;
while(st[top] != u){
scc[cnt].push_back(st[top]);
ins[st[top--]] = 0;
}
scc[cnt].push_back(st[top]);
ins[st[top--]] = 0;
}
}
一個 SCC 實際上就對應著一個可以單點經過多次的非簡單環,那麼如果我們把 SCC 縮成一個點,也就意味著把一個一般有向圖變成了 DAG。
由於 Tarjan 演算法以及後續的縮點只需要遍歷一次圖,所以演算法的總時間複雜度為 \(O(n+m)\)。
然後 DAG 上可以用拓撲排序 DP 來解決問題。
trick:統計出來的 SCC 裡 \(cnt\) 由大到小就是拓撲序。
P3387 【模板】縮點
板子。注意 trick 運用方式。
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const int N = 1e4+5;
int n, m;
vector<int> g[N];
vector<int> scc[N]; // scc 裡 cnt 由大到小就是拓撲序
vector<int> gn[N]; // scc 新圖
int w[N], dis[N], dfn[N], low[N], T, cnt, ans;
int st[N], top;
bool ins[N];
int inscc[N], dp[N];
void tarjan(int u){
dfn[u] = low[u] = ++T;
st[++top] = u, ins[u] = 1;
for(int v : g[u]){
if(!dfn[v]){
tarjan(v);
low[u] = min(low[u], low[v]);
} else if(ins[v]){
low[u] = min(low[u], dfn[v]);
}
}
if(dfn[u] == low[u]){
cnt++;
while(st[top] != u){
scc[cnt].push_back(st[top]);
inscc[st[top]] = cnt;
dis[cnt] += w[st[top]];
ins[st[top--]] = 0;
}
scc[cnt].push_back(st[top]);
inscc[st[top]] = cnt;
dis[cnt] += w[st[top]];
ins[st[top--]] = 0;
}
}
int main(){
ios::sync_with_stdio(0); cin.tie(0); cout.tie(0);
cin>>n>>m;
for(int i=1; i<=n; i++)
cin>>w[i];
for(int i=1; i<=m; i++){
int u, v; cin>>u>>v;
g[u].push_back(v);
}
for(int i=1; i<=n; i++)
if(!dfn[i]) tarjan(i);
for(int i=1; i<=n; i++){
for(int j : g[i]){
if(inscc[i] != inscc[j])
gn[inscc[i]].push_back(inscc[j]);
}
}
// dp
for(int i=cnt; i>=1; i--){
dp[i] = max(dp[i], dis[i]);
for(int j : gn[i]){
dp[j] = max(dp[j], dp[i] + dis[j]);
}
ans = max(dp[i], ans);
}
cout<<ans;
return 0;
}
割點與割邊
割點:
將某點從圖中去掉後,得到了一個非連通圖,則稱這個點是割點。
割邊:
將某邊從圖中去掉後,得到了一個非連通圖,則稱這條邊是割邊。
dfs 樹:
對於一個無向圖,透過 dfs 演算法得到的一顆生成樹。
- 在 dfs 過程中遍歷的邊稱為樹邊,未遍歷的邊稱為環邊。
- 所有的環邊一定是返祖邊。
- 沒有橫叉邊。
一些定義:
-
\(dfn_x\) 表示圖中 \(x\) 號節點在 dfs 演算法中是第 \(x\) 個遍歷到的。
-
\(low_x\) 表示 \(x\) dfs 樹的子樹內,透過環邊能回到的 \(dfn\) 最小的點。
實現方法(如何判斷一個點 \(x\) 是割點):
-
若 \(x\) 是 dfs 樹的根,\(x\) 是割點當且僅當 \(x\) 有不少於兩個兒子。
-
否則,\(x\) 是割點當且僅當存在一個 \(x\) dfs 樹的兒子 \(y\) 使得 \(low_y \ge dfn_x\)。
證明:若刪去 \(x\),則 \(y\) 子樹內沒有向 \(x\) 祖先以及其它子樹連線的邊,故 \(y\) 子樹與其它點會形成兩個連通塊。故 \(x\) 是割點。
實現方法(如何判斷邊 \((x, y)\) 是割邊):
-
若 \((x, y)\) 是環邊,則該邊一定不是割邊,因為刪除該邊之後生成樹不受影響。
-
若 \((x, y)\) 是樹邊,假設 \(x\) 是 \(y\) 的父親,則該邊是割邊當且僅當 \(low_y > dfn_x\)。
證明:若 \(low_y > dfn_x\),則刪除該邊後,\(y\) 子樹內與 \(y\) 子樹外會分成兩個連通塊,故該邊為割邊。否則,在刪除邊後 \(y\) 子樹內與 \(y\) 子樹外仍然連通,故該邊不是割邊。
點雙與邊雙
割點決定點雙,割邊決定邊雙。
點雙連通圖:
如果一個無向圖不存在割點,則稱該圖是一個點雙連通圖。
等價定義:圖中任意兩點都存在兩條除了起點終點外點不相交的路徑。
邊雙連通圖:
如果一個無向圖不存在割邊,則稱該圖是一個邊雙連通圖。
等價定義:圖中任意兩點都存在兩條邊不相交的路徑。
點雙連通分量:
極大的點雙連通子圖。
邊雙連通分量:
極大的邊雙連通子圖。
思路:
邊雙連通分量縮點後得到的圖為樹,而點雙連通分量“縮點”後得到的圖為圓方樹。
圓方樹的含義是用圓點表示原圖上的點,方點(新建點)表示不同的點雙。(再把原點雙的邊去掉就得到了一棵樹)
P8436 【模板】邊雙連通分量
板子。注意重邊處理方式。
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const int N = 5e5+5;
int n, m;
vector<int> g[N];
vector<int> bs[N];
int dfn[N], low[N], T;
int st[N], top, cnt;
bool vis[N]; // 是否被遍歷過
void tarjan(int u, int fa, int id){
dfn[u] = low[u] = ++T;
st[++top] = u; vis[u] = 1;
for(int v : g[u]){
if(v == fa && id == 0){
id = 1;
continue;
// 處理重邊
}
if(!vis[v]){
tarjan(v, u, 0);
low[u] = min(low[u], low[v]);
} else{
low[u] = min(low[u], dfn[v]);
}
}
if(dfn[u] == low[u]){
cnt++;
while(st[top] != u){
bs[cnt].push_back(st[top]);
top--;
}
bs[cnt].push_back(st[top]);
top--;
}
}
int main(){
ios::sync_with_stdio(0); cin.tie(0); cout.tie(0);
cin>>n>>m;
for(int i=1; i<=m; i++){
int u, v; cin>>u>>v;
g[u].push_back(v);
g[v].push_back(u);
}
for(int i=1; i<=n; i++)
if(!vis[i]) tarjan(i, 0, 0);
cout<<cnt<<"\n";
for(int i=1; i<=cnt; i++){
cout<<bs[i].size()<<" ";
for(int j : bs[i]) cout<<j<<" ";
cout<<"\n";
}
return 0;
}
P8435 【模板】點雙連通分量
板子。
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const int N = 5e5+5;
int n, m;
vector<int> g[N];
vector<int> ds[N];
int dfn[N], low[N], T;
int st[N], top, cnt;
bool vis[N]; // 是否被遍歷過
void tarjan(int u, int fa){
dfn[u] = low[u] = ++T;
st[++top] = u; vis[u] = 1;
int son = 0;
for(int v : g[u]){
if(v == fa) continue;
if(!vis[v]){
son++;
tarjan(v, u);
low[u] = min(low[u], low[v]);
if(low[v] >= dfn[u]){
cnt++;
while(st[top+1] != v){
ds[cnt].push_back(st[top]);
top--;
}
ds[cnt].push_back(u);
}
} else{
low[u] = min(low[u], dfn[v]);
}
}
if(fa == 0 && son == 0) ds[++cnt].push_back(u); // 孤立點判定
}
int main(){
ios::sync_with_stdio(0); cin.tie(0); cout.tie(0);
cin>>n>>m;
for(int i=1; i<=m; i++){
int u, v; cin>>u>>v;
g[u].push_back(v);
g[v].push_back(u);
}
for(int i=1; i<=n; i++)
if(!vis[i]) tarjan(i, 0);
cout<<cnt<<"\n";
for(int i=1; i<=cnt; i++){
cout<<ds[i].size()<<" ";
for(int j : ds[i]) cout<<j<<" ";
cout<<"\n";
}
return 0;
}
P2860 [USACO06JAN] Redundant Paths G
由題意很自然想到可以邊雙縮點,然後建新圖並找到葉子節點的個數。
答案為 \(\lfloor \frac{s+1}{2} \rfloor\)(\(s\) 為葉子節點數)。
轉證明。
口胡一下另一種:將度數為 2 的點縮了之後,每次連距離最長的兩個葉子節點即可。
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const int N = 5e3+5;
int n, m;
vector<int> g[N];
vector<int> bs[N];
int dfn[N], low[N], inbs[N], T;
int st[N], top, cnt, leaf;
bool vis[N]; // 是否被遍歷過
int ind[N];
void tarjan(int u, int fa, int id){
dfn[u] = low[u] = ++T;
st[++top] = u; vis[u] = 1;
for(int v : g[u]){
if(v == fa && id == 0){
id = 1;
continue;
// 處理重邊
}
if(!vis[v]){
tarjan(v, u, 0);
low[u] = min(low[u], low[v]);
} else{
low[u] = min(low[u], dfn[v]);
}
}
if(dfn[u] == low[u]){
cnt++;
while(st[top] != u){
bs[cnt].push_back(st[top]);
inbs[st[top--]] = cnt;
}
bs[cnt].push_back(st[top]);
inbs[st[top--]] = cnt;
}
}
int main(){
ios::sync_with_stdio(0); cin.tie(0); cout.tie(0);
cin>>n>>m;
for(int i=1; i<=m; i++){
int u, v; cin>>u>>v;
g[u].push_back(v);
g[v].push_back(u);
}
for(int i=1; i<=n; i++)
if(!vis[i]) tarjan(i, 0, 0);
for(int i=1; i<=n; i++){
for(int j : g[i]){
if(inbs[i] != inbs[j]){
ind[inbs[j]]++;
// 每個點都會被遍歷到一次,因為之前已經連的是無向邊,所以統計一個就行。
}
}
}
for(int i=1; i<=cnt; i++)
if(ind[i] == 1) leaf++;
cout<<(leaf+1)/2;
return 0;
}