本题采用基于入度统计的卡恩算法(Kahn 算法)解决有向图的拓扑排序与有向环检测问题。其核心本质是将课程间的先修依赖关系抽象为有向图的权重边,通过动态消除入度为 0 的节点来验证图的无环性(DAG)。当前提供的源码实现了在时间复杂度 O(v + e) 和额外空间复杂度 O(v + e) 条件下的拓扑网络扫描(其中 v 为课程数,e 为依赖对数),最终走向是精准判定是否存在循环依赖以决定是否能完成所有课程。
一、 问题本质与数据模型
对于包含 numCourses 门课程和 prerequisites 依赖数组的场景,题目给出了核心的拓扑依存约束:
-
有向依赖关系 :若修读课程
ai必须先修读bi,则构成一条从bi指向ai的有向边(bi -> ai)。 -
全局无环性约束:若要修完所有课程,课程依赖关系构成的有向图中绝对不能存在环路(例如 0 -> 1 -> 0)。
如果在处理此问题时仅进行局部的无序遍历,将无法在复杂的网格网络中准确捕捉到长路径的环路依存。
为了破除依赖闭环导致的死锁困局,算法引入了"邻接表与入度向量模型"。通过建立入度数组 inDegree 记录每个节点被直接指向的次数,以及邻接表 adj 存储每个节点的所有后继解锁节点。算法首先筛选出所有入度为 0 的节点(即没有任何先修限制的自由课程)作为初始拓扑火种,注入双端队列 q 中。随后利用消除法,每当一个节点出队,便将其指向的所有后继节点的入度减 1,物理模拟了"修完此课并解除后续课程部分限制"的过程。若后继节点入度降为 0,则该节点被激活入队。最终,通过比对成功出队的节点总数与课程总数是否相等,即可准确判定全图是否存在由于环路导致的死锁。
二、 算法演进对比
在解决有向图拓扑排序与死锁检测的场景中,卡恩算法在状态维护和早期熔断上表现稳定:
| 解法名称 | 时间复杂度 | 空间复杂度 | 核心原理 | 物理瓶颈 / 缺陷 |
|---|---|---|---|---|
| 深度优先搜索染色法 (DFS) | O(v + e) | O(v + e) | 利用三色标记法递归探测隐式调用栈中的回边 | 强依赖递归调用栈,在极长链式依赖下存在栈溢出风险,且逆向拓扑不易于直观理解 |
| 卡恩算法 (当前解法) | O(v + e) | O(v + e) | 利用显式队列和入度数组自底向上消除入度为 0 的节点 | 必须在前期进行全图线性扫描以显式构建入度向量和邻接表 |
| 暴力矩阵可达性闭包 | O(v 的三次方) | O(v 的二次方) | 通过弗洛伊德算法计算传递闭包检测对角线是否存在有效值 | 时空开销极大,完全无法应对数千量级的图节点规模 |
三、 核心分支控制逻辑与决策证明
当前源码的控制流完全依赖于入度预统计、队列步进消除以及入度归零触发,其内部决策分支证明如下:
1. 入度与邻接表初始化:inDegree[cp[0]]++; adj.get(cp[1]).add(cp[0]);
-
执行:遍历先修数组,递增后继节点的入度,并将后继节点挂载到前驱节点的邻接列表中。
-
数学证明 :
cp[1]是先修课,cp[0]是后继课。此操作在内存中构建了一个符合物理依存顺序的有向图雏形,inDegree精确映射了每个节点当前不可逾越的物理限制门槛。
2. 初始火种筛选:if (inDegree[i] == 0) { q.add(i); }
-
执行:将入度为 0 的节点压入队列。
-
物理意义:入度为 0 证明该课程没有任何先修限制,可以无条件直接修读。它们是图遍历的起点源头,消除了盲目搜索导致的死锁误判。
3. 计数器步进累加:count++; int cur = q.poll();
-
执行 :从队列中取出一个可修读节点,全局计数器
count自增。 -
数学证明 :成功出队代表该课程已经被合法修读完毕。
count的最终值代表了全图中能够通过无环路径合法到达并解锁的节点绝对总量。
4. 依存关系物理消除:inDegree[next]--; if (inDegree[next] == 0) { q.add(next); }
-
执行:遍历当前出队节点指向的所有后继节点,降低其入度;若降为 0 则入队。
-
数学证明 :
inDegree[next]--代表前驱限制已被物理剥离。当且仅当inDegree[next] == 0时,意味着该后继节点的所有先修限制已在之前的步骤中被全部修完,该节点由受限状态彻底坍塌为自由状态,获得入队修读权限。
四、 算法执行状态机步进示例
以输入 numCourses = 3,prerequisites = [[1, 0], [2, 1], [0, 2]] 构成死锁环路为例,展示卡恩算法由于环路拦截导致无法完成拓扑排序的状态机全貌:
| 步骤 | 当前初始化的入度数组 inDegree | 队列状态 q | 当前出队节点 cur | 触发的入度扣减动作与分支判定 | 已合法修读计数 count |
|---|---|---|---|---|---|
| 初始 | 1, 1, 1 (包含 0->1, 1->2, 2->0) | (空) | - | 检查入度数组发现没有入度为 0 的节点 | 0 |
| 终止 | 1, 1, 1 | - | while 条件 !q.isEmpty() 为假,直接退出循环 |
出口检验 count == numCourses (0 == 3) 返回 false |
再看一个可以成功修完的实例:numCourses = 3, prerequisites = [[1, 0], [2, 1]]:
| 步骤 | 当前入度数组 inDegree | 队列状态 q | 当前出队节点 cur | 触发的入度扣减动作与分支判定 | 已合法修读计数 count |
|---|---|---|---|---|---|
| 初始 | 0, 1, 1 | 0 | - | 节点 0 入度为 0,注入队列 | 0 |
| 1 | 0, 1, 1 | 0 | 扫描 0 的后继 1,inDegree1 减至 0,节点 1 入队 | 1 | |
| 2 | 0, 0, 1 | 1 | 1 | 扫描 1 的后继 2,inDegree2 减至 0,节点 2 入队 | 2 |
| 3 | 0, 0, 0 | 2 | 2 | 节点 2 无后继,退出后继遍历循环 | 3 |
| 终止 | 0, 0, 0 | - | 队列为空退出,满足 count == numCourses 返回 true |
3 |
五、 源码实现
import java.util.ArrayList;
import java.util.List;
import java.util.Deque;
import java.util.ArrayDeque;
class Solution {
public boolean canFinish(int numCourses, int[][] prerequisites) {
// 入度映射向量:索引代表课程 ID,值代表修读该课程所需的先修课剩余总数
int[] inDegree = new int[numCourses];
// 拓扑图的邻接表表示法:索引代表当前课程,List 容纳所有依赖于当前课程的后继课程
List<List<Integer>> adj = new ArrayList<>();
// 物理空间初始化:为每一门课程开辟独立的后继节点容器
for (int i = 0; i < numCourses; i++) {
adj.add(new ArrayList<>());
}
// 有向图结构装配:统计每个顶点的入度,并填充有向边的终点集合
for (int[] cp : prerequisites) {
inDegree[cp[0]]++; // 后继课程入度自增
adj.get(cp[1]).add(cp[0]); // 先修课程的邻接表中注入后继课程
}
// 显式辅助队列:用以动态维护当前全图中所有处于自由状态(入度为 0)的节点集合
Deque<Integer> q = new ArrayDeque<>();
for (int i = 0; i < numCourses; i++) {
if (inDegree[i] == 0) {
q.add(i); // 自由节点作为拓扑起点首批入队
}
}
// 全局合法修读课程计数器
int count = 0;
// 核心拓扑消除循环:若队列不为空,说明图网络中仍有可被解锁的节点
while (!q.isEmpty()) {
count++; // 成功修完一门课程,计数器自增
int cur = q.poll(); // 弹出当前已无限制的课程
// 依赖物理消除:遍历所有依赖于当前已修完课程的后继课程
for (int next : adj.get(cur)) {
inDegree[next]--; // 后继课程的先修条件满足其一,入度减 1
// 临界状态触发:若后继课程的先修限制全部解除,立刻将其激活入队
if (inDegree[next] == 0) {
q.add(next);
}
}
}
// 最终判定:若成功修完的课程总量精确等于图的总顶点数,说明全图无环,返回 true;否则存在死锁环路
return count == numCourses;
}
}
六、 复杂度分析
1. 时间复杂度:O(v + e)
-
分析 :算法包含三个核心时间片。首先,遍历
prerequisites数组构建图拓扑结构,耗时与依赖边数 e 呈严格正比。其次,初始化队列需要线性扫描所有课程顶点,耗时与节点数 v 呈严格正比。最后,在 Kahn 算法的消除扩散期间,图中的每个顶点至多入队出队一次,其对应的邻接表出边也至多被扫描一次,总步数受限于边数 e。 -
结论:总的基本操作步数上限是节点数与边数的线性加和,时间复杂度表示为 O(v + e),在图论拓扑排序中达到了最优线性性能。
2. 空间复杂度:O(v + e)
-
分析 :额外空间的物理消耗主要由三个容器分担。入度数组
inDegree占用 O(v) 空间;邻接表adj内部共存储了 v 个列表,且所有子列表中的元素总量等于有向边的总数 e,占用 O(v + e) 空间;双端队列q在最坏情况下需要容纳所有节点,占用 O(v) 空间。 -
结论:内存空间的动态开销由图的顶点规模与边规模共同决定,全局额外空间复杂度定性为 O(v + e)。