跳转至

# 图

图建模了关系和连接——从社交网络到道路地图再到依赖链。本文件涵盖图的表示、BFS、DFS、最短路径、拓扑排序和连通分量，包括遍历和寻路模式，这些是图面试题中的核心。

- 我们在第12章（邻接矩阵、拉普拉斯矩阵、谱性质）和第13章（树、平面性、着色）中已经介绍了图论。这里我们专注于算法模式：如何在代码中遍历、搜索和优化图。

- 两种基本的图算法是 BFS 和 DFS。几乎所有图问题都可以归结为其中一种，可能带有修改。掌握这两种算法，你就能解决绝大多数图问题。

## 图的表示

- 邻接表：对于每个节点，存储一个邻居列表。空间：\(O(|V| + |E|)\)。最适合稀疏图（大多数现实世界的图）。

    # 无向图
    graph = {
        0: [1, 2],
        1: [0, 3],
        2: [0, 3],
        3: [1, 2]
    }

    # 从边列表构建
    def build_graph(n, edges):
        graph = {i: [] for i in range(n)}
        for u, v in edges:
            graph[u].append(v)
            graph[v].append(u)  # 有向图省略这一行
        return graph

- 邻接矩阵：\(n \times n\) 矩阵，其中 \(A[i][j] = 1\) 如果边 \((i, j)\) 存在。空间：\(O(|V|^2)\)。最适合稠密图或需要 \(O(1)\) 边查找时。

- 何时使用哪种：绝大多数情况使用邻接表。只有当图很稠密（\(|E| \approx |V|^2\)）或需要常数时间边存在性检查时才使用矩阵。

## 模式：BFS（广度优先搜索）

- BFS 使用队列逐层探索节点。它是以下问题的首选算法：
  - 无权图中的最短路径
  - 层序遍历
  - 寻找连通分量
  - 任何询问"最小步数"的问题

    from collections import deque

    def bfs(graph, start):
        visited = {start}
        queue = deque([start])

        while queue:
            node = queue.popleft()
            for neighbour in graph[node]:
                if neighbour not in visited:
                    visited.add(neighbour)
                    queue.append(neighbour)

- 关键：在入队时添加到 `visited`，而不是在出队时。如果你在出队时标记已访问，同一个节点可能被不同前驱多次入队，浪费时间并可能导致错误结果。

### 简单：岛屿数量

- 问题：给定一个由 '1'（陆地）和 '0'（水）组成的 2D 网格，计算岛屿的数量。

- 模式：遍历网格。当找到一个 '1' 时，使用 BFS/DFS 将所有连通的陆地单元格标记为已访问。每次开始 BFS 就是一个岛屿。

    from collections import deque

    def num_islands(grid):
        if not grid:
            return 0

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

        for r in range(rows):
            for c in range(cols):
                if grid[r][c] == '1':
                    count += 1
                    # BFS 标记整个岛屿
                    queue = deque([(r, c)])
                    grid[r][c] = '0'  # 标记已访问
                    while queue:
                        cr, cc = queue.popleft()
                        for dr, dc in [(0,1),(0,-1),(1,0),(-1,0)]:
                            nr, nc = cr + dr, cc + dc
                            if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == '1':
                                grid[nr][nc] = '0'
                                queue.append((nr, nc))

        return count

- 陷阱：`directions = [(0,1),(0,-1),(1,0),(-1,0)]` 模式用于四连通网格邻居，几乎每个网格问题都会用到。记住它。对于八连通，加上对角线。

- 陷阱：修改输入网格（`grid[r][c] = '0'`）避免了需要单独的 `visited` 集合。在面试中这是可以接受的，但要明确说明权衡（改变了输入）。

### 中等：腐烂的橘子

- 问题：新鲜橘子如果与腐烂橘子相邻则会腐烂。返回所有橘子都腐烂的最短时间（如果不可能则返回 -1）。

- 模式：多源 BFS。将所有初始腐烂的橘子同时放入队列。每层 BFS 就是一个时间步。

    from collections import deque

    def oranges_rotting(grid):
        rows, cols = len(grid), len(grid[0])
        queue = deque()
        fresh = 0

        for r in range(rows):
            for c in range(cols):
                if grid[r][c] == 2:
                    queue.append((r, c))
                elif grid[r][c] == 1:
                    fresh += 1

        if fresh == 0:
            return 0

        time = 0
        while queue and fresh > 0:
            time += 1
            for _ in range(len(queue)):
                cr, cc = queue.popleft()
                for dr, dc in [(0,1),(0,-1),(1,0),(-1,0)]:
                    nr, nc = cr + dr, cc + dc
                    if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == 1:
                        grid[nr][nc] = 2
                        fresh -= 1
                        queue.append((nr, nc))

        return time if fresh == 0 else -1

- 关键洞察：多源 BFS 同时处理所有源。这给出了从任何源的最短距离，这正是"最后一个新鲜橘子腐烂需要多长时间"。

## 模式：DFS（深度优先搜索）

- DFS 尽可能深地探索，然后回溯。它使用栈（显式栈或通过递归使用调用栈）。DFS 是以下问题的首选：
  - 环检测
  - 拓扑排序
  - 连通分量
  - 回溯 / 穷举搜索
  - 带约束的寻路

    def dfs(graph, node, visited=None):
        if visited is None:
            visited = set()
        visited.add(node)
        for neighbour in graph[node]:
            if neighbour not in visited:
                dfs(graph, neighbour, visited)

### 中等：课程表（环检测）

- 问题：给定 \(n\) 门课程和先修条件，判断是否能完成所有课程（即，没有循环依赖）。

