Жадными называют класс алгоритмов, заключающихся в принятии локально оптимальных решений на каждом этапе. Так как локально оптимальное решение вычислить гораздо проще, чем глобально оптимальное, такие алгоритмы обычно имеют хорошую асимптотику.
В некоторых случаях жадные алгоритмы приводят к оптимальным конечным решениям, а в других — нет. Придумать и особенно доказать корректность жадных алгоритмов часто бывает очень сложно.
В этой статье мы разберем несколько классических примеров задач, решающихся жадным алгоритмом.
#Монетки
Задача. Монетная система некоторого государства состоит из монет достоинством , причём каждый следующий номинал делится на предыдущий. Требуется выдать сумму наименьшим возможным количеством монет.
Жадный алгоритм решения этой задачи заключается в том, что нужно сначала взять наибольшее количество монет достоинства (а именно, ), затем для набора остатка взять наибольшее количество монет достоинства и так далее. Так как , в конце мы всегда наберем нужную сумму.
a = [1, 2, 5, 10, 50, 100, 500, 1000, 2000, 5000]
def solution(s):
coins = []
for x in reversed(a):
coins += [x] * (s // x)
s %= x
return coins
Заметим, что без предположения, что номиналы монет делятся друг на друга, алгоритм не всегда выводит оптимальное решение, и в этом случае задачу остается решать динамическим программированием. Например, сумму в 24 рубля монетами в 1, 5 и 7 рублей жадный алгоритм разменивает как , в то время как оптимальным будет .
Корректность. Докажем корректность жадного алгоритма от противного: пусть максимальная монета имеет достоинство , но в оптимальном ответе её нет.
Пусть в оптимальном ответе монета с номиналом встретилась раз:
Если , то монет можно заменить на одну монету достоинства , а значит это не оптимальный ответ. Следовательно, .
Теперь посчитаем, какой может быть максимальная сумма всех достоинств всех монет в оптимальном ответе, если мы не брали максимальные монеты:
Отсюда делаем вывод, что если , то в оптимальный ответ всегда придется взять максимальную монету размера , потому что все меньшие монеты просто не смогут оптимальном ответе давать так много. Аналогичным размышлением про наборы из меньших монет получаем такой же результат для всех , откуда следует корректность жадного алгоритма.
Также этот алгоритм работает не только для делящихся друг на друга номинациях, но и для некоторых других. Такие монетные системы, где жадный алгоритм работает, называют каноническими.
Упражнение. Верно ли, что алгоритм работает корректно для российской монетной системы (1, 2, 5…)?
#Рюкзак с делимыми предметами
Задача. Пусть есть рюкзак с вместимостью не более, чем грамм и предметов весом грамм и стоимостью за грамм. Мы умеем отрезать от любого предмета целое количество грамм. Требуется набрать рюкзак максимальной стоимости.
Отсортируем предметы по убыванию «плотности ценности» и будем брать их жадно в таком порядке. От последнего предмета, который не влезет полностью, возьмем часть.
Корректность. Мысленно разделим все предметы на кусочков по 1 грамм, и при этом их ценность стала равна . Понятно, что из кусочков одинакового веса 1 грамм всегда оптимально просто взять кусочки с максимальной ценностью, что мы фактически и делаем в нашем алгоритме.
Итоговая асимптотика на сортировку.
#Выбор заявок
Задача. Даны заявки на проведение занятий в некоторой аудитории. В каждой заявке указаны начало и конец занятия. Нужно из всех заявок оставить как можно больше таким образом, чтобы они не пересекались.
Здесь жадность становится не такой уже очевидной, потому что неясно, в каком порядке рассматривать заявки и как их «жадно» набирать.
Посмотрим на первую по времени конца заявку. Заметим, что нам всегда выгодно включить её в оптимальный ответ — она заканчивается раньше всех остальных, а поэтому если в оптимальном ответе первая заявка какая-то другая, мы можем безболезненно заменить её на первую по времени конца, и при этом новых пересечений не появится, так как мы просто сдвинули самую первую заявку еще левее.
После того, как мы выбрали первую по времени конца, уберем из рассмотрения все, которые с ней пересекаются, и так же выберем из оставшихся первую по времени конца, снова уберем все пересекающиеся и так далее, пока заявки не кончатся.
При реализации удобно не удалять отрезки явно, а просто отсортировать отрезки по времени конца и поддерживать время, когда кончается последнее взятое занятие.
struct activity {
int l, r;
};
int solve(vector<activity> activities) {
sort(activities.begin(), activities.end(), [](activity a, activity b) {
return a.r < b.r;
});
int cnt = 0, last = 0;
for (activity a : activities)
if (a.l >= last)
last = a.r, cnt++;
return cnt;
}
Асимптотика на сортировку.