在LeetCode的图论题目中,「课程表」系列绝对是拓扑排序的经典应用,其中210. 课程表 II 相比101. 课程表,不仅要求判断是否能完成所有课程,还需要返回具体的学习顺序,难度略有提升,但核心依然围绕「拓扑排序」展开。
今天就来详细拆解这道题,分享两种主流解法—— Kahn算法(入度表+队列/栈) 和 深度优先搜索(DFS)+ 状态标记,结合代码逐行解析,帮大家搞懂每一步的逻辑,轻松掌握拓扑排序的实战技巧。
一、题目核心解读
先再明确一下题目要求,避免理解偏差:
-
给定
numCourses门课程(编号0~numCourses-1),以及先修关系数组prerequisites,其中prerequisites[i] = [ai, bi]表示「选修ai前必须先修bi」。 -
要求返回一种可行的学习顺序,能修完所有课程;若存在环(比如A需先修B,B需先修A),则返回空数组。
核心考点:拓扑排序——对有向无环图(DAG)的顶点进行排序,使得对于每一条有向边(u, v),顶点u在排序中都在顶点v之前。如果图中存在环,则不存在拓扑排序。
二、解法一:Kahn算法(入度表+栈)
1. 算法思路
Kahn算法是拓扑排序的贪心算法,核心逻辑是「先处理入度为0的节点」(入度:当前节点需要的先修课程数量),具体步骤如下:
-
构建两个核心数据结构:
-
邻接表:存储每个课程的后续课程(即修完该课程后可以修的课程);
-
入度表:存储每个课程的入度(需要先修的课程数量)。
-
-
初始化:将所有入度为0的课程加入栈(或队列,此处用栈,队列也可),这些课程可以直接开始学习,无需先修。
-
循环处理栈中课程:
-
弹出栈顶课程,将其加入结果列表;
-
遍历该课程的所有后续课程,将它们的入度减1(因为当前课程已修完,相当于少了一门先修课);
-
若某后续课程的入度减为0,说明其所有先修课已修完,加入栈中等待处理。
-
-
判断是否修完所有课程:若结果列表的长度等于课程总数,返回结果;否则存在环,返回空数组。
2. 代码逐行解析
function findOrder_1(numCourses: number, prerequisites: number[][]): number[] {
// 邻接表:key是先修课,value是该先修课的后续课程列表
const adjacencyList: Map<number, number[]> = new Map();
// 入度表:记录每个课程需要的先修课数量(初始化为0)
const inDegree: number[] = new Array(numCourses).fill(0);
// 初始化邻接表,给每门课程分配一个空的后续课程列表
for (let i = 0; i < numCourses; i++) {
adjacencyList.set(i, []);
}
// 填充邻接表和入度表
for (const [course, preCourse] of prerequisites) {
// preCourse的后续课程添加course(修完preCourse才能修course)
adjacencyList.get(preCourse)?.push(course);
// course的入度+1(多了一门先修课preCourse)
inDegree[course]++;
}
// 栈:存储入度为0的课程(可直接学习的课程)
const stack: number[] = [];
// 结果列表:存储最终的学习顺序
const res: number[] = [];
// 初始化栈,将所有入度为0的课程加入
for (let i = 0; i < numCourses; i++) {
if (inDegree[i] === 0) {
stack.push(i);
}
}
// 记录已修完的课程数量
let finishNum = 0;
// 循环处理栈中的课程
while (stack.length) {
// 弹出当前可修的课程(shift()是弹出栈底,等同于队列,用pop()也可,顺序不同但均正确)
const currentCourse: number = stack.shift()!;
// 获取当前课程的所有后续课程
const nextCourses = adjacencyList.get(currentCourse);
if (!nextCourses) continue; // 若没有后续课程,直接跳过
// 遍历后续课程,更新入度
for (const course of nextCourses) {
inDegree[course]--; // 后续课程的先修课减少一门
if (inDegree[course] === 0) { // 入度为0,加入栈等待处理
stack.push(course);
}
}
// 标记当前课程已修完,加入结果列表
finishNum++;
res.push(currentCourse);
}
// 若修完的课程数等于总课程数,返回结果;否则存在环,返回空数组
return finishNum === numCourses ? res : [];
};
3. 解法优势与注意点
-
优势:思路直观,易于理解,时间复杂度O(n + m)(n为课程数,m为先修关系数),空间复杂度O(n + m),效率较高。
-
注意点:
-
邻接表初始化时,要给每门课程都分配空列表,避免后续get时返回undefined;
-
stack.shift() 实际是队列的操作(先进先出),若用stack.pop()(先进后出),得到的学习顺序不同,但均为合法答案;
-
finishNum的作用是判断是否存在环——若最终修完的课程数不足,说明有课程因环无法修完。
-
三、解法二:DFS + 状态标记
1. 算法思路
DFS解法的核心是「通过深度优先搜索,判断图中是否存在环,并在搜索完成后反向输出结果」,核心逻辑的是用状态标记来追踪每个节点的访问状态,具体步骤如下:
-
构建邻接表:与Kahn算法一致,存储每个课程的后续课程。
-
定义状态数组:用三个状态标记课程的访问情况:
-
0:未访问(初始状态);
-
1:访问中(正在递归遍历该课程的后续课程,尚未回溯);
-
2:访问完(该课程及其所有后续课程均已遍历完成)。
-
-
深度优先搜索每门课程:
-
若当前课程状态为1,说明存在环(递归过程中再次遇到「访问中」的课程,形成闭环),返回true(存在环);
-
若当前课程状态为2,说明已处理完毕,无需再次遍历,返回false;
-
将当前课程状态设为1(标记为访问中),递归遍历其所有后续课程;
-
递归完成后,将当前课程状态设为2(标记为访问完),并将其加入结果列表(注意:此处是逆序加入,因为DFS是先处理后续课程,再处理当前课程)。
-
-
若遍历过程中发现环,返回空数组;否则返回结果列表(已自动逆序,符合拓扑排序要求)。
2. 代码逐行解析
function findOrder_2(numCourses: number, prerequisites: number[][]): number[] {
// 邻接表:存储每个课程的后续课程
const adjacencyList: Map<number, number[]> = new Map();
// 初始化邻接表,每门课程对应空的后续课程列表
for (let i = 0; i < numCourses; i++) {
adjacencyList.set(i, []);
}
// 填充邻接表:preCourse的后续课程是course
for (const [course, preCourse] of prerequisites) {
adjacencyList.get(preCourse)!.push(course);
}
// 状态数组:0=未访问,1=访问中,2=访问完
const status: number[] = new Array(numCourses).fill(0)
// 结果列表:存储学习顺序(初始逆序,最后直接返回即可)
const res: number[] = [];
// 递归函数:判断当前课程是否存在环,同时填充结果列表
const hasCycle = (course: number): boolean => {
if (status[course] === 1) {
// 遇到访问中的课程,说明存在环
return true;
} else if (status[course] === 2) {
// 已访问完,无需再次处理
return false;
}
// 标记当前课程为访问中
status[course] = 1;
// 获取当前课程的所有后续课程(无后续课程则取空数组)
const nextCourses = adjacencyList.get(course) || [];
// 递归遍历所有后续课程,若任意一个存在环,直接返回true
for (let nextCourse of nextCourses) {
if (hasCycle(nextCourse)) return true;
}
// 递归完成,标记当前课程为访问完
status[course] = 2;
// 逆序加入结果列表(后续课程先处理,当前课程后处理,逆序后即为正确顺序)
res.unshift(course);
return false;
}
// 遍历所有课程,逐一进行DFS
for (let i = 0; i < numCourses; i++) {
if (hasCycle(i)) return []; // 存在环,返回空数组
}
// 无环,返回结果列表(已逆序,符合拓扑排序)
return res;
};
3. 解法优势与注意点
-
优势:无需维护入度表,逻辑更简洁,同样是O(n + m)的时间复杂度和空间复杂度,适合对递归理解熟练的同学。
-
注意点:
-
状态标记是核心,必须区分「访问中」和「访问完」,否则会误判环(比如重复访问已处理完的课程);
-
结果列表必须用unshift()逆序加入,因为DFS是先深入到最底层的后续课程,再回溯处理当前课程,逆序后才是正确的学习顺序;
-
邻接表get时用「|| []」避免undefined,防止遍历报错。
-
四、两种解法对比与总结
| 解法 | 核心思想 | 优点 | 缺点 |
|---|---|---|---|
| Kahn算法 | 入度表+栈/队列,贪心处理入度为0的节点 | 直观易懂,迭代实现无递归栈溢出风险 | 需维护入度表,代码略繁琐 |
| DFS+状态标记 | 递归遍历,用状态标记判断环,逆序输出结果 | 代码简洁,无需维护入度表 | 递归深度大时可能栈溢出(LeetCode中课程数最多10000,需注意,但一般可通过) |
总结
两道解法本质都是实现拓扑排序,核心是「判断图中是否有环」和「输出合法的拓扑顺序」,适用于不同的场景:
-
如果是初学者,优先掌握Kahn算法,思路更直观,不容易出错;
-
如果追求代码简洁,且对递归熟练,DFS解法更优。
另外,题目要求「返回任意一种正确顺序」,所以两种解法得到的顺序可能不同,但都符合题目要求,无需纠结顺序的一致性。
五、刷题小贴士
-
遇到「依赖关系」「先后顺序」类题目,优先考虑拓扑排序;
-
拓扑排序的关键是「判断环」,两种解法分别用「入度是否为0」和「状态标记」来判断环,可灵活选用;
-
邻接表是图论题目中常用的数据结构,务必熟练掌握其构建和遍历方式。