Рекурсия (информатика) - Recursion (computer science)

Дерево создано с использованием языка программирования Logo и сильно зависит от рекурсии. Каждую ветвь можно рассматривать как уменьшенную версию дерева.

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

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

-  Никлаус Вирт , Алгоритмы + структуры данных = программы , 1976 г.

Большинство языков программирования поддерживают рекурсию, позволяя функции вызывать себя из собственного кода. Некоторые языки функционального программирования (например, Clojure ) не определяют никаких циклических конструкций, а полагаются исключительно на рекурсию для многократного вызова кода. В теории вычислимости доказано, что эти рекурсивные языки полны по Тьюрингу ; это означает, что они столь же мощны (их можно использовать для решения тех же проблем), что и императивные языки, основанные на управляющих структурах, таких как whileи for.

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

Рекурсивные функции и алгоритмы

Распространенная тактика компьютерного программирования - разделить проблему на подзадачи того же типа, что и исходная, решить эти подзадачи и объединить результаты. Это часто называют методом « разделяй и властвуй» ; в сочетании с таблицей поиска, в которой хранятся результаты ранее решенных подзадач (чтобы избежать их повторного решения и увеличения времени вычислений), это можно назвать динамическим программированием или мемоизацией .

Базовый вариант

Определение рекурсивной функции имеет один или несколько базовых случаев , то есть входные данные, для которых функция производит тривиальный результат (без повторения), и один или несколько рекурсивных случаев , то есть входные данные, для которых программа повторяется (вызывает саму себя). . Например, факториальная функция может быть определена рекурсивно уравнениями 0! = 1 и для всех п > 0 , п ! = п ( п - 1)! . Ни одно уравнение само по себе не составляет полного определения; первый - базовый случай, второй - рекурсивный. Поскольку базовый вариант прерывает цепочку рекурсии, его иногда также называют «завершающим случаем».

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

Для некоторых функций (например, той, которая вычисляет ряд для e = 1/0! + 1/1! + 1/2! + 1/3! + ... ), не существует очевидного базового случая, подразумеваемого входными данными. ; для них можно добавить параметр (например, количество добавляемых терминов в нашем примере серии), чтобы предоставить «критерий остановки», который устанавливает базовый случай. Такой пример более естественно трактовать с помощью corecursion , где последовательные члены на выходе являются частичными суммами; это можно преобразовать в рекурсию, используя параметр индексации, чтобы сказать «вычислить n- й член ( n- ю частичную сумму)».

Рекурсивные типы данных

Многие компьютерные программы должны обрабатывать или генерировать произвольно большие объемы данных . Рекурсия - это метод представления данных, точный размер которых неизвестен программисту : программист может указать эти данные с помощью определения, ссылающегося на себя . Есть два типа самореферентных определений: индуктивные и коиндуктивные .

Индуктивно определенные данные

Индуктивно определенное определение рекурсивных данных - это определение, которое указывает, как создавать экземпляры данных. Например, связанные списки могут быть определены индуктивно (здесь, используя синтаксис Haskell ):

data ListOfStrings = EmptyList | Cons String ListOfStrings

В приведенном выше коде указывается, что список строк должен быть пустым, или структура, содержащая строку и список строк. Само-ссылка в определении позволяет создавать списки из любого (конечного) числа строк.

Другой пример индуктивного определения - натуральные числа (или положительные целые числа ):

A natural number is either 1 or n+1, where n is a natural number.

Аналогичным образом рекурсивные определения часто используются для моделирования структуры выражений и операторов в языках программирования. Разработчики языков часто выражают грамматики в синтаксисе, таком как форма Бэкуса – Наура ; вот такая грамматика для простого языка арифметических выражений с умножением и сложением:

 <expr> ::= <number>
          | (<expr> * <expr>)
          | (<expr> + <expr>)

Это говорит о том, что выражение - это либо число, либо произведение двух выражений, либо сумма двух выражений. Рекурсивно ссылаясь на выражения во второй и третьей строках, грамматика допускает произвольно сложные арифметические выражения, например (5 * ((3 * 6) + 8)), с более чем одним произведением или операцией суммы в одном выражении.

Коиндуктивно определенные данные и коркурс

Определение коиндуктивных данных - это определение, которое определяет операции, которые могут быть выполнены с частью данных; как правило, самореференциальные коиндуктивные определения используются для структур данных бесконечного размера.

