代表性面试主题

如何用拓扑排序解决课程表 II?

编程题中等
OfferCC 编辑团队发布 更新

题干

给定 num_courses 门课程和先修关系 [course, prerequisite],返回任意一个能完成全部课程的学习顺序;若依赖中存在环,则返回空列表。请实现算法、证明正确性并分析复杂度。

题干与适用场景

有 num_courses 门课程,编号为 0 到 num_courses - 1。每个先修关系 [course, prerequisite] 表示必须先学 prerequisite,才能学 course。请返回任意一个能完成 全部课程的顺序;如果不存在这样的顺序,返回空列表。

本题约定 0 <= num_courses <= 2000,课程编号合法,每对关系互不相同,且不存在自己依赖自己 的输入边。num_courses == 0 时返回空列表。对于 num_courses = 4, prerequisites = [[1, 0], [2, 0], [3, 1], [3, 2]], [0, 1, 2, 3] 和 [0, 2, 1, 3] 都正确;对于 [[1, 0], [0, 1]],两门课互相依赖,只能返回空列表。

这道题适用于通用软件工程编码面试,核心是把自然语言里的依赖转换成有向图,再判断图是否为 有向无环图。基础任务只要求任意合法顺序,不要求字典序最小,也不计算每门课的耗时或并发容量。

面试官考察点

第一项信号是边的方向。[course, prerequisite] 应建立 prerequisite -> course,因为完成先修课后才能释放后续课程。反向建图仍可能跑出一个排列, 但排列表达的是相反约束,无法回答题目。

第二项信号是能否从“当前可以选的课程”推导入度。某门课的入度表示尚未满足的直接先修课数量。 入度为零时才能进入候选队列;完成一门课后,只减少它直接后继的入度。强回答会说清这个状态含义, 而不是只背诵“用 BFS”。

第三项信号是环检测。不能因为队列变空就直接返回已得到的部分顺序。只有结果长度等于课程总数时, 全部节点才被处理;长度不足说明剩余子图没有零入度节点,其中必然存在有向环。

面试官还会检查复杂度、边界和契约意识。邻接表实现需要 O(V + E) 时间和空间;使用 deque.popleft() 能保持队首删除为常数时间。多种合法顺序、互不连通的课程、空输入和长依赖链都 应在测试中出现。

回答前需要澄清的问题

  • 返回任意顺序还是字典序最小顺序? 任意顺序使用普通队列即可;字典序最小需要最小堆,

时间会变为 O(E + V log V)。

  • 先修关系是否可能重复? 本题保证互不相同。如果不保证,可以保留重复边并同步累计入度,

或在建图时去重;不能只去重一侧,否则入度永远减不到零。

  • 输入是否可能包含非法编号或自环? 本题假设调用方已经校验。需要防御式 API 时,应先定义

报错还是返回空列表,避免把“输入无效”和“存在依赖环”混成同一种结果。

  • 只需要一条顺序,还是全部合法顺序? 一条顺序是线性图遍历;枚举全部顺序要在每一步尝试

所有零入度课程,最坏输出量接近阶乘级。

  • 课程能否并行学习? 基础题返回线性排列。若要最少学期数且每学期容量无限,应按队列层级

批量处理;如果课程有时长,则要在 DAG 上计算最长依赖路径。

  • 规模能否放入内存? 在 V <= 2000 下邻接表足够直接。边无法全部放入内存时,需要外部存储

或分区处理,已经超出这道编码题的边界。

30 秒回答框架

“我会把每门课作为节点,把 [course, prerequisite] 建成 prerequisite -> course,并统计每门课的入度。先把所有零入度课程放进队列;每次取出一门课加入 结果,遍历它的后继并把入度减一,新变成零的课程再入队。队列里的课程恰好是当前所有先修要求都 已满足的未处理课程,所以按这个顺序选择始终安全。结束时如果结果长度等于课程数,就返回结果; 否则剩余节点包含环,返回空列表。邻接表方案的时间和额外空间都是 O(V + E)。”

分步骤深入解答

