EduBrick
классная работа

Жадные идеи и их ловушки

Программирование на Python: от нуля до олимпиад

0/18решено 0 из 18до зачёта осталось 10

Чему научитесь

  • Формулировать жадную идею и обосновывать её обменным аргументом
  • Искать контрпример на двух-трёх элементах
  • Проверять идею стресс-тестом против полного перебора
  • Подбирать ключ сортировки сравнением двух соседей

Как устроено занятие

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

Дальше 15 задач лестницей: разминка, основа, со звёздочкой. Занятие засчитывается, когда решено 10 — остальные не пропадают и учитываются отдельно.

После занятия — вторая часть, ещё 15 задач на те же приёмы в новых сюжетах.

Сколько это займёт

Примерно час-полтора вместе с задачами. Сроков нет: можно закрыть вкладку и вернуться когда удобно — прогресс сохранится.

теория

Локально лучший выбор

Жадный алгоритм устроен предельно просто: на каждом шаге делаем то, что выглядит лучше всего прямо сейчас, и никогда не возвращаемся назад.

Набрать сумму 87 монетами 1, 2, 5, 10, 20, 50? Берём 50, потом 20, потом 10, потом 5, потом 2. Пять монет, и это оптимум.

Такие решения короткие и быстрые. Проблема в другом.

Та же идея, другие монеты

Пусть монеты 1, 3 и 4, а набрать надо 6.

Жадность берёт 4, потом остаётся 2 — это 1 и 1. Три монеты.

А правильный ответ — 3 и 3. Две монеты.

Алгоритм не изменился ни на строку. Изменились входные данные — и он перестал быть верным.

В этом вся тема занятия. Придумать жадность легко, почти всегда она приходит в голову первой. Трудно понять, верна ли она. И само по себе «выглядит разумно» здесь ничего не значит: только что мы видели идею, которая выглядит совершенно разумно и при этом неверна.

теория

Когда жадность работает

Есть рассуждение, которое превращает «кажется верным» в «верно». Оно называется обменным аргументом.

Схема такая: берём какое-нибудь оптимальное решение и показываем, что его можно переделать в жадное, не ухудшив.

На примере

Задача: дано много отрезков, выбрать как можно больше попарно непересекающихся.

Жадная идея: брать отрезок, который заканчивается раньше всех, потом первый следующий, который с ним не пересекается, и так далее.

Почему это верно? Пусть есть оптимальный ответ, и первый отрезок в нём — не тот, что заканчивается раньше всех. Заменим его на наш, самый ранний по правому концу. Он заканчивается не позже, значит всем остальным выбранным отрезкам он мешает не больше прежнего — они как были непересекающимися, так и остались. Количество отрезков не изменилось, а первый шаг стал жадным.

Повторяя это рассуждение, превращаем весь оптимальный ответ в жадный, ни разу не потеряв в количестве. Значит жадный ответ — тоже оптимальный.

Что здесь важно

Аргумент опирается на конкретное свойство: «заканчивается раньше — мешает меньше». Никакого общего правила «жадность работает, если задача про максимум» нет. Каждый раз ищется своя причина.

Если причину найти не удаётся — это не доказательство неверности, но серьёзный повод поискать контрпример.

тест

Проверка: какая жадность верна

Какое из утверждений верно?

Войдите, чтобы ответить.
теория

Как искать контрпример

Это главный практический навык занятия. Контрпримеры почти всегда крошечные.

Начинайте с двух-трёх элементов. Если жадность неверна, она обычно ломается уже там. Пример из первого блока — три отрезка. Пример с монетами — сумма 6.

Пробуйте крайности. Один очень большой элемент среди маленьких; все элементы одинаковые; ровно граничное значение параметра.

Сравните с полным перебором. Самый надёжный способ. Пишем заведомо правильное, но медленное решение — перебор всех вариантов, — и на маленьких случайных входах сравниваем ответы:

from itertools import permutations

def slow(a):
    best = None
    for order in permutations(a):     # все порядки — заведомо верно
        value = check(order)
        if best is None or value < best:
            best = value
    return best

for test in range(1000):
    a = random_list(n=6)              # маленький вход!
    if greedy(a) != slow(a):
        print("нашёлся:", a)
        break

Такая проверка называется стресс-тестом. Она не доказывает, что жадность верна, — но если контрпример существует, на тысяче случайных шестёрок он почти наверняка найдётся. Полчаса на стресс-тест дешевле, чем неверное решение на олимпиаде.

