[图论]无向图双连通分量
无向图双连通分量
各种定义
无向图的割点
给定无向图 G =(V,E );
若对x
\in V ,从图中删去节点 x 以及所有与 x 关联的边之后,G 分裂成两个或 两个以上不相连的子图,则称 x 为G的割点; ——《算法竞赛进阶指南》
在一个无向图中,如果有一个顶点集合,删除这个顶点集合以及这个集合中所有顶点相关联的边以后,图的连通分量增多,就称这个点集为割点.集合
如果某个割点集合只含有一个顶点X(也即{X}是一个割点集合),那么X称为一个割点。
——《百度百科》
eg:
给定一个无向图,此时他的连通分量为1(就是他自己)
那么,图中删去的这个点就是这个无向图的一个割点,此时无向图的连通分量个数 为3
无向图的割边(桥)
给定无向图 G =(V,E );
若对e
\ in E ,从图中删去边 e 之后,G 分裂成两个或两个以上不相连的子图, 则称 e 为G的桥或割边; ——《算法竞赛进阶指南》
假设有连通图 G ,e 是其中一条边,如果 G - e 是不连通的,则边 e 是图 G的一条割边。此情形下,G - e 必包含两个连通分支;
——《百度百科》
eg:
还是一个无向图
点双连通图
若一个无向图中的去掉任意一个节点都不会改变此图的连通性,即不存在割点,则称作点双连通图。
——《百度百科》
边双连通图
若一个无向图中的去掉任意一条边都不会改变此图的连通性,即不存在割边(桥),则称作边双连通图。
——《百度百科》
双连通分量
无向图的极大点双连通子图被称为“点双连通分量”,简记为“v-DCC”,即“vertex double connected component”的缩写。无向图的极大边双连通子图被称为“边双连通分量”,简记为“e-DCC”,即“edge double connected component”的缩写。二者统称“双连通分量”,简记为“DCC”。
——《百度百科》
注意
1.一般在不连通图上讨论双连通没有意义
2.在边双连通分量中,点恰属于一个边双连通分量;
3.在点双连通分量中,边恰属于一个点双连通分量;
Tarjan 算法
这里只是无向图的Tarjan算法;
目的
求出无向图的所有割点,割边,以及点双连通分量和边双连通分量;
时间复杂度
O(m + n)
实现
搜索树
在无向图中任选一个节点出发进行深度优先遍历,每个点只访问一次,所有发生递归的边(x,y)(换言之,从x到y是对y的第一次访问)构成一棵树,我们把这棵树称为“无向连通图的搜索树”。当然,一般无向图(不一定连通)的各个连通块的搜索树构成无向图的“搜索森林”;
前置
对于每一个点u
引入一个时间戳 dfn[u] ,即在深度优先遍历中,按照每个节点第一次被访问的时间顺序,依次给予N个点1 ~ N的整数标记;
引入一个追溯值(这个名字奇奇怪怪)low[u],
设subtree(u)表示搜索树中以u为根的子树,low[u]定义为以下节点时间戳的最小值:
1.subtree(u)上的点;
2.通过一条不在搜索树上的边,能够到达subtree(u)的节点;
——《算法竞赛进阶指南》
其实low[u]表示从u出发不走通向u父亲的边(即不是从u返回上一个点)能到达的dfn的最小值;
要注意看有没有重边和自环!! (会影响到细节的处理);
求割边
割边判定法则
无向边(x , y)是桥,当且仅当搜索树上存在 x 的一个子节点y满足:dfn[x]<low[y];
证明:根据定义,dfn[x]<low[y]说明从subtree(y)出发,不经过(x,y)的前提下,不管走哪一条边,都无法访问到x或比x更早访问到的点。如果把(x,y)删除,那么subtree(y)就与x断开,没有与x相连的边,此时无向图断裂成了两部分,因此(x,y)是割边,反之,如果不存在这样的子节点y,使得dfn[x]<low[y],则说明每一个subtree(z)(z是x的子节点),都能绕行其他边到达x或比x更早访问到的点(即(x,z)在环上),(x,z)自然不是割边;
求割边的Tarjan代码
void tarjan(int now,int fa)//now为现在访问到的节点的编号
{ //fa为now的父亲节点的编号;
dfn[now]=++tot;//时间戳
low[now]=tot;
for(int j=hd[now];j!=-1;j=edge[j].nxt)//链式前向星存图
{
if(edge[j].to!=fa && dfn[edge[j].to])//如果下一个点不是父亲,继续往下搜
{
low[now] = min(low[now], dfn[edge[j].to]);//维护low数组
}
if(!dfn[edge[j].to])
{
tarjan(edge[j].to,now);
low[now]=min(low[now],low[edge[j].to]);//维护low数组
if(low[edge[j].to]>dfn[now])//判是不是割边
{
printf("%d %d\n",now,edge[j].to);
}
}
}
//如果是有重边的无向图,则now和fa都应该记录边的编号
//即now表示现在搜索到的边的编号,fa表示上一条搜索到的边的编号
}
signed main()
{
//... //读入+存图
for(int i=1;i<=n;i++)
{
if(!dfn[i])//如果还没有访问过,说明这个点和之前所有点都不连通
{
tarjan(i,0);//从他开始访问
}
}
}
求割点
割点判定法则
1.如果点x不是搜索树的根节点,则x为割点当且仅当搜索树上存在一个x的子节点y满足dfn[x]<=low[y]。
2.特别的,如果x是根节点,则要求至少有两个x的子节点
证明:
1.证明类似于割边判定法则的证明,即若果满足dfn[x]<=low[y],说明节点y无法走到比x更显访问到的节点,删去x之后,subtree(y)会构成一个单独的连通块,而由于x点并不是根节点,因此连通分量数必定会增加,x是割点
2.显然成立,因为x是搜索树上的根节点,他的时间戳dfn[x]与如low[x]都为1,其所有子节点的low[y]必然大于等于dfn[x]。如果有两个及两个以上的节点就说明删去x以后,这两个子节点不能互相到达,所以连通分量数增加,x为割点
求割点的Tarjan代码
void tarjan(int now)
{
dfn[now]=++tot;//时间戳
low[now]=tot;
bool fl=0;//打标记
int son=0;//符合标准的儿子数
for(int j=hd[now];j!=-1;j=edge[j].nxt)//链式前向星
{
int v=edge[j].to;
if(dfn[v])//在走(now,v)这条边以前就已经访问过,v不是now的儿子
{
low[now]=min(low[now],dfn[v]);//维护low数组
}
else//没有访问过,即从(now,v)是对于v的第一次访问
{
tarjan(v);//往下搜索
son++;//now的儿子数++;
low[now]=min(low[now],low[v]);//维护low数组
if(dfn[now]<=low[v] && now!=root)//关于是否为割点的判定1
{ //即对于非根节点的判定
fl=1;
}
}
}
if(fl || (now==root && son>1))//关于是否为割点的判定2
{ //即对根节点的判定
printf("%d ",now);
}
}
signed main()
{
//... //读入+存图
for(int i=1;i<=n;i++)
{
if(!dfn[i])//如果还没有访问过,说明这个点和之前所有点都不连通
{
root=i;
tarjan(i);//从他开始遍历
}
}
}
注意
再求割点或者点双连通分量时,不能把非树边的更新方式 low[u]=min(low[u],dfn[v])写成low[u]=min(low[u],low[v]);
求边双连通分量
小技巧
易征得,一个点最多属于一个边双连通分量(因为如果一个点属于多个边双连通分量,这些边双连通分量是可以合并的)
所以某些时候,我们处理割边时,可以认为其是大小为2的双连通分量
这样在有时候可以节约代码复杂度
一个点最多属于任意个点双连通分量(菊花图的中间的那个节点) 但是特殊情况往往需要单独讨论 因此,我们把它画成带环的
方法1
直接利用刚才的方法求出所有割边,删去这些边,得到的新图 G' 中的每一个连通分量都是边双连通分量
代码实现
void tarjan(int now,int fa)//now和fa都是点的编号
{
dfn[now]=++tot;//时间戳
low[now]=tot;
for(int j=hd[now];j!=-1;j=edge[j].nxt)//链式前向星
{
int v=edge[j].to;
if(!dfn[v])//如果没有访问过v,即(now,v)是对于v的第一次访问
{
tarjan(v,now);//从v继续搜
low[now]=min(kow[now],low[v]);//维护low数组
if(low[v]>dfn[now])//判断为割边
{
printf("%d %d\n",now,v);
is_bridge[j]=1;//记录这一条边是桥
//记录这条边的反向边是桥(毕竟是无向图)
if(j&1)//如果j是奇数,那么j的反向边为j+1
{
is_bridge[j+1]=1;
}
else//如果j是偶数,那么j的反向边为j-1
{
is_bridge[j-1]=1;
}
}
}
else if(v!=fa)//如果v不是第一次访问,且不是搜索树上now的父亲
{
low[now]=min(low[now],dfn[v]);//维护low数组
}
}
}
signed main()
{
//... //读入+存图
for(int i=1;i<=n;i++)
{
if(!dfn[i])//如果还没有访问过,说明这个点和之前所有点都不连通
{
tarjan(i,0);//从他开始遍历
}
}
}
方法2
在求割边时维护一个栈,每遍历到一个节点u,就将u压栈,如果在遍历完与u相连的所有边后(即更新完low[u]之后),如果low[u]=dfn[u],说明u是u所在边连通分量中最先被访问的节点,不断弹栈知道弹出u,这个过程中弹出的节点就是u所在边连通分量中的节点
代码实现
stack <int> q;
void tarjan(int now,int fa)
{
dfn[now]=++tot;//时间戳
low[now]=tot;
q.push(now);//维护栈,即维护访问次序
vis[now]=1;//打标记
for(int j=hd[now];j!=-1;j=edge[j].nxt)//链式前向星
{
int v=edge[j].to;
if(!dfn[v])//如果没有访问过v,即(now,v)是对于v的第一次访问
{
tarjan(v,now);//从v继续搜
low[now]=min(kow[now],low[v]);//维护low数组
}
else //如果v不是第一次访问
{
low[now]=min(low[now],dfn[v]);//维护low数组
}
}
if(dfn[now]==low[now])//满足判定条件
{
bcc++;//新的边双连通子图
while(1)//不断弹栈
{
int tmp=q.top();
q.pop();
vis[tmp]=0;//清空标记
belong[tmp]=bcc;//记录这个点属于哪一个边双连通子图
if(tmp==now) //弹栈弹到了now,跳出循环
{
break;
}
}
}
}
signed main()
{
//... //读入+存图
for(int i=1;i<=n;i++)
{
if(!dfn[i])//如果还没有访问过,说明这个点和之前所有点都不连通
{
tarjan(i,0);//从他开始遍历
}
}
}
求点双连通分量
小知识
一张连通图,点数大于1,不存在大小为1的点双连通分量,大小至少为2(点数),边数最小为1
证明:
这是一个大小为2的点双连通分量
那么对于任意一个点,都可以加上一条边,使它变成大小至少为2的点连通子图,那么大小为1的点双连通子图就必然不可能为极大的点双连通子图,即不可能为点双连通分量
当然,如果这个连通图只有一个点,这也就是一个大小为1 的点双连通分量(毕竟就它一个)
方法
类似于求边双连通分量时的方法2类似,每遍历到一个节点u,就把u压栈。如果(u,v)是搜索树上的一条边,(约定u是v的父亲),满足dfn[u]<=low[v],即u是一个割点,这时不断弹栈,知道栈顶为u,要记录u,但不弹出u,这个过程中弹出的点就构成了这张无向图的一个点双连通分量。
代码实现
stack <int> q;
vector <int> v-DCC[...];
void tarjan(int u)
{
dfn[u]=++tot;//时间戳
low[u]=tot;
q.push(u);//维护栈,即维护访问次序
for(int j=hd[u];j!=-1;j=edge[j].nxt)//链式前向星
{
int v=edge[j].to;
if(!dfn[v])//如果没有访问过v,即(u,v)是对于v的第一次访问
{
tarjan(v);//从v继续搜
low[u]=min(low[u],low[v]);//维护low数组
if(dfn[u]<=low[v])//如果满足条件,即u是一个割点
{
bcc++;//一个新的点双连通子图
while(q.top()!=u)//不断弹栈直到栈顶为u
{
int tmp=q.top();
q,pop();
v_DCC[bcc].push_back(tmp);
//把弹出的点加入到点双连通子图中
}
v_DCC.push_back(u);//把u加入到点双连通子图中
}
}
else
{
low[u]=min(low[u],dfn[v]);//维护low数组
}
}
}
signed main()
{
//... //读入+存图
for(int i=1;i<=n;i++)
{
if(!dfn[i])//如果还没有访问过,说明这个点和之前所有点都不连通
{
tarjan(i);//从他开始遍历
}
}
}
后话
这里只是对于无向图双连通分量的知识整理,并没有例题。
关于无向图双连通分量,有几个定义绕来绕去,而且Tarjan代码大同小异,因此需要格外注重细节
eg:
1.割边 dfn[x]<low[y];
割点 dfn[x]<=low[y];
2.if(!dfn[v]) low[u]=min(low[u],low[v]);
else low[u]=min(low[u],dfn[v]);
3.求双连通分量时,边双连通分量要弹出u,而点双连通分量中不弹u
.......
因此,细节决定成败。
谢谢观看。