LeetCode 210. 课程表 II 题解:Kahn算法+DFS 双解法精讲

在LeetCode的图论题目中,「课程表」系列绝对是拓扑排序的经典应用,其中210. 课程表 II 相比101. 课程表,不仅要求判断是否能完成所有课程,还需要返回具体的学习顺序,难度略有提升,但核心依然围绕「拓扑排序」展开。

今天就来详细拆解这道题,分享两种主流解法------ Kahn算法(入度表+队列/栈)深度优先搜索(DFS)+ 状态标记,结合代码逐行解析,帮大家搞懂每一步的逻辑,轻松掌握拓扑排序的实战技巧。

一、题目核心解读

先再明确一下题目要求,避免理解偏差:

  • 给定 numCourses 门课程(编号0~numCourses-1),以及先修关系数组 prerequisites,其中 prerequisites[i] = [ai, bi] 表示「选修ai前必须先修bi」。

  • 要求返回一种可行的学习顺序,能修完所有课程;若存在环(比如A需先修B,B需先修A),则返回空数组。

核心考点:拓扑排序------对有向无环图(DAG)的顶点进行排序,使得对于每一条有向边(u, v),顶点u在排序中都在顶点v之前。如果图中存在环,则不存在拓扑排序。

二、解法一:Kahn算法(入度表+栈)

1. 算法思路

Kahn算法是拓扑排序的贪心算法,核心逻辑是「先处理入度为0的节点」(入度:当前节点需要的先修课程数量),具体步骤如下:

  1. 构建两个核心数据结构:

    • 邻接表:存储每个课程的后续课程(即修完该课程后可以修的课程);

    • 入度表:存储每个课程的入度(需要先修的课程数量)。

  2. 初始化:将所有入度为0的课程加入栈(或队列,此处用栈,队列也可),这些课程可以直接开始学习,无需先修。

  3. 循环处理栈中课程:

    • 弹出栈顶课程,将其加入结果列表;

    • 遍历该课程的所有后续课程,将它们的入度减1(因为当前课程已修完,相当于少了一门先修课);

    • 若某后续课程的入度减为0,说明其所有先修课已修完,加入栈中等待处理。

  4. 判断是否修完所有课程:若结果列表的长度等于课程总数,返回结果;否则存在环,返回空数组。

2. 代码逐行解析

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

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

  // 填充邻接表和入度表
  for (const [course, preCourse] of prerequisites) {
    // preCourse的后续课程添加course(修完preCourse才能修course)
    adjacencyList.get(preCourse)?.push(course);
    // course的入度+1(多了一门先修课preCourse)
    inDegree[course]++;
  }

  // 栈:存储入度为0的课程(可直接学习的课程)
  const stack: number[] = [];
  // 结果列表:存储最终的学习顺序
  const res: number[] = [];
  // 初始化栈,将所有入度为0的课程加入
  for (let i = 0; i < numCourses; i++) {
    if (inDegree[i] === 0) {
      stack.push(i);
    }
  }

  // 记录已修完的课程数量
  let finishNum = 0;
  // 循环处理栈中的课程
  while (stack.length) {
    // 弹出当前可修的课程(shift()是弹出栈底,等同于队列,用pop()也可,顺序不同但均正确)
    const currentCourse: number = stack.shift()!;

    // 获取当前课程的所有后续课程
    const nextCourses = adjacencyList.get(currentCourse);
    if (!nextCourses) continue; // 若没有后续课程,直接跳过
    // 遍历后续课程,更新入度
    for (const course of nextCourses) {
      inDegree[course]--; // 后续课程的先修课减少一门
      if (inDegree[course] === 0) { // 入度为0,加入栈等待处理
        stack.push(course);
      }
    }

    // 标记当前课程已修完,加入结果列表
    finishNum++;
    res.push(currentCourse);
  }

  // 若修完的课程数等于总课程数,返回结果;否则存在环,返回空数组
  return finishNum === numCourses ? res : [];
};

3. 解法优势与注意点

  • 优势:思路直观,易于理解,时间复杂度O(n + m)(n为课程数,m为先修关系数),空间复杂度O(n + m),效率较高。

  • 注意点:

    • 邻接表初始化时,要给每门课程都分配空列表,避免后续get时返回undefined;

    • stack.shift() 实际是队列的操作(先进先出),若用stack.pop()(先进后出),得到的学习顺序不同,但均为合法答案;

    • finishNum的作用是判断是否存在环------若最终修完的课程数不足,说明有课程因环无法修完。

三、解法二:DFS + 状态标记

1. 算法思路

DFS解法的核心是「通过深度优先搜索,判断图中是否存在环,并在搜索完成后反向输出结果」,核心逻辑的是用状态标记来追踪每个节点的访问状态,具体步骤如下:

  1. 构建邻接表:与Kahn算法一致,存储每个课程的后续课程。

  2. 定义状态数组:用三个状态标记课程的访问情况:

    • 0:未访问(初始状态);

    • 1:访问中(正在递归遍历该课程的后续课程,尚未回溯);

    • 2:访问完(该课程及其所有后续课程均已遍历完成)。

  3. 深度优先搜索每门课程:

    • 若当前课程状态为1,说明存在环(递归过程中再次遇到「访问中」的课程,形成闭环),返回true(存在环);

    • 若当前课程状态为2,说明已处理完毕,无需再次遍历,返回false;

    • 将当前课程状态设为1(标记为访问中),递归遍历其所有后续课程;

    • 递归完成后,将当前课程状态设为2(标记为访问完),并将其加入结果列表(注意:此处是逆序加入,因为DFS是先处理后续课程,再处理当前课程)。

  4. 若遍历过程中发现环,返回空数组;否则返回结果列表(已自动逆序,符合拓扑排序要求)。

