9.2 KiB
C++ 算法:图论(三)- 图的遍历 讲义
一、 引言
1.1 问题引入
我们已经熟悉了数组和树的遍历。数组的遍历是一条直线,从头到尾即可。树的遍历虽有岔路,但结构清晰,从父到子,不会回头。
但是,图的遍历要复杂得多。我们面临着两个新的问题:
- 连接的顶点多:在树中,一个节点最多连接少数几个子节点。但在图中,一个顶点可能连接着任意数量的其他顶点,我们该如何选择前进的方向?
- 存在“环”:在树中,我们绝不会走回祖先节点。但在图中,从A出发,经过B、C,完全可能再回到A,形成一个“环”。如果程序处理不当,就会陷入无限循环。
让我们通过书本中的**【例18-3 查找文献】**案例,来学习如何解决这些问题。
小K正在阅读一篇编号为1的博客文章。这篇文章引用了若干其他文章作为参考文献,而这些参考文献又各自引用了更多的文章,形成了一个庞杂的引用网络。小K求知欲旺盛,他决定:只要读了一篇文章,就必须把它引用的所有没读过的文章都读一遍。
现在,我们拥有这个引用网络的完整数据(n篇文章,m条引用关系)。问题是:我们如何设计一个策略,能帮助小K不重复、不遗漏地看完所有他从文章1出发能接触到的文章?
我们可以对问题进行抽象为图论问题:
- 每一篇文章,都可以看作一个顶点。
- 每一条从文章X到文章Y的引用关系,都可以看作一条从X指向Y的有向边。
因此,这个问题被成功地转化为了一个图论问题:从图的某个起始顶点出发,遍历所有可达的顶点。
二、 核心思想:如何做到“不重复、不遗漏”?
2.1 用visited数组避免重复
为了解决图遍历中可能出现的“环”和“重复访问”问题,我们引入一个关键工具:visited布尔数组。
- 定义:
bool visited[N]; - 作用:用于记录对应的顶点我们是否已经访问过。
visited[i] = true表示顶点i我们已经访问过这个顶点了。 - 规则:
- 每当我们首次访问一个顶点时,立刻在“笔记”上记录下来:
visited[i] = true。 - 在尝试访问任何一个顶点之前,检查**:
if (!visited[i])**。我们只访问还没有到过的顶点。
- 每当我们首次访问一个顶点时,立刻在“笔记”上记录下来:
这个visited数组是保证遍历不重复的关键,是DFS和BFS都必须使用的基础工具。
2.2 DFS 与 BFS 遍历图
解决了不重复的问题,我们如何保证不遗漏呢?有两种策略:
-
策略一:深度优先(DFS)- “一条路走到底”
- 当你读了文章A,发现它引用了B和C。你选择立刻深入进去,先把B以及B的所有参考文献、B的参考文献的参考文献……全部看完,直到这条链条的尽头。然后才“回溯”过来看文章C。
- 一条路走到黑,不撞南墙不回头。
-
策略二:广度优先(BFS)- “逐层拓展”
- 当你读完文章A,发现它引用了B和C。你不会立刻深入,而是先把同一层级的B和C都看完。然后,再去看B和C共同引用的下一层级的所有文章。
- 一层一层地看,像水波一样由近及远地扩散。
三、 深度优先遍历 (DFS)
3.1 算法精讲
DFS体现的是一条路走到底的策略。其过程符合递归的定义:要访问顶点u的所有可达点,我们先访问u,然后对u的每一个邻居v,再去递归地执行“访问v的所有可达点”这一相同的任务。它的两个重要动作是深入探索和回溯。
3.2 步骤拆解(以查找文献的样例数据为例子)
DFS的遍历过程为:
- 从1出发:访问1,标记
visited[1]=true。1的邻居有2, 3, 4。 - 深入2:选择邻居2,递归调用DFS(2)。访问2,标记
visited[2]=true。2的邻居有5, 6。 - 深入5:选择邻居5,递归调用DFS(5)。访问5,标记
visited[5]=true。5没有邻居,已到达尽头。 - 回溯到2:从5的调用中返回。检查2的下一个邻居6。
- 深入6:选择邻居6,递归调用DFS(6)。访问6,标记
visited[6]=true。6没有邻居。 - 回溯到2:从6返回。2的所有邻居都已探索完毕。
- 回溯到1:从2返回。检查1的下一个邻居3。
- 深入3 -> 7 -> 8:重复上述过程,一路深入到8,再层层回溯到1。
- 探索4:检查1的下一个邻居4,递归调用DFS(4)。访问4,标记
visited[4]=true。 - 检查4的邻居:尝试访问7和8,但检查发现
visited[7]和visited[8]都已为true,故跳过。 - 回溯:从4返回1。1的所有邻居探索完毕。遍历结束。
3.3 代码实现
// DFS函数,v是当前访问的顶点, adj: 邻接表, visited: 访问数组
void DFS(int v) {
visited[v] = true;
cout << v << " "; // 访问当前节点
// 遍历v的所有邻居
for (int neighbor : adj[v]) {
// 如果邻居未被访问过,则递归深入
if (!visited[neighbor]) {
DFS(neighbor);
}
}
}
// 题目要求中邻居按小编号优先,所以在注意在主函数中对邻接表排序
for (int i = 1; i <= n; i++) {
sort(adj[i].begin(), adj[i].end());
}
四、 广度优先遍历 (BFS)
4.1 算法思路
BFS是“逐层扩散”的思路。它必须先处理完最早发现的节点,再处理新发现的节点。这种“先进先出”的规则,契合了**队列(Queue)**的数据结构。
算法流程:
- 创建一个队列,并将起始顶点放入队列。标记起始顶点为已访问。
- 当队列不为空时,循环执行以下操作:
a. 从队头取出一个顶点
u。 b. 访问u。 c. 遍历u的所有邻居v,如果v未被访问过,则标记v并将其放入队尾。
4.2 步骤拆解(以查找文献的样例数据为例子)
| 步骤 | 操作 | 队列状态 (Queue) | 已访问 (Visited) | 输出 |
|---|---|---|---|---|
| 1 | 初始状态,1入队 | [1] |
{1} | |
| 2 | 1出队,访问1,其邻居2,3,4入队 | [2, 3, 4] |
{1,2,3,4} | 1 |
| 3 | 2出队,访问2,其邻居5,6入队 | [3, 4, 5, 6] |
{1,2,3,4,5,6} | 2 |
| 4 | 3出队,访问3,其邻居7入队 | [4, 5, 6, 7] |
{1,2,3,4,5,6,7} | 3 |
| 5 | 4出队,访问4,其邻居8入队 | [5, 6, 7, 8] |
{1,2,3,4,5,6,7,8} | 4 |
| ... | ... | ... | ... | ... |
| 10 | 队列为空,结束 | - | - |
最终访问顺序:1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8
4.3 代码实现
// DFS函数,v是当前访问的顶点, adj: 邻接表, visited: 访问数组
void BFS(int v) {
queue<int> q;
q.push(v); // 起始点入队
visited[v] = true;
while (!q.empty()) {
int u = q.front(); q.pop(); // 取出队头
cout << u << " "; // 访问
// 根据邻接表遍历所有邻居
for (int v : adj[u]) {
if (!visited[v]) {
visited[v] = true;
q.push(v); // 新发现的顶点入队
}
}
}
}
// main函数与DFS版本只是调用函数不同而已
五、 总结与应用
5.1 如何将现实问题抽象为图?
图的表达能力极强,世界上绝大多数涉及“物体”和“连接”的问题,都可以抽象为图。只需问自己两个问题:
- 物体是什么? -> 它们成为图的顶点。
- 连接是什么? -> 它们成为图的边。
- 社交网络:用户是顶点,好友关系是边。找“朋友的朋友”就是图的遍历。
- 城市导航:路口是顶点,道路是边。找“最短路线”就是图的最短路径搜索。
5.2 拓展:非连通图的遍历
在本题中,我们只需从顶点1开始遍历。但如果一个图由多个互不相连的部分组成(非连通图),我们该如何遍历所有顶点?
解决办法:在 main 函数中使用一个 for 循环,从 1 到 n 检查每一个顶点 i。如果发现 !visited[i],就说明我们发现了一个新的、独立的连通块,此时再以 i 为起点,调用DFS或BFS来探索这个新部分。
for (int i = 1; i <= n; i++) {
if (!visited[i]) { // 如果还没有被访问过,从点i开始遍历这个新的连通图
DFS(i); // 或 BFS(i)
}
}