Cho một đồ thị có hướng, mỗi cung có một sức chứa, cùng hai đỉnh và . Tìm lượng hàng lớn nhất có thể chuyển từ tới , sao cho hàng đi trên mỗi cung không vượt quá sức chứa của cung đó; với đồ thị đỉnh và cung, thuật toán Dinic giải bài này trong , và trong thực tế còn nhanh hơn nhiều.
Đây là bài toán luồng cực đại (maximum flow). Ý tưởng chung là lặp lại hai việc: tìm một đường đi từ tới còn chở thêm được hàng, rồi đẩy thêm hàng theo đường đó. Điểm mấu chốt là cho phép rút lại phần hàng đã đẩy theo hướng không tốt. Bài học đi từ cách cơ bản nhất (Ford-Fulkerson), qua cách chọn đường thông minh hơn (Edmonds-Karp), tới Dinic, cách thường dùng khi đi thi. Rất nhiều bài trông chẳng liên quan cũng đưa được về luồng cực đại: tách hai đỉnh bằng ít cạnh nhất, ghép cặp, chọn tập có lợi nhất với các ràng buộc kéo theo.
IMạng, luồng và cách tham lam
Một mạng (network) là một đồ thị có hướng, trong đó mỗi cung có một sức chứa , và có hai đỉnh đặc biệt: nguồn và đích . Ta gọi số đỉnh là và số cung là .
Một luồng (flow) gán cho mỗi cung một lượng , gọi là luồng trên cung đó, thỏa hai điều kiện:
- luồng trên mỗi cung không âm và không vượt quá sức chứa: ;
- ở mỗi đỉnh khác và , tổng luồng đi vào bằng tổng luồng đi ra: hàng không tự sinh ra, cũng không mất đi ở giữa đường.
Giá trị của luồng là tổng luồng đi ra khỏi trừ tổng luồng đi vào , cũng bằng lượng hàng tới được . Bài toán luồng cực đại là tìm một luồng có giá trị lớn nhất.
Hình dưới là một mạng bốn đỉnh, mọi cung có sức chứa , với nguồn là đỉnh và đích là đỉnh . Luồng trong hình chở đơn vị theo đường và đơn vị theo đường , nên có giá trị . Đây cũng là giá trị lớn nhất, vì hai cung đi ra khỏi đỉnh chỉ chở được tổng cộng đơn vị.
Cách dễ nghĩ ra nhất là tham lam: tìm một đường đi từ tới mà mọi cung trên đường còn chở thêm được, đẩy thêm theo đường đó một lượng bằng sức chứa còn lại nhỏ nhất trên đường, rồi lặp lại cho tới khi không còn đường nào. Câu hỏi dưới đây thử cách này trên chính mạng ở hình trên.
Hỏi nhanh
Chương trình dưới đây làm đúng cách tham lam trên: mỗi lần tìm một đường bằng DFS, ưu tiên đỉnh có số nhỏ trước, đẩy thêm luồng theo đường đó, và lặp lại cho tới khi không còn đường. Bạn có thể bấm Chạy với mạng bốn đỉnh ở hình trên:
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 105;
ll n, m, s, t, cap[N][N], vis[N];
ll dfs(ll u, ll f) {
if (u == t) return f;
vis[u] = 1;
for (ll v = 1; v <= n; v++)
if (cap[u][v] > 0 && !vis[v]) {
ll d = dfs(v, min(f, cap[u][v]));
if (d > 0) {
cap[u][v] -= d;
return d;
}
}
return 0;
}
signed main() {
cin >> n >> m >> s >> t;
for (ll i = 1; i <= m; i++) {
ll u, v, c;
cin >> u >> v >> c;
cap[u][v] += c;
}
ll flow = 0, f;
do {
for (ll i = 1; i <= n; i++) vis[i] = 0;
f = dfs(s, 1e18);
flow += f;
} while (f > 0);
cout << flow << '\n';
}
4 5 1 4
1 2 1
1 3 1
2 3 1
2 4 1
3 4 1
1
Luồng cực đại là , nhưng chương trình in ra . Vì sao?
DFS ưu tiên đỉnh có số nhỏ, nên đường đầu tiên là , dùng hết hai cung và . Còn lại cung và cung , nhưng không có đường nào nối chúng từ tới , nên chương trình dừng với luồng . Luồng tốt nhất không dùng cung , nhưng cách tham lam đã đẩy luồng qua cung đó và không có cách nào rút lại.
Các lựa chọn còn lại không đúng: vòng do while gọi dfs nhiều lần và xóa mảng vis trước mỗi lần; còn một đỉnh hoàn toàn có thể nằm trên nhiều đường khác nhau.
IICung ngược và Ford-Fulkerson
Ví dụ trên cho thấy cách tham lam có thể đẩy luồng theo hướng không tốt rồi bị kẹt. Cách sửa là cho phép rút lại luồng đã đẩy: nếu cung đang chở đơn vị, ta coi như có thêm một cung chở được đơn vị. Đẩy hàng theo cung này nghĩa là giảm bớt luồng trên cung .
Với một luồng đang có, đồ thị dư (residual graph) gồm các cung sau:
- với mỗi cung của mạng, một cung có sức chứa , là lượng còn đẩy thêm được;
- và một cung ngược có sức chứa , là lượng có thể rút lại.
Cung nào có sức chứa thì coi như không có. Một đường tăng luồng (augmenting path) là một đường đi từ tới trong đồ thị dư. Đẩy thêm theo đường tăng luồng một lượng bằng sức chứa nhỏ nhất trên đường, giá trị luồng tăng đúng chừng ấy.
Quay lại mạng bốn đỉnh. Sau khi đẩy đơn vị theo đường , đồ thị dư có cung ngược . Nhờ cung này, ta tìm được đường tăng luồng : đẩy theo nó thì luồng trên cung được rút về , phần hàng đi tới đỉnh chuyển sang cung , và luồng tăng lên .
Ford-Fulkerson chính là cách tham lam ở trên, nhưng tìm đường trong đồ thị dư: lặp lại việc tìm một đường tăng luồng bất kỳ và đẩy theo nó, cho tới khi không còn đường tăng luồng. Khi lưu sức chứa bằng ma trận, cung ngược chính là ô cap[v][u], nên chỉ cần thêm đúng một dòng vào hàm dfs của chương trình trong câu hỏi trên; phần còn lại giữ nguyên:
ll dfs(ll u, ll f) {
if (u == t) return f;
vis[u] = 1;
for (ll v = 1; v <= n; v++)
if (cap[u][v] > 0 && !vis[v]) {
ll d = dfs(v, min(f, cap[u][v]));
if (d > 0) {
cap[u][v] -= d;
cap[v][u] += d;
return d;
}
}
return 0;
}
Độ phức tạp: mỗi lần tìm đường tốn với ma trận, và mỗi lần đẩy thêm ít nhất đơn vị khi các sức chứa là số nguyên, nên cả thuật toán tốn , với là giá trị luồng cực đại.
Khi không còn đường tăng luồng, luồng đang có là luồng cực đại. Điều này không hiển nhiên: có thể nào luồng chưa cực đại mà đã hết đường tăng? Câu trả lời là không (xem ô Chứng minh). Chứng minh dùng một khái niệm quan trọng: một lát cắt (cut) là một cách chia các đỉnh thành hai phần, phần chứa và phần chứa ; sức chứa của lát cắt là tổng sức chứa các cung đi từ sang .
Chứng minh
Luồng không vượt quá lát cắt nào. Cộng (luồng ra trừ luồng vào) trên mọi đỉnh của : đỉnh góp đúng giá trị luồng, các đỉnh khác góp . Luồng trên các cung nằm trọn trong được cộng một lần và trừ một lần nên triệt tiêu. Vậy giá trị luồng bằng tổng luồng trên các cung từ sang , trừ tổng luồng trên các cung từ về . Số này không vượt quá tổng sức chứa các cung từ sang , tức sức chứa của lát cắt.
Hết đường tăng thì có một lát cắt bằng luồng. Giả sử không còn đường tăng luồng. Gọi là tập các đỉnh tới được từ trong đồ thị dư, là các đỉnh còn lại; vì không còn đường tăng luồng nên thuộc . Với cung từ sang : nếu nó chưa đầy thì đồ thị dư còn cung , và tới được, trái với thuộc ; vậy cung này đầy. Với cung từ về : nếu nó có luồng thì đồ thị dư có cung ngược , và lại tới được; vậy cung này không có luồng. Theo ý trên, giá trị luồng bằng đúng sức chứa của lát cắt .
Hai ý cộng lại: không luồng nào vượt quá sức chứa của lát cắt , mà luồng đang có đã bằng nó, nên luồng đang có là cực đại.
Ford-Fulkerson có thể chạy rất chậm khi giá trị luồng lớn. Trên mạng ở hình dưới, luồng cực đại là . Nếu không may, các đường tăng luồng cứ lần lượt đi qua cung giữa theo hai chiều, rồi , mỗi lần chỉ tăng được , và thuật toán cần tới lần tìm đường.
IIIĐường tăng ngắn nhất: Edmonds-Karp
Edmonds-Karp sửa điểm yếu trên bằng một thay đổi nhỏ: luôn chọn đường tăng luồng ngắn nhất, tức là đi qua ít cung nhất, tìm bằng BFS thay vì DFS. Khi đó số lần tăng luồng không quá , không còn phụ thuộc vào giá trị luồng (xem ô Chứng minh). Trên mạng ở hình trên, hai đường ngắn nhất và được chọn trước, và thuật toán xong sau đúng hai lần tăng.
Trong code, BFS ghi lại đỉnh đứng trước của mỗi đỉnh trên đường đi; tới được thì đi ngược từ về hai lượt: lượt đầu tìm sức chứa nhỏ nhất trên đường, lượt sau đẩy luồng. Hàm dưới đây trả về lượng vừa đẩy, hoặc khi không còn đường tăng luồng; vòng lặp chính gọi nó cho tới khi nhận về .
ll bfs() {
for (ll i = 1; i <= n; i++) par[i] = 0;
queue<ll> q;
q.push(s);
par[s] = s;
while (!q.empty()) {
ll u = q.front();
q.pop();
for (ll v = 1; v <= n; v++)
if (cap[u][v] > 0 && !par[v]) {
par[v] = u;
q.push(v);
}
}
if (!par[t]) return 0;
ll d = 1e18;
for (ll v = t; v != s; v = par[v]) d = min(d, cap[par[v]][v]);
for (ll v = t; v != s; v = par[v]) {
cap[par[v]][v] -= d;
cap[v][par[v]] += d;
}
return d;
}
Độ phức tạp: mỗi lần BFS trên ma trận tốn và có không quá lần tăng luồng, nên cả thuật toán tốn ; nếu lưu đồ thị bằng danh sách cạnh như ở mục sau, mỗi lần BFS chỉ tốn và cả thuật toán tốn . Trong thực tế, số lần tăng luồng thường ít hơn cận này rất nhiều.
Chứng minh
Gọi là khoảng cách, tính bằng số cung, từ tới trong đồ thị dư.
Khoảng cách không bao giờ giảm. Giả sử sau một lần tăng luồng, có đỉnh mà khoảng cách mới nhỏ hơn ; chọn có nhỏ nhất. Gọi là đỉnh đứng ngay trước trên một đường ngắn nhất mới, thì và . Nếu cung đã có trong đồ thị dư cũ thì , mâu thuẫn. Vậy cung mới xuất hiện, tức là nó là cung ngược của cung vừa được đẩy luồng. Đường tăng luồng là đường ngắn nhất nên , suy ra , cũng mâu thuẫn.
Mỗi cung chỉ làm nghẽn không quá lần. Gọi cung có sức chứa nhỏ nhất trên đường tăng luồng là cung nghẽn; sau khi đẩy, nó hết sức chứa và biến mất khỏi đồ thị dư. Lúc cung làm nghẽn, . Muốn nó xuất hiện lại, phải có một lần tăng luồng sau đó đi qua cung ; lúc đó . Vậy giữa hai lần cung làm nghẽn, khoảng cách tới tăng ít nhất . Khoảng cách không quá , nên mỗi cung làm nghẽn không quá lần.
Mỗi lần tăng luồng có ít nhất một cung nghẽn, và đồ thị dư có không quá cung, nên có không quá lần tăng luồng.
IVTăng luồng theo từng pha: Dinic
Edmonds-Karp mỗi lần BFS chỉ dùng được một đường. Ta có thể thấy được điểm lãng phí: thường có nhiều đường tăng luồng cùng ngắn nhất, nhưng mỗi đường lại cần một lần BFS riêng. Dinic dùng một lần BFS cho tất cả các đường ngắn nhất cùng độ dài.
Dinic chạy theo từng pha. Đầu mỗi pha, BFS từ trong đồ thị dư để tính mức của mỗi đỉnh, là khoảng cách từ tới đỉnh đó. Trong pha này, ta chỉ dùng những cung đi từ một đỉnh ở mức tới một đỉnh ở mức ; mọi đường từ tới chỉ đi theo các cung này đều là đường ngắn nhất. Sau đó DFS đẩy luồng theo các đường như vậy, hết đường này tới đường khác, cho tới khi không còn đường nào. Hết pha, ta BFS lại để bắt đầu pha mới; khi BFS không tới được nữa thì luồng đã cực đại.
Để DFS không thử đi thử lại những cung vô ích, mỗi đỉnh giữ một con trỏ cung: chỉ số của cung đầu tiên đáng thử. Một cung đã hết sức chứa, hay dẫn tới một đỉnh không còn đường tới , thì con trỏ bỏ qua nó luôn cho tới hết pha.
Với đồ thị lớn, ma trận kề tốn ô nhớ và mỗi lần duyệt các đỉnh kề tốn . Vì vậy từ đây ta lưu đồ thị bằng danh sách cạnh: các cung được đánh số , mỗi cung lưu đỉnh cuối và sức chứa dư, và mỗi đỉnh giữ danh sách số hiệu các cung đi ra. Khi thêm cung , ta thêm luôn cung ngược với sức chứa ngay sau nó. Hai cung một cặp mang số hiệu và , nên cung ngược của cung là cung , viết trong C++ là e ^ 1.
Trong mô phỏng dưới đây, mỗi pha bắt đầu bằng BFS ghi mức lên các đỉnh, rồi đẩy luồng theo các đường cam; nhãn mỗi cung là luồng trên sức chứa. Bước cuối tô lát cắt nhỏ nhất, sẽ nói ở mục sau.
Hỏi nhanh
Ở một pha của Dinic, BFS vừa tính xong mức của các đỉnh, và đỉnh có mức . Cung trong đồ thị dư còn sức chứa.
Trong pha này, DFS đi theo cung khi nào?
DFS của một pha chỉ đi theo cung từ mức sang mức , nên chỉ khi có mức . Nhờ vậy mọi đường tìm được trong pha đều là đường ngắn nhất, và sau pha, khoảng cách từ tới tăng lên (xem ô Chứng minh dưới).
Đi theo cung về mức thấp hơn hay cùng mức thì đường dài hơn đường ngắn nhất; đó là việc của các pha sau.
Chứng minh
Không quá pha. Gọi là mức của ở đầu pha. Cuối pha, mọi đường từ tới chỉ đi theo cung từ mức sang mức đều có ít nhất một cung đã hết sức chứa. Các cung mới xuất hiện trong pha đều là cung ngược, đi từ mức về mức . Trong đồ thị dư mới, mỗi cung tăng mức thêm nhiều nhất , nên một đường từ tới dài đúng phải chỉ gồm các cung tăng đúng mức, tức là cung cũ của pha, mà không còn đường nào như vậy. Vì vậy khoảng cách từ tới tăng ít nhất sau mỗi pha. Khoảng cách không quá , nên có không quá pha.
Mỗi pha tốn . Mỗi lần DFS tới được , có ít nhất một cung trên đường hết sức chứa, và con trỏ cung sẽ bỏ qua nó; mỗi cung bị bỏ qua vì dẫn vào ngõ cụt cũng không bị thử lại trong pha. Mỗi đường dài không quá , có không quá đường, và tổng số lần bỏ qua cung là , nên cả pha tốn . Cộng lại, Dinic tốn .
Ví dụBăng Thông Cực Đại
Đề. Có máy tính và kết nối một chiều, mỗi kết nối có băng thông không quá . Tìm tốc độ truyền dữ liệu lớn nhất từ máy tới máy .
Lời giải. Đây đúng là bài toán luồng cực đại từ đỉnh tới đỉnh . Trong code, to[e] và cap[e] là đỉnh cuối và sức chứa dư của cung , g[u] là danh sách cung đi ra từ , lv[u] là mức của đỉnh , và it[u] là con trỏ cung của . Mỗi pha gọi bfs để tính mức, đặt lại con trỏ cung, rồi gọi dfs liên tiếp cho tới khi nó trả về . Nhiều kết nối giữa cùng hai máy chỉ là các cung song song, không cần gộp. Luồng có thể tới , nên cần dùng kiểu long long.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 505, M = 2005, INF = 4e18;
ll n, m, ec, to[M], cap[M], lv[N], it[N];
vector<ll> g[N];
void addEdge(ll u, ll v, ll c) {
to[ec] = v, cap[ec] = c, g[u].push_back(ec++);
to[ec] = u, cap[ec] = 0, g[v].push_back(ec++);
}
bool bfs(ll s, ll t) {
for (ll i = 0; i <= n; i++) lv[i] = -1;
queue<ll> q;
q.push(s);
lv[s] = 0;
while (!q.empty()) {
ll u = q.front();
q.pop();
for (ll e : g[u])
if (cap[e] > 0 && lv[to[e]] < 0) {
lv[to[e]] = lv[u] + 1;
q.push(to[e]);
}
}
return lv[t] >= 0;
}
ll dfs(ll u, ll t, ll f) {
if (u == t) return f;
for (; it[u] < (ll)g[u].size(); it[u]++) {
ll e = g[u][it[u]], v = to[e];
if (cap[e] > 0 && lv[v] == lv[u] + 1) {
ll d = dfs(v, t, min(f, cap[e]));
if (d > 0) {
cap[e] -= d;
cap[e ^ 1] += d;
return d;
}
}
}
return 0;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> m;
for (ll i = 1; i <= m; i++) {
ll a, b, c;
cin >> a >> b >> c;
addEdge(a, b, c);
}
ll flow = 0;
while (bfs(1, n)) {
for (ll i = 0; i <= n; i++) it[i] = 0;
ll f;
while ((f = dfs(1, n, INF)) > 0) flow += f;
}
cout << flow << '\n';
}
5 7
1 2 4
1 3 7
2 4 3
3 2 2
3 4 4
4 5 6
2 5 1
7
Độ phức tạp: trong trường hợp xấu nhất; với và cung (tính cả cung ngược), cận này là , nhưng thực tế Dinic chạy nhanh hơn cận này rất nhiều.
Nếu bỏ con trỏ cung, chương trình vẫn đúng nhưng chậm hơn: mỗi lần dfs lại thử lại từ đầu mọi cung đã vô ích. Hình dưới đo trên một mạng ghép cặp lớn:
Chỉ thêm một mảng con trỏ mà chương trình nhanh hơn khoảng lần, và chênh lệch thường càng lớn khi đồ thị càng dày.
VLát cắt nhỏ nhất
Nhắc lại, một lát cắt chia các đỉnh thành phần chứa và phần chứa , và sức chứa của lát cắt là tổng sức chứa các cung từ sang . Bài toán lát cắt nhỏ nhất (minimum cut) là tìm lát cắt có sức chứa nhỏ nhất. Nói cách khác: xóa các cung có tổng sức chứa nhỏ nhất để không còn đường đi từ tới .
Ô Chứng minh ở mục Cung ngược và Ford-Fulkerson đã cho ta một kết quả quan trọng, gọi là định lý luồng cực đại bằng lát cắt nhỏ nhất: giá trị luồng cực đại đúng bằng sức chứa của lát cắt nhỏ nhất. Chứng minh đó cũng cho cách tìm lát cắt: chạy luồng cực đại xong, BFS thêm một lần từ trong đồ thị dư; các đỉnh tới được là phần , và các cung từ sang là các cung cần xóa.
Hỏi nhanh
Luồng cực đại vừa chạy xong. Gọi là các đỉnh tới được từ trong đồ thị dư, là các đỉnh còn lại. Xét một cung của mạng, với thuộc và thuộc .
Luồng trên cung bằng bao nhiêu?
Nếu luồng trên cung nhỏ hơn sức chứa thì đồ thị dư còn cung ; khi đó từ đi tiếp được tới , nên tới được từ , trái với việc thuộc . Vậy cung này đầy.
Luồng bằng là tính chất của các cung đi theo chiều ngược lại, từ về . Hai lựa chọn còn lại không đúng vì lập luận trên áp dụng cho mọi cung từ sang .
Bài “xóa ít cạnh nhất để tách khỏi ” là bài lát cắt nhỏ nhất với sức chứa cho mỗi cạnh. Với đồ thị vô hướng, mỗi cạnh , được thay bằng hai cung và cùng sức chứa . Trong cài đặt bằng danh sách cạnh, ta chỉ cần một cặp cung số hiệu và , cả hai đều có sức chứa : cặp cung này vừa là hai chiều của cạnh, vừa là cung ngược của nhau.
Sử dụng kiến thức này cũng có thể giải bài: ngoài việc xóa cạnh, được phép tắt một số đỉnh đặc biệt để chúng không còn nhận hàng, nhưng hàng vẫn đi qua chúng được. Thêm một đích chung, nối mỗi đỉnh đặc biệt tới đích chung bằng một cung sức chứa ; cắt cung đó nghĩa là tắt đỉnh đó.
Ví dụChốt Chặn Tối Thiểu
Đề. Cho đồ thị vô hướng đỉnh, cạnh. Đóng ít cạnh nhất để không còn đường đi giữa đỉnh và đỉnh ; in số cạnh cần đóng và các cạnh đó.
Lời giải. Đây là bài lát cắt nhỏ nhất với sức chứa cho mỗi cạnh, nên số cạnh cần đóng bằng luồng cực đại. Mỗi cạnh là một cặp cung cùng sức chứa như trên. Chạy Dinic xong, gọi bfs(1, n) thêm một lần: lúc này không tới được , nhưng các đỉnh có lv[v] >= 0 chính là các đỉnh tới được từ , tức là phần . Cạnh có đúng một đầu thuộc là cạnh cần đóng; theo câu hỏi trên, các cạnh này đều đầy theo chiều từ sang , nên số cạnh đúng bằng luồng.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 505, M = 2005, INF = 4e18;
ll n, m, ec, to[M], cap[M], lv[N], it[N], ea[M], eb[M];
vector<ll> g[N];
bool bfs(ll s, ll t) {
for (ll i = 0; i <= n; i++) lv[i] = -1;
queue<ll> q;
q.push(s);
lv[s] = 0;
while (!q.empty()) {
ll u = q.front();
q.pop();
for (ll e : g[u])
if (cap[e] > 0 && lv[to[e]] < 0) {
lv[to[e]] = lv[u] + 1;
q.push(to[e]);
}
}
return lv[t] >= 0;
}
ll dfs(ll u, ll t, ll f) {
if (u == t) return f;
for (; it[u] < (ll)g[u].size(); it[u]++) {
ll e = g[u][it[u]], v = to[e];
if (cap[e] > 0 && lv[v] == lv[u] + 1) {
ll d = dfs(v, t, min(f, cap[e]));
if (d > 0) {
cap[e] -= d;
cap[e ^ 1] += d;
return d;
}
}
}
return 0;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> m;
for (ll i = 1; i <= m; i++) {
cin >> ea[i] >> eb[i];
to[ec] = eb[i], cap[ec] = 1, g[ea[i]].push_back(ec++);
to[ec] = ea[i], cap[ec] = 1, g[eb[i]].push_back(ec++);
}
ll flow = 0;
while (bfs(1, n)) {
for (ll i = 0; i <= n; i++) it[i] = 0;
ll f;
while ((f = dfs(1, n, INF)) > 0) flow += f;
}
bfs(1, n);
cout << flow << '\n';
for (ll i = 1; i <= m; i++)
if ((lv[ea[i]] >= 0) != (lv[eb[i]] >= 0)) cout << ea[i] << ' ' << eb[i] << '\n';
}
6 8
1 2
1 3
2 4
3 4
2 5
3 5
4 6
5 6
2
1 2
1 3
Độ phức tạp: luồng không quá , mỗi pha tốn khi mọi sức chứa bằng , và số pha cũng nhỏ; với chương trình chạy rất nhanh, cỡ bước.
VIGhép cặp hai phía
Bài toán: có hai nhóm đỉnh và , và một số cạnh nối một đỉnh của với một đỉnh của . Chọn nhiều cạnh nhất sao cho không có hai cạnh được chọn nào chung đỉnh. Các cạnh được chọn gọi là một cặp ghép (matching).
Ta dựng một mạng: thêm nguồn nối tới mọi đỉnh của bằng cung sức chứa ; mọi đỉnh của nối tới đích bằng cung sức chứa ; mỗi cạnh giữa và thành cung từ sang sức chứa . Mỗi đỉnh của nhận nhiều nhất đơn vị từ nguồn, mỗi đỉnh của gửi nhiều nhất đơn vị tới đích, nên mỗi luồng mà luồng trên mọi cung là số nguyên ứng với đúng một cặp ghép: cặp ghép gồm các cung từ sang có luồng . Khi mọi sức chứa là số nguyên, Ford-Fulkerson, Edmonds-Karp và Dinic đều cho luồng như vậy, vì mỗi lần đẩy chỉ cộng trừ các số nguyên.
Trên mạng ghép cặp, Dinic chạy trong , nhanh hơn nhiều so với cận chung .
Ví dụGhép Đôi Sân Khấu
Đề. Có người ở nhóm thứ nhất, người ở nhóm thứ hai, và cặp hợp lệ, mỗi cặp gồm một người mỗi nhóm. Chọn nhiều cặp nhất, mỗi người thuộc nhiều nhất một cặp, và in các cặp được chọn.
Lời giải. Dựng mạng ghép cặp như trên. Đánh số: nguồn là đỉnh , nhóm thứ nhất là các đỉnh tới , nhóm thứ hai là các đỉnh tới , đích là đỉnh . Hàm addEdge trả về số hiệu cung vừa thêm, và mảng eid giữ số hiệu cung của từng cặp hợp lệ. Chạy Dinic xong, cung của một cặp hết sức chứa nghĩa là có luồng đi qua, tức là cặp đó được chọn. Nếu một cặp xuất hiện hai lần trong dữ liệu, ta có hai cung song song, nhưng nhiều nhất một cung được dùng, vì mỗi người ở nhóm thứ nhất chỉ nhận đơn vị từ nguồn.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 1005, M = 6005, INF = 4e18;
ll n, m, k, V, ec, to[M], cap[M], lv[N], it[N], ea[M], eb[M], eid[M];
vector<ll> g[N];
bool bfs(ll s, ll t) {
for (ll i = 0; i <= V; i++) lv[i] = -1;
queue<ll> q;
q.push(s);
lv[s] = 0;
while (!q.empty()) {
ll u = q.front();
q.pop();
for (ll e : g[u])
if (cap[e] > 0 && lv[to[e]] < 0) {
lv[to[e]] = lv[u] + 1;
q.push(to[e]);
}
}
return lv[t] >= 0;
}
ll dfs(ll u, ll t, ll f) {
if (u == t) return f;
for (; it[u] < (ll)g[u].size(); it[u]++) {
ll e = g[u][it[u]], v = to[e];
if (cap[e] > 0 && lv[v] == lv[u] + 1) {
ll d = dfs(v, t, min(f, cap[e]));
if (d > 0) {
cap[e] -= d;
cap[e ^ 1] += d;
return d;
}
}
}
return 0;
}
ll addEdge(ll u, ll v, ll c) {
to[ec] = v, cap[ec] = c, g[u].push_back(ec++);
to[ec] = u, cap[ec] = 0, g[v].push_back(ec++);
return ec - 2;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> m >> k;
V = n + m + 1;
for (ll i = 1; i <= k; i++) {
cin >> ea[i] >> eb[i];
eid[i] = addEdge(ea[i], n + eb[i], 1);
}
for (ll i = 1; i <= n; i++) addEdge(0, i, 1);
for (ll j = 1; j <= m; j++) addEdge(n + j, V, 1);
ll flow = 0;
while (bfs(0, V)) {
for (ll i = 0; i <= V; i++) it[i] = 0;
ll f;
while ((f = dfs(0, V, INF)) > 0) flow += f;
}
cout << flow << '\n';
for (ll i = 1; i <= k; i++)
if (cap[eid[i]] == 0) cout << ea[i] << ' ' << eb[i] << '\n';
}
4 3 7
1 1
1 2
2 1
2 3
3 2
4 2
4 3
3
1 1
2 3
3 2
Độ phức tạp: trên mạng ghép cặp; với đỉnh và cung, cần cỡ bước.
VIIChọn dự án
Bài toán: có một số dự án, mỗi dự án mang lại một khoản lợi nhuận, và một số công cụ, mỗi công cụ tốn một khoản chi phí. Mỗi dự án cần một số công cụ: chọn làm dự án thì phải mua đủ các công cụ nó cần. Chọn các dự án và công cụ để tổng lợi nhuận trừ tổng chi phí lớn nhất.
Dựng mạng như sau:
- nguồn nối tới mỗi dự án bằng cung có sức chứa bằng lợi nhuận của dự án;
- mỗi công cụ nối tới đích bằng cung có sức chứa bằng chi phí của công cụ;
- nếu dự án cần công cụ , thêm cung có sức chứa vô hạn, trong code là một số rất lớn.
Với một lát cắt có sức chứa hữu hạn, ta chọn các dự án và công cụ thuộc phần . Đáp án là tổng lợi nhuận của mọi dự án trừ đi sức chứa của lát cắt nhỏ nhất (xem ô Chứng minh).
Chứng minh
Lát cắt có sức chứa hữu hạn không cắt cung vô hạn nào. Vì vậy nếu dự án thuộc và cần công cụ , thì cũng thuộc : cách chọn ứng với luôn hợp lệ. Ngược lại, mọi cách chọn hợp lệ cho một lát cắt hữu hạn, với gồm và các dự án, công cụ được chọn.
Các cung bị cắt, tức đi từ sang , gồm: cung từ tới các dự án không được chọn, và cung từ các công cụ được chọn tới . Vậy sức chứa của lát cắt bằng (lợi nhuận của các dự án không chọn) cộng (chi phí của các công cụ được chọn). Lấy tổng lợi nhuận của mọi dự án trừ đi số này, ta được đúng (lợi nhuận của các dự án được chọn) trừ (chi phí của các công cụ được chọn). Vì vậy lát cắt càng nhỏ thì cách chọn càng tốt.
Câu hỏi nhanh dành cho tài khoản Pro
Mở khóa bằng tài khoản ProVí dụChọn Dự Án Trên Đồ Thị
Đề. Cho đồ thị đỉnh, đỉnh thứ có chi phí , và cạnh, mỗi cạnh có trọng số . Chọn một số cạnh; chọn cạnh nào thì phải chọn cả hai đầu mút của nó. Giá trị là tổng trọng số các cạnh được chọn trừ tổng chi phí các đỉnh được chọn. Tìm giá trị lớn nhất.
Lời giải. Mỗi cạnh là một dự án với lợi nhuận , mỗi đỉnh là một công cụ với chi phí , và mỗi cạnh cần hai đầu mút của nó. Đánh số: nguồn là đỉnh , các cạnh là đỉnh tới , các đỉnh của đồ thị là đỉnh tới , đích là đỉnh . Đáp án là tổng trọng số mọi cạnh trừ đi luồng cực đại. Sức chứa vô hạn đặt bằng , lớn hơn hẳn mọi lát cắt hữu hạn (tổng trọng số không quá ), và vì hàm dfs luôn lấy min với sức chứa thật nên không bị tràn số. Không chọn cạnh nào thì giá trị là , nên đáp án không bao giờ âm.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 2005, M = 10005, INF = 4e18;
ll n, m, V, ec, to[M], cap[M], lv[N], it[N];
vector<ll> g[N];
void addEdge(ll u, ll v, ll c) {
to[ec] = v, cap[ec] = c, g[u].push_back(ec++);
to[ec] = u, cap[ec] = 0, g[v].push_back(ec++);
}
bool bfs(ll s, ll t) {
for (ll i = 0; i <= V; i++) lv[i] = -1;
queue<ll> q;
q.push(s);
lv[s] = 0;
while (!q.empty()) {
ll u = q.front();
q.pop();
for (ll e : g[u])
if (cap[e] > 0 && lv[to[e]] < 0) {
lv[to[e]] = lv[u] + 1;
q.push(to[e]);
}
}
return lv[t] >= 0;
}
ll dfs(ll u, ll t, ll f) {
if (u == t) return f;
for (; it[u] < (ll)g[u].size(); it[u]++) {
ll e = g[u][it[u]], v = to[e];
if (cap[e] > 0 && lv[v] == lv[u] + 1) {
ll d = dfs(v, t, min(f, cap[e]));
if (d > 0) {
cap[e] -= d;
cap[e ^ 1] += d;
return d;
}
}
}
return 0;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> m;
V = n + m + 1;
for (ll i = 1; i <= n; i++) {
ll a;
cin >> a;
addEdge(m + i, V, a);
}
ll sum = 0;
for (ll i = 1; i <= m; i++) {
ll u, v, w;
cin >> u >> v >> w;
sum += w;
addEdge(0, i, w);
addEdge(i, m + u, INF);
addEdge(i, m + v, INF);
}
ll flow = 0;
while (bfs(0, V)) {
for (ll i = 0; i <= V; i++) it[i] = 0;
ll f;
while ((f = dfs(0, V, INF)) > 0) flow += f;
}
cout << sum - flow << '\n';
}
2 1
3 4
1 2 10
3
Độ phức tạp: Dinic trên đỉnh và cung; cận rất lớn, nhưng thực tế chương trình chạy trong vài mili giây.
Bài nâng cao
Xem 2 bài nâng cao
Ví dụNgắt Các Bệ Nhảy
Đề. Một bảng () gồm ô trống ., bệ thường o, bệ xuất phát S và bệ đích T. Từ bệ đang đứng, có thể nhảy thẳng tới bất kỳ bệ nào nằm cùng hàng hoặc cùng cột, bất kể các ô ở giữa. Tháo ít bệ o nhất để không thể đi từ S tới T; không được tháo S và T. In số bệ ít nhất cần tháo, hoặc nếu không thể.
Lời giải. Nếu S và T cùng hàng hoặc cùng cột thì có thể nhảy thẳng, đáp án là . Ngược lại, cách dựng mạng tự nhiên nhất là mỗi bệ một đỉnh, nối hai bệ cùng hàng hoặc cùng cột, rồi tìm cách xóa ít đỉnh nhất. Nhưng một hàng có tới bệ thì đã có gần cặp, và xóa đỉnh lại cần thêm một bước biến đổi.
Ta đổi cách nhìn: thay vì đỉnh là bệ, cho mỗi hàng và mỗi cột là một đỉnh, còn mỗi bệ là một cạnh nối hàng của nó với cột của nó. Đứng trên một bệ nghĩa là có thể nhảy tới mọi bệ khác cùng hàng hoặc cùng cột, tức là mọi cạnh có chung một đầu với cạnh của bệ đó. Vì vậy một cách nhảy từ S tới T ứng với một đường đi trong đồ thị mới, đi từ cạnh của S qua các cạnh của các bệ tới cạnh của T, và ngược lại. Tháo một bệ là xóa một cạnh.
Như vậy bài toán trở thành: xóa ít cạnh nhất để tách hàng của S khỏi hàng của T, trong đó cạnh của S và cạnh của T không được xóa. Đó là lát cắt nhỏ nhất: mỗi bệ o là cạnh vô hướng sức chứa , còn hai bệ S và T là cạnh vô hướng sức chứa vô hạn. Đồ thị chỉ có đỉnh và không quá cạnh.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 205, M = 20005, INF = 1e9;
ll h, w, n, ec, to[M], cap[M], lv[N], it[N];
vector<ll> g[N];
void addEdge(ll u, ll v, ll c) {
to[ec] = v, cap[ec] = c, g[u].push_back(ec++);
to[ec] = u, cap[ec] = c, g[v].push_back(ec++);
}
bool bfs(ll s, ll t) {
for (ll i = 0; i <= n; i++) lv[i] = -1;
queue<ll> q;
q.push(s);
lv[s] = 0;
while (!q.empty()) {
ll u = q.front();
q.pop();
for (ll e : g[u])
if (cap[e] > 0 && lv[to[e]] < 0) {
lv[to[e]] = lv[u] + 1;
q.push(to[e]);
}
}
return lv[t] >= 0;
}
ll dfs(ll u, ll t, ll f) {
if (u == t) return f;
for (; it[u] < (ll)g[u].size(); it[u]++) {
ll e = g[u][it[u]], v = to[e];
if (cap[e] > 0 && lv[v] == lv[u] + 1) {
ll d = dfs(v, t, min(f, cap[e]));
if (d > 0) {
cap[e] -= d;
cap[e ^ 1] += d;
return d;
}
}
}
return 0;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> h >> w;
n = h + w;
ll si = 0, sj = 0, ti = 0, tj = 0;
for (ll i = 1; i <= h; i++) {
string row;
cin >> row;
for (ll j = 1; j <= w; j++) {
if (row[j - 1] == 'o') addEdge(i, h + j, 1);
if (row[j - 1] == 'S') si = i, sj = j;
if (row[j - 1] == 'T') ti = i, tj = j;
}
}
if (si == ti || sj == tj) {
cout << -1 << '\n';
return 0;
}
addEdge(si, h + sj, INF);
addEdge(ti, h + tj, INF);
ll flow = 0;
while (bfs(si, ti)) {
for (ll i = 0; i <= n; i++) it[i] = 0;
ll f;
while ((f = dfs(si, ti, INF)) > 0) flow += f;
}
cout << flow << '\n';
}
3 4
S.o.
....
..T.
1
Độ phức tạp: Dinic trên đỉnh và không quá cung; luồng không quá số bệ, nên chương trình chạy rất nhanh.
Ví dụBàn Tròn Tổng Nguyên Tố
Đề. Có thẻ (), thẻ thứ mang giá trị (). Chia mọi thẻ thành một hoặc nhiều nhóm, mỗi nhóm ít nhất ba thẻ và được xếp trên một vòng tròn, sao cho mọi cặp thẻ kề nhau trên cùng vòng tròn có tổng là số nguyên tố. In số vòng tròn và các thẻ trên mỗi vòng theo thứ tự, hoặc in Impossible nếu không xếp được.
Lời giải. Nhận xét về số nguyên tố. Mọi giá trị đều ít nhất , nên tổng hai thẻ ít nhất ; số nguyên tố lớn hơn đều lẻ, nên hai thẻ kề nhau phải có một thẻ lẻ và một thẻ chẵn. Vì vậy các thẻ chia thành hai nhóm, thẻ lẻ và thẻ chẵn, và mỗi thẻ có đúng hai thẻ kề, đều thuộc nhóm kia.
Mạng. Nguồn nối tới mỗi thẻ lẻ bằng cung sức chứa ; mỗi thẻ chẵn nối tới đích bằng cung sức chứa ; thẻ lẻ nối tới thẻ chẵn bằng cung sức chứa khi là số nguyên tố. Mỗi thẻ cần đúng hai thẻ kề, nên ta cần một luồng làm đầy mọi cung đi ra khỏi nguồn và mọi cung đi vào đích. Điều đó chỉ xảy ra khi số thẻ lẻ bằng số thẻ chẵn và luồng cực đại bằng đúng ; nếu không thì in Impossible. Kiểm tra số nguyên tố tới bằng sàng Eratosthenes (bài 0.9).
Ghép thành vòng. Các cung giữa hai nhóm có luồng cho mỗi thẻ đúng hai thẻ kề. Đồ thị gồm các cạnh này có mọi đỉnh bậc , nên tách thành các chu trình rời nhau. Mỗi chu trình xen kẽ thẻ lẻ và thẻ chẵn, và không có hai cạnh song song giữa cùng hai thẻ, nên mỗi chu trình có ít nhất bốn thẻ. Ta đi theo từng chu trình để in các vòng tròn: bắt đầu ở một thẻ chưa thăm, mỗi bước đi tới thẻ kề chưa thăm có số nhỏ hơn, cho tới khi quay về gần điểm đầu.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 205, M = 25005, INF = 4e18, C = 20005;
ll n, V, ec, a[N], to[M], cap[M], lv[N], it[N], vis[N], ea[M], eb[M], eid[M];
bool comp[C];
vector<ll> g[N], adj[N];
ll addEdge(ll u, ll v, ll c) {
to[ec] = v, cap[ec] = c, g[u].push_back(ec++);
to[ec] = u, cap[ec] = 0, g[v].push_back(ec++);
return ec - 2;
}
bool bfs(ll s, ll t) {
for (ll i = 0; i <= V; i++) lv[i] = -1;
queue<ll> q;
q.push(s);
lv[s] = 0;
while (!q.empty()) {
ll u = q.front();
q.pop();
for (ll e : g[u])
if (cap[e] > 0 && lv[to[e]] < 0) {
lv[to[e]] = lv[u] + 1;
q.push(to[e]);
}
}
return lv[t] >= 0;
}
ll dfs(ll u, ll t, ll f) {
if (u == t) return f;
for (; it[u] < (ll)g[u].size(); it[u]++) {
ll e = g[u][it[u]], v = to[e];
if (cap[e] > 0 && lv[v] == lv[u] + 1) {
ll d = dfs(v, t, min(f, cap[e]));
if (d > 0) {
cap[e] -= d;
cap[e ^ 1] += d;
return d;
}
}
}
return 0;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
for (ll i = 2; i < C; i++)
if (!comp[i])
for (ll j = i * i; j < C; j += i) comp[j] = 1;
cin >> n;
for (ll i = 1; i <= n; i++) cin >> a[i];
V = n + 1;
ll k = 0;
for (ll i = 1; i <= n; i++) {
if (a[i] % 2 == 0) {
addEdge(i, V, 2);
continue;
}
addEdge(0, i, 2);
for (ll j = 1; j <= n; j++)
if (a[j] % 2 == 0 && !comp[a[i] + a[j]]) {
k++;
ea[k] = i, eb[k] = j;
eid[k] = addEdge(i, j, 1);
}
}
ll flow = 0;
while (bfs(0, V)) {
for (ll i = 0; i <= V; i++) it[i] = 0;
ll f;
while ((f = dfs(0, V, INF)) > 0) flow += f;
}
if (flow != n) {
cout << "Impossible\n";
return 0;
}
for (ll i = 1; i <= k; i++)
if (cap[eid[i]] == 0) {
adj[ea[i]].push_back(eb[i]);
adj[eb[i]].push_back(ea[i]);
}
vector<vector<ll> > cycles;
for (ll i = 1; i <= n; i++) {
if (vis[i]) continue;
vector<ll> cyc;
ll u = i;
while (u) {
vis[u] = 1;
cyc.push_back(u);
ll nxt = 0;
for (ll v : adj[u])
if (!vis[v] && (!nxt || v < nxt)) nxt = v;
u = nxt;
}
cycles.push_back(cyc);
}
cout << cycles.size() << '\n';
for (ll i = 0; i < (ll)cycles.size(); i++) {
cout << cycles[i].size();
for (ll v : cycles[i]) cout << ' ' << v;
cout << '\n';
}
}
4
3 2 5 8
1
4 1 2 3 4
Độ phức tạp: sàng với ; mạng có đỉnh và không quá cung, Dinic chạy rất nhanh; ghép vòng tốn .
Khi đi thi
- Gặp “xóa ít nhất để tách khỏi ”, “ghép được nhiều cặp nhất”, “nhiều đường đi không chung cạnh nhất”, hay “chọn tập có lợi nhất với ràng buộc kéo theo” thì nghĩ tới luồng. Phần khó là dựng đúng mạng: nên vẽ mạng ra trên ví dụ nhỏ của đề và kiểm tra bằng tay.
- Giới hạn đặt trên đỉnh thì tách mỗi đỉnh thành hai đỉnh, một đỉnh vào và một đỉnh ra, nối bằng một cung có sức chứa bằng giới hạn đó. Cạnh vô hướng là một cặp cung cùng sức chứa theo hai chiều.
- Dinic cài bằng danh sách cạnh, cung ngược của cung là cung , có con trỏ cung, thường đủ nhanh cho mạng cỡ đỉnh và cung. Sức chứa vô hạn đặt bằng một số rất lớn như , và không cộng hai số như vậy với nhau.
Tóm tắt
- Đồ thị dư gồm cung xuôi với sức chứa còn lại và cung ngược với lượng có thể rút lại; luồng là cực đại khi và chỉ khi không còn đường tăng luồng.
- Ford-Fulkerson tìm đường tăng bất kỳ, có thể chậm khi luồng lớn; Edmonds-Karp tìm đường ngắn nhất bằng BFS, không quá lần tăng; Dinic tăng luồng theo từng pha trên đồ thị phân mức, .
- Luồng cực đại bằng lát cắt nhỏ nhất; phần của lát cắt là các đỉnh tới được từ trong đồ thị dư cuối cùng.
- Ghép cặp hai phía: nguồn tới , tới , tới đích, mọi sức chứa bằng ; cặp ghép là các cung từ sang có luồng.
- Chọn dự án: lợi nhuận nối từ nguồn, chi phí nối tới đích, ràng buộc là cung vô hạn; đáp án là tổng lợi nhuận trừ lát cắt nhỏ nhất.
Bài tập
Bài tập ngoài
Cung ngược và Ford-Fulkerson
Delivery BearsCodeforcesIILát cắt nhỏ nhất
Treasure IslandCodeforcesVĐọc thêm
- Bài toán Luồng cực đại trên mạngVNOI Wiki
- Ứng dụng của luồng trên mạngVNOI Wiki
- Maximum flow - Ford-Fulkerson and Edmonds-Karpcp-algorithms
- Maximum flow - Dinic's algorithmcp-algorithms
- Maximum FlowUSACO Guide