原文链接:http://blog.csdn.net/dm_vincent/article/details/7655764

本文主要介绍解决动态连通性一类问题的一种算法,使用到了一种叫做并查集的数据结构,称为Union-Find。

更多的信息可以参考Algorithms 一书的Section 1.5,实际上本文也就是基于它的一篇读后感吧。

原文中更多的是给出一些结论,我尝试给出一些思路上的过程,即为什么要使用这个方法,而不是别的什么方法。我觉得这个可能更加有意义一些,相比于记下一些结论。

关于动态连通性

我们看一张图来了解一下什么是动态连通性:

假设我们输入了一组整数对,即上图中的(4, 3) (3, 8)等等,每对整数代表这两个points/sites是连通的。那么随着数据的不断输入,整个图的连通性也会发生变化,从上图中可以很清晰的发现这一点。同时,对于已经处于连通状态的points/sites,直接忽略,比如上图中的(8, 9)。

动态连通性的应用场景:

  • 网络连接判断:

如果每个pair中的两个整数分别代表一个网络节点,那么该pair就是用来表示这两个节点是需要连通的。那么为所有的pairs建立了动态连通图后,就能够尽可能少的减少布线的需要,因为已经连通的两个节点会被直接忽略掉。

  • 变量名等同性(类似于指针的概念):

在程序中,可以声明多个引用来指向同一对象,这个时候就可以通过为程序中声明的引用和实际对象建立动态连通图来判断哪些引用实际上是指向同一对象。

对问题建模:

在对问题进行建模的时候,我们应该尽量想清楚需要解决的问题是什么。因为模型中选择的数据结构和算法显然会根据问题的不同而不同,就动态连通性这个场景而言,我们需要解决的问题可能是:

  • 给出两个节点,判断它们是否连通,如果连通,不需要给出具体的路径
  • 给出两个节点,判断它们是否连通,如果连通,需要给出具体的路径

就上面两种问题而言,虽然只有是否能够给出具体路径的区别,但是这个区别导致了选择算法的不同,本文主要介绍的是第一种情况,即不需要给出具体路径的Union-Find算法,而第二种情况可以使用基于DFS的算法。

建模思路:

最简单而直观的假设是,对于连通的所有节点,我们可以认为它们属于一个组,因此不连通的节点必然就属于不同的组。随着Pair的输入,我们需要首先判断输入的两个节点是否连通。如何判断呢?按照上面的假设,我们可以通过判断它们属于的组,然后看看这两个组是否相同,如果相同,那么这两个节点连通,反之不连通。为简单起见,我们将所有的节点以整数表示,即对N个节点使用0到N-1的整数表示。而在处理输入的Pair之前,每个节点必然都是孤立的,即他们分属于不同的组,可以使用数组来表示这一层关系,数组的index是节点的整数表示,而相应的值就是该节点的组号了。该数组可以初始化为:

  1. for(int i = 0; i < size; i++)
  2. id[i] = i;

即对于节点i,它的组号也是i。

初始化完毕之后,对该动态连通图有几种可能的操作:

  • 查询节点属于的组

数组对应位置的值即为组号

  • 判断两个节点是否属于同一个组

分别得到两个节点的组号,然后判断组号是否相等

  • 连接两个节点,使之属于同一个组

分别得到两个节点的组号,组号相同时操作结束,不同时,将其中的一个节点的组号换成另一个节点的组号

  • 获取组的数目

初始化为节点的数目,然后每次成功连接两个节点之后,递减1

 

API

我们可以设计相应的API:

注意其中使用整数来表示节点,如果需要使用其他的数据类型表示节点,比如使用字符串,那么可以用哈希表来进行映射,即将String映射成这里需要的Integer类型。

分析以上的API,方法connected和union都依赖于find,connected对两个参数调用两次find方法,而union在真正执行union之前也需要判断是否连通,这又是两次调用find方法。因此我们需要把find方法的实现设计的尽可能的高效。所以就有了下面的Quick-Find实现。

Quick-Find 算法:

  1. public class UF
  2. {
  3. private int[] id; // access to component id (site indexed)
  4. private int count; // number of components
  5. public UF(int N)
  6. {
  7. // Initialize component id array.
  8. count = N;
  9. id = new int[N];
  10. for (int i = 0; i < N; i++)
  11. id[i] = i;
  12. }
  13. public int count()
  14. { return count; }
  15. public boolean connected(int p, int q)
  16. { return find(p) == find(q); }
  17. public int find(int p)
  18. { return id[p]; }
  19. public void union(int p, int q)
  20. {
  21. // 获得p和q的组号
  22. int pID = find(p);
  23. int qID = find(q);
  24. // 如果两个组号相等,直接返回
  25. if (pID == qID) return;
  26. // 遍历一次,改变组号使他们属于一个组
  27. for (int i = 0; i < id.length; i++)
  28. if (id[i] == pID) id[i] = qID;
  29. count--;
  30. }
  31. }

