1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7

# теория · шаг 4 из 7

Дерево ван Эмде Боаса: следующий ключ за log log U

Коротко

Сбалансированное дерево ищет следующий по величине ключ за O(log n) - и это предел для сравнений. Если ключи - целые из диапазона 0..U−1, можно быстрее: дерево ван Эмде Боаса делит диапазон на √U кластеров по √U чисел, хранит каждый кластер такой же структурой меньшего размера и отдельно - сводку: какие кластеры непусты. Поиск следующего ключа спускается либо в один кластер, либо в сводку - но никогда в оба, а минимум каждого узла хранится отдельно и в кластер не кладётся. Размер задачи на каждом шаге - корень из прежнего, поэтому шагов log log U: для 64-битных ключей это 6. Цена - большие константы и память; на практике структуру используют, когда операций очень много, а ключи - целые.

Если ты знаешь, как дерево ван Эмде Боаса делит диапазон на кластеры и сводку и почему поиск следующего стоит log log U, листай до «Теперь сам».

Как думаешь, можно ли искать следующий ключ быстрее логарифма

Задача: множество целых чисел из 0..U−1, запросы «вставить» и «найти наименьший ключ больше x». Так работает таймер событий в ядре ОС, маршрутизатор с таблицей адресов, планировщик с приоритетами из фиксированного диапазона. Сбалансированное дерево даёт O(log n), и сравнениями лучше не сделать.

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

На что это похоже

Поиск ближайшего занятого места в огромном кинозале. Можно идти вдоль ряда кресло за креслом. А можно устроить так: у каждого ряда на входе табличка «в этом ряду кто-то есть, крайний справа - кресло 17», а у входа в зал - список непустых рядов. Тогда ищешь так: если в твоём ряду правее тебя кто-то есть - ищи в ряду; если нет - смотри по списку следующий непустой ряд и бери в нём самого левого. Никогда не приходится делать и то, и другое подробно. А если внутри ряда тоже устроить «секции» с табличками, и внутри секций ещё - получится дерево ван Эмде Боаса.

Механизм: кластеры, сводка и отдельный минимум

структура для диапазона 0..u−1 (u - квадрат, например 2^16):
    min, max      - хранятся прямо в узле
    кластер[h]    - структура для диапазона 0..√u−1, «ряд» номер h
    сводка        - структура для 0..√u−1: какие кластеры непусты

ключ x  →  h = x // √u   (номер кластера, «старшая половина цифр»)
           l = x %  √u   (место внутри кластера, «младшая половина»)

ВАЖНО: min узла в кластеры НЕ кладётся

Поиск следующего за x:

если x < min:                         ответ min
если в кластере h есть ключ > l:      ищем в кластере h  (1 спуск)
иначе:  h2 = следующий непустой кластер после h - ищем в сводке (1 спуск)
        ответ = h2·√u + min кластера h2   (min хранится, спуск не нужен)

Вставка тоже делает ровно один рекурсивный спуск: если кластер был пуст, ключ становится его min за O(1) (в глубину не идём - вот зачем min не кладут в кластер), а спуск уходит в сводку; если кластер не пуст - сводка уже знает о нём, и спуск идёт только в кластер.

Сколько уровней? Размер задачи u превращается в √u. Если u = 2^m, то √u = 2^(m/2): показатель степени делится пополам на каждом уровне. А число делений пополам до единицы - это логарифм. Значит, уровней log₂ m = log₂ log₂ u:

T(u) = T(√u) + O(1)        u = 2^64 → 2^32 → 2^16 → 2^8 → 2^4 → 2^2
                           шесть уровней: log₂ 64 = 6

Правило

Дерево ван Эмде Боаса для ключей из 0..U−1 хранит √U кластеров-поддеревьев размера √U, сводку непустых кластеров и min, max прямо в узле; min в кластеры не кладётся. Поиск следующего и вставка делают по одному рекурсивному спуску на уровень - либо в кластер, либо в сводку (удаление устроено так же: второй спуск у него возможен, только когда кластер опустел и стоит O(1)), - а размер задачи на уровне падает с u до √u. Отсюда O(log log U) на операцию: 6 уровней для 64-битных ключей. Полная структура требует памяти Θ(U); если заводить кластеры только по мере надобности (словарём), память - O(n · log log U).

Разбор: поиск следующего на 16 числах

U = 16, √U = 4. Ключи: 2 3 4 5 7 14 15
min = 2 - хранится только наверху, в кластеры не идёт
кластер 0 (числа 0..3):    {3}       → места {3}
кластер 1 (числа 4..7):    {4 5 7}   → места {0 1 3}
кластер 3 (числа 12..15):  {14 15}   → места {2 3}
сводка: непусты кластеры {0 1 3}

следующий после 5:  h = 1, l = 1. В кластере 1 max = 3 > 1 →
    ищем в кластере 1 следующий после 1: это 3  →  ответ 1·4 + 3 = 7

следующий после 7:  h = 1, l = 3. В кластере 1 max = 3, больше нет →
    сводка: следующий непустой после 1 - кластер 3
    ответ 3·4 + min(кластер 3) = 12 + 2 = 14

Во втором запросе сводка дала номер кластера, а сам ключ нашёлся без спуска в кластер - его min хранится в узле. Ровно поэтому спуск всегда один.

Разбор: от 2⁸ до 2⁶⁴

Программа строит структуру для диапазонов от 2⁸ до 2⁶⁴, кластеры заводит словарём по мере надобности, делает по 5000 запросов «следующий» и сверяет каждый ответ с двоичным поиском по отсортированному списку. Считается число вызовов на запрос.

veb.py
import bisect
import random

vyzovy = 0


