Добавил:
Опубликованный материал нарушает ваши авторские права? Сообщите нам.
Вуз: Предмет: Файл:

Демидов Основы программирования в примерах на языке ПАСЦАЛ 2010

.pdf
Скачиваний:
128
Добавлен:
16.08.2013
Размер:
1.28 Mб
Скачать

var e: integer; begin

c := a + b; d := c;

e := c; writeln('Подпрограмма:');

writeln('c = ', c, ' d = ', d, ' e = ', e); end;

begin

a := 3; b := 5; x(a, b, c, d);

writeln('Главная программа:');

writeln('c = ', c, ' d = ', d, ' e = ', e); end.

Результаты работы программы:

Подпрограмма:

c = 8 d = 8 e = 8

Главная программа: c = 0 d = 8 e = 0

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

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

91

Глава 9. Рекурсивные процедуры и функции

Рекурсивные подпрограммы, условие выхода из рекурсии

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

Ранее была построена функция Calculate, которая из-за использования её имени в качестве локальной переменной случайно вызывала саму себя. При этом она никогда бы не завершила своей работы из-за бесконечной рекурсии, так как у нее не было возможности не выполнять рекурсивный вызов.

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

function r(n: integer): integer; begin

if n <= 1 then r := 1 else r := n * r(n - 1);

end;

Данная программа вычисляет значение факториала, при этом используется рекуррентное соотношение n! = n*(n–1)!, где 1! = 1. Здесь вычисления завершатся, поскольку после серии рекурсивных вызовов при n = 1 выполнится основная ветвь условного оператора. Такое условие называют условием выхода из рекурсии, а ветвь, не содержащая рекурсивных вызовов, именуется терминальной.

Если одна подпрограмма вызывает другую, а та в свою очередь первую, то имеет место косвенная рекурсия:

procedure FА(…); begin

...

FB(…);

...

end;

procedure FВ(…);

92

begin

...

FA(…);

...

end;

Однако по правилам языка Pascal нельзя использовать объект раньше, чем он был описан. Таким образом, определение подпрограммы FA некорректно, поскольку имеет ссылку на неопределенный объект FB(). В этих случаях необходимо объявить (но не определять!) процедуру FB до процедуры FA. В языке Паскаль это выполняется с помощью директивы forward после заголовка подпрограммы. Например:

procedure FВ(a: integer); forward;

procedure FА(a: integer); begin

...

FB(…);

...

end;

procedure FВ(a: integer); begin

...

FA(…);

...

end;

Косвенная рекурсия часто используется при обработке динамических структур данных.

Правила построения рекурсивных подпрограмм:

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

2)проверка условия выхода должна предшествовать рекурсивным вызовам, в противном случае условие никогда не будет проверено.

93

Итерация и рекурсия. Сравнение подходов

Рассмотрим пример рекурсивной функции, вычисляющей значение n-го члена ряда Фибоначчи:

function fib(n: integer): integer; begin

if n <= 2 then fib := 1

else fib := fib(n-1) + fib(n-2); end;

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

Но насколько эффективен этот способ? Итеративный алгоритм вычисления чисел Фибоначчи имел линейную временную сложность и константную емкостную сложность. Чтобы оценить временную сложность рекурсивного алгоритма необходимо рассчитать общее число операций, выполняемых в результате всех рекурсивных вызовов. Вызов подпрограммы будем считать одной операцией. Для n=1 или n=2 выполняются одна проверка условия выхода и возврат значения (две операции), а для остальных случаев после проверки условия выполняются два вычитания, два рекурсивных вызова, сложение и присваивание (семь операций). К этому числу ещё необходимо добавить число операций, выполняемых в результате двух рекурсивных вызовов. Тогда:

для n = 3 имеем ∑(3) = 7 + ∑(2) + ∑(1) = 7+2+2 = 11 операций; для n = 4 имеем ∑(4) = 7 + ∑(3) + ∑(2) = 7+11+2 = 20 операций; для n = 5 имеем ∑(5) = 7 + ∑(4) + ∑(3) = 7+20+10 = 37 операции;

Обобщая, получаем ∑(n) = 7 + ∑(n–1) + ∑(n–2), где ∑(2) = 2, ∑(1) = 2.

Итак, получено рекуррентное соотношение, напоминающее соотношение для n-го члена ряда Фибоначчи, что для выбранного

94

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

рекурсивного алгоритма пропорциональна fib(n). Для получения

аналитического вида функций fib(n) и ∑(n) необходимо вспомнить

удивительное свойство чисел Фибоначчи:

 

 

 

 

fib(n)

1

5 1,618...

 

 

 

 

 

fib(n 1)

 

 

 

 

2

 

 

 

 

 

 

 

 

 

Тогда для больших n

 

 

 

 

 

 

 

 

 

 

fib(n) 1,618 1,618n

 

 

 

 

 

 

 

 

 

n

 

 

 

 

 

 

 

 

 

 

 

 

 

Таким образом, функция fib(n) оказывается прямо пропорцио-

нальна 1,618n. То же самое справедливо и для ∑(n). Рассмотрим

графики функций fib(n), ∑(n) и 1,618n, представленные на рис. 6:

500

 

 

 

 

 

 

 

 

 

 

 

 

 

 

450

 

 

 

 

 

 

 

 

 

 

 

 

 

 

400

 

 

 

 

 

 

 

 

 

 

 

 

 

 

350

 

 

 

 

 

 

 

 

 

 

 

 

 

 

