图算法趣味学——最大流算法

前几章展示了如何使用图来建模连通性和运输问题。本章则关注网络的整体容量以及物质如何在网络中流动。想象一下,我们希望模拟水在管道网络中的流动量。我们可以使用边的权重来表示任意两个节点之间的最大流量,从而确定整个网络的最大容量。

最大流问题旨在确定在给定边的容量限制下,图中能够支持的最大流量。这个表述故意保持通用性。我们可能在模拟水通过管道的流动、人群通过交通网络的流动,或信息通过社交网络的流动。每种应用都会带来自己的术语、度量和单位,但本质问题都是相同的。

在本章中,我们将考虑如何在有向加权图上计算最大流,使用 Ford-Fulkerson 和 Edmonds-Karp 算法。过程中,我们会展示如何扩展之前章节使用的 Graph 和 Edge 数据结构,以处理边上容量的动态使用情况。

最大流问题

假设给定一个带权图,其中边表示相邻节点之间的流向和容量,我们如何确定网络中的最大流量?我们将流量的起点节点称为源节点,用 s 表示;将流量的终点节点称为汇节点,用 t 表示。我们用 capacity(u, v) 表示从 u 到 v 的边的容量,也就是该边能够承载的最大流量;用 flow(u, v) 表示该边的实际流量。网络的总流量等于从源节点流出的总流量(或者等价地,流入汇节点的总流量)。

可以用城市污水处理系统来直观理解最大流问题。想象污水从城市通过单一源管道流出,进入污水处理厂的单一汇管道。在源节点和汇节点之间,污水通过不同尺寸的管道流动,在各个节点处分流和汇合。管道的容量决定了其能够承载的最大污水流量。

为了建模现实情况,最大流问题有几个约束条件:

  1. 源节点和汇节点约束:源节点(城市)只有流出,汇节点(污水处理厂)只有流入。数学表达为:

    scss 复制代码
    capacity(u, s) = 0 对于每个节点 u
    capacity(t, v) = 0 对于每个节点 v

    这对应于现实中合理的限制:污水不能从源管道回流到城市,处理系统也不会向外流出污水。

  2. 边容量约束:边的流量不能小于 0,也不能超过边的容量。数学表达为:

    scss 复制代码
    0 ≤ flow(u, v) ≤ capacity(u, v) 对于任意节点对 u 和 v

    上限对应于管道的物理限制:如果水流量过大,管道会爆裂;下限为 0 表示管道的方向性,例如单向阀防止回流。

  3. 流量守恒约束:除源节点和汇节点外,任意节点的流入量必须等于流出量。数学表达为:

    scss 复制代码
    ∑v flow(v, u) = ∑v flow(u, v)

    该约束防止出现节点处水凭空消失或生成的情况。

在本章的前几节中,我们还施加一个额外约束以便分析最大流算法:不允许存在反向平行边,即同一对节点之间存在方向相反的两条边。换句话说,如果有一条从 u 到 v 的边,就不允许存在从 v 到 u 的边。这个限制简化了残量网络(residual network)的定义,后面章节会放宽这一限制。

图 14-1 展示了一个小型图的最大流问题示例。图 14-1(a) 中边的权重表示容量。为了计算从源节点 0 到汇节点 3 的总流量,可以将每条路径上的流量加起来。图 14-1(b) 展示了最大流的配置。沿上方路径,我们可以从节点 0 向节点 1 发送 5 单位流量。节点 1 到节点 3 的边可以承载更多,但由于节点 1 的流入已达上限,我们无法从节点 1 输出超过 5 单位流量,因此上方路径的最大流为 5。

类似地,图 14-1(a) 显示了图底部路径上的一对限制。虽然从节点 0 到节点 2 的边看起来很有潜力,容量为 10,但我们无法从节点 2 输出如此大的流量。边 (2, 3) 是一个严重的瓶颈,容量仅为 1。就像大管道过渡到小管道一样,这组边限制了底部路径的总体容量为 1,同时将整个网络的总流量限制为 6。

对于较大的图,最大流问题会变得复杂得多。考虑图 14-2,当我们增加一条从节点 2 到节点 1、容量为 7 的新边时会发生什么。也许由于频繁的下水道堵塞,政府在节点 2 和节点 1 之间新建了一条管道。边 (2, 1) 为节点 2 的流量提供了一个备选路径。最多 7 单位的流量可以沿 (2, 1) 分流,而 1 单位继续沿 (2, 3) 流动。

然而,我们需要确保经过节点 1 的新路径能够承载这额外的流量。我们已经有 5 单位的流量从节点 0 流向节点 1。由于边 (1, 3) 的容量为 10,而我们已经使用了 5 单位,它只剩下 5 单位容量。尽管新建了一条容量为 7 的光鲜新边,我们实际上只能再通过网络发送 5 单位流量。

应用场景

最大流问题自然反映了多种现实世界现象,包括液体通过管道的流动、人流通过交通网络的流动或信息通过社交网络的传递。

物理管道

最大流问题的许多术语源自物质通过管道流动的物理现象。术语如 source(源)、sink(汇)、capacity(容量)甚至 flow(流量)都对应其物理意义。我们可以轻松地将这些物理问题映射到计算问题上。

