Подтвердить что ты не робот

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

Прошлой ночью я пытался решить задачу № 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% сделали это (основываясь на изучении ее текущей позиции/маршрута в сетке).

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

Обновление:

У меня сейчас есть рабочая версия. В основном он кэширует результаты, полученные до того, как блок × 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();

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

4b9b3361

Ответ 1

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

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

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

Ответ 2

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

Я считаю, что "нет возврата" означает, что мы всегда либо увеличиваем 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.

Ответ 3

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

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

long Fib(n) 
{
    if (n < 2) return 1;
    return Fib(n-1) + Fib(n-2);
}

Вы просите это вычислить Fib (5). Это вычисляет Fib (4) и Fib (3). Computing Fib (4) вычисляет Fib (3) и Fib (2). Computing Fib (3) вычисляет Fib (2) и Fib (1). Computing 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);
};

ОК, у нас есть наша не memoized функция. Теперь магия:

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

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

Ответ 4

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

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

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

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

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

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

Ответ 5

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

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

Ответ 6

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

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

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

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

Ответ 7

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

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

Ответ 8

Вы можете сократить время вычисления в два раза за счет того, что, как только вы уменьшите его до квадрата, сетка будет симметричной. Таким образом, всякий раз, когда у вас есть равное количество пространства в направлениях X и Y для прохождения остатка, вы можете использовать один и тот же расчет для увеличенного перемещения x и увеличения перемещения y.

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

Ответ 9

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

Ответ 10

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

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

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

Ответ 11

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

Ответ 12

Все указывают на динамическое программирование и результаты кэширования. Я, где-то, Ruby script, который оказался очень большим хешем, где хранились все данные. По правде говоря, как и большинство проблем проекта Эйлера, это скрытый математический трюк, и есть способы получить результат с помощью простого вычисления.

Ответ 13

Мое решение было неинтересным, но довольно легко понять:

Количество маршрутов к данному пересечению на сетке - это сумма количества маршрутов для двух его соседей.

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

для x или y = 0: сетка [x, y] = 1 для x и y >= 1: сетка [x, y] = сетка [x-1, y] + сетка [x, y -1]

Итак, после итерации по всем квадратам окончательный ответ содержится в сетке [20,20].

Ответ 14

def fac(n):
    if n==0: return 1
    else: return n*fac(n-1)

'''
(m,n) is the grid size
'''

def count_paths(m, n): return int(fac(m+n)/(fac(m)*fac(n)))