Splay-дерево: самобалансирующееся BST через продвижение к корню

Splay-дерево - самобалансирующееся двоичное дерево поиска, придуманное Дэниелом Слейтором и Робертом Тарьяном в 1985 году. В отличие от AVL и красно-чёрных деревьев, оно не хранит ни высот, ни цветов и вообще не пытается удерживать строгий баланс. Вся идея сводится к одному правилу: каждый раз, когда мы обращаемся к узлу, мы продвигаем его в корень через цепочку поворотов - эта операция называется splay. Удивительно, но такого «ленивого» правила достаточно, чтобы любая последовательность операций над деревом из узлов укладывалась в времени. То есть в среднем по последовательности - на операцию.
Базовая идея - почему продвигать к корню
Классическая операция в BST - поиск ключа - естественно проходит по цепочке от корня к найденному узлу. Splay-дерево превращает этот же путь в инструмент перестройки: после поиска узел становится новым корнем, а все вершины, через которые мы прошли, перебалансируются так, чтобы их глубина уменьшилась примерно вдвое.
Эффект - «само-настройка» под паттерн доступа. Если приложение часто запрашивает один и тот же ключ, он живёт около корня и достаётся за . Если паттерн - LRU-подобный (горячие узлы быстро мигрируют наверх), splay-дерево автоматически их кэширует. Это похоже на самонастраивающийся хэш с порядком, который ещё и поддерживает range queries.
Три случая поворотов: zig, zig-zig, zig-zag
Splay узла - это последовательность шагов, на каждом из которых поднимается на 1 или 2 уровня. Чтобы выбрать конкретный шаг, смотрим на , его родителя и деда .
Zig - одиночный поворот. Срабатывает, когда - корень (деда нет). Делаем один поворот вокруг ребра , после которого становится корнем. Этот случай - последний в цепочке, к нему приходим, когда у остался только один уровень до корня.
Zig-zig - двойной поворот в одну сторону. и - оба левые дети (или оба правые). Делаем два поворота в правильном порядке: сначала вокруг , потом вокруг . Именно такой порядок принципиально отличает splay от наивного «дважды поверни». Если поворачивать наоборот (сначала , потом ), амортизированная оценка ломается - глубина соседних узлов остаётся неизменной, и в худшем сценарии дерево превращается в список.
Zig-zag - двойной поворот в разные стороны. - левый ребёнок , а - правый ребёнок (или симметрично). Делаем два поворота: сначала вокруг , потом вокруг . Это в точности тот же двойной поворот, что используется при балансировке AVL в случае «LR/RL».
Каждый шаг splay завершён либо в zig (когда дед отсутствует), либо в zig-zig/zig-zag (когда дед есть). После шага оказывается на 2 уровня выше (или на 1, если был zig). Повторяем, пока не дойдёт до корня.
Операции дерева через splay
Все операции сводятся к одной - собственно splay. Это и есть главная архитектурная находка Слейтора и Тарьяна.
- Поиск . Идём по BST от корня вниз, как обычно. В конце вызываем , если ключ нашли, или для последней посещённой вершины при отсутствии ключа.
- Вставка . Стандартная BST-вставка, затем - новый узел сразу оказывается в корне.
- Удаление . Сначала - узел поднялся в корень. Затем удаляем корень: его левое поддерево и правое поддерево независимы. Делаем максимального элемента в - он становится новым корнем с пустым правым ребёнком; подвешиваем туда. Операция называется join.
- Split по ключу . , после чего разрезаем дерево по корню: левое поддерево с ключами и правое с ключами - готовые два дерева.
Заметьте, что join и split - две элементарные операции, на которых внутри устроены все остальные. В AVL/Red-Black эквивалентные операции требуют отдельной нетривиальной логики; в splay они получаются почти бесплатно из основного механизма.
Амортизированный анализ: potential function
Пиковая стоимость одной операции в splay-дереве может быть - например, если дерево вырождено в список и мы обращаемся к самой нижней вершине. Но по последовательности средняя стоимость всё равно . Доказывается через функцию потенциала (метод Тарьяна) - тот же приём, что даёт амортизированную оценку и для кучи Фибоначчи.
Определим - произвольный положительный вес узла, - суммарный вес поддерева с корнем , - ранг узла. Потенциал всего дерева:
Лемма access: амортизированная стоимость операции ограничена
если все веса равны , то .
Доказательство опирается на оценку каждого из трёх случаев поворота. Для шага zig-zig ключевое неравенство:
где - ранги после шага. Реальная стоимость шага равна (два поворота); за счёт уменьшения потенциала телескопическая сумма всех шагов splay сворачивается в , что и даёт оценку. Случай zig-zag даёт ту же оценку с константой 2 вместо 3, zig - однократную «утечку» .
В сумме по операциям амортизированный счёт даёт - второе слагаемое - это начальный потенциал, который мы платим один раз.
Сильные свойства: working-set, static optimality, dynamic finger
Из амортизационной леммы выводятся несколько глубоких теорем, для большинства из которых сами авторы доказали лишь часть, а сильные версии завершили позже.
- Static optimality. Сложность splay-дерева на любой последовательности доступов сравнима со сложностью наилучшего статического BST, оптимизированного под эту же последовательность. То есть splay автоматически делает то же, что делает заранее посчитанное оптимальное BST.
- Working-set theorem. Стоимость доступа к узлу - это , где - размер «рабочего множества» (число различных ключей, запрошенных с прошлого обращения к ). Это формализация интуиции про LRU-кэш: чаще трогаешь - быстрее достаёшь.
- Dynamic finger. Если последовательные запросы близки по ключу - стоимость очередного запроса логарифмически зависит от расстояния, а не от . Гипотеза доказана Коулом, Минцем и Сандэрсом в 2000 году.
- Гипотеза динамической оптимальности. Слейтор и Тарьян предположили, что splay-дерево с точностью до константы оптимально среди всех динамических BST - гипотеза до сих пор открыта.
Сравнение с AVL и красно-чёрным деревом
| AVL | Red-Black | Splay | |
|---|---|---|---|
| Гарантия на одну операцию | в худшем | ||
| Амортизированно | |||
| Дополнительная память на узел | высота (4-8 байт) | цвет (1 бит) | ничего |
| Адаптируется к паттерну доступа | нет | нет | да |
| Сложность реализации join/split | средняя | средняя | низкая |
AVL и красно-чёрные деревья дают жёсткую гарантию на каждую операцию - это важно для систем реального времени, где недопустимо разовое торможение. На дисковом уровне ту же задачу решают B-деревья - у них тоже жёсткий инвариант высоты, но за счёт ширины узла. Splay-дерево, наоборот, обещает только амортизацию: одна конкретная операция вполне может задеть узлов, если перед ней дерево было сильно перекошено.
Зато splay выигрывает на типичных load patterns. Если в последовательности обращений есть «горячая» подгруппа ключей (а почти любая реальная нагрузка такова), AVL/Red-Black хранят их где попало в дереве, а splay автоматически переносит наверх. Плюс отсутствие служебных полей у узла: для систем, где миллионы маленьких узлов и важна память, это ощутимая экономия.
Если нагрузка близка к равномерной, а интерактивность критична - берите AVL или красно-чёрное дерево. Если есть «горячее ядро» обращений и допустимы редкие медленные операции - splay часто оказывается быстрее на практике.
Применения
- Memory allocators. Аллокатор
dlmallocиспользовал splay-дерево для управления свободными блоками - горячие блоки автоматически оказывались наверху. - Сжатие данных. Алгоритм splay-tree compression Гранхэма и Витти - арифметическое кодирование с динамическим контекстом, в котором частые символы оказываются у корня.
- Link/cut trees. Структура Слейтора-Тарьяна для динамической связности на лесах деревьев строится поверх splay-деревьев - каждый «предпочтительный путь» хранится как splay-дерево.
- Виртуальная память. Некоторые ОС используют splay-деревья для управления VMA (virtual memory areas) - там типичны повторные обращения к одной и той же области.
Частые ошибки
- Перепутать порядок поворотов в zig-zig. Сначала поворот вокруг , потом - не наоборот. С обратным порядком асимптотика разваливается, и дерево деградирует на патологических последовательностях.
- Забыть про splay в неудачном поиске. Если ключа нет, в корень всё равно нужно поднять последнюю посещённую вершину - иначе следующее обращение к близкому ключу не получит amortized-выгоды.
- Применять splay в multi-threaded коде без локов. Любая операция, включая read-only поиск, мутирует структуру - без блокировок splay-дерево нельзя разделять между потоками. AVL и Red-Black с этой точки зрения дружелюбнее.
- Ожидать на каждую операцию. Splay даёт амортизированную оценку; конкретная операция может занять . В hard real-time системах это критично.
- Использовать splay для статических данных. Если данные не меняются и запросы равномерные, простое сбалансированное BST без splay будет быстрее: вы не платите за повороты.
FAQ
Чем splay-дерево принципиально отличается от AVL? AVL поддерживает строгое условие баланса (разность высот поддеревьев ) и хранит высоту в каждом узле. Splay не поддерживает баланс вообще - он перестраивается реактивно после каждого обращения. AVL гарантирует на любую конкретную операцию, splay только в среднем по последовательности.
Что доказывает «гипотеза динамической оптимальности»? Гипотеза утверждает, что splay-дерево с точностью до константы оптимально среди всех динамических BST на любой последовательности обращений. Static optimality (теорема, а не гипотеза) - слабее: оптимальность только относительно лучшего фиксированного дерева. Полная динамическая оптимальность до сих пор не доказана и не опровергнута - это одна из самых известных открытых задач в анализе структур данных.
Можно ли использовать splay-дерево для range-запросов? Да, через split/join. Чтобы посчитать сумму по диапазону , делаем и , получаем поддерево с искомыми ключами, в корне которого можно держать агрегированную сумму (как в seg-tree). Эту идею развивает структура Эйлерова обхода поверх splay-деревьев - фундамент link/cut trees.
Коротко
Splay-дерево - самобалансирующееся BST, в котором единственная балансирующая операция продвигает обращённый узел в корень через комбинации шагов zig, zig-zig и zig-zag. Амортизированная сложность на операцию доказывается через potential function . Дерево автоматически адаптируется к паттерну доступа (working-set, static optimality, dynamic finger) и обходится без служебных полей у узлов. Платой за гибкость становится отсутствие гарантии на конкретную операцию - поэтому в hard real-time системах предпочитают AVL или красно-чёрные деревья, а в адаптивных приложениях splay часто выигрывает на практике.
Читайте также

AVL-дерево: как работает балансировка и ротации
Разбираем, как AVL-дерево восстанавливает баланс после вставки и удаления: инвариант высоты, balance factor и четыре ротации LL, RR, LR, RL за O(log n).

Куча Фибоначчи: ленивая структура и амортизация
Куча Фибоначчи: амортизированный на insert и decrease-key, ленивая консолидация при extract-min, потенциал, каскадный cut и применение в алгоритме Дейкстры.

B-дерево: вставка ключа и разделение узла
Разбираем вставку в B-дерево по шагам: минимальная степень t, инвариант t-1..2t-1 ключей и разделение переполненного узла с подъёмом среднего ключа к родителю.