如何让在Python中用代码中表示图形?

188 阅读17分钟

简介

Python 中的图可以用几种不同的方式表示。最显著的是邻接矩阵、邻接列表和边的列表。在本指南中,我们将介绍所有这些方式。在实现图的时候,你可以在这些类型的表示方法之间随意切换。

首先,我们将快速回顾图论,然后解释你可以用来表示图的数据结构,最后,给你一个每种表示方法的实际实现。

什么是图——简而言之

你可能已经对什么是图有了至少一个直观的了解。然而,我们将快速浏览一下关于图的基本定义,以使你更容易跟上。

图是一种数据结构,你可以用它来模拟对象之间的层次和关系。它由一组节点和一组边组成。节点代表单个对象,而边则说明这些对象之间的关系。

**注意:**术语 "节点 "和 "顶点"("节点 "和 "顶点")通常可以互换使用。在这个系列中,我们选择使用节点这个术语,但它与顶点这个术语的含义相同。

如果一个图中的每条边都说明了一个双向的连接,我们称该图为无定向图。另一方面,如果你可以在一个方向上遍历每条边,那么这个图就是有向的

图中的所有节点并不需要与其他节点相连。如果你可以从图中的任何其他节点访问每个节点--我们称之为连接图。但有时有一些节点你不能从图中的任何其他节点访问--这就是断开连接的图的含义。常见的误解是,每个图都必须是连接的,但实际情况并非如此--事实上,一个图可以不包含边,只包含节点。

从实现的角度来看,我们需要涉及的最后一件事是边缘的权重。它是分配给边的一个数值,描述了穿越该边的成本有多高。一个边的权重越小,穿越它的成本就越低。在此基础上,给边分配了权重的图被称为加权图

掌握了这些基本知识,你就可以深入研究实现图形的方法了!

表示图形的三种最常见的方法

在本节中,我们将介绍最常见的图的表示方法。我们将解释它们背后的直觉,并给你一些说明性的例子。之后,你可以用这些知识在 Python 中实现一个图。

一般来说,任何给定的图的节点都是用数字标记的(从0开始),以便于更简单的实现。这就是为什么我们将在下面的例子和实现中使用这种符号。

在下面的章节中,我们将使用下面的加权有向图作为例子。

**注意:**我们选择加权有向图作为例子,是因为它说明了大部分的实现上的细微差别。一般来说,在加权图和非加权图之间的切换是非常简单的。在有向图和无向图之间的切换也是一件非常容易的事情。在需要的时候,我们会在下面的章节中分别介绍这些主题。

边的列表

边的列表可能是表示的最简单的方法,但由于它缺乏适当的结构,所以它常常只是用于说明问题。我们将用它来解释一些图的算法,因为它几乎没有提供任何开销,并且允许我们把注意力集中在算法的实现上,而不是图本身的实现。

正如你已经知道的,每条边都连接着两个节点,并可能有一个权重分配给它。因此,每条边用一个列表表示,方式如下。[node1, node2, weight],其中weight 是一个可选的属性(如果你有一个未加权的图,则不需要)。顾名思义,边的列表将图形存储为一个以所述方式表示的边的列表。

让我们看看一个有向加权图和它的边的列表。

正如你所看到的,一个边的列表实际上是一个表。该表的每一行代表一条边--它的两个节点和该边的权重。由于这是一个加权图,边的表示中的节点顺序说明了边的方向。你只能从node1node2 遍历这条边。

另一方面,你有两种方法来处理无定向图第一种方法是为每个节点添加两行--每条边的方向都有一行。这种方法空间效率很低,但如果你同时使用有向图和无向图,你必须使用这种方法。只有当你确定只处理无向图时,才可以使用第二种方法。在这种情况下,你可以认为每条边都是无向的,并保持相同的表示方法--每条边有一行。

优点。

  • 简单易懂
  • 非常适用于说明性目的
  • 根据定义代表一个图(一组结点和一组边)。
    缺点。
  • 没有任何形式的结构化
  • 没有资格用于任何现实世界的应用
  • 效率不高
  • 不够灵活,不能互换地表示有向图和无向图

毗连矩阵

邻接矩阵是表示图的最流行的方法之一,因为它是最容易理解和实现的方法,对许多应用来说效果相当好。它使用一个nxn 矩阵来表示一个图(n 是一个图中的节点数)。换句话说,行和列的数量等于图中节点的数量。

最初,矩阵的每个字段都被设置为你选择的特殊值--inf,0,-1,False, 等等,这表明图中没有节点存在。在初始阶段之后,你可以通过1 (对于非加权图)或边缘权重(对于加权图)填充相应的字段来添加图中的每条边。

