图算法趣味学——二分图匹配

198 阅读23分钟

许多商业和物流问题都涉及从两个不同集合中匹配元素。我们可能希望将人分配到工作岗位、将会议安排到地点、将车辆匹配到路线,或者将被收养的宠物匹配到家庭。在每种情况下,我们都必须确定一个集合中的哪一项与另一个集合中的哪一项兼容。本章将详细探讨这一问题,即二分图匹配(bipartite graph matching)

二分图是由两个不相交的节点集合组成的图,其中每条边的两个端点分别属于两个集合。这样的图自然适用于匹配问题:每个节点集合代表我们希望匹配的一组元素,而边表示元素之间的兼容性。

本章首先讨论图上的匹配这一更广泛的概念,然后正式介绍无向二分图及其匹配算法,同时展示图匹配如何涵盖丰富的问题集合——从为小组作业分配搭档,到数据中心的任务调度,以及相关的技术挑战。

匹配(Matching)

在无向图上,匹配是一组不共享节点的边。换句话说,匹配中的每条边连接两个不同的节点,每个节点最多只与一条边相邻。我们可以通过学生之间的友谊关系为项目分组来直观地理解匹配。匹配中的每条边表示两位朋友(边的两个节点)将共同完成一个项目。作为这种两两分配的自然结果,我们只会配对已有社交联系的学生,但不能保证每个学生都有搭档。

匹配的概念可以延伸出许多可解决的问题。本章将举两个特别有用的匹配问题作为例子:

  1. 最大基数匹配(Maximum-Cardinality Matching) :寻找边数最多的匹配,也称为最大匹配。这对应于找到一种学生配对方式,使分组数量最多。
  2. 极大匹配(Maximal Matching) :任何无法再增加边而不破坏匹配性质的匹配。最大基数匹配总是极大匹配,但极大匹配不一定是最大基数匹配。

图 15-1 展示了这两种匹配类型的例子。图 15-1(a) 中的极大匹配(粗线表示)无法再增加更多边而不重复使用节点。而图 15-1(b) 既是极大匹配,也是最大基数匹配:对于这幅图,不可能构造一个边数超过三条的匹配。

image.png

最大权重匹配(Maximum-Weight Matching) 问题是指在加权图中寻找一组匹配,使得所有边的权重之和最大。这相当于在分组时优先考虑学生之间友谊的强度。在追求最大化奖励函数(例如学生幸福感)的场景下,这种方法非常有用,但它不一定会产生最大基数匹配。例如,图 15-2 显示了一个最大权重匹配,但它并不是最大基数匹配:节点对 {0, 1} 和 {2, 5} 被匹配,而节点 3 和 4 没有被配对。

image.png

可能的匹配问题远不止这些初步例子。我们可以问一个图是否存在完美匹配,即每个节点恰好被包含一次;或者寻找一个最大基数匹配,同时使边权和最小;又或者寻找一个在给定边数限制下权重最大的匹配。在本章的其余部分,我们将主要关注最大基数匹配,这是最简单且应用最广的一类匹配问题,并将其应用于特定类型的图。

二分图(Bipartite Graphs)

如前所述,二分图可以划分为两个不相交的节点集合,且没有边连接同一集合中的两个节点。二分图通常被可视化为两列平行的节点,如图 15-3 所示。左列和右列分别定义两个节点集合,图中的每条边都跨越这两列。

image.png

二分图为成对匹配问题提供了自然的建模方式。在一个典型例子中,左侧的节点代表人,右侧的节点代表他们有资格胜任的工作,用一个简单的图总结了复杂的约束条件。

尽管本章重点讨论匹配问题,但二分图的用途远不止于此。二分图可以用于建模各种现象,从跨河的物理桥梁到派对上互相监视的间谍网络。

二分图标记(Bipartite Labeling)

给定一个无向图,我们可以询问它是否是二分图,如果是的话,每个节点属于哪一集合。我们可以利用二分图的性质来执行检查和标记节点所属的集合。已知在无向二分图中的任何路径都必须在两个集合之间交错,而节点永远不会有属于同一集合的邻居。我们可以用简单的搜索(广度优先或深度优先)遍历图,并给节点分配标签。关键在于,我们随意给第一个节点分配一个标签,每次跨越一条边就交替标签。如果发现两个邻居拥有相同标签,就说明该图不是二分图。

