Flujo máximo
| Hecho | Fuente | Nombre | Dificultad | Tags | Solución |
|---|---|---|---|---|---|
| CSES | Download Speed | Fácil | Max Flow | Solución |
Solución
Explicación
El algoritmo de Edmonds-Karp usa un enfoque voraz para resolver el problema de flujo máximo.
La velocidad de descarga (el flujo) se puede mejorar mientras podamos encontrar un camino aumentante (augmenting path) de capacidad no negativa desde la fuente (el servidor) hasta el sumidero (la computadora de Kotivalo). Usamos BFS para hallar ese camino.
Luego actualizamos los pesos de las aristas a lo largo de este camino aumentante. Cada arista , pierde un peso equivalente a su capacidad en el camino aumentante, mientras que cada arista inversa , gana ese peso.
Se puede demostrar que el número total de aumentos de flujo (llamadas a BFS) es y que cada llamada a BFS requiere de tiempo.
Implementación
Complejidad temporal:
#include <bits/stdc++.h>
using namespace std;
long long max_flow(vector<vector<int>> adj, vector<vector<long long>> capacity,
int source, int sink) {
int n = adj.size();
vector<int> parent(n, -1);
// Hallar un camino de la fuente al sumidero con capacidades no negativas
auto reachable = [&]() -> bool {
queue<int> q;
q.push(source);
while (!q.empty()) {
int node = q.front();
q.pop();
for (int son : adj[node]) {
long long w = capacity[node][son];
if (w <= 0 || parent[son] != -1) continue;
parent[son] = node;
q.push(son);
}
}
return parent[sink] != -1;
};
long long flow = 0;
// Mientras exista un camino de la fuente al sumidero con capacidades no
// negativas
while (reachable()) {
int node = sink;
// La capacidad mínima en el camino de la fuente al sumidero
long long curr_flow = LLONG_MAX;
while (node != source) {
curr_flow = min(curr_flow, capacity[parent[node]][node]);
node = parent[node];
}
node = sink;
while (node != source) {
// Restar la capacidad de las aristas de capacidad
capacity[parent[node]][node] -= curr_flow;
// Sumar el flujo actual a las aristas inversas
capacity[node][parent[node]] += curr_flow;
node = parent[node];
}
flow += curr_flow;
fill(parent.begin(), parent.end(), -1);
}
return flow;
}
int main() {
int n, m;
cin >> n >> m;
vector<vector<long long>> capacity(n, vector<long long>(n));
vector<vector<int>> adj(n);
for (int i = 0; i < m; i++) {
int a, b, c;
cin >> a >> b >> c;
adj[--a].push_back(--b);
adj[b].push_back(a);
capacity[a][b] += c;
}
cout << max_flow(adj, capacity, 0, n - 1) << endl;
}Algoritmo Push-Relabel
El algoritmo Push-Relabel es una solución alternativa para hallar el flujo máximo.
Para hallar el flujo máximo, manejaremos un preflujo (preflow). La única diferencia entre esto y un flujo normal es que el flujo entrante puede superar al flujo saliente.
Definamos el exceso de flujo del nodo como , donde es el flujo entrante y es el flujo saliente.
El algoritmo empieza con un preflujo inicial en el que la fuente tiene un exceso de flujo. En cada etapa, elige un nodo con exceso y empuja (push) el exceso hacia sus vecinos, si la capacidad lo permite. Para empujar exceso de flujo del nodo al nodo , definimos , donde es el flujo máximo que soporta la arista . El proceso se detiene cuando ya no quedan nodos con exceso en la red de flujo.
Otra característica importante del algoritmo es la función de etiquetado , también conocida como función de altura, que asigna a cada nodo un entero. Un etiquetado es válido si cumple las siguientes condiciones: , , y . Si hay una arista del nodo al nodo con capacidad positiva, es decir, si soporta más flujo. La función de altura nos dice hacia dónde enviar el exceso de flujo, y dónde se necesita. Es como el agua: solo puede fluir de arriba hacia abajo.
Para resumir: empezamos con un preflujo válido y un etiquetado válido. En cada paso, para cada nodo con exceso, intentamos empujar el exceso hacia los vecinos del nodo. Después de cada paso comprobamos si el flujo y el etiquetado siguen siendo válidos. Si lo son y ya no hay caminos entre la y el , el flujo máximo se ha encontrado.
La diferencia entre Edmonds-Karp (o Ford-Fulkerson) y Push-Relabel es que el primero mantiene un flujo válido todo el tiempo y lo mejora mientras haya caminos aumentantes, mientras que en Push-Relabel no existe un camino aumentante en ningún momento, y se mejora el preflujo hasta alcanzar el flujo máximo.
Complejidad temporal:
#include <bits/stdc++.h>
#define ll long long
using namespace std;
const int MAXN = 5000;
const ll INF = 1e15;
int n, m, source, sink;
ll capacity[MAXN + 1][MAXN + 1];
ll flow[MAXN + 1][MAXN + 1];
vector<ll> excess;
vector<ll> height;
vector<ll> next_son;
queue<int> excess_vertexes;
void relabel(int u) {
ll d = INF;
for (int v = 1; v <= n; v++) {
// Si el vecino soporta flujo.
if (capacity[u][v] > flow[u][v]) { d = min(d, height[v]); }
}
if (d < INF) { height[u] = d + 1; }
}
// Empujar flujo del nodo u al nodo v
void push(int u, int v) {
// delta = cuánto flujo soporta la arista
ll d = min(excess[u], capacity[u][v] - flow[u][v]);
// Aumentar el flujo del nodo u a v
flow[u][v] += d;
flow[v][u] -= d;
// Actualizar el exceso
excess[u] -= d;
excess[v] += d;
// Si el exceso de v es igual a delta, es la primera vez.
if (d && excess[v] == d) { excess_vertexes.push(v); }
}
void discharge(int u) {
// Mientras haya exceso de flujo para empujar
while (excess[u] > 0) {
// La siguiente parte también se puede escribir con un for
if (next_son[u] <= n) {
int v = next_son[u];
// Si la arista soporta más flujo y se puede empujar hacia ella
if (capacity[u][v] > flow[u][v] && height[u] > height[v]) {
push(u, v);
} else {
next_son[u]++;
}
} else {
relabel(u);
next_son[u] = 1;
}
}
}
int main() {
cin >> n >> m;
source = 1;
sink = n;
excess.resize(n + 1);
height.resize(n + 1);
next_son.resize(n + 1);
height[source] = n;
excess[source] = INF;
for (int i = 1; i <= m; i++) {
int x, y, cap;
cin >> x >> y >> cap;
capacity[x][y] += cap;
}
for (int i = 1; i <= n; i++) {
if (i == source) { continue; }
push(source, i);
}
while (!excess_vertexes.empty()) {
int node = excess_vertexes.front();
excess_vertexes.pop();
if (node != source && node != sink) { discharge(node); }
}
ll max_flow = 0;
for (int node = 1; node <= n; node++) { max_flow += flow[node][sink]; }
cout << max_flow << endl;
}Recursos
| Fuente | Recurso | Notas |
|---|---|---|
| CPC | 10 - Network Flow | |
| CPH | 20 - Flows & Cuts | |
| CP2 | 4.6, 4.7.4 - Max Flow, Bipartite Graphs |
Flujos
| Hecho | Fuente | Nombre | Dificultad | Tags | Solución |
|---|---|---|---|---|---|
| CSES | Distinct Routes | Fácil | Max Flow | Solución |
Matching bipartito
| Hecho | Fuente | Nombre | Dificultad | Tags | Solución |
|---|---|---|---|---|---|
| CSES | School Dance | Fácil | Max Flow | en el módulo |
Se recomienda resolver el primer problema — Download Speed — de la sección anterior antes de intentar este.
Solución 1
Un grafo bipartito no parece tener nada en común con una red de flujo, pero podemos cambiar el punto de vista simplemente agregando la fuente conectada a los nodos del conjunto y el sumidero conectado a los nodos del conjunto . Y eso es todo. Ahora tenemos una red de flujo en la que toda capacidad es igual a 1.
Transformamos nuestro grafo bipartito en una red de flujo, de modo que el flujo máximo de la red es igual al matching máximo. ¡Ahora podemos aplicar nuestro algoritmo favorito de flujo máximo para resolver el problema!
Complejidad temporal:
Solución 2
Sin embargo, podemos hacerlo mejor. Primero definamos algunas propiedades de los algoritmos de matching.
Digamos que el conjunto contiene todas las aristas que forman el matching máximo.
Definimos un camino alternante (alternating path) como un camino cuyas aristas están en el matching y fuera del matching, de forma alternada. Un camino alternante empieza en un nodo no emparejado y termina cuando ya no se puede agregar otra arista manteniendo una secuencia alternante.
Un camino aumentante se construye sobre el camino alternante y nodos no emparejados en ambos extremos: los nodos no están incluidos en .
El matching máximo se puede mejorar si y solo si se encuentra un camino aumentante en ; en caso contrario, es el matching máximo. Puede parecer difícil de entender, pero la idea principal es la siguiente:
El matching máximo se puede seguir mejorando mientras la secuencia alternante se pueda extender. Para una mejor comprensión, se puede imaginar los cordones de un zapato como un camino alternante en un grafo bipartito — o las zapatillas.
El algoritmo descrito arriba se llama algoritmo de Hopcroft-Karp .
Aquí hay una animación del algoritmo si todavía hay algo de confusión:
Complejidad temporal:
#include <bits/stdc++.h>
using namespace std;
const int INF = 1e9;
int n, m, k;
// dist[node] = la posición de node en el camino alternante
vector<int> match, dist;
vector<vector<int>> g;
bool bfs() {
queue<int> q;
// El camino alternante empieza con nodos no emparejados
for (int node = 1; node <= n; node++) {
if (!match[node]) {
q.push(node);
dist[node] = 0;
} else {
dist[node] = INF;
}
}
dist[0] = INF;
while (!q.empty()) {
int node = q.front();
q.pop();
if (dist[node] >= dist[0]) { continue; }
for (int son : g[node]) {
// Si el match de son está emparejado
if (dist[match[son]] == INF) {
dist[match[son]] = dist[node] + 1;
q.push(match[son]);
}
}
}
// Devuelve true si se encontró un camino alternante
return dist[0] != INF;
}
// Devuelve true si se encontró un camino aumentante que empieza en el
// vértice node
bool dfs(int node) {
if (node == 0) { return true; }
for (int son : g[node]) {
if (dist[match[son]] == dist[node] + 1 && dfs(match[son])) {
match[node] = son;
match[son] = node;
return true;
}
}
dist[node] = INF;
return false;
}
int hopcroft_karp() {
int cnt = 0;
// Mientras haya un camino alternante
while (bfs()) {
for (int node = 1; node <= n; node++) {
// Si node no está emparejado pero podemos emparejarlo con un
// camino aumentante
if (!match[node] && dfs(node)) { cnt++; }
}
}
return cnt;
}
int main() {
cin >> n >> m >> k;
dist.resize(n + m + 1);
match.resize(n + m + 1);
g.resize(n + m + 1);
for (int i = 1; i <= k; i++) {
int x, y;
cin >> x >> y;
y += n;
g[x].push_back(y);
g[y].push_back(x);
}
cout << hopcroft_karp() << '\n';
for (int node = 1; node <= n; node++) {
if (match[node]) { cout << node << ' ' << match[node] - n << '\n'; }
}
}Algoritmo de Dinic
Video de YouTube (M6cm8UeeziI)
| Hecho | Fuente | Nombre | Dificultad | Tags | Solución |
|---|---|---|---|---|---|
| SPOJ | Fast Flow | Fácil | — | ||
| YS | Bipartite Matching | Fácil | Solución |
¿Matching bipartito de Hopcroft-Karp?
Flujo más rápido
Existen algoritmos de flujo más rápidos, como Push-Relabel. Véase también la siguiente entrada de blog:
Sin embargo, la implementación estándar de Dinic es (casi) siempre suficientemente rápida en problemas razonables.
Implementación
| Fuente | Recurso | Notas |
|---|---|---|
| Benq | Dinic |
Cuando el flujo se rompe
Cuando las restricciones son demasiado altas…
| Fuente | Recurso | Notas |
|---|---|---|
| CF | maroonrk - TL Issue on CF 659 Div 1 F | |
| CF | Actual Complexity of Max Flow? |