数据结构:图的遍历

记录遍历状态

对于图结构来说,图的遍历和树的遍历有类似之处,树结构的遍历从根结点出发,图结构的遍历从某一结点出发。出发之后,按照某种手法无重复地访问所有的结点,这也是后续解决图的连通性、拓扑排序和关键路径的预备知识。
由于在图结构中,任意顶点都有可能与其他顶点相互邻接,因此如果没有对已走过的路径进行记录的话,很有可能会由于结点的重复访问而无法遍历所有顶点。因此我们需要一种手法记录访问过的顶点,一种直接而有效的手法是使用一个 visited[n] 数组,先将其每一个元素初始化为 0,当我访问了第 i 个顶点时,就将 visited[i] 的值赋值为 1,表示已经访问过。当我访问某一个顶点时,可以通过 visited 数组来确定我接下来是否要从这个顶点往下走。

DFS

深度优先搜索

深度优先搜索( Depth First Serarch )我们之前是接触过的,在迷宫问题(栈实现)和树结构的递归遍历法中,我们用其思想实现了一些功能,现在我们来详细谈一谈。所谓 DFS,我称之为视角放在路径的手法,思想是通过对某一条路径的顶点的挖掘,从而试探出一条可行的路径。当我使用递归或者通过修饰后的栈结构,可以实现回溯的效果,以获取全部的路径。

算法流程

对于一个连通图,DFS 的遍历过程为:

  1. 选择图中的某个顶点出发,并访问该顶点;
  2. 找出刚访问过的顶点的第一个未被访问的邻接点并访问;
  3. 以该顶点为新顶点,步骤 2 直至刚访问过的顶点没有未被访问的邻接点;
  4. 返回前一个访问过的且仍有未被访问的邻接点的顶点,找出该顶点的下一个未被访问的邻接点,并访问该顶点;
  5. 重复上述 2、3、4 步骤直至所有顶点访问完毕。

模拟遍历

例如上图的连通图,我们选择顶点 A 出发,访问该顶点:

选取 A 的邻接点 B,访问该结点:

选取 B 的邻接点 D,访问该结点:

选取 D 的邻接点 H,访问该结点:

选取 H 的邻接点 E,访问该结点:

由于 E 顶点的所有邻接点都遍历完了,因此需要回溯。回溯需要 4 次回到顶点 A,并访问下一个邻接点 C:

选取 C 的邻接点 F,访问该结点:

选取 F 的邻接点 G,访问该结点:

遍历完毕,顺序为 A->B->D->H->E->C->F->G。其深度优先生成树为:

代码实现

由于 DFS 需要涉及到回溯问题,因此我们想到使用递归来实现。

邻接矩阵 DFS

int visited[MAXV] = { 0 };
void DFS(MGraph g, int v)    //v 表示当前所在的顶点
{
    cout << v << " ";    //输出顶点
    visited[v] = 1;    //标记已访问
    for (int i = 1; i <= g.n; i++)    //使用循环控制回溯
    {
	if (g.edges[v][i] == 1 && visited[i] == 0)
	{
	    DFS(g, i);    //当前顶点与 i 顶点邻接且未被访问,递归搜索
	}
    }
}

邻接表 DFS

int visited[MAXV] = { 0 };
void DFS(AdjGraph* G, int v)    //从 v 顶点开始深度遍历
{
    ArcNode* ptr;

    cout << v << " ";    //输出顶点
    visited[v] = 1;    //标记已访问
    ptr = G->adjlist[v].firstarc;
    while (ptr)    //沿着邻接表搜索路径
    {
	if (visited[ptr->adjvex] == 0)
	{
	    DFS(G, ptr->adjvex);    //当前顶点与 i 顶点邻接且未被访问,递归搜索
	}
	ptr = ptr->nextarc;    //继续访问邻接表
    }
}

时间复杂度

当我们遍历一个连通图时,对图中每个顶点至多调用一次 DFS 函数,并且当这个顶点被访问过之后,由于被标志成已被访问,就不再从它出发进行搜索。因此,遍历图的过程实质上是对每个顶点查找其邻接点的过程,其时间复杂度则取决于使用的存储结构。当用邻接矩阵描述图结构时,查找每个顶点的邻接点的时间复杂度为 O(n2),其中 n 为图结构中的顶点数。当以邻接表做图的存储结构时,查找邻接点的时间复杂度为 O(e),e 为图结构的边数。由此当以邻接表做存储结构时,深度优先搜索遍历图的时间复杂度为 O(n + e)

BFS

广度优先搜索

广度优先搜索( Breadth First Serarch )我们之前也有接触过,在迷宫问题(队列实现)和树结构的层序遍历法中,我们用其思想实现了一些功能,现在我们来详细谈一谈。所谓 BFS,我称之为视角放在整个图结构的手法,思想是通过从某个顶点向外扩散,从而囊括所有顶点的手法。当我借助队列结构时,可以实现该算法。

算法流程

对于一个连通图,DFS 的遍历过程为:

  1. 选择图中的某个顶点出发,并访问该顶点;
  2. 依次访问 v 的每个未曾访问过的邻接点 i;
  3. 分别从这些邻接点出发,依次访问它们的邻接点,并使“先被访问的顶点的邻接点”先于“后被访问的顶点的邻接点”被访问;
  4. 重复步骤 3,直至图中所有己被访问的顶点的邻接点全部访问到。

模拟遍历

例如上图的连通图,我们选择顶点 A 出发,访问该顶点:

访问顶点 A 的所有邻接点:

访问顶点 B 的所有邻接点:

访问顶点 C 的所有邻接点:

访问顶点 D 的所有邻接点:

接着访问剩下的顶点,由于所有的顶点都被访问过了,因此没有新顶点入队列。遍历顺序为:A->B->C->D->E->F->G,广度优先生成树为:

代码实现

由于 BFS 需要涉及到回溯问题,因此我们想到使用递归来实现。
广度优先搜索遍历图结构其实和树结构的层序遍历是一个玩意,尽可能先对横向进行搜索。设 x 和 y 是两个相继被访问过的顶点,若当前是以 X 为出发点进行搜索,则在访问 x 的所有未曾被访问过的邻接点之后,紧接着是以 y 为出发点进行横向搜索,并对搜索到的 y 的邻接点中尚未被访问的顶点进行访问。也就是说,先访问的顶点其邻接点亦先被访问,因此我们需要一个队列来辅助实现算法。

邻接矩阵 BFS

void BFS(MGraph g, int v)
{
    int front, rear;    //头指针与尾指针
    int point[MAXV];    //构造队列结构(可以是非循环队列)

    front = 0;
    point[front] = v;    //顶点 v 入队列
    rear = visited[v] = 1;    //标记顶点已访问
    while (front != rear)    //队列不为空,搜索继续
    {
	for (int i = 1; i <= g.n; i++)    //遍历表头顶点的邻接点
	{
	    if (g.edges[point[front]][i] == 1 && visited[i] == 0)
	    {
	        point[rear++] = i;    //顶点 i 入队列
		visited[i] = 1;    //标记顶点已访问
	    }
        }
	cout << point[front++] << " ";    //输出顶点
}

邻接表 BFS

void BFS(AdjGraph* G, int v)    //顶点 v 开始广度遍历
{
    int front, rear;    //头指针与尾指针
    int a_que[MAXV];   //构造队列结构(可以是非循环队列
    ArcNode* ptr;

    front = 0;
    a_que[front] = v;    //顶点 v 入队列
    visited[v] = rear = 1;    //标记顶点已访问
    while (front != rear)    //队列不为空,搜索继续
    {
        ptr = G->adjlist[a_que[front]].firstarc;
	while (ptr)    //遍历表头顶点的邻接点
	{
	    if (visited[ptr->adjvex] == 0)
	    {
	        a_que[rear++] = ptr->adjvex;    //顶点 i 入队列
		visited[ptr->adjvex] = 1;    //标记顶点已访问
	    }
	    ptr = ptr->nextarc;
	}
	cout << point[front++] << " ";    //输出顶点
    }
}

时间复杂度

对于 BFS,每个顶点至多进一次队列,因此遍历图的过程实质上是通过边找邻接点的过程。因此广度优先搜索遍历图的时间复杂度和深度优先搜索遍历相同,两种遍历方法的不同之处仅仅在于对顶点访问的顺序不同。

实例:六度空间

情景需求

输入样例

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

输出样例

1: 70.00%
2: 80.00%
3: 90.00%
4: 100.00%
5: 100.00%
6: 100.00%
7: 100.00%
8: 90.00%
9: 80.00%
10: 70.00%

情景解析

这道题表面上看好像不好懂,但在本质上是一个有限的图结构遍历,即我们可能在遍历到某些顶点的时候就需要提前结束了。基于这一点,我们反应过来用 BFS 做比较方便一点,因为 BFS 的流程可以抽象为一层一层地往外探测,这与我们的思路是符合的。
那么接下来我们读题,根据题意,我们需要找到关系网在 6 层以内的结点。那也就是说,我们需要去记录某个顶点,它对于初始顶点来说是第几层的关系网。为了方便理解,我展示的做法是去修改记录结点信息的数组的结构体为:

typedef struct
{
	int level;    //表示结点相对目标顶点的距离
	int v;
}AVertex;

即多开一个成员来记录层次的信息。那么层次的信息怎么操作?首先我们先初始化为 0,接下来每进行一轮 BFS,添加入队列的顶点就从它的上一层继承层数,可以用这行代码实现:

a_que[rear].level = a_que[front].level + 1;

解决了层次的确定问题,剩下的就是我们喜闻乐见的建图和遍历的事情啦。

伪代码

代码实现

int getCount(AdjGraph* G, int v)
{
    int visited[MAXV] = { 0 };
    space a_que[MAXV] = { 0 };
    int front, rear;
    int count = 1;
    ArcNode* ptr;

    front = rear = 0;
    a_que[rear].v = v;    //目标顶点 v 入队列
    a_que[rear++].level = 0;    //初始化层数为 0
    visited[v] = 1;
    while (front != rear)
    {
	if (a_que[front].level == 6)
	{
	    break;    //表头顶点在第 6 层,结束 BFS
	}
	ptr = G->adjlist[a_que[front].v].firstarc;
	while (ptr)
	{
	    if (visited[ptr->adjvex] == 0)
	    {
	        a_que[rear].v = ptr->adjvex;
		a_que[rear++].level = a_que[front].level + 1;    //继承上一个顶点的层数
		visited[ptr->adjvex] = 1;
		count++;
	    }
	    ptr = ptr->nextarc;
        }
	front++;
    }
    return count;
}

扩充资料

六度空间理论(数学领域的猜想)

实例:判断 DFS 序列合法性

左转我另一篇博客——PTA习题解析——判断DFS序列的合法性

参考资料

《大话数据结构》—— 程杰 著,清华大学出版社
《数据结构(C语言版|第二版)》—— 严蔚敏 李冬梅 吴伟民 编著,人民邮电出版社

posted @ 2020-04-27 16:49  乌漆WhiteMoon  阅读(1226)  评论(0编辑  收藏  举报