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

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

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

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

— Никлаус Вирт, Алгоритмы + Структуры данных = Программы, 1976[3]

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

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

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

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

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

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

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

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

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

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

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

данные ListOfStrings = Пустой список | Минусы Нить ListOfStrings

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

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

Натуральное число - это либо 1, либо n + 1, где n - натуральное число.

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

 <expr> ::= <номер>          | (<expr> * <expr>)          | (<expr> + <expr>)

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

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

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

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

Поток строк - это такой объект, что: head (s) - это строка, а tail (s) - это поток строк.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Генеративная рекурсия это альтернатива:

Многие хорошо известные рекурсивные алгоритмы генерируют совершенно новый фрагмент данных из заданных данных и повторяются на нем. HtDP (Как разрабатывать программы) называет этот вид генеративной рекурсией. Примеры генеративной рекурсии включают: gcd, быстрая сортировка, бинарный поиск, Сортировка слиянием, Метод Ньютона, фракталы, и адаптивная интеграция.

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

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

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

Рекурсивные программы

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

Факториал

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

Псевдокод (рекурсивный):
функция факториал:
Вход: целое число п такой, что п >= 0
выход: [п × (п-1) × (п-2) × … × 1]
1. если п равно 0, возвращаться 1 2. в противном случае возвращаться [ п × факториал (п-1) ]
конец факториал

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

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

Вычисление рекуррентного соотношения для n = 4:
б4           = 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

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

Псевдокод (итеративный):
функция факториал:
Вход: целое число п такой, что п >= 0
выход: [п × (п-1) × (п-2) × … × 1]
1. Создайте новая переменная называется running_total со значением 1
2. начинать цикл 1. если п равно 0, выход петля 2. набор running_total к (running_total × п) 3. декремент п 4. повторение петля
3. возвращаться running_total
конец факториал

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

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

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

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

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

Псевдокод (рекурсивный):
функция gcd это:Вход: целое число Икс, целое число у такой, что Икс > 0 и у >= 0
1. если у равно 0, возвращаться Икс 2. в противном случае возвращаться [gcd ( у, (остаток Икс/у) ) ]
конец 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

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

Псевдокод (итеративный):
функция gcd это:
Вход: целое число Икс, целое число у такой, что Икс >= у и у >= 0
1. Создайте новая переменная называется остаток
2. начинать цикл 1. если у равно нулю, выход петля 2. набор остаток к остатку от x / y 3. набор x к y 4. набор у к остаток 5. повторение петля
3. возвращаться Икс
конец gcd

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

Башни Ханоя

Башни Ханоя

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

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

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

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


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

Псевдокод (рекурсивный):
функция Ханой это:
Вход: целое число п, так что п >= 1
1. если n равно 1 затем вернись 1
2. возвращаться [2 * [вызов ханой (n-1)] + 1]
конец Ханой

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

