如何使用Prim算法在Java中找到最小跨度树(MST)

160 阅读8分钟

简介

图是存储某些类型的数据的一种便捷方式。这个概念是从数学中移植过来的,并适用于计算机科学的需要。

由于很多东西都可以用图来表示,图的遍历已经成为一项常见的任务,特别是在数据科学和机器学习中使用。

Prim算法是如何工作的?

普利姆算法的设计是为了找到一个 ***最小生成树(MST)*为一个连接的、加权的无向图。这意味着该算法找到了一棵 "树"(一种没有循环的结构),它通过所有可用的边的一个子集连接所有的顶点,这些边的权重最小。

与Dijkstra算法一样,Prim算法也是一种贪婪的算法,但Prim算法允许负加权的边

在算法的最后,我们将循环浏览包含最低成本边的数组,并将它们相加,得到图中MST的值

我们将讨论这个算法的每一步是如何工作的,但可以列出算法的一个粗略的轮廓。假设我们有一个加权图G ,有一组顶点(节点)V ,有一组边E

  • 我们选择其中一个节点s 作为起始节点,并设定从ss 的距离为0
  • 我们将从节点s ,给其他每个节点分配一个数字,在开始时将其标记为无穷大。这个数字将随着我们的算法进展而改变和更新。
  • 每个节点s ,也将有一个代表 "父 "节点的数字,我们在MST中从它那里连接。这个数字被初始化为-1除了起始节点,其他每个节点在Prim算法结束时都会有一个与-1 不同的数字与之相关。
  • 对于每一个节点s ,我们将找到连接一个已经包括在MST中的节点的最小边。 不是已经包含在MST中。由于Prim's是一种贪婪的算法,一旦我们进入节点,我们就可以肯定我们已经选择了连接它和它的父节点的最短路径。我们重复这个步骤,直到所有的节点都被添加到MST中。
  • 最后,我们在我们的MST数组中循环,把边加起来,得到MST的值。

Prim算法的可视化

让我们快速可视化一个简单的例子--手动使用Prim算法在以下图形上寻找最小生成树。

graphClanak

我们将有5个节点,编号为0到4,每条边上的数字代表该边的权重。让我们来描述一下INF/-1 对:开头的-1 代表父节点,从该节点有一条连接到当前节点的边,其权重为INF 。当然,随着算法的进行,这些值也会被更新。

比方说,0 将是我们的起始节点。我们在前面提到,当我们选择起始节点时,我们需要设置与自己的距离为0 。由于0 是与自己有最小边的节点,我们可以安全地假设0 属于MST,我们就把它加进去。经过这个小小的改变,图看起来如下。

graphClanak1step

白色的节点代表我们添加到MST中的节点。

下一步是使Prim的算法成为现实的一个步骤。我们循环浏览节点0 的所有邻居,沿途检查一些东西。

  1. 如果该边缘存在
  2. 如果邻居节点已经被添加到MST中
  3. 如果通往邻居的边的成本低于当前通往该邻居的最小成本的边

0 的第一个邻居是1 。连接它们的边的权重为1 。该边存在,而当前节点1 不在 MST 中,所以剩下的就是检查从01 的边是否是通向节点1 的最小加权边。很明显,1 小于INF ,所以我们把节点1 的距离/父对更新为1/0

我们对节点0 的其他邻居采取完全相同的步骤,之后我们选择具有最小边权重的节点加入到MST中,并将其标记为蓝色。这个节点就是1

现在我们有了下面这个图。

graphClanak2step

我们现在要考虑的节点是1 。正如我们对节点0 所做的那样,我们检查节点1 的所有邻居。

节点0 已经被添加到MST中,所以我们跳过这一个。

节点2 是下一个邻居,从节点1 通向它的边的权重是2 。这条边的权重比之前通往该节点的边小,后者的权重是5 ,来自节点0

另一个邻居节点4 也是如此:从节点1 通向它的边的权重是1 ,而之前从节点0 通向节点4 的最小的权重边是4

我们选择下一个没有被添加到MST的节点,并且从节点1 ,有最小的加权边。这个节点就是节点4

更新后,我们有以下的图。

graphClanak3step

当我们考虑节点4 ,我们看到我们不能更新任何当前的边。也就是说,节点4 的两个邻居都已经属于MST,所以那里没有什么可更新的,我们只是在算法中继续前进,在这一步不做任何事情。

我们继续寻找一个与属于MST的节点相连的节点,并且具有最小的加权边。这个节点目前是2 ,它通过有权重的边连接到节点12 。图看起来如下。

graphClanak4step

01 两个节点都已经属于 MST,所以我们唯一可能去的节点是3 。从节点2 通向节点3 的边的权重是4 ,这显然小于之前从节点0 通向的10 。我们对其进行更新,得到以下图形。

graphClanak5step

