【每日算法】LeetCode 207. 课程表

对前端开发者而言,学习算法绝非为了"炫技"。它是你从"页面构建者"迈向"复杂系统设计者"的关键阶梯。它将你的编码能力从"实现功能"提升到"设计优雅、高效解决方案"的层面。从现在开始,每天投入一小段时间,结合前端场景去理解和练习,你将会感受到自身技术视野和问题解决能力的质的飞跃。------ 算法:资深前端开发者的进阶引擎

LeetCode 207. 课程表

1. 题目描述

你这个学期必须选修 numCourses 门课程,记为 0numCourses - 1

在选修某些课程之前需要先修一些课程。先修课程按数组 prerequisites 给出,其中 prerequisites[i] = [ai, bi] ,表示如果要学习课程 ai 则必须先学习课程 bi

请判断是否可能完成所有课程的学习?如果可以,返回 true;否则,返回 false

示例 1:

复制代码
输入:numCourses = 2, prerequisites = [[1,0]]
输出:true
解释:总共有 2 门课程。学习课程 1 之前,你需要完成课程 0。这是可能的。

示例 2:

复制代码
输入:numCourses = 2, prerequisites = [[1,0],[0,1]]
输出:false
解释:总共有 2 门课程。学习课程 1 之前,你需要完成课程 0;并且学习课程 0 之前,你需要完成课程 1。这是不可能的。

2. 问题分析

2.1 核心问题转化

这是一个典型的拓扑排序问题,可以转化为:

  • 将课程看作有向图中的节点
  • 将先修关系 [a, b] 看作从 b 指向 a 的有向边(b是a的先修课)
  • 问题等价于:判断有向图中是否存在环
    • 如果存在环,则无法完成所有课程
    • 如果不存在环,则可以完成(存在拓扑排序)

2.2 前端开发中的实际应用

  1. 任务调度:前端构建工具(如Webpack)中的任务依赖关系
  2. 组件加载顺序:Vue/React中组件的依赖加载
  3. 状态管理:Redux中异步action的执行顺序
  4. 路由守卫:导航守卫的执行顺序依赖

3. 解题思路

3.1 思路一:拓扑排序(Kahn算法,BFS实现)

时间复杂度:O(V+E)空间复杂度:O(V+E)

  • V 是课程数(节点数),E 是先修条件数(边数)
  • 最优解之一,直观且易于实现

算法步骤:

  1. 构建邻接表表示图
  2. 计算每个节点的入度(有多少先修课)
  3. 将入度为0的节点加入队列
  4. BFS遍历:每次取出入度为0的节点,将其后继节点的入度减1
  5. 如果所有节点都被访问过,说明无环;否则有环

3.2 思路二:深度优先搜索(DFS判断环)

时间复杂度:O(V+E)空间复杂度:O(V+E)

  • 同样是最优时间复杂度,但需要记录三种状态
  • 递归深度可能较大,需要注意栈溢出

算法步骤:

  1. 构建邻接表
  2. 为每个节点维护三种状态:
    • 0:未访问
    • 1:访问中(当前DFS路径中)
    • 2:已访问完成
  3. DFS遍历,如果遇到状态为1的节点,说明有环

4. 代码实现

4.1 拓扑排序(BFS)实现

javascript 复制代码
/**
 * 拓扑排序(Kahn算法)- BFS实现
 * @param {number} numCourses - 课程总数
 * @param {number[][]} prerequisites - 先修课程关系
 * @return {boolean} - 是否能完成所有课程
 */
