【Hot 100】207. 课程表

请添加图片描述

  • 🙋‍♂️ 作者:海码007
  • 📜 专栏:算法专栏
  • 💥 标题:【Hot 100】207. 课程表
  • ❣️ 寄语:书到用时方恨少,事非经过不知难!

引言

课程表,也就是判断是否能够完成所有课程的选修。题目给出的条件是需要先修某些课程,这可能形成依赖关系,如果有循环依赖的话,就无法完成所有课程。我的思路是用拓扑排序来判断是否存在环,但具体怎么实现代码呢?我之前学过一点拓扑排序的知识,记得有BFS和DFS两种方法,现在需要详细思考如何用这两种方法来写代码。

首先,问题分析:课程之间的依赖关系构成有向图,比如课程A依赖课程B,那么B必须先于A学习。如果有环的话,比如A依赖B,B依赖C,C又依赖A,这样就无法完成所有课程。所以问题的核心就是检测这个有向图是否存在环。拓扑排序能够处理这个问题,因为拓扑排序的前提是有向无环图(DAG),如果能完成拓扑排序,则说明没有环,反之则有环。

接下来,我需要考虑两种方法:BFS(基于入度)和DFS(检测路径中的环)。

先考虑BFS方法。BFS的思路是每次选择入度为0的节点,将其加入拓扑序列,然后减少其邻接节点的入度,直到没有入度为0的节点。如果最终处理的节点数等于总节点数,说明无环,否则有环。

以下是图论中拓扑排序相关算法的前置知识详解,包括基础概念、术语定义和具体应用:


预备知识点

一、图的基本概念

1. 无向图(Undirected Graph)

  • 定义:边没有方向的图,边表示两个节点的双向关系。
  • 示例:社交网络中的好友关系(A和B是好友,边无方向)。
  • 度的计算:每个节点的度(Degree)是与其相连的边的数量。

2. 有向图(Directed Graph)

  • 定义:边有方向的图,边从源节点指向目标节点。
  • 示例:课程依赖关系(A→B表示必须先修A才能修B)。
  • 度的分类
    • 入度(In-Degree):指向该节点的边的数量(如课程B的入度为2,表示有2门先修课)。
    • 出度(Out-Degree):从该节点出发的边的数量(如课程A的出度为3,表示它是3门课的先修课)。

二、图的存储方式

1. 邻接矩阵(Adjacency Matrix)

  • 定义:使用二维数组 matrix[N][N] 存储节点间的关系。
    • matrix[i][j] = 1 表示节点i到节点j有一条边(有向图中可能不对称)。
  • 特点
    • 优点:快速判断两节点是否相邻(时间复杂度 O(1))。
    • 缺点:空间复杂度为 O(N²),对稀疏图(边数远小于 N²)浪费空间。
  • 适用场景:稠密图(边数接近 N²)。

2. 邻接表(Adjacency List)

  • 定义:使用数组的数组(或链表)存储每个节点的邻居。
    • 例如:adj[i] 存储节点i的所有直接邻居。
  • 特点
    • 优点:空间复杂度 O(N + E),适合稀疏图。
    • 缺点:判断两节点是否相邻需要遍历列表(时间复杂度 O(k),k为邻居数)。
  • 适用场景:绝大多数图问题(尤其是边数较少时)。

三、拓扑排序(Topological Sorting)

1. 核心目标

  • 问题场景:对有向无环图(DAG)的节点排序,使得每个节点在排序中位于其所有后继节点之前。
  • 应用:课程选修顺序、任务调度、依赖解析等。

2. 关键性质

  • 有环图无法拓扑排序:如果图中存在环,无法找到满足条件的顺序(如课程依赖成环时无法完成学习)。

四、拓扑排序的两种实现

方法1:BFS(Kahn算法,基于入度)

  • 前置知识
    • 入度的意义:入度为0的节点没有先修条件,可直接处理。
    • 队列的作用:按顺序处理无依赖的节点。
  • 算法步骤
    1. 构建邻接表和入度数组
      for (依赖关系 [a→b]) {
          adj[a].push_back(b);  // a是b的先修课
          inDegree[b]++;        // b的入度+1
      }
      
    2. 初始化队列:将所有入度为0的节点加入队列。
    3. BFS处理
      • 取出节点u,将其加入拓扑序列。
      • 遍历u的所有邻居v,减少v的入度;若v入度为0,加入队列。
    4. 检测环:最终处理的节点数是否等于总节点数。
  • 时间复杂度:O(N + E),每个节点和边各处理一次。

