• 《算法》笔记 2


    • 动态连通性问题
    • 实现
      • 通用代码
      • Quick-Find算法
      • Quick-Union算法
      • 加权Quick-Union算法

    动态连通性问题

    在基础部分的最后一节,作者用一个现实中应用非常广泛的案例,说明以下几点:

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

    动态连通性问题的输入是一列整数对,其中的每个整数都表示一个某种类型的对象,一对整数对p q可以理解为“p和q是相连的”。相连是一种等价关系,其具有:

    • 自反性,p和p也是相连的;
    • 对称性,p和q相连,则q和p也是相连的;
    • 传递性,p和q相连,q和r相连,则p和r也是相连的。

    程序的目标是过滤掉序列中无意义的整数对,当程序从输入中读取了整数对pq时,如果已知的所有整数对都不能说明p和q是相连的,那么则将这一对整数写入到输出中。如果已知的数据可以说明p和q是相连的,那么程序应该忽略pq这对整数并继续处理输入中的下一对整数。

    动态连通性问题的实际应用很多,比如:检查通信网络中计算机之间是否连通、电子电路中的触点是否连接或者社交网络中的人是否相识等等。

    实现

    接下来会统一使用网络方面的术语,将整数称为触点、整数对称为连接、等价类称为连通分量分量
    明确需要解决的问题后,就可以抽象出算法的API了,包括

    void union(int p,int q)  //在p q之间添加一条连接,将两个分量归并
    int find(int p)  //返回p所在分量的标识符
    boolean connected(int p int q) //判断两个触点是否在同一分量
    int count() //连通分量的数量
    

    通用代码

    算法的基本代码如下,其中find()、union()方法在各种算法中有不同的实现。

    public class UF {
    
        private int[] id;  //分量id(以触点作为索引)
        private int count; // 连通分量的数量
    
        public UFquickFind(int n) {
            id = new int[n];
            count=n;
            for (int i = 0; i < n; i++) {
                id[i] = i;
            }
        }
    
        public int find(int p) {
            //待实现
        }
    
        public int count() {
            return count;
        }
    
        public boolean connected(int p, int q) {
            return find(p) == find(q);
        }
    
        public void union(int p, int q) {
            //待实现
        }
    
        public static void main(String[] args) {
            int n = StdIn.readInt();  //读取触点数量
            StdOut.println(n);
            UF uf = new UF(n);  //初始化N个分量
            while (!StdIn.isEmpty()) {
                int p = StdIn.readInt();
                int q = StdIn.readInt();  //读取整数对
                if (uf.connected(p, q))  //是否连通,如果已经连通则忽略
                    continue;
                uf.union(p, q);  //归并分量
            }
            StdOut.println(uf.count() + " components");
        }
    }
    

    Quick-Find算法

    实现

    最直接的方法是当p和q连通时,让id[p]=id[q],所以同一连通分量中的所有触点在id[]中的值是全部相同的。find(p)只需返回id[p],但union()方法在将p和q设置为同一连通分量时比较麻烦,需要改变p或q所在分量中所有元素的值。

    public int find(int p) {
            return id[p];
    }
    
    public void union(int p, int q) {
            int vp = find(p); //一次数组访问
            int vq = find(q); //一次数组访问
    
            if(vp==vq){
                return;
            }
            for(int i=0;i<id.length;i++){
                if(id[i]==vq){ //N次数组访问
                    id[i]=vp;  //最好情况只执行一次,最坏情况执行N-1次
                }
            }
            count--;
    }
    

    这种方法得到的find操作的速度很快,只需要访问id[]数组一次,但union操作由于每次都要扫描整个数组而变得很慢。这里代码中是刷新q所在的分量,也可以改为刷新p所在的分量。

    分析

    在分析算法的成本时,主要考虑的是数组的访问次数(包括读、写)。
    那么Quick-Find算法的成本如何呢?假设问题的规模为N,则数组的长度为N,那么每次find()只访问数组一次,union()访问数组的次数在(N+3)到(2N+1)之间。
    开头的两次find调用访问数组2次,for循环会访问数组N次,然后:
    a.在最好的情况下,q所在的连通分量中,只有q一个成员,内层的if判断只会成立一次。所以总次数=2+N+1=N+3次。

    b.在最坏的情况下,除了元素p,其余成员都与q在同一个连通分量中,那么内层的if判断会成立N-1次,总次数=2+N+N-1=2N+1。

    假设最后只得到一个连通分量,则至少需要调用union方法N-1次,所以最好情况下,union方法的调用总次数为(N-1)*(N+3),用~N^2来近似表示,可见在最终得到少数连通分量的场景下,Quick-Find算法的运行时间随问题规模的增长是平方级别的。平方级别、立方级别、指数级别的算法等都无法用来解决大型问题。

    Quick-Union算法

    接下来的Quick-Union算法相比Quick-Find算法,其Union的速度较快。它也采用相同的数据结构——以触点作为索引的id[]数组,但这次让每个数组元素都代表同一连通分量中另一个分量的名称,实际上同一连通分量的节点构成了一棵树,但数组元素的值等于其索引时,它就是这棵树的根节点。find()操作返回的就是一棵树的根节点,如果两个分量有相同的根节点则表示它们互相连通,否则调用union()方法将其中的一棵树合并到另一颗树上,就完成了两个连通分量的归并。

    实现
    public int find(int p) {
        while (p != id[p]) {
            p = id[p];
        }
        return p;
    }
    
    public void union(int p, int q) {
        int pRoot = find(p);
        int qRoot = find(q);
        if (pRoot == qRoot) {
            return;
        }
        id[pRoot] = qRoot;
        count--;
    }
    
    分析

    quick-union算法的成本依赖输入的特点,在最好的情况下,一棵树只有根节点自己,find()只需访问数组一次;而在最坏的情况下,树的结构为一颗深度为节点数-1,每一层都只有一个节点,设数的深度为N,这时如果需要查找最底层节点的根节点,find()方法需要访问数组N+(N+1)=2N+1次。

    while (p != id[p]) { //N+1次
        p = id[p];  //N次
    }
    

    在这种最坏的情况下,为了让树的深度最大,每次新的节点都会连接到树的最底层,输入整数对是有序的0-1,0-2,0-3等,其中0链接接到1,1链接到2,2链接到3,可见union的访问次数为:(2N+1)+1+1=2N+3次

    public void union(int p, int q) {
        int pRoot = find(p); //2N+1次
        int qRoot = find(q); //1次,另一个节点的根节点为它自己
        if (pRoot == qRoot) {
            return;
        }
        id[pRoot] = qRoot; //1次
        count--;
    }
    

    所以忽略常数3,处理N个节点的访问次数为2(1+2+...+N)=2n(n-1)/2,用~N^2来近似表示,则最坏情况为平方级别。

    加权Quick-Union算法

    Quick-Union算法的速度取决于生成的树的深度,深度越大速度越慢。union操作时,避免将较大的树链接到较小的树可以有效控制树的深度,从而大大改进算法的效率,这便是加权Quick-Union算法。加权Quick-Union算法是在Quick-Union算法的基础上,增加一个数组来记录每颗树的权重(树包含的节点数量),然后在union操作时将权重小的树链接到权重大的树。

    实现
    public void union(int a, int b) {
        int aRoot = find(a);
        int bRoot = find(b);
        if (aRoot == bRoot) {
            return;
        }
    
        if (size[aRoot] < size[bRoot]) {  //size[]记录树的权重
            parent[aRoot] = bRoot;
            size[bRoot] += size[aRoot];  //树归并时,权重也会发生变化
        } else {
            parent[bRoot] = aRoot;
            size[aRoot] += size[bRoot];
        }
        count--;
    }
    
    分析

    关于加权Quick-Union算法的最坏情况,既较要被归并的两棵树的大小总数相等的,且大小都是2的幂(满二叉树),此时N个节点的树的深度为Lg(N),结合对Quick-Union算法的分析可知加权Quick-Union算法的成本增长数量级为对数级别。

    综上,动态连通性问题的求解过程便是一个定义问题、给出初级算法的实现、当算法能解决问题的规模达不到期望时逐步改进算法的过程。并且用经验性的分析(和数学分析)验证改进后的效果,尽量为算法在最快情况下的性能提供保证,但在处理普通数据时也要有良好的性能。

  • 相关阅读:
    Django项目引入NPM和gulp管理前端资源
    Django实现统一包装接口返回值数据格式
    即学即会 Serverless | 如何解决 Serverless 应用开发部署的难题?
    足不出户,搞定交付——独家交付秘籍(第二回)
    如何使用阿里云容器服务保障容器的内存资源质量
    恭喜我的同事丁宇入选年度 IT 领军人物
    基于 KubeVela 的机器学习实践
    基于 KubeVela 的机器学习实践
    OpenKruise v1.1:功能增强与上游对齐,大规模场景性能优化
    云原生时代如何用 Prometheus 实现性能压测可观测Metrics 篇
  • 原文地址:https://www.cnblogs.com/zhixin9001/p/11380223.html
Copyright © 2020-2023  润新知