在算法面试中,课程表问题(LeetCode 207)是一个经典的有向图环检测问题。它模拟了大学选课时的先修关系:给定若干门课程和它们的依赖关系,判断是否可能完成所有课程。本文将从图论建模出发,深入讲解两种主流解法——BFS 入度表法和DFS 三色标记法,并附上 Java、Python、TypeScript 等语言的代码思路,帮助你彻底掌握这类问题。
问题建模:将课程表转化为有向图
题目要求:你必须修完 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。
预处理:构建邻接表
无论使用 BFS 还是 DFS,我们都需要将题目给出的“边列表”转换为更易于操作的“邻接表”。
// 邻接表结构: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]);
}
提示:在 Python 中,可以用 defaultdict(list) 简化;在 TypeScript 中,可以用 Map<number, number[]>;在 Go 中,可以用 map[int][]int。代码风格虽异,核心逻辑一致。
解法一:广度优先搜索 (BFS) — 入度表法
BFS 解法通常被称为 Kahn 算法。其核心思想是维护每个节点的入度(Indegree)——指向该节点的边的数量。在课程表中,入度代表“还需要修多少门先修课才能修这门课”。
逻辑:
- 如果一门课的入度为 0,说明它不需要任何先修课(或者先修课已经修完了),我们就可以“学习”它。
- 当我们学习了一门课,所有依赖它的后续课程的入度就可以减 1。
- 重复此过程,看是否能学完所有课程。
算法流程:
- 构建邻接表并计算所有节点入度。
- 将所有入度为 0 的节点放入队列。
- 循环:从队列取出一个节点,学习课程数 +1,遍历其所有邻居,将邻居入度减 1;若邻居入度变为 0,则将其加入队列。
- 最终判断:学习课程数是否等于总课程数?是则无环,否则有环。
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)(V 为课程数,E 为先修关系数),空间复杂度 O(V + E)。
解法二:深度优先搜索 (DFS) — 三色标记法
DFS 的思路是沿着一条路径一直往下走。如果在递归的过程中,我们又回到了当前路径上已经访问过的节点,那么就说明图中存在环。
为了区分“当前路径正在访问的节点”和“以前路径访问过且已经安全的节点”,我们需要引入三色标记法:
- 未搜索 (0/White):节点还未被访问。
- 搜索中 (1/Gray):节点正在递归栈中,当前路径正在经过它。如果 DFS 遇到标记为 1 的节点,说明有环。
- 已完成 (2/Black):节点及其所有子节点都已检查完毕,没有环。如果 DFS 遇到标记为 2 的节点,直接跳过。
状态流转图:
- 开始DFS → 未搜索(0) → 搜索中(1) → 遇到标记1的邻居(发现环!)→ 返回 false
- 所有邻居检查完毕无环 → 已完成(2) → 返回 true
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;
}
}
⚠️ 注意:在 Python 中,DFS 递归可能导致栈溢出(尤其当 V 很大时),可以考虑使用显式栈模拟。在 Go 中,递归深度同样需要注意。
复杂度分析:时间复杂度 O(V + E),空间复杂度 O(V + E)。
总结与对比
| 特性 | BFS (Kahn 算法) | DFS (三色标记法) |
|---|---|---|
| 核心思想 | 贪心策略,剥洋葱皮,不断移除入度为0的节点 | 递归回溯,一条路走到黑,检测是否回到原路 |
| 实现难度 | 中等,需要维护入度表和队列 | 中等,需要理解递归状态 |
| 空间开销 | 队列 + 数组 | 系统栈 + 数组 |
| 适用场景 | 仅仅需要判断可行性,或需要输出拓扑排序结果 | 判断环的存在,或者需要检测特定路径 |
| 直观性 | 符合“先修先学”的直觉 | 符合“探测回路”的逻辑 |
实践建议:
- 如果依赖关系图稀疏(E ≈ V),BFS 和 DFS 性能相近,选你熟悉的即可。
- 如果图稠密(E ≈ V²),BFS 的队列操作比 DFS 的递归栈更节省内存。
- 在生产代码(如 TypeScript 或 Java 后端)中,BFS 通常更易于调试和测试。
- 在面试手写时,DFS 三色标记法代码更短,但要注意递归栈溢出。
延伸思考:类似问题还有 LeetCode 210(课程表 II,要求输出一种拓扑排序顺序)、LeetCode 630(课程表 III,加入时间窗口)。掌握环检测是解决所有依赖关系问题的基础。
[AFFILIATE_SLOT_1]
多语言实现提示
除了 Java,你还可以用以下语言实现:
- Python:使用
collections.deque实现队列,defaultdict(list)实现邻接表。 - TypeScript:用
Map<number, number[]>和数组实现入度表。 - Go:用
map[int][]int和切片实现,注意 Go 没有内置队列,可以用切片模拟。 - C++:用
vector<vector<int>>和queue<int>。
每种语言的实现思路完全一致,核心都是邻接表 + 入度/颜色标记。建议你至少用两种语言练习,加深理解。
[AFFILIATE_SLOT_2]
结语
课程表问题(LeetCode 207)是有向图环检测的典型代表。通过 BFS 入度表法和 DFS 三色标记法,我们不仅解决了课程依赖问题,更掌握了拓扑排序和深度优先搜索的核心思想。无论是准备面试还是日常开发中的依赖解析,这种“建模 → 建图 → 环检测”的思维模式都极具价值。
浙公网安备 33010602011771号