📖 第91课:课程表

3 阅读18分钟

想系统提升编程能力、查看更完整的学习路线,欢迎访问 AI Compass:github.com/tingaicompa… 仓库持续更新刷题题解、Python 基础和 AI 实战内容,适合想高效进阶的你。

📖 第91课:课程表

模块:图论 | 难度:Medium ⭐⭐⭐ LeetCode 链接:leetcode.cn/problems/co… 前置知识:第89课(岛屿数量 - DFS/BFS基础)、第90课(腐烂的橘子 - 多源BFS) 预计学习时间:30分钟


🎯 题目描述

你需要选修 numCourses 门课程,编号为 0numCourses - 1

给定一个数组 prerequisites,其中 prerequisites[i] = [ai, bi] 表示如果想学习课程 ai,必须先学习课程 bi

判断是否可能完成所有课程的学习。换句话说,判断课程依赖关系中是否存在环。

示例 1:

输入:numCourses = 2, prerequisites = [[1,0]]
输出:true
解释:总共2门课,学完课程0后可以学习课程1

示例 2:

输入:numCourses = 2, prerequisites = [[1,0],[0,1]]
输出:false
解释:需要先学0才能学1,同时需要先学1才能学0,形成死循环。

约束条件:

  • 1 <= numCourses <= 2000 — 课程数量适中
  • 0 <= prerequisites.length <= 5000 — 依赖关系最多5000条
  • prerequisites[i].length == 2 — 每条关系包含两个课程
  • 所有课程对 [ai, bi] 互不相同 — 无重复边

🧪 边界用例(面试必考)

用例类型输入期望输出考察点
最小输入numCourses=1, prerequisites=[]true无依赖时直接返回true
单向链[[1,0],[2,1],[3,2]]true线性依赖无环
直接环[[1,0],[0,1]]false两个节点互相依赖
复杂环[[1,0],[2,1],[0,2]]false三个节点形成环
独立课程numCourses=5, prerequisites=[]true所有课程独立可学
多连通分量[[1,0],[3,2]]true多个独立的依赖链

💡 思路引导

生活化比喻

想象你在大学选课,有些高级课程要求先修课。

🐌 笨办法:每次尝试选一门课,发现需要先修课A,去选A又发现需要先修课B,去选B又发现需要A...一圈绕回来发现陷入死循环。这就像走迷宫一样,一条路一条路去试错,效率极低。

🚀 聪明办法:教务系统会自动检测"是否存在循环依赖"。它的做法是:先找出所有不需要先修课的课程(入度为0),选完这些课后,依赖它们的课程就可以解锁了,再继续选解锁的课程。如果最终所有课程都能被选完,说明没有环;如果还剩课程没选,说明存在环形依赖。

关键洞察

将课程依赖关系建模为有向图,判断能否完成所有课程 = 判断有向图中是否有环


🧠 解题思维链

这一节模拟你在面试中"从零开始思考"的过程。

Step 1:理解题目 → 锁定输入输出

  • 输入:课程总数 numCourses,依赖关系数组 prerequisites
  • 输出:布尔值,true表示能完成所有课程(无环),false表示不能(有环)
  • 限制:需要在合理时间内处理最多2000个节点和5000条边的图

Step 2:先想笨办法(暴力法)

对每个课程进行DFS深度搜索,记录访问路径。如果在搜索过程中再次遇到路径上的节点,说明有环。

  • 时间复杂度:O(V * (V + E)) — 对每个节点都做一次DFS
  • 瓶颈在哪:重复遍历了很多节点和边,且没有利用"已验证无环的节点"的信息

Step 3:瓶颈分析 → 优化方向

暴力DFS的问题是:

  • 对每个节点单独DFS,导致重复访问
  • 没有"记忆化",已经确认无环的节点还要重复检查

优化思路:

  • DFS优化:用三色标记法(未访问/访问中/已完成)避免重复检查
  • BFS拓扑排序:利用"入度"概念,从入度为0的节点开始逐层剥离

Step 4:选择武器

