Chapter 05
树与图的遍历
前面四章都在线性结构上做文章——这一章进入分叉的结构。核心洞察:DFS、BFS、回溯不是三个独立技巧,而是同一种"系统化穷举"换了容器,递归用的栈和 BFS 用的队列互为镜像。
本章 schema
- DFS 与 BFS 的唯一区别是用栈还是用队列——其余逻辑完全一致。
- BFS 按距离一层层扩展,因此在无权图里第一次到达终点即最短路径。
- 回溯是决策树上的 DFS:进入分支做选择,返回时撤销选择。
visited集合防止节点被重复访问,在有环图里它是不死循环的前提。
§5.1树的遍历
前序、中序、后序的差别只有一个:DFS 在递归过程中"访问根节点"这一步发生的时机不同。
一次树的 DFS 对每个节点做三件事:访问左子树、访问右子树、访问根节点本身。三种遍历只调换"访问根"插在哪一步——根在最前是前序,夹在左右之间是中序,放在最后是后序。
中序遍历对二叉搜索树(BST)有一条特殊性质:它按从小到大的顺序输出所有节点值。原因复用 04 章的有序性——BST 的定义是"左子树全部小于根,右子树全部大于根",先访问左、再访问根、最后访问右,正好把值从小到大串起来。
下面把中序遍历写两遍:先用递归,再用一个显式的栈把递归改写成迭代。两份代码访问节点的顺序完全相同,对照它们能看清一件事——递归的本质就是一个由语言运行时替你维护的栈。
from typing import Optional
class TreeNode:
def __init__(self, val: int, left=None, right=None):
self.val = val
self.left: Optional["TreeNode"] = left
self.right: Optional["TreeNode"] = right
def inorder_recursive(root: Optional[TreeNode]) -> list[int]:
"""递归版:访问顺序 = 左 -> 根 -> 右。"""
result: list[int] = []
def dfs(node: Optional[TreeNode]) -> None:
if node is None: # 空节点是递归的终止条件
return
dfs(node.left) # 先把整棵左子树走完
result.append(node.val) # 回到根,记录根的值(中序的"中")
dfs(node.right) # 再把整棵右子树走完
dfs(root)
return result
def inorder_iterative(root: Optional[TreeNode]) -> list[int]:
"""迭代版:用一个显式栈替代递归调用栈。"""
result: list[int] = []
stack: list[TreeNode] = []
node = root
while node is not None or stack:
while node is not None: # 对应 dfs(node.left):一路向左压栈
stack.append(node)
node = node.left
node = stack.pop() # 栈顶是当前最左、尚未访问的节点
result.append(node.val) # 访问它(对应 result.append)
node = node.right # 转向右子树(对应 dfs(node.right))
return result
if __name__ == "__main__":
# 4
# / \
# 2 6
# / \ / \
# 1 3 5 7 —— 一棵 BST
root = TreeNode(4,
TreeNode(2, TreeNode(1), TreeNode(3)),
TreeNode(6, TreeNode(5), TreeNode(7)))
print(inorder_recursive(root)) # [1, 2, 3, 4, 5, 6, 7]
print(inorder_iterative(root)) # [1, 2, 3, 4, 5, 6, 7] —— 有序,证明是 BST
递归版的 dfs(node.left) 在迭代版里是 while node is not None 这个内层循环——把一路向左的节点全压进栈。递归版的 result.append 紧跟在左子树之后,迭代版里则发生在 stack.pop() 之后:栈顶弹出的就是当前最深、最左、还没访问的节点。递归版的 dfs(node.right) 对应迭代版末尾的 node = node.right,把指针交给右子树,下一轮循环再对右子树重复这套压栈逻辑。
§5.2DFS 与 BFS:栈与队列的镜像
DFS:把待访问节点放进栈,一条路走到底再回头。
BFS:把待访问节点放进队列,按距离一层一层向外扩展。
DFS 和 BFS 的骨架是同一套:取出一个节点、访问它、把它的邻居放进容器、重复,直到容器空。唯一的分歧在容器——DFS 用栈(LIFO,后进先出),刚放进去的邻居最先被取出,于是沿着一条分支一直深入,走到尽头才回头处理别的分支;BFS 用队列(FIFO,先进先出),先放进去的先取出,于是离起点近的节点先被处理,访问顺序严格按距离分层。
正因为 BFS 按层扩展,无权图中第一次到达终点的那一刻,走过的步数就是最短路径——更近的节点已经在更早的层被全部检查过,不可能存在更短的路。DFS 没有这个保证:它可能先沿一条长路绕到终点,记下的步数并非最短。
worked:岛屿数量(网格 DFS 标记连通块)
给一个由 '1'(陆地)和 '0'(水)组成的网格,求岛屿数量。一座岛是上下左右相连的一片陆地。每遇到一块没访问过的陆地,岛屿计数加一,然后用 DFS 把这块陆地所在的整个连通块全部标记为已访问,避免重复计数。
def num_islands(grid: list[list[str]]) -> int:
if not grid:
return 0
rows, cols = len(grid), len(grid[0])
def dfs(r: int, c: int) -> None:
# 越界,或是水,或已淹没 —— 直接返回(递归终止条件)
if r < 0 or r >= rows or c < 0 or c >= cols or grid[r][c] != "1":
return
grid[r][c] = "0" # 把当前陆地"淹没",等价于标记 visited
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
if __name__ == "__main__":
grid = [
["1", "1", "0", "0", "0"],
["1", "1", "0", "0", "0"],
["0", "0", "1", "0", "0"],
["0", "0", "0", "1", "1"],
]
print(num_islands(grid)) # 3
worked:网格最短路径(BFS + deque)
同样是网格,现在求从左上角到右下角的最短步数,只能走相邻的空格(值为 0),不能斜走。这里换 BFS:用 02 章的 collections.deque 当队列,按层扩展,第一次到达终点时的步数即最短。
from collections import deque
def shortest_path(grid: list[list[int]]) -> int:
"""返回从 (0,0) 到右下角的最短步数;不可达返回 -1。"""
rows, cols = len(grid), len(grid[0])
if grid[0][0] != 0 or grid[rows - 1][cols - 1] != 0:
return -1
queue: deque[tuple[int, int, int]] = deque()
queue.append((0, 0, 0)) # (行, 列, 已走步数)
visited = {(0, 0)} # visited 防止节点重复入队
while queue:
r, c, steps = queue.popleft() # FIFO:先入队的先出队,保证按层处理
if (r, c) == (rows - 1, cols - 1):
return steps # 第一次到达终点 = 最短步数
for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)):
nr, nc = r + dr, c + dc
if (0 <= nr < rows and 0 <= nc < cols
and grid[nr][nc] == 0 and (nr, nc) not in visited):
visited.add((nr, nc)) # 入队前就标记,避免同一格被多次入队
queue.append((nr, nc, steps + 1))
return -1
if __name__ == "__main__":
grid = [
[0, 0, 1, 0],
[1, 0, 1, 0],
[0, 0, 0, 0],
[0, 1, 1, 0],
]
print(shortest_path(grid)) # 6
求最短步数为什么用 BFS 而不用 DFS?先自己说出理由,再展开核对。
展开核对
BFS 按距离分层扩展:队列里同一时刻的节点到起点的步数最多差 1,离起点近的先出队。所以终点被弹出的瞬间,能到达它的最短路径已经被找到——再短的路意味着更近的层,而更近的层已经全部检查过了。
DFS 用栈深入,访问顺序与距离无关。它可能先沿一条远路绕到终点,记下的步数不是最短;要用 DFS 求最短就得枚举所有路径取最小值,复杂度退化。结论:无权图求最短,BFS 是默认选择;DFS 适合"是否存在路径"或"枚举所有路径"。
§5.3回溯
回溯就是在一棵决策树上做 DFS:进入分支前做选择,返回时撤销选择。
回溯是 DFS 用来枚举所有方案的方式。每一步面对若干"选择",每个选择对应决策树的一个分支;沿一个分支递归到底得到一个完整方案,然后撤销(pop)刚才的选择,退回上一层去试下一个分支。撤销是为了让所有分支复用同一份路径数组,省去每层都复制一份的空间开销。
朴素枚举的规模是 O(n!) 或 O(2ⁿ),靠剪枝砍到可接受——在某个选择已注定不可能成功时提前 return,不再深入。复用 01 章的复杂度信号:当 n ≤ 20 这类很小的约束出现,O(2ⁿ) 或 O(n!) 的回溯往往就是预期解法。
worked:全排列(回溯 + used 标记 + 撤销)
给一组互不相同的数,返回所有排列。核心是"选择—递归—撤销"三步:把一个还没用过的数加进当前路径(选择),递归去填下一位(递归),递归返回后把这个数从路径里弹出并标记为未用(撤销),再试下一个数。
def permute(nums: list[int]) -> list[list[int]]:
result: list[list[int]] = []
path: list[int] = [] # 当前正在构造的排列
used = [False] * len(nums) # used[i] 标记 nums[i] 是否已在 path 中
def backtrack() -> None:
if len(path) == len(nums): # 路径填满 = 一个完整排列
result.append(path[:]) # 存副本:path 后面还会被改动
return
for i in range(len(nums)):
if used[i]: # 剪枝:这个数已经用过,跳过该分支
continue
path.append(nums[i]) # 选择:把 nums[i] 放进路径
used[i] = True
backtrack() # 递归:去填下一位
path.pop() # 撤销:把 nums[i] 拿出路径
used[i] = False # 撤销:标记回未用,供别的分支复用
backtrack()
return result
if __name__ == "__main__":
print(permute([1, 2, 3]))
# [[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
三步逐行对应:path.append + used=True 是"选择",把当前分支的状态写进共享的 path;backtrack() 是"递归",深入决策树的下一层;path.pop + used=False 是"撤销",把状态恢复到进入这个分支之前,让同一份 path 能被兄弟分支复用。
忘记撤销:只 append 不 pop,path 会越填越长,串进别的分支的选择,结果全错。规则:每个"选择"动作都必须有一个配对的"撤销"动作,并且撤销要恢复所有被改动的状态(这里是 path 和 used 两处)。
漏剪枝:不写 if used[i]: continue 之类的剪枝,会重复枚举无效分支,O(n!) 直接超时;该剪的分支不剪,是回溯题超时的头号原因。
可选:子集(同一套骨架,收集点不同)
def subsets(nums: list[int]) -> list[list[int]]:
result: list[list[int]] = []
path: list[int] = []
def backtrack(start: int) -> None:
result.append(path[:]) # 每个节点都是一个子集,进门就收集
for i in range(start, len(nums)):
path.append(nums[i]) # 选择
backtrack(i + 1) # 递归:start=i+1 保证不回头取重复元素
path.pop() # 撤销
backtrack(0)
return result
if __name__ == "__main__":
print(subsets([1, 2, 3]))
# [[],[1],[1,2],[1,2,3],[1,3],[2],[2,3],[3]]
子集与全排列共用"选择—递归—撤销"骨架,区别有二:子集在每个节点都收集(全排列只在叶子收集),用 start 参数避免回头取重复元素(全排列用 used 标记,允许任意顺序)。
§5.4图的表示与 visited
图用邻接表存稀疏图、邻接矩阵存稠密图;有环图必须用 visited 防死循环。
邻接表把每个节点的邻居存成一个列表(dict[节点, list[邻居]]),空间 O(V+E),遍历某点的邻居只扫它自己那条列表——稀疏图(边远少于 V²)首选。邻接矩阵用 V×V 的二维数组,matrix[u][v] 标记 u、v 间有无边,查任意两点是否相邻是 O(1),但空间恒为 O(V²),稠密图或频繁查"两点是否相邻"时才划算。
树是无环的,DFS 不会绕回;普通图可能有环,一个节点能从多条路径到达。不记 visited,遍历会沿环无限打转。规则:有环图的 DFS/BFS,节点出队/入栈时(或入队前)就标记 visited,再不重复处理。
worked:邻接表上的 DFS 与 BFS 通用模板
from collections import deque
def graph_dfs(graph: dict[int, list[int]], start: int) -> list[int]:
"""邻接表上的 DFS,返回访问顺序。"""
order: list[int] = []
visited: set[int] = set()
def dfs(node: int) -> None:
visited.add(node) # 进入即标记,防止有环时重复访问
order.append(node)
for nxt in graph[node]: # 扫这个节点的邻居列表(邻接表 O(degree))
if nxt not in visited:
dfs(nxt)
dfs(start)
return order
def graph_bfs(graph: dict[int, list[int]], start: int) -> list[int]:
"""邻接表上的 BFS,返回访问顺序。"""
order: list[int] = []
visited = {start} # 入队前就标记,避免同一点多次入队
queue: deque[int] = deque([start])
while queue:
node = queue.popleft() # FIFO:保证按层(按距离)访问
order.append(node)
for nxt in graph[node]:
if nxt not in visited:
visited.add(nxt)
queue.append(nxt)
return order
if __name__ == "__main__":
# 0 — 1 — 3
# | |
# 2 —-+ (含环 0-1-2-0)
g = {0: [1, 2], 1: [0, 3, 2], 2: [0, 1], 3: [1]}
print(graph_dfs(g, 0)) # [0, 1, 3, 2]
print(graph_bfs(g, 0)) # [0, 1, 2, 3]
| 需求 | 选择 | 理由 |
|---|---|---|
| 无权图最短路径 / 最少步数 | BFS | 按层扩展,第一次到达即最短。 |
| 是否存在一条路径 / 连通性 | DFS 或 BFS 均可 | 都能遍历整个连通块;DFS 递归写法更短。 |
| 枚举所有路径 / 所有方案 | DFS(回溯) | 沿分支深入到底再回退,天然枚举每条完整路径。 |
| 空间敏感、图又深又窄 | BFS 谨慎 | BFS 队列宽度可达一整层;DFS 只占栈深 O(深度)。 |
| 空间敏感、图又浅又宽 | DFS 谨慎 | DFS 递归深度虽小,但宽图里 BFS 队列可能爆。栈深 vs 队列宽要按图形状权衡。 |
自测
- DFS 与 BFS 的唯一本质区别是什么?
答案
容器不同:DFS 用栈(LIFO),BFS 用队列(FIFO)。取出—访问—放入邻居的骨架完全一致,换容器就换了遍历顺序。
- 为什么 BFS 能求无权图的最短路径,DFS 不能保证?
答案
BFS 按距离分层扩展,离起点近的节点先被访问,终点第一次被取出时走过的步数必为最短。DFS 访问顺序与距离无关,可能先绕远路到终点。
- 回溯里"撤销"那一步省了什么?
答案
省空间。撤销让所有兄弟分支复用同一份路径数组(
path),不必每层递归都复制一份新数组。 - BST 中序遍历得到什么?
答案
一个从小到大的有序序列。这是 BST "左小右大"定义加上"左—根—右"访问顺序的直接结果。
单词接龙(Word Ladder)
给两个等长单词 beginWord、endWord 和一个词典 wordList。每次只能改一个字母,且改完后的单词必须在词典里。求从 beginWord 变到 endWord 的最短转换序列的长度(含首尾两词),无法转换返回 0。例:"hit" → "hot" → "dot" → "dog" → "cog",长度 5。
提示
这是一道"无权图求最短"的题,伪装成字符串题。把每个单词看成图中一个节点,两个单词只差一个字母就连一条边——求最短转换序列,就是在这张图上求 beginWord 到 endWord 的最短路径。用 BFS。
建图技巧:不用两两比较所有单词(O(N²·L))。对每个单词,把每一位换成 * 生成通配模式(如 hot → *ot, h*t, ho*),共享同一模式的单词互为邻居。BFS 时按模式查邻居,每层步数加一,第一次弹出 endWord 即得最短长度。