Коиндуктивное определение бесконечных потоков строк, данное неформально, может выглядеть так:

A stream of strings is an object s such that:
 head(s) is a string, and
 tail(s) is a stream of strings.

Это очень похоже на индуктивное определение списков строк; разница в том, что это определение указывает, как получить доступ к содержимому структуры данных - а именно, через функции доступаhead и tail- и каким может быть это содержимое, тогда как индуктивное определение указывает, как создать структуру и из чего она может быть создана.

Corecursion относится к коиндукции и может использоваться для вычисления конкретных экземпляров (возможно) бесконечных объектов. Как метод программирования, он чаще всего используется в контексте ленивых языков программирования и может быть предпочтительнее рекурсии, когда желаемый размер или точность вывода программы неизвестны. В таких случаях программе требуется как определение бесконечно большого (или бесконечно точного) результата, так и механизм для получения конечной части этого результата. Проблема вычисления первых n простых чисел может быть решена с помощью базовой программы (например, здесь ).

Типы рекурсии

Одиночная рекурсия и множественная рекурсия

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

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

Множественная рекурсия иногда может быть преобразована в единую рекурсию (а затем, при желании, в итерацию). Например, хотя вычисление последовательности Фибоначчи наивно представляет собой многократную итерацию, поскольку каждое значение требует двух предыдущих значений, оно может быть вычислено с помощью одной рекурсии, передав два последовательных значения в качестве параметров. Это более естественно оформлено как corecursion, построение на начальных значениях, отслеживание на каждом шаге двух последовательных значений - см. Corecursion: examples . Более сложный пример - использование многопоточного двоичного дерева , которое позволяет выполнять итеративный обход дерева, а не множественную рекурсию.

Косвенная рекурсия

Большинство основных примеров рекурсии и большинство представленных здесь примеров демонстрируют прямая рекурсия , при которой функция вызывает сама себя. Косвенная рекурсия возникает, когда функция вызывается не сама по себе, а другой функцией, которую она вызвала (прямо или косвенно). Например, если f вызывает f, это прямая рекурсия, но если f вызывает g, который вызывает f, то это косвенная рекурсия f. Возможны цепочки из трех и более функций; например, функция 1 вызывает функцию 2, функция 2 вызывает функцию 3, а функция 3 снова вызывает функцию 1.

Косвенная рекурсия также называется взаимной рекурсией , что является более симметричным термином, хотя это просто разница в акцентах, а не другое понятие. То есть, если е вызывает г и затем г вызовы F, который , в свою очередь , вызывает г снова, с точки зрения е только, е косвенно рекурсией, в то время как с точки зрения г только, это косвенно рекурсией, в то время как с точки зрения обоих, f и g взаимно рекурсивны. Точно так же набор из трех или более функций, которые вызывают друг друга, можно назвать набором взаимно рекурсивных функций.

Анонимная рекурсия

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

Структурная рекурсия в сравнении с генеративной рекурсией

Некоторые авторы классифицируют рекурсию как «структурную» или «генеративную». Различие связано с тем, где рекурсивная процедура получает данные, с которыми она работает, и как она обрабатывает эти данные:

[Функции, потребляющие структурированные данные] обычно разбивают свои аргументы на их непосредственные структурные компоненты, а затем обрабатывают эти компоненты. Если один из непосредственных компонентов принадлежит к тому же классу данных, что и входные, функция является рекурсивной. По этой причине мы называем эти функции (СТРУКТУРНО) РЕКУРСИВНЫМИ ФУНКЦИЯМИ.

-  Фелляйзен, Финдлер, Флатт и Кришнаурти, Как разрабатывать программы , 2001 г.

Таким образом, определяющей характеристикой структурно рекурсивной функции является то, что аргументом каждого рекурсивного вызова является содержимое поля исходного ввода. Структурная рекурсия включает почти все обходы дерева, включая обработку XML, создание и поиск двоичного дерева и т. Д. Учитывая алгебраическую структуру натуральных чисел (то есть, натуральное число равно нулю или является преемником натурального числа), такие функции как факториал может также рассматриваться как структурная рекурсия.

Альтернативой является генеративная рекурсия :

Многие известные рекурсивные алгоритмы генерируют совершенно новый фрагмент данных из заданных данных и повторяются на нем. HtDP ( How to Design Programs ) называет этот вид генеративной рекурсией. Примеры порождающей рекурсии включают: НОД , быструю сортировку , бинарный поиск , сортировки слияние , метод Ньютона , фрактал и адаптивную интеграцию .

