C#图表算法之最小生成树

目录
  • 1.原理
    • 1.切分定理
    • 2.贪心算法
  • 2.加权无向图的数据类型
  • 3.最小生成树 API
  • 4.Prim 算法
    • 数据结构
    • 维护横切边的集合
    • 实现
    • 性能
  • 5. Prim 算法的即时实现
  • 6.Kruskal 算法
    • 实现

加权图是一种为每条边关联一个权值或是成本的图模型。这种图能够自然地表示许多应用。在一幅航空图中,边表示航线,权值则可以表示距离或是费用。在这些情形中,最令人感兴趣的自然是将成本最小化。这里用加权无向图模型来解决最小生成树:给定一幅加权无向图,找到它的一棵最小生成树。

图的生成树是它的一棵含有其所有顶点的无环连通子图。一幅加权图的最小生成树(MST)是它的一棵权值(树中所有边的权值之和)最小的生成树。

计算最小生成树的两种经典算法:Prim 算法和 Kruskal 算法。

1.原理

树的两个重要性质:

1.用一条边连接树中的任意两个顶点都会产生一个新的环;

2.从树中删除一条边将会得到两棵独立的树。

这两条性质是证明最小生成树的另一条基本性质的基础,而由这条基本性质就能够得到最小生成树的算法。

1.切分定理

我们称之为切分定理的这条性质将会把加权图中的所有所有顶点分为两个集合,检查横跨两个集合的所有边并识别哪条边应属于图的最小生成树。

通常,我们指定一个顶点集并隐式地认为它地补集为另一个顶点集来指定一个切分。这样,一条横切边就是连接该集合地一个顶点和不在该集合地另一个顶点地一条边。

切分定理:在一幅加权图中,给定任意得切分,它的横切边中权重最小者必然属于图的最小生成树。

注意,权重最小的横切边并不一定是所有横切边中唯一属于图的最小生成树的边。实际上,许多切分都会产生若干条属于最小生成树的横切边:

2.贪心算法

切分定理是解决最小生成树问题的所有算法的基础。更确切的说,这些算法都是一种贪心算法的特殊情况:使用切分定理找到最小生成树的一条边,不断重复直到找到最小生成树的所有便。这些算法之间的不同之处在于保存切分和判定权重最小的横切边的方式,但它们都是一下性质特殊情况:

最小生成树的贪心算法:下面这种算法会将含有 V 个顶点的任意加权连通图中属于最小生成树的边标记为黑色:初始状态下所有边均为灰色,找到一种切分,它产生的横切边均不为黑色。将它权重最小的横切边标记为黑色。反复,直到标记了 V - 1 条黑色边为止。

2.加权无向图的数据类型

namespace MinimumSpanningTrees
{
    public class Edge:IComparable<Edge>
    {
        private int v;//顶点之一
        private int w;//另一个顶点
        private double weight;//边的权重

        public Edge(int v, int w, double weight)
        {
            this.v = v;
            this.w = w;
            this.weight = weight;
        }

        public double Weight()
        {
            return weight;
        }

        public int Either()
        {
            return v;
        }

        public int Other(int vertex)
        {
            if (vertex == v)
                return w;
            else if (vertex == w)
                return v;
            else
                throw new ArgumentException();
        }

        public int CompareTo([AllowNull] Edge other)
        {
            if (this.Weight() < other.Weight())
                return -1;
            else if (this.Weight() > other.Weight())
                return 1;
            else
                return 0;
        }

        public override string ToString()
        {
            return string.Format("%d-%d %.2f",v,w,weight);
        }
    }
}

加权无向图的实现使用了 Edge 对象。

namespace MinimumSpanningTrees
{
    public class EdgeWeightedGraph
    {
        private int v;//顶点总数
        private int e;//边的总数
        private List<Edge>[] adj;//邻接表

        public EdgeWeightedGraph(int V)
        {
            this.v = V;
            e = 0;
            adj = new List<Edge>[V];
            for (int _v = 0; _v < V; _v++)
            {
                adj[_v] = new List<Edge>();
            }
        }

        public int V()
        {
            return v;
        }

        public int E()
        {
            return e;
        }

