ThinkChat🤖让你学习和工作更高效,注册即送10W Token,即刻开启你的AI之旅 广告
# 二、图 > 原文:[Chapter 2 Graphs](http://greenteapress.com/complexity2/html/thinkcomplexity2003.html) > 译者:[飞龙](https://github.com/wizardforcel) > 协议:[CC BY-NC-SA 4.0](http://creativecommons.org/licenses/by-nc-sa/4.0/) > 自豪地采用[谷歌翻译](https://translate.google.cn/) 本书的前三章有关一些模型,它们描述了由组件和组件之间的连接组成的系统。例如,在生态食物网中,组件是物种,连接代表捕食者和猎物的关系。 在本章中,我介绍了 NetworkX,一个用于构建和研究这些模型的 Python 包。我们从 Erdős-Rényi 模型开始,它具有一些有趣的数学属性。在下一章中,我们将介绍更有用的,解释现实系统的模型。 本章的代码在本书仓库中的`chap02.ipynb`中。使用代码的更多信息请参见第(?)章。 ## 2.1 图是什么? ![](https://img.kancloud.cn/4b/57/4b57f8a6d223c77c3557bc9e9f6f7483_354x238.png) > 图 2.1:表示社交网络的有向图 对于大多数人来说,图是数据集的视觉表示,如条形图或股票价格对于时间的绘图。这不是本章的内容。 在本章中,图是一个系统的表示,它包含离散的互连元素。元素由节点表示,互连由边表示。 例如,你可以表示一个路线图,每个城市都是一个节点,每个城市之间的路线是一条边。或者你可以表示一个社交网络,每个人是节点,如果他们是朋友,两个人之间有边,否则没有。 在某些图中,边具有长度,成本或权重等属性。例如,在路线图中,边的长度可能代表两个城市之间的距离,或旅行时间。在社交网络中,可能会有不同的边来表示不同种类的关系:朋友,商业伙伴等。 边可以是有向或无向的,这取决于它们表示的关系是不对称的还是对称的。在路线图中,你可能会使用有向边表示单向街道,使用无向边表示双向街道。在某些社交网络,如 Facebook,好友是对称的:如果 A 是 B 的朋友,那么 B 也是 A 的朋友。但在 Twitter 上,“关注”关系并不对称;如果 A 关注了 B,这并不意味着 B 关注 A。因此,你可以使用无向边来表示 Facebook 网络,并将有向边用于 Twitter。 图具有有趣的数学属性,并且有一个称为图论的数学分支,用于研究它们。 图也很有用,因为有许多现实世界的问题可以使用图的算法来解决。例如,Dijkstra 的最短路径算法,是从图中找到某个节点到所有其他节点的最短路径的有效方式。路径是两个节点之间的,带有边的节点序列。 图的节点通常以圆形或方形绘制,边通常以直线绘制。例如,上面的有向图中,节点可能代表在 Twitter 上彼此“关注”的三个人。线的较厚部分表示边的方向。在这个例子中,爱丽丝和鲍勃相互关注,都关注查克,但查克没有关注任何人。 下面的无向图展示了美国东北部的四个城市;边上的标签表示驾驶时间,以小时为单位。在这个例子中,节点的位置大致对应于城市的地理位置,但是通常图的布局是任意的。 ## 2.2 NetworkX ![](https://img.kancloud.cn/e2/a0/e2a02732a4107df1236126b3a3302dca_354x238.png) > 图 2.2:表示城市和高速公路的无向图 为了表示图,我们将使用一个名为 NetworkX 的包,它是 Python 中最常用的网络库。你可以在 <https://networkx.github.io/> 上阅读更多信息,但是我们之后会解释。 我们可以通过导入 NetworkX 和实例化`nx.DiGraph`来创建有向图: ```py import networkx as nx G = nx.DiGraph() ``` 通常将 NetworkX 导入为`nx`。此时,`G`是一个`DiGraph`对象,不包含节点和边。我们可以使用`add_node`方法添加节点: ```py G.add_node('Alice') G.add_node('Bob') G.add_node('Chuck') ``` 现在我们可以使用`nodes`方法获取节点列表: ```py >>> G.nodes() ['Alice', 'Bob', 'Chuck'] ``` 添加边的方式几乎相同: ```py G.add_edge('Alice', 'Bob') G.add_edge('Alice', 'Chuck') G.add_edge('Bob', 'Alice') G.add_edge('Bob', 'Chuck') ``` 我们可以使用`edges`来获取边的列表: ```py >>> G.edges() [('Alice', 'Bob'), ('Alice', 'Chuck'), ('Bob', 'Alice'), ('Bob', 'Chuck')] ``` NetworkX 提供了几个绘图的功能;`draw_circular`将节点排列成一个圆,并使用边将它们连接: ```py nx.draw_circular(G, node_color=COLORS[0], node_size=2000, with_labels=True) ``` 这就是我用来生成图(?)的代码。`with_labels`选项标注了节点;在下一个例子中,我们将看到如何标注边。 为了产生图(?),我们以一个字典开始,它将每个城市的名称,映射为对应的经纬度: ```py pos = dict(Albany=(-74, 43), Boston=(-71, 42), NYC=(-74, 41), Philly=(-75, 40)) ``` 因为这是个无向图,我实例化了`nx.Graph`: ```py G = nx.Graph() ``` 之后我可以使用`add_nodes_from`来迭代`pos`的键,并将它们添加为节点。 ```py G.add_nodes_from(pos) ``` 下面我会创建一个字典,将每条边映射为对应的驾驶时间。 ```py drive_times = {('Albany', 'Boston'): 3, ('Albany', 'NYC'): 4, ('Boston', 'NYC'): 4, ('NYC', 'Philly'): 2} ``` 现在我可以使用`add_edges_from`,它迭代了`drive_times`的键,并将它们添加为边: ```py G.add_edges_from(drive_times) ``` 现在我不使用`draw_circular`,它将节点排列成一个圆圈,而是使用`draw`,它接受`pos`作为第二个参数: ```py nx.draw(G, pos, node_color=COLORS[1], node_shape='s', node_size=2500, with_labels=True) ``` `pos`是一个字典,将每个城市映射为其坐标;`draw`使用它来确定节点的位置。 要添加边的标签,我们使用`draw_networkx_edge_labels`: ```py nx.draw_networkx_edge_labels(G, pos, edge_labels=drive_times) ``` `drive_times`是一个字典,将每条边映射为它们之间的驾驶距离,每条边表示为城市名称的偶对。这就是我生成图(?)的方式。 在这两个例子中,这些节点是字符串,但是通常它们可以是任何可哈希的类型。 ## 2.3 随机图 随机图就像它的名字一样:一个随机生成的节点和边的图。当然,有许多随机过程可以生成图,所以有许多种类的随机图。 其中一个更有趣的是 Erdős-Rényi 模型,PaulErdős 和 AlfrédRényi 在 20 世纪 60 年代研究过它。 Erdős-Rényi 图(ER 图)的特征在于两个参数:`n`是节点的数量,`p`是任何两个节点之间存在边的概率。见 <http://en.wikipedia.org/wiki/Erdos-Renyi_model>。 Erdős 和 Rényi 研究了这些随机图的属性;其令人惊奇的结果之一就是,随着随机的边被添加,随机图的属性会突然变化。 展示这类转变的一个属性是连通性。如果每个节点到每个其他节点都存在路径,那么无向图是连通的。 在 ER 图中,当`p`较小时,图是连通图的概率非常低,而`p`较大时接近`1`。在这两种状态之间,在`p`的特定值处存在快速转变,表示为`p*`。 Erdős 和 Rényi 表明,这个临界值是`p* = lnn / n`,其中`n`是节点数。如果`p < p*`,随机图`G(n, p)`不太可能连通,并且如果`p > p*`,则很可能连通。 为了测试这个说法,我们将开发算法来生成随机图,并检查它们是否连通。 ## 2.4 生成图 ![](https://img.kancloud.cn/6c/88/6c88c2e8d09f196ae675f453397a553e_354x238.png) 我将首先生成一个完全图,这是一个图,其中每个节点都彼此连接。 这是一个生成器函数,它接收节点列表并枚举所有不同的偶对。如果你不熟悉生成器函数,你可能需要阅读附录?,然后回来。 ```py def all_pairs(nodes): for i, u in enumerate(nodes): for j, v in enumerate(nodes): if i>j: yield u, v ``` 你可以使用`all_pairs`来构造一个完全图。 ```py def make_complete_graph(n): G = nx.Graph() nodes = range(n) G.add_nodes_from(nodes) G.add_edges_from(all_pairs(nodes)) return G ``` `make_complete_graph`接受节点数`n`,并返回一个新的`Graph`,拥有`n`个节点,所有节点之间都有边。 以下代码生成了一个包含 10 个节点的完全图,并绘制出来。 ```py complete = make_complete_graph(10) nx.draw_circular(complete, node_color=COLORS[2], node_size=1000, with_labels=True) ``` 图(?)显示了结果。不久之后,我们将修改此代码来生成 ER 图,但首先我们将开发函数来检查图是否是连通的。 ## 2.5 连通图 如果每个节点到每个其他节点都存在路径,这个图就是连通图。请见<http://en.wikipedia.org/wiki/Connectivity_(graph_theory)>。 对于许多涉及图的应用,检查图是否连通是很有用的。幸运的是,有一个简单的算法。 你可以从任何节点起步,并检查是否可以到达所有其他节点。如果你可以到达一个节点`v`,你可以到达`v`的任何一个邻居,他们是`v`通过边连接的任何节点。 `Graph`类提供了一个称为`neighbors`的方法,返回给定节点的邻居列表。例如,在上一节中我们生成的完全图中: ```py >>> complete.neighbors(0) [1, 2, 3, 4, 5, 6, 7, 8, 9] ``` 假设我们从节点`s`起步。我们可以将`s`标记为“已访问”,然后我们可以标记它的邻居。然后我们标记邻居的邻居,依此类推,直到你无法再到达任何节点。如果访问了所有节点,则图是连通图。 以下是 Python 中的样子: ```py def reachable_nodes(G, start): seen = set() stack = [start] while stack: node = stack.pop() if node not in seen: seen.add(node) stack.extend(G.neighbors(node)) return seen ``` `reachable_nodes`接受`Graph`和起始节点`start`,并返回可以从`start`到达的节点集合,他们。 最初,已访问的集合是空的,我们创建一个名为`stack`的列表,跟踪我们发现但尚未处理的节点。最开始,栈包含单个节点`start`。 现在,每次在循环中,我们: + 从栈中删除一个节点。 + 如果节点已在`seen`中,我们返回到步骤 1。 + 否则,我们将节点添加到`seen`,并将其邻居添加到栈。 当栈为空时,我们无法再到达任何节点,所以我们终止了循环并返回。 例如,我们可以找到从节点`0`可到达的,完全图中的所有节点: ```py >>> reachable_nodes(complete, 0) {0, 1, 2, 3, 4, 5, 6, 7, 8, 9} ``` 最初,栈包含节点`0`,`seen`是空的。第一次循环中,节点`0`添加到了`seen`,所有其他节点添加到了栈中(因为它们都是节点`0`的邻居)。 下一次循环中,`pop`返回栈中的最后一个元素,即节点`9.`因此,节点`9`被添加到`seen`,并且其邻居被添加到栈。 请注意,同一个节点在栈中可能会出现多次;实际上,具有`k`个邻居的节点将添加到栈`k`次。稍后我们将寻找方法,来使此算法更有效率。 我们可以使用`reachable_nodes`来编写`is_connected`: ```py def is_connected(G): start = next(G.nodes_iter()) reachable = reachable_nodes(G, start) return len(reachable) == len(G) ``` `is_connected`通过调用`nodes_iter`来选择一个起始节点,`node_iter`返回一个迭代器对象,并将结果传递给`next`,返回第一个节点。 `reachable`获取了一组节点,它们可以从`start`到达。如果这个集合的大小与图的大小相同,那意味着我们可以访问所有节点,也就是这个图是连通的。 一个完全图是连通的,毫不奇怪: ```py >>> is_connected(complete) True ``` 下一节中,我们会生成 ER 图,并检查它们是否是连通的。 ## 2.6 生成 ER图 ![](https://img.kancloud.cn/2b/29/2b290304a3a4ac2c27925500b2af2dd0_354x238.png) > 图 2.4:ER 图,`n=10`,`p=0.3` ER 图`G(n, p) `包含`n`个节点,每对节点以概率为`p`的边连接。生成 ER 图类似于生成完全图。 以下生成器函数枚举所有可能的边,并使用辅助函数`flip`,来选择哪些应添加到图中: ```py def random_pairs(nodes, p): for i, u in enumerate(nodes): for j, v in enumerate(nodes): if i>j and flip(p): yield u, v ``` `flip`以给定概率`p`返回`True`,以互补的概率`1-p`返回`False`。 ```py from numpy.random import random def flip(p): return random() < p ``` 最后,`make_random_graph`生成并返回 ER 图`G(n, p)`。 ```py def make_random_graph(n, p): G = nx.Graph() nodes = range(n) G.add_nodes_from(nodes) G.add_edges_from(random_pairs(nodes, p)) return G ``` `make_random_graph`几乎和`make_complete_graph`,唯一的不同是它使用`random_pairs`而不是`all_pairs`。 这里是`p=0.3`的例子: ```py random_graph = make_random_graph(10, 0.3) ``` 图(?)展示了结果。这个图是连通图;事实上,大多数`p=10`并且`p=3`的 ER 图都是连通图。在下一节中,我们将看看有多少。 ## 2.7 连通性的概率 ![](https://img.kancloud.cn/36/fb/36fbe6d3939fbeeeeab549c33b90a7b7_354x238.png) > 图 2.5:连通性的概率,`n=10`,`p`是一个范围。竖直的线展示了预测的临界值。 ![](https://img.kancloud.cn/38/7a/387a609096ea0fe237b585f8e1043648_354x238.png) >图 2.6:连通性的概率,`n`是多个值,`p`是一个范围。 对于`n`和`p`的给定值,我们想知道`G(n, p)`连通的概率。我们可以通过生成大量随机图,来计算有多少个来估计它。就是这样: ```py def prob_connected(n, p, iters=100): count = 0 for i in range(iters): random_graph = make_random_graph(n, p) if is_connected(random_graph): count += 1 return count/iters ``` `iters`是我们生成的随机图的数量。随着我们增加`iter`,估计的概率就会更加准确。 ```py >>> prob_connected(10, 0.3, iters=10000) 0.6454 ``` 在具有这些参数的 10000 个 ER 图中,6498 个是连通的,因此我们估计其中65%是连通的。所以 0.3 接近临界值,在这里连通概率从接近 0 变为接近 1。根据 Erdős 和 Rényi,`p* = lnn / n = 0.23`。 我们可以通过估计一系列`p`值的连通概率,来更清楚地了解转变: ```py import numpy as np n = 10 ps = np.logspace(-2.5, 0, 11) ys = [prob_connected(n, p) for p in ps] ``` 这是我们看到的使用 NumPy 的第一个例子。按照惯例,我将 NumPy 导入为`np`。函数`logspace`返回从`10 ** -2.5`到`10 ** 0 = 1`的 11 个元素的数组,在对数刻度上等间隔。 为了计算`y`,我使用列表推导来迭代`ps`的元素,并计算出每个值为`p`的随机图的连通概率。 图(?)展示了结果,竖直的线为`p*`。从 0 到 1 的转变发生在预测的临界值附近。在对数刻度上,这个转变大致对称。 对于较大的`n`值,图(?)展示了类似的结果。随着`n`的增加,临界值越来越小,转变越来越突然。 这些实验与 Erdős 和 Rényi 在其论文中证明的结果一致。 ## 2.8 图的算法分析 这一章中,我提出了一个检查图是否连通的算法;在接下来的几章中,我们将再次看到更多的图的算法。并且我们要分析这些算法的性能,了解它们的运行时间如何随着图大小的增加而增长。 如果你还不熟悉算法分析,在你继续之前,你应该阅读附录一。 图算法的增长级别通常表示为顶点数量`n`,以及边数量`m`的函数。 作为一个例子,我们分析从前面的`reachable_nodes`: ```py def reachable_nodes(G, start): seen = set() stack = [start] while stack: node = stack.pop() if node not in seen: seen.add(node) stack.extend(G.neighbors(node)) return seen ``` 每次循环,我们从栈中弹出一个节点;默认情况下,`pop`删除并返回列表的最后一个元素,这是一个常数时间的操作。 接下来我们检查节点是否被已访问,这是一个集合,所以检查成员是常数时间。 如果节点还没有访问,我们添加它是常量时间,然后将邻居添加到栈中,这相对于邻居数量是线性的。 为了使用`n`和`m`表达运行时间,我们可以将每个节点添加到`seen`和`stack`的总次数加起来。 每个节点只添加一次,所以添加的总数为`n`。 但是节点可能多次被添加到栈,具体取决于它们有多少邻居。如果节点具有`k`个邻居,则它会被添加到栈`k`次。当然,如果它有`k`个邻居,那意味着它拥有`k`个边。 所以添加到栈的总数是边的数量`m`的两倍,由于我们考虑每个边两次。 因此,这个函数的增长级别为`O(n + m)`,我们可以说,即运行时间与`n`或`m`成正比,以较大者为准。 如果我们知道`n`和`m`之间的关系,我们可以简化这个表达式。例如,在完全图中,边数是`n(n-1)/ 2`,它属于`O(n^2)`。所以对于一个完全图,`reachable_nodes`是二次于`n`的。 ## 2.9 练习 本章的代码在`chap02.ipynb`中,它是本书的仓库中的 Jupyter 笔记本。使用此代码的更多信息,请参阅第(?)节。 练习 1:启动`chap02.ipynb`并运行代码。笔记本中嵌入了一些简单的练习,你可能想尝试一下。 练习 2:我们分析了`reachable_nodes`的性能,并将其分类为`O(n + m)`,其中`n`是节点数,`m`是边数。继续分析,`is_connected`的增长级别是什么? ```py def is_connected(G): start = next(G.nodes_iter()) reachable = reachable_nodes(G, start) return len(reachable) == len(G) ``` 练习 3 :在我实现`reachable_nodes`时,你可能很困惑,因为向栈中添加所有邻居而不检查它们是否已访问,明显是低效的。编写一个该函数的版本,在将邻居添加到栈之前检查它们。这个“优化”是否改变了增长级别?它是否使函数更快? > 译者注:在弹出节点时将其添加到`seen`,在遍历邻居时检查它们是否已访问。 练习 4: 实际上有两种 ER 图。我们在本章中生成的一种,`G(n,p)`的特征是两个参数,节点数量和节点之间的边的概率。 一种替代定义表示为`G(n,m)`,也以两个参数为特征:节点数`n`和边数`m`。在这个定义中,边数是固定的,但它们的位置是随机的。 使用这个替代定义,重复这一章的实验。这里是几个如何处理它的建议: 1. 编写一个名为`m_pairs`的函数,该函数接受节点列表和边数`m`,并返回随机选择的`m`个边。一个简单的方法是,生成所有可能的边的列表,并使用`random.sample`。 2. 编写一个名为`make_m_graph`的函数,接受`n`和`m`,并返回`n`个节点和`m`个边的随机图。 3. 创建一个`prob_connected`的版本,使用`make_m_graph`而不是`make_random_graph`。 4. 计算一系列`m`值的连通概率。 与第一类 ER 图的结果相比,该实验的结果如何?