LeetCode 经典拓扑排序问题------207. 课程表。这道题是大厂面试常考的基础题,核心考察「有向图环检测」,也是拓扑排序的典型应用场景。下面会先梳理题目核心,再逐行解析两种解法(BFS Kahn算法 + DFS 状态标记法),最后对比两种解法的适用场景,帮大家吃透这道题。
一、题目解读(通俗版)
题目很简单,一句话概括:你要修 numCourses 门课,有些课需要先修其他课(比如学高数前要先学高中数学),判断能不能把所有课都修完。
关键信息拆解:
-
课程编号:0 到 numCourses - 1(纯数字标识,无实际意义);
-
先修关系:prerequisites[i] = [ai, bi] → 修 ai 必须先修 bi(重点:是 bi → ai 的依赖关系,不是 ai → bi);
-
核心问题:判断课程依赖关系中是否存在「环」(比如 A→B、B→C、C→A,互相依赖就永远修不完)。
举个例子:
prerequisites = [[0, 1]] → 修 0 必须先修 1 → 无环,返回 true;
prerequisites = [[0,1], [1,0]] → 修 0 先修 1,修 1 先修 0 → 有环,返回 false。
二、核心思路:有向图环检测
这道题的本质是「有向无环图(DAG)的判定」:
-
每门课程 = 图的「节点」;
-
先修关系 [ai, bi] = 图的「有向边」(bi 指向 ai,代表 bi 是 ai 的前置依赖);
-
能修完所有课 → 图中无环 → 可以进行拓扑排序;
-
修不完 → 图中有环 → 无法进行拓扑排序。
拓扑排序有两种经典实现方式,对应下面的两种解法,我们逐一拆解。
三、解法一:BFS(Kahn算法)------ 基于入度表
这是最直观、最易理解的解法,核心思路是「消解依赖」:先修无依赖的课(入度为0),修完后减少后续课程的依赖,直到所有课都修完,或发现有课无法消解依赖(有环)。
3.1 完整代码(TS版)
typescript
function canFinish_1(numCourses: number, prerequisites: number[][]): boolean {
// 邻接表:key是先修课,value是该先修课的后续课程列表
const adjacencyList: Map<number, number[]> = new Map();
// 入度表:记录每个课程需要的先修课数量(依赖数量)
const inDegree: number[] = new Array(numCourses).fill(0);
// 初始化邻接表(每个课程都有一个空的后续课程列表)
for (let i = 0; i < numCourses; i++) {
adjacencyList.set(i, []);
}
// 填充邻接表和入度表
for (const [course, preCourse] of prerequisites) {
// 学习course需要先学preCourse → preCourse的后续课程是course
adjacencyList.get(preCourse)!.push(course);
// course的依赖数(入度)+1
inDegree[course]++;
}
// 队列:存储所有无依赖(入度为0)的课程,可直接修
const queue: number[] = [];
for (let i = 0; i < numCourses; i++) {
if (inDegree[i] === 0) {
queue.push(i);
}
}
let completedCourses = 0; // 记录已修完的课程数
while (queue.length > 0) {
const currentCourse = queue.shift()!; // 取出当前可修的课程
completedCourses++; // 修完一门,计数+1
// 获取当前课程的所有后续课程(修完当前课,这些课的依赖会减少)
const nextCourses = adjacencyList.get(currentCourse)!;
for (const nextCourse of nextCourses) {
inDegree[nextCourse]--; // 后续课程的依赖数-1
// 若依赖数变为0,说明该课可修,加入队列
if (inDegree[nextCourse] === 0) {
queue.push(nextCourse);
}
}
}
// 若修完的课程数等于总课程数,说明无环;否则有环
return completedCourses === numCourses;
};
3.2 逐行解析(重点吃透两个表)
1. 邻接表(adjacencyList)
作用:存储每个课程的「后续课程」,也就是"修完这门课后,能解锁哪些课"。
比如 prerequisites = [[0,1], [2,1]],邻接表中 key=1 的 value 是 [0,2],表示修完 1 之后,能修 0 和 2。
初始化时给每个课程分配空数组,避免后续 get 时出现 undefined。
2. 入度表(inDegree)
作用:记录每门课程的「依赖数量」,也就是"修这门课需要先修几门课"。
比如课程 0 需要先修 1,那么 inDegree[0] = 1;无依赖的课程(比如 1),inDegree[1] = 0。
3. 队列操作(核心逻辑)
-
初始:把所有入度为0的课程加入队列(这些课无依赖,可直接开始修);
-
循环:每次从队列取出一门课,修完(计数+1),然后减少其后续课程的入度;
-
终止:队列空时,若修完的课程数等于总课程数 → 无环;否则 → 有环(剩下的课有无法消解的依赖)。
4. 关键细节
adjacencyList.get(preCourse)! 中的「!」:因为我们初始化时给每个课程都设置了空数组,所以 get 一定能拿到值,用「!」消除 TS 的 undefined 警告。
queue.shift()!:队列非空时才会进入循环,所以 shift() 一定能拿到值,同样用「!」消除警告。
四、解法二:DFS(状态标记法)------ 基于递归栈检测环
DFS 解法的核心思路是「跟踪节点访问状态」:通过标记每个节点的访问状态,检测递归过程中是否回到"正在访问"的节点(若回到,则存在环)。
4.1 完整代码(TS版)
typescript
function canFinish_2(numCourses: number, prerequisites: number[][]): boolean {
// 邻接表:和BFS解法一致,存储先修课的后续课程
const adjacencyList: Map<number, number[]> = new Map();
// 初始化邻接表
for (let i = 0; i < numCourses; i++) {
adjacencyList.set(i, []);
}
// 填充邻接表(逻辑和BFS一致)
for (const [course, preCourse] of prerequisites) {
adjacencyList.get(preCourse)!.push(course);
}
// 状态数组:0=未访问,1=正在访问(当前递归栈中),2=已访问完成
const status: number[] = new Array(numCourses).fill(0);
// 递归函数:检测从当前课程出发,是否存在环
const hasCycle = (course: number): boolean => {
// 若当前课程正在访问中(递归栈里有它),说明存在环
if (status[course] === 1) {
return true;
}
// 若当前课程已访问完成,直接返回无环(避免重复遍历)
if (status[course] === 2) {
return false;
}
// 标记当前课程为「正在访问」
status[course] = 1;
// 获取当前课程的后续课程(若没有,取空数组)
const nextCourses = adjacencyList.get(course) || [];
// 递归检测每一个后续课程
for (const next of nextCourses) {
if (hasCycle(next)) return true; // 发现环,立即返回
}
// 所有后续课程都检测完,无环,标记为「已访问完成」
status[course] = 2;
return false;
}
// 遍历所有课程,逐个检测是否存在环
for (let i = 0; i < numCourses; i++) {
if (hasCycle(i)) return false; // 有环,直接返回false
}
// 所有课程都无环,返回true
return true;
};
4.2 逐行解析(重点吃透状态标记)
1. 邻接表
和 BFS 解法完全一致,不再重复,核心是存储课程间的依赖关系。
2. 状态数组(status)------ DFS 核心
三个状态的意义(重中之重):
-
0:未访问 → 还没开始遍历这个节点;
-
1:正在访问 → 已经进入递归栈,正在遍历它的后续节点(还没遍历完);
-
2:已访问完成 → 该节点的所有后续节点都遍历完了,确认无环。
举个环的例子:A→B→C→A
递归 A(标记为1)→ 递归 B(标记为1)→ 递归 C(标记为1)→ 递归 A(此时 A 状态为1)→ 发现环,返回 true。
3. 递归函数 hasCycle
作用:检测从当前课程出发,是否存在环,返回布尔值(true=有环,false=无环)。
执行流程:
-
先判断状态:若为1 → 有环;若为2 → 无环;
-
标记当前课程为1(正在访问);
-
递归遍历所有后续课程,若任意一个后续课程返回 true(有环),则当前函数也返回 true;
-
所有后续课程遍历完,标记当前课程为2(已完成),返回 false(无环)。
4. 关键细节
adjacencyList.get(course) || []:避免后续课程为空时,for 循环报错(虽然初始化时已设空数组,但加个兜底更稳妥)。
递归终止条件:必须先判断状态,再进行递归,否则会出现重复遍历,导致效率低下或栈溢出。
五、两种解法对比(必看!)
两种解法的时间复杂度、空间复杂度都是 O(n + m)(n=课程数,m=先修关系数),但适用场景有差异,面试时可根据需求选择:
| 对比维度 | BFS(Kahn算法) | DFS(状态标记法) |
|---|---|---|
| 核心逻辑 | 消解依赖,统计可修课程数 | 跟踪递归栈,检测环 |
| 优势 | 直观易懂,可直接输出拓扑排序结果 | 代码更简洁,仅需检测环时更高效 |
| 劣势 | 需维护入度表和队列,空间更显性 | 递归深度过大时可能栈溢出(JS默认栈深约1000) |
| 适用场景 | 需输出拓扑序列、课程数较多(避免栈溢出) | 仅需检测环、课程数较少(递归深度可控) |
六、面试高频考点 & 避坑指南
1. 高频考点
-
拓扑排序的两种实现方式(BFS/DFS);
-
有向图环检测的核心思路;
-
邻接表、入度表的构建逻辑。
2. 避坑点
-
先修关系搞反:prerequisites[i] = [ai, bi] 是「修 ai 先修 bi」,不是反过来,邻接表要填 bi 的后续是 ai;
-
TS 类型警告:忘记初始化邻接表,导致 get 时返回 undefined,需提前给每个课程设置空数组;
-
DFS 栈溢出:当课程数极大(比如 10000+),优先用 BFS,或把 DFS 改成迭代版。
七、总结
LeetCode 207. 课程表的核心是「有向图环检测」,两种解法各有优劣:
-
新手优先学 BFS:逻辑直观,容易理解,面试时写出来不易出错;
-
追求简洁学 DFS:代码量更少,核心是掌握三个状态的标记逻辑。
其实这道题的两种解法,本质上都是拓扑排序的实现,掌握这道题,后续遇到拓扑排序相关的变体(比如输出拓扑序列、课程安排II)也能轻松应对。