Вчера вечером я пытался решить проблему № 15 от Euler Проекта:
При запуске в верхнем левом углу 2×2 сетка, существует 6 маршрутов (не отслеживая в обратном порядке) к правому нижнему углу.
(источник: projecteuler.net)Сколько маршрутов там через 20×20 сетка?
Я полагал, что это не должно быть настолько твердо, таким образом, я записал основную рекурсивную функцию:
const int gridSize = 20;
// call with progress(0, 0)
static int progress(int x, int y)
{
int i = 0;
if (x < gridSize)
i += progress(x + 1, y);
if (y < gridSize)
i += progress(x, y + 1);
if (x == gridSize && y == gridSize)
return 1;
return i;
}
Я проверил, что это работало на меньшие сетки такой как 2×2 или 3×3 и затем установило его для выполнения за 20×20 сетка. Вообразите мое удивление, когда 5 часов спустя программа все еще счастливо производила подсчеты, и сделанных только приблизительно 80% (на основе исследования ее текущей позиции / маршрут в сетке).
Очевидно я иду об этом неправильным путем. Как Вы решили бы эту проблему? Я думаю, что это должно быть решено с помощью уравнения, а не метода как мой, но это - к сожалению, не сильная моя сторона.
Обновление:
У меня теперь есть рабочая версия. В основном это кэширует результаты, полученные прежде, когда n×m блок все еще остается быть пересеченным. Вот код наряду с некоторыми комментариями:
// the size of our grid
static int gridSize = 20;
// the amount of paths available for a "NxM" block, e.g. "2x2" => 4
static Dictionary pathsByBlock = new Dictionary();
// calculate the surface of the block to the finish line
static long calcsurface(long x, long y)
{
return (gridSize - x) * (gridSize - y);
}
// call using progress (0, 0)
static long progress(long x, long y)
{
// first calculate the surface of the block remaining
long surface = calcsurface(x, y);
long i = 0;
// zero surface means only 1 path remains
// (we either go only right, or only down)
if (surface == 0)
return 1;
// create a textual representation of the remaining
// block, for use in the dictionary
string block = (gridSize - x) + "x" + (gridSize - y);
// if a same block has not been processed before
if (!pathsByBlock.ContainsKey(block))
{
// calculate it in the right direction
if (x < gridSize)
i += progress(x + 1, y);
// and in the down direction
if (y < gridSize)
i += progress(x, y + 1);
// and cache the result!
pathsByBlock[block] = i;
}
// self-explanatory :)
return pathsByBlock[block];
}
Вызов его 20 раз, для сеток с размером 1×1 через 20×20 производит следующий вывод:
There are 2 paths in a 1 sized grid
0,0110006 seconds
There are 6 paths in a 2 sized grid
0,0030002 seconds
There are 20 paths in a 3 sized grid
0 seconds
There are 70 paths in a 4 sized grid
0 seconds
There are 252 paths in a 5 sized grid
0 seconds
There are 924 paths in a 6 sized grid
0 seconds
There are 3432 paths in a 7 sized grid
0 seconds
There are 12870 paths in a 8 sized grid
0,001 seconds
There are 48620 paths in a 9 sized grid
0,0010001 seconds
There are 184756 paths in a 10 sized grid
0,001 seconds
There are 705432 paths in a 11 sized grid
0 seconds
There are 2704156 paths in a 12 sized grid
0 seconds
There are 10400600 paths in a 13 sized grid
0,001 seconds
There are 40116600 paths in a 14 sized grid
0 seconds
There are 155117520 paths in a 15 sized grid
0 seconds
There are 601080390 paths in a 16 sized grid
0,0010001 seconds
There are 2333606220 paths in a 17 sized grid
0,001 seconds
There are 9075135300 paths in a 18 sized grid
0,001 seconds
There are 35345263800 paths in a 19 sized grid
0,001 seconds
There are 137846528820 paths in a 20 sized grid
0,0010001 seconds
0,0390022 seconds in total
Я принимаю ответ danben, потому что его помогшие меня находят это решение больше всего. Но upvotes также Tim Goodman и Agos :)
Бонусное обновление:
После чтения ответа Eric Lippert я бросил другой взгляд и переписал его несколько. Основная идея является все еще тем же, но кэширующаяся часть была вынута и вставила отдельную функцию, как в примере Eric. Результатом является некоторый намного более изящно выглядящий код.
// the size of our grid
const int gridSize = 20;
// magic.
static Func Memoize(this Func f)
{
// Return a function which is f with caching.
var dictionary = new Dictionary();
return (A1 a1, A2 a2) =>
{
R r;
string key = a1 + "x" + a2;
if (!dictionary.TryGetValue(key, out r))
{
// not in cache yet
r = f(a1, a2);
dictionary.Add(key, r);
}
return r;
};
}
// calculate the surface of the block to the finish line
static long calcsurface(long x, long y)
{
return (gridSize - x) * (gridSize - y);
}
// call using progress (0, 0)
static Func progress = ((Func)((long x, long y) =>
{
// first calculate the surface of the block remaining
long surface = calcsurface(x, y);
long i = 0;
// zero surface means only 1 path remains
// (we either go only right, or only down)
if (surface == 0)
return 1;
// calculate it in the right direction
if (x < gridSize)
i += progress(x + 1, y);
// and in the down direction
if (y < gridSize)
i += progress(x, y + 1);
// self-explanatory :)
return i;
})).Memoize();
Между прочим, я не мог думать о лучшем способе использовать эти два аргумента в качестве ключа для словаря. Я погуглил вокруг немного, и кажется, что это - общее решение. О, хорошо.
Это можно сделать намного быстрее, если вы используете динамическое программирование. (сохранение результатов подзадач, а не их пересчет). Динамическое программирование может применяться к задачам, которые демонстрируют оптимальную подструктуру - это означает, что оптимальное решение может быть построено из оптимальных решений подзадач (кредит Википедия ).
Я бы предпочел не выдавать ответ, но подумайте, как количество путей к правому нижнему углу может быть связано с количеством путей к соседним квадратам.
Также - если бы вы собирались решить эту проблему вручную, как бы вы это сделали?
Несмотря на то, что динамическое программирование выглядит привлекательным способом решения проблемы (и делает его интересной задачей кодирования), немного творческого мышления в отношении структур данных дает чтобы дать немедленный ответ.
[остальная часть этого, по сути, является объяснением того, почему ответ Тима Гудмана лучший, для некоторого значения "лучший"]
Если у нас есть сетка nXm, мы можем представить каждый допустимый маршрут из угла в угол в виде битовой строки n + m, используя либо 0, либо 1 для представления «вниз». Немного подумав, можно подумать, что точное количество маршрутов - это количество способов взять N элементов из N + M элементов, и это, для удобства, оказывается стандартным простым комбинаторным M над N.
Итак, для любого N + M прямоугольник, количество возможных маршрутов из верхнего левого угла в нижний правый угол равно (n + m) (n + m-1) ... (m + 1) / ( п * (п-1) * ... 1).
Самая быстрая программа - это та, которая не должна хранить очень много, много использовать для хранения и (в идеале) имеет ответ в закрытой форме.
Вы можете вдвое сократить время расчета, приняв во внимание, что, как только вы уменьшите его до квадрата, сетка станет симметричной. Таким образом, если у вас есть равное количество свободного места в направлении X и Y для перемещения, вы можете использовать тот же расчет для увеличенного хода по оси x и увеличения хода по оси y.
При этом я сделал это на python и много кэшировал результаты, чтобы избежать пересчета.
Все указывают на динамическое программирование и кеширование результатов. У меня где-то есть сценарий Ruby, который закончился очень большим хешем, где все данные были сохранены. По правде говоря, как и большинство задач проекта Эйлера, это скрытый математический трюк, и есть способы получить результат с помощью простых вычислений.
Быстрое решение без программирования (на основе комбинаторики)
Я так понимаю, что «без возврата» означает, что мы всегда либо увеличиваем x, либо увеличиваем y.
Если это так, то мы знаем, что всего у нас будет 40 шагов, чтобы достичь финиша - 20 увеличений по x, 20 увеличений по y.
Единственный вопрос в том, какие из 40 увеличивают x. Проблема сводится к следующему: сколькими различными способами вы можете выбрать 20 элементов из набора из 40 элементов. (Элементы: шаг 1, шаг 2 и т. Д., И мы выбираем, скажем, те, которые увеличиваются по x).
Для этого есть формула: это биномиальный коэффициент с 40 сверху и 20 снизу.Формула 40! / ((20!) (40-20)!)
, другими словами 40! / (20!) ^ 2
. Вот !
представляет собой факториал. (например, 5! = 5 * 4 * 3 * 2 * 1
)
Отмена одного из 20! и часть 40 !, это становится: (40 * 39 * 38 * 37 * 36 * 35 * 34 * 33 * 32 * 31 * 30 * 29 * 28 * 27 * 26 * 25 * 24 * 23 * 22 * 21) / (20 * 19 * 18 * 17 * 16 * 15 * 14 * 13 * 12 * 11 * 10 * 9 * 8 * 7 * 6 * 5 * 4 * 3 * 2 * 1)
. Таким образом, задача сводится к простой арифметической. Ответ: 137 846 528 820
.
Для сравнения обратите внимание, что (4 * 3) / (2 * 1)
дает ответ из их примера, 6
.
Хотя динамическое программирование, безусловно, является правильным способом решения такого рода проблем, этот конкретный пример показывает закономерность, которую можно использовать.
Вы можете увидеть проблему в расположении ряда «правильных» и «вниз», при этом опасаясь, что не следует пересчитывать несколько раз идентичные расположения.
Например, решения проблемы размера 2 (указанные на изображениях в вопросе) можно увидеть следующим образом:
→→↓↓
→↓→↓
→↓↓→
↓→→↓
↓→↓→
↓↓→→
Итак, для любой сетки со стороной n вы можете найти решение с помощью комбинаторики :
from math import factorial
n = 20
print factorial(2*n)/(factorial(n)*factorial(n))
2n! это количество комбинаций 20 → + 20 ↓, а два n! учитывать идентичные способы расположения знаков → и ↓.
Как отмечали другие, у этой конкретной проблемы есть дискретное математическое решение. Но предположим, что вы действительно хотите решить ее рекурсивно. Ваша проблема с производительностью заключается в том, что вы решаете одни и те же проблемы снова и снова.
Позвольте мне показать вам небольшой трюк программирования более высокого порядка, который принесет большие дивиденды. Возьмем более простую рекурсивную задачу:
long Fib(n)
{
if (n < 2) return 1;
return Fib(n-1) + Fib(n-2);
}
Вы просите это вычислить Fib (5). Это вычисляет Fib (4) и Fib (3). Вычисление Fib (4) вычисляет Fib (3) и Fib (2). Вычисление Fib (3) вычисляет Fib (2) и Fib (1). Вычисление Fib (2) вычисляет Fib (1) и Fib (0). Теперь вернемся и снова вычислим Fib (2) . Затем мы возвращаемся и снова вычисляем Fib (3) . Огромное количество пересчетов.
Предположим, мы кэшировали результаты вычислений. Затем при втором запросе вычисления мы просто возвращали кешированный результат. Теперь перейдем к трюку высшего порядка.Я хочу представить эту концепцию «кэширования результатов функции» как функцию, которая принимает функцию и возвращает мне функцию с этим прекрасным свойством. Я напишу его как метод расширения для функций:
static Func<A, R> Memoize(this Func<A, R> f)
{
// Return a function which is f with caching.
var dictionary = new Dictionary<A, R>();
return (A a)=>
{
R r;
if(!dictionary.TryGetValue(a, out r))
{ // cache miss
r = f(a);
dictionary.Add(a, r);
}
return r;
};
}
Теперь мы немного переписываем Fib:
Func<long, long> Fib = null;
Fib = (long n) =>
{
if (n < 2) return 1;
return Fib(n-1) + Fib(n-2);
};
Хорошо, у нас есть немемоизованная функция. Теперь волшебство:
Fib = Fib.Memoize();
И бум, когда мы вызываем Fib (5), теперь мы выполняем поиск по словарю. 5 отсутствует в словаре, поэтому мы вызываем исходную функцию. Это вызывает Fib (4), который выполняет еще один поиск в словаре и пропускает. Это вызывает Fib (3) и так далее. Когда мы возвращаемся к вызову Fib (2) и Fib (3) во время секунд , результаты уже находятся в словаре, поэтому мы не вычисляем их повторно.
Написание версии с двумя аргументами:
static Func<A1, A2, R> Memoize(this Func<A1, A2, R>) { ... }
не так уж сложно и оставлено как упражнение. Если вы это сделаете, вы можете просто взять свою оригинальную красивую рекурсивную логику, выполнить простое переписывание в лямбду и сказать:
progress = progress.Memoize();
, и внезапно ваша производительность увеличится без потери читабельности исходного алгоритма.
Решения отражаются по диагонали с северо-запада на юго-восток вашей сети. Итак, вы должны вычислять решения только для верхней правой половины сетки, а затем отражать их, чтобы получить вторую половину ...
Кстати, вы можете еще больше повысить свою производительность, осознав, что 2x3 будет иметь такое же количество путей, что и 3x2. Ваша функция запоминания, похоже, учитывает только строку, которая представляет собой точно столбцы x строки. Однако вы можете включить в свое запоминание, чтобы указать общие пути для ключа 2x3, а также 3x2.
Таким образом, когда вы запоминаете 4x2 и т. Д., Он автоматически заполняет 2x4 таким же количеством путей. Это сократит ваше время, так как вы уже вычислили все пути через эту область поверхности однажды, так зачем делать это снова?
Мое решение было неожиданным, но довольно легким для понимания:
Количество маршрутов к данному перекрестку в сетке - это сумма количества маршрутов к двум его соседям.
Учитывая, что существует только один маршрут к каждой из точек на верхнем и левом краях, довольно легко перебрать оставшиеся точки и заполнить пробелы.
для x или y = 0: сетка [x, y] = 1
для x и y> = 1: сетка [x, y] = сетка [x-1, y] + сетка [x, y -1]
Итак, после перебора всех квадратов окончательный ответ содержится в сетке [20,20].
Вы фактически вычисляете каталонские числа , для которых доступна замкнутая формула с использованием ряда Тейлора.
Итак, одна программа, вычисляющая решение, могла бы вычислить биномиальные коэффициенты, которые сложно получить правильно, если у вас нет класса BigInt ...