# теория · шаг 2 из 6
Уменьшение ключа и удаление: чего куче не хватает
Коротко
Дейкстре и Приму мало «положить и забрать»: им нужно уменьшить приоритет уже лежащего в очереди
элемента. Куча этого не умеет по одной причине - она не знает, ГДЕ элемент лежит: поиск по ключу в
куче стоит O(n). Лечится двумя способами, и выбирают между ними по-инженерному, а не по
красоте. Первый: вести рядом словарь pos[элемент] = индекс и обновлять его при КАЖДОМ обмене -
тогда decrease_key стоит O(log n), но код кучи усложняется и ошибка в обновлении портит
структуру молча. Второй, ленивый: ничего не менять, а просто положить в очередь новую запись с
лучшим приоритетом, а устаревшие отбрасывать при извлечении по внешней отметке. Куча растёт до
числа ВСТАВОК, зато код остаётся тривиальным - так чаще всего и пишут Дейкстру.
Если ты умеешь и вести словарь позиций с обновлением при обменах, и писать ленивый вариант с отбрасыванием устаревших записей, и объяснить, чем платит каждый, листай до «Теперь сам».
Как думаешь, почему это вообще проблема
В очереди лежит вершина v с оценкой расстояния 17. Алгоритм нашёл путь покороче - 9. Прежде чем
читать дальше, прикинь, как это сделать в куче. Пройти массив и найти v - O(n), и весь смысл
логарифмической очереди пропал. Держать элементы отсортированными - это уже не куча. Вот в этом
зазоре и живёт вся тема: структура, отвечающая на вопрос «кто самый срочный», не отвечает на
вопрос «где лежит вот этот конкретный».
На что это похоже
Стопка заявок, разложенная так, что самая срочная всегда сверху. Пришло уточнение: заявка Петрова стала срочнее. Чтобы переложить её, надо сначала НАЙТИ её в стопке - а стопка не алфавитная, придётся перебрать. Два выхода. Первый: завести рядом тетрадку «Петров - страница 43» и честно править её при каждом перекладывании листов; тетрадка стоит труда и рвётся, если забыть запись. Второй: не искать вовсе - положить сверху новый лист с уточнённой срочностью, а старый, когда до него дойдёт очередь, узнать по отметке и выбросить. Второй способ тратит бумагу и экономит нервы.
Механизм: словарь позиций против ленивого удаления
Честный способ. Куча хранит ещё и pos - куда уехал каждый элемент:
# min-куча, элементы - номера вершин, ключи - в массиве key[]
обменять(i, j):
heap[i], heap[j] = heap[j], heap[i]
pos[heap[i]] = i # ОБА обновления обязательны,
pos[heap[j]] = j # и делать их надо внутри обмена, а не рядом с ним
decrease_key(v, new):
если new >= key[v]: выход # уменьшать можно только вниз
key[v] = new
просеять_вверх(pos[v]) # знаем позицию - сразу чиним, O(log n)
удалить(v):
i = pos[v]
обменять(i, len(heap) - 1)
heap.pop()
удалить pos[v] # иначе словарь показывает в уже отрезанный хвост
если i < len(heap): # удаляли не последний элемент - есть что чинить
просеять_вниз(i) # на месте i теперь ДРУГОЙ элемент: он мог оказаться
просеять_вверх(i) # и больше, и меньше прежнего, поэтому чиним в обе
# стороны; сработает ровно одна из них - если элемент
# опустился, он уже не меньше родителя, и наоборот
Ленивое удаление (оно же ленивое обновление) - второй способ: кучу не трогаем вовсе, а устаревшие записи отбрасываем при извлечении.
улучшить(v, new):
если new < dist[v]:
dist[v] = new
push((new, v)) # старая запись остаётся в куче и станет мусором
извлечь_актуальный():
пока куча не пуста:
(d, v) = pop()
если d == dist[v]: # запись свежая - её и возвращаем
вернуть (d, v)
# иначе это устаревший дубль - молча пропускаем
Цена: в куче до m записей вместо n (m - число улучшений), то есть память O(m) и логарифм от
m вместо логарифма от n. Для графа это log m <= log(E) <= 2·log(V) - разница в константу, и
на практике ленивый вариант обычно быстрее честного, потому что не платит за поддержку словаря на
каждом обмене.
Правило
Куча не знает позиций своих элементов, поэтому decrease_key требует внешнего словаря
pos[элемент] = индекс, обновляемого ВНУТРИ обмена; тогда уменьшение ключа - O(log n), а
удаление произвольного элемента - замена последним и просеивание в ОБЕ стороны. Альтернатива -
ленивое обновление: класть новую запись и отбрасывать устаревшие при извлечении, сверяя приоритет
записи с актуальным. Ленивый вариант проще и обычно быстрее, платит памятью O(числа вставок).
Разбор: уменьшаем ключ в min-куче
Min-куча ключей: [2, 7, 5, 12, 9, 6, 8] (индексы 0..6), и пусть элемент с ключом 12 лежит в
ячейке 3, а словарь говорит pos[D] = 3. Уменьшаем ключ D с 12 до 1.
key[D] = 1, массив:[2, 7, 5, 1, 9, 6, 8]. Свойство нарушено: родитель 7 больше ребёнка 1.- Просеиваем вверх от
i = 3. Родитель(3−1)//2 = 1, там 7.7 > 1- обмен. Массив:[2, 1, 5, 7, 9, 6, 8]. Обновляем словарь:pos[D] = 1,pos[элемента с ключом 7] = 3. i = 1, родитель0, там 2.2 > 1- обмен. Массив:[1, 2, 5, 7, 9, 6, 8]. Снова правим словарь:pos[D] = 0,pos[элемента с ключом 2] = 1.i = 0- корень, стоп.
Два обмена, O(log n). Главное в разборе - не обмены, а ЧЕТЫРЕ обновления словаря: при каждом
обмене меняются позиции ОБОИХ участников. Пропустишь одно - словарь начнёт врать, и следующий
decrease_key починит чужой узел; куча при этом останется формально корректной, а алгоритм даст
неверный ответ. Ошибка не падает и не видна в тестах на маленьких графах.
Разбор: ленивая Дейкстра на трёх шагах
Пусть dist[B] сначала 17, и в куче лежит запись (17, B). Потом нашёлся путь 9: кладём (9, B),
dist[B] = 9. В куче теперь две записи про B. Что произойдёт дальше:
pop()достаёт(9, B)- это минимум. Проверка:9 == dist[B]? Да - запись свежая, вершину обрабатываем, помечаем как готовую.- Позже
pop()достаёт(17, B). Проверка:17 == dist[B](а там 9)? Нет - запись устарела, молча пропускаем и берём следующую. Никакого поиска, никакого удаления. - Почему это корректно: свежая запись всегда имеет меньший приоритет, чем устаревшая, а значит выходит из кучи РАНЬШЕ неё. К моменту, когда наверх всплывёт мусор, вершина уже обработана, и пропустить его безопасно.
Цена видна тут же: куча хранила две записи вместо одной. В худшем случае записей столько, сколько
было улучшений, то есть O(E) для графа. Зато в коде нет ни словаря позиций, ни обмена с
обновлением - только push и проверка одной строкой при pop.
Что ломается без этого
Первое: обновлять pos не внутри обмена, а «где-то рядом» - рано или поздно найдётся путь, где
обмен есть, а обновления нет. Обновление обязано жить в той же функции, что и обмен. Второе:
decrease_key, который на самом деле увеличивает ключ, - просеивание вверх тогда не помогает,
нужно вниз; поэтому в честной реализации проверяют направление, а лучше держат две отдельные
операции. Третье: ленивый вариант без проверки при извлечении - очередь начнёт возвращать
отменённые и устаревшие записи - не сразу, а когда свежие кончатся: приоритет у мусора хуже,
поэтому он всплывает последним и тем опаснее, что до него доходит уже в конце работы. Четвёртое: ленивое удаление без ограничения роста - в долгоживущей очереди
(таймеры, события) мусор копится и съедает память; лечится либо периодической пересборкой кучи за
O(n), либо счётчиком мусора с порогом. Пятое: хранить в куче объекты, приоритет которых меняют
снаружи, - структура об изменении не узнает и свойство кучи нарушится незаметно для всех.
Зачем это в работе
Дейкстра и Прим - главные потребители: обе формулируются как «взять ближайшую вершину и улучшить
оценки соседей», и вторая половина фразы - это и есть decrease_key. От того, сколько он стоит,
зависит итоговая оценка алгоритма: с двоичной кучей Дейкстра стоит O((V+E)·log V), с кучей
Фибоначчи - O(E + V·log V) (следующая глава). Планировщики и таймеры сталкиваются с той же
задачей под другим именем: «отменить таймер» и «перевести задачу в другой приоритет» - это
удаление и изменение ключа. В ядре Linux НИЗКОТОЧНЫЕ таймеры (timer_list) живут не в куче, а в
колесе таймеров именно потому, что постановка и отмена должны быть O(1); высокоточные
(hrtimer) хранятся в красно-чёрном дереве, и там отмена - O(log n). Практический совет: начинай с ленивого
варианта - он проще, реже ломается и обычно быстрее; переходи к словарю позиций, когда память под
мусор становится проблемой или когда удаление нужно честное и немедленное.
Теперь сам
В ленивой очереди лежат записи (4, X), (9, Y), (6, Y), (7, Z), а актуальные оценки -
dist[X] = 4, dist[Y] = 6, dist[Z] = 7. Что вернут четыре подряд идущих извлечения и сколько
записей будет отброшено?
Ответ. Извлекаем по возрастанию приоритета. Первое pop даёт (4, X): сверяем 4 == dist[X]
- да, запись свежая, возвращаем X. Второе pop даёт (6, Y): 6 == dist[Y] - да,
возвращаем Y. Третье pop даёт (7, Z): 7 == dist[Z] - да, возвращаем Z. Четвёртое
pop даёт (9, Y): 9 == dist[Y]? Нет, там 6 - запись устарела, отбрасываем и идём дальше;
куча пуста, извлекать больше нечего. Итог: три полезных извлечения, одна отброшенная запись.
Обрати внимание на порядок: устаревшая запись (9, Y) всплыла ПОЗЖЕ свежей (6, Y) - иначе и быть
не могло, потому что свежая запись всегда с меньшим приоритетом. Именно это и делает ленивую схему
корректной: к моменту появления мусора вершина давно обработана. И заметь, что честная схема с
decrease_key на этом же примере хранила бы три записи вместо четырёх, но заплатила бы двумя обновлениями
словаря на КАЖДОМ обмене при просеивании - в разборе выше их набралось четыре за два обмена.
Главное
Куче не хватает знания о том, где лежит конкретный элемент: поиск по ключу в ней O(n), поэтому
уменьшение приоритета «изнутри» невозможно без посторонней помощи. Честный способ - словарь
pos[элемент] = индекс, обновляемый ВНУТРИ функции обмена (при каждом обмене меняются позиции
обоих участников); тогда decrease_key - O(log n), а удаление произвольного элемента - замена
последним плюс просеивание в ОБЕ стороны. Ленивый способ - не трогать кучу вовсе: класть новую
запись с лучшим приоритетом и отбрасывать при извлечении те, чей приоритет не совпал с актуальным;
корректность держится на том, что свежая запись всегда всплывает раньше устаревшей. Ленивый проще,
реже ломается и обычно быстрее, но куча растёт до числа вставок - в долгоживущих очередях мусор
приходится подрезать пересборкой. Эта разница напрямую определяет цену Дейкстры и Прима и объясняет,
зачем в следующей главе понадобится куча с дешёвым уменьшением ключа.
Комментарии
Пока нет комментариев. Будь первым!
Оставить комментарий