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

Мінімальне кістякове дерево - алгоритм Пріма

Дано зважений неорієнтований граф GG з nn вершинами та mm ребрами. Ми хочемо знайти кістякове дерево цього графа, яке з'єднує всі вершини й має найменшу вагу (тобто сума ваг ребер мінімальна). Кістякове дерево — це множина ребер така, що з будь-якої вершини можна дістатися до будь-якої іншої рівно одним простим шляхом. Кістякове дерево з найменшою вагою називають мінімальним кістяковим деревом.

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

Random graphMST of this graph

Легко бачити, що будь-яке кістякове дерево обов'язково міститиме n1n-1 ребро.

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

Коли підходить цей алгоритм?
  • Потрібне мінімальне кістякове дерево, а граф щільний (близький до повного) чи заданий матрицею суміжності — наприклад, евклідове MST по точках? (якщо граф розріджений і ребра подані списком → Крускал)
  • Зручно нарощувати дерево з однієї вершини, тримаючи для кожної необраної вершини найдешевше ребро до вже обраної частини?

Алгоритм Пріма

Цей алгоритм первісно відкрив чеський математик Войтех Ярнік (Vojtěch Jarník) у 1930 році. Однак цей алгоритм здебільшого відомий як алгоритм Пріма — на честь американського математика Роберта Клея Пріма (Robert Clay Prim), який заново відкрив і опублікував його в 1957 році. Крім того, Едсгер Дейкстра опублікував цей алгоритм у 1959 році.

Опис алгоритму

Тут ми опишемо алгоритм у його найпростішій формі. Мінімальне кістякове дерево будується поступово, додаванням по одному ребру за раз. Спочатку кістякове дерево складається лише з однієї вершини (обраної довільно). Потім обирається ребро мінімальної ваги, що виходить із цієї вершини, і додається до кістякового дерева. Після цього кістякове дерево вже складається з двох вершин. Тепер обираємо й додаємо ребро мінімальної ваги, яке має один кінець у вже обраній вершині (тобто у вершині, що вже належить кістяковому дереву), а інший кінець — у необраній вершині. І так далі, тобто щоразу ми обираємо й додаємо ребро мінімальної ваги, яке з'єднує одну обрану вершину з однією необраною. Процес повторюється, доки кістякове дерево не міститиме всі вершини (або, що те саме, доки ми не матимемо n1n - 1 ребро).

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

Доведення

Нехай граф GG зв'язний, тобто відповідь існує. Позначимо через TT граф, який отримав алгоритм Пріма, а через SS — мінімальне кістякове дерево. Очевидно, що TT справді є кістяковим деревом і підграфом GG. Нам лишається тільки показати, що ваги SS і TT збігаються.

Розглянемо перший момент в алгоритмі, коли ми додаємо до TT ребро, що не належить SS. Позначимо це ребро через ee, його кінці — через aa і bb, а множину вже обраних вершин — через VV (aVa \in V і bVb \notin V, або навпаки).

У мінімальному кістяковому дереві SS вершини aa і bb з'єднані деяким шляхом PP. На цьому шляху ми можемо знайти ребро ff таке, що один кінець ff лежить у VV, а інший — ні. Оскільки алгоритм обрав ee замість ff, це означає, що вага ff більша або дорівнює вазі ee.

Додаємо ребро ee до мінімального кістякового дерева SS і видаляємо ребро ff. Додавши ee, ми створили цикл, а оскільки ff також належало єдиному циклу, то після його видалення отриманий граф знову не містить циклів. А оскільки ми видалили лише ребро з циклу, отриманий граф усе ще зв'язний.

Отримане кістякове дерево не може мати більшу сумарну вагу, бо вага ee не перевищувала ваги ff, а також не може мати меншу вагу, бо SS було мінімальним кістяковим деревом. Це означає, що, замінивши ребро ff на ee, ми отримали інше мінімальне кістякове дерево. А отже, ee мусить мати ту саму вагу, що й ff.

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

Реалізація

Складність алгоритму залежить від того, як ми шукаємо наступне мінімальне ребро серед придатних ребер. Існує кілька підходів, що ведуть до різних складностей і різних реалізацій.

Тривіальні реалізації: O(nm)O(n m) та O(n2+mlogn)O(n^2 + m \log n)

Якщо ми шукаємо ребро, перебираючи всі можливі ребра, то на пошук ребра мінімальної ваги витрачається O(m)O(m) часу. Загальна складність буде O(nm)O(n m). У найгіршому випадку це O(n3)O(n^3) — дуже повільно.

Цей алгоритм можна покращити, якщо дивитися лише на одне ребро від кожної вже обраної вершини. Наприклад, ми можемо відсортувати ребра кожної вершини за зростанням їхніх ваг і зберігати вказівник на перше придатне ребро (тобто ребро, що веде до необраної вершини). Тоді після знаходження й вибору мінімального ребра ми оновлюємо вказівники. Це дає складність O(n2+m)O(n^2 + m), а на сортування ребер додатково O(mlogn)O(m \log n), що в найгіршому випадку дає складність O(n2logn)O(n^2 \log n).

