«Плохие программисты беспокоятся о коде. Хорошие программисты беспокоятся о структурах данных и их взаимосвязях», — Линус Торвальдс

Оглавление

Глава 1: Разрыв в производительности

Глава 2: Иерархия памяти

Глава 3: Бенчмаркинг и профилирование

Глава 4: Массивы и локальность кэша

Глава 5: Связанные списки — убийцы кэша

Глава 6: Стеки и очереди

Глава 7: Хэш-таблицы и конфликты кэша

Глава 8: Динамические массивы и управление памятью

Глава 9: Двоичные деревья поиска

Глава 10: B-деревья и деревья, эффективно использующие кэш

Глава 11: Префиксные деревья и базисные деревья

Глава 12: Кучи и очереди с приоритетом

Споры о планировщике

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

  • Вставлять новые задачи с приоритетом (O(log n))

  • Запрашивать задачу с наибольшим приоритетом (O(1))

  • Удалять задачу с наибольшим приоритетом (O(log n))

Кто-то предложил: «Давайте используем отсортированный массив». Но вставка будет занимать O(n) — придётся сдвигать элементы.

Кто-то ещё сказал: «Возьмём связанный список». Однако поиск наибольшего выполняется за O(n) — необходимо сканировать весь список.

Третий вспомнил о двоичном дереве поиска. Но из Главы 9 мы уже знаем, что BST ужасно ведут себя с кэшем.

Споры продолжались, пока кто-то не упомянул двоичные кучи. Покончить с разногласиями позволили результаты бенчмарка:

$ perf stat -e cache-misses,cycles ./scheduler_heap
  Performance counter stats:
    45,000 cache-misses
 1,200,000 cycles
 
$ perf stat -e cache-misses,cycles ./scheduler_bst
  Performance counter stats:
   180,000 cache-misses
 4,800,000 cycles

Куча оказалась в 4 раза быстрее, чем красно-чёрное дерево, а промахов кэша при этом было в 4 раза меньше.

Почему? Кучи хранятся в массивах, поэтому имеют замечательную локальность кэша.


Что нам рассказывают в учебниках

Двоичная куча — это завершённое двоичное дерево, в котором каждый родительский элемент больше (или не меньше) своих дочерних элементов.

Пример max-heap:

        90
       /  \
      70   50
     / \   / \
    40 30 20 10

Свойства:

  • Завершённое дерево: заполнены все уровни, за исключением, возможно, последнего, который заполняется слева направо

  • Свойство кучи: родительский элемент ≥ дочернего (max-heap) или родительский ≤ дочернего (min-heap)

  • Операции:

    • Вставка: O(log n) — добавление в конец, пузырьковое поднятие вверх

    • Extract max: O(log n) — удаление корня, пузырьковое опускание вниз

    • Peek max: O(1) — просто чтение корня

Что говорят в учебниках:

  • Идеально подходит для очередей с приоритетом

  • Вставка и удаление за O(log n)

  • Доступ к max/min за O(1)

  • Простота реализации

Выглядит здорово! Но не всё так просто...


Проверка реальностью: кучи на основе массивов

Самое важное наблюдение: можно хранить двоичную кучу в массиве при помощи индексной арифметики.

Куча как массив:

Индекс:   0   1   2   3   4   5   6
Массив: [90][70][50][40][30][20][10]

Дерево:
        90 (индекс 0)
       /  \
      70   50 (индексы 1, 2)
     / \   / \
    40 30 20 10 (индексы 3, 4, 5, 6)

Индексная арифметика:

  • Родительский элемент узла i: (i - 1) / 2

  • Левый дочерний элемент узла i: 2i + 1

  • Правый дочерний элемент узла i: 2i + 2

Никаких указателей! Только индексы массива.

Вот реализация, которую я использовал:

typedef struct {
    int *data;
    int size;
    int capacity;
} heap_t;

void heap_insert(heap_t *heap, int value) {
    // Добавление в конец
    heap->data[heap->size] = value;
    int i = heap->size;
    heap->size++;
    
    // Поднятие вверх
    while (i > 0) {
        int parent = (i - 1) / 2;
        if (heap->data[i] <= heap->data[parent]) break;
        
        // Обмен местами с родителем
        int temp = heap->data[i];
        heap->data[i] = heap->data[parent];
        heap->data[parent] = temp;
        
        i = parent;
    }
}

