Рекурсия (информатика)
В информатике , рекурсия — это метод решения вычислительной задачи решение которого зависит от решений более мелких экземпляров одной и той же проблемы. [1] [2] Рекурсия решает такие рекурсивные проблемы , используя функции , которые вызывают себя из собственного кода. Этот подход можно применить ко многим типам задач, а рекурсия — одна из центральных идей информатики. [3]
Сила рекурсии, очевидно, заключается в возможности определить бесконечное множество объектов с помощью конечного утверждения. Точно так же бесконечное число вычислений может быть описано конечной рекурсивной программой, даже если эта программа не содержит явных повторений.
- Никлаус Вирт , Алгоритмы + Структуры данных = Программы , 1976 г. [4]
Большинство языков программирования поддерживают рекурсию, позволяя функции вызывать себя из собственного кода. Некоторые функциональные языки программирования (например, Clojure ) [5] не определяйте никаких конструкций циклов, а полагайтесь исключительно на рекурсию для многократного вызова кода. доказано В теории вычислимости , что эти рекурсивные языки полны по Тьюрингу ; это означает, что они столь же мощны (их можно использовать для решения тех же задач), что и императивные языки, основанные на структурах управления, таких как while
и for
.
Повторный вызов функции изнутри может привести к тому, что размер стека вызовов будет равен сумме входных размеров всех задействованных вызовов. Отсюда следует, что для задач, которые можно легко решить путем итерации, рекурсия, как правило, менее эффективна , а для некоторых задач алгоритмические методы или методы оптимизации компилятора, такие как оптимизация хвостового вызова, могут повысить производительность вычислений по сравнению с наивной рекурсивной реализацией.
Рекурсивные функции и алгоритмы
[ редактировать ]Обычная тактика разработки алгоритмов состоит в том, чтобы разделить проблему на подзадачи того же типа, что и исходная, решить эти подзадачи и объединить результаты. Это часто называют методом «разделяй и властвуй» ; в сочетании со справочной таблицей , в которой хранятся результаты ранее решенных подзадач (чтобы избежать их повторного решения и увеличения времени вычислений), это можно назвать динамическим программированием или мемоизацией .
Базовый вариант
[ редактировать ]Определение рекурсивной функции имеет один или несколько базовых случаев , означающих ввод(ы), для которых функция выдает результат тривиально (без повторения), и один или несколько рекурсивных случаев , означающих ввод(ы), для которых программа повторяется (вызывает саму себя). . Например, факториал можно определить рекурсивно с помощью уравнений 0! = 1 и для всех n > 0 n ! = п ( п - 1)! . Ни одно уравнение само по себе не представляет собой полного определения; первый — базовый случай, а второй — рекурсивный случай. Поскольку базовый случай разрывает цепочку рекурсии, его иногда также называют «завершающим случаем».
Работу рекурсивных случаев можно рассматривать как разбиение сложных входных данных на более простые. В правильно спроектированной рекурсивной функции при каждом рекурсивном вызове задача ввода должна упрощаться таким образом, чтобы в конечном итоге был достигнут базовый случай. (Функции, которые не предназначены для завершения при нормальных обстоятельствах — например, некоторые системные и серверные процессы — являются исключением из этого правила.) Пренебрежение написанием базового варианта или неправильное его тестирование может привести к возникновению бесконечного цикла .
Для некоторых функций (например, той, которая вычисляет ряд для e = 1/0! + 1/1! + 1/2! + 1/3! + ... ) не существует очевидного базового случая, подразумеваемого входными данными. ; для них можно добавить параметр (например, количество добавляемых терминов, в нашем примере серии), чтобы обеспечить «критерий остановки», устанавливающий базовый случай. Такой пример более естественно рассматривать с помощью корекурсии , [ как? ] где последовательные члены вывода представляют собой частичные суммы; это можно преобразовать в рекурсию, используя параметр индексации, говорящий «вычислить 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
— и каким может быть это содержание, тогда как индуктивное определение указывает, как создать структуру и из чего она может быть создана.
Корекурсия связана с коиндукцией и может использоваться для вычисления конкретных экземпляров (возможно) бесконечных объектов. Как метод программирования он чаще всего используется в контексте ленивых языков программирования и может быть предпочтительнее рекурсии, когда желаемый размер или точность вывода программы неизвестны. В таких случаях программе требуется как определение бесконечно большого (или бесконечно точного) результата, так и механизм получения конечной части этого результата. Проблема вычисления первых n простых чисел может быть решена с помощью коркурсивной программы (например, здесь ).
Типы рекурсии
[ редактировать ]Одиночная рекурсия и множественная рекурсия
[ редактировать ]Рекурсия, содержащая только одну ссылку на самого себя, называется одиночная рекурсия , а рекурсия, содержащая несколько ссылок на себя, называется множественная рекурсия . Стандартные примеры одиночной рекурсии включают обход списка, например, при линейном поиске или вычисление функции факториала, тогда как стандартные примеры множественной рекурсии включают обход дерева , например, при поиске в глубину.
Одиночная рекурсия часто намного более эффективна, чем множественная рекурсия, и обычно ее можно заменить итеративными вычислениями, выполняемыми в линейном времени и требующими постоянного пространства. Множественная рекурсия, напротив, может требовать экспоненциального времени и пространства и является более фундаментально рекурсивной, поскольку ее нельзя заменить итерацией без явного стека.
Множественную рекурсию иногда можно преобразовать в одинарную рекурсию (и, при желании, затем в итерацию). Например, хотя вычисление последовательности Фибоначчи наивно влечет за собой несколько итераций, поскольку каждое значение требует двух предыдущих значений, его можно вычислить с помощью одной рекурсии, передав два последовательных значения в качестве параметров. Это более естественно оформить как корекурсию, основанную на начальных значениях, с отслеживанием двух последовательных значений на каждом шаге — см. корекурсию: примеры . Более сложный пример предполагает использование многопоточного двоичного дерева , которое позволяет осуществлять итеративный обход дерева, а не множественную рекурсию.
Косвенная рекурсия
[ редактировать ]Большинство основных примеров рекурсии и большинство представленных здесь примеров демонстрируют прямую рекурсию , при которой функция вызывает саму себя. Косвенная рекурсия возникает, когда функция вызывается не сама по себе, а другой функцией, которую она вызвала (прямо или косвенно). Например, если f вызывает f, это прямая рекурсия, но если f вызывает g, который вызывает f, то это косвенная рекурсия f. Возможны цепочки из трех и более функций; например, функция 1 вызывает функцию 2, функция 2 вызывает функцию 3, а функция 3 снова вызывает функцию 1.
Косвенную рекурсию также называют взаимной рекурсией , что является более симметричным термином, хотя это просто разница акцентов, а не другое понятие. То есть, если f вызывает g , а затем g вызывает f, который, в свою очередь, снова вызывает g , с точки зрения только f , f является косвенно рекурсивным, тогда как с точки зрения только g он является косвенно рекурсивным, в то время как с точки зрения обоих, f и g взаимно возвращаются друг к другу. Аналогично набор из трех или более функций, вызывающих друг друга, можно назвать набором взаимно рекурсивных функций.
Анонимная рекурсия
[ редактировать ]Рекурсия обычно выполняется путем явного вызова функции по имени. Однако рекурсию также можно выполнить путем неявного вызова функции на основе текущего контекста, что особенно полезно для анонимных функций и известно как анонимная рекурсия .
Структурная и генеративная рекурсия
[ редактировать ]Некоторые авторы классифицируют рекурсию как «структурную» или «генеративную». Различие связано с тем, откуда рекурсивная процедура получает данные, с которыми она работает, и как она обрабатывает эти данные:
[Функции, использующие структурированные данные], обычно разлагают свои аргументы на непосредственные структурные компоненты, а затем обрабатывают эти компоненты. Если один из непосредственных компонентов принадлежит тому же классу данных, что и входные данные, функция является рекурсивной. По этой причине мы называем эти функции (СТРУКТУРНО) РЕКУРСИВНЫМИ ФУНКЦИЯМИ.
- Феллисен, Финдлер, Флатт и Кришнаурти, Как разрабатывать программы , 2001 г. [6]
Таким образом, определяющей характеристикой структурно-рекурсивной функции является то, что аргументом каждого рекурсивного вызова является содержимое поля исходного ввода. Структурная рекурсия включает в себя почти все обходы деревьев, включая обработку XML, создание и поиск двоичного дерева и т. д. Учитывая алгебраическую структуру натуральных чисел (т. е. натуральное число является либо нулем, либо последователем натурального числа), такие функции, как факториал также можно рассматривать как структурную рекурсию.
Генеративная рекурсия является альтернативой:
Многие известные рекурсивные алгоритмы генерируют совершенно новый фрагмент данных из имеющихся данных и повторяются на нем. HtDP ( «Как разрабатывать программы» ) называет этот вид генеративной рекурсией. Примеры генеративной рекурсии включают: gcd , быструю сортировку , двоичный поиск , сортировку слиянием , метод Ньютона , фракталы и адаптивную интеграцию .
— Маттиас Феллейзен, Расширенное функциональное программирование , 2002 г. [7]
Это различие важно для доказательства завершения функции.
- все структурно-рекурсивные функции на конечных ( индуктивно определенных Можно легко показать, что ) структурах данных завершаются с помощью структурной индукции : интуитивно каждый рекурсивный вызов получает меньшую часть входных данных, пока не будет достигнут базовый случай.
- Напротив, генеративно-рекурсивные функции не обязательно передают меньший объем входных данных в свои рекурсивные вызовы, поэтому доказательство их завершения не обязательно так просто, а избежание бесконечных циклов требует большей осторожности. Эти генеративно-рекурсивные функции часто можно интерпретировать как корекурсивные функции — каждый шаг генерирует новые данные, например, последовательное приближение в методе Ньютона — и завершение этой коркурсии требует, чтобы данные в конечном итоге удовлетворяли некоторому условию, которое не обязательно гарантируется.
- С точки зрения вариантов цикла , структурная рекурсия — это когда существует очевидный вариант цикла, а именно размер или сложность, который начинается с конечного значения и уменьшается на каждом шаге рекурсии.
- Напротив, генеративная рекурсия - это когда нет такого очевидного варианта цикла, а завершение зависит от функции, такой как «ошибка аппроксимации», которая не обязательно уменьшается до нуля, и, таким образом, завершение не гарантируется без дальнейшего анализа.
Проблемы реализации
[ редактировать ]В реальной реализации вместо чистой рекурсивной функции (однократная проверка базового случая, в противном случае рекурсивный шаг) может быть сделан ряд модификаций в целях ясности или эффективности. К ним относятся:
- Функция-обертка (вверху)
- Короткое замыкание базового случая, также известное как «рекурсия на расстоянии вытянутой руки» (внизу)
- Гибридный алгоритм (внизу) – переключение на другой алгоритм, когда данных становится достаточно мало.
Из соображений элегантности функции-обертки обычно одобряются, в то время как сокращение базового варианта не одобряется, особенно в научных кругах. Гибридные алгоритмы часто используются для повышения эффективности, чтобы уменьшить накладные расходы на рекурсию в небольших случаях, и рекурсия на расстоянии вытянутой руки является частным случаем этого.
Функция-обертка
[ редактировать ]Функция -обертка — это функция, которая вызывается напрямую, но не выполняет рекурсию, а вместо этого вызывает отдельную вспомогательную функцию, которая фактически выполняет рекурсию.
Функции-оболочки можно использовать для проверки параметров (чтобы рекурсивная функция могла их пропускать), выполнения инициализации (выделение памяти, инициализация переменных), особенно для вспомогательных переменных, таких как «уровень рекурсии» или частичных вычислений для запоминания , а также обработки исключений и ошибок. . В языках, поддерживающих вложенные функции , вспомогательная функция может быть вложена в функцию-оболочку и использовать общую область действия. При отсутствии вложенных функций вспомогательные функции представляют собой отдельную функцию, если возможно, частную (поскольку они не вызываются напрямую), а информация передается функции-оболочке с помощью передачи по ссылке .
Короткое замыкание базового варианта
[ редактировать ]
Обычная рекурсия | Короткая рекурсия |
---|---|
int fac1(int n) {
if (n <= 0)
return 1;
else
return fac1(n-1)*n;
}
|
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) экономию .
Концептуально можно считать, что короткое замыкание либо имеет один и тот же базовый вариант и рекурсивный шаг, проверяя базовый случай только перед рекурсией, либо может рассматриваться как имеющее другой базовый случай (один шаг удален от стандартного базового варианта) и более сложный рекурсивный шаг, а именно «проверка допустимости, затем рекурсия», например, при рассмотрении листовых узлов, а не нулевых узлов, в качестве базовых случаев в дереве. Поскольку короткое замыкание имеет более сложный процесс по сравнению с четким разделением базового случая и рекурсивного шага в стандартной рекурсии, его часто считают плохим стилем, особенно в научных кругах. [8]
Поиск в глубину
[ редактировать ]Базовый пример короткого замыкания дан при поиске в глубину (DFS) двоичного дерева; см. раздел «Двоичные деревья» для обсуждения стандартной рекурсии.
Стандартный рекурсивный алгоритм для DFS:
- базовый случай: если текущий узел равен нулю, вернуть false
- рекурсивный шаг: в противном случае проверьте значение текущего узла, верните true, если оно совпадает, в противном случае выполните рекурсию для дочерних элементов.
Вместо этого при коротком замыкании это:
- проверить значение текущего узла, вернуть true, если оно совпадает,
- в противном случае для детей, если не Null, тогда выполняется рекурсия.
С точки зрения стандартных шагов, это перемещает проверку базового случая перед рекурсивным шагом. В качестве альтернативы их можно рассматривать как другую форму базового случая и рекурсивного шага соответственно. Обратите внимание, что для этого требуется функция-оболочка для обработки случая, когда само дерево пусто (корневой узел имеет значение NULL).
В случае идеального двоичного дерева высоты h существует 2 час +1 −1 узлов и 2 час +1 Нулевые указатели как дочерние (по 2 на каждый из 2 час листья), поэтому в худшем случае короткое замыкание сокращает количество вызовов функций вдвое.
В 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 является указателем на узел, второй член является логическим значением, поэтому общее выражение оценивается как логическое значение. Это распространенная идиома в рекурсивном коротком замыкании. Это в дополнение к сокращенной оценке логического || (OR) для проверки правого дочернего элемента только в случае сбоя левого дочернего элемента. Фактически, весь поток управления этими функциями можно заменить одним логическим выражением в операторе возврата, но разборчивость не пострадает, а эффективность не пострадает.
Гибридный алгоритм
[ редактировать ]Рекурсивные алгоритмы часто неэффективны для небольших данных из-за накладных расходов на повторные вызовы функций и возвраты. По этой причине эффективные реализации рекурсивных алгоритмов часто начинаются с рекурсивного алгоритма, но затем переключаются на другой алгоритм, когда входные данные становятся малы. Важным примером является сортировка слиянием , которая часто реализуется путем переключения на нерекурсивную сортировку вставкой, когда данных достаточно мало, как в мозаичной сортировке слиянием . Гибридные рекурсивные алгоритмы часто могут быть дополнительно усовершенствованы, как в 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;
}
Выразительная сила
[ редактировать ]Большинство языков программирования , используемых сегодня, допускают прямую спецификацию рекурсивных функций и процедур. программы Когда вызывается такая функция, среда выполнения отслеживает различные экземпляры функции (часто с использованием стека вызовов , хотя могут использоваться и другие методы). Любую рекурсивную функцию можно преобразовать в итеративную функцию, заменив рекурсивные вызовы итеративными конструкциями управления и моделируя стек вызовов стеком, явно управляемым программой. [9] [10]
И наоборот, все итеративные функции и процедуры, которые могут быть оценены компьютером (см. Полнота по Тьюрингу ), могут быть выражены через рекурсивные функции; итеративные конструкции управления, такие как циклы while и for, обычно переписываются в рекурсивной форме на функциональных языках . [11] [12] Однако на практике такое переписывание зависит от исключения хвостовых вызовов , которое есть не во всех языках. C , Java и Python — известные основные языки, в которых все вызовы функций, включая хвостовые вызовы , могут вызвать выделение стека, чего не произошло бы при использовании конструкций цикла; на этих языках рабочая итеративная программа, переписанная в рекурсивной форме, может переполнить стек вызовов , хотя устранение хвостовых вызовов может быть функцией, не охваченной спецификацией языка, и разные реализации одного и того же языка могут различаться возможностями устранения хвостовых вызовов.
Проблемы с производительностью
[ редактировать ]В языках (таких как C и Java ), которые предпочитают итеративные конструкции циклов, рекурсивные программы обычно требуют значительных затрат времени и пространства из-за накладных расходов, необходимых для управления стеком, и относительной медленности вызовов функций; в функциональных языках вызов функции (особенно хвостовой вызов ) обычно является очень быстрой операцией, и разница обычно менее заметна.
В качестве конкретного примера: разница в производительности между рекурсивной и итеративной реализацией приведенного выше примера «факториала» сильно зависит от используемого компилятора . В языках, где предпочтительны циклические конструкции, итеративная версия может быть на несколько порядков быстрее рекурсивной. В функциональных языках общая разница во времени между двумя реализациями может быть незначительной; на самом деле, стоимость умножения сначала больших чисел, а не меньших чисел (что происходит в приведенной здесь итерационной версии) может превысить любое время, сэкономленное за счет выбора итерации.
Пространство стека
[ редактировать ]В некоторых языках программирования максимальный размер стека вызовов намного меньше места, доступного в куче , а рекурсивным алгоритмам, как правило, требуется больше места в стеке, чем итеративным алгоритмам. Следовательно, эти языки иногда налагают ограничение на глубину рекурсии, чтобы избежать переполнения стека ; Python — один из таких языков. [13] Обратите внимание на предостережение ниже, касающееся особого случая хвостовой рекурсии .
Уязвимость
[ редактировать ]Поскольку рекурсивные алгоритмы могут быть подвержены переполнению стека, они могут быть уязвимы для патологических или вредоносных входных данных. [14] Некоторые вредоносные программы специально нацелены на стек вызовов программы и используют его рекурсивный характер. [15] Даже при отсутствии вредоносного ПО переполнение стека, вызванное неограниченной рекурсией, может оказаться фатальным для программы, а исключений обработки соответствующего процесса может не предотвратить завершение логика . [16]
Умноженные рекурсивные задачи
[ редактировать ]Множественно-рекурсивные задачи по своей сути рекурсивны, поскольку им необходимо отслеживать предшествующее состояние. Одним из примеров является обход дерева , как при поиске в глубину ; хотя используются как рекурсивные, так и итеративные методы, [17] они контрастируют с обходом списка и линейным поиском в списке, который является однорекурсивным и, следовательно, естественно итеративным методом. Другие примеры включают алгоритмы «разделяй и властвуй», такие как Quicksort , и такие функции, как функция Аккермана . Все эти алгоритмы могут быть реализованы итеративно с помощью явного стека , но усилия программиста, затрачиваемые на управление стеком, и сложность получаемой программы, возможно, перевешивают любые преимущества итеративного решения.
Рефакторинг рекурсии
[ редактировать ]Рекурсивные алгоритмы можно заменить нерекурсивными аналогами. [18] Одним из методов замены рекурсивных алгоритмов является их моделирование с использованием динамической памяти вместо стековой памяти . [19] Альтернативой является разработка алгоритма замены, полностью основанного на нерекурсивных методах, что может оказаться сложной задачей. [20] Например, рекурсивные алгоритмы сопоставления подстановочных знаков , такие как алгоритм Рича Зальца подстановочный , [21] когда-то были типичными. Нерекурсивные алгоритмы для той же цели, такие как алгоритм сопоставления подстановочных знаков Краусса , были разработаны, чтобы избежать недостатков рекурсии. [22] и улучшались лишь постепенно на основе таких методов, как сбор тестов и профилирование производительности. [23]
Хвостовые рекурсивные функции
[ редактировать ]Функции хвостовой рекурсии — это функции, в которых все рекурсивные вызовы являются хвостовыми вызовами и, следовательно, не создают никаких отложенных операций. Например, функция 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);
}
Функция 2
[ редактировать ]void recursiveFunction(int num) {
if (num < 4)
recursiveFunction(num + 1);
printf("%d\n", num);
}
Выходные данные функции 2 аналогичны выходным данным функции 1 с поменянными местами строками.
В случае, если функция вызывает себя только один раз, инструкции, помещенные перед рекурсивным вызовом, выполняются один раз за рекурсию перед любой из инструкций, помещенных после рекурсивного вызова. Последние выполняются повторно после достижения максимальной рекурсии.
Также обратите внимание, что порядок операторов печати обратный, что связано с тем, как функции и операторы хранятся в стеке вызовов .
Рекурсивные процедуры
[ редактировать ]Факториал
[ редактировать ]Классическим примером рекурсивной процедуры является функция, используемая для вычисления факториала натурального числа :
Псевдокод (рекурсивный): |
---|
function factorial is: |
Функцию также можно записать в виде рекуррентного отношения :
Эта оценка рекуррентного отношения демонстрирует вычисления, которые будут выполнены при оценке приведенного выше псевдокода:
Вычисление рекуррентного соотношения для 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: |
Приведенный выше императивный код эквивалентен этому математическому определению с использованием аккумуляторной переменной t :
Приведенное выше определение напрямую переводится в языки функционального программирования, такие как Scheme ; это пример рекурсивной итерации.
Наибольший общий делитель
[ редактировать ]Алгоритм Евклида , который вычисляет наибольший общий делитель двух целых чисел, можно написать рекурсивно.
Определение функции :
Псевдокод (рекурсивный): |
---|
function gcd is: input: integer x, integer y such that x > 0 and y >= 0 |
Рекуррентное соотношение для наибольшего общего делителя, где выражает остаток :
- если
Вычисление рекуррентного соотношения для 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: |
Итеративный алгоритм требует временной переменной, и даже при знании алгоритма Евклида труднее понять процесс путем простой проверки, хотя эти два алгоритма очень похожи по своим шагам.
Башни Ханоя
[ редактировать ]Ханойские башни — это математическая головоломка, решение которой иллюстрирует рекурсию. [24] [25] Есть три колышка, на которых можно держать стопки дисков разного диаметра. Диск большего размера никогда не может быть установлен поверх меньшего. Начиная с 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: |
Хотя не все рекурсивные функции имеют явное решение, последовательность Ханойской башни можно свести к явной формуле. [26]
Явная формула для Ханойских башен: |
---|
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);
}
Рекурсивные структуры данных (структурная рекурсия)
[ редактировать ]Важным применением рекурсии в информатике является определение динамических структур данных, таких как списки и деревья . Рекурсивные структуры данных могут динамически увеличиваться до теоретически бесконечного размера в соответствии с требованиями времени выполнения; напротив, размер статического массива должен быть установлен во время компиляции.
«Рекурсивные алгоритмы особенно подходят, когда основная проблема или данные, подлежащие обработке, определяются в рекурсивных терминах». [27]
Примеры в этом разделе иллюстрируют то, что известно как «структурная рекурсия». Этот термин относится к тому факту, что рекурсивные процедуры действуют на данные, определенные рекурсивно.
Пока программист извлекает шаблон из определения данных, функции используют структурную рекурсию. То есть рекурсии в теле функции потребляют некую непосредственную часть заданного составного значения. [7]
Связанные списки
[ редактировать ]Ниже приведено определение структуры узла связанного списка на языке 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 ) представляет работу что функция делает независимо от какой-либо рекурсии (например, разделения, рекомбинации) на каждом уровне рекурсии.
Рекурсия в логическом программировании
[ редактировать ]При процедурной интерпретации логических программ используются положения (или правила) вида A :- B
рассматриваются как процедуры, сводящие к минимуму цели формы A
к подцелям вида B
.
Например, предложения Пролога :
path(X,Y) :- arc(X,Y).
path(X,Y) :- arc(X,Z), path(Z,Y).
определить процедуру, которую можно использовать для поиска пути от X до Y либо путем нахождения прямой дуги от X до Y , либо путем нахождения дуги от X до Z , а затем рекурсивного поиска пути от Z до Y . Пролог выполняет процедуру, рассуждая сверху вниз (или наоборот ) и просматривая пространство возможных путей в глубину, по одной ветви за раз. Если он пытается выполнить второе предложение и в конечном итоге не может найти путь от Z до Y , он возвращается назад и пытается найти дугу от X до другого узла, а затем ищет путь от этого другого узла Y. до
Однако при логическом чтении логических программ предложения понимаются декларативно как универсально квантифицированные условные выражения. Например, рекурсивное предложение процедуры поиска пути понимается как представление знания о том, что для каждых X , Y и Z , если существует дуга от X до Z и путь от Z до Y , то существует путь от Х до Y. от В символической форме:
Логическое прочтение освобождает читателя от необходимости знать, как это предложение используется для решения проблем. Это предложение можно использовать сверху вниз, как в Прологе, чтобы свести проблемы к подзадачам. Или его можно использовать снизу вверх (или вперед ), как в Datalog , для получения выводов на основе условий. Такое разделение задач является формой абстракции , которая отделяет декларативные знания от методов решения проблем (см. Алгоритм#Алгоритм = Логика + Управление ). [28]
См. также
[ редактировать ]- Функциональное программирование
- Вычислительная задача
- Иерархические и рекурсивные запросы в SQL
- Парадокс Клини-Россера
- Открытая рекурсия
- Рекурсия (в целом)
- Кривая Серпинского
- Функция Маккарти 91
- μ-рекурсивные функции
- Примитивные рекурсивные функции
- Так (функция)
- Логическое программирование
Примечания
[ редактировать ]- ^ Грэм, Рональд; Кнут, Дональд; Паташник, Орен (1990). «1: Повторяющиеся проблемы» . Конкретная математика . Аддисон-Уэсли. ISBN 0-201-55802-5 .
- ^ Кухейл, Массачусетс; Негрейрос, Дж.; Сеффа, А. (2021). «Обучение рекурсивному мышлению с использованием отключенных действий» (PDF) . Мировые сделки по инженерному и технологическому образованию . 19 : 169–175.
- ^ Эпп, Сюзанна (1995). Дискретная математика с приложениями (2-е изд.). Издательская компания ПВС. п. 427 . ISBN 978-0-53494446-9 .
- ^ Вирт, Никлаус (1976). Алгоритмы + Структуры данных = Программы . Прентис-Холл . п. 126 . ISBN 978-0-13022418-7 .
- ^ «Функциональное программирование | Clojure для смелых и истинных» . www.braveclojure.com . Проверено 21 октября 2020 г.
- ^ Феллизен и др. 2001 г. , арт V «Генеративная рекурсия»
- ^ Перейти обратно: а б Феллейзен, Матиас (2002). «Разработка интерактивных веб-программ» . В Жеринге, Йохан (ред.). Продвинутое функциональное программирование: 4-я международная школа (PDF) . Спрингер. п. 108. ИСБН 9783540448334 .
- ^ Монган, Джон; Жигер, Эрик; Киндлер, Ной (2013). Разоблачение собеседований по программированию: секреты получения следующей работы (3-е изд.). Уайли . п. 115 . ISBN 978-1-118-26136-1 .
- ^ Хетланд, Магнус Ли (2010), Алгоритмы Python: освоение базовых алгоритмов языка Python , Apress, стр. 79, ISBN 9781430232384 .
- ^ Дроздек, Адам (2012), Структуры данных и алгоритмы в C++ (4-е изд.), Cengage Learning, стр. 197, ISBN 9781285415017 .
- ^ Дрожь, Олин. «Анатомия цикла — история масштаба и контроля» (PDF) . Технологический институт Джорджии . Проверено 3 сентября 2012 г.
- ^ Лямбда Ultimate. «Анатомия петли» . Лямбда Ultimate . Проверено 3 сентября 2012 г.
- ^ «27.1.sys — Системные параметры и функции — Документация Python v2.7.3» . Docs.python.org . Проверено 3 сентября 2012 г.
- ^ Краусс, Кирк Дж. (2014). «Сопоставление подстановочных знаков: эмпирический способ приручить алгоритм» . Журнал доктора Добба .
- ^ Мюллер, Оливер (2012). «Анатомия атаки на разрушение стека и как GCC ее предотвращает» . Журнал доктора Добба .
- ^ «Класс StackOverflowException» . Библиотека классов .NET Framework . Сеть разработчиков Microsoft . 2018.
- ^ «Поиск в глубину (DFS): итеративная и рекурсивная реализация» . Технический восторг. 2018.
- ^ Митрович, Иван. «Замените рекурсию итерацией» . МысльВоркс .
- ^ Ла, Ун Гю (2015). «Как заменить рекурсивные функции с помощью стека и цикла while, чтобы избежать переполнения стека» . КодПроект.
- ^ Мертель, Том (2013). «Профессиональные хитрости: рекурсия к итерации, часть 2: устранение рекурсии с помощью секретного трюка с путешествием во времени» .
- ^ Зальц, Рич (1991). "wildmat.c" . Гитхаб .
- ^ Краусс, Кирк Дж. (2008). «Сопоставление подстановочных знаков: алгоритм» . Журнал доктора Добба .
- ^ Краусс, Кирк Дж. (2018). «Сопоставление подстановочных знаков: улучшенный алгоритм для больших данных» . Развивайтесь ради производительности.
- ^ Грэм, Кнут и Паташник 1990 , §1.1: Ханойская башня
- ^ Эпп 1995 , стр. 427–430: Ханойская башня.
- ^ Epp 1995 , стр. 447–448: Явная формула для последовательности Ханойской башни.
- ^ Вирт 1976 , с. 127
- ^ Рассел, Стюарт Дж .; Норвиг, Питер. (2021). Искусственный интеллект: современный подход §9.3, §9.4 (4-е изд.). Хобокен: Пирсон. ISBN 978-0134610993 . LCCN 20190474 .
Ссылки
[ редактировать ]- Бэррон, Дэвид Уильям (1968) [1967]. Написано в Кембридже, Великобритания. Гилл, Стэнли (ред.). Рекурсивные методы в программировании . Компьютерные монографии Макдональда (1-е изд.). Лондон, Великобритания: Macdonald & Co. (Publishers) Ltd. SBN. 356-02201-3 . (viii+64 страницы)
- Феллейзен, Матиас; Финдлер, Роберт Б.; Флэтт, Мэтью; Кришнамурти, Шрирам (2001). Как разрабатывать программы: введение в вычислительную технику и программирование . МТИ Пресс . ISBN 0262062186 .
- Рубио-Санчес, Мануэль (2017). Введение в рекурсивное программирование . ЦРК Пресс . ISBN 978-1-351-64717-5 .
- Певац, Ирена (2016). Практика рекурсии в Java . CreateSpace Независимый. ISBN 978-1-5327-1227-2 .
- Робертс, Эрик (2005). Рекурсивное мышление с помощью Java . Уайли . ISBN 978-0-47170146-0 .
- Рол, Джеффри С. (1984). Рекурсия в Паскале . Издательство Кембриджского университета . ISBN 978-0-521-26934-6 .
- Хелман, Пол; Верофф, Роберт. Стены и зеркала .
- Абельсон, Гарольд ; Сассман, Джеральд Джей ; Сассман, Джули (1996). Структура и интерпретация компьютерных программ (2-е изд.). МТИ Пресс . ISBN 0-262-51087-1 .
- Дейкстра, Эдсгер В. (1960). «Рекурсивное программирование». Численная математика . 2 (1): 312–318. дои : 10.1007/BF01386232 . S2CID 127891023 .