# Итерация и рекурсия В алгоритмах часто требуется повторное выполнение определенной задачи, что тесно связано с анализом сложности. Поэтому, прежде чем перейти к обсуждению временной и пространственной сложности, рассмотрим, как реализовать повторное выполнение задач в программе, а именно две основные структуры управления программой: итерацию и рекурсию. ## Итерация Итерация (iteration) - это структура управления, которая позволяет повторно выполнять определенную задачу. В итерации программа повторяет выполнение определенного участка кода, пока выполняется определенное условие. ### Цикл for Цикл `for` - одна из наиболее распространенных форм итерации, **которая подходит для использования, когда количество итераций известно заранее**. Следующая функция реализует суммирование $1 + 2 + \dots + n$ с использованием цикла `for` , а результат суммирования сохраняется в переменной `res` . Следует отметить, что в Python диапазон `range(a, b)` соответствует левому закрытому, правому открытому интервалу, то есть перебираются значения $a, a + 1, \dots, b-1$ : ```src [file]{iteration}-[class]{}-[func]{for_loop} ``` На рисунке ниже представлена блок-схема этой функции суммирования. ![Блок-схема функции суммирования](iteration_and_recursion.assets/iteration.png) Количество операций этой функции суммирования пропорционально размеру входных данных $n$ , или, другими словами, линейно зависит от него. **На самом деле временная сложность описывает именно эту линейную зависимость**. Соответствующий материал будет подробно рассмотрен в следующем разделе. ### Цикл while Подобно циклу `for` , цикл `while` также представляет собой метод реализации итерации. В цикле `while` программа перед каждой итерацией проверяет условие: если условие истинно, то выполнение продолжается, иначе цикл завершается. Ниже приведен пример реализации суммирования $1 + 2 + \dots + n$ с использованием цикла `while` : ```src [file]{iteration}-[class]{}-[func]{while_loop} ``` **Цикл `while` обладает большей степенью свободы по сравнению с циклом `for` **. В цикле `while` можно свободно управлять инициализацией и обновлением условной переменной. Например, в следующем коде условная переменная $i$ обновляется дважды на каждой итерации, что затруднительно сделать с использованием цикла `for` : ```src [file]{iteration}-[class]{}-[func]{while_loop_ii} ``` В целом **код с использованием цикла `for` более компактный, а цикл `while` более гибкий**. Но они оба могут реализовать итерационную структуру. Выбор между ними определяется требованиями конкретной задачи. ### Вложенные циклы Внутрь одной циклической структуры можно вложить другую, например используя два цикла `for` : ```src [file]{iteration}-[class]{}-[func]{nested_for_loop} ``` На рисунке ниже приведена блок-схема такого вложенного цикла. ![Блок-схема вложенного цикла](iteration_and_recursion.assets/nested_iteration.png) В этом случае количество выполненных действий пропорционально $n^2$ , или, другими словами, время выполнения алгоритма и размер входных данных $n$ находятся в квадратичной зависимости. Можно и дальше добавлять вложенные циклы, тогда каждое вложение будет повышать размерность, увеличивая временную сложность до кубической зависимости, зависимости четвертой степени и так далее. ## Рекурсия Рекурсия (recursion) - это стратегия алгоритма, при которой функция вызывает саму себя для решения задачи. Она включает два основных этапа. 1. **Вызов**: программа постоянно вызывает саму себя, обычно передавая меньшие или более упрощенные параметры, пока не будет достигнуто условие завершения. 2. **Возврат**: после срабатывания условия завершения программа начинает возвращаться из самой глубокой рекурсивной функции, объединяя результаты каждого уровня. С точки зрения реализации рекурсивный код включает три основных элемента. 1. **Условие завершения**: используется для определения момента перехода от вызова к возврату. 2. **Рекурсивный вызов**: соответствует вызову, функция вызывает саму себя, обычно с меньшими или упрощенными параметрами. 3. **Возврат результата**: соответствует возврату, возвращает результат текущего уровня рекурсии на предыдущий уровень. Рассмотрим следующий код: вызов функции `recur(n)` позволяет вычислить сумму $1 + 2 + \dots + n$ : ```src [file]{recursion}-[class]{}-[func]{recur} ``` На рисунке ниже представлен рекурсивный процесс этой функции. ![Рекурсивный процесс функции суммирования](iteration_and_recursion.assets/recursion_sum.png) Хотя с точки зрения вычислений итерация и рекурсия могут давать одинаковый результат, **они представляют собой совершенно разные парадигмы мышления и решения задач**. - **Итерация**: решение задачи снизу вверх. Начинаем с самых базовых шагов, которые затем повторяются или накапливаются до завершения задачи. - **Рекурсия**: решение задачи сверху вниз. Исходная задача разбивается на более мелкие подзадачи, которые имеют ту же форму, что и исходная задача. Далее подзадачи продолжают делиться на еще более мелкие, пока не достигается базовый случай, решение которого известно. Рассмотрим в качестве примера вышеупомянутую функцию суммирования, где решается задача $f(n) = 1 + 2 + \dots + n$ . - **Итерация**: моделирование процесса суммирования в цикле проходит от $1$ до $n$ , выполняя операцию суммирования на каждом шаге, чтобы получить итоговое значение $f(n)$ . - **Рекурсия**: последовательное разбиение задачи на подзадачи вида $f(n) = n + f(n - 1)$ до достижения базового случая $f(1) = 1$ . ### Стек вызовов Каждый раз, когда рекурсивная функция вызывает саму себя, система выделяет память для нового вызова функции, чтобы хранить локальные переменные, адрес вызова и другую информацию. Это поведение имеет два последствия. - Контекстные данные функции хранятся в области памяти, называемой пространством стекового кадра, и освобождаются только после возврата функции. **Поэтому рекурсия обычно требует больше памяти, чем итерация**. - Рекурсивный вызов функции создает дополнительные накладные расходы. **Поэтому рекурсия обычно менее эффективна по времени, чем цикл**. Как показано на рисунке ниже, до срабатывания условия завершения одновременно существует $n$ невозвращенных рекурсивных функций, а **глубина рекурсии равна $n$** . ![Глубина рекурсивного вызова](iteration_and_recursion.assets/recursion_sum_depth.png) На практике глубина рекурсии, разрешенная языком программирования, обычно ограничена, и слишком глубокая рекурсия может привести к ошибке переполнения стека. ### Хвостовая рекурсия Интересно, что **если рекурсивный вызов происходит на последнем шаге перед возвратом функции** , то компилятор или интерпретатор может оптимизировать этот вызов, сделав его по эффективности использования памяти сопоставимым с итерацией. Это называется хвостовой рекурсией (tail recursion). - **Обычная рекурсия**: когда функция возвращается на предыдущий уровень, необходимо продолжить выполнение кода, поэтому системе нужно сохранить контекст предыдущего вызова. - **Хвостовая рекурсия**: рекурсивный вызов является последней операцией перед возвратом функции, что означает, что после возврата на предыдущий уровень не требуется выполнять другие операции, поэтому системе не нужно сохранять контекст предыдущей функции. В качестве примера вычисления суммы $1 + 2 + \dots + n$ можно установить переменную результата `res` в качестве параметра функции, чтобы реализовать хвостовую рекурсию: ```src [file]{recursion}-[class]{}-[func]{tail_recur} ``` Процесс выполнения хвостовой рекурсии показан на рисунке ниже. Сравнивая обычную и хвостовую рекурсии, можно заметить, что точка выполнения операции суммирования у них различается. - **Обычная рекурсия**: операция суммирования выполняется в процессе возврата, после каждого возврата необходимо снова выполнить операцию суммирования. - **Хвостовая рекурсия**: операция суммирования выполняется в процессе вызова, а процесс возврата требует только последовательного возврата. ![Процесс хвостовой рекурсии](iteration_and_recursion.assets/tail_recursion_sum.png) !!! tip Обратите внимание: многие компиляторы и интерпретаторы не поддерживают оптимизацию хвостовой рекурсии. Например, Python по умолчанию такую оптимизацию не выполняет, поэтому даже функция в хвостово-рекурсивной форме все равно может привести к переполнению стека. ### Дерево рекурсии При решении задач, связанных с алгоритмами типа «разделяй и властвуй», рекурсия часто оказывается более интуитивной и читабельной, чем итерация. Рассмотрим в качестве примера последовательность Фибоначчи. !!! question Дана последовательность Фибоначчи $0, 1, 1, 2, 3, 5, 8, 13, \dots$. Найди $n$-й элемент этой последовательности. Обозначив $n$-й член последовательности Фибоначчи как $f(n)$ , можно сформулировать два утверждения. - Первые два числа последовательности: $f(1) = 0$ и $f(2) = 1$ . - Каждое число последовательности является суммой двух предыдущих чисел, то есть $f(n) = f(n - 1) + f(n - 2)$ . Используя рекурсивные вызовы в соответствии с рекуррентным соотношением и принимая первые два числа за условия остановки, можно написать рекурсивный код. Вызов `fib(n)` позволит получить $n$-й член последовательности Фибоначчи: ```src [file]{recursion}-[class]{}-[func]{fib} ``` Проанализировав приведенный код, можно заметить, что внутри функции осуществляется рекурсивный вызов двух функций, **то есть из одного вызова образуются два ветвления**. Как показано на рисунке ниже, при последующем выполнении рекурсивных вызовов в итоге образуется дерево рекурсии (recursion tree) глубиной $n$ . ![Дерево рекурсии последовательности Фибоначчи](iteration_and_recursion.assets/recursion_tree.png) По своей сути рекурсия отражает парадигму мышления «разбиение задачи на более мелкие подзадачи», что делает стратегию «разделяй и властвуй» крайне важной. - С точки зрения **алгоритмов** многие важные алгоритмические стратегии, такие как поиск, сортировка, возврат, «разделяй и властвуй» и динамическое программирование, прямо или косвенно используют этот подход. - С точки зрения **структур данных** рекурсия естественно подходит для решения задач, связанных со списками, деревьями и графами, поскольку они очень хорошо поддаются анализу с использованием идеи «разделяй и властвуй». ## Сравнение Подводя итог, можно сказать, что итерация и рекурсия различаются по реализации, производительности и применимости, как показано в таблице ниже.

