【贪心算法(三)】并查集和克鲁斯卡尔算法

本文详细介绍了并查集的基本原理及其在解决特定问题中的应用,并进一步探讨了如何利用并查集进行路径压缩及优化处理。此外,还深入讲解了克鲁斯卡尔算法在求解最小生成树问题上的实现细节。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

1介绍

本节将记录两个问题,(1)并查集;(2)克鲁斯卡尔算法;。

这是贪心算法最后一节,可能不是所有的问题都与贪心算法有关,但是都是我认为有趣且比较重要的东西,有必要统一学习记录一下。可能我举例不太文雅,但绝对没有歧视和嘲讽任何群体的意思,只是为了让人印象深刻一些。

2并查集

2.1原理

2.1.1基础

我们在使用QQ或者其他社交软件的时候,会发现有一个好友推送的功能,也就是向你推荐你朋友的朋友。如果我们认定你朋友的朋友就是你的朋友,那么你的朋友圈就足够大了。倘若现在出现一个美女——如花,你非常想认识她/他,首先你不认识她,其次你朋友圈里也没有人认识如花和如花朋友圈里的人。


当然最直接的方法是直接去搭讪如花,这是最简单的方法。但是你想做的更高效一些,不能只考虑自己的幸福,因为你所处的是闷骚型屌丝群体,都是单身狗,而如花所处的朋友圈都是女神(原谅老郭),所以,有没有什么办法让这两个朋友圈都互相认识呢?

很简单,让你朋友圈的老大谦哥去跟对方朋友圈的老大老郭谈一谈,谈妥以后就可以联谊了,至于怎么联谊,从此以后,是让谦哥认老郭做朋友圈老大,还是让老郭认谦哥做朋友圈老大,这个也有讲究,稍后讨论。

可以总结一下这个过程:(1)你要认识如花;(2)你去找你的老大谦哥;(3)如花去找她的老大老郭;(4)发现你们两个的老大不是同一个人(谦哥和老郭),于是联合。

 

程序的一般结构,三个函数,一个数组:

(1) 一个数组:用来存贮当前数据的前驱结点,如:Arry[i]=j表示i的前驱是j,当Arry[i]=i时表示i是根结点。

(2) Find函数:寻找老大;

int Find(int num){
	while(Arry[num]!=num)
		num=Arry[num];
	return num;
}

(3) Connect函数:判断两人是否认识,是返回true,否返回false

bool Connect(int a,int b){
	if(Arry[a]==Arry[b])
		return true ;
	return false;
}

(4) Union函数:联合

void Union(int a ,int b){
	int aRoot=Find(a);
	int bRoot=Find(b);
	if(aRoot!=bRoot)
		Arry[aRoot]=bRoot;
}

2.1.2路径压缩

现在来优化一下,假如你的朋友圈是下面这种结构的,也就是说,你可能太宅了,朋友圈中很多牛逼的人物你接触不到。




如果是这样的话,你想促成这次联谊,就要一层一层往上去找老大,一个个问“咱们这个圈子谁比较有号召力啊?”,这样劳心费力,操碎了心,还不如直接去找谦哥来的快。


我们要想办法把路径压缩成上图这样比较好。我们在Find函数里压缩,修改前驱就可以了。当然还有很多高明的压缩手法此处就不拓展了。

路径压缩:

int Find(int num){
	int root=num,temp;
	while(Arry[root]!=root)
		root=Arry[root];
	//路径压缩
	while(num!=root){
		temp=Arry[num];
		Arry[num]=root;
		num=temp;
	}
	return num;
}
2.1.3畸形树

回到之前的一个问题,“从此以后,是让谦哥认老郭做朋友圈老大,还是让老郭认谦哥做朋友圈老大”。



之前默认的是Arry[aRoot]=bRoot,为什么不写成Arry[bRoot]=aRoot?这样一味跪舔女神有意义吗?毕竟不是所有的单身狗都是哈士奇,也有很多独狼,就像人生苦短,有人选择及时行乐,有人选择创造价值,无所谓对错,只是个人选择而已。

并查集告诉我们,一味地,盲目地妥协只能造成畸形,从树的角度而言,下列哪种树最合适一目了然。



给出一张正规的比较图。


优化代码,小树附在大树旁,要给每一个节点一个Size,那么所有结点的Size可以存进一个数组里。

Size数组的初始化,size[i]=j意思是以i为根结点的树中,有j个节点,初始化,每个结点是相互独立的,只包括自己:

for (int i = 0; i < max; i++)  
		 size[i] = 1;  

优化一下union函数:

void Union(int a ,int b){
	int aRoot=Find(a);
	int bRoot=Find(b);
	if(aRoot<bRoot){
		Arry[aRoot]=bRoot;
		size[bRoot]+=size[aRoot];
	}
	else {
		Arry[bRoot]=aRoot;
		size[aRoot]+=size[bRoot];
	}
}

给出一张正规的优化前(上)和优化后(下)的比较图,很明显优化后的查找速率更快。接下来,用并查集解决一个实际问题。


