关键词:深度优先搜索(DFS)、递归、栈、图遍历、路径查找、迷宫寻路、算法实战
摘要:深度优先搜索(DFS)是计算机科学中最经典的算法之一,被广泛应用于路径查找、游戏AI、社交网络分析等场景。本文将用“迷宫探险”的故事串联核心概念,结合生活案例、代码实战和LeetCode经典题,带您从0到1掌握DFS的底层逻辑与实战技巧。即使你是算法新手,也能通过通俗易懂的讲解,真正理解“不撞南墙不回头”的搜索哲学。
深度优先搜索(DFS)是图论与树结构的核心遍历算法,也是解决路径查找、连通性分析、排列组合等问题的“万能钥匙”。本文将覆盖:
本文将按“故事引入→核心概念→原理拆解→代码实战→场景落地”的逻辑展开,用“迷宫探险”贯穿始终,配合Python代码、Mermaid流程图和LeetCode案例,确保“学完就能用”。
周末,小明和妹妹去游乐园玩“魔法迷宫”。迷宫由很多房间(节点)和走廊(边)组成,有些房间藏着宝藏(目标节点)。妹妹问:“怎么才能最快找到宝藏?”小明想了想说:“我们可以用‘不撞南墙不回头’的策略——每次选一条没走过的走廊,一直走到不能再走(死胡同或找到宝藏),然后退回来,再选另一条没试过的走廊。”这就是深度优先搜索(DFS)的核心思想!
想象你在玩“挖地道”游戏:你有一把铲子,每次挖通一条新地道后,先把这条地道挖到底(比如挖到一个宝箱或碰到岩石),再回到挖地道的起点,尝试挖另一条没挖过的地道。这种“先深入、再回溯”的策略,就是DFS。
递归就像“套娃”。假设你有一个套娃盒子,里面套着更小的盒子,每个小盒子里可能还有更小的盒子。要找到最里面的小娃娃(终止条件),你需要不断打开当前盒子(调用自身),直到无法再打开(到达最内层),然后逐层把盒子合上(返回上一层)。DFS的“深入-回溯”过程,天然适合用递归来实现。
栈是一个“只有顶部开口”的魔法口袋:你只能从顶部放东西(压栈),也只能从顶部取东西(弹栈)。DFS的“深入-回溯”过程,可以用栈来模拟:每次选择一条新路径时,把当前位置压入栈;走到死胡同时,弹出栈顶回到上一层,继续尝试其他路径。
DFS的核心流程可以总结为:
1. 选择当前节点的一个未访问邻居
2. 标记该邻居为已访问
3. 递归(或压栈)访问该邻居
4. 若当前节点无未访问邻居,回溯(递归返回或弹栈)
DFS的实现有两种方式:递归(隐式栈)和显式栈(手动维护栈结构)。我们以“二叉树的前序遍历”和“图的遍历”为例,讲解底层逻辑。
二叉树的前序遍历顺序是:根节点→左子树→右子树。用递归实现DFS时,函数会先处理当前节点,再递归处理左子树,最后递归处理右子树。
Python代码示例:
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def dfs_preorder(root):
result = []
# 递归函数:处理当前节点,再递归左右子树
def helper(node):
if not node: # 终止条件:节点为空(类似套娃的最内层)
return
result.append(node.val) # 访问当前节点(根)
helper(node.left) # 递归访问左子树(深入)
helper(node.right) # 递归访问右子树(深入)
helper(root)
return result
步骤拆解:
helper
,参数是当前节点node
。node
为空(走到叶子节点的子节点),直接返回。result
)。图可能存在环(如A→B→A),因此需要用visited
集合记录已访问节点,避免死循环。显式栈的实现需要手动维护路径。
Python代码示例:
def dfs_graph(graph, start):
visited = set() # 记录已访问节点
stack = [start] # 初始化栈,压入起点
visited.add(start)
result = []
while stack:
current = stack.pop() # 弹出栈顶(后进先出)
result.append(current)
# 遍历当前节点的所有邻居(注意:栈是后进先出,所以逆序压栈保证顺序)
for neighbor in reversed(graph[current]):
if neighbor not in visited:
visited.add(neighbor)
stack.append(neighbor) # 压入邻居,下次循环会优先访问它
return result
步骤拆解:
DFS的时间复杂度为 O ( V + E ) O(V + E) O(V+E),其中:
原理: 每个顶点被访问一次( O ( V ) O(V) O(V)),每条边被访问一次(因为每条边连接两个顶点,遍历邻居时会处理一次)。
举例: 一个包含5个节点、7条边的图,DFS的时间复杂度是 O ( 5 + 7 ) = O ( 12 ) O(5+7)=O(12) O(5+7)=O(12)。
空间复杂度取决于递归深度或栈的最大大小,最坏情况下为 O ( V ) O(V) O(V)(如链状树,递归深度等于节点数)。
举例: 一个高度为 h h h的二叉树,递归实现的DFS空间复杂度是 O ( h ) O(h) O(h)(调用栈的最大深度)。
算法 | 时间复杂度 | 空间复杂度(最坏) | 核心差异 |
---|---|---|---|
DFS | O ( V + E ) O(V+E) O(V+E) | O ( V ) O(V) O(V)(递归深度) | 优先深入,用栈/递归 |
BFS | O ( V + E ) O(V+E) O(V+E) | O ( V ) O(V) O(V)(队列大小) | 优先扩展同层,用队列 |
问题描述: 给你二叉树的根节点root
,返回它的前序遍历结果。
思路分析: 前序遍历顺序是根→左→右,用DFS递归或显式栈均可实现。
递归实现代码:
# 同前文的dfs_preorder函数,这里直接调用
root = TreeNode(1, None, TreeNode(2, TreeNode(3)))
print(dfs_preorder(root)) # 输出:[1, 2, 3]
显式栈实现代码:
def preorder_stack(root):
if not root:
return []
stack = [root]
result = []
while stack:
node = stack.pop()
result.append(node.val)
# 注意:先压右子树,再压左子树(因为栈是后进先出)
if node.right:
stack.append(node.right)
if node.left:
stack.append(node.left)
return result
print(preorder_stack(root)) # 输出:[1, 2, 3]
代码解读: 显式栈需要手动控制压栈顺序。因为栈是“后进先出”,所以要先压右子树,再压左子树,这样弹出时左子树会先被处理(保证根→左→右的顺序)。
问题描述: 给定一个由'1'
(陆地)和'0'
(水域)组成的二维网格,计算岛屿的数量。岛屿被水域包围,由相邻的陆地连接(上下左右相邻)。
思路分析: 将网格视为图,每个'1'
是节点,相邻的'1'
是边。遍历每个网格,遇到'1'
时用DFS标记所有相连的'1'
为'0'
(避免重复计算),岛屿数加1。
Python代码实现:
def num_islands(grid):
if not grid:
return 0
rows, cols = len(grid), len(grid[0])
count = 0
def dfs(i, j):
# 边界条件:越界或当前是水域/已访问过
if i < 0 or i >= rows or j < 0 or j >= cols or grid[i][j] != '1':
return
grid[i][j] = '0' # 标记为已访问(避免重复计算)
# 递归访问上下左右四个方向
dfs(i+1, j)
dfs(i-1, j)
dfs(i, j+1)
dfs(i, j-1)
for i in range(rows):
for j in range(cols):
if grid[i][j] == '1': # 发现新岛屿的起点
dfs(i, j)
count += 1
return count
代码解读:
dfs(i, j)
函数的作用是标记所有与(i,j)
相连的陆地为'0'
(相当于“淹没”该岛屿)。'1'
时,说明发现了新岛屿,调用DFS淹没它,并计数加1。问题描述: 给定一个不含重复数字的数组nums
,返回其所有可能的全排列。
思路分析: 全排列问题可以用DFS回溯解决:每次选择一个未使用的数字,加入当前路径,递归生成剩余数字的排列,然后回溯(撤销选择)。
Python代码实现:
def permute(nums):
result = []
n = len(nums)
used = [False] * n # 标记数字是否已使用
def dfs(path):
if len(path) == n: # 终止条件:路径长度等于数组长度
result.append(path.copy())
return
for i in range(n):
if not used[i]:
used[i] = True # 选择第i个数字
path.append(nums[i])
dfs(path) # 递归生成剩余数字的排列
path.pop() # 回溯:撤销选择
used[i] = False
dfs([])
return result
print(permute([1,2,3])) # 输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
代码解读:
used
数组记录数字是否被选中,避免重复使用。path
记录当前已选的数字序列,当path
长度等于n
时,说明生成了一个完整排列。path.pop()
和used[i]=False
回溯,尝试其他选择。在《塞尔达传说》《我的世界》等游戏中,角色探索未知地图时,DFS可以模拟“先探索当前区域的所有角落,再返回分叉点”的逻辑,确保不遗漏任何隐藏的宝箱或任务点。
分析用户的“二度好友”(朋友的朋友)时,DFS可以沿着一个用户的关系链深入,找到所有间接关联的用户(需注意限制递归深度,避免无限循环)。
编译器在解析代码时,需要检查代码结构是否符合语法规则(如括号匹配、函数调用嵌套)。DFS可以递归遍历抽象语法树(AST),确保每个语法节点的子节点符合规则。
在生物信息学中,DFS可用于分析基因序列的突变路径:从原始序列出发,深入探索所有可能的突变分支,找到与目标序列匹配的路径。
DFS在博弈树搜索(如国际象棋、围棋)中已有应用(如AlphaGo的蒙特卡洛树搜索)。未来,DFS可能与强化学习结合,动态调整搜索策略,提高效率。
传统DFS是串行的,未来可通过多线程或分布式计算,将不同分支的搜索分配到多个节点,加速大规模图(如社交网络、交通网络)的遍历。
递归实现DFS时,若递归深度过大(如10万层),会导致栈溢出。解决方案:改用显式栈(手动管理内存),或限制递归深度(如迭代加深DFS)。
在复杂图中,DFS可能因环导致无限循环。需优化访问标记策略(如记录路径而非仅节点),或通过剪枝(提前终止无效分支)减少计算量。
path.pop()
)尝试所有可能路径。Q1:DFS和BFS的选择场景?
A:找最短路径用BFS(如迷宫最短路径),找所有路径或深层目标用DFS(如全排列、基因序列突变)。
Q2:递归DFS为什么会栈溢出?如何避免?
A:递归的调用栈由系统维护,默认大小有限(如Python默认递归深度约1000)。若递归深度超过限制,会报RecursionError
。解决方案:改用显式栈,或调整递归深度(如sys.setrecursionlimit(100000)
,但需谨慎)。
Q3:图的DFS中,访问标记为什么用集合而不是数组?
A:集合的in
操作时间复杂度是 O ( 1 ) O(1) O(1),数组的in
操作是 O ( n ) O(n) O(n)。若节点编号不连续(如字符串、对象),集合更灵活。