C/C++ 运用 DFS 实现拓扑排序与逆拓扑排序详解

在图论中,拓扑排序(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 过程(包括其所有后代节点的访问)完全结束时,将该节点压入栈中。最后,栈中元素的弹出顺序就是一种合法的拓扑排序。

算法步骤:

  1. 初始化:

    • 创建一个 visited 数组(或 vector)来跟踪每个节点的状态:UNVISITED(未访问), VISITING(正在访问), VISITED(已访问)。初始化所有节点为 UNVISITED。

    • 创建一个栈 resultStack 来存储拓扑排序的结果。

    • 选择一种图的表示方法,通常使用邻接表 vector<vector<int>> adj。

  2. DFS 遍历:

    • 遍历所有节点(从 0 到 n-1,或 1 到 n)。

    • 如果当前节点 u 的状态是 UNVISITED,则调用 dfsTopoSort(u)。

  3. dfsTopoSort(u) 函数:

    • 将节点 u 的状态标记为 VISITING。

    • 遍历 u 的所有邻居 v:

      • 如果 v 的状态是 VISITING,说明遇到了一个环(从 v 出发又回到了正在访问的 u 的祖先链上的 v),图中存在环,无法进行拓扑排序,返回错误(或抛出异常)。

      • 如果 v 的状态是 UNVISITED,则递归调用 dfsTopoSort(v)。如果递归调用返回错误(检测到环),则本层也返回错误。

    • 当 u 的所有邻居都已被访问(或递归调用已返回),说明以 u 为起点的 DFS 过程已完成。

    • 将节点 u 的状态标记为 VISITED。

    • 将节点 u 压入 resultStack。

    • 返回成功。

  4. 获取结果:

    • 如果所有 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 实现时,我们可以在 刚开始访问一个节点时 就将其加入结果列表。这样可以保证一个节点总是在其所有依赖它的节点(即通过边指向它的节点)之前被加入列表。

算法步骤:

  1. 初始化:

    • 同拓扑排序,需要 visited 数组(三状态用于判环)和邻接表 adj。

    • 创建一个 vector<int> resultList 来直接存储逆拓扑排序的结果。

  2. DFS 遍历:

    • 遍历所有节点。

    • 如果当前节点 u 的状态是 UNVISITED,则调用 dfsReverseTopoSort(u)。

  3. dfsReverseTopoSort(u) 函数:

    • 将节点 u 的状态标记为 VISITING。

    • 将节点 u 加入 resultList 的末尾。 (关键区别)

    • 遍历 u 的所有邻居 v:

      • 如果 v 的状态是 VISITING,检测到环,返回错误。

      • 如果 v 的状态是 UNVISITED,则递归调用 dfsReverseTopoSort(v)。如果递归调用返回错误,本层也返回错误。

    • 将节点 u 的状态标记为 VISITED。

    • 返回成功。

  4. 获取结果:

    • 如果所有 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 的图排序算法!


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值