举个例子,比如输入的Pair是(5, 9),那么首先通过find方法发现它们的组号并不相同,然后在union的时候通过一次遍历,将组号1都改成8。当然,由8改成1也是可以的,保证操作时都使用一种规则就行。

上述代码的find方法十分高效,因为仅仅需要一次数组读取操作就能够找到该节点的组号,但是问题随之而来,对于需要添加新路径的情况,就涉及到对于组号的修改,因为并不能确定哪些节点的组号需要被修改,因此就必须对整个数组进行遍历,找到需要修改的节点,逐一修改,这一下每次添加新路径带来的复杂度就是线性关系了,如果要添加的新路径的数量是M,节点数量是N,那么最后的时间复杂度就是MN,显然是一个平方阶的复杂度,对于大规模的数据而言,平方阶的算法是存在问题的,这种情况下,每次添加新路径就是“牵一发而动全身”,想要解决这个问题,关键就是要提高union方法的效率,让它不再需要遍历整个数组。

Quick-Union 算法:

考虑一下,为什么以上的解法会造成“牵一发而动全身”?因为每个节点所属的组号都是单独记录,各自为政的,没有将它们以更好的方式组织起来,当涉及到修改的时候,除了逐一通知、修改,别无他法。所以现在的问题就变成了,如何将节点以更好的方式组织起来,组织的方式有很多种,但是最直观的还是将组号相同的节点组织在一起,想想所学的数据结构,什么样子的数据结构能够将一些节点给组织起来?常见的就是链表,图,树,什么的了。但是哪种结构对于查找和修改的效率最高?毫无疑问是树,因此考虑如何将节点和组的关系以树的形式表现出来。

如果不改变底层数据结构,即不改变使用数组的表示方法的话。可以采用parent-link的方式将节点组织起来,举例而言,id[p]的值就是p节点的父节点的序号,如果p是树根的话,id[p]的值就是p,因此最后经过若干次查找,一个节点总是能够找到它的根节点,即满足id[root] = root的节点也就是组的根节点了,然后就可以使用根节点的序号来表示组号。所以在处理一个pair的时候,将首先找到pair中每一个节点的组号(即它们所在树的根节点的序号),如果属于不同的组的话,就将其中一个根节点的父节点设置为另外一个根节点,相当于将一颗独立的树编程另一颗独立的树的子树。直观的过程如下图所示。但是这个时候又引入了问题。

在实现上,和之前的Quick-Find只有find和union两个方法有所不同:

  1. private int find(int p)
  2. {
  3. // 寻找p节点所在组的根节点,根节点具有性质id[root] = root
  4. while (p != id[p]) p = id[p];
  5. return p;
  6. }
  7. public void union(int p, int q)
  8. {
  9. // Give p and q the same root.
  10. int pRoot = find(p);
  11. int qRoot = find(q);
  12. if (pRoot == qRoot)
  13. return;
  14. id[pRoot] = qRoot;    // 将一颗树(即一个组)变成另外一课树(即一个组)的子树
  15. count--;
  16. }

树这种数据结构容易出现极端情况,因为在建树的过程中,树的最终形态严重依赖于输入数据本身的性质,比如数据是否排序,是否随机分布等等。比如在输入数据是有序的情况下,构造的BST会退化成一个链表。在我们这个问题中,也是会出现的极端情况的,如下图所示。

为了克服这个问题,BST可以演变成为红黑树或者AVL树等等。

然而,在我们考虑的这个应用场景中,每对节点之间是不具备可比性的。因此需要想其它的办法。在没有什么思路的时候,多看看相应的代码可能会有一些启发,考虑一下Quick-Union算法中的union方法实现:

  1. public void union(int p, int q)
  2. {
  3. // Give p and q the same root.
  4. int pRoot = find(p);
  5. int qRoot = find(q);
  6. if (pRoot == qRoot)
  7. return;
  8. id[pRoot] = qRoot;  // 将一颗树(即一个组)变成另外一课树(即一个组)的子树
  9. count--;
  10. }

上面 id[pRoot] = qRoot 这行代码看上去似乎不太对劲。因为这也属于一种“硬编码”,这样实现是基于一个约定,即p所在的树总是会被作为q所在树的子树,从而实现两颗独立的树的融合。那么这样的约定是不是总是合理的呢?显然不是,比如p所在的树的规模比q所在的树的规模大的多时,p和q结合之后形成的树就是十分不和谐的一头轻一头重的”畸形树“了。

所以我们应该考虑树的大小,然后再来决定到底是调用:

id[pRoot] = qRoot 或者是 id[qRoot] = pRoot

即总是size小的树作为子树和size大的树进行合并。这样就能够尽量的保持整棵树的平衡。