Таблица   Сравнение итерации и рекурсии

| | Итерация | Рекурсия | | -------- | -------------------------------------- | ------------------------------------------------------------ | | Способ реализации | Циклическая структура | Функция вызывает саму себя | | Временная эффективность | Обычно высокая эффективность, нет затрат на вызов функции | Каждый вызов функции создает затраты | | Использование памяти | Обычно используется фиксированный объем памяти | Накопление вызовов функции может использовать значительное количество пространства стека | | Сфера использования | Подходит для простых циклических задач, код интуитивно понятен и хорошо читаем | Подходит для разбиения на подзадачи, для структур деревья и графы, алгоритмов «разделяй и властвуй», возврата и т. д.. Структура кода проста и ясна | !!! tip Если дальнейшее содержание кажется сложным, можно вернуться к нему после чтения главы о «стеке». Какова же внутренняя связь между итерацией и рекурсией? В рассмотренном примере рекурсивной функции операция сложения выполняется на этапе возврата рекурсии. Это означает, что функция, вызванная первой, фактически завершает операцию сложения последней, **что соответствует принципу стека «первым пришел - последним вышел»**. На самом деле такие термины рекурсии, как «стек вызовов» и «пространство стекового кадра», уже намекают на тесную связь между рекурсией и стеком. 1. **Вызов**: когда вызывается функция, система выделяет для нее новый стековый кадр в «стеке вызовов» для хранения локальных переменных функции, параметров, адреса возврата и других данных. 2. **Возврат**: когда функция завершает выполнение и возвращает результат, соответствующий стековый кадр удаляется из «стека вызовов», восстанавливая среду выполнения предыдущей функции. Таким образом, **можно использовать явный стек для моделирования поведения стека вызовов**, чтобы преобразовать рекурсию в итеративную форму: ```src [file]{recursion}-[class]{}-[func]{for_loop_recur} ``` Наблюдая за приведенным выше кодом, можно заметить, что после преобразования рекурсии в итерацию код становится более сложным. Хотя во многих случаях итерация и рекурсия действительно могут быть преобразованы друг в друга, это не всегда стоит делать по двум причинам. - Преобразованный код может стать труднее для понимания и менее читаемым. - Для некоторых сложных задач моделирование поведения системного стека вызовов может оказаться очень трудным. Итак, **выбор между итерацией и рекурсией зависит от природы конкретной задачи**. В практическом программировании крайне важно взвешивать преимущества и недостатки обоих подходов и выбирать подходящий метод с учетом контекста.