Явная формула для Башен Ханоя:
час1 = 1   = 21 - 1 час2 = 3   = 22 - 1 час3 = 7   = 23 - 1 час4 = 15  = 24 - 1 час5 = 31  = 25 - 1 час6 = 63  = 26 - 1 час7 = 127 = 27 - 1
В общем: чп = 2п - 1, для всех n> = 1

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

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

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

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

 /*  Вызовите binary_search с правильными начальными условиями.  ВХОД:    data - это массив целых чисел, СОРТИРУЕМЫЙ в порядке возрастания,    toFind - это целое число для поиска,    count - общее количество элементов в массиве  ВЫХОД:    результат binary_search */ int поиск(int *данные, int найти, int считать) {    // Start = 0 (начальный индекс)    // End = count - 1 (верхний индекс)    возвращаться binary_search(данные, найти, 0, считать-1); } /*   Алгоритм двоичного поиска.   ВХОД:        data - это массив целых чисел, СОРТИРУЕМЫЙ в порядке возрастания,        toFind - это целое число для поиска,        start - это минимальный индекс массива,        конец - это максимальный индекс массива   ВЫХОД:        позиция целого числа для поиска в данных массива,        -1 если не найдено */ int binary_search(int *данные, int найти, int Начните, int конец) {    // Получаем среднюю точку.    int середина = Начните + (конец - Начните)/2;   // Целочисленное деление    // Условие остановки.    если (Начните > конец)       возвращаться -1;    еще если (данные[середина] == найти)        //Найденный?       возвращаться середина;    еще если (данные[середина] > найти)         // Данные больше, чем toFind, поиск в нижней половине       возвращаться binary_search(данные, найти, Начните, середина-1);    еще                                 // Данные меньше, чем toFind, поиск в верхней половине       возвращаться binary_search(данные, найти, середина+1, конец); }

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

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

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

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

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

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

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

структура узел{  int данные;           // некоторые целочисленные данные  структура узел *следующий;  // указатель на другой узел структуры};

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

пустота list_print(структура узел *список){    если (список != НОЛЬ)               // базовый вариант    {       printf ("% d", список->данные);  // вывод целочисленных данных с пробелом       list_print (список->следующий);     // рекурсивный вызов следующего узла    }}

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

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

структура узел{  int данные;            // некоторые целочисленные данные  структура узел *оставили;   // указатель на левое поддерево  структура узел *верно;  // указываем на правое поддерево};

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

// Проверяем, содержит ли tree_node i; вернуть 1, если так, 0 в противном случае.int tree_contains(структура узел *tree_node, int я) {    если (tree_node == НОЛЬ)        возвращаться 0;  // базовый вариант    еще если (tree_node->данные == я)        возвращаться 1;    еще        возвращаться tree_contains(tree_node->оставили, я) || tree_contains(tree_node->верно, я);}

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

// Обход в порядке:пустота tree_print(структура узел *tree_node) {        если (tree_node != НОЛЬ) {                  // базовый вариант                tree_print(tree_node->оставили);      // иди налево                printf("% d", tree_node->данные);   // выводим целое число с пробелом                tree_print(tree_node->верно);     // Иди направо        }}

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

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

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

импорт java.io. *;общественный учебный класс Файловая система {	общественный статический пустота главный (Нить [] аргументы) {		траверс ();	}	/*** Получает корни файловой системы* Продолжает рекурсивный обход файловой системы	 */	частный статический пустота траверс () {		Файл [] фс = Файл.listRoots ();		за (int я = 0; я < фс.длина; я++) {			если (фс[я].isDirectory () && фс[я].canRead ()) {				rtraverse (фс[я]);			}		}	}	/*** Рекурсивно перемещаться по заданному каталогу	 ** @param fd указывает начальную точку обхода	 */	частный статический пустота rtraverse (Файл fd) {		Файл [] fss = fd.listFiles ();		за (int я = 0; я < fss.длина; я++) {			Система.из.println (fss[я]);			если (fss[я].isDirectory () && fss[я].canRead ()) {				rtraverse (fss[я]);			}		}	}}

Этот код, по крайней мере, частично смешивает строки между рекурсией и итерация. По сути, это рекурсивная реализация, которая является лучшим способом пройти через файловая система. Это также пример прямой и косвенной рекурсии. Метод «rtraverse» - чисто прямой пример; метод «traverse» является косвенным, который вызывает «rtraverse». В этом примере не требуется "базовый сценарий", поскольку в данной файловой системе всегда будет какое-то фиксированное количество файлов или каталогов.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

bool tree_contains(структура узел *tree_node, int я) {    если (tree_node == НОЛЬ)        возвращаться ложный;  // базовый вариант    еще если (tree_node->данные == я)        возвращаться истинный;    еще        возвращаться tree_contains(tree_node->оставили, я) ||               tree_contains(tree_node->верно, я);}

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

// Функция-оболочка для обработки пустого дереваbool tree_contains(структура узел *tree_node, int я) {    если (tree_node == НОЛЬ)        возвращаться ложный;  // пустое дерево    еще        возвращаться tree_contains_do(tree_node, я);  // вызов вспомогательной функции}// Предполагается, что tree_node! = NULLbool tree_contains_do(структура узел *tree_node, int я) {    если (tree_node->данные == я)        возвращаться истинный;  // найденный    еще  // рекурсивный        возвращаться (tree_node->оставили  && tree_contains_do(tree_node->оставили,  я)) ||               (tree_node->верно && tree_contains_do(tree_node->верно, я));}

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

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

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

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

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

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

функция рекурсивная (n) if n == base return xоснование    иначе вернуть f (n, рекурсивный (n-1)) 
функция итеративная (n) x = xоснование    for i = base + 1 to n x = f (i, x) return x

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

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

беззнаковый int факториал(беззнаковый int п) {  беззнаковый int товар = 1; // пустой продукт равен 1  пока (п) {    товар *= п;    --п;  }  возвращаться товар;}

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

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

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

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

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

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

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

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

Уязвимость

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

Множественные рекурсивные проблемы

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

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

Рекурсивные алгоритмы можно заменить нерекурсивными аналогами.[21] Одним из способов замены рекурсивных алгоритмов является их моделирование с использованием куча памяти на месте стековая память.[22] Альтернативой является разработка алгоритма замены, полностью основанного на нерекурсивных методах, что может оказаться сложной задачей.[23] Например, рекурсивные алгоритмы для совпадающие подстановочные знаки, Такие как Рич Зальц ' Wildmat алгоритм,[24] когда-то были типичными. Нерекурсивные алгоритмы для той же цели, такие как Алгоритм сопоставления подстановочных знаков Краусса, были разработаны, чтобы избежать недостатков рекурсии[25] и только постепенно улучшались на основе таких методов, как сбор тесты и профилирование спектакль.[26]

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

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

Хвостовая рекурсия:Дополнительная рекурсия:
// ВХОД: целые числа x, y такие, что x> = y и y> = 0int gcd(int Икс, int у){  если (у == 0)     возвращаться Икс;  еще     возвращаться gcd(у, Икс % у);}
// ВХОД: n - целое число, такое что n> = 0int факт(int п){   если (п == 0)      возвращаться 1;   еще      возвращаться п * факт(п - 1);}

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

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

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

Функция 1

пустота рекурсивная функция(int число) {    printf("% d п", число);    если (число < 4)        рекурсивная функция(число + 1);}

Recursive1.svg

Функция 2 с переставленными строками

пустота рекурсивная функция(int число) {    если (число < 4)        рекурсивная функция(число + 1);    printf("% d п", число);}

Recursive2.svg

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

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

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

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

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

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

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

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

Рекомендации

  1. ^ Грэм, Рональд; Кнут, Дональд; Паташник, Орен (1990). «1: повторяющиеся проблемы». Конкретная математика. ISBN  0-201-55802-5.CS1 maint: ref = harv (связь)
  2. ^ Эпп, Сюзанна (1995). Дискретная математика с приложениями (2-е изд.). п.427. ISBN  978-0-53494446-9.CS1 maint: ref = harv (связь)
  3. ^ Вирт, Никлаус (1976). Алгоритмы + Структуры данных = Программы. Prentice-Hall. п.126. ISBN  978-0-13022418-7.CS1 maint: ref = harv (связь)
  4. ^ "Функциональное программирование | Clojure для смелых и честных". www.braveclojure.com. Получено 2020-10-21.
  5. ^ Felleisen et al. 2001 г., искусство V "Генеративная рекурсия
  6. ^ а б Фелляйзен, Маттиас (2002). «Разработка интерактивных веб-программ». В Jeuring, Йохан (ред.). Расширенное функциональное программирование: 4-я международная школа (PDF). Springer. п. 108. ISBN  9783540448334.
  7. ^ Грэм, Кнут и Паташник, 1990, §1.1: Ханойская башня
  8. ^ Эпп 1995, стр. 427–430: Ханойская башня
  9. ^ Эпп 1995, стр. 447–448: Явная формула последовательности Ханойской башни
  10. ^ Вирт 1976, п. 127
  11. ^ Монган, Джон; Жигер, Эрик; Киндлер, Ной (2013). Разоблачены собеседования по программированию: секреты вашей следующей работы (3-е изд.). Wiley. п.115. ISBN  978-1-118-26136-1.
  12. ^ Хетланд, Магнус Ли (2010), Алгоритмы Python: освоение основных алгоритмов на языке Python, Апресс, стр. 79, ISBN  9781430232384.
  13. ^ Дроздек, Адам (2012), Структуры данных и алгоритмы в C ++ (4-е изд.), Cengage Learning, стр. 197, ISBN  9781285415017.
  14. ^ Дрожит, Олин. «Анатомия петли - история возможностей и контроля» (PDF). Технологический институт Джорджии. Получено 2012-09-03.
  15. ^ Лямбда Окончательный. "Анатомия петли". Лямбда Окончательный. Получено 2012-09-03.
  16. ^ «27.1. Sys - Системные параметры и функции - Документация Python v2.7.3». Docs.python.org. Получено 2012-09-03.
  17. ^ Краусс, Кирк Дж. (2014). «Подстановочные знаки соответствия: эмпирический способ приручить алгоритм». Журнал доктора Добба.
  18. ^ Мюллер, Оливер (2012). «Анатомия атаки Stack Smashing и то, как GCC предотвращает ее». Журнал доктора Добба.
  19. ^ «Класс StackOverflowException». Библиотека классов .NET Framework. Сеть разработчиков Microsoft. 2018.
  20. ^ «Поиск в глубину (DFS): итеративная и рекурсивная реализация». Techie Delight. 2018 г.
  21. ^ Митрович, Иван. «Заменить рекурсию итерацией». ThoughtWorks.
  22. ^ Ла, Вунг Гю (2015). «Как заменить рекурсивные функции с помощью стека и цикла while, чтобы избежать переполнения стека». CodeProject.
  23. ^ Moertel, Том (2013). «Профессиональные хитрости: от рекурсии к итерации, часть 2: устранение рекурсии с помощью секретного трюка с перемещением во времени».
  24. ^ Зальц, Рич (1991). "wildmat.c". GitHub.
  25. ^ Краусс, Кирк Дж. (2008). «Подстановочные знаки соответствия: алгоритм». Журнал доктора Добба.
  26. ^ Краусс, Кирк Дж. (2018). «Подстановочные знаки соответствия: улучшенный алгоритм для больших данных». Развивайте производительность.

дальнейшее чтение

внешняя ссылка