[算法系列] 数据结构并查集union-find sets多图详解:介绍+2种实现+多种优化

1. 为什么会有并查集?并查集做啥用的?

假设有n个村庄,有些村庄之间有连接的路,有些村庄之间没有:

[算法系列] 数据结构并查集union-find sets多图详解:介绍+2种实现+多种优化_第1张图片

设计一个数据结构,能够快速执行2个操作

  • 查询2个村庄之间是否有连接的路
  • 连接2个村庄

对于上面这两个需求,可以用我们已知的一些数据结构进行完成(数组、链表、平衡二叉树、集合),比如用一个数组维护所有有连接的村庄。不过这样查询find和连接union的时间复杂度都为O(n)级别的。

  • 而并查集就是专门做这两件事的数据结构:“并”union 和 “查” find
  • 并且其查询、连接的均摊时间复杂度最佳可优化到O(α(n)),α(n)<5

2. union set简要介绍

并查集也叫做不相交集合 Disjoint Set,其包括两个核心操作:

  • 查找 find : 查找两个元素所在的集合(这里的集合并不是特指Set这种数据结构,而是指广义上的)
  • 合并 Union:将两个元素所在的集合合并为一个集合

有两种常见的实现思路:

  • Quick Find
    • 查找 find 的时间复杂度;O(1)
    • 合并 Union 的时间复杂度: O(n)
  • Quick Union
    • 查找(Find) 的时间复杂度: O(logn), 可以优化至0( α(n), α (n) < 5
    • 合并(Union) 的时间复杂度:O(logn), 可以优化至0( α(n), α (n) < 5

3. union set 存储形式

如果并查集处理的数据都是整型,那么可以用整型数组来存储数据。

我们用parents[] 数组存储,其中parents[i] 表示i号元素的父节点的下标。 那么如何看他们是不是属于一个集合呢? 看他们属是否具有相同的根节点。也就是说,一棵树表示一个集合。

因此,并查集是可以用数组实现的树形结构(二叉堆、优先级队列也是可以用数组实现的树形结构)

[算法系列] 数据结构并查集union-find sets多图详解:介绍+2种实现+多种优化_第2张图片

4.接口定义

/*
查找v所属的集合,返回根节点
*/
int find(int v);

/*
合并v1,v2所属的集合
*/
void union(int v1, int v2);

/*
检查v1,v2是否属于同一个集合,即查看根节点是否相同
*/
boolean isSame(int v1,int v2){
    return find(v1) == find(v2);
}

5. 初始化

  • 初始化时,每个元素各自属于一个单元集合

在这里插入图片描述


public abstract class UnionFind {
	protected int[] parents;
	
	public UnionFind(int capacity) {
		 if(capacity < 0 )
			 throw new IllegalArgumentException("capacity must be >=1");
		 
		 parents = new int[capacity];
		 for(int i = 0 ; i < capacity ; i ++)
			 parents[i] = i;
	}
	...

6. Quick Find 模式的实现

  • union(a,b) 我们规定是将左边a的父节parent[a]点改为右边b的父节点parent[b]

[算法系列] 数据结构并查集union-find sets多图详解:介绍+2种实现+多种优化_第3张图片

  • 注意在上述 union(1,2) 中, 1的父节点是0. 同时0 的父节点也是0, 因此在将1的父节点置为2时, 也得把0的父节点也置为2。因此,我们在实现中,会遍历数组,去寻找那些和1有着相同的父节点的元素,将它们统一换成新的父节点(这也是为啥quick find型union操作复杂度为O(n))

[算法系列] 数据结构并查集union-find sets多图详解:介绍+2种实现+多种优化_第4张图片

我们发现,这种流程下来,数组里面存着的元素都是根节点,因此find函数直接返回parentsp[v] .

也就是像上图那样,这棵树很扁(高度最多为2)

[算法系列] 数据结构并查集union-find sets多图详解:介绍+2种实现+多种优化_第5张图片

这样就很能体现其find快的优势,因为其父节点就是该集合的根节点,直接存在parents[i] 当中,如上图中:

  • find(0) = 2
  • find(1) = 2
  • find(3) = 3
  • 时间复杂度: O(1)

步骤在前面已经讲明,那么代码如何实现的呢?

下面是quick find 模式的并查集数据结构:

  1. 并查集公共抽象基类,用于定义一些公共操作和函数
/**
 * 并查集抽象基类
 * @author lowfree
 *
 */
public abstract class UnionFind {
	protected int[] parents;
	
	public UnionFind(int capacity) {
		 if(capacity < 0 )
			 throw new IllegalArgumentException("capacity must be >=1");
		 
		 parents = new int[capacity];
		 for(int i = 0 ; i < capacity ; i ++)
			 parents[i] = i;
	}
	
	/**
	 * 查找所属于的集合,返回根节点
	 * @param v
	 * @return
	 */
	public abstract int find(int v) ;

	protected void rangeCheck(int v) {
		if(v < 0 || v > parents.length)
			throw new IllegalArgumentException("v should in the range of capactiy!");
	}
	
	
	/**
	 * 检查v1和v2是否属于相同集合,即检查根节点是否相等
	 * @param v1
	 * @param v2
	 * @return
	 */
	public boolean isSame(int v1 , int v2) {
		return find(v1) == find(v2);
	}
	
	
	/**
	 * 合并两个集合,规定是将左边的置到右边的集合中
	 * @param v1
	 * @param v2
	 */
	public abstract void union(int v1 , int v2);
	
}
  1. 下面是刚刚介绍的quick find 模式的实现:
/**
 * 并查集 quick find 
 * @author lowfree
 */
public class UnionFind_QF extends UnionFind{
	public UnionFind_QF(int capacity) {
		super(capacity);

	}
	
    /*
   	父节点就是根节点
    */
	//O(1)
	public int find(int v) {
		rangeCheck(v);
		return parents[v];		
	}

    
    /*
    将v1所在集合的所有元素,一个一个地嫁接到v2的父节点去
    */
	//O(n)
	public void union(int v1 , int v2) {
		int p1 = parents[v1];
		int p2 = parents[v2];
		
		//本来就是一个集合了
		if(p1 == p2) return;
        
		parents[v1] = p2;
		for(int i = 0 ; i < parents.length ; i ++) {
			if(parents[i] == p1)
				parents[i] = p2;
		}	
	}

7. Quick Union

7.1 union(a,b)

合并操作步骤:找到两个根节点, 我们规定将左边a的根节点 “指向”(的父节点置为) 右边b的根节点

[算法系列] 数据结构并查集union-find sets多图详解:介绍+2种实现+多种优化_第6张图片
[算法系列] 数据结构并查集union-find sets多图详解:介绍+2种实现+多种优化_第7张图片

相比前面的quick find 模式

  • 这一次我们是将左边根节点的父节点置为右边的父节点。所以在实现中,我们需要向上地去找到根节点。
  • 同时,我们不需要再像前面一样去找那些具有相同父节点的元素,因为整个根节点都作为了右边结点的子节点,集合原封不动搬了过去

7.2 find 操作

find操作需要不断地循环往上找根节点

[算法系列] 数据结构并查集union-find sets多图详解:介绍+2种实现+多种优化_第8张图片

  • find(0) = 2
  • find(1) = 2
  • find(4) = 2

这种情况下,那么代码如何实现的呢?


/**
 * 并查集 quick union
 * @author lowfree
 *
 */
public class UnionFind_QU extends UnionFind{

	public UnionFind_QU(int capacity) {
		super(capacity);
		// TODO Auto-generated constructor stub
	}
	
    /*
    通过parent链条不断地向上找
    */
	//O(logn)
	@Override
	public int find(int v) {
		rangeCheck(v);
		
		//不断向上地去找根节点
		while(v != parents[v])
			v = parents[v];
		
		return v;
	}
	
    /*
    将v1的根节点嫁接到v2的根节点上
    */
	//O(logn)
	@Override
	public void union(int v1, int v2) {
		//左边根节点
		int p1 = find(v1);
		//右边根节点
		int p2 = find(v2);
		
		if(p1 == p2) return;
		//左边根节点的父节点变为右边根节点
		parents[p1] = p2; 	
	}
}

7.3 小结一下呗:

首先两个函数:

  • find(v) : 寻找出v所在集合,即寻找v的根节点
  • union(v1,v2):将v1,v2所在集合连接起来

然后两个模式

  1. 对于Quick-Find:
    • find(v):寻找根节点即是父节点,所以直接返回parent[v] 即可
    • union(v1,v2):将v1所在集合中的所有元素,一个一个地嫁接到v2所指的父节点上去
  2. 对于Quick-Union:
    • find(v): 不断地向上连接去找根节点
    • union(v1,v2):将v1的根节点直接嫁接到v2的根节点上去

8. Quick Union 优化

在Union的过程中,可能会出现树不平衡的情况,甚至退化成链表

[算法系列] 数据结构并查集union-find sets多图详解:介绍+2种实现+多种优化_第9张图片

有2种常见的优化方案

  • 基于size的优化: 元素少的树嫁接到元素多的树
  • 基于rank的优化: 矮的树嫁接到高的树

8.1基于Size的优化:需要一个sizes数组维护各个集合的size大小

/**
 * 并查集 quick union
 * 基于Size的优化
 * @author lowfree
 *
 */
public class UnionFind_QU_S extends UnionFind_QU{

	private int[] sizes; //维护集合大小的数组
	
	public UnionFind_QU_S(int capacity) {
		super(capacity);
		
		sizes = new int[capacity];	//初始化时,各个集合的size都为1
		for(int i = 0 ; i < sizes.length; i ++) 
			 sizes[i] = 1;
	}
    
	//find函数不变
	@Override
	public int find(int v) {
		rangeCheck(v);
		//通过parent链条不断地向上找
		while(v != parents[v])
			v = parents[v];
		return v;
	}


	//O(logn)
	@Override
	public void union(int v1, int v2) {
		//左边根节点
		int p1 = find(v1);
		//右边根节点
		int p2 = find(v2);
		
		if(p1 == p2) return;
		
		//将size小的集合嫁接到大的集合根节点上
		if(sizes[p1] <sizes[p2]) {
			parents[p1] = p2; 
			sizes[p2] += sizes[p1];
		}else {
			parents[p2] = p1;
			sizes[p1] += sizes[p2];
		}	
	}
}

8.2 基于rank的优化: 需要维护集合的高度:

ranks数组,每次将矮的树嫁接到高的上面去

[算法系列] 数据结构并查集union-find sets多图详解:介绍+2种实现+多种优化_第10张图片

union(1,4) :

[算法系列] 数据结构并查集union-find sets多图详解:介绍+2种实现+多种优化_第11张图片

/**
 * 并查集 quick union
 * 基于Rank的优化
 * @author lowfree
 *
 */
public class UnionFind_QU_R extends UnionFind_QU{
	
	private int[] ranks;
	
	public UnionFind_QU_R(int capacity) {
		super(capacity);
		
		ranks = new int[capacity];
		for(int i = 0 ; i < ranks.length ; i++)
			ranks[i] =1;
	}
	
	//find函数不变
	@Override
	public int find(int v) {
		rangeCheck(v);
		
		//通过parent链条不断地向上找
		while(v != parents[v])
			v = parents[v];
		
		return v;
	}

	//将v1所在集合的所有元素都嫁接到v2上 
	//O(logn)
	@Override
	public void union(int v1, int v2) {
		//左边根节点
		int p1 = find(v1);
		//右边根节点
		int p2 = find(v2);
		
		if(p1 == p2) return;
		
		if(ranks[p1] < ranks[p2]) {
			parents[p1] = p2;
			//这里不需要对ranks改动
		}else if(ranks[p2] < ranks[1]) {
			parents[p2] = p1;
		}else {	//两树一样高
			parents[p1] = p2;
			ranks[p2] ++;	//此时高度会加1
		}	
	}
}

9.路径压缩path Compression

[算法系列] 数据结构并查集union-find sets多图详解:介绍+2种实现+多种优化_第12张图片

/**
 * 并查集 quick union
 * 	基于Rank的优化
 * 	路径压缩
 * @author lowfree
 *
 */
public class UnionFind_QU_R_PC extends UnionFind_QU_R{

	public UnionFind_QU_R_PC(int capacity) {
		super(capacity);
	}
	
	@Override
	public int find(int v) {
		rangeCheck(v);
		
		
		if(parents[v] != parents[v]) {
			 parents[v] = find(parents[v]);
		}
		return parents[v];
	
	}

	//union不变
}
  • 路径压缩使得路径上所有节点都指向根节点,所以实现成本稍高
  • 还有2种更优的做法,但不能降低树高,实现成本部也比路径压缩低

10. 路径分裂 Path Spliting

路径分裂:使路径.上的每个节点都指向其祖父节点(parent的parent)

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-u2hrKsLL-1590674681455)(union set.assets/image-20200528214325616.png)]

	@Override
	public int find(int v) {
		rangeCheck(v);
		
		while(v != parents[v]) {
			int p = parents[v];		//先要保存一下其父亲,否则等下节点丢失
			parents[v]= parents[parents[v]]; //v指向祖父节点
			v = p;
		}
		return parents[v];
	}

11.路径减半

路径减半:使路径上每隔一个节点就指向其祖父节点(parent的parent)

[算法系列] 数据结构并查集union-find sets多图详解:介绍+2种实现+多种优化_第13张图片

/**
 * 并查集 quick union
 * 	基于Rank的优化
 * 	路径分裂
 * @author lowfree
 *
 */
public class UnionFind_QU_R_PH extends UnionFind_QU_R{

	public UnionFind_QU_R_PH(int capacity) {
		super(capacity);
		// TODO Auto-generated constructor stub
	}
	
	@Override
	public int find(int v) {
		rangeCheck(v);
		
		while(v != parents[v]) {
			parents[v]= parents[parents[v]]; //v指向祖父节点
			v = parents[v]; //v 变为它的祖父节点
		}
		return parents[v];
	}
    
	//union不变
}

11. 小结

[算法系列] 数据结构并查集union-find sets多图详解:介绍+2种实现+多种优化_第14张图片
使用路径压缩,分裂或减半 + 基于rank或者size的优化可以确保每个操作的均摊时间复杂度为O(α(n)),α(n)<5

建议的搭配

  • Quick Union
  • 基于rank的优化
nionFind_QU_R_PH extends UnionFind_QU_R{

	public UnionFind_QU_R_PH(int capacity) {
		super(capacity);
	}
	
	@Override
	public int find(int v) {
		rangeCheck(v);
		
		while(v != parents[v]) {
			parents[v]= parents[parents[v]]; //v指向祖父节点
			v = parents[v]; //v 变为它的祖父节点
		}
		return parents[v];
	}
	
	//union不变
}

你可能感兴趣的:(数据结构/算法)