在算法面试中,课程表问题(LeetCode 207)是一个经典的有向图环检测问题。它模拟了大学选课时的先修关系:给定若干门课程和它们的依赖关系,判断是否可能完成所有课程。本文将从图论建模出发,深入讲解两种主流解法——BFS 入度表法DFS 三色标记法,并附上 Java、Python、TypeScript 等语言的代码思路,帮助你彻底掌握这类问题。

问题建模:将课程表转化为有向图

题目要求:你必须修完 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

预处理:构建邻接表

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

// 邻接表结构: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]);
    }

提示:在 Python 中,可以用 defaultdict(list) 简化;在 TypeScript 中,可以用 Map<number, number[]>;在 Go 中,可以用 map[int][]int。代码风格虽异,核心逻辑一致。

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

BFS 解法通常被称为 Kahn 算法。其核心思想是维护每个节点的入度(Indegree)——指向该节点的边的数量。在课程表中,入度代表“还需要修多少门先修课才能修这门课”。

逻辑:

  • 如果一门课的入度为 0,说明它不需要任何先修课(或者先修课已经修完了),我们就可以“学习”它。
  • 当我们学习了一门课,所有依赖它的后续课程的入度就可以减 1。
  • 重复此过程,看是否能学完所有课程。

算法流程:

  1. 构建邻接表并计算所有节点入度。
  2. 将所有入度为 0 的节点放入队列。
  3. 循环:从队列取出一个节点,学习课程数 +1,遍历其所有邻居,将邻居入度减 1;若邻居入度变为 0,则将其加入队列。
  4. 最终判断:学习课程数是否等于总课程数?是则无环,否则有环。
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)(V 为课程数,E 为先修关系数),空间复杂度 O(V + E)。

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

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

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

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

状态流转图:

  • 开始DFS → 未搜索(0) → 搜索中(1) → 遇到标记1的邻居(发现环!)→ 返回 false
  • 所有邻居检查完毕无环 → 已完成(2) → 返回 true
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;
      }
      }

⚠️ 注意:在 Python 中,DFS 递归可能导致栈溢出(尤其当 V 很大时),可以考虑使用显式栈模拟。在 Go 中,递归深度同样需要注意。

复杂度分析:时间复杂度 O(V + E),空间复杂度 O(V + E)。

总结与对比

特性BFS (Kahn 算法)DFS (三色标记法)
核心思想贪心策略,剥洋葱皮,不断移除入度为0的节点递归回溯,一条路走到黑,检测是否回到原路
实现难度中等,需要维护入度表和队列中等,需要理解递归状态
空间开销队列 + 数组系统栈 + 数组
适用场景仅仅需要判断可行性,或需要输出拓扑排序结果判断环的存在,或者需要检测特定路径
直观性符合“先修先学”的直觉符合“探测回路”的逻辑

实践建议

  • 如果依赖关系图稀疏(E ≈ V),BFS 和 DFS 性能相近,选你熟悉的即可。
  • 如果图稠密(E ≈ V²),BFS 的队列操作比 DFS 的递归栈更节省内存。
  • 生产代码(如 TypeScript 或 Java 后端)中,BFS 通常更易于调试和测试。
  • 面试手写时,DFS 三色标记法代码更短,但要注意递归栈溢出。

延伸思考:类似问题还有 LeetCode 210(课程表 II,要求输出一种拓扑排序顺序)、LeetCode 630(课程表 III,加入时间窗口)。掌握环检测是解决所有依赖关系问题的基础。

[AFFILIATE_SLOT_1]

多语言实现提示

除了 Java,你还可以用以下语言实现:

  • Python:使用 collections.deque 实现队列,defaultdict(list) 实现邻接表。
  • TypeScript:用 Map<number, number[]> 和数组实现入度表。
  • Go:用 map[int][]int 和切片实现,注意 Go 没有内置队列,可以用切片模拟。
  • C++:用 vector<vector<int>>queue<int>

每种语言的实现思路完全一致,核心都是邻接表 + 入度/颜色标记。建议你至少用两种语言练习,加深理解。

[AFFILIATE_SLOT_2]

结语

课程表问题(LeetCode 207)是有向图环检测的典型代表。通过 BFS 入度表法和 DFS 三色标记法,我们不仅解决了课程依赖问题,更掌握了拓扑排序和深度优先搜索的核心思想。无论是准备面试还是日常开发中的依赖解析,这种“建模 → 建图 → 环检测”的思维模式都极具价值。