j•jueansuyA LITTLE SPACE TO BUILD 回到学习与实践
PRACTICE NOTE / 004

课程表:
为什么入度为 0 才是 BFS 拓扑排序的钥匙

这是一道 CSP 备考中的经典有向图与拓扑排序练习。课程之间有先后先修要求,形成一张有向图。复盘这道题时,关键是用 Kahn 算法,将“可以开学的课程”与“入度为 0”画上等号,在队列推进中自然发现死锁环。

01 / THE PROBLEM

把先修关系,看成有向图。

总共有 numCourses 门课程,编号从 0 到 numCourses - 1。数对 [a, b] 表示想学课程 a 必须先修完课程 b。题目问是否可能修完所有课程。

如果课程之间存在互相依赖(如 0 依赖 1,1 依赖 0),或者多门课串成一条闭环,就会出现“谁也无法先学”的死锁。这就是典型的有向图环检测。通过拓扑排序,如果能排出一个合法的先后学习线性序列,说明图是无环的(DAG);如果排不完,说明存在环依赖。

02 / STEP THROUGH IT

分批消解入度,解锁新课程。

选择情形并点击“下一步”,观察各节点入度如何随着前置课程的修完逐步扣减,以及入度降为 0 后如何入队推进。

有向图拓扑排序 · BFS 步进 Kahn 算法
课程节点状态(入度为 0 代表前置要求全部满足)
依赖关系(b → a 表示学完 b 才能学 a)
  • 正在修读
  • 入度为 0 在等待
  • 已修完
当前队列

[]

已修完课程

0 / 0

步数

1 / 1

初始状态:准备将初始入度为 0 的课程加入队列。

每修完一门课,它指向的后续课程入度减 1。只有入度归零,课程才能解锁。

03 / THREE DECISIONS

把分批学课,交给队列。

  1. 01

    建图方向是 b → a

    数对 [a, b] 代表先学 b 再学 a。因此邻接表记录 b 的出边指向 a,同时 indegree[a]++ 记录 a 有一个未履行的前置。

  2. 02

    只有入度减到 0 才能入队

    一门课可能有 3 个前置。修完其中一个,只是入度从 3 变成 2;只有等全部 3 个前置都修完、入度真正变为 0 时,才能放入学习队列。

  3. 03

    用 learned == numCourses 判环

    如果图中有环,环上的节点由于互相等待,入度永远不可能降为 0。队列提前排空时,只要 learned < numCourses,就证明有环死锁。

为什么是广度优先遍历?

第一批入队的是一开始就无要求的课;学完第一批,解锁出第二批;再学完第二批,解锁出第三批。这种按依赖层次层层推进的过程,本质上就是有向图上的 BFS。

04 / C IMPLEMENTATION

入度数组与队列的紧凑配合。

这段代码来自仓库解法。使用静态邻接表建图并统计入度,队列保存当前可学课程,单循环即可完成拓扑推进与环检测。

C / Kahn 算法核心循环节选查看完整代码 ↗
/* 建图:b -> a,并统计入度 */
for (int i = 0; i < prerequisitesSize; i++) {
    int a = prerequisites[i][0];
    int b = prerequisites[i][1];
    graph[b][graphSize[b]] = a;
    graphSize[b]++;
    indegree[a]++;
}

/* 初始所有入度为 0 的课入队 */
int queue[MAXN], front = 0, rear = 0;
for (int i = 0; i < numCourses; i++) {
    if (indegree[i] == 0) {
        queue[rear++] = i;
    }
}

int learned = 0;
while (front < rear) {
    int course = queue[front++];
    learned++;
    for (int i = 0; i < graphSize[course]; i++) {
        int next = graph[course][i];
        indegree[next]--;
        if (indegree[next] == 0) {
            queue[rear++] = next;
        }
    }
}
return learned == numCourses;

仓库中使用固定数组模拟邻接表与队列,避免动态链表分配开销。对于超稀疏或极大规模图,亦可采用链式前向星压缩存储。

05 / NOTES TO KEEP

原复盘里,留下的四个提醒。

这四条真实记录在当时的题目笔记中。有向图的建图方向、入度递减时机与出入队概念,极易因为概念混淆而产生 Bug。

01 先修关系的建图方向别建反
[a, b] 的含义是“先学 b 才能学 a”,边应当是 b -> a。建反会导致入度和出度的含义颠倒,永远排不出正确顺序。
02 邻接表数组追加是赋值
写代码时注意 graph[b][graphSize[b]] = a; 是单等号赋值,不要误敲成判断 ==,否则邻接边根本没有加进去。
03 队列里放的是“当前入度为 0”的课
队列绝不是用来存放所有课程的,只存放“前置要求已经被全部满足,现在立即可以修读”的课。
04 判环依据是 learned == numCourses
不要用队列是否为空来判断是否有环。队列总会变空,关键是队列变空时,真正学完的课程数 learned 是否等于总课程数。
06 / CHECK THE EDGES

用几种典型形态,检查边界。

返回 true

毫无先修限制

prerequisites = []

所有课程初始入度均为 0,第一步全部入队,一轮顺次学完。

返回 false

两门课互相死锁

[0, 1] 且 [1, 0]

0 和 1 的入度都是 1,队列初始为空,直接返回 false。

返回 false

部分课程在环中

0->3 正常,1<->2 互锁

0 和 3 顺利学完,但 1 和 2 无法进入队列,最终 learned = 2 < 4 返回 false。

返回 true

多起点并行展开

0 和 1 共同解锁 2

0 和 1 同时在第一批队列,待两者先后出队后,2 的入度彻底减为 0 顺利解锁。

07 / TAKEAWAY

从网格 BFS 到图论拓扑序。

从笔记 001 的网格 BFS,到笔记 004 的有向图拓扑排序,队列承担的核心角色是一脉相承的:每一轮只处理当前已经就绪的节点,在推进的同时顺水推舟地解锁下一轮。入度不仅是一个数字,更是依赖关系的计数器。

时间复杂度O(V + E)

每个课程节点入队一次,每条先修边仅遍历一次。

空间复杂度O(V + E)

邻接表、入度数组与队列的存储开销。

上述复杂度对应图论通用链表实现。仓库二维静态数组写法空间为 O(V²)。本页步进演示保留历史帧以支持回溯。

SOURCE NOTES

题目与原始记录

本篇根据公开刷题仓库里的实现、样例与错误记录整理,保留原有解法的关键细节。