Java实现最小生成树算法详解

目录
  • 定义
  • 带权图的实现
  • Kruskal 算法
    • 二叉堆
    • 并查集
    • 实现算法
  • Prim 算法

定义

在一幅无向图G=(V,E) 中,(u,v) 为连接顶点u和顶点v的边,w(u,v)为边的权重,若存在边的子集T⊆E且(V,T) 为树,使得

最小,这称 T 为图 G 的最小生成树。

说的通俗点,最小生成树就是带权无向图中权值和最小的树。下图中黑色边所标识的就是一棵最小生成树(图片来自《算法第四版》),对于权值各不相同的连通图来说最小生成树只会有一棵:

带权图的实现

在 《如何在 Java 中实现无向图》 中我们使用邻接表数组实现了无向图,其中邻接表上的每个节点的数据域只是一个整数,代表着一个顶点。为了方便最小生成树的迭代,我们将数据域换成 Edge 实例。Edge 有三个成员:顶点 v、顶点 w 和权重 weight,为了比较每一条边的权重,需要实现 Comparable 接口。代码如下所示:

package com.zhiyiyo.graph;

/**
 * 图中的边
 */
public class Edge implements Comparable<Edge> {
    private final int v, w;
    private final double weight;

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

    /**
     * 返回边中的一个顶点
     */
    int either() {
        return v;
    }

    /**
     * 返回边中的拎一个顶点
     *
     * @param v 顶点 v
     * @return 另一个顶点
     */
    int another(int v) {
        if (this.v == v) {
            return w;
        } else if (w == v) {
            return this.v;
        } else {
            throw new RuntimeException("边中不存在该顶点");
        }
    }

    public double getWeight() {
        return weight;
    }

    @Override
    public String toString() {
        return String.format("Edge{%d-%d %f}", v, w, weight);
    }

    @Override
    public int compareTo(Edge edge) {
        return Double.compare(weight, edge.weight);
    }
}

之后只要照猫画虎,将 LinkGraph 的泛型从 Integer 换成 Edge 就行了:

package com.zhiyiyo.graph;

import com.zhiyiyo.collection.stack.LinkStack;
import com.zhiyiyo.collection.stack.Stack;

/**
 * 带权无向图
 */
public class WeightedGraph {
    private final int V;
    protected int E;
    protected LinkStack<Edge>[] adj;

    public WeightedGraph(int V) {
        this.V = V;
        adj = (LinkStack<Edge>[]) new LinkStack[V];
        for (int i = 0; i < V; i++) {
            adj[i] = new LinkStack<>();
        }
    }

    public int V() {
        return V;
    }

    public int E() {
        return E;
    }

    public void addEdge(Edge edge) {
        int v = edge.either();
        int w = edge.another(v);
        adj[v].push(edge);
        adj[w].push(edge);
        E++;
    }

    public Iterable<Edge> adj(int v) {
        return adj[v];
    }

    /**
     * 获取所有边
     */
    public Iterable<Edge> edges() {
        Stack<Edge> edges = new LinkStack<>();
        for (int v = 0; v < V; ++v) {
            for (Edge edge : adj(v)) {
                if (edge.another(v) > v) {
                    edges.push(edge);
                }
            }
        }

        return edges;
    }
}

同时给出最小生成树的 API:

package com.zhiyiyo.graph;

/**
 * 最小生成树
 */
public interface MST {
    /**
     * 获取最小生成树中的所有边
     */
    Iterable<Edge> edges();

    /**
     * 获取最小生成树的权重
     */
    double weight();
}

Kruskal 算法

假设 E 是图 G 中所有边的集合,T 是最小生成树的边集合,kruskal 算法的思想是每次从 E 中出权值最小的边 em,如果 em 不会和 T 中的边构成环,就将其加入 T 中,直到 |T|=|V|−1 也就是 TT 中边的个数是图 G 的顶点个数 -1 时,就得到了最小生成树。

对于上一幅图,使用 kruskal 算法得到最小生成树的过程如下图所示:

