《算法》第四版:Union-Find的高效算法

动态连通性

动态连通性用数学语言表示

假设最初有N个数字那么每个数字就为一个集合(即每个数都在不同的集合中),用一对数的方式表示先判断两个数是否属于一个集合如果不是就将两个数归并到一个集合中。

如果看不懂数学语言的表达方式可以看下面的表达方式

动态连通性表示

现在先假设一个问题:问题的输入是输入一列整数对,每对整数对表示两个数相连接,我们可以把两个数看作两个对象,我们可以用“相等”来理解
如果两个变量相等那它将会有一下特性

  1. 对称性
    如:p=q那么q=p
  2. 等价性
    如:因为p1=q1    q1=p2,所以p1=p2

两个等价的变量我们就可以把它看作一个等价的类,我们要做的就是找出输入的一对数中无意义的的对数如图所示
1
因为前面已经可以得到4 5 11属于一个集合中所以4 11这对数就属于无意义的数我们意义在于找出这些无意义的数对,对于这种小规模的数对我们可以很容易找出无意义的数对,但是如果问题的规模变得很大那找出无意义的数对将会变得很复杂。
为了进一步限定话题,我们会在后面用网络方面的术语,将对象成为触点,将整数对称为连接,将等价类称为连通分量简称分量。例如0——N-1的数称为有N个触点。

Quick-find

为了解决这样一个问题我们必须把问题精确的定义,所以先设计个API来把问题精准化

public class UF{
	private int[] id;
	private int count;
	UF(int N){}
	void union(int p,int q){/*在p和q之间建立一条连接*/}
	int find(int p){/*返回p所在的集合的标识符*/}
	boolean connection(int p,int q){/*如果p和q属于一个集合那么久返回true,如果不属于一个集合那么返回false*/}
	int count(){/*连通分量的数量*/}
}

可以通过API看到一个集合的标识符用的是一个int类型的数来表示,现在来研究一种简单的实现,我们把每个触点作为数组的索引,而不同索引所指向的值相同就判断其属于一个数组这样的话我们就可以写出find()方法的实现

union()方法把两个触点相连接,用find()方法可以返回一个触点所归属的集合所表示的标识符,connection()方法所返回的是一个布尔值,为了connection方法的顺利执行我们必须保证两个相同的触点调用find()方法后必须返回同样的标识符,所以我们可以很容易写出connection方法只需要写成find( p)==find(q);即可。

为了完成这样一个实现需要定义一种数据类型来存放触点的内容,这里就选择最普通的数组来说明。且用0——N-1的整数来作为触点的内容。

int find(int p){
	return id[p];
}

find()方法每次只访问一次数组,可以看到执行的效率为常数级别的

quick-union实现的方法主要在于union()方法的实现

void union(int p,int q){
	int p_value=find(p);
	int q_value=find(q);
	if(p_value==q_value) return;
	for(int i:id){
		if(i==p_value) i=q_value;
	}
	count--;
}

union()方法中为了防止两个属于相同集合的触点先判断两个触点是否属于同一个集合,如果不是要通过遍历数组来把一个集合的所有元素归并到另外一个集合中去,对于遍历数组是必要的因为我们不知道一个集合中到底包含了多少个触点和触点的位置,我们可以看到这种实现要频繁的访问数组不管是读还是写,对于这这样一种实现对于小规模的实验并无太大差别但是如果面对大规模的问题我们对于数组的访问次数将大到不可差交。

分析

在quick-find算法中,每次find()调用只需要访问数组一次,而归并两个分量的union()操作访问数组的次数在(N+3)到(2N+1)之间,由代码马上可以知道,每次connected()调用都会检查id[]数组中的两个元素是否相等,即会调用两次find()方法,归并两个分量的union()操作会调用两次find(),检查id数组中的全部N个元素并改变它们中1到N+1个元素的值。

完整实现
public class UF{
	private int[] id;
	private int count;
	UF(int N){
		count=N;
		id=new int[N];
		for(int i=0;i<N;i++){
			id[i]=i;
		}
	}
	void union(int p,int q){
	int p_value=find(p);
	int q_value=find(q);
	if(p_value==q_value) return;
	for(int i:id){
		if(i==p_value) i=q_value;
	}
	count--;
}
	int find(int p){return id[p];}
	boolean connection(int p,int q){return find(p)==find(q);}
	int count(){return count;}
}

Quick-Union

因为使用quick-find方法每次连接两个触点都会遍历一次数组,显然这样的方法是不行的,现在研究另外一个方法,假设我们连接两个触点时把其中一个触点的值改成另外一个触点的索引这样的话我们就可以通过一个触点找到另外一个和他相连接的触点,而另一个触点的值就指向自己,如图所示:
根触点
通过这样的方式我们就会发现在一个集合中从任何一个触点开始都能够找到一个指向自己的触点,我们称这个触点为根节点,那么我们就可以推出同个集合中的根触点肯定是唯一的,我们就可以把标识符看作根节点,如果根节点相同那么我们就认为他是同一个集合中的,当使用这种方式后我们每次连接就不用再遍历一次数组只需要通过触点值寻找下一个触点的索引直到找到根节点。那么我们就需要修改一下find()方法

