拓扑排序
拓扑排序
有向图的拓扑排序是对其顶点的一种线性排序,使得对于从顶点 \(u\) 到顶点 \(v\) 的每个有向边 \(uv\),\(u\) 在排序中都在 \(v\) 之前。
注意:当且仅当图中没有定向环时(即 有向无环图 ),才有可能进行拓扑排序。
拓扑排序的目标是将所有节点排序,使得排在前面的节点不能依赖于排在后面的节点。
拓扑排序主要有 Kahn 算法和 DFS 算法两种:
-
Kahn 算法采用入度方法,以循环迭代方法实现;
-
DFS 算法采用出度方法,以递归方法实现。
Kahn 算法和 DFS 算法的复杂度是一样的,算法过程也是等价的,不分优劣,因为本质上循环迭代 + 栈 = 递归。
算法
Kahn(卡恩)算法
Kahn 算法采用入度方法,其算法过程如下:
-
选择入度为 0 的节点,输出到结果序列中;
-
删除该节点以及该节点的边;
重复执行步骤 1 和 2,直到所有节点输出到结果序列中,完成拓扑排序,如果最后还存在入度不为 0 的节点,说明有向图中存在环,无法进行拓扑排序。
以如下有向无环图为例:
-
- 先找到图中所有入度为零的节点:\(C1\) 和 \(C2\),并将其入队
-
- 选择一个入度为零的节点 \(C1\) ,并将其从图中删除,同时,删除该节点的边,即将 \(C3\) 的入度减 \(1\)
-
- 再次选择一个入度为零的节点 \(C2\) ,并将其从图中删除,同时,删除该节点的边,即将 \(C4\) 和 \(C5\) 的入度减 \(1\)
-
- 再次选择一个入度为零的节点 \(C3\) ,并将其从图中删除,同时,删除该节点的边,即将 \(C6\) 的入度减 \(1\)
-
- 重复上述过程,直到所有入度为零的节点都已经从图中删除,即可得到一个拓扑排序的结果。
DFS 算法
深度优先搜索以任意顺序循环遍历图中的每个节点,若搜索进行中碰到之前已经遇到的节点,或碰到叶节点,则中止算法。
DFS算法采用出度算法,其算法过程如下:
-
对有向图进行深度优先搜索;
-
在执行深度优先搜索时,若某个顶点不能继续前进,即顶点的出度为0,则将此顶点入栈;
-
最后对栈中的序列进行逆排序,即完成拓扑排序;如果深度优先搜索时,碰到已遍历的节点,说明存在环。
代码实现
from enum import Enum, auto
class Status(Enum):
to_visit = auto()
visiting = auto()
visited = auto()
def topo_sort(graph: list[list[int]]) -> list[int] | None:
n = len(graph)
status = [Status.to_visit] * n
order = []
def dfs(u: int) -> bool:
status[u] = Status.visiting
for v in graph[u]:
if status[v] == Status.visiting:
return False
if status[v] == Status.to_visit and not dfs(v):
return False
status[u] = Status.visited
order.append(u)
return True
for i in range(n):
if status[i] == Status.to_visit and not dfs(i):
return None
return order[::-1]
复杂度:
-
时间复杂度:\(O(E+V)\)
-
空间复杂度:\(O(V)\)
应用
应用1:Leetcode.207
题目
你这个学期必须选修 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 。这是可能的。
解题思路
方法一:深度优先
深度优先搜索以任意顺序循环遍历图中的每个节点。若搜索进行中碰到之前已经遇到的节点,或碰到叶节点,则中止算法。
首先,我们将边转换成邻接表,然后通过深度优先遍历,用数组 \(visited\) 记录顶点是否访问过,同时使用 \(path\) 记录所有访问的路径,如果访问过的路径没有成环,那说明可以完成课程,如果成环,则不能完成所有课程。
方法二:广度优先
我们使用一个队列 queue 来进行广度优先搜索,初始时,所有入度为 \(0\) 的节点都被放入队列中,它们就是可以作为拓扑排序最前面的节点,并且它们之间的相对顺序是无关紧要的。
在广度优先搜索的每一步中,我们取出队首的节点 \(u\):
-
将 \(u\) 放入答案中;
-
移除 \(u\) 的所有出边,也就是将 \(u\) 的所有相邻节点的入度减少 \(1\);
-
如果某个相邻节点 \(v\) 的入度变为 \(0\),那么我们就将 \(v\) 放入队列中。
在广度优先搜索的过程结束后,如果答案中包含了这 \(n\) 个节点,那么我们就找到了一种拓扑排序,否则说明图中存在环,也就不存在拓扑排序了。
代码实现
- 方法一:DFS算法实现
Solution {
private List<List<Integer>> graph;
int[] visited;
boolean valid = true; // 0: 未访问;1:访问中;2:已完成访问
public boolean canFinish(int numCourses, int[][] prerequisites) {
visited = new int[numCourses];
graph = buildGraph(numCourses, prerequisites);
for (int i = 0; i < numCourses && valid; i++) {
if (visited[i] == 0) {
dfs(i);
}
}
return valid;
}
private void dfs(int u) {
visited[u] = 1; // 设置为访问中
for (int v : graph.get(u)) {
// 遇到访问中的节点,说明成环了
if (visited[v] == 1) {
valid = false;
return;
}
// 遇到未访问过的节点,则继续递归遍历
if (visited[v] == 0) {
dfs(v);
}
// 如果访问过程中存在环,则直接退出递归
if (!valid) {
return;
}
}
visited[u] = 2;
}
private List<List<Integer>> buildGraph(int numCourses, int[][] prerequisites) {
List<List<Integer>> graph = new ArrayList<List<Integer>>();
for (int i = 0; i < numCourses; i++) {
graph.add(new ArrayList<Integer>());
}
for (int[] info : prerequisites) {
graph.get(info[1]).add(info[0]);
}
return graph;
}
}
- 方法二:Kahn算法实现
class Solution {
public boolean canFinish(int numCourses, int[][] prerequisites) {
// 记录每个顶点的入度
int [] incomingEdge = new int[numCourses];
// 使用邻接表记录依赖关系
List<List<Integer>> graph = new ArrayList<>();
for (int i = 0; i < numCourses; i++) {
graph.add(new ArrayList<>());
}
// 创建图,同时,记录每个顶点的入度
for (int[] info : prerequisites) {
graph.get(info[1]).add(info[0]);
incomingEdge[info[0]] += 1;
}
// 将入度为零的顶点入队
Queue<Integer> queue = new ArrayDeque<>();
for (int u = 0; u < numCourses; u++) {
if (incomingEdge[u] == 0) {
queue.offer(u);
}
}
// 从入度为零的顶点开始BFS搜索
int count = 0;
while (!queue.isEmpty()) {
count++;
int u = queue.poll();
for (int v: graph.get(u)) {
incomingEdge[v] -= 1;
if (incomingEdge[v] == 0) {
queue.offer(v);
}
}
}
return count == numCourses;
}
}
应用2:Leetcode.210
题目
解题思路
方法一:深度优先搜索
对于图中任意一个节点,在搜索过程中都有三种状态:
-
未搜索(UNVISITED):还未搜索该节点;
-
搜索中(VISITING):已经搜索到该节点,但是还没有回溯到该节点,即相邻的节点还没有搜索完成;
-
已完成(VISITED):已经回溯过该节点,且相邻的节点已经搜索完成。
算法流程:
遍历所有的节点,找到未访问过的节点,进行深度优先搜索,我们将当前搜索的节点 \(u\) 标记为搜索中,遍历该节点的每一个相邻节点 \(v\):
-
如果 \(v\) 为未搜索,那么我们开始搜索 \(v\),搜索完成后回溯到 \(u\);
-
如果 \(v\) 为搜索中,那么我们就找到了图中的一个环,因此是不存在拓扑排序的;
-
如果 \(v\) 为已完成,那么说明 \(v\) 已经在栈中了,而 \(u\) 还不在栈中,因此 \(u\) 无论何时入栈都不会影响到 (\(u\), \(v\)) 之前的拓扑关系,以及不用进行任何操作。
方法二:广度优先搜索
先将所有入度为 \(0\) 的节点放入队列中,它们就是可以作为拓扑排序最前面的节点,并且它们之间的相对顺序是无关紧要的。
-
在广度优先搜索的每一步中,我们取出队首的节点 \(u\):
-
我们将 \(u\) 放入答案中;
-
我们移除 \(u\) 的所有出边,也就是将 \(u\) 的所有相邻节点的入度减少 \(1\),如果某个相邻节点 \(v\) 的入度变为 \(0\),那么我们就将 \(v\) 放入队列中。
在广度优先搜索的过程结束后,如果答案中包含了这 \(n\) 个节点,那么我们就找到了一种拓扑排序,否则说明图中存在环,也就不存在拓扑排序了。
代码实现
- DFS算法实现
class Solution {
private List<List<Integer>> graph;
private int[] visit;
private final ArrayList<Integer> path = new ArrayList<>();
private static class Status {
static final int NOT_VISIT = 0;
static final int VISITING = 1;
static final int VISITED = 2;
}
public int[] findOrder(int numCourses, int[][] prerequisites) {
visit = new int[numCourses];
graph = buildGraph(numCourses, prerequisites);
for (int u = 0; u < numCourses; u++) {
if (visit[u] == Status.NOT_VISIT && !dfs(u)) {
return new int[0];
}
}
Collections.reverse(path);
return path.stream().mapToInt(Integer::valueOf).toArray();
}
private List<List<Integer>> buildGraph(int numCourses, int[][] prerequisites) {
List<List<Integer>> graph = new ArrayList<>();
for (int i = 0; i < numCourses; i++) {
graph.add(new ArrayList<>());
}
for (int[] prerequisite : prerequisites) {
graph.get(prerequisite[1]).add(prerequisite[0]);
}
return graph;
}
private boolean dfs(int u) {
// 进入节点u,将其状态标记为搜索中
visit[u] = Status.VISITING;
for (int v : graph.get(u)) {
// 如果u的相邻的节点v也是搜索中的状态,则说明图中存在环
if (visit[v] == Status.VISITING) {
return false;
}
// 如果u的相邻的节点v未访问过,但是v的相邻节点存在环,则也说明图中存在环
if (visit[v] == Status.NOT_VISIT && !dfs(v)) {
return false;
}
}
// 记录遍历路径
path.add(u);
// 节点u回溯完成,将其状态标记为已访问
visit[u] = Status.VISITED;
return true;
}
}
- BFS算法实现
from collections import deque
from typing import List
class Solution:
def findOrder(self, numCourses: int, prerequisites: List[List[int]]) -> List[int]:
graph = [list() for _ in range(numCourses)]
incoming_edges = [0] * numCourses
for prerequisite in prerequisites:
graph[prerequisite[1]].append(prerequisite[0])
incoming_edges[prerequisite[0]] += 1
result = list()
# 将入度为零的顶点加入队列
q = deque([u for u in range(numCourses) if incoming_edges[u] == 0])
while q:
u = q.popleft()
result.append(u)
for v in graph[u]:
incoming_edges[v] -= 1
if incoming_edges[v] == 0:
q.append(v)
if len(result) != numCourses:
return list()
return result
应用3: Leetcode.239
题目
解题思路
方法一:DFS
将矩阵看成一个有向图,每个单元格对应图中的一个节点,如果相邻的两个单元格的值不相等,则在相邻的两个单元格之间存在一条从较小值指向较大值的有向边,因此,该问题就可以转化成在有向图中寻找最长路径。
我们可以从一个单元格开始进行深度优先搜索,即可找到从该单元格开始的最长递增路径。对每个单元格分别进行深度优先搜索之后,即可得到矩阵中的最长递增路径的长度。
在对每个单元格进行深度优先搜索的时候,同一个单元格会被多次访问,每次访问都要重新计算,为了避免大量的重复计算,这里我们用一个与 \(matrix\) 大小相同的矩阵 \(path\),来记录矩阵每个点作为起点的最长递增路径,避免对每个点都进行很多重复的搜索。
我们规定当访问到一个单元格 \((i,j)\) 时:
-
如果 \(path[i][j] \ne 0\),则说明该单元格的结果已经计算过,则直接从缓存中读取结果;
-
如果 \(path[i][j] = 0\),说明该单元格的结果尚未被计算过,则进行搜索,并将计算得到的结果存入缓存中。
使用深度优先搜索的方式,搜索所有的路径,遇到搜索过的点,就直接更新当前点的最长路径。
方法二:拓扑排序
通过观察,容易看出来每个单元格对应的最长递增路径的结果只和相邻单元格的结果有关,即对于方法一中的 \(path\) 存在如下状态转移方程:
也就是说,如果一个单元格的值比它的所有相邻单元格的值都要大,那么这个单元格对应的最长递增路径是 1,此时,这个位置就可以作为拓扑排序的起始位置。
这个起始位置并不直观,而是需要根据矩阵中的每个单元格的值找到作为边界条件的单元格。
将矩阵看成一个有向图,计算每个单元格对应的出度,即有多少条边从该单元格出发。对于作为起始位置的单元格,该单元格的值比所有的相邻单元格的值都要大,因此作为起始位置的单元格的出度都是 0。
算法步骤:
-
找到所有出度为 0 的零的单元格,并将其加入初始搜索路径;
-
从所有出度为 0 的单元格,开始进行广度优先搜索,每一轮搜索都会遍历当前层的所有单元格,更新其余单元格的出度,并将出度变为 0 的单元格加入下一层搜索;
-
当搜索结束时,搜索的总层数即为矩阵中的最长递增路径的长度。
代码实现
【方法一】:DFS
class Solution:
DIRECTIONS = [(-1, 0), (1, 0), (0, 1), (0, -1)]
def longestIncreasingPath(self, matrix: List[List[int]]) -> int:
m, n = len(matrix), len(matrix[0])
if m == 0 or n == 0:
return 0
path = [[0] * n for _ in range(m)]
result = 0
for i in range(m):
for j in range(n):
result = max(result, self.dfs(matrix, path, i, j))
return result
@classmethod
def dfs(cls, matrix, path, row, column):
if path[row][column]:
return path[row][column]
path[row][column] += 1
for direction in cls.DIRECTIONS:
x, y = row + direction[0], column + direction[1]
if 0 <= x < len(matrix) and 0 <= y < len(matrix[0]) and matrix[row][column] < matrix[x][y]:
path[row][column] = max(path[row][column], cls.dfs(matrix, path, x, y) + 1)
return path[row][column]
复杂度分析:
-
时间复杂度:\(O(m \times n)\),其中,m 和 n 分别为矩阵的行数和列数。
-
时间复杂度:\(O(m \times n)\)。
【方法二】:拓扑排序
from typing import List
from collections import deque
class Solution:
DIRECTIONS = [(-1, 0), (1, 0), (0, -1), (0, 1)]
def longestIncreasingPath(self, matrix: List[List[int]]) -> int:
if not matrix:
return 0
rows, columns = len(matrix), len(matrix[0])
out_degrees = [[0] * columns for _ in range(rows)]
queue = deque()
for i in range(rows):
for j in range(columns):
for _dir in self.DIRECTIONS:
x, y = i + _dir[0], j + _dir[1]
# 对于位置(i,j),它存在值大于它的邻近单元,则记录它的出度
if 0 <= x < rows and 0 <= y < columns and matrix[x][y] > matrix[i][j]:
out_degrees[i][j] += 1
# 将值大于其四周的单元格的点作为起点
if out_degrees[i][j] == 0:
queue.append((i, j))
result = 0
while queue:
result += 1
size = len(queue)
for _ in range(size):
row, column = queue.popleft()
# 对于每一个位置,如果它四周存在小于它的位置,将其出度减一
for _dir in self.DIRECTIONS:
x, y = row + _dir[0], column + _dir[1]
if 0 <= x < rows and 0 <= y < columns and matrix[x][y] < matrix[row][column]:
out_degrees[x][y] -= 1
if out_degrees[x][y] == 0:
queue.append((x, y))
return result
应用4:Leetcode.1136
题目
解题思路
我们可以将课程之间的关系看成有向图,然后找到入度为零的课程,并对图进行拓扑排序即可。
在拓扑排序过程,我们可以得到一次找到所有有向无环图中的最大路径,即当前学期可以学习的最大课程数。
代码实现
from typing import List
from collections import defaultdict, deque
class Solution:
def minimumSemesters(self, n: int, relations: List[List[int]]) -> int:
graph = defaultdict(list)
incoming_edges = defaultdict(int)
for start, end in relations:
graph[start].append(end)
incoming_edges[end] += 1
queue = deque([u for u in range(1, n + 1) if incoming_edges[u] == 0])
step = 0
studied_count = 0
while queue:
step += 1
size = len(queue)
for _ in range(size):
u = queue.popleft()
studied_count += 1
for v in graph[u]:
incoming_edges[v] -= 1
if incoming_edges[v] == 0:
queue.append(v)
return step if studied_count == n else -1
应用场景
拓扑排序可以判断图中是否有环,还可以用来判断图是否是一条链。拓扑排序可以用来求 AOE 网中的关键路径,估算工程完成的最短时间。
应用4:Leetcode.2050
题目
给你一个整数 n ,表示有 n 节课,课程编号从 1 到 n 。同时给你一个二维整数数组 relations ,其中 relations[j] = [prevCoursej, nextCoursej] ,表示课程 prevCoursej 必须在课程 nextCoursej 之前 完成(先修课的关系)。同时给你一个下标从 0 开始的整数数组 time ,其中 time[i] 表示完成第 (i+1) 门课程需要花费的 月份 数。
请你根据以下规则算出完成所有课程所需要的 最少 月份数:
-
如果一门课的所有先修课都已经完成,你可以在 任意 时间开始这门课程;
-
你可以 同时 上 任意门课程。
请你返回完成所有课程所需要的 最少 月份数。注意:测试数据保证一定可以完成所有课程(也就是先修课的关系构成一个有向无环图)。
示例 1:
输入:n = 3, relations = [[1,3],[2,3]], time = [3,2,5]
输出:8
解释:上图展示了输入数据所表示的先修关系图,以及完成每门课程需要花费的时间。
你可以在月份 0 同时开始课程 1 和 2 。课程 1 花费 3 个月,课程 2 花费 2 个月。所以,最早开始课程 3 的时间是月份 3 ,完成所有课程所需时间为 3 + 5 = 8 个月。
解题思路
我们可以将课程之间的关系看成有向图,然后找到入度为零的课程,并对图进行拓扑排序即可。
在拓扑排序过程,我们可以得到一次找到所有有向无环图中的最大路径,即当前学期可以学习的最大课程数。
定义 \(dp[i]\) 表示完成第 \(i\) 门课程需要花费的最少月份数。根据题意,只有当 \(i\) 的所有先修课程都完成时,才可以开始学习第 \(i\) 门课程,并且可以立即开始。
因此,
其中,\(j\) 是 \(i\) 的先修课程。
由于题目保证图是一个有向无环图,所以一定存在拓扑序。我们可以在计算拓扑序的同时,计算状态转移。
具体来说,设当前节点为 \(x\),我们可以在计算出 \(dp[x]\) 后,更新 \(y\) 的所有先修课程耗时的最大值,这里 \(x\) 是 \(y\) 的先修课程。答案就是所有 \(dp[i]\) 的最大值。
代码实现
【Python实现】
from typing import List
from collections import defaultdict, deque
class Solution:
def minimumTime(self, n: int, relations: List[List[int]], time: List[int]) -> int:
graph = defaultdict(list)
incoming_edges = [0] * n
for i, relation in enumerate(relations):
graph[relation[0] - 1].append((relation[1] - 1))
incoming_edges[relation[1] - 1] += 1
dp = [0] * n
queue = deque()
for u in range(n):
if incoming_edges[u] == 0:
queue.append(u)
dp[u] = time[u]
result = 0
while queue:
size = len(queue)
for _ in range(size):
u = queue.popleft()
result = max(result, dp[u])
for v in graph[u]:
incoming_edges[v] -= 1
dp[v] = max(dp[v], dp[u] + time[v])
if incoming_edges[v] == 0:
queue.append(v)
return result
【Java实现】
class Solution {
public int minimumTime(int n, int[][] relations, int[] time) {
List<Integer>[] g = new ArrayList[n];
Arrays.setAll(g, e -> new ArrayList<>());
var deg = new int[n];
for (var r : relations) {
int x = r[0] - 1, y = r[1] - 1;
g[x].add(y);
deg[y]++;
}
var q = new ArrayDeque<Integer>();
for (int i = 0; i < n; i++)
if (deg[i] == 0) // 没有先修课
q.add(i);
var f = new int[n];
int ans = 0;
while (!q.isEmpty()) {
int x = q.poll(); // x 出队,意味着 x 的所有先修课都上完了
f[x] += time[x]; // 加上当前课程的时间,就得到了最终的 f[x]
ans = Math.max(ans, f[x]);
for (int y : g[x]) { // 遍历 x 的邻居 y
f[y] = Math.max(f[y], f[x]); // 更新 f[y] 的所有先修课程耗时的最大值
if (--deg[y] == 0) // y 的先修课已上完
q.add(y);
}
}
return ans;
}
}
参考: