☰
力扣课程表题解:拓扑排序与环检测实战
2026/10/21 8:39:29 网站建设 项目流程

刚开始刷到力扣的“课程表”这道题时,我差点被名字骗了,以为后台要处理什么课表编排、节次冲突的问题,点进去才明白,这其实是一道非常经典的图论题。它在hot100里常年占着一个位置(原题对应力扣207,hot100编号序列里大概排在第151位左右),核心问法是:给你numCourses门课程,以及一组先修关系prerequisites,判断你能不能把所有这些课程都修完。

一句话翻译成图论语言就是:给定一个有向图,判断图中是否存在环。

为什么这个考点这么重要?因为“依赖关系”在真实世界里太常见了:课程有先修课、项目任务有前置依赖、软件包有依赖包、编译器编译模块有依赖顺序。一旦这些依赖之间形成循环,整个系统就会进入“谁都等谁完成”的死锁状态。所以面试官特别爱拿这道题做筛子,既能考你对图的建模能力,又能考察对拓扑排序和环检测这两种基础算法的掌握程度。这篇文章我会把这道题从头到尾拆透,包括两种主流解法、手写代码时容易踩的坑、以及它背后的那些变形题,争取让你看完之后不仅AC,还能举一反三。

1. 读懂题意:先修关系的本质是建一张有向图

1.1 题目输入输出到底长什么样

先看最基础的输入格式。numCourses表示总共有几门课程,比如4就代表有编号0、1、2、3四门课。prerequisites是一个二维数组,其中每一项prerequisites[i] = [a, b]表示:如果你想学课程a,那么必须先完成课程b。

举个例子:

numCourses = 2 prerequisites = [[1, 0]]

意思是学课程1之前必须先学课程0,所以答案是true,因为可以先学0再学1,不存在冲突。

再看一个反例:

numCourses = 2 prerequisites = [[1, 0], [0, 1]]

这表示课程1依赖课程0,课程0又依赖课程1。你要是学1就得先学0,但学0又得先学1,形成一个环,无论从哪边开始都无法推进,所以答案是false。

题目要求的就是这样一个布尔判断:能不能找到一个合法的修读顺序,把所有课程学完。从图论角度,这个判断等价于“给定的有向图是不是一个有向无环图(DAG)”。

1.2 为什么依赖关系能建模成图

把每门课程看成图里的一个节点,把先修关系“学a之前必须先学b”看成一条有向边,方向是b -> a。为什么是b指向a而不是反过来?

因为“b是a的前置条件”,也就是说从b出发可以通向a。画出来就是:

  • [1, 0]=> 边0 -> 1,表示0学完才能学1。

这种做法非常符合工程直觉:依赖关系天然是“被依赖者指向依赖者”。写代码的时候,邻接表里存的就是graph.get(前置课).add(后置课)。

图建好之后,问题就清晰了:如果从某门课出发,沿着有向边能绕一圈回到自己,说明这个依赖链成环了。环上的每一门课都在等环上的另一门课先完成,谁都动不了,整个修读计划就废了。

1.3 这道题为什么被归为hot100高频题

说实话,力扣hot100里有不少题是“刷了就忘”的类型,但“课程表”属于那种高频反复出现的题目,原因有三个:

第一,它把图论入门的三座大山全部串在一起——图的存储(邻接表)、图的遍历(DFS/BFS)、图的性质判断(DAG与环检测)。一道题能覆盖这么多基础点,性价比极高。

第二,它在实际面试里非常好扩展。面试官只要把原题换一层皮——比如“给你一堆编译模块,判断能不能编译通过”、“给你任务依赖关系,输出一种可执行顺序”,本质上就是在考这道题。问法可以千变万化,解法核心始终是拓扑排序。

第三,它天然通向一系列升级题。力扣官方就配套了“课程表II”(要求输出具体修课顺序),以及动态规划的变体。把这道题吃透了,等于给整个“依赖关系”类题目打好了地基。

2. 核心算法拆解:拓扑排序与DFS环检测

2.1 先搞懂拓扑排序到底在排什么

拓扑排序针对的是有向无环图,目标是把所有节点排成一个线性序列,使得对于图中任意一条有向边u -> v,节点u都排在节点v前面。放到课程表的语境里,就是一个合法的修课顺序:先修课永远排在后面课程之前。

你会不会觉得这有点像“先决条件排序”?对,就是这个意思。而这里有一个非常关键的图论结论:一个有向图存在拓扑排序,当且仅当它是一个有向无环图。所以“能不能把课程全部修完”这个问题,等价于“这个图的拓扑排序长度能不能覆盖所有节点”,也等价于“图里有没有环”。

