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

Пошук мостів онлайн

Нам дано неорієнтований граф. Міст — це таке ребро, видалення якого робить граф незв'язним (точніше, збільшує кількість компонент зв'язності). Наше завдання — знайти всі мости в заданому графі.

Неформально це завдання можна сформулювати так: ми маємо знайти всі «важливі» дороги на заданій карті доріг, тобто такі дороги, видалення будь-якої з яких призведе до того, що деякі міста стануть недосяжними з інших.

Уже існує стаття Пошук мостів за O(N+M)O(N+M), яка розв'язує це завдання за допомогою обходу пошуком у глибину. Цей алгоритм буде значно складнішим, але має одну велику перевагу: описаний у цій статті алгоритм працює онлайн, тобто вхідний граф не обов'язково знати заздалегідь. Ребра додаються по одному за раз, і після кожного додавання алгоритм перераховує всі мости в поточному графі. Іншими словами, алгоритм розроблено так, щоб ефективно працювати на динамічному графі, який змінюється.

Більш строго постановка задачі виглядає так: Спочатку граф порожній і складається з nn вершин. Потім ми отримуємо пари вершин (a,b)(a, b), які позначають ребро, що додається до графа. Після кожного отриманого ребра, тобто після додавання кожного ребра, виводимо поточну кількість мостів у графі.

Також можна підтримувати список усіх мостів, а також явно зберігати компоненти 2-реберної зв'язності.

Описаний нижче алгоритм працює за час O(nlogn+m)O(n \log n + m), де mm — кількість ребер. Алгоритм ґрунтується на структурі даних Система неперетинних множин. Однак реалізація в цій статті працює за час O(nlogn+mlogn)O(n \log n + m \log n), оскільки вона використовує спрощену версію СНМ без об'єднання за рангом.

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

Алгоритм

Спершу означимо kk-реберно зв'язну компоненту: це компонента зв'язності, яка залишається зв'язною, коли ви видаляєте менш ніж kk ребер.

Дуже легко бачити, що мости розбивають граф на компоненти 2-реберної зв'язності. Якщо ми стиснемо кожну з цих компонент 2-реберної зв'язності у вершини й залишимо в стисненому графі лише мости як ребра, то отримаємо ациклічний граф, тобто ліс.

Описаний нижче алгоритм явно підтримує цей ліс, а також компоненти 2-реберної зв'язності.

Зрозуміло, що спочатку, коли граф порожній, він містить nn компонент 2-реберної зв'язності, які самі по собі не з'єднані.

Під час додавання чергового ребра (a,b)(a, b) можуть виникнути три ситуації:

  • Обидві вершини aa і bb знаходяться в одній компоненті 2-реберної зв'язності — тоді це ребро не є мостом і нічого не змінює у структурі лісу, тож ми можемо просто пропустити це ребро.

    Отже, у цьому випадку кількість мостів не змінюється.

  • Вершини aa і bb знаходяться в зовсім різних компонентах зв'язності, тобто кожна з них є частиною різного дерева. У цьому випадку ребро (a,b)(a, b) стає новим мостом, і ці два дерева об'єднуються в одне (а всі старі мости залишаються).

    Отже, у цьому випадку кількість мостів збільшується на одиницю.

  • Вершини aa і bb знаходяться в одній компоненті зв'язності, але в різних компонентах 2-реберної зв'язності. У цьому випадку це ребро утворює цикл разом із деякими зі старих мостів. Усі ці мости перестають бути мостами, і отриманий цикл потрібно стиснути в нову компоненту 2-реберної зв'язності.

    Отже, у цьому випадку кількість мостів зменшується на одиницю або більше.

Відповідно, усе завдання зводиться до ефективної реалізації всіх цих операцій над лісом компонент 2-реберної зв'язності.

Структури даних для зберігання лісу

Єдина структура даних, яка нам потрібна, — це Система неперетинних множин. Насправді ми зробимо дві копії цієї структури: одна слугуватиме для підтримання компонент зв'язності, інша — для підтримання компонент 2-реберної зв'язності. А на додачу ми зберігаємо структуру дерев у лісі компонент 2-реберної зв'язності за допомогою вказівників: кожна компонента 2-реберної зв'язності зберігатиме індекс par[] свого предка в дереві.

