Занятие 25. Двоичная куча и heapsort
Финальный семинар и последняя структура данных семестра. Задача, ради которой она существует: многократно доставать самый срочный элемент из меняющегося набора — планировщики, таймеры, Дейкстра второго семестра. Несортированный вектор дёшев на вставке и дорог на минимуме, отсортированный — наоборот, set платит узлами и кэш-промахами (занятие 17: ×11). Куча — золотая середина: логарифм на всё, и притом в непрерывном массиве. Замеры реальные (Apple M-серия, clang++ -O2).
Куча и её инвариант
Двоичная куча (min-heap)
Массив, читаемый как полное двоичное дерево по уровням: родитель элемента $i$ — элемент $(i-1)/2$, дети — $2i+1$ и $2i+2$. Инвариант: значение каждого элемента не меньше значения родителя ⇒ в корне a[0] — минимум.
Никаких указателей — сплошная память (занятие 8). Дерево всегда полное (заполняется по уровням слева направо), поэтому высота $\lfloor \log_2 n \rfloor$: у $10^7$ элементов — всего 23. Весенний конспект нумерует с 1 (родитель $\lfloor i/2 \rfloor$, дети $2i$, $2i+1$) — формулы чуть красивее, суть та же.
insert: новичок всплывает
Новый элемент кладём в конец массива — форма дерева цела, сломаться могло только свойство кучи на пути вверх. siftUp чинит: каждый обмен поднимает на уровень, шагов не больше высоты — O(log n). Доказательство корректности — индукция по пути, как в весеннем конспекте.
extractMin: последний тонет
Минимум в корне; дырку затыкаем последним элементом (форма цела) и топим его, меняя с меньшим из детей — новый родитель обязан быть не больше обоих. Путь вниз ≤ высоты ⇒ O(log n).
Построение за O(n)
Половина вершин — листья с высотой 0: им ремонт не нужен. Дорогое просеивание достаётся единицам вершин у корня, и сумма работы сворачивается геометрической серией (занятия 6, 15, 23):
$$\sum_{h \ge 0} \frac{n}{2^{h+1}} \cdot h \le 2n.$$Живой счётчик подтверждает теорию:
Замер: три способа собрать кучу из 10⁷
Правило: все данные на руках — build; поступают по одному — вставки. Экономика «инвестиции» из занятия 15. У STL кучные примитивы живут прямо поверх вашего вектора: make_heap / push_heap / pop_heap.
Heapsort: гарантия n log n — и её цена
Инвариант — родня сортировки выбором (занятие 19): готовый суффикс окончателен, но выбор максимума стоит O(log n), а не O(n). Итого гарантированные $n \log n$ на любом входе, in-place, без буфера; нестабилен. Killer-входов у heapsort нет — за это его и держат страховкой.
Виновник знакомый — память: siftDown прыгает $i \to 2i+1$, с каждым уровнем шаг удваивается, кэш и предвыборка бессильны (та же болезнь, что у дерева и бинпоиска — занятия 8, 12, 17). Поэтому introsort устроен именно так (занятие 21): быстрый quicksort в главной роли, heapsort — запасной парашют: дорог, но раскрывается всегда.
Очередь с приоритетами
Адаптер над вектором с кучей внутри: push/top/pop — и всё, ни обхода, ни итераторов. Сюрприз № 1: по умолчанию это max-куча; минимум требует std::greater. Компаратор — знакомый третий параметр (лямбды занятия 14, строгий порядок занятия 17).
Топ-k потока: куча размера k
Сюрприз № 2: для топ-k наибольших нужна min-куча — её корень является худшим элементом текущего топа, именно его сравниваем с новичком. O(n log k) времени и O(k) памяти: поток можно не хранить вообще, в отличие от nth_element (занятие 23). Здесь куча выиграла и по чистому времени — ×7.
Изменить произвольный элемент: два пути
- Индексированная куча: хранить
pos[id]— позицию каждого элемента (обновляя в каждом swap); тогдаdecreaseKey(id, x)— записать значение и одинsiftUp, O(log n). Так живёт Дейкстра во втором семестре. - Ленивое удаление: класть в кучу новую версию элемента, а устаревшие выбрасывать при
extractMin. Куча растёт, зато код тривиален — амортизация (занятие 10) всё оплачивает.
std::priority_queue не умеет ни того, ни другого — контракт узкий. Нужен decreaseKey — своя куча (ДЗ) или ленивая схема поверх стандартной.
Задачи семинара
-
Build на бумаге. Постройте min-кучу из [5, 3, 8, 1, 9, 2]: siftDown от i = 2 к корню, массив после каждого шага.
-
Куча ли это? [1, 3, 2, 5, 9, 8] · [2, 1, 3] · [1, 2, 3, 4, 5] · [7].
-
k-й максимум потока. После каждого элемента уметь называть k-й по величине. Какая куча?
Домашнее задание (сдача через Git)
- MinHeap: класс с insert / getMin / extractMin / buildHeap; стресс-тест против
std::priority_queueна $10^5$ случайных операций. - Heapsort через вашу кучу + строка в таблице замеров занятия 21 (merge / quick / std::sort / heapsort) на своей машине.
- Топ-k потока: чтение чисел из файла без загрузки целиком, куча размера k; сверка с nth_element.
- isHeap за O(n) + тесты на граничных случаях (пустой, один элемент, равные).
- * Поток медиан: после каждого элемента печатать текущую медиану — max-куча левой половины + min-куча правой с балансировкой размеров.
Это последнее ДЗ семестра — и лучший тренажёр перед РК2: следующее занятие — рубежный контроль № 2 по материалу занятий 14–25.