文章目录
前言
摘要:AOV网(Activity On Vertex Network)是用顶点表示活动的有向无环图(DAG),用于描述工程中各活动的先后关系。拓扑排序是对AOV网顶点的一种线性排序,满足若存在路径从A到B,则B必在A之后。排序过程通过不断选择入度为0的顶点并删除其出边实现。若最终输出的顶点数少于总数,说明图中存在回路。代码采用邻接表存储,时间复杂度为O(|V|+|E|)。逆拓扑排序则选择出度为0的顶点进行类似处理。该算法可有效解决任务调度等实际问题。
一.AOV网

- AOV网(Activity On Vertex Network,用顶点表示活动的网)
- 用DAG图(有向无环图)表示一个工程。顶点表示活动,有向边 < V i , V j > <V_i,V_j> <Vi,Vj>表示活动 V i V_i Vi必须先于活动 V j V_j Vj进行
- 注意:AOV网它一定是一个有向无环图
二.拓扑排序的概念
1.标准定义
- 拓扑排序:在图论中,由一个有向无环图 的顶点组成的序列,当且仅当满足下列条件时,称为该图的一个拓扑排序:
①每个顶点出现且只出现一次。
②若顶点A在序列中排在顶点B的前面,则在图中不存在从顶点B到顶点A的路径。 - 或定义为:拓扑排序是对有向无环图的顶点的一种排序,它使得若存在一条从顶点A到顶点B的路径,则在排序中顶点B出现在顶点A的后面。每个AOV网都有一个或多个拓扑排序序列。
三.拓扑排序的过程
1.思路
- 拓扑排序的实现:
①从AOV网中选择一个没有前驱(入度为0 )的顶点并输出。
②从网中删除该顶点和所有以它为起点的有向边。
③重复①和②直到当前的AOV网为空或当前网中不存在无前驱的顶点为止。 - 注:如果出现了"当前网中不存在无前驱的顶点"的情况,说明图有回路,不能进行拓扑排序
2.例子
- 本质:找到做事的先后顺序

- 我们要做番茄炒蛋,那么这个大工程其实我们可以从这样的准备厨具,买菜作为起点
- 那我们选择先准备厨具

- 工具有了,还需要准备材料,所以接下来我们必须做的事情是买菜

- 现在鸡蛋有了,番茄也有了,那你可以选择先打鸡蛋或者先洗番茄,那么我们选择先洗番茄

- 那再往后你可以选择先切好番茄,或者说可以先去打鸡蛋,那我们选择先把番茄切好

- 后面的操作类似,排序结果如下

四.代码实现
1.代码展示
- 定义部分

c
#define MaxVertexNum 100 //图中顶点数目的最大值
typedef struct ArcNode{ //边表结点
int adjvex; //该弧所指向的顶点的位置
struct ArcNode *nextarc; //指向下一条弧的指针
//InfoType info; //网的边权值
}ArcNode;
typedef struct VNode{//顶点表结点
VertexType data; //顶点信息
ArcNode* firstarc; //指向第一条依附该弧的指点的指针
}Vnode, AdjList[MaxVertexNum];
typedef struct{
AdjList vertices;//邻接表
int vexnum, arcnum; //图的顶点数和弧数
}Graph; //图是以邻接表存储的图类型
- 主体部分
c
bool TopologicalSort(Graph G){
int indegree[MaxVertexNum];// 当前顶点入度
int print[MaxVertexNum];// 记录拓扑序列
InitStack(S); //初始化栈,存储入度为0的顶点
for(int i=0; i<G.vexnum; i++)
if(indegree[i]==0)
Push(S,i); //将所有入度为0的顶点进栈
int count=0; //计数,记录当前已经输出的顶点数
while(!IsEmpty(S)){
//栈不空,则存在入度为0的顶点
Pop(S,i); //栈顶元素出栈
print[count++]=i; //输出顶点i
for(p=G.vertices[i].firstarc;p;p=p->nextarc){
//将所有i指向的顶点的入度减1,并且将入度减为0的顶点压入栈S
v=p->adjvex;
if(!(--indegree[v]))
Push(S,v); //入度为0,则入栈
}
}//while
if(count<G.vexnum)
return false; //排序失败,有向图中有回路
else
return true; //拓扑排序成功
}
2.代码解释
-
定义
- 图用邻接表的方式存储

