目录
一、什么是有向无环图
了解图谱排序前,我们先了解什么是有向无环图。
有向无环图,简称 DAG(Directed Acyclic Graph),是一种图论中的概念,它具有以下两个主要特征:
-
有向(Directed):
图中的每条边都有一个方向,即每条边从一个节点指向另一个节点。例如,如果存在一条边 u→v,这表示从节点 u 指向节点 v。 -
无环(Acyclic):
图中不存在任何包含多个节点的环路。也就是说,从一个节点出发,通过若干条边可以到达的其他节点中,不可能回到这个出发节点。
1.1形象理解
-
有向意味着你在图中只能沿着边的方向“走”。就像一个单向街道网络,你只能按照指定方向行驶,不能逆行。
-
无环意味着在这个图中,你无法从一个节点出发,经过若干个节点后,又回到起点。这就像你在一个单向街道网络中,不可能绕一圈回到起点。
1.2形式化定义
- 一个图 G=(V,E)由一组节点 V和一组有向边 E组成。如果图 G 是有向无环图,那么对于图中的每条路径 v1→v2→⋯→vn,在路径中不可能出现 v1=vn的情况,即不可能形成一个从某个节点回到它自己的闭合路径。
1.3DAG 的特点
-
拓扑排序:DAG 是能够进行拓扑排序的唯一图结构。因为没有环,节点可以按照依赖关系排序。
-
依赖关系:DAG 常用于表示依赖关系。比如,任务调度、编译顺序、数据流图等,都可以使用 DAG 表示。
-
无向无环图(树)与 DAG 的关系:
一棵树是一个特殊的无向无环图。如果将树中的每一条无向边变为有向边,并且所有边的方向都从父节点指向子节点,那么它就成为了一个 DAG。
1.4DAG 的实际应用
-
任务调度:在操作系统中,任务的依赖关系通常用 DAG 表示,确保依赖的任务按顺序执行。
-
编译依赖:在编译大型软件时,不同模块之间可能存在依赖关系,DAG 可用于决定编译顺序。
-
版本控制:在分布式版本控制系统(如 Git)中,提交历史可以用 DAG 表示,分支和合并操作会创建或连接 DAG 中的节点。
-
数据流分析:在编译器优化中,DAG 用于表示表达式和变量之间的依赖关系,优化计算过程。
示例:
考虑以下 DAG:
1 → 2 → 3 → 5
↘ 4 ↗
- 这里,节点 1 指向节点 2,节点 2 指向节点 3 和 4,节点 4 和 3 都指向节点 5。
- 这个图中不存在任何环,所以它是一个 DAG。
- 拓扑排序可能的结果之一是:
1 2 4 3 5
或1 2 3 4 5
,这两种排序都符合 DAG 的性质。
二、拓扑排序的思想是什么
知道了有向无环图之后,我们再来了解拓扑排序。
拓扑排序(Topological Sorting)的思想是一种针对有向无环图(DAG, Directed Acyclic Graph)的排序方法。它将图中的所有节点按照某种线性顺序排列,使得对于图中的每一条有向边 u→v,节点 u都排在节点 v 之前。
2.1拓扑排序的核心思想
-
依赖关系的排序:
拓扑排序的目标是对节点进行排序,确保在排序结果中,每个节点都在其所有依赖节点之后。也就是说,如果有一条有向边 u→v,则在排序结果中 u必须排在 v之前。 -
无环性:
拓扑排序只能应用于有向无环图(DAG)。如果图中存在环(即有回路),则无法完成拓扑排序,因为环中的节点彼此依赖,无法确定一个合适的排序顺序。 -
入度的利用:
入度表示指向某个节点的边的数量。拓扑排序从入度为 0 的节点开始,因为这些节点没有依赖关系,可以首先被处理。将一个入度为 0 的节点加入排序后,删除与该节点相关的边,并更新其他节点的入度。对于每一个入度变为 0 的节点,将其加入到下一轮排序中。 -
递归消除依赖:
通过不断移除入度为 0 的节点及其边,图中的节点和边逐渐减少,最终所有节点都被排序,或检测到剩下的节点形成了环。
2.2拓扑排序的实现方法
拓扑排序常用两种方法实现:
-
Kahn 算法(基于入度的算法):
这种方法直接使用入度的概念:- 如果所有节点都被处理,则得到有效的拓扑排序;如果有节点未被处理,说明图中存在环。
- 重复步骤 2 和 3,直到队列为空。
- 删除该节点的所有出边,更新目标节点的入度。如果某个节点的入度变为 0,则将其加入队列。
- 从队列中依次取出节点,将其加入拓扑排序结果。
- 找出所有入度为 0 的节点,将它们放入一个队列中。
-
DFS(深度优先搜索)算法:
通过深度优先搜索(DFS)实现拓扑排序:- 这种方法也可以检测环,如果在 DFS 过程中遇到已经在访问中的节点,则说明图中存在环。
- 最终栈中的节点顺序即为拓扑排序的结果。
- 当 DFS 完成对某个节点的访问时,将该节点加入一个栈中。
- 对图中的每个节点执行 DFS,按照完成时间的逆序记录节点。
2.3拓扑排序的应用
- 任务调度:当某些任务之间存在依赖关系时,拓扑排序可以确定任务的执行顺序。
- 课程安排:如果某些课程有先修课要求,拓扑排序可以用于决定课程的学习顺序。
- 构建系统:编译或构建软件项目时,拓扑排序可以用于确定模块或文件的编译顺序。
三、基于Kahn 算法的方法
Kahn 算法的核心思想:
初始化入度表:计算并存储每个节点的入度,即有多少条边指向该节点。
寻找入度为 0 的节点:将所有入度为 0 的节点加入一个队列,因为这些节点没有前置依赖,可以最先被处理。
逐步删除节点:
- 从队列中取出一个入度为 0 的节点,将其添加到拓扑排序结果中。
- 删除该节点的所有出边(即将其邻接节点的入度减 1)。
- 如果某个邻接节点的入度因此减为 0,则将其加入队列。
检查排序结果:
- 重复上述步骤,直到队列为空。如果最终拓扑排序结果包含了所有节点,则排序成功。
- 如果图中存在环,则某些节点永远无法使其入度变为 0,队列会提前为空,此时拓扑排序失败。
Kahn 算法的优点:
- 高效性:每个节点和每条边都只被处理一次,时间复杂度为 O(V+E)O(V + E)O(V+E),其中 VVV 是节点数,EEE 是边数。
- 简单易实现:利用队列和入度的概念,算法逻辑清晰,适用于实际工程问题中的依赖关系排序。
代码:
#include <cstdio>
const int MAXN = 200005; // 最大节点数量
const int MAXM = 200005; // 最大边数量
// 拓扑排序需要,快速收集实时入度为0的节点
int indegree[MAXN]; // 每个节点的入度(指向该节点的边数)
int queue[MAXN]; // 用于存储入度为0的节点的队列
int front, back; // 队列的头部和尾部指针
// 建图需要
int head[MAXN] = { 0 }; // 存储每个节点的边链表的头节点编号
int to[MAXM]; // 存储每条边的目标节点
int next[MAXM]; // 存储每条边的下一条边的编号(链表结构)
int cnt; // 当前边的计数器,用于唯一标识每条边
// 收集答案需要
int ans[MAXN]; // 存储拓扑排序后的节点顺序
// 添加一条从u到v的有向边
void addEdge(int u, int v) {
to[cnt] = v; // 边编号为cnt的目标节点是v
next[cnt] = head[u]; // 边编号为cnt的下一条边是从u开始的现有第一条边
head[u] = cnt++; // 更新head[u]为当前的这条新边,并增加边计数器
}
// 拓扑排序函数,返回排序成功与否
bool topologicalSort(int n) {
front = back = 0; // 初始化队列指针
// 将所有入度为0的节点加入队列
for (int i = 1; i <= n; i++) {
if (indegree[i] == 0)
queue[back++] = i;
}
int size = 0; // 用于记录已经排序的节点数量
// 处理队列中的每个节点
while (front < back) {
int u = queue[front++]; // 从队列头部取出一个节点
ans[size++] = u; // 将其加入到结果数组中
// 遍历节点u的所有出边
for (int edgeId = head[u]; edgeId; edgeId = next[edgeId]) {
// 减少目标节点的入度
if (--indegree[to[edgeId]] == 0)
// 如果目标节点的入度减为0,则将其加入队列
queue[back++] = to[edgeId];
}
}
// 如果排序后的节点数量等于总节点数,说明排序成功
return size == n;
}
int main() {
cnt = 1; // 初始化边计数器
int n, m, u, v;
scanf("%d%d", &n, &m); // 读取节点数n和边数m
// 读取每条边,并构建图
while (m--) {
scanf("%d%d", &u, &v);
addEdge(u, v); // 添加一条从u到v的边
indegree[v]++; // 更新目标节点v的入度
}
// 执行拓扑排序,如果成功则输出排序结果
if (topologicalSort(n)) {
for (int i = 0; i < n - 1; i++)
printf("%d ", ans[i]); // 输出拓扑排序结果
printf("%d\n", ans[n - 1]);
}
else
printf("-1\n"); // 如果排序失败,输出-1
return 0;
}
示例:
输入:
6 6
1 2
2 3
3 4
4 5
5 6
1 3
这里表示有 6 个节点和 6 条边:
- 边 1 → 2
- 边 2 → 3
- 边 3 → 4
- 边 4 → 5
- 边 5 → 6
- 边 1 → 3
图的结构:
- 1 → 2
- 1 → 3
- 2 → 3
- 3 → 4
- 4 → 5
- 5 → 6
拓扑排序过程:
-
计算入度:
- 节点 1:入度 0
- 节点 2:入度 1(来自 1)
- 节点 3:入度 2(来自 1, 2)
- 节点 4:入度 1(来自 3)
- 节点 5:入度 1(来自 4)
- 节点 6:入度 1(来自 5)
-
初始化队列:
- 入度为 0 的节点:节点 1。
-
处理队列:
-
从队列中取出节点 1,将其加入拓扑序列:
- 拓扑序列:
1
- 更新节点 2 的入度为 0,将其加入队列。
- 更新节点 3 的入度为 1。
- 拓扑序列:
-
从队列中取出节点 2,将其加入拓扑序列:
- 拓扑序列:
1, 2
- 更新节点 3 的入度为 0,将其加入队列。
- 拓扑序列:
-
从队列中取出节点 3,将其加入拓扑序列:
- 拓扑序列:
1, 2, 3
- 更新节点 4 的入度为 0,将其加入队列。
- 拓扑序列:
-
从队列中取出节点 4,将其加入拓扑序列:
- 拓扑序列:
1, 2, 3, 4
- 更新节点 5 的入度为 0,将其加入队列。
- 拓扑序列:
-
从队列中取出节点 5,将其加入拓扑序列:
- 拓扑序列:
1, 2, 3, 4, 5
- 更新节点 6 的入度为 0,将其加入队列。
- 拓扑序列:
-
从队列中取出节点 6,将其加入拓扑序列:
- 拓扑序列:
1, 2, 3, 4, 5, 6
- 拓扑序列:
-
结果:
最后的拓扑序列为 1 2 3 4 5 6
。这种排序满足了拓扑排序的要求,即对于每一条有向边 u→v,节点 u在节点 v之前。
四、基于深度优先搜索(DFS)的方法
DFS 实现拓扑排序的思路:
图的表示:
- 使用邻接表
graph[]
存储图,其中graph[u]
是一个包含所有从节点u
指向的节点列表。递归 DFS 遍历:
- 对每个未访问的节点
u
,执行深度优先搜索。- 在 DFS 中,先递归访问所有邻接节点,然后将当前节点压入栈中。
- 这样,栈中节点的顺序正好是拓扑排序的逆序。
输出拓扑排序:
- 最后,通过弹出栈中的节点来输出拓扑排序的结果,确保前驱节点总是出现在后继节点之前。
DFS 拓扑排序的特点:
- 检测环:如果在 DFS 中检测到已经在栈中的节点又被访问,则说明图中存在环。不过这个代码版本不包含环检测功能。
- 逆序输出:DFS 的拓扑排序结果是通过逆序栈输出完成的,这与 Kahn 算法不同。
代码:
#include <cstdio>
#include <vector>
#include <stack>
#include <cstring>
const int MAXN = 200005; // 最大节点数量
std::vector<int> graph[MAXN]; // 用于存储图的邻接表
bool visited[MAXN]; // 标记节点是否被访问过
std::stack<int> topoStack; // 用于存储拓扑排序结果的栈
// 向图中添加一条从u到v的有向边
void addEdge(int u, int v) {
graph[u].push_back(v);
}
// 使用DFS进行拓扑排序
void dfs(int u) {
visited[u] = true; // 标记节点u为已访问
// 遍历节点u的所有邻接节点
for (int v : graph[u]) {
if (!visited[v]) {
dfs(v); // 如果节点v未被访问,则递归访问它
}
}
topoStack.push(u); // 当前节点处理完毕,压入栈中
}
// 主函数
int main() {
int n, m, u, v;
scanf("%d%d", &n, &m); // 读取节点数n和边数m
// 初始化
memset(visited, false, sizeof(visited));
// 读取每条边,并构建图
while (m--) {
scanf("%d%d", &u, &v);
addEdge(u, v); // 添加一条从u到v的边
}
// 对所有节点进行DFS,如果节点未被访问过,则调用dfs
for (int i = 1; i <= n; i++) {
if (!visited[i]) {
dfs(i);
}
}
// 输出拓扑排序结果
while (!topoStack.empty()) {
printf("%d ", topoStack.top()); // 输出栈顶元素
topoStack.pop(); // 弹出栈顶元素
}
printf("\n");
return 0;
}
示例:
输入:
6 6
1 2
2 3
3 4
4 5
5 6
1 3
输入表示的图:
- 节点数: 6
- 边数: 6
- 有向边:
- 1 → 2
- 2 → 3
- 3 → 4
- 4 → 5
- 5 → 6
- 1 → 3
这个图的结构如下:
1 → 2 → 3 → 4 → 5 → 6
\____/
DFS 实现拓扑排序的步骤:
-
图的表示:
- 我们用邻接表
graph[]
存储这个图的结构。 - 对于节点 1,
graph[1] = {2, 3}
表示从 1 出发的两条边,分别指向 2 和 3。
- 我们用邻接表
-
开始 DFS:
- 初始化
visited[]
数组为false
,表示所有节点未被访问。 - 我们从节点 1 开始 DFS。
- 初始化
-
DFS 过程:
- 节点 1:首先访问节点 1。标记
visited[1] = true
。递归访问 1 的邻居节点 2。 - 节点 2:访问节点 2。标记
visited[2] = true
。递归访问 2 的邻居节点 3。 - 节点 3:访问节点 3。标记
visited[3] = true
。递归访问 3 的邻居节点 4。 - 节点 4:访问节点 4。标记
visited[4] = true
。递归访问 4 的邻居节点 5。 - 节点 5:访问节点 5。标记
visited[5] = true
。递归访问 5 的邻居节点 6。 - 节点 6:访问节点 6。标记
visited[6] = true
。节点 6 没有邻居,结束递归,节点 6 压入栈中。
栈内容: [6]
- 结束节点 5 的递归,节点 5 压入栈中。
栈内容: [6, 5]
- 结束节点 4 的递归,节点 4 压入栈中。
栈内容: [6, 5, 4]
- 结束节点 3 的递归,节点 3 压入栈中。
栈内容: [6, 5, 4, 3]
- 回到节点 2,节点 2 压入栈中。
栈内容: [6, 5, 4, 3, 2]
- 回到节点 1,此时访问节点 1 的另一个邻居节点 3,但 3 已被访问,无需重复访问。
- 节点 1 压入栈中。
栈内容: [6, 5, 4, 3, 2, 1]
- 节点 1:首先访问节点 1。标记
-
继续 DFS:
由于节点 1 的 DFS 已经覆盖了整个图的所有节点,因此不再需要对其余节点进行 DFS 处理。 -
输出拓扑排序:
最后,栈中的元素按照弹出顺序输出,即为拓扑排序结果:1 2 3 4 5 6
。