经典算法题每日演练——第十五题 并查集
这一篇我们看看经典又神奇的并查集,顾名思义就是并起来查,可用于处理一些不相交集合的秒杀。
一:场景
有时候我们会遇到这样的场景,比如:M={1,4,6,8},N={2,4,5,7},我的需求就是判断{1,2}是否属于同一个集合,当然实现方法
有很多,一般情况下,普通青年会做出O(MN)的复杂度,那么有没有更轻量级的复杂度呢?嘿嘿,并查集就是用来解决这个问题的。
二:操作
从名字可以出来,并查集其实只有两种操作,并(Union)和查(Find),并查集是一种算法,所以我们要给它选择一个好的数据结构,
通常我们用树来作为它的底层实现。
1.节点定义
1 #region 树节点 2 /// <summary> 3 /// 树节点 4 /// </summary> 5 public class Node 6 { 7 /// <summary> 8 /// 父节点 9 /// </summary> 10 public char parent; 11 12 /// <summary> 13 /// 节点的秩 14 /// </summary> 15 public int rank; 16 } 17 #endregion
2.Union操作
<1>原始方案
首先我们会对集合的所有元素进行打散,最后每个元素都是一个独根的树,然后我们Union其中某两个元素,让他们成为一个集合,
最坏情况下我们进行M次的Union时会存在这样的一个链表的场景。
从图中我们可以看到,Union时出现了最坏的情况,而且这种情况还是比较容易出现的,最终导致在Find的时候就相当寒酸苦逼了,为O(N)。
<2> 按秩合并
我们发现出现这种情况的原因在于我们Union时都是将合并后的大树作为小树的孩子节点存在,那么我们在Union时能不能判断一下,
将小树作为大树的孩子节点存在,最终也就降低了新树的深度,比如图中的Union(D,{E,F})的时候可以做出如下修改。
可以看出,我们有效的降低了树的深度,在N个元素的集合中,构建树的深度不会超过LogN层。M次操作的复杂度为O(MlogN),从代
码上来说,我们用Rank来统计树的秩,可以理解为树的高度,独根树时Rank=0,当两棵树的Rank相同时,可以随意挑选合并,在新
根中的Rank++就可以了。
1 #region 合并两个不相交集合 2 /// <summary> 3 /// 合并两个不相交集合 4 /// </summary> 5 /// <param name="root1"></param> 6 /// <param name="root2"></param> 7 /// <returns></returns> 8 public void Union(char root1, char root2) 9 { 10 char x1 = Find(root1); 11 char y1 = Find(root2); 12 13 //如果根节点相同则说明是同一个集合 14 if (x1 == y1) 15 return; 16 17 //说明左集合的深度 < 右集合 18 if (dic[x1].rank < dic[y1].rank) 19 { 20 //将左集合指向右集合 21 dic[x1].parent = y1; 22 } 23 else 24 { 25 //如果 秩 相等,则将 y1 并入到 x1 中,并将x1++ 26 if (dic[x1].rank == dic[y1].rank) 27 dic[x1].rank++; 28 29 dic[y1].parent = x1; 30 } 31 } 32 #endregion
3.Find操作
我们学算法,都希望能把一个问题优化到地球人都不能优化的地步,针对logN的级别,我们还能优化吗?当然可以。
<1>路径压缩
在Union和Find这两种操作中,显然我们在Union上面已经做到了极致,下面我们在Find上面考虑一下,是不是可以在Find上运用
伸展树的思想,这种伸展思想就是压缩路径。
从图中我们可以看出,当我Find(F)的时候,找到“F”后,我们开始一直回溯,在回溯的过程中给,把该节点的父亲指向根节点。最终
我们会形成一个压缩后的树,当我们再次Find(F)的时候,只要O(1)的时间就可以获取,这里有个注意的地方就是Rank,当我们在路
径压缩时,最后树的高度可能会降低,可能你会意识到原先的Rank就需要修改了,所以我要说的就是,当路径压缩时,Rank保存的就
是树高度的上界,而不仅仅是明确的树高度,可以理解成"伸缩椅"伸时候的长度。
1 #region 查找x所属的集合 2 /// <summary> 3 /// 查找x所属的集合 4 /// </summary> 5 /// <param name="x"></param> 6 /// <returns></returns> 7 public char Find(char x) 8 { 9 //如果相等,则说明已经到根节点了,返回根节点元素 10 if (dic[x].parent == x) 11 return x; 12 13 //路径压缩(回溯的时候赋值,最终的值就是上面返回的"x",也就是一条路径上全部被修改了) 14 return dic[x].parent = Find(dic[x].parent); 15 } 16 #endregion
我们注意到,在路径压缩后,我们将LogN的复杂度降低到Alpha(N),Alpha(N)可以理解成一个比hash函数还有小的常量,嘿嘿,这
就是算法的魅力。
最后上一下总的运行代码:
using System; using System.Collections.Generic; using System.Linq; using System.Text; namespace ConsoleApplication1 { class Program { static void Main(string[] args) { //定义 6 个节点 char[] c = new char[] { 'A', 'B', 'C', 'D', 'E', 'F' }; DisjointSet set = new DisjointSet(); set.Init(c); set.Union('E', 'F'); set.Union('C', 'D'); set.Union('C', 'E'); var b = set.IsSameSet('C', 'E'); Console.WriteLine("C,E是否在同一个集合:{0}", b); b = set.IsSameSet('A', 'C'); Console.WriteLine("A,C是否在同一个集合:{0}", b); Console.Read(); } } /// <summary> /// 并查集 /// </summary> public class DisjointSet { #region 树节点 /// <summary> /// 树节点 /// </summary> public class Node { /// <summary> /// 父节点 /// </summary> public char parent; /// <summary> /// 节点的秩 /// </summary> public int rank; } #endregion Dictionary<char, Node> dic = new Dictionary<char, Node>(); #region 做单一集合的初始化操作 /// <summary> /// 做单一集合的初始化操作 /// </summary> public void Init(char[] c) { //默认的不想交集合的父节点指向自己 for (int i = 0; i < c.Length; i++) { dic.Add(c[i], new Node() { parent = c[i], rank = 0 }); } } #endregion #region 判断两元素是否属于同一个集合 /// <summary> /// 判断两元素是否属于同一个集合 /// </summary> /// <param name="root1"></param> /// <param name="root2"></param> /// <returns></returns> public bool IsSameSet(char root1, char root2) { return Find(root1) == Find(root2); } #endregion #region 查找x所属的集合 /// <summary> /// 查找x所属的集合 /// </summary> /// <param name="x"></param> /// <returns></returns> public char Find(char x) { //如果相等,则说明已经到根节点了,返回根节点元素 if (dic[x].parent == x) return x; //路径压缩(回溯的时候赋值,最终的值就是上面返回的"x",也就是一条路径上全部被修改了) return dic[x].parent = Find(dic[x].parent); } #endregion #region 合并两个不相交集合 /// <summary> /// 合并两个不相交集合 /// </summary> /// <param name="root1"></param> /// <param name="root2"></param> /// <returns></returns> public void Union(char root1, char root2) { char x1 = Find(root1); char y1 = Find(root2); //如果根节点相同则说明是同一个集合 if (x1 == y1) return; //说明左集合的深度 < 右集合 if (dic[x1].rank < dic[y1].rank) { //将左集合指向右集合 dic[x1].parent = y1; } else { //如果 秩 相等,则将 y1 并入到 x1 中,并将x1++ if (dic[x1].rank == dic[y1].rank) dic[x1].rank++; dic[y1].parent = x1; } } #endregion } }