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

Декартове дерево (treap, Cartesian tree)

Декартове дерево (treap) — це структура даних, яка поєднує бінарне дерево й бінарну купу (звідси й назва: tree + heap \Rightarrow Treap).

Точніше, декартове дерево — це структура даних, яка зберігає пари (X,Y)(X, Y) у бінарному дереві так, що воно є бінарним деревом пошуку за XX і бінарною купою за YY. Якщо деякий вузол дерева містить значення (X0,Y0)(X_0, Y_0), то всі вузли в лівому піддереві мають XX0X \leq X_0, усі вузли в правому піддереві мають X0XX_0 \leq X, а всі вузли і в лівому, і в правому піддеревах мають YY0Y \leq Y_0.

Декартове дерево часто називають «декартовим деревом» саме тому, що його легко вкласти в декартову площину:

Декартове дерево запропонували Raimund Siedel і Cecilia Aragon у 1989 році.

Коли підходить цей алгоритм?
  • Чи потрібні операції розрізання/злиття (split/merge) послідовності або вставка/видалення за довільною позицією, чого не вміє дерево відрізків? (якщо потрібні лише агрегати на відрізку без зміни структури → дерево відрізків)
  • Чи потрібне збалансоване бінарне дерево пошуку з порядковою статистикою (kk-й елемент, індекс елемента), а не лише запити на масиві фіксованого розміру?
  • Чи влаштовує очікувана збалансованість O(logn)O(\log n) за рахунок випадкових пріоритетів заради простоти реалізації?

Переваги такої організації даних

