大话数据结构 -- 图

图的各种定义

图是由顶点的有穷非空集合顶点之间边的集合组成,通常表示为:G(V,E),其中,G表示一个图,V是图G中顶点的集合,E是图G中边的集合。

在图形结构中,结点之间的关系可以是任意的,图中任意两个数据元素之间都可能相关。

图与其他数据结构的不同之处见下表:

数据结构 结点之间关系 数据元素名称 数据元素可否没有
线性表 一对一 元素 可以(空表)
一对多(除根结点外,一个结点有且只有一个双亲结点,但可以有多个孩子) 结点 可以(空树)
多对多 顶点 不可以

线性表中,相邻的数据元素之间具有线性关系;树结构中,相邻两层的结点具有层次关系;而图中,任意两个顶点之间都可能有关系,顶点之间的逻辑关系用边来表示,边集可以是空的

无向边:若顶点vi到vj之间的边没有方向,则称这条边为无向边,用无序偶对(vi,vj)来表示。如果图中任意两个顶点之间的边都是无向边,则称该图为无向图。故(vi,vj)也可写作(vj,vi)。

有向边:若顶点vi到vj之间的边有方向,则称这条边为有向边,也称为。用无序偶对<vi,vj>来表示。vi称为弧尾,vj称为弧头。如果图中任意两个顶点之间的边都是有向边,则称该图为有向图。故(vi,vj)不可写作(vj,vi)。

在图中,若不存在顶点到其自身的边,且同一条边不重复出现,则称这样的图为简单图。接下来我们要讨论的都是简单图。

在无向图中,如果任意两个顶点之间都存在边,则称该图为无向完全图含有n个结点的无向完全图有n(n-1)/2 条边。(n个结点都要与剩余n-1个结点相连,重复一倍除以2。)

在有向图中,如果任意两个顶点之间都存在方向互为相反的两条弧,则称该图为有向完全图含有n个结点的有向完全图有n(n-1) 条边。(有方向,不用除以2。)

故对于具有n个结点和e条边数的图,无向图0<=e<=n(n-1)/2,有向图0<=e<=n(n-1)。

有很少条边或弧的图称为稀疏图,反之称为稠密图。这里稀疏和稠密是模糊的概念,都是相对而言的。

与图的边或弧相关的数叫做。表示从一个顶点到另一个顶点的距离或耗费。这种带权的图通常称为

假设有两个图G=(V,{E})和G'=(V',{E'}),如果V'∈V且E'∈E,则称G’为G的子图

对于无向图G=(V,{E}),如果边(v,v')∈E,则称顶点v和顶点v'互为邻接点(Adjacent),即v和v'相邻接。边(v,v')依附(incident)于顶点v和v',或者说(v,v')与顶点v和v'相关联顶点v的(Degree)是和v相关联的边的数目,记为TD(v)。故边数其实就是各顶点度数和的一半,多出的一半是因为重复两次计数。

对于有向图G=(V,{E}),如果弧<v,v'>∈E,则称顶点v邻接到顶点v'顶点v'邻接自顶点v。弧<v,v'>与顶点v和v'相关联。以顶点v为头的弧的数目称为v的入度,记为ID(v)顶点v为头的弧的数目称为v的出度,记为OD(v);顶点v的度为TD(v)=ID(v)+OD(v)。边数=入度和=出度和

树中根结点到任意结点的路径是唯一的,但是图中顶点与顶点之间的路径却是不唯一的

路径的长度是路径上的边或弧的数目

第一个顶点和最后一个顶点相同的路径称为回路或环

序列中顶点不重复出现的路径称为简单路径除了第一个顶点和最后一个顶点外,其余顶点不重复出现的回路,称为简单回路或简单环

在无向图G中,如果从顶点v到顶点v'有路径,则称v和v'是连通的。如果对于图中任意两个顶点vi、vj∈V,vi和vj都是连通的,则称G是连通图

无向图中的极大连通子图称为连通分量。强调:

 ● 要是子图;

 ● 子图要是连通的;

 ● 联通子图含有极大顶点数

 ● 具有极大顶点数的连通子图包含依附于这些顶点的所有边。

如图图7-2-12,图1是一个无向非连通图。但是它有两个连通分量,即图2和图3。而图4,尽管是图1的子图,但是它却不满足连通子图的极大顶点数(图2满足)。因此它不是图1的无向图的连通分量。

在有向图G中,如果对于每一对vi、vj∈V,vivj,从vi到vj和从vj到vi都存在路径,则称G是强连通图有向图中的极大强连通子图称做有向图的强连通分量

如图7-2-13,图1并不是强连通图,因为顶点A到顶点D存在路径,而D到A就不存在。图2就是强连通图,而且显然图2是图1的极大强连通子图,即是它的强连通分量。

一个连通图的生成树是一个极小的连通子图,它含有图中全部的n个顶点,但只有足以构成一棵树的n-1条边。比如图7-2-14的图1是一普通图,但显然它不是生成树,当去掉两条构成环的边后,比如图2或图3,就满足n个顶点n-1条边且连通的定义了。它们都是一棵生成树。

如果一个图有n个顶点和小于n-1条边,则是非连通图,如果它多于n-1条边,必定构成一个环,因为这条边使得它依附的那两个顶点之间有了第二条路径。比如图2和图3,随便加哪两顶点的边都将构成环。不过有n-1条边并不一定是生成树,比如图4。

如果一个有向图恰有一个顶点的入度为0,其余顶点的入度均为1,则是一个有向树。所谓入度为0其实就相当于树中的根结点,其余顶点入度为1就是说树的非根结点的双亲只有一个。一个有向图的生成森林由若干棵有向树组成,含有图中全部顶点,但只有足以构成若干棵不相交的有向树的弧。如图7-2-15的图1是一棵有向图。去掉一些弧后,它可以分解为两棵有向树,如图2和图3,这两棵就是图1有向图的生成森林。

图的存储结构

图上任何一个顶点都可被看成是第一个顶点,任一顶点的邻接点之间也不存在次序关系。

图的结构比较复杂,任意两个顶点之间都可能存在联系。因此,图不可能用简单的顺序存储结构来表示。

1、邻接矩阵

用两个数组来表示图。一个一维数组存储图中顶点信息,一个二维数组(称为邻接矩阵)存储图中的边或弧的信息。 

设图G有n个顶点,则邻接矩阵是一个n×n的方阵,定义为:

可以看出,无向图的边数组是一个对称矩阵

有了这个矩阵,我们就可以很容易地知道图中的信息。
1.我们要判定任意两顶点是否有边无边就非常容易了。
2.我们要知道某个顶点的度,其实就是这个顶点vi在邻接矩阵中第i行(或第i列)的元素之和。比如顶点v1的度就是1+0+1+0=2。3.求顶点vi的所有邻接点就是将矩阵中第i行元素扫描一遍,arc[i][j]为1就是邻接点。

