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

# теория · шаг 1 из 6

Очередь с приоритетами: контракт и вставка в кучу

Коротко

Куча уже встречалась в разделе про сортировки - как способ отсортировать массив на месте. Здесь она нужна другой стороной: как рабочая СТРУКТУРА, которая живёт долго и к которой обращаются миллионы раз. Очередь с приоритетами - это контракт: положить элемент, посмотреть экстремум, забрать экстремум. Куча закрывает его ценой O(log n) / O(1) / O(log n) плюс построение из готового массива за O(n). Новая по сравнению с сортировкой операция здесь ровно одна - ВСТАВКА: элемент кладут в конец массива (единственное место, где дерево остаётся полным) и просеивают вверх, пока он больше родителя. Один путь до корня, одно сравнение на уровень - вдвое дешевле просеивания вниз.

Если ты отличаешь контракт очереди с приоритетами от его реализаций, уверенно пишешь просеивание вверх и знаешь, почему оно вдвое дешевле просеивания вниз, листай до «Теперь сам».

Как думаешь, зачем разделять очередь и кучу

Прежде чем читать дальше, ответь: зачем вообще нужны два слова - «очередь с приоритетами» и «двоичная куча», - если на практике вторая почти всегда реализует первую? Подсказка: в следующей главе появятся биномиальная куча и куча Фибоначчи, а в разделе про графы - алгоритм Дейкстры, который формулируется через контракт и работает с любой реализацией, меняя лишь итоговую оценку времени. Разделение отделяет «что мне нужно» от «чем я за это плачу».

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

Регистратура и способ хранения карточек. Регистратура обещает: принять пациента и вызвать самого срочного. Это контракт - его видит пациент. Как именно карточки лежат внутри (стопкой, в картотеке по алфавиту, в хитром шкафу), дело регистратуры, и от этого зависит только скорость. Заменят шкаф - обещание не изменится. И наоборот: если пациент попросит «найдите мою карточку и поменяйте в ней срочность», выяснится, что обещание такого не включало и шкаф под такую просьбу не приспособлен.

Механизм: контракт, его цена и просеивание вверх

push(x)      - положить элемент               O(log n)
peek()       - посмотреть экстремум           O(1)
pop()        - забрать экстремум              O(log n)
build(a)     - собрать очередь из массива     O(n)
size(), empty()                               O(1)

Вставка - единственная операция кучи, которой не было в сортировке (там кучу строили целиком и только разбирали). Работает она зеркально просеиванию вниз:

