【LeetCode 热题 100】207. 课程表——DFS+三色标记
Problem: 207. 课程表
文章目录
- 整体思路
- 完整代码
- 时空复杂度
- 时间复杂度:O(V + E)
- 空间复杂度:O(V + E)
整体思路
这段代码的目的是判断给定的课程和它们的先修课程要求是否能够构成一个可行的学习计划。这个问题在图论中等价于 检测一个有向图中是否存在环 (Cycle Detection in a Directed Graph)。如果存在环(例如,A->B->C->A),则意味着存在循环依赖,课程计划无法完成。
该算法采用了经典的 深度优先搜索(DFS) 配合 三色标记法 来高效地检测环。
-
构建图(邻接表):
- 首先,代码将课程和先修关系转换成一个图的数据结构。课程是图的节点(Vertex),先修关系是图的有向边(Edge)。
List<Integer>[] g
是一个邻接表,g[i]
存储了所有以课程i
为先修课的后续课程列表。例如,如果[c1, c0]
是一个先修关系(要上c1
必须先上c0
),则会添加一条从c0
到c1
的有向边,即g[c0].add(c1)
。
-
三色标记法:
- 为了在DFS中检测环,算法使用了一个
colors
数组来标记每个节点(课程)的状态。每个节点可以有三种颜色:- 0 (白色):表示该节点尚未被访问。
- 1 (灰色):表示该节点正在被访问。即它在当前的DFS递归调用栈中。
- 2 (黑色):表示该节点及其所有后代节点都已经被访问完毕,并且从该节点出发没有发现环。
- 为了在DFS中检测环,算法使用了一个
-
深度优先搜索 (DFS) 检测环:
canFinish
方法是主驱动程序。它遍历所有课程,如果遇到一个尚未被访问过的节点(白色),就以它为起点开始一次DFS。这样做是为了确保图中所有不连通的部分都被检查到。dfs(x, ...)
是核心的递归函数。当它访问一个节点x
时:
a. 首先将x
标记为灰色 (colors[x] = 1
),表示它进入了当前的递归路径。
b. 然后遍历x
的所有邻居节点y
(即所有以x
为先修课的课程)。
c. 对于每个邻居y
:- 发现环:如果
y
的颜色是灰色 (colors[y] == 1
),这意味着我们从x
走到了一个已经在当前递归路径上的节点y
。这形成了一个反向边 (Back Edge),证明了图中存在一个环。此时,DFS函数立即返回true
(表示“找到了环”)。 - 继续探索:如果
y
是白色 (colors[y] == 0
),则对y
进行递归调用dfs(y, ...)
。如果这个递归调用返回true
,说明在更深的路径中发现了环,需要将这个“找到环”的结果向上传递,所以也返回true
。
- 发现环:如果
- 如果遍历完
x
的所有邻居都没有返回true
,说明从x
出发的所有路径都是安全的(没有环)。此时,将x
标记为黑色 (colors[x] = 2
),表示该节点已经安全探索完毕,并返回false
(表示“没有找到环”)。
-
最终结果:
canFinish
方法根据dfs
的返回值来判断。如果任何一次dfs
调用返回了true
(找到了环),canFinish
就立刻返回false
(课程无法完成)。- 如果主循环正常结束,意味着对所有节点的DFS都没有发现环,那么
canFinish
返回true
(课程可以完成)。
完整代码
class Solution {/*** 判断给定的课程和先修关系是否能完成所有课程。* @param numCourses 课程总数* @param prerequisites 先修关系数组,[course, prerequisite] 表示要上 course 必须先上 prerequisite* @return 如果可以完成所有课程则返回 true,否则返回 false*/public boolean canFinish(int numCourses, int[][] prerequisites) {// 步骤 1: 构建邻接表来表示课程依赖图// g[i] 存储了所有以课程 i 为先修课的后续课程列表List<Integer>[] g = new ArrayList[numCourses];Arrays.setAll(g, i -> new ArrayList<>()); // 初始化邻接表中的每个列表for (int[] p : prerequisites) {// p[1] -> p[0] 是一条有向边g[p[1]].add(p[0]);}// 步骤 2: 初始化颜色数组用于三色标记法// 0: white (未访问), 1: gray (访问中), 2: black (已访问完毕)int[] colors = new int[numCourses];// 步骤 3: 遍历所有课程节点// 必须遍历所有节点,以防图是不连通的for (int i = 0; i < numCourses; i++) {// 如果节点是白色的(未访问过),则从该节点开始进行深度优先搜索// dfs返回true表示“找到了环”if (colors[i] == 0 && dfs(i, g, colors)) {// 只要找到一个环,就说明课程无法完成,直接返回 falsereturn false;}}// 如果所有节点的DFS都没有发现环,说明课程可以完成return true;}/*** 深度优先搜索辅助函数,用于检测从节点 x 出发是否存在环。* @param x 当前访问的节点* @param g 邻接表* @param colors 颜色标记数组* @return 如果从 x 出发检测到环,则返回 true;否则返回 false。*/private boolean dfs(int x, List<Integer>[] g, int[] colors) {// 将当前节点 x 标记为灰色(访问中),表示它进入了当前递归路径colors[x] = 1;// 遍历 x 的所有邻居节点 yfor (int y : g[x]) {// 如果邻居 y 是灰色,说明我们遇到了一个反向边,即找到了一个环if (colors[y] == 1) {return true; // 发现环,返回 true}// 如果邻居 y 是白色(未访问),则对其进行递归DFS// 如果深层DFS返回true(发现了环),则将结果向上传递if (colors[y] == 0 && dfs(y, g, colors)) {return true; // 发现环,返回 true}// 如果邻居y是黑色,说明它已被安全探索过,无需处理。}// 如果从 x 出发的所有路径都探索完毕且没有发现环,// 则将 x 标记为黑色(已访问完毕)colors[x] = 2;// 从 x 出发没有发现环return false;}
}
时空复杂度
时间复杂度:O(V + E)
- 图的构建:构建邻接表需要遍历所有的先修关系
prerequisites
一次。如果先修关系的数量为E
,则这部分的时间复杂度是 O(E)。 - DFS遍历:
canFinish
方法中的主循环会尝试对每个顶点i
调用dfs
,但由于有colors[i] == 0
的判断,每个顶点作为DFS的起点最多只会被访问一次。- 在整个DFS过程中,每个顶点(Vertex)被访问一次(颜色从0变为1,再变为2),每条边(Edge)被遍历一次(在
for (int y : g[x])
循环中)。 - 因此,所有DFS调用的总时间复杂度与图的顶点数
V
和边数E
之和成正比,即 O(V + E)。
综合分析:
总时间复杂度 = 图构建时间 + DFS时间 = O(E) + O(V + E) = O(V + E)。
其中 V
是 numCourses
,E
是 prerequisites.length
。
空间复杂度:O(V + E)
- 邻接表 (
g
):这是主要的存储开销。邻接表需要存储所有的顶点和边。它包含一个大小为V
的数组,并且所有列表中元素的总数等于E
。因此,邻接表的空间复杂度是 O(V + E)。 - 颜色数组 (
colors
):需要一个大小为V
的数组来存储每个顶点的颜色状态。空间复杂度为 O(V)。 - 递归调用栈:在DFS过程中,递归的深度在最坏的情况下(例如一个长链条状的图)可以达到
V
。因此,递归调用栈所需的空间复杂度是 O(V)。
综合分析:
总的空间复杂度由以上几部分相加决定:O(V + E) + O(V) + O(V)。在 Big O 表示法中,我们取最高阶项,所以最终的空间复杂度是 O(V + E)。
参考灵神