无向图的连通性

最近又复习了一遍tarjan,发现无向图的连通性比有向图复杂不少,决定写一篇博客总结一下。


总体来说,
跟边有关的是,然后就有边双连通分量
跟点有关的是割点(割顶),然后就有点双连通分量
接下来将详细的讲每一个知识点。



如果删除了一条边后,整个图被分裂成了两个不相连的子图,那么这条边称为
桥的判定方法很简单:对于在dfs树上的节点\(u\),如果他的一个儿子\(v\)满足\(low[v] > dfn[u]\),那么边\((u,v)\)就是一座桥。
直观的理解就是\(v\)及其子树的任何一个节点都到不了\(u\)或者更往上的点。


接下来是代码实现的一些细节:
因为是无向图,所以对于每一条无向边,我们用两条有向边代替,那么如果\(u\)走了边\((u,v)\)到达\(v\)后,显然不能再用反向边\((v,u)\)更新\(low[v]\)。为了避免这一点,在dfs的时候引入另一个参数\(e\),代表这条边的编号,因为两条边是成对存的,那么如果当前的边等于\(e\)^\(1\),就说明是反向边,就避开它。
因为有些题可能有重边,所以在dfs时记录父亲节点编号是会出错的。
主要代码:

int dfn[maxn], low[maxn], cnt = 0;
In void tarjan(int now, int _e)
{
	dfn[now] = low[now] = ++cnt;
	forE(i, now, v)
	{
		if(!dfn[v])
		{
			tarjan(v, i);
			low[now] = min(low[now], low[v]);
			if(dfn[now] < low[v]) bridge[i] = bridge[i ^ 1] = 1; 
		}
		else if(i ^ (_e ^ 1)) low[now] = min(low[now], dfn[v]);
	}
}


边双连通分量(e-DCC)

如果一个子图里不含桥,那这个子图就是一个边双连通分量。
边双联通分量的计算方法很简单,只要删除图中的所有桥就行了。
这个可以用上面的代码再加上一个dfs实现,我们只用判断当前的边是否是桥即可,不是的话继续dfs。


不过这样写未免有些冗余,我们可以仿照有向图强连通分量的做法,在第一遍dfs的时候开一个栈维护。
实际上他和强连通分量缩点非常像,如果点\(u\)满足\(dfn[u]==low[u]\),那么就一直弹栈知道把\(u\)弹出去为止,则这些点就是一个e-DCC。

int dfn[maxn], low[maxn], cnt = 0;
int st[maxn], top = 0;
int col[maxn], du[maxn], ccol = 0;
In void tarjan(int now, int _e)
{
	dfn[now] = low[now] = ++cnt;
	st[++top] = now;
	forE(i, now, v)
	{
		if(!dfn[v])
		{
			tarjan(v, i);
			low[now] = min(low[now], low[v]);
		}
		else if(i ^ (_e ^ 1)) low[now] = min(low[now], dfn[v]);
	}
	if(dfn[now] == low[now])
	{
		int x; ++ccol;
		do
		{
			x = st[top--];
			col[x] = ccol;		//点x属于编号为ccol的e-DCC 
		}while(x ^ now);
	}
}

至于e-DCC缩点,因为我们已经求出每一个点所属的e-DCC了,所以只要遍历每一个点的出边就能建立缩点之后的新图了。



割点

如果删除了点\(u\)及其相连的边后,这张图不连通,那么点\(u\)就成为割点,或者割顶。
割点的判定法则是:
如果点\(u\)不是dfs的树的根节点,那么对于\(u\)的儿子\(v\),如果\(dfn[u] \leqslant low[v]\),那么\(u\)就是割点;
如果\(u\)是根节点,需要有至少两个\(v\)满足上述条件。


理解起来就是\(v\)及其子树中的所有节点最高只能爬到\(u\),一点把\(u\)删去,\(v\)的子树就不和\(u\)的祖先连通了;而对于\(u\)是根节点的情况,如果只存在一个\(v\)满足条件,那么删除\(u\)后剩下的图是\(v\)及其子树,仍然连通,所以至少要需要两个这样的节点\(v_1,v_2\),这样删除\(u\)后才存在两个不连通的子图\(v_1\),\(v_2\)。


因为判定法则是小于等于号,所以反向边的父亲节点和重边并不会对结果产生影响,从\(u\)出发的所有点的时间戳都可以用来更新\(low[u]\)。

int dfn[maxn], low[maxn], cnt = 0;
int cut[maxn], root;
In void tarjan(int now)
{
	dfn[now] = low[now] = ++cnt;
	int flg = 0;
	forE(i, now, v)
	{
		if(!dfn[v])
		{
			tarjan(v);
			low[now] = min(low[now], low[v]);
			if(low[v] >= dfn[now])
			{
				++flg;
				if(now != root || flg > 1) cut[now] = 1;
			}
		}
		else low[now] = min(low[now], dfn[v]);
	}
}

//以下代码在主函数中
for(int i = 1; i <= n; ++i) if(!dfn[i]) root = i, tarjan(i);


点双连通分量(v-DCC)

一个点双连通分量的定义是,删去这个v-DCC中的任意一个点及其连边,该v-DCC中剩余的仍然连通。
特别的,对于孤立点,他自己构成一个v-DCC。除了孤立点之外,v-DCC的大小至少为2.


为什么不能用上面和桥类似的定义呢?因为一个割点可能属于多个v-DCC,这也导致点双的求法有些不同。


