图论算法之Prim 算法:对比 Kruskal、切分定理、实现和题目实践

2022-02-2210:36:49数据结构与算法Comments1,583 views字数 7143阅读模式

Prim 算法和 Kruskal 算法都是经典的最小生成树算法,阅读本文之前,希望你了解Kruskal 最小生成树的基本定义以及 Kruskal 算法的基本原理,这样就能很容易理解 Prim 算法逻辑了。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

对比 Kruskal 算法

图论的最小生成树问题,就是让你从图中找若干边形成一个边的集合mst,这些边有以下特性:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

1、这些边组成的是一棵树(树和图的区别在于不能包含环)。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

2、这些边形成的树要包含所有节点。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

3、这些边的权重之和要尽可能小。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

那么 Kruskal 算法是使用什么逻辑满足上述条件,计算最小生成树的呢?文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

首先,Kruskal 算法用到了贪心思想,来满足权重之和尽可能小的问题:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

先对所有边按照权重从小到大排序,从权重最小的边开始,选择合适的边加入mst集合,这样挑出来的边组成的树就是权重和最小的。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

其次,Kruskal 算法用到了 Union-Find 并查集算法,来保证挑选出来的这些边组成的一定是一棵「树」,而不会包含环或者形成一片「森林」:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

如果一条边的两个节点已经是连通的,则这条边会使树中出现环;如果最后的连通分量总数大于 1,则说明形成的是「森林」而不是一棵「树」。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

那么,本文的主角 Prim 算法是使用什么逻辑来计算最小生成树的呢?文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

首先,Prim 算法也使用贪心思想来让生成树的权重尽可能小,也就是「切分定理」,这个后文会详细解释。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

其次,Prim 算法使用 BFS 算法思想 和visited布尔数组避免成环,来保证选出来的边最终形成的一定是一棵树。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

Prim 算法不需要事先对所有边排序,而是利用优先级队列动态实现排序的效果,所以我觉得 Prim 算法类似于 Kruskal 的动态过程。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

下面介绍一下 Prim 算法的核心原理:切分定理文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

切分定理

「切分」这个术语其实很好理解,就是将一幅图分为两个不重叠且非空的节点集合:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

图论算法之Prim 算法:对比 Kruskal、切分定理、实现和题目实践

红色的这一刀把图中的节点分成了两个集合,就是一种「切分」,其中被红线切中的的边(标记为蓝色)叫做「横切边」。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

PS:记住这两个专业术语的意思,后面我们会频繁使用这两个词,别搞混了。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

当然,一幅图肯定可以有若干种切分,因为根据切分的定义,只要你能一刀把节点分成两部分就行。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

接下来我们引入「切分定理」:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

对于任意一种「切分」,其中权重最小的那条「横切边」一定是构成最小生成树的一条边文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

这应该很容易证明,如果一幅加权无向图存在最小生成树,假设下图中用绿色标出来的边就是最小生成树:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

图论算法之Prim 算法:对比 Kruskal、切分定理、实现和题目实践

那么,你肯定可以找到若干「切分」方式,将这棵最小生成树切成两棵子树。比如下面这种切分:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

图论算法之Prim 算法:对比 Kruskal、切分定理、实现和题目实践

你会发现,任选一条蓝色的「横切边」都可以将这两棵子树连接起来,构成一棵生成树。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

那么为了让最终这棵生成树的权重和最小,你说你要怎么选?文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

肯定选权重最小的那条「横切边」对吧,这就证明了切分定理。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

关于切分定理,你也可以用反证法证明:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

给定一幅图的最小生成树,那么随便给一种「切分」,一定至少有一条「横切边」属于最小生成树。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

假设这条「横切边」不是权重最小的,那说明最小生成树的权重和就还有再减小的余地,那这就矛盾了,最小生成树的权重和本来就是最小的,怎么再减?所以切分定理是正确的。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

有了这个切分定理,你大概就有了一个计算最小生成树的算法思路了:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