У такій реалізації значення XX є ключами (і водночас значеннями, що зберігаються в трипі), а значення YY називають пріоритетами. Без пріоритетів декартове дерево було б звичайним бінарним деревом пошуку за XX, і одному набору значень XX могло б відповідати багато різних дерев, деякі з яких вироджені (наприклад, у вигляді зв'язного списку), а отже надзвичайно повільні (основні операції мали б складність O(N)O(N)).

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

Операції

Декартове дерево надає такі операції:

  • Insert (X,Y) за O(logN)O(\log N).
    Додає новий вузол у дерево. Один з можливих варіантів — передавати лише XX, а YY генерувати випадково всередині операції.
  • Search (X) за O(logN)O(\log N).
    Шукає вузол із заданим значенням ключа XX. Реалізація така сама, як і для звичайного бінарного дерева пошуку.
  • Erase (X) за O(logN)O(\log N).
    Шукає вузол із заданим значенням ключа XX і видаляє його з дерева.
  • Build (X1X_1, ..., XNX_N) за O(N)O(N).
    Будує дерево зі списку значень. Це можна зробити за лінійний час (за умови, що X1,...,XNX_1, ..., X_N відсортовані).
  • Union (T1T_1, T2T_2) за O(Mlog(N/M))O(M \log (N/M)).
    Зливає два дерева, припускаючи, що всі елементи різні. Тієї самої складності можна досягти й тоді, коли під час злиття потрібно вилучати дублікати.
  • Intersect (T1T_1, T2T_2) за O(Mlog(N/M))O(M \log (N/M)).
    Знаходить перетин двох дерев (тобто їхні спільні елементи). Реалізацію цієї операції ми тут не розглядатимемо.

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

Опис реалізації

З точки зору реалізації, кожен вузол містить XX, YY і вказівники на лівого (LL) та правого (RR) нащадків.

Усі потрібні операції ми реалізуємо за допомогою лише двох допоміжних операцій: розділення (Split) і злиття (Merge).

Розділення (Split)

Split (TT, XX) розбиває дерево TT на 2 піддерева LL і RR (які є значеннями, що повертаються розділенням) так, що LL містить усі елементи з ключем XLXX_L \le X, а RR містить усі елементи з ключем XR>XX_R > X. Ця операція має складність O(logN)O (\log N) і реалізується чистою рекурсією:

  1. Якщо значення кореневого вузла (R) X\le X, то L міститиме принаймні R->L і R. Тоді ми викликаємо розділення на R->R і позначаємо результат його розділення як L' і R'. Зрештою, L також міститиме L', тоді як R = R'.
  2. Якщо значення кореневого вузла (R) >X> X, то R міститиме принаймні R і R->R. Тоді ми викликаємо розділення на R->L і позначаємо результат його розділення як L' і R'. Зрештою, L=L', тоді як R також міститиме R'.

Отже, алгоритм розділення такий:

  1. визначити, до якого піддерева належатиме кореневий вузол (лівого чи правого)
  2. рекурсивно викликати розділення на одному з його нащадків
  3. сформувати остаточний результат, повторно використавши рекурсивний виклик розділення.

Злиття (Merge)

Merge (T1T_1, T2T_2) об'єднує два піддерева T1T_1 і T2T_2 та повертає нове дерево. Ця операція також має складність O(logN)O (\log N). Вона працює за припущення, що T1T_1 і T2T_2 впорядковані (усі ключі XX у T1T_1 менші за ключі в T2T_2). Тож нам потрібно об'єднати ці дерева, не порушуючи порядку пріоритетів YY. Для цього ми обираємо коренем те дерево, яке має більший пріоритет YY у кореневому вузлі, і рекурсивно викликаємо Merge для іншого дерева та відповідного піддерева обраного кореневого вузла.

Вставка (Insert)

Тепер реалізація операції Insert (XX, YY) стає очевидною. Спершу ми спускаємося деревом (як у звичайному бінарному дереві пошуку за X) і зупиняємося на першому вузлі, у якому значення пріоритету менше за YY. Ми знайшли місце, куди вставимо новий елемент. Далі ми викликаємо Split (T, X) на піддереві, що починається зі знайденого вузла, і використовуємо повернуті піддерева LL і RR як лівого та правого нащадків нового вузла.

Як альтернатива, вставку можна виконати, розділивши початкове декартове дерево за XX і зробивши 22 злиття з новим вузлом (див. рисунок).

Видалення (Erase)

Реалізація операції Erase (XX) теж зрозуміла. Спершу ми спускаємося деревом (як у звичайному бінарному дереві пошуку за XX), шукаючи елемент, який хочемо видалити. Щойно вузол знайдено, ми викликаємо Merge для його нащадків і ставимо повернуте значення операції на місце елемента, який видаляємо.

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

Побудова (Build)

Операцію Build ми реалізуємо зі складністю O(NlogN)O (N \log N) за допомогою NN викликів Insert.

Об'єднання (Union)

Union (T1T_1, T2T_2) має теоретичну складність O(Mlog(N/M))O (M \log (N / M)), але на практиці працює дуже добре, імовірно, з дуже малою прихованою константою. Припустимо без втрати загальності, що T1Y>T2YT_1 \rightarrow Y > T_2 \rightarrow Y, тобто корінь T1T_1 буде коренем результату. Щоб отримати результат, нам потрібно злити дерева T1LT_1 \rightarrow L, T1RT_1 \rightarrow R і T2T_2 у два дерева, які могли б бути нащадками кореня T1T_1. Для цього ми викликаємо Split (T2T_2, T1XT_1\rightarrow X), розбиваючи T2T_2 на дві частини L і R, які потім рекурсивно об'єднуємо з нащадками T1T_1: Union (T1LT_1 \rightarrow L, LL) і Union (T1RT_1 \rightarrow R, RR), отримуючи таким чином ліве й праве піддерева результату.

Реалізація

struct item {
int key, prior;
item *l, *r;
item () { }
item (int key) : key(key), prior(rand()), l(NULL), r(NULL) { }
item (int key, int prior) : key(key), prior(prior), l(NULL), r(NULL) { }
};
typedef item* pitem;

Це означення нашого елемента. Зверніть увагу, що тут є два вказівники на нащадків, цілочисловий ключ (для BST) і цілочисловий пріоритет (для купи). Пріоритет призначається за допомогою генератора випадкових чисел.

void split (pitem t, int key, pitem & l, pitem & r) {
if (!t)
l = r = NULL;
else if (t->key <= key)
split (t->r, key, t->r, r), l = t;
else
split (t->l, key, l, t->l), r = t;
}

t — це декартове дерево, яке треба розділити, а key — значення BST, за яким розділяти. Зверніть увагу, що ми ніде не повертаємо (return) результуючі значення, натомість просто використовуємо їх так:

pitem l = nullptr, r = nullptr;
split(t, 5, l, r);
if (l) cout << "Left subtree size: " << (l->size) << endl;
if (r) cout << "Right subtree size: " << (r->size) << endl;

Цю функцію split буває непросто зрозуміти, оскільки в ній є і вказівники (pitem), і посилання на ці вказівники (pitem &l). Розгляньмо словами, що означає виклик функції split(t, k, l, r): «розділити декартове дерево t за значенням k на два дерева й зберегти ліве дерево в l, а праве — в r». Чудово! Тепер застосуймо це означення до двох рекурсивних викликів, скориставшись розбором випадків з попереднього розділу: (Перша умова if — це тривіальний базовий випадок для порожнього дерева)

  1. Коли значення кореневого вузла \le key, ми викликаємо split (t->r, key, t->r, r), що означає: «розділити декартове дерево t->r (праве піддерево t) за значенням key і зберегти ліве піддерево в t->r, а праве — в r». Після цього ми присвоюємо l = t. Зверніть увагу, що тепер результуюче значення l містить t->l, t, а також t->r (який є результатом зробленого нами рекурсивного виклику) — і все вже злите в правильному порядку! Варто зупинитися й переконатися, що цей результат l і r точно відповідає тому, що ми обговорювали раніше в розділі «Опис реалізації».
  2. Коли значення кореневого вузла більше за key, ми викликаємо split (t->l, key, l, t->l), що означає: «розділити декартове дерево t->l (ліве піддерево t) за значенням key і зберегти ліве піддерево в l, а праве — в t->l». Після цього ми присвоюємо r = t. Зверніть увагу, що тепер результуюче значення r містить t->l (який є результатом зробленого нами рекурсивного виклику), t, а також t->r — і все вже злите в правильному порядку! Варто зупинитися й переконатися, що цей результат l і r точно відповідає тому, що ми обговорювали раніше в розділі «Опис реалізації».

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

void insert (pitem & t, pitem it) {
if (!t)
t = it;
else if (it->prior > t->prior)
split (t, it->key, it->l, it->r), t = it;
else
insert (t->key <= it->key ? t->r : t->l, it);
}

void merge (pitem & t, pitem l, pitem r) {
if (!l || !r)
t = l ? l : r;
else if (l->prior > r->prior)
merge (l->r, l->r, r), t = l;
else
merge (r->l, l, r->l), t = r;
}

void erase (pitem & t, int key) {
if (t->key == key) {
pitem th = t;
merge (t, t->l, t->r);
delete th;
}
else
erase (key < t->key ? t->l : t->r, key);
}

pitem unite (pitem l, pitem r) {
if (!l || !r) return l ? l : r;
if (l->prior < r->prior) swap (l, r);
pitem lt, rt;
split (r, l->key, lt, rt);
l->l = unite (l->l, lt);
l->r = unite (l->r, rt);
return l;
}

Підтримання розмірів піддерев

Щоб розширити функціональність декартового дерева, часто потрібно зберігати кількість вузлів у піддереві кожного вузла — поле int cnt у структурі item. Наприклад, його можна використати для знаходження KK-го за величиною елемента дерева за O(logN)O (\log N) або для знаходження індексу елемента у відсортованому списку з тією самою складністю. Реалізація цих операцій буде такою самою, як і для звичайного бінарного дерева пошуку.

Коли дерево змінюється (додаються чи видаляються вузли тощо), значення cnt деяких вузлів треба відповідно оновити. Створимо дві функції: cnt() повертатиме поточне значення cnt або 0, якщо вузла не існує, а upd_cnt() оновлюватиме значення cnt для цього вузла за припущення, що для його нащадків L і R значення cnt вже оновлені. Очевидно, достатньо додати виклики upd_cnt() у кінці insert, erase, split і merge, щоб значення cnt лишалися актуальними.

int cnt (pitem t) {
return t ? t->cnt : 0;
}

void upd_cnt (pitem t) {
if (t)
t->cnt = 1 + cnt(t->l) + cnt (t->r);
}

Побудова декартового дерева за O(N)O (N) в офлайн-режимі

Маючи відсортований список ключів, декартове дерево можна побудувати швидше, ніж вставляючи ключі по одному, що займає O(NlogN)O(N \log N). Оскільки ключі відсортовані, збалансоване бінарне дерево пошуку легко побудувати за лінійний час. Значення купи YY ініціалізуються випадково, а потім із них можна незалежно від ключів XX зробити купу (heapify) за O(N)O(N).

void heapify (pitem t) {
if (!t) return;
pitem max = t;
if (t->l != NULL && t->l->prior > max->prior)
max = t->l;
if (t->r != NULL && t->r->prior > max->prior)
max = t->r;
if (max != t) {
swap (t->prior, max->prior);
heapify (max);
}
}

pitem build (int * a, int n) {
// Будуємо декартове дерево на значеннях {a[0], a[1], ..., a[n - 1]}
if (n == 0) return NULL;
int mid = n / 2;
pitem t = new item (a[mid], rand ());
t->l = build (a, mid);
t->r = build (a + mid + 1, n - mid - 1);
heapify (t);
upd_cnt(t)
return t;
}

Зауваження: виклик upd_cnt(t) потрібен лише тоді, коли вам потрібні розміри піддерев.

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

інформація

acmsguru - Cartesian Tree

Дано послідовність пар (xi,yi)(x_i, y_i); потрібно побудувати на них декартове дерево. Усі xix_i і всі yiy_i унікальні.

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

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

Ця задача розв'язується за допомогою модифікації стека з мінімумом за лінійний час:

void connect(auto from, auto to) {
vector<pitem> st;
for(auto it: ranges::subrange(from, to)) {
while(!st.empty() && st.back()->prior > it->prior) {
st.pop_back();
}
if(!st.empty()) {
if(!it->p || it->p->prior < st.back()->prior) {
it->p = st.back();
}
}
st.push_back(it);
}
}

pitem build(int *x, int *y, int n) {
vector<pitem> nodes(n);
for(int i = 0; i < n; i++) {
nodes[i] = new item(x[i], y[i]);
}
connect(nodes.begin(), nodes.end());
connect(nodes.rbegin(), nodes.rend());
for(int i = 0; i < n; i++) {
if(nodes[i]->p) {
if(nodes[i]->p->key < nodes[i]->key) {
nodes[i]->p->r = nodes[i];
} else {
nodes[i]->p->l = nodes[i];
}
}
}
return nodes[min_element(y, y + n) - y];
}

Декартові дерева за неявним ключем (Implicit Treaps)

Декартове дерево за неявним ключем — це проста модифікація звичайного декартового дерева, яка є дуже потужною структурою даних. Фактично декартове дерево за неявним ключем можна розглядати як масив з такими реалізованими процедурами (усі за O(logN)O (\log N) в онлайн-режимі):

  • Вставка елемента в масив у будь-яке місце
  • Видалення довільного елемента
  • Знаходження суми, мінімального / максимального елемента тощо на довільному інтервалі
  • Додавання, «зафарбовування» на довільному інтервалі
  • Обернення елементів на довільному інтервалі

Ідея в тому, що ключами мають бути індекси елементів у масиві, що відлічуються від нуля. Але ми не зберігатимемо ці значення явно (інакше, наприклад, вставка елемента спричинила б зміну ключа в O(N)O (N) вузлах дерева).

Зверніть увагу, що ключ вузла — це кількість вузлів, менших за нього (такі вузли можуть бути не лише в його лівому піддереві, а й у лівих піддеревах його предків). Точніше, неявний ключ для деякого вузла T — це кількість вершин cnt(TL)cnt (T \rightarrow L) у лівому піддереві цього вузла плюс аналогічні значення cnt(PL)+1cnt (P \rightarrow L) + 1 для кожного предка P вузла T, якщо T міститься в правому піддереві P.

Тепер зрозуміло, як швидко обчислити неявний ключ поточного вузла. Оскільки в усіх операціях ми приходимо до будь-якого вузла, спускаючись деревом, ми можемо просто накопичувати цю суму й передавати її у функцію. Якщо ми йдемо в ліве піддерево, накопичена сума не змінюється; якщо йдемо в праве піддерево, вона збільшується на cnt(TL)+1cnt (T \rightarrow L) +1.

Ось нові реалізації операцій Split і Merge:

void merge (pitem & t, pitem l, pitem r) {
if (!l || !r)
t = l ? l : r;
else if (l->prior > r->prior)
merge (l->r, l->r, r), t = l;
else
merge (r->l, l, r->l), t = r;
upd_cnt (t);
}

void split (pitem t, pitem & l, pitem & r, int key, int add = 0) {
if (!t)
return void( l = r = 0 );
int cur_key = add + cnt(t->l); //неявний ключ
if (key <= cur_key)
split (t->l, l, t->l, key, add), r = t;
else
split (t->r, t->r, r, key, add + 1 + cnt(t->l)), l = t;
upd_cnt (t);
}

У наведеній вище реалізації після виклику split(T,T1,T2,k)split(T, T_1, T_2, k) дерево T1T_1 складатиметься з перших kk елементів TT (тобто з елементів, неявний ключ яких менший за kk), а T2T_2 складатиметься з усіх інших.

Тепер розгляньмо реалізацію різних операцій на декартових деревах за неявним ключем:

  • Вставка елемента.
    Припустимо, нам потрібно вставити елемент у позицію pospos. Ми ділимо декартове дерево на дві частини, які відповідають масивам [0..pos1][0..pos-1] і [pos..sz][pos..sz]; для цього викликаємо split(T,T1,T2,pos)split(T, T_1, T_2, pos). Тоді ми можемо об'єднати дерево T1T_1 з новою вершиною, викликавши merge(T1,T1,new item)merge(T_1, T_1, \text{new item}) (легко бачити, що всі передумови виконуються). Нарешті, ми об'єднуємо дерева T1T_1 і T2T_2 назад у TT, викликавши merge(T,T1,T2)merge(T, T_1, T_2).
  • Видалення елемента.
    Ця операція ще простіша: знаходимо елемент, який треба видалити, TT, виконуємо злиття його нащадків LL і RR та замінюємо елемент TT результатом злиття. Фактично видалення елемента в декартовому дереві за неявним ключем точно таке саме, як і у звичайному декартовому дереві.
  • Знаходження суми / мінімуму тощо на інтервалі.
    По-перше, створюємо в структурі item додаткове поле FF для зберігання значення цільової функції для піддерева цього вузла. Це поле легко підтримувати так само, як і розміри піддерев: створюємо функцію, яка обчислює це значення для вузла на основі значень для його нащадків, і додаємо виклики цієї функції в кінці всіх функцій, що модифікують дерево.
    По-друге, нам потрібно навчитися обробляти запит для довільного інтервалу [A;B][A; B].
    Щоб отримати частину дерева, яка відповідає інтервалу [A;B][A; B], нам потрібно викликати split(T,T2,T3,B+1)split(T, T_2, T_3, B+1), а потім split(T2,T1,T2,A)split(T_2, T_1, T_2, A): після цього T2T_2 складатиметься з усіх елементів інтервалу [A;B][A; B], і лише з них. Тому відповідь на запит зберігатиметься в полі FF кореня T2T_2. Після того, як на запит відповіли, дерево потрібно відновити, викликавши merge(T,T1,T2)merge(T, T_1, T_2) і merge(T,T,T3)merge(T, T, T_3).
  • Додавання / зафарбовування на інтервалі.
    Діємо аналогічно до попереднього абзацу, але замість поля F зберігатимемо поле add, яке міститиме додане значення для піддерева (або значення, яким зафарбовується піддерево). Перед виконанням будь-якої операції ми маємо коректно «проштовхнути» (push) це значення — тобто змінити TLaddT \rightarrow L \rightarrow add і TRaddT \rightarrow R \rightarrow add та очистити add у батьківському вузлі. Так після будь-яких змін у дереві інформація не втратиться.
  • Обернення на інтервалі.
    Це знову схоже на попередню операцію: нам потрібно додати булевий прапорець rev і встановлювати його в true, коли піддерево поточного вузла треба обернути. «Проштовхування» (push) цього значення трохи складніше — ми міняємо місцями нащадків цього вузла й встановлюємо цей прапорець у true для них.

Ось приклад реалізації декартового дерева за неявним ключем з оберненням на інтервалі. Для кожного вузла ми зберігаємо поле value, яке є фактичним значенням елемента масиву в поточній позиції. Також ми наводимо реалізацію функції output(), яка виводить масив, що відповідає поточному стану декартового дерева за неявним ключем.

typedef struct item * pitem;
struct item {
int prior, value, cnt;
bool rev;
pitem l, r;
};

int cnt (pitem it) {
return it ? it->cnt : 0;
}

void upd_cnt (pitem it) {
if (it)
it->cnt = cnt(it->l) + cnt(it->r) + 1;
}

void push (pitem it) {
if (it && it->rev) {
it->rev = false;
swap (it->l, it->r);
if (it->l) it->l->rev ^= true;
if (it->r) it->r->rev ^= true;
}
}

void merge (pitem & t, pitem l, pitem r) {
push (l);
push (r);
if (!l || !r)
t = l ? l : r;
else if (l->prior > r->prior)
merge (l->r, l->r, r), t = l;
else
merge (r->l, l, r->l), t = r;
upd_cnt (t);
}

void split (pitem t, pitem & l, pitem & r, int key, int add = 0) {
if (!t)
return void( l = r = 0 );
push (t);
int cur_key = add + cnt(t->l);
if (key <= cur_key)
split (t->l, l, t->l, key, add), r = t;
else
split (t->r, t->r, r, key, add + 1 + cnt(t->l)), l = t;
upd_cnt (t);
}

void reverse (pitem t, int l, int r) {
pitem t1, t2, t3;
split (t, t1, t2, l);
split (t2, t2, t3, r-l+1);
t2->rev ^= true;
merge (t, t1, t2);
merge (t, t, t3);
}

void output (pitem t) {
if (!t) return;
push (t);
output (t->l);
printf ("%d ", t->value);
output (t->r);
}

Література

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

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