本章的主要示例是污水系统中的水流,但管道类比远不止于下水道或室内管道,它允许我们提出更多问题。比如,我们可能关心枫糖浆在加工厂的流动。面对复杂的管道和节点,整个系统的容量是多少?在不发生灾难性加工故障的前提下,我们最多可以输送多少液体?这些问题为进一步分析和优化提供了基础,包括回答如"当前系统的瓶颈在哪里?"或"应该在哪增加一条管道以扩充容量?"等后续问题。

交通网络

交通网络也是进行最大流分析的良好场景。假设你最喜欢的体育队要在遥远的城市参加冠军赛,成千上万的本地球迷想要飞往那里观看这场世纪大赛。航空公司可以将这种需求建模为最大流问题,以确定当前有多少球迷可以在两座城市间旅行。边表示城市对之间的航线,其座位数量构成容量。边上的流量表示已占用的座位数。本地城市是球迷出发的源节点,而主办城市是汇节点。

航空公司可以利用这一分析来决定是否增加航班。如果有兴趣的球迷数量远超现有航班容量,就意味着有更多盈利空间。

通信网络

我们还可以用最大流问题来模拟信息在通信或社交网络中的传递。例如,假设你想通过社交网络策略性地影响另一个人的决策。也许你想说服心仪公司的招聘经理,相信你是前任 CEO 的理想接班人。在这种情况下,你是源节点,招聘经理是汇节点,你开始分享自己的成就故事以影响其决策。

不幸的是,你网络中的成员在转发信息上的时间和兴趣有限,这种容量在任意两个节点间可能不同。比如两位朋友每天早晨喝咖啡时可以互传大量信息,但紧张关系可能限制信息传递量。将这种情况建模为最大流问题可以帮助你确定实际能传递到汇节点的信息量。

数据结构扩展

在介绍第一个算法之前,我们需要扩展 Edge 和 Graph 数据结构,以充分表示容量和流量。Graph 的边权重无法同时表示总容量和已使用量,而最大流问题要求图既能捕获固定总容量,又能动态记录流量。

在本节中,我们定义两个新数据结构。CapacityEdge 类基于 Edge 类,增加了表示已使用容量的支持。ResidualGraph 类基于 Graph 类,并增加了动态跟踪图中流量变化的功能。

带容量的边

为了建模最大流问题,图的边需要存储两类信息:固定总容量和动态流量。我们定义 CapacityEdge 类存储如下信息:

  • from_node (int) 边的起点节点索引
  • to_node (int) 边的终点节点索引
  • capacity (float) 边的总容量
  • used (float) 边已使用的容量

我们用 capacityused 的组合替代了原来的单一权重值。图 14-3 展示了这些属性在流量上下文中的可视化,其中 capacity 表示管道宽度,used 表示已占用量。

与我们在第 1 章定义并贯穿全书使用的 Edge 数据结构不同,CapacityEdge 对象不仅存储数据,还提供操作这些数据的函数,如下所示:

python 复制代码
class CapacityEdge: 
    def __init__(self, from_node: int, to_node: int, capacity: float):
        self.from_node: int = from_node
        self.to_node: int = to_node
        self.capacity: float = capacity
      ❶ self.used: float = 0.0

    def adjust_used(self, amount: float): 
      ❷ if self.used + amount < 0.0 or self.used + amount > self.capacity:
            raise Exception("Capacity Error")
        self.used += amount

    def capacity_left(self) -> float: 
        return self.capacity - self.used

    def flow_used(self) -> float: 
        return self.used

构造函数初始化对象变量,并将 used 设为 0,表示边初始时没有流量 ❶。接着,adjust_used() 函数允许算法修改边上的流量。它接受一个调整量,将其加到当前使用的容量上。我们可以将这个函数类比为水龙头的旋钮:旋正方向(传入正数)则流量增加,旋反方向(传入负数)则流量减少。但与真实水龙头不同的是,函数不会在到达限制时自动"停止旋转"。代码中额外的检查确保已用容量在边定义的限制范围内 ❷,即边上的流量永远不会小于 0,也不会超过边的总容量。

进一步推演水龙头类比,我们可能希望知道在每个方向上还能旋多少。capacity_left() 函数提供了边上剩余可用容量(也称为正向残量),表示还能向边上增加的流量。类似地,flow_used() 函数表示当前已使用的容量(也称为反向残量)。

残量图(Residual Graphs)

正如我们需要在边上跟踪已用容量一样,我们也必须增强图的表示,以支持对这些动态边的存储和计算。同时,我们增加了与最大流问题相关的辅助信息,即源节点和汇节点的索引。我们称这种增强后的图为 残量图,因为它跟踪节点对之间的剩余(residual)容量。

我们定义了 ResidualGraph 类,使用更简化的邻接表表示,并包含以下内容:

  • num_nodes (int) 存储图中节点总数
  • source_index (int) 存储源节点索引
  • sink_index (int) 存储汇节点索引
  • edges (list) 为每个节点存储一个字典,字典的键为目标节点索引,值为从该节点出发的 CapacityEdge 对象。访问从节点 j 到节点 k 的边,可使用 edges[j][k]
  • all_neighbors (list) 为每个节点存储其所有入邻居和出邻居的索引集合

