图论算法基础:表示、遍历与拓扑排序
简介
图论是计算机科学中用于建模对象间复杂关系的强大工具,广泛应用于社交网络、路径规划、任务调度、编译器优化等领域。掌握图论算法是解决众多高级算法问题的基石。本章将深入探讨图论的基础知识,包括图的两种核心表示方法——邻接表和邻接矩阵,这是所有图算法得以实现的前提。在此基础上,我们将详细解析图的两种基本遍历策略:深度优先遍历和广度优先遍历,它们是探索图结构的通用范式。最后,我们将聚焦于有向无环图的一个重要应用——拓扑排序,并介绍其两种经典算法:基于入度的Kahn算法和基于深度优先搜索的后序遍历方法。本章内容将通过“课程表”、“克隆图”、“岛屿的最大面积”等经典例题进行实战演练,帮助读者从理论到实践,全面构建图论算法的知识体系。
核心概念
图(Graph)由顶点(Vertex)和边(Edge)组成。根据边是否有方向,可分为有向图和无向图。根据边是否带有权重,可分为无权图和有权图。理解图的表示方法是实现算法的第一步。
邻接表 是一种空间效率较高的表示方法,特别适用于稀疏图。它为图中的每个顶点维护一个列表,记录所有与该顶点直接相连的邻接顶点。在无向图中,一条边会被存储两次(分别在两个顶点的邻接表中);在有向图中,则只存储一次(在起点的邻接表中)。邻接表的优点是能快速找到一个顶点的所有邻居,空间复杂度为 O(V+E),其中V是顶点数,E是边数。
邻接矩阵 是一个 V x V 的二维数组。对于无权图,matrix[u][v] = 1 表示存在从顶点 u 到顶点 v 的边,否则为0。对于有权图,该位置存储边的权重。邻接矩阵的优点是可以在 O(1) 时间内判断任意两个顶点间是否有边相连,但其空间复杂度为 O(V²),因此更适用于稠密图。
图的遍历 是系统访问图中所有顶点的过程。深度优先遍历 沿着一条路径尽可能深地探索,直到无法继续,然后回溯到上一个分叉点。它通常使用递归或栈来实现,适合解决连通性、环检测、拓扑排序等问题。广度优先遍历 则按“层次”进行,先访问起始顶点的所有邻居,再访问邻居的邻居。它通常使用队列来实现,适合求解最短路径(在无权图中)、层级扩散等问题。
拓扑排序 是针对有向无环图的一种线性顶点排序,使得对于图中的每一条有向边 (u, v),顶点 u 在排序中都出现在顶点 v 之前。它本质上是图的一种特定遍历顺序,常用于解决具有依赖关系的任务调度问题,如课程安排、构建系统等。
下图展示了从图的基本表示到拓扑排序结果的核心流程:
(空间高效)"] B --> D["邻接矩阵
(查询快速)"] C --> E["执行遍历算法"] D --> E E --> F{遍历目标?} F --> G["深度优先遍历
(DFS)"] F --> H["广度优先遍历
(BFS)"] G --> I["检测环 / 后序DFS"] H --> J["计算最短路径"] I --> K["生成拓扑序列"] J --> L["得到BFS层级"] K --> M["最终结果:
拓扑排序"]
实战示例
下面我们通过解决 LeetCode 207题“课程表”来演示拓扑排序的 Kahn 算法实现。问题描述:你需要选修 numCourses 门课程,记为 0 到 numCourses-1。给定一个数组 prerequisites,其中 prerequisites[i] = [ai, bi] 表示要学习课程 ai 必须先完成课程 bi。判断是否可能完成所有课程的学习(即判断图是否有环)。
from collections import deque
from typing import List
def canFinish(numCourses: int, prerequisites: List[List[int]]) -> bool:
"""
使用Kahn算法(基于BFS的拓扑排序)判断课程安排图是否是有向无环图(DAG)。
参数:
numCourses: 课程总数(顶点数)
prerequisites: 先修关系列表,每个元素 [ai, bi] 表示 ai 依赖于 bi
返回:
bool: 如果可以完成所有课程(无环)返回True,否则返回False
"""
# 1. 初始化邻接表和入度数组
# 邻接表:记录每个顶点的后继顶点(出边)
adj_list = [[] for _ in range(numCourses)]
# 入度数组:记录每个顶点的入度(有多少条边指向它)
in_degree = [0] * numCourses
# 2. 构建图:填充邻接表和入度数组
for course, pre in prerequisites:
# 添加一条从 pre 指向 course 的边
adj_list[pre].append(course)
# course 的入度加1
in_degree[course] += 1
# 3. 初始化队列:将所有入度为0的顶点加入队列
queue = deque([i for i in range(numCourses) if in_degree[i] == 0])
# 4. 拓扑排序过程
visited_count = 0 # 记录已排序(访问)的顶点数量
while queue:
# 从队列中取出一个入度为0的顶点
current = queue.popleft()
visited_count += 1
# 遍历当前顶点的所有后继顶点
for neighbor in adj_list[current]:
# “移除”从 current 到 neighbor 的边:将后继顶点的入度减1
in_degree[neighbor] -= 1
# 如果后继顶点的入度变为0,则加入队列
if in_degree[neighbor] == 0:
queue.append(neighbor)
# 5. 判断结果:如果访问过的顶点数等于总顶点数,说明拓扑排序成功(无环)
return visited_count == numCourses
# 测试用例
if __name__ == "__main__":
# 示例 1: 可以完成,图无环
numCourses1 = 2
prerequisites1 = [[1, 0]]
print(f"测试1 - 能否完成课程? {canFinish(numCourses1, prerequisites1)}") # 应输出 True
# 示例 2: 无法完成,图有环 (0->1, 1->0)
numCourses2 = 2
prerequisites2 = [[1, 0], [0, 1]]
print(f"测试2 - 能否完成课程? {canFinish(numCourses2, prerequisites2)}") # 应输出 False
# 示例 3: 多门课程,复杂依赖
numCourses3 = 4
prerequisites3 = [[1, 0], [2, 1], [3, 2]]
print(f"测试3 - 能否完成课程? {canFinish(numCourses3, prerequisites3)}") # 应输出 True
对比分析
下表详细对比了图的两种表示方法、两种遍历方式以及两种拓扑排序算法:
| 方案 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|
| 邻接表 | 1. 空间复杂度低,为 O(V+E),适合稀疏图。 2. 能高效地遍历一个顶点的所有邻居。 3. 动态增删边较为方便。 | 1. 判断任意两个顶点间是否有边需要 O(degree(V)) 时间。 2. 存储结构相对复杂。 | 绝大多数算法题和实际应用,如图遍历、最短路径(Dijkstra, SPFA)、拓扑排序等。 |
| 邻接矩阵 | 1. 判断任意两顶点间是否有边仅需 O(1) 时间。 2. 结构简单直观,易于理解和实现。 3. 适合表示稠密图。 | 1. 空间复杂度高,为 O(V²),浪费大量空间用于稀疏图。 2. 遍历一个顶点的所有邻居需要 O(V) 时间。 | 顶点数较少(V < 1000)的稠密图,或需要频繁进行边存在性查询的场景。 |
| 深度优先遍历 (DFS) | 1. 实现简洁,常使用递归。 2. 天然适合处理“探索到尽头”的问题,如找环、连通分量。 3. 在回溯过程中方便记录路径信息。 | 1. 递归实现可能导致栈溢出(对于极深图)。 2. 不保证找到的是最短路径。 | 拓扑排序(DFS后序逆序)、寻找连通分量、检测图中环、回溯问题(如迷宫)。 |
| 广度优先遍历 (BFS) | 1. 保证在无权图中找到的是起点到各点的最短路径。 2. 使用队列,无栈溢出风险。 3. 按“层级”扩散,适合计算最短距离。 | 1. 通常需要更多内存来维护队列。 2. 代码相比递归DFS稍显复杂。 | 无权图最短路径、层级遍历(如克隆图)、拓扑排序(Kahn算法)、扩散类问题(如岛屿面积)。 |
| 拓扑排序 (Kahn算法) | 1. 基于BFS,直观反映了“从入度为0的顶点开始”的过程。 2. 易于检测环:若最终排序顶点数小于总数,则存在环。 3. 可以方便地输出排序序列。 | 1. 需要额外维护入度数组。 2. 需要预先计算所有顶点的入度。 | 大多数需要拓扑排序的场景,尤其是需要同时检测环和获取排序结果时。 |
| 拓扑排序 (DFS方法) | 1. 基于标准的DFS,代码与DFS遍历高度统一。 2. 在DFS过程中可以同时完成环检测和排序。 3. 有时更节省空间(递归栈)。 | 1. 最终得到的是逆后序序列,需要反转。 2. 递归深度可能受限于图深度。 3. 逻辑上不如Kahn算法直观。 | 已经需要进行DFS遍历的图,或者问题本身需要深度优先的特性(如强连通分量分解中的第一步)。 |
最佳实践
-
根据图密度选择表示法:面对算法问题时,首先评估图的稀疏性。如果边数 E 远小于 V²,优先使用邻接表。这是LeetCode图论题中最常见的做法。只有在题目明确给出稠密图或顶点数极少时,才考虑邻接矩阵。
-
使用访问标记数组防止重复访问:无论是DFS还是BFS,在遍历时都必须使用一个
visited数组(或集合)来记录已访问过的顶点,避免陷入无限循环或重复计算。对于无环图或树,有时可以通过判断当前节点是否为其父节点来替代visited数组,但使用visited数组是最稳妥通用的做法。 -
拓扑排序优先考虑Kahn算法:对于典型的任务调度、课程安排问题,Kahn算法(基于BFS)通常是更优选择。它的逻辑清晰(不断移除入度为0的节点),并且能在排序过程中直接判断图中是否有环(排序结果包含所有顶点),代码模板化程度高,易于记忆和实现。
-
DFS遍历时注意递归深度:Python默认递归深度有限(约1000层)。如果问题中的图可能非常深(例如一条长链),使用递归DFS可能导致
RecursionError。此时应使用显式栈(list模拟栈)来实现迭代DFS,或者转而使用BFS。 -
灵活运用遍历框架解决变体问题:许多图论问题本质上是遍历的变体。例如,“克隆图”是在BFS/DFS遍历的同时复制节点和边;“岛屿的最大面积”是在网格上进行DFS或BFS,并累加连通分量的大小。掌握遍历的核心框架后,应学会识别问题本质,并通过添加计数、记录路径、修改访问逻辑等方式进行适配。
小结
本章系统性地讲解了图论算法的基础核心:邻接表与邻接矩阵为图提供了两种互补的表示方法,是算法实现的物理基础;深度优先遍历和广度优先遍历是探索图结构的两种基本策略,构成了众多高级算法的骨架;拓扑排序作为有向无环图的重要应用,其Kahn算法和DFS方法分别从入度管理和后序遍历的角度提供了解决方案。通过“课程表”等例题的实战,我们巩固了从图构建、遍历到结果判定的完整思维链条。掌握这些基础,是进一步攻克最短路径、最小生成树、网络流等复杂图论问题的必经之路。
下一节:高级数据结构与算法