YAZONG 我的开源

(数据结构)文件传输File Transfer集合并查集应用-C语言实现

File Transfer题目:给了一大堆计算机,按照要求给计算机连接网络,一边随时来查询,任何两台计算机之间是否可以联网/传输文件,是否已经属于同一个集合。

这是一道非常典型的集合并查集应用题。

“此文内容来源于=>中国大学MOOC=>数据结构(浙江大学:陈越、何钦铭)”

https://www.icourse163.org/learn/ZJU-93001#/learn/content”

集合的简化表示

image.png

此例是否可以更加简单?

此集合用数组来表达,数组类型是一个结构体,data是集合类型,parent相当于指针,检查父结点,如是根结点,那么下标为-1。

浪费时间的FOR循环:

如果要知道某个集合元素X属于的集合,data=X,在数组中查询是一个线性的过程,最坏的情况,数组中有N个元素,查数组中的最后一个元素,find函数反反复复找N遍,时间复杂度为O(N的平方)。

定义的data是一个整数,是否真的有必要专门一个数据阈去存储吗?

任何有限集合都可以被一一映射为0到N-1,计算机是从1开始编号,那么可以把1映射成0,以此类推,只要某个集合中的元素是有限个,就可以映射每一个元素。

这个问题就可以简化,把集合里的每个元素映射成数组的下标( 元素值就是下标 )去代表,那么数据阈data就可以去掉。

image.png

这里定义一个数组来表示一个集合。这里两个集合可以用这一个整型数组来存储。

集合里的每个元素值就是数组下标,S[元素值]就是每个元素的父结点元素值。

此数组中的集合数量可以根据-1的个数来衡量。

S[0]元素的父结点是元素6。

S[1]元素的父结点是元素6。

S[2]元素的父结点是没有的,默认根结点,父结点默认值-1。

S[3]元素的父结点是元素4。

S[4]元素的父结点是元素2。

S[5]元素的父结点是元素2。

S[6]元素的父结点是没有的,默认根结点,父结点默认值-1。

/*
集合的简化表示
*/
/*集合元素可以用非负整数表示,从0到N-1来表示,根结点的下标是一个整型.*/
typedef int ElementType;
/*默认用根结点的下标作为集合名称*/
typedef int SetName;
/*SetType是集合的类型,集合类型定义为整型数组.*/
typedef ElementType SetType[MaxSize];
/*
原data设置成整型ElementType
经过上述简化,当查找一个元素X,不用再去扫描整个数组,
而X这个元素直接就是数组的下标,S[X]就是每个元素对应的父结点元素值.
*/
SetName Find(SetType S, ElementType X){
	/*默认集合元素全部初始化为-1*/
	/*
	如果父结点是大于等于0的,那么继续往上找.
	如果找到-1,那说明上面没有父结点了,X就是这个根.
	*/
	for( ; S[X] >= 0; X = S[X]);
	return X;
}
/*
这里保证Root1和Root2是不同集合的根结点.
这里省略了Root1和Root2是不同集合的根结点时才做合并操作,否则啥都不做直接返回.
这里就是把参数合法性的检查交给了用户去做.
*/
void Union(SetType S, SetName Root1, SetName Root2){
	/*这里默认Root1和Root2是不同集合的根结点*/
	S[Root2] = Root1;
}

题意理解与程序框架搭建

image.png

输入样例1:

第1行:5计算机的个数。

image.png

输入样例1:

第2行:字符C,表示check,查计算机3和计算机2之间是否连通,no。

image.png

输入样例1:

第3行:字符I,表示input,查计算机3和计算机2之间加网线连通。

image.png

输入样例1:

第4行:字符C,表示check,查计算机1和计算机5之间是否连通,no。

image.png

输入样例1:

第5行:字符I,表示input,查计算机4和计算机5之间加网线连通。

image.png

输入样例1:

第6行:字符I,表示input,查计算机2和计算机4之间加网线连通。

image.png

输入样例1:

