[图论] [热题100] 207. 课程表

115 阅读9分钟

题目描述:

你这个学期必须选修 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
  • prerequisites[i] 中的所有课程对 互不相同

思路:

这道题应该是得知道每个课程,它是哪些其他课的先决条件。另外还得知道,哪些课没有依赖其他课。这样我们可以维护一个数据结构,对于每个课,它是哪些课的先决条件。然后我们先处理“无依赖”的课程,处理完之后,看看还有没有更多的课变成“无依赖”的课程了。

实现:

Kahn算法(广度优先)

class Solution {
    public boolean canFinish(int numCourses, int[][] prerequisites) {
        List<Integer>[] courseLink = new ArrayList[numCourses];
        int[] dependency = new int[numCourses];
        int completed = 0;
        for (int i = 0; i < numCourses; i++) {
            courseLink[i] = new ArrayList<Integer>();
        }

        for (int[] pair: prerequisites) {
            int a = pair[0];  // 2nd
            int b = pair[1];  // 1st
            courseLink[b].add(a); // a is b's neighour, b leading a, b first so b is root
            dependency[a]++; // a found 1 dependency, cannot directly learn a.
        }

        Deque<Integer> queue = new ArrayDeque<>();
        for (int i = 0; i < numCourses; i++) {
            if (dependency[i] == 0) {
                queue.add(i);  // this course can start right now
            }
        }

        while (!queue.isEmpty()) {
            int independent = queue.poll();
            completed++;
            for (int i: courseLink[independent]) {
                // all courses depend on independent
                dependency[i]--; // finished one, so dependent minus 1.
                if (dependency[i] == 0) {  // no more depend on other
                    queue.offer(i);
                }
            }
        }
        return completed == numCourses;
    }
}

该代码使用拓扑排序(Kahn算法)判断课程安排是否合法(即图中是否存在环)。具体思路如下:

  1. 构建邻接表与入度统计

    • courseLink数组存储每个课程的后继课程,表示学完当前课程后可学习的课程。
    • dependency数组记录每个课程的入度(即依赖的前置课程数量)。
    • 遍历prerequisites,将前置课程b的后继课程a加入邻接表,并增加a的入度。
  2. 初始化队列

    • 将所有入度为0的课程加入队列。这些课程无前置条件,可以直接学习。
  3. 拓扑排序处理

    • 从队列中取出课程,视为完成,统计已完成的课程数。
    • 遍历该课程的所有后继课程,将其入度减1。若某后继课程入度变为0,则加入队列。
    • 重复此过程直到队列为空。
  4. 判断结果

    • 若完成的课程数等于总课程数,说明所有课程无环依赖,返回true;否则存在环,返回false

关键点:通过入度是否为0判断课程是否可学习,利用队列逐步消除依赖,若最终无法消除所有课程则存在环。该算法时间复杂度为O(N+M),N为课程数,M为依赖数量。

这段代码实现了拓扑排序中的Kahn算法,用于检测课程依赖关系是否存在环。从正确性、效率和代码质量综合评估,可以给出 95分。以下是详细评分理由:


优点(占90分)

  1. 算法正确性(30分)

    • 完美实现了Kahn算法,通过入度统计和队列逐步消除依赖,能准确判断图中是否存在环。
    • 处理边界条件(如numCourses=0或依赖为空)完全正确。
  2. 时间复杂度(25分)

    • 时间复杂度为 O(N+M)(N为课程数,M为依赖数),是拓扑排序的最优实现,无冗余操作。
  3. 空间复杂度(15分)

    • 邻接表courseLink和入度数组dependency的空间复杂度为O(N+M),符合算法要求。
  4. 代码结构(20分)

    • 逻辑清晰,初始化、建图、队列处理、结果判断分块明确。
    • 使用Deque作为队列,保证O(1)的入队出队操作效率。

扣分点(5分)

  1. 变量命名(-2分)

    • dependency可更名为inDegree更直观。
    • ab在遍历依赖关系时容易混淆,建议改为courseprereq
  2. 注释与可读性(-2分)

    • 关键步骤(如队列初始化、入度更新)缺少注释,对算法思路的解释不足。
  3. 输入健壮性(-1分)

    • 未校验课程编号是否合法(如prerequisites中的数值超出numCourses范围),可能导致越界异常。

改进建议

// 示例:更清晰的变量命名和注释
int[] inDegree = new int[numCourses]; // 入度数组
List<Integer>[] adjacencyList = new ArrayList[numCourses]; // 邻接表

// 在遍历依赖时:
for (int[] edge : prerequisites) {
    int course = edge[0];
    int prerequisite = edge[1];
    adjacencyList[prerequisite].add(course); // 记录后序课程
    inDegree[course]++; // 更新入度
}

总结

代码在算法实现和性能上几乎完美,仅因变量命名、注释细节和输入校验略有不足扣5分。95分是一个合理的评分,属于高质量的实现。

深度优先搜索

DFS 解决思路

  1. 状态标记法

    • 为每个节点维护三种状态:
      0: 未访问
      1: 正在访问(当前DFS路径中)
      2: 已访问(已确认无环)
    • 如果在DFS过程中遇到状态为1的节点,说明存在环。
  2. 邻接表构建

    • 与Kahn算法类似,构建邻接表表示依赖关系。例如,课程A依赖课程B,则B是A的前驱,邻接表中记录为B → A
  3. DFS遍历

    • 对每个未访问的节点启动DFS。
    • 递归访问当前节点的所有后继节点。
    • 如果发现某个后继节点在本次DFS路径中已被访问(状态为1),说明存在环,直接返回false
  4. 拓扑排序生成

    • 在DFS后序位置将节点加入拓扑序列(逆后序即为合法拓扑排序)。