-  Маттиас Фелляйзен, Расширенное функциональное программирование , 2002 г.

Это различие важно для доказательства завершения функции.

  • Все структурно рекурсивные функции на конечных ( индуктивно определенных ) структурах данных можно легко показать, что они завершаются, с помощью структурной индукции : интуитивно каждый рекурсивный вызов получает меньшую часть входных данных, пока не будет достигнут базовый случай.
  • Генеративно-рекурсивные функции, напротив, не обязательно передают меньшие входные данные своим рекурсивным вызовам, поэтому доказательство их завершения не обязательно так просто, а предотвращение бесконечных циклов требует большей осторожности. Эти генеративно-рекурсивные функции часто можно интерпретировать как коркурсивные функции - каждый шаг генерирует новые данные, такие как последовательное приближение в методе Ньютона, - и для завершения этого коркурсивного курса требуется, чтобы данные в конечном итоге удовлетворяли некоторому условию, которое не обязательно гарантировано.
  • С точки зрения вариантов цикла , структурная рекурсия - это когда существует очевидный вариант цикла, а именно размер или сложность, которая начинается с конечного числа и уменьшается на каждом рекурсивном шаге.
  • Напротив, генеративная рекурсия - это когда нет такого очевидного варианта цикла, и завершение зависит от функции, такой как «ошибка приближения», которая не обязательно уменьшается до нуля, и, таким образом, завершение не гарантируется без дальнейшего анализа.

Проблемы реализации

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

  • Функция обертки (вверху)
  • Замыкание базового случая, также известного как "Рекурсия на длину руки" (внизу)
  • Гибридный алгоритм (внизу) - переключение на другой алгоритм, когда объем данных достаточно мал

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

Функция обертки

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

Функции-оболочки могут использоваться для проверки параметров (чтобы рекурсивная функция могла их пропускать), выполнения инициализации (выделения памяти, инициализации переменных), особенно для вспомогательных переменных, таких как «уровень рекурсии» или частичных вычислений для мемоизации , а также обработки исключений и ошибок. . В языках, поддерживающих вложенные функции , вспомогательная функция может быть вложена в функцию-оболочку и использовать общую область видимости. В отсутствие вложенных функций вспомогательные функции вместо этого являются отдельной функцией, если возможно, частной (поскольку они не вызываются напрямую), а информация передается функции-оболочке с помощью передачи по ссылке .

Короткое замыкание базового случая

Факториал: обычный против короткозамкнутого
Обычная рекурсия Рекурсия короткого замыкания
int fac1(int n) {
   if (n <= 0)
      return 1;
   else
      return fac1(n-1)*n;
}
static int fac2(int n) {
   // assert(n >= 2);
   if (n == 2)
      return 2;
   else
      return fac2(n-1)*n;
}
int fac2wrapper(int n) {
   if (n <= 1)
      return 1;
   else
      return fac2(n);
}

Сокращение базового случая, также известное как рекурсия на расстоянии вытянутой руки , состоит из проверки базового случая перед выполнением рекурсивного вызова, т. Е. Проверки того, будет ли следующий вызов базовым случаем, вместо вызова и последующей проверки базового случая. . Короткое замыкание делается, в частности, из соображений эффективности, чтобы избежать накладных расходов на вызов функции, который немедленно возвращается. Обратите внимание, что, поскольку базовый случай уже был проверен (непосредственно перед рекурсивным шагом), его не нужно проверять отдельно, но нужно использовать функцию-оболочку для случая, когда общая рекурсия начинается с базового случая. сам. Например, в функции факториала базовый случай равен 0! = 1, при этом сразу возвращается 1 вместо 1! это короткое замыкание, может пропустить 0; это можно смягчить с помощью функции-оболочки. В поле показан код C для сокращения факториальных случаев 0 и 1.

Короткое замыкание в первую очередь вызывает беспокойство, когда встречается много базовых случаев, таких как нулевые указатели в дереве, которые могут быть линейными по количеству вызовов функций, следовательно, значительная экономия для алгоритмов O ( n ) ; это проиллюстрировано ниже для поиска в глубину. Короткое замыкание на дереве соответствует рассмотрению листа (непустого узла без дочерних элементов) в качестве базового случая, а не рассмотрения пустого узла в качестве базового случая. Если есть только один базовый случай, например, при вычислении факториала, короткое замыкание обеспечивает только экономию O (1) .

