加权无向图的最小生成树(Minimum Spanning Tree,MST)是指连接图中所有节点且边权总和最小的一组边。我们可以利用这一概念来建模和优化各种现实问题,从设计电网到假想花栗鼠应该如何构建它们的地洞网络。
本章介绍了两种经典的最小生成树构建算法。Prim 算法是一种按节点聚合的算法,通过不断扩展已连接节点的集合来构建生成树。Kruskal 算法则从一份排序好的边列表出发,每次加入一条边,逐步构建最小生成树。
在讨论最小生成树如何应用于多个现实问题后,我们还会介绍两种与最小生成树紧密相关的算法:基于网格的迷宫生成算法和单连接聚类算法。我们将展示如何将这些任务映射到图问题上,并利用本章算法的变体加以解决。
最小生成树的结构
图的生成树是连接图中所有节点且不形成任何环的一组边。我们可以将生成树想象为现实基础设施网络的骨架——实现每个节点都可以从任意其他节点到达所需的最少连接。这些边可能是电力线路、道路、计算机网络中的链路,或者花栗鼠地洞中不同洞穴之间的通道。最小生成树就是在连接所有节点的同时,使边权总和最小的一组边。
可以用一个特别井然有序的花栗鼠地洞来形象化这些要求,如图 10-1 所示。花栗鼠将自己的领地构建成一系列洞穴(节点),由隧道(边)相连。像图中的边一样,每条隧道直接连接两个洞穴,并且是直线的。花栗鼠还提出两个额外要求。第一,每个洞口都必须能通过隧道从其他洞穴到达。毕竟,如果有多个入口却不能让你从一个入口钻进,从另一个入口钻出,那又有什么用呢?第二,所有隧道的总长度必须最小化。花栗鼠很懒,它更愿意在不同地点随意钻出地面,而不是花费额外精力去挖新的隧道。
形式上,我们可以将加权无向图的最小生成树问题定义如下:
给定一个节点集合为 、边集合为 EE 的图,找到一组边 ,使得它能够连接 中的每一个节点,同时最小化边权总和 。
根据定义,最小生成树将恰好包含 条边,这是连接 个节点所需的最少边数。若边数更多,则会形成环路并增加不必要的权重。
应用场景
本节介绍几个利用最小生成树概念设计成本高效的物理网络或优化社交网络通信的现实例子。
物理网络
最小生成树在确定构建物理网络所需的最少成本连接时非常有用。想象一家名为“算法咖啡馆公司”的企业,计划在各个门店之间建立先进的气动管道系统来传送咖啡豆。公司承诺提供超过 10,000 种咖啡豆品类,但很快意识到一些门店没有足够的存储空间来容纳如此庞大的品种。于是,它决定建一个中央仓库,根据需要将小包咖啡豆运送到各个门店。这样,每家门店都能拥有无与伦比的咖啡选择。
规划人员很快发现,从每家门店到仓库都建设气动管道成本过高。Javaville 的两个门店距离配送中心都超过 10 英里,但彼此只相隔两个街区。更经济的方案是:先从配送中心修一条管道到 Main Street 门店,再从 Main Street 修第二条管道到 Coffee Boulevard 门店。这样,Coffee Boulevard 的订单可以先送到 Main Street,再转发到 Coffee Boulevard。
这种多步骤的配送方式将气动送豆系统的设计转化为一个最小生成树问题,如图 10-2 所示。算法咖啡馆公司的每栋建筑是一个节点,任意两栋之间的潜在管道是边。
在图 10-2 中,边的权重表示在两栋建筑之间建造气动管道所需的成本。虽然成本通常与距离有关,但也可能因环境因素而增加。例如,穿过城市中心建设管道要比在农田下建造同样长度的管道贵得多。规划人员需要找到一组边(要建设的管道),既能连接所有建筑,又能最小化成本。
除了气动咖啡管道,最小生成树在物理网络上的更典型应用还包括:
- 公路建设:节点是城市,边是高速公路,边权是建设两点间高速公路的成本。
- 电网建设:节点是城市,边是输电线路,边权是建设两点间输电线路的成本。
- 群岛桥梁:节点是群岛中的岛屿,边是两岛之间的物理桥梁,边权是建设桥梁的成本。
- 航空网络设计:节点是机场,边是航班,边权是两机场间飞行的成本。
社交网络
最小生成树也适用于非物理网络。例如,设想有一个“数据结构专家个人通信协会”,他们不相信群发邮件,因为这种方式太冷漠。相反,组织者坚持每条消息都必须由成员间的个人电话传递。然而,像任何专家组织一样,成员之间存在各种旧友谊和宿怨。去年,Alice 哈希表与 Bob 二叉搜索树闹翻,不再交流。
每年,该组织都会制定一个精心设计的电话树,用以在最小化成员不适感的情况下传播即将举行的会议消息。每个成员被表示为一个节点,与其他每个成员有边相连。边的成本表示两名成员通话时的不适程度。最理想的情况下,好友之间的通话成本最低,主要代表通话的时间成本;而在最糟情况下,交恶成员之间的通话可能导致数天的效率损失和抱怨。
该组织需要找到一组通信对,使每个成员都能获知会议详情,同时整体不适感最小化。这就要求用最少数量和成本的边连接所有节点。
Prim 算法
构建最小生成树需要一种算法,从完整图的边集中选择一个最小成本的子集,使得结果图是完全连通的。寻找图的最小生成树的一种方法是 Prim 算法,该算法由计算机科学家 R.C. Prim 和数学家 Vojtěch Jarník 等人独立提出。该算法的操作方式与第 7 章的 Dijkstra 算法非常相似,通过一个未访问节点集合逐步构建最小生成树,每次添加一个节点。
Prim 算法从未访问节点的集合开始,并任意选择一个节点进行访问。这个被访问的节点成为最小生成树的起点。在每次迭代中,算法寻找与已访问节点集合相连的未访问节点中边权最小的节点,即“哪个节点离我们集合的边缘最近,从而可以以最小成本加入?”算法将这个新节点从未访问集合中移除,并将对应的边加入最小生成树。算法不断添加节点和边,每次迭代添加一个,直到所有节点都被访问。
Prim 算法每个节点最多访问一次,每条边最多考虑两次(每端一次)。此外,对于每个节点,在用标准堆实现的优先队列中插入或更新节点的操作可能需要与 |V| 的对数成正比的时间。因此,算法总成本为 ()。
我们可以把 Prim 算法想象成一家建筑公司被雇佣去升级群岛之间的桥梁。公司计划用现代化桥梁替换腐烂的木桥。由于旧木桥无法承受施工设备的重量,从公司的角度来看,只有新桥连接的岛屿才是真正连通的。合同规定,最终任何两座岛屿之间都必须可以通过新桥互相到达。
施工队从一个岛屿开始,向外扩展,用新桥连接越来越多的岛屿。在每一步,他们选择连接当前已连通集合与集合外岛屿的最短木桥进行升级。通过总是从已连通集合的岛屿开始新桥施工,施工队可以通过现代桥梁将设备运到新边的起点。通过总是将桥梁终点放在集合外的岛屿,每一步都增加了已连通集合的覆盖范围。
代码实现
在 Prim 算法的每一步,我们跟踪未连通的节点以及连接它们的最小边权。我们使用自定义的 PriorityQueue 来高效维护这些信息,包括查找队列中的值和修改优先级。对于理解算法,只需掌握将元素插入优先队列、从优先队列中移除元素以及修改优先级的基本操作即可。
算法核心如下:
def prims(g: Graph) -> Union[list, None]:
pq: PriorityQueue = PriorityQueue(min_heap=True)
last: list = [-1] * g.num_nodes
mst_edges: list = []
# ❶ 初始化优先队列
pq.enqueue(0, 0.0)
for i in range(1, g.num_nodes):
pq.enqueue(i, float('inf'))
# ❷ 主循环:处理未访问节点
while not pq.is_empty():
index: int = pq.dequeue()
current: Node = g.nodes[index]
# ❸ 如果存在连接到已连通集合的边,则加入最小生成树
if last[index] != -1:
mst_edges.append(current.get_edge(last[index]))
elif index != 0:
return None
# ❹ 检查邻居并更新优先队列
for edge in current.get_edge_list():
neighbor: int = edge.to_node
if pq.in_queue(neighbor):
if edge.weight < pq.get_priority(neighbor):
pq.update_priority(neighbor, edge.weight)
last[neighbor] = index
return mst_edges
pq:基于最小堆的优先队列,存储未连通节点。last:记录每个节点加入集合前的前驱节点。mst_edges:最终最小生成树的边集。
算法开始时,将所有节点加入优先队列 ❶。起始节点(0)的优先级为 0,其余节点为无穷大。算法像 Dijkstra 一样,每次迭代选择与已连通集合最接近的未访问节点 ❷。
代码接着检查是否存在连接已连通集合的边 ❸。last[index] == -1 的两种情况:
- 节点 0,作为第一个访问的节点没有前驱;
- 与起始节点不连通的组件,此时图不连通,返回 None。
节点加入已访问集合后,遍历其邻居 ❹,如果邻居未访问(仍在优先队列中),就判断是否找到了更小的边权,若是,则更新邻居信息。算法结束时返回最小生成树的边集。
如果图不连通,每个连通组件都有自己的最小生成树。另一种实现方式是返回每个连通组件的最小生成树边集,只需去掉 ❸ 的 elif 检查及返回语句,即可继续处理下一个组件。
示例
图 10-3 展示了八个节点的 Prim 算法示例。每个子图右侧的表格记录了每个节点的状态,包括节点 ID、与已连通集合的距离(即优先级)、以及 last 列表记录的最接近的已连通节点。
- 图 10-3(a):搜索从节点 0 开始,相当于施工队在总部岛屿建立作业基地。检查节点 0 的邻居,并更新信息:节点 1 的距离更新为 1.0,节点 3 更新为 0.6,
last指向节点 0。 - 图 10-3(b):搜索进展到距离已连通集合最近的节点 3,相当于建造第一座桥梁,更新节点 4 和 6 的信息。
- 图 10-3(c):搜索探索节点 1 时,发现节点 4 有更短的边(1,4)比原先计划的边(3,4)便宜,于是更新节点 4 的距离为 0.5,
last指向节点 1。搜索计划使用新边 (1,4) 将节点 4 加入已连通集合。
接下来的五个子图中,搜索依次推进到节点 5、节点 2、节点 4、节点 6 和节点 7,同时检查每个节点的未访问邻居,并更新找到更短边的邻居。每一步,已连通子图的规模增加一个节点,直到所有节点都被连接。
Kruskal 算法
相比 Prim 算法的逐节点方法,另一种构建最小生成树的方法是边为中心的方法。Kruskal 算法由跨学科学者 Joseph B. Kruskal 发明,其核心思想是遍历按权重排序的边列表,并逐步添加边来构建最小生成树。直观上,我们希望优先添加图中较小的边,因为它们是节点之间成本最低的连接。如果我们维护一个按权重排序的边列表,就可以顺序遍历,每次添加有助于构建最小生成树的下一条边。这个对排序边列表的循环构成了 Kruskal 算法的核心。
Kruskal 算法的时间复杂度约为 。算法首先提取并排序所有边,耗时与 成正比。使用高效的并查集(Union-Find)实现,可以在 时间内合并集合。只要 ,算法总成本仍为 。
我们可以将 Kruskal 算法形象化为宠物主人为仓鼠搭建复杂生活空间的场景。仓鼠已有多个大型栖息地,主人决定用透明管道连接这些栖息地,让宠物可以自由穿梭。栖息地的布局固定,主人为了最小化管道总长度,测量每对栖息地间的距离,将列表排序,并决定下一步添加哪条管道。与岛屿桥梁示例不同,主人不必担心运输施工设备,因为他们可以轻松在任意两个节点之间施工。
并查集(Union-Find)
在选择下一条最低成本边时,我们还需要回答一个问题:这条边是否连接了当前未连通的两个组件?如果没有,则该边是冗余的。记住关键字是 最小:如果已有边 (A, B) 和 (B, C),边 (A, C) 并不会增加任何价值,因为节点 C 已可通过 B 从节点 A 到达。
为了高效实现 Kruskal 算法,我们使用辅助数据结构 UnionFind。该结构可以表示多个不同集合,用于追踪图的连通组件,并提供以下高效操作:
are_disjoint(i, j):判断元素 i 和 j 是否属于不同集合,用于检查两个节点是否在同一连通集合。union_sets(i, j):将包含 i 的集合与包含 j 的集合合并,用于添加边时连接两个集合。
并查集还维护不相交集合的数量 num_disjoint_sets,每次操作更新。对于本书算法实现,不必深入理解 UnionFind 的内部实现,只需将其视为提供上述操作的模块即可。有关基础说明和实现代码,可参见附录 C。
代码实现
借助 UnionFind,Kruskal 算法的代码包括两步:首先生成图中所有边并排序;其次遍历该列表,检查当前边是否连接未连通组件,如果是,则加入最小生成树:
def kruskals(g: Graph) -> Union[list, None]:
djs: UnionFind = UnionFind(g.num_nodes)
all_edges: list = []
mst_edges: list = []
# ❶ 遍历所有节点,收集边
for idx in range(g.num_nodes):
for edge in g.nodes[idx].get_edge_list():
# ❷ 避免重复添加边
if edge.to_node > edge.from_node:
all_edges.append(edge)
# ❸ 按权重排序边
all_edges.sort(key=lambda edge: edge.weight)
# 遍历排序后的边
for edge in all_edges:
# ❹ 如果边连接未连通组件,则加入 MST
if djs.are_disjoint(edge.to_node, edge.from_node):
mst_edges.append(edge)
djs.union_sets(edge.to_node, edge.from_node)
# ❺ 检查是否所有节点已连通
if djs.num_disjoint_sets == 1:
return mst_edges
else:
return None
djs:UnionFind 实例,跟踪当前不相交集合。all_edges:存储所有边并排序。mst_edges:存储最终最小生成树边集。
遍历每个节点,将其所有边加入 all_edges ❶。由于无向图在邻接表中会记录两次相同边,使用简单判断避免重复 ❷。排序后 ❸,依次检查每条边是否连接未连通组件 ❹,如果是,则加入 MST 并合并集合。最后检查是否所有节点连通 ❺,若是,返回 MST 边集,否则返回 None。若去掉最后的检查,则可返回每个连通组件的最小生成树边集。
示例
图 10-4 展示了 Kruskal 算法在一个 8 个节点、12 条边的图上的运行示例。
搜索从空边集开始,节点集合初始不连通。
- 图 10-4(a):选择权重最小的边 (1,5),权重为 0.2。边加粗表示它是最小生成树的一部分。节点 1 和 5 现在属于同一连通子集,不相交集合数从 8 减少到 7。
- 图 10-4(b):选择下一个权重最小的边 (6,7),权重为 0.3,不相交集合数降至 6。
在接下来的两个子图中,搜索将节点 2 和节点 4 加入第一个连通子集 {1, 5},形成新的连通集合 {1, 2, 4, 5}。在图 10-4(e) 中,算法通过权重为 0.6 的边将节点 0 和节点 3 两个孤立节点合并。随后在接下来的两个子图中,算法又通过添加边 (0, 1) 和 (3, 6) 将剩余的三个不相交集合连接起来。此时,我们已经得到一个单一集合,这意味着最小生成树的边集已经连接了图中所有节点。
迷宫生成
前几章介绍的图搜索算法可以帮助我们算法化地解决迷宫,但它们并不能帮助我们生成迷宫。本节将从最小生成树算法在构建交通网络等经典应用的路线中稍作偏离,展示如何扩展 Kruskal 算法来创建随机但总是可解的迷宫。为了让迷宫足够有趣,我们确保每个迷宫只有唯一解。
想象我们需要为一家本地家庭餐厅的儿童餐垫生成迷宫。设计可以很简单,但必须可解,并且只有一条通路。餐厅老板明智地不希望通过不可能完成的迷宫来考验小朋友,以免引发尖叫和食物飞出。
基于网格的迷宫表示
为简化本节代码,我们用像方格纸一样的规则方格网格来表示迷宫。经过长时间的思考,我们决定通过为单个边上色来表示迷宫墙壁。玩家可以在没有墙壁阻隔的相邻方格间移动。当我们绘制每条边时,就相当于去掉了离开该方格的一个可能路径,同时也会暗自窃笑,因为我们制造了一个难题。
图 10-5(a) 展示了一个基于网格的迷宫示例。我们可以将这个网格结构等效地表示为图,如图 10-5(b) 所示。
在图 10-5(b) 中,迷宫中的每个方格对应一个图节点。我们在没有墙壁阻隔的相邻节点之间添加无向边,使得一条边表示可以从一个节点移动到另一个节点的能力。
迷宫生成
我们通过从基于网格的图开始构建迷宫,并使用基于 Kruskal 算法的随机生成树算法来连接所有节点。基于网格的初始结构为我们提供了基于邻接的连接。每个节点最多可以与其上、下、左、右四个方向的节点相连。生成生成树可以确保每个节点都能从其他节点到达,并且可以从起点到达终点。
我们通过连接的网格图定义可用的边,如图 10-6 所示。类似于第 5 章生成的网格,该图表示我们需要连接的所有节点,以及可用来连接它们的潜在边集。如果我们的网格宽度为 w,高度为 h,则它包含 h × w 个节点,并且邻近节点之间通过无向边(权重均为 1)连接。
如果我们直接使用图 10-6 作为最终迷宫,那么任意两个位置之间都会有大量可能的路径。换句话说,这样的图并不能生成特别有趣或具有挑战性的迷宫。从起始节点出发,我们可以通过在一个水平方向和一个垂直方向上移动最少的步数,直接到达终点。现实生活中的等价情况可能是把一个树篱迷宫设计成空旷的草坪,或者在餐垫上画一个空白迷宫。为了构造一个有趣的迷宫,我们需要使用这些边的最小子集。
像 Kruskal 算法一样,我们从一个空的生成树开始,此时没有节点相连。对于我们的基于网格的餐垫迷宫,我们从一组方格开始。我们一次添加一条边到生成树,并擦去相邻方格之间的线。可以把连接两个组件想象成卡通人物用一把超大号的大锤移除两个相邻房间之间的实体墙,或者直接冲破墙壁。当我们的卡通人物愉快地开辟通道(或者我们小心地擦去网格线)时,不同的组件就连通了,迷宫中的路径逐渐形成。
生成随机迷宫的关键直觉在于随机选择下一条边。Kruskal 和 Prim 算法都依赖某种方法来打破同权重边之间的平局。然而在这里,所有边的权重相同(1.0),所以我们可以直接随机选择一条边。如果选择的边连接了两个不相连的组件,我们就保留它。这条边打开了两条之前无法互通组件之间的通道。否则,如果选择的边连接了两个已连通的组件,我们就舍弃它,因为在组件之间增加多条路径会产生环路,从而打破“迷宫中只有一条路径”的规则。
代码实现
下面的代码允许我们从基于网格的图中随机生成迷宫的边集:
def randomized_kruskals(g: Graph) -> list:
❶ djs: UnionFind = UnionFind(g.num_nodes)
all_edges: list = []
maze_edges: list = []
❷ for idx in range(g.num_nodes):
for edge in g.nodes[idx].get_edge_list():
if edge.to_node > edge.from_node:
all_edges.append(edge)
❸ while djs.num_disjoint_sets > 1:
num_edges: int = len(all_edges)
❹ edge_ind: int = random.randint(0, num_edges - 1)
new_edge: Edge = all_edges.pop(edge_ind)
❺ if djs.are_disjoint(new_edge.to_node, new_edge.from_node):
maze_edges.append(new_edge)
djs.union_sets(new_edge.to_node, new_edge.from_node)
return maze_edges
函数接受一个完整的基于网格的图(g)来定义边列表。代码首先设置辅助数据结构,包括表示不相交集合的 UnionFind 数据结构(djs)、存储所有边的列表(all_edges)以及存储迷宫或生成树边的列表(maze_edges) ❶。与 Kruskal 算法类似,代码从图中提取完整的边列表 ❷。
算法通过单个 while 循环迭代,直到所有节点属于同一个集合(因此可互通) ❸。在循环的每次迭代中,算法随机选择一条边 ❹(使用 Python 的 random.randint() 函数,需要在文件开头 import random),然后从所有边列表中移除选中的边,并检查它是否连接了两个之前不相交的集合 ❺。如果是,则将这条边加入迷宫边列表,并合并对应的集合;否则,忽略这条边。所有节点合并为一个集合后,算法完成,返回定义迷宫的边列表——即最小生成树。
示例
图 10-7 展示了该算法前几步的示例。每个子图左侧显示当前迷宫(通过已移除的墙壁定义),右侧显示通过添加到图中的边定义的迷宫。在每一步(while 循环的每次迭代)中,最多添加一条边。
其实并不严格要求事先构建完整的基于网格的图(g)。我们也可以像第 5 章构建网格时那样,根据计算出的邻接关系,程序化地填充 all_edges 列表即可。不过,为了本章的讲解,使用完整的基于网格的图能让代码与 Kruskal 算法的联系更加直观,同时保持函数更简单。
随机化 Kruskal 算法是一种生成迷宫的简化方法,但它不能保证终点位于一条曲折且深远的路径尽头。它可能生成相当无聊的迷宫,比如图 10-8(a)、10-8(b) 和 10-8(c) 所示的情况。我们唯一可以确定的是,算法不会生成无法到达终点的迷宫,例如图 10-8(d) 所示的情形。
除了设计儿童餐垫所带来的商业乐趣之外,本节的迷宫生成算法还展示了如何扩展最小生成树和 Kruskal 算法的基本组件。此外,它也演示了如何在算法中引入随机化,从而生成不同的生成树。
单连接层次聚类
我们还可以将 Kruskal 算法改造,用于处理表面上看起来完全不同的空间点聚类问题。聚类是一种常见的无监督数据挖掘和机器学习方法,它将数据点分配到不同的簇中,使得同一簇内的数据点彼此相似(按照某种相似性定义)。例如,我们可以根据地理位置对咖啡馆进行聚类,将安克雷奇的所有咖啡馆归为一簇,而将檀香山的咖啡馆归为另一簇。聚类结果提供了数据点的划分,有助于我们发现数据结构或对相似数据点进行分类。
聚类技术种类繁多,主要区别在于它们如何定义相似点以及如何将点分配到簇中。顾名思义,层次聚类是一种通过在每一层将两个“邻近”簇合并来创建簇层次的方法。每个数据点最初自成一个簇,这些簇通过迭代不断合并,直到所有点都归入同一个簇。即使在层次聚类中,也有多种方法确定要合并的簇,包括:
- 计算每个簇点的平均位置,并合并中心最接近的簇
- 找出两簇中任意一对点的最远距离,并合并最大距离最小的簇
- 找出两簇中任意一对点的最短距离,并合并最小距离最小的簇
本节重点介绍最后一种方法,称为单连接聚类(single-linkage clustering),它将两簇中距离最近的点所在的簇合并。我们给出了一个实现该方法的算法,其结构几乎与图上的 Kruskal 算法相同。
图 10-9 展示了单连接聚类的示例。左图显示了五个二维点:(0, 0)、(1, 0)、(1.2, 1)、(1.8, 1) 和 (0.5, 1.5);右图显示了对应的层次聚类结果。
我们从每个点各自成簇开始,然后将距离最近的两个点(点 2 和点 3)合并成一个簇。接着,我们将簇 {2, 3} 与 {4} 合并,因为在不同簇中的所有点对里,点 2 和点 4 的距离最小。这个过程持续进行,如图 10-9 右侧所示。
层次聚类的优势在于,它提供了一个易于可视化和解释的结构。我们可以利用这个结构,通过沿层次向上“走”直到达到给定的距离阈值,动态地改变簇的数量(即划分的层级)。在达到阈值之前已经合并的点被归为同一簇,而尚未合并的簇则保持独立。
代码实现
为了简化聚类代码的逻辑,我们定义了两个小型辅助类,用于存储点信息和最终的聚类连接。首先,为了表示我们要聚类的二维点,定义一个 Point 类,用于存储坐标并计算两点间距离:
class Point:
def __init__(self, x: float, y: float):
self.x: float = x
self.y: float = y
def distance(self, b) -> float:
diff_x: float = (self.x - b.x)
diff_y: float = (self.y - b.y)
dist: float = math.sqrt(diff_x*diff_x + diff_y*diff_y)
return dist
distance() 函数用于计算二维空间中的欧氏距离,需要在文件开头 import math 以使用 math.sqrt() 函数。(附录 A 对从空间点创建图,包括使用替代距离函数进行了进一步讨论。)
其次,由于我们不使用显式图结构,还定义一个 Link 数据结构来存储同簇中点的连接信息:
class Link:
def __init__(self, dist: float, id1: int, id2: int):
self.dist: float = dist
self.id1: int = id1
self.id2: int = id2
这个数据结构实际上与无向图的边完全相同,存储了两点的标识符以及它们之间的距离(权重)。这里将其定义为独立的数据结构,以强调单连接聚类无需显式构建图。
借助这两个辅助数据结构,我们可以基于 Kruskal 算法实现单连接层次聚类:
def single_linkage_clustering(points: list) -> list:
num_pts: int = len(points)
djs: UnionFind = UnionFind(num_pts)
all_links: list = []
cluster_links: list = []
❶ for id1 in range(num_pts):
for id2 in range(id1 + 1, num_pts):
dist = points[id1].distance(points[id2])
all_links.append(Link(dist, id1, id2))
❷ all_links.sort(key=lambda link: link.dist)
for x in all_links:
❸ if djs.are_disjoint(x.id1, x.id2):
cluster_links.append(x)
djs.union_sets(x.id1, x.id2)
return cluster_links
该代码以一个 Point 对象列表(points)为输入。函数首先创建一系列辅助数据结构,包括表示各个不相交集的 UnionFind 数据结构(djs),用于判断哪些点已经属于同一簇;空列表 all_links 存储所有点对的距离;空列表 cluster_links 用于存储每次合并所形成的 Link 对象。随后,使用嵌套的 for 循环遍历所有点对 ❶,对每一对点计算距离并生成 Link 对象。计算完成后,将所有连接按距离升序排序 ❷。
接下来,通过另一个 for 循环遍历排序后的所有连接,利用 UnionFind 判断这对点是否已经属于同一簇 ❸。如果不属于,则将该连接加入 cluster_links,同时合并包含这两个点的簇。
最终,函数返回 cluster_links 列表,每个 Link 表示两簇之间的连接。列表按距离升序排列,第一个元素对应第一次合并的两点。
示例
图 10-10 展示了该聚类算法在图 10-9 点集上的步骤。左列显示当前簇作为二维点的图结构连接情况;右列显示相同簇在层次结构中的合并情况,每个簇用圆圈表示。
在图 10-10(a) 中,算法将距离最近的两个点——(1.2, 1) 和 (1.8, 1)——合并成一个簇。根据图 10-9 中的点标号,我们分别将这两个点称为点 2 和点 3。
在下一步图 10-10(b) 中,算法将包含距离最近点对的两个簇合并。此时,距离最近的点是 (1.2, 1) 和 (0.5, 1.5),距离约为 0.86。由于 (1.2, 1) 已经属于一个簇,算法将整个簇与包含单点 (0.5, 1.5) 的簇合并。合并后的簇包含三个点 {2, 3, 4}。
算法在图 10-10(c) 中继续操作,将剩余的两个单点 (0, 0) 和 (1, 0) 合并成一个新的簇。此时,算法创建了两个独立的簇,分别包含三个点和两个点。在最终步骤图 10-10(d) 中,这两个簇通过添加一条连接每个簇中最近点对 (1.2, 1) 和 (1, 0) 的连接线合并为一个簇。
由于单连接聚类是通过不断连接距离较远的点对来扩展簇的,因此我们可以将距离用作算法的停止阈值。例如,如果设置最大距离为 0.95,就会生成图 10-10(b) 所示的三个独立簇。
意义所在
最小生成树问题可以帮助我们解决一系列现实世界的优化问题,从修建道路到设计通信网络。在计算机科学领域,我们可以利用最小生成树解决网络、聚类以及生物数据分析等问题。例如,我们可以将通信网络表示为图,并通过寻找最小生成树来确定哪些链路需要升级,以确保所有节点通过新技术可达。
同样的基本方法也可以应用于一些我们通常不会认为是图结构的问题。通过 Kruskal 算法的变体,我们可以在实值数据集中寻找结构,构建相似数据点的簇,或者通过在算法中引入随机性来设计可解迷宫,从而产生新颖的解决方案。在单连接聚类中,我们利用距离来判断哪些点是相似的。
下一章将对这一讨论进行拓展,介绍帮助我们识别对保持连通性至关重要的节点和边的算法。