当前位置: 首页 > news >正文

【数据结构】深入浅出图论:拓扑排序算法全面解析与应用实践

拓扑排序

  • 导读
  • 一、拓扑排序
    • 1.1 基本概念
      • 1.1.1 AOV网
        • 基本定义
        • 定义理解
      • 1.1.2 拓扑排序
        • 基本定义
        • 定义理解
    • 1.2 算法思想
    • 1.3 算法实现
      • 1.3.1 C语言代码
      • 1.3.2 代码解释
    • 1.4 算法评价
  • 二、逆拓扑排序
  • 结语

图的基本应用

导读

大家好,很高兴又和大家见面啦!!!

在上一篇内容中,我们探讨了图论的基础概念和应用。今天,我们将深入探讨一个在图论中极为重要的概念——拓扑排序,它在工程调度、任务安排和依赖关系管理中有着广泛的应用。

在开始拓扑排序之前,让我们先回顾一下有向无环图(DAG图) 的核心特性:

  • 有向性:图中的边具有明确的方向性,表示一种单向关系

  • 无环性:图中不存在任何循环路径,即不可能从某个顶点出发沿着有向边最终又回到该顶点

  • 传递性:如果存在路径从顶点A到顶点B,再从B到C,则A与C之间存在间接的前驱后继关系

DAG图的这些特性使其成为表示具有先后顺序关系的活动的理想工具,这正是AOV网的基础。

在实际工程和任务调度中,我们经常需要处理各种具有依赖关系的活动。例如:

  • 软件编译过程中的模块依赖关系

  • 课程学习的先修条件

  • 项目开发中的任务先后顺序

拓扑排序正是解决这类问题的关键算法:它将DAG图中的所有顶点排列成一个线性序列,使得对于任何一条有向边 <u,v><u, v><u,v>,u在序列中都出现在v之前。

通过本文,您将深入了解:

  • AOV网如何用DAG图表示活动及其依赖关系

  • 拓扑排序的核心算法思想及其实现方式

  • 如何通过代码实现拓扑排序算法

  • 拓扑排序与逆拓扑排序的关系

让我们开始探索拓扑排序的奥秘,理解这一强大工具如何帮助我们理清复杂工程中的依赖关系,确保任务按照正确的顺序执行!

一、拓扑排序

1.1 基本概念

1.1.1 AOV网

基本定义

AOV网 (Activity On Vertex NetWork, 用顶点表示活动的网):在有向无环图(DAG图)中,每个顶点都表示一个活动,有向边 <Vi,Vj><V_i, V_j><Vi,Vj> 表示活动 ViV_iVi 必须先于活动 VjV_jVj 进行的这样一种关系,整个图表示一个完整的工程,则将这种 DAG图 称为 顶点表示活动的网络 ,简称 AOV网.

定义理解
买苹果
洗苹果
吃苹果
削苹果皮

上图就是通过 DAG图 表示的吃苹果这个工程:我们如果想吃苹果了,首先我们得去买苹果,买完苹果后,我们需要把苹果洗一下,之后可以根据个人的喜好选择是否去皮,最后我们就可以吃上苹果了。

在整个工程中,每个顶点都代表了这个工程中的一种活动,并且每个活动的先后顺序都是通过有向边进行连接,即我们只能先买完了苹果,才能洗苹果,不可能先洗苹果再买苹果。

买苹果
洗苹果
吃苹果
削苹果皮

在上图中,我们可以看到存在着一个环路:买苹果与洗苹果这两个活动所构成的环路,放在整个工程中,就表示,我们在买苹果前需要先洗苹果,我在洗苹果前需要先买苹果,由此我们不难看出,整个工程在实施的过程中,就陷入了一个死循环——到底是先买苹果还是先洗苹果?

因此,为了避免出现这种问题,AOV网 一定是一个 DAG图,即:从顶点 ViV_iVi 到顶点 VjV_jVj 的有向边 <Vi,Vj><V_i, V_j><Vi,Vj> 所表示的含义为:活动 ViV_iVi 一定是活动 VjV_jVj 的直接前驱,活动 VjV_jVj 一定是活动 ViV_iVi 的直接后继,这种前驱与后继的关系具有传递性,即:

买苹果
洗苹果
削苹果皮
吃苹果

在上图中,买苹果一定是洗苹果的直接前驱,是削苹果皮的前驱,是吃苹果的前驱;吃苹果一定是削苹果皮的直接后继,是洗苹果的后继,是买苹果的后继。

由此可知,在 AOV网 中,任何活动 ViV_iVi 都不可能以它自己作为自己的前驱或者后继,即不可能存在环路

1.1.2 拓扑排序

基本定义