Концептуально короткое замыкание может рассматриваться как имеющее один и тот же базовый случай и рекурсивный шаг, проверяя базовый случай только перед рекурсией, или может рассматриваться как имеющее другой базовый случай (один шаг удален из стандартного базового случая) и более сложный рекурсивный шаг, а именно «проверка правильности, затем рекурсия», как при рассмотрении листовых узлов, а не нулевых узлов в качестве базовых случаев в дереве. Поскольку короткое замыкание имеет более сложный поток по сравнению с четким разделением базового случая и рекурсивного шага в стандартной рекурсии, его часто считают плохим стилем, особенно в академических кругах.

Поиск в глубину

Базовый пример короткого замыкания дается при поиске в глубину (DFS) двоичного дерева; см. раздел о бинарных деревьях для стандартного рекурсивного обсуждения.

Стандартный рекурсивный алгоритм для DFS:

  • базовый случай: если текущий узел равен Null, вернуть false
  • рекурсивный шаг: в противном случае проверьте значение текущего узла, верните истину, если совпадение, в противном случае выполните рекурсию для дочерних узлов

При коротком замыкании это:

  • проверить значение текущего узла, вернуть истину, если совпадение,
  • в противном случае для потомков, если не Null, то рекурсивно.

Что касается стандартных шагов, это перемещает проверку базового случая перед рекурсивным шагом. В качестве альтернативы их можно рассматривать как другую форму базового случая и рекурсивного шага соответственно. Обратите внимание, что для этого требуется функция-оболочка для обработки случая, когда само дерево пусто (корневой узел имеет значение Null).

В случае идеального двоичного дерева высоты h, есть 2 h +1 −1 узла и 2 h +1 нулевых указателя в качестве дочерних (по 2 для каждого из 2 h листьев), поэтому короткое замыкание сокращает количество функций звонит пополам в худшем случае.

В C стандартный рекурсивный алгоритм может быть реализован как:

bool tree_contains(struct node *tree_node, int i) {
    if (tree_node == NULL)
        return false;  // base case
    else if (tree_node->data == i)
        return true;
    else
        return tree_contains(tree_node->left, i) ||
               tree_contains(tree_node->right, i);
}

Короткозамкнутый алгоритм может быть реализован как:

// Wrapper function to handle empty tree
bool tree_contains(struct node *tree_node, int i) {
    if (tree_node == NULL)
        return false;  // empty tree
    else
        return tree_contains_do(tree_node, i);  // call auxiliary function
}

// Assumes tree_node != NULL
bool tree_contains_do(struct node *tree_node, int i) {
    if (tree_node->data == i)
        return true;  // found
    else  // recurse
        return (tree_node->left  && tree_contains_do(tree_node->left,  i)) ||
               (tree_node->right && tree_contains_do(tree_node->right, i));
}

Обратите внимание на использование короткого замыкания логических операторов && (AND), так что рекурсивный вызов выполняется только в том случае, если узел действителен (не равен Null). Обратите внимание, что в то время как первый член в AND является указателем на узел, второй член является логическим, поэтому общее выражение оценивается как логическое. Это обычная идиома в рекурсивном коротком замыкании. Это в дополнение к оценке короткого замыкания логического || (ИЛИ), чтобы проверять только правый дочерний элемент, если левый дочерний элемент не работает. Фактически, весь поток управления этими функциями можно заменить одним логическим выражением в операторе возврата, но разборчивость не влияет на эффективность.

Гибридный алгоритм

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

Рекурсия против итерации

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

Сравните шаблоны для вычисления x n, определенного как x n = f (n, x n-1 ), из базы x :

function recursive(n)
    if n == base
        return xbase
    else
        return f(n, recursive(n-1))
function iterative(n)
    x = xbase
    for i = base+1 to n
        x = f(i, x)
    return x

Для императивного языка накладные расходы заключаются в определении функции, для функционального языка накладные расходы заключаются в определении переменной-накопителя x.

Например, факториальная функция может быть реализована итеративно в C путем присвоения переменной индекса цикла и переменной-накопителя, а не путем передачи аргументов и возврата значений путем рекурсии:

unsigned int factorial(unsigned int n) {
  unsigned int product = 1; // empty product is 1
  while (n) {
    product *= n;
    --n;
  }
  return product;
}

