Simon-【深入理解数据结构】有根树的不同实现① —— 并查集

树,就是节点数V和边数E满足V-E=1的图。有根树,就是指定其中一个节点为根节点的树。二叉树是最常见的有根树。实现一个二叉树,一般会选择采用如下的结构:

private class Node {
	private int val;
	private Node left, right;
	private Node parent;
}

如果是更一般的多叉树,则把多个儿子节点串成一个数组:

private class Node {
	private int val;
	private Node[] children;
	private int nrChildren; //子节点个数
	private Node parent;
}
以上的实现,是直接依据树的抽象数据类型(Abstract Data Type, ADT)实现的数据结构。所谓的抽象数据类型,就是与具体实现无关,描述一类数据结构的抽象表示。以树为例子,有根树的ADT规定了树的节点有一个父节点,有零到多个的子节点,还有一个数据域。有根树有且只有一个根节点,根节点的特征是其父节点为空。
但是,有根树的具体实现无需照搬ADT的定义,根据不同的应用场景,可以有多种不同的实现。有些实现看上去甚至根本就不像是“一棵树”。本系列文章分别以并查集、二叉堆、斐波那契堆作为例子,试图探讨有根树的不同实现,以及他们选择这些实现的内在原因。


并查集

并查集试图给出如下问题的解决:已知有n个不同的整数元素,把它们划分到不同的集合,一个元素只能属于一个集合——即任意两个集合的交集为空。有以下两种操作:
询给定元素所属的集合
② 合这两个不同的集合
考虑这两种操作会非常频繁时,采用并查集使得总时间复杂度尽量小。“并查集”的名字从它的操作得来。

在正式引入并查集的结构之前,首先考虑几个问题:
(1)如何表示一个集合?
(2)如何组织一个集合的所有元素?
(3)如何实现问题(2)的数据组织形式?
尤其是问题(2),直接决定了并查集两个操作的效率。

(1) 如何表示一个集合
最简单的方式,就是用集合中的某个元素代表这个集合。

(2) 如何组织一个集合的所有元素
最直接能够想到的方案是,用一个链表把所有属于同一个集合的元素串起来,链表头为代表该集合的元素(图1)。

[图1] 链表组织的并查集

分析这种结构下两种操作的效率。①查询,对每个在链表上的元素,向前迭代查询直到表头,表头元素即为所属集合,效率为O(n) ②合并,直接把两个集合所属的链表收尾相连,维护尾部指针的情况下效率为O(1)。
查询效率为O(n),对于查询操作非常多的时候效率是非常低的。查询的效率能否更高?当然,对链表中每个元素增加一个指针域,指向表头,就能做到O(1)的查询效率。代价是合并操作时,对被链接到尾部的链表元素,要更新它们指向表头的指针,效率退为O(n)(图2)。


[图2] 改进的链表组织的并查集

下一步应该怎么改进?注意到图2的结构中,实际上链表的有序结构对于我们要解决的问题,是冗余的。把这些前缀后继关系去掉后,呈现的就是一颗有根树的关系(图3)。合并操作变为一颗树的根节点为另一颗树的子节点,花销为O(1)。查询操作:向上递归遍历父节点,直到根节点为止,效率最坏情况是O(n)。


[图3] 有根树组织的并查集

单看复杂度,好像跟一开始的链表(图1)没有区别。但是要注意现在的查询效率O(n)跟链表查询的O(n)还是有区别的:由于链表的线性结构,查询链表的最后几个元素需要的花销是跟n相当的;而图3的树形结构,直观感觉上,最后的几个元素没有n“那么远”(平均意义下,应该是lg(n)的花销)。同时,针对最坏情况,后面会提到优化方式。

(3) 如何实现
需要把一个树完整实现吗?回到查询和合并操作的细节,查询是递归遍历父节点,合并是根节点合并——也就是说,所有的操作只跟父节点有关。所以在具体实现上,每一个节点只要保留父节点这个域就够了。这也揭示了并查集的特征:本质上,我们并不关心这颗树的节点是如何组织的,哪个节点是哪个节点的父节点并不重要,只要保证它们都是根节点的子孙,也就保证了它们同属一个集合。

所以结论是,我们仅需一个数组就能实现并查集。数组下标代表一个元素,数组元素代表下标所属的集合。图4是实现的一个例子。根节点元素的特征是p[i] = i
并查集的初始化:每个元素单独组成一个集合,即对任意元素i,p[i] = i。


[图4] 数组实现并查集的例子

下面是代码的实现。

public class UnionFind {
	private int N;
	private int[] p;
	
	UnionFind(int n) {
		this.N = n;
		p = new int[n];
	}
	
	public void makeSet() {
		for (int i = 0; i < this.N; i++) {
			p[i] = i;
		}
	}
	
	public int find(int x) {
		if (x == p[x]) return x;
		return find(p[x]);
	}
	
	public void union(int x, int y) {
		int fx = find(x);
		int fy = find(y);
		p[fy] = fx;
	}
}


并查集的优化

考虑以下例子:
有以下5个元素:1, 2, 3, 4, 5. 初始状态它们单独组成一个集合。依次进行如下合并操作:

	union(2, 1)
	union(3, 1)
	union(4, 1)
	union(5, 1)
每次合并操作后树的状态依次如图5所示。
[图5] 退化成链表表示的并查集


可以看出,通过构造一组特殊的合并操作,可以使并查集的结构退化成一个链表!合并操作中的每次查询操作效率都为Θ(n),总操作时间为Θ(n^2)。
造成这种退化有两个原因:
(1)对同一个元素的每次查询操作中,都重复了同一组父节点的遍历次序,实际上只要查询过一次,就能够直接把该元素挂到根节点下方,使得下一次查询效率为O(1)。
(2)根节点的合并操作,虽然树A合并到树B,还是树B合并到树A,本质上是一样的。但是查询操作的效率上有区别。很显然,把较矮的树合并到较高的树上,对查询操作更加有利。
以上两个原因,分别对应两种优化策略:路径压缩和启发式合并。

路径压缩
每次递归查询父节点的同时,更新父节点为返回的根节点。代码如下所示

public int find(int x) {
	if (x == p[x]) return x;
	return p[x] = find(p[x]);
}
可以看出现在的代码与原来的差别就是把return find(p[x]) 改成了 return p[x] = find(p[x])。但就是这么一个小改动,效率大大提高。如图6的例子,查询元素为21,查询路径经过的所有节点,它们的路径最后都压缩成长度为1。但是没有经过的节点路径不变。
[图6] 路径压缩的例子

启发式合并
原理就是把较矮的树合并到高的树上,为此需要新增一个域rank[i],表示节点i在树上的高度上界。在合并前比较两个根的rank,rank小的合并到rank大的节点上。这里有一个问题,为什么rank是高度上界,不是高度本身?(提示:路径压缩) 代码如下
public void union(int x, int y) {
	int fx = find(x);
	int fy = find(y);
	if (rank[fx] < rank[fy]) {
		p[fx] = fy;
	}
	else {
		p[fy] = fx;
		if (rank[fx] == rank[fy])
			rank[fx]++;
	}
}
结合路径压缩和启发式合并的时间复杂度,参考《算法导论》给出的结论,为O(m*alpha(n)),m为总操作数,alpha(n)是个关于元素个数n的函数,这个函数增长极慢,在n < 10^616的情况下,alpha(n) <= 4,所以可看成是常数。平均后每次操作复杂度即为O(1)。


  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值