数据结构-图

图的基本概念

图的定义

图G(Graph)由顶点集V(Vertex)和边集E(Edge)组成,记为G=(V,E),其中V(G)表示图G中顶点的有限非空集:E(G)表示图G中顶点之间的关系(边)集合,如图1-1。

注:

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

有向图

有向图就是点与点之间相互有方向之间的关系,若设v,w为顶点,而<v,w>为从顶点v到顶点w,则有向图中<v,w>\neq<w,v>。

无向图

在无向图中,若设v,w为顶点,而<v,w>为从顶点v到顶点w,则有<v,w>=<w,v>。

简单图、多重图

简单图就是满足以下条件的图:

  1. 不存在重复边。

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

多重图就是图中某两个结点的边数多余一条,又允许顶点通过同一条边和自己关联,则G为多重图。

顶点的度、入度、出度

对于无向图:顶点v的度是指依附于该顶点的边的条数,记为TD(v)。

在具有n个顶点、e条边的无向图中,无向图的全部顶点度的和等于边数的2倍:

\\sum_{i=1}\^nTD(v_i)=2e

对于有向图:

入度是以顶点v为终点的有向边的数目,记为ID(v)。

出度是以顶点v为起点的有向边的数目,记为OD(v)。

顶点v的度等于其入度和出度之和,即TD(v)=ID(v)+OD(v)。

在具有n个顶点、e条边的有向图中,

\\sum_{i=1}\^nID(v_i)=\\sum_{i=1}\^nOD(v_i)=e

即出度和入度的数量相等且等于边数。

路径、回路、距离

路径:顶点v_p到顶点v_q之间的一条路径是指顶点序列,在有向图中的路径也是有向的

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

简单路径:在路径序列中,顶点不重复出现的路径。

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

路径长度:路径上边的数目。

点到点距离:顶点u到到顶点v的最短路径称为u到v的距离,若不存在路径,则记路径为无穷。

连通、连通图、强连通图

连通:

  • 无向图中若顶点v到w有路径存在,则v和w是连通

  • 有向图中,若顶点v到w和从w到v都有路径,则v和w是强连通

连通图:若任意两个顶点都是连通的,则称为连通图,否则为非连通图。

强连通图:若图中任意一对顶点都是强连通,则称图为强连通图。

注:

对于n个顶点的无向图G,

若G是连通图,则最少有n-1条边

若G是非连通图,则最多可能有C_{n-1}^2条边。

对于n个顶点有向图G,

若G是强连通图,则最少有n条边(形成回路)。

子图

如图1-4所示,右图的图是左图的子集,即称为子图。

