数据结构笔记六:图
图
图的定义
图G由顶点集V和边集E组成,记为\(G=\{V,E\}\),其中\(V(G)\)表示图G中顶点的有限非空集;\(E(G)\)
表示图G中顶点之间的关系(边)集合。若\(V=\{v_1,v_2,...,v_n\}\),则用\(|V|\)表示图G中顶点的个数,
也称图G的阶,\(E=\{(u,v)|v\in V,v\in V\}\),用\(|E|\)表示图G中边的条数。
注意:线性表可以是空表,树可以是空树,但图不可以是空,即V一定是非空集
无向图,无向图
若E是无向边(简称边)的有限集合时,则图G为无向图。边是顶点的无序对,记为\((v,w)==(w,v)\)
若E是有向边(简称弧)的有限集合时,则图G为有向图。边是顶点的有序对,记为\((v,w)\),v为弧头,w称为弧尾。
简单图,多重图
简单图——①不存在重复边 ②不存在顶点到自身的边
多重图——图G中某两个结点之间的边数多余一条,又允许顶点通过同一条边和自己关联,则G为多重图。
顶点的度,入度,出度
对于无向图:顶点v的度是指依附于该顶点的边的条数,记为\(TD(V)\)
在具有n个顶点,e条边的无向图中,\(\sum_{i=1}^nTD(v_i)=2e\),即无向图的全部顶点的度的和等于边数的2倍
对于有向图:
- 入度是以顶点v为终点的有向边的数目,记为\(ID(V)\);
- 出度是以顶点v为起点的有向边的数目,记为\(OD(V)\)
顶点v的度等于其入度和出度之和,即\(TD(v)=ID(v)+OD(v)\)
在具有n个顶点,e条边的有向图中,\(\sum_{i=1}^nID(v_i)=\sum_{i=1}^OD(v_i)=e\)
顶点-顶点关系的描述
- 路径——顶点\(v_p\)到顶点\(v_q\)之间的一条路径是指顶点序列
- 回路——第一个顶点和最后一个顶点相同的路径称为回路或环
- 简单路径——在路径序列中,顶点不重复出现的路径称为简单路径
- 简单回路——除第一个顶点和最后一个顶点外,其余顶点不重复出现的回路称为简单回路
- 路径长度——路径上边的数目
- 点到点的距离——从顶点u出现到顶点v的最短路径若存在,则此路径的长度称为从u到v的距离。若从u到v根本不存在路径,则记该距离为无穷\((\infty)\)
- 无向图中,若从顶点v到顶点w有路径存在,则称v和w是连通的
- 有向图中,若从顶点v到顶点w和从顶点w到顶点v之间都有路径,则称这两个顶点是强连通的。
连通图,强连通图
若图G中任意两个顶点都是连通的,则称图G为连通图,否则称为非连通图
常见考点:对于n个顶点的无向图G
- 若G是连通图,则最少有\(n-1\)条边
- 若G是非连通图,则最多有\(C_{n-1}^2\)条边
若图中任何一对顶点都是强连通的,则称此图为强连通图。
常见考点:对于n个顶点的无向图G
- 若G是强连通图,则最少有\(n\)条边(形成回路)
研究图的局部——子图
设有两个图\(G=\{V,E\}\)和\(G'=\{V',E'\}\),若\(V'\)是\(V\)的子集,且\(E'\)是\(E\)的子集,则称\(G'\)是\(G\)的子图。
若有满足\(V(G')=V(G)\)的子图\(G'\),则称其为\(G\)的生成子图
连通分量
无向图的极大连通子图称为连通分量
有向图中的极大强连通子图称为有向图的强连通分量
生成树
连通图的生成树是包含图中全部顶点的极小连通子图。
若图中顶点数为n,则它的生成树含有\(n-1\)条边。对生成树而言,若砍去它的一条边,则会变成非连通图,若加上一条边则会形成一个回路。
生成森林
在非连通图中,连通分量的生成树构成了非连通图的生成森林
边的权、带权图/网
边的权——在一个图中,每条边都可以标上具有某种含义的数值,该数值称为该边的权值。
带权图/网——边上带有权值的图称为带权图,也称网。
带权路径长度——当图是带权图时,一条路径上所有边的权值之和,称为该路径的带权路径长度。
几种特殊形态的图
无向完全图——无向图中任意两个顶点之间都存在边
若无向图的顶点数\(|V|=n\),则\(|E|\in[0,C_n^2]=[0,n(n-1)/2]\)
有向完全图——有向图中任意两个顶点之间都存储方向相反的两条弧。
若有向图的顶点数\(|V|=n\),则\(|E|\in[0,2C_n^2]=[0,n(n-1)]\)
稀疏图——边数很少的图,反之为稠密图
没有绝对的界限,一般来说\(|E|<|V|log|V|\)时,可以视为稀疏图
树——不存在回路,且连通的无向图
常见考点:n个顶点的图,若\(|E|>n-1\),则一定会有回路
有向树——一个顶点的入度为0,其余顶点入度均为1的有向图,称为有向树。
图的存储
邻接矩阵法
#define MaxVertexNum 100 //顶点数目的最大值
typedef struct
{
char Vex[MaxVertexNum]; //顶点表
int Edge[MaxVertexNum][MaxVertexNum]; //邻接矩阵,边表
int vecnum,arcnum; //图的当前顶点数和边数/弧数
}MGraph;
第i个结点的度=第i行(列)的非零元素个数(无向图)
第i个结点的出度=第i行的非零元素个数
第i个结点的入度=第i列的非零元素个数
第i个结点的度=第i行,列的非零元素个数之和
//邻接矩阵带权图(网)
#define MaxVertexNum 100 //顶点数目的最大值
#define INFINITY 最大的int值 //定义“无穷”
typedef struct
{
char Vex[MaxVertexNum]; //顶点表
int Edge[MaxVertexNum][MaxVertexNum]; //邻接矩阵,边表
int vecnum,arcnum; //图的当前顶点数和边数/弧数
}MGraph;
性能分析
空间复杂度:\(O(|V|^2)\)——只和顶点数相关,和实际的边数无关
适合用于存储稠密图,无向图的邻接矩阵时对称矩阵,可以压缩存储(只存储上/下三角区)
性质
设图G的邻接矩阵为A(矩阵元素为0/1),则\(A^n\)的元素\(A^n[i][j]\)等于由顶点i到顶点j的长度为n的路径的数目。
邻接表法(顺序+链式存储)
#define MaxVertexNum 100 //顶点数目的最大值
//“边/弧”
typedef struct ArcNode{
int adjvex; //边/弧指向哪个结点
struct ArcNode* next; //指向下一条弧的指针
//InfoType info; //边权值
}
//顶点
typedef struct VNodes{
VertexType data; //顶点信息
ArcNode* first; //第一条边/弧
}VNode,AdjList[MaxVertexNum];
//用邻接表存储的图
typedef struct{
AdjList vertices;
int vexnum,arcnum;
}ALGraph;
无向图:边结点的数量是\(2|E|\),整体空间复杂度\(O(|V|+2|E|)\)
有向图:边结点的数量是\(|E|\),整体空间复杂度\(O(|V|+|E|)\)
总结
十字链表法
性能分析:
空间复杂度:\(O(|V|+|E|)\)
指定顶点的出边——绿色线;指定顶点的入边——橙色线
十字链表只用于存储有向图
邻接多重表法
空间复杂度:\(O(|V|+|E|)\)
删除边,删除节点等操作很方便
邻接多重表只用于存储无向图
总结
图的基本操作
Adjacent(G,x,y)
:判断图G是否存在边\(<x,y>\)或\((x,y)\)
Neighbors(G,x)
:列出图G中与结点x邻接的边
InsertVertex(G.x)
:在图G中插入顶点x
DeleteVertex(G,x)
:从图G中删除顶点x
AddEdge(G,x,y)
:若无向边\((x,y)\)或有向边\(<x,y>\)不存在,则向图G中添加该边。
FirstNeighbor(G,x)
:求图G中顶点x的第一个邻接点,若有则返回顶点号,若x没有邻接点或图中不存在x,则返回\(-1\)
NextNeighbor(G,x,y)
:假设图G中顶点y是顶点x的一个邻接点,返回除y之外顶点x的下一个邻接点的顶点号,若y是x的最后一个邻接点,则返回-1
Get_edge_value(G,x,y)
:获取图G中边\((x,y)\)或\(<x,y>\)对应的权值
Set_edge_value(G,x,y,v)
:设置图G中边\((x,y)\)或\(<x,y>\)对应的权值为v
图的遍历
广度优先遍历(BFS)
-
找到与一个顶点相邻的所有顶点
-
标记那些顶点被访问过
-
需要一个辅助队列
bool visited[MAX_VERTEX_NUM]; //访问标记数组
//广度优先遍历
void BFS(Graph G,int v) //从顶点v除发
{
visit(v);
visited[v]=TRUE; //对v做已访问标记
Enqueue(Q,v); //顶点v入队列Q
while(!isEmpty(Q))
{
DeQueue(Q,v); //顶点v出队列
for(int w=FristNeighbor(G,v);w>=0;w=NextNeighbor(G,v,w))
{
//检测v所有邻接点
if(!visited[w]) //w为w的尚未访问的邻接顶点
{
visit(w);
visited[w]=TRUE;
EnQueue(Q,w);
}
}
}
}
同一个图的邻接矩阵表示方式唯一,因此广度优先遍历序列唯一。
同一个图邻接表表示方式不唯一,因此广度优先遍历序列不唯一
//对图G进行广度优先遍历
void BFSTraverse(Graph G)
{
for(int i=0;i<G.vexnum;++i)
visited[i]=FALSE;
InitQueue(Q);
for(int i=0;i<G.vexnum;++i)
if(!visited[i]) //对每个连通分量调用一次BFS
BFS(G,i);
}
对于无向图,调用BFS函数的次数=连通分量数
复杂度分析
空间复杂度:最坏情况,辅助队列大小为\(O(|V|)\)
邻接矩阵存储的图:
访问\(|V|\)个顶点需要\(O(|V|)\)时间,查找每个顶点的邻接点都需要\(O(|V|)\)的时间,而总共有\(|V|\)顶点时间复杂度=\(O(|V|^2)\)
邻接表存储的图:
访问\(|V|\)个顶点需要\(O(|V|)\)时间,查找每个顶点的邻接点都需要\(O(||)E\)的时间,而总共有\(|V|\)顶点时间复杂度=\(O(|V|+|E|)\)+
广度优先生成树
广度优先生成森林
深度优先遍历(DFS)
bool visited[MAX_VERTEX_NUM]; //访问标记数组
//深度优先遍历
void DFS(Graph G,int v) //从顶点v除发
{
visit(v);
visited[v]=TRUE; //对v做已访问标记
for(int w=FristNeighbor(G,v);w>=0;w=NextNeighbor(G,v,w))
{
if(!visited[w]) //w为u的尚未访问的邻接顶点
DFS(G,w);
}
}
//对图G进行深度优先遍历
void DFSTraverse(Graph G)
{
for(int i=0;i<G.vexnum;++i)
visited[i]=FALSE;
for(int i=0;i<G.vexnum;++i)
if(!visited[i])
DFS(G,i);
}
复杂度分析
空间复杂度:来自函数调用栈,最坏情况,递归深度为\(O|V|\)
最好情况:\(O(1)\)
时间复杂度=访问各结点所需时间+探索各条边的所需时间
深度优先生成树
深度优先生成森林
图的遍历与图的连通性
对无向图进行BFS/DFS遍历,调用BFS/DFS函数的次数=连通分量数
对于连通图,只需调用一次BFS/DFS
对有向图进行BFS/DFS遍历,调用BFS/DFS函数的次数要具体问题具体分析
若其实顶点到其他各顶点都有路径,则只需调用1次BFS/DFS函数
对于强连通图,从任一结点出发都只需调用1次BFS/DFS函数
最小生成树
对于一个带权连通无向图\(G=(V,E)\),生成树不同,每棵树的权(即树中所有边上的权值之和)也可能不同。设R为G的所有树的集合,若T为R中边的权值之和最小的生成树,则T称为G的最小生成树(Minimun-Spanning-Tree,MST)
Prim算法(普里姆)
从某一个顶点开始构建生成树,每次将代价最小的新顶点纳入生成树,直到所有顶点都纳入为止。
时间复杂度:\(O(|V|^2)\)
适合用于边稠密图。
实现思想
Kruskal算法(克鲁斯卡尔)
每次选择一条权值最小的边,使这条边的两头连通(原本已经连通的就不选),直到所有结点都连通
时间复杂度:\(O(|E|log_2|E|)\)
适合用于边稀疏图。
实现思想
最短路径问题
BFS(无权图)
对BFS的小修改,在visit
一个顶点时,修改其最短路径长度d[]
并在path[]
记录前驱结点
//求顶点u到其他顶点的最短路径
void BFS_MIN_Distance(Graph G,int u) //从顶点v除发
{
//d[i]表示从u到i结点的最短路径
for(int i=0;i<G.vexnum;++i)
{
d[i]=无穷大; //初始化路径长度
path[i]=-1; //最短路径从哪个顶点过来
}
d[u]=0;
visited[v]=TRUE; //对v做已访问标记
Enqueue(Q,u); //顶点v入队列Q
while(!isEmpty(Q))
{
DeQueue(Q,u); //顶点v出队列
for(int w=FristNeighbor(G,v);w>=0;w=NextNeighbor(G,v,w))
{
//检测v所有邻接点
if(!visited[w]) //w为w的尚未访问的邻接顶点
{
d[w]=d[u]+1; //路径长度加1
path[w]=u;
visited[w]=TRUE;
EnQueue(Q,w);
}
}
}
}
Dijkstra算法(迪杰斯特拉)
初始:若从\(V_0\)开始,令final[0]=true;dist[0]=0;path[0]=-1
;其余顶点final[k]=false;disk[k]=arcs[0][k];path[k]=(arcs[0][k]==无穷大)?-1:0
n-1轮处理:循环遍历所有顶点,找到还没确定最短路径,且dist
最小的顶点\(V_i\),令final[i]=true
。并检查所有邻接自\(V_i\)的顶点,对于邻接自\(V_i\)的顶点\(V_j\),若final[j]==false
且dist[i]+arc[i][j]<dist[j]
,则令dist[j]=dist[i]+arc[i][j];path[j]=i
(arcs[i][j]
表示\(Vi\)到\(V_j\)d的弧的权值)
用于负权值带权图
结论:Dijkstra算法不适用于有负权值的带权图。
Floyd算法
若\(A^{(k-1)}[i][j]>A^{(k-1)}[i][k]+A^{(k-1)}[k][j]\)
则\(A^{(k)}[i][j]=A^{(k-1)}[i][k]+A^{(k-1)}[k][j]\),\(path^{(k)}[i][j]=k\)
否则,\(A^{(k)}\)和\(path^{(k)}\)都保持原值
//准备工作,根据图的信息初始化矩阵A和path(如上图)
for(int k=0;k<n;k++)//考虑以vk作为中转点
for(int i=0;i<n;i++) //遍历整个矩阵,i为行号,j为列号
if(A[i][j]>A[i][k]+A[k][j]) //以vk为中转点的路径更短
{
A[i][j]=A[i][k]+A[k][j]; //更新最短路径长度
path[i][j]=k; //中转点
}
不能解决的问题
Floyd算法不能解决带有“负权回路”的图,这种图有可能没有最短路径
总结
有向无环图
有向无环图:若一个有向图中不存在环,则称为有向无环图。简称DAG图(Directed Acyclic Graph)
解题方法:
- 把各个操作数不重复排成一排
- 标出各个运算符的生效顺序(先后顺序有点出入无所谓)
- 按顺序加入运算符,注意“分层”
- 从低向上逐层检查同层的运算符是否可以合体
拓扑排序
AOV网(Activity On Vertex Network,用顶点表示活动的网);
用DAG图(有向无环图)表示一个工程。顶点表示活动,有向边\(<V_i,V_j>\)表示活动\(V_i\)必须先于活动\(V_j\)进行
拓扑排序:
在图论中,由一个有向无环图的顶点组成的序列,当且仅当满足下列条件时,称为该图的一个拓扑排序:
- 每个顶点出现且只出现一次
- 若顶点A在序列中排在顶点B的签名,则在图中不存在从顶点B到顶点A的路径
或定义为:拓扑排序是对有向无环图的顶点的一种排序,它使得若在存在一条从顶点A到顶点B的路径,则在排序中顶点B出现在顶点A的后面。每个AOV网都有一个或多个拓扑排序序列。
拓扑排序实现:
- 从AOV网中选择一个没有前驱(入度为0)的顶点并输出
- 从网中删除该顶点和所有以它为起点的有向边
- 重复①和②直到当前的AOV网为空,或当前网中不存在无前驱的顶点为止
bool TopologicalSort(Grapg G)
{
InitStack(S); //初始化栈,存储入度为0的顶点
for(int i=0;i<G.vexnum;i++)
if(indegree[i]==0)
Push(S,i); //将所有入度为0的顶点进栈
int count=0; //计数,记录当前已经输出的顶点数
while(!IsEmpty(S)) //栈不空,则存在入度为0的顶点
{
Pop(S,i); //栈顶元素出栈
print[count++]=i; //输出顶点I
for(p=G.vertices[i].firstarc;p;p=p->nextarc)
{
//将所有i指向的顶点的入度减1,并且将入度减为0的顶点压入栈
v=p->adjvex;
if(!(--indegree[v]))
Push(S,v);
}
}
if(count<G.vexnum)
return false; //排序失败,有向图中有回路
else
return true;
}
时间复杂度:\(O(|V|+|E|)\),若采用邻接矩阵,则需\(O(|V|^2)\)
逆拓扑排序
对一个AOV网。如果采用下列步骤进行排序,则称之为逆拓扑排序
- 从AOV网中选择一个没有后继(出度为0)的顶点并输出
- 从网中删除该顶点和所有以它为终点的有向边
- 重复①和②直到当前的AOV网为空
逆拓扑排序的实现(DFS算法)
bool visited[MAX_VERTEX_NUM]; //访问标记数组
//对图G进行深度优先遍历
void DFSTraverse(Graph G)
{
for(int i=0;i<G.vexnum;++i)
visited[i]=FALSE;
for(int i=0;i<G.vexnum;++i)
if(!visited[i])
DFS(G,i);
}
//深度优先遍历
void DFS(Graph G,int v) //从顶点v除发
{
visit(v);
visited[v]=TRUE; //对v做已访问标记
for(int w=FristNeighbor(G,v);w>=0;w=NextNeighbor(G,v,w))
{
if(!visited[w]) //w为u的尚未访问的邻接顶点
DFS(G,w);
}
print(v); //输出顶点
}
关键路径
在带权有向图中,以顶点表示时间,以有向边表示活动,以边上的权值表示完成该活动的开销(入完成活动所需的时间),称之为用边表示活动的网络,简称AOE网(Acitvity On Edge NetWork)
AOE网具有以下两个性质:
- 只有在某顶点所代表的事件发生后,从该顶点出发的各有边所代表的活动才能开始
- 只有在进入某顶点的各有向边所代表的活动都已结束时,该顶点所代表的事件才能发生,另外,有些活动是可以并行进行的。
在AOE网中仅有一个入度为0的顶点,称为开始顶点(源点),它表示整个工程的开始
也仅有一个出度为0的顶点,称为结束顶点(汇点),它表示整个工程的结束
从源点到汇点的有向路径可能有多条,所有路径中,具有最大路径长度的路径称为关键路径,而把关键路径上的活动称为关键活动。
完成整个工程的最短时间就是关键路径的长度,若关键活动不能按时完成,则整个工程的完成时间就会延长。
事件\(v_k\)的最早发生时间\(Ve(k)\)——决定了所有从\(V_k\)开始的活动能够开工的最早时间
活动\(a_e\)的最早发生时间\(e(i)\)——指该活动弧的起点所表示的事件的最早发生时间
事件\(v_k\)的最迟发生时间\(Vl(k)\)——它是指在不推迟整个工程完成的前提下,该事件最迟必须发生的时间。
活动\(a_e\)的最迟发生时间\(l(i)\)——它是指该活动弧的终点所表示的事件的最迟发生时间于该活动所需时间之差。
活动\(a_i\)的时间余量\(d(i)=l(i)-e(i)\),表示在不增加完成整个工程所需总时间的情况下,活动\(a_i\)可以拖延的时间
若一个活动的时间余量为零,则说明该活动必须要如期完成,\(d(i)=0\)即\(l(i)=e(i)\)的活动\(a_i\)是关键活动
由关键活动组成的路径就是关键路径
求关键路径的步骤:
-
求所有事件的最早发生事件\(ve()\)
按拓扑排序序列,一次求各个顶点的\(ve(k)\)
\(ve(源点)=0\),\(ve(k)=Max\{ve(j)+Weight(v_j,v_k)\},v_j为v_k的任意前驱\)
-
求所有事件的最迟发生时间\(Vl()\)
按逆拓扑排序序列,一次求各个顶点的\(vl(k)\)
\(vl(汇点)=ve(汇点)\),\(v(lk)=Min\{vl(j)+Weight(v_k,v_j)\},v_j为v_k的任意后继\)
-
求所有活动的最早发生时间\(e()\)
若边\(<v_k,v_j>\)表示活动\(a_i\),则有\(e(i)=ve(k)\)
-
求所有活动的最迟发生时间\(l()\)
若边\(<v_k,v_j>\)表示活动\(a_i\),则有\(l(i)=vl(j)-Weight(v_k,v_j)\)
-
求所有活动的时间余量\(d()\)
\(d(i)=l(i)-e(i)\)
关键活动,关键路径的特性
若关键活动耗时增加,则整个工程的工期将增长
缩短关键活动的时间,可以缩短整个工程的工期
当缩短到一定程度时,关键活动可能会变成非关键活动
可能有多条关键路径,只提高一条关键路径上的关键活动并不能缩短整个工程的工期,只有加快那些包括在所有关键路径的关键活动才能缩短工期。