图的遍历:深度优先搜索与广度优先搜索
图遍历,说白了就是怎么把图里的每个节点都走一遍。我刚开始学的时候觉得这有什么难的,不就是挨个访问吗?后来在项目中处理一个百万节点的社交网络图时才发现,遍历顺序选不对,内存直接爆掉。嗯,今天咱们就把深度优先搜索(DFS)和广度优先搜索(BFS)彻底讲透。
一、图的存储结构
在讲遍历之前,得先确定图怎么存。我个人习惯用邻接表,尤其是稀疏图的时候,省内存。当然,邻接矩阵在判断两点是否相连时更快,但空间开销大。
// 邻接表节点
typedef struct AdjNode {
int vertex;
struct AdjNode* next;
} AdjNode;
// 图结构
typedef struct Graph {
int numVertices;
AdjNode** adjLists;
int* visited; // 遍历时用
} Graph;
这里visited数组很关键。我曾经在调试一个死循环时,找了半天才发现是忘记重置visited标志了。你想想看,图里如果有环,不标记已访问节点,DFS就会无限递归下去。
二、深度优先搜索(DFS)
2.1 递归实现
DFS的思路很简单:从一个节点出发,一直往深处走,走不动了就回溯。递归实现最直观,代码也最短。
void DFS_Recursive(Graph* graph, int startVertex) {
graph->visited[startVertex] = 1;
printf("访问节点: %d\n", startVertex);
AdjNode* temp = graph->adjLists[startVertex];
while (temp != NULL) {
int adjVertex = temp->vertex;
if (!graph->visited[adjVertex]) {
DFS_Recursive(graph, adjVertex);
}
temp = temp->next;
}
}
这里有个细节:递归深度等于图的直径。我在项目中遇到过一张深度为5000的图,递归直接栈溢出了。所以生产环境中,我建议用栈实现。
2.2 栈实现
栈实现其实就是模拟递归的过程。手动维护一个栈,把待访问节点压进去。
void DFS_Stack(Graph* graph, int startVertex) {
int stack[1000];
int top = -1;
stack[++top] = startVertex;
graph->visited[startVertex] = 1;
while (top != -1) {
int currentVertex = stack[top--];
printf("访问节点: %d\n", currentVertex);
AdjNode* temp = graph->adjLists[currentVertex];
while (temp != NULL) {
int adjVertex = temp->vertex;
if (!graph->visited[adjVertex]) {
stack[++top] = adjVertex;
graph->visited[adjVertex] = 1;
}
temp = temp->next;
}
}
}
注意:栈实现时,入栈就要标记visited,而不是出栈时标记。我曾经犯过这个错——出栈才标记,结果同一个节点被多次入栈,栈空间直接爆炸。
三、广度优先搜索(BFS)
BFS像水波一样一层层扩散。它天然适合求最短路径,因为第一次访问到目标节点时,路径一定最短。
void BFS_Queue(Graph* graph, int startVertex) {
int queue[1000];
int front = 0, rear = 0;
queue[rear++] = startVertex;
graph->visited[startVertex] = 1;
while (front != rear) {
int currentVertex = queue[front++];
printf("访问节点: %d\n", currentVertex);
AdjNode* temp = graph->adjLists[currentVertex];
while (temp != NULL) {
int adjVertex = temp->vertex;
if (!graph->visited[adjVertex]) {
queue[rear++] = adjVertex;
graph->visited[adjVertex] = 1;
}
temp = temp->next;
}
}
}
BFS和DFS的栈实现其实很像,就是一个用栈(后进先出),一个用队列(先进先出)。你想想看,这个区别导致了完全不同的遍历顺序。
四、连通分量判定
什么叫连通分量?就是图里那些"抱团"的节点群。一个连通分量里的任意两个节点都能通过路径相连,不同分量之间则没有路径。
判定方法很简单:对每个未访问的节点执行一次DFS或BFS,每次遍历到的所有节点就是一个连通分量。
int countConnectedComponents(Graph* graph) {
int count = 0;
// 重置visited数组
for (int i = 0; i < graph->numVertices; i++) {
graph->visited[i] = 0;
}
for (int i = 0; i < graph->numVertices; i++) {
if (!graph->visited[i]) {
count++;
printf("连通分量 %d: ", count);
DFS_Recursive(graph, i);
printf("\n");
}
}
return count;
}
核心思想:连通分量数 = 从外部调用的DFS/BFS次数。每次调用发现一个全新的连通分量。
五、三种遍历策略对比
| 算法 | 数据结构 | 空间复杂度 | 时间复杂度 | 适用场景 |
|---|---|---|---|---|
| DFS递归 | 系统栈 | O(V) | O(V+E) | 小图、路径搜索 |
| DFS栈 | 手动栈 | O(V) | O(V+E) | 大图、避免栈溢出 |
| BFS队列 | 队列 | O(V) | O(V+E) | 最短路径、层次遍历 |
六、知识体系总览
下面这张图把本章的核心逻辑串起来了。从图的存储出发,到两种遍历策略,再到连通分量判定,每一步都有对应的数据结构和实现方式。
实战建议:如果你在嵌入式系统里做图遍历,内存通常有限。我建议优先用BFS的队列实现,因为队列大小可以预估(最多V个节点),而DFS的栈深度不可控。我在一个STM32项目里就是这么干的,稳得很。
好了,图遍历的核心就这些。记住:DFS适合"是否存在路径"这类问题,BFS适合"最短路径是多少"。连通分量判定则是两者的基础应用。代码写多了你会发现,这些算法本质上就是"数据结构+访问顺序"的组合游戏。