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

Код Прюфера

У цій статті ми розглянемо так званий код Прюфера (або послідовність Прюфера) — спосіб однозначно закодувати позначене дерево у вигляді послідовності чисел.

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

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

Код Прюфера

Код Прюфера — це спосіб закодувати позначене дерево з nn вершинами за допомогою послідовності з n2n - 2 цілих чисел з проміжку [0;n1][0; n-1]. Таке кодування також задає бієкцію між усіма кістяковими деревами повного графа і числовими послідовностями.

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

Винахідник — Хайнц Прюфер — запропонував цей код у 1918 році як доведення формули Келі.

Побудова коду Прюфера для заданого дерева

Код Прюфера будується так. Ми повторюємо таку процедуру n2n - 2 рази: ми вибираємо листок дерева з найменшим номером, видаляємо його з дерева і записуємо номер вершини, яка була з ним з'єднана. Після n2n - 2 ітерацій залишиться лише 22 вершини, і алгоритм завершується.

Отже, код Прюфера для заданого дерева — це послідовність з n2n - 2 чисел, де кожне число є номером з'єднаної вершини, тобто це число лежить у проміжку [0,n1][0, n-1].

Алгоритм обчислення коду Прюфера легко реалізувати з часовою складністю O(nlogn)O(n \log n), просто використавши структуру даних для вилучення мінімуму (наприклад, set або priority_queue в C++), яка містить список усіх поточних листків.

vector<vector<int>> adj;

vector<int> pruefer_code() {
int n = adj.size();
set<int> leafs;
vector<int> degree(n);
vector<bool> killed(n, false);
for (int i = 0; i < n; i++) {
degree[i] = adj[i].size();
if (degree[i] == 1)
leafs.insert(i);
}

vector<int> code(n - 2);
for (int i = 0; i < n - 2; i++) {
int leaf = *leafs.begin();
leafs.erase(leafs.begin());
killed[leaf] = true;

int v;
for (int u : adj[leaf]) {
if (!killed[u])
v = u;
}

code[i] = v;
if (--degree[v] == 1)
leafs.insert(v);
}

return code;
}

Однак побудову можна реалізувати і за лінійний час. Такий підхід описано в наступному розділі.

Побудова коду Прюфера для заданого дерева за лінійний час

Суть алгоритму — використати рухомий вказівник, який завжди вказуватиме на поточну вершину-листок, яку ми хочемо видалити.

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

Для цього ми використаємо змінну ptr\text{ptr}, яка вказуватиме, що серед вершин від 00 до ptr\text{ptr} є щонайбільше одна вершина-листок, а саме поточна. Усі інші вершини в цьому діапазоні або вже видалені з дерева, або мають ще більше ніж одну суміжну вершину. Водночас ми вважаємо, що ми ще не видаляли жодної вершини-листка з номером, більшим за ptr\text{ptr}.

Ця змінна дуже корисна вже в першому випадку. Після видалення поточного листка ми знаємо, що між 00 і ptr\text{ptr} не може бути листка, тому ми можемо починати пошук наступного одразу з ptr+1\text{ptr} + 1, і нам не доведеться повертатися до пошуку від вершини 00. А в другому випадку можна виокремити ще два випадки: або щойно отримана вершина-листок менша за ptr\text{ptr}, і тоді саме вона має бути наступним листком, оскільки ми знаємо, що інших вершин, менших за ptr\text{ptr}, немає. Або щойно отримана вершина-листок більша. Але тоді ми також знаємо, що вона має бути більшою за ptr\text{ptr}, і можемо знову починати пошук з ptr+1\text{ptr} + 1.

Хоча нам, можливо, доведеться виконувати кілька лінійних пошуків наступної вершини-листка, вказівник ptr\text{ptr} лише зростає, а тому загальна часова складність становить O(n)O(n).

vector<vector<int>> adj;
vector<int> parent;

void dfs(int v) {
for (int u : adj[v]) {
if (u != parent[v]) {
parent[u] = v;
dfs(u);
}
}
}

vector<int> pruefer_code() {
int n = adj.size();
parent.resize(n);
parent[n-1] = -1;
dfs(n-1);

int ptr = -1;
vector<int> degree(n);
for (int i = 0; i < n; i++) {
degree[i] = adj[i].size();
if (degree[i] == 1 && ptr == -1)
ptr = i;
}

vector<int> code(n - 2);
int leaf = ptr;
for (int i = 0; i < n - 2; i++) {
int next = parent[leaf];
code[i] = next;
if (--degree[next] == 1 && next < ptr) {
leaf = next;
} else {
ptr++;
while (degree[ptr] != 1)
ptr++;
leaf = ptr;
}
}

return code;
}

