Вы находитесь на странице: 1из 49

О.Н. Паулин.

Основы теории алгоритмов

Глава 4
ПОСТРОЕНИЕ И АНАЛИЗ АЛГОРИТМОВ

4.1. Комбинаторные вычисления на конечных множествах

4.1.1. Введение в комбинаторику

Предметом теории комбинаторных алгоритмов, часто называемой


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

129
Глава 4. ПОСТРОЕНИЕ И АНАЛИЗ АЛГОРИТМОВ

общему, типу проблем применяются процедуры асимптотических


разложений, рекуррентных соотношений и производящих функций.

4.1.2. Асимптотика

Асимптота — особая линия (чаще всего прямая), являющаяся


предельной для рассматриваемой кривой.
Асимптотика — это искусство оценивания и сравнения скоростей

1  x2
роста функций. Говорят, что при х функция "ведёт себя, как х",
x
или "возрастает с такой же скоростью, как х", и при х0 "ведёт себя, как
1/x". Говорят, что "logx при x0 и любом >0 ведёт себя, как x, и что

n
 log i при n растёт не быстрее, чем nlogn". Такие неточные, но
i 1

интуитивно ясные утверждения полезны при сравнении функций так же,


как и соотношения <,  и = при сравнивании чисел.
Определим три основных асимптотических соотношения.
Определение 1. Функция f(x) эквивалентна g(x) при хx0, если и

lim f ( x)
только если x x0 g( x )
=1.

В этом случае говорят, что функция f(x) асимптотически равна


функции g(x) или что f(x) растёт с такой же скоростью, как и g(x).
lim f ( x)
Определение 2. f(x)=o(g(x)) при xx0, если и только если x x0 g( x )

=0.
Говорят, что при xx0 f(x) растёт медленнее, чем g(x), или что f(x)
"есть о-малое" от g(x).
Определение 3. f(x)=О(g(x)) при xx0, если и только если

130
О.Н. Паулин. Основы теории алгоритмов

lim f ( x)
существует константа С такая, что sup x x0 g( x )
=С.

В этом случае говорят, что f(x) растёт не быстрее, чем g(x), или что
при xx0 f(x) "есть О-большое" от g(x).
Cоотношение f(x)=g(x)+o(h(x)) при x означает, что f(x)-g(x)=o(h(x)).
Аналогично f(x)=g(x)+О(h(x)) означает, что f(x)-g(x)=О(h(x)).
Выражения О() и о() могут использоваться также и в неравенствах.
Например, неравенство x+o(x)2x при x0 означает, что для любой
функции f(x) такой, что f(x)=o(x), при x имеет место соотношение
x+f(x)2x для всех достаточно больших значений х.
Приведём некоторые полезные асимптотические равенства.
Полином асимптотически равен своему старшему члену:
k
 a i x i  O ( x k ) при x; (4.1)
i 0

k
 a i x i  o( x k 1) при x; (4.2)
i 0

k
 a i x i ~ a k ( x k ) при x и ak0. (4.3)
i 0

Суммы степеней целых чисел удовлетворяют соотношению:


n n k 1
 i k ~ k  1 при n. (4.4)
i 1

Отсюда, в частности, имеем при n


n n2 n n3
i ~ 2
и i ~ 3 .
2
(4.5)
i 1 i 1

В более общем случае при n и для любого целого k0


n n k 1
k
i   o( n k  1 ) ; (4.6)
i 1 k 1
n
k n k 1
i   O(n k ) . (4.7)
i 1 k 1

131
Глава 4. ПОСТРОЕНИЕ И АНАЛИЗ АЛГОРИТМОВ

4.1.3. Рекуррентные соотношения

Понятие рекуррентных соотношений проиллюстрируем на


классической проблеме, поставленной и изученной Фибоначчи около 1200
г.
Фибоначчи поставил свою проблему в форме рассказа о скорости
роста популяции кроликов при следующих предположениях. Все
начинается с одной пары кроликов. Каждая пара кроликов становится
фертильной (fertile – плодовитый) через месяц, после чего каждая пара
рождает новую пару кроликов каждый месяц. Кролики никогда не умирают,
и их воспроизводство никогда не прекращается. Пусть Fn - число пар
кроликов в популяции по прошествии n месяцев и пусть эта популяция
состоит из Nn пар приплода и On “старых” пар, т.е. Fn = Nn + On. Таким
образом, в очередном месяце произойдут следующие события:
- старая популяция в (n+1)-й момент увеличится на число
родившихся в момент времени n, т.е. On+1 = On + Nn = Fn;
- каждая старая в момент времени n пара производит в момент
времени (n+1) пару приплода, т.е. Nn+1 = Cn.
В последующий месяц эта картина повторяется:
On+2 = On+1+ Nn+1 = Fn+1,
Nn+2 = On+1;
объединив эти равенства, получим рекуррентное соотношение Фибонначи:
On+2 + Nn+2 = Fn+1 + On+1,
или
Fn+2 = Fn+1 + Fn. (4.8)
Выбор начальных условий для последовательности чисел
Фибоначчи не важен; существенные свойства этой последовательности

132
О.Н. Паулин. Основы теории алгоритмов

определяются рекуррентным соотношением (4.8). Обычно полагают F0=0,


F1=1 (иногда полагают F0=F1=1).
Рекуррентное соотношение (4.8) является частным случаем
однородных линейных рекуррентных соотношений с постоянными
коэффициентами:
xn = a1xn-1 + a2xn-2 +…akxn-k, (4.9)
где коэффициенты ai не зависят от n и x1, x2, …, xk считаются заданными.
Существует общий метод решения (т.е. отыскания xn как функции n)
линейных рекуррентных соотношений с постоянными коэффициентами.
Этот метод рассмотрим на примере соотношения (4.8). Найдём решение в
виде
Fn=crn (4.10)
с постоянными с и r. Подставляя это выражение в (4.8), получим
crn+2 = crn+1 + crn,
или
crn(rn-r-1)=0. (4.11)
Это означает, что Fn=crn является решением, если либо с=0, либо
r = 0 (и отсюда Fn=0 для всех n), а также (и это более интересный случай)
если r2 - r -1=0, причём константа с произвольна. Тогда из (4.11) следует
1 1
r = 2 (1  5) или r = 2 (1  5) . (4.12)
1
Число (1  5 ) 1,618 известно как золотое сечение, поскольку с
2

древних времен считается, что треугольник (прямоугольник) со сторонами


1
1и (1  5 ) имеет наиболее приятные для глаза пропорции.
2

Сумма двух решений однородного линейного рекуррентного


соотношения, очевидно, также является решением, и можно на самом

133
Глава 4. ПОСТРОЕНИЕ И АНАЛИЗ АЛГОРИТМОВ

деле показать, что общее решение последовательности Фибоначчи имеет


вид
1 5 n 1 5 n
Fn= c( )  c' ( ) , (4.13)
2 2

где константы с и с’ определяются начальными условиями. Положив F0=0


и F1=1, получим следующую систему линейных уравнений:
F 0  c  c'  0 

F1  c (
1 5
)  c ' (
1 5 
) 
, (4.14)
2 2 

решение которой даёт


1
c = -c' = . (4.15)
5

Последовательность Фибоначчи асимптотически растёт как


1 1 5 n
Fn  5
(
2
) , (4.16)

поскольку вторым слагаемым в (4.15) можно пренебречь.

4.1.4. Производящие функции

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


числовой последовательности сопоставляется некоторая функция
действительного (комплексного) переменного таким образом, чтобы
обычным операциям над последовательностями соответствовали простые
операции над сопоставленными функциями. Наиболее общим в
комбинаторике является сопоставление последовательности x0, x1, x2,…

k
производящей функции X(s) =  xk s действительной переменной s.
k 0

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


функций для вывода нескольких важных формул для сочетаний без
повторений на примере бинома Ньютона

134
О.Н. Паулин. Основы теории алгоритмов

n
(1  x )n   C rn x r . (4.17)
i 0

Рассмотрим следующие частные случаи:


n
i
а) если x = 1, то 2n =  Cn ; это выражение позволяет определить
i 0

количество подмножеств некоторого множества;

б) если x = -1, то 0 =  C ni   C ni , т.е.  Cni   Cni ,


i i' i i'

