中文 Walking in Code

拓扑排序完全指南:DFS 和 Kahn 算法详解

通过交互式可视化掌握拓扑排序。学习 DFS 和 Kahn 算法两种方法,解决 LeetCode 问题,轻松应对 FAANG 面试。

#algorithm #graph #topological-sort #dfs #bfs #interview #leetcode #visualization

拓扑排序完全指南

什么是拓扑排序?

拓扑排序是一种针对**有向无环图(DAG)**的算法,它能生成顶点的线性排序,使得对于每条有向边 (u,v)(u, v),顶点 uu 在排序中都出现在顶点 vv 之前。

为什么面试官喜欢考它?

  • 基础图算法,用于任务调度、依赖解析和构建系统
  • 测试对图遍历的理解(DFS/BFS)
  • FAANG 面试中以各种形式出现(课程表、任务排序、构建依赖)
  • 结合多个概念:图、环检测、排序
  • 两种不同的实现方法(DFS 和 Kahn 算法)

何时使用拓扑排序

在以下情况下使用拓扑排序:

  • 任务或项目之间存在依赖关系
  • 需要找到一个有效的顺序来满足依赖关系
  • 有一个有向无环图(必须验证无环)
  • 问题涉及先决条件课程安排构建顺序

经典例子:

  • 课程先修关系(学习课程 A 之前必须先学课程 B)
  • 包依赖管理
  • 带约束的任务调度
  • 模块编译顺序

可视化直觉

想象你在规划早晨的例行活动:

  • 穿袜子之前不能穿鞋
  • 准备早餐之前不能吃早餐
  • 穿衣服之前不能出门

这些依赖关系形成一个有向图

         起床
        /       \
       /         \
   准备早餐    穿衣服
       \        /     \
        \    穿袜子  穿衬衫
         \      \    /
          \    穿鞋
           \    /
           出门

拓扑排序给出一个有效的顺序:起床 → 准备早餐 → 穿衣服 → 穿袜子 → 穿衬衫 → 穿鞋 → 出门

关键特性

  1. 仅适用于 DAG(有环则无法排序)
  2. 可能存在多个有效排序
  3. 时间复杂度: O(V+E)O(V + E) 其中 VV = 顶点数,EE = 边数
  4. 两种主要方法: 基于 DFS 和基于 BFS(Kahn 算法)

交互式可视化:Kahn 算法

Kahn 算法使用 BFS 和入度计数。它重复找到入度为 0 的节点(没有前置依赖)并处理它们。

Kahn's Algorithm Topological Sort

Step 0 of 18Calculate in-degree for each node
0in: 21in: 22in: 13in: 14in: 05in: 0
Current
In Result
Visited

In-Degree

Node 0:2
Node 1:2
Node 2:1
Node 3:1
Node 4:0
Node 5:0

Topological Order

Empty

Visited

{}

工作原理:

  1. 计算每个节点的入度(入边数量)
  2. 将所有入度为 0 的节点入队
  3. 当队列不为空时:
    • 出队一个节点并添加到结果中
    • 对每个邻居,将其入度减 1
    • 如果邻居的入度变为 0,将其入队
  4. 如果结果包含所有节点 → 成功;否则 → 检测到环

交互式可视化:DFS 方法

基于 DFS 的方法尽可能深入探索,然后以逆后序将节点添加到结果中。

DFS-based Topological Sort

Step 0 of 14Initialize DFS-based topological sort
012345
Current
In Result
Finished
Visited

Stack

Empty

Topological Order

Empty

Visited

{}

工作原理:

  1. 标记所有节点为未访问
  2. 对每个未访问的节点运行 DFS
  3. 访问完一个节点的所有后代后,将其压入栈
  4. 反转栈得到拓扑排序

通用模式:识别拓扑排序问题

问题特征

在以下情况使用拓扑排序:

  • 提到”先决条件”或”依赖关系”
  • 需要确定”顺序”或”序列”
  • 带约束的有向图
  • “课程表”、“任务调度”、“构建顺序”
  • 需要检测排序是否可行(环检测)

解题步骤

  1. 根据输入构建图(邻接表)
  2. 选择算法: Kahn(更容易检测环)或 DFS(更直观)
  3. 运行算法获得拓扑排序
  4. 检查有效性: 结果应包含所有节点
  5. 根据问题要求返回结果

代码模板

Kahn 算法(基于 BFS)

Loading...

基于 DFS 的拓扑排序

Loading...

三色标记法 DFS(推荐)

三色标记法是一种更优雅的 DFS 实现,使用单个状态数组替代两个布尔数组。这种方法在图算法中被广泛使用,特别适合环检测。

三种颜色状态:

  • 白色 (0): 未访问的节点
  • 灰色 (1): 正在访问的节点(在当前 DFS 路径中)
  • 黑色 (2): 已完成访问的节点