我们再来看一个有向图样例,如图7-4-3所示的左图。

因为是有向图,所以此矩阵并不对称。有向图讲究入度和出度。

设图G是网图,有n个顶点,则邻接矩阵是一个n×n的方阵。权值wij大多数情况下是正值,但个别时候可能就是0,甚至有可能是负值。因此必须要用一个不可能的值来代表不存在。如图7-4-4左图就是一个有向网图,右图就是它的邻接矩阵。

邻接矩阵存储的结构: 

// 顶点类型应由用户定义
typedef char VertexType;
// 边上的权值类型应由用户定义
typedef int EdgeType;
// 最大顶点数,应由用户定义

#define INFINITY 65535
typedef struct{
    // 顶点表
    VertexType vexs[MAXVEX];
    // 邻接矩阵,可看作边表
    EdgeType arc[MAXVEX][MAXVEX];
    // 图中当前的顶点数和边数
    int numVertexes,numEdges;
}MGragh;

有了这个结构定义,我们构造一个图,其实就是给顶点表和边表输入数据的过程。我们来看看无向网图的创建代码。

void CreateMGragh(MGragh *G){
    int i,j,k,w;
    printf("输入顶点数和边数:\n");
    // 输入顶点数和边数
    scanf("%d,%d",&G->numVertexes,&G->numEdges);
    // 读入顶点信息,建立顶点表
    for(i=0;i<G->numVertexes;i++)
        scanf(&G->vexs[i]);
    for(i=0;i<G->numVertexes;i++)
        for(j=0;j<G->numVertexes;j++)
            // 邻接矩阵初始化
            G->arc[i][j]=INFINITY;
    // 读入numEgdes条边,建立邻接矩阵
    for(k=0;k<G->numEdges;k++)
    {
        printf("输入边(vi,vj)上的下标i,下标j和权w:\n");
        scanf("%d,%d,%d",&i,&j,&w);
        G->arc[i][j]=w;
        // 因为是无向图,矩阵对称
        G->arc[j][i]=w;
    }
}

从代码中也可以得到,n个顶点和e条边的无向网图的创建,时间复杂度为O(n+n^2+e),其中对邻接矩阵G.arc的初始化耗费了O(n^2)的时间。

2、邻接表

邻接矩阵对于边数相对顶点较少的图是存在对存储空间的极大浪费的。

考虑对边或弧是使用链式存储的方式来避免空间浪费的问题。

数组与链表相结合:

1、图中顶点用一个一维数组存储。每个数据元素还需要存储指向第一个邻接点的指针,以便于查找该顶点的边信息;

2、图中每个顶点vi的所有邻接点构成一个线性表,由于邻接点的个数不定,所以用单链表存储,无向图称为顶点vi的边表,有向图则称为顶点vi作为弧尾出边表。

有向图由于有方向,我们是以顶点为弧尾来存储边表的,这样很容易就可以得到每个顶点的出度。但也有时为了便于确定顶点的入度或以顶点为弧头的弧,我们可以建立一个有向图的逆邻接表,即对每个顶点vi都建立一个链接以vi为弧头的表。

此时我们很容易就可以算出某个顶点的入度或出度是多少,判断两顶点是否存在弧也很容易实现。

对于带权值的网图,可以在边表结点定义中再增加一个weight的数据域,存储权值信息即可,如图7-4-8所示。

结构定义:

// 顶点类型应由用户定义
typedef char VertexType;
// 边上的权值类型应由用户定义
typedef int EdgeType;
// 边表结点
typedef struct EdgeNode{
    // 邻接点域,存储该顶点对应的下标
    int adjvex;
    // 用于存储权值,对于非网图可以不需要
    EdgeType weight;
    // 链域,指向下一个邻接点
    struct EdgeNode *next;
} EdgeNode;
// 顶点表结点
typedef struct VertexNode{
    // 顶点域,存储顶点信息
    VertexType data;
    // 边表头指针
    EdgeNode *firstedge;
}VertexNode,AdjList[maxvex];
typdef strcut{
    AdjList adjlist;
    // 图中当前顶点数和边数
    int numVertexes,numEdges;
}GraphAdjList;
    

无向图的邻接表创建代码如下:

// 建立图的邻接表结构
void CreateALGragh(GraphAdjList *G)
{
    int i,j,k;
    EdgeNode *e;
    printf("输入顶点数和边数:\n");
    // 输入顶点数和边数
    scanf("%d,%d",&G->numVertexes,&G->numEdges);
    // 读入顶点信息,建立顶点表
    for (i=0;i<G->numVertexes;i++)
    {
        //输入顶点信息
        scanf(&G->adjList[i].data);
        //将边表置为空表
        G->adjList[i].firstedge=NULL;
    }
    // 建立边表
    for (k=0;k<G->numEdges;k++)
    {
        printf("输入边(vi,vj)上的顶点序号:\n");
        scanf("%d,%d:",&i,&j);
        // 向内存申请空间
        // 生成边表结点
        e=(EdgeNode *)malloc(sizeof(EdgeNode));
        // 邻接序号为j
        e->adjvex=j;
        // 将e指针指向当前顶点指向的结点(头插法)
        e->next=G->adjList[i].firstedge;
        G->adjList[i].firstedge=e;
        // 向内存申请空间
        // 生成边表结点
        e=(EdgeNode *)malloc(sizeof(EdgeNode));
        // 邻接序号为i
        e->adjvex=i;
        // 将e指针指向当前顶点指向的结点(头插法)
        e->next=G->adjList[j].firstedge;
        G->adjList[j].firstedge=e;
    }
}

 本算法的时间复杂度,对于n个顶点e条边来说,很容易得出是O(n+e)。

3、十字链表

对于有向图来说,邻接表是有缺陷的。关心了出度问题,想了解入度就必须要遍历整个图才能知道,反之,逆邻接表解决了入度却不了解出度的情况。而十字链表将邻接表与逆邻接表结合起来

重新定义顶点表结点结构如表7-4-1所示。

其中firstin表示入边表头指针,指向该顶点的入边表中第一个结点,firstout表示出边表头指针,指向该顶点的出边表中的第一个结点。

重新定义的边表结点结构如表7-4-2所示。

其中tailvex是指弧起点在顶点表的下标,headvex是指弧终点在顶点表中的下标,headlink是指入边表指针域,指向终点相同的下一条边,taillink是指出边表指针域,指向起点相同的下一条边。如果是网,还可以再增加一个weight域来存储权值

比如图7-4-10,顶点依然是存入一个一维数组{v0,v1,v2,v3},实线箭头指针的图示完全与图7-4-7的邻接表相同。就以顶点v0来说,firstout指向的是出边表中的第一个结点v3。所以v0边表结点的headvex=3,而tailvex其实就是当前顶点v0的下标0,由于v0只有一个出边顶点,所以headlink和taillink都是空。

