资讯动态

课程表问题详解:从DFS染色法到BFS拓扑排序的有向图判环

发布时间:2026/9/18 22:03:21 来源:尧图企业网站定制
最近在刷题群里看到好几个朋友被一道经典题卡住——编号是207的“课程表”。乍一看题目名字很生活化好像跟大学选课有关实际上它是一道非常标准的有向图判环问题。很多人第一次做的时候会直接写一个DFS加visited数组结果怎么提交怎么错甚至想不明白为什么简简单单一个“能不能学完所有课”会有这么多花样。这道题的经典程度不用多说它就是LeetCode上的207. Course Schedule但如果你只是把它当成一道“能AC就完事”的题目那会错过很多真正值得琢磨的东西。这篇文章我想换个角度把这题从题意拆解、算法原理、代码实现到实际工程场景一次讲透尤其是那些容易想当然、一踩一个准的细节。1. 课程表问题到底在问什么从选课规则到图模型1.1 先理解题目里那层“先修课”的关系题目本身很短你总共有 numCourses 门课要学编号从 0 到 numCourses-1。给你一个先修课程列表 prerequisites里面每一项是 [a, b]表示想要学习课程 a必须先完成课程 b。问你能不能完成所有课程的学习。举个例子prerequisites [[1,0]] 就表示“学1之前要先学0”那么可行顺序是 0 - 1能学完。但如果 prerequisites [[1,0],[0,1]]意思就变成“学1要先学0学0又要先学1”这就是一个互斥依赖循环永远找不到一个合理的先后顺序所以答案是 false。把这个问题翻译成图论语言就非常清晰了每门课是一个节点每条先修关系是一条有向边从“先修课”指向“后续课”。比如 [a, b] 表示 b - a先学b再学a。整个问题就等价于判断这个有向图里是否存在环。如果存在环那么环上的课程永远无法排出一个合法顺序结果就是 false如果没有环则一定存在至少一种拓扑排序结果就是 true。1.2 为什么不能简单“模拟选课”或者“递归检查”有一种特别常见的思维误区我直接模拟一个“当前可以学的课程集合”每次把没有前置要求的课程加进来然后逐层解锁后续课程。这其实就是BFS拓扑排序的思路但很多人第一次不是用“入度”来思考而是用DFS去递归判断每一门课的前置课程是否能学完。用DFS做本身是没问题的但问题往往出在“递归判断”时的状态处理。很多新手写的版本是这样的每次从当前课程出发沿着依赖关系往下走然后用一个 visited 数组记录“这个课我已经来判断过”。这会导致一种情况——你以为某条路径走不通就说明整个图有环其实只是因为不同路径共享了同一个节点而该节点本身完全合法。判断有向图是否有环核心不在于“有没有重复访问”而在于“在当前这条递归路径上有没有回到祖先节点”。这个点很多人要过很久才能真正体会下面我展开讲清楚。2. 访问状态设计visited数组的三种划分才是判环的关键2.1 只记住“访问过”远远不够你需要知道它还在递归栈里如果你写过无向图的DFS判环你可能会习惯性地用一个布尔数组 visited。但无向图判环用布尔数组能成立原因是无向图中一旦在DFS时遇到已经访问过的邻居就说明有环有向图完全不同因为从A可以访问B从C也可以访问BB被访问过完全正常不代表B所在的路径有问题。正确的做法是把每个节点的状态分成三种0未访问。1正在访问中也就是当前节点还在递归调用栈里或者已经进入DFS但还没有完全处理完它的所有后继。2已经访问完毕从这个节点出发的所有路径都检查过了确认没有环。当DFS过程中遇到一个状态为1的节点时说明找到了一个“后向边”也就是当前路径上出现了回路这时候可以立刻判定有环。如果遇到状态为2的节点说明这个子图之前已经检查过且无环可以直接跳过不需要重复计算。2.2 一个立刻暴露问题的反例拿 prerequisites [[0, 1], [0, 2], [1, 3], [2, 3]] 来说图结构是 3 - 1 - 0 和 3 - 2 - 0本身没有环。如果用布尔visited做DFS从0开始先递归到1再递归到3然后回到0再去递归2发现2的后继也是3但3已经被标记成“已访问”于是程序可能误判这里有环。实际上3是两条路径的交汇点布尔数组无法区分“正在栈中的访问”和“已经安全的访问”这就是最典型的踩坑点。2.3 状态切换的实际执行逻辑从代码层面来看三色标记法的逻辑并不复杂从任意一个状态为0的节点开始DFS。进入节点时把状态从0改成1。遍历所有后继节点时如果后继状态是1直接返回“有环”。如果后继状态是0继续递归检测。所有后继处理完之后把当前节点状态改成2表示这个节点已经安全。这个“状态1”就是整个算法里最难理解、也最关键的哨兵。它代表的不只是“我来过”而是“我正在我的祖先链上”。只要把握住这一点DFS判环基本就不会写错。3. 解法一DFS染色法实现课程表的完整推导3.1 从邻接表构建到递归函数设计要用DFS解决课程表第一步是建图。因为题目给的 prerequisites 是以边的形式给出的我们需要把它转换成邻接表方便从某门课出发快速找到它的后续课程。在 [a, b] 中b 是 a 的前置所以 a 依赖于 b建边时应该让 b 指向 a。写成代码就是 graph[b].append(a)。这里有一个特别容易搞反的细节很多人把边方向建反导致判环逻辑整体反转。虽然在这种情况下如果图里有环判环依然能判出来因为环反过来看也是环但会影响你对“哪些课依赖哪些课”的理解甚至在某些变体题目里会直接影响答案。因此开局第一步先花十秒钟想清楚方向谁指向谁值得养成习惯。3.2 DFS染色法代码实现下面用Python写一个可运行的版本为了便于理解我刻意把变量命名得直白一点def canFinish(self, numCourses: int, prerequisites: List[List[int]]) - bool: graph [[] for _ in range(numCourses)] for course, pre in prerequisites: # 想要学 course必须先学 pre # 所以从 pre 指向 course graph[pre].append(course) # 状态 0: 未访问, 1: 在当前递归栈中, 2: 已完成安全访问 state [0] * numCourses def dfs(node): if state[node] 1: # 又在当前路径上遇到这个节点说明有环 return False if state[node] 2: # 之前检查过没环直接放行 return True # 标记为正在访问 state[node] 1 for nxt in graph[node]: if not dfs(nxt): return False # 当前节点所有后继都没问题标记为安全 state[node] 2 return True for i in range(numCourses): if not dfs(i): return False return True这个实现非常精简但信息量很足。state[node] 1 的检查必须在 state[node] 2 的检查之前因为一个节点在DFS过程中只会先进入状态1之后才可能变成状态2。如果把顺序写反当递归再次遇到一个还在栈里的节点时会错误地认为它已经“验证安全”从而漏掉环。3.3 模拟一次带环的执行过程假设 numCourses 3, prerequisites [[0, 1], [1, 2], [2, 0]]建图后graph[0] [2]graph[1] [0]graph[2] [1]对0执行DFS状态0变1。沿着graph[0]找到2对2执行DFS状态2变1。沿着graph[2]找到1对1执行DFS状态1变1。沿着graph[1]找到0此时发现0的状态是1正在递归栈中于是立刻返回False。这就是整条“环”被识破的关键节点。如果题目给出的数据里有大量并行分支这种染色法还会自动做一些剪枝状态2的节点不用再重复递归。所以整体时间复杂度和每个节点、每条边都访问一次基本一致是 O(V E)。4. 解法二BFS拓扑排序与入度表的思路差异4.1 入度思想剥掉“没有前置要求的课”相比DFS的“往下钻”BFS拓扑排序的思路是“一层层往外剥”。每门课都有一个入度表示它依赖多少门先修课。入度为0的课程意味着当前无需任何先决条件可以直接学习。学完一门课之后它指向的所有后续课程的入度都减1如果某个后续课程入度变成0它就成了新的可学课程。如果最终能学到的课程数量等于 numCourses说明所有课都排进了拓扑序列没有环反之如果循环结束后还有课程没有被处理就说明它们永远进不了队列只能是因为彼此或者与某些课程构成了环。4.2 Kahn算法的完整实现细节直接看代码from collections import deque def canFinish(self, numCourses: int, prerequisites: List[List[int]]) - bool: graph [[] for _ in range(numCourses)] indegree [0] * numCourses for course, pre in prerequisites: graph[pre].append(course) indegree[course] 1 queue deque() for i in range(numCourses): if indegree[i] 0: queue.append(i) learned 0 while queue: node queue.popleft() learned 1 for nxt in graph[node]: indegree[nxt] - 1 if indegree[nxt] 0: queue.append(nxt) return learned numCourses这个版本里 learned 变量记录的是“能学完的课数量”。有些实现会额外维护一个 result 数组把学完的顺序保存下来如果只需要判断能不能学完用计数器就够了。4.3 为什么BFS判环比DFS更直观BFS这种做法的好处是它的每一步操作都有非常直观的生活化解释“一门课的前置都解决了就拿去学掉同时解锁后续课程”。如果最终还有课学不了说明它们陷入了“学你之前必须先学我”的死局。用这种思路去跟面试官讲往往比直接从DFS的三种状态开始讲更容易让对方跟上节奏。从工程角度来说Kahn算法还能顺便输出一门课可行的学习顺序而DFS染色法要额外维护一个栈来输出拓扑序列稍麻烦一点。所以如果题目后续扩展成“返回课程学习顺序”比如LeetCode 210很多人的第一反应就是先写Kahn算法因为它天然适合生成顺序。5. 两种解法都爱踩的坑输入边界、重复边和性能问题5.1 空依赖和空图最容易被忽略的边界题目有一类非常常见的边界情况prerequisites 是空的或者 numCourses 很小。比如 numCourses 1prerequisites []那这门课没有前置答案显然是 true。再比如 numCourses 2prerequisites []所有课都是入度0同样能学完。很多人在DFS里忘记写最外层的 for i in range(numCourses)只从0门课开始递归结果遇到独立节点没处理到导致即使图里有环也没查出来或者有多个连通分量时漏判。BFS的优势在这里又体现了一次它在初始化时把所有入度为0的节点都放进队列天然覆盖所有连通分量不存在漏掉起点的问题。5.2 重复先修关系会不会干扰计数假设输入是 [[1, 0], [1, 0]]也就是同一门先修关系给两次。在BFS解法中indegree[1] 会被累加两次变成2于是必须先消耗两次0对1的“解锁”最后 learned 才能等于总数。但题目不会出这种输入因为先修关系如果重复一门课不会因为同一条边给了两遍就真的需要学两遍0。不过在工程上如果数据来自外部系统去重一下更稳妥。否则入度计数会被人为放大导致明明可以学完的课程因为重复边而永远无法把入度降到0。5.3 递归深度与栈溢出问题DFS解法在极端情况下会遇到另一个麻烦递归深度。如果图是一条超长的链比如 0 - 1 - 2 - ... - 19999那么DFS递归深度会达到 numCourses。很多语言默认栈深度有限比如Python默认递归深度约1000超过就会抛异常。刷题时你可能会想“我用递归不就完了”但到了真实工程或者面试白板环节这个风险是实实在在的。要解决也不难一是把递归改成显式栈迭代二是在工程中直接选择BFS拓扑排序方案它的空间复杂度更可控而且不会因为链条深度而爆栈。5.4 时间复杂度的常见误判有些读者看到DFS里嵌套了 for 循环和递归会担心它是不是 O(N^2)。这里明确一下每个节点最多被完整DFS一次每条边最多被遍历一次所以无论DFS染色法还是BFS拓扑排序时间复杂度严格来说都是 O(V E)空间复杂度也都是 O(V E)其中E最多是 prerequisites 的长度。如果用邻接矩阵而不是邻接表来存图复杂度就会退化成 O(V^2)。对于这道题V最多可以到几千甚至更多邻接矩阵在极端情况下会浪费大量空间。因此工程上强烈建议不要用二维矩阵存储这种稀疏依赖关系。6. 从课程表到真实工程先修依赖建模的延展与变体6.1 不只是刷题依赖关系在现实系统里无处不在虽然题目包装成“课程表”但它描述的“先修关系”在现实系统里太常见了。随便举几个例子软件构建系统里A模块编译前需要先编译B模块B又依赖C。数据处理管道里任务A要等任务B产出结果后才能启动。包管理器里安装一个包之前必须先安装它依赖的依赖。这些场景本质上都是同一个模型节点有依赖、任务需要排序、存在环就没办法进行。LeetCode 207练熟了等于你在手工实现一个简化版的构建调度器。当然真实系统里还有版本冲突、平台差异、并发执行、资源限制等问题但核心的“判环”思想完全一致。6.2 高频变体207如何扩展成其他题目207最直接的升级版本是210 Course Schedule II它不光问你“能不能学完”还要求返回一种具体的学习顺序。用BFS拓扑排序最后只需要把 learned 换成 result 数组把每次从队列弹出的节点追加进去return result if len(result) numCourses else []。非常简单。还有一些别的问题比如“找出图中所有环”“检测并发任务依赖是否会导致死锁”“分析编译模块的最短构建顺序”也都基于同样的图遍历思想。如果你能把这道题的本质吃透后面遇到很多“看起来完全不是一个题”的题目其实都是换皮。6.3 使用这个模型的注意事项建模时最重要的一个提醒要时刻问自己“节点代表什么边代表什么”。拿课程表来说节点是课程边是依赖。如果把节点和边的含义搞反或者把方向建反判环结果也许碰巧对但一旦题目改成输出顺序就很容易错得离谱。另一个实际经验是不要在拿到题目后立刻写代码。先用三分钟手动画一个小的样例图比如3门课两个依赖、4门课一个环把走向走通。这个习惯在很多复杂图论题里都能帮你少走弯路尤其是面试现场手画样例更容易让面试官理解你的思路。7. 我的实操体会从AC到理解再到面试时怎么讲清楚我自己刷这道题的时候第一遍用的是BFS拓扑排序因为代码很短AC得很顺利。但当时有一个很大的盲点我完全不理解为什么入度可以代表先修数量也不理解为什么最终 count 不等于 numCourses 就意味有环。直到后来自己手动模拟了一遍带环样例看到队列为空却还有节点没被处理才真正明白。后来在模拟面试里我试着把两种解法都讲了一遍。面试官问我DFS里“状态为1”到底是什么意思我一开始只照本宣科说“表示在这条递归路径上”他紧接着追问“那为什么状态为1就能断定有环状态为2就不行”这个问题真正逼着我把三色标记和递归栈的关系想透彻。现在如果让我给一个朋友讲这道题我会说你可以把状态1理解成“这个节点正在被祖先链上的某个节点关注着”一旦再次遇到它说明这条关注链首尾相接了环就形成了。给正在刷题的朋友一个建议不要只满足于“两种解法都能过”。207是一道少有的、能把图论基础、递归状态设计、队列应用、复杂度分析全部串起来的好题。试着把它当作一道“讲课题”像老师一样从头到尾讲给自己听如果你能讲清楚为什么状态2可以剪枝、为什么入度减到0才能入队那你对拓扑排序的理解已经超过很多人了。最后再分享一个小技巧平时练习的时候刻意把DFS和BFS两种解法都写一遍然后对比它们的空间占用和运行时间。你会发现数据量小的时候差异不大但一旦图中出现一条超长链BFS的稳定性会明显更好。这个观察在真实项目中同样适用——能用队列解决的问题尽量别依赖深层递归。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价