ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

深度优先搜索(DFS)全解析:从递归迭代到拓扑排序与环检测

深度优先搜索(DFS)全解析:从递归迭代到拓扑排序与环检测 做图遍历需求的时候我见过不少同事一上来就写个三层嵌套循环硬塞“访问标记”结果数据一上量就出问题。深度优先搜索DFS听起来是算法课的入门概念但真正要把它用对、用好、用出性能边界里面其实有一堆值得掰开揉碎讲的细节。这篇我想用自己的实战经验把DFS从原理到代码、从拓扑排序到环检测、从递归到迭代、从崩溃到调优完整拆一遍。不管你是刚接触图的初学者还是已经在写业务代码但想补强算法功底的工程师这篇文章应该都能让你对DFS有一个更立体的认识。1. DFS到底在干什么先想清楚再写代码1.1 一句话定义和“走迷宫”类比我习惯把DFS描述成一种“不撞南墙不回头”的遍历策略从起点出发每来到一个顶点就优先挑一条没走过的边一直冲到不能再往前为止然后沿原路退回上一个分岔口换一条路继续冲。这个“退回”动作在代码里可能是递归返回也可能是栈弹出。拿走迷宫举例最直观。你站在入口面前有三条岔路。DFS的做法是先选最左边那条一路走到底如果撞墙就退回起点换第二条。你手里只攥着一张当前路径的地图不需要记住所有没走过的岔路只要保证退回时能回到最近的分岔口就行。这种“只记录当前路径最近状态”的特性让DFS的空间开销通常是O(V)V是顶点数这也是它在很多场景下比BFS更省内存的原因。但要特别注意的是迷宫有墙你撞了就知道此路不通而图遍历时一个顶点可能同时有多条边指向它也可能有回边指向祖先所以“访问标记”就变得至关重要。这是DFS和走迷宫最大的区别也是所有坑的源头。1.2 图的存储方式邻接表、邻接矩阵怎么选写DFS之前你得先确定图怎么存。实际工程里最常见的两种邻接表每个顶点维护一个邻居列表稀疏图边数E远小于顶点数V的平方下遍历复杂度是O(VE)空间也是O(VE)。邻接矩阵V行V列的二维数组查询任意两点是否相邻是O(1)但遍历一个顶点的所有邻居就要扫一整行复杂度变为O(V²)。稠密图下这个方式倒也可接受但空间O(V²)很容易让大图直接爆内存。我个人的选型准则很简单先确认图的规模再决定存储结构。如果顶点数上万、但每个顶点平均只有个位数的边那铁定用邻接表如果是个几百顶点的密集图邻接矩阵反而让代码简单不少。很多人在LeetCode上习惯了邻接表结果遇到邻接矩阵就忘了循环里要扫全列这种细节在实战里最容易出错。存储方式遍历一个点所有邻居的复杂度空间适合场景邻接表O(邻居数)O(VE)稀疏图绝大多数工程场景邻接矩阵O(V)O(V²)稠密图顶点数少的教学场景1.3 为什么“一条路走到黑”反而不笨刚学算法的人容易觉得DFS这么“愣头青”是不是效率不行其实不是。DFS的“愣”恰恰是它的优势来源。第一它能利用递归调用栈自动保存路径状态。你不需要额外存储一条完整路径操作系统帮你干了这个活。第二在某些问题里DFS可以先探索一条完整路径再到下一步比如拓扑排序、路径搜索、连通块染色等必须“走到尽头”才能获得完整信息。第三DFS天然适合剪枝场景——你在状态空间里搜索解时可以边探索边判断当前分支有没有可能产生解不行就提前返回这种“尽早止损”的能力是BFS很难做到的。所以DFS不是“笨”而是“一条路走到黑但随时知道回头”。理解了这一点后面所有应用就都顺理成章了。2. 两种实现递归版和迭代版各自的门道2.1 递归版最直观但visited标记别放错位置递归版DFS的代码量很少很多教科书上都有模板但细节全在访问标记上。先看标准写法def dfs_recursive(graph, start): visited set() def dfs(node): # 进入节点时立刻标记防止重复进入 visited.add(node) # 处理当前节点 print(visit:, node) for neighbor in graph[node]: if neighbor not in visited: dfs(neighbor) dfs(start) return visited这里有个常见错误有人会把visited.add(node)写在访问邻接表循环之后也就是“等这个节点全部处理完再标记”。这么做在单线程、无环图里可能碰巧没问题但只要图里有环或者两个分支共用同一个邻居就会出现重复访问甚至死循环。标记的时机必须是“入递归前”而不是“出递归后”。还有一个性能细节用递归解决问题时每次函数调用都涉及栈帧的创建和销毁节点深度大时开销不小。但它的优点是代码结构清晰尤其在做回溯、维护路径状态时特别好写。我的经验是如果你没遇到栈溢出问题优先用递归版因为心智负担最小。2.2 迭代版自己管理栈顺序和递归不一样递归的本质就是维护一条调用栈。如果不想受递归深度限制或者面试官要求写出非递归版本那就得手动用栈模拟。def dfs_iterative(graph, start): stack [start] visited {start} while stack: node stack.pop() print(visit:, node) for neighbor in graph[node]: if neighbor not in visited: visited.add(neighbor) stack.append(neighbor)看起来很简单但我要提醒你一个容易搞混的点这个版本的遍历顺序和递归版并不完全一样。递归版对每个节点会“先一路深入到第一个子节点”而上面这种写法因为先把所有未访问的邻居都压进栈所以弹出顺序是“最后一个压入的邻居先访问”更像是一种反序的层序推进。如果你需要严格复现递归的访问次序得用带“邻居索引”的显式栈def dfs_iterative_exact(graph, start): stack [(start, 0)] # (节点, 下一个要访问的邻居下标) visited {start} while stack: node, idx stack[-1] if idx len(graph[node]): neighbor graph[node][idx] stack[-1] (node, idx 1) if neighbor not in visited: visited.add(neighbor) stack.append((neighbor, 0)) else: stack.pop()这个版本才是递归的完全模拟栈顶元素记录“我正在访问这个节点并且它的第idx个邻居之前已经处理完了”。每次循环要么推进一个邻居要么结束当前节点。我把三种方式的差异整理成表格方便你对照实现方式遍历顺序与递归版一致性空间开销适用场景递归完全一致系统栈可能溢出深度可控逻辑复杂需回溯简单栈迭代不一致手动栈可控只需要遍历不关心精确顺序显式索引栈迭代严格一致手动栈可控深度极大无法递归但需保持DFS语义2.3 递归改迭代系统栈的显式化实际工程里“递归改迭代”的痛点是很多人没想清楚递归栈里每一帧不只是“当前节点”一个信息还有“当前处理到哪个邻居”这个隐含状态。上面那个显式索引栈就是把这两个信息打包成一个元组保存下来。我在重构一段递归DFS时踩过一次坑只拿一个栈存节点然后每弹出一个节点就把它的邻居全压进去。结果不仅顺序变了还因为在环里没正确标记visited导致无限循环。后来我意识到手动模拟递归时每一帧的状态必须完整否则你只是得到了一个能跑通的遍历而不是DFS本身。如果你遇到某个算法题要求严格DFS顺序又不让递归直接用带索引的栈这是最稳妥的写法。3. DFS能解决的实际问题从拓扑排序到环检测3.1 拓扑排序后序遍历反序为什么是反序先说结论对一张有向无环图做DFS按照节点“所有邻居都处理完毕”的先后顺序记录得到一个列表再把这个列表反转就是原图的一个拓扑序。为什么是反序因为DFS的“完成时间”天然满足一个性质如果存在边u→v那么u的完成时间一定晚于v因为u要等v处理完才能结束。所以按完成时间从早到晚排得到的是“依赖方在前被依赖方在后”反转之后变成“被依赖方在前依赖方在后”这正是拓扑排序的语义。def topological_sort(graph): visited set() stack [] # 用列表模拟拓扑序结果 def dfs(node): visited.add(node) for neighbor in graph[node]: if neighbor not in visited: dfs(neighbor) stack.append(node) # 后序记录 for n in graph: if n not in visited: dfs(n) return stack[::-1] # 反转获得拓扑序这个实现有几个工程点要注意第一必须遍历所有顶点而不是只从一个起点出发否则会漏掉不连通的子图第二代码里没有做环检测如果输入图有环这个结果就没有任何拓扑意义甚至可能有隐藏bug。所以在生产环境里我会建议先跑一遍环检测确认DAG之后再拓扑排序。3.2 环检测三色标记DFS里的状态机DFS有一个经典技巧叫三色标记把每个节点染成白色未访问、灰色正在访问中、黑色访问完成。如果DFS过程中遇到了一个灰色节点说明有一条边从当前节点指向它的祖先这个祖先还没处理完这就构成了环。def has_cycle_directed(graph): WHITE, GRAY, BLACK 0, 1, 2 color {n: WHITE for n in graph} def dfs(node): color[node] GRAY for neighbor in graph[node]: if color[neighbor] GRAY: return True if color[neighbor] WHITE and dfs(neighbor): return True color[node] BLACK return False for n in graph: if color[n] WHITE and dfs(n): return True return False为什么比单纯的visited池更好因为visited只告诉你“访问过”但无法区分“访问过且处理完了”和“访问过但还在递归路径上”。对于无向图只要避免走回父节点问题不大但对有向图来说visited根本不够必须区分GRAY和BLACK。我在实际项目里做依赖关系分析时就常用三色标记比如构建服务依赖图时检测循环依赖数据规模万级节点这个方案跑起来非常快。3.3 连通分量与岛屿问题一个DFS标记一个“岛”网格上的岛屿问题本质就是四连通分量的计数。给定一个二维网格1是陆地0是水要求数出有多少个连通岛屿。用DFS解决这个问题思路极其干净遇到一个陆地就把这个岛屿的所有陆地都遍历并标记成0沉岛每触发一次“沉岛”就计数加一。def num_islands(grid): if not grid: return 0 rows, cols len(grid), len(grid[0]) directions [(1, 0), (-1, 0), (0, 1), (0, -1)] def dfs(r, c): if r 0 or r rows or c 0 or c cols or grid[r][c] 0: return grid[r][c] 0 # 沉岛防止重复访问 for dr, dc in directions: dfs(r dr, c dc) count 0 for r in range(rows): for c in range(cols): if grid[r][c] 1: count 1 dfs(r, c) return count我特别想讲一下“沉岛法”的精妙之处它用“原地修改网格”代替外部visited矩阵省掉了额外O(rows*cols)的内存。这在面试题里很常见但在实际工程里也提醒我一点——如果输入数据不允许修改那就必须用一个同规模的布尔矩阵记录访问状态。另外方向数组的写法比写四个if判断更清晰而且扩展八方向时只需要加四个元素可维护性好很多。3.4 回溯法DFS在状态空间搜索的妙用DFS不止能遍历一张显式的图还能遍历一个“隐式状态图”。回溯法本质上就是在状态空间里做DFS并在分支不符合条件时提前剪枝。以全排列为例每一层递归负责确定排列中的一个位置选择某个候选数字后递归下一层等递归返回后撤销这个选择回溯。没有撤销这一步状态就会互相污染产生错误结果。def permute(nums): result [] path [] used [False] * len(nums) def dfs(): if len(path) len(nums): result.append(path[:]) return for i, n in enumerate(nums): if used[i]: continue used[i] True path.append(n) dfs() path.pop() # 关键的回溯 used[i] False dfs() return result这里最容易被忽略的是path[:]复制因为如果不复制result里存的都是同一个列表引用等回溯到头时全部变成空列表。当初我自己就栽在这个上面排查了半天。DFS回溯的黄金法则是递归前做什么修改递归后一定要原样撤销保持递归入口和出口时状态一致这是保证正确性的前提。4. 我在实战里踩过的那些DFS坑4.1 死循环visited标记时机错了死循环的根源几乎都是同一个该标记的节点没在最开始标记导致同一个节点被反复进入。尤其是在迭代写法里如果你在pop出栈时才标记visited那同一个节点可能被多个邻居同时压入栈造成大量冗余访问甚至无限循环。我的检查经验是递归写法里visited.add必须在遍历邻居之前迭代写法里visited.add应该在压栈时发生而不是弹出时。你只要在代码里画一条“这个节点什么时候第一次被看见”的时间线问题马上就能暴露。4.2 栈溢出递归深度限制与迭代化改造Python默认的递归深度是1000层。如果你处理的图是一条长链比如5000个节点顺序相连递归DFS必然报RecursionError。遇到这种情况有三种解法调大递归限制sys.setrecursionlimit(10000)治标不治本深度继续增大会撑爆C栈甚至导致进程段错误。改迭代用上一节讲的显式索引栈彻底规避递归深度问题。换思路如果图结构特殊比如是树可以尝试层序BFS不过那就不是DFS了需要看场景。我自己的习惯是先估算图中最坏可能出现的递归深度。如果是网格类问题深度最多是rowscols量级通常可控如果是长链式的图我一开始就写迭代版避免上线后踩雷。4.3 有向图和无向图的“边”差在哪无向图的邻接表里边要存储两次u的邻居有vv的邻居也有u。这意味着DFS时从u访问v之后v的下一个邻居里会出现u如果不判断就直接返回就会出现“来回弹跳”的问题。好在这个可以直接通过visited解决。但有向图就麻烦一点它天然不对称你的遍历逻辑必须依赖边的方向性。举例来说无向图的连通分量只需要跑一次DFS就能标记整个分量有向图则需要在“正向图”和“反向图”上分别处理比如强连通分量就需要Kosaraju算法或Tarjan算法单纯DFS是解决不了的。很多初学者拿无向图的DFS模板直接跑有向图结果连通性判断完全错误这个翻车案例我在code review里见过太多次。4.4 性能调优剪枝、记忆化和迭代加深DFS在状态空间搜索时性能瓶颈往往是“分支因子过大”也就是每个节点可选的方向太多。最有效的优化手段有三个剪枝在递归入口就判断当前分支有没有前景没有就立刻返回。比如数独、N皇后问题剪枝能砍掉90%以上无用分支。记忆化如果DFS过程中存在大量重复子状态可以把每个状态的计算结果缓存起来。典型的例子是“滑雪问题”——每个位置向四个方向走深度搜索后把每个点的最长滑行距离存起来后续再访问直接返回这其实就是用DFS实现动态规划复杂度从指数级降到O(V)。迭代加深在深度搜索前先限制一个最大深度逐层放宽。适用于“解一定存在于较浅层、但分支极多”的问题。比如某些博弈树搜索固定深度的DFS剪枝效果不够时用迭代加深可以获得更可控的时间和空间权衡。优化手段适用场景效果剪枝N皇后、数独、括号生成减少无效分支指数级加速记忆化网格DP、树形DP、重复子问题重复状态直接返回O(V)复杂度迭代加深博弈树、IDA*搜索控深度控内存空间友好这些手段不是互相排斥的实际工程里我常常“剪枝记忆化”一起用先剪掉明显无效的分支再把剩下的有效状态缓存起来。最后分享一个我自己的习惯每次写DFS前我会先在纸上画出3到5个节点的示例图手动走一遍整个遍历过程标出每个节点第一次被访问和最终完成的时间。这个预处理只需要几分钟却能省掉后面几个小时的调试时间。DFS的代码量很小但它作为所有图算法的基础贯穿了拓扑排序、强连通分量、二分图判定、网络流等一大堆高级问题值得你把它吃透。当你遇到一个新问题不知道用什么算法时先想想“能不能用DFS走一遍在遍历过程中收集信息”很多问题的答案往往就这么被解开了。
返回列表