【LeetCode Hot100 刷题日记 (53/100)】207. 课程表 —— 拓扑排序(Topological Sort)🧠

0 阅读6分钟

📌 题目链接:207. 课程表 - 力扣(LeetCode)

🔍 难度:中等 | 🏷️ 标签:图、拓扑排序、深度优先搜索、广度优先搜索

⏱️ 目标时间复杂度:O(n + m)

💾 空间复杂度:O(n + m)


🧠 题目分析

本题要求判断是否可以完成所有课程的学习。每门课程可能依赖于其他课程,这种依赖关系天然构成了一个有向图结构:

  • 节点:课程编号(0 到 numCourses - 1)
  • 有向边:若要学课程 A 必须先学课程 B,则存在一条从 B → A 的边

问题本质转化为:判断该有向图是否存在环
因为如果图中存在环,就表示某些课程互相依赖,无法完成;反之,若为有向无环图(DAG),则一定存在一种合法的学习顺序 —— 即拓扑排序

关键结论
有向图存在拓扑排序 ⇔ 图是 DAG(有向无环图)


🔁 核心算法及代码讲解

🎯 什么是拓扑排序?

拓扑排序是对有向无环图(DAG)的顶点进行线性排序,使得对于每一条有向边 (u → v),u 在排序中都出现在 v 的前面。

📌 注意:拓扑排序不是唯一的!只要满足依赖关系即可。

🛠️ 两种经典实现方式

方法一:深度优先搜索(DFS)+ 三色标记法

📖 状态定义(三色标记)
  • 0(白色 / 未访问) :尚未访问该节点
  • 1(灰色 / 访问中) :正在递归访问其子节点(在 DFS 栈中)
  • 2(黑色 / 已完成) :该节点及其所有子节点已处理完毕
🔄 核心思想
  • 若在 DFS 过程中遇到一个状态为 1(访问中) 的邻居节点 → 说明存在回边(back edge) → 图中有环 → 无法完成课程。
  • 只有当所有子节点都处理完(变为状态 2),当前节点才能标记为完成。
💻 代码与行注释(C++)
class Solution {
private:
    vector<vector<int>> edges;      // 邻接表:edges[i] 表示 i 的后继课程
    vector<int> visited;            // 三色标记数组:0=未访问, 1=访问中, 2=已完成
    bool valid = true;              // 全局标志:是否无环

public:
    void dfs(int u) {
        visited[u] = 1;             // 标记为“访问中”
        for (int v : edges[u]) {    // 遍历所有后继课程 v
            if (visited[v] == 0) {  // 若 v 未访问,递归 DFS
                dfs(v);
                if (!valid) return; // 若已发现环,提前返回
            }
            else if (visited[v] == 1) { // 若 v 正在访问中 → 发现环!
                valid = false;
                return;
            }
            // visited[v] == 2:已处理完毕,无需操作
        }
        visited[u] = 2;             // 所有子节点处理完,标记为“已完成”
    }

    bool canFinish(int numCourses, vector<vector<int>>& prerequisites) {
        edges.resize(numCourses);
        visited.resize(numCourses);
        // 构建邻接表:prerequisites[i] = [a, b] → b → a
        for (const auto& info : prerequisites) {
            edges[info[1]].push_back(info[0]);
        }
        // 对每个未访问节点启动 DFS
        for (int i = 0; i < numCourses && valid; ++i) {
            if (!visited[i]) {
                dfs(i);
            }
        }
        return valid;               // 无环则可完成
    }
};

面试重点

  • 为什么用三色而不是布尔?→ 区分“正在访问”和“已访问”,才能检测环
  • 时间复杂度为何是 O(n + m)?→ 每个节点和每条边最多访问一次

方法二:广度优先搜索(BFS)+ 入度表(Kahn 算法)

📖 核心思想(Kahn 算法)
  1. 计算每个节点的入度(有多少先修课)
  2. 将所有入度为 0 的课程加入队列(可立即学习)
  3. 依次出队,移除其出边(即减少后继课程的入度)
  4. 若某后继课程入度变为 0,加入队列
  5. 最终若处理的课程数 == 总课程数 → 无环;否则有环
💻 代码与行注释(C++)
class Solution {
private:
    vector<vector<int>> edges;  // 邻接表
    vector<int> indeg;          // 入度数组:indeg[i] 表示课程 i 的先修课数量

public:
    bool canFinish(int numCourses, vector<vector<int>>& prerequisites) {
        edges.resize(numCourses);
        indeg.assign(numCourses, 0); // 初始化入度为 0

        // 构建图并统计入度
        for (const auto& info : prerequisites) {
            int from = info[1], to = info[0];
            edges[from].push_back(to);
            indeg[to]++;            // to 的入度 +1
        }

        queue<int> q;
        // 所有入度为 0 的课程入队(可直接学习)
        for (int i = 0; i < numCourses; ++i) {
            if (indeg[i] == 0) {
                q.push(i);
            }
        }

        int visited = 0;            // 已处理的课程数
        while (!q.empty()) {
            ++visited;
            int u = q.front();
            q.pop();
            // 移除 u 的所有出边
            for (int v : edges[u]) {
                --indeg[v];         // v 的先修课少了一门
                if (indeg[v] == 0) {
                    q.push(v);      // 若 v 无先修课了,加入队列
                }
            }
        }

        return visited == numCourses; // 能学完所有课?
    }
};