首先将 E 中最小的边 0-7 弹出并加到 T 中,此时的 EE 中最小边为 2-3,虽然 2-3 和 0-7 无法构成连通图,但是没关系,只要贪心地将其加入 T 中即可,因为后续其他边的添加总会将二者连通起来。接着按照权值的升序依次把边 1-7、0-2、5-7 加到 T 中,直到碰到边 1-3,如果把 1-3 加入 T 中,就会出现环 1-3-2-0-7-1,所以直接将 1-3 舍弃,1-5、2-7 也同理被丢弃掉。由于边 4-5 不会在 T 中构成环,所以将其加入 T。重复上述步骤,直到|T|=|V|−1。

上述过程中有两个影响性能的地方,一个是找出 E 中权值最小的边 em,一个是判断将 em 加到 T 中是否会出现环。

二叉堆

二叉堆是一棵完全二叉树,且每个父节点总是大于等于(最大堆)或者小于等于(最小堆)他的子节点。《算法第四版》中给出了使用数组存储的最大堆的结构,其中数组下标为 0 的地方不存储元素,假设下标为 i 出存放的是父节点,那么 2i 和 2i+1 处就是子节点:

由于最小堆的堆顶节点总是最小的,所以只需将 E 变为一个最小堆,每次取出堆顶的元素即可,时间复杂度为O(log⁡N)。下面来看下如何实现最小堆。

API

对于一个二叉堆,我们关心以下操作:

package com.zhiyiyo.collection.queue;

public interface PriorQueue<T extends Comparable<T>> {
    /**
     * 向堆中插入一个元素
     * @param item 插入的元素
     */
    void insert(T item);

    /**
     * 弹出堆顶的元素
     * @return 堆顶元素
     */
    T pop();

    /**
     * 获取堆中的元素个数
     */
    int size();

    /**
     * 堆是否为空
     */
    boolean isEmpty();
}

插入

为了保证二叉堆是一棵完全二叉树,每次都将新节点插到数组的末尾,也就是二叉树的最后一个节点。如下图所示,假设插入的节点为 A,它的父节点为 P,兄弟节点为 S,由于 P > A,这就打破了二叉堆的有序性,所以需要对堆进行调整。具体流程就是将兄弟节点中的较小者(A)选为父节点,而先前的父节点 P 则退位变为子节点。如果此时 A 的父节点小于 A,则无需继续调整。但是下图中只交换了 A、P 之后还是没将二叉树调整为堆有序状态,因为父节点 D > A,接着将兄弟节点中较小的 A 变为父节点,而 D 则变成 A 的子节点,至此完成最小堆的调整。

上述过程的代码如下所示,为了保证后续插入操作,每当数组满员时就对其进行扩容操作:

package com.zhiyiyo.collection.queue;

import java.util.Arrays;

public class MinPriorQueue<T extends Comparable<T>> implements PriorQueue<T>{
    private T[] array;
    private int N;

    public MinPriorQueue() {
        this(3);
    }

    public MinPriorQueue(int maxSize) {
        array = (T[]) new Comparable[maxSize + 1];
    }

    @Override
    public boolean isEmpty() {
        return N == 0;
    }

    @Override
    public int size() {
        return N;
    }

    @Override
    public void insert(T item) {
        array[++N] = item;
        swim(N);
        if (N == array.length - 1) resize(1 + 2 * N);
    }

    /**
     * 元素上浮
     *
     * @param k 元素的索引
     */
    private void swim(int k) {
        while (k > 1 && less(k, k / 2)) {
            swap(k, k / 2);
            k /= 2;
        }
    }

    private void swap(int a, int b) {
        T tmp = array[a];
        array[a] = array[b];
        array[b] = tmp;
    }

    private boolean less(int a, int b) {
        return array[a].compareTo(array[b]) < 0;
    }

    private void resize(int size) {
        array = Arrays.copyOf(array, size);
    }
}

删除最小元素

假设我们需要删除下图中的 A 元素,这时候就需要将 A 和最小堆的最后一个元素 P 交换位置,并将数组的最后一个元素置为 null,使得 A 的引用次数变为 0,能被垃圾回收机制自动回收掉。交换之后最小堆的有序性被破坏了,因为父节点 P > 子节点 D,这时候和插入元素的操作一样,将较小的子节点和父节点交换位置,使得较大的父节点能够下沉,而较小的子节点上位,这个过程持续到没有子节点被 P 更小为止。

实现代码如下:

@Override
public T pop() {
    T item = array[1];
    swap(1, N);
    array[N--] = null;
    sink(1);
    if (N < (array.length - 1) / 4) resize((array.length - 1) / 2);
    return item;
}

/**
 * 元素下沉
 *
 * @param k 元素的索引
 */
private void sink(int k) {
    while (2 * k <= N) {
        int j = 2 * k;
        // 检查是否有两个子节点
        if (j < N && less(j + 1, j)) j++;
        if (less(k, j)) break;
        swap(k, j);
        k = j;
    }
}

并查集

假设 TT 中的顶点的集合为 V′,则有图 G′=(V′,T)。我们可以将 G′ 划分为 n 个连通分量,每个连通分量有一个标识 id∈[0,n−1]。要想判断将边 em 加入 T 后是否会构成环,只需判断 em 的两个顶点是都属于同一个连通分量即可。

判断是否连通

由于每个连通分量都不存在环,可以看作一棵小树,所以可以用一个数组 int[] ids 的索引表示树中的节点(图中的顶点),而索引处的元素值为父节点的索引值,数组中 ids[i] == i 的位置就是每棵树的根节点,i 就是这个连通分量的标识。而我们想要知道两个节点之间是否连通,只需判断他们所属的树的根节点是否相同即可。

假设从树底的叶节点 6 出发,一路向上直到树顶 1,中间需要经过 5 和 0 两个节点,如果节点 6 的根节点查询得比较频繁,那么这种查找效率是比较低的。由于我们只需知道根节点是谁即可,树的结构无关紧要,那么为何不想个办法把节点 5、6 直接挂到根节点 1,这样只要一步就能知道根节点。实现这种想法的的方式就是路径压缩:当从节点 6 走到父节点 5 时,就将节点 6 挂到节点 5 的父节点 0 上;而从节点 0 走到根节点 1 时,就将子节点 6 和 5 挂到根节点 1 下,树高被压缩为 1。

实现上述过程的代码如下所示:

package com.zhiyiyo.collection.tree;

public class UnionFind {
    private int[] ids;
    private int[] ranks;	// 每棵树的高度
    private int N;			// 树的数量

    public UnionFind(int N) {
        this.N = N;
        ids = new int[N];
        ranks = new int[N];
        for (int i = 0; i < N; i++) {
            ids[i] = i;
            ranks[i] = 1;
        }
    }

    /**
     * 获取连通分量个数
     *
     * @return 连通分量个数
     */
    public int count() {
        return N;
    }

    /**
     * 获得连通分量的 id
     *
     * @param p 触点 id
     * @return 连通分量 id
     */
    public int find(int p) {
        while (p != ids[p]) {
            ids[p] = ids[ids[p]];   // 路径压缩
            p = ids[p];
        }
        return p;
    }

    /**
     * 判断两个触点是否连通
     *
     * @param p 触点 p 的 id
     * @param q 触点 q 的 id
     * @return 是否连通
     */
    public boolean isConnected(int p, int q) {
        return find(p) == find(q);
    }
}

合并连通分量

我们将 E 中的 em 添加到T中时,em 的两个节点肯定分属于两个连通分量,加入 T 之后就需要将这两个分量合并,也就是将两棵小树合并为一颗大树。假设两棵树的高度分别为 h1 和 h2,如果直接将一颗树的根节点接到另一棵树的叶节点上,会导致新树高度为 h1+h2,降低寻找根节点的效率。解决方式是按秩归并,将矮树的根节点接到高树的根节点上,会出现两种情况:

  • 如果 h1≠h2,新树高度会是 max{h1,h2}m
  • 如果 h1=h2=c,新树高度会是 c+1

上述过程的代码如下所示:

/**
 * 如果两个触点不处于同一个连通分量中,则连接两个触点
 *
 * @param p 触点 p 的 id
 * @param q 触点 q 的 id
 */
public void union(int p, int q) {
    int pId = find(p);
    int qId = find(q);
    if (qId == pId) return;

    // 将小树并到大树
    if (ranks[qId] > ranks[pId]) {
        ids[pId] = qId;
    } else if (ranks[qId] < ranks[pId]) {
        ids[qId] = pId;
    } else {
        ids[qId] = pId;
        ranks[pId]++;
    }

    N--;
}

实现算法