可以将算法形象化为鸡尾酒会上的间谍对抗场景。各间谍机构由不相交的间谍集合组成,节点表示间谍,每条边表示两个人在互相监视。间谍训练有素,每个间谍可以同时监视多人——间谍 A 可能在监视 B、C 和 D!

一个无聊的侍者,不知道舞厅里任何人的真实身份,利用机会来确定间谍之间的分组。他从随机选取一个间谍开始,将其分配到绿色队。然后他确定该间谍正在监视的人,并将他们分配到黄色队。接着,对于新发现的黄色队成员,侍者确定他们正在监视的人,并将这些被监视的人分配到绿色队。这个过程不断往返,随着侍者递送各类开胃小菜,逐渐揭示每个人的归属。

当然,如果侍者发现某个间谍正在监视自己队伍的成员,就说明这不是一个二分图。可能机构派出了内部人员或存在双重间谍。无论如何,此时局势不再是简单的绿队与黄队,也可能不是侍者想要卷入的事情。

代码实现

列表 15-1 中的二分图标记代码使用广度优先搜索迭代地探索图,并标记节点属于左右哪一侧。

def bipartite_labeling(g: Graph) -> Union[list, None]: 
    label: list = [None] * g.num_nodes
    pending: queue.Queue = queue.Queue()

  ❶ for start in range(g.num_nodes):
      ❷ if label[start] is not None:
            continue

      ❸ pending.put(start)
        label[start] = True
        while not pending.empty():
            current: int = pending.get()
          ❹ next_label = not label[current]

            for edge in g.nodes[current].get_edge_list():
                neighbor: int = edge.to_node
              ❺ if label[neighbor] is None:
                    pending.put(neighbor)
                    label[neighbor] = next_label
              ❻ elif label[neighbor] != next_label:
                    return None
    return label

列表 15-1:根据二分图节点所属的集合标记节点

bipartite_labeling() 函数使用一个列表 label 来映射每个节点索引的状态(未标记 = None,右 = False,左 = True)。代码首先设置标签列表和队列 pending,需要导入 Python 的 queue 库。每个标签初始化为 None,表示算法尚未访问该节点并分配标签。该列表既用于跟踪 BFS 中已访问节点,也用于存储标签。

代码主体是重复的广度优先搜索,外层循环对任何未访问节点启动新搜索 ❶。循环检查潜在的起始节点是否已被之前搜索访问,如果是(标签不为 None)则跳过 ❷。未访问的节点会加入队列,标记为左侧(True),并从该节点开始新的 BFS ❸。

在 BFS 的每一步中,代码获取当前节点并使用其标签确定邻居的标签 ❹。即当前节点的所有邻居必须拥有相反标签,否则图不是二分图。代码迭代节点的所有边并检查邻居。如果邻居未被访问(labelNone)❺,则设置标签并加入队列。如果邻居已访问,代码检查其标签是否有效 ❻。若标签不符合预期,则图中存在同侧连接节点,不是二分图,立即返回 None

如果代码顺利完成对所有节点的 BFS 搜索,将返回节点标签列表(TrueFalse)。否则返回 None 表示图不是二分图。与书中其他例子类似,需要从 typing 库导入 Union 来支持多返回值的类型提示。

示例

图 15-4 展示了二分图标记算法在一个七节点示例图上的执行步骤。图 15-4(a) 中,随机选取一个节点(0),赋予标签 True 并加入队列准备探索,相当于侍者选择第一个间谍分配到绿队。

探索节点 0 后,算法发现两个邻居(图 15-4(b)),为节点 3 和 5 赋予 False 标签,表示它们属于与节点 0 相反的集合,同时加入队列。

图 15-4(c) 显示探索节点 3 后发现的新邻居节点 4。节点 3 的标签为 False,因此节点 4 被标记为 True。算法同时检查所有已访问节点(此处为节点 0),确认其标签符合预期。对侍者来说,这一步相当于观察黄队成员,并记录绿队成员。

