0101quick_find_union-union_find-动态连通性-算法研究

1 前言

为了说明我们设计和分析算法的基本方法,我们现在来学习一个具体的例子。我们的目的是强调以下几点:

  • 优秀的算法因为能够解决实际的问题而变得更为重要;
  • 高效算法的代码也可以很简单;
  • 理解某个实现的性能特点是一项有趣而令人满足的挑战;
  • 在解决统一问题的多种算法之间进行选择时,科学方法是一种重要的工具;
  • 迭代式改进能够让算法的效率越来越高。

下面我们要研究的就是关于动态连通性的计算性问题,首先我们会给出一个简单的方案,然后对它的性能进行研究并由此得出应用如果继续改进我们的算法。

2 动态连通性

有以下问题:问题的输入是一列整数对,其中某个整数表示某个类型的对象,一对整数p,q表示“p和q是相连的”。相连时一种等价关系,具有以下性质:

  • 自反性:p和p也是相连的;
  • 对称性:如果p和q时相连的,那么q和p也是相连的;
  • 传递性:如果p和q时相连的,q和r时相连的,那么p和r也是相连的。

依据等价关系分类,可以把对象分为若干个等价类。我们的目标是编写程序来过滤掉掉没有意义的整数对(两个整数在同一个等价类中)。

问题处理描述:

  • 程序从输入中读取了整数对pq时,如果已知所有整数对都不能说明p和q时相连的,那么将该整数的写入输出中;
  • 如果已知整数对能说明p和q上相连的,那么程序忽略pq这对整数继续处理下一对整数。

为了达到上述效果,我们需要一个数据结构存储已知的整数足够多的信息,并用它们判断一对新对象是否相连。 这种问题我们称之为动态连通性问题。动态连通性应用,如下表所示:

应用场景 对象 对象关系(整数对) 问题描述
大型计算机网络 计算机 网络中连接 计算机与计算机之间是否需要假设一条新的连接才能通信
电路板 触点 连接触点之间的电路 触点之间是否电路已经连通
变量名等价性 变量名 同一个对象的多个引用 判断两个变量名是否等价(指向同一个对象的引用)

在以后内容中我们使用网络中的术语,称对象为触点,将整数对称为连接,将等价类称为连通分量或者分量。简单起见,用0到N-1之间到整数表示N个触点。

如下图2-1所示,有几个连通分量?怎么判断两个触点是否在同一个分量中呢?

0101quick_find_union-union_find-动态连通性-算法研究_第1张图片

3 算法

3.1 算法设计

**我们设计算法的第一个任务就是要精确的定义问题。**一般情况下,算法能解决的问题越大,它完成任务所需的时间和空间就越多。但是它们之间的量化关系很难预先知道。通常我们只会在发现解决问题很困难,或者代价太大,或者幸运地发现算法所提供的信息比原问题所需的更加有用是修改问题。

以连通性问题为例,问题只要求我们能够判断给定的整数对p和q是否相连,但并没有要求给出两者之间的通路上的所有连接。

为了说明这个问题,我们设计类一份API封装所需的基本操作:初始化、连接两个触点、判断包含某个触点的分量、判断两个触点是否在同一个分量中以及返回分量的数量。

3.1 union-find算法API

详细的API如下表3.1-1所示:

public class UF
public UF(int N) 初始化N个触点
public void union(int p, int q) 连接触点p和q
public int find(int p) p所在分量的标志符
public boolean connected(int p, int q) 触点p和q是否相连
public int count() 连通分量的数量
  • 如果两个触点在不同的连通分量中,union()方法会将两个分量合并;
  • count()返回连通分量的数量,初始为N,没合并一次,数量减1。

3.2 数据结构和通用实现

为解决动态连通性问题设计算法的任务转化为实现这份API,所有的实现都应该:

  • 定义一种数据结构表示已知的连接;
  • 基于此数据结构实现高效的union()、find()、connected()和count()方法。

**数据结构的性质直接影响算法的效率。**这里我们触点和分量用int值表示,我们用一个以触点为索引的数组id[]作为数据结构表示所有的分量。

  • 初始化:触点i的名称为分量的标识,分量初始大小为N,值为触点对应的索引id[i];
  • connected():通过find§==find(q)判断触点q和触点q所在分量是否相等即是否相连。

union-find的成本模型:在研究实现union-find的APi的各种算法时,我们统计的是数组的访问次数(访问任意数组元素的次数,无论读写)。

3.3 quick-find算法

3.3.1 思想和实现

