最小生成树prim算法
by.Qin3Yu
请注意:阅读本文需要您先掌握图、对、顺序表以及优先队列的基本操作,具体可参阅我的往期博客:
【C++数据结构 | 顺序表速通】使用顺序表完成简单的成绩管理系统.by.Qin3Yu
【C++数据结构 | 队列速通】图解BFS算法走迷宫最短路径问题.by.Qin3Yu
文中所有代码使用 C++ 举例,且默认已使用 std 命名空间:
using namespace std;
针对本文中的部分代码案例,还需要额外导入以下头文件:
#include <vector>
#include <queue>
概念速览
图的基本概念及定义
- 在算法图论中,图(Graph) 是由一组顶点和连接这些节点的边组成的数据结构,顶点和边是组成图的基本元素。图可以抽象的看作为一些岛屿和连接这些岛屿的小桥,顶点就是岛屿,边就是桥。
- 在图中,如果所有边都可以双向通行,没有方向的限制,则称为无向图。
- 在无向图中,如果所有任意一对顶点都有路径可以从一个点前往另一个顶点 (中途可以经过其他顶点),则称为连通图。
- 一个基本的连通图如下所示:
- 在上面这个图中,我们可以明显看出,每个边的长度都不一样,所以我们引入了一个概念来表示边的长短,叫做权重(Weighted Graph),简称为权。每条边都带有权值的图称为加权图。如下图所示,我们可以说连接 A 与 B 的边的权重为 8 :
- 在上张图中,可以看到每条边的权各不相同,本文所讨论的最短路径搜索算法仅针对所有权重不完全相同的加权图(且权大于0)。
其他概念定义:
- 环: 由n个顶点和n-1条边组成的闭合回路,如上图中的A-B-E-F-C就是一个环。
- 稠密图: 相对于点数,边数较多的图。
- 稀疏图: 相对于边数,点数较多的图。
prim算法
-
Prim算法由美国计算机科学家罗伯特·C·普里姆(Robert C. Prim)于1957年提出。其基于贪心策略(Greedy Strategy)实现,和我们以后会讲的Dijkstra算法非常相似。它的具体实现方法可以概括为如下:从起始点开始向外扩散,寻找权最小的邻边,并将边另一侧的点加入集合,已在集合内的点不能再次加入(否则会成环),直到所有的点都加入了集合,则构成了最小生成树。
-
Prim算法每次选择连接生成树和非生成树顶点的最小权值边,这种局部最优的选择确保了最终生成树的全局最优性。因此,Prim算法能够在保证最小生成树的同时,保证较好的时间复杂度。此外,prim算法还能保证最小生成树的连通性。
时间复杂度: O(mlogn),m为边数,n为点数。
最佳适用情景: 稠密图或边的权值比较平均的图。
(拓展)kruskal算法
-
Kruskal算法由美国计算机科学家乔治·P·克鲁斯卡尔(George P. Kruskal)于1956年提出。其同样是通过贪心算法来实现的,但同时还需要 并查集(在后文会做出详细讲解) 来辅助操作。它的具体实现方法可以概括为如下:从连通图的权最小边开始,不断访问相邻的权最小边(前提是不能成环),最后将其合并,其中需要利用并查集来辅助操作。
-
与prim算法不同的是,Kruskal算法直接基于边开始运算,所以其时间复杂度与点数无关。Kruskal算法不需要图是连通的,因此可以应用于处理非连通图的最小生成树。
时间复杂度: O(mlogm),m为边数。
最佳适用情景: 稀疏图或边的权值差值较大的图。
算法详解
prim算法
- 我们先讲解prim算法,prim算法其实非常简单,我们只需要遵循以下几步即可:
- 找到最小权临边;
- 将边另一端的点加入集合。
- 与此同时,我们还要注意已经在集合内的点不能再次放入集合内,避免成环。直到图上的所有点都已经被加入到集合中,就能得到最小生成树,我们以下面这张图为例,我们从 A 点出发:
- 与 A 相邻的四条边权分别为7、6、4、3,最小的是3,这条边另一端的点是 G 点,于是我们把 G 放入集合:
- 此时 A 和 G 都在集合中(用红色表示),所以我们要将集合内的 AG 看为是一个整体。接下来,与 AG 相邻的四条边的权分别为7、6、4、2,最小的是2,所以我们把这条边另一端的 F 点放入集合中,此时集合就为 AGF:
- 同理,与 AGF 相邻的四条边的权分别为7、6、4、10,注意,虽然权最小的是4,但是4对应的 F 点已经被放入集合中,所以我们选择其次小的6对应的点 C 放入集合中……以此类推,最后会得出这样的结果,如图中红色部分,此即为此图的最小生成树:
(拓展)kruskal算法
- kruskal算法与prim算法不同的是,kruskal算法是以边入手来进行操作,具体而言遵循以下步骤:
- 找出最小权边,检查边两端的点是否处于同一个集合
- 若不处于同一个集合,则将它们合并为同一个集合
- 直到图上所有的点都处于同一个集合中,就能得到最小生成树,我们依然用上文的图来举例,图中权最小的边是 D-E边,权值为2(F-G边的权值也为2,我们任意取一条均可),于是我们看 D-E边 两端的点D和E是否在同一个集合中。目前为刚开始第一步,显然任意两个点都不在同一个集合,所以我们将 D 和 E 放入同一个集合(用蓝色表示)。同理,我们将 F 和 G 也放入同一个集合:
ps.相连的蓝色区块算作一个集合
- 然后,我们再找出图上除蓝色外权值最小的边,即 A-G边,权值为3,边的两端点 A 和 G 显然也不在同一个集合中,于是我们将 A 和 G 放入同一个集合:
- 同理,下一条权值最小边为 A-F边,权值为4,但是边的两段点 A 和 F 属于同一个集合,所以我们跳过此边,寻找下一条权值最小边,即 A-C边,边的两端顶点不属于同一个集合,所以我们将其放入同一个集合:
- 以此类推,直到最后一步:
- 此时图中仅剩一条符合要求的边,即 C-D边,我们将其两段的顶点 C 和 D 放入同一个集合,之后图中所有点则均在同一个集合中,如图中蓝色部分,此即为此图的最小生成树:
prim算法代码实现
本篇文章仅会做出prim算法的代码详解,kruskal算法的代码详解之后会发出。
- 既然是图论算法,我们的第一步当然是先把图表示出来。如下图所示,我们可以用 对(pair) 来表示一组边和终点(离初始点相对较远的点),然后再用 顺序表(vector) 将多个点边对记录下来,组成一张表,在后文代码中,我们也会以这个图为例讲解:
//分别代表边的终点和权值,并用typedef改写为pii
typedef pair<int, int> pii;
// 顶点数
int n = 7;
//使用顺序表保存多个pii
vector<vector<pii>> graph(n);
//参数分别表示起点、终点和权值
graph[0].push_back(make_pair(1, 7));
graph[1].push_back(make_pair(0, 7));
......
graph[5].push_back(make_pair(6, 2));
graph[6].push_back(make_pair(5, 2));
- 在prim算法的内部,如前文所说,我们要先规定一个初始点,从初始点向外扩散,此外,我们还需要两个顺序表来分别记录每个点到起始点的距离和是否有被访问过(避免成环):
//记录当前点是否被访问过并使用bool表示,默认false(未访问)
vector<bool> visited(n, false);
//代表无穷大,方便后续进行小于判断
const int INF = 1e9;
//记录点到起始点的距离
vector<int> dist(n, INF);
//记录最小生成树的权值和
int minCost = 0;
- 在算法的实现中,我们要将边权最小的点放入集合,这恰好与 优先队列(priority_queue) 的作用完美契合,我们可以 用边权值最为队列元素的优先因子 来控制每次将边权最小的点出对。
//优先队列 pq,权值作为优先因子
priority_queue<pii, vector<pii>, greater<pii>> pq;
//将起点放入,其的终点、边权和到起始点的距离均为0
pq.push(make_pair(0, 0));
dist[0] = 0;
- 我们通过循环来在队列内重复操作,循环条件则为队列不为空。如前文所示,我们先将边权最小的点取出,将其标记为已访问,然后再对其进行相关操作:
while (!pq.empty()) {
//取出当前权值最小的顶点u,获取u的索引
int u = pq.top().second;
pq.pop();
//将顶点u标记为已访问
visited[u] = true;
......
}
- 在对其的具体操作中,我们先遍历一遍与当前点相连的所有边,依次获取边的终点和权值,然后选出权值最小的一条边,将对应的顶点加入优先队列……如此往复循环,直到队列为空(没有元素可以取出),则代表所有点都已经被便利过一次,我们即得到了此图的最小生成树:
while (!pq.empty()) {
......
//遍历与顶点u相连的边
for (auto& edge : graph[u]) {
//记录边的终点
int v = edge.first;
//记录边的权值
int weight = edge.second;
//判断顶点 v 是否未被访问过且边的权值小于顶点 v 当前存储的权值
if (!visited[v] && weight < dist[v]) {
//更新顶点 v 的权值为边的权值
dist[v] = weight;
//将更新后的顶点 v 及其权值加入优先队列
pq.push(make_pair(dist[v], v));
}
}
}
实际应用案例
- 最小生成树在实际应用中有许多适用案例,比如以下例子:
Q国现有一个核发电站,需要依靠此发电站向A、B、C、D、E、F、G七个城市供电,各个城市与发电站之间的可用供路与距离如图所示,如何规划电路,最大程度上节约电缆成本?最少需要多少千米的电缆(不记城市内部的电缆消耗)?
- 面对这个问题,即便是数学学霸或许也要稍加思考一番,但在学习最小生成树以后,我们便能很轻松快速的将答案解出。我们以发电站为初始点构建Q国电缆图的最小生成树,如下图所示,可得最少需要2800km的电缆:
完整算法代码
本代码案例将使用下图为例,计算最小生成树的边权之和:
#include <iostream>
#include <vector>
#include <queue>
using namespace std;
const int INF = 1e9; //代表无穷大
typedef pair<int, int> pii; //对(pair),分别代表边的终点和权值
int PRIM(vector<vector<pii>>& graph, int n) {
vector<bool> visited(n, false); //记录当前点是否被访问过
vector<int> dist(n, INF); //记录点到起始点的距离
int minCost = 0; //记录最小生成树的权值和
priority_queue<pii, vector<pii>, greater<pii>> pq; //优先队列 pq,权值作为优先级
pq.push(make_pair(0, 0)); //将起点放入
dist[0] = 0;
while (!pq.empty()) {
int u = pq.top().second; //取出当前权值最小的顶点u,获取u的索引
pq.pop();
visited[u] = true; //将顶点u标记为已访问
//遍历与顶点u相连的边
for (auto& edge : graph[u]) {
int v = edge.first; //边的终点
int weight = edge.second; //边的权值
//判断顶点 v 是否未被访问过且边的权值小于顶点 v 当前存储的权值
if (!visited[v] && weight < dist[v]) {
dist[v] = weight; //更新顶点 v 的权值为边的权值。
pq.push(make_pair(dist[v], v)); //将更新后的顶点 v 及其权值加入优先队列
}
}
}
//计算和
for (int i = 0; i < n; i++)
minCost += dist[i];
return minCost;
}
int main() {
int n = 7; // 顶点数
vector<vector<pii>> graph(n);
// 起点 终点 权值
graph[0].push_back(make_pair(1, 7));
graph[1].push_back(make_pair(0, 7));
graph[0].push_back(make_pair(2, 6));
graph[2].push_back(make_pair(0, 6));
graph[0].push_back(make_pair(5, 4));
graph[5].push_back(make_pair(0, 4));
graph[0].push_back(make_pair(6, 3));
graph[6].push_back(make_pair(0, 3));
graph[1].push_back(make_pair(2, 8));
graph[2].push_back(make_pair(1, 8));
graph[2].push_back(make_pair(3, 8));
graph[3].push_back(make_pair(2, 8));
graph[3].push_back(make_pair(4, 2));
graph[4].push_back(make_pair(3, 2));
graph[4].push_back(make_pair(5, 10));
graph[5].push_back(make_pair(4, 10));
graph[5].push_back(make_pair(6, 2));
graph[6].push_back(make_pair(5, 2));
int minCost = PRIM(graph, n);
cout << "最小生成树的权值和:" << minCost << endl;
system("pause");
return 0;
}
参考输出:
最小生成树的权值和:28