最直接的思路是反复扫描所有未选课程,找到先修课都已出现的课程。这种做法即使使用集合记录已 完成课程,每轮仍可能检查全部边;在长链输入中要做 V 轮,时间可能达到 O(VE)。瓶颈来自反复 重新计算同一依赖是否已经满足。

Kahn 算法把这项信息增量维护为入度。设 graph[u] 保存完成课程 u 后可能被释放的课程, indegree[v] 保存课程 v 尚未满足的直接先修课数。建图时遇到 [course, prerequisite],把 course 加入 graph[prerequisite],同时增加 indegree[course]。

算法维护两个不变量:

  1. indegree[v] 等于从尚未处理节点指向 v 的边数。
  2. 队列包含且只包含所有尚未处理、剩余入度为零的课程。

初始统计显然满足第一个不变量,把全部零入度节点入队后也满足第二个。取出课程 u 时,它没有 来自未处理课程的先修约束,因此把它放进结果是安全的。删除 u 的影响等价于遍历 graph[u] 并减少每个后继的入度;某个后继第一次变成零时入队,两项不变量继续成立。

python
from collections import deque


def find_course_order(
    num_courses: int,
    prerequisites: list[list[int]],
) -> list[int]:
    graph = [[] for _ in range(num_courses)]
    indegree = [0] * num_courses

    for course, prerequisite in prerequisites:
        graph[prerequisite].append(course)
        indegree[course] += 1

    ready = deque(
        course for course, degree in enumerate(indegree) if degree == 0
    )
    order: list[int] = []

    while ready:
        course = ready.popleft()
        order.append(course)

        for dependent in graph[course]:
            indegree[dependent] -= 1
            if indegree[dependent] == 0:
                ready.append(dependent)

    return order if len(order) == num_courses else []

若所有课程都被取出,不变量保证每门课加入结果时其先修课都已经在前面,因此结果合法。若结果短于 V,剩余有限子图中每个节点的剩余入度都大于零。从任意节点不断沿一条入边向前追溯,有限节点 必然使某个节点重复出现,重复段构成有向环;所以不可能完成全部课程。这也说明长度检查同时完成了 环检测。

每个节点入队、出队至多一次,每条边在建图和释放后继时各处理一次,时间为 O(V + E)。 邻接表、入度数组、队列和结果合计占 O(V + E) 空间。这里使用 deque,因为从 Python 列表头部 pop(0) 会移动其余元素,可能把队列操作退化为线性时间。

对抗性验证应检查结果本身,而不只比较某个固定答案:结果必须包含恰好 V 个不重复编号,并且 每个 [course, prerequisite] 中 prerequisite 的位置都小于 course。测试集至少包括空输入、 单节点、全部独立、单条长链、菱形依赖、多个互不连通分量和有向环。菱形图尤其能防止把“任意合法 顺序”误写成“必须匹配一个固定数组”。

DFS 也能完成拓扑排序:用白、灰、黑三色标记,遇到指向灰色节点的边即发现环,节点退出递归时 加入结果,最后反转后序。它适合同时返回一条具体环路径,但 Python 中长链可能触发递归深度限制。 Kahn 算法直接暴露“当前可学课程集合”,更容易扩展到并行学期,因此是本题更直接的首选。若规模 很小且题目只问可行性,反复扫描的简单方案可以更短;面试中应明确它的最坏复杂度,而非把它包装成 线性算法。

高质量示范回答

“我先确认只需返回任意合法顺序,并假设课程编号和依赖边都有效且不重复。每个关系 [course, prerequisite] 表示一条从先修课指向课程的边。这样,课程的入度就是还有多少直接先修课 没有完成。

我会建立邻接表和入度数组,把所有入度为零的课程加入 deque。循环中从队首取一门课放入答案, 再遍历它的后继,把这些后继的入度减一;只有在入度刚变成零时才入队。关键不变量是:队列中恰好 是当前没有未完成先修要求的课程,所以每次选择都不会违反依赖。

循环结束后不能无条件返回。答案长度等于课程总数时,所有依赖都满足;长度不足时,剩余节点都还有 入边,在有限图中沿入边追溯一定会回到访问过的节点,因此存在环,我返回空列表。

