Декартово дерево - Алгоритмика
Декартово дерево

Декартово дерево

автор Сергей Слотин
опубликовано 2018
обновлено янв. 22, 2022
пререквизиты Деревья поиска Двоичная куча

Рене Декарт (фр. René Descartes) — великий французский математик и философ XVII века.

Рене Декарт не является создателем декартова дерева, однако он является создателем декартовой системы координат, которую мы все знаем и любим.

Декартово дерево же определяется и строится так:

  • Нанесём на плоскость набор из nn точек. Их xx зачем-то назовем ключом, а yy приоритетом.
  • Выберем самую верхнюю точку (с наибольшим yy, а если таких несколько — любую) и назовём её корнем.
  • От всех вершин, лежащих слева (с меньшим xx) от корня, рекурсивно запустим этот же процесс. Если слева была хоть одна вершина, то присоединим корень левой части в качестве левого сына текущего корня.
  • Аналогично, запустимся от правой части и добавим корню правого сына.

Заметим, что если все yy и xx различны, то дерево строится однозначно.

Если нарисовать получившуюся структуру на плоскости, то получится действительно дерево — по традиции, корнем вверх:

Вершины дерева и их координатные представления

Таким образом, декартово дерево — это одновременно бинарное дерево по xx и куча по yy. Поэтому ему придумали много альтернативных названий:

  • Дерамида (дерево + пирамида)
  • Дуча (дерево + куча)
  • ПиВо (пирамида + дерево)
  • КуРево (куча + дерево)
  • Treap (tree + heap)

По-английски его обычно называют cartesian или treap, а по-русски часто сокращают до «ДД».

#Как бинарное дерево поиска

С небольшими модификациями, декартово дерево умеет всё то же, что и любое бинарное дерево поиска, например:

  • добавить число xx в множество;
  • определить, есть ли в множестве число xx;
  • найти первое число, не меньшее xx — аналогично lower_bound;
  • найти количество чисел в промежутке [l,r][l, r].

Как и во всех сбалансированных деревьях поиска, все операции работают за высоту дерева: O(logn)O(\log n).

#Приоритеты и асимптотика

В декартовом дереве логарифмическая высота дерева гарантируется не инвариантами и эвристиками, а теорией вероятностей: оказывается, что если все приоритеты (yy) выбирать случайно, то средняя глубина вершины будет логарифмической. Поэтому ДД ещё называют рандомизированным деревом поиска.

Теорема. Ожидание глубины вершины в декартовом дереве равно O(logn)O(\log n).

Доказательство. Введем функцию a(x,y)a(x, y) равную единице, если xx является предком yy, и нулем в противном случае. Такие функции называются индикаторами.

Глубина вершины равна количеству её предков, и поэтому

di=j=1na(j,i) d_i = \sum_{j=1}^n a(j, i) Её матожидание равно E[di]=E[jia(j,i)]=jiE[a(j,i)]=jiE[a(j,i)]=jip(j,i) E[d_i] = E[\sum_{j \neq i} a(j, i)] = \sum_{j \neq i} E[a(j, i)] = \sum_{j \neq i} E[a(j, i)] = \sum_{j \neq i} p(j, i)

где p(x,y)p(x, y) это вероятность, что a(x,y)=1a(x, y) = 1, то есть что вершина xx это предок вершины yy. Здесь мы воспользовались важным свойством линейности: матожидание суммы чего угодно равна сумме матожиданий этого чего угодно.

Теперь осталось посчитать эти вероятности по всем возможным предкам и сложить. Но сначала нам понадобится вспомогательное утверждение.

Лемма. Вершина xx является предком yy, если у неё приоритет больше, чем у всех вершин из полуинтервала (x,y](x, y] (без ограничения общности, будем считать, что x<yx < y).

Необходимость. Если xx не самая высокая, то где-то между xx и yy есть вершина с большим приоритетом, чем у xx. Она не может быть потомком xx, а значит xx и yy будут разделены ей.

Достаточность. Если справа от интервала будет какая-то вершина с большим приоритетом, то её левым сыном будет какая-то вершина, которая будет являться предком xx. Таким образом, всё, что справа от yy, ни на что влиять не будет.

У всех вершин на любом отрезке одинаковая вероятность иметь наибольший приоритет. Объединяя этот факт с результатом леммы, мы можем получить выражение для искомых вероятностей — чтобы вершина xx была предком yy, её приоритет должен быть больше, чем у всех xy|x - y| остальных на отрезке с xx по y]y]:

p(x,y)=1xy+1 p(x, y) = \frac{1}{|x-y|+1} Теперь, чтобы найти матожидание глубины, эти вероятности надо просуммировать: E[di]=jip(j,i)=ji1ij+1=j<i1ij+j>i1ji2k=2n1k=O(logn) E[d_i] = \sum_{j \neq i} p(j, i) = \sum_{j \neq i} \frac{1}{|i-j|+1} = \sum_{j < i} \frac{1}{i - j} + \sum_{j > i} \frac{1}{j - i} \leq 2 \cdot \sum_{k=2}^n \frac{1}{k} = O(\log n)

Перед последним переходом мы получили сумму гармонического ряда.

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