Тепер ми послідовно розберемо кожну операцію, яку нам потрібно навчитися реалізовувати:

  • Перевірка, чи лежать дві вершини в одній компоненті зв'язності / 2-реберної зв'язності. Це робиться звичайним алгоритмом СНМ, ми просто знаходимо й порівнюємо представників СНМ.

  • З'єднання двох дерев для деякого ребра (a,b)(a, b). Оскільки могло б виявитися, що ні вершина aa, ні вершина bb не є коренями своїх дерев, єдиний спосіб з'єднати ці два дерева — це переробити корінь одного з них. Наприклад, можна зробити коренем дерева вершину aa, а потім приєднати його до іншого дерева, встановивши предком aa вершину bb.

    Однак виникає питання про ефективність операції зміни кореня: щоб переробити корінь дерева з коренем rr на вершину vv, необхідно відвідати всі вершини на шляху між vv і rr та перенаправити вказівники par[] у протилежному напрямку, а також змінити посилання на предків у СНМ, що відповідає за компоненти зв'язності.

    Отже, вартість зміни кореня — O(h)O(h), де hh — висота дерева. Можна зробити навіть гіршу оцінку, сказавши, що вартість становить O(size)O(\text{size}), де size\text{size} — кількість вершин у дереві. Підсумкова складність від цього не зміниться.

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

    Загалом сумарну вартість можна записати у вигляді рекурентного співвідношення:

    T(n)=maxk=1n1{T(k)+T(nk)+O(min(k,nk))}T(n) = \max_{k = 1 \ldots n-1} \left\{ T(k) + T(n - k) + O(\min(k, n - k))\right\}

    T(n)T(n) — кількість операцій, необхідних, щоб отримати дерево з nn вершинами за допомогою зміни кореня та об'єднання дерев. Дерево розміру nn можна створити, об'єднавши два менші дерева розмірів kk і nkn - k. Це рекурентне співвідношення має розв'язок T(n)=O(nlogn)T(n) = O (n \log n).

    Отже, сумарний час, витрачений на всі операції зміни кореня, буде O(nlogn)O(n \log n), якщо ми завжди змінюємо корінь меншого з двох дерев.

    Нам доведеться підтримувати розмір кожної компоненти зв'язності, але структура даних СНМ робить це можливим без труднощів.

  • Пошук циклу, утвореного додаванням нового ребра (a,b)(a, b). Оскільки aa і bb вже з'єднані в дереві, нам потрібно знайти найнижчого спільного предка вершин aa і bb. Цикл складатиметься зі шляхів від bb до LCA, від LCA до aa та ребра від aa до bb.

    Після знаходження циклу ми стискаємо всі вершини виявленого циклу в одну вершину. Це означає, що ми вже маємо складність, пропорційну довжині циклу, а отже, ми також можемо використовувати будь-який алгоритм LCA, пропорційний довжині, і не зобов'язані використовувати якийсь швидкий.

    Оскільки вся інформація про структуру дерева доступна в масиві предків par[], єдиний розумний алгоритм LCA такий: позначаємо вершини aa і bb як відвідані, потім переходимо до їхніх предків par[a] і par[b] та позначаємо їх, потім просуваємося до їхніх предків і так далі, доки не досягнемо вже позначеної вершини. Ця вершина і є LCA, який ми шукаємо, і ми можемо знайти вершини на циклі, знову проходячи шлях від aa і bb до LCA.

    Очевидно, що складність цього алгоритму пропорційна довжині шуканого циклу.

  • Стиснення циклу під час додавання нового ребра (a,b)(a, b) в дереві.

    Нам потрібно створити нову компоненту 2-реберної зв'язності, яка складатиметься з усіх вершин виявленого циклу (також сам виявлений цикл міг складатися з кількох компонент 2-реберної зв'язності, але це нічого не змінює). Крім того, необхідно стиснути їх так, щоб структура дерева не порушилася, а всі вказівники par[] та дві СНМ залишалися правильними.

    Найпростіший спосіб досягти цього — стиснути всі вершини циклу до їхнього LCA. Насправді LCA є найвищою з вершин, тобто його вказівник на предка par[] залишається незмінним. Для всіх інших вершин циклу предків оновлювати не потрібно, оскільки ці вершини просто перестають існувати. Але в СНМ компонент 2-реберної зв'язності всі ці вершини просто вказуватимуть на LCA.

    Ми реалізуємо СНМ компонент 2-реберної зв'язності без оптимізації об'єднання за рангом, тому отримаємо складність O(logn)O(\log n) у середньому на запит. Щоб досягти складності O(1)O(1) у середньому на запит, нам потрібно об'єднувати вершини циклу згідно з об'єднанням за рангом, а потім відповідно присвоїти par[].

Реалізація

Ось остаточна реалізація всього алгоритму.

Як згадувалося раніше, заради простоти СНМ компонент 2-реберної зв'язності написано без об'єднання за рангом, тому підсумкова складність буде O(logn)O(\log n) у середньому.

Також у цій реалізації самі мости не зберігаються, лише їхня кількість bridges. Однак буде неважко створити set усіх мостів.

Спочатку ви викликаєте функцію init(), яка ініціалізує дві СНМ (створюючи окрему множину для кожної вершини та встановлюючи розмір рівним одиниці) і задає предків par.

Основна функція — add_edge(a, b), яка обробляє й додає нове ребро.

vector<int> par, dsu_2ecc, dsu_cc, dsu_cc_size;
int bridges;
int lca_iteration;
vector<int> last_visit;

