学习Python中的最小生成树--Prim的算法

225 阅读15分钟

简介

图是建模对象之间关系的一个伟大工具。图可以对系统中任何数量的对象之间的复杂关系进行建模,这使它们成为代表物理学、经济学、化学等领域的概念/项目之间相互作用的理想工具。

由于不断提高的计算能力,你可以比以往更容易地操纵图。对数据结构和算法的研究使我们对图的使用有了更好的了解,这要归功于多年来伟大人物创造的算法。

这些算法之一是Prim算法。它最初是由Vojtech Jarnik在1930年设计的,之后Robert C. Prim在1957年重新设计了它。这个算法被用来寻找加权 无向图中的最小生成树

在本指南中,你将学习如何在Python中实现Prim的算法--我们将介绍该算法中使用的步骤,并通过一个功能实例来实现它们。

什么是(最小跨度)树?

树是图的一种类型,但并非所有的图都是树。树的主要特征是每对节点只由一条路径连接,所以与其他类型的图不同,图中不可能有循环--它们是无循环的。

此外,树是无定向的--节点之间没有严格的方向,你必须遵循。下面的插图将帮助你对树的实际情况有一个直观的了解。

trees in computer science

左边的图是一棵树,右边的不是,因为它有一个循环。虽然这可能看起来不太像你在公园里见过的树--但很多树实际上确实很像,从根节点开始有一个层次结构,有很多分支和叶子。不过,公平地说--计算机科学中的大多数树都是倒置的,根在顶部。

寻找**最小生成树(MST)在现实世界中是一种常见的现象。当你想找到某个区域的"最便宜 "的覆盖范围时,它通常会派上用场。例如,一家披萨连锁店可能想知道在哪里开餐厅,以覆盖最大的送货区域,在他们保证的时间内,用最少的开餐厅数量--为最多的人服务,用最小的投资,产生最大的回报。其结果是他们的餐厅的最小生成树

同样的类比可以转移到其他各种领域,包括送货网络、电信网络、电网、供水网络和几乎任何基础设施网络。

**注意:**在比萨饼店的比喻中,穿越一条街道所需的时间就是它的权重,所以目标是在城市中创建一棵树,连接所有的节点(送货区域/房屋),没有任何循环(这是低效的),并使所有的权重之和达到最低。在实践中,街道的 "权重 "很难确定,因为它是动态的,但可以用近似值来确保树在大多数情况下是正确的。

你可以为每一个为其边分配了权重的无向图计算一个最小生成树。这些树中的每一个都满足以下所有的条件。

  • 是一个子图(这意味着MST包含原图中的一些或全部关系,没有更多的关系)
  • 一棵树,这意味着它没有循环
  • MST权重(权重之和)是图中不同的潜在生成树可能的最小权重

一棵生成树(最大或最小)连接了一般图形的所有节点,但不一定是图形的所有边--有些是为了降低成本而避免的,即使不是,使用所有的边也可能使路径变成循环的,这就违背了树的目的。

什么是邻接矩阵?

在深入研究Prim算法的实现之前,你需要了解的另一个概念是用于表示图的数据结构。图中的关系可以用许多方式表示,但在实现Prim算法时,特别有趣的是邻接矩阵

这种结构显示哪些节点与哪些节点相连,也就是说,它说明了图的边缘结构。邻接矩阵是一个尺寸为n x n的方形矩阵,其中n是图中的节点数。一旦定义了矩阵结构,其字段就定义了哪些节点有与其他节点相连的路径。

下面是我们的例子树,重新表示为一个邻接矩阵。

我们给树上的每个节点都标上了一个字母。此外,我们还为相应的邻接矩阵的每一行和每一列标上了一个节点。因此,邻接矩阵的每个字段都对应着两个节点。例如,如果一棵树在节点AB 之间有一条边,那么邻接矩阵的字段[A][B] 就被标记为1 。另一方面,如果这些节点之间没有边,则字段[A][B] 被标记为0

注意:如果你正在处理一个有权重的图,当有关系时,你可以用边的权重而不是1填充字段

如果你再看一下这个例子的树,你会注意到它不是定向的,也就是说你可以从两个方向遍历每条边。当节点AB 之间有一条边时,毗连矩阵通过标记[A][B][B][A] 来说明这一点。

如何阅读邻接矩阵?
当一个邻接矩阵的一个字段[A][B] ,并填上数字10 ,你应该按以下方式阅读。一个图有一条连接节点AB 的边。该边的权重是10

这就是为什么树的邻接矩阵是一个对称的矩阵。你可以注意到一条明显的对角线(在上面的插图中标出)将矩阵分为两个镜像部分--称为上三角和下三角

有了这个总结,我们就可以进入Prim的算法了。

普利姆算法的直观性

1957年,Robert C. Prim设计了(或者说,重新设计了)一连串的步骤,利用路径权重找到图的最小生成树。该算法的步骤是这样的。

  1. 随机选择一个节点。
  2. 选择与所选节点相连的最小权重的路径。
  3. 该路径将把你带到一个新的节点,在那里定位。
  4. 一旦你形成/更新了初始树,选择与整棵树相连的权重最小的路径。请记住,你必须避免创造循环
  5. 重复步骤3和4,直到你覆盖所有顶点。

让我们看一下这个例子图,用Prim算法找到它的最小生成树。该图与关于在图中寻找MST的另外两种算法--Kruskal'sBorůvka's的指南中使用的图相同。

首先,你必须选择一个随机的节点来启动一个算法。在这个例子中,我们假设你选择从节点A

在你选择了起始节点后,看一下与之相连的所有边。在这种情况下,有两条这样的边--ABAC 。然后你选择权重较小的一条--AB ,并尝试将其添加到结果树中。如果它形成了一个循环,你就跳过它,选择一个权重第二小的边,等等。

在这种情况下,边AB ,不会形成任何循环,所以你可以把它加到结果树上。

现在,你有一棵由节点AB ,以及连接这两个节点的边组成的树。同样,你必须看一下到现在为止与你形成的树的节点相连的所有边。权重最小的一条是边AC 。由于它不产生任何循环,你可以把它加到树上。

之后,你再选择一条新的边来添加,如此反复。让我们来看看这种经过几次迭代后出现的有趣情况。

如果你看一下连接到结果树上的所有边,你会发现权重最小的一条边是EH 。但如果你试图把它加到结果树上,你会发现它形成了一个循环。因此,你必须跳过它,选择具有第二个最小权重的边--DG

再重复几次算法的步骤后,你将得到一个结果图的最小生成树。

这个过程可以通过一个动画来真正体会到。

如何在Python中实现Prim的算法

在这一节中,我们将用数字而不是字母来标记示例图的节点。这将有助于我们更容易地实现该算法。

你需要为Prim算法实现的第一件事是Graph 类。它是一个Python类,你将用它来表示一个图和所有相关的方法,帮助你操作图。在这篇文章中,我们将使用它的简单实现,并对其进行一些改动,使其与 Prim 的算法更加兼容。

class Graph:
    def __init__(self, num_of_nodes):
        self.m_num_of_nodes = num_of_nodes
        # Initialize the adjacency matrix with zeros
        self.m_graph = [[0 for column in range(num_of_nodes)] 
                    for row in range(num_of_nodes)]

    def add_edge(self, node1, node2, weight):
        self.m_graph[node1][node2] = weight
        self.m_graph[node2][node1] = weight

正如你所看到的,这个类有一个构造函数和一个用于向图添加边的方法。在这个例子中,我们对通用构造函数进行了调整,使其能够存储图形中的节点数和表示图形本身的邻接矩阵。当被调用时,构造函数将初始化邻接矩阵,所有元素设置为零。

**注意:**我们在__init__ 方法中使用列表理解法初始化了一个邻接矩阵。我们已经创建了一个充满其他列表的列表。每一行都是另一个列表,我们在那里存储了权重值。

add_edge() 方法向邻接矩阵添加一条边。由于Prim的算法是针对无向图的,所以add_edge() ,以确保生成的矩阵是对称的

注意:无向图的邻接矩阵的一个有用属性是它是对称的。这意味着上半部分(图的对角线以上)是下半部分的镜像。知道这一点意味着我们不需要填满整个矩阵,因为会有重复的值。

Prim的最小生成树算法

现在,你可以在Graph 类中定义prims_mst() 方法。你将用它来定义Prim算法的所有步骤,因此它将产生MST作为结果。

由于你要比较权重并为起始节点寻找最小的权重,你应该在第一个节点被分配到MST之前定义一个临时最小值的数字。这个数字高得离谱,例如无穷大,可以保证我们找到的第一个节点的权重比它小,而且它将被选中。这就是为什么我们要定义一个positive_inf ,以备不时之需--尽管在我们的例子中,数字被保证在10以下--将临时值设置为10 ,在技术上也是有效的。

我们必须跟踪选定的节点,以辨别哪些节点被包含在MST中。一旦所有的节点都是子图的一部分,你就可以停止搜索MST了!为了达到这个目的,我们要创建另一个带有布尔值的理解力列表--selected_nodes 。这个新的理解力列表中的每一列都代表一个节点。如果该节点被选为MST的一部分,该字段将是True ,否则就是False

result 将存储作为Prim算法结果的最小生成树。所有这些都集中在算法的主要部分--while(False in selected_nodes) 循环,在这个循环中,我们循环浏览所有尚未被选中的节点。

为此目的的MST并没有真正的开始或结束--尽管在算法上,有一个startend 节点会有帮助。start 将是第一个随机选择的节点,而end 将是我们添加到 MST 的最后一个节点。这些变量作为要连接的节点,我们要用它们来填入我们的MST矩阵。

def prims_mst(self):
    # Defining a really big number, that'll always be the highest weight in comparisons
    postitive_inf = float('inf')

    # This is a list showing which nodes are already selected 
    # so we don't pick the same node twice and we can actually know when stop looking
    selected_nodes = [False for node in range(self.m_num_of_nodes)]

    # Matrix of the resulting MST
    result = [[0 for column in range(self.m_num_of_nodes)] 
                for row in range(self.m_num_of_nodes)]
    
    indx = 0
    
    # While there are nodes that are not included in the MST, keep looking:
    while(False in selected_nodes):
        # We use the big number we created before as the possible minimum weight
        minimum = postitive_inf

        # The starting node
        start = 0

        # The ending node
        end = 0

        for i in range(self.m_num_of_nodes):
            # If the node is part of the MST, look its relationships
            if selected_nodes[i]:
                for j in range(self.m_num_of_nodes):
                    # If the analyzed node have a path to the ending node AND its not included in the MST (to avoid cycles)
                    if (not selected_nodes[j] and self.m_graph[i][j]>0):  
                        # If the weight path analized is less than the minimum of the MST
                        if self.m_graph[i][j] < minimum:
                            # Defines the new minimum weight, the starting vertex and the ending vertex
                            minimum = self.m_graph[i][j]
                            start, end = i, j
        
        # Since we added the ending vertex to the MST, it's already selected:
        selected_nodes[end] = True

        # Filling the MST Adjacency Matrix fields:
        result[start][end] = minimum
        
        if minimum == postitive_inf:
            result[start][end] = 0

        indx += 1
        
        result[end][start] = result[start][end]

    # Print the resulting MST
    # for node1, node2, weight in result:
    for i in range(len(result)):
        for j in range(0+i, len(result)):
            if result[i][j] != 0:
                print("%d - %d: %d" % (i, j, result[i][j]))

