拓扑排序介绍
https://oi-wiki.org/graph/topo/
本文主要学习拓扑排序相关知识。
拓扑排序的英文名是 Topological sorting。
拓扑排序要解决的问题是给一个 有向无环图 的所有节点排序。
我们可以拿大学每学期排课的例子来描述这个过程,比如学习大学课程中有:程序设计,算法语言,高等数学,离散数学,编译技术,普通物理,数据结构,数据库系统等。按照例子中的排课,当我们想要学习 数据结构 的时候,就必须先学会 离散数学 和 编译技术。当然还有一个更加前的课程 算法语言。这些课程就相当于几个顶点 u, 顶点之间的有向边 (u,v) 就相当于学习课程的顺序。教务处安排这些课程,使得在逻辑关系符合的情况下排出课表,就是拓扑排序的过程。
但是如果某一天排课的老师打瞌睡了,说想要学习 数据结构,还得先学 操作系统,而 操作系统 的前置课程又是 数据结构,那么到底应该先学哪一个(不考虑同时学习的情况)?在这里,数据结构 和 操作系统 间就出现了一个环,显然你现在没办法弄清楚你需要学什么了,于是你也没办法进行拓扑排序了。因为如果有向图中存在环路,那么我们就没办法进行 拓扑排序。
因此我们可以说 在一个 DAG(有向无环图) 中,我们将图中的顶点以线性方式进行排序,使得对于任何的顶点 u 到 v 的有向边 (u,v), 都可以有 u 在 v 的前面。
还有给定一个 DAG,如果从 i 到 j 有边,则认为 j 依赖于 i。如果 i 到 j 有路径(i 可达 j),则称 j 间接依赖于 i。
拓扑排序的目标是将所有节点排序,使得排在前面的节点不能依赖于排在后面的节点。
由于有环的图没有拓扑序列,因此拓扑排序还可以判断图中是否有环。
如何构造拓扑排序(⭐重要!)
- 从图中选择一个入度为零的点。
- 输出该顶点,从图中删除此顶点及其所有的出边。
重复上面两步,直到所有顶点都输出,拓扑排序完成,或者图中不存在入度为零的点,此时说明图是有环图,拓扑排序无法完成,陷入死锁。
例题:848. 有向图的拓扑序列
BFS 写法构造拓扑排序
思路按照:
- 从图中选择一个入度为零的点。
- 输出该顶点,从图中删除此顶点及其所有的出边。
重复上面两步,直到所有顶点都输出,拓扑排序完成,或者图中不存在入度为零的点,此时说明图是有环图,拓扑排序无法完成,陷入死锁。
import java.util.*;
public class Main {
static List<Integer>[] g; // 用来建图
static int[] in; // 存储各个节点的入度
static int n, m;
public static void main(String[] args){
Scanner scanner = new Scanner(System.in);
n = scanner.nextInt();
m = scanner.nextInt();
g = new ArrayList[n + 1];
in = new int[n + 1];
Arrays.setAll(g, e -> new ArrayList<Integer>());
for (int i = 0; i < m; ++i) {
int a = scanner.nextInt(), b = scanner.nextInt();
g[a].add(b);
in[b]++; // b 的入度 + 1
}
bfs();
}
static void bfs() {
Queue<Integer> q = new LinkedList<>();
boolean[] st = new boolean[n + 1];
// 将所有初始入度 = 0 的节点放入队列
for (int i = 1; i <= n; ++i) {
if (in[i] == 0) {
q.offer(i);
st[i] = true;
}
}
List<Integer> res = new ArrayList<>();
while (!q.isEmpty()) {
int x = q.poll();
res.add(x);
for (int y: g[x]) { // 将 x 的子节点 y 的入度 - 1
in[y]--;
if (!st[y] && in[y] == 0) {
st[y] = true;
q.offer(y);
}
}
}
if (res.size() == n) { // 如果都放进序列了,说明存在拓扑序列
for (int v: res) System.out.print(v + " ");
} else System.out.println("-1");;
}
}
这道题是有向无环图,因此是可以不使用 st 数组存储节点是否已经被访问过。修改后的代码如下:
import java.util.*;
public class Main {
static List<Integer>[] g; // 用来建图
static int[] in; // 存储各个节点的入度
static int n, m;
public static void main(String[] args){
Scanner scanner = new Scanner(System.in);
n = scanner.nextInt();
m = scanner.nextInt();
g = new ArrayList[n + 1];
in = new int[n + 1];
Arrays.setAll(g, e -> new ArrayList<Integer>());
for (int i = 0; i < m; ++i) {
int a = scanner.nextInt(), b = scanner.nextInt();
g[a].add(b);
in[b]++; // b 的入度 + 1
}
bfs();
}
static void bfs() {
Queue<Integer> q = new LinkedList<>();
// 将所有初始入度 = 0 的节点放入队列
for (int i = 1; i <= n; ++i) {
if (in[i] == 0) q.offer(i);
}
List<Integer> res = new ArrayList<>();
while (!q.isEmpty()) {
int x = q.poll();
res.add(x);
for (int y: g[x]) { // 将 x 的子节点 y 的入度 - 1
if (--in[y] == 0) q.offer(y);
}
}
if (res.size() == n) { // 如果都放进序列了,说明存在拓扑序列
for (int v: res) System.out.print(v + " ");
} else System.out.println("-1");;
}
}
相关题目练习
207. 课程表(判断是否存在拓扑序列)
https://leetcode.cn/problems/course-schedule/
bfs 写法
class Solution {
List<Integer>[] g;
int[] in;
public boolean canFinish(int numCourses, int[][] prerequisites) {
g = new ArrayList[numCourses];
Arrays.setAll(g, e -> new ArrayList<Integer>());
in = new int[numCourses];
for (int[] prerequisity: prerequisites) {
g[prerequisity[0]].add(prerequisity[1]);
in[prerequisity[1]]++;
}
int sum = 0;
Queue<Integer> q = new LinkedList<Integer>();
for (int i = 0; i < numCourses; ++i) {
if (in[i] == 0) {
q.offer(i);
sum++;
}
}
while (!q.isEmpty()) {
int x = q.poll();
for (int y: g[x]) {
if (--in[y] == 0) {
sum++;
q.offer(y);
}
}
}
return sum == numCourses;
}
}
dfs 写法
相比于 bfs 写法, dfs 写法多开了一个 st 数组用来存储各个节点是否已经被访问过了。
class Solution {
List<Integer>[] g;
boolean[] st;
int[] in;
int sum = 0;
public boolean canFinish(int numCourses, int[][] prerequisites) {
g = new ArrayList[numCourses];
Arrays.setAll(g, e -> new ArrayList<Integer>());
in = new int[numCourses];
st = new boolean[numCourses];
for (int[] prerequisity: prerequisites) {
g[prerequisity[0]].add(prerequisity[1]);
in[prerequisity[1]]++;
}
for (int i = 0; i < numCourses; ++i) {
if (in[i] == 0) {
dfs(i);
}
}
return sum == numCourses;
}
public void dfs(int x) {
if (st[x]) return;
++sum;
st[x] = true;
for (int y: g[x]) {
if (--in[y] == 0) {
dfs(y);
}
}
}
}
210. 课程表 II(找到一个拓扑序列)
https://leetcode.cn/problems/course-schedule-ii/description/
对上一题的代码进行简单修改即可,把找到的拓扑序列存下来。
class Solution {
List<Integer>[] g;
List<Integer> ans = new ArrayList();
int[] in;
public int[] findOrder(int numCourses, int[][] prerequisites) {
g = new ArrayList[numCourses];
Arrays.setAll(g, e -> new ArrayList<Integer>());
in = new int[numCourses];
for (int[] prerequisity: prerequisites) {
g[prerequisity[1]].add(prerequisity[0]);
in[prerequisity[0]]++;
}
Queue<Integer> q = new LinkedList<Integer>();
for (int i = 0; i < numCourses; ++i) {
if (in[i] == 0) {
q.offer(i);
ans.add(i);
}
}
while (!q.isEmpty()) {
int x = q.poll();
for (int y: g[x]) {
if (--in[y] == 0) {
ans.add(y);
q.offer(y);
}
}
}
if (ans.size() == numCourses) return ans.stream().mapToInt(Integer::intValue).toArray();
else return new int[]{};
}
}
上一题是用 sum 记录可以学的课程数目,这一题是把可学的课程都放进 ans 列表中。
另外还有一道题:630. 课程表 III,但是和 拓扑排序 没什么关系。
1136. 并行课程(找拓扑序列过程中记录最少学期数)
https://leetcode.cn/problems/parallel-courses/
在 bfs 的过程中记录走了几步即可。
class Solution {
List<Integer>[] g;
int[] in;
public int minimumSemesters(int n, int[][] relations) {
in = new int[n + 1];
g = new ArrayList[n + 1];
Arrays.setAll(g, e -> new ArrayList());
for (int[] r: relations) {
g[r[0]].add(r[1]);
in[r[1]]++;
}
int ans = 0, sum = 0; // ans记录答案,sum记录学了几个课程
Queue<Integer> q = new LinkedList();
for (int i = 1; i <= n; ++i) {
if (in[i] == 0) {
q.offer(i);
sum++;
}
}
while (!q.isEmpty()) {
int sz = q.size();
for (int i = 0; i < sz; ++i) {
int x = q.poll();
for (int y: g[x]) {
if (--in[y] == 0) {
++sum;
q.offer(y);
}
}
}
++ans;
}
return sum == n? ans: -1;
}
}
还有一道题目1494. 并行课程 II 可见:从集合论到位运算——常见位运算技巧及相关习题 & 状态压缩DP
2050. 并行课程 III(边带值的拓扑序列,好题!🐂)
https://leetcode.cn/problems/parallel-courses-iii/
是 https://leetcode.cn/problems/parallel-courses/ 的进阶版,学完每门课程的需要花费的时间是不一样的。
借助优先队列,每次取出队列中最先完成的课程,检查完成这个课程后是否有新的课程可以开始学习即可。
最终的答案就是完成最后一门课程时的时间。
class Solution {
List<Integer>[] g;
int[] in;
public int minimumTime(int n, int[][] relations, int[] time) {
in = new int[n + 1];
g = new ArrayList[n + 1];
Arrays.setAll(g, e -> new ArrayList());
for (int[] r: relations) {
g[r[0]].add(r[1]);
in[r[1]]++;
}
int ans = 0; // ans记录答案
// 按照完成时间升序排序的优先队列
PriorityQueue<int[]> pq = new PriorityQueue<int[]>((a, b) -> a[1] - b[1]);
for (int i = 1; i <= n; ++i) {
if (in[i] == 0) {
pq.offer(new int[]{i, time[i - 1]});
}
}
while (!pq.isEmpty()) {
int[] cur = pq.poll();
ans = cur[1];
for (int y: g[cur[0]]) {
if (--in[y] == 0) {
pq.offer(new int[]{y, ans + time[y - 1]});
}
}
}
return ans;
}
}
444. 序列重建(将问题转换成拓扑排序)
https://leetcode.cn/problems/sequence-reconstruction/
题目数据保证了所有 sequences[i] 都是 nums 的子序列,因此 nums 一定是一个超序列,如果序列有唯一一个超序列,那么这一唯一的超序列一定是 nums 。
在构造拓扑序列的过程中,如果每次只有一个节点的入度是 0 ,那么构造的拓扑序列就是唯一的,则答案返回 true,否则中间过程一旦检查到同时有两个节点的入度变成了 0,那么就返回 false。
class Solution {
public boolean sequenceReconstruction(int[] nums, List<List<Integer>> sequences) {
int n = nums.length;
List<Integer>[] g = new ArrayList[n + 1];
int[] in = new int[n + 1];
Arrays.setAll(g, e -> new ArrayList<Integer>());
for (List<Integer> s: sequences) {
for (int i = 0; i < s.size() - 1; i++) {
g[s.get(i)].add(s.get(i + 1));
in[s.get(i + 1)]++;
}
}
Queue<Integer> q = new LinkedList<Integer>();
boolean f = false;
int sum = 0;
for (int i = 1; i <= n; ++i) {
if (in[i] == 0) {
++sum;
if (f) return false;
f = true;
q.offer(i);
}
}
while (!q.isEmpty()) {
if (q.size() > 1) return false;
int x = q.poll();
for (int y: g[x]) {
if (--in[y] == 0) {
q.offer(y);
sum++;
}
}
}
return sum == n;
}
}
269. 火星词典(需要考虑情况比较多的题目,需要细心)🐂
https://leetcode.cn/problems/alien-dictionary/
提示:
1 <= words.length <= 100
1 <= words[i].length <= 100
words[i] 仅由小写英文字母组成
这题比较麻烦,有多个情况需要考虑。
class Solution {
public String alienOrder(String[] words) {
Set<Character>[] g = new HashSet[128];
Arrays.setAll(g, e -> new HashSet<Character>());
int n = words.length;
int[] in = new int[128];
Set<Character> letters = new HashSet();
// 记录所有出现的字符
for (String word: words) {
for (char ch: word.toCharArray()) letters.add(ch);
}
// 构造字符之间的顺序关系
for (int i = 0; i < n; ++i) {
for (int j = i + 1; j < n; ++j) {
String a = words[i], b = words[j];
for (int k = 0; k < a.length(); ++k) {
if (k >= b.length()) return ""; // 前面的字符都一样但长的排在前面了不合理
char x = a.charAt(k), y = b.charAt(k);
if (x != y) {
if (g[y].contains(x)) return ""; // 和之前的顺序冲突了
if (!g[x].contains(y)) {
g[x].add(y);
in[y]++;
}
break;
}
}
}
}
// bfs 寻找拓扑序列
StringBuilder ans = new StringBuilder();
Queue<Character> q = new LinkedList<Character>();
for (char letter: letters) {
if (in[letter] == 0) {
q.offer(letter);
ans.append(letter);
}
}
while (!q.isEmpty()) {
char x = q.poll();
for (char y: g[x]) {
if (--in[y] == 0) {
q.offer(y);
ans.append(y);
}
}
}
return ans.toString();
}
}
相关资料
https://oi-wiki.org/graph/topo/文章来源:https://www.toymoban.com/news/detail-600847.html
更多关于拓扑排序的题目可见:https://leetcode.cn/tag/topological-sort/problemset/文章来源地址https://www.toymoban.com/news/detail-600847.html
到了这里,关于【算法基础:搜索与图论】3.3 拓扑排序的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!