跳到主要内容

DFS(深度优先搜索)

一句话说清 DFS​

DFS 沿着一条路一直走,走不动了才回头,回到最近一个还有岔路的路口,换一条继续走。 它不保证第一次到达就是最短,但能在很小的空间里走遍整张图,也天然适合「枚举所有方案」和「汇总子树信息」。

        1
/ \
2 3
/ \ \
4 5 6

DFS 访问顺序:1 → 2 → 4 → (回退)→ 5 → (回退)→ 3 → 6

两个实现:递归与显式栈​

实现优点缺点适用
递归代码短,前/中/后序位置清晰受调用栈深度限制树、网格、回溯
显式栈不受递归限制,可控制入栈顺序中序/后序写起来绕超深图、需要手动管理状态

递归的本质就是用系统调用栈保存「回退点」。把递归改写成显式栈,就是把系统栈换成一个列表, 逻辑并没有变。

递归模板​

def dfs(node):
if not node: # 1. 终止条件
return

# 前序位置:刚进入 node,此时还没处理子树
dfs(node.left) # 2. 处理左子树
# 中序位置:左子树处理完,右子树还没开始
dfs(node.right) # 3. 处理右子树
# 后序位置:左右子树都处理完,可以汇总信息

显式栈模板(前序)​

def preorder(root):
if not root:
return []

ans = []
stack = [root]

while stack:
node = stack.pop()
ans.append(node.val)

# 栈是后进先出,所以先压右子树,保证左子树先被处理
if node.right:
stack.append(node.right)
if node.left:
stack.append(node.left)

return ans

前序、中序、后序:到底该把代码放在哪​

同一个递归框架里有三个「位置」,它们决定了你能拿到什么信息:

位置时机适合做的事
前序刚进入节点向下传递信息:路径、当前和、深度、父节点状态
中序左子树处理完二叉搜索树的有序输出、验证 BST
后序左右子树都处理完向上汇总信息:高度、节点数、子树是否合法、删节点

一句话记忆:信息往下走用前序,信息往上走用后序。

以「求最大深度」为例,深度是子树的结果,必须等左右子树都算完才能合并,所以用后序:

def max_depth(root):
if not root:
return 0
left = max_depth(root.left)
right = max_depth(root.right)
return max(left, right) + 1 # 后序:先有子结果,再合并

反过来,「路径总和」把剩余目标值往下传,用前序更自然:

def has_path_sum(root, target):
if not root:
return False
if not root.left and not root.right: # 叶子
return root.val == target
return (has_path_sum(root.left, target - root.val)
or has_path_sum(root.right, target - root.val))

什么时候该想到 DFS​

题目信号对应模型
判断连通、数连通块、Flood Fill图/网格遍历
枚举所有路径、所有组合、所有方案DFS + 回溯
需要子树的高度、节点数、是否平衡树的后序遍历
判断有向图是否有环三色标记 DFS
需要记录「从根到当前节点」的路径前序 + 回溯
记忆化搜索(递归 + 缓存)DFS + memo

不适合 DFS 的情况:

  • 无权图最短路 / 最少步数:DFS 第一次到达不保证最短,用 BFS。
  • 只关心「最近的那一个」:BFS 可以提前停,DFS 往往要把整片走完。
  • 图非常深、递归层数会超过 Python 限制:改成显式栈或 BFS。
  • 需要按层聚合结果:层序天然属于 BFS。

图的 DFS​

图与树的区别是可能有环,所以必须记录访问状态,否则会无限递归。

def dfs_graph(graph, start):
visited = set()
order = []

def dfs(u):
if u in visited:
return
visited.add(u) # 进入时标记,保证每个点只处理一次
order.append(u)
for v in graph[u]:
dfs(v)

dfs(start)
return order

如果图不连通,需要对每个未访问的点调用一次 dfs:

for u in range(n):
if u not in visited:
dfs(u)

三色标记判环​

只用一个 visited 集合,能判断「走没走过」,但判断不了「当前路径上有没有环」。 有向图判环需要三种状态:

  • 0:没访问过;
  • 1:正在当前 DFS 路径上(访问中);
  • 2:已经彻底处理完。

如果 DFS 时遇到状态 1 的邻居,说明沿着当前路径又走回了它 —— 有环。

def can_finish(num_courses, prerequisites):
graph = [[] for _ in range(num_courses)]
for course, pre in prerequisites:
graph[pre].append(course) # pre -> course

state = [0] * num_courses # 0 未访问 / 1 访问中 / 2 已完成