实现 kruskal 算法时,先将所有边加入最小堆中,每次取出堆顶的元素 em,然后使用并查集判断边的两个顶点是否连通,如果不连通就将 em 加入 T,重复这个过程直至 |T|=|V|−1,时间复杂度为O(|E|log⁡|E|)。

package com.zhiyiyo.graph;

import com.zhiyiyo.collection.queue.LinkQueue;
import com.zhiyiyo.collection.queue.MinPriorQueue;
import com.zhiyiyo.collection.queue.Queue;
import com.zhiyiyo.collection.tree.UnionFind;

import java.util.stream.Stream;
import java.util.stream.StreamSupport;

public class KruskalMST implements MST {
    private Queue<Edge> mst;

    public KruskalMST(WeightedGraph graph) {
        mst = new LinkQueue<>();
        UnionFind uf = new UnionFind(graph.V());

        MinPriorQueue<Edge> pq = new MinPriorQueue<>();
        for (Edge e : graph.edges()) {
            pq.insert(e);
        }

        while (mst.size() < graph.V() - 1 && !pq.isEmpty()) {
            Edge edge = pq.pop();
            int v = edge.either();
            int w = edge.another(v);
            if (!uf.isConnected(v, w)) {
                mst.enqueue(edge);
                uf.union(v, w);
            }
        }
    }

    @Override
    public Iterable<Edge> edges() {
        return mst;
    }

    @Override
    public double weight() {
        Stream<Edge> stream = StreamSupport.stream(mst.spliterator(), false);
        return stream.map(Edge::getWeight).reduce(0d, Double::sum);
    }
}

Prim 算法

Prim 算法的思想是初始化最小生成树为一个根节点 0,然后将根节点的所有邻边加入最小堆中,从最小堆中弹出最小的边 em,如果 em 不会使得树中出现环,将将其并入树中。每当有新的节点 v 被并入树中时,就得将 v 的所有邻边加入最小堆中。重复上述过程直到 |T|=|V|−1,时间复杂度为O(|E|log⁡|E|)。代码如下所示:

package com.zhiyiyo.graph;

import com.zhiyiyo.collection.queue.LinkQueue;
import com.zhiyiyo.collection.queue.MinPriorQueue;
import com.zhiyiyo.collection.queue.Queue;

import java.util.stream.Stream;
import java.util.stream.StreamSupport;

/**
 * 延时版本 Prim 算法
 */
public class PrimMST implements MST {
    private boolean[] marked;
    private MinPriorQueue<Edge> pq;
    private Queue<Edge> mst;

    public LazyPrimMST(WeightedGraph graph) {
        marked = new boolean[graph.V()];
        pq = new MinPriorQueue<>();
        mst = new LinkQueue<>();

        mark(graph, 0);
        while (mst.size() < graph.V() - 1 && !pq.isEmpty()) {
            Edge edge = pq.pop();
            int v = edge.either();
            int w = edge.another(v);

            // 构成环则舍弃
            if (marked[v] && marked[w]) continue;
            mst.enqueue(edge);

            if (!marked[v]) mark(graph, v);
            else if (!marked[w]) mark(graph, w);
        }
    }

    private void mark(WeightedGraph graph, int v) {
        marked[v] = true;
        for (Edge edge : graph.adj(v)) {
            if (!marked[edge.another(v)]) {
                pq.insert(edge);
            }
        }
    }

    @Override
    public Iterable<Edge> edges() {
        return mst;
    }

    @Override
    public double weight() {
        Stream<Edge> stream = StreamSupport.stream(mst.spliterator(), false);
        return stream.map(Edge::getWeight).reduce(0d, Double::sum);
    }
}

由于每次都是把新节点的所有邻边都加到了最小堆中,会引入许多无用的边,所以《算法第四版》中给出了使用索引优先队列实现的即时版 Prim 算法,时间复杂度能达到O(|E|log⁡|V|),但是这里写不下了,大家可以自行查阅,以上~~

以上就是Java实现最小生成树算法详解的详细内容,更多关于Java最小生成树的资料请关注我们其它相关文章!

(0)

