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

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

Стабильность, min-куча и кучи с большим ветвлением

Коротко

Три вещи, которые приходится решать при реальном использовании очереди. Первая: куча НЕ стабильна - при равных приоритетах порядок прихода не сохраняется; лечится ключом-парой (приоритет, счётчик), и тот же приём заодно спасает от падения при сравнении объектов. Вторая: min-куча из max-кучи получается отрицанием ключа или сменой знака в сравнении - отдельная структура не нужна. Третья: ветвление не обязано быть двойкой. В d-арной куче у узла d детей (d·i+1 .. d·i+d, родитель (i−1)//d), высота падает до log_d n - вставка и уменьшение ключа становятся вдвое дешевле при d = 4, зато извлечение сравнивает d детей на каждом уровне. По числу сравнений оптимум около d = 3, а по реальному времени выигрывает d = 4: четыре соседних ребёнка ложатся в одну кеш-линию.

Если ты знаешь приём (приоритет, счётчик), умеешь считать высоту и цену операций d-арной кучи и понимаешь, почему на практике берут d = 4, листай до «Теперь сам».

Как думаешь, что вернётся первым

В min-очередь кладут (5, "почта"), (2, "платёж"), (5, "отчёт"). Прежде чем читать дальше, ответь: что вернёт очередь вторым - почту или отчёт? Приоритеты у них равны, и честный ответ - звучит так: предсказать нельзя. Случайности здесь нет - порядок полностью определён историей операций и тем, как легли обмены при просеивании, - но контракт очереди его не гарантирует, и от версии к версии он может смениться. Для очереди, которая обслуживает людей или задачи одной важности, такой ответ не годится, и вопрос «а чем это чинить» - не теоретический.

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

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

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

min-куча          : класть −x (вернуть −результат) ИЛИ поменять знак сравнения
                    в просеиваниях; отдельной структуры не нужно

стабильность      : ключ = (приоритет, номер_прихода)
                    сравнение пар: сначала приоритет, при равенстве - счётчик
                    (приоритет, счётчик, объект) - ещё и защита от сравнения объектов

d-арная куча      : дети узла i - ячейки d·i+1 ... d·i+d, родитель (i−1)//d
                    высота log_d n
                    push / decrease_key: log_d n сравнений
                    pop: d·log_d n сравнений (на каждом уровне ищем лучшего из d)

Арифметика ветвления стоит того, чтобы её проговорить. Для n = 10⁶: у двоичной кучи log₂ 10⁶ ≈ 20 уровней, у четырёхарной - log₄ 10⁶ ≈ 10. Вставка становится вдвое короче. А вот извлечение: d·log_d n для d = 2 даёт 2·20 = 40 сравнений, для d = 4 - 4·10 = 40, ровно столько же. Функция d/ln d имеет минимум при d = e ≈ 2.72, то есть по числу сравнений НА ИЗВЛЕЧЕНИИ чуть лучше всех d = 3 (около 38 сравнений на том же миллионе); на смешанном профиле, где есть и вставки, картина другая - её посчитаем ниже. Разница между двойкой, тройкой и четвёркой мала, и решает не она, а память: четыре ребёнка лежат в массиве ПОДРЯД, то есть читаются одной-двумя кеш-линиями вместо четырёх разбросанных чтений, а индексы считаются сдвигами. Оговорка: блок детей в укладке d·i+1 .. d·i+d не выровнен по границе линии сам собой, и реализации, которым это важно, сдвигают массив на несколько ячеек. Поэтому в реальных реализациях (например, в очередях внутри некоторых планировщиков и в библиотеках для Дейкстры на больших графах) встречается именно d = 4.

Правило

Куча не стабильна - при равных приоритетах порядок прихода не сохраняется; ключ-пара (приоритет, счётчик) делает её стабильной и заодно не даёт сравнению дойти до объектов. Min-куча - это max-куча с отрицанием ключа или обратным сравнением. В d-арной куче дети узла i - это d·i+1 .. d·i+d, родитель (i−1)//d, высота log_d n: вставка и уменьшение ключа дешевеют в log₂ d раз, извлечение стоит d·log_d n сравнений. Минимум по сравнениям НА ИЗВЛЕЧЕНИИ - около d = 3, на смешанном профиле обычно выигрывает d = 4, а на реальном железе - оно же из-за кеша.

Разбор: делаем очередь стабильной

Заявки с приоритетами (2, "A"), (1, "B"), (2, "C"), (1, "D") приходят в этом порядке, извлекаем по возрастанию приоритета. Наивная min-куча вернёт сначала обе единицы, но B и D в непредсказуемом порядке. Делаем ключ парой, номера по порядку поступления: A → (2, 1), B → (1, 2), C → (2, 3), D → (1, 4). Теперь сравнение пар лексикографическое, и порядок извлечения определён однозначно: (1, 2) - B, (1, 4) - D, (2, 1) - A, (2, 3) - C.

Второй эффект того же приёма чаще всего и оказывается причиной, по которой его добавляют. Если в очередь кладут пары (приоритет, объект), а у объектов не определено сравнение, то при РАВНЫХ приоритетах интерпретатор полезет сравнивать сами объекты и упадёт - причём не сразу, а на первом входе, где приоритеты совпали. Счётчик посередине - (приоритет, счётчик, объект) - гарантирует, что до объекта сравнение никогда не дойдёт: два счётчика не бывают равны.

Считаем вместе: четырёхарная куча на миллионе элементов

Пусть в очереди всё время около n = 10⁶ элементов, а за время работы её просят об этом: 10 миллионов УМЕНЬШЕНИЙ КЛЮЧА и 1 миллион извлечений. Так выглядит честная Дейкстра со словарём позиций: вершин в очереди V = 10⁶, извлекают каждую по разу, а улучшать оценки приходится до одного раза на ребро. Уменьшение ключа просеивает элемент ВВЕРХ - той же ценой, что вставка. Считаем по худшему случаю: в отличие от случайной вставки из первого урока, здесь элемент всплывает целенаправленно, и короткие подъёмы обещать нельзя.

d = 2:  высота 20
        уменьшения ключа: 10 млн × 20     = 200 млн сравнений
        извлечения:        1 млн × 2 × 20 =  40 млн сравнений
        итого ≈ 240 млн

d = 4:  высота 10
        уменьшения ключа: 10 млн × 10     = 100 млн сравнений
        извлечения:        1 млн × 4 × 10 =  40 млн сравнений
        итого ≈ 140 млн

Экономия - в 1,7 раза (240 против 140, то есть минус 42%), и вся она на подъёмах, потому что именно они выигрывают от низкого дерева, а извлечения остаются при своих. Отсюда правило выбора: чем сильнее перекос в сторону вставок и уменьшений ключа, тем больше выгодно ветвление. При обратном перекосе (извлечений столько же, сколько вставок) разница между d = 2 и d = 4 по сравнениям исчезает, и остаётся только выигрыш по кешу. Заметь, что асимптотика при этом не изменилась ни на букву: log_d n и log₂ n отличаются константой log₂ d. Это тот случай, когда решение принимают по константам, и это нормальная инженерная работа, а не «микрооптимизация».

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

Первое: при отрицании ключа для min-кучи забыть отрицать счётчик - стабильность превратится в свою противоположность, вперёд полезет тот, кто пришёл позже. Второе: считать, что (приоритет, объект) безопасно, - пока приоритеты различны, всё работает, а на первом совпадении программа падает или сравнивает объекты неожиданным образом. Третье: в d-арной куче перепутать формулы индексов - d·i+1 .. d·i+d и (i−1)//d; двоичные 2i+1 и (i−1)//2 - их частный случай при d = 2, и смешение источников даёт кучу, которая теряет элементы. Четвёртое: брать большое d (16, 32) «чтобы дерево было ещё ниже» - извлечение становится линейным по d на каждом уровне, и выигрыш съедается. Пятое: ждать стабильности от библиотечной очереди по умолчанию - её не дают ни heapq, ни std::priority_queue. Разница лишь в том, что документация heapq говорит об этом прямо и предлагает тот самый приём со счётчиком, а стандарт C++ про стабильность priority_queue просто молчит - её там нет и обещано не было.

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

Стабильность нужна везде, где очередь обслуживает людей или задачи равной важности: заявки в техподдержке, задачи в планировщике, сообщения одного приоритета. Ключ-пара - стандартный ответ, и в документации heapq он приведён как рекомендуемый приём. Выбор ветвления - рабочий инструмент для больших графов: если Дейкстра на миллионах рёбер не укладывается во время, переход на четырёхарную кучу даёт заметное ускорение без смены алгоритма. Более общий навык, который стоит унести: структура данных - не монолит, у неё есть ПАРАМЕТРЫ (направление сравнения, состав ключа, ветвление), и настраивают их под профиль операций. Прежде чем менять алгоритм, посчитай, сколько у тебя операций каждого вида, - иногда ответ в том, чтобы подкрутить константу, а не переписывать всё.

Теперь сам

Профиль: n = 10⁶ элементов, 2 миллиона вставок и 2 миллиона извлечений. Посчитай сравнения для d = 2 и d = 4 и скажи, какое ветвление выгоднее по сравнениям.

Ответ. Высоты: log₂ 10⁶ ≈ 20, log₄ 10⁶ ≈ 10. Для d = 2: вставки 2 млн × 20 = 40 млн сравнений; извлечения 2 млн × 2 × 20 = 80 млн; итого 120 млн. Для d = 4: вставки 2 млн × 10 = 20 млн; извлечения 2 млн × 4 × 10 = 80 млн; итого 100 млн. Выгоднее d = 4, но всего на одну шестую - против почти двукратной экономии в разборе выше, где вставок было в десять раз больше, чем извлечений. Вывод, ради которого считали: выигрыш ветвления живёт ЦЕЛИКОМ на вставках и уменьшениях ключа; извлечения к нему равнодушны, потому что d·log_d n при d = 2 и d = 4 равны в точности. Если бы профиль состоял из одних извлечений, разницы по сравнениям не было бы вовсе - осталась бы только разница по кешу. И отдельно проверь себя: при d = 3 извлечения стоили бы 2 млн × 3 × log₃ 10⁶ ≈ 2 млн × 3 × 12.6 ≈ 76 млн, вставки 2 млн × 12.6 ≈ 25 млн, итого около 101 млн - практически столько же, сколько у четвёрки, и это подтверждает, что минимум функции d/ln d пологий: между тройкой и четвёркой выбирают не по сравнениям, а по памяти.

Главное

У кучи есть параметры, и их настраивают под задачу. Стабильность: куча её не даёт, но ключ-пара (приоритет, счётчик) делает порядок при равных приоритетах предсказуемым и заодно не даёт сравнению дойти до объектов (третьим элементом кортежа можно класть что угодно). Min-куча - это max-куча с отрицанием ключа или обратным сравнением, отдельной структуры не нужно. Ветвление: в d-арной куче дети узла i - это d·i+1 .. d·i+d, родитель (i−1)//d, высота log_d n. Вставка и уменьшение ключа дешевеют в log₂ d раз, а извлечение стоит d·log_d n сравнений - и эта величина при d = 2 и d = 4 одинакова, минимум достигается около d = 3. Значит весь выигрыш ветвления живёт на вставках: чем больше перекос в их сторону, тем больше выгодно d = 4, которое вдобавок кладёт четырёх детей в одну кеш-линию. Асимптотика при этом не меняется - решение принимают по константам, и это нормальная инженерная работа.

Комментарии

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

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

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