48·图算法高级

网络流基础

network-flowmax-flowmin-cutford-fulkerson

第 48 课 - 网络流基础

学习目标

通过本课学习,你将能够:

  1. 理解网络流的核心概念,包括源点、汇点、容量和流量。
  2. 掌握最大流问题的定义及其与增广路径的关系。
  3. 理解并实现基础的 Ford-Fulkerson 算法(使用 DFS 寻找增广路径)来计算最大流。
  4. 初步了解最大流最小割定理,并认识其重要性。

核心概念

1. 什么是网络流?

想象一个供水管道网络。每条管道都有其最大输水能力(容量)。我们有一个水源(源点 s)和一个需要供水的目的地(汇点 t)。我们希望找到一个从源点到汇点的送水方案,使得总输水量最大,同时保证任何一条管道中的实际流量不超过其容量。这就是网络流(Network Flow) 问题。

  • 有向图 G = (V, E): 代表网络,V 是顶点集合,E 是有向边(管道)集合。
  • 容量函数 c(u, v): 对每条边 (u, v) ∈ Ec(u, v) ≥ 0 表示该边的最大通过能力。如果 (u, v) ∉ E,则 c(u, v) = 0
  • 流函数 f(u, v): 表示边 (u, v) 上的实际流量。它必须满足:
    • 容量约束: 0 ≤ f(u, v) ≤ c(u, v)
    • 流守恒: 对于除源点 s 和汇点 t 的任何中间节点 v,所有流入 v 的流量之和等于所有从 v 流出的流量之和。

最大流问题:在满足上述约束的前提下,找到一个从 st 的流 f,使得从 s 流出的总流量(或等价地,流入 t 的总流量)最大化。这个最大总流量称为最大流值

2. 增广路径与残量网络

解决最大流问题的一个核心思想是增广路径(Augmenting Path)。它是在残量网络中一条从 st 的路径。

  • 残量网络 Gf: 给定一个当前流 f,残量网络 Gf 是一个与原图结构相似的图,它包含了所有还能增加流量的边。

    • 对于原图中的每条边 (u, v),如果 f(u, v) < c(u, v),则 Gf 中包含一条正向边 (u, v),其残量容量为 c(u, v) - f(u, v)
    • 关键的一点是,Gf 中也包含反向边 (v, u),其残量容量为 f(u, v)。这相当于给了算法一个“撤销”之前流量分配的机会,允许流量在后续迭代中被重新调整,这是算法能够找到最优解的关键。
  • 增广路径: 在残量网络 Gf 中任何一条从 st 的路径 P。沿着这条路径,我们可以增加 min_{(u,v) ∈ P} (c_f(u, v)) 的流量,这个值称为路径 P瓶颈容量

代码示例

下面是一个使用 Python 实现的 Ford-Fulkerson 算法(采用 DFS 寻找增广路径)。我们将用一个邻接字典来表示网络,其中键是节点,值是其邻居及对应的容量字典。

class MaxFlow:
    def __init__(self, graph):
        """
        初始化最大流求解器。
        :param graph: 邻接表表示的图。例如:{'s': {'A': 10, 'B': 5}, 'A': {'B': 15, 't': 10}, ...}
        """
        self.graph = graph # 原始容量图
        self.flow = {}     # 存储当前流,格式与graph相同,但存储流量值
        self._init_flow()
        
    def _init_flow(self):
        """初始化流量图,所有边的流量初始为0。"""
        for u in self.graph:
            self.flow[u] = {}
            for v in self.graph[u]:
                self.flow[u][v] = 0
                # 确保反向边的流量字段存在,即使原始图中没有这条边
                if v not in self.flow:
                    self.flow[v] = {}
                if u not in self.flow[v]:
                    self.flow[v][u] = 0

    def ford_fulkerson(self, source, sink):
        """
        使用Ford-Fulkerson算法计算从source到sink的最大流。
        :param source: 源点
        :param sink: 汇点
        :return: 最大流值
        """
        max_flow = 0
        parent = {} # 用于在DFS中记录路径
        
        while self._dfs(source, sink, parent):
            # 找到一条增广路径,计算瓶颈容量
            path_flow = float('inf')
            v = sink
            while v != source:
                u = parent[v]
                # 瓶颈容量是路径上残量容量的最小值
                residual_capacity = self._get_residual_capacity(u, v)
                path_flow = min(path_flow, residual_capacity)
                v = u
            
            # 沿路径更新流量
            v = sink
            while v != source:
                u = parent[v]
                # 增加正向边流量
                self.flow[u][v] += path_flow
                # 减少反向边流量(等价于增加反向边的残量容量)
                self.flow[v][u] -= path_flow
                v = u
            
            max_flow += path_flow
            parent.clear() # 清空路径记录,准备下一次DFS
        
        return max_flow

    def _dfs(self, source, sink, parent):
        """
        深度优先搜索寻找一条从source到sink的增广路径(在残量网络中)。
        :return: 是否找到路径
        """
        visited = set()
        stack = [source]
        visited.add(source)
        
        while stack:
            u = stack.pop()
            # 遍历所有邻接节点(包括正向和反向边)
            for v in self.graph.get(u, {}):
                # 检查正向边的残量容量
                residual = self._get_residual_capacity(u, v)
                if v not in visited and residual > 0:
                    parent[v] = u
                    if v == sink:
                        return True # 找到增广路径
                    visited.add(v)
                    stack.append(v)
            # 注意:为了简化,这个DFS没有显式遍历所有可能的反向边。
            # 实际上,我们的残量网络是通过`_get_residual_capacity`函数动态计算的,
            # 它已经考虑了原始图和当前流,隐含了反向边的概念。
        return False

    def _get_residual_capacity(self, u, v):
        """计算边(u, v)在残量网络中的容量。"""
        # 原始图的容量(如果存在)
        original_capacity = self.graph.get(u, {}).get(v, 0)
        # 当前流的流量
        current_flow = self.flow.get(u, {}).get(v, 0)
        # 残量容量 = 原始容量 - 当前流
        return original_capacity - current_flow

