本文带有 todo 标签,表示尚未完成本人复刷。刷完后可删除 frontmatter 中的 todo。
把每门课程看成一个节点。对于先修关系:
建立有向边:
indegree[course] 表示这门课还有多少门先修课没有完成。
Kahn 拓扑排序不断学习入度为 0 的课程,并删除它指向的依赖边:
numCourses 门课程,图中没有环,可以完成全部课程。这题虽然使用队列,但不是求最短路,也不需要按 BFS 层数更新答案。
共有 numCourses 门课程,编号为 0 到 numCourses - 1。
先修关系:
表示学习课程 a 前必须先完成课程 b。判断是否存在一种顺序完成所有课程。
示例 1:
可以按 0 → 1 的顺序学习。
示例 2:
课程 0 和 1 互相依赖,形成有向环。
题目约束:
1 <= numCourses <= 2000。0 <= prerequisites.length <= 5000。[a, b] 的含义是“先学 b,才能学 a”,所以自然的执行方向是:
代码中:
nextCourses[b] 保存完成课程 b 后可能被解锁的课程。
indegree[a] 是课程 a 尚未满足的先修条件数量。
0:现在就能学习。0:至少还有一门先修课没完成。1。入度减到 0 的瞬间,说明该课程所有先修条件都已满足,可以入队。
如果依赖图中存在环:
那么学习 A 前要先完成环中的另一门课程,沿环追溯永远找不到可以最先学习的课程。
如果图中没有环,它是有向无环图。任何有限有向无环图都至少存在一个入度为 0 的节点。删除它及其出边后,剩余图仍是有向无环图,可以继续这一过程,直到删除所有节点。
所以:
维护:
nextCourses:邻接表,保存每门课程的后继。indegree:当前尚未删除的依赖边数量。queue:已经满足全部先修条件、等待处理的课程。completed:已经从队列中处理的课程数量。循环过程中保持:
0。indegree 始终对应尚未处理子图中的真实入度。0 时入队一次。考虑:
图为:
初始入度:
| 课程 | 0 | 1 | 2 | 3 |
|---|---|---|---|---|
| 入度 | 0 | 1 | 1 | 2 |
处理过程:
| 出队课程 | 入度变化 | 新入队课程 | completed |
|---|---|---|---|
0 | 课程 1、2:1 → 0 | 1, 2 | 1 |
1 | 课程 3:2 → 1 | — | 2 |
2 | 课程 3:1 → 0 | 3 | 3 |
3 | 无后继 | — | 4 |
最终 completed === numCourses,所以返回 true。
对于环:
两个节点初始入度都是 1,队列从一开始就是空的,无法处理任何课程,返回 false。
numCourses 的邻接表和入度数组。[course, prerequisite]:
prerequisite → course。course 的入度。0 的课程加入队列。completed++。1。0 时,将它入队。completed === numCourses。原文章的 JavaScript 参考实现来源:JoshCrozier/leetcode-javascript。原实现采用 DFS 判环,本文改为 Kahn BFS,使代码与主解法和正文保持一致;原项目采用 MIT License。
使用头指针读取队列,不调用 Array.shift(),避免反复移动数组元素。
| 代码 | 作用 |
|---|---|
nextCourses[prerequisite].push(course) | 建立“完成先修课后解锁课程”的有向边 |
indegree[course]++ | 记录课程尚未满足的先修条件 |
初始扫描 indegree === 0 | 找出当前可以直接学习的课程 |
queue[head++] | 以摊还 O(1) 方式取出可学习课程 |
indegree[course]-- | 删除一条已经满足的依赖边 |
completed === numCourses | 判断是否生成了包含全部节点的拓扑序 |
课程只有在入度变为 0 时才会入队。根据入度定义,此时所有指向它的先修边都已随着对应先修课程的完成而删除。
所以每门出队课程的全部先修课都已处理,出队顺序是一个合法的拓扑序前缀。
如果 completed === numCourses,出队顺序包含每一门课程,并且根据上一条性质,每门课程都出现在其所有后继之前。因此存在合法学习顺序,应返回 true。
如果队列为空但仍有未处理课程,剩余子图中的每个节点入度都大于 0。
从任意剩余节点沿一条入边不断向前追溯。剩余节点有限,最终必然重复访问某个节点,形成有向环。环中课程互相等待,无法完成全部课程,应返回 false。
因此算法的返回值与题意完全一致。
0,返回 true。prerequisites 中,也必须加入初始队列并计入 completed。0,返回 false。[a, a],课程 a 构成自环,无法完成。[a, b] 应按执行顺序建立 b → a。indegree[a]++,不是增加 b。shift(): 使用头指针保持线性总时间。设:
V = numCourses。E = prerequisites.length。建图需要 O(V + E) 时间。每门课程至多入队、出队一次,每条依赖边恰好用于一次入度递减,因此:
O(V + E)。O(V + E),用于邻接表、入度数组和队列。队列数组会保留已经读取的槽位直到函数结束,所以其分配规模为 O(V),仍包含在上述空间界限内。
为每个节点维护:
0:尚未访问。1:正在当前递归路径中访问。2:已经完成,确认从它出发无环。DFS 遇到状态 1 的节点时说明存在回边和有向环;遇到状态 2 可以直接复用结果。时间和空间同样是 O(V + E)。
JavaScript 中递归深度受运行环境调用栈限制。虽然本题最多 2000 门课程通常可以运行,迭代式 Kahn 算法没有递归栈风险。
本题只返回布尔值。如果要解决 210「课程表 II」,把每次出队课程依次加入答案数组:
V:返回该数组。V:存在环,返回空数组。[a, b] 应该建立哪条边?学习 a 前必须先完成 b,所以按执行顺序建立 b → a。这样完成 b 时才能减少 a 的未满足条件。
0 在课程语义中表示什么?表示当前没有任何尚未完成的先修课程,这门课现在可以安全学习。
环中每个节点至少有一条来自环内前驱的入边。要删除这条边必须先处理前驱,而前驱又等待环中其他节点,所以没有节点能率先达到入度 0。
completed === numCourses 可以证明无环?被处理的每门课程都在所有先修条件满足后出队。如果全部节点都能按这种顺序处理,就已经构造出完整拓扑序;有向环不可能包含在拓扑序中。
它没有任何先修课,当然可以完成,而且题目要求完成全部 numCourses 门课程。遗漏它会导致处理数量错误。
本题不求最少学期数或层级,只要不断处理任意入度为 0 的课程即可。新解锁课程可以直接追加到同一个队列尾部。
Kahn 算法直接用处理数量判断环,也容易输出拓扑序;DFS 三色法直观对应递归路径上的回边,但要处理递归深度。两者渐进复杂度相同。
重新运行拓扑排序最直接,成本为 O(V + E)。若需要大量在线更新,就要维护动态拓扑序;新增边可能打破原顺序并产生环,问题明显更复杂。
[course, prerequisite] 的边方向和入度含义写反。true,没有比较处理节点总数。Array.shift() 却未经分析直接宣称 JavaScript 队列操作为常数时间。先只回答第 1 题,再展开后续问题:
[1, 0] 要建立边 0 → 1?completed < numCourses 时,剩余图为什么一定有环?1 与状态 2 的区别。