Code前端首页关于Code前端联系我们

图论算法之最小生成树Kruskal算法:关键是要熟悉Union-Find算法

terry 2年前 (2023-09-27) 阅读数 86 #数据结构与算法

最小生成树算法主要有两种:Prim算法(Prim算法)和Kruskal算法(Kruskal算法) )。虽然这两种算法都采用了贪心思想,但它们的实现差异还是相当大的。本文会先讲Kruskal算法,Prim算法会单独写一篇文章。

克鲁斯卡尔算法实际上非常容易理解和记忆。关键是要熟悉并查算法。如果不熟悉,建议阅读之前的文章并查算法。

然后我们从最小生成树的定义开始。

什么是最小生成树?

首先我们来说说“树”和“图”的根本区别:树不包含环,但图可以包含环

如果图像没有环,则可以将其拉伸为看起来像一棵树。更专业地说,树就是“无环连通图”。

那么什么是图的“生成树”呢?其实从字面意思上看也很容易理解。就是在图中找到一棵包含图中所有节点的树。从技术上讲,生成树是一个“非循环连接子图”,包含图的所有顶点。

很容易认为一个图像可以有许多不同的生成树。例如,在下图中,红色边形成两个不同的生成树:

图论算法之最小生成树Kruskal 算法:关键是熟悉Union-Find 并查集算法

对于加权图,每条边都有一个权重。所以每棵生成树都有一个权重之和。例如上图中,右侧生成树的权重之和明显小于左侧生成树的权重之和。

那么最小生成树就很容易理解了。在所有可能的生成树中,权重和最小的生成树称为“最小生成树”

PS:一般情况下,我们计算的是无向加权图中的最小生成树,所以在实际场景中使用最小生成树算法时,图的边权值一般表示成本和距离这样的标量。

在讲Kruskal算法之前,我们需要回顾一下并查算法。

并查并查算法

正如我刚才所说,图的生成树是一个包含其所有顶点的“无环连通子图”,最小生成树是权重最小的生成树总和。

说到连通性,我想老读者都能想到并查算法,它用来高效处理图中连通分量的问题。

上一篇文章《并查并查算法详解》详细介绍了并查算法的实现原理。它主要利用size数组和路径压缩技术来提高连通分量的评估效率。

不了解并查算法的读者可以阅读上一篇文章。为了节省篇幅,本文直接提供了并查算法的实现:

class UF {
    // 连通分量个数
    private int count;
    // 存储一棵树
    private int[] parent;
    // 记录树的「重量」
    private int[] size;

    // n 为图中节点的个数
    public UF(int n) {
        this.count = n;
        parent = new int[n];
        size = new int[n];
        for (int i = 0; i < n; i++) {
            parent[i] = i;
            size[i] = 1;
        }
    }

    // 将节点 p 和节点 q 连通
    public void union(int p, int q) {
        int rootP = find(p);
        int rootQ = find(q);
        if (rootP == rootQ)
            return;

        // 小树接到大树下面,较平衡
        if (size[rootP] > size[rootQ]) {
            parent[rootQ] = rootP;
            size[rootP] += size[rootQ];
        } else {
            parent[rootP] = rootQ;
            size[rootQ] += size[rootP];
        }
        // 两个连通分量合并成一个连通分量
        count--;
    }

    // 判断节点 p 和节点 q 是否连通
    public boolean connected(int p, int q) {
        int rootP = find(p);
        int rootQ = find(q);
        return rootP == rootQ;
    }

    // 返回节点 x 的连通分量根节点
    private int find(int x) {
        while (parent[x] != x) {
            // 进行路径压缩
            parent[x] = parent[parent[x]];
            x = parent[x];
        }
        return x;
    }

    // 返回图中的连通分量个数
    public int count() {
        return count;
    }
}

并查算法在上一篇文章《并查并查算法应用》中介绍了一些算法场景,以及它的主要作用Kruskal算法是为了保证最小生成树的合法性。

因为在构建最小生成树的过程中,首先要确保生成的东西是一棵树(不包含环),对吧?那么并查算法将帮助您解决这个问题。

我该怎么做?我们看一下理口第261题“看图论树”。我将描述下面的问题:

将为您提供从0n -1s的输入号码和一个NonEdge列表,(每个边缘都表示由一个节点元组),请确定由这些输入边组成的结构是否是树。

函数签名如下:

boolean validTree(int n, int[][] edges);

例如,输入如下:

n = 5
edges = [[0,1], [0,2], [0,3], [1,4]]

这些边形成一棵树,算法应返回 true: 图论算法之最小生成树Kruskal 算法:关键是熟悉Union-Find 并查集算法

但是如果输入是: ,则形成的结构不是树,因为包含环:图论算法之最小生成树Kruskal 算法:关键是熟悉Union-Find 并查集算法

对于这个问题我们可以想一下,什么情况下添加一条边会让树变成一个图(出现环)

显然,添加这样的边会导致循环: 图论算法之最小生成树Kruskal 算法:关键是熟悉Union-Find 并查集算法

添加这样的边不会导致循环: 图论算法之最小生成树Kruskal 算法:关键是熟悉Union-Find 并查集算法

总结一下规则:

对于这个额外的边,如果边A节点的两侧原本在同一个连通域中,那么添加这条边就会产生环;反之,如果边的两个节点不在同一个连通域中,则添加这条边不会产生环

判断两个节点是否连通(是否在同一个连通分量中)是并查算法的特殊之处,所以该问题的解法代码如下:

// 判断输入的若干条边是否能构造出一棵树结构
boolean validTree(int n, int[][] edges) {
    // 初始化 0...n-1 共 n 个节点
    UF uf = new UF(n);
    // 遍历所有边,将组成边的两个节点进行连接
    for (int[] edge : edges) {
        int u = edge[0];
        int v = edge[1];
        // 若两个节点已经在同一连通分量中,会产生环
        if (uf.connected(u, v)) {
            return false;
        }
        // 这条边不会产生环,可以是树的一部分
        uf.union(u, v);
    }
    // 要保证最后只形成了一棵树,即只有一个连通分量
    return uf.count() == 1;
}

class UF { 
    // 见上文代码实现
}

如果你能理解解法这个问题思路,那么掌握Kruskal算法就非常容易了。

Kruskal算法

所谓最小生成树,就是图中有几条边的集合(后面我们将这个集合称为mst,最小生成树的英文缩写)。您必须确保这些边缘:

1。包含图中的所有节点。

2。形成的结构是树结构(即没有循环)。

3。重量总和最小。

作为上一个问题的伏笔,前两个其实可以使用并查算法轻松实现。关键在于第3点,如何保证生成的生成树的权值和最小。

这里用到了贪心思想:

将所有边按权重从小到大排序,从权重最小的边开始穿越。如果这条边和mst中的其他边不会形成环,那么这条边是最小生成树的一部分,将其添加到mst集中;否则,该边不是最小生成树的一部分,请勿将其添加到mst

这样,最后一个mst集合中的边就形成了一棵最小生成树。让我们看一下使用 Kruskal 算法的两个示例。

第一个问题与问题1135“以尽可能低的成本连接所有城市”密切相关。这是一个标准的最小生成树问题:图论算法之最小生成树Kruskal 算法:关键是熟悉Union-Find 并查集算法

每个城市对应图中的一个节点,连接城市的成本对应一条边。的权重,连接所有城市的最小成本是最小生成树的权重之和。

int minimumCost(int n, int[][] connections) {
    // 城市编号为 1...n,所以初始化大小为 n + 1
    UF uf = new UF(n + 1);
    // 对所有边按照权重从小到大排序
    Arrays.sort(connections, (a, b) -> (a[2] - b[2]));
    // 记录最小生成树的权重之和
    int mst = 0;
    for (int[] edge : connections) {
        int u = edge[0];
        int v = edge[1];
        int weight = edge[2];
        // 若这条边会产生环,则不能加入 mst
        if (uf.connected(u, v)) {
            continue;
        }
        // 若这条边不会产生环,则属于最小生成树
        mst += weight;
        uf.union(u, v);
    }
    // 保证所有节点都被连通
    // 按理说 uf.count() == 1 说明所有节点被连通
    // 但因为节点 0 没有被使用,所以 0 会额外占用一个连通分量
    return uf.count() == 2 ? mst : -1;
}

class UF {
    // 见上文代码实现
}

这个问题就解决了。总体思路与上一个问题非常相似。你可以把树判定算法加上按权重排序的逻辑看成是Kruskal算法。

我们看一下Likou问题1584“连接所有点的最小成本”:图论算法之最小生成树Kruskal 算法:关键是熟悉Union-Find 并查集算法

例如问题中给出的例子:

points = [[0,0],[2,2],[3,10],[5,2],[7,0]]

算法应该返回20,连接点如下:

图论算法之最小生成树Kruskal 算法:关键是熟悉Union-Find 并查集算法

这当然也是一个标准的最小生成树问题:每个点都是无向加权图中的一个节点,边权重是曼哈顿距离,连接所有点的最小成本是权重之和最小生成树。

所以解题思路是先生成所有的边和权重,然后对这些边进行Kruskal算法:

int minCostConnectPoints(int[][] points) {
    int n = points.length;
    // 生成所有边及权重
    List<int[]> edges = new ArrayList<>();
    for (int i = 0; i < n; i++) {
        for (int j = i + 1; j < n; j++) {
            int xi = points[i][0], yi = points[i][1];
            int xj = points[j][0], yj = points[j][1];
            // 用坐标点在 points 中的索引表示坐标点
            edges.add(new int[] {
                i, j, Math.abs(xi - xj) + Math.abs(yi - yj)
            });
        }
    }
    // 将边按照权重从小到大排序
    Collections.sort(edges, (a, b) -> {
        return a[2] - b[2];
    });
    // 执行 Kruskal 算法
    int mst = 0;
    UF uf = new UF(n);
    for (int[] edge : edges) {
        int u = edge[0];
        int v = edge[1];
        int weight = edge[2];
        // 若这条边会产生环,则不能加入 mst
        if (uf.connected(u, v)) {
            continue;
        }
        // 若这条边不会产生环,则属于最小生成树
        mst += weight;
        uf.union(u, v);
    }
    return mst;
}

对这道题做了一个小小的修改:每个坐标点都是一个元组,所以基于据说应该用五元组来表示加权边,但是在这种情况下执行并查算法是不切实际的;所以我们用points矩阵中的索引来表示每个坐标点,这样我们就可以直接复用之前的Kruskal的算法逻辑。

通过上面三道算法题,我想你已经掌握了Kruskal算法。主要难点是利用并查算法给最小生成树添加边,配合排序的贪心思想得到带权树。和最小的拱肩。我们来谈谈Kruskal算法的复杂度分析:

假设一张图中的节点数为V,边数为e。首先,需要。 O(E)的空间包含所有边,并查算法还需要O(V)的空间,所以Kruskal的总空间复杂度为 算法为O(V + E)

时间复杂度主要花在排序上,需要O(ElogE)。并查算法所有操作的复杂度为 O(1),而一个 for 循环无非就是O(E),所以总的时间复杂度为O(ElogE)

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门