C#并查集(union-find)算法详解

目录
  • 算法的主题思想:
  • 1. 动态连通性
  • 2. 定义问题
  • 3. quick-find算法实现
    • 算法分析
  • 4. quick-union算法实现
    • 森林表示
    • 算法分析
  • 5.加权 quick-union 算法实现
    • 算法分析
  • 6.最优算法 - 路径压缩

算法的主题思想:

1.优秀的算法因为能够解决实际问题而变得更为重要;

2.高效算法的代码也可以很简单;

3.理解某个实现的性能特点是一个挑战;

4.在解决同一个问题的多种算法之间进行选择时,科学方法是一种重要的工具;

5.迭代式改进能够让算法的效率越来越高效;

1. 动态连通性

动态连接:输入是一对整数对的序列,其中每个整数代表某种类型的对象(或触点),我们将整数对p q 解释为意味着p连接到q。我们假设“连接到”是等价关系:

  • 对称性:如果p连接到q,则q 连接到p。
  • 传递性:如果p连接到q且q 连接到r,则p连接到r。
  • 自反性:p与p连接。

等价关系将对象划分为多个等价类 或连接的组件。等价类称为连通分量或分量。
我们的目标是编写一个程序,以从序列中过滤掉多余的对:当程序从输入中读取整数对 p q时,只有在该对点不等价的情况下,才应将对写入到输出中,并且将p连接到q。如果等价,则程序应忽略整数对pq 并继续读取下对。

动态连通性问题的应用:

  • 1.网络
  • 2.变量名等价性
  • 3.数学集合

在更高的抽象层次上,可以将输入的所有整数看做属于不同的数学集合。

2. 定义问题

设计算法的第一个任务就是精确地定义问题。

算法解决的问题越大,它完成任务所需的时间和空间可能越多。我们不可能预先知道这其间的量化关系,通常只会在发现解决问题很困难,或是代价巨大,或是发现算法所提供的信息比原问题所需要的更加有用时修改问题。例如,连通性问题只要求我们的程序能够判断出给定的整数对是否相连,但并没有要求给出两者之间的通路上的所有连接。这样的要求更难,并会得出另一组不同的算法。

为了定义和说明问题,先设计一份API 来封装基本操作: 初始化,连接两个触点,查找某个触点的分量 ,判断两个触点是否属于同一分量,分量的数量:

    /// <summary>
    /// 动态连通API
    /// </summary>
    public interface IUnionFind
    {
        /// <summary>
        /// 连接
        /// </summary>
        /// <param name="p"></param>
        /// <param name="q"></param>
        void Union(int p, int q);

        /// <summary>
        /// 查找触点 p 的分量标识符
        /// </summary>
        /// <param name="p"></param>
        /// <returns></returns>
        int Find(int p);

        /// <summary>
        /// 判断两个触点是否处于同一分量
        /// </summary>
        /// <param name="p"></param>
        /// <param name="q"></param>
        /// <returns></returns>
        bool Connected(int p, int q);

        /// <summary>
        /// 连通分量的数量
        /// </summary>
        /// <returns></returns>
        int Count();
    }

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

  • 1. 定义一种数据结构表示已知的连接;
  • 2. 基于此数据结构高效的实现API的方法;

数据结构的性质会直接影响算法的效率。这里,以触点为索引,触点和连接分量都是用 int 值表示,将会使用分量中某个触点的值作为分量的标识符。所以,一开始,每个触点都是只含有自己的分量,分量标识符为触点的值。由此,可以初步实现一部分方法:

    public class FirstUnionFind:IUnionFind
    {
        private int[] id;//* 分量id 以触点作为索引
        private int count;//分量数量

        public FirstUnionFind(int n)
        {
            count = n;
            id = new int[n];
            for (var i = 0; i < n; i++)
            {
                id[i] = i; // 第一个 i 作为触点,第二个 i 作为触点的值
            }
        }

        public int Count()
        {
            return count;
        }

        public bool Connected(int p, int q)
        {
            return Find(p) == Find(q);
        }

        public int Find(int p)
        {

        }

        public void Union(int p, int q)
        {

        }
    }

