(LeetCode-Hot100)207. 课程表

问题简介

207. 课程表 - LeetCode

复制代码
题解github地址: https://github.com/swf2020/LeetCode-Hot100-Solutions

题目描述

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

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

  • 例如,先修课程对 [0, 1] 表示:想要学习课程 0,你需要先完成课程 1。

请你判断是否可能完成所有课程的学习?如果可以,返回 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。这是不可能的。

📌 提示:

  • 1 <= numCourses <= 2000
  • 0 <= prerequisites.length <= 5000
  • prerequisites[i].length == 2
  • 0 <= ai, bi < numCourses
  • ai != bi
  • 所有 [ai, bi] 互不相同

解题思路

本题本质上是判断有向图中是否存在环。如果存在环,则无法完成所有课程(因为会出现循环依赖);否则可以完成。

我们可以将课程看作图中的节点,先修关系看作有向边(bi → ai 表示 bi 是 ai 的先修课)。问题转化为:判断该有向图是否为有向无环图(DAG)。

方法一:拓扑排序(Kahn 算法)

💡 核心思想:利用入度进行 BFS 拓扑排序。

  1. 构建邻接表和入度数组。
  2. 将所有入度为 0 的课程加入队列。
  3. 依次从队列中取出课程,将其指向的课程入度减 1;若某课程入度变为 0,则加入队列。
  4. 最终若处理的课程数等于 numCourses,说明无环,返回 true;否则存在环,返回 false。

✅ 优点:直观、易于理解,同时可输出拓扑序列。

方法二:DFS + 三色标记法

💡 核心思想:通过 DFS 遍历检测回边(back edge)。

使用三种状态标记节点:

  • 0(未访问):尚未访问该节点。
  • 1(正在访问):当前 DFS 路径中正在访问该节点(即在递归栈中)。
  • 2(已完成):该节点及其所有后继已访问完毕,无环。

若在 DFS 过程中遇到状态为 1 的节点,说明存在环。

✅ 优点:空间效率略高,适合稀疏图。


代码实现

java 复制代码
// Java 实现

// 方法一:拓扑排序(Kahn 算法)
class Solution {
    public boolean canFinish(int numCourses, int[][] prerequisites) {
        // 构建邻接表和入度数组
        List<List<Integer>> graph = new ArrayList<>();
        int[] indegree = new int[numCourses];
        
        for (int i = 0; i < numCourses; i++) {
            graph.add(new ArrayList<>());
        }
        
        for (int[] pre : prerequisites) {
            int from = pre[1], to = pre[0];
            graph.get(from).add(to);
            indegree[to]++;
        }
        
        // BFS
        Queue<Integer> queue = new LinkedList<>();
        for (int i = 0; i < numCourses; i++) {
            if (indegree[i] == 0) {
                queue.offer(i);
            }
        }
        
        int visited = 0;
        while (!queue.isEmpty()) {
            int course = queue.poll();
            visited++;
            for (int next : graph.get(course)) {
                indegree[next]--;
                if (indegree[next] == 0) {
                    queue.offer(next);
                }
            }
        }
        
        return visited == numCourses;
    }
}

// 方法二:DFS + 三色标记
class Solution {
    public boolean canFinish(int numCourses, int[][] prerequisites) {
        List<List<Integer>> graph = new ArrayList<>();
        for (int i = 0; i < numCourses; i++) {
            graph.add(new ArrayList<>());
        }
        for (int[] pre : prerequisites) {
            graph.get(pre[1]).add(pre[0]);
        }
        
        int[] color = new int[numCourses]; // 0: unvisited, 1: visiting, 2: visited
        
        for (int i = 0; i < numCourses; i++) {
            if (color[i] == 0 && hasCycle(graph, color, i)) {
                return false;
            }
        }
        return true;
    }
    
    private boolean hasCycle(List<List<Integer>> graph, int[] color, int node) {
        if (color[node] == 1) return true; // 发现环
        if (color[node] == 2) return false; // 已完成,无环
        
        color[node] = 1;
        for (int neighbor : graph.get(node)) {
            if (hasCycle(graph, color, neighbor)) {
                return true;
            }
        }
        color[node] = 2;
        return false;
    }
}
go 复制代码
// Go 实现

// 方法一:拓扑排序(Kahn 算法)
func canFinish(numCourses int, prerequisites [][]int) bool {
    graph := make([][]int, numCourses)
    indegree := make([]int, numCourses)
    
    for _, pre := range prerequisites {
        from, to := pre[1], pre[0]
        graph[from] = append(graph[from], to)
        indegree[to]++
    }
    
    queue := []int{}
    for i := 0; i < numCourses; i++ {
        if indegree[i] == 0 {
            queue = append(queue, i)
        }
    }
    
    visited := 0
    for len(queue) > 0 {
        course := queue[0]
        queue = queue[1:]
        visited++
        for _, next := range graph[course] {
            indegree[next]--
            if indegree[next] == 0 {
                queue = append(queue, next)
            }
        }
    }
    
    return visited == numCourses
}

