洛谷New Reform题解暨计算无向图的环数与非环数

我的QQ是2315606389,有什么问题可以联系我哦……
这里先行附上题目链接CF659E New Reform

题意:
有n(1<=n<=100000)个城市,m(1<=m<=100000)条双向道路,没有一个城市存在自己到自己的道路,两个不同的城市间,最多有一条道路,也不能保证能从一个城市到达任意一个其他城市。

现在需要对每一条道路定向使之成为单向道路,当然需要尽可能少地产生孤立的城市。当其他所有城市都不能到达某个城市,则称这个城市为孤立城市。

输入格式:
第一行,两个整数,n和m。
接下来m行,每行两个整数,表示一条双向道路。
输出格式: 一行,一个整数,表示最少的孤立城市

既然是双向道路,而且一个城市反映在地图上就是一个点,那么显然我们可以用无向图这种数据结构来刻画整个城市群。举个栗子,
在这里插入图片描述

题意理解

分类讨论

题意要求(这里着重注意)下,既然要寻找孤立城市,那我们首先应该先思考哪些样式的城市群在道路定向后会产生孤立城市,甚至我们也应该想想有没有特定模式的城市群在道路定向后一定不会产生孤立城市。

环状城市群是不是一定产生孤立城市呢?
在这里插入图片描述
显然,是可以的。比如
在这里插入图片描述
将1<—>4与2<—>4变成4—>1和4—>2后,显然,4成了任何城市都不能到达的孤立城市。上图中任何一点在经历同样操作后都可以成为孤立城市。但是,题目的要求是在这里插入图片描述
也就是说,能不产生孤立城市就不产生。那我们再回过头来看刚才的图,环状城市群在题意条件下能不产生孤立城市吗?
在这里插入图片描述
显然这是一定可以做到的!所以环状城市群在道路定向后是可以一定不产生孤立城市的

单独的线状城市群呢?
在这里插入图片描述
读者们可以自己尝试画一下,这里不再一一展示。直接给出结论:对于单独的线状城市群,道路定向后,至少会有一个孤立城市

再考虑一下稍微复杂的情况:环状加线状
在这里插入图片描述
事实上,这种情况也是可以做到一定不产生孤立城市的。
只需以线环交点为中心,向线的两个延伸段进行定向。
在这里插入图片描述
两个(或多个)相交的线状城市群(注意:一定不能构成环):

在这里插入图片描述
上图共有5条线。显然,只要不构成环,即使是多个线状线状城市群相交,在道路定向后,也一定可以做到最少只有一个孤立城市

结论归纳

  1. 只要有环状城市群,那么该环状城市群和与其相连的线状城市群构成的复杂城市群最少产生的孤立城市为0
  2. 单独的线状城市群最少产生的孤立城市为1
  3. 相交的线状城市群最少产生的孤立城市为1
  4. 单独的城市一定为孤立城市,其产生的最少孤立城市为1

题意转换

因为结论1,所以我们可以把与环状城市群相连的线状城市群也看成环状城市群。因此,此题实际上就是让我们在给定的图中找出不属于环的城市群(集合)的个数。

注意:题目测试用例给出的图不一定是连通图!!!
在这里插入图片描述

举例子

  1. 下面城市群的最少孤立城市数 1(5个相交线状城市群)

在这里插入图片描述

  1. 下面城市群最少孤立城市为0(一个环状)

  2. 如下城市群最少孤立城市为0(1个环状加2个线状)
    在这里插入图片描述

题解(并查集)

根据之前的题意分析,我们可以发现实际上,此题就是让我们计算
在这里插入图片描述
红圈中的三类实际上就是非环。
又根据之前的题意转换,
在这里插入图片描述
结合并查集的特点,所以并查集成了此题的优解。

不了解并查集的读者可以参考如下链接:
并查集详解(超级简单有趣~~就学会了)
并查集详解(我自己写的)

若仍不理解的读者可以这样想:

并查集里有一个概念叫做根结点。我们可以把根结点形象理解为一个小队的队长,这个队长管理着若干个队员(子结点)(从0到n不等)。
显然,如果某个队长的队员数为0,那么它的根结点就是自己(光杆司令)。
从数学角度来理解,那个某个队长和它的若干队员就构成了一个对外独立的集合(可以理解成我们在日常生活中所说的小团体)。

而这道题之所以可以用并查集,是因为出题者是想让我们在题目给定的图中,

  1. 先根据是否成环将整个图划分为若干个集合(每个集合都有一个队长,也就是根结点);
  2. 最后找出所有未被标记过根结点的数目!!!

此题AC代码

对代码有疑惑可以继续往下看解析……
我在命名数组时喜欢用对应义项的英文单词……

#include <stdio.h>
#include <stdlib.h>

//并查集是一种树型的数据结构,用于处理一些不相交集合(Disjoint Sets)
//的合并及查询问题.常常在使用中以森林来表示

int visit[100];//记录结点的标记情况
int filiation[100];//filiation意为父子关系,此数组用于存储两结点的上下级关系

int search_root_node(int random_node)//查找根结点;random_node任意结点
{
    int root_node;//root_node用于储存最后找到的根结点
    int intermediary=random_node;//intermediary意为中介
    //对于根结点root_node,有filiation[root_node]==root_node;
    //非根结点元素不满足此关系
    while(filiation[intermediary]!=intermediary)//若当前元素的上级不是根结点
    {
        intermediary=filiation[intermediary];//继续查看上级的上级是不是根结点
    }
    //退出循环后intermediary即为根结点
    root_node=intermediary;
    return root_node;
}

int merger(int random_node,int root_node)//merge意为合并,即路径压缩
{//将查找路径中所有属于根结点 但上级不是根结点的 结点的上级换成根结点
    int j;
    while(random_node!=root_node)//若某个结点的上级不是根结点
    {
        j=filiation[random_node];
        filiation[random_node]=root_node;//将该结点的上级换成根结点
        random_node=j;
    }
    return 0;
}

int main()
{
    int sum=0;
    int n,m,i,a,b;//n为结点总数,m为路径数量
    int a_root,b_root;
    scanf("%d%d",&n,&m);
    for(i=1;i<=n;i++)//刚开始所有结点的父结点都是自己
    {
        filiation[i]=i;
    }
    for(i=1;i<=m;i++)
    {
        scanf("%d%d",&a,&b);
        //寻找a、b的根结点
        a_root=search_root_node(a);
        b_root=search_root_node(b);
        //路径压缩(也可以不要路径压缩)
        merger(a,a_root);
        merger(b,b_root);
        if(a_root!=b_root)//说明a,b两结点应属于同一个根结点但没有
        {
            filiation[a_root]=b_root;//连接a_root与b_root,使b_root作为a_root的根结点
            ifvisit[a_root]||visit[b_root])//两个根结点中只要有一个结点被访问过,就说明有环
            {
                visit[a_root]=visit[b_root]=1;
            }
        }
        else//进入else说明a,b共属于一个环
        {//也说明a_root==b_root
            visit[a_root]=1;//将这个环状城市群的根结点打上标记
        }
    }
    for(i=1;i<=n;i++)
    {
        if(!visit[i]&&filiation[i]==i)!visit[i]说明城市i未被标记,
        {//filiation[i]==i说明城市i是根结点;两者结合在一起共同说明城市i不属于环
            sum++;//既然不属于环,一定会产生1个孤立城市
        }
    }
    printf("%d",sum);
    return 0;
}

代码解析

有朋友可能对这段代码的作用和逻辑感到疑惑,容我来稍稍分析。

if(a_root!=b_root)//说明a,b两结点应属于同一个根结点但没有
{
       filiation[a_root]=b_root;//连接a_root与b_root,使b_root作为a_root的根结点
       if(visit[a_root]||visit[b_root])//两个根结点中只要有一个结点被标记过,就说明有环
        {
           visit[a_root]=visit[b_root]=1;
        }
}
else//进入else说明a,b共属于一个环,也说明a_root==b_root
{
       visit[a_root]=1;//将这个环状城市群的根结点打上标记
}