本题有两种经典解法:

  1. DFS + 三色标记(检测回边)
  2. BFS + 拓扑排序(Kahn算法)
  • 选用:**拓扑排序(BFS)**作为主推解法
  • 理由:
    • 拓扑排序是有向无环图(DAG)判定的标准算法
    • BFS实现直观,易于理解和编码
    • 面试中更容易讲清楚逻辑

🔑 模式识别提示:当题目出现"课程依赖"、"任务顺序"、"前置条件"等关键词,优先考虑拓扑排序


🔑 解法一:DFS + 三色标记(环检测)

思路

用深度优先搜索遍历图,给每个节点标记三种状态:

  • 0 (白色):未访问
  • 1 (灰色):正在访问中(在当前DFS路径上)
  • 2 (黑色):已完成访问(该节点及其后代都无环)

如果DFS过程中遇到灰色节点,说明遇到了回边,存在环。

图解过程

示例:numCourses = 4, prerequisites = [[1,0],[2,1],[3,2]]

构建邻接表:
  0 -> [1]
  1 -> [2]
  2 -> [3]
  3 -> []

DFS执行过程:

Step 1: 从节点0开始
  访问0 (标记灰色1)
    -> 访问1 (标记灰色1)
      -> 访问2 (标记灰色1)
        -> 访问3 (标记灰色1)
          -> 3无后继,标记黑色2 ✓
        <- 2完成,标记黑色2 ✓
      <- 1完成,标记黑色2 ✓
    <- 0完成,标记黑色2 ✓

结果:所有节点都变为黑色,无环 → 返回true

---

反例:prerequisites = [[1,0],[0,1]]

构建邻接表:
  0 -> [1]
  1 -> [0]

DFS执行:
  访问0 (灰色1)
    -> 访问1 (灰色1)
      -> 访问0 (发现0已是灰色!) ❌ 检测到环!

返回false

Python代码

from typing import List
from collections import defaultdict


def canFinish(numCourses: int, prerequisites: List[List[int]]) -> bool:
    """
    解法一:DFS + 三色标记(环检测)
    思路:用DFS遍历图,通过检测回边判断是否有环
    """
    # 构建邻接表
    graph = defaultdict(list)
    for course, prereq in prerequisites:
        graph[prereq].append(course)  # prereq -> course

    # 0=未访问(白), 1=访问中(灰), 2=已完成(黑)
    color = [0] * numCourses

    def dfs(node):
        """返回True表示无环,False表示有环"""
        if color[node] == 1:  # 灰色节点 → 回边 → 有环
            return False
        if color[node] == 2:  # 黑色节点 → 已验证无环
            return True

        color[node] = 1  # 标记为访问中(灰色)

        # 访问所有邻居
        for neighbor in graph[node]:
            if not dfs(neighbor):  # 如果发现环
                return False

        color[node] = 2  # 标记为已完成(黑色)
        return True

    # 对所有未访问节点执行DFS
    for i in range(numCourses):
        if color[i] == 0:  # 白色节点
            if not dfs(i):
                return False

    return True


# ✅ 测试
print(canFinish(2, [[1, 0]]))           # 期望输出:true
print(canFinish(2, [[1, 0], [0, 1]]))   # 期望输出:false
print(canFinish(4, [[1, 0], [2, 1], [3, 2]]))  # 期望输出:true
print(canFinish(3, [[1, 0], [2, 1], [0, 2]]))  # 期望输出:false (环:0->1->2->0)

复杂度分析

  • 时间复杂度:O(V + E) — V是课程数,E是依赖关系数

    • 每个节点最多访问一次(白→灰→黑)
    • 每条边最多检查一次
    • 具体地说:如果有2000门课程和5000条依赖,大约需要7000次操作
  • 空间复杂度:O(V + E) — 邻接表O(E) + 颜色数组O(V) + 递归栈O(V)

优缺点

  • ✅ 直接检测环,逻辑简洁
  • ✅ 空间利用高效(只需颜色数组)
  • ❌ 递归深度可能很大(极端情况下链式依赖会达到V层)
  • ❌ 对于初学者,三色标记理解有一定难度