第7行:字符C,表示check,查计算机3和计算机5之间是否连通,yes。

输入样例1:

第8行:S,输入结束。

对整个网络做判断,此集合中,任意两台计算机是直接或间接连通的,只有计算机1孤立,但是它自己也认为自己是连通的。

image.png

image.png

输入样例2:

各种执行方式同”输入样例1”。

此时,仅在计算机1和计算机3连接,那么计算机1和计算机5就是连通的了。

image.png

此时,计算机之间通过一个完全连通的网络相连接。

image.png

#include<stdio.h>
/*
集合并查集的简化
运行的关键取决于find函数与union函数.
*/
int main(void){

	SetType S;
	int n;
	char in;
	scanf("%d\n", &n);
	/*
	初始化集合
	*/
	Initialization(S, n);
	do{
		scanf("%c", &in);
		switch(in){
			case 'I':
				/*
				集合S初始化.
				把两台计算机的编号读进来,检查两台计算机是否都连接成功(find),没连接好则连接(union).
				*/
				Input_connection(S);
				break;
			case 'C':
				/*
				读入待查询的计算机编号,是否在集合中(find).
				*/
				Check_connection(S);
				break;
			case 'S':
				/*
				整个网络是否连通.
				数一下这个集合中有多少根结点,即:多少个联通的集合.
				*/
				Check_network(S, n);
				break;
		}
	}while(in != 'S');

	return 0;
}


void Input_connection(SetType S){
	ElementType u, v;
	SetName Root1, Root2;
	scanf("%d %d\n", &u, &v);
	/*
	读入计算机编号的数组下标u-1
	*/
	Root1 = Find(S, u - 1);
	/*
	读入计算机编号的数组下标v-1
	*/
	Root2 = Find(S, v - 1);
	if(Root1 != Root2){
		/*
		合并分属的不同集合
		*/
		Union(S, Root1, Root2);
	}
}

void Check_connection(SetType S){
	ElementType u, v;
	SetName Root1, Root2;
	scanf("%d %d\n", &u, &v);
	Root1 = Find(S, u - 1);
	Root2 = Find(S, v - 1);
	/*
	俩集合一样,那么已经联通.
	*/
	if(Root1 == Root2){
		printf("yes\n.");
	}else{
		printf("no\n.");
	}
}

void Check_network(SetType S, int n){
	int i, counter = 0;
	for(i = 0; i < n; i++){
		/*
		结点值小于0,多个根结点,意味着有未联通的集合.
		*/
		if(S[i] < 0){
			counter++;
		}
	}
	/*
	只有一个根结点,那么表示整个集合都联通了.
	否则,只输出联通的集合个数.
	*/
	if(counter == 1){
		printf("The network is connected.\n");
	}else{
		printf("There are %d components.\n", counter);
	}
}



/*
题外话:
集合S初始化.
每个元素都是独立的,每个元素都是自己的根结点,全部初始化为-1.
*/
void Input_connection(SetType S, int n){
	for(i = 0; i < n; i++){
		S[i] = -1;
	}
}

TSSN的实现 (原始优化)

(TSSN:too simple sometimes naive)

第1版测试:

image.png

#这里是后续简化版本,union方法中:Root2第2个集合指向Root1第1个集合的根结点.

PTA测试结果:

image.png

第2版测试:

image.png

#这里是后续简化版本改造,union方法中:Root1第1个集合指向Root2第2个集合的根结点.

PTA测试结果:

image.png

第3版测试:

image.png

#这里是未简化前的原始版本进行PTA测试结果.

红色标记依然带来整个find函数的时间复杂度,如find到N次,时间复杂度为O(N的平方)。

====对于此效率的破坏,能否更快一点呢?继续下节”按秩归并”。

按秩归并 (union改进)(更快)

image.png

第1行:1指向2。

第2行:1指向3。

......

第n行:1指向n。

这里总会把第二个集合find(1)指向第一个集合。

这棵树在越长越高,做union,整棵树变成了单链表。