        public void AddEdge(Edge edge)
        {
            int v = edge.Either();
            int w = edge.Other(v);

            adj[v].Add(edge);
            adj[w].Add(edge);

            e++;
        }

        public IEnumerable<Edge> Adj(int _v)
        {
            return adj[_v];
        }
    }
}

为了整洁,用一对 int 值和一个 double 值表示每个 Edge 对象。实际的数据结构是一个链表,其中每个元素都是一个指向含有这些值的对象的指针。需要注意的是,虽然每个Edge对象都有两个引用,但图中的每条边所对应的 Edge 对象只有一个。

  • 用权重来比较边

Edge 类实现了 IComparable 接口的CompareTo 方法。一幅加权无向图中的边的自然次序就是按权重排序。

  • 平行边

和无环图的实现一样,这里也允许存在平行边。我们也可以用更复杂的方法来消除平行边,比如保留平行边中权重最小者。

  • 自环

允许存在自环,这对最小生成树算法没有影响,因为最小生成树肯定不会含有自环。

有个 Edge 对象之后用例的代码就可以变得更加干净整洁。这也有小的代价:每个邻接表的结点都是一个都是一个指向 Edge 对象的引用,它们含有一些冗余的信息, v 的邻接链表中的每个结点都会用一个变量保存 v 。使用对象也会带来一些开销。虽然每条边的 Edge 对象都只有一个,但邻接表中还是会含有两个指向同一 Edge 对象的引用。

另一种广泛使用的方案是与 Graph 一样,用两个结点对象表示一条边,每个结点对象都会保存顶点的信息和边的权重。这种方法也是有代价的 -- 需要两个结点,每条边的权重都会被保存两次。

3.最小生成树 API

按照惯例,在 API 中会定义一个接受加权无向图为参数的构造函数并且支持能够为用例返回图的最小生成树和其权重的方法。那么应该如何表示最小生成树?由于图的最小生成树是图的一幅子图并且同时也是一棵树,因此可以有一下选择:

  • 1.一组边的列表;
  • 2.一幅加权无向图;
  • 3.一个以顶点为索引且含有父结点链接的数组。

在为了各种应用选择这些表示方法时,我们希望尽可能给予最小生成树的实现以最大的灵活性,采用如下 API :

4.Prim 算法

Prim 算法,它的每一步都会为一棵生长中的树添加一条边。一开始这棵树只有一个顶点,然后会向它添加 V-1 条边,每次总是将下一条连接树中的顶点与不在树中的顶点且权重最小的边加入树中。

Prim 算法能够得到任意加权连通图的最小生成树。这棵不断生长的树定义了一个切分且不存在树中的横切边。该算法会选择权重最小的横切边并根据贪心算法不断将他们标记。

数据结构

实现 Prim 算法需要一些简单常见的数据结构。具体来说,我们会用一下方法表示树中的顶点,边和横切边:

1.顶点:使用一个由顶点索引的布尔数组 marked[ ] ,如果顶点 v 在树中,那么 marked[v] 的值为 true。

2.边:选择以下两种数据结构之一:一条队列 mst 来保存最小生成树中的边,或者一个由顶点索引的Edge对象的数组 edgeTo[ ] ,其中edgeTo[ v ] 为将 v 连接到树中的 Edge 对象;

3.横切边:使用一条优先队列 MinPQ<Edge> 来根据权重比较所有边。

有了这些数据结构就可以回答 “哪条边的权重最小” 这个基本问题了。

维护横切边的集合

每当我们向树中添加了一条边后,也向树中添加一个顶点。要维护一个包含所有横切边的集合,就要将连接这个顶点(新加入树)和其他所有不在树中的顶点的边加入优先队列。但还有一点:连接新加入树中的顶点和其他已经在树中的顶点的所有边都要失效(这样的边已经不是横切边了,因为它的两个顶点都在树中)。Prim 算法的即时实现可以将这样的边从优先队列中删掉,先来看一种延时实现,将这些边先留在优先队列中,等到要删除它们的时候再检查边的有效性。

算法构造最小生成树的过程:

1.将顶点 0 添加到最小生成树,将它的邻接链表中的所有边添加到优先队列中。

2.取权重最小的边 0-7,将顶点 7 和 边 0-7 添加到最小生成树中,将顶点 7 的邻接链表中的所有边添加到优先队列中。

