算法刷题系列(三十九):拓扑排序——安排有向无环图的依赖顺序
算法刷题系列(三十九):拓扑排序——安排有向无环图的依赖顺序
算法刷题系列(三十九):拓扑排序——安排有向无环图的依赖顺序
课程先修、任务依赖和构建顺序都可以表示成有向边。若 u → v 表示 u 必须先于 v,拓扑序就是一个让每条边都从较早位置指向较晚位置的顶点排列。只有有向无环图(DAG)才存在拓扑序。
Kahn 算法
顶点的入度是指向它的边数。任何非空 DAG 至少有一个入度为零的顶点:若每个顶点入度都大于零,沿着前驱不断回走,在有限图中必然重复顶点,从而形成环。
Kahn 算法把所有入度为零的顶点放入队列,取出并加入序列,再删除它的出边(邻居入度减一)。邻居入度降到零时入队。关键不变量是:队列中的节点已经没有尚未处理的前驱,可以安全地安排在当前序列末尾。
Java 实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;
public class TopologicalSort {
public static List<Integer> sort(List<List<Integer>> graph) {
int n = graph.size();
int[] indegree = new int[n];
for (List<Integer> neighbors : graph) {
for (int neighbor : neighbors) {
if (neighbor < 0 || neighbor >= n) {
throw new IllegalArgumentException("neighbor out of range");
}
indegree[neighbor]++;
}
}
Deque<Integer> ready = new ArrayDeque<>();
for (int node = 0; node < n; node++) {
if (indegree[node] == 0) {
ready.addLast(node);
}
}
List<Integer> order = new ArrayList<>();
while (!ready.isEmpty()) {
int node = ready.removeFirst();
order.add(node);
for (int neighbor : graph.get(node)) {
indegree[neighbor]--;
if (indegree[neighbor] == 0) {
ready.addLast(neighbor);
}
}
}
return order.size() == n ? order : new ArrayList<>();
}
}
空列表代表发现环;顶点数为零时,空列表同时也是合法的空拓扑序,调用方若要区分这两种情况,可返回包含 hasCycle 字段的结果对象。
Python 实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from __future__ import annotations
from collections import deque
def topological_sort(graph: list[list[int]]) -> list[int] | None:
n = len(graph)
indegree = [0] * n
for neighbors in graph:
for neighbor in neighbors:
if not 0 <= neighbor < n:
raise ValueError("neighbor out of range")
indegree[neighbor] += 1
ready = deque(node for node, degree in enumerate(indegree) if degree == 0)
order: list[int] = []
while ready:
node = ready.popleft()
order.append(node)
for neighbor in graph[node]:
indegree[neighbor] -= 1
if indegree[neighbor] == 0:
ready.append(neighbor)
return order if len(order) == n else None
assert topological_sort([[2], [2], [3], []]) is not None
assert topological_sort([[1], [2], [0]]) is None
assert topological_sort([]) == []
正确性与复杂度
每次加入队列的顶点当前入度为零,说明未处理的前驱边数为零,因此把它追加到结果不会违反任何依赖。处理它时恰好移除所有出边,使后继入度反映剩余未处理前驱。若最终处理了全部顶点,所得顺序满足每条边的先后约束;若仍有顶点未处理,它们的剩余子图中每个顶点都有未消除入边,必含有向环。
每个顶点入队、出队至多一次,每条边处理一次,时间为 O(V+E);邻接表和入度、队列使用 O(V+E) 空间。
多解、优先级与常见错误
- 拓扑序通常不唯一。若需要字典序最小的序列,可用最小堆替换普通队列,复杂度增加为
O((V+E) log V)。 - 不能只根据初始入度为零的节点判断是否有环;必须把队列处理到结束,并检查结果顶点数。
- 重边会在计算与减少入度时各自计数一次,因此只要邻接表一致,算法仍正确。
- 边方向要与“谁依赖谁”的约定一致。若
u依赖v,应建边v → u。 - 有环时不应返回看似有效的部分序列并当作完整答案;必须明确告知无解或返回环检测状态。
Kahn 算法把依赖约束转换成可反复移除的零入度节点。处理数量不足 V 本身就是环检测,不需要额外再运行一次 DFS。
本文由作者按照 CC BY 4.0 进行授权