这道题本质是一个经典的"有向无环图(DAG)拓扑排序"问题:给定课程数 numCourses 和若干先修关系 prerequisites[i] = [ai, bi],表示"想上课程 ai,必须先上课程 bi",要求返回一种可以完成所有课程的上课顺序;如果无法完成(存在环),返回空数组。leetcode
常见做法有两类:
BFS(Kahn 算法,基于入度)
DFS(基于后序遍历 + 拓扑排序)
这篇文章重点讲清楚:DFS + 栈/数组的拓扑排序为什么正确、顺序是怎么保证的、遇到多条链和不连通分量时顺序是否会乱,以及你在推理过程中容易卡住的几个点。leetcode
一、图建模:方向到底怎么连?
题意是:[ai, bi] 表示"要上 ai,必须先上 bi"。leetcode
有两种常见的建图方式:
方式 A:bi -> ai
含义:从"先修课"指向"后续课"。
拓扑排序里最常见的写法。
方式 B:ai -> bi
含义:从"后续课"指向"先修课"。
两种都能做拓扑排序,但习惯上、也更自然的是用方式 A(先修 → 后续),本文统一采用:
对于每个 [ai, bi],连一条边 bi -> ai。leetcode
这样有向图的语义就很直观:
边的方向,就是"学习的依赖方向";
拓扑序中,一条边 u -> v 总是要求 u 出现在 v 之前。
二、DFS 拓扑排序的核心:后序入栈 + 反向读出
1. 标准 DFS 拓扑流程
在方向 bi -> ai 的图上,标准 DFS 拓扑排序的套路是:
使用三色标记每个节点状态:
0:未访问(UNVISITED)
1:访问中(VISITING)
2:已访问(VISITED)
对所有课程做一遍"外层遍历":
for (每个课程 v in [0...numCourses-1])
如果 v 还未访问,调用 dfs(v)。leetcode
在 dfs(u) 里:
标记 u 为 VISITING。
遍历 u 的所有邻接点 v(也就是所有后续课程):
如果 v 是 UNVISITED,则递归 dfs(v);
如果 v 是 VISITING,说明遇到回边,存在环,直接判定无解。
所有邻接点都处理完后,标记 u 为 VISITED。
此时把 u 压入栈 / append 到数组末尾。
整个 DFS 完成后:
如果过程中没发现环,说明图是 DAG;
此时"完成时间顺序"数组是一个逆拓扑序,需要整体反转或"用栈从栈顶依次弹出",才能得到拓扑序。leetcode
关键点有两个:
"什么时候入栈":必须在 DFS 递归返回的时候(后序);
"结果怎么读":要反转,或者用"栈顶往外弹"的方式读。
三、用一条链把顺序推清楚:ci → bi → ai → di
假设存在一条依赖链:
ci → bi → ai → di
含义:
必须先学 ci,再学 bi,再学 ai,最后学 di。
并且已经按"先修 → 后续"建边:
ci -> bi
bi -> ai
ai -> di
情形 1:外层第一次 DFS 起点刚好是最前面的 ci
当外层循环第一个遇到且未访问的是 ci,从 ci 开始 DFS:
DFS 访问顺序(递归顺序):
ci -> bi -> ai -> di
入栈顺序(完成顺序):
先完成 di,入栈:[di]
返回到 ai,入栈:[di, ai]
返回到 bi,入栈:[di, ai, bi]
最后返回到 ci,入栈:[di, ai, bi, ci]
如果你用"栈顶往外弹"的方式输出:
出栈顺序:ci, bi, ai, di
正好是课程应该上的顺序。
如果你用"数组 + 最后反转"的方式:
完成时数组为 [di, ai, bi, ci]
反转后为 [ci, bi, ai, di]
一样是正确的拓扑序。
四、起点不一定是源头:从 bi 开始会怎样?
前面那条链 ci → bi → ai → di,真实情况中,DFS 第一次起点不一定是 ci,有可能是中间某个,比如 bi。这一点很多人一开始会担心"会不会乱序"。
设想:
外层 for 第一次遇到的未访问节点是 bi,于是从 bi 开始 DFS;
图结构依然是:ci -> bi -> ai -> di。
步骤推演:
从 bi DFS:
bi -> ai -> di
入栈顺序:
di
ai
bi
栈此时为 [di, ai, bi]
外层继续遍历到 ci:
发现 ci 还未访问,于是 DFS(ci);
递归中会看到 bi 已访问,跳过,不再往下;
DFS(ci) 返回时,把 ci 入栈。
栈变成 [di, ai, bi, ci]
最终出栈或反转:
弹栈顺序:ci, bi, ai, di
依然满足 ci 在 bi 前,bi 在 ai 前,ai 在 di 前。
结论:
起点是 bi 而不是 ci 完全没问题;
循环中后面再从 ci 做一个 DFS,把"前面那节课"补进栈顶前面,整体出栈顺序仍然正确。
五、不连通的路径是否会打乱顺序?
再加一个常见担心:
图中还有另一条完全不连通的依赖链,在 DFS 的遍历顺序上,插在了 ci 之前,会不会破坏拓扑顺序?
假设:
已有链:ci → bi → ai → di。
还有一条不连通链:x → y → z,与上面四门课完全无边相连。
可能发生的遍历顺序:
第一次 DFS 起点是 bi,如上,栈得到 [di, ai, bi]。
接着遇到一个属于 x → y → z 这一块的起点,比如 x,整条 DFS 下去,入栈后栈变为:
di, ai, bi, z, y, x\] 或类似顺序(取决于从哪个点先 DFS,但内部依赖保持) 最后才遇到 ci,DFS(ci),入栈后: \[di, ai, bi, z, y, x, ci
从栈顶往外弹出:
ci, x, y, z, bi, ai, di 或类似的某种排列。
核心问题:这样合法吗?答案是:完全合法。leetcode
原因是:
拓扑排序的定义,只要求对每条边 u -> v,在结果序列中 u 必须出现在 v 前。
对不同连通分量(比如 {ci, bi, ai, di} 和 {x, y, z})之间,并不存在边约束,因此它们彼此相对顺序是任意的。
只要每个连通分量内部满足依赖顺序(比如 ci 仍在 bi 前,x 仍在 y 前),整个序列就是合法拓扑序之一。
题目本来也允许"任何一个合法答案",并不要求"同一条链的节点在数组里必须连在一起"。leetcode
六、DFS 版算法的整体结构小结
把上面的讨论抽象成一个模板,就是你已经写出的那套逻辑:
建有向图,边为"先修课 → 后续课",即 bi -> ai。leetcode
使用三色标记检测环:
访问中再次遇到访问中节点,存在环,返回空数组。
DFS 访问图:
外层:遍历所有课程,对每个未访问节点调用 DFS,保证覆盖所有连通分量和独立课程。
内层:在 DFS 递归结束(所有邻接点处理完)后,把当前课程压入"栈"或结果数组尾部。
最后:
如果没检测到环,从栈顶依次弹出填入结果,或者反转数组,即为拓扑序。
关键理解点:
不需要强行维持"链内连续";
不需要手动插入"前序课"到结果中,只要 DFS 覆盖全图,后序入栈 + 反向读出会自动保证"先修在前、后修在后";
起点在哪都没关系,遍历所有未访问节点这一外层循环会"补全"前面没从源头开始的情况。
七、和 BFS(Kahn 算法)的对比一句话带过
同样是拓扑排序,BFS 版(Kahn)是这样的:leetcode
建图同时统计每个节点入度;
把所有入度为 0 的节点入队(这些就是"当前可以学习的课");
不断出队一个节点 u,加入结果,然后把它指向的邻接点 v 的入度减一,若减为 0 再入队;
最后如果结果中节点数等于课程数,则有解,否则存在环。
这里队列是 真正负责"决定顺序"的容器,而 DFS 版中,顺序是由"递归完成时间 + 结果反转"决定的,队列反而不适合作为 DFS 的主结果容器。
八、结语:你那套思路,其实已经是标准答案了
回到最开始你的提炼:
建图:顶点是课程,边表示先修关系。
三色法检测环。
DFS 探索所有节点,访问结束时把节点压入栈。
若存在环,返回空数组;否则弹栈构造结果数组。
这就是一个完整正确的 DFS 拓扑排序解法,能 AC Course Schedule II,只要细节(状态管理、栈操作、图存储)写对即可。leetcode