ResidualGraph 与 Graph 类的区别在于,我们不再存储 Node 对象。相同的邻接表信息(包括字典的使用)被整合到 edges 列表中。这种表示更紧凑,足以支持最大流算法,但我们失去了像其他算法中在节点上轻松存储辅助数据的能力。

尽管我们处理的是有向图,后续算法需要扫描所有邻居节点,包括传统邻接表中未包含的入邻居。为便于这些计算,我们额外存储了 all_neighbors 列表。限制任意两节点之间仅存在一条有向 CapacityEdge(不允许反向边)简化了正向流量和反向流量的推理。如后文所示,这一限制不会降低图的表达能力,因为可以将带反向边的图转换为不带反向边的图。

为演示 edgesall_neighbors 列表如何捕捉图的结构,考虑图 14-4 所示的 ResidualGraph 示例及其两个列表数据结构。左侧是四节点图,中间是对应的 edges 列表,右侧是 all_neighbors 列表。节点 1 有两条出边(节点 2 和 3),因此其邻接字典 edges[1] 中有两条条目。字典中每条条目将邻居索引映射到对应的 CapacityEdge。由于节点 1 还有一条来自节点 0 的入边,集合 all_neighbors[1] 包含三个索引:0、2 和 3。

ResidualGraph 类提供了创建和操作这种类型图的函数:

python 复制代码
class ResidualGraph: 
    def __init__(self, num_nodes: int, source_index: int, sink_index: int):
        self.num_nodes: int = num_nodes
        self.source_index: int = source_index
        self.sink_index: int = sink_index
        self.edges: list = [{} for _ in range(num_nodes)]
        self.all_neighbors: list = [set() for _ in range(num_nodes)]

    def get_edge(self, from_node: int, to_node: int) -> Union[CapacityEdge, None]: 
        if from_node < 0 or from_node >= self.num_nodes:
            raise IndexError
        if to_node < 0 or to_node >= self.num_nodes:
            raise IndexError
        if to_node in self.edges[from_node]:
            return self.edges[from_node][to_node]
        return None

    def insert_edge(self, from_node: int, to_node: int, capacity: float): 
      ❶ if from_node < 0 or from_node >= self.num_nodes:
            raise IndexError
        if to_node < 0 or to_node >= self.num_nodes:
            raise IndexError

      ❷ if from_node == self.sink_index:
            raise ValueError("Tried to insert edge FROM sink node.")
        if to_node == self.source_index:
            raise ValueError("Tried to insert edge TO source node.")
        if from_node in self.edges[to_node]:
            raise ValueError(f"Tried to insert edge {from_node}->{to_node}, "
                             f"edge {to_node}->{from_node} already exists.")
        if capacity <= 0:
            raise ValueError(f"Tried to insert capacity {capacity}")

      ❸ self.edges[from_node][to_node] = CapacityEdge(from_node, to_node, capacity)
      ❹ self.all_neighbors[from_node].add(to_node)
        self.all_neighbors[to_node].add(from_node)

    def compute_total_flow(self) -> float: 
        total_flow: float = 0.0
        for to_node in self.edges[self.source_index]:
            total_flow += self.edges[self.source_index][to_node].flow_used()
        return total_flow

构造函数通过为所有节点创建空的邻接字典 (edges) 和邻居集合 (all_neighbors) 来初始化一个空图。

接着,get_edge() 函数与 Graph 类中的版本类似,用于访问每条边。该函数大部分代码用于边界检查:如果 from_nodeto_node 不在图中,则抛出 IndexError;如果边不存在,则返回 None;如果节点有效且边存在,则返回对应的 CapacityEdge 对象。代码中使用 typing 库的 Union 来支持多类型返回值的类型提示。

由于 ResidualGraph 使用不同的结构存储带容量的边,并增加了更多邻居信息来跟踪入边,因此 insert_edge() 函数需要相应地处理这些信息。代码首先进行与 Graph 类相同的索引有效性检查 ❶,并增加对最大流问题中图结构约束的检查 ❷。具体包括:

  1. 源节点不允许入边;
  2. 汇节点不允许出边;
  3. 新插入的边不能是已有边的反向边;
  4. 容量必须大于 0。

如果所有检查通过,代码会创建一个新的 CapacityEdge 并添加到 edges 列表对应字典中 ❸。如果两个节点之间同方向已有边,代码会覆盖原边。最后,将 to_node 添加到 from_node 的邻居集合,将 from_node 添加到 to_node 的邻居集合 ❹。

compute_total_flow() 函数演示了如何使用 ResidualGraph 内的数据推导图的性质:通过对源节点出边的流量求和,计算源到汇的总流量。由于所有流量都源自单一源节点,这也就是图的总流量。

ResidualGraph 类中的其余函数与 Ford-Fulkerson 算法密切相关,我们将在介绍算法时结合上下文展示。

Ford-Fulkerson 算法

数学家 L.R. Ford Jr. 和 D.R. Fulkerson 提出了一种通用方法,通过不断寻找从源节点到汇节点的未充分利用路径,并沿这些路径增加流量,从而求解图的最大流。该方法依赖于"增广路径"的概念,即从源节点到汇节点的一条可以增加流量的路径。

严格来说,Ford-Fulkerson 是一种通用方法,包含了一系列具体算法,因为原始论文并未指定用于寻找增广路径的具体搜索算法。本节将给出一个基于深度优先搜索(DFS)的示例实现。

