Сортировка с помощью разделения




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

В Quicksort исходят из того соображения, что для достижения наилучшей эффективности сначала лучше производить перестановки на большие расстояния. Предположим, у нас есть n элементов, расположенных по ключам в обратном порядке. Их можно отсортировать за n /2 обменов, сначала поменять местами самый левый с самым правым, а затем последовательно двигаться с двух сторон. Конечно, это возможно только в том случае, когда мы знаем, что порядок действительно обратный. Но из этого примера можно извлечь и нечто действительно поучительное.

Давайте попытаемся воспользоваться таким алгоритмом: выберем наугад какой-либо элемент (назовем его x) и будем просматривать слева направо массив до тех пор, пока не обнаружим элемент a i > x, затем просмотрим его справа налево, пока не найдем элемент a j < x. Теперь поменяем местами эти два элемента и продолжим процесс «просмотра с обменом», пока два просмотра не встретятся где-то в середине массива. В результате массив разделится на две части: левую – с ключами, меньшими, чем x, и правую – с ключами большими х. Теперь этот процесс разделения представим в виде процедуры (программа 2.4).

 

Procedure partition;

Var w,x: item;

Begin i:=1; j:=n;

Случайно выбрать x;

Repeat

While a[i].key < x.key do i:=i + 1;

While x.key < a[j].key do j:=j - 1;

If i <= j then

Begin w:=a[i]; a[i]:=a[j]; a[j]:=w;

i:=i+1; j:=j-1

End

until i > j

end;

Программа 2.4. Сортировка с помощью разделения.

 

Обратите внимание, что вместо отношений > и < используются ≥ и ≤, а в заголовке цикла с WHILE — их отрицания < и >. При таких изменениях x выступает в роли барьера для того и другого просмотра. Если взять в качестве x для сравнения сред­ний ключ 42, то в массиве ключей

44 55 12 42 94 06 18 67

для разделения понадобятся два обмена: 18 ó 44 и 6 ó 55

 

18 06 12 42 94 55 44 67

Последние значения индексов таковы: i = 5, a j = 3. Ключи a 1... a i-1 меньше или равны ключу х = 42, а ключи a j+1... а n больше или равны х. Следовательно, существует две части, а именно:

a k. key <= x. key для: k = 1, …, i -1,

(*)

a k. key >= x. key для: k = j +1, …, n.

И, следовательно,

a k. key = x. key k = j +1, …, I -1

Описанный алгоритм очень прост и эффективен, поскольку главные сравниваемые величины i, j и x можно хранить во время просмотра в быстрых регистрах машины. Однако он может ока­заться и неудачным, что, например, происходит в случае n иден­тичных ключей: для разделения нужно n /2 обменов. Этих вовсе необязательных обменов можно избежать, если операторы про­смотра заменить на такие:

While a [ i ]. key <= x.key do i: = i+1;

While x.key <= a[ j ]. key do j: = j -1

Однако в этом случае выбранный элемент x, находящийся среди компонент массива, уже не работает как барьер для двух разнонаправленных просмот­ров. В результате просмотры массива со всеми идентичными клю­чами приведут, если только не использовать более сложные усло­вия их окончания, к переходу через границы массива. Простота условий, употребленных в программе 2.4, вполне оправдывает те дополнительные обмены, которые происходят в среднем относи­тельно редко. Можно еще немного сэкономить, если изменить за­головок, управляющий самим обменом: от i <= j перейти к i < j. Однако это изменение не должно касаться двух операторов:

i: = i + 1; j: = j - 1.

Поэтому для них потребуется отдельный условный оператор. Необходимость условия i <= j можно проиллюстрировать следующим примером при x = 2:

1 1 1 2 1 1 1

Первый просмотр и обмен дают

1 1 1 1 1 1 2,

причем i = 5, j = 6. Второй просмотр не изменяет массив и заканчивается с i = 7 и j = 6. Если бы обмен не подчинялся условию i <= j, то произошел бы ошибочный обмен a 6 и a 7.

Убедиться в правильности алгоритма разделения мож­но, удостоверившись, что отношения (*) представляют собой инварианты оператора цикла с Repeat. Вначале при i = 1 и j = n их истинность тривиальна, а при выходе с i > j они дают как раз желаемый результат.

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

procedure QuickSort;

procedure sort(l,r:index);

var i,j:index; x,w:item;

Begin

i:=l; j:=r;

x:=a[(l+r) div 2];

Repeat

while a[i].key<x.key do i:=i+1;

