第五节——图的遍历

专栏

算法(C语言)

第五节——图的遍历

深度和广度优先究竟是指啥

前面我们已经学习过深度和广度优先搜索。为什么叫做深度和广度呢?其实是针对图的遍历而言的,请看下面这个图。

图

哦,忘记说了什么是图。简单地说,图就是由一些小圆点(称为顶点)和连接这些小圆点的直线(称为边)组成的。例如上图是由五个顶点(编号为1、2、3、4、5)和5条边(1-2、1-3、1-5、2-4、3-5)组成。
现在我们从1号顶点开始遍历这个图,遍历就是指把图的每一个顶点都访问一次。使用深度优先搜索来遍历这个图将会得到如下的结果。

使用深度优先搜索来遍历

这五个顶点的被访问顺序如下图。图中每个顶点右上方的数就表示这个顶点是第几个被访问到的,我们还为这个数起了很好听的名字——时间截。

时间戳

使用深度优先搜索来遍历这个图的过程具体是:首先从一个未走到过的顶点作为起始顶点,比如以1号顶点作为起点。沿1号顶点的边去尝试访问其它未走到过的顶点,首先发现2号顶点还没有走到过,于是来到了2号顶点。再以2号顶点作为出发点继续尝试访问其它未走到过的顶点,这样又来到了4号顶点。再以4号顶点作为出发点继续尝试访问其它未走到过的顶点。但是,此时沿4号顶点的边,已经不能访问到其它未走到过的顶点了,所以需要返回到2号顶点。返回到2号顶点后,发现沿2号顶点的边也不能再访问到其它未走到过的顶点。因此还需要继续返回到1号顶点。再继续沿1号顶点的边看看还能否访问到其它未走到过的顶点。此时又会来到3号顶点,再以3号顶点作为出发点继续访问其它未走到过的顶点,于是又来到5号顶点。到此,所有顶点都走到过了,遍历结束。

深度优先遍历的主要思想就是:首先以一个未被访问过的顶点作为起始顶点,沿当前顶点的边走到未访问过的顶点;当没有未访问过的顶点时,则回到上一个顶点,继续试探访问别的顶点,直到所有的顶点都被访问过。显然,深度优先遍历是沿着图的某一条分支遍历直到末端,然后回溯,再沿着另一条进行同样的遍历,直到所有的顶点都被访问过为止。那这一过程如何用代码来实现呢?在讲代码实现之前我们先来解决如何存储一个图的问题。最常用的方法是使用一个二维数组 e 来存储,如下。

二维数组

上图二维数组中第 i 行第 j 列表示的就是顶点 i 到顶点 j 是否有边。1 表示有边,∞表示没有边,这里我们将自己到自己(即 i 等于 j )设为 0。我们将这种存储图的方法称为图的邻接矩阵存储法。

注意观察的同学会发现这个二维数组是沿主对角线对称的,因为上面这个图是无向图。所谓无向图指的就是图的边没有方向,例如边 1-5 表示,1 号顶点可以到5号顶点,5 号顶点也可以到 1 号顶点。

接下来要解决的问题就是如何用深度优先搜索来实现遍历了。

void dfs(int cur)//cur是当前所在的顶点编号
{
    printf("%d ",cur);
    sum++;//每访问一个顶点sum++
    if(sum==n) return ;//所有顶点都已经访问过则直接退出
    for(i=1;i<=n;i++)//从1号顶点到n号顶点依次尝试,看哪些顶点与当前顶点cur有边相连
    {
        //判断当前顶点cur到顶点i是否有边,并判断顶点i是否已经访问过
        if(e[cur][i]==1 && book[i]==0)
        {
            book[i]=1;//标记顶点i已经访问过
            dfs(i);//从顶点i再出发继续遍历
        }
    }
    return;
}

在上面的代码中变量 cur 存储的是当前正在遍历的顶点,二维数组 e 存储的就是图的边(邻接矩阵),数组 book 用来记录哪些顶点已经访问过,变量 sum 用来记录已经访问过多少个顶点,变量 n 存储的是图的顶点的总个数。完整代码如下。