算法以此方式逐步遍历图,每步检查所有邻居,标记新节点并加入队列,同时验证已知邻居标签的一致性。图 15-4(h) 显示,当每个节点都已检查完毕,搜索结束。

image.png

我们也可以使用相同的算法来识别非二分图。图 15-5 展示了该算法在一个非二分图上的执行情况,这个图是在图 15-4 的基础上添加了一条额外的边形成的。前几步的搜索过程与图 15-4 类似。图 15-5(a) 中选取了一个任意初始节点,并在图 15-5(b) 中进行探索。问题的第一个迹象出现在图 15-5(c),此时节点 1 被标记为属于左侧,因为它是节点 3 的邻居。从视觉上我们可以很容易看出这是一个错误,但算法此时还没有这些信息。从算法的视角来看,节点 1 很可能确实属于左侧。算法要到后续进一步搜索时,才会发现问题。

image.png

算法最终在图 15-5(e) 中发现了问题,此时它正在探索节点 1。由于节点 1 本身的标签为 True,它期望其邻居的标签为 False。但在检查节点 2 时就失败了。节点 2 在探索节点 5 时之前被标记为 True,而节点 2 不可能同时为 True 和 False,因此图不是二分图。

应用场景

二分图匹配问题即在二分图上求解匹配问题,可用于解决许多现实世界中的优化和分配问题。由于二分图的结构,每条被选中的边都会将左侧的单个节点与右侧的单个节点连接。根据任务不同,问题可能旨在最大化不同的指标,例如匹配数量或已使用边权重之和。这个模型涵盖了广泛的现实问题,从工作调度到办公室空间规划,甚至可以用于魔法地下城中英雄与怪物的匹配。

工作调度

假设一个物理实验室希望最大化其机器上同时运行的模拟程序数量。机器能力各异,但每台机器一次只能运行一个程序。科学家们将各自的程序提交给人工调度员,并希望优先安排自己的工作。每个程序都有自己的要求,例如高内存或需要 GPU。调度员必须遵守这些约束,从而限制有效分配的数量。

调度员决定用二分图匹配算法来建模分配问题:将科学家的工作列在左侧,机器列在右侧,如果某个工作可以在某台机器上运行,就画一条边连接。高内存需求的工作与高内存机器相连,需要 GPU 的工作与带 GPU 的机器相连,依此类推。这样一来,调度员就可以找到一次性能安排的最大工作数量。

办公室空间分配

Happy Data Structures 公司准备搬入新办公室。大部分团队兴奋地倒计时搬入日,而规划人员则忙于考虑如何将每个团队分配到新楼的工作区域。每个工作区都有约束条件,包括自然采光、资源访问和空间大小。

经过多日收集需求清单后,规划人员决定将问题建模为二分图匹配问题。一组节点表示团队,另一组节点表示工作区。满足团队需求的空间通过边连接。每个团队只能分配到一个空间,每个空间只能安排一个团队。规划人员通过二分图匹配找到满足约束条件的团队分配方案。

任务战斗分配

一个冒险队伍在魔法地下城探险时遇到了一间充满怪物的房间。每个冒险者同意挑战一个怪物,但队伍需要快速完成分配。有些分配是无效的:法师无法对抗抗魔蜥蜴,剑士无法挑战毒雾云。

在经典工作分配问题的变体中,队伍将问题建模为二分图分配:一组节点表示冒险者,另一组节点表示怪物。可以匹配的敌人通过边连接。现在他们只需要高效地将每个冒险者与一个敌人匹配即可。

穷举算法

一种直接的方法是通过尝试各种匹配,包括最大基数匹配和最大权重匹配,来找到最优解。具体来说,我们可以枚举所有 2∣E∣2^{|E|} 种可能的边集合,丢弃任何节点被重复使用的集合,然后根据感兴趣的指标对剩下的集合打分。在本节中,我们简要讨论一种基于深度优先搜索(DFS)的穷举算法。该算法提供了一个基线,可用于与更高效的计算方法进行对比。

