EduBrick

Сортировка как жадность

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

5 мин

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

Угадывать его не нужно. Ключ выводится, и способ вывода один и тот же.

Обмен соседей

Рассуждение. Пусть оптимальный ответ — какой-то порядок элементов. Возьмём в нём двух соседей и спросим: при каком условии выгодно поставить их именно в таком порядке?

Ответ на этот вопрос и есть ключ сортировки. Логика: если для любой пары соседей известно, кто должен идти первым, то весь порядок задан однозначно — это и есть сортировка с таким компаратором.

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

Пример: минимум суммарного ожидания

Есть nn работ. Работа ii выполняется tit_i времени и имеет важность wiw_i. Работы делаются по очереди. Нужно минимизировать

iwi(момент завершения работы i)\sum_i w_i \cdot (\text{момент завершения работы } i)

Применим обмен. Пусть в порядке рядом стоят работы aa и bb, а до них прошло время TT. Считаем вклад пары в обоих вариантах:

порядок вклад
сначала aa wa(T+ta)+wb(T+ta+tb)w_a(T + t_a) + w_b(T + t_a + t_b)
сначала bb wb(T+tb)+wa(T+tb+ta)w_b(T + t_b) + w_a(T + t_b + t_a)

Вычтем одно из другого. Всё, что зависит от TT, сокращается, остаётся сравнение wbtaw_b t_a против watbw_a t_b. Значит, aa должно идти раньше, когда

tawa<tbwb\frac{t_a}{w_a} < \frac{t_b}{w_b}
sort(jobs.begin(), jobs.end(), [](const Job& a, const Job& b) {
    return a.time * b.weight < b.time * a.weight;   // t_a/w_a < t_b/w_b
});

Проверено перебором всех перестановок: на двадцати тысячах случайных наборов до шести работ жадный порядок совпал с оптимальным во всех.

Обратите внимание, что сокращение TT — не случайность, а суть метода. Именно поэтому достаточно смотреть на пару: остальная часть порядка на сравнение не влияет.

И снова сравнение через умножение вместо деления — иначе вылезут ошибки округления там, где их не ждёшь.

Каталог ключей

Что получается обменом в типовых задачах:

задача ключ
максимум непересекающихся отрезков правый конец, возрастая
минимум ожидания (равные веса) длительность, возрастая
минимум взвешенного ожидания ti/wit_i / w_i, возрастая
непрерывный рюкзак ci/mic_i / m_i, убывая
дедлайны со штрафом дедлайн, возрастая
склеить числа в максимальное a+b>b+aa + b > b + a как строки
пары «сильный с сильным» оба массива в одном порядке
пары «сильный со слабым» один по возрастанию, другой по убыванию

Предпоследние две строки — частный случай неравенства о перестановках: сумма произведений aibσ(i)\sum a_i b_{\sigma(i)} максимальна, когда оба набора отсортированы одинаково, и минимальна, когда противоположно.

Строка про склейку заслуживает пояснения: чтобы из чисел {9,91,918}\{9, 91, 918\} собрать максимальное число, сортировать по значению или по длине нельзя. Правильный компаратор — сравнить конкатенации: aa идёт раньше bb, если строка a+ba + b больше строки b+ab + a. Это тоже вывод обменом, просто вклад пары считается конкатенацией.

Пример посложнее: игра с добавкой

Алиса и Боб по очереди забирают числа из набора, Алиса ходит первой. Алиса максимизирует разность своей суммы и суммы Боба, Боб минимизирует. Перед игрой Боб может суммарно добавить к числам не больше kk (по целому, только увеличивая).

Сначала игра без добавки. Обе стороны действуют одинаково: берут наибольшее оставшееся. Значит, после сортировки по убыванию Алисе достаются элементы с чётными индексами, Бобу — с нечётными, и разность равна знакопеременной сумме.

Теперь добавка. Боб хочет уменьшить разность, то есть подтянуть свои элементы к соседним слева. Поднимать выше соседа бессмысленно: тогда Алиса просто заберёт этот элемент, и выигрыш пропадёт.

sort(a.rbegin(), a.rend());
for (size_t i = 1; i < a.size(); i += 2) {
    long long add = min<long long>(k, a[i - 1] - a[i]);
    a[i] += add;
    k -= add;
}

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

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

Когда обмен не срабатывает

Метод даёт ключ не всегда. Признак: разность вкладов не сокращается — в ней остаётся TT или элементы, стоящие не рядом.

Это означает, что порядок соседей зависит от всего остального, и одной сортировкой задача не решается. Обычно дальше идёт динамика.

Второй признак — когда обмен даёт несогласованный порядок: aa раньше bb, bb раньше cc, но cc раньше aa. Такой компаратор не задаёт порядка, и sort с ним не просто ошибётся, а упадёт. Если ваш ключ такое допускает — жадность неверна, и это хорошо, что выяснилось до отправки.