这样,我们已经访问了所有现有的节点,并将其添加到MST中,由于Prim的算法是一种贪婪的算法,这意味着我们已经找到了MST。

让我们回忆一下;被添加到跟踪我们的MST的数组中的边是以下内容。

  • 权重的边缘0-1 1
  • 边缘1-2 的权重2
  • 权重的边1-4 1
  • 权重的边2-3 4

剩下的就是把组成MST的所有边加起来,之后我们得到我们例子中的图的MST的值是8 ,我们在这里结束了算法的执行。

Prim算法的时间复杂度是O((|E| + |V|)log|V|) ,其中|E| 是图中边的数量,|V| 是图中顶点(节点)的数量。

在Java中实现Prim的算法

有了一般的想法和可视化的方式,让我们在Java中实现Prim的算法。

在本指南中,我们将使用邻接矩阵的方法。请注意,我们也可以使用邻接列表来实现Prim的算法,但矩阵方法稍微容易一些,而且代码变得更短、更易读。

稍后要注意的一件事是,当我们初始化我们的邻接矩阵时,所有没有分配权重的地方将自动被初始化为0

图形类的实现

首先,我们要在我们的Graph 类中添加三个新的数组。

public class Graph {

    private int numOfNodes;
    private boolean directed;
    private boolean weighted;
    private double[][] matrix;
    
    private double[] edges;
    private double[] parents;
    private boolean[] includedInMST;
    
    private boolean[][] isSetMatrix;
   
	// ...
}

让我们简单地看看这些数组分别代表什么。

  • edges 代表一个数组,用于保存属于MST的、连接节点和父节点的边的值。
  • parents 给我们提供每个节点的父节点的信息。
  • includedInMST 告诉我们,我们要检查的节点是否已经属于MST中。

然后,我们将这些内容与之前声明的变量一起添加到构造函数中。

public Graph(int numOfNodes, boolean directed, boolean weighted) {
    this.directed = directed;
    this.weighted = weighted;
    this.numOfNodes = numOfNodes;

    // Simply initializes our adjacency matrix to the appropriate size
    matrix = new double[numOfNodes][numOfNodes];
    isSetMatrix = new boolean[numOfNodes][numOfNodes];
    
    edges = new double[numOfNodes];
    parents = new double[numOfNodes];
    includedInMST = new boolean[numOfNodes];

    for(int i = 0; i < numOfNodes; i++){
        edges[i] = Double.POSITIVE_INFINITY;
        parents[i] = -1;
        includedInMST[i] = false;
    }
}

我们已经为我们的每个单独的数组分配了numOfNodes 空间。这里的一个重要步骤是初始化。

  • 开始时到每一个节点的距离被设置为Double.POSITIVE_INFINITY 。这基本上意味着我们还没有从任何其他节点到达该节点,因此到它的距离是Infinity 。这个数字在Java中也代表Infinity ,是一种数据类型。
  • 由于在算法开始时没有一个节点被到达,每一个节点的父节点都被设置为-1 ,表示该特定节点没有它所到达的父节点。我们可以将父节点的值设置为-1 ,原因是我们将节点从0n-1 ,其中n 是节点的数量,所以从逻辑上来说,有一个节点-1 是没有意义的。
  • 在算法的开始,没有一个节点属于MST,所以从逻辑上讲,不包括任何一个节点,即把includedInMST 中的每一个成员的值设为false

addEdge()printMatrix() 方法保持不变,因为它们的作用都是不言自明的,我们就不深究了。

然而,我们确实需要额外的getterssetters,让我们能够改变上述数组。这些是以下内容。

public int getNumOfNodes() {
    return numOfNodes;
}

public double getEdges(int i) {
	return edges[i];
}

public void setEdges(double edge, int node) {
	this.edges[node] = edge;
}

public boolean getIncludedInMST(int i) {
	return includedInMST[i];
}

public void setIncludedInMST(int node) {
	this.includedInMST[node] = true;
}

public double[][] getMatrix() {
	return matrix;
}

public void setParents(double parent, int node) {
	this.parents[node] = parent;
}

public double getParents(int i) { 
   return parents[i]; 
}

如果这些获取器/设置器中的任何一个不直观--每一个获取器和设置器都将在我们实现Prim的算法时使用它们时被额外解释。

至此,我们已经完成了对加权Graph 的适应性实现,我们可以继续讨论算法本身了。

Prim算法的实现

准备好了Graph ,我们就可以继续实现在它上面运行的算法了。让我们用一组节点和它们的边来初始化一个Graph 。我们将使用与前面章节中的可视化相同的节点和边的集合。

public class Prim {
    public static void main(String[] args){
        Graph graph = new Graph(5, false, true);

        graph.addEdge(0, 1, 1);
        graph.addEdge(0, 2, 5);
        graph.addEdge(0, 3, 10);
        graph.addEdge(0, 4, 4);
        graph.addEdge(1, 2, 2);
        graph.addEdge(1, 4, 1);
        graph.addEdge(2, 3, 4);
     	
        // ...
    }
}

