Введение в асимптотический анализ. Рекурсивные функции.#
Исследование рекурсии занимает отдельную область в информатике, называемую теорией рекурсии. Поскольку невозможно охватить всю эту область, мы рассмотрим лишь ключевые особенности, которые стали основой для многих программистов.
Note
Рекурсивная функция (recursive function) – функция, в теле которой присутствует вызов самой себя
Концепция самоссылочных сущностей, способных воспроизводить себя, глубоко укоренилась в информатике. Рассмотрим простые примеры и увидим элегантность рекурсии.
Возьмём, например, функцию факториала:
Другой способ выразить эту функцию:
Вычисление факториала принимает число и связывает его с вычислением факториала для числа, уменьшенного на 1. Рассмотрим следующий псевдокод:
function factorial(int:a):
if a == 0 return 1
return a * factorial(a-1)
Давайте заместим рекурсивную функцию, используя цикл while - такой подход называется итеративным
function factorial(int: a):
int result = 1
while a > 1:
result *= a
a -= 1
Рекурсивный вызов функции состоит из двух основных частей:
- Базовый случай — условие, при котором рекурсия останавливается.
- Индуктивный случай — правила, которые уменьшают задачу до базового случая.
Продолжим вводить определения. В целом, существуют два типа рекурсии: прямая и косвенная.
Прямая рекурсия — это когда функция вызывает саму себя напрямую. Код, который мы рассмотрели ранее, является примером прямой рекурсии.
Косвенная рекурсия происходит, когда одна функция вызывает другую, а та, в свою очередь, снова вызывает первую. Например:
function print(int: a):
print a + " × "
return a * factorial(a-1)
function factorial(int: a):
if a == 0 return 1
print(a)
Косвенную рекурсию можно расширить, если цепочка вызовов функций образует цикл:
g₁(x₁) → g₂(x₂) → ... → g₁(xₙ)
Здесь функция g₁
вызывает g₂
, затем g₂
вызывает следующую функцию, и так далее, пока одна из функций снова не вызовет g₁
. Это приводит к зацикливанию, если нет условия завершения.
Вычиление рекурсивной функции#
Рекурсивная функция работает в два этапа:
- Прямой ход рекурсии (углубление) – функция вызывает саму себя, пока не достигнет условия выхода.
- Обратный ход рекурсии (возвращение) – начинается, когда выполнено условие выхода, и вычисления сворачиваются, возвращаясь по цепочке вызовов.
Прямой ход рекурсии
- Функция вызывает саму себя с новыми аргументами, уменьшая задачу до более простых случаев.
- Новые вызовы функции откладываются в стеке вызовов.
- Глубина рекурсии увеличивается, пока не будет достигнуто условие выхода.
Обратный ход рекурсии
- Когда достигается условие выхода, функция начинает возвращать значения.
- Значения подставляются в предыдущие вызовы, вычисления выполняются по цепочке назад.
- Постепенно все отложенные вызовы завершаются, и программа возвращается к исходному вызову.
Условие выхода из рекурсии
- Это критическая часть рекурсивной функции, предотвращающая бесконечный вызов.
- Обычно выражается в виде if (условие) return значение;.
- Позволяет завершить рекурсию и начать обратный ход.
Вернемя к нашему псевдокоду и рассмотрим вызов функции factorial(5)
function factorial(int:a):
if a == 0 return 1
return a * factorial(a-1)
factorial(5)
вызываетfactorial(4)
,factorial(4)
вызываетfactorial(3)
, и так далее… (прямой ход).- Когда
factorial(0)
возвращает 1, начинается обратный ход:
factorial(1) = 1 * 1 = 1
factorial(2) = 2 * 1 = 2
factorial(3) = 3 * 2 = 6
factorial(4) = 4 * 6 = 24
factorial(5) = 5 * 24 = 120
Если представлять себе это в виде анимации, то вышла бы следующая картина:
Чтобы хранить порядок операций, необходимых для выполнения компьютер формируется стек вызова.
Стек вызовов функций (call stack)#
Системный стек — это область памяти, используемая для хранения:
- Адресов возврата (куда возвращаться после выполнения функции).
- Локальных переменных функции.
- Аргументов, переданных в функцию.
Когда вызывается функция, для неё выделяется кадр стека (stack frame).
При выходе из функции её кадр удаляется, и управление передаётся по адресу возврата.
Warning
Рекурсивные функции могут занимать значительную часть стековой памяти для хранения адресов возврата!
Прямой ход рекурсии (вложенные вызовы)#
Вызов | Аргумент n |
Адрес возврата |
---|---|---|
factorial(3) |
3 |
main() |
factorial(2) |
2 |
factorial(3) |
factorial(1) |
1 |
factorial(2) |
factorial(0) |
0 |
factorial(1) (условие выхода) |
При выходе из функции из головы стека извлекается адрес возврата
Обратный ход рекурсии (разворачивание стека)#
factorial(0)
возвращает1
, удаляется из стека.factorial(1)
вычисляет1 * 1
, удаляется.factorial(2)
вычисляет2 * 1
, удаляется.factorial(3)
вычисляет3 * 2
, удаляется.
Любопытные заметки#
Стек в операционной системе имеет ограниченный размер, который влияет на глубину рекурсии. Например, команда в Linux-системах:
ulimit -s 8192
устанавливает максимальный размер стека на 8192 килобайта.
Если программа использует слишком много памяти в стеке, например, из-за слишком глубокой рекурсии, может произойти переполнение стека (stack overflow).
Это может привести к сбоям в работе программы или системы.
Виды рекурсии#
Линейная рекурсия (linear recursion) — это тип рекурсии, при котором в функции присутствует единственный рекурсивный вызов самой себя. Такой тип рекурсии обычно используется в задачах, где необходимо выполнить серию шагов, один за другим, например, в вычислении факториала.
Очевидно, что наш фрактал является линейной рекурсией, поскольку одна функция один раз вызывает саму себя
Однако легко представить себе и иной случай, когда функция в return вызывает себя два или более раз. Назовем такую структуру древовидной.
Древовидная рекурсия (или нелинейная рекурсия, non-linear recursion) — это тип рекурсии, при котором функция вызывает себя несколько раз. Это создает структуру данных, похожую на дерево, где каждый узел может иметь несколько дочерних вызовов. Такой тип рекурсии часто используется в сложных задачах, таких как обход деревьев или решение задач с множеством вариантов.
Осталось только придумать подходящий алгоритм для иллюстрации.
Числа Фибоначчи#
Тысячу лет назад люди увлекались вовсе не нейросетями, а числами и их свойствами. Так, в XII веке главным трендом были загадочные числа Фибоначчи, следы которых и сейчас можно найти практически везде: от расположения лепестков на дереве до формы ураганов и галактик.
Числа Фибоначчи — это последовательность чисел, которые задаются по определённому правилу. Оно звучит так: каждое следующее число равно сумме двух предыдущих.
Первые два числа заданы сразу и равны 0 и 1.
Вот как выглядит последовательность Фибоначчи:
0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1597, 2584, 4181, … , ∞
Длиться этот числовой ряд может бесконечно, но для большинства задач обычно хватает первых десяти чисел:
первое — 0;
второе — 1;
третье — 1;
четвёртое — 2;
пятое — 3;
шестое — 5;
седьмое — 8;
восьмое — 13;
девятое — 21;
десятое — 34.
Ну умеем мы складывать числа друг с другом, и что с того? Можно придумать ещё несколько таких же последовательностей — например, где следующее число будет равно сумме трёх или четёрых предыдущих. Как это пригодится в науке и технике? Но обо всём по порядку.
Как появились числа Фибоначчи#
Первым эту последовательность описал итальянский учёный Леонардо Пизанский по прозвищу Фибоначчи. Он жил в XII веке и усердно изучал работы античных и индийских математиков. В них Леонардо нашёл много полезных знаний — например, что десятичная система удобнее, чем римская нотация, и что по ней проще считать.
Полученные знания Леонардо систематизировал в своём главном труде — «Книге абака». Там же появилось и первое упоминание о числах Фибоначчи — как ни странно, в контексте решения задачи о кроликах:
Задача о размножении кроликов#
Постановка задачи
В огороженный загон посадили двух кроликов — самку и самца.
Каждый месяц пара являет миру ещё одну пару кроликов.
Вопрос: сколько пар кроликов будет в загоне через год?
Конечно, решить эту задачу не так просто, потому что на размножение кроликов влияет много факторов — например, они могут умереть или убежать. Поэтому Леонардо ограничил задачу такими условиями:
- кролики не могут умереть;
- они достигают половой зрелости за месяц;
- самки беременны ровно месяц;
- кролики всегда рождаются парами: самка + самец.
Теперь задачу вполне можно решить: ответом на неё как раз будет последовательность Фибоначчи. Логика такая: каждая взрослая пара кроликов будет создавать ещё одну пару через месяц после рождения. Эти кролики-дети будут расти месяц, а потом размножаться с другими кроликами. И так двенадцать месяцев.
Чуть лучше этот процесс можно представить с помощью этой схемы:
Смотрите, первая пара кроликов ещё совсем молодая, поэтому пока не может дать потомство. Но уже через месяц кролики подрастут и смогут размножаться — соответственно, на третий месяц пар будет уже две. Дальше количество пар будет равняться сумме пар за два предыдущих месяца, и последовательность примет уже знакомый нам вид:
Получаем ответ на задачу: 233 пары кроликов.
И в этом весь смысл чисел Фибоначчи — считать кроликов в загоне? Нет! Оказывается, Леонардо лишь приоткрыл дверь в возможности этой последовательности. Основное применение она нашла в математике, архитектуре и искусстве.
Архитекторы античных и средневековых городов много времени уделяли идеальным пропорциям. Они хотели создавать красивые постройки, которыми бы наслаждались все жители города. Так появилось понятие золотого сечения.
Золотое сечение — это число, которое помогает делить вещи на красивые части. Оно равно примерно 1,618. Золотое сечение можно найти так: если взять два отрезка чего-то, то большой отрезок должен быть в 1,618 раза больше маленького отрезка, а вся вещь должна быть в 1,618 раза больше большого отрезка. Это число называется «фи» и пишется так: φ.
Вернёмся к числам Фибоначчи. Оказывается, отношение каждого числа к предыдущему примерно равно значению золотого сечения. Правда, на первых числах последовательности этого не будет заметно.
Рекурсивный алгоритм вычисления чисел Фибоначчи#
Если начать упорно считать эти числа, раскладыывая рекурсивную формулу, то получится следующее:
- F(5) = F(4) + F(3)
- F(4) = F(3) + F(2)
- F(3) = F(2) + F(1)
- F(2) = F(1) + F(0)
- F(1) = 1
- F(0) = 0
Так как F(1) = 1 и F(0) = 0, мы можем постепенно вычислить каждое число, начиная с самых маленьких значений:
- F(2) = 1 + 0 = 1
- F(3) = 1 + 1 = 2
- F(4) = 2 + 1 = 3
- F(5) = 3 + 2 = 5
Таким образом, значения чисел Фибоначчи для первых 6 чисел будут выглядеть так:
- F(0) = 0
- F(1) = 1
- F(2) = 1
- F(3) = 2
- F(4) = 3
- F(5) = 5
На этом этапе можно раглядеть рекуррентную формулу следующего вида:
Реализуем её при помощи следующего псевдокода:
function fn(n) {
if (n == 0 || n == 1)
return n
return fn(n-1) + fn(n-2)
}
Базовый случай : Если n = 1 или n = 2, вернуть в вызывающую ветку единицу, так как первый и второй элементы ряда Фибоначчи равны единице.
Индуктивный случай: Во всех остальных случаях вызвать эту же функцию с аргументами n - 1 и n - 2. Результат двух вызовов сложить и вернуть в вызывающую ветку программы.
Если начать визуализировать порядок расчетов, то легко увидеть древовидную структуру алгоритма:
Заокономерным вопросом будет: а как же нам посчитать количество операций?
Рекуррентная форма#
Рекуррентная формула* (англ. recurrence relation) — это формула вида:
где:
- a_n — это текущий член последовательности,
- f(n, a_{n-1}, a_{n-2}, \dots, a_{n-p}) — функция, которая вычисляет следующий член последовательности на основе предыдущих членов,
- n — номер текущего члена,
- p — порядок рекуррентного соотношения (то есть количество предыдущих членов, которые используются для вычисления следующего).
Рекуррентные формулы часто используются для задания последовательностей, где каждый элемент зависит от одного или нескольких предыдущих элементов. В таких случаях, для нахождения значений последовательности, необходимо знать несколько начальных значений.
Для чисел Фибоначчи рекуррентная формула имеет вид:
/[ F(0) = 0 ]
/[ F(1) = 1 ]
/[ F(n) = F(n-1) + F(n-2) ] для n > 1
Здесь p = 2, так как для вычисления каждого следующего числа нужно использовать два предыдущих.
Дерево рекурсивных вызовов#
Дерево рекурсии — это графический метод, который отображает разворачивание рекуррентного соотношения в систему рекурсивных вызовов. В этом дереве каждый узел представляет собой вызов функции, а ветви указывают на рекурсивные вызовы, которые делаются в процессе выполнения алгоритма.
Рассмотрим рекурсивную формулу для чисел Фибоначчи:
/[ F(0) = 0 ]
/[ F(1) = 1 ]
/[ F(n) = F(n-1) + F(n-2) ] для n > 1
Для вычисления, например, F(4), рекурсивные вызовы будут развернуты в дерево следующим образом:
F(4)
/ \
F(3) F(2)
/ \ / \
F(2) F(1) F(1) F(0)
/ \
F(1) F(0)
Здесь:
- Каждый узел представляет вызов функции для конкретного значения /[n].
- Листья дерева показывают базовые случаи /[F(0) = 0] и /[F(1) = 1].
Такое дерево помогает визуализировать, как алгоритм рекурсивно разворачивается и какие промежуточные значения вычисляются.
Визуализация дерева рекурсий#
Для лучшего понимания рекурсивных алгоритмов, можно использовать визуальные инструменты, такие как:
В данном случае, рекурсивные вызовы для чисел Фибоначчи можно представить в виде дерева, где каждый узел — это вызов функции для определенного значения /[n].
Когда будете строить дерево обратите внимание на следующее:
1. Высота дерева зависит от числа вызовов функции и равна /[O(n)], потому что на каждом уровне дерева происходит вычисление для меньших значений /[n] до достижения базовых случаев.
2. На каждом уровне i количество узлов увеличивается в два раза (для каждого вызова функции). То есть на уровне /[i] будет /[2^i] узлов.
3. Каждый узел выполняет определенную работу (в данном случае — вычисление для конкретного значения /[n]), и количество операций, которые нужно выполнить для каждого узла, растет с увеличением /[n]. Для вычисления одного числа Фибоначчи потребуется выполнить около /[2n + 1 - 1] операций.
Анализ рекуррентных соотношений (методом подстановки)#
Один из методов анализа алгоритмов — это метод рекуррентных соотношений. Этот метод позволяет вычислять временную сложность рекурсивных алгоритмов на основе их рекуррентных формул. Рассмотрим на примере задачи вычисления чисел Фибоначчи с помощью рекурсии.
Давайте разберем пошагово, чтобы проще было понять.
Шаг 1: Подставляем рекуррентное соотношение для T(n-1)
Мы начинаем с рекуррентного соотношения:
Теперь подставляем выражение для T(n-1) из предыдущего шага, которое равно:
Тогда получаем:
Шаг 2: Подставляем рекуррентное соотношение для T(n-2)
Теперь подставим для T(n-2) :
Тогда выражение для T(n) примет вид:
Шаг 3: Подставляем рекуррентное соотношение для T(n-3)
Далее подставим для T(n-3) :
Тогда получаем:
Шаг 4: Закономерность и общее решение
Из вышеизложенного видно, что на каждом шаге множитель перед T(n-k) удваивается, а добавляемое число увеличивается по закономерности 2^k - 1 .
Таким образом, можно записать общую форму для T(n) :
Для любого k , где k — это количество шагов рекурсии.
Шаг 5: Определение k
Для того чтобы решить это уравнение, нам нужно подставить T(0) = 1 , то есть:
Следовательно, k = n .
Шаг 6: Подставляем k = n
Подставляем k = n в общее решение:
Таким образом, мы получаем:
Для самостоятельного рассмотрения Сложность алгоримта факториал#
Естественной мерой объема входных данных для функции factorial является значение . Обозначим через время выполнения программы. Время выполнения строк (1) и (2) имеет порядок O(1), а для строки (3) — O(1) + T(n-1). Таким образом, для некоторых констант и имеем:
- для ,
- для .
Полагая, что , и раскрывая в соответствии с рекуррентным соотношением выражение (то есть подставляя вместо значение ), получаем:
Аналогично, если , раскрывая , получаем:
Продолжая этот процесс, в общем случае для некоторого , где , имеем:
Положив в последнем выражении , окончательно получаем:
Из этого следует, что имеет порядок O(n).
Рассмотрим теперь другой алгоритм с рекуррентным соотношением:
- для ,
- для .
Заменим в этом соотношении на . Получим:
Подставим это соотношение вместо в исходное:
Аналогично, заменяя на , получаем оценку для :
Подставляя эту оценку в предыдущее неравенство, получаем:
Индукцией по для любого можно получить следующее соотношение:
Предположим, что является степенью числа 2, например, . Тогда при в правой части неравенства будет стоять :
Поскольку , то . Так как , получаем:
Таким образом, имеет порядок роста не более O(n log n).