关键直觉:

  • 如果在 DFS 过程中遇到灰色节点 → 发现后向边 → 存在环
  • 如果遇到黑色节点 → 该节点及其子树已完全处理 → 安全跳过
  • 如果遇到白色节点 → 继续 DFS 探索

优势:

  • 代码更简洁(只需一个状态数组)
  • 更容易理解和解释
  • 是图算法中的经典模式
  • 环检测逻辑更清晰
Loading...

三色标记法 vs 双布尔数组:

方面三色标记法visited + inStack
数组数量1 个状态数组2 个布尔数组
空间使用稍少(单数组)稍多(两数组)
代码清晰度⭐ 更清晰直观需要理解两个标志
环检测检查灰色节点检查 inStack
行业标准⭐ 经典图算法模式同样有效

推荐: 在面试中,如果你熟悉三色标记法,使用它会显得更专业。它是图算法教材中的标准方法。

LeetCode 问题示例

问题一:课程表 II (LC 210)

问题: 现在你总共有 numCourses 门课需要选,记为 0numCourses - 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 算法的解决方案:

Loading...

复杂度分析:

  • 时间: O(V+E)O(V + E) 其中 VV = 课程数,EE = 先修关系数
  • 空间: O(V+E)O(V + E) 用于图和辅助数据结构

关键点:

  • 图的方向: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

解决方案:

Loading...

复杂度分析:

  • 时间: O(C)O(C) 其中 CC = 所有单词的总长度
  • 空间: O(1)O(1)O(26)O(26) 对于英文字母

关键点:

  • 只比较已排序字典中的相邻单词
  • 只有第一个不同的字符创建排序约束
  • 边界情况:如果 word1word2 的前缀但更长 → 无效输入
  • 可能存在多个有效排序;任何一个都可以

问题三:最小高度树 (LC 310)

问题: 树是一个无向图,其中任何两个顶点只通过一条路径连接。给定这样一棵有 n 个节点的树,节点标记为 0n - 1,找到所有能产生最小高度树的根节点标签。

示例:

输入: n = 6, edges = [[3,0],[3,1],[3,2],[3,4],[5,4]]
输出: [3,4]

树结构:
     0  1  2
      \ | /
        3
        |
        4
        |
        5

解决方案(从叶子开始拓扑排序):

Loading...

复杂度分析:

  • 时间: O(n)O(n) 其中 nn = 节点数
  • 空间: O(n)O(n) 用于图

关键点:

  • 这是无向树上的拓扑排序变体
  • 逐层修剪叶子直到剩余 1 或 2 个节点
  • 剩余的节点是树的中心点
  • 最多有 2 个节点可以作为最小高度树的根

边界情况和常见错误

需要处理的边界情况

  1. 空图: 没有节点 → 返回空结果
  2. 单节点: 只有一个节点 → 返回该节点
  3. 环检测: 如果存在环 → 没有有效的拓扑排序
  4. 非连通图: 如果需要,处理多个连通分量
  5. 自环: 通常对拓扑排序无效
  6. 多重边: 同一对节点之间有多条边

常见错误

错误 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; // 这是错的!

正确: 同时使用 visitedinStack

if (!visited[neighbor]) {
    if (hasCycle(neighbor)) return true;
} else if (inStack[neighbor]) {
    return true; // 后向边 = 环
}

面试技巧

如何解释拓扑排序

解释框架:

  1. 定义问题: “拓扑排序对 DAG 中的节点排序,使依赖关系在前”
  2. 说明假设: “图必须是有向无环的”
  3. 选择方法: “我将使用带入度计数的 Kahn 算法” 或 “我将使用带后序遍历的 DFS”
  4. 解释环检测: “如果无法处理所有节点,则存在环”
  5. 分析复杂度:O(V+E)O(V + E) 时间,O(V+E)O(V + E) 空间”

当面试官要求优化时

常见后续问题:

  1. “你能检测环并返回环路径吗?”

    • 使用带递归栈追踪的 DFS
    • 发现后向边时,从栈重建路径
  2. “如果有多个有效排序?返回所有排序。”

    • 使用 DFS 回溯
    • 探索所有可能的排序(指数时间)
  3. “如果图非常大且无法放入内存怎么办?”

    • 使用外部排序技术
    • 分块处理图
    • 使用基于磁盘的图数据库
  4. “你能找到字典序最小的拓扑排序吗?”

    • 在 Kahn 算法中使用优先队列(最小堆)代替普通队列
    • 时间变为 O(VlogV+E)O(V \log V + E)

时间和空间复杂度

Kahn 算法(基于 BFS)

时间复杂度: O(V+E)O(V + E)

  • 构建图:O(E)O(E)
  • 计算入度:O(E)O(E)
  • 处理每个顶点一次:O(V)O(V)
  • 处理每条边一次:O(E)O(E)
  • 总计: O(V+E)O(V + E)