где i - чётное, а i’– нечётное.


Для сочетаний с повторениями используется полиномиальная
производящая функция X(s)=1 + s1x + s2x2 +…, коэффициенты sr которой
представляют собой r-сочетания из n различных элементов с
повторениями. Эта функция является обобщением бинома Ньютона,
которая рассматривается как произведение многочленов разных степеней,
причём старшая степень каждого из перемножаемых многочленов
задаётся специфи-кацией (приложение Б). Например, для r-сочетаний из
трёх элементов a, b, c со спецификацией {3, 1, 2} имеем
(1 + x + x2 + x3)(1 + x )( 1 + x + x2)=1 + 3x + 5x2 + 6x3 + 5x4 + 3x5 + x6.
Здесь коэффициент при xr дает искомое число r-сочетаний. Так,
имеется пять 2-сочетаний (аа, ab, ac, bc, cc), шесть 3-сочетаний (aaa, aab,
aac, abc, acc, bcc) и т. д.

135
Глава 4. ПОСТРОЕНИЕ И АНАЛИЗ АЛГОРИТМОВ

4.2. Оценки сложности алгоритмов

Важным мотивом, побуждающим рассматривать формальные


модели вычислений, является желание раскрыть вычислительную
сложность различных задач с целью получить нижние оценки на время
вычисления. Чтобы показать, что не существует алгоритма, выполняющего
данное задание менее чем за определённое время, необходимо точное и
подчас высокоспециализированное определение того, что есть алгоритм.
Одним из примеров такого определения служат машины Тьюринга.
Машина Тьюринга – простейшая модель вычислений; существуют и
более сложные модели, например многоленточные машины Тьюринга,
автоматы Неймана [7]. Все они могут быть использованы для решения тех
задач, для которых существует алгоритм. На основе этих моделей
строятся более специализированные модели вычислений, а именно:
неветвящиеся арифметические программы, битовые вычисления,
вычисления с двоичными векторами и деревья решений.
Алгоритмы имеют следующие характеристики:
1) сложность;
2) трудоёмкость;
3) надежность и др.
Далее рассматривается только первая характеристика – сложность.
Для оценки сложности алгоритмов существует много критериев. Чаще
всего важен порядок роста необходимых для решения задачи времени и
ёмкости памяти (количество ячеек для хранения данных) при увеличении
количества входных данных. Свяжем с каждой конкретной задачей
некоторое число, называемое её размером, которое выражало бы меру
количества входных данных. Например, размером задачи умножения
матриц может быть наибольший размер матриц сомножителей; размером
задачи о графах может быть число рёбер данного графа и т.п.

136
О.Н. Паулин. Основы теории алгоритмов

Время, затрачиваемое алгоритмом, как функция размера задачи,


называется временной сложностью этого алгоритма. Поведение этой
сложности в пределе при увеличении размера задачи называется
асимптотической временной сложностью. Аналогично определяются
ёмкостная сложность и асимптотическая ёмкостная сложность.
Именно асимптотическая сложность алгоритма определяет в итоге
размер задач, которые можно решить этим алгоритмом. Если алгоритм об-
рабатывает входы размера n за время Cn2, где C - некоторая постоянная,
то говорят, что временная сложность этого алгоритма есть O(n2) (читается
“порядка n2“). Точнее, говорят, что неотрицательная функция g(n) есть
O(f(n)), если существует такая постоянная C, что g(n)Cf(n) для всех, кроме
конечного (возможно, пустого) множества, неотрицательных значений n.
Временная сложность в худшем случае (или просто временная
сложность) алгоритма - это функция f(n), равная наибольшей (по всем
входам размера n) из сумм времён, затраченных на каждый выполненный
оператор. Временная сложность в среднем - это среднее, взятое по всем
входам размера n, тех же самых сумм.
Аналогично определяется понятие для ёмкости памяти, только
вместо "времён, затраченных на каждый выполненный оператор", следует
подставить "ёмкость всех ячеек, к которым было обращение".
Определение. Говорят, что неотрицательные функции f1(n) и f2(n)
полиномиально связаны (эквивалентны), если найдутся такие полиномы
P1(x) и P2(x), что для всех n справедливы неравенства f1(n)P1(f2(n)) и
f2(n)P2(f1(n)).
Пример 4.1. Пусть f1(n)=2n2 и f2=n5.Покажем, что функции f1 и f2
полиномиально связаны, для чего введём в рассмотрение полиномы
P1(x)=2x и P2(x)=x3. Имеем 2n22(n5) и n5(2n2)3=8n6.
Пример 4.2. Для функций f1(n)=n2 и f2(n)=2n не удаётся подобрать
такие полиномы P1(x) и P2(x), чтобы выполнились неравенства

137
Глава 4. ПОСТРОЕНИЕ И АНАЛИЗ АЛГОРИТМОВ

f1(n)P1(f2(n)) и f2(n)P2(f1(n)) при любом n, так как показательная функция с


ростом n растёт быстрее степенной.
Не важно, как алгоритм представлен - в терминах языка высокого
уровня или последовательностью двоичных символов; если алгоритм
имеет полиномиальную (экспоненциальную) сложность для первого
представления, то он будет иметь также полиномиальную
(экспоненциальную) сложность и для второго представления.
Говорят, что данный алгоритм является алгоритмом с
полиномиальным временем, если время его работы, т.е. число
элементарных двоичных операций, которые он выполняет на входной
строке длины l, ограничено сверху некоторым полиномом P(l). Класс всех
задач, решаемых такими алгоритмами, обозначается через P; в этот класс
очевидно не входят задачи, для решения которых необходим
экспоненциальный алгоритм.
Введём в рассмотрение класс NP задач, которые можно решить за
полиномиальное время с помощью недетерминированного алгоритма. В
отличие от детерминированного алгоритма, в котором для любого данного
состояния1 существует не более одного вполне определённого
следующего состояния, в недетерминированном алгоритме может быть
больше одного допустимого следующего состояния2. Отметим, что P NP .
Задача A является NP–трудной, если детерминированный
полиномиальный алгоритм её решения можно использовать для
получения детерминированного полиномиального алгоритма для каждой
задачи из NP. Другими словами, задача A является NP–трудной, если она
по крайней ме-ре так же трудна, как и любая задача в NP. NP–трудная
задача из NP на-зывается NP–полной; такая задача не труднее, чем любая
задача из NP.
1
Под состоянием понимаются адрес текущей команды и значения всех переменных.
2
Недетерминированные алгоритмы не являются вероятностными; они являются
алгоритмами, которые могут находиться одновременно во многих состояниях.

138
О.Н. Паулин. Основы теории алгоритмов

4.3. Методы повышения эффективности алгоритмов

Эффективность алгоритма определяется мерой близости к


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

4.3.1. Рекурсия

Процедуру, которая прямо или косвенно обращается к себе,


называют рекурсивной. Применение рекурсии часто позволяет давать
более ясные и сжатые описания алгоритмов, чем это было бы возможно
без неё.
Рекурсия является особенно мощным средством в математических
определениях. Рассмотрим несколько примеров таких определений.
1. Натуральные числа:
а) 1 есть натуральное число;

139
Глава 4. ПОСТРОЕНИЕ И АНАЛИЗ АЛГОРИТМОВ

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


(х'=x+1 – см. c. 64).
2. Древовидные структуры:
а) О  есть дерево (называемое пустым деревом);
б) если Т1 и Т2  деревья, то

есть дерево (нарисованное сверху вниз).


3. Функция факториал n! для неотрицательных целых чисел:
а) 0!=1;
б) если n>0, то n! = n(n-1)!
Мощность рекурсии связана с тем, что она позволяет определять
бесконечное множество объектов с помощью конечного высказывания.
Точно так же бесконечные вычисления можно описать с помощью
конечной рекурсивной программы, даже если эта программа не содержит
явных циклов. Однако лучше всего использовать рекурсивные алгоритмы
в тех случаях, когда решаемая задача, или вычисляемая функция, или
обрабатываемая структура данных определена с помощью рекурсии. В
общем виде рекурсивную программу P можно изобразить как композицию
R базовых операторов Si (не содержащих P) и самой P:
PR[Si, P]. (4.18)
С процедурой принято связывать некоторое множество локальных
объектов, т.е. переменных, констант, типов и процедур, которые определе-
ны только в этой процедуре, а вне её не существуют или не имеют
смысла. Каждый раз, когда такая процедура рекурсивно вызывается, для
неё создаётся новое множество локальных переменных. Хотя они имеют
те же имена, что и соответствующие элементы множества локальных