push(x):
    a.append(x)                    # в конец: дерево остаётся полным, без дыр
    i = len(a) - 1
    пока i > 0 и a[(i-1)//2] < a[i]:
        поменять a[i] и a[(i-1)//2]
        i = (i-1)//2               # поднялись к родителю

Почему именно в конец, а не на «подходящее место»: полное дерево заполнено по уровням слева направо без дыр, и единственная позиция, куда можно добавить узел, не порвав форму, - следующая свободная ячейка массива. Форму чинить нечем, её сохраняют по построению; а порядок потом восстанавливают обменами вдоль одного пути.

422835 1422 931 50 50 встаёт восьмой ячейкой - ребёнком узла 14 50 меняется с 14 50 меняется с 28 50 меняется с 42 и встаёт в корень три обмена, по одному сравнению на уровень: O(log n)
Просеивание вверх: новый элемент поднимается по единственному пути от листа к корню, сравниваясь только с родителем. Соседние ветви не трогаются вовсе.

Правило

Очередь с приоритетами - контракт (push, peek, pop экстремума); двоичная куча - его реализация ценой O(log n) / O(1) / O(log n) и построением O(n). Вставка кладёт элемент в конец массива и просеивает ВВЕРХ, пока он больше родителя: не больше ⌊log₂ n⌋ обменов и ОДНО сравнение на уровень (против двух при просеивании вниз). Куча не ищет по ключу, не обходит по порядку и не сливается дёшево - в контракте этого нет.

Разбор: вставляем 50 и считаем работу

Куча [42, 28, 35, 14, 22, 9, 31]. Добавляем 50.

  1. В конец: [42, 28, 35, 14, 22, 9, 31, 50], индекс нового элемента i = 7.
  2. Родитель (7−1)//2 = 3, там 14. Сравнение 1: 14 < 50 - обмен. Массив: [42, 28, 35, 50, 22, 9, 31, 14], i = 3.
  3. Родитель (3−1)//2 = 1, там 28. Сравнение 2: 28 < 50 - обмен. Массив: [42, 50, 35, 28, 22, 9, 31, 14], i = 1.
  4. Родитель (1−1)//2 = 0, там 42. Сравнение 3: 42 < 50 - обмен. Массив: [50, 42, 35, 28, 22, 9, 31, 14], i = 0. Корень, стоп.

Три сравнения, три обмена. Заметь, чего НЕ произошло: числа 9, 31, 22 не сдвинулись и даже не участвовали в сравнениях - им безразлично, кто наверху. В отсортированном по убыванию массиве 50 встало бы в самое начало и сдвинуло ВСЕ семь элементов; здесь работа идёт вдоль одного пути длиной ⌊log₂ 8⌋ = 3.

Разбор: почему вверх дешевле, чем вниз

Обе операции идут по одному пути и обе O(log n), но константа у них разная. При просеивании ВВЕРХ на каждом уровне одно сравнение: элемент против родителя, второго кандидата нет. При просеивании ВНИЗ сравнений два: сначала левый ребёнок против правого (кто больше), затем победитель против родителя. Итого log₂ n против 2·log₂ n.

Вторая разница - в типичной длине пути, и она перевешивает первую. Элемент, вставляемый в случайном порядке, почти наверняка остановится через шаг-другой: чтобы подняться на k уровней, надо оказаться больше k своих предков, то есть быть наибольшим среди всех элементов своего пути; при случайном порядке вставок шанс на это падает примерно вдвое с каждым уровнем, а сумма 1/2 + 1/4 + 1/8 + ... даёт в среднем меньше двух обменов на вставку. Поэтому n случайных вставок обходятся примерно в O(n), хотя каждая по отдельности оценивается как O(log n). А при извлечении наоборот: в корень мы ставим ПОСЛЕДНИЙ элемент - он лежал у самого дна и потому, скорее всего, мал, ему предстоит опуститься почти до низа. Худший случай для pop - не редкая неудача, а обычный ход дел. Отсюда практический вывод: очередь с преобладанием вставок дешевле, чем кажется по асимптотике, а очередь с преобладанием извлечений - ровно такая, как обещает оценка.

И отдельно про n ВОЗРАСТАЮЩИХ вставок (каждая новая больше всех предыдущих): тут каждый элемент всплывает до корня, и получаются честные Θ(n·log n). Именно поэтому, когда все данные уже есть, кучу строят целиком за O(n) (heapify), а не наполняют циклом из push.

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

Первое: вставка «на подходящее место» в середину массива - дерево перестаёт быть полным, и арифметика индексов начинает указывать в пустоту. Второе: забытая остановка на i > 0 - при i = 0 формула (0−1)//2 в Python даёт −1, то есть последний элемент массива, и элемент начинает сравниваться сам с хвостом кучи; ошибка не падает, а тихо портит структуру. Третье: push в цикле вместо build на готовых данных - потеря линейности. Четвёртое: ожидание, что куча после вставок окажется отсортированной: [50, 42, 35, 28, 22, 9, 31, 14] - корректная куча, в которой 9 стоит перед 31. Пятое: pop на пустой очереди - самый частый источник падений в реальном коде, пустоту проверяют до чтения a[0].

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

Контракт «дай самое срочное прямо сейчас» встречается чаще, чем кажется: планировщики задач и потоков, таймеры и колёса событий, очереди сообщений с приоритетами, A* и Дейкстра в навигации и в играх, слияние k отсортированных потоков (шарды базы, лог-файлы, внешняя сортировка), top-k по частоте в аналитике, сжатие Хаффмана. Полезная привычка при выборе структуры - задать себе два вопроса: какие операции мне ДЕЙСТВИТЕЛЬНО нужны (только экстремум или ещё поиск, слияние, изменение ключа) и сколько будет каждой. Нужен только экстремум - двоичная куча, и сложнее искать не надо. Нужны частые слияния или дешёвое уменьшение ключа - следующие два урока и следующая глава. В стандартных библиотеках контракт закрыт именно кучей: heapq в Python, PriorityQueue в Java, std::priority_queue в C++.

Теперь сам

В пустую max-кучу кладут по одному числа 10, 20, 5, 30. Выпиши массив после каждой вставки и посчитай обмены. Сколько обменов дало бы построение из готового массива [10, 20, 5, 30]?

Ответ. Вставка 10: массив [10], i = 0 - корень, обменов 0. Вставка 20: [10, 20], i = 1, родитель 0 (10), 10 < 20 - обмен, массив [20, 10] (1 обмен). Вставка 5: [20, 10, 5], i = 2, родитель (2−1)//2 = 0 (20), 20 > 5 - обмена нет, массив [20, 10, 5]. Вставка 30: [20, 10, 5, 30], i = 3, родитель (3−1)//2 = 1 (10), 10 < 30 - обмен, массив [20, 30, 5, 10], i = 1; родитель 0 (20), 20 < 30 - обмен, массив [30, 20, 5, 10], i = 0, стоп. Итого три обмена, куча [30, 20, 5, 10]. Построение из готового массива [10, 20, 5, 30]: просеиваем вниз узлы от 4//2 − 1 = 1 к нулю. i = 1 (20): единственный ребёнок 3 (30), 30 > 20 - обмен, массив [10, 30, 5, 20]. i = 0 (10): дети 30 и 5, больший 30, 30 > 10 - обмен, массив [30, 10, 5, 20], спускаемся в i = 1: ребёнок 3 (20), 20 > 10 - обмен, массив [30, 20, 5, 10]. Итого тоже три обмена: на четырёх элементах разницы ещё НЕТ, и это стоит увидеть своими глазами - разрыв между Θ(n) и Θ(n·log n) появляется только с ростом n. Замер на движке курса (256 чисел по возрастанию, худший вход для вставок): построение делает 249 обменов, цикл вставок - 1546. Обрати внимание и на то, что итоговая куча совпала, хотя пути были разные: куча не единственна для данного набора.

Главное

Очередь с приоритетами - это контракт: push, peek, pop экстремума (плюс build из массива). Двоичная куча - его реализация ценой O(log n) / O(1) / O(log n) и O(n) на построение. Держать контракт и реализацию врозь нужно затем, что алгоритмы (Дейкстра, Прим, Хаффман) формулируются через контракт, а время работы зависит от выбранной реализации. Вставка кладёт элемент в КОНЕЦ массива - единственную позицию, где полное дерево остаётся полным, - и просеивает вверх вдоль одного пути: одно сравнение на уровень против двух при просеивании вниз. На случайных данных вставка в среднем стоит константу (подъём на k уровней вдвое менее вероятен с каждым уровнем), а вот на возрастающем входе - честный log n каждая, поэтому готовый массив превращают в кучу построением за O(n), а не циклом вставок. Чего в контракте нет: поиска по ключу, обхода по порядку, дешёвого слияния и изменения приоритета изнутри - об этом следующие уроки.

Комментарии

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

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

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