Skip to content

拓扑排序

图与搜索 ⭐⭐ 中级 🔥 高频

💡 核心要点

拓扑排序求有向无环图(DAG)中满足依赖关系的线性顺序。核心方法是 BFS(Kahn 算法):维护入度数组,每次取入度为 0 的节点。如果最终处理的节点数 < 总节点数,说明有环。


识别信号

有这些特征时,优先考虑拓扑排序:

  • 题目出现"先修/先后顺序/依赖关系"等描述
  • 问能否完成所有任务(即判断有向图是否有环)
  • 问满足依赖关系的执行顺序(课程顺序、编译顺序、构建顺序)
  • 从已知顺序反推字符/节点之间的关系(火星词典)

反例——以下情况不需要拓扑排序:

  • 无向图的连通性问题 → 用 BFS/DFS 或 Union-Find
  • 有向图的最短/最长路径(非依赖关系)→ Dijkstra / Bellman-Ford
  • 仅判断图是否连通,没有依赖顺序需求 → DFS/BFS 遍历即可
  • 题目约束是"无向"的,即使有先后描述 → 拓扑排序不适用

通用思考框架

第一步:确认是 DAG 上的依赖问题

  • 关键词:"先修"、"先后"、"依赖"、"能否完成"→ 考虑拓扑排序
  • 必须是有向图。无向图没有拓扑序,不要尝试拓扑排序
  • 必须检测环。有环的有向图(不是 DAG)不存在拓扑序,要作为失败情况处理

第二步:建图并计算入度

  • 边的方向要想清楚:题目说"b 是 a 的先修课"→ 边是 b → a(b 先,a 后)
  • 常见错误:把边方向搞反,建成 a → b,导致入度计算错误,最终顺序完全反
  • 建图的同时统计每个节点的 indegree[],不要分两次遍历
java
for (int[] pre : prerequisites) {
    // pre[1] 是先修课,pre[0] 是后续课
    graph.get(pre[1]).add(pre[0]);  // 边:先修 → 后续
    indegree[pre[0]]++;             // 后续课入度加一
}

第三步:选择算法

BFS Kahn 算法(面试推荐)

  • 直观:每次取"没有前置依赖"的节点(入度为 0)
  • 天然检测环:处理完后 count < n 就说明有环,无需额外代码
  • 代码结构清晰,面试中不容易写错

DFS 后序拓扑排序

  • 原理:后序遍历的逆序即拓扑序
  • 需要三色标记(未访问/访问中/已完成)才能正确检测环,代码更复杂
  • 除非题目有特殊需要,面试中优先选 BFS Kahn

Kahn 算法模板:

java
public List<Integer> topologicalSort(int numNodes, int[][] edges) {
    // 建图 + 统计入度
    List<List<Integer>> graph = new ArrayList<>();
    int[] indegree = new int[numNodes];
    for (int i = 0; i < numNodes; i++) graph.add(new ArrayList<>());
    for (int[] edge : edges) {
        graph.get(edge[0]).add(edge[1]);
        indegree[edge[1]]++;
    }

    // 入度为 0 的节点入队
    Queue<Integer> queue = new LinkedList<>();
    for (int i = 0; i < numNodes; i++) {
        if (indegree[i] == 0) queue.offer(i);
    }

    // BFS
    List<Integer> order = new ArrayList<>();
    while (!queue.isEmpty()) {
        int node = queue.poll();
        order.add(node);
        for (int next : graph.get(node)) {
            if (--indegree[next] == 0) {
                queue.offer(next);
            }
        }
    }

    // 处理节点数 < 总数 → 有环
    return order.size() == numNodes ? order : new ArrayList<>();
}

第四步:检查结果

  • 处理节点数 < 总节点数 → 图中有环 → 返回失败(空数组/false)
  • 拓扑序不唯一:某步有多个入度为 0 的节点时,选不同节点产生不同合法序
  • 需要字典序最小的拓扑序?→ 把 Queue 换成 PriorityQueue(小根堆)

典型例题:课程表(LeetCode 207

题目: 你需要修 numCourses 门课,给定先修关系 prerequisites[i] = [a, b] 表示学 a 之前要先学 b。判断能否修完所有课程。

1. 识别: 出现"先修关系" + "能否完成所有课程" → 检测有向图是否有环 → 拓扑排序。

2. 建图: pre[1] 是先修课,pre[0] 是后续课,边方向:pre[1] → pre[0],同时计算 indegree[pre[0]]++

3. 算法: BFS Kahn,每次取入度为 0 的课程,处理后减少后续课的入度。

4. 检查: count == numCourses 说明所有课都能被处理,无环,返回 true;否则有环,返回 false

java
public boolean canFinish(int numCourses, int[][] prerequisites) {
    List<List<Integer>> graph = new ArrayList<>();
    int[] indegree = new int[numCourses];
    for (int i = 0; i < numCourses; i++) graph.add(new ArrayList<>());

    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 course = queue.poll();
        count++;
        for (int next : graph.get(course)) {
            if (--indegree[next] == 0) {
                queue.offer(next);
            }
        }
    }

    return count == numCourses;
}
  • 时间复杂度:
  • 空间复杂度:

延伸题目

题号题名难度关键差异一句话提示
210课程表 II中等需返回具体顺序而非 true/false和 207 逻辑相同,改为记录并返回 order 数组
269火星词典困难需从单词顺序反推字符大小关系,先建图相邻单词比较首个不同字符,建有向边,再拓扑排序
329矩阵中的最长递增路径困难矩阵上的隐式 DAG,求最长路径拓扑排序按层推进统计最大深度,或记忆化 DFS
1462课程表 IV中等需判断任意两课程是否存在先后可达关系拓扑排序 + 可达性集合传播(或 Floyd-Warshall)

常见陷阱与调试

陷阱 1:边方向搞反

prerequisites[i] = [a, b] 中 b 是先修课,边应是 b → a。新手常建成 a → b,导致入度统计全部错误,排序结果完全相反。调试方法:画出 2-3 个节点的小例子,手动验证入度是否符合预期。

陷阱 2:忘记环检测

处理完 BFS 后忘记判断 count < numNodes,直接返回 order。当图有环时,环上的节点入度永远不会降为 0,不会入队,返回的结果是不完整的,却没有任何报错提示。

陷阱 3:DFS 环检测不用三色标记

用 DFS 拓扑排序时,仅用 visited[] 布尔数组无法区分"正在访问的祖先"和"已完成的节点",会产生误判。正确做法是三色标记:0 = 未访问,1 = 访问中(在当前 DFS 路径上),2 = 已完成。遇到状态为 1 的节点才是真正的环。


面试常问 & 怎么答

BFS 拓扑排序和 DFS 后序拓扑排序怎么选?

BFS(Kahn 算法)更直观,能同时判断有无环(处理节点数 < 总数就有环),面试中优先用 BFS。DFS 后序需要额外维护状态检测环(三色标记),代码更复杂。

拓扑排序的结果唯一吗?

不唯一。如果某一步有多个入度为 0 的节点,选择不同节点会产生不同的拓扑序。如果要求字典序最小的拓扑序,把 Queue 换成 PriorityQueue。


看到什么就先想到这类

  • "课程依赖/任务先后顺序" → 拓扑排序
  • "判断有向图是否有环" → 拓扑排序(处理节点数 < 总数)
  • "编译/构建顺序" → 拓扑排序
  • "从顺序推导关系" → 建图 + 拓扑排序