207. 课程表#
今天要聊的这道题,困扰了无数求职者。但只要掌握了正确的思维方法,你会发现它其实很优雅。
🎓 从选课系统说起#
小明最近在给弟弟规划大学课程,发现了一个有趣的问题:
- “要学高数2,得先学高数1”
- “要学数据结构,得先学C语言”
- “要学操作系统,得先学数据结构”
这不就是今天要解决的算法题吗?
问题描述#
题目目标#
你这个学期必须选修 numCourses 门课程,课程编号为 0 到 numCourses - 1 。给定一个数组 prerequisites ,其中 prerequisites[i] = [ai, bi] 表示如果要学习课程 ai 就必须先完成课程 bi 。请判断是否可能完成所有课程。
示例 1#
输入: numCourses = 2, prerequisites = [1,0](#)
输出: true
依赖关系示意:
0 -> 1text说明: 把输入和输出放在一起看,会更容易直接抓住题目要求的变化。
补充说明#
prerequisites[i] = [a, b]表示必须先学课程b,才能学习课程a。
💡 问题的本质#
LeetCode 207题”课程表”的描述是这样的:
你总共需要修完 numCourses 门课程
prerequisites[i] = [ai, bi] 表示:想要学习课程 ai ,必须先完成课程 bi
判断是否可能完成所有课程的学习?
示例:
输入:numCourses = 4, prerequisites = [[1,0],[2,1],[3,2]]
输出:true
解释:可以按照 0→1→2→3 的顺序学习plaintext🤔 这题的关键是什么?#
本质上,我们在判断:课程之间的依赖关系是否形成了”环”。
- 如果有环:比如 A依赖B、B依赖C、C依赖A,那就不可能完成
- 如果无环:就一定存在一种可行的学习顺序
这就是典型的拓扑排序问题!
🎬 模拟运行:看看算法是如何工作的#
让我们用一个具体例子,一步步看清算法的运行过程:
课程数:4
依赖关系:[[1,0], [2,1], [3,2]]
Step 1: 构建邻接表和入度数组
课程0 → 被课程1依赖
课程1 → 被课程2依赖
课程2 → 被课程3依赖
入度统计:
课程0:0个依赖
课程1:1个依赖(依赖0)
课程2:1个依赖(依赖1)
课程3:1个依赖(依赖2)
Step 2: BFS遍历过程
第一轮:
- 入度为0的课程:[0]
- 将0加入队列
- 学习0后,课程1的入度减1(变为0)
第二轮:
- 入度为0的课程:[1]
- 将1加入队列
- 学习1后,课程2的入度减1(变为0)
第三轮:
- 入度为0的课程:[2]
- 将2加入队列
- 学习2后,课程3的入度减1(变为0)
第四轮:
- 入度为0的课程:[3]
- 将3加入队列
- 没有更多依赖需要解除
最终顺序:0 → 1 → 2 → 3
学习课程总数:4(等于课程总数,说明可行)plaintext⚡ 代码实现:BFS解法#
class Solution {
public boolean canFinish(int numCourses, int[][] prerequisites) {
// adjacency:邻接表,adjacency[x] 存放“依赖课程 x 的所有课程”
// 例如 x -> y 表示学完 x 后可以解锁 y
List<List<Integer>> adjacency = new ArrayList<>();
// 为每门课程初始化一个空的后续课程列表
for (int i = 0; i < numCourses; i++) {
adjacency.add(new ArrayList<>());
}
// inDegrees[i]:课程 i 当前还有多少前置课程未完成(入度)
int[] inDegrees = new int[numCourses];
// 根据 prerequisites 构建图结构和入度数组
for (int[] pre : prerequisites) {
// curr:要学习的课程(被依赖课程)
int curr = pre[0];
// prev:curr 的前置课程(先修课程)
int prev = pre[1];
// 建边 prev -> curr,表示“学完 prev 才能学 curr”
adjacency.get(prev).add(curr);
// curr 多了一个前置依赖,入度加 1
inDegrees[curr]++;
}
// queue:拓扑排序的 BFS 队列,先放入所有“当前可直接学习”的课程(入度为 0)
Queue<Integer> queue = new LinkedList<>();
for (int i = 0; i < numCourses; i++) {
if (inDegrees[i] == 0) {
// 没有任何前置依赖,可立即学习
queue.offer(i);
}
}
// count:记录已经“成功学习并出队”的课程数量
int count = 0;
// 开始 BFS 拓扑遍历
while (!queue.isEmpty()) {
// 取出一门当前可学习的课程
int course = queue.poll();
// 这门课程被学习完成,计数加 1
count++;
// 遍历所有依赖当前课程的后续课程
for (int next : adjacency.get(course)) {
// 当前课程学完后,next 少了一个未完成前置条件
inDegrees[next]--;
// 如果 next 的入度降到 0,说明它现在也可以学习了,入队
if (inDegrees[next] == 0) {
queue.offer(next);
}
}
}
// 若 count == numCourses,说明所有课程都能按某种顺序学完(无环)
// 否则说明图中存在环,至少有一批课程永远无法把入度降到 0
return count == numCourses;
}
}java🎯 算法要点解析#
拓扑排序的精髓在于:
- 找到所有没有依赖的节点(入度为0)
- 删除这些节点,并更新其他节点的依赖状态
- 重复以上步骤,直到:
- 所有节点都被删除(有解)
- 或剩下的节点都有依赖(无解)
📊 复杂度分析#
时间复杂度:O(N + E)
- N是课程数量
- E是依赖关系的数量
空间复杂度:O(N + E)
- 邻接表和队列的空间开销
🎯 面试官最爱追问#
-
Q:如何输出一个可行的学习顺序? A:只需要将BFS过程中的课程号按顺序记录下来
-
Q:能不能用DFS解决? A:可以,通过检测环的方式实现
-
Q:如果有多个可行解,如何输出字典序最小的解? A:将队列改为优先队列,按课程号排序
💡 举一反三#
这个模板还可以解决:
- 任务调度
- 软件包依赖管理
- 项目构建顺序
- 施工工序安排
🎁 思考题#
如果每门课程都有一个学习时长,求完成所有课程的最短时间?
例如:
课程时长:[3,2,4,1](小时)
依赖关系:[[1,0],[2,1],[3,2]]plaintext如果你知道答案,或者有自己的想法?欢迎在评论区留言、讨论~
📝 代码模板总结#
拓扑排序的通用步骤:
- 统计入度
- 将入度为0的节点入队
- BFS删除节点并更新入度
- 判断是否所有节点都被删除