Union-find 的成本模型 是数组的访问次数(无论读写)。

3. quick-find算法实现

quick-find 算法是保证当且仅当 id[p] 等于 id[q] 时,p 和 q 是连通的。也就是说,在同一个连通分量中的所有触点在 id[ ] 中的值全部相等。

所以Find 方法只需返回id[q],Union 方法需要先判断Find(p) 是否等于Find(q) ,若相等直接返回;若不相等,需要将 q 所在的连通分量中所有触点的 id [ ] 值全部更新为 id[p]。

    public class QuickFindUF: IUnionFind
    {
        private int[] id;//* 分量id 以触点作为索引
        private int count;//分量数量

        public QuickFindUF(int n)
        {
            count = n;
            id = new int[n];
            for (var i = 0; i < n; i++)
            {
                id[i] = i; // 第一个 i 作为触点,第二个 i 作为触点的值
            }
        }

        public int Count()
        {
            return count;
        }

        public bool Connected(int p, int q)
        {
            return Find(p) == Find(q);
        }

        public int Find(int p)
        {
            return id[p];
        }

        public void Union(int p, int q)
        {
            var pID = Find(p);
            var qID = Find(q);

            if (pID == qID)
                return;

            for (var i = 0; i < id.Length; i++)
            {
                if (id[i] == qID)
                    id[i] = pID;
            }
            count--; //连通分量减少
        }

        public void Show()
        {
            for(var i = 0;i<id.Length;i++)
                Console.WriteLine("索引:"+i+",值:"+ id[i] );
            Console.WriteLine("连通分量数量:"+count);
        }
    }

算法分析

Find() 方法只需访问一次数组,所以速度很快。但是对于处理大型问题,每对输入 Union() 方法都需要扫描整个数组。

每一次归并两个分量的 Union() 方法访问数组的次数在 N+3 到 2N+1 之间。由代码可知,两次 Find 操作访问两次数组,扫描数组会访问N次,改变其中一个分量中所有触点的值需要访问 1 到 N - 1 次(最好情况是该分量中只有一个触点,最坏情况是该分量中有 N - 1个触点),2+N+N-1。

如果使用quick-find 算法来解决动态连通性问题并且最后只得到一个连通分量,至少需要调用 N-1 次Union() 方法,那么至少需要 (N+3)(N-1) ~ N^2 次访问数组,是平方级别的。

4. quick-union算法实现

quick-union 算法重点提高 union 方法的速度,它也是基于相同的数据结构 -- 已触点为索引的 id[ ] 数组,但是 id[ ] 的值是同一分量中另一触点的索引(名称),也可能是自己(根触点)——这种联系成为链接。

在实现 Find() 方法时,从给定触点,链接到另一个触点,知道到达根触点,即链接指向自己。同时修改 Union() 方法,分别找到 p q 的根触点,将其中一个根触点链接到根触点。

    public class QuickUnionUF : IUnionFind
    {
        private int[] id;
        private int count;

        public QuickUnionUF(int n)
        {
            count = n;
            id = new int[n];
            for (var i = 0; i < n; i++)
            {
                id[i] = i; // 第一个 i 作为触点,第二个 i 作为触点的值
            }
        }

        public int Count()
        {
            return count;
        }

        public bool Connected(int p, int q)
        {
            return Find(p) == Find(q);
        }

        public int Find(int p)
        {
            while (p != id[p])
                p = id[p];
            return p;
        }

        public void Union(int p, int q)
        {
            var pRoot = Find(p);
            var qRoot = Find(q);

            if (pRoot == qRoot)
                return;

            id[pRoot] =qRoot;
            count--; //连通分量减少
        }
        public void Show()
        {
            for (var i = 0; i < id.Length; i++)
            Console.WriteLine("索引:" + i + ",值:" + id[i]);
            Console.WriteLine("连通分量数量:" + count);
        }
    }

