• 《啊哈算法》——割点、割边、二分图


      这篇文章我们简单的介绍求解图的割点、割边和二分图相关的概念。

      割点:

      对于含n个点、m条边的连通无向图G,如果去掉顶点vi(并同时去掉与之相连的边),使得G不再连通,那么称vi是一个割点。

      通过其定义,我们不难判断某个点是否是割点,但是现在我们面临的问题是,如何给出一个图G,编码让计算机求解割点呢?

      首先我们考虑这样一个问题,判定某个点的指标是什么。我们通过人脑来判断其是否是割点,其实是利用非常模糊的视觉效应,即“通过去掉该点观察图是否连通”即可,而如果想要通过计算机来判断,就需要非常量化的判断条件。

      我们考虑从深度优先搜索的角度来找到这样一个判断条件,利用dfs遍历图,得到的生成子图本质上会得到一个生成树,我们拿出两个相邻的点vi、vj,vi是vj的父节点。我们回到深搜遍历的过程中,假设当前遍历到vj,如果我们从vj能够找到一条回到已经访问过的v1、v2...等节点,那么这表明去掉vi,将不会影响剩余图的连通性。

      我们似乎发现了些什么,但是这种判定关系还是有些模糊。

      我们借用这样一个概念——时间戳,即深度优先搜索的过程中,我们记录访问节点的顺序,我们用num[i]来表示节点vi的时间戳,即在深搜遍历过程中第几个访问vi节点。借用这个工具,我们考虑能不能将上述我们描述的关系用量化的表达式表示出来呢?好像还是有点捉襟见肘啊,我们不妨再设置一个数组low[i],用以表达vi不经过dfs的生成树的父节点所能够到达的时间戳最小的节点(好好理解,非常拗口),基于这个工具,我们能够看到上述的判断条件,可以用这样一个表达式简洁的概括:

                                                                              low[j] < num[i]

      那么现在我们首要的问题似乎变成了求解n个节点的low[]、num[]了。

      首先,对于num[],也就是时间戳的记录,并不困难。而对于low[]数组的求解,就需要动一些脑筋了。我们模拟遍历过程,当前遍历到vi点,我们访问所有与vi连通的点vj,会出现如下两种情况。

      1.vj访问过,被我们打上过时间戳,  那么我们此时需要更新low[i]了,即low[i] = min{num[j] | vj与vi连通}。

      2.vj没有访问过,那么我们继续深搜遍历点的过程。

      在遍历完成之后,也完成了num[]、low[]的求解,我们再利用深搜的回溯过程,完成判断即可。

      这里需要注意的一点是,对于某个图的根节点,即dfs开始的那个点(记作v1)其实是不满足上文给出的判断式子的,需要我们特殊判断,记child是根节点的子树个数,则v1是个割点的必要条件是,child  = 2。

      简单的参考代码如下。

    #include<cstdio>
    #include<algorithm>
    using namespace std;
    
    int n , m , e[9][9] , root;
    int num[9] , low[9] , flag[9],index;
    int min(int a , int b)
    {
         return a < b ? a : b;
    }
    void dfs(int cur , int father)
    {
        int child = 0 , i , j;
    
          index++;
          num[cur] = index;
          low[cur] = index;
          for(i = 1;i <= n;i++)
          {
                if(e[cur][i] == 1)
                {
                       if(num[i] == 0)  //第一种情况
                       {
                             child++;
                             dfs(i,cur);
                             low[cur] = min(low[cur] , low[i]);//回溯过程:判断割点
    
                             if(cur != root && low[i] >= num[cur])
                                  flag[cur] = 1;
                             else if(cur == root && child == 2)
                                  flag[cur] = 1;
                       }
                       
                        else if(i != father) //第二种情况
                        {
                            low[cur] = min(low[cur] , num[i]);
                        }
    
                }
          }
    }
    
    int main()
    {
         int i , j, x , y;
         scanf("%d %d",&n,&m);
         for(i = 1;i <= n;i++)
              for(j = 1;j <= n;j++)
                 e[i][j] = 0;
         for(i = 1;i <= m;i++)
         {
              scanf("%d %d",&x,&y);
              e[x][y] = 1;
              e[y][x] = 1;
         }
    
         root = 1;
         dfs(1,root);
    
         for(i = 1;i <= n;i++)
         {
               if(flag[i] == 1)
                  printf("%d ",i);
         }
    
         return 0;
    }

      割边:

      有个割点的概念,割点非常好理解,即对于图G,如果删除边ei,导致G的连通度发生变化,那么ei即是G的一个割边。

      那么我们来继续思考如何利用编程实现求G的割边。

      基于上文我们对割点问题的思考,这里问题会显得非常简单,在判断割点的时候,我们利用的核心判断条件是low[j] >= num[i],其中vi是vj的父节点。那么拿到割边上来,我们分两部分看,如果low[j]>num[i],则表明去掉eij后,vj便不再和vj连通,这是符合割边定义的。而如果low[j] = num[i],则表明去掉eij,vj依然能够在不经过eij的情况下到达vi,连通度没有发生改变。

      因此我们可以看到,对于互相连通的父子节点vi、vj,满足 low[j]>num[i],可判定eij是一条割边。

      基于dfs找割点的代码,我们进行稍微的改动,有如下代码。

    #include<cstdio>
    #include<algorithm>
    using namespace std;
    
    int n , m , e[9][9] , root;
    int num[9] , low[9] , flag[9],index;
    int min(int a , int b)
    {
         return a < b ? a : b;
    }
    void dfs(int cur , int father)
    {
        int  i , j;
    
          index++;
          num[cur] = index;
          low[cur] = index;
          for(i = 1;i <= n;i++)
          {
                if(e[cur][i] == 1)
                {
                       if(num[i] == 0)  //第一种情况
                       {
    
                             dfs(i,cur);
                             low[cur] = min(low[cur] , low[i]);//回溯过程:判断割点
    
                             if(low[i] > num[cur])
                                  printf("%d-%d
    ",cur,i);
                       }
    
                        else if(i != father) //第二种情况
                        {
                            low[cur] = min(low[cur] , num[i]);
                        }
    
                }
          }
    }
    
    int main()
    {
         int i , j, x , y;
         scanf("%d %d",&n,&m);
         for(i = 1;i <= n;i++)
              for(j = 1;j <= n;j++)
                 e[i][j] = 0;
         for(i = 1;i <= m;i++)
         {
              scanf("%d %d",&x,&y);
              e[x][y] = 1;
              e[y][x] = 1;
         }
    
         root = 1;
         dfs(1,root);
    
    
         return 0;
    }

      二分图的最大匹配:

      我们先给出这样导入模型的实际问题:现有n个妹子和n个汉字相约去坐过山车,过山车的结构是两两一排,现在要求坐一排的必须是一男一女,并且要求两人必须相互认识,那么请问我们最多能安排多少对男女上过山车?

      我们将问题抽象化,将每个个体视为点,而男女之间的是否认识视为点与点之间的边,这里仅仅是强调了男生和女生的联系,其余的关系我们不考虑,因此我们将男生放入A集合(该集合中的元素之间不存在边),女生放入B集合,可以看到,这是典型的二分图。

      而我们将一对一对的男女送到两座一排的过山车的过程,抽象得来看,可以用图论中的术语——匹配,来表示。即将A中每个元素与B中的元素形成一一对应的关系,需要强调的是,只可以使一一对应的关系,而这种一一对应的匹配的对数,便称作匹配数量。即高度概括一下我们即将导入的模型——如何求解二分图的最大匹配数量。

      我们注意到“最多”这个字眼,容易联想到其与贪心算法有着密切的联系,因此我们考虑从这个角度给出一个计算二分图最大匹配数量的算法。

      考虑将全局问题给子问题化然后寻求局部最优解,这样方能引导出全局最优解。假设含2n个顶点的二分图的一个分图A含有n个顶点,我们依次遍历集合A中的点v1、v2...vn,我们从过程开始分析,假设当前遍历到vi点,显然,我们尽可能的将vi匹配到B集合中的某个和vi相连的点,是当前局部最优的策略,那么我们不妨再遍历B集合中与vi相连的点vi'、vj'......容易看到,对于这些点(以vi'为例),都会满足如下的两个性质之中的一个:

      1.vi'在之前A集合(i-1)个点遍历的过程中,并没有与前i-1个点匹配。

      2.vi'在之前A集合(i-1)个点遍历的过程中,和前i-1个点中的某个点进行了匹配。

      针对情况1,我们当然可以将vi与vi'匹配,则匹配数+1,这是当前的最优策略。

      针对情况2,就显得有些麻烦,既然vi'在A中已经有了匹配点,那么我们就此放弃vi了么?显然不是,我们需要经过深思熟虑才能决定是否放弃vi。我们注意到初始情况的二分图G是存在一对多的情况,即A集合中的某个点可能与B集合中的多个点均有边,这其实就像是在匹配的时候留下了“其他选项 ”,其实就十分像我们利用dfs实现的回溯寻找迷宫,而我们在面对这种情况的时候,则需要利用dfs来回溯回去来尝试所有这些“其他选项”,判断在A集合前i-1个点在最大匹配数的基础上,能否将vi返回情况1.如果存在,那则匹配数+1,这是当前的最优策略;如果不存在,那么即可放弃vi的匹配(想一想,为什么这里就可以直接放弃了,这种做法其实和前面的铺垫是自洽的)。

      理解了上文对求解二分图最大匹配数算法的过程的描述,其正确性是不言自明的。概括起来,它本质上是一种贪心策略和基于dfs的穷举策略。

      简单的参考代码如下。

    #include<cstdio>
    using namespace std;
    
    int e[101][101];
    int match[101];
    int book[101];
    int n , m;
    int dfs(int u)
    {
         int i;
         for(i = 1;i <= n;i++)
         {
              if(book[i] == 0 && e[u][i] == 1)
              {
                    book[i] = 1;
                       if(match[i] == 0 || dfs(match[i]))
                       {
                            match[i] = u;
                            match[u] = i;
                            return 1;
                       }
              }
         }
    
         return 0;
    }
    
    int main()
    {
          int i , j , t1 , t2 , sum = 0;
          scanf("%d %d",&n,&m);
    
          for(i = 1;i <= m;i++)
          {
                scanf("%d %d",&t1,&t2);
                e[t1][t2] = 1;
                e[t2][t1] = 1;
          }
    
            for(i = 1;i <= n;i++)   match[i] = 0;
    
            for(i = 1;i <= n;i++)
            {
                  for(j = 1;j <= n;j++)  book[j] = 0;
                      if(dfs(i))  sum++;
            }
    
            printf("%d",sum);
    
            return 0;
    }
  • 相关阅读:
    Java接口的实现理解
    RDP |SSH |VNC简介
    关于彻底理解cookie,session,token的摘录,生动形象
    7.Reverse Integer&#160;&#160;
    1.Two Sum
    图形化编程娱乐于教,Kittenblock实例,播放与录制声音
    图形化编程娱乐于教,Kittenblock实例,一只思考的变色猫
    内存条性能参数查询(任务8)
    任务8选配内存,重点解读兼容与接口的搭配技术,解读选配内存的过程
    图形化编程娱乐于教,Kittenblock实例,键盘操控角色
  • 原文地址:https://www.cnblogs.com/rhythmic/p/5515819.html
Copyright © 2020-2023  润新知