// 方法二:DFS + 三色标记
func canFinish(numCourses int, prerequisites [][]int) bool {
    graph := make([][]int, numCourses)
    for _, pre := range prerequisites {
        graph[pre[1]] = append(graph[pre[1]], pre[0])
    }
    
    color := make([]int, numCourses) // 0: unvisited, 1: visiting, 2: visited
    
    var hasCycle func(int) bool
    hasCycle = func(node int) bool {
        if color[node] == 1 {
            return true
        }
        if color[node] == 2 {
            return false
        }
        
        color[node] = 1
        for _, neighbor := range graph[node] {
            if hasCycle(neighbor) {
                return true
            }
        }
        color[node] = 2
        return false
    }
    
    for i := 0; i < numCourses; i++ {
        if color[i] == 0 && hasCycle(i) {
            return false
        }
    }
    return true
}

示例演示

以 numCourses = 4, prerequisites = [[1,0],[2,0],[3,1],[3,2]] 为例:

复制代码
课程依赖图:
0 → 1 → 3
↓ ↗
2 ────┘
  • 拓扑排序过程 :
    • 初始入度:[0,1,1,2]
    • 入度为 0 的课程:0
    • 处理 0 → 1 和 2 入度减为 0 → 加入队列
    • 处理 1 → 3 入度减为 1
    • 处理 2 → 3 入度减为 0 → 加入队列
    • 处理 3
    • 共处理 4 门课程 ⇒ 返回 true

✅ 无环,可以完成。


答案有效性证明

  • 拓扑排序法 :若图中存在环,则环上所有节点入度 ≥1,永远不会被加入队列,最终 visited < numCourses。
  • DFS 三色法:若存在环,则在 DFS 路径中必会再次访问到状态为"正在访问"的节点(即回边),从而检测到环。

两种方法均能充要地判断有向图是否存在环,因此解法正确。


复杂度分析

方法 时间复杂度 空间复杂度
拓扑排序(Kahn) O(V + E) O(V + E)
DFS 三色标记 O(V + E) O(V + E)

其中:

  • V = numCourses(顶点数)
  • E = prerequisites.length(边数)

📌 两种方法时间/空间复杂度相同,实际性能取决于图的结构和语言运行时。


问题总结

✅ 关键洞察:课程安排问题 ⇨ 有向图环检测 ⇨ 拓扑排序 / DFS 环检测。

✅ 适用场景:

  • 拓扑排序:需输出合法顺序时优先使用。
  • DFS 三色法:仅需判断可行性时代码更简洁。

❌ 常见误区:

  • 忽略图可能不连通(需遍历所有节点)。
  • 使用普通 visited 标记(无法区分"正在访问"和"已完成")。

💡 扩展思考:

  • 若需输出任意一个合法课程顺序?→ 拓扑排序结果即为答案。
  • 若课程有学分、学期限制?→ 可结合动态规划或分层拓扑排序。

本题是图论基础应用的经典代表,掌握其解法对解决依赖关系类问题至关重要。

相关推荐
倔强的石头106几秒前
【Linux指南】动静态库系列(九):动态库如何进入进程地址空间:从磁盘 .so 到共享内存映射
java·linux·服务器
朔北之忘 Clancy2 分钟前
2024 CSP-J 第二轮真题解析
c++·算法·青少年编程·noip·csp·信奥赛·noi
TeamDev7 分钟前
JxBrowser 9.5.2 版本发布啦!
java·前端·windows·chromium·混合应用·jxbrowser·浏览器控件
鬼手点金8 分钟前
Claude Code示范案例-进阶学习路径
java·服务器·前端·学习·计算机视觉·前向传播
浪子明X10 分钟前
用 CMake Presets 固定跨平台构建:从本地开发到 CI 的依赖一致性实践
java·spring·ci/cd
苏supper25 分钟前
记一次Nacos鉴权报错unknown user,403排查|SecurityProxy源码,fastjson2扩展包缺失
java·后端
\光辉岁月/28 分钟前
6.mybatisplus学习-BaseMapper、Service、常用注解
java·mybatis·mybatisplus
微信开发api28 分钟前
基于WTAPI构建社群运营平台:自动拉群与会话承接链路设计
java·大数据·网络·数据库·微信·自动化
小七在进步34 分钟前
类和对象(五)
java·开发语言·算法
就叫年华吧丶43 分钟前
长文档点目录定位总是不准?一个 content-visibility + 平滑滚动引发的连环坑(Vue3 实战)
前端·javascript·算法·vue