概念
-
連通分量:如果一對頂點\((u, v)\)之間有一條無向邊,則稱\(u\)和\(v\)連通。如果一個無向圖\(G\)中的任意一對頂點均連通,則無向圖\(G\)為一個連通圖。連通分量指無向圖的極大連通子圖,可近似理解成連通塊。
-
強連通分量:如果一對頂點\((u, v)\)之間有一條有向邊,則稱\(u\)和\(v\)強連通。如果一個有向圖\(G\)中的任意一對頂點均強連通,則有向圖\(G\)為一個強連通圖。強連通分量指有向圖的極大強連通子圖。只有有向圖才有強連通分量。
-
橫插邊:如果樹上一對頂點\((u, v)\)之間有連邊,且\(u\)和\(v\)不是父子關系,則邊\((u, v)\)稱為橫插邊。
-
前向邊:如果樹上一對頂點\((u, v)\)之間有連邊,且\(u\)是\(v\)的祖先(非父子關系),則邊\((u, v)\)為前向邊。
-
后向邊(返祖邊):如果樹上一對頂點\((u, v)\)之間有連邊,且\(v\)是\(u\)的祖先,則邊\((u, v)\)為后向邊(返祖邊)。
-
樹枝邊:如果樹上一對頂點\((u, v)\)之間有連邊,且\(u\)和\(v\)是父子關系,則邊\((u, v)\)是樹枝邊。
\(tarjan\)
算法思想
\(tarjan\)算法是一種用於求解有向圖的強連通分量的算法,由計算機科學家\(Robert\) \(Tarjan\)提出。它的時間復雜度優秀,為\(O(n + m)\),其中\(n\)為點數,\(m\)為邊數。它可以求出每個強連通分量的大小、屬於其的頂點和強連通分量的總數。
概念定義
給圖中的每一個頂點賦予一個新的編號,如果一個頂點\(v\)在\(dfs\)樹中第\(i\)個遍歷,則頂點\(v\)的編號為\(i\),稱為時間戳,用\(dfn\)表示。時間戳是唯一的,頂點對應的時間戳也是唯一的。
定義\(low_{i}\)表示頂點\(i\)不經過其父結點可以到達的最小的時間戳,即\(i\)和\(i\)的子樹可以到達的最小的時間戳,也描述為\(i\)在棧中可以追溯到的最小的時間戳。
強連通分量的性質
注意到一個強連通分量滿足以下性質:
-
一個強連通分量中必定有環。
證明:一棵有\(n\)個結點、\(n - 1\)條邊的樹一定是連通的(無向邊)。如果加上若干條邊,則樹中一定會出現環。因為\(n\)個結點的圖至少需要\(n - 1\)條邊連接才能連通,所以一個強連通分量可以看做是一棵樹加上若干條邊。 -
如果一個頂點\(v\)的\(low\)等於它的時間戳,則該頂點一定是所屬強連通分量的“根”(強連通分量中時間戳最小的結點)。
證明:如果\(v\)可以到達一個時間戳更小的頂點\(u\),且\(u\)也同樣在\(v\)所屬的強連通分量內,則\(v\)和\(v\)的子樹一定可以到達\(u\)。此時\(v\)雖然不滿足條件,但是\(u\)成為了新的"\(v\)",故此性質成立。
基本思想
故而我們可以用一個棧來描述這棵樹。如果\(u\)沒有被深度優先遍歷到,則更新\(u\)的\(dfn\)和\(low\)(一個結點至少能夠走到它本身,自成一個強連通分量)。對於\(u\)連出的每一條有向邊\((u, v)\),分幾種情況:
-
\(v\)沒有被深度優先遍歷過,遍歷\(v\)。因為\(v\)能到達的點,\(u\)一定可以通過有向邊\((u, v)\)到達\(v\)后間接到達,所以\(low_{u} = min(low_{u}, low_{v})\)。我們通過遞歸先得出\(low_{v}\),再通過回溯更新\(low_{u}\)。
-
\(v\)已經被遍歷過,即在棧內。因為深度優先遍歷是從根結點到子結點,且\(v\)被遍歷過,說明這條邊是一條返祖邊。雖然\(u\)可以到達\(v\),\(v\)或許可以到達一個時間戳更小的結點\(\alpha\),但是\(\alpha\)、\(u\)和\(v\)不一定形成一個環,也就不在同一個強連通分量內。故而我們只能使用\(dfn_{v}\)來更新,而非\(low_{v}\)。\(low_{u} = min(low_{u}, dfn_{v})\)。
如果我們已經發現了一個\(low\)與\(dfn\)相等的頂點\(v\),那么\(v\)和\(v\)以后入棧的頂點一定屬於同一個強連通分量內。我們不斷將元素出棧,標記其所在的強連通分量,更新其所在的強連通分量大小,直到\(v\)也出棧,\(v\)所屬的強連通分量已經求出。
枚舉圖中的頂點\(v\),如果\(dfn_{v} = 0\),說明\(v\)屬於一個新的強連通分量,從\(v\)開始\(tarjan\)算法。
模板
P1726的參考代碼如下:
#include <cstdio>
#include <stack>
#include <algorithm>
using namespace std;
#define maxn 5005
#define maxm 100005
struct node
{
int to, nxt;
}edge[maxm];
int n, m;
int cnt, cnt_node, cntn;
int head[maxn], dfn[maxn], low[maxn], size[maxn], id[maxn];
bool in_stack[maxn];
stack<int> s;
inline void add_edge(int u, int v)
{
cnt++;
edge[cnt].to = v;
edge[cnt].nxt = head[u];
head[u] = cnt;
}
//tarjan算法
void tarjan(int u)
{
cnt_node++;
low[u] = cnt_node;
dfn[u] = cnt_node;
s.push(u);
in_stack[u] = true;
for (int i = head[u]; i; i = edge[i].nxt)
{
//樹枝邊
if (!dfn[edge[i].to])
{
tarjan(edge[i].to);
low[u] = min(low[u], low[edge[i].to]);
}
//返祖邊
else if (in_stack[edge[i].to])
low[u] = min(low[u], dfn[edge[i].to]);
}
//找到新的強連通分量
if (low[u] == dfn[u])
{
cntn++;
while (s.top() != u)
{
//出棧
int v = s.top();
s.pop();
in_stack[v] = false;
//更新強連通分量信息
id[v] = cntn;
size[cntn]++;
}
int tp = s.top();
s.pop();
in_stack[tp] = false;
id[tp] = cntn;
size[cntn]++;
}
}
int main()
{
int u, v, op;
scanf("%d%d", &n, &m);
for (int i = 1; i <= m; i++)
{
scanf("%d%d%d", &u, &v, &op);
add_edge(u, v);
if (op == 2)
add_edge(v, u);
}
for (int i = 1; i <= n; i++)
if (!dfn[i])
tarjan(i);
//找出最大的強連通分量的大小
int mx = -1, target = 0;
for (int i = 1; i <= n; i++)
mx = max(mx, size[id[i]]);
//找出最大的強連通分量的編號
for (int i = 1; i <= n, !target; i++)
if (size[id[i]] == mx)
target = id[i];
printf("%d\n", mx);
//輸出最大的強連通分量中的頂點
for (int i = 1; i <= n; i++)
if (id[i] == target)
printf("%d ", i);
printf("\n");
return 0;
}
\(kosaraju\)
\(kosaraju\)算法是一種時間復雜度與\(tarjan\)相同,但是代碼實現比\(tarjan\)簡單的算法。它有一個重要的特點:求出的強連通分量是按拓撲序排列的。由於\(kosaraju\)算法證明的困難和篇幅,本文不再深入證明,具體證明可參考證明。
基本概念
- 反圖:對於一個有向圖\(G\),將\(G\)中的每一條邊\((u, v)\)都倒置一條有向邊\((v, u)\),最終得到的圖稱為\(G\)的反圖,通常記作\(G^{t}\)。
算法思想
\(kosaraju\)算法是由兩遍\(dfs\)組成的:
第一遍\(dfs\),我們使用原圖。我們從未訪問的頂點集合中隨意挑選出一個頂點\(u\),並訪問與\(u\)相鄰的結點\(v\)。在遞歸后、回溯前,我們給\(u\)編上一個序號。即,做\(u\)的后序遍歷。最后,我們把頂點\(u\)入棧。
第二遍\(dfs\),我們使用反圖。我們不斷彈出棧頂\(u\),更新\(u\)所屬的強連通分量\(color_{u}\)為\(cnt\),接着更新與\(u\)相鄰的頂點。
\(kosaraju\)算法的時間復雜度同樣是\(O(n + m)\),其中\(n\)為點集,\(m\)為邊集。
模板
#include <cstdio>
#include <stack>
using namespace std;
#define maxn 10005
#define maxm 100005
struct node
{
int to, nxt;
}edge1[maxm], edge2[maxm];
int n, m, cnt;
int head1[maxn], head2[maxn];
int color[maxn], size[maxn];
bool vis[maxn];
stack<int> s;
void add_edge1(int u, int v, int x)
{
edge1[x].to = v;
edge1[x].nxt = head1[u];
head1[u] = x;
}
void add_edge2(int u, int v, int x)
{
edge2[x].to = v;
edge2[x].nxt = head2[u];
head2[u] = x;
}
void dfs1(int u)
{
vis[u] = true;
for (int i = head1[u]; i; i = edge1[i].nxt)
if (!vis[edge1[i].to])
dfs1(edge1[i].to);
s.push(u);
}
void dfs2(int u)
{
color[u] = cnt;
for (int i = head2[u]; i; i = edge2[i].nxt)
if (!color[edge2[i].to])
dfs2(edge2[i].to);
}
void kosaraju()
{
for (int i = 1; i <= n; i++)
if (!vis[i])
dfs1(i);
while (!s.empty())
{
int v = s.top();
s.pop();
if (!color[v])
{
cnt++;
dfs2(v);
}
}
}
int main()
{
int u, v;
scanf("%d%d", &n, &m);
for (int i = 1; i <= m; i++)
{
scanf("%d%d", &u, &v);
add_edge1(u, v, i);
add_edge2(v, u, i);
}
kosaraju();
for (int i = 1; i <= cnt; i++)
{
printf("scc %d:\n", i);
for (int j = 1; j <= n; j++)
if (color[j] == i)
printf("%d ", j);
puts("");
}
return 0;
}
縮點
將一個強連通分量縮成一個單獨的點,稱為縮點。縮點通常用於點數多、要判斷某種性質且強連通分量內的結點滿足該性質的情況。換句話說,如果某種性質從大規模到小規模具有傳遞性,可以使用縮點。具體實現只需要在枚舉每一條邊的兩端,如果它們所屬的強連通分量不相同,則在這兩個強連通分量之間連邊即可。
值得注意的是,上述方法建出的縮點圖可能會出現重邊。
參考代碼如下:
#include <cstdio>
#include <vector>
#include <stack>
using namespace std;
const int maxn = 1e4 + 5;
const int maxm = 5 * 1e4 + 5;
int n, m;
int cnt_node, cnt_edge, cnt_scc;
int low[maxn], dfn[maxn], color[maxn], size[maxn];
bool in_stack[maxn];
vector<int> g[maxn], point[maxn];
stack<int> s;
void tarjan(int u) {
cnt_node++;
dfn[u] = cnt_node;
low[u] = cnt_node;
s.push(u);
in_stack[u] = true;
for (int i = 0; i < g[u].size(); i++) {
int v = g[u][i];
if (!dfn[v]) {
tarjan(v);
low[u] = min(low[u], low[v]);
} else if (in_stack[v]) {
low[u] = min(low[u], dfn[v]);
}
}
if (low[u] == dfn[u]) {
cnt_scc++;
while (s.top() != u) {
int tp = s.top();
s.pop();
in_stack[tp] = false;
color[tp] = cnt_scc;
size[cnt_scc]++;
}
int tp = s.top();
s.pop();
in_stack[tp] = false;
color[tp] = cnt_scc;
size[cnt_scc]++;
}
}
void build_edge() {
for (int i = 1; i <= n; i++) {
for (int j = 0; j < g[i].size(); j++) {
int v = g[i][j];
if (color[i] != color[v]) {
point[color[i]].push_back(color[v]);
}
}
}
}
int main() {
int u, v;
int deg_cnt = 0, scc_id;
scanf("%d%d", &n, &m);
for (int i = 1; i <= m; i++) {
scanf("%d%d", &u, &v);
g[u].push_back(v);
}
for (int i = 1; i <= n; i++) {
if (!color[i]) {
tarjan(i);
}
}
build_edge();
for (int i = 1; i <= cnt_scc; i++) {
if (!point[i].size()) {
scc_id = i;
deg_cnt++;
}
}
if (deg_cnt == 1) {
printf("%d\n", size[scc_id]);
} else {
printf("%d\n", 0);
}
return 0;
}
總結
能使用強連通分量解決的題目通常含有以下要素:
-
題目給出一個有向圖;
-
點與點之間滿足某種性質,並且同一個強連通分量內的任意一對頂點一定都滿足這種性質;
-
試求滿足要求的最小點權(或邊權)之和;
-
需要維護圖的連通性。