『Tarjan算法 无向图的双联通分量』
<更新提示>
<第一次更新>
<正文>
无向图的双连通分量
定义:若一张无向连通图不存在割点,则称它为"点双连通图"。若一张无向连通图不存在割边,则称它为"边双连通图"。
无向图图的极大点双连通子图被称为"点双连通分量",记为"\(v-DCC\)"。无向图图的极大边双连通子图被称为"边双连通分量",记为"\(e-DCC\)"。
没错,万能的图论连通性算法\(Tarjan\)又来了。
预备知识
时间戳
图在深度优先遍历的过程中,按照每一个节点第一次被访问到的顺序给\(N\)个节点\(1-N\)的标记,称为时间戳,记为\(dfn_x\)。
追溯值
设节点\(x\)可以通过搜索树以外的边回到祖先,那么它能回到祖先的最小时间戳称为节点\(x\)的追溯值,记为\(low_x\)。当\(x\)没有除搜索树以外的边时,\(low_x=x\)。
如何求解见『Tarjan算法 无向图的割点与割边』。
Tarjan 算法
著名的\(Tarjan\)算法可以在线性时间内求解无向图的双连通分量。
边双连通分量
核心概念:没有割边的无向连通图。
注意到,割边只会把图分成两部分,对图中的点没有影响。那么有一个显而易见的求法就是:利用\(Tarjan\)算法求解无向图的割边,并将割边去除,得到的各个连通块即为边双连通分量。
\(Tarjan\)算法求解割边详见『Tarjan算法 无向图的割点与割边』。
\(Code:\)
inline void Tarjan(int x,int inedge)
{
dfn[x]=low[x]=++cnt;
for(int i=Last[x];i;i=e[i].next)
{
int y=e[i].ver;
if(!dfn[y])
{
Tarjan(y,i);
low[x]=min(low[x],low[y]);
if(low[y]>dfn[x])e[i].flag=e[i^1].flag=true;
}
else if(i!=(inedge^1))low[x]=min(low[x],dfn[y]);
}
}
//先求解出无向图的割边
inline void dfs(int x)
{
con[x]=tot;
for(int i=Last[x];i;i=e[i].next)
{
if(e[i].flag)continue;//如果是割边,则不能经过
int y=e[i].ver;
if(!con[y])dfs(y);
}
}
//利用dfs遍历每一个边双联通分量
inline void colored(void)
{
for(int i=1;i<=n;i++)
if(!con[i])++tot,dfs(i);
}
//将每一个边双联通分量内的节点进行标号,类似于染色的思想
点双连通分量
核心概念:没有割点的无向连通图。
点双连通分量的求解就没有边双连通分量那么简单了,去掉割点显然是不行的,我们可以看如下例子。
它的割点是\(2\),但是,他有三个点双连通分量:\(\{1,2\},\{2,3\},\{2,3,4\}\)。
这里给出定理,无向连通图是"点双连通图",当且仅当满足下列两个条件之一:
- 图的顶点不超过\(2\)个。
- 图中任意两个点都同时包含在一个简单环中。"简单环"指的是不相交的环。
点双连通分量的求法:
- 若某个点为“孤立点”,这个点肯定是点双。
- 其他的点双连通分量大小至少为\(2\)个点。
与强联通分量类似,用一个栈来维护,如果这个点第一次被访问时,把该节点进栈。当割点判定法则中的条件 \(dfn_x\leq low_y\)时,无论\(x\)是否为根,都要从栈顶不断弹出节点,直至节点\(y\)被弹出,刚才弹出的所有节点与节点\(x\)一起构成一个点双连通分量。
我的理解:
一个割点必然包含在多个点双连通分量中,而一个非割点却至多包含在一个点双连通分量中。所以,当割点判定法则得到满足时,当前点即割点和它在搜索树上的若干子节点必然构成了一个点双连通分量,这与强连通分量的求解是类似的。而与强连通分量不同的是,由于一个割点可能包含在多个点双连通分量中,所以我们需要将出栈操作放在遍历子节点的循环中,并每次额外将割点\(x\)也加入点双连通分量中。
\(Code:\)
inline void Tarjan(int x,int root)
{
dfn[x]=low[x]=++cnt;
Stack.push(x);
int flag=0;
if(x==root&&!Last[x])
{
Stack.pop();T++;
cut[x]=true;
con[T].push_back(x);
return;
}
for(int i=Last[x];i;i=e[i].next)
{
int y=e[i].ver;
if(!dfn[y])
{
Tarjan(y,root);
low[x]=min(low[x],low[y]);
if(low[y]>=dfn[x])
{
int top=0;T++;
while(top!=y)
{
top=Stack.top();
Stack.pop();
con[T].push_back(top);
}
con[T].push_back(x);
flag++;
if(x!=root||flag>1)cut[x]=true;
}
}
else low[x]=min(low[x],dfn[y]);
}
}
缩点
和强连通分量一样,我们也可以利用双连通分量缩点。而双连通分量的缩点的性质是:将无向图缩为一棵树。具体地,使用点的双连通分量还是边的双连通分量等问题,我们需要具体分析并讨论。
逃不掉的路
Description
现代社会,路是必不可少的。任意两个城镇都有路相连,而且往往不止一条。按理说条条大路通罗马,大不了绕行其他路呗。可小鲁却发现:从a城到b城不管怎么走,总有一些逃不掉的必经之路。
他想请你计算一下,a到b的所有路径中,有几条路是逃不掉的?
Input Format
第一行是n和m,用空格隔开。
接下来m行,每行两个整数x和y,用空格隔开,表示x城和y城之间有一条长为1的双向路。
第m+2行是q。接下来q行,每行两个整数a和b,用空格隔开,表示一次询问。
Output Format
对于每次询问,输出一个正整数,表示a城到b城必须经过几条路。
Sample Input
5 5
1 2
1 3
2 4
3 4
4 5
2
1 4
2 5
Sample Output
0
1
解析
我们可以先放宽题目限制,假设给出的图是一棵树,那么如何求解呢?
对于树,我们显然使用树上点距的方法来求解。\(dis(x,y)=depth_x+depth_y-2depth_{lca(x,y)}\)。\(lca\)用树上倍增求解就可以了,这些都是模板,就不过多讨论了。
那么这是无向图呢,\(e-DCC\)缩点构树就可以了。
为什么不用\(v-DCC\)?\(v-DCC\)比较特殊,由于割点同时被多个点双连通分量包含,所以缩点需要保留割点,会增加点的数量,一般模型中,我们使用\(e-DCC\)缩点。
\(Code:\)
#include<bits/stdc++.h>
using namespace std;
const int N=1e5*2,M=2e5*4,Q=1e5*2,MaxlogN=25;
int n,m,q,Last[M*2],Last_[M*2],dfn[N],low[N],cnt,tot,t=1,t_,T,begin[Q],end[Q],bridge[M*2],con[N],depth[N],f[N][MaxlogN];
struct EDGE{int ver,next;}e[M*2],e_[M*2];
struct LINK{int x,y;}Link[M*2];
inline void insert(int x,int y)
{
e[++t].ver=y;e[t].next=Last[x];Last[x]=t;
}
inline void insert_(int x,int y)
{
e_[++t_].ver=y;e_[t_].next=Last_[x];Last_[x]=t_;
}
inline void input(void)
{
scanf("%d%d",&n,&m);
for(int i=1;i<=m;i++)
{
int x,y;
scanf("%d%d",&x,&y);
insert(x,y);
insert(y,x);
}
scanf("%d",&q);
for(int i=1;i<=q;i++)
scanf("%d%d",&begin[i],&end[i]);
}
inline void Tarjan(int x,int inedge)
{
dfn[x]=low[x]=++cnt;
for(int i=Last[x];i;i=e[i].next)
{
int y=e[i].ver;
if(!dfn[y])
{
Tarjan(y,i);
low[x]=min(low[x],low[y]);
if(low[y]>dfn[x])
{
bridge[i]=bridge[i^1]=true;
Link[++T]=(LINK){x,y};
}
}
else if(i!=(inedge^1))low[x]=min(low[x],dfn[y]);
}
}
inline void dfs(int x)
{
con[x]=tot;
for(int i=Last[x];i;i=e[i].next)
{
if(bridge[i])continue;
int y=e[i].ver;
if(!con[y])dfs(y);
}
}
inline void colored(void)
{
for(int i=1;i<=n;i++)
if(!con[i])tot++,dfs(i);
}
inline void build(void)
{
for(int i=1;i<=T;i++)
{
insert_(con[Link[i].x],con[Link[i].y]);
insert_(con[Link[i].y],con[Link[i].x]);
}
}
inline void init(int x,int dep)
{
depth[x]=dep;
for(int i=Last_[x];i;i=e_[i].next)
{
int y=e_[i].ver;
if(f[x][0]==y)continue;
f[y][0]=x;
init(y,dep+1);
}
}
inline void dp(void)
{
f[1][0]=-1;
for(int k=1;(1<<k)<tot;k++)
{
for(int i=1;i<=tot;i++)
{
if(f[i][k-1]<0)f[i][k]=-1;
else f[i][k]=f[f[i][k-1]][k-1];
}
}
}
inline int LCA(int x,int y)
{
if(depth[x]>depth[y])
x^=y^=x^=y;
for(int d=depth[y]-depth[x],i=0;d;d>>=1,i++)
if(1&d)y=f[y][i];
if(x==y)return x;
for(int i=MaxlogN-2;i>=0;i--)
{
if(f[x][i]^f[y][i])
{
x=f[x][i];
y=f[y][i];
}
}
return f[x][0];
}
inline void solve(void)
{
for(int i=1;i<=q;i++)
{
int x=con[begin[i]],y=con[end[i]];
printf("%d\n",depth[x]+depth[y]-2*depth[LCA(x,y)]);
}
}
int main(void)
{
input();
Tarjan(1,0);
colored();
build();
init(1,0);
dp();
solve();
return 0;
}
矿场搭建(BZOJ2730)
Description
煤矿工地可以看成是由隧道连接挖煤点组成的无向图。
为安全起见,希望在工地发生事故时所有挖煤点的工人都能有一条出路逃到救援出口处。于是矿主决定在某些挖煤点设立救援出口,使得无论哪一个挖煤点坍塌之后,其他挖煤点的工人都有一条道路通向救援出口。
请写一个程序,用来计算至少需要设置几个救援出口,以及不同最少救援出口的设置方案总数。
Input Format
输入文件有若干组数据,每组数据的第一行是一个正整数 N(N≤500),表示工地的隧道数,接下来的 N 行每行是用空格隔开的两个整数 S 和 T,表示挖煤点 S 与挖煤点 T 由隧道直接连接。输入数据以 0 结尾。
Output Format
输入文件中有多少组数据,输出文件 output.txt 中就有多少行。
每行对应一组输入数据的 结果。其中第 i 行以 Case i: 开始,其后是用空格隔开的两个正整数,第一个正整数表示对于第 i 组输入数据至少需 要设置几个救援出口,第二个正整数表示对于第 i 组输入数据不同最少救援出口的设置方案总 数。
输入数据保证答案小于 2^64。输出格式参照以下输入输出样例。
Sample Input
9
1 3
4 1
3 5
1 2
2 6
1 5
6 3
1 6
3 2
6
1 2
1 3
2 4
2 5
3 6
3 7
0
Sample Output
Case 1: 2 4
Case 2: 4 1
解析
对于每一个发生事故的点,可能会将原图分为几个联通块,使得我们需要在不同的联通块设置更多的救援出口,这就和我们割点模型有些相似了。
进一步地,我们需要求出图中的每一个割点和各个点双连通分量,对于每一个点双连通分量,如果当中包含了一个割点,则说明这当中我们需要在非割点位置设置一个救援出口(如果割点位置发生事故,该连通块会被独立,必须额外独立设置一个救援出口),出口数累加,方案数按照点双连通分量大小减去一(一个割点不能放)累乘即可。如果当中包含了两个割点,则说明无论什么情况这一块都不会被"独立",不需要设置救援出口。
最后,还有一种特殊情况需要判断:若整张图就是一个点双连通分量,即没有割点,那么我们至少设置两个出口(若设置一个,可能恰好在出口处发生事故),方案数为\(\frac{n(n-1)}{2}\)。
算法流程:
先\(tarjan\)求一下所有的点双连通分量。然后对于每一个点双连通分量,分类讨论:
- 只有一个割点,必须选一个非割点。
- 有多于\(1\)个割点,不用选
- 没有个割点,必须选两个。
\(Code:\)
#include<bits/stdc++.h>
using namespace std;
const int M=500*3,N=1000;
int m,n,Last[N],t,dfn[N],low[N],cnt,T,cut[N],total[N],vis[N];
long long ans1,ans2=1;
struct edge{int ver,next;}e[M];
vector < int >con[N];
stack < int > Stack;
inline void insert(int x,int y)
{
e[++t].ver=y;e[t].next=Last[x];Last[x]=t;
}
inline bool input(void)
{
scanf("%d",&n);
if(n==0)return false;
for(int i=1;i<=n;i++)
{
int x,y;
scanf("%d%d",&x,&y);
insert(x,y);
insert(y,x);
if(!vis[x])vis[x]=true,m++;
if(!vis[y])vis[y]=true,m++;
}
return true;
}
inline void Tarjan(int x,int root)
{
dfn[x]=low[x]=++cnt;
Stack.push(x);
int flag=0;
if(x==root&&!Last[x])
{
Stack.pop();T++;
cut[x]=true;
con[T].push_back(x);
return;
}
for(int i=Last[x];i;i=e[i].next)
{
int y=e[i].ver;
if(!dfn[y])
{
Tarjan(y,root);
low[x]=min(low[x],low[y]);
if(low[y]>=dfn[x])
{
int top=0;T++;
while(top!=y)
{
top=Stack.top();
Stack.pop();
con[T].push_back(top);
}
con[T].push_back(x);
flag++;
if(x!=root||flag>1)cut[x]=true;
}
}
else low[x]=min(low[x],dfn[y]);
}
}
inline void solve(void)
{
for(int i=1;i<=T;i++)
{
if(con[i].size()==m)
{
ans1=2;ans2=m*(m-1)/2;
return;
}
for(int j=0;j<con[i].size();j++)
if(cut[con[i][j]])total[i]++;
if(total[i]==1)
ans1+=1LL,ans2*=1LL*(con[i].size()-total[i]);
}
}
inline void Reset(void)
{
for(int i=1;i<=T;i++)
con[i].clear();
t=0;ans1=0;ans2=1;T=0;cnt=0;m=0;
memset(Last,0,sizeof Last);
memset(total,0,sizeof total);
memset(cut,0,sizeof cut);
memset(dfn,0,sizeof dfn);
memset(low,0,sizeof low);
memset(e,0,sizeof e);
memset(vis,0,sizeof vis);
while(!Stack.empty())Stack.pop();
}
int main(void)
{
int index=0;
while(input())
{
index++;
Tarjan(1,1);
solve();
printf("Case %d: %lld %lld\n",index,ans1,ans2);
Reset();
}
return 0;
}
<后记>