Bài 41: Network Flow — Luồng Cực Đại¶
1. Bài toán luồng cực đại¶
Bản chất vấn đề¶
Cho đồ thị có hướng \(G = (V, E)\) với:
- Nguồn \(s\): đỉnh bắt đầu
- Bể \(t\): đỉnh kết thúc
- Dung lượng \(c(u,v)\): lượng tối đa chảy qua cạnh \((u,v)\)
Một luồng hợp lệ \(f\) phải thỏa mãn hai ràng buộc:
- Ràng buộc dung lượng: \(0 \le f(u,v) \le c(u,v)\) với mọi cạnh
- Bảo toàn luồng: Với mọi đỉnh \(v \notin \{s, t\}\), tổng luồng vào bằng tổng luồng ra:
Mục tiêu: Tìm giá trị luồng cực đại \(|f| = \sum_{v} f(s,v)\).
Tư duy cốt lõi¶
Ẩn dụ đơn giản: hình dung một hệ thống ống nước. Nước chảy từ nguồn \(s\) qua các ống có giới hạn dung lượng đến bể \(t\). Câu hỏi là: tối đa bao nhiêu nước có thể chảy qua hệ thống?
Minh họa một mạng luồng nhỏ:
Trong sơ đồ trên, mỗi cạnh hiển thị \(f/c\) (luồng hiện tại / dung lượng tối đa).
2. Ford-Fulkerson & Đồ thị dư¶
Bản chất vấn đề¶
Ford-Fulkerson là phương pháp (framework) tổng quát, không phải một thuật toán cụ thể. Mọi thuật toán luồng cực đại đều dựa trên ý tưởng này.
Tư duy cốt lõi¶
Bước 1: Bắt đầu với luồng \(f = 0\) trên mọi cạnh.
Bước 2: Tìm một đường tăng luồng (augmenting path) từ \(s\) đến \(t\) trong đồ thị dư.
Bước 3: Tăng luồng dọc theo đường đó một lượng bằng bottleneck (cạnh nhỏ nhất trên đường đi).
Bước 4: Lặp lại cho đến khi không còn đường tăng luồng nào.
Đồ thị dư (Residual Graph)¶
Đồ thị dư là chìa khóa của toàn bộ lý thuyết. Với mỗi cạnh \((u,v)\) có dung lượng \(c\) và luồng \(f\):
- Cạnh thuận: từ \(u\) đến \(v\) với dung lượng dư \(c - f\) (còn đẩy thêm được bao nhiêu)
- Cạnh ngược: từ \(v\) đến \(u\) với dung lượng dư \(f\) (có thể "rút lại" bao nhiêu luồng)
Tại sao cần cạnh ngược? Khi ta đẩy luồng sai đường, cạnh ngược cho phép "hủy" luồng đó và đẩy lại theo hướng khác. Đây là cơ chế cốt lõi đảm bảo thuật toán luôn tìm được lời giải tối ưu.
Ví dụ minh họa¶
Xem xét mạng luồng ban đầu với 2 đường đi từ \(s\) đến \(t\):
Lần 1: Tìm đường \(s \to A \to t\), bottleneck = 5. Đẩy luồng 5.
Lần 2: Tìm đường \(s \to B \to t\), bottleneck = 10. Đẩy luồng 10.
Tổng luồng = 15 = dung lượng cắt nhỏ nhất \(\{s\}\) vs \(\{A, B, t\}\).

