LeetCode 207. 课程表:两种解法(BFS+DFS)详细解析

LeetCode 经典拓扑排序问题------207. 课程表。这道题是大厂面试常考的基础题,核心考察「有向图环检测」,也是拓扑排序的典型应用场景。下面会先梳理题目核心,再逐行解析两种解法(BFS Kahn算法 + DFS 状态标记法),最后对比两种解法的适用场景,帮大家吃透这道题。

一、题目解读(通俗版)

题目很简单,一句话概括:你要修 numCourses 门课,有些课需要先修其他课(比如学高数前要先学高中数学),判断能不能把所有课都修完。

关键信息拆解:

  • 课程编号:0 到 numCourses - 1(纯数字标识,无实际意义);

  • 先修关系:prerequisites[i] = [ai, bi] → 修 ai 必须先修 bi(重点:是 bi → ai 的依赖关系,不是 ai → bi);

  • 核心问题:判断课程依赖关系中是否存在「环」(比如 A→B、B→C、C→A,互相依赖就永远修不完)。

举个例子:

prerequisites = [[0, 1]] → 修 0 必须先修 1 → 无环,返回 true;

prerequisites = [[0,1], [1,0]] → 修 0 先修 1,修 1 先修 0 → 有环,返回 false。

二、核心思路:有向图环检测

这道题的本质是「有向无环图(DAG)的判定」:

  • 每门课程 = 图的「节点」;

  • 先修关系 [ai, bi] = 图的「有向边」(bi 指向 ai,代表 bi 是 ai 的前置依赖);

  • 能修完所有课 → 图中无环 → 可以进行拓扑排序;

  • 修不完 → 图中有环 → 无法进行拓扑排序。

拓扑排序有两种经典实现方式,对应下面的两种解法,我们逐一拆解。

三、解法一:BFS(Kahn算法)------ 基于入度表

这是最直观、最易理解的解法,核心思路是「消解依赖」:先修无依赖的课(入度为0),修完后减少后续课程的依赖,直到所有课都修完,或发现有课无法消解依赖(有环)。

3.1 完整代码(TS版)

typescript 复制代码
function canFinish_1(numCourses: number, prerequisites: number[][]): boolean {
  // 邻接表:key是先修课,value是该先修课的后续课程列表
  const adjacencyList: Map<number, number[]> = new Map();
  // 入度表:记录每个课程需要的先修课数量(依赖数量)
  const inDegree: number[] = new Array(numCourses).fill(0);

  // 初始化邻接表(每个课程都有一个空的后续课程列表)
  for (let i = 0; i < numCourses; i++) {
    adjacencyList.set(i, []);
  }

  // 填充邻接表和入度表
  for (const [course, preCourse] of prerequisites) {
    // 学习course需要先学preCourse → preCourse的后续课程是course
    adjacencyList.get(preCourse)!.push(course);
    // course的依赖数(入度)+1
    inDegree[course]++;
  }

  // 队列:存储所有无依赖(入度为0)的课程,可直接修
  const queue: number[] = [];
  for (let i = 0; i < numCourses; i++) {
    if (inDegree[i] === 0) {
      queue.push(i);
    }
  }

  let completedCourses = 0; // 记录已修完的课程数
  while (queue.length > 0) {
    const currentCourse = queue.shift()!; // 取出当前可修的课程
    completedCourses++; // 修完一门,计数+1

    // 获取当前课程的所有后续课程(修完当前课,这些课的依赖会减少)
    const nextCourses = adjacencyList.get(currentCourse)!;
    for (const nextCourse of nextCourses) {
      inDegree[nextCourse]--; // 后续课程的依赖数-1
      // 若依赖数变为0,说明该课可修,加入队列
      if (inDegree[nextCourse] === 0) {
        queue.push(nextCourse);
      }
    }
  }

  // 若修完的课程数等于总课程数,说明无环;否则有环
  return completedCourses === numCourses;
};