140
О.Н. Паулин. Основы теории алгоритмов

переменных, созданного при предыдущем обращении к этой процедуре,


значения переменных различны. Следующие правила области действия
идентификаторов позволяют исключить какой-либо конфликт при
использовании имён: идентификаторы всегда ссылаются на множество
переменных, созданное последним; то же правило относится и к
параметрам процедуры.
В качестве примера рекурсивного алгоритма рассмотрим процедуру
прохождения двоичного дерева во внутреннем порядке с присвоением
узлам соответствующего номера.
Алгоритм 4.1. Нумерация узлов двоичного дерева в соответствии с
внутренним порядком (INOR  INternal ORder).
ВХОД. Двоичное дерево, представленное массивами LES (Left Son-
левый сын) и RIS (RIght Son  правый сын).
ВЫХОД. Массив, называемый NUM (NUMber - номер), такой, что
NUM[i] - номер узла i во внутреннем порядке.
МЕТОД. Кроме массивов LES, RIS и NUM, алгоритм использует
глобальную переменную COT (COunT  счёт), значение которой  номер
очередного узла в соответствии с внутренним порядком. Начальное
значение переменной СОТ является 1. Параметр ND (NoDe  узел)
вначале равен RT (RooT  корень). На рис. 4.1а изображена СА основного
алгоритма, а на рис. 4.1б  процедура INOR(ND), которая применяется
рекурсивно.
Основной алгоритм таков:
begin
COT := 1;
ND := RT;
INOR (ND)
еnd.

Процедура INOR (ND) записывается следующим образом:

141
Глава 4. ПОСТРОЕНИЕ И АНАЛИЗ АЛГОРИТМОВ

procedure INOR (ND)


begin
if LES[ND]  0 then INOR (LES[ND]);
NUM[ND] := COT;
COT := COT + 1;
if RIS[ND]  0 then INOR (RIS[ND])
end.

Рекурсия даёт несколько преимуществ и, прежде всего, простоту


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

142
О.Н. Паулин. Основы теории алгоритмов

Рассмотрим возможный вариант того же алгоритма, но без


использования рекурсии.
Алгоритм 4.2. Вариант алгоритма 4.1 без рекурсии.
ВХОД. Тот же, что и у алгоритма 4.1.
ВЫХОД. Тот же, что и у алгоритма 4.1.
МЕТОД. При прохождении дерева в стеке запоминаются все узлы,
которые ещё не были занумерованы и которые лежат на пути из корня в
узел, рассматриваемый в данный момент. При переходе из узла v к его
левому сыну узел v запоминается в стеке. После нахождения левого
поддерева для v узел v нумеруется и выталкивается из стека. Затем
нумеруется правое поддерево для v.
При переходе из v к его правому сыну узел v не помещается в стек,
поскольку после нумерации правого поддерева не нужно возвращаться в
v, а следует вернуться к тому предку узла v, который еще не занумерован
(т.е. к ближайшему предку w узла v такому, что v лежит в левом поддереве
для w). Схема этого алгоритма приведена на рис. 4.2.
Запишем нерекурсивный вариант процедуры обхода дерева.

begin
COT := 1 ;
ND := RT;
STK := 0;
L: while LES[ND]  0 do
begin
PUSH; (* затолкнуть узел в стек *)
ND := LES[ND]
end;
C: NUM[ND] := COT;
COT := COT +1;
if RIS[ND] 0 then
begin
ND := RIS[ND];
goto L;
end;

143
Глава 4. ПОСТРОЕНИЕ И АНАЛИЗ АЛГОРИТМОВ

if STK 0 then
begin
ND := ETS; (*ETS - номер элемента в вершине стека*)
POP; (* вытолкнуть узел из стека *)
goto C;
end
end.

Рассмотрим процедуру INOR из алгоритма 4.1. Когда, например, она


вызывает себя с фактическим параметром LES[ND], она запоминает в
стеке адрес нового значения параметра ND вместе с адресом возврата,

144
О.Н. Паулин. Основы теории алгоритмов

который указывает, что по окончании работы этого вызова выполнение


программы продолжается со второй строки 2. Таким образом, переменная
ND эффективно заменяется на LES[ND], где бы ни входила ND в это
определение процедуры.
В алгоритме 4.2 сделано так, что окончание выполнения вызова
INOR с фактическим параметром RIS[ND] завершает выполнение и самой
вызывающей процедуры. Поэтому не обязательно теперь хранить адрес
возврата или узел (ND) в стеке, если фактическим параметром является
RIS[ND].
Подобно операторам цикла, рекурсивные процедуры могут привести
к бесконечным вычислениям. Поэтому необходимо рассмотреть проблему
окончания работы процедур. Очевидно, что для того чтобы работа когда-
либо завершилась, необходимо, чтобы рекурсивное обращение к
процедуре Р подчинялось условию В, которое в какой-то момент перестаёт
выполняться. Поэтому более точно схему рекурсивных алгоритмов можно
представить в виде
P  if B then R[Si, P], (4.19)
или
P  R [Si, if B then P]. (4.20)
Наиболее надежный способ обеспечить окончание процедуры 
связать с Р параметр (значение) n и рекурсивно вызвать Р со значением
этого параметра n-1. Тогда замена условия В на n>0 гарантирует
окончание работы. Это можно изобразить следующими схемами программ:
P(n)  if n>0 then R [Si, P(n-1)], (4.21)
P(n)  R [Si, if n>0 then P(n-1)]. (4.22)
На практике нужно обязательно убедиться, что наибольшая глубина
рекурсии не только конечна, но и не слишком велика. Дело в том, что при
каждом рекурсивном вызове процедуры Р для размещения её переменных

145
Глава 4. ПОСТРОЕНИЕ И АНАЛИЗ АЛГОРИТМОВ

выделяется некоторая память. Кроме этих локальных переменных, нужно


ещё сохранять текущее состояние вычислений, чтобы вернуться к нему,
когда закончится выполнение новой активации Р и нужно будет вернуться
к старой. Другими словами, рекурсивный алгоритм работает в два
прохода: сначала алгоритм "уходит" на всю глубину, резервируя память
для переменных процедуры, а затем возвращается, вычисляя их значения.
Рекурсивные алгоритмы наиболее пригодны в случаях, когда
поставленная задача или используемые данные определены рекурсивно.
Но это не значит, что при наличии таких рекурсивных определений
лучшим способом решения задачи непременно является рекурсивный
алгоритм.
Программы, в которых следует избегать использования рекурсии,
можно охарактеризовать схемой, изображающей их строение:
P  if B then (S; P), (4.23)
или эквивалентной ей
P  (S; if B then P). (4.24)
Эти схемы естественно применять в тех случаях, когда вычисляемые
значения определяются с помощью простых рекуррентных соотношений.
Рассмотрим известный пример вычисления факториалов fi = i!:
i = 0, 1, 2, 3, 4, 5,... ,
f = 1, 1, 2, 6, 24, 120,... (4.25)
"Нулевое" число определяется явным образом как f0=1, а
последующие числа обычно определяются рекурсивно - с помощью
предшествующего значения:
fi+1 = (i+1)·fi . (4.26)
Эта формула предполагает использование рекурсивного алгоритма
для вычисления n-го факториального числа. Если ввести две переменные
I и F для значений i и fi на i-м уровне рекурсии, то для перехода к

146
О.Н. Паулин. Основы теории алгоритмов

следующему числу в последовательности (4.25) понадобятся такие


вычисления:
I := I+1; F := F*I; (4.27)
подставив (4.27) вместо S в (4.23), получим рекурсивную программу
P  if I<n then (I := I+1; F := F*I; P);
I := 0; F := 1; (4.28)
Первую строку в (4.28) можно записать следующим образом:

procedure P;
begin
if I<n then
begin
I := I+1; F := F*I; P
end
end. (4.29)

Вместо процедуры можно ввести чаще используемую процедуру-


