一、图的基本概念
1.图的定义
图G由顶点集V和边集E组成,记为G=(V,E),其中V(G)表示图G中顶点的有限非空集;E(G)表示图G 中顶点之间的关系(边)集合。若V={v1,v2,...,vn},则用|V∣表示图G中顶点的个数,也称图G的阶,E={(u,v)∣u∈V,v∈V},用∣E∣表示图G中边的条数。
注意!!!!!!!!!!!!!!!!!!!!!!!!!!!:
线性表可以是空表,树可以是空树,但图不可以是空,即V一定是非空集。
2.无向图,有向图
若E是无向边 (简称边)的有限集合时,则图G为无向图。
若E是有向边 (也称弧)的有限集合时,则图G为有向图。
例:
上图中左边图G1为有向图,右边图G2为无向图,可表示为
G1=(V1,E1) V1={a,b,c,d,e,f} E1={<a,b>,<b,a>,<c,c>,<e,e>,<b,c>,<a,d>}
G2=(V2,E2) V2={a,b,c,d} E2={(b,b),(a,b),(a,d),(a,c),(b,c),(c,d),(b,d)}
3.简单图,多重图
简单图:
- 不存在重复边。
- 不存在顶点到自身的边。
多重图: 图G中某两个结点之间的边数多于一条 ,有允许顶点通过同一条边和自己关联。
4.顶点的度,出度,入度
对于无向图 :顶点v的度是指依附于该顶点的边的条数,记为TD(v)。
无向图的全部顶点的度的和等于边数的2倍。
对于有向图:
入度 是以顶点v为终点 的有向边的数目,记为ID(v)。
出度 是以顶点v为起点的有向边的数目,记为OD(v)。
顶点v的度等于其入度和出度之和。
5.顶点------顶点的关系描述
- 路径------顶点vp到顶点vq之间的一条路径是指顶点序列。
顶点之间有可能不存在路径,有向图的路径也是有向的
- 回路/环------第一个顶点和最后一个顶点相同的路径
- 简单路径 ------在路径序列中,顶点不重复出现的路径
- 简单回路------除第一个顶点和最后一个顶点外,其余顶点不重复出现的回路
- 路径长度------路径上边的数目
- 点到点的距离------从顶点u出发到顶点v的最短路径若存在,则此路径的长度称为从u到v的距离。若u到v根本不存在路径,则记该距离为无穷(∞)
- 无向图中,若从顶点v vv到顶点w ww有路径存在,则称v vv和w ww是连通的
- 有向图中,若从顶点v到顶点w和从顶点w到顶点v之间都有路径,则称这两个顶点是强连通的
6.连通图,强连通图
若图G中任意两个顶点都是连通的,则称图G为连通图,否则称为非连通图
注意:!!!!!!!!!!!!!!!!!!!!!!!!!
研究其最小连通时,求n-1的最大联通(1+2+3+...+n-1)+1
7.子图
8.连通分量
无向图 中的极大连通子图称为连通分量
9.强连通分量
有向图 中的极大强连通子图称为有向图的强连通分量
10.生成树
连通图的生成树是包含图中全部顶点 的一个极小连通子图。
若图中顶点数为n,则它的生成树 含有n−1条边。对生成树而言,若砍去它的一条边,则会变成非连通图,若加上一条边则会形成一个回路。
11.生成森林
在非连通图中,连通分量的生成树 构成了非连通图的生成森林。
12.边的权,带权图/网
- 边的权------在一个图中,每条边都可以标上具有某种含义的数值,该数值称为该边的权值。
- 带权图/网------边上带有权值的图称为带权图,也称网。
- 带权路径长度------当图是带权图时,一条路径上所有边的权值之和 ,称为该路径的带权路径长度。
13.几种特殊形态的图
- 无向完全图------无向图中任意两个顶点之间都存在边
若无向图的顶点数∣V∣=n,则E∣∈[0,Cn2]=[0,n(n−1)/2]
- 有向完全图------有向图中任意两个顶点之间都存在方向相反的两条弧
若有向图的顶点数∣V∣=n,则∣E∣∈[0,2Cn2]=[0,n(n−1)]
- 稀疏图------边数很少的图
- 稠密图------边数很多的图
- 树------不存在回路,且连通的无向图
n个顶点的树,必有n−1条边
- 有向树------一个顶点的入度为0,其余顶点的入度均为1的有向图,称为有向树
二、图的存储和基本操作
1.邻接矩阵法
不管是无向图还是有向图,都可以用邻接矩阵 表示并存储。一个一维数组 存储图中的顶点信息 ,二维数组存储 存储各顶点之间的邻接关系。
下面是两个具体的例子
无向图:
有向图:
用代码实现就是
cpp
#define MaxVertexNum 100 //顶点数目的最大值
typedef struct{
char Vex[MaxVertexNum]; //顶点表
int Edge[MaxVertexNum][MaxVertexNum]; //邻接矩阵,边表
int vexnum,arcnum; //图的当前顶点数和边数/弧数
}MGraph;
邻接矩阵的性能分析:
空间复杂度:O(n^2) ------只与顶点数有关,和实际的边数无关 。
适合存储稠密图。
邻接矩阵法的性质:
无向图:
- 临界矩阵是一个对称矩阵
- 某行或列的非0元素之和是对应点的度
有向图
- 行是出度,列是入度
总:
A为邻接矩阵,A^n的元素A^n[i][j]==由顶点i到顶点j的长度为n的路径数目。
2.邻接表法
邻接表 | 邻接矩阵 | |
---|---|---|
空间复杂度 | 无向图O(∣V∣+2∣E∣),有向图O(∣V∣+∣E∣) | O(∣V∣^2) |
适合用于 | 存储稀疏图 | 存储稠密图 |
表示方式 | 不唯一 | 唯一 |
计算度/出度/入度 | 计算有向图的度,入度不方便,其余很方便 | 必须遍历对应行或列 |
找相邻的边 | 找有向图的入边不方便,其余很方便 | 必须遍历对应行或列 |
邻接矩阵 是数组实现的顺序存储 ,空间复杂度高 ,不适合存储稀疏图。邻接表法 用顺序+链式存储 ,类似树的孩子表示法 ,即各个结点顺序存储 ,再用一个链表来指明和这个结点相邻的各个边
用一个一维数组来存储各个顶点的信息。
代码(无向图)实现如下
cpp
//用邻接表存储的图
typedef struct{
AdjList vertices;
int vexnum,arcnum;
}ALGraph;
//"顶点"
typedef struct VNode{
VertexType data; //顶点信息
ArcNode *first; //第一条边/弧
}VNode,Adjust[MaxVertexNum]
//"边/弧"
typedef struct ArcNode{
int adjvex; //边/弧指向哪个结点
struct ArcNode *next; //指向下一条弧的指针
//InfoType info; //边权值
}ArcNode;
从上图能看出同一条边实际上被存储了两次 ,整体空间复杂度为O(|V|+2|E|)。
有向图: 从上面可以看到边结点的数量是∣E∣,整体空间复杂度为O(|V|+|E|)。
3.十字链表 邻接多重表
邻接矩阵 | 邻接表 | 十字链表 | 邻接多重表 | |
---|---|---|---|---|
空间复杂度 | O(∣V∣^2) | 无向图O(∣V∣+2∣E∣),有向图O(∣V∣+∣E∣) | O(∣V∣+∣E∣) | O(∣V∣+∣E∣) |
找相邻边 | 遍历对应行或列时间复杂度为O(|V|) | 遍历整个邻接表 | 很方便 | 很方便 |
删除边或顶点 | 删除边很方便,删除顶点需要大量移动数据 | 无向图中删除边或顶点都不方便 | 很方便 | 很方便 |
适用于 | 稠密图 | 稀疏图和其他 | 只能存有向图 | 只能存无向图 |
表示方式 | 唯一 | 不唯一 | 不唯一 | 不唯一 |
①十字链表(有向图)
定义两种结构体,一种表示顶点,另一种表示弧。绿色指针对应所有出边。橙色指针对应所有入边 。空间复杂度为O(∣V∣+∣E∣),且找各个顶点的出边和入边都很方便。
②邻接多重图(无向图)
结点为边。
空间复杂度为O(∣V∣+∣E∣) ,每条边只对应一份数据。删除边,删除节点,找到和指定结点相连的边等操作都很方便。
三、图的遍历
1.图的广度优先遍历(BFS)
类似于二叉树的层序遍历 。邻接表 实现表示不唯一 ,邻接矩阵 实现唯一。
广度优先遍历(BFS)要点:
- 找到与一个顶点相邻的所有顶点
- 标记哪些顶点被访问过
- 需要一个辅助队列
cpp
bool visited[MAX_VERTEX_NUM]; //标记访问数组
void BFSTraverse(Graph G){ //对图G进行广度优先遍历
for(i=0;i<G.vexnum;++i){
visited[i]=FALSE; //访问标记数组初始化
}
InitQueue(Q); //初始化辅助队列
for(i=0;i<G.vexnum;++i){ //从0号顶点开始遍历
if(!visited[i]){ //对每个连通分量调用一次BFS
BFS(G,i); //vi未访问过,从vi开始BFS
}
}
}
void BFS(Graph G,int v){ //从顶点v出发,广度优先遍历图G
visit(v); //访问初始顶点v
visited[v]=TRUE; //对v做已访问标记
Enqueue(Q,v);
while(!isEmpty(Q)){
DeQueue(Q,v); //顶点v出队列
for(w=FirstNeighbor(G,v);w>=0;w=NextNeighbor(G,v,w)){
//检测v所有邻接点
if(!visited[w]){ //w为v的尚未访问的邻接顶点
visit(w); //访问顶点w
visited[w]=TRUE; //对w做已访问标记
EnQueue(Q,w); //顶点w入队列
}//if
}//while
}
}
复杂度分析:
空间复杂度:主要是与辅助队列有关,最坏时间复杂度为O(∣V∣)。
时间复杂度: 主要来自访问各个顶点和探索各条边 。邻接矩阵为O(|V|^2),邻接表为O(|V|+|E|)。
广度优先生成树
以下图为例,从2号结点开始进行广度优先遍历。
注意:!!!!!!!!!!!!!!!!!!!!!!!!!!
同一个图临界矩阵存储表示唯一,广度优先生成树也唯一;邻接表不唯一,广度优先生成树也不唯一。
广度优先生成森林
对非连通图的广度优先遍历,可得广度优先生成森林
2.图的深度优先遍历(DFS)
图的深度优先遍历类似树的先根遍历,都可以用递归来实现。只不过树的先根遍历新找到的结点一定是没有访问过的。
代码实现
cpp
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){ //本代码是从v=0开始遍历
if(!visited[v]){
DFS(G,v)
}//if
}
}
void DFS(Graph G,int v){ //从顶点v出发,深度优先遍历图G
visit(v); //访问顶点v
visited[v]=TRUE; //设已访问标记
for(w=FirstNeighbor(G,v);w>=0;w=NextNeighbor(G,v,w)){
if(!visited[w]){ //w为u的尚未访问的邻接顶点
DFS(G,w);
}
}
}
算法复杂度分析
空间复杂度:主要来自于函数的递归调用,最坏为O(|V|),最好为O(1)。
时间复杂度:邻接矩阵为O(|V|^2),邻接表为O(|V|+|E|)。
深度优先生成树 与深度优先生成森林类比广度优先。
3.图的遍历与图的连通性
对无向图进行BFS/DFS遍历,调用BFS/DFS函数的次数=连通分量数 。对于连通图,只需调用1次BFS/DFS。
四、图的应用
1.最小生成树
一个连通图的生成树包含图的所有顶点,权值最小的那个生成树叫做最小生成树。
最小生成树的性质:
- 存在权值相同的边,最小生成树可能不唯一
- 最小生成树不唯一,但权值一定唯一且最小
- 最小生成树V=E-1
1)Prim(普里姆)算法
从某一个顶点开始构建生成树;每次将代价最小的新顶点纳入生成树,直到所有顶点都纳入为止
时间复杂度为O(|V|^2),适合用于边稠密图。
算法实现:
。。。。
2)Kruskal(克鲁斯卡尔)算法
每次选择一条权值最小的边,使这条边的两头连通 (原本已经连通的就不选);直到所有结点都连通。 时间复杂度为O(|E|log2 |E|),适合用于边稀疏图。
Kruskal(克鲁斯卡尔)算法的实现思想
。。。。。
2.最短路径问题
1)BFS(广度优先)算法
代码实现
在广度优先算法上做一点修改即可,在visit一个顶点时,修改其最短路径长度d[]并在path[]记录前驱结点。d[]数组 反映起点到目标结点的最短长度 ,path[]数组 可以反映最短路径的走法。
cpp
//求顶点u到其他顶点的最短路径
void BFS_MIN_Distance(Graph G,int u){
//d[i]表示从u到i结点的最短路径
for(i=0;i<G.vexnum;i++){
d[i]=无穷大; //初始化路径长度
path[i]=-1; //最短路径从哪个顶点过来
}
d[u]=0;
visited[u]=TRUE;
EnQueue(Q,u);
while(!IsEmpty(Q)){ //BFS算法主过程
DeQueue(Q,u); //队头元素u出队
for(w=FirstNeighbor(G,u);w>=0;w=NextNeighbor(G,u,w)){
if(!visited[w]){ //w为u的尚未访问的邻接顶点
d[w]=d[u]+1; //路径长度加1
path[w]=u; //最短路径应从u到w
visited[w]=TRUE; //设已访问标记
EnQueue(Q,w); //顶点w进队
}//if
}
}//while
}
之前提到过广度优先生成树,在生成树中某结点在哪一层直接反映了从起点(根结点)到该结点的最短路径长度。既然是最短路径,就意味着如果以起点为根结点来构造一棵生成树的话,用广度优先构造出的生成树深度一定是最小的
2)Dijkstra算法
采用贪心策略,不适用于带负权值的图,求点到点的。
算法演示
时间复杂度:O(n^2)即O(|V|^2)。经过n−1轮处理,每次处理时间复杂度为O(n)+O(n)
3)Floyd算法
Floyd算法可以用于负权值带权图 ,但不能解决带有"负权回路"的图(有负权值的边组成回路),这种图可能没有最短路径
求出每一对顶点之间的最短路径(求所有点的)
使用动态规划思想,将问题的求解分为多个阶段
对于n个顶点的图G,求任意一对顶点Vi→Vj之间的最短路径可分为如下几个阶段:
#初始:不允许在其他顶点中转,最短路径是?
#0:若允许在V0中转,最短路径是?
#1:若允许在V0,V1中转,最短路径是?
#2:若允许在V0,V1,V2中转,最短路径是?
...
#n-1:若允许在V0,V1,V2......Vn−1中转,最短路径是?
算法演示
Floyd算法核心代码
cpp
//......准备工作,根据图的信息初始化矩阵A和path(见上)
for(int k=0;k<n;k++){ //考虑以Vk作为中转点
for(int i=0;i<n;i++){ //遍历整个矩阵,i为行号,j为列号
for(int j=0;j<n;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; //中转点
}
}
}
}
时间复杂度:O(|V|^3)
空间复杂度:O(|V|^2)
总结!!!!!!!!!!!
BFS算法 | Dijkstra算法 | Floyd算法 | |
---|---|---|---|
无权图 | √ | √ | √ |
带权图 | × | √ | √ |
带负权值的图 | × | × | √ |
带负权回路的图 | × | × | × |
时间复杂度 | O(∣V∣+∣E∣) | O(|V|^2) | O(|V|^3) |
通常用于 | 求无权图的单源最短路径 | 求带权图的单源最短路径 | 求带权图中各顶点间的最短路径 |
3.有向无环图描述表达式
有向无环图(DAG):一个有向图中不存在环,则称为有向无环图,简称DAG图(Directed Acyclic Graph)
DAG描述表达式
29.[2019 统考真题]用有向无环图描述表达式(x+y)((x+y)/x),需要的顶点个数至少是()。
A.5 B.6 C.8 D.9
答案:A
化简步骤如下:
4.拓扑排序
AOV网:有先后顺序的有向无环图。
拓扑排序 :所谓的拓扑排序实际上就是找到做事的先后顺序。注意①每个顶点只出现一次②A再B前面,就不会出现B->A的序列。
注意:!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
拓扑排序的实现:
- 从AOV网中选择一个没有前驱的顶点并输出
- 从网中删除该顶点和所有以它为起点的有向边
- 重复1和2直到当前的AOV网为空或当前网中不存在无前驱的顶点为止
代码实现:
先用邻接表来存储图
cpp
#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; //Graph是以邻接表存储的图类型
拓扑排序算法实现如下,这段代码省略了对两个数组的声明,indegree用于记录每个结点当前的入度,print用于记录拓扑序列,此外还要定义一个栈S用来保存当前度为0的顶点(也可用队列)
cpp
bool TopologicalSort(Graph 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的顶点压入栈S
v=p->adjvex;
if(!(--indegree[v])){
Push(S,v); //入度为0,则入栈
}
}
}//while
if(count<G.vexnum){
return false; //排序失败,有向图中有回路
}else{
return true; //拓扑排序成功
}
}
逆拓扑排序
- 从AOV网中选择一个没有后继(出度为0)的顶点并输出
- 从网中删除该顶点和所有以它为终点的有向边
- 重复1和2直到当前当前的AAOV网为空
逆拓扑排序的实现(DFS实现)
cpp
void DFSTraverse(Graph G){ //对图G进行深度优先遍历
for(v=0;v<G.vexnum;v++){
visited[v]=FALSE; //初始化已访问标记数据
}
for(v=0;v<G.vexnum;++v){ //本代码是从v=0开始遍历
if(!visited[v]){
DFS(G,v)
}
}
}
void DFS(Graph G,int v){ //从顶点v出发,深度优先遍历图G
visited[v]=TRUE; //设已访问标记
for(w=FirstNeighbor(G,v);w>=0;w=NextNeighbor(G,v,w)){
if(!visited[w]){ //w为u的尚未访问的邻接顶点
DFS(G,w);
} //if
}
print(v); //输出顶点
}
5.关键路径
AOE网: 在带权有向图中,以顶点表示事件 ,以有向边表示活动 ,以边上的权值表示完成该活动的开销。
AOE网具有以下两个性质:
- 只有在某顶点所代表的事件发生后,从该顶点出发的各有向边所代表的活动才能开始;
- 只有在进入某顶点的各有向边所代表的活动都已结束时,该顶点所代表的事件才能发生。
- 有些活动是可以并行进行的
在AOE网中仅有一个入度为0的顶点 ,称为开始顶点(源点 ),它表示整个工程的开始;
也仅有一个出度为0的顶点 ,称为结束顶点(汇点),它表示整个工程的结束。
关键路径
从源点到汇点的有向路径可能有多条,所有路径中,具有最大路径长度的路径称为关键路径 ,而把关键路径上的活动称为关键活动
事件vk的最早发生时间ve(k)------ 决定了所有从vk开始的活动能够开工的最早时间
活动ai的最早开始时间e(i) ------ 指该活动弧的起点所表示的事件的最早发生时间
事件vk的最迟发生时间vl(k)------ 它是指在不推迟整个工程完成的前提下,该事件最迟必须发生的时间
活动ai的最迟开始时间l(i) ------ 它是指该活动弧的终点所表示事件的最迟发生时间与该活动所需时间之差
活动ai的时间余量d(i)= l(i)−e(i),表示在不增加完成整个工程所需总时间的情况下,活动ai可以拖延的时间
若一个活动的时间余量为零,则说明该活动必须要如期完成,d(i)=0d(i)=0即l(i)=e(i)的活动ai是关键活动
由关键活动组成的路径就是关键路径
求关键路径:
关键活动、关键路径的特性:
- 若关键活动耗时增加 ,则整个工程的工期将增长。
- 缩短关键活动的时间 ,可以缩短整个工程的工期。当缩短到一定程度时,关键活动可能会变成非关键活动。
- 可能有多条关键路径,只提高一条关键路径上的关键活动速度并不能缩短整个工程的工期,只有加快那些包括在所有关键路径上的关键活动才能达到缩短工期的目的