О чём эта статья
Серия «Стратегия победы» — про траекторию тренировки. Берём одну технику и проходим её на трёх уровнях нарастающей сложности, явно фиксируя, что именно усложняется между ступенями. Тема этой статьи — жадные алгоритмы, и центральный сюжет — не «как написать жадный», а когда он ломается и какими способами его «лечат».
Жадные легко производят иллюзию, что задача решена: первое попавшееся правило работает на маленьких тестах. Реальные ошибки приходят там, где задача вроде бы «жадная по структуре», но: либо ключ сортировки нужен нестандартный (ступень 1); либо локальный выбор влияет на следующий шаг и нужно «переносить остаток» (ступень 2); либо локального правила вообще нет, и единственный способ — DP, в котором жадная интуиция остаётся в порядке обхода (ступень 3).
Ядро техники
Жадный = на каждом шаге выбираем «локально лучшее» и не откатываемся. Корректность обосновывается через exchange argument: любое отличающееся оптимальное решение можно цепочкой обменов превратить в жадное, не ухудшив. Если такая цепочка не строится — жадный неверен.
Соседняя статья этой серии («Алгоритмические основы #4 — Жадные и exchange argument») разобрала шаблон рассуждения; здесь мы смотрим на расширение шаблона: что именно меняется в задачах, где «чисто жадный» уже не проходит.
Базовый шаблон (псевдокод)
отсортировать элементы по нужному ключу
для каждого элемента в этом порядке:
принять или отвергнуть, обновить состояние
вернуть ответ
Сложность базового шаблона: — доминирует сортировка.
Что мы будем усложнять по мере роста:
- Ступень 1 → ступень 2: к локальному выбору добавляется «хвост», который надо переносить в следующий шаг (текущее решение влияет на ограничение для будущего).
- Ступень 2 → ступень 3: «локального правила» больше нет — есть структура, в которой выгодный выбор зависит от всей конфигурации впереди. Жадный заменяется DP с тем же порядком обхода.
Ступень 1 — CF ~1200 (чистый exchange argument)
Условие
Дано заданий. Задание занимает единиц времени. На одном процессоре выполняем задания по очереди. Время завершения -го задания равно сумме длительностей всех заданий, поставленных не позже -го. Найти минимальную сумму времён завершения по всем порядкам выполнения.
Ограничения. , .
Как применяется базовый шаблон
Заметим: если задание стоит на позиции , то его длительность войдёт в сумму времён завершения раз — оно учитывается в собственном «завершении» и в завершении каждого следующего. Чтобы минимизировать сумму, длинные задания должны иметь наименьший коэффициент, то есть стоять в конце.
Жадный: отсортировать задания по возрастанию . Запустить в этом порядке.
Exchange argument
Предположим, в оптимальном порядке есть пара соседних заданий (раньше) и (позже) с . Поменяем их местами. На длительности других заданий это не влияет (они идут до или после обеих позиций). Влияет только на сумму завершений и :
- Было: .
- Стало: .
Разница: было больше на . То есть обмен улучшил ответ. Любая «инверсия» в оптимуме приводит к противоречию. Значит, оптимум — это отсортированный порядок. ∎
Разбор на примере
, . Сортируем: . Времена завершения: . Сумма: .
Проверим другие порядки:
- : , сумма .
- : , сумма .
Жадный даёт минимум.
Код решения
Сложность: .
Что закрыли
На этом уровне жадный — это «отсортировать по правильному ключу и один раз пройти». Состояние — буквально одна переменная cur (накопленная длительность). Exchange argument — двухсимвольная замена соседей.
Следующий шаг — жадный, где локальный выбор создаёт ограничение для следующего шага. Простое правило сохранится, но потребуется честно нести «хвост».
Ступень 2 — CF ~1500 (жадный с переносом «остатка»)
Условие
На прямой стоят заправок с координатами и стоимостями литра топлива . Машина движется слева направо от заправки 1 до заправки , расход — 1 литр на единицу расстояния. На каждой заправке можно купить любое количество литров. Объём бака — литров. Начальный объём — 0. Найти минимальную стоимость доезда до заправки .
Ограничения. , , , . Гарантируется, что для каждого расстояние .
Условие, чем эта задача сложнее ступени 1
В ступени 1 решение полностью определялось одним глобальным порядком. Здесь каждый локальный выбор (сколько литров купить на заправке ) влияет на доступные варианты в будущем: купили слишком много — потратили деньги по высокой цене; купили слишком мало — пришлось заправляться по ещё более высокой. Локального правила «отсортировать и пройти» нет.
Что меняется в шаблоне
- Состояние: теперь это не одна переменная, а остаток бака на каждой заправке плюс уже потраченные деньги.
- Решающее правило: на заправке заглядываем вперёд — есть ли впереди заправка с более дешёвым топливом, до которой хватит остатка?
- Если есть — покупаем ровно столько, чтобы доехать до неё.
- Если нет — заполняем бак до полного и едем дальше; в следующем дешёвом месте остаток ещё пригодится.
Это уже не «отсортировать», а жадный с просмотром вперёд и переносом неиспользованного остатка.
- Доказательство: exchange argument на парах заправок. Если в оптимуме мы купили на заправке больше, чем нужно до ближайшей более дешёвой , можно сдвинуть «лишний» литр с на — стоимость уменьшится (литр стоит дешевле в ). Если в оптимуме мы купили меньше — значит, в какой-то более дорогой заправке между и мы заправились, что хуже. Цепочкой обменов приходим к жадному правилу.
Разбор на примере
, , , .
- Заправка 0 (цена 3). Ближайшая более дешёвая впереди — заправка 2 (цена 1), расстояние . Покупаем 9 литров. Стоимость: . Остаток на заправке 2: 0.
- Заправка 2 (цена 1). Ближайшая более дешёвая впереди — заправка 4 (цена 2). Подождите, 4 не дешевле 1. Значит, дешёвых впереди нет. Заполняем бак до . Стоимость: . Остаток после заезда: 10.
- Заправка 3 (цена 4). Расход с заправки 2: , остаток .
- Уже едем с бензином, ничего не покупаем. Ждём заправку 4.
- Заправка 4 (цена 2). Доехали с остатком , до неё доехали бесплатно. Это конец маршрута: стоимость остаётся .
Финальный ответ: 37.
Код решения
Для каждой заправки определим следующую более дешёвую за через монотонный стек (классический шаблон «next smaller»):
Сложность: на построение next_cheaper (каждая заправка попадает в стек и уходит из него по разу), на основной проход. Итого .
Что закрыли
Ступень 2 — умение держать остаток между шагами и принимать решение, исходя из «не только сейчас, но и ближайшего будущего». Состояние выросло до пары (fuel, cost), появилось предвычисление вспомогательной структуры (next_cheaper). Это всё ещё жадный — но «жадный с памятью».
Следующий шаг — задачи, где локальное правило вообще не складывается, и нужен переход к DP. Жадная интуиция остаётся, но в виде порядка обработки.
Ступень 3 — CF ~1900 (жадный сломался, спасает DP)
Условие
Дано работ. Работа задана тройкой : — момент, когда её можно начать (не раньше); — длительность (целое); — премия за выполнение. Работа считается выполненной, если её непрерывно делали с момента до момента . Все работы — на одном процессоре, параллельно выполнять нельзя. Максимизировать суммарную премию.
Ограничения. , , .
Условие, чем эта задача сложнее ступени 2
Естественные жадные гипотезы все ломаются:
- «Сортировать по премии убыванию, брать жадно совместимые» — контрпример: одна жирная работа на длинный интервал блокирует две меньшие с большей суммарной премией.
- «Сортировать по окончанию возрастанию, брать совместимые» — это даст максимум числа работ, а не максимум премии.
- «Сортировать по » (эффективности) — тоже строится контрпример.
Локальное правило для взвешенной непересекающейся выборки не существует. Стандартное решение — DP с порядком обхода по концу работ (это и есть жадная интуиция: «принимать решение в момент окончания работы»).
Что меняется в шаблоне
- Состояние: — максимальная суммарная премия среди работ, которые закончились не позже конца -й работы (в порядке возрастания конца). Перебор: либо -ю работу не берём (), либо берём (, где — последняя работа, заканчивающаяся не позже ).
- Переход: , где найти бинпоиском по списку концов.
- Доказательство: стандартное по принципу оптимальности — каждая работа либо в оптимальном наборе, либо нет, и обе ветки рассматриваются.
Жадный порядок («по концу работ») сохраняется как порядок обхода DP — это и есть момент, в который жадная интуиция переходит в DP.
Разбор на примере
, работы (после нормализации к интервалам ):
| Индекс | s | s + d | p |
|---|---|---|---|
| 1 | 0 | 3 | 5 |
| 2 | 2 | 5 | 6 |
| 3 | 4 | 6 | 4 |
| 4 | 5 | 8 | 7 |
Отсортируем по концу: уже в порядке 1, 2, 3, 4 концы 3, 5, 6, 8.
- (пустой набор).
- : работа 1, , предыдущая совместимая — (нет). .
- : работа 2, , ищем работы с концом — нет, . .
- : работа 3, , ищем с концом — работа 1 (конец 3). . .
- : работа 4, , ищем с концом — работы 1 и 2. . .
Ответ: 13 (работы 2 и 4).
Код решения
Сложность: — сортировка плюс бинпоиск на каждом шаге DP.
Что закрыли
Ступень 3 — точка, где жадный перестаёт быть алгоритмом, но остаётся интуицией. Состояние DP — массив , переход выбирает между «взять -ю работу» и «пропустить». Главное умение этого уровня — увидеть, что естественного «локального правила» нет, и не пытаться его натянуть.
Дальнейший шаг — задачи, где даже такой DP не помещается по памяти / времени и нужны сжатия состояния (свёртки по последнему интересному параметру). Это уже за рамкой этой стратегии.
Карта траектории
| Ступень | CF | Ключевое состояние | Ключевое усложнение vs предыдущей |
|---|---|---|---|
| 1 | ~1200 | одна переменная (накопленная сумма) | — |
| 2 | ~1500 | пара (остаток, стоимость) + предвычисленная структура | локальный выбор влияет на ограничения для следующего шага; «лечится» переносом остатка |
| 3 | ~1900 | массив , бинпоиск по концам | локального правила нет; жадная интуиция остаётся в виде порядка обхода, но решение — DP |
Типичные ловушки на каждой ступени
- Ступень 1: неверный ключ сортировки. Кажется естественным «отсортировать по убыванию» вместо возрастания, или по другому параметру. Лекарство — построить exchange argument до написания кода и убедиться, что обмен инверсий улучшает ответ.
- Ступень 2: «оставить полный бак на потом» вместо честного просмотра вперёд. Жадный без структуры
next_cheaper(«заправляться по полной всегда») даёт WA на тестах, где впереди есть дешёвая заправка, до которой хватит. Лекарство — два правила в одном: «если есть дешёвая впереди — до неё, иначе — полный бак». - Ступень 3: пытаться спасти жадный «эвристически». «Сортировка по премии длительности», «двухпроходные жадные», «жадный с откатом» — всё это даёт WA на тщательно подобранных тестах. Симптом: «работает на ручных примерах, падает на больших». Лекарство — переключиться на DP в момент, когда не получается доказать exchange argument.
План тренировки
- Решить задачу ступени 1 самостоятельно. Цель — отработать exchange argument как метод доказательства.
- На Codeforces в полосе ~1200 решить 3–5 задач с тегом
greedy(фильтр по difficulty). Перепроверять каждое решение через «а доказал ли я обмен?». - Перейти к ступени 2. Если буксует более 30 минут — вернуться к шаблону
next_cheaper(монотонный стек) — это часть базы, без неё ступень 2 не решается. - На Codeforces в полосе ~1500 — 3–5 задач с тегами
greedy + two pointersилиgreedy + stack. - Ступень 3 — если жадная гипотеза не складывается за 15 минут, переключиться на DP. Главное умение — диагностировать «жадный не работает».
- На Codeforces в полосе ~1700–1900 — 3–5 задач из набора «weighted interval scheduling» и его вариантов.
Итого
- Тема: жадные с расширением «локальное правило → перенос остатка → DP с жадным порядком».
- Траектория: CF ~1200 → ~1500 → ~1900, с усложнением состояния: одна переменная → пара с предвычислением → массив DP.
- Ключевое умение: диагностировать, когда жадный сломался, и переходить к нужной технике без попыток «заэвристить».