Выразительная сила

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

И наоборот, все итерационные функции и процедуры, которые могут быть оценены компьютером (см. Полнота по Тьюрингу ), могут быть выражены в терминах рекурсивных функций; Конструкции итеративного управления, такие как циклы while и for , обычно переписываются в рекурсивной форме на функциональных языках . Однако на практике это переписывание зависит от исключения хвостового вызова , что характерно не для всех языков. C , Java и Python - известные основные языки, в которых все вызовы функций, включая хвостовые вызовы , могут вызывать выделение стека, чего не произошло бы при использовании конструкций цикла; на этих языках рабочая итеративная программа, переписанная в рекурсивной форме, может переполнять стек вызовов , хотя устранение хвостового вызова может быть функцией, не охватываемой спецификацией языка, а различные реализации одного и того же языка могут отличаться в возможностях исключения хвостового вызова.

Проблемы с производительностью

В языках (таких как C и Java ), которые отдают предпочтение конструкциям итеративного цикла, рекурсивные программы обычно требуют значительных временных и пространственных затрат из-за накладных расходов, необходимых для управления стеком, и относительной медленности вызовов функций; в функциональных языках вызов функции (особенно хвостовой вызов ) обычно является очень быстрой операцией, и разница обычно менее заметна.

В качестве конкретного примера разница в производительности между рекурсивной и итерационной реализациями приведенного выше «факториального» примера сильно зависит от используемого компилятора . В языках, где предпочтительны циклические конструкции, итеративная версия может быть на несколько порядков быстрее, чем рекурсивная. В функциональных языках общая разница во времени двух реализаций может быть незначительной; на самом деле, стоимость умножения сначала больших чисел, а не меньших (что и происходит в итерационной версии, приведенной здесь) может сокрушить в любое время, сэкономленное за счет выбора итерации.

Пространство стека

В некоторых языках программирования максимальный размер стека вызовов намного меньше, чем пространство, доступное в куче , и рекурсивные алгоритмы, как правило, требуют больше места в стеке, чем итерационные алгоритмы. Следовательно, эти языки иногда устанавливают ограничение на глубину рекурсии, чтобы избежать переполнения стека ; Python - один из таких языков. Обратите внимание на предостережение ниже относительно особого случая хвостовой рекурсии .

Уязвимость

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

Умножение рекурсивных задач

Множественные рекурсивные задачи по своей природе рекурсивны, поскольку их необходимо отслеживать из-за предшествующего состояния. Одним из примеров является обход дерева как при поиске в глубину ; хотя используются как рекурсивные, так и итерационные методы, они контрастируют с обходом по списку и линейным поиском в списке, который является однократно рекурсивным и, следовательно, естественно итеративным методом. Другие примеры включают алгоритмы «разделяй и властвуй», такие как Quicksort , и такие функции, как функция Аккермана . Все эти алгоритмы могут быть реализованы итеративно с помощью явного стека , но усилия программиста, задействованные в управлении стеком, и сложность результирующей программы, возможно, перевешивают любые преимущества итеративного решения.

Рефакторинг рекурсии

Рекурсивные алгоритмы можно заменить нерекурсивными аналогами. Одним из методов замены рекурсивных алгоритмов является их имитация с использованием памяти кучи вместо памяти стека . Альтернативой является разработка алгоритма замены, полностью основанного на нерекурсивных методах, что может оказаться сложной задачей. Например, рекурсивные алгоритмы для сопоставления групповых символов , таких как Rich Salz ' wildmat алгоритм, когда - то были типичны. Нерекурсивные алгоритмы для той же цели, такие как алгоритм сопоставления подстановочных знаков Краусса , были разработаны, чтобы избежать недостатков рекурсии, и улучшались только постепенно на основе таких методов, как сбор тестов и производительность профилирования .

Хвостовые рекурсивные функции

Хвостовые рекурсивные функции - это функции, в которых все рекурсивные вызовы являются хвостовыми вызовами и, следовательно, не создают никаких отложенных операций. Например, функция gcd (снова показанная ниже) является хвостовой рекурсивной. Напротив, факториальная функция (также ниже) не является хвостовой рекурсивной; поскольку его рекурсивный вызов не находится в хвостовой позиции, он создает отложенные операции умножения, которые должны выполняться после завершения последнего рекурсивного вызова. С компилятором или интерпретатором, который обрабатывает хвостовые рекурсивные вызовы как переходы, а не вызовы функций, хвостовая рекурсивная функция, такая как gcd, будет выполняться с использованием постоянного пространства. Таким образом, программа по существу является итеративной, что эквивалентно использованию структур управления императивного языка, таких как циклы «for» и «while».

