文章目录
1.等价类的定义
在离散数学中,等价类的定义是:
如果集合S中的关系R是自反的、对称的和传递的,则称它是一个等价关系。
集合S上的关系R可定义为,集合SXS的笛卡尔积的子集,即关系是序对的集合。
设R是集合S上的等价关系,对任何
,由
给出的集合
称为由于
生成的一个R等价类。
若R是集合S上的一个等价关系,则由这个等价关系可产生这个集合的唯一划分,即可按R将S划分为若干不相交的子集
他们的并为S,则这些子集
便为S的等价类。
2. 等价类的求法
假设集合S有n个元素,m个形如
的等价偶对确定了等价关系R,现在求S的划分:
1)令S中的每个元素各自形成一个只含单个成员的子集,记作
2)重复读入m个偶对,对每个读入的偶对
,判定x和y所属的子集。不失一般性,假设
,若
,则将
并入
并置
为空(或反过来)。
3)则当m个偶对都被处理过后,
中所有非空子集即为S的R等价类。
3.等价类的实现
由2可知,划分等价类需要对集合进行三种操作:
- 构造只有单个成员的集合;
- 判定某个单元素所在的子集
- 归并两个互不相交的集合为一个集合。
由此,我们需要一个包含上述3种操作的数据结构MFSet(并查集)。
3.1 MFSet的形式定义
根据MFSet需要的查找函数和归并函数的特点,我们可以用树型结构表示它:
约定以森林
表示MFSet型的集合S,
森林中的每一棵树
表示S中的一个元素——子集
树中的每个结点表示对应子集
中的一个成员
,为方便起见,令每个结点含有一个指向其双亲的指针
,并约定根结点的成员兼作子集的名字
。
显然,这样的树形结构易于实现上述两种集合操作:
- 由于各子集成员均不相同,"并操作"只需将一棵子集树的根指向另一子集树的根即可;
- 完成"查找"某个成员所在集合的操作,只需从该成员结点出发,顺着指针找到树的根结点就行。
例如,下图(a)和(b)分别表示子集 , ,集合
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的时间复杂度分别为
,其中
为树的深度。
如果每次并操作都是令成员多的结点指向成员少的根结点,则所得到的树的深度可能会越来越深,这不利于下次集合的合并(因为下次涉及叶子结点和合并需要查找根节点)。所以,我们可以在并操作
前先判别子集中所含成员的数目,然后令含成员少的子集树根结点指向
含成员多的子集的根(私认为最好是深度浅的指向深度深的子树)。
所以,我们可以修改根结点的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进行改进:
当所查元素
不在树的第二层的时,在算法中增加一个路径压缩
的功能,即将所有从根到元素
上的元素都变成树根的孩子,这将大大减少树的深度,只是增大了树的宽度。
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语言描述》 严蔚敏著