森林表示

id[ ] 数组用父链接的形式表示一片森林,用节点表示触点。无论从任何触点所对应的节点随着链接查找,最后都将到达含有该节点的根节点。初始化数组之后,每个节点的链接都指向自己。

算法分析

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

quick-union 算法比 quick-find 算法更快,因为它对每对输入不需要遍历整个数组。

分析quick-union 算法的成本比 quick-find 算法的成本要困难,因为quick-union 算法依赖于输入的特点。在最好的情况下,find() 方法只需访问一次数组就可以得到一个触点的分量表示;在最坏情况下,需要 2i+1 次数组访问(i 时触点的深度)。由此得出,该算法解决动态连通性问题,在最佳情况下的运行时间是线性级别,最坏情况下的输入是平方级别。解决了quick-find 算法中 union() 方法总是线性级别,解决动态连通性问题总是平方级别。

quick-union 算法中 find() 方法访问数组的次数为 1(到达根节点只需访问一次) 加上 给定触点所对应节点的深度的两倍(while 循环,一次读,一次写)。union() 访问两次 find() ,如果两个触点不在同一分量还需加一次写数组。

假设输入的整数对是有序的 0-1, 0-2,0-3 等,N-1 对之后N个触点将全部处于相同的集合之中,且得到的树的高度为 N-1。由上可知,对于整数对 0-i , find() 访问数组的次数为 2i + 1,因此,处理 N 对整数对所需的所有访问数组的总次数为 3+5+7+ ......+(2N+1) ~ n^2

5.加权 quick-union 算法实现

简单改动就可以避免 quick-union算法 出现最坏情况。quick-union算法 union 方法是随意将一棵树连接到另一棵树,改为总是将小树连接到大树,这需要记录每一棵树的大小,称为加权quick-union算法。

代码:

    public class WeightedQuickUnionUF: IUnionFind
    {
        int[] sz;//以触点为索引的 各个根节点对应的分量树大小
        private int[] id;
        private int count;

        public WeightedQuickUnionUF(int n)
        {
            count = n;
            id = new int[n];
            sz = new int[n];
            for (var i = 0; i < n; i++)
            {
                id[i] = i; // 第一个 i 作为触点,第二个 i 作为触点的值
                sz[i] = 1;
            }
        }

        public int Count()
        {
            return count;
        }

        public bool Connected(int p, int q)
        {
            return Find(p) == Find(q);
        }

        public int Find(int p)
        {
            while (p != id[p])
                p = id[p];
            return p;
        }

        public void Union(int p, int q)
        {
            var pRoot = Find(p);
            var qRoot = Find(q);

            if (pRoot == qRoot)
                return;

            if (sz[pRoot] < sz[qRoot])
            {
                id[pRoot] = qRoot;
            }
            else
            {
                id[qRoot] = pRoot;
            }

            count--; //连通分量减少
        }

        public void Show()
        {
            for (var i = 0; i < id.Length; i++)
                Console.WriteLine("索引:" + i + ",值:" + id[i]);
            Console.WriteLine("连通分量数量:" + count);
        }
    }

算法分析

加权 quicj-union 算法最坏的情况:

这种情况,将要被归并的树的大小总是相等的(且总是 2 的 冥),都含有 2^n 个节点,高度都正好是 n 。当归并两个含有 2^n 个节点的树时,得到的树含有 2 ^ n+1 个节点,高度增加到 n+1 。

节点大小: 1  2  4  8  2^k = N

高       度: 0  1  2  3  k

k = logN

所以加权 quick-union 算法可以保证对数级别的性能。

对于 N 个触点,加权 quick-union 算法构造的森林中的任意节点的深度最多为logN。

对于加权 quick-union 算法 和N 个触点,在最坏情况下 find,connected 和 union 方法的成本的增长量级为 logN。

