并查集
并查集(Union-find Sets),它主要是处理一些不相交集合的合并问题的数据结构,用于在线等价类(online equiralence class)问题。
在线等价类问题中,初始时有n 个元素,每个元素都属于一个独立的等价类。需要执行以下的操作:
1) Union(x, y):把元素 x 和元素 y 所在的集合合并。
2) Find(x):找到元素 x 所在的集合的代表了。
并查集中把每个集合描述为一棵树,开始时每个元素在仅包含它自己的一个集合中,如图:
若两个元素祖先相同,则它们位于同一集合。图a)和图b)中就只有一个集合,而图c)中有两个集合{15},{26,32}。
那么我可以很容易写出简单的并查集操作:
1 class Simple_UnionFindSet {
2 public:
3 void MakeSet(const int &N) {
4 for (int i = 0; i <= N; ++i)
5 Parent[i] = i;
6 }
7 int Find(int Root) {
8 while(Root != Parent[Root]) Root = Parent[Root];
9 return Root;
10 }
11 void Union(const int &RootA, const int &RootB) {
12 Parent[RootA] = RootB;
13 }
14 private:
15 int Parent[MaxN];
16 };
这时Find函数的最坏时间为O(N),M次操作的最坏时间为O(MN)。在输入数据是有序的情况下,构造的BST会退化成一个链表。
因此我们要对性能进行优化:
• 合并:1)重量规则:若树x 节点数少于树y 节点数,则将y 作为x 的父节点。否则将x 作为y 的父节点。
新的构建函数:
1 void MakeSet() {
2 memset(Parent, -1, sizeof(Parent));
3 //Parent[e]的值若为正数,则表示e的父亲;
4 //Parent[e]的值若为负数,则表示e为根,且其负数为它的重量
5 }
合并操作:
1 void Union(const int &RootA, const int &RootB) {
2 if (Parent[RootA] < Parent[RootB]) {
3 Parent[RootA] += Parent[RootB];
4 Parent[RootB] = RootA;
5 } else {
6 Parent[RootB] += Parent[RootA];
7 Parent[RootA] = RootB;
8 }
9 }
2)高度规则:若树x 的高度小于树y 的高度,则将y 作为x 的父节点,否则将x 作为y 的父节点。
我们先定义一个描述高度规则的结构:
1 struct HeightNode {
2 int Parent, Height;
3 WeightNode(int _Height = 1) {
4 Height = _Height;
5 //初始每个集合的高度都是1
6 }
7 } Node[MaxN];
合并操作:
1 void Union(const int &RootA, const int &RootB) {
2 if (Node[RootA].Height > Node[RootB].Height) Node[RootB].Parent = RootA;
3 else {
4 Node[RootA].Parent = RootB;
5 if(Node[RootA].Height == Node[RootB].Height) ++Node[RootB].Height;
6 }
7 }
→使用这两个规则都可以使树的高度 ≤ (log2N) + 1。推荐使用重量规则,因为查找函数优化后会改变树的高度,且使用重量规则可以节省空间。
• 查找:1)紧缩路径(path compaction):改变从节点e到根的路径上所有节点的指针,使这些指针直接指向根节点。
1 //递归
2 int Find(int Root) {
3 if(Root != Parent[Root]) Parent[Root] = Find(Parent[Root]);
4 return Parent[Root];
5 }
6
7 //迭代
8 int Find(int Root) {
9 int Now, Ancestor = Root;
10 while(Parent[Ancestor] != Ancestor) Ancestor = Parent[Ancestor];
11 while(Root != Ancestor) {
12 Now = Parent[Root];
13 Parent[Root] = Ancestor;
14 Root = Now;
15 }
16 return Root;
17 }
2)路径分割(path splitting):改变从e 到根节点路径上每个节点(除了根和其子节点)的指针,使其指向各自的祖父节点。
1 int Find(int Root) {
2 int Now;
3 while(Root != Parent[Root]) {
4 Now = Parent[Root];
5 Parent[Root] = Parent[Parent[Root]];
6 Root = Now;
7 }
8 return Root;
9 }
3)路径对折(path halving):改变从e 到根节点路径上每隔一个节点(除了根和其子节点)的指针,使其指向各自的祖父节点。
1 int Find(int Root) {
2 while(Root != Parent[Root]) {
3 Parent[Root] = Parent[Parent[Root]];
4 Root = Parent[Root];
5 }
6 return Root;
7 }
→这三种方法中推荐使用路径对折。在实际使用中,只优化Find函数就可以达到很高的效率。
附:使用重量规则后的查找函数:
1 int Find(int Root) { 2 while(Parent[Root] >= 0 && Parent[Parent[Root]] >= 0) 3 Root = (Parent[Root] = Parent[Parent[Root]]); 4 return Parent[Root] >= 0 ? Parent[Root] : Root; 5 }
复杂度(使用了合并和查找优化):
设T(ƒ, µ)是交错的ƒ次查找和µ次合并操作所需的最大时间。假设µ ≥ N / 2,则 k1(N + ƒα(ƒ + N, N)) ≤ T(ƒ, µ) ≤ k2(N + ƒα(ƒ + N, N))。
• 其中α为Ackermann函数的反函数,因为Ackermann函数的增长很快,所以其反函数α的增长是非常慢的,可以认为α ≤ 4。