int find(int p){
	while(p!=id[p]){
		p=id[p];
	}
	return p;
}

这样find()就能找到根节点,find()方法的实现也很简单直到找到根节点位置才把根节点这个触点返回,如果最坏的情况下find()方法将会访问数组2N+1次但是这种情况出现的机率可能微乎其微。
接下来就要实现union()方法了,这个方法我们还是按照之前的方法先比较两个触点是否属于同一个集合,如果属于同一个集合那么就退出。想要连接两个触点方法很简单就按照上面所说把其中一个触点的值改成另外一个触点的索引,例如:p与q相连接我们可以把触点p的值改成q的索引,当然反过来把q的值改成p的索引也可以,这样我们就可以很容易的写出union()的实现方法了。

void union(int p,int q){
	p_value=find(p);
	q_value=find(q);
	if(q_value==p_value) return;
	id[p]=q;  //使触点p的值为q的索引
}

我们可以看到union方法除开对数组进行比较我们只需要访问数组一次,这样这方法相对于之前quick-find每次都要对数组进行遍历看起来效率要高的很多。
一棵树的大小是它的节点的数量。树中的一个节点的深度是它到根节点的路径的链接数。树的高度是它的所有节点中的最大深度。

分析

使用quick-union方法实际上是把所有触点创建出一片森林,而每一个集合就是一个树,我们可以用下图来说明一下
在这里插入图片描述
这里把3 4 6 8所在的集合叫做一个树,因为他们不管哪个节点所指向的触点最终都是3,而多个树组成的称为森林。
quick-union只是quick-find的一种改进后的方式,我们只是把触点的值的表示方法改变了,但是所得出的效率有了大大的提高,但是这只是再某些情况下是这样的,我们试想一下quick-union最坏的情况将是什么,我们用下图来说明一下quick-union可能遇到最坏的情况
在这里插入图片描述
我们很快就会发现如此下去虽然union方法只访问数组一次,但是find()方法对数组的访问次数将会成线性增长,当有N个触点时最多的时候我们还是会访问数组2N+1次,这样的话我们可以得知在最坏的情况下运行时间将会是平方级别的。
可以假设在最坏的输入情况下quick-union和quick-find所需的运行时间都是平方级别的所以在最坏的情况下两者之间的效率是相差不大的。

完整实现
public class UF{
	private int[] id;
	private int count;
	UF(int N){
		count=N;
		id=new int[N];
		for(int i=0;i<N;i++){
			id[i]=i;
		}
	}
	void union(int p,int q){
		p_value=find(p);
		q_value=find(q);
		if(q_value==p_value) return;
		id[p]=q;  //使触点p的值为q的索引
	}
	int find(int p){
		while(p!=id[p]){
			p=id[p];
		}
	return p;
	}
	boolean connection(int p,int q){return find(p)==find(q);}
	int count(){return count;}
}

加权quick-union

在了解了quick-union后我们还是会发现,在某种最坏的情况下还是会使得运行时间变成平方级别的,那么我们还能够有更好的算法吗,答案是当然的,我们可以在quick-union的基础上再次进行改进,我们就来讨论一下加权quick-union。
假设我们在每次连接两个分量时都对分量的大小进行判断,每次都把小的分量连接到大的分量上,如此一来就能有效的控制树的最大深度。按照quick-union的方法我们不再修改find()方法,但是我们为了对分量进行计数我们就得定义一个新的数组用来对分量进行计数。
接下来我们就直接展现加权quick-union算法的实现

完整实现
public class WeightQuickUnionUF{
	private int[] id;
	private int[] sz;
	private int count;
	public WeightQuickUnionUF(int N){
		count=N;
		id=new int[N];
		for(int i=0;i<N;i++)
			id[i]=i;
		sz=new int[N];
		for(int i=0;i<N;i++)
			sz[i]=1;
	}
	public count(int p,int q){return count;}
	public boolean connected(){
		return find(p)==find(q);
	}
	public find(int q){
		while(id[q]!=q) q=id[q];
	}
	public int union(int p,int q){
		int p_value=find(p);
		int q_value=find(q);
		if(p_value==q_value) return;
		if(sz[p]>sz[q]){id[p_value]=q_value;  +-sz[p_value];}
		else           {id[q_value]=p_value;  +=sz[q_value];}
		count--;
	}
}	
分析

基本使用这种方法可以很大程度的压缩树的最大深度,但是对于这种实现还是有最糟糕的情况,试想一下每次链接的分量都是相同的,那么在这种最坏的情况下当前的算法也能保证运行时间是对数级别的,那么加权quick-union与上面两个不同于它可以用来处理大规模的问题。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值