LeetCode Hot100(38/100)——207. 课程表

文章目录

题目描述

你必须修完 numCourses 门课程(编号 0 到 numCourses - 1)。给定一个数组 prerequisites,其中 prerequisites[i] = [ai, bi] 表示在修课程 ai 之前,必须先修完课程 bi。请判断你是否可能完成所有课程的学习。

示例

  • 输入:numCourses = 2, prerequisites = [[1, 0]]
  • 输出:true (先修 0,再修 1)
  • 输入:numCourses = 2, prerequisites = [[1, 0], [0, 1]]
  • 输出:false (0 依赖 1,1 依赖 0,形成死循环)

问题建模

这本质上是一个有向图(Directed Graph) 问题。

  • 节点(Vertex):每一门课程。
  • 边(Edge) :先修关系。如果必须先修 b 再修 a,则存在一条有向边 b -> a
  • 核心目标 :判断图中是否存在环(Cycle)
    • 如果存在环(例如 A -> B -> C -> A),则永远无法完成课程,返回 false
    • 如果不存在环(即该图是一个有向无环图 DAG ),则可以完成,返回 true

下面的思维导图展示了该问题的解题脉络:
课程表问题
核心概念
有向图建模
环检测 Cycle Detection
解法一: BFS
广度优先搜索
入度表 Indegree
Kahn 算法
解法二: DFS
深度优先搜索
三色标记法
递归栈


预处理:构建图

无论使用 BFS 还是 DFS,我们首先都需要将题目给出的"边列表"转换为更易于操作的"邻接表"。

java 复制代码
// 邻接表结构:adjacency[i] 存储了所有依赖于课程 i 的后续课程
List<List<Integer>> adjacency = new ArrayList<>();
for (int i = 0; i < numCourses; i++) {
    adjacency.add(new ArrayList<>());
}
// 填充邻接表
for (int[] cp : prerequisites) {
    // cp[1] 是先修课,cp[0] 是后修课,即 cp[1] -> cp[0]
    adjacency.get(cp[1]).add(cp[0]);
}

解法一:广度优先搜索 (BFS) - 入度表法

原理分析

BFS 解法通常被称为 Kahn 算法 。其核心思想是维护每个节点的入度(Indegree)

  • 入度:指向该节点的边的数量。在课程表中,代表"还需要修多少门先修课才能修这门课"。
  • 逻辑
    1. 如果一门课的入度为 0,说明它不需要任何先修课(或者先修课已经修完了),我们就可以"学习"它。
    2. 当我们学习了一门课,所有依赖它的后续课程的入度就可以减 1。
    3. 重复此过程,看是否能学完所有课程。

算法流程





遍历结束


开始
构建邻接表 & 计算所有节点入度
将所有入度为0的节点

放入队列 Queue
队列是否为空?
学习课程数 == 总课程数?
从队列取出一个节点 u
学习课程数 + 1
遍历 u 的所有邻居节点 v
节点 v 的入度 - 1
v 的入度 == 0?
将 v 加入队列
True: 无环
False: 有环

Java 代码实现

java 复制代码
import java.util.*;

class Solution {
    public boolean canFinish(int numCourses, int[][] prerequisites) {
        // 1. 初始化邻接表和入度数组
        List<List<Integer>> adjacency = new ArrayList<>();
        int[] indegrees = new int[numCourses];
        
        for (int i = 0; i < numCourses; i++) {
            adjacency.add(new ArrayList<>());
        }
        
        // 2. 构建图并统计入度
        for (int[] cp : prerequisites) {
            int course = cp[0];
            int prerequisite = cp[1];
            adjacency.get(prerequisite).add(course);
            indegrees[course]++;
        }
        
        // 3. 将所有入度为0的课程入队
        Queue<Integer> queue = new LinkedList<>();
        for (int i = 0; i < numCourses; i++) {
            if (indegrees[i] == 0) {
                queue.offer(i);
            }
        }
        
        // 4. BFS 过程
        int finishedCourses = 0;
        while (!queue.isEmpty()) {
            int current = queue.poll();
            finishedCourses++;
            
            // 遍历当前课程的所有后续课程
            for (int nextCourse : adjacency.get(current)) {
                indegrees[nextCourse]--; // 依赖减1
                // 如果入度变为0,说明依赖全部完成,加入队列
                if (indegrees[nextCourse] == 0) {
                    queue.offer(nextCourse);
                }
            }
        }
        
        // 5. 判断是否所有课程都修完了
        return finishedCourses == numCourses;
    }
}

复杂度分析

  • 时间复杂度 : O ( V + E ) O(V + E) O(V+E)。 V V V 为课程数, E E E 为先修关系数。我们需要遍历所有的点和所有的边各一次。
  • 空间复杂度 : O ( V + E ) O(V + E) O(V+E)。邻接表存储图需要 O ( V + E ) O(V + E) O(V+E) 的空间,入度数组和队列需要 O ( V ) O(V) O(V) 的空间。