而若有满足V(G')=V(G)的子图G',则称其为G的生成子图,图1-5即为生成子图,相比原图少了些边。

连通分量、强连通分量、生成树、生成森林

无向图中的极大连图子图(子图必须连通,同时保留尽可能多的边)称为连通分量。

有向图中的极大强连通子图(子图必须强连通,同时保留尽可能多的边)称为有向图的强连通分量。

连通图的生成树是包含图中全部顶点的极小连通子图。

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

边的权、带权图/网

边的权:在一个图中,每条边都可以标上具有某种含义的数值,该数值称为该边的权值。

带权图/网:边上带有权值的图称为带权图,也称网。

带权路径长度:当图是带权图时,一条路径上所有边的权值之和,称为该路径的带权路径长度。

图的存储及基本操作

邻接矩阵法

邻接矩阵存储就是用一个一维数组存储顶点的信息,用二维数组存储图中边的信息(即各顶点之间的邻接关系),存储顶点的邻接关系的二维数组称邻接矩阵。

图2-1右边的矩阵就是无向图使用邻接矩阵后所在内存中存储的结构。

获取某个结点的度只需对所在行或列 进行遍历,获取到的非零元素总和即为结点的度。

图2-2是有向图用邻接矩阵后所在内存中存储的结构。

获取某个结点的出度 是对所在 进行遍历获取非零元素总和,获取入度 则是对所在进行遍历获取非零元素总和。

邻接矩阵存储结构定义:

复制代码
#define MaxVertexNum 100 //顶点数目最大值
typedef char VertexType; //顶点的数据类型
typedef int EdgeType; //带权图中边上权值的数据类型
typedef struct{
  VertexType Vex[MaxVertexNum];  //顶点表
  EdgeType Edge[MaxVertexNum][MaxVertexNum]; //邻接矩阵,边表
  int vexnum,arcnum;  //图的当前顶点数和弧度
}MGraph;
邻接矩阵存储法的特点
  1. 无向图的邻接矩阵一定是一个对称矩阵且唯一。因此,在实际存储邻接矩阵只需存储上(或下)三角矩阵的元素。

  2. 对于无向图,邻接矩阵的第i行(或第i列)非零元素(或非∞元素)的个数正好是顶点i的度TD(v)。

  3. 对于有向图,邻接矩阵的第i行非零元素(或非∞元素)的个数正好是顶点i的出度OD(v);第i列非零元素(或非∞元素)的个数正好是顶点i的入度ID(v)。

  4. 用邻接矩阵存储图,很容易确定图中任意两个顶点之间是否有边相连。但是,要确定图有多少条边,则必须按行、按列对每个元素进行检测。

  5. 稠密图适合用邻接矩阵的存储表示。

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

邻接表法

邻接表法结合了顺序存储和链式存储,可以减少使用邻接矩阵对空间的浪费。

邻接表法是将图中的每个顶点建立一个单链表,第i个单链表中的结点表示依附于顶点v的边(对于有向图则是以顶点v为尾的弧),这个单链表就称为顶点v的边表。

图邻接表的存储结构:

复制代码
#define MaxVertexNum 100 //顶点数目最大值
typedef char VertexType; //顶点的数据类型
​
//边表的结构
typedef struct ArcNode{ //边表结点
  int adjvex; //该弧所指向的顶点的位置
  struct ArcNode *next; //指向下一条弧的指针
  //InfoType info; //网的边权值
}ArcNode;
​
//图的各个结点的结构
typedef struct VNode{  //顶点表结点
  VertexType data;  //顶点信息
  ArcNode *first;  //指向第一条依附该顶点的弧的指针
}VNode, AdjList[MaxVertexNum];
​
//此为图创建
typedef struct{
  AdjList vertices;  //邻接表
  int vexnum,arcnum; //图的顶点数和弧数
}ALGraph;  //是以邻接表存储的图类型
邻接表法的特点
  1. 若图为无向图,则所需存储空间为O(|V|+2|E|);若G为有向图,则所需存储空间为O(|V|+|E|)。前者的倍数是2是由于每条边在邻接表中出现了两次。

  2. 对于稀疏图,采用邻接表可以极大地节省存储空间。

  3. 在邻接表中,给一顶点可以很容易找出它的邻边,因为只需读取它的邻接表即可。而对于有向图,邻接表查结点的入度不如邻接矩阵来的快。

  4. 图的邻接表表示并不唯一,它取决于建立邻接表的算法及边的输入次序。

十字链表

十字链表是有向图的一种链式存储结构。在十字链表中,对应于有向图中的每条弧有一个结点,对应于每个顶点也有一个结点,如图所示。

顺着firstin可以找到该结点所有的入边,顺着firstout可以找到该结点所有的出边。

邻接多重表

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

在邻接表中,容易求得顶点和边的各种信息,而执行删除和求两个顶点之间是否存在边等操作时效率较低。所以就采用邻接多重表来解决,结构如图所示。

邻接多重表可以根据所对应的顶点编号很快的找到顶点所有对应的边。

图的遍历

广度优先遍历(BFS)

广度优先的基本思想是:

先访问起始顶点v,接着由v出发,依次访问v的各个未访问过的顶点,然后依次通过这些顶点去访问其他没有访问过的顶点,直到所有顶点都被访问过为止。若图中有顶点尚未被访问,则重新从未被访问的顶点重复上述过程。

广度优先遍历算法实现:

复制代码
Queue Q; //通过队列来安排访问顺序
bool visited[MAX_VERTEX_NUM]; //访问标记数组
​
void BFSTraverse(Graph G){ //对图G进行广度优先遍历
    for(i=0;i<G.vexnum;++i)
        visited[i] = FALSE; //将标记数组初始化,FALSE代表元素还未被访问
    InitQueue(Q); //初始化辅助队列Q
    for(i=0;i<G.vexnum;++i) //从0号顶点开始遍历
        if(!visited[i]) //查看顶点是否被访问过
            BFS(G,i);   //将未被访问过的顶点进行遍历
}
​
void BFS(Graph G,int v){ //从顶点v出发,广度优先遍历图G
    visit(v); //访问初始顶点v  visit()是一个函数,主要就是包含访问顶点要做的事
    visited[v] = TRUE;  //将访问过的顶点做已访问标记
    Enqueue(Q,v);   //将顶点v入队列
    while(!isEmpty(Q)){     //若队列中有元素说明有需要遍历的顶点
        DeQueue(Q,v);   //遍历过后将顶点v出列接下来来检查顶点v周围是否有未被访问过的顶点
        for( w = FirstNeighbor(G,v); w >= 0; w = NextNeighbor(G,v,w)) //FirstNeighbor(G,v)用于获取图G中顶点v的第一个邻接点 NextNeighbor(G,v,w)是返回除w外的顶点v的下一个邻接点的顶点号 以此for循环就能检查与顶点v连接的所有邻接点
            if(!visited[w]){    //w为v的邻接点中尚未访问过的顶点
                visit(w);   //进行访问
                visited[w]= TRUE;   //访问过后进行标记
                EnQueue(Q,w);   //将顶点w入队,然后再次进行while循环时候就会访问w的邻接点相关信息
            }
    }
}

辅助数组visited[]标志顶点是否被访问过,初始状态为FALSE,一旦有顶点被访问就设置为TRUE,防止被多次访问

BFS算法性能分析

无论是邻接表还是邻接矩阵的存储方式,BFS算法都需要借助一个辅助队列Q,n个顶点均需入队一次,在最坏情况下,空间复杂度为O(|V|)。

采用邻接表存储方式:

每个顶点均需访问一次,所以时间复杂度为O(|V|),而在访问任意顶点的邻接点时,每条边至少访问过一次,故时间复杂度为O(|E|),总时间复杂度为O(|V|+|E|)。

采用邻接矩阵存储方式:

查找每个顶点的邻接点所需时间为O(|V|),邻接点也是O(|V|),所以总时间复杂度为O(|V|^2)。

BFS算法求解单源最短路径问题

若图为非带权图,定义从顶点u到顶点v的最短路径d(u,v)为从u到v的任何路径中最少的边数;若从u到v没有通路,则d(u,v)=∞。

BFS算法求解单源最短路径问题:

复制代码
void BFS_MIN_Distance(Graph G,int u){
    //d[i]表示从u到i结点的最短路径
    for(i=0; i<G.vexnum; ++i)
        d[i]=∞;     //初始化路径长度
    visited[u] = TRUE; //对访问过的顶点进行标记 
    d[u] = 0; //设置路径长度为0
    EnQueue(Q,u);   //将顶点u进行入队
    //BFS算法主过程
    while(!isEmpty(Q)){     //若队列中有元素说明有需要遍历的顶点
        DeQueue(Q,v);   //遍历过后将顶点v出列接下来来检查顶点v周围是否有未被访问过的顶点
        for( w = FirstNeighbor(G,v); w >= 0; w = NextNeighbor(G,v,w)) 
            if(!visited[w]){    //w为v的邻接点中尚未访问过的顶点
                visited[w]= TRUE;   //访问过后进行标记
                d[w]=d[u]+1;    //路径长度加1后再进行遍历
                EnQueue(Q,w);   //将顶点w入队,然后再次进行while循环时候就会访问w的邻接点相关信息
            }
    }
}
广度优先生成树

在广度遍历过程中,可以得到一颗遍历树,就称为广度优先生成树,此树用邻接矩阵生成是唯一的,而用邻接表则是不唯一的。

如图3-1,若用邻接矩阵从2号顶点开始遍历生成树则是图3-3。

而邻接表由于链表连接邻接点方式不唯一,所以生成树也是不唯一的。

深度优先遍历(DFS)

深度优先遍历基本思想:

首先访问图中某一起始顶点v,然后由v出发,访问与v邻接且未被访问的任一顶点w,再访问w邻接且未被访问的任一顶点,以此重复。当不能再继续向下访问时,依次退回到最近被访问的顶点,若它还有邻接顶点未被访问过,则从该点开始继续上述搜索过程,直至所有顶点均被访问过。

深度优先遍历算法实现:

复制代码
bool visited[MAX_VERTEX_NUM];   //标记访问数组
void DFSTraverse(Graph G){      //对图G进行深度优先遍历
    for(v=0; v<G.vexnum; ++v)
        visited[v]=FALSE;       //初始化已访问标记数组
    
    for(v=0; v<G.vexnum; ++v)   //进行图的深度优先遍历
        if(!visited[v])         //若没被访问则进行访问
            DFS(G,v);
}
​
void DFS(Graph G,int v){        //从顶点v出发,深度优先遍历图G
    visit(v);                   //访问顶点v
    visited[v]=TRUE;            //将已访问顶点进行标记
    for(w=FistNeighbor(G,v); w>=0; w=NextNeighbor(G,v,w))   
        if(!visited[w]){        //w为v尚未访问的邻接顶点
            DFS(G,w);           //递归往深度遍历
        }
}
DPS算法性能分析

DFS算法作为一个递归算法要借助递归工作栈,故其空间复杂度为O(|V|)。

邻接矩阵时间复杂度:

以邻接矩阵表示时,与广度优先遍历同样需要遍历查找邻接点,故总的时间复杂度和广度优先遍历同为O(|V|^2)。

邻接表时间复杂度:

与广度优先遍历同样的搜索方式,且同为O(|V|+|E|)。

图的应用

最小生成树

对于一个带权连通无向图G=(V,E),生成树不同,每棵树的权(即树中所有边上的权值之和)也可能不同。设R为G的所有生成树的集合,若T为R中边的权值之和最小的生成树,则T称为G的最小生成树。

  • 最小生成树可能由多个,但边的权值之和是唯一且最小的

  • 最小生成树的边数=顶点数-1

最小生成树算法

Prim算法:

从某一个顶点开始构建生成树;每次将代价最小的新顶点纳入生成树,直到所有顶点都纳入为止。

Kruskal算法

kruskal算法是一种按权值的递增次序选择合适的边来构造最小生成树的方法。

最短路径

解决无权图可以使用哦个广度优先搜索来解决最短路径问题,而对于带权图则需要另寻他法,即可分为:单源最短路径,即求图中某一顶点导其他各顶点的最短路径,可通过Dijkstra算法求解;二是求每对顶点间的最短路径,可通过Floyd算法求解。

Dijkstra算法求单源最短路径问题

单源指的就是单独一个源头,在Dijkstra算法中设置了一个集合S来记录已求得的最短路径的顶点,初始时把源头v_0放入到S,集合S每并入一个新顶点,都要修改源点v_0到集合S中顶点当前的最短路径长度值。

Dijkstra算法在构造的过程中还设置了两个辅助数组:

  • dist[]:记录从源头到其他各顶点的最短路径长度,若源头v_0到某个结点有路径,则dist[i]相应结点的位置标上路径长度,若源头到某个结点没有路径,则为∞。

  • path[]:表示从源头到其他各顶点的最短路径前驱结点。在算法结束后,可根据其值追溯得到源点到其他顶点的最短路径。

上图以v_0为源头,v_0到自身的权值为0,到v_1的权值为10,到v_2,v_3目前没有路径则设置为∞,到v_4的路径权值为5,以此初始化dist[]

路径前驱path[]则初始默认为-1,若与源头连接则设置为0。

Dijkstra算法开始时根据final[]数组,根据final[]值中为falsedist[]中目前权值最小的值来进行遍历搜索当前顶点未连接的结点,并将当前结点final[]中的值设置为true

如图4-2所示,当遍历v_4顶点时候,可以获得到顶点v_1比原先更短的路径长度8,并将path中的结点前驱改为4,而且获取了到v_2,v_3的路径长度14,7,并且结点前驱都为4。

后再进行遍历查询下一个除v_4以外dist中最短路径长度的v_3。并检查其他v_1,v_2这些尚未确定最短路径结点以v_3作为结点前驱会不会发生变化。

扫描v_3,显然到v_2经过v_3的路径长度13要小于从v_4直接到v_2的路径长度14,所以将v_2所对应的dist改为13,并将path直接前驱改为3。

后再根据路径长度来扫描到v_1,而此时经过v_1到v_2的最短路径为5+3+1=9,要小于原先的路径13,所以将v_2的dist设置为9,path为1。

最后v_2已经没有其他为false的顶点就直接设置为true即可。

注:对于负权值带权图dijkstra算法可能失效!

Floyd算法求各顶点之间最短路径问题

Floyd算法的基本思想是:递推产生一个n阶方阵序列A^{(-1)},A^{(0)},\dots, A^{(n-1)},其中A^{k}[i][j]表示从顶点v_i到顶点v_j的路径长度,k表示绕行第k个顶点的运算步骤。

若上图4-5允许在v_0中转,最短路径就是求A^{(0)},path^{0},而所进行的就是对矩阵进行检查:

若\\quad A\^{(-1)}\[2\]\[1\]\>A\^{(-1)}\[2\]\[0\]+A\^{(-1)}\[0\]\[1\]=11\\\\ 则\\quad A\^{(0)}\[2\]\[1\]=11\\\\ \\quad path\^{(0))}\[2\]\[1\]=0