var canFinish = function(numCourses, prerequisites) {
    // 1. 初始化邻接表和入度数组
    const graph = new Array(numCourses).fill(0).map(() => []);
    const inDegree = new Array(numCourses).fill(0);
    
    // 2. 构建图和入度数组
    for (const [course, prereq] of prerequisites) {
        graph[prereq].push(course); // prereq -> course
        inDegree[course]++; // course的入度+1
    }
    
    // 3. 初始化队列,将所有入度为0的节点加入
    const queue = [];
    for (let i = 0; i < numCourses; i++) {
        if (inDegree[i] === 0) {
            queue.push(i);
        }
    }
    
    // 4. BFS遍历
    let visitedCount = 0;
    while (queue.length > 0) {
        const current = queue.shift();
        visitedCount++;
        
        // 遍历当前节点的所有后继节点
        for (const neighbor of graph[current]) {
            // 将后继节点的入度减1
            inDegree[neighbor]--;
            // 如果入度变为0,加入队列
            if (inDegree[neighbor] === 0) {
                queue.push(neighbor);
            }
        }
    }
    
    // 5. 判断是否所有节点都被访问
    return visitedCount === numCourses;
};

// 测试用例
console.log(canFinish(2, [[1,0]])); // true
console.log(canFinish(2, [[1,0],[0,1]])); // false

步骤分解说明:

  1. 构建邻接表graph[prereq]存储所有以prereq为先修课的课程
  2. 计算入度inDegree[course]表示课程course需要先修多少门课
  3. BFS遍历:从入度为0的课程开始学习,每学完一门课,将其后继课程的入度减1
  4. 判断结果:如果所有课程都能学到(入度都变为0),则返回true

4.2 深度优先搜索(DFS)实现

javascript 复制代码
/**
 * DFS判断图中是否有环
 * @param {number} numCourses - 课程总数
 * @param {number[][]} prerequisites - 先修课程关系
 * @return {boolean} - 是否能完成所有课程
 */
var canFinishDFS = function(numCourses, prerequisites) {
    // 1. 构建邻接表
    const graph = new Array(numCourses).fill(0).map(() => []);
    for (const [course, prereq] of prerequisites) {
        graph[prereq].push(course);
    }
    
    // 2. 状态数组:0=未访问,1=访问中,2=已访问完成
    const state = new Array(numCourses).fill(0);
    
    /**
     * 深度优先搜索
     * @param {number} node - 当前节点
     * @return {boolean} - 是否有环(true表示有环)
     */
    const hasCycle = (node) => {
        // 如果当前节点正在访问中,说明有环
        if (state[node] === 1) return true;
        // 如果已经访问完成,直接返回
        if (state[node] === 2) return false;
        
        // 标记为访问中
        state[node] = 1;
        
        // 递归访问所有后继节点
        for (const neighbor of graph[node]) {
            if (hasCycle(neighbor)) {
                return true;
            }
        }
        
        // 标记为已访问完成
        state[node] = 2;
        return false;
    };
    
    // 3. 对每个未访问的节点进行DFS
    for (let i = 0; i < numCourses; i++) {
        if (state[i] === 0) {
            if (hasCycle(i)) {
                return false; // 有环,无法完成
            }
        }
    }
    
    return true; // 无环,可以完成
};

// 测试用例
console.log(canFinishDFS(4, [[1,0],[2,1],[3,2]])); // true
console.log(canFinishDFS(3, [[0,1],[1,2],[2,0]])); // false

状态机说明:

  • 状态0(未访问):节点尚未开始处理
  • 状态1(访问中) :节点正在当前DFS路径中被处理
    • 如果DFS中再次遇到状态1的节点,说明存在环
  • 状态2(已访问完成):节点及其所有后继节点都已处理完成

5. 实现思路对比

特性 拓扑排序(BFS) 深度优先搜索(DFS)
时间复杂度 O(V+E) O(V+E)
空间复杂度 O(V+E) O(V+E)
实现难度 中等 中等
优势 1. 直观易懂 2. 天然得到拓扑排序结果 3. 递归深度小 1. 代码简洁 2. 一次DFS即可判断环 3. 更容易记录路径
劣势 1. 需要额外队列 2. 需要维护入度数组 1. 递归可能导致栈溢出 2. 状态管理稍复杂
适用场景 需要拓扑排序结果时 需要记录环的具体路径时
前端应用 构建工具任务调度 组件依赖关系检查

