網路流完全指南 — Ford-Fulkerson、Dinic 演算法與最大流最小割定理 | 資料結構與演算法
網路流(Network Flow) 是圖論中最強大的技術之一,研究在有容量限制的有向圖中,從源點到匯點能傳送的最大流量問題。從 Ford-Fulkerson 的增廣路徑法,到 Dinic 的分層圖與阻塞流,再到 最大流最小割定理 揭示的深刻對偶性,這些概念在航線排程、影像分割、任務分配等領域都有重要應用。本文帶你從核心原理到 TypeScript 與 C++ 完整實作,全面掌握網路流演算法。
前言
想像一座城市的自來水管道系統:水從淨水廠(源點)出發,經過大大小小的管道流向千家萬戶,最終匯集到下游處理廠(匯點)。每條管道有其最大承載量——粗管能通過更多水,細管則形成瓶頸。問題是:在所有管道容量限制下,整個系統最多能從源點送多少水到匯點?
這就是 最大流問題(Maximum Flow Problem) 的本質。它不只是一個抽象的數學問題——Google 用網路流優化資料中心間的流量分配、Adobe Photoshop 用最小割做影像分割、航空公司用它規劃航線排程。
學習本文後,你將能夠:
- 理解 流網路(Flow Network) 的三大約束條件:容量限制、反對稱性、流量守恆
- 掌握 殘餘網路(Residual Network) 與反向邊的「退流」機制
- 理解並證明 最大流最小割定理(Max-Flow Min-Cut Theorem)
- 實作 Edmonds-Karp(BFS 增廣路徑)與 Dinic 演算法(分層圖 + 阻塞流)
- 將網路流應用於 二分圖最大匹配 和 最小費用流 等變體問題
核心概念
流網路(Flow Network)
一個流網路 G = (V, E) 是一個有向圖,包含以下要素:
- 每條邊 (u, v) 有一個 容量上限 c(u, v) >= 0
- 一個 源點 s(Source,流的起點)和一個 匯點 t(Sink,流的終點)
- 每條邊上的實際流量 f(u, v) 必須滿足三大條件
三大流量條件:
1. 容量限制(Capacity Constraint):
0 <= f(u, v) <= c(u, v) 對所有邊 (u, v)
→ 流量不能超過管道容量
2. 反對稱性(Skew Symmetry):
f(u, v) = -f(v, u)
→ 如果 u→v 的流量是 6,那 v→u 的流量就是 -6
3. 流量守恆(Flow Conservation):
對所有 u(除了 s 和 t),流入 u 的總流量 = 流出 u 的總流量
→ 中間節點不能「積水」或「漏水」
最大流 |f| = 從 s 流出的總流量 = 流入 t 的總流量
用自來水管道的比喻:容量限制是管道的物理承載量、流量守恆是中間水管不會漏水、最大流就是整個管網能輸送的最大水量。
殘餘網路(Residual Network)與反向邊
殘餘網路是理解所有最大流演算法的核心工具。對於原圖中每條邊 (u -> v),殘餘網路包含兩條邊:
原圖邊 (u → v):容量 c = 10,當前流量 f = 6
殘餘網路包含:
正向殘餘邊 u ──[cf=4]──→ v (cf = c - f = 10 - 6 = 4,還能再推多少)
反向殘餘邊 v ──[cf=6]──→ u (cf = f = 6,可以退回多少已送出的流量)
反向邊的意義:
允許演算法「反悔」之前的路由決策,
將流從某條路徑「退回」再改走另一條路徑,
這是增廣路徑能找到最優解的關鍵。
為什麼需要反向邊?考慮一個簡單的例子:如果演算法先走了一條「不太好」的路徑,反向邊提供了修正機會——透過在反向邊上推流,等效於「取消」之前的流量分配,讓它改走更好的路徑。沒有反向邊,貪婪式的路徑選擇可能卡在次優解。
增廣路徑(Augmenting Path)
增廣路徑 是殘餘網路中從 s 到 t 的任意一條路徑,路徑上每條邊的殘餘容量都大於 0。路徑的 瓶頸容量(Bottleneck) 是所有邊殘餘容量的最小值,決定了本次能推送的流量。
增廣路徑的流量計算:
路徑 s → A → B → t
殘餘容量:s→A: 4,A→B: 3,B→t: 7
瓶頸 = min(4, 3, 7) = 3
沿此路徑增廣:每條邊的殘餘容量各減 3,反向邊各加 3
Ford-Fulkerson 方法 的核心思想就是:反覆在殘餘網路中找增廣路徑,直到找不到為止。此時的總流量就是最大流。
最大流最小割定理(Max-Flow Min-Cut Theorem)
割(Cut) 是將節點集 V 分為兩個不相交的子集 S 和 T = V \ S,其中 s 屬於 S、t 屬於 T。割的容量定義為所有從 S 到 T 的邊的容量之和(注意:不包含 T 到 S 的邊)。
最大流最小割定理(Max-Flow Min-Cut Theorem):在任何流網路中,最大流的值等於最小割的容量。
直觀理解:
任何從 s 到 t 的流必須穿越任何割,所以:
最大流 <= 任意割的容量
當 Ford-Fulkerson 終止時(找不到增廣路徑),
s 在殘餘網路中能到達的節點集合 S 就構成最小割:
最大流 = 這個割的容量
範例:
s ─[4]→ A ─[3]→ t
s ─[2]→ B ─[5]→ t
A ─[1]→ B
最大流 = 6(路徑 s→A→t 送 3,s→B→t 送 2,s→A→B→t 送 1)
最小割:切斷 s→A(4) 和 s→B(2),容量 = 4 + 2 = 6 ✓
這個定理的重要性在於:它將「求最大流」和「找最小割」兩個看似不同的問題等價起來。在實務中,最小割常用於分析網路的瓶頸和脆弱點。
三大演算法對比
在深入實作之前,先比較三種主要的最大流演算法:
| 演算法 | 增廣路徑選法 | 時間複雜度 | 說明 |
|---|---|---|---|
| Ford-Fulkerson(DFS) | 任意路徑(DFS) | O(E x |f*|) | |f*| = 最大流值,容量為整數時終止 |
| Edmonds-Karp(BFS) | 最短路徑(邊數最少) | O(V x E²) | 多項式保證,不依賴流量大小 |
| Dinic(分層圖 + 阻塞流) | 分層圖上的阻塞流 | O(V² x E) | 最快通用演算法,單位容量 O(E sqrt(E)) |
- Ford-Fulkerson 用 DFS 找路徑,簡單但效能依賴最大流值——如果容量是 10^9,可能需要 10^9 次迭代
- Edmonds-Karp 用 BFS 保證每次找最短增廣路徑,時間複雜度成為多項式
- Dinic 進一步優化,用 BFS 建分層圖、DFS 找阻塞流,搭配當前弧優化後效能優秀,是競程和面試的首選
JavaScript / TypeScript 實作
Edmonds-Karp 演算法(BFS 增廣路徑)
Edmonds-Karp 是 Ford-Fulkerson 方法的一個特例:它用 BFS 來尋找增廣路徑,確保每次找到的都是 邊數最短 的路徑。這一改進使得時間複雜度從依賴流量值的 O(E x |f*|) 變成多項式的 O(V x E²)。
// Edmonds-Karp 演算法(Ford-Fulkerson + BFS)
// 時間複雜度:O(V × E²)
// 適用場景:中小規模圖,或作為理解網路流的入門
interface Edge {
to: number; // 終點
cap: number; // 當前殘餘容量
rev: number; // 反向邊在 graph[to] 中的索引
}
class EdmondsKarp {
private graph: Edge[][]; // 鄰接表(含反向邊)
private readonly n: number;
constructor(n: number) {
this.n = n;
this.graph = Array.from({ length: n }, () => []);
}
// 添加有向邊(同時自動添加反向邊,反向邊初始容量 = 0)
addEdge(from: number, to: number, cap: number): void {
const forwardIdx = this.graph[to].length; // 正向邊的反向邊索引
const backwardIdx = this.graph[from].length; // 反向邊的正向邊索引
this.graph[from].push({ to, cap, rev: forwardIdx });
this.graph[to].push({ to: from, cap: 0, rev: backwardIdx });
}
// BFS 尋找從 s 到 t 的最短增廣路徑
// 返回路徑上的瓶頸容量(0 表示找不到路徑)
private bfs(
s: number,
t: number,
parent: Array<{ node: number; edgeIdx: number }>
): number {
// parent[v] 記錄到達 v 的前一個節點和使用的邊索引
parent.fill({ node: -1, edgeIdx: -1 });
const visited = new Array(this.n).fill(false);
visited[s] = true;
const queue: Array<[number, number]> = [[s, Infinity]]; // [節點, 到此節點的瓶頸容量]
let head = 0;
while (head < queue.length) {
const [u, flow] = queue[head++];
for (let i = 0; i < this.graph[u].length; i++) {
const e = this.graph[u][i];
// 只走殘餘容量 > 0 且未被訪問的邊
if (!visited[e.to] && e.cap > 0) {
visited[e.to] = true;
parent[e.to] = { node: u, edgeIdx: i };
const newFlow = Math.min(flow, e.cap);
if (e.to === t) return newFlow; // 到達匯點,返回瓶頸容量
queue.push([e.to, newFlow]);
}
}
}
return 0; // 找不到增廣路徑
}
// 主函式:求 s 到 t 的最大流
maxflow(s: number, t: number): number {
let totalFlow = 0;
const parent = new Array(this.n).fill({ node: -1, edgeIdx: -1 });
let pathFlow: number;
while ((pathFlow = this.bfs(s, t, parent)) > 0) {
totalFlow += pathFlow;
// 沿增廣路徑更新殘餘容量(回溯 parent 鏈)
let curr = t;
while (curr !== s) {
const { node: prev, edgeIdx } = parent[curr];
this.graph[prev][edgeIdx].cap -= pathFlow; // 正向邊容量減少
this.graph[curr][this.graph[prev][edgeIdx].rev].cap += pathFlow; // 反向邊容量增加
curr = prev;
}
}
return totalFlow;
}
}
// ─── 使用範例 ──────────────────────────────────────────────
const ek = new EdmondsKarp(6);
ek.addEdge(0, 1, 10); // s → 城市A,容量10
ek.addEdge(0, 2, 10); // s → 城市B,容量10
ek.addEdge(1, 3, 4); // A → C,容量4
ek.addEdge(1, 2, 2); // A → B,容量2
ek.addEdge(2, 4, 9); // B → D,容量9
ek.addEdge(3, 5, 10); // C → t,容量10
ek.addEdge(4, 3, 6); // D → C,容量6
ek.addEdge(4, 5, 10); // D → t,容量10
console.log("Edmonds-Karp 最大流:", ek.maxflow(0, 5)); // 輸出:19
Dinic 演算法(分層圖 + 阻塞流)
Dinic 演算法 是競賽中最常用的最大流演算法,時間複雜度 O(V² x E)。它的核心改進是引入 分層圖(Level Graph) 和 阻塞流(Blocking Flow) 的概念:
- BFS 建立分層圖:記錄每個節點到源點的最短距離
level[v],只保留level[to] = level[from] + 1的邊 - DFS 找阻塞流:在分層圖中反覆用 DFS 推流,直到所有 s 到 t 的路徑上都有飽和邊
- 當前弧優化(Current Arc Optimization):用
iter[v]記錄每個節點上次掃到的邊,避免重複遍歷已飽和的邊 - 重複迭代:每輪後重建殘餘網路和分層圖,直到 BFS 無法到達 t
Dinic 分層圖建構過程(完整範例):
圖:s=0, t=5
0→1(容量10), 0→2(容量10)
1→3(容量4), 1→2(容量2)
2→4(容量9)
3→5(容量10)
4→3(容量6), 4→5(容量10)
第一輪 BFS 分層(距離/level):
level: [s=0, 1=1, 2=1, 3=2, 4=2, t=3]
分層圖(只留 level[to] = level[from] + 1 的邊):
s(0) ──[10]──→ 1(1) ──[4]──→ 3(2) ──[10]──→ t(3)
│ │
[10] [2]
│ ↓
└──────────→ 2(1) ──[9]──→ 4(2) ──[10]──→ t(3)
│
[6]
↓
3(2) ──[10]──→ t(3)
阻塞流(DFS 推流):
路徑 s→1→3→t:推 min(10,4,10) = 4,邊 1→3 飽和
路徑 s→2→4→t:推 min(10,9,10) = 9,邊 2→4 飽和
→ 本輪阻塞流 = 13
第二輪 BFS(更新殘餘網路後重新分層):
level[t] 增大 → 繼續找阻塞流...
每輪後 level[t] 嚴格增加 → 最多 O(V) 輪
以下是 Dinic 演算法的完整 TypeScript 實作:
// Dinic 最大流演算法
// 時間複雜度:O(V² × E),單位容量圖為 O(E × √E)
// 適用場景:一般最大流問題,競賽中的主力演算法
interface DinicEdge {
to: number; // 終點
cap: number; // 當前殘餘容量
rev: number; // 反向邊在 graph[to] 中的索引(維護殘餘網路的關鍵)
}
class MaxFlow {
private graph: DinicEdge[][]; // 鄰接表(含反向邊)
private level: number[]; // BFS 分層,level[v] = s 到 v 的最短距離
private iter: number[]; // 當前弧優化:避免重複遍歷飽和邊
private readonly n: number;
constructor(n: number) {
this.n = n;
this.graph = Array.from({ length: n }, () => []);
this.level = new Array(n).fill(-1);
this.iter = new Array(n).fill(0);
}
// 添加有向邊(同時添加殘餘反向邊,初始容量 = 0)
addEdge(from: number, to: number, cap: number): void {
const forwardIdx = this.graph[to].length; // 正向邊的反向邊在 graph[to] 中的索引
const backwardIdx = this.graph[from].length; // 反向邊的正向邊在 graph[from] 中的索引
this.graph[from].push({ to, cap, rev: forwardIdx });
this.graph[to].push({ to: from, cap: 0, rev: backwardIdx }); // 反向邊,容量初始為 0
}
// BFS 建立分層圖:O(V + E)
// 返回 true 表示 t 可達(還有增廣路徑)
private bfs(s: number, t: number): boolean {
this.level.fill(-1);
const queue: number[] = [s];
this.level[s] = 0;
let head = 0;
while (head < queue.length) {
const v = queue[head++];
for (const e of this.graph[v]) {
// 只走殘餘容量 > 0 且未被訪問的邊
if (e.cap > 0 && this.level[e.to] < 0) {
this.level[e.to] = this.level[v] + 1;
queue.push(e.to);
}
}
}
return this.level[t] >= 0; // 若 t 可達,返回 true
}
// DFS 找阻塞流(含當前弧優化):O(V × E)
// f = 從路徑上游傳來的可用流量上限
private dfs(v: number, t: number, f: number): number {
if (v === t) return f; // 到達匯點,返回可推流量
// 當前弧優化:從上次停止的邊繼續掃描,跳過已飽和的邊
for (; this.iter[v] < this.graph[v].length; this.iter[v]++) {
const e = this.graph[v][this.iter[v]];
// 只走分層圖中的邊(level[to] = level[v] + 1)且有殘餘容量
if (e.cap > 0 && this.level[v] < this.level[e.to]) {
const d = this.dfs(e.to, t, Math.min(f, e.cap));
if (d > 0) {
e.cap -= d; // 正向邊容量減少
this.graph[e.to][e.rev].cap += d; // 反向邊容量增加(允許「退流」)
return d;
}
}
}
return 0; // 此節點已無法推流
}
// 主函式:求 s 到 t 的最大流
// 每輪 BFS + DFS,最多 O(V) 輪
maxflow(s: number, t: number): number {
let flow = 0;
while (this.bfs(s, t)) {
// 每次 BFS 後重置當前弧指針
this.iter.fill(0);
let f: number;
// 在當前分層圖上反覆推流,直到阻塞
while ((f = this.dfs(s, t, Infinity)) > 0) {
flow += f;
}
}
return flow;
}
// 求最小割:在最大流計算完成後,BFS 仍可達的節點構成 S 集合
minCut(s: number): boolean[] {
const inS = new Array(this.n).fill(false);
for (let i = 0; i < this.n; i++) {
inS[i] = this.level[i] >= 0; // 最後一次 BFS 後可達的節點屬於 S
}
return inS;
}
// 輸出每條邊的實際流量(用於調試與分析)
getEdgeFlows(): Array<{ from: number; to: number; flow: number; cap: number }> {
const result: Array<{ from: number; to: number; flow: number; cap: number }> = [];
for (let v = 0; v < this.n; v++) {
for (const e of this.graph[v]) {
// 正向邊的原始容量 = 當前殘餘容量 + 反向邊的殘餘容量
const originalCap = e.cap + this.graph[e.to][e.rev].cap;
if (originalCap > 0) {
const flow = this.graph[e.to][e.rev].cap; // 反向邊容量 = 實際流量
result.push({ from: v, to: e.to, flow, cap: originalCap });
}
}
}
return result;
}
}
// ─── 使用範例:航線排程最大吞吐量 ──────────────────────────────
// 節點:s=0(出發地),t=5(目的地)
// 中轉站:1=城市A,2=城市B,3=城市C,4=城市D
const mf = new MaxFlow(6);
// 從源點出發的航線(容量 = 每日最大班次)
mf.addEdge(0, 1, 10); // s → 城市A,10班/天
mf.addEdge(0, 2, 10); // s → 城市B,10班/天
// 城市間連線
mf.addEdge(1, 3, 4); // A → C,4班/天
mf.addEdge(1, 2, 2); // A → B,2班/天(共用航線)
mf.addEdge(2, 4, 9); // B → D,9班/天
// 到達目的地的航線
mf.addEdge(3, 5, 10); // C → t,10班/天
mf.addEdge(4, 3, 6); // D → C,6班/天
mf.addEdge(4, 5, 10); // D → t,10班/天
console.log("Dinic 最大流:", mf.maxflow(0, 5)); // 輸出:19
// 輸出各邊實際流量
const flows = mf.getEdgeFlows();
flows.forEach(({ from, to, flow, cap }) => {
if (flow > 0) console.log(` ${from} → ${to}: ${flow}/${cap}`);
});
二分圖最大匹配(匈牙利演算法)
二分圖最大匹配 是網路流的重要特例。匈牙利演算法(Hungarian Algorithm)用 DFS 找增廣路徑,本質上等同於在單位容量的流網路上做 Ford-Fulkerson:
// 匈牙利演算法(Hungarian Algorithm)
// 時間複雜度:O(V × E)
// 適用:二分圖最大匹配(任務分配、人員調度)
class BipartiteMatching {
private readonly leftN: number; // 左側節點數量
private readonly rightN: number; // 右側節點數量
private adj: number[][]; // adj[左節點] = [可匹配的右節點列表]
private matchL: number[]; // matchL[左節點] = 匹配的右節點(-1 表示未匹配)
private matchR: number[]; // matchR[右節點] = 匹配的左節點(-1 表示未匹配)
constructor(leftN: number, rightN: number) {
this.leftN = leftN;
this.rightN = rightN;
this.adj = Array.from({ length: leftN }, () => []);
this.matchL = new Array(leftN).fill(-1);
this.matchR = new Array(rightN).fill(-1);
}
// 添加左節點 u 到右節點 v 的邊
addEdge(u: number, v: number): void {
this.adj[u].push(v);
}
// 對左節點 u 做 DFS,嘗試找增廣路徑
// visited:本輪 DFS 中已訪問的右節點(避免循環)
private dfs(u: number, visited: boolean[]): boolean {
for (const v of this.adj[u]) {
if (!visited[v]) {
visited[v] = true; // 標記 v 在本輪已嘗試
// 若 v 未匹配,或 v 的當前匹配左節點能改配其他右節點(增廣路徑)
if (this.matchR[v] === -1 || this.dfs(this.matchR[v], visited)) {
// 更新匹配:u 與 v 互相匹配
this.matchL[u] = v;
this.matchR[v] = u;
return true; // 增廣成功
}
}
}
return false; // 無法增廣
}
// 執行最大匹配,返回匹配數量
maxMatching(): number {
let result = 0;
for (let u = 0; u < this.leftN; u++) {
// 每個左節點嘗試一次增廣,visited 每輪重置
const visited = new Array(this.rightN).fill(false);
if (this.dfs(u, visited)) {
result++;
}
}
return result;
}
// 獲取最終匹配對(左節點 → 右節點)
getMatching(): Array<[number, number]> {
const pairs: Array<[number, number]> = [];
for (let u = 0; u < this.leftN; u++) {
if (this.matchL[u] !== -1) {
pairs.push([u, this.matchL[u]]);
}
}
return pairs;
}
}
// ─── 使用範例:工程師任務分配 ──────────────────────────────
// 場景:3 位工程師(左側)匹配 4 個任務(右側)
// 每位工程師有能力完成的任務集合
const bm = new BipartiteMatching(3, 4);
// 工程師 0:能完成任務 0 和任務 1
bm.addEdge(0, 0);
bm.addEdge(0, 1);
// 工程師 1:能完成任務 1 和任務 2
bm.addEdge(1, 1);
bm.addEdge(1, 2);
// 工程師 2:能完成任務 2 和任務 3
bm.addEdge(2, 2);
bm.addEdge(2, 3);
const matchCount = bm.maxMatching();
console.log(`最大匹配數(同時完成的任務數): ${matchCount}`); // 輸出:3
const pairs = bm.getMatching();
pairs.forEach(([eng, task]) => {
console.log(` 工程師${eng} → 任務${task}`);
});
// 工程師0 → 任務0(或任務1)
// 工程師1 → 任務1(或任務2)
// 工程師2 → 任務2(或任務3)
C++ 對照實作
以下是相同演算法的 C++ 實作。C++ 在競賽環境中更常用於網路流,因為其效能優勢和標準的 bits/stdc++.h 萬用標頭檔。
語言差異重點
- C++ 使用
vector<vector<Edge>>代替 TypeScript 的Edge[][],記憶體管理更直接 - C++ 的
int&引用特性讓當前弧優化寫法更簡潔(for (int& i = iter[v]; ...)) - C++ 使用
INT_MAX而 TypeScript 使用Infinity - C++ 的 struct 預設公開成員,而 TypeScript 需明確標記
public
#include <bits/stdc++.h>
using namespace std;
// ===== Dinic 最大流演算法 =====
// 時間複雜度:O(V² × E)
// 對於單位容量圖(二分圖匹配):O(E × √E)
struct Edge {
int to, rev; // 終點,反向邊索引
int cap; // 殘餘容量
};
class MaxFlow {
private:
int n;
vector<vector<Edge>> graph;
vector<int> level; // BFS 分層
vector<int> iter; // 當前弧優化
// BFS 建立分層圖
bool bfs(int s, int t) {
level.assign(n, -1);
queue<int> q;
level[s] = 0;
q.push(s);
while (!q.empty()) {
int v = q.front(); q.pop();
for (const auto& e : graph[v]) {
if (e.cap > 0 && level[e.to] < 0) {
level[e.to] = level[v] + 1;
q.push(e.to);
}
}
}
return level[t] >= 0;
}
// DFS 推阻塞流(含當前弧優化)
// 注意 int& i = iter[v] 是 C++ 引用,直接修改 iter[v] 的值
int dfs(int v, int t, int f) {
if (v == t) return f;
for (int& i = iter[v]; i < (int)graph[v].size(); i++) {
Edge& e = graph[v][i];
if (e.cap > 0 && level[v] < level[e.to]) {
int d = dfs(e.to, t, min(f, e.cap));
if (d > 0) {
e.cap -= d;
graph[e.to][e.rev].cap += d;
return d;
}
}
}
return 0;
}
public:
explicit MaxFlow(int n) : n(n), graph(n), level(n), iter(n) {}
// 添加有向邊(自動添加反向邊)
void addEdge(int from, int to, int cap) {
int fwdIdx = graph[to].size();
int bwdIdx = graph[from].size();
graph[from].push_back({ to, fwdIdx, cap });
graph[to].push_back({ from, bwdIdx, 0 }); // 反向邊初始容量 0
}
// 求最大流
int maxflow(int s, int t) {
int flow = 0;
while (bfs(s, t)) {
iter.assign(n, 0);
int f;
while ((f = dfs(s, t, INT_MAX)) > 0) {
flow += f;
}
}
return flow;
}
// 找最小割的 S 集合(BFS 後 level[v] >= 0 的節點)
vector<bool> minCutS(int s, int t) {
maxflow(s, t); // 先求最大流
bfs(s, t); // 最後一次 BFS 確定可達節點
vector<bool> inS(n);
for (int i = 0; i < n; i++) {
inS[i] = (level[i] >= 0); // 可達 = 在 S 集合中
}
return inS;
}
};
// ===== 匈牙利演算法(二部圖最大匹配)=====
class BipartiteMatching {
private:
int leftN, rightN;
vector<vector<int>> adj;
vector<int> matchR; // matchR[右節點] = 匹配的左節點
bool dfs(int u, vector<bool>& visited) {
for (int v : adj[u]) {
if (!visited[v]) {
visited[v] = true;
if (matchR[v] == -1 || dfs(matchR[v], visited)) {
matchR[v] = u;
return true;
}
}
}
return false;
}
public:
BipartiteMatching(int leftN, int rightN)
: leftN(leftN), rightN(rightN),
adj(leftN), matchR(rightN, -1) {}
void addEdge(int u, int v) {
adj[u].push_back(v);
}
int maxMatching() {
int result = 0;
for (int u = 0; u < leftN; u++) {
vector<bool> visited(rightN, false);
if (dfs(u, visited)) result++;
}
return result;
}
// 輸出最終匹配對
void printMatching() const {
for (int v = 0; v < rightN; v++) {
if (matchR[v] != -1) {
cout << "左" << matchR[v] << " <-> 右" << v << "\n";
}
}
}
};
// ===== 主程式:測試 =====
int main() {
// 測試 Dinic 最大流
{
MaxFlow mf(6);
mf.addEdge(0, 1, 10);
mf.addEdge(0, 2, 10);
mf.addEdge(1, 3, 4);
mf.addEdge(1, 2, 2);
mf.addEdge(2, 4, 9);
mf.addEdge(3, 5, 10);
mf.addEdge(4, 3, 6);
mf.addEdge(4, 5, 10);
cout << "最大流: " << mf.maxflow(0, 5) << "\n"; // 19
}
// 測試二部圖匹配
{
BipartiteMatching bm(3, 4);
bm.addEdge(0, 0); bm.addEdge(0, 1);
bm.addEdge(1, 1); bm.addEdge(1, 2);
bm.addEdge(2, 2); bm.addEdge(2, 3);
cout << "最大匹配: " << bm.maxMatching() << "\n"; // 3
bm.printMatching();
}
return 0;
}
複雜度分析
| 演算法 | 時間複雜度 | 空間複雜度 | 適用場景 |
|---|---|---|---|
| Ford-Fulkerson(DFS) | O(E x |f*|) | O(V + E) | 容量小的整數圖 |
| Edmonds-Karp(BFS) | O(V x E²) | O(V + E) | 中小規模、入門學習 |
| Dinic(分層圖 + 阻塞流) | O(V² x E) | O(V + E) | 通用首選,競賽主力 |
| Dinic(單位容量圖) | O(E x sqrt(V)) | O(V + E) | 二分圖匹配的特例 |
| 匈牙利演算法 | O(V x E) | O(V + E) | 二分圖最大匹配專用 |
| Hopcroft-Karp | O(E x sqrt(V)) | O(V + E) | 大規模二分圖匹配 |
效能比較重點:
- Edmonds-Karp 的 O(V x E²) 在稠密圖上可能很慢,因為 E 可以接近 V²
- Dinic 的 O(V² x E) 在大多數實際案例中表現遠優於理論上界
- 當前弧優化對 Dinic 的實際效能提升巨大——沒有它,DFS 階段退化為 O(VE²)
- 對於二分圖匹配,Dinic 退化為 Hopcroft-Karp 的等效複雜度 O(E x sqrt(V))
變體與延伸
二分圖最大匹配與 Konig 定理
二分圖最大匹配 可以直接轉化為最大流問題:
原二部圖:
左側 L = {A, B, C}
右側 R = {1, 2, 3, 4}
邊:A-1, A-2, B-2, B-3, C-3, C-4
轉化為最大流:
新增超級源點 s,連接 s→A, s→B, s→C(容量 1)
新增超級匯點 t,連接 1→t, 2→t, 3→t, 4→t(容量 1)
原邊方向 A→1, A→2, B→2, B→3, C→3, C→4(容量 1)
最大流 = 最大匹配數
Konig 定理:在二分圖中,最大匹配數 = 最小頂點覆蓋數。這是最大流最小割定理在二分圖上的特例。
最小費用最大流(Minimum Cost Maximum Flow)
當每條邊除了容量之外還有一個「單位流量成本」時,我們希望在達到最大流的前提下,使總成本最小。
做法:將 Edmonds-Karp 中的 BFS 替換為 SPFA(Bellman-Ford 的佇列優化) 或 Dijkstra(需要 Johnson 重標號處理負權邊),每次找成本最低的增廣路徑。
典型應用:
- 物流配送:在滿足需求的前提下最小化運輸成本
- 任務調度:在完成所有任務的前提下最小化人力成本
- 二分圖帶權最佳匹配(等效於 Kuhn-Munkres / 匈牙利帶權演算法)
多源多匯問題
當流網路有多個源點 s₁, s₂, … 和多個匯點 t₁, t₂, … 時,只需添加:
- 超級源點 S:S → s₁(容量 ∞), S → s₂(容量 ∞), …
- 超級匯點 T:t₁ → T(容量 ∞), t₂ → T(容量 ∞), …
然後在新圖上求 S 到 T 的最大流即可。如果各源點/匯點有供應量/需求量限制,可以把容量 ∞ 替換為相應的限制值。
有上下界的流
有些問題要求某些邊的流量不僅有上界,還有下界 l(u,v) <= f(u,v) <= c(u,v)。解法是引入 循環流 的概念,建構輔助網路並轉化為標準最大流問題。
常見面試考點
面試中網路流的常見問法:
概念理解題 — 解釋殘餘網路和反向邊的作用。回答要點:反向邊讓演算法能「反悔」之前的路由決策,是保證找到全域最優解的關鍵
最大流最小割定理的應用 — 給一個網路圖,手算最大流和最小割。要能識別最小割的邊集合,以及它與瓶頸分析的關係
二分圖匹配建模 — 將實際問題(任務分配、排班、配對)轉化為二分圖匹配。關鍵是識別「左側集合」和「右側集合」
Ford-Fulkerson vs Edmonds-Karp vs Dinic 的差異 — 能說清楚三者的增廣策略和時間複雜度差異,以及 Dinic 為何最快
網路流建模 — 面試中最難的部分:將看似不相關的問題轉化為網路流。常見轉化技巧包括「拆點」(一個節點拆為入節點和出節點,用容量邊連接)和「超級源/匯」
常見陷阱:
// 陷阱 1:忘記添加反向邊
// 錯誤:只有正向邊,殘餘網路不完整,演算法無法退流
graph[from].push({ to, cap, rev: 0 }); // 沒有反向邊!
// 正確:同時添加反向邊(容量為 0)
graph[from].push({ to, cap, rev: graph[to].length });
graph[to].push({ to: from, cap: 0, rev: graph[from].length - 1 });
// 陷阱 2:Dinic 遺漏當前弧優化
// 錯誤:每次 DFS 都從第 0 條邊開始,退化為 O(V × E²)
for (const e of graph[v]) { /* 每次都掃描所有邊 */ }
// 正確:用 iter[v] 持續前進
for (; iter[v] < graph[v].length; iter[v]++) { /* 跳過已飽和邊 */ }
// 陷阱 3:匈牙利演算法的 visited 沒有每輪重置
// 錯誤:visited 跨輪持久,導致右側節點被錯誤鎖定
const visited = new Array(rightN).fill(false); // 在迴圈外宣告
for (let u = 0; u < leftN; u++) {
dfs(u, visited); // 前一輪的 visited 影響後一輪!
}
// 正確:每個左節點嘗試前重置 visited
for (let u = 0; u < leftN; u++) {
const visited = new Array(rightN).fill(false); // 每輪重新建立
dfs(u, visited);
}
Ford-Fulkerson 的無限迴圈風險:若容量為無理數(如 1/sqrt(2)),DFS 版本可能永遠不終止——每次增廣的流量越來越小但永遠不歸零。解法:使用 BFS(Edmonds-Karp)或分層圖(Dinic),它們都有多項式時間保證。
LeetCode 練習
網路流在 LeetCode 上的純題目較少(多為 Hard+),但以下題目涉及網路流的核心思想或可用網路流建模:
| 題號 | 題目 | 難度 | 核心技巧 |
|---|---|---|---|
| 1591 | Strange Printer II | Hard | 拓撲排序判斷可行性,類似流網路的有向圖分析 |
| 785 | Is Graph Bipartite? | Medium | 二分圖判定(BFS/DFS 染色),是匹配的前置知識 |
| 1349 | Maximum Students Taking Exam | Hard | 二分圖匹配(匈牙利演算法)或狀態壓縮 DP |
| 1494 | Parallel Courses II | Hard | 狀態壓縮 DP,可用類網路流思路建模 |
| LCP 04 | 覆蓋(Domino and Tromino Tiling 變體) | Hard | 二分圖最大匹配的直接應用 |
LeetCode 785 — Is Graph Bipartite?
判斷一個圖是否為二分圖——這是所有二分圖匹配問題的前置條件。
// BFS 染色法:O(V + E)
// 二分圖的充要條件:不存在奇數長度的環
function isBipartite(graph: number[][]): boolean {
const n = graph.length;
const color = new Array(n).fill(-1); // -1 表示未染色
for (let start = 0; start < n; start++) {
if (color[start] !== -1) continue; // 已染色,跳過
// BFS 染色
const queue: number[] = [start];
color[start] = 0;
let head = 0;
while (head < queue.length) {
const u = queue[head++];
for (const v of graph[u]) {
if (color[v] === -1) {
color[v] = 1 - color[u]; // 鄰居染相反顏色
queue.push(v);
} else if (color[v] === color[u]) {
return false; // 相鄰節點同色,不是二分圖
}
}
}
}
return true;
// 時間複雜度:O(V + E);空間複雜度:O(V)
}
LeetCode 1349 — Maximum Students Taking Exam
這題可以用二分圖匹配來解:將座位按棋盤格染色,相鄰(會作弊)的座位分屬不同顏色組。求最大獨立集 = 總可用座位 - 最大匹配。
// 提示:將座位按 (row + col) % 2 分為兩組
// 建立二分圖:有作弊風險的座位對之間連邊
// 最大可坐學生數 = 可用座位數 - 最大匹配數(Konig 定理)
// 實作時直接用匈牙利演算法求最大匹配
function maxStudents(seats: string[][]): number {
const m = seats.length, n = seats[0].length;
const dirs = [[-1, -1], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 1]];
// 將座位編號,分為左右兩組
let available = 0;
const id = Array.from({ length: m }, () => new Array(n).fill(-1));
const leftNodes: number[][] = []; // (row+col) % 2 === 0 的座位
const rightNodes: number[][] = []; // (row+col) % 2 === 1 的座位
for (let i = 0; i < m; i++) {
for (let j = 0; j < n; j++) {
if (seats[i][j] === '.') {
available++;
if ((i + j) % 2 === 0) {
id[i][j] = leftNodes.length;
leftNodes.push([i, j]);
} else {
id[i][j] = rightNodes.length;
rightNodes.push([i, j]);
}
}
}
}
// 建立二分圖匹配
const bm = new BipartiteMatching(leftNodes.length, rightNodes.length);
for (const [r, c] of leftNodes) {
for (const [dr, dc] of dirs) {
const nr = r + dr, nc = c + dc;
if (nr >= 0 && nr < m && nc >= 0 && nc < n && id[nr][nc] !== -1 && (nr + nc) % 2 === 1) {
bm.addEdge(id[r][c], id[nr][nc]);
}
}
}
// Konig 定理:最大獨立集 = 總數 - 最大匹配
return available - bm.maxMatching();
}
常見陷阱總整理
| 陷阱 | 症狀 | 解法 |
|---|---|---|
| 忘記添加反向邊 | 演算法找不到最優解 | addEdge 中同時 push 反向邊(cap=0) |
| Dinic 缺少當前弧優化 | TLE,時間退化為 O(VE²) | 用 iter[v] 記住掃描位置 |
| 匈牙利 visited 未重置 | 匹配數偏少 | 每輪 new Array(rightN).fill(false) |
| Ford-Fulkerson 用於非整數容量 | 可能無限迴圈 | 改用 Edmonds-Karp 或 Dinic |
| 容量很大時用 Ford-Fulkerson | TLE(O(E x |f*|)) | 改用 Dinic(不依賴流量大小) |
rev 索引計算錯誤 | 正反向邊不對應,結果錯誤 | push 前先記錄 graph[to].length |
總結
本文涵蓋了網路流演算法的核心知識:
- 流網路基礎:容量限制、流量守恆、殘餘網路三大概念構成理解網路流的基石。反向邊的「退流」機制是所有增廣路徑方法能找到最優解的數學保證
- 最大流最小割定理:最大流值 = 最小割容量,這個定理將「推流」和「找瓶頸」兩個問題等價起來,在實際應用中意義深遠
- 三大演算法:Ford-Fulkerson(DFS,O(E x |f*|))→ Edmonds-Karp(BFS,O(VE²))→ Dinic(分層圖 + 阻塞流,O(V²E)),每一步改進都針對前一步的效能瓶頸
- Dinic 演算法 搭配當前弧優化是競程和面試的首選實作,理解 BFS 分層 + DFS 推流 + iter 優化三個組件缺一不可
- 二分圖匹配 是網路流最重要的特例,匈牙利演算法和 Konig 定理是面試高頻考點
網路流的精髓在於 建模能力——把現實問題轉化為流網路。任務分配、影像分割、航線規劃、網路可靠性分析,這些看似不同的問題都能用同一套演算法框架解決。
上一篇:樹演算法 — LCA、樹 DP 與重鏈剖分進階技巧下一篇預告:字串演算法(String Algorithms) — KMP、Rabin-Karp、Trie 與後綴陣列,解鎖文字處理的高效演算法。
希望這篇文章能幫助你深入理解網路流演算法的核心原理與實作技巧。如有任何問題或疑惑,歡迎透過 Contact 頁面 與我聯繫!