虚线箭头其实就是此图的逆邻接表的表示。对于v0来说,它有两个顶点v1和v2的入边。因此v0的firstin指向顶点v1的边表结点中headvex为0的结点,如图7-4-10右图中的①。接着由入边结点的headlink指向下一个入边顶点v2,如图中的②。对于顶点v1,它有一个入边顶点v2,所以它的firstin指向顶点v2的边表结点中headvex为1的结点,如图中的③。顶点v2和v3也是同样有一个入边顶点,如图中④和⑤。

十字链表的好处就是因为把邻接表和逆邻接表整合在了一起,这样既容易找到以vi为尾的弧,也容易找到以vi为头的弧,因而容易求得顶点的出度和入度。而且它除了结构复杂一点外,其实创建图算法的时间复杂度是和邻接表相同的。

4、邻接多重表

如果我们更关注边的操作,比如对已访问过的边做标记,删除某一条边等操作,那就意味着,需要找到这条边的两个边表结点进行操作,这其实还是比较麻烦的。比如图7-4-11,若要删除左图的(v0,v2)这条边,需要对邻接表结构中右边表的阴影两个结点进行删除操作,显然这是比较烦琐的。

其中ivex和jvex是与某条边依附的两个顶点在顶点表中的下标。ilink指向依附顶点ivex的下一条边,jlink指向依附顶点jvex的下一条边。这就是邻接多重表结构。

我们开始连线,如图7-4-13。首先连线的①②③④就是将顶点的firstedge指向一条边,顶点下标要与ivex的值相同,这很好理解。接着,由于顶点v0的(v0,v1)边的邻边有(v0,v3)和(v0,v2)。因此⑤⑥的连线就是满足指向下一条依附于顶点v0的边的目标,注意ilink指向的结点的jvex一定要和它本身的ivex的值相同。同样的道理,连线⑦就是指(v1,v0)这条边,它是相当于顶点v1指向(v1,v2)边后的下一条。v2有三条边依附,所以在③之后就有了⑧⑨。连线⑩的就是顶点v3在连线④之后的下一条边。左图一共有5条边,所以右图有10条连线,完全符合预期。

邻接多重表与邻接表的差别,仅仅是在于同一条边在邻接表中用两个结点表示,而在邻接多重表中只有一个结点。这样对边的操作就方便多了,若要删除左图的(v0,v2)这条边,只需要将右图的⑥⑨的链接指向改为∧即可。

5、边集数组

边集数组是由两个一维数组构成。一个是存储顶点的信息;另一个是存储边的信息,这个边数组每个数据元素由一条边的起点下标(begin)、终点下标(end)和权(weight)组成,如图7-4-14所示。显然边集数组关注的是边的集合,在边集数组中要查找一个顶点的度需要扫描整个边数组,效率并不高。因此它更适合对边依次进行处理的操作,而不适合对顶点相关的操作

这一结构将会在Kruskal算法中应用。

图的遍历

图的遍历是和树的遍历类似,我们希望从图中某一顶点出发访遍图中其余顶点,且使每一个顶点仅被访问一次,这一过程就叫做图的遍历(Traversing Graph)。

我们需要在遍历过程中把访问过的顶点打上标记,以避免访问多次而不自知。具体办法是设置一个访问数组visited[n]n是图中顶点的个数,初值为0,访问过后设置为1

1、深度优先遍历

也称为深度优先搜索,简称为DFS

深度优先遍历其实就是一个递归的过程,如果再敏感一些,会发现其实转换成如图7-5-2的右图后,就像是一棵树的前序遍历,没错,它就是。它从图中某个顶点v出发,访问此顶点,然后从v的未被访问的邻接点出发深度优先遍历图,直至图中所有和v有路径相通的顶点都被访问到。事实上,我们这里讲到的是连通图,对于非连通图,只需要对它的连通分量分别进行深度优先遍历,即在先前一个顶点进行一次深度优先遍历后,若图中尚有顶点未被访问,则另选图中一个未曾被访问的顶点作起始点,重复上述过程,直至图中所有顶点都被访问到为止。

如果我们用的是邻接矩阵的方式,则代码如下:

/* Boolean是布尔类型,其值是TRUE或FALSE */
typedef int Boolean;             
/* 访问标志的数组 */
Boolean visited[MAX];            
/* 邻接矩阵的深度优先递归算法 */
void DFS(MGraph G, int i)
{
    int j;
    visited[i] = TRUE;
    /* 打印顶点,也可以其他操作 */
    printf("%c ", G.vexs[i]);    
    for (j = 0; j < G.numVertexes; j++)
        if (G.arc[i][j] == 1 && !visited[j])
            /* 对为访问的邻接顶点递归调用 */
            DFS(G, j);           
}
/* 邻接矩阵的深度遍历操作 */
void DFSTraverse(MGraph G)
{
    int i;
    for (i = 0; i < G.numVertexes; i++)
        /* 初始所有顶点状态都是未访问过状态 */
        visited[i] = FALSE;      
    for (i = 0; i < G.numVertexes; i++)
        /* 对未访问过的顶点调用DFS,若是连通图,只会执行一次 */
        if (!visited[i])         
            DFS(G, i);
}

对于n个顶点e条边的图来说,邻接矩阵由于是二维数组,要查找每个顶点的邻接点需要访问矩阵中的所有元素,因此都需要O(n

^2)的时间。 

如果图结构是邻接表结构,其DFSTraverse函数的代码是几乎相同的,只是在递归函数中因为将数组换成了链表而有不同,代码如下。

/* 邻接表的深度优先递归算法 */
void DFS(GraphAdjList GL, int i)
{
    EdgeNode *p;
    visited[i] = TRUE;
    /* 打印顶点,也可以其他操作 */
    printf("%c ", GL->adjList[i].data);    
    p = GL->adjList[i].firstedge;
    while (p)
    {
        if (!visited[p->adjvex])
            /* 对为访问的邻接顶点递归调用 */
            DFS(GL, p->adjvex);            
        p = p->next;
    }
}
/* 邻接表的深度遍历操作 */
void DFSTraverse(GraphAdjList GL)
{
    int i;
    for (i = 0; i < GL->numVertexes; i++)
        /* 对未访问过的顶点调用DFS,若是连通图,只会执行一次 */
        if (!visited[i])                   
            DFS(GL, i);
}


而邻接表做存储结构时,找邻接点所需的时间取决于顶点和边的数量,所以是O(n+e)。显然对于点多边少的稀疏图来说,邻接表结构使得算法在时间效率上大大提高

对于有向图而言,由于它只是对通道存在可行或不可行,算法上没有变化,是完全可以通用的。

2、广度优先遍历

又称为广度优先搜索,简称BFS

如果说图的深度优先遍历类似树的前序遍历,那么图的广度优先遍历就类似于树的层序遍历了。