class VEB:                          # ключи 0..u−1, u = 2^(2^k)
    def __init__(self, u):
        self.u, self.min, self.max = u, None, None
        if u > 2:
            self.kor = int(round(u ** 0.5))   # √u: и число, и размер
            self.klastery = {}                # заводим по требованию
            self.svodka = None                # какие кластеры непусты

    def vstavit(self, x):
        global vyzovy
        vyzovy += 1
        if self.min is None:                  # пустая - O(1), вглубь
            self.min = self.max = x           # не спускаемся
            return
        if x < self.min:
            x, self.min = self.min, x         # min живёт только здесь
        if self.u > 2:
            h, l = divmod(x, self.kor)        # номер кластера, место
            c = self.klastery.get(h)
            if c is None:
                c = self.klastery[h] = VEB(self.kor)
            if c.min is None:                 # кластер был пуст:
                if self.svodka is None:       # один рекурсивный вызов -
                    self.svodka = VEB(self.kor)
                self.svodka.vstavit(h)        # в сводку,
                c.min = c.max = l             # а в кластер - O(1)
            else:
                c.vstavit(l)                  # иначе только в кластер
        if x > self.max:
            self.max = x

    def sleduyushchij(self, x):               # наименьший ключ > x
        global vyzovy
        vyzovy += 1
        if self.u == 2:
            return 1 if x == 0 and self.max == 1 else None
        if self.min is not None and x < self.min:
            return self.min
        h, l = divmod(x, self.kor)
        c = self.klastery.get(h)
        if c is not None and c.max is not None and l < c.max:
            return h * self.kor + c.sleduyushchij(l)   # внутри кластера
        if self.svodka is None:
            return None
        h2 = self.svodka.sleduyushchij(h)     # или в следующем кластере
        if h2 is None:
            return None
        return h2 * self.kor + self.klastery[h2].min


rng = random.Random(2)
for bity in (8, 16, 32, 64):
    U = 2 ** bity
    klyuchi = rng.sample(range(U), 100) if bity == 8 else \
        [rng.randrange(U) for _ in range(5000)]
    v, spisok = VEB(U), sorted(set(klyuchi))
    for k in spisok:                          # без повторов
        v.vstavit(k)
    vyzovy = 0
    for _ in range(5000):
        x = rng.randrange(U)
        i = bisect.bisect_right(spisok, x)
        otvet = spisok[i] if i < len(spisok) else None
        assert v.sleduyushchij(x) == otvet
    print(f"U = 2^{bity:<2}  вызовов на поиск следующего:"
          f" {vyzovy / 5000:.1f}")
↳  выводpython3 veb.py
U = 2^8   вызовов на поиск следующего: 3.4
U = 2^16  вызовов на поиск следующего: 4.0
U = 2^32  вызовов на поиск следующего: 4.9
U = 2^64  вызовов на поиск следующего: 6.0

Все ответы совпали с двоичным поиском. Число вызовов растёт на единицу при каждом ВОЗВЕДЕНИИ диапазона в квадрат: 2⁸ → 2¹⁶ → 2³² → 2⁶⁴ дают в среднем 3.4 → 4 → 4.9 → 6, и выше log₂ log₂ U вызовов не бывает. Сбалансированное дерево на тех же пяти тысячах ключей прошло бы около 12 уровней, и каждый - со сравнением.

Что ломается без этого

Самая частая ошибка - класть min в кластер, как все остальные ключи. Тогда вставка в пустой кластер спускается и в кластер, и в сводку - два рекурсивных вызова на уровень, и время становится O(log U): вся выгода пропала. Вторая - завести полную структуру на 64-битный диапазон: массив кластеров на верхнем уровне - 2³² ссылок, это 32 ГБ до первого ключа; без словаря структура годится только для U до миллионов. Третья - ждать от неё чудес на малом числе ключей: константы у неё большие, и на тысяче ключей двоичный поиск по списку или обычное дерево быстрее.

Зачем это в работе

Сама структура встречается редко, но её идея - «разделить ключ на старшую и младшую части и держать сводку непустых групп» - везде. Иерархические битовые карты в аллокаторах памяти и планировщиках ОС (битовая маска непустых очередей приоритетов плюс маски внутри групп) - это два-три уровня того же дерева. Префиксные деревья для таблиц маршрутизации делят адрес на куски по 8-16 бит. Алгоритм Дейкстры на графах с целыми весами использует такие очереди, чтобы обойти логарифм кучи. Когда ключи - целые из известного диапазона, граница log n для поиска следующего перестаёт быть пределом.

Теперь сам

U = 16, ключи 1 6 9 10 13. Разложи их по кластерам (с учётом того, что min наверху), запиши сводку и найди следующий после 10.

Ответ. min = 1 хранится наверху. Остальные: 6 → кластер 1, место 2; 9 и 10 → кластер 2, места 1 и 2; 13 → кластер 3, место 1. Кластер 0 пуст. Сводка: {1 2 3}. Следующий после 10: h = 2, l = 2, в кластере 2 max = 2 - правее нет. Сводка: следующий непустой после 2 - кластер 3, его min = место 1. Ответ 3·4 + 1 = 13.

Главное

Дерево ван Эмде Боаса ищет следующий ключ среди целых 0..U−1 за O(log log U), обходя границу сравнений. Ключ делится на номер кластера и место в нём; узел хранит кластеры, сводку непустых кластеров и свой min отдельно - поэтому каждая операция спускается только в одно место, а размер задачи падает с u до √u. Для 64-битных ключей это 6 уровней против десятков у дерева сравнений; цена - константы и память, которую спасает заведение кластеров по требованию.

Комментарии

Пока нет комментариев. Будь первым!

Оставить комментарий

Комментарий появится после проверки. Email не публикуется. Войти, чтобы не вводить имя каждый раз.