拓扑排序
理论基础
- 对一个有向无环图(Directed Acyclic Graph简称DAG)G进行拓扑排序,是将G中所有顶点排成一个线性序列,使得图中任意一对顶点u 和v ,若边< u , v > ∈ E ( G ) ,**则u 在线性序列中出现在v 之前。**通常,这样的线性序列称为满足拓扑次序(Topological Order)的序列,简称拓扑序列。简单的说,由某个集合上的一个偏序得到该集合上的一个全序,这个操作称之为拓扑排序。
- 拓扑排序主要用来解决有向图中的依赖解析(dependency resolution)问题。
- 举例来说,如果我们将一系列需要运行的任务构成一个有向图,图中的有向边则代表某一任务必须在另一个任务之前完成这一限制。那么运用拓扑排序,我们就能得到满足执行顺序限制条件的一系列任务所需执行的先后顺序。当然也有可能图中并不存在这样一个拓扑顺序,这种情况下我们无法根据给定要求完成这一系列任务,这种情况称为循环依赖(circular dependency)。
- 拓扑排序存在的前提
- 当且仅当一个有向图为有向无环图(directed acyclic graph,或称DAG)时,才能得到对应于该图的拓扑排序。每一个有向无环图都至少存在一种拓扑排序。
- 如何得到一个有向无环图的拓扑排序?
- 要想完成拓扑排序,我们每次都应当从入度为0的结点开始遍历。因为只有入度为0的结点才能够成为拓扑排序的起点。否则根据拓扑排序的定义,只要一个结点v的入度不为0,则至少有一条边起始于其他结点而指向v,那么这条边的起点在拓扑排序的顺序中应当位于v之前,则v不能成为当前遍历的起点。
- BFS
与普通的广度优先遍历唯一的区别在于需要维护每一个节点对应的入度,并在遍历的每一层时选取入度为0的节点开始遍历(而普通的广度优先遍历则无此限制,可以从每一层任意一个节点开始遍历)。这个算法描述如下:
- 统计图的每一个节点的入度存储与数组inDeg。
- 选取入度为0的节点加入队列【贪心】
- 从队列中取出一个节点,
将该节点加入输出
将该节点的所有邻接点的入度树减1,减1后入度数变为0的节点加入队列
- 重复步骤3,直到遍历完所有的结点。
- 如果无法遍历完所有的结点,则意味着当前的图不是有向无环图。不存在拓扑排序。
在基于广度优先搜索的拓扑排序中,可以根据最终拓扑排序输出列表的长度是否等于图的节点数,来判断输入图是否存在拓扑排序。
- DFS
使用深度优先搜索实现拓扑排序的基本思想是:对于一个特定节点,如果该节点的所有相邻节点都已经搜索完成,则该节点也会变成已经搜索完成的节点,在拓扑排序中,该节点位于其所有相邻节点的前面。一个节点的相邻节点指的是从该节点出发通过一条有向边可以到达的节点。
由于拓扑排序的顺序和搜索完成的顺序相反,因此需要使用一个栈存储所有已经搜索完成的节点。深度优先搜索的过程中需要维护每个节点的状态,每个节点的状态可能有三种情况:
- 0:未访问;
- 1:访问中;
- 2:已访问;
- 初始时,所有节点的状态都是「未访问」。
每一轮搜索时,任意选取一个「未访问」的节点 u,从节点 u 开始深度优先搜索。将节点 u 的状态更新为「访问中」,对于每个与节点 u 相邻的节点 v,判断节点 v 的状态,执行如下操作:
- 如果节点 v 的状态是「未访问」,则继续搜索节点 v;
- 如果节点 v 的状态是「访问中」,则找到有向图中的环,因此不存在拓扑排序;
- 如果节点 v 的状态是「已访问」,则节点 v 已经搜索完成并加入输出排序列表,节点 u 尚未完成搜索,因此节点 u 的拓扑顺序一定在节点 v 的前面,不需要执行任何操作。
- 当节点 u 的所有相邻节点的状态都是「已访问」时,将节点 u 的状态更新为「已访问」,并将节点 u 加入输出排序列表。
- 当所有节点都访问结束之后,如果没有找到有向图中的环,则存在拓扑排序,所有节点从栈顶到栈底的顺序即为拓扑排序。
BSF实现【掌握】
int[] topoSort(int k, int[][] edges) { List<Integer>[] g = new ArrayList[k]; Arrays.setAll(g, e -> new ArrayList<>()); var inDeg = new int[k];// 入度 for (var e : edges) { int x = e[0] - 1, y = e[1] - 1; // 顶点编号从 0 开始,方便计算 g[x].add(y); ++inDeg[y]; } var order = new ArrayList<Integer>(); var q = new ArrayDeque<Integer>(); for (var i = 0; i < k; ++i) if (inDeg[i] == 0) q.push(i); while (!q.isEmpty()) { var x = q.pop(); order.add(x); for (var y : g[x]) if (--inDeg[y] == 0) q.push(y); } return order.stream().mapToInt(x -> x).toArray(); } 作者:灵茶山艾府 链接:https://leetcode.cn/problems/build-a-matrix-with-conditions/solutions/1781092/by-endlesscheng-gpev/ 来源:力扣(LeetCode) 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
相关题目
课程表【LC207】
你这个学期必须选修
numCourses
门课程,记为0
到numCourses - 1
。
在选修某些课程之前需要一些先修课程。 先修课程按数组 prerequisites
给出,其中 prerequisites[i] = [ai, bi]
,表示如果要学习课程 ai
则 必须 先学习课程 bi
。
- 例如,先修课程对
[0, 1]
表示:想要学习课程0
,你需要先完成课程1
。
请你判断是否可能完成所有课程的学习?如果可以,返回 true
;否则,返回 false
。
思路
由于在选修课程 ai
前 必须 先选修 bi
,因此我们可以构建一条由bi
指向 ai
的边,然后使用拓扑排序,优先遍历入度为0的节点,
- 并将其加入拓扑排序数组中,如果最后数组的大小与节点个数不相同,那么证明有向图中有环存在,不可能完成所有课程
- 实现
class Solution { public boolean canFinish(int numCourses, int[][] prerequisites) { Deque<Integer> queue = new LinkedList<>(); int[] inDeg = new int[numCourses]; List<Integer>[] after = new List[numCourses]; Arrays.setAll(after, e -> new ArrayList<>()); for (int[] pre : prerequisites){ inDeg[pre[0]]++; after[pre[1]].add(pre[0]); } List<Integer> sorted = new ArrayList<>(); for (int i = 0; i < numCourses; i++){ if (inDeg[i] == 0){ queue.addLast(i); } } while(!queue.isEmpty()){ int x = queue.pollFirst(); sorted.add(x); for (int y : after[x]){ inDeg[y]--; if (inDeg[y] == 0){ queue.addLast(y); } } } return sorted.size() == numCourses; } }
课程表 II【LC210】
现在你总共有
numCourses
门课需要选,记为0
到numCourses - 1
。给你一个数组
prerequisites
,其中 prerequisites[i] = [ai, bi]
,表示在选修课程 ai
前 必须 先选修 bi
。
- 例如,想要学习课程
0
,你需要先完成课程1
,我们用一个匹配来表示:[0,1]
。
返回你为了学完所有课程所安排的学习顺序。可能会有多个正确的顺序,你只要返回 任意一种 就可以了。如果不可能完成所有课程,返回 一个空数组 。
思路:拓扑排序
由于在选修课程 ai
前 必须 先选修 bi
,因此我们可以构建一条由bi
指向 ai
的边,然后使用拓扑排序,优先遍历入度为0的节点,
- 并将其加入拓扑排序数组中,如果最后数组的大小与节点个数不相同,那么证明有向图中有环存在,不可能完成所有课程
- 实现
class Solution { public int[] findOrder(int numCourses, int[][] prerequisites) { int[] inDeg = new int[numCourses]; List<Integer>[] g = new ArrayList[numCourses]; Arrays.setAll(g, e -> new ArrayList<>()); for (int[] p : prerequisites){ int u = p[1], v = p[0]; g[u].add(v); inDeg[v]++; } Deque<Integer> queue = new LinkedList<>(); List<Integer> order = new ArrayList<>(); for (int i = 0; i < numCourses; i++){ if (inDeg[i] == 0){ queue.addLast(i); } } while (!queue.isEmpty()){ int u = queue.pollFirst(); order.add(u); for (int v : g[u]){ inDeg[v]--; if (inDeg[v] == 0){ queue.addLast(v); } } } return order.size() == numCourses ? order.stream().mapToInt(x -> x).toArray() : new int[]{}; } }
并行课程 III【LC2050】
给你一个整数
表示课程n
,表示有n
节课,课程编号从1
到n
。同时给你一个二维整数数组relations
,其中relations[j] = [prevCoursej, nextCoursej]
,prevCoursej
必须在课程nextCoursej
之前 完成(先修课的关系)。同时给你一个下标从 0 开始的整数数组time
,其中time[i]
表示完成第(i+1)
门课程需要花费的 月份 数。
请你根据以下规则算出完成所有课程所需要的 最少 月份数:
- 如果一门课的所有先修课都已经完成,你可以在 任意 时间开始这门课程。
- 你可以 同时 上 任意门课程 。
请你返回完成所有课程所需要的 最少 月份数。
**注意:**测试数据保证一定可以完成所有课程(也就是先修课的关系构成一个有向无环图)。
- 思路:拓扑排序+dp
class Solution { public int minimumTime(int n, int[][] relations, int[] time) { List<Integer>[] g = new List[n]; Arrays.setAll(g, e -> new ArrayList<>()); int[] costs = new int[n]; int[] inDeg = new int[n]; Deque<Integer> queue = new LinkedList<>(); // 邻接表 for (int[] relation : relations){ int u = relation[0] - 1, v = relation[1] - 1; g[u].add(v); inDeg[v]++; } // dp计算最少月份数 修完某课程的最少月份数为其先修课的最大时间+其完成需要的时间 // dp[v] = time[v] + max(dp[u]); int res = 0; // 入度 for (int i = 0; i < n; i++){ if (inDeg[i] == 0){ queue.addLast(i); } } // 拓扑排序 while (!queue.isEmpty()){ int u = queue.pollFirst(); costs[u] += time[u]; res = Math.max(res, costs[u]); // order[index++] = u; for (int v : g[u]){ costs[v] = Math.max(costs[v], costs[u]); if (--inDeg[v] == 0){ queue.addLast(v); } } } return res; } }
给定条件下构造矩阵【LC2392】
给你一个 正 整数
k
,同时给你:
- 一个大小为
n
的二维整数数组rowConditions
,其中rowConditions[i] = [abovei, belowi]
和
- 一个大小为
m
的二维整数数组colConditions
,其中colConditions[i] = [lefti, righti]
。
两个数组里的整数都是 1
到 k
之间的数字。
你需要构造一个 k x k
的矩阵,1
到 k
每个数字需要 恰好出现一次 。剩余的数字都是 0
。
矩阵还需要满足以下条件:
对于所有 0 到 n - 1 之间的下标 i ,数字 abovei 所在的 行 必须在数字 belowi 所在行的上面。
对于所有 0 到 m - 1 之间的下标 i ,数字 lefti 所在的 列 必须在数字 righti 所在列的左边。
返回满足上述要求的 任意 矩阵。如果不存在答案,返回一个空的矩阵。
思路
- 通过
rowConditions
和colConditions
获得数字所在行和所在列的拓扑排序,如果不存在拓扑排序,那么返回空矩阵
- 然后通过拓扑排序将数字填入相应位置,先遍历行的拓扑排序记录每个数字应填入的行,然后再遍历列拓扑,将每个数字填入对应的位置
- 实现
class Solution { public int[][] buildMatrix(int k, int[][] rowConditions, int[][] colConditions) { int[] rowOrder = topoSort(k, rowConditions); int[] colOrder = topoSort(k, colConditions); if (rowOrder.length < k || colOrder.length < k) return new int[][]{}; int[][] res = new int[k][k]; int[] pos = new int[k + 1];// 记录数字i位于的行 for (int i = 0; i < k; i++){ pos[rowOrder[i]] = i; } for (int i = 0; i < k; i++){ res[pos[colOrder[i]]][i] = colOrder[i]; } return res; } public int[] topoSort(int k, int[][] edges){ List<Integer>[] g = new List[k + 1]; Arrays.setAll(g, e -> new ArrayList<>()); int[] inDeg = new int[k + 1]; Deque<Integer> q = new LinkedList<>(); List<Integer> res = new ArrayList<>(); for (int[] edge : edges){ int u = edge[0], v = edge[1]; g[u].add(v); inDeg[v]++; } for (int i = 1; i <= k; i++){ if (inDeg[i] == 0){ q.addLast(i); } } while(!q.isEmpty()){ int u = q.pollFirst(); res.add(u); for (int v : g[u]){ if (--inDeg[v] == 0){ q.addLast(v); } } } return res.stream().mapToInt(x -> x).toArray(); } }
收集树中金币【LC2603】
给你一个
n
个节点的无向无根树,节点编号从0
到n - 1
。给你整数n
和一个长度为n - 1
的二维整数数组edges
,其中edges[i] = [ai, bi]
表示树中节点ai
和bi
之间有一条
边。再给你一个长度为 n
的数组 coins
,其中 coins[i]
可能为 0
也可能为 1
,1
表示节点 i
处有一个金币。
一开始,你需要选择树中任意一个节点出发。你可以执行下述操作任意次:
- 收集距离当前节点距离为
2
以内的所有金币,或者 - 移动到树中一个相邻节点。
你需要收集树中所有的金币,并且回到出发节点,请你返回最少经过的边数。
如果你多次经过一条边,每一次经过都会给答案加一。
思路:拓扑排序
- 首先,我们可以去掉不包含金币的子树,因为访问其中任何一个点都毫无意义。
- 如果所有在叶子上的金币全部都能收集到,那么我们可以收集到树上所有金币。因此可以去除两轮叶子,剩余的结点即为必须经过的结点【两轮拓扑排序】
- 从距离叶子为2的节点处出发【局部最优】,收集树中所有的金币,并且回到出发节点时经过的边数最少【全局最优】。
- 实现
- 无向图,节点度为1时为叶子
class Solution { public int collectTheCoins(int[] coins, int[][] edges) { int n = coins.length; List<Integer> g[] = new ArrayList[n]; Arrays.setAll(g, e -> new ArrayList<>()); var deg = new int[n]; for (var e : edges) { int x = e[0], y = e[1]; g[x].add(y); g[y].add(x); // 建图 ++deg[x]; ++deg[y]; } // 用拓扑排序「剪枝」:去掉没有金币的子树 var q = new ArrayDeque<Integer>(); for (int i = 0; i < n; ++i) if (deg[i] == 1 && coins[i] == 0) // 无金币叶子 q.add(i); while (!q.isEmpty()) { int x = q.peek(); q.pop(); for (int y : g[x]) if (--deg[y] == 1 && coins[y] == 0) q.add(y); } // 再次拓扑排序 for (int i = 0; i < n; ++i) if (deg[i] == 1 && coins[i] == 1) // 有金币叶子 q.add(i); if (q.size() <= 1) return 0; // 至多一个有金币的叶子,直接收集 var time = new int[n]; while (!q.isEmpty()) { int x = q.peek(); q.pop(); for (int y : g[x]) if (--deg[y] == 1) { time[y] = time[x] + 1; // 记录入队时间 q.add(y); } } // 统计答案 int ans = 0; for (var e : edges) if (time[e[0]] >= 2 && time[e[1]] >= 2) ans += 2; return ans; } } 作者:灵茶山艾府 链接:https://leetcode.cn/problems/collect-coins-in-a-tree/solutions/2191371/tuo-bu-pai-xu-ji-lu-ru-dui-shi-jian-pyth-6uli/ 来源:力扣(LeetCode) 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
- 升级:如果把题目中的 2 换成 0,1,2,3,⋯ ,n−1,你能把这些情况对应的答案全部算出来吗?
遍历所有的边,如果两个节点的入队时间均大于q(最大距离),那么表示这条边必须要经过,结果+2
有向图中最大颜色值【LC1857】
给你一个 有向图 ,它含有
n
个节点和m
条边。节点编号从0
到n - 1
。给你一个字符串
colors
,其中colors[i]
是小写英文字母,表示图中第i
个节点的 颜色
(下标从 0 开始)。同时给你一个二维数组 edges
,其中 edges[j] = [aj, bj]
表示从节点 aj
到节点 bj
有一条 有向边 。
图中一条有效 路径 是一个点序列 x1 -> x2 -> x3 -> ... -> xk
,对于所有 1 <= i < k
,从 xi
到 xi+1
在图中有一条有向边。路径的 颜色值 是路径中 出现次数最多 颜色的节点数目。
请你返回给定图中有效路径里面的 最大颜色值 **。**如果图中含有环,请返回 -1
。
- 每个节点在其对应的颜色处数量+1,然后更新最大颜色值
- 实现
class Solution { public int largestPathValue(String colors, int[][] edges) { int n = colors.length(); int[] inDeg = new int[n]; List<Integer>[] g = new List[n]; Arrays.setAll(g, e -> new ArrayList<>()); Deque<Integer> q = new LinkedList<>(); List<Integer> order = new ArrayList<>(); int[][] dp = new int[n][26]; // dp[i][c] 以节点i为终点的所有路径中,包含c的节点数量的最大值 int res = 0; for (int[] edge : edges){ int u = edge[0], v = edge[1]; g[u].add(v); inDeg[v]++; } for (int i = 0; i < n; i++){ if (inDeg[i] == 0){ q.addLast(i); } } while (!q.isEmpty()){ int u = q.pollFirst(); order.add(u); dp[u][colors.charAt(u) - 'a']++; res = Math.max(res, dp[u][colors.charAt(u) - 'a']); for (int v : g[u]){ if (--inDeg[v] == 0){ q.addLast(v); } for (int i = 0; i < 26; i++){ dp[v][i] = Math.max(dp[v][i], dp[u][i]); res = Math.max(res, dp[v][i]); } } } if (order.size() < n) return -1; return res; } }
火星字典【LC269】
现有一种使用英语字母的火星语言,这门语言的字母顺序对你来说是未知的。
给你一个来自这种外星语言字典的字符串列表
words
,words
中的字符串已经 按这门新语言的字母顺序进行了排序 。如果这种说法是错误的,并且给出的
words
不能对应任何字母的顺序,则返回""
。否则,返回一个按新语言规则的 字典递增顺序 排序的独特字符串。如果有多个解决方案,则返回其中 任意一个 。
- 实现
class Solution { public String alienOrder(String[] words) { // 拓扑排序 // 某个字母之前必须有其他某些拓扑排序在它之前的字母 // 根据相邻单词字母顺序找到拓扑排序,找到第一个不一样的字母,words[i-1][j]出现在 words[i][j]之前,如果words[i-1]包含words[i],那么非法 // 用mask表示 某位为1时,对应字母必须在该字母之前 Map<Character, Integer> inDeg = new HashMap<>(); // 先初始化每个字母 for (String s : words){ for (int i = 0; i < s.length(); i++){ inDeg.putIfAbsent(s.charAt(i), 0); } } // 每个单词与前一个单词的顺序 for (int i = 1; i < words.length; i++){ int j = 0; while (j < words[i].length() && j < words[i - 1].length() && words[i].charAt(j) == words[i - 1].charAt(j)){ j++; } if (j < words[i].length() && j < words[i - 1].length() && words[i].charAt(j) != words[i - 1].charAt(j)){ int mask = inDeg.getOrDefault(words[i].charAt(j), 0); mask |= (1 << (words[i - 1].charAt(j) - 'a')); inDeg.put(words[i].charAt(j), mask); }else if (j < words[i - 1].length()){// 特殊判断,后一个字符串包含在前一个字符串中 return ""; } } // 拓扑排序 Deque<Character> queue = new LinkedList<>(); StringBuilder sb = new StringBuilder(); int size = inDeg.size(); for (char c = 'a'; c <= 'z'; c++){ if (inDeg.containsKey(c) && inDeg.get(c) == 0){ queue.addLast(c); inDeg.remove(c); } } while (!queue.isEmpty()){ Character u = queue.pollFirst(); sb.append(u); int val = u - 'a'; for (char c = 'a'; c <= 'z'; c++){ if (inDeg.containsKey(c)){ int mask = inDeg.get(c); if (((inDeg.get(c) >> val) & 1) == 1){ mask ^= (1 << val); } if (mask == 0){ queue.addLast(c); inDeg.remove(c); }else{ inDeg.put(c, mask); } } } } return sb.length() == size ? sb.toString() : ""; } }