🏆 解法二:拓扑排序 BFS(Kahn算法,最优解)

优化思路

核心想法:

  • 有向无环图(DAG)一定可以进行拓扑排序
  • 拓扑排序的过程:每次选择入度为0的节点,删除它及其出边,重复此过程
  • 如果能删除所有节点,说明无环;如果还剩节点,说明这些节点在环中(入度永远无法变为0)

💡 关键想法:入度为0的节点就像"没有前置条件的课程",可以直接学习。学完后,依赖它的课程的"前置条件数"减1。

图解过程

示例:numCourses = 4, prerequisites = [[1,0],[2,0],[3,1],[3,2]]

Step 0: 构建图和入度表
  图:
    0 -> [1, 2]
    1 -> [3]
    2 -> [3]
    3 -> []

  入度:
    0: 0  (无前置)
    1: 1  (需要0)
    2: 1  (需要0)
    3: 2  (需要1和2)

Step 1: 队列初始化
  队列 = [0]  (入度为0的节点)
  已处理 = 0

Step 2: 处理节点0
  弹出0  已处理 = 1
  更新邻居:
    1的入度: 1 -> 0 (入队)
    2的入度: 1 -> 0 (入队)
  队列 = [1, 2]

Step 3: 处理节点1
  弹出1  已处理 = 2
  更新邻居:
    3的入度: 2 -> 1
  队列 = [2]

Step 4: 处理节点2
  弹出2  已处理 = 3
  更新邻居:
    3的入度: 1 -> 0 (入队)
  队列 = [3]

Step 5: 处理节点3
  弹出3  已处理 = 4
  队列 = []

结果:已处理 == numCourses (4)  返回true

---

反例:prerequisites = [[1,0],[0,1]]

入度:
  0: 1
  1: 1

初始队列 = [] (没有入度为0的节点!)
已处理 = 0

结果:已处理 < numCourses  返回false

Python代码

from typing import List
from collections import defaultdict, deque


def canFinish(numCourses: int, prerequisites: List[List[int]]) -> bool:
    """
    解法二:拓扑排序 BFS (Kahn算法)
    思路:从入度为0的节点开始逐层剥离,能剥离完说明无环
    """
    # 1. 构建图和入度表
    graph = defaultdict(list)
    in_degree = [0] * numCourses

    for course, prereq in prerequisites:
        graph[prereq].append(course)  # prereq -> course
        in_degree[course] += 1

    # 2. 找出所有入度为0的节点(无前置条件的课程)
    queue = deque([i for i in range(numCourses) if in_degree[i] == 0])

    # 3. BFS逐层处理
    processed = 0  # 已处理的课程数

    while queue:
        node = queue.popleft()
        processed += 1  # 选修这门课

        # 更新邻居的入度
        for neighbor in graph[node]:
            in_degree[neighbor] -= 1
            if in_degree[neighbor] == 0:  # 前置条件满足
                queue.append(neighbor)

    # 4. 判断是否所有课程都能学完
    return processed == numCourses


# ✅ 测试
print(canFinish(2, [[1, 0]]))           # 期望输出:True
print(canFinish(2, [[1, 0], [0, 1]]))   # 期望输出:False
print(canFinish(4, [[1, 0], [2, 0], [3, 1], [3, 2]]))  # 期望输出:True
print(canFinish(1, []))                 # 期望输出:True (无依赖)

复杂度分析

  • 时间复杂度:O(V + E) — 与DFS相同

    • 构建图和入度表:O(E)
    • BFS遍历:每个节点入队出队一次O(V),每条边检查一次O(E)
    • 总计:O(V + E)
  • 空间复杂度:O(V + E)

    • 邻接表O(E) + 入度数组O(V) + 队列O(V)