在某些极端情况下,如果容量使用了无理数,Ford-Fulkerson 方法可能无法终止。这类情况可以通过限制容量的小数精度来避免,或者如本章后面将介绍的,通过选择边数最少的增广路径来规避。

增广路径的定义

增广路径最简单的形式是从源节点到汇节点的一系列有向边,其当前流量小于边的容量。在这种情况下,如图 14-5(a) 所示,我们可以沿路径 [0, 2, 3] 增加流量,从而将总流量增加 2 单位。图 14-5(b) 显示了沿源节点流出、汇节点流入的总流量达到 7 单位的结果。

添加正向流量只能解决问题的一部分。然而,图 14-6 展示了这样一种情况:从源点到汇点不存在具有剩余容量的路径。这个图还不是最大流,因为从节点 1 到节点 2 的流量正在"抽走"从节点 1 到节点 3 的潜在流量。与此同时,这股流量导致从节点 2 到节点 3 的边被完全占用,因此无法再接受来自边 (0, 2) 的更多流量。

我们可以使用图 14-7 所示的两个步骤来增加图的流量。进入节点 1 的 5 单位流量最初被分成两股流:1 单位流向节点 2,4 单位流向节点 3。我们改变这一分配,如图 14-7(a) 所示,将额外 1 单位流量引向节点 3。图中的总流量保持不变,但边 (2, 3) 上的流量现在低于容量。第二步,如图 14-7(b) 所示,我们增加从节点 0 经节点 2 流向节点 3 的流量,从而将整个图的总流量提高到 8 单位。

为了在网络中重新引导流量,算法还需要能够通过另一条边来减少某条边的流量。因此,我们定义有向边 (u, v) 的残量如下:

  • 正向残量(forward residual)是从节点 u 到节点 v 方向上未使用的容量,即 capacity(u,v)−flow(u,v)\text{capacity}(u, v) - \text{flow}(u, v)。这符合我们通常对"可用容量"的理解。
  • 反向残量(backward residual)是沿边的反方向(即从节点 v 到节点 u)已使用的容量,即 flow(v,u)\text{flow}(v, u)。这对应于可以从节点 u 的输入中移除的容量,从而允许我们从其他地方接收输入。

我们可以通过利用正向残量在利用不足的有向边上推动更多流量,或者通过反向残量将流量沿有向边的相反方向推回。图 14-8 展示了正向残量和反向残量结合使用的示例情况。

加粗的边表示从源点到汇点的一条无向路径,我们可以沿着这条路径按如下方式调整流量:

  • 边 (0, 2) 的正向残量为 8,因此我们可以在该边上向节点 2 增加更多流量。
  • 边 (1, 2) 的反向残量为 1,因此我们可以将这条流量减少 1 单位,使节点 2 可以从另一个来源(此例中为节点 0)接收更多流量。由于节点 2 的输出流量被限制为 3 单位,且需要保证流入流出平衡,我们必须减少来自节点 1 的输入流量,以便增加来自节点 0 的流量。
  • 边 (1, 3) 的正向残量为 6,因此它可以接收节点 1 中不再流向节点 2 的额外输出流量。同样,需要保证节点 1 的流入与流出平衡。

理解 Ford-Fulkerson 算法的关键在于:沿反向边推动流量,其实就是减少起始节点的输出流量,使其可以流向新的目标节点。正如下一节所示,由于我们需要在任意方向推动流量,单纯探索每个节点(有向)邻接列表中的边已经不够,我们需要同时考虑流入和流出的边。

我们可以将这个算法类比为一名污水工程师管理之前描述的污水系统。工程师通过将流量引导至最优管道组合来最大化总流量。主要约束是管道(边)和汇流箱(节点)的容量。上一位工程师为了炫耀,忽略了总容量,推动了超过承载能力的流量,结果导致管道爆裂,引发喷涌的污水,新闻报道了数周之久。

新的工程师则通过不断寻找可以承载更多污水的源点到汇点路径,并尽可能多地通过该路径输送污水(但不超量)来解决问题。有时这意味着要抵消已有流量,只要这些流量可以通过另一个节点流向汇点即可。任何流入汇流箱的污水也必须流出,否则会有爆裂风险。工程师不断增加流量,直到所有路径完全饱和。

寻找增广路径

在定义搜索算法之前,我们需要形式化沿路径计算残量的方法。回想之前的描述,增广路径可以包含正向残量和反向残量的组合。我们在 ResidualGraph 类中定义一个辅助方法,以简化计算路径上任意两节点之间残量(正向或反向)的逻辑:

php 复制代码
def get_residual(self, from_node: int, to_node: int) -> float: 
  ❶ if to_node not in self.all_neighbors[from_node]:
        return 0

  ❷ if to_node in self.edges[from_node]:
        return self.edges[from_node][to_node].capacity_left()
    else:
        return self.edges[to_node][from_node].flow_used()

get_residual() 函数首先检查两节点是否连通 ❶。如果不连通,则该边既不是正向边也不是反向边,残量为 0。如果边 (from_node, to_node) 在有向边邻接列表中,则为正向边 ❷,函数返回剩余容量(正向残量);否则,边必然存在于反方向,返回已使用流量(反向残量)。