常见做法有两种思路,一种是利用节点的“入度”来层层剥离的Kahn算法,另一种是利用深度优先搜索递归栈来检测回边的DFS方法。两种我都建议掌握,因为不同场景下它们的扩展性不一样。

2.2 Kahn算法:从入度为0的节点开始层层剥离

Kahn算法的核心思路非常生活化:把“所有前置课都学完了”的课程,从图里一个接一个地摘掉。

那怎么判断一门课的所有前置课都学完了?需要一个数组记录每门课的入度。在图中,节点v的入度就是指向v的边的数量。放到课程先修关系里,入度正好等于“还有多少门先修课没有完成”。

算法分四步走:

第一步,根据prerequisites构建邻接表,同时计算每门课的入度。每遇见一条b -> a,indegree[a]就加1,表示课程a多了一个前置门槛。

第二步,把所有入度为0的课程初始放入一个队列。这些课程没有先修课,可以直接开始学。

第三步,从队列里取出一个节点,相当于“学完了这门课”。每学完一门课,它在邻接表里的所有后继课程(依赖它的课程)的入度都减1。如果某个后继课程的入度减到了0,说明它这门课的所有先修课都已完成,可以入队了。

第四步,统计一下总共从队列里取出了多少个节点。如果这个数字等于numCourses,说明所有课程都能被安排;如果不等于,说明剩下的课程互相构成了环,永远无法推进。

这个过程就像一个“剥洋葱”的操作:不断把外围没有依赖的节点剥掉,如果最后能全部剥完,说明图里没有环;如果剥到一定程度卡住了,剩下的就是环。核心正确性论证也不复杂:如果图无环,就一定至少有一个人度为0的起点,剥掉它之后剩下的子图依然无环,所以可以一直剥到空;如果图有环,环上每个节点的入度都至少为1,永远不会被剥掉。

2.3 DFS三色标记法:递归栈里找“回头路”

第二种思路是用深度优先搜索遍历整张图,在遍历过程中检测有没有“回边”。这里最标准的做法是给每个节点打上三种状态:

  • 状态0:尚未访问
  • 状态1:正在本次递归栈中访问(也就是说它还在当前这条DFS路径上)
  • 状态2:已经访问完毕,从它出发的所有路径都检查过,确认无环

为什么需要三种状态而不是简单的已访问/未访问?如果你只用两种状态,遇到一个曾经访问过的节点就认为有环,那会把多个分叉路径上的共享节点误判成环。比如0 -> 1、2 -> 1,1被0访问过之后,从2出发再遇到1,它并不是环,只是共享节点,所以必须用“是否在递归栈里”来区分。

具体过程是:对每个未访问的节点调用DFS。进入节点时先标记为状态1,然后遍历它的所有后继节点。如果某个后继节点已经是状态1,说明这个后继节点在当前递归路径上,也就是说有一条边从当前节点绕了一个圈回到当前路径上,环出现了,立刻返回失败。如果后继节点是状态0,就正常递归进去检查。如果后继节点是状态2,直接跳过,因为它的子树已经验证过无环了。

把所有后继检查完,确认没有发现环,再把当前节点标记为状态2。

这种方法本质上是利用DFS调用的递归栈做检测,不需要额外的队列,也不需要“删除节点”的动作,只是通过状态标记模拟递归进度。很多同学刚学的时候容易把状态1和状态2用混,实际上你只要记住一句话:状态1表示“正在这条路上”,遇到它就是遇到环;状态2表示“这条路走完了是安全的”,遇到它就直接忽略。

2.4 两种解法的对比与场景选择

对比维度Kahn算法(BFS入度法)DFS三色标记法
核心思想不断消减入度,剥离开头节点递归栈检测回边
实现难度思路直观,代码模板固定稍绕,需要理解三色状态含义
空间占用邻接表 + 入度数组 + 队列邻接表 + 状态数组 + 递归栈
天然产物能直接给出拓扑排序顺序状态2的逆序是拓扑排序
适合场景题目要求输出顺序、依赖分层复杂DFS剪枝、环路径定位

从我个人的刷题习惯来说,面试时优先推荐Kahn算法,因为它思路直白、好解释,面试官听了也容易跟上。但DFS三色标记法绝对不能忽视,很多变体题比如“找出环上的所有节点”、“判断某条路径是否经过环”,用DFS会更顺手。两道题都练熟,才是真正的“吃透”。

3. 手把手写代码:两种解法的直通AC路径