为什么是最优解

  1. 时间复杂度O(V+E)已经是理论最优 — 必须至少遍历所有边一次才能判断环
  2. 空间复杂度合理 — O(V+E)用于存储图结构,无法避免
  3. 无递归栈风险 — 迭代BFS不会栈溢出
  4. 逻辑直观易懂 — "入度"概念比三色标记更容易向面试官解释
  5. 通用性强 — Kahn算法不仅能判环,还能输出拓扑序列(见举一反三题)

🐍 Pythonic 写法

利用列表推导式和生成器表达式简化代码:

def canFinish_pythonic(numCourses: int, prerequisites: List[List[int]]) -> bool:
    from collections import defaultdict, deque

    # 一行构建图(使用setdefault)
    graph = defaultdict(list)
    in_degree = [0] * numCourses
    for a, b in prerequisites:
        graph[b].append(a)
        in_degree[a] += 1

    # 一行生成初始队列
    queue = deque(i for i in range(numCourses) if in_degree[i] == 0)

    # BFS + 计数
    processed = sum(1 for _ in iter(lambda: queue and queue.popleft(), None)
                    for neighbor in graph.get(_, [])
                    if not (in_degree.__setitem__(neighbor, in_degree[neighbor] - 1) or
                            in_degree[neighbor] or queue.append(neighbor)))

    # 简洁写法(推荐):
    processed = 0
    while queue:
        processed += 1
        for neighbor in graph[queue.popleft()]:
            in_degree[neighbor] -= 1
            in_degree[neighbor] or queue.append(neighbor)

    return processed == numCourses

⚠️ 面试建议:先写清晰版本展示思路,再提"可以用列表推导优化初始化"展示语言功底。过度Pythonic会降低可读性。


📊 解法对比

维度解法一:DFS + 三色标记🏆 解法二:拓扑排序BFS(最优)
时间复杂度O(V + E)O(V + E) ← 时间最优
空间复杂度O(V + E)O(V + E) ← 相同
代码难度中等(需理解三色)简单(入度概念直观)
栈溢出风险有(深度递归)(迭代BFS)
面试推荐⭐⭐⭐⭐⭐ ← 首选
扩展性只能判环可输出拓扑序列
适用场景偏好递归思维通用,面试标准解

为什么BFS拓扑排序是最优解:

  • 时间空间已达理论最优O(V+E),无法进一步优化
  • 逻辑清晰,易于向面试官解释"为什么这样做"
  • 实现简单,不易出错,面试压力下更稳
  • 通用性强,可以扩展到"输出课程学习顺序"(LeetCode 210)

面试建议:

  1. 先用30秒口述DFS思路:"可以用DFS检测环,但有更直观的方法"
  2. 立即优化到🏆BFS拓扑排序:"利用入度概念,从无依赖课程开始逐层剥离"
  3. 重点讲解核心逻辑:"入度为0 = 可学习,学完后更新依赖它的课程的入度"
  4. 强调为什么最优:"O(V+E)已是理论下限,且逻辑最清晰"
  5. 手动测试边界用例:空图、单节点、直接环、线性链

🎤 面试现场

模拟面试中的完整对话流程,帮你练习"边想边说"。

面试官:请你解决一下这道课程表问题。

:(审题30秒)好的,这道题要求判断是否能完成所有课程,给定了课程之间的依赖关系。我理解这本质上是判断有向图中是否存在环

让我先想一下...

直观想法:可以用DFS遍历图,用三色标记法检测回边,如果遇到"访问中"的节点就说明有环。

更好的方法:用拓扑排序(Kahn算法)。核心思路是:

  1. 统计每个课程的入度(有多少前置条件)
  2. 从入度为0的课程开始学习(无前置条件)
  3. 学完一门课后,将依赖它的课程的入度减1
  4. 重复此过程,如果最终能学完所有课程,说明无环

我用第二种方法,因为它逻辑更直观,且不会有递归栈溢出风险。

面试官:很好,请写一下代码。

:(边写边说关键步骤)

# 1. 先构建邻接表和入度数组
graph = defaultdict(list)
in_degree = [0] * numCourses
for course, prereq in prerequisites:
    graph[prereq].append(course)  # prereq指向course
    in_degree[course] += 1

