拓冰建站拓冰建站
首页 / 资讯中心 / 正文

DFS算法实战:从连通性到最小步数,掌握搜索模型与优化技巧

1. 项目概述从“迷宫寻路”到“状态转移”的思维跃迁在算法竞赛和实际软件开发中我们常常会遇到两类看似不同实则内核相通的经典问题。一类是“连通性”问题给你一个迷宫或者一张地图判断从起点能否走到终点或者计算有多少个互不连通的区域。另一类是“最小步数”问题同样是走迷宫但这次我们想知道从起点到终点的最短路径需要多少步或者如何用最少的操作次数将一个状态转换到另一个目标状态。很多初学者会把这两者分开学习用BFS解决最短路径用DFS解决连通性这固然没错但容易陷入对工具的机械记忆而忽略了问题本质的抽象与建模。今天我想结合自己多年刷题和项目实战的经验深入聊聊DFS深度优先搜索在这两类模型中的应用特别是如何用DFS的思维框架统一看待它们以及在实际编码中如何避开那些教科书上不会写的“坑”。简单来说连通性模型关心的是“能否到达”核心是遍历和标记而最小步数模型关心的是“最快到达”核心是搜索所有可能路径并记录最优解。虽然BFS因其“层层推进”的特性在寻找无权图最短路径时天然具有优势但DFS同样可以解决最小步数问题尤其在状态空间不大、需要记录路径细节或结合剪枝优化时DFS的递归思维和代码结构往往更清晰。理解这两种模型不仅能帮你解决LeetCode上的矩阵搜索题如“岛屿数量”、“单词搜索”更能让你在面对诸如“华容道”、“八数码”、“翻转游戏”等经典状态搜索问题时拥有清晰的建模思路和可靠的实现方案。无论你是正在备战算法面试的求职者还是希望提升工程中问题抽象能力的开发者掌握这两种模型的本质与实现细节都至关重要。2. 核心模型解析连通性与最小步数的本质区别与联系2.1 连通性模型一场“地毯式”的探索连通性模型要解决的问题本质是在给定的图可能是网格、树、或者抽象的关系图中判断两个点是否处于同一个连通分量内或者统计连通分量的数量。这里的“连通”通常指存在一条路径可以抵达。核心思想是使用DFS或BFS进行遍历和标记。从某个起点出发探索所有能到达的点并将它们标记为“已访问”。一次完整的DFS遍历所覆盖的所有点就构成了一个连通分量。这个过程不关心路径的长短只关心“能否走到”。典型应用场景网格中的岛屿问题给定一个二维网格1代表陆地0代表水计算岛屿的数量相连的陆地视为一个岛屿。图的连通分量给定一个无向图判断两个节点是否连通或者计算有多少个连通子图。迷宫可行性判断给定一个迷宫判断入口和出口是否连通不要求给出具体路径。DFS实现连通性搜索的关键点状态表示通常用一个与地图同样尺寸的visited数组来记录每个位置是否已被探索过防止重复访问陷入死循环。递归终止条件当搜索到目标点如果存在特定目标或者搜索到地图边界、障碍物、已访问点时停止当前分支的深入。搜索方向在网格中通常是上下左右四个方向有时包括对角线八个方向。注意在纯粹的连通性判断中DFS和BFS可以互换因为它们都能完成遍历任务。选择DFS通常是因为其递归实现代码简洁在路径记录和回溯时更直观。2.2 最小步数模型一场“寻优”的竞赛最小步数模型要解决的问题本质是在状态空间中找到从初始状态变换到目标状态所需的最少操作步骤。这里的“状态”可以是一个人在迷宫中的位置也可以是一个棋盘布局、一个字符串排列等。核心思想同样是搜索但目标从“遍历”变成了“寻优”。我们需要系统地探索所有可能的状态转移路径并记录到达每个状态所需的步数最终找到到达目标状态的最小步数。典型应用场景迷宫最短路径在无障碍的网格迷宫中找到从起点到终点的最短步数。八数码问题在一个3x3的棋盘上移动数字方块使其达到目标布局求最少移动步数。单词接龙最短序列给定两个单词和一个词典每次改变一个字母找到从起始词变到结束词的最短转换序列。用DFS实现最小步数搜索的挑战与策略 理论上DFS会“一条道走到黑”它首先找到的路径不一定是最短的。因此单纯的DFS不适合直接求解最短路径。但是通过以下两种方式DFS可以用于求解最小步数问题记录全局最优在DFS过程中维护一个全局变量min_steps。每当到达目标状态就比较当前路径步数是否比min_steps更小如果是则更新。同时必须配合强力的剪枝。迭代加深搜索IDS这是一种结合了DFS空间效率和BFS最优性保证的算法。它从小到大依次增加搜索深度限制在每一层深度限制内进行DFS。当深度限制达到最短路径长度时就能找到最优解。注意对于无权图的最短路径问题BFS是更标准、更高效的工具因为它第一次访问到某个节点时的路径就是最短路径。但在状态空间复杂、状态表示非网格坐标如排列、集合时DFS结合剪枝和迭代加深的策略往往更具灵活性。2.3 模型的联系与思维转换两者底层都是搜索算法的应用。你可以将最小步数模型看作是连通性模型在**加权图权值为1**上的一个特例只不过我们关心的边权步数之和最小。连通性模型回答了“是否连通”而最小步数模型在此基础上进一步回答了“最短距离是多少”。在建模时最关键的一步是定义“状态”。在迷宫连通性问题中“状态”就是(x, y)坐标。在岛屿问题中“状态”也是(x, y)坐标但目标是对所有状态进行连通块划分。在八数码问题中“状态”是整个3x3棋盘的布局可以用一个字符串或整数表示。在华容道问题中“状态”是所有棋子的位置集合。一旦状态定义清晰无论是用DFS还是BFS剩下的就是套用搜索框架维护一个已访问状态集合从初始状态开始根据规则生成所有可能的下一状态然后递归或迭代地进行探索。3. 实战拆解一DFS连通性模型经典应用与实现细节让我们通过两个经典问题看看DFS连通性模型如何落地并分享一些关键的实现技巧和避坑指南。3.1 案例统计网格中的岛屿数量LeetCode 200这是最经典的连通性模型问题。地图是一个二维字符网格我们需要找出被水‘0’包围的陆地‘1’块的数量。DFS解题思路遍历网格中的每一个点。如果遇到一个1陆地且未被访问过则说明发现了一个新的岛屿计数器加1。从这个点开始进行DFS将所有与之相连上下左右的1都标记为已访问或直接修改为0。这一步的目的是将整个岛屿“淹没”或“标记”避免后续重复计数。继续遍历网格重复步骤2-3。代码实现与关键注释def numIslands(grid): :type grid: List[List[str]] :rtype: int if not grid: return 0 rows, cols len(grid), len(grid[0]) count 0 def dfs(r, c): # 递归终止条件越界、遇到水、或已访问过这里通过将访问过的陆地改为‘0’来标记 if r 0 or r rows or c 0 or c cols or grid[r][c] ! 1: return # 标记当前单元格为已访问 grid[r][c] 0 # 递归探索四个方向 dfs(r - 1, c) # 上 dfs(r 1, c) # 下 dfs(r, c - 1) # 左 dfs(r, c 1) # 右 for r in range(rows): for c in range(cols): # 发现未访问的陆地启动DFS淹没整个岛屿并计数 if grid[r][c] 1: dfs(r, c) count 1 return count实操心得与避坑指南原地修改 vs 额外访问数组上述代码采用了原地修改网格值‘1’-‘0’的方式来标记已访问节省了visited数组的空间。这在题目允许修改输入时是常用技巧。如果不允许修改输入就必须使用一个独立的visited二维数组。递归深度风险网格非常大比如1000x1000且整个都是陆地时DFS递归深度可能达到百万级可能导致栈溢出。对于生产环境或极端用例考虑使用**栈模拟递归迭代DFS**或直接使用BFS更为稳妥。方向数组的运用将四个方向的坐标偏移定义为一个数组dirs [(-1,0), (1,0), (0,-1), (0,1)]然后在循环中调用dfs(rdr, cdc)可以使代码更简洁也便于扩展到八个方向。并查集Union-Find的替代方案对于纯粹的连通块计数问题并查集是另一种非常高效的数据结构尤其适合动态连接查询的场景。其思想是将相连的陆地“合并”到同一个集合中最终集合的数量就是岛屿数量。虽然DFS/BFS更直观但了解并查集解法能拓宽思路。3.2 案例被围绕的区域LeetCode 130这个问题是连通性模型的一个变种。给定一个矩阵将那些被‘X’完全包围的‘O’区域全部填充为‘X’。但位于边界的‘O’或与边界‘O’相连的‘O’不会被填充。解题思路的转变 直接寻找被包围的区域比较困难。一个巧妙的逆向思维是所有不会被填充的‘O’都直接或间接与边界的‘O’相连。因此我们可以对四条边界上的每一个‘O’进行DFS将所有与之连通的‘O’标记为一个特殊字符例如‘#’。这些‘#’代表最终会保留的‘O’。遍历整个矩阵将剩余的‘O’即被包围的改为‘X’。最后将所有‘#’恢复为‘O’。这个案例的精髓在于它教会我们有时解决连通性问题需要从问题的反面或边界条件入手进行预处理从而将复杂问题简化。避坑指南注意遍历边界的技巧只需遍历第一行、最后一行、第一列、最后一列即可注意不要重复遍历四个角点两次。标记字符的选择选择输入中不存在的字符作为临时标记避免混淆。4. 实战拆解二DFS在最小步数问题中的迂回策略如前所述DFS并非求解最短步数的最优解但在特定约束下或结合特定策略后它依然能发挥作用。我们来看两种常见策略。4.1 策略一DFS 全局变量记录最小值 剪枝这种方法思路直接用DFS枚举所有可能的路径用一个全局变量min_steps记录当前找到的到达目标的最小步数。在DFS过程中如果当前已走步数已经大于等于min_steps则立即终止当前分支的搜索剪枝因为继续走下去也不可能得到更优解。案例在迷宫中寻找最短路径假设迷宫很小def shortestPathDFS(grid, start, end): rows, cols len(grid), len(grid[0]) visited [[False] * cols for _ in range(rows)] min_steps float(inf) # 初始化为无穷大 directions [(-1,0), (1,0), (0,-1), (0,1)] def dfs(x, y, steps): nonlocal min_steps # 剪枝1步数已超过当前最优解无需继续 if steps min_steps: return # 到达终点更新最优解 if (x, y) end: min_steps min(min_steps, steps) return # 标记访问 visited[x][y] True for dx, dy in directions: nx, ny x dx, y dy if 0 nx rows and 0 ny cols and not visited[nx][ny] and grid[nx][ny] ! #: # ‘#’代表墙 dfs(nx, ny, steps 1) # 回溯撤销标记 visited[x][y] False dfs(start[0], start[1], 0) return min_steps if min_steps ! float(inf) else -1这种方法的严重局限性效率低下在最坏情况下比如没有障碍物的空旷网格它仍然会尝试几乎所有路径时间复杂度是指数级的。依赖强力剪枝如果min_steps在搜索后期才被更新为一个较小的值那么前期的剪枝效果就很弱。提示在实际竞赛或面试中对于明确的无权图最短路径问题务必优先使用BFS。DFS剪枝的方法仅适用于状态空间非常小或者你确信剪枝能极大减少搜索量的情况。4.2 策略二迭代加深搜索Iterative Deepening DFS, IDDFSIDS是DFS用于求解最小步数问题的“正确打开方式”。它模拟了BFS的层序遍历但使用了DFS的递归形式从而兼具了DFS的空间效率和BFS能找到最优解的特性。算法步骤设定一个深度限制depth_limit从0或1开始。在深度限制depth_limit内进行深度优先搜索。即DFS只探索深度不超过depth_limit的节点。如果在当前深度限制内找到了目标则返回解必然是最短路径因为是从小到大尝试深度。如果没找到则将深度限制depth_limit增加1回到步骤2重新开始新一轮的DFS。代码框架def iddfs(start_state): depth 0 while True: visited set() # 每一轮新的深度搜索都需要新的访问记录 found depth_limited_dfs(start_state, depth, visited) if found: return depth, found_solution # 返回深度和解 depth 1 def depth_limited_dfs(state, depth, visited): if depth 0: return state target_state # 检查是否为目标状态 if depth 0: return False visited.add(state) for next_state in generate_next_states(state): if next_state not in visited: if depth_limited_dfs(next_state, depth - 1, visited): return True visited.remove(state) # 回溯 return FalseIDS的优势与适用场景优势它一定能找到最短解如果存在且空间复杂度仅为O(b*d)其中b是分支因子d是目标深度。这远优于BFS的O(b^d)空间复杂度在状态空间大但解深度不深时优势明显。适用场景适用于状态空间巨大且最短路径深度未知或较深的问题如某些棋类游戏、八数码问题当使用DFS框架时。IDS避免了BFS需要存储整层节点的内存爆炸问题。实操心得每一轮的visited注意visited集合通常在每一轮深度限制的搜索中独立使用。也可以使用一个全局的visited记录所有访问过的状态但需要区分是在哪一轮深度访问的实现稍复杂。结合启发式可以结合迭代加深与A算法的思想即IDA。它在深度优先搜索时不仅限制深度还利用一个启发式函数f(state) g(state) h(state)已走代价预估到目标代价进行剪枝如果f(state)超过当前深度限制则剪枝。这是解决诸如十五数码等难题的利器。5. 状态设计与编码技巧将抽象问题转化为可搜索模型无论是连通性还是最小步数模型最难也最关键的一步是如何将实际问题抽象成一个可以用于搜索的“状态”。状态设计得好问题就解决了一半。5.1 简单状态坐标与网格对于迷宫、棋盘上的移动问题状态通常是当前所在的(x, y)坐标。这是最直观的状态。技巧对于二维网格常将坐标编码为一个整数state x * cols y方便存入集合或字典进行去重和查找。解码时x state // cols, y state % cols。5.2 复杂状态排列、集合与位运算很多问题无法用单一坐标表示。八数码问题状态是整个3x3棋盘的布局。可以用一个9位的字符串如”123456780“或一个整数来表示。单词接龙状态是当前单词。但为了求最短序列我们通常将(当前单词当前步数)作为一个组合状态放入BFS队列。如果使用DFSIDS状态就是当前单词。携带钥匙穿越迷宫LeetCode 864状态不仅包含坐标(x, y)还包含当前已经获得的钥匙集合。因为拿到不同的钥匙能打开不同的锁。这时状态可以设计为(x, y, keys)其中keys可以用一个位掩码bitmask表示例如有a-f六把钥匙keys就是一个6位的二进制数第i位为1表示拥有第i把钥匙。位掩码技巧详解 假设有n种钥匙我们可以用一个n位的整数mask来表示钥匙持有情况。获取第i把钥匙mask mask | (1 i)检查是否有第i把钥匙(mask i) 1检查是否能打开第j把锁假设锁也用字符表示且钥匙i对应锁j需要检查是否有对应的钥匙。 这种表示法非常紧凑且判断操作是O(1)的极大地提升了搜索效率。5.3 状态去重避免重复搜索的生死线在搜索中尤其是最小步数搜索如果不进行状态去重可能会在相同的状态间无限循环或者进行大量无效的重复搜索。去重策略visited集合最通用的方法。将状态如编码后的整数、字符串、元组加入一个集合。在DFS进入新状态前检查。visited set() state (x, y, keys_mask) # 将状态表示为不可变的元组 if state in visited: return visited.add(state)针对最小步数的特殊visited在BFS或寻找最短路径时我们通常记录到达某个状态的最小步数。如果新的搜索以更多步数到达同一状态则剪枝。# dist 是一个字典记录到达每个状态的最短步数 if next_state not in dist or new_steps dist[next_state]: dist[next_state] new_steps # 将新状态加入搜索队列或栈在DFS中这种“步数更多则剪枝”的策略同样有效是优化的重要手段。6. 性能优化与剪枝艺术让搜索从“可能”到“可行”当状态空间巨大时粗暴的搜索是无法接受的。剪枝是搜索算法的灵魂目的是提前排除那些明显不可能到达最优解的分支。6.1 可行性剪枝在搜索过程中如果当前状态已经不可能达到目标则立即返回。迷宫问题如果当前点与终点的曼哈顿距离|dx| |dy|大于剩余步数则不可能到达。八数码问题可以通过计算逆序对奇偶性来判断当前状态是否可解。如果与目标状态的奇偶性不同则直接剪枝。6.2 最优性剪枝在寻找最小步数时如果当前已花费的代价已经大于等于当前已知的最优解则剪枝。这就是前面提到的if steps min_steps: return。6.3 启发式剪枝A与IDA的核心使用一个启发式函数h(state)来估计从当前状态到目标状态至少还需要多少步。在DFS特别是IDA*中如果当前代价g(state) 启发式估计h(state) 深度限制则剪枝。启发式函数的设计原则可采纳性h(state)必须永不高于实际剩余代价。否则可能剪掉最优解。一致性单调性对于任意状态转移到下一状态满足h(state) cost(state, next_state) h(next_state)。这能保证A*找到最优解。启发性越强越好在满足可采纳性的前提下h(state)越接近实际代价算法效率越高。举例网格最短路径曼哈顿距离是一个可采纳的启发函数假设只能上下左右移动。八数码问题每个数字当前位置到目标位置的曼哈顿距离之和称为“曼哈顿距离启发式”是一个非常有效的可采纳启发函数。6.4 对称性剪枝与状态等效有些状态通过旋转、翻转、重排列后是等效的。我们可以定义一个状态的“规范形式”在搜索前将所有状态转换为其规范形式再进行去重可以大幅减少搜索空间。 例如在某些棋盘游戏中旋转对称或镜像对称的棋盘状态视为相同。7. 从模型到实战一个综合案例分析单词接龙 IILeetCode 126. 单词接龙 II 是一个很好的综合练习它要求找出所有从beginWord到endWord的最短转换序列。这既是最小步数问题找最短序列又需要找出所有路径。难点分析最短路径必须使用BFS来保证首先找到的是最短路径。所有路径需要记录BFS树中所有能到达某个单词的父节点列表而不能像普通BFS那样只记录一个父节点那样只能找到一条路径。路径重建在BFS构建好关系图后使用DFS从endWord回溯到beginWord重建所有路径。解决思路BFS构建层次图队列进行层级BFS。使用字典level_map {word: [parent1, parent2, ...]}记录每个单词是在哪一层被哪些单词发现的。同时使用集合visited记录本层已访问的单词但关键点同一层的多个单词可能发现同一个下层单词这需要被允许因为它们代表了不同的最短路径分支。所以visited的更新是在每一层遍历结束后才统一进行。一旦遇到endWord不再向队列添加新单词但需要完成当前层的遍历以收集所有可能在同一层到达endWord的路径。DFS回溯所有路径从endWord开始利用level_map递归地查找所有可能的父节点直到回溯到beginWord形成一条完整路径。注意路径需要反转因为是从终点回溯到起点。这个案例完美展示了如何将BFS用于寻找最短步数层级同时结合DFS用于路径回溯与生成是两种搜索模型优势互补的典范。避坑指南去重的时机在BFS中必须在处理完同一层的所有单词后再将这些单词加入全局visited。如果在生成一个单词时就将其标记为已访问会漏掉同一层其他单词到达它的路径。提前终止发现endWord后继续处理完当前层的所有节点很重要这样才能找到所有最短路径。DFS回溯的优化可以使用记忆化搜索避免对同一子路径的重复计算。8. 总结与进阶思考DFS的连通性模型和最小步数模型是搜索算法应用的两大基石。连通性模型侧重于遍历与标记是很多图论和网格问题的基础。最小步数模型则是在状态空间中寻优虽然BFS在无权图中是标准解法但通过迭代加深、强力剪枝以及A*启发式搜索DFS也能在此领域大放异彩尤其在状态表示复杂、解空间深但分支因子不大的问题中。在实际应用中我个人的体会是不要机械地记忆“DFS用于连通性BFS用于最短路径”。更重要的是培养问题建模的能力如何定义“状态”状态之间的“转移规则”是什么目标是判断连通性还是寻找最优解回答清楚这些问题该用DFS还是BFS用哪种变体如何剪枝就会变得清晰起来。最后再分享一个小心得在解决复杂搜索问题时先写一个不加任何优化的暴力DFS版本确保逻辑正确、能解决小规模用例。然后再一步步加入状态记忆化、剪枝等优化。这样既能保证正确性又能让你深刻理解每一项优化带来的收益。例如在解决八数码问题时先写一个纯DFS找任意解再改为IDS找最优解最后加入曼哈顿距离启发式升级为IDA*。这个循序渐进的过程比直接背诵最终算法代码收获要大得多。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门