LEETCODE 207Medium

课程表

判断能否修完所有课就是判断有向图无环:不断摘掉入度为 0 的点,摘得完就无环。

问题拆解

numCourses 门课,prerequisites 里的 [a, b] 表示修 a 之前必须先修 b,问能否修完所有课。把每门课看成节点、每条先修关系看成 b -> a 的有向边,问题就变成:这张有向图有没有环?无环就存在一个合法的修课顺序(拓扑序),有环则环上的课互相等待,谁也修不了。

怎么判环?模拟现实里的选课过程:一门课能开修,当且仅当它的所有先修课都已修完——对应图上“入度为 0”。修完它之后,依赖它的课程各自少了一门先修课——对应“把它的出边删掉,邻居入度减一”。不断重复“找入度 0 的点、摘掉、更新邻居”,这就是 Kahn 拓扑排序。

环上的每个节点都有一条来自环内的入边,无论外围怎么摘,它们的入度永远到不了 0,于是永远进不了队列。“摘掉的点数 < 总数”与“有环”是一回事。

入度表加队列的拓扑排序

public boolean canFinish(int numCourses, int[][] prerequisites) {
    List<List<Integer>> graph = new ArrayList<>();
    for (int i = 0; i < numCourses; i++) {
        graph.add(new ArrayList<>());
    }
    int[] indegree = new int[numCourses];
    for (int[] p : prerequisites) {
        graph.get(p[1]).add(p[0]); // 先修 p[1] 才能修 p[0]
        indegree[p[0]]++;
    }
    Deque<Integer> queue = new ArrayDeque<>();
    for (int i = 0; i < numCourses; i++) {
        if (indegree[i] == 0) {
            queue.offer(i); // 没有先修课,直接可修
        }
    }
    int finished = 0;
    while (!queue.isEmpty()) {
        int cur = queue.poll();
        finished++;
        for (int next : graph.get(cur)) {
            if (--indegree[next] == 0) { // 先修课清零,解锁
                queue.offer(next);
            }
        }
    }
    return finished == numCourses;
}
def canFinish(numCourses: int, prerequisites: List[List[int]]) -> bool:
    graph = defaultdict(list)
    indegree = [0] * numCourses
    for a, b in prerequisites:
        graph[b].append(a)  # 先修 b 才能修 a
        indegree[a] += 1

    queue = deque(i for i in range(numCourses) if indegree[i] == 0)  # 没有先修课,直接可修
    finished = 0
    while queue:
        cur = queue.popleft()
        finished += 1
        for nxt in graph[cur]:
            indegree[nxt] -= 1
            if indegree[nxt] == 0:  # 先修课清零,解锁
                queue.append(nxt)
    return finished == numCourses
func canFinish(numCourses int, prerequisites [][]int) bool {
    graph := make([][]int, numCourses)
    indegree := make([]int, numCourses)
    for _, p := range prerequisites {
        graph[p[1]] = append(graph[p[1]], p[0]) // 先修 p[1] 才能修 p[0]
        indegree[p[0]]++
    }
    queue := []int{}
    for i, d := range indegree {
        if d == 0 {
            queue = append(queue, i) // 没有先修课,直接可修
        }
    }
    finished := 0
    for len(queue) > 0 {
        cur := queue[0]
        queue = queue[1:]
        finished++
        for _, next := range graph[cur] {
            indegree[next]--
            if indegree[next] == 0 { // 先修课清零,解锁
                queue = append(queue, next)
            }
        }
    }
    return finished == numCourses
}
pub fn can_finish(num_courses: i32, prerequisites: Vec<Vec<i32>>) -> bool {
    let n = num_courses as usize;
    let mut graph = vec![Vec::new(); n];
    let mut indegree = vec![0; n];
    for p in &prerequisites {
        let (a, b) = (p[0] as usize, p[1] as usize);
        graph[b].push(a); // 先修 b 才能修 a
        indegree[a] += 1;
    }
    let mut queue: std::collections::VecDeque<usize> =
        (0..n).filter(|&i| indegree[i] == 0).collect(); // 没有先修课,直接可修
    let mut finished = 0;
    while let Some(cur) = queue.pop_front() {
        finished += 1;
        for &next in &graph[cur] {
            indegree[next] -= 1;
            if indegree[next] == 0 { // 先修课清零,解锁
                queue.push_back(next);
            }
        }
    }
    finished == n
}

最容易搞反的是建边方向:[a, b] 是“修 a 之前先修 b”,边应当是 b -> a(b 修完才解锁 a),入度加在 a 头上;方向建反了,简单用例照样能过,藏得很深。另外每个节点入度恰好减到 0 时只入队一次,不需要 visited 数组——入度本身就承担了去重。判断依据必须是出队计数 finished == numCourses,而不是“队列空了”:有环时队列也会正常变空,只是环上的课从没进来过。

DFS 三色标记(未访问/访问中/已完成,撞上“访问中”即有环)也能判环,两者复杂度相同;BFS 版的额外好处是出队序列就是一个合法修课顺序,第 210 题“课程表 II”只需把它收集起来返回。

复杂度

指标 复杂度 原因
时间 O(V + E) 每个节点入队一次,每条边被松弛一次
空间 O(V + E) 邻接表、入度数组和队列

可以迁移的模式

  • “x 依赖 y”的判定与调度问题,第一步都是建有向图、问有没有环;
  • Kahn 算法的骨架:入度表 + 零入度队列 + 出队计数,计数不满即有环;
  • 入度减到 0 才入队,天然保证每个点只处理一次,省掉 visited。

任务调度、编译依赖、包管理器装包顺序,内核全是这一套拓扑排序——课程表是它最干净的题面。