Splay-дерево: различия между версиями
СВ (обсуждение | вклад) (Новая страница: «== TL;DR == Splay-дерево (splay tree; «расширяющееся дерево») — саморегулирующееся (self-adjusting) двоично...») |
СВ (обсуждение | вклад) |
||
| Строка 698: | Строка 698: | ||
# Wikipedia: Splay tree. URL: https://en.wikipedia.org/wiki/Splay_tree ''(проверено)''. | # Wikipedia: Splay tree. URL: https://en.wikipedia.org/wiki/Splay_tree ''(проверено)''. | ||
# Cole R., Mishra B., Schmidt J., Siegel A. On the Dynamic Finger Conjecture for Splay Trees // SIAM Journal on Computing. — 2000. — Vol. 30, No. 1 ''(проверено)''. | # Cole R., Mishra B., Schmidt J., Siegel A. On the Dynamic Finger Conjecture for Splay Trees // SIAM Journal on Computing. — 2000. — Vol. 30, No. 1 ''(проверено)''. | ||
[[Category:Алгоритмы и структуры данных]] | |||
[[Category:Деревья поиска]] | |||
Текущая версия на 10:51, 29 октября 2026
TL;DR
Splay-дерево (splay tree; «расширяющееся дерево») — саморегулирующееся (self-adjusting) двоичное дерево поиска, которое не хранит никакой информации о балансе. После каждого обращения к узлу он переносится в корень операцией splay (последовательностью поворотов zig, zig-zig, zig-zag). Дерево самоподстраивается: часто запрашиваемые узлы оказываются вверху. Все операции — поиск, вставка, удаление, а также Split и Join — амортизированно выполняются за [math]\displaystyle{ O(\log n) }[/math], при этом гарантируется только амортизированная оценка: отдельная операция может стоить [math]\displaystyle{ O(n) }[/math]. Splay-дерево обладает свойствами адаптивности (лемма о доступе, рабочее множество, статический палец; динамический палец доказан отдельно, в 2000 году) и лежит в основе link-cut trees. Изобретено Слейтором и Тарджаном в 1985 году.
Инфобокс
| Свойство | Значение |
|---|---|
| Класс | Саморегулирующееся двоичное дерево поиска (BST) |
| Изобретение | Слейтор и Тарджан, 1985 [2] |
| Отличительная черта | Нет полей баланса; узел переносится в корень при каждом доступе |
| Поиск (амортизированный) | [math]\displaystyle{ O(\log n) }[/math]; отдельная операция — до [math]\displaystyle{ O(n) }[/math] |
| Вставка / удаление | [math]\displaystyle{ O(\log n) }[/math] амортизированно |
| Split / Join | [math]\displaystyle{ O(\log n) }[/math] амортизированно |
| Min / Max, соседние | [math]\displaystyle{ O(\log n) }[/math] амортизированно (если найденный узел сплайнится) |
| Память | [math]\displaystyle{ O(n) }[/math]; на узел — key, left, right, parent |
| Адаптивность | Да: лемма о доступе, рабочее множество, статический палец; динамический палец — отдельная теорема [7] |
| Наихудшая гарантия | Амортизированная, а не на отдельную операцию (не для реального времени) |
| Альтернативы | АВЛ-дерево (план), красно-чёрное дерево (план), treap, link-cut trees |
| Где встречается | link-cut trees, модели кэша с локальностью, динамическая связность, структуры последовательностей |
Определения
Splay-дерево — двоичное дерево поиска (BST), в котором после каждого доступа к узлу [math]\displaystyle{ x }[/math] выполняется операция splay([math]\displaystyle{ x }[/math]), переносящая [math]\displaystyle{ x }[/math] в корень последовательностью поворотов вдоль пути доступа [2]. Никаких полей высоты/баланса/цвета дерево не хранит.
Операция splay([math]\displaystyle{ x }[/math]). Пока [math]\displaystyle{ x }[/math] не корень, применяется один из трёх случаев (через [math]\displaystyle{ p }[/math] — родителя, [math]\displaystyle{ g }[/math] — деда [math]\displaystyle{ x }[/math]):
- zig — [math]\displaystyle{ p }[/math] является корнем: одиночный поворот, поднимающий [math]\displaystyle{ x }[/math].
- zig-zig — [math]\displaystyle{ x }[/math] и [math]\displaystyle{ p }[/math] оба левые (или оба правые) дети: сначала поворот вокруг [math]\displaystyle{ p }[/math], затем вокруг [math]\displaystyle{ x }[/math].
- zig-zag — [math]\displaystyle{ x }[/math] — правый ребёнок левого [math]\displaystyle{ p }[/math] (или наоборот): два поворота вокруг [math]\displaystyle{ x }[/math] подряд (это двойной поворот, как в АВЛ-дереве).
Поворот (rotation) — локальная перестройка, поднимающая узел над его родителем за [math]\displaystyle{ O(1) }[/math] с сохранением порядка ключей (см. авл-дерево (план)).
Саморегулирующееся дерево (self-adjusting tree) — двоичное дерево поиска, форма которого определяется историей обращений, а не поддерживаемым инвариантом баланса. Формально: структура саморегулирующаяся, если последовательность операций над ней имеет оценку, зависящую от потока запросов (например, от рабочего множества), при том что высота в отдельный момент не ограничена.
Ключевое свойство. Splay-дерево — пример саморегулирующейся структуры: его форма зависит не только от множества ключей, но и от порядка обращений к ним. Частые обращения «всплывают» узлы наверх, ускоряя последующие к ним доступы.
Обозначения. [math]\displaystyle{ n }[/math] — число узлов, [math]\displaystyle{ m }[/math] — число операций; [math]\displaystyle{ h }[/math] — текущая высота дерева; [math]\displaystyle{ \log }[/math] — двоичный; size(v) — число узлов в поддереве с корнем [math]\displaystyle{ v }[/math]; веса [math]\displaystyle{ w(v) \gt 0 }[/math] и [math]\displaystyle{ W = \sum_v w(v) }[/math] — для анализа адаптивности.
Мотивация и интуиция
Сбалансированные деревья (АВЛ (план), КЧ (план)) держат высоту [math]\displaystyle{ O(\log n) }[/math] и дают гарантию на каждую операцию, но платят за это хранением баланса и постоянной перебалансировкой. Однако во многих реальных нагрузках обращения неравномерны: одни ключи запрашиваются часто, другие почти никогда («горячие» и «холодные»). Для таких нагрузок полезна структура, которая меняет форму под поток запросов.
Splay-дерево делает это радикально просто: любой доступ «подтягивает» нужный узел к корню. Часто используемые элементы постепенно оказываются вверху и ищутся быстро. При этом не хранится ни баланс, ни приоритеты — вся адаптация встроена в операцию splay.
Интуиция «почему это не разваливается»: повороты сжимают длинные пути. Хотя один доступ к глубокому узлу дорог, серия обращений в сумме не может часто обращаться к глубоким узлам — каждый дорогой splay выпрямляет путь и облегчает будущие обращения. Формализует это метод потенциала: потенциал [math]\displaystyle{ \Phi = \sum_v \log_2 \text{size}(v) }[/math], где [math]\displaystyle{ \text{size}(v) }[/math] — число узлов в поддереве с корнем [math]\displaystyle{ v }[/math]. Дорогая операция оплачивается потенциалом, накопленным ранее; для каждого поворота амортизированная стоимость ограничена изменением потенциала поддерева с корнем [math]\displaystyle{ x }[/math]. Отсюда амортизированное [math]\displaystyle{ O(\log n) }[/math] и более сильные адаптивные оценки.
Устройство
Splay-дерево — это обычное BST с тремя ссылками на узел (left, right, parent), но без полей баланса. Вся механика — в операции splay, которая вызывается после каждого доступа (см. рис. 1). Ссылка на родителя нужна, чтобы подниматься по пути доступа.
Три случая splay реализуют разную геометрию:
- zig (родитель у корня) — один поворот;
- zig-zig (оба ребёнка в одну сторону) — два поворота, сначала вокруг родителя;
- zig-zag (зигзаг) — двойной поворот вокруг [math]\displaystyle{ x }[/math].
Порядок поворотов в zig-zig важен: наивное «покорное» вращение [math]\displaystyle{ x }[/math] к корню даёт худшую амортизированную оценку; именно комбинация zig-zig/zig-zag обеспечивает сжатие путей и все адаптивные свойства.
Операции и алгоритмы
Операция: splay
Идея. Поднимать [math]\displaystyle{ x }[/math] к корню, разбирая случаи zig, zig-zig, zig-zag.
Пошаговое описание.
- Пока у [math]\displaystyle{ x }[/math] есть родитель [math]\displaystyle{ p }[/math]:
- если [math]\displaystyle{ p }[/math] — корень: один поворот (
zig). - иначе если [math]\displaystyle{ x }[/math] и [math]\displaystyle{ p }[/math] — дети в одну сторону: повернуть [math]\displaystyle{ p }[/math], затем [math]\displaystyle{ x }[/math] (
zig-zig). - иначе: два поворота вокруг [math]\displaystyle{ x }[/math] (
zig-zag).
Инвариант. Перед каждой итерацией путь от [math]\displaystyle{ x }[/math] до корня — корректный список предков, а BST-свойство выполнено. После каждого поворота BST-свойство сохраняется, ссылки parent согласованы с left/right, и глубина [math]\displaystyle{ x }[/math] уменьшается минимум на 1. По завершении [math]\displaystyle{ x }[/math] — корень.
Псевдокод.
Алгоритм Splay(T, x)
Вход: BST T; x — существующий узел дерева T (x ≠ NIL)
Выход: x — корень T; порядок ключей и ссылки parent сохранены
Инвариант: путь x → parent[x] → … → корень корректен; после каждой итерации
глубина x уменьшается минимум на 1
1 while parent[x] ≠ NIL
2 p ← parent[x]
3 g ← parent[p]
4 if g = NIL
5 Rotate(x) # zig
6 else if (left[g] = p) = (left[p] = x)
7 Rotate(p) # zig-zig: сначала родитель
8 Rotate(x)
9 else
10 Rotate(x) # zig-zag: дважды x
11 Rotate(x)
Сложность: O(h) в худшем случае, O(log n) амортизированно; память O(1).
Корректность: каждый поворот сохраняет порядок ключей; цикл поднимает x на
каждой итерации минимум на один уровень, поэтому завершается у корня.
Разбор. Пусть [math]\displaystyle{ x }[/math] — левый ребёнок [math]\displaystyle{ p }[/math], а [math]\displaystyle{ p }[/math] — левый ребёнок [math]\displaystyle{ g }[/math] (zig-zig). Первый поворот (Rotate(p)) поднимает [math]\displaystyle{ p }[/math] над [math]\displaystyle{ g }[/math]: [math]\displaystyle{ x }[/math] остаётся левым ребёнком [math]\displaystyle{ p }[/math]. Второй поворот (Rotate(x)) поднимает [math]\displaystyle{ x }[/math] над [math]\displaystyle{ p }[/math]. В итоге [math]\displaystyle{ x }[/math] — корень поддерева, содержащего [math]\displaystyle{ x }[/math], [math]\displaystyle{ p }[/math] и [math]\displaystyle{ g }[/math] (вместе с их поддеревьями), а [math]\displaystyle{ g }[/math] и [math]\displaystyle{ p }[/math] образуют правую ветвь.
Сложность. Время: [math]\displaystyle{ O(h) }[/math] в худшем случае, [math]\displaystyle{ O(\log n) }[/math] амортизированно. Память: [math]\displaystyle{ O(1) }[/math]. Обоснование: каждый поворот стоит [math]\displaystyle{ O(1) }[/math], поворотов не больше [math]\displaystyle{ h }[/math]; амортизированная оценка следует из теоремы 2 (см. «Корректность и оценка сложности»).
Крайние случаи. [math]\displaystyle{ x }[/math] уже корень — цикл не выполняется. У [math]\displaystyle{ x }[/math] нет деда — один поворот zig. [math]\displaystyle{ x }[/math] = NIL — операция не определена: вызывающий обязан передать существующий узел.
Рисунок. См. рис. 2 (zig-zig) и рис. 3 (zig-zag).
Операция: поиск (Search)
Идея. Спуск как в BST; нашедший узел сплайнится к корню. Если ключа нет, к корню поднимается последний посещённый узел (это важно для адаптивности).
Пошаговое описание.
- Начать с корня; запоминать каждый посещённый узел в
last. - Спускаться влево или вправо по сравнению [math]\displaystyle{ k }[/math] с
key[cur]. - При совпадении —
Splay(cur)и вернуть узел. - При достижении NIL —
Splay(last), если дерево непусто, и вернуть NIL.
Инвариант. На каждом шаге в поддереве с корнем cur ключ [math]\displaystyle{ k }[/math] ищется по BST-свойству: все ключи слева меньше key[cur], все ключи справа — не меньше. last — последний посещённый узел (NIL только для пустого дерева).
Псевдокод.
Алгоритм Search(T, k)
Вход: BST T (возможно пустое); ключ k
Выход: узел с ключом k (он же — корень T) либо NIL
Инвариант: в поддереве с корнем cur ключ k ищется по BST-свойству;
last — последний посещённый узел (NIL только если T пусто)
1 cur ← root[T]; last ← NIL
2 while cur ≠ NIL
3 last ← cur
4 if k = key[cur]
5 Splay(T, cur)
6 return cur
7 else if k < key[cur]
8 cur ← left[cur]
9 else
10 cur ← right[cur]
11 if last ≠ NIL
12 Splay(T, last) # ключа нет: в корень — последний посещённый
13 return NIL
Сложность: O(h) в худшем случае, O(log n) амортизированно; память O(1).
Корректность: спуск находит узел по BST-свойству; splay сохраняет множество и
порядок ключей, поэтому после вызова найденный (или последний посещённый) узел
стоит в корне.
Разбор. Поиск 15 в дереве 20(10(5, ·), 30(25, 35)): [math]\displaystyle{ 15 }[/math] меньше [math]\displaystyle{ 20 }[/math] — влево к 10; [math]\displaystyle{ 15 }[/math] больше [math]\displaystyle{ 10 }[/math] — вправо, right[10] = NIL. Ключа нет, Splay(10) делает 10 корнем: 10(5, 20(·, 30(25, 35))). Повторный поиск 15 снова дойдёт до 10 за два сравнения — спуск не удлинился.
Сложность. Время: [math]\displaystyle{ O(h) }[/math] в худшем случае, [math]\displaystyle{ O(\log n) }[/math] амортизированно. Память: [math]\displaystyle{ O(1) }[/math]. Обоснование: спуск стоит [math]\displaystyle{ O(h) }[/math], splay — амортизированно [math]\displaystyle{ O(\log n) }[/math] (теорема 2).
Крайние случаи. Пустое дерево — цикл не выполняется, last = NIL, splay не вызывается. Ключ отсутствует — splay последнего посещённого узла, возврат NIL. Ключ в корне — один вызов splay без поворотов.
Операция: вставка (Insert)
Идея. Спуск до места вставки, подключение листа и splay нового узла к корню.
Пошаговое описание.
- Спускаться от корня, запоминая родителя
par. - При совпадении ключа —
Splay(cur)и выход: дубликат не добавляется. - Создать узел [math]\displaystyle{ x }[/math], подключить его к
par(или сделать корнем, если дерево пусто). Splay(x)— новый узел становится корнем.
Инвариант. До подключения par — родитель будущего узла, а спуск сохраняет BST-свойство. После подключения ссылки left/right/parent согласованы; после splay [math]\displaystyle{ x }[/math] — корень.
Псевдокод.
Алгоритм Insert(T, k)
Вход: BST T (возможно пустое); ключ k
Выход: T с ключом k; узел с ключом k — корень T
Инвариант: par — родитель будущего узла; спуск сохраняет BST-свойство;
после подключения x ссылки left/right/parent согласованы
1 cur ← root[T]; par ← NIL
2 while cur ≠ NIL
3 par ← cur
4 if k = key[cur]
5 Splay(T, cur); return # дубликат: ключ уже есть
6 else if k < key[cur] cur ← left[cur]
7 else cur ← right[cur]
8 x ← новый узел с ключом k
9 parent[x] ← par
10 if par = NIL root[T] ← x # T было пустым
11 else if k < key[par] left[par] ← x
12 else right[par] ← x
13 Splay(T, x)
Сложность: O(h) в худшем случае, O(log n) амортизированно; память O(1)
плюс O(1) на новый узел.
Корректность: x подключён на место пустого указателя, поэтому BST-свойство
сохранено; splay переносит x в корень и не меняет порядок ключей.
Разбор. Вставка 15 в 10(·, 20(·, 30)). Спуск: [math]\displaystyle{ 15 }[/math] больше [math]\displaystyle{ 10 }[/math] — вправо к 20; [math]\displaystyle{ 15 }[/math] меньше [math]\displaystyle{ 20 }[/math] — влево, left[20] = NIL. Узел 15 подключён слева от 20; 15 — левый ребёнок 20, а 20 — правый ребёнок 10, то есть направления разные, поэтому выполняется zig-zag: два поворота вокруг 15. Итог — 15(10, 20(·, 30)); порядок ключей сохранён.
Сложность. Время: [math]\displaystyle{ O(h) }[/math] в худшем случае, [math]\displaystyle{ O(\log n) }[/math] амортизированно; память [math]\displaystyle{ O(1) }[/math] плюс узел. Обоснование: спуск [math]\displaystyle{ O(h) }[/math], один splay, амортизированно [math]\displaystyle{ O(\log n) }[/math].
Крайние случаи. Пустое дерево — новый узел сразу корень, splay не нужен. Дубликат — ключ не добавляется, но существующий узел сплайнится. Вставка в лист вырожденного дерева — [math]\displaystyle{ O(n) }[/math] на отдельной операции, амортизированно [math]\displaystyle{ O(\log n) }[/math].
Операция: удаление (Delete)
Идея. Сплайнить удаляемый ключ к корню, отсоединить левое и правое поддеревья, сшить их, подняв максимум левого поддерева.
Пошаговое описание.
- Если дерево пусто или ключа нет — выйти.
Search(T, k)сплайнит [math]\displaystyle{ k }[/math] в корень.- Запомнить левое поддерево [math]\displaystyle{ L }[/math] и правое [math]\displaystyle{ R }[/math], обнулив у них ссылки
parent. - Удалить корень; если [math]\displaystyle{ L }[/math] пусто — корнем становится [math]\displaystyle{ R }[/math].
- Иначе корень — [math]\displaystyle{ L }[/math], найти в нём максимум [math]\displaystyle{ m }[/math],
Splay(m)и прицепить [math]\displaystyle{ R }[/math] справа.
Инвариант. После шага 2 ключ [math]\displaystyle{ k }[/math] — в корне, поэтому все ключи [math]\displaystyle{ L }[/math] меньше [math]\displaystyle{ k }[/math], а все ключи [math]\displaystyle{ R }[/math] больше. В [math]\displaystyle{ L }[/math] максимум [math]\displaystyle{ m }[/math] — наибольший ключ, поэтому после splay у него нет правого ребёнка и место справа свободно.
Псевдокод.
Алгоритм Delete(T, k)
Вход: BST T (возможно пустое); ключ k
Выход: T без узла с ключом k (если он был); корень — NIL или узел T
Инвариант: после Search ключ k — в корне; все ключи L < k < всех ключей R;
m — максимум L, поэтому right[m] = NIL
1 if root[T] = NIL
2 return # пустое дерево
3 if Search(T, k) = NIL
4 return # ключа нет
5 L ← left[root[T]]; R ← right[root[T]] # k теперь в корне
6 if L ≠ NIL parent[L] ← NIL
7 if R ≠ NIL parent[R] ← NIL
8 удалить корень
9 if L = NIL root[T] ← R; return
10 root[T] ← L
11 m ← максимум поддерева L # правый спуск
12 Splay(T, m) # m — корень левого поддерева
13 right[m] ← R
14 if R ≠ NIL parent[R] ← m
Сложность: O(h) в худшем случае, O(log n) амортизированно; память O(1).
Корректность: в L все ключи < k < ключей R, а m — максимум L, поэтому
right[m] = NIL и прицепление R справа сохраняет BST-свойство.
Разбор. Удаление 20 из 20(10(5, 15), 30). Search(20) не делает поворотов: 20 уже корень. [math]\displaystyle{ L = 10(5, 15) }[/math], [math]\displaystyle{ R = 30 }[/math]. Максимум [math]\displaystyle{ L }[/math] — 15; Splay(15) в [math]\displaystyle{ L }[/math] даёт 15(10(5), ·). Прицепляем [math]\displaystyle{ R }[/math] справа: 15(10(5, ·), 30). Порядок ключей сохранён.
Сложность. Время: [math]\displaystyle{ O(h) }[/math] в худшем случае, [math]\displaystyle{ O(\log n) }[/math] амортизированно. Память: [math]\displaystyle{ O(1) }[/math]. Обоснование: один поиск и один splay, каждый амортизированно [math]\displaystyle{ O(\log n) }[/math]; спуск к максимуму оплачивается тем же splay.
Крайние случаи. Пустое дерево — выход по шагу 1. Ключа нет — выход по шагу 3 (но splay последнего посещённого уже выполнен). У корня нет левого поддерева — корнем становится [math]\displaystyle{ R }[/math]. У корня нет правого поддерева — корнем становится максимум [math]\displaystyle{ L }[/math]: Splay поднимает его, и он остаётся корнем. Единственный узел — дерево становится пустым.
Операции: разделение и слияние (Split и Join)
Идея. Splay делает эти операции естественными, потому что корень делит ключи на «меньше» и «не меньше».
- Split([math]\displaystyle{ T, k }[/math]):
Search(T, k)сплайнит к корню ключ [math]\displaystyle{ k }[/math], а если его нет — последний посещённый узел; затем дерево разрезается по сравнениюkey[root]с [math]\displaystyle{ k }[/math]: левое поддерево — ключи меньше [math]\displaystyle{ k }[/math], правое — ключи не меньше [math]\displaystyle{ k }[/math]. - Join([math]\displaystyle{ T_1, T_2 }[/math]) (все ключи [math]\displaystyle{ T_1 }[/math] меньше ключей [math]\displaystyle{ T_2 }[/math]):
Splayмаксимум [math]\displaystyle{ T_1 }[/math] к корню, прицепить [math]\displaystyle{ T_2 }[/math] правым ребёнком.
Разрез нельзя делать безусловно по левому поддереву корня: при отсутствующем [math]\displaystyle{ k }[/math] Search сплайнит последний посещённый узел [math]\displaystyle{ r }[/math], и он может быть как меньше, так и больше [math]\displaystyle{ k }[/math]. Если key[r] меньше [math]\displaystyle{ k }[/math], то [math]\displaystyle{ r }[/math] и его левое поддерево целиком уходят в [math]\displaystyle{ L }[/math], а в [math]\displaystyle{ R }[/math] — только right[r]; если key[r] не меньше [math]\displaystyle{ k }[/math] — наоборот. Пример: ключи 10, 20, 30, [math]\displaystyle{ k = 15 }[/math]. Search(15) не находит ключ и сплайнит 10, после чего дерево имеет вид 10(·, 20(·, 30)). Так как key[r] = 10 меньше 15, получаем [math]\displaystyle{ L = \{10\} }[/math] и [math]\displaystyle{ R = \{20, 30\} }[/math].
Алгоритм Split(T, k) # возвращает (L, R): ключи L < k ≤ ключи R
Вход: BST T (возможно пустое); ключ k (может отсутствовать в T)
Выход: (L, R) — два BST; все ключи L < k, все ключи R ≥ k
Инвариант: после Search в корне r стоит либо k, либо последний посещённый узел;
left[r] содержит только ключи < key[r], right[r] — только ≥ key[r]
1 if root[T] = NIL
2 return (NIL, NIL)
3 Search(T, k) # в корне — k или последний посещённый
4 r ← root[T]
5 if key[r] < k
6 L ← r; R ← right[r]
7 right[r] ← NIL
8 else
9 R ← r; L ← left[r]
10 left[r] ← NIL
11 if L ≠ NIL parent[L] ← NIL
12 if R ≠ NIL parent[R] ← NIL
13 return (L, R)
Алгоритм Join(T1, T2)
Вход: два BST T1, T2; все ключи T1 < всех ключей T2 (любое из деревьев может быть пустым)
Выход: BST из всех ключей T1 и T2
Инвариант: m — максимум T1, поэтому right[m] = NIL и место справа свободно
1 if T1 = NIL return T2
2 m ← максимум T1 # правый спуск
3 Splay(T1, m) # m — корень T1
4 right[m] ← T2
5 if T2 ≠ NIL parent[T2] ← m
6 return T1
Сложность: O(log n) амортизированно для Split и для Join; память O(1).
Корректность: после Search в корне стоит либо k, либо последний посещённый узел;
в первом случае разрез по корню точен, во втором все ключи по другую сторону от k
уже отделены splay-поворотами, поэтому L и R не пересекаются и покрывают T.
Разбор. Join для [math]\displaystyle{ T_1 = \{5, 10, 15\} }[/math] и [math]\displaystyle{ T_2 = \{20, 30\} }[/math]: максимум [math]\displaystyle{ T_1 }[/math] — 15, после splay(15) он корень, правого ребёнка у него нет, а слева — поддерево из [math]\displaystyle{ \{5, 10\} }[/math]; прицепляем [math]\displaystyle{ T_2 }[/math] справа. Split результата по [math]\displaystyle{ k = 10 }[/math]: Search(10) поднимает 10 в корень, key[r] = 10 не меньше 10, поэтому [math]\displaystyle{ R = r }[/math] (с правым поддеревом [math]\displaystyle{ \{15, 20, 30\} }[/math]), а [math]\displaystyle{ L = \{5\} }[/math].
Сложность. Время: [math]\displaystyle{ O(\log n) }[/math] амортизированно для обеих операций; память [math]\displaystyle{ O(1) }[/math]. Обоснование: одна операция Search/Splay плюс [math]\displaystyle{ O(1) }[/math] перестановок указателей.
Крайние случаи. Пустое дерево — Split возвращает (NIL, NIL). Если [math]\displaystyle{ k }[/math] меньше всех ключей, то [math]\displaystyle{ L }[/math] пусто, а [math]\displaystyle{ R = T }[/math]. Если [math]\displaystyle{ k }[/math] больше всех ключей, то [math]\displaystyle{ L = T }[/math], а [math]\displaystyle{ R }[/math] пусто. Join с пустым [math]\displaystyle{ T_1 }[/math] возвращает [math]\displaystyle{ T_2 }[/math], а при пустом [math]\displaystyle{ T_2 }[/math] результат — [math]\displaystyle{ T_1 }[/math]; предусловие «все ключи [math]\displaystyle{ T_1 }[/math] меньше ключей [math]\displaystyle{ T_2 }[/math]» обязательно проверить у вызывающего. См. рис. 4.
Min/Max и соседние элементы находятся спуском влево/вправо; чтобы оценка была амортизированной, найденный узел нужно сплайнить (в C++-реализации ниже так и сделано). Тогда операция стоит [math]\displaystyle{ O(\log n) }[/math] амортизированно; без splay это лишь [math]\displaystyle{ O(h) }[/math], то есть [math]\displaystyle{ O(n) }[/math] в худшем случае.
Реализация на C++ (приложение)
Splay-дерево с операциями find, insert, erase и проверкой инвариантов validate. Операции Split и Join в коде не реализованы — они вынесены в задачи для практики. Раздел носит характер приложения: для lecture достаточно псевдокода, код приведён для проверки утверждений.
#include <iostream>
#include <vector>
#include <algorithm>
#include <cassert>
template <typename T>
class SplayTree {
struct Node {
T key;
Node *left = nullptr, *right = nullptr, *parent = nullptr;
explicit Node(const T& k) : key(k) {}
};
Node* root_ = nullptr;
void rotate(Node* x) { // поднять x над его родителем
Node* p = x->parent;
if (!p) return;
Node* g = p->parent;
if (p->left == x) {
p->left = x->right;
if (x->right) x->right->parent = p;
x->right = p;
} else {
p->right = x->left;
if (x->left) x->left->parent = p;
x->left = p;
}
p->parent = x;
x->parent = g;
if (g) { if (g->left == p) g->left = x; else g->right = x; }
else { root_ = x; }
}
void splay(Node* x) {
while (x->parent) {
Node* p = x->parent;
Node* g = p->parent;
if (!g) { // zig
rotate(x);
} else if ((g->left == p) == (p->left == x)) { // zig-zig
rotate(p);
rotate(x);
} else { // zig-zag
rotate(x);
rotate(x);
}
}
}
static void destroy(Node* n) {
if (!n) return;
destroy(n->left);
destroy(n->right);
delete n;
}
static bool check(Node* n, const T* lo, const T* hi) {
if (!n) return true;
if (lo && n->key < *lo) return false;
if (hi && *hi < n->key) return false;
if (n->left && n->left->parent != n) return false;
if (n->right && n->right->parent != n) return false;
return check(n->left, lo, &n->key) && check(n->right, &n->key, hi);
}
void inorder(Node* n, std::vector<T>& out) const {
if (!n) return;
inorder(n->left, out);
out.push_back(n->key);
inorder(n->right, out);
}
public:
SplayTree() = default;
~SplayTree() { destroy(root_); }
SplayTree(const SplayTree&) = delete;
SplayTree& operator=(const SplayTree&) = delete;
bool empty() const { return root_ == nullptr; }
bool find(const T& k) {
Node* cur = root_;
Node* last = nullptr;
while (cur) {
last = cur;
if (k < cur->key) cur = cur->left;
else if (cur->key < k) cur = cur->right;
else { splay(cur); return true; }
}
if (last) splay(last);
return false;
}
void insert(const T& k) {
if (!root_) { root_ = new Node(k); return; }
Node* cur = root_;
Node* par = nullptr;
while (cur) {
par = cur;
if (k < cur->key) cur = cur->left;
else if (cur->key < k) cur = cur->right;
else { splay(cur); return; } // дубликат
}
Node* x = new Node(k);
x->parent = par;
if (k < par->key) par->left = x; else par->right = x;
splay(x);
}
bool erase(const T& k) {
if (!find(k)) return false; // k — в корне
Node* L = root_->left;
Node* R = root_->right;
if (L) L->parent = nullptr;
if (R) R->parent = nullptr;
delete root_;
if (!L) { root_ = R; return true; }
root_ = L;
Node* m = L; // максимум левого поддерева
while (m->right) m = m->right;
splay(m);
m->right = R;
if (R) R->parent = m;
return true;
}
// предусловие: дерево непусто; найденный узел сплайнится в корень,
// поэтому повторные вызовы стоят O(log n) амортизированно
T minimum() {
assert(root_);
Node* m = root_;
while (m->left) m = m->left;
splay(m);
return m->key;
}
T maximum() {
assert(root_);
Node* m = root_;
while (m->right) m = m->right;
splay(m);
return m->key;
}
std::vector<T> sorted() const {
std::vector<T> v;
inorder(root_, v);
return v;
}
bool validate() const { return check(root_, nullptr, nullptr); }
};
int main() {
SplayTree<int> t;
for (int v : {20, 10, 30, 5, 15, 25, 35}) t.insert(v);
assert(t.validate());
assert(t.find(15));
assert(!t.find(99));
assert(t.validate());
std::vector<int> s = t.sorted(); // временный вектор сохраняем в переменную
assert(std::is_sorted(s.begin(), s.end()));
assert(t.minimum() == 5);
assert(t.maximum() == 35);
assert(t.validate());
assert(t.erase(20));
assert(!t.find(20));
assert(t.validate());
s = t.sorted();
assert(std::is_sorted(s.begin(), s.end()));
std::cout << "ok\n";
return 0;
}
Замечания по реализации.
- Ссылка
parentобязательна — по ней splay поднимается к корню. findпри неудаче сплайнит последний посещённый узел: это поддерживает адаптивность.minimum/maximumсплайнят найденный узел, поэтому их амортизированная оценка [math]\displaystyle{ O(\log n) }[/math] согласована с таблицей сложности. Предусловие — дерево непусто (assert(root_)).- Рекурсивный
destroy/checkбезопасны при разумной высоте; для гарантии используйте итеративные обходы (splay не даёт границы высоты на отдельной операции).
Сводная таблица сложности
| Операция | Худший случай | Амортизированный | Память |
|---|---|---|---|
| Search | [math]\displaystyle{ O(n) }[/math] | [math]\displaystyle{ O(\log n) }[/math] | [math]\displaystyle{ O(1) }[/math] |
| Insert | [math]\displaystyle{ O(n) }[/math] | [math]\displaystyle{ O(\log n) }[/math] | [math]\displaystyle{ O(1) }[/math] |
| Delete | [math]\displaystyle{ O(n) }[/math] | [math]\displaystyle{ O(\log n) }[/math] | [math]\displaystyle{ O(1) }[/math] |
| Min / Max (со splay) | [math]\displaystyle{ O(n) }[/math] | [math]\displaystyle{ O(\log n) }[/math] | [math]\displaystyle{ O(1) }[/math] |
| Predecessor / Successor (со splay) | [math]\displaystyle{ O(n) }[/math] | [math]\displaystyle{ O(\log n) }[/math] | [math]\displaystyle{ O(1) }[/math] |
| Split / Join | [math]\displaystyle{ O(n) }[/math] | [math]\displaystyle{ O(\log n) }[/math] | [math]\displaystyle{ O(1) }[/math] |
| Обход по возрастанию | [math]\displaystyle{ O(n) }[/math] | [math]\displaystyle{ O(n) }[/math] | [math]\displaystyle{ O(h) }[/math]; в худшем [math]\displaystyle{ O(n) }[/math] стека |
| Память структуры | — | — | [math]\displaystyle{ O(n) }[/math] (3 указателя на узел, без баланса) |
Примечание. Все оценки, кроме обхода, амортизированные: отдельная операция может стоить [math]\displaystyle{ O(n) }[/math] (например, доступ к самому глубокому узлу вырожденного на время дерева). Поэтому splay-дерево не подходит для систем жёсткого реального времени, где нужна гарантия на каждую операцию; там лучше авл-дерево (план) или красно-чёрное-дерево (план). Обход требует стека глубины [math]\displaystyle{ h }[/math]: высота splay-дерева ничем не ограничена, и в худший момент [math]\displaystyle{ h = n }[/math], поэтому рекурсивный обход занимает [math]\displaystyle{ O(n) }[/math] памяти.
Трассировка примера
Вставим [math]\displaystyle{ 20, 10, 30, 5, 15, 25, 35 }[/math]; затем выполним find(5), find(99) и erase(20).
| Шаг | Операция | Результат (in-order) | Что произошло |
|---|---|---|---|
| 1 | insert(20) |
20 | дерево пусто, 20 — корень |
| 2 | insert(10) |
10, 20 | 10 подключён слева, один поворот zig — 10 корень |
| 3 | insert(30) |
10, 20, 30 | 30 подключён справа от 20; zig-zig — корень 30 |
| 4 | insert(5) |
5, 10, 20, 30 | 5 подключён слева от 10; zig-zig, затем zig — корень 5 |
| 5 | insert(15) |
5, 10, 15, 20, 30 | 15 подключён слева от 20; два zig-zag — корень 15 |
| 6 | insert(25) |
5, 10, 15, 20, 25, 30 | 25 подключён справа от 20; splay — корень 25 |
| 7 | insert(35) |
5, 10, 15, 20, 25, 30, 35 | 35 подключён справа от 30; splay — корень 35 |
| 8 | find(5) |
5, 10, 15, 20, 25, 30, 35 | ключ найден; 5 поднят в корень |
| 9 | find(99) |
5, 10, 15, 20, 25, 30, 35 | ключа нет; в корень поднят последний посещённый (35) |
| 10 | erase(20) |
5, 10, 15, 25, 30, 35 | 20 сплайнится в корень; [math]\displaystyle{ L = 5(\cdot, 15(10)) }[/math], [math]\displaystyle{ R = 35(30(25)) }[/math]; максимум [math]\displaystyle{ L }[/math] — 15, после Splay(15) левая часть [math]\displaystyle{ 15(5(\cdot, 10)) }[/math], [math]\displaystyle{ R }[/math] прицеплен справа
|
In-order остаётся отсортированным на каждом шаге, validate() подтверждает BST-свойство и корректность ссылок на родителей.
Корректность и оценка сложности
Лемма 1 (splay сохраняет BST). Каждый поворот сохраняет порядок ключей, поэтому splay не меняет множество ключей; после splay узел [math]\displaystyle{ x }[/math] — корень.
Доказательство. Поворот переставляет два узла и перевешивает среднее поддерево, сохраняя неравенство left < node < right (см. авл-дерево (план)). Цикл поднимает [math]\displaystyle{ x }[/math] на каждом шаге, пока у него есть родитель, поэтому завершается, когда [math]\displaystyle{ x }[/math] — корень. [math]\displaystyle{ \blacksquare }[/math]
Теорема 2 (амортизированная оценка). Любая последовательность из [math]\displaystyle{ m }[/math] операций над splay-деревом с [math]\displaystyle{ n }[/math] узлами выполняется за [math]\displaystyle{ O((n + m)\log n) }[/math], то есть амортизированно [math]\displaystyle{ O(\log n) }[/math] на операцию.
Эскиз доказательства (метод потенциала, см. рис. 5). Положим
[math]\displaystyle{ \Phi(T) = \sum_{v \in T} \log_2 \text{size}(v). }[/math]
Для каждого поворота в точке [math]\displaystyle{ x }[/math] амортизированная стоимость ограничена утроенным приращением потенциала поддерева с корнем [math]\displaystyle{ x }[/math]:
[math]\displaystyle{ \hat c \le 3\big(\Phi'(x) - \Phi(x)\big), }[/math]
где [math]\displaystyle{ \Phi(x) = \log_2 \text{size}(x) }[/math]. Складывая вдоль пути доступа, получаем [math]\displaystyle{ O(\log n) }[/math]: вклад корня [math]\displaystyle{ \log_2 n }[/math], а телескопирование оставляет только разность потенциалов корня. Общий потенциал [math]\displaystyle{ \Phi(T) }[/math] при этом не обязан убывать — важен локальный потенциал узлов на пути. [math]\displaystyle{ \blacksquare }[/math]
Оговорка к оценке: [math]\displaystyle{ O(\log n) }[/math] на операцию получается при [math]\displaystyle{ m = \Omega(n) }[/math]. При малом [math]\displaystyle{ m }[/math] в оценке остаётся начальный потенциал порядка [math]\displaystyle{ O(n\log n) }[/math], и на одну операцию приходится [math]\displaystyle{ O\big((n/m + 1)\log n\big) }[/math].
Лемма о доступе (адаптивность). При весах [math]\displaystyle{ w(v) }[/math] амортизированная стоимость splay([math]\displaystyle{ x }[/math]) есть [math]\displaystyle{ O\!\big(\log(W/w(x))\big) }[/math], где [math]\displaystyle{ W = \sum_v w(v) }[/math]. При единичных весах это [math]\displaystyle{ O(\log n) }[/math], но для «горячих» (тяжёлых) узлов — существенно меньше.
Следствия. Из леммы о доступе выводятся:
- Статическая оптимальность: если доступы независимы с частотами [math]\displaystyle{ p(v) }[/math], суммарное время работы не хуже оптимального статического BST с точностью до константы.
- Статический палец: при фиксированном ключе [math]\displaystyle{ f }[/math] доступ к ключу [math]\displaystyle{ x }[/math] стоит амортизированно [math]\displaystyle{ O\big(\log(|x - f| + 1)\big) }[/math] — при весах, убывающих с расстоянием от [math]\displaystyle{ f }[/math] [2].
- Свойство рабочего множества: работа за период, в котором запрашивались [math]\displaystyle{ w }[/math] различных ключей, есть [math]\displaystyle{ O\big((w + \#\text{запросов})\log(w+1)\big) }[/math].
Теорема (динамический палец). Если доступ к [math]\displaystyle{ y }[/math] следует за доступом к [math]\displaystyle{ x }[/math], амортизированная стоимость доступа к [math]\displaystyle{ y }[/math] есть [math]\displaystyle{ O(\log d(x,y) + 1) }[/math], где [math]\displaystyle{ d }[/math] — число ключей между [math]\displaystyle{ x }[/math] и [math]\displaystyle{ y }[/math] [7]. Это не следствие леммы о доступе: теорему доказали Коул, Мишра, Шмидт и Сигел в 2000 году, через 15 лет после появления splay-деревьев.
Гипотеза динамической оптимальности. Тарджан и Слейтор предположили, что splay-дерево асимптотически не хуже любого BST (в том числе «офлайн-оптимального») с точностью до константы. Гипотеза открыта ⚠️ — это одна из центральных открытых проблем теории алгоритмов.
Сравнение с альтернативами
Сводка различий — в таблице ниже и на рис. 6.
| Критерий | Splay | АВЛ (план) | КЧ (план) | Treap |
|---|---|---|---|---|
| Хранит баланс | нет | высота / BF | цвет | случайный приоритет |
| Худший поиск | [math]\displaystyle{ O(n) }[/math] | [math]\displaystyle{ O(\log n) }[/math] | [math]\displaystyle{ O(\log n) }[/math] | [math]\displaystyle{ O(\log n) }[/math] (ожидаемо) |
| Амортизированный | [math]\displaystyle{ O(\log n) }[/math] | [math]\displaystyle{ O(\log n) }[/math] (следствие худшего) | [math]\displaystyle{ O(\log n) }[/math] (следствие худшего) | [math]\displaystyle{ O(\log n) }[/math] (следствие ожидаемого) |
| Адаптивность | да (рабочее множество) | нет | нет | нет |
| Split / Join | [math]\displaystyle{ O(\log n) }[/math] | сложно | сложно | [math]\displaystyle{ O(\log n) }[/math] |
| Локальность доступа | отличная | слабая | слабая | слабая |
| Гарантия на операцию | только амортизированная | да | да | вероятностная |
Вердикт. Splay выбирают, когда обращения неравномерны (кэши, горячие ключи, частые Split/Join) и когда допустима амортизированная (а не пооперационная) гарантия. Если нужна жёсткая гарантия худшего случая или предсказуемость, берут красно-чёрное-дерево (план) или авл-дерево (план); если нужна простота и рандомизация — treap.
Где применяется
- Link-cut trees (Слейтор — Тарджан, 1983) [4]. Лес динамических деревьев хранится как набор «предпочитаемых путей», каждый путь — в splay-дереве. Splay выбран потому, что
accessперестраивает путь локально и оплачивается амортизированно, без хранения баланса: получаются операции со связностью и путями за [math]\displaystyle{ O(\log n) }[/math] амортизированно. - Динамическая связность и максимальный поток (Голдберг — Тарджан, 1986). Алгоритм максимального потока Голдберга — Тарджана использует динамические деревья [4] для операций над путями в остаточной сети: дерево меняет структуру путей по ходу вычисления, а splay позволяет делать это без полной перебалансировки.
- Модель кэша с вытеснением по недавности [2]. Свойство рабочего множества объясняет, почему splay-дерево работает как модель кэша: стоимость работы на отрезке, где запрашивалось [math]\displaystyle{ w }[/math] различных ключей, ограничена [math]\displaystyle{ O\big((w + \#\text{запросов})\log(w+1)\big) }[/math], то есть зависит от размера рабочего множества, а не от [math]\displaystyle{ n }[/math]. Это модель поведения, а не конкретный продукт.
- Структуры последовательностей (
Split/Join). В «верёвках» (ropes) и редакторах текст хранят в листьях дерева, а вставку и удаление фрагмента выражают черезSplitпо позиции иJoin. Splay удобен тем, что обе операции — это один splay и [math]\displaystyle{ O(1) }[/math] перестановок указателей, без полей баланса. - Упорядоченные таблицы с «горячими» ключами. Как модель: если поток запросов имеет локальность (недавние ключи запрашиваются снова), адаптивность даёт выигрыш относительно структур с гарантией на каждую операцию; при равномерно случайном потоке выигрыша нет.
Историческая справка
- 1983. Д. Слейтор и Р. Тарджан предложили динамические деревья (link-cut trees) — структуру на основе splay-подобных путей [4].
- 1985. Д. Слейтор и Р. Тарджан опубликовали «Self-Adjusting Binary Search Trees» [2]: описаны splay-деревья, лемма о доступе, рабочее множество и статическая оптимальность. Это одна из первых структур с доказанными адаптивными оценками.
- 1986. Те же авторы предложили саморегулирующиеся кучи (self-adjusting heaps) [3] — ещё один пример амортизационного анализа без хранения баланса.
- 2000. Р. Коул, Б. Мишра, Ж.-П. Шмидт и А. Сигел доказали гипотезу динамического пальца [7] — через 15 лет после появления структуры.
- 1985–. Гипотеза динамической оптимальности splay-деревьев сформулирована Тарджаном и Слейтором и до сих пор открыта ⚠️.
- Название «splay» (расширять, разворачивать) отражает разворачивание пути доступа.
Типичные ошибки и подводные камни
- Забыть splay после доступа. Без сплайна дерево теряет адаптивность и амортизированные гарантии (и может выродиться).
- Неверный порядок в zig-zig. Сначала поворот вокруг родителя, затем вокруг [math]\displaystyle{ x }[/math]; перестановка ломает анализ и часть свойств.
- Путаница zig-zig и zig-zag. Направление детей у [math]\displaystyle{ x }[/math] и [math]\displaystyle{ p }[/math] одинаковое (zig-zig) — двойной поворот «снизу вверх»; разное (zig-zag) — два поворота вокруг [math]\displaystyle{ x }[/math].
- Использование как реального времени. [math]\displaystyle{ O(\log n) }[/math] — только амортизированное; отдельная операция может стоить [math]\displaystyle{ O(n) }[/math].
- Опора на
validateс рекурсией при вырождении. Глубина может быть велика; для надёжности делайте итеративные обходы. - Потеря ссылки
parent. splay поднимается по родителям; ошибка вparentломает всю операцию. - Ожидание стабильности/детерминированности формы. Форма зависит от истории обращений; одинаковые множества, полученные разными доступами, выглядят по-разному.
Splitбез splay. Деление «на месте» без сплайна корня легко нарушает BST-свойство.- Разрез не по тому сравнению. При отсутствующем [math]\displaystyle{ k }[/math] нельзя безусловно брать
left[root]в [math]\displaystyle{ L }[/math]: последний посещённый узел может быть меньше [math]\displaystyle{ k }[/math], и тогда он сам уходит в [math]\displaystyle{ L }[/math], а в [math]\displaystyle{ R }[/math] — толькоright[root]. - Запись в
parent[NIL]. Строки видаparent[L] ← NILбез проверкиL ≠ NILпишут в несуществующий узел; в C++ это разыменованиеnullptr. - Крайние случаи
Delete. Отсутствие одного из поддеревьев или отсутствие ключа — частые источники ошибок. - Ожидание, что splay всегда даёт баланс. В худший момент высота может быть большой; баланс — в среднем по последовательности.
Задачи для практики
- Реализация splay. Реализуйте поворот и splay со всеми тремя случаями; проверьте BST-инвариант после случайных операций. Подсказка:
parentобязателен. Ответ. Достаточноrotate+ разбора трёх случаев;validateвызывайте после каждой случайной операции. Суммарное время [math]\displaystyle{ O((n+m)\log n) }[/math] при [math]\displaystyle{ m }[/math] операциях. - Амортизированная цена. Измерьте суммарное время [math]\displaystyle{ m }[/math] случайных поисков; сравните с [math]\displaystyle{ m\log n }[/math]. Подсказка: отдельные операции могут быть дорогими, сумма — нет. Ответ. Суммарное время есть [math]\displaystyle{ O(m\log n) }[/math] (при [math]\displaystyle{ m = \Omega(n) }[/math]), тогда как отдельный вызов может стоить до [math]\displaystyle{ O(n) }[/math]; отношение «сумма / [math]\displaystyle{ m }[/math]» и должно вести себя как [math]\displaystyle{ O(\log n) }[/math].
- Локальность. Сравните последовательные и случайные доступы: во сколько раз splay быстрее при повторах? Подсказка: свойства рабочего множества. Ответ. При повторах splay быстрее за счёт свойства рабочего множества; выигрыш растёт с долей повторных запросов и падает с ростом числа различных ключей [math]\displaystyle{ w }[/math] в периоде.
- Split/Join. Реализуйте
Split(T, k)иJoin(T1, T2); проверьте их на наборе ключей. Подсказка: splay разделяющего узла. Ответ.Split—Search(k)и разрез по сравнениюkey[root]с [math]\displaystyle{ k }[/math];Join— splay максимума [math]\displaystyle{ T_1 }[/math] и прицепление [math]\displaystyle{ T_2 }[/math]. Обе операции [math]\displaystyle{ O(\log n) }[/math] амортизированно. - Диапазонный вывод и порядковая статистика. Добавьте
rank/select, поддерживая размеры поддеревьев. Подсказка: пересчитывайте размеры при поворотах. Ответ.rank/selectидут поsizeподдеревьев; при каждом повороте пересчитываются два узла, поэтому оценка остаётся [math]\displaystyle{ O(\log n) }[/math] амортизированно. - Link-cut tree (набросок). Постройте динамическое дерево на splay для связности/путей. Подсказка: «предпочитаемые пути» и
access. Ответ.accessреализуется через splay предпочитаемых путей (смена предпочитаемого ребёнка — этоSplit/Join), что даёт [math]\displaystyle{ O(\log n) }[/math] амортизированно на операцию. - Свойство рабочего множества. Сгенерируйте поток запросов из [math]\displaystyle{ w }[/math] различных ключей и проверьте оценку [math]\displaystyle{ O((w + q)\log(w+1)) }[/math]. Подсказка: считайте число поворотов. Ответ. Число поворотов за поток из [math]\displaystyle{ q }[/math] запросов есть [math]\displaystyle{ O\big((q + w)\log(w+1)\big) }[/math]; счётчик поворотов в реализации — прямой способ проверить оценку экспериментально.
- Сравнение с КЧ. На неравномерной нагрузке сравните splay и красно-чёрное дерево. Подсказка: где обращения локальны, splay выигрывает. Ответ. На локальных нагрузках splay выигрывает; на равномерно случайных красно-чёрное дерево стабильнее, так как не платит за перестройку формы.
Связанные темы
- двоичное-дерево-поиска (план) — базовая структура.
- авл-дерево (план) и красно-чёрное-дерево (план) — строгие гарантии худшего случая.
- фибоначчиева-куча (план) — другой пример амортизационного анализа.
- асимптотическая-сложность — амортизированные оценки.
- графы (план) — link-cut trees для динамических графов.
Источники
- Cormen T. H., Leiserson C. E., Rivest R. L., Stein C. Introduction to Algorithms (CLRS). 3rd ed. — MIT Press, 2009. — Гл. 12 «Binary Search Trees», гл. 13 (повороты) (проверено).
- Sleator D. D., Tarjan R. E. Self-Adjusting Binary Search Trees // Journal of the ACM. — 1985. — Vol. 32, No. 3. — P. 652–686 (проверено).
- Sleator D. D., Tarjan R. E. Self-Adjusting Heaps // SIAM Journal on Computing. — 1986. — Vol. 15, No. 1 (проверено).
- Sleator D. D., Tarjan R. E. A Data Structure for Dynamic Trees // Journal of Computer and System Sciences. — 1983. — Vol. 26, No. 3. — P. 362–391 (link-cut trees) (проверено).
- Sedgewick R., Wayne K. Algorithms. 4th ed. — Addison-Wesley, 2011. — Гл. 3 (двоичные деревья поиска, красно-чёрные деревья) — общий справочник по сбалансированным деревьям (проверено).
- Wikipedia: Splay tree. URL: https://en.wikipedia.org/wiki/Splay_tree (проверено).
- Cole R., Mishra B., Schmidt J., Siegel A. On the Dynamic Finger Conjecture for Splay Trees // SIAM Journal on Computing. — 2000. — Vol. 30, No. 1 (проверено).