拓扑排序:在图论中,由一个有向无环图的顶点组成的序列,当且仅当满足下列条件时,称为该图的一个拓扑排序:

  1. 每个顶点出现且只出现一次
  2. 若顶点 AAA 在序列中排在顶点 BBB 的前面,则图中不存在从 BBBAAA 的路径

或定义为:拓扑排序是对 DAG图 中各顶点的一种排序,它使得若存在一条从顶点 AAA 到顶点 BBB 的路径,则在排序中 BBB 出现在 AAA 的后面。

每个AOV网都有一个或多个拓扑排序序列

定义理解

拓扑排序实际上就是指的完成一个工程时,工程中各个活动的先后顺序。为了方便大家理解,我们还是以吃苹果这个工程为例:

买苹果
洗苹果
吃苹果
削苹果皮

在这个工程中,总共有4个顶点,所谓的拓扑排序就是需要按照事情的先后顺序对这4个顶点进行排序,因此,其拓扑排序序列只有一个:

买苹果
洗苹果
削苹果皮
吃苹果

这时有朋友可能就会有疑问了,为什么 买苹果–>洗苹果–>吃苹果 这条路径不属于拓扑排序呢?

这是因为,拓扑排序的排序对象是DAG图中的所有顶点,当我们按照: 买苹果–>洗苹果–>吃苹果 这条路径进行排序时,那么剩余的削苹果皮这个活动就无法参与到排序中,如果我们直接将其添加到吃苹果的后面,即:买苹果–>洗苹果–>吃苹果–>削苹果皮,那么这里就存在以下问题:

  • 从AOV网中进行分析:图中并不存在吃苹果–>削苹果皮这条路径
  • 从实际意义进行分析:苹果都吃了,我们还削哪门子的苹果皮?

这时可能有盆友说,我可以边吃边啃苹果皮,这不也是成立的吗?虽然这种事情我也做过,但是如果我们将这条路径放入到 DAG图中,我们就会得到下面这个图:

买苹果
洗苹果
吃苹果
削苹果皮

可以看到此时的图中,就已经存在环了,此时的图,就已经不属于DAG图了,因此,该图自然就不存在拓扑排序了。

1.2 算法思想

在获取一个 DAG图 的拓扑排序时,我们需要优先确定的是活动的起始顶点,以吃苹果这个工程为例:

买苹果
洗苹果
吃苹果
削苹果皮

在这个工程中,起始顶点很显然是买苹果,这是因为该顶点不存在前驱结点,以图的角度来说,那就是:该顶点的入度为0

当我们将该顶点去掉后,其出度也需要一并去掉,即:

洗苹果
吃苹果
削苹果皮

此时的图中,可以看到洗苹果这个顶点不再存在前驱顶点,因此,该顶点为该图中的起始顶点,这时我们继续去掉该顶点及其出度,我们就得到了下图:

吃苹果
削苹果皮

可以看到,此时的图中,入度为0的顶点为:削苹果皮,因此该顶点为此图的起始顶点,将其去掉后,我们就得到了下图:

吃苹果

可以看到,此时的图中,入度为0的顶点为:吃苹果,因此该顶点为此图的起始顶点,将其去掉后,图就变成了空图,按照顶点去掉的先后顺序,我们就得到了该图的拓扑排序:

  • 买苹果–>洗苹果–>削苹果皮–>吃苹果

从上述的过程,我们可以总结出拓扑排序的获取步骤:

  1. 获取图中,入度为0的顶点
  2. 去掉该顶点及其出度
  3. 重复上述步骤,直到图中不存在顶点入度为0或图为空图

这里可能有朋友会奇怪,为什么是不存在顶点的入度为0?这里我们以下图为例:

买苹果
洗苹果
吃苹果
削苹果皮

当我们按照上述步骤执行时,我们在去掉洗苹果这个顶点后,我们就得到了下图:

吃苹果
削苹果皮

可以看到,由于剩余的两个顶点成环,因此两个顶点的入度与出度均为1,所以图中不存在入度为0的顶点。

在理解了这点后,我们就可以理解,对于判断入度为0这个条件,实际上就是在判断该图是否存在环路。

1.3 算法实现

1.3.1 C语言代码

这里我们采用邻接矩阵的方式来实现拓扑排序:

int S[MAXVERNUM];						// 记录顶点的栈
int indegree[MAXVERNUM];				// 记录各顶点的入度
int Sort_list[MAXVERNUM];				// 排序数组
// 邻接矩阵实现
bool TopologicalSort(Graph G) {InitStack(S);						// 初始化栈int top = 0;						// 栈顶// 找工程中的起始顶点for (int i = 0; i < G.Mgraph.ver_num; i++) {if (indegree[i] == 0) {			// 当前顶点入度为0Push(S, i);					// 该顶点入栈top += 1;}}int count = 0;						// 已排序的顶点数while (!IsEmpty(S) || count) {int i = Pop(S, top);			// 栈顶元素出栈top -= 1;Sort_list[count] = i;			// 排序栈顶元素count += 1;// 删除该顶点对应的所有出度for (int j = 0; j < G.Mgraph.ver_num; j++) {if (G.Mgraph.edge[i][j]) {	// 判断是否存在弧<i, j>indegree[j] -= 1;		// 该顶点入度-1}if (indegree[j] == 0) {		// 该顶点入度为0Push(S, j);				// 该顶点入栈}}}return count == G.Mgraph.ver_num;	// 判断是否完成所有顶点的排序
}

1.3.2 代码解释

	InitStack(S);						// 初始化栈int top = 0;						// 栈顶

整个排序的过程,我们是借用栈来实现,通过记录顶点编号的栈来判断图中是否存在入度为0的顶点;

	// 找工程中的起始顶点for (int i = 0; i < G.Mgraph.ver_num; i++) {if (indegree[i] == 0) {			// 当前顶点入度为0Push(S, i);					// 该顶点入栈top += 1;}}

在算法的第一个for循环中,我们主要进行的是寻找图中起始入度为0的顶点,并对其通过栈进行记录;

	int count = 0;						// 已排序的顶点数while (!IsEmpty(S) || count) {int i = Pop(S, top);			// 栈顶元素出栈top -= 1;Sort_list[count] = i;			// 排序栈顶元素count += 1;// 删除该顶点对应的所有出度for (int j = 0; j < G.Mgraph.ver_num; j++) {if (G.Mgraph.edge[i][j]) {	// 判断是否存在弧<i, j>indegree[j] -= 1;		// 该顶点入度-1}if (indegree[j] == 0) {		// 该顶点入度为0Push(S, j);				// 该顶点入栈}}}

在完成对起始顶点的记录后,我们通过变量count来记录当前完成排序的顶点数量,通过判断栈是否为空,来控制循环是否结束:

  • 当栈为空,说明图中不存在入度为0的顶点,此时会存在两种情况
    • 图中的顶点均完成排序,循环结束时,count 与图中的顶点数相同
    • 图中存在未排序的顶点,即图中存在环,循环结束时,count 小于图中的顶点数

整个循环的过程,算法一直在重复进行:

  • 将栈顶元素出栈
  • 对出栈元素进行排序
  • 删除该元素的所有出入
  • 对新的入度为0的元素进行入栈

由于这里我们是通过邻接矩阵实现,因此我们在对其出度进行删除时,实际上是通过遍历以顶点 i 为起始点的矩阵,判断其对应的分区中是否存在以顶点 j 为终点的弧,若存在,入度数组 indegree 中其顶点 j 所对应的入度数量-1;

若我们通过邻接表实现的话,我们只需要遍历顶点 i 的边表即可,将边表中存在的顶点 j 所对应的入度数量-1;

整体的视线并不复杂,这里我就不再赘述。为了方便大家更好的理解上述代码,这里我给大家展示一下头文件中的内容:

typedef char VerType;
typedef int EdgeType;
typedef int InfoType;
#define MAXVERNUM 5
typedef struct MatrixGraph {VerType verlist[MAXVERNUM];					// 顶点表EdgeType edge[MAXVERNUM][MAXVERNUM];		// 边矩阵InfoType info;								// 边权值int ver_num;								// 顶点数int edge_num;								// 边数
}MG;
typedef struct ArcNode {int adjver;									// 邻接顶点struct ArcNode* nextarc;					// 下一条弧指针InfoType info;								// 边权值
}ANode;
typedef struct VerNode {VerType data;								// 顶点信息ANode* firtarc;								// 第一条弧指针
}VNode;
typedef struct AdjListGraph {VNode verlist[MAXVERNUM];					// 顶点表int vernum;									// 顶点数int arcnum;									// 弧数
}ALG;
typedef struct Graph {MG Mgraph;									// 邻接矩阵ALG ALGraph;								// 邻接表
}Graph;

感兴趣的朋友可以自己动手实现一下。

1.4 算法评价

从上述的代码实现我们不难看出,当我们采用邻接矩阵实现时,外层循环需要完成对所有顶点的遍历,内层循环同样也要完成对所有顶点的遍历,因此对应的时间复杂度为:O(∣V∣2)O(|V|^2)O(V2)

若我们改用邻接表实现的话,内层循环我们只需要对该顶点对应的边进行遍历即可,即对应的时间复杂度为:O(∣V∣+∣E∣)O(|V| + |E|)O(V+E)

在整个实现的过程中,我们需要额外开辟3个大小与顶点数相同,或者与最大顶点数相同的整型数组空间,即空间复杂度为:O(∣V∣)O(|V|)O(V)

二、逆拓扑排序

