一、最大流问题是什么
把一张有向图想象成一个城市供水系统: 是源头水库, 是用户区。中间有很多管道和中转站,管道各有最大的通水能力(容量)。
在这个系统中,有两条最基础的常识性物理规则:
- 容量限制:每一根水管都有粗细,流过去的水不能超过这根管子的最大容量。
- 流量守恒(中转站不蓄水):除了起点水库 和终点用户区 ,中间的每一个中转站流入了多少水,就必须立刻流出去多少水,既不能凭空漏水,也不能把水蓄起来。
在这个前提下,用户区 最多能收到多少水?这就是最大流问题。
先用最简单的例子建立直觉。只有一根管道连接水库和用户区:
容量 表示这条管道最多通水 单位。这根管道既是唯一的路,也是整个系统的最终瓶颈,最大流就是 。
再加两条路:
现在有两条路送水: 和 。最多能送多少?直观上,把所有从 出发的管道容量加起来就是上限。但如果中间某段管道容量很小,实际可能达不到这个上限。到底能送多少,我们需要算法来帮我们自动算出来。
二、简单找路为什么会出错
找最大流的直觉做法很像探路:反复找一条能送水的路线,送一次水占掉一部分容量,直到再也找不到路为止。但这个看似稳妥的“简单找路”,可能第一步就走错。
看下面这个 4 顶点网络,所有管道容量都是 ,中间多了一根 的连通管:
假设第一次探路运气不好,选了路径 送 单位水,途经的 、、 三条边全部被占满。
接着再去探路,两条直通路线都被堵死:
- 走 ,但 已满,过不去。
- 走 ,但 已满,水到不了 。
没有纠错机制时算法只能宣布最大流为 。可整体看图,明明能走 送 、 再送 ,真正的最大流是 。第一次选错了路又没法修正,算法就卡在了次优解上。
三、反向边:允许后悔的“水流对冲”机制
贪心找路失败,根子在于流量推出去就撤不回来。如果允许算法“后悔”——把占用的水流退回来重新分配,问题就解决了。为此,算法引入反向边作为纠错通道。
核心:水流对冲抵消模型
为什么“沿反向边推流”就等于“撤回水流”?用一个直观的物理常识就能理解:水流抵消。
回到前面的例子。
推送 的 单位流量后,残量网络中自动多出三条反向边 、、,它们就是“退水额度”。
现在尝试走新路 :
-
:正向送水 单位。
-
:这是反向边,推 单位逆向水。
它与管道里原有的 那 单位水迎面相撞、互相抵消, 的实际流量瞬间归零。
-
:正向送水 单位。
效果是:
原本经 流来的水不再拐去 ,改走空出来的 ;
新从 来的水也不必去 ,直接走 。
一次“对冲”就让水流在交叉口自动重新分流,两条原本堵死的路都用上了,总流量达到 。
四、残量网络
有了“正向加水、反向退水”的感觉,就可以定义残量网络(记作 )——一张随施工实时更新的导向图。原图中每条水管 都拆成两条指导线:
- 正向边 ,残量 :还能再灌多少水。
- 反向边 ,残量 :已经灌了多少,也就是能退多少水的“退水额度”。
下面这张图直观展示了这一点:
原图中 容量为 。当我们推了 单位流量后,正向还剩 单位空间;反向则出现 单位的残量——意思是我们拥有“最多往回退 7 单位水”的额度。
五、增广路
当前流量可能还不是最大流。既然还想送更多水,我们就要问:在当前的残量网络中,是否还存在一条从水库 一路连通到用户区 的路线?
这样一条路线就叫做增广路。
- 增广路上的每一步,都可以是正向边(加水),也可以是反向边(退水),但它们的残量必须全部大于 0(有余量可加或有额度可退)。
- 沿着这条路径,我们能够推送的额外水流量,取决于整条路上最窄的那个瓶颈,即路径上残量的最小值 :
确定瓶颈后,我们开始推流更新:走正向边时流量加 ,走反向边时流量减 。
一旦在施工图上无论怎么走都找不到任何一条路能把 和 连通,当前流就是最大流。
六、标号法
标号法是在寻找增广路时记录前驱和瓶颈的方法。本文用 BFS 来实现它,也就是 Edmonds-Karp 的找路方式。 在搜索过程中,我们给访问过的顶点贴上一个“便利贴”(标号),记录两项关键信息:
- 这个点是从哪个前驱顶点走过来的(前驱节点,用于最后沿路反向推流)。
- 到该点为止,整条路上的瓶颈限制到了多少(可增广量 )。
举个例子,从源点 开始探索:
- 水库 水量无限,前驱为空,标号
s : (+, ∞)。 - BFS 发现邻居 ,正向管道 还能容纳 单位,标号
A : (s, 2)。 - 从 探到终点 , 只剩 单位,瓶颈被压到 ,标号
t : (A, 1)。 - 顺着 的前驱
A、A的前驱s逆推,就得到增广路 ,瓶颈 。
算法流程图如下:
需要说明一下三个名字的关系。
Ford-Fulkerson 是“反复找增广路推流”这套通用框架的统称,它不规定用什么方法找路;如果用 BFS 每次都找边数最少的增广路,这个特定实现就叫 Edmonds-Karp。
下面的完整示例用的都是 Edmonds-Karp。
七、Edmonds-Karp 算法完整示例
简单情况:4 顶点
以下面的 4 顶点流网络为例:
| 边 | 容量 |
|---|---|
| 2 | |
| 1 | |
| 1 | |
| 1 | |
| 2 |
初始状态下,所有边流量都为 。
第 1 轮:,
BFS 从 出发探索:
- 给 标号
(+, ∞)。 - 探索到 ,标号
(s, 2)。 - 探索到 ,标号
(A, 1),受限于 容量限制。
回溯前驱,得到增广路 ,瓶颈容量 。
推流后,管道 饱和。此时 流量为 1,残量剩余 1。
第 2 轮:,
由于 已经满流(残量 0),BFS 换了一条路:
- 探索到 ,标号
(s, 1)。 - 探索到 ,由于走不了 ,改为从 到达,标号
(B, 1)。
回溯得到增广路 ,。
推流后 饱和。本轮未影响 ,其流量仍为 1,残量仍为 1。
第 3 轮:,
为什么此时 s ➔ A 残量是 1?第 1 轮中 已流过 1 单位流量(容量为 2),第 2 轮没有改变它,所以本轮开始前这根管道的残量是 1。后续每一轮探路都基于此前所有推流累加后的状态。
- BFS 探索到 ,标号为
(s, 1)。 - 从 探索到 ,标号为
(A, 1)。 - 从 探索到 ,标号为
(B, 1)。
回溯得到增广路 ,。推流后,三条管道全部饱和。此时总流量变为了 。
第 4 轮:无增广路,算法终止
残量网络中从 出发的所有管道都已经满流,BFS 无法到达任何邻居,汇点 无法被标号。算法终止,最大流为 。
为了更直观地看清流量的累积过程,下面是各条边在每一轮推流中的流量变化表:
| 管道 | 容量 | 第 1 轮流量变化 | 第 2 轮流量变化 | 第 3 轮流量变化 | 最终累计流量 | 最终剩余残量 |
|---|---|---|---|---|---|---|
| 2 | +1 | 0 | +1 | 2 | 0 | |
| 1 | 0 | +1 | 0 | 1 | 0 | |
| 1 | 0 | 0 | +1 | 1 | 0 | |
| 1 | +1 | 0 | 0 | 1 | 0 | |
| 2 | 0 | +1 | +1 | 2 | 0 |
反向边登场:6 顶点
考虑下面这个 6 顶点的复杂网络,我们来看看反向边是如何在搜索中被利用的:
| 边 | 容量 | 边 | 容量 |
|---|---|---|---|
| 2 | 3 | ||
| 1 | 2 | ||
| 2 | 2 | ||
| 3 |
初始状态下,所有管道流量为 。
第 1 轮:,
第一轮 BFS 标号顺理成章地找到了最短路径 ,瓶颈 。
推流后,这三条边全部流满。关键的是,在当前残量网络中,由于 流过了 2 的水,所以自动出现了一条反向退水通道 ,残量为 2。
第 2 轮:,(退水路登场)
当 BFS 再次探路时:
- 已满,不能走。
- 还有 1 空间,BFS 走到 ,标为
B : (s, 1)。 - 还有 2 空间,BFS 走到 ,标为
C : (B, 1)。 - 寻找下一站:在 点,正向去往 的管子已经流满(残量 0),走不过去。但 BFS 发现此时存在一条反向退水通道 ,残量为 2。
- BFS 顺着这根通道走了过去,来到了 !给 标上
A : (C, 1)。
- BFS 顺着这根通道走了过去,来到了 !给 标上
- 从 出发:走正向管道 (残量 3),标记
D : (A, 1)。 - 从 出发:走正向管道 (残量 3),到达终点,标记
t : (D, 1)。
回溯前驱,我们得到了奇妙的增广路:,瓶颈 。
根据“水流对冲抵消”模型: 当我们沿着反向通道 推流 单位时,相当于让 单位逆向水与第一轮中 流着的 单位水迎面相撞,互相抵消。 因此, 的流量被撤回了 1 单位(流量从 降为 )。
撤回出来的这 1 单位水改走 流入终点;腾出的 点空间则刚好接纳从 流过来的 1 单位新水。在没有修改原图结构的前提下,算法仅通过“退水/分流”就成功把总流量提升到了 。
第 3 轮:无增广路,终止
此时从 出发的两条通道 和 均已完全流满,BFS 无法继续探索。算法终止,最大流为 。
八、最大流最小割定理:剪水管的“破坏者游戏”
算出来的最大流怎么验证对不对?可以玩一个“破坏者游戏”:你要切断一些管道,让 的水一滴也到不了 ,每根管道的切断代价等于它的容量。
- 能让 、 彻底断开的那组被剪边,叫一个割;它们的容量之和就是割的容量。
- 最省力气(容量之和最小)的剪法,叫最小割。
由此引出最大流最小割定理:最大流的值等于最小割的容量。道理有两面:
- 最大流不可能超过任意一个割的容量:水要流到 ,必然要穿过被剪开的这个“截面”,所以总流量被这个截面的容量卡死。
- 算法终止时,最小割刚好被榨干:BFS 再也找不到增广路,说明最窄的那组瓶颈(即最小割对应的管道)全都流满了,最大流正好顶在它们的容量上限。
以 6 顶点示例为例:算法终止时, 能到达的顶点集只有 ,其余 。分隔 、 的正是 (容量 2)和 (容量 1),两条都已流满,割容量 ,正好等于最大流 。
九、代码实现
读代码只需抓两处
读下面的 Java 实现时,抓住两个跟前文物理模型对应的地方就够了:
addEdge里的rev指针:每加一条正向边,就配一条容量为 0 的反向边,用rev把两者绑定。这样操作正向边时能顺指针同步更新它的反向边。edmondsKarp里的推流:e.flow += bottleneck; e.rev.flow -= bottleneck;—— 正向边加流量、反向边减流量。反向边流量一减,它的残量(容量 流量)就涨,这正是“积累退水额度”的代码体现。
import java.util.*;
public class MaxFlow {
/** * 边对象:存储终点、容量、实际流量,以及反向边的引用指针。 */ static class Edge { int to; int cap; int flow; Edge rev; // 指向与之绑定的反向边,用于快速“退水”
Edge(int to, int cap) { this.to = to; this.cap = cap; this.flow = 0; }
// 计算这条边的富余空间(残量) int residual() { return cap - flow; } }
private final int n; private final List<Edge>[] adj;
@SuppressWarnings("unchecked") public MaxFlow(int n) { this.n = n; this.adj = new ArrayList[n]; for (int i = 0; i < n; i++) { adj[i] = new ArrayList<>(); } }
/** * 添加一条从 u 到 v 容量为 cap 的有向水管。 * 同时自动在底层创建一条容量为 0 的反向退水管道 v -> u。 */ public void addEdge(int u, int v, int cap) { Edge forward = new Edge(v, cap); Edge backward = new Edge(u, 0); // 反向边初始容量为 0 forward.rev = backward; backward.rev = forward; adj[u].add(forward); adj[v].add(backward); }
/** * BFS 标号法:在残量网络中寻找 s 到 t 的最短增广路。 * parent[v] 数组用来记录到达 v 的那条管道,方便我们在寻路成功后回溯。 * @return 寻找到的增广路上的瓶颈流量;若 s 与 t 已断开则返回 0 */ private int bfs(int s, int t, Edge[] parent) { boolean[] visited = new boolean[n]; Queue<Integer> queue = new LinkedList<>();
visited[s] = true; queue.offer(s);
while (!queue.isEmpty()) { int u = queue.poll(); for (Edge e : adj[u]) { // 如果这个邻居没有访问过,且管道还可以继续送水(残量 > 0) if (!visited[e.to] && e.residual() > 0) { visited[e.to] = true; parent[e.to] = e; // 贴上便利贴:记录前驱边
if (e.to == t) { // 成功探索到终点,顺着前驱链回溯,计算整条路上的瓶颈限制 int bottleneck = Integer.MAX_VALUE; for (int v = t; v != s; ) { Edge pe = parent[v]; bottleneck = Math.min(bottleneck, pe.residual()); v = pe.rev.to; // 顺着反向边找回上一个起点 } return bottleneck; } queue.offer(e.to); } } } return 0; // s 到 t 在当前导向图中已无法连通 }
/** * Edmonds-Karp 最大流算法主循环。 * 反复用 BFS 寻路并推流,直到找不到增广路为止。 */ public int edmondsKarp(int s, int t) { int maxFlow = 0; Edge[] parent = new Edge[n];
while (true) { int bottleneck = bfs(s, t, parent); if (bottleneck == 0) break; // 找不到路了,宣告结束
// 沿着刚刚贴好标签的增广路进行“推流与对冲”: // 正向边流量增加,反向边流量同步减少 for (int v = t; v != s; ) { Edge e = parent[v]; e.flow += bottleneck; e.rev.flow -= bottleneck; // 对冲更新 v = e.rev.to; }
maxFlow += bottleneck; }
return maxFlow; }
/** 打印每条正向管道的最终水流量。 */ public void printFlow(String[] name) { for (int u = 0; u < n; u++) { for (Edge e : adj[u]) { if (e.cap > 0) { System.out.printf("%s -> %s : f=%d / c=%d (残量=%d)%n", vertexName(name, u), vertexName(name, e.to), e.flow, e.cap, e.residual()); } } } }
private String vertexName(String[] name, int id) { if (name != null && id < name.length) return name[id]; return String.valueOf(id); }
public static void main(String[] args) { // 顶点编号:s=0, A=1, B=2, t=3 String[] name = {"s", "A", "B", "t"}; MaxFlow mf = new MaxFlow(4); mf.addEdge(0, 1, 2); // s -> A mf.addEdge(0, 2, 1); // s -> B mf.addEdge(1, 2, 1); // A -> B mf.addEdge(1, 3, 1); // A -> t mf.addEdge(2, 3, 2); // B -> t
System.out.println("max flow: " + mf.edmondsKarp(0, 3)); mf.printFlow(name); }}运行结果:
max flow: 3s -> A : f=2 / c=2 (残量=0)s -> B : f=1 / c=1 (残量=0)A -> B : f=1 / c=1 (残量=0)A -> t : f=1 / c=1 (残量=0)B -> t : f=2 / c=2 (残量=0)十、复杂度分析
设顶点数为 ,边数为 。
| 算法实现 | 时间复杂度 | 空间复杂度 | 说明 |
|---|---|---|---|
| Edmonds-Karp 算法 | 最多 轮增广,每轮 BFS 耗时 ,速度稳定 | ||
| Ford-Fulkerson 算法 | 为最大流值;该界限依赖整数容量,容量很大时可能很慢;若容量允许无理数,朴素选路甚至可能不终止 |
Edmonds-Karp 的核心优势:通过 BFS 寻路,保证每次都走边数最少的增广路。这样可以保证路径长度单调不减,从而将总增广轮数限制在 内,避免 DFS 在差路径上反复兜圈子。
手算小技巧考试或手算最大流时,在草稿纸上画一张“容量-流量”表。每一轮在残量网络上用 BFS 找路,用笔圈出增广路,并写下它的瓶颈流量,直接在纸上更新流量。千万注意反向边:如果走的是反向边,对应正向边的流量要减掉。一旦从起点走不通终点了,直接停笔,当前累加出来的总流量就是最大流!
十一、总结
最大流的核心:在残量网络中反复用 BFS 寻找最短增广路,正向边加流,反向边减流,直到源汇不连通。反向边提供了“后悔机制”;Edmonds-Karp 用 BFS 固定选路规则,因此能在多项式时间内收敛到最优解。
记住以下核心要点,最大流就不再难懂:
- 最大流:能从起点 送到终点 的最大总水量限制。
- 反向边:一条预留的“退水/后悔”通道。物理本质是相反方向的水流在管道里对冲抵消。
- 残量网络:当前还能继续调整流量的网络(正向为余量,反向为退水额度)。
- 增广路:残量网络中,一条从起点能顺利通往终点的送水新路线。
- 最大流最小割:最大流的值 = 切断整个管网所需付出的最小代价(最窄瓶颈能力)。
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时


