int heap_extract_max(heap_t *heap) {
    int max = heap->data[0];
    
    // Перемещение последнего элемента в корень
    heap->size--;
    heap->data[0] = heap->data[heap->size];
    
    // Опускание вниз
    int i = 0;
    while (2 * i + 1 < heap->size) {
        int left = 2 * i + 1;
        int right = 2 * i + 2;
        int largest = i;
        
        if (left < heap->size && heap->data[left] > heap->data[largest]) {
            largest = left;
        }
        if (right < heap->size && heap->data[right] > heap->data[largest]) {
            largest = right;
        }
        
        if (largest == i) break;
        
        // Обмен местами с самым большим дочерним элементом
        int temp = heap->data[i];
        heap->data[i] = heap->data[largest];
        heap->data[largest] = temp;
        
        i = largest;
    }
    
    return max;
}

Поведение кэша:

  • Все данные находятся в сплошном массиве

  • При пузырьковом поднятии/опускании доступ выполняется к соседним элементам

  • Превосходная пространственная локальность

Именно поэтому куча оказалась в 4 раза быстрее красно-чёрного дерева. Никакого следования по указателям, только индексация массива.


Результаты бенчмарков

Я сравнил планировщик на основе кучи с другими структурами данных:

Датасет: 10 000 задач со случайными приоритетами
Тест: 100 000 операций вставок + извлечения max

Красно-чёрное дерево:
  Такты на операцию: 4 800
  Промахи кэша: 18,0

Двоичная куча (на основе массива):
  Такты на операцию: 1 200
  Промахи кэша: 4,5
  Ускорение: 4,0×

Отсортированный массив:
  Вставка: 12 000 тактов (сдвиг за O(n))
  Извлечение max: 100 тактов (извлечение из конца за O(1))
  В среднем: 6 050 тактов на операцию

При такой рабочей нагрузке куча оказалась явным победителем.

Почему куча побеждает:

  1. На основе массива: все данные находятся рядом, превосходная локальность кэша

  2. Сбалансированные операции: и вставка, и извлечение максимального выполняются за O(log n)

  3. Нет следования по указателям: просто индексация массивов

Почему проигрывает отсортированный массив:

  • Вставка за O(n), потому что нужно сдвигать элементы

  • При нагрузках с большим количеством вставок они преобладают


Оптимизация с учётом кэша: d-арные кучи

У двоичных куч есть проблема: с ростом кучи операции пузырькового поднятия и опускания разбрасываются по куче.

В случае кучи с 1 миллионом элементов:

  • Высота: log₂(1M) ≈ 20 уровней

  • Пузырьковое опускание: 20 промахов кэша (каждый уровень — это другая линия кэша)

Решение: использовать d-арную кучу, в которой каждый узел имеет d дочерних элементов, а не 2.

4-арная куча:

Индекс:  0   1   2   3   4   5   6   7   8   9  10  11  12
Массив: [90][70][60][50][40][65][55][45][30][35][25][20][15]

Дерево:
           90 (индекс 0)
        /  |  |  \
      70  60  50  40 (индексы 1, 2, 3, 4)
     /|\ /|\ /|\ /|\
    ... (индексы 5-20)

Индексная арифметика (d-арная куча):

  • Родитель узла i: (i - 1) / d

  • Первый дочерний элемент узла i: d × i + 1

  • Последний дочерний элемент узла i: d × i + d

Компромиссы:

  • Более короткое дерево: высота = logd(n), а не log₂(n)

  • Больше сравнений на уровень: необходимо сравнивать d дочерних элементов, а не 2

  • Улучшенное поведение кэша: меньше уровней = меньше промахов кэша

Я протестировал разные значения d:

Датасет: 1 000 000 элементов
Тест: 100 000 операций вставки + извлечения максимального

Двоичная куча (d=2):
  Высота: 20 уровней
  Такты на операцию: 2 400
  Промахи кэша: 8,5

4-арная куча (d=4):
  Высота: 10 уровней
  Такты на операцию: 1 600
  Промахи кэша: 4,2
  Ускорение: 1,5×

8-арная куча (d=8):
  Высота: 7 уровней
  Такты на операцию: 1 400
  Промахи кэша: 2,8
  Ускорение: 1.,7×

16-арная куча (d=16):
  Высота: 5 уровней
  Такты на операцию: 1,500
  Промахи кэша: 2.1
  Ускорение: 1,6× (эффективность уменьшается)