所提到的矩阵本质上是一个表格,每一行和每一列都代表图形的一个节点。例如,行3 指的是节点3 - 这就是为什么你可以用邻接矩阵只表示有数字标记的节点的图。

**注意:**事实上,你可以改变邻接矩阵的实现,使其能够处理不同命名的节点,但需要的额外工作通常会使潜在的收益消失。这就是为什么我们选择使用更简单的实现--只有数字标记的节点。

作为行i 和列j 的交集的字段指的是节点ij 之间的边的存在或权重。例如,如果你填写了行1 和列4 的交集,就表示有一条连接节点14 的边(按特定顺序)。如果一个图是加权的,你就在这个字段中填入边的权重,如果是无权图,就填入1

如果是无定向图,你必须为每条边添加两个条目--每个方向一个。

如果前面的解释还不够清楚,让我们试着简化一下,以下面的图为例,说明如何创建一个邻接矩阵。

这个图中有n 个节点。因此,我们创建了一个具有n 行和列的表格,并将所有单元格初始化为0 ,这个特殊的值表示任何两个节点之间没有边。由于该例图是加权和定向的,你需要。

  • 扫描图中的每一条边
  • 确定该边的起点和终点节点
  • 确定该边的权重
  • 将权重值填入矩阵的相应字段中

让我们以边3-4 为例。开始节点是3 ,结束节点是4 ,因此你知道你需要填写的字段是行3 和列4 的交叉点。从图像中,你可以读出边缘的权重是11 ,因此你要在适当的字段中填入11 。现在你已经标记了边的存在3-4 。这个过程会重复进行,直到你标记了图中的每一条边。

优点。

  • 查询时间短--你可以在一个小时内确定一条边是否存在。O(1)
  • 添加/删除边需要O(1) 时间
  • 易于实现和理解

缺点。

  • 占用更多空间O(num_of_nodes²)
  • 添加节点需要O(num_of_nodes²) 时间
  • 寻找所选节点的相邻节点的成本很高。O(num_of_nodes)
  • 遍历一个图的成本很高O(num_of_nodes²)
  • 标记/列举边的成本很高。O(num_of_nodes²)

毗连列表

邻接列表是存储图形的最有效方式。它允许你只存储图中存在的边,这与邻接矩阵相反,后者明确地存储所有可能的边--包括存在的和不存在的。邻接矩阵最初是为了表示非加权图而开发的,但以最有效的方式--只使用一个数组。

正如你在下面的插图中所看到的,我们可以只用一个12个整数值的数组来表示我们的例子图。与邻接矩阵相比--它由 元素组成(n 是图形中节点的数量),而邻接列表只需要n+e 元素(e 是边的数量)。如果一个图不是很密集(有少量的边),那么空间效率会高很多。

问题是,与邻接矩阵相比,邻接列表更难理解,所以如果你以前没有解释过它们,请仔细跟读。

构建邻接列表首先需要知道的是图中节点的数量。在我们的示例图中,有5个节点,所以列表中的前5个位置代表这些节点--例如,索引为1 的元素代表一个节点1 。在保留了列表中的前5个位置后,你可以开始填充列表了。索引i 上的值指的是列表中的索引,在这里你可以找到节点i 的相邻节点的索引。

例如,索引0 上的值是5 ,这意味着你应该查看邻接列表中的索引5 ,以找到哪些节点与节点0 相连 - 这些是节点01 ,和2 。但是我们怎么知道什么时候停止寻找相邻的节点呢?这很简单!看看列表中0 旁边的索引上的值。下一个索引是1 ,它代表节点1 ,它的值是8 ,这意味着你可以从毗邻列表中的索引8 开始找到与节点1 相邻的节点。因此,你可以找到与节点0 相邻的节点,作为索引58 之间的列表的值。

为了更容易理解这个结构,你可以以更有条理的方式重新排列邻接列表的元素。如果你这样做,你可以看到所产生的结构看起来很像一个链表。

此外,链表的结构与字典(或地图)非常相似。它有一组--节点,以及每个键的一组--邻近键节点的一组节点。如果你想表示一个加权图,你必须找到一种方法来存储相邻节点以外的权重(正如你在下面的插图中看到的)。但我们会在后面的章节中介绍实现细节。

下面的插图向你展示了示例图的邻接列表--包括有权重和无权重。

当你看一下加权的相邻列表时,你可以很容易地构建示例图的边的集合。节点0 有三个相邻的节点 -0,1,2, 这意味着该图有边0-0,0-1, 和0-2 。这些边的权重也可以从邻接列表中读取。边0-0 的权重是25 ,边0-1 的权重是5 ,以此类推,图中的每条边都是如此。

