不知道你有没有这种感觉,刚接触图论算法的时候,看着题目半天反应不过来。这不就是个普通的选课问题吗?怎么就跟拓扑排序扯上关系了?我第一次刷这道课程表的时候,盯着题目愣了五分钟,完全不知道从哪下手。
题目说的啥
其实核心特别简单。总共有 numCourses 门课,编号从 0 到 numCourses - 1。给你一堆先修关系,比如 [a, b] 代表:想学 a 这门课,必须先上完 b。问你能不能把所有课程都顺利上完。
说白了就是判断这个有向图里有没有环 ------ 有环就死循环了,肯定上不完。
我最先理解的方法:BFS 入度表
想通之后我发现,这个思路特别符合选课的直觉。没有先修要求的课,肯定可以直接学对吧?学完一门课,那些依赖它的课,就少了一个阻碍。等一门课的所有先修都搞定了,它也就可以学了。
这个思路对应到算法里就是 Kahn 算法,用入度表来实现。
举个最简单的例子:2 门课,先修关系是 [[1,0]]。学 1 要先学 0,所以 0 的入度是 0,1 的入度是 1。先把 0 放进队列,学完 0 之后,1 的入度就变成 0 了,也可以学。最后一共学了 2 门,和总数相等,返回 true。
如果是互相依赖的情况,比如 [[1,0],[0,1]],两个入度都是 1,谁也进不了队列,最后只学了 0 门,就返回 false。
代码实现
javascript
var canFinish = function(numCourses, prerequisites) {
// 邻接表:存每门课的后继课程
const adj = new Array(numCourses).fill(0).map(() => []);
// 入度数组:记录每门课有几个必须先上的课
const inDegree = new Array(numCourses).fill(0);
for (const [a, b] of prerequisites) {
// 学a要先学b,所以边是 b -> a,a的入度+1
adj[b].push(a);
inDegree[a]++;
// 别搞反方向!我第一次写反了,调了半小时才反应过来
}
const queue = [];
// 先把所有入度为0的课入队,这些课没有先修,可以直接学
for (let i = 0; i < numCourses; i++) {
if (inDegree[i] === 0) {
queue.push(i);
}
}
let count = 0; // 记录已经学完的课程数量
while (queue.length) {
const cur = queue.shift();
count++;
// 遍历当前课的所有后继课程
for (const next of adj[cur]) {
inDegree[next]--;
// 这门课的所有先修都搞定了,可以加入学习队列
if (inDegree[next] === 0) {
queue.push(next);
}
}
}
// 学完的数量等于总课程数,就说明全部都能上完
return count === numCourses;
};
时间复杂度是 O (n + m),n 是课程数,m 是先修关系的数量,每个节点每条边都只走一次。空间复杂度也是 O (n + m),用来存邻接表和入度数组。
另一种思路:DFS 判环
其实我最先想到的是 DFS。判断有没有环嘛,深度优先遍历,走着走着走回自己正在走的路,那不就是环了。
这个方法的核心是给每个节点标记三种状态:
- 0 代表还没访问过
- 1 代表正在访问(在当前的递归路径里)
- 2 代表已经访问完了
遍历每一个节点,如果碰到状态是 1 的,说明遇到环了,直接返回 false。如果是 2,就不用再走了,已经确认过没问题。
代码实现
javascript
var canFinish = function(numCourses, prerequisites) {
const adj = new Array(numCourses).fill(0).map(() => []);
// 0: 未访问 1: 访问中 2: 已访问
const visited = new Array(numCourses).fill(0);
for (const [a, b] of prerequisites) {
adj[b].push(a);
}
const dfs = (i) => {
// 碰到正在访问的节点,说明走出环了
if (visited[i] === 1) return false;
// 已经访问过的节点,不用再走
if (visited[i] === 2) return true;
visited[i] = 1; // 标记为当前正在访问
for (const next of adj[i]) {
if (!dfs(next)) return false;
}
visited[i] = 2; // 所有后继都走完了,标记为已访问
return true;
}
// 每个节点都要遍历一遍,防止有不连通的分量
// 这个坑我踩过!一开始只从0出发,漏掉了孤立的课程
for (let i = 0; i < numCourses; i++) {
if (!dfs(i)) return false;
}
return true;
};
复杂度和 BFS 是一样的,都是 O (n + m)。
最后唠两句
这道题是拓扑排序的入门必刷题,核心就是有向图判环。两种方法都能解决,BFS 更直观,符合选课的直觉;DFS 代码写起来更简洁一点。
我自己踩过的坑给你们提个醒:一个是先修关系的方向搞反,邻接表建错,提交之后怎么都不对;一个是 DFS 的时候忘记遍历所有节点,只从 0 出发,碰到不连通的图就错了。
你第一次做这道题的时候,最先想到的是哪种方法?有没有在边的方向或者状态标记上踩过坑?评论区聊聊呗,我都会看的。如果觉得这篇对你有帮助,点个赞让更多人看到~