深度优先搜索
深度优先搜索(Depth-First Search,DFS)是图与隐式状态空间遍历的最自然范式。想象你站在迷宫入口,眼前的岔路通向未知。最直觉的走法是:右手扶墙,遇岔路先挑一条往下走,走到死胡同就退回上一个岔口换路–这套「一条路走到黑、走不通就回退」的策略,正是 DFS 的精神。它和人类走迷宫的行为高度一致,也因此成为最容易「想得到」的搜索算法。
DFS 的适用面远不止迷宫。判断地图里有几个连通区域、有向图是否存在循环依赖、表达式树如何求值、数独如何被搜出解、网格里有几座岛屿–这些问题底层都共享同一种「深入-回退」的搜索骨架。它既是独立的搜索范式,又是回溯、拓扑排序、Tarjan 割点/桥/强连通分量、迭代加深等一大类算法的「底层引擎」。本文从迷宫直觉出发,讲透递归调用栈为何天然提供回退机制,再逐步展开树的前中后序、图的连通分量与环检测、拓扑排序、Tarjan 算法、网格 flood fill、剪枝与迭代加深,并在末尾点明它与回溯、BFS、DP 的分工边界。
核心思想与正确性直觉
DFS 的核心可以用一句话概括:沿一条未访问的边深入到底,无路可走时回退到最近的有未访问邻居的祖先,直到所有可达点都被访问。这里的「回退到最近的祖先」是关键–它不是随机跳转,而是严格按进入的逆序退回。
递归调用栈就是回退栈
为什么 DFS 的代码如此简洁?因为系统调用栈天然提供了回退机制。每次 dfs(u) 调用时,u 被压入调用栈;当 u 的所有邻居都处理完毕、函数返回时,u 自动弹出,控制权回到调用者(父节点)。这意味着我们不需要显式维护「从哪里来」的父指针–栈结构本身记住了来路。
1 | |
树的前序遍历(先访问根,再递归左右子树)就是无环图上的 DFS。DFS 是前序遍历在一般图上的推广:图可能有环,所以多了一个 visited 数组防重复进入。树形 DP、表达式求值等「树上 DFS」问题都是前序/后序遍历的自然应用。
正确性:为何能遍历到所有可达点
DFS 的完备性可以用反证法说明。设起点为 ,假设存在某个从 可达的节点 未被 DFS 访问。由于 可达, 到 存在一条路径 。沿这条路径看, 被访问了(DFS 的起点), 未被访问,因此路径上必存在某条边 其中 已访问而 未访问。但 DFS 在处理 时会枚举其所有邻居,遇到未访问的 必然递归进入–矛盾。故所有从 可达的点必被访问。这一完备性与 BFS 相同,差异只在访问顺序与空间特征。
「深入到底再回退」的触发时机也很明确:当前节点无任何未访问邻居时返回(弹栈回到父节点)。这一条件保证了 DFS 不会在还有未探索分支时提前回头。
DFS 树与四类边
DFS 走过的「树边」(首次访问某节点时经过的边)构成原图的一棵生成树(或生成森林,若图不连通)。在这棵 DFS 树的视角下,原图的边可分为四类:
- 树边(tree edge):DFS 树的边,指向首次访问的节点。
- 回边(back edge):指向 DFS 树中某个祖先节点的边(非树边)。回边意味着环。
- 前向边(forward edge):指向 DFS 树中某个后代节点的边(非树边)。仅在有向图中出现。
- 横叉边(cross edge):既非祖先也非后代的边。仅在有向图中出现。
无向图只有树边和回边两类–这是无向图环检测比有向图简单的原因。四类边的区分是环检测、拓扑排序、Tarjan 算法的基础。
下面用一张 6 节点无向图展示 DFS 树与回边。每点标注 [enter, leave] 时间戳(进入与离开的顺序号):
1 | |
DFS 树是一条链 1->2->3->6->5->4,两条回边各构成一个环。时间戳 enter/leave 是首次访问与处理完子树返回的顺序;子树中所有节点的 [enter, leave] 区间都包含在根节点的区间内,这一性质是 Tarjan 算法与子树查询的基础。
两种实现:递归与显式栈迭代
DFS 有两种等价的实现方式:递归与显式栈迭代。递归版借用系统调用栈,代码最贴近思维;迭代版自己维护一个栈,能规避栈溢出并精确控制访问顺序。
递归版
1 | |
代码极简:标记当前节点、遍历邻居、递归。前序动作(进入节点时做)与后序动作(离开节点时做)是两个天然的「可插拔点」–后续所有应用都在这两处填内容。主函数 for i in 0..n: if !vis[i] then dfs(i) 覆盖多连通块,下一节模板会给出完整写法。
显式栈迭代版
迭代版用一个显式栈模拟递归栈。核心循环:弹栈、若未访问则标记、压入所有未访问邻居。
1 | |
等价性与标记时机
两种实现等价,但有一个常被忽视的差异:标记时机。
- 入栈即标记(上方写法):节点压入栈时立刻标记
visited,后续不会被重复压入。空间最优,最推荐。代价是为复现递归访问顺序,邻居需逆序压栈(后访问的先压入,弹栈时才先出来)。 - 弹栈才标记:节点弹出时才标记。同一节点可能被多个前驱先后压入,导致栈膨胀,极端情况下(如完全图)栈中同时存在 个重复节点。
何时必须用迭代
三种场景下递归版会出问题:
- 递归深度过大:链状图 时,递归深度等于链长,远超默认线程栈(Linux 默认 8MB,每帧几十到几百字节,约能支撑 级别)。需改迭代或手动开大栈。
- 需精确控制遍历顺序:如求字典序最小的路径,迭代版可以精确调整压栈顺序。
- 运行环境限制递归深度:某些在线判题平台或嵌入式环境对栈深度有硬限制。
迭代 DFS 的「前序」容易写(弹栈时处理即可),但「后序」较难–需要额外标记「子节点是否已处理完毕」,或用双栈/颜色标记法。这也是为什么拓扑排序、Tarjan 等依赖后序的算法通常仍用递归实现(在栈深度允许的前提下)。
通用 DFS 模板
把两种最常见的形态–图(邻接表)与网格(方向数组)–提取成模板,后续所有应用都在此基础上填内容。
图 DFS 模板
1 | |
主循环 for i in 0..n: if !vis[i] then dfs(i) 是多连通块的关键–漏掉它只会遍历起点所在的连通块。参数传递上,adj 与 vis 用引用避免拷贝;坐标、计数器按需传值或传引用。
网格 DFS 模板
1 | |
网格是「伪装成矩阵的图」:每格是一个节点,上下左右四邻接就是边。方向数组 dx/dy 是网格题的通用工具–八连通再加四对角 (-1,-1),(-1,1),(1,-1),(1,1) 即可。visited 有两种实现:通用 bool 数组,或网格题常用的原地修改(置 '#' 或异或),后者省去额外空间。
模板的四个可插拔点
所有 DFS 应用都在模板的四个位置填内容:
| 插点 | 时机 | 典型用途 |
|---|---|---|
| 前序动作 | 进入节点时 | 收集路径、更新全局答案、染色 |
| 后序动作 | 离开节点时 | 拓扑逆后序、子树大小聚合、Tarjan 涂黑 |
| 剪枝条件 | 枚举邻居时 | 越界/已访问/不匹配则跳过 |
| 答案收集 | 视问题而定 | 叶子收集(排列)或节点收集(子集) |
后文每一节,本质上都是在回答「这四个插点填什么」。
树的 DFS
树是无环图,DFS 在树上即前序、中序、后序遍历。无需 visited(树无环),只需传 parent 参数避免回头。三种序的语义差异是本节重点。
前序(根左右)
1 | |
前序适合自顶向下传递信息:父节点信息已就绪,可以在「进入子节点前」把信息传下去。典型应用有路径前缀和、路径拼字符串、树的序列化(LeetCode 297)。
中序(左根右)
1 | |
中序的标志应用是二叉搜索树(BST):BST 的中序遍历得到升序序列。由此衍生出 BST 合法性验证、第 小元素、最近公共祖先等问题。
后序(左右根)
1 | |
后序适合自底向上聚合子树信息:离开节点时,左右子树的信息已齐备,可以合并到根。这是表达式求值、子树统计、树形 DP 的底层操作。树形 DP(如「没有上司的舞会」「树的直径」)的完整讲解见 动态规划 树形 DP 节。
应用一:表达式树求值
表达式树的每个内部节点是运算符,叶子是操作数。后序求值:先递归算出左右子树的值,再按根节点的运算符合并。
1 | |
这是后序「自底向上聚合」的样板:子树的值先算好,父节点只需做一次合并。子树大小统计 size[u] = 1 + Σ size[child] 同理,是树链剖分与树形 DP 的底层操作。N 叉树的后序同理:for child in children: postorder(child) 先递归所有子节点,最后访问根。
应用二:子树大小统计
子树大小是后序聚合的最简形式:进入节点时不做事,递归完所有子节点后,把自己的 size 设为 1 + Σ size[child]。这一操作是树链剖分(重链/轻链划分依赖 size)、树形 DP(如树上背包按子树大小合并)的底层基础。树的直径也可用后序树形 DP 聚合:维护每个节点向子树方向的最长链与次长链,经过该节点的最长路径即为两者之和,全局取最大。
前序 vs 后序的本质
- 前序是「进入节点时做事」:此时父信息已就绪(自顶向下),适合传递。
- 后序是「离开节点时做事」:此时子树信息已齐备(自底向上),适合聚合。
判断用哪种序,只需问一个问题:当前节点需要的信息来自父节点还是子节点? 来自父节点用前序,来自子节点用后序。
图的 DFS 应用:连通分量与环检测
连通分量
连通分量是 DFS 最直接的应用:主循环对每个未访问点启动一次 DFS,启动次数即为连通块数。上一节的 countComponents 模板已给出完整代码。
对于动态的「合并 + 查询」连通性场景(如不断添加边并询问两点是否连通),并查集更合适;DFS 适合静态图的一次性分析。两者的分工对照见 并查集。
无向图环检测
无向图判环只需在 DFS 中传一个 parent 参数:遍历邻居 v 时,若 v 已访问且 v != parent,则发现回边,存在环。
1 | |
重边陷阱:若两点间有两条平行边,v != parent 会误判为环(实际上 v 是父节点,只是有重边)。正确做法是传「父边编号」而非父节点,遇到同一条边时跳过。这一技巧在 Tarjan 求桥时也会用到。
有向图环检测:三色标记法
有向图的环检测更微妙。无向图的 parent 判断在这里失效–有向边是单向的,「已访问」的节点可能是祖先(构成环)也可能是不相干的已处理节点(横叉边,不构成环)。三色标记法解决了这个问题:
- 白色(0):未访问。
- 灰色(1):在当前递归栈中(即 DFS 路径上的祖先)。
- 黑色(2):已完全处理(子树已遍历完毕)。
1 | |
三色标记的本质:灰色节点代表「当前 DFS 路径上的祖先」。遇到灰色节点意味着找到了一条从当前节点指向祖先的回边–这正是环的定义。返回前必须涂黑,否则后续从其他路径到达该节点时会误判为环。
三色标记过程示意:
1 | |
应用:课程表(LeetCode 207)。 门课,先修关系构成有向图。能完成所有课程等价于图无环。上方的 canFinish 即为完整解法,复杂度 。
二分图判定(染色法)
DFS 染色是二分图判定的基础范式:对每个未染色节点 DFS,染成与父节点相反的颜色,若邻居已染色且与当前同色则非二分图。这本质上是 DFS 在「前序动作」处填入「染色」的模板应用,此处不展开代码。
图的 DFS 应用:拓扑排序
拓扑排序把有向无环图(DAG)的节点排成线性序列,使每条有向边 中 排在 前面。它是编译依赖排序、任务调度的数学基础。DFS 实现拓扑排序的核心观察是:DFS 完成一个节点(涂黑)时,它依赖的节点要么已完成、要么仍在栈中–后序的逆序使「被依赖者排在前面」。
算法
在三色标记框架上加一个栈:节点涂黑时压入栈。最终栈顶到栈底即为拓扑序(逆后序)。
1 | |
为什么是「逆后序」而非「后序」。后序按完成顺序 push_back,最早完成的无依赖叶子排在最前。但拓扑序要求「指向别人的节点排前」–一个节点越晚涂黑,说明它越靠近 DFS 树的根、被依赖得越多,应排越前。reverse 把最晚涂黑的翻到最前,正是所需的。
与 Kahn 入度法对比
拓扑排序有两大实现:
| 维度 | DFS 逆后序 | Kahn 入度法 |
|---|---|---|
| 数据结构 | 递归栈 + 颜色数组 | 入度数组 + 队列 |
| 环检测 | 遇灰即环 | 队列空时仍有节点即环 |
| 字典序最小 | 不天然支持 | 用优先队列即可 |
| 代码紧凑性 | 与环检测合并,一行递归 | 需维护入度 |
Kahn 按入度做 BFS(每次取入度为 0 的节点入队),直观且易输出字典序最小拓扑序。DFS 逆后序的优势是与环检测天然合并、代码紧凑。课程表 II(LeetCode 210) 要求输出拓扑序,上方 findOrder 即为完整解法,复杂度 。应用场景还包括编译依赖顺序(make/cargo 的构建顺序)、任务调度等。
图的 DFS 应用:Tarjan 算法(割点、桥、SCC)
Tarjan 算法用 DFS 的时间戳 dfn 与 low 值,在一次 DFS 内求出无向图的割点、桥与有向图的强连通分量,是 DFS 的高阶武器。
dfn 与 low
dfn[u](Discovery Function Number):节点 被首次访问的时间戳,即 DFS 进入顺序。low[u]: 或 的子树经至多一条回边能到达的最早祖先的dfn。
low 的更新规则是 Tarjan 的核心,也是最容易出错的地方:
- 树边 :
low[u] = min(low[u], low[v])。子树能到达的最早祖先也是 能间接到达的。 - 回边 ( 在栈中/已访问且是祖先):
low[u] = min(low[u], dfn[v])。注意是dfn[v]而非low[v]。 - 横叉边/前向边(有向图):不更新。这是高频低级错误。
为什么回边用 dfn[v] 而非 low[v]?因为回边只允许「经至多一条回边」到达 本身,不应继承 经其他回边到达的更早祖先。在无向图中没有横叉边/前向边,这一区分不那么关键;但有向图中用错会导致 low 值被错误地拉低。
割点判定
割点(articulation point)是删除后会使图不连通的节点。
- 根节点:若 DFS 树根有 个树子节点,则根是割点(删根后子树间断开)。
- 非根节点 :若存在树子节点 使
low[v] >= dfn[u],则 是割点。含义是 的子树无法绕过 到达 的上方,删 后 子树与上方断开。
桥判定
桥(bridge)是删除后使图不连通的边。边 为树边且 low[v] > dfn[u](严格大于),则 是桥。> 而非 >=:若 low[v] == dfn[u],说明 子树能到达 本身(经回边),删 后 子树仍与 相连,不构成桥。
1 | |
注意这里用「父边编号」而非父节点来跳过回头–这正确处理了重边的情况(两条平行边不会被误判为环或忽略)。
下面用一张 7 节点无向图展示 dfn、low 与割点/桥判定:
1 | |
这个例子很好地展示了割点与桥的差异:节点 2 是割点,但边 2-3 不是桥–因为回边 4->2 让 3 的子树能绕过边 2-3 到达节点 2。
有向图强连通分量(SCC)
强连通分量(SCC)是有向图中任意两点互相可达的最大节点集。Tarjan 的 SCC 算法维护一个栈,保存「尚未归入任何 SCC 的灰节点」。当 dfn[u] == low[u] 时,说明 是某个 SCC 的根,弹栈直到 (含 ),弹出的节点构成一个 SCC。
1 | |
dfn[u] == low[u] 的含义: 无法通过回边到达比自己更早的祖先,因此 是其所在 SCC 中「最早发现」的节点(SCC 的根)。栈中 上方的节点都是 的子树中尚未归入其他 SCC 的节点,它们与 互相可达,构成一个 SCC。
关键区别:SCC 的回边更新条件是 inStack[v]( 在栈中),而非简单的「已访问」。因为只有栈中节点才可能是当前 SCC 的成员;已弹出(归入其他 SCC)的节点是横叉边的终点,不应更新 low。这是 SCC 与无向图 Tarjan 的核心差异。
应用:2-SAT。2-SAT 问题中每个约束形如「 或 」,可建模为有向图(变量及其否定互为后继)。缩点后按拓扑逆序赋值:若 与 在同一 SCC 则无解;否则按 SCC 拓扑序,排在后面的 SCC 先赋值为真。Tarjan SCC 天然按逆拓扑序产出 SCC 编号(先弹出的 SCC 在拓扑序中靠后),无需额外排序。
与并查集的分工。并查集做动态连通性(不断合并 + 查询),Tarjan 做一次性静态分析(割点/桥/SCC)。并查集处理无向图的连通分量,无法求割点/桥;Tarjan 能求割点/桥/SCC 但不支持动态加边。两者互补,详见 并查集。
复杂度 ,一次 DFS 完成–这是 Tarjan 算法的精妙之处。
网格 DFS:岛屿与 Flood Fill
网格是「伪装成矩阵的图」:每格一节点,四邻接为边。岛屿问题与 flood fill 是网格 DFS 的核心应用。网格最短步数问题则归 BFS,详见 BFS 专篇;本节只讲连通性、flood fill 与枚举。
岛屿数量(LeetCode 200)
遍历每个格子,遇 '1'(陆地)且未访问则 DFS 标记整个连通块,计数加一。
1 | |
思路解读。外层双重循环枚举每个格子作为 DFS 起点,与图的连通分量计数完全同构。dfs 用原地修改('1' -> '2')省去 visited 数组,是网格题的常用技巧。边界检查写在递归入口处:先判越界,再判格子内容,避免下标越界。
复杂度。,每格至多访问一次。
岛屿最大面积(LeetCode 695)
DFS 返回当前连通块大小(1 + 四方向递归之和),取所有起点的最大值。这演示了 DFS 返回值自底向上聚合的范式–后序在「离开节点时」把子树信息合并到父。
1 | |
area = 1 + Σ dfs(邻居) 是后序聚合的典型写法:先把当前格子标记并贡献 1,再递归四个方向收集子连通块的面积,返回总和。这与表达式树求值 l + r 的结构同构。岛屿周长问题同理:DFS 中统计边界贡献(相邻为水或越界则贡献 1)。
被围绕的区域(LeetCode 130)
从四条边的 'O' 出发 DFS 标记「不被围绕」的 O,剩余的 O 翻转为 X。这是边界 flood fill 范式。
1 | |
思路解读。被围绕的 O 无法到达边界,因此从边界 O 出发 flood fill 标记的 O 一定不被围绕。标记完后,未被标记的 O 就是被围绕的,翻转为 X。这种「从边界反向标记」的思路在太平洋大西洋水流等问题中也常见。
Flood Fill 总结
Flood fill(种子填充)是画图软件「油漆桶」的原理:从一点出发 DFS 把连通同色区域改成新色。原地修改技巧('1' -> '2'、'O' -> '#')省去额外 visited,是网格 DFS 的标配。注意求所有解时 DFS 后需恢复原值,求连通块标记或一解时则无需恢复。
网格 DFS:单词搜索
单词搜索(LeetCode 79)是「网格 DFS + 回溯」的典型:从每个起点出发,沿四方向匹配 word 的下一字符,同一格子不能重复使用。
1 | |
从 DFS 视角看。外层枚举每个格子作为匹配起点,dfs(x, y, k) 表示从 (x, y) 出发匹配 word[k..]。原地标记(board[x][y] = '#')避免重复使用同一格子,回溯时恢复原字符–这就是「做选择/撤销选择」。
复杂度。,其中 是单词长度。首步有 4 方向,后续每步至多 3 方向(不回头到刚来的格子,因已标记)。
优化。反向单词:统计首尾字符频率,若 word[0] 在网格出现次数多于 word.back(),则反转 word 再搜,分支因子显著降低。多词搜索(单词搜索 II)用 Trie 同步下降,网格只扫一遍。
单词搜索是回溯的样板题,「做选择/撤销选择」的逐行讲解与回溯模板的完整分析见 回溯算法 单词搜索节,本文只给 DFS 骨架与原地标记技巧,不重复其回溯细节。
DFS 与回溯的关系
回溯是 DFS 在「决策树」上的应用。两者共享同一套「深入-回退」的递归栈机制,差别在标记语义。
1 | |
统一视角。图 DFS 遍历「图的节点」,回溯遍历「决策树的节点」,底层都是「深入-回退」。但图 DFS 的 visited 是永久标记(每点访问一次,因为图的连通性是确定的);回溯的标记是临时标记(路径回退后撤销,因为决策树中同一选择层可重复进入–比如全排列里数字 1 可以出现在不同位置)。
何时叫 DFS、何时叫回溯。遍历确定结构(图/树/网格连通性、flood fill)叫 DFS;在指数级状态空间枚举方案(排列、组合、子集、N 皇后、数独)叫回溯。分界线是「状态空间是否是指数级的隐式决策树」。
排列、组合、子集、N 皇后、数独、分割回文串的完整模板与剪枝详解均在 回溯算法,本文不重复。两者共有的陷阱是栈溢出与递归深度;回溯特有的陷阱是「撤销遗漏」–做了选择却忘了在递归返回后撤销。
剪枝
剪枝是「在展开子树前判断其无望并跳过」,是 DFS/回溯从指数爆炸走向可用的关键。
可行性剪枝
当前路径已违反约束,子树无解,立即剪。网格 DFS 中的越界检查、单词搜索的字符不匹配、数独的冲突检测都属此类。这是最基础也最有效的剪枝。
最优性剪枝(界限)
求最优解时,当前累计代价已劣于已知最优则剪。常配合「乐观下界估计」:当前代价 + 剩余最小可能代价 最优,则剪。
以数字组合之和为例(求凑成目标和的最少数字数):
1 | |
if (cnt >= best) return 是最优性剪枝:当前已用数字数不小于已知最优,继续搜不可能改进。if (cand[i] > remain) break 是可行性剪枝(排序保证后续更大)。
记忆化剪枝
若状态 (u, 状态) 已被更优地访问过,则剪–这正是 DP 的雏形。当重叠子问题足够多、状态可哈希时,记忆化剪枝演化为记忆化搜索,即自顶向下的 DP。记忆化与递推的取舍见 动态规划 记忆化与递推取舍节。
启发式排序
不改变正确性,但改变分支展开顺序:先展开「更有希望」的分支,让剪枝更早生效。常见策略是 MRV(Minimum Remaining Values)–选候选最少的位置先试。数独的「候选最少格优先」即此。
剪枝不改变最坏复杂度(最坏情况下还是没得剪),但常把实际规模压几个数量级。一个通用原则:剪枝越早越好,在更靠近根的位置剪掉一棵子树,省下的是整棵子树的代价。N 皇后、数独的剪枝细节见 回溯算法 剪枝艺术节。
迭代加深 IDDFS 与 IDA*
DFS 省内存但可能在无解分支上无限深探;BFS 完备且找最短但内存 爆炸。迭代加深 DFS(IDDFS)取两者之长:用「限制深度逐步加深」的多次 DFS,取得 BFS 的完备性与 DFS 的省内存。
IDDFS
1 | |
每次 dfs 限制最大深度为 limit,从 0 逐步加深到 。重复展开上层的代价可忽略:深度 的搜索代价是 ( 为分支因子),总代价 ,与 BFS 同阶。而空间只需 (递归栈深度),与 DFS 相同。这是 IDDFS 最精妙的取舍:用「时间常数翻倍」换「空间从 降到 」。
注意 IDDFS 的 visited 是临时标记(回溯时撤销),与普通图 DFS 的永久标记不同–因为同一节点可能在不同的深度受限路径中被重复访问。
IDA*
IDA* 是 IDDFS 的启发式增强:用估价函数 代替纯深度限制。阈值从 起,每轮取上一轮所有超阈节点中的最小 值作新阈值。
1 | |
heuristic 是到终点的估计代价(如曼哈顿距离)。 时 IDA* 退化为 IDDFS; 恰为真实距离时 IDA* 沿最优路径直奔终点。可采纳性(admissible)要求 永不高估真实最短距离,这是 IDA* 给出最优解的充要条件。
适用场景。解深度大但未知、状态空间庞大(15 数码、魔方、埃及分数)、A* 的优先队列内存吃紧时。IDA* 是 A* 的「深度受限迭代加深版」,用阈值代替开放集。骑士周游(Knight Tour)也可用 IDDFS/IDA* 求解:在 棋盘上找一条经过所有格子的马步路径,解深度为 64 但分支因子不固定,Warnsdorff 启发式(优先走出口最少的格子)配合 IDDFS 是经典解法。
与双向 BFS 的对称性:双向 BFS 是空间换时间,IDA* 是时间换空间,两者都是「单向搜索不够用」时的优化,可对照 BFS 专篇 阅读。
横向对比 DFS vs BFS
一句话对照:DFS 用栈深探优先(省内存、适合找任意解/枚举/拓扑/Tarjan),BFS 用队列逐层扩展(找无权最短路/最小步数)。
- 选 DFS:连通性判断、拓扑排序、割点/桥/SCC、回溯枚举、深解空间。
- 选 BFS:无权最短路、最小步数、层序信息。
详细的对比表(数据结构、完备性、最短路径、内存、典型场景)见 BFS 专篇,本文不重复。两者共享 visited 机制与方向数组模板,差别只在容器(栈 vs 队列)与推进顺序。
实战场景:竞赛与工程
竞赛
DFS 是算法竞赛搜索与图论的基石:
- 图论:连通块计数、拓扑排序、Tarjan 割点/桥/SCC 是 Div.2 C/D 的常客。Tarjan 的
low更新细节(回边用dfn[v]而非low[v]、横叉边不更新)是高频考点。 - 搜索:回溯 + 剪枝 + IDDFS 是搜索题的三大武器。网格图 DFS 常数小但注意栈溢出(链状网格 时可手动开栈或改迭代)。
- 树形 DP 与树链剖分:底层遍历是 DFS 后序,详见 动态规划 树形 DP 节。
- 网格搜索:岛屿、flood fill、单词搜索是面试高频。
工程
DFS 在工程中是依赖分析、连通性求解、编译器遍历、垃圾回收的底层工具:
- 依赖解析:
make/cargo/webpack的依赖图拓扑排序、循环依赖检测。cargo检测 crate 循环依赖本质上就是有向图三色标记。 - 连通性:网络分区分析、集群可达性判断。
- 编译器:AST 遍历(语法分析)、控制流图(CFG)可达性分析(死代码消除的本质是 DFS 标记可达基本块)。
- 求解器:SAT/约束求解器内部的 CDCL(Conflict-Driven Clause Learning)本质是带学习的 DFS。
- 运行时:文件系统递归遍历、
git commitDAG 遍历、GC 标记-清除的「标记」阶段即图 DFS(从根集出发标记所有可达对象)。
工程教训。生产代码要警惕递归栈溢出(深链/深树场景),改迭代或显式栈;警惕指数爆炸,给搜索加深度/时间上限。一个真实案例:用户构造恶意正则触发灾难性回溯,本质是 DFS 在无剪枝的隐式树上指数级展开。
常见陷阱与边界条件
DFS 代码看似简单,但以下高频踩坑点值得逐条排查:
1. 访问标记时机。必须「首次进入节点时」立即标记,而非「处理完邻居后」。后者会让同一节点在栈中重复出现,时空退化。正确:dfs(u) 第一行就 vis[u] = true。
2. 迭代 DFS 重复入栈。弹栈才标记会让节点多次入栈(多个前驱各压一次)。入栈即标记可避免,但需注意压栈顺序要逆序以复现递归的访问顺序。
3. 方向数组写错。dx/dy 配对错位致方向错乱。常用 {0,0,1,-1} / {1,-1,0,0}(右左下上)。自检手段:打印移动后的坐标验证。
4. 无向图环检测父节点陷阱。传 parent 判 v != parent,但重边会误判(两点间两条平行边,v 是父节点但非同一条边)。正确做法传「父边编号」而非父节点,见上方 Tarjan 代码。
5. 三色标记忘涂黑。递归返回前必须 color[u] = 2,否则后续从其他路径到达该节点时误判为环(灰色 = 栈中祖先,但此时它已不在栈中)。
6. 栈溢出。递归深度 = 图最长链,链状图 爆栈。对策:竞赛用 #pragma comment(linker, "/STACK:...") 或 ulimit -s 开大栈;改迭代;或用 IDDFS 限制深度。
7. 网格 DFS 越界顺序。必须先判越界再访问 board[x][y],否则下标越界段错误。正确:if (x<0 || x>=m || y<0 || y>=n) return; 在最前面。
8. 原地修改未恢复。求所有解时必须恢复 board[x][y],否则污染后续搜索。求一解(找到即返回)可不恢复–反正不再用。这是「求一解」与「求全解」在代码上的微妙差异。
9. Tarjan low 更新错误。回边用 dfn[v] 更新(不是 low[v]);横叉边/前向边不更新(SCC 中 inStack[v]=false 的边)。低级但致命,是 Tarjan 最常见的 bug。
10. 拓扑排序逆后序写反。直接用后序(push_back 顺序)而非逆后序(reverse 后),依赖关系颠倒。记住:最晚涂黑的节点(被依赖最多)应在拓扑序最前。
11. 起点 visited 漏标。主循环调用 dfs(i) 前,dfs 内部第一行就标记 vis[i]。若在 dfs 外部标记又不在内部标记,或反过来重复标记,逻辑会乱。统一在 dfs 入口标记。
12. 多连通图主循环遗漏。必须 for i in 0..n: if !vis[i] then dfs(i),否则漏掉非起点连通块。这是连通分量计数、拓扑排序、环检测的共同前提。
小结
DFS 的三句话本质:
- 沿一条路深探到底、无路则回退到最近有未访问邻居的祖先。递归调用栈天然提供回退机制,迭代栈与之等价。
visited/ 三色标记保证每点访问一次,并支撑环检测、拓扑排序、Tarjan 等高阶应用。- 模板的四个可插拔点(前序动作、后序动作、剪枝条件、答案收集)覆盖了从连通分量到 Tarjan 的所有应用。
学习路线:通用模板 -> 树的前中后序 -> 图连通分量/环检测/拓扑 -> 网格岛屿 -> Tarjan -> 剪枝 -> IDDFS/IDA*。每一步都是在模板的某个插点填入特定内容。
与同系列的关系:回溯是 DFS 在决策树上的特化(见 回溯算法),BFS 是 DFS 的「逐层版」对照(见 BFS 专篇),记忆化 DFS 是自顶向下的 DP(见 动态规划),并查集是动态连通性的替代方案(见 并查集)。
DFS 的精神:不追求「更优复杂度」(最坏仍是 遍历或指数搜索),而追求「用最少的机制(一个栈 + 一个 visited)覆盖最多的搜索场景」。从迷宫找路到 Tarjan 求割点,从岛屿计数到 SAT 求解器的 CDCL,底层都是同一套「深入-回退」的骨架。理解了这套骨架,再面对陌生问题时,只需问一句「四个插点该填什么」,就能把 DFS 从模板变成工具。

