你有没有遇到过这种崩溃时刻:
- 写毕业论文时,导师说:“你得先做完实验才能写数据分析,但实验数据又得等开题报告通过。”
- 运行
npm run build时,Webpack 报错:“Module A depends on Module B, but Module B hasn't been compiled yet.”
这些看似无关的烦恼,本质上都是同一个算法问题:拓扑排序 (Topological Sort) 。
1. 什么是拓扑排序?
简单来说,就是把一个有向无环图 (DAG) 中的所有节点排成一个线性序列,使得对于图中的每一条有向边 u -> v,节点 u 在序列中都出现在节点 v 的前面。
人话翻译: 如果任务 A 必须在任务 B 之前完成,那么在最终的执行清单里,A 必须排在 B 前面。
2. 核心逻辑:寻找“零入度”节点
想象一下你的待办事项清单:
- 入度 (In-degree) :有多少个前置任务指向它。
- 出度 (Out-degree) :它完成后能解锁多少个后续任务。
算法步骤(Kahn 算法):
- 找出所有入度为 0 的节点(没有前置依赖的任务),放入队列。
- 从队列取出一个节点,将其加入结果序列。
- 删除该节点及其发出的所有边(即把它的后继节点的入度减 1)。
- 如果某个后继节点的入度变成了 0,说明它的前置任务都搞定了,把它也加入队列。
- 重复直到队列为空。
3. 代码实现:模拟任务调度系统
class TopologicalSorter {
constructor(numTasks) {
this.numTasks = numTasks;
this.graph = new Array(numTasks).fill(0).map(() => []);
this.inDegree = new Array(numTasks).fill(0);
}
// 添加依赖关系:taskA 必须在 taskB 之前完成 (A -> B)
addDependency(taskA, taskB) {
this.graph[taskA].push(taskB);
this.inDegree[taskB]++;
}
sort() {
const queue = [];
const result = [];
// 1. 找到所有入度为 0 的起点
for (let i = 0; i < this.numTasks; i++) {
if (this.inDegree[i] === 0) {
queue.push(i);
}
}
while (queue.length > 0) {
const current = queue.shift();
result.push(current);
// 2. 处理当前任务的后继
for (const neighbor of this.graph[current]) {
this.inDegree[neighbor]--;
// 3. 如果后继任务的依赖都满足了,加入队列
if (this.inDegree[neighbor] === 0) {
queue.push(neighbor);
}
}
}
// 如果结果数量不等于总任务数,说明存在环(死锁)
if (result.length !== this.numTasks) {
throw new Error("检测到循环依赖!无法完成所有任务。");
}
return result;
}
}
// 场景:毕业论文流程
// 0: 选题, 1: 开题报告, 2: 实验, 3: 数据分析, 4: 论文撰写, 5: 答辩
const sorter = new TopologicalSorter(6);
sorter.addDependency(0, 1); // 选题 -> 开题
sorter.addDependency(1, 2); // 开题 -> 实验
sorter.addDependency(2, 3); // 实验 -> 数据分析
sorter.addDependency(3, 4); // 数据分析 -> 撰写
sorter.addDependency(4, 5); // 撰写 -> 答辩
console.log("推荐执行顺序:", sorter.sort());
// 输出: [0, 1, 2, 3, 4, 5]
4. 工业界应用:无处不在的调度
-
前端工程化 (Webpack/Vite) :
- 模块之间存在
import/export依赖。构建工具必须通过拓扑排序确定编译顺序,否则会出现“变量未定义”的错误。
- 模块之间存在
-
数据库迁移 (Migration) :
- 表 B 的外键引用了表 A,那么创建表 A 的脚本必须先于表 B 执行。
-
课程安排系统:
- 必须先修完《高等数学》才能修《机器学习》,教务系统用它来检测选课冲突。
5. 进阶:如何处理“循环依赖”?
如果在图中出现了 A -> B -> C -> A,拓扑排序将无法进行(队列会提前变空,但还有节点没处理)。
解决方案:
- 静态分析:在代码编译阶段直接报错,提示开发者解耦。
- 运行时降级:在某些微服务架构中,如果检测到循环调用,可能会采用异步消息队列来打破同步依赖链。
6. 总结与面试考点
| 特性 | 说明 |
|---|---|
| 适用场景 | 任务调度、依赖解析、编译顺序 |
| 前提条件 | 图必须是有向无环图 (DAG) |
| 时间复杂度 | O(V + E),V 是节点数,E 是边数 |
面试官常问:
- “如何判断一个图中是否存在环?” (答:尝试进行拓扑排序,如果最后输出的节点数少于总节点数,则存在环。)
- “DFS 也能做拓扑排序吗?” (答:可以。利用 DFS 的回溯过程,将节点压入栈中,最后出栈的顺序即为拓扑序。)
下期预告: 任务调度完了,我们来看看如何在海量数据中快速找到“第 K 大”的元素。堆 (Heap) 和 快速选择 (Quick Select) 谁是性能之王?
如果你觉得这篇关于“秩序”的文章对你有帮助,欢迎点赞收藏!🚀
📢 欢迎关注我的公众号:《Lee 的成长日记》
💡 福利:关注公众号回复“算法”,获取本教程的 PDF 完整版及 LeetCode 刷题清单。