一、例题
这是一道典型的拓扑排序题:
拓扑关系是,只有前导课程学习完毕,才能学习后序课程。
二、拓扑排序
先了解一些必要的概念:
-
图的度
图的度是指图中元素所连接的节点数量。在
有向图
中,分为入度和出度;- 入度:
- 指向当前结点的所有结点数量;
- 出度:
- 当前结点指向的结点数量
如上图,0的入度为0,出度为1
- 入度:
-
有向无环图
- 不管按照怎样的路径,都最终会到达一个终点(不一定是同一个终点),而不会前进一定的步数还能回到前遍历过得节点的图。
- 上图就是一个有向无环图
-
拓扑排序
- 将一个有向无换图按照其内部的逻辑转化为一个线性序列的过程。[逻辑一般是,只有当前节点的
入度节点全部访问
过了,才能访问当前节点] - 如,对于上图,可以得到一个序列:0,1,2,3,4,5
- 将一个有向无换图按照其内部的逻辑转化为一个线性序列的过程。[逻辑一般是,只有当前节点的
-
拓扑排序的流程:
- 首先找到任一个入度为0的节点,访问他;
- 将该节点“删除”,即以他作为入度的节点的入度数减一【可能会使得入度减一的节点入度变为0,他也就可以访问了】
- 继续执行上述步骤
结果可能有两种:
(1)所有节点都访问过了,证明这个图可以进行拓扑排序,且按照访问顺序可以得到一个拓扑排序序列,这个图是个有向无环图
(2)进行到一定程度,没有发现入度为0的节点,因此拓扑排序无法进行下去,证明该图是个有环图。【如,将上图中的2节点指向1,1节点指向0,0节点指向2,这样,无论无核都找不到一个入度为0的节点,拓扑排序失败】
因此拓扑排序能否成功是检验图是否有环的一个好方法
对于课程表问题:
没有先导课程的课就可以视为一个入度为0的节点,而先上完这节课之后,让将他作为先导课的入度-1。再如此反复,若所有节点全空,返回true,否则返回false。
方法已经确定好了,那如何实现
拓扑排序呢?
拓扑排序的实现与BFS极其类似:先入队入度为0的节点,对队列中节点进行遍历,并将入队其出度节点的入度减一;
从剩余的入度为0的节点中取出一个(或者全部),再进行上述步骤
一趟循环访问度为0还是一个度为0取决于算法实现【这点和二叉树的两种层序遍历方法有点类似】
-
数据结构:
- 入度表:需要一个记录所有入度信息的序列,因为节点数量(即课程数是确定的),可以通过一维数组实现【下标为课程号,值为入度】
- 邻接表:需要记录将当前节点作为入度的节点列表,可以使用hash表实现:key使用当前节点下标,val使用他出度的节点们下标组成的数组。
-
结束条件,使用count记录遍历过的节点数,若 == num,返回true,否则为false
- 或者,在while循环结束后,遍历入度表,发现还有入度为0的节点,即为拓扑失败。
三、代码实现
static class Solution {
public boolean canFinish(int numCourses, int[][] prerequisites) {
//入度表,记录当前节点的入度值
int[] inDgree = new int[numCourses]; //默认数组的初始值为全0
//邻接表,记录当前节点的所有出度节点
Map<Integer, List<Integer>> adj = new HashMap<>();
//遍历前置课程信息表,记录入度表和邻接表
for (int i = 0; i < prerequisites.length; i++) {
int pre = prerequisites[i][1]; //当前前置课程号
int post = prerequisites[i][0]; //当前后置课程号 后置会在前置上完后才上
if (adj.containsKey(pre)) {
List<Integer> list = adj.get(pre);
list.add(post); //保存其一个出度节点
adj.put(pre, list);
} else {
List<Integer> list = new ArrayList<>(){{add(post);}};
adj.put(pre, list);
}
inDgree[post]++; //当前后置节点有前置节点,他的入度加一。
}
System.out.println(Arrays.toString(inDgree));
System.out.println(adj);
Queue<Integer> queue = new LinkedList<>();
//queue的初始化,选择一次遍历将所有入度为0的节点存入
for (int i = 0; i < numCourses; i++) {
if (inDgree[i] == 0) queue.offer(i);
}
int count = 0; //最终结果判定
//BFS经典条件
while (!queue.isEmpty()) {
int p = queue.poll(); //遍历一个节点
count++;
//遍历其所有后置节点,将其入度减一,若因此降为0.将其加入到队列中
List<Integer> posts = adj.get(p);
if (posts == null) continue; //存在那种只有没有入度也没有出度的节点【比如说排序中最后一个节点就是】
for (int post: posts) {
inDgree[post]--;
if (inDgree[post] == 0) {
queue.offer(post);
}
}
}
return count == numCourses; //根据有无遍历最大课程数作为返回
}
}
四、拓扑排序总结
- 构建入度列表,节点与出度节点关联的邻接表;
- 初始化队列,将0入度的节点入队,进行遍历,并将其出度节点的入度减一,若为0,入队;
- 重复执行,直到队列为空;
- 根据遍历的节点数量与入度列表的入度是否全部为0查看拓扑排序成功与否。
课后练习:
一个完整的软件项目往往会包含很多由代码和文档组成的源文件。编译器在编译整个项目的时候,可能需要按照依赖关系来依次编译每个源文件。比如,A.cpp 依赖 B.cpp,那么在编译的时候, 编译器需要先编译 B.cpp,才能再编译 A.cpp。 假设现有 0,1,2,3 四个文件,0号文件依赖1号文件,1号文件依赖2号文件,3号文件依赖1号文件,则源文件的编译顺序为 2,1,0,3 或 2,1,3,0。
\ 现给出文件依赖关系,如 1,2,-1,1,表示0号文件依赖1号文件,1号文件依赖2号文件,2号文件没有依赖,
\ 3号文件依赖1号文件。请补充完整程序,返回正确的编译顺序。注意如有同时可以编译多个文件的情况,
\ 按数字升序返回一种情况即可,比如前述案例输出为:2,1,0,3
输入例子1:
“1,2,-1,1”
输出例子1:
“2,1,0,3”
参考答案:
public String compileSeq (String input) {
input = input.replace(" ", ""); //去除所有空格
int[] nums = Arrays.stream(input.split(",")).mapToInt(Integer:: parseInt).toArray();
int num = nums.length;
int[] inDgree = new int[num];
Map<Integer, List<Integer>> adj = new HashMap<>();
for (int i = 0; i < num; i++) {
int idx; //s.charAt(i)表示: i文件的前置是idx
if ((idx = nums[i]) > 0) {
//下标对应的指向为正数,证明是存在前置节点的
inDgree[i]++; //存在前置,因此入度加一
//根据前置、后置关系更新邻接表
if (adj.containsKey(idx)) {
List<Integer> list = adj.get(idx);
list.add(i);
adj.put(idx, list);
} else {
List<Integer> list = new ArrayList<>();
list.add(i);
adj.put(idx, list);
}
}
}
System.out.println(Arrays.toString(inDgree));
System.out.println(adj);
Queue<Integer> queue = new LinkedList<>();
StringBuilder sb = new StringBuilder();
//将所有入度为零的节点加入队列
for (int i = 0; i < num; i++) {
if (inDgree[i] == 0) {
queue.offer(i);
//加入这些为0的为首先编译的对象
sb.append(i);
}
}
// int count = 0;
while (!queue.isEmpty()) {
int p = queue.poll();
//本题的逻辑是找到序列, 无需做额外的遍历,只需要按顺序收集那些入度为零的节点即可
List<Integer> posts = adj.get(p);
if (posts == null) continue;
for (int post: posts) {
inDgree[post]--;
if (inDgree[post] == 0) {
queue.offer(post);
//时刻记得本题的目标
sb.append(post);
}
}
}
return sb.toString();
}
是不是感觉一个磨子刻出来的。。。毕竟出自一人之手。
queue.offer(post);
//时刻记得本题的目标
sb.append(post);
}
}
}
return sb.toString();
}
是不是感觉一个磨子刻出来的。。。毕竟出自一人之手。
本文参考至[笨猪爆破组](https://leetcode-cn.com/problems/course-schedule/solution/bao-mu-shi-ti-jie-shou-ba-shou-da-tong-tuo-bu-pai-/)大佬的题解。这位大佬的题解都很浅显易懂,而且总结解题套路,欢迎大家前去访问