首页 > 编程语言 >【算法笔记】Kruskal/Prim算法——求解最小生成树问题

【算法笔记】Kruskal/Prim算法——求解最小生成树问题

时间:2024-09-08 23:28:16浏览次数:10  
标签:Prim int Kruskal -- 算法 ans

前言

生活中经常遇到类似这种的问题:

公路修建
有一些城市,城市之间要修建高速公路,每两个城市之间都可以修双向的路。其中每两个城市之间修路都需要花费对应的金额。请问如何修路,使得总花费的金额最少,且任意两个城市之间都可以直接或间接通过修建的路来通行?

实际上,我们可以把这种问题抽象化,把城市看作图的顶点,公路看作带权的无向边,这样整个国家就被抽象成了一张带权无向图。又因为要求总花费最小,所以修的路一定组成一棵生成树,于是转换成下面的问题:

给定一张带权无向图\(G\),求它的一棵生成树,使其中所有边权之和最小

实际上,这就是大名鼎鼎的「最小生成树问题」。
比如看下面这张图:

MST-Graph

其中,标绿的部分即为其最小生成树。

对于这种问题,很多数学家都有所研究。但毕竟是数学家,不懂计算机,就只管算法的正确性,不管实现起来的简单性、可行性和效率,所以很多算法都被人们所抛弃。不过,还是有两种算法脱颖而出,它们就是标题中的——Kruskal 和 Prim。

模板:洛谷 P3366【模板】最小生成树
数据范围:\(N\le5000,M\le2\times10^5,w\le 10^4\)。

Kruskal

Kruskal算法是由Joseph Kruskal于1956年提出的最小生成树算法,时间复杂度为\(\mathcal O(m\log m)\)。下面来看这种算法的流程。

Kruskal 算法流程

  1. 将所有边按权值从小到大排序,依次遍历每一条边;
  2. 对于每一条边,如果在当前子图中连上之后不会形成环,则选择这条边作为最小生成树的一部分,加入子图;
  3. 选择\(N-1\)条边后即可结束算法。

并查集 - 加快算法速度

在正式实现Kruskal算法之前,我们还需要先了解一下并查集。如果判定是否会出现环的部分使用\(\text{DFS}\),则时间复杂度为\(\mathcal O(nm+m\log m)\),费时费力。若使用并查集来实现,则代码非常简单,且时间复杂度仅为\(\mathcal O(m\log m)\)(排序的耗时)。并查集模板:

class dsu
{
private:
	const int n;
	int* fa;
public:
	inline dsu(int count): n(count) // 初始化大小为n的并查集
	{
		fa = new int[n]; // 申请新的内存
		for(int i=0; i<n; i++)
			fa[i] = i; // 初始化fa[i]=i
	}
	inline ~dsu() { delete[] fa; }  // 销毁存储空间,防止内存泄露
	inline int size() { return n; } // 返回并查集大小
	int find(int x) { return fa[x] == x? x: fa[x] = find(fa[x]); } // 查找父亲+路径压缩
	inline bool same(int x, int y) { return find(x) == find(y); } // x,y是否在同一个连通分量里?
	inline void merge(int x, int y) { fa[find(x)] = find(y); } // 合并x、y,即连接x<->y这条双向边
	inline bool connected() // 判断整个图是否连通
	{
		int p = find(0);
		for(int i=0; i<n; i++)
			if(find(i) != p)
				return false;
		return true;
	}
};

使用并查集后,算法时间复杂度降到\(\mathcal O(m\log m)\),即排序的时间复杂度。下面来看代码。

参考代码

如果对并查集不熟悉的读者可以先复制模板写代码,后面再仔细研究TA
单次Kruskal算法的排序建议用priority_queue(比sort效率更高),如果要多次Kruskal则需要提前排好序。

#include <cstdio>
#include <queue>
using namespace std;

// 代表一条边,方便排序
struct Edge
{
	int from, to, weight;
	inline bool operator <(const Edge& e2) const
	{
		return weight > e2.weight; // 注意:使用优先队列时要把大小倒过来过来
	}
	inline void read()
	{
		scanf("%d%d%d", &from, &to, &weight);
		from --, to --;
	}
};

// 并查集模板
class dsu
{
private:
	const int n;
	int* fa;
public:
	inline dsu(int count): n(count)
	{
		fa = new int[n];
		for(int i=0; i<n; i++)
			fa[i] = i;
	}
	inline ~dsu() { delete[] fa; }
	inline int size() { return n; }
	int find(int x) { return fa[x] == x? x: fa[x] = find(fa[x]); }
	inline bool same(int x, int y) { return find(x) == find(y); }
	inline void merge(int x, int y) { fa[find(x)] = find(y); }
	inline bool connected()
	{
		int p = find(0);
		for(int i=0; i<n; i++)
			if(find(i) != p)
				return false;
		return true;
	}
};