2.2问题

问题:今天是Ignatius的生日。他邀请了许多朋友。现在是吃晚饭的时间了。Ignatius想知道他至少需要多少桌。你必须注意到并不是所有的朋友都认识对方,而且所有的朋友都不想和陌生人呆在一起。

这个问题的一个重要规则是,如果我告诉你A知道BB知道C,意思是ABC互相了解,所以他们可以呆在一桌中。

例如:如果我告诉你A知道BB知道CD知道E,那么ABC可以呆在一桌,DE必须留在另一桌。所以伊格至少需要2桌。

2.3解析

详细的就不用解释了,只是最后输出的是树的数量,最初Count=M,每次合并(UnionCount就要减1,或者找根结点的个数,循环数组序号与值相等的即为根结点。

AC源代码:

#include<iostream>
using namespace std;
#define max 25
//数组
int Arry[1003];
int size[1003];
//find
int Find(int num){
	int root=num,temp;
	while(Arry[root]!=root)
		root=Arry[root];
	//路径压缩
	while(num!=root){
		temp=Arry[num];
		Arry[num]=root;
		num=temp;
	}
	return num;
}
//connect
bool Connect(int a,int b){
	if(Arry[a]==Arry[b])
		return true ;
	return false;
}
//union
void Union(int a ,int b){
	int aRoot=Find(a);
	int bRoot=Find(b);
	if(aRoot==bRoot)
		return ;
	if(aRoot<bRoot){
		Arry[aRoot]=bRoot;
		size[aRoot]+=size[bRoot];
	}
	else {
		Arry[bRoot]=aRoot;
		size[bRoot]+=size[aRoot];
	}

}
int main (){
	int n,m;//m为数据组数,n为结点个数
	int i,j,k;//循环用
	int x,y;//输入的两个参数
	int count;
	cin>>k;
	while(k--){
		cin>>n>>m;
		count=0;
		//初始化size数组
		for (i = 0; i <n; i++)  
			 size[i] = 1;
		//初始化Arry数组
		for(i=0;i<n;i++)
			Arry[i]=i;
		for(i=0;i<m;i++){
			cin>>x>>y;
			Union(x-1,y-1);
		}
		for(j=0;j<n;j++){
			if(Arry[j]==j)
				count++;
		}
		cout<<count<<endl;
	}
	return 0;
}

2.3结果


3克鲁斯卡尔算法

3.1问题

问题:求上图的最小生成树。

3.2分析

这个问题,我之前在写【动态规划(二)】时用普里姆算法解析过了,这次用克鲁斯卡尔算法详解一次。这跟并查集有很重要的联系。

(1)依据贪心算法思想,要求最小生成树,那么每次需要寻找最小权值的边,然后依据边确定相应的结点

(2)找最小权值的边很容易,只需要升序排序即可,问题在于一个结点对应着多个边,各种交错,很容易形成环路,那样求得的肯定就不是最小生成树了。

(3)我们回到并查集,去掉所有的边(其实是把他们放进一个数组里按升序排序),把他们看成一个个独立的结点,


(4)把每个结点看成一个独立的树,遍历边权值数组,每次寻找最小的,如果边两端的结点所在树的根节点不通,则说明二者为两棵树,这样合并之后就不会出现环路的情况了。

(5)那么我们来给出克鲁斯卡尔算法的框架(可能会有些细微不同,因为我在尽量把它跟并查集的框架保持一致,)

#define Maxvex 9//最多点数
#define Maxedge 81//最多边数
#define INFINITY 65536
typedef struct 
{
	int begin;
	int end;
	int weight;
}Edge;
int Parent[Maxvex];//记录每个根结点的前驱结点
//不影响树的结构只起记录作用:不考虑畸形树的问题
int Find(int num){
	int root = num, temp;
	while (Parent[root]!= root)
		root = Parent[root];
	return num;
}
bool Union(int s, int e){
	int sRoot = Find(s);
	int eRoot = Find(e);
	if (sRoot != eRoot){
		Parent[eRoot] = sRoot;//不需要考虑畸形树的问题
		return true;
	}
	return false;
}
void KrusKal(int M[Maxvex][Maxvex]){
	 Edge e[Maxedge],temp;
	 int i, j,t=0;

	 //初始化Parent数组,在大话数据结构中此处全初始化为0,原理是一样的,本人此处是为确保和上述所讲并查集保持一致
	 for (i = 0; i < Maxvex;i++){
		 Parent[i] = i;
	 }
	 //开始选边
	 for (i = 0; i < t;i++){
		 if (Union(e[i].begin,e[i].end))
			 cout << "(" << e[i].begin<< "," << e[i].end << "),"<<e[i].weight << endl;
	 }
}

(6)《大话数据结构》关于克鲁斯卡尔算法,跟我在此处的代码是有许多不同的。首先,它把union并入了void KrusKal(int M[Maxvex][Maxvex]),其次, Parent[i]它记录的是下一个结点,我们记录的是前驱Parent[eRoot] = sRoot;,从而该数组初始化可能不同,if (sRoot != eRoot)不需变动。再次,路径压缩与畸形树的优化可有可无,因为Parent不影响树的结构只起记录作用,最后给出源代码。

源代码:

#include<iostream>
using namespace std;
#define Maxvex 9//最多点数
#define Maxedge 81//最多边数
#define INFINITY 65536
typedef struct 
{
	int begin;
	int end;
	int weight;
}Edge;
int Parent[Maxvex];//记录每个根结点的前驱结点
//不影响树的结构只起记录作用:不考虑畸形树的问题
int Find(int num){
	int root = num, temp;
	while (Parent[root]!= root)
		root = Parent[root];
	//路径压缩
	while (num != root){
		temp = Parent[num];
		Parent[num] = root;
		num = temp;
	}
	return num;
}
bool Union(int s, int e){
	int sRoot = Find(s);
	int eRoot = Find(e);
	if (sRoot != eRoot){
		Parent[eRoot] = sRoot;//不需要考虑畸形树的问题
		return true;
	}
	return false;
}
void KrusKal(int M[Maxvex][Maxvex]){
	 Edge e[Maxedge],temp;
	 int i, j,t=0;

	 //初始化Parent数组,在大话数据结构中此处全初始化为0,原理是一样的,本人此处是为确保和上述所讲并查集保持一致
	 for (i = 0; i < Maxvex;i++){
		 Parent[i] = i;
	 }
	 //处理矩阵,化成Edge
	 for (i = 0; i < Maxvex;i++){
		 for (j = i+1; j < Maxvex; j++){
			 if (M[i][j] < INFINITY){
				 e[t].begin = i;
				 e[t].end = j;
				 e[t].weight = M[i][j];
				 t++;
			 }
		 }
	 }
	 //根据贪心思想,每次选取最小的边,先对Edge按升序排序
	 for (i = 0; i <t; i++){
		 for (j = i + 1; j <t; j++){
			 if (e[i].weight> e[j].weight){
				 temp = e[i];
				 e[i] = e[j];
				 e[j] = temp;
			 }
		 }
	 }
	 //开始选边
	 for (i = 0; i < t;i++){
		 if (Union(e[i].begin,e[i].end))
			 cout << "(" << e[i].begin<< "," << e[i].end << "),"<<e[i].weight << endl;
	 }
}
int main(){
	//构建图矩阵
	int MyGraph[Maxvex][Maxvex] = {
		{ 0, 10, INFINITY, INFINITY, INFINITY, 11, INFINITY, INFINITY, INFINITY },
		{ 10, 0, 18, INFINITY, INFINITY, INFINITY, 16, INFINITY, 12 },
		{ INFINITY, INFINITY, 0, 22, INFINITY, INFINITY, INFINITY, INFINITY, 8 },
		{ INFINITY, INFINITY, 22, 0, 20, INFINITY, 24, 16, 21 },
		{ INFINITY, INFINITY, INFINITY, 20, 0, 26, INFINITY, 7, INFINITY },
		{ 11, INFINITY, INFINITY, INFINITY, 20, 0, 17, INFINITY, INFINITY },
		{ INFINITY, 16, INFINITY, INFINITY, INFINITY, 17, 0, 19, INFINITY },
		{ INFINITY, INFINITY, INFINITY, 16, 7, INFINITY, 19, 0, INFINITY },
		{ INFINITY, 12, 8, 21, INFINITY, INFINITY, INFINITY, INFINITY, 0 } };
	KrusKal(MyGraph);
	return 0;
}

3.3结果

蓝色框为边出现顺序。




八个问题的源代码项目(19):https://github.com/AngryCaveman/C-Struct.git


克鲁斯卡尔算法是一种用于求解最小生成树的贪心算法,而并查集是一种用于维护元素分组信息的数据结构。它们在解决图论问题中经常一起使用。 克鲁斯卡尔算法的基本思想是,通过不断选择边权值最小且不会产生环路的边,逐步构建最小生成树。在实现过程中,使用并查集来判断两个节点是否属于同一个连通分量,以避免形成环路。 并查集是一种用于解决集合合并与查询问题的数据结构。它通过维护一棵树来表示每个元素所属的集合,其中每个节点指向其父节点,树的根节点表示该集合的代表元素。通过路径压缩按秩合并等优化策略,可以提高并查集的效率。 在克鲁斯卡尔算法中,首先将图中的所有边按权值从小到大排序,然后依次选择边进行判断。当选择一条边时,判断该边连接的两个节点是否属于同一个连通分量。如果不属于同一个连通分量,则选择该边,并将两个节点合并到同一个连通分量中。重复这个过程直到选择了 n-1 条边,其中 n 是图中节点的个数,即得到最小生成树。 克鲁斯卡尔算法的时间复杂度主要取决于排序边的时间复杂度,一般情况下为 O(ElogE),其中 E 是边的数量。并查集的操作时间复杂度为 O(α(n)),其中 α(n) 是一个非常慢增长的函数,可以认为是常数级别。因此,整个算法的时间复杂度为 O(ElogE)
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值