最近又复习了一遍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后不用建图的那种……
关于无向图的连通性大概就是这些吧,可能有写的不严谨的地方,欢迎指正。