先解决一个小问题,求一张图删除一个点后,最多有多少个连通分量。
显然是要删除割点,所以我们现在要做的是求出删除一个割点\(u\)后形成的连通分量个数\(dp[u]\)。
其实就是在dfs的时候,如果\(dfn[u] \leqslant low[v]\),那么\(u\)和\(v\)及其子树就形成了一个v-DCC,\(dp[u]\)++即可。
最后当\(u\)回溯的时候判断\(u\)是否是根节点,不是的话\(dp[u]\)++,因为还有\(u\)的祖先所在的v-DCC。


这道题启发我们求v-DCC的时候不能等\(u\)回溯的时候再开始弹栈处理\(u\)所在的v-DCC,而是要在\(dfn[u] \leqslant low[v]\)成立的时候马上执行。
而且这个弹栈的终止条件是一直弹到\(v\),而不是\(u\),因为栈里面在\(u\)和\(v\)之间还有\(u\)在\(v\)之前遍历的子树(而且这些子树一定不满足\(dfn[u] \leqslant low[v']\),否则就被弹出去了)。弹出\(v\)后,再把\(u\)加入到该v-DCC中即可。

int dfn[maxn], low[maxn], cnt = 0;
int st[maxn], top = 0;
vector<int> dcc[maxn];
int cut[maxn], root, ccol = 0;
In void tarjan(int now)
{
	dfn[now] = low[now] = ++cnt;
	st[++top] = now;
	if(x == root && head[x] == -1) {dcc[++ccol].push_back(x); return;}	//孤立点 
	int flg = 0;
	forE(i, now, v)
	{
		if(!dfn[v])
		{
			tarjan(v);
			low[now] = min(low[now], low[v]);
			if(low[v] >= dfn[now])
			{
				++flg;
				if(now != root || flg > 1) cut[now] = 1;
				int x; ++ccol;
				do
				{
					x = st[top--];
					dcc[ccol].push_back(x);
				}while(x ^ v);
				dcc[ccol].push_back(now);
			}
		}
		else low[now] = min(low[now], dfn[v]);
	}
}

但这样写有一个弊端:无法在保证时间复杂度的前提下确定每一个v-DCC中有几条边。
因为我们只能重新遍历每一个v-DCC中的每一个点的所有出边,判断有没有两个端点都在该v-DCC中。但因为一个割点可能在多个v-DCC中,所以我们只要构造一个菊花图,这样中间的点就是割点,且在\(n-1\)个v-DCC中,那么遍历边的复杂度就是\(O(n^2)\)的了。


为了解决这个问题,改成往栈里存边。因为点虽然可能在多个v-DCC中,但是边一定是在一个v-DCC中的。
这样弹栈的时候只用把不在当前v-DCC的节点加进去即可,这个可以用一个set维护,如果不想多一个log的话,开一个数组记录这个点所属的v-DCC编号,如果不是当前的v-DCC,就把他加进去,并把这个点所属的v-DCC编号改过来。


关于往栈里加边,除了树边,还有满足\(dfn[v] < dfn[u]\)的边,否则存的边可能属于是已经算过的v-DCC,导致存的边比实际上的多。

int dfn[maxn], low[maxn], cnt = 0, top = 0, ccol = 0;
struct Node{int x, y;}st[maxn];
vector<int> dcc[maxn];
In void tarjan(int now, int _e)
{
	dfn[now] = low[now] = ++cnt;
	forE(i, now, v)
	{
		if(!dfn[v])
		{
			st[++top] = (Node){now, v};
			tarjan(v, i);
			low[now] = min(low[now], low[v]);
			if(low[v] >= dfn[now])
			{
				Node tp; ++ccol;
				dcc.clear(); 
				do
				{
					tp = st[top--];
					if(col[tp.x] ^ ccol) dcc[col[tp.x] = ccol].push_back(tp.x); 
					if(col[tp.y] ^ ccol) dcc[col[tp.y] = ccol].push_back(tp.y); 
				}while(tp.x != now || tp.y != v);
			}
		}
		else if(i ^ (_e ^ 1))
		{
			low[now] = min(low[now], dfn[v]);
			if(dfn[v] < dfn[now]) st[++top] = (Node){now, v};
		}
	}
}

综合来看,第二种写法在功能上确实比第一种要好的多,也是网上大多数的写法,但同时也更难理解,所以如果不用统计内部边数的话,第一种写法当然是性价比更高的了。

关于v-DCC缩点,要比e-DCC缩点复杂一些,而且建图方式不一样。
思路是把每一个v-DCC和割点看成新的节点,并在每个割点与包含他的所有v-DCC之间连边。
做法是在上述求出v-DCC的集合后,给每一个割点分配一个新的编号,并连边。
以下给出建图部分的代码:

num = ccol;
//给每一个割点x分配一个新的编号nid[x] 
for(int i = 1; i <= n; ++i) if(cut[i]) nid[i] = ++num;
for(int i = 1; i <= ccol; ++i)
	for(int j = 0; j < (int)dcc[i].size(); ++j)
	{
		int x = dcc[i][j];
		if(cut[x]) addEdge(i, nid[x]), addEdge(nid[x], i);
	}

话说v-DCC缩点的题我似乎还没做过诶……都是求完v-DCC后不用建图的那种……




关于无向图的连通性大概就是这些吧,可能有写的不严谨的地方,欢迎指正。

上一篇:仙人掌图小记


下一篇:LOJ3176「IOI2019」景点划分【分析性质,构造】