这一列操作,工作量最大的就是反反复复的find(1),每次都要从最底下的结点开始,一直往上去找其根结点,重复了N-1次之后,工作量就积累起来了。

比如当N=10的4次方时,N的平方数量级的算法肯定就会超时了。

这里设计两组数据来卡这件事,因为在标准实现中,是让第二个集合指向第一个集合,当然可能会有,第一个集合指向第二个集合。

所以还需设计另外一组数据,find顺序倒过来,不管是集合1指向集合2,还是集合2指向集合1,总有一组数据会卡的住。

方法:树的高度(O(N的X次方))

image.png

当两颗树高度不一致时,合并之前,最大高度是3。

image.png

树合并之后,最后,树的高度等于最高的这棵树的高度+1。

image.png

树如何才能不变高?

把矮的树贴到高树上,跟树的高度+1是无关的,而高的树的高度无变化。

image.png

在矮的树贴到高树上前,需要比较二者树的高度,除了根结点,在结构体中加一个阈值来存放其高度是合适的,现在存的都是父结点的下标,以元素值为下标存放,根结点-1除外。

image.png

这里把根结点设置成-1,-1不可能是任何一个元素的下标,可以和其他结点区分开了,任何负数也可以,可以提升此值的意义。

image.png

这里把根结点设置成”-树高”,但初始化时依然为-1。

一方面是负数标识,另一方面,数值代表树本身的高度。

因为初始认为每一台计算机都是独立的根结点,所以树的高度开始全部都为-1。

image.png

/*
按秩归并方法:树的高度
注意:如果Root2的高度>Root1的高度,A>B,那么-A<-B.
*/
void Union(SetName Root1, SetName Root2){
	if(S[Root2] < S[Root1]){
		S[Root1] = S[Root2];
	}else{
		if(S[Root1] == S[Root2]){
			/*
			要注意,这里存的是"负数-树高",如果树的高度要+1,那么这里要-1.
			*/
			S[Root1]--;
		}
		S[Root2] = S[Root1];
	}
}

根据俩树的高度做判断,无论哪几个集合的高低,只要高度不同,那么作为最后的结果,最终树的高度是不变的。当俩树的高度相同,树的高度必须+1。

要注意,这里存的是”负数-树高”,如果树的高度要+1,那么这里要-1。

方法:树的规模(推荐)(NLog以2为底N的对数)

image.png

需要记录树的规模,即元素的总个数,以负数的形式存到根结点。

/*
按秩归并方法:树的规模(推荐)
*/
void Union(SetType S, SetName Root1, SetName Root2){
	/*
	集合2指向集合1.
	把小集合贴到大集合中.
	*/
	if(S[Root2] < S[Root1]){
		S[Root2] += S[Root1];
		S[Root1] = S[Root2];
	}
	/*
	集合1指向集合2.
	把小集合贴到大集合中.
	*/
	else{
		S[Root1] += S[Root2];
		S[Root2] = S[Root1];
	}
}

按高度归并:大树高度可能不需要改变。

按规模归并:大树的规模是会改变的,变成两颗树的和。

最坏情况下,不管是按高度还是按规模,最快情况都是把两棵树按高度或规模合并到一起,这种时候树的高度就不得不长大,如果整棵树的规模是N的话,那么这种合并会进行多少次呢?

最坏情况下的树高,Log以2为底N的对数,即使都去访问这颗树里面最下面的结点,重复N次的话,整个时间复杂度是NLog以2为底N的对数,而不是N的X次方。

========能否更快一点呢?继续下节”路径压缩”。

路径压缩 (find改进)(最快)

image.png

此find函数工作方式:判断传进的元素X,S[X]<0,意味着X已经是根了,直接返回X元素值,否则X还有父结点。

红色标记语句三个功能:

找到X的父结点S[X]所属集合的根(X和S[X]所属同一个集合)。

把根变成X的父结点,即:找到集合名称变成X的父结点

