2-3-дерево: различия между версиями

Материал из Викиконспекты ПМ-ПУ
(Новая страница: «== TL;DR == 2-3 дерево (2-3 tree) — сбалансированное дерево поиска, в котором '''каждый внутренний у...»)
 
 
(не показано 10 промежуточных версий этого же участника)
Строка 1: Строка 1:
== TL;DR ==
== TL;DR ==


2-3 дерево (2-3 tree) — сбалансированное дерево поиска, в котором '''каждый внутренний узел имеет двух или трёх детей''' и хранит один или два ключа, а '''все листья лежат на одной глубине'''. Благодаря тому, что у каждого узла не меньше двух детей, высота не превосходит <math>\log_2 n</math>, а благодаря тому, что не больше трёх, — не меньше <math>\log_3 n</math>; дерево всегда идеально сбалансировано без хранения полей баланса. Вставка добавляет ключ в лист и при переполнении '''расщепляет''' узел, поднимая средний ключ в родителя; удаление '''заимствует''' ключ у брата-3-узла или '''сливает''' узлы. Все операции работают за <math>O(\log n)</math>, а расщепления при вставке — амортизированно <math>O(1)</math>. 2-3 дерево — частный случай B-дерева (порядок 3) и родоначальник идеи, из которой выросли [[2-3-4-дерево|2-3-4 деревья]] и [[Красно-чёрное дерево|красно-чёрные деревья]].
2-3 дерево (2-3 tree) — сбалансированное дерево поиска, в котором '''каждый внутренний узел имеет двух или трёх детей''' и хранит один или два ключа, а '''все внешние листья лежат на одной глубине'''. Благодаря тому, что у каждого узла не меньше двух детей, высота не превосходит <math>\log_2(n+1)</math>, а благодаря тому, что не больше трёх, — не меньше <math>\log_3(n+1)</math>; дерево всегда идеально сбалансировано без хранения полей баланса. Вставка добавляет ключ в последний внутренний узел и при переполнении '''расщепляет''' узел, поднимая средний ключ в родителя; удаление '''заимствует''' ключ у брата-3-узла или '''сливает''' узлы. Все операции работают за <math>O(\log n)</math>, а расщепления при вставке — амортизированно <math>O(1)</math>. 2-3 дерево — частный случай B-дерева (порядок 3) и родоначальник идеи, из которой выросли [[2-3-4-дерево|2-3-4 деревья]] и [[Красно-чёрное дерево|красно-чёрные деревья]].


{| class="wikitable"
{| class="wikitable"
Строка 15: Строка 15:
| Балансировка || Идеальная: все листья на одной глубине
| Балансировка || Идеальная: все листья на одной глубине
|-
|-
| Высота || <math>\log_3 n \le h \le \log_2 n</math>
| Высота || <math>\log_3(n+1) \le h \le \log_2(n+1)</math>
|-
|-
| Поиск || <math>O(\log n)</math>
| Поиск || <math>O(\log n)</math>
Строка 25: Строка 25:
| Память || <math>O(n)</math>; узел хранит 1–2 ключа и 2–3 ссылки, поля баланса не нужны
| Память || <math>O(n)</math>; узел хранит 1–2 ключа и 2–3 ссылки, поля баланса не нужны
|-
|-
| Родственники || 2-3-4 дерево, B-дерево, [[Красно-чёрное дерево|красно-чёрное дерево]] (соответствие)
| Родственники || [[2-3-4-дерево]], [[B-дерево]], [[Красно-чёрное дерево|красно-чёрное дерево]] (соответствие)
|-
|-
| Где встречается || учебная основа B-деревьев; идея используется в индексах СУБД и файловых систем
| Где встречается || учебная основа B-деревьев; идея используется в индексах СУБД и файловых систем
Строка 39: Строка 39:
# '''все листья находятся на одной глубине''' (дерево идеально сбалансировано).
# '''все листья находятся на одной глубине''' (дерево идеально сбалансировано).


'''2-узел''' — узел с одним ключом и двумя детьми. '''3-узел''' — узел с двумя ключами и тремя детьми. '''Лист''' — внешний узел, не хранящий ключей (в разных изложениях листьями называют и сами ключи).
'''2-узел''' — узел с одним ключом и двумя детьми. '''3-узел''' — узел с двумя ключами и тремя детьми. '''Лист''' — внешний узел, не хранящий ключей. В этой статье ключи хранятся только во внутренних узлах, а все внешние листья лежат на одной глубине; в компактной реализации внешние листья не хранятся, и флаг <code>leaf</code> означает последний внутренний узел.


'''Высота <math>h</math>.''' Внутренний узел имеет не менее двух детей, поэтому в дереве высоты <math>h</math> не менее <math>2^h</math> листьев, откуда <math>h \le \log_2 n</math>. С другой стороны, не более трёх детей даёт <math>h \ge \log_3 n</math>.
'''Высота <math>h</math>.''' Внутренний узел имеет не менее двух детей, поэтому в дереве высоты <math>h</math> не менее <math>2^h</math> внешних листьев, откуда <math>h \le \log_2 L</math>. С другой стороны, не более трёх детей даёт <math>h \ge \log_3 L</math>.


'''Соответствие.''' 2-3 дерево — это B-дерево порядка (степени) 3. Обобщение узлов до «2, 3 или 4 детей» даёт [[2-3-4 дерево]], а через кодировку 3-узлов красными рёбрами — красно-чёрное дерево.
'''Соответствие.''' 2-3 дерево — это B-дерево порядка (степени) 3. Обобщение узлов до «2, 3 или 4 детей» даёт [[2-3-4 дерево]], а через кодировку 3-узлов красными рёбрами — красно-чёрное дерево.


'''Обозначения.''' <math>n</math> — число ключей; <math>h</math> — высота; <math>\log</math> — двоичный, если не оговорено.
'''Обозначения.''' <math>n</math> — число ключей; <math>L</math> — число внешних листьев; <math>h</math> — высота; <math>\log</math> — двоичный, если не оговорено.


[[Файл:tree23-01-structure.svg|frame|none|700px|Рис. 1. 2-узлы и 3-узлы; все листья на одной глубине. Высота заключена между <math>\log_3 n</math> и <math>\log_2 n</math>.]]
[[Файл:tree23-01-structure.svg|frame|none|700px|Рис. 1. 2-узлы и 3-узлы; все листья на одной глубине. Высота заключена между <math>\log_3 L</math> и <math>\log_2 L</math>.]]


== Мотивация и интуиция ==
== Мотивация и интуиция ==


Двоичное дерево поиска простое, но легко вырождается в список. Сбалансированные BST ([[АВЛ-дерево|АВЛ]], [[Красно-чёрное дерево|КЧ]]) чинят это поворотами и хранением баланса. 2-3 дерево предлагает другой подход: '''разрешить узлам хранить больше одного ключа'''. Как только узлу разрешено иметь три ребёнка, дереву становится «тесно» вырождаться: у внутренних узлов минимум '''два''' ребёнка, а значит, высота уже не может превысить <math>\log_2 n</math>.
Двоичное дерево поиска простое, но легко вырождается в список. Сбалансированные BST ([[АВЛ-дерево|АВЛ]], [[Красно-чёрное дерево|КЧ]]) чинят это поворотами и хранением баланса. 2-3 дерево предлагает другой подход: '''разрешить узлам хранить больше одного ключа'''. Как только узлу разрешено иметь три ребёнка, дереву становится «тесно» вырождаться: у внутренних узлов минимум '''два''' ребёнка, а значит, высота уже не может превысить <math>\log_2(n+1)</math>.


Ключевая идея — '''локальность балансировки'''. Когда лист переполняется (в него попадает третий ключ), узел временно становится 4-узлом и '''расщепляется''': средний ключ уходит в родителя, а крайние образуют два 2-узла. Баланс восстанавливается локально, а если переполнится родитель — расщепление продолжается вверх. Единственный случай, когда высота меняется, — расщепление корня. Аналогично удаление «разгружает» узел заимствованием у соседа или слиянием.
Ключевая идея — '''локальность балансировки'''. Когда последний внутренний узел переполняется (в него попадает третий ключ), узел временно становится 4-узлом и '''расщепляется''': средний ключ уходит в родителя, а крайние образуют два 2-узла. Баланс восстанавливается локально, а если переполнится родитель — расщепление продолжается вверх. Единственный случай, когда высота меняется, — расщепление корня. Аналогично удаление «разгружает» узел заимствованием у соседа или слиянием.


Так 2-3 дерево поддерживает идеальную балансировку '''структурно''', без единого поля высоты или цвета: инварианты формы гарантируют <math>\Theta(\log n)</math> сами по себе. Это делает 2-3 деревья удобной учебной моделью и мостиком к B-деревьям и красно-чёрным деревьям.
Так 2-3 дерево поддерживает идеальную балансировку '''структурно''', без единого поля высоты или цвета: инварианты формы гарантируют <math>\Theta(\log n)</math> сами по себе. Это делает 2-3 деревья удобной учебной моделью и мостиком к B-деревьям и красно-чёрным деревьям.
Строка 90: Строка 90:
'''Псевдокод.'''
'''Псевдокод.'''


<syntaxhighlight lang="text" line>
<syntaxhighlight lang="text">
Алгоритм Search(T, k)
Алгоритм Search(T, k)
  cur ← root[T]
1  cur ← root[T]
  while cur ≠ NIL
2  while cur ≠ NIL
      i ← 0
3      i ← 0
      while i < num[cur] and keys[cur][i] < k
4      while i < num[cur] and keys[cur][i] < k
          i ← i + 1
5          i ← i + 1
      if i < num[cur] and keys[cur][i] = k
6      if i < num[cur] and keys[cur][i] = k
          return cur
7          return cur
      if leaf[cur]
8      if leaf[cur]
          return NIL
9          return NIL
      cur ← child[cur][i]
10    cur ← child[cur][i]
  return NIL
11 return NIL
</syntaxhighlight>


Сложность: O(log n) — не длиннее высоты.
Сложность: O(log n) — не длиннее высоты.
Корректность: выбор ребёнка следует порядку ключей; свойство порядка гарантирует,
Корректность: выбор ребёнка следует порядку ключей; свойство порядка гарантирует,
что при наличии k спуск приведёт к нему.
что при наличии k спуск приведёт к нему.
</syntaxhighlight>


'''Крайние случаи.''' Ключ меньше всех — спуск в <code>child[0]</code>; больше всех — в последнего ребёнка.
'''Крайние случаи.''' Ключ меньше всех — спуск в <code>child[0]</code>; больше всех — в последнего ребёнка.
Строка 113: Строка 114:
=== Операция: вставка (Insert) ===
=== Операция: вставка (Insert) ===


'''Идея.''' Спуститься до листа и вставить ключ; при переполнении узла — '''расщепить''' его, подняв средний ключ в родителя (рекурсивно вверх).
'''Идея.''' Спуститься до последнего внутреннего узла и вставить ключ; при переполнении узла — '''расщепить''' его, подняв средний ключ в родителя (рекурсивно вверх).


'''Пошаговое описание.'''
'''Пошаговое описание.'''


# Спуск до листа по ключу.
# Спуск до последнего внутреннего узла по ключу.
# Вставка ключа в лист (лист временно может стать 4-узлом с тремя ключами).
# Вставка ключа в последний внутренний узел (он временно может стать 4-узлом с тремя ключами).
# Если ключей стало три — расщепление: крайние остаются в двух 2-узлах, средний поднимается.
# Если ключей стало три — расщепление: крайние остаются в двух 2-узлах, средний поднимается.
# Если переполнился родитель — повторить расщепление выше.
# Если переполнился родитель — повторить расщепление выше.
# Если расщепился корень — создать новый корень; высота растёт на 1.
# Если расщепился корень — создать новый корень; высота растёт на 1.


'''Инвариант.''' После каждого шага дерево остаётся 2-3 деревом (порядок, степень, одна глубина листьев); нарушением может быть лишь временное переполнение одного узла.
'''Инвариант.''' После каждого шага дерево остаётся 2-3 деревом (порядок, степень, одна глубина внешних листьев); нарушением может быть лишь временное переполнение одного узла.


'''Псевдокод (рекурсивный, возвращает «подъём» при переполнении).'''
'''Псевдокод (рекурсивный, возвращает «подъём» при переполнении).'''


<syntaxhighlight lang="text" line>
<syntaxhighlight lang="text">
Алгоритм Insert(T, k)
Алгоритм Insert(T, k)
  (overflow, mid, right) ← Insert-Rec(root[T], k)
1  (overflow, mid, right) ← Insert-Rec(root[T], k)
  if overflow                            # расщепился корень
2  if overflow                            # расщепился корень
      newRoot ← новый узел с ключом mid
3      newRoot ← новый узел с ключом mid
      child[newRoot][0] ← root[T];  child[newRoot][1] ← right
4      child[newRoot][0] ← root[T];  child[newRoot][1] ← right
      root[T] ← newRoot                  # высота +1
5      root[T] ← newRoot                  # высота +1


Алгоритм Insert-Rec(node, k)              # возвращает (overflow, mid, right)
Алгоритм Insert-Rec(node, k)              # возвращает (overflow, mid, right)
  if leaf[node]
6  if все дети node — внешние листья
      вставить k в keys[node] (с сохранением порядка)
7      вставить k в keys[node] (с сохранением порядка)
      if num[node] ≤ 2
8      if num[node] ≤ 2
          return (false, NIL, NIL)
9          return (false, NIL, NIL)
      keys[node] ← keys[node][0]        # левый 2-узел
10    keys[node] ← keys[node][0]        # левый 2-узел
      right ← лист с ключом keys[node][2] # правый 2-узел
11    right ← 2-узел с ключом keys[node][2] # правый 2-узел
      return (true, keys[node][1], right)  # средний ключ — вверх
12    return (true, keys[node][1], right)  # средний ключ — вверх
  i ← 0
13 i ← 0
  while i < num[node] and keys[node][i] < k
14 while i < num[node] and keys[node][i] < k
      i ← i + 1
15    i ← i + 1
  (overflow, mid, right) ← Insert-Rec(child[node][i], k)
16 (overflow, mid, right) ← Insert-Rec(child[node][i], k)
  if not overflow
17 if not overflow
      return (false, NIL, NIL)
18    return (false, NIL, NIL)
  вставить mid в keys[node] (позиция i) и right в child[node] (позиция i+1)
19 вставить mid в keys[node] (позиция i) и right в child[node] (позиция i+1)
  if num[node] ≤ 2
20 if num[node] ≤ 2
      return (false, NIL, NIL)
21    return (false, NIL, NIL)
  left  ← 2-узел с keys[node][0]
22 left  ← 2-узел с keys[node][0]
  rightNode ← 2-узел с keys[node][2]
23 rightNode ← 2-узел с keys[node][2]
  return (true, keys[node][1], rightNode)
24 return (true, keys[node][1], rightNode)
</syntaxhighlight>
</syntaxhighlight>


Строка 160: Строка 161:


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


'''Разбор на примере.''' Вставка 20 в 3-узел <code>[15 | 25]</code> даёт временный 4-узел <code>[15 | 20 | 25]</code>, который расщепляется: ключ 20 поднимается, а 15 и 25 образуют два 2-узла (рис. 2). Если при этом переполнился и родитель, расщепление идёт выше; расщепление корня повышает высоту (рис. 3).
'''Разбор на примере.''' Вставка 20 в 3-узел <code>[15 | 25]</code> даёт временный 4-узел <code>[15 | 20 | 25]</code>, который расщепляется: ключ 20 поднимается, а 15 и 25 образуют два 2-узла (рис. 2). Если при этом переполнился и родитель, расщепление идёт выше; расщепление корня повышает высоту (рис. 3).


'''Крайние случаи.''' Вставка в пустое дерево — единственный лист. Вставка дубликата — либо игнорируется, либо хранится счётчик повторов.
'''Крайние случаи.''' Вставка в пустое дерево — единственный внутренний узел с двумя внешними листьями. Вставка дубликата — либо игнорируется, либо хранится счётчик повторов.


[[Файл:tree23-02-insert-split.svg|frame|none|700px|Рис. 2. Вставка в 3-узел: временный 4-узел расщепляется, средний ключ поднимается в родителя.]]
[[Файл:tree23-02-insert-split.svg|frame|none|700px|Рис. 2. Вставка в 3-узел: временный 4-узел расщепляется, средний ключ поднимается в родителя.]]
Строка 177: Строка 177:
'''Пошаговое описание.'''
'''Пошаговое описание.'''


# Найти ключ; если он во внутреннем узле, заменить его ключом-предшественником из листа (как в BST).
# Найти ключ; если он во внутреннем узле, заменить его ключом-предшественником из последнего внутреннего узла (как в BST).
# Удалить ключ из листа.
# Удалить ключ из последнего внутреннего узла.
# Если лист опустел: - у соседа '''справа/слева есть 3-узел''' — заимствовать: перебросить ключ через родителя; - у соседей только 2-узлы — слить узел с соседом, опустив ключ родителя.
# Если последний внутренний узел опустел: - у соседа '''справа/слева есть 3-узел''' — заимствовать: перебросить ключ через родителя; - у соседей только 2-узлы — слить узел с соседом, опустив ключ родителя.
# Если родитель потерял ребёнка и опустел — повторить пункт 3 выше.
# Если родитель потерял ребёнка и опустел — повторить пункт 3 выше.
# Если корень опустел — сделать его единственного ребёнка новым корнем (высота −1).
# Если корень опустел — сделать его единственного ребёнка новым корнем (высота −1).


'''Инвариант.''' Свойства порядка и одна глубина листьев сохраняются; временным нарушением может быть лишь недостача ключей в одном узле.
'''Инвариант.''' Свойства порядка и одна глубина внешних листьев сохраняются; временным нарушением может быть лишь недостача ключей в одном узле.


'''Псевдокод (схема).'''
'''Псевдокод (схема).'''


<syntaxhighlight lang="text" line>
<syntaxhighlight lang="text">
Алгоритм Delete(T, k)
Алгоритм Delete(T, k)
  найти ключ k в узле z
1  найти ключ k в узле z
  if k во внутреннем узле
2  if k во внутреннем узле
      заменить k его предшественником из листа;  z ← этот лист
3      заменить k его предшественником из последнего внутреннего узла;  z ← этот узел
  удалить k из z
4  удалить k из z
  while z опустел и z ≠ root[T]
5  while z опустел и z ≠ root[T]
      p ← parent[z]
6      p ← parent[z]
      if у соседа z есть три ребёнка        # 3-узел
7      if у соседа z есть три ребёнка        # 3-узел
          заимствовать ключ у соседа через p (перераспределение)
8          заимствовать ключ у соседа через p (перераспределение)
          break
9          break
      else
10    else
          слить z с соседом, опустив разделяющий ключ из p
11        слить z с соседом, опустив разделяющий ключ из p
          z ← p                              # недостача поднялась выше
12        z ← p                              # недостача поднялась выше
  if root[T] опустел
13 if root[T] опустел
      root[T] ← единственный ребёнок root[T]  # высота −1
14    root[T] ← единственный ребёнок root[T]  # высота −1
</syntaxhighlight>
</syntaxhighlight>


Сложность: O(log n) времени; слияние/заимствование — O(1), повторов не более высоты.
Сложность: O(log n) времени; слияние/заимствование — O(1), повторов не более высоты.


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


'''Крайние случаи.''' Удаление последнего ключа корня-листа — дерево пустеет. Удаление отсутствующего ключа — no-op.
'''Крайние случаи.''' Удаление последнего ключа корня — последнего внутреннего узла — дерево пустеет. Удаление отсутствующего ключа — no-op.


[[Файл:tree23-04-delete.svg|frame|none|700px|Рис. 4. Опустевший 2-узел восстанавливают заимствованием у соседа-3-узла или слиянием с соседом; слияние может подняться до корня и уменьшить высоту.]]
[[Файл:tree23-04-delete.svg|frame|none|700px|Рис. 4. Опустевший 2-узел восстанавливают заимствованием у соседа-3-узла или слиянием с соседом; слияние может подняться до корня и уменьшить высоту.]]
Строка 231: Строка 231:
         K keys[2];
         K keys[2];
         Node* child[3] = {nullptr, nullptr, nullptr};
         Node* child[3] = {nullptr, nullptr, nullptr};
         bool leaf = true;
         bool leaf = true;                 // последний внутренний узел; внешние листья не хранятся
     };
     };


Строка 393: Строка 393:
* При переполнении узел временно содержит три ключа; расщепление оставляет по одному ключу в двух узлах и поднимает средний.
* При переполнении узел временно содержит три ключа; расщепление оставляет по одному ключу в двух узлах и поднимает средний.
* Наследование ребёнка <code>child[i+1]</code> при вставке ключа — частый источник ошибок; сверяйтесь с псевдокодом.
* Наследование ребёнка <code>child[i+1]</code> при вставке ключа — частый источник ошибок; сверяйтесь с псевдокодом.
* <code>validate</code> проверяет все три инварианта, включая одну глубину листьев.
* <code>validate</code> проверяет все три инварианта, включая одну глубину внешних листьев.
* В коде внешние листья не хранятся: <code>leaf == true</code> означает последний внутренний узел, а его дети — внешние листья. Глубина в <code>validate</code> считается по последним внутренним узлам (на 1 меньше глубины внешних листьев) и не влияет на проверку баланса.
* Удаление реализуется тем же рекурсивным приёмом с «подъёмом недостачи», но содержит больше случаев (заимствование/слияние).
* Удаление реализуется тем же рекурсивным приёмом с «подъёмом недостачи», но содержит больше случаев (заимствование/слияние).


Строка 427: Строка 428:
! Шаг !! Ключ !! Действие !! Дерево (в порядке возрастания)
! Шаг !! Ключ !! Действие !! Дерево (в порядке возрастания)
|-
|-
| 1 || 10 || первый лист || <code>[10]</code>
| 1 || 10 || первый узел || <code>[10]</code>
|-
|-
| 2 || 20 || в лист <code>[10]</code> || `[10 || 20]`
| 2 || 20 || в последний внутренний узел <code>[10]</code> || <code>[10 &#124; 20]</code>
|-
|-
| 3 || 5 || в лист `[10 || 20]` → расщепление || корень <code>[10]</code>, дети <code>[5]</code>, <code>[20]</code>
| 3 || 5 || в последний внутренний узел <code>[10 &#124; 20]</code> → расщепление || корень <code>[10]</code>, дети <code>[5]</code>, <code>[20]</code>
|-
|-
| 4 || 15 || спуск к <code>[20]</code> (между 10 и 20) || корень <code>[10]</code>, дети <code>[5]</code>, `[15 || 20]`
| 4 || 15 || спуск к <code>[20]</code> (между 10 и 20) || корень <code>[10]</code>, дети <code>[5]</code>, <code>[15 &#124; 20]</code>
|-
|-
| 5 || 25 || в лист `[15 || 20]` → расщепление || корень `[10 || 20]<code>, дети </code>[5]<code>, </code>[15]<code>, </code>[25]`
| 5 || 25 || в последний внутренний узел <code>[15 &#124; 20]</code> → расщепление || корень <code>[10 &#124; 20]</code>, дети <code>[5]</code>, <code>[15]</code>, <code>[25]</code>
|-
|-
| 6 || 30 || в лист <code>[25]</code> || корень `[10 || 20]<code>, дети </code>[5]<code>, </code>[15]<code>, </code>[25 || 30]`
| 6 || 30 || в последний внутренний узел <code>[25]</code> || корень <code>[10 &#124; 20]</code>, дети <code>[5]</code>, <code>[15]</code>, <code>[25 &#124; 30]</code>
|}
|}


На шаге 3 расщепился лист, на шаге 5 — лист, а его средний ключ (20) поднялся в корень, превратив его в 3-узел; высота не менялась. Все ключи выходят в порядке возрастания, <code>validate()</code> подтверждает инварианты.
На шаге 3 расщепился последний внутренний узел, на шаге 5 — последний внутренний узел, а его средний ключ (20) поднялся в корень, превратив его в 3-узел; высота не менялась. Все ключи выходят в порядке возрастания, <code>validate()</code> подтверждает инварианты.


== Корректность и оценка сложности ==
== Корректность и оценка сложности ==


'''Лемма 1 (граница высоты).''' 2-3 дерево с <math>n</math> ключами имеет высоту <math>h</math>, где <math>\log_3 n \le h \le \log_2 n</math>.
'''Лемма 1 (граница высоты).''' 2-3 дерево с <math>L</math> внешними листьями (и <math>n = L - 1</math> ключами) имеет высоту <math>h</math>, где <math>\log_3 L \le h \le \log_2 L</math>.


''Доказательство.'' У каждого внутреннего узла от 2 до 3 детей, все листья на глубине <math>h</math>. Число листьев <math>L</math> удовлетворяет <math>2^h \le L \le 3^h</math>. Так как ключей <math>n = L - 1</math> (или <math>L \approx n</math>), то <math>2^h \le n</math> и <math>n \le 3^h</math>, откуда <math>\log_3 n \le h \le \log_2 n</math>. <math>\blacksquare</math>
''Доказательство.'' У каждого внутреннего узла от 2 до 3 детей, все внешние листья на глубине <math>h</math>. Число листьев <math>L</math> удовлетворяет <math>2^h \le L \le 3^h</math>, откуда <math>\log_3 L \le h \le \log_2 L</math>. Так как ключей <math>n = L - 1</math>, получаем <math>h = \Theta(\log n)</math>. <math>\blacksquare</math>


'''Теорема 2 (корректность вставки).''' После <code>Insert</code> дерево остаётся 2-3 деревом с тем же множеством ключей плюс новый.
'''Теорема 2 (корректность вставки).''' После <code>Insert</code> дерево остаётся 2-3 деревом с тем же множеством ключей плюс новый.
Строка 454: Строка 455:
'''Теорема 3 (корректность удаления).''' После <code>Delete</code> дерево остаётся 2-3 деревом без удалённого ключа.
'''Теорема 3 (корректность удаления).''' После <code>Delete</code> дерево остаётся 2-3 деревом без удалённого ключа.


''Доказательство.'' Заимствование переносит ключ из соседа-3-узла через родителя, сохраняя и порядок, и число детей; слияние объединяет два 2-узла и разделяющий ключ родителя в один 3-узел, уменьшая число детей родителя на один — недостача поднимается вверх и в итоге поглощается, а опустевший корень заменяется единственным ребёнком. Все листья остаются на одной глубине. <math>\blacksquare</math>
''Доказательство.'' Заимствование переносит ключ из соседа-3-узла через родителя, сохраняя и порядок, и число детей; слияние объединяет два 2-узла и разделяющий ключ родителя в один 3-узел, уменьшая число детей родителя на один — недостача поднимается вверх и в итоге поглощается, а опустевший корень заменяется единственным ребёнком. Все внешние листья остаются на одной глубине. <math>\blacksquare</math>


'''Оценка сложности.''' Высота <math>O(\log n)</math>, на каждом уровне <math>O(1)</math> работы (сравнения, один раскол или слияние) — операции за <math>O(\log n)</math>. Число расщеплений при вставке амортизированно <math>O(1)</math> (аргумент счётчика: узел можно расщепить лишь после того, как его заполняли вставками).
'''Оценка сложности.''' Высота <math>O(\log n)</math>, на каждом уровне <math>O(1)</math> работы (сравнения, один раскол или слияние) — операции за <math>O(\log n)</math>. Число расщеплений при вставке амортизированно <math>O(1)</math> (аргумент счётчика: узел можно расщепить лишь после того, как его заполняли вставками).
Строка 468: Строка 469:
! Структура !! Хранит баланс !! Высота !! Поиск !! Вставка/удаление !! Особенность
! Структура !! Хранит баланс !! Высота !! Поиск !! Вставка/удаление !! Особенность
|-
|-
| '''2-3 дерево''' || нет (структурно) || <math>\le \log_2 n</math> || <math>O(\log n)</math> || <math>O(\log n)</math> || 2–3 ребёнка на узел
| '''2-3 дерево''' || нет (структурно) || <math>\le \log_2 L</math> || <math>O(\log n)</math> || <math>O(\log n)</math> || 2–3 ребёнка на узел
|-
|-
| [[2-3-4 дерево]] || нет || <math>\le \log_2 n</math> || <math>O(\log n)</math> || <math>O(\log n)</math> || 2–4 ребёнка; изоморфно КЧ
| [[2-3-4 дерево]] || нет || <math>\le \log_2 L</math> || <math>O(\log n)</math> || <math>O(\log n)</math> || 2–4 ребёнка; изоморфно КЧ
|-
|-
| [[АВЛ-дерево|АВЛ-дерево]] || BF/высота || <math>\le 1.44\log_2 n</math> || <math>O(\log n)</math> || <math>O(\log n)</math> || строгий баланс, много поворотов
| [[АВЛ-дерево|АВЛ-дерево]] || BF/высота || <math>\le 1.44\log_2 n</math> || <math>O(\log n)</math> || <math>O(\log n)</math> || строгий баланс, много поворотов
Строка 478: Строка 479:
| B-дерево порядка <math>t</math> || нет || <math>\le \log_t n</math> || <math>O(\log n)</math> || <math>O(\log n)</math> || рассчитано на страницы диска
| B-дерево порядка <math>t</math> || нет || <math>\le \log_t n</math> || <math>O(\log n)</math> || <math>O(\log n)</math> || рассчитано на страницы диска
|}
|}
'''Обозначения в таблице.''' <math>L</math> — число внешних листьев; для 2-3 и 2-3-4 <math>L = n + 1</math>, где <math>n</math> — число ключей.


'''Вердикт.''' 2-3 дерево — самый простой пример «многоключевого» баланса: нет полей баланса, а гарантия высоты следует из формы. На практике в памяти чаще берут двоичные КЧ/АВЛ (компактнее на узел), а на диске — B-деревья больших порядков; 2-3 дерево ценно как модель, объясняющая и B-деревья, и красно-чёрные деревья.
'''Вердикт.''' 2-3 дерево — самый простой пример «многоключевого» баланса: нет полей баланса, а гарантия высоты следует из формы. На практике в памяти чаще берут двоичные КЧ/АВЛ (компактнее на узел), а на диске — B-деревья больших порядков; 2-3 дерево ценно как модель, объясняющая и B-деревья, и красно-чёрные деревья.
Строка 535: Строка 538:
# Guibas L. J., Sedgewick R. A Dichromatic Framework for Balanced Trees // FOCS. — 1978.
# Guibas L. J., Sedgewick R. A Dichromatic Framework for Balanced Trees // FOCS. — 1978.
# Wikipedia: 2–3 tree. URL: https://en.wikipedia.org/wiki/2%E2%80%933_tree.
# Wikipedia: 2–3 tree. URL: https://en.wikipedia.org/wiki/2%E2%80%933_tree.
[[Category:Алгоритмы и структуры данных]]
[[Category:Деревья поиска]]

Текущая версия на 23:06, 25 октября 2026

TL;DR

2-3 дерево (2-3 tree) — сбалансированное дерево поиска, в котором каждый внутренний узел имеет двух или трёх детей и хранит один или два ключа, а все внешние листья лежат на одной глубине. Благодаря тому, что у каждого узла не меньше двух детей, высота не превосходит [math]\displaystyle{ \log_2(n+1) }[/math], а благодаря тому, что не больше трёх, — не меньше [math]\displaystyle{ \log_3(n+1) }[/math]; дерево всегда идеально сбалансировано без хранения полей баланса. Вставка добавляет ключ в последний внутренний узел и при переполнении расщепляет узел, поднимая средний ключ в родителя; удаление заимствует ключ у брата-3-узла или сливает узлы. Все операции работают за [math]\displaystyle{ O(\log n) }[/math], а расщепления при вставке — амортизированно [math]\displaystyle{ O(1) }[/math]. 2-3 дерево — частный случай B-дерева (порядок 3) и родоначальник идеи, из которой выросли 2-3-4 деревья и красно-чёрные деревья.

Свойство Значение
Класс Сбалансированное дерево поиска; B-дерево порядка 3
Изобретение Дж. Хопкрофт, около 1970 (2-3 деревья); обобщение — Байер и Маккрейт, 1972 [3][4]
Типы узлов 2-узел (1 ключ, 2 ребёнка), 3-узел (2 ключа, 3 ребёнка)
Балансировка Идеальная: все листья на одной глубине
Высота [math]\displaystyle{ \log_3(n+1) \le h \le \log_2(n+1) }[/math]
Поиск [math]\displaystyle{ O(\log n) }[/math]
Вставка [math]\displaystyle{ O(\log n) }[/math]; расщепления амортизированно [math]\displaystyle{ O(1) }[/math]
Удаление [math]\displaystyle{ O(\log n) }[/math] (заимствование или слияние)
Память [math]\displaystyle{ O(n) }[/math]; узел хранит 1–2 ключа и 2–3 ссылки, поля баланса не нужны
Родственники 2-3-4-дерево, B-дерево, красно-чёрное дерево (соответствие)
Где встречается учебная основа B-деревьев; идея используется в индексах СУБД и файловых систем

Определения

2-3 дерево — дерево поиска, в котором:

  1. каждый внутренний узел имеет либо двух, либо трёх детей;
  2. узел с двумя детьми (2-узел) хранит один ключ, узел с тремя детьми (3-узел) — два ключа;
  3. ключи в узле упорядочены, и для 3-узла с ключами [math]\displaystyle{ k_1 \lt k_2 }[/math]: левое поддерево содержит ключи [math]\displaystyle{ \lt k_1 }[/math], среднее — между [math]\displaystyle{ k_1 }[/math] и [math]\displaystyle{ k_2 }[/math], правое — [math]\displaystyle{ \gt k_2 }[/math];
  4. все листья находятся на одной глубине (дерево идеально сбалансировано).

2-узел — узел с одним ключом и двумя детьми. 3-узел — узел с двумя ключами и тремя детьми. Лист — внешний узел, не хранящий ключей. В этой статье ключи хранятся только во внутренних узлах, а все внешние листья лежат на одной глубине; в компактной реализации внешние листья не хранятся, и флаг leaf означает последний внутренний узел.

Высота [math]\displaystyle{ h }[/math]. Внутренний узел имеет не менее двух детей, поэтому в дереве высоты [math]\displaystyle{ h }[/math] не менее [math]\displaystyle{ 2^h }[/math] внешних листьев, откуда [math]\displaystyle{ h \le \log_2 L }[/math]. С другой стороны, не более трёх детей даёт [math]\displaystyle{ h \ge \log_3 L }[/math].

Соответствие. 2-3 дерево — это B-дерево порядка (степени) 3. Обобщение узлов до «2, 3 или 4 детей» даёт 2-3-4 дерево, а через кодировку 3-узлов красными рёбрами — красно-чёрное дерево.

Обозначения. [math]\displaystyle{ n }[/math] — число ключей; [math]\displaystyle{ L }[/math] — число внешних листьев; [math]\displaystyle{ h }[/math] — высота; [math]\displaystyle{ \log }[/math] — двоичный, если не оговорено.

Рис. 1. 2-узлы и 3-узлы; все листья на одной глубине. Высота заключена между [math]\displaystyle{ \log_3 L }[/math] и [math]\displaystyle{ \log_2 L }[/math].

Мотивация и интуиция

Двоичное дерево поиска простое, но легко вырождается в список. Сбалансированные BST (АВЛ, КЧ) чинят это поворотами и хранением баланса. 2-3 дерево предлагает другой подход: разрешить узлам хранить больше одного ключа. Как только узлу разрешено иметь три ребёнка, дереву становится «тесно» вырождаться: у внутренних узлов минимум два ребёнка, а значит, высота уже не может превысить [math]\displaystyle{ \log_2(n+1) }[/math].

Ключевая идея — локальность балансировки. Когда последний внутренний узел переполняется (в него попадает третий ключ), узел временно становится 4-узлом и расщепляется: средний ключ уходит в родителя, а крайние образуют два 2-узла. Баланс восстанавливается локально, а если переполнится родитель — расщепление продолжается вверх. Единственный случай, когда высота меняется, — расщепление корня. Аналогично удаление «разгружает» узел заимствованием у соседа или слиянием.

Так 2-3 дерево поддерживает идеальную балансировку структурно, без единого поля высоты или цвета: инварианты формы гарантируют [math]\displaystyle{ \Theta(\log n) }[/math] сами по себе. Это делает 2-3 деревья удобной учебной моделью и мостиком к B-деревьям и красно-чёрным деревьям.

Устройство

Узел 2-3 дерева — это небольшая запись с массивом ключей (до двух) и массивом детей (до трёх):

  • 2-узел: keys[0], child[0], child[1].
  • 3-узел: keys[0], keys[1], child[0], child[1], child[2].

Листья (внешние узлы) не хранят ключей и все находятся на одном уровне. Поиск спускается от корня, сравнивая ключ с одним или двумя ключами узла и выбирая ребёнка; при совпадении — успех.

Инварианты (поддерживаются всеми операциями):

  1. Порядок: ключи вдоль обхода слева направо возрастают.
  2. Степень: у внутреннего узла 2 или 3 ребёнка; у листа — 0.
  3. Совершенная балансировка: все листья на одной глубине.

Именно третий инвариант и даёт [math]\displaystyle{ O(\log n) }[/math]: он сохраняется при вставках и удалениях автоматически.

Операции и алгоритмы

Операция: поиск (Search)

Идея. Спуск от корня: сравнить ключ с ключом (или двумя ключами) узла, выбрать нужного ребёнка.

Пошаговое описание.

  1. В текущем узле найти позицию ключа среди keys[0..n-1].
  2. Если ключ совпал — найдено.
  3. Иначе при листе — не найдено; при внутреннем узле — спуститься в соответствующего ребёнка.

Инвариант. Искомый ключ, если есть, лежит в поддереве текущего узла.

Псевдокод.

Алгоритм Search(T, k)
1  cur ← root[T]
2  while cur ≠ NIL
3      i ← 0
4      while i < num[cur] and keys[cur][i] < k
5          i ← i + 1
6      if i < num[cur] and keys[cur][i] = k
7          return cur
8      if leaf[cur]
9          return NIL
10     cur ← child[cur][i]
11 return NIL

Сложность: O(log n) — не длиннее высоты.

Корректность: выбор ребёнка следует порядку ключей; свойство порядка гарантирует, что при наличии k спуск приведёт к нему.

Крайние случаи. Ключ меньше всех — спуск в child[0]; больше всех — в последнего ребёнка.

Операция: вставка (Insert)

Идея. Спуститься до последнего внутреннего узла и вставить ключ; при переполнении узла — расщепить его, подняв средний ключ в родителя (рекурсивно вверх).

Пошаговое описание.

  1. Спуск до последнего внутреннего узла по ключу.
  2. Вставка ключа в последний внутренний узел (он временно может стать 4-узлом с тремя ключами).
  3. Если ключей стало три — расщепление: крайние остаются в двух 2-узлах, средний поднимается.
  4. Если переполнился родитель — повторить расщепление выше.
  5. Если расщепился корень — создать новый корень; высота растёт на 1.

Инвариант. После каждого шага дерево остаётся 2-3 деревом (порядок, степень, одна глубина внешних листьев); нарушением может быть лишь временное переполнение одного узла.

Псевдокод (рекурсивный, возвращает «подъём» при переполнении).

Алгоритм Insert(T, k)
1  (overflow, mid, right) ← Insert-Rec(root[T], k)
2  if overflow                            # расщепился корень
3      newRoot ← новый узел с ключом mid
4      child[newRoot][0] ← root[T];  child[newRoot][1] ← right
5      root[T] ← newRoot                  # высота +1

Алгоритм Insert-Rec(node, k)              # возвращает (overflow, mid, right)
6  if все дети node — внешние листья
7      вставить k в keys[node] (с сохранением порядка)
8      if num[node] ≤ 2
9          return (false, NIL, NIL)
10     keys[node] ← keys[node][0]         # левый 2-узел
11     right ← 2-узел с ключом keys[node][2] # правый 2-узел
12     return (true, keys[node][1], right)  # средний ключ — вверх
13 i ← 0
14 while i < num[node] and keys[node][i] < k
15     i ← i + 1
16 (overflow, mid, right) ← Insert-Rec(child[node][i], k)
17 if not overflow
18     return (false, NIL, NIL)
19 вставить mid в keys[node] (позиция i) и right в child[node] (позиция i+1)
20 if num[node] ≤ 2
21     return (false, NIL, NIL)
22 left  ← 2-узел с keys[node][0]
23 rightNode ← 2-узел с keys[node][2]
24 return (true, keys[node][1], rightNode)

Сложность: O(log n) времени; расщеплений не более высоты, амортизированно O(1) на вставку.

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

Разбор на примере. Вставка 20 в 3-узел [15 | 25] даёт временный 4-узел [15 | 20 | 25], который расщепляется: ключ 20 поднимается, а 15 и 25 образуют два 2-узла (рис. 2). Если при этом переполнился и родитель, расщепление идёт выше; расщепление корня повышает высоту (рис. 3).

Крайние случаи. Вставка в пустое дерево — единственный внутренний узел с двумя внешними листьями. Вставка дубликата — либо игнорируется, либо хранится счётчик повторов.

Рис. 2. Вставка в 3-узел: временный 4-узел расщепляется, средний ключ поднимается в родителя.
Рис. 3. Расщепление доходит до корня; корень разделяется, высота растёт на 1, а листья остаются на одном уровне.

Операция: удаление (Delete)

Идея. Удалить ключ; если узел «опустел» (стал недопустимым), восстановить инвариант заимствованием у соседа-3-узла или слиянием с соседом через ключ родителя.

Пошаговое описание.

  1. Найти ключ; если он во внутреннем узле, заменить его ключом-предшественником из последнего внутреннего узла (как в BST).
  2. Удалить ключ из последнего внутреннего узла.
  3. Если последний внутренний узел опустел: - у соседа справа/слева есть 3-узел — заимствовать: перебросить ключ через родителя; - у соседей только 2-узлы — слить узел с соседом, опустив ключ родителя.
  4. Если родитель потерял ребёнка и опустел — повторить пункт 3 выше.
  5. Если корень опустел — сделать его единственного ребёнка новым корнем (высота −1).

Инвариант. Свойства порядка и одна глубина внешних листьев сохраняются; временным нарушением может быть лишь недостача ключей в одном узле.

Псевдокод (схема).

Алгоритм Delete(T, k)
1  найти ключ k в узле z
2  if k во внутреннем узле
3      заменить k его предшественником из последнего внутреннего узла;  z ← этот узел
4  удалить k из z
5  while z опустел и z ≠ root[T]
6      p ← parent[z]
7      if у соседа z есть три ребёнка        # 3-узел
8          заимствовать ключ у соседа через p (перераспределение)
9          break
10     else
11         слить z с соседом, опустив разделяющий ключ из p
12         z ← p                              # недостача поднялась выше
13 if root[T] опустел
14     root[T] ← единственный ребёнок root[T]   # высота −1

Сложность: O(log n) времени; слияние/заимствование — O(1), повторов не более высоты.

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

Крайние случаи. Удаление последнего ключа корня — последнего внутреннего узла — дерево пустеет. Удаление отсутствующего ключа — no-op.

Рис. 4. Опустевший 2-узел восстанавливают заимствованием у соседа-3-узла или слиянием с соседом; слияние может подняться до корня и уменьшить высоту.

Операции: минимум, максимум и обход

Min/Max — спуск в крайнего левого/правого ребёнка до листа, [math]\displaystyle{ O(\log n) }[/math]. Обход по возрастанию — симметричный обход: для 3-узла сначала левое поддерево, keys[0], среднее поддерево, keys[1], правое поддерево; [math]\displaystyle{ O(n) }[/math].

Реализация на C++

Реализация с вставкой (рекурсивное расщепление), поиском и проверкой инвариантов validate (порядок, степень, одна глубина листьев). Удаление оставлено как упражнение — оно заметно объёмнее.

#include <iostream>
#include <cassert>

template <typename K>
class TwoThreeTree {
    struct Node {
        int n = 0;                        // число ключей: 1 или 2
        K keys[2];
        Node* child[3] = {nullptr, nullptr, nullptr};
        bool leaf = true;                  // последний внутренний узел; внешние листья не хранятся
    };

    struct Split { bool overflow; K mid; Node* right; };

    Node* root_ = nullptr;

    Split insertRec(Node* node, const K& k) {
        if (node->leaf) {
            K tmp[3];
            int cnt = 0;
            bool placed = false;
            for (int j = 0; j < node->n; ++j) {
                if (!placed && k < node->keys[j]) { tmp[cnt++] = k; placed = true; }
                tmp[cnt++] = node->keys[j];
            }
            if (!placed) tmp[cnt++] = k;
            if (cnt <= 2) {
                for (int j = 0; j < cnt; ++j) node->keys[j] = tmp[j];
                node->n = cnt;
                return {false, K{}, nullptr};
            }
            // переполнение листа: расщепляем
            node->keys[0] = tmp[0];
            node->n = 1;
            Node* right = new Node();
            right->leaf = true;
            right->keys[0] = tmp[2];
            right->n = 1;
            return {true, tmp[1], right};
        }
        int i = 0;
        while (i < node->n && node->keys[i] < k) ++i;
        Split s = insertRec(node->child[i], k);
        if (!s.overflow) return {false, K{}, nullptr};

        // вставить ключ s.mid в позицию i и s.right в позицию i+1
        K keys[3];
        Node* ch[4];
        for (int j = 0; j < node->n; ++j) keys[j] = node->keys[j];
        for (int j = 0; j <= node->n; ++j) ch[j] = node->child[j];
        for (int j = node->n; j > i; --j) keys[j] = keys[j - 1];
        keys[i] = s.mid;
        for (int j = node->n + 1; j > i + 1; --j) ch[j] = ch[j - 1];
        ch[i + 1] = s.right;
        int nk = node->n + 1;

        if (nk <= 2) {
            for (int j = 0; j < nk; ++j) node->keys[j] = keys[j];
            for (int j = 0; j <= nk; ++j) node->child[j] = ch[j];
            node->n = nk;
            return {false, K{}, nullptr};
        }
        // переполнение внутреннего узла
        node->keys[0] = keys[0];
        node->n = 1;
        node->child[0] = ch[0];
        node->child[1] = ch[1];
        Node* right = new Node();
        right->leaf = false;
        right->keys[0] = keys[2];
        right->n = 1;
        right->child[0] = ch[2];
        right->child[1] = ch[3];
        return {true, keys[1], right};
    }

    static void destroy(Node* n) {
        if (!n) return;
        if (!n->leaf) for (int j = 0; j <= n->n; ++j) destroy(n->child[j]);
        delete n;
    }

    bool check(const Node* n, int depth, int& leafDepth,
               const K* lo, const K* hi) const {
        if (n->n < 1 || n->n > 2) return false;
        for (int j = 0; j < n->n; ++j) {
            if (lo && !(*lo < n->keys[j])) return false;
            if (hi && !(n->keys[j] < *hi)) return false;
            if (j > 0 && !(n->keys[j - 1] < n->keys[j])) return false;
        }
        if (n->leaf) {
            if (n->child[0] || n->child[1] || n->child[2]) return false;
            if (leafDepth == -1) leafDepth = depth;
            return depth == leafDepth;
        }
        for (int j = 0; j <= n->n; ++j) if (!n->child[j]) return false;
        for (int j = 0; j <= n->n; ++j) {
            const K* l = (j == 0) ? lo : &n->keys[j - 1];
            const K* h = (j == n->n) ? hi : &n->keys[j];
            if (!check(n->child[j], depth + 1, leafDepth, l, h)) return false;
        }
        return true;
    }

public:
    TwoThreeTree() = default;
    ~TwoThreeTree() { destroy(root_); }
    TwoThreeTree(const TwoThreeTree&) = delete;
    TwoThreeTree& operator=(const TwoThreeTree&) = delete;

    void insert(const K& k) {
        if (!root_) {
            root_ = new Node();
            root_->keys[0] = k;
            root_->n = 1;
            root_->leaf = true;
            return;
        }
        Split s = insertRec(root_, k);
        if (s.overflow) {
            Node* newRoot = new Node();
            newRoot->leaf = false;
            newRoot->n = 1;
            newRoot->keys[0] = s.mid;
            newRoot->child[0] = root_;
            newRoot->child[1] = s.right;
            root_ = newRoot;
        }
    }

    bool contains(const K& k) const {
        const Node* cur = root_;
        while (cur) {
            int i = 0;
            while (i < cur->n && cur->keys[i] < k) ++i;
            if (i < cur->n && !(k < cur->keys[i])) return true;   // равенство
            if (cur->leaf) return false;
            cur = cur->child[i];
        }
        return false;
    }

    bool validate() const {
        if (!root_) return true;
        int leafDepth = -1;
        return check(root_, 0, leafDepth, nullptr, nullptr);
    }
};

int main() {
    TwoThreeTree<int> t;
    for (int v : {10, 20, 5, 15, 25, 30, 28, 35, 40, 50, 3, 7})
        t.insert(v);
    assert(t.validate());
    for (int v : {10, 20, 5, 15, 25, 30, 28, 35, 40, 50, 3, 7})
        assert(t.contains(v));
    assert(!t.contains(99));

    TwoThreeTree<int> u;                       // много вставок с расщеплениями
    for (int i = 1; i <= 200; ++i) u.insert((i * 37) % 200);
    assert(u.validate());

    std::cout << "ok\n";
    return 0;
}

Замечания по реализации.

  • При переполнении узел временно содержит три ключа; расщепление оставляет по одному ключу в двух узлах и поднимает средний.
  • Наследование ребёнка child[i+1] при вставке ключа — частый источник ошибок; сверяйтесь с псевдокодом.
  • validate проверяет все три инварианта, включая одну глубину внешних листьев.
  • В коде внешние листья не хранятся: leaf == true означает последний внутренний узел, а его дети — внешние листья. Глубина в validate считается по последним внутренним узлам (на 1 меньше глубины внешних листьев) и не влияет на проверку баланса.
  • Удаление реализуется тем же рекурсивным приёмом с «подъёмом недостачи», но содержит больше случаев (заимствование/слияние).

Сводная таблица сложности

Операция Худший Амортизированная Замечание
Search [math]\displaystyle{ O(\log n) }[/math] [math]\displaystyle{ O(\log n) }[/math] сравнение с 1–2 ключами на узел
Insert [math]\displaystyle{ O(\log n) }[/math] [math]\displaystyle{ O(\log n) }[/math] расщеплений амортизированно [math]\displaystyle{ O(1) }[/math]
Delete [math]\displaystyle{ O(\log n) }[/math] [math]\displaystyle{ O(\log n) }[/math] заимствование/слияние за [math]\displaystyle{ O(1) }[/math]
Min / Max [math]\displaystyle{ O(\log n) }[/math] [math]\displaystyle{ O(\log n) }[/math] спуск к листу
Traversal [math]\displaystyle{ O(n) }[/math] [math]\displaystyle{ O(n) }[/math] симметричный обход
Память [math]\displaystyle{ O(n) }[/math] — 1–2 ключа и 2–3 ссылки на узел

Примечание. Число расщеплений при вставке может достигать высоты, но амортизированно их [math]\displaystyle{ O(1) }[/math]: каждое расщепление одного узла оплачивается предшествующими вставками — тот же аргумент, что и у двоичного счётчика (сравните с двоичной кучей и биномиальной кучей).

Рис. 5. Высота между [math]\displaystyle{ \log_3 n }[/math] и [math]\displaystyle{ \log_2 n }[/math]; операции логарифмические, расщепления амортизированно константны.

Трассировка примера

Вставим ключи [math]\displaystyle{ 10, 20, 5, 15, 25, 30 }[/math]. Показываем состояние после каждой вставки (ключи узла через |).

Шаг Ключ Действие Дерево (в порядке возрастания)
1 10 первый узел [10]
2 20 в последний внутренний узел [10] [10 | 20]
3 5 в последний внутренний узел [10 | 20] → расщепление корень [10], дети [5], [20]
4 15 спуск к [20] (между 10 и 20) корень [10], дети [5], [15 | 20]
5 25 в последний внутренний узел [15 | 20] → расщепление корень [10 | 20], дети [5], [15], [25]
6 30 в последний внутренний узел [25] корень [10 | 20], дети [5], [15], [25 | 30]

На шаге 3 расщепился последний внутренний узел, на шаге 5 — последний внутренний узел, а его средний ключ (20) поднялся в корень, превратив его в 3-узел; высота не менялась. Все ключи выходят в порядке возрастания, validate() подтверждает инварианты.

Корректность и оценка сложности

Лемма 1 (граница высоты). 2-3 дерево с [math]\displaystyle{ L }[/math] внешними листьями (и [math]\displaystyle{ n = L - 1 }[/math] ключами) имеет высоту [math]\displaystyle{ h }[/math], где [math]\displaystyle{ \log_3 L \le h \le \log_2 L }[/math].

Доказательство. У каждого внутреннего узла от 2 до 3 детей, все внешние листья на глубине [math]\displaystyle{ h }[/math]. Число листьев [math]\displaystyle{ L }[/math] удовлетворяет [math]\displaystyle{ 2^h \le L \le 3^h }[/math], откуда [math]\displaystyle{ \log_3 L \le h \le \log_2 L }[/math]. Так как ключей [math]\displaystyle{ n = L - 1 }[/math], получаем [math]\displaystyle{ h = \Theta(\log n) }[/math]. [math]\displaystyle{ \blacksquare }[/math]

Теорема 2 (корректность вставки). После Insert дерево остаётся 2-3 деревом с тем же множеством ключей плюс новый.

Доказательство. Расщепление переполненного узла заменяет один узел двумя (или, на верхнем уровне, создаёт новый корень), сохраняя порядок ключей и глубину листьев: крайние ключи остаются на прежней глубине, средний поднимается на уровень выше, где для него освобождается место; при расщеплении корня все листья углубляются на один уровень одновременно. Инварианты порядка и степени восстанавливаются на каждом уровне, значит, и во всём дереве. [math]\displaystyle{ \blacksquare }[/math]

Теорема 3 (корректность удаления). После Delete дерево остаётся 2-3 деревом без удалённого ключа.

Доказательство. Заимствование переносит ключ из соседа-3-узла через родителя, сохраняя и порядок, и число детей; слияние объединяет два 2-узла и разделяющий ключ родителя в один 3-узел, уменьшая число детей родителя на один — недостача поднимается вверх и в итоге поглощается, а опустевший корень заменяется единственным ребёнком. Все внешние листья остаются на одной глубине. [math]\displaystyle{ \blacksquare }[/math]

Оценка сложности. Высота [math]\displaystyle{ O(\log n) }[/math], на каждом уровне [math]\displaystyle{ O(1) }[/math] работы (сравнения, один раскол или слияние) — операции за [math]\displaystyle{ O(\log n) }[/math]. Число расщеплений при вставке амортизированно [math]\displaystyle{ O(1) }[/math] (аргумент счётчика: узел можно расщепить лишь после того, как его заполняли вставками).

Соответствие красно-чёрному дереву. Если каждый 3-узел закодировать чёрным узлом и его красным левым ребёнком, получится лево-ориентированное красно-чёрное дерево (LLRB) — вариант красно-чёрного дерева с запретом красных правых рёбер. Обобщение узлов до трёх ключей/четырёх детей даёт 2-3-4 дерево и классическое соответствие КЧ-дереву.

50] кодируется чёрным узлом 50 и красным левым ребёнком 30 — так 2-3 дерево превращается в лево-ориентированное красно-чёрное дерево.

Сравнение с альтернативами

Структура Хранит баланс Высота Поиск Вставка/удаление Особенность
2-3 дерево нет (структурно) [math]\displaystyle{ \le \log_2 L }[/math] [math]\displaystyle{ O(\log n) }[/math] [math]\displaystyle{ O(\log n) }[/math] 2–3 ребёнка на узел
2-3-4 дерево нет [math]\displaystyle{ \le \log_2 L }[/math] [math]\displaystyle{ O(\log n) }[/math] [math]\displaystyle{ O(\log n) }[/math] 2–4 ребёнка; изоморфно КЧ
АВЛ-дерево BF/высота [math]\displaystyle{ \le 1.44\log_2 n }[/math] [math]\displaystyle{ O(\log n) }[/math] [math]\displaystyle{ O(\log n) }[/math] строгий баланс, много поворотов
Красно-чёрное цвет [math]\displaystyle{ \le 2\log_2 n }[/math] [math]\displaystyle{ O(\log n) }[/math] [math]\displaystyle{ O(\log n) }[/math] двоичное, стандарт STL
B-дерево порядка [math]\displaystyle{ t }[/math] нет [math]\displaystyle{ \le \log_t n }[/math] [math]\displaystyle{ O(\log n) }[/math] [math]\displaystyle{ O(\log n) }[/math] рассчитано на страницы диска

Обозначения в таблице. [math]\displaystyle{ L }[/math] — число внешних листьев; для 2-3 и 2-3-4 [math]\displaystyle{ L = n + 1 }[/math], где [math]\displaystyle{ n }[/math] — число ключей.

Вердикт. 2-3 дерево — самый простой пример «многоключевого» баланса: нет полей баланса, а гарантия высоты следует из формы. На практике в памяти чаще берут двоичные КЧ/АВЛ (компактнее на узел), а на диске — B-деревья больших порядков; 2-3 дерево ценно как модель, объясняющая и B-деревья, и красно-чёрные деревья.

Где применяется

  1. Учебная основа B-деревьев. 2-3 дерево вводит ключевые приёмы B-деревьев — расщепление, слияние, заимствование — в минимальном виде.
  2. Символьные таблицы и словари в памяти. Там, где важны локальность и отсутствие полей баланса, многоключевые узлы дают хорошую кэш-эффективность.
  3. Индексы СУБД и файловых систем (через обобщение). Реальные системы используют B-деревья и B⁺-деревья больших порядков, но их балансировка — прямое развитие идей 2-3 дерева.
  4. Функциональные и неизменяемые структуры. 2-3 деревья и их аналоги (2-3 finger trees) применяются в чисто функциональных языках для неизменяемых упорядоченных множеств.
  5. Соответствие красно-чёрным деревьям. Понимание 2-3 (и 2-3-4) деревьев объясняет, почему КЧ-дерево сбалансировано и как устроены операции LLRB.

Историческая справка

  • 1970. Дж. Хопкрофт предложил 2-3 деревья — одну из первых сбалансированных структур с многоключевыми узлами [3].
  • 1972. Р. Байер и Э. Маккрейт обобщили идею до B-деревьев, ориентированных на внешнюю память [4].
  • 1978. Л. Гийба и Р. Седжвик показали соответствие между такими деревьями и красно-чёрными деревьями [5].
  • 2008. Р. Седжвик предложил лево-ориентированные красно-чёрные деревья (LLRB) — прямую кодировку 2-3 деревьев.

Типичные ошибки и подводные камни

  • Нарушение «одной глубины» листьев. Расщепление и слияние должны сохранять глубину; ошибка в наследовании детей ломает баланс.
  • Неверный выбор «подъёмного» ключа при расщеплении. Поднимается средний ключ; крайние образуют два 2-узла.
  • Спутанный порядок ключей в 3-узле. Между [math]\displaystyle{ k_1 }[/math] и [math]\displaystyle{ k_2 }[/math] идёт среднее поддерево; легко перепутать child[1] и child[2].
  • Забыть случай переполнения внутреннего узла. Расщепление должно продолжаться вверх, а не только на уровне листа.
  • Удаление: неправильное заимствование. Ключ идёт через родителя (перераспределение), а не напрямую; иначе нарушается порядок.
  • Забыть про опустевший корень. При удалении корень может остаться без ключей — его нужно заменить единственным ребёнком (высота −1).
  • Путаница 2-3 и 2-3-4. У 2-3 максимум 3 ребёнка, у 2-3-4 — 4; набор инвариантов и число случаев различаются.
  • Дубликаты. Стандартное 2-3 дерево хранит уникальные ключи; для мультимножества нужен счётчик.
  • Рекурсия. Вставка/удаление рекурсивны по высоте [math]\displaystyle{ O(\log n) }[/math] — безопасно; но удаление легко реализовать с ошибками в индексах детей.

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

  1. Реализация вставки. Реализуйте рекурсивное расщепление; проверьте инварианты на [math]\displaystyle{ 10^5 }[/math] случайных вставках. Подсказка: validate с проверкой глубины листьев.
  2. Поиск и диапазон. Реализуйте contains и вывод ключей в диапазоне [math]\displaystyle{ [a, b] }[/math] за [math]\displaystyle{ O(\log n + k) }[/math]. Подсказка: симметричный обход с отсечением.
  3. Удаление. Реализуйте заимствование и слияние; проверьте уменьшение высоты при опустевшем корне. Подсказка: подъём «недостачи».
  4. Высота. Докажите [math]\displaystyle{ \log_3 n \le h \le \log_2 n }[/math] и постройте деревья, дающие обе крайности. Подсказка: все узлы 2-узлы против всех 3-узлов.
  5. Соответствие LLRB. Закодируйте 3-узлы красными рёбрами и проверьте свойства КЧ-дерева. Подсказка: красное ребро наклонено влево.
  6. Амортизированные расщепления. Докажите, что [math]\displaystyle{ n }[/math] вставок дают [math]\displaystyle{ O(n) }[/math] расщеплений. Подсказка: аналогия с двоичным счётчиком.
  7. Обобщение до 2-3-4. Измените код на 2-3-4 дерево и сравните число операций. Подсказка: 4-узел расщепляется на два 2-узла с подъёмом среднего ключа.
  8. Много дубликатов. Добавьте счётчик повторов в узел. Подсказка: ключ хранится один раз, счётчик растёт/убывает.

Связанные темы

Источники

  1. Cormen T. H., Leiserson C. E., Rivest R. L., Stein C. Introduction to Algorithms (CLRS). 3rd ed. — MIT Press, 2009. — Гл. 18 «B-Trees» (многоключевые узлы, расщепление и слияние).
  2. Sedgewick R., Wayne K. Algorithms. 4th ed. — Addison-Wesley, 2011. — Гл. 3 (сбалансированные деревья поиска).
  3. Knuth D. E. The Art of Computer Programming. Vol. 3: Sorting and Searching. — Addison-Wesley, 2nd ed., 1998. — §6.2.4 (многопутевые деревья; 2-3 деревья приписываются Хопкрофту).
  4. Bayer R., McCreight E. Organization and Maintenance of Large Ordered Indexes // Acta Informatica. — 1972. — Vol. 1, No. 3. — P. 173–189.
  5. Guibas L. J., Sedgewick R. A Dichromatic Framework for Balanced Trees // FOCS. — 1978.
  6. Wikipedia: 2–3 tree. URL: https://en.wikipedia.org/wiki/2%E2%80%933_tree.