#include <stdio.h>
int book[101],sum,n,e[101][101];
void dfs(int cur)//cur是当前所在的顶点编号
{
    printf("%d ",cur);
    sum++;//每访问一个顶点sum++
    if(sum==n) return ;//所有顶点都已经访问过则直接退出
    for(i=1;i<=n;i++)//从1号顶点到n号顶点依次尝试,看哪些顶点与当前顶点cur有边相连
    {
        //判断当前顶点cur到顶点i是否有边,并判断顶点i是否已经访问过
        if(e[cur][i]==1 && book[i]==0)
        {
            book[i]=1;//标记顶点i已经访问过
            dif(i);//从顶点i再出发继续遍历
        }
    }
    return;
}
int main()
{
    int i,j,m,a,b;
    scanf("%d %d",&n,&m);
    //初始化二维矩形
    for(i=1;i<=n;i++)
        for(j=1;j<=n;j++)
            if(i==j) e[i][j]=0;
    			else e[i][j]=99999999;	//我们假设99999999为正无穷
    
    //读入顶点之间的边
    for(i=1;i<=m;i++)
    {
        scanf("%d %d",&a,&b);
        e[a][b]=1;
        e[b][a]=1;//这里是无向图,所以需要将e[b][a]也赋为1
    }
    
    //从1号城市出发
    book[1]=1;//标记1号顶点已访问
    dfs(1);//从1号顶点开始遍历
    
    getchar();getchar();
    return 0;
}

可以输入以下数据进行验证。

5 5
1 2
1 3
1 5
2 4
3 5

运行结果是:

1 2 3 4 5

继续使用广度优先搜索来遍历这个图的结果如下。

使用广度优先搜索来遍历

这五个顶点的被访问顺序如下图。

时间戳1

使用广度优先搜索来遍历这个图的过程如下。首先以一个未被访问过的顶点作为起始顶点,比如以 1 号顶点为起点。将 1 号顶点放入到队列中,然后将与 1 号顶点相邻的未访问过的顶点即 2 号、3 号和 5 号顶点依次再放入到队列中。如下图。

队列

接下来再将 2 号顶点相邻的未访问过的顶点 4 号顶点放入到队列中。到此所有顶点都被访问过,遍历结束。如下图。

队列1

广度优先遍历的主要思想就是:首先以一个未被访问过的顶点作为起始顶点,访问其所有相邻的顶点,然后对每个相邻的顶点,再访问它们相邻的未被访问过的顶点,直到所有顶点都被访问过,遍历结束。代码实现如下。

#include <stdio.h>
int main()
{
    int i,j,n,m,a,b,cur,book[101]={0},e[101][101];
    int que[10001],head,tail;
    scanf("%d %d",&n,&m);
    //初始化二维矩形
    for(i=1;i<=n;i++)
        for(j=1;j<=n;j++)
            if(i==j) e[i][j]=0;
    			else e[i][j]=99999999;	//我们假设99999999为正无穷
    
    //读入顶点之间的边
    for(i=1;i<=m;i++)
    {
        scanf("%d %d",&a,&b);
        e[a][b]=1;
        e[b][a]=1;//这里是无向图,所以需要将e[b][a]也赋为1
    }
    
    //队列初始化
    head=1;
    tail=1;
    
    //从1号顶点出发
    que[tail]=1;
    tail++;
    book[1]=1;//标记1号顶点已访问
    
    //当队列不为空的时候循环
    while(head<tail)
    {
        cur=que[head];//当前正在访问的顶点编号
        for(i=1;i<=n;i++)//从1~n依次尝试
        {
            //判断从顶点cur到顶点i是否有边,并判断顶点i是否已经访问过
            if(e[cur][i]==1 && book[i]==0)
            {
                //如果从顶点cur到顶点i有边,并且顶点i没有被访问过,则将顶点i入队
                que[tail]=i;
                tail++;
                book[i]=1;//标记顶点i已访问
            }
            //如果tail大于n,则表示所有的顶点都已经被访问过
            if(tail>n)
            {
                break;
            }
        }
        head++;//注意这地方,千万不要忘记当一个顶点扩展结束后,head++,然后才能继续往下扩展
    }
    
    for(i=1;i<tail;i++)
        printf("%d ",que[i]);
    
    getchar();getchar();
    return 0;
}