在本节中,我们使用修改后的深度优先搜索来检查图中是否存在增广路径:

sql 复制代码
def find_augmenting_path_dfs(g: ResidualGraph) -> list: 
    seen: list = [False] * g.num_nodes
    last: list = [-1] * g.num_nodes
    augmenting_path_dfs_recursive(g, g.source_index, seen, last)
    return last

def augmenting_path_dfs_recursive(g: ResidualGraph, current: int,
                                  seen: list, last: list): 
    seen[current] = True
    for n in g.all_neighbors[current]:
      ❶ if not seen[n] and g.get_residual(current, n) > 0:
            last[n] = current
          ❷ if last[g.sink_index] != -1:
                return
            augmenting_path_dfs_recursive(g, n, seen, last)

这段代码由两个函数组成。首先,find_augmenting_path_dfs() 函数为深度优先搜索设置 seenlast 列表(如第 4 章所述),然后调用递归函数,并返回表示增广路径的 last 列表。

augmenting_path_dfs_recursive() 函数执行递归深度优先探索。与标准 DFS 类似,它将当前节点标记为已访问,然后遍历节点邻居。代码通过遍历残量图的 all_neighbors 列表,同时沿有向边的正反方向探索。在遍历当前节点邻居时,代码检查节点未被访问且残量非零 ❶,以避免使用已饱和的边。如果边可行且节点未访问,则更新追踪信息并递归探索该节点。

代码还包含可选的提前终止检查 ❷。一旦找到从源点到汇点的路径,即停止探索新邻居。通过检查 last[g.sink_index] 是否已赋值,代码可以在汇点前的节点及路径上的前面节点跳过递归探索。

图 14-9 展示了 find_augmenting_path_dfs() 在图 14-6 上的迭代过程。每条边标注为 X/Y,其中 X 为已使用流量,Y 为边的总容量。阴影节点表示已访问节点,虚线圈中的节点是递归函数刚刚调用的节点。

图 14-9(a) 展示了算法在访问源节点之前的状态。图 14-9(b) 展示了搜索的第二步:在访问节点 0 后,算法发现了两个邻居节点 1 和节点 2。只有边 (0, 2) 具有未使用的容量,因此搜索沿该分支继续。

由于算法同时考虑流出的边和流入的边,它在节点 2 发现了两个选项。边 (1, 2) 和 (2, 3) 在各自方向上都已达到容量上限,但边 (1, 2) 是流入节点 2 的,因此具有 1 单位的反向残量。这条边提供了减少流入节点 2 流量的机会。如图 14-9(c) 所示,搜索沿该边前往节点 1。

在探索节点 1 时,算法找到了一条通向汇点且具有未使用容量的路径。代码并不真正访问汇点,而是在找到任意路径时立即返回。在本例中,如图 14-9(d) 所示,算法返回 last 数组为 [-1, 2, 0, 1],表示增广路径。

更新路径的容量

找到增广路径后,Ford-Fulkerson 算法必须确定可以沿该路径推动的额外流量,然后更新路径的容量以反映流量增加。为此,我们在 ResidualGraph 类中添加两个函数。min_residual_on_path() 函数使用 last 指针遍历路径,并计算路径上任意边的最小残量:

python 复制代码
def min_residual_on_path(self, last: list) -> float: 
    min_val: float = math.inf

    current: int = self.sink_index
  ❶ while current != self.source_index:
        prev: int = last[current]
        if prev == -1:
            raise ValueError
        min_val = min(min_val, self.get_residual(prev, current))
      ❷ current = prev
    return min_val

代码先将 min_val 设置为无穷大(需 import math),表示尚未找到最小值。然后使用 while 循环从汇点向源点回溯指针链 ❶。每步检查前驱节点不为 -1(否则路径断裂),并使用 get_residual() 更新当前边的最小值,然后继续回溯 ❷。遍历完整条路径后,返回遇到的最小残量。

若将 min_residual_on_path() 应用于图 14-6 的结果,last 数组为 [-1, 2, 0, 1],则遍历图 14-8 所示路径,路径上最小残量为边 (1, 2) 的 1。

确定可推动的额外流量后,我们使用 update_along_path() 更新路径:

ini 复制代码
def update_along_path(self, last: list, amount: float): 
    current: int = self.sink_index
  ❶ while current != self.source_index:
        prev: int = last[current]
        if prev == -1:
            raise ValueError

      ❷ if current in self.edges[prev]:
            self.edges[prev][current].adjust_used(amount)
        else:
            self.edges[current][prev].adjust_used(-amount)
        current = prev

min_residual_on_path() 类似,update_along_path() 使用 while 循环从汇点向源点回溯 last 指针 ❶,并检查前驱节点是否有效。如果有效,先判断路径上边的方向,然后更新使用的流量 ❷。正向边存在于邻接表中,直接增加已用容量;反向边表示算法推动流量回流,边方向相反,不在邻接表中,代码从已用流量中减去新流量。

综合算法

使用深度优先搜索的 Ford-Fulkerson 算法,将本章介绍的各部分组合在一起。正如清单 14-1 所示,算法不断搜索增广路径,找到后计算路径上最小残量,并据此增加流量:

ini 复制代码
def ford_fulkerson(g: Graph, source: int, sink: int) -> ResidualGraph: 
  ❶ residual: ResidualGraph = ResidualGraph(g.num_nodes, source, sink)
    for node in g.nodes:
        for edge in node.edges.values():
            residual.insert_edge(edge.from_node, edge.to_node, edge.weight)

  ❷ done = False
    while not done:
      ❸ last: list = find_augmenting_path_dfs(residual)
      ❹ if last[sink] > -1:
            min_value: float = residual.min_residual_on_path(last)
            residual.update_along_path(last, min_value)
        else:
            done = True

    return residual

代码首先创建一个 ResidualGraph,容量等于原图的权重 ❶,相当于复制图并转换表示。

算法的主循环较小,使用布尔变量 done 跟踪上次迭代是否找到增广路径 ❷。如果找到,done=False,则搜索新增广路径 ❸。代码检查返回路径是否有效 ❹,若有效,使用 min_residual_on_path() 计算最小残量,并通过 update_along_path() 更新路径流量。如果汇点的 last 值为 -1,则说明源点到汇点没有路径,可将 done=True。函数最后返回残量图。

示例

图 14-10 展示了 Ford-Fulkerson 算法在示例图上的运行情况,每个子图表示算法一次迭代后 ResidualGraph 的状态。加粗箭头表示本次迭代找到的增广路径,各边上的容量使用情况通过 X/Y 标注已更新,以充分利用该增广路径。

图 14-10 展示了使用深度优先搜索时,Ford-Fulkerson 算法获取增广路径的顺序。例如,尽管示例图底部沿边 (0, 2)、(2, 5) 和 (5, 6) 存在一条容量为 3 的路径,算法却首先填充一些较小的流量,例如图 14-10(c) 中的容量为 1 的路径。

图 14-10(e) 展示了一条同时使用正向和反向残量的增广路径。为了增加流入汇点的边 (4, 6) 的流量,算法将节点 1 的流量从边 (1, 3) 重定向到边 (1, 4)。这给节点 4 提供了 2 单位的输入流量,可传递至汇点。但这会导致节点 3 缺少 1 单位输入。搜索通过源点沿边 (0, 3) 提供额外流量来弥补节点 3 的输入缺口。

一旦算法计算出 ResidualGraph,我们就可以使用该数据结构回答其他问题。例如,可以使用 compute_total_flow() 函数计算图的最大流量。

Edmonds-Karp 算法

计算机科学家 Yefim Dinitz(以 E.A. Dinic 名义)以及 Jack Edmonds 和 Richard M. Karp 独立发表了对 Ford-Fulkerson 算法的分析,提出选择边数最少的增广路径。这种方法现在称为 Dinitz 算法或 Edmonds-Karp 算法,通过路径选择避免在使用非理性边容量时出现问题,从而对算法的迭代次数进行上界。本节展示如何使用广度优先搜索找到这样的增广路径。

代码实现

Edmonds-Karp 算法的大部分代码沿用前面 Ford-Fulkerson 算法的函数,如 update_along_path()min_residual_on_path()。我们只需修改寻找增广路径的函数及调用它的外层函数。

我们使用修改后的广度优先搜索来寻找增广路径:

ini 复制代码
def find_augmenting_path_bfs(g: ResidualGraph) -> list: 
    seen: list = [False] * g.num_nodes
    last: list = [-1] * g.num_nodes
    pending: queue.Queue = queue.Queue()

  ❶ seen[g.source_index] = True
    pending.put(g.source_index)
  ❷ while not pending.empty() and not seen[g.sink_index]:
        current: int = pending.get()
        for n in g.all_neighbors[current]:
          ❸ if not seen[n] and g.get_residual(current, n) > 0:
              ❹ pending.put(n)
                seen[n] = True
                last[n] = current

    return last

find_augmenting_path_bfs() 函数首先设置标准的广度优先搜索数据结构,包括每个节点是否被访问的列表 seen、路径前驱节点列表 last 和待探索节点队列 pending。使用队列需在文件顶部 import queue。函数将源节点加入队列作为起点 ❶。主 while 循环持续执行,直到队列为空或汇点已被访问 ❷。与深度优先搜索代码一样,这个检查保证在找到任意源点到汇点的路径时即可终止搜索。

在探索当前节点的邻居时,代码同时检查邻居节点未被访问以及边的残量非零 ❸。若边可用且邻居未访问,搜索更新追踪信息并将节点加入队列 ❹。

图 14-11 展示了 find_augmenting_path_bfs() 在部分容量已使用的图上的迭代情况。阴影节点表示已访问节点,虚线圆圈中的节点是刚处理完的节点。

图 14-11(a) 展示了 while 循环开始前的算法状态,而图 14-11(b) 展示了搜索的第一步。访问节点 0 后,算法发现两条边通向未访问且有未使用容量的邻居(节点 1 和节点 3),两者均加入待探索队列。

算法在图 14-11(c) 与标准广度优先搜索有所不同。尽管节点 2 是节点 1 的邻居,但边 (1, 2) 已满,无法再发送流量,因此算法排除使用该边的路径,使节点 2 保持未访问状态。直到图 14-11(d),算法才从节点 3 找到一条可行路径到节点 2。

