Рекурсия не должна пугать

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

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

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

Давайте вместе рассмотрим пять примеров кода. Сначала мы решим каждую задачу с помощью цикла, а затем рекурсией. Какой подход лучше? Это вам решать.

Пример №1: факториал

Давайте напишем функцию, которая позволяет нам вычислять факториал для положительных целых чисел. Факториалы записываются так: 5!. А формула факториала выглядит так:

n! = n * (n - 1) * (n - 2) * ... * 1

Итак, 5! будет:

5! = 5 * 4 * 3 * 2 * 1 = 120

Как бы мы могли написать для этого функцию? Одним из подходов было бы использование файла while loop. Мы можем создать переменную для нашего result, а затем умножить ее на x в цикле, каждый раз уменьшая x на 1. Код выглядит следующим образом:

Обратите внимание, что мы также обработали неверные входные данные меньше 0 и упростили случаи 0 и 1, поскольку 0! и 1! оба равны 1.

Итак, это решение, использующее цикл. Что, если бы мы захотели написать эту функцию рекурсивно? Рекурсивное решение может выглядеть так:

Вау! Это намного короче. Обратите внимание, что в последней строке мы возвращаем x, умноженное на результат повторного вызова нашей той же функции factorial с x — 1 в качестве аргумента. Это рекурсивный случай.

Если бы мы просто вызывали нашу функцию рекурсивно, мы бы оказались в бесконечном цикле, поэтому нам нужен какой-то способ выйти из него. Вот почему у нас есть условие, которое обрабатывается, когда x <= 1. Когда мы достигнем этой точки, мы перестанем рекурсивно вызывать нашу функцию factorial. Это базовый случай.

Готовы к другому примеру?

Пример №2: Мощность

Попробуем аналогичный пример. На этот раз давайте реализуем степенную функцию, которая вычисляет результат возведения базового числа в степень. Так, например, 2^3 — это два, возведенные в степень три, или 2 * 2 * 2, что равно 8.

В JavaScript есть несколько способов сделать мощные функции изначально, например, вызвать Math.pow(2, 3) или использовать синтаксис возведения в степень, например 2 ** 3. А пока давайте притворимся, что этих утилит не существует. Как мы могли сами написать эту функциональность?

Используя while loop, мы могли бы написать функцию, которая выглядит так:

Обратите внимание, что в этом примере мы проигнорируем отрицательные показатели степени, хотя в реальной жизни иметь отрицательную степень вполне допустимо. Мы также рассмотрим случай, когда показатель степени равен 0, поскольку любое число, возведенное в 0, всегда равно 1 (2^0 = 1).

Как и в нашем примере с факториалом, мы начали с начального result, равного 1. Затем мы умножаем result на x в цикле, уменьшая y, пока не достигнем 0.

Теперь, как будет выглядеть рекурсивное решение? Как насчет этого:

Ого, опять короче! В самом низу у нас есть наш рекурсивный случай, когда мы умножаем x на результат вызова нашей функции power с x и уменьшенным y — 1.

Кроме того, у нас есть наш базовый случай, в котором мы возвращаем единицу, если y равно 0. Таким образом, мы можем отказаться от бесконечного вызова нашей функции power.

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

Готовы двигаться дальше?

Пример № 3: суммирование чисел в массиве

Напишем функцию, которая суммирует все числа в массиве. В JavaScript метод reduce предоставляет простой инструмент для преобразования массива в такую ​​сумму:

numbers.reduce((number, sum) => number + sum, 0);

А пока представим, что reduce не существует. Как мы могли бы написать свою собственную функцию? Один из вариантов — использовать for loop для перебора всех элементов в массиве и добавления их вместе. Код выглядит следующим образом:

Коротко и просто. А что, если мы хотим сделать это рекурсивно? Мы могли бы написать рекурсивное решение следующим образом:

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