我们将下图的第一幅图稍微变形,变形原则是顶点A放置在最上第一层,让与它有边的顶点B、F为第二层,再让与B和F有边的顶点C、I、G、E为第三层,再将这四个顶点有边的D、H放在第四层,如图7-5-3的第二幅图所示。此时在视觉上感觉图的形状发生了变化,其实顶点和边的关系还是完全相同的。

以下是邻接矩阵结构的广度优先遍历算法。

/* 邻接矩阵的广度遍历算法 */
void BFSTraverse(MGraph G)
{
    int i, j;
    Queue Q;
    for (i = 0; i < G.numVertexes; i++)
        visited[i] = FALSE;
    /* 初始化一辅助用的队列 */
    InitQueue(&Q);                                   
    /* 对每一个顶点做循环 */
    for (i = 0; i < G.numVertexes; i++)              
    {
        /* 若是未访问过就处理 */
        if (!visited[i])                             
        {
            /* 设置当前顶点访问过 */
            visited[i]=TRUE;                         
            /* 打印顶点,也可以其他操作 */
            printf("%c ", G.vexs[i]);                
            /* 将此顶点入队列 */
            EnQueue(&Q,i);                           
            /* 若当前队列不为空 */
            while (!QueueEmpty(Q))                   
            {
                /* 将队中元素出队列,赋值给i */
                DeQueue(&Q, &i);                     
                for (j = 0; j < G.numVertexes; j++)
                {
                    /* 判断其他顶点若与当前顶点存在边且未访问过 */
                    if (G.arc[i][j] == 1 && !visited[j])
                    {
                        /* 将找到的此顶点标记为已访问 */
                        visited[j]=TRUE;             
                        /* 打印顶点 */
                        printf("%c ", G.vexs[j]);    
                        /* 将找到的此顶点入队列 */
                        EnQueue(&Q,j);               
                    }
                }
            }
        }
    }
}

其中涉及到队列操作:

/* 用到的队列结构与函数********************************** */

typedef int Status;	/* Status是函数的类型,其值是函数结果状态代码,如OK等 */  

/* 循环队列的顺序存储结构 */
typedef struct
{
	int data[MAXSIZE];
	int front;    	/* 头指针 */
	int rear;		/* 尾指针,若队列不空,指向队列尾元素的下一个位置 */
}Queue;

/* 初始化一个空队列Q */
Status InitQueue(Queue *Q)
{
	Q->front=0;
	Q->rear=0;
	return  OK;
}

/* 若队列Q为空队列,则返回TRUE,否则返回FALSE */
Status QueueEmpty(Queue Q)
{ 
	if(Q.front==Q.rear) /* 队列空的标志 */
		return TRUE;
	else
		return FALSE;
}

/* 若队列未满,则插入元素e为Q新的队尾元素 */
Status EnQueue(Queue *Q,int e)
{
	if ((Q->rear+1)%MAXSIZE == Q->front)	/* 队列满的判断 */
		return ERROR;
	Q->data[Q->rear]=e;			/* 将元素e赋值给队尾 */
	Q->rear=(Q->rear+1)%MAXSIZE;/* rear指针向后移一位置, */
								/* 若到最后则转到数组头部 */
	return  OK;
}

/* 若队列不空,则删除Q中队头元素,用e返回其值 */
Status DeQueue(Queue *Q,int *e)
{
	if (Q->front == Q->rear)			/* 队列空的判断 */
		return ERROR;
	*e=Q->data[Q->front];				/* 将队头元素赋值给e */
	Q->front=(Q->front+1)%MAXSIZE;	/* front指针向后移一位置, */
									/* 若到最后则转到数组头部 */
	return  OK;
}
/* ****************************************************** */

对于邻接表的广度优先遍历,代码与邻接矩阵差异不大,代码如下。

/* 邻接表的广度遍历算法 */
void BFSTraverse(GraphAdjList GL)
{
    int i;
    EdgeNode *p;
    Queue Q;
    for (i = 0; i < GL->numVertexes; i++)
        visited[i] = FALSE;
    InitQueue(&Q);
    for (i = 0; i < GL->numVertexes; i++)
    {
        if (!visited[i])
        {
            visited[i] = TRUE;
            /* 打印顶点,也可以其他操作 */
            printf("%c ", GL->adjList[i].data);    
            EnQueue(&Q, i);
            while (!QueueEmpty(Q))
            {
                DeQueue(&Q, &i);
                /* 找到当前顶点边表链表头指针 */
                p = GL->adjList[i].firstedge;      
                while (p)
                {
                    /* 若此顶点未被访问 */
                    if (!visited[p->adjvex])       
                    {
                        visited[p->adjvex] = TRUE;
                        printf("%c ", GL->adjList[p->adjvex].data);
                        /* 将此顶点入队列 */
                        EnQueue(&Q, p->adjvex);    
                    }
                    /* 指针指向下一个邻接点 */
                    p = p->next;                   
                }
            }
        }
    }
}

对比图的深度优先遍历与广度优先遍历算法,你会发现,它们在时间复杂度上是一样的,不同之处仅仅在于对顶点访问的顺序不同。可见两者在全图遍历上是没有优劣之分的,只是视不同的情况选择不同的算法。

不过如果图顶点和边非常多,不能在短时间内遍历完成,遍历的目的是为了寻找合适的顶点,那么选择哪种遍历就要仔细斟酌了。深度优先更适合目标比较明确,以找到目标为主要目的的情况,而广度优先更适合在不断扩大遍历范围时找到相对最优解的情况

最小生成树

所谓的最小成本,就是n个顶点,用n-1条边把一个连通图连接起来,并且使得权值的和最小

一个连通图的生成树是一个极小的连通子图,它含有图中全部的顶点,但只有足以构成一棵树的n-1条边。那么我们把构造连通网的最小代价生成树称为最小生成树

1、普里姆(Prim)算法

我们先构造图7-6-1的邻接矩阵,如图7-6-3的右图所示。

也就是说,现在我们已经有了一个存储结构为MGragh的G(见本书7.4节邻接矩阵)。G有9个顶点,它的arc二维数组如图7-6-3的右图所示。数组中的我们用65535来代表∞。

于是普里姆(Prim)算法代码如下,左侧数字为行号。其中INFINITY为权值极大值,不妨是65535,MAXVEX为顶点个数最大值,此处大于等于9即可。

