mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4mobile wallpaper 5mobile wallpaper 6
3906 字
10 分钟
最大流问题与 Edmonds-Karp 算法
2026-06-16 10:51:00

一、最大流问题是什么#

把一张有向图想象成一个城市供水系统:ss 是源头水库,tt 是用户区。中间有很多管道和中转站,管道各有最大的通水能力(容量)。

在这个系统中,有两条最基础的常识性物理规则:

  1. 容量限制:每一根水管都有粗细,流过去的水不能超过这根管子的最大容量。
  2. 流量守恒(中转站不蓄水):除了起点水库 ss 和终点用户区 tt,中间的每一个中转站流入了多少水,就必须立刻流出去多少水,既不能凭空漏水,也不能把水蓄起来。

在这个前提下,用户区 tt 最多能收到多少水?这就是最大流问题。

先用最简单的例子建立直觉。只有一根管道连接水库和用户区:

最简管道示例

容量 1010 表示这条管道最多通水 1010 单位。这根管道既是唯一的路,也是整个系统的最终瓶颈,最大流就是 1010

再加两条路:

多管道分流示例

现在有两条路送水:sAts \to A \to tsBts \to B \to t。最多能送多少?直观上,把所有从 ss 出发的管道容量加起来就是上限。但如果中间某段管道容量很小,实际可能达不到这个上限。到底能送多少,我们需要算法来帮我们自动算出来。


二、简单找路为什么会出错#

找最大流的直觉做法很像探路:反复找一条能送水的路线,送一次水占掉一部分容量,直到再也找不到路为止。但这个看似稳妥的“简单找路”,可能第一步就走错。

看下面这个 4 顶点网络,所有管道容量都是 11,中间多了一根 ABA \to B 的连通管:

贪心找路反例

假设第一次探路运气不好,选了路径 sABts \to A \to B \to t11 单位水,途经的 sAs \to AABA \to BBtB \to t 三条边全部被占满。

接着再去探路,两条直通路线都被堵死:

  • sBs \to B,但 BtB \to t 已满,过不去。
  • AtA \to t,但 sAs \to A 已满,水到不了 AA
没有纠错机制时

算法只能宣布最大流为 11。可整体看图,明明能走 sAts \to A \to t11sBts \to B \to t 再送 11,真正的最大流是 22。第一次选错了路又没法修正,算法就卡在了次优解上。


三、反向边:允许后悔的“水流对冲”机制#

贪心找路失败,根子在于流量推出去就撤不回来。如果允许算法“后悔”——把占用的水流退回来重新分配,问题就解决了。为此,算法引入反向边作为纠错通道。

核心:水流对冲抵消模型#

为什么“沿反向边推流”就等于“撤回水流”?用一个直观的物理常识就能理解:水流抵消

回到前面的例子。

推送 sABts \to A \to B \to t11 单位流量后,残量网络中自动多出三条反向边 BAB \to AtBt \to BAsA \to s,它们就是“退水额度”。

现在尝试走新路 sBAts \to B \to A \to t

  1. sBs \to B:正向送水 11 单位。

  2. BAB \to A:这是反向边,推 11 单位逆向水。

    它与管道里原有的 ABA \to B11 单位水迎面相撞、互相抵消,ABA-B 的实际流量瞬间归零。

  3. AtA \to t:正向送水 11 单位。

效果是:

原本经 sAs \to A 流来的水不再拐去 BB,改走空出来的 AtA \to t

新从 sBs \to B 来的水也不必去 AA,直接走 BtB \to t

一次“对冲”就让水流在交叉口自动重新分流,两条原本堵死的路都用上了,总流量达到 22


四、残量网络#

有了“正向加水、反向退水”的感觉,就可以定义残量网络(记作 GfG_f)——一张随施工实时更新的导向图。原图中每条水管 (u,v)(u, v) 都拆成两条指导线:

  • 正向边 uvu \to v,残量 cf(u,v)=c(u,v)f(u,v)c_f(u, v) = c(u, v) - f(u, v):还能再灌多少水。
  • 反向边 vuv \to u,残量 cf(v,u)=f(u,v)c_f(v, u) = f(u, v):已经灌了多少,也就是能退多少水的“退水额度”。