所以现在的问题就变成了:树的大小该如何确定?

我们回到最初的情形,即每个节点最一开始都是属于一个独立的组,通过下面的代码进行初始化:

  1. for (int i = 0; i < N; i++)
  2. id[i] = i;    // 每个节点的组号就是该节点的序号

以此类推,在初始情况下,每个组的大小都是1,因为只含有一个节点,所以我们可以使用额外的一个数组来维护每个组的大小,对该数组的初始化也很直观:

  1. for (int i = 0; i < N; i++)
  2. sz[i] = 1;    // 初始情况下,每个组的大小都是1

而在进行合并的时候,会首先判断待合并的两棵树的大小,然后按照上面图中的思想进行合并,实现代码:

  1. public void union(int p, int q)
  2. {
  3. int i = find(p);
  4. int j = find(q);
  5. if (i == j) return;
  6. // 将小树作为大树的子树
  7. if (sz[i] < sz[j]) { id[i] = j; sz[j] += sz[i]; }
  8. else { id[j] = i; sz[i] += sz[j]; }
  9. count--;
  10. }

Quick-Union 和 Weighted Quick-Union 的比较:

可以发现,通过sz数组决定如何对两棵树进行合并之后,最后得到的树的高度大幅度减小了。这是十分有意义的,因为在Quick-Union算法中的任何操作,都不可避免的需要调用find方法,而该方法的执行效率依赖于树的高度。树的高度减小了,find方法的效率就增加了,从而也就增加了整个Quick-Union算法的效率。

上图其实还可以给我们一些启示,即对于Quick-Union算法而言,节点组织的理想情况应该是一颗十分扁平的树,所有的孩子节点应该都在height为1的地方,即所有的孩子都直接连接到根节点。这样的组织结构能够保证find操作的最高效率。

那么如何构造这种理想结构呢?

在find方法的执行过程中,不是需要进行一个while循环找到根节点嘛?如果保存所有路过的中间节点到一个数组中,然后在while循环结束之后,将这些中间节点的父节点指向根节点,不就行了么?但是这个方法也有问题,因为find操作的频繁性,会造成频繁生成中间节点数组,相应的分配销毁的时间自然就上升了。那么有没有更好的方法呢?还是有的,即将节点的父节点指向该节点的爷爷节点,这一点很巧妙,十分方便且有效,相当于在寻找根节点的同时,对路径进行了压缩,使整个树结构扁平化。相应的实现如下,实际上只需要添加一行代码:

  1. private int find(int p)
  2. {
  3. while (p != id[p])
  4. {
  5. // 将p节点的父节点设置为它的爷爷节点
  6. id[p] = id[id[p]];
  7. p = id[p];
  8. }
  9. return p;
  10. }

至此,动态连通性相关的Union-Find算法基本上就介绍完了,从容易想到的Quick-Find到相对复杂但是更加高效的Quick-Union,然后到对Quick-Union的几项改进,让我们的算法的效率不断的提高。

这几种算法的时间复杂度如下所示:

Algorithm

Constructor

Union

Find

Quick-Find

N

N

1

Quick-Union

N

Tree height

Tree height

Weighted Quick-Union

N

lgN

lgN

Weighted Quick-Union With Path Compression

N

Very near to 1 (amortized)

Very near to 1 (amortized)

对大规模数据进行处理,使用平方阶的算法是不合适的,比如简单直观的Quick-Find算法,通过发现问题的更多特点,找到合适的数据结构,然后有针对性的进行改进,得到了Quick-Union算法及其多种改进算法,最终使得算法的复杂度降低到了近乎线性复杂度。

如果需要的功能不仅仅是检测两个节点是否连通,还需要在连通时得到具体的路径,那么就需要用到别的算法了,比如DFS或者BFS。

并查集的应用,可以参考另外一篇文章 并查集应用举例