/* Prim算法生成最小生成树 */
  void MiniSpanTree_Prim(MGraph G)
  {
      int min, i, j, k;
      /* 保存相关顶点下标 */
      int adjvex[MAXVEX];                        
      /* 保存相关顶点间边的权值 */
      int lowcost[MAXVEX];                       
      /* 初始化第一个权值为0,即v0加入生成树 */
      /* lowcost的值为0,在这里就是此下标的顶点已经加入生成树 */
      lowcost[0] = 0;                            
      /* 初始化第一个顶点下标为0 */
      adjvex[0] = 0;                             
      /* 循环除下标为0外的全部顶点 */
      for (i = 1; i < G.numVertexes; i++)        
      {
         /* 将v0顶点与之有边的权值存入数组 */
         lowcost[i] = G.arc[0][i];              
         /* 初始化都为v0的下标 */
         adjvex[i] = 0;                         
     }
     for (i = 1; i < G.numVertexes; i++)
     {
         /* 初始化最小权值为∞, */
         /* 通常设置为不可能的大数字如32767、65535等 */
         min = INFINITY;                        
         j = 1; k = 0;
         /* 循环全部顶点 */
         while (j < G.numVertexes)              
         {
             /* 如果权值不为0且权值小于min */
             if (lowcost[j] != 0 && lowcost[j] < min)
             {                                  
                 /* 则让当前权值成为最小值 */
                 min = lowcost[j];              
                 /* 将当前最小值的下标存入k */
                 k = j;                         
             }
             j++;
         }
         /* 打印当前顶点边中权值最小边 */
         printf("(%d,%d)", adjvex[k], k);       
         /* 将当前顶点的权值设置为0,表示此顶点已经完成任务 */
         lowcost[k] = 0;                        
         /* 循环所有顶点 */
         for (j = 1; j < G.numVertexes; j++)    
         {
             /* 若下标为k顶点各边权值小于此前这些顶点未被加入生成树权值 */
             if (lowcost[j] != 0 && G.arc[k][j] < lowcost[j])
             {                                  
                 /* 将较小权值存入lowcost */
                 lowcost[j] = G.arc[k][j];      
                 /* 将下标为k的顶点存入adjvex */
                 adjvex[j] = k;                 
             }
         }
     }
 }

假设N=(V,{E})是连通网,TE是N上最小生成树中边的集合。算法从U={u0}(u0∈V),TE={}开始。重复执行下述操作:在所有u∈U,v∈V-U的边(u,v)∈E中找一条代价最小的边(u0,v0)并入集合TE,同时v0并入U,直至U=V为止。此时TE中必有n-1条边,则T=(V,{TE})为N的最小生成树。

总结来说,即从一个结点开始,先找到权值最小的一条边,记录下这条边依附的另一结点,再记录这一结点的各个边的权值,连同之前保存下来的第一个点到各点的距离进行比较,继续找权值最小的边......在此过程中,忽略已经访问过的点。

由算法代码中的循环嵌套可得知此算法的时间复杂度为O(n^2)。

2、克鲁斯卡尔(Kruskal)算法

Prim算法是以某顶点为起点,逐步找各顶点上最小权值的边来构建最小生成树的。

同样,我们也可以直接就以边为目标去构建,因为权值是在边上,直接去找最小权值的边来构建生成树也是很自然的想法,只不过构建时要考虑是否会形成环路

此时我们就用到了图的存储结构中的边集数组结构。以下是edge边集数组结构的定义代码:

// 对边集数组Edge结构的定义
typedef struct{
    int begin;
    int end;
    int weight;
}Edge;

我们将图7-6-3的邻接矩阵通过程序转化为图7-6-7的右图的边集数组,并且对它们按权值从小到大排序。

于是克鲁斯卡尔(Kruskal)算法代码如下,左侧数字为行号。其中MAXEDGE为边数量的极大值,此处大于等于15即可,MAXVEX为顶点个数最大值,此处大于等于9即可。

// Kruskal 算法生成最小生成树
// 生成最小生成树
void MiniSpanTree_Kruskal(MGraph G){
    int i,j,n,m;
    // 定义边集数组
    Edge edges[MAXEDGE];
    /* 用来构建边集数组并排序********************* */
	for ( i = 0; i < G.numVertexes-1; i++)
	{
		for (j = i + 1; j < G.numVertexes; j++)
		{
			if (G.arc[i][j]<INFINITY)
			{
				edges[k].begin = i;
				edges[k].end = j;
				edges[k].weight = G.arc[i][j];
				k++;
			}
		}
	}
	sort(edges, &G);
	/* ******************************************* */
    // 定义一数组用来判断边与边是否形成环路
    int parent[MAXVEX];
    // 此处省略将邻接矩阵G转化为边集数组edges
    // 并按权有小到大排序的代码
    for (i=0;i<G.numVertexes;i++){
        // 初始化数组值为0
        parent[i]=0;
    }
    // 循环每一条边
    for(i=0;i<G.numEdges;i++){
        n= Find(parent,edges[i].begin);
        m= Find(parent,edges[i].end);
        // 假如n与m不等,说明此边没有与现有生成树形成环路
        if(n!=m){
            // 将此边的结尾顶点放入下标为起点的parent中
            // 表示此顶点已经在生成树集合中
            parent[n]=m;
            printf("(%d,%d)%d",edges[i].begin,edges[i].end,edges[i].weight);
        }
    }
}

// 查找连线顶点的尾部下标
int Find(int *parent, int f){
    while(parent[f]>0)
        f=parent[f];
    return f;
}

其中,构造边集数组时用到了:

/* 交换权值 以及头和尾 */
void Swapn(Edge *edges,int i, int j)
{
	int temp;
	temp = edges[i].begin;
	edges[i].begin = edges[j].begin;
	edges[j].begin = temp;
	temp = edges[i].end;
	edges[i].end = edges[j].end;
	edges[j].end = temp;
	temp = edges[i].weight;
	edges[i].weight = edges[j].weight;
	edges[j].weight = temp;
}

/* 对权值进行排序 */
void sort(Edge edges[],MGraph *G)
{
	int i, j;
	for ( i = 0; i < G->numEdges; i++)
	{
		for ( j = i + 1; j < G->numEdges; j++)
		{
			if (edges[i].weight > edges[j].weight)
			{
				Swapn(edges, i, j);
			}
		}
	}
	printf("权排序之后的为:\n");
	for (i = 0; i < G->numEdges; i++)
	{
		printf("(%d, %d) %d\n", edges[i].begin, edges[i].end, edges[i].weight);
	}
}

我们其实是有两个连通的边集合A与B中纳入到最小生成树中的,如图7-6-12所示。当parent[0]=1,表示v0和v1已经在生成树的边集合A中。此时将parent[0]=1的1改为下标,由par-ent[1]=5,表示v1和v5在边集合A中,par-ent[5]=8表示v5与v8在边集合A中,par-ent[8]=6表示v8与v6在边集合A中,par-ent[6]=0表示集合A暂时到头,此时边集合A有v0、v1、v5、v8、v6。我们查看parent中没有查看的值,parent[2]=8表示v2与v8在一个集合中,因此v2也在边集合A中。再由parent[3]=7、par-ent[4]=7和parent[7]=0可知v3、v4、v7在另一个边集合B中。

