逻辑结构

图G,由顶点集V和边集E组成,即为G=(V,E),其中V(G)表示图G中顶点的有限非空集,E(G)表示图G中顶点之间的关系集合。
若V = {v1,v2.....,vn},则用|V|表示图G中顶点的个数,E = {(u,v) | u ∈ V, v ∈ V},用|E|表示图G中边的条数。

线性表可以是空表,树可以是空树,但图不能是空图。
图中不能一个顶点没有,但可以没有边。

有向图

E为有向边(也称_弧_)的有限集合。
弧是顶点的有序对,记为<v,w>,v和w是顶点,v是弧尾(tail),w是弧头(head),<v,w>称为从v到w的弧,也称v邻接到w。

无向图

E为无向边(简称_边_)的有限集合。
边是顶点的无序对,即为(v,w)或(w,v),v和w是顶点。可以说v和w互为邻接点。
边(v,w)依附于v和w,或称边(v,w)与v,w相关联。

简单图

满足:

  1. 不存在重复边
  2. 不存在顶点到自身的边

反之成为多重图。

完全图(简单完全图)

  • 对于无向图

|E| = n(n-1)/2时,称为完全图。即任意两个顶点都存在边。

  • 对于有向图

|E| = n(n-1)时,称为完全图。即任意两个顶点之间都存在方向相反的两条弧。

子图

