第十五题 并查集


这一篇我们看看经典又神奇的并查集,顾名思义就是并起来查,可用于处理一些不相交集合的秒杀。

一:场景

  1. 有时候我们会遇到这样的场景,比如:M={1,4,6,8},N={2,4,5,7},我的需求就是判断{1,2}是否属于同一个集合,当然实现方法

有很多,一般情况下,普通青年会做出O(MN)的复杂度,那么有没有更轻量级的复杂度呢?嘿嘿,并查集就是用来解决这个问题的。

二:操作

从名字可以出来,并查集其实只有两种操作,并(Union)和查(Find),并查集是一种算法,所以我们要给它选择一个好的数据结构,

通常我们用树来作为它的底层实现。

1.节点定义

2.Union操作

<1>原始方案

  1. 首先我们会对集合的所有元素进行打散,最后每个元素都是一个独根的树,然后我们Union其中某两个元素,让他们成为一个集合,

最坏情况下我们进行M次的Union时会存在这样的一个链表的场景。

第十五题 并查集 - 图1

从图中我们可以看到,Union时出现了最坏的情况,而且这种情况还是比较容易出现的,最终导致在Find的时候就相当寒酸苦逼了,为O(N)。

<2> 按秩合并

  1. 我们发现出现这种情况的原因在于我们Union时都是将合并后的大树作为小树的孩子节点存在,那么我们在Union时能不能判断一下,

将小树作为大树的孩子节点存在,最终也就降低了新树的深度,比如图中的Union(D,{E,F})的时候可以做出如下修改。

第十五题 并查集 - 图2

可以看出,我们有效的降低了树的深度,在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操作

  1. 我们学算法,都希望能把一个问题优化到地球人都不能优化的地步,针对logN的级别,我们还能优化吗?当然可以。

<1>路径压缩

  1. UnionFind这两种操作中,显然我们在Union上面已经做到了极致,下面我们在Find上面考虑一下,是不是可以在Find上运用

伸展树的思想,这种伸展思想就是压缩路径。

第十五题 并查集 - 图3

从图中我们可以看出,当我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函数还有小的常量,嘿嘿,这

就是算法的魅力。

最后上一下总的运行代码:

  1. using System;
  2. using System.Collections.Generic;
  3. using System.Linq;
  4. using System.Text;
  5.  
  6. namespace ConsoleApplication1
  7. {
  8. class Program
  9. {
  10. static void Main(string[] args)
  11. {
  12. //定义 6 个节点
  13. char[] c = new char[] { 'A', 'B', 'C', 'D', 'E', 'F' };
  14.  
  15. DisjointSet set = new DisjointSet();
  16.  
  17. set.Init(c);
  18.  
  19. set.Union('E', 'F');
  20.  
  21. set.Union('C', 'D');
  22.  
  23. set.Union('C', 'E');
  24.  
  25. var b = set.IsSameSet('C', 'E');
  26.  
  27. Console.WriteLine("C,E是否在同一个集合:{0}", b);
  28.  
  29. b = set.IsSameSet('A', 'C');
  30.  
  31. Console.WriteLine("A,C是否在同一个集合:{0}", b);
  32.  
  33. Console.Read();
  34. }
  35. }
  36.  
  37. /// <summary>
  38. /// 并查集
  39. /// </summary>
  40. public class DisjointSet
  41. {
  42. #region 树节点
  43. /// <summary>
  44. /// 树节点
  45. /// </summary>
  46. public class Node
  47. {
  48. /// <summary>
  49. /// 父节点
  50. /// </summary>
  51. public char parent;
  52.  
  53. /// <summary>
  54. /// 节点的秩
  55. /// </summary>
  56. public int rank;
  57. }
  58. #endregion
  59.  
  60. Dictionary<char, Node> dic = new Dictionary<char, Node>();
  61.  
  62. #region 做单一集合的初始化操作
  63. /// <summary>
  64. /// 做单一集合的初始化操作
  65. /// </summary>
  66. public void Init(char[] c)
  67. {
  68. //默认的不想交集合的父节点指向自己
  69. for (int i = 0; i < c.Length; i++)
  70. {
  71. dic.Add(c[i], new Node()
  72. {
  73. parent = c[i],
  74. rank = 0
  75. });
  76. }
  77. }
  78. #endregion
  79.  
  80. #region 判断两元素是否属于同一个集合
  81. /// <summary>
  82. /// 判断两元素是否属于同一个集合
  83. /// </summary>
  84. /// <param name="root1"></param>
  85. /// <param name="root2"></param>
  86. /// <returns></returns>
  87. public bool IsSameSet(char root1, char root2)
  88. {
  89. return Find(root1) == Find(root2);
  90. }
  91. #endregion
  92.  
  93. #region 查找x所属的集合
  94. /// <summary>
  95. /// 查找x所属的集合
  96. /// </summary>
  97. /// <param name="x"></param>
  98. /// <returns></returns>
  99. public char Find(char x)
  100. {
  101. //如果相等,则说明已经到根节点了,返回根节点元素
  102. if (dic[x].parent == x)
  103. return x;
  104.  
  105. //路径压缩(回溯的时候赋值,最终的值就是上面返回的"x",也就是一条路径上全部被修改了)
  106. return dic[x].parent = Find(dic[x].parent);
  107. }
  108. #endregion
  109.  
  110. #region 合并两个不相交集合
  111. /// <summary>
  112. /// 合并两个不相交集合
  113. /// </summary>
  114. /// <param name="root1"></param>
  115. /// <param name="root2"></param>
  116. /// <returns></returns>
  117. public void Union(char root1, char root2)
  118. {
  119. char x1 = Find(root1);
  120. char y1 = Find(root2);
  121.  
  122. //如果根节点相同则说明是同一个集合
  123. if (x1 == y1)
  124. return;
  125.  
  126. //说明左集合的深度 < 右集合
  127. if (dic[x1].rank < dic[y1].rank)
  128. {
  129. //将左集合指向右集合
  130. dic[x1].parent = y1;
  131. }
  132. else
  133. {
  134. //如果 秩 相等,则将 y1 并入到 x1 中,并将x1++
  135. if (dic[x1].rank == dic[y1].rank)
  136. dic[x1].rank++;
  137.  
  138. dic[y1].parent = x1;
  139. }
  140. }
  141. #endregion
  142. }
  143. }