匹配数据

为了简化并通用化本节的代码,我们使用一个封装数据结构来跟踪匹配分配,同时记录当前分数。Matching 对象包含当前匹配的三条信息:

  • num_nodes (int) 存储图中节点总数
  • assignments (list) 存储每个节点的匹配伙伴,如果节点未匹配则为 -1
  • score (float) 存储匹配的得分

assignments 列表是双向的,用于存储二分图两侧节点的匹配伙伴。例如,如果边 (0, 4) 被选中,则 assignments[0]=4assignments[4]=0

为了实现该封装数据结构,我们定义构造函数来创建空匹配,并提供向匹配中添加和删除边的函数:

class Matching:
    def __init__(self, num_nodes: int): 
        self.num_nodes: int = num_nodes
        self.assignments: list = [-1] * num_nodes
        self.score: float = 0.0

    def add_edge(self, ind1: int, ind2: int, score: float): 
        self.assignments[ind1] = ind2
        self.assignments[ind2] = ind1
        self.score += score

    def remove_edge(self, ind1: int, ind2: int, score: float): 
        self.assignments[ind1] = -1
        self.assignments[ind2] = -1
        self.score -= score

为了简化示例,add_edge()remove_edge() 函数都未检查节点的有效性或节点当前是否已分配。在真实生产环境中,通常需要添加检查,确保没有节点被重复使用,并验证添加的边确实存在于图中,这类似于第 1 章中的检查方法。

穷举打分

我们采用基于递归深度优先搜索的方法来枚举使用的边。不同于在节点上进行深度优先探索,这里是在节点匹配分配上进行探索。

例如,考虑图 15-6 中的二分图。节点 0 有三种匹配选项:不匹配、匹配节点 1 或匹配节点 3。节点 2 也是同样的情况。

image.png

我们可以将图 15-6 中的潜在匹配空间可视化为一棵树,每一层表示左侧某个节点的匹配分配。这棵树如图 15-7 所示。树的第一层根据节点 0 的三种不同选择分为三条分支:

  • 左侧分支表示节点 0 保持未匹配的情况;
  • 中间分支表示节点 0 与节点 1 配对的情况;
  • 右侧分支表示节点 0 与节点 3 配对的情况。

我们通过只考虑图中存在的边对应的潜在匹配,来限制搜索空间。

image.png

我们可以将图 15-7 中树的第二层视作节点 2 的匹配分配情况的类似分支。由于节点 0 已在三条分支中的两条中被分配,因此这两条分支只会产生两个子选项。

由于这种方法执行的是穷举搜索,它可以用于多种二分图匹配问题,包括最大基数匹配和最大权重匹配。正如我们将看到的,唯一不同的是计算匹配得分的方式。

代码实现

我们使用递归算法在这棵树上搜索最大权重匹配:

def bipartite_matching_exh(g: Graph) -> Union[list, None]: 
  ❶ labels: Union[list, None] = bipartite_labeling(g)
    if labels is None:
        return None

    current: Matching = Matching(g.num_nodes)
  ❷ best_matching: Matching = matching_recursive(g, labels, current, 0)
    return best_matching.assignments

def matching_recursive(g: Graph, labels: list, current: Matching,
                       index: int) -> Matching: 
  ❸ if index >= g.num_nodes:
        return copy.deepcopy(current)
  ❹ if not labels[index]:
        return matching_recursive(g, labels, current, index + 1)

  ❺ best: Matching = matching_recursive(g, labels, current, index + 1)
    for edge in g.nodes[index].get_edge_list():
      ❻ if current.assignments[edge.to_node] == -1:
            current.add_edge(index, edge.to_node, edge.weight)
            new_m: Matching = matching_recursive(g, labels, current, index + 1)
            if new_m.score > best.score:
                best = new_m
            current.remove_edge(index, edge.to_node, edge.weight)
    return best

外部包装函数 bipartite_matching_exh() 首先标记图的两侧节点 ❶,并设置 Matching 数据结构。如果图不是二分图,它会返回 None(因此需要 Union 来支持类型提示)。然后它调用递归函数来执行匹配 ❷。

