在图论中,拓扑排序(Topological Sort)是对 有向无环图 (Directed Acyclic Graph, DAG) 的顶点进行排序,使得对于每一条有向边 (u, v),节点 u 都排在节点 v 的前面。这种排序在很多领域都有应用,比如任务调度(任务依赖关系)、课程安排(先修课程)、编译器依赖解析等。
逆拓扑排序(Reverse Topological Sort)则是得到一个序列,其中对于每一条有向边 (u, v),节点 v 都排在节点 u 的前面。
本文将详细介绍如何使用深度优先搜索 (DFS) 算法,通过 C/C++ 来实现拓扑排序和逆拓扑排序,并提供相应的代码示例。
前提知识
-
基本的 C/C++ 编程知识 (vector, stack, 递归等)
-
图的基本概念 (顶点、有向边、邻接表表示法)
-
深度优先搜索 (DFS) 算法基础
拓扑排序 (Topological Sort) via DFS
核心思想:
基于 DFS 的拓扑排序利用了 DFS 完成访问一个节点的时间点。在一个 DAG 中,如果有一条边 u -> v,那么 DFS 访问 v 的过程一定会在访问 u 的过程结束之前结束。换句话说,完成时间 较晚的节点,在拓扑序列中应该排在前面。
我们可以使用一个栈来记录节点的完成顺序。当一个节点的 DFS 过程(包括其所有后代节点的访问)完全结束时,将该节点压入栈中。最后,栈中元素的弹出顺序就是一种合法的拓扑排序。
算法步骤:
-
初始化:
-
创建一个 visited 数组(或 vector)来跟踪每个节点的状态:UNVISITED(未访问), VISITING(正在访问), VISITED(已访问)。初始化所有节点为 UNVISITED。
-
创建一个栈 resultStack 来存储拓扑排序的结果。
-
选择一种图的表示方法,通常使用邻接表 vector<vector<int>> adj。
-
-
DFS 遍历:
-
遍历所有节点(从 0 到 n-1,或 1 到 n)。
-
如果当前节点 u 的状态是 UNVISITED,则调用 dfsTopoSort(u)。
-
-
dfsTopoSort(u) 函数:
-
将节点 u 的状态标记为 VISITING。
-
遍历 u 的所有邻居 v:
-
如果 v 的状态是 VISITING,说明遇到了一个环(从 v 出发又回到了正在访问的 u 的祖先链上的 v),图中存在环,无法进行拓扑排序,返回错误(或抛出异常)。
-
如果 v 的状态是 UNVISITED,则递归调用 dfsTopoSort(v)。如果递归调用返回错误(检测到环),则本层也返回错误。
-
-
当 u 的所有邻居都已被访问(或递归调用已返回),说明以 u 为起点的 DFS 过程已完成。
-
将节点 u 的状态标记为 VISITED。
-
将节点 u 压入 resultStack。
-
返回成功。
-
-
获取结果:
-
如果所有 DFS 调用都成功完成(没有检测到环),则从 resultStack 中依次弹出元素,得到的序列就是一种拓扑排序。
-
为什么需要三种状态 (UNVISITED, VISITING, VISITED)?
-
UNVISITED: 节点还未开始访问。
-
VISITING: 节点已经开始访问,但其所有邻居(及后代)的访问尚未全部完成。如果在访问某个节点的邻居时,发现该邻居的状态是 VISITING,则表明存在一个回边 (back edge),即图中存在环。
-
VISITED: 节点及其所有后代都已访问完毕。再次遇到 VISITED 状态的节点是正常的,表示访问到了一个已经被完全处理过的子图。
C++ 实现 (拓扑排序):
#include <iostream>
#include <vector>
#include <stack>
#include <algorithm> // 包含 reverse
using namespace std;
enum VisitStatus { UNVISITED, VISITING, VISITED };
// DFS 函数用于拓扑排序
// adj: 图的邻接表
// u: 当前访问的节点
// visited: 节点访问状态数组
// resultStack: 存储结果的栈
bool dfsTopoSort(int u, const vector<vector<int>>& adj, vector<VisitStatus>& visited, stack<int>& resultStack) {
visited[u] = VISITING;
for (int v : adj[u]) {
if (visited[v] == VISITING) {
// 检测到环
cerr << "Error: Graph contains a cycle. Topological sort not possible." << endl;
return false;
}
if (visited[v] == UNVISITED) {
if (!dfsTopoSort(v, adj, visited, resultStack)) {
return false; // 如果下游检测到环,则直接返回
}
}
// 如果 v 是 VISITED,则忽略,继续下一个邻居
}
visited[u] = VISITED; // 标记为已访问(完成)
resultStack.push(u); // 将完成访问的节点压栈
return true;
}
// 主拓扑排序函数
// n: 节点数量
// adj: 图的邻接表
// sortedResult: 存储拓扑排序结果的 vector (如果成功)
bool topologicalSort(int n, const vector<vector<int>>& adj, vector<int>& sortedResult) {
stack<int> resultStack;
vector<VisitStatus> visited(n, UNVISITED); // 假设节点编号从 0 到 n-1
for (int i = 0; i < n; ++i) {
if (visited[i] == UNVISITED) {
if (!dfsTopoSort(i, adj, visited, resultStack)) {
return false; // 检测到环,排序失败
}
}
}
// 从栈中弹出元素得到拓扑序列
sortedResult.clear();
while (!resultStack.empty()) {
sortedResult.push_back(resultStack.top());
resultStack.pop();
}
// 栈弹出的是逆序,如果需要正序(通常意义上的拓扑序)可以不用反转
// 这里保持栈弹出顺序,即 u 完成时间晚的排在前面
// 如果需要 u -> v,u 排在 v 前面的传统顺序,则需要反转
// std::reverse(sortedResult.begin(), sortedResult.end()); // 取消注释以获得 u 在 v 前的顺序
return true; // 排序成功
}
逆拓扑排序 (Reverse Topological Sort) via DFS
核心思想:
逆拓扑排序的目标是得到一个序列,使得对于边 u -> v,v 排在 u 的前面。使用 DFS 实现时,我们可以在 刚开始访问一个节点时 就将其加入结果列表。这样可以保证一个节点总是在其所有依赖它的节点(即通过边指向它的节点)之前被加入列表。
算法步骤:
-
初始化:
-
同拓扑排序,需要 visited 数组(三状态用于判环)和邻接表 adj。
-
创建一个 vector<int> resultList 来直接存储逆拓扑排序的结果。
-
-
DFS 遍历:
-
遍历所有节点。
-
如果当前节点 u 的状态是 UNVISITED,则调用 dfsReverseTopoSort(u)。
-
-
dfsReverseTopoSort(u) 函数:
-
将节点 u 的状态标记为 VISITING。
-
将节点 u 加入 resultList 的末尾。 (关键区别)
-
遍历 u 的所有邻居 v:
-
如果 v 的状态是 VISITING,检测到环,返回错误。
-
如果 v 的状态是 UNVISITED,则递归调用 dfsReverseTopoSort(v)。如果递归调用返回错误,本层也返回错误。
-
-
将节点 u 的状态标记为 VISITED。
-
返回成功。
-
-
获取结果:
-
如果所有 DFS 调用都成功,resultList 中存储的就是一种逆拓扑排序。
-
C++ 实现 (逆拓扑排序):
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
// enum VisitStatus { UNVISITED, VISITING, VISITED }; // 复用之前的枚举
// DFS 函数用于逆拓扑排序
// adj: 图的邻接表
// u: 当前访问的节点
// visited: 节点访问状态数组
// resultList: 存储结果的 vector
bool dfsReverseTopoSort(int u, const vector<vector<int>>& adj, vector<VisitStatus>& visited, vector<int>& resultList) {
visited[u] = VISITING;
resultList.push_back(u); // 在访问开始时就加入结果列表
for (int v : adj[u]) {
if (visited[v] == VISITING) {
cerr << "Error: Graph contains a cycle. Reverse topological sort not possible." << endl;
return false;
}
if (visited[v] == UNVISITED) {
if (!dfsReverseTopoSort(v, adj, visited, resultList)) {
return false;
}
}
}
visited[u] = VISITED;
return true;
}
// 主逆拓扑排序函数
// n: 节点数量
// adj: 图的邻接表
// sortedResult: 存储逆拓扑排序结果的 vector (如果成功)
bool reverseTopologicalSort(int n, const vector<vector<int>>& adj, vector<int>& sortedResult) {
sortedResult.clear();
vector<VisitStatus> visited(n, UNVISITED);
for (int i = 0; i < n; ++i) {
if (visited[i] == UNVISITED) {
if (!dfsReverseTopoSort(i, adj, visited, sortedResult)) {
return false; // 检测到环,排序失败
}
}
}
return true; // 排序成功
}
示例与测试
int main() {
int n = 6; // 节点数量 (0 to 5)
vector<vector<int>> adj(n);
// 构建一个 DAG:
// 5 -> 2
// 5 -> 0
// 4 -> 0
// 4 -> 1
// 2 -> 3
// 3 -> 1
adj[5].push_back(2);
adj[5].push_back(0);
adj[4].push_back(0);
adj[4].push_back(1);
adj[2].push_back(3);
adj[3].push_back(1);
cout << "Graph Adjacency List:" << endl;
for(int i = 0; i < n; ++i) {
cout << i << ": ";
for(int neighbor : adj[i]) {
cout << neighbor << " ";
}
cout << endl;
}
cout << endl;
vector<int> topoOrder;
cout << "Attempting Topological Sort..." << endl;
if (topologicalSort(n, adj, topoOrder)) {
cout << "Topological Sort (one possible order, finish time based): ";
// 栈弹出顺序:先完成的在后面
for (int node : topoOrder) {
cout << node << " "; // 输出 4 5 2 3 1 0 (或 5 4 2 3 1 0, 取决于遍历顺序)
// 如果需要 u->v, u 在前的传统顺序,需要反转结果
}
cout << endl;
// 反转以获得 u -> v, u 在 v 前的顺序
reverse(topoOrder.begin(), topoOrder.end());
cout << "Topological Sort (traditional order u before v): ";
for (int node : topoOrder) {
cout << node << " "; // 输出 0 1 3 2 4 5 (或 0 1 3 2 5 4)
}
cout << endl;
}
cout << endl;
vector<int> reverseTopoOrder;
cout << "Attempting Reverse Topological Sort..." << endl;
if (reverseTopologicalSort(n, adj, reverseTopoOrder)) {
cout << "Reverse Topological Sort (one possible order): ";
for (int node : reverseTopoOrder) {
cout << node << " "; // 输出 0 1 2 3 4 5 (或 4 0 1 5 2 3 等,取决于遍历顺序)
// 保证了对于 u->v, v 在 u 前面
}
cout << endl;
}
// 测试带环的情况 (取消注释下面两行)
// adj[1].push_back(5); // 添加一条回边 1 -> 5, 形成 5->2->3->1->5 环
// cout << "\nTesting with a cycle (1 -> 5 added):" << endl;
// if (!topologicalSort(n, adj, topoOrder)) { }
// if (!reverseTopologicalSort(n, adj, reverseTopoOrder)) { }
return 0;
}
复杂度分析
-
时间复杂度: O(V + E),其中 V 是顶点数,E 是边数。因为每个顶点和每条边在 DFS 过程中都只会被访问常数次。
-
空间复杂度: O(V),主要用于存储 visited 数组和递归调用栈(最坏情况下深度为 V)。存储结果的栈或列表也需要 O(V) 空间。
总结
使用 DFS 实现拓扑排序和逆拓扑排序是一种直观且常用的方法。关键在于理解 DFS 的访问顺序和完成时间,并利用栈或列表来正确地记录排序结果。同时,通过维护三种访问状态可以有效地检测图中是否存在环,这是拓扑排序能够进行的前提。逆拓扑排序的实现则是在 DFS 访问开始时记录节点,巧妙地达到了相反的排序效果。
希望这篇博客能帮助你理解并掌握这两种基于 DFS 的图排序算法!