解法二:深度优先搜索 (DFS) - 三色标记法

原理分析

DFS 的思路是沿着一条路径一直往下走。如果在递归的过程中,我们又回到了当前路径上已经访问过的节点,那么就说明图中存在环。

为了区分"当前路径正在访问的节点"和"以前路径访问过且已经安全的节点",我们需要引入三色标记法

  • 未搜索 (0/White):节点还未被访问。
  • 搜索中 (1/Gray) :节点正在递归栈中,当前路径正在经过它。如果 DFS 遇到标记为 1 的节点,说明有环
  • 已完成 (2/Black):节点及其所有子节点都已检查完毕,没有环。如果 DFS 遇到标记为 2 的节点,直接跳过。

状态流转图

开始DFS
遇到标记1的邻居\n(发现环!)
所有邻居检查完毕\n无环
未搜索(0)
搜索中(1)
已完成(2)

Java 代码实现

java 复制代码
import java.util.*;

class Solution {
    public boolean canFinish(int numCourses, int[][] prerequisites) {
        List<List<Integer>> adjacency = new ArrayList<>();
        for (int i = 0; i < numCourses; i++) {
            adjacency.add(new ArrayList<>());
        }
        for (int[] cp : prerequisites) {
            adjacency.get(cp[1]).add(cp[0]);
        }
        
        // 标记数组:0=未搜索, 1=搜索中, 2=已完成
        int[] flags = new int[numCourses];
        
        // 因为图可能不是连通的,需要对每个节点尝试 DFS
        for (int i = 0; i < numCourses; i++) {
            if (!dfs(adjacency, flags, i)) {
                return false; // 只要发现环,立即返回 false
            }
        }
        return true;
    }

    // 返回 true 表示无环,返回 false 表示有环
    private boolean dfs(List<List<Integer>> adjacency, int[] flags, int i) {
        if (flags[i] == 1) return false; // 再次遇到正在搜索的节点 -> 有环
        if (flags[i] == 2) return true;  // 遇到已完成节点 -> 安全,跳过
        
        // 标记为"搜索中"
        flags[i] = 1;
        
        // 递归访问所有邻居
        for (int neighbor : adjacency.get(i)) {
            if (!dfs(adjacency, flags, neighbor)) {
                return false; // 下层发现环,向上传递
            }
        }
        
        // 标记为"已完成"
        flags[i] = 2;
        return true;
    }
}

复杂度分析

  • 时间复杂度 : O ( V + E ) O(V + E) O(V+E)。每个节点只会被访问一次,每条边也只会被遍历一次。
  • 空间复杂度 : O ( V + E ) O(V + E) O(V+E)。邻接表存储图 O ( V + E ) O(V + E) O(V+E),标记数组 O ( V ) O(V) O(V),递归栈最大深度为 O ( V ) O(V) O(V)。

总结与对比

特性 BFS (Kahn 算法) DFS (三色标记法)
核心思想 贪心策略,剥洋葱皮,不断移除入度为0的节点 递归回溯,一条路走到黑,检测是否回到原路
实现难度 中等,需要维护入度表和队列 中等,需要理解递归状态
空间开销 队列 + 数组 系统栈 + 数组
适用场景 仅仅需要判断可行性,或需要输出拓扑排序结果 判断环的存在,或者需要检测特定路径
直观性 符合"先修先学"的直觉 符合"探测回路"的逻辑
相关推荐
寻寻觅觅☆11 小时前
东华OJ-基础题-106-大整数相加(C++)
开发语言·c++·算法
偷吃的耗子12 小时前
【CNN算法理解】:三、AlexNet 训练模块(附代码)
深度学习·算法·cnn
化学在逃硬闯CS13 小时前
Leetcode1382. 将二叉搜索树变平衡
数据结构·算法
ceclar12313 小时前
C++使用format
开发语言·c++·算法
Gofarlic_OMS13 小时前
科学计算领域MATLAB许可证管理工具对比推荐
运维·开发语言·算法·matlab·自动化
夏鹏今天学习了吗14 小时前
【LeetCode热题100(100/100)】数据流的中位数
算法·leetcode·职场和发展
忙什么果14 小时前
上位机、下位机、FPGA、算法放在哪层合适?
算法·fpga开发
董董灿是个攻城狮14 小时前
AI 视觉连载4:YUV 的图像表示
算法
ArturiaZ15 小时前
【day24】
c++·算法·图论
大江东去浪淘尽千古风流人物16 小时前
【SLAM】Hydra-Foundations 层次化空间感知:机器人如何像人类一样理解3D环境
深度学习·算法·3d·机器人·概率论·slam