对于动态连通性问题,加权 quick-union 算法 是三种算法中唯一可以用于解决大型问题的算法。加权 quick-union 算法 处理 N 个触点和 M 条连接时最多访问数组 c M logN 次,其中 c 为常数。

三个算法处理一百万个触点运行时间对比:

三个算法性能特点:

6.最优算法 - 路径压缩

在检查节点的同时将它们直接连接到根节点。

实现:为 find 方法添加一个循环,将在路径上的所有节点都直接链接到根节点。完全扁平化的树。

研究各种基础问题的基本步骤:

  • 1. 完整而详细地定义问题,找出解决问题所必须的基本抽象操作并定义一份API。
  • 2. 简洁地实现一种初级算法,给出一个精心组织的开发用例并使用实际数据作为输入。
  • 3. 当实现所能解决的问题的最大规模达不到期望时决定改进还是放弃。
  • 4. 逐步改进实现,通过经验性分析和数学分析验证改进后的效果。
  • 5. 用更高层次的抽象表示数据结构或算法来设计更高级的改进版本。
  • 6. 如果可能尽量为最坏情况下的性能提供保证,但在处理普通数据时也要有良好的性能。
  • 7.在适当的时候将更细致的深入研究留给有经验的研究者并解决下一个问题。

到此这篇关于C#并查集(union-find)算法的文章就介绍到这了。希望对大家的学习有所帮助,也希望大家多多支持我们。

(0)