- 模式：在有向图中检测环。使用带有三种状态的 DFS：未访问、正在进行（在当前 DFS 路径上）、已完成。

    def can_finish(num_courses, prerequisites):
        graph = {i: [] for i in range(num_courses)}
        for course, prereq in prerequisites:
            graph[course].append(prereq)

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

        def has_cycle(node):
            if state[node] == 1:
                return True   # 回边 → 环
            if state[node] == 2:
                return False  # 已经完全探索过

            state[node] = 1  # 标记为进行中
            for neighbour in graph[node]:
                if has_cycle(neighbour):
                    return True
            state[node] = 2  # 标记为已完成
            return False

        for course in range(num_courses):
            if has_cycle(course):
                return False
        return True

- 为什么需要三种状态：两种状态（已访问/未访问）无法区分"我正在探索这个节点"和"我已完成对这个节点的探索"。找到一个当前正在被探索的节点（状态 = 1）意味着我们发现了环。找到一个已经完全探索的节点（状态 = 2）只是交叉边，不是环。

### 中等：课程表 II（拓扑排序）

- 问题：返回一个有效的课程顺序（拓扑排序）。

- 模式（Kahn 算法——基于 BFS）：从没有入边的节点（入度为 0）开始。处理它们，减少它们邻居的入度。重复。

    from collections import deque

    def find_order(num_courses, prerequisites):
        graph = {i: [] for i in range(num_courses)}
        indegree = [0] * num_courses

        for course, prereq in prerequisites:
            graph[prereq].append(course)
            indegree[course] += 1

        queue = deque([i for i in range(num_courses) if indegree[i] == 0])
        order = []

        while queue:
            node = queue.popleft()
            order.append(node)
            for neighbour in graph[node]:
                indegree[neighbour] -= 1
                if indegree[neighbour] == 0:
                    queue.append(neighbour)

        return order if len(order) == num_courses else []  # 空 = 存在环

- 陷阱：如果结果中的节点数少于图中的节点数，则存在环（某些节点的入度从未降到 0）。

## 最短路径

### Dijkstra 算法

- 在非负加权图中从源点找到到所有其他节点的最短路径。使用优先队列（最小堆）。

    import heapq

    def dijkstra(graph, start):
        # graph: {node: [(neighbour, weight), ...]}
        dist = {node: float('inf') for node in graph}
        dist[start] = 0
        heap = [(0, start)]

        while heap:
            d, node = heapq.heappop(heap)
            if d > dist[node]:
                continue  # 过期条目

            for neighbour, weight in graph[node]:
                new_dist = d + weight
                if new_dist < dist[neighbour]:
                    dist[neighbour] = new_dist
                    heapq.heappush(heap, (new_dist, neighbour))

        return dist

- 时间：使用二叉堆为 \(O((|V| + |E|) \log |V|)\)。

- 陷阱：`if d > dist[node]: continue` 这行是必须的。没有它，你会处理过期的堆条目，可能退化到 \(O(|V|^2)\)。

- 陷阱：Dijkstra 不适用于负权重。如果一条边有负权重，贪心假设（一旦节点被确定，其距离就是最优的）就不成立了。应改用 Bellman-Ford。

### 困难：网络延迟时间

- 问题：给定 \(n\) 个节点和加权有向边，找出信号从源点到达所有节点所需的时间。如果并非所有节点都可到达，返回 -1。

    def network_delay(times, n, k):
        graph = {i: [] for i in range(1, n + 1)}
        for u, v, w in times:
            graph[u].append((v, w))

        dist = dijkstra(graph, k)
        max_time = max(dist.values())
        return max_time if max_time < float('inf') else -1

## 强连通分量

- 在有向图中，强连通分量（SCC）是一个最大节点集合，其中每个节点都能到达其他所有节点。

- Kosaraju 算法：(1) 在原始图上进行 DFS，记录完成顺序。(2) 转置图（反转所有边）。(3) 按完成顺序的逆序在转置图上进行 DFS。第3步中的每个 DFS 树就是一个 SCC。

- 何时使用：寻找循环依赖、2-SAT、将有向图压缩为 SCC 的 DAG。

## 常见陷阱总结

陷阱 | 示例 | 修复
--- | --- | ---
在出队时标记已访问 | 同一节点被多次入队 | 在入队时标记已访问
有向图中只有两种状态 | 无法区分回边和交叉边 | 使用三种状态：未访问/进行中/已完成
Dijkstra 用于负权重 | 错误的最短路径 | 使用 Bellman-Ford
忘记 `if d > dist[node]: continue` | 处理过期堆条目 | 总是跳过当前距离更差的情况
网格边界检查 | 索引越界 | `0 <= nr < rows and 0 <= nc < cols`
没有考虑 time=0 的边界情况 | 腐烂橘子：没有新鲜橘子 | 在 BFS 之前检查 `fresh == 0`
将有向图构建为无向图 | 先修条件是单向的 | 只在一个方向添加边

## 课后练习题（NeetCode）

### BFS 模式

- 岛屿数量 — 网格 BFS/DFS
- 腐烂的橘子 — 多源 BFS
- 克隆图 — BFS + 哈希表克隆
- 太平洋大西洋水流 — 从两个海洋开始的 BFS
- 单词接龙 — 隐式图上的 BFS

### DFS 模式

- 岛屿的最大面积 — 带面积计数的 DFS
- 课程表 — 有向图中的环检测
- 课程表 II — 拓扑排序
- 连通分量数量 — DFS 或并查集
- 图是否是树 — 连通 + 无环

### 最短路径

- 网络延迟时间 — Dijkstra
- K 站中转内最便宜的航班 — 带约束的修改版 BFS/Bellman-Ford
- 上升水温游泳 — 二分查找 + BFS 或网格上的 Dijkstra

### 进阶

- 外星文字典 — 从字符顺序进行拓扑排序