下面这张图直观展示了这一点:

残量网络示例

原图中 sAs \to A 容量为 1010。当我们推了 77 单位流量后,正向还剩 33 单位空间;反向则出现 77 单位的残量——意思是我们拥有“最多往回退 7 单位水”的额度。


五、增广路#

当前流量可能还不是最大流。既然还想送更多水,我们就要问:在当前的残量网络中,是否还存在一条从水库 ss 一路连通到用户区 tt 的路线?

这样一条路线就叫做增广路

  • 增广路上的每一步,都可以是正向边(加水),也可以是反向边(退水),但它们的残量必须全部大于 0(有余量可加或有额度可退)。
  • 沿着这条路径,我们能够推送的额外水流量,取决于整条路上最窄的那个瓶颈,即路径上残量的最小值 Δ\Delta
Δ=min(u,v)Pcf(u,v)\Delta = \min_{(u,v) \in P} c_f(u, v)

确定瓶颈后,我们开始推流更新:走正向边时流量加 Δ\Delta,走反向边时流量减 Δ\Delta

一旦在施工图上无论怎么走都找不到任何一条路能把 sstt 连通,当前流就是最大流。


六、标号法#

标号法是在寻找增广路时记录前驱和瓶颈的方法。本文用 BFS 来实现它,也就是 Edmonds-Karp 的找路方式。 在搜索过程中,我们给访问过的顶点贴上一个“便利贴”(标号),记录两项关键信息:

  1. 这个点是从哪个前驱顶点走过来的(前驱节点,用于最后沿路反向推流)。
  2. 到该点为止,整条路上的瓶颈限制到了多少(可增广量 Δ\Delta)。

举个例子,从源点 ss 开始探索:

  • 水库 ss 水量无限,前驱为空,标号 s : (+, ∞)
  • BFS 发现邻居 AA,正向管道 sAs \to A 还能容纳 22 单位,标号 A : (s, 2)
  • AA 探到终点 ttAtA \to t 只剩 11 单位,瓶颈被压到 min(2,1)=1\min(2, 1) = 1,标号 t : (A, 1)
  • 顺着 tt 的前驱 AA 的前驱 s 逆推,就得到增广路 sAts \to A \to t,瓶颈 Δ=1\Delta = 1

算法流程图如下:

算法流程图

需要说明一下三个名字的关系。

Ford-Fulkerson 是“反复找增广路推流”这套通用框架的统称,它不规定用什么方法找路;如果用 BFS 每次都找边数最少的增广路,这个特定实现就叫 Edmonds-Karp

下面的完整示例用的都是 Edmonds-Karp。


七、Edmonds-Karp 算法完整示例#

简单情况:4 顶点#

以下面的 4 顶点流网络为例:

示例流网络

容量
sAs \to A2
sBs \to B1
ABA \to B1
AtA \to t1
BtB \to t2

初始状态下,所有边流量都为 00

4顶点执行流程

第 1 轮:sAts \to A \to tΔ=1\Delta = 1#

BFS 从 ss 出发探索:

  • ss 标号 (+, ∞)
  • 探索到 AA,标号 (s, 2)
  • 探索到 tt,标号 (A, 1),受限于 AtA \to t 容量限制。

回溯前驱,得到增广路 sAts \to A \to t,瓶颈容量 Δ=1\Delta = 1

推流后,管道 AtA \to t 饱和。此时 sAs \to A 流量为 1,残量剩余 1

第1轮增广

第 2 轮:sBts \to B \to tΔ=1\Delta = 1#

由于 AtA \to t 已经满流(残量 0),BFS 换了一条路:

  • 探索到 BB,标号 (s, 1)
  • 探索到 tt,由于走不了 AtA \to t,改为从 BtB \to t 到达,标号 (B, 1)

回溯得到增广路 sBts \to B \to tΔ=1\Delta = 1

推流后 sBs \to B 饱和。本轮未影响 sAs \to A,其流量仍为 1,残量仍为 1

