深度优先搜索与广度优先搜索:图遍历的核心思想、代码实现与实战选型
1. 项目概述:从迷宫到网络,理解图的遍历
最近在社区里看到不少朋友在讨论图的遍历,特别是DFS(深度优先搜索)和BFS(广度优先搜索)。无论是“3*3迷宫(全0)的dfs的路径是什么意思”这样的具体问题,还是“连通分量”这类抽象概念,都指向一个核心:我们如何系统地“走遍”一个图结构,并从中获取我们需要的信息。这不仅仅是算法竞赛的考点,更是解决无数实际工程问题的基石。从社交网络的好友推荐(六度空间理论)、网页爬虫的抓取策略、网络路由的路径发现,到游戏地图的寻路AI,背后都离不开这两种最基础、最强大的图遍历思想。
我自己在早期做路径规划项目时,也曾对DFS和BFS的选择感到困惑。用DFS,代码写起来简单,但一不小心就掉进“死胡同”出不来;用BFS,感觉能稳扎稳打,但内存消耗又让人头疼。后来经过大量实战才明白,没有最好的算法,只有最合适的场景。这篇文章,我就结合自己踩过的坑和积累的经验,带你彻底吃透图的遍历。我会从最直观的“走迷宫”例子入手,拆解DFS和BFS每一步的思考逻辑,然后深入到代码实现、性能分析和那些教科书上不会讲的调试技巧。无论你是正在啃《算法导论》的学生,还是需要解决实际连通性问题的开发者,相信都能从中获得可以直接“抄作业”的干货。
2. 核心思想拆解:深度与广度的哲学
在深入代码之前,我们必须像建筑师理解蓝图一样,先吃透DFS和BFS的核心设计哲学。这两种算法代表了两种截然不同的探索世界的策略,理解了这个,你才能在做技术选型时毫不犹豫。
2.1 深度优先搜索:一条路走到黑
DFS的策略,可以用一个词概括:递归与回溯。它的核心思想是,从起点开始,选择一条边尽可能深地探索下去,直到这条路径的尽头(无法继续前进),然后“回溯”到上一个分岔路口,选择另一条未探索的路径继续深入。
为什么是“深度优先”?想象你在探索一个巨大的地下洞穴。DFS就像是一个固执的探险家,他看到一个洞口就钻进去,一直走到洞穴尽头,标记好所有岔路,然后原路返回到最近的一个未探索的岔路口,再钻进去。他优先保证的是对单条路径的完整探索。
核心数据结构:栈无论是显式使用栈,还是利用函数调用栈实现递归,DFS都依赖于栈“后进先出”的特性。这完美契合了“走到尽头再回溯”的行为:你最后探索的分支,正是你需要最先回溯处理的分支。
注意:很多新手容易混淆递归和迭代实现的DFS。递归实现利用了系统的函数调用栈,代码简洁,但深度过大时可能导致栈溢出。显式栈的迭代实现更可控,但代码稍复杂。理解它们本质相同至关重要。
一个生活化类比:破解密码锁你有一个3位数的密码锁,每位是0-9。DFS的策略是:先固定第一位为0,然后尝试第二位为0,再尝试第三位从0到9。穷尽所有第三位后,回溯,将第二位改为1,再穷尽所有第三位……以此类推。它是在深度上(从高位到低位)进行穷举。
2.2 广度优先搜索:层层递进的扩张
BFS的策略则相反,它追求的是公平与层次。从起点开始,先访问所有与起点直接相连的顶点,然后再访问这些顶点的邻居(即距离起点为2的顶点),以此类推,像水波一样一圈圈扩散出去。
为什么是“广度优先”?继续地下洞穴的比喻,BFS像是一个指挥有序的勘探队。队长先派第一批队员探索起点直接相连的所有洞口,并回报情况。等所有直接洞口探索完毕,队长再命令第一批队员的队员(即第二批)去探索这些新洞口的直接连接洞。它优先保证的是对所有“距离”起点同等近的顶点进行公平访问。
核心数据结构:队列队列“先进先出”的特性是BFS的天然伴侣。起点先入队,访问后,将其所有未访问的邻居入队。这样,先被访问的顶点,其邻居也会先被访问,严格保证了按层次遍历的顺序。
一个生活化类比:社交网络的传播你想知道通过多少层朋友关系可以认识某个名人。BFS的做法是:先列出你的所有直接朋友(第一层),问他们是否认识该名人。如果不认识,再让你的朋友们列出他们的朋友(第二层),你去询问这批人……这个过程一定是按关系亲疏(层数)由近及远进行的。
2.3 核心对比与选型逻辑
理解了思想,我们就能从原理上对比,并指导选型:
| 特性维度 | 深度优先搜索 | 广度优先搜索 |
|---|---|---|
| 核心思想 | 递归回溯,钻探到底 | 层次扩散,水波涟漪 |
| 数据结构 | 栈 (Stack) | 队列 (Queue) |
| 解的空间 | 适用于发现一条路径、拓扑排序、检测环 | 适用于寻找最短路径(边权相等时)、连通分量 |
| 内存消耗 | 与深度成正比。路径长时消耗小,但递归深可能栈溢出。 | 与宽度成正比。需要存储当前层的所有节点,在宽图上消耗大。 |
| 经典应用 | 迷宫所有路径、排列组合、图的连通性检测、拓扑排序 | 最短步数迷宫、社交网络度数、广播网络、网页爬虫 |
选型心法: 当你需要“找到任意一个解”或“遍历所有可能状态”(如全排列),或者问题空间很深但很窄时,优先考虑DFS。当你明确需要“最短路径”或“最少步骤”,或者需要按层次处理节点时,BFS是唯一选择。例如,走迷宫找出口,如果只问“能否走出去”,DFS和BFS都可以;但如果问“最短的出路是哪条”,就必须用BFS。
3. 从理论到代码:邻接表下的实现详解
理论说得再透,不如一行代码。我们以最常用的邻接表方式存储图(适合稀疏图),分别用递归、迭代实现DFS,用队列实现BFS。这里我假设图是无向且连通(或弱连通)的,顶点从0开始编号。
3.1 深度优先搜索的两种实现
首先,我们需要一个图。这里用一个vector<vector<int>>来表示邻接表,graph[i]存储顶点i的所有邻居。
#include <iostream> #include <vector> #include <stack> using namespace std; class Graph { private: vector<vector<int>> adjList; // 邻接表 int numVertices; public: Graph(int n) : numVertices(n), adjList(n) {} void addEdge(int u, int v) { adjList[u].push_back(v); adjList[v].push_back(u); // 无向图,双向添加 } const vector<int>& getNeighbors(int v) const { return adjList[v]; } int getNumVertices() const { return numVertices; } };3.1.1 递归实现DFS递归实现是最直观,也最能体现DFS“深入”本质的方式。
void dfsRecursive(int node, vector<bool>& visited, const Graph& graph) { // 1. 访问当前节点 cout << node << " "; visited[node] = true; // 2. 递归访问所有未访问的邻居 for (int neighbor : graph.getNeighbors(node)) { if (!visited[neighbor]) { dfsRecursive(neighbor, visited, graph); // 递归深入 } } // 函数结束,自动回溯到调用者(上一层) } void dfs(const Graph& graph, int start) { vector<bool> visited(graph.getNumVertices(), false); dfsRecursive(start, visited, graph); }关键点解析:
visited数组是灵魂。它防止重复访问陷入死循环,尤其是在有环的图中。- 递归调用
dfsRecursive(neighbor, ...)就是“选择一条路走下去”。 - 当
for循环结束,当前函数返回,就是“回溯”到上一个节点。
实操心得:递归DFS的访问顺序取决于邻接表中邻居的存储顺序。如果你需要特定的遍历顺序(如按节点编号),务必先对邻居列表进行排序。
3.1.2 显式栈迭代实现DFS对于深度可能非常大的图,为了避免系统栈溢出,我们需要用自己维护的栈来模拟递归过程。
void dfsIterative(const Graph& graph, int start) { vector<bool> visited(graph.getNumVertices(), false); stack<int> s; // 初始化:起点入栈 s.push(start); // 注意:此时不要标记起点为已访问 while (!s.empty()) { int node = s.top(); s.pop(); // **关键检查**:弹出栈顶后,才检查是否访问过 if (visited[node]) { continue; // 如果已访问,跳过 } // 访问该节点 cout << node << " "; visited[node] = true; // 将其所有未访问的邻居入栈 // 注意:为了模拟递归的顺序,可能需要逆序入栈(取决于你对顺序的要求) const vector<int>& neighbors = graph.getNeighbors(node); for (auto it = neighbors.rbegin(); it != neighbors.rend(); ++it) { if (!visited[*it]) { s.push(*it); // 这里不标记 visited!标记是在弹出时进行的。 } } } }为什么弹出时才标记visited?这是迭代DFS最容易出错的地方。在递归中,我们一进入函数就标记visited。在迭代中,同一个节点可能被多次压入栈中(通过不同的父节点)。如果我们在入栈时就标记,那么后压入的同一节点就会被忽略,这可能错过一些合法的访问路径(在某些允许重复访问的问题中)。而在弹出时标记,保证了每个节点只会被访问一次,但允许其被发现多次。对于标准的图遍历(每个节点访问一次),这种“延迟标记”是正确且通用的写法。如果你想在入栈时标记,必须确保每个节点只会被压入栈一次,这通常需要额外的数据结构来记录“是否在栈中”,实现更复杂。
3.2 广度优先搜索的实现
BFS的实现范式非常统一,几乎总是使用队列。
#include <queue> void bfs(const Graph& graph, int start) { vector<bool> visited(graph.getNumVertices(), false); queue<int> q; // 初始化:起点入队并标记 q.push(start); visited[start] = true; // BFS通常在入队时标记 while (!q.empty()) { int levelSize = q.size(); // 记录当前层的节点数(可选) // 遍历当前层的所有节点 for (int i = 0; i < levelSize; ++i) { int node = q.front(); q.pop(); cout << node << " "; // 访问节点 // 将下一层的未访问邻居入队 for (int neighbor : graph.getNeighbors(node)) { if (!visited[neighbor]) { q.push(neighbor); visited[neighbor] = true; // **入队时立即标记** } } } // 此处可以输出换行,直观显示层次 cout << endl; } }BFS的关键细节:
- 入队时标记:这是BFS与迭代DFS的一个重要区别。因为队列保证每个节点只会被处理一次,在入队时标记可以防止同一个节点被多次加入队列,减少不必要的重复判断和内存占用。
- 层次信息:通过
levelSize,我们可以轻松地知道当前正在处理的是第几层的节点。这对于求解“最短路径步数”等问题非常有用。在打印时换行,能直观看到遍历的层次。 - 最短路径:BFS天然地按距离起点的边数(层次)由近及远访问节点。因此,当图中所有边权值相等时,BFS第一次访问到某个节点的路径,就是从起点到该节点的最短路径。要记录路径,只需在访问每个节点时,同时记录它的“前驱节点”即可。
4. 实战应用场景深度剖析
懂了怎么写,更要懂什么时候用。下面我们结合几个典型场景,看看DFS和BFS如何大显神通。
4.1 场景一:迷宫问题
这是最经典的例子。“3*3迷宫(全0)的dfs的路径是什么意思”?假设一个3x3网格,0代表可走,1代表墙。从(0,0)走到(2,2),求所有路径。
- DFS解法:DFS会探索一条路直到终点或死路,然后回溯。它能找出所有可能的路径。记录路径时,需要在递归调用前将当前点加入路径向量,递归返回后从向量中弹出(回溯)。这样,当到达终点时,路径向量里保存的就是一条完整路径。
- 路径含义:DFS输出的“路径”序列,是它探索过程中依次经过的格子顺序。由于回溯,这个序列会很长,包含所有走过的岔路。你需要专门用一个数组在到达终点时记录快照,才是真正的有效路径。
- BFS解法:BFS按步数层层推进,第一次到达终点的路径就是最短路径(假设每步移动代价相同)。它通常不直接输出所有路径,而是输出最短步数和一条最短路径。
代码片段示意(DFS找所有路径):
vector<vector<pair<int, int>>> allPaths; // 存储所有路径 vector<pair<int, int>> currentPath; void dfsMaze(int x, int y, vector<vector<int>>& maze, vector<vector<bool>>& visited) { if (x < 0 || x >= maze.size() || y < 0 || y >= maze[0].size() || maze[x][y] == 1 || visited[x][y]) return; // 进入当前点 currentPath.push_back({x, y}); visited[x][y] = true; if (x == targetX && y == targetY) { allPaths.push_back(currentPath); // 找到一条路径 } else { // 四个方向递归探索 dfsMaze(x+1, y, maze, visited); dfsMaze(x-1, y, maze, visited); dfsMaze(x, y+1, maze, visited); dfsMaze(x, y-1, maze, visited); } // 回溯:离开当前点 visited[x][y] = false; currentPath.pop_back(); }4.2 场景二:连通分量与岛屿问题
“bfs 连通分量”是另一个热点。连通分量是指图中一个极大的连通子图。求连通分量数量是经典的“岛屿数量”问题(LeetCode 200)的图论版本。
- 核心思路:遍历所有节点。如果遇到一个未访问的节点,就从它开始进行一次完整的DFS或BFS,这次遍历所能到达的所有节点构成一个连通分量。计数器加1。然后继续寻找下一个未访问的节点。
- DFS vs BFS:在这个问题上,两者完全等价,都能正确标记一个连通分量内的所有节点。选择依据通常是图的特点(深度大用BFS防栈溢出)或个人编码习惯。
int countComponents(const Graph& graph) { int n = graph.getNumVertices(); vector<bool> visited(n, false); int count = 0; for (int i = 0; i < n; ++i) { if (!visited[i]) { count++; // 使用DFS或BFS遍历这个连通分量,标记所有节点为visited // dfsRecursive(i, visited, graph); bfs(graph, i); // 注意这里的bfs需要适配,仅遍历未访问的 } } return count; }4.3 场景三:拓扑排序
拓扑排序针对有向无环图,用于确定任务的执行顺序。DFS是实现拓扑排序非常优雅的方式。
- DFS解法:对每个未访问节点进行DFS。在DFS递归函数返回之前,将当前节点压入一个栈。最终,将栈中元素依次弹出,得到的序列就是拓扑排序的一个逆序(或正序,取决于压栈顺序)。
- 为什么是DFS?因为DFS的特性是,必须将一个节点的所有后代都访问完毕,该节点自身的递归才会结束。这正好符合“一个任务必须在它的所有依赖任务完成后才能进行”的语义。
- BFS也可以实现拓扑排序(Kahn算法),通过不断移除入度为0的节点,这里不展开。
5. 性能优化与避坑指南
在实际项目中,直接套用模板常常会出问题。下面是我总结的几个关键陷阱和优化技巧。
5.1 栈溢出与递归深度
这是递归DFS的阿喀琉斯之踵。当图是一条长长的链时,递归深度等于节点数,很容易触发栈溢出。
- 解决方案:
- 改用迭代DFS:使用显式栈,内存通常分配在堆上,容量远大于系统调用栈。
- 限制递归深度:如果问题性质允许,可以设置一个最大递归深度。
- 使用BFS:如果问题可以用BFS解决,优先使用BFS,其空间复杂度通常与宽度相关,更可控。
5.2 访问标记的时机与状态
如前所述,visited标记的时机是易错点。
- DFS(递归):一进入函数就标记。
- DFS(迭代,通用模板):从栈中弹出节点时标记。
- BFS:节点入队时标记。
踩坑实录:在一次解决“图中两点间所有简单路径”的问题时,我使用了迭代DFS并在入栈时标记
visited,结果漏掉了许多路径。因为从A到B可能有两条路径共享中间节点C,如果在第一次经过C时就标记为已访问,第二条路径就无法通过C了。正确的做法是使用“路径上的visited”或者回溯时取消标记。
5.3 邻接表的遍历顺序
邻接表中邻居的顺序会影响DFS/BFS的访问序列。如果问题要求特定顺序(如字典序最小路径),必须在遍历前对每个节点的邻居列表进行排序。这是一个常见的性能与正确性权衡点。
// 在构建图或遍历前排序 for (auto& neighbors : adjList) { sort(neighbors.begin(), neighbors.end()); // 升序 }5.4 处理大规模图时的内存与效率
当图非常大(如社交网络图)时:
- 数据结构:使用
vector<vector<int>>可能内存不连续,可以考虑用单一大数组存储所有边,配合索引数组(CSR格式),对缓存更友好。 visited数组:如果节点ID非常稀疏,可以用unordered_set代替vector<bool>,但查询速度会慢。有时可以用vector<int>存储时间戳来判断是否为本轮访问,节省清空数组的时间。- 并行化:对于BFS,每一层的节点可以独立访问其邻居,适合并行化处理。而DFS的深度优先特性使其难以并行。
6. 从遍历到算法:DFS/BFS的进阶思考
掌握了基础的遍历,我们可以将其作为基石,解决更复杂的问题。
6.1 双向BFS
当起点和终点都已知,且需要找最短路径时,双向BFS能大幅减少搜索空间。从起点和终点同时开始BFS,当两个搜索 frontier 相遇时,路径找到。理论搜索空间从 O(b^d) 降到 O(b^(d/2)),其中b是分支因子,d是深度。
实现要点:使用两个队列和两个visited字典(或一个字典但记录来源)。每次迭代选择当前节点数较少的方向进行扩展,检查新扩展的节点是否出现在另一个方向的visited集合中。
6.2 带权图的最短路径
BFS只能处理边权相等的情况。对于带权图,需要Dijkstra算法(边权非负)或Bellman-Ford算法。但有趣的是,Dijkstra算法可以看作是BFS的广义形式——它使用优先队列(最小堆)代替普通队列,每次扩展当前距离起点最近的节点。而BFS可以看作是边权为1时的Dijkstra特例。
6.3 回溯法与DFS
回溯法本质是一种特殊的DFS,用于在解空间树中搜索所有解。它和图的DFS共享“尝试-回溯”的核心思想。区别在于,回溯法在搜索树上进行,每个节点代表一个部分解;而图的DFS是在已有的图结构上遍历。很多组合问题(八皇后、数独)都是用回溯法(DFS思想)解决的。
图的遍历,DFS和BFS,远不止是教科书上的两个算法名字。它们是两种强大的问题解决范式。DFS教你如何专注深入,穷尽一条线索的所有可能;BFS教你如何步步为营,以最小的代价覆盖全局。真正掌握它们,不在于背诵代码模板,而在于理解其背后的思想,并在面对具体问题时,能清晰地判断“此时,我该深度优先,还是广度优先?”这种判断力,需要在大量实践中反复锤炼。下次当你遇到需要遍历状态空间的问题时,不妨先停下来画一画,想想是“一条路走到黑”更合适,还是“广撒网”更高效。