def dfs(u):
if state[u] == 1:
return False # 回到当前路径上的点:有环
if state[u] == 2:
return True # 这个点已经确认安全

state[u] = 1
for v in graph[u]:
if not dfs(v):
return False
state[u] = 2 # 所有后代都处理完,标记为安全
return True

return all(dfs(u) for u in range(num_courses))

同一道 207. 课程表 也可以用 BFS 的 Kahn 算法做。 两种写法都对:Kahn 更直观,三色 DFS 更容易扩展成「输出环上的点」。

网格的 DFS(Flood Fill)​

网格可以看成一个每个格子最多有 4 个邻居的图。DFS 的写法非常短,代价是递归深度可能达到 m × n。

def num_islands(grid):
if not grid or not grid[0]:
return 0

rows, cols = len(grid), len(grid[0])

def dfs(r, c):
# 越界、不是陆地、已经访问过,都直接返回
if not (0 <= r < rows and 0 <= c < cols):
return
if 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)

count = 0
for r in range(rows):
for c in range(cols):
if grid[r][c] == '1':
count += 1
dfs(r, c)

return count

200. 岛屿数量 用 DFS 或 BFS 都可以。 区别只在:DFS 代码短、占递归栈;BFS 代码长一点、占队列,但不会被递归深度卡住。

回溯:DFS 的一个特殊分支​

普通 DFS 每个节点只访问一次;回溯则是在 DFS 的基础上,进入下一层前「做选择」, 返回后「撤销选择」,从而枚举所有路径。

def subsets(nums):
ans = []
path = []

def dfs(i):
if i == len(nums):
ans.append(path.copy()) # 必须拷贝,否则后续修改会污染答案
return

dfs(i + 1) # 不选 nums[i]

path.append(nums[i]) # 选 nums[i]
dfs(i + 1)
path.pop() # 撤销选择

dfs(0)
return ans

「选 / 不选」的决策树有 2^n 个叶子,所以子集问题的复杂度是 O(2^n)。 回溯的模板、去重和剪枝细节见 回溯模式。

例题精讲​

例 1:二叉树的最大深度(LeetCode 104)​

104. 二叉树的最大深度

def max_depth(root):
if not root:
return 0
return max(max_depth(root.left), max_depth(root.right)) + 1

推演 [3, 9, 20, null, null, 15, 7]:

节点左子树深度右子树深度返回
9001
15001
7001
201(15)1(7)2
31(9)2(20)3

答案 3。注意递归是先一路走到叶子,再在返回的路上逐层加一 —— 这就是后序。

例 2:二叉树的直径(LeetCode 543)​

543. 二叉树的直径

直径 = 任意两个节点之间最长路径的边数。它不一定经过根,所以不能只算「左高 + 右高」。 正确做法是:对每个节点,算出「以它为最高点的路径长度」,取全局最大值。

def diameter_of_binary_tree(root):
ans = 0

def depth(node):
nonlocal ans
if not node:
return 0

left = depth(node.left)
right = depth(node.right)

# 后序位置:左右深度都已经知道,可以更新答案
ans = max(ans, left + right)
return max(left, right) + 1 # 返回给父节点的是「单边最大深度」

depth(root)
return ans

这里有两个不同的量:

  • 返回值是「以当前节点为端点、向下走的最长边数」,父节点只能接这一条;
  • 答案是「以当前节点为拐点的左右两条边之和」,它不需要返回给父节点。

把这两个量分清楚,是树形 DFS 最关键的一步。

例 3:二叉树的最近公共祖先(LeetCode 236)​

236. 二叉树的最近公共祖先

后序的经典应用。对每个节点,返回它的子树里找到了什么:

  • 左右都找到目标 → 当前节点就是最近公共祖先;
  • 只在一边找到 → 把那个结果继续往上传递;
  • 都没找到 → 返回 None。
def lowest_common_ancestor(root, p, q):
if not root or root is p or root is q:
return root

left = lowest_common_ancestor(root.left, p, q)
right = lowest_common_ancestor(root.right, p, q)

if left and right:
return root # p、q 分居两侧,当前节点就是 LCA
return left or right # 都在同一侧,继续向上传

例 4:岛屿数量(LeetCode 200)​

200. 岛屿数量

前面已经给出完整代码。这里补充一个易错点:DFS 的终止条件必须同时覆盖「越界」和「不是陆地」。 只判断 grid[r][c] != '1' 时,越界会先发生,Python 会报 IndexError;只判断越界时, 又会把水当成陆地继续递归。

例 5:单词搜索(LeetCode 79)​