在了解了何为拓扑排序后,逆拓扑排序实际上就是将原先的入度改为出度,在算法实现的过程中,通过判断出度为0的顶点来依次获取顶点序列, 这里我就不再多加赘述,作简单的了解即可,以吃苹果这个工程为例:

买苹果
洗苹果
吃苹果
削苹果皮

其拓扑排序与逆拓扑排序分别为:

  • 拓扑排序:买苹果 --> 洗苹果 --> 削苹果皮 --> 吃苹果
  • 逆拓扑排序:吃苹果 --> 削苹果皮 --> 洗苹果 --> 买苹果

结语

通过本文的系统学习,相信大家已经对拓扑排序的核心概念和实现方法有了扎实的理解。让我们简单回顾一下本文的关键知识点:

📚 本文核心内容总结

  1. AOV网与DAG图的密切关系:AOV网建立在有向无环图(DAG图)基础上,用顶点表示活动,用有向边清晰定义活动间的依赖关系,其无环特性确保了工程不会陷入死循环。

  2. 拓扑排序的算法精髓:通过不断选择入度为0的顶点,逐步构建满足所有前置条件的活动序列,既确定了执行顺序,又能有效检测图中的环路。

  3. 实践与应用价值

    • 邻接矩阵实现:时间复杂度O(V²)

    • 邻接表实现:优化至O(V + E)

    • 空间复杂度稳定在O(V)

🔜 下篇预告:关键路径分析

虽然拓扑排序解决了"执行顺序"的问题,但实际工程管理中我们更关心:“完成整个工程的最短时间是多少?哪些任务是影响工期的关键环节?”

这就是我们下一篇要深入探讨的关键路径(Critical Path)分析。通过关键路径方法,您将能够:

  • ⏰ 精确计算工程的最短完成时间

  • 🔍 识别关键任务(任何延迟都会影响总工期)

  • 📊 优化资源分配,提高工程效率

期待在下一篇文章中与您继续探索图论的实用价值!

觉得本文对你有帮助吗?

👍 点赞支持一下呗!——每一个赞都是我持续创作的动力
👀 关注不迷路——获取更多图论与算法干货
📁 收藏常复习——方便需要时快速查找
🔄 转发分享爱——帮助更多需要的朋友

💬 评论留言等你来——说说你的想法:

  • 在实际项目中用过拓扑排序吗?

  • 对关键路径分析有什么期待?

  • 还有什么想要了解的图论知识?

期待在评论区看到你的留言和建议!我们下期再见~

http://www.dtcms.com/a/391433.html

相关文章:

  • 全矩阵布局+硬核技术,中资机器人管家重塑智能服务新格局
  • Linux进程间通信(IPC)完全指南:从管道到共享内存的系统性学习
  • vllm安装使用及问题
  • redis配置与优化(2)
  • 苹果开发者账号( Apple Developer)登录出现:你的 Apple ID 暂时不符合使用此应用程序的条件(您的apple账户不符合资格)
  • Git常用命令和分支管理
  • AI报告撰写实战指南:从提示词工程到全流程优化的底层逻辑与实践突破
  • 主流数据库压测工具全解析(从工具选型到实战压测步骤)
  • Vue的理解与应用
  • TDMQ CKafka 版客户端实战指南系列之一:生产最佳实践
  • 苹果群控系统的资源调度
  • Qt如何实现自定义标题栏
  • Qt QPlugin界面插件式开发Q_DECLARE_INTERFACE、Q_PLUGIN_METADATA和Q_INTERFACES
  • 梯度增强算法(Gradient Boosting)学习笔记
  • 确保邵氏硬度计测量精度问题要考虑事宜
  • `scroll-margin-top`控制当页面滚动到某个元素滚时,它在视口预留的位置,上方留白
  • 内存管理-伙伴系统合并块计算,__find_buddy_pfn,谁是我的伙伴???
  • 【LVS入门宝典】LVS核心原理与实战:Director(负载均衡器)配置指南
  • 算法常考题:描述假设检验的过程
  • IEEE会议征集分论坛|2025年算法、软件与网络安全国际学术会议(ASNS2025)
  • 洞见未来:计算机视觉的发展、机遇与挑战
  • MongoDB集合学习笔记
  • C++ 中 std::list使用详解和实战示例
  • IO流的简单介绍
  • 【AI论文】SAIL-VL2技术报告
  • 基于 SSM(Spring+SpingMVC+Mybatis)+MySQL 实现(Web)软件测试用例在线评判系统
  • 【2/20】理解 JavaScript 框架的作用:Vue 在用户界面中的应用,实现一个动态表单应用
  • Android冷启动和热启动以及温启动都是什么意思
  • Java数据结构 - 单链表的模拟实现
  • git忽略CRLF警告