课程表 图解题解
这道题到底在问什么
- 输入
- 6 门课,依赖:0→2, 0→3, 1→3, 1→4, 2→5, 3→5, 4→5
- 输出
- true(存在合法学习顺序)
最优解:为什么这么做
一句话答案:LeetCode 207 课程表本质是判断先修依赖图有没有环,标准解是 BFS 拓扑排序(Kahn 算法):统计每门课的入度,入度为 0 的课先入队,出队修掉后把它指向的后继课入度减一,减到 0 就入队;最终修掉的课数等于总课数说明无环、能修完。时间与空间都是 O(V+E)。
课程表这道题真正在问什么
共 numCourses 门课,prerequisites 里每一对 [b, a] 表示修 b 之前必须先修 a,问能否把所有课修完。把每门课看作节点、每条先修关系看作从 a 指向 b 的有向边(先修指向后修),问题就翻译成:这张有向图能否排出一个不违反任何箭头的线性顺序。答案取决于一件事——图里有没有环:课程互相等待形成死锁,谁都修不了;无环则一定存在合法顺序。所以这题真正问的是「有向图判环」。
为什么想到拓扑排序和入度
顺着生活直觉走:制定学习计划时,第一批修的必然是「没有任何先修要求」的课。翻译到图上,就是入度为 0 的节点——入度指有几条边指向它,也就是它还剩几门先修没满足。
修完一门课,它对后继课的约束就解除了,相当于把它的出边全部擦掉,后继课的入度各减一;谁的入度减到 0,谁就获得了「可以修」的资格。反复执行「取一门入度 0 的课修掉、给后继减入度」,就是 Kahn 算法的 BFS 拓扑排序:用队列装着当前所有可修的课,出队一门、松动一批,直到队列耗尽。
队列维护的不变量与每步的正确性
整个过程保持一条不变量:队列里的课,先修要求全部已被修掉,随时可修。出队修掉它不会违反任何约束;给后继入度减一,则如实记录「又一门先修被满足」。每门课恰好在入度归零那一刻入队一次,每条边恰好在起点被修掉时处理一次,既不重复也不遗漏。
建图方向是最高频的错误:[b, a] 要建 a 指向 b 的边、给 b 的入度加一。方向一反,整个先修关系颠倒,拓扑序全错。记住口诀「先修指向后修」即可。
为什么用修课计数判环而不是看队列空
队列空只说明「没有课可修了」,不说明「课都修完了」。若图里有环,环上每门课都在等环上另一门课先修完,入度永远大于 0,一辈子进不了队——队列会提前耗尽,而这些课一门也没修掉。所以必须数一数总共出队了多少门课:计数等于 numCourses 才是真的全部修完(无环),小于则说明剩下的课困在环里,返回 false。
顺带一提,简单的 visited 标记判不出有向环,要么像这样用入度加计数,要么改用 DFS 三色标记法:搜索中遇到「正在访问中」的灰色节点,说明走回了当前路径,即有环。
复杂度怎么数,还有哪些变体
时间 O(V+E):V 门课每门入队出队至多一次,E 条边在起点出队时各处理一次;空间 O(V+E),花在邻接表、入度数组和队列上。
两个延伸:LeetCode 210 要求给出一个合法学习顺序,做法完全一样,把每次出队的课依次记进数组,最后长度够就返回它;另外若输入含重复的先修边,入度会被多算导致误判,健壮的做法是建图时先对边去重。
▶ 动画逐步走查(共 22 步)——想跟着动画一帧帧对照就展开
- 3记住这套「入度为 0 入队 → 出队修掉 → 后继入度减一 → 减到 0 再入队」,下面每一步都在套它。
- 4算每门课入度先数入度:每个节点头顶标着「有几门课指向它」。入度 0 的课没有先修、可以立刻修——这里是 0 和 1。
- 5队列 = [0,1]把入度为 0 的 0、1 放进队列(标记为「待修」)。它们是整个学习计划的起点。
- 6修课 0从队首取出课 0,把它修掉(高亮)。它入度为 0,先修都满足了,现在轮到它。
- 7order = 0课 0 修完,记入学习顺序。它指向的后继课 2、3 入度各减一——2 减到 0 了!
- 8队列 = [1,2]把刚减到 0 入度的 2 入队,等着被修。队列现在是 [1, 2]。
- 9修课 1从队首取出课 1,把它修掉(高亮)。它入度为 0,先修都满足了,现在轮到它。
- 10order = 0→1课 1 修完,记入学习顺序。它指向的后继课 3、4 入度各减一——3、4 减到 0 了!
- 11队列 = [2,3,4]把刚减到 0 入度的 3、4 入队,等着被修。队列现在是 [2, 3, 4]。
- 12修课 2从队首取出课 2,把它修掉(高亮)。它入度为 0,先修都满足了,现在轮到它。
- 13order = 0→1→2课 2 修完,记入学习顺序。它指向的后继课 5 入度各减一。
- 14队列 = [3,4]这一轮没有新的课入度减到 0,没有新课入队。队列现在是 [3, 4],继续修下一门。
- 15修课 3从队首取出课 3,把它修掉(高亮)。它入度为 0,先修都满足了,现在轮到它。
- 16order = 0→1→2→3课 3 修完,记入学习顺序。它指向的后继课 5 入度各减一。
- 17队列 = [4]这一轮没有新的课入度减到 0,没有新课入队。队列现在是 [4],继续修下一门。
- 18修课 4从队首取出课 4,把它修掉(高亮)。它入度为 0,先修都满足了,现在轮到它。
- 19order = 0→1→2→3→4课 4 修完,记入学习顺序。它指向的后继课 5 入度各减一——5 减到 0 了!
- 20队列 = [5]把刚减到 0 入度的 5 入队,等着被修。队列现在是 [5]。
- 21修课 5从队首取出课 5,把它修掉(高亮)。它入度为 0,先修都满足了,现在轮到它。
- 22order = 0→1→2→3→4→5课 5 修完,记入学习顺序。它指向的后继课 (无) 入度各减一。
- 23队列 = [空]这一轮没有新的课入度减到 0,没有新课入队。队列现在是 [空],继续修下一门。
- 24修完 6/6 → true队列空了,一共修掉 6 门课 = 总课数 6 → 无环,能全部修完!一条合法学习顺序是 0 → 1 → 2 → 3 → 4 → 5。
⚠️ 容易写错的地方
✗ 错:把先修方向建反
✓ 对:prerequisites[i]=[b,a] 应建 a→b、给 b 加入度
方向反了拓扑序整个错;记牢「先修指向后修」
✗ 错:用 visited 标记判环
✓ 对:BFS 拓扑用「入度+计数」判环
简单 visited 判不出有向环;要么统计修掉数,要么用 DFS 三色标记
✗ 错:队列为空就直接返回 true
✓ 对:必须比较「修掉数 == 总课数」
有环时环上的课入度永远 >0、进不了队,队列会提前空,但课没修完
完整代码(Python / Java / C++)
Python
from collections import deque
def canFinish(numCourses, prerequisites):
g = [[] for _ in range(numCourses)]
indeg = [0] * numCourses
for b, a in prerequisites: # 修 b 前先修 a
g[a].append(b); indeg[b] += 1
q = deque(i for i in range(numCourses) if indeg[i] == 0)
seen = 0
while q:
u = q.popleft(); seen += 1 # 修掉 u
for v in g[u]:
indeg[v] -= 1 # 后继入度减一
if indeg[v] == 0: q.append(v)
return seen == numCourses # 修完=无环Java
public boolean canFinish(int n, int[][] pre) {
List<List<Integer>> g = new ArrayList<>();
for (int i = 0; i < n; i++) g.add(new ArrayList<>());
int[] indeg = new int[n];
for (int[] p : pre) { g.get(p[1]).add(p[0]); indeg[p[0]]++; }
Deque<Integer> q = new ArrayDeque<>();
for (int i = 0; i < n; i++) if (indeg[i] == 0) q.offer(i);
int seen = 0;
while (!q.isEmpty()) {
int u = q.poll(); seen++; // 修掉 u
for (int v : g.get(u))
if (--indeg[v] == 0) q.offer(v); // 减到0入队
}
return seen == n;
}C++
bool canFinish(int n, vector<vector<int>>& pre) {
vector<vector<int>> g(n);
vector<int> indeg(n, 0);
for (auto& p : pre) { g[p[1]].push_back(p[0]); indeg[p[0]]++; }
queue<int> q;
for (int i = 0; i < n; i++) if (indeg[i] == 0) q.push(i);
int seen = 0;
while (!q.empty()) {
int u = q.front(); q.pop(); seen++; // 修掉 u
for (int v : g[u])
if (--indeg[v] == 0) q.push(v); // 减到0入队
}
return seen == n;
}复杂度
时间
O(V + E)
每个点入队出队一次,每条边松弛一次
空间
O(V + E)
邻接表 O(V+E) + 入度数组与队列 O(V)
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 课程表 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
要求输出一个合法的学习顺序(LC210)怎么办?+
一样的 Kahn 流程,把每次出队的课依次记进结果数组;若最后数组长度等于课数就返回它,否则(有环)返回空数组。
BFS 拓扑和 DFS 判环有什么区别?+
BFS(Kahn)靠入度,天然给出拓扑序,判环看计数;DFS 用三色标记(白/灰/黑),遇到「灰色」节点说明走回了正在访问的路径=有环。两者都 O(V+E),看习惯选用。
如果有重复的先修边会出问题吗?+
会让入度被多算,导致该课永远修不完。健壮做法是建图时对边去重,或题目保证无重复。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 课程表 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。