深度优先搜索(DFS)在迷宫问题中的应用与Java实现详解

📅 发布时间:2026/8/26 3:56:05
深度优先搜索(DFS)在迷宫问题中的应用与Java实现详解
1. 从“暴走”到“寻路”为什么DFS是迷宫问题的首选一提到“暴走迷宫”很多刚接触算法竞赛的朋友可能会想到暴力枚举所有路径然后找最短的那条。这想法没错但迷宫稍微大一点比如10x10的格子路径数量就可能是个天文数字计算机算到“天荒地老”也算不完。这时候我们就需要一种更聪明的“暴走”策略——深度优先搜索也就是DFS。DFS的核心思想其实很像我们玩迷宫游戏时的一种本能策略一条路走到黑碰壁再回头。你站在迷宫的入口面前可能有多个岔路。DFS的做法是先随便选一条路比如最左边然后一直往前走直到走到死胡同或者终点。如果走到死胡同时还没到终点它就退回到上一个岔路口尝试另一条没走过的路。这种“不撞南墙不回头”的劲头就是“深度优先”。为什么在蓝桥杯的迷宫题里DFS往往是首选的开局思路呢我总结了几点原因代码实现极其直观DFS的递归写法几乎就是对这个“一条路走到黑”过程的直接翻译逻辑清晰易于理解和上手。对于竞赛中常见的二维矩阵迷宫代码模板化程度很高。空间消耗相对友好在搜索树或图的形态下DFS在某一时刻只需要存储从根节点到当前节点的一条路径。对于迷宫我们通常用一个等大的二维boolean数组来标记是否访问过空间复杂度是O(N*M)这是可以接受的。是更高级算法的基础DFS是许多图论和搜索算法的基石。理解了DFS再学习回溯法、记忆化搜索、乃至剪枝优化都会顺畅很多。很多迷宫问题在DFS基础上稍加改动比如记录路径、求最短步数就能解决。当然DFS也不是万能的。它最大的问题在于如果迷宫存在环或者非常庞大且分支众多它可能会在找到出口前在错误的路径上“深陷”很久效率低下。而且用朴素的DFS找到的路径往往不是最短路径。但对于蓝桥杯大多数考察DFS的迷宫题题目设计通常会让DFS有很好的用武之地或者明确要求输出所有可行路径这时DFS就是标准答案。接下来我们就用Java把DFS在迷宫中的应用从理论到实战彻底“暴走”一遍。2. 迷宫建模如何用Java数据表示一个迷宫在让算法“暴走”之前我们得先给算法一个可以走的“世界”。在程序中迷宫最自然的表示方法就是一个二维字符数组char[][]或整数数组int[][]。每种表示都有其适用场景。2.1 两种常见的迷宫表示法1. 字符数组表示法 (char[][] maze)这是最直观的一种。我们直接用不同的字符来代表迷宫的不同元素。S或s代表起点 (Start)T或E或e代表终点 (Target/End).或0代表可通行的空地#或1或*代表不可通过的墙壁例如一个5x5的迷宫可能长这样char[][] maze { {#, S, #, #, #}, {#, ., ., ., #}, {#, #, #, ., #}, {#, ., ., ., T}, {#, #, #, #, #} };优点一目了然打印出来就是迷宫的样子调试非常方便。缺点比较每个位置的状态时需要用字符去比较效率略低于整数比较。在需要记录额外信息比如走到该点的步数时需要另开数组。2. 整数数组表示法 (int[][] maze)用不同的整数值来编码状态更贴近底层和算法竞赛的习惯。0可通行的空地1墙壁不可通行2起点有时也当作空地处理但需要记录其坐标3终点int[][] maze { {1, 2, 1, 1, 1}, {1, 0, 0, 0, 1}, {1, 1, 1, 0, 1}, {1, 0, 0, 0, 3}, {1, 1, 1, 1, 1} };优点判断状态快直接整数比较节省内存。可以方便地复用该数组来存储其他整型信息例如用负数表示访问过用正数表示步数。缺点没有字符数组直观打印时需要转换。选择建议在蓝桥杯比赛中如果题目输入直接给的就是字符矩阵那就用char[][]。如果给的是0/1矩阵通常用int[][]。我个人的习惯是在需要频繁打印迷宫状态进行调试时用char[][]在追求极致性能或进行复杂状态记录时用int[][]。2.2 方向数组把“上下左右”行走抽象化在迷宫中移动无非就是上下左右四个方向有些题目有八个方向。在代码里写四个if判断固然可以但更优雅和通用的做法是使用方向数组。// 四个方向下 右 上 左 (符合坐标系向下为x正向右为y正的习惯) int[][] dirs {{1, 0}, {0, 1}, {-1, 0}, {0, -1}}; // 或者 右 下 左 上 // int[][] dirs {{0, 1}, {1, 0}, {0, -1}, {-1, 0}};dirs[i][0]表示x坐标行号的变化量dirs[i][1]表示y坐标列号的变化量。使用方向数组后探索新坐标的代码就从冗长的重复判断变成了一个简洁的循环for (int[] dir : dirs) { int nextX currentX dir[0]; int nextY currentY dir[1]; // 然后判断(nextX, nextY)是否合法、是否可走 }这样做的好处是代码简洁避免重复。易于扩展如果题目改成可以走“米”字型八个方向只需要将dirs数组扩展为8个元素即可核心搜索逻辑完全不用动。顺序可控通过调整dirs数组中的方向顺序可以控制DFS优先探索哪个方向。这在某些题目中会影响搜索效率或路径输出顺序。2.3 访问标记防止在原地打转这是DFS实现中至关重要的一环。想象一下你从A点走到B点如果不做标记那么从B点又可以走回A点程序就会在A和B之间无限循环导致栈溢出。我们需要一个和迷宫大小相同的boolean数组visited[][]或者直接修改原迷宫数组例如将走过的点从.改为X。使用独立的visited数组推荐boolean[][] visited new boolean[n][m]; visited[startX][startY] true; // 标记起点已访问优点是不破坏原始迷宫数据方便回溯时恢复状态逻辑清晰。修改原数组// 假设原数组是 char[][] maze, 可通行是. maze[currentX][currentY] X; // 走过标记为X // 回溯时需要改回来 maze[currentX][currentY] .;优点是节省了一点空间但回溯时需要小心恢复状态容易出错。踩坑提醒务必在递归调用DFS之前就标记当前位置为已访问。如果等到递归返回后再标记或者在递归函数开头标记都可能因为重复访问相邻节点而导致栈溢出。正确的顺序是标记当前点 - 判断是否终点 - 向四周探索。3. DFS核心引擎递归与回溯的代码实现有了迷宫模型现在我们来打造DFS的引擎。我们将通过一个经典的蓝桥杯题型——“判断迷宫是否有解”来拆解整个过程。3.1 递归函数的骨架设计一个标准的迷宫DFS递归函数通常包含以下参数和步骤/** * 深度优先搜索迷宫 * param maze 迷宫地图 * param visited 访问标记数组 * param x 当前所在的行坐标 * param y 当前所在的列坐标 * return 从(x,y)出发是否能到达终点 */ public static boolean dfs(char[][] maze, boolean[][] visited, int x, int y) { // 1. 边界条件与合法性检查“撞墙” if (x 0 || x maze.length || y 0 || y maze[0].length) { return false; // 超出迷宫边界 } if (maze[x][y] # || visited[x][y]) { return false; // 撞到墙或已经走过 } // 2. 终点判断“找到出口” if (maze[x][y] T) { return true; } // 3. 标记当前点已访问 visited[x][y] true; // 4. 向四个方向进行探索 int[][] dirs {{1, 0}, {0, 1}, {-1, 0}, {0, -1}}; for (int[] dir : dirs) { int nextX x dir[0]; int nextY y dir[1]; if (dfs(maze, visited, nextX, nextY)) { return true; // 如果某个方向找到了终点直接返回true } } // 5. 回溯四个方向都走不通取消当前点的标记注意此问题中可省略 // visited[x][y] false; // 对于“是否有解”问题不需要回溯因为走过不通的路以后也不用再走。 // 但对于“找出所有路径”的问题这一步是必须的 return false; // 所有方向都走不通 }关键点解析递归终止条件包含两类。一是“失败”条件越界、撞墙、重复访问直接返回false二是“成功”条件到达终点返回true。访问标记的时机在判断完终止条件之后开始探索之前进行标记。这保证了不会重复访问自己。递归探索通过循环方向数组生成下一个坐标并递归调用dfs函数。返回值传递如果某个子调用返回true意味着从这个方向找到了出口那么当前调用也立刻返回true将成功信号一层层传递回最初的调用点。回溯在这个“判断是否有解”的例子中我们不需要回溯即visited[x][y] false。因为如果从某个点出发探索所有方向都失败了那么以后从其他路径再走到这个点也注定失败没必要再尝试。这其实是一种隐式的剪枝。3.2 从“是否有解”到“记录一条路径”上面的函数只告诉我们能不能走出去。如果我们还想知道是怎么走出去的就需要记录路径。通常我们用一个Listint[]或者一个栈来保存路径上的坐标。public static boolean dfsWithPath(char[][] maze, boolean[][] visited, Listint[] path, int x, int y) { // 边界与合法性检查同上 if (...) { return false; } // 终点判断 if (maze[x][y] T) { path.add(new int[]{x, y}); // 将终点加入路径 return true; } visited[x][y] true; path.add(new int[]{x, y}); // **加入路径** int[][] dirs {{1, 0}, {0, 1}, {-1, 0}, {0, -1}}; for (int[] dir : dirs) { int nextX x dir[0]; int nextY y dir[1]; if (dfsWithPath(maze, visited, path, nextX, nextY)) { return true; // 找到路径直接返回 } } // **回溯关键步骤**当前点所有方向都不通需要从路径中移除并取消访问标记 path.remove(path.size() - 1); visited[x][y] false; return false; }与之前代码的核心区别路径记录在访问一个点后立即将其加入path列表。回溯操作当从一个点所有方向探索都失败后在返回false之前必须将这个点从path中移除path.remove(lastIndex)并且将visited标记重置为false。这是真正的“回溯”即撤销当前选择尝试其他可能性。找到路径后的处理在终点处将终点坐标加入路径然后一路return true路径就被完整地保存在path列表中了。实操心得在递归函数中修改List这样的可变对象作为路径记录时要特别注意回溯时的清理工作。一个常见的错误是只记得visited[x][y]false却忘了从path中移除该点导致最终路径包含了许多错误分支上的点。3.3 处理多个出口与所有路径有些迷宫问题可能有多个出口或者要求输出所有可能的路径。这时我们的DFS函数就不再返回boolean了而是直接进行搜索和收集。public static void dfsAllPaths(char[][] maze, boolean[][] visited, Listint[] path, ListListint[] allPaths, int x, int y) { // 边界与合法性检查 if (x 0 || x maze.length || y 0 || y maze[0].length || maze[x][y] # || visited[x][y]) { return; } // 加入当前路径 path.add(new int[]{x, y}); visited[x][y] true; // 判断是否为出口之一 if (maze[x][y] T) { // 假设T是出口 // 找到一条完整路径保存当前路径的副本 allPaths.add(new ArrayList(path)); // **注意**找到出口后不能直接return要继续回溯探索其他可能 } // 向四个方向探索 int[][] dirs {{1, 0}, {0, 1}, {-1, 0}, {0, -1}}; for (int[] dir : dirs) { int nextX x dir[0]; int nextY y dir[1]; dfsAllPaths(maze, visited, path, allPaths, nextX, nextY); } // 回溯撤销当前点选择 path.remove(path.size() - 1); visited[x][y] false; }关键变化返回类型为void函数不再通过返回值传递是否找到而是通过修改外部集合allPaths来收集结果。找到出口后不立即返回即使找到了一个出口也需要执行后续的回溯步骤path.remove和visitedfalse以便探索其他可能到达该出口或其它出口的路径。保存路径副本在将当前path加入allPaths时必须创建一份新的列表new ArrayList(path)。因为path对象在后续回溯中会被修改如果直接加入引用最后allPaths里所有的条目都会指向同一个被修改后的空列表。4. 性能优化与剪枝让DFS“暴走”得更快朴素的DFS在复杂迷宫面前可能会非常慢因为它会尝试所有可能的路径。剪枝就是在搜索过程中提前判断出某些分支不可能产生我们需要的解从而直接放弃对这些分支的探索节省大量时间。4.1 可行性剪枝最基本的优化在迷宫中最常见的可行性剪枝就是判断下一步是否“合法”我们在递归开头做的边界检查、墙壁检查、访问标记检查本身就是一种剪枝——剪掉了那些明显无效的移动。4.2 最优性剪枝寻找最短路径如果我们的目标是找到最短路径朴素的DFS记录所有路径再比较长度效率极低。我们可以引入一个minSteps变量记录当前找到的最短步数在搜索过程中如果当前已走步数已经大于等于minSteps那么即使这条路能走到终点步数也肯定不会更短了可以直接放弃。public static void dfsShortest(char[][] maze, boolean[][] visited, int steps, int[] minSteps, int x, int y) { // 最优性剪枝如果当前步数已经不可能打破记录直接返回 if (steps minSteps[0]) { return; } // 边界、合法性、终点判断... if (maze[x][y] T) { minSteps[0] Math.min(minSteps[0], steps); return; } visited[x][y] true; for (int[] dir : dirs) { dfsShortest(maze, visited, steps 1, minSteps, x dir[0], y dir[1]); } visited[x][y] false; }这里用int[] minSteps而不是int是因为Java中基本类型是值传递在递归中修改int参数不会影响外层而用数组对象引用可以解决这个问题。初始化时minSteps[0]可以设为一个很大的数如Integer.MAX_VALUE。4.3 记忆化搜索避免重复计算同一状态在有些迷宫变种题中比如带有“传送门”或者状态如持有钥匙的迷宫从同一个坐标(x, y)出发如果携带的状态如钥匙集合相同那么后续能到达的结果是确定的。如果不对这个状态进行记忆DFS会重复计算很多次。我们可以用一个缓存通常是Map或数组来存储已经计算过的(状态 - 结果)。// 假设状态可以用一个整数 mask 表示例如二进制位表示有哪些钥匙 // memo[x][y][mask] 表示在(x,y)点持有钥匙状态为mask时能否到达终点 Boolean[][][] memo new Boolean[n][m][1 keyCount]; public static boolean dfsWithMemo(char[][] maze, boolean[][] visited, int x, int y, int keyMask, Boolean[][][] memo) { // ... 边界判断等 if (memo[x][y][keyMask] ! null) { return memo[x][y][keyMask]; // 直接返回缓存结果 } // ... 原有DFS逻辑 boolean result ...; // 根据DFS探索得到的结果 memo[x][y][keyMask] result; // 存储结果到缓存 return result; }记忆化搜索将DFS从纯粹的暴力搜索变成了动态规划式的自顶向下带记忆的搜索能极大提升效率解决许多原本会超时的问题。4.4 方向搜索顺序的优化虽然理论上DFS无论按什么顺序搜索最终都能找到解如果存在的话但搜索顺序会影响找到第一个解的速度。一个常见的启发式策略是优先朝离终点更近的方向搜索。我们可以预先计算终点坐标(tx, ty)然后在方向数组排序或选择时优先选择使曼哈顿距离|nextX - tx| |nextY - ty|更小的方向。这并不能保证一定最快但在许多情况下能显著加速找到解的过程。// 在递归函数内部对dirs进行动态排序或选择 Listint[] dirList new ArrayList(Arrays.asList(dirs)); dirList.sort((d1, d2) - { int dis1 Math.abs(x d1[0] - targetX) Math.abs(y d1[1] - targetY); int dis2 Math.abs(x d2[0] - targetX) Math.abs(y d2[1] - targetY); return dis1 - dis2; }); for (int[] dir : dirList) { // ... 递归调用 }5. 蓝桥杯真题实战迷宫问题的典型变种与解法掌握了DFS的基本框架和优化技巧我们来看几类蓝桥杯中常见的迷宫变种题以及如何调整我们的DFS策略来应对。5.1 变种一最大连通块面积细胞问题这类问题不找路径而是找迷宫矩阵中最大的连通区域。例如1代表细胞0代表背景上下左右相邻的1算作同一个细胞求最大细胞的面积。解法对矩阵中的每个未访问过的1启动一次DFS或BFS这次搜索的目的不是找出口而是遍历并计数所有连通的1。每次DFS返回该连通块的面积维护一个最大值即可。public static int dfsArea(int[][] grid, boolean[][] visited, int x, int y) { if (x 0 || x grid.length || y 0 || y grid[0].length || grid[x][y] 0 || visited[x][y]) { return 0; } visited[x][y] true; int area 1; // 当前点算1面积 area dfsArea(grid, visited, x 1, y); area dfsArea(grid, visited, x - 1, y); area dfsArea(grid, visited, x, y 1); area dfsArea(grid, visited, x, y - 1); // 注意这里不需要回溯 visited因为计算过的连通块无需再次访问 return area; } // 在主函数中遍历所有点对每个未访问的1调用dfsArea更新最大面积。5.2 变种二带有状态的多层迷宫捡钥匙开门迷宫中有门用大写字母如A表示和对应的钥匙用小写字母如a表示。只有拿到对应的钥匙才能通过门。解法此时搜索状态不仅仅是坐标(x, y)还要加上当前拥有的钥匙集合。通常钥匙数量有限比如不超过26个可以用一个整数的二进制位来表示钥匙集合状态压缩。mask的第i位为1表示拥有第i把钥匙。访问标记数组需要升维visited[x][y][mask]。遇到小写字母a时更新状态newMask mask | (1 (a - a))。遇到大写字母A时检查状态if ((mask (1 (A - A))) 0) { 不能通过 }。DFS函数签名变为dfs(maze, visited, x, y, mask)。5.3 变种三求最短路径步数BFS更优虽然DFS通过剪枝也能求最短路径但在无权图每一步代价相同的迷宫找最短路径问题上广度优先搜索BFS是更自然、更高效的选择。因为BFS是按照距离起点的层次来遍历的第一次到达终点时的步数就是最短步数。DFS与BFS在此问题上的对比特性DFS (深度优先搜索)BFS (广度优先搜索)数据结构栈 (递归调用栈)队列搜索顺序一条路深入到底一层一层向外扩找到的第一条路径不一定是最短的一定是最短的边权相同时空间复杂度O(最长路径深度)O(最大宽度)通常比DFS高适用场景判断连通性、找所有路径、拓扑排序最短路径、层次遍历对于“迷宫最短步数”题如果明确要求建议直接使用BFS。但用DFS配合最优性剪枝见4.2节也可以解只是效率通常不如BFS。5.4 变种四计数问题有多少种走法例如从左上角走到右下角每次只能向右或向下问有多少种不同的路径。这其实是动态规划的经典题杨辉三角/组合数。但如果迷宫中有障碍物或者可以走四个方向但要求统计所有不重复路径DFS回溯计数是一种方法。解法将DFS的返回值改为long表示从(x,y)出发到终点的路径数。利用记忆化搜索避免超时。public static long dfsCount(char[][] maze, boolean[][] visited, int x, int y, Long[][] memo) { // ... 边界、障碍物判断 if (maze[x][y] T) { return 1; } if (memo[x][y] ! null) { return memo[x][y]; } visited[x][y] true; long totalPaths 0; for (int[] dir : dirs) { totalPaths dfsCount(maze, visited, xdir[0], ydir[1], memo); } visited[x][y] false; memo[x][y] totalPaths; return totalPaths; }6. 调试技巧与常见“坑点”排查即使思路正确实现DFS时也容易掉进一些坑里。下面是我在刷题和教学中总结的几个常见问题及排查方法。6.1 栈溢出错误 (StackOverflowError)这是递归DFS最典型的错误。原因和解决方案没有设置访问标记visited或标记时机错误导致在两个相邻点之间来回走无限递归。务必在递归函数开头检查visited并在递归调用前标记。迷宫过大递归深度太深Java的默认调用栈深度可能不够。可以尝试将递归改为显式栈的迭代实现或者通过JVM参数-Xss增加栈大小竞赛环境通常不允许。对于特别大的迷宫BFS通常是更好的选择。终点判断条件写错或遗漏导致过了终点还在继续搜索。检查终点判断逻辑是否在所有条件分支的最前面。6.2 结果不对或漏解回溯时状态恢复不全在寻找所有路径或需要回溯的场景下只恢复了visited数组忘了恢复path列表或者只恢复了path列表忘了恢复迷宫地图本身如果修改了原地图。确保所有在递归前被修改的全局或共享状态在递归返回后都得到恢复。方向数组定义错误检查dirs数组中的坐标变化量(dx, dy)是否正确对应了上下左右。一个常见的混淆是二维数组的行列索引与数学坐标系的对应关系。边界条件判断顺序一定要先判断数组下标是否越界再使用该下标去访问数组否则会引发ArrayIndexOutOfBoundsException。标准的写法是if (x 0 || x n || y 0 || y m) { // 先判断越界 return; } if (maze[x][y] # || visited[x][y]) { // 再访问数组 return; }6.3 性能问题与超时缺少剪枝对于最优解问题务必使用最优性剪枝。对于存在大量重复子问题的问题考虑使用记忆化搜索。不必要的状态拷贝在保存路径时如果频繁使用new ArrayList(path)来创建副本在路径很长时会非常耗时。有时可以只记录路径长度或者用数组存储上一步位置来反向推导路径。visited数组使用不当在“寻找所有路径”问题中必须在回溯时重置visited。但在“判断连通性”或“计算连通块”问题中访问过的点无需再次访问则不能重置visited。理解问题本质决定visited的生命周期。6.4 一个实用的调试方法可视化打印在DFS函数的关键位置插入打印语句输出当前坐标、路径、visited数组状态是调试的最直接方法。可以写一个辅助函数来打印迷宫当前状态public static void printMaze(char[][] maze, boolean[][] visited, int x, int y) { for (int i 0; i maze.length; i) { for (int j 0; j maze[i].length; j) { if (i x j y) { System.out.print(); // 当前位置 } else if (visited[i][j]) { System.out.print(*); // 已访问 } else { System.out.print(maze[i][j]); } } System.out.println(); } System.out.println(---------------); }在递归开始时调用printMaze可以像看动画一样观察DFS的探索过程对于理解算法和定位错误非常有帮助。7. 总结与进阶思考DFS深度优先搜索是解决迷宫类问题的利器其核心在于“递归”与“回溯”两大思想。通过这次“暴走”我们从最基础的迷宫表示、递归框架走到了路径记录、多解处理再探讨了剪枝优化和各类变种题的应对策略。我个人的体会是学习DFS绝不能停留在背诵模板的层面。关键要理解其**“尝试-回溯”**的本质。在遇到新的变种题时问自己几个问题问题的“状态”是什么通常包含坐标有时还包括钥匙、步数等递归的“终止条件”是什么找到目标、无路可走、超出限制如何进行“状态转移”向哪些方向移动移动后状态如何变化是否需要“回溯”需要回溯哪些状态visited,path,maze等是否有优化空间可行性剪枝、最优性剪枝、记忆化把这些问题想清楚了代码写出来就是水到渠成的事。最后再分享一个小心得在蓝桥杯赛场上如果遇到迷宫题先想清楚题目到底要什么判断连通、一条路径、所有路径、最短路径再选择最合适的搜索策略DFS、BFS或DFS剪枝。时间充裕的话可以用DFS先实现一个基础版本保分再去思考优化。希望这篇长文能帮你打通DFS解决迷宫问题的任督二脉。算法学习没有捷径多思考、多编码、多调试你也能在算法的迷宫中自如“暴走”。