79. 单词搜索

在网格里找一条路径,使字符依次拼成 word。每个格子只能使用一次,所以需要「访问标记 + 恢复现场」:

def exist(board, word):
rows, cols = len(board), len(board[0])

def dfs(r, c, i):
if i == len(word):
return True
if not (0 <= r < rows and 0 <= c < cols):
return False
if board[r][c] != word[i]:
return False

saved = board[r][c]
board[r][c] = '#' # 标记为当前路径已使用
found = (dfs(r + 1, c, i + 1) or dfs(r - 1, c, i + 1)
or dfs(r, c + 1, i + 1) or dfs(r, c - 1, i + 1))
board[r][c] = saved # 回溯:恢复现场

return found

return any(dfs(r, c, 0) for r in range(rows) for c in range(cols))

这类题和普通 Flood Fill 的区别是:访问标记只对当前路径有效。 如果像岛屿数量那样把格子永久改成 '0',其他分支就再也用不了这个格子了。

常见变体​

1. 多连通块遍历​

图不连通时,外层套一个循环,对每个未访问的点启动一次 DFS。连通块个数就是启动次数。

2. 记录 DFS 序(时间戳)​

在进入和离开节点时各记一个时间戳,可以把「子树」映射成一段连续区间, 用于「子树查询」「祖先判断」等问题。树链剖分、欧拉序都建立在这个思想上。

3. 记忆化搜索​

DFS 的返回值只依赖参数时,可以把结果缓存起来,避免重复计算:

from functools import cache

@cache
def dfs(i, j):
...

这就是自顶向下的动态规划,详见 动态规划。

4. 迭代加深​

当答案深度未知、且 BFS 空间太大时,限制 DFS 的最大深度,逐次放宽。 它在每一轮都重复搜索浅层,但空间仍是 O(深度)。常见于「最少步数但状态空间巨大」的题。

复杂度分析​

场景时间复杂度空间复杂度说明
二叉树遍历O(n)O(h)h 是树高,最坏 O(n)
图的 DFSO(V + E)O(V)visited + 递归栈
网格 Flood FillO(m × n)O(m × n)最坏递归深度为格子数
三色判环O(V + E)O(V)每个点、每条边各访问一次
子集枚举O(2^n × n)O(n)2^n 个叶子,每个答案拷贝 O(n)
全排列O(n! × n)O(n)n! 个答案,每个长度 n

易错点​

  • ❌ 忘记 visited:图有环时无限递归,直接栈溢出。
  • ❌ visited 时机不对:普通遍历进入时标记没问题;判环必须区分「访问中」和「已完成」。
  • ❌ 回溯忘记恢复现场:修改了 board / path 却没有还原,后面的分支会拿到脏状态。
  • ❌ 答案没有拷贝:ans.append(path) 存的是引用,最后所有答案都会变成同一个列表。
  • ❌ 递归深度超限:Python 默认约 1000 层。网格 DFS 在 1000 × 1000 的图上会直接崩,改用 BFS 或显式栈。
  • ❌ 只从一个点开始 DFS:图/网格可能不连通,要外层遍历所有起点。
  • ❌ 树题漏掉空节点判断:if not root: return ... 是所有树递归的第一行。
  • ❌ 在递归中修改全局状态却不恢复:尤其是 nonlocal 变量和闭包里的列表。

相关题目​

树:

图与网格:

回溯:

相关模式​

算法可视化​

下面有两个播放器。

树的 DFS 遍历:输入格式是 层序数组; 前序/中序/后序(例如 1,2,3,4,5,null,7; 前序)。 同一棵树,把遍历顺序切换成前序 / 中序 / 后序,观察访问序列的差别。 三种遍历的代码只差「记录当前节点」这一句的位置:前序在进入时记录,中序在左子树返回后记录, 后序在右子树返回后记录。中序在二叉搜索树上会得到有序序列,这不是巧合,而是 BST 的定义决定的。

回溯:子集:输入是逗号分隔的数字(例如 1,2,3)。跟着决策树走一遍「选 → 递归 → 撤销」。重点看 path 在每次返回后如何退回上一层。 如果撤销那一步被漏掉,后续分支会把别的分支的数字带进来,画面上会立刻出现重复或错误的答案。

算法可视化 · 树的 DFS 遍历(前序 / 中序 / 后序)可改输入后重跑
算法可视化 · 回溯:子集可改输入后重跑

下面这些题解的 tags 命中本模式,共 23 道, 按难度排列,点进去可以直接做。

加载评论中...