stringtranslate.com

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

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

В информатике рекурсия — это метод решения вычислительной задачи , решение которого зависит от решений более мелких экземпляров одной и той же проблемы. [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 ):

данные ListOfStrings = EmptyList | Минусы String ListOfStrings       

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

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

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

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

 < выражение >  ::=  < число > | ( < выражение > * < выражение > ) | ( < выражение > + < выражение > )

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

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

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

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

Поток строк — это такой объект, что: head(s) — это строка, а хвост(ы) — это поток строк.

Это очень похоже на индуктивное определение списков строк; разница в том, что это определение определяет, как получить доступ к содержимому структуры данных, а именно, с помощью функций доступа и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]

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

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

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

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

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

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

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

Короткое замыкание базового варианта

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

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

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

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

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

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

Вместо этого при коротком замыкании это:

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

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

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

booltree_contains ( struct node * tree_node , int i ) { if ( tree_node == NULL ) return false ; _ // базовый случай else if ( tree_node -> data == i ) return true ; иначе верните Tree_contains ( tree_node -> left , i ) || Tree_contains ( tree_node -> вправо , я ); }                           

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

// Функция-обертка для обработки пустого дерева booltree_contains ( struct node * tree_node , int i ) { if ( tree_node == NULL ) return false ; // пустое дерево else return Tree_contains_do ( tree_node , i ); // вызываем вспомогательную функцию }                  // Предполагается , что Tree_node != NULL booltree_contains_do ( struct node * tree_node , int i ) { if ( tree_node -> data == i ) return true ; // найдено еще // рекурсивный возврат ( 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 :

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

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

unsigned int факториал ( unsigned int n ) { unsigned int product = 1 ; // пустой продукт равен 1 while ( n ) { product *= n ; -- н ; } вернуть товар ; }                     

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

Большинство языков программирования , используемых сегодня, допускают прямую спецификацию рекурсивных функций и процедур. Когда вызывается такая функция, среда выполнения программы отслеживает различные экземпляры функции (часто с использованием стека вызовов , хотя могут использоваться и другие методы). Любую рекурсивную функцию можно преобразовать в итеративную функцию, заменив рекурсивные вызовы итеративными конструкциями управления и моделируя стек вызовов стеком, явно управляемым программой. [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».

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

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

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

Функция 1

void recursiveFunction ( int num ) { printf ( "%d \n " , num ); если ( число < 4 ) recursiveFunction ( число + 1 ); }            

Функция 2

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

Выходные данные функции 2 аналогичны выходным данным функции 1 с поменянными местами строками.

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

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

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

Факториал

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

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

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

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

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

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

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

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

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

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

если

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

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

Башни Ханоя

Башни Ханоя

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

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

Рекуррентное соотношение для Ханоя :


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

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

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

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

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

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

 /*  Вызовbinary_search с правильными начальными условиями. ВВОД:  данные — это массив целых чисел, СОРТИРОВАННЫХ в порядке ВОЗРАСТАНИЯ,  toFind — целое число для поиска,  count — общее количество элементов в массиве. ВЫХОД:  результат бинарного_поиска*/ int search ( int * data , int toFind , int count ) { // Начало = 0 (начальный индекс) // Конец = count - 1 (верхний индекс) returnbinary_search ( data , toFind , 0 , count -1 ); }                 /*  Алгоритм двоичного поиска. ВВОД:  данные представляют собой массив целых чисел, отсортированных по возрастанию,  toFind — целое число для поиска,  начало — минимальный индекс массива,  конец — максимальный индекс массива.  ВЫХОД:  позиция целого числа, которое нужно найти в данных массива,  -1, если не найдено */ intbinary_search ( int * data , int toFind , int start , int end ) { // Получаем среднюю точку. int Mid = начало + ( конец - начало ) / 2 ; //Целое деление                     if ( start > end ) //Условие остановки (базовый случай) return -1 ; else if ( data [ mid ] == toFind ) //Найдено, возвращаем индекс return Mid ; else if ( data [ mid ] > toFind ) //Данные больше, чем toFind, поиск в нижней половине returnbinary_search ( data , toFind , start , middle -1 ) ; else //Данные меньше, чем toFind, поиск в верхней половине returnbinary_search ( data , toFind , middle + 1 , end ) ; }                                 

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

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

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

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

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

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

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

структура узла { интервал данных ; // некоторая целочисленная структура данных node * next ; // указатель на другой узел структуры };        

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

void list_print ( struct node * list ) { if ( list != NULL ) // базовый случай { printf ( "%d" , list -> data ); // печатаем целочисленные данные, за которыми следует пробел list_print ( list -> next ); // рекурсивный вызов следующего узла } }                 

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

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

структура узла { интервал данных ; // некоторая целочисленная структура данных node * left ; // указатель на левый узел структуры поддерева * right ; // указываем на правое поддерево };            

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

// Проверяем, содержит ли Tree_node i; верните 1, если да, 0, если нет. inttree_contains ( struct node * tree_node , int i ) { if ( tree_node == NULL ) return 0 ; _ // базовый случай else if ( tree_node -> data == i ) return 1 ; иначе верните Tree_contains ( tree_node -> left , i ) || Tree_contains ( tree_node -> вправо , я ); }                           

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

// Обход по порядку: void Tree_print ( struct node * tree_node ) { if ( tree_node != NULL ) { // базовый случай Tree_print ( tree_node -> left ); // идем влево printf ( "%d" , tree_node -> data ); // печатаем целое число, за которым следует пробел Tree_print ( tree_node -> right ); // Иди направо } }                  

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

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

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

импортировать java.io.File ; общественный класс FileSystem {   public static void main ( String [] args ) {      траверс ();}/** * Получает корни файловой системы * Приступает к рекурсивному обходу файловой системы. */частный статический недействительный траверс () {    Файл [] фс = Файл . списокКорни ();   для ( int я знак равно 0 ; я < fs . length ; я ++ ) {         Система . вне . println ( фс [ я ] );if ( fs [ i ] . isDirectory () && fs [ i ] . canRead ()) {    rtraverse ( fs [ i ] );}}}/** * Рекурсивно перемещаться по заданному каталогу * * @param fd указывает начальную точку обхода */Private static void rtraverse ( Файл fd ) {     Файл [] fss = fd . СписокФайлов ();   for ( int я знак равно 0 ; я < fss . length ; я ++ ) {         Система . вне . println ( фсс [ я ] );if ( fss [ i ] . isDirectory () && fss [ i ] . canRead ()) {    rtraverse ( fss [ i ] );}}}}

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

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

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

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

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

Правило сокращения (основная теорема)

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

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

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

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

Примечания

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

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