Рекурсия хвоста : Дополнительная рекурсия:
//INPUT: Integers x, y such that x >= y and y >= 0
int gcd(int x, int y)
{
  if (y == 0)
     return x;
  else
     return gcd(y, x % y);
}
//INPUT: n is an Integer such that n >= 0
int fact(int n)
{
   if (n == 0)
      return 1;
   else
      return n * fact(n - 1);
}

Значение хвостовой рекурсии состоит в том, что при выполнении хвостового рекурсивного вызова (или любого хвостового вызова) позиция возврата вызывающего абонента не должна сохраняться в стеке вызовов ; когда рекурсивный вызов возвращается, он будет переходить непосредственно к ранее сохраненной позиции возврата. Следовательно, в языках, которые распознают это свойство хвостовых вызовов, хвостовая рекурсия экономит как пространство, так и время.

Порядок исполнения

Рассмотрим эти две функции:

Функция 1

void recursiveFunction(int num) {
    printf("%d\n", num);
    if (num < 4)
        recursiveFunction(num + 1);
}

Recursive1.svg

Функция 2

void recursiveFunction(int num) {
    if (num < 4)
        recursiveFunction(num + 1);
    printf("%d\n", num);
}

Recursive2.svg

Функция 2 - это функция 1 с переставленными строками.

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

Также обратите внимание, что порядок операторов печати обратный, что связано с тем, как функции и операторы хранятся в стеке вызовов .

Рекурсивные процедуры

Факториал

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

Псевдокод (рекурсивный):
function factorial is:
input: integer n such that n >= 0
output: [n × (n-1) × (n-2) × … × 1]
1. if n is 0, return 1 2. otherwise, return [ n × factorial(n-1) ]
end factorial

Функцию также можно записать как рекуррентное отношение :

Эта оценка отношения рекуррентности демонстрирует вычисление, которое будет выполнено при оценке псевдокода выше:

Вычисление рекуррентного соотношения для n = 4:
b4           = 4 × b3
             = 4 × (3 × b2)
             = 4 × (3 × (2 × b1))
             = 4 × (3 × (2 × (1 × b0)))
             = 4 × (3 × (2 × (1 × 1)))
             = 4 × (3 × (2 × 1))
             = 4 × (3 × 2)
             = 4 × 6
             = 24

Эта факториальная функция также может быть описана без использования рекурсии, используя типичные конструкции цикла, найденные в императивных языках программирования:

Псевдокод (итеративный):
function factorial is:
input: integer n such that n >= 0
output: [n × (n-1) × (n-2) × … × 1]
1. create new variable called running_total with a value of 1
2. begin loop 1. if n is 0, exit loop 2. set running_total to (running_total × n) 3. decrement n 4. repeat loop
3. return running_total
end factorial

Приведенный выше императивный код эквивалентен этому математическому определению с использованием аккумуляторной переменной t :

Приведенное выше определение напрямую транслируется на языки функционального программирования, такие как Scheme ; это пример рекурсивной итерации.

Наибольший общий делитель

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

Определение функции :

Псевдокод (рекурсивный):
function gcd is:
input: integer x, integer y such that x > 0 and y >= 0

1. if y is 0, return x 2. otherwise, return [ gcd( y, (remainder of x/y) ) ]
end gcd

Отношение повторяемости для наибольшего общего делителя, где выражает остаток от :

если
Вычисление рекуррентного соотношения для x = 27 и y = 9:
gcd(27, 9)   = gcd(9, 27 % 9)
             = gcd(9, 0)
             = 9
Вычисление рекуррентного соотношения для x = 111 и y = 259:
gcd(111, 259)   = gcd(259, 111 % 259)
                = gcd(259, 111)
                = gcd(111, 259 % 111)
                = gcd(111, 37)
                = gcd(37, 111 % 37)
                = gcd(37, 0)
                = 37

