拓扑排序(Topological Sort)是对有向无环图(DAG)的节点进行线性排序的算法,使得对于图中的每一条有向边(u, v),节点u在排序中始终位于节点v之前。拓扑排序广泛应用于任务调度、编译依赖解析、课程选修计划、Makefile构建顺序等场景。
核心思想
拓扑排序基于两个关键观察:
- DAG中必然存在至少一个入度为0的节点(没有前置依赖的节点)
- 移除一个入度为0的节点及其出边后,剩余图仍然是DAG
Kahn算法利用上述性质,反复移除入度为0的节点,直到所有节点被处理或发现环。
Kahn算法步骤
- 计算所有节点的入度
- 将所有入度为0的节点加入队列
- 从队列中取出一个节点,加入排序结果
- 遍历该节点的所有邻接节点,将其入度减1;若入度变为0,加入队列
- 重复步骤3-4直到队列为空
- 若排序结果节点数少于总节点数,说明图中存在环
完整可运行代码
import java.util.*;
/**
* 拓扑排序实现 - 基于Kahn算法(BFS方式)
* 同时提供DFS实现方式
* 时间复杂度: O(V + E)
* 空间复杂度: O(V + E)
*/
public class TopologicalSort {
private final int vertices;
private final List<List<Integer>> adjacencyList;
public TopologicalSort(int vertices) {
this.vertices = vertices;
this.adjacencyList = new ArrayList<>();
for (int i = 0; i < vertices; i++) {
adjacencyList.add(new ArrayList<>());
}
}
/**
* 添加有向边 u -> v
*/
public void addEdge(int u, int v) {
adjacencyList.get(u).add(v);
}
/**
* Kahn算法 - BFS方式实现拓扑排序
*/
public List<Integer> kahnSort() {
int[] inDegree = new int[vertices];
// 计算每个节点的入度
for (int u = 0; u < vertices; u++) {
for (int v : adjacencyList.get(u)) {
inDegree[v]++;
}
}
// 将所有入度为0的节点加入队列
Queue<Integer> queue = new LinkedList<>();
for (int i = 0; i < vertices; 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 : adjacencyList.get(u)) {
inDegree[v]--;
if (inDegree[v] == 0) {
queue.offer(v);
}
}
}
// 若结果节点数不等于总节点数,说明存在环
if (result.size() != vertices) {
throw new RuntimeException("图中存在环,无法进行拓扑排序");
}
return result;
}
/**
* DFS方式实现拓扑排序
*/
public List<Integer> dfsSort() {
boolean[] visited = new boolean[vertices];
boolean[] onStack = new boolean[vertices]; // 用于检测环
Deque<Integer> stack = new ArrayDeque<>();
for (int i = 0; i < vertices; i++) {
if (!visited[i]) {
dfsHelper(i, visited, onStack, stack);
}
}
List<Integer> result = new ArrayList<>();
while (!stack.isEmpty()) {
result.add(stack.pop());
}
return result;
}
private void dfsHelper(int u, boolean[] visited, boolean[] onStack, Deque<Integer> stack) {
visited[u] = true;
onStack[u] = true;
for (int v : adjacencyList.get(u)) {
if (!visited[v]) {
dfsHelper(v, visited, onStack, stack);
} else if (onStack[v]) {
throw new RuntimeException("图中存在环,无法进行拓扑排序");
}
}
onStack[u] = false;
stack.push(u);
}
/**
* 课程选修计划示例 - 检测是否能完成所有课程
* LeetCode 207: Course Schedule
*/
public boolean canFinishCourses(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 count = 0;
while (!queue.isEmpty()) {
int u = queue.poll();
count++;
for (int v : graph.get(u)) {
inDegree[v]--;
if (inDegree[v] == 0) {
queue.offer(v);
}
}
}
return count == numCourses;
}
public static void main(String[] args) {
// 示例: 任务依赖关系
// 0 -> 2, 1 -> 2, 2 -> 3, 2 -> 4, 3 -> 5, 4 -> 5
TopologicalSort graph = new TopologicalSort(6);
graph.addEdge(0, 2);
graph.addEdge(1, 2);
graph.addEdge(2, 3);
graph.addEdge(2, 4);
graph.addEdge(3, 5);
graph.addEdge(4, 5);
System.out.println("Kahn算法拓扑排序: " + graph.kahnSort());
System.out.println("DFS算法拓扑排序: " + graph.dfsSort());
// 课程选修示例
int numCourses = 4;
int[][] prerequisites = {{1, 0}, {2, 1}, {3, 2}};
System.out.println("能否完成所有课程: " +
graph.canFinishCourses(numCourses, prerequisites));
}
}
关键特性分析
唯一性
拓扑排序的结果通常不唯一。例如,当多个节点同时入度为0时,取出的顺序会影响最终排序。若需要字典序最小的拓扑排序,可将普通队列替换为优先队列(最小堆)。
// 字典序最小拓扑排序
PriorityQueue<Integer> pq = new PriorityQueue<>();
// ... 初始化和处理逻辑相同 ...
环检测
拓扑排序天然支持环检测:若排序结果节点数少于图中总节点数,则图中必存在环。这一特性使拓扑排序成为判断DAG的重要工具。
复杂度分析
| 指标 | Kahn算法 | DFS算法 |
|---|---|---|
| 时间复杂度 | O(V + E) | O(V + E) |
| 空间复杂度 | O(V + E) | O(V + E) |
| 适用场景 | 适合找最短/字典序排序 | 适合理解递归结构 |
其中V为节点数,E为边数。两种算法均需遍历所有节点和边一次。
实际应用场景
- 编译系统:确定源文件的编译顺序,处理头文件依赖
- 任务调度:CPU指令调度、分布式任务编排,确保依赖任务先执行
- 课程规划:判断选修课程的先修关系是否合理
- 数据处理:ETL流程中各阶段的执行顺序编排
- 软件包管理:apt、npm等包管理器的依赖解析
拓扑排序是图论中最实用的算法之一,理解其原理对于处理各类依赖关系问题至关重要。