# 使用示例
if __name__ == "__main__":
    # 定义一个示例网络图(邻接表)
    # s -> A (10), s -> B (5)
    # A -> B (15), A -> t (10)
    # B -> t (10)
    graph = {
        's': {'A': 10, 'B': 5},
        'A': {'B': 15, 't': 10},
        'B': {'t': 10},
        't': {} # 汇点没有出边
    }
    
    solver = MaxFlow(graph)
    source = 's'
    sink = 't'
    max_flow_value = solver.ford_fulkerson(source, sink)
    
    print(f"从 {source}{sink} 的最大流值为: {max_flow_value}")
    # 输出应为: 从 s 到 t 的最大流值为: 15
    # 解释:路径1: s->A->t (流量10),路径2: s->B->t (流量5),总流量15。
    # 注意:虽然A->B的容量是15,但s->A只有10,所以无法充分利用。

实践练习

练习 1:基础计算

在上面的代码示例网络中,手动模拟一次算法的执行过程。写出前两条增广路径以及它们的瓶颈容量。最终的最大流值是否与代码输出一致?

练习 2:设计自己的网络

创建一个新的网络流图,至少包含 5 个节点(包括源点和汇点),使得最大流值能够达到某个你设定的值(例如 20)。用代码验证你的设计。 提示:确保你设计的图中,源点有足够容量的出边,汇点有足够容量的入边,并且中间路径相互连接。

练习 3:理解最大流最小割

研究上面代码运行结束后,流量分布(self.flow 字典)。找出一个(一组边,移除它们后 st 不再连通),使得割中边的容量之和等于你计算出的最大流值。这个割就是最小割。尝试写出你的割。

常见错误

  1. 忽略反向边的作用:这是初学者最常见的错误。在残量网络中,反向边 (v, u) 的容量等于当前流 f(u, v)。它允许算法在后续迭代中将 u -> v 的部分流量“退回”,从而调整之前的非最优分配。如果实现时没有正确处理反向边,算法可能无法找到最大流。
  2. 错误的 DFS 实现:DFS 在寻找增广路径时,需要遍历所有邻接边,并正确计算残量容量。常见的错误包括:没有考虑所有可能的边(特别是当图是用邻接矩阵表示时,需要遍历所有节点),或者在更新流量时忘记同时更新正向和反向边的流量。
  3. 误解“增广路径”的含义:增广路径存在于残量网络中,而不是原始容量图中。残量网络是动态变化的,每进行一次流量更新后都需要重新计算。在代码中,这通常通过实时计算 original_capacity - current_flow 来实现,而不是维护一个独立的残量图数据结构。
  4. 无法终止:如果容量值是无理数,Ford-Fulkerson 算法可能不会终止。在实际编程中,我们通常假设容量为整数或有理数。对于整数容量,算法保证终止,且最大迭代次数不超过最大流值。

小结

  • 网络流是一个在有向图中,满足容量约束和流守恒条件下,最大化从源点到汇点流量的问题。
  • 残量网络增广路径是解决最大流问题的关键工具。残量网络反映了当前状态下还可以增加(或减少)的流量潜力。
  • Ford-Fulkerson 算法的核心思想是:不断地在残量网络中寻找增广路径,并沿着该路径增加流量,直到找不到为止。其中,反向边 的概念至关重要,它使得流量可以被重新分配。
  • 使用深度优先搜索(DFS)寻找增广路径的 Ford-Fulkerson 方法,时间复杂度为 O(E * max_flow),对于大整数流可能较慢。更高效的算法如 Edmonds-Karp(使用 BFS)或 Dinic 算法会在后续课程中探讨。
  • 最大流最小割定理是网络流理论中最深刻的结论之一:在一个网络中,最大流的值等于最小割的容量。这个定理将流问题与割问题(一个图划分问题)联系起来,在理论和应用上都非常重要。
  • 掌握了网络流基础后,你将能够解决许多看似不同的问题,如二部图匹配(下一课内容)、项目选择、棒球比赛淘汰赛等,它们都可以转化为网络流模型。

练习编辑器

rust
Loading...

继续学习

完成本课后,建议继续学习下一课「二分图匹配」 以巩固所学知识。