Рекурсия в JavaScript
Рекурсия в общем смысле — это повторение какого-либо объекта или процесса внутри самого этого объекта или процесса.
В программировании в том числе и в JavaScript под рекурсией чащего всего понимают метод, который предполагает создание функции, которая вызывает саму себя до тех пор пока программа не достигнет необходимого результата.
Рассмотрим простой пример
let days = 0; function howMuchToLearnJS() < days++; console.log(days); howMuchToLearnJS(); >howMuchToLearnJS();
В данном примере мы написали функцию, которая:
- берет переменную days ;
- увеличивает ее на единицу;
- выводит результат в console;
- вызывает функцию howMuchToLearnJS() , тоесть саму себя.
В результате мы получим рекурсию и в дополнение «бесконечный» вывод чисел, каждое из которых будет больше на 1-цу предыдущего. Однако есть такое понятие, как стек, который рассчитан на ограниченное количество вложенных вызовов. В итоге, где-то на 11 тысячах вывод прекратиться и console выдаст ошибку Uncaught RangeError: Maximum call stack size exceeded — превышен максимальный размер стека вызовов.
Для того, чтобы такой ситуации не произошло в рекурсивной функции прописывают базовый случай — условие, когда выполнение программы должно закончится. Для большей надежности рекомендуют сначала прописать условие выхода, а затем описывать функционал.
let days = 0; function howMuchToLearnJS() < if (days === 400) return; // условие выхода days++; console.log(days); howMuchToLearnJS(); >howMuchToLearnJS();
Цикл и рекурсия
Любая задача, которую можно решить рекурсией, также решается и с помощью цикла. Перепишим наш пример.
for (let days = 0; days
Тут возникает вопрос, зачем использовать рекурсию, когда можно обойтись циклом. Дело в том, что в разных ситуациях один метод можно применять с большей эффективностью, чем другой и наоборот.
Плюсы рекурсии
- легче обходить вложенные структуры;
- чаще всего код получается короче, легче для понимания и поддержки.
Плюсы цикла
- код легче отлаживать;
- не приведет к переполнению стека.
Точного ответа, что лучше цикл и рекурсия нет. Все зависит от конкретной задачи.
Контекст выполнения и стек
Контекст выполнения — это структура данных, содержащая информацию о вызываемой функции. Другими словами, когда функция запускается, создается контекст, в который записывается все относящееся к вызываемой программе от переменных до того места где находился интерпретатор, когда она была вызвана.
Стек в случае с рекурсией — это специальная структура данных в которую записывается по порядку информация о каждом вызове функции самой себя, в том числе и первом вызове.
Отсюда исходит основная опасность работы с рекурсией — это переполнение стека. Глубина рекурсии равняется количеству контекстов, которые хранятся в стеке одновременно, а это число ограничено мощностью компьютера — обычно от 10 000 до 12 000. Для недопущения переполнения и существует базовый случай, тоесть условие, когда вызов функции самой себя должен прекратиться.
Факториал
Разбавим теорию популярным примером и напишим функцию вычисляющую факториал.
Факториал — это произведение всех натуральных чисел от 1 до заданного включительно.
Способ №1 — рекурсия
let s = 1; function calcFactorial(n) < if (n === 0) return; s = s * n; calcFactorial(n - 1); >calcFactorial(10); console.log(s);
Способ №2 — рекурсия
function calcFactorial(n) < if (n return n * calcFactorial(n - 1); > console.log(calcFactorial(10));
Способ №3 — цикл
function calcFactorial(n) < let s = 1; for (let i = 1; i console.log(s); > calcFactorial(10);
Сложные структуры и рекурсия
Объекты с неизвестной глубиной вложенности — это классический пример, когда с помощью рекурсии задачу решить намного легче, чем с помощью цикла.
let jsUniversity = < javascript: [< name: 'Дмитрий', payment: 20000, >, < name: 'Ольга', payment: 20000 >, < name: 'Вероника', payment: 20000 >], react: < 'full time': [< name: 'Татьяна', payment: 30000, >, < name: 'Витор', payment: 30000, >], extramural: [< name: 'Павел', payment: 25000, >, < name: 'Денис', payment: 25000, >] > >; function income(devUniversity) < if (Array.isArray(devUniversity)) < return devUniversity.reduce((prev, current) =>prev + current.payment, 0); > else < let sum = 0; for (let extramural of Object.values(devUniversity)) < sum += income(extramural); >return sum; > > alert(income(jsUniversity));
На входе объект с несколькими уровнями вложенности — стоит задача рассчитать доход университета со всех учеников. Если глубина структуры заранее известна, найти нужные элементы с помощью цикла не так уж и сложно, но как только появится дополнительная глубина, программу придется дописывать. В нашем случае доход рассчитан с помощью рекурсии, при добавлении новых направлений или групп, код продолжил работать корректно.
Итого
Рекурсия — это метод написания функции, когда она вызывает саму себя. С помощью рекурсии можно решить много задач, но большинство из них можно легко переписать используя цикл.
Структуры данных с глубокой вложенностью или неизвестной глубиной — отличный пример, когда рекурсия справляется с обходом лучше, чем цикл.
Базовый случай — это условие, когда рекурсия должна закончится. Такое условие важно предусмотреть, так как количество вызовов функции самой себя ограничено из-за переполнения стека.
Skypro — научим с нуля
Что такое рекурсия в javascript
Среди функций отдельно можно выделить рекурсивные функции. Их суть состоит в том, что функция вызывает саму себя.
Например, рассмотрим функцию, определяющую факториал числа:
function getFactorial(n) < if (n === 1)< return 1; >else < return n * getFactorial(n - 1); >> const result = getFactorial(4); console.log(result); // 24
Функция getFactorial() возвращает значение 1, если параметр n равен 1, либо возвращает результат опять же функции getFactorial, то в нее передается значение n-1. Например, при передаче числа 4, у нас образуется следующая цепочка вызовов:
result = 4 * getFactorial(3); result = 4 * 3 * getFactorial(2); result = 4 * 3 * 2 * getFactorial(1); result = 4 * 3 * 2 * 1; // 24
Рассмотрим другой пример — определение чисел Фибоначчи:
function getFibonachi(n) < if (n === 0)< return 0; >if (n === 1) < return 1; >else < return getFibonachi(n - 1) + getFibonachi(n - 2); >> const result = getFibonachi(8); //21 console.log(result); // 21
JavaScript: Рекурсия
Рекурсия – это когда функция в своём теле вызывает саму себя. Функцию, которая вызывает сама себя, называют рекурсивной функцией . Вызов рекурсивной функции, называется рекурсивным вызовом .
В качестве примера, вычислим факториал с использованием рекурсии:
function f(n) < if (n === 1) return 1; return n * f(n - 1); >alert(f(4));
Визуально последовательное выполнение данной функции можно представить так:
Выполнение программы многократно спускается вниз, пока не упрётся в условие выхода из рекурсии. Достигнув конца, она идёт обратно, возвращая результаты сделанных вызовов.
Рекурсивная функция обязательно должна иметь условие завершения, если его не указать, функция будет вызываться до тех пор, пока не будет достигнута максимальная глубина рекурсии, затем будет сгенерировано исключение.
Общее количество вложенных вызовов называют глубиной рекурсии . Максимальная глубина рекурсии в браузерах ограничена 10 000 рекурсивных вызовов.
Любую рекурсию можно заменить циклом. Перепишем вычисление факториала с помощью цикла:
let result = 1; for (let i = 2; i console.log(result); // 24
С этой темой смотрят:
- Объявление и вызов функции
- Параметры и аргументы функции
- arguments и this
- Инструкция return
Копирование материалов с данного сайта возможно только с разрешения администрации сайта
и при указании прямой активной ссылки на источник.
2011 – 2023 © puzzleweb.ru | razumnikum.ru
Рекурсия
Зачем функции вызывать саму себя — одна из любимых тем на собеседовании.
Время чтения: больше 15 мин
Открыть/закрыть навигацию по статье
- Кратко
- Рекурсия в программировании
- Повторяющиеся операции
- Базовый случай
- Факториал с помощью цикла
- Факториал с помощью рекурсии
- Деревья
- Рекурсивный обход
Обновлено 30 сентября 2022
Кратко
Скопировать ссылку «Кратко» Скопировано
Рекурсия — это что-то, что описывает само себя.
Представить рекурсию проще всего на примере зеркального коридора — когда напротив друг друга стоят два зеркала. Если посмотреть в одно, то в нём будет отражение второго, во втором — отражение первого и так далее.
В «Начале» Нолана есть момент с зеркальным коридором, когда в отражении зеркала видно отражение зеркала, в котором видно отражение зеркала, в котором видно.
Второй пример, чуть более академически правильный — это фрактал. Тот же треугольник Серпинского — это пример рекурсии, потому что часть фигуры — это одновременно вся фигура.
Треугольник состоит из 3 точно таких же треугольников.
Рекурсия в программировании
Скопировать ссылку «Рекурсия в программировании» Скопировано
В программировании под рекурсией чаще всего понимают функцию, которая вызывает саму себя.
При решении некоторых задач мы можем обнаружить, что решение можно разбить на несколько простых действий и более простой вариант той же задачи.
Например, при возведении числа в степень мы берём число, умножаем его на себя несколько раз. Эту операцию можно представить в виде:
// 2^5 = 2 * 2 * 2 * 2 * 2//// 1 шаг: 2// 2 шаг: 2 * 2// 3 шаг: 2 * 2 * 2// 4 шаг: 2 * 2 * 2 * 2// 5 шаг: 2 * 2 * 2 * 2 * 2//// Какой по счёту шаг —// столько и умножений.
// 2^5 = 2 * 2 * 2 * 2 * 2 // // 1 шаг: 2 // 2 шаг: 2 * 2 // 3 шаг: 2 * 2 * 2 // 4 шаг: 2 * 2 * 2 * 2 // 5 шаг: 2 * 2 * 2 * 2 * 2 // // Какой по счёту шаг — // столько и умножений.
Но это же можно представить в виде нескольких последовательных умножений на 2:
// 2^5 = ((((2 * 2) * 2) * 2) * 2)//// 1 шаг: 2// 2 шаг: 2 * 2 (результат 1-го шага * 2)// 3 шаг: 4 * 2 (результат 2-го шага * 2)// 4 шаг: 8 * 2 (результат 3-го шага * 2)// 5 шаг: 16 * 2 (результат 4-го шага * 2)//// Для получения нового результата// мы берём предыдущий и умножаем его на 2.
// 2^5 = ((((2 * 2) * 2) * 2) * 2) // // 1 шаг: 2 // 2 шаг: 2 * 2 (результат 1-го шага * 2) // 3 шаг: 4 * 2 (результат 2-го шага * 2) // 4 шаг: 8 * 2 (результат 3-го шага * 2) // 5 шаг: 16 * 2 (результат 4-го шага * 2) // // Для получения нового результата // мы берём предыдущий и умножаем его на 2.
При таком представлении всё возведение в степень — это лишь умножение предыдущего результата на 2:
// 2^n = 2^(n-1) * 2// Значение степени двойки —// это предыдущее значение, умноженное на 2.
// 2^n = 2^(n-1) * 2 // Значение степени двойки — // это предыдущее значение, умноженное на 2.
Именно такие задачи называются рекурсивными — когда часть условия ссылается на всю задачу в целом (или похожую на неё).
У рекурсии 2 составляющие: повторяющиеся операции и базовый случай.
Повторяющиеся операции
Скопировать ссылку «Повторяющиеся операции» Скопировано
В примере с возведением в степень повторяющиеся операции — это умножение.
Такие операции могут быть сложными и включать в себя несколько подзадач. Такое, например, часто встречается в математике.
Знаменитая сумма всех натуральных чисел контринтуитивно равняется -1/12. А доказывается это именно рекурсивно.
Базовый случай
Скопировать ссылку «Базовый случай» Скопировано
Вторая важная часть рекурсии — это базовый случай.
Базовый случай — это условие, при выполнении которого рекурсия заканчивается и функция больше не вызывает саму себя.
Например, при возведении в степень базовый случай наступает, когда значение степени становится равно искомому.
Как только выполнение доходит до базового случая, оно останавливается.
Без базового случая любая рекурсивная функция уйдёт в бесконечное выполнение, потому что будет вызывать себя без конца.
В JS это приводит к переполнению стека вызовов, и функция останавливается с ошибкой.
Если выполнить функцию без базового случая, которая лишь вызывает себя, получим ошибку.
Цикл и рекурсия
Скопировать ссылку «Цикл и рекурсия» Скопировано
Из-за повторяющихся операций рекурсия схожа с циклом. Их часто считают взаимозаменяемыми, но это всё же не совсем так.
Рекурсия проигрывает циклу в следующем:
- Отлаживать рекурсию значительно сложнее, чем цикл, а если функция написана плохо — то и просто читать.
- Она может приводить к переполнению стека. Особенно это ощутимо в таких языках как JS, где переполнение стека может наступить раньше базового случая с высокой вероятностью.
- Её выполнение может (хотя необязательно) занимать больше памяти.
Цикл же проигрывает рекурсии в таких вещах:
- Его нельзя использовать в функциональном программировании, потому что он императивен.
- Циклом гораздо сложнее обходить вложенные структуры данных, например, каталоги файлов.
- Результат выполнения рекурсивной функции проще закэшировать, чтобы ускорить выполнение, с циклом это сделать сложнее.
- При работе с общими ресурсами или асинхронными задачами чаще удобнее использовать рекурсивные функции из-за замыканий.
Поэтому на вопрос «Что использовать: рекурсию или цикл?» ответом будет «Зависит от задачи», серебряной пули здесь нет :–)
Давайте решим одну и ту же задачу с использованием цикла и рекурсии, чтобы увидеть разницу в подходах. Будем писать функцию для нахождения факториала.
Факториал числа — это произведение всех чисел от единицы до этого числа. Например, факториал 5 — это произведение (1 × 2 × 3 × 4 × 5) = 120.
Факториал с помощью цикла
Скопировать ссылку «Факториал с помощью цикла» Скопировано
Сперва решим задачу нахождения факториала с помощью цикла.
function factorial(n) // Начальный результат будет равен 1, // чтобы его можно было умножать на последующие числа. // 0 подходит только для подсчёта суммы, // потому что умножение на 0 всегда даёт 0. let result = 1 for (let i = 0; i < n; i++) // Так как наш счётчик начинается с 0 // и растёт до n-1, нам нужно прибавить к нему // единицу, чтобы правильно рассчитать произведение. result *= i + 1 > return result> console.log(factorial(5))// 120
function factorial(n) // Начальный результат будет равен 1, // чтобы его можно было умножать на последующие числа. // 0 подходит только для подсчёта суммы, // потому что умножение на 0 всегда даёт 0. let result = 1 for (let i = 0; i n; i++) // Так как наш счётчик начинается с 0 // и растёт до n-1, нам нужно прибавить к нему // единицу, чтобы правильно рассчитать произведение. result *= i + 1 > return result > console.log(factorial(5)) // 120
В этой функции мы используем цикл, чтобы умножить каждое число на результат предыдущего умножения. То же самое мы можем сделать и рекурсивно.
Факториал с помощью рекурсии
Скопировать ссылку «Факториал с помощью рекурсии» Скопировано
Для расчёта факториала рекурсивно мы создадим функцию, в которой в первую очередь опишем базовый случай, а уже потом — повторяющиеся действия.
Хорошим правилом при работе с рекурсией считается первым делом описывать базовый случай (как ранний выход, early return) и только потом — всё остальное. Это позволяет сделать работу с рекурсией безопаснее.
В виде блок-схемы мы можем представить алгоритм факториала как условие и под-вызов той же функции.
function factorial(n) // Если мы пытаемся найти факториал 1, // возвращаем 1 — это базовый случай. if (n <= 1) return 1 > // В остальных случаях // возвращаем произведение n // на факториал предыдущего числа — // таким образом мы от n дойдём до 1, // перебрав каждое число. return n * factorial(n - 1)> console.log(factorial(5))// 120
function factorial(n) // Если мы пытаемся найти факториал 1, // возвращаем 1 — это базовый случай. if (n 1) return 1 > // В остальных случаях // возвращаем произведение n // на факториал предыдущего числа — // таким образом мы от n дойдём до 1, // перебрав каждое число. return n * factorial(n - 1) > console.log(factorial(5)) // 120
Кроме того, что функция стала заметно короче, она теперь выражает непосредственно математическую суть факториала.
Процесс вычисления факториала 5 будет состоять из 4 под-вызовов функции factorial ( ) .
Разберём по шагам, что происходит с переменной n и результатом функции factorial после каждого вызова:
/* Сперва мы «спускаемся вглубь» вызовов.Первый вызов создаёт новую область видимости:factorial(5) в ней переменная n становится равной 5; n - 1 => 4; функция возвращает 5 * factorial(4); Второй вызов создаёт ещё одну область видимости: factorial(4) n => 4 n - 1 => 3 return 4 * factorial(3) Третий вызов, ещё одна область видимости: factorial(3) n => 3 n - 1 => 2 return 3 * factorial(2) Четвёртый вызов, ещё одна область: factorial(2) n => 2 n - 1 => 1 return 2 * factorial(1) Финальный вызов, последняя область, базовый случай: factorial(1) n => 1 Так как это базовый случай, возвращаем 1. После базового случая мы «поднимаемся наверх». > В этот момент результат factorial(1) становится равен 1 Возвращаем return 2 * 1 > Результат factorial(2) => 2 return 3 * 2 > factorial(3) => 6 return 4 * 6 > factorial(4) => 24 return 5 * 24> После каждого return область видимости соответствующей функции очищается.Результат вызова становится равным конкретному числу. */
/* Сперва мы «спускаемся вглубь» вызовов. Первый вызов создаёт новую область видимости: factorial(5) < в ней переменная n становится равной 5; n - 1 =>4; функция возвращает 5 * factorial(4); Второй вызов создаёт ещё одну область видимости: factorial(4) < n =>4 n - 1 => 3 return 4 * factorial(3) Третий вызов, ещё одна область видимости: factorial(3) < n =>3 n - 1 => 2 return 3 * factorial(2) Четвёртый вызов, ещё одна область: factorial(2) < n =>2 n - 1 => 1 return 2 * factorial(1) Финальный вызов, последняя область, базовый случай: factorial(1) < n =>1 Так как это базовый случай, возвращаем 1. После базового случая мы «поднимаемся наверх». > В этот момент результат factorial(1) становится равен 1 Возвращаем return 2 * 1 > Результат factorial(2) => 2 return 3 * 2 > factorial(3) => 6 return 4 * 6 > factorial(4) => 24 return 5 * 24 > После каждого return область видимости соответствующей функции очищается. Результат вызова становится равным конкретному числу. */
Минусы такой реализации:
- Много областей видимости (замыканий), которые относительно требовательны к памяти.
- Относительно просто читать, но сложнее отлаживать, чем цикл.
- Если мы часто считаем факториалы, мы можем кэшировать результаты выполнения, чтобы не считать факториалы заново. Рекурсивная функция с кешем будет возвращать посчитанный ранее результат сразу же, цикл же будет считать заново.
- Невозможно повлиять на процесс подсчёта как-то извне, замыкания не дают внешнему миру получить доступ к переменным внутри.
Рекурсивные структуры данных
Скопировать ссылку «Рекурсивные структуры данных» Скопировано
Раньше мы упомянули, что в программировании чаще всего под рекурсией понимают функцию, которая вызывает сама себя. Но кроме рекурсивных функций ещё есть рекурсивные структуры данных.
Структура данных — это контейнер, который хранит данные в определённом формате. Этот контейнер решает, каким образом внешний мир может эти данные считать или изменить.
Структуры данных, части которых включают в себя такие же структуры, называются (вы угадали) рекурсивными. Работать с такими структурами в цикле не очень удобно. Чтобы понять почему, рассмотрим пример одной из рекурсивных структур данных — дерева.
Деревья
Скопировать ссылку «Деревья» Скопировано
Дерево — это структура, в которой у каждого узла может быть несколько дочерних подузлов — «детей».
Мы уже встречались с деревьями в статье «Как браузер рисует страницы». Мы рассматривали DOM, CSSOM и Render Tree. Вспомним, как выглядит DOM-дерево.
Hello Hello world
html ______|_______ | | body head ___|____ ___|___ | | | | p img meta title | | "Hello world" "Hello" -->html> head> meta charset="utf-8"> title>Hellotitle> head> body> p class="text">Hello worldp> img src="/hello.jpg" alt="Привет!"> body> html>
У каждого дерева есть корень — узел, из которого берут начало остальные узлы. Корень DOM-дерева выше — это элемент html .
Работать с деревьями с помощью циклов (итеративно) неудобно. Представьте, что мы хотим получить названия всех элементов на странице. Да, мы можем пройтись циклом по 1-му уровню или 2-му, но дальше нужно думать, как определять, где мы были, где ещё нет и куда идти дальше.
С рекурсией обход дерева становится немного проще.
Рекурсивный обход
Скопировать ссылку "Рекурсивный обход" Скопировано
В случае с рекурсией мы можем придумать например такой алгоритм для обхода дерева:
- базовый случай: если у узла дерева нет дочерних узлов вовсе, или мы обошли их все — возвращаем название этого элемента;
- повторяемые операции: рекурсивно обходим каждый дочерний элемент, собирая их названия.
// Псевдокод: function nameOf(element) /* . Возвращает название элемента. */> function eachChild(element, func) /* . Вызывает функцию func на каждом дочернем узле элемента element. */> function childrenOf(node) /* . Возвращает дочерние узлы данного узла, если их нет, возвращает null. */> function traverse(treeNode) // Если дочерних узлов нет, это базовый случай; // возвращаем массив с названием текущего элемента. // Массив здесь нужен для последовательности, // так как дальше мы будем возвращать массивы с названиями других узлов, // то чтобы нам не приходилось каждый раз проверять тип результата, // мы всегда возвращаем массив. if (!childrenOf(treeNode)) return [nameOf(treeNode)] > // Если же случай не базовый, // то мы проходим по каждому из дочерних узлов // и вызываем на нём функцию traverse. // В nameArrays у нас получится список списков названий. // (На каждый дочерний узел по списку.) const namesArrays = eachChild(treeNode, traverse) // Нам останется сделать список плоским // и вернуть его как результат. const names = namesArrays.flat() return names> // Начав обход с корневого узла дерева,// вы получим на выходе список имён всех элементов.const names = traverse(rootNode)
// Псевдокод: function nameOf(element) /* . Возвращает название элемента. */ > function eachChild(element, func) /* . Вызывает функцию func на каждом дочернем узле элемента element. */ > function childrenOf(node) /* . Возвращает дочерние узлы данного узла, если их нет, возвращает null. */ > function traverse(treeNode) // Если дочерних узлов нет, это базовый случай; // возвращаем массив с названием текущего элемента. // Массив здесь нужен для последовательности, // так как дальше мы будем возвращать массивы с названиями других узлов, // то чтобы нам не приходилось каждый раз проверять тип результата, // мы всегда возвращаем массив. if (!childrenOf(treeNode)) return [nameOf(treeNode)] > // Если же случай не базовый, // то мы проходим по каждому из дочерних узлов // и вызываем на нём функцию traverse. // В nameArrays у нас получится список списков названий. // (На каждый дочерний узел по списку.) const namesArrays = eachChild(treeNode, traverse) // Нам останется сделать список плоским // и вернуть его как результат. const names = namesArrays.flat() return names > // Начав обход с корневого узла дерева, // вы получим на выходе список имён всех элементов. const names = traverse(rootNode)
Когда использовать рекурсию
Скопировать ссылку "Когда использовать рекурсию" Скопировано
Сама по себе рекурсия — это всего лишь инструмент. Нет чётких правил, когда её надо использовать, а когда — нет. Есть лишь некоторые рекомендации.
- Если вы работаете с рекурсивной структурой данных, лучше использовать рекурсию — это будет сильно проще.
- Если промежуточный результат выполнения функции можно закэшировать, то стоит подумать об использовании рекурсии.
Когда использовать цикл
Скопировать ссылку "Когда использовать цикл" Скопировано
- Если рекурсивную функцию сложно читать или отлаживать, можно превратить её в цикл. Код станет менее лаконичным, но сил на отладку будет уходить меньше.
- Если вам жизненно необходимо оптимизировать работу программы, рекурсию можно переписать на цикл. Это почти всегда работает быстрее, хотя код и становится менее читаемым.