图
1、图的基本概念
1.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 中边的条数。
下面是图的一些基本概念:
-
有向图
若 E 是有向边(也称为弧)的有限集合,则图 G 为有向图。弧是顶点的有序对,记为 <v,w>,其中 v、w 是顶点。
-
无向图
边是顶点的无序对,记为 (v,w) 或 (w,v)。
-
简单图
一个图 G 满足:1、不存在重复边;2、不存在顶点到自身的边。则称图 G 为简单图。
-
多重图
若图 G 中某两个结点之间的边数多于一条,又允许顶点通过同一条边和自己关联,则 G 为多重图。
-
完全图(也称简单完全图)
在无向图中,如果任意两个顶点之间都存在边,则称该图为无向完全图。含有 n 个顶点的无向完全图有 n(n-1)/2 条边。在有向图中,如果任意两个顶点之间都存在方向相反的两条弧,则称该图为有向完全图。含有 n 个顶点的有向完全图有 n(n-1) 条有向边。
-
子图
设有两个图 G=(V,E) 和 G’=(V’,E’),若 V‘ 是 V 的子集,且 E’ 是 E 的子集,则称 G‘ 是 G 的子图。若有满足 V(G’)=V(G) 的子图 G’,则为 G 的生成子图。
-
连通、连通图和连通分量
在无向图中,若从顶点 v 到顶点 w 有路径存在,则称 v 和 w 是连通的。若图 G 中任意两个顶点都是连通的,则称图 G 为连通图,否则称为非连通图。无向图中的极大连通子图称为连通分量。如果一个图有 n 个顶点,并且有小于 n-1 条边,则此图必是非连通图。
注意:首先要区分极大连通子图和极小连通子图,极大连通子图是无向图的连通分量,极大要求该连通子图包含其所有的边,极小连通子图是既要保持图连通,又要使得边数最少的子图。
-
强连通图、强连通分量
在有向图中,若从顶点 v 到顶点 w 和从顶点 w 到顶点 v 之间都有路径,则称这两个顶点是强连通的。若图中任何一对顶点都是强连通的,则称此图为强连通图。有向图中的极大强连通子图称为有向图的强连通分量。
-
生成树、生成森林
连通图的生成树是包含图中全部顶点的一个极小连通子图。若图中顶点数为 n,则它的生成树含有 n-1 条边。对于生成树而言,若砍去它的一条边,则会变成非连通图,若加上一条边则会形成一个回路。在非连通图中,连通分量的生成树构成了非连通图的生成森林。
-
顶点的度、入读和出度
图中每个顶点的度定义为以该顶点为一个端点的边的数目。
对于无向图,顶点 v 的度是指依附于该顶点的边的数目,记为 TD(v)。
无向图的全部顶点的度之和等于边数的两倍。
对于有向图,顶点 v 的度分为入度(ID(v))和出度(OD(v))。顶点 v 的度等于其入度和出度之和,即 TD(v) = ID(v) + OD(v)。
2、图的存储及基本操作
2.1、邻接矩阵法
所谓的邻接矩阵,就是用一个一维的数组来存储图中顶点的信息,用一个二维数组来存储图中边的信息(即各顶点之间的邻接关系),存储顶点之间邻接关系的二维数组称为邻接矩阵。
结点数为 n 的图 G = (V,E) 的邻接矩阵 A 是 n × n 的。将 G 的顶点编号为 v1,v2,…, vn。若 (vi,vj) ∈ E,则 A[i][j] = 1,否则 A[i][j] = 0。
A
[
i
]
[
j
]
=
{
0
若
(
v
i
,
v
j
)
或
<
v
i
,
v
j
>
不
是
E
(
G
)
中
的
边
1
若
(
v
i
,
v
j
)
或
<
v
i
,
v
j
>
是
E
(
G
)
中
的
边
A[i][j]=\left\{\begin{array}{l} 0 & 若(v_i,v_j)或<v_i,v_j>不是E(G)中的边 \\ 1 & 若(v_i,v_j)或<v_i,v_j>是E(G)中的边 \end{array}\right.
A[i][j]={01若(vi,vj)或<vi,vj>不是E(G)中的边若(vi,vj)或<vi,vj>是E(G)中的边
对于带权图而言,若顶点 vi 和 vj 之间有边相连,则邻接矩阵中对应项存放着该边对应的权值,若不相连,则用 ∞ 来代表。
A
[
i
]
[
j
]
=
{
∞
若
(
v
i
,
v
j
)
或
<
v
i
,
v
j
>
不
是
E
(
G
)
中
的
边
w
i
j
若
(
v
i
,
v
j
)
或
<
v
i
,
v
j
>
是
E
(
G
)
中
的
边
A[i][j]=\left\{\begin{array}{l} ∞ & 若(v_i,v_j)或<v_i,v_j>不是E(G)中的边 \\ w_{ij} & 若(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)中的边
图的邻接矩阵存储结构如下:
#define MaxVertexNum 100 //顶点数目的最大值
typedef char VertexType; //顶点的数据类型
typedef int EdgeType; //带权图中边上权值的数据类型
typedef struct{
VertexType Vex[MaxVertexNum]; //顶点表
EdgeType Edge[MaxVertexNum][MaxVertexNum]; //邻接矩阵,边表
int vexnum,arcnum; //图中当前顶点数和弧数
}
注意:
- 在简单图中,可直接使用二维数组作为图的邻接矩阵;
- 当邻接矩阵中的元素仅表示相应的边是否存在时,EdgeType 可定义值为 0 和 1 的枚举类型;
- 无向图的邻接矩阵是对阵矩阵,对规模特大的邻接矩阵可采用压缩存储;
- 邻接矩阵表示法的空间复杂度为 O(n2),其中 n 为图的顶点数 |V|。
图的邻接矩阵存储表示法具有以下特点:
- 无向图的邻接矩阵一定是一个对称阵(并且唯一)。因此,在实际存储邻接矩阵时只需要存储上(下)三角矩阵的元素即可;
- 对于无向图,邻接矩阵的第 i 行(或第 i 列)非零元素(或非 ∞ 元素)的个数正好是第 i 个顶点的度 TD(vi);
- 对于有向图,邻接矩阵的第 i 行(或第 i 列)非零元素(或非 ∞ 元素)的个数正好是第 i 个顶点的出度 OD(vi)(或入度 ID(vi));
- 用邻接矩阵法存储图,很容易确定图中任意两个顶点之间是否有边相连。但是,要确定图中有多少条边,则必须按行、列对每个元素进行检测,所花费的时间代价很大;
- 稠密图适合用邻接矩阵存储;
- 设图 G 的邻接矩阵为 A,An 的元素 An[i][j] 等于由顶点 i 到顶点 j 的长度为 n 的路径的数目。
2.2、邻接表法
所谓的邻接表法就是对图 G 中的每个顶点 vi 建立一个单链表,第 i 个单链表中的结点表示依附于顶点 vi 的边(对于有向图则是以顶点 vi 为尾的弧),这个单链表就称为顶点 vi 的边表(对于有向图则称为出边表)。边表的头指针和顶点的数据信息采用顺序存储(称为顶点表),所以在邻接表中存在两种结点:顶点表结点和边表结点,如图:
顶点表结构由顶点域(data)和指向第一条邻接边的指针(firstarc)构成,边表(邻接表)结点由邻接点域(adjvex)和指向下一条邻接边的指针域(nextarc)构成。
无向图和有向图的邻接表实例,如图:
图的邻接表存储结构定义如下:
#define MaxVertexNum 100 //图中顶点数目的最大值
typedef struct ArcNode{ //边表结点
int adjvex; //该弧所指向的顶点的位置
struct ArcNode *next; //指向下一条弧的指针
//InfoType info; //网的边权值
}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|);
- 对于稀疏图,采用邻接表表示将极大地节省存储空间;
- 在邻接表中,给定一顶点,能很容易地找出它的所有邻边,因为只需要读取它的邻接表就可以了。在邻接矩阵中,相同的操作则需要扫描一行,花费的时间为 o(n)。但是若确定给定的两个顶点间是否存在边,则在邻接矩阵里可以立刻查到,在邻接表中则需要在相应结点对应的边表中查找另一结点,效率很低;
- 图的邻接表的表示并不唯一,这是因为在每个顶点对应的单链表中,各边结点的链接次序可以是任意的,取决于建立邻接表的算法以及边的输入次序。
3、图的基本操作
- 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> 不存在,则添加;
- RemoveEdge(G,x,y):如果无向边 (x,y) 或者有向边 <x,y> 存在,则删除;
- FirstNeighbor(G,x):求图 G 中顶点 x 的第一个邻接点,若有则返回顶点号。若 x 没有邻接点或图中不存在 x,则返回 -1;
- NextNeighbor(G,x,y):假设图 G 中顶点 y 是顶点 x 的一个邻接点,若有则返回顶点号。若 x 没有邻接点或图中不存在 x,则返回 -1;
- Get_edge_value(G,x,y):获取图 G 中边 <x,y> 或 (x,y) 对应的权值;
- Set_edge_value(G,x,y):设置图 G 中边 <x,y> 或 (x,y) 对应的权值。
4、图的遍历
4.1、广度优先遍历(BFS)
广度优先遍历(BFS)类似于二叉树的层次遍历,它的基本思想是:首先访问起始顶点 v,接着由 v 出发,依次访问 v 的各个未访问过的邻接顶点 w1,w2,…,wi,然后再依次访问 w1,w2,…,wi 的是所有未被访问过的邻接顶点;再从这些访问过的顶点出发,再访问他们所有未被访问过的顶点…… 直到图中所有顶点都被访问过为止。
广度优先遍历是一种分层的查找过程,每向前走一步可能访问一批顶点,不像深度优先搜索那样有往回退的情况,因为它不是一个递归的算法。为了实现逐步分层,算法必须借助一个辅助队列,以记忆正在访问的顶点的下一层顶点。
广度优先遍历的伪代码如下:
bool visited[MAX_VERTEX_NUM]; //访问标记数组
void BFSTraverse(Graph G){
//对图进行广度优先遍历,设访问函数为 visit()
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
BFS(G,i); //vi 未访问过,从 vi 开始 BFS
}
void BFS(Graph G,int v){
//从顶点 v 出发,广度优先遍历图 G,算法借助一个辅助队列 Q
visit(v); //访问初始顶点 v
visited[v] = true; //对 v 做已访问标记
EnQueue(Q,v); //顶点 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; //标记已访问
EnQueue(Q,w); //顶点 w 入队
}//if
}//while
}
BFS 算法的性能分析:
无论是邻接表还是邻接矩阵,BFS 算法都需要一个辅助队列 Q,n 个顶点均需要入队一次,在最坏情况下,空间复杂度为 O(|V|)。
当采用邻接表存储方式的时候,每个顶点均需要搜索一次(或入队一次),故时间复杂度为 O(|V|),在搜索任一顶点的邻接点时,每条边至少访问一次,故时间复杂度为 O(|E|),算法总的时间复杂度为 O(|V|+|E|)。
采用邻接矩阵时,查找每个顶点的邻接点所需的时间为 O(|V|),故时间复杂度为 O(|V|2)。
4.1.1、BFS 算法求解单源最短路径
void BFS_MIN_Distance(Graph G,int u){
//d[i] 表示从 u 到 i 结点的最短路径
for(i = 0;i < G.vexnum;++i)
d[i] = ∞ //初始化路径长度
visited[u] = true;
d[u] = 0;
EnQueue(Q,u);
while(!isEmpty(Q)){ //BFS 算法主过程
DeQueue(Q,u); //队头元素出队
for(w = FirstNeighbor(G,v);w >= 0; w = NextNeighbor(G,v,w))
if(!visited[w]){ //w 为 v 尚未访问过的邻接顶点
visited[w] = true; //标记已访问
d[w] = d[u] + 1; //路径长度加 1
EnQueue(Q,w); //顶点 w 入队
}//if
}//while
}
4.2、深度优先遍历(DFS)
基本思想:首先访问图中某一起点 v,然后由 v 出发,访问与 v 邻接且未被访问的任一顶点 w1,再访问与 w1 邻接且未被访问的任一顶点 w2,…… 重复上述过程。当不能再继续访问的时候,依次退回到最近被访问的顶点,若它还有邻接点未被访问到,则从该点开始继续上述搜索过程,直到图中所有顶点均被访问过为止。
bool visited[MAX_VERTEX_NUM]; //访问标记数组
void DFSTraverse(Graph G){
//对图 G 进行深度优先遍历,访问函数为 visit()
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
visit(v);
visited[v] = true;
for(w = FirstNeighbor(G,v);w >= 0; w = NextNeighbor(G,v,w))
if(!visited[w]){
DFS(G,w);
}//if
}
DFS 算法性能分析:
DFS 算法是一个递归算法,需要借助一个递归工作栈,故它的空间复杂度为 O(|V|)。
遍历图的过程实质上是一个对每个顶点查找其邻接点的过程,其耗费的时间取决于所采用的存储结构。当以邻接矩阵存储时,查找每个顶点的邻接点所需时间为 O(|V|),故时间复杂度为 O(|V|2)。当以邻接表存储时,查找所有顶点的邻接点所需的时间为 O(|E|),访问顶点所需时间为 O(|V|),此时,总的时间复杂度为 O(|V|+|E|)。
4.3、图的遍历与图的连通性
图的遍历可以用来判断图的连通性。
对于无向图来说,如果无向图是连通的,则从任一结点出发,仅需一次遍历就能够访问图中所有顶点;如果无向图是非连通的,则从某一顶点出发,一次遍历只能访问到该顶点所在连通分量的所有顶点,而对于图中其他连通分量的顶点,则无法通过这次遍历访问。
对于有向图来说,若从初始顶点到图中每个顶点有路径,则能够访问到图中的所有顶点,否则不能访问到所有顶点。
故,对于无向图,调用 BFS(G,i) 或 DFS(G,i) 的次数等于该图的连通分量数。然而,对于有向图,分为强连通和非强连通,非强连通则不能调用一次访问到该连通分量的所有顶点。
5、图的应用
5.1、最小生成树
一个连通图的生成树是图的极小连通子图,它包含图中的所有顶点,并且只含可能少的边。
对于一个带权值的连通无向图,生成树不同,每棵树的权也可能不同。权值最小的生成树称为最小生成树。
5.1.1、Prim 算法
在既有的顶点中找代价最小的边,把边两端的顶点并入集合,直到集合中包含全部顶点。
5.1.2、克鲁斯卡尔算法
已知全部顶点,找代价最小的边,不构成回路。
5.2、最短路径
5.2.1、Dijkstra 算法求单源最短路径
5.2.2、Floyd 算法
应用到上面中的一个结论:
设图 G 的邻接矩阵为 A,An 的元素 An[i][j] 等于由顶点 i 到顶点 j 的长度为 n 的路径的数目。
定义一个 n 阶方阵:A(-1),A(0),…,A(n-1),其中:
A
(
−
1
)
[
i
]
[
j
]
=
a
r
c
s
[
i
]
[
j
]
A
(
k
)
[
i
]
[
j
]
=
M
i
n
{
A
(
k
−
1
)
[
i
]
[
j
]
,
A
(
k
−
1
)
[
i
]
[
k
]
+
A
(
k
−
1
)
[
k
]
[
j
]
}
,
k
=
0
,
1
,
.
.
.
,
n
−
1
A^{(-1)}[i][j]=arcs[i][j] \\ A^{(k)}[i][j]=Min\{A^{(k-1)}[i][j],A^{(k-1)}[i][k]+A^{(k-1)}[k][j]\},k=0,1,...,n-1
A(−1)[i][j]=arcs[i][j]A(k)[i][j]=Min{A(k−1)[i][j],A(k−1)[i][k]+A(k−1)[k][j]},k=0,1,...,n−1
5.3、拓扑排序
前提条件:有向无环图。
bool TopologicalSort(Graph G){
//如果 G 存在拓扑排序,返回 true;否则返回 false,这是 G 中有环
InitStack(S);
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); //入度为 0,则入栈
}//for
}//while
if(count < G.vexnum)
return false; //拓扑排序失败,有回路
else
return true; //拓扑排序成功
}
拓扑排序可用来检测一个有向图中是否有环。
5.4、关键路径
在带权有向图中,以顶点代表事件,有向边代表活动,边上的权值表示完成该活动的开销(如完成活动所需时间),这种有向图为用边表示活动的网络,简称 AOE 网。从源点到汇点的所有路径中,具有最大路径长度的路径称为关键路径。把关键路径上的活动称为关键活动。
几个参数的定义:
-
事件 vk 的最早发生时间 ve(k)
它是指从开始顶点 V 到 Vk 的最长路径长度。事件的最早发生时间决定了所有从 Vk 开始的活动能够开工的最早时间。
v e ( 源 点 ) = 0 v e ( k ) = M a x { v e ( j ) + W e i g h t ( v j , v k ) } , W e i g h t ( v j , v k ) 表 示 < v j , v k > 上 的 权 值 ve(源点)=0 \\ ve(k)=Max\{ve(j)+Weight(v_j,v_k)\},Weight(v_j,v_k) 表示<v_j,v_k>上的权值 ve(源点)=0ve(k)=Max{ve(j)+Weight(vj,vk)},Weight(vj,vk)表示<vj,vk>上的权值
注意:在计算 ve(k) 时,是按从前往后来计算的。 -
事件 vk 的最迟发生时间 vl(k)
它是指在不推迟整个工程完成的前提下,即保证它所指向的事件 vi 在 ve(i) 时刻能够发生时,该事件最迟必须发生的时间。
v l ( 汇 点 ) = v e ( 汇 点 ) v l ( j ) = M i n { v l ( k ) − W e i g h t ( v j , v k ) } vl(汇点)=ve(汇点) \\ vl(j)=Min\{vl(k)-Weight(v_j,v_k)\} vl(汇点)=ve(汇点)vl(j)=Min{vl(k)−Weight(vj,vk)}
注意:在计算 vl(j) 时,是按照从后往前来计算的。 -
活动 ai 的最早开始时间 e(i)
它是指该活动的起点所表示的事件最早发生时间。如果边 <vk,vj> 表示活动 ai,则有 e(i) = ve(k)。
-
活动 ai 的最迟开始时间 l(i)
它是指该活动的终点所表示的事件最迟发生时间与该活动所需时间之差。如果边 <vk,vj> 表示活动 ai,则有 l(i) = vl(j) - Weight(vk,vj)。
-
一个活动 ai 的最迟开始时间 l(i) 和其最早开始时间 e(i) 的差额 d(i) = l(i) - e(i)
它是指该活动完成的时间余量,是在不增加完成整个工程所需的总时间的情况下,活动 ai 可以拖延的时间。如果一个活动的时间余量为零时,说明该活动必须要如期完成,否则就会拖延完成整个工程的进度。
求解步骤如下:
- 求 AOE 网中所有事件的最早发生时间 ve();
- 求 AOE 网中所有事件的最迟发生时间 vl();
- 求 AOE 网中所有活动的最早开始时间 e();
- 求 AOE 网中所有活动的最迟开始时间 l();
- 求 AOE 网中所有活动的差额 d(),找出所有 d() = 0 的活动构成关键路径。
对于关键路径,我们需要注意:
- 关键路径上的所有活动都是关键活动,它是决定整个工程的关键因素,因此可通过加快关键活动来缩短整个工程的工期。但也不能任意缩短关键活动,因为一旦缩短到一定的程度,该关键活动可能变成非关键活动。
- 网中的关键路径并不唯一。且对于有几条关键路径的网,只提高一条关键路径上的关键活动速度并不能缩短整个工程的工期,只有加快那些包括所有关键路径上的关键活动才能达到缩短工期的目的。