文章目录
题目描述 :
你必须修完 numCourses 门课程(编号 0 到 numCourses - 1)。给定一个数组 prerequisites,其中 prerequisites[i] = [ai, bi] 表示在修课程 ai 之前,必须先修完课程 bi。请判断你是否可能完成所有课程的学习。
示例:
- 输入:
numCourses = 2,prerequisites = [[1, 0]] - 输出:
true(先修 0,再修 1) - 输入:
numCourses = 2,prerequisites = [[1, 0], [0, 1]] - 输出:
false(0 依赖 1,1 依赖 0,形成死循环)
问题建模 :
这本质上是一个有向图(Directed Graph) 问题。
- 节点(Vertex):每一门课程。
- 边(Edge) :先修关系。如果必须先修
b再修a,则存在一条有向边b -> a。 - 核心目标 :判断图中是否存在环(Cycle) 。
- 如果存在环(例如 A -> B -> C -> A),则永远无法完成课程,返回
false。 - 如果不存在环(即该图是一个有向无环图 DAG ),则可以完成,返回
true。
- 如果存在环(例如 A -> B -> C -> A),则永远无法完成课程,返回
下面的思维导图展示了该问题的解题脉络:
课程表问题
核心概念
有向图建模
环检测 Cycle Detection
解法一: BFS
广度优先搜索
入度表 Indegree
Kahn 算法
解法二: DFS
深度优先搜索
三色标记法
递归栈
预处理:构建图
无论使用 BFS 还是 DFS,我们首先都需要将题目给出的"边列表"转换为更易于操作的"邻接表"。
java
// 邻接表结构:adjacency[i] 存储了所有依赖于课程 i 的后续课程
List<List<Integer>> adjacency = new ArrayList<>();
for (int i = 0; i < numCourses; i++) {
adjacency.add(new ArrayList<>());
}
// 填充邻接表
for (int[] cp : prerequisites) {
// cp[1] 是先修课,cp[0] 是后修课,即 cp[1] -> cp[0]
adjacency.get(cp[1]).add(cp[0]);
}
解法一:广度优先搜索 (BFS) - 入度表法
原理分析
BFS 解法通常被称为 Kahn 算法 。其核心思想是维护每个节点的入度(Indegree)。
- 入度:指向该节点的边的数量。在课程表中,代表"还需要修多少门先修课才能修这门课"。
- 逻辑 :
- 如果一门课的入度为 0,说明它不需要任何先修课(或者先修课已经修完了),我们就可以"学习"它。
- 当我们学习了一门课,所有依赖它的后续课程的入度就可以减 1。
- 重复此过程,看是否能学完所有课程。
算法流程
是
否
是
否
遍历结束
是
否
开始
构建邻接表 & 计算所有节点入度
将所有入度为0的节点
放入队列 Queue
队列是否为空?
学习课程数 == 总课程数?
从队列取出一个节点 u
学习课程数 + 1
遍历 u 的所有邻居节点 v
节点 v 的入度 - 1
v 的入度 == 0?
将 v 加入队列
True: 无环
False: 有环
Java 代码实现
java
import java.util.*;
class Solution {
public boolean canFinish(int numCourses, int[][] prerequisites) {
// 1. 初始化邻接表和入度数组
List<List<Integer>> adjacency = new ArrayList<>();
int[] indegrees = new int[numCourses];
for (int i = 0; i < numCourses; i++) {
adjacency.add(new ArrayList<>());
}
// 2. 构建图并统计入度
for (int[] cp : prerequisites) {
int course = cp[0];
int prerequisite = cp[1];
adjacency.get(prerequisite).add(course);
indegrees[course]++;
}
// 3. 将所有入度为0的课程入队
Queue<Integer> queue = new LinkedList<>();
for (int i = 0; i < numCourses; i++) {
if (indegrees[i] == 0) {
queue.offer(i);
}
}
// 4. BFS 过程
int finishedCourses = 0;
while (!queue.isEmpty()) {
int current = queue.poll();
finishedCourses++;
// 遍历当前课程的所有后续课程
for (int nextCourse : adjacency.get(current)) {
indegrees[nextCourse]--; // 依赖减1
// 如果入度变为0,说明依赖全部完成,加入队列
if (indegrees[nextCourse] == 0) {
queue.offer(nextCourse);
}
}
}
// 5. 判断是否所有课程都修完了
return finishedCourses == numCourses;
}
}
复杂度分析
- 时间复杂度 : O ( V + E ) O(V + E) O(V+E)。 V V V 为课程数, E E E 为先修关系数。我们需要遍历所有的点和所有的边各一次。
- 空间复杂度 : O ( V + E ) O(V + E) O(V+E)。邻接表存储图需要 O ( V + E ) O(V + E) O(V+E) 的空间,入度数组和队列需要 O ( V ) O(V) O(V) 的空间。
解法二:深度优先搜索 (DFS) - 三色标记法
原理分析
DFS 的思路是沿着一条路径一直往下走。如果在递归的过程中,我们又回到了当前路径上已经访问过的节点,那么就说明图中存在环。
为了区分"当前路径正在访问的节点"和"以前路径访问过且已经安全的节点",我们需要引入三色标记法:
- 未搜索 (0/White):节点还未被访问。
- 搜索中 (1/Gray) :节点正在递归栈中,当前路径正在经过它。如果 DFS 遇到标记为 1 的节点,说明有环。
- 已完成 (2/Black):节点及其所有子节点都已检查完毕,没有环。如果 DFS 遇到标记为 2 的节点,直接跳过。
状态流转图
开始DFS
遇到标记1的邻居\n(发现环!)
所有邻居检查完毕\n无环
未搜索(0)
搜索中(1)
已完成(2)
Java 代码实现
java
import java.util.*;
class Solution {
public boolean canFinish(int numCourses, int[][] prerequisites) {
List<List<Integer>> adjacency = new ArrayList<>();
for (int i = 0; i < numCourses; i++) {
adjacency.add(new ArrayList<>());
}
for (int[] cp : prerequisites) {
adjacency.get(cp[1]).add(cp[0]);
}
// 标记数组:0=未搜索, 1=搜索中, 2=已完成
int[] flags = new int[numCourses];
// 因为图可能不是连通的,需要对每个节点尝试 DFS
for (int i = 0; i < numCourses; i++) {
if (!dfs(adjacency, flags, i)) {
return false; // 只要发现环,立即返回 false
}
}
return true;
}
// 返回 true 表示无环,返回 false 表示有环
private boolean dfs(List<List<Integer>> adjacency, int[] flags, int i) {
if (flags[i] == 1) return false; // 再次遇到正在搜索的节点 -> 有环
if (flags[i] == 2) return true; // 遇到已完成节点 -> 安全,跳过
// 标记为"搜索中"
flags[i] = 1;
// 递归访问所有邻居
for (int neighbor : adjacency.get(i)) {
if (!dfs(adjacency, flags, neighbor)) {
return false; // 下层发现环,向上传递
}
}
// 标记为"已完成"
flags[i] = 2;
return true;
}
}
复杂度分析
- 时间复杂度 : O ( V + E ) O(V + E) O(V+E)。每个节点只会被访问一次,每条边也只会被遍历一次。
- 空间复杂度 : O ( V + E ) O(V + E) O(V+E)。邻接表存储图 O ( V + E ) O(V + E) O(V+E),标记数组 O ( V ) O(V) O(V),递归栈最大深度为 O ( V ) O(V) O(V)。
总结与对比
| 特性 | BFS (Kahn 算法) | DFS (三色标记法) |
|---|---|---|
| 核心思想 | 贪心策略,剥洋葱皮,不断移除入度为0的节点 | 递归回溯,一条路走到黑,检测是否回到原路 |
| 实现难度 | 中等,需要维护入度表和队列 | 中等,需要理解递归状态 |
| 空间开销 | 队列 + 数组 | 系统栈 + 数组 |
| 适用场景 | 仅仅需要判断可行性,或需要输出拓扑排序结果 | 判断环的存在,或者需要检测特定路径 |
| 直观性 | 符合"先修先学"的直觉 | 符合"探测回路"的逻辑 |