300

 

 

 

 

 

 

 

 

 

 

 

 

 

n

 

 

 

 

 

 

 

 

 

 

 

 

 

fib(n)

 

 

 

 

 

 

 

 

 

 

 

 

 

 

250

 

 

 

 

 

 

 

 

 

 

 

 

 

∑(n)

200

 

 

 

 

 

 

 

 

 

 

 

 

 

n^2

 

 

 

 

 

 

 

 

 

 

 

 

 

1,618^n

 

 

 

 

 

 

 

 

 

 

 

 

 

 

150

 

 

 

 

 

 

 

 

 

 

 

 

 

 

100

 

 

 

 

 

 

 

 

 

 

 

 

 

 

50

 

 

 

 

 

 

 

 

 

 

 

 

 

 

0

 

 

 

 

 

 

 

 

 

 

 

 

 

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

Рис. 6. График функции fib(n) в сравнении с другими

Видно, что графики fib(n) и sigma(n) повторяют форму графика 1,618n, а сами функции связаны соотношениями

fib(n) = 0,4475*1,618n; ∑(n) = 4,028*1,618n.

95

Таким образом, сложность рекурсивного алгоритма оценивается как O(1,618n). Можно заметить, что функция fib(n) возрастает быстрее n2 и в точке n = 12 перегоняет её. Столь низкая производительность рекурсивной функции fib(n) объясняется тем, что вычисления многократно повторяются. Визуально рекурсивные вызовы можно представить в виде дерева, где число повторных вычислений растёт при углублении рекурсии (рис. 7).

fib(n)

fib(n-1)

 

fib(n-2)

 

 

 

fib(n-2)

 

fib(n-3)

 

fib(n-3)

 

fib(n-4)

 

 

 

 

 

 

 

fib(n-3) fib(n-4)

Рис. 7. Дерево рекурсивных вызовов fib(n)

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

Реализация рекурсии в операторных языках. Стек вызовов подпрограмм

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

96

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

2)вычисляются значения фактических параметров, выполняется контроль типов;

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

4)управление передается первому оператору подпрограм-

мы.

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

Для хранения адресов возврата и состояний окружения (значений параметров и переменных) используется стек. Стеком называется упорядоченный набор некоторого переменного числа объектов, работающий по правилу: «Последним пришел, первым вышел» (от англ. LIFO – Last In, First Out). Схему работы стека можно проиллюстрировать на следующем примере: представим, что машина заехала в узкий тупик, а за ней еще несколько машин. Выезжать они будут в обратном порядке, и последней выедет первая машина.

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

Рассмотрим работу стека и порядок вычислений fib(n) для n = 4:

fib(4) в стек проверка n<=2

выделение памяти для промежуточных переменных @1,@2,@3

fib(3) в стек

97

проверка n<=2

выделение памяти для промежуточных переменных @4,@5,@6

fib(2) в стек проверка n<=2

возврат 1 @41 (извлечение из стека)

fib(1) в стек проверка n<=2

возврат 1 @51 из стека

@6 = @4 + @5 = 2

возврат @6 @12 из стека

fib(2) в стек проверка n<=2

возврат 1 @21 из стека

@3 = @1+@2 = 3

возврат @3

3 из стека

Видно, что расчёта числа Фибоначчи требуется сначала рекурсивно погрузиться на максимальную глубину, а лишь затем вычислить сумму:

fib(4)

fib(4-1) + fib(4-2) fib(3) + fib(2)

(fib(3-1) + fib(3-2)) + fib(2) (fib(2) + fib(1)) + fib(2)

(1 + fib(1)) + fib(2) (1 + 1) + fib(2)

2 + fib(2)

2 + 1

3

При этом память тратится не только на обеспечение рекурсивных вызовов в стеке, а еще и на хранение многочисленных вспомогательных локальных переменных.

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

98

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

Хвостовая рекурсия и аккумуляторы, оптимизация рекурсивных программ

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

function fib(n: integer): integer;

function f(n, prev, pprev: integer): integer; begin

if n <= 2 then f := prev

else f := f(n-1, prev+pprev, prev); end;

begin

f(n, 1,1); end;

Дерево рекурсивных вызовов для fib(4) выглядит так:

fib(4) f(4, 1, 1)

f(4-1, 1+1, 1) f(3, 2, 1) f(3-1, 2+1, 2) f(2, 3, 2)

3

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

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

99

Правила построения рекурсивных подпрограмм с аккумулятором:

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

2)главная функция вызывает вспомогательную функцию с начальными значениями аккумуляторов;

3)при выполнении условия выхода из рекурсии вспомогательная функция возвращает значение аккумулятора;

4)рекурсивный вызов вспомогательной функции выполняется с таким значением аккумулятора, которое возвращалось бы главной функцией.

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

Задание для самостоятельного выполнения: написать функцию вычисления факториала f(n)=n*f(n-1) с помощью хвостовой рекурсии.

Возможный вариант решения:

function fact(n: integer): integer; function f(n, acc: integer): integer; begin

if n <= 1 then f := acc else f := f(n-1, n*acc);

end; begin

f(n,1); end;

Проверка работы хвостовой рекурсии для 4!: fact(4)

f(4, 1) f(4-1, 4*1) f(3, 4) f(3-1, 3*4) f(2, 12) f(2-1, 2*12) f(1, 24)

24

100

Соседние файлы в предмете [НЕСОРТИРОВАННОЕ]