面试优势

  • Kahn 算法天然支持输出拓扑序列(只需记录出队顺序)
  • 非递归,避免栈溢出(适合大图)
  • 更直观体现“依赖消除”过程

🧭 解题思路(分步拆解)

✅ 步骤 1:建模为图问题

  • 每门课 → 图的一个节点
  • 先修关系 [a, b] → 有向边 b → a

✅ 步骤 2:判断图是否有环

  • 有环 → 存在循环依赖 → ❌ 无法完成
  • 无环 → 是 DAG → ✅ 存在拓扑排序 → 可完成

✅ 步骤 3:选择算法实现

方法优点缺点面试推荐
DFS + 三色代码简洁,空间省(无需队列)递归深度大可能栈溢出✅ 高频考察
BFS (Kahn)非递归,可输出拓扑序需额外入度数组✅ 实用性强

✅ 步骤 4:边界处理

  • prerequisites 为空 → 无依赖 → 直接返回 true
  • 课程数为 1 → 无需依赖 → 返回 true

📊 算法分析

维度DFS 方法BFS 方法
时间复杂度O(n + m)O(n + m)
空间复杂度O(n + m)(邻接表 + 递归栈)O(n + m)(邻接表 + 队列)
是否可输出拓扑序需额外栈天然支持(出队顺序)
适用场景小图、理论分析大图、工程实现

💡 n = 课程数,m = 先修关系数(边数)


💻 完整代码

C++

#include <bits/stdc++.h>
using namespace std;
using ll = long long;

class Solution {
private:
    vector<vector<int>> edges;
    vector<int> visited;
    bool valid = true;

public:
    void dfs(int u) {
        visited[u] = 1;
        for (int v: edges[u]) {
            if (visited[v] == 0) {
                dfs(v);
                if (!valid) {
                    return;
                }
            }
            else if (visited[v] == 1) {
                valid = false;
                return;
            }
        }
        visited[u] = 2;
    }

    bool canFinish(int numCourses, vector<vector<int>>& prerequisites) {
        edges.resize(numCourses);
        visited.resize(numCourses);
        for (const auto& info: prerequisites) {
            edges[info[1]].push_back(info[0]);
        }
        for (int i = 0; i < numCourses && valid; ++i) {
            if (!visited[i]) {
                dfs(i);
            }
        }
        return valid;
    }
};

// 测试
signed main(){
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    cout.tie(nullptr);

    Solution sol;
    // 示例 1
    cout << sol.canFinish(2, {{1,0}}) << "\n"; // 输出: 1 (true)
    // 示例 2
    cout << sol.canFinish(2, {{1,0},{0,1}}) << "\n"; // 输出: 0 (false)
    // 边界:无依赖
    cout << sol.canFinish(1, {}) << "\n"; // 输出: 1
    // 多课程无环
    cout << sol.canFinish(4, {{1,0},{2,0},{3,1},{3,2}}) << "\n"; // 输出: 1

    return 0;
}

JavaScript

/**
 * @param {number} numCourses
 * @param {number[][]} prerequisites
 * @return {boolean}
 */
var canFinish = function(numCourses, prerequisites) {
    const edges = Array.from({length: numCourses}, () => []);
    const indeg = new Array(numCourses).fill(0);
    
    // 构建邻接表和入度数组
    for (const [a, b] of prerequisites) {
        edges[b].push(a);
        indeg[a]++;
    }
    
    const queue = [];
    // 入度为0的课程入队
    for (let i = 0; i < numCourses; i++) {
        if (indeg[i] === 0) {
            queue.push(i);
        }
    }
    
    let visited = 0;
    while (queue.length > 0) {
        visited++;
        const u = queue.shift();
        for (const v of edges[u]) {
            indeg[v]--;
            if (indeg[v] === 0) {
                queue.push(v);
            }
        }
    }
    
    return visited === numCourses;
};

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

🌟 本期完结,下期见!🔥

👉 点赞收藏加关注,新文更新不迷路。关注专栏【算法】LeetCode Hot100刷题日记,持续为你拆解每一道热题的底层逻辑与面试技巧!

💬 欢迎留言交流你的解法或疑问!一起进步,冲向 Offer!💪

📌 记住:当你在刷题时,不要只看答案,要像写这篇文章一样,深入思考每一步背后的原理、优化空间和面试价值。这才是真正提升算法能力的方式!