Глава 8. Как обойтись без рекурсии.
Глава 8. Как обойтись без рекурсии.
Для универсальных языков программирования (каковым является
паскаль) рекурсия не дает ничего нового: для всякой рекурсивной
программы можно написать эквивалентную программу без рекурсии.
Мы не будем доказывать этого, а продемонстрируем некоторые при-
емы, позволяющие избавиться от рекурсии в конкретных ситуациях.
Зачем это нужно? Ответ прагматика мог бы быть таким: во
многих компьютерах (в том числе, к сожалению, и в современных,
использующих так называемые RISC-процессоры), рекурсивные прог-
раммы в несколько раз медленнее соответствующих нерекурсивных
программ. Еще один возможный ответ: в некоторых языках програм-
мирования рекурсивные программы запрещены. А главное, при удале-
нии рекурсии возникают изящные и поучительные конструкции.
8.1. Таблица значений (динамическое программирование)
8.1.1. Следующая рекурсивная процедура вычисляет числа со-
четаний (биномиальные коэффициенты). Написать эквивалентную не-
рекурсивную программу.
function C(n,k: integer):integer;
| {n,k >=0; k <=n}
begin
| if (k = 0) or (k = n) then begin
| | C:=1;
| end else begin {0<k<n}
| | C:= C(n-1,k-1)+C(n-1,k)
| end;
end;
Замечание. C(n,k) - число k-элементных подмножеств n-элементного
множества. Соотношение C(n,k) = C(n-1,k-1)+C(n-1,k) получится,
если мы фиксируем некоторый элемент n-элементного множества и
отдельно подсчитаем k-элементные множества, включающие и не
включающие этот элемент. Таблица значений C(n,k)
1
1 1
1 2 1
1 3 3 1
.................
называется треугольником Паскаля (того самого). В нем каждый
элемент, кроме крайних единиц, равен сумме двух стоящих над ним.
Решение. Можно воспользоваться формулой
C(n,k) = n! / (k! * (n-k)!)
Мы, однако, не будем этого делать, так как хотим продемонстриро-
вать более общие приемы устранения рекурсии. Составим таблицу
значений функции C(n,k), заполняя ее для n = 0, 1, 2,..., пока
не дойдем до интересующего нас элемента.
8.1.2. Что можно сказать о времени работы рекурсивной и не-
рекурсивной версий в предыдущей задаче? Тот же вопрос о памяти.
Решение. Таблица занимает место порядка n*n, его можно сок-
ратить до n, если заметить, что для вычисления следующей строки
треугольника Паскаля нужна только предыдущая. Время работы в
обоих случаях порядка n*n. Рекурсивная программа требует су-
щественно большего времени: вызов C(n,k) сводится к двум вызовам
для C(n-1,..), те - к четырем вызовам для C(n-2,..) и т.д. Таким
образом, время оказывается экспоненциальным (порядка 2 в степени
n). Используемая рекурсивной версией память пропорциональна n -
умножаем глубину рекурсии (n) на количество памяти, используемое
одним экземпляром процедуры (константа).
Кардинальный выигрыш во времени при переходе от рекурсивной вер-
сии к нерекурсивной связан с тем, что в рекурсивном варианте од-
ни и те же вычисления происходят много раз. Например, вызов
C(5,3) в конечном счете порождает два вызова C(3,2):
C(5,3)
/ \
C(4,2) C(4,3)
/ \ / \
C(3,1) C(3,2) C(3,3)
......................
Заполняя таблицу, мы каждую клетку заполняем только однажды -
отсюда и экономия. Этот прием называется динамическим программи-
рованием, и применим в тех случаях, когда объем хранимой в таб-
лице информации оказывается не слишком большим.
8.1.2. Порассуждать на ту же тему на примере рекурсивной и
(простейшей) нерекурсивной программ для вычисления чисел Фибо-
наччи, заданных соотношением
f(1) = f (2) = 1; f(n) = f(n-1) + f(n-2) для n > 2.
8.1.3. Дан выпуклый n-угольник (заданный координатами своих
вершин в порядке обхода). Его разрезают на треугольники диагона-
лями, для чего необходимо n-2 диагонали (докажите индукцией по
n). Стоимостью разрезания назовем сумму длин всех использованных
диагоналей. Найти минимальную стоимость разрезания. Число
действий должно быть ограничено некоторым многочленом от n. (Пе-
ребор не подходит, так как число вариантов не ограничено многоч-
леном.)
Решение. Будем считать, что вершины пронумерованы от 1 до n
и идут по часовой стрелке. Пусть k, l - номера вершин, причем
l>k. Через A(k,l) обозначим многоугольник, отрезаемый от нашего
хордой k--l. (Эта хорда разрезает многоугольник на 2, один из
которых включает сторону 1--n; через A(k,l) мы обозначаем дру-
гой.) Исходный многоугольник естественно обозначить A(1,n). При
l=k+1 получается "двуугольник" с совпадающими сторонами.
Через a(k,l) обозначим стоимость разрезания многоугольника
A(k,l) диагоналями на треугольники. Напишем рекуррентную формулу
для a(k,l). При l=k+1 получается двуугольник, и мы полагаем
a(k,l)=0. При l=k+2 получается треугольник, и в этом случае так-
же a(k,l)=0. Пусть l > k+2. Хорда k--l является стороной много-
угольника A(k,l) и, следовательно, стороной одного из тре-
угольников, на которые он разрезан. Противоположной вершиной i
этого треугольника может быть любая из вершин k+1,...,l-1, и ми-
нимальная стоимость разрезания может быть вычислена как
min {(длина хорды k--i)+(длина хорды i--l)+a(k,i)+a(i,l)}
по всем i=k+1,..., i=l-1. При этом надо учесть, что при i=k+1
хорда k--i - не хорда, а сторона, и ее длину надо считать равной
0 (по стороне разрез не проводится).
Составив таблицу для a(k,l) и заполняя ее в порядке возрас-
тания числа вершин (равного l-k+2), мы получаем программу, ис-
пользующую память порядка n*n и время порядка n*n*n (однократное
применение рекуррентной формулы требует выбора минимума из не
более чем n чисел).
8.1.4. Матрицей размера m*n называется прямоугольная табли-
ца из m строк и n столбцов, заполненная числами. Матрицу размера
m*n можно умножить на матрицу размера n*k (ширина левого сомно-
жителя должна равняться высоте правого), и получается матрица
размером m*k. Ценой такого умножения будем считать произведение
m*n*k (таково число умножений, которые нужно выполнить при стан-
дартном способе умножения - но сейчас это нам не важно). Умноже-
ние матриц ассоциативно, поэтому произведение n матриц можно вы-
числять в разном порядке. Для каждого порядка подсчитаем суммар-
ную цену всех матричных умножений. Найти минимальную цену вычис-
ления произведения, если известны размеры всех матриц. Число
действий должно быть ограничено многочленом от числа матриц.
Пример. Матрицы размером 2*3, 3*4, 4*5 можно перемножать
двумя способами. В первом цена равна 2*3*4 + 2*4*5 = 24 + 40 =
64, во втором цена равна 3*4*5 + 2*3*5 = 90.
Решение. Представим себе, что первая матрица написана на
отрезке [0,1], вторая - на отрезке [1,2],..., s-ая - на отрезке
[s-1,s]. Матрицы на отрезках [i-1,i] и [i,i+1] имеют общий раз-
мер, позволяющих их перемножить. Обозначим его через d[i]. Таким
образом, исходным данным в задаче является массив d[0]..d[s].
Через a(i,j) обозначим минимальную цену вычисления произве-
дения матриц на участке [i,j] (при 0<=i<j<=s). Искомая величина
равна a(0,s). Величины a(i,i+1) равны нулю (матрица одна и пе-
ремножать ничего не надо). Рекуррентная формула будет такой:
a(i,j) = min {a(i,k)+ a(k,j) + d[i]*d[k]*d[j]}
где минимум берется по всем возможных местам последнего умноже-
ния, то есть по всем k=i+1..j-1. В самом деле, произведение мат-
риц на отрезке [i,k] есть матрица размера d[i]*d[k], произведе-
ние матриц на отрезке [k,j] имеет размер d[k]*d[j], и цена вы-
числения их произведения равна d[i]*d[k]*d[j].
Замечание. Две последние задачи похожи. Это сходство станет
яснее, если написать матрицы - множители на сторонах 1--2,
2--3,..., s-1--s многоугольника, а на каждой хорде i--j написать
произведение всех матриц, стягиваемых этой хордой.
8.1.5. Железная дорога с односторонним движением имеет n
станций. Известны цены белетов от i-ой станции до j-ой (при i <
j - в обратную сторонону проезда нет). Найти минимальную сто-
имость проезда от начала до конца (с учетом возможной экономии
за счет пересадок).
Мы видели, что замена рекурсивной программы на заполнение
таблицы значений иногда позволяет уменьшить число действий. При-
мерно того же эффекта можно добиться иначе: оставить программу
рекурсивной, но в ходе вычислений запоминать уже вычисленные
значения, а перед очередным вычислением проверять, нет ли уже
готового значения.
8.1.6. Задано конечное множество с бинарной операцией (во-
обще говоря, не коммутативной и даже не ассоциативной). Имеется
n элементов a[1]..a[n] этого множества и еще один элемент x.
Проверить, можно ли так расставить скобки в произведении
a[1]..a[n], чтобы в результате получился x. Число операций
должно не превосходить C*n*n*n для некоторой константы C (зави-
сищей от числа элементов в выбранном конечном множестве).
Решение. Заполняем таблицу, в которой для каждого участка
a[i]..a[j] нашего произведения хранится список всех возможных
его значений (при разной расстановке скобок).
По существу этот же прием применяется в полиномиальном ал-
горитме проверки принадлежности слова произвольному кон-
текстно-свободному языку (см. главу 13).
Следующая задача (задача о рюкзаке) уже упоминалась в главе
3 (Обход дерева).
8.1.7. Имеется n положительных целых чисел x[1]..x[n] и
число N. Выяснить, можно ли получить N, складывая некоторые из
чисел x[1]..x[n]. Число действий должно быть порядка N*n.
Указание. После i шагов хранится множество тех чисел на от-
реке 0..N, которые предствимы в виде суммы некоторых из
x[1]..x[i].
8.2. Стек отложенных заданий.
Другой прием устранения рекурсии продемонстрируем на приме-
ре задачи о ханойских башнях.
8.2.1. Написать нерекурсивную программу для нахождения пос-
ледовательности перемещений дисков в задаче о ханойских башнях.
Решение. Вспомним рекурсивную программу:
procedure move(i,m,n: integer);
| var s: integer;
begin
| if i = 1 then begin
| | writeln ('сделать ход', m, '->', n);
| end else begin
| | s:=6-m-n; {s - третий стержень: сумма номеров равна 6}
| | move (i-1, m, s);
| | writeln ('сделать ход', m, '->', n);
| | move (i-1, s, n);
| end;
end;
Видно, что задача "переложить i верхних дисков с m-го стержня на
n-ый" сводится к трем задачам того же типа: двум задачам с i-1
дисками и к одной задаче с единственным диском. Выполняя эти за-
дачи, важно не позабыть, что еще осталось сделать.
Для этой цели заведем стек отложенных заданий, элементами
которого будут тройки <i,m,n>. Каждая такая тройка интерпретиру-
ется как заказ "переложить i верхних дисков с m-го стержня на
n-ый". Заказы упорядочены в соответствии с требуемым порядком их
выполнения: самый срочный - вершина стека. Получам такую прог-
рамму:
procedure move(i,m,n: integer);
begin
| сделать стек заказов пустым
| положить в стек тройку <i,m,n>
| {инвариант: осталось выполнить заказы в стеке}
| while стек непуст do begin
| | удалить верхний элемент, переложив его в <j,p,q>
| | if j = 1 then begin
| | | writeln ('сделать ход', p, '->', q);
| | end else begin
| | | s:=6-p-q;
| | | {s - третий стержень: сумма номеров равна 6}
| | | положить в стек тройки <j-1,s,q>, <1,p,q>, <j-1,p,s>
| | end;
| end;
end;
(Заметим, что сначала в стек кладется тройка, которую надо вы-
полнять последней.) Стек троек может быть реализован как стри
отдельных стека. (Кроме того, в паскале есть специальный тип,
называемый "запись", который может быть применен.)
8.2.2. (Сообщил А.К.Звонкин со ссылкой на Анджея Лисовско-
го.) Для задачи о ханойских башнях есть и другие нерекусивные
алгоритмы. Вот один из них: простаивающим стержнем (не тем, с
которого переносят, и не тем, на который переносят) должны быть
все стержни по очереди. Другое правило: поочередно перемещать
наименьшее кольцо и не наименьшее кольцо, причем наименьшее - по
кругу.
8.2.3. Использовать замену рекурсии стеком отложенных зада-
ний в рекурсивной программе печати десятичной записи целого чис-
ла.
Решение. Цифры добываются с конца и закладываются в стек, а
затем печатаются в обратном порядке.
8.2.4. Написать нерекурсивную программу, печатающую все
вершины двоичного дерева.
Решение. В этом случае стек отложенных заданий будет содер-
жать заказы двух сортов: заказ напечатать (в свое время) данную
вершину и заказ напечатать все вершины поддерева с данным корнем
(при этом nil считается корнем пустого дерева). Таким образом,
элемент стека есть пара: <тип заказа, номер вершины>.
Вынимая элемент из стека, мы либо сразу исполняем его (если
это заказ первого типа) либо помещаем в стек три порожденных им
заказа - в одном из шести возможных порядков.
8.2.5. Что изменится, если требуется не печатать вершины
двоичного дерева, а подсчитать их количество?
Решение. Печатание вершины следует заменить прибавлением
единицы к счетчику. Другими словами, инвариант таков: (общее
число вершин) = (счетчик) + (сумма чисел вершин в поддеревьях,
корни которых лежат в стеке).
8.2.6. Для некоторых из шести возможных порядков возможны
упрощения, делающие ненужным хранение в стеке элементов двух ви-
дов. Указать некоторые из них.
Решение. Если требуемый порядок таков:
корень, левое поддерево, правое поддерево,
то заказ на печатание корня можно не закладывать в стек, а вы-
полнять сразу.
Несколько более сложная конструкция применима для порядка
левое поддерево, корень, правое поддерево.
В этом случае все заказы в стеке, кроме самого первого (напеча-
тать поддерево) делятся на пары:
напечатать вершину x, напечатать правое поддерево x
(т.е. поддерево с корнем в правом сыне x). Объединив эти пары в
заказы специального вида и введя переменную для отдельного хра-
нения первого заказа, мы обойдемся стеком однотипных заказов.
То же самое, разумеется, верно, если поменять местами левое
и правое - получается еще два порядка.
Замечание. Другую программу печати всех вершин дерева можно
построить на основе программы обхода дерева, разобранной в соот-
ветствующей главе. Там используется команда "вниз". Поскольку
теперешнее представление дерева с помощью массивов l и r не поз-
воляет найти предка заданной вершины, придется хранить список
всех вершин на пути от корня к текущей вершине. Cмотри также
главу об алгоритмах на графах.
8.2.7. Написать нерекурсивный вариант программы быстрой
сортировки. Как обойтись стеком, глубина которого ограничена
C*log n, где n - число сортируемых элементов?
Решение. В стек кладутся пары <i,j>, интерпретируемые как
отложенные задания на сортировку соответствующих участков масси-
ва. Все эти заказы не пересекаются, поэтому размер стека не мо-
жет превысить n. Чтобы ограничиться стеком логарифмической глу-
бины, будем придерживаться такого правила: глубже в стек поме-
щать больший из возникающих двух заказов. Пусть f(n) - макси-
мальная глубина стека, которая может встретиться при сортировке
массива из не более чем n элементов таким способом. Оценим f(n)
сверху таким способом: после разбиения массива на два участка мы
сначала сортируем более короткий (храня в стеке про запас) более
длинный, при этом глубина стека не больше f(n/2)+1, затем сорти-
руем более длинный, так что
f(n) <= max (f(n/2)+1, f(n-1)),
откуда очевидной индукцией получаем f(n) = O(log n).
8.3. Более сложные случаи рекурсии.
Пусть функция f с натуральными аргументами и значениями оп-
ределена рекурсивно условиями
f(0) = a,
f(x) = h(x, f(l(x))),
где a - некоторое число, а h и l - известные функции. Другими
словами, значение функции f в точке x выражается через значение
f в точке l(x). При этом предполагается, что для любого x в пос-
ледовательности
x, l(x), l(l(x)),...
рано или поздно встретится 0.
Если дополнительно известно, что l(x) < x для всех x, то
вычисление f не представляет труда: вычисляем последовательно
f(0), f(1), f(2),...
8.3.1. Написать нерекурсивную программу вычисления f для
общего случая.
Решение. Для вычисления f(x) вычисляем последовательность
l(x), l(l(x)), l(l(l(x))),...
до появления нуля и запоминаем ее, а затем вычисляем значения f
в точках этой последовательности, идя справа налево.
Еще более сложный случай из следующей задачи вряд ли встре-
тится на практике (а если и встретися, то проще рекурсию не
устранять, а оставить). Но тем не менее: пусть функция f с нату-
ральными аргументами и значениями определяется соотношениями
f(0) = a,
f(x) = h(x, f(l(x)), f(r(x))),
где a - некоторое число, а l, r и h - известные функции. Предпо-
лагается, что если взять произвольное число и начать применять к
нему функции l и r в произвольном порядке, то рано или поздно
получится 0.
8.3.2. Написать нерекурсивную программу вычисления f.
Решение. Можно было бы сначала построить дерево, у которого
в корне находится x, а в сыновьях вершины i стоят l(i) и r(i) -
если только i не равно нулю, а затем вычислять значения функции,
идя от листьев к корню. Однако есть и другой способ.
"Обратной польской записью" (или "постфиксной записью") вы-
ражения называют запись, где знак функции стоит после всех ее
аргументов, а скобки не используются. Вот несколько примеров:
f(2) 2 f
f(g(2)) 2 g f
s(2,t(7)) 2 7 t s
s(2, u(2, s(5,3)) 2 2 5 3 s u s
Постфиксная запись выражения позволяет удобно вычислять его с
помощью "стекового калькулятора". Этот калькулятор имеет стек,
который мы будем представлять себе расположенным горизонтально
(числа вынимаются и кладутся справа). При нажатии на клавишу с
числом это число кладется в стек. При нажатии на функциональную
клавишу соответствующая функция применяется к нескольким аргу-
ментам у вершины стека. Например, если в стеке были числа
2 3 4 5 6
и нажата функциональная клавиша s, соотвтетствующая функции от
двух аргументов, то в стеке окажутся числа
2 3 4 s(5,6)
Перейдем теперь к нашей задаче. В процессе вычисления значения
функции f мы будем работать со стеком чисел, а также с последо-
вательностью чисел и символов "f", "l", "r", "h", которую мы бу-
дем интерпретировать как последовательность нажатий кнопок на
стековом калькуляторе. Инвариант такой:
если стек чисел представляет собой текущее состояние
стекового калькулятора, то после нажатия всех клавиш
последовательности в стеке останется единственное
число, и оно будет искомым ответом.
Пусть нам требуется вычислить значение, к примеру, f(100). Тогда
вначале мы помещаем в стек число 100, а последовательность со-
держит единственный символ "f". (При этом инвариант соблюдает-
ся.) Далее с последовательностью и стеком выполняются такие пре-
образования:
старый старая новый новая
стек последовательность стек последовательность
X x P X x P
X x l P X l(x) P
X x r P X r(x) P
X x y z h P X h(x,y,z) P
X 0 f P X a P
X x f P X x x l f x r f h P
Обозначения: x, y, z,.. - числа, X - последовательность чисел, P
- последовательность чисел и символов "f", "l", "r", "h". В пос-
ледней строке предполагается, что m не равно 0. Эта строка соот-
ветствует равенству
f(x) = h(x, f(l(x)), f(r(x))),
Эти преобразования выполняются, пока последовательность не ста-
нет пуста. В этот момент в стеке окажется единственное число,
которое и будет ответом.
Замечание. Последовательность по существу представляет со-
бой стек отложенных заданий (вершина которого находится слева).
[ Назад ]
[ Оглавление ]
[ Далее ]