void init(int n) {
par.resize(n);
dsu_2ecc.resize(n);
dsu_cc.resize(n);
dsu_cc_size.resize(n);
lca_iteration = 0;
last_visit.assign(n, 0);
for (int i=0; i<n; ++i) {
dsu_2ecc[i] = i;
dsu_cc[i] = i;
dsu_cc_size[i] = 1;
par[i] = -1;
}
bridges = 0;
}

int find_2ecc(int v) {
if (v == -1)
return -1;
return dsu_2ecc[v] == v ? v : dsu_2ecc[v] = find_2ecc(dsu_2ecc[v]);
}

int find_cc(int v) {
v = find_2ecc(v);
return dsu_cc[v] == v ? v : dsu_cc[v] = find_cc(dsu_cc[v]);
}

void make_root(int v) {
int root = v;
int child = -1;
while (v != -1) {
int p = find_2ecc(par[v]);
par[v] = child;
dsu_cc[v] = root;
child = v;
v = p;
}
dsu_cc_size[root] = dsu_cc_size[child];
}

void merge_path (int a, int b) {
++lca_iteration;
vector<int> path_a, path_b;
int lca = -1;
while (lca == -1) {
if (a != -1) {
a = find_2ecc(a);
path_a.push_back(a);
if (last_visit[a] == lca_iteration){
lca = a;
break;
}
last_visit[a] = lca_iteration;
a = par[a];
}
if (b != -1) {
b = find_2ecc(b);
path_b.push_back(b);
if (last_visit[b] == lca_iteration){
lca = b;
break;
}
last_visit[b] = lca_iteration;
b = par[b];
}

}

for (int v : path_a) {
dsu_2ecc[v] = lca;
if (v == lca)
break;
--bridges;
}
for (int v : path_b) {
dsu_2ecc[v] = lca;
if (v == lca)
break;
--bridges;
}
}

void add_edge(int a, int b) {
a = find_2ecc(a);
b = find_2ecc(b);
if (a == b)
return;

int ca = find_cc(a);
int cb = find_cc(b);

if (ca != cb) {
++bridges;
if (dsu_cc_size[ca] > dsu_cc_size[cb]) {
swap(a, b);
swap(ca, cb);
}
make_root(a);
par[a] = dsu_cc[a] = b;
dsu_cc_size[cb] += dsu_cc_size[a];
} else {
merge_path(a, b);
}
}

СНМ для компонент 2-реберної зв'язності зберігається у векторі dsu_2ecc, а функція, що повертає представника, — find_2ecc(v). Ця функція використовується багато разів у решті коду, оскільки після стиснення кількох вершин в одну всі ці вершини перестають існувати, і натомість лише лідер має правильного предка par у лісі компонент 2-реберної зв'язності.

СНМ для компонент зв'язності зберігається у векторі dsu_cc, а також є додатковий вектор dsu_cc_size для зберігання розмірів компонент. Функція find_cc(v) повертає лідера компоненти зв'язності (який насправді є коренем дерева).

Зміна кореня дерева make_root(v) працює так, як описано вище: вона проходить від вершини vv через предків до кореневої вершини, щоразу перенаправляючи предка par у протилежному напрямку. Посилання на представника компоненти зв'язності dsu_cc також оновлюється, щоб воно вказувало на нову кореневу вершину. Після зміни кореня ми маємо присвоїти новому кореню правильний розмір компоненти зв'язності. Також нам треба бути уважними й викликати find_2ecc(), щоб отримати представників компоненти 2-реберної зв'язності, а не якусь іншу вершину, яку вже стиснуто.

Функція пошуку й стиснення циклу merge_path(a, b) також реалізована так, як описано вище. Вона шукає LCA вершин aa і bb, піднімаючи ці вузли паралельно, доки не зустрінемо вершину вдруге. З міркувань ефективності ми обираємо унікальний ідентифікатор для кожного виклику пошуку LCA й позначаємо ним пройдені вершини. Це працює за O(1)O(1), тоді як інші підходи, як-от використання setset, працюють гірше. Пройдені шляхи зберігаються у векторах path_a і path_b, і ми використовуємо їх, щоб пройтися ними вдруге до LCA, отримавши таким чином усі вершини циклу. Усі вершини циклу стискаються приєднанням до LCA, тому середня складність — O(logn)O(\log n) (оскільки ми не використовуємо об'єднання за рангом). Усі ребра, які ми проходимо, були мостами, тому ми віднімаємо 1 за кожне ребро в циклі.

Нарешті, функція-запит add_edge(a, b) визначає компоненти зв'язності, у яких лежать вершини aa і bb. Якщо вони лежать у різних компонентах зв'язності, то менше дерево змінює корінь, а потім приєднується до більшого дерева. Інакше, якщо вершини aa і bb лежать в одному дереві, але в різних компонентах 2-реберної зв'язності, то викликається функція merge_path(a, b), яка виявить цикл і стисне його в одну компоненту 2-реберної зв'язності.