Теперь это лучший способ, чем использование for loop? Это субъективный вопрос, но, на мой взгляд, использование рекурсии здесь кажется более неестественным, чем просто использование цикла. Что вы думаете?

А пока рассмотрим другой пример.

Пример № 4: найти наибольшее число в массиве

Давайте напишем функцию, которая принимает массив чисел и возвращает наибольшее из них. В JavaScript мы могли бы использовать вспомогательную функцию Math.max следующим образом:

Math.max(...numbers);

Но ради аргумента давайте притворимся, что у нас нет этой функции. Как мы могли написать это сами? Используя for loop, наш код мог бы выглядеть так:

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

Итак, довольно прямолинейно. А что, если бы мы захотели написать это рекурсивно? Мы могли бы написать нашу функцию так:

Не кажется ли вам, что это немного сложнее понять интуитивно? Давайте пройдемся по нему.

Если массив содержит ноль элементов или один элемент, мы просто вернем первый элемент (undefined в случае пустого массива). Это наш базовый случай.

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

Все еще не ясно? И я нет.

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

maxNumberArray([1, 9, 5, 7, 3, 8, 2, 4])

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

Is 2 greater than 4? 4 is our max number.
Is 8 greater than 4? 8 is our max number.
Is 3 greater than 8? 8 is our max number.
Is 7 greater than 8? 8 is our max number.
Is 5 greater than 8? 8 is our max number.
Is 9 greater than 8? 9 is our max number.
Is 1 greater than 9? 9 is our max number.

Таким образом, концептуально мы идем по массиву в обратном направлении, начиная с двух последних чисел. Мы сравниваем эти два числа и оставляем наибольшее. Затем мы перемещаемся влево на один элемент и сравниваем это с нашим текущим максимальным числом.

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

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

Рекурсивная функция кажется «умным», но не «ясным» решением.

Давайте сделаем еще один пример, прежде чем мы завершим.

Пример № 5: найти ключ, спрятанный внутри коробок внутри коробок

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

Итак, представьте, что:

  • Первый ящик (ящик A) содержит три других ящика (ящик B, ящик C и ящик D).
  • В ящике B ничего нет.
  • Внутри ящика C находятся два ящика (ящик E и ящик F).
  • Внутри ящика E находится один ящик (ящик G).
  • В коробке G лежит ключ (ура!).
  • В ящике F ничего нет.
  • Внутри ящика D находится один ящик (ящик H).
  • В ящике H ничего нет.

Голова еще не болит?

Как мы можем написать функцию, которая просматривает ящики, пока не найдет ключ?

Мы могли бы написать эту функцию, используя while loop следующим образом:

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

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

Каждый предмет, который мы находим в коробке, может быть либо ключом (ура, мы его нашли!) либо другой коробкой (отлично…). Если мы найдем ключ, то нам конец. Если мы находим коробку, то добавляем эту коробку в нашу стопку.

Мы повторяем наш цикл, пока не закончим все коробки или пока не найдем ключ.

А что, если бы мы захотели найти рекурсивное решение этой проблемы? Мы могли бы написать нашу функцию так:

Обратите внимание на различия между нашим рекурсивным решением и нашим циклическим решением. В рекурсивном решении мы не отслеживаем стопку. Мы просто просматриваем первый ящик, а затем, если находим еще ящики, рекурсивно вызываем нашу функцию findKeyInBox для этого ящика. Стек вызовов снова отслеживает наше состояние для нас!

Заключение

Итак, что лучше: циклы или рекурсия? Это вам решать. Обычно между циклами и рекурсией нет существенных различий в производительности, и нотация Big O одинакова, поскольку в каждом случае выполняется одинаковое количество операций.

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

Код читается в 10 раз чаще, чем пишется, поэтому оптимизируйте его для удобочитаемости.

Спасибо за прочтение! Если вы хотите вернуться к этим примерам и увидеть тестовые случаи, вы можете найти все функции, которые мы обсуждали, в этом репозитории GitHub.