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

Алгоритм Дейкстри на розріджених графах

Постановку задачі, сам алгоритм разом із реалізацією та доведенням можна знайти у статті Алгоритм Дейкстри.

Коли підходить цей алгоритм?
  • Граф розріджений, тобто mn2m \ll n^2? (якщо граф щільний, mn2m \approx n^2 → простіша версія за O(n2)O(n^2))
  • Усі ваги ребер невід'ємні? (якщо є від'ємні ребра → Беллман-Форд)
  • Потрібні найкоротші шляхи з однієї вершини? (якщо ваги лише 00 та 11 → ще швидший 0-1 BFS)

Алгоритм

Пригадаймо, що при виведенні складності алгоритму Дейкстри ми використовували два чинники: час на пошук непозначеної вершини з найменшою відстанню d[v]d[v] та час релаксації, тобто час зміни значень d[to]d[\text{to}].

У найпростішій реалізації ці операції потребують O(n)O(n) та O(1)O(1) часу. Отже, оскільки першу операцію ми виконуємо O(n)O(n) разів, а другу — O(m)O(m) разів, ми отримали складність O(n2+m)O(n^2 + m).

Зрозуміло, що така складність є оптимальною для щільного графа, тобто коли mn2m \approx n^2. Проте у розріджених графах, коли mm значно менше за максимальну кількість ребер n2n^2, складність стає менш оптимальною через перший доданок. Тому необхідно покращити час виконання першої операції (і, звісно, не зіпсувавши при цьому суттєво час другої).

Щоб цього досягти, ми можемо скористатися різними допоміжними структурами даних. Найефективнішою є фібоначчієва купа, яка дозволяє виконувати першу операцію за O(logn)O(\log n), а другу — за O(1)O(1). Тоді ми отримаємо для алгоритму Дейкстри складність O(nlogn+m)O(n \log n + m), що є також теоретичним мінімумом для задачі пошуку найкоротшого шляху. Тому цей алгоритм працює оптимально, а фібоначчієві купи є оптимальною структурою даних. Не існує жодної структури даних, яка могла б виконувати обидві операції за O(1)O(1), бо це дозволило б також відсортувати список випадкових чисел за лінійний час, що неможливо. Цікаво, що існує алгоритм Торупа (Thorup), який знаходить найкоротший шлях за O(m)O(m) часу, проте він працює лише для цілочисельних ваг і використовує цілком іншу ідею. Тож це не призводить до жодних суперечностей. Фібоначчієві купи забезпечують оптимальну складність для цієї задачі. Однак їх досить складно реалізувати, до того ж вони мають доволі велику приховану константу.

Як компроміс можна використати структури даних, які виконують обидва типи операцій (видобування мінімуму та оновлення елемента) за O(logn)O(\log n). Тоді складність алгоритму Дейкстри становить O(nlogn+mlogn)=O(mlogn)O(n \log n + m \log n) = O(m \log n).

C++ надає дві такі структури даних: set та priority_queue. Перша базується на червоно-чорних деревах, а друга — на купах. Тому priority_queue має меншу приховану константу, але також має недолік: вона не підтримує операцію видалення елемента. Через це нам доведеться вдатися до «обхідного шляху», який насправді призводить до дещо гіршого множника logm\log m замість logn\log n (хоча з погляду складності вони однакові).

Реалізація

set

Почнімо з контейнера set. Оскільки нам потрібно зберігати вершини, впорядковані за їхніми значеннями d[]d[], зручно зберігати власне пари: відстань та індекс вершини. У результаті в set пари автоматично сортуються за своїми відстанями.

const int INF = 1000000000;
vector<vector<pair<int, int>>> adj;

void dijkstra(int s, vector<int> & d, vector<int> & p) {
int n = adj.size();
d.assign(n, INF);
p.assign(n, -1);

d[s] = 0;
set<pair<int, int>> q;
q.insert({0, s});
while (!q.empty()) {
int v = q.begin()->second;
q.erase(q.begin());

for (auto edge : adj[v]) {
int to = edge.first;
int len = edge.second;

if (d[v] + len < d[to]) {
q.erase({d[to], to});
d[to] = d[v] + len;
p[to] = v;
q.insert({d[to], to});
}
}
}
}

Масив u[]u[] зі звичайної реалізації алгоритму Дейкстри нам більше не потрібен. Ми використовуватимемо set, щоб зберігати цю інформацію, а також щоб знаходити з його допомогою вершину з найменшою відстанню. Він певною мірою діє як черга. Головний цикл виконується доти, доки в множині/черзі ще залишаються вершини. Вершина з найменшою відстанню видобувається, і для кожної успішної релаксації ми спочатку видаляємо стару пару, а потім, після релаксації, додаємо до черги нову пару.

priority_queue

Головна відмінність від реалізації зі set полягає в тому, що в багатьох мовах, зокрема в C++, ми не можемо видаляти елементи з priority_queue (хоча купи теоретично можуть підтримувати таку операцію). Тому нам доведеться скористатися обхідним шляхом: ми просто не видаляємо стару пару з черги. Як наслідок, одна вершина може водночас з'являтися в черзі кілька разів з різними відстанями. Серед цих пар нас цікавлять лише ті, у яких перший елемент дорівнює відповідному значенню в d[]d[]; усі інші пари — застарілі. Тому нам потрібно зробити невелику модифікацію: на початку кожної ітерації, після видобування чергової пари, ми перевіряємо, чи це важлива пара, чи це вже застаріла й опрацьована пара. Ця перевірка важлива, інакше складність може зрости аж до O(nm)O(n m).

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

const int INF = 1000000000;
vector<vector<pair<int, int>>> adj;

void dijkstra(int s, vector<int> & d, vector<int> & p) {
int n = adj.size();
d.assign(n, INF);
p.assign(n, -1);

d[s] = 0;
using pii = pair<int, int>;
priority_queue<pii, vector<pii>, greater<pii>> q;
q.push({0, s});
while (!q.empty()) {
int v = q.top().second;
int d_v = q.top().first;
q.pop();
if (d_v != d[v])
continue;

for (auto edge : adj[v]) {
int to = edge.first;
int len = edge.second;

if (d[v] + len < d[to]) {
d[to] = d[v] + len;
p[to] = v;
q.push({d[to], to});
}
}
}
}

На практиці версія з priority_queue трохи швидша за версію зі set.

Цікаво, що в технічному звіті 2007 року дійшли висновку, що варіант алгоритму без операцій зменшення ключа (decrease-key) працював швидше за варіант із такими операціями, причому розрив у швидкодії був більшим для розріджених графів.

Позбуваємося пар

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

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

Оскільки видаляти ми можемо лише зі set, ця оптимізація застосовна лише до методу зі set і не працює з реалізацією на priority_queue. На практиці це суттєво підвищує швидкодію, особливо коли для зберігання відстаней використовуються більші типи даних, як-от long long чи double.

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