Перейти до основного вмісту

Максимальний потік — алгоритм проштовхування передпотоку (push-relabel)

Алгоритм проштовхування передпотоку (push-relabel) (також відомий як алгоритм preflow-push) — це алгоритм для обчислення максимального потоку в потоковій мережі. Точне формулювання задачі, яку ми хочемо розв'язати, можна знайти у статті Максимальний потік — Форда-Фалкерсона та Едмондса-Карпа.

У цій статті ми розглянемо розв'язання задачі шляхом проштовхування передпотоку через мережу, що працює за час O(V4)O(V^4), а точніше за час O(V2E)O(V^2 E). Алгоритм розробили Ендрю Голдберг та Роберт Тарджан у 1985 році.

Коли підходить цей алгоритм?
  • Потрібен максимальний потік у мережі з пропускними здатностями (без вартостей на ребрах)? (якщо ребра мають вартості → потік мінімальної вартості)
  • Граф щільний, і гарантований час O(V2E)O(V^2 E) привабливіший за пошук доповнювальних шляхів? (для більшості задач достатньо простішого Дініца)
  • Готовий до складнішої реалізації заради кращої практичної швидкодії? (ще швидша версія → покращений push-relabel)

Означення

Під час роботи алгоритму нам доведеться мати справу з передпотоком — тобто з функцією ff, яка схожа на функцію потоку, але не обов'язково задовольняє умову збереження потоку. Для неї мають виконуватися лише обмеження

0f(e)c(e)0 \le f(e) \le c(e)

та

(v,u)Ef((v,u))(u,v)Ef((u,v))\sum_{(v, u) \in E} f((v, u)) \ge \sum_{(u, v) \in E} f((u, v))

Отже, деяка вершина може отримувати більше потоку, ніж вона розподіляє. Ми кажемо, що така вершина має деякий надлишок потоку, і визначаємо його величину за допомогою функції надлишку x(u)=(v,u)Ef((v,u))(u,v)Ef((u,v))x(u) =\sum_{(v, u) \in E} f((v, u)) - \sum_{(u, v) \in E} f((u, v)).

Так само, як і з функцією потоку, ми можемо визначити залишкові пропускні здатності та залишковий граф за допомогою функції передпотоку.

Алгоритм починає роботу з початкового передпотоку (деякі вершини мають надлишок), і під час виконання передпотік буде оброблятися та змінюватися. Розкриваючи деякі деталі наперед: алгоритм вибиратиме вершину з надлишком і проштовхуватиме надлишок до сусідніх вершин. Він повторюватиме це, доки всі вершини, окрім витоку та стоку, не позбудуться надлишку. Легко бачити, що передпотік без надлишку є коректним потоком. Завдяки цьому алгоритм завершується зі справжнім потоком.

Залишаються ще дві проблеми, з якими ми маємо розібратися. По-перше, як ми гарантуємо, що це справді завершиться? А по-друге, як ми гарантуємо, що це справді дасть нам максимальний потік, а не якийсь випадковий потік?

Щоб розв'язати ці проблеми, нам потрібна допомога ще однієї функції, а саме функції мітки hh, яку часто також називають функцією висоти і яка присвоює кожній вершині ціле число. Ми називаємо мітку коректною, якщо h(s)=Vh(s) = |V|, h(t)=0h(t) = 0 і h(u)h(v)+1h(u) \le h(v) + 1 за умови, що в залишковому графі є ребро (u,v)(u, v) — тобто ребро (u,v)(u, v) має додатну пропускну здатність у залишковому графі. Інакше кажучи, якщо можна збільшити потік з uu до vv, то висота vv може бути не більш ніж на одиницю меншою за висоту uu, але вона може бути рівною або навіть більшою.

Важливо зауважити, що якщо існує коректна функція мітки, то в залишковому графі не існує доповнювального шляху від ss до tt. Адже такий шлях матиме довжину щонайбільше V1|V| - 1 ребер, і кожне ребро може зменшити висоту щонайбільше на одиницю, що неможливо, якщо перша висота дорівнює h(s)=Vh(s) = |V|, а остання — h(t)=0h(t) = 0.

Використовуючи цю функцію мітки, ми можемо сформулювати стратегію алгоритму push-relabel: Ми починаємо з коректного передпотоку та коректної функції мітки. На кожному кроці ми проштовхуємо деякий надлишок між вершинами та оновлюємо мітки вершин. Ми маємо подбати про те, щоб після кожного кроку передпотік і мітка залишалися коректними. Якщо ж алгоритм визначає, що передпотік є коректним потоком. А оскільки в нас також є коректна мітка, то в залишковому графі не існує шляху між ss і tt, а це означає, що потік справді є максимальним.

Якщо порівняти алгоритм Форда-Фалкерсона з алгоритмом push-relabel, то здається, що ці алгоритми є двоїстими один до одного. Алгоритм Форда-Фалкерсона весь час підтримує коректний потік і покращує його, доки більше не існує доповнювального шляху, тоді як в алгоритмі push-relabel доповнювального шляху не існує в жоден момент, і ми покращуємо передпотік, доки він не стане коректним потоком.