一种思想是保证当前仅当ip[p]等于id[q]时p和q时连通的。即同一连通分量中所有的id[]的值都是相等的。

  • find(i):只需返回id[i]即为触点i所在的分量标志;
  • union():
    • 首先通过connected()判断触点p和q是否相连,如果相连,啥也不做;
    • 如果不相连,连接触点p和触点q所在的分量。
      • 触点p所在分量标志都为一个值,触发q所在的连通分量都为另外一个值;
      • 通过遍历把p所在连通分量值改为q所在连通分量的值或者把q所在连通分量id[q]改为p所在分量的值id[p]

我们称这种实现方式为quic-find算法,实现代码3.3-1如下所示:

package edu.princeton.cs.algs4;

/**
 *  动态连通性quick-find算法
 */

public class QuickFindUF {
    /**
    * 触点所在分量标志
    */
    private int[] id;
  
    /**
    * 连通分量数量
    */
    private int count;

    /**
     * 初始化触点数量
     * {@code n} elements {@code 0} through {@code n-1}. 
     *
     * @param  初始化触点数量{@code n}
     */
    public QuickFindUF(int n) {
        count = n;
        id = new int[n];
        for (int i = 0; i < n; i++)
            id[i] = i;
    }

    /**
     * 连通分量的数量
     *
     * @return 数量 (between {@code 1} and {@code n})
     */
    public int count() {
        return count;
    }
  
    /**
     * 返回触点p所在的分量标志
     *
     * @param  触点p 
     * @return {@code p}所在分量的标志
     */
    public int find(int p) {
        validate(p);
        return id[p];
    }

    /**
    * 校验触点p是否合法
    * @param  触点p 
    */
    private void validate(int p) {
        int n = id.length;
        if (p < 0 || p >= n) {
            throw new IllegalArgumentException("index " + p + " is not between 0 and " + (n-1));
        }
    }

    /**
     * 判断触点p和触点q是否相连
     * 
     * @param  触点p
     * @param  触点q
      * @return {@code true} 如果 {@code p} 和 {@code q} 相连;
     *         {@code false} 否则
     */
    @Deprecated  
    /**
     * 连接触点p所在的分量和触点q所在的分量
     */
    public void union(int p, int q) {
        validate(p);
        validate(q);
        int pID = id[p];   // needed for correctness
        int qID = id[q];   // to reduce the number of array accesses

        // p and q are already in the same component
        if (pID == qID) return;

        for (int i = 0; i < id.length; i++)
            if (id[i] == pID) id[i] = qID;
        count--;
    }
}

测试数据如下所示:

10
4 3
3 8
6 5
9 4
2 1
8 9
5 0
7 2
6 1
1 0
6 7

测试程序3.3-2如下所示:

public static void testQF() {

    String path = System.getProperty("user.dir") + File.separator + "asserts/tinyUF.txt";
    In in = new In(path);
    int n = in.readInt();
    QuickFindUF uf = new QuickFindUF(n);
    while (in.hasNextLine()) {
        int p = in.readInt();
        int q = in.readInt();
        if (uf.find(p) == uf.find(q)) continue;
        uf.union(p, q);
        StdOut.println(p + " " + q);
    }
    StdOut.println(uf.count() + " 连通分量");
}

测试结果如下所示:

4 3
3 8
6 5
9 4
2 1
5 0
7 2
6 1
2 连通分量

轨迹示意图3.3-1如下所示:

0101quick_find_union-union_find-动态连通性-算法研究_第2张图片

3.3.2 分析

find()操作的速度很快,因为它只需要访问id[]数组一次。但quick-find算法一般无法处理大型问题,因为对于每一对输入uinon()都需要扫描整个id[]数组。

命题F。在quick-find算法中,每次find()调用只需要访问数组一次,而归并两个分量的union()操作访问数组的次数在N到2N+2次之间;

证明:find()访问数组一次很明显。union()获取pid,qid访问2次id[]。如果全部pid等于qid那么最少需要遍历整个数组即N次;如果pid和qid都不想等,那么会检查id[]数组中的全部N个元素并改变它们中的1-N个元素的值,即最多2N+2次。

在最坏情况下,我们使用quick-find算法来解决动态连通性问题并且最坏只得到了一个连通分量,那么至少需要调用N-1次union(),即至少N(N-1)~(2N+2)(N-1)次数组访问-qucik-find解决动态连通性问题算法是平方级别的。

3.4 quick-union算法

3.4.1 算法描述

即然qucik-find算法的瓶颈在于union连接,那么我们想办法提高union方法的速度。

quick-uinon算法使用相同的数据结构:

  • 以触点为索引的数组id[];
  • 但是某个触点p,对应的id[p]不在表示分量标志,而是指向同一分量中的另外一个触点-这种联系我们称之为链接。

find()方法实现:

  • 我们从给定的触点p触发,通过调用id[p]获取上一个触点,以此类推,直到该分量的根触点,即链接指向自己的触点-在后面我们要学习的树结构中称之为跟结点。