int main()
{
	int n, m;
	scanf("%d%d", &n, &m); // 读入顶点数和边数
	priority_queue<Edge> q; // 初始化优先队列,用于排序
	while(m--)
	{
		Edge e;
		e.read();  // 读入一条边
		q.push(e); // 放入队列进行排序
	}
	int ans = 0, // 记录总权值
		cnt = 0; // 当前选择边的个数
	dsu d(n);    // 初始化并查集
	while(!q.empty() && cnt < n - 1) // 遍历所有边,选择了n-1条边即可退出
	{
		auto [u, v, w] = q.top(); q.pop(); // 弹出边权最小的边
		if(!d.same(u, v))  // 如果连通后不会形成环
		{
			d.merge(u, v);    // 连上这条边
			ans += w, cnt ++; // 更新答案和计数
		}
	}
	if(cnt == n - 1) printf("%d\n", ans); // 如果最终选择了n-1条边,输出答案
	else puts("orz"); // 否则...
	return 0;
}

最后一段也可以写成这样(不用cnt计数,输出答案时判定连通,速度稍慢):

int ans = 0;
dsu d(n);
while(!q.empty())
{
	auto [u, v, w] = q.top(); q.pop();
	if(!d.same(u, v))
	{
		d.merge(u, v);
		ans += w;
	}
}
if(d.connected()) printf("%d\n", ans);
else puts("orz");

Prim

Prim算法于1930年由捷克数学家Vojtěch Jarník发现,在1957年又由美国计算机科学家Robert C. Prim独立发现。1959年,Edsger Wybe Dijkstra(没错,就是Dijkstra算法的发明者)再次发现了该算法。因此,在某些场合,Prim算法又被称为DJP算法、Jarník算法或Prim-Jarník算法。

Prim 算法流程

Prim与Dijkstra很相似,将顶点分为\(S\)和\(T\)两个集合,具体流程如下:

  1. 初始时,所有顶点全部在\(S\)中,\(T\)为空集。
  2. 从\(S\)中选择任意顶点,移动到集合\(T\);
  3. 重复以下步骤,直到所有顶点都在\(T\)中:
    • 选择一条边\((u,v,w)\),使得\(u\)在点集\(S\)中,\(v\)在点集\(T\)中,且权值\(w\)最小;
    • 将这条边加入最小生成树,并将\(u\)移入点集\(T\)。

Prim算法的原始写法就不多说了,这里和Dijkstra一样,介绍priority_queueset优化。

优先队列优化

运行时间:\(328\mathrm{ms}\)
时间复杂度:\(\mathcal O(n\log m)\)

#include <cstdio>
#include <queue>
#define maxn 5005
#define INF 2147483647
using namespace std;

using pii = pair<int, int>;
vector<pii> G[maxn];
int dis[maxn];

int main()
{
	int n, m;
	scanf("%d%d", &n, &m);
	while(m--)
	{
		int u, v, w;
		scanf("%d%d%d", &u, &v, &w);
		G[--u].emplace_back(--v, w);
		G[v].emplace_back(u, w);
	}
	for(int i=1; i<n; i++)
		dis[i] = INF;
	priority_queue<pii, vector<pii>, greater<pii>> q;
	q.emplace(0, 0);
	int ans = 0, left = n;
	while(!q.empty() && left > 0)
	{
		auto [d, v] = q.top(); q.pop();
		if(d != dis[v]) continue;
		dis[v] = -INF, left --, ans += d;
		for(auto [u, w]: G[v])
			if(w < dis[u])
				q.emplace(dis[u] = w, u);
	}
	if(left) puts("orz");
	else printf("%d\n", ans);
	return 0;
}

set优化

运行时间:\(351\mathrm{ms}\)
时间复杂度:\(\mathcal O(n\log n)\)

#include <cstdio>
#include <vector>
#include <set>
#define maxn 5005
#define INF 2147483647
using namespace std;

using pii = pair<int, int>;
vector<pii> G[maxn];
int dis[maxn];

int main()
{
	int n, m;
	scanf("%d%d", &n, &m);
	while(m--)
	{
		int u, v, w;
		scanf("%d%d%d", &u, &v, &w);
		G[--u].emplace_back(--v, w);
		G[v].emplace_back(u, w);
	}
	for(int i=1; i<n; i++)
		dis[i] = INF;
	set<pii> s;
	s.emplace(0, 0);
	int ans = 0, left = n;
	while(!s.empty() && left > 0)
	{
		auto it = s.begin();
		auto [d, v] = *it; s.erase(it);
		dis[v] = -INF, left --, ans += d;
		for(auto [u, w]: G[v])
			if(w < dis[u])
			{
				if(dis[u] != INF)
					s.erase(pii(dis[u], u));
				s.emplace(dis[u] = w, u);
			}
	}
	if(left) puts("orz");
	else printf("%d\n", ans);
	return 0;
}

习题

总结

我们来看一下Kruskal、Prim两种算法的对比:

指标 Kruskal Prim
时间复杂度 \(\mathcal O(m\log m)\) \(\mathcal O(n\log m)\)[1]
运行时间[2] \(255\mathrm{ms}\) \(328\mathrm{ms}\)
编码难度
适用域 稀疏图 稠密图