可以输入以下数据进行验证。

5 5
1 2
1 3
1 5
2 4
3 5

运行结果是:

1 2 3 5 4

使用深度优先搜索和广度优先搜索来遍历图都将会得到这个图的生成树。那么什么叫做生成树,生成树又有哪些作用呢?我们将在第 8 节详细讨论。本章我们先来看看图有什么作用,它究竞能解决什么实际问题。请看下节——城市地图。

图的深度优先遍历

暑假小明想到小红家里去玩,小明和小红住在不同的城市,并且小明之前从来没有去过小红家,这是小明第一次上门。怎么去呢?小明便想起了百度地图。百度地图一下子就给出了从小明家到小红家的最短行车方案。爱思考的小明想知道百度地图是如何计算出最短行车方案的。下面就是城市的地图。

城市的地图

数据是这样给出的,如下。

5 8
1 2 2
1 5 10
2 3 3
2 5 7
3 1 4
3 4 4
4 5 5
5 3 3

第一行的 5 表示有 5 个城市(城市编号为 1~5),8 表示有 8 条公路。接下来 8 行每行是一条类似 “a b c” 这样的数据,表示有一条路可以从城市 a 到城市 b,并且路程为 c 公里。需要注意的是这里的公路都是单行的,即 "a b c” 仅仅表示一条路可以从城市 a 到城市 b,并不表示城市 b 也有一条路可以到城市 a。小明家在 1 号城市,小红家在 5 号城市。现在请求出 1 号城市到 5 号城市的最短路程(也叫做最短路径)。

已知有 5 个城市和 8 条公路,我们可以用一个 5*5 的矩阵(二维数组 e)来存储这些信息,如下。

任意两个城市之间的地图

上面这个二维矩阵表示了任意两个城市之间的路程。比如 e[1] [2]的值为 2 就表示从 1 号城市到 2 号城市的路程为 2 公里。∞表示无法到达,比如 e[1] [3]的值为∞,表示目前从 1 号城市不能到达 3 号城市。另外,此处我们约定一个城市自己到自己的距离是 0。

接下来我们就要寻找从 1 号城市到 5 号城市的最短路程(最短路径)了。首先从 1 号城市出发,那么 1 号城市可以到哪些城市呢?从二维数组 e 的第一行可以看出 1 号城市可以到 2 号城市和 5 号城市。那此时是先到 2 号城市呢,还是先到 5 号城市呢?这里还是需要规定一个顺序,比如按照从 1 到 n 的顺序。现在先选择到 2 号城市,到达 2 号城市后接下来又该怎么办呢?依旧参照刚才对 1 号城市的处理办法,再来看 2 号城市可以到哪些城市。从二维数组 e 的第二行可以看出从 2 号城市可以到 3 号城市和 5 号城市。按照刚才规定好的顺序,我们先到 3 号城市。同理,3 号城市又可以到 4 号城市,4 号城市可以到 5 号城市(5号城市为最终目标城市),此时我们就已经找出了一条从 1 号城市到 5 号城市的路径,这条路径是 1→2→3→4→5 ,路径的长度是 14。那是不是算法到此就结束了呢?

还远远没有,因为 1→2→3→4→5 这条路径的长度并不一定是最短的。因此我们还需要再返回到 4 号城市看看还有没有别的路可以让路径更短一点。但是我们发现 4 号城市除了可以到 5 号城市,便没有别的路可以走了。此时需要返回到 3 号城市,我们发现 3 号城市除了一条路通往 4 号城市也没有其他的路了。此时又继续回到 2 号城市,我们发现 2 号城市还有一条路可以到5号城市,于是就产生了 1→2→5 这条路径,路径长度为 9。在对 2 号城市的所有路都尝试过后,又返回到了 1 号城市。我们发现 1 号城市还有一条路是通向 5 号城市的,于是又产生了一条路径 1→5 ,路径长度为 10。至此我们已经找出了所有从 1 号城市到 5 号城市的通路,一共有 3 条,分别是:

​ 1→2→3→4→5 路径长度为 14

​ 1→2→5 路径长度为 9