相关推荐

  • C#图表算法之无向图

    目录 1.相关术语 2.表示无向图的数据结构 3.图的处理算法的设计模式 4.深度优先搜索 5.寻找路径 实现 6.广度优先搜索 实现 7.连通分量 实现 union-find 算法 8.符号图 实现 间隔的度数 总结 图是由一组顶点和一组能够将两个顶点相连的边组成. 顶点叫什么名字并不重要,但我们需要一个方法来指代这些顶点.一般使用 0 至 V-1 来表示一张含有 V 个顶点的图中的各个顶点.这样约定是为了方便使用数组的索引来编写能够高效访问各个顶点信息的代码.用一张符号表来为顶点的名字和 0

  • C#图表算法之有向图

    目录 1.术语 2.有向图的数据类型 有向图表示 有向图取反 顶点的符号名 3.有向图的可达性 4.环和有向无环图 调度问题 有向图中的环 顶点的深度优先次序与拓扑排序 拓扑排序 5.有向图中的强连通性 强连通分量 强连通分量API Kosaraju算法 再谈可达性 总结 在有向图中,边是单向的:每条边连接的两个顶点都是一个有序对,它们的邻接性是单向的.许多应用都是天然的有向图,如下图.为实现添加这种单向性的限制很容易也很自然,看起来没什么坏处.但实际上这种组合性的结构对算法有深刻的影响,使得有

  • C#算法设计与分析详解

    目录 1. 什么是科学方法?? 1.观察 2.将问题规模和运行时间的关系量化 2.数学模型 近似 近似运行时间 成本模型 总结 3. 增长数量级的分类 4. 倍率实验 5.注意事项 6. 处理对于输入的依赖 7.内存 1. 对象 2. 链表 3. 数组 4. 字符串对象 作为程序员,开发完一段代码,实现了某个功能时,有必要知道: 我的程序需要多长时间? 是什么导致我的程序消耗很多内存? 比如,统计或者处理了一大批数据.影响这些问题的因素很多,例如,电脑的性能,数据的性质(值类型和引用类型的区别)

  • C#算法之散列表

    目录 1.散列函数 正整数 浮点数 字符串 组合键 将 HashCode() 的返回值转化为一个数组索引 自定义的 HashCode 软缓存 2.基于拉链法的散列表 散列表的大小 删除操作 有序性相关的操作 3.基于线性探测法的散列表 删除操作 键簇 线性探测法的性能分析 调整数组大小 拉链法 均摊分析 4.内存的使用 如果所有的键都是小整数,我们可以使用一个数组来实现无序的符号表,将键作为数组的索引而数组中键 i 处存储的就是它对应的值.散列表就是用来处理这种情况,它是简易方法的扩展并能够处理

  • C#实现冒泡排序和插入排序算法

    1.选择排序(冒泡排序) 升序 用第一个元素跟其他元素比较,如果该元素比其他元素,则交换,保证该元素是最小的.然后再用第二个元素跟后面其他的比较,保证第二个元素是除第一个最小的.依次循环,直到整个数组. /// <summary> /// 选择排序 /// </summary> public class Selection:BaseSort { public static long usedTimes = 0; public Selection() { } public stati

  • C#实现递归算法经典实例

    目录 一 .递归算法简介 二 .Fibonacci数列和阶乘 1.Fibonacci数列 2.阶乘 三 .汉诺塔问题 四 .排列组合 1.输出任意个数字母.数字的全排列 2.将全排列结果保存到链表中 总结 一 .递归算法简介 在数学与计算机科学中,递归是指在函数的定义中使用函数自身的方法. 递归算法是一种直接或者间接地调用自身算法的过程.在计算机编写程序中,递归算法对解决一大类问题是十分有效的,它往往使算法的描述简洁而且易于理解. 递归算法解决问题的特点: (1) 递归就是在过程或函数里调用自身

  • C#算法之实现阿姆斯特朗数

    阿姆斯特朗数 阿姆斯特朗数是一个数字,等于每个数字的幂乘以总位数. 例如,诸如0.1.153.370.371和407.1634.8208.9474的数字是阿姆斯特朗数. 例如: 371 为3位数, 则用每位数的3次方 (3 * 3 * 3)=27 (7 * 7 * 7)=343 (1 * 1 * 1) =1 总数: 27+343+1=371 判断数字是否属于阿姆斯特朗数? static void Main(string[] args) { int i = 0; int digitCount =

  • C#实现快速排序算法

    快速排序是应用最广泛的排序算法,流行的原因是它实现简单,适用于各种不同情况的输入数据且在一般情况下比其他排序都快得多. 快速排序是原地排序(只需要一个很小的辅助栈),将长度为 N 的数组排序所需的时间和 N lg N 成正比. 1.算法 快速排序也是一种分治的排序算法.它将一个数组分成两个子数组,将两部分独立地排序. 快速排序和归并排序是互补:归并排序是将数组分成两个子数组分别排序,并将有序数组归并,这样数组就是有序的了:而快速排序将数组通过切分变成部分有序数组,然后拆成成两个子数组,当两个子数

  • C#使用符号表实现查找算法

    高效检索海量信息(经典查找算法)是现代信息世界的基础设施.我们使用符号表描述一张抽象的表格,将信息(值)存储在其中,然后按照指定的键来搜索并获取这些信息.键和值的具体意义取决于不同的应用.符号表中可能会保存很多键和很多信息,因此实现一张高效的符号表是很重要的任务. 符号表有时被称为字典,有时被称为索引. 1.符号表 符号表是一种存储键值对的数据结构,支持两种操作:插入(put),即将一组新的键值对存入表中:查找(get),即根据给定的键得到相应的值.符号表最主要的目的就是将一个健和一个值联系起来

  • C#实现抢红包算法的示例代码

    目录 二倍均值法(公平版) 线段切割法(手速版) 二倍均值法(公平版) 发出一个固定金额的红包,由若干个人来抢,需要满足哪些规则? 1.所有人抢到金额之和等于红包金额,不能超过,也不能少于. 2.每个人至少抢到一分钱. 3.要保证所有人抢到金额的几率相等. 假设剩余红包金额为M,剩余人数为N,那么有如下公式: 每次抢到的金额 = 随机区间 (0, M / N × 2) 这个公式,保证了每次随机金额的平均值是相等的,不会因为抢红包的先后顺序而造成不公平.举个例子: 假设有10个人,红包总额100元

随机推荐