1. 课程表问题概述
LeetCode 207题"Course Schedule"是图论中的经典拓扑排序问题,也是硅谷大厂面试中的高频考题。题目要求判断给定的课程安排是否合理,即是否存在循环依赖导致无法完成所有课程。这个问题在实际开发中有着广泛的应用场景,比如构建系统的依赖管理、任务调度系统的死锁检测等。
我第一次遇到这个问题是在准备Amazon面试时,当时花了整整两天才彻底理解拓扑排序的精髓。现在回头看,其实只要掌握几个关键点就能轻松应对这类问题。本文将用最直白的语言,带你从问题本质出发,逐步拆解解题思路。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 问题分析与建模
2.1 题目理解与示例
题目给出课程总数n和一组先修关系prerequisites,其中prerequisites[i] = [a, b]表示要学习课程a必须先完成课程b。我们需要判断是否可能完成所有课程。
示例1:
输入:n = 2, prerequisites = [[1,0]]
输出:true
解释:总共有2门课程。要学习课程1,需先完成课程0。所以是可能的。
示例2:
输入:n = 2, prerequisites = [[1,0],[0,1]]
输出:false
解释:课程1依赖课程0,课程0又依赖课程1,形成死循环。
2.2 图论模型建立
这个问题可以转化为有向图的环检测问题:
- 每门课程是图中的一个节点
- 先修关系[a,b]表示从b到a的有向边
- 如果图中存在环,则无法完成所有课程
我第一次建模时就犯了个错误:把边的方向搞反了。记住,边的方向应该与依赖方向相反,即"被依赖者指向依赖者"。
3. 拓扑排序解法详解
3.1 拓扑排序基本概念
拓扑排序是对有向无环图(DAG)的线性排序,满足:
- 每个顶点出现且仅出现一次
- 若存在边u→v,则u在排序中位于v之前
实现拓扑排序的两种经典方法:
- Kahn算法(基于入度)
- DFS深度优先搜索
3.2 Kahn算法实现
这是我最推荐新手掌握的方法,思路直观且容易实现:
- 计算每个节点的入度
- 将入度为0的节点加入队列
- 依次取出节点,将其邻接节点入度减1
- 若邻接节点入度变为0,加入队列
- 最后检查是否所有节点都被处理
python复制from collections import deque
def canFinish(n, prerequisites):
# 构建邻接表和入度数组
adj = [[] for _ in range(n)]
in_degree = [0] * n
for a, b in prerequisites:
adj[b].append(a)
in_degree[a] += 1
# 初始化队列
q = deque([i for i in range(n) if in_degree[i] == 0])
count = 0
# 开始处理
while q:
u = q.popleft()
count += 1
for v in adj[u]:
in_degree[v] -= 1
if in_degree[v] == 0:
q.append(v)
return count == n
注意:邻接表存储的是出边,即adj[u]包含所有u指向的节点
3.3 DFS实现方法
DFS方法利用递归深度优先搜索来检测环:
python复制def canFinish(n, prerequisites):
adj = [[] for _ in range(n)]
for a, b in prerequisites:
adj[b].append(a)
# 0=未访问, 1=访问中, 2=已访问
visited = [0] * n
def hasCycle(u):
if visited[u] == 1:
return True
if visited[u] == 2:
return False
visited[u] = 1
for v in adj[u]:
if hasCycle(v):
return True
visited[u] = 2
return False
for i in range(n):
if hasCycle(i):
return False
return True
DFS的实现关键在于三色标记法:
- 白色(0):未访问
- 灰色(1):正在访问(递归栈中)
- 黑色(2):已访问完成
如果在DFS过程中遇到灰色节点,说明存在环。
4. 复杂度分析与优化
4.1 时间复杂度
两种方法的时间复杂度都是O(V+E):
- V是节点数(课程数n)
- E是边数(先修关系数)
因为每个节点和边都只被处理一次。
4.2 空间复杂度
空间复杂度也是O(V+E):
- 邻接表存储需要O(E)空间
- 入度数组/访问标记需要O(V)空间
- 队列/递归栈最坏情况下需要O(V)空间
4.3 实际运行对比
在LeetCode上测试:
- Kahn算法通常运行更快(约80ms)
- DFS方法稍慢(约100ms)
- 但实际差异不大,都可以轻松通过测试用例
5. 常见错误与调试技巧
5.1 典型错误案例
-
边方向搞反:
python复制# 错误写法 adj[a].append(b) # 应该反过来 -
忘记初始化数据结构:
python复制# 缺少入度数组初始化 in_degree = [0] * n -
DFS中标记顺序错误:
python复制# 错误顺序 visited[u] = 2 for v in adj[u]: # 应该先标记为1 ...
5.2 调试技巧
-
打印中间状态:
python复制print(f"处理节点{u}, 当前入度数组:{in_degree}") -
可视化小规模测试用例:
- 画出图结构
- 手动模拟算法流程
-
边界测试:
- n=0或1的情况
- prerequisites为空的情况
- 完全独立或完全依赖的情况
6. 变种问题与扩展
6.1 LeetCode 210. Course Schedule II
不仅要判断能否完成,还要返回完成顺序:
python复制def findOrder(n, prerequisites):
adj = [[] for _ in range(n)]
in_degree = [0] * n
for a, b in prerequisites:
adj[b].append(a)
in_degree[a] += 1
q = deque([i for i in range(n) if in_degree[i] == 0])
result = []
while q:
u = q.popleft()
result.append(u)
for v in adj[u]:
in_degree[v] -= 1
if in_degree[v] == 0:
q.append(v)
return result if len(result) == n else []
6.2 并行执行问题
如果允许同时上k门课,最少需要多少学期完成?
这类问题需要在拓扑排序的基础上增加层级统计。
6.3 实际工程应用
- 构建系统的依赖解析(Makefile)
- 任务调度系统(Airflow等)
- 软件包依赖管理(npm, pip等)
7. 面试技巧与注意事项
7.1 面试常见问题
-
如何证明你的算法是正确的?
- 拓扑排序的性质保证无环图的线性顺序
- 如果处理节点数≠总节点数,说明存在环
-
两种方法的优缺点?
- Kahn算法更适合并行实现
- DFS可能更适合某些特定场景
-
如何处理大规模数据?
- 讨论分布式拓扑排序
- 考虑内存限制下的处理方式
7.2 白板编程建议
- 先明确问题输入输出
- 画出示例图并手动模拟
- 解释清楚算法步骤后再编码
- 讨论时间/空间复杂度
7.3 代码风格建议
-
使用有意义的变量名:
python复制# 好名字 course_to_prerequisites = defaultdict(list) # 不好的名字 mp = {} -
添加必要注释:
python复制# 将入度为0的节点加入队列 q = deque([i for i in range(n) if in_degree[i] == 0]) -
处理边界条件:
python复制if n == 0: return True
8. 个人实战经验分享
我在第一次实现时犯了一个典型错误:在Kahn算法中忘记维护count计数器,导致无法正确判断是否处理了所有节点。调试时通过打印in_degree数组才发现问题。
另一个经验是:对于这类问题,先在小规模测试用例上手动模拟算法流程非常有用。比如n=3,prerequisites=[[1,0],[2,0],[2,1]],可以画出图并手动执行算法。
最后给准备面试的同学一个建议:不要死记硬背代码,要真正理解拓扑排序的工作原理。面试官经常会问变种问题,只有理解本质才能灵活应对。