​ 1→5 路径长度为 10

我们可以用一个全局变量 min 来更新每次找到的路径的最小值,最终找到的最短路径为 9。此外还需要一个 book 数组来记录哪些城市已经走过,以免出现 1→2→3→1 这样的无限死循环。代码如下。

#include <stdio.h>
int min=99999999,book[101],n,e[101][101];//我们这里假设99999999为正无穷

//cur是当前所在的城市编号,dis是当前已经走走过的路程
void dfs(int cur,int dis)
{
    int j;
    //如果当前走过的路径已经大于之前找到的最短路,则没有必要再往下尝试了,立即返回
    if(dis>min) return;
    if(cur==n)//判断是否到达了目标城市
    {
        if(dis<min) min=dis;//更新最小值
        return ;
    }
    
    for(j=1;j<=n;j++)
    {
        //判断当前城市cur到城市j是否有路,并判断城市j是否在已经走过的路径中
        if(e[cur][j]!=99999999 && book[j]==0)
        {
            book[j]=1;//标记城市j已经在路径中
            dfs(j,dis+e[cur][j]);//从城市j再出发,继续寻找目标城市
            book[j]=0;//之前一步探索完毕之后,取消对城市j的标记
        }
    }
    return;
}
int main()
{
    int i,j,m,a,b,c;
    scanf("%d %d",&n,&m);
    //初始化二位矩阵
    for(i=1;i<=n;i++)
        for(j=1;j<=n;j++)
            if(i==j) e[i][j]=0;
    			else e[i][j]=99999999;
    
    //读入城市之间的道路
    for(i=1;i<=m;i++)
    {
        scanf("%d %d %d",&a,&b,&c);
        e[a][b]=c;
    }
    
    //从1号城市出发
    book[1]=1;
    dfs(1,0);//标记1号城市已经在路径中
    printf("%d",min);//打印1号城市到5号城市的最短路径
    
    getchar();getchar();
    return 0;
}

可以输入以下数据进行验证。

5  8
1 2 2
1 5 10
2 3 3
2 5 7
3 1 4
3 4 4
4 5 5
5 3 3

运行结果是:

9

有一点需要注意的是:如何表示正无穷。上面的代码中我们将正无穷定义为 99999999。

现在来小结一下图的基本概念。图就是有 N 个顶点和 M 条边组成的集合。这里的城市地图其实就是一个图,图中每个城市就是一个顶点,而两个城市之间的公路则是两个顶点的边。虽然这个定义不是很严谨,但是我想你应该可以听懂。如果想知道图的精确定义,你可以去百度一些数据结构或者图论中关于图的定义,一串串的公式一定可以满足你。

通过上一节和本节内容我们知道图分为有向图和无向图,如果给图的每条边规定一个方向,那么得到的图称为有向图,其边也称为有向边。在有向图中,与一个点相关联的边有出边和入边之分,而与一个有向边关联的两个点也有始点和终点之分。相反,边没有方向的图称为无向图。我们将上面的城市地图改为无向图后如下。

将城市地图改为无向图

处理无向图和处理有向图的代码几乎是一模一样的。只是在处理无向图初始化的时候有一点需要注意。"a b c”表示城市 a 和城市 b 可以互相到达,路程为 c 公里。因此我们需要将 e[1] [2] 和 e[2] [1] 都初始化 2,因为这条路是双行道。初始化后的数组 e 如下。

任意两个城市之间的地图1

你会发现这个表是对称的(上一节我们已经说过),这是无向图的一个特征。在这个无向图中,我们会发现从 1 号城市到 5 号城市的最短路径不再是 1→2→5 ,而是 1→3→5 ,路径长度为 7。

本节的解法中我们使用了二维数组来存储这个图(顶点和边的关系),我们知道这种存储方法叫做图的邻接矩阵表示法。存储图的方法还有很多种,比如邻接表等,我们后面再做详细讲解。

此外求图上两点之间的最短路径,除了使用深度优先搜索以外,还可以使用广度优先搜索、Floyd、Bellman-Ford、Dijkstra等,我们将在下一节再进行详细阐述。

图的广度优先遍历