空间复杂度: O(V+E)O(V + E)

  • 邻接表:O(E)O(E)
  • 入度数组:O(V)O(V)
  • 队列:最坏情况 O(V)O(V)
  • 结果数组:O(V)O(V)
  • 总计: O(V+E)O(V + E)

基于 DFS 的方法

时间复杂度: O(V+E)O(V + E)

  • DFS 访问每个顶点一次:O(V)O(V)
  • DFS 探索每条边一次:O(E)O(E)
  • 总计: O(V+E)O(V + E)

空间复杂度: O(V+E)O(V + E)

  • 邻接表:O(E)O(E)
  • 访问数组:O(V)O(V)
  • 递归栈:最坏情况 O(V)O(V)
  • 结果栈:O(V)O(V)
  • 总计: O(V+E)O(V + E)

最佳、平均、最坏情况

两种算法都有:

  • 最佳情况: O(V+E)O(V + E) - 线性依赖链
  • 平均情况: O(V+E)O(V + E) - 典型的 DAG 结构
  • 最坏情况: O(V+E)O(V + E) - 完全连通的 DAG

注意: 无论图结构如何,时间复杂度始终是 O(V+E)O(V + E),这使得拓扑排序非常高效!

何时不使用拓扑排序

❌ 不要在以下情况使用拓扑排序:

  1. 图有环

    • 拓扑排序需要 DAG
    • 使用环检测算法
    • 或找强连通分量(Tarjan/Kosaraju 算法)
  2. 无向图

    • 拓扑排序需要有向边
    • 考虑:BFS、DFS 或生成树算法
  3. 需要最短路径

    • 拓扑排序给出排序,不是距离
    • 使用:Dijkstra、Bellman-Ford 或 A*
    • 例外:可以在 DAG 上使用拓扑排序 + DP 求最短路径
  4. 需要所有可能的排序

    • 标准拓扑排序只给出一个排序
    • 使用:带 DFS 的回溯(指数时间)
  5. 图的实时更新

    • 拓扑排序不是增量的
    • 考虑:动态拓扑排序算法

更好的替代方案

你的需求不要用拓扑排序用这个
找环拓扑排序DFS 后向边检测
最短路径拓扑排序 + BFSDijkstra 算法
所有排序单次拓扑排序回溯
强连通分量拓扑排序Tarjan 或 Kosaraju
无向图遍历拓扑排序BFS 或 DFS

Kahn vs DFS:选择哪个?

方面Kahn 算法基于 DFS
方法带入度的 BFS后序 DFS
直觉移除无依赖的节点先完成子节点再处理父节点
环检测更容易(计数处理的节点)需要额外的 inStack 数组
空间O(V+E)O(V + E)O(V+E)O(V + E) + 递归栈
迭代 vs 递归迭代(队列)递归(可以改为迭代)
字典序最小容易(使用最小堆)更难
面试偏好⭐ 初学者更直观⭐ 更优雅,代码更少

建议:

  • 如果需要显式检测环或需要迭代解决方案,使用 Kahn 算法
  • 如果你熟悉递归且想要更简洁的代码,使用基于 DFS 的方法

总结:面试关键要点

模式识别

在以下情况使用拓扑排序:

  • 依赖关系、先决条件或排序约束
  • “课程表”、“任务顺序”、“构建依赖”
  • 需要检测是否存在有效排序(环检测)
  • 有向无环图(DAG)结构

需要记住的模板

Kahn 算法(4 步):

  1. 构建图 + 计算入度
  2. 将所有入度为 0 的节点入队
  3. 当队列不为空时:出队,添加到结果,减少邻居的入度
  4. 检查 result.length == numNodes(环检测)

DFS 方法(4 步):

  1. 构建图
  2. 从每个未访问的节点进行 DFS
  3. 访问完所有后代后,将节点压入栈
  4. 反转栈 = 拓扑排序

面试关键点

  1. 始终检查环 - 拓扑排序只适用于 DAG
  2. 明确边的方向 - [a, b] 表示什么?(a → b 还是 b → a?)
  3. 处理多个连通分量 - 从所有未访问的节点进行 DFS
  4. 时间复杂度: O(V+E)O(V + E) - 非常高效!
  5. 空间复杂度: O(V+E)O(V + E) - 需要存储图

离开面试前

确保你已经涵盖:

  • ✅ 正确构建了图(检查边的方向!)
  • ✅ 处理了环检测
  • ✅ 考虑了边界情况(空图、单节点、非连通分量)
  • ✅ 分析了时间和空间复杂度
  • ✅ 用小例子测试

最后提示: 拓扑排序出现在 5-10% 的 FAANG 图面试中。掌握两种方法(Kahn 和 DFS),你就能应对任何变体!