并查集(Union-Find)算法介绍的更多相关文章

  1. 并查集(Union/Find)模板及详解

    概念: 并查集是一种非常精巧而实用的数据结构,它主要用于处理一些不相交集合的合并问题.一些常见的用途有求连通子图.求最小生成树的Kruskal 算法和求最近公共祖先等. 操作: 并查集的基本操作有两个 ...

  2. 【数据结构】【计算机视觉】并查集(disjoint set)结构介绍

    1.简述 在实现多图像无序输入的拼接中,我们先使用surf算法对任意两幅图像进行特征点匹配,每对图像的匹配都有一个置信度confidence参数,来衡量两幅图匹配的可信度,当confidence> ...

  3. 最小生成数(并查集)Kruskal算法

    并查集:使用并查集可以把每个连通分量看作一个集合,该集合包含连通分量的所有点.这两两连通而具体的连通方式无关紧要,就好比集合中的元素没有先后顺序之分,只有属于和不属于的区别.#define N 100 ...

  4. 并查集实现Tarjan算法

    本文是对http://noalgo.info/476.html的一点理解,特别是对其中 int father[mx]: //节点的父亲 int ancestor[mx]; //已访问节点集合的祖先 这 ...

  5. 普林斯顿算法(1.3)并查集(union-find算法)——本质就是一个数 下面的子树代表了连在一起的点

    转自:https://libhappy.com/2016/03/algs-1.3/ 假设在互联网中有两台计算机需要互相通信,那么该怎么确定它们之间是否已经连接起来还是需要架设新的线路连接这两台计算机. ...

  6. POJ 1611 The Suspects 并查集 Union Find

    本题也是个标准的并查集题解. 操作完并查集之后,就是要找和0节点在同一个集合的元素有多少. 注意这个操作,须要先找到0的父母节点.然后查找有多少个节点的额父母节点和0的父母节点同样. 这个时候须要对每 ...

  7. Union-Find(并查集): Quick find算法

    解决dynamic connectivity的一种算法:Quick find Quick find--Data sturcture 如果两个objects是相连的,则它们有相同的array value ...

  8. 九度OJ 1024 畅通工程 -- 并查集、贪心算法(最小生成树)

    题目地址:http://ac.jobdu.com/problem.php?pid=1024 题目描述:     省政府"畅通工程"的目标是使全省任何两个村庄间都可以实现公路交通(但 ...

  9. HDU ACM 2586 How far away ?LCA-&gt;并查集+Tarjan(离线)算法

    题意:一个村子有n个房子,他们用n-1条路连接起来,每两个房子之间的距离为w.有m次询问,每次询问房子a,b之间的距离是多少. 分析:近期公共祖先问题,建一棵树,求出每一点i到树根的距离d[i],每次 ...

  10. Java 并查集Union Find

    对于一组数据,主要支持两种动作: union isConnected public interface UF { int getSize(); boolean isConnected(int p,in ...

随机推荐

  1. 最简单的视频编码器:基于libx265(编码YUV为H.265)

    ===================================================== 最简单的视频编码器系列文章列表: 最简单的视频编码器:编译 最简单的视频编码器:基于libx ...

  2. Git打补丁常见问题

    Git打补丁常见问题 往往觉得得到某个功能的补丁就觉得这个功能我就已经成功拥有了,可是在最后一步的打补丁的工作也是须要相当慎重的,甚至有可能还要比你获取这个补丁花费的时间还要多.看到好多同行遇到这个问 ...

  3. 乐在其中设计模式(C#) - 提供者模式(Provider Pattern)

    原文:乐在其中设计模式(C#) - 提供者模式(Provider Pattern) [索引页][源码下载] 乐在其中设计模式(C#) - 提供者模式(Provider Pattern) 作者:weba ...

  4. Python 保存爬行动物捕捉网页

    选址的桌面壁纸网站汽车主题: 下面的两个print打开调试期间 #print tag #print attrs #!/usr/bin/env python import re import urlli ...

  5. Java知多少(87)选择框和单选按钮(转)

    选择框.单选框和单选按钮都是选择组件,选择组件有两种状态,一种是选中(on),另一种是未选中(off),它们提供一种简单的 “on/off”选择功能,让用户在一组选择项目中作选择. 选择框 选择框(J ...

  6. Java读取图像和网络存储

    该公司最近在搞一个Web工程,需要下载网络图片,那么既然恢复了一些最基本的东西.数据传输不同的流,简单,很容易下载网络打破了样品的图片,代码非常easy.贡献给大家! 结论,图片主要就四步: 1:拿到 ...

  7. JAVA 公众微信的开放源码项目管理合作伙伴招募的版本号

    大家好:       jeecg开源社区.现在正在进行"JAVA 开放源码的版本号项目微信公共账号"工作,有兴趣的朋友一起參与. 截止时间:20140510 採用技术: 1. JE ...

  8. 【世外桃源】福音节目 swtychina.com

    [世外桃源]福音节目 今天小编满怀热情,带着激动地心情,颤抖的双手,以“大无畏的精神,高山仰止的情操”为大家郑重推荐很好的一个福音节目: <世外桃源>, 哎,不对,貌似从2013年初开始& ...

  9. CentOS-6.5-x86_64 最小化安装后,怎样安装 man 程序?

    CentOS-6.5-x86_64 最小化安装后.怎样安装man 程序? CentOS-6.5-x86_64 最小化安装后,没有man 程序,没它还真的不方便. man 是 manual(手冊)的意思 ...

  10. webstorm创建nodejs + express + jade 的web 项目

    webstorm创建nodejs + express + jade 的web 项目 前简单了解过nodejs,觉得用nodejs来做个网站也太麻烦了,要自己拼html的字符串返回,这能做网站嘛? 最近 ...