функцию, т.е. некоторую процедуру, с которой явно связывается
вычисляемое значение. Поэтому функцию можно использовать
непосредственно как элемент выражения. Тем самым переменная F
становится излишней, а роль I выполняет явный параметр процедуры.

function F(I);
begin
if I>0 then F(I) := F(I-1)*I
else F(I) := 1
end. (4.30)

Совершенно ясно, что здесь рекурсию можно заменить обычной


итерацией, а именно программой

I := 0; F := 1;
while I<n do
begin

147
Глава 4. ПОСТРОЕНИЕ И АНАЛИЗ АЛГОРИТМОВ

I := I+1; F := F*I
end. (4.31)

В общем виде программу, соответствующую схеме (4.23) или (4.24),


нужно преобразовать так, чтобы она соответствовала схеме
P  (x := xo; while B do S). (4.32)
Есть и другие, более сложные рекурсивные схемы, которые можно и
должно переводить в итеративную форму. Примером служит вычисление
чисел Фибоначчи, определяемых с помощью рекуррентного соотношения
fibn+1 = fibn + fibn-1 (4.33)
для n>0 и fib1=1, fib0=0.
При непосредственном подходе получим программу

function Fib(n);
begin
if n=0 then Fib(n) := 0 else
if n=1 then Fib(n) := 1 else
Fib(n) := Fib(n-1) + Fib(n-2)
еnd. (4.34)

При вычислении fibn обращение к функции Fib(n) приводит к


рекурсивным активациям этой процедуры. Сколько раз? Можно заметить,
что каждое обращение при n>1 приводит к двум дальнейшим обращениям,
т.е. общее число обращений растёт экспоненциально. Ясно, что такая
программа непригодна для практического использования.
Однако очевидно, что числа Фибоначчи можно вычислить по
итеративной схеме, при которой использование вспомогательных
переменных x=fibi и y=fibi-1 позволяет избежать повторного вычисления
одних и тех же значений. Тогда программа принимает вид

/* вычисляем x=fibn для n>0 */


i := 0; x := 1; y := 0;
while i<n do

148
О.Н. Паулин. Основы теории алгоритмов

begin
z := x; i := i+1;
x :=x+y; y := z
end.

Отметим, что три присваивания x, y и z можно выразить всего лишь


двумя присваиваниями без использования вспомогательной переменной z:
x := x+y; y := x-y.
Итак, следует избегать рекурсии, когда имеется очевидное
итеративное решение поставленной задачи. Но это не означает, что
всегда нужно избавляться от рекурсии любой ценой; во многих случаях
она вполне применима. Тот факт, что рекурсивные процедуры можно
реализовать на нерекурсивных по сути машинах, говорит о том, что для
практических целей любую рекурсивную программу можно преобразовать
в чисто итеративную. Но это требует явного манипулирования со стеком
рекурсий, и эти операции до такой степени заслоняют суть программы, что
понять её становится очень трудно. Следовательно, алгоритмы, которые
по своей сути скорее рекурсивны, чем итеративны, нужно представлять в
виде рекурсивных процедур.
Примеры рекурсивных процедур (построение кривых Гильберта,
Серпинского, алгоритмы с возвратом и др.) – см. в [9].

4.3.2. Приём “разделяй и властвуй”

Для решения сложной задачи ее часто разбивают на части, находят


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

149
Глава 4. ПОСТРОЕНИЕ И АНАЛИЗ АЛГОРИТМОВ

Рассмотрим для примера задачу нахождения наибольшего (MAXEL)


и наименьшего (MINEL) элементов массива S, содержащего n элементов.
Для простоты будем считать, что n есть степень числа 2. Очевидно, что
MAXEL и MINEL следует искать по отдельности. Например, следующая
процедура находит MAXEL множества S, производя n-1 сравнений его
элементов:
begin
MAXEL := произвольный элемент из S;
for все другие элементы из S do
if x> MAXEL then MAXEL := x
end.

Аналогично можно найти MINEL из остальных n-1 элементов,


произведя n-2 сравнений. Итак, для нахождения максимального и
минимального элементов при n2 потребуется 2n-3 сравнений.
Применяя прием "разделяй и властвуй", можно было бы разбить
множество S на два подмножества S1 и S2 по n/2 элементов в каждом.
Тогда описанный выше алгоритм нашёл бы MAXEL и MINEL в каждой из
двух половин с помощью рекурсии. MAXEL и MINEL множества S можно
было бы определить, произведя еще два сравнения - наибольших
элементов в S1 и S2 и наименьших элементов в них.
Алгоритм 4.3. Нахождение наибольшего и наименьшего элементов
множества.
ВХОД. Множество S из n элементов, где n - степень числа 2, n2.
ВЫХОД. Наибольший (MAXEL) и наименьший (MINEL) элементы
множества S.
МЕТОД. К множеству S применяется рекурсивная процедура
MAXMIN. Она имеет один аргумент, представляющий собой множество S,
такое, что |S|=2k при некотором k1, и вырабатывает пару (a,b), где a -
MAXEL, b - MINEL.

150
О.Н. Паулин. Основы теории алгоритмов

Процедура MAXMIN приведена ниже.


procedure MAXMIN(S)
1. if |S|=2 then
begin
2. пусть s={a, b};
3. return ( MAXEL(a, b), MINEL(a, b) )
end
else
begin
4. разбить S на два равных подмножества S1 и S2
5. (max1, min1)MAXMIN(S1);
6. (max2, min2)MAXMIN(S2);
7. return (MAXEL(max1, max2), MINEL(min1, min2) )
end.

Пусть Т(n) - число сравнений элементов множества S, которые надо


произвести в процедуре MAXMIN, чтобы найти наибольший и наименьший
элементы n-элементного множества. Ясно, что Т(2)=1. Если n>2, то Т(n) -
общее число сравнений, выполненных в двух вызовах процедуры MAXMIN
(строки 5 и 6), работающей на множестве размера n/2, и еще два
сравнения в строке 7. Таким образом,
1 при n = 2
T (n )   . (4.35)
 2T (n / 2)  2 при n > 2

Решение рекуррентных уравнений (4.35) - функция Т(n)=3n/2 - 2, что


можно доказать индукцией по n.
Можно показать, что для одновременного поиска наибольшего и
наименьшего элементов n-элементного множества надо выполнить не
менее 3n/2 - 2 сравнений его элементов. Следовательно, алгоритм 4.3
оптимален в смысле числа сравнений между элементами из S, когда n
есть степень числа 2.
Временная сложность процедуры определяется числом и размером
подзадач и в меньшей степени работой, необходимой для разбиения
данной задачи на подзадачи. Так как рекуррентные уравнения вида (4.35)

151
Глава 4. ПОСТРОЕНИЕ И АНАЛИЗ АЛГОРИТМОВ

часто возникают при анализе рекурсивных алгоритмов типа “разделяй и


властвуй”, рассмотрим решение таких уравнений в общем виде.
Теорема 4.1. Пусть a, b, c - неотрицательные постоянные. Решением
рекуррентных уравнений вида
b при n = 1
T (n )   , (4.36) где n- степень
aT (n / c )  bn при n > 1

числа с, является
O(n), если a < c

T (n)   O(n log 2 n), если a = c . (4.37)
 O(nlogc a), если а > c

Из теоремы вытекает, что разбиение задачи размера n (за линейное
время) на две подзадачи размера n/2 даёт алгоритм сложности О(nlog2n).
Если бы подзадач было 3, 4 или 16, то получился бы алгоритм сложности
порядка nlog23 , n2 или n4 соответственно. С другой стороны, разбиение
задачи на 4 подзадачи размера n/4 даёт сложность порядка nlog2n, а на 9 и
16 - порядка n log 2 3 и n2 соответственно.
Если n не является степенью числа С, то обычно можно вложить
задачу размера n в задачу размера n', где n' - наименьшая степень числа
С, большая n. Поэтому порядки роста, приведенные в теореме 4.1,
сохраняются для любого n. На практике часто можно разработать
рекурсивные алгоритмы, разбивающие задачи произвольного размера на
С равных частей, где С велико, насколько возможно. Эти алгоритмы, как
правило, эффективнее (на постоянный множитель) тех, которые
получаются путем представления размера входа в виде ближайшей
сверху степени числа С.

4.3.3 Принцип балансировки

