目录

题目描述

LCR 113. 课程表 II

题意分析

有编号 0numCourses - 1 的若干门课,prerequisites[i] = [a, b] 表示想学 a 就必须先学完 b。要求返回一种能学完全部课程的学习顺序;如果根本学不完,返回空数组。答案不唯一,只要满足所有先修约束即可。

这里和「课程表 I」的差别要先摆清楚:I 只问能不能学完,返回布尔值;II 要求把顺序本身交出来。也就是说,判定环的能力仍然需要,但还要在判定过程中把一条合法序列记录下来。

约束透露的信号有几层。课程用连续整数编号,说明所有的「表」都可以用数组而不是哈希;课程数最多两千、先修关系数量上限约为课程数的平方,说明 $O(n + m)$ 或 $O(n \log n)$ 级别都能过,但枚举顺序的指数级做法一定不行;题目没有承诺图是连通的,也没有承诺先修关系不重复。

边界有四类:prerequisites 为空时任意顺序都合法;只有一门课时直接返回 [0];出现形如 [a, a] 的自我依赖时必然无解;多个互不相连的分量要一起输出,不能只走出发点所在的那一块。

解法:BFS 拓扑排序

核心思路

最朴素的想法是枚举全部 n! 种学习顺序,逐个检查是否违反先修约束,显然不可行。稍微聪明一点的做法是每一轮扫描所有还没学的课程,找出「先修课都学完了」的那些学掉,这样虽然正确,但每轮都要重扫全部课程和全部边,最坏是 $O(n \cdot m)$。瓶颈很清楚:一门课的可学状态只会因为「它的某个先修课刚被学完」而改变,全量重扫做了大量无效检查。

关键观察是把先修关系画成有向边 b -> a(先修课指向后续课),那么「课程 a 现在可学」等价于「a 在图中所有指向它的边的起点都已被学完」,也就是等价于「a 的剩余入度为 0」。于是完全不必重扫:每学完一门课,只需沿着它的出边把后继课程的剩余入度各减一,恰好只有减到 0 的那些课变成新的可学课程,其余状态不受影响。这把每轮全扫压成了「每条边只被处理一次」。

由此得到贯穿全程的不变量:在任何时刻,indegree[x] 恰好等于「课程 x 的先修课中尚未写入结果序列的数量」,而结果序列 order 中的每一门课,其所有先修课都已经排在它前面。初始化时结果序列为空,indegree[x] 就是 x 的原始入度,不变量成立;每次从队列取出一门入度为 0 的课写入序列,它的每个后继的未完成先修数正好少一个,所以对应减一,不变量继续保持。

终止情况也由不变量直接推出:队列空意味着所有未写入的课程剩余入度都大于等于 1,即每个点都还有一个未完成的先修课。在有限点集里,沿着「未完成先修」一直往回走必然重复访问某个点,也就是必然存在环,所以此时无解。反过来,如果写入数量等于课程总数,序列本身就是一个合法的学习顺序。判环和产序在这里是同一次遍历的两个副产品,这正是本题相对课程表 I 只需多一个 order 数组的原因。

解题步骤

  • 建邻接表 graph 和入度数组 indegree。对每条 [a, b] 执行 graph[b].add(a)indegree[a]++,方向必须是先修课指向后续课,因为后面要「学完一门课去释放它的后继」。
  • 扫描所有课程,把入度为 0 的全部入队。这些课没有任何先修要求,是唯一能作为起点的候选;必须一次全放进去,否则会漏掉不连通的分量。
  • 循环取出队首课程,写入结果数组并让下标前移。出队顺序就是学习顺序,这一步是本题与只判环的版本唯一的实质差别。
  • 遍历该课程的所有后继,各自入度减一;只有减到 0 的才入队。减不到 0 说明它还有别的先修课没学,现在入队会破坏顺序合法性。
  • 循环结束后比较写入数量与课程总数:相等则返回结果数组,否则说明剩余课程互相成环,返回空数组。

numCourses = 4prerequisites = [[1,0],[2,0],[3,1],[3,2]] 走一遍:建图后 graph[0] = [1,2]graph[1] = [3]graph[2] = [3]graph[3] = [],入度为 indegree = [0,1,1,2]。初始化时只有课程 0 入度为 0,队列为 [0]order = []。第一轮取出 0order = [0];遍历后继 1,入度由 1 减到 0,入队;遍历后继 2,入度由 1 减到 0,入队;此时队列为 [1,2]indegree = [0,0,0,2]。第二轮取出 1order = [0,1];后继 3 的入度由 2 减到 1,未到 0 不入队。第三轮取出 2order = [0,1,2];后继 3 的入度由 1 减到 0,入队。第四轮取出 3order = [0,1,2,3],它没有后继。队列空,写入数量 4 等于课程总数,返回 [0,1,2,3]。若把 [1,0] 改成 [0,3],则 0 的入度变为 1,四门课入度全部大于 0,初始队列为空,一轮都进不去,写入数量 0 小于 4,返回空数组。

代码实现