方法2:DFS(基于路径检测)

  • 前置知识
    • DFS的递归栈:用于跟踪当前路径上的节点。
    • 状态标记
      • 0-未访问:节点未被处理。
      • 1-访问中:节点在递归栈中,表示正在处理的路径。
      • 2-已访问:节点已处理完毕。
  • 算法步骤
    1. 构建邻接表(同上)。
    2. 遍历所有未访问节点,对每个节点启动DFS。
    3. DFS递归过程
      • 若当前节点标记为“访问中”,说明存在环。
      • 若标记为“已访问”,直接返回。
      • 标记当前节点为“访问中”,递归处理所有邻居。
      • 完成后标记为“已访问”。
  • 时间复杂度:O(N + E),每个节点和边各访问一次。

五、关键问题与解答

1. 为什么拓扑排序只能用于有向无环图(DAG)?

  • 答案:若存在环,环中节点的入度无法降为0,无法被处理,导致排序失败。

2. 邻接表和邻接矩阵如何选择?

  • 答案
    • 邻接表:适合稀疏图(如课程表问题,每个课程的先修课通常较少)。
    • 邻接矩阵:适合需要频繁查询两节点是否相邻的稠密图。

3. 如何检测环?

  • BFS方法:最终处理的节点数 < 总节点数 → 存在环。
  • DFS方法:递归过程中遇到“访问中”状态的节点 → 存在环。

六、总结

理解图的基本概念(有向/无向图、入度/出度)和存储方式(邻接表/矩阵)是掌握拓扑排序的基础。BFS和DFS方法分别通过入度管理和路径检测来解决环的存在性问题,两种方法均高效且易于实现。实际应用中,邻接表+BFS的组合在大多数场景下更为常见。

课程表

  • 🎈 题目链接:
  • 🎈 做题状态:写不出来

以下是两种解决方法的C++代码实现:

方法一:BFS(拓扑排序,基于入度)

class Solution {
public:
    bool canFinish(int numCourses, vector<vector<int>>& prerequisites) {
        // 使用BFS拓扑排序方法检测课程依赖图中是否有环
        // 如果拓扑排序能够包含所有课程(count == numCourses),则返回true
        // 否则说明存在环,返回false

        // 1. 构建邻接表(图的表示)
        // adj[i] 存储的是课程i的所有直接后续课程
        // 例如:adj[1] = {2,3} 表示修完课程1后可以修课程2和3
        vector<vector<int>> adj(numCourses);

        // 2. 入度数组(记录每门课程的先修课程数量)
        // inDegree[i] 表示课程i的先修课程数量
        // 例如:inDegree[2] = 1 表示课程2有1门先修课程
        vector<int> inDegree(numCourses, 0);

        // 3. 构建邻接表和入度数组
        // 遍历所有先决条件,建立课程之间的依赖关系
        for (auto& p : prerequisites) {
            int course = p[0];      // 当前课程
            int preCourse = p[1];   // 必须先修的课程
            
            // 将先修课程指向当前课程(表示必须先修preCourse才能修course)
            adj[preCourse].push_back(course);
            
            // 当前课程的入度加1(因为它多了一门先修课程)
            inDegree[course]++;
        }

        // 4. 初始化队列(用于BFS拓扑排序)
        // 队列中存储的是当前可以学习的课程(入度为0的课程)
        queue<int> q;
        
        // 将所有入度为0的课程加入队列
        // 这些课程没有先修要求,可以直接学习
        for (int i = 0; i < numCourses; ++i) {
            if (inDegree[i] == 0) {
                q.push(i);
            }
        }

        // 5. 开始BFS拓扑排序
        int count = 0;  // 记录已经学习完成的课程数量
        
        while (!q.empty()) {
            // 取出队列中的课程(当前可以学习的课程)
            int current = q.front();
            q.pop();
            
            // 课程学习完成,计数加1
            count++;
            
            // 处理当前课程的所有后续课程
            // 因为current课程已经学完,所以它的后续课程的先修要求减少
            for (int nextCourse : adj[current]) {
                // 减少后续课程的入度(相当于减少一门先修要求)
                // 如果入度减为0,表示这门课程的所有先修课程都已经完成
                if (--inDegree[nextCourse] == 0) {
                    // 将这门课程加入队列,准备学习
                    q.push(nextCourse);
                }
            }
        }

        // 6. 检查是否所有课程都学习完成
        // 如果count等于课程总数,说明拓扑排序成功,没有环
        // 否则说明存在环(有些课程的入度始终无法降为0)
        return count == numCourses;
    }
};