我先举个例子(以下没有路径压缩)

  1. 先输入n=3,m=3,便有了以下的图:
    在这里插入图片描述
  2. 输入1 、2
    在这里插入图片描述
  3. 输入2、3
    在这里插入图片描述
  4. 输入a=1、b=3
    经过根结点寻找后,发现结点1的根结点为3,于是a_root=b_root=3而输入的1、3表示结点1、3之间有边,说明结点1 、3 之间有2条路径(如图),结点1、3属于同一个环
    在这里插入图片描述于是程序进入
else//进入else说明a,b共属于一个环,也说明a_root==b_root
{
       visit[a_root]=1;//将这个环状城市群的根结点打上标记
}

将visit [ 3 ]=1,表明结点3是一个环的根结点。注意,刚开始所有结点的visit都为0,第一次对visit赋值无论如何只能是在else内进行的(想想为什么???)
这样一来,相信大家也能理解if内判断语句的含义了

if(visit[a_root]||visit[b_root])//两个根结点中只要有一个结点被标记过,就
{//说明a_root与b_root中至少有一个结点要么是环的一部分要么与环相连
      visit[a_root]=visit[b_root]=1;//这样一来两个中的另一个结点也必须属于这个环状集合
}
							~~手动分割~~ 

利用深度优先搜索在无向图中找环(题外话)

正确思路

一般我们在进行深搜时,所建立的标记数组visit[ ]只有0(没遍历)和1(已遍历)两个值,分别对应结点的两种状态。但在找环时,一个结点不只有两种状态。我们知道,对于无向图,每个结点都有i(0、1、……n)条与其相连的边(可以理解为路径)。
所以每个结点可以有三种状态

0——未访问过该点
1——已访问过该点,但未遍历完该点的路径
2——已访问过该点,且已遍历完该点的路径

我们再建立一个数组father_point来存储环的每个结点。father_point[ i ]表示结点 i 的前驱结点为father_point[ i ]。当我们找到一个完整的环后,便将其打印出来。之所以将整个数组命名为father_point,是因为环上任意两个相邻的结点存在着类似于父与子的关系,所以以下我把这个数组称为父系数组

接下来再谈谈对每个结点定义三种状态的原因。

当我们在遍历当前结点now的分支路径的途中,如果发现某个结点 i 对应的visit[ i ]==1(说明结点 i 已经遍历过),并且 i ≠ father[ now ]即结点 i 不是结点 now 的父结点时(为什么要加上这个判断读者可以思考),就可以证明我们已经找到一个完整的环
读者可参照下图对代码加深理解(从结点1出发),
在这里插入图片描述

完整代码

#include <stdio.h>
#include <stdlib.h>

int father_point[100];//father[i]表示i的前驱结点
int map[100][100],visit[100];//map为邻接矩阵
int n,m,sum;//n为结点数目,sum为环总数

int DFS(int now)//now为当前结点
{
    int i;
    visit[now]=1;//表示当前结点已经访问过,但还没有遍历完它的所有分支
    for(i=1;i<=n;i++)//遍历当前结点now所有可能存在的路径
    {
        if(map[now][i])//有路径
        {
            if(visit[i]==0)//此结点未被访问过
            {
                father_point[i]=now;//now是i的父结点
                DFS(i);//继续搜索
            }
            else if(visit[i]==1&&i!=father_point[now])//找到环
            {
                //打印整个环
                int temp=now;
                printf("ring:%d->",i);
                while(temp!=i)//沿父系数组father_point向前搜索
                {
                    printf("%d->",temp);
                    temp=father_point[temp];
                }
                printf("%d\n",temp);
            }
        }
    }
    visit[now]=2;//now这个点已经访问过,且它的所有分支已经遍历完
    return 0;//函数返回
}

int main()
{
    int i,j,a,b;
    printf("input n and m!\n");
    scanf("%d%d",&n,&m);//输入数据
    printf("n=%d m=%d\n",n,m);
    for(i=1;i<=m;i++)
    {
        printf("input a and b\n");
        scanf("%d%d",&a,&b);
        //无向图的路径是对称的
        map[a][b]=1;
        map[b][a]=1;
    }
    for(i=1;i<=n;i++)
    {
            if(!visit[i])//若结点i未被访问过
            {
                DFS(i);//搜索该结点的所有路径
            }
    }
    return 0;
}

