哈喽各位,我是前端小L。
欢迎来到我们的图论专题第十六篇!我们告别了自由探索的"网格",来到了一所规则森严的"大学"。在这里,选课是有要求的:想修课程 A,必须先修课程 B。
这种依赖关系 (Dependency) ,在图论中被建模为有向边 :B -> A。 如果我们面临一堆课程和一堆依赖,最担心的是什么? 是**"死循环"**!比如:A 依赖 B,B 依赖 C,C 又依赖 A。这就形成了一个环,导致谁也无法开始。
今天的任务很简单:给定课程和依赖,判断我们能否 修完所有课程(即判断图中是否有环 )。这也是拓扑排序 (Topological Sort) 的核心应用之一。
力扣 207. 课程表
https://leetcode.cn/problems/course-schedule/

题目分析:
-
输入 :课程总数
numCourses,先修条件数组prerequisites([a, b]表示修a必须先修b,即b -> a)。 -
目标:判断是否可能完成所有课程。
-
模型转化:
-
课程 = 节点。
-
先修要求 = 有向边。
-
能否完成 = 有向图中是否存在环 (Cycle)?
-
如果有环,返回
false;如果是 DAG (有向无环图) ,返回true。
-
解法一:BFS ------ Kahn 算法 (入度表法)
这是最直观、最符合人类思维的解法。想象一下,如果让你去选课,你会先选哪门? 当然是选那些"没有先修课"的课程!
这就引入了拓扑排序中的核心概念------入度 (In-degree)。
- 入度:指向该节点的边的数量(即这门课有多少门先修课)。
算法流程(剥洋葱法):
-
建图 & 统计入度:
-
创建邻接表
adj。 -
创建数组
indegree,indegree[i]表示课程i的入度。 -
遍历
[a, b]:添加边b -> a,并且indegree[a]++。
-
-
寻找起点:
- 将所有 入度为 0 的课程(可以直接修的课),放入队列
q。
- 将所有 入度为 0 的课程(可以直接修的课),放入队列
-
BFS 拆解:
-
count = 0(记录已修课程数)。 -
while (!q.empty()):-
拿出一门课
curr,修完它 (count++)。 -
核心操作 :
curr修完了,那么它指向的所有后续课程next,它们的"前置障碍"就少了一个! -
遍历
adj[curr]中的所有next:-
indegree[next]--。 -
检查 :如果
indegree[next]变成了0,说明next的所有先修课都搞定了!它变成了新的"可修课程",入队。
-
-
-
-
最终审判:
-
如果
count == numCourses,说明所有课都修完了(图中无环)。 -
否则(队列空了但还有课没修),说明剩下的课这就构成了环,互相依赖,谁也入不了队。返回
false。
-
代码实现 (Kahn's Algorithm):
C++
#include <vector>
#include <queue>
using namespace std;
class Solution {
public:
bool canFinish(int numCourses, vector<vector<int>>& prerequisites) {
// 1. 建图 + 统计入度
vector<vector<int>> adj(numCourses);
vector<int> indegree(numCourses, 0);
for (const auto& relation : prerequisites) {
int course = relation[0];
int prereq = relation[1];
// 边:prereq -> course
adj[prereq].push_back(course);
indegree[course]++;
}
// 2. 将所有入度为 0 的节点入队
queue<int> q;
for (int i = 0; i < numCourses; ++i) {
if (indegree[i] == 0) {
q.push(i);
}
}
// 3. BFS 拆解
int finishedCount = 0;
while (!q.empty()) {
int curr = q.front();
q.pop();
finishedCount++;
for (int next : adj[curr]) {
indegree[next]--;
if (indegree[next] == 0) {
q.push(next);
}
}
}
// 4. 判断是否所有课程都完成了
return finishedCount == numCourses;
}
};
解法二:DFS ------ 三色标记法
DFS 也可以检测环,但普通的 visited (bool) 数组不够用。为什么? 因为我们需要区分:
-
未访问的节点。
-
当前递归路径上正在访问的节点(如果遇到这种,说明有环!)。
-
已经访问过且确认安全的节点(如果遇到这种,不需要重复搜,直接跳过)。
我们引入 "三色标记法":
-
0 (White):未访问。
-
1 (Gray):正在访问(在当前的递归栈中)。
-
2 (Black):已完成(及其所有子孙节点都已检查无环)。
DFS 逻辑: 对于每个节点 u:
-
如果是
1:发现环了! 返回false。 -
如果是
2:安全的,返回true。 -
如果是
0:-
标记为
1(开始访问)。 -
递归访问所有邻居。如果任何邻居返回
false(有环),我们也返回false。 -
标记为
2(访问结束,安全)。 -
返回
true。
-
代码实现 (DFS):
C++
#include <vector>
using namespace std;
class Solution_DFS {
private:
// 0: unvisited, 1: visiting, 2: visited
bool hasCycle(int u, vector<vector<int>>& adj, vector<int>& state) {
if (state[u] == 1) return true; // 遇到正在访问的节点 -> 有环
if (state[u] == 2) return false; // 遇到已完成的节点 -> 安全
state[u] = 1; // 标记为正在访问
for (int v : adj[u]) {
if (hasCycle(v, adj, state)) {
return true;
}
}
state[u] = 2; // 标记为已完成
return false;
}
public:
bool canFinish(int numCourses, vector<vector<int>>& prerequisites) {
vector<vector<int>> adj(numCourses);
for (const auto& relation : prerequisites) {
adj[relation[1]].push_back(relation[0]);
}
vector<int> state(numCourses, 0);
// 因为图可能不是连通的,需要对每个节点尝试启动 DFS
for (int i = 0; i < numCourses; ++i) {
if (state[i] == 0) {
if (hasCycle(i, adj, state)) {
return false; // 只要发现一个环,就失败
}
}
}
return true;
}
};
深度复杂度分析
-
V (Vertices):课程数。
-
E (Edges):先修条件的数量。
-
时间复杂度 O(V + E):
-
BFS (Kahn):建图 O(E),每个节点入队出队一次 O(V),每条边被遍历一次 O(E)。
-
DFS:每个节点最多被访问常数次(0->1->2),每条边也是。
-
-
空间复杂度 O(V + E):
- 邻接表存储整张图。
总结:有向图的"体检"
今天这道题,是我们对有向图进行的一次"全身检查"。我们学会了两种检测"死循环"(环)的黄金标准:
-
Kahn 算法 (BFS) :基于入度。不断移除入度为0的点,看最后能不能删光。逻辑顺畅,适合需要输出排序结果的场景。
-
三色 DFS :基于状态。利用递归栈捕捉"回边"(Back Edge)。
这两种方法是图论中处理依赖关系的基石。下一题,我们将不仅要判断"能否"修完,还要真正给出一张可行的课表!
下期见!