У коді ми спочатку знаходимо для кожної вершини її предка parent[i], тобто того предка, якого ця вершина матиме, коли ми видалятимемо її з дерева. Цього предка можна знайти, підвісивши дерево за вершину n1n-1. Це можливо, бо вершина n1n-1 ніколи не буде видалена з дерева. Ми також обчислюємо степінь кожної вершини. ptr — це вказівник, який показує мінімальний номер серед решти вершин-листків (окрім поточної leaf). Ми або призначимо поточною вершиною-листком next, якщо вона теж є листком і менша за ptr, або починаємо лінійний пошук вершини-листка з найменшим номером, збільшуючи вказівник.

Легко бачити, що цей код має складність O(n)O(n).

Деякі властивості коду Прюфера

  • Після побудови коду Прюфера залишаться дві вершини. Одна з них — вершина з найбільшим номером n1n-1, але про іншу нічого певного сказати не можна.
  • Кожна вершина з'являється в коді Прюфера рівно фіксовану кількість разів — на одиницю менше за свій степінь. Це легко перевірити, оскільки степінь зменшується щоразу, коли ми записуємо її номер у код, і ми видаляємо її, коли степінь стає рівним 11. Для двох вершин, що залишилися, цей факт також виконується.

Відновлення дерева за кодом Прюфера

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

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

Ми повторюватимемо цю операцію, доки не використаємо всі числа коду Прюфера: ми шукаємо вершину з найменшим номером і степенем, рівним 11, з'єднуємо її з наступною вершиною з коду Прюфера і зменшуємо степінь.

Урешті в нас залишається лише дві вершини зі степенем, рівним 11. Це ті вершини, які не було видалено під час процесу побудови коду Прюфера. Ми з'єднуємо їх, отримуючи останнє ребро дерева. Одна з них завжди буде вершиною n1n-1.

Цей алгоритм легко реалізувати за O(nlogn)O(n \log n): ми використовуємо структуру даних, що підтримує вилучення мінімуму (наприклад, set<> або priority_queue<> в C++), щоб зберігати всі вершини-листки.

Наступна реалізація повертає список ребер, що відповідають дереву.

vector<pair<int, int>> pruefer_decode(vector<int> const& code) {
int n = code.size() + 2;
vector<int> degree(n, 1);
for (int i : code)
degree[i]++;

set<int> leaves;
for (int i = 0; i < n; i++) {
if (degree[i] == 1)
leaves.insert(i);
}

vector<pair<int, int>> edges;
for (int v : code) {
int leaf = *leaves.begin();
leaves.erase(leaves.begin());

edges.emplace_back(leaf, v);
if (--degree[v] == 1)
leaves.insert(v);
}
edges.emplace_back(*leaves.begin(), n-1);
return edges;
}

Відновлення дерева за кодом Прюфера за лінійний час

Щоб отримати дерево за лінійний час, ми можемо застосувати ту саму техніку, яку використовували для отримання коду Прюфера за лінійний час.

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

vector<pair<int, int>> pruefer_decode(vector<int> const& code) {
int n = code.size() + 2;
vector<int> degree(n, 1);
for (int i : code)
degree[i]++;

int ptr = 0;
while (degree[ptr] != 1)
ptr++;
int leaf = ptr;

vector<pair<int, int>> edges;
for (int v : code) {
edges.emplace_back(leaf, v);
if (--degree[v] == 1 && v < ptr) {
leaf = v;
} else {
ptr++;
while (degree[ptr] != 1)
ptr++;
leaf = ptr;
}
}
edges.emplace_back(leaf, n-1);
return edges;
}

Бієкція між деревами та кодами Прюфера

Для кожного дерева існує відповідний йому код Прюфера. І для кожного коду Прюфера ми можемо відновити вихідне дерево.

Звідси випливає, що й кожен код Прюфера (тобто послідовність з n2n-2 чисел у діапазоні [0;n1][0; n - 1]) відповідає якомусь дереву.

Отже, всі дерева й усі коди Прюфера утворюють бієкцію (взаємно однозначну відповідність).

Формула Келі

Формула Келі стверджує, що кількість кістякових дерев у повному позначеному графі з nn вершинами дорівнює:

nn2n^{n-2}

Існує кілька доведень цієї формули. З огляду на поняття коду Прюфера це твердження не викликає жодного подиву.

Справді, будь-який код Прюфера з n2n-2 чисел з проміжку [0;n1][0; n-1] відповідає якомусь дереву з nn вершинами. Отже, ми маємо nn2n^{n-2} різних таких кодів Прюфера. Оскільки кожне таке дерево є кістяковим деревом повного графа з nn вершинами, кількість таких кістякових дерев теж дорівнює nn2n^{n-2}.

