拓扑排序完全指南:DFS 和 Kahn 算法详解
通过交互式可视化掌握拓扑排序。学习 DFS 和 Kahn 算法两种方法,解决 LeetCode 问题,轻松应对 FAANG 面试。
拓扑排序完全指南
什么是拓扑排序?
拓扑排序是一种针对**有向无环图(DAG)**的算法,它能生成顶点的线性排序,使得对于每条有向边 ,顶点 在排序中都出现在顶点 之前。
为什么面试官喜欢考它?
- 是基础图算法,用于任务调度、依赖解析和构建系统
- 测试对图遍历的理解(DFS/BFS)
- 在FAANG 面试中以各种形式出现(课程表、任务排序、构建依赖)
- 结合多个概念:图、环检测、排序
- 有两种不同的实现方法(DFS 和 Kahn 算法)
何时使用拓扑排序
在以下情况下使用拓扑排序:
- 任务或项目之间存在依赖关系
- 需要找到一个有效的顺序来满足依赖关系
- 有一个有向无环图(必须验证无环)
- 问题涉及先决条件、课程安排或构建顺序
经典例子:
- 课程先修关系(学习课程 A 之前必须先学课程 B)
- 包依赖管理
- 带约束的任务调度
- 模块编译顺序
可视化直觉
想象你在规划早晨的例行活动:
- 穿袜子之前不能穿鞋
- 准备早餐之前不能吃早餐
- 穿衣服之前不能出门
这些依赖关系形成一个有向图:
起床
/ \
/ \
准备早餐 穿衣服
\ / \
\ 穿袜子 穿衬衫
\ \ /
\ 穿鞋
\ /
出门
拓扑排序给出一个有效的顺序:起床 → 准备早餐 → 穿衣服 → 穿袜子 → 穿衬衫 → 穿鞋 → 出门
关键特性
- 仅适用于 DAG(有环则无法排序)
- 可能存在多个有效排序
- 时间复杂度: 其中 = 顶点数, = 边数
- 两种主要方法: 基于 DFS 和基于 BFS(Kahn 算法)
交互式可视化:Kahn 算法
Kahn 算法使用 BFS 和入度计数。它重复找到入度为 0 的节点(没有前置依赖)并处理它们。
Kahn's Algorithm Topological Sort
In-Degree
Topological Order
Visited
工作原理:
- 计算每个节点的入度(入边数量)
- 将所有入度为 0 的节点入队
- 当队列不为空时:
- 出队一个节点并添加到结果中
- 对每个邻居,将其入度减 1
- 如果邻居的入度变为 0,将其入队
- 如果结果包含所有节点 → 成功;否则 → 检测到环
交互式可视化:DFS 方法
基于 DFS 的方法尽可能深入探索,然后以逆后序将节点添加到结果中。
DFS-based Topological Sort
Stack
Topological Order
Visited
工作原理:
- 标记所有节点为未访问
- 对每个未访问的节点运行 DFS
- 访问完一个节点的所有后代后,将其压入栈
- 反转栈得到拓扑排序
通用模式:识别拓扑排序问题
问题特征
✅ 在以下情况使用拓扑排序:
- 提到”先决条件”或”依赖关系”
- 需要确定”顺序”或”序列”
- 带约束的有向图
- “课程表”、“任务调度”、“构建顺序”
- 需要检测排序是否可行(环检测)
解题步骤
- 根据输入构建图(邻接表)
- 选择算法: Kahn(更容易检测环)或 DFS(更直观)
- 运行算法获得拓扑排序
- 检查有效性: 结果应包含所有节点
- 根据问题要求返回结果
代码模板
Kahn 算法(基于 BFS)
基于 DFS 的拓扑排序
三色标记法 DFS(推荐)
三色标记法是一种更优雅的 DFS 实现,使用单个状态数组替代两个布尔数组。这种方法在图算法中被广泛使用,特别适合环检测。
三种颜色状态:
- 白色 (0): 未访问的节点
- 灰色 (1): 正在访问的节点(在当前 DFS 路径中)
- 黑色 (2): 已完成访问的节点
关键直觉:
- 如果在 DFS 过程中遇到灰色节点 → 发现后向边 → 存在环
- 如果遇到黑色节点 → 该节点及其子树已完全处理 → 安全跳过
- 如果遇到白色节点 → 继续 DFS 探索
优势:
- 代码更简洁(只需一个状态数组)
- 更容易理解和解释
- 是图算法中的经典模式
- 环检测逻辑更清晰
三色标记法 vs 双布尔数组:
| 方面 | 三色标记法 | visited + inStack |
|---|---|---|
| 数组数量 | 1 个状态数组 | 2 个布尔数组 |
| 空间使用 | 稍少(单数组) | 稍多(两数组) |
| 代码清晰度 | ⭐ 更清晰直观 | 需要理解两个标志 |
| 环检测 | 检查灰色节点 | 检查 inStack |
| 行业标准 | ⭐ 经典图算法模式 | 同样有效 |
推荐: 在面试中,如果你熟悉三色标记法,使用它会显得更专业。它是图算法教材中的标准方法。
LeetCode 问题示例
问题一:课程表 II (LC 210)
问题: 现在你总共有 numCourses 门课需要选,记为 0 到 numCourses - 1。给你一个数组 prerequisites,其中 prerequisites[i] = [ai, bi] 表示如果要学习课程 ai 则必须先学习课程 bi。返回你为了学完所有课程所安排的学习顺序。如果不可能完成所有课程,返回空数组。
示例:
输入: numCourses = 4, prerequisites = [[1,0],[2,0],[3,1],[3,2]]
输出: [0,2,1,3] 或 [0,1,2,3]
解释:
课程 0 → 课程 1 → 课程 3
课程 0 → 课程 2 ↗
使用 Kahn 算法的解决方案:
复杂度分析:
- 时间: 其中 = 课程数, = 先修关系数
- 空间: 用于图和辅助数据结构
关键点:
- 图的方向:
prerequisites[i] = [a, b]表示b → a(先学 b 再学 a) - 使用入度追踪剩余的先修课程数量
- 如果最终结果的课程数少于总数,说明存在环
问题二:火星词典 (LC 269)
注意:这是一道 LeetCode 会员题。需要订阅才能提交。
问题: 给定一个已排序的外星语言字典,推导出该语言中字符的顺序。
示例:
输入: words = ["wrt","wrf","er","ett","rftt"]
输出: "wertf"
解释:
从 "wrt" 和 "wrf" 可知 't' < 'f'
从 "wrt" 和 "er" 可知 'w' < 'e'
从 "er" 和 "ett" 可知 'r' < 't'
从 "ett" 和 "rftt" 可知 'e' < 'r'
所以顺序是: w → e → r → t → f
解决方案:
复杂度分析:
- 时间: 其中 = 所有单词的总长度
- 空间: 或 对于英文字母
关键点:
- 只比较已排序字典中的相邻单词
- 只有第一个不同的字符创建排序约束
- 边界情况:如果
word1是word2的前缀但更长 → 无效输入 - 可能存在多个有效排序;任何一个都可以
问题三:最小高度树 (LC 310)
问题: 树是一个无向图,其中任何两个顶点只通过一条路径连接。给定这样一棵有 n 个节点的树,节点标记为 0 到 n - 1,找到所有能产生最小高度树的根节点标签。
示例:
输入: n = 6, edges = [[3,0],[3,1],[3,2],[3,4],[5,4]]
输出: [3,4]
树结构:
0 1 2
\ | /
3
|
4
|
5
解决方案(从叶子开始拓扑排序):
复杂度分析:
- 时间: 其中 = 节点数
- 空间: 用于图
关键点:
- 这是无向树上的拓扑排序变体
- 逐层修剪叶子直到剩余 1 或 2 个节点
- 剩余的节点是树的中心点
- 最多有 2 个节点可以作为最小高度树的根
边界情况和常见错误
需要处理的边界情况
- 空图: 没有节点 → 返回空结果
- 单节点: 只有一个节点 → 返回该节点
- 环检测: 如果存在环 → 没有有效的拓扑排序
- 非连通图: 如果需要,处理多个连通分量
- 自环: 通常对拓扑排序无效
- 多重边: 同一对节点之间有多条边
常见错误
❌ 错误 1: 忘记检查环
// 错误:假设图总是无环的
if (result.size() != numNodes) {
// 忘记处理这种情况!
}
✅ 正确:
if (result.size() != numNodes) {
return new int[0]; // 或抛出异常
}
❌ 错误 2: 边的方向错误
// 错误:边的方向反了
graph[prerequisites[i][0]].add(prerequisites[i][1]);
✅ 正确: 如果 [a, b] 表示”先学 b 再学 a”:
graph[prerequisites[i][1]].add(prerequisites[i][0]);
❌ 错误 3: 未处理多个连通分量
// 错误:只从节点 0 开始 DFS
dfs(0);
✅ 正确:
for (int i = 0; i < n; i++) {
if (!visited[i]) {
dfs(i);
}
}
❌ 错误 4: DFS 中仅使用 visited 进行环检测
// 错误:无法正确检测环
if (visited[neighbor]) return true; // 这是错的!
✅ 正确: 同时使用 visited 和 inStack:
if (!visited[neighbor]) {
if (hasCycle(neighbor)) return true;
} else if (inStack[neighbor]) {
return true; // 后向边 = 环
}
面试技巧
如何解释拓扑排序
解释框架:
- 定义问题: “拓扑排序对 DAG 中的节点排序,使依赖关系在前”
- 说明假设: “图必须是有向无环的”
- 选择方法: “我将使用带入度计数的 Kahn 算法” 或 “我将使用带后序遍历的 DFS”
- 解释环检测: “如果无法处理所有节点,则存在环”
- 分析复杂度: ” 时间, 空间”
当面试官要求优化时
常见后续问题:
-
“你能检测环并返回环路径吗?”
- 使用带递归栈追踪的 DFS
- 发现后向边时,从栈重建路径
-
“如果有多个有效排序?返回所有排序。”
- 使用 DFS 回溯
- 探索所有可能的排序(指数时间)
-
“如果图非常大且无法放入内存怎么办?”
- 使用外部排序技术
- 分块处理图
- 使用基于磁盘的图数据库
-
“你能找到字典序最小的拓扑排序吗?”
- 在 Kahn 算法中使用优先队列(最小堆)代替普通队列
- 时间变为
时间和空间复杂度
Kahn 算法(基于 BFS)
时间复杂度:
- 构建图:
- 计算入度:
- 处理每个顶点一次:
- 处理每条边一次:
- 总计:
空间复杂度:
- 邻接表:
- 入度数组:
- 队列:最坏情况
- 结果数组:
- 总计:
基于 DFS 的方法
时间复杂度:
- DFS 访问每个顶点一次:
- DFS 探索每条边一次:
- 总计:
空间复杂度:
- 邻接表:
- 访问数组:
- 递归栈:最坏情况
- 结果栈:
- 总计:
最佳、平均、最坏情况
两种算法都有:
- 最佳情况: - 线性依赖链
- 平均情况: - 典型的 DAG 结构
- 最坏情况: - 完全连通的 DAG
注意: 无论图结构如何,时间复杂度始终是 ,这使得拓扑排序非常高效!
何时不使用拓扑排序
❌ 不要在以下情况使用拓扑排序:
-
图有环
- 拓扑排序需要 DAG
- 使用环检测算法
- 或找强连通分量(Tarjan/Kosaraju 算法)
-
无向图
- 拓扑排序需要有向边
- 考虑:BFS、DFS 或生成树算法
-
需要最短路径
- 拓扑排序给出排序,不是距离
- 使用:Dijkstra、Bellman-Ford 或 A*
- 例外:可以在 DAG 上使用拓扑排序 + DP 求最短路径
-
需要所有可能的排序
- 标准拓扑排序只给出一个排序
- 使用:带 DFS 的回溯(指数时间)
-
图的实时更新
- 拓扑排序不是增量的
- 考虑:动态拓扑排序算法
更好的替代方案
| 你的需求 | 不要用拓扑排序 | 用这个 |
|---|---|---|
| 找环 | 拓扑排序 | DFS 后向边检测 |
| 最短路径 | 拓扑排序 + BFS | Dijkstra 算法 |
| 所有排序 | 单次拓扑排序 | 回溯 |
| 强连通分量 | 拓扑排序 | Tarjan 或 Kosaraju |
| 无向图遍历 | 拓扑排序 | BFS 或 DFS |
Kahn vs DFS:选择哪个?
| 方面 | Kahn 算法 | 基于 DFS |
|---|---|---|
| 方法 | 带入度的 BFS | 后序 DFS |
| 直觉 | 移除无依赖的节点 | 先完成子节点再处理父节点 |
| 环检测 | 更容易(计数处理的节点) | 需要额外的 inStack 数组 |
| 空间 | + 递归栈 | |
| 迭代 vs 递归 | 迭代(队列) | 递归(可以改为迭代) |
| 字典序最小 | 容易(使用最小堆) | 更难 |
| 面试偏好 | ⭐ 初学者更直观 | ⭐ 更优雅,代码更少 |
建议:
- 如果需要显式检测环或需要迭代解决方案,使用 Kahn 算法
- 如果你熟悉递归且想要更简洁的代码,使用基于 DFS 的方法
总结:面试关键要点
模式识别
✅ 在以下情况使用拓扑排序:
- 依赖关系、先决条件或排序约束
- “课程表”、“任务顺序”、“构建依赖”
- 需要检测是否存在有效排序(环检测)
- 有向无环图(DAG)结构
需要记住的模板
Kahn 算法(4 步):
- 构建图 + 计算入度
- 将所有入度为 0 的节点入队
- 当队列不为空时:出队,添加到结果,减少邻居的入度
- 检查 result.length == numNodes(环检测)
DFS 方法(4 步):
- 构建图
- 从每个未访问的节点进行 DFS
- 访问完所有后代后,将节点压入栈
- 反转栈 = 拓扑排序
面试关键点
- 始终检查环 - 拓扑排序只适用于 DAG
- 明确边的方向 -
[a, b]表示什么?(a → b 还是 b → a?) - 处理多个连通分量 - 从所有未访问的节点进行 DFS
- 时间复杂度: - 非常高效!
- 空间复杂度: - 需要存储图
离开面试前
确保你已经涵盖:
- ✅ 正确构建了图(检查边的方向!)
- ✅ 处理了环检测
- ✅ 考虑了边界情况(空图、单节点、非连通分量)
- ✅ 分析了时间和空间复杂度
- ✅ 用小例子测试
最后提示: 拓扑排序出现在 5-10% 的 FAANG 图面试中。掌握两种方法(Kahn 和 DFS),你就能应对任何变体!