本篇文章是基于b站王卓老师数据结构课程对图这一学习板块所做的笔记,希望能够帮助到正在学习数据结构的同学~
!!!插图来源于网课视频
本文涵盖六个部分:案例引入、图的定义和基本术语、图的类型定义、图的存储结构、图的遍历和图的应用。
Part I.案例引入
六度空间理论
阐述:你和一个陌生人之间所间隔的人不会超过六个,也就是说,最多通过六个人你就能够认识任何一个陌生人,也称六度分隔理论
验证:把六度空间理论中的人际关系网络抽象成一个无向图。用图G中的一个顶点表示一个人,两个人认识与否用代表这两个人的顶点之间是否有一条边来表示。从任一顶点出发用广度优先方法对图进行遍历,统计所有路径长度不超过7的顶点。
Part.II图的定义和基本术语
图的概念:(多对多的数据结构)
G=(V,{E}) //Graph=(Vertex,Edge)
V:顶点(数据元素)的有穷非空集合
E:边的有穷集合
图为顶点和边的集合
无向图:每条边都没有方向
有向图:每条边都有方向
完全图:任意两个点都有一条边相连
稀疏图:有很少边或弧的图(边数e<无向完全图的边数或者顶点n的平方,没有严格规定)
稠密图:有较多的边或弧的图
网:边或者弧带权的图
权:图中边或弧所具有的相关数称为权,表明从一个顶点到另一个顶点的距离或耗费
邻接:有边/弧相连的两个顶点之间的关系
有边或弧:<vi,vj>或(vi,vj)∈VR
存在(Vi,Vj),则称Vi和Vj互为邻接点//此为无向图的邻接,()表示没有先后关系
存在<Vi,Vj>,则称Vi邻接到Vj,Vj邻接于Vi//此为有向图的邻接,<>表示存在先后关系,左边元素在右边元素的后面
关联(依附):边/弧与顶点之间的关系
存在(Vi,Vj)或<Vi,Vj>,则称该边/弧关联于Vi和Vj
顶点的度:与该顶点相关联的边的数目,记为TD(v)
在有向图中,顶点的度等于该顶点的入度与出度之和
顶点v的入度是以v为终点的有向边的条数,记作ID(v)
顶点v的出度是以v为始点的有向边的条数,记作OD(v)
问:当有向图中仅1个顶点的入度为0,其余顶点的入度均为1,此时是何种形状
答:树,而且是一棵有向树
路径:接续的边构成的顶点序列
路径长度:路径上边或弧的数目/权值之和
回路(环):第一个顶点和最后一个顶点相同的路径
简单路径:除了路径起点和终点可以相同外,其余顶点均不相同的路径
简单回路(简单环):除了路径起点和终点相同外,其余顶点均不相同的路径
连通图(强连通图):在无(有)向图G=(V,{E})中,若对任何两个顶点v,u都存在从v到u的路径,则称G是连通图(强连通图)
子图:设有两个图G=(V,{E})、G1=(v1.{E1}),若V1∈V,E1∈E,则称G1是G的子图
连通分量(强连通分量):
无向图(有向图)G的极大连通子图(极大强连通子图)称为G的连通分量(强连通分量)
极大连通子图的意思是:该子图是G的连通子图,将G的任何不在该子图的顶点加入,子图不再连通
极小连通子图:该子图是G的连通子图,在该子图中删除任何一条边后子图不再连通(即所有顶点均连通,但是不存在回路)
生成树:包含无向图G所有顶点的极小连通子图;生成树一定有n个顶点n-1条边,但n个顶点n-1条边的不一定是生成树
生成森林:对非连通子图,由各个联通分量的生成树的集合
Part III.图的类型定义
ADT Graph{
数据对象V:具有相同特性的数据元素的集合,称为顶点集
数据关系R:R={VR}
VR={<v,w>|(v,w)|v,w∈V^p(v,w),
<v,w>表示从v到w的弧,p(v,w)定义了弧<v,w>的信息即弧的权
}
基本操作P:
Create_Graph():图的创建操作
初始条件:无
操作结果:生成一个没有顶点的空图
GetVex(G,v):求图中的顶点v的值
初始条件:图G存在,v是图中的一个顶点
操作结果:返回顶点的值
CreateGraph(&G,V,VR)
初始条件:V是图的顶点集,VR是图中弧的集合
操作结果:按照V和VR的定义构造图G
DFSTraverse(G)
初始条件:图G存在
操作结果:对图进行深度优先遍历
BFSTraverse(G)
初始条件:图G存在
操作结果:对图进行广度优先遍历
}ADT Graph
Part IV.图的存储结构
图的逻辑结构:多对多
图没有顺序存储结构,但可以借助二维数组来表示元素间的关系(数组表示法/邻接矩阵)
链式存储结构——多重链表:邻接表,邻接多重表,十字链表
数组(邻接矩阵)表示法:
存在弧或着边,二维数组单元即为1
有n个顶点,则二维数组定义为n*n方阵(因为任意两个顶点之间都要表示是否存在边或弧的关系)
例:无向图的邻接矩阵表示法:
分析:无向图的邻接矩阵是主对角线元素为0的对称矩阵;顶点i的度等于第i行(列)中1的个数;完全图的邻接矩阵中,对角元素为0,其余全为1
例:有向图的邻接矩阵表示法:
A.arcs[i][j]=1:存在Vi到Vj的弧或边
在有向图的邻接矩阵中,
第i行含义:以节点Vi为尾的弧(即出度边)
第i列含义:以节点Vi为头的弧(即入度边)
分析:有向图的邻接矩阵可能是不对称的;顶点的出度等于第i行元素之和;顶点的入度等于第i列元素之和;顶点i的度等于第i行和第i列元素之和
例:网(即有权图)的邻接矩阵表示法:
邻接矩阵定义为:A.arcs[i][j]={Wij(即从vi到vj的边或弧的权值,当有边或弧时)/ ∞(当边或者弧不存在时)}
邻接矩阵的建立
#define Maxlbt 32767//表示极大值,即∞
#define MVNum 100//最大顶点数
tepedef char VerTexType;//设顶点的数据类型为字符型
typedef int ArcType;//假设边的权值类型为整型
typedef struct{
VerTexType vexs[MVNum];//顶点表
ArcType arcs[MVNum][MVNum];//邻接矩阵
int vexnum,arcnum;//图的当前点数和边数
}AMGraph;
采用邻接矩阵表示法创建无向网的算法
算法思想:
1.输入总顶点数和总边数
2.依次输入点的信息存入顶点表中
3.初始化邻接矩阵,使每个权值初始化为极大值
4.构造邻接矩阵
算法实现:
Status CreateUDN(AMGraph &G){
cin>>G.vexnum>>arcnum;//输入总顶点数和总边数
for(i=0;i<G.vexnum;i++)
cin>>G.vexs[i];//依次输入顶点信息
for(i=0;i<G.vexnum;i++){
for(j=0;j<G.vexnum;j++){
G.arcs[i][j]=Maxlnt;//初始化邻接矩阵,把边的权值全部置为极大值
}
}
for(k=0;k<G.arnum;k++){//构造邻接矩阵
cin>>v1>>v2>>w;//输入一条边所依附的顶点及边的权值
i=LocateVex(G,v1);
j=LocateVex(G,v2);//确定v1和v2在G中的位置,返回顶点在顶点表的下标
G.arcs[i][j]=w;
G.arcs[j][i]=w;//为边赋权值
}
return OK;
}
int LocateVex(AMGraph G,VertexType u){//图G中查找顶点u
int i;
for(i=0;i<G.vexnum;i++){
if(u==G.vex[i]
return i;
}
return -1;
}
邻接矩阵表示法的优缺点
优点:直观、简单、好理解;方便检查任意一对顶点间是否存在边;方便找任一顶点的所有邻接点;方便计算任一顶点的度
缺点:不便于增删顶点;空间复杂度高(n平方),对于稀疏图(点多边少)有大量无效元素;
浪费时间(n平方);
邻接表表示法(链式)
一维数组中每个单元是一个头节点,有两个域,一个存放顶点数据,一个存放指向第一条边的指针——头节点:
data | firstarc |
表结点:
adjvex | nextarc |
每一个顶点都是链表,nextarc指向下一条边或者弧,称为链域;adjvex指的是与头节点代表的顶点连通的顶点在一维数组的下标,称为邻接点域值;如果是网,可以在增加一个域用来存放边的权值:
adjvex | nextarc | info |
例:无向图的邻接表表示法
即为上图所示;
特点:邻接表不唯一(因为链表的表节点顺序可变);若无向图中有n个顶点,e条边,则其邻接表需要n个头节点和2e个表节点(每条边都会在表节点中出现两次),适宜存储稀疏图;存储空间O(n+2e);无向图中顶点Vi的度等于该链表中表节点的个数
例:有向图的邻接表表示法
特点:顶点Vi的出度为第i个单链表中的表节点个数;入度为整个单链表中邻接点域值是i-1的节点个数——找出度容易,找入度难
逆邻接表:把表节点从出度改为入度节点,如
V1 | ——> | 3 | ^ |
//v1的入度边来自v4
特点:找入度容易,找出度难(出度为整个单链表中邻接点域值是i-1的节点个数)
图的邻接表存储表示
顶点的节点结构:
data | firstarc |
typedef struct VNode{
VerTexType data;//顶点信息
ArcNode *firstarc;//指向第一条依附该顶点的边的指针
}VNode,AdjList[MVNum]; //顶点组成的一维数组
AdjList v;//定义一个数组,相当于VNode v[MVnum];
边(弧)的节点结构:
adjvex | nextarc | info |
#define MVnum 100
typedef struct ArcNode{
int adjvex;
struct ArcNode *nextarc;
OtherInfo info;
}ArcNode;
图的结构定义:
typedef struct{
AdjList vertices;
int vexnum,arcnum;//图的当前顶点个数和边个数
}ALGraph;
邻接表的操作:
跟链表的操作方式完全相同:
ALGraph G;//定义邻接表表示的图G
G.vexnum=5;G.arcnum=5;//图G包含5个顶点5条边
G.vertices[1].data='b';//图G第2个顶点是b
p=G.vertices[1].firstarc;//指针p指向顶点b的第一条边节点
p->adjvex=4;//p指针所指边节点是到下标为4的节点的边
采用邻接表表示法创建无向网
算法思想:
1.输入总顶点数和总边数
2.建立顶点表:依次输入点的信息存入顶点表中。使每个表头节点的指针域初始化为NULL
3.创建邻接表:依次输入每条边依附的两个顶点,确定两个顶点的序号i和j,建立边节点,将此边节点分别插入到vi和vj对应的两个边链表的头部
Status CreateUDG(ALGraph &G){
cin>>G.vexnum>>G.arcnum;
for(i=0;i<G.vexnum;i++){
cin>>G.vertices[i].data;
G.vertices[i].firstarc=NULL;
}
for(k=0;k<G.arcnum;k++){
cin>>v1>>v2;
i=LocateVex(G,v1);
j=LocateVex(G,v2);
p1=new ArcNode;
cin>>p1.info;
p1->adjvex=j;
p1->nextarc=G.vertices[i].firstarc;
G.vertices[i].firstarc=p1;//头插法插入新节点
p2=new ArcNode;
cin>>p2.info;
p2->adjvex=i;
p2->nextarc=G.vertices[j].firstarc;
G.vertices[j].firstarc=p2;
}
return OK;
}
邻接表优缺点:
方便查找任一顶点的所有邻接点;节约稀疏图的空间;方便计算任一顶点的度(对无向图来说方便,对有向图不方便)
邻接表的改进:
邻接表存储有向图的缺点:求节点的度困难——>十字链表
邻接表存储无向图的缺点:每条边都要存储两遍,如果要删除一条边则需要找表示此边的两个节点,需要删两次——>邻接多重表
十字链表:是有向图的另一种链式存储结构。我们也可以把它看作是将有向图的邻接表和逆邻接表结合起来形成的一种链表。有向图的每一条弧对应十字链表中的一个弧节点,每个顶点在十字链表中对应一个节点,叫做顶点节点
顶点节点:既有入度边也有出度边
data | firstin | firstout |
弧节点:
tailvex | headvex | hlink | tlink |
tailvex:弧尾下标;headvex:弧头下标;hlink:指向下一个弧头;tlink:指向下一个弧尾;
邻接多重表:
Part V.图的遍历
遍历定义:
从已知的连通图中某一顶点出发,沿着一些边访遍图中所有顶点,且使每个顶点仅被访问一次,就叫做图的遍历,它是图的基本运算
实质:找每个顶点的邻接点的过程
图的特点:图中可能存在回路,且图的任一顶点都可能与其他顶点连通,在访问完某个顶点后可能会沿着某些边又回到了曾经访问过的顶点。
这就出现了重复访问的问题,怎么避免呢?
解决思路:设置辅助数组visited[n],用来标记每个被访问过的顶点,初始状态visited[i]为0,顶点i被访问后,改visited[i]为1,防止被多次访问
图常用的遍历:
深度优先搜索DFS;广度优先搜索BFS;
深度优先遍历DFS:
通俗的讲,就是一条路走到黑,走到尽头后往后退一步看看是否存在其他没经过的路径(边),类似于树的先根遍历
定义:
在访问图中某一起始顶点v后,由v出发,访问它的任一邻接顶点w1;
再从w1出发,访问与w1邻接但还未被访问过的顶点w2;
然后再从w2出发,进行类似的访问,······
如此进行下去,直至到达所有的邻接点都被访问的顶点u为止(即顶点u不存在没被访问过的邻接点);
接着,退回一步,退到前一次刚访问过的顶点,看其是否有其它没有别访问过的邻接点;
如果有,则访问此顶点,之后再从此顶点出发,进行与前述类似的访问
如果没有,则再退回一步,重复上述操作,直到连通图所有顶点全部被访问过为止
算法实现:
1.邻接矩阵表示的无向图深度遍历实现
void DFS(AMGraph G,int v){
cout<<v;
visited[v]=true;//访问第v个顶点
for(w=0;w<G.vexnum;w++){
if((G.arcs[v][w]!=0)&&(!visited[w])){
DFS(G,w);
}//找邻接点w并判断w是否被访问
}
}//当起点所在行的w加满了时函数结束
时间复杂度:遍历图中每一个顶点都要从头扫描该顶点所在行,时间复杂度为O(n平方)
邻接表表示的无向图深度遍历实现
Status DFS(AMGraph G,int v){
cout<<v;
visited[v]=true;
p=G.vertices[v].firstarc;
w=p->adjvex;
if(p==NULL) return OK;//某一条链全部访问完毕
exit:
while(visited[w]){
p=p->nextarc;
w=p->adjvex;//如果被访问过,则看此链上的下一个表节点
if(p==NULL) return OK;//某一条链全部访问完毕
}
DFS(G,w);
goto:exit;
}
时间复杂度:虽然有2e个表节点,但只需扫描e个节点即可完成遍历,加上访问n个头节点的时间,时间复杂度为O(n+e)
广度优先遍历BFS:
定义:
从图的某一节点出发,首先依次访问该节点的所有邻接点,再按这些顶点被访问的先后顺序一次访问与它们邻接的所有未被访问过的顶点,重复此过程直至所有顶点均被访问为止(类似于树的层次遍历)
使用队列:访问——>出队——>入队——>访问
算法实现:
邻接表
void BFS(Graph G,int v){
cout<<v;
visited[v]=true;
InitQueue(Q);
EnQueue(Q,v);
while(!QueueEmpty(Q)){
DeQueue(Q,u);
for(w=FirsrAdjVex(G,u);w>=0;w=NextAdjVex(G,u,w)){
if(!visited[w]){
cout<<w;
visited[w]=true;
EnQueue(Q,w);
}
}//如果firstarc或者nextarc为空则w会是负数,这点可以在for循环的两个函数中实现
}
}//已经访问过的会入队
时间复杂度与DFS相同
Part VI.图的应用
最小生成树
无向图的生成树
最小生成树定义:
给定一个无向网,该网所有生成树中各边权值和最小的那一棵生成树称为该网的最小生成树,也叫最小代价生成树
典型用途:在城市之间建立通信网,铺线路;n个村庄之间通路;
构造最小生成树:
算法很多,多数算法都利用了MST的性质
怎么用:
算法实现:
普里姆算法
克鲁斯卡尔算法
两种算法的比较:
普里姆算法选点,适用于稠密图;
克鲁斯卡尔算法选边,适用于稀疏图;
最短路径
交通网络用有向网表示:
顶点——表示地点;弧——表示两个地点有路相同;弧上的权值——表示两地之间的距离、交通费或中途所花费的时间等
问题抽象:在有向网中,从A点(源点)到达B点(终点)的多条路径中,寻找一条割边权值之和最小的路径,即最短路径
第一类问题(单源点):迪杰斯特拉算法
1.初始化:先找出从源点V0到各终点Vk的直达路径(V0,Vk),即通过一条弧到达的路径(如果不能一条弧到达的终点,则记为无穷)
2.选择:从这些路径中找出一条长度最短的路径(V0,u)
3.更新:然后对其他各条路径进行适当调整。若在图中存在弧(u,Vk),且(V0,u)+(u,Vk)<(V0,Vk),(即加入u作为中间点后可以使路径变短的话)则以路径(V0,u,Vk)代替(V0,u);在调整后的路径中再找最短的,以此类推
第二类问题(多源点):
方法一:每次以一个顶点为源点,重复执行迪杰斯特拉算法直至每个顶点都找到最短路径
方法二:弗洛伊德算法
算法思想:逐个顶点试探;从Vi到Vj的所有可能存在的路径中选出一条长度最短的路径
有向无环图及其应用
有向无环图:无环的有向图,简称DAG图
常用来描述一个工程或系统的进行过程(通常把计划、施工、生产、程序流程等当作一个工程),一个工程若可以分为若干个子工程,只要完成了这些子工程,就可以导致整个工程的完成
AOV网:解决拓扑排序问题
AOE网:解决关键路径问题
拓扑排序:
例:排课表
AOV网的特点:
若从i到j有一条邮箱路径,则i是j的前驱;j是i的后继;
若<i,j>是网中的有向边,则i是j的直接前驱,j是i的直接后继;
AOV网中不允许有回路,因为如果有回路的话则表明某项活动以自己为先决条件,显然这是荒谬的
如何判断AOV网是否有环?
==》拓扑排序
拓扑排序定义
在AOV网没有回路的前提下,我们将全部活动排列成一个线性序列,使得若AOV网中有弧<i,j>存在,则在这个序列中,i一定排在j的前面,具有这种性质的线性序列称为拓扑有序序列,相应的拓扑有序排序的算法称为拓扑排序
拓扑排序的方法:
在有向图AOV网中选一个没有前驱的顶点且输出;
从图中删除该节点和所有以它为尾(非箭头端)的弧;
重复上述操作,直至全部顶点均已输出或者当图中不再存在无前驱的顶点为止
如例子中的AOV排序后:c1,c2,c3,c4,c5,c7,c9,c10,c11,c6,c12,c8(注:拓扑序列不唯一,这一段也可以以c9开头)
拓扑排序的应用:
检测AOV网是否存在环:
对有向图构造其顶点的拓扑有序序列,若所有顶点都在序列中则AOV网不存在环,若图中仍有顶点且这些顶点中不再存在无前驱(即不能再进行拓扑排序)的顶点,则AOV网存在环,且这些顶点就是环的组成顶点
关键路径:
把工程计划表示为边表示活动的网络,即AOE网,用顶点表示时间,弧表示活动,弧的权表示活动持续时间
事件表示在它之前的活动已经完成,在它之后的活动可以开始
例2的AOE网:
源点:入度为0的顶点,表示整个工程的开始,如上图的v1
汇点:出度为0的顶点,表示整个工程的结束,如上图的v2
对于AOE网,我们关心两个问题:
1.完成整项工程至少需要多少时间
2.哪些活动时影响工程进度的关键
==》关键路径:路径长度最长的路径
如何确定关键路径:
定义四个描述量:
ve(vi)——表示事件vi的最早发生时间,如上图ve(v1)=0;ve(v2)=30
vl(vi)——表示事件vi的最迟发生时间,如上图若要求整项工程最晚完成时间是180,则vl(v4)=180-15=165(因为v7表示整项工程完成,要完成v7要先完成v4)
e(i)——表示活动ai的最早开始时间,如e(a3)=30;a3指C
l(i)——表示活动ai的最迟开始时间,如l(a3)=120(假设180为整项工程的最晚完成时间)
l(i)-e(i)——表示完成活动ai的时间余量(指可以用来完成ai的时间段)
关键活动:关键路径上的活动,即l(i)==e(i)的活动,没有时间余量,必须在规定的那个时间完成
如何找关键活动?
说明:(1)中选最大是因为后一个事件即j若前面有多个前驱事件即多个i,则要等这多个i都执行完(即完成所有分支后)才可以执行j,所以最早时间要选前驱里最大的