Кількість способів зробити граф зв'язним

Поняття коду Прюфера ще потужніше. Воно дозволяє створювати набагато загальніші формули, ніж формула Келі.

У цій задачі нам дано граф з nn вершинами та mm ребрами. Наразі граф має kk компонент. Ми хочемо обчислити кількість способів додати k1k-1 ребер так, щоб граф став зв'язним (очевидно, що k1k-1 — це мінімальна кількість ребер, необхідна, щоб зробити граф зв'язним).

Виведемо формулу для розв'язання цієї задачі.

Позначимо через s1,,sks_1, \dots, s_k розміри компонент зв'язності графа. Ми не можемо додавати ребра всередині компоненти зв'язності. Тому виявляється, що ця задача дуже схожа на пошук кількості кістякових дерев повного графа з kk вершинами. Єдина відмінність у тому, що кожна вершина насправді має розмір sis_i: кожне ребро, що з'єднує вершину ii, фактично домножує відповідь на sis_i.

Отже, щоб обчислити кількість можливих способів, важливо порахувати, як часто кожна з kk вершин використовується у з'єднувальному дереві. Щоб отримати формулу для задачі, необхідно просумувати відповідь за всіма можливими степенями.

Нехай d1,,dkd_1, \dots, d_k — степені вершин у дереві після з'єднання вершин. Сума степенів дорівнює подвоєній кількості ребер:

i=1kdi=2k2\sum_{i=1}^k d_i = 2k - 2

Якщо вершина ii має степінь did_i, то вона з'являється di1d_i - 1 разів у коді Прюфера. Код Прюфера для дерева з kk вершинами має довжину k2k-2. Отже, кількість способів вибрати код з k2k-2 чисел, де число ii з'являється рівно di1d_i - 1 разів, дорівнює поліноміальному коефіцієнту

(k2d11,d21,,dk1)=(k2)!(d11)!(d21)!(dk1)!.\binom{k-2}{d_1-1, d_2-1, \dots, d_k-1} = \frac{(k-2)!}{(d_1-1)! (d_2-1)! \cdots (d_k-1)!}.

З урахуванням того, що кожне ребро, суміжне з вершиною ii, домножує відповідь на sis_i, ми отримуємо відповідь за умови, що степені вершин дорівнюють d1,,dkd_1, \dots, d_k:

s1d1s2d2skdk(k2d11,d21,,dk1)s_1^{d_1} \cdot s_2^{d_2} \cdots s_k^{d_k} \cdot \binom{k-2}{d_1-1, d_2-1, \dots, d_k-1}

Щоб отримати остаточну відповідь, нам потрібно просумувати це за всіма можливими способами вибору степенів:

di1i=1kdi=2k2s1d1s2d2skdk(k2d11,d21,,dk1)\sum_{\substack{d_i \ge 1 \\\\ \sum_{i=1}^k d_i = 2k -2}} s_1^{d_1} \cdot s_2^{d_2} \cdots s_k^{d_k} \cdot \binom{k-2}{d_1-1, d_2-1, \dots, d_k-1}

Наразі це виглядає як справді жахлива відповідь, однак ми можемо скористатися поліноміальною теоремою, яка стверджує:

(x1++xm)p=ci0i=1mci=px1c1x2c2xmcm(pc1,c2,cm)(x_1 + \dots + x_m)^p = \sum_{\substack{c_i \ge 0 \\\\ \sum_{i=1}^m c_i = p}} x_1^{c_1} \cdot x_2^{c_2} \cdots x_m^{c_m} \cdot \binom{p}{c_1, c_2, \dots c_m}

Це вже виглядає доволі схоже. Щоб скористатися нею, нам потрібно лише зробити підстановку ei=di1e_i = d_i - 1:

ei0i=1kei=k2s1e1+1s2e2+1skek+1(k2e1,e2,,ek)\sum_{\substack{e_i \ge 0 \\\\ \sum_{i=1}^k e_i = k - 2}} s_1^{e_1+1} \cdot s_2^{e_2+1} \cdots s_k^{e_k+1} \cdot \binom{k-2}{e_1, e_2, \dots, e_k}

Застосувавши поліноміальну теорему, ми отримуємо відповідь до задачі:

s1s2sk(s1+s2++sk)k2=s1s2sknk2s_1 \cdot s_2 \cdots s_k \cdot (s_1 + s_2 + \dots + s_k)^{k-2} = s_1 \cdot s_2 \cdots s_k \cdot n^{k-2}

Між іншим, ця формула також справджується для k=1k = 1.

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

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