Phân tích tính đúng đắn¶
Định lý Max-Flow Min-Cut (Ford-Fulkerson 1956): Luồng cực đại = Giá trị cắt nhỏ nhất.
Cắt \(s\)-\(t\) là cách chia đỉnh thành hai tập \(S\) và \(T\) sao cho \(s \in S\), \(t \in T\). Giá trị cắt là tổng dung lượng các cạnh đi từ \(S\) sang \(T\):
Minh họa cắt nhỏ nhất:
Giá trị cắt = \(10 + 5 = 15\). Luồng cực đại không thể vượt quá 15, và ta đã đạt được 15.
Chứng minh (phác thảo):
- Luồng \(\le\) cắt: Mọi đơn vị luồng từ \(s\) đến \(t\) phải đi qua ít nhất một cạnh trong cắt. Tổng luồng qua cắt \(\le\) tổng dung lượng cắt.
- Luồng = cắt: Khi thuật toán dừng, tồn tại cắt mà luồng = giá trị cắt. Cạnh cắt đầy (\(f = c\)), các cạnh khác cân bằng.
Đánh giá độ phức tạp¶
Nếu dùng DFS để tìm đường tăng, thuật toán có thể chạy vô hạn khi dung lượng là số vô tỉ. Với dung lượng nguyên, độ phức tạp là \(O(E \cdot f^*)\) trong đó \(f^*\) là luồng cực đại — có thể rất lớn.
3. Edmonds-Karp (BFS)¶
Bản chất vấn đề¶
Edmonds-Karp là phiên bản cụ thể của Ford-Fulkerson, dùng BFS thay vì DFS để tìm đường tăng luồng. Sự thay đổi đơn giản này đảm bảo thuật toán luôn dừng trong thời gian đa thức.
Tư duy cốt lõi¶
Thay vì tìm đường tăng bất kỳ, ta luôn tìm đường tăng ngắn nhất (ít cạnh nhất) bằng BFS.
Tại sao ngắn nhất lại tốt? Khi mỗi bước chỉ tìm đường ngắn nhất, ta có thể chứng minh rằng khoảng cách từ \(s\) đến \(t\) trong đồ thị dư không giảm sau mỗi lần tăng luồng. Điều này giới hạn số lần tăng luồng.
Phân tích tính đúng đắn¶
Bổ đề quan trọng: Trong Edmonds-Karp, khoảng cách ngắn nhất từ \(s\) đến \(t\) trong đồ thị dư (gọi là \(\delta\)) không giảm sau mỗi lần tăng luồng.
Chứng minh ý tưởng: Giả sử sau khi tăng luồng, \(\delta\) giảm. Xét đường tăng cuối cùng trước khi \(\delta\) giảm. Đường đó phải đi qua ít nhất một cạnh "bị đầy" (trở thành cạnh ngược mới). Phân tích các trường hợp cho thấy điều này mâu dẩn.
Hệ quả: Mỗi cạnh chỉ có thể trở thành "critical edge" (cạnh bị đầy trên đường tăng) tối đa \(O(V)\) lần. Tổng số lần tăng luồng là \(O(VE)\).
Đánh giá độ phức tạp¶
- Mỗi lần BFS: \(O(E)\)
- Số lần tăng luồng: \(O(VE)\)
- Tổng: \(O(VE^2)\)
Cài đặt¶
4. Dinic's Algorithm¶
Bản chất vấn đề¶
Edmonds-Karp chạy \(O(VE^2)\) — mỗi BFS tìm một đường tăng. Với đồ thị lớn (hàng chục nghìn đỉnh), ta cần thuật toán nhanh hơn. Dinic cải thiện bằng cách mỗi lần BFS xây dựng toàn bộ đồ thị tầng, sau đó dùng DFS đẩy nhiều đường tăng cùng lúc.
Tư duy cốt lõi¶
Thuật toán gồm 2 giai đoạn lặp lại:
Giai đoạn 1 — Xây đồ thị tầng (BFS):
Dùng BFS đánh số "tầng" \(level[v]\) cho mỗi đỉnh. Chỉ giữ cạnh \((u,v)\) nào thỏa mãn \(level[v] = level[u] + 1\).
Giai đoạn 2 — Tìm Blocking Flow (DFS):
Trên đồ thị tầng, dùng DFS tìm nhiều đường tăng cùng lúc cho đến khi không còn đường nào. Kết quả gọi là blocking flow — luồng mà không còn đường tăng nào trên đồ thị tầng.
Sau khi blocking flow được tìm, ít nhất một cạnh trên mỗi đường tăng bị đầy. Đồ thị tầng cũ không còn hữu dụng → quay lại giai đoạn 1.
Phân tích tính đúng đắn¶
Bổ đề: Khoảng cách \(level[t]\) từ \(s\) đến \(t\) trong đồ thị dư tăng ít nhất 1 sau mỗi giai đoạn.
Chứng minh: Khi blocking flow được tìm, mọi đường tăng ngắn nhất trong đồ thị tầng đều bị chặn. Đường tăng mới (nếu có) phải đi qua cạnh ngược mới tạo ra, tức là phải đi qua đỉnh ở tầng cao hơn → \(level[t]\) tăng.
Hệ quả: Số giai đoạn (số lần BFS) tối đa là \(V - 1\).
Current Edge Optimization (Con trỏ ptr):
Khi DFS tại đỉnh \(u\) duyệt xong cạnh thứ \(i\) mà không đẩy được luồng, lần sau không cần duyệt lại cạnh \(0..i\). Dùng mảng \(ptr[u]\) để nhớ vị trí đã duyệt. Nếu không dùng \(ptr\), Dinic có thể chậm đi rất nhiều!
Đánh giá độ phức tạp¶
- Số lần BFS: \(O(V)\)
- Mỗi giai đoạn DFS tổng cộng: \(O(VE)\) (mỗi cạnh được duyệt tối đa một lần nhờ \(ptr\))
- Tổng: \(O(V^2 E)\)
Trường hợp đặc biệt — Đồ thị hai phía: \(O(E\sqrt{V})\). Vì mỗi đường tăng trong bipartite graph có độ dài cố định, số giai đoạn chỉ là \(O(\sqrt{V})\).
Cài đặt¶
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 | |
5. Min-Cost Max-Flow¶
Bản chất vấn đề¶
Giống max-flow, nhưng mỗi cạnh có thêm chi phí \(cost(u,v)\). Mục tiêu kép:
- Đẩy luồng cực đại từ \(s\) đến \(t\)
- Trong số các luồng cực đại, chọn luồng có tổng chi phí nhỏ nhất
Tư duy cốt lõi¶
Thuật toán Successive Shortest Path:
- Bắt đầu với luồng \(f = 0\)
- Trong đồ thị dư, mỗi cạnh có cost:
- Cạnh thuận \((u,v)\): \(cost(u,v)\)
- Cạnh ngược \((v,u)\): \(-cost(u,v)\) (rút luồng → hoàn tiền)
- Mỗi bước, tìm đường tăng có chi phí nhỏ nhất bằng SPFA hoặc Dijkstra với potential
- Đẩy luồng dọc theo đường đó
- Lặp cho đến khi không còn đường tăng
Dijkstra với Potential (Nâng cao):
SPFA chạy worst-case \(O(VE)\) mỗi lần. Để cải thiện, dùng hàm tiềm năng \(h[v]\):
- Lần đầu dùng Bellman-Ford/SPFA để tính \(h[v]\)
- Các lần sau, trọng số cạnh điều chỉnh thành \(cost(u,v) + h[u] - h[v] \ge 0\)
- Dùng Dijkstra thay SPFA → \(O(F \cdot E \log V)\) thay vì \(O(F \cdot VE)\)
Phân tích tính đúng đắn¶
Mỗi bước tìm đường ngắn nhất trong đồ thị dư. Cạnh ngược có cost âm (hoàn tiền), nhưng nhờ potential function, trọng số điều chỉnh luôn không âm → Dijkstra hoạt động đúng.
Khi không còn đường tăng, luồng hiện tại là luồng cực đại. Vì mỗi bước chọn đường chi phí nhỏ nhất, tổng chi phí được tối thiểu hóa trong số các luồng cực đại.
Đánh giá độ phức tạp¶
- SPFA: \(O(F \cdot VE)\) trong đó \(F\) là giá trị luồng cực đại
- Dijkstra + Potential: \(O(F \cdot E \log V)\)
Cài đặt¶
6. Ứng dụng¶
6.1 Khớp đôi đồ thị hai phía (Bipartite Matching)¶
Cho đồ thị hai phía \(G = (U, V, E)\). Tìm tập cạnh đối lớn nhất.
Giả bài toán về max-flow:
- Tạo đỉnh nguồn \(s\), nối đến mọi đỉnh trong \(U\) với dung lượng 1
- Nối cạnh từ \(U\) đến \(V\) theo đồ thị gốc với dung lượng 1
- Nối mọi đỉnh trong \(V\) đến bể \(t\) với dung lượng 1
- Chạy max-flow → kết quả = kích thước khớp đôi lớn nhất
Max-flow = 3 → Khớp đôi lớn nhất có 3 cặp.
Độ phức tạp: \(O(E\sqrt{V})\) nhờ tính chất đồ thị hai phía của Dinic.
6.2 Định lý König — Đỉnh bao nhỏ nhất¶
Định lý König: Trong đồ thị hai phía, kích thước đỉnh bao nhỏ nhất = kích thước khớp đôi lớn nhất.
Tìm đỉnh bao nhỏ nhất từ max-flow:
- Chạy max-flow trên đồ thị hai phía
- Dùng BFS từ \(s\) trong đồ thị dư, đánh dấu đỉnh reachable
- Đỉnh bao = \((U \setminus \text{reachable}) \cup (V \cap \text{reachable})\)
6.3 Tập độc lập lớn nhất trong đồ thị hai phía¶
Tập độc lập lớn nhất = \(|V| -\) đỉnh bao nhỏ nhất = \(|V| -\) khớp đôi lớn nhất.
6.4 Đường đi cạnh không chung (Edge-Disjoint Paths)¶
Tìm số đường đi từ \(s\) đến \(t\) sao cho không cạnh nào được dùng quá một lần.
Giải: Cho mỗi cạnh dung lượng 1, chạy max-flow. Kết quả = số đường đi cạnh không chung.
6.5 Truy vết cắt nhỏ nhất (Min Cut)¶
Sau khi chạy max-flow, dùng BFS/DFS từ \(s\) trong đồ thị dư. Các đỉnh reachable thuộc tập \(S\), còn lại thuộc tập \(T\). Cạnh cắt là các cạnh từ \(S\) sang \(T\) trong đồ thị gốc mà đã đầy (\(f = c\)).
Hàm minCut() trong cài đặt Dinic ở mục 4 thực hiện chính xác việc này.
7. Lưu ý & Cạm bẫy¶
7.1 Quên cạnh ngược trong đồ thị dư¶
addEdge(u, v, cap) phải tạo cả cạnh \(u \to v\) (dung lượng \(cap\)) lẫn cạnh \(v \to u\) (dung lượng 0). Nếu quên cạnh ngược, thuật toán không thể "rút" luồng đã đẩy sai → sai kết quả!
7.2 Tràn số nguyên với dung lượng lớn¶
Dung lượng có thể lên đến \(10^9\), tổng luồng có thể lên đến \(10^{14}\). Bắt buộc dùng long long (int64), không dùng int!
7.3 MCMF với chu trình âm¶
Nếu đồ thị có chu trình âm trong chi phí, SPFA sẽ bị kẹt trong vòng lặp vô hạn. Đảm bảo đồ thị không có chu trình âm, hoặc dùng Bellman-Ford lần đầu để phát hiện.
7.4 Đồ thị có cạnh song song¶
Nhiều cạnh cùng chiều giữa hai đỉnh → không vấn đề, chỉ cần thêm tất cả vào danh sách cạnh.
7.5 Self-loop¶
Cạnh từ đỉnh về chính mình → có thể bỏ qua, không ảnh hưởng kết quả.
7.6 Đồ thị vô hướng¶
Cạnh vô hướng \((u,v)\) với dung lượng \(c\) tương đương hai cạnh có hướng \((u,v)\) và \((v,u)\) cùng dung lượng \(c\). Gọi addEdge(u, v, cap) và addEdge(v, u, cap).
8. Bài tập luyện tập¶
| Bài | Nền tảng | Độ khó | Chủ đề |
|---|---|---|---|
| CSES - Download Speed | CSES | ⭐⭐ | Max flow cơ bản |
| CSES - Police Chase | CSES | ⭐⭐⭐ | Min cut |
| CSES - School Dance | CSES | ⭐⭐ | Bipartite matching |
| SPOJ - FASTFLOW | SPOJ | ⭐⭐⭐ | Dinic với dung lượng lớn |
| SPOJ - MATCHING | SPOJ | ⭐⭐⭐ | Bipartite matching lớn |
| CF 277E - Binary Tree on Plane | CF | ⭐⭐⭐⭐ | MCMF |
| LeetCode - Maximum Students Taking Exam | LeetCode | ⭐⭐⭐⭐ | Max flow / bitmask |
Gợi ý giải:
- CSES Download Speed: Bài mẫu max-flow. Cài Dinic hoặc Edmonds-Karp, chạy trực tiếp.
- CSES Police Chase: Chạy max-flow, rồi dùng BFS trên đồ thị dư để tìm cắt nhỏ nhất.
- CSES School Dance: Giả bài về bipartite matching → max-flow.
- SPOJ FASTFLOW: Dung lượng lớn → bắt buộc dùng Dinic (Edmonds-Karp sẽ TLE).
- SPOJ MATCHING: Đồ thị hai phía lớn → Dinic chạy \(O(E\sqrt{V})\).
- CF 277E: Xây đồ thị, gán chi phí cho cạnh, chạy MCMF.
Tóm tắt¶
| Thuật toán | Độ phức tạp | Khi nào dùng |
|---|---|---|
| Edmonds-Karp | \(O(VE^2)\) | Đồ thị nhỏ, dễ cài |
| Dinic | \(O(V^2 E)\) | Hầu hết bài max-flow |
| Dinic (bipartite) | \(O(E\sqrt{V})\) | Khớp đôi đồ thị hai phía |
| MCMF (SPFA) | \(O(F \cdot VE)\) | Luồng chi phí nhỏ nhất |
| MCMF (Dijkstra) | \(O(F \cdot E \log V)\) | MCMF đồ thị lớn |
Hãy học thuộc cài đặt Dinic. Đây là thuật toán luồng phổ biến nhất trong competitive programming. Khi gặp bài liên quan đến flow, hãy nghĩ đến việc mô hình hóa bài toán thành mạng luồng trước!