Рекурсивные алгоритмы и функции
Теоретическая часть
Рекурсивные функции (лат. recursio – возвращение) – в вычислительной математике – функции, определенные на множестве натуральных чисел и принимающие значения того же множества [18.1].
Рекурсивный алгоритм – это алгоритм, решающий задачу путем решения одного или нескольких более узких вариантов той же задачи [18.2]. Функция называется рекурсивной, если в ее определении содержится вызов этой же функции [18.3]. Рекурсивная функция может вызывать саму се6я или непосредственно, или косвенно через другую функцию [18.4]. Рекурсии целесообразно применять в задачах, которые можно разбить на множество меньших подобных задач [18.5]. Рекурсия в программировании может быть определена как сведение задачи к такой же задаче, но манипулирующей с боле простыми данными.
Рекурсивную программу всегда можно преобразовать в итеративную программу, использующую циклы, которая выполняет те же вычисления. И наоборот, используя рекурсию, можно реализовать, не прибегая к циклам.
Рекурсивный подход обычно предпочитается итеративному подходу в тех случаях, когда рекурсия более естественно отражает математическую сторону задачи и приводит к программе, которая проще для понимания и отладки. Другой причиной для выбора рекурсивного решения является то, что итеративное решение может не быть очевидным [18.4].
Наличие в задаче рекуррентного соотношения позволяет использовать рекурсию. Например, арифметическая прогрессия – последовательность чисел, в которой разность между последующими и предыдущими членами остается неизменной и называется разностью прогрессии [18.1]. То есть каждый следующий член прогрессии равен предыдущему, увеличенному на разность прогрессии.
Различают прямую и косвенную рекурсию. Прямой (непосредственной) рекурсией является вызов функции внутри тела этой функции. Косвенной рекурсией является рекурсия, осуществляющая рекурсивный вызов функции посредством цепочки вызова других функций [18.6]. Все функции, входящие в цепочку, тоже считаются рекурсивными.
В рекурсии простейшей формы рекурсивный вызов расположен в конце функции, непосредственно перед оператором возврата из функции (или возвращаемого значения). Такая рекурсия называется хвостовой или концевой. Хвостовая рекурсия является простейшей формой рекурсии, поскольку она действует подобно циклу [18.7]. Если в программе имеется хвостовая рекурсия, то ее лучше преобразовать к итерации.
Отметим особенности работы рекурсивных функций, характерных для тех языков программирования, которые поддерживают рекурсию. К этим языкам относится и язык С.
Когда функция вызывает сама себя (когда имеем дело с рекурсивной функцией), новый набор локальных переменных и параметров размещается в памяти в стеке, а код функции выполняется с самого своего начала, причем используются именно эти новые переменные. При рекурсивном вызове функции новая копия ее кода не создается. Новыми являются только значения, которые использует данная функция. При каждом возвращении из рекурсивного вызова старые локальные переменные и параметры извлекаются из стека, и сразу за рекурсивным вызовом возобновляется работа функции [18.8]. Выполнение функции возобновляется с "внутренней" точки ее вызова.
Общая схема определения рекурсивной функции
- Существует условие, при котором функция выполняет свою задачу с использованием рекурсивных вызовов, соответствующих одной или нескольких "уменьшенным" версиям задачи.
- Существует также условие, которое будет удовлетворено и при котором функция выполняет свою задачу без рекурсивных вызовов. Это условие называется базовым или условием останова [18.2,18.4-18.5-18.6].
Рассмотрим некоторые определения, относящиеся к рекурсии. Максимальное число рекурсивных вызовов без возвратов, которое происходит во время выполнения программы, называется глубиной рекурсии. Число рекурсивных вызовов в каждый конкретный момент времени, называется текущим уровнем рекурсии. В практических приложениях важно убедиться, что максимальная глубина рекурсий не только конечна, но и достаточно мала [18.11].
Существует три разных формы рекурсивных программ:
- Форма с выполнением действий до рекурсивного вызова (с выполнением действий на рекурсивном спуске).
- Форма с выполнением действий после рекурсивного вызова (с выполнением действий на рекурсивном возврате).
- Форма с выполнением действий как до, так и после рекурсивного вызова (с выполнением действий, как на рекурсивном спуске, так и на рекурсивном возврате).
Понятие рекурсии сходно с понятием математической индукции. У рекурсии, как и у математической индукции, есть база – аргументы, для которых значения функции определены (элементарные задачи), и шаг рекурсии – способ сведения задачи к более простым задачам.
Рекурсия обладает своими преимуществами и недостатками. Одно из преимуществ рекурсии состоит в том, что она предлагает простейшее решение некоторых задач программирования. Один из недостатков рекурсии заключается в том, что некоторые рекурсивные алгоритмы могут довольно-таки быстро исчерпать ресурс памяти компьютера. Наряду с этим рекурсию трудно документировать и поддерживать [18.7].
Главное при оформлении рекурсивной функции – это правильное задание условия выхода из рекурсивных вызовов. Если при зацикливании программы при операторе цикла компьютер может выполнять такой цикл, не реагируя на любые действия пользователя, то при зацикливании из-за неправильного оформления рекурсивной функции неизбежно происходит аварийный останов, когда будет исчерпан объем оперативной памяти, которая выделяется при каждом рекурсивном вызове. При этом следует всегда помнить, что даже при правильном оформлении рекурсивной функции, необходимо учитывать объем вычислительной работы. Например, расчет факториала целого положительного числа – трудоемкая операция. В математической постановке задачи нет ничего сложного, но в случае программирования этого процесса для различных систем и компьютеров имеются свои ограничения. Например, это касается рекурсивного вычисления факториала.
В то же время для некоторых задач рекурсивные функции вполне оправданы. В частности динамические информационные структуры включают рекурсивность в само определение обрабатываемых данных. Именно для таких данных применение рекурсивных алгоритмов не имеет конкуренции со стороны итерационных методов [18.6].
Рекурсивная задача в общем случае разбивается на ряд этапов. Для решения задачи вызывается рекурсивная функция. Эта функция знает, как решать только простейшую часть задачи – так называемую базовую задачу (или несколько таких задач). Если эта функция вызывается для решения базовой задачи, она просто возвращает результат. Если функция вызывается для решения более сложной задачи, она делит эту задачу на две части: одну часть, которую функция умеет решать, и другую, которую функция решать не умеет. Чтобы сделать рекурсию выполнимой, последняя часть должна быть похожа на исходную задачу, но быть по сравнению с ней несколько проще или несколько меньше. Поскольку эта новая задача подобна исходной, функция вызывает новую копию самой себя, чтобы начать работать над меньшей проблемой – это называется рекурсивным вызовом, или шагом рекурсии. Шаг рекурсии включает ключевое слово return, так как в дальнейшем его результат будет объединен с той частью задачи, которую функция умеет решать, и сформируется конечный результат, который будет передан обратно в исходное место вызова.
Рассмотрим пример вычисления факториала целого положительного числа, когда есть, и нет хвостовой рекурсии. При этом покажем возможность исключения рекурсии. Пусть определена следующая рекурсивная функция:
int fact (int n) { if (n == 0) return 1; else return n * fact (n - 1); }
Эта исходная функция fact() не имеет хвостовой рекурсии т. к. выполняется перемножение после рекурсивного вызова (умножение на n ). Для исключения рекурсии сначала приведем fact () к виду с хвостовой рекурсией, например
int fact (int n) { return fact_w (1,n); }
Вспомогательная функция fact_w () будет содержать хвостовую рекурсию. Ее программный код:
int fact_w (int r, int n) { if (n == 0) return r; else return fact(r*n,n-1); }
В функции fact_w() хвостовая рекурсия присутствует в силу рекурсивного обращения только к самой функции в операторе return.
Обратимся к известному положению об исключении хвостовой рекурсии: если функция f(x) содержит в конце рекурсивный вызов в виде return f(y), то рекурсию можно исключить путем присваивания x = y, и перехода goto на начало функции [18.14]. Программный код исключения хвостовой рекурсии:
int fact_w (int r, int n) { met: if (n == 0) return r; else { r = r*n; n = n-1; goto met; } }
Использование оператора goto нежелательно с точки зрения современных представлений и методов структурного программирования, преобразуем конструкцию с goto к циклу:
int fact_w (int r, int n) { while (n != 0) { r = r*n; n = n-1; } return r; }
В качестве окончательной оптимизации можно исключить и вспомогательную функцию fact_w (), выполнив подстановку тела функции в точку ее вызова (inlining). В итоге получим следующий программный код функции, вычисляющей факториал числа:
/* * Оптимизация функции * вычисления факториала числа */ int fact (int n) { int r = 1; while (n != 0) { r = r*n; n = n-1; } return r; }
В приведенной функции нет ни рекурсивных вызовов, ни оператора goto.
Однако во многих случаях использование рекурсивных функций оправдано, хотя бы с точки зрения формирования особого мышления, свойственного профессиональным программистам. И как было отмечено, некоторые динамические информационные структуры легче реализуются с помощью рекурсии.
Каждая функция в программе на языке С находится на одном уровне с остальными функциями, составляющие программный проект. Каждая из них может вызвать любую другую функцию или быть вызванной другими функциями. По этой причине функция main() может вызывать сама себя в рамках некоторой рекурсии или быть вызванной из других функций, хотя подобное встречается достаточно редко [18.7]. Следует только иметь в виду, что когда программа составляется из нескольких функций, выполнение этой программы начинается с первого оператора функции main().
В практической части данной лабораторной работы будут рассмотрены примеры программ для решения типовых задач, позволяющих использовать рекурсию.
Практическая часть
Пример 1. Напишите рекурсивную функцию определения наибольшего общего делителя двух целых чисел.
Программный код решения примера:
#include <stdio.h> #include <conio.h> #include <stdlib.h> // Прототип рекурсивной функции int gcd(int a, int b); int main (void) { int a = 0, b = 0; int in; // Проверка ввода двух целых чисел do { printf("\n Enter the two different natural numbers, through the gap: "); in = scanf_s("%d%d", &a, &b); if (in != 2) { printf("\n Error input. Press any key to exit: "); _getch(); exit(1); } if ( (a != b) && (b != 0) ) break; if (b == 0) a = b; } while ( (a == b) ); // Вывод результата на консоль printf("\n a = %d, b = %d, GCD = %d; \n", a, b, gcd(a,b)); printf("\n\n Press any key: "); _getch(); return 0; } // Определение рекурсивной функции int gcd(int a, int b) { if ( (a % b) == 0) return b; else return gcd(b, a % b); }
Решение примера выполнено на основе простой хвостовой рекурсии, поскольку значения вызовов функции самой себя gcd(b, a%b) возвращаются оператором return. В программе используется оператор % – операция остатка от деления двух целых чисел (целочисленное деление). Известно, что если даны два числа А и В, то максимальный остаток от деления числа А на число В будет на единицу меньше числа В. В определении рекурсивной функции gcd() условием останова является то, что остаток от деления двух данных чисел равен нулю. Рекурсивные вызовы функции gcd() связаны с изменением расположения первоначально заданных аргументов, когда аргумент, стоящий на втором месте ( b ), определяется на первом месте, а на втором месте определяется операция остатка от деления, т. е. a%b . И это происходит до тех пор, пока остаток от деления не станет равным нул ю, т. е. будет выполнено условие останова (базовое условие).
Возможный результат выполнения программы приведен на рис. 18.1.