递归函数 matching_recursive() 先检查是否到达搜索底部 ❸,也就是左侧所有节点已被分配(即使分配为 -1)。如果没有更多节点可分配,它会使用 Python 的 deepcopy() 返回当前匹配的副本,作为该分支下找到的最佳匹配。这保证了后续修改当前对象不会影响已保存的匹配。使用 deepcopy() 需要在文件顶部 import copy

接下来代码检查当前节点是否在左侧 ❹。由于只从左侧节点进行分配,右侧节点会被跳过,递归调用下一索引节点即可。虽然可以修改代码测试两侧节点,但分配右侧节点并不必要:每条边只会使用一次,并且保证连接到左侧节点。

然后代码检查每个可能的匹配选项,并保存最佳匹配。首先处理将当前节点(index)保留未分配的情况 ❺,然后通过 for 循环遍历当前节点的邻居,跳过已分配的节点 ❻。代码将可行邻居加入匹配,递归获取该路径下的最佳匹配,必要时更新最佳匹配,最后移除该边以恢复状态。整个分支结束后返回最佳匹配。

如果希望改为最大基数匹配,只需在增加或移除边时将 edge.weight 替换为 1.0。这样搜索会选择边数更多的匹配,而非总权重更大的匹配。

示例

我们可以通过观察每次递归到底时的当前匹配状态来可视化该函数。图 15-8 展示了算法在示例图上第一次递归到底的九种情况。

由于算法先测试未分配(-1)分支,递归首次遇到完整分配 [-1,-1,-1,-1](图 15-8(a))。评估完空匹配后,搜索回溯并测试节点 4 的替代分配,同时保持节点 0 和节点 2 的分配不变,得到图 15-8(b)、15-8(c) 和 15-8(d) 的匹配。直到图 15-8(f),算法才评估了一个使用两条边的匹配。

image.png

虽然穷举算法既完整又通用,但效率远远不够,尤其是在大型图上。即便是图 15-8 中的六节点图,穷举搜索也要探索 34 种不同的分配情况。下一节将介绍一种专门算法,用于高效解决特定的匹配问题。

求解最大基数二分图问题

本节展示如何利用上一章的最大流算法,高效解决最大基数二分图问题。我们可以将最大基数二分图问题直接转化为最大流问题:将二分图转化为带有方向边和单位容量的流网络。

图 15-9(a) 展示了一个二分图,图 15-9(b) 展示了转化后的结果。我们增加一个源节点 ss,向左列的所有节点提供流量;增加一个汇节点 tt,接收右列节点的流量。图中的每条边都从左向右,有容量 1。(为了简洁,图中未标出边的容量。)

image.png

使用这种设置,源节点可以向每个左侧节点提供最多 1 个单位的流量。同样,右侧列的每个节点最多只能向汇节点提供 1 个单位的流量。由于流入节点的流量必须等于流出节点的流量,每个左侧节点最多只能向右侧的一个节点发送 1 个单位的流量,每个右侧节点最多只能从左侧的一个节点接收 1 个单位的流量。最大流量等于我们可以分配的最大配对数。

代码说明

使用最大流算法进行最大基数二分图匹配的代码,重用了第 14 章的 Edmonds-Karp 实现来处理核心计算。包装函数本身主要完成图的扩展(增加源节点和汇节点)以及之后对不必要的边进行修剪:

def bipartite_matching_max_flow(g: Graph) -> Union[list, None]: 
    num_nodes: int = g.num_nodes

    labeling: Union[list, None] = bipartite_labeling(g)
    if labeling is None:
        return None

  ❶ extended: Graph = Graph(g.num_nodes + 2, undirected=False)
    for node in g.nodes:
        for edge in node.edges.values():
            if labeling[edge.from_node]:
                extended.insert_edge(edge.from_node, edge.to_node, 1.0)

  ❷ source_ind: int = num_nodes
    sink_ind: int = num_nodes + 1
    for i in range(num_nodes):
        if labeling[i]:
            extended.insert_edge(source_ind, i, 1.0)
        else:
            extended.insert_edge(i, sink_ind, 1.0)

  ❸ residual: ResidualGraph = edmonds_karp(extended, source_ind, sink_ind)

  ❹ result: list = [-1] * g.num_nodes
    for from_node in range(residual.num_nodes):
        if from_node != source_ind:
            edge_list: dict = residual.edges[from_node]
            for to_node in edge_list.keys():
                if to_node != sink_ind and edge_list[to_node].used > 0.0:
                    result[from_node] = to_node
                    result[to_node] = from_node
    return result