小明和小红一同坐飞机去旅游,他们现在位于 1 号城市,目标是 5 号城市,可是 1 号城市并没有到 5 号城市的直航。不过小明已经收集了很多航班的信息,现在小明希望找到一种乘坐方式,使得转机的次数最少,如何解决呢?

5 7 1 5
1 2
1 3
2 3
2 4
3 4
3 5
4 5

5个城市

第一行的 5 表示有 5 个城市(城市编号为1~5),7 表示有 7 条航线,1 表示起点城市,5 表示目标城市。接下来 7 行每行是一条类似 “a b” 这样的数据表示城市 a 和城市 b 之间有航线,也就是说城市 a 和城市 b 之间可以相互到达。

这里我们还是使用邻接矩阵来存储图,需要注意的是这里是无向图。城市的编号就是图的顶点,而航班则是两顶点之间的边。小明要求的是转机次数最少,所以我们可以认为所有边的长度都是 1。下面我们用广度优先搜索来解决这个问题。

首先将 1 号城市入队,通过 1 号城市我们可以到达(扩展出)2 号和 3 号城市。2 号城市又可以扩展出 3 号城市和 4 号城市。因为 3 号城市已经在队列中,所以只需将 4 号城市入队。接下来 3 号城市又可以扩展出 4 号城市和 5 号城市,因为 4 号城市也已经在队列中,所以只需将 5 号城市入队。此时已经找到了目标城市 5 号城市,算法结束。你可能要问:这里为什么一扩展到5号城市就结束了呢?为什么之前的深度优先搜索却不行呢?自己想一想哦。

队列

完整的代码如下。

#include <stdio.h>
struct note
{
    int x;//城市编号
    int s;//转机此数
};
int main()
{
    struct note que[2501];
    int e[51][51]={0},book[51]={0};
    int head,tail;
    int i,j,n,m,a,b,cur,start,end,flag=0;
    scanf("%d %d %d %d",&n,&m,&start,&end);
    //初始化二维矩阵
    for(i=1;i<=n;i++)
        for(j=1;j<=n;j++)
            if(i==j) e[i][j]=0;
    			else e[i][j]=99999999;
    
    //读入城市之间的航班
    for(i=1;i<=m;i++)
    {
        scanf("%d %d",&a,&b);
        //注意这里是无向图
        e[a][b]=1;
        e[b][a]=1;
    }
    
    //队列初始化
    head=1;
    tail=1;
    
    //从start号城市出发,将start号城市加入队列
    que[tail].x=start;
    que[tail].s=0;
    tail++;
    book[1]=start;//标记start号城市已在队列中
    
    //当队列不为空的时候循环
    while(head<tail)
    {
        cur=que[head].x;//当前队列中首城市的编号
        for(j=1;j<=n;j++)//从1~n依次尝试
        {
            //从城市cur到城市j是否有航班并且判断城市j是否已经在队列中
            if(e[cur][j]!=99999999 && book[j]==0)
            {
                //如果从城市cur到城市j有航班并且城市j不在队列中,则将j号城市入队
                que[tail].x=j;
                que[tail].s=que[head].s+1;//转机次数+1
                tail++;
                //标记城市j已经在队列中
                book[j]=1;
            }
            //如果到达目标城市前,停止扩展,任务结束,退出循环
            if(que[tail].x==end)
            {
                //注意下面两句话的位置千万不要写颠倒了
                flag=1;
                break;
            }
        }
        if(flag==1)
            break;
        head++;//注意这地方,千万不要忘记当一个点扩展结束后,head++才能继续扩展
    }
    
    //打印队列中末尾最后一个(目标城市)的转机次数
    //注意tail是指向队列队尾(即最后一位)的下一个位置,所以这需要-1
    printf("%d",que[tail-1].s);
    
    getchar();getchar();
    return 0;
}

可以输入以下数据进行验证。

5 7 1 5
1 2
1 3
2 3
2 4
3 4
3 5
4 5

运行结果是:

2

当然也可以使用深度优先搜索解决,但是这里用广度优先搜索会更快。广度优先搜索更加适用于所有边的权值相同的情况。

参考:《啊哈!算法》

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

FantasyQin

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值