Loading

强连通分量

概念

  1. 连通分量:如果一对顶点\((u, v)\)之间有一条无向边,则称\(u\)\(v\)连通。如果一个无向图\(G\)中的任意一对顶点均连通,则无向图\(G\)为一个连通图连通分量指无向图的极大连通子图,可近似理解成连通块。

  2. 强连通分量:如果一对顶点\((u, v)\)之间有一条有向边,则称\(u\)\(v\)强连通。如果一个有向图\(G\)中的任意一对顶点均强连通,则有向图\(G\)为一个强连通图强连通分量指有向图的极大强连通子图。只有有向图才有强连通分量

  3. 横插边:如果树上一对顶点\((u, v)\)之间有连边,且\(u\)\(v\)不是父子关系,则边\((u, v)\)称为横插边。

  4. 前向边:如果树上一对顶点\((u, v)\)之间有连边,且\(u\)\(v\)的祖先(非父子关系),则边\((u, v)\)为前向边。

  5. 后向边(返祖边):如果树上一对顶点\((u, v)\)之间有连边,且\(v\)\(u\)的祖先,则边\((u, v)\)为后向边(返祖边)。

  6. 树枝边:如果树上一对顶点\((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\)在栈中可以追溯到的最小的时间戳。

强连通分量的性质

注意到一个强连通分量满足以下性质:

  1. 一个强连通分量中必定有环
    证明:一棵有\(n\)个结点、\(n - 1\)条边的树一定是连通的(无向边)。如果加上若干条边,则树中一定会出现环。因为\(n\)个结点的图至少需要\(n - 1\)条边连接才能连通,所以一个强连通分量可以看做是一棵树加上若干条边。

  2. 如果一个顶点\(v\)\(low\)等于它的时间戳,则该顶点一定是所属强连通分量的“根”(强连通分量中时间戳最小的结点)。
    证明:如果\(v\)可以到达一个时间戳更小的顶点\(u\),且\(u\)也同样在\(v\)所属的强连通分量内,则\(v\)\(v\)的子树一定可以到达\(u\)。此时\(v\)虽然不满足条件,但是\(u\)成为了新的"\(v\)",故此性质成立。

基本思想

故而我们可以用一个栈来描述这棵树。如果\(u\)没有被深度优先遍历到,则更新\(u\)\(dfn\)\(low\)(一个结点至少能够走到它本身,自成一个强连通分量)。对于\(u\)连出的每一条有向边\((u, v)\),分几种情况:

  1. \(v\)没有被深度优先遍历过,遍历\(v\)。因为\(v\)能到达的点,\(u\)一定可以通过有向边\((u, v)\)到达\(v\)后间接到达,所以\(low_{u} = min(low_{u}, low_{v})\)。我们通过递归先得出\(low_{v}\),再通过回溯更新\(low_{u}\)

  2. \(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\)算法证明的困难和篇幅,本文不再深入证明,具体证明可参考证明

基本概念

  1. 反图:对于一个有向图\(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;
} 

总结

能使用强连通分量解决的题目通常含有以下要素:

  1. 题目给出一个有向图;

  2. 点与点之间满足某种性质,并且同一个强连通分量内的任意一对顶点一定都满足这种性质;

  3. 试求满足要求的最小点权(或边权)之和;

  4. 需要维护图的连通性

posted @ 2021-07-24 23:13  kymru  阅读(992)  评论(0编辑  收藏  举报