拓扑排序(思想+代码+示例)

目录

一、什么是有向无环图

1.1形象理解

1.2形式化定义

1.3DAG 的特点

1.4DAG 的实际应用

二、拓扑排序的思想是什么

2.1拓扑排序的核心思想

2.2拓扑排序的实现方法

2.3拓扑排序的应用

三、基于Kahn 算法的方法

四、基于深度优先搜索(DFS)的方法


一、什么是有向无环图

了解图谱排序前,我们先了解什么是有向无环图。

有向无环图,简称 DAG(Directed Acyclic Graph),是一种图论中的概念,它具有以下两个主要特征:

  1. 有向(Directed):

    图中的每条边都有一个方向,即每条边从一个节点指向另一个节点。例如,如果存在一条边 u→v,这表示从节点 u 指向节点 v。
  2. 无环(Acyclic):

    图中不存在任何包含多个节点的环路。也就是说,从一个节点出发,通过若干条边可以到达的其他节点中,不可能回到这个出发节点。

1.1形象理解

  • 有向意味着你在图中只能沿着边的方向“走”。就像一个单向街道网络,你只能按照指定方向行驶,不能逆行。

  • 无环意味着在这个图中,你无法从一个节点出发,经过若干个节点后,又回到起点。这就像你在一个单向街道网络中,不可能绕一圈回到起点。

1.2形式化定义

  • 一个图 G=(V,E)由一组节点 V和一组有向边 E组成。如果图 G 是有向无环图,那么对于图中的每条路径 v1→v2→⋯→vn,在路径中不可能出现 v1=vn的情况,即不可能形成一个从某个节点回到它自己的闭合路径。

1.3DAG 的特点

  1. 拓扑排序:DAG 是能够进行拓扑排序的唯一图结构。因为没有环,节点可以按照依赖关系排序。

  2. 依赖关系:DAG 常用于表示依赖关系。比如,任务调度、编译顺序、数据流图等,都可以使用 DAG 表示。

  3. 无向无环图(树)与 DAG 的关系

    一棵树是一个特殊的无向无环图。如果将树中的每一条无向边变为有向边,并且所有边的方向都从父节点指向子节点,那么它就成为了一个 DAG。

1.4DAG 的实际应用

  1. 任务调度:在操作系统中,任务的依赖关系通常用 DAG 表示,确保依赖的任务按顺序执行。

  2. 编译依赖:在编译大型软件时,不同模块之间可能存在依赖关系,DAG 可用于决定编译顺序。

  3. 版本控制:在分布式版本控制系统(如 Git)中,提交历史可以用 DAG 表示,分支和合并操作会创建或连接 DAG 中的节点。

  4. 数据流分析:在编译器优化中,DAG 用于表示表达式和变量之间的依赖关系,优化计算过程。

示例

考虑以下 DAG:

1 → 2 → 3 → 5
     ↘ 4 ↗
  • 这里,节点 1 指向节点 2,节点 2 指向节点 3 和 4,节点 4 和 3 都指向节点 5。
  • 这个图中不存在任何环,所以它是一个 DAG。
  • 拓扑排序可能的结果之一是:1 2 4 3 51 2 3 4 5,这两种排序都符合 DAG 的性质。

二、拓扑排序的思想是什么

知道了有向无环图之后,我们再来了解拓扑排序。

拓扑排序(Topological Sorting)的思想是一种针对有向无环图(DAG, Directed Acyclic Graph)的排序方法。它将图中的所有节点按照某种线性顺序排列,使得对于图中的每一条有向边 u→v,节点 u都排在节点 v 之前。

2.1拓扑排序的核心思想

  1. 依赖关系的排序

    拓扑排序的目标是对节点进行排序,确保在排序结果中,每个节点都在其所有依赖节点之后。也就是说,如果有一条有向边 u→v,则在排序结果中 u必须排在 v之前。
  2. 无环性

    拓扑排序只能应用于有向无环图(DAG)。如果图中存在环(即有回路),则无法完成拓扑排序,因为环中的节点彼此依赖,无法确定一个合适的排序顺序。
  3. 入度的利用

    入度表示指向某个节点的边的数量。拓扑排序从入度为 0 的节点开始,因为这些节点没有依赖关系,可以首先被处理。将一个入度为 0 的节点加入排序后,删除与该节点相关的边,并更新其他节点的入度。对于每一个入度变为 0 的节点,将其加入到下一轮排序中。
  4. 递归消除依赖

    通过不断移除入度为 0 的节点及其边,图中的节点和边逐渐减少,最终所有节点都被排序,或检测到剩下的节点形成了环。

2.2拓扑排序的实现方法

拓扑排序常用两种方法实现:

  1. Kahn 算法(基于入度的算法)

    这种方法直接使用入度的概念:
    • 如果所有节点都被处理,则得到有效的拓扑排序;如果有节点未被处理,说明图中存在环。
    • 重复步骤 2 和 3,直到队列为空。
    • 删除该节点的所有出边,更新目标节点的入度。如果某个节点的入度变为 0,则将其加入队列。
    • 从队列中依次取出节点,将其加入拓扑排序结果。
    • 找出所有入度为 0 的节点,将它们放入一个队列中。
  2. DFS(深度优先搜索)算法

    通过深度优先搜索(DFS)实现拓扑排序:
    • 这种方法也可以检测环,如果在 DFS 过程中遇到已经在访问中的节点,则说明图中存在环。
    • 最终栈中的节点顺序即为拓扑排序的结果。
    • 当 DFS 完成对某个节点的访问时,将该节点加入一个栈中。
    • 对图中的每个节点执行 DFS,按照完成时间的逆序记录节点。