搜索在图 14-11(e) 完成,此时找到了一条通往汇点的路径。此时,它已经找到一条从源点到汇点的可行路径 [0, 1, 4, 5],无需考虑其他节点。

Edmonds-Karp 算法的顶层代码几乎与 Listing 14-1 中 Ford-Fulkerson 的深度优先搜索版本相同:

ini 复制代码
def edmonds_karp(g: Graph, source: int, sink: int) -> ResidualGraph: 
    residual: ResidualGraph = ResidualGraph(g.num_nodes, source, sink)
    for node in g.nodes:
        for edge in node.edges.values():
            residual.insert_edge(edge.from_node, edge.to_node, edge.weight)

    done = False
    while not done:
      ❶ last: list = find_augmenting_path_bfs(residual)
        if last[sink] > -1:
            min_value: float = residual.min_residual_on_path(last)
            residual.update_along_path(last, min_value)
        else:
            done = True
    return residual

与 Listing 14-1 唯一显著的区别是使用了 find_augmenting_path_bfs() 函数来搜索增广路径 ❶。

示例

图 14-12 展示了在一个包含 8 个节点、11 条边的图上运行 Edmonds-Karp 算法的示例,其中节点 0 为源点,节点 7 为汇点。图 14-12(a) 表示第一次迭代前 ResidualGraph 的状态,所有边的容量均未使用。算法的每一步都在更新每条增广路径后展示,粗体边表示被使用的增广路径。例如,在图 14-12(b) 中,边 (0, 1)、(1, 2) 和 (2, 7) 形成了增广路径。最小残量为 3,子图显示沿该路径增加 3 单位流量后的已用容量。

图 14-12(f) 展示了算法使用反向残量的一步。在仅沿前向边搜索了四轮之后,搜索遇到了瓶颈,于是减少了从节点 5 到节点 4 的边上的流量,以释放更多容量。要理解这一步的作用,可以考虑从节点 0 到节点 5 的流量。在上一步之前,这条路径的流量已经达到最大,边最多只能承载 10 单位的流量。然而,这部分流量并未被最优利用。通过减少从节点 5 到节点 4 的流量,我们可以将更多流量通过节点 6 送入汇点。这使得节点 4 的输入流量少于输出流量。为了解决这个不平衡,我们需要通过另一条路径推送更多流量。在本例中,这额外的 1 单位流量通过路径 [0, 1, 2, 3, 4] 到达节点 4。

模拟日益复杂的真实情况

我们的最大流算法在图的结构上设置了一些限制,以简化对算法的推理。这些限制包括:限制图只有单一源点和单一汇点,以及禁止反向平行边。本节探讨如何放宽这些限制,从而模拟越来越复杂的现实情况。

多源点

许多现实世界的流网络中存在不止一个源节点。例如,考虑本章一直使用的更贴近实际的污水问题。与其只有单一的进水管,更可能的是网络中会包含来自每栋建筑的管道。即便在城市层面建模,也可以预期周边郊区会不断加入新的源点。图 14-13(a) 展示了一个包含三个源节点的网络。

幸运的是,我们可以通过添加一个新的人工源节点 s′s' 来轻松扩展流网络模型,这个节点实际上为之前的每个源点提供流量。新的源节点通过有向边连接到每个原来的源节点。反过来,这些原来的源点在扩展模型中变为内部节点,如图 14-13(b) 所示。粗体箭头表示从新节点到原先源节点新增的边。

当然,这个人工源节点在现实中并不存在。城市的雨水排水系统并不是由某个神秘的超级排水管提供水流。相反,这个聚合源只是一个方便的数学抽象,使我们能够将所有流量的源头统一归到一个单一(虚拟)节点上。

添加新节点和边后,就产生了如何选择这些新边容量的问题。如果设置容量过低,这些边会成为瓶颈,阻碍我们准确地建模问题。然而,如果将边的容量设置得过高也无妨,因为瓶颈仍然会出现在原网络中已有的瓶颈上。因此,我们可以在这些新边上使用无限容量,为原来的源点提供它们能处理的全部流量。在污水系统的背景下,这些就相当于比工程师实际能建造的管道流量大得多的巨大管道。

我们可以创建一个辅助函数,用于将任意多源图增加一个聚合源,如 Listing 14-2 所示:

arduino 复制代码
def augment_multisource_graph(g: Graph, sources: list) -> int: 
  ❶ new_source: Node = g.insert_node()

  ❷ for old_source in sources:
        g.insert_edge(new_source.index, old_source, math.inf)
    return new_source.index

Listing 14-2:将多源图转换为单源图

代码首先在图中插入一个新节点 ❶。然后,对于每个原来的源节点,从新源节点创建一条容量为无限的边到原源节点 ❷。最后,函数返回新源节点的索引,以便在调用 Ford-Fulkerson 算法时使用。

多汇点

正如许多现实问题存在多个源点,我们也经常遇到具有多个汇点的网络。例如,考虑州际公路系统,车辆沿道路流向多个不同的目的地。图 14-14(a) 展示了一个具有两个汇点的网络。

我们可以借鉴处理多源问题的方法来处理多汇点问题。我们创建一个新的聚合汇点 t′t',并从每个原汇点到新聚合汇点建立有向边,如图 14-14(b) 所示。新节点和新边用粗体表示。我们为每条边分配足够的容量,以确保它们不会产生新的瓶颈。

