- •Управляющий граф содержит разветвления
- •Управляющий граф содержит цикл, порожденный оператором for
- •Управляющий граф содержит цикл, порожденный операторами while или repeat
- •Идеальные деревья
- •Представление длинных целых чисел
- •Алгоритм умножения 1 (классический)
- •Алгоритм умножения 2 (оптимизированный по времени)
Идеальные деревья
Как уже ясно, при данном n = 2k-1 существует только одно идеальное бинарное дерево. Это должно обескураживать. Ведь вырожденных деревьев с тем же количеством вершин 2(n-1). Значит ли это, что плохие деревья встречаются чаще, чем хорошие?
Анализ ситуации основан на анализе последовательностей значений и на том, какие деревья порождаются последовательностями. Как известно, имеется n! различных последовательностей (перестановок) из n чисел. Это значительно больше, чем количество:
деревьев с n вершинами. Следовательно, некоторые перестановки порождают одинаковые деревья. В связи с единственностью идеального дерева вопрос должен быть сформулирован так: сколько различных последовательностей чисел при помещении этих чисел в бинарное дерево порождают идеальное дерево? Именно отношение числа таких последовательностей к общему числу различных последовательностей (n!) и характеризуют частоту появления идеальных деревьев.
Конкретная последовательность диктует трассу алгоритма построения дерева. Все множество последовательностей, порождающих идеальные деревья, можно разделить на группы (подмножества) по типам трасс. Например, можно выделить группу последовательностей, строящих дерево по уровням: вершины не включаются в (i + 1)-й уровень, пока не завершен i-й уровень.
Другая группа - заполнение дерева сначала левого поддерева, а затем правого и т. д. - непересекающихся групп может быть выделено очень много (две последовательности отличаются, если в них изменен порядок хотя бы двух чисел).
Оценим количество последовательностей в первой группе - последовательностей, заполняющих дерево по уровням. Поскольку не для любого n существует идеальное дерево будем рассматривать только значения n = 2k-1. Пусть даны n различных чисел, которые нужно поместить в дерево. Их конкретные значения не влияют на анализ, но для определенности возьмем числа 1, 2, 3, ..., n. Каждое из них имеет определенное место в идеальном дереве, например, в четырехуровневом дереве при n = 15 (рисунок 2).
Рис.2.
Идеальное четырехуровневое дерево
Любая последовательность чисел 1, 2, 3, ..., 15 должна начинаться с 8, иначе построить дерево не удастся. Вторым членом последовательности может быть 4 или 12, третьим членом - то из двух чисел, которое не было выбрано в качестве второго члена. Четвертым членом может быть любое из чисел 2, 6, 10, 14, пятым - любое из трех, не выбранных в качестве четвертого и т. д.
Рассматривая заполнение по уровням, можно заметить, что:
на первом уровне выбора нет - имеется только одно число, 2(k-1), которое должно стать корнем;
на втором уровне имеется 2 вершины и 2! возможностей выбора второго и третьего элементов последовательности;
на третьем уровне 4 вершины и 4! возможностей выбора четвертого ... седьмого элементов последовательности;
на k-м уровне 2(k-1) вершин и 2(k-1)! возможностей выбора "хвоста" последовательности.
Таким образом, общее количество вариантов построения последовательностей чисел, приводящих к идеальному графу при поуровневом заполнении, равно:
(20)! (21)! (22)! (23)! ... (2k-1)!
При k=2, n=3 имеется 2! = 2 последовательности поуровневого заполнения и это единственные последовательности, приводящие к идеальному дереву. Заметим, что вырожденных деревьев получается больше - 4. При k = 3, n = 7 получаем 1! 2! 4! = 48 последовательностей (общее количество последовательностей, приводящих к идеальному графу любыми способами, равно 80 - найдено прямым подсчетом). Вырожденных деревьев - 64.
Картина резко меняется при дальнейшем увеличении n. Четыре уровня, n = 15: вырожденных деревьев - 34768; идеальных последовательностей - 1935360. Пять уровней, n = 31: вырожденных деревьев - 2147483648 ~ 2,15*109; количество идеальных последовательностей 1,9*1021.
Два последних примера показывают, что идеальные деревья могут встречаться гораздо чаще, чем вырожденные. Чтобы показать, что для достаточно больших n это всегда так, оценим отношение частот их появления; причем частоту появления идеальных деревьев оценим снизу, используя количество последовательностей, строящих дерево по уровням. Чуть менее громоздко формула будет выглядеть, если взять логарифм отношения частот. Факториалы, встречающиеся в формулах, заменим приближенными выражениями по формуле Стирлинга:
Тогда логарифм отношения частот равен:
В выводе последней формулы для упрощения выражения некоторые конечные суммы (геометрической прогрессии и др.) заменены бесконечными, что, учитывая быструю сходимость соответствующих рядов, дает относительно небольшую ошибку.
Из этой формулы видно, что при k > 4 значение логарифма отношения частот положительно и, следовательно, идеальные деревья встречаются чаще вырожденных. Более того, значение логарифма быстро растет, увеличивая разрыв частот.
К этому анализу необходимо добавить несколько замечаний. Во-первых, учтены были не все последовательности, порождающие идеальные деревья, т. е. на самом деле отношение частот еще больше. Во-вторых, идеальные и вырожденные деревья - это крайние случаи; между ними множество почти идеальных (заметим, что даже не для любого n существуют идеальные деревья) и почти вырожденных и некие средние случаи разреженных деревьев. В-третьих, реальный интерес при анализе сложности программ и алгоритмов имеют только случаи достаточно большого (сотни, тысячи и более) количества вершин в дереве, поэтому можно не прибегать к точным вычислениям, а использовать асимптотические оценки.
Из приведенных выше результатов можно с достаточной степенью уверенности сделать следующий вывод: хорошие деревья получаются в результате включения случайных элементов существенно чаще, чем плохие; поэтому, средние оценки сложности операций поиска и включения вероятнее всего будут логарифмическими.
На следующем шаге мы рассмотрим балансировку деревьев.
На этом шаге мы рассмотрим балансировку деревьев.
Если хотим, чтобы дерево, которое строим, никогда не выродилось в список, можно прибегнуть к процедуре балансировки. Балансировка - это метод, позволяющий не допустить возникновения слишком плохих деревьев. Обсудим здесь лишь основную идею алгоритма, оставив детали реализации для самостоятельных упражнений.
Высотой дерева называется длина самой длинной ветви. В дереве выделяется корень, левое поддерево и правое поддерево. В идеальном дереве высота hL левого поддерева равна высоте hR правого поддерева. Назовем деревосбалансированным, если высоты левого и правого поддеревьев для каждой вершины отличаются не более чем на единицу.
Сбалансированные деревья не так уж плохи. В частности, идеальные деревья являются сбалансированными (лучшими из них). Худшими из сбалансированных деревьев являются так называемые деревья Фибоначчи. Примером дерева Фибоначчи является бинарное дерево, получающееся при последовательном создании вершин с информационными полями 8, 5, 11, 3, 7, 10, 12, 2, 4, 6, 7, 9, 1. Согласно теореме Адельсона-Вельского и Ландиса высота сбалансированного дерева не превышает величины 1,4404 log2(n+2) - 0,328, т.е. не более чем в полтора раза превышает высоту идеального дерева.
При включении новой вершины в дерево может (но не обязательно) увеличиться высота одного из поддеревьев. Ситуации может быть три:
вершина включается в поддерево меньшей высоты; его высота увеличивается на единицу, сбалансированность дерева улучшается;
поддеревья имели одинаковую высоту; при включении новой вершины высота одного из них увеличивается на единицу, разность высот не превышает единицу, дерево остается сбалансированным;
вершина включается в поддерево большей высоты; разность высот становится равной двум, дерево становится несбалансированным.
В третьем случае при включении вершины предпринимается перестройка дерева.
Что можно сделать? Видно, что ветви одного из поддеревьев, например, левого, слишком сильно опустились. Решением может быть: поднять левое поддерево на единицу и одновременно опустить на единицу правое. Эта модификация приводит к изменению корня. Поднимая левое поддерево (включая корень поддерева), делаем уровень корня поддерева более высоким, чем корень всего дерева. Следовательно, корень левого поддерева становится корнем всего дерева. Требуется изменить и некоторые связи между вершинами: поскольку корни меняются ролями, меняются на обратные и отношения "предок - потомок".
Для удобства дальнейшего изложения введем следующие обозначения: h(t) - высота дерева t; t = vuw - дерево, состоящее из левого поддерева v, корня и u правого поддерева w.
Ясно, что имеет место соотношение h(t) = 1 + max(h(v), h(w)).
Пусть до включения новой вершины в левое поддерево имело место равенство h(v) = h(w)+l. Представим, что дерево имеет вид t = (A×B)zR, т. е. корнем является z, правым поддеревом R, левое поддерево имеет корень x, и, в свою очередь, состоит из левого поддерева A и правого поддерева B. Соотношение высот: h(A)=h(B)=h(R). Новая вершина включается в поддерево A, после чего высота его увеличивается на единицу. Общее соотношение высот может стать равным h(v)=h(w)+2.
Преобразуем дерево t в новую форму: (A×B)zR => А×(ВzR).
Высоты поддеревьев A, B и R не изменились, но высота дерева t уменьшилась на единицу, и дерево стало сбалансированным.
Симметричная ситуация имеет место, если новая вершина включается в поддерево R дерева t = A×(BzR). В этом случае преобразование балансировки будет обратным: A×(ВzR) => (А×В)zR.
Если же новая вершина включается в поддерево B, то эти преобразования ничего не дают (поддерево B не поднимается).
Разбалансировка дерева за счет включения новой вершины в B требует более внимательно взглянуть на структуру этого поддерева. Выделим в нем корень, левое и правое поддеревья, В = CyD. Таким образом, исходное дерево будет представлено в виде t=(Ax(CyD))zR. Вершина, включенная в поддерево В и увеличившая высоту дерева t, принадлежит одному из поддеревьев С или D. Неважно, какому именно. Оба их можно поднять. Преобразование
(Ax(CyD))zR => (АxС)y(DzR)
разделяет поддеревья C или D, привязывая их к различным корням, и уменьшает высоту дерева t на единицу. Заметим, что все описанные преобразования меняют корень дерева t, что надо учитывать при написании списка формальных параметров процедур.
Напомним, что в сбалансированном дереве для каждой вершины должно выполняться условие отличия высот левого и правого поддеревьев не более чем на единицу. Поэтому, говоря выше о дереве t, имеется в виду не только дерево в целом, но любое поддерево, в котором нарушалась балансировка.
Дополнительную информацию по балансировке деревьев можно получить здесь.
На следующем шаге мы рассмотрим оптимизацию алгоритмов.
На этом шаге мы рассмотрим оптимизацию алгоритмов.
Одна из задач, которая обычно ставится при разработке алгоритмов и программ - минимизация требуемых программой ресурсов. Особенно это касается системного программного обеспечения: программ операционной системы, трансляторов, систем управления базами данных и знаний и т. д., т.е. программ, имеющих большое количество пользователей и испытывающих как товар, большую конкуренцию на рынке программных средств.
Пока компьютерные науки не накопили достаточно сведений для того, чтобы задача минимизации могла быть поставлена с обычной для математики определенностью. Этому мешает несколько факторов.
Во-первых, сложно сформулировать критерий оптимизации, имеющий одновременно и бесспорное практическое значение и однозначно определенный в математическом плане. Например, можно поставить задачу минимизации числа команд машины Тьюринга - критерий, хорошо определенный математически; но совсем неясно его практическое значение, поскольку вряд ли реальная программа на реальном компьютере будет моделировать машину Тьюринга. Можно поставить задачу минимизации времени выполнения программы на реальной машине - ясный с практической точки зрения критерий. Однако невозможно будет решить задачу оптимизации математическими методами, так как время выполнения зависит (иногда значительно) от архитектуры ЭВМ, а архитектуру современных компьютеров не опишешь небольшим числом параметров. Важно также, что программа, работающая быстрее других на одном компьютере, может оказаться не самой быстрой на другом. Существуют даже специальные программы с общим названием benchmark, предназначенные для оценки архитектур.
Во-вторых, не совсем ясно, что такое сложность задачи. Ее можно было бы определить как минимальную из сложностей алгоритмов, решающих эту задачу. Но существует ли алгоритм минимальной сложности (как убедиться, что найденный алгоритм действительно минимальный или, напротив, не минимальный)? Есть ли к чему стремиться? И насколько труден поиск этого минимума? Эти вопросы связаны с нижней оценкой сложности алгоритмов (а не верхней, как в предыдущих шагах).
Можно ли для рассматриваемой задачи доказать, что никакой решающий ее алгоритм не может быть проще этой нижней оценки? Возьмем уже решавшуюся задачу перемножения квадратных матриц. Приведен алгоритм сложностиТα(n) = 3n3 + n2. Вероятно это не лучший алгоритм, но какова оценка снизу? Результирующая матрица имеет n2элементов. Для вычисления любого элемента нужна хотя бы одна операция однопроцессорной машины - два элемента за одну операцию найти нельзя. Таким образом, для минимального алгоритма мы получаем неравенства
n2 <= Tα, min(n) <= 3n3+n2
Вряд ли n2 - хорошая нижняя оценка, но уже известно, что n3 нижней оценкой не является, так как найдены более быстрые алгоритмы (в частности, алгоритм Штрассена).
Имея в виду сказанное, можно отступить от математической традиции и под оптимизацией алгоритмов понимать просто изменение алгоритма или поиск нового с меньшей сложностью.
Существует несколько самостоятельных аспектов оптимизации программ, из которых выделим два:
оптимизация, связанная с выбором метода построения алгоритма;
оптимизация, связанная с выбором методов представления данных в программе.
Первый вид оптимизации имеет глобальный характер и (при удачной оптимизации) ведет к уменьшению порядка функции сложности - например, замена алгоритма с Тα(V) = O(FS) на алгоритм с Tα(V) = O(V4). Он зависит от того, как задача разбивается на подзадачи, насколько это разбиение свойственно самой задаче или является только искусственным приемом.
Общим руководящим подходом здесь может быть последовательность действий, обратная анализу алгоритмов. При анализе по рекурсивному алгоритму строится уравнение, которое затем решается. При оптимизации реализуется цепочка:
Формула, задающая желаемую сложность ->
Соответствующее уравнение (одно из возможных) ->
Метод разбиения задачи на подзадачи.
Второй вид оптимизации, не меняя структуры программы в целом, ведет к экономии памяти и/или упрощению работы со структурами данных, повышению эффективности вспомогательных процедур, обеспечивающих "интерфейс" между прикладным уровнем (на котором мыслим в терминах высокоуровневых объектов - графов, матриц, текстов и т. д.) и машинным уровнем, поддерживающим простейшие типы данных (числа, символы, указатели). Результатом этого обычно является уменьшение коэффициентов при некоторых слагаемых в функции сложности (при удачной оптимизации - при наиболее значимом слагаемом), но порядок функции сложности остается тем же.
Оба вида оптимизации дополняют друг друга и могут применяться совместно. По-видимому, трудно дать какие-либо рекомендации универсального характера по методам оптимизации. Вместо этого приведем несколько успешных примеров оптимизации и попытаемся сделать на их основе некоторые выводы. Для каждой задачи будут проанализированы по два алгоритма: наиболее часто употребляемый или решающий задачу "в лоб", и усовершенствованный.
На следующем шаге мы рассмотрим рекурсивный алгоритм умножения матриц.
На этом шаге мы рассмотрим рекурсивный алгоритм умножения матриц.
Классический итеративный алгоритм перемножения квадратных матриц размера n*n имеет сложность O(n3), а нижняя оценка сложности не менее O(n2). Следовательно, оптимальный алгоритм имеет сложность, заключенную в этом узком коридоре. Отыскивая алгоритм в классе алгоритмов, разбивающих задачу на a подзадач в c раз меньшего размера с уравнением для функции сложности:
Т(n) = а Т(n/с) + bnk, T(1) = b,
вспоминаем возможные оценки сложности O(nk), O(nklоgcn), O(nlоgcn). Эти оценки были получены в предположении, что на один шаг разбиения задачи на a подзадач (без решения самих подзадач) требуется bnk операций. Три оценки отражают три варианта соотношений между а и ck: a < ck, a = ck, a > ck.
Поскольку хотим получить алгоритм с показателем функции сложности меньше трех, то не можем допустить в алгоритме ни одного шага со сложностью O(n3) или более. Следовательно, k может быть равно только единице или двум. При k = 1 отпадают два первых варианта (а < с1, а = с1), так как в этом случае получались бы оценки сложности ниже нижней границы O(n2). В третьем варианте с учетом нижней границы требуется, чтобы 2 <= logcа < 3 или c2 <= а < с3.
Сделаем попытку применить рекурсию с использованием известного в теории матриц блочного представления:
Здесь Aij - матрицы размера (n/2)*(n/2).
Аналогично представим блоками матрицы В и С. Тогда произведение С=АВ можно найти в четыре этапа, вычислив блоки С11, С12, С21, С22 матрицы C по формулам Cij = Ai,1B1,j + Ai,2B2,j.
Рекурсивная процедура Перемножить вызывается с координатами левого верхнего угла (rA - номер строки, cA - номер столбца) матрицы A и с аналогичными координатами матриц В и С (при первом вызове все координаты равны единице), размером матриц n. В процессе выполнения процедура вызывает себя для перемножения блоков половинного размера. Для суммирования частных произведений она вызывает вспомогательную процедуруСложить и использует вспомогательные матрицы XI и Х2 для хранения промежуточных результатов.
Если размеры матриц n=1, то производится обычное умножение скалярных значений (элементов матриц).
процедура Перемножить (А, В: матрица;
переменная C: матрица; rА, сА, rВ, сВ, rС, сС: индекс; n: размер);
переменная X1, X2: матрица;
начало
если n > 1 то
начало
{Вычислить 1-й блок:}
Перемножить(A, В, XI, rА, сА, rВ, сВ, 1, 1, nil);
Перемножить(A, В, X2, rА, сA+n/2, rВ+n/2, сB, 1, 1, n/2);
Сложить (Х1, Х2, С, rС, cC, n/2);
{Вычислить 2-й блок:}
Перемножить(A, В, XI, rА, сА, rВ, сВ+n/2, 1, 1, n/2);
Перемножить(A, В, Х2, rА, сA+n/2, rВ+n/2, сB+n/2,1,1,n/2);
Сложить(Х1, Х2, С, rС, сC+n/2, n/2);
{Вычислить 3-й блок:}
Перемножить(A, В, XI, rА+n/2, сA, rВ, cВ,1,1, n/2);
Перемножить (A, В, Х2, rА+n/2, сA+n/2, rВ+n/2, сB,1,1,n/2);
Сложить(Х1, Х2, С, rС+n/2, cС, n/2);
{Вычислить 4-й блок:}
Перемножить (A, В, XI, rА+n/2, cА, rВ, cВ+n/2,1,1,n/2);
Перемножить (A, В, Х2, rА+n/2, cА+n/2, rВ+n/2, cВ+n/2,1,1, n/2);
Сложить (Х1, Х2, С, rС+n/2, cC+n/2, n/2);
конец
иначе С[rС, cС]:= А[rА, cА] * В[rВ, cВ] {Умножение скаляров.}
конец
Легко видеть, что для этой процедуры k=2, c=2, т. е. сk = 4. Поскольку процедура вызывает себя восемь раз (a=8), то мы имеем вариант а > сk и решение уравнения для функции сложности имеет вид O(nlogca) = O(n3).
К сожалению, улучшения результата по сравнению с классическим алгоритмом не произошло. Улучшение возможно, если, оставаясь в рамках данного подхода, сумеем организовать вычисления таким образом, чтобы значение a было меньше восьми.
Это удалось сделать в 1969 г. немецкому ученому Ф. Штрассену (Volker Strassen). В его методе а=7, т.е. сложность O(n2,81). Ф.Штрассен предложил вычислять блоки матрицы-произведения следующим образом:
В этих равенствах только семь умножений матриц, следовательно, рекурсивная процедура содержит в своем теле семь рекурсивных вызовов. Кроме того, имеется 18 аддитивных (сложений и вычитаний) матричных операций, но они не рекурсивны и имеют квадратичную сложность.
Конечно, практическое значение алгоритм Штрассена вряд ли имеет, так как зависимость и 2,81 дает выигрыш против n3 только при очень больших размерностях задачи, а накладные расходы на организацию рекурсии и на дополнительные аддитивные матричные операции (в классическом алгоритме их четыре) велики. Но с теоретической точки зрения результат очень важен: он помогает продвинуться к пониманию того, какова сложность задачи.
На следующем шаге мы рассмотрим задачу перемножения длинных целых чисел без знака.
На этом шаге мы рассмотрим задачу перемножения длинных целых чисел без знака.
Длинные целые числа используются в арифметике высокой точности (Д. Кнут. Т. 2), криптографическом кодировании сообщений (шифровании). При этом длина чисел (т.е. количество цифр в записи числа) может достигать нескольких сотен и более. Арифметические операции (сложение, вычитание, умножение, деление) над длинными числами не могут выполняться компьютером за один шаг. Любая такая операция должна реализовываться специально написанной процедурой.