3.2 逐行解析(重点吃透两个表)

1. 邻接表(adjacencyList)

作用:存储每个课程的「后续课程」,也就是"修完这门课后,能解锁哪些课"。

比如 prerequisites = [[0,1], [2,1]],邻接表中 key=1 的 value 是 [0,2],表示修完 1 之后,能修 0 和 2。

初始化时给每个课程分配空数组,避免后续 get 时出现 undefined。

2. 入度表(inDegree)

作用:记录每门课程的「依赖数量」,也就是"修这门课需要先修几门课"。

比如课程 0 需要先修 1,那么 inDegree[0] = 1;无依赖的课程(比如 1),inDegree[1] = 0。

3. 队列操作(核心逻辑)

  • 初始:把所有入度为0的课程加入队列(这些课无依赖,可直接开始修);

  • 循环:每次从队列取出一门课,修完(计数+1),然后减少其后续课程的入度;

  • 终止:队列空时,若修完的课程数等于总课程数 → 无环;否则 → 有环(剩下的课有无法消解的依赖)。

4. 关键细节

adjacencyList.get(preCourse)! 中的「!」:因为我们初始化时给每个课程都设置了空数组,所以 get 一定能拿到值,用「!」消除 TS 的 undefined 警告。

queue.shift()!:队列非空时才会进入循环,所以 shift() 一定能拿到值,同样用「!」消除警告。

四、解法二:DFS(状态标记法)------ 基于递归栈检测环

DFS 解法的核心思路是「跟踪节点访问状态」:通过标记每个节点的访问状态,检测递归过程中是否回到"正在访问"的节点(若回到,则存在环)。

4.1 完整代码(TS版)

typescript 复制代码
function canFinish_2(numCourses: number, prerequisites: number[][]): boolean {
  // 邻接表:和BFS解法一致,存储先修课的后续课程
  const adjacencyList: Map<number, number[]> = new Map();

  // 初始化邻接表
  for (let i = 0; i < numCourses; i++) {
    adjacencyList.set(i, []);
  }

  // 填充邻接表(逻辑和BFS一致)
  for (const [course, preCourse] of prerequisites) {
    adjacencyList.get(preCourse)!.push(course);
  }

  // 状态数组:0=未访问,1=正在访问(当前递归栈中),2=已访问完成
  const status: number[] = new Array(numCourses).fill(0);

  // 递归函数:检测从当前课程出发,是否存在环
  const hasCycle = (course: number): boolean => {
    // 若当前课程正在访问中(递归栈里有它),说明存在环
    if (status[course] === 1) {
      return true;
    }
    // 若当前课程已访问完成,直接返回无环(避免重复遍历)
    if (status[course] === 2) {
      return false;
    }

    // 标记当前课程为「正在访问」
    status[course] = 1;
    // 获取当前课程的后续课程(若没有,取空数组)
    const nextCourses = adjacencyList.get(course) || [];

    // 递归检测每一个后续课程
    for (const next of nextCourses) {
      if (hasCycle(next)) return true; // 发现环,立即返回
    }

    // 所有后续课程都检测完,无环,标记为「已访问完成」
    status[course] = 2;
    return false;
  }

  // 遍历所有课程,逐个检测是否存在环
  for (let i = 0; i < numCourses; i++) {
    if (hasCycle(i)) return false; // 有环,直接返回false
  }

  // 所有课程都无环,返回true
  return true;
};

4.2 逐行解析(重点吃透状态标记)

1. 邻接表

和 BFS 解法完全一致,不再重复,核心是存储课程间的依赖关系。

2. 状态数组(status)------ DFS 核心

三个状态的意义(重中之重):

  • 0:未访问 → 还没开始遍历这个节点;

  • 1:正在访问 → 已经进入递归栈,正在遍历它的后续节点(还没遍历完);

  • 2:已访问完成 → 该节点的所有后续节点都遍历完了,确认无环。

