图–数据结构操作与算法全解析
一、引言
图作为一种重要的数据结构,在计算机科学与众多领域中都有着广泛的应用。它能够有效地描述和解决各种复杂的关系问题,如网络拓扑、路径规划、资源分配等。本文将详细介绍图的相关操作和知识点,包括图的创建、遍历(深度优先遍历和广度优先遍历)、最小生成树(Kruskal 和 Prim 算法)、最短路径(Djikstra 和 Floyd 算法)以及拓扑排序,并结合具体代码进行深入剖析。
二、图的基本概念
图由顶点(Vertex)和边(Edge)组成。顶点表示对象,边表示对象之间的关系。根据边是否有方向,图可分为有向图(Directed Graph)和无向图(Undirected Graph);根据边是否有权重,又可分为有权图(Weighted Graph)和无权图(Unweighted Graph)。在实际应用中,这些不同类型的图各有其适用场景,例如社交网络可以用无向图表示用户之间的好友关系,而交通网络则可以用有权有向图表示道路的方向和距离。
三、图的存储结构
(一)邻接矩阵
- 定义与原理
- 邻接矩阵是用一个二维数组来表示图中顶点之间的关系。对于一个具有
n
个顶点的图,其邻接矩阵arcs
的大小为n×n
。如果arcs[i][j] = 1
(或边的权重值,对于有权图),表示顶点i
和顶点j
之间有边相连;如果arcs[i][j] = 0
(或MaxInt
,表示无穷大,对于无权图),则表示顶点i
和顶点j
之间没有边相连。在无向图中,邻接矩阵是对称的,因为边没有方向,即arcs[i][j] = arcs[j][i]
。
- 邻接矩阵是用一个二维数组来表示图中顶点之间的关系。对于一个具有
- 代码实现与分析
- 在给定的代码中,
AMGraph
结构体中的arcs
二维数组就是用于存储邻接矩阵。在CreateUDN
函数中,首先初始化邻接矩阵,将所有元素设置为MaxInt
,表示初始时顶点之间没有边相连。然后,当输入边的信息时,通过Locate
函数找到顶点在数组中的下标m
和n
,并将arcs[m][n]
和arcs[n][m]
(对于无向图)设置为边的权重值a
,从而构建起邻接矩阵。这种存储结构的优点是简单直观,容易判断两个顶点之间是否有边,并且在获取某个顶点的邻接顶点时,可以通过遍历一行(或一列)数组快速得到。然而,其缺点是对于稀疏图(边较少的图),会浪费大量的存储空间,因为大部分元素可能都是MaxInt
。
- 在给定的代码中,
//邻接矩阵的结构体创建
typedef char VerTexType; //假设顶点的数据类型为字符型
typedef int ArcType; // 假设边的权值类型为整型
typedef struct {
VerTexType vexs[MVNum]; // 顶点表
ArcType arcs[MVNum][MVNum]; // 邻接矩阵
int vexnum, arcnum; // 图的当前顶点数和弧(边)数
GraphKind kind;//图的种类
} AMGraph;
邻接矩阵创建无向网以及其打印
//邻接矩阵创建无向网
Status CreateUDN(AMGraph &G){
cout<<"所需点的个数:";cin>>G.vexnum;
cout<<"所需边的个数:";cin>>G.arcnum;
G.kind = UDN;//无向网
//输入顶点的信息
cout<<"请依次输入顶点信息"<<endl;
for(int i = 0;i<G.vexnum;i++){
cout<<"第"<<i+1<<"个:";
cin>>G.vexs[i];
}
//初始化邻接矩阵
for(int i=0;i<G.vexnum ;i++) //初始化邻接矩阵
for(int j=0;j<G.vexnum ;j++)
G.arcs[i][j]=MaxInt;
//输入边的信息
cout<<"请依次输入边的信息"<<endl;
cout<<"输入格式 点v1 点v2 边长度a"<<endl;
for(int i = 0;i<G.arcnum;i++){
VerTexType v1,v2;
ArcType a;
cout<<"第"<<i+1<<"个:";
cin>>v1>>v2>>a;
Edges[i].Head = v1;
Edges[i].Tail = v2;
Edges[i].lowcost = a;
int m = Locate(G,v1),n = Locate(G,v2);
G.arcs[m][n] = G.arcs[n][m] = a;//构建边成功
}
return OK;
}
//打印邻接矩阵内容
void PrintUDN(AMGraph &G){
cout<<"vexnum:"<<G.vexnum<<"\tarcnum:"<<G.arcnum<<endl<<endl;
cout<<"\t";
for(int i = 0 ;i<G.arcnum;i++)cout<<G.vexs[i]<<"\t";
cout<<endl;
for(int i = 0;i<G.vexnum;i++){
cout<<G.vexs[i]<<"\t";
for(int j = 0;j<G.vexnum;j++){
if(G.arcs[i][j] == MaxInt) cout<<"∞\t";
else cout<<G.arcs[i][j]<<"\t";
}
cout<<endl;
}
}
(二)邻接表
- 定义与原理
- 邻接表是一种链式存储结构,它为图中的每个顶点建立一个单链表,链表中的节点表示与该顶点相邻接的顶点。每个链表节点包含两个部分:邻接顶点的下标
adjvex
和指向下一个邻接顶点节点的指针nextarc
。对于有权图,还可以在节点中添加一个字段来存储边的权重信息info
。
- 邻接表是一种链式存储结构,它为图中的每个顶点建立一个单链表,链表中的节点表示与该顶点相邻接的顶点。每个链表节点包含两个部分:邻接顶点的下标
- 代码实现与分析
- 在代码中的
ALGraph
结构体定义了邻接表。vertices
是一个数组,每个元素是一个VNode
结构体,代表一个顶点。VNode
结构体中的firstarc
指针指向该顶点的邻接顶点链表的头节点。在CreateDG
函数中,通过头插法创建邻接表。当输入一条边v1
到v2
时,先找到v1
和v2
对应的顶点下标i
和j
,然后创建一个新的边节点p
,将其adjvex
设置为j
,并将p
插入到顶点i
的邻接顶点链表的头部,即p->nextarc = G.vertices[i].firstarc; G.vertices[i].firstarc = p;
。邻接表的优点是对于稀疏图能够节省存储空间,只存储实际存在的边。其缺点是在判断两个顶点之间是否有边时,需要遍历相应顶点的邻接链表,效率相对较低,不如邻接矩阵直接通过数组下标访问那么快速。
- 在代码中的
//邻接表的相关结构体创建
typedef enum {
DG, DN, UDG, UDN} GraphKind;
//{有向图,有向网,无向图,无向网}
typedef char VerTexType; //假设顶点的数据类型为字符型
typedef int ArcType; // 假设边的权值类型为整型
typedef struct ArcNode {
//边结点
int adjvex; // 该弧(边)所指向的顶点的位置
struct ArcNode *nextarc; //指向下一条边
InfoType *info; //和边相关的信息
} ArcNode;
typedef struct VNode {
//表头结点
VerTexType data; // 顶点信息
ArcNode *firstarc; // 指向第一条依附该顶点的弧(边)
} VNode, AdjList[MVNum];
typedef struct {
AdjList vertices; //顶点数组
int vexnum, arcnum; // 图的当前顶点数和弧(边)数
GraphKind kind;//图的种类
} ALGraph;
邻接表创建有向网及其打印
//邻接表创建有向网
Status CreateDG(ALGraph &G){
cout<<"所需点的个数:";cin>>G.vexnum;
cout<<"所需边的个数:";cin>>G.arcnum;
G.kind = DG;//有向网
//输入顶点的信息
//构造头节点
cout<<"请依次输入顶点信息"<<endl;
for(int i = 0;i<G.vexnum;i++){
cout<<"第"<<i+1<<"个:";
cin>>G.vertices[i].data;
G.vertices[i].firstarc = nullptr;
}
//边的信息 采用头插法
cout<<"请依次输入边的信息"<<endl;
cout<<"输入格式 点v1 点v2"<<endl;
for(int k = 0;k<G.arcnum;k++){
VerTexType v1,v2;
// ArcType a;
cin>>v1>>v2;
int i = Locate(G,v1);
int j = Locate(G,v2);
ArcNode* p = new ArcNode;//新的边节点
p->adjvex = j;//边指向的顶点的序号
//头插法
//p的下一个边改成第i个点后依附的第一条弧
p->nextarc = G.vertices[i].firstarc;
G.vertices[i].firstarc = p;
}
return OK;
}
//打印邻接表
void PrintDG(ALGraph &G) {
for (int i = 0; i < G.vexnum; i++) {
cout << "[" << G.vertices[i].data << "]";
ArcNode* p = G.vertices[i].firstarc;
while (p!= nullptr) {
int t = p->adjvex;
cout << "->" << p->adjvex << "("<< G.vertices[t].data<<")";
p = p->nextarc;
}
cout << endl;
}
}
四、图的遍历
(一)深度优先遍历(DFS)
- 原理与算法流程
- 深度优先遍历的基本思想是从图中的某个顶点
v
出发,访问该顶点,然后递归地遍历与v
相邻接且未被访问过的顶点,直到所有与v
有路径相通的顶点都被访问到。如果图中还有未被访问的顶点,则任选一个未被访问的顶点作为起始点,重复上述过程,直到图中所有顶点都被访问过。
- 深度优先遍历的基本思想是从图中的某个顶点
- 代码实现与分析(以邻接矩阵为例)
- 在
dfs
函数中,首先输出当前顶点G.vexs[v]
,并将其标记为已访问visited[v] = true
。然后通过一个循环遍历所有顶点,如果G.arcs[v][w]!= MaxInt
表示顶点v
和顶点w
之间有边相连,且顶点w
未被访问过,则递归调用dfs(G, w)
继续深度优先遍历。这种递归的方式能够沿着一条路径尽可能深地访问顶点,直到无法继续,然后回溯到上一个顶点,继续探索其他未被访问的路径。时间复杂度为 O(V2)O(V^2)O(V2),其中V
是图的顶点数,因为对于每个顶点都可能需要遍历所有其他顶点来判断是否有边相连。空间复杂度为 O(V)O(V)O(V),主要是用于存储递归调用栈和标记数组visited
。
- 在
//邻接矩阵构造无向网的DFS
bool visited[MVNum];
void dfs(AMGraph &G,int v){
cout<<G.vexs[v];visited[v] = true;
for(int w = 0;w<G.vexnum;w++){
if(G.arcs[v][w]!=MaxInt&&!visited[w])
dfs(G,w);
}
}
//邻接表构造有向网的DFS
bool visited[MVNum];
void dfs(ALGraph &G,int v){
cout<<G.vertices[v].data;
visited[v] = true;
ArcNode *p = G.vertices[v].firstarc;
while(p!=nullptr){
int w = p->adjvex;//p的周围的点的下标
if(!visited[w]) dfs(G,w);//第w个点未被访问 =>访问下
p = p->nextarc;
}
}
(二)广度优先遍历(BFS)
- 原理与算法流程
- 广度优先遍历的基本思想是从图中的某个顶点
v
出发,先访问该顶点,然后依次访问与v
相邻接的所有未被访问过的顶点,再按照这些顶点被访问的先后顺序,依次访问它们相邻接的未被访问过的顶点,直到图中所有顶点都被访问过。它类似于层次遍历,先访问距离起始顶点最近的一层顶点,然后再依次访问距离更远的层次的顶点。
- 广度优先遍历的基本思想是从图中的某个顶点
- 代码实现与分析(以邻接矩阵为例)
- 在
bfs
函数中,首先将所有顶点标记为未访问。然后将起始顶点v
标记为已访问并输出,将其下标放入队列Q
中。在循环中,当队列不为空时,取出队首元素u
,并遍历所有顶点。如果顶点u
和顶点v
之间有边相连且顶点v
未被访问过,则将顶点v
标记为已访问并输出,然后将其下标放入队列Q
中。这样可以保证按照距离起始顶点的层次顺序依次访问顶点。时间复杂度为 O(V2)O(V^2)O(V2),与深度优先遍历类似,因为都需要遍历邻接矩阵。空间复杂度为 O(V)O(V)O(V),主要用于存储队列和标记数组visited
。
- 在
//邻接矩阵构造无向网的BFS
//广度优先搜索
void bfs(AMGraph &G) {
for (int i = 0; i < G.vexnum; i++) {
visited[i] = false;
}
queue<int> Q;
for (int i = 0; i < G.vexnum; i++) {
if (!visited[i]) {
visited[i] = true;
cout << G.vexs[i];
Q.push(i);
while (!Q.empty()) {
int u = Q.front();
Q.pop();
for (int v = 0; v < G.vexnum; v++) {
if (G.arcs[u][v]!= MaxInt &&!visited[v]) {
visited[v] = true;
cout << G.vexs[v];
Q.push(v);
}
}
}
}
}
}
//邻接表构造有向网的BFS
void bfs(ALGraph &G){
for (int i = 0; i < G.vexnum; i++) {
visited[i] = false;
}
queue<int> Q;
for(int i =0;i<G.vexnum;i++){
if(!visited[i]){
visited[i] = true;
cout<<G.vertices[i].data;
Q.push(i);
while(!Q.empty()){
int u = Q.front();//Q存放的是下标
Q.pop();
ArcNode *p = G.vertices[u].firstarc;
while(p!=nullptr){
if(!visited[p->adjvex]){
visited[p->adjvex] = true;
cout<<G.vertices[p->adjvex].data;
Q.push(p->adjvex);
}
p = p->nextarc;
}
}
}
}
}
五、最小生成树
(一)Prim 算法
-
原理与算法流程
- Prim 算法的基本思想是从图中的任意一个顶点开始,逐步构建最小生成树。首先将起始顶点加入到最小生成树的顶点集合 (U) 中,然后在集合 (U) 和集合 (V - U)((V) 是图的所有顶点集合)之间的边中,选择一条权值最小的边,将这条边的另一个顶点加入到集合 (U) 中。重复这个过程,直到集合 (U) 包含了图中的所有顶点。在这个过程中,需要维护一个数组 (lowcost) 来记录集合 (V - U) 中每个顶点到集合 (U) 中顶点的最小权值边的权值,以及一个数组 (vset) 来标记顶点是否已经加入到集合 (U) 中。
-Prim算法 又叫加边法 实现流程如图所示
- Prim 算法的基本思想是从图中的任意一个顶点开始,逐步构建最小生成树。首先将起始顶点加入到最小生成树的顶点集合 (U) 中,然后在集合 (U) 和集合 (V - U)((V) 是图的所有顶点集合)之间的边中,选择一条权值最小的边,将这条边的另一个顶点加入到集合 (U) 中。重复这个过程,直到集合 (U) 包含了图中的所有顶点。在这个过程中,需要维护一个数组 (lowcost) 来记录集合 (V - U) 中每个顶点到集合 (U) 中顶点的最小权值边的权值,以及一个数组 (vset) 来标记顶点是否已经加入到集合 (U) 中。
-
代码实现与分析(以邻接矩阵为例)
- 在
Prim
函数中,首先进行初始化。将起始顶点v0
加入到集合U
中,即vset[v] = 1
,并将与起始顶点相邻接的顶点的lowcost
值设置为相应边的权值lowcost[i] = G.arcs[v][i]
。然后在循环中,找到lowcost
数组中的最小值min
,其对应的顶点k
就是要加入到集合U
中的顶点。输出加入的边G.vexs[prevV] - G.vexs[v] : lowcost[v]
,并更新prevV = v
和v = k
。接着,对于集合 (V - U) 中的顶点,如果通过新加入的顶点v
到该顶点的边权值更小,就更新lowcost
数组的值。时间复杂度为 (O(V^2)),其中 (V) 是图的顶点数,因为在每次选择最小权值边时,都需要遍历lowcost
数组来找到最小值,这个过程的时间复杂度为 (O(V)),而总共需要进行 (V - 1) 次这样的选择操作。空间复杂度为 (O(V)),主要用于存储lowcost
、vset
和其他辅助变量。
- 在
//求最小生成树
void Prim(AMGraph &G,int v0){
cout<<"利用Prim算法构建最小生成树所加入的边是"<<endl;
//初始化
int v = v0;
int k ;//存放最小的权值的下标
int vset[MVNum];//存放在U中的点的下标
ArcType lowcost[MVNum];//存放权值
for(int i = 0;i<G.vexnum;i++){
lowcost[i] = G.arcs[v][i];//存放每个点到v的权值
vset[i] = 0;//点都还未进U 所以都是0
}
vset[v] = 1;//首先把第一个点存进去
for(int i = 0;i<G.vexnum - 1;i++){
//剩余的其他几个节点遍历,依次存入U 共需要遍历n-1次
//找lowcost的最小点
int min = MaxInt;
for(int j = 0;j<G.vexnum;j++){
if(min>lowcost[j] && vset[j] == 0){
//找未在U中的最小值
min = lowcost[j];//找最小值
k = j;//更新最小值对应点的下标(k点要存进U中去)
}
}
vset[k] = 1;//第k个点存进U
int prevV = v;//存入边的起点
v = k;//更新v为k 此时v为存入的边的终点
cout << G.vexs[prevV] << " - " << G.vexs[v] << " : " << lowcost[v] << endl;
//更新lowcost 只是更新值 不会显露lowcost的来源信息 就是v->w,v,w不知
for(int j = 0;j<G.vexnum;j++){
if(vset[j] == 0 && G.arcs[v][j]<lowcost[j]){
lowcost[j] = G.arcs[v][j];
}
}
}
}
(二)Kruskal 算法
-
原理与算法流程
- Kruskal 算法的基本思想是将图中的所有边按照权值从小到大进行排序,然后依次选择边。如果选择的边不会形成环,就将其加入到最小生成树中,直到选择了 (V - 1) 条边为止,其中 (V) 是图的顶点数。为了判断选择的边是否会形成环,使用了并查集数据结构,通过维护一个数组