📌 题目链接: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 算法)
- 计算每个节点的入度(有多少先修课)
- 将所有入度为 0 的课程加入队列(可立即学习)
- 依次出队,移除其出边(即减少后继课程的入度)
- 若某后继课程入度变为 0,加入队列
- 最终若处理的课程数 == 总课程数 → 无环;否则有环
💻 代码与行注释(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!💪
📌 记住:当你在刷题时,不要只看答案,要像写这篇文章一样,深入思考每一步背后的原理、优化空间和面试价值。这才是真正提升算法能力的方式!