О чём статья
Жадный алгоритм — самый простой класс алгоритмов олимпиадного программирования: на каждом шаге берём «локально лучшее» и идём дальше. Кажется наивно — и часто работает. Подвох — в доказательстве. Жадный, который «вроде бы работает», ломается на специально подобранных тестах; жадный, обоснованный через exchange argument, гарантированно правильный.
Эта статья — про обоснование. Сам по себе шаблон жадного — две строки кода: отсортировать массив, пробежать. Содержательная часть — какой выбрать ключ сортировки, какой инвариант сохранять, и как доказать, что любое другое решение можно «обменять» на жадное без потери качества. Разбираем шаблон рассуждения на двух классических задачах и одной более сложной с приоритетной очередью.
Когда применять: сигналы в условии
Жадный — первая гипотеза, которую стоит проверить на следующих сигналах:
- «Максимизировать / минимизировать сумму / количество» при простой структуре условия. Если задача не упоминает «выбрать из » с экспоненциальным числом комбинаций, а просто «расположить элементы в порядке X и взять максимум» — жадный работает в 80% случаев.
- «Выбрать максимальное число / минимальный набор» с независимыми условиями. Активити-задачи, упаковочные, расписания.
- Естественный порядок в данных. Если в условии есть «временные интервалы», «отрезки на прямой», «приоритеты» — отсортировать по одному из ключей и пройти один раз часто даёт правильный ответ.
- Маленькая структура состояния. Если в задаче нет «зависимостей между выборами» — нет необходимости в DP, жадный точно работает.
Если хотя бы один сигнал есть — стоит попробовать обосновать жадный через exchange argument. Если доказательство не складывается — это сильный сигнал, что задача не жадная, и нужно DP или другой подход.
Базовый шаблон рассуждения (Exchange argument)
Exchange argument — стандартный приём доказательства оптимальности жадного. Структура — четыре шага:
- Сформулировать жадный. Какой ключ сортировки, какой инвариант сохраняется.
- Допустить контрпример. Пусть существует решение , отличающееся от жадного и при этом строго лучше (или равно).
- Найти «обмен». Показать, что в есть пара элементов, которые можно поменять местами / заменить на «жадные», и стоимость не вырастет.
- Сделать вывод. Цепочка обменов превращает в , не ухудшая. Значит, — оптимален.
Это не обязательно «строгое математическое доказательство в три строчки» — в боевых задачах достаточно понимать механику обмена: какие два элемента вы меняете и почему стоимость не растёт. Без этого жадный — догадка, не алгоритм.
Задача-иллюстрация 1: максимальное число непересекающихся интервалов (~CF 1200)
Условие
Дано интервалов на прямой (целочисленные концы). Найти максимальное число попарно не пересекающихся интервалов, которые можно выбрать. Два интервала и не пересекаются, если или (касание считается допустимым).
Сигнал и жадный
«Выбрать максимальное число элементов» + «независимые условия». Жадный: отсортировать интервалы по правому концу возрастанию. Идти по списку и брать каждый интервал, чей левый конец правого конца последнего выбранного.
Доказательство через exchange argument
- Жадный (GR): на каждом шаге берём интервал с минимальным правым концом среди тех, что не пересекаются с уже взятыми.
- Допущение: пусть существует с большим числом интервалов.
- Обмен. Пусть в первый по правому концу интервал — , а в — . По жадному правилу . Заменим на в — это не сломает : все остальные интервалы в начинаются после , значит, не пересекаются и с .
- Рекурсия. По тому же аргументу — заменяем второй интервал в на , и т.д. В конце становится — без изменения числа интервалов. Значит, . ∎
Псевдокод
отсортировать интервалы по возрастанию e_i
cnt ← 0
last_end ← -∞
для (s, e) из отсортированного списка:
если s ≥ last_end:
cnt ← cnt + 1
last_end ← e
вернуть cnt
Код
Что характерно
- Ключ сортировки — нетривиальный. Естественные кандидаты: длина интервала, левый конец, центр. Все они дают неверные жадные. Только сортировка по правому концу — корректна.
- Exchange argument — обоснование выбора ключа. Если попытаться доказать по другому ключу — обмен не пройдёт (можно построить контрпример).
- Сложность: на сортировку, на проход.
Задача-иллюстрация 2: минимальная стоимость слияния (~CF 1400)
Условие
Дано «верёвок» длинами . За одну операцию можно взять две верёвки длины и , склеить в одну длины , заплатив стоимость . Нужно склеить все верёвки в одну за минимальную суммарную стоимость.
Ограничения. , .
Сигнал и жадный
«Минимизировать суммарную стоимость» + «структура операций повторяющаяся». Жадный: на каждом шаге склеивать две самые короткие верёвки.
Реализация — через min-heap (приоритетная очередь): извлекаем два минимума, добавляем их сумму обратно в кучу, прибавляем к ответу.
Доказательство через exchange argument
Понятно, что выбор пары для слияния влияет на глубину включений: длина каждого исходного войдёт в финальную сумму ровно depth(a_i) раз, где depth — глубина в «дереве слияний». Цель — минимизировать , что эквивалентно: длиннее элементы должны быть выше в дереве.
Жадный «склеивать два минимума» строит полное дерево слияний снизу вверх так, что глубина обратна — длинные элементы оказываются ближе к корню.
Exchange argument: допустим, в оптимальном дереве два самых коротких оказались не на одном самом глубоком уровне. Тогда есть какой-то лист на самом глубоком уровне с или . Поменяем и местами — глубина увеличилась, глубина уменьшилась. Поскольку , . Стоимость не выросла. Значит, можно превратить любое оптимальное дерево в жадное. ∎
Это — упрощённая форма доказательства, общая идея пришла из конструкции Huffman.
Псевдокод
heap ← min-heap из всех a_i
cost ← 0
пока |heap| > 1:
x ← heap.pop_min()
y ← heap.pop_min()
cost ← cost + (x + y)
heap.push(x + y)
вернуть cost
Код
Что характерно
- Структура данных — приоритетная очередь. Сортировки одной не хватает: после каждого слияния новый элемент должен встать в правильное место.
- Стоимость не очевидна из формулы. «Сумма по слияниям» зависит от порядка слияний — это структурная задача, а не «отсортировать и пройти».
- Exchange argument сложнее. Прямого «поменять два элемента» недостаточно — нужно понимать, что мы меняем глубины в дереве слияний.
- Сложность: — извлечений и вставок из кучи размера .
Задача-иллюстрация 3: расписание задач с дедлайнами и штрафами (~CF 1600)
Условие
Дано задач. Каждая задача занимает ровно 1 единицу времени. Задача имеет дедлайн (целое, ) и штраф за невыполнение в срок. Можно выполнять задачи в произвольном порядке, но в каждую единицу времени — ровно одну. Задача считается выполненной в срок, если она поставлена в слот . Минимизировать сумму штрафов за задачи, не выполненные в срок.
Сигнал и жадный
«Минимизировать штраф» + «независимые единицы времени». Жадный: отсортировать задачи по убыванию штрафа. По очереди — пытаемся занять самый поздний свободный слот . Если такого слота нет — задача не выполняется, штраф добавляется к ответу.
Доказательство через exchange argument
Допустим, есть оптимальное расписание , отличающееся от жадного. Возьмём первое расхождение: жадный поставил задачу с наибольшим штрафом в слот , а — какую-то задачу (или оставил слот пустым). Тогда:
- Если в OPT не выполнена (пропущена), а выполнена в : уберём из слота , поставим . Штраф уменьшился на .
- Если в OPT выполнена в другом слоте , а в стоит : поменяем и местами. Оба остаются в срок ( — потому что слот по жадному правилу; — потому что в ). Штрафы не изменились.
Цепочка таких обменов превращает в жадный, не увеличив штраф. ∎
Псевдокод
отсортировать задачи по убыванию p_i
occupied[1..n] ← все False
penalty ← 0
для (d, p) из отсортированного списка:
slot ← -1
для t от min(d, n) до 1 (шаг -1):
если не occupied[t]:
slot ← t
прервать
если slot = -1:
penalty ← penalty + p
иначе:
occupied[slot] ← True
вернуть penalty
Простой поиск свободного слота — на задачу, итого . На — приемлемо. На больших — заменить на DSU (disjoint set union) с операцией «найти ближайший свободный слот », что даёт .
Код
Что характерно
- Ключ сортировки — штраф, не дедлайн. Естественная гипотеза «сортировать по дедлайну» не работает.
- Поиск слота — с самого позднего. Это сохраняет ранние слоты для задач с меньшими дедлайнами, которые ещё придут.
- Сложность: простая, с DSU. На — нужен DSU.
Типичные ошибки
-
«Очевидный» ключ сортировки. Самая частая ошибка: взять первую попавшуюся сортировку (по длине, по началу, по сумме координат), запустить, получить WA. Лекарство — придумать exchange argument до написания кода. Если не сложился — ключ неправильный.
-
Жадный там, где нужен DP. Если задача спрашивает «выбрать из так, чтобы сумма / произведение / ... было оптимально» — почти всегда DP. Жадный работает только при независимых выборах. Тест: можно ли построить контрпример на , где жадный даёт неоптимум?
-
Перепутать min/max в обмене. В доказательстве exchange argument: меняем какой элемент на какой, и в какую сторону растёт стоимость? Аккуратность с неравенствами — простая, но частая ошибка.
-
Не доказать монотонность ключа. Сортировка по «весу/времени» или «штрафу/дедлайну» (комбинированный ключ) — нужно отдельно доказать, что именно этот ключ даёт правильный порядок. Иногда комбинация неочевидна (Smith's rule в shop scheduling).
-
Структура данных — не приоритетная очередь. При слияниях нужна именно куча; сортировкой не обойтись (после каждого слияния новый элемент должен попасть в нужное место). Замена кучи на отсортированный массив с поиском вставки — вместо .
-
Жадный «по двум осям». Когда у задачи два параметра (например, «вес» и «значение») — простая сортировка по одному не работает, нужна по комбинированному критерию (например, «значение/вес»). Без доказательства это часто WA.
-
Жадный с заглядыванием на шагов вперёд. «Берём не самое выгодное сейчас, а с учётом следующих двух шагов» — это уже не жадный, а DP. Если действительно нужно — переходим к DP, не маскируем под жадный.
Когда жадный НЕ подходит
- Сильные зависимости между выборами. Если выбор в шаге влияет на доступность вариантов в шаге , — почти наверняка DP.
- Сложная функция стоимости. Когда стоимость зависит не от отдельных элементов, а от пар или подмножеств взаимодействующих — жадный обычно ломается.
- Подсчёт количества решений. Жадный находит одно оптимальное решение; для подсчёта всех — DP.
- Задачи с обратной связью. Например, «выбрать подмножество, затем …» — обычно это DP по подмножествам или поток.
Связанные техники
- Бинпоиск по ответу с жадной проверкой. Внутри
can(x)часто стоит жадный алгоритм. См. тему «Бинпоиск по ответу» этой же серии и стратегию по бинпоиску. - DP вместо жадного. Когда жадный неверен. См. тему «Динамическое программирование: базовые шаблоны».
- Сортировка + проход. Базовый паттерн жадного. Эта статья — обобщение этого паттерна.
- Приоритетная очередь. Структура данных для жадного с динамическим выбором минимума / максимума.
- DSU (disjoint set union) — для эффективного «следующего свободного слота» в задачах вроде расписания со штрафами.
Итого
- Идея жадного: на каждом шаге выбираем локально лучшее, никогда не откатываемся.
- Главная техника доказательства: exchange argument — заменить любое отличающееся решение на жадное цепочкой обменов без потери качества.
- Шаблон: отсортировать по правильному ключу + один проход (или приоритетная очередь для динамики).
- Типичный критерий: «локально лучшее» + «независимость выборов» + «доказуемый exchange argument».
- Когда не подходит: зависимости между выборами, сложные функции стоимости, подсчёт количества решений, обратная связь.
- Главные ловушки: «очевидный» ключ сортировки без доказательства, жадный там, где нужен DP, путаница в exchange argument.