数据结构:拓扑排序
从“泡茶”说起
张三给客人沏茶,烧开水需要 12 分钟,洗茶杯要 2 分钟,买茶叶要 8 分钟,放茶叶泡茶要 1 分钟。为了让客人早点喝上茶,你认为最合理的安排,多少分钟就可以了?
小学有一种奥数题型叫“最优化策略”,诞生出这种题型的原因是由于完成一系列操作是由有先后顺序的,同时做一些事情的同时可以顺手做另一件事情。若这些事情是以一个无序线性地去完成,很明显是不现实的,因为有的事件是其他事件的预备条件,就例如要放茶叶的话就要有茶叶,买茶叶就是放茶叶的预备条件。而有些是可以并行地去做,例如烧开水和洗茶杯这两件事情可以交换顺序做,亦可以一起做。
AOV 网
有向无环图 (Directed Acycline Graph) 是描述一项工程或系统进行过程的有效工具,对于一个工程而言有存在着子工程,子工程之间可能是存在着先后顺序的,一旦存在顺序就会产生约束。我们可以把一个工程抽象成一个网结构,顶点表示一个个子工程,弧表示子工程之间的优先关系,这样的抽象手法我们称之为 AOE(Activity On Vertex Network) 网。例如对于课程的学习顺序:
我们可以得出对应的 AOV-网:
同时我们也可以观察出一个结论:AOV 网中不能够出现有向环,因为某个子工程以自己为先决条件明显是可笑的。
拓扑排序
因此对于一个 AOV 网就需要判其是否有环是很重要的,因此就需要进行拓扑排序,若拓扑排序中所有点都存在与序列中就证明没有环的存在。不过这当然不是拓扑排序的最终目的啦,我认为拓扑排序最重要的作用是给出一种可行的流程,这个流程可以描述出整个工程的一种流程,有了流程就有了方向。
现在就来讲讲什么是拓扑排序,所谓拓扑排序就是将 AOV 网中所有顶点排成一个序列,该序列满足坐在 AOV 网中有顶点 vi 到 vj 有一条路径,则在该线性序列中的顶点 vi 必定在 vj 之前。
排序流程
- 在有向图中选择一个无前驱的顶点并输出;
- 在图中删除该顶点和所有以它为尾的弧;
- 重复前两步直至不存在无前驱的顶点;
- 对得到的序列进行判断,若顶点数小于有向图中的顶点数说明有环存在,否则无环。
模拟排序
就拿上面的 AOV 网来看好了。任意选择无前驱的顶点 C1 并输出,在图中删除 C1 和所有以它为尾的弧:
任意选择无前驱的顶点 C2 并输出,在图中删除 C2 和所有以它为尾的弧:
任意选择无前驱的顶点 C3 并输出,在图中删除 C3 和所有以它为尾的弧:
任意选择无前驱的顶点 C4 并输出,在图中删除 C4 和所有以它为尾的弧:
任意选择无前驱的顶点 C5 并输出,在图中删除 C5 和所有以它为尾的弧:
任意选择无前驱的顶点 C7 并输出,在图中删除 C7 和所有以它为尾的弧:
任意选择无前驱的顶点 C9 并输出,在图中删除 C9 和所有以它为尾的弧:
任意选择无前驱的顶点 C10 并输出,在图中删除 C10 和所有以它为尾的弧:
任意选择无前驱的顶点 C11 并输出,在图中删除 C11 和所有以它为尾的弧:
任意选择无前驱的顶点 C6 并输出,在图中删除 C6 和所有以它为尾的弧:
任意选择无前驱的顶点 C12 并输出,在图中删除 C12 和所有以它为尾的弧:
任意选择无前驱的顶点 C8 并输出,在图中删除 C8 和所有以它为尾的弧:
到此为止,拓扑排序结束,序列为:C1,C2,C3,C4,C5,C7,C9,C10,C11,C6,C12,C8。由此看见这个图无环,是一个 AOV 图,而且拓扑序列不唯一。
算法实现
结构设计
选择邻接表作为有向图的存储结构,需要的辅助结构有:
- 一维数组 indegree[i]:存放各个点的入度,没有前驱的顶点就是入度为 0 的顶点。删除顶点及以其为弧尾的弧可以用弧头顶点入度减去 1 的手法描述;
- 栈或队列 S:存储所有入度为 0 的顶点,可以避免重复扫描数组 indegree 检测入度为 0 的顶点;
- 一维数组 topo[i]:记录拓扑序列的顶点序号。
算法步骤
- 求出各顶点的入度存入数组 indegree[i] 中,将入度为 0 的顶点入栈;
- 将栈顶顶点 vi 出栈并保存于拓扑序列 topo 中,对顶点 vi 的每个临接点 vk 的入度减 1,若 vk 的入度变为 0 则 vk 入栈;
- 重复第二步直至栈为空栈;
- 判断拓扑序列中的顶点个数,若少于 AOV 网中的顶点个数则有环,否则无环
代码实现
bool TopologicalSort(ALGraph G,int topo[])
{
stack<int> S;
int indegree[MAXV];
int idx = 0;
ALGraph ptr;
int k;
FindInDegree(G,indegree); //求出每个顶点的入度并存入 indegree 中
for(int i = 0; i < G.n; i++)
{
if(!indegree[i])
S.push(i); //入度为 0 的元素入栈
}
while(!S.empty())
{
topo[idx++] = S.top(); //栈顶 vi 存入拓扑序列
S.pop(); //栈顶 vi 出栈
ptr = G.vertices[i].firstarc;
while(ptr)
{
k = ptr->adjvex;
indegree[k]--; //vi 的邻接点入度减 1
if(indegree[k] == 0)
S.push(k); //若入度减为 0,入栈
ptr = ptr->nextarc;
}
}
if(idx < G.n)
return false; //图出现回路
return true;
}
实例:剿灭魔教
情景需求
输入样例
3
7 10
2 1
3 1
4 1
5 2
6 2
5 3
6 3
6 4
7 5
7 6
5 0
3 3
1 2
2 3
3 1
输出样例
1 2 3 4 5 6 7
1 2 3 4 5
-1
情景分析
其实本质上还是拓扑排序啦,不过加入了当一些部件都可以安装时,应当先安装编号较小的部件这个限制。我们知道拓扑排序最大的意义在于判断一个有向图结构有没有出现环,至于序列的话,我说过这是提出了一种导向,这种导向的执行是不唯一的,加入这个限制之后使得输出的拓扑排序变的唯一。看似仅仅是加入了一个很小的需求,但是可能我们这些写程序的就要头痛了。我有 2 种方案:
- 我之前提过,我们总是很喜欢有序的东西,因为一个结构一旦有序我们就有很多操作可以对付之,因此第一种思路是对存储结构进行调整。比较粗暴的方式是先做一个邻接矩阵,然后按照倒序头插法建邻接表,但是这么做明显空间复杂度巨大。还有一种手法是直接做邻接表,不过邻接表的话生成结点的方式使用插入法使得邻接表本身是一个有序的结构,然后拓扑排序的时候利用好有序这个特性来就行了;
- 第二种想法是从我们的辅助结构下手,也就是说我们处理入度为 0 的顶点时使用的是栈或队列这种操作受限的线性表,这 2 种结构会对排序序列造成影响。因此我们就想要强化这种结构使其能按照我们希望的序列去生成,这就是我们熟悉的手法:最(大\小)栈和优先级队列。为什么这么说?因为这 2 种结构是能够对弹栈或出队列的元素进行限制的强化结构,和我们的需求不谋而合!
代码实现
#include <iostream>
#include <queue>
#define MAXV 100001
using namespace std;
typedef struct ANode
{
int adjvex; //该边的终点编号
struct ANode* nextarc; //指向下一条边的指针
int info; //该边的相关信息,如权重
} ArcNode; //边表节点类型
typedef int Vertex;
typedef struct Vnode
{
Vertex data; //顶点信息
int count; //入度
ArcNode* firstarc; //指向第一条边
} VNode; //邻接表头节点类型
typedef VNode AdjList[MAXV];
typedef struct
{
AdjList adjlist; //邻接表
int n, e; //图中顶点数n和边数e
} AdjGraph;
//邻接表拓扑排序
void TopSort(AdjGraph* G) //需要在该函数开始计算并初始化每个节点的入度,然后再进行拓扑排序
{
int indegree[MAXV] = { 0 };
ArcNode* ptr;
priority_queue<int, vector<int>, greater<int>>que;
int topo[MAXV];
int top = 0, idx = 0;
int i;
int v;
for (i = 1; i <= G->n; i++)
{
ptr = G->adjlist[i].firstarc;
while (ptr)
{
indegree[ptr->adjvex]++;
ptr = ptr->nextarc;
}
}
for (i = 1; i <= G->n; i++)
{
if (indegree[i] == 0)
{
que.push(i);
}
}
while (!que.empty())
{
v = topo[idx++] = que.top();
que.pop();
ptr = G->adjlist[v].firstarc;
while (ptr)
{
v = ptr->adjvex;
indegree[v]--;
if (indegree[v] == 0)
{
que.push(v);
}
ptr = ptr->nextarc;
}
}
if (idx < G->n)
{
cout << "-1 ";
}
else
{
for (i = 0; i < G->n; i++)
{
cout << topo[i] << " ";
}
}
return;
}
“泡茶”问题解决篇
这里我其实并没有解决“泡茶问题”,而只是用这个话题做个引入。再回顾下,我认为拓扑排序提供的是一种导向,它描述了工程这么去操作是否可行,以及给了一种大致上的流程规划。但是这毕竟只是一种导向,并不精确,它指示的不是解决这个问题的最优解。
左转博客关键路径!
参考资料
《数据结构(C语言版|第二版)》—— 严蔚敏 李冬梅 吴伟民 编著,人民邮电出版社
堆、优先级队列和堆排序
关键路径