Рекурсивная программа выше является хвостовой рекурсивной ; он эквивалентен итерационному алгоритму, и вычисление, показанное выше, показывает шаги оценки, которые будут выполняться языком, исключающим хвостовые вызовы. Ниже приведена версия того же алгоритма с использованием явной итерации, подходящая для языка, который не исключает хвостовые вызовы. Сохраняя свое состояние полностью в переменных x и y и используя конструкцию цикла, программа избегает выполнения рекурсивных вызовов и увеличения стека вызовов.

Псевдокод (итеративный):
function gcd is:
input: integer x, integer y such that x >= y and y >= 0
1. create new variable called remainder
2. begin loop 1. if y is zero, exit loop 2. set remainder to the remainder of x/y 3. set x to y 4. set y to remainder 5. repeat loop
3. return x
end gcd

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

Башни Ханоя

Башни Ханоя

Башни Ханоя - это математическая головоломка, решение которой иллюстрирует рекурсию. Есть три колышка, на которых можно удерживать стопки дисков разного диаметра. Диск большего размера нельзя ставить поверх меньшего. Начиная с n дисков на одной привязке, их нужно перемещать на другую привязку по очереди. Какое наименьшее количество шагов для перемещения стека?

Определение функции :

Соотношение повторяемости для ханоя :

Вычисление рекуррентного соотношения для n = 4:
hanoi(4)     = 2×hanoi(3) + 1
             = 2×(2×hanoi(2) + 1) + 1
             = 2×(2×(2×hanoi(1) + 1) + 1) + 1
             = 2×(2×(2×1 + 1) + 1) + 1
             = 2×(2×(3) + 1) + 1
             = 2×(7) + 1
             = 15


Примеры реализации:

Псевдокод (рекурсивный):
function hanoi is:
input: integer n, such that n >= 1
1. if n is 1 then return 1
2. return [2 * [call hanoi(n-1)] + 1]
end hanoi

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

Явная формула для Башен Ханоя:
h1 = 1   = 21 - 1
h2 = 3   = 22 - 1
h3 = 7   = 23 - 1
h4 = 15  = 24 - 1
h5 = 31  = 25 - 1
h6 = 63  = 26 - 1
h7 = 127 = 27 - 1
In general:
hn = 2n - 1, for all n >= 1

Бинарный поиск

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

В этом алгоритме используется рекурсия, потому что при каждом проходе создается новый массив путем разрезания старого пополам. Затем процедура двоичного поиска вызывается рекурсивно, на этот раз в новом (и меньшем) массиве. Обычно размер массива регулируется путем изменения начального и конечного индекса. Алгоритм демонстрирует логарифмический порядок роста, потому что он по существу делит проблемную область пополам с каждым проходом.

Пример реализации бинарного поиска на C:

 /*
  Call binary_search with proper initial conditions.

  INPUT:
    data is an array of integers SORTED in ASCENDING order,
    toFind is the integer to search for,
    count is the total number of elements in the array

  OUTPUT:
    result of binary_search

 */
 int search(int *data, int toFind, int count)
 {
    //  Start = 0 (beginning index)
    //  End = count - 1 (top index)
    return binary_search(data, toFind, 0, count-1);
 }

 /*
   Binary Search Algorithm.

   INPUT:
        data is a array of integers SORTED in ASCENDING order,
        toFind is the integer to search for,
        start is the minimum array index,
        end is the maximum array index
   OUTPUT:
        position of the integer toFind within array data,
        -1 if not found
 */
 int binary_search(int *data, int toFind, int start, int end)
 {
    //Get the midpoint.
    int mid = start + (end - start)/2;   //Integer division


    if (start > end)                     //Stop condition (base case)
       return -1;
    else if (data[mid] == toFind)        //Found, return index
       return mid;
    else if (data[mid] > toFind)         //Data is greater than toFind, search lower half
       return binary_search(data, toFind, start, mid-1);
    else                                 //Data is less than toFind, search upper half
       return binary_search(data, toFind, mid+1, end);
 }

Рекурсивные структуры данных (структурная рекурсия)

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

«Рекурсивные алгоритмы особенно подходят, когда основная проблема или обрабатываемые данные определены в рекурсивных терминах».

Примеры в этом разделе иллюстрируют так называемую «структурную рекурсию». Этот термин относится к тому факту, что рекурсивные процедуры воздействуют на данные, которые определены рекурсивно.

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

Связанные списки

Ниже приведено определение C структуры узлов связанного списка. Обратите особое внимание на то, как узел определяется в терминах самого себя. «Следующий» элемент узла структуры - это указатель на другой узел структуры , фактически создающий тип списка.

