图的遍历与拓扑排序
图的遍历是按照某种规则访问图中所有顶点的过程,是图算法的基础。最基本的两种遍历算法是深度优先搜索(DFS)和广度优先搜索(BFS),它们不仅是图论的核心操作,也是许多复杂图算法的基石。
图的表示方式
在讨论遍历算法之前,需要了解图的常见表示方式:
邻接矩阵
使用二维数组表示,matrix[i][j] 表示顶点 i 到顶点 j 是否有边。
// 邻接矩阵表示
int[][] graph = {
{0, 1, 1, 0}, // 顶点0连接到顶点1和2
{1, 0, 0, 1}, // 顶点1连接到顶点0和3
{1, 0, 0, 1}, // 顶点2连接到顶点0和3
{0, 1, 1, 0} // 顶点3连接到顶点1和2
};
- 空间复杂度:
- 查询边是否存在:
- 遍历邻居:
- 适合稠密图
邻接表
使用数组或哈希表存储,每个顶点维护一个邻居列表。
// 邻接表表示
List<List<Integer>> graph = new ArrayList<>();
// 初始化
for (int i = 0; i < n; i++) {
graph.add(new ArrayList<>());
}
// 添加边
graph.get(0).add(1); // 顶点0连接到顶点1
graph.get(0).add(2); // 顶点0连接到顶点2
- 空间复杂度:
- 查询边是否存在:
- 遍历邻居:
- 适合稀疏图(大多数实际应用)
深度优先搜索(DFS)
深度优先搜索(Depth-First Search)是一种"一条路走到黑"的遍历策略:从起点出发,沿着一条路径尽可能深入,直到无法继续,然后回溯到上一个分叉点继续探索。
核心思想
DFS 的核心是递归或栈:先访问当前节点,然后对每个未访问的邻居递归调用 DFS。由于使用栈(或递归调用栈),DFS 会沿着一条路径尽可能深入。
算法步骤
- 从起始顶点开始,标记为已访问
- 对当前顶点的每个未访问邻居,递归执行 DFS
- 当所有邻居都已访问,回溯到上一顶点
代码实现
递归实现:
public void dfs(int[][] graph, int start) {
int n = graph.length;
boolean[] visited = new boolean[n];
dfsHelper(graph, start, visited);
}
private void dfsHelper(int[][] graph, int vertex, boolean[] visited) {
// 标记当前顶点为已访问
visited[vertex] = true;
System.out.println("访问顶点: " + vertex);
// 遍历所有邻居
for (int neighbor = 0; neighbor < graph.length; neighbor++) {
// 如果有边且邻居未访问
if (graph[vertex][neighbor] == 1 && !visited[neighbor]) {
dfsHelper(graph, neighbor, visited);
}
}
}
邻接表版本:
public void dfs(List<List<Integer>> graph, int start) {
int n = graph.size();
boolean[] visited = new boolean[n];
dfsHelper(graph, start, visited);
}
private void dfsHelper(List<List<Integer>> graph, int vertex, boolean[] visited) {
visited[vertex] = true;
System.out.println("访问顶点: " + vertex);
// 遍历所有邻居
for (int neighbor : graph.get(vertex)) {
if (!visited[neighbor]) {
dfsHelper(graph, neighbor, visited);
}
}
}
迭代实现(使用显式栈):
public void dfsIterative(List<List<Integer>> graph, int start) {
int n = graph.size();
boolean[] visited = new boolean[n];
Stack<Integer> stack = new Stack<>();
stack.push(start);
while (!stack.isEmpty()) {
int vertex = stack.pop();
// 可能多次入栈,需要检查是否已访问
if (visited[vertex]) continue;
visited[vertex] = true;
System.out.println("访问顶点: " + vertex);
// 将未访问的邻居入栈(逆序入栈保证顺序正确)
List<Integer> neighbors = graph.get(vertex);
for (int i = neighbors.size() - 1; i >= 0; i--) {
int neighbor = neighbors.get(i);
if (!visited[neighbor]) {
stack.push(neighbor);
}
}
}
}
时间复杂度分析
| 图表示方式 | 时间复杂度 | 说明 |
|---|---|---|
| 邻接表 | 每个顶点和边访问一次 | |
| 邻接矩阵 | 每个顶点检查所有可能的邻居 |
空间复杂度:(visited 数组 + 递归栈/显式栈)
DFS 的应用场景
1. 检测连通分量
无向图中,从任意一个未访问顶点开始 DFS,能到达的所有顶点构成一个连通分量。
public int countConnectedComponents(List<List<Integer>> graph) {
int n = graph.size();
boolean[] visited = new boolean[n];
int count = 0;
for (int i = 0; i < n; i++) {
if (!visited[i]) {
dfsHelper(graph, i, visited);
count++; // 每次DFS完成,找到一个连通分量
}
}
return count;
}
2. 检测环
在有向图中检测环:维护一个递归栈,如果在 DFS 过程中遇到一个已在递归栈中的顶点,则存在环。
public boolean hasCycle(List<List<Integer>> graph) {
int n = graph.size();
int[] status = new int[n]; // 0: 未访问, 1: 正在访问, 2: 已完成
for (int i = 0; i < n; i++) {
if (status[i] == 0) {
if (dfsCycle(graph, i, status)) {
return true;
}
}
}
return false;
}
private boolean dfsCycle(List<List<Integer>> graph, int vertex, int[] status) {
status[vertex] = 1; // 标记为正在访问
for (int neighbor : graph.get(vertex)) {
if (status[neighbor] == 1) {
// 邻居正在被访问,说明有环
return true;
}
if (status[neighbor] == 0) {
if (dfsCycle(graph, neighbor, status)) {
return true;
}
}
}
status[vertex] = 2; // 标记为已完成
return false;
}
3. 拓扑排序(DFS 方法)
DFS 的逆后序遍历就是拓扑排序的一种。详见后文拓扑排序章节。
4. 路径查找
查找从起点到终点的路径:
public List<Integer> findPath(List<List<Integer>> graph, int start, int end) {
int n = graph.size();
boolean[] visited = new boolean[n];
List<Integer> path = new ArrayList<>();
if (dfsPath(graph, start, end, visited, path)) {
return path;
}
return Collections.emptyList(); // 没有路径
}
private boolean dfsPath(List<List<Integer>> graph, int current, int end,
boolean[] visited, List<Integer> path) {
visited[current] = true;
path.add(current);
if (current == end) {
return true; // 找到终点
}
for (int neighbor : graph.get(current)) {
if (!visited[neighbor]) {
if (dfsPath(graph, neighbor, end, visited, path)) {
return true;
}
}
}
// 回溯
path.remove(path.size() - 1);
return false;
}
广度优先搜索(BFS)
广度优先搜索(Breadth-First Search)是一种"层层推进"的遍历策略:由内而外、逐层访问顶点,先访问距离起点为 1 的所有顶点,再访问距离为 2 的所有顶点,以此类推。
核心思想
BFS 的核心是队列:先访问当前顶点,将其所有未访问的邻居加入队列。这保证了按"距离"的顺序访问顶点。
算法步骤
- 将起始顶点加入队列
- 从队列取出一个顶点,标记为已访问
- 将该顶点的所有未访问邻居加入队列
- 重复步骤 2-3 直到队列为空
代码实现
public void bfs(List<List<Integer>> graph, int start) {
int n = graph.size();
boolean[] visited = new boolean[n];
Queue<Integer> queue = new LinkedList<>();
visited[start] = true;
queue.offer(start);
while (!queue.isEmpty()) {
int vertex = queue.poll();
System.out.println("访问顶点: " + vertex);
for (int neighbor : graph.get(vertex)) {
if (!visited[neighbor]) {
visited[neighbor] = true; // 标记后再入队,避免重复入队
queue.offer(neighbor);
}
}
}
}
时间复杂度分析
| 图表示方式 | 时间复杂度 | 说明 |
|---|---|---|
| 邻接表 | 每个顶点和边访问一次 | |
| 邻接矩阵 | 每个顶点检查所有可能的邻居 |
空间复杂度:(visited 数组 + 队列)
BFS 的应用场景
1. 无权图的最短路径
BFS 天然适合求无权图的最短路径:第一次到达某个顶点时,经过的边数就是最短距离。
public int shortestPath(List<List<Integer>> graph, int start, int end) {
if (start == end) return 0;
int n = graph.size();
int[] distance = new int[n];
Arrays.fill(distance, -1); // -1 表示未访问
Queue<Integer> queue = new LinkedList<>();
distance[start] = 0;
queue.offer(start);
while (!queue.isEmpty()) {
int vertex = queue.poll();
for (int neighbor : graph.get(vertex)) {
if (distance[neighbor] == -1) {
distance[neighbor] = distance[vertex] + 1;
if (neighbor == end) {
return distance[neighbor]; // 找到最短路径
}
queue.offer(neighbor);
}
}
}
return -1; // 没有路径
}
2. 层级遍历
当需要按层级处理顶点时(如输出每层的顶点):
public List<List<Integer>> levelOrder(List<List<Integer>> graph, int start) {
List<List<Integer>> result = new ArrayList<>();
int n = graph.size();
boolean[] visited = new boolean[n];
Queue<Integer> queue = new LinkedList<>();
visited[start] = true;
queue.offer(start);
while (!queue.isEmpty()) {
int levelSize = queue.size();
List<Integer> level = new ArrayList<>();
for (int i = 0; i < levelSize; i++) {
int vertex = queue.poll();
level.add(vertex);
for (int neighbor : graph.get(vertex)) {
if (!visited[neighbor]) {
visited[neighbor] = true;
queue.offer(neighbor);
}
}
}
result.add(level);
}
return result;
}
3. 判断二分图
使用 BFS 对图进行二染色:
public boolean isBipartite(List<List<Integer>> graph) {
int n = graph.size();
int[] color = new int[n]; // 0: 未染色, 1: 颜色A, -1: 颜色B
for (int i = 0; i < n; i++) {
if (color[i] == 0) {
if (!bfsBipartite(graph, i, color)) {
return false;
}
}
}
return true;
}
private boolean bfsBipartite(List<List<Integer>> graph, int start, int[] color) {
Queue<Integer> queue = new LinkedList<>();
color[start] = 1;
queue.offer(start);
while (!queue.isEmpty()) {
int vertex = queue.poll();
for (int neighbor : graph.get(vertex)) {
if (color[neighbor] == 0) {
// 染成相反颜色
color[neighbor] = -color[vertex];
queue.offer(neighbor);
} else if (color[neighbor] == color[vertex]) {
// 相邻顶点颜色相同,不是二分图
return false;
}
}
}
return true;
}
DFS vs BFS 对比
| 特性 | DFS | BFS |
|---|---|---|
| 数据结构 | 栈(递归或显式栈) | 队列 |
| 空间复杂度 | ,h 为深度 | ,w 为最大宽度 |
| 最短路径 | 不保证(除非回溯) | 天然保证(无权图) |
| 适用场景 | 路径查找、连通性、环检测 | 最短路径、层级遍历 |
| 实现复杂度 | 递归简洁 | 迭代直观 |
选择建议:
- 需要最短路径 → BFS
- 图的深度很大、宽度很小 → BFS 空间更优
- 图的宽度很大、深度很小 → DFS 空间更优
- 需要遍历所有路径 → DFS
- 层级相关的问题 → BFS
拓扑排序
拓扑排序(Topological Sort)是对有向无环图(DAG)顶点的线性排序,使得对于每一条有向边 ,顶点 都在顶点 之前。
应用场景
拓扑排序广泛应用于有依赖关系的调度问题:
- 课程安排:课程 A 是课程 B 的先修课,确定课程顺序
- 编译依赖:模块 A 依赖模块 B,确定编译顺序
- 任务调度:任务 A 必须在任务 B 之前完成
前提条件
拓扑排序存在的充要条件是图是有向无环图(DAG)。如果图中存在环,则不存在拓扑排序。
实现方法一:Kahn 算法(BFS)
Kahn 算法基于入度的概念,是一种 BFS 方法。
核心思想:每次选择入度为 0 的顶点加入排序结果,然后移除该顶点及其出边,更新邻居的入度,继续这个过程。
算法步骤:
- 计算所有顶点的入度
- 将所有入度为 0 的顶点加入队列
- 从队列取出一个顶点,加入排序结果
- 将该顶点所有邻居的入度减 1
- 如果某邻居入度变为 0,加入队列
- 重复步骤 3-5 直到队列为空
代码实现:
public int[] topologicalSortKahn(List<List<Integer>> graph) {
int n = graph.size();
int[] inDegree = new int[n];
// 计算所有顶点的入度
for (int i = 0; i < n; i++) {
for (int neighbor : graph.get(i)) {
inDegree[neighbor]++;
}
}
// 将入度为 0 的顶点加入队列
Queue<Integer> queue = new LinkedList<>();
for (int i = 0; i < n; i++) {
if (inDegree[i] == 0) {
queue.offer(i);
}
}
int[] result = new int[n];
int index = 0;
while (!queue.isEmpty()) {
int vertex = queue.poll();
result[index++] = vertex;
// 更新邻居的入度
for (int neighbor : graph.get(vertex)) {
inDegree[neighbor]--;
if (inDegree[neighbor] == 0) {
queue.offer(neighbor);
}
}
}
// 如果排序结果不足 n 个,说明有环
if (index != n) {
throw new IllegalArgumentException("图中存在环,无法进行拓扑排序");
}
return result;
}
时间复杂度: 空间复杂度:
实现方法二:DFS 逆后序
DFS 方法利用递归完成时间:顶点的完成时间越晚,越应该排在前面。
核心思想:在 DFS 过程中,顶点遍历完所有邻居后才"完成"。完成时间越晚的顶点,在拓扑排序中越靠前。
算法步骤:
- 对每个未访问顶点执行 DFS
- 在顶点的所有邻居都访问完毕后,将顶点加入结果列表
- 最后将结果列表逆序
代码实现:
public int[] topologicalSortDFS(List<List<Integer>> graph) {
int n = graph.size();
int[] status = new int[n]; // 0: 未访问, 1: 正在访问, 2: 已完成
LinkedList<Integer> result = new LinkedList<>();
for (int i = 0; i < n; i++) {
if (status[i] == 0) {
if (!dfsTopo(graph, i, status, result)) {
throw new IllegalArgumentException("图中存在环,无法进行拓扑排序");
}
}
}
return result.stream().mapToInt(i -> i).toArray();
}
private boolean dfsTopo(List<List<Integer>> graph, int vertex,
int[] status, LinkedList<Integer> result) {
status[vertex] = 1; // 标记为正在访问
for (int neighbor : graph.get(vertex)) {
if (status[neighbor] == 1) {
// 遇到正在访问的顶点,说明有环
return false;
}
if (status[neighbor] == 0) {
if (!dfsTopo(graph, neighbor, status, result)) {
return false;
}
}
}
status[vertex] = 2; // 标记为已完成
result.addFirst(vertex); // 逆序添加,或最后反转
return true;
}
时间复杂度: 空间复杂度:
两种方法对比
| 特性 | Kahn 算法 | DFS 方法 |
|---|---|---|
| 检测环 | 结果长度小于顶点数 | 遇到正在访问的顶点 |
| 遍历方式 | BFS | DFS |
| 空间 | 队列 + 入度数组 | 递归栈 + 结果列表 |
| 直观性 | 入度概念直观 | 逆后序需要理解 |
拓扑排序唯一性
拓扑排序不一定唯一。当存在多个入度为 0 的顶点时,选择顺序不同会得到不同的拓扑排序。
例如,对于图 A → C, B → C,两种拓扑排序都是合法的:
[A, B, C][B, A, C]
综合示例:课程表问题(LeetCode 207/210)
判断能否完成所有课程
给定 n 门课程和先修关系列表,判断能否完成所有课程。
分析:这是一个拓扑排序问题。如果存在环,则无法完成。
public boolean canFinish(int numCourses, int[][] prerequisites) {
// 构建图
List<List<Integer>> graph = new ArrayList<>();
for (int i = 0; i < numCourses; i++) {
graph.add(new ArrayList<>());
}
int[] inDegree = new int[numCourses];
for (int[] pre : prerequisites) {
graph.get(pre[1]).add(pre[0]); // pre[1] -> pre[0]
inDegree[pre[0]]++;
}
// Kahn 算法
Queue<Integer> queue = new LinkedList<>();
for (int i = 0; i < numCourses; i++) {
if (inDegree[i] == 0) {
queue.offer(i);
}
}
int count = 0;
while (!queue.isEmpty()) {
int course = queue.poll();
count++;
for (int next : graph.get(course)) {
inDegree[next]--;
if (inDegree[next] == 0) {
queue.offer(next);
}
}
}
return count == numCourses;
}
返回课程学习顺序
不仅判断能否完成,还要返回一种合法的学习顺序。
public int[] findOrder(int numCourses, int[][] prerequisites) {
List<List<Integer>> graph = new ArrayList<>();
for (int i = 0; i < numCourses; i++) {
graph.add(new ArrayList<>());
}
int[] inDegree = new int[numCourses];
for (int[] pre : prerequisites) {
graph.get(pre[1]).add(pre[0]);
inDegree[pre[0]]++;
}
Queue<Integer> queue = new LinkedList<>();
for (int i = 0; i < numCourses; i++) {
if (inDegree[i] == 0) {
queue.offer(i);
}
}
int[] result = new int[numCourses];
int index = 0;
while (!queue.isEmpty()) {
int course = queue.poll();
result[index++] = course;
for (int next : graph.get(course)) {
inDegree[next]--;
if (inDegree[next] == 0) {
queue.offer(next);
}
}
}
return index == numCourses ? result : new int[0];
}
小结
图遍历算法是图论的基础,掌握 DFS 和 BFS 是学习高级图算法的前提:
DFS 要点:
- 使用递归或栈实现
- 适合路径查找、连通性检测、环检测
- 空间与图深度相关
BFS 要点:
- 使用队列实现
- 天然适合求无权图最短路径
- 空间与图宽度相关
拓扑排序要点:
- 仅适用于 DAG
- Kahn 算法(BFS)直观易懂
- DFS 方法基于逆后序
- 可用于检测环
练习
DFS 相关:
- LeetCode 200:岛屿数量
- LeetCode 130:被围绕的区域
- LeetCode 133:克隆图
- LeetCode 494:目标和
BFS 相关: 5. LeetCode 102:二叉树的层序遍历 6. LeetCode 127:单词接龙 7. LeetCode 785:判断二分图 8. LeetCode 994:腐烂的橘子
拓扑排序: 9. LeetCode 207:课程表 10. LeetCode 210:课程表 II 11. LeetCode 310:最小高度树 12. LeetCode 802:找到最终的安全状态