Суп с котом: забавная задачка с LeetCode
Есть на LeetCode задачка. Medium уровня, на динамическое программирование, ничего особенного. Однако, если присмотреться внимательнее, она окажется интереснее, чем на первый взгляд. Кроме того, можно получить более быстрое решение, чем "официальное".
Условие (в адаптации для постсоветского читателя)
Недалеко от вокзала, есть маленькая столовая. Каждое утро в ней варят две одинаковых кастрюли супа: один с говядиной
Для особо уважаемых клиентов наливают только суп с говядиной (
мл говядина); Для просто уважаемых на четверть разбавляют котом (
мл говядина / мл кот); Для обычных с улицы - пятьдесят на пятьдесят (
мл говядина / мл кот); Для местных алкашей подают, в основном, кота, добавив чуть-чуть говядины для аромата (
мл говядина / мл кот).
Клиенты каждой категории приходят в случайном порядке, каждая категория с вероятностью
Если какого-то вида супа для текущего клиента не хватило - ему подают сколько есть, не доливая ни того, ни другого. При этом заказ считается исполненным.
При заданном объёме кастрюли жрать кушать только и исключительно несчастного кота. Кроме того, к ответу надо добавить
Объем кастрюли
Наблюдения и размышления
Прежде всего, заметим, что объем половника у них
В начальный момент у нас имеется
После первого заказа может остаться:
, если гость особо уважаемый; , если гость просто уважаемый; , если гость обычный; , если гость алкаш.
Или, после
, если гость особо уважаемый; , если гость просто уважаемый; , если гость обычный; , если гость алкаш.
Тут мы, внезапно, замечаем, что общее количество супа с каждой порцией уменьшается ровно на
Введём новые переменные:
В начальный момент
После
Рассмотрим вероятность
либо перед этим было
, но пришёл особо уважаемый гость и разность изменилась на ; либо было
, но пришёл просто уважаемый гость и разность изменилась на ; либо было
, пришёл обычный гость и разность не изменилась; либо было
, но пришёл алкаш и разность изменилась на .
Все эти события имеют место с вероятностью
Таким образом, если мы создадим массив
Но в какой-то момент один (или оба) супа закончатся. Когда это произойдёт?
Возвращаясь от наших переменных
Количество супа с говядиной становится равным
А когда закончатся оба супа, тогда их полусумма
Рассмотрим, например,
Возможные состояния супов
Границы серой области показывают уменьшение
В голубых кружочках указана искомая вероятность того, что суп с говядиной закончится раньше. Для каждого шага
Стрелками показано, какое состояние может получиться на следующем шаге (чтобы не загромождать, показаны только крайние случаи).
Отметим, что состояния, где один из супов закончился, на последующие не влияют, но если бы мы продолжали их строить, то они давали бы вклад только в серой области (отмечено прозрачной зеленой и красной заливкой) — если суп закончился, то обратно он не появится. Запомним этот факт, он нам понадобится позже.
Рассмотрим также случай для чётного
Мы видим, что для нечетного
Алгоритм №1. Динамическое программирование
Таким образом стандартное решение данной задачи в неоптимизированном случае выглядит следующим образом.
Находим начальное
Создаем массив вероятностей
, состоящий из строк, столбцов. Обозначим индекс элемента соответствующего . Пусть индексация начинается с 0, тогда . Строка соответствует началу дня, когда порций съедено, строка — когда оба супа кончились; Задаём
, все остальные элементы в этой строке равны ; Инициализируем переменную, в которой будем хранить результат
; Инициализируем переменную, в которой будем хранить текущее значение полусуммы
; Запускаем цикл по строкам
от до включительно; Вычисляем
на текущем шаге: . Если получилась меньше , то присваиваем (любой остаток считается полноценной порцией); Запускаем цикл по всем столбцам
от до ; Для каждого
элемента строки считаем вероятность: Если индекс переполняется, соответствующее слагаемое не учитываем;
Если оба супа закончились одновременно
, прибавляем половину к ; Иначе если только суп с котом закончился
, зануляем его ; Иначе если закончился суп с говядиной
, прибавляем полученную вероятность к ответу и зануляем ; Переходим к следующему элементу
и повторяем 9-13; Переходим к следующей строке
и повторяем 9-13; Ответ находится в
.
Вот программка на C++. Разумеется, можно сделать красиво, можно заметить, что массив избыточен, что нам надо хранить только предыдущую и следующую строчки и т.п.
double soupServings(int n) {
int s0 = (n-1)/25+1;
vector<vector<double>> P((s0+1)/2+1, vector<double>(2*s0+1,0.));
int d0 =s0;
P[0][d0] = 1.;
double ans = 0.;
int s = s0;
for (int i = 1; i< P.size(); ++i) {
s -= 2;
if (s<0) s = 0;
for (int j = 0; j< P.front().size(); ++j) {
if (j-2 >= 0) P[i][j] += P[i-1][j-2]*0.25;
if (j-1 >= 0) P[i][j] += P[i-1][j-1]*0.25;
P[i][j] += P[i-1][j]*0.25;
if (j+1 < P.front().size()) P[i][j] += P[i-1][j+1]*0.25;
if (j==d0 && s <= 0) {
ans += P[i][j]*0.5;
P[i][j] = 0;
}
else if (j-d0 >= s) {
ans += P[i][j];
P[i][j] = 0;
}
else if (j-d0 <= -s) P[i][j] = 0;
}
}
return ans;
И все бы хорошо, но временная сложность получается
Однако, мы можем заметить, что с ростом if (n>5000) return 1.;. При
Наблюдения и размышления (продолжение)
Вышеизложенное всем известно и никому не интересно. А что, если придумать решение
Прежде всего вспомним, что область влияния состояний, которые соответствуют закончившемуся супу, ограничена серыми областями на рисунке. Что будет, если мы не будем обнулять эти вероятности? Раз они остаются в серой зоне, мы можем их оставить "на потом", и сложить уже только на последнем шаге.
Мы получили весьма интригующую картину. Вероятности оказались симметричны, более того, получившееся дерево одинаково для всех
К сожалению, ответ не совпадает. И мы видим, почему. Состояние с
Тогда давайте уменьшим число шагов на 1, а последний сделаем "ручками". Для нечетных
Для четных
Ура! Получилось.
Но что дальше? Нам все равно придется строить пирамиду. Или нет?
Избавившись от граничный условий, мы получили следующую задачу. Есть целое число
Если бы у нас было, к примеру, только два возможных исхода, мы получили бы биномиальное распределение https://ru.wikipedia.org/wiki/Биномиальное_распределение.
О биномиальном распределении
Пусть мы бросаем монетку
Каждый индекс в списке встречается ровно
Таким образом, надо разделить общее число перестановок на число перестановок отдельно орлов и число перестановок отдельно решек. Получаем биномиальный коэффициент https://ru.wikipedia.org/wiki/Биномиальный_коэффициент:
Это число возможных исходов серии бросков, когда выпадает ровно
Но у нас не два, а четыре варианта для каждого шага. И тут мы вспоминаем, что четыре - это дважды два. Что если вместо каждого броска с четырьмя исходами, делать два броска с двумя исходами? Вместо того, чтобы выбирать одно из четырех возможных слагаемых
Выпишем все возможные исходы:
выбрали
и : , вероятность ; выбрали
и : , вероятность ; выбрали
и : , вероятность ; выбрали
и : , вероятность .
Получается, два бинарных события в сумме дают нам искомое событие с четырьмя исходами. Такой трюк можно провернуть далеко не в каждом случае, но здесь можно.
Какова вероятность, что после
Пусть в паре
В свою очередь, в паре
Чтобы получить
Выразим
Видим, что
Формулу получили, осталось применить её к нашей задаче.
Посчитаем вероятность того, что на шаге
Вернемся к исходной форме выражения:
Однако, если мы применим данную формулу на предпоследнем шаге, результат будет включать вероятности, которые мы хотели обрабатывать "ручками", для случая
Выпишем формулы для них:
Посмотрев на рисунки 4 и 5 и подумав, получим окончательное выражение для искомой вероятности того, что суп с говядиной закончится раньше. Вспоминаем, что максимальное количество порций
для нечетных s:
для четных s:
Теперь бы все это посчитать.
Для начала, нам нужны биномиальные коэффициенты. Поскольку
будем считать только половину из них. Но если
Тут возникает обратная проблема:
А так как факториалы считаются последовательно, следующий выражается через предыдущий, то умножение на
Параллельно будем считать сумму:
Чуть раньше, вы наверное, удивлялись, как же так, ведь
Кстати, вы помните, что
Алгоритм №2.1. Расчет вероятностей биномиального распределения
Создаём массивы для хранения биномиальных коэффициентов
и сумм размером каждый; Инициализируем переменную
, в которой будем хранить текущий биномиальный коэффициент деленный на какую-то (сами не знаем, какую, но можем сочинить) степень двойки; Записываем первый коэффициент
(помним, что целочисленные степени двойки считаются очень просто); То же самое записываем в первую ячейку массива сумм
; Запускаем цикл по
от до включительно; Модифицируем значение:
Вы можете легко получить это выражение, если разделите
на ; Записываем биномиальный коэффициент с учетом уже использованных степеней двойки:
; Вычисляем текущую сумму:
; Повторяем 6-9
Удобно сделать отдельный класс, который при инициализации будет считать коэффициенты (точнее, вероятности), а потом по запросу выдавать, в зависимости от того, из какой половины — посчитанной или симметричной — требуется коэффициент:
class Binom {
vector<double> binomials;
vector<double> sums;
int i;
public:
Binom(int _i) : i(_i), binomials(_i/2 + 1), sums(_i/2 + 1, 0) {
double val = 1.;
binomials.front() = val*exp2(-i);
sums.front() = binomials.front();
for (int k = 1; k < binomials.size(); k++) {
val *= (i-k+1.) / k;
binomials[k] = val*exp2(-(i-2*k+2));
sums[k] = sums[k-1] + binomials[k];
val /= 4;
}
}
double get_k(size_t k) {
if (k < 0) return 0.;
if (k < binomials.size()) return binomials[k];
if (k <= i) return binomials[i - k];
return 0;
}
double get_sum(int k) {
if (k < 0) return 0.;
if (k < sums.size()) return sums[k];
if (k < i) return 1. - sums[i - k - 1];
return 1.;
}
};
Отметим, что здесь мы возвращаем коэффициент 0, если
Наконец, начинаем считать. Только предварительно переобозначим индекс суммирования
напишем:
И суммировать все выражения будем до
Да, вы ведь еще помните, что
Алгоритм №2.2. O(n)/O(n)
Находим начальное
; Находим индекс предпоследнего шага
; Считаем вероятности биномиального распределения и их суммы;
Задаем начальные значения сумм
; Запускаем цикл по
от до включительно; Вычисляем сумму по
в : Добавляем слагаемые к искомым вероятностям:
Увеличиваем
на и повторяем 6-8; если
нечетное, выдать ответ: если
четное, выдать ответ:
double soupServings(int n) {
if (n == 0) return 0.5;
if (n > 5000) return 1.;
int s = (n - 1) / 25 + 1;
int i = (s - 1) / 2;
Binom bin(i);
double ps = 0., p0 = 0., p_1 = 0., p1 = 0.;
for (int q = 0; q*2 <= i; q++) {
ps += (1. - bin.get_sum(q*2-1)) * bin.get_k(q);
p0 += bin.get_k(2*q) * bin.get_k(q);
p_1 += bin.get_k(2*q + 1) * bin.get_k(q);
p1 += bin.get_k(2*q + 1) * bin.get_k(q + 1);
}
double ans = 1. - ps + p0*0.625;
if (!(s&1)) ans += p_1*0.375 - p1*0.125;
return ans;
}
Решение
Кстати, if (n > 5000) return 1.; в начале оставляем все равно. Мы ведь не дурные, чтобы каждый раз заново вычислять единицу, правда?.
Алгоритм №3. O(n)/O(1)
И тут кто-то вспомнил, я ведь обещал
Нет, и у нас уже есть всё, что нужно. Мы просто будем вычислять биномиальные коэффициенты по мере надобности, параллельно вычислению сумм. Выглядит это примерно так:
double soupServings(int n) {
if (n == 0) return 0.5;
if (n > 5000) return 1.;
int s = (n - 1) / 25 + 1;
int i = (s+1) / 2 - 1;
double ps = 1., ksum = 1.;
double p0 = 1., p_1 = i, p1 = 0.;
double bin_q = 1.0, bin_2q = i;
double iP1 = i + 1;
for (int q = 1; q*2 <= i; q++) {
// Renormalization
bin_q /= 4;
bin_2q /= 4;
ksum /= 4; // Includes bin_2q
ps /= 4; // Includes bin_q
p0 /= 16; // Includes bin_q * bin_2q
p_1 /= 16; // Includes bin_q * bin_2q
p1 /= 16; // Includes bin_q * bin_2q
// Calculation
bin_q *= iP1/q - 1.;
p1 += bin_q * bin_2q;
ksum += bin_2q;
ps += bin_q * (1. - ksum * exp2(2*q - i));
bin_2q *= iP1/(2*q) - 1.;
ksum += bin_2q;
p0 += bin_q * bin_2q;
bin_2q *= iP1/(2*q+1) - 1;
p_1 += bin_q * bin_2q;
}
if (i & 1) {
int q = (i+1) / 2;
bin_q *= iP1/q - 1.;
p1 += bin_q * bin_2q;
ps /= 2;
p0 /= 4;
p_1 /= 4;
p1 /= 4;
}
double ans = 1. - ps + p0*0.625;
if (!(s&1)) ans += p_1*0.375 - p1*0.125;
return ans;
}
Не спрашивайте, как, но оно работает ?.
Заключение
Что в итоге? Решили очень полезную для владельцев тошниловок задачу. Порисовали картинки. Поиграли с биномиальным распределением. Научились считать отношения больших чисел без переполнений. Немного повыпендривались.