优点。

  • 找到所选节点的相邻节点的成本很低。O(1)
  • 对密度较低的图来说很有效(与节点数相比,边的数量较少)。
  • 你可以对字母和数字标记的节点都使用它
  • 遍历图形的成本低 - 遍历图形的成本低O(length_of_list)
  • 标注/枚举边的成本低----。O(length_of_list)

缺点。

  • 查阅时间长O(length_of_list)
  • 移除边缘的成本高 -O(length_of_list) (高查找时间的逻辑延伸)

注: length_of_list ,等于图中节点数和边数

如何在Python中实现一个图

现在你知道了如何用最常见的数据结构来表示一个图了!接下来要做的事情是在Python中实现这些数据结构。本指南的目标是给你一个尽可能普遍的图的实现,但仍要使其轻巧。这样才能让你专注于实现图的算法,而不是把图作为一种数据结构。理想情况下,你会有一个代表图数据结构的封装类,你可以用它来封装你以后要实现的任何图算法方法。

注意:我们将在本指南中创建的简单实现应该涵盖你所有非高度特殊的用例。例如,我们将假设所有的节点都是由从零开始的数字标记的。但是,如果你需要更全面的实现,我们也能满足你的要求你可以在下面的GitHub repo中找到完整的实现。

Graph 类将存储图形表示,以及所有其他你可能需要的操作图形的方法。让我们看一下它的总体结构,然后再深入到实现中。

class Graph:
    # Constructor
        # Number of edges
        # Adjacancy matrix, adjacency list, list of edges

    # Methods for adding edges
    
    # Methods for removing edges

    # Methods for searching a graph
        # BFS, DFS, Dijkstra, A*...

    # Methods for finding a minimum spanning tree
        # Prim's algorithm, Kruskal's algorithm, Borůvka's algorithm...
    
    # Other interesting methods

正如你所看到的,在一个Graph 类中,有几个,比如说,你应该实现的 "部分"。最重要的部分,也是我们在本节中要重点讨论的部分是构造函数部分。这是你应该把你的图实现(数据结构表示)放在那里。之后,你可以在这个类中实现任何与图有关的算法,作为一个方法。或者,你也可以将任何图的遍历/图的搜索算法作为一个独立的方法来实现,并将图本身传入。从字面上看,区别仅仅在于该算法是引用self (父图)还是传入graph

让我们来看看如何在Graph 类中实现上述每种图的表示。在这一节中,我们将使用之前显示的例子图来测试每个实现。

如何在 Python 中实现边的列表

正如我们之前所说,边的列表在现实世界中的应用并不多,但它经常被用于说明问题。当你需要一个简单的图的实现时,你可以使用它,它不会不必要地使算法的实现复杂化。

让我们看一下Graph 类的实现,它使用一个边的列表来表示一个图。

class Graph:
    # Constructor
    def __init__(self, num_of_nodes, directed=True):
        self.m_num_of_nodes = num_of_nodes
		self.m_directed = directed
        
        # Different representations of a graph
        self.m_list_of_edges = []
	
    # Add edge to a graph
    def add_edge(self, node1, node2, weight=1):        
        # Add the edge from node1 to node2
        self.m_list_of_edges.append([node1, node2, weight])
        
        # If a graph is undirected, add the same edge,
        # but also in the opposite direction
        if not self.m_directed:
            self.m_list_of_edges.append([node1, node2, weight])

	# Print a graph representation
    def print_edge_list(self):
        num_of_edges = len(self.m_list_of_edges)
        for i in range(num_of_edges):
            print("edge ", i+1, ": ", self.m_list_of_edges[i])

正如你所看到的,这个实现是非常简单的。一个图被表示为一个边的列表,其中每条边由一个列表表示,其方式如下:[node1, node2, weight] 。因此,图实际上是一个矩阵,其中每一行代表一条边。

让我们来创建我们的示例图,看看边的列表是如何存储的。

graph = Graph(5)

graph.add_edge(0, 0, 25)
graph.add_edge(0, 1, 5)
graph.add_edge(0, 2, 3)
graph.add_edge(1, 3, 1)
graph.add_edge(1, 4, 15)
graph.add_edge(4, 2, 7)
graph.add_edge(4, 3, 11)

graph.print_edge_list()

首先,这个例子图有5个节点,因此你用构造函数创建一个有5个节点的图。然后你把所有的边添加到创建的图中,并打印该图。这将输出以下内容。

edge  1 :  [0, 0, 25]
edge  2 :  [0, 1, 5]
edge  3 :  [0, 2, 3]
edge  4 :  [1, 3, 1]
edge  5 :  [1, 4, 15]
edge  6 :  [4, 2, 7]
edge  7 :  [4, 3, 11]

正如你所看到的,这个输出与我们在前面几节中展示的边的例子列表是一致的。