- indegree数组其实是用于记录每一个节点当前的入度
- print数组是用于记录我们得到的拓扑排序序列

- 定义一个栈,用于保存度为0的顶点,当然这个栈其实也可以用队列,甚至可以用数组来代替

- 图用邻接表的方式存储
-
初始化
- 0号顶点的入度为0,1号顶点的入度为1,然后2号顶点的入度为0,3号顶点的入度为2,4号顶点的入度为2

- 那print这个数组刚开始会被全部初始化为-1

- 0号顶点的入度为0,1号顶点的入度为1,然后2号顶点的入度为0,3号顶点的入度为2,4号顶点的入度为2
-
第一轮循环
-
这个for循环会检查当前入度为零的所有的顶点
cfor(int i=0; i<G.vexnum; i++) if(indegree[i]==0) Push(S,i); //将所有入度为0的顶点进栈 -
那可以发现当前入度为0的顶点有0号顶点和2号顶点,所以这两个顶点的下标数值就会被放到栈里边进行一个保存

-
另外这定义了一个叫count的变量,刚开始是0

-
其实我们对一个图进行拓扑排序的过程,就是不断地删除当前入度为0的这些顶点,所以接下来我们需要通过栈里保存的入度为0的顶点信息来确定我们的拓扑排序序列当中的第一个节点,那当前弹出栈顶的是2号节点