class Solution {
    public int[] findOrder(int numCourses, int[][] prerequisites) {
        List<List<Integer>> graph = new ArrayList<>();
        for (int i = 0; i < numCourses; i++) {
            graph.add(new ArrayList<>());
        }

        int[] indegree = new int[numCourses];
        for (int[] edge : prerequisites) {
            graph.get(edge[1]).add(edge[0]);
            indegree[edge[0]]++;
        }

        Queue<Integer> queue = new ArrayDeque<>();
        for (int course = 0; course < numCourses; course++) {
            if (indegree[course] == 0) {
                queue.offer(course);
            }
        }

        int[] order = new int[numCourses];
        int idx = 0;
        while (!queue.isEmpty()) {
            int course = queue.poll();
            order[idx++] = course;
            // 学完当前课程后,释放所有依赖它的后续课程。
            for (int next : graph.get(course)) {
                indegree[next]--;
                if (indegree[next] == 0) {
                    queue.offer(next);
                }
            }
        }
        return idx == numCourses ? order : new int[0];
    }
}
func findOrder(numCourses int, prerequisites [][]int) []int {
    graph := make([][]int, numCourses)
    indegree := make([]int, numCourses)
    for _, edge := range prerequisites {
        course := edge[0]
        pre := edge[1]
        graph[pre] = append(graph[pre], course)
        indegree[course]++
    }

    queue := make([]int, 0)
    for course := 0; course < numCourses; course++ {
        if indegree[course] == 0 {
            queue = append(queue, course)
        }
    }

    order := make([]int, 0, numCourses)
    for head := 0; head < len(queue); head++ {
        course := queue[head]
        order = append(order, course)
        // 入度降为 0 时,说明该课程的先修课已经全部完成。
        for _, next := range graph[course] {
            indegree[next]--
            if indegree[next] == 0 {
                queue = append(queue, next)
            }
        }
    }
    if len(order) != numCourses {
        return []int{}
    }
    return order
}

复杂度分析

  • 时间复杂度:$O(n + m)$,n 为课程数、m 为先修关系数。建图遍历每条边一次,初始化扫描每个点一次;主循环中每个点最多入队出队一次(入度只减不增,减到 0 仅发生一次),每条边在其起点出队时被处理一次。
  • 空间复杂度:$O(n + m)$。邻接表存下全部 m 条边,入度数组与队列各占 $O(n)$,结果数组同为 $O(n)$。

关键点总结

  • 「找一个满足偏序约束的线性顺序」这类问题,通用建模是把约束变成有向边、把「可执行」变成「剩余入度为 0」,任务调度、编译依赖、事件排期都能套这一层抽象。
  • 增量维护优于全量重扫:状态只在相邻元素变化时才可能改变,就该沿边推送更新,而不是每轮重新判断所有对象。
  • 判环与产序可以合并:入度法在同一次遍历里既得到序列,又通过「写入数量是否等于点数」得到有环判定,不需要额外跑一次环检测。
  • 初始队列必须放入全部零入度点,这是保证不连通分量都被覆盖的唯一入口。
  • 面试视角:本题最常见的追问是「和课程表 I 有什么区别」,标准回答是算法框架完全相同,只是把计数器换成记录出队序列;其次是「BFS 和 DFS 两种拓扑排序怎么选」,可以答 DFS 需要三色标记判环并把结果逆序输出,边界更易写错,而 BFS 的入度法状态直观、天然给出正序,白板上更稳;若追问「要求字典序最小的顺序」,把队列换成小根堆即可,代价是复杂度升到 $O(n \log n + m)$。

易错点总结

  • 错误写法:把边建成 graph[a].add(b),即后续课指向先修课 → 对 numCourses = 2prerequisites = [[1,0]],入度为 0 的会变成课程 1,输出 [1,0],恰好把先修顺序整个颠倒。
  • 错误写法:只把「某一个」入度为 0 的课程入队就开始循环 → 输入 numCourses = 4prerequisites = [[1,0],[3,2]] 时,只放入 0 会漏掉另一条链的起点 2,写入数量小于课程总数,被误判成有环并返回空数组。
  • 错误写法:后继入度减一后无条件入队 → 该课程会在剩余先修课学完前就被写入序列,且可能被重复写入多次,结果数组越界或顺序非法。
  • 错误写法:循环结束后直接返回 order 而不比较数量 → 存在环时数组尾部留着未填的默认值 0,会返回一串带有重复 0 的非法序列,而不是要求的空数组。
  • 错误写法:用「prerequisites 长度是否为 0」之类的条件判断有没有环 → 环的存在与边数多少无关,[[0,1],[1,0]] 只有两条边却无解。
  • 错误写法:把 Java 的判环条件写成 queue.isEmpty() && idx < numCourses 之外的启发式判断,例如统计剩余入度之和是否为 0 之后又忘记返回空数组 → 有环用例下仍然返回了部分序列,判定为答案不完整。
  • 错误写法:邻接表按课程数分配,却用先修关系的下标去索引,或忘记为每门课初始化空列表 → Java 侧访问未初始化的元素抛空指针,Go 侧则会因为切片长度不足而越界。
  • 错误写法:假设图连通、只从课程 0 出发做一次遍历 → 多个独立分量时只输出了一部分课程,数量校验失败。
  • 错误写法:认为答案唯一,据此写死某个期望序列去自测 → 本题接受任意合法顺序,用固定字符串比对会把正确实现误判成错误。

相似题目

题目 难度 考察点
207. 课程表 中等 同一套入度法,但只需返回能否学完,不必记录出队顺序
269. 火星词典 困难 难点在从相邻单词的首个差异字符推出边,建图本身就是主要工作量
310. 最小高度树 中等 无向图上按度为 1 逐层剥叶子,是入度法在树上的对称变形
444. 序列重建 中等 要求拓扑序唯一,判定条件变成队列中始终最多只有一个元素
630. 课程表 III 困难 与依赖无关,是按截止时间排序加大根堆反悔的贪心
1462. 课程表 IV 中等 回答任意两点的可达性查询,需要传递闭包或在拓扑过程中传播祖先集
LCR 114. 火星词典 困难 269 的 LCR 版本,同样要处理前缀更长的非法输入
LCR 115. 序列重建 中等 444 的 LCR 版本,判定唯一性的写法一致
面试题 04.01. 节点间通路 中等 只判断有向图中两点是否可达,用搜索即可,不需要排出全序