**注意:**如果你想让这个图变成无定向的,你应该以下面的方式进行构造器:graph = Graph(5, directed=False)

如何在Python中实现一个邻接矩阵

一个邻接矩阵本质上是一个简单的nxn 矩阵,其中n 是一个图中的节点数。因此,我们将把它实现为具有num_of_nodes 行和列的矩阵。我们将使用一个列表理解来构造它,并将所有字段初始化为0

在这种情况下,0 是一个特殊的值,指的是图形中最初没有边的事实。添加边是非常简单的,我们只需要在矩阵的相应字段上标记1weight ,这取决于一个图是否是加权的。

class Graph:
    def __init__(self, num_of_nodes, directed=True):
        self.m_num_of_nodes = num_of_nodes
        self.m_directed = directed

        # Initialize the adjacency matrix
        # Create a matrix with `num_of_nodes` rows and columns
        self.m_adj_matrix = [[0 for column in range(num_of_nodes)] 
                            for row in range(num_of_nodes)]

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

        if not self.m_directed:
            self.m_adj_matrix[node2][node1] = weight

    def print_adj_matrix(self):
        for i in range(self.m_num_of_nodes):
            print(self.m_adj_matrix[i])

现在让我们用前面描述的方式测试一下这个实现。

graph = Graph(5)

graph.add_edge(0, 0, 25)
graph.add_edge(0, 1, 5)
graph.add_edge(0, 2, 3)
graph.add_edge(1, 3, 1)
graph.add_edge(1, 4, 15)
graph.add_edge(4, 2, 7)
graph.add_edge(4, 3, 11)

graph.print_adj_matrix()

运行上面的代码将产生以下输出。

[25, 5, 3, 0, 0]
[0, 0, 0, 1, 15]
[0, 0, 0, 0, 0]
[0, 0, 0, 0, 0]
[0, 0, 7, 11, 0]

正如预期的那样,输出结果与我们在前面几节中展示的矩阵相同。

**注意:**如果你想让这个图变成无定向的,你应该以下面的方式进行构造函数:graph = Graph(5, directed=False) 。在这种情况下,邻接矩阵将是对称的。

如何在Python中实现一个邻接列表

正如我们在前面几节中所解释的,在 Python 中表示邻接列表的最好方法是使用一个字典--它有一组和相应的

我们将为每个节点创建一个键,并为每个键创建一组相邻的节点。这样,我们将为图中的每个节点有效地创建一组相邻节点。从本质上讲,一个相邻节点代表了关键节点和相邻节点之间的边,因此我们需要给每条边分配一个权重。这就是为什么我们要把每个相邻节点表示为一个元组--一对相邻节点的名称和该边缘的权重。

class Graph:
    def __init__(self, num_of_nodes, directed=True):
        self.m_num_of_nodes = num_of_nodes
        self.m_nodes = range(self.m_num_of_nodes)

        # Define the type of a graph
        self.m_directed = directed

        self.m_adj_list = {node: set() for node in self.m_nodes}      

    def add_edge(self, node1, node2, weight=1):
        self.m_adj_list[node1].add((node2, weight))
        
        if not self.m_directed:
        	self.m_adj_list[node2].add((node1, weight))

    def print_adj_list(self):
        for key in self.m_adj_list.keys():
            print("node", key, ": ", self.m_adj_list[key])

再一次,让我们以之前描述的方式测试一下实现。

graph = Graph(5)

graph.add_edge(0, 0, 25)
graph.add_edge(0, 1, 5)
graph.add_edge(0, 2, 3)
graph.add_edge(1, 3, 1)
graph.add_edge(1, 4, 15)
graph.add_edge(4, 2, 7)
graph.add_edge(4, 3, 11)

graph.print_adj_list()

输出结果与前面几节中描述的链接列表完全相同。

node 0 :  {(2, 3), (0, 25), (1, 5)}
node 1 :  {(3, 1), (4, 15)}
node 2 :  set()
node 3 :  set()
node 4 :  {(2, 7), (3, 11)}

**注意:**如果你想让这个图变成无定向的,你应该以如下方式使用构造函数:graph = Graph(5, directed=False)

结语

读完本指南后,你可能会问自己--“我最终应该使用什么样的图表示法?”但是答案,像往常一样,并不是一个简单的答案--它取决于

每种图形表示法都有它的优点和缺点--它在一个用例中大放异彩,但在其他用例中却性能低下。这就是为什么我们给你一个关于图表示法的全面概述。读完本指南后,你应该对图形表示法有一个直观的了解,知道如何实现它们中的每一个,以及何时根据方便的利弊清单使用每一个。因此,如果你想深入研究实现与图有关的算法,本指南应该是一个很好的起点。