Упражнение. Выведите по аналогии с этим рассуждением асимптотику quicksort.

#Реализация

Декартово дерево удобнее всего писать на указателях и структурах.

Создадим структуру Node, в которой будем хранить ключ, приоритет и указатели на левого и правого сына:

struct Node {
    int key, prior;
    Node *l = 0, *r = 0;
    Node(int key) : key(key), prior(rand()) {}
};

Указателя на корень дерева достаточно для идентификации всего дерева. Поэтому, когда мы будем говорить «функция принимает два дерева» на самом деле будем иметь в виду указатели на их корни. К нулевому указателю же мы будем относиться, как к «пустому» дереву.

Объявим две вспомогательные функции, изменяющие структуру деревьев: одна будет разделять деревья, а другая объединять. Как мы увидим, через них можно легко выразить почти все функции, которые нам потом понадобятся.

#Merge

Принимает два дерева (два корня, LL и RR), про которые известно, что в левом все вершины имеют меньший ключ, чем все в правом. Их нужно объединить в одно дерево так, чтобы ничего не сломалось: по ключам это всё ещё дерево, а по приоритетами — куча.

Сначала выберем, какая вершина будет корнем. Здесь всего два кандидата — левый корень LL или правый RR — просто возьмем тот, у кого приоритет больше.

Пусть, для однозначности, это был левый корень. Тогда левый сын корня итогового дерева должен быть левым сыном LL. С правым сыном сложнее: возможно, его нужно смерджить с RR. Поэтому рекурсивно сделаем merge(l->r, r) и запишем результат в качестве правого сына.

Node* merge(Node *l, Node *r) {
    if (!l) return r;
    if (!r) return l;
    if (l->prior > r->prior) {
        l->r = merge(l->r, r);
        return l;
    }
    else {
        r->l = merge(l, r->l);
        return r;
    }
}

#Split

Принимает дерево PP и ключ xx, по которому его нужно разделить на два: LL должно иметь все ключи не больше xx, а RR должно иметь все ключи больше xx.

В этой функции мы сначала решим, в каком из деревьев должен быть корень, а потом рекурсивно разделим его правую или левую половину и присоединим, куда надо:

pair<Node*, Node*> split(Node *p, int x) {
    if (!p) return {0, 0};
    if (p->key <= x) {
        auto [l, r] = split(p->r, x);
        p->r = l;
        return {p, r};
    }
    else {
        auto [l, r] = split(p->l, x);
        p->l = r;
        return {l, p};
    }
}

#Пример: вставка

merge и split сами по себе не очень полезные, но помогут написать всё остальное.

Например, чтобы добавить число xx в дерево, мы можем разрезать его по xx через split, создать новую вершину с одним числом xx, и склеить через merge три получившихся дерева:

Node *root = 0;

void insert(int x) {
    auto [l, r] = split(root, x);
    Node *t = new Node(x);
    root = merge(l, merge(t, r));
}

#Пример: модификация для суммы на отрезке

Иногда нам нужно написать какие-то модификации для более продвинутых операций.

Например, нам может быть интересно иногда считать сумму чисел на отрезке. Для этого в вершине нужно хранить также своё число и сумму на своем «отрезке»:

struct Node {
    int val, sum;
    // ...
};

При merge и split надо будет поддерживать эту сумму актуальной.

Вместо того, чтобы модифицировать и merge, и split под наши хотелки, напишем вспомогательную функцию upd, которую будем вызывать при обновлении детей вершины:

int sum(Node* v) { return v ? v->sum : 0; }
// обращаться по пустому указателю нельзя -- выдаст ошибку

void upd(Node* v) { v->sum = sum(v->l) + sum(v->r) + v->val; }

В merge и split теперь можно просто вызывать upd перед тем, как вернуть вершину, и тогда ничего не сломается:

Node* merge(Node *l, Node *r) {
    // ...
    if (...) {
        l->r = merge(l->r, r);
        upd(l);
        return l;
    }
    else {
        // ...
    }
}

pair<Node*, Node*> split(Node *p, int x) {
    // ...
    if (...) {
        // ...
        upd(p);
        return {p, r};
    }
    else {
        // ...
    }
}

Тогда при запросе суммы нужно просто вырезать нужный отрезок и запросить эту сумму:

int sum(int l, int r) {
    auto [T, R] = split(root, r);
    auto [L, M] = split(T, l);
    int res = sum(M);
    root = merge(L, merge(M, R));
    return res;
}

#Небольшой рефакторинг

Реализация большинства операций всегда примерно одинаковая — вырезаем отрезок с ll по rr, что-то с ним делаем и склеиваем обратно.

Дублирующийся код — это плохо. Давайте воспользуемся всей мощью C++ и определим функцию, которая принимает другую функцию, которая в свою очередь уже будет делать полезные вещи на нужном отрезке:

int apply(int l, int r, auto f) {
    auto [T, R] = split(root, r);
    auto [L, M] = split(T, l);
    int res = f(M);
    root = merge(L, merge(M, R));
    return res;
}

Применять её нужно так:

apply(l, r, sum);

Для большинства операций удобно туда передать лямбду, если она ещё не была реализована для upd.