在这里,我们通过初始图的邻接矩阵,使用两个循环来移动。第一个循环是针对X轴(行),第二个循环是针对Y轴(列)。在进入第二个循环之前,你必须验证第一个循环给出的节点是否被选中,这确保它是MST图的一部分。我们用上面代码中的if selected_nodes[i]: 块来处理这个问题。

当你开始构建树时,没有一个节点最初被选中,它们都是False ,所以第一个循环会在我们进入第二个循环之前结束。由于这个原因,startend 最初被设置为0,当我们从循环中退出时,分配给end 位置的布尔值将变成True 。因此,result 的一个字段将被填充为现有的最小值,由于result 是对称的,我们可以对self.m_graph 使用同样的技巧来填充另一个字段。

现在我们有了一个选定的节点,也就是Prim算法的第1步,我们进入了第2步,在第二个循环内首先,你在每一列中移动,检查我们选定的节点和其他节点之间的关系。我们选定的节点将根据以下参数与其他n个节点进行比较。

  • i 给出的顶点必须有一条路径将其与顶点j 连接起来(这意味着在邻接矩阵的(i,j) 位置的权重必须大于零)。
  • 顶点j 必须没有被选中(如果它已经被选中,这可能导致一个循环)。

鉴于这两个条件,你可以将给定关系的边权重与MST的一般最小值进行比较。如果权重小于最小值,那么它将成为新的最小值,变量startend 将得到ij 的值。如果权重大于最小值,那么你就继续在剩余的列中搜索。

startend 将填充MST矩阵,创建我们正在寻找的树。之后,你重复描述的过程,直到你从初始图中选择所有节点。

在示例图上测试Prim的算法

为了测试前面描述的Prim算法的实现,让我们创建一个有9个节点的Graph 实例。

# Example graph has 9 nodes
graph = Graph(9)

然后,让我们重新创建我们先前在插图和动画中使用的示例图。

graph.add_edge(0, 1, 4)
graph.add_edge(0, 2, 7)
graph.add_edge(1, 2, 11)
graph.add_edge(1, 3, 9)
graph.add_edge(1, 5, 20)
graph.add_edge(2, 5, 1)
graph.add_edge(3, 6, 6)
graph.add_edge(3, 4, 2)
graph.add_edge(4, 6, 10)
graph.add_edge(4, 8, 15)
graph.add_edge(4, 7, 5)
graph.add_edge(4, 5, 1)
graph.add_edge(5, 7, 3)
graph.add_edge(6, 8, 5)
graph.add_edge(7, 8, 12)

最后,运行该算法。

graph.prims_mst()

这将输出以下结果。

0 - 1: 4
0 - 2: 7
2 - 5: 1
3 - 4: 2
3 - 6: 6
4 - 5: 1
5 - 7: 3
6 - 8: 5

就这样!这就是我们对该图的MST--与Prim的算法直觉一节中的相同。输出的每一行都代表所产生的MST的一条边,其形式为:node1 - node2: weight

记住,我们可以从一个随机的节点开始,我们不一定要从第一个节点开始。如果你想挑战自己,你可以修改代码,让它以一个随机数(当然是在正确的范围内)作为起始节点,观察算法以不同的顺序找到相同的树。

Prim算法的复杂性是什么?

如果你只用一个邻接矩阵来实现Prim的算法,其时间复杂度是O(N²) - 其中N 是图中的节点数。在这种情况下,简单的实现会产生很高的时间复杂度。

你可以通过选择更复杂的实现方式来提高时间复杂度,包括Fibonacci或与邻接矩阵一起的二进制堆。在这种情况下,时间复杂度可以是 O(E log N),这意味着Prim的算法可以和Kruskal和Borůvka的算法一样快地运行!

注: E 是初始图中的边数。

结论

在寻找图的最小生成树时,Prim的算法不仅高效而且灵活。Python的实现也非常简单。MST是一种有用的结构,可以应用于各种领域,这使得Prim的算法非常重要。