ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

最大流算法详解:从Ford-Fulkerson到Dinic的工程实践

最大流算法详解:从Ford-Fulkerson到Dinic的工程实践 网络流里的最大流问题说实话是我当年学算法时卡得比较久的一个点。教科书上讲Ford-Fulkerson算法往往一页纸就带过去了配一张看起来很简单的图但真到自己动手写代码、跑数据、调bug的时候才会发现里面藏着不少细节。这篇把我从“看懂定义”到“能写对代码、能建对模型”过程中踩过的坑、理顺的思路一并整理出来如果你也正在跟最大流、最小割这些概念较劲希望能帮你省点时间。1. 最大流问题到底在解决什么1.1 一个水管网络的直观比喻想象你有一个自来水厂源点s要通过一张管道网络把水送到一个小区汇点t。管道之间有很多中转站中间节点每根管道都有自己固定的粗细容量上限单位时间内能通过的水量是有限的。现在问你在这个网络结构下自来水厂到小区之间单位时间内最多能送多少水这就是最大流问题的原型。这个比喻虽然简单但抓住了问题的三个核心要素一个有向图、每条边上的容量限制、一个源点加一个汇点。你要做的是在满足“每条边流量不超过容量”的前提下让从s到t的总流量最大化。学这个东西的时候我最大的困惑是现实生活中水在管道里是会分流、会汇合的我是不是要在每个节点上考虑流量守恒对这就是这个问题里“流”的严格定义的一部分——除了源点和汇点其他每个节点流入的量必须等于流出的量不能凭空多出来也不能凭空消失。这样定义之后“最大流”才是一个具体的、可以计算的数学对象。1.2 问题的数学化定义把直觉翻译成形式化定义其实就三条容量约束对每条边(u,v)流量f(u,v)满足 0 ≤ f(u,v) ≤ c(u,v)。流量守恒对每个非源非汇的节点v所有流入v的流量之和等于所有从v流出的流量之和。目标最大化从源点s流出的总流量等价于流入汇点t的总流量。这三条看着简单但很多人在后面学算法的时候会忘记它们。比如有人会把DFS找到的一条路径上的流量累加就算完了完全无视“这个加法会不会破坏流量守恒”。实际上Ford-Fulkerson算法的每一步都在维护这三条约束理解了这一点后面看增广路径、残余网络就不会晕。我这里再补一句最大流问题里允许有环允许有多条边甚至允许有容量为0的边。这些情况在建模时很常见算法本身不需要特殊处理但如果你用邻接矩阵存图重边会直接把数据覆盖掉这坑我后面会细说。2. 从零推导Ford-Fulkerson核心思路2.1 残余网络与增广路径Ford-Fulkerson算法没有特别高深的理论它就是一个朴素的贪心想法只要还存在一条从s到t的路径使得路径上每条边都还有剩余容量那我就沿着这条路径多送一些流量过去。反复做直到找不到这样的路径为止。这里“每条边都还有剩余容量”的路径就叫增广路径augmenting path。而“剩余容量”这个说法就需要引入残余网络residual network的概念。残余网络里的边有两种正向边容量为原容量减去当前已用流量即 c(u,v) - f(u,v)表示还能再送多少。反向边容量为当前已用流量 f(u,v)表示“最多能退回去多少”。一开始学的时候大多数人能理解正向边但觉得反向边很别扭我也一样。反向边的作用我用一个例子讲清楚你再看就会豁然开朗。假设图里有三条边s→aa→bb→t每条容量都是1。另外有一条s→b容量也是1还有一条a→t容量也是1。这个网络结构决定了最大流是2一条路走s→a→t另一条走s→b→t各送1个单位。但如果你一开始贪心选了s→a→b→t这条路把s→a、a→b、b→t全部用满了那请问接下来还能不能再找到一条增广路径从残余网络看s→a容量为0走不了增广路径断了算法以为最大流是1。这显然是错的。错在哪错在第一次增广时选了a→b这条边而这个选择导致a到t的边没法用了。如果有一种机制让我“撤销”这个决定把a→b上的流量退回去改成走a→t就能找到最优解。反向边就是干这个的。当你在a→b上流过1个单位的流量后残余图里会多一条b→a、容量为1的反向边。于是第二次增广时路径可以是s→b→a→ts→b容量1b→a是通过反向边“退回”流量a→t容量1。这样一来等效的最终结果是s→a→t送1s→b→t送1总流量2正确。这个例子我建议你亲手画一遍把每次增广后的正向边和反向边容量都标出来。很多教材上不讲清楚反向边为什么存在我当年就是看了好几遍才醒悟过来——它本质上是给算法一个“反悔”的机会让贪心策略不至于因为一次错误的选择而陷入局部最优。2.2 为什么反向边是算法的灵魂如果把反向边去掉Ford-Fulkerson算法就退化成“每次沿一条容量最大的路径送流”这种策略很容易被上面的小例子击败。可以说反向边让算法有了回溯能力是它能保证最终收敛到最大流的核心原因。从另一个角度理解你实际运送的流量是 net flow正向边上的流量减去反向边上的流量才是这条边真正的流量。反向边上的流量不代表真的有水倒流它只是用来表示“之前分配在这里的流量被撤回了”。这个视角在证明算法的正确性时很有用在调代码的时候也很有用——一旦你发现某条边的两个方向都有流量不要慌它们相减之后才是真实流量。所以写代码时一定不要省略反向边。我在初学阶段犯过这个错误省掉了反向边结果算法在小规模随机图上时而正确时而不对调试起来非常痛苦。之后我学乖了任何涉及Ford-Fulkerson的实现里加边的时候同时加正向边和反向边容量分别为 c 和 0这是标准做法。2.3 算法完整流程把上面的概念攒到一起Ford-Fulkerson方法就是下面这四步循环初始化所有边的流量为0。在当前的残余网络里找一条从s到t的增广路径。如果找不到则当前流量就是最大流算法结束。如果找到了记路径上最小的残余容量为delta把路径上所有正向边容量减去delta所有反向边容量加上delta同时总流量加上delta回到第2步。注意第4步的操作为什么是“正向边容量减delta、反向边容量加delta”因为残余网络是动态变化的你每增广一次图的结构就变了。下次找增广路径是在新的残余网络上找。这就意味着增广路径上出现的边既有可能是原来的原始边也有可能是之前增广产生的反向边两者统一处理不加区分。“找到一条增广路径”这一步可以用DFS也可以用BFS。用DFS的版本是教科书里的标准Ford-Fulkerson用BFS的版本叫Edmonds-Karp是前者在时间复杂度上的重要改进我下一节专门讲。3. 代码实现与关键细节3.1 DFS版本实现先给一个能直接跑的Python实现。使用邻接矩阵存储容量图用DFS寻找增广路径。class FordFulkerson: def __init__(self, n): self.n n self.capacity [[0] * n for _ in range(n)] def add_edge(self, u, v, cap): self.capacity[u][v] cap def dfs(self, s, t, visited): if s t: return float(inf) visited[s] True for v in range(self.n): if not visited[v] and self.capacity[s][v] 0: flow self.dfs(v, t, visited) if flow 0: self.capacity[s][v] - flow self.capacity[v][s] flow return min(flow, self.capacity[s][v] flow) # 这个写法有歧义见下文修正 return 0 def max_flow(self, s, t): total_flow 0 while True: visited [False] * self.n flow self.dfs(s, t, visited) if flow 0: break total_flow flow return total_flow上面这段代码里我故意留了一个错误就是dfs返回时处理flow和容量关系的写法有问题。初学者很容易在“返回路径上的最小残余容量”和“更新这路径上每条边的容量”之间搞混。正确写法应该把“找路径”和“更新容量”分开——DFS先找到了路径上的最小容量delta然后再回溯更新。下面这段是修正过的版本逻辑更清晰class FordFulkerson: def __init__(self, n): self.n n self.capacity [[0] * n for _ in range(n)] def add_edge(self, u, v, cap): # 注意遇到重边时累加容量 self.capacity[u][v] cap def dfs_find_path(self, s, t, visited): 在残余网络中寻找增广路径返回路径上的最小残余容量找不到返回0 if s t: return float(inf) visited[s] True for v in range(self.n): if not visited[v] and self.capacity[s][v] 0: delta self.dfs_find_path(v, t, visited) if delta 0: return min(delta, self.capacity[s][v]) return 0 def dfs_update(self, s, t, delta): 沿着增广路径更新容量图实际场景中可以合并到dfs_find_path中 pass def max_flow(self, s, t): total_flow 0 while True: visited [False] * self.n delta self.dfs_find_path(s, t, visited) if delta 0: break # 这里需要再走一遍路径去更新容量实际更常见的做法是在递归返回时直接更新 total_flow delta return total_flow这样分开写虽然直观但效率不高因为会走两遍路径。实际更常见的做法是在DFS回溯返回的时候同时完成容量更新但是要注意你需要确保返回的是这条路径上的最小残余容量而不是某一条子路径的最小值。我建议先实现一个明确分开的版本确认逻辑正确后再改成回溯更新的写法减少调试难度。实现时有个细节visited数组必须在一条路径的搜索过程中共享。如果你每个节点递归时新建visited数组算法会退化成一棵搜索树不仅慢还可能找出无效路径。3.2 为什么用BFSEdmonds-Karp优化教科书上教的用DFS实现Ford-Fulkerson理论上最坏情况下会非常慢。为什么因为找增广路径的顺序会影响需要增广的次数。有一个经典例子每次DFS都可能选到一条“绕远路”的路径导致每次只能增广1个单位流量需要增广的次数等于最大流的值。如果最大流是1e9这个算法直接跑死。解决方式是用BFS而不是DFS来找增广路径每次选择“边数最少”的最短增广路径。这个改进就是Edmonds-Karp算法。它的时间复杂度有严格的上界O(V·E²)在竞赛和实际工程里比朴素的DFS版本可靠得多。BFS实现也很简单把dfs_find_path改成bfs_find_path仍然需要在路径上记录每个节点的前驱然后从汇点一直回溯到源点找出路径上所有边并找到最小残余容量最后统一更新容量。3.3 手写容量图的实操经验实际用邻接矩阵还是邻接表我用下来这么判断点数少几百以内边数多邻接矩阵简单直接不容易写错推荐新手。点数中等几千用邻接表存储边但反向边要成对存储即边号i和i^1互为反向边。这是竞赛里常用的链式前向星写法。关键注意点重边必须累加容量不能覆盖。反向边的初始容量是0不是负容量。每次增广时正向边减少delta反向边增加delta对称更新。如果容量是浮点数DFS的终止条件不要用capacity 0要加一个小阈值判断否则可能因为精度问题陷入死循环。4. 最大流最小割把对流量的理解升级4.1 割到底是什么“最小割”这个词听起来好像跟最大流是两回事但最大流最小割定理告诉我们最大流的值等于最小割的容量。要理解这个定理先得理解割。把图的所有点分成两个集合S和T要求源点s在S里汇点t在T里。那么所有从S连向T的边的容量之和就叫这个割的容量。最小割就是所有可能的划分方式里容量最小的那一个。用之前水管的比喻就是你想切断水厂到小区之间的所有通路怎么切才能让被切断的管道总容量最小最小割就是最优的切法。4.2 最大流等于最小割的直觉解释为什么最大流会等于最小割先看为什么最小割是最大流的上界无论你怎么流从s到t的总流量必然要经过S和T之间的那些边因为s在S里t在T里流量要跨过这条分界线。而S→T边上的总流量不可能超过这些边的总容量所以任何一个割的容量都是最大流的上界。这就得出最大流 ≤ 最小割容量。再看为什么能达到这个上界Ford-Fulkerson算法终止的时候找不到增广路径了。定义S为从s出发、在残余网络中还能到达的点集合T为其余点。那么所有从S到T的原始边其正向容量一定为0或已经被用满所有从T到S的原始边其反向容量也为0或没有流量。这个割的容量恰好等于当前总流量。于是我们构造了一个容量等于当前流量的割。既然最大流 ≤ 这个割容量而当前流量又等于这个割容量那当前流量只能是最大流这个割也只能是最小割。这个证明的思路很重要因为它的副产品是用来找到最小割集合的直接方法算法跑完后从源点做一次BFS/DFS能到达的节点集合就是S集合剩下的就是T集合。4.3 怎么从流结果反推最小割很多应用场景不只是要“最大流的值”还需要知道“最小割具体割了哪些边”。比如在图像分割、项目选择的模型里你知道哪条边被切掉才知道哪些资源被舍弃了。方法很简单运行最大流算法直到没有增广路径。从源点s出发在最终的残余网络上做一次DFS/BFS标记所有能到达的点。所有连接“可到达的点”和“不可到达的点”的原始边构成最小割。注意这里说的“可到达”是在最终残余网络上能到达不是原始图。因为有些点虽然在原始图上有边相连但那条边的剩余容量已经是0了走不过去。5. 最大流算法的实际应用与建模5.1 解决二分图最大匹配问题很多人可能不知道最大流最经典的应用之一是二分图最大匹配。给定一个二分图左边是一组点右边是一组点边表示可以配对求最多能配出多少对。方法是建一个超源点s从s连向左边每个点容量为1保留所有左右之间的边容量为1再建一个超汇点t右边每个点连向t容量为1。然后跑一遍最大流最大流的值就是最大匹配数而网络里那些流量为1的左右边就是匹配结果。为什么容量设为1就能保证每个点只用一次因为s到左点的容量是1意味着左点最多输出1右点到t的容量是1意味着右点最多接收1。中间的边容量1只是表示“这条配对关系最多被用一次”。这个建模思路非常经典值得好好体会。它把配对问题变成了一个纯图上的流量问题是最大流应用里最直接的案例。5.2 图像分割Graph Cut的实质图像分割也是最大流最小割的典型应用。每个像素是图上的一个节点相邻像素之间连边边的容量表示“如果强行把这两个像素分到不同的区域需要付出的代价”。另外每个节点还跟“前景”和“背景”两个特殊节点相连边的容量由模型对每个像素属于前景还是背景的置信度决定。然后求背景节点到前景节点的最小割被割出来的边就界定了分割的轮廓。这个场景里你实际关心的是最小割而不是最大流但算法上还是用最大流解因为两边是同一个问题的两面。这也是我在工作中最常用到最大流算法的场景之一建模这一步决定效果求最大流这一步反而显得“只是工具”。5.3 其他常见问题建模项目选择有若干个项目和若干种资源收益和成本都是二元的如何取舍使净利润最大建图时源点连项目、成本连汇点、项目依赖关系连边求最小割。人员排班/任务分配限制每个人最多做几个任务、每个任务需要几个人直接用容量限制表达。路径不交问题把每个点拆成两个点中间连容量为1的边从而保证“一个点最多被经过一次”。通信网络带宽规划把链路带宽作为容量求任意两节点间的最大带宽。建模的核心是把“约束条件”变成“边的容量”把“目标”变成“流的集合”或“割的代价”。多练几个经典模型之后看到新问题时就会自然往这个方向思考。6. 常见问题与排查技巧实录6.1 忽视反向边导致算法错误这是新手最容易犯的错误。如果没有反向边不仅可能得到错误的答案更可能在特殊图上出现“第一次贪心选了差路径后续再也找不到纠正机会”的情况代码在简单例子上碰巧对了但一到随机数据就出错。排查方法如果你怀疑反向边有问题可以构造一个反例。比如s→a→b→t和s→b→a→t的经典例子如果代码只能得出最大流1而不是2那就说明反向边处理不对。建议在实现里打印每一步更新后的容量图亲眼看看反向边容量有没有加进去。6.2 浮点容量带来死循环当容量值是浮点数时比如0.1、0.2这类增广路径上的最小流量delta也变成浮点数。每次减掉之后如果精度误差积累残余容量可能永远大于某个极小的阈值算法就很难终止。解决方案有两个尽量把容量转化为整数再跑比如乘10000跑完再除回来。如果必须用浮点就把判断条件从capacity 0改成capacity 1e-9同时给递归或循环加一个最大迭代次数上限防止极端情况卡死。注意这个坑不仅仅是理论上的我在处理连续数值型建模时真实遇到过。容量是小数本身没问题但浮点计算带来的误差累积对“反复增广”的算法伤害很大。6.3 邻接矩阵过大导致内存爆炸如果节点数超过5000邻接矩阵O(n²)就会变成25,000,000个格子即使一个格子用int占4字节也要100MB左右很多环境扛不住。解决办法是改用邻接表边数组每条边存“目标点to、容量cap、反向边在数组中的下标rev”。加边时添加两条边正向边的下标为m反向边的下标为m^1也可以用m1这样可以通过异或快速找到反向边。更新容量时正向边减delta反向边加delta。下面给一个链式前向星风格的Python实现参考class Edge: def __init__(self, to, rev, cap): self.to to self.rev rev self.cap cap class Dinic: def __init__(self, n): self.n n self.graph [[] for _ in range(n)] def add_edge(self, fr, to, cap): forward Edge(to, len(self.graph[to]), cap) backward Edge(fr, len(self.graph[fr]), 0) self.graph[fr].append(forward) self.graph[to].append(backward)6.4 Edmonds-Karp还是DFS版本怎么选如果网络规模很小几百个点、几百条边DFS版本完全够用代码也简单。但如果你不确定数据规模或者网络结构比较特殊最好直接用Edmonds-Karp或者Dinic。Dinic算法是目前解决最大流问题最常用的算法之一它在BFS分层的思路上再引入多路增广实际性能远超Edmonds-Karp。虽然这篇主要讲Ford-Fulkerson但如果你的使用场景数据量大我建议你学会Dinic再上生产环境。我个人的习惯竞赛或工程里默认写Dinic但讲解和教学、以及快速验证模型时不排斥用简单的DFS版本。理解Ford-Fulkerson是理解Dinic的基础别为了快而跳过它。6.5 最小割结果集合不唯一我遇到过一种情况跑完算法后想根据最小割集合判断哪些边被切了但发现不同实现或不同增广顺序下割集合不完全一样。这是正常的。最小割可以不唯一。只在最终残余网络上做从源点出发的BFS得到的是“从左到右最常见的那个最小割”但并不是唯一的最小割。如果业务上对割集合有要求你需要额外加约束条件来限定结果或者接受任意一个最小割集合。还有一个常见疑惑为什么有时候最小割的容量等于最大流的值但割集合看起来不符合直觉因为割的容量是S到T的边容量之和而不考虑T到S的边。所以即使有从T指向S的大容量边它们也不会算进割容量里。这个“有向割”的概念不要忘了。7. 从Ford-Fulkerson到Dinic的进阶路径学完Ford-Fulkerson之后下一步基本都会转向Dinic算法。Dinic的核心优化有两点用BFS给每个节点打上层次号level只允许流量从层次低的点流向层次高的点这样可以避免环路造成的无效搜索。用DFS一次找多条增广路而不是每次只找一条。配合当前弧优化当前弧指在DFS中每个点当前已经遍历到的边下次直接从这条边继续实际效率提升非常明显。这里我用一个简短Dinic实现作为示例方便你对照着理解from collections import deque class Dinic: def __init__(self, n): self.n n self.graph [[] for _ in range(n)] def add_edge(self, fr, to, cap): forward Edge(to, len(self.graph[to]), cap) backward Edge(fr, len(self.graph[fr]), 0) self.graph[fr].append(forward) self.graph[to].append(backward) def bfs_level(self, s, t): self.level [-1] * self.n q deque() q.append(s) self.level[s] 0 while q: v q.popleft() for e in self.graph[v]: if e.cap 0 and self.level[e.to] 0: self.level[e.to] self.level[v] 1 q.append(e.to) return self.level[t] 0 def dfs_flow(self, v, t, f): if v t: return f for i in range(self.it[v], len(self.graph[v])): self.it[v] i e self.graph[v][i] if e.cap 0 and self.level[v] 1 self.level[e.to]: ret self.dfs_flow(e.to, t, min(f, e.cap)) if ret 0: e.cap - ret self.graph[e.to][e.rev].cap ret return ret return 0 def max_flow(self, s, t): flow 0 INF 10 ** 18 while self.bfs_level(s, t): self.it [0] * self.n while True: f self.dfs_flow(s, t, INF) if f 0: break flow f return flow这个实现里it数组就是当前弧优化。它记录每个节点已经用到了哪条边避免每次DFS都从第一条边重新扫描。实测下来Dinic在大多数网络流模型上都非常快是值得优先选择的算法。8. 我踩过的那些坑以及一些小建议最后分享一些我在实际工作中经常用到的经验希望对你有帮助。第一测试最大流代码时不要只看正确答案还要打印出每次增广的路径和剩余容量图。原因是很多错误在结果正确时也可能隐藏着比如反向边没更新到位但在某些图上恰好不影响结果。打印中间状态能帮你直观理解算法执行过程也能快速定位问题。第二建图的时候把容量明确为整数能避开浮点精度问题。如果建模时遇到小数容量我一般会全局乘一个大整数比如1e4跑完再换算回去。这个方法不是万能的但大多数情况下能避免最头疼的死循环问题。第三学最大流的时候不要只盯代码一定要自己画图手推一遍。我强烈建议你用上面讲的那个“两条路径竞争”的例子手动跑一遍Ford-Fulkerson感受一下反向边的存在感。手推一遍之后什么残余网络、增广路径、最小割定理全都通了。第四最小割的应用建模比算法本身更能拉开差距。同样一个问题有的人能一眼看出是二分图匹配最大流有的人只会暴力搜索。建议多看一些经典的建图案例比如项目选择、图像分割、任务调度、路径不交问题慢慢培养“把业务约束映射成容量”的感觉。第五很多所谓“最大流应用题”终极考点其实是“你会不会把问题归约成最大流”。题目表面上可能是“有多少种方案”“最多能完成多少个任务”“最小代价是什么”但本质都是建图。多积累模型多练习转化比刷一堆裸最大流题有用得多。最大流和最小割这套理论在我自己遇到的工程问题里出现频率不算特别高但每次出现都是关键环节。无论是做调度系统时算最大吞吐量还是做图像分割时用graph cut懂原理和不懂原理的差距非常大。希望这篇文章能让你少走一些弯路把这块知识点彻底拿下。
返回列表