Files
maths-cs-ai-compendium-zh/chapter 14: data structures and algorithms/04. graphs.md
T
flykhan 2536c937e3 feat: 完整中文翻译 maths-cs-ai-compendium(数学·计算机科学·AI 知识大全)
翻译自英文原版 maths-cs-ai-compendium,共 20 章全部完成。

第01章 向量 | 第02章 矩阵 | 第03章 微积分
第04章 统计学 | 第05章 概率论 | 第06章 机器学习
第07章 计算语言学 | 第08章 计算机视觉 | 第09章 音频与语音
第10章 多模态学习 | 第11章 自主系统 | 第12章 图神经网络
第13章 计算与操作系统 | 第14章 数据结构与算法
第15章 生产级软件工程 | 第16章 SIMD与GPU编程
第17章 AI推理 | 第18章 ML系统设计
第19章 应用人工智能 | 第20章 前沿人工智能

翻译说明:
- 所有数学公式 $...$ / $$...$$、代码块、图片引用完整保留
- mkdocs.yml 配置中文导航 + language: zh
- README.md 已翻译为中文(兼 docs/index.md)
- docs/ 目录包含指向各章文件的 symlink
- 约 29,000 行中文内容,排除 .cache/ 构建缓存
2026-05-03 10:23:20 +08:00

324 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 图
*图建模了关系和连接——从社交网络到道路地图再到依赖链。本文件涵盖图的表示、BFS、DFS、最短路径、拓扑排序和连通分量,包括遍历和寻路模式,这些是图面试题中的核心。*
- 我们在第12章(邻接矩阵、拉普拉斯矩阵、谱性质)和第13章(树、平面性、着色)中已经介绍了图论。这里我们专注于**算法模式**:如何在代码中遍历、搜索和优化图。
- 两种基本的图算法是 **BFS****DFS**。几乎所有图问题都可以归结为其中一种,可能带有修改。掌握这两种算法,你就能解决绝大多数图问题。
## 图的表示
- **邻接表**:对于每个节点,存储一个邻居列表。空间:$O(|V| + |E|)$。最适合稀疏图(大多数现实世界的图)。
```python
# 无向图
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 使用队列**逐层**探索节点。它是以下问题的首选算法:
- **无权**图中的最短路径
- 层序遍历
- 寻找连通分量
- 任何询问"最小步数"的问题
```python
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 就是一个岛屿。
```python
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 就是一个时间步。
```python
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 是以下问题的首选:
- 环检测
- 拓扑排序
- 连通分量
- 回溯 / 穷举搜索
- 带约束的寻路
```python
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 路径上)、已完成。
```python
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)开始。处理它们,减少它们邻居的入度。重复。
```python
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 算法
- 在**非负**加权图中从源点找到到所有其他节点的最短路径。使用优先队列(最小堆)。
```python
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。
```python
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 模式
- [岛屿数量](https://neetcode.io/problems/count-number-of-islands) — 网格 BFS/DFS
- [腐烂的橘子](https://neetcode.io/problems/rotting-fruit) — 多源 BFS
- [克隆图](https://neetcode.io/problems/clone-graph) — BFS + 哈希表克隆
- [太平洋大西洋水流](https://neetcode.io/problems/pacific-atlantic-water-flow) — 从两个海洋开始的 BFS
- [单词接龙](https://neetcode.io/problems/word-ladder) — 隐式图上的 BFS
### DFS 模式
- [岛屿的最大面积](https://neetcode.io/problems/max-area-of-island) — 带面积计数的 DFS
- [课程表](https://neetcode.io/problems/course-schedule) — 有向图中的环检测
- [课程表 II](https://neetcode.io/problems/course-schedule-ii) — 拓扑排序
- [连通分量数量](https://neetcode.io/problems/count-connected-components) — DFS 或并查集
- [图是否是树](https://neetcode.io/problems/valid-tree) — 连通 + 无环
### 最短路径
- [网络延迟时间](https://neetcode.io/problems/network-delay-time) — Dijkstra
- [K 站中转内最便宜的航班](https://neetcode.io/problems/cheapest-flight-path) — 带约束的修改版 BFS/Bellman-Ford
- [上升水温游泳](https://neetcode.io/problems/swim-in-rising-water) — 二分查找 + BFS 或网格上的 Dijkstra
### 进阶
- [外星文字典](https://neetcode.io/problems/foreign-dictionary) — 从字符顺序进行拓扑排序