树与等价问题——并查集

1.等价类的定义

在离散数学中,等价类的定义是:
如果集合S中的关系R是自反的对称的传递的,则称它是一个等价关系。

集合S上的关系R可定义为,集合SXS的笛卡尔积的子集,即关系是序对的集合。

设R是集合S上的等价关系,对任何 x S x∈S ,由 [ x ] R = { y y S x R y } [x]_{R}=\{ y|y∈S \wedge xRy \} 给出的集合 [ x ] R S [x]_{R} \subseteq S 称为由于 x S x∈S 生成的一个R等价类。
若R是集合S上的一个等价关系,则由这个等价关系可产生这个集合的唯一划分,即可按R将S划分为若干不相交的子集 S 1 , S 2 , . . . , S_{1},S_{2},..., 他们的并为S,则这些子集 S i S_{i} 便为S的等价类。

2. 等价类的求法

假设集合S有n个元素,m个形如 ( x , y ) ( x , y S ) (x,y)(x,y∈S) 的等价偶对确定了等价关系R,现在求S的划分:
1)令S中的每个元素各自形成一个只含单个成员的子集,记作 S 1 , S 2 , . . . , S n S_{1},S_{2},...,S_{n}。
2)重复读入m个偶对,对每个读入的偶对 ( x , y ) (x,y) ,判定x和y所属的子集。不失一般性,假设 x S i , y S j x∈S_{i},y∈S_{j} ,若 S i S j S_{i} \neq S_{j} ,则将 S i S_{i} 并入 S j S_{j} 并置 S i S_{i} 为空(或反过来)。
3)则当m个偶对都被处理过后, S 1 , S 2 , . . . , S n S_{1},S_{2},...,S_{n} 中所有非空子集即为S的R等价类。

3.等价类的实现

2可知,划分等价类需要对集合进行三种操作:

  • 构造只有单个成员的集合;
  • 判定某个单元素所在的子集
  • 归并两个互不相交的集合为一个集合。

由此,我们需要一个包含上述3种操作的数据结构MFSet(并查集)

3.1 MFSet的形式定义

根据MFSet需要的查找函数和归并函数的特点,我们可以用树型结构表示它:
约定以森林 F = T 1 , T 2 , . . . , T n F=(T_{1},T_{2},...,T_{n}) 表示MFSet型的集合S,
森林中的每一棵树 T i ( i = 1 , 2 , . . . , n ) T_{i}(i=1,2,...,n) 表示S中的一个元素——子集 S i ( S i S , i = 1 , 2 , . . . , n ) S_{i}(S_{i} \subset S,i=1,2,...,n) 树中的每个结点表示对应子集 S i S_{i} 中的一个成员 x x ,为方便起见,令每个结点含有一个指向其双亲的指针,并约定根结点的成员兼作子集的名字

显然,这样的树形结构易于实现上述两种集合操作:

  • 由于各子集成员均不相同,"并操作"只需将一棵子集树的根指向另一子集树的根即可;
  • 完成"查找"某个成员所在集合的操作,只需从该成员结点出发,顺着指针找到树的根结点就行。

例如,下图(a)和(b)分别表示子集 S 1 = { 1 , 3 , 6 , 9 } S_{1}=\{1,3,6,9\} , S 2 = { 2 , 8 , 10 } S_{2}=\{2,8,10\} ,集合 S 3 = S 1 S 2 S_{3} = S_{1} \cup S_{2}

并查集实例

3.2 MFSet类型定义

为了便于实现这两种操作,且便于找到双亲,我们可以采用双亲表示法来作树的存储结构:
以一组连续空间存储树的结点,同时在每个结点中附设一个指示器指示其双亲结点在链表中的位置:

// ---- 树的双亲表存储表示 ----
#define MAX_NODE_NUM 100
typedef string ElemType;
typedef struct PTNode{ // 结点结构
	ElemType data;
	int parent; // 双亲位置域
}PTnode;
typedef struct { // 树结构
	PTnode nodes[MAX_NODE_NUM];
	int r, n;       // 根的位置和结点数
}PTree;

这种结构,寻找结点的双亲和所在子树的根结点很方便,但是求结点的孩子需要遍历整个结构。
树的双亲表示法示例
有了树的双亲结点表示我们能定义所需的MFSet类型:

//---- MFSet的树的双亲存储表示 ----
typedef PTree MFSet;
3.3 查找和并操作实现

查找操作
算法1

int find_mfset(MFSet s, int i) {
	// 找集合S中i所在集合的根
	if (i < 1 || i > s.n) return -1;  // i 不属于S中的任意子集
	int j;
	for (j = i; j = s.nodes[i].parent > 0; j = s.nodes[i].parent);
	return j;
}

并操作
算法2

int merge_mfset(MFSet& s, int i, int j) {
	// s.nodes[i]和s.nodes[j]分别为s的互不相交的两个子集si和sj的根结点
	// 求并集si U sj
	if (i < 1 || i>s.n || j<1 || j>s.n) return 0; //输入有误
	s.nodes[i].parent = j;
	return 1;
}
3.4 并操作的优化

算法1和算法2的时间复杂度分别为 O ( d ) O ( 1 ) O(d)和O(1) ,其中 d d 为树的深度。
如果每次并操作都是令成员多的结点指向成员少的根结点,则所得到的树的深度可能会越来越深,这不利于下次集合的合并(因为下次涉及叶子结点和合并需要查找根节点)。所以,我们可以在并操作前先判别子集中所含成员的数目,然后令含成员少的子集树根结点指向
含成员多的子集的根
(私认为最好是深度浅的指向深度深的子树)。

所以,我们可以修改根结点的parent域,使其存储子集中所含成员数目的负值(原本是-1)。
修改后的并操作算法:
算法3

int mix_merge(MFSet& s, int i, int j) {
	if (i < 1 || i>s.n || j<1 || j>s.n) return 0; //输入有误
	if (s.nodes[i].parent > s.nodes[j].parent) { // i的成员少
		s.nodes[j].parent += s.nodes[i].parent;
		s.nodes[i].parent = j;
	}
	else {
		s.nodes[i].parent += s.nodes[j].parent;
		s.nodes[j].parent = i;
	}
	return 1;
}
3.5 查找操作的优化(路径压缩)

随着子集的合并,树的深度会越来越大(即使我们使用了算法3).
为了进一步减少确定元素所在子集的时间,我们可以对算法2进行改进:
当所查元素 i i 不在树的第二层的时,在算法中增加一个路径压缩的功能,即将所有从根到元素 i i 上的元素都变成树根的孩子,这将大大减少树的深度,只是增大了树的宽度。

int mix_find(MFSet& s, int i) {
	// 确定i所在子集,并将从到根路径上的所有结点变成根的孩子结点
	if (i < 1 || i > s.n) return -1;
	int j;
	// 查找i的根结点j
	for (j = i; s.nodes[j].parent > 0; j = s.nodes[j].parent); 
	int k;
	int t;
	for (k = i; k != j; k = t) {
		t = s.nodes[k].parent;
		s.nodes[k].parent = j;
	}
	return j;
}

此时,可能会有人有疑问(包括我),有了算法4之后,我们还需要改进算法1为3吗?
我的理解是:
算法4也需要查找元素的根,涉及到树的深度,毕竟我们做的只是将根结点到i的路径进行压缩,还存在其他叶子结点,如果合并的结点是根,可能就没有查找这一步了吧,所以能优化就优化吧!

4.并查集的应用

网络的最小生成树算法——克鲁斯算法

参考资料

《数据结构 C语言描述》 严蔚敏著

猜你喜欢

转载自blog.csdn.net/Africa_South/article/details/88655556
今日推荐