6. 总结

6.1 通用解题模板

拓扑排序(BFS)模板:

javascript 复制代码
function topologicalSort(numNodes, edges) {
    // 1. 构建图和入度数组
    const graph = Array(numNodes).fill(0).map(() => []);
    const inDegree = Array(numNodes).fill(0);
    
    for (const [u, v] of edges) {
        graph[v].push(u); // v -> u
        inDegree[u]++;
    }
    
    // 2. 初始化队列
    const queue = [];
    for (let i = 0; i < numNodes; i++) {
        if (inDegree[i] === 0) queue.push(i);
    }
    
    // 3. BFS
    let count = 0;
    const result = [];
    
    while (queue.length) {
        const node = queue.shift();
        result.push(node);
        count++;
        
        for (const neighbor of graph[node]) {
            inDegree[neighbor]--;
            if (inDegree[neighbor] === 0) {
                queue.push(neighbor);
            }
        }
    }
    
    // 4. 判断是否有环
    return count === numNodes ? result : [];
}

DFS检测环模板:

javascript 复制代码
function hasCycleDFS(numNodes, edges) {
    const graph = Array(numNodes).fill(0).map(() => []);
    for (const [u, v] of edges) {
        graph[v].push(u);
    }
    
    const state = Array(numNodes).fill(0); // 0:未访问, 1:访问中, 2:已访问
    
    function dfs(node) {
        if (state[node] === 1) return true;
        if (state[node] === 2) return false;
        
        state[node] = 1;
        for (const neighbor of graph[node]) {
            if (dfs(neighbor)) return true;
        }
        state[node] = 2;
        return false;
    }
    
    for (let i = 0; i < numNodes; i++) {
        if (state[i] === 0 && dfs(i)) {
            return false; // 有环
        }
    }
    return true; // 无环
}

6.2 类似题目推荐

  1. LeetCode 210. 课程表 II - 本题的进阶版,要求返回拓扑排序结果
  2. LeetCode 269. 火星词典 - 拓扑排序在字典序问题中的应用
  3. LeetCode 444. 序列重建 - 拓扑排序的验证问题
  4. LeetCode 1136. 平行课程 - 拓扑排序的层数计算
  5. LeetCode 1462. 课程表 IV - 课程表的可达性问题
相关推荐
多米Domi0119 小时前
0x3f 第25天 黑马web (145-167)hot100链表
数据结构·python·算法·leetcode·链表
sali-tec9 小时前
C# 基于OpenCv的视觉工作流-章7-膨胀
图像处理·人工智能·opencv·算法·计算机视觉
叫我:松哥9 小时前
基于机器学习的地震风险评估与可视化系统,采用Flask后端与Bootstrap前端,系统集成DBSCAN空间聚类算法与随机森林算法
前端·算法·机器学习·flask·bootstrap·echarts·聚类
一起养小猫9 小时前
LeetCode100天Day12-删除重复项与删除重复项II
java·数据结构·算法·leetcode
码农丁丁9 小时前
谈谈面试的本质
面试·职场和发展·技术管理·ai时代的技术管理
小O的算法实验室9 小时前
2023年IEEE TITS SCI2区TOP,增强遗传算法+分布式随机多无人机协同区域搜索路径规划,深度解析+性能实测
算法·论文复现·智能算法·智能算法改进
Allen_LVyingbo9 小时前
病历生成与质控编码的工程化范式研究:从模型驱动到系统治理的范式转变
前端·javascript·算法·前端框架·知识图谱·健康医疗·easyui
一起努力啊~9 小时前
算法刷题--螺旋矩阵II+区间和+开发商购买土地
数据结构·算法·leetcode
Swift社区9 小时前
LeetCode 470 用 Rand7() 实现 Rand10()
算法·leetcode·职场和发展