LC 207 课程表:刚学图论那会儿,我连这是拓扑排序都没看出来

77 阅读4分钟

不知道你有没有这种感觉,刚接触图论算法的时候,看着题目半天反应不过来。这不就是个普通的选课问题吗?怎么就跟拓扑排序扯上关系了?我第一次刷这道课程表的时候,盯着题目愣了五分钟,完全不知道从哪下手。

题目说的啥

其实核心特别简单。总共有 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。

image.png

代码实现

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 的,说明遇到环了,直接返回 true。如果是 0,说明没有被访问过,继续递归遍历。如果是 2,就不用再走了,已经确认过没问题。

image.png

代码实现

var canFinish = function(numCourses, prerequisites) {
    const adj = Array.from({length:numCourses},()=>[]);// 创建出一个空的课程学习空图
    // 0: 未访问  1: 访问中  2: 已访问
    const visited = new Array(numCourses).fill(0);
    
    for (const [a, b] of prerequisites) {// 绘制课程学习路线图
        adj[b].push(a);
    }
    
    const dfs = (i) => {        
        visited[i] = 1; // 标记为当前正在访问
        for (const next of adj[i]) {
            if(visited[next]==1||(visited[next]==0&&dfs(next))){// 如果相邻课程已经访问过了,则碰到环了,返回true;如果没有访问过则继续递归
                return true;
            }
        }
        visited[i] = 2; // 所有后继都走完了,标记这条路线已探查完毕无环
        return false;
    }
    
    for (let i = 0; i < numCourses; i++) {
        if(visited[i]==0&&dfs(i)){// 探查状态为0的路线
            return false;
        }
    }
    return true;
};

复杂度和 BFS 是一样的,都是 O (n + m)。

最后唠两句

这道题是拓扑排序的入门必刷题,核心就是有向图判环。两种方法都能解决,BFS 更直观,符合选课的直觉;DFS 代码写起来更简洁一点。

我自己踩过的坑给你们提个醒:一个是先修关系的方向搞反,邻接表建错,提交之后怎么都不对;一个是 DFS 的时候忘记遍历所有节点,只从 0 出发,碰到不连通的图就错了。

你第一次做这道题的时候,最先想到的是哪种方法?有没有在边的方向或者状态标记上踩过坑?评论区聊聊呗,我都会看的。如果觉得这篇对你有帮助,点个赞让更多人看到~