图的深度优先与广度优先遍历:邻接矩阵与邻接表实现详解

📅 2026/8/13 7:32:26
图的深度优先与广度优先遍历:邻接矩阵与邻接表实现详解
1. 项目概述图的遍历实战精讲最近在辅导学生做数据结构课程设计发现很多同学一碰到“图的遍历”这个头歌平台的习题集就有点发怵。邻接矩阵和邻接表看着简单真写起代码来各种指针乱飞、数组越界的问题就都来了。这太正常了我当年学的时候也在这栽过跟头。图的遍历像深度优先搜索DFS和广度优先搜索BFS是图论算法最最基础的骨架后续的最短路径、拓扑排序、连通分量全得靠它俩。这个合集习题本质上就是逼着我们把这两种遍历方式在两种不同的存储结构下完完整整、稳稳当当地实现出来。它不考你多炫技的算法考的就是基本功扎不扎实对图这种非线性结构的理解到不到位。不管是计算机考研复试还是大厂笔试的编程题图的遍历都是高频考点这道坎必须迈过去。今天我就以一个老码农的身份带你拆解这套习题把里面容易踩的坑、必须掌握的技巧还有从原理到代码的每一步都掰开揉碎了讲清楚。2. 核心需求与设计思路拆解2.1 习题核心目标解析这套习题的核心目标非常明确实现图的深度优先遍历DFS和广度优先遍历BFS算法并分别适配邻接矩阵和邻接表两种存储结构。这听起来是四个任务DFS-矩阵、DFS-表、BFS-矩阵、BFS-表但内在逻辑是相通的。题目通常会提供一个图的顶点和边信息要求你输出从某个指定顶点出发的一个遍历序列。这里的关键在于遍历序列可能不唯一尤其对于非连通图或存在多种选择时但必须符合DFS“一条路走到黑再回头”和BFS“层层推进”的核心逻辑。平台判题系统往往会有多个测试用例覆盖连通图、非连通图、有向图、无向图等不同情况这就要求我们的代码必须具备完备的健壮性。2.2 存储结构选型与遍历逻辑为什么一定要掌握两种存储结构因为它们在空间和时间复杂度上各有优劣直接影响了遍历算法的实现细节。邻接矩阵用一个二维数组matrix[v][w]表示顶点v和w之间是否有边或边的权值。它的优点是判断任意两顶点间是否有边非常快O(1)而且代码直观。缺点是空间开销大O(V²)对于稀疏图边数远小于顶点数平方极其浪费。在遍历时我们通常需要一个visited数组来记录顶点访问状态然后通过循环扫描矩阵的一行来寻找邻接点。邻接表为每个顶点建立一个单链表链表中存储与该顶点直接相连的所有邻接点。它完美适配稀疏图空间复杂度为O(VE)。但在判断任意两点间是否有边时需要遍历链表效率是O(degree(v))。在遍历实现上寻找邻接点不再需要循环扫描而是直接遍历该顶点的链表即可。遍历算法逻辑本身是独立于存储结构的DFS (深度优先搜索)模仿“走迷宫”策略。从起点出发任意选择一个未访问的邻接点深入直到无路可走再回溯到上一个顶点尝试其他分支。递归实现最直观也符合其“栈”的本质递归调用栈。非递归实现则需要显式使用一个栈。BFS (广度优先搜索)模仿“水波扩散”策略。从起点出发先访问所有距离为1的邻接点再访问距离为2的邻接点以此类推。这天然契合“队列”数据结构FIFO。所以BFS通常用队列辅助实现是非递归的。设计思路就是将这四种组合存储 x 遍历分别实现核心框架是初始化访问数组 - 从起点调用遍历函数 - 在遍历函数中根据存储结构的不同方式寻找邻接点并按照DFS或BFS的策略访问它们。3. 核心数据结构实现细节3.1 邻接矩阵的构建与要点用C语言实现邻接矩阵通常是一个动态分配的二维数组或者是一个一维数组模拟二维。#define MAX_VERTEX_NUM 100 // 根据题目要求设定 typedef struct { int vertices[MAX_VERTEX_NUM]; // 顶点表有时可省略 int edges[MAX_VERTEX_NUM][MAX_VERTEX_NUM]; // 邻接矩阵 int vertexNum, edgeNum; // 顶点数和边数 int isDirected; // 标识是否有向图 } MGraph;构建关键步骤初始化矩阵将所有edges[i][j]初始化为0无权图或一个特殊值如INF有权图表示无边。插入边读取一条边(v, w)令edges[v][w] 1或权值。如果是无向图切记要对称赋值edges[w][v] 1。这是新手最容易忘记的一点会导致遍历时“有去无回”。顶点编号题目顶点可能是数字或字符。如果是字符如‘A’ ‘B’通常需要做一个映射将其转换为从0开始的整数索引方便数组操作。注意如果题目顶点数很大比如超过1000使用静态二维数组可能造成栈溢出如果矩阵定义在函数内部。此时应使用动态内存分配int** 循环malloc或者将矩阵声明为全局变量。3.2 邻接表的构建与要点邻接表的实现稍复杂涉及链表操作。typedef struct ArcNode { // 边表节点 int adjvex; // 该边所指向的顶点位置索引 struct ArcNode* nextarc; // 指向下一条边的指针 // int weight; // 若为带权图可增加权值域 } ArcNode; typedef struct VNode { // 顶点表节点 int data; // 顶点信息有时可省略 ArcNode* firstarc; // 指向第一条依附该顶点的边的指针 } VNode, AdjList[MAX_VERTEX_NUM]; typedef struct { AdjList vertices; // 邻接表 int vertexNum, edgeNum; int isDirected; } ALGraph;构建关键步骤初始化顶点表将每个顶点的firstarc指针初始化为NULL。插入边这是核心采用头插法效率最高。为新边(v, w)创建一个ArcNode节点其adjvex设为w。将该节点的nextarc指向当前vertices[v].firstarc。将vertices[v].firstarc更新为该新节点。如果是无向图需要再对称地插入一条边(w, v)即重复上述步骤创建adjvex为v的节点插入到vertices[w]的链表头部。内存管理由于使用了动态分配的边节点在程序最后如果必要应遍历所有顶点释放每个链表占用的内存避免泄漏。这在算法题中常被忽略但却是良好的编程习惯。头插法与尾插法的选择头插法O(1)比尾插法需要找到链表尾部O(n)更高效。虽然这会导致邻接点顺序与输入顺序相反但图的遍历通常不关心邻接点的访问顺序除非题目特殊说明所以头插法是更通用的选择。4. 深度优先搜索DFS实现详解4.1 递归实现最直观的版本DFS的递归实现简洁优美完美体现了其“深度优先”的思想。邻接矩阵版DFS递归函数int visited[MAX_VERTEX_NUM] {0}; // 访问标记数组通常设为全局 void DFS_Matrix(MGraph* G, int v) { printf(%d , v); // 访问顶点v按题目要求输出 visited[v] 1; for (int w 0; w G-vertexNum; w) { // 找到v的所有未访问的邻接点w if (G-edges[v][w] ! 0 !visited[w]) { DFS_Matrix(G, w); // 递归深入 } } }邻接表版DFS递归函数void DFS_List(ALGraph* G, int v) { printf(%d , v); visited[v] 1; ArcNode* p G-vertices[v].firstarc; while (p ! NULL) { int w p-adjvex; if (!visited[w]) { DFS_List(G, w); } p p-nextarc; } }递归实现的要点递归终止条件隐含在for/while循环中。当顶点v的所有邻接点都已访问或没有邻接点时该层递归函数自然返回。访问标记的位置必须在递归调用之前标记当前顶点为已访问。如果放在之后或者在递归函数开头不立即标记在存在环的图中会导致无限递归。非连通图的处理上面的函数只完成了从一个顶点开始的遍历。对于非连通图需要在主调函数中循环检查所有顶点如果visited[i]为0就调用DFS(G, i)。这样才能遍历到所有连通分量。4.2 非递归实现显式使用栈非递归实现有助于理解DFS的栈本质也是面试常考点。我们需要一个栈来手动模拟递归调用栈。邻接矩阵版DFS非递归栈实现void DFS_Matrix_NonRecur(MGraph* G, int v) { int stack[MAX_VERTEX_NUM], top -1; printf(%d , v); visited[v] 1; stack[top] v; // 起始顶点入栈 while (top ! -1) { int current stack[top]; // 获取栈顶但不弹出 int w; for (w 0; w G-vertexNum; w) { if (G-edges[current][w] ! 0 !visited[w]) { break; // 找到一个未访问的邻接点 } } if (w G-vertexNum) { // 找到了这样的邻接点w printf(%d , w); visited[w] 1; stack[top] w; // 访问并入栈相当于递归深入 } else { top--; // 当前顶点所有邻接点都已访问弹出栈顶相当于递归返回 } } }非递归实现的难点关键在于“获取栈顶元素但不弹出只有当其所有邻接点都被访问后才弹出”。如果像BFS用队列那样访问完就出队那就错了。上面的代码中current始终是栈顶元素我们在这个顶点的邻接点中寻找下一个目标。找到就访问并入栈找不到就将该顶点弹出。实操心得非递归DFS的写法有很多变种另一种常见写法是每次都将一个顶点的一个未访问邻接点入栈并访问然后break出循环下次循环继续处理新栈顶。这同样可行。选择一种你理解最透彻的并保持一致。5. 广度优先搜索BFS实现详解5.1 队列辅助的标准实现BFS必须使用队列这是其“广度”特性的要求。邻接矩阵版BFSvoid BFS_Matrix(MGraph* G, int v) { int queue[MAX_VERTEX_NUM], front 0, rear 0; printf(%d , v); visited[v] 1; queue[rear] v; // 入队 while (front ! rear) { int current queue[front]; // 出队 for (int w 0; w G-vertexNum; w) { if (G-edges[current][w] ! 0 !visited[w]) { printf(%d , w); visited[w] 1; queue[rear] w; // 入队 } } } }邻接表版BFSvoid BFS_List(ALGraph* G, int v) { int queue[MAX_VERTEX_NUM], front 0, rear 0; printf(%d , v); visited[v] 1; queue[rear] v; while (front ! rear) { int current queue[front]; ArcNode* p G-vertices[current].firstarc; while (p ! NULL) { int w p-adjvex; if (!visited[w]) { printf(%d , w); visited[w] 1; queue[rear] w; } p p-nextarc; } } }BFS实现的要点访问即入队与DFS不同BFS中顶点在被访问的同时就立即入队。这保证了队列中存储的都是已被访问、但其邻接点尚未被探索的顶点符合“层层推进”的逻辑。出队操作的意义从队列中取出一个顶点意味着我们要开始探索它的所有邻接点了。层序信息BFS天然可以计算顶点到起点的最短距离无权图。只需在入队时记录距离distance[w] distance[current] 1。这是BFS一个非常重要的扩展应用。5.2 遍历的初始化与驱动函数无论是DFS还是BFS一个健壮的遍历程序都需要一个驱动函数来处理非连通图并正确初始化访问数组。void TraverseGraph(MGraph* G) { // 以邻接矩阵为例 // 1. 初始化访问数组 for (int i 0; i G-vertexNum; i) { visited[i] 0; } // 2. 循环检查所有顶点驱动遍历 for (int i 0; i G-vertexNum; i) { if (!visited[i]) { // 选择一种遍历方式 // DFS_Matrix(G, i); // BFS_Matrix(G, i); printf(\n); // 一个连通分量遍历结束可以换行根据题目要求 } } }为什么需要驱动循环因为图可能不是连通图。仅从指定顶点比如0开始一次遍历可能无法到达所有顶点。这个驱动循环确保了每个顶点都会被检查到从而遍历整个图的所有连通分量。这也是头歌平台测试用例常考的点。6. 常见问题与调试技巧实录在实际编码和调试头歌习题时以下几个问题是高频雷区6.1 数组越界与顶点映射错误问题Segmentation fault或输出乱码。这通常是因为数组索引超出了[0, vertexNum-1]的范围。排查检查顶点输入处理。如果顶点是字符‘A’你将其映射为0那么输入边‘A’ ‘B’就应该转化为0 1。确保映射函数正确。在邻接矩阵的循环中for (int w 0; w G-vertexNum; w)确保循环条件是w vertexNum而不是w vertexNum。在邻接表遍历链表时确保while (p ! NULL)的判断正确不要访问p-adjvex时p已是NULL。6.2 忘记处理无向图的对称性问题遍历序列不完整或者对于无向图从A能到B但从B开始遍历却找不到A。排查在InsertEdge函数里如果是无向图插入边(v, w)后必须再插入边(w, v)。在邻接矩阵中是给对称位置赋值在邻接表中是创建两个边节点分别插入两个顶点的链表。这是最经典的错误之一。6.3 访问标记数组未重置或作用域错误问题程序第一次运行正确但同一个图遍历第二次或者换一个测试用例时输出为空或错误。排查visited数组必须在每次调用TraverseGraph或新的遍历开始前全部重置为0。确保visited数组的作用域和生命周期正确。如果它在遍历函数内部定义为静态数组那么多次调用之间它的值会保留。通常建议在驱动函数开始处集中重置或者将其作为全局变量注意多组测试数据时要重置。6.4 非连通图输出格式错误问题平台判题要求每个连通分量的序列可能要以空格隔开或者每个序列单独一行。排查仔细阅读题目输出说明。在驱动函数中每次调用DFS/BFS开始一个新的连通分量遍历时可能需要输出一个空格或换行。例如可以在if (!visited[i])里面在调用遍历函数前或后按格式要求输出分隔符。6.5 递归DFS栈溢出问题对于顶点数非常多如数万的深度很大的图如一条长链递归DFS可能导致调用栈溢出。解决方案在算法题中顶点数通常可控。如果真遇到应使用非递归的栈实现。这也是为什么掌握非递归实现有价值的原因。调试技巧小数据测试用最简单的图如3个顶点的链或三角形手动模拟你的代码用纸笔画出每一步visited数组、栈、队列的变化。打印调试在关键位置如访问顶点时、入栈/入队时、出栈/出队时打印状态信息与你的手动模拟对比。单元测试思维分别测试单顶点图、完全图、链状图、环状图、非连通图。确保你的代码在所有基础拓扑结构上都正确。边界检查顶点数为0或1的图你的程序能处理吗这是平台常见的边界测试用例。把这两种存储结构和两种遍历算法理解透彻、实现稳健图论算法的大门才算真正推开。这些代码模板和避坑经验足够你应对头歌的习题和大部分基础面试题了。剩下的就是在更多复杂场景中应用和变通。