bipartite_matching_max_flow() 函数返回一个分配列表(与 Matching 类的 assignments 列表格式相同),如果图不是二分图,则返回 None

  1. 标记节点:首先需要知道二分图中哪些节点属于哪一侧,使用 bipartite_labeling() 函数,同时检测是否非二分图。
  2. 构建扩展图:创建一个新的有向图,增加源节点和汇节点 ❶。根据标记列表,添加从左列到右列的方向边,每条容量为 1。然后将源节点与左列节点、右列节点与汇节点连接 ❷。
  3. 计算最大流:运行 Edmonds-Karp 算法,得到残余图 ❸。
  4. 生成匹配结果:遍历残余图的边,对于使用流量大于 0 的边,将左右节点互相记录到结果列表中 ❹。

示例说明

图 15-10 展示了算法在图 15-9 中示例二分图上的匹配步骤。每步算法完成后,已满容量的边(used = 1)加粗显示。

可以将该算法与“用例”中介绍的作业调度问题类比:

  • 作业 0(节点 0)最灵活,可以在四台机器中的三台上运行;
  • 作业 2、6、8 只能在特定机器上运行。
  1. 初始状态:算法加入源节点、汇节点和所有对应边,但尚未有流量,通过这种状态表示没有作业被调度运行(图 15-10(a))。
  2. 前两轮:算法找到前两个增广路径,将作业 0 分配给未使用的机器 1,将作业 4 分配给机器 3(图 15-10(b)、15-10(c))。
  3. 增广路径处理冲突:如图 15-10(d),将作业 0 分配给机器 1 阻塞了作业 2 和 6,因为它们也只能运行在机器 1 上。算法通过新的增广路径 (s,2),(2,1),(1,0),(0,5),(5,t) 将流量沿边 (0,1) 回推,取消作业 0 的分配,从而增加已调度作业数量。
  4. 多步增广路径:如图 15-10(e),通过路径 (s,8),(8,3),(3,4),(4,7),(7,t),算法将作业 4 从机器 3 移出,重新分配到机器 7,同时作业 8 分配到机器 3,实现更优的调度。

image.png

在最大流算法完成后,它得到如图 15-10(e) 所示的图。为了生成匹配列表,算法随后遍历残余图的边。它会跳过所有与源节点 ss 或汇节点 tt 相连的边,因为这些边不属于原始二分图。算法保存所有流量非零的连接。最终得到的匹配边为 (0,5)、(2,1)、(4,7)、(8,3)(0,5)、(2,1)、(4,7)、(8,3),如图 15-11 所示。原始二分图中未使用的连接则以细灰线显示,供参考。

image.png

最大流算法只能找到一个最大基数匹配,但该匹配不一定是唯一的。例如在图 15-11 中,还存在其他可能的匹配。比如,我们可以选择边 (6,1)(6,1) 而不是边 (2,1)(2,1)。

为何重要

二分图使我们能够将各种分配问题转换为等价的图算法问题,从而可以利用丰富而强大的图算法库。通过这种方式,我们能够解决一些最初可能不会被认为是图问题的问题。一个清晰的例子就是最大基数匹配问题,它将来自两个不相交集合的匹配问题转化为图问题,然后可以用最大流算法来求解。

在本书的下一部分,我们将切换主题,讨论图上的各种计算上具有挑战性的问题。第 16 章介绍了给图节点分配颜色的问题,要求相邻节点颜色不同。第 17 章探讨了其他各种有用的节点分配问题。最后,第 18 章将挑战性问题的讨论扩展到在图中寻找特定类型路径的问题。