当i=7时,第10行,调用Find函数,会传入参数edges[7].begin=5。此时第21行,parent[5]=8>0,所以f=8,再循环得par-ent[8]=6。因parent[6]=0所以Find返回后第10行得到n=6。而此时第11行,传入参数edges[7].end=6得到m=6。此时n=m,不再打印,继续下一循环。这就告诉我们,因为边(v5,v6)使得边集合A形成了环路。因此不能将它纳入到最小生成树中,如图7-6-12所示。11.当i=8时,与上面相同,由于边(v1,v2)使得边集合A形成了环路。因此不能将它纳入到最小生成树中,如图7-6-12所示。12.当i=9时,边(v6,v7),第10行得到n=6,第11行得到m=7,因此parent[6]=7,打印“(6,7)19”。此时parent数组值为{1,5,8,7,7,8,7,0,6},如图7-6-13所示。13.此后边的循环均造成环路,最终最小生成树即为图7-6-13所示。

简单来说,parent数组的作用就是求连通分量。通过层层循环,最终同一个连通分量里的顶点在Find函数中返回的数值是一样的。

假设N=(V,{E})是连通网,则令最小生成树的初始状态为只有n个顶点而无边的非连通图T={V,{}},图中每个顶点自成一个连通分量。在E中选择代价最小的边,若该边依附的顶点落在T中不同的连通分量上,则将此边加入到T中,否则舍去此边而选择下一条代价最小的边。依次类推,直至T中所有顶点都在同一连通分量上为止。

此算法的Find函数由边数e决定,时间复杂度为O(loge),而外面有一个for循环e次。所以克鲁斯卡尔算法的时间复杂度为O(eloge)

克鲁斯卡尔算法主要是针对边来展开,边数少时效率会非常高,所以对于稀疏图有很大的优势;而普里姆算法对于稠密图,即边数非常多的情况会更好一些。

最短路径

在网图和非网图中,最短路径的含义不同的。由于非网图没有边上的权值,所谓的最短路径,其实就是指两顶点之间经过的边数最少的路径;而对于网图来说,最短路径是指两顶点之间经过的边上权值之和最少的路径,并且我们称路径上的第一个顶点是源点,最后一个顶点是终点

非网图可以理解为所有边的权值都为1的网

1、迪杰斯特拉(Dijkstra)算法

从某个源点到其余各顶点的最短路径问题。按路径长度递增的次序产生最短路径的算法

它并不是一下子就求出了两个顶点间的最短路径,而是一步步求出它们之间顶点的最短路径,过程中都是基于已经求出的最短路径的基础上,求得更远顶点的最短路径,最终得到结果。

#define MAXVEX 9
#define INFINITY 65535
// 用于存储最短路径下标的数组
typedef int Patharc[MAXVEX];
// 用于存储到各点最短路径的权值和
ShortPathTable[MAXVEX];
// Dijkstra算法,求有向网G的v0顶点到其余顶点v最短路径P[v]及带权长度D[v]
// P[v]的值为前驱顶点下标,D[v]表示v0到v的最短路径长度和
void ShortestPath_Dijkstra(MGraph G, int v0, Patharc *P, ShortPathTable *D){
    int v,w,k,min;
    // final[w]=1表示求得顶点v0至vw的最短路径
    int final[MAXVEX];
    // 初始化数据
    for(v=0;v<G.numVertexes;v++){
        // 全部顶点初始化为未知最短路径状态
        final[v]=0;
        // 将与v0点有连线的顶点加上权值
        (*D)[v]=G.arc[v0][v];
        // 初始化路径数组P为-1
        (*P)[v]=-1;
    }
    // v0至v0不需要求路径
    final[v0]=1;
    // 开始主循环,每次求得v0到某个v顶点的最短路径
    for(v=1;v<G.numVertexes;v++){
        // 当前所知离v0顶点的最近距离
        min=INFINITY;
        // 寻找离v0最近的顶点
        for(w=0;w<G.numVertexes;w++){
            if(!final[w]&&(*D)[w]<min){
                k=w;
                min=(*D)[w]
            }
        }
        // 将目前找到的最近的顶点置为1
        final[k]=1;
        // 修正当前最短路径及距离
        for(w=0;w<G.numVertexes;w++){
            // 如果经过v顶点的路径比现在这条路径的长度短的话
            if(!final[w] && (min+G.arc[k][w]<(*D)[w])){
                // 说明找到了更短的路径,修改D[w]和P[w]
                // 修改当前路径长度
                (*D)[w]=min+G.arc[k][w];
                (*P)[w]=k;
            }
        }
    }
}

从循环嵌套可以很容易得到此算法的时间复杂度为O(n^2),尽管有同学觉得,可不可以只找到从源点到某一个特定终点的最短路径,其实这个问题和求源点到其他所有顶点的最短路径一样复杂,时间复杂度依然是O(n^2)。

可如果我们还需要知道如v3到v5、v1到v7这样的任一顶点到其余所有顶点的最短路径怎么办呢?此时简单的办法就是对每个顶点当作源点运行一次迪杰斯特拉(Dijkstra)算法,等于在原有算法的基础上,再来一次循环,此时整个算法的时间复杂度就成了O(n^3)

2、弗洛伊德(Floyd)算法

我们先定义两个二维数组D[3][3]和P[3][3],D代表顶点到顶点的最短路径权值和的矩阵,P代表对应顶点的最小路径的前驱矩阵,用来存储路径

在未分析任何顶点之前,我们将D命名为D-1,其实它就是初始的图的邻接矩阵。将P命名为P-1,初始化为图中所示的矩阵。

首先我们来分析,所有的顶点经过v0后到达另一顶点的最短路径。因为只有三个顶点,因此需要查看v-1→v0→v2,得到D-1[1][0]+D-1[0][2]=2+1=3。D-1[1][2]表示的是v1→v2的权值为5,我们发现D-1[1][2]>D-1[1][0]+D-1[0][2],通俗的话讲就是v1→v0→v2比直接v1→v2距离还要近。所以我们就让D-1[1][2]=D-1[1][0]+D-1[0][2]=3,同样的D-1[2][1]=3,于是就有了D0的矩阵。因为有变化,所以P矩阵对应的P-1[1][2]和P-1[2][1]也修改为当前中转的顶点v0的下标0,于是就有了P0。也就是说D0[v][w]=min{D-1[v][w],D-1[v][0]+D-1[0][w]}。

接下来,其实也就是在D0和P0的基础上继续处理所有顶点经过v1和v2后到达另一顶点的最短路径,得到D1和P1、D2和P2完成所有顶点到所有顶点的最短路径计算工作。

代码如下,注意因为是求所有顶点到所有顶点的最短路径,因此Pathmatrix和ShortPathTable都是二维数组

