Проект Эйлера № 15 - PullRequest
       34

Проект Эйлера № 15

44 голосов
/ 04 февраля 2010

Прошлой ночью я пытался решить задачу № 15 от Project Euler :

Начиная с верхнего левого угла Сетка 2х2, есть 6 маршрутов (без возврат) внизу справа угол.

alt text
(источник: 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<string, long> pathsByBlock = new Dictionary<string, long>();

// 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

Я принимаю ответ Данбена, потому что он помог мне найти это решение наиболее. Но приветствует также Тим Гудман и Агос:)

Бонусное обновление :

Прочитав ответ Эрика Липперта, я еще раз посмотрел и несколько переписал его. Основная идея все та же, но часть кэширования была удалена и помещена в отдельную функцию, как в примере Эрика. В результате получается более элегантный код.

// the size of our grid
const int gridSize = 20;

// magic.
static Func<A1, A2, R> Memoize<A1, A2, R>(this Func<A1, A2, R> f)
{
    // Return a function which is f with caching.
    var dictionary = new Dictionary<string, R>();
    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<long, long, long> progress = ((Func<long, long, long>)((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();

Кстати, я не мог придумать лучшего способа использовать два аргумента в качестве ключа для словаря. Я немного погуглил, и, похоже, это общее решение. Ну хорошо.

Ответы [ 13 ]

48 голосов
/ 04 февраля 2010

Быстрое решение без программирования (на основе комбинаторики)

Я так понимаю, «без возврата» означает, что мы всегда либо увеличиваем x, либо увеличиваем y.

Если это так, мы знаем, что всего у нас будет 40 шагов, чтобы достичь финиша - 20 увеличений в x, 20 увеличений в y.

Единственный вопрос в том, какие из 40 - это 20 увеличений по 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.

40 голосов
/ 04 февраля 2010

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

Я бы предпочел не выдавать ответ, но рассмотрим, как число путей в нижний правый угол может быть связано с количеством путей к соседним квадратам.

Кроме того - если бы вы собирались решить это вручную, как бы вы это сделали?

39 голосов
/ 04 февраля 2010

Как уже отмечали другие, есть конкретное математическое решение этой конкретной проблемы. Но предположим, что вы хотите решить это рекурсивно. Ваша проблема с производительностью заключается в том, что вы решаете одни и те же проблемы снова и снова.

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

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();

И boom, когда мы вызываем Fib (5), теперь мы ищем словарь. 5 нет в словаре, поэтому мы вызываем исходную функцию. Это вызывает Fib (4), который выполняет другой поиск по словарю и пропускает его. Это вызывает Fib (3) и так далее. Когда мы вернемся к вызову Fib (2) и Fib (3) second time, результаты уже будут в словаре, поэтому мы не будем их повторно вычислять.

Написание версии с двумя аргументами:

static Func<A1, A2, R> Memoize(this Func<A1, A2, R>) { ... }

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

progress = progress.Memoize();

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

18 голосов
/ 04 февраля 2010

Хотя динамическое программирование, безусловно, является правильным способом решения такого рода проблем, этот конкретный пример демонстрирует закономерность, которую можно использовать.

Вы можете рассматривать проблему как организацию ряда «правых» и «нисходящих», стараясь не учитывать многократно одинаковые расположения. Например, решения проблемы размера 2 (представленные на изображениях в вопросе) можно увидеть следующим образом:

→→↓↓  
→↓→↓
→↓↓→
↓→→↓
↓→↓→
↓↓→→

Таким образом, для любой сетки стороны n вы можете найти решение с помощью комбинаторики :

from math import factorial
n = 20
print factorial(2*n)/(factorial(n)*factorial(n))

2n! количество аранжировок 20 → + 20 ↓, а два n! учитывать одинаковые способы, которыми могут быть расположены → и ↓.

5 голосов
/ 27 апреля 2010

Кстати, вы можете увеличить свою производительность еще больше, осознав, что 2x3 будет иметь то же количество путей через него, что и 3x2. Ваша функция запоминания, кажется, учитывает только строку, которая является точно столбцами х строк. Однако вы можете включить в свою память, чтобы указать общее количество путей для ключа 2x3, а также 3x2.

Таким образом, когда вы запоминаете 4x2 и т. Д., Он автоматически заполнит 2x4 с тем же количеством путей. Это сократит ваше время, так как вы уже рассчитали все пути через эту площадь поверхности один раз, так зачем делать это снова?

4 голосов
/ 04 февраля 2010

Несмотря на то, что динамическое программирование выглядит привлекательным способом решения проблемы (и делает его интересным вызовом кодирования), немного творческого подхода к структурам данных дает немедленный ответ.

[остальное это, по сути, объяснение того, почему ответ Тима Гудмана лучший, для некоторого значения «лучший»]
Если у нас есть сетка nXm, мы можем представить каждый действительный маршрут от угла к углу как битовую строку n + m, используя либо 0, либо 1 для представления «вниз». Немного больше размышлений дает под рукой то, что точное число маршрутов - это количество способов взять N элементов из N + M элементов, и это, удобно, оказывается стандартным простым комбинаторным M над N.

Таким образом, для любого прямоугольника N + M число возможных маршрутов от верхнего левого до нижнего правого угла равно (n + m) (n + m-1) ... (m + 1) / (n * (n-1) * ... 1).

Самая быстрая программа - та, в которой не нужно много хранить, много использовать для хранения и (в идеале) ответ закрытого типа.

3 голосов
/ 22 июля 2010

Вы фактически вычисляете каталонские числа , для которых доступна замкнутая формула с использованием рядов Тейлора.

Таким образом, одна программа, вычисляющая решение, может вычислить биномиальные коэффициенты, что сложно сделать правильно, если у вас нет класса BigInt ...

1 голос
/ 28 июля 2016

Проблема намного проще, чем многие люди делают это. Путь должен быть последовательностью с 20 «правами» и 20 «падениями». Количество различных последовательностей - это количество способов выбрать 20 позиций для (скажем) «прав» из 40 возможных.

1 голос
/ 29 сентября 2010

Я полагаю, что некоторая математика средней школы была бы полезна здесь, эта ссылка объясняет требуемую формулу комбинации:

http://mathworld.wolfram.com/Combination.html

Теперь, используя это, чтобы найти количество путей через квадратную сетку, формула становится 2n, выберите n. В качестве предупреждения вам нужно будет использовать тип данных, который может содержать довольно большое число

1 голос
/ 04 февраля 2010

Решения отражаются по диагонали от NW до SE вашей сетки. Итак, вы должны только вычислять решения для верхней правой половины сетки, а затем отражать их, чтобы получить вторую половину ...

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...