while x.key<a[j].key do j:=j-1;

if i<=j then

begin w:=a[i]; a[i]:=a[j]; a[j]:=w;

i:=i+1; j:=j-1

End

until i>j;

if l<j then sort(l,j);

if l<r then sort(i,r)

end;

Begin

sort (1,n)

end;

Программа 2.5. Быстрая сортировка (Quicksort)

Анализ Quicksort. В благоприятных ситуациях каждый процесс разделения расщепляет массив на две половины и для сортировки требуется всего log n проходов. В результате общее число сравнений равно n*log n, а общее число обменов n* log(n)/6. Удивительный, однако, факт: средняя производительность Quicksort при случайном выборе границы отличается от упомянутого оптимального варианта лишь коэффициентом 2*ln (2).

Как бы то ни было, но Quicksort присущи и некоторые недостатки. Главный из них — недостаточно высокая производительность при небольших n, впрочем, этим грешат все усовершенствованные методы. Однако данный метод имеет то преимущество, что для обработки небольших частей в него можно легко включить какой-либо из прямых методов сортировки. Это особенно удобно делать в случае рекурсивной версии программы.

Остается все еще вопрос о самом плохом случае. Как тогда будет работать Quicksort? К несчастью, ответ неутешителен и демонстрирует одно неприятное свойство Quicksort. Разберем, скажем, тот несчастный случай, когда каждый раз для сравнения выбирается наибольшее из всех значений в указанной части. Тог­да на каждом этапе сегмент из n элементов будет расщепляться на левую часть, состоящую из n -1 элементов, и правую, состоя­щую из одного-единственного элемента. В результате потребует­ся n (а не log n) разделений и наихудшая производительность ме­тода будет порядка n 2.

Явно видно, что главное заключается в выборе элемента для сравнения - х. В нашей редакции им становится средний элемент. Заметим, однако, что почти с тем же успехом можно выбирать первый или последний. В этих случаях хуже всего будет, если массив окажется первоначально, уже упорядочен, ведь Quicksort определенно "не любит" такую тривиальную работу и предпочи­тает иметь дело с неупорядоченными массивами. Выбирая сред­ний элемент, мы как бы затушевываем эту странную особенность Quicksort, поскольку в этом случае первоначально упорядочен­ный массив будет уже оптимальным вариантом. Таким образом, фактически средняя производительность при выборе среднего элемента чуточку улучшается. Сам Хоар предполагает, что x надо выбирать случайно. Такой разумный выбор мало влияет на среднюю производительность Quicksort, но зато значительно ее улучшает (в наихудших случаях). В некото­ром смысле быстрая сортировка напоминает азартную игру: все­гда следует учитывать, сколько можно проиграть в случае неве­зения.

Заканчивая наш обзор усовершенствованных методов сортировки, мы попытаемся срав­нить их эффективность. Как и раньше, n — число сортируемых элементов, а С и М — соответственно число необходимых сравне­ний ключей и число обменов. Для всех прямых методов сорти­ровки можно дать точные аналитические формулы. Для усовершенствованных методов нет сколько-либо осмыслен­ных, простых и точных формул. Существенно, однако, что в слу­чае сортировки Шелла вычислительные затраты составляют с*n 1.2, а для HeapSort и Quicksort — с* n* log n, где с — соответствую­щие коэффициенты.

Эти формулы просто вводят грубую меру для производитель­ности как функции n и позволяют разбить алгоритмы сортиров­ки на примитивные, прямые методы (n 2) и на усложненные или "логарифмические" методы (n * log(n)).

Рассмотрев, несколько различных усложненных методов сортировки массивов нетрудно заметить главный недостаток – недостаточно высокая производительность при небольших n. Действительно эти сортировки не рекомендуется применять для небольшого числа элементов (для этого существуют простые методы сортировки). Хотя и это можно исправить, путем включения простых методов сортировки для небольших n.

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

 

Лекция № 3.

Тема: С ортировка последовательных файлов

Основные вопросы, рассматриваемые на лекции:

  1. Особенности сортировки последовательных файлов.
  2. Прямое слияние.

 

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

1. Последовательность а разбивается на две половины b и с.

2. Последовательности b и c сливаются при помощи объединения отдельных элементов в упорядоченные пары.

3. Полученной последовательности присваивается имя a, и повторяются шаги 1 и 2; на этот раз упорядоченные пары сливаются в упорядоченные четверки.

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

 

В качестве примера рассмотрим последовательность