3.将顶点 1 和边 1-7 添加到最小生成树中,将顶点的邻接链表中的所有边添加到优先队列中。

4.将顶点 2 和边 0-2 添加到最小生成树,将边 2-3 和 6-2 添加到优先队列。边 2-7 和 1-2 失效(因为边的两个顶点都在树中,最后删除)。

5.将顶点 3 和边 2-3 添加到最小生成树,将边 3-6 添加到优先队列。边 1-3 失效。

6.将顶点 5 和边 5-7 添加到最小生成树,将边 4-5 添加到优先队列。边 1-5 失效。

7.从优先队列删除失效的边 1-3,1-5 和 2-7 。

8.将顶点 4 和边 4-5 添加到最小生成树中,将边 6-4 添加到优先队列。边 4-7 和 0-4 失效。

9.从优先队列删除失效的边 1-2,4-7 和 0-4 。

10.将顶点 6 和边 6-2 添加到最小生成树中,和顶点 6 相关联的其他边均失效。

在添加了 V 个顶点以及 V-1 条边之后,最小生成树就完成了。优先队列中剩下的边都是无效的,可以不用去检查。

实现

我们使用一个私有方法 Visit()来为树添加一个顶点,将它标记为“已访问”并将与它关联的所有未失效的边加入优先队列,以保证队列含有所有连接树顶点和非树顶点的边(也可能含有一些已失效的边)。代码的内循环是算法的具体实现:从优先队列中取出一条边并将它添加到树中(如果没有失效),再把这条边的另一个顶点也添加到树中,然后用新顶点作为参数调用 Visit 方法来更新横切边的集合。 Weight 方法可以遍历树的所有边并得到权重之和。

namespace MinimumSpanningTrees
{
    public class LazyPrimMST
    {
        private bool[] marked;//最小生成树的顶点
        private Queue<Edge> mst;//最小生成树的边
        private MinPQ<Edge> pq;//横切边(包括失效的边)

        public LazyPrimMST(EdgeWeightedGraph G)
        {
            pq = new MinPQ<Edge>();
            marked = new bool[G.V()];
            mst = new Queue<Edge>();

            Visit(G,0);
            while (!pq.Count > 0)
            {
                Edge e = pq.Dequeue();

                int v = e.Either();
                int w = e.Other(v);
                if (marked[v] && marked[w])
                    continue;
                pq.Enqueue(e);

                if (!marked[v])
                    Visit(G,v);
                if (!marked[w])
                    Visit(G,w);
            }
        }

        private void Visit(EdgeWeightedGraph G, int v)
        {
            marked[v] = true;
            foreach (Edge e in G.Adj(v))
            {
                if (!marked[e.Other(v)])
                    pq.Enqueue(e);
            }
        }

        public IEnumerable<Edge> Edges()
        {
            return mst;
        }

        public double Weight()
        {
            return mst.Sum(o=>o.Weight());
        }
    }
}

性能

Prim 算法的延时实现计算一幅含有 V 个顶点和 E 条边的连通加权无向图的最小生成树所需的空间和 E 成正比,所需的时间与 ElogE成正比(最坏情况)。算法的瓶颈在于优先队列的删除和添加方法中比较边的权重的次数。优先队列最多可能含有 E 条边,这就是空间需求的上限。在最坏情况下,一次插入成本为 ~lgE ,删除最小元素的成本为 ~2lgE 。因为最多只能插入 E 条边,删除 E 次最小元素,时间上限显而易见。

5. Prim 算法的即时实现

要改进LazyPrimMST ,可以尝试从优先队列中删除失效的边,这样优先队列就只有树顶点和非树顶点之间的横切边,但还可以删除更多的边。关键在于,我们感兴趣的只是连接树顶点和非树顶点中权重最小的边。当我们将顶点 v 添加到树中,对于每个非树顶点 w 产生的变化只可能使得 w 到最小生成树的距离更近。简而言之,我们不需要在优先队列中保存所有从 w 到树顶点的边 —— 而只需保存其中权重最小的那条。在将 v 添加到树中后检查是否需要更新这条权重最小的边(因为 v-w 的权重可能更小)。我们只需遍历 v 的邻接链表就可以完成这个任务。换句话说,我们只会在优先队列中保存每个非树顶点 w 的一条边:将它与树中顶点连起来的权重最小的那条边。将 w 和树的顶点连接起来的其他权重较大的边迟早都会失效,所以没必要在优先队列中保存他们。