-
所以我们把count所指向的这个位置记为2,表示在这个序列当中,第一个节点的编号是2,并且会进行一个count++的操作
cwhile(!IsEmpty(S)){ //栈不空,则存在入度为0的顶点 Pop(S,i); //栈顶元素出栈 print[count++]=i; //输出顶点i ... } -
接下来的这个for循环其实是要把当前弹出的这个节点,也就是2号节点,把所有的和2号节点相连的3号和4号节点入度都减1[1](#1),同时判断其入度是否为0,如果为0则压入栈中,不为0则进行下一次循环
cfor(p=G.vertices[i].firstarc;p;p=p->nextarc){ //将所有i指向的顶点的入度减1,并且将入度减为0的顶点压入栈S v=p->adjvex; if(!(--indegree[v])) Push(S,v); //入度为0,则入栈 } -
接下来在会重复一轮循环中的4,5,6操作,直到栈空,这里相当于访问到了1号顶点,将1号顶点入栈后,其当前状态如图

-
-
接下来就重复一轮循环的4,5,6,7操作,直到栈空,此时count的值应该是等于5,也就是等于节点的数量,那这就表示我们的拓扑排序是成功的,如果最后我们算法停止的时候,count值小于顶点的个数,那么就表示说这个图里边肯定存在回路
cif(count<G.vexnum) return false; //排序失败,有向图中有回路 else return true; //拓扑排序成功
五.时间复杂度
1.推导
- 那这个算法当中我们每一个顶点都会被处理一次,每一条边也都需要被遍历一次
- 所以整体来看时间复杂度应该是O(|V|+|E|)
- 但是如果我们这个图是用邻接矩阵来存储,则需O(|V|²)
2.结论
- 邻接表:O(|V|+|E|)
- 邻接矩阵O(|V|²)
六.逆拓扑排序
1.定义
- 对一个AOV网,如果采用下列步骤进行排序,则称之为逆拓扑排序:
①从AOV网中选择一个没有后继(出度为0 )的顶点并输出。
②从网中删除该顶点和所有以它为终点的有向边。
③重复①和②直到当前的AOV网为空。
2.步骤
1.思路
- 每一次选择的是删除出度为0的顶点
2.例子

- 应该比较简单,直接给答案

3.代码实现
c
bool ReverseTopologicalSort(Graph G){
int outdegree[MaxVertexNum];// 当前顶点出度
int print[MaxVertexNum];// 记录拓扑序列
InitStack(S); //初始化栈,存储出度为0的顶点
for(int i=0; i<G.vexnum; i++)
if(outdegree[i]==0)
Push(S,i); //将所有出度为0的顶点进栈
int count=0; //计数,记录当前已经输出的顶点数
while(!IsEmpty(S)){
//栈不空,则存在入度为0的顶点
Pop(S,i); //栈顶元素出栈
print[count++]=i; //输出顶点i
for(p=G.vertices[i].firstarc; p;p=p-> nextarc){
//将所有i指向的顶点的出度减1,并且将出度减为0的顶点压入栈S
v=p-> adjvex;
if(!(--outdegree[v]))
Push(S,v); //出度为0,则入栈
}
}//while
if(count<G.vexnum)
return false; //排序失败,有向图中有回路
else
return true; //拓扑排序成功
}
4.时间复杂度
1.推导
- 如果采用邻接表存储的话,由于要找到指向一个顶点的入边意味着我们得把整个连接表都给遍历一遍才能找出来,所以如果采用邻接表来实现逆拓扑排序那显然是一个比较低效的方法
- 而如果我们采用邻接矩阵的话,当我们删除一个顶点之后,想要找到指向它的边只需要遍历它所对应的这一列就可以了
补充:一个和邻接表很类似的一种存储的方式逆邻接表,逆连接表当中每一个顶点所对应的这些边的信息其实指的是指向这个顶点的这些边(入边)
5.DFS算法实现逆拓扑排序
1.代码展示
c
void DFSTraverse(Graph G){
for(v=0; v<G.vexnum;++v) //对图G进行深度优先遍历
visited[v]=FALSE; //初始化已访问标记数据
for(v=0; v<G.vexnum;++v) //本代码中是从v=0开始遍历
if(!visited[v])
DFS(G,v);
}
void DFS(Graph G,int v){ //从顶点v出发,深度优先遍历图G
visited[v]=TRUE; //设已访问标记
for(w=FirstNeighbor(G,v);w>=0;w=NextNeighbor(G,v,w))
if(!visited[w]){ //w为u的尚未访问的邻接顶点
DFS(G,w);
} //if
print(v); //输出顶点
}
2.思路
- 当我们访问完一个顶点,并且已经访问完与这个顶点相连接的所有顶点之后,我们会把这个顶点输出出来
- 用这种方式输出得到的序列就是逆拓扑排序的一个序列
- DFS实现逆拓扑排序:在顶点退栈前输出
- 注意:如果存在回路,则不存在逆拓扑排序序列,此时需要停止循环表示逆拓扑排序失败
3.执行过程

-
初始化工作和DFS算法一样
-
通过下面的for循环我们会从0号定点开始找到第一个当前还没有被访问过的顶点
cfor(v=0; v<G.vexnum;++v) //本代码中是从v=0开始遍历 if(!visited[v]) DFS(G,v); -
所以我们刚开始会从0号顶点作为入口来调用这个dfs函数,并且我们会把0号顶点的visit值设为true

-
那在这个图里边,如果我们把这个顶点标成了这种灰色的话,那就意味着这个顶点的visit值是true
cvisited[v]=TRUE; //设已访问标记 -
现在我们需要从0号顶点出发找到第一个与它邻接的顶点,那第一个与它邻接的顶点是1号顶点,并且1号顶点此时是没有被访问过的,因此接下来会进入下一层的DFS函数
cfor(w=FirstNeighbor(G,v);w>=0;w=NextNeighbor(G,v,w)) if(!visited[w]){ //w为u的尚未访问的邻接顶点 DFS(G,w); } //if -
接下来访问1号顶点

-
接下来则是循环上述过程,当访问到最后一个顶点(出度为0)时停止上述循环,此时的递归栈如下图所示

-
此时是在访问4号定点,由于4号顶点没有相邻顶点,执行
print(v);,得到逆拓扑排序的第一个元素4,4号DFS函数调用结束之后,返回上一层的DFS


-
那对于3号顶点来说,此时已经找不到任何一个与之相邻并且没有被访问过的顶点,因此3号顶点的这个for循环也可以顺利的跳出,接着输出3号顶点,并且返回上一层函数


-
接下来也是一样的,最终结果如下

4.怎么判断是否在走一个环路

1.思路
在DFS遍历过程中,我们可以通过维护三种顶点状态来检测环路:
-
未访问(UNVISITED):顶点尚未被访问
-
访问中(VISITING):顶点正在递归栈中,其DFS调用尚未结束
-
已访问(VISITED):顶点及其所有后继顶点都已完成访问
如果在DFS过程中遇到一个状态为"访问中"的顶点,说明存在后向边,即存在环路。
2.代码展示
- 更改visit数组定义为:
c
// 顶点状态定义
typedef enum {
UNVISITED, // 未访问
VISITING, // 访问中(在递归栈中)
VISITED // 已访问完成
} VertexStatus;
// 全局状态数组
VertexStatus status[MAX_VERTEX_NUM];
- 代码优化为
c
int reverseTopoWithCycleCheck(Graph G, int v, VertexStatus status[], int result[], int count) {
// 标记当前顶点为访问中
status[v] = VISITING;
// 遍历所有邻接顶点
for (ArcNode *p = G.vertices[v].firstarc; p != NULL; p = p->nextarc) {
int w = p->adjvex;
if (status[w] == UNVISITED) {
// 递归访问未访问的邻接顶点
count = reverseTopoWithCycleCheck(G, w, status, result, count);
if (count == -1) {
return -1; // 子调用中检测到环路,直接返回
}
} else if (status[w] == VISITING) {
// 遇到正在递归栈中的顶点,检测到环路
printf("检测到环路: %d -> %d\n", v, w);
return -1;
}
// 如果status[w] == VISITED,继续检查下一个邻接顶点
}
// 所有邻接顶点处理完成,将当前顶点加入结果
status[v] = VISITED;
result[count] = v;
return count + 1;
}
// 逆拓扑排序主函数(集成环路检测)
int integratedReverseTopologicalSort(Graph G, int result[]) {
VertexStatus status[MAX_VERTEX_NUM];
int count = 0;
// 初始化所有顶点状态为未访问
for (int i = 0; i < G.vexnum; i++) {
status[i] = UNVISITED;
}
// 对每个未访问的顶点进行DFS
for (int i = 0; i < G.vexnum; i++) {
if (status[i] == UNVISITED) {
count = reverseTopoWithCycleCheck(G, i, status, result, count);
if (count == -1) {
return -1; // 检测到环路
}
}
}
return count; // 返回序列长度
}
3.代码解释

- 有环图的情况:加入函数调用栈的顶点初始都是正在访问的状态,如上图的函数调用栈最终为:0->1->3->4->2,接下来访问的是3,发现3已经在访问中,因此判断出存在回路,退出循环

- 无环图的情况:加入函数调用栈的顶点初始都是正在访问的状态,最终访问到4,因为4没有下一个顶点,因此将4作为逆拓扑序列的第一个元素,并且将其设置为已经访问,接着就是3,1,0,访问完毕
七.知识回顾与重要考点

结语
二更😉
如果想查看更多章节,请点击:一、数据结构专栏导航页
- 所以这个操作就相当于其实我们是在逻辑上把2号顶点还有与2号顶点相连的边给删除了,图本身的连接表的信息其实是没有改变 ↩︎