И обратите внимание: перебор пишется намеренно тупо. Его задача — быть очевидно правильным, а не быстрым.

расчёт

Проверка: сколько даст жадность

Монеты достоинством 1, 5 и 8, набрать нужно сумму 15.

Сколько монет возьмёт жадный алгоритм, если каждый раз брать самую крупную подходящую? Введите целое число.

Войдите, чтобы ответить.
теория

Жадность после сортировки

Подавляющее большинство жадных решений начинается одинаково: отсортировать. Вопрос только в том, по какому ключу — и это и есть вся задача.

задача ключ сортировки
наибольшее число непересекающихся отрезков по правому концу
наименьшее суммарное ожидание в очереди по времени обслуживания
наибольшая стоимость делимого груза по стоимости килограмма, убывая
наименьшее наибольшее опоздание по сроку

Когда ключ не очевиден

Бывает, что ключ — не одно из данных чисел, а их комбинация. Классический пример: работы выполняются по одной, работа ii занимает tit_i минут и приносит штраф wiw_i за каждую минуту до своего завершения.

Что ставить раньше — короткую работу или дорогую? Сравним два соседних порядка. Если сначала ii, потом jj, лишний штраф равен tiwjt_i \cdot w_j: работа jj ждёт время tit_i. Если наоборот — tjwit_j \cdot w_i.

Значит ii выгодно ставить раньше, когда

tiwj<tjwi,то естьtiwi<tjwj.t_i \cdot w_j < t_j \cdot w_i, \quad\text{то есть}\quad \frac{t_i}{w_i} < \frac{t_j}{w_j}.

Сортируем по отношению t/wt/w — и всё. Заметьте, как это получилось: не догадкой, а сравнением двух соседей. Этот приём работает в большинстве задач «в каком порядке делать».

теория

Три ловушки

Рюкзак с неделимыми предметами. Если груз сыпучий и его можно брать частями, жадность по стоимости килограмма верна. Если предметы целые — неверна. Вместимость 10, предметы: (6 кг, 60), (5 кг, 45), (5 кг, 45). Стоимость килограмма у первого выше, жадность берёт его и больше ничего не помещает — 60. Оптимум: два вторых, 90. Такие задачи решаются динамическим программированием, до которого мы дойдём в восьмом модуле.

«Возьму побольше сейчас». В задаче про смену знаков заманчиво поменять знак у самого большого по модулю отрицательного числа и остановиться. Но если действий больше, чем отрицательных чисел, остаток надо куда-то деть — и правильный ответ зависит от чётности остатка.

Неверный ключ, который почти работает. Сортировка по левому концу вместо правого, по длине вместо конца, по стоимости вместо стоимости килограмма. Такие решения проходят большинство тестов и падают на одном — потому что ошибка проявляется только в специальной конфигурации.

Как проверять себя

  • два-три элемента — почти все контрпримеры такого размера;
  • один огромный элемент среди маленьких;
  • все элементы одинаковые;
  • стресс-тест против перебора, если есть хоть малейшее сомнение.
теория

Практика: пятнадцать задач

Лестница прежняя: пять разминочных, семь основных, три со звёздочкой. Зачёт при десяти решённых.

Почти в каждой задаче первым делом надо решить, по какому ключу сортировать. Прежде чем писать код, скажите себе одним предложением, почему именно этот ключ.

А если предложение не складывается — придумайте контрпример к своей идее. Это быстрее, чем отлаживать неверное решение.

лестница задач
развёрнутый ответ

Жадность, которая выглядит разумной

Задача: «дано nn отрезков; выберите как можно больше попарно непересекающихся». Совпадение концов считается пересечением.

Ученик рассуждает так: «короткие отрезки мешают остальным меньше, значит надо брать сначала самые короткие». И пишет:

n = int(input())
flat = list(map(int, input().split()))
segments = [(flat[2 * i], flat[2 * i + 1]) for i in range(n)]

segments.sort(key=lambda s: s[1] - s[0])

taken = []
for left, right in segments:
    if all(right < l or r < left for l, r in taken):
        taken.append((left, right))

print(len(taken))

Проверка на пересечение написана верно, и на многих тестах ответ правильный.

Постройте вход, на котором решение ошибается, объясните, почему рассуждение про «короткие мешают меньше» несостоятельно, и скажите, по какому ключу надо сортировать и почему.

Войдите, чтобы ответить.