如果一个有向图的任意顶点都无法通过一些有向边回到自身,那么称这个有向图为有向无环图(DAG)。
拓扑排序是将有向无环图G的所有顶点排成一个线性序列,使得对图G中的任意两个顶点u、v,如果存在边u->v,那么u在序列中一定在v的前面。这个序列又被称为拓扑序列。
如何求拓扑序列?
用邻接表实现拓扑排序。显然由于需要记录结点的入度,因此需要额外建立一个数组inDegree[MAXV],并在程序一开始读入图时就记录好每个结点的入度。
#include
using namespace std;
const int MAXV = 110;
vector<int> G[MAXV];//邻接表
int n,m,inDegree[MAXV]//顶点数、入度
//拓扑排序
bool topologicalSort(){
int num =0;//记录加入拓扑序列的顶点数
queue<int> q;
for(int i =0;i<n;i++){
if(inDegree[i] == 0){
q.push(i);//将所有入度为0的顶点入队
}
}
while(!q.empty()){
int u = q.front();//取队首顶点u
//printf("%d",u);//可以输出u作为拓扑序列的顶点
q.pop();
for(int i =0;i<G[u].size;i++){
int v = G[u][i];//u的后继结点v
inDegree[v]--;//v的入度-1
if(inDegree[v] == 0){
q.push(v);//顶点v的入度减为0则入队
}
}
G[u].clear();//清空顶点u的所有边
num++;//加入拓扑序列的顶点数+1
}
if(num == n){
return true;//拓扑排序成功
}else{
return false;//失败,有环
}
}
拓扑排序很重要的应用就是判断一个给定的图是否是有向无环图。如果上述函数返回true,说明是有向无环图。
如果要求有多个入度为0的结点,选择编号最小的结点,那么把queue改成priority_queue,并保持队首元素是优先队列中最小的元素(greater)即可。【或者使用set也可以】
顶点活动(AOV)网是指用顶点表示活动,而用边集表示活动间优先关系的有向图。显然图中不应当出现有向环。
边活动(AOE)网是指用带权的边集表示活动,而用顶点表示事件的有向图,其中边权表示完成活动需要的时间。“事件”仅代表一个中介状态。
一般来说,AOE网是用来表示一个工程的进行过程,而工程常常可以分为若干个子工程(即“活动”),显然AOE网不应当有环。考虑到对工程来说总会有一个起始时刻和结束时刻,因此AOV网一般只有一个源点(即入度为0的点)和一个汇点(即出度为0的点)。但如果有多个源点和汇点,我们可以通过添加一个“超级源点”和“超级汇点”的方法来使源点和汇点唯一,即从超级源点出发,连接所有入度为0的点;从所有出度为0的点出发连接超级汇点;添加的有向边的边权均为0。
如果给定AOV网中各顶点活动所需要的时间,那么就可以将AOV网转换为AOE网。比较简单的方法是:将AOV网中每个顶点都拆成两个顶点,分别表示活动的起点和终点,而两个顶点之间用有向边连接,该有向边表示原顶点的活动,边权给定;原AOV网中的边全部视为空活动,边权为0。
AOE网中的最长路径被称为关键路径(关键路径就是AOE网的最长路径),而把关键路径上的活动成为关键活动,显然关键活动会影响整个工程的进度。
对一个没有正环的图(指从源点可达的正环),如果需要求最长路径长度,则可以把所有边的边权乘以 -1,令其变为相反数,然后使用bellman-Ford算法或SPFA算法求最短路径长度,将所得结果取反即可。注意:此处不能使用Dijkstra算法。
最长路径问题,寻求的是图中的最长简单路径。
而如果求有向无环图的最长路径长度,则使用求关键路径的求法更快。
求解有向无环图(DAG)中最长路径的方法。
关键路径是指那些不允许拖延的活动,因此这些活动的最早开始时间必须=最晚开始时间。因此可以设置数组e和l,其中e[r]和l[r]分别表示活动ar的最早开始时间和最迟开始时间。于是,求出这两个数组之后,就可以通过判断e[r] == l[r]是否成立来确定活动r是否是关键活动。
如何求解数组e和l?
事件的最早开始时间可以理解为旧活动的最早结束时间,事件的最迟发生时间可以理解成新活动的最迟开始时间。设置数组ve和vl,其中ve[i]和vl[i]分别表示事件i的最早发生时间和最迟发生时间,然后就可以将求解e[r]和l[r]转换成求解这两个新的数组:
于是只要求出ve和vl这两个数组,就可以通过上面的公式得到e和l这两个数组。
ve[j] = max{ ve[ip]+length[rp] } (p = 1…k)
因为只有所有能到达vj的活动都完成之后,vj才能被激活。
想获得ve[j]的正确值,还需要得到前面的ve。即要做到,在访问某个结点前,其前驱结点都已被访问完毕。因此使用拓扑排序。让拓扑排序访问到Vj的时候,Vi1~ Vik一定都已经用来更新过ve[j],此时的ve[j]便是正确值,就可以用它去更新Vj后面的所有后继结点的ve值。
//拓扑序列
stack<int> topOrder;
//拓扑排序,顺便求ve数组
bool topologicalSort(){
queue<int> q;
for(int i =0;i<n;i++){
if(inDegree[i] == 0){
q.push(i);
}
}
while(!q.empty()){
int u =q.front();
q.pop();
topOrder.push(u);//将u加入拓扑序列
for(int i =0;i<G[u].size();i++){
int v = G[u][i].v;
inDegree[v]--;
if(inDegree[v] == 0){
q.push(v);
}
//用ve[u]来更新u的所有后继结点v
if(ve[u]+G[u][i].w > ve[v]){
ve[v] = ve[u]+G[u][i].w;
}
}
}
if(topOrder.size() == n) return true;
else return false;
}
ve[i] = min{ vl[jp]-length[rp] } (p=1…k)
必须保证Vj1~ Vjk的最迟发生时间能被满足。
与ve数组类似,要求vl的正确值需要vl[j1]vl[jk]的值已求出。即需要在**访问某个结点时保证它的后续结点都已经被访问完毕**,通过**逆拓扑序列**来实现。可以通过颠倒拓扑序列来得到一组合法的逆拓扑序列。因为上述使用栈来存放拓扑序列,因此**只要按顺序出栈就是逆拓扑序列**。当访问逆拓扑序列中的每个书剑Vi时,就可以遍历Vi的所有后继结点Vj1~~ Vjk,使用vl[j1]~vl[jk]来求出vl[i]。
fill(vl,vl+n,ve[n-1]);//vl数组初始化,初始值为终点的ve值
//直接使用topOrder出栈即为逆拓扑序列,求解vl数组
while(!topOrder.empty()){
int u = topOrder.top();//栈顶元素u
topOrder.pop();
for(int i =0;i<G[u].size();i++){
int v = G[u][i].v;
//用u的所有后继结点v的vl值来更新vl[u]
if(vl[v]-G[u][i].w < vl[u]){
vl[u] = vl[v]-G[u][i].w;
}
}
}
下面给出上面过程的步骤总结,即“先求点,再夹边”:
//计算点的事件的最早开始时间
//拓扑序列
stack<int> topOrder;
//拓扑排序,顺便求ve数组
bool topologicalSort(){
queue<int> q;
for(int i =0;i<n;i++){
if(inDegree[i] == 0){
q.push(i);
}
}
while(!q.empty()){
int u =q.front();
q.pop();
topOrder.push(u);//将u加入拓扑序列
for(int i =0;i<G[u].size();i++){
int v = G[u][i].v;
inDegree[v]--;
if(inDegree[v] == 0){
q.push(v);
}
//用ve[u]来更新u的所有后继结点v
if(ve[u]+G[u][i].w > ve[v]){
ve[v] = ve[u]+G[u][i].w;
}
}
}
if(topOrder.size() == n) return true;
else return false;
}
//关键路径,不是有向无环图返回-1,否则返回关键路径长度
int criticalPath(){
memset(ve,0,sizeof(ve));//ve数组初始化
if(topologicalSort() == false){
return -1;//不是有向无环图
}
fill(vl,vl+n,ve[n-1]);//vl数组初始化,初始值为终点的ve值
//直接使用topOrder出栈即为逆拓扑序列,求解vl数组
while(!topOrder.empty()){
int u = topOrder.top();//栈顶元素u
topOrder.pop();
for(int i =0;i<G[u].size();i++){
int v = G[u][i].v;
//用u的所有后继结点v的vl值来更新vl[u]
if(vl[v]-G[u][i].w < vl[u]){
vl[u] = vl[v]-G[u][i].w;
}
}
}
//遍历邻接表的边,计算活动的最早开始时间和最晚开始时间
for(int u =0;u<n;u++){
for(int i =0;i<G[u].size();i++){
int v = G[u][i].v,w = G[u][i].w;
//活动的最早开始时间e和最晚开始时间l
int e = ve[u], l = vl[v]-w;
//如果e==l,说明活动u->v是关键活动
if(e == l){
printf("%d->%d\n",u,v);//输出关键活动
}
}
}
return ve[n-1];//返回关键路径长度
}
如果事先不知道汇点编号,可以取ve数组的最大值。于是只用在fill函数之前添加一小段语句,然后改变下vl函数初始值即可:
int maxLength = 0;
for(int i =0;i<n;i++){
if(ve[i] >maxlength){
maxlength = ve[i];
}
}
fill(ve,ve+n,maxlength);