3.1 手写前的关键决策:图的方向千万别搞反

这是整个题目里最容易被坑的地方,必须单独拿出来说清楚。题目给出的prerequisites[i] = [a, b]大部分题解里含义都是**“学a之前要先学b”**,所以在图里我们要建立的边是b -> a。

但你有没有想过,如果把方向建反了会怎样?建反之后,邻接表里两个课程之间的依赖关系就会颠倒,最终判环的结果虽然在一些对称场景下仍然恰好正确,但一旦用例复杂一点,尤其是涉及统计入度、输出拓扑序时,结果会完全不对劲。

我在写这个题的时候有个固定习惯:先在注释里写一行// graph[b].add(a),表示b是a的先修课,然后再写代码。这样就算隔几天回头再看,也不会因为概念混淆把自己绕晕。

另外,很多新手会纠结“要不要真的把边从图里删掉”。在Kahn算法里完全不需要真的删除边,只需要维护入度数组就够了。每剥掉一个节点,把它的后继节点入度减1,就等效于删掉了以它为起点的所有出边,因为后续再也不会访问这个节点了。

3.2 Kahn算法完整实现与逐段剖析

我用Java写一版最经典的实现,注释也尽量保留清楚,方便直接对照理解。

public boolean canFinish(int numCourses, int[][] prerequisites) { // 1. 初始化邻接表和入度数组 List<List<Integer>> graph = new ArrayList<>(); int[] indegree = new int[numCourses]; for (int i = 0; i < numCourses; i++) { graph.add(new ArrayList<>()); } // 2. 构建图,方向是 prerequisites[i] = [后置, 前置] => 前置 -> 后置 for (int[] p : prerequisites) { int pre = p[1], post = p[0]; graph.get(pre).add(post); indegree[post]++; } // 3. 初始入度为0的课程入队(没有先修课的课程) Queue<Integer> queue = new LinkedList<>(); for (int i = 0; i < numCourses; i++) { if (indegree[i] == 0) queue.offer(i); } // 4. 不断取出可上课程,消减后继课程入度 int learned = 0; while (!queue.isEmpty()) { int course = queue.poll(); learned++; for (int next : graph.get(course)) { // 后续课程减少一个前置门槛 if (--indegree[next] == 0) { queue.offer(next); } } } // 5. 学完的课程数等于总课程数,说明没有环 return learned == numCourses; }

有几个细节值得细品:

为什么用learned计数而不是用一个boolean[] visited数组?因为这道题只关心“能修完的数量”,队列的弹出顺序天然就是拓扑序的长度。如果用visited数组,你得另外统计访问次数,不如一个计数器干净。

为什么在循环内直接用--indegree[next] == 0判断入队?因为同一个节点可能会被多个前驱课程解锁,每一次解锁都要判断是否恰好降到了0。如果入度还没降到0,说明它还有其他先修课没修,绝对不能提前入队。这个“减到0才入队”的条件是整个算法的灵魂。

为什么初始入队时要把所有入度为0的节点都放进去?因为初始就没有任何依赖的课程可能有多个,它们之间的相对顺序无所谓,但每一个都必须作为起点。要是漏掉其中一个,可能就会有一部分独立的依赖链永远无法被处理。

3.3 DFS三色标记法完整实现与高频错点

再看DFS版本,这个版本写的时候尤其要注意状态转移的时机。

public boolean canFinish(int numCourses, int[][] prerequisites) { List<List<Integer>> graph = new ArrayList<>(); for (int i = 0; i < numCourses; i++) graph.add(new ArrayList<>()); for (int[] p : prerequisites) { graph.get(p[1]).add(p[0]); } int[] state = new int[numCourses]; // 0未访问,1遍历中,2已完成 for (int i = 0; i < numCourses; i++) { if (state[i] == 0 && !dfs(graph, state, i)) { return false; } } return true; } private boolean dfs(List<List<Integer>> graph, int[] state, int course) { state[course] = 1; // 进入递归栈 for (int next : graph.get(course)) { if (state[next] == 1) { return false; // 遇到还在递归栈里的节点,说明有环 } if (state[next] == 0 && !dfs(graph, state, next)) { return false; } } state[course] = 2; // 所有后继验证完毕,标记为安全 return true; }

最常见的错误有两个。

第一个错误是把状态1漏掉,只判断state[next] == 0才递归。如果不判断状态1,那么环依然会被DFS的递归栈间接发现,因为在递归栈里的节点当你再调用dfs时,它会把状态1继续向下传递,最终要么栈溢出要么通过后续节点的重复访问发现,但代码逻辑会很混乱,也容易在复杂用例里得出错误结果。最规范的做法就是显式遇到状态1立刻返回。

第二个错误是忘记对整张图的每一个未访问节点都发起DFS。有些同学只从入度为0的节点开始DFS,但图可能是不连通的,多个依赖链并存,必须遍历所有节点。如果某个节点从未被访问,说明它不在任何已检查的路径里,它的子树中可能藏着环,绝不能跳过。

3.4 把复杂度算明白,并锁定几个边界用例

两种解法的时间复杂度都是O(V + E),V是课程数,E是先修关系数。

空间复杂度上,邻接表本身是O(V + E),再加上入度数组或状态数组O(V),以及BFS的队列或DFS的递归栈O(V)。这个量级对10^5级别的课程和依赖关系是完全能够承受的,但如果你错误地使用邻接矩阵boolean[][] graph = new boolean[numCourses][numCourses]存储,空间直接变成O(V^2),课程数量稍微大一点就会内存爆炸。这是一个非常常见的初学陷阱,尤其是习惯了用矩阵表示小规模图之后,很容易在大数据量题目中翻车。

写题前我还习惯把边界用例在脑子里过一遍:

  • 完全没有先修关系,prerequisites为空:任意一门课都没有入度,BFS会把所有课程一次性入队,返回true。
  • 只有一个自环,[0, 0]:课程0依赖自己,它的入度永远减不到0,BFS会卡住,返回false,DFS遇到相邻节点状态1也会返回false。
  • 多个并列的依赖链,比如0->1和2->3:没有交叉,入度为0的节点有0和2,逐个剥完都能成功。
  • 大环上挂着分支,比如0->1、1->2、2->0,同时1->3:BFS会先剥掉3(入度0),然后环上所有节点入度不为0,最终learned=1<4,返回false。

3.5 必练的测试数据清单

为了确保实现无误,我建议提交之前至少本地跑一遍下面这张表的用例。

测试场景numCoursesprerequisites期望结果
空依赖3[]true
单条线性依赖3[[1,0],[2,1]]true
双向互依赖2[[1,0],[0,1]]false
自环1[[0,0]]false
环上挂分支4[[1,0],[2,1],[0,2],[3,1]]false
多链无环4[[1,0],[3,2]]true

这些用例基本覆盖了“无环、线性依赖、简单环、环带分支、多分量”所有主干场景。我每次写图论题都会保留这样一份测试清单,调试代码时能节省大量时间。

4. 从这道题延伸出去的变形与升级

4.1 变形一:不仅判断,还要输出修课顺序

这是最直接的升级题,对应力扣的“课程表II”。要求从“能否修完”变成“给出任意一种合法修课顺序”。

用Kahn算法改起来非常简单:在每次queue.poll()取出课程时,加入一个List<Integer> result,最后如果learned == numCourses就返回result,否则返回空数组。因为Kahn算法的出队顺序天然就是拓扑序,一条路走到黑就行。

如果用DFS三色标记法输出拓扑序,会稍微绕一点,需要在节点状态变为2(即它的所有后继都验证完毕)之后,把该节点加入一个结果栈,最后整个栈的逆序才是拓扑序。这是因为DFS是“先深入后继,再回溯当前节点”,一个节点要等它的所有依赖(后继)都标记完成之后才能确定位置。

4.2 变形二:判断某门特定课程是否在环上

题目如果换成“给你一堆课程依赖,再给你一个目标课程,判断目标课程是否一定能修完”,你能怎么处理?

思路可以这样拆:目标课程修不完,要么目标课程本身在环上,要么它依赖的某个前置课程在环上。因此需要两个方向的图:正向图(从先修课到后修课)用于从目标课程出发向下游搜索,反向图(从后修课到先修课)用于从目标课程出发向上游搜索。两个方向都做一次环检测或可达性分析,只要有任何一边遇到环,目标课程就无法修完。

这种实操在工程里非常常见,比如你在构建系统里看到一个模块编译失败,第一反应不是重编,而是去查它的依赖链里有没有循环依赖。

4.3 变形三:带权重的最短完成时间

如果再给每门课程标一个“修读耗时”,并且假设你有无限多的“同修名额”,那么完成所有课程的最短时间就是一张DAG里的最长路径,也就是常说的关键路径。

为什么不是所有课程耗时总和?因为可以并行修读彼此没有依赖关系的课程。比如课程A耗时5天,课程B耗时3天,但它们都依赖课程0,那么0学完之后A和B可以同时进行,总耗时是5而不是8。

解法是拓扑排序加动态规划更新:

dist[next] = max(dist[next], dist[current] + cost[next])

由于拓扑序保证了当前节点的所有前置节点都已经被处理过,按拓扑序遍历节点时,每个节点的最早开始时间已经是最优的,所以一遍拓扑排序加一遍DP就能求出全图的最短完成时间。这个变形在很多调度系统、编译时间估算场景中都会出现。

4.4 真实世界的映射:包管理器与构建系统

这类“依赖判断”在真实场景里简直无处不在。最典型的是各种包管理器,它们内部都会维护一个依赖图,执行安装前先检查依赖树是否有循环引用。一旦出现环,包管理器会直接报错,提示用户某个包通过某一串依赖关系回指到了自己。

另外,现代前端框架中模块的初始化顺序、数据流中多个服务节点的启动依赖,也会用到同样的模型。很多人可能会觉得算法和工作是脱节的,但如果你所在的公司有任何“依赖编排”系统,只要涉及“哪些任务先跑、哪些任务后跑、跑不了的原因是不是循环等待”,你就能从这道题里找到影子。

5. 避坑指南:我在刷题和工程里踩过的那些坑

5.1 五个高频Bug,几乎每次都有新人踩

Bug1:边方向搞反。这是最经典的错误。上面已经强调过,prerequisites[i]=[a,b]里b是先修课,要建b->a。我第一次做的时候就是没仔细看题,建成了a->b,结果测试用例第一个例子就输出相反答案,排查了半天才发现问题。

Bug2:用邻接矩阵代替邻接表。如果numCourses达到几万甚至几十万,邻接矩阵的空间是numCourses^2,直接爆内存。原因其实很好理解:课程之间的依赖关系通常是稀疏的,真正存在的边远远少于全连接,邻接表只存真实存在的边,空间和边的数量成正比,而矩阵无论有没有边都会占用一个格子。

Bug3:入度减到0后才入队这个条件被写成<= 0入队。如果用<=0,同一个节点可能会因为多条前驱边反复入队多次,轻则重复计数导致learned数量虚高,重则死循环。正确的是只有在--indegree[next] == 0这个精确时刻才入队一次。

Bug4:DFS中状态2的节点没有跳过。如果一个节点已经标记为状态2,说明它和它的整棵子树都已经安全,直接continue就够了。如果不做这个判断,而是当作未访问节点重新递归,时间复杂度会退化,而且环检测逻辑会变得模棱两可。

Bug5:BFS结束后的判断条件写错。很多人会写成learned != 0或者检查最后队列是不是空。队列为空可能有环,也可能所有课程都学完了,所以唯一靠谱的判断就是learned == numCourses。

5.2 调试这类图论题的小技巧

图论题有个特点,光靠脑子空想很难发现逻辑漏洞,所以我的本地调试习惯是三步走。第一步,写一个打印函数,输出每一门课的入度和邻接表内容,用小规模样例对照检查建图是否正确。第二步,在BFS的每一次出队和入队时打印队列内容,重点观察入度减到0的节点有没有被及时加入。第三步,把整个prerequisites列表画成一张有向图,肉眼确认环的位置,再对照代码输出结果。

这种“打印中间状态”的方法,比盯着代码干看有效得多。我以前遇到一个DFS死活判不出环的case,最后就是靠打印每个节点的状态变化,才发现是一个后继节点在递归返回后没有被正确标记为状态2,导致后续访问路径上的节点状态混乱。

5.3 给第一次接触图论的同学的学习顺序建议

如果你是完全没接触过图论的新手,我建议按这个顺序刷:先用这个“课程表”的BFS解法入门,理解入度和拓扑排序。然后去刷“课程表II”,尝试输出具体顺序。接着自己把两种解法都背熟,做到10分钟之内无脑默写。最后再去研究带权关键路径的变体。

我自己最早学图论时,经历了从“完全不知道邻接表怎么写”到“看到入度就条件反射式想到队列”的过程。这个转变靠的就是这道题反复刷了三遍,每一遍的关注点都不一样:第一遍关注AC,第二遍关注不同解法之间的差异,第三遍关注如何把解法迁移到其他题目上。

现在回头看,这道“课程表”就像图论世界里的一把钥匙。它不炫技,没有那些花哨的剪枝和复杂的状态压缩,但它勾画出了依赖类问题最核心的骨架:建图、判环、拓扑序。把这道题研究透彻之后,再遇到任何“依赖”、“先决”、“顺序”相关的题目,你的第一反应都会是往这个模型上套,而这就是刷题积累的真正意义所在。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询