使用graph.printMatrix() 打印出这个矩阵,输出如下。

 /       1.0     5.0    10.0     4.0
 1.0     /       2.0     /       1.0
 5.0     2.0     /       4.0     /
10.0     /       4.0     /       /
 4.0     1.0     /       /       /

我们还需要一个名为minEdgeNotIncluded() 的方法,找到通向尚未包含在MST中的邻居的最小加权边。

public static int minEdgeNotIncluded(Graph graph){
    double min = Double.POSITIVE_INFINITY;
    int minIndex = -1;
    int numOfNodes = graph.getNumOfNodes();

    for(int i = 0; i < numOfNodes; i++){
        if(!graph.getIncludedInMST(i) && graph.getEdges(i) < min){
            minIndex = i;
            min = graph.getEdges(i);
        }
    }
    return minIndex;
}

Infinity 在开始时,我们将min ,表示我们还没有找到最小的边。变量minIndex 代表我们正在寻找的最小边所连接的节点,我们在开始时将其初始化为-1 。之后,我们循环浏览所有的节点,寻找一个还没有被包含在MST中的节点,之后我们检查连接到该节点的边是否是 更小的比我们当前的min 边。

最后,我们准备实现Prim的算法。

public class Prim {
    public static void main(String[] args){
        // Initialized and added the graph earlier
        
        int startNode = 0;
        // Distance from the start node to itself is 0
        graph.setEdges(0, startNode); 

        for(int i = 0; i < graph.getNumOfNodes()-1; i++){
            int node = minEdgeNotIncluded(graph);

            graph.setIncludedInMST(node);

            double[][] matrix = graph.getMatrix();
            for(int v = 0; v < graph.getNumOfNodes(); v++){
                if(matrix[node][v] != 0 && 
                   !graph.getIncludedInMST(v) && 
                   matrix[node][v] < graph.getEdges(v)){
                    graph.setEdges(matrix[node][v], v);
                    graph.setParents(node, v);
                }
            }
        }
        
        double cost = 0;
        for(int i = 0; i < graph.getNumOfNodes(); i++){
            if(i != startNode){
                cost += graph.getEdges(i);
            }
        }
        System.out.println(cost);
    }
}

代码本身可能有点混乱,所以让我们深入了解一下,并解释其中每一部分的作用。

首先,我们选择我们的startNode ,即0 。记住,我们需要一个节点来开始,这个节点可以是集合中的任何节点,但在这个例子中,它将是0 。我们将节点0 到自身的距离设定为0

for 循环中,对于从0n-1 的每一个i ,我们寻找一个节点s ,以便使边i-s 是来自i 的最小的边。在我们找到相应的节点后,由于Prim的算法是一种贪婪的算法,我们确信从节点i 到任何其他节点都没有更小的边,除了s ,所以我们将s 添加到MST中。

接下来是通过节点s 的所有邻居。让我们回顾一下在邻接矩阵中如何处理非初始化的权重。

在我们的邻接矩阵中,所有没有被分配权重的地方将自动被初始化为0

这一点很重要,因为在matrix[i][j] 这个位置上的任何(负数或正数)数字都表示节点ij 之间存在一条边,而0 表示没有。

因此,一条边(和一个节点)被添加到MST中需要满足的条件有以下三个。

  1. 我们检查matrix[i][j] 的值是否与0 不同,如果是,我们就知道该边存在,该值代表节点ij 之间的权重。
  2. 我们检查该邻居是否已经被添加到MST中。如果是,我们就跳过这个节点,继续寻找下一个邻居。
  3. 如果从节点i 到节点j 的边的值小于从不同节点到节点j 的已经存在的值,我们更新距离/父节点这一对来反映这种情况,即距离成为边i-j 的值,我们到达节点j 的父节点是节点i

这大概总结了Prim的算法是如何工作的。剩下要做的就是通过edges 数组,把组成MST的所有边加起来,找到它的值。这正是我们代码的最后部分所做的,并将结果存储在cost

让我们用MST的输出来总结一下这个算法。

System.out.println("MST consists of the following edges:");
    for(int i = 1; i < graph.getNumOfNodes(); i++){
      System.out.println("edge: (" + (int)graph.getParents(i) + ", " + i + "), weight: " + graph.getEdges(i));
}

让我们运行它,看看输出。

MST consists of the following edges:
edge: (0, 1), weight: 1.0
edge: (1, 2), weight: 2.0
edge: (2, 3), weight: 4.0
edge: (1, 4), weight: 1.0

结论

在本指南中,我们已经涵盖并解释了如何使用Prim算法在Java中找到一个 ***最小跨度树(MST)***在Java中。

Prim算法和Kruskal算法是解决这个问题最常用的两种算法之一,它在设计计算机网络、电信网络和一般的网络等领域都有应用。