由此可见,大部分题目首选Kruskal,有特殊需要时才使用Prim。
本篇文章到此结束,如果觉得好的话就请给个三连,感谢大家的支持!


  1. 此处为优先队列优化的复杂度,set优化为\(\mathcal O(n\log n)\) ↩︎

  2. 洛谷 P3366上的提交结果,Kruskal算法使用并查集+优先队列,Prim使用优先队列优化 ↩︎

标签:Prim,int,Kruskal,--,算法,ans
From: https://www.cnblogs.com/stanleys/p/18403698/algonotes-mst

相关文章

  • 【算法笔记】树状数组/Binary Indexed Tree/Fenwick Tree
    前言树状数组,即树形存储的数组,又称BinaryIndexedTree或FenwickTree。抛开它树形的存储结构,这种神奇的数据结构的应用看起来与「树」没什么关系:有一个序列\(A=(A_1,A_2,\dots,A_N)\),在不超过\(\mathcalO(\logN)\)的时间复杂度内完成下列操作:\(\to~\)求\([L,R]\)区间内所......
  • Cooley-Tukey FFT算法的非递归实现
    一维情况#include<iostream>#include<complex>#include<cmath>constdoublePI=3.14159265358979323846;//交换位置voidswap(std::complex<double>&a,std::complex<double>&b){std::complex<double>temp=a......
  • 【算法笔记】位运算详解
    0.前言突然想到位运算是个好东西,就来水一波文章了……注意:我把能想到的有关位运算的所有内容都放进来了,所以篇幅较长,请谅解!若有写的不清楚或者不够详细的地方欢迎在评论区补充,谢谢支持!本文中参考代码均使用C++编写。废话不多说,下面步入正题。1.基本运算有一定基础的可以......
  • 【算法笔记】最近公共祖先(LCA)问题求解——倍增算法
    0.前言最近公共祖先简称LCA(LowestCommonAncestor)。两个节点的最近公共祖先,就是这两个点的公共祖先里面,离根最远的那个。这种算法应用很广泛,可以很容易解决树上最短路等问题。为了方便,我们记某点集\(S=\{v_1,v_2,\ldots,v_n\}\)的最近公共祖先为\(\text{LCA}(v_1,v_2,\ld......
  • 【算法笔记】【专题】RMQ 问题:ST表/树状数组/线段树
    0.前言好久没更算法笔记专栏了,正好学了新算法来更新……这也是本专栏的第一个专题问题,涉及到三种数据结构,如果写得有问题请各位大佬多多指教,谢谢!1.关于RMQ问题RMQ的全称是RangeMinimum/MaximumQuery,即区间最大/最小值问题。本文中,我们的算法以求最大值为例(最小值基本......
  • 【算法笔记】树形DP算法总结&详解
    0.定义树形DP,又称树状DP,即在树上进行的DP,是DP(动态规划)算法中较为复杂的一种。1.基础令\(f[u]=~\)与树上顶点\(u\)有关的某些数据,并按照拓扑序(从叶子节点向上到根节点的顺序)进行\(\text{DP}\),确保在更新一个顶点时其子节点的dp值已经被更新好,以更新当前节点的\(\text{DP}\)值......
  • 最小二乘回归算法原理及Python实践
    最小二乘回归算法原理主要基于最小化误差平方和的思想,以找到数据的最佳函数匹配。以下是对其原理的详细阐述:一、基本原理最小二乘法(LeastSquaresMethod,简称LS)是一种数学优化技术,它通过最小化误差的平方和来寻找数据的最佳函数匹配。在回归分析中,最小二乘法被广泛应用于......
  • 偏最小二乘回归算法原理及Python实践
    偏最小二乘回归(PartialLeastSquaresRegression,PLS回归)是一种统计学和机器学习中的多元数据分析方法,特别适用于处理因变量和自变量之间存在多重共线性问题的情况。其原理主要可以归纳为以下几点:一.原理概述PLS回归通过投影分别将预测变量(自变量X)和观测变量(因变量Y)投......
  • 如何在Java服务中实现分布式ID生成:雪花算法与UUID的对比
    如何在Java服务中实现分布式ID生成:雪花算法与UUID的对比大家好,我是微赚淘客返利系统3.0的小编,是个冬天不穿秋裤,天冷也要风度的程序猿!在现代分布式系统中,唯一标识符(ID)的生成是一个关键问题。常见的ID生成方案包括雪花算法(Snowflake)和UUID(通用唯一识别码)。本文将对这两种方案进行详......
  • MATLAB实现Dijkstra算法和Floyd算法
    目录1、文件功能介绍2、代码执行效果展示3、Dijkstra算法求图的单源最短路径4、DijkstrafullPath的更新逻辑5、DIjkstra算法流程图6、Floyd算法实现图的任意两点间最短路径7、Floyd算法流程图8、FloydfullPath的更新逻辑(非递归算法)1、文件功能介绍代码文件功能wor......