调用返回SetName,即返回根。

image.png

棕色结点为根结点。

第一次当调用find[X]后,首先X不是此集合的根,然后去集合中,递归去找X的父结点的根,把find[X]压入系统堆栈中,逐步解决X父结点的问题,再次父结点调用时,依然要检查X的父结点是否是根结点,不是就把X压入系统堆栈中,继续上述步骤往上走,一直到找到了真正的根结点,运行到find这个结点的时候,会发现S[X]是这个这个结点X,X小于0,直接返回X这个结点的父结点给X结点。

image.png

第1次返回结点,在返回之前,先把棕色结点设置为X的父结点。

对应第一个案例的状态。

此棕色结点本来就是X的父结点,所以这一步等于什么都没做,棕色父结点被return回去,并返回到下述蓝色结点这一层次,返回的仍然是棕色根结点。

image.png

image.png

后续,继续上述步骤往上走,也把棕色结点变成其父结点,最终把这个棕色父结点返回给最下面的X结点,直接变成了X结点的父结点,这就完成了一个路径压缩的过程。

image.png

什么叫做路径压缩?非常直观的:在调用find函数的时候,不仅仅是X开始一路向上查找它的根结点,同时还把这个路径上的每个结点都直接贴到了根结点的下面,所以整棵树,至少这条路径被一下子压短了,这就叫路径压缩。

如果只调用一次find[X]时,其实只要把棕色结点返回即可,做上述那么多步骤好处在于,如果在程序中反复调用find,调用一次find麻烦一些,但是还需要find这条路径其他结点的话,就会变得非常划算。

需要注意的是,如果路径中的压缩结点太多的话,递归函数可能会使系统爆掉,但伪递归不会,因为机器的编译器会自动将伪递归变成循环。

为什么要做路径压缩呢?

建议把按秩归并算法和路径压缩算法,在PTA进行测试。

引理(路径压缩与按秩归并)

image.png

T(M,N)是最坏情况的时间复杂度。

M是带路径压缩的查找次数。

N-1是按秩归并的查找次数。

假设M大于N-1,其实是比较自然的假设。

在一次union之前总要做两次find,结论是说存在K1与K2,使得有这样一个不等式。

最坏情况的时间复杂度是Ma(阿尔法)函数,此函数跟M和N都有关系,Ma(阿尔法)函数既是这个时间复杂度的上限也是下限。

如果用渐进方式来表示的话,那这就是一个θ(SEI塔)的关系,是θ(SEI塔)(M)*a(阿尔法)。

image.png

这个a(阿尔法)函数跟Ackermann(艾克们)函数紧密联合在一起。

Ackermann(艾克们)函数有很多不同的定义,也是一个递归的定义,此函数有趣在于它可以以飞快的速度长到很大,它增长的速度有多快算以下这个A(2,4)。

image.png

非常恐怖的数字,幸亏a(阿尔法)不是这么大的数字。

此a(阿尔法)跟此数字如何联系在一起,以及Ackermann(艾克们)更多性质,更多看上述链接。

a(阿尔法)函数的定义如下:

image.png

image.png

image.png

image.png

image.png

其实是否做路径压缩,根本区别在于在查找次数M前面,乘以常数还是一个LogN的问题,

当然,LogN是N的一个递增函数,当N趋于无穷大的时候,LogN是会趋于无穷大的,而常数不会变得,所以从理论效率来讲,做路径压缩比不做路径压缩要快的多。

就此题来说,N只有10的4次方这么大,LogN其实是一个很小的数字,所以比不出是否做路径压缩算法的优劣的。

如果把N再提高2个数量级,就可以看出是否做路径压缩算法的效率,当然编程语言也不同。

所以这道题的测试数据,要想充分体现路径压缩的优势是很难的。

小总结

image.png


标题:(数据结构)文件传输File Transfer集合并查集应用-C语言实现
作者:yazong
地址:https://blog.llyweb.com/articles/2023/09/27/1695771873988.html