Находим N’е число Фибоначчи тремя способами за приемлемое время: основы динамического программирования

Задача: посчитать N-е число последовательности, в которой каждый элемент равен сумме двух предыдущих. Такая последовательность называется последовательностью Фибоначчи: 1, 1, 2, 3, 5, 8…

Очень часто на разнообразных олимпиадах попадаются задачи вроде этой, которые, как думается на первый взгляд, можно решить с помощью простого перебора. Но если мы подсчитаем количество возможных вариантов, то сразу убедимся в неэффективности такого подхода: например, простая рекурсивная функция, приведенная ниже, будет потреблять существенные ресурсы уже на 30-ом числе Фибоначчи, тогда как на олимпиадах время решения часто ограничено 1-5 секундами.

int fibo(int n)
{
    if (n == 1 || n == 2) {
        return 1;
    } else {
        return fibo(n - 1) + fibo(n - 2);
    }
}

Давайте подумаем, почему так происходит. Например, для вычисления fibo(30) мы сначала вычисляем fibo(29) и fibo(28). Но при этом наша программа «забывает», что fibo(28) мы уже вычисляли при поиске fibo(29).

Основная ошибка такого подхода «в лоб» в том, что одинаковые значения аргументов функции исчисляются многократно — а ведь это достаточно ресурсоемкие операции. Избавиться от повторяющихся вычислений нам поможет метод динамического программирования — это прием, при использовании которого задача разбивается на общие и повторяющиеся подзадачи, каждая из которых решается только 1 раз — это значительно повышает эффективность программы. Этот метод подробно описан в нашей статье, там же есть и примеры решения других задач.

Самый просто вариант улучшения нашей функции — запоминать, какие значения мы уже вычисляли. Для этого нужно ввести дополнительный массив, который будет служить как бы «кэшем» для наших вычислений: перед вычислением нового значения мы будем проверять, не вычисляли ли его раньше. Если вычисляли, то будем брать из массива готовое значение, а если не вычисляли — придётся считать его на основе предыдущих и запоминать на будущее:

int cache[100];

int fibo(int n)
{
    if (cache[n] == 0) {
        if (n == 1 || n == 2) {
            cache[n] = 1;
        } else {
            cache[n] = fibo(n - 1) + fibo(n - 2);
        }
    }

    return cache[n];
}

Так как в данной задаче для вычисления N-ого значения нам гарантированно понадобится (N-1)-е, то не составит труда переписать формулу в итерационный вид — просто будем заполнять наш массив подряд до тех пор, пока не дойдём до нужной ячейки:

cache[0] = 1;
cache[1] = 1;

for (int i = 2; i <= n; i++) {
    cache[i] = cache[i - 1] + cache[i - 2];
}

cout << cache[n-1];

Теперь мы можем заметить, что когда мы вычисляем значение F(N), то значение F(N-3) нам уже гарантированно никогда не понадобится. То есть нам достаточно хранить в памяти лишь два значения — F(N-1) и F(N-2). Причём, как только мы вычислили F(N), хранение F(N-2) теряет всякий смысл. Попробуем записать эти размышления в виде кода:

//Два предыдущих значения:
int cache1 = 1;
int cache2 = 1;
//Новое значение
int cache3;

for (int i = 2; i <= n; i++) {
    cache3 = cache1 + cache2; //Вычисляем новое значение

    //Абстрактный cache4 будет равен cache3+cache2
    //Значит cache1 нам уже не нужен?..

    //Отлично, значит cache1 -- то значение, которое потеряет актуальность на следующей итерации.
    //cache5 = cache4 - cache3 => через итерацию потеряет актуальность cache2, т.е. он и должен стать cache1

    //Иными словами, cache1 -- f(n-2), cache2 -- f(n-1), cache3 -- f(n).
    //Пусть N=n+1 (номер, который мы вычисляем на следующей итерации). Тогда n-2=N-3, n-1=N-2, n=N-1.
    //В соответствии с новыми реалиями мы и переписываем значения наших переменных:

    cache1 = cache2;
    cache2 = cache3;
}

cout << cache3;

Бывалому программисту понятно, что код выше, в общем-то ерунда, так как cache3 никогда не используется (он сразу записывается в cache2), и всю итерацию можно переписать, используя всего одно выражение:

cache[0] = 1;
cache[1] = 1;
 
for (int i = 2; i <= n; i++) {
    cache[i%2] = cache[0] + cache[1];
    //При i=2 устареет 0-й элемент
    //При i=3 в 0 будет свежий элемент (обновили его на предыдущей итерации), а в 1 -- ещё старый
    //При i=4 последним элементом мы обновляли cache[1], значит ненужное старьё сейчас в cache[0]
    //Интуитивно понятно, что так будет продолжаться и дальше
}
 
cout << cache[n%2];

Для тех, кто не может понять, как работает магия с остатком от деления, или просто хочет увидеть более неочевидную формулу, существует ещё одно решение:

int x = 1;
int y = 1;

for (int i = 2; i < n; i++) {
   y = x + y;
   x = y - x;
}

cout << "Число Фибоначчи: " << y;

Попробуйте проследить за выполнением этой программы: вы убедитесь в правильности алгоритма.


P.S. Вообще, существует единая формула для вычисления любого числа Фибоначчи, которая не требует никаких итераций или рекурсии:

const double SQRT5 = sqrt(5);
const double PHI = (SQRT5 + 1) / 2;

int fibo(int n){
    return int(pow(PHI, n) / SQRT5 + 0.5);
}

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