图 图(graph)用来建模关系和连接,从社交网络到路网再到依赖链。本文件涵盖图的表示、BFS、DFS、最短路径、拓扑排序和连通分量,以及主宰图论面试题的遍历与寻路模式。 我们在第 12 章和第 13 章讲过图论(邻接矩阵、拉普拉斯矩阵、谱性质,以及树、平面性、着色)。这里我们聚焦于算法模式:如何用代码遍历、搜索和在图上做优化。 两个最基本的图算法是 BFS 和 DFS。几乎每个图问题都能归结为这两者之一(可能带些改造)。掌握这两个,你就能解决绝大多数图问题。 图的表示 邻接表(adjacency list):对每个节点,存它的邻居列表。空间:$O(|V| + |E|)$。最适合稀疏图(大多数真实世界的图)。
图(graph)用来建模关系和连接,从社交网络到路网再到依赖链。本文件涵盖图的表示、BFS、DFS、最短路径、拓扑排序和连通分量,以及主宰图论面试题的遍历与寻路模式。
我们在第 12 章和第 13 章讲过图论(邻接矩阵、拉普拉斯矩阵、谱性质,以及树、平面性、着色)。这里我们聚焦于算法模式:如何用代码遍历、搜索和在图上做优化。
两个最基本的图算法是 BFS 和 DFS。几乎每个图问题都能归结为这两者之一(可能带些改造)。掌握这两个,你就能解决绝大多数图问题。
# 无向图 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
邻接矩阵(adjacency matrix):n \times n 的矩阵,若边 (i, j) 存在则 A[i][j] = 1。空间:O(|V|^2)。最适合稠密图,或需要 O(1) 查询边是否存在时。
何时用哪个:几乎什么都用邻接表。只有当图很稠密(|E| \approx |V|^2)或需要常数时间检查边是否存在时才用矩阵。
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)] 这个 4 连通网格邻居的模式几乎用在每一道网格题里。记牢它。要 8 连通,加上对角线。
陷阱:直接修改输入网格(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
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
题目:返回一个合法的修课顺序(拓扑序)。
模式(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 [] # 为空 = 存在环
import heapq def dijkstra(graph, start): # graph: {节点: [(邻居, 权重), ...]} 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。
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
在有向图中,**强连通分量(strongly connected component,SCC)**是一个极大的节点集合,其中每个节点都能到达其他所有节点。
Kosaraju 算法:(1) 在原图上做 DFS,记录完成顺序。(2) 转置图(反转所有边)。(3) 按完成顺序的逆序在转置图上做 DFS。第 3 步的每棵 DFS 树就是一个 SCC。
何时使用:找循环依赖、2-SAT、把有向图凝聚成一个由 SCC 组成的 DAG。
| 陷阱 | 例子 | 修复 |
|---|---|---|
| 出队时才标记已访问 | 同一节点被多次入队 | 入队时就标记已访问 |
| 有向图用两态 visited | 无法区分回边和横跨边 | 用三态:未访问/进行中/已完成 |
| Dijkstra 处理负权 | 最短路径错误 | 用 Bellman-Ford |
忘了 if d > dist[node]: continue |
处理陈旧的堆条目 | 当前距离更差时永远跳过 |
| 网格越界检查 | 下标越界 | 0 <= nr < rows and 0 <= nc < cols |
| 没处理 time=0 边界 | 腐烂橘子:没有新鲜橘子 | BFS 前检查 fresh == 0 |
| 把有向图建成无向图 | 先修关系是单向的 | 只在一个方向加边 |
以下题目可在 NeetCode 的题目列表中练习。