2. 代码逐行解析

typescript 复制代码
function findOrder_2(numCourses: number, prerequisites: number[][]): number[] {
  // 邻接表:存储每个课程的后续课程
  const adjacencyList: Map<number, number[]> = new Map();

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

  // 填充邻接表:preCourse的后续课程是course
  for (const [course, preCourse] of prerequisites) {
    adjacencyList.get(preCourse)!.push(course);
  }

  // 状态数组:0=未访问,1=访问中,2=访问完
  const status: number[] = new Array(numCourses).fill(0)
  // 结果列表:存储学习顺序(初始逆序,最后直接返回即可)
  const res: number[] = [];

  // 递归函数:判断当前课程是否存在环,同时填充结果列表
  const hasCycle = (course: number): boolean => {
    if (status[course] === 1) {
      // 遇到访问中的课程,说明存在环
      return true;
    } else if (status[course] === 2) {
      // 已访问完,无需再次处理
      return false;
    }

    // 标记当前课程为访问中
    status[course] = 1;

    // 获取当前课程的所有后续课程(无后续课程则取空数组)
    const nextCourses = adjacencyList.get(course) || [];

    // 递归遍历所有后续课程,若任意一个存在环,直接返回true
    for (let nextCourse of nextCourses) {
      if (hasCycle(nextCourse)) return true;
    }

    // 递归完成,标记当前课程为访问完
    status[course] = 2;
    // 逆序加入结果列表(后续课程先处理,当前课程后处理,逆序后即为正确顺序)
    res.unshift(course);
    return false;
  }

  // 遍历所有课程,逐一进行DFS
  for (let i = 0; i < numCourses; i++) {
    if (hasCycle(i)) return []; // 存在环,返回空数组
  }

  // 无环,返回结果列表(已逆序,符合拓扑排序)
  return res;
};

3. 解法优势与注意点

  • 优势:无需维护入度表,逻辑更简洁,同样是O(n + m)的时间复杂度和空间复杂度,适合对递归理解熟练的同学。

  • 注意点:

    • 状态标记是核心,必须区分「访问中」和「访问完」,否则会误判环(比如重复访问已处理完的课程);

    • 结果列表必须用unshift()逆序加入,因为DFS是先深入到最底层的后续课程,再回溯处理当前课程,逆序后才是正确的学习顺序;

    • 邻接表get时用「|| []」避免undefined,防止遍历报错。

四、两种解法对比与总结

解法 核心思想 优点 缺点
Kahn算法 入度表+栈/队列,贪心处理入度为0的节点 直观易懂,迭代实现无递归栈溢出风险 需维护入度表,代码略繁琐
DFS+状态标记 递归遍历,用状态标记判断环,逆序输出结果 代码简洁,无需维护入度表 递归深度大时可能栈溢出(LeetCode中课程数最多10000,需注意,但一般可通过)

总结

两道解法本质都是实现拓扑排序,核心是「判断图中是否有环」和「输出合法的拓扑顺序」,适用于不同的场景:

  • 如果是初学者,优先掌握Kahn算法,思路更直观,不容易出错;

  • 如果追求代码简洁,且对递归熟练,DFS解法更优。

另外,题目要求「返回任意一种正确顺序」,所以两种解法得到的顺序可能不同,但都符合题目要求,无需纠结顺序的一致性。

五、刷题小贴士

  1. 遇到「依赖关系」「先后顺序」类题目,优先考虑拓扑排序;

  2. 拓扑排序的关键是「判断环」,两种解法分别用「入度是否为0」和「状态标记」来判断环,可灵活选用;

  3. 邻接表是图论题目中常用的数据结构,务必熟练掌握其构建和遍历方式。

相关推荐
臣妾没空1 小时前
里程碑5:完成框架npm包抽象封装并发布
前端·npm
cxxcode1 小时前
搞懂 JS 异步的底层真相:从 V8 源码看微任务与宏任务
前端
欧阳的棉花糖1 小时前
React 小误区:派生值 vs useEffect
前端
马可菠萝2 小时前
从零开始,用 Tauri + Vue 3 打造轻量级桌面应用
前端
陆枫Larry2 小时前
JavaScript 字符串处理实战:从 `startsWith` 到链式 `replace` 的避坑指南
前端
天蓝色的鱼鱼2 小时前
你的项目真的需要SSR吗?还是只是你的简历需要?
前端·架构
颜酱2 小时前
单调队列:滑动窗口极值问题的最优解(通用模板版)
javascript·后端·算法
恋猫de小郭2 小时前
移动端开发稳了?AI 目前还无法取代客户端开发,小红书的论文告诉你数据
前端·flutter·ai编程
文心快码BaiduComate3 小时前
百度云与光本位签署战略合作:用AI Agent 重构芯片研发流程
前端·人工智能·架构