typedef int Pathmatrix[MAXVEX][MAXVEX];
typedef int ShortPathTable[MAXVEX][MAXVEX];
// Floyd算法,求网图G中各顶点v到其余顶点w最短路径P[v][w]及带权长度D[v][w]
void ShortestPath_Floyd(MGragh G,Pathmatrix *P,ShortPathTable *D){
    int v,w,k;
    // 初始化D与P
    for(v=0;v<G.numVertexes;++v){
        for(w=0;w<G.numVertexes;++w){
            // D[v][w]值即为对应点间的权值
            (*D)[v][w]=G.matrix[v][w];
            // 初始化P
            (*P)[v][w]=w;
        }
    }
    for(k=0;k<G.numVertexes;++k){
        for(v=0;v<G.numVertexes;++v){
            for(w=0;w<G.numVertexes;++w){
                if((*D)[v][w] > (*D)[v][k]+(*D)[k][w]){
                    // 如果经过下标为k顶点路径比原两点间路径更短
                    // 将当前两点间权值设为更小的一个
                    (*D)[v][w]=(*D)[v][k]+(*D)[k][w];
                    // 路径设置经过下标为k的顶点
                    (*P)[v][w]=(*P)[v][k];
                }
            }
        }
    }
}

这段代码简洁到就是一个二重循环初始化加一个三重循环权值修正,就完成了所有顶点到所有顶点的最短路径计算。

由于它的三重循环,因此也是O(n^3)时间复杂度。如果你面临需要求所有顶点至所有顶点的最短路径问题时,弗洛伊德(Floyd)算法应该是不错的选择。. 

另外,我们虽然对求最短路径的两个算法举例都是无向图,但它们对有向图依然有效,因为二者的差异仅仅是邻接矩阵是否对称而已。

区分最小生成树和最短路径:前者是“建路”,即把n个结点连通所付出的最小代价;后者是路已存在,求某两个结点的最短路径。

拓扑排序(无环的图的应用)

在一个表示工程的有向图中,用顶点表示活动,用弧表示活动之间的优先关系,这样的有向图顶点表示活动的网,我们称为AOV网(ActivityON)。AOV网中的弧表示活动之间存在的某种制约关系

设G=(V,E)是一个具有n个顶点的有向图,V中的顶点序列v1,v2,……,vn,满足若从顶点vi到vj有一条路径,则在顶点序列中顶点vi必在顶点vj之前。则我们称这样的顶点序列为一个拓扑序列

图7-8-1这样的AOV网的拓扑序列不止一条。序列v0 v1 v2 v3 v4 v5 v6 v7 v8 v9 v10 v11 v12 v13 v14 v15 v16 是一条拓扑序列,而v0 v1 v4 v3 v2 v7 v6 v5 v8 v10 v9 v12 v11 v14 v13 v15 v16也是一条拓扑序列。

所谓拓扑排序,其实就是对一个有向图构造拓扑序列的过程。构造时会有两个结果,如果此网的全部顶点都被输出,则说明它是不存在环(回路)的AOV网;如果输出顶点数少了,哪怕是少了一个,也说明这个网存在环(回路),不是AOV网

对AOV网进行拓扑排序的基本思路是:从AOV网中选择一个入度为0的顶点输出,然后删去此顶点,并删除以此顶点为尾的弧,继续重复此步骤,直到输出全部顶点或者AOV网中不存在入度为0的顶点为止

前面求最小生成树和最短路径时,我们用的都是邻接矩阵,但由于拓扑排序的过程中,需要删除顶点,显然用邻接表会更加方便。因此我们需要为AOV网建立一个邻接表。考虑到算法过程中始终要查找入度为0的顶点,我们在原来顶点表结点结构中,增加一个入度域in

因此对于图7-8-2的第一幅图AOV网,我们可以得到如第二幅图的邻接表数据结构。

在拓扑排序算法中,涉及的结构代码如下。

// 边表结点
typedef struct EdgeNode{
    // 邻接点域,存储该顶点对应的下标
    int adjvex;
    int weight;
    // 链域,指向下一个邻接点
    struct EdgeNode *next;
}
// 顶点表结点
typedef struct VertexNode{
    // 顶点入度
    int in;
    // 顶点域,存储顶点信息
    int data;
    // 边表头指针
    EdgeNode *firstedge;
}VertexNode,AdjList[MAXVEX];
typedef struct{
    AdjList adjList;
    // 图中当前顶点数和边数
    int numVertexes,numEdges;
}graphAdjList,*GRaphAdjList

我还需要辅助的数据结构—用来存储处理过程中入度为0的顶点,目的是为了避免每个查找时都要去遍历顶点表找有没有入度为0的顶点