算法类 PrimMST 将 LazyPrimMST 类中 marked[ ] 和 mst[ ] 替换为两个顶点索引的数组 edgeTo[ ] 和 distTo[ ] :

  • 1.如果顶点 v 不在树中但至少含有一条边和树相连,那么 edgeTo[v] 是将 v 和树连接的最短边,distTo[v] 为这条边的权重。
  • 2.所有这类顶点 v 都保存在一条索引优先队列中,索引 v 关联的值是 edgeTo[v] 的边的权重。

这些性质的关键在于优先队列中的最小键即是权重最小的横切边的权重,而和它相关联的顶点 v 就是下一个将被添加到树中的顶点。marked[ ] 数组已经没有必要了,因为判断条件 !marked[w] 等价于 distTo[w] 是无穷的(且 edgeTo[w] 为 null)。要维护这些数据结构,PrimMST 会从优先队列中取出一条边并检查它的邻接链表中的每条边 v-w 。如果 w 已经被标记过,那这条边就已经失效了;如果 w 不在优先队列中或者 v-w 的权重小于目前已知的最小值 edgeTo[w],代码会更新数组,将 v-w 作为将 w 和树连接的最佳选择。

namespace MinimumSpanningTrees
{
    public class PrimMST
    {
        private Edge[] edgeTo;//距离树最近的边
        private double[] distTo;//distTo[w]=edgeTo[w].weight()
        private bool[] marked;//如果 v 在树中则为 true
        public IndexMinPQ<double> pq;//有效的横切边

        public PrimMST(EdgeWeightedGraph G)
        {
            edgeTo = new Edge[G.V()];
            distTo = new double[G.V()];
            marked = new bool[G.V()];

            for (int v = 0; v < G.V(); v++)
            {
                distTo[v] = Double.MaxValue;
            }

            pq = new IndexMinPQ<double>(G.V());

            distTo[0] = 0.0;
            pq.Insert(0,0.0); //用顶点 0 和权重 0 初始化 pq
            while (pq.Count > 0)
            {
                Visit(G,pq.DelMin());//将最近的顶点添加到树中
            }
        }

        private void Visit(EdgeWeightedGraph G, int v)
        {
            //将顶点 v 添加到树中,更新数据
            marked[v] = true;
            foreach (Edge e in G.Adj(v))
            {
                int w = e.Other(v);

                if (marked[w])
                    continue;

                if (e.Weight() < distTo[w])
                {
                    edgeTo[w] = e;
                    distTo[w] = e.Weight();

                    if (pq.Contains(w))
                        pq.Change(w, distTo[w]);
                    else
                        pq.Insert(w,distTo[w]);
                }
            }
        }
    }
}

Prim 算法的即时实现计算一幅含有 V 个顶点和 E 条边的连通加权无向图的最小生成树所需的空间和 V 成正比,所需的时间与 ElogV成正比(最坏情况)。

6.Kruskal 算法

第二种生成最小生成树的主要思想是按照边的权重顺序(从小到大)处理它们,将边加入最小生成树中,加入的边不会与已经加入的边构成环,直到树中含有 V-1 条边为止。这些边逐渐由一片森林合共成一棵树。

为什么 Kruskal 算法能够计算任意加权连通图的最小生成树?

如果下一条将被加入最小生成树中的边不会和已有的边构成环,那么它就跨越了由所有和树顶点相邻的顶点组合的合集以及它们的补集所构成的一个切分。因为加入的边不会形成环,它是目前已知的唯一一条横切边且是按照权重顺序选择的边,所以它必然是权重最小的横切边。因此,该算法能够连续选择权重最小的横切边,和贪心算法一致。

Prim 算法是一条边一条边地来构造最小生成树,每一步都为一棵树添加一条边。 Kruskal 算法构造最小生成树地时候也是一条边一条边地构造,但不同的是它寻找的边会连接一片森林中的两棵树。我们从一片由 V 棵单顶点的树构成的森林开始并不断将两棵树合并(用可以找到的最短边)直到只剩下一棵树,它就是最小生成树。