前述示例的运行结果
在这里插入图片描述

错误思路解析

以下可以略去不看有兴趣者可以一览

或许很多人(包括我)刚开始的思路是这样的:

从1号点开始,尽可能遍历所有分支。每经过一个点,便给其打上标记,防止重复遍历。当遍历到某一个已经被打上标记的点 j ,便说明已经找到一个环。

随思路构建的代码

#include <stdio.h>
#include <stdlib.h>

int map[100][100],visit[100];//map为邻接矩阵
int n,m,sum;//n为结点数目,m为边(道路)数目,sum为环的总数

int DFS(int now)//now为当前结点
{
    int i;
    visit[now]=1;//标记当前结点
    for(i=1;i<=n;i++)//遍历当前结点now所有可能存在的路径
    {
        if(map[now][i])//有路径
        {
            if(!visit[i])//此结点未被访问过
            {
                DFS(i);//沿该路径继续搜索
            }
            else//此结点之前已访问过
            {
                sum++;//环总数加一
            }
        }
    }
    return 0;
}

int main()
{
    int i,a,b;
    printf("input n and m!\n");
    scanf("%d%d",&n,&m);//输入结点数目与边数
    printf("n=%d m=%d\n",n,m);
    for(i=1;i<=m;i++)
    {
        printf("input a and b\n");
        scanf("%d%d",&a,&b);
        //无向图的路径是对称的
        map[a][b]=1;
        map[b][a]=1;
    }
    for(i=1;i<=n;i++)
    {
        if(!visit[i])//该结点未访问过
        {
            DFS(i);//沿该结点进行搜索
        }
    }
    printf("sum=%d\n",sum);//输出环总数
    return 0;
}

上述代码运行实例:
在这里插入图片描述
在这里插入图片描述
只有两个结点,一条道路1—>2,也就是说没有环,但是最后输出的结果却是1.
之所以我们会有这个思路,我想可能是因为忽略了无向图的路径是对称的

//无向图的路径是对称的
        map[a][b]=1;
        map[b][a]=1;

当输入1 2后,此时邻接矩阵map中,map[ 1 ][2]=map[2][ 1 ] =1.
当我们从1结点开始,遍历到2结点后,此时visit[ 1]=visit[2]=1.
又因为DFS函数中的for循环,且map[2][ 1 ]=1,visit[ 2 ]=1,所以程序就会进入else 中,sum就会加一,导致输出错误的结果。

for(i=1;i<=n;i++)//遍历当前结点now所有可能存在的路径
    {
        if(map[now][i])//有路径
        {
            if(!visit[i])//此结点未被访问过
            {
                DFS(i);//沿该路径继续搜索
            }
            else//此结点之前已访问过
            {
                sum++;//环总数加一
            }
        }
    }

也就是说,在这种思路下,同一条边会被遍历两次!!!

更错误的思路

在上面那种思路下,同一条边会被遍历两次。为了解决这个问题,有人可能会说,我可以在从i结点—>j结点后,将map[ j ][i ]赋零(如此一来,i----j就从双向路径变成了单向路径)。这样的话因为判断语句

if(map[now][i])//有路径

的存在,就不会重复遍历i----j这条边。于是得到如下代码(比之前的错误代码只多了一行代码),


        if(map[now][i])//有路径
        {
        	map[i][now]=0;//将遍历过的边进行定向
            if(!visit[i])//此结点未被访问过
            {
                DFS(i);//沿该路径继续搜索
            }
            else//此结点之前已访问过
            {
                sum++;//环总数加一
            }
        }

嗯……这个思路看似可行,实际上也是错误的。
举个简单的例子就能说明,对于如下无向图,
在这里插入图片描述
读者可以自行对节点编号,进行运行,比对自己的手工计算结果(正确环数为5)。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值