Как стать автором
Обновить

Оптимизация хвостовой рекурсии в JavaScript

Время на прочтение4 мин
Количество просмотров26K
Привет, читатель.

Иногда для решении задачи приходится использовать Рекурсию, в которой есть свои плюсы и минусы. Я столкнулся с проблемой переполнения стека.
Максимальная глубина рекурсии ограничена движком JavaScript. Точно можно рассчитывать на 10000 вложенных вызовов, некоторые интерпретаторы допускают и больше, но для большинства из них 100000 вызовов – за пределами возможностей. Существуют автоматические оптимизации, помогающие избежать переполнения стека вызовов («оптимизация хвостовой рекурсии»), но они ещё не поддерживаются везде и работают только для простых случаев.

Пример рекурсивной функции:
function sum(n) {
  return n === 0 ? 0 : n + sum(n - 1)
}

sum(5) // 1 + 2 + 3 + 4 + 5  = 15
sum(100000) // Error: Maximum call stack size exceeded.


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

Пример хвостовой рекурсивной функции:
function sum(number, s = 0){
  return number === 0 ? s : sum(number - 1, s + number)
}

Но, без поддержки браузерами мы столкнемся с той же проблемой — переполнения стека. Можем попробовать использовать вместе с Trampolining.

Напишем декоратор, который будет принимать измененную рекурсивную функцию(следующий шаг) и исполнять ее в цикле, без увеличения глубины вызовов.
function trampoline(fn) {
  return function(...args) {
    let result = fn.apply(fn.context, args)
    while (typeof result === 'function') {
      result = result()
    }

    return result
  }
}


Теперь наша рекурсивная функция должна возвращать функцию, которая будет сразу исполняться декоратором. Таким способом, мы условно сделаем массив функций и исполним их по очереди. Но поскольку мы каждый раз возвращаем новую анонимную функцию, этот код будет работать несколько медленнее.
function sum(number, s = 0) {
  return number === 0 ? s : () => sum(number - 1, s + number)
}


Используем:
const trampolineSum = trampoline(sum)
trampolineSum(100000) // 5000050000

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

Спасибо, за внимание. Хорошего дня :)

Update
Производительность:

#1 Рекурсия
Код #1
function sum(n) {
  return n === 0 ? 0 : n + sum(n - 1)
}

console.time("run")
sum(1000)
console.timeEnd("run")


Результат #1 Safari 12.1.2
[Debug] run: 0.353ms
[Debug] run: 0.281ms
[Debug] run: 0.305ms
[Debug] run: 0.315ms
[Debug] run: 0.319ms
[Debug] run: 0.231ms
[Debug] run: 0.255ms
[Debug] run: 0.334ms
[Debug] run: 0.370ms
[Debug] run: 0.274ms
[Debug] run: 0.260ms

Результат #1 Google Chrome 78.0.3892.0
run: 0.118896484375ms
run: 0.101806640625ms
run: 0.099853515625ms
run: 0.10205078125ms
run: 0.10302734375ms
run: 0.099853515625ms
run: 0.106201171875ms
run: 0.103759765625ms
run: 0.105224609375ms
run: 0.262939453125ms
run: 0.136962890625ms
run: 0.10107421875ms
run: 0.10302734375ms


#2 Итерация
Код #2
function sum(n) {
  let sum = 0
  for (let i = 1; i <= n; i++) {
    sum += i
  }

  return sum
}

console.time("run")
sum(1000)
console.timeEnd("run")


Результат #2 Safari 12.1.2
[Debug] run: 0.562ms
[Debug] run: 0.552ms
[Debug] run: 0.502ms
[Debug] run: 0.527ms
[Debug] run: 0.434ms
[Debug] run: 0.462ms
[Debug] run: 0.511ms
[Debug] run: 0.528ms
[Debug] run: 0.549ms
[Debug] run: 0.475ms
[Debug] run: 0.530ms
[Debug] run: 0.494ms

Результат #2 Google Chrome 78.0.3892.0
run: 0.932861328125ms
run: 0.751953125ms
run: 0.4580078125ms
run: 0.678955078125ms
run: 0.424072265625ms
run: 0.505859375ms
run: 0.563720703125ms
run: 0.404052734375ms
run: 0.411865234375ms
run: 0.634033203125ms
run: 0.4169921875ms
run: 0.390869140625ms
run: 0.464111328125ms


#3 Хвостовая рекурсия и «батут»
Код #3
function trampoline(fn) {
  return function(...args) {
    let result = fn.apply(fn.context, args)
    while (typeof result === 'function') {
      result = result()
    }

    return result
  }
}

function sum(number, s = 0) {
  return number === 0 ? s : () => sum(number - 1, s + number)
}

const trampolineSum = trampoline(sum)
console.time("run")
trampolineSum(1000)
console.timeEnd("run")


Результат #3 Safari 12.1.2
[Debug] run: 0.936ms
[Debug] run: 0.792ms
[Debug] run: 0.882ms
[Debug] run: 0.826ms
[Debug] run: 0.968ms
[Debug] run: 0.818ms
[Debug] run: 1.681ms
[Debug] run: 0.777ms
[Debug] run: 1.109ms
[Debug] run: 0.832ms
[Debug] run: 0.826ms

Результат #3 Google Chrome 78.0.3892.0
run: 0.60888671875ms
run: 0.989990234375ms
run: 0.567138671875ms
run: 0.56005859375ms
run: 1.0087890625ms
run: 0.5400390625ms
run: 0.578125ms
run: 0.541015625ms
run: 0.557861328125ms
run: 1.97607421875ms
run: 0.570068359375ms
run: 0.593017578125ms
run: 0.530029296875ms
run: 0.89794921875ms
run: 0.590087890625ms


#4 Хвостовая рекурсия и «батут» (большое число)
Код #4
function trampoline(fn) {
  return function(...args) {
    let result = fn.apply(fn.context, args)
    while (typeof result === 'function') {
      result = result()
    }

    return result
  }
}

function sum(number, s = 0) {
  return number === 0 ? s : () => sum(number - 1, s + number)
}

const trampolineSum = trampoline(sum)
console.time("run")
trampolineSum(100000)
console.timeEnd("run")


Результат #4 Safari 12.1.2
[Debug] run: 33.693ms
[Debug] run: 24.564ms
[Debug] run: 25.313ms
[Debug] run: 23.262ms
[Debug] run: 24.848ms
[Debug] run: 23.909ms
[Debug] run: 24.248ms
[Debug] run: 32.416ms
[Debug] run: 24.090ms
[Debug] run: 23.986ms

Результат #4 Google Chrome 78.0.3892.0
run: 40.73681640625ms
run: 33.955078125ms
run: 40.907958984375ms
run: 37.693115234375ms
run: 28.929931640625ms
run: 30.7548828125ms
run: 29.720947265625ms
run: 40.8310546875ms
run: 31.5830078125ms
run: 30.712890625ms
run: 30.162841796875ms
run: 31.56103515625ms


По результатам мы видим, что наш декоратор хоть и позволяет избежать ошибки переполнения стека, но работает он медленнее чем рекурсивный и итеративный вариант. Так что данный способ стоит использовать только если вы не можете заменить рекурсию на итерацию или боитесь переполнения стека при выполнении вашей рекурсивной функции.
Теги:
Хабы:
Всего голосов 32: ↑28 и ↓4+24
Комментарии32

Публикации

Истории

Работа

Ближайшие события

22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
2 – 18 декабря
Yandex DataLens Festival 2024
МоскваОнлайн
11 – 13 декабря
Международная конференция по AI/ML «AI Journey»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань