图的基本概念
图的定义
图G由顶点集V和边集E组成,记为G=(V,E),其中V(G)表示图G中顶点的有限非空集;E(G)表示图G中顶点之间关系(边)的集合。
注意:线性表可以是空表,树可以是空树,但图不可以是空图。图不能一个顶点都没有,图的顶点集一定不空,但是图的边集可以为空,即图可以只有顶点而没有边。
1)有向图
若E是有向边(也称弧)的有限集合时,则图G为有向图。弧是顶点的有序对,记为<v,w>,其中v称为弧尾,w称为弧头,<v,w>称为从顶点v到顶点w的弧。
2)无向图
若E是无向边的有限集合时,则图G为无向图。边是顶点的无序对,记为(v,w)或(w,v),其中w,v是顶点。
3)简单图
一个图G若满足:1.不存在重复边;2.不存在顶点到自身的边,则称图G为简单图。
4)多重图
若图G中某两个结点之间的边数多于一条,又允许顶点通过同一条边与自己关联,则G为多重图。
5)完全图(也称简单完全图)
对于无向图,|E|的取值范围是0到n(n-1)/2,有n(n-1)/2条边的无向图称为完全图,即在无向完全图中任意两个顶点之间都存在边。对于有向图,|E|的取值范围是0到n(n-1),有n(n-1)条弧的有向图称为有向完全图,即在有向完全图中任意两个顶点之间都存在方向相反的两条弧。
6)子图
设有两个图G=(v,E)和G’=(v’,E’)若v’是v的子集,且E’是E的子集,则称G’是G的子图。若有满足V(G’)=V(G)的子图G’,则称其为G的生成子图。
7)连通、连通图和连通分量
在无向图中,若从顶点v到顶点w有路径存在,则称v和w是连通的。若图G中任意两个顶点都是连通的,则称图G为连通图,无向图的极大连通子图称为连通分量。
8)强连通图、强连通分量
在有向图中,若从顶点v到顶点w和从顶点w到顶点v之间都有路径,则称这两个顶点是强连通的。若图中任何一对顶点都是强连通的,则称此图为强连通图。有向图中的极大强连通子图称为有向图的强连通分量。
9)生成树、生成森林
连通图的生成树是包含图中全部顶点的极小连通子图。若图中顶点数为n,则它的生成树含有n-1条边。
10)顶点的度、入度和出度
图中每个顶点的度定义为以该顶点为一个端点的边的数目。
对于无向图,顶点v的度是指依附于该顶点的边的条数,记为TD(v);在具有n个顶点,e条边的无向图中,
∑
i
=
1
n
T
D
(
v
i
)
=
2
e
\sum _{i=1}^n TD(v_i) = 2e
i=1∑nTD(vi)=2e
即无向图的全部顶点的度的和等于边数的2倍。
对于有向图,顶点v的度分为入度和出度,入度是以顶点v为终点的有向边的数目,记为ID(v);而出度是以顶点v为起点的有向边的数目,记为OD(v)。顶点v的度等于其入度与出度之和,即TD(v)=OD(v)+ID(v)。在具有n个顶点,e条边的有向图中
∑
i
=
1
n
I
D
(
v
i
)
=
∑
i
=
1
n
O
D
(
v
i
)
=
e
\sum _{i=1}^n ID(v_i)=\sum _{i=1}^n OD(v_i)=e
i=1∑nID(vi)=i=1∑nOD(vi)=e
即有向图的全部顶点的入度之和与出度之和相等,并且等于边数。
11)边的权和网
在一个图中,每一条边都可以标上具有某种含义的数值,该数值称为该边的权值。这种边上带有权值的图称为带权图,也称网。
12)稠密图、稀疏图
边数很少的图称为稀疏图,反之称为稠密图。一般当图G满足|E|<|V| log |V|时,可以将G视为稀疏图
13)路径、路径长度和回路
顶点v_1到v_n之间的一条路径是指顶点序列v_1、v_2、v_3…v_n,路径上边的数目称为路径长度。第一个顶点和最后一个顶点相同的路径称为回路或环。若一个图有n个顶点,并且有大于n-1条边,则该图一定有环。
14)简单路径、简单回路
在路径序列中,顶点不重复出现的路径称为简单路径;除第一个顶点和最后普一个顶点外,其余顶点不重复出现的回路称为简单回路
15)距离
从顶点u出发到顶点v的最短路径若存在,则此路径的长度称为从u到v 的距离
16)有向树
一个顶点的入度为0,其余顶点的入度均为1的有向图,称为有向树。
常见考点:
对于n个顶点的无向图G,
-
所有顶点度数之和为2|E|
-
若G是连通图,则最少有n-1条边(树),若|E|>n-1,则一定有回路
-
若G是非连通图,则边最多可能有
C n − 1 2 C_{n-1}^2 Cn−12 -
无向完全图共有边
C n 2 C_{n}^2 Cn2
对于n个顶点的有向图G,
- 所有顶点的出度之和=所有顶点的入度之和=|E|
- 所有顶点的度之和=2|E|
- 若G是强连通图,则最少有n条边
- 有向完全图共有边
2 C n 2 2C_{n}^2 2Cn2
图的存储及基本操作
1.邻接矩阵法
所谓邻接矩阵存储,是指用一个一维数组存储图中顶点的信息,用一个二维数组存储边的信息,存储顶点之间邻接关系的二维数组称为邻接矩阵。
结点数为n的图G=(V,E)的邻接矩阵A是n*n的,将G的顶点编号为v_1,v_2,…,v_n。
A
[
i
]
[
j
]
=
{
1
,
若
(
v
i
,
v
j
)
或
<
v
i
,
v
j
>
是
E
(
G
)
中
的
边
0
,
若
(
v
i
,
v
j
)
或
<
v
i
,
v
j
>
不
是
E
(
G
)
中
的
边
A[i][j]=\left\{ \begin{array}{lr} 1, &若(v_i,v_j)或<v_i,v_j>是E(G)中的边\\ 0, &若(v_i,v_j)或<v_i,v_j>不是E(G)中的边 \end{array} \right.
A[i][j]={1,0,若(vi,vj)或<vi,vj>是E(G)中的边若(vi,vj)或<vi,vj>不是E(G)中的边
对于带权图而言,若顶点v_i和v_j之间有边相连,则邻接矩阵中对应项存放该边对应的权值,若顶点v_i与v_j不相连,则用无穷来代表这两条边之间不存在边
A
[
i
]
[
j
]
=
{
w
i
j
,
若
(
v
i
,
v
j
)
或
<
v
i
,
v
j
>
是
E
(
G
)
中
的
边
∞
,
若
(
v
i
,
v
j
)
或
<
v
i
,
v
j
>
不
是
E
(
G
)
中
的
边
A[i][j]=\left\{ \begin{array}{lr} w_{ij}, &若(v_i,v_j)或<v_i,v_j>是E(G)中的边\\ \infty, &若(v_i,v_j)或<v_i,v_j>不是E(G)中的边 \end{array} \right.
A[i][j]={wij,∞,若(vi,vj)或<vi,vj>是E(G)中的边若(vi,vj)或<vi,vj>不是E(G)中的边
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OMpz9QtU-1598624101080)(http://myimg.zhengjc.cn/images/2020/08/26/Inked1-12022322341C59_LI.jpg)]
图的邻接矩阵存储结构定义如下:
#define MaxVertexNum 100 //顶点数目的最大值
typedef char VertexType; //顶点的数据类型
typedef int EdgeType; //带权图中边上权值的数据类型
typedef struct{
VertexType Vex[MaxVertexNum]; //顶点表
EdgeType Edge[MaxVertexNum][MaxVertexNum]; //邻接矩阵,边表
int vexnum,arcnum; //图的当前顶点数和弧数
}MGraph;
图的邻接矩阵存储表示法具有以下特点:
- 无向图的邻接矩阵一定是一个对称矩阵(并且唯一)。因此,在实际存储邻接矩阵时只需存储上(或下)三角矩阵的元素
- 对于无向图,邻接矩阵的第i行(或第i列)非零元素的个数正好是第i个顶点的度
- 对于无向图,邻接矩阵的第i行(或第i列)非零元素的个数正好是第i个顶点的出度[或入度]
- 用邻接矩阵法存储图,很容易确定图中任意两个顶点之间是否有边相连
- 稠密图适合用邻接矩阵的存储表示
- 设图G的邻接矩阵为A,An的元素An [i] [j]等于由顶点i到顶点j的长度为n的路径的数目。
2.邻接表法
所谓邻接表,是指对图G中的每个顶点v_i建立一个单链表,第i个单链表中的结点表示依附于顶点v_i的边(对于有向图则是以顶点v_i为尾的弧),这个单链表就称为顶点v_i的边表。边表的头指针和顶点的数据信息采用顺序存储,所以在邻接表中存在两种结点:顶点表结点和边表结点。
顶点表结点由顶点域(data)和指向第一条邻接边的指针(firstarc)构成,边表(邻接表)结点由邻接点域(adjvex)和指向下一条邻接边的指针域(nextarc)构成。
图的邻接表存储结构定义如下:
#define MaxVertexNum 100
typedef struct ArcNode{ //边表结点
int adjvex; //该弧所指向的顶点的位置
struct ArcNode *next; //指向下一条弧的指针
}ArcNode;
typedef struct VNode{ //顶点表结点
VertexType data; //顶点信息
ArcNode *first; //指向第一条依附该顶点的弧的指针
}VNode,AdjList[MaxVertexNum];
typedef struct{ //邻接表
AdjList vertices; //图的顶点数和弧数
int vexnum,arcnum; //ALGraph是以邻接表存储的图类型
}ALGraph;
图的邻接表存储方法具有以下特点:
- 若G为无向图,则所需的存储空间为O(|V|+2|E|);若G为有向图,则所需的存储空间为O(|V|+|E|)。
- 对于稀疏图,采用邻接表表示将极大地节省存储空间
- 在邻接表中,给定一顶点,很容易地找出它所有邻边
- 在有向图的邻接表表示中,求一个给定顶点的出度只需计算其邻接表中的结点个数,但求其顶点的入度则需要遍历全部的邻接表
- 图的邻接表表示不唯一
3.十字链表
十字链表是有向图的一种链式存储结构,在十字链表中,对应于有向图的每条弧有一个结点,对应于每一个顶点也有一个结点。
图的十字链表表示不是唯一的,但一个十字链表表示确定一个图
4.邻接多重表
邻接多重表是无向图的另一种链式存储结构。
5.图的基本操作
图的基本操作是独立于图的存储结构的。图的基本操作主要包括:
- 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中添加该边
- RemoveEdge(G,x,y):若无向边(x,y)或有向边<x,y>存在,则向图G中删除该边
- FirstNeighbor(G,x):求图G中顶点x的第一个邻接点,若有则返回顶点号。若x没有邻接点或图中不存在x,则返回-1
- NexNeighbor(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
图的遍历
广度优先搜索
广度优先搜索类似于二叉树的层次遍历算法。
广度优先算法的伪代码如下:
bool visited[MAX_VERTEX_NUM]; //访问标记数组
void BFSTraverse(Graph G){ //广度优先遍历
for(i=0;i<G.vexnum;++i)
visited[i]=FALSE; //访问标记数组初始化
InitQueue(Q); //初始化辅助队列Q
for(i=0;i<G.vexnum;++i) //从0号顶点开始遍历
if(!visited[i]) //对每个连通分量调用一次BFS
BSF(G,i); // vi未访问过,从vi开始BFS
}
void BFS(Graph G,int v){ // 从顶点v出发,广度优先遍历图G
visit(v); //访问初始顶点v
visited[v]=TRUE; //对v做已访问标记
Enqueue(Q,v); //顶点v入队列Q
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入队列
}
}
}
BFS算法的性能分析
在最坏情况下,空间复杂度为O(|V|),采用邻接表存储方式时,每个顶点均需要搜索一次,故时间复杂度为O(V),在搜索任一顶点的邻接点时,每条边至少访问一次,故时间复杂度为O(|E|),算法总的时间复杂度为O(|V|+|E|)。采用邻接矩阵存储方式时,查找每个顶点的邻接点需要的时间为O(|V|),算法总的时间复杂度为O(|V|^2)。
广度优先生成树:在广度遍历的过程中,我们可以得到一棵遍历树称为广度优先生成树。一给定图的邻接矩阵存储表示是唯一的,故其广度优先生成树也是唯一的,但由于图的邻接表存储是不唯一的,故其广度优先生成树也不唯一。
深度优先搜素
深度优先搜索类似于树的先序遍历,它的基本思想如下:首先访问图中某一起始顶点v,然后由v出发,访问与v邻接且未被访问的任一结点w1,再访问与w1邻接且未被访问的任一顶点w2……重复以上过程,当不能再继续向下访问时,依次退回到最近被访问的顶点,若它还有邻接顶点未被访问过,则从该点开始继续上述搜索过程,直至图中所有顶点均被访问过为止。
深度优先算法的伪代码如下:
bool visited[MAX_VERTEX_NUM]; //访问标记数组
void DFSTraverse(Graph G){ //深度优先遍历
for(v=0;v<G.vexnum;++v)
visited[v]=FALSE; //访问标记数组初始化
for(v=0;v<G.vexnum;++v) //从0号顶点开始遍历
if(!visited[v]) //对每个连通分量调用一次BFS
DSF(G,v); // vi未访问过,从vi开始BFS
}
void DFS(Graph G,int v){ // 从顶点v出发,深度优先遍历图G
visit(v); //访问初始顶点v
visited[v]=TRUE; //对v做已访问标记
for(w=FirstNeighbor(G,v);w>=0;w=NextNeighbor(G,v,w)) //检测v所有邻接点
if(!visited[w]){ //w为v的尚未访问的邻接顶点
DFS(G,w)
}
}
DFS算法的性能分析
深度优先遍历(DFS)是一个递归算法,需要借助一个递归工作栈,其空间复杂度为O(|V|),采用邻接表存储方式时,每个顶点均需要搜索一次,故时间复杂度为O(V),在搜索任一顶点的邻接点时,每条边至少访问一次,故时间复杂度为O(|E|),算法总的时间复杂度为O(|V|+|E|)。采用邻接矩阵存储方式时,查找每个顶点的邻接点需要的时间为O(|V|),算法总的时间复杂度为O(|V|^2)。
深度优先生成树和生成森林:与广度优先搜索一样,深度优先搜索也会产生一棵深度优先生成树。但其生成需要条件,即对连通图调用DFS才能产生深度优先生成树,否则产生的将是深度优先生成森林。与BFS类似,基于邻接表存储的深度优先生成树是不唯一的。
图的应用
1.最小生成树
对于一个带权连通无向图G=(V,E),生成树不同,每棵树的权也可能不同。设R为G的所有生成树的集合,若T为R中边的权值最小的那棵生成树,则T称为G的最小生成树。
最小生成树有如下性质:
- 最小生成树不唯一,即最小生成树的树形不唯一。当G本身是一棵树时,则G的最小生成树是它本身
- 最小生成树的边的权值之和总是唯一的。
- 最小生成树的边数为顶点数减1
构造最小生成树的算法有很多,下面是两种比较通用的算法:
1)Prim算法
算法思想:初始时从图中任取一顶点加入树T,此时树中只含有一个顶点,之后选择一个与当前T中顶点集合距离最近的顶点,并将该顶点和相对应的边加入T,每次操作后T中的顶点数和边数都增加1.依此类推,直至图中所有顶点都并入T,的到的T就是最小生成树。此时T中必然有n-1条边。需要注意的是,如果所选取的边在T中会使得T构成回路,则应该丢弃这条边另选其他边。
Prim算法的简单实现如下:
void Prim(G,T){
T=∅; //初始化空树
U={w}; //添加任一顶点w
while((V-U)!=∅){ //若树中不含全部顶点
//设{u,v}是使u∈U与v∈(V-U),且权值最小的边;
T=T ∪ {(u,v)}; //边归入树
U=U ∪ {v}; //顶点归入树
}
}
Prim算法的时间复杂度为O(|V|^2),不依赖与|E|,因此它适合于求解边稠密的图的最小生成树。
2)Kruskal算法
Kruskal算法是一种按权值递增次序选择合适的边来构造最小生成树的办法。
算法思想:初始时为只有n个顶点而无边的非连通图T={V,{}},每个顶点自成一个连通分量,然后按照边的权值由小到大的顺序,不断选取当前未被选取过且权值最小的边,若该边依附的顶点落在T中不同的连通分量上,则将此边加入T,否则舍弃此边而选择下一条权值最小的边。(其实也就是选取的边不能是能构成回路的边)依次类推,直至T中所有顶点都在一个连通分量上。
Kruskal算法的简单实现如下:
void Kruskal(V,T){
T=V; //初始化树T,仅含顶点
numS=n; //连通分量数
while(numS>1){ //若连通分量数大于1
从E中取出权值最小的边(v,u);
if(v和u属于T中不同的连通分量){
T=T∪{(u,v)}; //将此边加入生成树中
numS--; //连通分量数-1
}
}
}
Kruskal算法的时间复杂度为O(|E|log|E|),因此它适合于边稀疏而顶点较多的树。
2.最短路径
当图是带权图时,把从一个顶点v0到图中其余任意一个顶点vi的路径所经过边上的权值之和,定义为该路径的带权路径长度,把带权路径长度最短的那条路径称为最短路径。
1)Dijkstra算法求单源最短路径问题
Dijkstra算法设置了一个集合S记录已求得的最短路径的顶点,初始时把源点v0放入S,集合S每并入一个新顶点vi,都要修改源点v0到集合V-S中顶点当前的最短路径长度值。
在构造过程中还设置了两个辅助数组:
-
dist[]:记录从源点v0到其他各顶点当前的最短路径长度,它的初始状态为:若从v0到vi有弧,则dist[i]为弧上的权值;否则置dist[i]为∞。
-
path[]:path[i]表示从源点到顶点i之间的最短路径的前驱结点。
使用邻接矩阵表示时,时间复杂度为O(|V|2),使用带权的邻接表表示时,时间复杂度仍为O(|V|2)。
需要注意的是,边上带有负权值时,Dijkstra算法并不适用
2)Floyd算法求各顶点之间最短路径问题
Floyd算法的基本思想是:递推产生一个n阶方阵序列A(-1),A(0),…A(k),…A(n-1),其中A^(k)[i] [j]表示从顶点vi到顶点vj的路径长度,k表示绕行第k个顶点的运算步骤。
Floyd算法是一个迭代过程,每迭代一次,在从vi到vj的最短路径上就多考虑了一个顶点;经过n次迭代后,所得到的A^(n-1)中就保存了任意一对顶点之间的最短路径长度。
Floyd算法的时间复杂度为O(|V|^2)。Floyd算法允许图中带有负权值的边,但不允许有包含带负权值的边组成的回路。Floyd算法同样适用于带权无向图
3.有向无环图描述表达式
有向无环图:若有一个有向图中不存在环,则称为有向无环图,简称DAG图。
4.拓扑排序
AOV网:若用DAG图表示一个工程,其顶点表示活动,用有向边<vi,vj>表示活动vi必须先于活动vj进行的这样一种关系,则将这种有向图称为顶点表示活动的网络,记为AOV网。
拓扑排序:在图论中,由一个有向无环图的顶点组成的序列,当且仅当满足下列条件时,称为该图的一个拓扑排序:
① 每个顶点出现且只出现一次
② 若顶点A在序列中排在顶点B的前面,则在图中不存在从顶点B到顶点A的路径。
对一个AOV网进行拓扑排序的一种常用方法的步骤为:
- 从AOV网中选择一个没有前驱的顶点并输出
- 从网中删除该顶点和所有以它为起点的有向边
- 重复1和2直到当前的AOV网为空或当前网中不存在无前驱的顶点为止。后一种情况说明有向图中必然存在环
拓扑排序算法的实现如下:
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->nextarc){
//将所有i指向的顶点的入度减1,并且将入度减为0的顶点压入栈S
v=p->adjvex;
if(!(--indegree[v]))
Push(S,v); //入度为0,则入栈
}
}
if(count<G.vexnum)
return false; //排序失败,有向图中有回路
else
return true; //拓扑排序成功
}
由于输出每个顶点的同时还要删除以它为起点的边,故拓扑排序的时间复杂度为O(|V|+|E|)。
5.关键路径
在带权有向图中,以顶点表示事件,以有向边表示活动,以边上的权值表示完成该活动的开销,称之为用边表示活动的网络,简称AOE网。AOV网和AOE网都是有向无环图,不同之处在于它们的边和顶点所代表的含义是不同的,AOE网的边有权值,而AOV网中的边无权值,仅表示顶点之间的前后关系。
AOE网具有以下两个性质:
①只有在某顶点所代表的事件发生后,从该顶点出发的各有向边所代表的活动才能开始
②只有在进入某顶点的各有向边所代表的活动都已结束时,该结点代表的事件才能发生
在AOE网中仅有一个入度为0的顶点,称为开始顶点(源点)它表示整个工程的开始;网中也仅存在一个出度为0的顶点,称为结束顶点(汇点),它表示整个工程的结束。
从源点到汇点的所有路径中,具有最大路径长度的路径称为关键路径,而把关键路径上的活动称为关键活动。
在寻找关键活动时所需要用到的几个参量定义:
- 从开始顶点 v1 出发,令 ve(1)=0,按拓扑有序序列求其余各顶点的可能最早发生时间。Ve(k)=max{ve(j)+dut(<j,k>)} , j ∈ T 。其中T是以顶点vk为尾的所有弧的头顶点的集合(2 ≤ k ≤ n)。如果得到的拓朴有序序列中顶点的个数小于网中顶点个数n,则说明网中有环,不能求出关键路径,算法结束。
- 从完成顶点出发,令vl(汇点)=ve(汇点),按逆拓扑有序求其余各顶点的允许的最晚发生时间:
vl(j)=min{vl(k)-dut(<j,k>)} ,k ∈ S 。其中 S 是以顶点vj是头的所有弧的尾顶点集合(1 ≤ j ≤ n-1)。 - 求每一项活动ai(1 ≤ i ≤ m)的最早开始时间e(i)=ve(j),最晚开始时间l(i)=vl(k)-dut(<j,k>) 。
若某条弧满足 e(i)=l(i) ,则它是关键活动。
对于关键路径,需要注意以下几点:
- 关键路径上的所有活动都是关键活动,它是决定整个工程的关键因素,因此可通过加快关键活动来缩短整个工程的工期。但不能任意缩短,因为一旦缩短到一定程度,该关键活动就可能会变成非关键活动
- 网中的关键活动并不唯一。