Раскрывающееся дерево — это бинарное дерево поиска с дополнительным свойством, что недавно использованные элементы быстро доступны снова. Подобно самобалансирующимся бинарным деревьям поиска , раскрывающееся дерево выполняет основные операции, такие как вставка, поиск и удаление за O (log n ) амортизированного времени. Для шаблонов случайного доступа, взятых из неравномерного случайного распределения, их амортизированное время может быть быстрее логарифмического, пропорционального энтропии шаблона доступа. Для многих шаблонов неслучайных операций, также, раскрывающиеся деревья могут занимать лучшее, чем логарифмическое время, не требуя предварительного знания шаблона. Согласно недоказанной гипотезе динамической оптимальности, их производительность для всех шаблонов доступа находится в пределах постоянного множителя наилучшей возможной производительности, которая может быть достигнута любым другим самонастраивающимся бинарным деревом поиска, даже выбранным для соответствия этому шаблону. Раскрывающееся дерево было изобретено Дэниелом Слейтором и Робертом Тарьяном в 1985 году. [1]
Все обычные операции на бинарном дереве поиска объединены с одной базовой операцией, называемой splaying . Растягивание дерева для определенного элемента перестраивает дерево так, что элемент помещается в корень дерева. Один из способов сделать это с помощью базовой операции поиска — сначала выполнить стандартный поиск в бинарном дереве для нужного элемента, а затем использовать повороты дерева определенным образом, чтобы переместить элемент наверх. В качестве альтернативы, алгоритм сверху вниз может объединить поиск и реорганизацию дерева в одну фазу.
Хорошая производительность для раскидистого дерева зависит от того, что оно является самооптимизирующимся, в том смысле, что часто используемые узлы будут перемещаться ближе к корню, где к ним можно будет получить доступ быстрее. Худшая высота — хотя и маловероятная — составляет O( n ), а средняя — O(log n ). Наличие часто используемых узлов вблизи корня является преимуществом для многих практических приложений (см. также локальность ссылок ), и особенно полезно для реализации кэшей и алгоритмов сборки мусора .
Преимущества включают в себя:
Самым существенным недостатком разветвленных деревьев является то, что высота разветвленного дерева может быть линейной. [2] : 1 Например, это будет иметь место после доступа ко всем n элементам в неубывающем порядке. Поскольку высота дерева соответствует наихудшему времени доступа, это означает, что фактическая стоимость одной операции может быть высокой. Однако амортизированная стоимость доступа в этом наихудшем случае логарифмическая, O(log n ). Кроме того, ожидаемая стоимость доступа может быть снижена до O(log n ) с помощью рандомизированного варианта. [4]
Представление расширяющихся деревьев может меняться, даже если к ним осуществляется доступ «только для чтения» (т. е. с помощью операций поиска ). Это усложняет использование таких расширяющихся деревьев в многопоточной среде. В частности, требуется дополнительное управление, если нескольким потокам разрешено выполнять операции поиска одновременно. Это также делает их непригодными для общего использования в чисто функциональном программировании, хотя даже там их можно использовать ограниченными способами для реализации приоритетных очередей.
Наконец, когда модель доступа случайна , дополнительные накладные расходы на расширение добавляют значительный постоянный фактор к стоимости по сравнению с менее динамичными альтернативами.
При доступе к узлу x выполняется операция splay для перемещения x в корень. Операция splay представляет собой последовательность шагов splay , каждый из которых перемещает x ближе к корню. Выполняя операцию splay на интересующем узле после каждого доступа, недавно использованные узлы сохраняются около корня, и дерево остается примерно сбалансированным, поэтому оно обеспечивает желаемые амортизированные временные границы.
Каждый конкретный шаг зависит от трех факторов:
Существует три типа ступеней расширения, каждый из которых имеет два симметричных варианта: левосторонний и правосторонний. Для краткости для каждого типа показан только один из этих двух вариантов. (На следующих диаграммах кружки обозначают интересующие узлы, а треугольники обозначают поддеревья произвольного размера.) Три типа ступеней расширения:
Шаг Zig: этот шаг выполняется, когда p является корнем. Дерево поворачивается на ребре между x и p . Шаги Zig существуют для решения проблемы четности, будут выполняться только как последний шаг в операции расширения и только когда x имеет нечетную глубину в начале операции.
Шаг зигзагообразный: этот шаг выполняется, когда p не является корнем, а x и p являются либо правыми потомками, либо левыми потомками. На рисунке ниже показан случай, когда x и p являются левыми потомками. Дерево поворачивается на ребре, соединяющем p с его родителем g , затем поворачивается на ребре, соединяющем x с p . Шаги зигзагообразный — единственное, что отличает расходящиеся деревья от метода поворота к корню, введенного Алленом и Манро [5] до введения расходящихся деревьев.
Шаг зигзага: этот шаг выполняется, когда p не является корнем, а x является правым потомком, а p является левым потомком или наоборот ( x является левым, p является правым). Дерево поворачивается на ребре между p и x , а затем поворачивается на результирующем ребре между x и g .
Если даны два дерева S и T, такие, что все элементы S меньше элементов T, то для их объединения в одно дерево можно использовать следующие шаги:
Дано дерево и элемент x , вернуть два новых дерева: одно, содержащее все элементы, меньшие или равные x , и другое, содержащее все элементы, большие x . Это можно сделать следующим образом:
Чтобы вставить значение x в расширяющееся дерево:
В результате вновь вставленный узел x становится корнем дерева.
Альтернативно:
Чтобы удалить узел x , используйте тот же метод, что и в бинарном дереве поиска:
Таким образом, удаление сводится к проблеме удаления узла с 0 или 1 потомком. В отличие от бинарного дерева поиска, в расширяющемся дереве после удаления мы расширяем родительский узел удаленного узла на вершину дерева.
Альтернативно:
Растягивание, как упоминалось выше, выполняется во время второго прохода снизу вверх по пути доступа узла. Можно записать путь доступа во время первого прохода для использования во время второго, но это требует дополнительного места во время операции доступа. Другой альтернативой является сохранение родительского указателя в каждом узле, что позволяет избежать необходимости в дополнительном месте во время операций доступа, но может снизить общую эффективность времени из-за необходимости обновления этих указателей. [1]
Другой метод, который можно использовать, основан на аргументе, что дерево может быть реструктурировано во время пути вниз по пути доступа вместо того, чтобы делать второй проход. Эта процедура расширения сверху вниз использует три набора узлов — левое дерево, правое дерево и среднее дерево. Первые два содержат все элементы исходного дерева, которые, как известно, меньше или больше текущего элемента соответственно. Среднее дерево состоит из поддерева с корнем в текущем узле. Эти три набора обновляются вниз по пути доступа, при этом операции расширения контролируются. Другой метод, полурасширение, изменяет случай зигзага, чтобы уменьшить количество реструктуризаций, выполняемых во всех операциях. [1] [6]
Ниже представлена реализация расширяющихся деревьев на языке C++, которая использует указатели для представления каждого узла дерева. Эта реализация основана на версии расширяющегося снизу вверх и использует второй метод удаления в расширяющемся дереве. Также, в отличие от определения выше, эта версия C++ не расширяет дерево при поиске — она расширяет только при вставках и удалениях, и операция поиска, таким образом, имеет линейную временную сложность.
#include <функциональный> #ifndef SPLAY_TREE #define SPLAY_TREEtemplate < typename T , typename Comp = std :: less < T >> class splay_tree { private : Comp comp ; unsigned long p_size ; struct node { node * left , * right ; node * parent ; T key ; node ( const T & init = T ()) : left ( nullptr ), right ( nullptr ), parent ( nullptr ), key ( init ) { } ~ node () { } } * root ; void left_rotate ( узел * x ) { узел * y = x -> вправо ; если ( y ) { x -> вправо = y -> влево ; если ( y -> влево ) y -> влево -> родитель = x ; y -> родитель = x -> родитель ; } если ( ! x -> родитель ) корень = y ; иначе если ( x == x -> родитель -> влево ) x -> родитель -> влево = y ; иначе x -> родитель -> вправо = y ; если ( y ) y -> влево = x ; x -> родитель = y ; } void right_rotate ( узел * x ) { узел * y = x -> влево ; если ( y ) { x -> влево = y -> вправо ; если ( y -> вправо ) y -> вправо -> родитель = x ; y -> родитель = x -> родитель ; } если ( ! x -> родитель ) корень = y ; иначе если ( x == x -> родитель -> левый ) x -> родитель -> левый = y ; иначе x -> родитель -> справа = y ; если ( y ) y -> справа = x ; x -> родитель = y ; } void splay ( узел * x ) { while ( x -> родитель ) { if ( !x -> родитель -> родитель ) { if ( x -> родитель -> влево == x ) поворот_вправо ( x -> родитель ); else поворот_влево ( x -> родитель ); } else if ( x -> родитель -> влево == x && x -> родитель -> родитель -> влево == x -> родитель ) { поворот_вправо ( x -> родитель -> родитель ); поворот_вправо ( x -> родитель ); } else if ( x -> родитель -> вправо == x && x -> родитель -> родитель -> вправо == x -> родитель ) { поворот_влево ( x -> родитель -> родитель ); поворот_влево ( x -> родитель ); } else if ( x -> родитель -> влево == x && x -> родитель -> родитель -> вправо == x -> родитель ) { поворот_вправо ( x -> родитель ); поворот_влево ( x -> родитель ); } else { left_rotate ( x -> parent ); right_rotate ( x -> parent ); } } } void replace ( node * u , node * v ) { if ( ! u -> parent ) root = v ; else if ( u == u -> parent -> left ) u -> parent -> left = v ; else u -> parent -> right = v ; if ( v ) v -> parent = u -> parent ; } node * subtree_minimum ( node * u ) { while ( u -> left ) u = u -> left ; return u ; } node * subtree_maximum ( node * u ) { while ( u -> right ) u = u -> right ; return u ; } public : splay_tree () : root ( nullptr ), p_size ( 0 ) { } void insert ( const T & key ) { node * z = root ; узел * p = nullptr ; while ( z ) { p = z ; if ( comp ( z -> key , key )) z = z -> right ; else z = z -> left ; } z = new node ( key ); z -> parent = p ; if ( ! p ) root = z ; else if ( comp ( p -> key , z -> key )) p -> right = z ; else p -> left = z ; splay ( z ); p_size ++ ; } node * find ( const T & key ) { node * z = root ; while ( z ) { if ( comp ( z -> key , key )) z = z -> right ; else if ( comp ( key , z -> key )) z = z -> left ; else return z ; } return nullptr ; } void erasure ( const T & key ) { node * z = find ( key ); if ( ! z ) return ; splay ( z ); if ( ! z -> left ) replace ( z , z -> right ); else if ( ! z -> right ) replace ( z , z -> left ); иначе { узел * y = subtree_minimum ( z -> right ); если ( y -> родитель != z ) { заменить ( y , y -> вправо ); y -> вправо = z -> вправо ; y -> вправо -> родитель = y ; } заменить ( z , y ); y -> влево = z -> влево ; y -> влево -> родитель = y ; } удалить z ; p_size -- ; } /* //альтернативная реализация void erasure(const T &key) { node *z = find(key); if (!z) return; splay(z); node *s = z->left; node *t = z->right; delete z; node *sMax = NULL; if (s) { s->parent = NULL; sMax = subtree_maximum(s); splay(sMax); root = sMax; } if (t) { if (s) sMax->right = t; else root = t; t->parent = sMax; } p_size--; } */ const T & minimum () { return subtree_minimum ( root ) -> key ; } const T & maximum () { return subtree_maximum ( root ) -> key ; } bool empty () const { return root == nullptr ; } unsigned long size () const { return p_size ; } }; #endif // ДЕРЕВО_ВОСПРОИЗВЕДЕНИЯ
Простой амортизационный анализ статических раскидистых деревьев можно выполнить с использованием метода потенциала . Определим:
Значение Φ будет, как правило, высоким для плохо сбалансированных деревьев и низким для хорошо сбалансированных деревьев.
Чтобы применить метод потенциала , мы сначала вычисляем ΔΦ: изменение потенциала, вызванное операцией расширения. Мы проверяем каждый случай отдельно. Обозначим через rank' функцию ранга после операции. x, p и g — узлы, затронутые операцией вращения (см. рисунки выше).
Амортизированная стоимость любой операции равна ΔΦ плюс фактическая стоимость. Фактическая стоимость любой операции зигзаг или зигзаг равна 2, поскольку необходимо сделать два поворота. Следовательно:
При суммировании по всей операции расширения это сжимается до 1 + 3(rank(root)−rank( x )), что составляет O(log n ), поскольку мы используем операцию Zig не более одного раза, а амортизированная стоимость zig не превышает 1+3(rank'( x )−rank( x )).
Итак, теперь мы знаем, что общее амортизированное время для последовательности из m операций составляет:
Чтобы перейти от амортизированного времени к фактическому времени, мы должны прибавить уменьшение потенциала от начального состояния до выполнения любой операции (Φ i ) до конечного состояния после завершения всех операций (Φ f ).
где обозначение O большое может быть оправдано тем фактом, что для каждого узла x минимальный ранг равен 0, а максимальный ранг равен log( n ).
Теперь мы наконец можем ограничить фактическое время:
Приведенный выше анализ можно обобщить следующим образом.
Применяется тот же анализ, и амортизированная стоимость операции по распрямлению снова составляет:
где W — сумма всех весов.
Уменьшение от начального до конечного потенциала ограничено:
поскольку максимальный размер любого отдельного узла равен W , а минимальный равен w(x) .
Следовательно, фактическое время ограничено:
Существует несколько теорем и гипотез относительно наихудшего времени выполнения последовательности S из m обращений в развернутом дереве, содержащем n элементов.
Теорема баланса — стоимость выполнения последовательности S составляет .
Возьмем постоянный вес, например для каждого узла x . Тогда .
Эта теорема подразумевает, что развернутые деревья работают так же хорошо, как статически сбалансированные двоичные деревья поиска на последовательностях из не менее n обращений. [1]
Теорема статической оптимальности — Пусть будет числом раз, которое элемент x доступен в S. Если каждый элемент доступен по крайней мере один раз, то стоимость выполнения S равна
Пусть . Тогда .
Эта теорема подразумевает, что разветвленные деревья работают так же хорошо, как и оптимальное статическое двоичное дерево поиска на последовательностях из не менее n обращений. [7] Они тратят меньше времени на более частые элементы. [1] Другой способ сформулировать тот же результат заключается в том, что на входных последовательностях, где элементы выбираются независимо случайным образом из неравномерного распределения вероятностей на n элементах, амортизированная ожидаемая ( средняя ) стоимость каждого доступа пропорциональна энтропии распределения. [8]
Теорема статического пальца — Предположим, что элементы пронумерованы от 1 до n в порядке возрастания. Пусть f — любой фиксированный элемент («палец»). Тогда стоимость выполнения S составляет .
Пусть . Тогда . Чистое падение потенциала равно O ( n log n ), поскольку вес любого предмета составляет не менее . [1]
Теорема динамического пальца — Предположим, что «палец» для каждого шага, получающего доступ к элементу y, — это элемент, к которому обращались на предыдущем шаге, x . Стоимость выполнения S составляет . [9] [10]
Теорема рабочего множества — В любой момент последовательности пусть будет числом различных элементов, к которым был получен доступ до предыдущего времени доступа к элементу x. Стоимость выполнения S равна
Пусть . Обратите внимание, что здесь веса изменяются в ходе последовательности. Однако последовательность весов по-прежнему является перестановкой . Так что, как и прежде . Чистое падение потенциала равно O ( n log n ).
Эта теорема эквивалентна разветвленным деревьям, имеющим независимую от ключа оптимальность . [1]
Теорема сканирования — также известная как теорема последовательного доступа или теорема очереди . Доступ к n элементам расширяющегося дерева в симметричном порядке занимает O ( n ) времени, независимо от начальной структуры расширяющегося дерева. [11] Самая точная верхняя граница, доказанная на данный момент, — . [12]
В дополнение к доказанным гарантиям производительности для раскидистых деревьев существует недоказанная гипотеза, представляющая большой интерес, из оригинальной статьи Слейтора и Тарьяна. Эта гипотеза известна как гипотеза динамической оптимальности , и она в основном утверждает, что раскидистые деревья работают так же хорошо, как и любой другой алгоритм бинарного дерева поиска с точностью до постоянного множителя.
Существует несколько следствий гипотезы динамической оптимальности, которые остаются недоказанными:
Чтобы сократить количество операций реструктуризации, можно заменить расширение полурасширением , при котором элемент расширяется только наполовину по направлению к корню. [1] [2]
Другой способ уменьшить реструктуризацию — выполнить полное расширение, но только в некоторых операциях доступа — только когда путь доступа длиннее порогового значения или только в первых m операциях доступа. [1]
CBTree дополняет splay tree счетчиками доступа в каждом узле и использует их для нечастой реструктуризации. Вариант CBTree, называемый LazyCBTree, делает максимум один оборот при каждом поиске. Это используется вместе с оптимистической схемой проверки hand-over-hand для создания параллельного самонастраивающегося дерева. [15]
Используя методы сжатия указателей [16], можно построить краткое расширяющееся дерево.
Результаты показывают, что полураспластывание, которое было представлено в той же статье, что и распластывание, работает лучше, чем распластывание практически при всех возможных условиях. Это делает полураспластывание хорошей альтернативой для всех приложений, где обычно применяется распластывание. Причина, по которой распластывание стало настолько заметным, в то время как полураспластывание относительно неизвестно и изучено гораздо меньше, трудно понять.
Средняя глубина доступа в расширённом дереве пропорциональна энтропии.
Известно, что время, необходимое для доступа к данным в расширяющемся дереве, составляет максимум небольшое постоянное кратное времени доступа статически оптимального дерева, если его амортизировать за любую серию операций.