# 2. 找出所有入度为0的课程
queue = deque([i for i in range(numCourses) if in_degree[i] == 0])

# 3. BFS逐个处理
processed = 0
while queue:
    node = queue.popleft()
    processed += 1  # 学习这门课
    for neighbor in graph[node]:
        in_degree[neighbor] -= 1  # 前置条件-1
        if in_degree[neighbor] == 0:
            queue.append(neighbor)

# 4. 判断是否全部学完
return processed == numCourses

面试官:测试一下?

:用示例 [[1,0],[0,1]] 走一遍:

  • 构建图:0->1, 1->0
  • 入度:0和1都是1
  • 初始队列为空(没有入度为0的节点)
  • processed=0 < numCourses=2 → 返回false ✓

再测一个正常情况 [[1,0],[2,1]]:

  • 图:0->1->2
  • 入度:0:0, 1:1, 2:1
  • 队列:[0] → 学0 → 1入度变0 → 队列:[1] → 学1 → 2入度变0 → 队列:[2] → 学2
  • processed=3 == numCourses ✓

面试官:复杂度是多少?

:

  • 时间O(V+E):构建图O(E),BFS每个节点和边各访问一次O(V+E)
  • 空间O(V+E):邻接表O(E),入度数组和队列O(V)

高频追问

追问应答策略
"还有更优解吗?""时间O(V+E)已经是理论最优,因为至少要遍历所有边一次才能判断环。空间也无法避免,需要存储图结构。"
"如果要输出课程学习顺序呢?""完全一样的算法!只需在BFS过程中将出队的节点记录到结果数组,就是拓扑序列。这就是LeetCode 210题。"
"能用DFS做吗?""可以。用三色标记法:白色(未访问)、灰色(访问中)、黑色(已完成)。遇到灰色节点说明有回边(环)。但面试中BFS更直观。"
"如果数据量特别大呢?""可以考虑:1) 并行化拓扑排序(多个入度为0的节点可同时处理); 2) 如果内存不足,用外部排序或分治处理子图。"
"实际工程中的应用?""项目构建系统(如Makefile、Maven)检测循环依赖;任务调度系统(DAG任务流);数据库外键约束检测。"

🎓 知识点总结

Python技巧卡片 🐍

# 技巧1:defaultdict构建邻接表 — 避免KeyError
from collections import defaultdict
graph = defaultdict(list)
graph[0].append(1)  # 自动创建空列表

# 技巧2:列表推导生成初始队列 — 简洁优雅
queue = deque([i for i in range(n) if in_degree[i] == 0])

# 技巧3:短路逻辑简化条件判断
in_degree[neighbor] -= 1
if in_degree[neighbor] == 0:
    queue.append(neighbor)
# 等价于:
in_degree[neighbor] or queue.append(neighbor)  # 0为假,执行append

💡 底层原理(选读)

为什么拓扑排序能检测环?

核心原理:有向无环图(DAG)一定可以被拓扑排序,有环图一定不能

数学证明:

  1. 如果图中有环,环上所有节点的入度都 ≥1(每个节点至少有一条入边来自环内)
  2. Kahn算法每次只处理入度为0的节点
  3. 环上节点永远无法变为入度0(删除外部入边后,环内入边仍存在)
  4. 因此有环图一定会剩下节点无法处理

deque性能:

  • popleft()append() 都是O(1)
  • 普通list的 pop(0) 是O(n)(需要移动所有元素)
  • 这就是为什么BFS必须用deque而不是list

算法模式卡片 📐

  • 模式名称:拓扑排序(Kahn算法)
  • 适用条件:有向图,需要判断是否有环 或 需要输出依赖顺序
  • 识别关键词:"任务依赖"、"课程先修"、"编译顺序"、"循环引用检测"
  • 核心要素:
    1. 入度数组(统计每个节点的入边数)
    2. 队列(存储入度为0的节点)
    3. BFS逐层剥离
  • 模板代码:
# 拓扑排序通用模板
def topological_sort(n, edges):
    from collections import defaultdict, deque

    graph = defaultdict(list)
    in_degree = [0] * n

    for u, v in edges:
        graph[u].append(v)
        in_degree[v] += 1

    queue = deque([i for i in range(n) if in_degree[i] == 0])
    order = []

    while queue:
        node = queue.popleft()
        order.append(node)
        for neighbor in graph[node]:
            in_degree[neighbor] -= 1
            if in_degree[neighbor] == 0:
                queue.append(neighbor)

    # 判断是否有环:
    # 有环 → len(order) < n
    # 无环 → len(order) == n,且order就是拓扑序列
    return order if len(order) == n else []

易错点 ⚠️

  1. 边的方向搞反

    • 错误:graph[course].append(prereq) — 这会建成反图
    • 正确:graph[prereq].append(course) — prereq指向course
    • 记忆法:"先修课指向后续课"
  2. 入度更新时机错误

    • 错误:只在初始化时统计入度,BFS中不更新
    • 正确:每次处理节点时,将其邻居的入度减1
    • 记忆法:"学完一门课,依赖它的课的前置条件-1"
  3. 判断条件写错

    • 错误:return len(queue) == 0 — 队列为空不代表全处理完
    • 正确:return processed == numCourses — 必须统计实际处理的节点数

🏗️ 工程实战(选读)

这个算法思想在真实项目中的应用,让你知道"学了有什么用"。

  • 场景1:构建系统依赖管理

    • Maven、Gradle等构建工具检测模块间循环依赖
    • Makefile编译顺序决策(先编译哪个源文件)
  • 场景2:任务调度系统

    • Airflow、Oozie等DAG任务流引擎
    • 检测任务间是否有循环依赖,生成执行顺序
  • 场景3:数据库外键约束

    • 检测表之间是否有循环外键引用
    • 删除表时的顺序决策
  • 场景4:包管理器

    • npm、pip等包管理器解析依赖关系
    • 检测循环依赖,生成安装顺序

🏋️ 举一反三

完成本课后,试试这些同类题目来巩固知识:

题目难度相关知识点提示
LeetCode 210. 课程表IIMedium拓扑排序输出序列完全相同算法,只需记录order数组
LeetCode 310. 最小高度树Medium拓扑排序变体从叶子节点(度为1)开始剥离
LeetCode 444. 序列重建Medium拓扑排序唯一性判断拓扑序列是否唯一
LeetCode 802. 找到最终的安全状态Medium反向图拓扑排序找出不在环中的节点
LeetCode 1136. 并行课程Medium拓扑排序+层数BFS层序遍历统计最长路径

📝 课后小测

试试这道变体题,不要看答案,自己先想5分钟!

题目:给定课程依赖关系,如果可以完成所有课程,返回一种合法的学习顺序;如果不能,返回空数组。(LeetCode 210)

💡 提示(实在想不出来再点开)

完全相同的拓扑排序算法!唯一区别:在BFS过程中,每次 popleft() 时将节点加入结果数组。

✅ 参考答案
def findOrder(numCourses: int, prerequisites: List[List[int]]) -> List[int]:
    from collections import defaultdict, deque

    graph = defaultdict(list)
    in_degree = [0] * numCourses

    for course, prereq in prerequisites:
        graph[prereq].append(course)
        in_degree[course] += 1

    queue = deque([i for i in range(numCourses) if in_degree[i] == 0])
    order = []  # 唯一改动:记录顺序

    while queue:
        node = queue.popleft()
        order.append(node)  # 记录学习顺序
        for neighbor in graph[node]:
            in_degree[neighbor] -= 1
            if in_degree[neighbor] == 0:
                queue.append(neighbor)

    return order if len(order) == numCourses else []

核心改动:增加一行 order.append(node),BFS的出队顺序就是拓扑序列!


如果这篇内容对你有帮助,推荐收藏 AI Compass:github.com/tingaicompa… 更多系统化题解、编程基础和 AI 学习资料都在这里,后续复习和拓展会更省时间。