struct node
{
  int data;           // some integer data
  struct node *next;  // pointer to another struct node
};

Поскольку структура данных узла структуры определяется рекурсивно, процедуры, которые работают с ней, могут быть реализованы естественным образом как рекурсивные процедуры. Процедура list_print , определенная ниже, просматривает список до тех пор, пока список не станет пустым (т. Е. Указатель списка не будет иметь значение NULL). Для каждого узла он печатает элемент данных (целое число). В реализации C список остается неизменным с помощью процедуры list_print .

void list_print(struct node *list)
{
    if (list != NULL)               // base case
    {
       printf ("%d ", list->data);  // print integer data followed by a space
       list_print (list->next);     // recursive call on the next node
    }
}

Бинарные деревья

Ниже приводится простое определение узла двоичного дерева. Как и узел для связанных списков, он определяется сам по себе рекурсивно. Есть два самореферентных указателя: левый (указывающий на левое поддерево) и правый (указывающий на правое поддерево).

struct node
{
  int data;            // some integer data
  struct node *left;   // pointer to the left subtree
  struct node *right;  // point to the right subtree
};

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

// Test if tree_node contains i; return 1 if so, 0 if not.
int tree_contains(struct node *tree_node, int i) {
    if (tree_node == NULL)
        return 0;  // base case
    else if (tree_node->data == i)
        return 1;
    else
        return tree_contains(tree_node->left, i) || tree_contains(tree_node->right, i);
}

Для любого данного вызова tree_contains, как определено выше, будет выполнено не более двух рекурсивных вызовов .

// Inorder traversal:
void tree_print(struct node *tree_node) {
    if (tree_node != NULL) {              // base case
        tree_print(tree_node->left);      // go left
        printf("%d ", tree_node->data);   // print the integer followed by a space
        tree_print(tree_node->right);     // go right
    }
}

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

Обход файловой системы

Поскольку количество файлов в файловой системе может быть разным, рекурсия - единственный практический способ пройти и, таким образом, перечислить ее содержимое. Обход файловой системы очень похож на обход дерева , поэтому концепции обхода дерева применимы к обходу файловой системы. Более конкретно, приведенный ниже код будет примером обхода файловой системы перед порядком .

import java.io.File;

public class FileSystem {

	public static void main(String [] args) {
		traverse();
	}

	/**
	 * Obtains the filesystem roots
	 * Proceeds with the recursive filesystem traversal
	 */
	private static void traverse() {
		File[] fs = File.listRoots();
		for (int i = 0; i < fs.length; i++) {
			System.out.println(fs[i]);
			if (fs[i].isDirectory() && fs[i].canRead()) {
				rtraverse(fs[i]);
			}
		}
	}

	/**
	 * Recursively traverse a given directory
	 *
	 * @param fd indicates the starting point of traversal
	 */
	private static void rtraverse(File fd) {
		File[] fss = fd.listFiles();

		for (int i = 0; i < fss.length; i++) {
			System.out.println(fss[i]);
			if (fss[i].isDirectory() && fss[i].canRead()) {
				rtraverse(fss[i]);
			}
		}
	}

}

Этот код и рекурсия и итерация - файлы и каталоги итерация, и каждый каталог открыт рекурсивно.

Метод «rtraverse» является примером прямой рекурсии, в то время как метод «traverse» является функцией-оболочкой.

«Базовый» сценарий состоит в том, что в данной файловой системе всегда будет фиксированное количество файлов и / или каталогов.

Временная эффективность рекурсивных алгоритмов

Эффективность времени рекурсивных алгоритмов может быть выражена в рекуррентном соотношении из Big O нотации . Затем их можно (обычно) упростить до одного термина Big-O.

Краткое правило (основная теорема)

Если временная сложность функции имеет вид

Тогда Большое О временной сложности будет таким:

  • Если для некоторой константы , то
  • Если , то
  • Если для некоторой константы , и если для некоторой константы c <1 и всех достаточно больших n , то

где a представляет количество рекурсивных вызовов на каждом уровне рекурсии, b представляет, на какой коэффициент меньше вход для следующего уровня рекурсии (то есть количество частей, на которые вы делите проблему), а f  ( n ) представляет работу функция не зависит от какой-либо рекурсии (например, разделения, рекомбинации) на каждом уровне рекурсии.

Смотрите также

Примечания

использованная литература