资讯动态

深度优先搜索(DFS)两大核心模型:连通性与最小步数详解

发布时间:2026/8/28 11:28:56 来源:尧图企业网站定制
1. 从迷宫到棋盘理解搜索算法的两大基石干了这么多年算法我越来越觉得搜索算法是程序员的“内功”。它不像那些花哨的框架今天火明天凉搜索的核心思想——系统地探索所有可能性是解决无数实际问题的底层逻辑。而深度优先搜索DFS作为搜索家族里的经典成员其应用之广远超很多人的想象。今天我们不聊那些复杂的剪枝和优化就聚焦在DFS最朴实、也最核心的两种应用模型上连通性模型和最小步数模型。你可以把DFS想象成一个在迷宫里执着探索的冒险家。连通性模型就是他拿着油漆刷任务是把所有能走通的房间都刷上同一种颜色回答“哪些地方是连着的”这个问题。而最小步数模型则是他拿着计步器目标是以最快的速度从入口跑到出口回答“最短路径怎么走”这个问题。这两个模型一个关乎“可达性”一个关乎“最优性”是无数算法题和实际项目比如游戏地图寻路、网络连通性检查、图像处理中的区域填充的基石。无论你是正在刷题准备面试的新手还是工作中需要处理一些路径规划问题的开发者吃透这两个模型都能让你在面对“找路径”、“算距离”、“判连通”这类问题时心里更有底。2. 连通性模型你的“地图染色”工具箱2.1 模型核心不问路有多远只问能否到达连通性模型要解决的核心问题非常简单粗暴给定一个起点有时也包括终点判断在给定的规则和约束下能否从起点到达目标点或遍历所有可达点。它不关心走了多少步不关心路径是否最优只关心一个布尔值的结果是或否。这听起来简单但应用场景极其广泛。比如图像处理中的“泛洪填充”Photoshop里的油漆桶工具点击一个像素会把颜色相近的连通区域都填上色。这就是标准的连通性模型。迷宫游戏的基础逻辑判断玩家是否有可能从出生点走到终点而不需要立即给出路径。网络分析判断社交网络中两个人是否间接认识六度空间理论的基础或者判断网络中的两个节点是否连通。棋盘类游戏判断在围棋或黑白棋中一片棋子是否还有“气”即是否与空位连通。这个模型的DFS实现本质上就是一次对图或矩阵的遍历。我们从起点出发按照规则比如上下左右四个方向尝试移动每走到一个新的合法位置就标记为“已访问”然后递归地从这个新位置继续探索直到无路可走或到达目标。2.2 经典框架与代码实现我们用一个最经典的例子来具象化迷宫中的点连通性判断。假设有一个n x m的二维网格迷宫0代表可走道路1代表障碍物。给定起点(sx, sy)和终点(ex, ey)判断能否从起点走到终点。下面是一个清晰、标准的DFS连通性模型模板。我习惯在写这种搜索时把方向数组、访问数组、边界检查都写得明明白白虽然看起来代码量多一点但结构清晰不容易出错。#include iostream #include vector using namespace std; const int N 110; // 假设网格最大尺寸 int n, m; // 网格行数、列数 vectorvectorint maze(N, vectorint(N, 0)); // 迷宫地图 vectorvectorbool visited(N, vectorbool(N, false)); // 访问标记数组 int sx, sy, ex, ey; // 起点和终点坐标 // 方向数组上、右、下、左 (顺时针或逆时针顺序均可但要完整) int dx[4] {-1, 0, 1, 0}; int dy[4] {0, 1, 0, -1}; // DFS连通性判断函数 bool dfs_connect(int x, int y) { // 1. 递归终止条件到达终点 if (x ex y ey) { return true; } // 2. 标记当前点为已访问避免重复走 visited[x][y] true; // 3. 遍历四个方向 for (int i 0; i 4; i) { int nx x dx[i]; int ny y dy[i]; // 4. 检查新位置是否合法在边界内、不是障碍、未被访问 if (nx 0 nx n ny 0 ny m maze[nx][ny] 0 !visited[nx][ny]) { // 5. 递归探索如果找到路径则直接返回true if (dfs_connect(nx, ny)) { return true; } // 注意这里没有“撤销访问”的步骤因为连通性模型只关心能否到达 // 一个点走过一次就知道它是否连通不需要为了找其他路径而回溯状态。 } } // 6. 所有方向都走不通返回false return false; } int main() { // 示例输入 n 5, m 5; // 初始化一个简单迷宫0可走1障碍 vectorvectorint map_init { {0, 1, 0, 0, 0}, {0, 1, 0, 1, 0}, {0, 0, 0, 0, 0}, {0, 1, 1, 1, 0}, {0, 0, 0, 1, 0} }; for(int i0; in; i) for(int j0; jm; j) maze[i][j] map_init[i][j]; sx 0, sy 0; // 起点(0,0) ex 4, ey 4; // 终点(4,4) // 每次搜索前清空访问记录 for(int i0; in; i) fill(visited[i].begin(), visited[i].end(), false); if (dfs_connect(sx, sy)) { cout Yes, a path exists. endl; } else { cout No path found. endl; } return 0; }关键点解析访问数组visited这是防止DFS在图上无限递归绕圈子的生命线。在网格中它确保每个点只被探索一次。方向数组dx[], dy[]将四个方向的坐标变化量化避免写四段重复的if判断代码让结构更清晰也更容易扩展到八方向。递归终止条件首先是到达目标直接返回成功。隐含的终止条件是所有方向都尝试完毕返回失败。无回溯操作注意看函数里没有visited[x][y] false;这样的语句。这是因为连通性模型通常只要求找到一个连通分量或判断连通性不需要枚举所有路径。标记过已访问的点不需要为了其他可能的路径而“释放”。这是和回溯算法如排列组合一个重要的区别。2.3 变体与实战统计连通块数量连通性模型一个非常常见的变体是统计连通块的数量。比如给你一张卫星地图1代表陆地0代表海洋计算有多少个独立的岛屿。这其实就是对每个未访问的陆地单元格执行一次DFS把能连通的陆地全部标记计数器加一。int countIslands(vectorvectorchar grid) { if (grid.empty()) return 0; int n grid.size(), m grid[0].size(); vectorvectorbool visited(n, vectorbool(m, false)); int islandCount 0; // 方向数组 int dx[4] {-1, 0, 1, 0}; int dy[4] {0, 1, 0, -1}; // 辅助DFS函数用于“淹没”/标记一个完整的岛屿 functionvoid(int, int) dfs [](int x, int y) { visited[x][y] true; for (int i 0; i 4; i) { int nx x dx[i], ny y dy[i]; if (nx 0 nx n ny 0 ny m grid[nx][ny] 1 !visited[nx][ny]) { dfs(nx, ny); } } }; // 遍历整个网格 for (int i 0; i n; i) { for (int j 0; j m; j) { if (grid[i][j] 1 !visited[i][j]) { // 发现一块新陆地新连通块计数并“淹没”它 islandCount; dfs(i, j); } } } return islandCount; }实操心得 在统计连通块时visited数组至关重要。有时为了节省空间如果允许修改原数组我们可以直接用原数组进行标记比如把访问过的‘1’改成‘0’或‘2’这样连visited数组都省了。但这取决于题目要求如果要求不修改原数据就必须额外开数组。3. 最小步数模型寻找最优路径的探索者3.1 模型核心不仅要到还要最快到最小步数模型在连通性模型的基础上增加了一个维度距离或代价。它不仅要找到一条路径更要找到步数最少、代价最小的那条路径。这时DFS的“深度优先”特性就暴露出一个缺点它可能会一头扎进一条很深的、但并非最优的路径里浪费大量时间甚至因为递归过深导致栈溢出。因此用DFS实现最小步数模型必须配合剪枝。最常用的剪枝策略是最优性剪枝当当前已走的步数已经大于或等于当前已知的最优解时就没有必要继续往下探索了直接返回。3.2 DFS剪枝的实现框架我们依然用迷宫寻路为例现在要找出从起点到终点的最短步数。#include iostream #include vector #include climits using namespace std; const int N 110; int n, m; vectorvectorint maze(N, vectorint(N, 0)); vectorvectorbool visited(N, vectorbool(N, false)); int sx, sy, ex, ey; int minSteps INT_MAX; // 全局变量记录当前找到的最小步数 int dx[4] {-1, 0, 1, 0}; int dy[4] {0, 1, 0, -1}; // DFS最小步数搜索函数 void dfs_min_steps(int x, int y, int currentSteps) { // 最优性剪枝如果当前步数已经不可能打破记录直接返回 if (currentSteps minSteps) { return; } // 终止条件到达终点 if (x ex y ey) { minSteps min(minSteps, currentSteps); // 更新最优解 return; } visited[x][y] true; // 标记访问 for (int i 0; i 4; i) { int nx x dx[i]; int ny y dy[i]; if (nx 0 nx n ny 0 ny m maze[nx][ny] 0 !visited[nx][ny]) { dfs_min_steps(nx, ny, currentSteps 1); // 步数1 } } visited[x][y] false; // 关键回溯为了探索其他可能更短的路径 } int main() { // 初始化迷宫和起点终点同上一示例 n 5, m 5; vectorvectorint map_init { {0, 1, 0, 0, 0}, {0, 1, 0, 1, 0}, {0, 0, 0, 0, 0}, {0, 1, 1, 1, 0}, {0, 0, 0, 1, 0} }; for(int i0; in; i) for(int j0; jm; j) maze[i][j] map_init[i][j]; sx 0, sy 0; ex 4, ey 4; // 初始化访问数组和最小步数 for(int i0; in; i) fill(visited[i].begin(), visited[i].end(), false); minSteps INT_MAX; dfs_min_steps(sx, sy, 0); if (minSteps ! INT_MAX) { cout Minimum steps: minSteps endl; } else { cout No path found. endl; } return 0; }与连通性模型的本质区别引入了状态“步数”递归函数多了一个参数currentSteps用于记录走到当前状态所用的步数。必须回溯visited[x][y] false;这行代码出现了为什么因为我们要找的是全局最优解最短路径。从A点走到B点在当前这条路径上B点被访问了但当我们从其他路径探索时B点完全有可能成为一条更短路径的一部分。因此在递归返回时必须撤销当前点的访问标记允许其他路径再次探索它。这是DFS用于求最优解时的一个关键特征。最优性剪枝if (currentSteps minSteps) return;这是提升效率的关键。一旦发现当前路径的累积步数已经不低于已知的最优解这条路径就没有继续探索的价值了果断放弃。3.3 模型局限与BFS的对比虽然DFS剪枝可以解决最小步数问题但在无权图每步代价相同的最短路径搜索上它的效率通常远低于广度优先搜索BFS。原因在于BFS是按“层”扩散的它第一次到达终点时所用的步数就一定是最小的。而DFS是“一条道走到黑”即使有剪枝也可能探索很多无效的长路径后才找到最优解。对于上面那个迷宫问题BFS的代码会更简洁且能保证在找到路径时就是最短的。通常的选型原则是判断连通性、统计连通块DFS代码简洁思路直观。无权图求最短步数优先使用BFS。这是BFS的主场。带权图或复杂约束求最优解DFS或更优的DFS变种如记忆化搜索、IDA*配合强力剪枝或者使用专门的最短路径算法Dijkstra, A*。注意事项 用DFS求最小步数时一定要小心递归深度。网格如果太大比如1000x1000递归DFS很容易导致栈溢出。这时要么改用BFS使用队列是迭代形式要么使用迭代加深搜索IDS或显式地用栈模拟递归但后者实现起来更复杂。在实际编程中如果明确是求最短步数我个人的第一选择永远是BFS。4. 深入辨析状态管理与复杂度分析4.1 状态的定义与存储无论是连通性还是最小步数模型核心都在于对“状态”的管理。在网格DFS中一个“状态”通常就是当前的坐标(x, y)。对于连通性模型状态只需要记录“是否被访问过”。我们用一个布尔型的visited数组来存储目的是避免重复访问防止循环。对于最小步数模型状态需要记录“是否被访问过”以及“到达该状态时的当前步数”。这时visited数组的含义可能变得更微妙。如果我们用DFSvisited需要在回溯时被重置因为它标记的是“在当前搜索路径中是否被访问”。如果我们用BFSvisited或dist距离数组则标记的是“是否已被最优地访问过”一旦设置就不需要更改。更复杂的问题中状态可能不止包含坐标。比如带钥匙的迷宫状态就是(x, y, keyState)其中keyState是一个二进制数表示已经获得了哪些钥匙。这时visited就需要升维变成visited[x][y][keyState]。4.2 时间与空间复杂度估算复杂度分析能帮你判断算法是否会超时或超内存是做题和设计系统时必备的技能。时间复杂度最坏情况下DFS会访问所有可达的状态。对于n x m的网格如果没有障碍可达状态数是O(n*m)。每个状态会尝试向4个方向扩展所以粗略的时间复杂度是O(4^(n*m))不对这是一个常见的误解。因为visited数组的存在每个点最多被访问一次在连通模型中或几次在最小步数回溯模型中。更准确的说法是状态总数是 O(n*m)对每个状态我们进行常数次如4次邻接状态检查。因此时间复杂度通常是 O(状态总数 * 每个状态的转移数)即O(n*m * C)C是常数。在最坏情况下如最小步数模型疯狂回溯可能会指数增长但强剪枝下往往可控。空间复杂度主要消耗在递归调用栈深度取决于最长路径最坏 O(n*m)。这是DFS最大的风险点。visited等标记数组O(n*m)。存储图/网格本身O(n*m)。所以对于网格DFS空间复杂度通常是O(n*m)。如果递归深度接近n*m就要警惕栈溢出。4.3 从DFS到BFS思维转换当题目明确要求“最短步数”时强烈建议将思维从DFS切换到BFS。BFS的模板化程度更高且能稳定求最短。这里给出一个等价的BFS迷宫最短步数代码你可以对比一下。int bfs_min_steps() { vectorvectorbool visited(n, vectorbool(m, false)); // 队列中存储 pair坐标, 步数 queuepairpairint, int, int q; q.push({{sx, sy}, 0}); visited[sx][sy] true; while (!q.empty()) { auto [pos, steps] q.front(); q.pop(); int x pos.first, y pos.second; if (x ex y ey) { return steps; // BFS首次到达就是最短 } for (int i 0; i 4; i) { int nx x dx[i], ny y dy[i]; if (nx 0 nx n ny 0 ny m maze[nx][ny] 0 !visited[nx][ny]) { visited[nx][ny] true; q.push({{nx, ny}, steps 1}); } } } return -1; // 无法到达 }BFS的空间消耗在于队列但通常不会出现递归栈溢出的问题。看到“最短”、“最少”这类字眼BFS应该是你条件反射般的首选。5. 常见“坑点”与调试技巧实录5.1 那些年我踩过的坑忘记标记visited这是新手最容易犯的错误结果就是程序在循环路径上无限递归最终栈溢出或超时。教训DFS递归函数入口除了终止条件检查第一件正经事就是标记当前状态已访问。visited标记时机错误应该在即将进入递归之前标记还是在递归函数开头标记我推荐在递归开头刚进来就标记。如果放在for循环里面在判断邻居合法性之后标记逻辑容易混乱也可能导致重复访问。连通性与最小步数模型混淆该回溯时不回溯在需要求所有解或最优解如最小步数时忘记在递归返回前visited[x][y] false导致其他路径被阻塞。不该回溯时回溯在只需要判断连通性或统计连通块时错误地加上了回溯语句导致重复计数和无限循环。方向数组设置错误漏写某个方向或者dx,dy对应错误导致搜索逻辑不对。建议定义方向数组后简单脑补测试一下比如(0,0)加上(dx[0], dy[0])是不是走到了(-1,0)上方。边界检查顺序一定要先检查数组下标是否越界再使用该下标去访问数组if (nx 0 nx n ny 0 ny m maze[nx][ny] 0)这个顺序不能乱否则maze[nx][ny]可能访问非法内存。5.2 调试与验证方法当你的DFS代码没有给出预期结果时可以按以下步骤排查小数据测试用一个 2x2 或 3x3 的极小网格手动推导出正确结果然后单步调试你的程序看状态变化是否符合预期。打印调试法在递归函数入口打印当前坐标和步数在每次做出选择向某个方向移动时也打印信息。这样可以清晰看到程序的搜索路径。void dfs(int x, int y, int steps) { cout Entering: ( x , y ) steps steps endl; // ... for(...) { if(isValid(nx, ny)) { cout Trying to go to ( nx , ny ) endl; dfs(nx, ny, steps1); } } cout Leaving: ( x , y ) endl; }检查初始化和重置对于多组测试数据确保visited数组、minSteps等全局变量在每组数据开始前都被正确重置。这是一个非常高频的错误点。可视化对于网格问题可以写一个简单的函数在搜索过程中打印出带有标记的地图直观看到哪些点被访问了。5.3 性能优化小技巧剪枝的威力在最小步数模型中最优性剪枝if (currentSteps minSteps) return;能极大提升效率。有时还可以结合“启发式”信息进行更激进的剪枝。方向顺序在某些情况下调整方向数组的顺序可能让程序更快地找到解尤其是终点在起点右下角时优先向右、向下搜索可能会更快触达。但这属于“玄学”优化不一定总是有效。使用迭代加深搜索如果担心递归深度也想要最优解可以了解一下迭代加深搜索IDS。它结合了DFS的空间优势和BFS能找到最优解的特性。记忆化搜索如果问题有大量重复子状态比如从某个点出发到终点的最少步数被多次计算可以用一个数组memo[x][y]存储计算结果避免重复递归。这其实是动态规划的思想。6. 模型扩展与应用场景联想掌握了这两个基础模型你可以尝试解决更复杂的问题它们往往是这些模型的组合或变体。连通性模型扩展带有条件的连通比如“只能走比当前格子数值大1的格子”判断能否连通。这时isValid判断条件需要修改。求连通块的最大面积/周长在统计连通块的DFS中加入计数器即可。判断环路在遍历中如果发现一个邻居已被访问过且不是自己的“父亲节点”则存在环。这常用于图论中。最小步数模型扩展多起点/多终点初始化时将所有起点加入BFS队列步数为0或者判断到达任意一个终点即可。带有权值每一步的代价不同。这时DFS/BFS就不够了需要Dijkstra算法。状态压缩如前文提到的带钥匙迷宫(x, y, keyState)visited升维BFS/DFS照用。融合应用“孤岛求生”类问题先使用连通性模型DFS/BFS找到所有可达的陆地资源点然后在这些点之间使用最小步数模型BFS计算彼此距离最后可能转化为一个图上的规划问题。图像处理中的高级操作先连通性分析找出物体轮廓再计算轮廓上两点间的最短路径。说到底连通性模型和最小步数模型是搜索世界里的两把瑞士军刀看起来简单但组合起来能应对各种复杂地形。我个人的习惯是拿到一个问题先问自己它核心是在问“能不能到”还是在问“怎样最快到”回答清楚这个问题就决定了你该拿起哪把工具或者是否需要两把工具一起用。多写多调试多思考为什么这样写慢慢地这些模型就会成为你本能的一部分。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价