Суп с котом: забавная задачка с LeetCode
Есть на LeetCode задачка. Medium уровня, на динамическое программирование, ничего особенного. Однако, если присмотреться внимательнее, она окажется интереснее, чем на первый взгляд. Кроме того, можно получить более быстрое решение, чем «официальное».
Условие (в адаптации для постсоветского читателя)
Недалеко от вокзала, есть маленькая столовая. Каждое утро в ней варят две одинаковых кастрюли супа: один с говядиной , другой, собственно, с котом . Порция супа составляет миллилитров.
Клиенты каждой категории приходят в случайном порядке, каждая категория с вероятностью %.
Если какого-то вида супа для текущего клиента не хватило — ему подают сколько есть, не доливая ни того, ни другого. При этом заказ считается исполненным.
При заданном объёме кастрюли мл требуется определить, какова вероятность того, что говядина закончится раньше, и припозднившиеся гости будут жрать кушать только и исключительно несчастного кота. Кроме того, к ответу надо добавить вероятности того, что оба супа закончатся одновременно. Результат требуется получить с погрешностью не более .
Объем кастрюли может принимать значения от до .
Наблюдения и размышления
Прежде всего, заметим, что объем половника у них мл, поэтому, вместо того, чтобы считать в миллилитрах, будем считать в половниках. Тогда объём кастрюли будет половников. Если не кратно , то округляем в большую сторону — как указано в условии, остатки считаются за полную порцию.
В начальный момент у нас имеется половников говядины и половников кота.
После первого заказа может остаться:
, если гость особо уважаемый;
, если гость просто уважаемый;
, если гость обычный;
, если гость алкаш.
Или, после -го заказа:
, если гость особо уважаемый;
, если гость просто уважаемый;
, если гость обычный;
, если гость алкаш.
Тут мы, внезапно, замечаем, что общее количество супа с каждой порцией уменьшается ровно на половника, а разность увеличивается либо на 4, либо на 2, либо не меняется, либо уменьшается на 2. Учитывая, что с утра , она всегда чётная, как и сумма, которая в начальный момент .
Введём новые переменные: и .
В начальный момент .
После -го заказа эти величины меняются следующим образом:
Рассмотрим вероятность того, что после заказа разность равна . Если в этот момент , значит:
либо перед этим было , но пришёл особо уважаемый гость и разность изменилась на ;
либо было , но пришёл просто уважаемый гость и разность изменилась на ;
либо было , пришёл обычный гость и разность не изменилась;
либо было , но пришёл алкаш и разность изменилась на .
Все эти события имеют место с вероятностью . Тогда:
Таким образом, если мы создадим массив , зададим , то сможем последовательно посчитать вероятности после каждого заказа.
Но в какой-то момент один (или оба) супа закончатся. Когда это произойдёт?
Возвращаясь от наших переменных обратно к количествам супов, получим:
Количество супа с говядиной становится равным при , а супа с котом при . То есть, как только будет достигнуто состояние, в котором , мы должны прекратить сервировку. Это значит, что соответствующие им вероятности , где , не внесут вклада в вероятности последующих событий и их надо обнулить. Если при этом первым закончился суп с говядиной, то есть станет равна . Таким образом, максимальное количество порций равно , при чем последняя может быть неполной.
Рассмотрим, например, .
Рисунок 1
Возможные состояния супов на каждом шаге отмечены кружочечком.
Границы серой области показывают уменьшение на каждом шаге. Если попало в серую область, значит после данного заказа какой-то из супов закончился. Если закончился суп с говядиной — кружок зелёный, если закончился суп с котом — красный, если оба — двухцветный.
В голубых кружочках указана искомая вероятность того, что суп с говядиной закончится раньше. Для каждого шага она равна сумме всех зелёных значений, лежащих ниже или на том же уровне (двухцветный входит с коэффициентом ).
Стрелками показано, какое состояние может получиться на следующем шаге (чтобы не загромождать, показаны только крайние случаи).
Отметим, что состояния, где один из супов закончился, на последующие не влияют, но если бы мы продолжали их строить, то они давали бы вклад только в серой области (отмечено прозрачной зеленой и красной заливкой) — если суп закончился, то обратно он не появится. Запомним этот факт, он нам понадобится позже.
Рассмотрим также случай для чётного .
Рисунок 2
Мы видим, что для нечетного и следующего за ним четного количество раздач не меняется.
Алгоритм №1. Динамическое программирование
Таким образом стандартное решение данной задачи в неоптимизированном случае выглядит следующим образом.
Находим начальное
Создаем массив вероятностей , состоящий из строк, столбцов. Обозначим индекс элемента соответствующего . Пусть индексация начинается с 0, тогда . Строка соответствует началу дня, когда порций съедено, строка — когда оба супа кончились;
Задаём , все остальные элементы в этой строке равны ;
Инициализируем переменную, в которой будем хранить результат ;
Инициализируем переменную, в которой будем хранить текущее значение полусуммы ;
Запускаем цикл по строкам от до включительно;
Вычисляем на текущем шаге: . Если получилась меньше , то присваиваем (любой остаток считается полноценной порцией);
Запускаем цикл по всем столбцам от до ;
Для каждого элемента строки считаем вероятность:
Если индекс переполняется, соответствующее слагаемое не учитываем;
Если оба супа закончились одновременно , прибавляем половину к ;
Иначе если только суп с котом закончился , зануляем его ;
Иначе если закончился суп с говядиной , прибавляем полученную вероятность к ответу и зануляем ;
Переходим к следующему элементу и повторяем 9–13;
Переходим к следующей строке и повторяем 9–13;
Ответ находится в .
Вот программка на C++. Разумеется, можно сделать красиво, можно заметить, что массив избыточен, что нам надо хранить только предыдущую и следующую строчки и т.п.
double soupServings(int n) {
int s0 = (n-1)/25+1;
vector> P((s0+1)/2+1, vector(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.;
. При мы имеем , то есть получается массив , а если оптимизировать и того меньше. Именно такой тип решения и предлагается в качестве «официального» на LeetCode.
Наблюдения и размышления (продолжение)
Вышеизложенное всем известно и никому не интересно. А что, если придумать решение по времени и по затратам памяти? Давайте придумаем.
Прежде всего вспомним, что область влияния состояний, которые соответствуют закончившемуся супу, ограничена серыми областями на рисунке. Что будет, если мы не будем обнулять эти вероятности? Раз они остаются в серой зоне, мы можем их оставить «на потом», и сложить уже только на последнем шаге.
Рисунок 3.
Мы получили весьма интригующую картину. Вероятности оказались симметричны, более того, получившееся дерево одинаково для всех , разница только в том, где его обрубать.
К сожалению, ответ не совпадает. И мы видим, почему. Состояние с на предпоследнем шаге влияет на правую серую область и на центральное значение, а также состояние с на предпоследнем шаге влияет на центральное.
Тогда давайте уменьшим число шагов на 1, а последний сделаем «ручками». Для нечетных надо дополнительно обработать только один белый кружок .
Рисунок 4.
Для четных ручками будем обрабатывать аж 3 кружка:
Рисунок 5.
Ура! Получилось.
Но что дальше? Нам все равно придется строить пирамиду. Или нет?
Избавившись от граничный условий, мы получили следующую задачу. Есть целое число , в начальный момент . На каждом шаге к нему добавляется , , , с одинаковой вероятностью, обозначим это событие как . Найти вероятность того, что на шаге получится .
Если бы у нас было, к примеру, только два возможных исхода, мы получили бы биномиальное распределение https://ru.wikipedia.org/wiki/Биномиальное_распределение.
О биномиальном распределении
Пусть мы бросаем монетку раз. Какова вероятность, что выпадет ровно орлов, считая, что вероятность выпадения орла и решки одинакова? Посчитаем количество таких исходов. Выпишем подряд индексы бросков . Переставим их так, чтобы все броски, при которых выпал орёл, были в начале списка. То есть для бросков, соответствующих первым индексам выпал орел, для остальных решка.
Каждый индекс в списке встречается ровно раз. Всего имеем вариантов расположения индексов. Однако, если мы перетасуем индексы, которые соответствуют выпадению орла, только среди «своих», мы получим тот же самый случай. Аналогично для решки.
Таким образом, надо разделить общее число перестановок на число перестановок отдельно орлов и число перестановок отдельно решек. Получаем биномиальный коэффициент https://ru.wikipedia.org/wiki/Биномиальный_коэффициент:
Это число возможных исходов серии бросков, когда выпадает ровно орлов. Чтобы найти вероятность, надо разделить его на общее число возможных исходов, которое равно — для каждого из бросков возможны два варианта, а всего бросков .
Но у нас не два, а четыре варианта для каждого шага. И тут мы вспоминаем, что четыре — это дважды два. Что если вместо каждого броска с четырьмя исходами, делать два броска с двумя исходами? Вместо того, чтобы выбирать одно из четырех возможных слагаемых , выберем сначала одно из двух , а потом еще одно из другой пары .
Выпишем все возможные исходы:
выбрали и : , вероятность ;
выбрали и : , вероятность ;
выбрали и : , вероятность ;
выбрали и : , вероятность .
Получается, два бинарных события в сумме дают нам искомое событие с четырьмя исходами. Такой трюк можно провернуть далеко не в каждом случае, но здесь можно.
Какова вероятность, что после пар бросков ?
Пусть в паре раз выпала двойка, вероятность этого равна .
В свою очередь, в паре раз выпала , вероятность .
Чтобы получить необходимо, чтобы . Просуммируем вероятности:
Выразим через :
Видим, что не может быть меньше, чем , и больше, чем .
Формулу получили, осталось применить её к нашей задаче.
Посчитаем вероятность того, что на шаге супа с котом больше или столько же, сколько супа с говядиной . Почему не наоборот? Потому что это число меньше, а значит, при одинаковой относительной погрешности вычисления будем иметь меньшую абсолютную погрешность. Кстати отметим, что минимальное возможное значение на каждом шаге равно . Оно соответствует случаю, когда каждый раз выпадала .
Вернемся к исходной форме выражения:
не может быть больше, чем , и в то же время . Если внешнее суммирование делать по , то его пределы будут от до . Тогда:
Однако, если мы применим данную формулу на предпоследнем шаге, результат будет включать вероятности, которые мы хотели обрабатывать «ручками», для случая , а для четных также и . К тому же, нам будут нужны сами эти вероятности отдельно, а также вероятность для четных .
Выпишем формулы для них:
Посмотрев на рисунки 4 и 5 и подумав, получим окончательное выражение для искомой вероятности того, что суп с говядиной закончится раньше. Вспоминаем, что максимальное количество порций , значит на предпоследнем шаге :
для нечетных s:
для четных s:
Теперь бы все это посчитать.
Для начала, нам нужны биномиальные коэффициенты. Поскольку у нас фиксировано, можно рассчитать их заранее. Проницательно отметив, что
будем считать только половину из них. Но если велико, то и биномиальные коэффициенты большие, может случиться переполнение. К счастью, нам нужны не сами коэффициенты, а вероятности, то есть:
Тут возникает обратная проблема: — очень маленькая величина.
А так как факториалы считаются последовательно, следующий выражается через предыдущий, то умножение на даст .Чтобы решить эту проблему, будем делить на степени двойки постепенно. Сначала на 1, потом на 4, потом на 16 и т.д. Почему именно так? Потому что мы считаем только половину из биномиальных коэффициентов, соответственно, к концу дойдет до нужной степени. А самое главное, наибольшее значение как раз в середине:
Параллельно будем считать сумму:
Чуть раньше, вы наверное, удивлялись, как же так, ведь содержит две суммы, почему же я заявляю время ? А потому, что суммы по считается здесь один раз и за линейное время. Очень удобно: посчитал коэффициент и сразу прибавил.
Кстати, вы помните, что ?
Алгоритм №2.1. Расчет вероятностей биномиального распределения
Создаём массивы для хранения биномиальных коэффициентов и сумм размером каждый;
Инициализируем переменную , в которой будем хранить текущий биномиальный коэффициент деленный на какую-то (сами не знаем, какую, но можем сочинить) степень двойки;
Записываем первый коэффициент (помним, что целочисленные степени двойки считаются очень просто);
То же самое записываем в первую ячейку массива сумм ;
Запускаем цикл по от до включительно;
Модифицируем значение:
Вы можете легко получить это выражение, если разделите на ;
Записываем биномиальный коэффициент с учетом уже использованных степеней двойки: ;
Вычисляем текущую сумму: ;
Повторяем 6–9
Удобно сделать отдельный класс, который при инициализации будет считать коэффициенты (точнее, вероятности), а потом по запросу выдавать, в зависимости от того, из какой половины — посчитанной или симметричной — требуется коэффициент:
class Binom {
vector binomials;
vector 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, если выходит за допустимые пределы, что сделает подсчет сумм удобнее. Сумма, соответственно, 0 для , 1 для , но это добавили просто для согласованности, реально таких значений не будет.
Наконец, начинаем считать. Только предварительно переобозначим индекс суммирования в выражении для . Вместо:
напишем:
И суммировать все выражения будем до , все равно для неправильных значений коэффициенты будут 0.
Да, вы ведь еще помните, что ?
Алгоритм №2.2. O (n)/O (n)
Находим начальное ;
Находим индекс пр