Алгоритм

Спочатку ми маємо ініціалізувати граф коректним передпотоком та функцією мітки.

Використати порожній передпотік — як це робиться в алгоритмі Форда-Фалкерсона — неможливо, бо тоді існуватиме доповнювальний шлях, а це означає, що коректної мітки не існує. Тому ми ініціалізуємо кожне ребро, що виходить з ss, його максимальною пропускною здатністю: f((s,u))=c((s,u))f((s, u)) = c((s, u)). А всі інші ребра — нулем. У цьому випадку існує коректна мітка, а саме h(s)=Vh(s) = |V| для витоку і h(u)=0h(u) = 0 для всіх інших.

Тепер опишемо дві операції докладніше.

За допомогою операції push (проштовхування) ми намагаємося проштовхнути якомога більше надлишку потоку з однієї вершини uu до сусідньої вершини vv. У нас є одне правило: нам дозволено проштовхувати потік з uu до vv, лише якщо h(u)=h(v)+1h(u) = h(v) + 1. Простими словами, надлишок потоку має текти вниз, але не надто стрімко. Звісно, ми можемо проштовхнути лише min(x(u),c((u,v))f((u,v)))\min(x(u), c((u, v)) - f((u, v))) потоку.

Якщо вершина має надлишок, але неможливо проштовхнути цей надлишок до жодної сусідньої вершини, то нам потрібно збільшити висоту цієї вершини. Цю операцію ми називаємо relabel (підняття). Ми збільшуємо її настільки, наскільки це можливо, але так, щоб мітка залишалася коректною.

Підсумовуючи, алгоритм у двох словах такий: Ми ініціалізуємо коректний передпотік і коректну мітку. Поки ми можемо виконувати операції проштовхування або підняття, ми їх виконуємо. Після цього передпотік стає справжнім потоком, і ми його повертаємо.

Складність

Легко показати, що максимальна мітка вершини дорівнює 2V12|V| - 1. У цей момент увесь решта надлишку може бути і буде проштовхнутий назад до витоку. Це дає щонайбільше O(V2)O(V^2) операцій підняття.

Можна також показати, що буде виконано щонайбільше O(VE)O(V E) насичувальних проштовхувань (проштовхування, де використовується вся пропускна здатність ребра) і щонайбільше O(V2E)O(V^2 E) ненасичувальних проштовхувань (проштовхування, де пропускна здатність ребра використовується не повністю). Якщо ми оберемо структуру даних, яка дозволяє знаходити наступну вершину з надлишком за час O(1)O(1), то загальна складність алгоритму становить O(V2E)O(V^2 E).

Реалізація

const int inf = 1000000000;

int n;
vector<vector<int>> capacity, flow;
vector<int> height, excess, seen;
queue<int> excess_vertices;

void push(int u, int v) {
int d = min(excess[u], capacity[u][v] - flow[u][v]);
flow[u][v] += d;
flow[v][u] -= d;
excess[u] -= d;
excess[v] += d;
if (d && excess[v] == d)
excess_vertices.push(v);
}

void relabel(int u) {
int d = inf;
for (int i = 0; i < n; i++) {
if (capacity[u][i] - flow[u][i] > 0)
d = min(d, height[i]);
}
if (d < inf)
height[u] = d + 1;
}

void discharge(int u) {
while (excess[u] > 0) {
if (seen[u] < n) {
int v = seen[u];
if (capacity[u][v] - flow[u][v] > 0 && height[u] > height[v])
push(u, v);
else
seen[u]++;
} else {
relabel(u);
seen[u] = 0;
}
}
}

int max_flow(int s, int t) {
height.assign(n, 0);
height[s] = n;
flow.assign(n, vector<int>(n, 0));
excess.assign(n, 0);
excess[s] = inf;
for (int i = 0; i < n; i++) {
if (i != s)
push(s, i);
}
seen.assign(n, 0);

while (!excess_vertices.empty()) {
int u = excess_vertices.front();
excess_vertices.pop();
if (u != s && u != t)
discharge(u);
}

int max_flow = 0;
for (int i = 0; i < n; i++)
max_flow += flow[i][t];
return max_flow;
}

Тут ми використовуємо чергу excess_vertices, щоб зберігати всі вершини, які наразі мають надлишок. У такий спосіб ми можемо вибрати наступну вершину для операції проштовхування або підняття за константний час.

А щоб не витрачати забагато часу на пошук сусідньої вершини, до якої можна проштовхнути потік, ми використовуємо структуру даних під назвою current-arc (поточне ребро). По суті, ми перебиратимемо ребра в циклічному порядку і завжди зберігатимемо останнє використане ребро. У такий спосіб для певного значення мітки ми змінюватимемо поточне ребро лише O(n)O(n) разів. А оскільки підняття вже займає O(n)O(n) часу, ми не погіршуємо складність.

Відеоматеріали