Идеальный вариант: d=8 для большинства нагрузок. Уменьшает промахи кэша в три раза, при этом сравнений на уровень не так много.


Пример из реального мира: планировщик CFS ядра Linux

В Completely Fair Scheduler (CFS) ядра Linux используется красно-чёрное дерево, а не куча. Почему?

Потому что планировщику требуется не просто получение задачи с наивысшим приоритетом:

  • Запросы по диапазонам: находить все задачи с приоритетом > X

  • Произвольное удаление: удаление конкретной задачи (не просто максимальной)

  • Справедливое планирование: отслеживание виртуальной среды исполнения, а не только приоритета

Кучи неспособны делать это эффективно. Они оптимизированы под одну задачу: операции над очередями с приоритетом (вставка, извлечение максимального).

Но для более простых планировщиков (например, во встраиваемых RTOS) кучи подходят идеально.

Пример: во FreeRTOS используется просто планировщик на основе приоритетов со структурой, близкой к куче (хоть и реализованной в виде связанного списка из-за малого количества задач).


Когда использовать кучи

После выбора куч для нашего планировщика RTOS я понял, что это было верным решением:

1. Очереди с приоритетом

Если вам нужны:

  • Вставка с приоритетом: O(log n)

  • Получение максимального/минимального: O(1)

  • Удаление максимального/минимального: O(log n)

то используйте кучу. Это стандартная структура данных для очередей с приоритетом.

Примеры:

  • Планировщики задач

  • Очереди событий

  • Алгоритм кратчайшего пути Дейкстры

  • Код Хаффмана

2. Задачи Top-K

Нахождение K наибольших/наименьших элементов в потоке:

  • Хранение кучи минимальных размера K

  • Если новый элемент больше, чем минимальный в куче, то заменяем минимальный

  • Конечная куча содержит K наибольших элементов

Время: O(n log k) вместо O(n log n) полной сортировки

3. Хранение медианы

Хранение медианы потока при помощи двух куч:

  • Куча максимальных для меньшей половины

  • Куча минимальных для большей половины

  • Медиана — это корень большей кучи (или среднее от обоих корней)

Время: O(log n) на вставку, O(1) на получение медианы

4. Когда НЕ использовать кучи

Не используйте кучи для:

  • Произвольного удаления: удаления некорневого элемента выполняется за O(n)

  • Поиска: нахождение произвольного элемента выполняется за O(n)

  • Запросов по диапазонам: невозможно эффективно находить все элементы в диапазоне

  • Итераций по отсортированному: кучи не обеспечивают полностью отсортированный порядок

Для этого используйте сбалансированное BST или B-дерево.


Подведём итог

Спор о планировщике позволили разрешить числа. Двоичная куча обеспечила производительность в четыре раза выше, чем красно-чёрное дерево, при этом имея в четыре раза меньше промахов кэша. Благодаря тому, что куча основана на массиве, обеспечивается локальность кэша, недостижимая для деревьев на основе указателей.

Ключевые наблюдения:

  1. Кучи основаны на массивах. Никаких указателей, никакого следования по указателям, только индексация массивов. Это обеспечивает превосходную локальность кэша.

  2. Завершённые двоичные деревья идеально умещаются в массивы. Индексная арифметика (родитель = (i-1)/2, дочерний элемент = 2i+1 и 2i+2) проста и удобна для кэша.

  3. d-арные кучи снижают промахи кэша. Увеличив коэффициент ветвления до 4 или 8, можно снизить высоту дерева и промахи кэша в 2-3 раза

  4. Кучи предназначены для очередей с приоритетом. Если вам нужны вставки, извлечение максимального и поиск максимального, то кучи подходят идеально. Но они не позволяют эффективно выполнять произвольные удаления и запросы по диапазонам.

  5. Важен баланс. У двоичных куч (d=2) меньше сравнений на уровень. У 8-арных куч (d=8) меньше промахов кэша. Оптимальный выбор зависит от конкретной нагрузки.

Показатели из нашего планировщика RTOS:

  • Красно-чёрное дерево: 4 800 тактов на операцию, 18,0 промахов кэша

  • Двоичная куча: 1 200 тактов на операцию, 4,5 промаха кэша

  • 8-арная куча: 1 400 тактов на операцию, 2,8 промаха кэша

Очевидно, что для нашего планировщика оптимальной была двоичная куча. Она простая, быстрая и удобная для кэша.

В следующей главе мы поговорим о графах и их представлениях в памяти.

Комментарии (0)