算法(1):Union-Find

来源:互联网 发布:淘宝信誉查询网站 编辑:程序博客网 时间:2024/05/16 12:07

编写一段计算机程序一般都是实现一种已有的方法来解决某个问题,这种方法适用于各种计算机及编程语言。在计算机科学(Computer Science,CS)领域,用算法(Algorithm)这个词来描述一种有限、确定、有效的并适合用计算机语言来实现的解决问题的方法。算法是CS的基础,是这个领域研究的核心。

这里通过考虑一个叫Union-Find的动态连通性问题,来说明开发和分析算法的基本方法。

动态连通性

动态连通性问题是这样的:有一组共N个对象,用0到N的数字来标记它们,两个对象间可以是相连的。假设有一个方法union,可以用来连接两个对象,将两个对象的标记传入这个方法,这两个对象就相连了。假设“相连”是一种等价关系,即p与q相连后具有:
* 自反性:p和p是相连的,q和q是相连的
* 对称性:q与p也是相连的
* 传递性:如果q和r相连,则p和r也相连

现在,问题的关键是连通性的查询,即查询两个对象之间是否有连通的路径存在。
查询
如图,connected方法用来查询两个对象的连通性,图中0和7未连通,而8和9是连通的。对大量对象执行这些方法时,就需要有一个高效的算法。这便是我们需要解决的问题。

关于这个问题的算法的应用涉及各种各样的对象。在数码照片上,应用对象是照片上的每一个像素点;在网络上,应用的对象是每一台计算机;在社交网络上,应用的对象就是每一个人。

现在,定义好Union-Find算法的API如下:

Union-Find API

我们的目标,就是为Union-Find设计一个高效的数据结构。

以下的实现都根据以对象为索引的id[]数组来确定两个对象是否存在连通的分量中。

Quick-Find算法

Quick-Find算法是保证当且仅当id[p]等于id[q]时p和q是连通的,也就是说,同一个连通分量中的所有对象在id[]中的值必须全部相同。如图所示。

Quick-Find

则connected操作的实现中,只要传入p、q并判断id[p]和id[q]是否相等,find操作只要返回对象在数组id[]中的值,而进行union操作时,需要遍历整个数组id[],将已连通的分量赋上相同的值。

Quick-Find具体实现为:

//Quick-Findpublic class QuickFindUF {   private int[] id;   public QuickFindUF(int N) {      id = new int[N];      for (int i = 0; i < N; i++) //初始化数组        id[i] = i;   }   public  boolean connected(int p, int q) {      return id[p] == id[q];   }   public int find(int p) {     return id[p];   }   public void union(int p, int q) {     int pid = find(p);     int qid = find(q);     if(pid == qid)       return;     for (int i = 0; i < id.length; i++) //遍历整个数组       if(id[i] == pid)         id[i] = qid;   }}

当对N个对象当进行N次union操作时,Quick-Find的初始化过程需要访问N次数组,union过程需要访问N次数组,find只访问了一次数组,整体相当于要进行N2次数组访问,过于缓慢,遇到大型问题时无法高效处理。

Quick-Union算法

Quick-Union算法在于提高了Quick-Find算法中union方法的速度。还是用以对象作为索引的数组id[]作数据结构,但这个数组有了不同的含义。将数组看一组树即一片森林,其中的每一个对象刚开始都是一棵树,数组中每一项的值,将用来代表它的父节点的索引。

Quick-Union

如图所示,每个对象都有它的根节点,3的父节点索引是4,4的父节点索引是9,2的父节点索引也是9,则2、3、4的根节点都是9,而9的父节点和根节点都是它本身,这样就形成一颗树。

这样的话,在我们的实现过程中,connected操作就是确定两个对象的根节点的值是否相同,find操作就是确定对象的根节点,union操作就是将两个对象的根节点值统一。

Quick-Union具体实现为:

//Quick-Unionprivate class QuickUnionUF {  private int[] id;  public QuickUnionUF(int N) {     id = new int[N];    for (int i = 0; i < N; i ++)      id[i] = i;  }  public int find(int p) {     while (p != id[p])       p = id[p]; //找出根节点     return p;  }  public boolean connected(int p, int q) {    return find(p) == find(q);  }  public void union(int p, int q) {     int pRoot = find(p);     int qRoot = find(q);     if (pRoot == qRoot)       return;     id[pRoot] = qRoot;  }}

对于Quick-Union算法,当对N个对象当进行N次union操作时,初始化过程需要访问N次数组,union过程需要寻找根节点,要访问N+次数组,find寻找根节点时也需要可能访问N次数组。对于大型的问题来说,Quick-Union算法比Quick-Find有些情况快了一些,但极端情况下可能比Quick-Find还糟糕。

加权Quick-Union算法

Quick-Union算法的缺点在于到后面一棵树可能会变得很高,这时确定某个对象的根节点的代价就会变得很大,糟糕的情况下可能要遍历整棵树才能找到某个对象的根节点。对此进行改进的方法之一,就是进行加权。

加权操作,就是在Quick-Union算法中,跟踪每一棵树中对象的个数,确保总是将小树的根节点作为大树根节点的子节点,避免将一棵大树放在小树下面,这样就能有效缩短树的高度。

加权Quick-Union和不加权对比

加权Quick-Union具体实现为:

//Weighted Quick-Unionpublic class  WeightedQuickUnionUF {   private int[] id;   private int[] sz;   public WeightedQuickUnionUF(int N) {      id = new int[N];      for (int i = 0; i < N; i++)        id[i] = i;      sz = new int[N];      for (int i = 0; i < N; i++)        sz[i] = 1;   }   public int find(int p) {      while (p != id[p])        p = id[p];      return p;   }   public boolean connected(int p, int q) {      return find(p) == find(q);   }   public void union(int p, int q) {     int pRoot = find(p);     int qRoot = find(q);     if (pRoot == qRoot)       return;      if (sz[pRoot] < sz[qRoot]) {         id[pRoot] = qRoot;         sz[qRoot] += sz[pRoot];      } else {         id[qRoot] = pRoot;         sz[pRoot] = sz[qRoot];      }   }}

加权Quick-Union中,N个对象连接在一棵树上的形成的树的最大高度为lgN。对N个对象当进行N次union操作时,初始化过程需要访问N次数组,union过程需要访问lgN+次数组,find寻找根节点时最多也只需要访问lgN次数组。面对大型问题时,这个算法的性能就比较能接受了,实现起来也很容易。而这个算法也还可以再多改进一些。

在加权Quick-Union的基础上再进行路径压缩是很容易实现的。理想情况下,我们希望每个对象节点都直接连接到根节点上,但又不想像Quick-Find里的那样进行修改大量的连接,于是我们可以找到某个对象的根节点后,回过头来将这个对象节点直接连接上根节点,这样整个树的路径就得到了极大的压缩。

实际上,为了只添加少量代码,我们只将每个节点指向它路径上的祖父节点,这样并没又将树完全展平,具体实现是将find方法修改如下:

public int  find(int i) {  while(i != id[i]) {    id[i] = id[id[i]];    i = id[i];  }  return i;}

这样只用修改了一行代码,就将树基本展平,效果和完全展平相当。

到这里。总结比较一下以上几种算法的性能:

比较

对N个对象进行M次Union-Find操作,如表所示,几种算法的性能往下越来越好。

算法分析

编写好的一个程序将会运行多长的时间,将会占用多大的内存,这些都是我们需要经常考虑的问题。对此,就要用一些科学的方法,对一个算法的性能好坏作出大致的分析预测,了解最坏情况下算法性能的底线,避免性能错误。

CS圣经之一《计算机程序设计艺术》的作者D.E.Knuth认为,尽管有许多复杂的因素影响我们对程序的运行时间的理解,原则上我们还是可以构建出一个数学模型来描述任意程序的运行时间。他的基本见解是,一个程序运行的总时间主要与执行每条语句和耗时、执行每条语句的频率这两点有关,前者取决于计算机、编译器和操作系统,而后者就取决于程序的本身和输入。

以下是一个叫3-SUM的程序:

//3-SUMpublic calss ThreeSUM {   public staic int count(int[] a) {     int  N = a.length;     int count = 0;     for (int i = 0; i < N; i++)       for(int j = 0; j < N; j++)         for(int k = j+1; k < N; k++)           if(a[i]  + a[j] + a[k] == 0)             count++;     return count;   }   public static void main(String[] args) {      int[] a = In.readInts(args[0]);      StdOut.println(count(a));   }}

它用来统计一个文本文件里所有和为0的三个整数的数量。其中if语句的执行次数为:

N(N1)(N2)6=N36N22+N3

这种表达式中,首项之后的其他项都相对较小,于是常用约等于号(~)来忽略较小的项,以此简化式子。于是上式的近似就为 N36,以这种近似模型来对程序的开销作出大致的衡量。

在分析算法时,一般也不会遇到太多各种不同的函数,所以我们可以对算法的增长阶数进行分类,算法分析时遇到的增长阶数有下面几种:

函数

当一个算法的近似模型等于以上的值时,由数学知识可知,该算法的开销从上到下依次递增。上面的3-SUM问题,使用二分查找法来解决的话,能将程序的运行时间增长数量级从N3降到N2logN

除对增长阶数分类外,不用的输入也会使算法的性能发生剧烈的变化,所以在进行算法分析时,也得考虑一个算法运行时间的最好情况和最坏情况。最好情况是算法代价的下限,程序运行的时间总是大于等于它,最坏情况是算法代价的上界,程序运行的时间不会长于它,两种情况综合起来可以得到用以衡量一般情况下的平均性能。

有一些常用的记号ΘOΩ用来描述性能的界限:

三个记号

O用以描述算法性能的渐进上界,如O(N2)就表示N增长时,程序运行时间小于某个常数乘以N2Ω用以描述最坏情况下的性能下限,如Ω(N2)就表示N增长时,程序运行时间比某个常数乘以N2大;Θ用以表示增长阶数,如Θ(N2)就是某个常数乘以N2的简写。

关于程序内存占用,涉及到的是计算机组成原理,在此不作累赘。

Union-Find应用:渗透问题

问题描述

给定一个由N*N个方格组成的方形水槽,其中每个方格的开启和关闭都是随机的。其中相邻的开启的格子能形成一条通路,如果存在一条从水槽顶端到底端的通路,则称整个水槽是渗透的(Percolation)

渗透

假设其中的一个格子开启的概率为p,研究p的大小与整个系统渗透的概率之间的关系,当系统大小分别为20*20、100*100可以得到以下面的两个曲线。

20*20
100*100

要求编程建立一个模型,来验证当整个系统渗透时,格子的开启概率p的取值区间。(问题具体描述及要求)

问题分析

渗透模型

如图所示,可以将这个问题抽象为Union-Find问题,一个格子抽象为一个节点,并用二维数组进行索引,二维数组索引值需要特别注意。此外,为了方便判断整个系统是否渗透,可以在顶部和底部分别添加一个虚节点,同时将它们分别与系统的顶层所有节点及底层所有节点相连接,这样只要判断顶部和底部的虚节点是否连通即可知整个系统是否渗透。

抽象好整个系统后,根据具体要求中的提示,用蒙特卡洛(Monte Carlo)方法进行仿真分析,确定渗透阈值p。先初始化所有的格子为关闭状态,之后随机开启一些格子,直到系统渗透;统计出开启的格子数,与全部格子数的比值即是作为p值;进行T次实验,得到足够多的p值后,算出所有p的平均值x¯及方差s2后,根据如下公式可以计算出置信区间在95%的的渗透阈值范围:

[x¯1.96sT,x¯+1.96sT]

(具体实现见参考资料)

参考资料

  1. Algorithms-Coursera
  2. 代码及课件资料-GitHub

注:本文涉及的图片及资料均整理翻译自Robert Sedgewick的Algorithms课程以及书籍《算法(第四版)》,版权归其所有。翻译整理水平有限,如有不妥的地方欢迎指出。

更新历史:
* 2017.11.15 完成初稿
原文链接

原创粉丝点击