在算法和计算机科学领域,图是一种极其强大且灵活的数据结构。你是否想过,GPS 导航是如何为你找到路径的?或者社交网络是如何推荐“你可能认识的人”的?这些问题的背后往往都涉及到图的遍历算法。
今天,我们将深入探讨图遍历中最基础且最重要的算法之一:深度优先搜索(Depth First Search,简称 DFS)。我们将一起探索它的工作原理,通过 C 语言从零开始构建它,并讨论在实际开发中如何高效地使用它。
目录
什么是深度优先搜索(DFS)?
你可以把 DFS 想象成你在探索一个巨大的迷宫。
- 起点:你选择一个入口出发。
- 深入:你沿着一条路一直走,只要遇到没去过的路口,就继续往深处走。
- 回溯:当你走到死胡同(或者所有路都走过了),你会原路返回到上一个路口,看看有没有其他没走的路。
- 循环:重复这个过程,直到所有能到达的地方都 explored(探索)完毕。
与树的遍历非常相似,对吧?唯一的区别在于,树是不会分叉回去的,而图可能会。
核心挑战:环与重复访问
图的结构比树更复杂,因为它可能包含环(Cycles)。这意味着你可能会从节点 A 走到 B,再从 C 走回 A。如果不加控制,我们的程序可能会陷入“无限循环”,像个鬼打墙一样在节点之间来回跑。
解决方案:我们需要一个“记事本”。在计算机术语中,这通常是一个布尔类型的数组 visited[]。当我们访问一个节点时,我们在记事本上打勾;下次遇到这个节点时,先看一眼记事本,如果来过了,就忽略它。
DFS 的实际应用场景
在我们敲代码之前,让我们先看看 DFS 在现实世界中解决了哪些问题:
- 路径寻找:在迷宫游戏中寻找从起点到终点的路。
- 拓扑排序:判断课程修习的先后顺序,或者任务依赖关系(A 任务必须在 B 之前完成)。
- 检测环:在死锁检测中,判断资源分配图是否存在环路。
- 连通性检查:判断图中两个节点是否相连(比如社交网络中的六度人脉理论)。
图的表示方法:邻接表
在 C 语言中实现图,最常用的方法是邻接表。你可以把它想象成一张散列表,或者是一个数组的链表。
- 每个节点(顶点)对应数组中的一个索引。
- 每个数组元素指向一个链表,链表中存储着所有与该节点直接相连的邻居节点。
这种方法的优点是空间效率高,特别是对于稀疏图(边数远小于节点数的平方)来说。
核心代码实现(C 语言)
让我们来实现一个完整的 C 程序。我们将使用邻接表来构建图,并使用递归来实现 DFS 逻辑。
1. 数据结构定义
首先,我们需要定义图的基本积木。
#include
#include
#include
// 邻接表中链表节点的结构
struct Node {
int dest; // 目标顶点的数据
struct Node* next; // 指向下一个邻居节点的指针
};
// 邻接表的结构
struct AdjList {
struct Node* head; // 指向链表头部的指针
};
// 图的结构
struct Graph {
int numVertices; // 图中顶点的总数
struct AdjList* array; // 邻接表数组
};
2. 辅助函数:创建节点与图
我们需要一些工厂函数来帮我们初始化内存。
// 创建新的邻接表节点
struct Node* createNode(int dest) {
struct Node* newNode = (struct Node*)malloc(sizeof(struct Node));
newNode->dest = dest;
newNode->next = NULL;
return newNode;
}
// 创建具有特定顶点数量的图
struct Graph* createGraph(int numVertices) {
struct Graph* graph = (struct Graph*)malloc(sizeof(struct Graph));
graph->numVertices = numVertices;
// 为邻接表分配内存
graph->array = (struct AdjList*)malloc(numVertices * sizeof(struct AdjList));
// 初始化每个邻接表为空
for (int i = 0; i array[i].head = NULL;
return graph;
}
3. 添加边
这是图构建的关键步骤。请注意,我们在头部插入新节点,这在链表操作中是 O(1) 的时间复杂度,非常高效。
// 添加边(有向图)
void addEdge(struct Graph* graph, int src, int dest) {
// 从 src 添加一条到 dest 的边
struct Node* newNode = createNode(dest);
newNode->next = graph->array[src].head;
graph->array[src].head = newNode;
}
// 如果你想让图变成无向图(双向通行),取消下面代码的注释
/*
void addEdgeUndirected(struct Graph* graph, int src, int dest) {
// src -> dest
struct Node* newNode = createNode(dest);
newNode->next = graph->array[src].head;
graph->array[src].head = newNode;
// dest -> src
newNode = createNode(src);
newNode->next = graph->array[dest].head;
graph->array[dest].head = newNode;
}
*/
4. DFS 的核心逻辑
这是魔法发生的地方。我们使用递归,这使得代码非常简洁直观。
// DFS 递归辅助函数
void DFSUtil(struct Graph* graph, int vertex, bool visited[]) {
// 1. 标记当前节点为已访问,并打印
visited[vertex] = true;
printf("%d ", vertex);
// 2. 递归访问所有相邻的未访问节点
struct Node* pCrawl = graph->array[vertex].head;
while (pCrawl != NULL) {
int adjVertex = pCrawl->dest;
if (!visited[adjVertex]) {
DFSUtil(graph, adjVertex, visited);
}
pCrawl = pCrawl->next;
}
}
// DFS 遍历的主入口函数
void DFS(struct Graph* graph, int startVertex) {
// 初始化 visited 数组
bool* visited = (bool*)malloc(graph->numVertices * sizeof(bool));
for (int i = 0; i numVertices; i++)
visited[i] = false;
// 调用递归辅助函数
printf("从顶点 %d 开始的深度优先遍历:
", startVertex);
DFSUtil(graph, startVertex, visited);
printf("
");
free(visited);
}
5. 完整示例与运行
让我们把这些拼起来,看看效果。我们将构建一个包含 4 个节点的有向图。
int main() {
// 创建一个包含 4 个顶点的图 (0 到 3)
int V = 4;
struct Graph* graph = createGraph(V);
// 添加边构建图结构
// 这里的逻辑是:2->0, 0->2, 1->2, 0->1, 3->3, 1->3
addEdge(graph, 0, 1);
addEdge(graph, 0, 2);
addEdge(graph, 1, 2);
addEdge(graph, 2, 0);
addEdge(graph, 2, 3);
addEdge(graph, 3, 3);
// 从顶点 2 开始打印 DFS
DFS(graph, 2);
return 0;
}
预期输出:
从顶点 2 开始的深度优先遍历:
2 0 1 3
让我们分析一下这个过程:
- Start at 2:打印 2。邻居是 0, 3。
- Go to 0:打印 0。邻居是 1, 2(2 已访问)。
- Go to 1:打印 1。邻居是 2(已访问),3。
- Check 3 from 1:3 未访问,Go to 3。打印 3。
- Backtrack:从 3 回到 1,再到 0,最后回到 2。
- Check 3 from 2:3 已经被访问过了,跳过。
- 结束。
深入理解:处理非连通图
上面的代码有一个局限性:如果图是“非连通图”(即有些节点之间根本没有路相连),从单一节点出发的 DFS 无法遍历到所有节点。
想象一下,你有两个互不连通的岛屿。你在一个岛上探险,永远不可能走到另一个岛上去。为了解决这个问题,我们需要修改 DFS 函数,让它能够遍历图中的每一个节点。如果该节点未访问,我们就对它启动一次 DFS。
// 针对非连通图的改进版 DFS
void DFS_Full(struct Graph* graph) {
bool* visited = (bool*)malloc(graph->numVertices * sizeof(bool));
for (int i = 0; i numVertices; i++)
visited[i] = false;
printf("完整的深度优先遍历(含非连通图):
");
for (int i = 0; i numVertices; i++) {
if (!visited[i]) {
printf("发现新连通分量,起始节点 %d: ", i);
DFSUtil(graph, i, visited);
printf("
");
}
}
free(visited);
}
这个功能对于统计图中存在多少个独立的连通分量非常有用。
迭代式 DFS:使用栈消除递归
虽然递归代码很优雅,但在处理极深的图时,可能会导致栈溢出(Stack Overflow)。作为经验丰富的开发者,我们需要掌握迭代的方法。
迭代式 DFS 显式地使用一个栈来模拟递归调用的过程。
#include // 为了使用动态数组模拟栈
// 迭代式 DFS 实现
void DFS_Iterative(struct Graph* graph, int startVertex) {
bool* visited = (bool*)malloc(graph->numVertices * sizeof(bool));
for (int i = 0; i numVertices; i++)
visited[i] = false;
// 创建一个栈用于模拟递归
// 注意:标准 C 没有内置栈库,这里为了简化逻辑,我们仅展示概念
// 实际工程中可以使用链表或数组实现栈
int* stack = (int*)malloc(graph->numVertices * sizeof(int));
int top = -1; // 栈顶指针
// 初始操作
stack[++top] = startVertex;
printf("迭代式 DFS (从 %d 开始):
", startVertex);
while (top >= 0) {
// 弹出栈顶元素
int currentVertex = stack[top--];
if (!visited[currentVertex]) {
printf("%d ", currentVertex);
visited[currentVertex] = true;
}
// 将所有未访问的邻居压入栈中
// 注意:为了模拟递归的顺序(先左后右),我们需要按特定顺序压栈
struct Node* pCrawl = graph->array[currentVertex].head;
// 这是一个小技巧:因为栈是后进先出(LIFO),
// 如果我们希望先处理邻居 A 再处理邻居 B,我们需要先把 B 压栈。
// 但为了简单起见,这里我们按链表顺序压栈,顺序可能与递归版不同。
while (pCrawl != NULL) {
int adjVertex = pCrawl->dest;
if (!visited[adjVertex]) {
stack[++top] = adjVertex;
}
pCrawl = pCrawl->next;
}
}
printf("
");
free(stack);
free(visited);
}
算法复杂度分析
这是我们在面试或系统设计时必须考虑的硬指标。
时间复杂度:O(V + E)
- V (Vertices):顶点数量。我们初始化
visited数组需要遍历所有顶点,O(V)。 - E (Edges):边数量。在递归或迭代过程中,每条边最多被访问两次(一次是有向图的一次访问,无向图是两次)。所以是 O(E)。
- 总时间:O(V) + O(E) = O(V + E)。这是非常高效的线性时间复杂度。
空间复杂度:O(V)
- visited 数组:O(V)。
- 递归栈 / 显式栈:在最坏情况下(例如一条长链),栈的深度会达到 V,即 O(V)。
- 邻接表存储:O(V + E)。
常见陷阱与最佳实践
在实际开发中,你可能会遇到以下问题,这里有一些调试建议:
- 段错误
– 原因:通常是因为忘记分配 INLINECODE90b071d1 数组的内存,或者在 INLINECODEf9f27b8a 中分配内存失败。
– 解决:确保所有的 INLINECODEb8fffc16 都有对应的 INLINECODE32209bb8(尽管在简单的算法题中常省略 INLINECODE17b2b836,但在服务器程序中这是内存泄漏的根源)。检查 INLINECODE1c3dfff4 指针是否初始化为 NULL。
- 死循环 / 打印重复
– 原因:在 INLINECODE41454a71 函数中,如果你手动构建了双向边,但在 DFS 中忘记检查 INLINECODE51f9b256 数组,程序会陷入环中。
– 解决:严格遵守“检查-访问-标记”的顺序。或者像我代码中写的那样:先检查 !visited 再递归。
- 性能优化建议
– 位图代替布尔数组:如果你的图非常大(数百万节点),INLINECODE7f1b95af 会占用大量内存。可以使用 INLINECODEb492c69e 或位图来压缩存储。
– 邻接矩阵 vs 邻接表:虽然我们这里使用了邻接表,但如果图是非常稠密的(边数接近顶点数的平方),邻接矩阵(二维数组)在访问速度上可能更快(O(1) 访问任意边),尽管空间复杂度更高。
总结
在这篇文章中,我们全面掌握了图的深度优先搜索(DFS):
- 我们了解了 DFS 的核心逻辑——尽可能深地探索,然后回溯。
- 我们学会了如何在 C 语言中使用邻接表来构建图结构,并处理环的问题。
- 我们通过递归和迭代两种方式实现了 DFS,理解了它们各自的适用场景。
- 我们分析了 O(V+E) 的时间复杂度,这是处理图问题的黄金标准之一。
DFS 不仅仅是一个算法,它是解决复杂图论问题(如检测环、拓扑排序、寻找强连通分量)的基石。当你下次面对复杂的网络结构或路径规划问题时,不妨试着用 DFS 的思路去拆解它。
希望这篇指南能帮助你建立起扎实的算法基础。继续练习,尝试修改上面的代码去解决实际问题——你会发现算法世界充满了乐趣!