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

Мінімальне кістякове дерево — алгоритм Крускала

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

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

Випадковий граф MST цього графа

У цій статті ми розглянемо кілька важливих фактів, пов'язаних із мінімальними кістяковими деревами, а потім наведемо найпростішу реалізацію алгоритму Крускала для пошуку мінімального кістякового дерева.

Коли підходить цей алгоритм?
  • Потрібно з'єднати всі вершини зваженого неорієнтованого графа ребрами з мінімальною сумарною вагою (мінімальне кістякове дерево)?
  • Граф розріджений і ребра зручно подати списком (сортуємо ребра + СНМ)? (якщо граф щільний / заданий матрицею суміжності → Прим за O(n2)O(n^2))
  • Для швидкої реалізації за O(MlogN)O(M \log N) доступна Система неперетинних множин?

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

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

Алгоритм Крускала

Цей алгоритм описав Joseph Bernard Kruskal, Jr. у 1956 році.

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

Найпростіша реалізація

Наведений нижче код безпосередньо реалізує описаний вище алгоритм і має часову складність O(MlogM+N2)O(M \log M + N^2). Сортування ребер потребує O(MlogN)O(M \log N) (що те саме, що й O(MlogM)O(M \log M)) операцій. Інформація про піддерево, якому належить вершина, зберігається за допомогою масиву tree_id[] — для кожної вершини v значення tree_id[v] містить номер дерева, якому належить v. Для кожного ребра можна за O(1)O(1) визначити, чи належать його кінці різним деревам. Нарешті, об'єднання двох дерев виконується за O(N)O(N) простим проходом по масиву tree_id[]. Враховуючи, що загальна кількість операцій злиття дорівнює N1N-1, ми отримуємо асимптотику O(MlogN+N2)O(M \log N + N^2).

struct Edge {
int u, v, weight;
bool operator<(Edge const& other) {
return weight < other.weight;
}
};

int n;
vector<Edge> edges;

int cost = 0;
vector<int> tree_id(n);
vector<Edge> result;
for (int i = 0; i < n; i++)
tree_id[i] = i;

sort(edges.begin(), edges.end());

for (Edge e : edges) {
if (tree_id[e.u] != tree_id[e.v]) {
cost += e.weight;
result.push_back(e);

int old_id = tree_id[e.u], new_id = tree_id[e.v];
for (int i = 0; i < n; i++) {
if (tree_id[i] == old_id)
tree_id[i] = new_id;
}
}
}

Доведення коректності

Чому алгоритм Крускала дає нам правильний результат?

Якщо вихідний граф був зв'язним, то й результуючий граф буде зв'язним. Бо інакше існували б дві компоненти, які можна було б з'єднати щонайменше одним ребром. Однак це неможливо, адже Крускал обрав би одне з цих ребер, оскільки ідентифікатори компонент різні. Також результуючий граф не містить циклів, бо ми явно забороняємо це в алгоритмі. Отже, алгоритм будує кістякове дерево.

То чому ж цей алгоритм дає нам мінімальне кістякове дерево?

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

На початку твердження, очевидно, істинне: порожня множина є підмножиною будь-якого MST.

Тепер припустимо, що FF — деяка множина ребер на якомусь етапі алгоритму, TT — MST, що містить FF, а ee — нове ребро, яке ми хочемо додати за Крускалом.

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

Якщо TT уже містить ee, твердження також істинне після цього кроку.

Якщо ж TT не містить ребра ee, то T+eT + e міститиме цикл CC. Цей цикл міститиме щонайменше одне ребро ff, якого немає в FF. Множина ребер Tf+eT - f + e також буде кістяковим деревом. Зауважимо, що вага ff не може бути меншою за вагу ee, бо інакше Крускал обрав би ff раніше. Вона також не може мати більшу вагу, оскільки тоді сумарна вага Tf+eT - f + e була б меншою за сумарну вагу TT, що неможливо, адже TT уже є MST. Це означає, що вага ee має бути такою самою, як вага ff. Отже, Tf+eT - f + e також є MST, і воно містить усі ребра з F+eF + e. Тож і тут твердження залишається виконаним після кроку.

Це доводить твердження. А це означає, що після перебору всіх ребер результуюча множина ребер буде зв'язною й міститиметься в деякому MST, а отже, вона сама вже має бути MST.

Покращена реалізація

Ми можемо використати структуру даних Система неперетинних множин (СНМ), щоб написати швидшу реалізацію алгоритму Крускала з часовою складністю близько O(MlogN)O(M \log N). Ця стаття детально описує такий підхід.

Задачі для практики

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