44 55 12 42 94 18 06 67

На первом шаге разбиение дает последовательности

44 55 12 42

94 18 06 67

Слияние отдельных компонент (которые являются упорядо­ченными последовательностями длины 1) в упорядоченные пары дает

44 94 ' 18 55 ' 06 12 ' 42 67

Повое разбиение пополам и слияние упорядоченных пар дают

06 12 44 94 ' 18 42 55 67

Третье разбиение и слияние приводят, наконец, к нужному результату:

 

06 12 18 42 44 55 67 94

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

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

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

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

 

 


Рис. 3.1. Сортировка двух массивов методом слияния

 

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

a: array [l..2* n ] of item

Пусть индексы i и j указывают два исходных элемента, тогда как k и l обозначают два места пересылки (см. рис. 3.1). Исходные данные — это, разумеется, элементы а 1,..., а n. Очевидно, что нужна булевская переменная up для указания направления пересылки данных; up = true будет означать, что на текущем проходе компоненты а 1,..., а n будут пере­сылаться «вверх» — в переменные аn+ 1,..., а2 n, тогда как upfalse будет указывать, что аn+ 1,..., а2 n должны пере­писываться «вниз» —в а 1,..., а n. Значение up строго чере­дуется между двумя последовательными проходами. И нако­нец, вводится переменная р для обозначения длины сливае­мых подпоследовательностей (р -наборов). Ее начальное зна­чение равно 1, и оно удваивается перед каждым очередным проходом. Для простоты мы будем считать, что n — всегда степень двойки. Итак, первая версия программы простого слияния имеет такой вид:

procedure mergesort;

var i,j,k,l: index;

up: Boolean; p: integer;

begin up:== true; p: = 1;

repeat { инициация индексов}

if up then

begin i:= 1; j:= n; k:= n + 1; l:= 2* n
end else

begin k:=1; l:= n; i:= n + 1; j:= 2* n end;

«слияние р-наборов последовательностей i и j

в последовательности k и l »;

:= - up; p:= 2

until р = п

End

 

На следующем этапе мы уточняем действие, описанное на естественном языке (внутри кавычек). Ясно, что этот проход, обрабатывающий n элементов, состоит из последовательных слияний р -наборов. После каждого отдельного слияния на­правление пересылки переключается из нижнего в верхний конец выходного массива или наоборот, чтобы обеспечить одинаковое распределение в обоих направлениях. Если сли­ваемые элементы посылаются в нижний конец массива, то индексом пересылки служит k и k увеличивается на 1 после каждой пересылки элемента. Если же они пересылаются в верхний конец массива, то индексом пересылки является l и l после каждой пересылки уменьшается на 1. Чтобы упро­стить операцию слияния, мы будем считать, что место пере­сылки всегда обозначается через k, и будем менять местами значения k и l после слияния каждого р -набора, а прираще­ние индекса обозначим через h, где h равно либо 1, либо -1. Уточнив таким образом «конструкцию», мы получаем

h:= 1; m:= n; { m-номера сливаемых элементов }

repeat q: = р; r: = р; т:= т-2*р;

«слияние q элементов из i и r элементов из j,

индекс засылки есть k с приращением h »;

h:= - h;

обмен значениями k и l

until m = 0

 

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

while (q<>0) and (r<>0) do

begin {выбор элемента из i uли j}

if a[i].key < a[j].key then

begin «пересылка элемента из i в k,

увеличение i и k»; q:= q -1

End else

begin «пересылка элемента из j в k,

увеличение j и k »; r:=r - 1

End

end;

«копирование остатка последовательности i »;

«копирование остатка последовательности j »

 

После уточнения операций копирования остатков про­грамма будет ясна во всех деталях. Перед тем как записать ее полностью, мы хотим устранить ограничение, в соответ­ствии с которым n должно быть степенью двойки. На какую часть алгоритма это повлияет? Легко убедиться в том, что в более общей ситуации лучше всего использовать прежний метод до тех пор, пока это возможно. В данном случае это означает, что мы продолжаем слияние р-наборов, пока длина остатков входных последовательностей не станет меньше p. Это влияет только на ту часть, где определяются значения q и r — длины последовательностей, которые предстоит слить. Вместо трех операторов

q:= р; r:= p; m:= m - 2

 

используются следующие четыре оператора, и, как может убедиться читатель, здесь эффективно применяется описанная выше стратегия; заметим, что m обозначает общее число эле­ментов в двух входных последовательностях, которые оста­лось слить:

if т >= р then q:= p else q:= m; m:= m - q;

if т >= р then r:= p else r:= m; m:= m - r;

 

 

И наконец, чтобы обеспечить окончание работы про­граммы, нужно заменить условие р = n, управляющее внеш­ним циклом, на р >= n. После этих модификаций мы можем попытаться описать весь алгоритм в виде законченной про­граммы (см. программу 3.1).

 

procedure mergesort;

var i,j,k,l,t: index;

h,m,p,q,r:integer; up:boolean;

begin up:=true; p:=1;

repeat h:=1; m:=n;

if up then

begin i:=1; j:=n; k:=n+1; l:=2*n;

end else

begin k:=1; l:=n; i:=n+1; j:=2*n

end;

repeat

if m>=p then q:=p else q:=m; m:=m-q;

if m>=p then r:=p else r:=m; m:=m-r;

while (q<>0) and (r<>0) do

begin

if a[i].key < a[j]. key then

begin a[k]:=a[i]; k:=k+h; i:=i+1; q:=q-1;

end else

begin a[k]:=a[j]; k:=k+h; j:=j-1; r:=r-1

end

end;

while r<>0 do

begin a[k]:=a[j]; k:=k+h; j:=j-1; r:=r-1

end;

while q<>0 do

begin a[k]:=a[i]; k:=k+h; i:=i+1; q:=q-1

end;

h:=-h; t:=k; k:=l; l:=t

until m=0;

up:= not up; p:=2*p

until p>=n;

if not up then

for i:=1 to n do a[i]:=a[i+n]

end;

 

Программа 3.1.

 

 

Анализ сортировки слиянием. Поскольку на каждом про­ходе р удваивается и сортировка заканчивается, как только р >= n, она требует [log2 n ] проходов. По определению при каждом проходе все множество из n элементов копируется ровно один раз. Следовательно, общее число пересылок равно

М = п [log2 n ]

Число С сравнений по ключу еще меньше, чем М, так как при копировании остатка последовательности сравнения не производятся. Но, поскольку сортировка слиянием обычно применяется при работе с внешними запоминающими устрой­ствами, стоимость операций пересылки часто на несколько порядков превышает стоимость сравнений. Поэтому подроб­ный анализ числа сравнений не представляет особого прак­тического интереса.

Алгоритм сортировки слиянием выдерживает сравнение даже с усовершенствованными методами сортировки, которые обсуждались в предыдущей лекции. Но затраты на управле­ние индексами довольно высоки, кроме того, существенным недостатком является использование памяти размером 2 n элементов. Поэтому сортировка слиянием редко приме­няется при работе с массивами, т. е. данными, расположен­ными в оперативной памяти.

 

Лекция № 4.

Тема: Введение в рекурсию. Когда не нужно использовать рекурсию.

Основные вопросы, рассматриваемые на лекции:

  1. Рекурсия, терминология.
  2. Примеры задач, когда не нужно использовать рекурсию.

Содержание лекционного материала

 

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

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

Тут нужно задуматься – что значит, функция вызывает сама себя? Обычно конструкции языков программирования можно более или менее представить и проиллюстрировать примерами из реальной жизни. А как же представить наглядно функцию, вызывающую самою себя? Лучшей моделью, на мой взгляд, служит английский стишок «Дом, который построил Джек», а еще лучшей – пародия на него, стишок одной команды КВН из книжки «КВН раскрывает секреты» вышедшей в 1967 году (М. Молодая гвардия).

Вот стенд, который построил студент,
А вот космическая частица,
Которая с бешеной скоростью мчится
В стенде, который построил студент.
А вот инженер молодой, бледнолицый,
Который клянет и судьбу, и частицу,
Которая с бешеной скоростью мчится
В стенде, который построил студент.
А вот кандидат, горделивый не в меру,
Который блистательно сделал карьеру,
Совместно работая с тем инженером,
Который клянет и судьбу и частицу,
Которая с бешеной скоростью мчится
В стенде, который построил студент.
А вот начальник, на вид простоватый,
Который был шефом того кандидата,
Который блистательно сделал карьеру,
Совместно работая с тем инженером,
Который клянет и судьбу и частицу,
Которая с бешеной скоростью мчится
В стенде, который построил студент.
А вот консультант от академии,
С которым встречался время от времени
Тот самый начальник, на вид простоватый,
Который был шефом того кандидата,
Который блистательно сделал карьеру,
Совместно работая с тем инженером,
Который клянет и судьбу и частицу,
Которая с бешеной скоростью мчится
В стенде, который построил студент.
А вот отчетов горы бумажные,
В которых копалась комиссия важная,
Которая выдала крупную премию,
Тому консультанту из академии,
Начальнику дали за вид простоватый,
Кусок уделили тому кандидату,
Который блистательно сделал карьеру,
Остатки вручили тому инженеру,
Который уже не ругает частицу,
Которая с бешеной скоростью мчится
В стенде, который построил
СТУДЕНТ

 