进行以允许在v_0中转后,就可得到v_2->v_1的路径长度。

后再添加允许再v_1中转,此时对所有元素扫描后,只有v_0->v_2路径满足最短路径。由公式得:

若\\quad A\^{(0)}\[2\]\[0\]\>A\^{(0)}\[0\]\[1\]+A\^{(0)}\[1\]\[2\]=10\\\\ 则\\quad A\^{(1)}\[0\]\[2\]=10\\\\ \\quad path\^{(1))}\[0\]\[2\]=1

即可得此时v_0->v_2的最短路径。

再允许v_2中转,此时对所有元素扫描后,只有v_1->v_0路径满足最短路径。由公式得:

若\\quad A\^{(1)}\[1\]\[0\]\>A\^{(1)}\[1\]\[2\]+A\^{(1)}\[2\]\[0\]=9\\\\ 则\\quad A\^{(2)}\[1\]\[0\]=9\\\\ \\quad path\^{(2))}\[1\]\[0\]=2

经过n论递推后得到图4-8,获得所有路径间的最短路径。

有向无环图描述表达式

若一个有向图中部存在环路,则称为有向无环图,简称DAG图。

有向无环图是描述含有公共子式的表达式的有效工具,例表达式:((a+b)*(b*(c))+(c+d)*e)*((c+d)*e)可以用下图的二叉树形式来表示:

观察树或表达式,可以发现表达式(c+d)(c+d)*e在二叉树中重复出现,即可利用有向无环图来实现相同子式的共享,从而节省存储空间。

利用有向无环图即可将图4-9的二叉树转换为图4-10的有向无环图。

拓扑排序

AOV网:若用DAG图(有向无环图)表示一个工程,其顶点表示活动,用有向边<V_i,V_j>表示活动V_i必须先于活动V_j进行的一种关系,则将这种有向图称为顶点表示活动的网络,记为AOV网。

拓扑排序的概念:在图论中,由一个DAG图的顶点组成的序列且满足以下条件时,称为该图的拓扑排序:

  1. 每个顶点出现且只出现一次。

  2. 若顶点A在序列中排在顶点B的前面,则在图中不存在从顶点B到顶点A的路径。

拓扑排序通俗的讲就是找到每个顶点的先后顺序,然后根据先后进行排序即可。

拓扑排序的实现:

  1. 从AOV网中选择一个没有前驱(入度为0)的顶点并输出。

  2. 从网中删除该顶点和其他所有以它为起点的有向边。

  3. 重复1和2直到AOV网为空 或当前网中部存在无前驱的顶点为止。

拓扑排序算法实现(基于邻接表结构):

复制代码
bool TopologicalSort(Graph G){
    InitStack(S);           //初始化栈,存储入度为0的顶点
    int i;
    for(i=0; i<G.vexnum; i++)   //遍历图中的每个顶点
        if(indegree[i]==0)  //若入度为0则入栈  indegree[]用于记录每个顶点的入度为多少
            Push(S,i);
    int count=0;            //记录当前已经输出的顶点数
    while(!IsEmpty(S)){     //栈不空,则存在入度为0的顶点,就进行遍历
        Pop(S,i);           //栈顶元素出栈
        print[count++]=i;   //输出顶点i print[]用于记录拓扑序列
        for(p=G.vertices[i].firstarc; p; p=p->nextarc){     //遍历该顶点邻接表相关的顶点
            v=p->adjvex;
            if(!(--indegree[v]))
                Push(S,v);  //入度为0,则出栈
        }   
    }
    if(count < G.vexnum)
        return false;   //排序失败,有向图中有回路
    else
        return true;
}
关键路径

在带权有向图中,以顶点表示事件 ,以有向边表示活动 ,以边上的权值表示完成该活动的开销 (如完成活动所需的时间),称之为用边表示活动的网络,简称AOE网(Activity On Edge NetWork)。

AOE网具有以下两个性质:

  1. 只有在某顶点所代表的事件发生后,从该顶点出发的各有向边所代表的活动才能开始;

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

另外,有些活动是可以并行进行的。

AOE网的概念:

  • 在AOE网中仅有一个入度为0的顶点,称为开始顶点(源点)。它表示整个工程的开始。

  • 网中只存在一个出度为0的点,称为结束顶点(汇点),它表示整个工程的结束。

  • 从源点到汇点的有向路径可能有多条,而其中具有最大路径长度的路径称为关键路径 ,从而把关键路径上的活动称为关键活动

  • 事件v_k的最早发生时间ve(k):决定了所有从v_k开始的活动能开工的最早时间。

  • 活动a_i的最早开始时间e(i):指该活动弧的起点所表示的事件的最早发生事件。

  • 事件v_k的最迟发生事件vl(k):指在不推迟整个工程完成的前提下,该事件最迟必须发生的时间。

  • 活动a_i的最迟开始时间l(i):指该活动弧的终点所表示事件的最迟发生时间与该活动所需时间之差。

  • 活动a_i的时间余量d(i)=l(i)-e(i):表示在不增加完成整个工程所需总时间的情况下,活动a_i可以拖延的时间。

  • 若一个活动的时间余量为零,则说明这活动a_i为关键活动

