神奇的数据结构---并查集

并查集(Union Find)
何为集,集合,用树表示;何为并,集合的合并(union);何为查,查询元素所属集合(find)。

一、树的双亲表示法

使用树的双亲表示法来表示集合。如果两个结点具有相同的根结点,就认为这两个结点属于同一集合,一棵树表示一个集合。
树
这棵树对应的双亲表示法数组如下:
数组
如果我们想要知道元素 2 的父结点,则直接取 parent[2] 的值即可,parent[2] 的值为 5,所以元素 2 的父结点为元素 5。

如果树的根结点为 root,parent[root] 的值可以是 root,也可以是一个负数,只要能够表示 root 不以其它结点为父结点即可。当 parent[root] 存储一个负数时,可以包含树的结点数或深度等启发信息。

二、并查集的基础实现

public class UnionFind {
	private int[] parent;
	
	public UnionFind(int len) {
		parent = new int[len];
		for(int i = 0; i < len; ++i) {
			parent[i] = i;
		}
	}
	
	public int find(int a) {
		while(a != parent[a]) {
			a = parent[a];
		}
		return a;
	}
	
	public void union(int a, int b) {
		parent[find(a)] = find(b);
	}
}

固定将 a 的根合并到 b 的根上,极端情况下,合并后的树就是一个线性表,find 函数的时间复杂度较高。

三、并查集的优化实现

1. 优化 union 函数

借助树的结点数或深度等启发信息,决定究竟是 a 的根合并到 b 的根上,还是 b 的根合并到 a 的根上。通俗来讲,就是把小树合并到大树或低树合并到高树。

1.1. size 优化

size 指树的结点数。

public class UnionFind {
	private int[] parent;
	
	public UnionFind(int len) {
		parent = new int[len];
		for(int i = 0; i < len; ++i) {
			parent[i] = -1; // 相反数表示树的结点数
		}
	}
	
	public int find(int a) {
		while(parent[a] >= 0) {
			a = parent[a];
		}
		return a;
	}
	
	public void union(int a, int b) {
		if((a = find(a)) == (b = find(b))) return;
		if(parent[a] < parent[b]) {// b 挂在 a 身上
			parent[a] += parent[b];
			parent[b] = a;
		}else {
			parent[b] += parent[a];
			parent[a] = b;
		}
	}
}

1.2. depth 优化

depth 指树的深度。

public class UnionFind {
	private int[] parent;
	
	public UnionFind(int len) {
		parent = new int[len];
		for(int i = 0; i < len; ++i) {
			parent[i] = -1; // 相反数表示树的深度
		}
	}
	
	public int find(int a) {
		while(parent[a] >= 0) {
			a = parent[a];
		}
		return a;
	}
	
	public void union(int a, int b) {
		if((a = find(a)) == (b = find(b))) return;
		if(parent[a] < parent[b]) parent[b] = a;
		else parent[a] = b;
		if(parent[a] == parent[b]) --parent[b]; // 两棵树深度相同时,才更新深度信息
	}
}

2. 优化 find 函数

路径压缩优化,在查询时顺便压缩路径,让结点离根更近,提高下次查询的效率。

2.1. 折半压缩

public class UnionFind {
	private int[] parent;
	
	public UnionFind(int len) {
		parent = new int[len];
		for(int i = 0; i < len; ++i) {
			parent[i] = i;
		}
	}
	
	public int find(int a) {
		while(a != parent[a]){ // 查询时直接同步压缩
			parent[a] = parent[parent[a]];
			a = parent[a];
		}
		return a;
	}
	
	public void union(int a, int b) {
		parent[find(a)] = find(b);
	}

压缩过程像这个样子:
折半压缩

2.2. 完全压缩

2.2.1. 递归算法
public class UnionFind {
	private int[] parent;
	
	public UnionFind(int len) {
		parent = new int[len];
		for(int i = 0; i < len; ++i) {
			parent[i] = i;
		}
	}
	
	public int find(int a) { // 递归获取根结点,回溯更改路径上结点的父结点为根结点
		if(a != parent[a]) return parent[a] = find(parent[a]);
		else return a;
	}
	