相关推荐

  • 基于Java实现无向环和有向环的检测

    目录 无向环 有向环 有向图 检测算法 无向环 一个含有环的无向图如下所示,其中有两个环,分别是 0-2-1-0 和 2-3-4-2: 要检测无向图中的环,可以使用深度优先搜索.假设从顶点 0 出发,再走到相邻的顶点 2,接着走到顶点 2 相邻的顶点 1,由于顶点 0 和顶点 1 相邻,并且顶点 0 被标记过了,说明我们饶了一圈,所以无向图中存在环.虽然顶点 2 和顶点 1 相邻,但是并不能说明存在环,因为我们就是从顶点 2 直接走到顶点 1 的,这二者只有边的关系.算法如下所示: packag

  • Java求最小生成树的两种算法详解

    目录 1 最小生成树的概述 2 普里姆算法(Prim) 2.1 原理 2.2 案例分析 3 克鲁斯卡尔算法(Kruskal) 3.1 原理 3.2 案例分析 4 邻接矩阵加权图实现 5 邻接表加权图实现 6 总结 介绍了图的最小生成树的概念,然后介绍了求最小生成树的两种算法:Prim算法和Kruskal算法的原理,最后提供了基于邻接矩阵和邻接链表的图对两种算法的Java实现. 阅读本文需要一定的图的基础,如果对于图不是太明白的可以看看这篇文章:Java数据结构之图的原理与实现. 1 最小生成树的

  • java图论普利姆及克鲁斯卡算法解决最小生成树问题详解

    目录 什么是最小生成树? 普利姆算法  算法介绍 应用 --> 修路问题  图解分析  克鲁斯卡尔算法 算法介绍 应用场景 -- 公交站问题  算法图解   算法分析  如何判断是否构成回路 什么是最小生成树? 最小生成树(Minimum Cost Spanning Tree),简称MST. 最小生成树要求图是连通图.连通图指图中任意两个顶点都有路径相通,通常指无向图.理论上如果图是有向.多重边的,也能求最小生成树,只是不太常见. 普利姆算法  算法介绍 应用 --> 修路问题  图解分析 

  • Java实现无向图的示例详解

    目录 基本概念 图的定义 无向图的定义 无向图的 API 无向图的实现方式 邻接矩阵 边的数组 邻接表数组 无向图的遍历 深度优先搜索 广度优先搜索 后记 基本概念 图的定义 一个图是由点集V={vi} 和 VV 中元素的无序对的一个集合E={ek} 所构成的二元组,记为G=(V,E),V中的元素vi叫做顶点,E中的元素 ek叫做边. 对于V中的两个点 u,v,如果边(u,v) 属于E,则称 u,v两点相邻,u,v称为边(u,v)的端点. 我们可以用m(G)=|E| 表示图G中的边数,用n(G)

  • Java实现最小生成树算法详解

    目录 定义 带权图的实现 Kruskal 算法 二叉堆 并查集 实现算法 Prim 算法 定义 在一幅无向图G=(V,E) 中,(u,v) 为连接顶点u和顶点v的边,w(u,v)为边的权重,若存在边的子集T⊆E且(V,T) 为树,使得 最小,这称 T 为图 G 的最小生成树. 说的通俗点,最小生成树就是带权无向图中权值和最小的树.下图中黑色边所标识的就是一棵最小生成树(图片来自<算法第四版>),对于权值各不相同的连通图来说最小生成树只会有一棵: 带权图的实现 在 <如何在 Java 中实

  • java 中归并排序算法详解

    java 中归并排序算法详解 归并排序算法,顾名思义,是一种先分再合的算法,其算法思想是将要排序的数组分解为单个的元素,每个元素就是一个单个的个体,然后将相邻的两个元素进行从小到大或从大到小的顺序排序组成一个整体,每个整体包含一到两个元素,然后对相邻的整体继续"合"并,因为每个整体都是排过序的,因而可以采用一定的算法对其进行合并,合并之后每个整体包含三到四个元素,继续对相邻的整体进行合并,直到所有的整体都合并为一个整体,最终得到的整体就是将原数组进行排序之后的结果. 对于相邻的整体,其

  • Java顺序查找算法详解

    目录 一.查找的基本概念 1.查找表 2.关键字 3.查找 4.动态查找表与静态查找表 5.平均查找长度 二.顺序查找法 1.概念 2.实践 一.查找的基本概念 在讲顺序查找法之前先来认识一些关于查找的基本概念. 1.查找表 由同一类型的数据元素(或记录)所构成的集合 数据元素之间存在完全松散的关系 非常灵活的数据结构 2.关键字 关键字是数据元素(或记录)中某个数据项的值,可以用它标识一个数据元素(或记录) 若关键字可以唯一地标识一个记录,则称之为主关键字 反之,若用以识别若干记录的关键字称之

  • Java图论进阶之最小生成树算法详解

    目录 1. 最小生成树 1.1 Kruskal(克鲁斯卡尔) 算法 1.2 Prime(普里姆) 算法 总结 1. 最小生成树 连通图中的每一棵生成树 , 都是原图的极大无环子图 , 即: 从中删去任何一条边 , 生成树就不再连通;反之 , 在其中引入任何一条新边 , 都会形成一条回路. 若连通图由n个顶点组成 , 则其生成树必含n个顶点和n-1条边 , 因此构造最小生成树有三个准则: 1.只能使用图中的边来构造最小生成树 2.只能使用恰好n-1条边来连接图中的n个顶点 3.选用的n-1条边不能

  • Java语言实现快速幂取模算法详解

    快速幂取模算法的引入是从大数的小数取模的朴素算法的局限性所提出的,在朴素的方法中我们计算一个数比如5^1003%31是非常消耗我们的计算资源的,在整个计算过程中最麻烦的就是我们的5^1003这个过程 缺点1:在我们在之后计算指数的过程中,计算的数字不都拿得增大,非常的占用我们的计算资源(主要是时间,还有空间) 缺点2:我们计算的中间过程数字大的恐怖,我们现有的计算机是没有办法记录这么长的数据的,所以说我们必须要想一个更加高效的方法来解决这个问题 当我们计算AB%C的时候,最便捷的方法就是调用Ma

  • Java垃圾回收之分代收集算法详解

    概述 这种算法,根据对象的存活周期的不同将内存划分成几块,新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法.可以用抓重点的思路来理解这个算法. 新生代对象朝生夕死,对象数量多,只要重点扫描这个区域,那么就可以大大提高垃圾收集的效率.另外老年代对象存储久,无需经常扫描老年代,避免扫描导致的开销. 新生代 在新生代,每次垃圾收集器都发现有大批对象死去,只有少量存活,采用复制算法,只需要付出少量存活对象的复制成本就可以完成收集:可以参看我之前写的Java垃圾回收之复制算法详解 老年代

  • Java垃圾回收之复制算法详解

    之前的Java垃圾回收之标记清除算法详解 会导致内存碎片.下文的介绍的coping算法可以解决内存碎片问题. 概述 如果jvm使用了coping算法,一开始就会将可用内存分为两块,from域和to域, 每次只是使用from域,to域则空闲着.当from域内存不够了,开始执行GC操作,这个时候,会把from域存活的对象拷贝到to域,然后直接把from域进行内存清理. 应用场景 coping算法一般是使用在新生代中,因为新生代中的对象一般都是朝生夕死的,存活对象的数量并不多,这样使用coping算法

  • Java垃圾回收机制算法详解

    概述 Java GC(Garbage Collection,垃圾回收)机制,是Java与C++/C的主要区别之一,作为Java开发者,一般不需要专门编写内存回收和垃圾清理代码,对内存泄露和溢出的问题,也不需要像C程序员那样战战兢兢.这是因为在Java虚拟机中,存在自动内存管理和垃圾清扫机制.概括地说,该机制对JVM中的内存进行标记,并确定哪些内存需要回收,根据一定的回收策略,自动的回收内存,永不停息的保证JVM中的内存空间,防止出现内存泄露和溢出问题. 在真实工作中的项目中,时不时的会发生内存溢

  • Java实现权重随机算法详解

    目录 应用场景 本文目标 算法详解 权重比例 Java 实现 参考 应用场景 客户端负载均衡,例如 Nacos 提供的客户端负载均衡就是使用了该算法 游戏抽奖(普通道具的权重很高,稀有道具的权重很低) 本文目标 Java 实现权重随机算法 算法详解 比如我们现在有三台 Server,权重分别为1,3,2.现在想对三台 Server 做负载均衡 Server1 Server2 Server3 weight weight weight 1 3 2 权重比例 我们算出每台 Server 的权重比例,权

随机推荐