2.3拓扑排序的应用

  • 任务调度:当某些任务之间存在依赖关系时,拓扑排序可以确定任务的执行顺序。
  • 课程安排:如果某些课程有先修课要求,拓扑排序可以用于决定课程的学习顺序。
  • 构建系统:编译或构建软件项目时,拓扑排序可以用于确定模块或文件的编译顺序。

三、基于Kahn 算法的方法

Kahn 算法的核心思想

  1. 初始化入度表:计算并存储每个节点的入度,即有多少条边指向该节点。

  2. 寻找入度为 0 的节点:将所有入度为 0 的节点加入一个队列,因为这些节点没有前置依赖,可以最先被处理。

  3. 逐步删除节点

    • 从队列中取出一个入度为 0 的节点,将其添加到拓扑排序结果中。
    • 删除该节点的所有出边(即将其邻接节点的入度减 1)。
    • 如果某个邻接节点的入度因此减为 0,则将其加入队列。
  4. 检查排序结果

    • 重复上述步骤,直到队列为空。如果最终拓扑排序结果包含了所有节点,则排序成功。
    • 如果图中存在环,则某些节点永远无法使其入度变为 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. 计算入度:

    • 节点 1:入度 0
    • 节点 2:入度 1(来自 1)
    • 节点 3:入度 2(来自 1, 2)
    • 节点 4:入度 1(来自 3)
    • 节点 5:入度 1(来自 4)
    • 节点 6:入度 1(来自 5)
  2. 初始化队列:

    • 入度为 0 的节点:节点 1。
  3. 处理队列:

    • 从队列中取出节点 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 实现拓扑排序的思路

  1. 图的表示

    • 使用邻接表 graph[] 存储图,其中 graph[u] 是一个包含所有从节点 u 指向的节点列表。
  2. 递归 DFS 遍历

    • 对每个未访问的节点 u,执行深度优先搜索。
    • 在 DFS 中,先递归访问所有邻接节点,然后将当前节点压入栈中。
    • 这样,栈中节点的顺序正好是拓扑排序的逆序。
  3. 输出拓扑排序

    • 最后,通过弹出栈中的节点来输出拓扑排序的结果,确保前驱节点总是出现在后继节点之前。

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 实现拓扑排序的步骤

  1. 图的表示

    • 我们用邻接表 graph[] 存储这个图的结构。
    • 对于节点 1,graph[1] = {2, 3} 表示从 1 出发的两条边,分别指向 2 和 3。
  2. 开始 DFS

    • 初始化 visited[] 数组为 false,表示所有节点未被访问。
    • 我们从节点 1 开始 DFS。
  3. 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]

  4. 继续 DFS

    由于节点 1 的 DFS 已经覆盖了整个图的所有节点,因此不再需要对其余节点进行 DFS 处理。
  5. 输出拓扑排序

    最后,栈中的元素按照弹出顺序输出,即为拓扑排序结果:1 2 3 4 5 6
  • 26
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
拓扑排序是一种对有向无环图进行排序的算法,它可以用来确定前驱关系,验证图中是否有环等。拓扑排序的基本思想是将图中的节点按照一定的顺序排列,使得所有的有向边从排在前面的节点指向排在后面的节点。在实际应用中,拓扑排序常常被用来解决任务调度、编译顺序等问题。 在C++中,可以使用DFS算法进行拓扑排序。具体实现方法是,对于每个节点,先将其标记为正在访问中,然后遍历其所有的邻居节点,如果邻居节点已经被访问过了,就说明存在环,返回false;如果邻居节点还没有被访问过,就递归地对其进行访问。当所有的邻居节点都被访问完毕后,将该节点标记为已经访问过,并将其加入到拓扑序列中。最后,将拓扑序列倒序输出即可。 下面是一个使用DFS算法进行拓扑排序的C++代码示例: ``` const int maxn = 100; int c[maxn]; int topo[maxn], t, n; int G[maxn][maxn]; bool dfs(int u) { c[u] = -1; for(int v = 0; v < n; v++) { if(G[u][v]) { if(c[v] < 0) return false; else if(!c[v] && !dfs(v)) return false; } } c[u] = 1; topo[--t] = u; return true; } bool topoSort() { t = n; memset(c,0,sizeof(c)); for(int u = 0; u < n; u++){ if(!c[u] && !dfs(u)) return false; } return true; } int main() { char ch[100] = {'a', 'b', 'c', 'd'}; n = 4; G[0][1] = 1; G[2][1] = 1; G[3][2] = 1; if(!topoSort()){ printf("无法拓扑排序\n"); return 0; } for(int i = 0; i < 4; i++){ printf("%c ", ch[topo[i]]); } printf("\n"); } ```
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值