割点:在无向连通图中,删除一个顶点以及和它相邻的所有边,图中的连通分量个数增加,则该顶点称为割点
割边(桥):在无向连通图中,删除一条边,图中的连通分量个数增加,则该条边称为割边或者桥
举个栗子:
割点:
割边:
点双连通分量的性质:
一张图无向连通图是“点双连通图”,当且仅当满足以下两个条件之一:
一张无向连通图是“边双连通图”,当且仅当任意一条边都包含在至少一个简单环中。
总结:
对于一个连通图,如果任意两点之间至少存在两条没有重复节点的路径,则称这个图为点双连通的(简称双连通);如果任意两点之间至少存在两条没有重复边的路径,则称该图为边双连通的。点双连通图的定义等价于任意两条边都同在一个简单环中,而边双连通图的定义等价于任意一条边至少在一个简单环中。对一个无向图,点双连通的极大子图称为点双连通分量(简称双连通分量),边双连通的极大子图称为边双连通分量。在每一个点双连通图中,内部无割点;在每一个边双连通图中,内部无桥。
举个栗子:
边双连通图:
边双连通分量:
点双连通图:
点双连通分量:
割点判定法则:
先来看第一种,分为以下三个式子讨论:
如果 l o w [ y ] > d f n [ x ] low[y]>dfn[x] low[y]>dfn[x],如下图所示:
如果 l o w [ y ] = d f n [ x ] low[y]=dfn[x] low[y]=dfn[x],如下图所示:
如果 l o w [ y ] < d f n [ x ] low[y]low[y]<dfn[x],如下图所示:
综上可知,当 l o w [ y ] ≥ d f n [ x ] low[y]\geq dfn[x] low[y]≥dfn[x]时,才能说明节点 x x x的一个割点
接着再来看第二种,分两种情况讨论:
如果 x x x是根节点,但是它只有一个子节点:
如果 x x x是根节点,但是它至少有两个子节点:
割边判定法则:
无向边 ( x , y ) (x,y) (x,y)是桥,当且仅当在搜索树上存在 x x x的一个子节点 y y y,满足 l o w [ y ] > d f n [ x ] low[y]>dfn[x] low[y]>dfn[x]
分为以下三个式子讨论:
当 l o w [ y ] > d f n [ x ] low[y]>dfn[x] low[y]>dfn[x]时:
当 l o w [ y ] = d f n [ x ] low[y]=dfn[x] low[y]=dfn[x]时:
当 l o w [ y ] < d f n [ x ] low[y]low[y]<dfn[x]时:
综上,当 l o w [ y ] > d f n [ x ] low[y]>dfn[x] low[y]>dfn[x]时,才能说明边 ( x , y ) (x,y) (x,y)是一条割边
注意,在无向图中,求割边时,孩子节点到父节点的边是不用处理的:
原因如下:
对于非负整数 n n n:
因此“0与1” “2与3” “4与5” ⋯ \cdots ⋯关于XOR 1 1 1 运算构成了“成对变换”
这一性质经常用于图论邻接表中边集的存储。在具有无向边的图中把一条正反方向的边分别存储在邻接表数组的第 n n n与 n + 1 n+1 n+1位置(其中 n n n是偶数),那么就可以通过XOR 1 1 1运算获得与当前边 ( x , y ) (x,y) (x,y)所反向的边 ( y , x ) (y,x) (y,x)的存储位置了(存储位置也就是这条边的编号)
如下图:
从中我们发现成对变换可以帮助我们在求割边时避免从子节点走回到父节点。
问题:如何理解割边的代码模板中的i!=(from^1)呢?
若某个节点为孤立节点,则它自己单独构成一个v-DCC。除了孤立节点外,点双连通分量的大小至少为2。注意一个割点可能属于多个点双连通分量。
下面的无向图共有2个割点(节点1和节点6),4个点双连通分量,深色部分表示点双连通分量:
为了找出“点双连通分量”,需要在tarjan算法的过程中维护一个栈,并按照如下方法维护栈中的元素:
问题:为什么是弹到 y y y而不是弹出 x x x呢?
因为一个割点可能属于多个点双连通分量,如果在某一个点双连通分量把x这个割点弹出了,然后放进了这个点双连通分量的dcc中,那么其他点双连通分量本来是含有割点x 但是由于x已经弹出栈并且放进了dcc,那么其他的点双连通分量就在栈中找不到 x x x了,因此其他的点双连通分量就都缺少了割点 x x x,但这与事实不符合。因此必须把割点 x x x保存在栈中,而不能被弹出来。
割点的代码模板:
#include
#include
#include
using namespace std;
const int N=1010,M=N*N;
int n,m,root;
int h[N],e[M],ne[M],idx;
int low[N],dfn[N],num;
bool cut[N]; //cut[i]=true表示节点i是割点
void add(int a,int b)
{
e[idx]=b;
ne[idx]=h[a];
h[a]=idx++;
}
void tarjan(int x)
{
low[x]=dfn[x]=++num;
int count=0; //统计节点x有多少个子节点
//遍历节点x的所有邻接点
for(int i=h[x];~i;i=ne[i]) //i是边
{
int y=e[i]; //y是i的邻接点
//如果节点y还没有被访问过
if(!dfn[y])
{
tarjan(y); //递归访问y
low[x]=min(low[x],low[y]); //回溯时更新
//满足割点判定准则
if(low[y]>=dfn[x])
{
count++; //节点x的子节点个数+1
//如果x不是根节点 那么如果x满足了割点判定准则,则x必是割点
//或者x是根节点,但是它至少有2个子节点,并且x满足了割点判定准则,则x必是割点
if(x!=root||count>1)
cut[x]=true;
}
}
//否则说明节点y已经被访问过了,但是有可能节点y可以通过非树边(非父子边)追溯到更早的节点
//那么也可以更新
else
low[x]=min(low[x],dfn[y]);
}
}
int main()
{
memset(h,-1,sizeof h); //初始化表头
cin >>n>>m;
for(int i=1;i<=m;i++) //读入m条边
{
int a,b;
cin >>a>>b;
if(a==b)
continue;
//建立无向图
add(a,b);
add(b,a);
}
for(int i=1;i<=n;i++)
{
if(!dfn[i])
{
root=i;
tarjan(i);
}
}
for(int i=1;i<=n;i++) //输出割点
{
if(cut[i])
printf("%d ",i);
}
puts("是割点.");
return 0;
}
割边的代码模板:
#include
#include
#include
using namespace std;
const int N=1010,M=N*N;
int h[N],e[M],ne[M],idx;
int dfn[N],low[N],num;
//bridge[i]=bridge[i^1]=true表示节点e[i]与节点e[i^1]之间的这条边是桥
bool bridge[M];
int n,m;
void add(int a,int b)
{
e[idx]=b;
ne[idx]=h[a];
h[a]=idx++;
}
//由于无向图中,从子节点到父节点的这条边是不需要处理的 因此需要记录子节点是父节点从边from走过来的
//目的是为了防止子节点又通过from这条边走回到父节点 这样就不能判定割边了
//from表示 当前节点y是上一个节点x通过边from到达的
void tarjan(int x,int from)
{
dfn[x]=low[x]=++num;
//遍历节点x的所有邻接点
for(int i=h[x];~i;i=ne[i])
{
int y=e[i]; //y是i的邻接点
//如果节点y还没有被访问过
if(!dfn[y])
{
tarjan(y,i); //递归访问y
low[x]=min(low[x],low[y]); //回溯时更新
//满足割边判定法则
if(low[y]>dfn[x])
{
//表示节点e[i]与节点e[i^1]之间的这条边是桥
bridge[i]=bridge[i^1]=true;
}
}
//否则说明节点y已经被访问过了 但是有可能节点y可以通过非树边(非父子边)追溯到更早的节点
//那么也是可以更新的 一条无向边可以看作是两条反向的有向边
//x通过from这条边到达y,即x->y是通过from边,那么y就可以通过from^1这条边到达x,即y->x是通过from^1边
//为了防止从子节点y走回到了父节点x 那么此时y就不能走from^1这条边
else if(i!=(from^1))
low[x]=min(low[x],low[y]);
}
}
int main()
{
memset(h,-1,sizeof h); //初始化表头
cin>>n>>m;
for(int i=1;i<=m;i++) //读入m条边
{
int a,b;
cin>>a>>b;
//建立无向图
add(a,b);
add(b,a);
}
for(int i=1;i<=n;i++)
{
if(!dfn[i])
tarjan(i,0);
}
//总共有idx条边 边的编号是从0开始的
//由于是成对变换,因此编号为0和编号为1就表示一条无向边 编号为2和编号为3就表示另一条无向边
//所以这里是i+=2 即遍历下一条无向边 如果写成i++则仍然会遍历这条无向边
for(int i=0;i
点双连通分量代码模板:
#include
#include
#include
#include
using namespace std;
const int N=1010,M=N*N;
int n,m,root;
int h[N],e[M],ne[M],idx;
int low[N],dfn[N],num;
int stk[N],top;
bool cut[N]; //cut[i]=true表示节点i是割点
//dcc[i]表示存储第i个点双连通分量中的所有节点
vector<int>dcc[N];
int cnt; //点双连通分量的个数
void add(int a,int b)
{
e[idx]=b;
ne[idx]=h[a];
h[a]=idx++;
}
void tarjan(int x)
{
low[x]=dfn[x]=++num;
stk[++top]=x;
//特判如果某个节点为孤立节点,则它自己单独构成一个点双连通分量
//此时这个点双连通分量只有一个节点
//判定孤立节点:如果它是根节点并且它没有邻边
if(x==root&&h[x]==-1)
{
dcc[++cnt].push_back(x);
return;
}
int count=0; //统计节点x有多少个子节点
//遍历节点x的所有邻接点
for(int i=h[x];~i;i=ne[i]) //i是边
{
int y=e[i]; //y是i的邻接点
//如果节点y还没有被访问过
if(!dfn[y])
{
tarjan(y); //递归访问y
low[x]=min(low[x],low[y]); //回溯时更新
//满足割点判定准则
if(low[y]>=dfn[x])
{
count++; //节点x的子节点个数+1
//如果x不是根节点 那么如果x满足了割点判定准则,则x必是割点
//或者x是根节点,但是它至少有2个子节点,并且x满足了割点判定准则,则x必是割点
if(x!=root||count>1)
cut[x]=true;
cnt++; //点双连通分量个数+1
int z;
//依次弹出这个点双连通分量中的节点 然后放到dcc中
//注意是弹到y截止 而不是弹到x
//为什么是弹到y截止而不是弹到x呢?
//因为一个割点可能属于多个点双连通分量,如果在某一个点双连通分量把x这个
//割点弹出了然后放进了这个点双连通分量的dcc中
//那么其他点双连通分量本来是含有割点x 但是由于x已经弹出栈并且放进了dcc
//那么其他点双连通分量就不能放入割点x了 因此会出错
do{
z=stk[top--];
dcc[cnt].push_back(z);
}while(z!=y);
//注意这里此时x仍然在栈中,只不过我们把它加入了dcc而已 x并没有弹出栈
//这样当其他点双连通分量也包含割点x时 由于x仍然在栈中 因此能够找到割点x
dcc[cnt].push_back(x);
}
}
//否则说明节点y已经被访问过了,但是有可能节点y可以通过非树边(非父子边)追溯到更早的节点
//那么也可以更新
else
low[x]=min(low[x],dfn[y]);
}
}
int main()
{
memset(h,-1,sizeof h); //初始化表头
cin >>n>>m;
for(int i=1;i<=m;i++) //读入m条边
{
int a,b;
cin >>a>>b;
if(a==b)
continue;
//建立无向图
add(a,b);
add(b,a);
}
for(int i=1;i<=n;i++)
{
if(!dfn[i])
{
root=i;
tarjan(i);
}
}
//输出割点和所有的点双连通分量
for(int i=1;i<=cnt;i++)
{
printf("v-DCC #%d:",i);
for(int j=0;j<dcc[i].size();j++)
printf(" %d",dcc[i][j]);
cout <<endl;
}
return 0;
}