邻接表让每个节点和每条边只被处理常数次,时间为 O(V + E),空间也是 O(V + E)。我会用空图、 单节点、长链、菱形多解、断开的分量和二节点环测试。验证多解时,我会检查每条先修边的相对位置, 不会把结果限定成某一个固定排列。”

常见错误

  • 把边建成 course -> prerequisite → 输出可能把课程放在先修课之前 → **按“完成哪个节点会释放

哪些节点”建立 prerequisite -> course。**

  • 只从课程 0 开始遍历 → 独立分量会被漏掉 → 初始化时扫描全部节点,把所有零入度节点入队。
  • 队列为空就返回部分结果 → 有环输入被误报为成功 → **只有 len(order) == num_courses 才返回

顺序。**

  • 同一节点多次入队 → 结果出现重复课程 → 只在入度从 1 降到 0 的那一次入队。
  • 用 list.pop(0) 实现队列 → 长输入中反复移动元素 → 使用 deque.popleft()。
  • 拿输出和一个固定拓扑序比较 → 另一条合法顺序被判错 → 检查唯一性、长度和每条边的相对位置。
  • 图去重但入度不去重,或反过来 → 重复边契约下计数不一致 → **要么完整保留重复边,要么建图时

对边统一去重。**

  • 声称额外空间是 O(V) → 邻接表还保存了全部边 → 按稀疏图实现报告 O(V + E)。
  • 用 DFS 却没有访问中状态 → 环上的节点会被重复递归或错误完成 → **至少使用三色状态区分正在

访问和已经完成。**

追问及应对

追问 1:如何返回字典序最小的合法顺序?

把普通队列替换成最小堆。每次从所有当前零入度课程中取编号最小者,就能通过贪心交换论证得到 字典序最小结果。边处理仍为 O(E),节点入堆和出堆使时间变为 O(E + V log V);如果只需任意 顺序,普通队列更简单且更快。

追问 2:如果每学期可以同时学习任意多门课程,最少需要几个学期?

按队列的当前长度分层处理:一层中的课程在这一学期同时完成,它们释放的零入度后继进入下一层。 每完成一层,学期数加一。这个结论依赖所有课程耗时相同且并发容量无限;若每学期最多选 k 门课, 简单分层不再保证全局最优。

追问 3:每门课程耗时不同,怎样得到最早毕业时间?

先取得拓扑序,再按该顺序做 DAG 动态规划。课程的最早开始时间是所有先修课最早完成时间的最大值, 加上自身耗时得到最早完成时间。答案是全部课程最早完成时间的最大值。不能直接用 Kahn 算法的层数, 因为一门耗时十周的课和一门耗时一周的课并不等价。

追问 4:如何返回导致失败的一条具体依赖环?

Kahn 算法能判断剩余子图有环,但不会直接保留环路径。可以在剩余节点上运行三色 DFS,并记录父节点; 遇到指向灰色节点的边时,沿父指针回溯即可构造一条环。若诊断信息是核心需求,也可以从一开始使用 带父指针的 DFS 拓扑排序。

追问 5:不断新增依赖边时,如何维护顺序?

低频更新时,加入新边后重新运行 O(V + E) 拓扑排序最可靠,也最容易验证。更新频繁且图很大时, 可以维护节点当前位置:若新边已符合当前顺序,无需调整;否则只在受影响区间内检查可达性并重排。 动态拓扑排序实现复杂,面试中应先用更新频率和图规模证明它值得引入。

追问 6:如何枚举所有合法学习顺序?

使用回溯:每一步枚举所有当前零入度节点,暂时选择一个、更新后继、递归,再恢复入度。它会避免输出 非法排列,但合法顺序数量本身可能接近 V!,所以时间至少与输出规模成正比。需要先确认上限很小, 并讨论是否只需计数、采样或前 k 个结果。

公开来源

同类题目

相关面试工具

用 Screenshot 处理算法题

截图题目后,按顺序看约束、解法、代码、边界条件和复杂度。

查看工具