	public void union(int a, int b) {
		parent[find(a)] = find(b);
	}
}
2.2.2. 递推算法
public class UnionFind {
	private int[] parent;
	
	public UnionFind(int len) {
		parent = new int[len];
		for(int i = 0; i < len; ++i) {
			parent[i] = i;
		}
	}
	
	public int find(int a) {
		int root = a;
		while(parent[root] >= 0) { // 先获取根结点
			root = parent[root];
		}
		int t;
		while(parent[a] >= 0) { // 依次让路径上的结点直接指向根结点
			t = parent[a];
			parent[a] = root;
			a = t;
		}
		return root;
	}
	
	public void union(int a, int b) {
		parent[find(a)] = find(b);
	}
}

压缩过程像这个样子:
完全压缩
虽然压缩的比较彻底,但步骤要更多。

3. 同时优化 find 函数和 union 函数

find 函数和 union 函数的优化并不冲突,可以自由组合前面提到的优化方法。
仅举两个例子:
一、 同时使用折半路径压缩和 size 优化

public class UnionFind {
	private int[] parent;
	
	public UnionFind(int len) {
		parent = new int[len];
		for(int i = 0; i < len; ++i) {
			parent[i] = -1;
		}
	}
	
	public int find(int a) {
		int t;
		while(parent[a] >= 0) {
			if((t = parent[parent[a]]) >= 0) parent[a] = t;
			a = parent[a];
		}
		return a;
	}
	
	public void union(int a, int b) {
		if((a = find(a)) == (b = find(b))) return;
		if(parent[a] < parent[b]) {
			parent[a] += parent[b];
			parent[b] = a;
		}else {
			parent[b] += parent[a];
			parent[a] = b;
		}
	}
}

二、同时使用完全路径压缩和 depth 优化

public class UnionFind {
	private int[] parent;
	
	public UnionFind(int len) {
		parent = new int[len];
		for(int i = 0; i < len; ++i) {
			parent[i] = -1;
		}
	}
	
	public int find(int a) {
		if(parent[a] >= 0) return parent[a] = find(parent[a]);
		else return a;
	}
	
	public void union(int a, int b) {
		if((a = find(a)) == (b = find(b))) return;
		if(parent[a] < parent[b]) parent[b] = a;
		else parent[a] = b;
		if(parent[a] == parent[b]) --parent[b];
	}
}

其实在路径压缩的时候,我们并没有维护 depth 的语义,它可能已经不再是树的深度,所以你可以在其它地方看到用 rank(秩)来回避这里说的 depth。虽然它已经不能正确的表示树的深度,但仍然具有优化 union 函数的启发意义。

四、不同优化策略的性能测试

测试代码统一为:

	static final int N = 10000000;
	public static void main(String[] args) {
		int len = N;
		int unionCount = N;
		int findCount = N;
		UnionFind uf = new UnionFind(len);
		Random r = new Random(1);
		int sum = 0;
		for(int iter = 0; iter < 10; ++iter) {			
			long start = System.currentTimeMillis();
			for(int i = 0; i < unionCount; ++i) {
				int a = r.nextInt(len);
				int b = r.nextInt(len);
				uf.union(a, b);
			}
			for(int i = 0; i < findCount; ++i) {
				int a = r.nextInt(len);
				uf.find(a);
			}
			long end = System.currentTimeMillis();
			System.out.println(end-start);
			sum += (end-start);
		}
		System.out.println("平均用时:"+sum/10);
	}

测试分十轮迭代,每次迭代都输出用时,最后计算平均用时。
集合规模为一千万,每次迭代随机做一千万次合并和一千万次查询。

size 优化和 depth 优化的性能对比

size 优化
depth 优化
对比来看 size 优化的效果要更好。

组合策略优化的性能对比

折半路径压缩和 size 优化
折半路径压缩和 depth 优化
完全路径压缩(递推)和 depth 优化
完全路径压缩(递归)和 depth 优化
从路径压缩和 depth 优化的组合来看,折半路径压缩优于完全路径压缩,完全路径压缩的递推算法优于其递归算法。
所以并查集最优的优化组合为折半路径压缩和 size 优化。

本文所有代码见 github

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章