一、问题描述:
你这个学期必须选修 numCourses 门课程,记为 0 到 numCourses - 1 。
在选修某些课程之前需要一些先修课程。 先修课程按数组 prerequisites 给出,其中 prerequisites[i] = [ai, bi] ,表示如果要学习课程 ai 则 必须 先学习课程 bi 。
例如,先修课程对 [0, 1] 表示:想要学习课程 0 ,你需要先完成课程 1 。
请你判断是否可能完成所有课程的学习?如果可以,返回 true ;否则,返回 false 。
二、拓扑排序是什么?
本题属于经典的拓扑排序问题:拓扑排序是一种对有向图的顶点进行排序的算法,使得图中任意一条边的终点在排序后的序列中都出现在起点之前。换句话说,拓扑排序可以将有向无环图(DAG)中的顶点按照依赖关系进行线性排序。
在拓扑排序中,如果存在一条有向边(u, v),则顶点u必须排在顶点v的前面。拓扑排序的结果并不唯一,一个有向图可能有多个拓扑排序序列。拓扑排序通常用于解决任务调度、依赖关系分析等问题,例如课程学习的先修关系、软件构建中模块的编译顺序等。
解题思路:如果有向图中存在环路,则无法进行拓扑排序,因为无法确定环路中节点的相对顺序!!!所以本题我们要找的就是是否存在环路。拓扑排序算法可以使用深度优先搜索(DFS)或广度优先搜索(BFS)来实现。
三、深度优先搜索(DFS):
- 首先,根据给定的先修关系构建有向图,图的节点表示课程,图的边表示先修关系。
使用DFS遍历图的每个节点,对于每个节点,首先标记为正在访问中(visited[u] = 1),然后递归地访问该节点的所有邻接节点。
在DFS的过程中,如果发现当前节点的邻接节点已经被访问过且仍处于正在访问中的状态(visited[v] == 1),则说明存在环路,将valid标记为false,并立即返回。
如果DFS结束后未发现环路,则将当前节点标记为已访问(visited[u] = 2)。 - 代码示例:
class Solution {
List<List<Integer>> edges; // 邻接表,用于存储有向图的边关系
boolean valid = true; // 用于标记图中是否存在环路,默认为true,表示不存在环路
int[] visited; // 记录节点的访问状态,0表示未访问,1表示正在访问中,2表示已访问
public boolean canFinish(int numCourses, int[][] prerequisites) {
// 初始化邻接表和访问状态数组
edges = new ArrayList<List<Integer>>();
for(int i = 0; i < numCourses; i++){
edges.add(new ArrayList<Integer>());
}
visited = new int[numCourses];
// 构建有向图的邻接表表示
for (int[] info : prerequisites) {
edges.get(info[1]).add(info[0]); // info[1]表示后续课程,info[0]表示先修课程,将先修课程作为后续课程的邻接节点
}
// 从每个节点开始进行深度优先搜索
for (int i = 0; i < numCourses && valid; ++i) {
if (visited[i] == 0) {
dfs(i); // 对未访问过的节点进行DFS
}
}
return valid; // 返回结果,valid为true表示不存在环路,可以完成课程学习
}
// 深度优先搜索函数
public void dfs(int u){
visited[u] = 1; // 将当前节点标记为正在访问中
for(int v : edges.get(u)){ // 遍历当前节点的邻接节点
if(visited[v] == 0){ // 如果邻接节点未被访问过,则递归访问该节点
dfs(v);
if(!valid) return; // 如果在递归过程中发现环路,则直接返回
}
else if(visited[v] == 1){ // 如果邻接节点已经处于正在访问中的状态,则说明存在环路
valid = false; // 将valid标记为false
return;
}
}
visited[u] = 2; // 将当前节点标记为已访问
}
}
- 时间复杂度分析:该算法的时间复杂度取决于DFS的执行次数以及构建图的过程。假设图有 n 个节点和 m 条边,则构建图的时间复杂度为 O(m),DFS的时间复杂度为 O(n+m)。因此,总的时间复杂度为 O(n+m)。
四、广度优先搜索(BFS):
- 构建有向图: 使用邻接表来表示有向图的边关系,并统计每个节点的入度。
拓扑排序: 将入度为0的节点加入队列,然后不断从队列中取出节点,并将其邻接节点的入度减1。如果邻接节点的入度减为0,则将其加 入队列。
判断是否存在环路: 如果最终访问过的节点数等于课程数目,则说明图中不存在环路,可以完成课程学习;否则,存在环路,无法完成学习。 - 代码示例:
class Solution {
List<List<Integer>> edges; // 邻接表,用于存储有向图的边关系
int[] indeg; // 记录每个节点的入度
int n; // 课程数目
public boolean canFinish(int numCourses, int[][] prerequisites) {
n = numCourses;
edges = new ArrayList<List<Integer>>();
for (int i = 0; i < n; ++i) {
edges.add(new ArrayList<Integer>());
}
indeg = new int[n];
// 构建有向图的邻接表表示和入度数组
for (int[] info : prerequisites) {
edges.get(info[1]).add(info[0]); // info[1]表示后续课程,info[0]表示先修课程,将先修课程作为后续课程的邻接节点
++indeg[info[0]]; // 更新后续课程的入度
}
// 将入度为0的节点加入队列
Queue<Integer> q = new LinkedList<Integer>();
for (int i = 0; i < n; ++i) {
if (indeg[i] == 0) {
q.offer(i);
}
}
int visited = 0; // 记录访问过的节点数
// BFS遍历图
while (!q.isEmpty()) {
++visited;
int u = q.poll();
for (int v : edges.get(u)) {
--indeg[v]; // 将当前节点的邻接节点的入度减1
if (indeg[v] == 0) {
q.offer(v); // 如果邻接节点的入度为0,则加入队列
}
}
}
return visited == n; // 如果访问过的节点数等于课程数目,则说明不存在环路,可以完成课程学习
}
}
- 时间复杂度分析:构建有向图: 遍历所有的先修课程关系,时间复杂度为O(m),其中m表示边的数量。
拓扑排序: 每个节点入队和出队各一次,以及每条边被访问一次,时间复杂度为O(m + n),其中n表示节点的数量。
总体时间复杂度为O(m + n)。