算法引入
在现实世界中,许多任务之间存在先后顺序的依赖关系:编译程序时需要先编译依赖库才能编译主程序,课程学习需要先修完前置课程才能选后续课程,项目管理中某些任务必须在其他任务完成后才能开始。这些依赖关系可以用有向无环图(DAG, Directed Acyclic Graph)来建模,而拓扑排序就是将DAG中的所有节点排成一个线性序列,使得对于图中的每一条有向边 $(u, v)$,节点 $u$ 在序列中都出现在 $v$ 之前。
拓扑排序是图论中最实用的算法之一,它揭示了一个DAG中隐含的”先后次序”,广泛应用于任务调度、编译系统、数据流处理等场景。
核心思想
拓扑排序有两大经典实现思路:
Kahn算法(BFS-based):
– 反复寻找并移除图中入度为0的节点(没有前置依赖的节点)
– 移除节点时,将其所有邻接节点的入度减1
– 若某邻接节点入度变为0,说明其所有前置依赖已满足,加入待处理队列
– 最终若所有节点都被移除,则得到一个合法的拓扑序列;若剩余节点入度均大于0,则图中存在环
DFS后序遍历法:
– 对图进行深度优先搜索
– 当某节点的所有邻接节点都已访问完毕(即递归返回时),将该节点加入结果列表前端
– 利用递归调用栈的自然”后进先出”特性,确保子节点先于父节点入序列
– 若DFS过程中遇到已访问但未完成的节点,说明存在环
完整Java实现
import java.util.*;
/**
* 拓扑排序(Topological Sort)实现
* 提供Kahn算法(BFS)和DFS后序遍历两种实现
* 适用于任务调度、编译依赖、课程规划等场景
*/
public class TopologicalSort {
/** 图的邻接表表示 */
private final Map<Integer, List<Integer>> adjacencyList;
/** 节点数量 */
private final int numNodes;
public TopologicalSort(int numNodes) {
this.numNodes = numNodes;
this.adjacencyList = new HashMap<>();
for (int i = 0; i < numNodes; i++) {
adjacencyList.put(i, new ArrayList<>());
}
}
/**
* 添加有向边 u -> v
* 表示任务u必须在任务v之前完成
*
* @param u 前置节点
* @param v 后置节点
*/
public void addEdge(int u, int v) {
adjacencyList.get(u).add(v);
}
/**
* Kahn算法实现拓扑排序(基于BFS)
* 核心思想:反复移除入度为0的节点
*
* @return 拓扑排序结果,若图存在环则返回空列表
*/
public List<Integer> kahnSort() {
// 计算每个节点的入度
int[] inDegree = new int[numNodes];
for (int u = 0; u < numNodes; u++) {
for (int v : adjacencyList.get(u)) {
inDegree[v]++;
}
}
// 将所有入度为0的节点加入队列
Queue<Integer> queue = new LinkedList<>();
for (int i = 0; i < numNodes; i++) {
if (inDegree[i] == 0) {
queue.offer(i);
}
}
List<Integer> result = new ArrayList<>();
while (!queue.isEmpty()) {
int u = queue.poll();
result.add(u);
// 移除节点u后,其所有邻接节点的入度减1
for (int v : adjacencyList.get(u)) {
inDegree[v]--;
// 若邻接节点入度变为0,说明其所有前置依赖已满足
if (inDegree[v] == 0) {
queue.offer(v);
}
}
}
// 若结果节点数小于总节点数,说明图中存在环
if (result.size() != numNodes) {
System.out.println("图中存在环,无法进行拓扑排序!");
return new ArrayList<>();
}
return result;
}
/**
* DFS后序遍历实现拓扑排序
* 核心思想:节点在其所有邻接节点处理完成后才加入结果
*
* @return 拓扑排序结果,若图存在环则返回空列表
*/
public List<Integer> dfsSort() {
// 0=未访问, 1=访问中(当前递归栈中), 2=已完成
int[] state = new int[numNodes];
// 使用Deque作为栈,将完成的节点压入栈中
Deque<Integer> stack = new ArrayDeque<>();
for (int i = 0; i < numNodes; i++) {
if (state[i] == 0) {
if (!dfsHelper(i, state, stack)) {
System.out.println("图中存在环,无法进行拓扑排序!");
return new ArrayList<>();
}
}
}
// 从栈中弹出即为拓扑序(后序遍历的逆序)
List<Integer> result = new ArrayList<>();
while (!stack.isEmpty()) {
result.add(stack.pop());
}
return result;
}
/**
* DFS辅助方法
*
* @param u 当前节点
* @param state 节点访问状态数组
* @param stack 完成后序存储栈
* @return 是否无环
*/
private boolean dfsHelper(int u, int[] state, Deque<Integer> stack) {
state[u] = 1; // 标记为访问中
for (int v : adjacencyList.get(u)) {
if (state[v] == 1) {
// 遇到访问中的节点,说明存在环
return false;
}
if (state[v] == 0) {
if (!dfsHelper(v, state, stack)) {
return false;
}
}
}
state[u] = 2; // 标记为已完成
stack.push(u); // 当前节点及其所有后代都已处理完毕
return true;
}
/**
* 获取所有可能的拓扑排序方案数量
* 使用动态规划 + 状态压缩(适用于n <= 20的情况)
*
* @return 拓扑排序方案总数
*/
public long countTopologicalSorts() {
if (numNodes > 20) {
throw new IllegalArgumentException("节点数过多,无法使用状态压缩DP");
}
// 预处理:计算每个节点的入度对应的二进制掩码
int[] inMask = new int[numNodes];
for (int u = 0; u < numNodes; u++) {
for (int v : adjacencyList.get(u)) {
inMask[v] |= (1 << u);
}
}
// dp[mask] 表示已选择mask中节点的方案数
long[] dp = new long[1 << numNodes];
dp[0] = 1;
for (int mask = 0; mask < (1 << numNodes); mask++) {
if (dp[mask] == 0) continue;
for (int u = 0; u < numNodes; u++) {
// 若u已在mask中,跳过
if ((mask & (1 << u)) != 0) continue;
// 若u的所有前置依赖都已包含在mask中
if ((inMask[u] & ~mask) == 0) {
dp[mask | (1 << u)] += dp[mask];
}
}
}
return dp[(1 << numNodes) - 1];
}
/**
* 课程表问题:判断是否能完成所有课程
* LeetCode 207 经典题型
*
* @param numCourses 课程总数
* @param prerequisites 前置依赖数组 [a,b] 表示要学a必须先学b
* @return 是否能完成所有课程
*/
public static boolean canFinish(int numCourses, int[][] prerequisites) {
TopologicalSort ts = new TopologicalSort(numCourses);
for (int[] pre : prerequisites) {
ts.addEdge(pre[1], pre[0]); // b -> a
}
return !ts.kahnSort().isEmpty();
}
/**
* 课程表II:返回一个可行的学习顺序
* LeetCode 210 经典题型
*
* @param numCourses 课程总数
* @param prerequisites 前置依赖数组
* @return 可行的学习顺序,若无法完成则返回空数组
*/
public static int[] findOrder(int numCourses, int[][] prerequisites) {
TopologicalSort ts = new TopologicalSort(numCourses);
for (int[] pre : prerequisites) {
ts.addEdge(pre[1], pre[0]);
}
List<Integer> order = ts.kahnSort();
if (order.isEmpty()) return new int[0];
return order.stream().mapToInt(Integer::intValue).toArray();
}
/**
* 主方法:演示拓扑排序的各项功能
*/
public static void main(String[] args) {
// 示例:课程依赖关系
// 0: 程序设计基础, 1: 数据结构, 2: 算法, 3: 操作系统, 4: 计算机网络
// 依赖: 0->1, 0->3, 1->2, 3->4, 1->4
TopologicalSort ts = new TopologicalSort(5);
ts.addEdge(0, 1); // 程序设计基础 -> 数据结构
ts.addEdge(0, 3); // 程序设计基础 -> 操作系统
ts.addEdge(1, 2); // 数据结构 -> 算法
ts.addEdge(3, 4); // 操作系统 -> 计算机网络
ts.addEdge(1, 4); // 数据结构 -> 计算机网络
System.out.println("=== Kahn算法拓扑排序 ===");
List<Integer> kahnResult = ts.kahnSort();
System.out.println("结果: " + kahnResult);
System.out.println("\n=== DFS后序遍历拓扑排序 ===");
TopologicalSort ts2 = new TopologicalSort(5);
ts2.addEdge(0, 1);
ts2.addEdge(0, 3);
ts2.addEdge(1, 2);
ts2.addEdge(3, 4);
ts2.addEdge(1, 4);
List<Integer> dfsResult = ts2.dfsSort();
System.out.println("结果: " + dfsResult);
// 环检测示例
System.out.println("\n=== 环检测测试 ===");
TopologicalSort cyclic = new TopologicalSort(3);
cyclic.addEdge(0, 1);
cyclic.addEdge(1, 2);
cyclic.addEdge(2, 0); // 形成环 0->1->2->0
List<Integer> cyclicResult = cyclic.kahnSort();
System.out.println("有环图排序结果(应为空): " + cyclicResult);
// 课程表问题
System.out.println("\n=== 课程表问题 ===");
int numCourses = 4;
int[][] prerequisites = {{1, 0}, {2, 0}, {3, 1}, {3, 2}};
System.out.println("canFinish: " + canFinish(numCourses, prerequisites));
System.out.println("findOrder: " + Arrays.toString(findOrder(numCourses, prerequisites)));
// 拓扑排序方案数
System.out.println("\n=== 拓扑排序方案数 ===");
TopologicalSort ts3 = new TopologicalSort(4);
ts3.addEdge(0, 1);
ts3.addEdge(0, 2);
ts3.addEdge(1, 3);
ts3.addEdge(2, 3);
System.out.println("方案数: " + ts3.countTopologicalSorts());
}
}
复杂度分析
| 指标 | Kahn算法 | DFS后序法 | 说明 |
|---|---|---|---|
| 时间复杂度 | $O(V + E)$ | $O(V + E)$ | $V$ 为节点数,$E$ 为边数 |
| 空间复杂度 | $O(V + E)$ | $O(V)$ | Kahn需存储入度数组和队列 |
| 环检测 | 自然支持 | 需三色标记 | 两者都能检测DAG |
| 字典序最小 | 可用优先队列 | 需调整遍历顺序 | Kahn更灵活 |
两种算法的核心差异在于处理顺序:Kahn算法”从入度为0的节点向外扩散”,类似BFS的层层推进;DFS后序法则”从叶子节点向内收缩”,依赖递归栈的后进先出特性。Kahn算法实现更直观,且易于扩展到”字典序最小拓扑序”(用优先队列替换普通队列);DFS后序法代码更紧凑,且天然产生”逆拓扑序”。
应用场景
拓扑排序在实际工程中无处不在:
- 编译系统:
make、Maven、Gradle等工具通过拓扑排序确定编译顺序,确保依赖库先于使用它的模块编译 - 任务调度:操作系统和分布式调度器将任务依赖建模为DAG,拓扑排序生成可并行执行的任务批次
- 数据库迁移:Flyway、Liquibase等数据库迁移工具按依赖关系排序执行迁移脚本
- Excel公式计算:电子表格的单元格引用构成依赖图,拓扑排序确定计算顺序避免循环引用
- 数据流处理:Apache Flink、Spark等流处理引擎用拓扑排序规划算子执行顺序
拓扑排序不仅是算法竞赛中的经典题型,更是连接图论理论与工程实践的重要桥梁。它将抽象的依赖关系转化为具体的执行序列,是”把事情按正确顺序做完”这一朴素需求的数学表达。