connected()方法实现:

  • 只有当2个触点对应的根触点相等时,表示2个触点在同一个连通分量中。

union(p,q)方法实现:

  • 我们有p和q的链接分别找到它们的根触点,判断如果想等,不需要操作;
  • 不相等只需要将以恶根触点链接到另外一个根触点即可。

3.4.2 算法实现

实现代码3.4.2-1如下所示:

package edu.princeton.cs.algs4;

/**
 *  quick-union算法
 */
public class QuickUnionUF {
    /**
     * 父链接(触点)数组
    */ 
    private int[] parent;
  
    /**
     * 分量数量
    */
    private int count;

    /**
     * 初始化有n个触点的parent[]
     *
     * @param  n the number of element
     */
    public QuickUnionUF(int n) {
        parent = new int[n];
        count = n;
        for (int i = 0; i < n; i++) {
            parent[i] = i;
        }
    }

    /**
     * 分量的数量
     *
     * @return 分量的数量
     */
    public int count() {
        return count;
    }
  
    /**
     * 返回触点p所在的分量标志
     *
     * @param  p 触点p
     */
    public int find(int p) {
        validate(p);
        while (p != parent[p])
            p = parent[p];
        return p;
    }

    /**
     * 校验触点p
    */
    private void validate(int p) {
        int n = parent.length;
        if (p < 0 || p >= n) {
            throw new IllegalArgumentException("index " + p + " is not between 0 and " + (n-1));
        }
    }

    /**
     * 判断触点p和触点q是否在同一分量中
     * 
     * @param  p 触点p
     * @param  q 触点q
     * @return {@code true} 如果触点 {@code p} and {@code q} 在同一分量中;
     *         {@code false} 否则
     */
    @Deprecated
    public boolean connected(int p, int q) {
        return find(p) == find(q);
    }

    /**
     * 合并触点p所在分量和触点q所在分量
     *
     * @param  p 触点或者所在分量
     * @param  q 触点q所在分量
     */
    public void union(int p, int q) {
        int rootP = find(p);
        int rootQ = find(q);
        if (rootP == rootQ) return;
        parent[rootP] = rootQ; 
        count--;
    }
}

测试用例同quick-find算法,测试代码3.4.2-2如下所示:

public static void testQU() {
    String path = System.getProperty("user.dir") + File.separator + "asserts/tinyUF.txt";
    In in = new In(path);
    int n = in.readInt();
    QuickUnionUF uf = new QuickUnionUF(n);
    while (!in.isEmpty()) {
        int p = in.readInt();
        int q = in.readInt();
        if (uf.find(p) == uf.find(q)) continue;
        uf.union(p, q);
        StdOut.println(p + " " + q);
    }
    StdOut.println(uf.count() + " components");
}

测试结果如下所示:

4 3
3 8
6 5
9 4
2 1
5 0
7 2
6 1
2 components

3.4.3 性能分析

id[]用父链接的形式表示了一片森林,union实现了将一个根结点变为另外一个根结点的父结点,从而归并了两棵树。如下图3.4.3-1所示:

0101quick_find_union-union_find-动态连通性-算法研究_第3张图片

定义:一棵树的大小树它的节点数量。树中的一个节点的深度树它到根结点路径上的链接数。树的高度树它的所有节点中的最大深度值。

命题G。quick-union算法中的find()方法访问数组的次数为1加上给定触点对应的节点的深度2倍。union()和connected()访问数组的次数为两次find()操作给定两个触点的分别存在不同树中则还需要加1。

假设我们使用quick-union算法最终解决了动态连通性问题并最终只得到一个分量,由命题G只算法在最坏情况下上平方级别的。

最坏情况即我们的整数对为有序的0-1、0-2、0-3等,最后我们所有的触点全都在一个分量中,id[]形成一条链表。整数对0-i,union()操作访问数组次数2i+1(触点0的深度i-1,触点i的深度0),处理N对整数所需所有find()操作数组的总次数 3 + 5 + 7 + ⋯ + ( 2 N − 1 ) ∽ N 2 3+5+7+\cdots+(2N-1)\backsim N^2 3+5+7++(2N1)N2

结语

如果小伙伴什么问题或者指教,欢迎交流。

❓QQ:806797785

⭐️源代码仓库地址:https://gitee.com/gaogzhen/algorithm

参考链接:

[1][美]Robert Sedgewich,[美]Kevin Wayne著;谢路云译.算法:第4版[M].北京:人民邮电出版社,2012.10.p136-149.

你可能感兴趣的:(数据结构和算法,union-find,数据结构和算法,Java,quick-find,quick-union)