引言
拓扑排序(Topological Sort)是对有向无环图(DAG)中所有节点进行线性排序的算法,要求对于图中的每一条有向边 $(u, v)$,节点 $u$ 在排序结果中始终位于节点 $v$ 之前。拓扑排序广泛应用于任务调度、课程选修计划、编译依赖分析、数据流处理等场景。本文将深入讲解两种经典实现:基于入度的Kahn算法和基于DFS的逆后序算法。
图的数据结构
import java.util.*;
public class DirectedGraph {
private int vertices;
private List<List<Integer>> adjList; // 邻接表
public DirectedGraph(int vertices) {
this.vertices = vertices;
adjList = new ArrayList<>();
for (int i = 0; i < vertices; i++) {
adjList.add(new ArrayList<>());
}
}
public void addEdge(int from, int to) {
adjList.get(from).add(to);
}
public List<Integer> getNeighbors(int v) {
return adjList.get(v);
}
public int getVertices() { return vertices; }
}
Kahn算法:基于入度的BFS实现
Kahn算法的核心思想是 repeatedly 移除入度为0的节点。若最终所有节点都被移除,则存在拓扑排序;否则图中存在环。
public class TopologicalSortKahn {
public static List<Integer> topoSort(DirectedGraph graph) {
int n = graph.getVertices();
int[] inDegree = new int[n];
// 计算所有节点的入度
for (int u = 0; u < n; u++) {
for (int v : graph.getNeighbors(u)) {
inDegree[v]++;
}
}
// 将所有入度为0的节点加入队列
Queue<Integer> queue = new LinkedList<>();
for (int i = 0; i < n; i++) {
if (inDegree[i] == 0) {
queue.offer(i);
}
}
List<Integer> result = new ArrayList<>();
while (!queue.isEmpty()) {
int u = queue.poll();
result.add(u);
// 移除u的所有出边,更新邻接节点入度
for (int v : graph.getNeighbors(u)) {
inDegree[v]--;
if (inDegree[v] == 0) {
queue.offer(v);
}
}
}
// 若结果中节点数不足,说明存在环
if (result.size() != n) {
throw new IllegalStateException("图中存在环,无法进行拓扑排序");
}
return result;
}
}
DFS逆后序算法
DFS拓扑排序利用后序遍历的性质:当节点 $u$ 的所有后继节点都被访问后,才将 $u$ 加入结果。最终需将结果逆序。
public class TopologicalSortDFS {
public static List<Integer> topoSort(DirectedGraph graph) {
int n = graph.getVertices();
boolean[] visited = new boolean[n];
boolean[] onStack = new boolean[n]; // 用于检测环
Deque<Integer> stack = new ArrayDeque<>(); // 模拟递归栈用于输出
for (int i = 0; i < n; i++) {
if (!visited[i]) {
dfs(graph, i, visited, onStack, stack);
}
}
List<Integer> result = new ArrayList<>();
while (!stack.isEmpty()) {
result.add(stack.pop());
}
return result;
}
private static void dfs(DirectedGraph graph, int u, boolean[] visited,
boolean[] onStack, Deque<Integer> stack) {
visited[u] = true;
onStack[u] = true;
for (int v : graph.getNeighbors(u)) {
if (!visited[v]) {
dfs(graph, v, visited, onStack, stack);
} else if (onStack[v]) {
// v在当前递归栈中,发现回边,存在环
throw new IllegalStateException("图中存在环,无法进行拓扑排序");
}
}
onStack[u] = false;
stack.push(u); // 后序入栈
}
}
课程选修计划:实际应用案例
假设有 $n$ 门课程,某些课程有先修要求,求合法的修课顺序。
public class CourseSchedule {
// 判断是否能完成所有课程,并返回一种合法顺序
public static int[] findOrder(int numCourses, int[][] prerequisites) {
// 构建图
DirectedGraph graph = new DirectedGraph(numCourses);
for (int[] pre : prerequisites) {
graph.addEdge(pre[1], pre[0]); // pre[1]是pre[0]的先修课
}
try {
List<Integer> order = TopologicalSortKahn.topoSort(graph);
int[] result = new int[numCourses];
for (int i = 0; i < numCourses; i++) {
result[i] = order.get(i);
}
return result;
} catch (IllegalStateException e) {
return new int[0]; // 无法完成
}
}
public static void main(String[] args) {
// 课程0需要课程1作为先修,课程1需要课程2作为先修
int[][] prerequisites = { {0, 1}, {1, 2} };
int[] order = findOrder(3, prerequisites);
System.out.println("修课顺序: " + Arrays.toString(order)); // [2, 1, 0]
}
}
两种算法对比
| 特性 | Kahn算法 (BFS) | DFS逆后序 |
|---|---|---|
| 核心思想 | 逐层剥离入度为0的节点 | 后序遍历+逆序 |
| 环检测 | 结果节点数不足n即存在环 | 发现回边即存在环 |
| 空间复杂度 | $O(V + E)$ | $O(V)$ |
| 时间复杂度 | $O(V + E)$ | $O(V + E)$ |
| 输出特点 | 倾向于得到字典序最小的解(配合优先队列) | 依赖DFS遍历顺序 |
| 适用场景 | 需要分层处理、按优先级调度 | 需要检测强连通分量 |
字典序最小拓扑排序
在Kahn算法中将普通队列替换为优先队列(小顶堆),即可输出字典序最小的拓扑序列。
public static List<Integer> topoSortLexicographically(DirectedGraph graph) {
int n = graph.getVertices();
int[] inDegree = new int[n];
for (int u = 0; u < n; u++) {
for (int v : graph.getNeighbors(u)) inDegree[v]++;
}
// 使用优先队列保证字典序最小
PriorityQueue<Integer> pq = new PriorityQueue<>();
for (int i = 0; i < n; i++) {
if (inDegree[i] == 0) pq.offer(i);
}
List<Integer> result = new ArrayList<>();
while (!pq.isEmpty()) {
int u = pq.poll();
result.add(u);
for (int v : graph.getNeighbors(u)) {
inDegree[v]--;
if (inDegree[v] == 0) pq.offer(v);
}
}
return result;
}
拓扑排序是处理有向图依赖关系的基础工具,无论是编译器的Makefile依赖解析,还是现代CI/CD流水线的任务编排,都离不开这一经典算法。