Chapter 05

树与图的遍历

前面四章都在线性结构上做文章——这一章进入分叉的结构。核心洞察:DFS、BFS、回溯不是三个独立技巧,而是同一种"系统化穷举"换了容器,递归用的栈和 BFS 用的队列互为镜像。

本章 schema

  • DFS 与 BFS 的唯一区别是用栈还是用队列——其余逻辑完全一致。
  • BFS 按距离一层层扩展,因此在无权图里第一次到达终点即最短路径。
  • 回溯是决策树上的 DFS:进入分支做选择,返回时撤销选择。
  • visited 集合防止节点被重复访问,在有环图里它是不死循环的前提。

§5.1树的遍历

前序、中序、后序的差别只有一个:DFS 在递归过程中"访问根节点"这一步发生的时机不同。

机制

一次树的 DFS 对每个节点做三件事:访问左子树、访问右子树、访问根节点本身。三种遍历只调换"访问根"插在哪一步——根在最前是前序,夹在左右之间是中序,放在最后是后序。

中序遍历对二叉搜索树(BST)有一条特殊性质:它按从小到大的顺序输出所有节点值。原因复用 04 章的有序性——BST 的定义是"左子树全部小于根,右子树全部大于根",先访问左、再访问根、最后访问右,正好把值从小到大串起来。

下面把中序遍历写两遍:先用递归,再用一个显式的栈把递归改写成迭代。两份代码访问节点的顺序完全相同,对照它们能看清一件事——递归的本质就是一个由语言运行时替你维护的栈。

inorder.pyPython
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,把指针交给右子树,下一轮循环再对右子树重复这套压栈逻辑。

4 2 6 1 3 5 7 #4 #2 #6 #1 #3 #5 #7
图 5.1中序遍历的访问顺序(红色 #n 标号)正好把节点值从小到大排出:1·2·3·4·5·6·7。注意:这条有序性只对 BST 成立——普通二叉树中序遍历的结果不保证有序。

§5.2DFS 与 BFS:栈与队列的镜像

DFS:把待访问节点放进栈,一条路走到底再回头。

BFS:把待访问节点放进队列,按距离一层一层向外扩展。

机制(深一层)

DFS 和 BFS 的骨架是同一套:取出一个节点、访问它、把它的邻居放进容器、重复,直到容器空。唯一的分歧在容器——DFS 用栈(LIFO,后进先出),刚放进去的邻居最先被取出,于是沿着一条分支一直深入,走到尽头才回头处理别的分支;BFS 用队列(FIFO,先进先出),先放进去的先取出,于是离起点近的节点先被处理,访问顺序严格按距离分层。

正因为 BFS 按层扩展,无权图中第一次到达终点的那一刻,走过的步数就是最短路径——更近的节点已经在更早的层被全部检查过,不可能存在更短的路。DFS 没有这个保证:它可能先沿一条长路绕到终点,记下的步数并非最短。

DFS(栈 / 深) A B E C D F 1 2 3 4 5 6 A B C D E F BFS(队列 / 宽) A B E C D F 1 2 3 4 5 6 A B E C D F
图 5.2同一棵树,两种访问顺序(红色数字为访问序号)。DFS 一头扎到底(A·B·C·D 走完左半边才回头到 E),BFS 按层铺开(A 这一层,再 B·E 一层,再 C·D·F 一层)。注意:换个邻居枚举顺序,序号会变,但"深 vs 宽"的形状不变。

worked:岛屿数量(网格 DFS 标记连通块)

给一个由 '1'(陆地)和 '0'(水)组成的网格,求岛屿数量。一座岛是上下左右相连的一片陆地。每遇到一块没访问过的陆地,岛屿计数加一,然后用 DFS 把这块陆地所在的整个连通块全部标记为已访问,避免重复计数。

num_islands.pyPython
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 当队列,按层扩展,第一次到达终点时的步数即最短。

grid_bfs.pyPython
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!) 的回溯往往就是预期解法。

[ ] 选 1 选 2 选 3 [1] [2] [3] 选 2 选 3 [1,2] [1,3] [1,2,3] 撤销
图 5.3对 [1,2,3] 求全排列的决策树:每一层"选一个还没用过的数"是一个分支,走到叶子(红框)收集一个完整排列,再沿虚线"撤销"退回上层试别的分支。注意:图只画出 [1] 这条子树,[2]、[3] 两棵子树结构对称,省略未画。

worked:全排列(回溯 + used 标记 + 撤销)

给一组互不相同的数,返回所有排列。核心是"选择—递归—撤销"三步:把一个还没用过的数加进当前路径(选择),递归去填下一位(递归),递归返回后把这个数从路径里弹出并标记为未用(撤销),再试下一个数。

permute.pyPython
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!) 直接超时;该剪的分支不剪,是回溯题超时的头号原因。

可选:子集(同一套骨架,收集点不同)
subsets.pyPython
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 通用模板

graph_traverse.pyPython
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]
表 5.1 · 求路径 / 连通性:DFS vs BFS 怎么选
需求选择理由
无权图最短路径 / 最少步数BFS按层扩展,第一次到达即最短。
是否存在一条路径 / 连通性DFS 或 BFS 均可都能遍历整个连通块;DFS 递归写法更短。
枚举所有路径 / 所有方案DFS(回溯)沿分支深入到底再回退,天然枚举每条完整路径。
空间敏感、图又深又窄BFS 谨慎BFS 队列宽度可达一整层;DFS 只占栈深 O(深度)。
空间敏感、图又浅又宽DFS 谨慎DFS 递归深度虽小,但宽图里 BFS 队列可能爆。栈深 vs 队列宽要按图形状权衡。

自测

  1. DFS 与 BFS 的唯一本质区别是什么?
    答案

    容器不同:DFS 用栈(LIFO),BFS 用队列(FIFO)。取出—访问—放入邻居的骨架完全一致,换容器就换了遍历顺序。

  2. 为什么 BFS 能求无权图的最短路径,DFS 不能保证?
    答案

    BFS 按距离分层扩展,离起点近的节点先被访问,终点第一次被取出时走过的步数必为最短。DFS 访问顺序与距离无关,可能先绕远路到终点。

  3. 回溯里"撤销"那一步省了什么?
    答案

    省空间。撤销让所有兄弟分支复用同一份路径数组(path),不必每层递归都复制一份新数组。

  4. 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 即得最短长度。