几乎本书中的每个算法都需要与节点的邻居进行交互。邻居的概念直观上很容易理解;在无向图中,某个节点的邻居就是与它通过边相连的那些节点。在有向图中,邻居的术语稍微复杂一些,因为邻居类型取决于边是指向节点(入边)还是从节点指出(出边)。
确定某个节点的邻居集合是大多数图算法的基础步骤,比如在新图中搜索路径,以及许多现实任务中也如此。举例来说,在规划通过交通网络的旅行时,我们可能会问从当前城市可以直接到达哪些城市。
本章介绍邻居的正式定义,并展示一些我们将在全书中使用的基本函数。同时介绍了两个基于邻居的示例指标:节点的度(degree)和聚类系数(clustering coefficient)。这些指标帮助我们了解节点邻域的性质,从而分析图的特征。节点的度告诉我们它有多少连接,而聚类系数反映其邻居之间的互联程度。
无向图中的邻居
许多指标和算法都需要确定给定节点 v 的邻近节点集合。在无向图中,节点 v 的邻居是所有与 v 通过边连接的节点。图 2-1 展示了一个示例图,并列出了每个节点的邻居。节点 0 有三个邻居(1、3 和 4),而节点 3 只有一个邻居(0)。
我们可以给 Node 类添加一个简短的辅助函数,用于计算无向图中某节点的邻居集合,如代码清单 2-1 所示。
def get_neighbors(self) -> set:
neighbors: set = set()
for edge in self.edges.values():
neighbors.add(edge.to_node)
return neighbors
代码清单 2-1:确定无向图中邻居节点集合
清单 2-1 中的 get_neighbors() 函数首先创建一个空的集合数据结构,然后遍历该节点的所有边,并将对应的邻居节点加入集合中。
假设有一个社交网络的图表示,每个人是一个节点,节点 v 和节点 u 之间存在无向边表示这两人是朋友。我们可能会用节点的邻居列表来组成派对的嘉宾名单,或者模拟谣言在网络中的传播。
作为另一个应用,考虑经典的问题:“谁和那位明星一起出演过电影?”我们将在本章后面更详细地讨论这个问题。我们可以构建一个共现图,表示哪些演员曾在同一部电影中合作。每个节点代表一个人,一条边表示两人曾一起出演电影。由于这种关系总是对称的,我们用无向图来模拟这些共现关系。
有向图中的邻居
在有向图中,邻居的定义更复杂:可以是节点 v 的出边所指向的节点,入边的起始节点,或者两侧的节点。为了解决这种模糊性,我们将邻居分为两类:
- 入邻居(in-neighbors) :所有以 v 为终点的边所连接的节点;换句话说,从节点 v 的角度看,是入边的起点。在不对称的有向社交网络中,v 的入邻居就是那些会告诉他最新八卦的朋友。
- 出邻居(out-neighbors) :所有从 v 指向其他节点的边所连接的节点,代表 v 会把八卦告诉的朋友。
我们给 Node 类添加的计算有向图中出邻居的代码,与无向图中 get_neighbors() 函数的代码几乎相同,唯一的区别是函数名,如代码清单 2-2 所示。
def get_out_neighbors(self) -> set:
neighbors: set = set()
for edge in self.edges.values():
neighbors.add(edge.to_node)
return neighbors
代码清单 2-2:确定有向图中的出邻居集合
get_out_neighbors() 函数遍历所有出边,将目标节点收集到一个集合中返回。社交网络中,这相当于列出某人发送消息的所有对象。
计算入邻居集合的代码则不同,因为我们没有维护指向某节点的入边列表,所以必须遍历图中的所有节点,如代码清单 2-3 所示。该函数属于 Graph 类,因为它需要访问所有节点。
def get_in_neighbors(self, target: int) -> set:
neighbors: set = set()
for node in self.nodes:
❶ if target in node.edges:
neighbors.add(node.index)
return neighbors
代码清单 2-3:确定入邻居集合
和其他邻居算法类似,代码从一个空集合开始构建邻居集合。函数遍历每个节点,并检查目标节点是否在该节点的出边字典中存在(❶)。如果存在,说明该节点是目标节点的入邻居,将其加入集合。
如果我们在无向图上运行清单 2-3 的代码会发生什么?函数不会出错,且结果是正确的邻居集合。因为无向图的边是对称的,get_in_neighbors() 所考虑的邻居节点和清单 2-1 中的代码相同,只是边的方向反过来。
然而,由于 get_in_neighbors() 需要遍历图中所有节点,而不是仅遍历与目标节点相连的节点,它在无向图上的效率显著低于 get_neighbors()。
自环(Self-Loops)
在定义邻居时,还有一个额外的复杂情况是自环,即一条边将节点自身连接到自己。例如,在图 2-2 中,节点 1 有一条指向它自身的边。
自环就像是绕着圆形道路回到起点的路。更具体地说,我们可以把它想象成我和不同人对话时的情景。如果用带权图来表示我和各个人的对话次数,那么权重最大的边就是一个自环,表示我在思考问题时对自己嘟囔的次数。
在邻接表表示法中,自环由一条边表示,其起点和终点是同一个节点。在邻接矩阵表示法中,节点 v 的自环则表示为矩阵主对角线上(行 = v,列 = v)非零的值。
如果节点 v 有自环,那么我们也将它视为自己的邻居。对于有向图来说,这意味着节点 v 同时是它自己的入邻居和出邻居,因为边的起点和终点都是节点 v。
在本书中,我们遵循计算机科学中的常见约定,只允许有向图存在自环。虽然很多算法可以处理带有自环的无向图,且其余算法也可以轻松调整以支持自环,但在无向图所建模的问题上下文中,自环通常没有意义。比如,第16章讨论的图着色问题要求给任意两条边连接的两个节点着不同颜色,在这种问题中自环是没有意义的。
度(Degree)
了解一个节点连接性的一个有用统计量是它的度,即连接到该节点的边的数量。图 2-3 显示了一个示例无向图,每个节点旁都标注了其度。节点 0 的度为 3,而节点 5 的度为 2。
在社交网络中,一个节点的度数表示这个人有多少朋友。我们可以将其作为该人受欢迎程度或人际关系广泛程度的一个粗略指标。
从数学角度来看,无向图中形成自环的边在计算度数时会被计为两次,因为自环的两端都连接着该节点。虽然本书中算法不会使用无向图的自环,但为了完整性,我们将在第18章计算度数时包含这一检查。
在有向图中,度的概念被分为两个独立的度量,就像邻居的分类一样。节点的出度表示从该节点发出的连接数量,而入度表示从其他节点指向该节点的边的数量。在社交网络中,你的入度和出度分别代表你向多少人分享消息和有多少人向你分享消息。一个好的知己是入度高而出度低的朋友。一个好的八卦来源则是入度高(能收集信息)且出度也高(愿意传播信息)。
在有向图中,形成自环的边的起点和终点相同,这样的边在计算度数时,既计入一次入度,也计入一次出度。例如,图2-4展示了一个有向图及每个节点的度数。图的左侧显示每个节点的出度,右侧显示它们的入度。
计算一个节点的入度或出度需要统计该节点的入边或出边数量。为此,我们可以改编上一节中邻居计算的代码,用计数器替代集合的构建。
聚类系数
节点的聚类系数(有时称为局部聚类系数)是一个度量,用来描述该节点的邻居彼此之间的互联程度。在社交网络的语境下,聚类系数实际上在问:“我的朋友们之间有多少互为朋友?”聚类系数为零时,意味着我们的朋友之间没有任何联系,聚会会非常尴尬;而聚类系数为一时,表示我们的每个朋友都彼此认识,关系紧密。
形式上,无向图中节点 的聚类系数是 的邻居之间实际存在的边数占所有可能边数的比例。我们先找出所有邻居(即所有与 有边连接的节点),统计这些邻居之间存在多少条边,再除以这些邻居间可能存在的最大边数。如果节点 有 个邻居,那么邻居间最多可能有 条边。
如果节点只有一个或没有邻居,需要特殊处理,因为它们的邻居之间不存在可能的连接。如果某人没有朋友,计算朋友之间互相喜欢的百分比毫无意义。为简便起见,我们在这种情况下返回聚类系数为 0,表示局部连接缺失。
我们可以定义一个函数,计算无向图中给定节点索引为 ind 的节点的聚类系数,如清单 2-4 所示:
def clustering_coefficient(g: Graph, ind: int) -> float:
❶ neighbors: set = g.nodes[ind].get_neighbors()
num_neighbors: int = len(neighbors)
count: int = 0
for n1 in neighbors:
for edge in g.nodes[n1].get_edge_list():
❷ if edge.to_node > n1 and edge.to_node in neighbors:
count += 1
total_possible = (num_neighbors * (num_neighbors - 1)) / 2.0
❸ if total_possible == 0.0:
return 0.0
return count / total_possible
清单 2-4 中的 clustering_coefficient() 函数,首先使用清单 2-1 中的 get_neighbors() 函数 ❶ 来生成节点所有邻居的集合。然后用两层嵌套的循环来检查邻居间的所有唯一节点对。外层循环遍历节点的邻居,内层循环遍历邻居的边。
对于每条包含邻居节点的边,代码检查该边另一端的节点编号是否大于当前邻居的编号且该节点也属于原节点的邻居 ❷。第一个检查用于避免重复计数邻居,因为无向边在邻接列表中出现两次,但只能算一次。每条边 只在 时被计数。如果边通过了检查,则计数加一。
clustering_coefficient() 函数最后返回邻居间实际观察到的边数与可能边数的比例,并在节点邻居数为零或一时避免除零错误 ❸。
图 2-5 展示了一个示例图,并列出了每个节点的聚类系数。
节点0有三个邻居(1、3和4),这三个邻居之间最多可以形成三条边,但实际上只有其中一对邻居(1和4)是相连的,因此节点0的聚类系数为1/3。相比之下,节点5有两个邻居且它们之间有一条边,所以其聚类系数为1。节点3只有一个邻居,因此其聚类系数被赋值为0。
计算平均聚类系数
聚类系数仅反映了图中某个单一节点周围的特征。我们可以通过计算图中所有节点的局部聚类系数的平均值,来获得该无向图局部互联程度的数值衡量。
计算方法是对每个节点计算聚类系数,然后取平均值,代码如下:
def ave_clustering_coefficient(g: Graph) -> float:
total: float = 0.0
for n in range(g.num_nodes):
total += clustering_coefficient(g, n)
if g.num_nodes == 0:
return 0.0
return total / g.num_nodes
ave_clustering_coefficient() 函数遍历每个节点,调用 clustering_coefficient() 函数计算该节点的聚类系数,并累加到总和中。只要图中至少有一个节点,函数就返回总和除以节点数的结果。比如,图2-5中该值大约是0.5278。
局限性说明
聚类系数只反映了某个节点邻居之间的连接情况,而无法告诉我们这些邻居节点整体的连通性。例如,考虑图2-6中的图,节点0的聚类系数为1,表示它的所有邻居之间都互相连接,但这并不能反映更远一步的网络结构,更不能说明所有邻居的连接关系。
图2-6中,节点1有许多额外的连接,但聚类系数并未考虑这些连接,因为它们并不直接与节点0的邻居相连。这些额外连接用灰色表示,而直接邻居用黑色表示。在这种情况下,节点1属于两个不同的互联节点集合,分别是{0, 1, 2}和{1, 3, 4, 5}。
在我们的社交网络示例中,这意味着聚类系数无法告诉我们朋友的朋友的情况。我们的朋友可能彼此相处融洽,但他们也可能属于其他社交圈。实际上,局部聚类系数可以告诉我们被邀请参加聚会的人是否彼此和睦,但无法告诉我们他们是否更愿意参加另一个更大的聚会。例如,如果图2-6中的节点0和节点4都举办派对,节点1会乐于参加其中任意一个,但会在节点4的聚会上认识更多朋友。
生成邻域子图
我们可以扩展“邻居”的概念,在无向图中确定一个邻域子图,该子图包括邻居节点及它们之间的边。根据是否包含原始节点,我们定义无向图中的两种邻域:
- 开邻域子图(open-neighborhood subgraph):由节点v的邻居以及它们之间的边组成。
- 闭邻域子图(closed-neighborhood subgraph):由节点v本身及其所有邻居以及它们之间的边组成。
代码实现
我们可以在Graph类中创建一个函数,用于生成无向图中某节点(索引为ind)的开邻域或闭邻域子图。该函数通过确定邻居节点来构造新图,并添加对应的边:
def make_undirected_neighborhood_subgraph(self, ind: int, closed: bool):
❶ if not self.undirected:
raise ValueError
❷ nodes_to_use: set = self.nodes[ind].get_neighbors()
if closed:
nodes_to_use.add(ind)
index_map = {}
❸ for new_index, old_index in enumerate(nodes_to_use):
index_map[old_index] = new_index
g_new: Graph = Graph(len(nodes_to_use), undirected=True)
for n in nodes_to_use:
for edge in self.nodes[n].get_edge_list():
❹ if edge.to_node in nodes_to_use and edge.to_node > n:
ind1_new = index_map[n]
ind2_new = index_map[edge.to_node]
g_new.insert_edge(ind1_new, ind2_new, edge.weight)
return g_new
make_undirected_neighborhood_subgraph() 函数首先检查图是否为无向图,如果不是,则抛出ValueError异常 ❶。虽然这一步不是绝对必须的,代码对有向图也会返回结果,但此举确保函数按照预期使用。接下来,代码用前面2-1节中get_neighbors()函数获取目标节点的邻居集合 ❷,这个集合nodes_to_use包含了子图中将使用的所有节点。如果是闭邻域子图,则将目标节点本身也加入集合。
由于Graph类中节点的索引是连续的数字(范围是[0, |V|-1],|V|为节点总数),生成的子图可能会给节点重新分配不同的索引。为此,代码构建了一个字典index_map,将旧索引映射到新索引 ❸,确保新图中节点索引连续无间隙。稍后在图2-8中,我们还会看到可以使用标签等其他信息来保持节点身份。
最后,代码创建一个新的图g_new,通过两层嵌套循环添加节点和边。第一层循环遍历nodes_to_use中的每个节点,第二层循环遍历该节点的边。通过判断边的目标节点索引是否大于当前节点索引n,确保每条无向边只被添加一次 ❹。只有当两个节点都在nodes_to_use中,且目标节点尚未处理时,才插入新边。Graph类的insert_edge()函数会正确地使用新索引插入无向边。
示例
考虑用图2-7中的图构建邻域子图时的情形。回到之前的影视明星网络示例,这个图代表了7位出演世界著名《图论》动作惊悚系列的明星(Alice、Bob、Carl、Dan、Edward、Fiona和Gwen):包括《图论》(主演Alice和Bob)、《图论2:新节点》(主演Bob和Carl)、《图论3:失落的边》(主演Bob、Fiona和Gwen)等等。每个节点用明星名字的首字母标识,边表示两位明星曾共同出演过同一电影。
为了更好地了解Bob及其合作明星的出演情况,我们围绕Bob(节点1)创建了一个闭邻域子图。这个子图表示Bob曾一起出演过电影的明星们,并捕捉他们之间的互动关系。图2-8展示了构建该子图的过程。左侧列显示完整图,其中当前正在处理的节点用虚线圆圈标出;右侧列则显示该时刻生成的新子图。如前所述,子图中的节点使用了不同的索引;在这个例子中,我们可能会将明星的名字存储在节点的标签中。
图 2-8(a) 从创建一个只包含 Bob 和他的合作明星的新图开始。邻居集合包括所有与 Bob 一起出现在银幕上的明星。Alice 是因为他们一起出演了最初的《Graph Theory》电影而被包含进来,而与明星 Fiona 和 Gwen 的连接则来自该系列中第三部、评价最高的续集。
新图中的索引也发生了变化。如图所示,三个人保留了原来的索引(节点 0、1 和 2),而另外两个人被分配了新的索引(5 和 6)。在子图中,Fiona 的节点索引从 5 变成了 3,Gwen 的索引从 6 变成了 4。在附录 A 中,我们将讨论如何扩展 Graph 结构以使用基于字符串的标签,从而避免这种索引重映射的需求。
设置好新图后,我们依次遍历考虑中的人物(Bob 及其合作明星),并向子图中添加新的边。考虑图 2-8(b) 中的节点 0 时,我们只添加了他们的两条边中的一条,即 (0, 1),因为 Alice 和 Bob 都在考虑范围内。相比之下,Edward(节点 4)只在糟糕的衍生作品《The Golden Vertex》中与 Alice 合作过。由于 Edward 从未与 Bob 同台出现,他不属于 Bob 的邻域子图。
当轮到代表该系列的核心人物 Bob 时,我们在图 2-8(c) 中向三个新的合作明星添加了边。我们没有再向 Alice 添加边,因为她和她的边已经处理过了。代码随后依次处理了 Carl(图 2-8(d))、Fiona(图 2-8(e))和 Gwen(图 2-8(f))。由于 Edward 和 Dan 没有与 Bob 合作过,他们不在邻居列表中,也没有被考虑。最终的子图展示在图 2-8(f) 的右侧。
为什么这很重要
一个图的邻居提供了关于某个节点周围局部结构和相互连接的基础信息。大多数情况下,这些术语的正式定义都很直观。在遍历图时,我们会询问当前节点的邻居节点有哪些,从而知道可以访问哪些节点。邻居的概念将成为后续章节图搜索算法讨论的基础,因为许多算法的核心循环都是遍历节点的边,查看与该节点共享边的其他节点。
节点的度数和局部聚类系数等概念提供了关于其直接邻居和邻域的具体度量。这些示例指标只是众多用于量化图属性方式的一小部分。已经开发了许多指标来分析真实世界图的性质,从它们的互连程度到宽度。全面回顾所有图指标超出了本书范围,但后续章节会讨论一些额外的分析内容。
下一章,我们将考虑另一个基础的图算法概念:路径。路径描述了在图中的移动方式,并允许我们记录如何从一个节点遍历到另一个节点。