对于G = (V,E)和G' = (V',E'),若V'∈V,且E'∈E,则称为G'为G的子图。
若有V(G') = V(G)的子图G',则称G'为G的生成子图。(只是边集取了子集,顶点仍是原集)

并非V和E的所有子集都能构成子图

其他定义

  • 连通

无向图中,如果v到w有路径存在,称为v与w是连通的
如果无向图中任意两个结点都是连通的,则称为连通图
无向图中的极大连通子图称为连通分量

  • 强连通

有向图中,如果v到w与w到v都有路径存在,称v与w是强连通的
如果有向图中任意两个结点都是强连通的,则称为强连通图
有向图中的极大强连通子图称为强连通分量

  • 生成树

连通图的生成树是包含图中全部顶点的一个极小连通子图。
n个顶点,则生成树有n-1条边。
如果去掉生成树的一条边,就会变成非连通图。
如果加上一条边,就会出现回路。

  • 生成森林

在非连通图中,连通分量的生成树构成了非连通图的生成森林

  • 度、入度、出度

无向图中,顶点v的度是依附于该点的边的条数,记为TD(v)。
无向图的全部顶点的度的和 = 边数的二倍,因为每条边与两个顶点关联。
有向图中,顶点v的入度是以顶点v为弧头的有向边的数目,记为ID(v),顶点v的出度是以顶点v为弧尾的有向边的数目,记为OD(v)。顶点v的度 = 入度+ 出度。
有向图的全部顶点的入度之和 = 出度之和,因为每条有向边都有一个弧尾和弧头。

每条边可以具有某种含义的数值,称为该边的权值。
带权图又称_网_。

  • 稠密图、稀疏图

边数很少的图是稀疏图,反之为稠密图。
一般当|E| < |V| log|V|时,可以视为稀疏图。

  • 简单路径、简单回路

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

  • 距离

从顶点u到顶点v的最短路径的长度,如果不存在,则为∞。

  • 有向树

一个顶点的入度为0、其余顶点的入度均为1的有向图,称为有向树。

物理结构

邻接矩阵法

用一个一维数组存储顶点,用一个二维数组存储边(邻接矩阵)。

  • 在简单应用中,可以直接用邻接矩阵来表示图(顶点信息忽略)。
  • 当邻接矩阵中的元素仅用来表示相应边是否存在时,可采用值为0和1的枚举类型。
  • 无向图的邻接矩阵是对称矩阵(实际是上(或下)三角),对规模较大的邻接矩阵可以采用压缩存储。
  • 邻接矩阵表示法的空间复杂度为O(n2),n为图的顶点数|V|。

此外:

  • 无向图的邻接矩阵的第i行(或第i列)非零元素(或非∞元素)的个数是顶点i的度TD(vi)。

  • 有向图的邻接矩阵的第i行非零元素(或非∞元素)的个数是顶点i的出度OD(vi)。

    第i列非零元素(或非∞元素)的个数是顶点i的入度ID(vi)。

  • 在邻接矩阵中,很容易确定图中任意两结点是否相连;但要确定图中有多少条边,则必须按行、按列逐个扫描,时间代价大。

  • 稠密图适合用邻接矩阵表示。

  • 图G的邻接矩阵A,An的元素An[i][j]等于由顶点i到顶点j的长度为n的路径的数目。

#define MaxVertexNum 100

typedef char VertexType;	//顶点的数据类型
typedef int EdgeType;		//带权图中边上权值的数据类型
typedef struct {
    VertexType Vex[MaxVertexNum];	//顶点表
    EdgeType Edge[MaxVertexNum][MaxVertexNum]	//邻接矩阵,边表
    int vexnum, arcnum;
}Graph;

邻接表法

稀疏图如果用邻接矩阵法,会浪费很多空间。采用邻接表法可以极大地节省空间
邻接表通过对每个结点建立一个单链表,第i个单链表中的结点表示依附于顶点i的边(有向图中则是以顶点i为弧尾的边),这个单链表称为顶点i的边表(对于有向图则是出边表)。
所以,邻接表法实际上就是 顶点表(边表的头指针)+ 边表 组成的。

  • 无向图所需要的存储空间为O(|V| + 2|E|)
  • 有向图所需要的存储空间为O(|V| + |E|)
  • 对于稀疏图,邻接表存储能极大地节省存储空间
  • 给定一结点,可以很方便地找出它的所有邻边;但若要查询两个顶点之间是否存在边,则需要扫描边表,相对邻接矩阵法来说,稍慢。
  • 给定一结点,确认其出度可以直接扫描其邻接表中结点的个数;但确认其入度则需要扫描全部结点的邻接表。
  • 邻接表并不唯一,因为边表中结点的顺序是任意的。
#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;

十字链表

十字链表是有向图的一种存储结构。

邻接多重表

邻接多重表是无向图的一种存储结构。

图的基本操作

图的基本操作是独立于存储结构的。

图的遍历

广度优先搜索(BFS)

广度优先搜索,类似于二叉树的层序遍历算法。
以v为起点,依次访问和v路径长度为1,2.....的顶点。

广度优先算法是一种分层查找过程,不像深度优先那样有往回退的情况,所以并不是递归类算法。

无论是邻接表还是邻接矩阵的的存储方式,BFS都需要借助一个辅助队列Q,n个顶点均需要入队一次,在最坏的情况下,空间复杂度为O(|V|)。(就只有两层)
采用邻接表存储方式时,每个顶点均需要搜索一次(或入队一次),时间复杂度O(|V|)。在搜索任一顶点的邻接点时,每条边至少访问一次,时间复杂度为O(|E|)。总的时间复杂度为O(|E|+|V|)。
采用邻接矩阵存储方式时,查询每个顶点的邻接点时间复杂度为O(|V|)。总的时间复杂度O(|V|²)。

广度优先生成树

在广度遍历的过程得到一棵遍历树。
邻接矩阵是唯一的,所以遍历其生成的广度优先生成树是唯一的。
邻接表是不唯一的,所以遍历其生成的广度优先生成树是不唯一的。

深度优先搜索(DFS)

类似于树的先序遍历。
以v为起点,访问与v邻接且未被访问过的任一顶点w1,在访问与w1邻接且未被访问过的任一顶点w2.......
直到不能再访问时,退回最近被访问的顶点,若还有其他邻接顶点未访问过,则重复上面搜索过程。

DFS是一种递归算法

无论是邻接表还是邻接矩阵的存储方式,DFS都需要借助一个栈,空间复杂度为O(|V|)。
遍历图的过程实质上是对每个顶点查找其邻接点的过程,耗费的时间取决于存储结构。
采用邻接矩阵的存储方式时,查找每个顶点的邻接点时间复杂度O(|V|)。总时间复杂度O(|V|2)。
采用邻接表的存储方式时,查找每个顶点的邻接点时间复杂度O(|E|)。访问顶点需要的时间复杂度O(|V|)。总时间复杂度O(|V|+|E|)。

深度优先生成树和生成森林

对连通图的DFS的遍历过程得到一棵遍历树,深度优先生成树。
对非连通图的DFS遍历过程得到一个森林,深度优先生成森林。
与BFS类似,基于邻接表的DFS生成树是不唯一的。

图的遍历与图的连通性
图的遍历算法可以用来判断图的连通性
对于无向图,如果是连通的,则从任一顶点出发,一次遍历可以访问图的所有顶点。否则只能访问到该顶点属于的连通分量中的所有顶点。
对于有向图,如果是连通的。则从初始点可以访问图中的所有顶点。

图的应用

最小生成树

一个连通图的生成树包含图的所有顶点,并且包含最少的边。
生成树中,去掉一条边,就会变成非连通图。加上一条边,就会出现回路。
对于一个带权连通无向图G,可能有多个生成树,生成树不同,每棵树的权(即树中所有边上的权值之和)也可能不同。其中权值最小的生成树,叫最小生成树(Minimum-Spaning-Tree,MST)

  • 带权连通图可能有多个最小生成树,但这些最小生成树的权值和是唯一且最小的。当带权连通图中各边权值都不相等时,最小生成树唯一。
  • 最小生成树的边数 = 顶点数 - 1。当带权连通图本身边数比顶点数少1,即本身是一棵树时,最小生成树就是它本身。

构造最小生成树的算法

利用性质:

  1. 带权连通无向图G = (V,E)
  2. U ∈ V
  3. (u,v)是具有最小权值的一条边,且u∈U,v∈V-U

则必存在一棵包含(u,v)的最小生成树。

Prim算法

Prim算法非常类似于寻找图的最短路径的Dijkstra算法。

  1. 从图中任取一顶点加入生成树T(此时只有这一个顶点)
  2. 选择一个与T中顶点集合距离最近的顶点(权值最小),把它和相应的边加入T
  3. 重复2.直至所有顶点都并入T

每次2.操作都会使T中顶点数和边数加1,最后T包含所有的n个顶点以及n-1条边。

时间复杂度 O(|V2|),不依赖|E|,因此它适合求解边稠密的图的最小生成树。

Kruskal算法

与Prim算法从顶点开始拓展最小生成树不同,Kruskal(克鲁斯卡尔)算法是一种按权值的递增次序选择合适的边来构造最小生成树的方法

  1. 初始只有n个结点,而无边的非连通图T。每个顶点自成一个连通分量。
  2. 将剩下的边按照权值递增排序,选取权值最小的边,若该边依附于T中不同的连通分量上,则加入T,否则舍弃改变选择下一条权值最小的边。
  3. 重复2.直至T中所有顶点都在一个连通分量上。


通常采用堆来存放剩余边的集合,每次选择最小权值的边只需要O(log|E|)的时间。
此外,由于生成树T中的所有边可视为一个等价类,因此每次添加新的边的过程类似于求解等价类的过程,所以可以使用并查集的数据结构来描述T,从而构造T的时间复杂度为O(|E|log|E|)。
适用于边稀疏且顶点较多的图。

最短路径

对于无权图,广度优先搜索可以解决最短路径的问题。
对于有权图,需要考虑边的权值的问题,最短路径也指的是带权最短长度。

求最短路径的算法

依赖性质:两点间的最短路径也包含了路径上的其他顶点间的最短路径。
对于带权有向图,最短路径问题包括:

  • 某一顶点到其他各顶点的最短路径(单源最短路径)
  • 每对顶点间的最短路径

Dijkstra

Dijkstra(迪杰斯特拉)用于求解单源最短路径问题。
设置一个S集合记录已求得的最短路径的顶点。

  1. 把起点v0放入S
  2. 从顶点集合V-S中选择vj,vj就是当前求得的从v0出发路径最短的顶点,将vj加入S
  3. 更新从v0到集合V-S上任一顶点vk可达的最短路径长度
  4. 重复2.3. 共n-1次,直到所有顶点都包含在S中


使用邻接矩阵表示时,时间复杂度为O(|V|2)。
使用带权的邻接表表示时,时间复杂度仍为O(|V|2)。
如果只希望找到从源点到目的顶点的最短路径,也和这个问题一样复杂,时间复杂度也是O(|V|2)。

边上带有负权值时,Dijkstra算法不适用。

/*
图采用邻接表的存储结构
*/
void dijkstra(Graph G, int v) {
    int flag[MaxVertexNum];             //标识各顶点是否已找到最短路径:1 已找到; 0 未找到
    int dist[MaxVertexNum];             //记录到各顶点的最短路径长度
    int path[MaxVertexNum];             //记录到各顶点的最短路径上的前驱结点:-1 无前驱结点

    //初始化起点
    for (int i = 0; i < G.vexnum; i++) {
        flag[i] = 0;                    
        dist[i] = G.Edge[v][i];	        //起点到各顶点的权值(若为起点的直接后继则为权值,否则为INF)
        if (dist[i] != INF)             //顶点i是起点的直接后继
            path[i] = 0;
        else
            path[i] = -1;        
    }
    flag[v] = 1;
    dist[v] = 0;

    for (int i = 1; i < G.vexnum; i++)
    {
        //寻找当前最小路径

    }
  

}

Floyd

Floyd(弗洛伊德)算法用于求解各顶点之间的最短路径问题。
基本思想:递推产生一个n阶方阵序列A(-1),A(0),A(1)....,A(n-1),其中A(k)[i][j]表示从顶点vi到vj的路径长度,k表示绕行第k个顶点的运算步骤。
初始时,如果两个顶点间存在边,则以该边的权值作为最短路径长度。如果不存在有向边,则以∞作为它们之间的最短路径长度。
然后,逐步尝试在原路径中加入顶点k(k = 0,1,2...n-1)作为中间顶点。若添加中间顶点后,得到的路径比原来路径长度少了,则代替原路径。

图片

Floyd算法是一个迭代的过程,每迭代一次,在从vi到vj的最短路径上就多考虑了一个顶点;经过n次迭代后,得到的A(n-1)[i][j]就是vi到vj的最短路径长度。即方阵A(n-1)中就保存了任意一对顶点之间的最短距离长度。
时间复杂度为O(|V|3)。
Floyd算法允许带有负权值的边,但不允许有包含负权值的边组成的回路。
Floyd算法也适用于带权无向图,因为带权无向图可视为权值相同往返二重边的有向图。

也可用单源最短路径算法来解决每对顶点之间的最短路径问题。
轮流将每个顶点作为起点,并且在所有边权值非负时,运行一次Dijkstra算法,时间复杂度O(|V|2) * O(|V|) = O(|V|3)

拓扑排序

用有向无环图(DAG图)来表示一个工程,其顶点表示活动,其有向边<vi,vj>表示活动i和活动j进行的一种关系,则将这种有向图称为_顶点表示活动的网络,记为_AOV网。
在AOV网中,活动i是活动j的直接前驱,活动j是活动i的直接后继,这种前驱和后继关系具有传递性,且任何活动不能以它自己作为自己的前驱或后继(即不能与自己有关系)。
在图论中,由一个有向无环图的顶点组成的序列,当且仅当满足以下条件时,称为该图的一个拓扑排序:

  1. 每个顶点出现且只出现一次
  2. 若顶点A排在顶点B的前面,则图中不存在从顶点B到顶点A的路径

或定义为,拓扑排序是对有向无环图的顶点的一种排序,使得若存在一条从顶点A到顶点B的路径,则排序中顶点B出现在顶点A的后面。
每个AOV网都有一个或多个拓扑排序序列。

拓扑排序算法

常用步骤:

  1. 从AOV网中选择一个没有前驱的顶点并输出
  2. 从网中删除该顶点和所有以它为起点的有向边
  3. 重复1. 2. 直到AOV网为空,或当前网中不存在无前驱的顶点为止(说明有向图中存在环)。


由于输出每个顶点同时还要删除以它为起点的边,所以采用邻接表存储时拓扑排序的时间复杂度为O(|V|+|E|),采用邻接矩阵存储时拓扑排序的时间复杂度为O(|V|2)。
用拓扑排序处理AOV网时需要注意:

  • 入度为0的顶点(即没有前驱活动或前驱活动都已经完成的顶点),工程可以从这个顶点活动开始或继续。
  • 若一个顶点有多个直接后继,则拓扑排序的结果通常不唯一;但若各个顶点已经排在一个线性有序序列中,每个顶点有唯一的前驱和后继关系,则拓扑排序的结果是唯一的。
  • 由于AOV网中各顶点地位平等,每个顶点编号是人为的。因此可以按拓扑排序的结果重新编号,生成AOV网新的邻接存储矩阵,这种邻接矩阵一般是三角矩阵。
  • 对于一般图,若其邻接矩阵为三角矩阵,则存在拓扑序列;反之不对。
/*
基于邻接表的图
已有条件:
- VNode[] indegree,记录着入度为0的顶点序号
- VNode[] print,记录着输出的顶点序号
*/
bool TopologicalSort(Graph G){
    InitStack(S);						//初始化栈,用来存储入度为0的顶点
	    
    for(int i = 0; i < G.vexnum; i++)
        if(indegree[i] == 0)			//将所有入度为0的顶点入栈
            Push(S, i);	
    
    int count = 0;						//计数,记录当前已经输出的顶点数
    while(!Empty(S)){					//栈不空,则还存在入度为0的顶点
        int i = Pop(S);				
        print[count++] = i;				//输出

        //找到输出顶点的所有弧,对应找到所有相邻后继顶点
        for(ArcNode* p = G.vertices[i].firstArc; p; p = p->nextArc){
            int v = p->adjvex;
            if(!(--indegree[v]))		//将所有相邻后继顶点的入度-1后,如果==0,压入栈中
                Push(S, v);
        }
    }

    if(count < G.vexnum)
        return false;					//排序失败,有向图中有回路
    else
        return true;					//排序成功,print[]数组即为排序结果
}

关键路径

用有向带权无环图(DAG网且带权)来表示一个工程,其顶点表示事件,其有向边表示活动,边上的权值表示该活动的开销,则称这种有向图为_用边表示活动的网络_,记为_AOE网_。
AOE网有以下两个性质:

  1. 只有在某顶点所代表的事件发生后,从该顶点出发的各有向边所代表的活动才能开始。
  2. 只有在进入某顶点的各有向边所代表的活动都结束时,该顶点所代表的事件才能发生。

在AOE网中,仅有一个入度为0的顶点,称为开始顶点(源点),它表示整个工程的开始;也仅有一个出度为0的顶点,称为结束顶点(汇点),它表示整个工程的结束。
在AOE网中,有些活动可以是并行进行的。从源点到汇点的有向路径可能有多条,并且路径长度可能不同。虽然不同路径上的活动所需要的时间不同,但是只有所有路径上的活动完成后,整个工程才算结束。因此,从源点到汇点的所有路径中,具有最大路径长度的路径称为关键路径,该路径上的顶点代表的活动称为关键活动。(其实就是拖后腿的)

寻找关键路径所用到的参量

事件vk的最早发生时间ve(k)

从源点v1到顶点vk的最长路径长度。
事件vk的最早发生时间决定了所有从vk开始的活动能动工的最早时间。
可用下面的递推公式计算:

  1. ve(v1) = 0 (v1为源点)
  2. ve(k) = Max{ve(j) + Weight(vj,vk)},vk为vj的任意后继

计算ve()值时,按从前往后的顺序进行,可以在拓扑排序的基础上计算:

  1. 初始时,令ve[1....n] = 0
  2. 输出一个入度为0的顶点vj时,计算它所有直接后继顶点vk的最早发生时间,若ve[j] + Weight(vj,vk) > ve[k],则ve[k] = ve[j] + Weight(vj,vk),以此类推,直至输出全部顶点

事件vk的最迟发生时间vl(k)

在不推迟整个工程完成的前提下,即保证它的后继事件vj在其最迟发生时间vl(j)能够发生时,该时间最迟发生的时间。
可用下面的递推公式计算:

  1. vl(vn) = ve(vn) (vn为汇点)
  2. vl(k) = Min{vl(j) - Weight(vk,vj)},vk为vj的任意前驱

计算vl()值时,按从后往前的顺序进行,可以在逆拓扑排序的基础上计算——增设一个栈以记录拓扑序列,最后出栈序列即为逆拓扑序列:

  1. 初始时,令vl[1....n] = ve[n]
  2. 栈顶顶点vj出栈,计算所有直接前驱顶点vk的最迟发生时间,若vl[j] - Weight(vk,vj) < vl[k],则vl[k] = vl[j] - Weight(vk,vj),以此类推,直至输出全部栈中顶点

活动ai的最早开始时间e(i)

该活动弧的起点事件的最早发生时间,ai = <vk,vj>,则e(i) = ve(k)。

活动ai的最迟开始时间l(i)

该活动弧的终点事件的最迟发生时间与该活动所需时间之差,ai = <vk,vj>,则l(i) = vl(k) - Weight(vk,vj)。

活动ai的最迟开始时间l(i)和最早开始时间e(i)之差 d(i) = l(i) - e(i)

活动完成的时间余量,即在不增加完成整个工程所需总时间的情况下,活动ai可以拖延的时间。
若一个活动的时间余量为0,则说明该活动必须如期完成,否则就会拖慢整个工程的进度。
所以,l(i) - e(i) = 0 或 l(i) = e(i) 的活动是关键活动

求解关键路径

  1. 从源点出发,令ve(v1) = 0,按拓扑有序求其余顶点的最早发生时间ve()
  2. 从汇点出发,令vl(vn) = ve(vn),按逆拓扑排序求其余顶点的最迟发生时间vl()
  3. 根据各顶点的ve()值求所有弧的最早开始时间e()
  4. 根据各顶点的vl()值求所有弧的最迟开始时间l()
  5. 求AOE网中所有活动的差额d(),找出所有d() = 0的活动构成关键路径


posted @ 2023-03-23 15:57  青子Aozaki  阅读(141)  评论(0)    收藏  举报