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

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

Итак, если мы начнем с 0 и 1, вот как будут выглядеть первые N чисел Фибоначчи:

0, 1, 1, 2, 3, 5, 8, 13, 21, 34, ...

Как видите, каждое число (кроме первых двух) получается из суммы двух предыдущих. Например, 13является результатом сложения8и 5.

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

Первое решение

Давайте попробуем написать функцию, которая будет получать число n и извлекать число Фибоначчи в позиции n. Мы будем использовать рекурсивный подход:

function fibonacci(n) {
  if (n <= 0) {
   return 0;
  } else if (n <= 2) {
   return 1;
  }
  return fibonacci(n - 1) + fibonacci(n - 2);
}

(Обратите внимание, что некоторые теории пропускают первый 0 из последовательности Фибоначчи, и в этом случае все, что вам нужно сделать, это опустить первый оператор if и изменить второй на n ‹ 2 )

Довольно просто, да? Давайте создадим несколько тестовых случаев:

console.log(fibonacci(0)); // 0
console.log(fibonacci(1)); // 1
console.log(fibonacci(2)); // 1
console.log(fibonacci(3)); // 2
console.log(fibonacci(4)); // 3
console.log(fibonacci(10)); // 55
console.log(fibonacci(20)); // 6765
console.log(fibonacci(30)); // 832040
console.log(fibonacci(40)); // 102334155

Это здорово! Теперь, если вы выполните это в своем браузере, вы увидите, что он становится все медленнее и медленнее по мере увеличения параметра n. Это связано с тем, что при каждом вызове fibonacci выполняется еще 2 вызова одной и той же функции (за исключением случаев, когда n ≤ 2). Это называется экспоненциальной сложностью и обозначается O(2^n).

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

Бенчмаркинг

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

let timesExecuted = 0;
function fibonacci(n) {
  timesExecuted++;
  if (n <= 0) {
   return 0;
  } else if (n <= 2) {
   return 1;
  }
  return fibonacci(n - 1) + fibonacci(n - 2);
}
const result = fibonacci(40);
console.log(result); // 102334155
console.log(`Times executed: ${timesExecuted}`); // 204668309

Можете ли вы что-то заметить? Для такого небольшого числа, как 40, мы получили более 200 миллионов выполнений, что невозможно масштабировать, и для обработки больших чисел потребуется огромный процессор.

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

------------------
fibonacci(40)
------------------
timesExecuted = 1
fibonacci(39)
fibonacci(38)
------------------
------------------
fibonacci(39)
------------------
timesExecuted = 2
fibonacci(38)
fibonacci(37)
------------------
------------------
fibonacci(38)
------------------
timesExecuted = 3
fibonacci(37)
fibonacci(36)
------------------
...

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

Мемоизация

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

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

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

Теперь мы немного изменим наш код, чтобы он поддерживал это:

let timesExecuted = 0;
function fibonacci(n) {
  const innerFibonacci = m => {
    timesExecuted++;
    if (m <= 0) {
      return 0;
    } else if (m <= 2) {
     return 1;
    } else {
      return innerFibonacci(m - 2) + innerFibonacci(m - 1);
    }
  }
  
  return innerFibonacci(n);
}
const result = fibonacci(40);
console.log(result); // 102334155
console.log(`Times executed: ${timesExecuted}`); // 204668309

Как видите, теперь мы определили функцию innerFibonacci, которая будет отвечать за вычисление значения, и теперь у нас есть оболочка с именем fibonacci, которая является функцией, которая будет выполняться изначально и которая вернет наш результат.

При этом следующим шагом будет создание переменной хранения внутри нашей функции-оболочки для хранения наших частичных результатов и проверки наличия ранее вычисленного значения перед вычислением новых значений.

Вот как это будет выглядеть:

let timesExecuted = 0;
function fibonacci(n) {
  const fibonacciCache = new Map();
  const innerFibonacci = m => {
    if (fibonacciCache.has(m)) {
      return fibonacciCache.get(m);
    } else if (m <= 0) {
      timesExecuted++;
      return 0;
    } else if (m <= 2) {
      timesExecuted++;
     return 1;
    } else {
      const resultInner = innerFibonacci(m - 2) + innerFibonacci(m - 1);
      timesExecuted++;
      fibonacciCache.set(m, resultInner);
      return resultInner;
    }
  }
  return innerFibonacci(n);
}
const result = fibonacci(40);
console.log(result); // 102334155
console.log(`Times executed: ${timesExecuted}`); // 41

Ты можешь в это поверить? 41 раз против 200 миллионов раз, это невероятно. Мы используем Map javascript для хранения наших данных, но вы можете использовать собственный Object, если хотите, или любую другую структуру данных, которая вам нравится больше всего. Тем не менее, есть возможности для улучшения. Мы можем попытаться удалить timesExecuted++ и несколько если, чтобы избежать повторения, и мы можем немного лучше сортировать вещи.

Давайте попробуем это сделать:

function fibonacci(n) {
  const fibonacciCache = new Map();
  
  // Predefined values
  fibonacciCache.set(0, 0);
  fibonacciCache.set(1, 1);
  fibonacciCache.set(2, 1);
  
  const innerFibonacci = m => {
    if (fibonacciCache.has(m)) {
      return fibonacciCache.get(m);
    } else {
      const resultInner = innerFibonacci(m - 1) + innerFibonacci(m - 2);
      timesExecuted++;
      fibonacciCache.set(m, resultInner);
      return resultInner;
    }
  }
  return innerFibonacci(n);
}
const result = fibonacci(40);
console.log(result); // 102334155
console.log(`Times executed: ${timesExecuted}`); // 38

С этим новым изменением мы определяем некоторые значения в нашем кеше мемоизации, чтобы мы могли удалить некоторые операторы if, а также мы сокращаем количество выполнений с 41 до 38. Легко!

Вывод

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

Как вы думаете, есть ли способ еще больше сократить количество выполнений с помощью этой реализации? Что бы вы изменили, чтобы сделать его еще более эффективным?

Источники, связанные статьи и следующие чтения