引言
课程表,也就是判断是否能够完成所有课程的选修。题目给出的条件是需要先修某些课程,这可能形成依赖关系,如果有循环依赖的话,就无法完成所有课程。我的思路是用拓扑排序来判断是否存在环,但具体怎么实现代码呢?我之前学过一点拓扑排序的知识,记得有BFS和DFS两种方法,现在需要详细思考如何用这两种方法来写代码。
首先,问题分析:课程之间的依赖关系构成有向图,比如课程A依赖课程B,那么B必须先于A学习。如果有环的话,比如A依赖B,B依赖C,C又依赖A,这样就无法完成所有课程。所以问题的核心就是检测这个有向图是否存在环。拓扑排序能够处理这个问题,因为拓扑排序的前提是有向无环图(DAG),如果能完成拓扑排序,则说明没有环,反之则有环。
接下来,我需要考虑两种方法:BFS(基于入度)和DFS(检测路径中的环)。
先考虑BFS方法。BFS的思路是每次选择入度为0的节点,将其加入拓扑序列,然后减少其邻接节点的入度,直到没有入度为0的节点。如果最终处理的节点数等于总节点数,说明无环,否则有环。
以下是图论中拓扑排序相关算法的前置知识详解,包括基础概念、术语定义和具体应用:
预备知识点
一、图的基本概念
1. 无向图(Undirected Graph)
- 定义:边没有方向的图,边表示两个节点的双向关系。
- 示例:社交网络中的好友关系(A和B是好友,边无方向)。
- 度的计算:每个节点的度(Degree)是与其相连的边的数量。
2. 有向图(Directed Graph)
- 定义:边有方向的图,边从源节点指向目标节点。
- 示例:课程依赖关系(A→B表示必须先修A才能修B)。
- 度的分类:
- 入度(In-Degree):指向该节点的边的数量(如课程B的入度为2,表示有2门先修课)。
- 出度(Out-Degree):从该节点出发的边的数量(如课程A的出度为3,表示它是3门课的先修课)。
二、图的存储方式
1. 邻接矩阵(Adjacency Matrix)
- 定义:使用二维数组
matrix[N][N]
存储节点间的关系。matrix[i][j] = 1
表示节点i到节点j有一条边(有向图中可能不对称)。
- 特点:
- 优点:快速判断两节点是否相邻(时间复杂度 O(1))。
- 缺点:空间复杂度为 O(N²),对稀疏图(边数远小于 N²)浪费空间。
- 适用场景:稠密图(边数接近 N²)。
2. 邻接表(Adjacency List)
- 定义:使用数组的数组(或链表)存储每个节点的邻居。
- 例如:
adj[i]
存储节点i的所有直接邻居。
- 例如:
- 特点:
- 优点:空间复杂度 O(N + E),适合稀疏图。
- 缺点:判断两节点是否相邻需要遍历列表(时间复杂度 O(k),k为邻居数)。
- 适用场景:绝大多数图问题(尤其是边数较少时)。
三、拓扑排序(Topological Sorting)
1. 核心目标
- 问题场景:对有向无环图(DAG)的节点排序,使得每个节点在排序中位于其所有后继节点之前。
- 应用:课程选修顺序、任务调度、依赖解析等。
2. 关键性质
- 有环图无法拓扑排序:如果图中存在环,无法找到满足条件的顺序(如课程依赖成环时无法完成学习)。
四、拓扑排序的两种实现
方法1:BFS(Kahn算法,基于入度)
- 前置知识:
- 入度的意义:入度为0的节点没有先修条件,可直接处理。
- 队列的作用:按顺序处理无依赖的节点。
- 算法步骤:
- 构建邻接表和入度数组:
for (依赖关系 [a→b]) { adj[a].push_back(b); // a是b的先修课 inDegree[b]++; // b的入度+1 }
- 初始化队列:将所有入度为0的节点加入队列。
- BFS处理:
- 取出节点u,将其加入拓扑序列。
- 遍历u的所有邻居v,减少v的入度;若v入度为0,加入队列。
- 检测环:最终处理的节点数是否等于总节点数。
- 构建邻接表和入度数组:
- 时间复杂度:O(N + E),每个节点和边各处理一次。
方法2:DFS(基于路径检测)
- 前置知识:
- DFS的递归栈:用于跟踪当前路径上的节点。
- 状态标记:
- 0-未访问:节点未被处理。
- 1-访问中:节点在递归栈中,表示正在处理的路径。
- 2-已访问:节点已处理完毕。
- 算法步骤:
- 构建邻接表(同上)。
- 遍历所有未访问节点,对每个节点启动DFS。
- DFS递归过程:
- 若当前节点标记为“访问中”,说明存在环。
- 若标记为“已访问”,直接返回。
- 标记当前节点为“访问中”,递归处理所有邻居。
- 完成后标记为“已访问”。
- 时间复杂度:O(N + E),每个节点和边各访问一次。
五、关键问题与解答
1. 为什么拓扑排序只能用于有向无环图(DAG)?
- 答案:若存在环,环中节点的入度无法降为0,无法被处理,导致排序失败。
2. 邻接表和邻接矩阵如何选择?
- 答案:
- 邻接表:适合稀疏图(如课程表问题,每个课程的先修课通常较少)。
- 邻接矩阵:适合需要频繁查询两节点是否相邻的稠密图。
3. 如何检测环?
- BFS方法:最终处理的节点数 < 总节点数 → 存在环。
- DFS方法:递归过程中遇到“访问中”状态的节点 → 存在环。
六、总结
理解图的基本概念(有向/无向图、入度/出度)和存储方式(邻接表/矩阵)是掌握拓扑排序的基础。BFS和DFS方法分别通过入度管理和路径检测来解决环的存在性问题,两种方法均高效且易于实现。实际应用中,邻接表+BFS的组合在大多数场景下更为常见。
课程表
- 🎈 题目链接:
- 🎈 做题状态:写不出来
以下是两种解决方法的C++代码实现:
方法一:BFS(拓扑排序,基于入度)
class Solution {
public:
bool canFinish(int numCourses, vector<vector<int>>& prerequisites) {
// 使用BFS拓扑排序方法检测课程依赖图中是否有环
// 如果拓扑排序能够包含所有课程(count == numCourses),则返回true
// 否则说明存在环,返回false
// 1. 构建邻接表(图的表示)
// adj[i] 存储的是课程i的所有直接后续课程
// 例如:adj[1] = {2,3} 表示修完课程1后可以修课程2和3
vector<vector<int>> adj(numCourses);
// 2. 入度数组(记录每门课程的先修课程数量)
// inDegree[i] 表示课程i的先修课程数量
// 例如:inDegree[2] = 1 表示课程2有1门先修课程
vector<int> inDegree(numCourses, 0);
// 3. 构建邻接表和入度数组
// 遍历所有先决条件,建立课程之间的依赖关系
for (auto& p : prerequisites) {
int course = p[0]; // 当前课程
int preCourse = p[1]; // 必须先修的课程
// 将先修课程指向当前课程(表示必须先修preCourse才能修course)
adj[preCourse].push_back(course);
// 当前课程的入度加1(因为它多了一门先修课程)
inDegree[course]++;
}
// 4. 初始化队列(用于BFS拓扑排序)
// 队列中存储的是当前可以学习的课程(入度为0的课程)
queue<int> q;
// 将所有入度为0的课程加入队列
// 这些课程没有先修要求,可以直接学习
for (int i = 0; i < numCourses; ++i) {
if (inDegree[i] == 0) {
q.push(i);
}
}
// 5. 开始BFS拓扑排序
int count = 0; // 记录已经学习完成的课程数量
while (!q.empty()) {
// 取出队列中的课程(当前可以学习的课程)
int current = q.front();
q.pop();
// 课程学习完成,计数加1
count++;
// 处理当前课程的所有后续课程
// 因为current课程已经学完,所以它的后续课程的先修要求减少
for (int nextCourse : adj[current]) {
// 减少后续课程的入度(相当于减少一门先修要求)
// 如果入度减为0,表示这门课程的所有先修课程都已经完成
if (--inDegree[nextCourse] == 0) {
// 将这门课程加入队列,准备学习
q.push(nextCourse);
}
}
}
// 6. 检查是否所有课程都学习完成
// 如果count等于课程总数,说明拓扑排序成功,没有环
// 否则说明存在环(有些课程的入度始终无法降为0)
return count == numCourses;
}
};
方法二:DFS(检测路径中的环)
class Solution {
public:
bool canFinish(int numCourses, vector<vector<int>>& prerequisites) {
// 使用DFS方法检测课程依赖图中是否有环
// 如果有环则无法完成所有课程,返回false;否则返回true
// 1. 构建邻接表(图的表示)
// adj[i] 存储的是课程i的所有直接后续课程
// 例如:adj[1] = {2,3} 表示修完课程1后可以修课程2和3
vector<vector<int>> adj(numCourses);
// 2. 访问状态数组
// visited[i] 的值表示课程i的访问状态:
// 0: 未被访问过(初始状态)
// 1: 正在访问中(当前DFS路径中)
// 2: 已完全访问过(包括所有后续课程)
vector<int> visited(numCourses, 0);
// 3. 构建邻接表
// 遍历所有先决条件,建立课程之间的依赖关系
for (auto& p : prerequisites) {
int course = p[0]; // 当前课程
int preCourse = p[1]; // 必须先修的课程
// 将先修课程指向当前课程(表示必须先修preCourse才能修course)
adj[preCourse].push_back(course);
}
// 4. 对每个课程进行DFS检查
// 因为图可能是不连通的(有多个独立的子图),所以需要检查每个课程
for (int i = 0; i < numCourses; ++i) {
if (visited[i] == 0) { // 只检查未访问过的课程
if (!dfs(adj, visited, i)) {
// 如果DFS发现环,立即返回false
return false;
}
}
}
// 5. 所有课程都检查完毕且没有发现环
return true;
}
private:
bool dfs(vector<vector<int>>& adj, vector<int>& visited, int current) {
// DFS辅助函数,用于检测从current课程开始的路径中是否有环
// 1. 如果当前课程的状态是"正在访问中"
// 说明我们在当前DFS路径中又回到了这个课程,形成了环
if (visited[current] == 1) return false;
// 2. 如果当前课程的状态是"已完全访问过"
// 说明这个课程的所有后续课程都已经检查过了,无需重复处理
if (visited[current] == 2) return true;
// 3. 将当前课程标记为"正在访问中"
// 表示我们开始处理这个课程及其后续课程
visited[current] = 1;
// 4. 递归检查所有后续课程
for (int nextCourse : adj[current]) {
if (!dfs(adj, visited, nextCourse)) {
// 如果在后续课程中发现环,立即返回false
return false;
}
}
// 5. 当前课程的所有后续课程都检查完毕,没有发现环
// 将当前课程标记为"已完全访问过"
visited[current] = 2;
// 6. 返回true表示从当前课程开始的路径没有环
return true;
}
};
方法解释
-
BFS方法:
- 邻接表:记录每个课程的后继课程(即依赖它的后续课程)。
- 入度数组:记录每个课程的先修课程数量。
- 队列初始化:将入度为0的课程加入队列(这些课程可以直接学习)。
- 拓扑排序:依次处理队列中的课程,减少其后续课程的入度,若入度变为0则加入队列。
- 结果判断:最终处理的课程数等于总课程数时,说明无环。
-
DFS方法:
- 邻接表:同上,记录后续课程。
- 状态标记:
visited
数组标记课程状态(未访问、访问中、已访问)。 - DFS检测环:若在递归过程中遇到状态为“访问中”的节点,说明存在环。
- 回溯标记:完成当前节点的所有邻接节点后,标记为“已访问”避免重复处理。
两种方法均能高效检测图中是否存在环,时间复杂度为O(N + E)(N为课程数,E为依赖数)。BFS更直观,而DFS可以更早地发现某些环。