求关键路径的算法步骤:

  1. 从源点出发,令ve(源点)=0,按拓扑有序求所有时间的最早发生时间ve()。

  2. 从汇点出发,令vl(汇点)=ve(汇点),按逆拓扑有序求所有事件的最迟发生时间vl()。

  3. 求所有活动的最早发生时间e()。

  4. 求所有活动的最迟发生时间l()。

  5. 求所有活动的时间余量d(),而余量为0的就是关键活动,由关键活动可得关键路径。

相关推荐
究极无敌暴龙战神40 分钟前
复习自用2
人工智能·算法·机器学习
TANGLONG2221 小时前
【C++】揭开C++类与对象的神秘面纱(首卷)(类的基础操作详解、实例化艺术及this指针的深究)
java·开发语言·数据结构·c++·python·考研·面试
夏末秋也凉1 小时前
力扣-数组-121 买卖股票的最佳时机
算法·leetcode
还是车万大佬2 小时前
C语言与ASCII码应用之简单加密
c语言·开发语言·算法
q567315233 小时前
利用Python实现Union-Find算法
android·python·算法
岸榕.3 小时前
551 灌溉
数据结构·c++·算法
浪前3 小时前
【算法】移除元素
开发语言·数据结构·算法
快乐飒男3 小时前
数据结构(单链表和循环单链表)
数据结构
XuanRanDev4 小时前
【数据结构】 树的遍历:先序、中序、后序和层序
数据结构·算法·深度优先