Как ломать жадность
Стресс-тест против перебора: сорок строк, которые находят контрпример за секунды. И почему сверка двух своих решений так не работает.
3 мин
Жадная идея приходит в голову сама, и главный вопрос не «как её доказать», а «не вранья ли это». Быстрее всего отвечает на него не размышление, а запуск.
Схема стресс-теста
Нужны три вещи: ваше решение, заведомо правильное медленное решение и генератор маленьких случайных входов.
import random
for attempt in range(10000):
n = random.randint(1, 8) # маленькие входы!
a = [random.randint(1, 10) for _ in range(n)]
fast = greedy(a)
slow = brute_force(a)
if fast != slow:
print("контрпример:", a, "жадность:", fast, "перебор:", slow)
break
Три условия, без которых это не работает:
Входы должны быть маленькими. На перебор мгновенен, а контрпример — если он есть — почти наверняка найдётся среди коротких. Большие входы только замедлят поиск и дадут контрпример, в котором ничего не разберёшь.
Значения должны быть маленькими. Числа от 1 до 10 дают повторы, нули, равенства — ровно те случаи, на которых жадность обычно и ломается. С числами до миллиарда все элементы различны, и половина крайних случаев не встретится никогда.
Перебор должен быть тупым. Его задача — быть очевидно правильным, а не быстрым. Перебрать все подмножества, все перестановки, все разбиения — что угодно, лишь бы про него нельзя было ошибиться.
Чего стресс-тест не заменяет
Есть распространённая ошибка: сравнить две свои реализации одной идеи и счесть совпадение проверкой.
Так не выйдет. Если идея неверна, обе реализации будут неверны одинаково, и сверка покажет полное согласие. Она поймает опечатку, но не поймает то, ради чего затевалась.
Проверять надо против решения, построенного на другом основании: перебор не воплощает вашу идею, он просто рассматривает все варианты.
Как писать перебор
Для задачи о расписании — все подмножества заявок, проверить совместимость, взять наибольшее по размеру:
def brute_force(segments):
n = len(segments)
best = 0
for mask in range(1 << n):
chosen = [segments[i] for i in range(n) if mask >> i & 1]
chosen.sort()
ok = all(chosen[i][1] < chosen[i + 1][0] for i in range(len(chosen) - 1))
if ok:
best = max(best, len(chosen))
return best
Для задачи о монетах — динамика или полный перебор количеств. Для разбиения на пары — рекурсивное сопоставление. Общее правило: если перебор пишется дольше десяти минут, вы, скорее всего, пытаетесь сделать его умным.
Что делать с контрпримером
Найденный контрпример обычно ещё великоват. Стоит его уменьшить: убирать по элементу и смотреть, сохраняется ли расхождение. Из примера на восьми числах часто получается пример на трёх, и вот на нём уже видно, в чём дело.
Дальше два пути. Либо понять, чего не хватает жадности, и поправить порядок сортировки — тогда стресс запускается снова. Либо признать, что жадность здесь не работает, и переходить к динамике.
Классические поломки
Идеи, которые выглядят правдоподобно и неверны:
| Задача | Соблазнительная жадность | Что не так |
|---|---|---|
| Монеты произвольных номиналов | брать самые крупные | 1, 3, 4 и сумма 6 |
| Рюкзак с весами и ценностями | брать по убыванию отношения | предмет с лучшим отношением может не влезть выгодно |
| Непересекающиеся отрезки | брать самые короткие | короткий на стыке выбивает два длинных |
| Разбиение на пары | брать первого подходящего | заберёт того, кто был нужен другому |
| Раскраска | брать первый свободный цвет | зависит от порядка обхода |
Общее у всех: локально выгодный ход отнимает возможность, которая понадобилась бы позже. Это и есть то место, куда стоит смотреть в первую очередь.