// 拓扑排序,若GL无回路,则输出拓扑排序序列
// 并返回OK,若有回路返回ERROR
Status TopologicalSort(GraphAdjList GL){
    EdgeNode *e;
    int i,k,gettop;
    // 用于栈指针下标
     int top=0;
    // 用于统计输出顶点的个数
    int count=0;
    // 建栈存储入度为0的顶点
    int *stack;
    stack=(int*)malloc(GL->numVertexes *sizeof(int);
    for(i=0;i<GL->numVertexes;i++)
        if(GL->adjList[i].in==0)
            // 将入度为0的顶点入栈
            stack[++top]=i;
    while(top!=0){
        // 出栈
        gettop=stack[top--];
        // 打印此顶点
        printf("%d->",GL->adjList[gettop].data);
       // 统计输出顶点数
        count++;
        // 对此顶点弧表遍历
        for(e=GL->adjList[gettop].firstedge;e;e=e->next){
            k=e->adjvex;
            // 将k号顶点邻接点的入度减1
            if(!(--GL->adjList[k].in))
                // 若为0则入栈,以便于下次循环输出
                stack[++top]=k;
        }
    }
    if(count<GL->numVertexes)
        return ERROR;
    else
        return OK;
}

关键路径

拓扑排序主要是为解决一个工程能否顺序进行的问题,但有时我们还需要解决工程完成需要的最短时间问题。

我们如果要对一个流程图获得最短时间,就必须要分析它们的拓扑关系,并且找到当中最关键的流程,这个流程的时间就是最短时间

在一个表示工程的带权有向图中,用顶点表示事件,用有向边表示活动,用边上的权值表示活动的持续时间,这种有向图的边表示活动的网,我们称之为AOE网(Activity On Edge Network)。我们把AOE网中没有入边的顶点称为始点或源点没有出边的顶点称为终点或汇点。由于一个工程总有一个开始一个结束,所以正常情况下,AOE网只有一个源点一个汇点

例如图7-9-2就是一个AOE网。其中v0即是源点,表示一个工程的开始,v9是汇点,表示整个工程的结束,顶点v0,v1,……,v9分别表示事件,弧<v0,v1>,<v0,v2>,……,<v8,v9>都表示一个活动,用a0,a1,……,a12表示,它们的值代表着活动持续的时间,比如弧<v0,v1>就是从源点开始的第一个活动a0,它的时间是3个单位。

既然AOE网是表示工程流程的,所以它就具有明显的工程的特性。如只有在某顶点所代表的事件发生后,从该顶点出发的各活动才能开始。只有在进入某顶点的各活动都已经结束,该顶点所代表的事件才能发生

AOV网是顶点表示活动的网,它只描述活动之间的制约关系,而AOE网是用边表示活动的网边上的权值表示活动持续的时间,如图7-9-3所示两图的对比。因此,AOE网是要建立在活动之间制约关系没有矛盾的基础之上,再来分析完成整个工程至少需要多少时间,或者为缩短完成工程所需时间,应当加快哪些活动等问题。

我们把路径上各个活动所持续的时间之和称为路径长度从源点到汇点具有最大长度的路径关键路径,在关键路径上的活动关键活动。显然就图7-9-3的AOE网而言,开始→发动机完成→部件集中到位→组装完成就是关键路径,路径长度为5.5。

只有缩短关键路径上的关键活动时间才可以减少整个工期长度

判断关键活动:活动的最早开始时间等于最晚开始时间

为此,我们需要定义如下几个参数:
1.事件的最早发生时间etv(earliest time of vertex):即顶点vk的最早发生时间。
2.事件的最晚发生时间ltv(latest time of vertex):即顶点vk的最晚发生时间,也就是每个顶点对应的事件最晚需要开始的时间,超出此时间将会延误整个工期。
3.活动的最早开工时间ete(earliest time of edge):即弧ak的最早发生时间。
4.活动的最晚开工时间lte(latest time of edge):即弧ak的最晚发生时间,也就是不推迟工期的最晚开工时间。

我们是由1和2可以求得3和4,然后再根据ete[k]是否与lte[k]相等来判断ak是否是关键活动。

我们将图7-9-2的AOE网转化为邻接表结构如图7-9-4所示,注意与拓扑排序时邻接表结构不同的地方在于,这里弧链表增加了weight域,用来存储弧的权值。

求事件的最早发生时间etv的过程,就是我们从头至尾找拓扑序列的过程。因此,在求关键路径之前,需要先调用一次拓扑序列算法的代码来计算etv和拓扑序列列表。为此,我们首先在程序开始处声明几个全局变量。

int *etc,*ltv;    // 事件最早发生时间和最迟发生时间数组
int *stack2 // 用于存储拓扑序列的栈
int top2; // 用于stack2的指针

下面是改进过的求拓扑序列算法。

// 拓扑排序,用于关键路径计算
Status TopologicalSort(GraphAdjList GL){
    EdgeNode *e;
    int i,k,gettop;
    // 用于栈指针下标
    int top =0;
    // 用于统计输出顶点的个数
    int count=0;
    // 建议将入度为0的顶点入栈
    int *stack;
    stack=(int*) malloc(GL->numVertexes*sizeof(int));
    for(i=0;i<GL->numVertexes;i++)
        if(0==GL->adjList[i].in)
            stack[++top]=i;
        // 初始化为0
    top2=0;
    // 事件最早发生时间
    etv=(int*)malloc(GL->numVertexes *sizeof(int));
    for(i=0;i<GL->numVertexes;i++)
        // 初始化为0
        etv[i]=0;
    // 初始化
    stack2=(int*)malloc(GL->numVertexes * sizeof(int));
    while(top!=0){
        gettop=stack[top--];
        count++;
        // 将弹出的顶点序号压入拓扑序列的栈
        stack2[++top2]=gettop;
        for(e=GL->adjList[gettop].firstedge;e;e=e->next){
            k=e->adjvex;
            if(!(--GL->adjList[k].in))
                stack[++top]=k;
            // 求各顶点事件最早发生时间值
            if((etv[gettop]+e->weight)>etv[k])
                etv[k]=etv[gettop]+e->weight;
        }
    }
    if(count<GL->numVertexes)
        return ERROR;
    else
        return OK;
}

顶点最早发生时间,即以该顶点为弧头的弧(活动)均已进行完毕时,该顶点才可开始发生,也即代码中的:

            if((etv[gettop]+e->weight)>etv[k])
                etv[k]=etv[gettop]+e->weight;

用了“>"符号的原因。

下面是求关键路径的算法代码:

// 求关键路径,GL为有向网,输出GL的各项关键活动
void CriticalPath(GraphAdjList GL){
    EdgeNode *e;
    int i,gettop,k,j;
    // 声明活动最早发生时间和最迟发生时间变量
    int ete,lte;
    // 求拓扑序列,计算数组etv和stack2的值
    TopologicalSort(GL);
    // 事件最晚发生时间
    ltv=(int*)malloc(GL->numVertexes*sizeof(int));
    for(i=0;i<GL->numVertexes;i++)
        // 初始化ltv
        ltv[i]=etv[GL->numVertexes-1];
    // 计算ltv
    while(top2!=0){
        //将拓扑序列出栈,后进先出
        gettop=stack2[top2--];
        for(e=GL->adjList[gettop].firstedge;e;e=e->next){
            // 求各顶点事件的最迟发生时间ltv值
            k=e->adjvex;
            // 求各顶点事件最晚发生时间ltv
            if(ltv[k]- e->weight<ltv[gettop])
                ltv[gettop]=ltv[k]-e->weight;
        }
    }
    // 求ete,lte和关键活动
    for(j=0;j<GL->numVertexes;j++){
        for(e=GL->adjList[j].firstedge;e;e=e->next){
            k=e->adjvex;
            // 活动最早发生时间
            ete=etv[j];
            // 活动最迟发生时间
            lte=ltv[k]-e->weight;
            // 两者相等即在关键路径上
            if(ete=lte)
                printf("<v%d,v%d>length:%d,",GL->adjList[j].data,GL->adjList[k].data,e->weight);
        }
    }
}

我们在计算ltv时,其实是把拓扑序列倒过来进行的。因此我们可以得出计算顶点vk即求ltv[k]的最晚发生时间的公式是:

其中S[K]表示所有从顶点vk出发的弧的集合。比如图7-9-8的S[4]就是<v4,v6>和<v4,v7>两条弧,en<vk,vj>是弧<vk,vj>上的权值。

ete本来是表示活动<vk,vj>的最早开工时间,是针对弧来说的。但只有此弧的弧尾顶点vk的事件发生了,它才可以开始,因此ete=etv[k]

而lte表示的是活动<vk,vj>的最晚开工时间,但此活动再晚也不能等vj事件发生才开始,而必须要在vj事件之前发生,所以lte=ltv[j]-len<vk,vj>

所以最终,其实就是判断ete与lte是否相等,相等意味着活动没有任何空闲,是关键活动,否则就不是。

最终求关键路径算法的时间复杂度依然是O(n+e)

不过注意,本例是唯一一条关键路径,这并不等于不存在多条关键路径的有向无环图。如果是多条关键路径,则单是提高一条关键路径上的关键活动的速度并不能导致整个工程缩短工期,而必须提高同时在几条关键路径上的活动的速度

总结

通常稠密图,或读存数据较多,结构修改较少的图,用邻接矩阵要更合适,反之则应该考虑邻接表。

猜你喜欢

转载自blog.csdn.net/qq_36770641/article/details/82285291