无向图的双连通分量

无向图的双连通分量


割点和割边

  • 割点:在无向连通图中,删除一个顶点以及和它相邻的所有边,图中的连通分量个数增加,则该顶点称为割点

  • 割边(桥):在无向连通图中,删除一条边,图中的连通分量个数增加,则该条边称为割边或者桥

举个栗子:

割点:

无向图的双连通分量_第1张图片

割边:

无向图的双连通分量_第2张图片


边连通分量和点连通分量

  • 边双连通图:如果在无向图中不存在割边,则称它为边双连通图。在边双连通图中,任意两个节点之间都存在两条及以上的路径,并且这些路径上的边互不重复
  • 点双连通图:如果在无向图中不存在割点,则称它为点双连通图。在点双连通图中,如果节点数大于2,则在任意两个节点之间都存在两条及以上路径,并且这些路径上的点互不重复
  • 边双连通分量:无向图的极大边双连通子图被称为边双连通分量,记作e-DCC。边双连通分量就是删掉桥之后所留下的连通块,连接两个边双连通分量的边就是桥
  • 点双连通分量:无向图的极大点双连通子图被称为点双连通分量,记作v-DCC

点双连通分量的性质:

  • 点双连通分量之间以割点连接,且两个点双连通分量之间有且只有一个割点
  • 每一个割点可以任意属于多个点双连通分量,因此求点双连通分量时,可能包含重复的点
  • 只有一条边连通的两个点也是点双连通分量

一张图无向连通图是“点双连通图”,当且仅当满足以下两个条件之一:

  • 图的顶点个数不超过2
  • 图中任意两点都同时包含至少一个简单环,其中“简单环”是指不自交的环,也就是我们通常画出的环。

一张无向连通图是“边双连通图”,当且仅当任意一条边都包含在至少一个简单环中。

总结:

对于一个连通图,如果任意两点之间至少存在两条没有重复节点的路径,则称这个图为点双连通的(简称双连通);如果任意两点之间至少存在两条没有重复边的路径,则称该图为边双连通的。点双连通图的定义等价于任意两条边都同在一个简单环中,而边双连通图的定义等价于任意一条边至少在一个简单环中。对一个无向图,点双连通的极大子图称为点双连通分量(简称双连通分量),边双连通的极大子图称为边双连通分量。在每一个点双连通图中,内部无割点;在每一个边双连通图中,内部无桥。

举个栗子:

边双连通图:

无向图的双连通分量_第3张图片

边双连通分量:

无向图的双连通分量_第4张图片

点双连通图:

点双连通分量:

无向图的双连通分量_第5张图片


求解割点和割边

割点判定法则:

  • 如果 x x x不是根节点,那么 x x x是割点,当且仅当在搜索树上存在 x x x的一个子节点 y y y,满足 l o w [ y ] ≥ d f n [ x ] low[y]\geq dfn[x] low[y]dfn[x]
  • 如果 x x x是根节点,那么 x x x是割点,当且仅当在搜索树上至少存在两个子节点 y i y_i yi,满足 l o w [ y i ] ≥ d f n [ x ] low[y_i]\geq dfn[x] low[yi]dfn[x]

先来看第一种,分为以下三个式子讨论:

如果 l o w [ y ] > d f n [ x ] low[y]>dfn[x] low[y]>dfn[x],如下图所示:

无向图的双连通分量_第6张图片

如果 l o w [ y ] = d f n [ x ] low[y]=dfn[x] low[y]=dfn[x],如下图所示:

无向图的双连通分量_第7张图片

如果 l o w [ y ] < d f n [ x ] low[y]low[y]<dfn[x],如下图所示:

无向图的双连通分量_第8张图片

综上可知,当 l o w [ y ] ≥ d f n [ x ] low[y]\geq dfn[x] low[y]dfn[x]时,才能说明节点 x x x的一个割点

接着再来看第二种,分两种情况讨论:

如果 x x x是根节点,但是它只有一个子节点:

无向图的双连通分量_第9张图片

如果 x x x是根节点,但是它至少有两个子节点:

无向图的双连通分量_第10张图片

割边判定法则:

无向边 ( 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]时:

无向图的双连通分量_第11张图片

l o w [ y ] = d f n [ x ] low[y]=dfn[x] low[y]=dfn[x]时:

无向图的双连通分量_第12张图片

l o w [ y ] < d f n [ x ] low[y]low[y]<dfn[x]时:

无向图的双连通分量_第13张图片

综上,当 l o w [ y ] > d f n [ x ] low[y]>dfn[x] low[y]>dfn[x]时,才能说明边 ( x , y ) (x,y) (x,y)是一条割边

注意,在无向图中,求割边时,孩子节点到父节点的边是不用处理的:

原因如下:

无向图的双连通分量_第14张图片


成对变换

对于非负整数 n n n

  • n n n是偶数时, n n n XOR 1 1 1 等于 n + 1 n+1 n+1
  • n n n是奇数时, n n n XOR 1 1 1 等于 n − 1 n-1 n1

因此“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)的存储位置了(存储位置也就是这条边的编号)

如下图:

无向图的双连通分量_第15张图片

从中我们发现成对变换可以帮助我们在求割边时避免从子节点走回到父节点。

问题:如何理解割边的代码模板中的i!=(from^1)呢?

无向图的双连通分量_第16张图片


点双连通分量

若某个节点为孤立节点,则它自己单独构成一个v-DCC。除了孤立节点外,点双连通分量的大小至少为2。注意一个割点可能属于多个点双连通分量。

下面的无向图共有2个割点(节点1和节点6),4个点双连通分量,深色部分表示点双连通分量:
无向图的双连通分量_第17张图片
为了找出“点双连通分量”,需要在tarjan算法的过程中维护一个栈,并按照如下方法维护栈中的元素:

  • 当一个节点第一次被访问时,把该节点入栈
  • 当割点判定法则中的条件 l o w [ y ] ≥ d f n [ x ] low[y]\geq dfn[x] low[y]dfn[x]成立时,无论 x x x是否为根,都要:
    • 从栈顶不断弹出节点,直至节点 y y y被弹出(注意不是节点 x x x被弹出)
    • 刚才弹出的所有节点与节点 x x x一起构成一个点双连通分量

问题:为什么是弹到 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;
}

你可能感兴趣的:(算法,无向图的双连通分量)