实现

我们将会使用一条优先队列来将边按照权重排序,用一个 union-find 数据结构来识别会形成环的边,以及一条队列来保存最小生成树的所有边。

    public class KruskalMST
    {
        private Queue<Edge> mst;
        public KruskalMST(EdgeWeightedGraph G)
        {
            mst = new Queue<Edge>();//最小生成树的边
            MinPQ<Edge> pq = new MinPQ<Edge>(G.Edges);//所有边
            UF uf = new UF(G.V());

            while (!pq.IsEmpty() && mst.Count < G.V() - 1)
            {
                Edge e = pq.DelMin();//从 pq 得到权重最小的边和它的顶点
                int v = e.Either(), w = e.Other(v);

                if (uf.Connected(v, w))//忽略失效的边
                    continue;
                uf.Union(v,w);//合并分量
                mst.Enqueue(e);//将边添加到最小生成树中
            }
        }
    }

Kruskal 算法的计算一幅含有 V 个顶点和 E 条边的连通加权无向图的最小生成树所需的空间和 E 成正比,所需的时间和 ElogE 成正比(最坏情况)。

算法的实现在构造函数中使用所有边初始化优先队列,成本最多为 E 次比较(请见 union-find)。优先队列构造完成后,其余的部分和 Prim 算法完全相同。优先队列中最多可能含有 E 条边,即所需空间的上限。每次操作的成本最多为 2lgE 次比较,这就是时间上限的由来。 Kruskal 算法最多还会进行 E 次 Connected() 和 V 次 Union()操作,但这些成本相比 ElogE 的总时间的增长数量级可以忽略不计。

与 Prim 算法一样,这个估计是比较保守的,因为算法在找到 V-1 条边之后就会终止。实际成本应该与 E+E0 logE 成正比,其中 E0 是权重小于最小生成树中权重最大的边的所有边的总数。尽管拥有这个优势,Kruskal 算法一般还是比 Prim 算法要慢,因为在处理每条边时,两种方法都要完成优先队列操作之外,它还需要进行一次 Connect() 操作。

到此这篇关于C#图表算法之最小生成树的文章就介绍到这了。希望对大家的学习有所帮助,也希望大家多多支持我们。

(0)

相关推荐

  • C#图表算法之无向图

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

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

    目录 算法的主题思想: 1. 动态连通性 2. 定义问题 3. quick-find算法实现 算法分析 4. quick-union算法实现 森林表示 算法分析 5.加权 quick-union 算法实现 算法分析 6.最优算法 - 路径压缩 算法的主题思想: 1.优秀的算法因为能够解决实际问题而变得更为重要: 2.高效算法的代码也可以很简单: 3.理解某个实现的性能特点是一个挑战: 4.在解决同一个问题的多种算法之间进行选择时,科学方法是一种重要的工具: 5.迭代式改进能够让算法的效率越来越高

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

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

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

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

  • C#实现快速排序算法

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

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

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

  • C#算法之罗马数字转整数

    罗马数字转整数 罗马数字包含以下七种字符: I, V, X, L,C,D 和 M. 字符 数值 I 1 V 5 X 10 L 50 C 100 D 500 M 1000 例如, 罗马数字 2 写做 II ,即为两个并列的 1.12 写做 XII ,即为 X + II . 27 写做  XXVII, 即为 XX + V + II . 通常情况下,罗马数字中小的数字在大的数字的右边.但也存在特例,例如 4 不写做 IIII,而是 IV.数字 1 在数字 5 的左边,所表示的数等于大数 5 减小数 1

  • C#算法之散列表

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

  • C#图表算法之有向图

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

  • C#算法之回文数

    回文数 判断一个整数是否是回文数.回文数是指正序(从左向右)和倒序(从右向左)读都是一样的整数. 示例 1: 输入: 121 输出: true 示例 2: 输入: -121 输出: false 解释: 从左向右读, 为 -121 . 从右向左读, 为 121- .因此它不是一个回文数. 示例 3: 输入: 10 输出: false 解释: 从右向左读, 为 01 .因此它不是一个回文数. 进阶: 你能不将整数转为字符串来解决这个问题吗? 代码模板 public class Solution {

随机推荐