Сортировка и жадные алгоритмы

Брать лучшее «здесь и сейчас» — и доказывать, что это приводит к глобальному оптимуму. Самый коварный класс приёмов.

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

Зачем это нужно

Жадные алгоритмы — одни из самых красивых и опасных в спортивном программировании. Красивых — потому что решение часто умещается в несколько строк после правильной сортировки. Опасных — потому что «очевидная» жадность нередко неверна, и без доказательства ей нельзя доверять. Главный навык урока — не только написать жадность, но и обосновать, почему она даёт оптимум. На олимпиадах это спасает от WA на скрытых тестах.

Идея «на пальцах»

Жадность = «бери лучшее сейчас и не оглядывайся». Размен монет: чтобы выдать сумму минимумом монет, берём самые крупные, пока можно. Для стандартных номиналов (1, 2, 5, 10) это работает. Но для номиналов (1, 3, 4) и суммы 6 жадность даст 4+1+1 = 3 монеты, а оптимум — 3+3 = 2. Вот почему жадность нужно доказывать: одна и та же идея на одних данных оптимальна, на других — нет.

Классика: выбор максимума непересекающихся заявок

Дано n мероприятий с временем начала и конца. Нужно выбрать максимум непересекающихся. Какая жадность верна? Можно сортировать по началу, по длительности, по концу. Правильный ответ: по времени окончания, и каждый раз брать заявку, которая не пересекается с последней выбранной.

intervals = [(1, 3), (2, 5), (4, 7), (1, 8), (5, 9), (8, 10)]
intervals.sort(key=lambda p: p[1])     # сортируем по правому концу
count = 0
last_end = float("-inf")
chosen = []
for s, e in intervals:
    if s >= last_end:                  # не пересекается с предыдущей
        count += 1
        last_end = e
        chosen.append((s, e))
print("Максимум непересекающихся:", count)
print("Выбраны:", chosen)

Мы перебираем заявки в порядке возрастания конца и жадно берём каждую, что начинается не раньше окончания предыдущей. Сортировка — O(n log n), проход — O(n).

Вывод:

Максимум непересекающихся: 3
Выбраны: [(1, 3), (4, 7), (8, 10)]

Почему именно «по концу»: доказательство обменом

Главный инструмент доказательства жадности — аргумент обмена (exchange argument). Покажем, что жадный выбор не хуже любого оптимального.

Пусть существует оптимальное решение OPT. Рассмотрим заявку с самым ранним концом — назовём её g (её первой берёт жадность). Если OPT уже содержит g — отлично. Если нет, то в OPT есть какая-то первая по времени заявка x. Так как g заканчивается не позже x (мы выбрали самый ранний конец), мы можем заменить x на g: g точно не пересечётся с остальными заявками OPT (они начинались после конца x ≥ конца g). Размер решения не изменился, оно осталось допустимым — значит, существует оптимум, содержащий g. Дальше рассуждаем по индукции для оставшихся заявок. Вывод: жадность по концу даёт оптимум. Эта схема «возьми жадный элемент, обменяй его в любой оптимум без потери» — универсальна для доказательства жадных алгоритмов.

Ещё пример: минимизация времени ожидания

Есть задания с длительностями; нужно так упорядочить их в очереди, чтобы суммарное время ожидания всех было минимальным. Жадность: сортировать по возрастанию длительности (короткие — вперёд). Проверим на числах.

durations = [4, 1, 3, 2]
durations.sort()                  # короткие задания вперёд
wait = 0
total = 0
for d in durations:
    total += wait                 # это задание ждало, пока шли предыдущие
    wait += d
print("Порядок:", durations)
print("Суммарное время ожидания:", total)

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

Вывод:

Порядок: [1, 2, 3, 4]
Суммарное время ожидания: 10

Как проверять жадность на олимпиаде

  • Придумай контрпример. Прежде чем верить жадности, активно ищи маленький тест, где она ломается (как с монетами 1,3,4).
  • Стресс-тест. Сравни жадность с перебором на случайных малых тестах (раздел 7). Если расхождений нет на тысячах тестов — жадность, скорее всего, верна.
  • Доказательство обменом. Попробуй показать, что жадный шаг можно «вставить» в любой оптимум без ухудшения.

Подводные камни

  • Жадность ≠ всегда верно. Размен монет, рюкзак 0/1 — здесь жадность ошибается, нужна динамика.
  • Ключ сортировки решает всё. «По концу», «по длительности», «по отношению цена/вес» — перепутал ключ, и оптимум превращается в WA.
  • Не доказал — не доверяй. Самая частая олимпиадная ловушка: правдоподобная, но неверная жадность.

Итог

  • Жадный алгоритм делает локально лучший выбор; работает не всегда — нужно доказательство.
  • Выбор максимума непересекающихся заявок: сортировка по концу + брать совместимые — оптимум за O(n log n).
  • Основной инструмент доказательства — аргумент обмена: жадный элемент вставляется в любой оптимум без ухудшения.
  • Проверяй жадность контрпримерами и стресс-тестом; где она ломается — там динамика.
Проверьте себя
1. По какому ключу нужно сортировать заявки в задаче о максимуме непересекающихся интервалов?
AПо времени начала
BПо времени окончания
CПо длительности
DВ случайном порядке
2. Почему жадному алгоритму нельзя доверять без доказательства?
AОн всегда медленный
BЛокально оптимальный выбор не всегда ведёт к глобальному оптимуму (например, размен монет 1,3,4)
CОн требует много памяти
DЖадность не компилируется
3. Как называется основной приём доказательства корректности жадных алгоритмов?
AМетод Гаусса
BАргумент обмена (exchange argument)
CБинарный поиск
DДинамическое программирование

Закрепите практикой

Задачи с автоматической проверкой — решайте прямо здесь, не уходя из учебника.

Поддержать проект