举个环的例子:A→B→C→A

递归 A(标记为1)→ 递归 B(标记为1)→ 递归 C(标记为1)→ 递归 A(此时 A 状态为1)→ 发现环,返回 true。

3. 递归函数 hasCycle

作用:检测从当前课程出发,是否存在环,返回布尔值(true=有环,false=无环)。

执行流程:

  1. 先判断状态:若为1 → 有环;若为2 → 无环;

  2. 标记当前课程为1(正在访问);

  3. 递归遍历所有后续课程,若任意一个后续课程返回 true(有环),则当前函数也返回 true;

  4. 所有后续课程遍历完,标记当前课程为2(已完成),返回 false(无环)。

4. 关键细节

adjacencyList.get(course) || []:避免后续课程为空时,for 循环报错(虽然初始化时已设空数组,但加个兜底更稳妥)。

递归终止条件:必须先判断状态,再进行递归,否则会出现重复遍历,导致效率低下或栈溢出。

五、两种解法对比(必看!)

两种解法的时间复杂度、空间复杂度都是 O(n + m)(n=课程数,m=先修关系数),但适用场景有差异,面试时可根据需求选择:

对比维度 BFS(Kahn算法) DFS(状态标记法)
核心逻辑 消解依赖,统计可修课程数 跟踪递归栈,检测环
优势 直观易懂,可直接输出拓扑排序结果 代码更简洁,仅需检测环时更高效
劣势 需维护入度表和队列,空间更显性 递归深度过大时可能栈溢出(JS默认栈深约1000)
适用场景 需输出拓扑序列、课程数较多(避免栈溢出) 仅需检测环、课程数较少(递归深度可控)

六、面试高频考点 & 避坑指南

1. 高频考点

  • 拓扑排序的两种实现方式(BFS/DFS);

  • 有向图环检测的核心思路;

  • 邻接表、入度表的构建逻辑。

2. 避坑点

  • 先修关系搞反:prerequisites[i] = [ai, bi] 是「修 ai 先修 bi」,不是反过来,邻接表要填 bi 的后续是 ai;

  • TS 类型警告:忘记初始化邻接表,导致 get 时返回 undefined,需提前给每个课程设置空数组;

  • DFS 栈溢出:当课程数极大(比如 10000+),优先用 BFS,或把 DFS 改成迭代版。

七、总结

LeetCode 207. 课程表的核心是「有向图环检测」,两种解法各有优劣:

  • 新手优先学 BFS:逻辑直观,容易理解,面试时写出来不易出错;

  • 追求简洁学 DFS:代码量更少,核心是掌握三个状态的标记逻辑。

其实这道题的两种解法,本质上都是拓扑排序的实现,掌握这道题,后续遇到拓扑排序相关的变体(比如输出拓扑序列、课程安排II)也能轻松应对。

相关推荐
Gorway1 小时前
解析残差网络 (ResNet)
算法
Kayshen1 小时前
我用纯前端逆向了 Figma 的二进制文件格式,实现了 .fig 文件的完整解析和导入
前端·agent·ai编程
wuhen_n1 小时前
模板编译三阶段:parse-transform-generate
前端·javascript·vue.js
椰子皮啊1 小时前
音视频会议 ASR 实战:概率性识别不准问题定位与解决
前端
小码哥_常1 小时前
Kotlin扩展:为代码注入新活力
前端
小码哥_常1 小时前
Kotlin函数进阶:解锁可变参数与局部函数的奇妙用法
前端
Wect1 小时前
浏览器缓存机制
前端·面试·浏览器
滕青山1 小时前
正则表达式测试 在线工具核心JS实现
前端·javascript·vue.js
不可能的是1 小时前
前端图片懒加载方案全解析
前端·javascript
拖拉斯旋风1 小时前
LeetCode 经典算法题解析:优先队列与广度优先搜索的巧妙应用
算法