同样,我们提供一个辅助函数,用于将给定图增加多个汇点:

arduino 复制代码
def augment_multisink_graph(g: Graph, sinks: list) -> int: 
    new_sink: Node = g.insert_node()

    for old_sink in sinks:
        g.insert_edge(old_sink, new_sink.index, math.inf)
    return new_sink.index

这段代码沿用了 Listing 14-2 中 augment_multisource_graph() 函数的形式。它在图中插入一个新的汇点节点,为每个原汇点创建容量足够的边,并返回新节点的索引。

反向平行边

在现实场景中,禁止反向平行边也是不现实的。继续以州际公路系统为例,几乎每条高速公路都是双向的:你可以从克利夫兰开车到布法罗,也可以从布法罗开车回克利夫兰。

我们可以使用另一个数学技巧来支持这种现实情况,同时仍保持图中不得存在反向平行边的限制。对于一个包含边 <math xmlns="http://www.w3.org/1998/Math/MathML"> ( u , v ) (u, v) </math>(u,v) 容量为 <math xmlns="http://www.w3.org/1998/Math/MathML"> w 1 w_1 </math>w1 和边 <math xmlns="http://www.w3.org/1998/Math/MathML"> ( v , u ) (v, u) </math>(v,u) 容量为 <math xmlns="http://www.w3.org/1998/Math/MathML"> w 2 w_2 </math>w2 的环,如图 14-15(a) 所示,我们可以添加一个新节点 <math xmlns="http://www.w3.org/1998/Math/MathML"> x x </math>x,并将边 <math xmlns="http://www.w3.org/1998/Math/MathML"> ( u , v ) (u, v) </math>(u,v) 替换为两条边 <math xmlns="http://www.w3.org/1998/Math/MathML"> ( u , x ) (u, x) </math>(u,x) 和 <math xmlns="http://www.w3.org/1998/Math/MathML"> ( x , v ) (x, v) </math>(x,v),如图 14-15(b) 所示。如果对两条新边 <math xmlns="http://www.w3.org/1998/Math/MathML"> ( u , x ) (u, x) </math>(u,x) 和 <math xmlns="http://www.w3.org/1998/Math/MathML"> ( x , v ) (x, v) </math>(x,v) 使用原边 <math xmlns="http://www.w3.org/1998/Math/MathML"> ( u , v ) (u, v) </math>(u,v) 的容量 <math xmlns="http://www.w3.org/1998/Math/MathML"> w 1 w_1 </math>w1,则扩展路径允许的总流量保持不变(仍为 <math xmlns="http://www.w3.org/1998/Math/MathML"> w 1 w_1 </math>w1)。

我们可以定义一个预处理步骤,遍历图中的所有边,并在必要时插入额外的节点和边。如果原始图中存在一条边 (origin,destination)(origin, destination) 以及它的反向边 (destination,origin)(destination, origin),那么就出现了反向平行边,这时需要插入一个新的节点。

这为什么重要

最大流问题可以用于解决广泛的现实分析和优化问题。除了简单地求出源点到汇点的最大流量外,解决最大流问题的方法还提供了对网络本身的关键洞察:我们可以利用残量图来发现瓶颈,或者找出哪些连接还有富余容量。例如,假设对一个拟建的污水处理系统进行分析,发现容量为每分钟 50 加仑的管道由于网络中其他限制,只能使用每分钟 10 加仑的流量。我们现在就知道,这条管道是一个明显的节约成本机会。

本章介绍的算法方法也为处理图提供了新的思路。CapacityEdge 数据结构是对标准边的扩展,它可以跟踪动态的流量,而随着流量的变化,残量图中的路径也会发生改变。这是我们第一次看到需要考虑与边相关的动态量的算法。

正如下一章所示,最大流算法可以扩展到更一般的匹配问题,包括优化节点对之间的连接。我们还将看到这些技术如何应用于更抽象的最大基数二分图匹配问题。

相关推荐
阿里云视频云2 小时前
实战揭秘|魔搭社区 + 阿里云边缘云 ENS,快速部署大模型的落地实践
云计算·边缘计算·cdn
秋难降3 小时前
【数据结构与算法】———深度优先:“死磕 + 回头” 的艺术
数据结构·python·算法
数据智能老司机3 小时前
图算法趣味学——图着色
数据结构·算法·云计算
数据智能老司机3 小时前
图算法趣味学——启发式引导搜索
数据结构·算法·云计算
John.Lewis4 小时前
数据结构初阶(8)二叉树的顺序结构 && 堆
c语言·数据结构·算法
SimonSkywalke4 小时前
基于知识图谱增强的RAG系统阅读笔记(七)GraphRAG实现(基于小说诛仙)(一)
算法
再睡一夏就好5 小时前
【排序算法】④堆排序
c语言·数据结构·c++·笔记·算法·排序算法
不是二师兄的八戒5 小时前
阿里云KMS完全指南:从零开始的密钥管理实践
数据库·阿里云·云计算
再睡一夏就好5 小时前
【排序算法】⑥快速排序:Hoare、挖坑法、前后指针法
c语言·数据结构·经验分享·学习·算法·排序算法·学习笔记