最小生成树
本部分主要对图中一些关于连通性和最小生成树的概念进行学习和理解,之后对于建立最小生成树的两种算法进行学习和实现。
接下来主要对于无向图进行分析,有向图也是一样的分析方法。
图的连通性以及生成树
首先对图连通性中一些定义进行阐释:
- 在无向图中,如果存在不连通的顶点,则该图称为非连通图。
- 非连通图的最大连通子图叫做连通分量。
- 若从无向图的每一个连通子图中的一个顶点出发进行 D F S DFS DFS或 B F S BFS BFS遍历,可求得无向图的所有连通分量的生成树( D F S DFS DFS或 B F S BFS BFS生成树)。
- 所有连通分量的生成树组成了非连通图的生成森林。
在上述定义中,其实连通图也就代表着图中任意两个顶点之间都至少有一条路径可以彼此到达。
对于一个非连通图来说,可以将其划分为多个连通图,也被称为原来的非连通图的连通子图。其中最大(包含顶点最多)的连通子图叫做连通分量。那么对于一个连通图而言,连通分量就是它自己。下图即为记忆额非连通图。
对于
D
F
S
DFS
DFS或
B
F
S
BFS
BFS生成树,其实就是根据图的
D
F
S
DFS
DFS遍历顺序和
D
F
S
DFS
DFS遍历顺序来建立一棵树。这里注意的是,在使用
B
F
S
BFS
BFS或
D
F
S
DFS
DFS生成树的时候,一个连通子图的遍历结果是一棵树,多个连通子图所构成的森林组成了生成森林。接下来通过两个例子来进行进一步的了解。
图的连通性判断
在对图的连通性有了一定的了解和学习之后,那么在给定一个图的时候,如何判断该图是否是一个连通图呢。这里其实方法有很多。接下来简单给出几个方法。
- 从某个顶点开始 D F S DFS DFS或 B F S BFS BFS遍历,遍历时统计遍历到的顶点数量,如果遍历到了所有的顶点,则说明是连通图,反之则是非连通图。
- 通过计算出任意两点之间的距离来进行判断,如果距离不是无穷大(即可达),则说明是连通图,反之则是非连通图。
- 使用并查集,也就是在建立生成树之后,查看所有顶点的根节点是否相同,如果相同则代表是连通图,反之则是非连通图。
总而言之,判断方法各式各样,接下来主要是通过 D F S DFS DFS遍历来判断一个图是否是连通图。
在使用 D F S DFS DFS判断一个图是否是连通图时,只需要统计中间遍历了多少个点即可,这一步在使用代码实现时其实可以通过判断访问数组 v i s i t visit visit中有多少个被置为了 t r u e true true即可。
// 判断是否是连通图
void judge()
{
// 初始化访问数组
for (int i = 0; i < vexnum; i++)
visit[i] = false;
// 使用dfs来进行判断
dfsMethod(0);
int count = 0; // 被访问的点的个数
// 判断是否所有的点都被访问到
for (int i = 0; i < vexnum; i++)
{
if (visit[i])
count++;
}
if (count == vexnum)
cout << "连通图" << endl;
else
cout << "非连通图" << endl;
}
// DFS
void dfsMethod(int index)
{
cout << index << endl; // 输出当前遍历的结点信息
visit[index] = true; // 修改访问状态,代表已经访问过
for (int i = 0; i < vexnum; i++) // 将当前结点相连的结点逐个进行遍历
{
if (visit[i] == false && map[index][i] != 0)
dfsMethod(i);
}
}
最小生成树
在对连通性和连通图有了一定的了解之后,接下来对于最小生成树进行学习和了解。
在了解最小生成树之前,首先对于一些定义进行一些阐释:
- 如果无向图中,边上有权值,则称该无向图为无向网。
- 如果无向网中每个顶点都相通,则该网被称为连通网。
- 最小生成树(Minimum Cost Spanning Tree)是代价最小的连通网的生成树,即该生成树上的边的权值和最小
根据这些定义,可以知道最小生成树是基于一个连通图的,其实也可以理解为简化一个连通图,即删去一些边的同时保持其连通性,并使得剩下的边上的权值之和最小。
根据上述分析可以得到建立最小生成树的一些准则:
- 必须使用且仅使用连通网中的 n − 1 n-1 n−1条边来联结网络中的 n n n个顶点。
- 不能使用产生回路的边。
- 各边上的权值的综合达到最小。
最小生成树一般用于道路建设等,举一个具体例子来说,有 n n n个村庄,现在需要在 n n n个村庄之间建立公路,如何建立公路使得每个村庄互相到达且为了减少成本,需要使得总的公路长度最小。对于这个问题,其实就是需要建立最小生成树。
在建立最小生成树时,有两种较为常用的方法,即 P r i m Prim Prim和 K r u s k a l Kruskal Kruskal算法,接下来对于这两种算法进行介绍。
普里姆算法 P r i m Prim Prim
P r i m Prim Prim算法是建立最小生成树的算法之一,首先列出该算法的所有流程,之后对于该流程再进一步进行具体分析。假设 N = ( V , E ) N=(V,E) N=(V,E)是连通网。 T E TE TE是 N N N上最小生成树中边的集合, u 0 u_0 u0是起始点,也就是最开始建立生成树的位置。
- U = u 0 U={u_0} U=u0, u 0 ∈ V u_0 \in V u0∈V, T E = { } TE=\left\{ \right\} TE={}。
- 在所有的 u ∈ U u \in U u∈U, v ∈ E ′ v \in E' v∈E′中找到一条权重最小的边 ( u , v 0 ) (u,v_0) (u,v0)并加入集合 T E TE TE,同时 v 0 v_0 v0并入 U U U。其中 E ′ E' E′中是由 V − U V-U V−U中顶点所构成的子图中的边 v v v, v ∈ E v \in E v∈E。
- 重复2步骤,直到 U = V U=V U=V,则所得的 T = ( V , T E ) T=(V,TE) T=(V,TE)即为所求最小生成树。
上述步骤其实仔细看来也较为简单,简单来说, P r i m Prim Prim算法的总的思路为,初始给一个点,也就是起点,然后每一轮根据一个固定的算法来选择一个点添加到生成树点集中,当生成树点集中的点与原图中的点相同时,该树就完成了建立。
那么核心就在于每一轮如何进行选点。首先可以确定一点的是,每次添加的点都是不存在于当前生成树点集中的点,即新添加的点 v o ∉ U v_o \notin U vo∈/U。那么根据上述算法,所要找的点要满足一个特点,即 u ∈ U u \in U u∈U, v ∈ E ′ v \in E' v∈E′中权重最小的一条边。这一点包含两条信息,首先是所要添加的点应该是不存在于 U U U中但是于 U U U中点邻接的点;其次是所要找的点与 U U U中的点所构成的边的权重,要是当前这种边集 E ’ E’ E’中权重最小的,如果一个点被多个点邻接,取距离最短的一个。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GjLJ3eEc-1629042852874)(…\6.图\pics\895cd8638476ce9b8f49c97c10e1c23.png)]
故通过上面的分析,
P
r
i
m
Prim
Prim算法其实就是逐点添加的算法,接下来通过一个例子进行实际运算。
在上图中,首先起点是0点,生成树点集中也就只有0,那么根据原图可知,0的邻接点为1和5,权重分别是28和10,那么根据权重大小,选择最小的,故选择5结点,之后将5号结点也纳入生成树点集中。第二轮时,生成树点集的邻接点为1和4,根据权重大小关系选择4结点(25<28),并将4结点添加到生成树点集中。之后的过程以此类推。
上述即为 P r i m Prim Prim算法的基本过程,那么接下来就是到具体部分,即代码实现部分。在实现的时候,首先需要一个访问数组,因为不能重复添加某个点,之后需要一个用于记录生成树点集到邻接点的距离的数组,用一个数组 d i s dis dis来表示, d i s [ i ] dis[i] dis[i]代表生成树点集到第 i i i个结点的距离,配合 v i s i t visit visit数组进行使用即可。
在实现时首先根据起始点初始化 v i s i t visit visit数组并结合邻接矩阵来初始化 d i s dis dis数组,之后通过循环来逐个添加点。在内部循环中,首先需要做的是找出距离最小的新点,并将其添加到点集中 ( v i s i t [ i ] = t r u e ) (visit[i]=true) (visit[i]=true)。之后需要更新距离,例如原本从2结点到5结点距离是99,此时往点集中添加了3结点,但是3结点也与5结点邻接并且距离只有10,那么就将这个距离从99更新为10。代码实现如下所示。
// Prim算法
void Prim(int start)
{
bool visit[MAX_NUM]; // 访问数组,用于判断该节点是否被访问过
// 初始化全部为false,即没有被访问过
for (int i = 0; i < MAX_NUM; i++)
visit[i] = false;
visit[start] = true; // start为起始结点,已经被访问
int dis[MAX_NUM]; // 到各个结点目前最近的距离
int pre[MAX_NUM]; // 前驱,即距离各个结点最近的结点的下标
for (int i = 0; i < vexnum; i++)
{
dis[i] = map[start][i]; // 设置距离
if (map[start][i] == INF || i == start) // 不可达则设置前驱为-1
pre[i] = -1;
else // 可达则设置前驱
pre[i] = start;
}
string ans_begin[MAX_NUM];
string ans_end[MAX_NUM];
int ans_weight[MAX_NUM];
int all_path = 0;
for (int i = 0; i < MAX_NUM; i++)
{
ans_begin[i] = "";
ans_end[i] = "";
ans_weight[i] = -1;
}
// 逐点添加,已经添加了start,则继续添加vexnum - 1 个点即可
for (int i = 0; i < vexnum - 1; i++)
{
int min_dis = INF; // 当前轮的最小距离
int min_index = -1; // 所要添加的结点的下标
for (int j = 0; j < vexnum; j++) // 暴力遍历即可
{
// 从未被访问的点中找出一个距离最小的
if (visit[j] == false && min_dis > dis[j])
{
min_dis = dis[j];
min_index = j;
}
}
if (min_index == -1) // 没有最小的
continue;
visit[min_index] = true; // 设置标记为访问过
if (pre[min_index] == -1) // 错误情况
break;
// cout << pre[min_index] << " " << min_index << " " << dis[min_index] << endl;
cout << nodes[pre[min_index]] << " " << nodes[min_index] << " " << dis[min_index] << endl;
all_path += dis[min_index];
// 更新距离向量
for (int j = 0; j < vexnum; j++)
{
if (visit[j] == false && map[min_index][j] < dis[j])
{
dis[j] = map[min_index][j];
pre[j] = min_index;
}
}
}
cout << all_path << endl;
}
克鲁斯卡尔算法 K r u s k a l Kruskal Kruskal
K r u s k a l Kruskal Kruskal算法是另一种建立最小生成树的算法,接下来也是先介绍该算法的整体过程,之后进行更具体的分析。首先假设 N = ( V , E ) N=(V,E) N=(V,E)是连通网。
- 非连通图 T = { V , { } } T=\left\{V,\left\{ \right\} \right\} T={V,{}},图中每个顶点自成一个连通分量。
- 在 E E E中找一个权重最小,且其两个顶点分别依附在不同的连通分量的边,将其加入 T T T中。
- 重复二,直到 T T T中所有顶点都在同一个连通分量。
从上述过程中可以看出, K r u s k a l Kruskal Kruskal算法的思路在于,首先将原图中所有的边去除,这样每个点单独属于一个连通分量,之后通过一个固定算法往图中添加边,直到该图变成一个连通图位置。
在进行选边的时候,根据流程中的规定,主要遵循两点要求,首先是添加进去的边的两端点不能处在同一个连通分量,其次是权重最小。每次找到边权重最小的符合条件的边进行添加即可,这样一来每次都可以减少一个连通分量,直到最后只剩一个连通分量。
那么根据上述分析, K r u s k a l Kruskal Kruskal算法其实是一个逐边添加的算法,这与 P r i m Prim Prim算法是不同的。
接下来通过一个例子来对该算法进行一个简单的学习。
如上图所示,一开始去掉所有的边,之后由于10是最短的边,且0和5不在同一个连通分量,故添加0-5边。之后10虽然权重最短,但是0和5已经在同一个连通分量,故此时只能退而求其次,选择权重为12的2-3边,因为2和3此时属于不同的连通分量。之后的过程以此类推。
上述即为 K r u s k a l Kruskal Kruskal算法的具体过程,接下来主要对于代码实现部分进行具体分析。
在进行代码实现的时候,由于每次添加的都是一条边,故这里需要一个数据结构来表示边的内容,即起点,终点以及权重。边的定义如下所示。
// 边
typedef struct Edge
{
string begin; // 弧尾
string end; // 弧头
int weight; // 权重,距离
// 构造函数
Edge()
{
begin = "";
end = "";
weight = -1;
}
}Edge;
之后由于需要逐个添加,且总边集始终不会变化,故这里直接对整体先进行一次排序即可,按照升序,这样按照数组顺序进行遍历并结合另一条规定即可。排序代码如下所示。
// 对边集进行排序
void sortEdge()
{
for (int i = 0; i < arcnum - 1; i++)
{
for (int j = 0; j < arcnum - i - 1; j++)
{
if (edge[j].weight > edge[j + 1].weight)
{
Edge temp = edge[j];
edge[j] = edge[j + 1];
edge[j + 1] = temp;
}
}
}
for (int i = 0; i < arcnum; i++)
{
cout << edge[i].begin << " " << edge[i].end << " " << edge[i].weight << endl;
}
cout << "***********" << endl;
}
那么接下来重点在于如何判断两个结点是否处于同一个连通分量了。这时候就需要使用到并查集的知识了,其实并查集也就是定义一个数组,将一个树中所有结点的该数组值设置为该根。这样一来就可以利用该数组中的值是否相同来判断两个节点是否处于同一个连通分量(加边的过程其实就是每个连通分量建立生成树的过程)。在添加完成之后记得更新一次并查集即可。
// 更新根
void updateRoot(int root[], int old_root, int new_root)
{
for (int i = 0; i < vexnum; i++) // 遍历所有结点
{
if (root[i] == old_root) // 换根
root[i] = new_root;
}
}
// Kruskal算法
void Kruskal()
{
sortEdge(); // 先排序
int root[MAX_NUM]; // 根数组
for (int i = 0; i < MAX_NUM; i++) // 初始化根就是自己
root[i] = i;
int all_path = 0; // 总长度
int count = 0; // 记录已经添加的边的数量,最多只需要vexnum-1条即可
for (int i = 0; i < arcnum; i++) // 遍历所有的边,直到添加了vexnum-1条边为止
{
int index1 = getIndex(edge[i].begin); // 获取下标
int index2 = getIndex(edge[i].end);
if (index1 == -1 || index2 == -1) // 异常
break;
if (root[index1] == root[index2]) // 根相同说明添加改变不会增加新的结点
continue;
cout << edge[i].begin << " " << edge[i].end << " " << edge[i].weight << endl;
all_path += edge[i].weight;
updateRoot(root, index1, index2); // 更新根节点
count++; // 已添加边的数目+1
if (count == vexnum - 1) // 达到要求则退出即可
break;
}
cout << all_path << endl;
}