第2轮增广

第 3 轮:sABts \to A \to B \to tΔ=1\Delta = 1#

为什么此时 s ➔ A 残量是 1

第 1 轮中 sAs \to A 已流过 1 单位流量(容量为 2),第 2 轮没有改变它,所以本轮开始前这根管道的残量是 1。后续每一轮探路都基于此前所有推流累加后的状态。

  • BFS 探索到 AA,标号为 (s, 1)
  • AA 探索到 BB,标号为 (A, 1)
  • BB 探索到 tt,标号为 (B, 1)

回溯得到增广路 sABts \to A \to B \to tΔ=1\Delta = 1。推流后,三条管道全部饱和。此时总流量变为了 33

第3轮增广

第 4 轮:无增广路,算法终止#

残量网络中从 ss 出发的所有管道都已经满流,BFS 无法到达任何邻居,汇点 tt 无法被标号。算法终止,最大流为 33

第4轮终止

最终流分配

为了更直观地看清流量的累积过程,下面是各条边在每一轮推流中的流量变化表:

管道容量第 1 轮流量变化第 2 轮流量变化第 3 轮流量变化最终累计流量最终剩余残量
sAs \to A2+10+120
sBs \to B10+1010
ABA \to B100+110
AtA \to t1+10010
BtB \to t20+1+120

反向边登场:6 顶点#

考虑下面这个 6 顶点的复杂网络,我们来看看反向边是如何在搜索中被利用的:

反向边示例流网络

容量容量
sAs \to A2ADA \to D3
sBs \to B1BCB \to C2
ACA \to C2CtC \to t2
DtD \to t3

初始状态下,所有管道流量为 00

6顶点执行流程

第 1 轮:sACts \to A \to C \to tΔ=2\Delta = 2#

第一轮 BFS 标号顺理成章地找到了最短路径 sACts \to A \to C \to t,瓶颈 Δ=min(2,2,2)=2\Delta = \min(2, 2, 2) = 2

推流后,这三条边全部流满。关键的是,在当前残量网络中,由于 ACA \to C 流过了 2 的水,所以自动出现了一条反向退水通道 CAC \to A,残量为 2

6顶点第1轮增广

第 2 轮:sBCADts \to B \to C \to A \to D \to tΔ=1\Delta = 1(退水路登场)#

当 BFS 再次探路时:

  1. sAs \to A 已满,不能走。
  2. sBs \to B 还有 1 空间,BFS 走到 BB,标为 B : (s, 1)
  3. BCB \to C 还有 2 空间,BFS 走到 CC,标为 C : (B, 1)
  4. 寻找下一站:在 CC 点,正向去往 tt 的管子已经流满(残量 0),走不过去。但 BFS 发现此时存在一条反向退水通道 CAC \to A,残量为 2。
    • BFS 顺着这根通道走了过去,来到了 AA!给 AA 标上 A : (C, 1)
  5. AA 出发:走正向管道 ADA \to D(残量 3),标记 D : (A, 1)
  6. DD 出发:走正向管道 DtD \to t(残量 3),到达终点,标记 t : (D, 1)

回溯前驱,我们得到了奇妙的增广路:sBCADts \to B \to C \to A \to D \to t,瓶颈 Δ=1\Delta = 1

6顶点第2轮增广(反向边登场)

根据“水流对冲抵消”模型: 当我们沿着反向通道 CAC \to A 推流 11 单位时,相当于让 11 单位逆向水与第一轮中 ACA \to C 流着的 22 单位水迎面相撞,互相抵消。 因此,ACA \to C 的流量被撤回了 1 单位(流量从 22 降为 11)。

撤回出来的这 1 单位水改走 ADtA \to D \to t 流入终点;腾出的 CC 点空间则刚好接纳从 BCB \to C 流过来的 1 单位新水。在没有修改原图结构的前提下,算法仅通过“退水/分流”就成功把总流量提升到了 33

第 3 轮:无增广路,终止#

此时从 ss 出发的两条通道 sAs \to AsBs \to B 均已完全流满,BFS 无法继续探索。算法终止,最大流为 33

6顶点第3轮终止


八、最大流最小割定理:剪水管的“破坏者游戏”#

算出来的最大流怎么验证对不对?可以玩一个“破坏者游戏”:你要切断一些管道,让 ss 的水一滴也到不了 tt,每根管道的切断代价等于它的容量。

  • 能让 sstt 彻底断开的那组被剪边,叫一个;它们的容量之和就是割的容量
  • 最省力气(容量之和最小)的剪法,叫最小割

由此引出最大流最小割定理:最大流的值等于最小割的容量。道理有两面:

  1. 最大流不可能超过任意一个割的容量:水要流到 tt,必然要穿过被剪开的这个“截面”,所以总流量被这个截面的容量卡死。
  2. 算法终止时,最小割刚好被榨干:BFS 再也找不到增广路,说明最窄的那组瓶颈(即最小割对应的管道)全都流满了,最大流正好顶在它们的容量上限。

以 6 顶点示例为例:算法终止时,ss 能到达的顶点集只有 S={s}S = \{s\},其余 T={A,B,C,D,t}T = \{A, B, C, D, t\}。分隔 SSTT 的正是 sAs \to A(容量 2)和 sBs \to B(容量 1),两条都已流满,割容量 2+1=32 + 1 = 3,正好等于最大流 33


九、代码实现#

读代码只需抓两处#

读下面的 Java 实现时,抓住两个跟前文物理模型对应的地方就够了:

  1. addEdge 里的 rev 指针:每加一条正向边,就配一条容量为 0 的反向边,用 rev 把两者绑定。这样操作正向边时能顺指针同步更新它的反向边。
  2. 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: 3
s -> 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)

十、复杂度分析#

设顶点数为 VV,边数为 EE

算法实现时间复杂度空间复杂度说明
Edmonds-Karp 算法O(VE2)O(VE^2)O(V+E)O(V + E)最多 O(VE)O(VE) 轮增广,每轮 BFS 耗时 O(E)O(E),速度稳定
Ford-Fulkerson 算法O(Ef)O(E \cdot f^*)O(V+E)O(V + E)ff^* 为最大流值;该界限依赖整数容量,容量很大时可能很慢;若容量允许无理数,朴素选路甚至可能不终止

Edmonds-Karp 的核心优势:通过 BFS 寻路,保证每次都走边数最少的增广路。这样可以保证路径长度单调不减,从而将总增广轮数限制在 O(VE)O(VE) 内,避免 DFS 在差路径上反复兜圈子。

手算小技巧

考试或手算最大流时,在草稿纸上画一张“容量-流量”表。每一轮在残量网络上用 BFS 找路,用笔圈出增广路,并写下它的瓶颈流量,直接在纸上更新流量。千万注意反向边:如果走的是反向边,对应正向边的流量要减掉。一旦从起点走不通终点了,直接停笔,当前累加出来的总流量就是最大流!


十一、总结#

最大流的核心:在残量网络中反复用 BFS 寻找最短增广路,正向边加流,反向边减流,直到源汇不连通。反向边提供了“后悔机制”;Edmonds-Karp 用 BFS 固定选路规则,因此能在多项式时间内收敛到最优解。

记住以下核心要点,最大流就不再难懂:

  • 最大流:能从起点 ss 送到终点 tt 的最大总水量限制。
  • 反向边:一条预留的“退水/后悔”通道。物理本质是相反方向的水流在管道里对冲抵消
  • 残量网络:当前还能继续调整流量的网络(正向为余量,反向为退水额度)。
  • 增广路:残量网络中,一条从起点能顺利通往终点的送水新路线。
  • 最大流最小割:最大流的值 = 切断整个管网所需付出的最小代价(最窄瓶颈能力)。
分享

如果这篇文章对你有帮助,欢迎分享给更多人!

最大流问题与 Edmonds-Karp 算法
https://dawn114514.site/posts/algorithm/maxflow/
作者
黎明
发布于
2026-06-16 10:51:00
许可协议
MIT

部分信息可能已经过时

目录