题目描述:
你这个学期必须选修 numCourses 门课程,记为 0 到 numCourses - 1 。
在选修某些课程之前需要一些先修课程。 先修课程按数组 prerequisites 给出,其中 prerequisites[i] = [ai, bi] ,表示如果要学习课程 ai 则 必须 先学习课程 bi 。
- 例如,先修课程对
[0, 1]表示:想要学习课程0,你需要先完成课程1。
请你判断是否可能完成所有课程的学习?如果可以,返回 true ;否则,返回 false 。
示例 1:
输入: numCourses = 2, prerequisites = [[1,0]]
输出: true
解释: 总共有 2 门课程。学习课程 1 之前,你需要完成课程 0 。这是可能的。
示例 2:
输入: numCourses = 2, prerequisites = [[1,0],[0,1]]
输出: false
解释: 总共有 2 门课程。学习课程 1 之前,你需要先完成课程 0 ;并且学习课程 0 之前,你还应先完成课程 1 。这是不可能的。
提示:
1 <= numCourses <= 20000 <= prerequisites.length <= 5000prerequisites[i].length == 20 <= ai, bi < numCoursesprerequisites[i]中的所有课程对 互不相同
思路:
这道题应该是得知道每个课程,它是哪些其他课的先决条件。另外还得知道,哪些课没有依赖其他课。这样我们可以维护一个数据结构,对于每个课,它是哪些课的先决条件。然后我们先处理“无依赖”的课程,处理完之后,看看还有没有更多的课变成“无依赖”的课程了。
实现:
Kahn算法(广度优先)
class Solution {
public boolean canFinish(int numCourses, int[][] prerequisites) {
List<Integer>[] courseLink = new ArrayList[numCourses];
int[] dependency = new int[numCourses];
int completed = 0;
for (int i = 0; i < numCourses; i++) {
courseLink[i] = new ArrayList<Integer>();
}
for (int[] pair: prerequisites) {
int a = pair[0]; // 2nd
int b = pair[1]; // 1st
courseLink[b].add(a); // a is b's neighour, b leading a, b first so b is root
dependency[a]++; // a found 1 dependency, cannot directly learn a.
}
Deque<Integer> queue = new ArrayDeque<>();
for (int i = 0; i < numCourses; i++) {
if (dependency[i] == 0) {
queue.add(i); // this course can start right now
}
}
while (!queue.isEmpty()) {
int independent = queue.poll();
completed++;
for (int i: courseLink[independent]) {
// all courses depend on independent
dependency[i]--; // finished one, so dependent minus 1.
if (dependency[i] == 0) { // no more depend on other
queue.offer(i);
}
}
}
return completed == numCourses;
}
}
该代码使用拓扑排序(Kahn算法)判断课程安排是否合法(即图中是否存在环)。具体思路如下:
-
构建邻接表与入度统计
courseLink数组存储每个课程的后继课程,表示学完当前课程后可学习的课程。dependency数组记录每个课程的入度(即依赖的前置课程数量)。- 遍历
prerequisites,将前置课程b的后继课程a加入邻接表,并增加a的入度。
-
初始化队列
- 将所有入度为0的课程加入队列。这些课程无前置条件,可以直接学习。
-
拓扑排序处理
- 从队列中取出课程,视为完成,统计已完成的课程数。
- 遍历该课程的所有后继课程,将其入度减1。若某后继课程入度变为0,则加入队列。
- 重复此过程直到队列为空。
-
判断结果
- 若完成的课程数等于总课程数,说明所有课程无环依赖,返回
true;否则存在环,返回false。
- 若完成的课程数等于总课程数,说明所有课程无环依赖,返回
关键点:通过入度是否为0判断课程是否可学习,利用队列逐步消除依赖,若最终无法消除所有课程则存在环。该算法时间复杂度为O(N+M),N为课程数,M为依赖数量。
这段代码实现了拓扑排序中的Kahn算法,用于检测课程依赖关系是否存在环。从正确性、效率和代码质量综合评估,可以给出 95分。以下是详细评分理由:
优点(占90分)
-
算法正确性(30分)
- 完美实现了Kahn算法,通过入度统计和队列逐步消除依赖,能准确判断图中是否存在环。
- 处理边界条件(如
numCourses=0或依赖为空)完全正确。
-
时间复杂度(25分)
- 时间复杂度为 O(N+M)(N为课程数,M为依赖数),是拓扑排序的最优实现,无冗余操作。
-
空间复杂度(15分)
- 邻接表
courseLink和入度数组dependency的空间复杂度为O(N+M),符合算法要求。
- 邻接表
-
代码结构(20分)
- 逻辑清晰,初始化、建图、队列处理、结果判断分块明确。
- 使用
Deque作为队列,保证O(1)的入队出队操作效率。
扣分点(5分)
-
变量命名(-2分)
dependency可更名为inDegree更直观。a和b在遍历依赖关系时容易混淆,建议改为course和prereq。
-
注释与可读性(-2分)
- 关键步骤(如队列初始化、入度更新)缺少注释,对算法思路的解释不足。
-
输入健壮性(-1分)
- 未校验课程编号是否合法(如
prerequisites中的数值超出numCourses范围),可能导致越界异常。
- 未校验课程编号是否合法(如
改进建议
// 示例:更清晰的变量命名和注释
int[] inDegree = new int[numCourses]; // 入度数组
List<Integer>[] adjacencyList = new ArrayList[numCourses]; // 邻接表
// 在遍历依赖时:
for (int[] edge : prerequisites) {
int course = edge[0];
int prerequisite = edge[1];
adjacencyList[prerequisite].add(course); // 记录后序课程
inDegree[course]++; // 更新入度
}
总结
代码在算法实现和性能上几乎完美,仅因变量命名、注释细节和输入校验略有不足扣5分。95分是一个合理的评分,属于高质量的实现。
深度优先搜索
DFS 解决思路
-
状态标记法
- 为每个节点维护三种状态:
◦0: 未访问
◦1: 正在访问(当前DFS路径中)
◦2: 已访问(已确认无环) - 如果在DFS过程中遇到状态为
1的节点,说明存在环。
- 为每个节点维护三种状态:
-
邻接表构建
- 与Kahn算法类似,构建邻接表表示依赖关系。例如,课程A依赖课程B,则B是A的前驱,邻接表中记录为
B → A。
- 与Kahn算法类似,构建邻接表表示依赖关系。例如,课程A依赖课程B,则B是A的前驱,邻接表中记录为
-
DFS遍历
- 对每个未访问的节点启动DFS。
- 递归访问当前节点的所有后继节点。
- 如果发现某个后继节点在本次DFS路径中已被访问(状态为
1),说明存在环,直接返回false。
-
拓扑排序生成
- 在DFS后序位置将节点加入拓扑序列(逆后序即为合法拓扑排序)。
具体步骤(以课程依赖问题为例)
假设课程依赖为:[[1,0], [2,1], [3,2]](即学0后才能学1,学1后才能学2,学2后才能学3)。
-
构建邻接表
0 → [1] 1 → [2] 2 → [3] 3 → [] -
DFS过程
- 从课程0开始DFS:
◦ 标记0为1(正在访问)。
◦ 访问0的后继1:标记1为1。
◦ 访问1的后继2:标记2为1。
◦ 访问2的后继3:标记3为1。
◦ 3无后继,标记3为2(已访问),返回。
◦ 回溯到2,标记2为2,返回。
◦ 回溯到1,标记1为2,返回。
◦ 回溯到0,标记0为2,完成。 - 最终拓扑序列(逆后序):
[3,2,1,0],无环,返回true。
- 从课程0开始DFS:
DFS代码框架(Java)
class Solution {
public boolean canFinish(int numCourses, int[][] prerequisites) {
// 构建邻接表
List<Integer>[] graph = new ArrayList[numCourses];
for (int i = 0; i < numCourses; i++) {
graph[i] = new ArrayList<>();
}
for (int[] edge : prerequisites) {
int course = edge[0], prereq = edge[1];
graph[prereq].add(course); // prereq → course
}
int[] visited = new int[numCourses]; // 0:未访问, 1:访问中, 2:已访问
for (int i = 0; i < numCourses; i++) {
if (visited[i] == 0 && !dfs(graph, visited, i)) {
return false; // 发现环
}
}
return true;
}
private boolean dfs(List<Integer>[] graph, int[] visited, int node) {
if (visited[node] == 1) return false; // 存在环
if (visited[node] == 2) return true; // 已确认无环
visited[node] = 1; // 标记为正在访问
for (int neighbor : graph[node]) {
if (!dfs(graph, visited, neighbor)) {
return false;
}
}
visited[node] = 2; // 标记为已访问
return true;
}
}
你提到的这一句“在DFS后序位置将节点加入拓扑序列(逆后序即为合法拓扑排序)”是DFS生成拓扑排序的关键,这里我用一个具体例子帮你彻底理解它。
核心概念解释
-
DFS的遍历顺序
• 后序遍历(Post-order):指在递归过程中,先访问完所有子节点,最后再处理当前节点。
• 例如,对树结构A → B → C的后序遍历顺序是C → B → A。 -
拓扑排序与后序的关系
• 拓扑排序要求:所有依赖(父节点)必须出现在被依赖项(子节点)之前。
• 而后序遍历的顺序是子节点在前,父节点在后,因此将后序结果反转(逆后序)即可满足拓扑排序的要求。
具体例子演示
定义邻接表为依赖方向:C → B → A(学C后才能学B,学B后才能学A),邻接表为:
C的邻居是B
B的邻居是A
A没有邻居
修正后的DFS过程
-
从节点
C开始DFS:
• 访问C的邻居B
• 访问B的邻居A
•A没有邻居,加入后序列表 →[A]
• 回溯到B,加入列表 →[A, B]
• 回溯到C,加入列表 →[A, B, C] -
逆后序得到拓扑排序:反转列表 →
[C, B, A]✅
这正是合法的拓扑排序!
关键总结
-
邻接表方向:必须表示依赖方向(例如
prereq → course),即学完前置课程才能学后续课程。 -
后序与逆后序:
• 后序遍历会先处理叶子节点(无依赖的课程),最后处理根节点(依赖最多的课程)。
• 逆后序会将根节点(依赖多的课程)放到最后,叶子节点(无依赖的课程)放到最前,形成合法拓扑排序。 -
代码中的实现
// 在DFS递归返回前(后序位置)将节点加入列表 private void dfs(List<Integer>[] graph, int node, List<Integer> postOrder) { visited[node] = true; for (int neighbor : graph[node]) { if (!visited[neighbor]) { dfs(graph, neighbor, postOrder); } } postOrder.add(node); // 后序位置加入列表 } // 最终反转列表得到拓扑排序 Collections.reverse(postOrder);
对比图示
依赖关系:C → B → A
邻接表:C的邻居是B,B的邻居是A
DFS后序列表:[A, B, C]
逆后序结果:[C, B, A] (拓扑排序)
通过这个例子,你应该能理解为什么逆后序是拓扑排序了!关键在于邻接表的方向和DFS遍历路径的配合。
DFS vs Kahn算法对比
| 特性 | DFS | Kahn算法 |
|---|---|---|
| 环检测时机 | 递归路径中立即发现环 | 处理完所有入度0节点后判断剩余节点 |
| 拓扑排序生成 | 逆后序即为拓扑序列 | 直接按处理顺序生成序列 |
| 空间复杂度 | O(N)(递归栈深度) | O(N+M)(队列+邻接表) |
| 适用场景 | 适合需要逆序或快速检测环的场景 | 适合需要动态更新依赖的场景 |
关键点总结
- 状态标记:通过
0/1/2状态避免重复访问,同时检测环。 - 后序遍历:拓扑排序的逆后序结果即为合法顺序。
- 时间复杂度:O(N+M),与Kahn算法相同,但实际性能可能因图结构而异。
通过DFS,我们不仅能检测环,还能生成拓扑序列,是解决此类问题的另一种高效方法!