方法二:DFS(检测路径中的环)

class Solution {
public:
    bool canFinish(int numCourses, vector<vector<int>>& prerequisites) {
        // 使用DFS方法检测课程依赖图中是否有环
        // 如果有环则无法完成所有课程,返回false;否则返回true

        // 1. 构建邻接表(图的表示)
        // adj[i] 存储的是课程i的所有直接后续课程
        // 例如:adj[1] = {2,3} 表示修完课程1后可以修课程2和3
        vector<vector<int>> adj(numCourses);

        // 2. 访问状态数组
        // visited[i] 的值表示课程i的访问状态:
        // 0: 未被访问过(初始状态)
        // 1: 正在访问中(当前DFS路径中)
        // 2: 已完全访问过(包括所有后续课程)
        vector<int> visited(numCourses, 0);

        // 3. 构建邻接表
        // 遍历所有先决条件,建立课程之间的依赖关系
        for (auto& p : prerequisites) {
            int course = p[0];      // 当前课程
            int preCourse = p[1];    // 必须先修的课程
            // 将先修课程指向当前课程(表示必须先修preCourse才能修course)
            adj[preCourse].push_back(course);
        }

        // 4. 对每个课程进行DFS检查
        // 因为图可能是不连通的(有多个独立的子图),所以需要检查每个课程
        for (int i = 0; i < numCourses; ++i) {
            if (visited[i] == 0) {  // 只检查未访问过的课程
                if (!dfs(adj, visited, i)) {
                    // 如果DFS发现环,立即返回false
                    return false;
                }
            }
        }
        
        // 5. 所有课程都检查完毕且没有发现环
        return true;
    }

private:
    bool dfs(vector<vector<int>>& adj, vector<int>& visited, int current) {
        // DFS辅助函数,用于检测从current课程开始的路径中是否有环

        // 1. 如果当前课程的状态是"正在访问中"
        // 说明我们在当前DFS路径中又回到了这个课程,形成了环
        if (visited[current] == 1) return false;

        // 2. 如果当前课程的状态是"已完全访问过"
        // 说明这个课程的所有后续课程都已经检查过了,无需重复处理
        if (visited[current] == 2) return true;

        // 3. 将当前课程标记为"正在访问中"
        // 表示我们开始处理这个课程及其后续课程
        visited[current] = 1;

        // 4. 递归检查所有后续课程
        for (int nextCourse : adj[current]) {
            if (!dfs(adj, visited, nextCourse)) {
                // 如果在后续课程中发现环,立即返回false
                return false;
            }
        }

        // 5. 当前课程的所有后续课程都检查完毕,没有发现环
        // 将当前课程标记为"已完全访问过"
        visited[current] = 2;

        // 6. 返回true表示从当前课程开始的路径没有环
        return true;
    }
};

方法解释

  1. BFS方法

    • 邻接表:记录每个课程的后继课程(即依赖它的后续课程)。
    • 入度数组:记录每个课程的先修课程数量。
    • 队列初始化:将入度为0的课程加入队列(这些课程可以直接学习)。
    • 拓扑排序:依次处理队列中的课程,减少其后续课程的入度,若入度变为0则加入队列。
    • 结果判断:最终处理的课程数等于总课程数时,说明无环。
  2. DFS方法

    • 邻接表:同上,记录后续课程。
    • 状态标记visited数组标记课程状态(未访问、访问中、已访问)。
    • DFS检测环:若在递归过程中遇到状态为“访问中”的节点,说明存在环。
    • 回溯标记:完成当前节点的所有邻接节点后,标记为“已访问”避免重复处理。

两种方法均能高效检测图中是否存在环,时间复杂度为O(N + E)(N为课程数,E为依赖数)。BFS更直观,而DFS可以更早地发现某些环。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值