数据结构复习笔记(6)
第七章 图
基本定义
图中的数据元素称为顶点。对于有向图,从弧尾到弧头称为一条弧;对于无向图,两个顶点之间称为一条边
此处的图都为简单图,即顶点没有到其自身的弧或边。若用e为图中弧或边的数目,n为图中顶点数目,对于无向图;对于有向图。具有条边的无向图称为完全图;具有条弧的有向图称为有向完全图。
有两个图G和G',若G'的顶点集和边/弧集都为G的顶点集和边/弧集的子集,则称G'为G的子图。
在无向图中,有边相连接的顶点互为邻接点,这条边称为依附于两个邻接点、这条边和这两个顶点相关联,某个顶点的度就是与该顶点相关联的边的数目;在有向图中,某顶点的入度就是以该顶点为头的弧的数目,出度就是以该顶点为尾的弧的数目,度就是入度与出度之和。很显然,一个图的边/弧的数目等于所有顶点度之和的一半。
从一个顶点v到另一个顶点v'的顶点序列称为从v到v'的路径,路径的长度就是这条路径上的边/弧的数目。其中,第一个顶点和最后一个顶点相同的路径称为回路/环;顶点序列中没有重复顶点的路径称为简单路径;除了第一个顶点和最后一个顶点外没有重复顶点的回路,称为简单回路/简单环
在无向图中,若从顶点v到顶点v'有路径,则称v与v'是连通的,若一个图中任意两个顶点都是连通的,称该图为连通图。无向图的连通分量指无向图中的极大连通子图。
在一有向图中,若任意两个顶点双向都有路径,则称该图为强连通图。有向图中的极大强连通子图称为有向图的强连通分量。
对于一个连通图(显然首先是无向的),其生成树为一个极小连通子图,生成树包含图中的全部顶点,但只有n-1条边。若在生成树上添加一条边,必定会构成一个环。若一个无向图有n个顶点并且边数小于n-1,则为非连通图;若其边数大于n-1,则一定有环。
若一有向图只有一个顶点入度为0,其他顶点入度均为1,则称其为一棵有向树。一个有向图的生成森林由若干有向树组成,这些有向树含有图中的全部顶点,并且只有足已构成这些不相交的有向树的弧。
对于无向图,若在删去顶点v及所有与其关联的边之后,该图的一个连通分量被分割成了两个或两个以上的连通分量,称v为该图的一个关节点。若一个连通图没有关节点,该连通图称为重连通图。若在连通图上至少删去k个顶点才能破坏该图的连通性,称此图的连通度为k
具体实现
数组表示法
即使用邻接矩阵来表示图。无向图的邻接矩阵是对称的。对于图,一般使用0、1表示是否邻接;对于网,使用权值作为邻接矩阵中的值,并且一般需要约定一个值作为不可达的“无穷远”
邻接表
是一种链式存储结构,为每个顶点建立一个单链表,单链表中是所有依附于该顶点的边/从该顶点出发的弧。单链表中的结点需要指示与当前顶点关联的另一个顶点、维持所在单链表的指针域、存储边/弧信息的数据域。代表顶点的表头结点通常以顺序结构存储。
对于无向图,则建立双向的弧即可
十字链表
一般用于有向图,为有向图中每一条弧建立一个结点、每个顶点建立一个结点。
弧结点中有5个域:两个分别指示自身的弧头、弧尾的顶点结点,两个分别指示弧头相同的下一条弧、弧尾相同的下一条弧,还有一个存储弧的信息
顶点结点有3个域:一个存储顶点的信息,另两个分别指示以该结点为弧头、弧尾的第一个弧结点
十字链表非常容易找以某个顶点为弧头/弧尾的所有弧,容易计算顶点的出度、入度
邻接多重表
为解决无向图邻接表中一条边存在于两个顶点的单链表中的问题而设计。与有向图的十字链表非常相似,使用边结点和顶点结点来存储无向图
边结点中有6个域:一个标记该边是否被搜索过,两个指示该边依附的两个顶点结点,两个分别指示该边邻接的两个顶点的下一个邻接边结点,还有一个指示该边自身的信息
顶点结点中有2个域:一个指示顶点的信息,另一个指示第一条依附于该顶点的边结点
也就是说,每个边结点都处于两个链表中
图的相关算法
图的遍历
使用递归/栈的深度优先搜索DFS(Depth First Search)和使用队列的广度优先搜索BFS(Breadth First Search)。在遍历过程中,要注意对访问过的顶点的标注
图的连通性
无向图的连通分量、连通图
在遍历无向图时,对于连通图可以从任一顶点出发遍历完全图,对于非连通图,需要从多个顶点出发进行遍历。在非连通图的遍历中,每从一个顶点出发遍历的一些顶点就构成一个连通分量的顶点集。
遍历无向图时经过的各条边可以组成极小连通子图,即生成树。用DFS遍历得到的称为深度优先生成树、BFS遍历得到的称为广度优先生成树。在非连通图中,这些连通分量的生成树就组成了非连通图的生成森林,也有深度优先生成森林和广度优先生成森林的说法
有向图的强连通分量、强连通图
求取有向图的强连通分量有两个算法:Tarjan算法、Kosaraju算法
Tarjan算法
Tarjan算法是一种基于DFS的算法,使用栈来存储访问过程中的顶点
先引入两个非常重要的数组:dfn 与 low
dfn:顶点被搜索到的“时间戳”,用于记录顶点被搜到的次序,一个顶点的时间戳记录后就不再改变
low:在本次DFS搜索中(最外层循环),且仍在栈中的最小时间戳,像是确立了一个关系,low相等的点在同一强连通分量中。
注意初始化时 dfn= low = ++cnt
算法思路:
图不一定是强连通图,所以跑Tarjan时要枚举每个点,若dfn == 0,进行深搜。
对于搜到的顶点,寻找以其为弧尾的顶点,判断这些顶点是否已经被搜索过,若没有(显然也一定没有入栈),则进行搜索;若该点已经入栈,说明形成了环,则更新low
在DFS回溯时不断比较low,不断取最小的low值。如果dfn[x]==low[x] 则说明找到了一个强连通分量,并可以以x为起点。对栈进行弹出操作,直到x被弹出,这些被弹出的顶点构成一个强连通分量的顶点集
递归函数代码:
void tarjan(int now) { dfn[now]=low[now]=++cnt; //初始化 stack[++t]=now; //入栈操作 v[now]=1; //v[]代表该点是否已入栈 for(int i=f[now];i!=-1;i=e[i].next) //邻接表存图 if(!dfn[e[i].v]) //判断该点是否被搜索过 { tarjan(e[i].v); low[now]=min(low[now],low[e[i].v]); //回溯时更新low[ ],取最小值 } else if(v[e[i].v]) low[now]=min(low[now],dfn[e[i].v]); //一旦遇到已入栈的点,就将该点作为连通量的根 //这里用dfn[e[i].v]更新的原因是:这个点可能 //已经在另一个强连通分量中了但暂时尚未出栈,所 //以now不一定能到达low[e[i].v]但一定能到达 //dfn[e[i].v]. if(dfn[now]==low[now]) { int cur; do { cur=stack[t--]; v[cur]=false; //不要忘记出栈 }while(now!=cur); } }
Kosaraju算法
Kosaraju给出的方案:对原图取反,然后从反向图的任意节点开始进行DFS的逆后序遍历。根据逆后序遍历得到的序列进行DFS遍历(与无向图找连通分量类似)一定可以找到各强连通分量。
DFS的逆后序遍历是指:如果当前顶点未访问,先遍历完与当前顶点相连的且未被访问的所有其它顶点,然后将当前顶点加入栈中,最后栈中从栈顶到栈底的顺序就是我们需要的顶点顺序。
也就是说,使用Kosaraju算法求强连通分量分为两步
- 对原图取反,从任意一个顶点开始对反向图进行逆后续DFS遍历
- 按照逆后续遍历中栈中的顶点出栈顺序,对原图进行DFS遍历,一次DFS遍历中访问的所有顶点都属于同一强连通分量
最小生成树
此处无向图的边都是有权值的,构造的生成树中花费最小的,称为最小生成树
比如要在n个城市之间建立通信网,n个城市之间最多可以设置条线路,连通这n个城市最少需要n-1条线路,考虑各线路的花费,要选择n-1条线路建立使得最节省经费。
MST性质:
利用MST性质求最小生成树的算法——普利姆Prim算法、克鲁斯卡尔Kruskal算法
Prim算法
从顶点出发求解,设连通网为,求解步骤如下
- 取一个顶点放入集合
- 在所有的边中找一条代价最小的边,将这条边加入生成树的边集
- 若,重复第二步
最终中有n-1条边,最小生成树为
记连通网顶点数目为n,通常在算法具体实现中,使用一个长为n-1的数组来记录到的各条边的距离和所依附的顶点(记录U中的顶点)。Prim算法的时间复杂度为
Kruskal算法
从边出发求解,同样设连通网为,求解步骤如下
- 最小生成树的初始状态为只有n个顶点而无边的非连通图
- 在边集中选择代价最小的边,若该边依附的顶点在中的两个不同的连通分量上,将该边加入的边集中;否则,将该边舍弃,重新执行第二步
由于需要不断选择代价最小的边,可以用堆来存储边。若使用堆来存储边,Kruskal算法的时间复杂度为,其中e为图中边的数目。
可见,对顶点较少的连通网,使用Prim算法;对顶点较多的,使用Kruskal算法
拓扑排序及关键路径
有向无环图可以用来描述一项工程或系统的进行过程,即可以用来表示流程图
集合X上的一个偏序关系R,若对每个必有或,则称R是集合X上的全序关系(也就是集合中的所有元素都可以互相比出“大小”)。从偏序关系出发,将图中所有顶点排成一个线性序列,使得图中任意一对顶点u和v,若边,则u在线性序列中出现在v之前,这个序列称为拓扑序列,称这个序列是拓扑有序的,得到这个拓扑序列的操作称为拓扑排序,
用顶点表示活动,用弧表示活动间的优先关系,称为AOV网(Activity On Vertex Network),显然AOV是一个有向无环图。若可以从一个AOV网中得到一个拓扑有序序列,且网中所有顶点都在这个序列中,说明该AOV网中不存在环
拓扑排序
拓扑排序可以用来判断一项工程/过程是否能够最终完成,拓扑排序有两个步骤
- 在有向图中选一个没有前驱的顶点,输出
- 删除该顶点及以其为尾的弧
- 重复1、2两步,直到所有顶点都被输出。(若图中还有顶点,但所有顶点都有前驱,说明图中有环)
在具体实现中,使用一个存放顶点入度的数组即可,入度为0就是没有前驱。在删去弧时维护这个数组,并将入度为0的放入一个栈中。时间复杂度为,其中n、e分别为图中的顶点数与弧数
关键路径
关键路径中使用的是AOE网(Activity On Edge),用弧表示活动、用顶点表示事件,AOE网是一个弧带权的有向无环图。AOE网可以用来估算工程完成的最短时间、哪些活动是影响工程进度的关键。此处的事件,一般是指“xxxx活动已完成”,是一个瞬间。在AOE网中,从起点到终点的最长路径称为关键路径,关键路径上的所有活动都是关键活动,关键路径的长度(算上权值)就是整个工程的最短完成时间。

约定一些变量:e[r]和l[r]分别表示活动r的最早/最晚开始时间(若e[r]与l[r]值相同,说明活动不能拖延,即为关键活动);ve[i]和vl[i]分别表示事件i的最早/最晚发生时间;length[r]表示活动r需要花费的时间
欲求e[r]、l[r],可以先求ve[i]、vl[i]。对活动r来说,事件最早发生的瞬间就可以开始,也就是说;事件j最晚发生的瞬间显然是,所以

欲求ve[j],即事件r1、r2、···、rk全部完成的最早时间,显然,可见要求ve[j],必须要知道它的所有前驱结点的ve值,按拓扑序列顺序求解就自然满足这一点

再求vl[j],以j1为例,j1的最晚发生时间vl[j1]要求i的最晚发生时间为,对其他的事件来说同理。很显然,在各个对i要求的最晚发生时间中要取最小的才能使得i的发生时间满足所有,因此。显然,要求vl[j],需要知道它的所有后继结点的vl值,按逆拓扑序列顺序求解就自然满足这一点。颠倒一组合法的拓扑序列就可以得到一组逆拓扑序列。
最后求出e、l两个数组,就可以得到关键活动,根据关键活动就可以确定各条关键路径了
最短路径
单源最短路径
给定带权有向图G和起点s,要得到s到达其他每个顶点的最短距离,使用迪杰斯特拉Dijkstra算法求解,其基本思想:对图设置集合,用以存放已访问的顶点,每次从(即未访问过的顶点)中选择与起点s的最短距离最小的一个顶点u,访问之(存入集合),再以u为中介点,优化起点s与所有从u能到达的并且未访问过的顶点之间的最短距离。这个操作会重复n次,直到中包含了所有顶点
使用pre数组记录路径中每个顶点的前驱,之后使用递归可以还原整条路径。
以邻接矩阵为例的代码
int n, G[MAXV][MAXV];//顶点数n与邻接矩阵G int d[MAXV];//存储起点到各点的最短路径长度 bool vis[MAXV] = {false};//标记顶点是否被访问过 int pre[MAXV];//用于保存最短路径 //以下代码中用INF作为无穷 //s为起点 void Dijkstra(int s) { int u, minDistance; //将s到所有顶点的距离初始化为无穷大 for (int i = 0; i < MAXV; i++) { d[i] = INF; } d[s] = 0;//显然s到自身距离为0 for (int i = 0; i < n; i++) {//重复n次操作 u = -1, minDistance = INF;//初始化中介点u for (int j = 0; j < n; j++) {//在未访问过的顶点中找与起点s最短距离最小的顶点u if (vis[j] == false && d[j] < minDistance) { u = j; minDistance = d[j]; } } } if (u == -1)//在未访问过的顶点中都是与起点s不连通的,直接退出 return; vis[u] = true;//标记u为已访问 for (int v = 0; v < n; v++) { //从u能到达的并且未访问过的顶点中找v,能够使起点s到v的最短距离变小 if (vis[v] == false && G[u][v] != INF && d[u]+G[u][v] < d[v]) { d[v] = d[u]+G[u][v]; pre[v] = u;//记录v的前驱是u } } }
该算法的时间复杂度为,若使用优先队列优化,可以降到。Dijkstra适用于所有边权都是非负数的情况
全源最短路径
给定图,求任意两点之间的最短路径长度。可以对每个顶点都执行一遍Dijkstra算法,也可以用更易实现的弗洛伊德Floyd算法
Floyd算法的依据:若存在顶点k,使得以k为中介点时顶点i和j之间的当前最短距离缩短,则使用k作为i和j之间的中介点。(非常显然的事实)即当时,另。
在具体实现时,要把dis数组初始化为无穷大、顶点到自身的距离初始化为0、图中的边/弧也读入dis中,代码如下
void Floyd() { //注意最外层循环为中介点 for (int k = 0; k < n; k++) { for (int i = 0; i < n; i++) { for (int j = 0; j < n; j++) { if (dis[i][k] != INF && dis[k][j] != INF && dis[i][k]+dis[k][j] < dis[i][j]) { dis[i][j] = dis[i][k]+dis[k][j]; }
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享4款.NET开源、免费、实用的商城系统
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
· 上周热点回顾(2.24-3.2)