在离散数学中,等价类的定义是:
如果集合S中的关系R是自反的、对称的和传递的,则称它是一个等价关系。
集合S上的关系R可定义为,集合SXS的笛卡尔积的子集,即关系是序对的集合。
设R是集合S上的等价关系,对任何 x ∈ S x∈S x∈S,由 [ x ] R = { y ∣ y ∈ S ∧ x R y } [x]_{R}=\{ y|y∈S \wedge xRy \} [x]R={y∣y∈S∧xRy}给出的集合 [ x ] R ⊆ S [x]_{R} \subseteq S [x]R⊆S称为由于 x ∈ S x∈S x∈S生成的一个R等价类。
若R是集合S上的一个等价关系,则由这个等价关系可产生这个集合的唯一划分,即可按R将S划分为若干不相交的子集 S 1 , S 2 , . . . , S_{1},S_{2},..., S1,S2,...,他们的并为S,则这些子集 S i S_{i} Si便为S的等价类。
假设集合S有n个元素,m个形如 ( x , y ) ( x , y ∈ S ) (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}。 S1,S2,...,Sn。
2)重复读入m个偶对,对每个读入的偶对 ( x , y ) (x,y) (x,y),判定x和y所属的子集。不失一般性,假设 x ∈ S i , y ∈ S j x∈S_{i},y∈S_{j} x∈Si,y∈Sj,若 S i ≠ S j S_{i} \neq S_{j} Si̸=Sj,则将 S i S_{i} Si并入 S j S_{j} Sj并置 S i S_{i} Si为空(或反过来)。
3)则当m个偶对都被处理过后, S 1 , S 2 , . . . , S n S_{1},S_{2},...,S_{n} S1,S2,...,Sn中所有非空子集即为S的R等价类。
由2可知,划分等价类需要对集合进行三种操作:
由此,我们需要一个包含上述3种操作的数据结构MFSet(并查集)。
根据MFSet需要的查找函数和归并函数的特点,我们可以用树型结构表示它:
约定以森林 F = ( T 1 , T 2 , . . . , T n ) F=(T_{1},T_{2},...,T_{n}) F=(T1,T2,...,Tn)表示MFSet型的集合S,
森林中的每一棵树 T i ( i = 1 , 2 , . . . , n ) T_{i}(i=1,2,...,n) Ti(i=1,2,...,n)表示S中的一个元素——子集 S i ( S i ⊂ S , i = 1 , 2 , . . . , n ) S_{i}(S_{i} \subset S,i=1,2,...,n) Si(Si⊂S,i=1,2,...,n)树中的每个结点表示对应子集 S i S_{i} Si中的一个成员 x x x,为方便起见,令每个结点含有一个指向其双亲的指针
,并约定根结点的成员兼作子集的名字
。
显然,这样的树形结构易于实现上述两种集合操作:
例如,下图(a)和(b)分别表示子集 S 1 = { 1 , 3 , 6 , 9 } S_{1}=\{1,3,6,9\} S1={1,3,6,9}, S 2 = { 2 , 8 , 10 } S_{2}=\{2,8,10\} S2={2,8,10},集合 S 3 = S 1 ∪ S 2 S_{3} = S_{1} \cup S_{2} S3=S1∪S2
为了便于实现这两种操作,且便于找到双亲,我们可以采用双亲表示法
来作树的存储结构:
以一组连续空间存储树的结点,同时在每个结点中附设一个指示器指示其双亲结点在链表中的位置:
// ---- 树的双亲表存储表示 ----
#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;
查找操作
算法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;
}
算法1和算法2的时间复杂度分别为 O ( d ) 和 O ( 1 ) O(d)和O(1) O(d)和O(1),其中 d 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).
为了进一步减少确定元素所在子集的时间,我们可以对算法2进行改进:
当所查元素 i i 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的路径进行压缩,还存在其他叶子结点,如果合并的结点是根,可能就没有查找这一步了吧,所以能优化就优化吧!
也可以单独给每个结点设立一个数组parent[]
表示其父亲结点来模拟并和查找操作。之前压缩查找路径时,使用了2次循环,这次只将节点的父亲指向其爷爷节点来压缩路径。
class MFSet {
private:
int* parent; // 父亲结点的索引
int count; // 集合中所有元素的总数
public:
MFSet(int N) {
// 表示初始有N个结点(N个类别)
count = N;
parent = new int[N];
for (int i = 0; i < N; i++) {
parent[i] = -1; // 表示该等价集合有的元素个数的相反数
}
}
int find(int p) {
// 查找的时候,将p的父亲结点设置为他的爷爷节点
while (parent[p] > 0) {
if (parent[parent[p]] > 0) parent[p] = parent[parent[p]];
p = parent[p];
}
return p;
}
void merge(int p, int q) {
int pID = find(p);
int qID = find(q);
if (pID == qID) return;
if (parent[pID] < parent[qID]) {
// p 的孩子更多
parent[pID] += parent[qID]; // 注意先改变数目
parent[qID] = pID;
}
else {
// q的孩子更多
parent[qID] += parent[pID];
parent[pID] = qID;
}
}
};
网络的最小生成树算法——克鲁斯算法。
《数据结构 C语言描述》 严蔚敏著
推荐另一篇写的不错的博文并查集(Union-Find)算法介绍。