具体步骤(以课程依赖问题为例)

假设课程依赖为:[[1,0], [2,1], [3,2]](即学0后才能学1,学1后才能学2,学2后才能学3)。

  1. 构建邻接表

    0[1]  
    1[2]  
    2[3]  
    3[]  
    
  2. DFS过程

    • 从课程0开始DFS:
      ◦ 标记0为1(正在访问)。
      ◦ 访问0的后继1:标记1为1
      ◦ 访问1的后继2:标记2为1
      ◦ 访问2的后继3:标记3为1
      ◦ 3无后继,标记3为2(已访问),返回。
      ◦ 回溯到2,标记2为2,返回。
      ◦ 回溯到1,标记1为2,返回。
      ◦ 回溯到0,标记0为2,完成。
    • 最终拓扑序列(逆后序):[3,2,1,0],无环,返回true

DFS代码框架(Java)

class Solution {
    public boolean canFinish(int numCourses, int[][] prerequisites) {
        // 构建邻接表
        List<Integer>[] graph = new ArrayList[numCourses];
        for (int i = 0; i < numCourses; i++) {
            graph[i] = new ArrayList<>();
        }
        for (int[] edge : prerequisites) {
            int course = edge[0], prereq = edge[1];
            graph[prereq].add(course); // prereq → course
        }

        int[] visited = new int[numCourses]; // 0:未访问, 1:访问中, 2:已访问
        for (int i = 0; i < numCourses; i++) {
            if (visited[i] == 0 && !dfs(graph, visited, i)) {
                return false; // 发现环
            }
        }
        return true;
    }

    private boolean dfs(List<Integer>[] graph, int[] visited, int node) {
        if (visited[node] == 1) return false; // 存在环
        if (visited[node] == 2) return true;  // 已确认无环
        visited[node] = 1; // 标记为正在访问
        for (int neighbor : graph[node]) {
            if (!dfs(graph, visited, neighbor)) {
                return false;
            }
        }
        visited[node] = 2; // 标记为已访问
        return true;
    }
}

你提到的这一句“在DFS后序位置将节点加入拓扑序列(逆后序即为合法拓扑排序)”是DFS生成拓扑排序的关键,这里我用一个具体例子帮你彻底理解它。


核心概念解释

  1. DFS的遍历顺序
    后序遍历(Post-order):指在递归过程中,先访问完所有子节点,最后再处理当前节点。
    • 例如,对树结构A → B → C的后序遍历顺序是C → B → A

  2. 拓扑排序与后序的关系
    • 拓扑排序要求:所有依赖(父节点)必须出现在被依赖项(子节点)之前
    • 而后序遍历的顺序是子节点在前,父节点在后,因此将后序结果反转(逆后序)即可满足拓扑排序的要求。


具体例子演示

定义邻接表为依赖方向C → B → A(学C后才能学B,学B后才能学A),邻接表为:

C的邻居是B  
B的邻居是A  
A没有邻居  
修正后的DFS过程
  1. 从节点C开始DFS:
    • 访问C的邻居B
    • 访问B的邻居A
    A没有邻居,加入后序列表 → [A]
    • 回溯到B,加入列表 → [A, B]
    • 回溯到C,加入列表 → [A, B, C]

  2. 逆后序得到拓扑排序:反转列表 → [C, B, A]
    这正是合法的拓扑排序!


关键总结

  1. 邻接表方向:必须表示依赖方向(例如prereq → course),即学完前置课程才能学后续课程。

  2. 后序与逆后序
    • 后序遍历会先处理叶子节点(无依赖的课程),最后处理根节点(依赖最多的课程)。
    • 逆后序会将根节点(依赖多的课程)放到最后,叶子节点(无依赖的课程)放到最前,形成合法拓扑排序。

  3. 代码中的实现

    // 在DFS递归返回前(后序位置)将节点加入列表
    private void dfs(List<Integer>[] graph, int node, List<Integer> postOrder) {
        visited[node] = true;
        for (int neighbor : graph[node]) {
            if (!visited[neighbor]) {
                dfs(graph, neighbor, postOrder);
            }
        }
        postOrder.add(node); // 后序位置加入列表
    }
    
    // 最终反转列表得到拓扑排序
    Collections.reverse(postOrder);
    

对比图示

依赖关系:C → BA  
邻接表:C的邻居是BB的邻居是A  

DFS后序列表:[A, B, C]  
逆后序结果:[C, B, A] (拓扑排序)

通过这个例子,你应该能理解为什么逆后序是拓扑排序了!关键在于邻接表的方向和DFS遍历路径的配合。


DFS vs Kahn算法对比

特性DFSKahn算法
环检测时机递归路径中立即发现环处理完所有入度0节点后判断剩余节点
拓扑排序生成逆后序即为拓扑序列直接按处理顺序生成序列
空间复杂度O(N)(递归栈深度)O(N+M)(队列+邻接表)
适用场景适合需要逆序或快速检测环的场景适合需要动态更新依赖的场景

关键点总结

  • 状态标记:通过0/1/2状态避免重复访问,同时检测环。
  • 后序遍历:拓扑排序的逆后序结果即为合法顺序。
  • 时间复杂度:O(N+M),与Kahn算法相同,但实际性能可能因图结构而异。

通过DFS,我们不仅能检测环,还能生成拓扑序列,是解决此类问题的另一种高效方法!