152
О.Н. Паулин. Основы теории алгоритмов

Разбиение задачи на подзадачи равных размеров с целью


поддержания равновесия - основной руководящий принцип при разработке
эффективного алгоритма. Балансировка полезна не только при
использовании приема “разделяй и властвуй”; например, эффективные
алгоритмы получаются в результате балансировки размеров поддеревьев,
а также весов двух операций.
Рассмотрим задачу расположения целых чисел в порядке
неубывания. Простейший способ сделать это - найти наименьший
элемент, исследуя всю последовательность и затем меняя местами
наименьший элемент с первым. Процесс повторяется на остальных n-1
элементах, и это повторение приводит к тому, что второй наименьший
элемент оказывается на втором месте. Повторение процесса на
остальных n-2, n-3,..., 2 элементах сортирует всю последовательность
(этот процесс известен под названием “сортировка простым выбором“).
Этот алгоритм приводит к рекуррентным уравнениям
0 при n = 1
T (n )   (4.38)
T (n  1)  n  1 при n > 1

для числа сравнений, произведенных между сортируемыми элементами.


Решением этих уравнений служит Т(n)=n(n-1)/2, что составляет О(n2).
Хотя этот алгоритм можно считать рекурсивным применением
приема “разделяй и властвуй”, он не эффективен для больших n,
поскольку задача разбивается на неравные части (одна подзадача имеет
размер i, а другая - n-i, где i - номер шага процесса решения задачи).
Эффективнее будет решение, если разбить задачу на две подзадачи c
размерами примерно n/2. Это выполняется методом, известным как
сортировка слиянием.
Рассмотрим последовательность целых чисел х1, х2,..., хn. Снова
предположим для простоты, что n - степень числа 2. Один из способов
упорядочить эту последовательность - разбить ее на две

153
Глава 4. ПОСТРОЕНИЕ И АНАЛИЗ АЛГОРИТМОВ

подпоследовательности х1, х2, ... , хn/2 и xn/2+1, ... , хn, упорядочить каждую
из них и затем слить их. Под “слиянием” понимают объединение двух уже
упорядоченных последовательностей в одну упорядоченную
последовательность.
Алгоритм 4.4. Сортировка слиянием
ВХОД. Последовательность чисел х1, x2,..., хn, где n - степень числа 2.
ВЫХОД. Последовательность чисел у1, y2,..., уn, которая является
перестановкой входа и удовлетворяет неравенствам y1y2...yn .
МЕТОД. Применим процедуру MERGE(S, Т) (merge - слияние),
входом которой служат две упорядоченные последовательности S и Т, а
выходом - последовательность Q элементов из S и Т, расположенных в
порядке неубывания. Поскольку S и Т упорядочены, MERGE требует
сравнений не больше, чем сумма длин S и Т без единицы (|S| + |T| -1).
Работа этой процедуры состоит в выборе большего из наибольших
элементов, остающихся в S и Т, и в последующем удалении выбранного
элемента и перезаписи его в Q. В случае совпадения можно отдавать
предпочтение последовательности S. Кроме того, применяется процедура
SORT(i, j) (см. ниже), сортирующая подпоследовательность xi, xi+1, ... , xj в
предположении, что она имеет длину 2k для некоторого k0. Для
сортировки исходной последовательности вызывается процедура
SORT(1, n).

procedure SORT(i, j)
if i=j then return xi
else
begin
m(i+j-1)/2;
return MERGE(SORT(i, m), SORT(m+1, j))
end.

154
О.Н. Паулин. Основы теории алгоритмов

Подсчёт числа сравнений в алгоритме 4.4 приводит к рекуррентным


уравнениям
0 при n = 1
T (n )   , (4.39)
 2T (n / 2)  n  1 при n > 1

решением которых по теореме 4.1 является Т(n)=О(nlog2n). Для больших


n сбалансированность размеров подзадач даёт значительную выгоду.
Аналогичный анализ показывает, что общее время работы процедуры
SORT, затрачиваемое не только на сравнение, также есть О(nlog2n).

4.3.4. Динамическое программирование

Рекурсивная техника полезна, если задачу можно разбить на


подзадачи за разумное время, а суммарный размер подзадач будет
небольшим. Из теоремы 4.1 вытекает, что если сумма размеров подзадач
равна an для некоторой постоянной а>0, то рекурсивный алгоритм,
вероятно, имеет полиномиальную временную сложность. Но если
очевидное разбиение задачи размера n сводит её к n задачам размера n-
1, то рекурсивный алгоритм, вероятно, имеет экспоненциальную
сложность.
В этом случае часто можно получить более эффективные алгоритмы
с помощью табличной техники, называемой динамическим
программированием.
В сущности, при динамическом программировании вычисляется
решение для всех подзадач. Вычисление идет от малых подзадач к
большим, и ответы запоминаются в таблице. Преимущество этого метода
состоит в том, что раз уж задача решена, её ответ где-то хранится и
никогда не вычисляется заново.
Рассмотрим эту технику на примере вычисления произведения k
матриц М=М1 x М2 x...x Мk , где Mi - матрица с ri-1 строками и ri столбцами.

155
Глава 4. ПОСТРОЕНИЕ И АНАЛИЗ АЛГОРИТМОВ

Порядок, в котором эти матрицы перемножаются, может существенно


сказаться на общем числе операций, требуемых для вычисления М,
независимо от алгоритма, применяемого для умножения матриц.
Пример 4.1. Будем считать, что умножение (р xq)-матрицы на (q x r)-
матрицу требует pqr операций, и рассмотрим произведение (в квадратных
скобках указаны размерности матриц).
М= М1 x М2 x М3 x М4
[10 x 20] [20 x 50] [50 x 1] [1 x 100]
Если вычислять М в порядке М1x(М2x(М3xМ4)), то потребуется 125000
операций, тогда как вычисления в порядке (М1x(М2xМ3))xМ4 занимают лишь
2200 операций.
Процесс перебора всех порядков, в которых можно вычислить
рассматриваемое произведение n матриц с целью минимизации числа
операций, имеет экспоненциальную сложность, что при больших n
практически неприемлемо. Однако динамическое программирование
приводит к алгоритму сложности О(n3).

4.3.5. Алгоритмы с возвратом

Особенно интересный раздел программирования - это задачи из


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

156
О.Н. Паулин. Основы теории алгоритмов

экспоненциально, в зависимости от заданного параметра. Соответственно


увеличивается стоимость поиска. Часто дерево поиска можно обрезать,
используя только эвристические соображения, и тем самым сводить
количество вычислений к разумным пределам.
Далее на примере задачи о ходе коня рассматривается общий
принцип разбиения таких задач на подзадачи и использование в них
рекурсии.
Задача “Обход конем шахматной доски nxn”. Конь помещается на
поле с начальными координатами Х0, У0. Нужно покрыть ходами коня всю
доску (осуществить обход доски) за n2-1 ход, при том, что каждое поле
посещается ровно один раз.
Эта задача покрытия n2 полей сводится к более простой: или
выполнить очередной ход, или установить, что никакой ход невозможен.
Характерная черта этого алгоритма состоит в том, что он
предпринимает какие-то шаги по направлению к общему решению, эти
шаги фиксируются (записываются), но можно возвращаться обратно и
стирать записи, если оказывается, что шаг не приводит к решению, а
заводит в “тупик”. Такое действие называется возвратом.
Пусть число возможных дальнейших путей на каждом шаге конечно и
фиксировано (например, равно m); пусть используется явный параметр
уровня, обозначающий глубину рекурсии и допускающий простое условие
окончания. Тогда схема, типичная для задач подобного рода, может быть
представлена так:
procedure try (i)
begin k := 0; (*инициировать выборку возможных шагов*)
repeat k := k+1; выбрать k-й возможный путь;
if приемлемо then
begin записать его;
if i < n then (*решение неполно*)
begin try (I+1); (*попробовать очередной шаг*)
if неудачно then стереть запись

157
Глава 4. ПОСТРОЕНИЕ И АНАЛИЗ АЛГОРИТМОВ

end
end
until удачно  (k=m)
end.
4.4. Построение и анализ алгоритма

4.4.1. Как разрабатывать алгоритм?

Авторы алгоритмов редко раскрывают тайны своего творчества – они


или не считают нужным тратить на это время, или не задумываются над
этим; можно только догадываться о том, как они пришли к идеям своих
алгоритмов. Это общая ситуация, характерная для решения любой
проблемы.
Однако всегда находились математики (и представители других
наук), для которых вопрос: «Как сформировалась идея?» и более общий
вопрос: «Как вообще формировать идеи решения или изобретения?» был
важнее решения конкретной задачи (проблемы). Многие выдающиеся
математики пытались сформулировать принципы и методы решения
любой задачи. Среди них первым можно отметить знаменитого греческого
математика Паппа, который жил предположительно около 300 г. н. э. В
седьмом томе своего "Математического сборника" он говорит об отрасли
науки, названной им эвристикой, так (в свободном изложении): "То, что
называют эвристикой, можно кратко определить как особое собрание
принципов, предназначенное для тех, кто после изучения «Начал»
Евклида имеет желание научиться решать математические задачи…".
Эвристика – не совсем чётко очерченная область науки, которая
находится на стыке логики, искусственного интеллекта, философии и
психологии. Цель эвристики – исследовать методы и правила, как делать
открытия и изобретения. Отдельные высказывания о таком исследовании
можно уже обнаружить у комментаторов Евклида.

158
О.Н. Паулин. Основы теории алгоритмов

Наиболее известные попытки создать стройную систему эвристики


принадлежат Р. Декарту (1596 – 1650) и Г. Лейбницу (1646 – 1716) - двум
великим математикам и философам. Первый попытался разработать
универсальный метод решения задач, однако его «Правила для
направления ума» остались неоконченными. Второй намеревался
написать «Искусство изобретения», но не осуществил своего намерения;
однако многочисленные отрывки, разбросанные в его трудах, показывают,
что у него были интересные мысли по этому вопросу. Он неоднократно
подчеркивал значение этого вопроса; так, он писал: «Нет ничего важнее,
чем умение найти источник изобретения, – на мой взгляд, это еще
интереснее, чем само изобретение».
Б. Больцано (1781 – 1848), логик и математик, оставил интересное и
подробное изложение эвристики; он предпослал изложению введение, в
котором написал: «… я приложу усилия к тому, чтобы ясно изложить
правила и способы исследования, которыми руководствуются все
способные люди …».
Следующим в этом ряду назовём Д. Пойа, не столько великого
математика, сколько очень талантливого Учителя. Он написал книгу «Как
решать задачу?» [14], весьма полезную как учителю, так и ученику не
только средней, но и студенту высшей школы (в последнем случае
рекомендуем также познакомиться со второй его книгой [15]). По её
мотивам написаны книги [2] и [16].
Задача построения алгоритма, как и любая задача, может быть
решена в соответствии с методикой, разработанной Д. Пойа [14]. Она
заключается в разбиении процесса решения на этапы:
 постановка задачи (понимание постановки задачи);
 составление плана решения (анализ);
 реализация плана (синтез);

159
Глава 4. ПОСТРОЕНИЕ И АНАЛИЗ АЛГОРИТМОВ

 осмысление полученного решения («взгляд назад»)


и в системе чётко сформулированных вопросов к каждому из
перечисленных этапов. Назначение вопросов – стимуляция умственной
деятельности. В приложении В приведена таблица, заимствованная из
[14], в которой к каждому этапу процесса решения даются вопросы и
советы, цель которых – помочь решить задачу. Приведём краткие
замечания к каждому этапу.

Понимание постановки задачи

Серьёзное внимание должно быть уделено каждому этапу, но


особого внимания заслуживает постановка задачи, которая заключается в
ясно сформулированных требованиях (условиях) к данным, которые
подлежат обработке. Недаром говорят, что правильно поставленная
задача – это половина её решения. Если задача сформулирована не вами,
то её надо понять настолько ясно, чтобы можно было считать, что
поставить её могли бы и вы. При этом важно понять связь данных и
условия.
Важно не только понять задачу, важно и хотеть её решить. Прежде
всего, должна быть понятна словесная формулировка задачи: что
неизвестно? Что дано? В чём состоит условие? Нужно внимательно,
многократно и с разных сторон рассмотреть главные элементы задачи.
Если с задачей связана какая-либо геометрическая фигура,
необходимо сделать чертёж и указать на нём неизвестное и данные.
Если необходимо как-нибудь назвать эти объекты, нужно ввести
подходящие обозначения; уделяя определённое внимание подходящему
выбору символов, решающий задачу вынужден сосредоточить свои мысли
на объектах, для которых следует подыскать символы.

160
О.Н. Паулин. Основы теории алгоритмов

На этой предварительной стадии полезным может оказаться вопрос:


возможно ли удовлетворить условию? Притом, что мы удовлетворимся
лишь временным ответом, догадкой.

Составление плана решения

При построении плана можно использовать многократно


апробированный в проектировании (а построение – это разновидность
проектирования) метод «сверху–вниз», когда идея на каждом этапе всё
более детализируется. Этот же метод применяется и в технологии
программирования.
Можно считать, что есть план, если известно, хотя бы в общих
чертах, какие вычисления или построения придётся проделать, чтобы
получить неизвестное. Путь от понимания постановки задачи до
представления себе плана решения может быть долгим и извилистым.
Главный шаг на пути к решению задачи состоит в том, чтобы выработать
идею плана. Эта идея может появляться постепенно. Или она может
возникнуть вдруг, в один миг, после, казалось бы, безуспешных попыток и
продолжительных сомнений (блестящая идея).
Хорошие идеи имеют своим источником прошлый опыт и ранее
приобретенные знания. Уместно начать работу с вопроса: известна ли
вам какая-нибудь родственная задача? Трудность здесь в том, что
обычно оказывается слишком много задач, имеющих с данной какие-либо
общие черты. Нужно выбрать те задачи, которые имеют существенную
черту, и таковой является неизвестное: вспомните знакомую задачу с
тем же или подобным неизвестным. Если повезёт и удастся вспомнить
уже решённую такую задачу, то теперь надо извлечь из неё всё, что
можно: вот задача, сходная с данной и уже решенная. Нельзя ли
воспользоваться ею?

161
Глава 4. ПОСТРОЕНИЕ И АНАЛИЗ АЛГОРИТМОВ

Если подобные вопросы не помогают, необходимо начать поиски


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

Осуществление плана

Чтобы придумать план, найти идею решения, требуется очень


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

162
О.Н. Паулин. Основы теории алгоритмов

При реализации плана необходимо проверять каждый свой шаг.


Убедиться в правильности некоторого шага в рассуждениях можно либо
интуитивно, либо логически. Можно сосредоточить внимание на
рассматриваемом утверждении до тех пор, пока оно не станет столь
явным и отчётливым, что не останется никакого сомнения в правильности
выбранного шага. Но можно поступить и иначе, выводя утверждение по
логическим правилам. Самое важное, чтобы была твердая убеждённость в
правильности каждого шага. Однако надо понимать разницу между
«увидеть» и «доказать»: ясно ли вам, что предпринятый шаг правилен?
А в состоянии ли вы доказать, что он правилен?

Взгляд назад

Никакую задачу нельзя исчерпать до конца: если её решение


действительно нельзя усовершенствовать, то, во всяком случае, всегда
можно глубже осмыслить её решение.
Проверка каждого шага (хода) решения ещё не гарантирует
отсутствие ошибок; важна проверка и результата решения. Особенно
важно не проглядеть какой-либо быстрый интуитивный способ (если он
имеется) проверки результата или хода решения. Полезен вопрос: нельзя
ли получить тот же результат иначе? Конечно, короткое интуитивное
рассуждение устроит в большей мере, чем длинное и тяжеловесное:
нельзя ли усмотреть его с первого взгляда? Когда оглядываемся назад
на решение задачи, представляется естественная возможность исследо-
вать связь данной задачи с другими задачами.
Применительно к построению алгоритма С. Гудман и С. Хидетниеми
[2] сформулировали аналогичные подход и вопросы, которые
воспроизведены ниже.

163
Глава 4. ПОСТРОЕНИЕ И АНАЛИЗ АЛГОРИТМОВ

Основные этапы полного построения алгоритма

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

Постановка задачи

Прежде чем понять задачу, следует её точно сформулировать. Это


условие само по себе не является достаточным для понимания задачи, но
оно абсолютно необходимо. Для плохо сформулированных задач полезны
следующие вопросы:
 Понятна ли терминология, используемая в предварительной
формулировке?
 Что дано? Что нужно найти? В чём состоит условие?
 Каких данных не хватает? Все ли они нужны?
 Являются ли какие-то имеющиеся данные бесполезными?
 Какие сделаны допущения?
Возможны и другие вопросы в зависимости от конкретной задачи.
Часто после получения полных или частичных ответов на некоторые из
вопросов их приходится ставить повторно.

164
О.Н. Паулин. Основы теории алгоритмов

Построение модели

Задача чётко поставлена, теперь нужно сформулировать для неё


математическую модель. Это очень важный шаг в процессе решения, и его
надо хорошо обдумать. Выбор модели существенно влияет на остальные
этапы процесса решения.
Ясно, что невозможно предложить набор правил, автоматизирующих
стадию моделирования. Большинство задач должно рассматриваться
индивидуально. Тем не менее существует несколько полезных
руководящих принципов. Выбор модели – в большей степени дело
искусства, чем науки, и, вероятно, эта тенденция сохранится. Изучение
удачных моделей – это наилучший способ приобрести опыт в
моделировании.
Приступая к разработке модели, следует задать, по крайней мере,
два основных вопроса:
 Какие математические структуры больше всего подходят для
задачи?
 Существует ли решение аналогичной задачи?
Второй вопрос, возможно, самый полезный во всей математике. В
контексте моделирования он часто даёт ответ на первый вопрос.
Действительно, большинство решаемых в математике задач, как правило,
являются модификациями уже решённых. Поскольку большинство из нас
не обладает талантами великих математиков, то для продвижения вперёд
приходится руководствоваться накопленным опытом.
Сначала нужно рассмотреть первый вопрос. Необходимо описать
математически, что знаем, и что хотим найти. На выбор соответствующей
структуры оказывают влияние следующие факторы: 1) ограниченность
наших знаний относительно небольшим количеством структур; 2)

165
Глава 4. ПОСТРОЕНИЕ И АНАЛИЗ АЛГОРИТМОВ

удобство представления; 3) простота вычислений; 4) полезность


различных операций, связанных с рассматриваемой структурой или
структурами.
Сделав пробный выбор математической структуры, задачу следует
переформулировать в терминах соответствующих математических
объектов. Это будет одна из возможных моделей, если можно
утвердительно ответить на такие вопросы:
 Вся ли важная информация задачи хорошо описана
математическими объектами?
 Существует ли математическая величина, ассоциируемая с
искомым результатом?
 Выявлены ли какие-нибудь полезные отношения между объектами
модели?
 Можно ли работать с моделью? Удобно ли с ней работать?

Разработка алгоритма

Как только задача чётко поставлена и для неё построена модель,


следует приступить к разработке алгоритма её решения. Выбор метода
разработки, зачастую сильно зависящий от выбора моделей, может в
значительной степени повлиять на эффективность алгоритма решения.
Два разных алгоритма могут быть правильными, но очень сильно
отличаться по эффективности.
Многие программисты затрачивают относительно небольшое время
на стадию разработки алгоритма при создании программ – проявляется
желание как можно быстрее начать писать саму программу. Однако этому
побуждению не стоит поддаваться! На стадии разработки требуется
тщательное обдумывание; нужно также уделить внимание двум

166
О.Н. Паулин. Основы теории алгоритмов

предшествующим и первым трём следующим за стадией разработки


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

Правильность алгоритма

Доказательство правильности алгоритма – это один из наиболее


трудных, а иногда и особенно утомительных этапов создания алгоритма.
Вероятно, наиболее распространенная процедура доказательства
правильности программы – это прогон её на разных тестах. Если
выданные программой ответы могут быть подтверждены известными или
вычисленными вручную данными, возникает искушение сделать вывод,
что программа «работает». Однако этот метод редко исключает все
сомнения; может существовать случай, когда программа не сработает.
Можно предложить следующую методику доказательства
правильности алгоритма [2]. Предположим, что алгоритм описан в виде
последовательности шагов, например, от шага 0 до шага m. Постараемся
предложить некое обоснование правомерности для каждого шага. В
частности, может потребоваться лемма об условиях, действующих до и
после пройденного шага. Затем постараемся предложить доказательство
конечности алгоритма, при этом будут проверены все подходящие
входные данные и получены все подходящие выходные данные.
Подобный метод доказательства известен как «доказательство
исчерпыванием»; это самый грубый из всех методов доказательства.

167
Глава 4. ПОСТРОЕНИЕ И АНАЛИЗ АЛГОРИТМОВ

Подчеркнём тот факт, что правильность алгоритма ещё ничего не


говорит о его эффективности. Исчерпывающие алгоритмы редко бывают
хорошими во всех отношениях.

Реализация алгоритма

Как только алгоритм выражен, допустим, в виде последовательности


шагов и есть уверенность, что он правильный, настаёт черёд реализации
алгоритма, т. е. написание программы для ЭВМ.
Этот существенный шаг может быть довольно трудным. Во-первых,
трудность заключается в том, что очень часто отдельно взятый шаг
алгоритма может быть выражен в форме, которую трудно перевести
непосредственно в конструкции языка программирования (например, для
реализации данного шага потребуется целая подпрограмма). Во-вторых,
реализация может оказаться трудным процессом потому, что перед тем,
как начать писать программу, нужно построить целую систему структур
данных для представления важных аспектов используемой модели. Чтобы
сделать это, необходимо ответить, например, на такие вопросы:
 Каковы основные переменные? Каких они типов?
 Сколько нужно массивов и какой размерности?
 Имеет ли смысл пользоваться связанными списками?
 Какие нужны подпрограммы (возможно, уже записанные в памяти)?
 Каким языком программирования пользоваться?
Конкретная реализация может существенно влиять на требования к
памяти и на скорость алгоритма.
Другой аспект построения программной реализации – это
программирование “сверху–вниз”, которое состоит в преобразовании
алгоритма в такую последовательность всё более конкретизированных

168
О.Н. Паулин. Основы теории алгоритмов

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


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

4.4.2. Пример построения алгоритма

Рассмотрим следующую задачу: построить алгоритм, формирующий


всевозможные сочетания без повторений из символов (объектов),
заданных множеством M={a1, a2, …, aN}, за исключением тривиальных
случаев отсутствия символов либо наличия всех символов.
Данный алгоритм может быть представлен как процедура построения
собственного подмножества В множества А={Q0, Q1, …, QN}, где QJ –
множества элементов (наборов) всевозможных сочетаний без повторений
из N по J символов из M, J  0, N . Напоминаем, что в собственное
подмножество не входят как пустое, так и полное подмножества, т. е.
ВА , BQ0=, ВQN.
При разработке алгоритма воспользуемся методом "сверху-вниз" [2].
Очевидно, алгоритм должен включать в себя ввод исходного множества M
символов, инициализацию, т.е. подготовительную фазу процесса решения
задачи: вычисление констант, параметров, например, циклов,
предварительное преобразование данных и представление их в удобной
форме и т. п., процедуру PAQ формирования всех подмножеств QJ,

169
Глава 4. ПОСТРОЕНИЕ И АНАЛИЗ АЛГОРИТМОВ

J  1, N  1 наборов, а также вывода сформированных подмножеств (рис.


5.2,а).
Далее заметим, что если подмножества сгруппировать по признаку
одинаковой длины L наборов, то вывод сформированного подмножества
QL с наборами длины L может быть произведен в цикле по L (рис. 5.2,б).
Это даёт экономию памяти по сравнению с первым вариантом алгоритма.
Процедуру формирования подмножества наборов длины L назовём PQL.
Рассмотрим один из возможных способов формирования
подмножества QL. Очевидно, что новое подмножество (назовём его NQ)
может быть получено из предыдущего дописыванием к его элементам
символов из множества M. При этом нужно учесть, что если элемент qQL
содержит последний символ ls (last symbol), ls=M[N], то к нему уже больше
нечего дописывать. Поэтому имеет смысл из подмножества QL образовать
подмножество Q'L, не содержащее элементов, последний символ p
которых совпадает с ls; элементы, у которых p=ls, будем называть
особыми, а процедуру удаления особых элементов будем называть DEL.
Процедуру дописывания к выбранным из подмножества Q'L элементам q
символов xM назовём INS. Тогда процедура формирования нового
подмножества NQ из подмножества QL может быть реализована
последовательным применением процедур DEL и INS (рис. 5. 2,в).
Более тщательное рассмотрение этих процедур показывает, что
изолированная реализация каждой из них является избыточной по
временной сложности, так как и в первой, и во второй процедурах
используется перебор в цикле элементов подмножеств QL и Q'L, но
поочередно. Поэтому целесообразно организовать общий для них цикл по
К, где К - номер элемента qQL (рис.5.2,г). Тогда эти процедуры
необходимо несколько изменить и увязать их в общем цикле (рис. 5.2,д);
число повторений цикла определяется количеством элементов в

170
О.Н. Паулин. Основы теории алгоритмов

подмножестве QL: S=СLn. При выходе из цикла подмножествo NQ следует


переименовать в QL, т. е. переписать содержимое файла с именем NQ в
файл с именем QL (NQ обнуляется).
В процедуре DEL теперь будет решаться вопрос: "Удалить элемент
q=QL[K]?"; в случае положительного ответа процедура INS для элемента q
не выполняется. Удаление элемента q осуществляется по признаку cовпа-
дения символов: p=ls (предварительно для q определяется его последний
символ - Last Symbol of the Element - p = LSE(q)). Этот фрагмент СА
представлен на рис. 5.2,д. В случае отрицательного ответа элемент q
обрабатывается процедурой INS, где на его основе формируются новые
элементы множества NQ. Отметим здесь же, что имеет смысл вынести из
цикла по К и по L определение ls, как не зависящее от параметров этих
циклов.
Рассмотрим процедуру INS. Должно быть ясно, что к элементу q надо
дописывать не все символы хM, а только те, которые в множестве M идут
после символа, совпадающего с последним символом р элемента q. Так,
если M={a,b,c,d,e} и q= ab, то LSE(q)=b, и x{c,d,e}. Обозначим NM
множество следующих за р символов из множества M; для данного
примера NM={c,d,e}. Тогда процедура PNM - это формирование множества
NM, которое получается исключением из М всех символов слева до р
включительно. Далее можно непосредственно реализовать в цикле по I (I -
номер символа в множестве NМ) процедуру дописывания по одному
символу хNМ к элементу q, пока выбранный из NМ символ не окажется
последним; полученные новые элементы заносятся в множество NQ
слева направо. Затем производится переименование множества
(QL:=NQ). Этот фрагмент СА представлен на рис. 5.2,е.
Отметим, что процедуры LSE, РNМ, а также операции занесения
элемента в множество пока не определены. Конкретная их реализация

171
Глава 4. ПОСТРОЕНИЕ И АНАЛИЗ АЛГОРИТМОВ

зависит от выбора структуры данных; доработать данный алгоритм с


учётом выбранных структур данных можно самостоятельно. "За бортом"
остались также вопросы формирования имени подмножества, переменной
длины подмножества, ввода и вывода данных; решение этих вопросов в
большей мере определяется языком программирования.
"Соберём" схему алгоритма из отдельных, уже продуманных
фрагментов. При этом с целью стыковки фрагментов СА в неё включены
дополнительные блоки: блоки инициализации всех циклов (L:=1; K:=0;
I:=0), блоки общей инициализации (ls:=A[N]- это константа; NQ:=М - для
единообразия обработки подмножества Q1); блок вывода QL переставлен
в начало цикла по L с целью единообразия обработки всех подмножеств
QL, включая Q1.
Схему алгоритма построения собственного подмножества,
составленного из фрагментов СА с учётом их стыковки и замечаний о
неполноте проработки, можно получить самостоятельно.

172
О.Н. Паулин. Основы теории алгоритмов

173
Глава 4. ПОСТРОЕНИЕ И АНАЛИЗ АЛГОРИТМОВ

Рассмотрим контрольный пример. Пусть задано множество


символов М = {a, b, c, d, e}; последний элемент этого множества ls=e.
Имеем (выделены особые элементы)
Q0 =  - исключаем из рассмотрения; Число элементов
L = 1: Q1 = {a, b, c, d, e} = M; S = C15 = 5
Q’1 = {a, b, c, d}; S1 = C14 = 4
L = 2: Q2 = {ab, ac, ad, ae, bc, bd, be, cd, ce, de}; S = C25 = 10
Q’2 = {ab, ac, ad, bc, bd, cd}; S1 = C24 = 6
L = 3: Q3 = {abc, abd, abe, acd, ace, ade, bcd, bce, bde, cde}; S = C34 = 10
Q’3 = {abc, abd, acd, bcd}; S1 = C34 = 4
L = 4: Q4 = {abcd, abce, abde, acde, bcde}; S = C44 = 5
Q’4 = {abcd}; S1 = C44 = 1
L = 5: Q5 = {abcde} - исключаем из рассмотрения.
Проведём краткий анализ построенного алгоритма и определим
оценки его временной и ёмкостной сложности.
Временная сложность T(N)
Примем для простоты, что все операции (присваивание, добавление
1 при счёте, выборка из множества символа или элемента либо их
занесение в множество, конкатенация элемента и символа, вывод
элемента множества на печать либо экран) выполняются за единицу
времени. Тогда можно считать, что основное время тратится на
формирование подмножеств QL и Q’L, причём на Q’L примерно вполовину
меньше. Поэтому
1 N 1
T(N)= (C1N  C2N  ...  C N
N )  (C N 1  C N 1  ...  C N 1) =2 -2+2 -1
1 2 N N-1

1.5*2N-1 = O(2N).
Итак, оценкой временной сложности является T(N) = O(2N).
Ёмкостная сложность Е(N)

174
О.Н. Паулин. Основы теории алгоритмов

В худшем случае, если в памяти хранятся все подмножества QL и


Q’L (объёмом памяти, занимаемым простыми переменными и
параметрами цикла, а также исходным множеством М, пренебрегаем),
оценка ёмкостной сложности определяется величиной Е(N) = O(2 N). Если
же хранить в памяти только текущее подмножество Q’L, а по нему
формировать элементы подмножества QL и тут же их выводить, затем
формировать новое подмножество Q’L, при этом исключая особые
элементы в подмножестве QL, то оценкой ёмкостной сложности будет Е(N)
= O(СRN-1), где R=](N-1)/2[  целая часть дроби (N-1)/2. Действительно,
максимальное количество элементов содержит подмножество, длина
элементов которого равна округлённо половине числа символов без 1 в
множестве М.
Итак, оценкой ёмкостной сложности является T(N) = O(СRN-1).

175
Глава 4. ПОСТРОЕНИЕ И АНАЛИЗ АЛГОРИТМОВ

Заключение

При разработке эффективных алгоритмов необходимо использовать


как структуры высокого уровня (списки, очереди, стеки), так и мощную
технику рекурсии и динамического программирования. Важными являются
приёмы общего вида “разделяй и властвуй” и “балансировка”. Существуют
и другие методы повышения эффективности алгоритмов [2, 10, 13].
Разработчик алгоритмов должен изучить задачу с разных точек зрения,
пока не убедится, что получил алгоритм, наиболее подходящий для его
целей.
Полезными являются вопросы к основным этапам полного
построения алгоритма, сформулированные в подразд. 4.4.1.
Для анализа построенного алгоритма важно уметь применять
формулы, соотношения и асимптотики комбинаторики.

Контрольные вопросы. Упражнения

1. Каковы достоинства рекурсии? Каковы её недостатки? Когда


целесообразно её применение?
2. Чем отличается рекурсивное построение алгоритма от итера-
ционного? В каких случаях целесообразно применение итерационных
алгоритмов?
3. Постройте рекурсивный и нерекурсивный алгоритмы для пере-
мещений дисков на свободный стержень в задаче “Ханойские башни” (см.
подразд. 1.3.2).
4. Составьте списки констант, переменных, массивов и процедур с
расшифровкой их функционального назначения для задачи подразд. 4.4.2.

176
О.Н. Паулин. Основы теории алгоритмов

Докажите, что если S0 – количество особых элементов, то S1=S-S0= C LN 1

177