用 DFS 拓扑排序吃透 LeetCode 210:Course Schedule II

这道题本质是一个经典的"有向无环图(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

相关推荐
chao1898448 小时前
电容层析成像Tikhonov算法
算法
会挠头但不秃8 小时前
2.逻辑回归模型
算法·机器学习·逻辑回归
✎ ﹏梦醒͜ღ҉繁华落℘8 小时前
菜鸟的算法基础
java·数据结构·算法
爪哇部落算法小助手9 小时前
每日两题day65
数据结构·c++·算法
麒qiqi9 小时前
【数据结构核心篇】树与哈希(Hash)的原理、特性及实战应用
数据结构·算法·哈希算法
Swift社区9 小时前
LeetCode 443. 压缩字符串
leetcode·职场和发展·蓝桥杯
ada7_9 小时前
LeetCode(python)——543.二叉树的直径
数据结构·python·算法·leetcode·职场和发展
橘颂TA9 小时前
【剑斩OFFER】算法的暴力美学——颜色分类
数据结构·c++·算法·动态规划
吴秋霖9 小时前
profileData纯算逆向分析
算法·设备指纹·反爬虫技术