文章目录
1.等价类的定义
在离散数学中,等价类的定义是:
如果集合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的等价类。
2. 等价类的求法
假设集合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等价类。
3.等价类的实现
由2可知,划分等价类需要对集合进行三种操作:
- 构造只有单个成员的集合;
- 判定某个单元素所在的子集
- 归并两个互不相交的集合为一个集合。
由此,我们需要一个包含上述3种操作的数据结构MFSet(并查集)。
3.1 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
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)
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.5 查找操作的优化(路径压缩)
随着子集的合并,树的深度会越来越大(即使我们使用了算法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;
}
此时,可能会有人有疑问(包括我),有了路径压缩之后,我们还需要优化并操作为算法3吗?
我的理解是:
路径压缩也需要查找元素的根,涉及到树的深度,毕竟我们做的只是将根结点到i的路径进行压缩,还存在其他叶子结点,如果合并的结点是根,可能就没有查找这一步了吧,所以能优化就优化吧!
4.数组模拟
也可以单独给每个结点设立一个数组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;
}
}
};
有时候为了方便,我们也可以初始化父节点parent[i] = i
5.并查集的应用
网络的最小生成树算法——克鲁斯算法。
参考资料
《数据结构 C语言描述》 严蔚敏著
推荐阅读
推荐另一篇写的不错的博文并查集(Union-Find)算法介绍。