Решение задач на использование рекурсивных алгоритмов
Цель лекции: изучить рекурсивные алгоритмы и основные схемы решения задач рекурсивными способами, научиться применять рекурсивные алгоритмы при решении задач на языке C++.
В построении алгоритмов решения задачи важным является формирование общих подходов к выбору способа решения. Универсального метода, гарантирующего верный и оптимальный алгоритм решения для любой задачи, не существует. В частности, сведение решения к выбору итерационного или рекурсивного способа построения алгоритма зависит от результатов анализа постановки задачи. Поэтому необходимо иметь представление о возможных направлениях анализа решаемой задачи.
Разбиение задачи на подзадачи. Метод процедурной абстракции, положенный в основу процедурного программирования, предполагает выделение в задаче отдельных модулей, в дальнейшем реализуемых посредством функций. В процессе анализа задачи возможны случаи:
- разбиение условий задачи на части;
- разбиение требований задачи на части;
- разбиение области определения задачи на части.
Преобразования задачи. Последовательные преобразования решаемой задачи в цепочку эквивалентных задач сводятся к получению задачи, решение которой может быть получено более простым способом или уже известно. При этом эквивалентность задач понимается как совпадение их множеств решений, а преобразование должно не менять языка, ее записи. В противном случае это уже будет не преобразование, а моделирование.
Моделирование. В процессе работы над условием происходит замена исходной задачи ее моделью: текстовая задача переводится в уравнение, систему уравнений или неравенств. При этом проводится детальное исследование возможных ошибок или погрешностей метода, учет которых входит в алгоритмизацию задачи.
Введение вспомогательных элементов. В постановке задачи не всегда явно указывается набор данных, которые оказывают влияние на получение результата. Например, решение квадратного уравнения на множестве действительных чисел сводится к вычислению и анализу значения дискриминанта, о котором в постановке задачи ничего не сказано. Выделим следующие случаи:
- введение недостающих по смыслу задачи элементов между данными и искомыми элементами (дополнительное построение на чертеже, новые переменные для составления уравнений и т.п.);
- преднамеренное погружение задачи в большую размерность, то есть введение дополнительных параметров, не связанных с существом задачи.
С учетом вышеизложенного рассмотрим существующие подходы к выбору рекурсии как метода решения задач, то есть выделим основные опорные схемы рекурсивных вычислений:
- "Увидеть";
- "Переформулировать";
- "Обобщить";
- "Использовать характеристическое свойство";
- "Перенести часть условий в проверку";
- "Обратить функцию";
- "Найти родственника".
Опорные схемы по своей сути не являются реальной классификацией методов решения задач с использованием рекурсии. Одна и та же задача, исследуемая с опорой на разные схемы, может приводить к одному и тому же рекурсивному алгоритму. Более того, иногда достаточно трудно однозначно утверждать, что при решении задачи применялась именно конкретная схема. Однако опорные схемы определяют подходы к анализу условия задачи, опираясь на которые можно выработать метод ее решения.
Опорная схема "Увидеть"
Данная опорная схема является наиболее естественной, так как содержится в постановке задачи. Для разработки триады достаточно использовать параметры, тривиальный случай и соотношения, непосредственно вытекающие из условия.
Опорная схема "Переформулировать"
Часто в условии задачи не только не обозначена рекурсия, но и сама задача не является алгоритмически сформулированной. Иногда ее простая перефразировка, а чаще построение математической модели позволяют обнаружить первоначально скрытую рекурсию.
Рассмотрим задачу о динамике вклада. Большой выбор простых содержательных задач, допускающих рекурсивное решение, можно встретить в сфере банковской деятельности. Рассмотрим несколько различных рекурсивных вариантов решения задачи о динамике вклада.
Вкладчик положил в банк сумму в sum денежных единиц под p процентов за один период времени. Составим функцию, возвращающую величину вклада по истечении n периодов времени.
Вычисление значения величины вклада можно проводить по известной формуле сложных процентов: . Но рассмотрим рекурсивный вариант алгоритма решения задачи.
Параметризация: выбор параметров следует непосредственно из условия задачи, то есть sum – первоначальный размер положенной суммы, – процент вклада, n – количество периодов хранения вклада.
База рекурсии: для n=0 размер суммы не изменится, то есть останется sum.
Декомпозиция: если n>0, то размер вклада вычисляется как сумма за n-1 периодов, увеличенная на процент p.
float Deposit(float sum, float p, int n){ if(n==0) return sum; //база рекурсии return Deposit(sum,p,n-1)*(1+p/100); //декомпозиция }
Общее количество рекурсивных вызовов при вычислении Deposit(sum, p, n) равно n. Можно уменьшить это значение до величины порядка O(log2n+1) исходя из следующих двух декомпозиционных посылок, описывающих случаи четного и нечетного n.
float DepositNew(float sum, float p, int n){ if (n==0) return sum ;// база рекурсии if (n%2==0) //декомпозиция для четного n return sum*pow(DepositNew(1.0,p,n/2),2); //декомпозиция для нечетного n return sum*(1+p/100)*DepositNew(1.0,p,n-1); }
Опорная схема "Обобщить"
Если из постановки задачи рекурсию извлечь не удается, то за счет перехода к ее некоторому обобщению иногда это сделать возможно. Как правило, это обобщение протекает за счет введения дополнительных параметров, то есть намеренного погружения исходной задачи в пространство большей размерности, чем это обусловлено ее основными параметрами. Поэтому данную опорную схему иногда называют "Погрузить" или "Вложить". Использование рассматриваемой схемы предполагает, что из решения обобщенной задачи может быть получено решение исходной задачи. В некоторых случаях данная схема может быть использована для улучшения быстродействия алгоритма или для перехода от одного типа рекурсии к другому. При этом "Обобщение" является наиболее общей и часто используемой схемой при решении многих задач рекурсивными алгоритмами.
Стоит отметить еще одно обстоятельство, связанное с данной схемой. Имея свободу выбора обобщения исходной задачи, мы, тем не менее, ограничены жесткими рамками, регламентирующими этот выбор: решение (доказательство) обобщения должно быть по возможности простым и из него должно легко выделяться решение исходной задачи.
Рассмотрим задачу под названием "Абракадабра". Последовательность из латинских букв строится следующим образом. На нулевом шаге она пуста. На каждом последующем шаге последовательность удваивается, то есть приписывается сама к себе, и к ней слева добавляется очередная буква алфавита (a, b, c, ...). По заданному числу n определить символ, который стоит на n -м месте последовательности, получившейся после шага 26.
Приведем первые шаги формирования последовательности: 0 ? пустая последовательность, 1 - "a", 2 - "baa", 3 - "cbaabaa", 4 - "dcbaabaacbaabaa" и так далее по закономерности. Данный процесс носит рекурсивный характер.
Параметризация. Построим более общую функцию, чем это требуется по условиям задачи. Пусть значение функции Abra(k,n) - n -я буква в последовательности, полученной на шаге k (k = 1, ..., 26). Будем возвращать значение функции в виде целочисленного кода, соответствующего требуемому символу.
База рекурсии. Значение Abra(k,1) равно k -й букве латинского алфавита. Этот факт можно взять в качестве базы рекурсии.
Декомпозицию удобно организовать по k, проводя "раскрутку" последовательности по шагам в обратном направлении. Это приводит к следующей зависимости:
- если n<=2k-1, то искомый символ находится на (n-1) месте в латинском алфавите;
- если n>2k-1, то искомый символ находится на (2k-1) месте в латинском алфавите.
int Abra(int k, int n){ if (n > pow(2, k-1)-1 || k > 26) return 0; //корректность входных данных if (n == 1) return k+96; //база рекурсии return Abra(k-1, n-(n <= pow(2, k-1) ? 1 : pow(2, k-1))); //декомпозиция }