【每日算法】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 - 课程表的可达性问题
相关推荐
m0_7088309613 小时前
C++中的享元模式实战
开发语言·c++·算法
naruto_lnq13 小时前
分布式计算C++库
开发语言·c++·算法
m0_7066532313 小时前
模板编译期排序算法
开发语言·c++·算法
历程里程碑13 小时前
Linxu14 进程一
linux·c语言·开发语言·数据结构·c++·笔记·算法
木井巳14 小时前
【递归算法】验证二叉搜索树
java·算法·leetcode·深度优先·剪枝
m0_5613596714 小时前
嵌入式C++加密库
开发语言·c++·算法
近津薪荼14 小时前
优选算法——双指针专题7(单调性)
c++·学习·算法
j4455661114 小时前
C++中的职责链模式实战
开发语言·c++·算法
m0_6860416114 小时前
实时数据流处理
开发语言·c++·算法
波波侠814 小时前
代码随想录算法训练营打卡第31天|56. 合并区间、738.单调递增的数字
算法