Min Cost Flow
Min Cost Flow (luồng cực đại chi phí nhỏ nhất, viết tắt MCMF — Min Cost Max Flow) giải bài toán: trong một mạng mà mỗi cạnh vừa có sức chứa (capacity) vừa có chi phí trên một đơn vị luồng (cost), hãy đẩy một lượng luồng cho trước (hoặc luồng cực đại) từ nguồn tới đích sao cho tổng chi phí nhỏ nhất.
Đây là tổng quát hoá của bài toán luồng cực đại: ngoài việc tối đa hoá lượng luồng, ta còn muốn trả ít tiền nhất. Rất nhiều bài toán tối ưu hoá tổ hợp (phân công, vận tải, ghép đôi có trọng số) quy về MCMF. Thuật toán chuẩn (SSP — Successive Shortest Paths) chạy trong hoặc tuỳ cách tìm đường, trong đó là tổng lượng luồng cần đẩy — nhanh hơn nhiều so với việc liệt kê mọi cách phân bổ.
Ý tưởng / Trực giác
Ý tưởng cốt lõi: lặp đi lặp lại việc đẩy luồng dọc theo đường tăng luồng (augmenting path) RẺ NHẤT, thay vì đường bất kỳ như trong Ford–Fulkerson thông thường.
Vì sao điều này cho ra tổng chi phí nhỏ nhất? Dựa trên một định lý kinh điển:
Nếu luồng hiện tại đang là luồng rẻ nhất trong số mọi luồng có cùng giá trị , thì sau khi tăng thêm dọc theo đường ngắn nhất (theo chi phí) trong đồ thị thặng dư (residual graph), luồng mới cũng rẻ nhất trong số mọi luồng có giá trị .
Nói cách khác, ta xây dựng lời giải tối ưu từng lớp: luồng giá trị 0 rẻ nhất (chi phí 0); rồi mỗi lần tăng luồng theo đường rẻ nhất, ta giữ được tính tối ưu cho giá trị luồng mới. Đây chính là một dạng quy hoạch lồi (đường cong chi phí theo lượng luồng là lồi — đẩy mỗi đơn vị luồng tiếp theo không bao giờ rẻ hơn đơn vị trước).
Điểm mấu chốt là cạnh ngược (residual edge). Khi đẩy luồng qua cạnh với chi phí , ta tạo cạnh ngược với chi phí . Cạnh âm này cho phép thuật toán "hoàn tác" một quyết định cũ nếu sau này phát hiện cách rẻ hơn — đây là lý do ta cần thuật toán tìm đường ngắn nhất xử lý được trọng số âm (Bellman–Ford / SPFA), chứ không dùng Dijkstra thuần.
Vì sao đường rẻ nhất không tạo chu trình âm? Do bất biến ở trên: nếu luồng hiện tại đã tối ưu cho giá trị của nó, thì đồ thị thặng dư không chứa chu trình chi phí âm — nếu có thì ta đã có thể giảm chi phí mà không đổi giá trị luồng, mâu thuẫn với "đang tối ưu". Nhờ vậy thuật toán tìm đường ngắn nhất luôn hợp lệ ở mỗi bước.
Ví dụ chạy tay
Mạng có 4 đỉnh: nguồn , . Mỗi cạnh ghi (cap, cost). Ta cần đẩy 2 đơn vị luồng từ tới .
(1, 1)
0 -----------> 1
| |
(2,2)| |(2,1)
| |
v v
2 -----------> 3
(2, 3)
và cạnh 1 -> 2 (1, 1)
Liệt kê cạnh:
- : cap 1, cost 1
- : cap 2, cost 2
- : cap 2, cost 1
- : cap 1, cost 1
- : cap 2, cost 3
Vòng 1 — tìm đường rẻ nhất từ 0 đến 3. Chạy SPFA, mảng dist:
| đỉnh | 0 | 1 | 2 | 3 |
|---|---|---|---|---|
| dist | 0 | 1 | 2 | 2 |
Đường rẻ nhất: , chi phí . Nút cổ chai (bottleneck) = .
Đẩy 1 đơn vị. Cập nhật cap (giảm chiều thuận, tăng chiều ngược):
0 ->1 : cap 1->0 (ngược 1->0 : cap 0->1)
1 ->3 : cap 2->1 (ngược 3->1 : cap 0->1)
Trạng thái: flow = 1, cost = 1 * 2 = 2.
Vòng 2 — tìm lại đường rẻ nhất. Cạnh đã hết chỗ (cap 0). SPFA mới:
| đỉnh | 0 | 1 | 2 | 3 |
|---|---|---|---|---|
| dist | 0 | ∞ | 2 | 5 |
Đường rẻ nhất còn lại: , chi phí . Bottleneck , nhưng ta chỉ còn cần đơn vị nữa, nên push = min(2, 1) = 1.
Đẩy 1 đơn vị. Trạng thái: flow = 2, cost = 2 + 1*5 = 7.
Đã đủ 2 đơn vị → dừng. Kết quả: luồng = 2, tổng chi phí = 7.
Lưu ý: đáp án này tối ưu vì ở mỗi vòng ta luôn chọn đường rẻ nhất hiện có ( rồi ); không có cách đẩy 2 đơn vị nào rẻ hơn .
Cài đặt
Cài đặt bằng SPFA (Bellman–Ford dạng hàng đợi) để xử lý được cạnh ngược chi phí âm. Mỗi vòng tìm một đường rẻ nhất rồi đẩy tối đa dọc đường đó.
#include <bits/stdc++.h>
using namespace std;
struct MinCostFlow {
struct Edge { int to, rev; long long cap, cost; };
vector<vector<Edge>> graph;
int n;
MinCostFlow(int n) : n(n), graph(n) {}
// Thêm cạnh có hướng from->to: sức chứa cap, chi phí cost mỗi đơn vị.
// Đồng thời thêm cạnh ngược to->from: cap 0, cost -cost (để hoàn tác).
void add_edge(int from, int to, long long cap, long long cost) {
graph[from].push_back({to, (int)graph[to].size(), cap, cost});
graph[to].push_back({from, (int)graph[from].size() - 1, 0, -cost});
}
// Trả về {tổng luồng đẩy được, tổng chi phí}.
// flow_limit: chặn trên lượng luồng (mặc định = vô hạn => luồng cực đại).
pair<long long, long long> min_cost_flow(int s, int t,
long long flow_limit = LLONG_MAX) {
long long flow = 0, cost = 0;
while (flow < flow_limit) {
// SPFA tìm đường rẻ nhất s -> t trong đồ thị thặng dư.
vector<long long> dist(n, LLONG_MAX);
vector<bool> in_queue(n, false);
vector<int> prev_v(n, -1), prev_e(n, -1); // truy vết đường đi
dist[s] = 0;
queue<int> q;
q.push(s); in_queue[s] = true;
while (!q.empty()) {
int v = q.front(); q.pop();
in_queue[v] = false;
for (int i = 0; i < (int)graph[v].size(); i++) {
Edge& e = graph[v][i];
// chỉ đi qua cạnh còn chỗ và làm giảm khoảng cách
if (e.cap > 0 && dist[v] != LLONG_MAX
&& dist[v] + e.cost < dist[e.to]) {
dist[e.to] = dist[v] + e.cost;
prev_v[e.to] = v;
prev_e[e.to] = i;
if (!in_queue[e.to]) {
q.push(e.to);
in_queue[e.to] = true;
}
}
}
}
// Không còn đường tăng luồng tới t => dừng.
if (dist[t] == LLONG_MAX) break;
// Tìm nút cổ chai dọc đường vừa tìm được.
long long push = flow_limit - flow;
for (int v = t; v != s; v = prev_v[v])
push = min(push, graph[prev_v[v]][prev_e[v]].cap);
// Đẩy luồng: giảm chiều thuận, tăng chiều ngược.
for (int v = t; v != s; v = prev_v[v]) {
Edge& e = graph[prev_v[v]][prev_e[v]];
e.cap -= push;
graph[v][e.rev].cap += push; // cạnh ngược tăng theo
}
flow += push;
cost += push * dist[t]; // chi phí = lượng đẩy * giá đường này
}
return {flow, cost};
}
};
Ví dụ dùng cho bài toán phân công ( công nhân, việc): tạo nguồn nối tới mỗi công nhân (cap 1, cost 0), mỗi công nhân nối tới việc (cap 1, cost ), mỗi việc nối tới đích (cap 1, cost 0). Gọi min_cost_flow(S, T) cho ra tổng chi phí phân công nhỏ nhất.
Độ phức tạp
Gọi = số đỉnh, = số cạnh, = tổng lượng luồng cần đẩy.
- Thời gian: với SPFA như trên. Mỗi vòng
whileđẩy ít nhất 1 đơn vị luồng (thường nhiều hơn nhờ bottleneck), nên số vòng . Mỗi vòng chạy một lần SPFA, trường hợp xấu nhất . Nếu các sức chứa là số nguyên nhỏ và đồ thị thưa thì thực tế chạy rất nhanh. - Nếu thay SPFA bằng Dijkstra + thế năng Johnson (Johnson potentials) để khử trọng số âm, mỗi vòng giảm còn , tổng — đáng dùng khi nhỏ nhưng đồ thị lớn.
- Bộ nhớ: cho danh sách kề (mỗi cạnh lưu kèm một cạnh ngược, tức phần tử), cộng cho các mảng
dist,prev_v,prev_e,in_queuemỗi lần SPFA.
Vì sao xuất hiện trong độ phức tạp? Vì mỗi vòng chỉ tăng giá trị luồng một lượng, nên muốn đạt luồng cần tới vòng (xấu nhất). Đây là lý do MCMF chỉ thực tế khi không quá lớn (bài toán thường chọn dữ liệu để nhỏ, ví dụ hoặc ).
⚠️ Lỗi thường gặp
- Tràn số (overflow): tổng chi phí = (lượng luồng) × (chi phí đường) cộng dồn nhiều vòng, dễ vượt
int. Luôn đểcapvàcostkiểulong long. Một test với và chi phí cạnh đã cho tổng –, biênintrất nguy hiểm. - Dùng Dijkstra thuần trên cạnh âm: đồ thị thặng dư có cạnh ngược chi phí âm. Dijkstra thuần sẽ cho khoảng cách SAI. Phải dùng SPFA/Bellman–Ford, hoặc Dijkstra kèm thế năng (Johnson). Triệu chứng: đáp án nhỏ hơn/khác đáp án đúng ở các test có đường hoàn tác.
- Khởi tạo
distchưa kiểm tra vô hạn: điều kiện nới lỏng phải códist[v] != LLONG_MAXtrước khi tínhdist[v] + e.cost. Quên kiểm tra sẽ làmLLONG_MAX + costtràn xuống số âm và "mở khoá" những cạnh không nên đi — đáp án sai một cách khó hiểu. - Quên cạnh ngược hoặc đặt sai chi phí cạnh ngược: cạnh ngược phải có
cap = 0vàcost = -cost. Nếu để chi phí cạnh ngược bằng+cost, thuật toán không hoàn tác được và cho luồng tối ưu cục bộ sai. Nếu đểcapcạnh ngược khác 0, sẽ tạo luồng "ma". - Lẫn lộn
rev(chỉ số cạnh ngược): khiadd_edge,revcủa cạnh thuận phải là kích thước hiện tại củagraph[to](vị trí cạnh ngược sắp thêm), vàrevcủa cạnh ngược làgraph[from].size() - 1. Sai chỉ số này làm việc cập nhật cạnh ngược ghi nhầm cạnh khác. - Vòng lặp vô tận khi mạng có chu trình chi phí âm sẵn trong đầu vào: thuật toán SSP giả định không có chu trình âm ban đầu. Nếu bài cho phép cạnh chi phí âm tạo chu trình âm, cần khử trước (vd kỹ thuật negative cycle canceling), nếu không SPFA có thể không hội tụ.
- Đặt
flow_limitsai cho bài "đẩy đúng đơn vị": với bài như vận chuyển đúng kiện hàng, phải truyềnflow_limit = kvà kiểm traflow == kở cuối; nếuflow < knghĩa là không đủ sức chứa, phải in thay vì in chi phí của luồng chưa đủ.
Biến thể / Mở rộng
- Min cost max flow: gọi
min_cost_flow(s, t)không giới hạn → vừa cực đại hoá luồng vừa cực tiểu hoá chi phí. - Đẩy đúng đơn vị: truyền
flow_limit = k. - Tối đa hoá lợi nhuận: nếu cạnh có "lãi" thay vì "chi phí", nhân chi phí với rồi tìm min cost (hoặc dừng sớm khi đường rẻ nhất bắt đầu mang chi phí dương).
- Dijkstra + thế năng (Johnson potentials): tăng tốc cho đồ thị lớn, nhỏ; biến mọi cạnh thành chi phí không âm nhờ thế năng cập nhật sau mỗi vòng.
- Liên hệ: MCMF là tổng quát của luồng cực đại (bỏ chi phí) và là công cụ giải bài toán phân công (assignment) cũng như ghép đôi có trọng số (weighted bipartite matching).
Bài tập luyện
- Bài Toán Phân Công (assgn) — (Kỳ cựu) Bài phân công kinh điển công nhân– việc: dựng mạng công nhân việc , mỗi cạnh công nhân–việc cost , chạy MCMF lấy tổng chi phí nhỏ nhất và truy vết cách ghép.
- Giao Hàng (parceldeliv) — (Kỳ cựu) MCMF trực tiếp nhất: mỗi tuyến đường là cạnh có sức chứa và chi phí /đơn vị; đặt
flow_limit = k, kiểm tra đẩy đủ kiện hay in . - Ghép Đôi Platinum (pairedup21) — (Chuyên gia) Ghép đôi có ràng buộc khoảng cách và trọng số, cần mô hình hoá khéo thành luồng chi phí nhỏ nhất/lớn nhất — luyện kỹ năng dựng mạng cho bài tối ưu tổ hợp.