Нижче ми розглянемо два дещо різні алгоритми — один для щільних графів, а інший для розріджених, обидва з кращою складністю.

Щільні графи: O(n2)O(n^2)

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

Тоді на кожному кроці нам треба буде лише переглянути ці ребра мінімальної ваги, що матиме складність O(n)O(n).

Після додавання ребра деякі вказівники на мінімальні ребра доведеться перерахувати. Зауважимо, що ваги можуть лише зменшуватися, тобто ребро мінімальної ваги кожної ще не обраної вершини може лишитися тим самим або буде оновлене ребром до щойно обраної вершини. Тому цю фазу теж можна виконати за O(n)O(n).

Отже, ми отримали версію алгоритму Пріма зі складністю O(n2)O(n^2).

Зокрема, ця реалізація дуже зручна для задачі евклідового мінімального кістякового дерева: у нас є nn точок на площині, відстань між кожною парою точок — це евклідова відстань між ними, і ми хочемо знайти мінімальне кістякове дерево для цього повного графа. Цю задачу можна розв'язати описаним алгоритмом за час O(n2)O(n^2) і пам'ять O(n)O(n), що неможливо за допомогою алгоритму Крускала.

int n;
vector<vector<int>> adj; // матриця суміжності графа
const int INF = 1000000000; // вага INF означає, що ребра немає

struct Edge {
int w = INF, to = -1;
};

void prim() {
int total_weight = 0;
vector<bool> selected(n, false);
vector<Edge> min_e(n);
min_e[0].w = 0;

for (int i=0; i<n; ++i) {
int v = -1;
for (int j = 0; j < n; ++j) {
if (!selected[j] && (v == -1 || min_e[j].w < min_e[v].w))
v = j;
}

if (min_e[v].w == INF) {
cout << "No MST!" << endl;
exit(0);
}

selected[v] = true;
total_weight += min_e[v].w;
if (min_e[v].to != -1)
cout << v << " " << min_e[v].to << endl;

for (int to = 0; to < n; ++to) {
if (adj[v][to] < min_e[to].w)
min_e[to] = {adj[v][to], v};
}
}

cout << total_weight << endl;
}

Матриця суміжності adj[][] розміру n×nn \times n зберігає ваги ребер, причому вона використовує вагу INF, якщо ребра між двома вершинами не існує. Алгоритм використовує два масиви: прапорець selected[], який вказує, які вершини ми вже обрали, і масив min_e[], який для кожної ще не обраної вершини зберігає ребро мінімальної ваги до обраної вершини (він зберігає вагу й кінцеву вершину). Алгоритм виконує nn кроків, на кожній ітерації обирається вершина з найменшою вагою ребра, і min_e[] усіх інших вершин оновлюється.

Розріджені графи: O(mlogn)O(m \log n)

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

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

Але якщо врахувати, що загалом нам потрібно оновлювати лише O(m)O(m) разів і виконувати O(n)O(n) пошуків мінімального ребра, то загальна складність буде O(mlogn)O(m \log n). Для розріджених графів це краще, ніж попередній алгоритм, але для щільних графів це буде повільніше.

const int INF = 1000000000;

struct Edge {
int w = INF, to = -1;
bool operator<(Edge const& other) const {
return make_pair(w, to) < make_pair(other.w, other.to);
}
};

int n;
vector<vector<Edge>> adj;

void prim() {
int total_weight = 0;
vector<Edge> min_e(n);
min_e[0].w = 0;
set<Edge> q;
q.insert({0, 0});
vector<bool> selected(n, false);
for (int i = 0; i < n; ++i) {
if (q.empty()) {
cout << "No MST!" << endl;
exit(0);
}

int v = q.begin()->to;
selected[v] = true;
total_weight += q.begin()->w;
q.erase(q.begin());

if (min_e[v].to != -1)
cout << v << " " << min_e[v].to << endl;

for (Edge e : adj[v]) {
if (!selected[e.to] && e.w < min_e[e.to].w) {
q.erase({min_e[e.to].w, e.to});
min_e[e.to] = {e.w, v};
q.insert({e.w, e.to});
}
}
}

cout << total_weight << endl;
}

Тут граф подано через список суміжності adj[], де adj[v] містить усі ребра (у вигляді пар «вага й ціль») для вершини v. min_e[v] зберігатиме вагу найменшого ребра від вершини v до вже обраної вершини (знову ж таки у вигляді пари «вага й ціль»). Крім того, чергу q заповнено всіма ще не обраними вершинами в порядку зростання ваг min_e. Алгоритм виконує n кроків, на кожному з яких обирає вершину v з найменшою вагою min_e (вилучаючи її з початку черги), а потім переглядає всі ребра з цієї вершини й оновлює значення в min_e (під час оновлення нам також потрібно видалити старе ребро з черги q і вставити нове ребро).

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