既然每一次「切分」一定可以找到最小生成树中的一条边,那我就随便切呗,每次都把权重最小的「横切边」拿出来加入最小生成树,直到把构成最小生成树的所有边都切出来为止文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

嗯,可以说这就是 Prim 算法的核心思路,不过具体实现起来,还是要有些技巧的。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

因为你没办法让计算机理解什么叫「随便切」,所以应该设计机械化的规则和章法来调教你的算法,并尽量减少无用功。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

Prim 算法实现

我们思考算法问题时,如果问题的一般情况不好解决,可以从比较简单的特殊情况入手,Prim 算法就是使用的这种思路。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

按照「切分」的定义,只要把图中的节点切成两个不重叠且非空的节点集合即可算作一个合法的「切分」,那么我只切出来一个节点,是不是也算是一个合法的「切分」?文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

是的,这是最简单的「切分」,而且「横切边」也很好确定,就是这个节点的边。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

那我们就随便选一个点,假设就从A点开始切分:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

图论算法之Prim 算法:对比 Kruskal、切分定理、实现和题目实践

既然这是一个合法的「切分」,那么按照切分定理,这些「横切边」AB, AF中权重最小的边一定是最小生成树中的一条边:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

图论算法之Prim 算法:对比 Kruskal、切分定理、实现和题目实践

好,现在已经找到最小生成树的第一条边(边AB),然后呢,如何安排下一次「切分」?文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

按照 Prim 算法的逻辑,我们接下来可以围绕AB这两个节点做切分:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

图论算法之Prim 算法:对比 Kruskal、切分定理、实现和题目实践

然后又可以从这个切分产生的横切边(图中蓝色的边)中找出权重最小的一条边,也就又找到了最小生成树中的第二条边BC文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

图论算法之Prim 算法:对比 Kruskal、切分定理、实现和题目实践

接下来呢?也是类似的,再围绕着A, B, C这三个点做切分,产生的横切边中权重最小的边是BD,那么BD就是最小生成树的第三条边:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

图论算法之Prim 算法:对比 Kruskal、切分定理、实现和题目实践文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

接下来再围绕A, B, C, D这四个点做切分……文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

Prim 算法的逻辑就是这样,每次切分都能找到最小生成树的一条边,然后又可以进行新一轮切分,直到找到最小生成树的所有边为止文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

这样设计算法有一个好处,就是比较容易确定每次新的「切分」所产生的「横切边」。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

比如回顾刚才的图,当我知道了节点A, B的所有「横切边」(不妨表示为cut({A, B})),也就是图中蓝色的边:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

图论算法之Prim 算法:对比 Kruskal、切分定理、实现和题目实践

是否可以快速算出cut({A, B, C}),也就是节点A, B, C的所有「横切边」有哪些?文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

图论算法之Prim 算法:对比 Kruskal、切分定理、实现和题目实践文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

是可以的,因为我们发现:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

cut({A, B, C}) = cut({A, B}) + cut({C})

cut({C})就是节点C的所有邻边:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

图论算法之Prim 算法:对比 Kruskal、切分定理、实现和题目实践

这个特点使我们用我们写代码实现「切分」和处理「横切边」成为可能:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

在进行切分的过程中,我们只要不断把新节点的邻边加入横切边集合,就可以得到新的切分的所有横切边。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

当然,细心的读者肯定发现了,cut({A, B})的横切边和cut({C})的横切边中BC边重复了。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

不过这很好处理,用一个布尔数组inMST辅助,防止重复计算横切边就行了。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

最后一个问题,我们求横切边的目的是找权重最小的横切边,怎么做到呢?文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

很简单,用一个优先级队列存储这些横切边,就可以动态计算权重最小的横切边了。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

明白了上述算法原理,下面来看一下 Prim 算法的代码实现文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

class Prim {
    // 核心数据结构,存储「横切边」的优先级队列
    private PriorityQueue<int[]> pq;
    // 类似 visited 数组的作用,记录哪些节点已经成为最小生成树的一部分
    private boolean[] inMST;
    // 记录最小生成树的权重和
    private int weightSum = 0;
    // graph 是用邻接表表示的一幅图,
    // graph
Spread收缩
 记录节点 s 所有相邻的边,     // 三元组 int[]{from, to, weight} 表示一条边     private List<int[]>[] graph;     public Prim(List<int[]>[] graph) {         this.graph = graph;         this.pq = new PriorityQueue<>((a, b) -> {             // 按照边的权重从小到大排序             return a[2] - b[2];         });         // 图中有 n 个节点         int n = graph.length;         this.inMST = new boolean[n];         // 随便从一个点开始切分都可以,我们不妨从节点 0 开始         inMST[0] = true;         cut(0);         // 不断进行切分,向最小生成树中添加边         while (!pq.isEmpty()) {             int[] edge = pq.poll();             int to = edge[1];             int weight = edge[2];             if (inMST[to]) {                 // 节点 to 已经在最小生成树中,跳过                 // 否则这条边会产生环                 continue;             }             // 将边 edge 加入最小生成树             weightSum += weight;             inMST[to] = true;             // 节点 to 加入后,进行新一轮切分,会产生更多横切边             cut(to);         }     }     // 将 s 的横切边加入优先队列     private void cut(int s) {         // 遍历 s 的邻边         for (int[] edge : graph
Spread收缩
) {             int to = edge[1];             if (inMST[to]) {                 // 相邻接点 to 已经在最小生成树中,跳过                 // 否则这条边会产生环                 continue;             }             // 加入横切边队列             pq.offer(edge);         }     }     // 最小生成树的权重和     public int weightSum() {         return weightSum;     }     // 判断最小生成树是否包含图中的所有节点     public boolean allConnected() {         for (int i = 0; i < inMST.length; i++) {             if (!inMST[i]) {                 return false;             }         }         return true;     } }

明白了切分定理,加上详细的代码注释,你应该能够看懂 Prim 算法的代码了。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

这里我们可以再回顾一下本文开头说的 Prim 算法和 Kruskal 算法 的联系:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

Kruskal 算法是在一开始的时候就把所有的边排序,然后从权重最小的边开始挑选属于最小生成树的边,组建最小生成树。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

Prim 算法是从一个起点的切分(一组横切边)开始执行类似 BFS 算法的逻辑,借助切分定理和优先级队列动态排序的特性,从这个起点「生长」出一棵最小生成树。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

说到这里,Prim 算法的时间复杂度是多少呢文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

这个不难分析,复杂度主要在优先级队列pq的操作上,由于pq里面装的是图中的「边」,假设一幅图边的条数为E,那么最多操作O(E)pq。每次操作优先级队列的时间复杂度取决于队列中的元素个数,取最坏情况就是O(logE)文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

所以这种 Prim 算法实现的总时间复杂度是O(ElogE)。回想一下 Kruskal 算法,它的时间复杂度主要是给所有边按照权重排序,也是O(ElogE)文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

不过话说回来,和前文 Dijkstra 算法 类似,Prim 算法的时间复杂度也是可以优化的,但优化点在于优先级队列的实现上,和 Prim 算法本身的算法思想关系不大,所以我们这里就不做讨论了,有兴趣的读者可以自行搜索。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

接下来,我们实操一波,把之前用 Kruskal 算法解决的力扣题目运用 Prim 算法再解决一遍。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

题目实践

第一题是力扣第 1135 题「最低成本联通所有城市」,这是一道标准的最小生成树问题:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

图论算法之Prim 算法:对比 Kruskal、切分定理、实现和题目实践

函数签名如下:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

int minimumCost(int n, int[][] connections);

每座城市相当于图中的节点,连通城市的成本相当于边的权重,连通所有城市的最小成本即是最小生成树的权重之和。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

那么解法就很明显了,我们先把题目输入的connections转化成邻接表形式,然后输入给之前实现的Prim算法类即可:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

public int minimumCost(int n, int[][] connections) {
    // 转化成无向图邻接表的形式
    List<int[]>[] graph = buildGraph(n, connections);
    // 执行 Prim 算法
    Prim prim = new Prim(graph);

    if (!prim.allConnected()) {
        // 最小生成树无法覆盖所有节点
        return -1;
    }

    return prim.weightSum();
}

List<int[]>[] buildGraph(int n, int[][] connections) {
    // 图中共有 n 个节点
    List<int[]>[] graph = new LinkedList[n];
    for (int i = 0; i < n; i++) {
        graph[i] = new LinkedList<>();
    }
    for (int[] conn : connections) {
        // 题目给的节点编号是从 1 开始的,
        // 但我们实现的 Prim 算法需要从 0 开始编号
        int u = conn[0] - 1;
        int v = conn[1] - 1;
        int weight = conn[2];
        // 「无向图」其实就是「双向图」
        // 一条边表示为 int[]{from, to, weight}
        graph[u].add(new int[]{u, v, weight});
        graph[v].add(new int[]{v, u, weight});
    }
    return graph;
}

class Prim { /* 见上文 */ }

关于buildGraph 函数需要注意两点:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

一是题目给的节点编号是从 1 开始的,所以我们做一下索引偏移,转化成从 0 开始以便Prim类使用;文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

二是如何用邻接表表示无向加权图,前文 图论算法基础 说过「无向图」其实就可以理解为「双向图」。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

这样,我们转化出来的graph形式就和之前的Prim算法类对应了,可以直接施展 Prim 算法计算最小生成树。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

再来看看力扣第 1584 题「连接所有点的最小费用」:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

图论算法之Prim 算法:对比 Kruskal、切分定理、实现和题目实践

比如题目给的例子:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

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

算法应该返回 20,按如下方式连通各点:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

图论算法之Prim 算法:对比 Kruskal、切分定理、实现和题目实践

函数签名如下:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

int minCostConnectPoints(int[][] points);

很显然这也是一个标准的最小生成树问题:每个点就是无向加权图中的节点,边的权重就是曼哈顿距离,连接所有点的最小费用就是最小生成树的权重和。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

所以我们只要把points数组转化成邻接表的形式,即可复用之前实现的Prim算法类:文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

public int minCostConnectPoints(int[][] points) {
    int n = points.length;
    List<int[]>[] graph = buildGraph(n, points);
    return new Prim(graph).weightSum();
}

// 构造无向图
List<int[]>[] buildGraph(int n, int[][] points) {
    List<int[]>[] graph = new LinkedList[n];
    for (int i = 0; i < n; i++) {
        graph[i] = new LinkedList<>();
    }
    // 生成所有边及权重
    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];
            int weight = Math.abs(xi - xj) + Math.abs(yi - yj);
            // 用 points 中的索引表示坐标点
            graph[i].add(new int[]{i, j, weight});
            graph[j].add(new int[]{j, i, weight});
        }
    }
    return graph;
}

class Prim { /* 见上文 */ }

这道题做了一个小的变通:每个坐标点是一个二元组,那么按理说应该用五元组表示一条带权重的边,但这样的话不便执行 Prim 算法;所以我们用points数组中的索引代表每个坐标点,这样就可以直接复用之前的 Prim 算法逻辑了。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

作者labuladong文章源自菜鸟学院-https://www.cainiaoxueyuan.com/suanfa/23264.html

  • 本站内容整理自互联网,仅提供信息存储空间服务,以方便学习之用。如对文章、图片、字体等版权有疑问,请在下方留言,管理员看到后,将第一时间进行处理。
  • 转载请务必保留本文链接:https://www.cainiaoxueyuan.com/suanfa/23264.html

Comment

匿名网友 填写信息

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定