文章目录
- 6.1 图的定义及性质
-
- 6.2 图的存储--邻接矩阵法
-
- 6.3 图的存储--邻接表法
-
- 6.4 十字链表和邻接多重表
-
- 6.5 图的常见基本算法
- 6.5.1 Adjacent(G,x,y)
- 6.5.2 Neighbors(G,x)
- 6.5.3 InsertVertex(G,x)
- 6.5.4 DeleteVertex(G,x)
- 6.5.5 AddEdge(G,x,y)
- 6.5.6 FirstNeighbor(G,x)
- 6.5.7 NextNeighbor(G,x,y)
- 6.6 图的广度优先遍历BFS
-
- 6.7 图的深度优先遍历DFS
-
- 6.8 最小生成树MST
-
- 6.9 最短路径
-
- 6.10 有向无环图
- 6.11 拓扑排序
-
- 6.13 关键路径
-
6.1 图的定义及性质
图G由顶点集v和边集E组成,记为G=(V,E)。
-
V(G)表示图G中顶点的有限非空集。–vertex
V
=
{
v
1
,
v
s
,
.
.
.
,
v
n
}
∣
V
∣
表示图
G
中顶点的个数,也称图
G
的阶
V=\{ v_1, v_s,..., v_n\}\\ |V|表示图G中顶点的个数,也称图G的阶
V={v1?,vs?,...,vn?}∣V∣表示图G中顶点的个数,也称图G的阶 -
E(G)表示图G中顶点之间的关系(边)集合。–edge
E
=
{
(
u
,
v
)
,
l
∈
V
,
v
∈
V
}
∣
E
∣
表示图
G
中边数
E= \{(u, v), l\in V, v\in V\}\\ |E|表示图G中边数
E={(u,v),l∈V,v∈V}∣E∣表示图G中边数
注意:线性表可以是空表,树可以是空树,但图不能是空图。也就是V一定是非空集。(E可以是空集)
6.1.1 无向图和有向图
-
无向图 若边集E是无向边(简称边)有限集合时,则图G为无向图。 边是顶点的无序对,对应使用圆括号。记为 (v, w) 或 (w, v) ,因为 (v, w) = (w, v) 。 用公共边的顶点互为邻接点,如对于边 (v,w) ,那么v和w互为邻接点。边 (v, w) 依附于顶点w和v,或者说边 (v, w) 和顶点v、w相关联。
G
2
=
(
V
2
,
E
2
)
V
2
=
{
A
,
B
,
C
,
D
,
E
}
E
2
=
{
(
A
,
B
)
,
(
B
,
D
)
,
(
B
,
E
)
,
(
C
,
D
)
,
(
C
,
E
)
,
(
D
,
E
)
}
G_2=(V_2,E_2)\\ V_2= \{A,B,C,D,E\}\\ E_2=\{ (A,B),(B,D),(B,E),(C,D),(C,E),(D,E)\}
G2?=(V2?,E2?)V2?={A,B,C,D,E}E2?={(A,B),(B,D),(B,E),(C,D),(C,E),(D,E)} -
有向图 若边集E是有向边(也称弧)的有限集合时,则图G为有向图。 弧是顶点的有序对,对应使用尖括号。记为 <v, w> ,注意:<v, w>≠<w,v> 。 其中v、w是顶点,v称为弧尾,w称为弧头。<v, w>称为从顶点v到顶点w的弧,也称v邻接到w,或w邻接自v。
G
1
=
(
V
1
,
E
1
)
V
1
=
{
A
,
B
,
C
,
D
,
E
}
E
1
=
{
<
A
,
B
>
,
<
A
,
C
>
,
<
A
,
D
>
,
<
A
,
E
>
,
<
B
,
A
>
,
<
B
,
C
>
,
<
B
,
E
>
,
<
C
,
D
>
}
G_1=(V_1, E_1)\\ V_1= \{A,B,C,D,E\}\\ E_1= \{<A,B>,<A,C>,<A,D>,<A,E>,<B,A>,<B,C>,<B,E>,<C, D>\}
G1?=(V1?,E1?)V1?={A,B,C,D,E}E1?={<A,B>,<A,C>,<A,D>,<A,E>,<B,A>,<B,C>,<B,E>,<C,D>}
6.1.2 简单图和多重图
-
简单图
①不存在重复边(有向图会考虑方向);②不存在顶点到自身的边
数据结构课程讨论的是简单图(简单无向图和简单有向图)
-
多重图
①存在重复边(有向图也会考虑方向);②不存在顶点到自身的边
简单图和多重图也分为无向图和有向图
6.1.3 图的相关概念
6.1.3.1 顶点的度
-
无向图:
-
有向图:
-
入度是以顶点v为终点的有向边的数目,记为ID(v); -
出度是以顶点v为起点的有向边的数目,记为OD(v)。 -
顶点v的度等于其入度和出度之和,即TD(v)= ID(v)+ OD(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∑n?ID(vi?)=i=1∑n?OD(vi?)=e
6.1.3.2 顶点和顶点的关系
-
路径――顶点v到顶点w,之间的一条路径是指顶点序列v,a,l,k,d,j,f,o,i,u,w
无向图中指的是从v沿着边到达w经过的顶点序列,有向图中指的是从v沿着弧到达w经过的顶点序列
-
回路——第一个顶点和最后一个顶点相同的路径称为回路或环 -
简单路径――在路径序列中,顶点不重复出现的路径称为简单路径。 -
简单回路――除第一个顶点和最后一个顶点外,其余顶点不重复出现的回路称为简单回路。 -
路径长度―—路径上边的数目 -
点到点的距离――点到点之间最短路径的长度称为点到点的距离。
若所求两点之间根本不存在路径,则记该距离为无穷∞
此外注意点到点距离,在无向图中交换点的顺序,距离不变。有向图中交换点顺序,距离可能发生变化!
-
无向图中,若从顶点v到顶点w有路径存在,则称v和w是连通的 -
有向图中,若从顶点v到顶点w和从顶点w到顶点v之间都有路径,则称这两个顶点是强连通的 -
对于无向图,若任意两个顶点都是连通的,则称图G为连通图,否则称为非连通图。 -
对于有向图,若任何一对顶点都是强连通的,则称此图为强连通图。
无向图考虑连通性,有向图考虑强连通性。所以说到连通图就是无向图,强连通图就是有向图
-
n个顶点的无向图
若G为连通图,则最少有n-1条边(设其中一个点为连通图,其余n-1个点每次加入之前的连通图都需要加上一条边,所以最少n-1条边)
若G为非连通图,则最多可能有Cn-12条边。(选一个点孤立,其余n-1个点两两连通)
-
n个顶点的有向图
若G为强连通图,则最少有n条边(形成回路)。
6.1.3.3 子图
-
无向图 设有两个图 G= (V,E) 和 G'= (V',E') ,若 V' 是 V 的子集,且 E' 是 E 的子集,则称 G '是 G 的子图。(注意图,中边一定是依附于顶点的)
若 V'(G) = V(G) ,子图顶点集和原图顶点集相同,这样的子图叫左生成子图。 (也可以看成原图去掉某些边既可得到生成子图)
-
有向图 设有两个图 G= (V,E) 和 G'= (V',E') ,若 V' 是 V 的子集,且 E' 是 E 的子集,则称 G '是 G 的子图。(注意图,中边一定是依附于顶点的)
若 V'(G) = V(G) ,子图顶点集和原图顶点集相同,这样的子图叫左生成子图。 (也可以看成原图去掉某些边既可得到生成子图)
6.1.3.4 连通分量
无向图的极大连通子图称为连通分量(所谓极大连通子图就是:子图必须连通,且包含尽可能多的顶点和边)
6.1.3.5 强连通分量
有向图中的极大强连通子图称为有向图的强连通分量(所谓极大强连通子图就是:子图必须强连通,且包含尽可能多的顶点和边)
6.1.3.6 生成树
(无向)连通图的生成树是包含图中全部顶点的一个极小连通子图(边尽可能的少,但保持连通)
若图中顶点数为n,则它的生成树含有n-1条边。
(n个节点(无向)连通图最少含有n-1条边)
对生成树而言,若砍去它的一条边,则会变成非连通图,若加上一条边则会形成一个回路)
6.1.3.7 生成森林
在非连通图中,连通分量的生成树构成了非连通图的生成森林。
无向图的极大连通子图称为连通分量
6.1.3.8 边的权、带权图/网
- 边的权―一在一个图中,每条边都可以标上具有某种含义的数值,该数值称为该边的权值。
- 带权图/网――边上带有权值的图称为带权图,也称网。
- 带权路径长度――带权图一条路径上所有边的权值之和,称为该路径的带权路径长度
6.1.3.9 几种特殊的图
-
无向完全图——无无向图中任意两个顶点之间都存在边
若无向图的顶点数
∣
v
∣
=
n
,则
∣
E
∣
∈
[
0
,
C
n
2
]
=
[
0
,
n
(
n
?
1
)
2
]
若无向图的顶点数|v|=n,则|E| ∈ [0,C_n^2]=[0,\frac{n(n-1)}{2}]
若无向图的顶点数∣v∣=n,则∣E∣∈[0,Cn2?]=[0,2n(n?1)?] -
有向完全图——有向图中任意两个顶点之间都存在方向相反的两条弧
若有向图的顶点数
∣
v
∣
=
n
,则
∣
E
∣
∈
[
0
,
C
n
2
]
=
[
0
,
n
(
n
?
1
)
]
若有向图的顶点数|v|=n,则|E| ∈ [0,C_n^2]=[0,n(n-1)]
若有向图的顶点数∣v∣=n,则∣E∣∈[0,Cn2?]=[0,n(n?1)] -
稀疏图——边数很少的图称为稀疏图 -
稠密图——边多
具体边多变少,没有绝对界限,可以参考
一般来说
∣
E
∣
<
∣
V
∣
l
o
g
∣
V
∣
时,可以将
G
视为稀疏图
一般来说|E|< |V|log|V|时,可以将G视为稀疏图
一般来说∣E∣<∣V∣log∣V∣时,可以将G视为稀疏图
-
树——不存在回路,且连通的无向图 n个顶点的图,若边数大于n-1,则一定有回路 -
有向树——一个顶点的入度为0、其余顶点的入度均为1的有向图,称为有向树。
6.2 图的存储–邻接矩阵法
邻接矩阵是表示顶点之间相邻关系的矩阵(方阵)。
- 使用一维数组存储顶点集,数组元素存储顶点数据,相应的下标为顶点的编号。
- 使用二维数组存储模拟矩阵的存储,表示边集。相应位置表示边的有无(不带权图)或者边的权值(带权图)
邻接矩阵(Adjacency Matrix)*/*??d?e?s?nsi/ /?me?tr?ks/
对应图的定义为:
#define MAX_VERTEX_NUM 100
typedef char VertexType;
typedef bool EdgeType;
struct AMGraph {
VertexType Vertex[MAX_VERTEX_NUM];
EdgeType Edge[MAX_VERTEX_NUM][MAX_VERTEX_NUM];
int vexnum, edgenum;
};
边集是对应矩阵中的零一序列,可以用int类型存储,但bool占用字节更小。二维数组模拟矩阵,含义如下:
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]= \begin{cases} 1,若(v_i,v_j)或<v_i,v_j>\in E(G) \\ 0,若(v_i,v_j)或<v_i,v_j>\not\in E(G)\\ \end{cases}
A[i][j]={1,若(vi?,vj?)或<vi?,vj?>∈E(G)0,若(vi?,vj?)或<vi?,vj?>∈E(G)?
6.2.1 邻接矩阵的创建
邻接矩阵表示法创建无向图【算法步骤】
- 输人总顶点数和总边数。
- 依次输人点的信息并将其存人顶点表中。
- 初始化邻接矩阵,使每个权值初始化为极大值。
- 构造邻接矩阵。依次输人每条边依附的项点和其权值,确定两个顶点在图中的位置之后,使相应边赋予相应的权值,同时使其对称边赋予相同的权值。
这里给出Java实现的邻接矩阵的创建
package Graph.adjacency_Matrix;
import java.util.Scanner;
public class AMGraph {
static private Scanner scan = new Scanner(System.in);
private int vertexNum, edgeNum;
private char[] vertex = new char[20];
private int[][] edge = new int[20][20];
void createGraph() {
while (vertexNum == 0 || vertexNum > 20) {
System.out.print("输入顶点数: ");
vertexNum = scan.nextInt();
if (vertexNum > 20) System.out.println("顶点数目不得超过20,重新输入");
}
System.out.print("输入边数: ");
edgeNum = scan.nextInt();
System.out.println("请依次输入顶点集----");
for (int i = 0; i < vertexNum; i++) {
vertex[i] = scan.next().charAt(0);
}
int x = 0, y = 0;
int w = 0;
for (int i = 0; i < edgeNum; i++) {
System.out.println("输入第" + (i + 1) + "条边(顶点形式)--");
x = scan.nextInt();
y = scan.nextInt();
System.out.println("输入边权:");
w = scan.nextInt();
edge[x][y] = edge[y][x] = w;
}
}
void printMatrix() {
System.out.print(" ");
for (int i = 0; i < vertexNum; i++) {
System.out.print(vertex[i] + " ");
}
System.out.println();
for (int i = 0; i < vertexNum; i++) {
System.out.print(vertex[i] + " ");
for (int j = 0; j < vertexNum; j++) {
System.out.print(edge[i][j] + " ");
}
System.out.println();
}
}
}
调用createGraph和printMatrix两个方法,相应的测试图例:
6.2.2 带权图的邻接矩阵
在带权图中,邻接矩阵中存储的是边的权值,若点之间没有邻接关系,则设置为无穷。
但在有些带权图的邻接矩阵中会将自己指向自己的边权值设置为0,所以这类图,矩阵数值为0或无穷说明对应两个顶点不邻接
参考带权图邻接矩阵定义方式(和不带权图是一样的,只是举证存储的是边的权值)
#define MAX_VERTEX_NUM 100
#define MAX INT_MAX
typedef char VertexType;
typedef int EdgeType;
struct AMGraph {
VertexType Vertex[MAX_VERTEX_NUM];
EdgeType Edge[MAX_VERTEX_NUM][MAX_VERTEX_NUM];
int vexnum, edgenum;
};
6.2.3 邻接矩阵性能分析
对于n个节点的图,顶点集空间复杂度为 O(N),边集空间复杂度为 O(N2)。所以邻接矩阵的空间复杂度为 ==O(N2) ==。对于稀疏图,边集元素少,邻接矩阵中存储的有效元素少。所以邻接矩阵适合存储稠密图。
对于无向图,由于无向图的邻接矩阵关于主对角线对称,所以可以使用矩阵的压缩存储(用一维数组只存储上三角区/下三角区)
6.2.4 邻接矩阵性质
性质一
设图G的邻接矩阵为A(矩阵元素为0/1),则 An 的元素A[i][j] 等于由顶点 i 到顶点 j 的长度为n的路径的数目。
6.3 图的存储–邻接表法
邻接表法就是顺序存储+链式存储。(有些类似数的存储)
期间使用到三个结构体,顶点结构体,边/弧结构体,邻接表结构体
- 顶点,包含存储顶点信息的数据域和指向邻接点链表的指针域。
- 邻接点链表,包含邻接点在数组中对应下标的数据域和下一个邻接点的数据域
- 邻接表,用数组存储每一个顶点,记录当前顶点数和边数
邻接表定义的C++描述
#define MAX_VERTEX_NUM 100
typedef bool EdgeType;
typedef char VertexType;
struct VNode {
VertexType data;
ArcNode* first;
};
struct ArcNode {
int adjvex;
ArcNode* next;
};
struct ALGraph {
VNode vertices[MAX_VERTEX_NUM];
int vexnum, edgenum;
};
邻接表法描述:先将图中每一个顶点抽象成顶点结构体,每一个顶点结构体包含顶点信息和邻接点链表。最后用一维数组存储这些顶点结构体。
不同于邻接矩阵,邻接矩阵只要确定顶点编号,其对应的邻接矩阵是唯一的。而邻接表法中,由于邻接点链表中邻接点顺序无要求。所以同一个图,其邻接表表示是不唯一的。如下:
6.3.1 无向图和有向图的邻接表
-
空间复杂度
对于无向图,每一条边对应邻接表中两个边节点(邻接点链表中每一个节点,也能看成一条边),所以邻接表法存储的无向图对应空间复杂度为 O(|V|+2|E|) 。
对于有向图,每一条边对应邻接表中一个边节点(该边节点对应弧头),所以邻接表法存储的有向图对应的空间复杂度为 O(|V|+|E|)
-
邻接表求度,出度,入读
求无向图节点的度–只需要求数组对应节点,其邻接点链表中节点个数即可
求有向图节点的出度–只需要求数组对应节点,其邻接点链表中节点个数即可
求有向图节点的入读–遍历整个数组的左右邻接点链表,有该节点则入读加一
6.3.2 邻接表和邻接矩阵
邻接矩阵适合存储稠密图,对于稀疏图存在浪费空间问题。其空间复杂度为 |V|2 。邻接表法解决空间复杂度的问题,但其存储有向图时寻找入度不太方便
6.4.3 邻接表的创建
这里给出Java实现无向图的邻接表的样例(只有基本的功能)
【算法步骤】
- 输人总顶点数和总边数。
- 依次输人点的信息存人顶点表中,使每个表头节点的指针域初始化为
NULL。 - 创建邻接表。依次输人每条边依附的两个顶点,确定这两个顶点的序
号i和j之后,将此边节点分别插人v和v,对应的两个边链表的头部。
package Graph.adjacency_list;
import java.util.Scanner;
/**
* @ClassName ALGraph
* @Description TODO
* @Author 林卓为
* @Date 2022-2022/12/4 21:52
**/
class VNode {
char data;//存储顶点数据
ArcNode firstArc;//第一条邻接边,实际存储的是邻接点,两个邻接点表示一条边
}
class ArcNode {
int index;//存储顶点编号(顶点存储在顶点集合数组对应下标即为编号)
ArcNode nextArc;//下一个邻接点,实际存储的是邻接点,两个邻接点表示一条边
}
public class ALGraph {
static private Scanner scanner = new Scanner(System.in);
private int vertexNum;//当前有效顶点个数
private int arcNum;//当前边的有效顶点个数
private VNode[] vertices = new VNode[20];//顶点集,预设值20个顶点
public void createALGraph() {
while (vertexNum == 0 || vertexNum > 20) {
System.out.print("输入顶点个数: ");
vertexNum = scanner.nextInt();
if (vertexNum > 20) System.out.println("图顶点数容量为20,重新输入");
}
System.out.print("输入边数: ");
arcNum = scanner.nextInt();
//输入顶点
System.out.println("依次输入顶点--");
for (int i = 0; i < vertexNum; i++) {
vertices[i] = new VNode();
vertices[i].data = scanner.next().charAt(0);
}
//输入边集
int x = 0, y = 0;//用于表示邻接点
for (int i = 0; i < arcNum; i++) {
System.out.println("输入第" + (i + 1) + "对邻接点");
x = scanner.nextInt();
y = scanner.nextInt();
//注意无向图的临界表边节点是冗余的!!!
ArcNode p1 = new ArcNode();
p1.index = x;
ArcNode p2 = new ArcNode();
p2.index = y;
//无向图冗余边两个,头插
p2.nextArc = vertices[x].firstArc;
vertices[x].firstArc = p2;
p1.nextArc = vertices[y].firstArc;
vertices[y].firstArc = p1;
}
}
void printGraph() {
for (int i = 0; i < vertexNum; i++) {
System.out.print(vertices[i].data + "-->");
ArcNode cur = vertices[i].firstArc;
while (cur != null) {
System.out.print(cur.index + " ");
cur = cur.nextArc;
}
System.out.println();
}
}
}
6.4 十字链表和邻接多重表
6.4.1 有向图-十字链表法
注意十字链表发只能用于存储有向图
十字链表法使用到两种类型的节点,顶点节点和弧节点。
十字链表发存储简述:顶点节点表示每一个顶点,弧节点表示每一个每一条弧,弧中存储着弧的相邻节点表示该弧。然后每一个顶点链接到弧上,弧节点用指针也串联这图顶点之间的关系。
十字链表发找出边和入边都很方便,先说结论:
- 如何找到指定顶点的所有出边?――顺着绿色线路找
- 如何找到指定顶点的所有入边?——顺着橙色线路找
以一个顶点起始,沿着弧尾相同的下一个弧,这个链表就能找到以当前节点为弧尾的所有弧(可以这么理解,该链表都是公用一个弧尾,而这个弧尾就是顶点。在图中就是沿着绿色向下看)
相同的,以一个顶点起始,沿着弧头相同的下一个弧,这个链表就能找到以当前节点为弧头的所有弧(可以这么理解,该链表都是公用一个弧头,而这个弧头就是顶点。在图中就是沿着橙色向下看)
十字链表发空间复杂度是 O(|V|+|E|) 。(和邻接表发相同)
6.4.2 无向图-邻接多重表
邻接表法存储无向图,每条边对应两份冗余信息,删除顶点,删除边等操作时间复杂度高。而邻接矩阵存储的空间复杂度高为 O(N) 。对此我们可以使用邻接多重表
邻接多重表和十字链表法类似的。
邻接多重表法存储无向图用到两种节点
- 顶点节点:存储数据+与该节点相连的一条边
- 边节点:存储边的两个邻接点 i 和 j +(权值)+依附于i节点的下一条边+依附于 j 节点的下一条边。
邻接多重表删除操作思路清晰的(照着图的物理模型思考)
- 删除边,就处理好边两头的邻接点。
- 删除节点就要处理好和该点相连的边,对应这些边的节点也要处理好。
这里可以分为删除边节点和顶点来分开思考(删边和删点往往是一起进行的,删了边往往会影响指向这条边的顶点)
邻接多重表的空间复杂度为 O(|V|+2|E|) ,空间复杂度比邻接表(同一边冗余)和邻接矩阵(N2)都要好
6.5 图的常见基本算法
只探讨邻接矩阵和邻接表法的基本操作
下面探讨几个重要的算法,其余的a简单自己想ba
6.5.1 Adjacent(G,x,y)
判断图G中是否存在边 <x,y> 或 (x,y) ,实际上就是判断两个点是否邻接(Adjacent)。
对于邻接矩阵法,找到邻接矩阵边或弧对应位置判断即可。(随机存取,时间复杂度为O(1) ,邻接矩阵算法更优)
对于邻接表法,遍历对应边节点(弧节点,时间复杂度为O(1)~O(|V|) )。
这里给出无向图和有向图的邻接矩阵和邻接表:
6.5.2 Neighbors(G,x)
列出图G中节点x邻接的边(也就是列出x的邻接点)
对于邻接矩阵法存储的图,无向图遍历其矩阵对应的行或列。有向图遍历行即为出弧,遍历列即为入弧。(时间复杂度O(|V|) )
对于邻接表法存储的图,无向图直接遍历顶点对应的边节点链表即可。有向图顶点对应弧节点链表即为以该节点为弧尾的弧,遍历其余弧节点链表就是以该节点为弧头对应的弧(无向图邻接表时间复杂度O(1)~O(|V|) ,有向图邻接表对应时间复杂度为O(|E|) )
这里给出无向图和有向图的邻接矩阵和邻接表:
6.5.3 InsertVertex(G,x)
图G插入节点x
邻接矩阵就是矩阵加入新的行和列,时间复杂度是O(1)
邻接表法就是再数组尾部插入对应节点即可,时间复杂度是O(1)
有向图和无向图操作一致
6.5.4 DeleteVertex(G,x)
删除图G中顶点x
邻接矩阵存储的图,可以直接删除对应行对应列,但需要移动矩阵中大量的元素,不科学。我们可以将对应行对应列矩阵值设置为0,数组顶点加上bool型标志,表示其是否为空节点(有效节点),这种方法时间复杂度为O(|V|)
邻接表存储的图,对于无向图,删除顶点和对应的边表,并且遍历其余边表,找到对应顶点一并删除(因为邻接表存储的图,边是冗余的,也就是存了两份)。对于有向图,删除顶点和对应的弧表(出边),遍历其余弧表,删除该顶点对应入弧。总得来说,都得遍历边一次(除非该点是孤立点)。时间复杂度为 O(1)~O(|E|) 。
6.5.5 AddEdge(G,x,y)
若无向边(x,y)或有向边不存在,则向有向图中添加该边。
邻接矩阵,直接改变举证对应位置值即可(无向图改两个值,有向图改一个值)。(时间复杂度O(1) )
邻接表法,无向图需要修改边两头两个邻接点对应的边表,可以头插也可以尾插。有向图需要修改弧尾对应的弧表即可。(时间复杂度为O(1) 头插为例)
6.5.6 FirstNeighbor(G,x)
求图G中顶点x的第一个邻接点,若有返回顶点号,若x没有邻接点或图中不存在x,则返回-1。
邻接矩阵存储的图,无向图,按行(或列)从前往后遍历。有向图,按行找出边按列找入边。对应时间复杂度O(1)~O(|V|)
邻接表存储的图,无向图,按对应顶点的边表从前向后遍历即可。有向图按应顶点的边表从前向后遍历找出边,遍历其余边表找入边。无向图找第一个邻接点和有向图找出边对应的时间复杂度为O(1) 。有向图找入边时间复杂度O(|V|)~O(|E|) (下图我觉得是错了的)
6.5.7 NextNeighbor(G,x,y)
假设图G中顶点y是顶点x的一个邻接点**,返回除y之外顶点x的下一个邻接点的顶点号**,若y是x的最后一个邻接点,则返回-1。
邻接矩阵存储的表中,在x对应行进行遍历,第y列的下一个为1的边对应即为所求。
邻接表中,在数组中找到x顶点的边链表,其中y节点下一个接待你即为所求
6.6 图的广度优先遍历BFS
广度优先搜索遍历的过程如下。
- 从图中某个顶点v出发,访问v。
- 依次访问v的各个未曾访问过的邻接点。
- 分别从这些邻接点出发依次访问它们的邻接点,并使“先被访问的顶点的邻接点”先于“后被访问的顶点的邻接点”被访问。重复步骤2,直至图中所有已被访问的顶点的邻接点都被访问到。
【算法步骤】
-
从图中某个顶点v出发,访问v,并置visited[v]的值为true,然后使v入队。 -
只要队列不空,则重复下述操作:
bool visited [MAX_VERTEX_NUM] ;
void BFS(Graph G,int v){
visit(v);
visited [v]=true;
Enqueue(Q,v);
while(!isEmpty(Q)){
DeQueue(Q,v);
for(w=FirstNeighbor(G,v);w>=0;w=NextNeighbor(G,v,w))
if(!visited[w]){
visit(w);
visited [w]=true;
EnQueue(Q,W) ;
}
}
}
一个图的BFS是不唯一的,考虑因素有广度优先遍历BFS起点就是不唯一的。其次采用图的不同存储形式或图存储形式表示方式不唯一(同一个图邻接表有多种表现形式)。对于图的邻接矩阵顶点编号确定,其图的邻接矩阵是唯一的,所以往往认为图的邻接矩阵唯一
- 同一个图的邻接矩阵表示方式唯一 ,因此广度优先遍历序列唯一
- 同一个图邻接表表示方式不唯一,因此广度优先遍历序列不唯一
上述算法是连通图的广度优先遍历,或者说是连通分支的广度优先遍历,无法实现对非连通图进行广度优先遍历。非连通图含有多个连通分支,我们分别对这些连通分支采用广度优先遍历,就能实现对非连通图的广度优先遍历。visited数组记录顶点是否访问,所以我么可以用一个循环反复遍历visited数组来获得未被访问的连通分支,调用BFS函数遍历连通分支,直到所有顶点均被访问过。
具体实现代码如下
bool visited [MAX_VERTEX_NUM] ;
void BFSTraverse(Graph G){
for(i=0;i<G.vexnum;++i)
visited[i]=false;
InitQueue(Q);
for( i=0; i<G.vexnum;++i)
if(!visited [i])
BFS(G,i);
}
void BFS(Graph G,int v){
visit(v);
visited [v]=TRUE;
Enqueue(Q,v);
while(!isEmpty(Q)){
DeQueue(Q,v);
for(w=FirstNeighbor(G,v);w>=0;w=NextNeighbor(G,v,w))
if(!visited[w]){
visit(W);
visited [w]=TRUE;
EnQueue(Q,W) ;
}
}
}
上面是无向图的广度优先遍历,对于有向图的BFS需要使用的是非连通无向图的BFS,修改firstNeighbor和nextNeighbor函数即可
6.6.1 广度优先遍历的时间复杂度
广度优先遍历算法时间复杂度来自顶点访问和邻接点查找
邻接矩阵存储的图:
访问|V|个顶点需要O( |V| )的时间 查找每个顶点的邻接点都需要O( |V| )的时间,而总共有|M|个顶点 时间复杂度= O( |V|2 )–(|V|2+|V|)
邻接表存储的图:
访问IM个顶点需要0( |V| )的时间 查找各个顶点的邻接点共需要0( |E| )的时间,实际上是O( 2*|E| )–冗余 时间复杂度= O( |V|+|E| )
6.6.2 广度优先生成树
连通图对应广度优生成树,非连通图对应广度优先生成森林
对连通图图按照广度优先遍历,每一个顶点的所有邻接点作为其孩子,就形成了广度优先生成树。
类比,同一个图,广度优先遍历可以不同,那么其广度优先生成树也是可以不同的!
如下,同一个图,不同邻接表存储,其广度优先生成树也是不一样的
相应的,对非连通图图按照广度优先遍历,就形成了广度优先生成森林。
6.7 图的深度优先遍历DFS
深度优先搜索(Depth First Search,DFS)历类似于树的先序遍历,是树的先序遍历的推广对于一个连通图,深度优先搜索遍历的过程如下
- 从图中某个顶点v出发,访问v。
- 找出刚访问过的顶点的第一个未被访问的邻接点,访问该顶点。以该顶点为新顶点重复此步骤,直至刚访问过的顶点没有未被访问的邻接点为止。
- 返回前一个访问过的且仍有未被访问的邻接点的顶点,找出该顶点的下一个未被访问的邻接点,访问该顶点。
- 重复步骤(2)和步骤 (3),直至图中所有顶点都被访问过,搜索结束
算法实现
bool visited [MAX_ _VERTEX_ _NUM];
void DFS(Graph G,int v){
visit(v);
visited[v]=true;
for (w=FirstNeighbor(G,v);W>=0;w=NextNeighor(G,v,w))
if(!visited[w])
DFS(G,w);
}
可以看到上述算法是连通图的深度优先算法,类似的我们只需要增加循环获取不同的连通分支,对不同连通分支实现DFS,就能实现非连通图的深度优先遍历。如下:
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)
if( !visited[v])
DFS(G,v);
}
void DFS(Graph G,int v){
visit(v);
visited[v]=true;
for (w=FirstNeighbor(G,v);W>=0;w=NextNeighor(G,v,w))
if(!visited[w])
DFS(G,w);
}
和广度优先遍历一样,一个图的DFS是不唯一的,考虑因素有深度优先遍历DFS起点就是不唯一的。其次采用图的不同存储形式或图存储形式表示方式不唯一(同一个图邻接表有多种表现形式)。对于图的邻接矩阵顶点编号确定,其图的邻接矩阵是唯一的,所以往往认为图的邻接矩阵唯一
- 同一个图的邻接矩阵表示方式唯一 ,因此深度优先遍历序列唯一
- 同一个图邻接表表示方式不唯一,因此深度优先遍历序列不唯一
6.7.1 深度优先遍历复杂度分析
空间复杂度:来自函数递归调用栈
时间复杂度分析:时间复杂度=访问各结点所需时间+探索各条边所需时间
-
邻接矩阵存储的图:
访问 |V| 个顶点需要O(V)的时间,查找每个顶点的邻接点都需要O(V)的时间,而总共有 |V| 个顶点 时间复杂度= O( |V|2 )
-
邻接表存储的图
访问|M|个顶点需要O( |V| )的时间,查找各个顶点的邻接点共需要O( |E| )的时间, 时间复杂度= O( |V|+|E| )
6.7.2 深度优先生成树
深度优先遍历中递归栈中每一层对应深度优先生成树中的每一层。
- 同一个图的邻接矩阵表示方式唯一,因此深度优先遍历序列唯-,深度优先生成树也唯一
- 同一个图邻接表表示方式不唯- -,因此深度优先遍历序列不唯一, 深度优先生成树也不唯一
相应的非连通图的深度优先遍历对应的就是深度优先生成森林
6.7.3 图遍历和连通性
6.8 最小生成树MST
连通图的生成树是包含图中全部顶点的一个极小连通子图,同一个图有不同的生成树,其边权之和最小对应的生成树就是最小生成树,最小生成树可以有多个,但边权之和最小肯定是只有一个的(最字懂吧)
带权连通图,边权之和最小的生成树称为最小生成树
- 最小生成树可能有多个,但边的权值之和总是唯一且最小的
- 最小生成树的边数=顶点数- 1。砍掉一条则不连通,增加一条边则会出现回路
- 如果一个连通图本身就是一棵树,则其最小生成树就是它本身
- 只有连通图才有生成树,非连通图只有生成森林
6.8.1 Prim算法-普里姆算法
Prim算法(普里姆)描述:
从某一个顶点开始构建生成树;每次将代价最小的新顶点纳入生成树,直到所有顶点都纳入为止。(选点,实际上还是选边)下面以v0为起点开始构造最小生成树
Prim算法实现
Prim使用到两个数组,一个数组记录各个节点是否加入树,另一个数组用于记录各个节点加入当前最小生成树的最低代价。如上图以V0为起点构造最小生成树,和V0非相邻的点数组对应值为无穷,其余的记录相应权值即可。
实现过程如下
注意:每一次选完点都需要更新lowCost数组的值,计算各个未加入生成树的顶点和刚刚加入生成树的顶点的边权,比较边权较lowCost数组是否有减小
时间复杂度为:O( |V|2 )适合稠密图。
6.8.2 Kruskal算法-克鲁斯卡尔算法
Kruskal算法(克鲁斯卡尔) 描述:
每次选择一条权值最小的边,使这条边的两头连通(原本已经连通的就不选)直到所有结点都连通(选边)
Kruakal算法实现
算法中判断顶点是否同属于一个集合,使用到并查集的内容
时间复杂度为:O( |E|log2|E| )适合稀疏图
6.9 最短路径
6.9.1 BFS求无权图的单源最短路径
无权图可以视为每条边带权为1的特殊的带权图
利用广度优先遍历求最短路径就是–就是对BFS的小修改,在visit个顶点时,修改其最短路径长度数组d[]并在数组path[]记录该节点的最短路径的直接前驱结点
void BFS_MIN_Distance(GraphMatrix G, int u, int path[], int d[], bool visited[]) {
for (int i = 0; i < G.verNum; ++i) {
d[i] = INT_MAX;
path[i] = -1;
}
d[u] = 0;
visited[u] = true;
queue<int>Q;
Q.push(u);
while (!Q.empty()) {
u = Q.front();
Q.pop();
for (int w = firstNeighbor(G, u); w >= 0; w = nextNeighbor(G, u, w))
if (!visited[w]) {
d[w] = d[u] + 1;
path[w] = u;
visited[w] = true;
Q.push(w);
}
}
}
下面给出BFS求无权图最短路径的例子,这里求的是2号顶点到各个顶点的最短距离。友友们可以试着写出两个数组的变化过程
我么可以根据d数组求最短路径长度,根据path数组求该最短路径(就是求路径顶点数)。如:
2到8的最短路径长度: d[8] = 3,也就是最短路径长度为3 通过path数组可知,2到8的最短路径为:8 - 7 - 6 - 2
其实广度优先遍历对应的广度优先生成树,每个顶点对应在广度优先生成树的层数就是其最短路径!!可以根据下图比对d数组数值和层数的关系
6.9.2 Dijkstra算法–迪杰斯特拉
广度优先遍历算法求无权图的最短路径,迪杰斯特拉算法求带权图或者无权图的最短路径。实际上无向图可以看成有向图(每条边对应两条方向相反的弧)所以解决了
带权路径长度–当图是带权图时,一条路径上所有边的权值之和,称为该路径的带权路径长度
当前顶点是V0,对应dist数组记录每一个顶点到V0的最短路径
迪杰斯特拉算法使用到三个数组
- final数组记录各个顶点是否已经寻找到最短路径(找到设为true)
- dist数组记录 当前未确定修短路径的顶点 到上一个个确定最短路径顶点的最短路径
- path数组记录顶点最短路径上最后一个顶点的直接前驱(也就是确定最短路径的时候修改path数组)
得到两个数组,可以根据dist数组求最短路径长度,根据path数组求最短路径。
上面描述的是迪杰斯特拉算法的手算过程,对于代码实现算法:
初始条件:若从V0开始,迪杰斯特拉算法使用到三个数组–
- 令
final[0]=false; dist[0]=0; path[0]=-1; - 其余顶点
final[k]=false; (false表示该顶点最短路径待定)disk[k]=arcs[0][k]; (arcs[0][k] 表示顶点0到k弧的长度)path[k]=(arcs[0][k]==无穷)?-1:0 (path记录最短路径的直接前驱)
n-1轮处理:每一轮确定一个最短路径
循环遍历final数组,找到还没有确定最短路径,且dist最小的顶点 Vi,令final[i]=true ,并检查所有Vi的邻接点,对于 Vi 的邻接点 Vj,若final[j]=false; 且dist[i]+arcs[i][j]<dist[j] (也就是找到了更短的路径)则进行修改:dist[j]=dist[i]+arcs[i][j] ,path[j]=i (arcs[i][j] 表示顶点Vi和Vj的弧的距离)
- 可以看出实现的关键点在于判断顶点是否临界,且返回顶点距离。
Dijkstra算法分析:
迪杰斯特拉算法需要进行n-1轮处理,每一轮处理确定一个最短路径。
第一步:先找到没确定最短路径且final值为false的顶点,
第二部:接着比对未确定最短路径的邻接点,当前最短路径是否有减少
对于邻接矩阵和邻接表第一步时间复杂度都是O(|V|)
对于邻接矩阵,第二部需要遍历整一行,时间复杂度为O(|V|) ,但对于邻接表则不需要这么多
但每一轮处理的时间复杂度都是O(|V|) ,总共需要进行n-1轮处理,时间复杂度为O( |V|2 )
上一小节求最小生成树的的Prim算法中,用到两个数组,isJoin数组记录顶点是否加入生成树,lowCost数组记录每一个节点当前加入最小生成树所需要的最小代价。lowCost数组和次数的迪杰斯特拉算法中dist数组很类似。
结论: Dijkstra 算法不适用于有负权值的带权图
6.9.3 Floyd算法
Floyd算法–求出每一对顶点之间的最短路径
使用动态规划思想,将问题的求解分为多个阶段 对于n个顶点的图G,求任意一对顶点 Vi - Vj 之间的最短路径可分为如下几个阶段:
Floyd算法实现过程
这里先以最简单的三个顶点的图来演示Floyd算法
通过弗洛伊德算法得到两个最终的数组,记录各个顶点之间最短路径的二维数组,和记录最短路径的中转点。具体如何使用呢,最短路径二维数组直接用没什么好说的。如何通过path数组求得最短路径?由于最短路径可能存在多个中转点,所以需要反复根据path数组求最短路径的中转点,反复递归,直到中转点为-1(代表没有中转点)。上面的例子都是只有一个中转点的。后面Floyd算法例子分析会举一个复杂的例子。
- 根据A(2)可知,V1到V2最短路径长度为4。根据path(2)可知,完整路径信息为V1_V2
- 根据A(2)可知,V0到V2最短路径长度为10。根据path(2)可知,完整路径信息为V0_V1_V2
Floyd算法代码实现
使用到两个矩阵(二维数组)
- 一个最短路径矩阵,初始化是邻接矩阵(带权的)
- 另一个矩阵就是path数组,记录求最短路径的中转点,初始状态均为-1(没有中转点)
Floyd算法核心(三层循环)
- 接着不断增加中转点(此处一层循环,循环次数为顶点数)
- 每增加一个中转点,就根据上一轮的最短路径矩阵,判断中转点的加入是否能使得最短路径变短,若可以则修改最短路径矩阵和path矩阵。(此处两层循环)
for (int k=0; k<n; k++){
for(int i=0; i<n; i++) {
for (int j=0; j<n; j++){
if (A[i][j]>A[i][k]+A[k][j]){
A[i][j]=A[i][k]+A[k][j];
path[i][j]=k;
}
}
}
}
相应的时间复杂度为O( |V|3),空间复杂度为O( |V|2)
Floyd算法例子分析
上面用的是简单的例子,不能很好的体现path数组的使用,这里用一个复杂的例子
最终得到:两个数组,试着求0号顶点到4号顶点的最短路径
上一小节的迪杰斯特拉算法不能解决负权值的图,弗洛伊德算法能解决部分负权值图。弗洛伊德解决不了带有负权回路的图(有负权值的边组成回路)这中图可能没有最短路径,如下图,每转一圈路径都会减少
6.9.4 最短路径算法总结
6.10 有向无环图
没有环的有向图,则称为有向无环图。简称DAG图(Directed Acyclic Graph)
前面计算表达式可用树结构转化为后缀表达式,如果处理表达式树中重复项,那么就能够得到有向无环图。
那么如何写出表达式的有向无环图?可以根据树来求解,但不系统。解法如下:
-
步骤一:根据唯一操作数初步写下有向无环图。首先表达式对应的有向无环图,操作数一定只有一份。不可能存在多个节点存储同一个操作数。
step1:略
step2:就按照自己计算顺序给每一个运算符标号
step3:运算符放在操作数(子树)的上一层
-
从底向上逐层检查同层的运算符是否可以合体,合并同类项(操作数独一份不需要合体)
6.11 拓扑排序
AOV网,Activity On Vertex Network–用顶点表示活动的网
用DAG图(有向无环图)表示一个工程,顶点表示活动。有向边<Vi,Vj>表示活动 Vi 必须先于活动 Vj
拓扑排序就是AOV网的顶点的一种排序,排成一个线性的序列,该序列满足若存在有向边<Vi,Vj>,则拓扑排序中 Vi 一定先于 Vj 。
6.11.1 拓扑排序实现(循环)
拓扑排序实现算法思路
- 从AOV网中选择一个没有前驱(入度为0)的顶点并输出。
- 从网中删除该顶点和所有以它为起点的有向边。
- 重复1. 和2. 直到当前的AOV网为空或当前网中不存在无前驱的顶点为止(说明该图有回路)。
拓扑排序分析:
- 维数组
indegree[i] :存放各顶点人度,没有前驱的顶点就是入度为0的顶点。删除顶点及以它为尾的弧的操作,可不必真正对图的存储结构进行改变,可用弧头顶点的入度减1的办法来实现。 - 栈S:暂存所有入度为0的顶点,这样可以避免重复查找数
indegree[i] 检测人度为0的顶点,提高算法的效率。(当然你也可以每一遍历数组查找入读为0的顶点,设置类似广度优先遍历和深度优先的visited数组标识该顶点是否已经排好序,如果没有排好序且入读为0,则入栈) - 一维数组
print[i] :记录拓扑序列的顶点序号。
这里给出AOV网(有向无环图)的邻接表对应拓扑排序:
邻接表定义:
#define MAX_VERTEX_NUM 100
typedef bool EdgeType;
typedef char VertexType;
struct VNode {
VertexType data;
ArcNode* first;
};
struct ArcNode {
int adjvex;
ArcNode* next;
};
struct Graph {
VNode vertices[MAX_VERTEX_NUM];
int vexnum, edgenum;
};
拓扑排序函数体:
bool TopologicalSort(Graph G){
Initstack(S);
for(int i=0; i<G. vexnum; i++ )
if (indegree[i]==0)
Push(S,i);
int count=0;
while(!IsEmpty(S)){
Pop(S,i);
print[count++]=i;
int v;
for(p=G.vertices[i].first;p!=NULL;p=p->nextarc){
v=p->adjvex;
if(!(-- indegree[v]))
Push(S,v) ;
}
if(count<G.vexnum)
return false;
else return true;
}
- 邻接表拓扑排序时间复杂度:
O(|V|+|E|) //for循环遍历所有顶点+沿着边进行拓扑排序 - 邻接矩阵拓扑排序时间复杂度:O( |V|2)//每一次成功排序一个顶点,需要遍历一行进行入度减1
6.11.2 逆拓扑排序
对一个AOV网,如果采用下列步骤进行排序,则称之为逆拓扑排序:
- 从AOV网中选择一个出度为0的顶点并输出。
- 从网中删除该顶点和所有以它为终点的有向边。
- 重复①和②直到当前的AOV网为空。
拓扑排序就是选择入度为零,逆拓扑排序就是选则出度为零。具体实现区别不大(邻接表实现逆拓扑排序,在修改出度时需要遍历整个邻接表!这里引入逆邻接表,邻接表记录的时出边,而逆邻接表记录的时入边,具体如下:)
6.11.3 DFS实现逆拓扑排序
就是在原来DFS基础上改的(下面的这个代码时没有判断成环问题的)
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)
if( !visited[v])
DFS(G,v);
}
void DFS(Graph G,int v){
visited[v]=true;
for (w=FirstNeighbor(G,v);W>=0;w=NextNeighor(G,v,w))
if(!visited[w])
DFS(G,w);
print(v);
}
这里注意一点:拓扑排序和逆拓扑排序时不唯一的。拓扑排序可以解决事件排序问题,也可以解决图是否有huan
6.13 关键路径
知识体系:
与AOV网相对应的AOE网,AOE网表示用边表示活动的网络。AOE网就是带权有向无环图,其中顶点表示事件,有向边表示活动,以边上的权值表示完成该活动的开销
- AOV网–用顶点表示活动的网络(Activity On Edge Network)
AOE网具有以下两个性质:
- 只有在某顶点所代表的事件发生后,从该顶点出发的各有向边所代表的活动才能开始;
- 只有在进入某顶点的各有向边所代表的活动都已结束时,该顶点所代表的事件才能发生。
另外,有些活动是可以并行进行的
- AOE网中仅有一个入度为0的顶点,称为开始顶点(源点),它表示整个工程的开始;
- AOE网也仅有一个出度为0的顶点,称为结束顶点(汇点),它表示整个工程的结束。
AOE网中最长的带权路径称为关键路径,相应d额关键路径上的活动称为关键活动。
完成整个工程的最短时间就是关键路径的长度,若关键活动不能按时完成,则整个工程的完成时间就会延长
下面定义四个描述量:
- 事件 vk(对应顶点) 的最早发生时间 ve(k) --决定了所有从 vk 开始的活动能够开工的最早时间
- 活动 ai (对应弧)的最早发生时间 e(i) --指该活动弧的起点所表示的事件的最早发生时间
- 事件 vk(对应顶点) 的最迟发生时间 vl(k) --它是指在不推迟整个工程完成的前提下,该事件最迟必须发生的时间。
- 活动 ai (对应弧)的最迟发生时间 l(i) --它是指该活动弧的终点所表示事件的最迟发生时间与该活动所需时间之差。
对于有些活动,其活动最早发生时间和最迟发生时间必须一致,否则整个工程会延后。但有些又不要求一致,如下:
对此引入新的概念:
6.13.1 关键路径求解
-
求所有事件的最早发生时间 ve( )–求前往后求
求解事件最早发生时间依据公式:ve(k) = Max { ve(j) + Weight(Vj, vk) }
以当前事件的任意前驱加上弧权值,求出最大值就是当前事件的最早发生时间
按照拓扑排序的顺序来求解事件的最早发生时间,确保了求解当前事件最早发生时间时,与其相关的事件的最早发生事件已经被求完了。
-
求所有事件的最迟发生时间 vl( )–从后往前求
求解事件最迟发生事件依据公 vl(k) = Min { vl(j) - Weight(vk,vj ) }
以当前事件的任意后继减去弧权值,求出最小值就是当前事件的最迟发生时间
-
求所有活动的最早发生时间–就是弧事件最早发生时间!!
-
求所有活动的最迟发生时间–就是弧尾的最迟发生时间-弧权值
-
求所有活动的时间余量 d()—就是活动的最晚发生时间-活动最早发生时间
-
活动时间余量为零的就是关键活动,相应的就能求出关键路径
关键活动:a2、a5、a7
关键路径:V1,V3,V4,V6
6.13.2 关键路径的特性
|