А кто не видел рекламной картинки, которая содержит свое собственное изображение?

 

 


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

Натуральные числа:

(a) 1 есть натуральное число;

(b) целое число, следующее за натуральным, есть натуральное число.

 

Функция факториал n! для неотрицательных целых чисел:

(a) 0! = 1,

(b) если n > 0, то n! = n × (n - 1)!

 

Вот еще одно рекурсивное определение.

(a) 3 коровы – это стадо коров.

(b) Стадо из n коров – это стадо из n –1 коровы и еще одна корова.

 

Попробуем применить это определение для проверки, является ли стадом группа из пяти коров (обозначим ее К5). Объект К5 не удовлетворяет первому пункту определения, поскольку пять коров – это не три коровы. Согласно второму пункту К5 – стадо, если там есть одна корова, а остальная часть К5, назовем ее К4, – тоже стадо коров. Решение относительно объекта К5 откладывается, пока не будет принято решение относительно К4. Объект К4 снова не подходит под первый пункт, а второй пункт гласит, что К4 – стадо, если объект К3, полученный из К4 путем отделения одной коровы, тоже стадо. Решение о К4 тоже откладывается. Наконец, объект К3 удовлетворяет первому пункту определения, и мы можем смело утверждать, что К3– стадо коров. Теперь и о К4 можно утверждать, что это стадо, а значит, и К5 является стадом коров.

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

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

Рº R [Si, P ]. (4.1)

Необходимое и достаточное средство для рекурсивного представления программ – это описание процедур, или подпрограмм, так как оно позволяет присваивать какому-либо оператору имя, с помощью которого можно вызвать этот оператор. Если процедура Р содержит явное обращение к самой себе, то она называется прямо рекурсивной; если Р содержит обращение к процедуре Q, которая содержит (прямо или косвенно) обращение к Р, то Р называется косвенно рекурсивной. Поэтому использование рекурсии не всегда сразу видно из текста программы.

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

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

P º if B then R [Si, P], (4.2)

или

P º R [Si, if B then P] (4.3)

Основной способ доказать, что выполнение операторов цикла когда-либо заканчивается, – определить функцию f(x) (x – множество переменных программы), такую, что f(x)≤0 удовлетворяет условию окончания цикла (с предусловием или с постусловием), и доказать, что при каждом повторении f(x) уменьшается. Точно так же можно доказать, что выполнение рекурсивной процедуры Р когда-либо завершиться, показав, что каждое выполнение Р уменьшает f(x). Наиболее надежный способ обеспечить окончание процедуры – связать с Р параметр (значение), скажем п, и рекурсивно вызвать Р со значением этого параметра п – 1. Тогда замена условия В на п > 0 гарантирует окончание работы. Это можно изобразить следующими схемами программ:

P(n) º if n > 0 then R [Si, P(n-1], (4.4)

P(n) º R [Si, if n > 0 then P (n-1)] (4.5)

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

Порождение все новых копий рекурсивной подпрограммы до выхода на граничное условие называется рекурсивным спуском. Максимальное количество копий рекурсивной подпрограммы, которое одновременно может находиться в памяти компьютера, называется глубиной рекурсии. Завершение работы рекурсивных подпрограмм, вплоть до самой первой, инициировавшей рекурсивные вызовы, называется рекурсивным подъёмом.

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

Begin P; Операторы End; Begin Операторы; P End; Begin Операторы; P; Операторы End;
Рекурсивный подъем Рекурсивный спуск И рекурсивный спуск, и рекурсивный подъем

 

Здесь P — рекурсивная подпрограмма. Как видно, действия могут выполняться либо на одном из этапов рекурсивного обращения, либо на обоих сразу.



Поделиться:




Поиск по сайту

©2015-2024 poisk-ru.ru
Все права принадлежать их авторам. Данный сайт не претендует на авторства, а предоставляет бесплатное использование.
Дата создания страницы: 2019-04-30 Нарушение авторских прав и Нарушение персональных данных


Поиск по сайту: