/
Tags: компьютерные технологии общие вопросы лингвистики, литературы и филологии
ISBN: 5-94157-506-8
Text
А. А. Кубенский
void. aluat- :vi°it (Operator * opNode) {
^ontex ;
*~ext
"■*e) - " i;
r
0;
£
СТРУКТУРЫ и АЛГОРИТМЫ
ОБРАБОТКИ ДАННЫХ
обЪЕКТНО-ОрИЕНТИрОВАННЫЙ
ПОДХОД И РЕАЛИЗАЦИЯ НА ^++
Сложные структуры данных: щ
стеки, деревья, графы
Современные технологии работы с данными
Выбор эффективных решений
Нетрадиционные способы представления данных
Примеры программ
УЧЕБНОЕ ПОСОБИЕ (^ЗУ
umm
А. А. Кубенский
СТРУКТУРЫ и АЛГОРИТМЫ
ОБРАБОТКИ ДАННЫХ
обЪЕКТНО-ОрИЕНТИрОВАННЫЙ
ПОДХОД И РЕАЛИЗАЦИЯ НА £++
Допущено учебно-методическим объединением
на базе Санкт-Петербургского государственного университета
Министерства образования Российской Федерации в качестве
учебного пособия по специальности "Математическое обеспечение
и администрирование информационных систем" — 351500
Санкт-Петербу|
«БХВ-ПетербургЛ^1
2004
УДК 681.3.068+800.92С++
ББК 32.973.26-018.1я73
К88
Кубенский А. А.
К88 Структуры и алгоритмы обработки данных:
объектно-ориентированный подход и реализация на C++. — СПб.:
БХВ-Петербург, 2004. - 464 с: ил.
ISBN 5-94157-506-8
Описываются методы построения и использования сложных структур
данных: стеки, деревья, графы; нетрадиционные представления данных, в
частности функциональное представление. Рассматриваются различные
алгоритмы обработки этих структур на простых примерах программ.
Изложение осуществляется на основе объектно-ориентированного подхода с
использованием языка программирования C++. Показано, как тот или иной
выбор решения задач влияет на эффективность и выразительность
программ. Приводится большое количество текстов программ,
иллюстрирующих рассматриваемые алгоритмы.
Компакт-диск, прилагаемый к книге, содержит свободно
распространяемый компилятор языка C++ (лицензия GNU) и примеры программ из
книги с техническими подробностями, опущенными в тексте.
Для программистов
УДК681.3.068+800.92С++
ББК 32.973.26-018 1я73
Группа подготовки издания:
Главный редактор Екатерина Кондукова
Зам. главного редактора Людмила Еремеевская
Зав. редакцией Григорий Добин
Редактор Наталья Бубнова
Компьютерная верстка Ольги Сергиенко
Корректор Зинаида Дмитриева
Дизайн обложки Игоря Цырульникова
Зав. производством Николай Тверских
Лицензия ИД № 02429 от 24.07.00. Подписано в печать 21.09 04.
Формат 70хЮ01/1в Печать офсетная. Усл. печ. л. 37,41
Тираж 3000 экз Заказ No 3577
"БХВ-Петербург", 190005, Санкт-Петербург, Измайловский пр., 29.
Гигиеническое заключение на продукцию, товар № 77.99XgJ5§iJ.001537.03 02
от 13.03.2002 г. выдано Департаментом ГСЭН^й§з^а§Т^^ии.
Отпечатано с готовых диапо;
в ГУП "Типография "Н;
199034, Санкт-Петербург, ^/лйния,
ISBN 5-94157-506-8
1тельс/вМБХВ-Петербург", 2004
Содержание
Введение 5
Глава 1. Способы представления структур данных 9
1.1. Массивы 10
1.2. Списки 18
1.3. Деревья 26
1.4. Множества 37
1.5. Графы 43
Глава 2. Базовые алгоритмы 58
2.1. Абстрактные типы данных 58
2.2. Сортировка и поиск в массивах 73
2.3. Стеки и очереди 100
2.4. Итераторы 130
2.5. Прохождение деревьев 145
2.6. Бинарные деревья поиска 168
Глава 3. Обработка текстов 187
3.1. Способы представления строк 188
3.2. Хеширование и поиск в хеш-таблицах 206
3.3. Словари, представленные списками и деревьями 218
Глава 4. Символьные преобразования 234
4.1. Представление выражений 234
4.2. Вычисления по формулам 265
4.3. Преобразование формул 272
Глава 5. Алгоритмы распределения памяти 288
5.1. Абстрактная система распределения памяти 289
5.2. Распределение памяти блоками постоянной длины 295
5.3. Распределение памяти блоками переменной длины 301
4 Содержание
Глава 6. Алгоритмы обработки графов 326
6.1. Обходы и поиск в графах 326
6.2. Поиск кратчайших путей 353
6.3. Определение остовных деревьев 371
Глава 7. Обмен сообщениями и обработка сообщений 383
7.1. Схема обмена сообщениями 383
7.2. Об одном способе вычисления конечных сумм 397
Глава 8. Функции как носитель информации 412
8.1. Еще о представлении множеств 413
8.2. Задача о расстановке ферзей на шахматной доске и другие задачи 425
Заключение 456
Приложение. Содержание компакт-диска 458
Литература 460
Предметный указатель 461
Введение
Эта книга предназначена для тех читателей, кто уже умеет писать программы
на одном (или нескольких) языке программирования, но пока имеет малый
опыт использования сложных структур данных, и хочет узнать, как
эффективно использовать различные структуры данных, как правильно
организовать структуру классов при объектно-ориентированном подходе к
программированию. В общем, это книга для тех, кто хочет повысить свою
программистскую квалификацию, приобретая новые знания и умения в области
технологии работы со сложными структурами программ и данных.
К сожалению, часто бывает, что, научившись писать простые программы и
изучив один язык программирования, школьники или студенты считают, что
на этом наука программирования закончена, и теперь они могут
программировать не хуже, чем любой опытный программист. Иногда к этому
добавляется еще и знание конкретных библиотек и технологий, таких как MFC
фирмы Microsoft или CORBA фирмы Sun Microsystems. Однако оказывается, что
владения даже самыми современными технологиями недостаточно, чтобы
писать хорошие программы.
В настоящее время выработано много "инструментов мастера", с помощью
которых истинные мастера программирования с легкостью решают довольно
сложные задачи. Таким образом, часто достаточно лишь выбрать одно из
известных решений. Вопрос только в том, чтобы знать эти решения. Однако, к
сожалению, книг, посвященных решениям традиционных задач
проектирования программ и собственно программирования, не так уж и много. Подобные
книги пользуются большим спросом и составляют золотой фонд книг по
программированию. Достаточно назвать лишь такие монографии, как [1J, [2], [3],
[5], [1], чтобы понять, насколько высоко ценятся подобные книги.
Конечно, автор не претендует на то, чтобы войти в этот золотой фонд.
Предлагаемая вашему вниманию книга содержит лишь некоторые избранные
алгоритмы и решения, посвященные, в основном, работе со сложными структу-
6
Введение
рами данных и отобранные по принципу личных пристрастий самого автора.
Тем не менее надеюсь, что эта книга поможет начинающим программистам
ощутить вкус к программированию и приучить к поиску элегантных решений
задач. Некоторые из приводимых решений можно сразу же брать в качестве
готовых образцов программ и их фрагментов. Другие лишь демонстрируют
некоторый подход к построению программ, и сами по себе служат только
примерами конкретных решений.
Основой для построения программ в данной книге служит
объектно-ориентированный подход (ООП) к проектированию и программированию. В
настоящее время техника ООП широко используется для создания как больших
проектов, так и сравнительно небольших программ. Поддержка ООП
фактически является обязательной при создании систем программирования даже
для тех языков, которые первоначально не предполагалось использовать для
объектно-ориентированного программирования. Ярким примером может
служить язык Паскаль, который стал коммерческим языком
программирования после того, как в него были добавлены средства ООП.
В качестве инструментального языка программирования, на котором
записываются все примеры, выбран язык C++. Выбор этого языка автором
обусловлен несколькими причинами. Во-первых, язык широко используется для
создания программ самого разного назначения, поэтому знание этого языка
фактически является обязательным для программиста любого уровня. Во-вторых,
язык обеспечивает хорошую поддержку ООП, что дает возможность ясно
выражать на нем достаточно сложные конструкции при построении объектно-
ориентированных программ. Средства описания шаблонов, использующиеся
во всех последних версиях языка, также оказывают существенную поддержку
при построении типов данных, которые впоследствии могут применяться
в различных программах. Наконец, язык C++ в последнее время достаточно
часто используется в программистской литературе в качестве языка для
детального описания алгоритмов.
Автор предполагает, что читатели в достаточной степени владеют языком,
чтобы понимать записанные на нем алгоритмы. В частности, без всяких
дополнительных пояснений используется аппарат описания шаблонов,
наследования классов, виртуальных функций и т. п. Не предполагается, однако,
знания никаких библиотек, поддерживаемых различными системами
программирования на C++. Правда, в тексте книги иногда встречаются ссылки на
библиотеку стандартных шаблонов STL, однако в книге используются только
самые простые и очевидные шаблоны из этой библиотеки, так что уровень
знакомства с библиотекой никак не может сказаться на понимании основного
текста. Не используются в книге и средства отображения графической
информации и организации диалога с пользователем. Все вопросы
взаимодействия со средой лежат за пределами этой книги; самое большее, что исполь-
Введение
7
зуется здесь — это вывод информации в стандартный выходной поток
средствами пакета iostream.
В книге много программного кода. Наверное, можно сказать, что основная
часть информации содержится именно в программах, поэтому без их
глубокого изучения, а может быть, и исполнения на компьютере, книга будет
почти бесполезной. Тем не менее книга не является учебником по языку
программирования, хотя и может оказаться полезной в качестве источника
примеров программ на C++. В помощь тем, кто захочет самостоятельно
модифицировать и запускать программы, приведенные в книге, к ней
приложен компакт-диск, содержащий практически все тексты программ, иногда
даже несколько расширенные по сравнению с текстами книги. Все
программы, записанные на компакт-диск, готовы к немедленной компиляции и
исполнению. Если у читателей не установлена никакая система
программирования на C++, то они могут установить такую систему непосредственно с
компакт-диска. Подробные инструкции по установке и использованию
системы можно найти в корневом каталоге диска в файле Readme.txt. Там же
описана структура и содержание папок (каталогов) этого диска.
Книга состоит из 8 глав. Условно ее можно разделить на 2 основные части.
В первой части, состоящей из двух глав, приведено описание базовых
структур данных и алгоритмов; во второй, состоящей из 6 глав, — описанные
структуры данных и алгоритмы применяются для решения различных задач и
описания других более сложных алгоритмов.
В главе 1 книги вводятся структуры данных, используемые затем на
протяжении всего дальнейшего изложения. Описаны способы представления
в языках программирования таких известных структур данных, как массивы
(с фиксированными и плавающими границами), списки, деревья, множества и
графы.
В главе 2 вводится понятие абстрактного типа данных, активно
использующееся затем на протяжении всей книги, а также приведены базовые
алгоритмы обработки стеков и деревьев. Разд. 2.2 этой главы содержит также
описание некоторых алгоритмов сортировки массивов.
В главе 3 вводится еще один из основных типов данных — строка.
Приводятся примеры способов представления строк, отвечающие различным
потребностям в написании алгоритмов обработки строк.
Глава 4 целиком посвящена символьным преобразованиям выражений.
Выбор именно этих алгоритмов обусловлен личным интересом к ним автора,
однако выражения оказываются очень простым и удобным материалом, на
котором можно продемонстрировать многие интересные подходы и приемы
программирования.
Глава 5 посвящена системам распределения памяти. Алгоритмы
распределения памяти не только могут служить хорошим примером использования раз-
8
Введение
нообразных структур данных, но и сами по себе имеют важное прикладное
значение. Подробно объяснено, как использовать собственную систему
распределения и управления памятью для хранения объектов, создаваемых в
программах на C++.
В главе 6 приведены примеры некоторых классических алгоритмов
обработки графов. Опять, не претендуя на полноту описания, автор выбрал
некоторые из достаточно простых и, на его взгляд, интересных алгоритмов. Это
алгоритмы нахождения кратчайших путей между вершинами в графе, а также
алгоритмы нахождения минимальных остовных деревьев (скелетов) графа.
Глава 7 посвящена технологии построения программ, в которой основу
программы составляют объекты, обменивающиеся между собой сообщениями.
Обычно такая технология применяется только для построения очень больших
программ, таких как операционные системы, системы программирования
и т. п. Достаточно назвать в качестве примера операционную систему
MS Windows. Тем не менее в этой главе приведены примеры достаточно
простых программ, в которых применение этого подхода также может быть
оправдано, хотя, конечно, существуют и более простые способы решения
приводимых в этой главе задач.
Наконец, в главе 8 приводится один из нетрадиционных способов
представления информации — функциональное представление. Такое представление
чаще всего используется в функциональном программировании, однако даже
в традиционных императивных языках можно использовать некоторые из
подходов и приемов функционального программирования. Часто это дает
возможность получать необыкновенно короткие и изящные программы для
решения сложных задач. Опять для приведенных в этой главе задач,
наверное, можно и даже лучше использовать другие подходы, однако я хочу не
столько продемонстрировать готовое решение, сколько подход, который
может использоваться в более сложных случаях.
Примеры программ, содержащиеся на приложенном компакт-диске, также
разбиты на главы в соответствии со структурой книги. Так, например, все
примеры из разд. 3.3 третьей главы содержатся на диске в папке
"\Programs\chapter3\3.3". Иногда при этом несколько примеров физически
находятся в составе одной исполняемой программы, однако чаще всего
каждый пример содержится в своей собственной папке и может компилироваться
и исполняться полностью независимо от других.
Автор благодарен Марине Валерьевне Дмитриевой, с которой вместе в
издательстве Санкт-Петербургского университета 8 лет назад был подготовлен и
выпущен первый (сильно отличающийся от этого) вариант книги, а также
своему научному руководителю Святославу Сергеевичу Лаврову, который
принимал активное участие в подготовке той первой книги, и издательству
"БХВ-Петербург" за предоставленную возможность издания этой книги.
ГЛАВА 1
Способы представления
структур данных
В этой главе обсуждаются способы представления базовых структур данных,
которые затем будут использоваться на протяжении всей книги — массивы,
списки, деревья и др. Конечно, если вы уже программировали на C++, то
наверняка пользовались массивами; вероятнее всего, представление списков и
деревьев тоже не будет для вас чем-то абсолютно незнакомым. Цель этой
главы — не только напомнить известные структуры данных, но и
продемонстрировать авторский подход к организации структур данных.
Часто, уже после завершения этапа проектирования, возникает вопрос: как
правильно выбрать структуры данных для своей программы? Иногда можно
просто использовать объекты, предлагаемые выбранной системой
программирования, но бывают ситуации, когда приходится создавать свои
собственные базовые структуры, более подходящие для целей разрабатываемого
проекта, чем стандартные универсальные структуры. Этому может быть
множество причин. Наиболее частой из них является соображение эффективности
рабочей программы. Действительно, стандартные структуры данных,
спроектированные на все случаи жизни, могут требовать излишне много ресурсов,
использовать общие, долго работающие алгоритмы и т. д. Еще одна часто
встречающаяся причина — это желание включить в создаваемый класс
специфические методы, приспособленные для решения конкретной задачи.
Не претендуя на универсальность, мы рассмотрим несколько базовых
структур и основные подходы к их реализации в программных проектах.
Некоторые из описанных структур будут использоваться в следующих главах
практически так, как они описаны в этой главе, однако в большинстве случаев
придется вносить в эти структуры определенные изменения, обусловленные
спецификой решаемой задачи или желанием автора показать новый подход к
описанию и обработке данных.
10
Глава 1
1.1. Массивы
Массивы данных широко используются в языках программирования для
представления объектов, состоящих из определенного числа компонентов
(элементов массива) одного и того же типа (класса). Над массивами
определена одна базовая операция — индексация массива. Очень часто одной этой
базовой операции оказывается достаточно для решения задачи.
Пусть, например, решается задача кодирования текста, в которой необходимо
каждую букву текста представить некоторым целочисленным кодом.
(Заметим, что задача не имеет отношения к шифровке шпионских донесений или
обеспечению режима секретности. Скорее, она применима к "переводу"
текста, составленного в одной системе кодирования, в другую.) Если выразиться
более точно, то необходимо по заданному тексту (строке) получить массив
целых чисел той же длины, в котором каждому символу исходной строки
соответствует его код. Очевидно, наиболее удобным способом решения такой
задачи будет составление кодирующей таблицы — массива, в котором
каждому символу будет сопоставлен его целочисленный код. Если считать, что
коды исходных символов лежат в диапазоне, определенном типом char, то
кодирующая таблица может быть представлена следующим описанием:
char codeTable[] = { ... }; // элементы не показаны
Функция кодирования текста с помощью этой таблицы использует только
операцию индексации массива:
void doCode (char * source, char * dest) {
for (int i = 0; source[i]; i++) {
dest[i] = codeTable[source[i]];
}
}
Однако еще чаще встречаются ситуации, в которых только одной операции
индексации недостаточно. Например, если исходные коды символов
расположены в диапазоне от 32 до 255, то в этом случае пользоваться кодовой
таблицей так, как показано выше, становится неудобно: либо в кодовой таблице
оказывается много незадействованных элементов, либо индексацию
приходится делать со смещением. Основное неудобство при этом состоит в том,
что представление кодовой таблицы начинает влиять на те фрагменты
программ, которые, вообще говоря, не должны зависеть от деталей ее
представления.
Не очень подходит простой массив и для таких обычных ситуаций в работе,
как неправильно заданный индекс (здесь надо генерировать подходящее в
каждом отдельном случае исключение), динамическое добавление или уда-
Способы представления структур данных
11
ление элементов (при увеличении длины массива придется заново заказывать
память под его элементы) и т. д. Во всех подобных ситуациях лучше
представлять массивы с помощью объектов специально спроектированного
класса, подходящего для целей нашей задачи. Можно сказать, что во всех
случаях, когда массив используется не просто как временное хранилище объектов,
а представляет собой структуру, заслуживающую отдельного названия
(кодовая таблица в вышеописанной задаче), целесообразно описать отдельный
класс, погрузив в него сам массив и скрыв детали реализации в таком классе.
Итак, пусть в программе необходимо использовать кодовую таблицу,
содержащую коды символов в диапазоне от low до high. В листинге 1.1 приведен
класс CodeTabie для описания такой кодовой таблицы. Упомянутые выше
детали реализации в данном случае — это способ индексации таблицы и
генерация исключительных ситуаций в случае неправильного задания индекса.
Массив array, который собственно и будет содержать элементы кодовой
таблицы, представлен в классе CodeTabie в виде атрибута класса.
Дополнительными атрибутами класса являются нижний и верхний предельные индексы
таблицы. Операции над массивом заменены в данной реализации методами
класса CodeTabie. Собственно говоря, как и для любого массива, метод только
один — индексация таблицы. Кроме того, как обычно, в классе описываются
конструкторы и деструктор кодовой таблицы.
В качестве исключительной ситуации при задании неправильных индексов
будем использовать стандартный класс библиотеки STL outofrange, однако
текст сообщения будем генерировать, исходя из каждой конкретной
ситуации. В тексте класса также используется идентификатор byte как сокращение
для unsigned char. В этом листинге и везде далее в книге содержатся тексты
сразу нескольких файлов. Обычно это заголовочный файл и файл реализации
класса или нескольких классов. Тексты, принадлежащие разным файлам,
отделены друг от друга комментариями, содержащими имена соответствующих
файлов.
В этом и во всех последующих листингах в книге комментарии сделаны на
русском, однако все программные строки приведены на английском языке.
Это позволит получать осмысленные результаты работы программы при
использовании любого компилятора и операционной среды. Английский язык
используется также при составлении всех наименований (идентификаторов),
применяющихся в программах. Конечно, выбор именно английского языка в
качестве языка представления результатов может создать некоторые
(надеемся, не очень большие) трудности для читателей, слабо знакомых с этим
языком, однако это в любом случае не должно мешать пониманию сути работы
программы. Независимость от среды можно было бы сохранить также при
написании идентификаторов и программных строк на русском языке в латин-
12
Глава 1
ской транслитерации, но, по мнению автора, это выглядит ужасно, и часто
расшифровка подобных идентификаторов оказывается еще сложнее, чем
понимание не очень знакомого английского слова.
{ Листинг 1.1. Определение и реализация класса CodeTable
// файл codetable.h
typedef unsigned char byte;
// Определение класса:
class CodeTable {
byte lBound; // нижняя граница элементов
byte hBound; // верхняя граница элементов
byte *array; // собственно массив с кодами символов
public :
//В конструкторе задаются границы создаваемого массива
//и, возможно, массив для начального заполнения кодовой таблицы
CodeTable(byte low, byte high, byte* iniTable = 0);
// Следующий конструктор - это конструктор копирования
CodeTable(const CodeTable &src);
// Деструктор освобождает занятую память
-CodeTable();
// Реализация операции индексации как для обычного массива
byte& operator[](byte i);
}; // конец определения класса CodeTable
// файл codetable.срр
// Реализация операций класса
CodeTable::CodeTable(byte low, byte high, byte* iniTable) {
if ((hBound = high) < (lBound = low)) {
throw out_of_range(
"CodeTable constructor: lower bound is higher than upper one");
}
// Инициализация таблицы
array = new byte[high - low + 1];
for (byte code = lBound;; code++) {
array[code - lBound] = code;
if (code == hBound) break;
}
Способы представления структур данных
13
if (miTable) {
for (byte ndx = 0; ndx <= high - lBound && iniTable[ndx]; ndx++)
array[ndx] = iniTable[ndx];
}
}
CodeTable::CodeTable(const CodeTable &src) {
array = new byte [(hBound=src.hBound)-(lBound=src.lBound)+1];
for (byte ndx = lBound;; ndx++) {
array[ndx-lBound] = src.array[ndx-lBound];
if (ndx == hBound) break;
}
}
// Деструктор
CodeTable::-CodeTable() { delete[] array; }
byte& CodeTable::operator[](byte i) {
if (i < lBound I I i > hBound) {
throw out_of_range("Index is out of range");
}
return array[i - lBound];
}
Теперь решение той же задачи кодирования текста с помощью нашей новой
кодовой таблицы может выглядеть так же просто, как и раньше:
void doCode(byte* source, byte* dest, CodeTable & codeTable) {
for (int i = 0; source[i]; i++) {
dest[i] = codeTable[source[i]];
}
}
Полностью текст, содержащий определение класса CodeTable, а также
тестовая программа для проверки его работоспособности приведены на
приложенном компакт-диске в папке "\chapteri\i.i\codeTabie". Вы можете
попробовать внести некоторые изменения в программу, например, расширив
диапазон кодируемых значений и кодов, определив новые операции над таблицей
или вообще изменив ее реализацию.
Конечно, определение класса CodeTable не является универсальным и, скорее
всего, для решения другой подобной задачи потребуется запрограммировать
другой класс. Можно попробовать написать универсальный класс, который
затем можно будет использовать во многих программах, где требуется
обработка массивов. Несмотря на некоторую тяжеловесность, такой класс имеет
14
Глава 1
право на существование, и многие стандартные библиотеки имеют в своем
составе подобные классы. Оправданием для написания его может служить
стремление убрать некоторые недостатки стандартных массивов, с которыми
приходится сталкиваться чаще всего. Нестандартное решение все же может
потребоваться в том случае, когда стандартные средства не имеют всех
необходимых функций или слишком неэффективны для проектируемой
программы.
В листинге 1.2 и в папке "\Chapterl\l.i\DynArray" компакт-диска приведен
пример такого универсального класса DynArray. Этот пример показывает, как
можно самому определить массив с плавающей верхней границей. Такой
массив можно расширять или укорачивать с помощью метода resize, а также
с помощью операции add добавлять в его конец новые элементы. Разумеется,
определены конструкторы и деструктор. Для увеличения общности вместо
определения класса определим шаблон с параметром Elem, задающим тип
элементов массива. Поскольку определяется не класс, а шаблон классов, то
определение всех операций этого шаблона помещено вместе с определением
самого шаблона в один и тот же файл dynarray .h.
I Листинг 1.2. Определение класса DynArray
;.... ..>...... ...;
// файл dynarray.h
#include <stdexcept> // стандартные исключительные ситуации
using namespace std;
template <class Elem>
class DynArray {
int size; // текущий размер массива (количество элементов)
int raaxSize; // размер отведенной памяти
Elem *array; // сам массив (размера maxSize)
public :
// Конструктор нового массива. Аргументы указывают, сколько памяти
// надо отвести под его элементы, и сколько всего памяти заказывается.
// Еще один дополнительный аргумент содержит массив для
// начальной инициализации элементов
DynArray(int sz = 0, int raaxSz = 0, Elem *iniArray = NULL);
// Конструктор копирования
DynArray(const DynArray<Elem> & a) {
size = maxSize = 0;
*this = a;
}
Способы представления структур данных
15
I/ Деструктор - освобождает занятую ранее память.
~DynArray() { delete[] array; }
// Операция индексации
Elem & operator [] (int i);
// Операция поэлементного копирования
DynArray<Elem> & operator = (const DynArray<Elem> & a);
// Изменение текущего размера массива. Аргумент delta задает
// размер изменения (положительный - увеличение размера;
// отрицательный - уменьшение)
void resize(int delta);
// Добавление нового элемента с возможным расширением массива
void add(const Elem & e);
private:
// Операция расширения массива
void enlarge(int delta);
// Операция уменьшения размера массива
void shrink(int delta);
}; // Конец определения класса DynArray
// Далее следуют реализации функций-членов класса DynArray
// Реализация конструктора
template <class Elem>
DynArray<Elem>: : DynAr ray (int sz, int maxSz, Elem *iniArray) {
if ((size = sz) < 0) {
throw out_of_range("DynArray constructor: negative array size");
}
maxSize = (maxSz < sz ? sz : maxSz);
array = new Elem[maxSize]; // отведение памяти
if (iniArray) { // копирование элементов
for (int i = 0; i < size; i++)
array[i] = iniArray[i];
}
}
// Реализация операции индексации
template <class Elem>
16
Глава 1
Elem & DynArray<Elem>::operator [] (int i) {
if (i < 0 || i >= size) {
throw out_of_range("operator [] : array index is out of range");
}
return array[i];
}
// Реализация операции присваивания (поэлементного копирования)
template <class Elem>
DynArray<Elem> & DynArray<Elem>: :operator = (const DynArray<Elem> & a) {
resize(a.size - size);
for (int i = 0; i < size; i++)
array[i] = a.array[i];
return *this;
}
// Реализация операции изменения текущего размера массива
template <class Elem>
void DynArray<Elem>::resize(int delta) {
if (delta > 0) enlarge(delta); // увеличение размера массива
else if (delta < 0) shrink(-delta); // уменьшение размера массива
}
// Реализация операции добавления элемента
template <class Elem>
void DynArray<Elem>::add(const Elem & e) {
resize(1);
array[size-1] = e;
}
// Реализация операции расширения массива
template <class Elem>
void DynArray<Elem>::enlarge(int delta) {
if ((size += delta) > maxSize) { // необходимо заказать новую память
maxSize = size;
Elem *newArray = new Elem[maxSize];
// копируем элементы
for (int i =0; i < size - delta; i++)
newArray[i] = array[i];
array = newArray;
}
Способы представления структур данных
17
// Реализация операции уменьшения размера массива
template <class Elem>
void DynArray<Elem>::shrink(int delta) {
size = (delta > size ? 0 : size - delta);
}
Поскольку класс DynArray определяет более сложный объект, чем обычный
массив, то работа с объектами класса DynArray в программах обработки
данных будет несколько более сложной, чем непосредственная работа со
встроенными массивами. Однако для нашего примера с кодирующей таблицей
функция doCode будет выглядеть практически одинаково как при
использовании класса DynArray<char>, так и при использовании стандартных массивов.
Единственное различие — это тип аргумента, задающего кодовую таблицу.
void doCode(char *source, char *dest, DynArray<char> & codeTable) {
for (int i = 0; source[i]; i++) {
dest[i] = codeTable[source[i]];
}
}
В проверочной программе на компакт-диске работа проводится только с
массивом символов, но вы можете попробовать поработать и с более сложными
объектами. Обратите внимание на требования, которым должен
удовлетворять класс-аргумент шаблона, чтобы работа с ним была возможной. Конечно,
имеется широкое поле для возможных изменений в составе операций класса
DynArray и реализации имеющихся операций.
В конце раздела заметим, что во всех системах программирования на C++
имеется богатый набор библиотек классов и шаблонов, которые, в частности,
содержат различные шаблоны и классы для работы с массивами. Так,
например, библиотека стандартных шаблонов и классов STL (Standard Template
Library) имеет в своем составе шаблон vector, у которого имеются методы,
похожие на описанные выше операции индексации и добавления элементов
шаблона DynArray. Широко распространенная библиотека классов фирмы
Microsoft— MFC (Microsoft Foundation Classes) также имеет шаблон для
работы с массивами САггау. Имеются и другие классы и шаблоны для описания
массивов и массивоподобных структур. Многие из них весьма удобны для
использования, хотя, на наш взгляд, несколько тяжеловесны, и часто
малоэффективны.
Как правило, для серьезной работы с массивами в больших программах все
равно приходится создавать свои собственные классы. В нашем примере
реализации динамического массива тоже имеется несколько весьма
существенных недостдтков. Например, сразу бросается в глаза, что при добавлении
18
Глава 1
элементов в массив по одному, увеличение размера массива будет
происходить при каждом добавлении. Для массивов большого размера это
совершенно неприемлемо, т. к. приводит не только к заказу памяти при добавлении
каждого элемента, но и к копированию при этом всего содержимого массива.
При реализации следовало бы учесть это и заказывать память с некоторым
запасом (размер такого "запаса" может быть параметром реализации).
Интересующемуся читателю предлагается расширить нашу реализацию и
исследовать, насколько такая новая реализация может оказаться эффективнее
приведенной в книге в различных ситуациях.
1.2. Списки
Если массив всегда занимает непрерывный участок памяти, то список
является простейшим примером так называемой динамической структуры данных.
В динамических структурах данных объект содержится в различных участках
памяти, количество и состав которых может меняться в процессе работы.
Единство такого объекта поддерживается за счет объединения его частей в
описании класса.
Простейший линейный список представляет собой линейную
последовательность элементов. Для каждого из них, кроме последнего, имеется следующий
элемент, и для каждого, кроме первого — предыдущий. Список традиционно
изображают в виде последовательности элементов, каждый из которых
содержит ссылку (указатель) на следующий и/или предыдущий элемент
(рис. 1.1), однако заметим, что физически в представлении элементов списка
может и не быть никаких ссылок.
•н w «-н w ♦
I Г
j_
Рис. 1.1. Простейший линейный список
Типичный набор операций над списком будет включать добавление,
удаление и поиск его элементов, вычисление длины списка, последовательную
обработку элементов (итерацию) списка.
Как и в случае массивов, многие библиотеки классов включают в себя
возможность описания и работы со списками (например, CList библиотеки
классов MFC). Несмотря на это, часто возникает необходимость описания своих
собственных структур данных в виде/ списков, содержащих более
подходящие для решаемой задачи операции, более простые (и, следовательно, более
Способы представления структур данных
19
эффективные), чем стандартные, или обладающие специфическими
особенностями (например, упорядоченные списки).
Как правило, при описании списка представление каждого элемента списка
описывается в виде отдельного класса. В этом классе в качестве его атрибута
имеется ссылка на следующий и/или предыдущий элемент. В листинге 1.3
представлено описание класса intList, задающего простейший
однонаправленный список из целых чисел. Элементы списка являются объектами класса
Listitem, причем его описание вложено в описание класса intList. В
листинге представлены лишь некоторые наиболее характерные операции над
списком: операции добавления нового элемента в начало и конец списка,
операция удаления из начала списка, подсчет числа элементов в списке. Чуть более
широкий набор операций содержится на компакт-диске в папке "\chapteri
\1.2\IntList".
Заметим, что удаление из конца списка не является типичной операцией для
однонаправленных списков ввиду своей относительной сложности и
неэффективности. Действительно, для удаления последнего элемента требуется
иметь доступ не только к самому последнему элементу, но и к
предпоследнему, а это значит, что при выполнении такой операции потребовалось бы
просмотреть все элементы, возможно, длинного списка от начала до конца.
Объекты класса intList содержат ссылки на первый и последний элементы
списка, а также счетчик числа элементов, использующийся в функции,
выдающей количество элементов списка. Этот счетчик должен
модифицироваться всякий раз при добавлении или удалении элементов списка.
Мы не описываем технику работы со ссылками, т. к. считаем, что читатели
владеют основами программирования на языке C++, а значит, им должны
быть знакомы указатели и техника работы с ними. Но мы хотели бы обратить
внимание читателей на то, что все операции по работе со списком
сосредоточены внутри описания класса, так что при программировании задач
обработки списков можно будет пользоваться этим классом, даже не зная о том, как
именно реализованы отдельные детали в представлении списка. Это один из
основных принципов объектно-ориентированного программирования —
использование класса должно быть максимально независимым от его
реализации.
\ Листинг 1.3. Определение класса intList
// файл intlist.h
class IntList {
/* Класс ListItem представляет элемент списка, связанный со
следующим с помощью указателя, содержащегося в поле next
*/
20
Глава 1
struct ListItem {
int item; // значение элемента списка
ListItem *next; // указатель на следующий элемент списка
// Конструктор для создания нового элемента
ListItem(int i, Listltem *n = NULL) { item = i; next = n; }
int count; // счетчик числа элементов
Listltem *first; // первый элемент списка
Listltem *last; // последний элемент списка
public :
// Конструктор по умолчанию - создание пустого списка
IntListO { count = 0; first = last = NULL; }
// Конструктор копирования - создание копии имеющегося списка
IntList(const IntList & src);
// Деструктор списка
-IntListO ;
// Доступ к первому элементу списка
int head() const { return first->item; }
int & head() { return first->item; }
// Доступ к последнему элементу списка
int tail() const { return last->item; }
int & tail() { return last->item; }
// Добавить один элемент в начало списка
void addFirst(int item);
// Добавить один элемент в конец списка
void addLast(int item);
// Добавить элементы другого списка в конец этого
void addLast(const IntList & src) ;
// Удалить первый элемент
int remove Fir st () ;
Способы представления структур данных
21
// количество элементов списка
int getCountO { return count; }
}; // Конец определения класса IntList
// файл intlist.cpp
#include "intlist.h"
// Реализация конструктора копирования
IntList::IntList(const IntList & src) {
count = 0;
first = last = NULL;
addLast(src); // добавляет список src в конец списка this
}
// Реализация деструктора
IntList::-IntList() {
Listltem ^current = NULL;
ListItem *next = first;
while (next) {
current = next;
next = next->next;
delete current;
}
// Добавление одного элемента в начало списка
void IntList::addFirst(int item) {
// создаем новый элемент и присоединяем его к началу списка
Listltem *newltem = new Listltem(item, first);
if (first = NULL) {
// список был пуст - новый элемент будет и первым, и последним
last = newltem;
}
first = newltem;
count++; // число элементов списка увеличилось.
}
// Добавление одного элемента в конец списка
void IntList::addLast(int item) {
// создаем новый элемент списка
Listltem *newltem = new Listltem (item) ;
if (last == NULL) {
// список был пуст - новый элемент будет и первым, и последним
first = newltem;
// указатель на элемент, подлежащий удалению
// указатель на следующий элемент
// пока есть еще элементы в списке
// переход к следующему элементу
// освобождение памяти
22
Глава 1
} else {
// новый элемент присоединяется к последнему элементу списка
last->next = newltem;
}
last = newltem;
count++; // число элементов списка увеличилось.
}
// Добавление элементов заданного списка в конец определяемого
void IntList::addLast(const IntList & src) {
for (Listltem *cur = src.first; cur; cur = cur->next)
addLast(cur->item) ; // используем добавление одного элемента
}
// Удаление первого элемента из списка
int IntList::remove() {
int res = first->item; // содержимое первого элемента
first = first->next; // второй элемент становится первым
count—; // число элементов списка уменьшилось
return res; // удаленный элемент возвращается в качестве результата
}
В приведенном описании списка есть два существенных недостатка. Во-
первых, не определены методы для итерации списка, т. е. нет способа
перебрать все элементы списка, не изменяя его. Между тем, итерация списка —
это одна из наиболее часто встречающихся операций над списком.
Во-вторых, операции не защищены от некорректного использования. Не зная
деталей реализации, невозможно узнать, что произойдет, если, например, будет
предпринята попытка удалить элемент из пустого списка (в приведенной
реализации будет попытка обращения к объекту по пустому указателю).
Исправить второй недостаток не представляет особого труда. Надо лишь
описать соответствующие исключительные ситуации (или воспользоваться
одной из имеющихся в библиотеке) и вставить соответствующие проверки в
операции, которые могут быть вызваны некорректно. Это несложное
упражнение может быть проделано читателями, если они захотят на практике
использовать приведенный в книге пример.
Чтобы исправить первый недостаток, надо сначала понять, чего же мы,
собственно, хотим, т. е. какие операции надо определить для того, чтобы можно
было перебрать элементы списка. Вопрос не так прост, как это может
показаться на первый взгляд, поэтому мы отложим рассмотрение методов
итерации до разд. 2.4.
Гибкость списковых структур особенно ярко проявляется при вставке и
удалении элементов. Действительно, в отличие от массива элементов, нет необ-
Способы представления структур данных
23
ходимости перемещать элементы списка, достаточно лишь заменить значения
нескольких ссылочных полей. Это может значительно ускорить работу
программы в случае большого количества обрабатываемых однородных
объектов, хотя может случиться и так, что для доступа к элементу списка
потребуется просмотреть значительную часть списка прежде, чем требуемый элемент
будет найден.
Обычно для списков также предлагаются методы, позволяющие вставлять и
удалять элементы списка в соответствии с некоторым критерием. Например,
для описанного списка из целых чисел можно предложить операцию
удаления элементов с заданным значением. Для реализации этой операции
потребуется пройти вдоль всего списка, удаляя элементы, содержащие заданное
значение.
В такой операции для удаления помимо указателя на текущий элемент списка
придется хранить еще и указатель на предыдущий элемент (похожая техника
была использована нами при реализации деструктора списка). Это
необходимо, поскольку при удалении элемента корректируется ссылка, содержащаяся
в предыдущем элементе списка. Ниже показана одна из возможных
реализаций такой операции удаления заданного элемента. Реализованная нами
операция remove возвращает значение true, если найденный элемент списка был
удален, и false в противном случае. Заметим, что если в списке было
несколько элементов с заданным значением, то удаляется лишь первый из
найденных элементов.
bool IntList::remove(int n) {
ListItem *pred =0, // указатель на предыдущий элемент
*current = first; // указатель на текущий элемент
while (current) {
if (current->item == n) {
if (pred) { // корректируем ссылку на удаляемый элемент
pred->next = current->next;
}
if (current == last) { // удаляется последний элемент
last = pred; // корректируем ссылку на последний элемент
}
delete current; // освобождаем память
count—; // уменьшаем количество элементов
return true;
} else { // переходим к следующему элементу
pred = current;
current = current->next;
}
}
24
Глава 1
// удаляемый элемент не найден
return false;
}
В качестве примера еще одной операции по добавлению элементов в список
приведем операцию вставки элемента в упорядоченный список. Функция
будет работать правильно, только если элементы в списке расположены в
порядке возрастания значений. При вставке элемента тоже придется
корректировать ссылку, содержащуюся в элементе, предшествующем вставляемому,
так что, как и для операции удаления в функции, используются два указателя
на соседние элементы.
void IntList::insert(int n) {
ListItem *pred = NULL, // элемент, предшествующий вставляемому
*succ = first; // элемент, следующий за вставляемым
while (succ != NULL && succ->item < n) { // поиск места вставки
pred = succ;
succ = succ->next;
}
// генерируем новый элемент:
ListItem *newltem = new Listltem(n, succ);
if (succ == NULL) { // вставляемый элемент - последний
last = newltem;
}
// вставляем новый элемент в список
if (pred == NULL) {
first = newltem;
} else {
pred->next = newltem;
}
count++;
}
В приведенной функции сначала производится поиск первого элемента,
значение которого не меньше аргумента п. После того как элемент найден (или
обнаружен конец списка), порождается новый элемент списка, и вставка
производится с помощью изменения значения поля next в предыдущем элементе
(или поля first списка, если предыдущего элемента нет).
Заметим, что работа всех операций с упорядоченным списком должна
производиться согласованно. Например, не следует в список, образованный
применением методов addFirst и addLast, вставлять затем элементы с помощью
нашего нового метода insert. Если исходный список был неупорядоченным,
то предсказать, куда в этом случае попадет новый элемент, довольно трудно.
Способы представления структур данных
25
Однако если вставка элементов всегда производилась только с помощью
метода insert, то получившийся при этом список обязательно будет упорядочен
по возрастанию элементов.
Иногда кроме линейных рассматривают еще и кольцевые списки. В
кольцевом списке последний элемент содержит указатель на первый. Обработка
кольцевых списков не очень отличается от обработки линейных списков, но
нужно аккуратно следить за тем, чтобы не пропустить конец списка и не уйти
в бесконечный цикл по элементам кольцевого списка. Это требует
дополнительных усилий при программировании и несколько замедляет обработку, но
зато в кольцевых списках все элементы равноправны, и любой из них может
быть назначен головным. Такое свойство списка может использоваться,
например, в алгоритмах обслуживания некоторого множества элемента в
порядке очереди.
В классе, реализующем линейный список, мы описали два указателя
соответственно на первый и последний элементы списка. В представлении
кольцевого списка можно вообще обойтись без ссылки на первый элемент, имея
только ссылку на последний. В этом случае ссылку на первый элемент можно
легко извлечь из последнего элемента (рис. 1.2).
|
#4 >\ ш-\ рА ♦
Рис. 1.2. Простейший кольцевой список
В дальнейшем списки будут часто использоваться в качестве составных
частей различных структур. При этом элементами списков будут, как правило,
не целые числа, как в вышеприведенном примере, а более сложные объекты.
Определение класса intList легко обобщить на случай списков из
произвольных объектов, если вместо описания класса рассмотреть описание
шаблона с параметром, задающим тип элементов списка. Разумеется, такие
операции, как вставка в упорядоченный список, в самом общем случае
неприменимы, однако, если класс элементов списка поддерживает операции
сравнения, то даже операции с упорядоченным списком будут иметь смысл,
да и реализация их будет выглядеть практически так же. В дальнейшем в
подобных случаях будем использовать класс List<Eiem> для задания списка из
элементов типа El em, считая, что определение соответствующего шаблона
классов очевидно. Впрочем, одно такое определение приведено далее в
разд. 7.5, где оно используется для одного из способов представления графа.
26
Глава 1
1.3. Деревья
Элементы могут образовывать и более сложную структуру, чем линейный
список. Часто данные, подлежащие обработке, образуют иерархическую
структуру, подобную изображенной на рис. 1.3, которую необходимо
отобразить в памяти компьютера и, соответственно, описать в структурах данных.
Каждый элемент такой структуры может содержать ссылки на элементы
более низкого уровня иерархии, а может быть, и на объект, находящийся на
более высоком уровне иерархии. Целостность такого объекта также должна
поддерживаться за счет описания класса, содержащего методы доступа и
обработки отдельных элементов иерархической структуры.
Уровень 1
Уровень 2
Уровень 3
Рис. 1.3. Пример дерева
Если абстрагироваться от конкретного содержания объектов, то получится
математический объект, называемый деревом (точнее, корневым деревом).
Дадим два равносильных определения корневого дерева.
Определение 1. Корневым деревом называется непустое множество
элементов, в котором выделен один элемент, называемый корнем дерева, а все
остальные элементы разбиты на несколько непересекающихся подмножеств,
называемых поддеревьями исходного дерева. Каждое из поддеревьев, в свою
очередь, есть дерево.
Определение 2. Корневым деревом называется множество элементов,
называемых узлами этого дерева, связанных следующим отношением. Каждому
из узлов, кроме одного (называемого корнем дерева), можно сопоставить
ровно один из других имеющихся узлов, который при этом называется
непосредственным предком этого узла. Корень дерева— это единственный узел,
не имеющий непосредственного предка.
Оба определения фактически задают один и тот же объект. Существенная
разница между ними состоит в том, что первое определение рекурсивно, т. е.
в нем содержится ссылка на определяемое понятие (поддеревья — это де-
Способы представления структур данных
27
ревья), а второе определение — нерекурсивно. Связь между этими
определениями можно установить, если понять, что каждый узел является
непосредственным предком корней поддеревьев этого узла. Если узел А является
непосредственным предком узла В, то узел В называют непосредственным
потомком узла Л.
При представлении в памяти компьютера элементы дерева (узлы) связывают
между собой таким образом, чтобы каждый узел был связан со своим
непосредственным предком и/или своими непосредственными потомками.
Наиболее распространенными способами представления дерева являются
следующие три (или их комбинации).
При первом способе каждый узел (кроме корня) содержит указатель на узел,
являющийся его непосредственным предком, т. е. на элемент, находящийся
на более высоком уровне иерархии. Для корня дерева соответствующий
указатель будет пустым. При таком способе представления, имея информацию о
местоположении некоторого узла, можно, отслеживая указатели,
подниматься на более высокие уровни иерархии. К сожалению, этот способ
представления непригоден, если требуется не только подниматься вверх по дереву, но и
спускаться вниз, и при этом нет возможности получать независимо ссылки на
узлы дерева. Тем не менее такое представление дерева иногда используется в
алгоритмах, где прохождение узлов всегда осуществляется в восходящем
порядке. Преимуществом этого способа представления дерева является то, что в
нем используется минимальное количество памяти, причем практически вся
она эффективно используется для представления связей.
Второй способ представления применяют, если каждый узел дерева имеет не
более двух (в общем случае не более К) непосредственных потомка. Тогда
можно включить в представление узла указатели на этих потомков. В этом
случае дерево называют двоичным (бинарным), а два поддерева каждого узла
называют соответственно левым и правым поддеревьями этого узла.
Разумеется, узел может иметь и только одно — левое или правое — поддерево (эти
две ситуации в бинарных деревьях обычно считаются различными) или
может вообще не иметь поддеревьев (и в этом случае узел называется концевым
или терминальным узлом или листом дерева). При этом способе
представления достаточно иметь ссылку на корень дерева, чтобы получить доступ к
любому узлу дерева, спускаясь по указателям, однако, память при таком способе
представления используется не столь эффективно— часть
зарезервированной памяти будет содержать пустые указатели.
В листинге 1.4 описан шаблон классов, определяющий структуру бинарного
дерева, содержащего в узлах объекты произвольного класса т. Мы пока не
будем определять никаких методов для этого класса, так что в основном
описание шаблона сводится к описанию структуры узлов дерева. Никакой
реализации шаблона также пока не представлено.
28
Глава 1
Листинг 1.4. Определение шаблона классов Tree
template <class T>
class Tree {
// Определение класса для узла дерева
struct Node {
Т item; // содержимое узла
Node *left; // указатель на левое поддерево
Node *right; // указатель на правое поддерево
// Конструктор узлов дерева:
Node (const T & item, Node *left = NULL, Node *nght = NULL) {
Node: : item = item;
Node::left = left;
Node::right = right;
}
};
Node *root; // Корень дерева
public :
// Конструктор дерева задает новое пустое дерево
Tree() { root = NULL; }
// Деструктор дерева должен освободить память, занятую его узлами
-Tree ();
};
Здесь корень дерева представлен указателем root на элемент класса Node
(узел), а каждый узел, в свою очередь, содержит два указателя на корни
левого и правого поддерева (left и right соответственно).
Если бинарное дерево имеет N узлов, то при таком способе представления
всегда остаются пустыми более половины указателей (точнее, из 2N
указателей пустыми будут N+\ указатель, докажите это!). Тем не менее такое
представление является настолько удобным, что потерями памяти обычно
пренебрегают.
Третий способ представления дерева состоит в том, что каждый узел списка
содержит список своих поддеревьев. При этом можно задать такой список,
непосредственно используя шаблон классов List, так что описание структуры
узла дерева в контексте описания шаблона для самого дерева могло бы
выглядеть следующим образом:
Способы представления структур данных
29
struct Node {
Т item;
List<Tree<T>*> subtrees;
};
// содержание узла
// список поддеревьев
Однако чаще поступают несколько по-иному: в каждый узел дерева
помещают два указателя, причем один из них указывает на начало списка
поддеревьев, а второй служит для связывания корней поддеревьев в этот список. На
рис. 1.4,а изображено некоторое дерево, узлы которого помечены буквами
латинского алфавита. Если его узлы представлять так, как только что
описано, то физически указатели в представлениях узлов в памяти будут
размещаться так, как показано на рис. 1.4, б.
Рис. 1.4, а. Дерево, узлы которого помечены латинскими буквами
A f »
*±
Е I щ
F I • I •+-, G
Н I • I ш-\—►! I
Рис. 1.4, б. Физическое представление такого дерева с рис 1.4, а в памяти
Из рисунка хорошо видно, что представление каждого узла в точности такое
же, как и у узлов бинарного дерева, которые тоже помимо смысловой
нагрузки содержат два указателя на левое и правое поддеревья. Это означает, что
30
Глава 1
формально описание класса для этого представления может быть тем же
самым, что и для случая бинарного дерева. Однако смысл содержащихся в этом
описании указателей совершенно другой: это уже не указатели на левое и
правое поддеревья, а указатели на начало списка поддеревьев и соседний узел
в таком списке. При этом способе представления деревьев часто используется
генеалогическая терминология. Узлы, которые мы ранее называли
непосредственными потомками некоторого узла, называют также его сыновьями;
соответственно, сыновей одного и того же узла называют братьями и т. д. Эту
терминологию можно отразить в описании класса, и тогда поля left и right
будут называться son и brother соответственно.
struct Node {
Т item; // содержание узла
Node *son, ^brother; // указатели на сына и брата
};
Такое определение дерева и пример работы с ним можно найти в папке
"\chapteri\i.3\Tree" приложенного компакт-диска.
Рекурсивную природу дерева, отраженную в одном из его определений,
можно выразить более явно и в описании класса, если в описании ссылок на
поддеревья вместо указателей на объекты класса Node использовать указатели на
объекты класса Tree. Тогда вместо двух классов — Node и Tree — достаточно
использовать всего один, и многие операции над деревом могут быть просто
выражены в виде рекурсивных функций.
Определим, например, метод для вычисления высоты произвольного дерева.
Высотой дерева назовем максимальное число узлов, которое может
встретиться на пути из корня дерева в некоторый другой узел, при условии, что
этот путь проходит только по связанным между собой узлам и никогда не
проходит дважды через один и тот же узел.
Будем для удобства считать, что пустой указатель представляет вырожденное
пустое дерево, высота которого равна нулю. В этом случае высота бинарного
дерева может быть выражена следующей формулой:
ГО, если t = null
Kt) = max (Ktsuhtree)) +1, если / * null'
{subtree
где t — исходное дерево, tsubtree — поддеревья исходного дерева, null —
пустое дерево.
Теперь определим шаблон класса Tree, в котором представление узла дерева
соединено с представлением самого дерева. Объекты такого класса
представляют корень непустого дерева, связанного ссылками с корнями других де-
Способы представления структур данных
31
ревьев. В листинге 1.5 представлено описание самого шаблона, а также
реализации конструктора, деструктора и функции для вычисления высоты
дерева.
( Листинг 1.5. Рекурсивное определение дерева
template <class T>
class Tree {
Т item; // содержимое корневого узла дерева
Tree<T> *son; // указатель на старшего сына
Tree<T> ^brother; // указатель в список сыновей
public :
// Конструктор корневого узла получает в качестве аргументов
// содержимое этого узла и два указателя на дерево-сына и дерево-брата
Tree(const T & item, Tree<T> *s = NULL, Tree<T> *b = NULL);
// Деструктор освобождает память, занятую поддеревьями этого узла
-Tree();
// Метод для вычисления высоты дерева
int height();
}; // Конец определения класса Tree
// Далее следуют шаблонные функции, реализующие операции над деревом
// Конструктор узла дерева.
template <class T>
Tree<T>::Tree(const T & item, Tree<T> *son, Tree<T> ^brother) {
Tree<T>::item = item;
Tree<T>::son = son;
Tree<T>::brother = brother;
}
// Деструктор освобождает память, занятую
// поддеревьями этого узла и его братьями.
template <class T>
Тгее<Т>::-Тгее() {
if (son) delete son;
if (brother) delete brother;
}
32
Глава 1
// Рекурсивная функция вычисления высоты.
template <class T>
int Tree<T>::height() {
//В цикле вычисляются высоты всех сыновей данного узла
//и выбирается максимальная из этих высот
int max = 0;
for (Tree<T> *current = son; current; current = current->brother) {
int curHeight = current->height();
if (curHeight > max) max = curHeight;
}
// К результату добавляется единица за счет самого корневого узла
return max + 1;
}
Приведенное рекурсивное определение дерева и пример работы с ним также
можно найти на приложенном компакт-диске в папке "\chapterl\l.3
\RecursiveTree".
Конечно, запись операции вычисления высоты дерева в виде рекурсивной
функции выглядит очень просто, однако работать с рекурсивным описанием
дерева все же неудобно. В частности, не очень удобно, что дерево, не
содержащее узлов (пустое дерево), представляется пустым указателем, а не
объектом класса Tree, как все прочие деревья. Программы становятся более
ясными, если деревья и их узлы представлены разными классами. В дальнейшем
мы всегда будем пользоваться ранее приведенным представлением дерева с
явным выделением структуры узла. Так, в листинге 1.6 представлено
модифицированное описание бинарного дерева для представления дерева
произвольной структуры (со ссылками из узлов на старшего сына и брата этого
узла) и переопределен метод для вычисления высоты дерева. В примере очень
хорошо видно, что изменения, которые необходимо сделать в этом методе, не
очень существенны.
| Листинг 1.6. Вычисление высоты дерева
template <class T>
class Tree {
// Описание структуры узла со ссыпками на сына и брата
struct Node {
Т item;
Node *son;
Node ^'brother;
Способы представления структур данных
33
Node(T i, Node *s = NULL, Node *b = NULL) {
item = i; son = s; brother = b;
}
};
Node *root; // Корневой узел
public :
// Конструктор, деструктор и метод для вычисления высоты дерева
Tree() { root = NULL; }
-Tree() { deleteSubtree(root); }
int height() { return height(root); }
private :
// Вспомогательные рекурсивные функции для деструктора
//и функции вычисления высоты дерева
void deleteSubtree(Node *node);
int height(Node *node);
}; // Конец определения класса Tree
// Далее следуют реализации функций-членов класса
template <class T>
void Tree<T>::deleteSubtree(Node *node) {
if (node) {
deleteSubtree(node->son); // Уничтожение узлов потомков
deleteSubtree(node->brother); // Уничтожение узлов братьев
}
}
template <class T>
int Tree<T>::height(Node *node) {
if (node == NULL) return 0;
int max = 0;
for (Node *current = node->son; current; current = current->brother) {
int curHeight = height(current);
if (curHeight > max) max = curHeight;
}
return max + 1;
}
34
Глава 1
Здесь описание метода height сделано с помощью вспомогательной
рекурсивной функции, аргументом которой является указатель узла того
поддерева, высоту которого требуется вычислить.
Иногда структура рекурсивной функции, обрабатывающей дерево, не так
проста. Рассмотрим, например, следующую задачу. Пусть надо определить
функцию, вычисляющую число узлов бинарного дерева, расположенных на
/-м уровне при заданном значении i > 0. Заметим, что в пустом дереве таких
вершин нет вовсе, а в непустом дереве требуемое количество узлов можно
определить рекурсивно. Действительно, на уровне / = 1 существует лишь
одна вершина — корень дерева, а при i > 1 число узлов, расположенных на /*-м
уровне дерева, равно сумме чисел узлов, расположенных в его поддеревьях
на(/- 1)-м уровне.
Отсюда сразу же следует определение функции (считаем его также
выполненным в контексте описания шаблона Tree, приведенного в листинге 1.6).
public :
int nodesOnLevel(int level) { return nodesOnLevel(root, level); }
private :
int nodesOnLevel(Node *node, int level) {
// Если дерево пусто - узлов в нем нет
if (node == NULL) return 0;
// Если заданный уровень нулевой (или меньше), то узлов тоже нет
if (level <= 0) return 0;
//В остальных случаях нужная сумма складывается из следующих трех
// значений: число узлов в поддеревьях этого узла уровня (level - 1),
// сам узел, если требуемый уровень - первый, число узлов того же
// уровня в поддеревьях-братьях данного узла.
return nodesOnLevel(node->son, level-1) +
(level — 1) +
nodesOnLevel(node->brother, level);
}
Легко убедиться, что представленная функция действительно решает
поставленную задачу.
Так же, как и в случае списков, важной задачей обработки деревьев является
итерация (обход) дерева. Сейчас эту задачу тоже обсуждать не будем.
Заметим только, что обходы элементов дерева могут быть значительно более
разнообразны, чем обходы элементов списка. В случае списков обход обычно
выполняется в естественном порядке (от начала списка к концу), также
можно рассмотреть еще обход элементов списка в противоположном
направлении — от конца к началу. В случае дерева существует много порядков обхода
Способы представления структур данных
35
его узлов, большинство из которых имеет свое самостоятельное значение и
применяется в различных алгоритмах. Подробнее эта задача обсуждается в
разд. 2.5 и некоторых последующих главах настоящей книги, а также в
книгах [1, 3] и многих других.
Иногда используются и более экзотические способы представления деревьев.
Например, в некоторых случаях связи между узлами дерева можно вообще не
представлять с помощью указателей, а "вычислять", если узлы дерева
образуют регулярную структуру, не меняющуюся или меняющуюся очень мало
в процессе работы с деревом.
Рассмотрим следующий пример. Пусть узлы бинарного дерева
пронумерованы, начиная с корня и далее вниз по иерархическим уровням, как показано на
рис. 1.5.
Рис. 1.5. Пронумерованное дерево
При этом узлы расположены настолько плотно, что предком узла с номером i
всегда является узел с номером //2. В этом случае узлы дерева можно
расположить в массиве, причем местоположение каждого узла будет задано его
индексом в массиве, а максимальное число узлов задается сразу же при
создании дерева. Представление такого дерева описано в листинге 1.7. В нем
определено дерево, обладающее следующим свойством: каждый узел этого
дерева содержит целое число, меньшее, чем значения, хранящееся в
поддеревьях этого узла. Таким образом, минимальное число всегда находится
в корне дерева. Такое дерево иногда называют пирамидой или кучей.
В разд. 2.2 пирамида используется для одного из способов сортировки
элементов массива — так называемой пирамидальной сортировки.
Класс Heap определяет такое дерево вместе с операцией insert добавления
нового элемента в дерево. Сначала это новое значение добавляется в качестве
36
Глава 1
последнего узла дерева, но если оно оказывается меньше, чем значения,
расположенные выше по дереву, то оно "всплывает" наверх до тех пор, пока не
окажется минимальным среди всех узлов поддерева, в корне которого это
значение находится.
В процессе работы операция insert определяет для каждого узла индекс его
родителя и затем определяет, надо ли продвигать новое значение дальше по
направлению к вершине пирамиды. Для простоты в этом листинге опущено
определение исключительной ситуации NoMoreSpace.
| Листинг 1.7. Представление пирамиды в виде массива
template <class T>
class Heap {
int count; // Количество узлов в дереве
Т *items; // Массив, представляющий узлы дерева
int size; // Размер этого массива
public :
//В конструкторе задается размер массива; элементов пока нет
Heap(int size) {
count = 0;
items = new T[Heap::size = size];
}
// Операция вставки нового элемента в пирамиду
void insert(const T & element);
};
// Реализация операции вставки элемента
template <class T>
void Heap<T>::insert(const T & elem) {
// Проверяем, есть ли еще место в массиве
if (count >= size) throw NoMoreSpace();
// Записываем новый элемент
int curIndex = count++; // индекс текущей позиции в пирамиде
int parentIndex; // индекс родителя
// Цикл "всплытия" элемента
while (curlndex > 0 && elem < items[parentIndex = (curlndex-l)/2]) {
// Сдвигаем элемент вниз
items[curlndex] = items[parentIndex];
//и переходим к элементу, лежащему выше в пирамиде
curlndex = parentlndex;
}
Способы представления структур данных
37
// Помещаем новый -элемент на свое место
items [curIndex] = elem;
}
Подробнее о построении и анализе деревьев будет рассказано в следующих
главах этой книги.
1.4. Множества
Множество — это составной объект, который так же, как и массив, содержит
компоненты одного и того же класса. Отличие от массива состоит в том, что
над множеством определены совсем другие операции, и это определяет
существенную разницу в представлении объекта. В массиве элементы
упорядочены, доступ к ним осуществляется по индексу, собственно говоря,
индексация — это практически единственная операция над массивом. В множестве,
напротив, порядок элементов несуществен. Основная операция— это
проверка принадлежности элемента множеству. Другие операции — это
теоретико-множественные операции объединения и пересечения множеств,
добавление элементов в множество, определение числа элементов (мощность)
множества. Конечно, для массива тоже можно определить подобные
операции, другими словами, можно представлять множество массивом, однако в
этом случае многие типичные операции над множествами будут выполняться
недопустимо долго. Так, например, проверка принадлежности элемента
массиву может потребовать просмотра всех его элементов.
Эффективная реализация множества обычно подразумевает такое его
представление, при котором элементы множества не хранятся, вместо этого
хранится лишь информация о том, содержится ли элемент в множестве. Это
возможно в том случае, если элементы множества достаточно просты, а
максимальное число элементов множества не слишком велико. Тогда можно
присутствие каждого из возможных элементов множества кодировать одним
битом, например, единицей, если элемент присутствует в множестве, и
нулем, если элемент в множестве отсутствует. Наиболее часто используются
множество целых чисел из некоторого диапазона и множество символов из
некоторого набора символов. В обоих случаях для представления множества
можно сформировать битовую шкалу, т. е. последовательность двоичных
элементов, каждый из которых определяет, содержится ли в множестве
некоторый элемент. Такая битовая шкала будет содержать столько битов, сколько
элементов содержится в выбранном диапазоне возможных элементов
(универсальном множестве для данного проекта или задачи).
Например, если в программе нужно работать с множествами целых чисел из
диапазона от 0 до 10, то можно каждое множество представлять набором из
38
Глава 1
11 битов (занумерованных числами от 0 до 10), причем если бит содержит
единицу, то это означает, что его номер содержится в множестве в качестве
элемента, а если бит содержит ноль, то соответствующий элемент в
множестве отсутствует. Таким образом, битовая шкала 00101100010 (биты считаются
занумерованными подряд от 0 до 10) в данном случае представляет
множество элементов [2, 4, 5, 9].
Приведем еще пример. Пусть мы хотим работать с множествами строчных
русских букв. Прежде всего, следует закодировать все эти буквы целыми
числами. Наиболее экономным способом кодирования будет такой, при
котором все 33 символа получат коды из диапазона (0, 32). Например, в
расширенной кодировке Win-1251 коды русских строчных букв расположены в
диапазоне от 1072 до 1105 (исключая код 1104). Этот диапазон можно легко
привести к диапазону (0, 32), если выполнить следующее преобразование:
вычтем 1072 из кода символа; если получится число 33, то дополнительно
вычтем еще единицу. При таком способе кодирования гласные буквы V, V,
V, 'и', 4о', 'у', 'ы\ V, 'ю', V будут иметь коды 0, 5, 32, 8, 14, 19, 27, 29, 30,
31 соответственно. Битовая шкала, представляющая множество всех гласных
букв, будет выглядеть тогда следующим образом:
100001001000001000010000000101111
В языке C++ битовая шкала может быть представлена массивом
16-разрядных слов (значений типа WORD), каждый элемент которого (слово)
содержит 16 битовых элементов шкалы. Биты удобно нумеровать целыми
числами, начиная с нуля, причем бит номер п будет содержаться в слове с
индексом я/16 (имеется в виду операция целочисленного деления с
отбрасыванием остатка). Например, приведенная выше шкала для представления
множества гласных букв займет 3 слова (если бы букв всего было 32, то
удалось бы поместиться в 2 слова). Учитывая, что биты в словах принято
нумеровать справа налево (т. е. при изображении слова его младшие биты
располагают справа), можно нарисовать ту же битовую шкалу в виде
последовательности слов, представленной на рис. 1.6.
ОМ
i
00
00
01
00
11 111 19876543210
5 4 3 2 1 О
СЛОВО 0
ою
ОН
1111119876543210
5 4 3 2 10
СЛОВО 1
ою
ою
1111119876543210
5 4 3 2 10
СЛОВО 2
Рис. 1.6. Представление множества в виде битовой шкалы
Способы представления структур данных
39
Ниже (см. листинг 1.8, а также папку "\chapterl\l.4\set" компакт-диска)
приводится определение класса для представления множества целых чисел из
заданного диапазона. Битовая шкала в этой реализации представлена
массивом слов, а операции над битами реализуются с помощью побитовых
операций. Операция побитового сложения тв данном случае представляет
операцию объединения множеств, а операция побитового умножения '&' —
операцию пересечения множеств. С помощью операций побитового сдвига
получают слово с единицей, расположенной в нужном разряде. Вот,
например, как в заданное слово wd можно добавить единичный бит, записанный в
разряде номер п:
wd |= (1 « п);
Выражение для проверки того, содержит ли заданное слово единицу в
разряде номер п, будет выглядеть следующим образом:
(wd & (1 « п)) != О
Аналогичным образом будут представлены и другие операции над битовыми
шкалами и отдельными битами. Операции деления нацело на 16 и получения
остатка от деления на 16, необходимые для правильного вычисления адреса
нужного бита, тоже можно выразить с помощью побитовых операций C++.
Так, результат деления числа п на 16 можно получить с помощью выражения
п » 4, а остаток от деления на 16 — с помощью выражения п & 15.
// файл set.h
class Set {
int minElem; // минимальный элемент диапазона
int maxElem; // максимальный элемент диапазона
WORD *eleras; // битовая шкала
int numWords; // длина шкалы (в словах)
friend const Set & operator I (const Set & si, const Set & s2);
friend const Set & operator & (const Set & si, const Set & s2) ;
friend const Set & operator - (const Set & si, const Set & s2) ;
friend const Set & operator - (const Set & s);
public:
// Конструктор
Set (int min = 0, int max = 255);
40
Глава 1
// Конструктор копирования
Set(const Set & s);
// Деструктор
-Set () { delete[] elems; }
// Проверка принадлежности элемента множеству
bool has(int n) const;
// Добавление элемента в множество
Set & operator |= (int n);
// Добавление множества в множество (объединение)
Set & operator |= (const Set & other);
// Пересечение множества с множеством
Set & operator &= (const Set & other);
// Удаление элемента из множества
Set & operator -= (int n);
// Удаление множества из множества (вычитание множеств)
Set & operator -= (const Set & other);
// Обращение (нахождение дополнения) множества
Set & inverse();
};
// файл set.cpp
#include <stdexcept>
#include "set.h"
// Конструктор
Set:: Set (int min, int max) {
// обеспечим min < max
if (min > max) {
minElem = max;
maxElem = min;
} else {
minElem = min;
maxElem = max;
}
int num = maxElem - minElem + 1;
numWords = (num + 15) » 4;
elems = new WORD [numWords ];
// количество битов
// количество слов
Способы представления структур данных
41
// Инициализация пустого множества
for (int i = 0; i < numWords; i++) elems[i] = 0;
}
// Конструктор копирования
Set::Set(const Set & s) {
minElem = s.minElem;
maxElem = s. maxElem;
elems = new WORD[numWords = s.numWords];
for (int i = 0; i < numWords; i++) {
elems [i] = s.elems [i];
}
}
// Проверка принадлежности элемента множеству
bool Set::has(int n) const {
if (n > maxElem | | n < minElem)
return false; // элемент находится за пределами границ множества
int bit = n - minElem;
return (elems[bit » 4] & (1 « (bit & 15))) != 0;
}
// Добавление элемента в множество
Set & Set::operator |= (int n) {
if (n <= maxElem &&. n >= minElem) {
int bit = n - minElem;
elems [bit » 4] |= (1 « (bit & 15));
} else {
throw out_of_range("Cannot add an element: it is out of range");
}
return *this;
// Добавление элементов другого множества в данное (объединение)
Set & Set::operator |= (const Set & other) {
if (other.minElem != minElem | | other.maxElem != maxElem) {
throw out_of_range("Sets incomparable");
}
for (int i = 0; i < numWords; i++) {
elems [i] |= other.elems[i] ;
}
return *this;
}
42
Глава 1
II Пересечение множества с другим множеством
Set & Set::operator &= (const Set & other) {
if (other.minElem != minElem | I other.maxElem != maxElem) {
throw out_of_range("Sets incomparable");
}
for (int i = 0; i < numWords; i++) {
elems[i] &= other.elems[i];
}
return *this;
}
// Удаление элемента из множества
Set & Set::operator -= (int n) {
if (n <= maxElem && n >= minElem) {
int bit = n - minElem;
elems[bit » 4] &= ~(1 « (bit & 15));
}
return *this;
}
// Удаление элементов другого множества из данного (вычитание)
Set & Set::operator -= (const Set & other) {
if (other.minElem != minElem I I other.maxElem != maxElem) {
throw out_of__range ("Sets incomparable");
}
for (int i = 0; i < numWords; i++) {
elems[i] &= -other.elems[i] ;
}
return *this;
}
// Обращение (нахождение дополнения) множества
Set & Set::inverse() {
for (int i = 0; i < numWords; i++) {
elems [i] = -elems [i];
}
return *this;
}
// Объединение множеств
const Set & operator I (const Set & si, const Set & s2) {
return Set(si) |= s2;
}
Способы представления структур данных
43
// Пересечение множеств
const Set & operator & (const Set & si, const Set & s2) {
return Set (si) &= s2;
}
// Вычитание множеств
const Set & operator - (const Set & si, const Set & s2) {
return Set(si) -= s2;
}
// Дополнение множества до универсального
const Set & operator - (const Set & s) {
return Set(s).inverse();
}
В приведенном классе определен достаточно богатый набор операций над
множествами, однако для практических целей может понадобиться еще
расширить и модифицировать этот класс, например, чтобы можно было работать
с множествами элементов из разных диапазонов. В приведенном выше классе
при попытке, например, объединить множество чисел, лежащих в диапазоне
(О, 255), с множеством чисел из диапазона (10, 25) возбуждается
исключительная ситуация outofrange, хотя по смыслу это, конечно, совершенно
законная операция. Полезным упражнением было бы модифицировать
определение класса Set таким образом, чтобы все логически корректные операции
над множествами были допустимы.
Еще одним удобным дополнением мог бы быть набор операций для выдачи
всех элементов множества, например, в виде массива или в виде итератора
элементов. В дальнейшем мы будем использовать те операции над
множествами, которые окажутся наиболее подходящими в конкретной ситуации, без
дополнительного определения.
1.5. Графы
В процессе обработки данных на компьютере часто приходится моделировать
объекты сложной структуры, содержащие в качестве составных частей
(элементов) объекты более простой структуры. Примерами таких структур
являются все рассмотренные ранее структуры — массивы, множества, списки и
деревья. Иногда сложный объект состоит из некоторого множества элементов
одного и того же типа, между которыми существуют определенные связи
(отношения). В технике такие объекты часто называют сетями. Приведем
несколько примеров.
44
Глава 1
Пусть моделируемым объектом является транспортная сеть для перевозки
определенных грузов. Элементами (или узлами) такой сети служат
начальные, конечные и транзитные пункты перевозок, а в качестве связей между
пунктами выступают дороги. Узлы сети могут характеризоваться
индивидуальным именем (названием), объемом принимаемой или поставляемой
продукции. Дороги могут характеризоваться длиной, пропускной способностью,
качеством покрытия и т. д.
Еще пример. Для расчета характеристик сети компьютеров создается ее
модель. Узлами такой сети служат отдельные компьютеры, связанные между
собой каналами связи. Каждый узел может характеризоваться
интенсивностью выдачи и приема сообщений, объемом предоставляемой памяти.
Канал связи может характеризоваться скоростью передачи информации,
количеством одновременно передаваемых сообщений и т. д.
Последний пример. Программа игры в шахматы для анализа позиции строит
сеть, состоящую из позиций, связанных между собой возможными ходами
соперников. Узлы сети могут характеризоваться игровой оценкой позиции, а
связывающие позиции ходы могут характеризоваться локальными целями
и т. д.
Для моделирования сетей в математике служат объекты, называемые
графами. Графом G называется пара множеств (V, Е), где V— конечное множество
элементов, называемых вершинами графа, а Е— конечное множество
упорядоченных пар е = (и, v), называемых дугами, где и, v — вершины.
Говорят, что дуга е выходит из вершины и и входит в вершину v. Вершины и
и v называют инцидентными дуге е, а дугу е — инцидентной вершинам и и v.
В этом определении множество вершин V соответствует множеству узлов
моделируемой сети, а множество дуг — связям между узлами. Определение,
данное выше, отражает лишь структуру сети, но не характеристики ее
отдельных узлов и связей. Если такие характеристики все же существенны, то
рассматривают нагруженные графы, в которых с каждой вершиной или дугой
(может быть, и с тем, и с другим) связана величина или несколько величин,
называемых нагрузкой на граф. Формально говоря, нагрузку графа
определяют функции:
где W\ и W2 представляют собой множества значений нагрузки вершин и дуг
графа соответственно. Иногда при анализе графа неважно, какая из вершин и
и v в дуге е = (и, v) первая, а какая вторая, т. е. пара (и, v) не упорядочена.
В этом случае дугу е называют ребром, а весь граф называют
неориентированным в отличие от ориентированного графа, задаваемого исходным
определением. Разумеется, в этом случае бессмысленно говорить о том, из какой
Способы представления структур данных
45
вершины ребро выходит и в какую вершину входит. Формально говоря,
неориентированным графом называют такой граф, у которого наряду с любой
дугой е\ = (w, v) имеется и противоположная дуга ег = (v, u). Эта пара дуг и
образует ребро е = <и, v> неориентированного графа.
При программировании задач обработки сетевых структур требуется решить
вопрос о представлении графа структурами данных языка программирования.
Выбор представления графа определяется прежде всего тем, какие алгоритмы
обработки графов используются, а также соображениями экономии памяти
при обработке очень больших графов или в условиях жестких ограничений
на расход памяти.
Говоря о представлении графа, имеют в виду прежде всего вопрос о
представлении в памяти его структуры, т. е. считается, что по представлению
графа нужно уметь определять, какие вершены с какими другими вершинами в
графе связаны. На практике кроме структуры графа нужно каким-то образом
представлять и нагрузку на его вершины и дуги. От того, какие элементы
графа нагружены и какого типа эта нагрузка, тоже зависит представление
графа.
Ниже приводится несколько способов представления графов, причем для
каждого способа указано, для каких алгоритмов он подходит, а также дана
приблизительная оценка занимаемой памяти. Везде далее считается, что
число вершин графа N = card(V) и число дуг (или ребер) графа М = card(E) —
величины постоянные. Можно считать, что вершины графа имеют номера
от 0 до N-\, а каждая дуга характеризуется парой номеров вершин,
инцидентных этой дуге.
В листингах будем представлять только структуру графов, причем для
каждого способа представления опишем только конструктор соответствующего
класса и операции добавления новой дуги и проверки наличия дуги с
заданными концами. Для простоты опущены деструкторы графов и некоторые
другие, несущественные для данного раздела, подробности. Поскольку
большинство операций, представленных ниже в листингах, одинаковы для всех
представлений графов, имеет смысл описать абстрактный класс Graph, в
котором и определить все эти операции в виде пустых виртуальных функций.
Тогда конкретные представления графов будут описаны в виде классов,
производных от базового класса Graph.
Первое из описываемых представлений графа основано на следующих
соображениях. Структуру графа можно описать, сопоставив каждой вершине
множество дуг, выходящих из нее, причем каждая дуга, выходящая из
вершины и, идентифицируется своим концом— номером вершины, в которую
эта дуга входит. Таким образом, граф представляется массивом, в котором
каждому номеру вершины и в диапазоне от 0 до N сопоставлено множество
46
Глава 1
целых чисел — номеров вершин, в которые входят дуги, исходящие из
выбранной вершины и. Описанное представление графа будем называть
S-графом (от set— множество). Будем считать, что множество целых чисел в
диапазоне от 0 до N представлено объектом класса Set из разд. 1.4. Тогда
«У-граф может быть описан следующим классом (в листинге 1.9 представлено
описание абстрактного класса Graph и производного класса SetGraph).
// файл graph.h
class Graph {
public:
// Функция addArc позволяет добавить к графу новую дугу,
// ведущую из вершины с номером from в вершину с номером to.
virtual void addArc (int from, int to) = 0;
// Функция hasArc проверяет, имеется ли в графе дуга, ведущая
//из вершины с номером from в вершину с номером to.
virtual bool hasArc(int from, int to) const = 0;
// Функция vertexCount выдает число вершин графа
virtual int vertexCount() const = 0;
};
// файл setgraph.h
#include "graph.h"
#include "set.h" // Определение класса для работы с множествами
class SetGraph : public Graph {
Set **graph; // Массив множеств дуг
int vertexNumber; // Число вершин
public:
// Конструктор графа с п вершинами создает массив из пустых множеств
SetGraph(int n) : vertexNumber(n), graph(new Set*[n]) {
for (int i = 0; i < n; i++) { graphfi] = new Set(0,n); }
}
// Деструктор уничтожает массив множеств
-SetGraph();
// Функция подсчета числа вершин просто выдает
// ранее сохраненное значение
int vertexCount() const { return vertexNumber; }
Способы представления структур данных
47
11 Основные методы работы с графом
void addArc(int from, int to);
bool hasArc(int from, int to) const;
};
Все виртуальные функции легко реализуются с помощью соответствующих
операций над множествами. Так, принадлежность дуги (w, v) графу может
быть легко реализована следующим образом:
bool SetGraph::hasArc(int u, int v) const {
if (u < 0 || u >= vertexNumber || v < 0 || v >= vertexNumber)
return false; // Неправильно задана дуга
return graph[u]->has(v);
}
Аналогично, добавление дуги к графу реализуется так:
void SetGraph::addArc(int u, int v) {
if (u < 0 || u >= vertexNumber || v < 0 || v >= vertexNumber)
return; // Неправильно задана дуга
*graph[u] |= v;
}
В данной реализации при попытке задать номера вершин, которых заведомо
нет в графе, функции просто ничего не делают. Возможно, более правильной
моделью поведения было бы возбуждение исключительной ситуации.
В данном представлении также легко и эффективно реализуется метод,
позволяющий удалить дугу с заданными номерами инцидентных ей вершин.
Несколько сложнее решается задача перебора дуг, инцидентных данной
вершине. Например, для того чтобы определить количество дуг, выходящих из
вершины с заданным номером, нужно иметь возможность определять
количество элементов в множестве. Если считать, что класс set содержит метод
card для подсчета числа элементов множества (мощности множества), то
число выходящих из заданной вершины и дуг легко получить с помощью
выражения graph[u] .card(), однако число входящих в заданную вершину дуг
может быть получено только путем перебора всех вершин графа.
Еще один распространенный способ представления графа — это
представление в виде матрицы смежности размером N х N. В этой матрице в элементе
с индексами (ij) записывается информация о дугах, ведущих из вершины с
номером / в вершину с номером j. В важном частном случае простого графа,
т. е. графа, в котором каждая упорядоченная пара вершин соединена не более
чем одной дугой, и отсутствуют дуги вида (w, w), элементы матрицы могут
принимать значения 0 или 1 в зависимости от того, существует ли соответст-
48
Глава 1
вующая дуга. Ясно, что такое представление самым тесным образом связано
с представлением, описанным выше в определении класса SetGraph.
Представление в виде матрицы смежности будем называть М-графом.
Удобно связывать с матрицей смежности информацию о нагрузке на дуги. В этом
случае элементом матрицы будет либо значение нагрузки на
соответствующую дугу, либо некоторое стандартное значение, говорящее об отсутствии
дуги. Если, например, граф моделирует транспортную сеть, в которой
нагрузка на дугу представляет расстояние между пунктами, соединенными этой
дугой, то признаком отсутствия дуги удобно сделать значение 0 или,
напротив, максимально возможное целое, смотря по тому, что окажется более
удобным в используемом алгоритме. В общем виде, если тип нагрузки
представлен значениями класса W, то представление графа будет содержать
двумерный массив значений класса W. Мы для простоты не будем
интересоваться нагрузкой на дуги графа, таким образом, элементами матрицы,
представляющей М-граф, могут быть просто логические значения.
В листинге 1.10 приведено описание класса для представления графа в виде
матрицы смежности.
\ Листинг 1.10. Определение М-графа
// файл MatrixGraph.h
#include "graph.h"
class MatrixGraph : public Graph {
bool **graph;
int vertexNumber;
public:
// Конструктор с параметром - количеством вершин графа.
MatrixGraph(int n);
// Количество вершин постоянно и содержится в поле vertexNumber
int vertexCount() const { return vertexNumber; }
// Остальные виртуальные функции, реализацию которых требуется
// предоставить при определении конкретного представления графа
void addArc(int from, int to);
bool hasArc(int from, int to) const;
};
// файл MatrixGraph. cpp -r
#include "MatrixGraph.h"
Способы представления структур данных
49
II Реализация конструктора - заказ и инициализация памяти
// под двумерный массив логических значений
MatrixGraph::MatrixGraph(int n) {
graph = new bool*[vertexNumber = n];
for (int i = 0; i < n; i++) {
bool * row = graph[i] = new bool[n];
for (int j =0; j < n; j++) {
row[j] = false;
}
}
}
bool MatrixGraph::hasArc(int from, int to) const {
if (from < 0 || from >= vertexNumber || to < 0 || to >= vertexNumber)
return false; // неправильно заданы номера вершин
return graph[from] [to];
}
void MatrixGraph::addArc(int from, int to) {
if (from < 0 || from >= vertexNumber || to < 0 || to >= vertexNumber)
return; // невозможно добавить дугу
graph[from][to] = true;
}
Поскольку представление М-графа очень близко к представлению S-графа, то
и операции над М-графом выполняются практически с той же степенью
эффективности, что и для S-графа. Удобными и эффективно реализуемыми
операциями над М-графом являются операции проверки наличия дуги, значения
ее нагрузки (в случае нагруженного графа), добавления и удаления дуг,
изменения нагрузки.
Менее эффективными операциями будут операция подсчета или перебора
дуг, инцидентных заданной вершине. Между тем во многих алгоритмах
именно эта операция является самой часто используемой. Структура графа
может меняться крайне редко, а вот исследоваться эта структура будет
достаточно часто, причем инцидентность вершин и дуг чаще всего является
главным предметом таких исследований. В этом случае представление графа
в виде матрицы смежности будет неудобным.
Еще одно соображение. Если число вершин графа велико, то матрица
смежности будет занимать достаточно много места. В то же время в графе
количество дуг чаще всего существенно меньше NxN— размера матрицы, так что
большинство элементов матрицы смежности будут пустыми. В этом случае
более удобным будет представление, в котором с каждой вершиной графа
50
Глава 1
связывается список исходящих из нее дуг. Списки вообще удобны тем, что
могут содержать переменное количество элементов, при этом общий размер
занимаемой ими памяти соответствует количеству элементов списка. Каждый
элемент списка будет содержать информацию об одной дуге, причем номер
вершины, из которой эта дуга исходит, определяется самим
местоположением списка, так что его можно в самом элементе списка не хранить.
Информация о дуге будет содержать номер вершины, в которую эта дуга входит, а
в случае нагруженного графа также и значение нагрузки на дугу.
Соответствующее представление будем называть L-графом (от list—
список). В листинге 1.11 приведено возможное описание класса и реализация
операций для ненагруженного Z-графа. Кроме описания класса для Z-графа в
листинге показано определение простого однонаправленного списка
объектов с операциями добавления элемента в список и проверки наличия
элемента в списке.
// файл ListGraph.h
#include "graph.h" // Описание родительского класса
// Описание шаблона классов для представления
// простых однонаправленных списков
template <class T>
class List {
// Внутренний класс для представления элементов списка
struct Listltem {
Т item; // Собственно элемент списка
Listltem *next; // Ссылка на следующий элемент списка
// Конструктор элементов введен для удобства реализации операций
Listltem(const T &item, Listltem *next = NULL) {
Listltem: :item = item;
Listltem::next = next;
}
>;
// Список представлен ссылками на первый и последний элементы...
Listltem *first, *last;
// ...а также счетчиком числа элементов
int count;
public:
// Конструктор пустого списка - единственный в нашем описании
List () { first = last = NULL; count = 0; }
Способы представления структур данных
51
// Операции добавления элемента...
void add(const T &);
// ...и проверки наличия элемента в списке
bool has(const T &) const;
>;
// Собственно описание класса для представления L-графа
class ListGraph : public Graph {
// Массив списков целых, каждое из которых задает
// номер вершины, в которую входит дуга. Индексы в массиве
// задают номера вершин, из которых эти дуги исходят.
List<int> *graph;
// Количество вершин в графе.
int vertexNumber;
public:
// Конструктор порождает N списков и инициализирует массив этих
// списков, запоминая количество вершин, заданное аргументом
ListGraph(int n) { graph = new List<int>[vertexNumber = n]; }
// Операция подсчета вершин просто выдает запомненное ранее значение
int vertexCount() const { return vertexNumber; }
// Две следующие функции реализуют операции над дугами
void addArc(int from, int to);
bool hasArc(int from, int to) const;
};
// файл ListGraph.cpp
#include "ListGraph.h"
// Реализация операций над списком.
// Добавление нового элемента в список
template <class T>
void List<T>::add(const T & item) {
Listltem *newltem = new Listltem(item) ;
// Новый элемент включается в конец списка
if (last) last->next = newltem; else first = newltem;
last = newltem;
count++;
}
// Проверка наличия элемента в списке
template <class T>
bool List<T>::has(const T & item) const {
52
Глава 1
// Список просматривается с начала в поисках нужного элемента
for (ListItem *current = first; current; current = current->next) {
if (current->item = item) return true;
}
// Элемент не найден
return false;
}
// Реализация операций над графом.
// Операция добавления дуги.
void ListGraph::addArc(int from, int to) {
if (from < 0 |! from >= vertexNumber || to < 0 || to >= vertexNumber)
return; // невозможно добавить дугу
// Добавление новой дуги в конец списка
graph[from].add(to);
}
// Операция проверки наличия дуги.
bool ListGraph::hasArc(int from, int to) const {
if (from < 0 || from >= vertexNumber || to < 0 || to >= vertexNumber)
return false; // неправильно заданы номера вершин
//В списке с номером from ищем элемент to.
return graph[from].has(to);
}
Удаление дуги при таком представлении не удается реализовать столь же
просто, как это было сделано для 5-графа и М-графа. Для этого придется
просмотреть соответствующий список, найти в нем нужную дугу и удалить ее из
списка.
Представление графа в виде совокупности списков дуг становится
неудобным в тех случаях, когда наиболее часто используются операции проверки
наличия или модификации конкретной дуги, т. е. как раз те операции,
которые наиболее эффективно реализуются для ^-графов и М-графов. Зато
эффективно реализуется перебор всех дуг, исходящих из заданной вершины,
операция, которая сравнительно долго выполняется для ^-графов и М-графов.
Правда, для того чтобы найти дуги, входящие в заданную вершину,
приходится вообще просмотреть все списки, т. е. пройти полностью всю структуру
графа.
Иногда используются и другие представления графов, например, для случая
очень разреженных графов, когда при большом количестве N вершин графа
число дуг существенно меньше N*N9 например, порядка N. Одним из таких
представлений является представление в виде списка дуг, когда все дуги
графа собраны в единый список, в каждом элементе которого записана инфор-
Способы представления структур данных
53
мация об обоих концах дуги, а также, возможно, о нагрузке на дугу. Другим
возможным представлением графа является матрица инцидентности,
содержащая N строк и М столбцов, в которой элемент с индексами (ij) равен
единице, если /-я вершина инцидентнау-му ребру, и нулю — в противном случае.
В последнем примере речь идет о неориентированном графе, поскольку в
описанном представлении матрицы инцидентности не содержится
информации о направленности дуги, однако это представление легко модифицировать
и для случая ориентированного графа. Например, можно положить элемент
с индексами {ij) равным 1, если дуга у входит в вершину /, и -1, если дуга у
исходит из вершины /.
В листинге 1.12 приведено представление графа в виде списка дуг. Такое
представление будем называть Л-графом от слова Arc — дуга. Список дуг
представлен списком элементов, каждый из которых содержит два целых
числа— номера концов дуги. В данной реализации список не представлен в
виде отдельного описания класса. Вместо этого описание элемента такого
списка— дуги— внесено прямо в описание класса для представления
Л-графа, а реализации операций над списком просто являются частью
реализации операций над дугами графа.
Операция добавления дуги в этом представлении — это просто операция
добавления одного элемента в конец списка, и реализуется она сравнительно
эффективно. Напротив, при выполнении операции поиска дуги может
потребоваться просмотреть весь список. Разумеется, настолько же неэффективной
будет и операция удаления дуги.
| Листинг 1.12. Представление Д-графа
// файл ArcGraph.h
#include "graph.h" // Определение родительского класса
// Описание класса для представления А-графа
class ArcGraph : public Graph {
// Дуга представлена элементом списка, содержащим номера
// концов дуги и указатель на следующий элемент списка
struct Arc {
int begin, end;
Arc *next;
// Конструктор дуги.
Arc(int b, int e, Arc *n = NULL) {
begin = b; end = e; next = n;
}
};
54
Глава 1
II Список дуг представлен, как обычно,
// указателями на первый и последний элементы списка
Arc *first, *last;
// arcCount - счетчик числа дуг-элементов списка
int arcCount;
// vertexNumber - количество вершин графа, используемое
II в данном представлении только для контроля номеров вершин
int vertexNumber;
public:
// Конструктор графа инициализирует пустой список
//и запоминает количество вершин графа.
ArcGraph(int n) {
first = last = NULL; arcCount = 0; vertexNumber = n;
}
// Функция подсчета количества вершин выдает запомненное значение
int vertexCount() const { return vertexNumber; }
// Операции над дугами:
void addArc(int from, int to);
bool hasArc(int from, int to) const;
>;
// файл ArcGraph.cpp
#include "ArcGraph.h"
// Реализация операции добавления дуги
void ArcGraph: raddArc (int from, int to) {
// Сначала проверяем правильность задания номеров вершин.
if (from < 0 || to < 0 || from >= vertexNumber || to >= vertexNumber)
return;
Arc *newArc = new Arc(from, to);
// Новая дуга добавляется в конец списка
if (last) last->next = newArc; else first = newArc;
last = newArc;
arcCount++;
}
// Реализация операции проверки наличия дуги.
bool ArcGraph::hasArc(int from, int to) const {
Способы представления структур данных
55
// Необходимо просмотреть список дуг в поисках нужной.
for (Arc *current = first; current; current = current->next) {
if (current->begin = from && current->end = to)
return true;
}
return false;
}
Конечно, выбирать представление графа для решения конкретной задачи
нужно исходя из того, какие операции с графами будут выполняться чаще
всего. Если в процессе работы граф часто меняет структуру связей, то,
наверное, лучшим способом представления будет матрица смежности. Если
наиболее частой операцией будет поиск путей в графе, проходящих по
направлению дуг, то более удобным и выгодным способом будет способ
представления графа в виде списков смежности.
Однако иногда бывает, что выбрать какой-то один способ представления
графа трудно, например, потому, что в разные периоды времени работы
программы требуется выполнять различные операции. В этом случае было бы
удобно переходить от одного представления графа к другому. К счастью,
такие преобразования занимают не очень много времени. Конечно, для
преобразования придется, по крайней мере, один раз просмотреть весь граф, но
время, затраченное на построение нового представления, всегда будет прямо
пропорционально размеру графа. Это означает, что если время работы
некоторого алгоритма на графе имеет порядок сложности не меньше размера
графа, то скорость его работы практически не зависит от способа представления
графа, поскольку всегда можно перед началом работы выполнить нужное
преобразование без существенной потери эффективности основного
алгоритма.
Итак, в заключение этой главы приведем функции преобразования, которые
могли бы использоваться для изменения представления графа. Так
определенные функции должны, как правило, иметь доступ к структурам как
исходного, так и результирующего графов, так что в соответствующих описаниях
классов эти функции должны быть объявлены "друзьями" соответствующих
классов. В листинге 1.13 описаны четыре функции для следующего
преобразования структур: М-граф -» 5-граф -» Z-граф -> Л-граф -» М-граф. Таким
образом, с помощью этих функций можно из каждого описанного
представления графа получить любое другое представление.
_^^„....,,„.,,.,^ „.,,„.„„.,„„. , ,..,.„. , ,.
[Листинг 1.13. Функции преобразования представлений графов
// файл convert.срр
#include "SetGraph.h"
#include "MatrixGraph.h"
56
Глава 1
#indude "ListGraph.h"
#include "ArcGraph.h"
// Функция преобразования представления из М-графа в S-граф
SetGraph * convert(const MatrixGraph & srcGraph) {
int n = srcGraph.vertexCount();
SetGraph *destGraph = new SetGraph(n);
for (int i = 0; i < n; i++) {
// Представление строки исходного графа:
bool * srcRow = srcGraph.graph[i];
// Соответствующее множество результирующего графа:
Set & destRow = *destGraph->graph[i];
for (int j = 0; j < n; j++) {
// Новая дуга записывается с помощью операции
// добавления элемента к множеству
if (srcRow[j]) destRow != j;
}
}
return destGraph;
}
// Функция преобразования представления из S-графа в L-граф
ListGraph * convert(const SetGraph & srcGraph) {
int n = srcGraph.vertexCount();
ListGraph *destGraph = new ListGraph(n);
for (int i = 0; i < n; i++) {
// Представление строки исходного графа:
Set & srcRow = *srcGraph.graph[i];
// Соответствующий список дуг в результирующем графе:
List<int> & destRow = destGraph->graph[i];
for (int j = 0; j < n; j++) {
// Обе операции - проверка принадлежности множеству и добавления
// элемента в список выполняются за фиксированное время.
if (srcRow.has(j)) destRow.add(i);
}
}
return destGraph;
}
// Функция преобразования представления из L-графа в А-граф.
//В этой функции используются только открытые операции А-графа,
// поэтому она может и не "дружить" с описанием класса ArcGraph
ArcGraph * convert(const ListGraph & srcGraph) {
Способы представления структур данных
57
int n = srcGraph.vertexCount();
ArcGraph *destGraph = new ArcGraph(n);
for (int i = 0; i < n; i++) {
List<int>:-.Listltem ^current = srcGraph.graph [i] . first;
while (current) {
// Добавление дуги в список дуг с помощью операции addArc.
destGraph->addArc(i, current->item);
current = current->next;
}
}
return destGraph;
// Функция преобразования представления из А-графа в М-граф.
MatrixGraph * convert(const ArcGraph & srcGraph) {
int n = srcGraph.vertexCount();
MatrixGraph *destGraph = new MatrixGraph(n);
// В цикле перебираются все дуги
for (ArcGraph::Arc * current = srcGraph.first;
current; current = current->next) {
destGraph->graph[current->begin][current->end] = true;
}
return destGraph;
}
Приведенные определения классов и операций над графами можно найти в
папке "\chapterl\l.5\Graphs" на компакт-диске. Там же содержится
программа, проверяющая работу функций преобразования графов.
ГЛАВА 2 Х^ЬАЙ
Базовые алгоритмы
В этой главе рассматривается несколько важных алгоритмов и структур
данных и обсуждаются операции над ними. Понятие абстрактного типа данных,
вводимое в разд. 2.1, является очень важным понятием, используемым как в
теории, так и в практике программирования. На основе абстрактных типов
данных определяются способы описания интерфейсов и реализаций многих
важных структур данных, например таких, как стеки и очереди. Здесь же
рассматриваются решения задач сортировок в массивах, которые служат
хорошей иллюстрацией того, как достаточно сложные алгоритмы реализуются на
практике и связываются с понятием типов и структур данных.
Значительная часть главы посвящена обсуждению итерации элементов
сложных структур. Способы прохождения деревьев, которые рассматриваются в
конце главы, иллюстрируют методы итерации в сложных ситуациях.
2.1. Абстрактные типы данных
В предыдущей главе мы говорили о способах представления структур
данных. Однако не менее важным является вопрос о способах доступа к
элементам этих структур. Конечно, именно способ представления часто определяет
методы доступа к элементам, однако иногда можно ввести методы доступа
независимо от представления структуры и рассматривать различные способы
реализации этих методов для разных представлений этой структуры.
Действительно, нам не так уж важно, как представлены, скажем, вещественные
числа в памяти в конкретной реализации языка C++, но для нас существенно,
что над ними можно выполнять арифметические операции, которые
подчиняются определенным, точно заданным законам. В этом смысле операции над
вещественными числами не зависят (ну, скажем, почти не зависят) от
конкретного представления, хотя, разумеется, реализация этих операций будет
использовать конкретное представление чисел.
Базовые алгоритмы
59
Когда хотят отделить понятие об операциях от конкретного представления
тех или иных данных, говорят, что задается абстрактный тип данных.
Абстрактный тип данных можно определить, если перечислить все операции,
разрешенные над значениями этого типа, и задать правила, которым должны
удовлетворять эти операции. В теории программирования такие правила
часто задаются с помощью специальных уравнений или аксиом; мы, однако,
будем ограничиваться словесным описанием правил, которым должны
удовлетворять такие операции.
В качестве примера абстрактного типа данных рассмотрим понятие
абстрактной даты, задающей число, месяц и год григорианского календаря.
Легко представить себе набор операций, которые можно выполнять над
датами. Это операции доступа, позволяющие проверить или установить
конкретное значение числа, месяца или года заданной даты, операции
арифметического сдвига даты на заданное число дней, месяцев или лет, операции
сравнения дат.
Можно сказать, что абстрактный тип данных "дата" представлен множеством
характерных для этого типа операций, при этом между операциями должны
существовать определенные отношения. Например, если в наборе операций
имеются операции сравнения дат и операции увеличения и уменьшения даты
на заданное количество дней, то имеет смысл задать следующую аксиому:
"дата, увеличенная на положительное число дней, больше исходной даты".
Задать набор операций сравнительно нетрудно, надо лишь определить, какой
тип имеют аргументы этих операций и каков тип результата операций. Задать
исчерпывающий набор аксиом, который бы полностью характеризовал
поведение объектов этого типа данных, значительно сложнее. Аксиоматика
такого рода и способы ее задания являются предметом серьезных научных
исследований. При программировании абстрактных типов данных обычно вместо
строгих аксиом дают пояснения, раскрывающие смысл операций над
абстрактным типом данных. Считается, что таких пояснений при наличии
здравого смысла вполне достаточно для практических нужд.
Чтобы описать набор операций некоторого абстрактного типа данных на
языке C++, требуется определить класс, в котором эти операции будут
объявлены как методы класса. Можно сказать, что такое описание определяет
интерфейс взаимодействия с абстрактным типом данных. Поскольку чаще всего
никакой реализации этих методов непосредственно в интерфейсе не
подразумевается, то можно не задавать тела соответствующих функций, оставляя их
"чистыми". Разумеется, это означает, что объектов описанного класса не
существует, а соответствующий класс называется в C++ абстрактным классом.
Такой класс будет служить базовым классом для реализации конкретных
представлений (воплощений, реализаций) объектов, заданных абстрактно.
60
Глава 2
Для того чтобы задать "аксиоматику" описываемого абстрактного типа
данных, при описании операций вставляют комментарии, разъясняющие смысл
выполняемых операций. Кроме того, в большинстве случаев будет разумным
определить виртуальный деструктор с пустым телом, поскольку, возможно,
потребуется определять деструкторы в классах, реализующих данный
интерфейс.
Итак, пусть требуется определить абстрактный тип данных дата
григорианского календаря с операциями сравнения дат, изменения дат на определенное
количество дней, месяцев или лет, преобразования даты в строковые
форматы. Опишем абстрактный класс Date, в котором определим соответствующие
операции. Семантику отдельных операций и их аргументов будем задавать
с помощью комментариев (листинг 2.1).
j Листинг 2.1. Определение интерфейса для даты григорианского календаря
// файл date.h
/*
* Абстрактный тип данных Date служит для представления дат
* григорианского календаря и операций над ними
• * /
class Date {
public:
// Виртуальный деструктор
virtual -Date() {}
// Следующие функции реализуют доступ к полям даты.
// Конечно, эти поля, как и весь интерфейс, - абстрактные, так что
// они не обязательно должны существовать в виде реальных
// переменных - членов класса в реализации интерфейса.
// Запрос и установка номера дня (от 1 до последнего числа месяца):
virtual int getDate() const = 0;
virtual void setDate(int day) = 0;
// Запрос и установка номера месяца (от 1 - январь до 12 - декабрь):
virtual int getMonth() const = 0;
virtual void setMonth(int month) = 0;
// Запрос и установка года. Конкретные реализации могут накладывать
// свои ограничения на значение этого поля. Отметим, в частности, что
// григорианский календарь в большинстве стран Европы начал действовать
// с 15 октября 1582 г., в Англии - с 1752 г., а в России - лишь
// с 14 февраля 1918 г.
Базовые алгоритмы
61
virtual int getYear() const = 0;
virtual void setYear(int year) = 0;
// Функции сдвига даты позволяют увеличить или уменьшить дату на
// определенное число дней, месяцев или лет. Для корректной работы
// этих функций необходимо иметь в виду, что при переходе через границу
// месяца номер дня меняется в соответствии с количеством дней
//в месяце, а также то, что при добавлении или вычитании некоторого
// числа месяцев номер дня может выйти за границу количества дней
//в получающемся месяце.
virtual Date & addDate(int days =1) = 0;
virtual Date & addMonth(int months =1) = 0;
virtual Date & addYear(int years =1) = 0;
virtual Date & subDate(int days =1) =0;
virtual Date & subMonth(int months = 1) = 0;
virtual Date & subYear(int years =1) = 0;
// Оператор присваивания позволяет создавать копии даты:
virtual Date & operator = (const Date & date) = 0;
// Операции, совмещенные с присваиваниями, являются просто удобными
// сокращениями для операций сдвига даты на заданное количество дней,
// поэтому они представлены не чистыми функциями, как большинство
// других операций, а имеют свою реализацию прямо внутри класса.
virtual Date & operator += (int days) { return addDate(days); }
virtual Date & operator -= (int days) { return subDate(days); }
virtual Date & operator ++ () { return addDate(); }
virtual Date & operator — () { return subDate(); }
// Все операторы сравнения определены друг через друга.
// Это позволяет в реализации определить только минимальный набор
// из трех операторов, например, <, <= и ==. Разумеется, каждая
// реализация вправе предъявить и полный набор всех операций.
virtual bool operator < (const Date & date) const
{ return !(*this >= date); }
virtual bool operator == (const Date & date) const
{ return !(*this != date); }
virtual bool operator > (const Date & date) const
{ return !(*this <= date); }
virtual bool operator <= (const Date & date) const
{ return. ! (*this > date); }
virtual bool operator != (const Date & date) const
{ return !(*this == date); }
62
Глава 2
virtual bool operator >= (const Date & date) const
{ return !(*this < date); }
// Операция преобразования даты в строку (форматирование) будет
// иметь реализацию по умолчанию, в которой строка, представляющая
// дату, будет составлена из полей этой даты
virtual const char * format() const;
};
Итак, определен абстрактный тип данных, и, следовательно, можно уже
писать программы для обработки дат. Например, можно рассчитывать даты
пасхалий на заданный год или составлять календарь праздничных дней.
Единственная трудность состоит в том, что пока невозможно создавать новые
объекты типа дата, поскольку таких объектов еще не существует. Сначала
надо определить хотя бы одну реализацию нашего абстрактного типа данных,
тогда можно будет и создавать объекты соответствующего типа. Однако если
подобный объект будет создан, то всю работу с ним можно будет выполнять
в терминах уже объявленных операций, в этом смысле наша программа не
будет зависеть от конкретной реализации даты.
Существует множество различных способов представления дат и работы с
ними. Один из наиболее очевидных способов реализации — это представить
дату в виде набора из трех целых значений: номеров дня, месяца и года.
Будем называть такое представление структурным представлением даты.
Другой возможный способ — это представить дату в виде номера дня,
отсчитываемого от какой-либо фиксированной даты, не важно от какой именно.
Например, можно принять в качестве точки отсчета 1 января первого года
(Рождество Христово), правда, не очень понятно, как считать дни до
григорианской реформы. Можно во избежание путаницы принять за точку отсчета
саму дату введения григорианского календаря— 15 октября 1582 года, тогда
для положительных значений счетчика дней наш календарь будет совпадать с
европейским.
Мы приведем в качестве примера оба упомянутых представления, хотя и не
все реализации операций будут приведены в тексте книги. Кроме того, в
реализации будут опущены все детали, связанные с возможным неправильным
заданием аргументов, так что, например, можно будет задать дату с
отрицательным номером дня или месяцем номер 25. Тем не менее обработка
ситуаций, когда, например, к дате 30 января добавляется один месяц, происходит
корректно, так что в результате получается не 30 февраля, а в зависимости от
года либо 28, либо 29 февраля. Полностью реализации всех операций и
пример работы с датами приведены на приложенном компакт-диске в папке
"\Chapter2\2.l\Date".
Базовые алгоритмы
63
В представленном листинге 2.2 содержатся коды для некоторых функций
общего назначения (эти коды помечены как содержимое файла date, срр), a
также определения двух представлений абстрактного типа данных дата —
структурная дата (класс structDate, содержимое файла structdate.h) и дата,
представленная в виде счетчика дней (класс DaysDate, файл daysdate.h). Затем
показаны реализации некоторых операций для этих классов (файлы
structdate. срр И daysdate. срр).
// файл date.срр
#include <stdio.h>
#include "date.h"
// Функция проверки високосного года по правилам григорианского календаря
bool leapYear(int year) {
return year % 4 == 0 && (year % 100 != 0 || year % 400 = 0);
}
// Функция вычисления количества дней в заданном месяце в заданном году.
// Предполагается, что номера месяцев заданы в виде мнемонических
// констант в файле date.h (в частности, FEBRUARY = 2).
int daysInMonth(int month, int year) {
// длины месяцев обычного (невисокосного) года
static int monthLengths[] =
{ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
//В случае февраля високосного года надо добавить еще один день
return monthLengths [month-1] + (month = FEBRUARY && leap Year (year) ) ;
}
// Функция представления даты в виде строки, заданная по умолчанию
const char * Date::format() const {
static const char * monthNames [ ] = {
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"
};
// Резервируется символьный буфер достаточного размера
char * result = new char[12];
// Запись форматированной даты в буфер
sprintf(result, "%2d-%s-%4d",
getDate(), monthNames[getMonth() - 1], getYear());
return result;
}
64
Глава 2
// файл structdate.h
#include <time.h>
#include "date.h"
// Класс StructDate представляет одну из возможных реализаций абстрактной
// даты: представление даты в виде совокупности трех целых значений
class StructDate : public Date {
int d; // Номер дня - от 1 до максимального дня в месяце
int in; // Номер месяца - от 1 до 12
int у; // Номер года - любое четырехзначное число, большее 1582
public:
// Здесь представлены конструкторы структурных дат.
// Первый конструктор просто запоминает значения аргументов,
// даже не проверяя их на правдоподобность.
// Главное в такой ситуации - не перепутать порядок аргументов.
StructDate(int day, int month, int year) {
d = day;
m = month;
у = year;
}
// Конструктор по умолчанию формирует сегодняшнюю дату с помощью
// пакета TIME, известного еще со времен DOS.
StructDate() {
// Текущая дата берется с помощью функции 'time' из пакета TIME
time_t tim;
time (&tm) ;
// Преобразование в формат (день, месяц, год) также делается
// стандартным методом с помощью функции localtime
struct tm * dt = localtime (&tm) ;
d = dt->tm_mday;
m = dt->tm_mon + 1;
у = dt->tm_year + 1900; }
// Конструктор копирования пользуется определенной далее
// операцией присваивания для создания даты по образцу.
StructDate(const Date & date) { *this = date; }
//===========================^================================
// Далее заявляются реализации чистых виртуальных функций, заголовки
// которых были определены в интерфейсе нашего абстрактного типа
// данных - классе Date.
Базовые алгоритмы
65
// Некоторые из простых функций определены прямо в описании класса.
// Смысл функций описан ранее, поэтому здесь
// заголовки функций приводятся без комментариев.
//
// Функции доступа, вообще говоря, реализуются непосредственным
// обращением к соответствующим полям. Однако при установке
// месяца и года может также потребоваться коррекция дня.
//==«=«=«=__===
void setDate(int day) { d = day; }
int getDate() const { return d; }
void setMonth(int month);
int getMonth() const { return m; }
void setYear(int year);
int getYear() const { return y; }
//======================^===================================
// Функции сдвига дат
//============_=======================^========_=====^=^===_===
Date & addDate(int days = 1);
Date & addMonth(int months = 1);
Date & addYear(int years =1);
Date & subDate(int days = 1);
Date & subMonth(int months = 1);
Date & subYear(int years = 1);
//=======================================_====================
// Оператор присваивания реализован в соответствии с соглашениями
// для данного оператора: он возвращает ссылку на измененную дату.
Date & operator = (const Date & date);
//===================================_^========================
// Операторы сравнения дат используют лексикографический порядок дат
bool operator < (const Date & date) const;
bool operator == (const Date & date) const;
bool operator > (const Date & date) const;
};
// файл structdate.cpp
#include "structdate.h"
66
Глава 2
// Реализация этой функции чуть сложнее остальных функций доступа,
// т. к. необходимо еще проверить, не получилась ли неправильная дата.
void StructDate::setMonth(int month) {
m = month;
// теперь проверяем, есть ли день d в месяце m года у.
int limit = daysInMonth(m, у);
if (d > limit) d = limit;
}
// Добавление к дате определенного количества дней реализовано путем
// последовательного добавления к дате количеств дней в очередном месяце.
// Это будет долго работать, если задано большое количество дней для
// добавления; предполагается, что все-таки это число сравнительно мало.
Date & StructDate:raddDate(int days) {
if (days < 0) return subDate(-days);
while (days > 0) {
// Сначала проверяем, нужно ли переходить к следующему месяцу.
int mDays = daysInMonth (m, у) ;
if (d + days <= mDays) {
// остаемся в текущем месяце и завершаем работу,
d += days;
days = 0;
} else {
// переходим к первому числу следующего месяца и продолжаем работу.
days -= (mDays - d + 1);
d = 1;
if (++m > DECEMBER) {
// Переходим через границу года
m = JANUARY;
y++;
}
}
}
return *this;
}
// В этой функции можно просто посчитать, сколько
// лет и месяцев надо добавить, так что функция работает
// быстро даже для большого значения аргумента.
Date & StructDate::addMonth(int months) {
if (months < 0) return subMonth(-months);
int years = months / 12; // количество добавляемых лет
months %= 12; //и количество добавляемых месяцев.
Базовые алгоритмы
67
у += years;
m += months;
// Коррекция даты на случай перехода через границу года.
if (m > DECEMBER) {
m -= 12; y++;
}
// Еще одна коррекция на случай, если попали в месяц,
//в котором нет дня, заданного исходной датой.
int limit = daysInMonth(m, у);
if (d > limit) d = limit;
return *this;
}
// Добавление лет - самая простая из подобных операций.
Date & StructDate::addYear(int years) {
у += years;
return *this;
}
// Реализации функций вычитания дней, месяцев и лет опущены
// Оператор присваивания использует абстрактные функции аргумента,
// и поэтому может применяться с аргументом, реализованным как угодно.
Date & StructDate::operator = (const Date & date) {
d = date.getDate();
m = date.getMonth();
у = date.getYear();
return *this;
}
// Операторы сравнения используют алгоритм лексикографического сравнения
// троек чисел. Здесь представлена только реализация оператора "меньше",
// но на самом деле необходимо реализовать по крайней мере три оператора
bool StructDate::operator < (const Date & date) const {
// Значения полей аргумента извлекаются с помощью абстрактных функций
int date_d = date.getDate();
int datejn = date.getMonth();
int date_y = date.getYear();
// Теперь сравнение происходит по полям.
return у < date_y I|
(у == date_y && (m < datejn | |
(m == datejn && d < date_d)));
}
68
Глава 2
// файл daysdate.h
#include "date.h"
// Класс DaysDate представляет еще одну из возможных реализаций
// абстрактной даты: представление даты в виде счетчика дней.
//В качестве нулевой даты выбрано 31 декабря 1599 года.
// Правильная работа с датами гарантируется для дат
// с 1 января 1600 года по 31 декабря 9999 года.
class DaysDate : public Date {
long days; // Число дней, прошедших с 31 декабря 1599 года
public:
// Конструкторы дат - такие же, как и для класса StructDate
DaysDate();
DaysDate(const DaysDate & date) { *this = date; }
DaysDate(int day, int month, int year);
// Все чистые виртуальные функции класса Date переопределяются
void setDate(int day);
int getDate() const;
void setMonth(int month);
int getMonth() const;
void setYear(int year);
int getYear() const;
Date & addDate(int days =1);
Date & subDate(int days = 1);
Date & addMonth(int months = 1);
Date & subMonth(int months =1);
Date & addYear(int years =1);
Date & subYear(int years =1);
Date & operator = (const Date & date);
bool operator < (const Date & date) const;
bool operator == (const Date & date) const;
bool operator > (const Date & date) const;
bool operator <= (const Date & date) const;
bool operator != (const Date & date) const;
bool operator >= (const Date & date) const;
};
// файл daysdate.cpp
#include <time.h>
#include "daysdate.h"
Базовые алгоритмы
69
//В этом файле представлены реализации некоторых
// функций-членов класса DaysDate.
//В данной реализации наиболее важными являются две следующие
// функции преобразования представлений. Они дают возможность
// свободно переключаться между двумя основными способами
// представления дат - в виде структуры и в виде счетчика дней
// Массив monthLength представляет количество дней,
// прошедшие с начала года до первого числа каждого из месяцев
//в обычном и високосном годах.
static const int monthLength[2][12] = {
{ 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334 },
{ 0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335 }
};
// Следующая функция вычисляет для заданных дня, месяца и года
// число дней, прошедших от 31 декабря 1599 года до этой даты.
// Функция используется для перевода из представления даты,
// привычного для человека, в представление в виде счетчика дней.
static long convert(int d, int m, int y) {
// Сначала вычисляем число полных лет, прошедших до заданной даты
int year = у - 1600;
// Потом вычислим, сколько дней это составило
long days = year*365 + year/4 - year/100 + year/400;
// Если год високосный, то сначала делаем обратную поправку,
// поскольку пока что сосчитали дни только до 1 января...
bool leap = leapYear(y);
if (leap) days—;
// И, наконец, добавляем оставшиеся дни по заданным месяцу и дню.
return days + monthLength [leap] [iti-1] + d;
}
// Вторая вспомогательная функция делает обратное преобразование - по
// заданному счетчику дней вычисляет дату в привычном для человека виде.
static void convert (long days, int & d, int & m, int & y) {
// Сначала вычисляем год. Для дат до 9999 года такой способ вычисления
// числа дней даст ошибку заведомо не более чем на один год.
у = (int)(1600 + 4*days/(365*4 + 1)) + 1;
// Теперь смотрим, какова разница в днях между 1 января этого года и
// датой, заданной нашим счетчиком дней.
int diff = (int)(days - convert(1, JANUARY, y) + 1);
70
Глава 2
//В зависимости от заданной даты значение diff может оказаться как
// положительным, так и отрицательным. В случае отрицательного
// значения делаем коррекцию года для обеспечения 0 < diff <= 365.
if (diff <= 0) у—;
bool leap = leapYear(y);
if (diff <= 0) diff += 365 + leap;
// Теперь выбираем месяц внутри найденного года.
for (in = JANUARY; m < DECEMBER &&
diff > monthLength[leap][m]; m++)
// Оставшаяся разница дает номер дня в месяце,
d = diff - nionthLength[leap] [m-1] ;
}
// Последняя из вспомогательных функций корректирует номер дня в месяце,
// если этот день превышает максимальное число в этом месяце в этом году.
static void correctDate(int & d, int m, int y) {
int limit = daysInMonth(m, y);
if (d > limit) d = limit;
}
//Из всех конструкторов приведем только реализацию конструктора,
// создающего сегодняшнюю дату.
DaysDate::DaysDate() {
time_t tm;
time (&tm) ;
struct tm * dt = localtime(&tm);
days = convert(dt->tm_mday, dt->tm_mon + 1, dt->tm_year + 1900);
}
// Все функции доступа реализованы практически одинаково, поэтому здесь
// рассматриваются только функции getDate и setMonth.
int DaysDate::getDate() const {
int d, m, y;
convert(days, d, m, y) ;
return d;
}
void DaysDate::setMonth(int month) {
int d, m, y;
convert(days, d, m, y) ;
correctDate(d, month, y);
days = convert(d, month, y) ;
}
Базовые алгоритмы
71
//Из функций изменения даты представляем реализацию функции addMonth.
Date & DaysDate::addMonth(int months) {
if (months < 0) return subMonth(-months);
int y, m, d;
// Преобразуем счетчик дней в структурную дату.
convert(days, d, m, у);
// Корректируем месяц и год.
у += months / 12;
m += months % 12;
if (m > DECEMBER) {
У++;
m -= 12;
}
// Если надо, корректируем день месяца.
correctDate(d, m, у);
// Делаем обратное преобразование даты в счетчик дней.
days = convert(d, m, у);
return *this;
}
// Ввиду того, что оператор присваивания определен для абстрактной
// даты, приходится даже при присваивании производить преобразование
// представления из структурной даты в счетчик дней и обратно.
Date & DaysDate::operator = (const Date & date) {
days = convert(date.getDate(), date.getMonth(), date.getYear() ) ;
return *this;
}
// Аналогично устроены и функции сравнения дат.
//В данной реализации должны быть представлены все
// б операторов сравнения, но они< все очень похожи, так что
// в этом листинге опять приведена только одна реализация.
bool DaysDate::operator < (const Date & date) const {
return days < convert(date.getDate(), date.getMonth(), date.getYear());
}
Если интерфейс, определяющий абстрактный тип данных, содержит
некоторый набор методов (операций этого типа), то реализующие этот интерфейс
классы обязаны предоставить реализацию всех тех операций абстрактного
типа данных, которые в исходном интерфейсе заданы чистыми виртуальными
функциями. Также можно переопределить и некоторые из тех функций,
реализации которых уже были сделаны на уровне определения интерфейса. Это
разумно делать из соображений эффективности или определенных особенно-
72
Глава 2
стей реализации. На самом деле нередко классы, реализующие некоторый
интерфейс, вносят дополнительные методы, так что такие классы уже не
могут считаться реализацией именно этого абстрактного типа данных. В этом
случае, скорее, нужно говорить о реализации некоторого типа данных,
совместимого с исходным абстрактным типом данных.
Бывают также и случаи, когда некоторый класс является реализацией
нескольких абстрактных типов данных одновременно. Технически такое
определение может быть представлено в виде класса с множественным
наследованием. Например, если определен абстрактный тип индексированных
последовательностей (обобщение понятия массива) и абстрактный тип линейных
списков (обобщение связного списка элементов), то вполне разумным будет
определение класса, объекты которого обладают поведением как одного, так
и другого типа. Объекты этого класса будут состоять из элементов, причем
доступ к этим элементам может быть организован с помощью индексов
(поведение, характерное для массивов), в то же время для таких объектов будут
допускаться вставки и удаления элементов, характерные для списков. В этом
случае можно сказать, что класс реализует понятия сразу двух абстрактных
типов данных — массива и списка.
Все же, мы ни в коем случае не хотим поставить знак равенства между
понятиями абстрактного типа данных и абстрактными классами языка C++.
Абстрактный класс может задавать систему методов, которая вовсе не будет
представлять некоторый абстрактный тип данных. Такой класс может просто
задавать набор некоторых общих понятий и объектов для последующей
детализации и использования в ряде родственных классов. Но чаще всего
верно, что абстрактные типы данных выражаются в языке C++ с помощью
абстрактных классов.
Если в программе можно использовать как абстрактные типы данных, так и
конкретные реализации, то предпочтение следует отдавать использованию
абстрактных типов, даже если имеется всего одна реализация абстрактного
типа данных. Действительно, если функция использует только абстрактный
интерфейс с объектами других классов, то реализация такой функции
становится более общей и независимой от реализаций других классов.
Пусть, например, определен абстрактный тип данных Date и имеется лишь
одна реализация этого типа в виде определения класса DaysDate. Если мы
хотим написать функцию, вычисляющую разницу в днях между двумя
заданными датами, то заголовок этой функции может выглядеть так:
long datesDiff(const Date & dtl, const Date & dt2);
или так:
long datesDiff(const DaysDate & dtl, const DaysDate & dt2);
Базовые алгоритмы
73
Конечно, последняя функция может пользоваться особенностями
представления даты в виде счетчика дней, например, если этот счетчик дней сделан
доступным для чтения в реализации класса DaysDate или если функция
объявлена дружественной для этого класса. Такая реализация функции будет
очень простой и эффективной, поскольку результат может быть получен
простым вычитанием двух счетчиков дней. Однако функция, использующая
только абстрактные операции, будет, несомненно, более общей, т. к. будет
работать с любой реализацией этого абстрактного типа данных. Даже если в
дальнейшем не предполагается иметь много реализаций этого типа данных,
все же такая функция будет более устойчива к возможным изменениям в
реализации класса DaysDate (даже к изменению, возможно, не очень удачного
названия класса).
Зависимости от реализации не удается избежать лишь в том случае, когда
необходимо порождать новые объекты класса DaysDate. Создать новый
объект можно только с помощью конструктора, который в абстрактном классе
определять чаще всего бессмысленно. Тем не менее даже и для таких случаев
есть приемы, позволяющие существенно повысить степень абстрагирования
от конкретной реализации, сосредоточив порождение новых объектов
конкретного типа в специальном классе. Такой класс часто называют
абстрактной фабрикой; в нашем случае такая фабрика будет ответственна за
порождение новых дат. В главе 3 этой книги технология абстрактной фабрики
описывается более подробно и применяется для порождения абстрактных стеков и
очередей.
2.2. Сортировка и поиск в массивах
Очень часто приходится организовывать быстрый поиск в большом массиве
информации. Для поиска используются так называемые ключи, связанные с
каждым из элементов данных, в которых производится поиск. В общем
случае таким ключом может служить любой объект, который можно сравнивать
с другими объектами того же типа — другими ключами, однако типичными
случаями являются поиск по строке и по числовому значению.
Рассмотрим, например, базу данных, содержащую информацию о
сотрудниках некоторой фирмы. Информация о сотрудниках может включать фамилию
и другие личные данные сотрудника, информацию о принадлежности
сотрудника отделу и его подчиненности, размер заработной платы,
информацию о квалификации, об отпусках и т. п. Если требуется искать в такой базе
данных информацию, то ключом для поиска может быть, например, фамилия
(если необходимо что-то узнать о конкретном сотруднике), или зарплата
(например, если хочется узнать, каковы самые высокие заработки в фирме), или
74
Глава 2
название отдела (если, например, нужно узнать число работников в
некотором отделе).
Таким образом, в этом примере ключом для поиска действительно может
быть строка или число (зарплата, например, обычно выражается некоторым
числом). Конечно, всегда можно просто перебирать все элементы данных в
случайном порядке, но для того чтобы поиск осуществлялся по возможности
быстрее, необходимо иметь какую-то упорядоченность информации по
используемым ключам. Рассмотрим подробнее способы упорядочения
информации.
Будем считать, что имеется некоторый набор элементов данных, которые
можно сравнивать между собой с помощью стандартных операторов
сравнения "больше", "меньше", "равны" и т. д. Конечно, можно сравнивать между
собой значения не только элементарных типов данных, таких как числа или
символы, но и значения произвольного класса, однако, операторы сравнения
должны подчиняться некоторым законам. Говоря математическим языком,
операторы сравнения должны задавать на множестве значений некоторый
порядок. Проще говоря, должны выполняться естественные правила,
например, "каждое значение равно самому себе" или "если значение А меньше
значения В, а В меньше С, то А должно быть меньше С". Под упорядочением или
сортировкой этого набора будем понимать выстраивание элементов в
линейную последовательность, в которой каждый следующий элемент не меньше
предыдущего.
Способы решения такой задачи зависят от очень многих параметров:
насколько много элементов данных имеется; существует ли уже некоторая
упорядоченность в исходном наборе данных; возможен ли быстрый доступ к
любому элементу данных и т. п. Рассмотрим подробнее некоторые из этих
параметров.
Для оценки скорости работы того или иного способа сортировки
важнейшими параметрами служат относительное время, за которое можно выполнить
сравнение двух значений, и время перемещения значения на новое место.
Обычно скорость сравнения ключей зависит только от их типа, а время
перемещения данных зависит от размера элемента и способа организации данных.
Существенное влияние может оказывать и общий объем данных: если
данных очень много, то они могут не помещаться целиком в оперативной
памяти, поэтому программа или операционная система может вытеснять часть
данных во внешнюю память. Время перемещения данных в этом случае
может резко возрасти.
Если все данные изначально находятся в некоторой последовательности
(скажем, содержатся в одном линейном массиве), то время перемещения
данных может зависеть от того, насколько далеко в массиве перемещается эле-
Базовые алгоритмы
75
мент данных. В этом случае обычно считают, что перемещение сводится к
обмену местами между собой двух элементов данных. Однако если данных
очень много, и только часть из них находится в быстрой оперативной памяти,
то даже время обмена местами двух элементов данных может сильно меняться
в зависимости от того, как далеко друг от друга располагаются эти элементы.
Все эти соображения заставляют по возможности уменьшать размеры
перемещаемых элементов. Самый быстрый вариант— это тот, при котором
сортировке подлежат данные, состоящие только из ключей. Однако, даже если
это не так, можно все-таки постараться свести потери от перемещения
данных к минимуму, если отделить ключи от данных. Такая операция называется
индексацией данных, и сводится она к тому, что ключи элементов данных
копируются и заносятся в отдельную структуру (скажем, массив). Каждый
элемент этой структуры (индекса) содержит ключ и указатель на исходный
элемент данных. При такой организации можно заниматься сортировкой ключей
только в индексе, не трогая основной массив данных. Перемещаться в
индексе при этом будут только пары, состоящие из ключа и указателя, а сами
элементы данных будут оставаться на месте.
Дополнительным преимуществом индексирования данных является то, что
если элементы данных содержат несколько различных ключей (например,
фамилия, зарплата и название отдела в примере с базой данных сотрудников
фирмы), то можно организовать несколько разных индексов, сортируя
данные в этих индексах независимо друг от друга. Таким образом, удается
организовать быстрый поиск данных по разным ключам, проделав индексацию по
каждому ключу только один раз.
При описании методов сортировки обычно предполагается, что ключи
содержатся в некотором линейном массиве, и задача состоит в том, чтобы
упорядочить элементы этого массива. Чаще всего необходимо также, чтобы
результат сортировки оказался на том же самом месте в памяти, где изначально
были расположены ключи. Если при этом не требуется резервировать
дополнительной памяти больше, чем некоторый фиксированный объем, зависящий
только от метода сортировки, а не от объема сортируемых данных, то
говорят, что такая сортировка производится на месте. Если все же размер
дополнительной памяти зависит от количества сортируемых данных, то этот размер
является важной характеристикой метода сортировки.
Описание методов сортировки мы начнем не с самих сортировок, а с поиска в
уже упорядоченном массиве ключей. Описываемый нами метод поиска —
это хорошо известный алгоритм, называемый двоичным или бинарным
поиском в массиве. Этот алгоритм ищет заданный элемент в упорядоченном
массиве из ^элементов за время, пропорциональное log2^V. Конечно, это гораздо
быстрее, чем последовательный перебор элементов массива для поиска нуж-
76
Глава 2
ного элемента, если только заранее не известно, что нужный элемент массива
находится где-то недалеко от начала массива. Алгоритм двоичного поиска не
только служит обоснованием полезности методов сортировки, но и
используется в некоторых методах сортировки как \\х составная часть.
Во всех алгоритмах этой главы мы будем считать, что имеется массив
объектов некоторого класса, над которыми можно выполнять операции сравнения.
В алгоритмах задается участок этого массива, в котором и производятся
описываемые действия — сортировка или поиск. Так, например, функция,
реализующая двоичный поиск заданного ключа, получит в качестве аргументов
указатель на массив объектов, два целых числа, задающих начальный и
конечный индексы элементов в этом массиве, и сам ключ поиска. В качестве
результата функция вернет индекс найденного элемента или значение -1,
если ключа нет в заданном отрезке массива. Разумеется, предполагается, что
элементы массива упорядочены по возрастанию значений, и первый из двух
заданных индексов меньше второго.
В листинге 2.3 описан алгоритм двоичного поиска в виде шаблона функции
binSearch, параметризованного типом ключей.
j Листинг 2.3. Двоичный поиск в массиве
//
// Шаблон binSearch задает функцию двоичного поиска
//в упорядоченном массиве.
// - Key - класс, задающий тип элементов массива;
// - array - упорядоченный массив;
// - low, high - индексы, задающие диапазон поиска;
// - key - ключ, по которому производится поиск.
//
template <class Key>
int binSearch(Key * array, int low, int high, const Key & key) {
// Предполагается, что в начале работы low <= high
while (low < high) {
int middle = (low + high) / 2;
// middle - середина диапазона; low <= middle < high
if (array[middle] >= key) high = middle; else low = middle + 1;
// после проверки low <= high
}
// здесь low == high
return array[low] == key ? low : -1;
}
Базовые алгоритмы
77
Если массив или его фрагмент уже упорядочен по возрастанию ключей, то
поддерживать его упорядоченность при добавлении новых или удалении
имеющихся ключей оказывается значительно проще, чем упорядочивать все
элементы заново. Если, например, известно, что в конец упорядоченного
массива были добавлены два новых элемента, имеющие произвольное значение,
то вместо того, чтобы заново упорядочивать весь массив, достаточно лишь
вставить два последних элемента на свои места в уже упорядоченную часть
массива. При этом поиск места вставки тоже можно организовать достаточно
быстро, если использовать алгоритм двоичного поиска, реализованный выше
В функции binSearch.
К сожалению, функцию binSearch в этом случае применить не удастся,
поскольку найденное ею место для вставки нового значения будет потеряно при
возврате из функции, если только вставляемого значения уже нет в
упорядоченной части массива. Поэтому определим еще одну похожую шаблонную
функцию insert, которая будет вставлять новое значение в уже
упорядоченную часть массива, сдвигая все элементы, большие вставляемого, в сторону
больших индексов. Эта функция приведена в листинге 2.4.
\ Листинг 2А Вставка нового элемента в упорядоченный массив
//
// Шаблон insert задает функцию двоичного поиска и вставки
// нового элемента в упорядоченном массиве.
// - Key - класс, задающий тип элементов массива;
// - array - упорядоченный массив;
// - low, high - индексы, задающие диапазон поиска;
// - key - новый вставляемый ключ.
//
template <class Key>
void insert(Key * array, int low, int high, const Key & key) {
// Предполагается, что в начале работы low <= high,
// причем в массиве имеется место для еще хотя бы одного элемента.
//В результате вставки получается упорядоченный фрагмент массива
//в диапазоне от low до high+1
if (array[high] <= key) {
// Новый элемент становится последним элементом массива
array[high+1] = key;
} else {
int border = high; // запоминаем старую границу массива
// поиск места вставки как в функции binSearch
while (low < high) {
int middle = (low + high) / 2;
78
Глава 2
// middle - середина диапазона; low <= middle < high
if (array[middle] >= key) high = middle; else low = middle + 1;
// после проверки low <= high, причем array[high] >= key
}
// Здесь low == high, причем array[high] >= key
// Сдвигаем элементы массива начиная от места вставки
for (int i = border; i >= low; i—) {
array[i+1] = array[i];
}
// Собственно вставка элемента
array[low] = key;
}
}
Функция insert получает в качестве аргумента упорядоченный фрагмент
массива и добавляет новый элемент, расширяя тем самым этот
упорядоченный фрагмент на одно значение. Это позволяет организовать полную
сортировку массива, последовательно расширяя упорядоченный фрагмент массива
до тех пор, пока все элементы не окажутся входящими в этот фрагмент.
Функция сортировки при этом получается очень простой и естественной, а
сам этот метод сортировки имеет название сортировки двоичными
вставками. Этот алгоритм приведен в листинге 2.5 в виде определения шаблона
функции binlnsertSort.
| Листинг 2.5. Метод сортировки двоичными вставками
//
// Шаблон binlnsertSort задает функцию сортировки элементов
// массива методом двоичных вставок. Используется функция
// двоичной вставки элемента insert.
// - Key - класс, задающий тип элементов массива;
// - array - упорядочиваемый массив;
// - low, high - индексы, задающие диапазон сортировки.
//
template <class Key>
void binlnsertSort(Key * array, int low, int high) {
// Предполагается, что в начале работы low <= high
//В результате сортировки получается упорядоченный фрагмент
// массива в диапазоне от low до high
for (int i = low+1; i <= high; i++) {
// Уже упорядочен фрагмент массива от low до i-1
Key temp = array[i]; // Копия вставляемого элемента
insert<Key>(array, low, i-1, temp);
}
}
Базовые алгоритмы
7$
Скорость работы алгоритма можно оценить, если подсчитать количество
сравнений и перемещений элементов в процессе работы алгоритма. Если
считать, что исходный массив содержал п элементов, расположенных в
случайном порядке, то функция insert вызывается в процессе работы п-\ раз,
осуществляя вставку одного значения в увеличивающийся в размере от 1
до п-\ элементов упорядоченный фрагмент массива. При вставке элемента
с помощью двоичного поиска в упорядоченный фрагмент длиной к элементов
осуществляется порядка log2 к сравнений ключей и около к/2 перемещений
элементов. Просуммировав эти величины при всех значениях А: от 1 до и-1,
получим всего около п * log2 n сравнений и около п2/2 перемещений
элементов.
Конечно, эта оценка на самом деле очень приблизительна, поскольку
реальное число сравнений и перемещений элементов зависит не только от метода
сортировки, но и от того, в каком порядке были расположены элементы в
исходном массиве. Например, если в исходном массиве элементы были уже
упорядочены, то перемещений элементов не будет совсем (если не считать
того, что каждый элемент будет сначала скопирован в промежуточную
переменную temp функции bininsertsort, а затем помещен обратно на свое
место), а вот число сравнений не изменится. Худшим случаем для этого метода
сортировки является случай, когда элементы массива с самого начала были
расположены в порядке убывания значений. Общее количество перемещений
элементов в этом случае достигает п2.
Вообще говоря, вставку нового элемента в упорядоченный фрагмент массива
можно производить и не только методом двоичного поиска места вставки.
Более простым, хотя и не столь быстрым методом оказывается
последовательный перебор элементов упорядоченной части массива. Надо сказать, что
на практике часто оказывается, что самые простые методы бывают
одновременно и самыми эффективными, особенно если количество исходных данных
алгоритма не очень велико. Поэтому метод поиска места вставки
последовательным перебором заслуживает рассмотрения. Более того, для небольших
массивов метод сортировки простыми вставками оказывается на практике
самым быстрым! Алгоритм приведен в листинге 2.6 в виде шаблона функции
insertsort, причем вставка элементов в упорядоченный фрагмент массива
производится не с помощью вызова отдельной функции вставки, а
непосредственно в теле самой функции insertsort.
\ Листинг 2.6. Сортировка методом простых вставок
//
// Шаблон insertSort задает функцию сортировки элементов
// массива методом простых вставок.
80
Глава 2
// - Key - класс, задающий тип элементов массива;
// - array - упорядочиваемый массив;
// - low, high - индексы, задающие диапазон сортировки.
//
template <class Key>
void insertSort(Key * array, int low, int high) {
// Предполагается, что в начале работы low <= high
//В результате сортировки получается упорядоченный фрагмент
// массива в диапазоне от low до high
for (int i = low+1; i <= high; i++) {
// Уже упорядочен фрагмент массива от low до i-1
// Сдвигаем элементы массива в сторону больших индексов,
// пока не найдем элемент, меньший или равный array[i]
Key temp = array[i]; // Копия вставляемого элемента
int j ;
for (j = i-1; j >= low && array[j] > temp; j—) {
array[j+1] = array[j];
}
// Вставляем элемент temp на свое место в массив
array[j+1] = temp;
}
}
Количество сравнений ключей в этом методе совпадает с числом
перемещений элементов — около п2/2 сравнений, однако замедление этого метода
работы по сравнению с методом двоичных вставок может сказаться только при
очень больших размерах массива или при очень медленном алгоритме
сравнения элементов. Если же исходный массив был уже упорядочен или почти
упорядочен, то метод простых вставок вообще оказывается самым быстрым
из известных методов сортировки.
Надо сказать, что ситуации, когда сортировке подлежит почти
упорядоченный массив, на практике встречаются довольно часто. Чаще всего к
имеющемуся упорядоченному массиву бывает необходимо добавить еще несколько
элементов. Один из возможных вариантов сделать это— дописать новые
элементы в конец массива, а потом провести его сортировку методом
простых или двоичных вставок.
Другой часто встречающийся случай— объединение двух упорядоченных
массивов в один. Эту процедуру называют слиянием упорядоченных
массивов. Проще всего выполнить слияние, если имеется отдельный буфер
достаточного размера, чтобы поместить в него все элементы результирующего
массива. В этом случае оказывается, что как число сравнений, так и число
перемещений элементов во время слияния массивов будет равно длине объ-
Базовые алгоритмы
81
единенного массива. Идея слияния состоит в том, чтобы шаг за шагом
перемещать элементы исходных массивов в результирующий массив, причем на
каждом шаге переносится минимальный из оставшихся элементов.
Алгоритм слияния массивов прост и изящен и, безусловно, заслуживает
представления его программным текстом. Листинг 2.7 реализует шаблонную
функцию merge, которая осуществляет слияние двух упорядоченных
последовательностей, расположенных в массивах arri и агг2, в третий массив аггЗ.
Как и раньше, обработке подлежит лишь некоторая часть исходных массивов,
заданная верхним и нижним индексами. Для результирующего массива
задается только нижний индекс. Предполагается, что в результирующем массиве
имеется достаточно места для размещения результата слияния. Конечно,
алгоритм будет работать правильно только в том случае, когда исходные
массивы не перекрываются с результирующим.
\ Листинг 2.7. Слияние упорядоченных массивов
//
// Шаблон merge задает функцию слияния двух упорядоченных
// массивов в один результирующий.
// - Key - класс, задающий тип элементов массива;
// - arri, arr2 - исходные упорядоченные массивы;
// - lowl, highl, low2, high2 - индексы, задающие диапазоны слияния;
// - аггЗ - буфер для результирующих элементов;
// - low3 - нижний индекс в результирующем буфере.
//
template <class Key>
void merge(Key * arri, int lowl, int highl,
Key * arr2, int low2, int high2,
Key * аггЗ, int low3) {
// Предполагается, что в начале работы исходные массивы
// упорядочены по крайней мере в своих фрагментах
// arri[lowl:highl] и arr2[low2:high2].
//В результате работы получается упорядоченный массив
// аггЗ[low3:low3+(highl-lowl)+(high2-low2)+1]
int ndxl = lowl, // Индекс в первом массиве
ndx2 = low2, // Индекс во втором массиве
ndx3 = low3; // Индекс в результирующем массиве
// 1. Сначала происходит слияние, пока не будут
// перенесены все элементы одного из массивов
while (ndxl <= highl && ndx2 <= high2) {
if (arri[ndxl] <arr2[ndx2]) {
arr3[ndx3++] = arrl[ndxl++];
82
Глава 2
} else {
arr3[ndx3++] = arr2[ndx2++];
}
}
// 2. Затем переносится остаток элементов второго массива
if (ndxl > highl)
while (ndx2 <= high2) arr3[ndx3++] = arr2[ndx2++];
else
while (ndxl <= highl) arr3[ndx3++] = arrl[ndxl++];
}
На слиянии массивов основан один из самых старых методов сортировки —
сортировка слиянием. Идея метода состоит в том, чтобы последовательно
сливать маленькие упорядоченные фрагменты массива во все большие
упорядоченные фрагменты до тех пор, пока весь массив не станет
упорядоченным. Первоначально имеется п упорядоченных фрагментов массива, каждый
из которых состоит из единственного элемента. После каждого шага слияния
число упорядоченных фрагментов уменьшается примерно вдвое, а длины
фрагментов увеличиваются также вдвое. Поскольку на каждом шаге слияния
просматриваются и перемещаются все элементы массива, то время работы
этого алгоритма оценивается приблизительно в п х log2 n сравнений и
столько же перемещений элементов. Шаблон функции, реализующей сортировку
методом слияния, приведен в листинге 2.8.
| Листинг 2,8. Алгоритм сортировки слиянием
//
// Шаблон move задает вспомогательную функцию, которая просто
// переносит элементы массива из одного фрагмента массива
//в другой фрагмент того же самого или другого массива.
// - Key - класс, задающий тип элементов массива;
// - src - исходный фрагмент массива;
// - sLow, sHigh - индексы, задающие диапазон в исходном массиве;
// - dst - результирующий фрагмент массива;
// - dLow - индекс, задающий нижнюю границу результирующего массива.
//
template <class Key>
void move(Key * src, int sLow, int sHigh, Key * dst, int dLow) {
for (int pSrc = sLow, pDst = dLow; pSrc <= sHigh;) {
dst[pDst++] = src[pSrc++];
}
}
Базовые алгоритмы
83
И
// Шаблон mergeSort задает функцию сортировки элементов
// массива методом последовательного слияния.
// - Key - класс, задающий тип элементов массива;
// - array - упорядочиваемый массив;
// - low, high - индексы, задающие диапазон сортировки.
//
template <class Key>
void mergeSort(Key * array, int low, int high) {
// Предполагается, что в начале работы low <= high
//В результате сортировки получается упорядоченный фрагмент
// массива в диапазоне от low до high
int n = high - low + 1; // Длина массива
int frag = n; // Число упорядоченных фрагментов
int len =1; // Длина сливаемых фрагментов
Key * source = array, // Массив, из которого происходит слияние
* dest = new Key[n]; // Массив, в который происходит слияние
int sourceLow = low, // Нижняя граница индексов массива-источника
destLow =0; // Нижняя граница индексов массива-назначения
while (frag > 1) {
// Один шаг цикла слияния состоит в попарном слиянии фрагментов
// исходного массива и переносе оставшихся неслитыми элементов
//из исходного массива в результирующий.
// Индексы pSburce и pDest задают нижние границы этих массивов.
int pSource = sourceLow, pDest = destLow;
do {
int nextSource = min(pSource + 2*len, sourceLow + n);
// Выполняем слияние двух фрагментов или
// перенос последнего оставшегося фрагмента
if (nextSource > pSource + len) {
merge<Key>(source, pSource, pSource+len-1,
source, pSource+len, nextSource-1,
dest, pDest);
} else {
move<Key>(source, pSource, nextSource-1,
dest, pDest);
}
pSource = nextSource; pDest += 2*len;
} while (pSource < sourceLow+n);
len *= 2; // Длина фрагментов увеличивается вдвое
frag = (frag+l)/2; // Число фрагментов уменьшается вдвое
84
Глава 2
II Переставляем местами массивы источника и назначения:
Key * tempArray = dest; dest = source; source = tempArray;
int tempLow = destLow; destLow = sourceLow; sourceLow = tempLow;
}
// Если в конце работы результат оказался не на месте,
//то организуется его перенос в исходный массив
if (source != array) {
move<Key>(source, sourceLow, sourceLow+n-1, dest, destLow);
}
}
Несмотря на то, что сортировка слиянием работает достаточно быстро, она
все же не очень часто используется на практике, поскольку требует отведения
дополнительной памяти размера исходного массива. Другими словами, в
отличие от предыдущих методов сортировки, сортировка слиянием не
производится на месте. Такой расход памяти был бы приемлем для сортировки
небольших массивов, но их оказывается проще и быстрее упорядочивать
методами простых или двоичных вставок. Сортировка слиянием имеет, однако,
одно существенное преимущество перед всеми прочими методами в том, что
доступ к элементам массивов в ней производится строго последовательно.
Это оказывается очень существенным преимуществом, когда
упорядочиваются элементы, лежащие не в основной памяти, а в файловой системе на
магнитном носителе. Последовательный доступ к содержимому таких файлов
оказывается значительно быстрее, чем доступ к произвольно расположенным
элементам. Мы в нашей книге не будем рассматривать сортировки файлов,
однако заметим, что различные модификации сортировок слиянием играют
ведущую роль при выборе алгоритмов упорядочения информации, лежащей
на внешнем носителе.
Методы сортировок, рассмотренные нами выше, являются довольно
простыми и естественными. Все они переходят от небольших упорядоченных
фрагментов массивов к более длинным упорядоченным фрагментам. Однако
среди алгоритмов сортировок есть и такие, которые используют совершенно
другой подход к сортировке. Мы рассмотрим два таких метода.
Первый метод называют пирамидальной сортировкой. Напомним, что
пирамидой называют дерево, в каждом узле которого находится минимальное
значение из всех значений, лежащих в поддереве, корнем которого является
этот узел. В пирамидальной сортировке элементы массива сначала
выстраиваются в такое дерево (правда, обычно используется перевернутая пирамида,
в узлах которой находятся не минимальные, а максимальные значения).
После этого элементы постепенно изымаются из пирамиды и выстраиваются
в упорядоченную последовательность.
Базовые алгоритмы
85
Схематично процесс работы пирамидальной сортировки можно представить
в виде последовательности работы двух подпроцессов. В первом подпроцессе
из последовательно взятых элементов массива постепенно выстраивается
пирамида. Во втором подпроцессе, наоборот, элементы вытягиваются из
пирамиды, образуя упорядоченную последовательность исходных элементов
массива. Если считать, что шаблон классов неар<кеу> представляет пирамиду,
для которой определены операции вставки нового элемента void insert (e) и
удаления минимального элемента Key remove (), то схема пирамидальной
сортировки может быть выражена функцией, показанной ниже. Заметим, что
в разд. 1.3 описанный нами шаблон классов Heap содержал операцию insert,
но операция remove не была определена.
template <class Key>
void heapSort(Key * array, int low, int high) {
Heap<Key> heap;
for (int i = low; i <= high; i++) heap.insert(array[i]);
for (int i = low; i <= high; i++) array[i] = heap.remove();
}
На самом деле в реализации этого алгоритма нет необходимости описывать
отдельный объект для представления пирамиды. Фактически, можно
перемещать элементы только внутри массива, так что пирамида оказывается
построенной только в воображении программиста. При этом в алгоритме
организуется граница, отделяющая часть элементов массива, принадлежащую
пирамиде, от части элементов, находящейся в линейной последовательности.
В первом из двух описанных подпроцессов эта граница перемещается от
начала массива к его концу, так что постепенно все элементы массива попадают
в пирамиду. Во втором подпроцессе граница перемещается обратно к началу,
так что элементы из пирамиды возвращаются обратно в массив. Вся
трудность состоит только в том, чтобы при переносе элементов в пирамиду и
обратно не нарушалась бы структура самой пирамиды.
Опишем работу алгоритма более подробно. Пусть в исходном массиве
требуется упорядочить элементы с индексами от low до high. Введем переменную
heapLength, которая в каждый момент времени будет содержать количество
элементов массива, принадлежащих пирамиде. Значение этой переменной и
определяет границу между элементами пирамиды и элементами линейной
части массива, так что в пирамиде будут находиться элементы с индексами
от low до low+heapLength-i, а в линейной части массива— элементы с
индексами от low+heapLength до high. Пирамида выстраивается таким образом,
чтобы элемент, находящийся на вершине пирамиды (с индексом low), был бы
максимальным во всей пирамиде. Для этого каждый вновь попадающий в
пирамиду элемент протаскивается по направлению к вершине до тех пор, пока
он не попадет на свое место. После того как в пирамиде окажутся все элемен-
86
Глава 2
ты массива и начнется обратный процесс, максимальный элемент с вершины
пирамиды записывается в конец массива, а пирамида вновь реорганизуется
таким образом, чтобы в ее вершине оказался максимальный из оставшихся
элементов.
Остается только заметить, что, как и в реализации из разд. 1.3, организация
связей элементов в пирамиде сделана с помощью арифметических
вычислений над индексами этих элементов. Каждый элемент пирамиды имеет свой
относительный индекс i (соответствующий элемент массива имеет индекс
low+i). При этом непосредственным предком такого элемента является
элемент с относительным индексом (i-i)/2, а непосредственными потомками
этого элемента будут элементы с относительными индексами (2*i+i) и
(2*i+2). Конечно, все это справедливо, только если элементы с такими
индексами действительно присутствуют в пирамиде.
Полностью алгоритм пирамидальной сортировки реализован шаблонной
функцией heapSort в листинге 2.9.
| Листинг 2.9. Пирамидальная сортировка ч j
//
// Шаблон heapSort задает функцию сортировки элементов
// массива методом пирамидальной сортировки.
// - Key - класс, задающий тип элементов массива;
// - array - упорядочиваемый массив;
// - low, high - индексы, задающие диапазон сортировки.
//
template <class Key>
void heapSort(Key * array, int low, int high) {
// Предполагается, что в начале работы low <= high
//В результате сортировки получается упорядоченный фрагмент
// массива в диапазоне от low до high
int heapLength = 1; // Размер пирамиды
// 1. Выстраивание пирамиды, пока она не займет весь массив
while (heapLength <= high-low) {
// Элемент с индексом low + heapLength включается в пирамиду
int ndx = low + heapLength;
Key elem = array[ndx];
// Элемент elem включается в пирамиду и протаскивается к вершине
while (ndx > low && elem > array[(ndx-low-1)/2]) {
array[ndx] = array[(ndx-low-1)/2];
ndx = (ndx-low-1)/2;
}
Базовые алгоритмы
87
array[ndx] = elem;
heapLength++;
}
// 2. Упорядочивание элементов, вынимаемых из пирамиды
while (heapLength > 1) {
heapLength—;
// Элемент берется из конца пирамиды и проталкивается
//от вершины по направлению к основанию пирамиды
Key elem = array[low + heapLength];
// Максимальный элемент пирамиды записывается в конец массива
array[low + heapLength] = array[low];
int ndx = low;
while (2*(ndx-low) < heapLength-1) {
// Индексы непосредственных потомков проталкиваемого элемента:
int childl = low + 2*(ndx-low) + 1,
child2 = low + 2*(ndx-low) + 2;
if (child2 <= low + heapLength - 1 &&
array[child2] > array[childl]) {
childl = child2;
}
if (elem > array[childl]) break;
array[ndx] = array[childl];
ndx = childl;
}
array[ndx] = elem;
}
}
Время работы пирамидальной сортировки можно приблизительно оценить,
если понять, сколько сравнений и перемещений элементов производится во
время протаскивания элементов по пирамиде. Очевидно, что если в пирамиде
содержится п элементов, то ее высота приблизительно равна log2 и, а значит,
при протаскивании будет сделано не более п х log2 n сравнений и
перемещений элементов. Однако размер пирамиды в процессе работы алгоритма
постоянно меняется — сначала пирамида увеличивается в размерах от 1 до я, а
затем уменьшается. Тем не менее если проделать некоторые выкладки, то
окажется, что, как и для случая сортировки слиянием, общее число сравнений
и перемещений элементов будет приблизительно равно п х log2 п. Очевидное
преимущество пирамидальной сортировки— это малый объем требуемой
дополнительной памяти, хотя порою во время процесса сортировки элементы
массива перемещаются на значительные расстояния.
88
Глава 2
К сожалению, пирамидальная сортировка практически никогда не
используется на практике, так что ее полезно изучить только в качестве изящного
примера использования такой структуры данных, как пирамида.
Пирамидальная сортировка не так проста, как сортировка вставками, так что ее
невыгодно применять для небольших массивов, когда оценки, сделанные для
большого числа элементов, не имеют значения. Этот вид сортировки не
обеспечивает последовательного просмотра элементов, так что для сортировки
данных во внешней памяти она тоже не подходит. Что же касается случая
сортировки достаточно большого числа элементов, расположенных в
основной памяти, то оказывается, что существует алгоритм, который имеет
приблизительно такую же оценку скорости работы, но на практике оказывается
более быстрым. Этот метод сортировки так и называется быстрой
сортировкой (quick sorting). К его рассмотрению мы сейчас и перейдем.
Идея быстрой сортировки состоит в том, чтобы разделить сортируемый
массив на две части таким образом, чтобы можно было каждую из этих частей
упорядочить по отдельности, а затем просто механически соединить их части
в единый упорядоченный массив. Для возможности такого соединения надо,
чтобы все элементы одной части массива были бы меньше, чем любой из
элементов другой части, в противном случае пришлось бы не просто
соединять, а сливать две части массива. Каждую из двух частей массива можно
упорядочивать любым из возможных методов сортировки, в частности тем
же самым методом быстрой сортировки, пока не окажется, что
упорядочиваемый фрагмент массива состоит всего из одного элемента.
Для разделения массива на две части так, чтобы элементы одной части были
заведомо меньше элементов другой части, можно выбрать некоторый
произвольный элемент и делить все остальные элементы на те, которые меньше
него, и те, которые не меньше него. Оказывается, что такое разделение
можно выполнить, просмотрев элементы массива один раз, причем для него не
требуется дополнительной памяти. Если после разделения элементов обе
половинки окажутся приблизительно одинаковыми по длине, то продолжение
упорядочения элементов таким способом приведет к тому, что потребуется
около log2 n проходов по массиву, чтобы он оказался разделенным на
фрагменты длиной в один элемент. Таким образом, оказывается, что скорость
работы алгоритма будет пропорциональна п х log2 и, так же как и для
пирамидальной сортировки и сортировки слиянием.
Однако со скоростью работы алгоритма дело обстоит не так просто. Давайте
сначала опишем алгоритм более подробно, а потом уже обсудим вопросы,
связанные с его эффективностью.
При разделении массива на две части сначала выбирается некоторый
произвольный элемент этого массива, а затем массив просматривается
одновременно с начала и с конца. Как только окажется, что в начале массива найдет-
Базовые алгоритмы
89
ся некоторый элемент, больший выбранного, а в конце массива найдется
элемент, меньший выбранного, можно будет просто поменять местами
найденные элементы и продолжить просмотр. Когда указатели просмотра сойдутся в
середине массива, то можно выбранный с самого начала элемент поместить в
место, куда будут указывать оба указателя.
Из этого описания сразу же следует рекурсивный алгоритм быстрой
сортировки, показанный в листинге 2.10 в виде описания рекурсивной функции
quicksort.
! Листинг 2.10. Быстрая сортировка
//
// Шаблон quicksort задает рекурсивную функцию сортировки
// элементов массива методом быстрой сортировки.
// - Key - класс, задающий тип элементов массива;
// - array - упорядочиваемый массив;
// -.low, high - индексы, задающие диапазон сортировки.
//
template <class Key>
void quicksort(Key * array, int low, int high) {
// Предполагается, что в начале работы low <= high
//В результате сортировки получается упорядоченный фрагмент
// массива в диапазоне от low до high
int pLow = low,
pHigh = high; // указатели концов фрагмента массива
Key elem = array[low]; // выбранный произвольный элемент
while (pLow != pHigh) {
// 1. Просмотр элементов массива снизу
while (pHigh > pLow && array[pHigh] >= elem) pHigh—;
if (pHigh > pLow) {
array[pLow] = array[pHigh]; // Обмен местами элементов
// 2. Просмотр элементов массива "сверху"
while (pLow < pHigh && array[pLow] <= elem) pLow++;
array[pHigh] = array[pLow]; // Еще один обмен
}
}
// Теперь указатели pLow и pHigh столкнулись, массив разделен,
array[pLow] = elem;
// Далее следуют рекурсивные вызовы функции для двух половинок.
if (pLow - low > 1) quickSort<Key>(array, low, pLow-1);
if (high - pHigh > 1) quickSort<Key>(array, pHigh+1, high);
}
90
Глава 2
Внимательный анализ этого метода сортировки показывает, что скорость его
работы в большой степени зависит от того, насколько удачно на каждом шаге
массив делится на две приблизительно равные половины. Если такое деление
происходит очень неравномерно, то скорость работы алгоритма сильно
уменьшается. Например, в случае изначально упорядоченного массива одна
из половинок всегда оказывается пустой. Это приводит к тому, что
количество сравнений увеличивается с п х log2 п до п2 (хотя, правда, перемещений
элементов в этом случае совсем не происходит, если не считать копирования
каждого элемента во временную переменную, а затем обратно). Очевидный
вывод: быструю сортировку не следует применять в том случае, когда
исходный массив почти упорядочен. Впрочем, мы и так уже знаем, что в этом
случае самым быстрым методом оказывается метод сортировки простыми
вставками.
Если теоретически эффективность работы алгоритма быстрой сортировки
оценить довольно трудно из-за большого разброса оценок в различных
исходных ситуациях, то практически получается, что для массивов со случайно
расположенными элементами, находящихся в основной памяти, этот метод
оказывается, как правило, самым быстрым. Впрочем, это справедливо только
для достаточно больших массивов; для массивов же с небольшим числом
элементов по-прежнему самой быстрой оказывается сортировка методом
простых или двоичных вставок.
Кстати, если в вашем распоряжении имеются как метод быстрой сортировки,
так и метод сортировки вставками, то рекурсивный алгоритм быстрой
сортировки легко приспособить для случая, когда он вызывается только для
больших массивов, а все маленькие фрагменты сортируются методом вставки
элементов. Для этого достаточно при рекурсивном вызове обращаться к
методу быстрой сортировки, только если размер сортируемого фрагмента
довольно велик. Например, две последние строчки приведенной в листинге 2.10
функции можно заменить следующими строками:
if (pLow - low > 1) {
if (pLow - low > 30)
quickSort<Key>(array, low, pLow-1);
else
insertSort<Key>(array, low, pLow-1);
}
if (high - pHigh > 1) {
if (high - pHigh > 30)
quickSort<Key>(array, pHigh+1, high);
else
insertSort<Key>(array, pHigh+1, high);
}
Базовые алгоритмы
91
Константа 30 в этом участке программы выбрана нами произвольно.
Фактически размер массива, при котором быстрая сортировка действительно
оказывается более эффективной, чем сортировка вставками, зависит не столько
от самого алгоритма, сколько от его реализации, используемого компилятора,
характеристик компьютера и т. п. Другими словами, в тех случаях, когда
скорость сортировки оказывается критическим местом в работе программы,
следует провести эксперименты в реальной среде выполнения программы.
Заметим еще, что быстрая сортировка, в отличие от многих других методов,
приведенных в этом разделе, требует некоторого количества дополнительной
памяти, причем размер требуемой дополнительной памяти зависит от
количества элементов массива. В самой функции quicksort никакой памяти,
кроме памяти для нескольких переменных, не резервируется, однако
рекурсивный вызов сам по себе требует запоминания контекста в стеке вызовов
процедур, так что количество требуемой памяти будет зависеть от глубины
вложенности рекурсивных вызовов. В лучшем случае, когда фрагменты
массива на каждом шаге алгоритма делятся ровно пополам, максимальная
глубина вложенности вызовов, а значит, и количество требуемой памяти будет
пропорционально log2 п, где п — число элементов массива. Таким образом,
в условиях жесткого лимита памяти алгоритм быстрой сортировки тоже
оказывается неприменимым.
Чтобы уменьшить потери памяти и, возможно, еще несколько увеличить
скорость работы алгоритма, можно попытаться избавиться от рекурсии,
организовав явное запоминание необходимых значений в специально отведенной
для этого памяти. Мы, однако, не будем этого проделывать, чтобы сохранить
ясность и простоту описания алгоритма. К тому же, следует иметь в виду,
что, выиграв в объеме памяти для промежуточных данных, мы можем
потерять в объеме памяти, занятой кодом самой программы, хотя, конечно, эти
потери будут очень невелики.
Все приведенные выше методы сортировки массивов рассматривались нами в
самом общем случае, когда про ключи известно только то, что их можно
сравнивать между собой по величине. Лучшие из известных методов
сортировки показывают при этом производительность по числу сравнений и
перемещений элементов порядка п х log2 п. Можно доказать, что эта оценка
является теоретически минимальной, т. е. не существует и не может существовать
методов сортировки, основанных только на сравнении ключей, которые
показывали бы лучшую производительность. Речь, конечно, идет только о
порядке величин. На самом деле, на практике методы, показывающие по порядку
величин одну и ту же производительность, могут существенно отличаться по
фактическому времени исполнения процедур.
Теоретически установленный барьер можно преодолеть, если о структуре
ключа известно несколько больше, чем просто возможность сравнивать клю-
92
Глава 2
чи друг с другом операторами арифметического сравнения величин.
Рассмотрим случай, когда ключи можно упорядочивать, используя так называемый
лексикографический порядок значений.
Пусть каждый ключ к составлен из определенного количества элементов, так
что его можно записать в виде к = а0а\...ап.]. При этом сравнение ключей по
величине определяется результатом сравнения этих составных элементов
ключей следующим образом:
□ ключ к\ = tf0tfi..-tf/i-i равен ключу кг = Ъф\...Ьп-\ тогда и только тогда, когда
для каждого / от 0 до п-\ значение ах равно значению Ъ}\
П ключ к\ - аоа\...а„-\ меньше ключа к2 = Ьф\..Ъп-\ тогда и только тогда,
когда найдется такое /, что значение а, меньше значения й„ но для всех
предыдущих индексов j < i (если они есть) значения соответствующих
элементов совпадают: а} = bj.
Для самых распространенных случаев целых и строковых ключей такое
деление на составляющие элементы легко проделать. Целое значение можно
представить в виде составляющих его цифр. Разложение на цифры можно
производить по любому основанию; для человека, видимо, самым
естественным будет разложение по десятичному основанию, для компьютерных
алгоритмов более удобным будет разложение на восьмеричные или шестнадцате-
ричные цифры. Конечно, результат сравнения чисел по цифрам будет
совпадать с результатом обычного сравнения чисел. Строки естественным образом
делятся на составляющие их символы (при этом более короткая строка при
сравнении дополняется справа пустыми символами до более длинной
строки). Сравнение строк тоже происходит именно по описанным выше правилам
сравнений лексикографического порядка.
Теперь можно воспользоваться тем, что сравнивать между собой можно не
целые значения, а отдельные элементы (цифры или буквы). Сортировки,
основанные на таких сравнениях, называются цифровыми или алфавитными
сортировками. Одну из таких сортировок мы сейчас и опишем.
Пусть каждый ключ состоит из / цифр, причем каждая цифра может
принимать одно из р различных значений (например, для значений типа int в
32-разрядной среде программирования при разложении их на шестнадцате-
ричные цифры получим / = 8 и р = 16). Значение / будем называть длиной
значения, а р — разрядностью. Сортировка будет состоять из / просмотров
массива, причем при каждом просмотре значения будут распределяться по р
спискам. На первом просмотре распределение происходит по последней
цифре, при этом значение попадает в список с номером /, если его последняя
цифра равна /'. Потом значения из всех списков снова собираются в единый
массив в том порядке, как они оказались в списках, и новое распределение
Базовые алгоритмы
93
происходит уже по предпоследней цифре. Последнее распределение
элементов происходит по первой цифре значения. После последнего сбора значений
из списков массив оказывается полностью упорядоченным.
Приведем пример сортировки небольшого массива из 20 трехзначных целых
чисел, записанных в десятичной системе счисления (таким образом, в нашем
примере /=3, р= 10). Пусть исходный массив состоял из следующих
значений:
395, 935, 880, 799, 079, 132, 240, 116, 025, 881,
629, 188, 151, 258, 304, 791, 628, 358, 042, 926
Результаты распределения по спискам представим в таблице, состоящей из
10 столбцов, соответствующих 10-ти возможным значениям цифр. После
первого распределения элементов по последней цифре получим следующие
списки:
0
880
240
1
881
151
791
2
132
042
3
4
304
5
395
935
025
6
116
926
7
8
188
258
628
358
9
799
079
629
Собрав все элементы списков в единый массив, получим следующую
последовательность значений:
880, 240, 881, 151, 791, 132, 042, 304, 395, 935,
025, 116, 926, 188, 258, 628, 358, 799, 079, 629
В этом массиве упорядочение произошло по последней цифре. Теперь еще
раз распределим значения по спискам, теперь уже по второй цифре значения.
В результате получится следующая таблица:
0
304
1
116
2
025
926
628
629
3
132
935
4
240
042
5
151
258
358
6
7
079
8
880
881
188
9
791
395
799
Заметьте, что в каждом из списков значения уже упорядочены по последней
цифре, поскольку они были уже упорядочены по последней цифре в нашей
общей последовательности значений. После объединения всех списков
получим последовательность, упорядоченную уже по последним двум цифрам:
304, 116, 025, 926, 628, 629, 132, 935, 240, 042,
151, 258, 358, 079, 880, 881, 188, 791, 395, 799
94
Глава 2
Наконец, сделаем последнее распределение значений по первой цифре:
0123456789
025 116 240 304 628 791 880 926
042 132 258 358 629 799 881 935
079 151 395
188
И вот после последнего объединения всех списков получим полностью
упорядоченный массив значений:
025, 042, 079, 116, 132, 151, 188, 240, 258, 304,
358, 395, 628, 629, 791, 799, 880, 881, 926, 935
Насколько быстро удается выполнить сортировку массива таким способом?
При каждом распределении элементов по спискам и последующем слиянии
элементы просматриваются по два раза, причем таких просмотров требуется
столько, какова длина элемента /. Таким образом, если имеется п элементов,
то потребуется / х п обращений к цифрам для сравнения и 2 х / х п
перемещений элементов. Ясно, что такая сортировка будет выгодна, если длина
элемента / не превосходит log2 п. В нашем примере при / = 3 и п = 20
значение log2 n будет чуть больше четырех, таким образом, теоретически нам
удастся превзойти по скорости самые быстрые алгоритмы сортировки,
основанные на сравнении элементов целиком.
На практике все оказывается несколько менее радужным. Во-первых,
некоторое время при обращении к цифрам может составлять само выделение цифры
из значения. Во-вторых, организация списков и работа с ними требует как
ресурсов памяти, так и временных ресурсов. Таким образом, этот метод
сортировки оказывается довольно расточительным, прежде всего по количеству
используемой памяти, особенно принимая во внимание, что его следует
применять в первую очередь для сортировки больших массивов данных.
Реального ускорения сортировки по сравнению с ранее описанными методами
удается добиться только при очень удачном стечении обстоятельств, когда
элементов много, но их длина и разрядность невелики.
Перед тем как привести алгоритм цифровой сортировки в виде определения
функции на C++, надо договориться о том, каким образом происходит
деление ключей на цифры, что это за цифры, как определить разрядность и длину
ключей и т. п.
Прежде всего, условимся, что отдельные цифры ключа всегда представлены
целыми значениями из диапазона [0,/>-1], где/? — разрядность ключа. Таким
образом, нарисованную выше таблицу из списков значений ключей можно
будет представлять массивом из р списков, в котором каждый элемент будет
Базовые алгоритмы
95
представлять один столбец нашей таблицы. Это достаточно естественное
требование, которое легко выполнить для конкретных случаев ключей
разнообразных типов. Далее, будем считать, что отдельные цифры ключа можно
получить с помощью операции индексации ключа целым индексом из
диапазона [0, /-1], где /— длина ключа. При этом договоримся, что меньшим
значениям индекса соответствуют цифры ключа, расположенные в его начале.
Наконец, будем считать, что длину и разрядность ключа можно вычислить по
любому из ключей с помощью операций length () и power () соответственно.
Для того чтобы удовлетворить всем этим требованиям, придется вместо
стандартных целых или строковых значений определить для ключей
специальный класс, в котором и будут описаны операции lengtho, power о и
оператор индексации. Мы сделаем это для целых 31-разрядных неотрицательных
ключей с помощью описания класса intKey, и будем считать, что
сортируемый массив состоит из значений этого класса.
Далее потребуется определить структуры данных для работы со списками
ключей. Здесь можно было бы воспользоваться слегка модифицированным
описанием типа данных intList из разд. 1.2, однако применение столь
мощного аппарата в данном случае не оправдано. Специфика нашей ситуации
состоит в том, что общее количество ключей, которые будут помещены в
списки, известно с самого начала и не меняется по ходу работы. Это делает
совершенно излишним динамическое выделение памяти под элементы
списка— достаточно один раз отвести память под все элементы списков и
затем использовать эту отведенную память. Это означает, что мы хотим
описать некоторую простую систему распределения памяти под элементы
списка, в которой нам потребуется брать элементы из некоторого буфера (пула
памяти) и затем возвращать в пул элементы, когда надобность в них отпадет.
Алгоритмы отведения и распределения памяти— это особый интересный
раздел теории и практики программирования. Ему посвящено много книг,
например, много материала по этому вопросу можно найти в [5]. В нашей
книге в главе 5 мы тоже опишем несколько систем распределения памяти и
запрограммируем их на C++, но пока определим очень простую
узкоспециализированную для нашей задачи систему. В этой системе определены два
класса, один из которых представляет пул для хранения свободных и занятых
элементов списков, а второй описывает список с хранением элементов в этом
пуле. Оба класса параметризованы типом хранимых элементов.
Буферный пул системы управления памятью описан в нашей программе в
виде определения параметризованного класса ListBuffer. Объекты этого
класса содержат массив элементов списков, причем каждый элемент хранит
некоторое значение типа, заданного параметром, и индекс следующего
элемента в списке. Если следующего элемента в списке нет, то в качестве такого
96
Глава 2
индекса используется значение -1. Фрагмент, описывающий эту структуру,
может выглядеть следующим образом:
template <class Key>
struct Elem {
Key value;
int next;
};
Elem<Key> * buffer;
Наша система управления памятью должна уметь выдавать свободные
элементы по одному, а после того, как все элементы памяти будут использованы,
будет производиться новый цикл обработки, в начале которого система
инициализируется заново, так что все элементы буфера снова оказываются
свободными. Таким образом, от системы требуется, чтобы она предоставляла
две основные функции: выдачу индекса свободного элемента памяти и
полную очистку буфера. Эти функции будут реализованы в нашей системе в
виде методов класса ListBuffer get () и clear (). Кроме этих двух методов,
разумеется, необходим еще метод, обеспечивающий доступ к имеющимся
элементам буфера. Мы реализуем его в виде оператора индексации operator [ ].
Для реализации системы будем хранить индекс первого свободного элемента
в буфере памяти freePtr. При выдаче свободного элемента будем
увеличивать этот индекс, уменьшая тем самым число свободных элементов буфера.
Операция clear о будет просто сбрасывать этот индекс в ноль, так что ее
реализация будет выглядеть очень просто:
void clear() {
freePtr = 0;
}
Функция get(), выдающая индекс очередного свободного элемента и
удаляющая его из списка свободных элементов, оказывается не сложнее:
int get() {
return freePtr++;
}
Теперь уже можно описывать и класс KeyList, определяющий работу со
списками значений, хранящимися в исходном массиве. Этот класс напрямую
взаимодействует с буферным пулом ячеек памяти, а в остальном его
описание очень напоминает определение класса intList из разд. 1.2. Различия
состоят только в том, что этот класс параметризован типом элементов списка,
вместо адресных указателей языка C++ в нем используются индексы
элементов в буферном пуле. Мы опишем не все возможные операции над списками,
Базовые алгоритмы
97
а только те, которые нам необходимы: добавление нового элемента в список
и перенос всех элементов списка в заданный фрагмент массива.
После определения классов ListBuffer и KeyList описание самой функции
сортировки оказывается очень простым— практически всю работу
выполняют функции нашей базовой системы распределения памяти. Полностью
система распределения памяти и функция цифровой сортировки приведены
в листинге 2.11.
; Листинг 2.11. Цифровая сортировка элементов
II Класс ListBuffer представляет буферный пул памяти для
// организации в нем списков ключей (для цифровой сортировки).
template <class Key>
class ListBuffer {
Elem<Key> * buffer; // Указатель на буфер
int size; // Размер буфера
int freePtr; // Указатель первого свободного элемента
public :
// Конструктор:
ListBuffer(int n) {
buffer = new Elem<Key>[size = n];
clear();
}
// Деструктор:
-ListBuffer() { delete [] buffer; }
// Очистка памяти формирует список свободных
// элементов из всех элементов буфера.
void clear() {
freePtr = 0;
}
// Операция доступа к элементам
Elem<Key> & operator [] (int i) { return buffer[i]; }
// Операция выделения элемента свободной части буфера
int get() {
return freePtr++;
}
};
98
Глава 2
11 Класс KeyList представляет список ключей, хранящийся в
// буферном пуле памяти, который передается конструктору списка.
template <class Key>
class KeyList {
ListBuffer<Key> * buffer; // Пул памяти
int first; // Указатель на первый элемент списка
int last; // Указатель на последний элемент списка
public :
// Конструктор получает в качестве аргумента указатель на пул памяти
KeyList(ListBuffer<Key> * buf) : buffer(buf), first(-1), last(-l) {}
// Операция добавления нового элемента в конец списка
void addKey(const Key & key) {
// Запрашиваем свободный элемент у пула памяти buffer
int nextElem = buffer->get() ;
// Записываем новое значение в этот элемент
(^buffer)[nextElem] = Elem<Key>(key);
// Присоединяем новый элемент к уже имеющемуся списку
if (first == -1) {
first = nextElem;
} else {
(*buffer)[last].next = nextElem;
}
last = nextElem;
}
// Операция toArray переносит все элементы списка во фрагмент,
// массива array, начиная с элемента с индексом from.
//В качестве результата функция выдает индекс первого элемента
// массива, следующего за перенесенным фрагментом
int toArray(Key * array, int from) {
// Организуем просмотр элементов с помощью указателя ptr.
int ptr = first;
while (ptr != -1) {
array[from++] = (*buffer)[ptr].value;
ptr = (*buffer)[ptr].next;
}
return from;
}
Базовые алгоритмы
99
If Функция очистки списка просто заземляет указатели
//на первый и последний элементы списка.
void clear() { first = last = -1; }
};
//
// Шаблон digitSort задает функцию сортировки элементов
// массива методом цифровой сортировки.
// - Key - класс, задающий тип элементов массива;
// - array - упорядочиваемый массив;
// - low, high - индексы, задающие диапазон сортировки .•
//
template <class Key>
void digitSort(Key * array, int low, int high) {
// Предполагается, что в начале работы low <= high
//В результате сортировки получается упорядоченный фрагмент
// массива в диапазоне от low до high
int n = high - low + 1; // Длина массива
int listNumber = array[low].power(); // Количество списков ключей
int passes = array[low].length(); // Количество проходов по массиву
ListBuffer<Key> buffer(n); // Буфер для списков ключей
// Организуем массив списков ключей с использованием буфера buffer:
KeyList<Key> * lists = new KeyList<Key>[listNumber](fibuffer);
// Цикл по проходам по массиву (digit - номер цифры) :
for (int digit = passes-1; digit >= 0; digit—) {
// Помещение ключей из массива в соответствующие списки
for (int i = low; i <= high; i++) {
lists[array[i][digit]].addKey(array[i]);
}
// Обратное слияние списков в массив и очищение списков
int start = low;
for (int i = 0; i < listNumber; i++) {
start = lists[i].toArray(array, start);
lists[i].clear();
}
// Очистка буфера
buffer.clear();
}
}
На цифровой сортировке мы и закончим описание методов сортировок в этой
книге. Полная реализация всех описанных методов имеется в приложенном
100
Глава 2
компакт-диске в папке "\chapter2\2.2\sorting", причем каждый из методов
реализован в виде шаблонной функции в отдельном заголовочном файле.
2.3. Стеки и очереди
Покажем, как принцип абстрактных типов данных, описанный в разд. 2.1,
применяется для описания таких важных для программирования понятий, как
стеки и очереди. В соответствии с этим принципом будем вводить новые
типы данных, перечисляя и описывая не столько структуру объектов, сколько
операции над ними. Разумеется, в конкретных приложениях будет нужно
представить какую-либо конкретную структуру (реализацию) объектов. Мы
тоже предложим несколько способов реализации стеков и очередей, конечно,
не исчерпывающих весь спектр возможных реализаций. Тем не менее,
независимо от способа представления, состав операций и способы их вызова
останутся практически неизменными, а это и определяет то, что мы имеем дело
с одним и тем же типом данных.
Стеком назовем последовательность элементов одного и того же типа, к
которой можно добавлять и убирать элементы, причем как добавление новых
элементов, так и удаление старых производится с одного и того же конца
этой последовательности, называемого вершиной стека. Иначе говоря, при
удалении элемента удаляется всегда тот элемент, который был последним
добавлен в стек, таким образом, элементы удаляются в порядке, обратном
порядку добавления элементов (рис. 2.1 и 2.2).
1
2
N
Вершина
' ГЛ
3
Ja
^Х\
1
2
\
3
Вершина
/
4
Рис. 2.1. Добавление элемента в стек
Ж
Вершина
Вершина
Ж
=й
Рис. 2.2. Удаление элемента из стека
Базовые алгоритмы
101
Различают ограниченные и неограниченные стеки. В первом случае
количество элементов стека ограничивается некоторым числом. При попытке
добавить элемент в ограниченный стек, содержащий максимальное количество
элементов, возникает исключительная ситуация. Обычно в таком случае в
программе можно определить реакцию на возникновение такой ситуации. Во
втором случае размер стека ограничен только наличием доступной памяти.
Набор операций, характеризующих стек как абстрактный тип данных, вполне
традиционный и легко может быть описан в виде следующего интерфейса
(листинг 2.12).
; Листинг 2.12. Определение интерфейса для стека ?£г:.'r;:':.\,^yj, •/:'•• ^-'н.
If файл stack.h
template <class T>
class Stack {
public:
// Виртуальный деструктор
virtual -Stack() {}
// добавление нового элемента в стек; в случае ограниченного стека
// может возникнуть ситуация переполнения стека - StackOverflow
virtual void push(const T & e) = 0;
// удаление верхнего элемента из стека; если стек пуст, то
// возбуждается ситуация исчерпания стека - StackUnderflow:
virtual void pop() = 0;
// функция проверки пустоты стека
virtual bool empty() = 0;
// Две функции доступа, позволяющие взять или изменить значение
// верхнего элемента из стека без его удаления с вершины стека;
// если стек пуст, то возбуждается ситуация StackUnderflow:
virtual T & top() = 0;
virtual const T & top() const = 0;
};
Каждая реализация этого интерфейса должна будет определить все базовые
операции над стеком. Кроме того, для каждой конкретной реализации стека
необходимо определить хотя бы один конструктор и, возможно, деструктор.
Одна из самых простых реализаций стека представлена в листинге 2.13 — это
ограниченный стек, выполненный в виде массива элементов, причем размер
102
Глава 2
этого массива определяет максимальное число элементов в стеке. Кроме
массива и его длины требуется хранить еще указатель положения верхнего
элемента стека в этом массиве. Роль такого указателя может играть целое
число — количество элементов, хранящихся в стеке. Кроме того, для того чтобы
можно было работать с конкретным представлением стека, надо описать в
виде классов исключительные ситуации stackoverf low и stackunderf low.
Ввиду простоты эти описания опущены, так же как и описание исключительной
ситуации Wrongstacksize, которая может возникнуть из-за неправильного
задания размера стека при его создании.
#include "stack.h"
//
// Описание реализации ограниченного стека в виде массива элементов
//
template <class T>
class BoundStack : public Stack<T> {
T *array; // Указатель на массив элементов
int count, maxSize; // Счетчик числа элементов стека и длина массива
public:
//В конструкторе стека задается его размер.
BoundStack(int size = 100);
// Стандартный конструктор копирования создает новый
// стек и копирует в него элементы исходного стека.
BoundStack(const Boundstack<T> & src) ;
// Деструктор стека освобождает память, занятую под его элементы.
-BoundStack();
// Класс будет содержать реализации абстрактных операций со стеком.
void push(const T & е);
void pop();
bool empty() { return count = 0; }
T & top();
const T & top() const;
};
Базовые алгоритмы
103
//
// Описание функций, реализующих абстрактные
// операции над ограниченным стеком
//
// Конструктор нового пустого стека
template <class T>
BoundStack<T>::Boundstack(int size) {
try {
array = new T[maxSize = size];
} catch (...) {
throw WrongStackSizeO ;
}
count = 0;
// Конструктор копирования
template <class T>
BoundStack<T>::BoundStack(const BoundStack<T> & src) {
// Прежде всего заказывается новая память под элементы нового стека
try {
array = new T[maxSize = src.maxSize];
} catch (...) {
throw WrongStackSizeO;
}
// Теперь производится копирование элементов
count = src.count;
for (int i = 0; i < count; i++) array[i] = src.array[i];
}
// Деструктор ограниченного стека освобождает память, занятую под массив.
template <class T>
BoundStack<T>::-BoundStack() {
delete [] array;
}
// Операция занесения элемента в стек
template <class T>
void Boundstасk<T>::push(const T & e) {
// проверка на переполнение стека
if (count = maxSize) throw StackOverflowO;
// новый элемент записывается в стек
array[count++] = е;
}
104
Глава 2
// Операция выталкивания элемента из стека
template <class T>
void Boundstасk<T>::pop() {
// проверка на пустоту стека
if (count == 0) throw StackUnderflow();
// физически верхний элемент остается в массиве,
//но количество элементов уменьшается
count—;
}
// Операции доступа к верхнему элементу стека
template <class T>
Т & BoundStack<T>::top() {
// проверка на пустоту стека
if (count == 0) throw StackUnderflow();
// верхний элемент возвращается в качестве результата
return array[count-1];
}
template <class T>
const T & BoundStack<T>::top() const {
// проверка на пустоту стека
if (count == 0) throw StackUnderflow();
// верхний элемент возвращается в качестве результата
return array[count-1];
}
Конечно, на практике описанный интерфейс стека может расширяться, в том
числе и за счет операций доступа к элементам, отличным от верхнего, но
общий принцип работы со стеком состоит в том, что нумерация элементов
всегда производится от вершины, и при этом изменения происходят только в
верхних элементах стека. Например, вполне допустимо определить такие
операции над стеком:
// дублирование верхнего элемента -
// операция эквивалентна push(topО)
void dup();
// обмен местами двух верхних элементов - операция может быть
// реализована с помощью следующей последовательности действий:
// const T & а = top(); pop();
// const T & b = top(); pop(); push(a); push(b);
void swap();
// копирование на вершину i-го элемента, считая от вершины
void copy(int i);
Базовые алгоритмы
105
На практике эти и подобные им операции часто включаются в определение
стека как абстрактного типа данных.
Приведем простой пример использования стека в задаче анализа скобочной
структуры некоторого текста.
Пусть задан текст, содержащий три типа скобок: (), [] и {}. Программа
должна проверить правильность расстановки скобок в тексте, т. е., имеется ли для
каждой открывающей скобки соответствующая закрывающая скобка и
наоборот, а также, не нарушены ли правила вложенности скобок. Символы, не
являющиеся скобками, программа должна игнорировать. Для простоты
запишем решение задачи в виде статической функции, получающей исходный
текст в виде строкового аргумента и выдающей истинное значение, если
скобки расставлены правильно, и ложное — если обнаружена ошибка.
Алгоритм решения задачи может быть следующим. Программа, приведенная
в листинге 2.14, читает символ за символом, причем, встретив открывающую
скобку, помещает ее в стек, а, встретив закрывающую скобку, проверяет,
находится ли уже на вершине стека соответствующая открывающая скобка.
Если проверка была успешной, то после нее скобка удаляется из стека, иначе
функция немедленно заканчивает работу, выдав отрицательный результат
(false).
Если в исходном тексте закрывающих скобок оказывается больше, чем
открывающих, то программа попытается извлечь символ из пустого стека. Это
приведет к ситуации stackunderflow, которая перехватывается, и опять в
качестве результата работы будет значение false. Наоборот, если в тексте
открывающих скобок окажется больше, чем закрывающих, то после того, как
весь текст будет прочитан и обработан, в стеке еще останутся незакрытые
скобки. В этом случае функция тоже выдаст значение false. Результат
проверки текста окажется успешным, если во время работы ни разу не
обнаружится несоответствия скобок, а также не возникнет ситуации исчерпания
стека, причем в конце обработки текста стек окажется пустым.
Размер стека определяет максимальную глубину вложенности скобок,
которая может быть исследована программой. При превышении этой глубины
вложенности функция возбудит исключительную ситуацию stackOverfiow.
Будем считать, что эта максимальная глубина вложенности скобок также
передается в функцию как аргумент.
[ Листинг 2.14. Анализ скобочной структуры текста с помощью стека
bool brackets (const char *txt, int niaxdeep = 20) {
Boundstack<char> stk(maxdeep);
try {
char c;
106
Глава 2
for (int i = 0; с = txt[i]; i++) {
switch (c) {
case ' (': case '[': case '{':
stk.push(c);
break;
case ')':
if (stk.topO != ' (') {
return false;
}
stk.popO ;
break;
case ']':
if (stk.topO != f [•) {
return false;
}
stk.popO ;
break;
case f}f:
if (stk.topO != •{') {
return false;
}
stk.popO;
break;
}
}
return stk.empty();
} catch (StackUnderflow) {
return false;
}
}
Заметим, что если в качестве исходной строки для этой функции взять текст
самой функции, то будет зафиксирована ошибка в расстановке скобок из-за
символьных констант, изображающих скобки. Обратите внимание на
обработку исключительных ситуаций в этой функции: возникновение ситуации
StackUnderflow возможно в том случае, когда скобки в исходной строке
расставлены неправильно. Ситуация перехватывается в теле функции и
возвращается false. Если же случится ситуация переполнения стека —
stackoverflow, то это будет свидетельствовать лишь о том, что глубина
вложенности скобок в исходной строке превысила максимально допустимую.
Реакция на эту исключительную ситуацию в функции не предусмотрена, так
что при вызове надо иметь в виду, что такая ситуация может стать
результатом работы, а значит, предусмотреть подходящую реакцию при вызове
функции.
Базовые алгоритмы
107
Рассмотрим другие возможные реализации стека. Если в программе
используется много стеков, размер которых может сильно меняться, то реализация
стека в виде массива (ограниченный стек) может привести к неоправданно
большим расходам памяти. При этом если в какой-то момент времени
реальное количество элементов, содержащихся в стеках, невелико, то память,
зарезервированная для элементов стеков, может быть загружена весьма мало.
В подобных случаях имеет смысл захватывать память только под те
элементы, которые фактически размещаются в стеке.
Основу приведенной в листинге 2.15 реализации стека составляет список
элементов стека List<Eiem>, описанный в разд. 1.2. Список имеет то
преимущество перед массивом, что под его элементы память выделяется
независимо от других элементов. Вершиной стека в этой реализации служит
головной элемент списка. Разумеется, элемент списка — это более сложный
объект, чем элемент массива: требуется дополнительная память для связывания
элементов. Тем не менее общий расход памяти может даже уменьшиться за
счет того, что не расходуется память под несуществующие элементы.
j Листинг2.15, Реализациянерграйим^ногб'отёкаввщьстЬш V;;;Wt\*|
#include "list.h" // Включаем определение списков
#include "stack.h" // Включаем определение абстрактного стека
// Шаблон, представляющий реализацию абстрактного
// стека в виде линейного списка элементов
template <class T>
class ListStack : public Stack<T> {
List<T> stack; // Реализация представлена списком элементов
public:
// Конструкторы стека просто создают список
ListStack() : stack() {}
ListStack(const ListStack & stk) : stack(stk.stack) {}
// Далее объявляются и частично реализуются абстрактные операции
void push(const T & е) { stack.addFirst(e); }
void pop();
bool empty () { return stack.getCount () ===0; }
T & top();
const T & top() const;
}; // Конец объявления класса ListStack
// Операция pop удаляет первый элемент списка
template <class T>
108
Глава 2
void ListStack<T>::pop() {
try {
stack.remove();
} catch (...) {
// Если удалить элемент не удается, возбуждается
// исключительная ситуация StackUnderflow
throw StackUnderflow();
}
}
// Операции доступа к вершине стека реализованы
// через доступ к первому элементу списка
template <class T>
Т & ListStack<T>::top() {
try {
return stack.head();
} catch (...) {
// Если доступ к первому элементу невозможен,
// возбуждается исключительная ситуация StackUnderflow
throw StackUnderflow();
}
}
template <class T>
const T & ListStack<T>::top() const {
try {
return stack.head();
} catch (...) {
// Если доступ к первому элементу невозможен,
// возбуждается исключительная ситуация StackUnderflow
throw StackUnderflow();
}
}
В приведенной реализации все операции со стеком реализуются с помощью
соответствующих операций со списком. По существу, происходит просто
перевод одних понятий в другие: операция списка addFirst превращается в
операцию push стека; исключительная ситуация, возникающая при попытке
обращения к несуществующему элементу списка, превращается в ситуацию
StackUnderflow и т. д. Тем не менее, несмотря на такую близость понятий, мы
бы не рекомендовали реализовывать стек как класс, производный от класса
List. Программное содержание операций может совпадать, но семантический
смысл в них вкладывается разный. Кроме того, реализация интерфейса stack
Базовые алгоритмы
109
позволяет использовать в программе только абстрактное понятие стека,
однако если стек реализуется и применяется как производный класс от класса
List, программа уже использует не столько абстрактный интерфейс, сколько
эту конкретную реализацию, и, следовательно, начинает зависеть от этой
реализации.
Иногда используется некоторая комбинация реализаций стека в виде списка и
в виде массива. Например, можно использовать массив для хранения
элементов двух стеков, растущих друг навстречу другу. К сожалению, операции над
такими разнонаправленными стеками требуют различной реализации, так что
здесь речь идет не о реализации двух отдельных стеков, а, скорее, об объекте,
представляющем сразу пару стеков. Переполнение в этом случае возникает
сразу у обоих стеков, когда весь массив оказывается заполненным
элементами.
Еще один вариант реализации — хранение элементов многих стеков в одном
массиве (пуле), причем элементы в этом случае лучше связывать между
собой не с помощью стандартных указателей, а с помощью индексов массива.
Такая реализация позволяет гибко переиспользовать память. Переполнение в
этом случае возникает сразу для всех стеков в ситуации, когда весь пул занят
их элементами. Такая реализация хорошо подходит для случая, когда работа
со стеками составляет основу программы, и резервирование памяти
системными средствами под каждый элемент стека не годится из-за высоких
накладных расходов на работу системы.
Рассмотрим еще одну задачу, для решения которой удобно воспользоваться
парой стеков. Пусть дан текст, представляющий собой выражение,
содержащее операнды, соединенные между собой знаками операций +, - и *.
Допускается также использование круглых скобок. Будем считать, что значение
каждого операнда может быть вычислено, причем результатом вычисления
будет некоторое целое значение. Требуется вычислить значение всего
выражения.
Прежде всего уточним постановку задачи. Будем считать, что выражение не
содержит ошибок, таких как неопределенные знаки операций,
несбалансированные скобки и т. п. Кроме того, будем считать, что текст уже
предварительно разобран на отдельные элементы — лексемы, представляющие
минимальные рассматриваемые единицы текста: операнд, знак операции, скобка.
Разбор осуществляется с помощью специального анализатора— объекта
parser класса LexAnaiyzer. Анализатор строки создается с помощью
конструктора, получающего исходное выражение в качестве аргумента. После
этого лексемы можно будет получать по одной с помощью вызова функции
next о, до тех пор, пока строка не кончится. Проверить, есть ли еще в тексте
лексемы, можно с помощью вызова логической функции hasNext (). Каждая
110
Глава 2
лексема в свою очередь является объектом класса Lexical, содержащего
значение очередной лексемы — числовое значение операнда или знак операции.
Еще одна компонента лексемы — лексический класс будет показывать тип
лексемы.
В листинге 2.16 представлены классы LexAnaiyzer и Lexical, но реализация
их методов не показана.
[Листинг 2.16. Определение классов 1&^Х&жн]ь^саХ > - •- > ;; (
class LexAnaiyzer {
public :
// Конструктор:
LexAnaiyzer(const char * source);
Lexical next(); // выдает очередную лексему
bool hasNext(); // проверяет, есть ли еще лексемы
}
class Lexical {
// Возможные типы лексем:
enum LexType {
OPERAND =1, // операнд
OPERATOR, // знак операции
LEFTPAR, // левая (открывающая) скобка
RIGHTPAR // правая (закрывающая) скобка
};
// Конструкторы
Lexical(int lexClass = OPERAND, int value = 0);
Lexical(const Lexical & src);
// Проверка класса и выдача значения лексемы:
int getLexClass() const; // Класс лексемы
char getOperSign() const; // Знак операции (в случае оператора)
int getValueO const; // Значение операнда (в случае операнда)
};
Для решения нашей задачи будем использовать два стека. В одном из них
будут храниться значения операндов и промежуточных результатов. Таким
образом, элементами этого стека будут целые числа. Во втором стеке в
процессе вычислений будут храниться знаки операций вместе со своими
приоритетами. Эти операции будут записываться в стек и выполняться по мере
возможности, т. е. как только будут готовы значения операндов для выполнения
операции. В следующем примере (листинг 2.17) приведено определение
класса Operator, представляющего знак операции и ее приоритет, которые
хранятся в стеке операций.
Базовые алгоритмы
111
L;...i..l..^ ^^IzZ...»..;^.^ :*:^*^:.;.JE^^
class Operator {
char sign; // Знак операции
int prio; // Приоритет операции
public :
// Конструктор создает объект по знаку операции
Operator(char s = f(f) :
sign(s), // запоминаем знак операции
//и вычисляем приоритет по знаку операции
prio(s =='*•? 2 : s == f+' || s = f-' ? 1 : 0)
{}
// Конструктор копирования
Operator(const Operator & src) :
sign(src.sign), prio(src.prio)
О
// Функции доступа:
char getSign() const { return sign; }
int getPrioO const { return prio; }
};
Теперь определим функцию (листинг 2.18), которая выполняет операцию,
лежащую на вершине стека операций, над операндами, лежащими на
вершине стека операндов. Операнды— целые числа— извлекаются с вершины
стека операндов. Результат выполнения операции снова записывается в стек
операндов. Стеки с операциями и операндами передадим функции в виде
аргументов.
После этого определим несколько более сложную функцию, которая
исполняет последовательность операций до тех пор, пока в стеке операций не
обнаружится операция, имеющая приоритет, меньший заданного, или вовсе не
окажется операций. Такая функция исполняется в момент, когда в процессе
анализа обнаруживается новая операция, и надо выполнить все те операции
такого же и более высокого приоритета, которые уже попали в стек операций
и операнды для которых уже подготовлены.
|Slyicfинг2,18, функции\вьтотещ^ф^ф1^; :{fт^Г- с;Л-{:УУ -.,AS*>/^Щ}У
#include "stack.h"
// Функция doOperator выполняет одно действие,
// используя абстрактные стеки операндов и операций
112
Глава 2
void doOperator(
Stack<int> & operands, // стек операндов
Stack<Operator> & operators // стек операций
) {
int op2 = operands.top(); // правый операнд
operands.pop(); // операнд удаляется из стека
int opl = operands.top(); // левый операнд
operands.pop(); // операнд удаляется из стека
char opSign = operators.top().getSign(); // знак операции
operators.pop(); // операция также удаляется из стека
//В зависимости от знака операции выполняется соответствующее
// действие, и результат операции записывается в стек операндов
switch (opSign) {
case ' +' :
operands.push(opl + op2) ;
break;
case '-':
operands.push(opl - op2);
break;
case '*':
operands.push(opl * op2);
break;
}
}
// Функция doOperators выполняет действия,
// записанные в стеке операций до тех пор, пока стек не будет исчерпан
// или в нем не встретится операция с приоритетом, меньшим заданного.
void doOperators(
Stack<int> & operands, // стек операндов
Stack<Operator> & operators, // стек операций
int minPrio // граничный приоритет
) {
while (!operators.empty() &&
operators.top().getPrio() >= minPrio) {
doOperator(operands, operators);
}
В обеих функциях возможно возникновение ситуации stackunderf low в
методах top () и pop () стека, а также ситуации stackOverf low при вызове метода
push (). Для простоты в данном примере эти ситуации не обрабатываются, а
просто пропускаются в вызывающую функцию.
базовые алгоритмы
113
Наконец, определим функцию (листинг 2.19), которая создает оба стека и
выполняет все требуемые вычисления.
[Листинг 2.19. Вычисление значения выражения на стеках
int exprValue(char * expr) {
Stack<int> * operands = new ListStack<int>(); // операнды
Stack<Operator> * operators = new ListStack<Operator>(); // операции
LexAnalizer analizer(expr); // анализатор
try {
while (analizer.hasNext()) {
Lexical lex = analizer.next();
switch (lex.getLexClass()) {
case Lexical::OPERAND:
/* операнд записывается в стек */
operands->push(lex.getValue());
break;
case Lexical::RIGHTPAR:
/* выполняем все операции до соответствующей левой
скобки, которую затем выталкиваем из стека
*/
doOperators(^operands, *operators, 1);
operators->pop();
break;
case Lexical::OPERATOR:
/* выполняются операции с приоритетом большим или равным
приоритету новой операции, после чего новый оператор
записывается в стек операций
*/
{
Operator newOp(lex.getOperSign() ) ;
doOperators(^operands, *operators, newOp.getPrio());
operators->push(newOp);
}
break;
case Lexical::LEFTPAR:
/* левая скобка просто записывается в стек операций
с приоритетом О
*/
operators->push(Operator('('));
break;
}
}
114
Глава 2
If Анализ закончен, выполняем все оставшиеся в стеке операции
doOperators(*operands, ^operators, 0);
//на вершине стека операндов - результат вычислений.
int result = operands->top();
// Стеки больше не нужны - уничтожаем их
delete operands;
delete operators;
return result;
} catch (StackOverflow & ex) {
delete operands;
delete operators;
throw WrongExpressionO ;
} catch (StackUnderflow & ex) {
delete operands;
delete operators;
throw WrongExpressionO;
}
}
При условии правильности подаваемого на вход выражения приведенная
функция произведет все необходимые вычисления и выдаст целочисленный
результат. Ее нетрудно расширить на случай других бинарных операций
(скажем, деления) или других типов операндов. Не очень трудно
предусмотреть и случай корректного анализа ошибок. В приведенной функции
определена реакция только на случай переполнения или исчерпания
использующегося стека. Однако, очевидно, что множество допустимых лексем в каждый
момент времени определено однозначно: после левой скобки или знака
операции должен идти операнд или левая скобка; после операнда или правой
скобки ожидается знак операции или правая скобка. Если учесть это правило
и следить за правильностью расстановки скобок и допустимостью всех
лексем, то в процессе вычисления любую ошибку в выражении можно легко
обнаружить.
Как в этом, так и в предыдущем примере в функциях exprvaiue и brackets
используется абстрактный стек, представленный интерфейсом stack. Тем не
менее обе функции все же остаются зависимыми от конкретной реализации
стека, т. к. при создании стека приходится использовать конструктор одной
из реализаций. В частности, это означает, что если по каким-либо причинам в
проекте будет произведена глобальная замена реализации стека, то придется
вносить изменения во все места программы, где применяются вызовы
конструкторов. Хорошо было бы и для создания стеков тоже использовать
абстрактные функции, вынеся вызовы конструкторов в отдельный класс так,
чтобы при смене реализации стека можно было бы произвести замену только
Базовые алгоритмы
115
одной строчки в программе. Такая технология носит название фабрики
объектов.
В нашем случае можно построить интерфейс stackFactory (фабрика стеков),
реализующий операцию создания абстрактного стека newstack ().
Единственной задачей этого метода будет вызов конкретного конструктора для
создания стека. Например:
template <class T>
class StackFactory {
public:
virtual Stack<T> *newStack(int size = 100) = 0;
};
Теперь если считать, что имеется некоторая переменная, значением которой
будет такая абстрактная фабрика, то ее можно применять для создания новых
стеков. Для этого во всех используемых ранее функциях надо заменить вызов
конкретного конструктора стека на вызов функции newstack с помощью
фабрики. Например, для наших двух стеков понадобятся две абстрактные
фабрики, которые можно представить переменными:
extern StackFactory<int> * stacklntFactory;
extern StackFactory<Operator> * stackOpFactory;
Тогда начало функции exprValue будет выглядеть следующим образом:
int exprValue(char * expr) {
Stack<int> * operands = stacklntFactory.newStack(); // операнды
Stack<Operator> * operators = stackOpFactory.newStack(); // операции
}
Разумеется, для каждого способа реализации стеков необходимо реализовать
также и соответствующую фабрику, которая будет создавать конкретные
стеки в выбранном представлении. Так, например, если имеется представление
ограниченного стека Boundstack в виде массива элементов, то следует также
определить класс, задающий фабрику ограниченных стеков. Описание такого
класса может выглядеть следующим образом:
template <сlass T>
class BoundStackFactory : public StackFactory<T> {
public:
Stack<T> *newStack(int size = 100) { return new Boundstack<T>(size); }
};
Теперь обе переменные stacklntFactory И stackOpFactory могут быть ОПИСа-
ны и инициализированы. Для этого в исполняемую программу следует
включить строки:
116
Глава 2
StackFactory<int> *stacklntFactory = new BoundStackFactory<int>;
StackFactory<Operator> *stackOpFactory = new BoundStackFactory<Operator>;
Если по тем или иным соображениям потребуется поменять представление
стеков в нашей программе, то можно будет добавить класс реализации для
соответствующей фабрики стеков и заменить инициализацию переменных,
представляющих фабрики. Например, если класс Liststack является
альтернативной реализацией стека в виде списка элементов, то надо будет добавить
определение соответствующей фабрики стеков:
template <class T>
class ListStackFactory : public StackFactory<T> {
public:
Stack<T> *newStack(int size = 100) { return new ListStack<T>(); }
};
и заменить описание двух переменных:
StackFactory<int> *stacklntFactory = new ListStackFactory<int>;
StackFactory<Operator> *stackOpFactory = new ListStackFactory<Operator>;
Программы, использующие абстрактные стеки, останутся при этом
неизменными. Подробнее об абстрактных фабриках и виртуальных конструкторах
можно прочитать в книге [2]. Полностью все приведенные в книге
реализации стеков и программы их использования содержатся на приложенном
компакт-диске В папках "\Chapter2\2.3\Stacks" И "\Chapter2\2.3\Calculator".
Похожей на стек структурой данных является очередь. Очередью будем
называть последовательность элементов одного и того же типа, к которой
добавляются и из которой удаляются элементы. Однако, в отличие от стека,
добавление элементов производится в один конец, а удаление происходит из
другого конца (рис. 2.3).
Тот конец очереди, из которого производится удаление элементов,
называется головой очереди, а другой ее конец, в который происходит добавление
элементов, — хвостом очереди.
<^
Голова
Хвост
Ж.
N"
Рис. 2.3. Структура очереди
Базовые алгоритмы
117
Набор операций над очередью практически тот же, что и над стеком, однако
реализации обычно отличаются достаточно сильно— наличие двух концов
у очереди осложняет дело.
Можно представлять очередь в виде кольца элементов с разрывом в точке,
где сходятся ее голова и хвост (рис. 2.4).
Рис. 2.4. Очередь, представленная в виде кольца
Если память под очередь представлена массивом элементов, то реализация
будет учитывать эту кольцевую структуру массива— первый его элемент
будет логически следовать за последним, так что в программе при переходе
от предыдущего элемента к следующему придется специально проверять, не
нужно ли переходить от конца к началу массива. Если же в качестве базовой
структуры памяти выбирается список, то его кольцевая структура может быть
организована естественным образом с помощью ссылки из последнего
элемента списка на его первый элемент.
Один из вариантов интерфейса абстрактной очереди представлен в
листинге 2.20.
i Листинг 2.20. Интерфейс для описания очереди
template <class T>
class Queue {
public:
// Виртуальный деструктор для переопределения в реализациях
virtual -Queue() {}
// Операции с абстрактной очередью:
// Добавление элемента в очередь. Может возникнуть ситуация
// QueueOverfow, если в очереди нет места для записи нового элемента.
virtual void enqueue(const T & e) = 0;
118
Глава 2
// Удаление элемента из головы очереди. Если ни одного элемента
//в очереди нет, может возникнуть ситуация QueueUnderflow.
virtual void dequeue() = 0;
// Проверка того, что очередь пуста (не имеет ни одного элемента)
virtual bool empty() const = 0;
// Четыре функции доступа для чтения/записи
// элемента в голове и в хвосте очереди.
virtual Т & head() = 0;
virtual const T & head() const = 0;
virtual T & tail() = 0;
virtual const T & tail() const = 0;
};
Здесь предполагается, что при добавлении элемента в очередь (как и при
добавлении в стек) может возникнуть ситуация переполнения очереди. Такое
возможно, например, при реализации очереди в виде ограниченного массива.
Ситуация исчерпания очереди может возникнуть при попытке удаления или
взятия значения элемента из пустой очереди.
В нашей первой реализации (листинг 2.21) очередь будет представлена
массивом элементов. В данном случае это будет ограниченная очередь.
Максимальное количество элементов очереди задается при ее создании в
конструкторе. Реализация очень похожа на соответствующую реализацию
ограниченного стека, однако есть и существенное отличие. Поскольку элементы
добавляются в один конец очереди, а удаляются из другого конца, то в
процессе работы с очередью элементы будут смещаться в конец массива.
Наконец, может настать момент, когда ни одного элемента больше вставить не
удастся, хотя свободное место в массиве будет — в начале массива на месте
ранее существовавших, но впоследствии удаленных элементов. Можно,
конечно, физически передвинуть элементы очереди, однако это может
оказаться достаточно длительной процедурой. Лучше просто написать методы
работы с очередью так, чтобы считать массив замкнутым в кольцо, т. е.
переходить от последнего элемента массива непосредственно к первому.
В приведенной реализации переменные класса pHead и pTaii содержат
индексы первого и последнего элемента очереди, соответственно. Количество
элементов очереди равно (pTaii - pHead + 1), если индекс последнего
элемента больше или равен индексу первого элемента (рис. 2.5), и равно
(maxSize - pHead + pTaii + 1) —В прОТИВНОМ Случае (рис. 2.6).
Одна из трудностей состоит в том, что при удалении последнего элемента из
очереди возникает ситуация, при которой нет ни первого, ни последнего
элемента. Удобно в реализации считать, что в этом случае индекс pTaii на еди-
Базовые алгоритмы
119
ницу больше индекса pHead. Однако точно такая же ситуация возникает и
в том случае, когда все элементы массива заняты!
рНе
)ad = s
ш
1р
i
3
W
ш
1ш
ш
■
^^
1
1
^
^
pTail =
б
Рис. 2.5. Очередь содержит элементы (3), (4), (5) и (6)
0123456 78
]pTail = 2
I pHead =
7
Рис. 2.6. Очередь содержит элементы (7), (8), (0), (1) и (2)
Существует множество способов справиться с этой ситуацией. Один из них
(приведенный в этой книге) состоит в том, что дополнительно к индексам
первого и последнего элементов хранится количество элементов массива
(ncount), занятых элементами очереди. В этом случае ситуация отсутствия
элементов однозначно определяется тем, что ncount == о. Упрощается также
реализация метода empty о, и дополнительно появляется возможность очень
просто реализовать метод, выдающий количество элементов очереди. Платой
за такую простоту является то, что приходится тратить время и память на
работу с дополнительной переменной.
Все остальное в этой реализации очень просто и не нуждается в пояснениях.
| ^Листинг 2.21. Реализация ограниченной очереди
template <class T>
class BoundQueue : public Queue<T> {
120
Глава 2
Т * elements; // массив элементов очереди
int maxSize; // количество элементов в массиве
int pHead; // индекс первого элемента
int pTail; // индекс последнего элемента
int nCount; // количество элементов очереди
public:
// Конструктор ограниченной очереди BoundQueue.
// Необязательный аргумент size задает размер массива по умолчанию.
BoundQueue(int size = 100);
// Конструктор копирования
BoundQueue(const BoundQueue<T> & src);
// Деструктор освобождает память, занятую элементами очереди.
virtual -BoundQueue() { delete [] elements; }
// Реализация абстрактных операций над очередью
// Включение нового элемента в конец очереди
void enqueue(const T & item) {
// Проверка, есть ли в массиве место для нового элемента
if (nCount == maxSize) throw QueueOverflowO;
// Помещение элемента в массив
if (++pTail = maxSize) pTail = 0;
elements[pTail] = item;
nCount++;
}
// Удаление элемента из головы очереди
void dequeue() {
// Проверка, есть ли в очереди хоть один элемент
if (nCount == 0) throw QueueUnderflow();
if (++pHead == maxSize) pHead = 0;
Базовые алгоритмы
121
// Уменьшение счетчика числа элементов
nCount—;
}
// Проверка очереди на пустоту
bool empty() const { return nCount ==0; }
// Доступ к головному элементу очереди
Т & head () {
// Проверка, есть ли в очереди хоть один элемент
if (nCount == 0) throw QueueUnderflow();
// Результат - ссылка на головной элемент
return elements[pHead];
}
const T & head() const {
// Проверка, есть ли в очереди хоть один элемент
if (nCount == 0) throw QueueUnderflow();
// Результат - постоянная ссылка на головной элемент
return elements[pHead];
// Доступ к хвостовому элементу очереди
Т & tail () {
// Проверка, есть ли в очереди хоть один элемент
if (nCount == 0) throw QueueUnderflow();
// Результат - ссылка на хвостовой элемент
return elements[pTail];
}
const T & tail () const {
// Проверка, есть ли в очереди хоть один элемент
if (nCount == 0) throw QueueUnderflow();
// Результат - постоянная ссылка на хвостовой элемент
return elements[pTail];
}
};
Еще одна реализация очереди использует кольцевой список как базовую
структуру памяти. Как и в случае со стеком, основную нагрузку берет на себя
реализация операций с кольцевым списком, а уже в очереди эти операции
просто переводятся на язык ее интерфейса. В листинге 2.22 приведено
описание шаблона классов circuiarList, представляющего список кольцевой
структуры, и далее определена реализация очереди на базе этого класса.
722
Глава 2
^Листинг 2.122. Определение очереди на базе списка 5 г 1
// Реализация простейшего кольцевого списка
//=__=======^^
template <class T>
class CircularList {
// Элементы списка состоят из собственно хранимого значения
//и указателя на следующий элемент списка.
struct ListItem {
Т item; // элемент списка
ListItem *next; // следующий элемент
Listltem(const T & item, Listltem *next = NULL) {
Listltem:litem = item;
Listltem: :next = next;
}
};
// Список представлен указателем на последний элемент списка,
// который, в свою очередь, содержит указатель на первый элемент.
// Этот указатель будет пустым, если список не содержит элементов.
Listltem *last;
public :
// Конструктор по умолчанию создает новый пустой список.
CircularList() { last = NULL; }
// Конструктор копирования создает
// копию аргумента с помощью присваивания.
CircularList(const CircularList<T> & src) { *this = src; }
// Деструктор освобождает память, занятую элементами списка.
virtual -CircularList() { destroy(); }
// Вставка новых элементов может производиться
// как в начало, так и в конец списка.
void insertHead(const T & item);
void insertTail(const T & item);
Базовые алгоритмы
123
// Удалять можно только первый элемент
void removeHead () ;
// Функция 'empty' проверяет, содержит ли список хоть один элемент.
bool empty() const { return last == NULL; }
// Функции доступа дают возможность чтения/записи
//в первый и последний элементы списка
Т & head();
const T & head() const;
Т & tail () ;
const T & tail () const;
// Оператор присваивания
CircularList<T> & operator = (const CircularList<T> & src);
// Функция разрушает список, освобождая память, занятую его элементами.
void destroy();
};
template <class T>
void CircularList<T>::insertHead(const T & item) {
if (last == NULL) {
// Новый элемент будет одновременно первым и последним
last = new Listltem(item);
last->next = last;
} else {
// Новый элемент вставляется за последним
last->next = new Listltem(item, last->next);
}
template <class T>
void CircularList<T>::insertTail(const T & item) {
insertHead(item);
// Чтобы первый элемент стал последним в кольцевом списке,
// достаточно сдвинуться вперед на один шаг
last = last->next;
}
template <class T>
void CircularList<T>::removeHead() {
if (last == NULL) throw EmptyException();
124
Глава 2
if (last->next == last) {
// удаляется единственный элемент
delete last;
last = NULL;
} else {
Listltem * itemToDelete = last->next;
last->next = last->next->next;
delete itemToDelete;
}
template «slass T>
T & CircularList<T>::head() {
if (last == NULL) throw EmptyException() ;
return last->next->item;
}
template <class T>
const T & CircularList<T>::head() const {
if (last == NULL) throw EmptyException();
return last->next->itern;
}
template <class T>
T & CircularList<T>::tail() {
if (last == NULL) throw EmptyException();
return last->item;
}
template <class T>
const T & CircularList<T>::tail() const {
ir* (last == NULL) throw EmptyException () ;
return last->item;
}
// Оператор присваивания
template <class T>
CircularList<T> & CircularList<T>::operator =
(const CircularList<T> & src) {
destroy();
if (!src.empty()) {
Listltem * current = src.last->next; // Указатель на первый элемент
do {
insertTail(current->item);
Базовые алгоритмы
125
if (current == src.last) break; // Последний элемент добавлен
current = current->next;
} while (true);
}
return *this;
}
template <class T>
void CircularList<T>::destroy() {
while (last) removeHead();
}
' Теперь
на
базе
реализующий
определения
абстрактную
кольцевого
очередь.
списка
определим
класс,
template<class T>
class ListQueue : public Queue<T> {
CircularList<T> list; // Базовый список
public :
// Конструкторы и деструкторы определяются очень просто.
ListQueue() : list() {}
ListQueue(const ListQueue & src) { list = src.list; }
virtual -ListQueue() {}
// Теперь определим и реализуем все абстрактные операции.
void enqueue(const T & item) { list.insertTail(item); }
void dequeue();
bool empty() const { return list.empty(); }
T & head();
const T & head() const;
T & tail();
const T & tail() const;
};
// Операция удаления элемента из очереди
template <class T>
void ListQueue<T>::dequeue() {
try {
return list.removeHead();
} catch (EmptyException) {
126
Глава 2
throw QueueUnderflow();
}
}
// Функции доступа к концевым элементам очереди
template <class T>
Т & ListQueue<T>::head() {
try {
return list.head();
} catch (EmptyException) {
throw QueueUnderflow();
}
}
template <class T>
const T & ListQueue<T>::head() const {
try {
return list.head();
} catch (EmptyException) {
throw QueueUnderflow();
}
}
template <class T>
T & ListQueue<T>::tail() {
try {
return list.tail();
} catch (EmptyException e) {
throw QueueUnderflow();
}
}
template <class T>
const T & ListQueue<T>::tail() const {
try {
return list.tail();
} catch (EmptyException e) {
throw QueueUnderflow();
}
}
Здесь при описании кольцевого списка определены все необходимые
операции над ним по добавлению, удалению и доступу к концевым элементам, ко-
Базовые алгоритмы
127
торые впоследствии используются при определении очереди. Конечно, в
реальной жизни для работы с кольцевым списком этих операций будет
недостаточно, однако для работы с очередью этого вполне хватит.
При попытке обратиться к несуществующим элементам списка возбуждается
ситуация EmptyException (которую, разумеется, тоже надо определить
отдельно). При реализации операций над очередью она превращается в
ситуацию, характерную ДЛЯ очереди, — QueueUnderf low.
Интересно, что практически вся реализация уложилась в чистый перевод
одних названий (операций над списком) в другие (операции над очередью).
Если бы не разница в названиях исключительных ситуаций, реализация каждой
функции вообще состояла бы из единственного вызова соответствующей
операции над списком. Тем не менее подчеркнем еще раз: очередь не
является частным случаем кольцевого списка, соответственно, реализовывать ее в
виде наследника класса circuiarList не следует, хотя работать подобная
реализация будет не хуже только что приведенной. Причина этого состоит в том,
что над очередями должны выполняться только операции, допустимые для
очередей, но не списковые операции. Конечно, операции, объявленные в
интерфейсе класса circuiarList, можно закрыть для использования над
объектами класса Queue, например, объявив наследование не с помощью ключевого
слова public, а с помощью слова protected или private. Однако все же
идеологически более чистым подходом будет именно включение списка в
реализацию очереди в виде переменной-члена класса, а не реализация
наследованием.
Описанные примеры реализации очереди имеются на приложенном компакт-
диске в папке "\chapter2\2.3\Queue". Еще некоторые примеры использования
стеков и очередей можно найти в книге далее в разд. 2.5, 6.1 и главе 5.
В заключение этого раздела приведем пример одной экзотической
реализации стека (есть и аналогичная реализация очереди), предназначенной для
случая неглубокого стека, содержащего только битовые значения (нули и
единицы). В этом случае весь стек можно уложить в одно слово (например,
в значение типа unsigned int или unsigned long). В слове может помещаться
16 или 32 бита, но поскольку по крайней мере один бит придется занять для
служебных целей (надо отметить положение вершины стека), то остается 15
или 31 бит, в которых можно сохранить одновременно до 15 или,
соответственно, 31 битовых элемента стека. Для многих задач этого вполне
достаточно. Рассмотрим, например, задачу об анализе скобочной структуры
выражения, решение которой было приведено в этой главе в листинге 2.5. Если бы в
задаче речь шла не о трех, а о двух типах скобок (скажем, круглые и
квадратные), то для хранения информации о типе скобки было бы достаточно одного
битового значения. Стека из 15-битовых элементов вполне хватило бы для
128
Глава 2
практических целей — вложенность скобок редко превышает 7—8 уровней,
так что ситуация переполнения стека была бы очень маловероятной.
В реализации все операции определены над элементами типа int (на самом
деле предполагается, что значениями этих элементов будут только нули и
единицы). Соответственно, в листинге 2.23 приведена реализация интерфейса
stack<int>, однако фактически в стек будут помещаться только нули и
единицы.
I Листинг 2.23. Реализация стека битовых значений в одном целом значении
* Реализация ограниченного стека из битовых элементов
* Стек может содержать до 31 бита
***************************************** **************/
class BitStack : public Stack<int> {
unsigned long stack; // содержимое стека. Вершина - младший бит слова
public :
// Конструкторы битового стека.
// stack == 1 означает, что дно стека находится в
// младшем бите, т. е. в стеке нет элементов.
BitStack() : stack(1) {}
// Копирование такого стека сводится к копированию одного слова stack.
BitStack(const BitStack & src) : stack(src.stack) {}
// Операция вталкивания элемента в стек
void push(const int & e) {
// Проверка переполнения: старшая единица в слове
// служит маркером дна стека
if (stack & 0x80000000) throw StackOverflow();
stack «= 1; // содержимое сдвигается влево на один бит
stack |= (е & 1); // новый элемент записывается в младший бит
}
// Операция выталкивания элемента из стека
void pop() {
// стек пуст, если в нем содержится только маркер дна стека
if (stack == 1) throw StackUnderflow();
stack »= 1; // Содержимое слова сдвигается вправо
}
Базовые алгоритмы
129
// Операция выдачи содержимого вершины стека
// (ссылка на временную переменную!)
int & top () {
if (stack == 1) throw StackUnderflow();
//На самом деле при такой реализации нельзя выдать полноценную
// ссылку для записи значения, поскольку невозможно адресовать
// один-единственный бит. Поэтому используется не очень-то красивый
// суррогат: выдается ссылка на локальную временную переменную.
//В реальной работе со стеком функцию top можно использовать
// только для чтения значения, но не для записи на вершину стека!
int temp = stack & 1;
return temp;
}
// Операция выдачи содержимого вершины стека (во временной переменной)
const int & top() const {
if (stack == 1) throw StackUnderflow();
int temp = stack & 1;
return temp;
}
// Операция проверки пустоты стека
bool empty() const {
return stack == 1;
}
};
В приведенной реализации есть один существенный недостаток. Функция
доступа к значению, лежащему на вершине стека, фактически не может быть
реализована в таком виде, как она заявлена в интерфейсе stack. Проблема
состоит в том, что невозможно сформировать адрес одного бита внутри
слова. Поэтому вместо двух вариантов функции top (), выдающей в качестве
результата ссылку на значение, лежащее на вершине стека, следовало бы
определить Две функции, Скажем, int topValueO И void replaceTop(int bit) ДЛЯ
выдачи битового значения, лежащего на вершине стека и замены этого
значения на новое. Конечно, реализовать такие две функции не составило бы
труда, но, к сожалению, если мы хотим, чтобы у нас оставалась реализация
заданного интерфейса stack, надо выдерживать этот интерфейс полностью в
том виде, в котором он был заявлен. Таким образом, имеем две довольно
безрадостные альтернативы: либо отказаться от того, что наша реализация
является реализацией интерфейса stack, признав ее самостоятельным классом,
либо смириться с тем, что функция top о в этой реализации не применима
для замены значения, лежащего на вершине стека.
130
Глава 2
Можно написать реализацию стека, основанную на той же идее, но с
элементами данных, занимающими не один бит, а два или даже три. Разумеется, чем
более крупные элементы записываются в стек, тем меньше их число удастся
записать. Так, для элементов шириной в три бита (например, целые числа из
диапазона от 0 до 7) в 32-разрядном слове хватит места для 10 элементов, так
что максимальная глубина стека составит 10 позиций.
2.4. Итераторы
В этом разделе мы рассмотрим вопрос о том, как лучше всего организовать
просмотр элементов сложной структуры данных. Пусть, например, некоторая
программа использует определение списка целых чисел, приведенное нами в
разд. 1.2. Программа может построить список, используя операции,
определенные В классе IntList, такие как IntList: :addFirst И IntList: :addLast, ИЛИ
удалять из него элементы с помощью операции IntList::remove, однако, для
того, чтобы просматривать содержимое элементов списка, этих операций не
достаточно. Попробуем восполнить этот недостаток.
Если в программе требуется проверять, имеется ли в списке некоторое
конкретное число, то удобнее всего было бы иметь среди операций класса
IntList метод boolean hasEiement (int). Конечно, такую операцию
определить несложно, тем более что, по-видимому, она была бы полезна во многих
случаях. Однако таких ситуаций может оказаться довольно много, и не всегда
получится, что соответствующая операция будет достаточно общей для того,
чтобы стоило включать ее в интерфейс класса. Например, если в программе
требуется подсчитывать количество отрицательных элементов списка, то,
КОНеЧНО, ТОЖе МОЖНО было бы Определить Операцию int IntList::
negatives о, но на самом деле такая операция слишком специфична для того,
чтобы включать ее непосредственно в описание класса.
Имеется и еще одно соображение. Основу обеих приведенных в качестве
примеров операций составляет одно и то же действие — просмотр элементов
списка. Повторять это действие в реализации каждой из операций не очень
разумно. Если бы удалось выделить просмотр элементов в отдельную
операцию, то это не только позволило бы легко определять все действия, подобные
приведенным выше, но и обеспечило бы независимость реализации
просмотра элементов от конкретных действий с элементами списка.
Одно из возможных решений этой задачи — это описать абстрактное
действие над элементами списка в виде класса (конечно, это опять будет
абстрактный класс) и параметризовать просмотр элементов списка объектом такого
класса.
Остановимся на этом решении чуть более подробно. Абстрактный класс,
представляющий операцию, выполняемую над элементами списка, будет со-
Базовые алгоритмы
131
держать единственную интерфейсную функцию — функцию обработки
целого числа— элемента списка. Определение такого класса может выглядеть
следующим образом:
class Actor {
public:
virtual void action(int & element) = 0;
};
Теперь просмотр элементов списка может быть организован независимо от
содержания конкретной операции над элементами списка, в виде метода void
intList:: traverse (Actor & а). Если вернуться к разд. 1.2 и вспомнить
сделанное там представление списков, то реализовать операцию обхода списка
(просмотра его элементов) не составит труда. В листинге 2.24 представлена
реализация операции traverse в контексте описания класса intList из
разд. 1.2. Часть определения класса, сделанная ранее, повторена в этом
листинге.
class IntList {
/* класс ListItem представляет элемент списка,
связанный со следующим с помощью поля next
*/
struct Listltem {
int item; // значение элемента списка
Listltem *next; // указатель на следующий элемент списка
// Конструктор для создания нового элемента
Listltem (int i, Listltem *n = NULL) { item = i; next = n; }
};
int count; // счетчик числа элементов
Listltem *first; // первый элемент списка
Listltem *last; // последний элемент списка
public :
// Операция прохождения списка
void traverse(Actor & a);
}; // Конец определения класса IntList
void IntList::traverse(Actor & a) {
for (Listltem *current = first; current; current = current->next) {
a.action(current->item) ;
}
}
132
Глава 2
Теперь для того, чтобы выполнить конкретную обработку элементов списка,
надо отдельно описать действие по обработке одного элемента, затем создать
объект соответствующего класса, применить к нему операцию traverse и
получить результат обработки. Например, если требуется найти сумму всех
элементов списка, то прежде всего опишем действие суммирования в виде
класса — наследника абстрактного класса Actor:
class Suramator : public Actor {
int sum;
public:
SummatorO : sum(O) {}
int getSumO { return sum; }
void action (int & element) { sum += element; }
};
Основное действие, которое выполняют объекты этого класса над
элементами списка, — это добавление значения элемента к хранящейся в объекте
сумме. Теперь, если имеется список целых, представленный переменной list
класса intList, то просуммировать его элементы можно с помощью
специально созданного для этого объекта класса Summator:
Summator summator;
list.traverse(summator);
Наконец, накопленную сумму можно извлечь из объекта summator с помощью
вызова summator. getSum ().
Похожим образом можно реализовать и другие упомянутые выше действия.
Например, найти нужный элемент в списке можно определив класс, для
которого действие над элементом списка заключается в сравнении его с
некоторым заранее заданным значением:
class Comparator : public Actor {
int value; // значение для сравнения
bool found; // признак того, найдено ли значение value в списке
public :
Comparator(int val) : value(val), found(false) {}
bool hasFound() { return found; }
void action(int & element) { if (value == element) found = true; }
};
Тогда функция, проверяющая, имеется ли в списке элемент с заданным
значением, могла бы выглядеть следующим образом:
bool hasElement(IntList & 1, int value) {
Comparator comp(value);
Базовые алгоритмы
133
1. traverse (comp) ;
return comp.has Found();
}
Разумеется, в самом определении класса intList специфические функции,
использующие просмотр элементов списка, могут использовать функцию
traverse не только для того, чтобы уменьшить длину кода, но и для того,
чтобы сохранить общий принцип хорошего программирования: одно и то же
действие не должно описываться несколько раз. Так, функции поиска
элемента в списке или суммирование элементов списка могут и должны
определяться непосредственно в теле самого класса intList.
Однако и такие экзотические задачи, как подсчет числа отрицательных
элементов списка, будут решаться точно так же, надо только определить
подходящее действие над элементом списка в виде описания класса-наследника
класса Actor:
class NegativeCounter : public Actor {
int counter;
public :
NegativeCounter() : counter(0) {}
int getCounter() { return counter; }
void action(int & element) { counter += (element < 0); }
};
int negatives(IntList & 1) {
NegativeCounter counter;
1.traverse(counter);
return counter.getCounter();
}
Правда, функция подсчета количества отрицательных элементов списка вряд
ли будет включена в список методов класса intList.
Несмотря на большую общность и несомненное изящество приведенного
решения задачи просмотра элементов, оно обладает существенными
недостатками. Во-первых, сразу бросается в глаза неудобство в определении новых
операций. Для того чтобы описать новую операцию по просмотру элементов
списка, надо определять новый класс, а для этого придется описать, по
крайней мере, конструктор класса, члены класса и т. п. Очень длинно и неудобно.
Во-вторых, не любую операцию, требующую просмотра элементов списка,
можно описать, определив лишь операцию обработки одного конкретного
элемента. Это можно сделать лишь в том случае, когда обработка отдельных
элементов не зависит (или почти не зависит) от значений других элементов.
Некоторые трудности возникнут даже при определении такой простой
операции, как проверка того, возрастают ли значения элементов списка от начала
134
Глава 2
списка к его концу, т. е. упорядочен ли список. Трудность состоит в том, что
значение каждого элемента списка надо сравнивать со значением
предыдущего элемента, а значит, при обработке потребуется запоминать его значение
для сравнения со значением следующего элемента.
Наконец, с помощью приведенного метода невозможно обрабатывать
несколько списков одновременно, просматривая их элементы поочередно.
Например, невозможно написать функцию, которая сравнивала бы поэлементно
два списка. Действительно, метод traverse просматривает все элементы
одного списка, и пока просмотр полностью не завершится, нет никакой
возможности вмешаться в работу этого метода: приостановить его работу,
завершить до того, как будут просмотрены все элементы и т. п.
Просмотр элементов списка или другой структуры, состоящей из однотипных
элементов, часто называют итерацией этой структуры, а механизм такого
просмотра называют, соответственно, итератором. В описанном выше
способе просмотра элементов списка итератором является метод traverse класса
intList. Именно он обеспечивает переход от элемента к элементу при
итерации списка. Итератор, подобный только что описанному методу traverse,
часто называют внутренним итератором, поскольку этот метод описан
внутри класса intList, т. е. сам объект класса intList отвечает за просмотр
своих элементов.
Конечно, один и тот же класс может предлагать несколько различных внут^-
ренних итераторов. Например, для просмотра элементов списка в обратном
порядке может быть предложен другой итератор, скажем, traverseBack. Для
представленной реализации класса intList в виде однонаправленного списка
такой итератор не будет слишком естественным, однако все же представим
такой итератор в виде пары функций. Описание этих функций дано в
листинге 2.25. В этом решении просмотр в обратном порядке обеспечивается
рекурсивной функцией traverseBackRec, которая для каждого элемента списка
сначала с помощью рекурсивного вызова производит просмотр остатка списка,
расположенного за этим элементом, а уже после этого обрабатывает сам этот
элемент.
Представленная рекурсивная функция выглядит очень просто, однако при
прохождении по списку ей потребуется количество памяти,
пропорциональное размеру самого списка!
j Листинг 2,25. Итератор для просмотра элементов списка в обратном порядке
class IntList {
public :
Базовые алгоритмы
135
/I Функция, представляющая внутренний итератор списка
// для просмотра элементов списка в обратном порядке.
void traverseBack(Actor & а) { traverseBackRec(a, first); }
private :
// Вспомогательная рекурсивная функция, обеспечивающая
// обратную итерацию элементов списка, начиная с элемента list.
void traverseBackRec(Actor & a, Listltem * list);
};
void IntList::traverseBackRec(Actor & a, Listltem * list) {
if (list) {
traverseBackRec(a, list->next); // Итерация последующих элементов
a.action(list->item) ; //-Обработка элемента list
}
}
Внутренние итераторы удобны тем, что полностью берут на себя управление
процессом обхода сложной структуры. При использовании такого итератора
нужно позаботиться лишь об алгоритме обработки содержимого одного
элемента этой структуры. Однако, как уже было замечено выше, именно это
достоинство внутренних итераторов является и его самым большим
недостатком. Действительно, невозможность управления извне процессом итерации
приводит к тому, что итерацию нельзя досрочно прекратить или запустить
одновременно два итератора для двух разных списков, управляя их работой
так, чтобы, например, сравнивать между собой элементы этих списков.
Внешнее управление работой итератора подразумевает организацию
просмотра элементов с помощью внешних управляющих конструкций,
например, так, как схематично показано в следующем цикле.
for (/* начать итерацию */;
/* пока есть еще элементы */;
/* перейти к следующему элементу */) {
int item = /* взять очередной элемент */;
/* обработать этот элемент */
}
Если считать, что все действия, представленные в этом фрагменте
комментариями, реализованы в виде методов класса IntList, то, например, цикл,
суммирующий элементы списка 1, будет выглядеть так:
int sum = 0;
for (1.startlterator(); 1.hasMoreElements(); 1.nextElement()) {
int item = 1.getCurrent();
sum += item;
}
136
Глава 2
Конечно, реализовать такие функции для класса intList нетрудно. Для этого
надо лишь запоминать в объектах этого класса текущую позицию при
итерации списка. Вот как, например, может рыглядеть такая реализация:
class IntList {
private :
Listltem * curPos; // Текущая позиция итерации
public :
void startlterator() { curPos = first; }
bool hasMoreElements () const { return curPos != NULL; }
void nextElement() { if (curPos) curPos = curPos->next; }
int getCurrentO const { return curPos->item; }
int & getCurrent() { return curPos->item; }
};
На первый взгляд кажется, что приведенная реализация, несмотря на свою
простоту, очень удобна для использования и позволяет реализовать не только
такие функции, как суммирование или поиск элемента в списке, но и те
задачи, которые не могли быть решены с помощью внутреннего итератора.
Действительно, вот как теперь будет выглядеть функция поиска элемента с
заданным значением:
bool hasElement(IntList & 1, int value) {
for (1.startlterator(); 1.hasMoreElements(); 1.nextElement()) {
if (value == 1.getCurrent()) return true;
}
return false;
}
Так же легко можно реализовать и функцию поэлементного сравнения
списков:
bool equalLists(IntList & 11, IntList & 12) {
for (11.startIterator(), 12.startlterator();
11.hasMoreElements() && 12.hasMoreElements();
11.nextElement(), 12.nextElement()) {
if (11.getCurrent() != 12.getCurrent()) return false;
}
return !(11.hasMoreElements() || 12.hasMoreElements());
}
Больше не требуется описывать специальных классов для представления
действия по обработке одного элемента списка. Вообще, работа со списком так,
как это показано в вышеприведенных примерах, выглядит простой и привыч-
Базовые алгоритмы
137
ной. Однако, к сожалению, у приведенного решения имеются очень
серьезные недостатки, так что использование описанного метода для итерации
списков практически никогда не используется.
Один из этих недостатков — это требование точной согласованности вызовов
всех методов, представляющих итератор списка. Действительно, для того
чтобы функции имели хоть какой-то смысл, необходимо, чтобы прежде всего
был бы вызван метод startiterator. Без такого вызова значение переменной
curPos останется неопределенным, а значит, работа остальных функций также
будет не определена. Далее, обязательно нужно проверять наличие
следующих элементов в списке с помощью метода hasMoreEiements, в противном
случае обращение к текущему элементу с помощью метода getcurrent
вызовет возбуждение исключительной ситуации.
Второй и главный недостаток состоит в том, что в каждый момент времени
над списком должен быть запущен лишь один итератор, поскольку ииформа-
ция о текущем положении указателя итерации хранится непосредственно в
самом объекте. Пусть, например, требуется определить, каково максимальное
число повторений одного и того же значения имеется среди элементов
списка. Простейшее решение состоит в том, чтобы брать по очереди каждый из
элементов списка и проверять, сколько таких элементов есть в этом списке.
Попробуем написать функцию, которая подсчитывает число элементов
списка, равных заданному значению.
int countElements(IntList & Is, int value) {
int count = 0;
for (Is.startiterator(); Is.hasMoreEiements(); Is.nextElement()) {
if (value = ls.getCurrent()) count++;
}
return count;
}
Теперь, казалось бы, очень просто написать и требуемую функцию:
int maxEqualElements(IntList & Is) {
int max = 0;
for (Is.startiterator(); Is.hasMoreEiements(); ls.nextElement()) {
int current = countElements(Is, Is.getCurrent());
if (current > max) max = current;
}
return current;
}
Однако приведенная функция работать не будет! Дело в том, что в процессе
ее работы во время итерации списка внутри цикла производится повторная
138
Глава 2
итерация того же самого списка, которая, разумеется, нарушит работу
внешнего цикла итерации.
Решение этой проблемы состоит в том, чтобы вынести указатель текущего
положения итерации из списка в отдельный объект, который и будет
полностью управлять итерацией этого списка. Тогда при необходимости начать
повторную итерацию списка, не закончив предыдущую, нужно будет лишь
создать новый объект для управления итерацией, называемый внешним
итератором.
Естественно полагать, что инициализация итератора происходит в
конструкторе, и тогда, если считать, что классом объекта итератора является
intListiterator, решение предыдущей задачи о подсчете максимального
количества повторений элементов списка будет выглядеть следующим
образом:
int countElements(IntList & Is, int value) {
int count = 0;
for (IntListiterator i(ls); i.hasMoreElements(); i.nextElement()) {
if (value == i.getCurrent()) count++;
}
return count;
}
int maxEqualElements(IntList & Is) {
int max = 0;
for (IntListiterator i(ls); i.hasMoreElements(); i.nextElement()) {
int current = countElements(Is, i.getCurrent());
if (current > max) max = current;
}
return max;
}
Конечно же, определение класса intListiterator теснейшим образом связано
с классом IntList. Возможно, самым правильным решением будет поместить
его определение внутрь определения класса IntList. Реализация методов
этого класса останется практически такой же, как и в нашем предыдущем
"наивном" определении внешнего итератора. Несмотря на то, что наше новое
решение выглядит чуть более громоздким, оно имеет дополнительное
преимущество: обойти инициализацию итератора теперь невозможно даже по
ошибке, поскольку объект, представляющий итератор, не может быть создан
помимо конструктора. В следующем примере (листинг 2.26) внешний
итератор описан в виде класса, определение которого погружено в класс IntList.
Обратите внимание также на описание дополнительного метода iterator
Базовые алгоритмы
139
класса IntList. Этот метод содержит вызов конструктора нашего внешнего
итератора, так что создавать новые итераторы заданного списка list можно
будет двумя способами: либо с помощью непосредственного обращения
к конструктору класса iterator:
IntList: : Iterator * mylterator = new IntList: -.Iterator (list) ;
либо С ПОМОЩЬЮ метода iterator:
IntList::Iterator * mylterator = list.iterator();
Пожалуй, второй способ выглядит более элегантно.
vrrv ""■"•-: •": ; """"* ! : ..,...,,,г..,„„,,.:т.„^..,...:_,„,,„,„,т„..у.^, ,, тг ,, .„„^„„^„^
| Листинг 2.26: Внешний Итератор для 1фасоа inti^st ^ f; AM
class IntList {
public :
// Класс, представляющий внешний итератор списка
class Iterator {
Listltem * curPos; // Текущая позиция в списке
public :
// Конструктор создает новый итератор для списка list.
Iterator(IntList & list) { curPos = list.first; }
// Метод hasMoreElements проверяет, есть ли еще элементы для итерации
bool hasMoreElements() const { return curPos != NULL; }
// Метод nextElement сдвигает текущую позицию на следующий элемент.
void nextElement() { if (curPos) curPos = curPos->next; }
// Функция getCurrent обеспечивает доступ к текущему элементу.
int getCurrent() const { return curPos->item; }
// Этот вариант метода позволяет изменить значение текущего элемента.
int & getCurrent() { return curPos->item; }
};
friend class Iterator;
// Этот метод создает новый внешний итератор для итерации списка this.
Iterator * iterator() { return new Iterator(*this); }
};
Простые функции останутся такими же простыми и для этого решения.
Приведем в качестве примера функцию, вычисляющую среднее арифметическое
значений элементов списка.
140
Глава 2
double average(IntList & list) {
IntList::Iterator *i = list.iterator();
double sum = 0, count = 0;
for (; i->hasMoreElements(); i->nextElement()) {
sum += i->getCurrent();
count++;
}
delete i;
return sum / count;
}
Обратите внимание, как созданный итератор уничтожается оператором
delete в конце работы. Это необходимо сделать, поскольку создавался он с
ПОМОЩЬЮ Оператора new При ВЫЗОВе метода list. iterator ().
Конечно, как и в случае внутренних итераторов, в одном и том же классе
может быть определено несколько внешних итераторов. Например, для
просмотра списка элементов в обратном порядке может быть определен другой
внешний итератор. Правда, такой внешний итератор для однонаправленного
списка определить гораздо сложнее, чем внутренний итератор, поскольку
решение с помощью рекурсивной функции в данном случае не подходит.
Пожалуй, самым простым решением здесь было бы создание нового
обращенного списка из указателей на элементы исходного списка.
До сих пор мы использовали итераторы только для того, чтобы исследовать
элементы списка, но не для того, чтобы изменять структуру списка.
Действительно, основное назначение итераторов состоит именно в том, чтобы
просматривать элементы, а не для того, чтобы их менять. Тем не менее иногда
все же удобно рассматривать внешний итератор не только как средство
просмотра, но и как обобщение понятия указателя на элемент списка, с помощью
которого можно добавлять новые элементы или удалять имеющиеся.
Действительно, класс iterator в определении класса IntList инкапсулирует
указатель на некоторый элемент списка, поэтому имеет смысл определить для него
не только операции перемещения указателя и доступа к элементам списка, но
и операции изменения структуры списка. Наиболее распространенной
является реализация операции удаления из списка текущего элемента, но можно
определить также и операции вставки в список новых элементов в текущей
позиции.
Рассмотрим еще одну реализацию внешнего итератора списка, в которой
определим эти дополнительные операции. Метод remove () будет удалять
текущий элемент списка, при этом позиция указателя итерации будет сдвигаться
на Следующий за удаляемым Элемент. Два Метода insertBefore(int item) И
insert After (int item) будут добавлять новый элемент в список перед и, со-
Базовые алгоритмы
141
ответственно, после текущего элемента. В обоих случаях текущая позиция
будет устанавливаться на вновь созданный элемент.
Для изменения структуры списка недостаточно иметь в итераторе указатель
только на текущий элемент списка, поскольку при вставках и удалениях
элементов необходимо изменять также поле next предыдущего элемента списка.
Поэтому в новом представлении итератора будут храниться два указателя —
указатель на текущий элемент и ссылка на переменную, в которой хранится
указатель на этот элемент. Эта ссылка может указывать либо на поле next
предыдущего элемента списка, либо на поле first самого списка, если
текущим элементом является первый элемент. Заметим, что в тот момент, когда
итератор добирается до конца списка (метод hasMoreEiements в этот момент
выдает значение false), есть возможность вставить элемент "перед текущим"
с помощью метода insertBefore. При этом новый элемент становится
последним элементом в списке, а текущим элементом становится новый, только
что вставленный элемент.
В случае, когда какую-либо из операций выполнить невозможно (например,
удалить текущий элемент в ситуации, когда итератор дошел до конца
списка), будем возбуждать исключительную ситуацию NoListEiement. В
листинге 2.27 представлено модифицированное определение внешнего итератора
списка целых intList:: iterator. Описание класса для представления
исключительной ситуации NoListEiement в тексте книги опущено.
I Листинг 2.27, Определение внешнего итератора с возможностью
! модификации списка
class IntList {
public :
// Класс, представляющий внешний итератор списка
class Iterator {
Listltem * curPos; // Текущая позиция в списке
ListItem ** pred; // Ссылка на место, где хранится
// указатель на этот элемент
public :
// Конструктор создает новый итератор для списка list.
Iterator(IntList & list) { curPos = *(pred = &list.first); }
// Метод hasMoreEiements проверяет, есть ли еще элементы для итерации
bool hasMoreEiements() const { return curPos != NULL; }
// Метод nextElement сдвигает текущую позицию на следующий элемент.
void nextElement() { if (curPos) curPos = *(pred = &curPos->next); }
142
Глава 2
// Функция getCurrent обеспечивает доступ к текущему элементу.
int getCurrent() const { return curPos->item; }
// Этот вариант метода позволяет изменить значение текущего элемента.
int & getCurrent() { return curPos->item; }
// Метод для удаления текущего элемента;
// текущим становится следующий элемент
void remove () {
if (IcurPos)
throw NoListElement("remove: отсутствует текущий элемент");
*pred = curPos->next;
delete curPos;
curPos = *pred;
}
// Следующие два метода вставляют новый элемент
// перед текущим и после текущего
void insertBefore(int newValue) {
Listltem * newltem = new Listltem(newValue, curPos);
curPos = *pred = newltem;
}
void insertAfter(int newValue) {
if (IcurPos)
throw NoListElement("insertAfter: отсутствует текущий элемент");
Listltem * newltem = new Listltem(newValue, curPos->next);
curPos = *(pred = &newltem->next);
}
};
friend class Iterator;
// Этот метод создает новый внешний итератор для итерации списка this.
Iterator * iterator() { return new Iterator(*this); }
};
Пользуясь внешними итераторами для изменения структуры списка, надо
соблюдать некоторую осторожность и помнить о том, что, например,
текущий элемент итерации может исчезнуть после выполнения метода remove о,
лучше лишний раз проверить его наличие с помощью метода hasMoreElements ().
Как видно из наших примеров, внешние итераторы могут быть очень
разными, обладая различными свойствами и поведением. Тем не менее у всех
итераторов МОЖНО выделить ПО крайней мере три метода— hasMoreElements,
nextElement и getCurrent, которые характерны для любых внешних итерато-
Базовые алгоритмы
143
ров. С некоторой натяжкой к ним можно еще добавить и метод remove. Это
означает, что можно ввести понятие абстрактного типа данных — внешний
итератор как некий обобщенный указатель на элементы структуры, с
помощью которого можно перемещаться от предыдущих элементов структуры
к следующим, получать доступ к содержимому этих элементов (точнее,
одного текущего элемента) и, может быть, удалять текущий элемент. Операции
вставки элементов уже не являются такими общими, поскольку в общем
случае не очень ясно, куда должны попадать вставляемые элементы. Можно
описать абстрактный класс iterator и считать, что все реализации внешних
итераторов должны быть его наследниками. Это позволит некоторым
программам не зависеть от конкретных реализаций итераторов, осуществляя
перебор элементов на абстрактном уровне.
Некоторые библиотеки классов C++ поддерживают понятие итератора на
абстрактном уровне, определяя соответствующий абстрактный класс. В
качестве примера можно привести и стандартную библиотеку классов и шаблонов
STL, однако в ней понятие итератора довольно существенным образом
отличается от используемого нами. В этой библиотеке итераторы гораздо ближе
к обычным указателям, которые можно передвигать по сложной структуре
данных приблизительно так же, как можно сдвигать указатели по массиву в
стандартном языке С. Для реализации таких итераторов в самом общем виде
используется сложный механизм параметризации итераторов способами
отведения памяти под элементы структуры.
Описание абстрактного итератора в нашем, более простом случае может
выглядеть следующим образом:
template <class Element>
class Iterator {
public :
virtual -Iterator() {}
virtual bool hasMoreElements() const = 0;
virtual void nextElement() = 0;
virtual const Element & getCurrentO const = 0;
virtual Element & getCurrentO = 0;
virtual void remove() = 0;
};
Тогда реализация этого абстрактного итератора в классе intList будет
выглядеть так, как это представлено в листинге 2.28.
| Листинг 2.28. Реализация абстрактного внешнего итератора для списка целых j
class IntList {
144
Глава 2
public :
// Класс, представляющий внешний итератор списка
class Mylterator : public Iterator<int> {
ListItem * curPos; // Текущая позиция в списке
Listltem ** pred; // Ссылка на место, где хранится
// указатель на этот элемент
public :
Mylterator(IntList & list) { curPos = *(pred = &list.first); }
bool hasMoreElements() const { return curPos != NULL; }
void nextElement () { if (curPos) curPos = *(pred = &curPos->next); }
const int & getCurrentO const { return curPos->item; }
int & getCurrent() { return curPos->item; }
void remove () {
if (!curPos)
throw NoListElement("remove: no current element");
*pred = &curPos->next;
delete curPos;
curPos = *pred;
}
void insertBefore(int newValue) {
Listltem * newltem = new Listltem(newValue, curPos);
curPos = *pred = newltem;
}
void insertAfter(int newValue) {
if (!curPos)
throw NoListElement("insertAfter: no current element");
Listltem * newltem = new Listltem(newValue, curPos->next);
curPos = *(pred = &newltem->next);
}
};
friend class Mylterator;
Mylterator * iterator() { return new Mylterator(*this); }
};
В этой реализации, помимо обязательных интерфейсных методов,
присутствуют И МеТОДЫ ВСТаВКИ НОВЫХ Элементов В СПИСОК— insertBefore И
insertAfter. Поскольку метод iterator класса IntList возвращает указатель
на реализацию описанного выше интерфейса, то программы могут
пользоваться этой реализацией в точности так же, как и раньше. Однако если
никаких вставок новых элементов не предполагается, то программа может повы-
Базовые алгоритмы
145
сить уровень абстракции при работе с абстрактным итератором вместо
конкретного итератора списка целых. Например, функция суммирования
элементов, которая раньше предназначалась только для суммирования значений
элементов списка, теперь сможет суммировать значения целых элементов
любой итерируемой структуры, если будет получать в качестве аргумента не
список целых, а итератор целых:
int suinmator (Iterator<int> &iter) {
int' sum = 0;
while (iter.hasMoreElements()) {
sum += iter.getCurrent();
iter.nextElement();
}
return sum;
}
Такая функция может, например, суммировать элементы некоторого
целочисленного массива, если только класс, описывающий такой массив, будет
поддерживать итерацию его элементов в виде внешнего итератора,
совместимого с абстрактным классом iterator.
Различные рассмотренные выше методы реализации внешних и внутренних
итераторов представлены на приложенном компакт-диске в папках
"\Chapter2\2.4\IntIterator" И "\Chapter2\2.4\ExtIterator".
2.5. Прохождение деревьев
Прохождение (итерация, обход) деревьев — это алгоритм, с помощью
которого, как и в случае списков, осуществляется перебор узлов дерева для их
последовательной обработки. Обходы деревьев применяются в самых разных
задачах для исследования содержимого и структуры деревьев. По сравнению
с итерацией списков итерация деревьев имеет несколько существенных
особенностей.
В случае списков обычно обход выполняется в естественном порядке от
начала списка к концу, в крайнем случае, можно рассмотреть еще обход
элементов списка в противоположном направлении — от конца к началу. В
случае дерева существует много порядков обхода его узлов, большинство из
которых имеет свое самостоятельное значение и применяется в различных
алгоритмах. В этом разделе мы рассмотрим некоторые из наиболее важных
порядков обхода узлов.
Как и в случае списков, итерация деревьев производится с помощью
внутренних или внешних итераторов, однако в случае деревьев итераторы прак-
146
Глава 2
тически никогда не используются для изменения структуры дерева —
добавления или удаления его узлов. Дело в том, что при добавлении или удалении
узлов обычно происходит столь существенное изменение структуры дерева,
что продолжение итерации становится практически бессмысленным.
Поэтому такие изменения производятся с помощью отдельных операций, часть из
которых приводилась в разд. 1.3.
Мы будем рассматривать алгоритмы обхода деревьев на примере наиболее
часто встречающегося класса деревьев — бинарных деревьев. Будем считать,
что бинарные деревья задаются с помощью шаблона классов, приведенного
в разд. 1.3 в листинге 1.4.
Как и в случае списков, наиболее просто программируются обходы с
помощью внутренних итераторов, но иногда оказывается более удобно
организовать обход узлов дерева с помощью внешнего итератора, т. е. определить
специальный класс (или классы), реализующий подходящий интерфейс
обобщенного указателя в структуру дерева. Классы, задающие как внешние,
так и внутренние итераторы, могут быть описаны непосредственно внутри
класса, определяющего дерево. Конечно, необходимо описать методы класса,
которые запускают внутренний итератор или создают необходимый внешний
итератор для использования его извне класса.
Пусть, например, дерево определено с помощью шаблона классов Tree. Пусть
для этого дерева предлагаются два способа его обхода. Для каждого из этих
способов можно определить как внутренний, так и внешний итераторы.
Будем считать, что внешний итератор для дерева определяется с помощью
интерфейса Treeiterator, а действие, выполняемое внутренним итератором над
узлами дерева, задается с помощью класса Actor. Тогда определим два метода
extTraverseio и extTraverse2 (), которые будут генерировать и выдавать
внешние итераторы для наших двух гипотетических способов обхода дерева,
И два метода intTraversel () И intTraverse2 (), КОТОрые будут Задавать ЭЛГО-
ритмы работы соответствующих внутренних итераторов.
template <class T>
class Tree {
public :
// Методы, выдающие указатели на внешние итераторы узлов дерева
TreeIterator<T> * extTraversel();
TreeIterator<T> * extTraverse2();
// Методы, осуществляющие внутреннюю итерацию узлов дерева
void intTraversel(Actor<T> actor);
void intTraverse2(Actor<T> actor);
Базовые алгоритмы
147
// Классы для определения внешних итераторов могут быть при этом
// определены непосредственно внутри шаблона классов Tree
private :
template <class C> class Traversel : public TreeIterator<C> { ... }
template <class C> class Traverse2 : public TreeIterator<C> { ... }
}
Тогда в программе можно будет осуществлять итерацию дерева разными
способами (с помощью внутреннего или внешнего итератора) и в разной
последовательности обхода в зависимости от потребностей программы.
Например, для дерева тутгее, в узлах которого находятся целые числа, это будет
выглядеть следующим образом:
// Внешний итератор; первый порядок обхода
TreeIterator<int> * mylterator = myTree.extTraversel();
for (; myIterator->hasMoreElements();
myIterator->nextElement()) {
int & nextElement = myIterator->getCurrent();
<обработка очередного узла>
}
delete mylterator;
ИЛИ
// Сначала определяем необходимое действие над узлами:
class MyActor : public Actor<int> {
public :
void action(int & node) { ... }
};
// теперь вызываем внутренний итератор для второго порядка обхода
myTree.intTraverse2(MyActor());
Внутренние итераторы часто строятся в виде рекурсивных процедур.
Действительно, сравнительно несложно написать рекурсивную процедуру, которая
будет для каждого узла дерева выполнять посещение этого узла, заданное
аргументом процедуры. Для внешнего итератора такой способ написания
обхода не подходит: внешний итератор должен быть готов к тому, что его
действие прерывается на время обработки узла, после чего итератор должен
выдать следующий по порядку обхода узел.
Давайте рассмотрим двоичное дерево, содержащее в узлах символы,
например, как на рис. 2.7.
Говорят, что дерево проходится сверху вниз, если каждый узел проходится
раньше, чем поддеревья этого узла. Для изображенного дерева следующие
порядки прохождения узлов будут порядками обхода сверху вниз:
148
Глава 2
ABCDEFGHI
ABDHGCEFI
ACBFIEDGH
В этом случае также говорят, что дерево обходится в нисходящем порядке.
Аналогично, при обходе снизу вверх (восходящий порядок обхода) узел
проходится после того, как пройдены его поддеревья, например:
IHGFEDCBA
IFECGHDBA
Очевидно, что для каждого обхода сверху вниз обход тех же узлов в
противоположном порядке будет обходом снизу вверх.
Рис. 2.7. Пример бинарного дерева
Порядок обхода бинарного дерева называется инфиксным, если каждый узел
проходится после того, как пройдены все узлы одного поддерева, и до того,
как пройдены узлы другого поддерева. Обычно используют левосторонний
инфиксный обход, при котором сначала обходится левое поддерево, затем
корневой узел, потом правое поддерево, либо правосторонний инфиксный
обход, при котором, наоборот, сначала обходится правое поддерево, затем
корень и наконец левое поддерево. Вот как, например, располагаются узлы
нашего дерева в порядке левостороннего инфиксного обхода:
GDHBAECIF
Для нисходящих и восходящих обходов обычно определяются некоторые
стандартные порядки обхода. Например, при нисходящем левостороннем
обходе порядок прохождения узлов определен следующим образом:
непосредственно после обхода каждого узла проходятся узлы его поддеревьев в
порядке слева направо (для бинарного дерева — сначала левого поддерева, за-
Базовые алгоритмы
149
тем правого). Для дерева, приведенного на рис. 2.7, узлы в порядке
нисходящего левостороннего обхода располагаются следующим образом:
ABDGHCEFI
Существуют еще два термина, часто использующихся для определения
порядка обхода узлов: обход в глубину (depth first) и обход в ширину (breadth
first).
Говорят, что обход является обходом в ширину, если узлы, расположенные
ближе к корню дерева, обходятся раньше, чем узлы, отстоящие дальше от
корня. При этом расстояние от узла до корня измеряется количеством ребер
на кратчайшем пути из этого узла до корня. Так, например, в дереве на
рис. 2.7 узлы Д Ей F находятся на одном и том же расстоянии от корня — 2,
а узел В находится ближе к корню, т. к. расстояние от него до корня равно 1.
Обычно полагают, что любой узел находится на расстоянии 0 от себя, так что
при обходе в ширину корень дерева всегда посещается первым.
Левосторонний обход в ширину для дерева на рис. 2.7 расположит его узлы в следующем
порядке:
ABCDEFGHI
Говорят, что обход является обходом в глубину, если в любом поддереве
исходного дерева узлы обходятся подряд, т. е. если обход некоторого поддерева
начат, то он продолжается до тех пор, пока все поддерево не будет обойдено.
Например, нисходящий обход в глубину может быть представлен следующей
последовательностью узлов того же дерева:
ABDGHCEFI
Если в этой последовательности узлов расставить скобки для обозначения
поддеревьев, то можно увидеть, что действительно каждое поддерево в ней
занимает свой участок, обрамленный скобками и не пересекающийся с
занятыми другими поддеревьями участками:
(А (В (D (G) (Н))) (С (Е (F (I)))))
Обходы в глубину удобно реализовывать в виде рекурсивных процедур,
поскольку такая процедура, получив в качестве аргумента некоторое поддерево
для обхода, будет обходить его до тех пор, пока все узлы этого поддерева не
будут пройдены. Реализовать обход в ширину с помощью рекурсивной
процедуры практически невозможно или, во всяком случае, такая реализация
обхода будет неестественной и неэффективной.
В данной книге обходы деревьев неоднократно рассматриваются для
решения самых разных задач. В этом разделе мы, прежде всего, рассмотрим
четыре способа обхода деревьев. Соответственно, будут построены четыре
итератора — два внутренних и два внешних. В некоторых из этих алгоритмов для
150
Глава 2
хранения информации в процессе обхода будут использоваться уже
рассмотренные нами ранее структуры данных — стеки и очереди. Итак, четыре
рассматриваемых алгоритма следующие:
□ внутренний итератор для левостороннего инфиксного обхода,
построенный с помощью рекурсивной процедуры;
□ внутренний итератор для нисходящего обхода в глубину, использующий
стек для хранения информации об еще не пройденных поддеревьях;
□ внешний итератор для нисходящего обхода в ширину, использующий
очередь для хранения вершин в порядке обхода;
□ внешний итератор для восходящего обхода, использующий стек для
хранения информации о пути из корня к вершине, рассматриваемой в
текущий момент.
После этого мы приведем еще два способа обхода для специальных деревьев
с хранением всей информации внутри самой структуры дерева и его узлов.
В следующих главах рассматриваются и применяются еще несколько
способов обхода дерева для использования их в специальных алгоритмах
обработки информации.
Итак, начнем с описания внутреннего итератора для левостороннего
инфиксного обхода с помощью рекурсивной процедуры.
Сама рекурсивная процедура обхода очень проста и строится
непосредственно по определению этого способа обхода узлов. Дня каждого узла, начиная с
корня, сначала обходится его левое поддерево (рекурсивный вызов
процедуры обхода), потом — сам этот узел и наконец — правое поддерево (еще один
рекурсивный вызов той же процедуры). Считаем, что интерфейс для
определения действия, производимого над каждым узлом дерева в процессе
итерации, задан:
template <class T>
class Actor {
public :
virtual void action(T & node) = 0;
};
Тогда левосторонний обход дерева может быть представлен следующим
методом, определенным в классе Tree (структура дерева была описана ранее
в разд. 1.3):
template <class T>
class Tree {
// Определение класса для узла дерева
struct Node {
Базовые алгоритмы t 151
Т item; // содержимое узла
Node *left; // указатель на левое поддерево
Node *right; // указатель на правое поддерево
// Конструктор узлов дерева:
Node(const T & item, Node *left = NULL, Node *right = NULL) {
Node: : item = item;
Node::left = left;
Node::right = right;
}
};
Node *root; // Корень дерева
public :
// Обход осуществляется с помощью вспомогательной рекурсивной
// процедуры, описанной ниже в виде метода этого же класса.
void traverselnfixLeft(Actor<T> & a) {
recTraverselnfixLeft(root, a);
}
private :
void recTraverselnfixLeft(Node *pNode, Actor<T> & a);
};
template <class T>
void Tree<T>:-.recTraverselnfixLeft (Node *pNode, Actor<T> & a) {
if (pNode) {
recTraverselnfixLeft(pNode->left, a); // Обход левого поддерева
a.action(pNode->item); // Обход узла
recTraverselnfixLeft(pNode->right, a); // Обход правого поддерева
}
}
Алгоритм очень прост и его реализация не требует никаких комментариев.
Заметим лишь, что если дерево велико, то на организацию рекурсивных
вызовов может потребоваться большое количество памяти стека вызовов
процедур. При описании остальных алгоритмов для хранения промежуточной
информации в процессе обхода мы используем явно описанные структуры
данных, представленные ниже абстрактным стеком и абстрактной очередью.
Второй из рассматриваемых нами алгоритмов — это обход сверху вниз,
реализованный в виде внутреннего итератора с хранением промежуточной
информации в стеке. Это практически тот же самый алгоритм, который был
реализован нами для левостороннего обхода с помощью рекурсивной
функции, но в нем теперь вместо рекурсии используется стек указателей на узлы
дерева. В этом стеке хранится та же информация, которая при рекурсии хра-
152
Глава 2
нится в стеке вызовов функций. За счет явного использования стека удается
несколько сэкономить память, поскольку в данном случае в нем хранится
только необходимая информация.
Алгоритм можно описать следующим образом. Для того чтобы обойти
некоторое поддерево, сначала пройдем корень этого поддерева, затем спустимся в
его левое поддерево, а правое поддерево запомним в стеке. Если левое
поддерево пусто, то можно сразу спускаться в правое поддерево вместо того,
чтобы запоминать его в стеке. Если оба поддерева пусты, то очередное
поддерево надо извлечь из стека. В следующей таблице (табл. 2.1) представлены
последовательные стадии обхода дерева, изображенного на рис. 2.7, при этом
на каждом шаге показаны текущая вершина и вершины поддеревьев,
хранящихся в стеке. Дно стека обозначено символом "[", а вершина—
символом ")"■
Таблица 2.1. Последовательность шагов при нисходящем обходе в глубину
Номер
шага
1
2
3
4
5
6
7
8
9
10
Текущая вершина
А
В
D
G
Н
С
Е
F
1
null
Содержимое стека
D
[Q
[С)
[С,Н)
[С)
D
IF)
1)
[)
[)
Пройденные вершины
А
А В
А, В, D
А, Б, D, G
А, Б, D, G, Н
Д Б, D, G,H,C
А, В, D, G, Н,С,Е
А, В, D, G, И, C,E,F
А, В, D, G, Н, С, E,F,l
В листинге 2.29 приведено описание внутреннего итератора в виде метода
уже использовавшегося нами шаблона классов Tree. При этом считается, что
также задано описание интерфейса Actor для организации внутренней
итерации и сделаны все необходимые описания классов для работы со стеком
(см. разд. 2.3).
\ Листинг 2.29. Нисходящий обход дерева с хранением узлов в стеке
template <class T>
class Tree {
Базовые алгоритмы
153
public :
* Внутренний итератор для нисходящего
* обхода с хранением узлов в стеке
••••••••••••••••••••••••••••••••••••••••••••••••••••••••/
void traverseUpDown(Actor<T> & actor);
};
template <class T>
void Tree<T>::traverseUpDown(Actor<T> & actor) {
// Стек для хранения узлов; для определенности считаем, что
//в качестве реализации стека выбирается ListStack - стек,
// реализованный в виде списка элементов.
ListStack<Node*> stack;
// Текущая вершина:
Node *current = root;
// Основной цикл
for (;;) {
// Обходим текущую вершину
actor.action(current->item);
// Переходим к следующему узлу
if (current->right != NULL && current->left != NULL) {
stack.push(current->right);
current = current->left;
} else if (current->left != NULL) {
current = current->left;
} else if (current->right != NULL) {
current = current->right;
} else try {
// или пытаемся извлечь очередную вершину из стека
current = stack.top();
stack.pop();
} catch (StackUnderflow & ex) {
// Стек пуст, заканчиваем работу цикла и функции
break;
}
}
}
Если t — дерево типа Tree<char>, содержащее в узлах символы, как показано
на рис. 2.7, то можно распечатать узлы дерева, если определить в качестве
действия, выполняемого в процессе итерации, следующую функцию:
154
Глава 2
class Printer : public Actor<char> {
public :
void action(char & c) { cout « c; }
};
и затем инициировать обход дерева с помощью обращения к методу
traverseUpDown:
t.traverseUpDown(new Printer());
Такой оператор вызовет печать следующей строки:
ABDGHCEFI
С помощью небольшой модификации итератора можно изменить порядок
обхода на левосторонний инфиксный обход. Для этого вместо прохождения
узла и помещения в стек его правого поддерева следует поместить в стек сам
узел, а проходить его надо в момент извлечения из стека. Модифицированная
функция показана в листинге 2.30.
{Листинг 2.30. Левосторонний инфиксный обход дерева с хранением узлов \
Гв стеке . ,';-,. -V '; .'..**■'. v — ' r-:--- ., ~<, -\X;,f ?t:;j
template <class T>
class *Ггее {
public :
/•••••••••••••••••••••••••••••••••••••••••••••^
* Внутренний итератор для левостороннего
* обхода с хранением узлов в стеке
•••••••••••••••••••••••••••it*************************************/
void traverselnfix(Actor<T> & actor);
};
template <class T>
void Tree<T>::traverselnfix(Actor<T> & actor) {
// Стек для хранения информации
ListStack<Node*> stack;
// Текущая вершина
Node *current = root;
// Основной цикл
bool stackEmpty = falser-
while (!stackEmpty) {
// Кладем в стек текущую вершину
stack.push(current);
// Переходим к левому поддереву, если оно есть
if (current->left != NULL) {
current = current->left;
}
Базовые алгоритмы
155
// или извлекаем очередную вершину из стека
else do {
try {
current = stack.top();
stack.pop () ;
// и обходим текущую вершину
actor.action(current->itein) ;
} catch (StackUnderflow & ex) {
stackEmpty = true;
}
} while (!stackEmpty && (current = current->right) == NULL);
}
}
В обоих приведенных алгоритмах обхода дерева с помощью стека
максимальную требующуюся глубину стека можно оценить высотой дерева, т. к.
в любой момент времени в нем содержатся узлы (или правые поддеревья этих
узлов), находящиеся на пути из корня в некоторую вершину.
Внутренние итераторы получаются достаточно простыми, но иногда
пользоваться ими бывает неудобно. Рассмотрим следующие алгоритмы просмотра
дерева на примерах внешних итераторов. Собственно, методика написания
внешних итераторов не сильно отличается от методики написания
внутренних итераторов. В обоих случаях организуется некоторая структура данных
для хранения пути от корня до текущей вершины (обычно стек или очередь).
Разница состоит только в том, что если во внутреннем итераторе все данные
создаются в виде локальных переменных функдии, то во внешнем итераторе
те же данные будут сосредоточены в описании класса, реализующего
итератор.
В качестве первого примера рассмотрим внешний итератор для нисходящего
обхода в ширину, использующий очередь для хранения вершин в порядке
обхода.
Очередь идеально подходит для организации обхода в ширину.
Действительно, по мере продвижения от корня дерева вниз в очередь будут попадать все
более удаленные от него вершины дерева. При этом, чем раньше вершина
попала в очередь, тем раньше она будет обработана — это основной принцип
хранения информации в структуре очереди. Рассмотрим дерево, приведенное
на рис. 2.7, и покажем (табл. 2.2), как последовательно проходятся его
вершины, если на каждом шаге посещается первая вершина из очереди, а в
очередь для последующей обработки кладутся корни поддеревьев
обрабатываемой вершины.
Из таблицы видно, что в результате узлы дерева действительно будут
обходиться в ширину в нисходящем порядке.
156
Глава 2
Таблица 2.2. Последовательность шагов при обходе в ширину
Номер
шага
1
2
3
4
5
6
7
8
9
10
Текущая вершина
А
В
С
D
Е
F
G
Н
1
null
Содержимое стека
(В, С]
(C,D]
(D, E, F]
{EKF,G,H\
(F, G, Н]
(G, Н, /]
(",/]
(/]
(]
(]
Пройденные вершины
А
А, В
А, В, С
А, Б, С, D
А, В, С, D, Е |
А, В, С, D, E, F
А, В, С, D, E,F,G
А, В, С, D, E, F, G, Н
А, Б, С, D, E, F, G,H,l
Как и раньше будем предполагать, что внешний итератор описывается в виде
некоторого класса, реализующего интерфейс iterator. Описание интерфейса
итератора будем полагать сделанным так же, как мы делали это ранее, только
не будем включать в этот интерфейс функцию удаления узлов remove.
Причина очевидна и уже упоминалась выше: удаление узла дерева должно вызвать
перестройку его структуры, которая может быть сделана самым разным
образом. Итератор не должен заниматься такими проблемами, поэтому удаление
узлов обычно реализуется совсем другими методами. Сделаем еще пару
косметических изменений: заменим в описании абстрактного итератора метод
getcurrent оператором взятия значения по указателю (operator *), а метод
nextEiement — оператором увеличения значения (operator ++). Содержание и
смысл методов остаются без изменения, но такая модификация позволит
записывать алгоритмы обработки деревьев с помощью внешних итераторов
чуть более сжато.
template <class Element>
class Iterator {
public :
virtual -Iterator() {}
virtual bool hasMoreElements() const = 0;
virtual Iterator<Element> & operator ++ () = 0;
virtual const Element & operator * () const = 0;
virtual Element & operator * () = 0;
};
Базовые алгоритмы
157
Итак, опишем внешний итератор дерева для обхода дерева сверху вниз в
ширину с помощью очереди. Описание этого класса вложим в шаблон
классов Tree и, как обычно, определим метод, позволяющий породить и
выдать в качестве результата экземпляр такого класса. Для реализации
очереди используется интерфейс Queue абстрактной очереди, причем она
порождается с помощью переменной типа абстрактной фабрики очередей
QueueFactory. Все упомянутые понятия и структуры для работы с очередями
были описаны в разд. 2.3.
Определение класса приведено в листинге 2.31.
| Листинг 2.31. Внешний итератор дерева для обхода в ширину
template <class T>
class Tree {
// ...
extern QueueFactory<T> *queueFactory;
protected :
* Внешний итератор для нисходящего обхода в ширину
* с хранением узлов в очереди
class BreadthFirst : public Iterator<T> {
Queue<Node*> *queue; // Очередь непройденных поддеревьев
public :
// Конструктор итератора инициирует очередь...
BreadthFirst(Tree<T> & t) ;
// ...а деструктор - освобождает память, занятую ею.
-BreadthFirst() { delete queue; }
// Проверка конца итерации - это проверка пустоты очереди.
bool hasMoreElements() const { return !queue->empty(); }
// Переход к следующему элементу состоит в том, что очередной
// узел извлекается из очереди, а его потомки ставятся в очередь.
Iterator<T> & operator ++ ();
// Доступ к очередному узлу - это доступ к узлу в голове очереди.
const T & operator *() const;
Т & operator *();
};
friend class BreadthFirst;
};
158
Глава 2
* Реализация операций внешнего итератора BreadthFirst
••*•*••*****•••••••••••••••••••*******^
// Конструктор итератора инициирует очередь.
template <class T>
Tree<T>::BreadthFirst::BreadthFirst(Tree<T> & t) {
queue = queueFactory->newQueue() ;
// Корень дерева записывается в очередь первым
if (t.root) {
queue->enqueue(t.root);
}
}
// Переход к следующему элементу состоит в том, что очередной
// указатель извлекается из очереди, а его потомки ставятся в очередь.
template <class T>
Iterator<T> & Tree<T>::BreadthFirst::operator ++ () {
if (!queue->empty()) {
Node * next = queue->head(); // Это головной элемент из очереди.
queue->dequeue(); //Он извлекается из очереди,
//а его потомки (если есть) становятся в очередь
if (next->left) queue->enqueue(next->left);
if (next->right) queue->enqueue(next->right);
}
return *this;
}
// Доступ к очередному узлу - это доступ к узлу, стоящему в голове
очереди.
template <class T>
const T & Tree<T>::BreadthFirst::operator *() const {
if (queue->empty()) {
throw NoElementExceptionO;
}
Node * & element = queue->head();
return element->item;
}
template <class T>
T & Tree<T>::BreadthFirst::operator *() {
if (queue->empty()) {
throw NoElementExceptionO;
}
Базовые алгоритмы
159
Node * & element = queue->head();
return element->item;
}
Функция, порождающая соответствующий итератор, теперь будет выглядеть
совсем просто:
BreadthFirst * iteratorBF() { return new BreadthFirst(*this); }
До сих пор мы все время говорили о бинарных деревьях. Это до некоторой
степени оправдано, поскольку в деревьях общего вида узлы могут содержать
два указателя точно так же, как и в бинарных. Соответственно, все те
способы обхода, которые применимы к бинарным деревьям, могут быть
использованы и к деревьям общего вида. Тем не менее смысл, вкладываемый нами в
эти обходы, может меняться, т. к. логическая структура дерева может не
совпадать с его физической структурой.
Поясним сказанное на примере. Пусть имеется дерево, логическая структура
которого показана на рис. 2.8.
Рис. 2.8. Пример дерева общего вида для обхода
В памяти связи между узлами будут выглядеть несколько по-другому:
каждый узел будет содержать указатели на первого непосредственного потомка
(старшего сына) и на соседний узел, находящийся на том же уровне иерархии
(брата). Таким образом, физическая структура связей между теми же узлами
будет выглядеть, как на рис. 2.9 (логические связи, отсутствующие в
физической структуре дерева показаны пунктиром).
Если теперь рассмотреть какой-либо из порядков обхода узлов для исходного
дерева, скажем, левосторонний обход в ширину, то для второго дерева тот же
160
Глава 2
порядок обхода уже не будет обходом в ширину. Для исходного дерева узлы
в порядке левостороннего обхода в ширину будут располагаться так:
ABCDEFGHIK
но для второго дерева узлы в порядке обхода в ширину будут расположены
уже по-другому:
ABCEDFGHIK.
Рис. 2.9. Представление дерева общего вида в виде бинарного
Конечно, нетрудно написать функцию, которая будет представлять обход в
ширину узлов исходного дерева, получая в качестве аргумента его
физическое представление, такое, как на рис. 2.9. Однако реализация этой функции
будет отличаться от реализации обхода в ширину для бинарного дерева. Тем
не менее для некоторых порядков обхода функции будут одинаковы в обоих
случаях. Например, нисходящий левосторонний обход в глубину для обоих
деревьев будет одним и тем же:
ABCEFHIKDG.
Иногда очень важным оказывается вопрос о количестве дополнительной
памяти, требующейся для организации обхода дерева. Так, например, в
системах, реализующих язык программирования LISP, практически вся память,
с которой работает программа, организована в виде деревьев, которые иногда
требуется обходить в условиях недостатка памяти (например, для
организации сбора мусора). Во всех приводимых ранее алгоритмах для организации
обхода явно или неявно использовалась некоторая достаточно сложно
организованная структура памяти — стек или очередь. Такая структура нужна для
того, чтобы в процессе обхода помнить, какие элементы дерева еще не
пройдены.
Базовые алгоритмы
161
Для того чтобы иметь возможность перейти от узла дерева к любому
другому, часто изменяют структуру дерева, добавляя в его узлы обратные ссылки
на узлы-предки. При таком изменении структуры дерева удается избежать
дополнительных накладных расходов при обходе узлов. Это, однако,
приводит к увеличению расхода памяти для представления деревьев, так что
ожидаемого выигрыша не получается, если только обратные ссылки не нужны по
каким-то другим соображениям.
Компромиссным способом обхода являются варианты, в которых в той или
иной степени используются элементы памяти самого дерева. При этом
обычно приходится отводить дополнительную память непосредственно в узлах
дерева. Некоторые потери памяти все равно остаются, однако их можно
свести к минимуму. Часто достаточно всего одного дополнительного бита в
каждом узле дерева для того, чтобы организовать обход дерева без
использования дополнительных структур. Иногда такой дополнительный бит памяти
можно получить "даром", если при хранении информации в дереве имеется
некоторая избыточность. Например, если узлы дерева содержат
неотрицательные целые числа, то можно использовать знаковый разряд этих чисел для
хранения нужной дополнительной информации. Для этих целей можно также
задействовать неиспользуемые пустые указатели на несуществующие
поддеревья и узлы.
При таком использовании дополнительной информации в узлах дерева
алгоритмы обхода обычно немного усложняются по сравнению с
рассмотренными нами простыми алгоритмами. Тем самым мы несколько теряем в скорости
прохождения узлов дерева, зато память расходуется более экономно.
Рассмотрим два способа обхода деревьев, использующие внутреннюю
информацию узлов дерева для организации обхода. В первом способе основная
идея состоит в том, чтобы задействовать пустые указатели в исходном дереве
для организации перехода от потомков к предкам. Во втором способе
указатели трансформируются уже в процессе обхода. При движении по дереву
вниз пройденные указатели заменяются указателями "наверх", к корню
дерева; при движении по ним обратно происходит восстановление
направленности указателей. В обоих случаях в каждом узле дерева требуется иметь
дополнительный бит, который в наших программах будет представлен полем
типа booi в структуре узла дерева.
Сначала рассмотрим обход сверху вниз для дерева произвольной структуры
(в общем случае не бинарного), представленного так, как показано на
рис. 2.9. В этом представлении, как и в любом бинарном представлении
дерева, имеется большое количество пустых, неиспользуемых ссылок, столько же,
сколько и непустых. В частности, в каждом из списков узлов, имеющих
одного и того же предка, имеется последний узел, представляющий самого млад-
162
Глава 2
шего брата среди всех сыновей одного и того же отца. Ссылка на очередного
брата в таком узле всегда отсутствует, поэтому ее можно использовать для
того, чтобы поместить туда ссылку на отца— так называемую обратную
ссылку. Будем применять ее для организации обхода дерева без
использования дополнительной памяти.
Чтобы различить эти два способа использования ссылки, введем
дополнительный признак в каждый узел. Получившееся представление дерева
изображено на рис. 2.10. На нем младшие братья выделены штриховкой, а
ссылки на родительские узлы представлены стрелками другого начертания. В
листинге 2.32 в определении класса дерева признак младшего брата представлен
логическим полем youngest, хотя в реальной ситуации для этого будет
использован какой-нибудь избыточный бит в представлении информации,
хранящейся в узле дерева.
Рис. 2.10. Представление дерева с обратными ссылками
Для обхода построим внешний итератор дерева с описанием класса,
вложенным в описание класса дерева. В этом итераторе переход к следующему
элементу в порядке обхода осуществляется предельно просто: если у узла
имеется сын, то происходит переход к этому сыну; если имеется брат, то
происходит переход к брату, и наконец, если нет и брата, то происходит поиск брата
у ближайшего предка.
| Листинг 2.32. Обход дерева с использованием указателей
| от младшего сына к отцу <[г:;,^г/^С -/;/* "v
template <class T>
class Tree {
protected:
Базовые алгоритмы
163
II Описание структуры узла со ссылками на сына и брата
struct Node {
Т item; // значение, содержащееся в узле
Node *son; // указатель на сына
Node ^brother; // указатель на брата
bool youngest; // признак самого младшего брата
// Конструктор узла
Node(T i, Node *s = NULL, Node *b = NULL) {
item = i; son = s; setBrother(b);
}
// Функция для записи ссылки на брата
void setBrother(Node *b) {
brother = b;
// узел младший, если отсутствует ссылка на брата:
youngest = (b == NULL);
}
};
// Дерево реализовано в виде указателя на корневой узел.
Node *root;
public :
// Класс BackRefIterator является реализацией внешнего итератора для
// обхода дерева в глубину с использованием обратных ссылок.
class BackRefIterator : public Iterator<T> {
Node* curNode; // текущий узел при обходе
public:
// Конструктор итератора инициирует ссылку на текущий узел
BackRefIterator(Tree<T> & t) { curNode = t.root; }
// Проверка конца итерации - проверка наличия очередного элемента.
bool hasMoreElements() const { return curNode != NULL; }
// Переход к следующему элементу
Iterator<T> & operator ++ ();
// Доступ к очередному (текущему) узлу.
const T & operator *() const;
Т & operator *();
};
friend class BackRefIterator;
164
Глава 2
I/ Конструктор "по умолчанию" создает пустое дерево
Tree () : root (NULL) {}
// Функция построения внешнего итератора BackRefIterator:
BackRefIterator * iteratorBRO { return new BackRefIterator(*this); }
}; // конец класса Tree
// Реализация методов внешнего итератора
// Переход к следующему узлу дерева.
template <class T>
Iterator<T> & Tree<T>: -.BackRefIterator::operator ++ () {
if (curNode != NULL) {
// Ищем следующий элемент
if (curNode->son) {
curNode = curNode->son;
} else {
while (curNode != NULL && curNode->youngest) {
curNode = curNode->brother; // переход к отцу
}
if (curNode != NULL) curNode = curNode->brother; // переход к брату
}
}
return *this;
}
// Доступ к очередному узлу.
template <class T>
Т & Tree<T>::BackRefIterator::operator *() {
if (curNode == NULL) {
// доступ невозможен, если нет очередного узла
throw NoElementException();
}
return curNode->item;
}
Если дерево, изображенное на рис. 2.10, построено правильно, то его узлы
будут пройдены в следующем порядке:
ABCEFHIKDG
что является для этого дерева естественным порядком обхода сверху вниз.
Недостатком такого способа обхода является то, что необходимо аккуратно
поддерживать правильную структуру ссылок в дереве. Если, например, неко-
Базовые алгоритмы
165
торый узел удаляется, то следует проверить, не содержит ли удаляемый узел
ссылку вверх по дереву на предка (т. е. не является ли он самым младшим
братом). Если это действительно так, то нужно найти его ближайшего
старшего брата (разумеется, с использованием той же самой обратной ссылки
вверх по дереву) и модифицировать представление этого брата. Аналогично,
необходимо аккуратно следить за корректностью представления и при
добавлении узлов.
Второй способ обхода с использованием внутренней структуры дерева,
представленный в листинге 2.33, избавлен от этого недостатка. При этом способе
обхода нет необходимости как-то по-особому обрабатывать или представлять
узлы дерева. Все, что необходимо иметь, — это свободный бит информации
для использования во время обхода. Недостаток этого способа обхода
заключается в другом: структура дерева динамически меняется во время его
обхода. При обходе дерева данным способом ссылки на поддеревья заменяются
обратными ссылками вверх по дереву прямо в процессе обхода. Когда же
обратная ссылка будет использована при возврате наверх, то ее прежнее
состояние будет восстановлено.
Изменение ссылок динамически во время обхода, в частности, означает, что
для такого способа обхода внешние итераторы не подходят. Действительно,
если обход будет прерван до его окончания, или во время обхода будет
предпринята попытка изменить структуру дерева, или даже просто будет
запущена вторая итерация — все это может привести к непредсказуемым
последствиям. Поэтому второй способ обхода дерева с использованием его
внутренней структуры будет представлен внутренним итератором. Данный способ
обхода запрограммирован для обхода бинарных деревьев, но его можно
модифицировать и для обхода деревьев произвольной структуры.
На рис. 2.11 представлен промежуточный этап при обходе бинарного дерева,
изображенного на рис. 2.7. При работе алгоритма используются следующие
указатели:
□ processed— указатель на последнюю обработанную вершину,
содержащую указатель вверх по дереву;
□ current — указатель на первую вершину еще не обработанной части
дерева.
При движении вниз по дереву (от корня к листьям) некоторые указатели
используются для того, чтобы запомнить обратный путь. При движении
обратно значения указателей восстанавливаются. Дополнительный бит
информации применяется для того, чтобы запомнить, какой из двух указателей —
левый или правый— используется для временного хранения ссылки на
родительский узел.
166
Глава 2
На рис. 2.11 изображена ситуация, когда в двух узлах дерева — А и С—
поля, в которых в нормальном состоянии записаны указатели на правое
поддерево, используются для хранения ссылок на родительский узел.
processed
current
Рис. 2.11. Обход дерева с использованием внутренних указателей
В листинге 2.33 представлена операция, осуществляющая левосторонний
инфиксный обход дерева с помощью внутреннего итератора. Это один из самых
сложных алгоритмов, приведенных в этой книге; для его успешного
понимания рекомендуется попробовать проследить его работу на каком-нибудь
простом примере. Удобно воспользоваться каким-либо отладчиком программ на
C++. Тексты всех используемых нами программ обхода деревьев
можно найти на приложенном компакт-диске в папке "\chapter2\2.5
\BinaryTreeiterators" (обходы с помощью рекурсивных функций и
вспомогательных структур данных— стеков и очередей), а также в папках
"\Chapter2\2.5\BackReference" (обход С ПОМОЩЬЮ обратных ССЫЛОК ИЗ
младшего сына на его отца) и "\chapter2\2.5\DynBackRefs" (динамическое
обращение ссылок).
[Листинг 2.33. Левосторонний инфиксный обход с обращением ссылок
template <class T>
class Tree {
protected:
// Описание структуры узла со ссылками на левое и правое поддеревья
struct Node {
Базовые алгоритмы
167
Т item; // значение, содержащееся в узле
Node *left; // указатель на левое поддерево
Node *right; // указатель на правое поддерево
bool flag; // флажок для обхода
// Конструктор узла
Node(T i, Node *1 = NULL, Node *r = NULL) :
item(i), left(l), right(r), flag(false) {}
};
// Дерево реализовано в виде указателя на корневой узел.
Node *root;
public :
// Конструктор по умолчанию создает пустое дерево
Tree () : root (NULL) {}
// Обход узлов дерева с динамическим обращением ссылок
void traverseWithInversion(Actor<T> & actor);
}; // конец класса Tree
// Обход узлов дерева с динамическим обращением ссылок
template <class T>
void Tree<T>::traverseWithInversion(Actor<T> & actor) {
Node ^processed = NULL; // указатель вверх по дереву
Node *current = root; // указатель на текущую вершину
bool down = true; // направление движения
// Цикл обхода узлов закончится, когда при движении вверх
// окажется, что уже все узлы пройдены
while (down || processed != NULL) {
if (down) {
if (current == NULL) {
// меняем направление движения
down = false;
} else {
// спускаемся вниз по дереву на один шаг
Node *w = current->left;
current->left = processed;
processed = current;
current = w;
}
} else {
if (processed->flag) {
// восстанавливаем указатель и продвигаемся вверх по дереву
processed->flag = false;
Node *w = processed->right;
168
Глава 2
processed->right = current;
current = processed;
processed = w;
else {
// посещаем вершину при переходе из левого поддерева в правое
actor.action(processed->item);
// переходим к обработке правого поддерева
Node *w = processed->right;
processed->flag = true;
processed->right = processed->left;
processed->left = currents-
current = w;
// снова двигаемся вниз
down = true;
На этом мы закончим рассмотрение алгоритмов обхода деревьев. Некоторые
из них будут использованы позже в главах 3 и 4 книги.
2.6. Бинарные деревья поиска
Для того чтобы найти в дереве нужную информацию, можно перебрать все
его узлы с помощью одного из алгоритмов обхода дерева. Однако если
объекты, хранящиеся в дереве, допускают линейное упорядочивание, то время
поиска можно существенно сократить, если разместить узлы дерева в
определенном порядке в соответствии с результатами сравнения объектов,
хранящихся в узлах. Одним из самых распространенных способов упорядочивания
является такое расположение информации в узлах, при котором все значения,
хранящиеся в левом поддереве некоторого узла, меньше значения,
хранящегося в этом узле, а значения, хранящиеся в правом поддереве, больше или
равны этому же значению. Если это условие выполняется, то дерево
называют бинарным деревом поиска, а алгоритм поиска в таком дереве будет
проходить только одну ветвь дерева, что может сократить время поиска с
линейного до логарифмического.
В представлении дерева поиска будем считать, что в его узлах расположены
объекты, которые можно сравнивать между собой с помощью обычных
операторов сравнения '<', '>', '<=', '>=', '=' и '!='.
Алгоритм поиска объекта в бинарном дереве поиска ищет узел, содержащий
объект со значением, равным заданному. Равенство понимается в смысле,
Базовые алгоритмы
169
придаваемом ему оператором равенства '—'. Приводя примеры обработки
деревьев поиска, мы будем считать, чтЬ в его узлах расположены целые
значения. Разумеется, все перечисленные операции сравнения можно применять
к целым числам.
На практике узлы дерева содержат более сложные объекты. Например, если
дерево используется для организации быстрого поиска в некотором большом
массиве или файле (будем условно говорить о поиске в базе данных), то узел
дерева будет содержать ключ — то значение, по которому производится
поиск и указатель на связанную с этим ключом запись в базе данных (индекс
в массиве, номер записи в файле и т. п.). Сравнение объектов в узлах дерева в
этом случае осуществляется по ключу, т. е. операции сравнения применяются
к ключам, хранящимся в узлах дерева.
На рис. 2.12 показан пример бинарного дерева поиска с целыми числами в
узлах. При поиске в нем, скажем, значения 30, будут последовательно
пройдены узлы со значениями 10, 20 и, наконец, 30. Результат поиска будет
успешным. При поиске в нем же значения 18 будут последовательно пройдены
узлы 10, 20, 15, после чего выяснится, что значения 18 в дереве нет, так что
результат поиска окажется отрицательным.
Рис. 2.12. Бинарное дерево поиска с целыми значениями в узлах
В листинге 2.34 представлено описание шаблона классов для бинарных
деревьев поиска с определенным в нем методом поиска по ключу. Операция
выдает указатель на хранящийся в дереве объект в случае успешного
завершения поиска, а также пустой указатель null в случае, если значение в дереве
не найдено. В этом примере узлы дерева хранят только ключ; на практике
структура узла будет более сложной.
170
Глава 2
* Бинарное дерево поиска с операцией поиска в нем
**************************************
template <class T>
class Tree {
protected:
// Описание структуры узла со ссылками на сына и брата
struct Node {
Т key; // ключ, содержащийся в узле
Node *left; // указатель на левое поддерево
Node *right; // указатель на правое поддерево
// Конструктор узла
Node(T k, Node *1 = NULL, Node *r = NULL) {
key = к; left = 1; right = r;
}
};
// Дерево реализовано в виде указателя на корневой узел.
Node *root;
public :
// Конструктор по умолчанию создает пустое дерево
Tree() : root(NULL) {}
// Функция поиска по ключу в бинарном дереве поиска
Т * search(const T & key);
}; // конец класса Tree
// Тела методов класса
// Функция поиска по ключу в бинарном дереве поиска
template <class T>
Т * Tree<T>::search(const T & key) {
for (Node * current = root; current;) {
if (current->key == key) { // узел найден
return & current->key;
Базовые алгоритмы
171
} else if (current->key < key) { // ключ в корне меньше искомого
current = current->right; // переходим в правое поддерево
} else { // ключ в корне больше искомого
current = current->left; // переходим в левое поддерево
}
}
return NULL; // узел не найден
}
Следует заметить, что попытка изменить содержимое найденного узла,
используя полученный в результате поиска указатель, может привести к
нарушению структуры дерева поиска, если новое значение окажется не на своем
месте. Лучше всего будет, если значение ключа в объектах, хранимых в
дереве, вообще будет невозможно изменить. Это обычно достигается за счет того,
что информация в узле дерева представляется объектом, для которого доступ
к ключу предоставляется только для чтения.
Левосторонний инфиксный обход бинарного дерева поиска позволяет
посетить его узлы в порядке возрастания значений. В следующем примере такой
обход используется для того, чтобы выводить последовательность значений,
хранящихся в дереве, в символьный выходной поток. Для этого необходимо,
чтобы к этим значениям можно было применить операцию вывода в
выходной поток. Тогда представление в виде строки всего дерева будет составлено
из представлений его узлов в порядке возрастания значений, разделенных
пробелами и заключенных в квадратные скобки. В листинге 2.35
представлена реализация оператора '«' для вывода бинарного дерева поиска в
выходной символьный поток. Считаем, что такой оператор уже определен для
узлов дерева, а также предполагаем, что для дерева уже определен внешний
итератор, выдающий узлы дерева в порядке левостороннего инфиксного
обхода. Если метод, определенный для дерева
Iterator<T> *iterator();
строит и выдает такой итератор, то вывод узлов дерева в выходной
символьный поток в порядке левостороннего обхода может быть реализован так, как
показано в листинге 2.35.
| Листинг 2.35. Вывод узлов дерева в выходной символьный поток
template <class T>
ostream & operator « (ostream & out, const Tree<T> & tree) {
out « ' [';
Iterator<T> * it = tree.iterator();
while (it->hasMoreElements()) {
772
Глава 2
out « **it; // Предполагается, что содержимое узла
// может быть выведено в поток out
++*it; // продвижение к следующему узлу
if (it->hasMoreElements()) {
out « ',f;
}
}
out « ' ] ';
delete it;
return out;
}
Для деревьев поиска наиболее важным является вопрос о способах
модификации дерева при включении новых узлов и удалении имеющихся. При всех
таких изменениях должна быть сохранена структура дерева поиска, т. е.
новый узел не должен нарушать основное правило: в каждом узле значение
должно быть больше значений узлов левого поддерева и меньше значений
узлов правого поддерева. При удалении узлов также иногда требуется
перестраивать дерево, так что и в этом случае необходимо следить за
сохранением структуры.
Прежде всего, опишем два алгоритма добавления нового узла в дерево
поиска. Первый из них образует в дереве новый лист, при этом вся остальная
структура сохраняется. Второй алгоритм образует новый корень дерева, при
этом структура остальной части частично меняется.
При добавлении узла в лист дерева нужно лишь обеспечить, чтобы этот
новый лист мог бы быть впоследствии эффективно найден функцией поиска.
Таким образом, алгоритм добавления может работать практически так же, как
и при поиске, только в последний момент вместо того, чтобы сообщать о том,
что искомого узла в дереве нет, надо добавить новый узел в то самое место,
где он должен находиться. Еще одна небольшая трудность состоит в
правильной обработке узла, значение которого уже есть в дереве. Здесь
возможны два варианта: либо новый узел с тем же самым значением ключа все же
добавляется в дерево (впоследствии такой узел никогда не будет найден
нашей процедурой поиска, поскольку она всегда ищет только первый из узлов с
заданным значением), либо новый узел не добавляется в дерево вообще.
В листинге 2.36 представлена функция добавления нового значения в
бинарное дерево поиска тгее<т>, при этом даже если такое значение уже есть в
дереве, то новый узел все равно создается и включается в дерево. В разд. 3.3
будет представлен и другой способ добавления значения в дерево поиска с
учетом особенностей этого дерева. Функция insertLeaf, реализующая такую
вставку, считается включенной в описание класса тгее<т> в качестве его
метода.
Базовые алгоритмы
173
i Листинг 2.36. Включение нового узла в дерево поиска
template <class T>
void Tree<T>::insertLeaf(const T& elem) {
Node ** current = &root;
wjiile (^current != NULL) {
if (elem < (^current)->key) {
current = &(*current)->left;
} else {
current = &(^current)->right;
}
}
^current = new Node(elem);
}
Проверьте, что дерево, изображенное на рис. 2.12, может быть получено
с помощью следующей последовательности операций:
Tree<int> t;
t.insertLeaf(10);
t.insertLeaf(3) ;
t.insertLeaf(20) ;
t.insertLeaf(15) ;
t.insertLeaf(30) ;
t.insertLeaf(2) ;
t.insertLeaf(5) ;
t.insertLeaf(32);
t.insertLeaf(22);
После этого содержимое дерева может быть распечатано с помощью вызова
операции:
cout « t « endl;
поскольку для дерева определена операция вывода его символьного
изображения в выходной поток.
Описанная процедура добавления нового узла в дерево поиска обладает тем
недостатком, что новый узел оказывается лежащим далеко от корня, хотя
довольно часто бывают ситуации, когда поиск среди узлов, добавленных
последними, производится гораздо чаще, чем поиск более "старых" узлов.
В этих условиях было бы лучше добавлять новые узлы в корень дерева, а не в
листья. К счастью, существует достаточно простой и практически такой же
эффективный алгоритм для добавления нового узла в корень дерева. В этом
алгоритме исследуется та же последовательность узлов, что и при
добавлении в лист и, разумеется, та же самая, что и при поиске узла, но структура
дерева меняется более существенно.
174
Глава 2
В процессе работы алгоритма отслеживаются позиции узлов, в которых
производятся изменения. На рис. 2.13 показана последовательность состояний
двоичного дерева поиска при добавлении в него нового узла со значением 12
в корень дерева.
Рис. 2.13. Последовательные состояния дерева при вставке нового узла в корень
Кружочками со словами "меньше", "больше" отмечены ссылки в узлах
дерева, в которых могут происходить изменения на следующем шаге работы
алгоритма. Если текущий рассматриваемый узел оказывается меньше нового
значения, то он помещается в позицию "меньше", а если он оказывается
больше нового значения, — то в позицию "больше". Позиции этих ссылок
отмечены в программе (листинг 2.37) переменными leftNode и rightNode.
Текущий рассматриваемый узел дерева представлен на рисунке и в программе
указателем current.
Базовые алгоритмы
175
template <class T>
void Tree<T>: : insertRoot (const T& eleni) {
// Прежде всего создается новый узел и указатели
//на позиции "меньше" и "больше"
Node * current = root; // Текущий просматриваемый узел
root = new Node(elem); // Создание нового узла
Node ** leftNode = & root->left; // Ссылка на позицию "меньше"
Node ** rightNode = & root->right; // Ссылка на позицию "больше"
// Цикл просмотра узлов и размещения элементов в
// поддеревьях нового корня дерева
while (current) {
if (current->key < elem) {
*leftNode = current; // Изменение ссылки "меньше"
leftNode = & current->right;
current = current->right; // Переход к правому поддереву
} else {
*rightNode = current; // Изменение ссылки "больше"
rightNode = & current->left;
current = current->left; // Переход к левому поддереву
}
}
// Очистка ссылок "меньше" и "больше" в конце работы
*leftNode = *rightNode = NULL;
}
Вторая задача, которую надо решить для обеспечения нормальной работы с
деревом поиска на практике, — это удаление узлов из дерева. Если эта задача
будет решена, то третья имеющаяся возможность— перемещение узла по
дереву из-за изменения его значения — может быть решена удалением
одного узла и вставкой нового вместо него.
Проще всего оказывается удалить узел, если он находится в листе дерева.
В этом случае достаточно лишь убрать из дерева указатель на этот узел.
Разумеется, сначала придется произвести поиск этого узла в дереве. Не очень
сильно отличается от этого и случай, при котором удаляемый узел имеет
лишь один указатель на поддерево (левое или правое). Очевидно, что и в этом
случае для удаления узла достаточно изменить лишь один указатель на
удаляемый узел, заменив его указателем на его единственное поддерево. На
рис. 2.14 представлена ситуация до и после удаления такого узла из дерева
(удаляемый узел выделен штриховкой).
Для того чтобы решить задачу удаления узла в общем виде, ее сводят к одной
из описанных простых ситуаций. Для этого в одном из поддеревьев удаляе-
176
Глава 2
мого узла находят узел, значение которого наиболее близко к значению в
удаляемом узле: максимальное значение в левом поддереве или минимальное
значение в правом поддереве. Очевидно, что в обоих случаях такое
ближайшее значение обязательно будет расположено либо в листе дерева, либо в
узле, имеющем только одно поддерево. После этого делают подмену: удаляют
вместо нужного узла один из найденных ближайших узлов, а значение,
которое в нем содержалось, переносят в узел, который требовалось удалить.
Очевидно, что структура дерева поиска при этом не нарушится.
Процесс удаления узла по описанному алгоритму изображен на рис. 2.15.
Рис. 2.14. Удаление узла из бинарного дерева поиска
Рис. 2.15. Удаление узла из бинарного дерева поиска в общем виде
Базовые алгоритмы
177
Функция удаления узла из бинарного дерева поиска оказывается не такой
простой, как функция вставки, поскольку приходится рассматривать много
различных ситуаций, возникающих при удалении узла. Эта функция
представлена в качестве метода remove шаблона классов тгее<т> в листинге 2.38.
Для определенности в ней всегда в качестве узла-заместителя для удаляемого
ищется узел в правом поддереве, т. е. узел, значение которого является
ближайшим к значению удаляемого узла сверху.
j Листинг 2.38. Функция удаления узла из бинарного дерева поиска
/************************************************************************
* Функция удаления узла с заданным значением из бинарного дерева поиска
******************** ***************************************************/
template <class T>
void Tree<T>::remove(const T & item) {
Node ** current = & root; // указатель на удаляемый узел
// 1. Поиск удаляемого узла
while (^current != NULL && (^current)->key != item) {
if ((^current)->key < item) {
current = &(*current)->right;
} else {
current = &(^current)->left;
}
}
// 2. Разбор простых случаев
if (^current == NULL) return;
if ((*current)->left = NULL) {
^current = (^current)->right;
} else if ((^current)->right ==
^current = (^current)->left;
} else {
// 3. Разбор общего случая: оба поддерева не пусты
// Цикл поиска узла-заместителя в правом поддереве.
Node ** nodeToReplace = & (^current)->right;
while ((*nodeToReplace)->left) {
nodeToReplace = &(*nodeToReplace)->left;
}
// Замещение значения в удаляемом узле
(^current)->key = (*nodeToReplace)->key;
// Физическое удаление узла-заместителя из дерева
^nodeToReplace = (^nodeToReplace)->right;
}
}
// удаляемое значение не найдено
// левое поддерево отсутствует
NULL) {// правое поддерево отсутствует
178
Глава 2
Бинарные деревья поиска могут применяться для упорядочения информации.
Для того чтобы расположить некоторые элементы в порядке возрастания,
можно выстроить их в бинарное дерево поиска, а затем, обойдя это дерево
в левостороннем порядке, получить отсортированную последовательность.
Это дает нам еще один способ сортировки последовательностей (массивов) в
дополнение к способам, рассмотренным ъразд. 2.2.
Пусть, например, в некотором тексте требуется расположить все слова в
алфавитном порядке, т. е. построить таблицу всех слов, встречающихся в нем.
Для того чтобы выделить слова из текста, можно воспользоваться
простейшей функцией strtok пакета обработки строк <string.h> языка С.
Разделителями слов будем при этом считать пробелы и другие пустые символы
(символы табуляции, перевода строки и т. п.), а также знаки пунктуации. Чтобы
записывать слова в дерево поиска, определим новый класс Word,
необходимый только для определения операторов сравнения слов и оператора вывода
слова в символьный выходной поток. Объекты класса Word будут содержать
ссылку на обычную строку языка С, а операторы сравнения, определенные
над словами, будут сравнивать символы этих строк в лексикографическом
йорядке.
Функция, решающая эту задачу, приведена в листинге 2.39. Она получает
строку текста в качестве аргумента и выводит последовательность слов,
составляющую этот текст, в выходной поток, заданный вторым аргументом
функции. Функция пользуется бинарным деревом поиска Tree<word> и его
операцией insertLeaf для вставки нового слова в дерево, а также оператором
вывода узлов дерева в выходной поток в порядке левостороннего обхода.
Слова сравниваются без учета регистра букв с помощью вызова метода
stricmp().
j Листинг 2.39. Вывод слов заданного текста в лексикографическом порядке ~ j
* Определение класса для слова - указателя на неизменяемую *
* строку для сравнений и вывода в символьный поток *
class Word {
// Операторы сравнения и вывода слов в выходной поток
// должны иметь доступ к внутренней структуре слова
friend bool operator < (const Word &, const Word &);
friend bool operator > (const Word &, const Word &);
friend bool operator <= (const Word &, const Word &);
friend bool operator >= (const Word &, const Word &);
friend bool operator == (const Word &, const Word &);
Базовые алгоритмы
179
friend bool operator != (const Word &, const Word &);
friend ostream & operator « (ostream &, const Word &);
const char * src; // Указатель на строку
public:
// Конструкторы слов
Word() : src(M") {}
Word (const Word & w) : src(w.src) {}
Word(char * s) : src(s) {}
};
// Операторы сравнения слов
bool operator < (const Word & si, const Word & s2) {
return stricmp(sl.src, s2.src) < 0;
}
bool operator == (const Word & si, const Word & s2) {
return stricmp(sl.src, s2.src) == 0;
}
bool operator > (const Word & si, const Word & s2) {
return stricmp(sl.src, s2.src) > 0;
}
bool operator <= (const Word & si, const Word & s2) {
return stricmp (sl.src, s2.src) <= 0;
}
bool operator >= (const Word & si, const Word & s2) {
return stricmp (sl.src, s2.src) >= 0;
}
bool operator != (const Word & si, const Word & s2) {
return stricmp (sl.src, s2.src) != 0;
}
// Оператор вывода слова в выходной поток
ostream & operator « (ostream & out,, const Word & w) {
return out « w.src;
}
180
Глава 2
// Функция вывода слов текста в выходной поток
//в лексикографическом (алфавитном) порядке.
void sortWords(char * source, ostream & out) {
Tree<Word> words;
char * delimeters = ",.?!;:-\*\" \t\n\r\f";
for (char * nextToken = strtok(source, delimeters);
nextToken;
nextToken = strtok(NULL, delimeters)) {
words.insertLeaf(Word(nextToken));
}
out « words « endl;
}
Если вызвать эту функцию для некоторого текста и направить вывод
результата в стандартный выходной поток, скажем, следующим образом:
sortWords("Jackdaws love my big sphinx of quartz", cout);
то в выходном потоке мы получим такой результат:
[big,Jackdaws,love,my,of,quartz,sphinx]
К сожалению, не всегда есть гарантия того, что при встарках новых узлов в
дерево их последующий поиск будет быстрым. Скорость поиска существенно
зависит от того, насколько равномерно расположены узлы в дереве.
Например, при обработке вышеприведенного текста после вставки слов в дерево
поиска его структура будет такой, как изображено на рис. 2.16.
| Jackdows |
\ш
love I
^^
| ту |
1 sphinx I
| of |
| quartz |
Рис. 2.16. Дерево слов после вставки в него слов из текста примера
Ясно, что это далеко не лучшая структура в отношении времени поиска узлов
по дереву. Более того, она лишь немногим лучше, чем просто упорядоченный
Базовые алгоритмы
181
список слов. Конечно, если бы текст примера был больше, то и структура
дерева могла бы несколько улучшиться. Например, если удлинить текст
предложением "A quick brown fox jumps over the lazy dog", TO ВЫСОТа дерева
(а значит, и среднее время поиска узлов в нем) увеличится всего на единицу,
несмотря на то, что слов стало значительно больше. Это второе дерево,
получившееся после вставки слов второго предложения, показано на рис. 2.17.
|Jackdows
1 big 1
|| А | | brown |
fox
1 dog 1
1 love I
jumps | | my [
1 lazy 1 | sphinx | |
1 of 1 1 the 1
1 quartz |
*ч
| over | | quick |
Рис. 2.17. Дерево слов после вставки в него слов второго предложения
Все-таки и в новом дереве на рис. 2.17 слова расположены не оптимальным
образом. Если произвести некоторые подсчеты, то окажется, что для поиска
слова в получившемся дереве в среднем потребуется сделать 4 шага. В то же
время, если те же слова были бы расположены в дереве оптимальной
структуры (например, как на рис. 2.18), то для поиска слова требовалось бы
сделать в среднем всего 3,375 шага. Это, впрочем, вполне понятно, поскольку
дерево на рис. 2.17 имеет 7 уровней, а дерево на рис. 2.18 — всего 5, да и то
на пятом уровне находится всего одно слово.
Вообще говоря, оптимальным для поиска будет дерево минимальной высоты,
у которого к тому же узлы всех уровней, кроме двух последних, имеют
непустые указатели на оба поддерева. У оптимального дерева на любом из
промежуточных уровней с номером к находятся 2к'] узлов, и только на
последнем уровне узлов может быть меньше. Соответственно, если всего в
оптимальном дереве содержатся п узлов, то поиск в таком дереве производится
приблизительно за log2 n шагов.
Хорошо было бы, если в процессе вставки элементов все время удавалось бы
поддерживать дерево в оптимальном виде. К сожалению, это заняло бы
столько времени, что свело бы на нет всю выгоду от быстрого поиска.
182
Глава 2
I big I
I A | I brown
J lazy
dog I
| Jackdows |
/ ^
| | fox | | jumps |
I over I
my quick
love | | of | | quartz | | the |
i
| sphinx |
Рис. 2.18. Оптимальное дерево слов
Тем не менее имеются схемы, при которых удается поддерживать структуру
дерева поиска более или менее приемлемой с точки зрения эффективности
поиска, причем алгоритмы вставки и удаления лишь незначительно
усложняются. Наиболее известными из таких схем являются АВЛ-деревъя,
прекрасно описанные во многих книгах, таких, как, например, [1, 4, 6], и 2-3-деревъя,
также многократно описанные и рассматриваемые нами далее.
Идея 2-3-дерева состоит в том, что структура бинарного дерева несколько
усложняется за счет ввода специальных узлов, имеющих два ключа в узле
вместо одного и, соответственно, три поддерева вместо двух. За счет этого
удается поддерживать структуру, при которой все листья дерева оказываются
лежащими на одном и том же уровне, а все вышерасположенные узлы имеют,
по крайней мере, два непустых поддерева.
Более строгое определение 2-3-дерева состоит в следующем:
1. Каждый узел 2-3-дерева содержит один или два ключа.
2. Узлы дерева делятся на две категории— листья и промежуточные узлы,
причем если промежуточный узел содержит один ключ, то этот узел имеет
два непустых поддерева (2-узел), а если он содержит два ключа, то имеет
три непустых поддерева (3-узел).
3. Принцип упорядоченности значений сохраняется для 2-3-дерева в
следующем виде. Для 2-узла, как и в случае бинарного дерева поиска, все
ключи, лежащие в левом поддереве, имеют значения, меньшие ключа,
хранящегося в узле, а ключи, лежащие в правом поддереве, — больше или
равны ключу, хранящемуся в узле. Для 3-узла ключи, лежащие в левом
поддереве, меньше первого из ключей, хранящихся в узле. Он же в свою
очередь меньше или равен значениям ключей, лежащих в среднем
поддереве, которые меньше второго из ключей узла, который меньше или равен
ключам, лежащим в правом поддереве.
4. Все листья лежат в дереве на одном и том же уровне.
Базовые алгоритмы
183
На рис. 2.19 представлен пример 2-3-дерева, содержащего в узлах целые
числа. А дерево на рис. 2.20 не является 2-3-деревом: нарушено условие (2).
ГйП
J^
101 |21
251 |
L/T^^T^J
| 2 | | 5
8 | | 11 | 13 | | 16 | 19 | | 24 | | 28 | 30 | |
Рис. 2.19. Пример 2-3-дерева
Рис. 2.20. Пример нарушения структуры 2-3-дерева
Физически 2-3-дерево часто представляют в виде обычного бинарного
дерева, как показано на рис. 2.21. Чтобы отличить указатель на поддерево от
указателя на соседнее значение, логически принадлежащее тому же узлу,
вводится специальный флажок. На рисунке указатели разных типов
представлены стрелками с разными концами.
С2 У
С4 У
(б Л-С
W-io)
Л8 )
С11У
-Л15Х
OM3J
мб\н
А21
>M9J
)Ч>С2Ч
24J (2вЛн
б/зоп
Рис. 2.21. Физическая структура 2-3-дерева
Заметьте, что поиск в 2-3-дереве, представленном в виде бинарного дерева,
можно производить точно так же, как и в обычном бинарном дереве поиска.
184
Глава 2
Однако если с точки зрения логической структуры дерево, показанное на
рис. 2.21, имеет высоту, равную 3, то с точки зрения его физической
структуры как бинарного дерева оно имеет высоту, равную 5. Оптимальное бинарное
дерево, содержащее те же значения, могло бы иметь высоту, равную 4.
Вставка в 2-3-дерево осуществляется только в листья. При этом сначала
производится поиск листа, в который можно добавить новое значение с
сохранением структуры поиска, а затем производится вставка почти как в обычное
бинарное дерево поиска. Может оказаться, что при этом структура 2-3-дерева
осталась не нарушенной. Так, например, после вставки узла со значением 23
в дерево, изображенное на рис. 2.21, получившееся дерево снова будет 2-3-
деревом, так что никаких дальнейших преобразований производить не надо
(рис. 2.22).
Рис. 2.22. 2-3-дерево после вставки элемента 23
Однако при вставке в то же самое дерево значения 12 после самой вставки
образуется переполнение узла. Для того чтобы исправить ситуацию, узел
делится на два новых, при этом среднее из трех значений, образующих
переполненный узел, поднимается на предыдущий уровень и присоединяется к
узлу, лежащему на предыдущем уровне. Разумеется, на предыдущем уровне
вновь может возникнуть переполнение узла, и тогда операция расщепления
узла повторяется снова. На рис. 2.23 показаны последовательные стадии
преобразования дерева, изображенного на рис. 2.19, при добавлении в него
нового узла со значением 12. "Нестабильные" узлы выделены пунктирной
границей.
Алгоритм вставки не очень сложно запрограммировать, но поскольку разных
случаев при преобразованиях структуры оказывается довольно много,
программа получается слишком длинной, так что мы не приводим ее здесь.
Интересующихся читателей можем отослать к книге [5], где соответствующие
алгоритмы приведены в более общем виде для В-деревьев, являющихся
обобщением 2-3-деревьев.
Удаление элемента из 2-3-дерева происходит в обратном порядке: там, где
при добавлении элемента происходило расщепление, при удалении происхо-
Базовые алгоритмы
185
дит склеивание. Некоторое усложнение происходит за счет того, что, во-
первых, удаление, как и в случае бинарных деревьев, надо производить из
листа, а для этого может потребоваться сначала найти элемент-заместитель, а
во-вторых, кроме склеивания элементов добавляется еще один способ
исправления структуры дерева — переливание.
I 4
Аг
| 2 | | 5
10
Г
8
."fi"i2"; 1"з" ■
П?1
1 21
25 |
r^ctv
| 16 | 19 | [24 | | 28 | 30 |
, J
16
19 |
21
25
24 |
is.
| 28] 30 |
28 30
Рис. 2.23. Последовательные стадии преобразования 2-3-дерева
при вставке нового узла
На рис. 2.24 показана последовательность состояний 2-3-дерева при
удалении элемента.
На рис. 2.24 {а) удалению подлежит ключ 10. Он находится не в листе дерева,
поэтому ищем заместитель этого элемента — ближайшее к нему сверху
значение. Таким значением оказывается значение 11.
На рис. 2.24 (Ь) ключ 11 переместился в корень дерева, а узел, ранее
содержавший этот ключ, удален из дерева. Структура 2-3-дерева нарушилась,
поскольку узел 12 теперь не имеет левого поддерева. Чтобы восстановить
186
Глава 2
структуру, делается попытка склеить два значения 12 и 13 в один узел, а узел
12 удалить из дерева.
На рис. 2.24 (с) склейка успешно произведена, но узел из дерева удалить
безболезненно опять не удается — образовался пустой узел. Склейка теперь
тоже не получится — значение 11 надо было бы склеивать с узлом, уже
содержащим два значения: 2 и 6. Поэтому выбран другой метод преобразования —
переливание. Ключ 6 поднимается в корень на место ключа 11, а ключ 11
опускается в пустой узел. Ссылка на узел, содержащий ключи 7 и 8,
перемещается вместе с ним.
На рис. 2.24 (d) преобразования завершены; структура 2-3-дерева
восстановлена.
1 2~"
1 о 1 1 1 \2
П2~
1 о 1 1 1 \2
R 1 • 1 -Го 1
У 1 7 | 8 | QT] [Тз]
(а)
[771
ГЦ Щ
LJ 1 7 1 8 1 I 12| 13[
(с)
пи
2 с ' I -10 I I
1 о 1 1 1 \2
(b)
ПП M1J
| 0 | 1 | [4] I 7 | 8 | | 12 | 13|
(d)
Рис. 2.24. Удаление узла из 2-3-дерева
На этом мы закончим обзор методов работы с деревом поиска. Позже мы
ненадолго вернемся к этой структуре при обсуждении методов представления
словаря.
o.^Tv' /г т
ГЛАВА 3 12ЫШ-
Обработка текстов
Обработка строк является важнейшей составной частью работы со
структурами данных. Очень часто именно удобство работы со строками определяет
выбор языка пользователем. Имеется много языков программирования,
ориентированных именно на работу со строками, но часто в таких языках
отсутствуют развитые средства работы с другими типами данных. Яркими
представителями таких языков являются Рефал и Снобол. В то же время в
традиционных универсальных языках программирования, таких как С, Pascal, Java,
средства работы со строками присутствуют в весьма ограниченном виде. Как
правило, предоставляются методы для склеивания строк, поиска подстрок,
вырезания фрагментов строк. Иногда предоставляются простые способы для
модификации строк — вставка новых фрагментов в заданную строку,
добавление символов и строк в конец заданной строки и т. п. Часто имеются
средства для преобразования других объектов языка в строковое представление и
обратно.
Тем не менее для серьезной обработки строк этого недостаточно. Прежде
всего необходимо тщательно продумать механизмы распределения памяти
под строковые объекты. Если при каждом добавлении одного символа в
конец имеющейся длинной строки происходит отведение памяти под всю
строку, то эффективность работы программы от этого очень страдает. Далее,
желательно иметь средства для сравнения строк с заданным образцом, быстрые
операции поиска подстроки в длинном тексте и т. д.
К счастью, универсальные языки программирования предоставляют
возможности организации собственных типов и данных и методов работы с ними.
Ниже рассматриваются несколько способов организации строковых объектов
в языке программирования. Как и везде, в книге в качестве
инструментального средства используется язык C++, однако похожим образом можно
организовать данные и на любом другом языке, поддерживающем определение
собственных структур данных.
188 Глава 3
3.1. Способы представления строк
В языке С строкой называется массив символов, не содержащий во
внутренних элементах символа с нулевым кодом, кроме последнего элемента,
ограничивающего строку. То, что это не просто массив символов, а именно
особый тип данных, подчеркивается двумя обстоятельствами. Во-первых, в
языке имеется способ изображения строк в виде последовательности символов,
заключенной в двойные кавычки, во-вторых, имеется пакет стандартных
функций обработки строк, причем эти функции работают именно с
вышеописанным способом представления строк в виде массива символов,
заканчивающегося символом с нулевым кодом.
Язык C++ унаследовал от С такой способ представления строк, однако сразу
же было очевидно, что кроме этого базового способа необходимо иметь еще
класс или классы, с помощью которых можно было бы более свободно
работать со строковыми объектами. К сожалению, в настоящее время в разных
реализациях языка C++ существует много разных стандартов представления
классов строк, противоречащих друг другу. Широкую известность имеют
класс string библиотеки стандартных шаблонов и классов STL, класс cstring
библиотеки MFC фирмы Microsoft, класс TString, использующийся в
реализациях от фирмы Borland, и многие другие.
Одной из основных причин, почему пользоваться строками в том
представлении, которое они имеют в языке С, неудобно — это то, что свободная
работа со строками как с массивами символов легко может вызвать ошибки.
Действительно, рассмотрим следующий небольшой фрагмент программы:
char * strl = "Fleur-de-Lys";
char * str2 = strl;
strcpy(strl, "Esmeralda");
cout « str2 « endl;
Нет ничего удивительного в том, что в выходном потоке появится строка
Esmeralda, несмотря на то, что в тот момент, когда переменной str2 было
присвоено значение, этим значением была строка "Fleur-de-Lys", и эта
переменная не меняла своего значения. Просто эти переменные указывают на
одну и ту же область памяти (один и тот же массив символов!), и эта область
памяти была переписана в тот момент, когда изменялось содержимое
переменной strl с помощью стандартной функции копирования строк strcpy.
Гораздо удивительнее то, что если вслед за этим фрагментом программы
записать следующие строки:
char * str3 = "Fleur-de-Lys";
cout « str3 « endl;
Обработка текстов
189
то в выходном потоке может также оказаться строка Esmeralda! Это может
произойти потому, что многие компиляторы оптимизируют память, занятую
постоянными объектами, в частности изображениями строк, и второе
вхождение такой же строки не приведет к созданию нового объекта. Вместо
памяти под две одинаковые строки будет отведена память лишь под одну строку,
ту самую, которая и была испорчена вызовом функции strcpy.
Строго говоря, такое использование строк некорректно, однако не всякий
компилятор распознает его как некорректное использование констант, и не
всякая система программирования зафиксирует ошибку исполнения во время
работы такой программы. В частности, компилятор фирмы Delorie Software,
использованный нами при подготовке примеров книги и содержащийся на
приложенном к книге компакт-диске, ведет себя именно так, как было
описано. Да и многие другие компиляторы при включении опции экономии памяти
тоже продемонстрируют только что описанный эффект подмены константной
строки. Чтобы убедиться в этом, попробуйте, используя различные
компиляторы, запустить программу, текст которой приведен на компакт-диске в
папке "\Chapter3\3.1\StrangeStrings".
Часто хочется, чтобы при любом присваивании строк происходило бы
копирование строки-источника, и именно эта причина, по-видимому, является
основной для появления попыток стандартизации различных классов,
представляющих строки. Попутно описания таких классов решают и другие
немаловажные задачи: определение большого числа удобных операций обработки
строк, удобные и незаметные для пользователя системы отведения памяти
под строки и т. д. Рассмотрим некоторые способы реализации строк в виде
классов.
Во всех случаях реализации строки в виде массива символов обычно
используют один из двух способов внутреннего представления строки:
представление с хранимой длиной и представление с символом-терминатором.
В представлении с хранимой длиной в дополнение к элементам строки —
символам, составляющим строку, — в памяти, отведенной под строку,
хранится длина этого массива— количество символов в строке. Такой способ
представления позволяет отделить информацию о символах строки от
информации о длине массива, в котором эти символы хранятся. Так, например,
в языках Turbo Pascal и его потомках (Borland Pascal, Object Pascal),
стандартной реализацией коротких строк является массив из 256 элементов.
В первом элементе массива хранится длина строки, содержащейся в нем.
Таким образом, один и тот же массив можно использовать для хранения в нем
строк различной длины (до 255 символов).
Вообще, в языке Turbo Pascal строка может рассматриваться не только как
самостоятельный тип данных, но и как обычный массив символов. Таким
образом, в этом языке для строк нарушается принцип работы с типами как
190
Глава 3
с абстрактными типами данных. Программа может не только использовать те
операции, которые были специально определены для строк, но также
напрямую использовать представление строки. Так, например, для определения
длины строки в языке имеется стандартная функция length, так что если
значение строки message было задано оператором присваивания:
message := 'hello, world';
то значение ее длины lenMsg может быть вычислено с помощью оператора
присваивания:
lenMsg := length(message);
Однако то же самое значение может быть получено и с помощью обращения
непосредственно к представлению строки в виде массива символов, если
только программист знает, что фактически длина строки хранится в нулевом
элементе этого массива в виде символа с кодом, задающим эту длину:
lenMsg := ord(message[0]);
Здесь функция ord используется для формального преобразования элемента
строки от символьного типа к типу целого значения.
Аналогично можно использовать непосредственный доступ к символам,
составляющим строку. Собственно говоря, индексация массива является
единственным способом доступа к элементам строки. Так, например, для того,
чтобы заменить первый символ в строке message, можно использовать
следующий оператор присваивания:
message[1] := 'Hf;
Однако для добавления восклицательного знака в конец строки было бы
ошибкой воспользоваться оператором:
message[length(message) + 1] := '!';
поскольку в этом случае длина строки не увеличивается автоматически. Здесь
лучше всего было бы воспользоваться какой-либо из стандартных операций
над строками, например функцией соединения строк:
message := concat(message, '!');
В действительности можно использовать и внутреннее представление строки
в виде массива символов, только делать это надо чуть более аккуратно, явно
увеличивая длину строки:
message[0] := succ(message[0]);
message[length(message)] :='!';
Здесь функция succ использована для получения символа со следующим
кодом, т. е. фактически для увеличения длины строки на единицу.
Обработка текстов
191
Другим стандартным способом представления строк является представление
с символом-терминатором, как это принято, например, в языке С. В этом
представлении длина строки не хранится, вместо этого в качестве конечного
символа в строке используется специальный символ (в С — это символ с
нулевым кодом). Этот способ тоже позволяет в массиве длины п хранить
строки длиной от 0 до {п-\) символа. Он не позволяет быстро выдавать
информацию о длине строки, операции с концом строки (например, добавление
символа в конец строки) также выполняются достаточно долго (точнее, за время,
пропорциональное длине строки). Однако такое представление имеет и ряд
преимуществ, позволяя быстро "разрезать" строку на части вставкой внутрь
нее нескольких дополнительных символов-терминаторов, рассматривать
часть массива, содержащую последние символы строки, как самостоятельную
строку и т. п. Кроме того, длина строки теперь ограничена только
максимальной длиной массивов, хотя, конечно, чем длиннее строка, тем дольше
будут выполняться операции с ней.
В случае представления строки в виде массива с символом-терминатором
можно работать со строкой не только как с абстрактным типом данных,
используя специально определенные операции над строкой, но и просто как
с массивом символов. Например, если строка в языке С задана с помощью
следующего описания:
char * message = "Hello, world!";
то убрать последний символ из строки можно с помощью записи символа-
терминатора на место восклицательного знака:
message[strlen(message) - 1] = ' \0';
В этом примере функция strlen использована для вычисления длины строки.
Здесь, в отличие от представления с хранимой длиной, никакой
дополнительной коррекции длины производить не надо.
Многие из недостатков описанных представлений строки можно устранить,
если использовать для работы со строками специально определенный класс,
предоставляющий удобные методы для работы со строками. Объекты этого
класса могут содержать внутри себя строку в одном из описанных
представлений. Если к тому же строка записывается не в массив фиксированной
длины, а в динамический массив (можно использовать технику представления
динамических массивов, описанную в разд. 7.7), то становится возможно
работать даже с достаточно длинными строками, отводя необходимое
количество памяти по мере необходимости.
Именно так — в виде динамического массива — реализовано представление
строк в большинстве стандартных пакетов классов для работы со строками:
класс string библиотеки STL, класс cstring библиотеки MFC и многие
другие.
192
Глава 3
Для большинства программ, в которых работа со строками не является
основным содержанием, вполне достаточно одного из этих способов
представления строк. Однако если основная задача программы состоит в сложной
обработке длинных строк (глобальный анализ текста программы компилятором,
статистическая обработка текста на естественном языке и т. п.), стандартных
средств окажется недостаточно. Основная причина этого— низкая
эффективность программ при обработке строк большой длины из-за необходимости
частого обращения к системе распределения памяти даже при
незначительном увеличении длины строки.
Одним из наиболее простых способов решить эту проблему является
списковое представление строк. В списках память отводится независимо под
отдельные элементы списка, так что добавление новых символов в конец или
середину строки уже не требует заказа памяти под целую строку.
Очевидно, что наиболее гибким, но в то же время и наиболее расточительным
способом такого представления является список отдельных символов строки.
Конечно, это несколько странный способ экономить память — отводить под
каждый символ вместо одного-двух байтов целый объект, имеющий в своем
составе помимо символа, по крайней мере, один указатель. Более экономным
способом отведения памяти будет сосредоточение в одном элементе списка
нескольких символов. Так или иначе, списковое представление окажется
эффективным в случае сильно изменяющихся длинных строк. К сожалению, в
случае списковой реализации мы лишаемся одного из самых главных
преимуществ массивов — простоты доступа к элементам.
На рис. 3.1 изображено представление строки в виде списка элементов,
содержащих по 16 символов (не больше, но, может быть, меньше) каждый.
Помимо самих символов в элементе списка содержится указатель на следующий
элемент и количество символов, хранящихся в элементе списка. На рисунке
приведено одно из возможных представлений строки "На берегу пустынных
волн стоял он, дум великих полн".
ю
Н
1
а
п
б
У
*~
е
с
Р
^
е
^
г
£
W
ю
т
о
ы
л
н
н
н
*~
ы
с
X
т
о
в
я
^
W
IU
л
м
о
£
н
£
w
£
а
д
а
Y
&
W
Л
\о
в
п
е
о
л
л
и
н
к
•
и
а
X
£
£!
Рис. 3.1. Списковое представление строки
В ЛИСТИНГе 3.1 И В папке "\Chapter3\3.1\ListStrings" приложенного KOM-
пакт-диска приведена реализация строки в этом представлении. В состав
реализации включены следующие операции:
П operator [] (index) —доступ к символу строки по заданному индексу;
□ operator = (strSource) —присваивание строк;
Обработка текстов
193
□ substr (start, end)—взятие подстроки;
□ length () — длина строки;
□ operator += (symbol) — добавление символа в конец строки;
□ operator += (string) —добавление строки в конец исходной строки;
□ insert (index, string)—вставка строки в середину исходной строки.
Конечно, этого недостаточно. Необходимо, по крайней мере, определить
операции ввода и вывода строки в последовательный поток, расширить
семантику представленных операций (например, добавить операции вставки символа,
удаления подстроки и т. п.), более тщательно определить номенклатуру
исключительных ситуаций и т. д. Однако приведенного примера достаточно,
чтобы пояснить основное содержание операций над строкой в описанном
представлении, так что читатель может самостоятельно расширить или
изменить определение класса, приспособив его для своих собственных
потребностей.
[Листинг 3.1. Реализация спискового представления отроки
class ListString {
protected:
static const int ITEM_SIZE =16; // максимальный размер элемента
//
// Вспомогательные классы:
// - Stringltem представляет элемент строки,
// содержащий от 1 до 16 символов;
// - SymbolPosition представляет адрес символа внутри
// строки - пару из указателя на элемент и номера
// символа внутри элемента.
//
struct Stringltem {
char symbols[ITEM_SIZE]; // массив символов
Stringltem *next; // указатель на следующий элемент
unsigned short size; // количество символов
//
// Конструктор для создания цепочки элементов строки
// получает строку, режет ее на фрагменты длиной
// ITEM_SIZE, и связывает их в список элементов
//
Stringltem(const char *src, Stringltem *nextltem = NULL);
};
194
Глава 3
struct SymbolPosition {
Stringltem *item; // Элемент строки
unsigned short pos; // Позиция внутри элемента строки
// Простой конструктор
SymbolPosition(Stringltem *i, unsigned short p) : item(i), pos(p) {}
};
// Внутреннее представление строки
Stringltem *first; // Указатель на первый элемент
public:
//
// Конструкторы строк
//
ListString() : first(NULL) {}
ListString(const ListString & src) : first(NULL) {
*this = src;
}
ListString(const char *s) : first (NULL) {
if (s != NULL && *s != 0) {
first = new Stringltem(s) ;
}
}
//
// Деструктор
//
-ListString() { destroy(); }
//
// Объявление основных операций над строками
//
char & operator [] (int index);
char operator [] (int index) const;
ListString & operator = (const ListString & src);
ListString & operator = (const char * src);
ListString substr(int begin, int end = -1) const;
Обработка текстов
195
int length() const;
ListString & operator += (const ListString & src);
ListString & operator += (const char * src);
ListString & operator += (char ch);
ListString & insert (int index, const ListString & src);
ListString & insert (int index, const char * src);
operator char* () const;
protected:
//
// Вспомогательные функции
//
// Освобождает память, занятую элементами строки
void destroy();
// Ищет последний элемент в представлении строки
Stringltem * getLastO const;
// Ищет позицию символа строки с заданным индексом
SymbolPosition findPos(int index) const;
};
//
// Конструктор для создания цепочки элементов строки получает строку,
// режет ее на фрагменты длиной ITEM_SIZE, и связывает их в список.
//
ListString:rStringltem::Stringltem(const char *src, Stringltem *nextltem)
{
// считаем, что строка содержит хотя бы один символ
int len = strlen(src); // len > О
if (len > ITEM_SIZE) {
// Рекурсивный вызов конструктора:
nextItem = new Stringltem (src + ITEMJSIZE, nextItem) ;
}
// Реальная длина строки или ее начального сегмента
size = (len < ITEM_SIZE ? len : ITEMJSIZE);
// Сохранение символов
strncpy(symbols, src, size);
next = nextItern;
}
196
Глава 3
If
// Функция освобождения памяти, занятой
// элементами строки, и опустошения строки
//
void ListString::destroy() {
Stringltem * current = first, // текущий элемент списка
* pred = NULL; // предыдущий элемент списка
// цикл по элементам списка
while (current) {
pred = current;
current = current->next;
delete pred;
}
// обнуление строки
first = NULL;
}
//
// Функция поиска последнего элемента в представлении строки
//
ListString::Stringltem * ListString::getLast() const {
Stringltem * current = first, // текущий элемент списка
* pred = NULL; // предыдущий элемент списка
// цикл по элементам списка
while (current) {
pred = current;
current = current->next;
}
// возврат результата
return pred;
}
//
// Функция поиска позиции символа строки с заданным индексом
//
ListString::SymbolPosition ListString::findPos(int index) const {
if (index < 0) {
throw IndexException("String indexing with index < 0M);
}
// количество символов, которые надо еще отсчитать:
unsigned short curIndex = (unsigned short)index;
// указатель на текущий элемент:
Stringltem * current = first;
Обработка текстов
197
// Цикл по элементам списка
while (current && current->size <= curlndex) {
curlndex -= current->size;
current = current->next;
}
if (current == NULL) { // строка кончилась, а индекс еще не достигнут
throw IndexException("String index greater than string length");
}
// Выдача результата:
return SymbolPosition(current, curlndex);
}
//
// Функция преобразования строки в стандартное представление С
//
ListString::operator char* () const {
// Буфер для записи символов строки:
char * res = new char[length() +1];
// Указатель текущего места для записи символов
char * ptr = res;
// Цикл по элементам строки
for (Stringltem * current = first; current; current = current->next) {
// Запись в буфер символов из очередного элемента
strncpy(ptr, current->symbols, current->size);
ptr += current->size;
}
*ptr = 0;
return res;
// ,_
// Функция вычисления длины строки
//
int ListString::length() const {
int length =0; // переменная для вычисления длины
// Цикл по элементам строки
for (Stringltem * current = first; current; current = current->next) {
length += current->size;
}
return length;
}
198
Глава 3
//
// Операции индексации строки
//
char & ListString::operator [] (int index) {
// Вычисление позиции символа в строке с помощью функции findPos
SymbolPosition sp = findPos(index);
// выдача символа с использованием вычисленной позиции
return sp.item->symbols[sp.pos];
}
char ListString::operator [] (int index) const {
// Вычисление позиции символа в строке с помощью функции findPos
SymbolPosition sp = findPos(index);
// выдача символа с использованием вычисленной позиции
return sp.item->symbols[sp.pos];
}
//
// Операция присваивания с копированием строки
//
ListString & ListString::operator = (const ListString & src) {
// Сначала операция приведения к типу char* скопирует все
// символы строки в символьный буфер, а потом оператор
// присваивания построит новые элементы строки,
destroy();
*this = (char*)src;
}
//
// Операция присваивания с копированием строки
//
ListString & ListString::operator = (const char * src) {
destroy();
if (src && src[0]) {
first = new Stringltem(src);
}
}
//
// Операция выделения подстроки с ее копированием
//
ListString ListString::substr(int begin, int end) const {
// Проверяем правильность задания аргументов
int len = length();
Обработка текстов
199
if (begin < О I I begin > len) {
throw IndexException("Wrong start index in substr");
} else if (end > len || end < begin) {
end = len;
}
// Цикл копирования символов строки в промежуточный буфер
int leftSymbols = end - begin;
// Сначала проверяем, надо ли вообще что-то копировать
if (leftSymbols == 0) return ListStringO;
// Находим начальную позицию для копирования и организуем буфер
SymbolPosition startPos = findPos(begin);
char * buffer = new char[leftSymbols + 1] ;
char * ptr = buffer;
// Цикл продолжается, пока еще есть что копировать
while (leftSymbols) {
// Вычисляем, сколько символов можно взять из текущего элемента
int lenToCopy = startPos.item->size - startPos.pos;
if (leftSymbols < lenToCopy) lenToCopy = leftSymbols;
// Копируем символы
strncpy(ptr, startPos.item->symbols + startPos.pos, lenToCopy);
ptr += lenToCopy;
leftSymbols -= lenToCopy;
// Продвигаемся к следующему элементу
startPos.item = startPos.item->next;
startPos.pos = 0;
}
// Записываем в буфер маркер конца строки
*ptr = 0;
// Создаем новую строку из символов, записанных в буфер
ListString result(buffer);
// Уничтожаем буфер
delete buffer;
// Возвращаем результат
return result;
}
200
Глава 3
/,
// Добавление символа в конец строки
//
ListString & ListString::operator += (char ch) {
Stringltem * last = getLastO; // последний элемент списка
if (last == NULL) {
// Новый элемент становится первым и единственным в списке
first = new Stringltem(&ch);
} else if (last->size < ITEM_SIZE) {
// Символ просто добавляется в последний элемент списка
last->symbols[last->size++] = ch;
} else {
// Новый элемент присоединяется к концу списка
last->next = new Stringltem(&ch);
}
return *this;
}
//
// Добавление нового списка элементов в конец строки
// ВНИМАНИЕ: элементы исходной строки не копируются!
//_
ListString & ListString::operator += (const ListString & src) {
Stringltem * last = getLastO; // последний элемент списка
if (last == NULL) {
// Копируется указатель на первый элемент списка
first = src.first;
} else {
// Новые элементы присоединяются в конец списка
last->next = src.first;
}
return *this;
}
//
// Добавление новых символов в конец строки
//
ListString & ListString::operator += (const char * src) {
return *this += *(new ListString(src));
}
//
// Вставка элементов строки внутрь данной
//
ListString & ListString::insert (int index, const ListString & src) {
Обработка текстов
201
if (index < 0) {
throw IndexException("insert: negative insertion index");
}
// Определяем позицию элемента с заданным индексом
// примерно так же, как в операции findPos
Stringltem * current = first; // указатель на текущий элемент
Stringltem * pred = NULL; // указатель на предыдущий элемент
int curlndex = index; // переменная для отсчета символов
while (current && current->size <= curlndex) {
curlndex -= current->size;
pred = current;
current = current->next;
}
// проверка правильности задания индекса
if (current == NULL && curlndex != 0) {
throw IndexException("insert: insertion index is too big");
}
if (src.first = NULL) {
// Собственно, и вставлять-то было нечего!
//Но правильность индекса надо было проверить все равно
return * this;
}
// Последний элемент вставляемого фрагмента:
Stringltem * lastlnSource = src.getLast();
if (curlndex > 0) {
// Найденный элемент надо разрезать на две части.
// Формируем вторую из этих частей
char * buffer = new char[current->size - curlndex + 1];
strncpy(buffer, current->symbols +curlndex, current->size -curlndex);
buffer[current->size - curlndex] = 0;
Stringltem * newltem = new Stringltem(buffer, current->next);
// Теперь формируем длину первой части
current->size = (unsigned short)curlndex;
// Вставляем новый участок списка между этими двумя частями
current->next = newltem;
pred = current;
current = newltem;
}
// Окончательное формирование указателей в списке:
if (pred == NULL) {
first = src.first;
202
Глава 3
} else {
pred->next = src.first;
}
lastInSource->next = current;
}
//
// Вставка строки в стандартном представлении внутрь данной
//
ListString & ListString::insert (int index, const char * src) {
insert(index, *(new ListString(src)) );
}
В приведенной реализации имеются два существенных недостатка.
Во-первых, в процессе обработки строк время от времени происходит
дробление элементов списка, представляющего строку. Например, такое
дробление происходит во время выполнения операции insert, т. е. при вставке в
строку другой строки. Вообще, если имеется потребность в сложной
обработке длинных строк, то почти наверняка будут реализованы и другие
операции, которые выделяют части исходной строки и осуществляют их
перестановку, копирование, удаление и т. п. Во всех этих случаях неизбежно будет
происходить дробление элементов списка. Наоборот, нигде в реализации нет
слияния соседних элементов списка, даже если такое слияние можно
осуществить, т. е. если суммарное количество символов, содержащихся в двух
соседних элементах списка, не превосходит максимально возможного.
Такое дробление строки постепенно приводит к неоправданно большим
расходам памяти. Чтобы избежать этого, нужно время от времени производить
слияние соседних элементов списка там, где это возможно. Например, в
реализации, приведенной в листинге 3.1, можно вставить операцию слияния
соседних элементов списка в функции, осуществляющие просмотр списка.
Некоторый эффект может быть достигнут, даже если такое слияние добавить
в какую-нибудь одну достаточно часто работающую функцию, например в
функцию getLasto поиска последнего элемента списка. Вот как будет
выглядеть тогда эта функция:
//
// Функция поиска последнего элемента в представлении строки
//
ListString::StringItem * ListString:rgetLast() const {
Stringltem * current = first, // текущий элемент списка
* pred = NULL; // предыдущий элемент списка
Обработка текстов
203
// цикл по элементам списка
while (current) {
pred = current;
current = current->next;
// Проверим, нельзя ли осуществить слияние элементов pred и current
while (current && pred->size + current->size <= ITEMJSIZE) {
// копирование символов
strncpy(pred->synibols + pred->size, current->symbols,
current->size);
// коррекция элемента pred
pred->size += current->size;
pred->next = current->next;
Stringltem *toDelete = current;
current = current->next;
// Уничтожение лишнего элемента
delete toDelete;
}
}
// возврат результата
return pred;
}
Второй недостаток реализации заключается в том, что при исполнении
некоторых операций изменяется не только та строка, к которой применяется
операция, но могут быть также разрушены строки, передаваемые в эти операции
в качестве аргументов. Так, например, при вставке в строку strl некоторой
другой строки str2 структура строки str2 нарушается, так что использовать
эту переменную во второй раз не удастся. Это достаточно неприятно, хотя в
некоторых случаях и оправдано. Еще хуже то, что, например, после
применения оператора strl += str2, хотя формально строка str2 и не изменяется, но
использовать ее повторно опасно, т. к. элементы списка, составляющего этот
аргумент, после выполнения операции войдут в состав другой строки.
Рассмотрим, как выполняется вызов этой операции в реализации оператора
ListString & operator += (const char * src):
ListString & ListString: .-operator += (const char * src) {
return *this += *(new ListString(src));
}
Здесь нельзя было бы записать тело оператора в более простом виде:
return *this += ListString(src);
т. к. созданная конструктором ListString строка при возврате из оператора
будет уничтожена неявно запущенным деструктором, что повлияет и на ту
строку, в состав которой она включается.
204
Глава 3
Единственный надежный способ борьбы с этим недостатком состоит в том,
чтобы копировать аргументы, структура которых может быть нарушена. Это,
однако, может привести к существенному снижению эффективности работы.
Копирование строки может быть легко реализовано в виде отдельной
операции, скажем, сору о. Реализация этой операции предоставляется читателям
в качестве весьма полезного и несложного упражнения.
Еще один способ представления строк состоит в том, чтобы вообще не
хранить в элементах списка символы, составляющие строку. Часто встречаются
ситуации, когда обработке подлежат только строки, заданные с самого начала
константами или введенные с внешнего устройства. Можно сказать, что в
этом случае обработка строк состоит в перестановке и, может быть,
копировании фрагментов, которые имелись в памяти с самого начала работы
программы. В этом случае можно поместить все символы всех обрабатываемых
строк в некоторый буфер (пул памяти), а для представления строк
использовать указатели на этот пул.
Пусть, например, в программе на языке C++ имеется следующий фрагмент,
формирующий строку из некоторой заданной (используем представление
строк в виде объектов типа string библиотеки STL):
string source = "We all live in a yellow submarine!";
// сформируем строку ", yellow submarine",...
string submarine = "," + source.substr(16, 17);
// ...вставим ее два раза внутрь исходной строки,...
source.insert(33, submarine).insert(33, submarine);
cout « source;
// ...напечатается строка:
// We all live in a yellow submarine, yellow submarine, yellow submarine!
Результирующая строка получилась из двух исходных строк (source и ",")
путем вырезок фрагментов и вставок. Если использовать представление строк
в виде списка элементов, содержащих ссылки в буферный пул, то исходные
строковые константы должны быть помещены в пул, а все операции,
работающие с фрагментами строк, будут на самом деле производиться только с
элементами соответствующих списков.
На рис. 3.2 изображено возможное содержимое буферного пула (рис. 3.2, а),
числа над символами в пуле показывают их индексы в массиве и
представление строки source в начале работы (рис. 3.2, б) и в конце работы после всех
вставок (рис. 3.2, в). Чтобы не загромождать рисунок, указатели в буферный
пул представлены в элементах списка парами чисел, первое из которых
задает индекс символа в пуле, а второе — длину фрагмента строки. Собственно
говоря, в программе эти указатели могут быть представлены точно так же.
Обработка текстов
205
Это удобно, и к тому же позволяет полностью абстрагироваться от
содержимого пула, обращаясь к нему только в том случае, когда явно требуются
символы, например, при выводе строки в выходной поток. Для удобства около
каждого элемента указано, какой фрагмент строки он представляет.
0 2 4 6 8 10 12 14 16 18 20 22 24 26 28 30 32 34
|W|e| |a| 11 11 ll|i|v|e| |i|n| |a| |y|e| 11 l|o|w| |s|u|b|m|a|r|i|n|e| !| , |
буферный пул
а)
dest:
[о
34
б)
dest
I °
2
nWe all live \ir
i a
34
1
he
4Г yeJ
/ SUbltlc
_£J
17
low \
irine" V
34j
1
/
i
16
17
yellow \
ubmarine" \
1
| 33
1
в)
Рис. З.2. Представление строк с помощью указателей на буферный пул
Описанное представление, несмотря на некоторые преимущества, обладает
также и рядом недостатков. Несомненным достоинством является тот факт,
что отдельный элемент списка может описывать сколь угодно длинный
участок строки. Однако, как и в предыдущем представлении, при работе с
фрагментами строк элементы списка будут описывать все более мелкие
фрагменты, так что эффективность работы со строкой будет снижаться. Конечно,
можно попробовать и здесь время от времени пытаться соединить соседние
элементы списка в один, но в данном случае это не так просто, поскольку
соединить удастся только такие фрагменты строк, которые физически
расположены друг за другом в буферном пуле.
Несложно определить класс, реализующий строку в описанном
представлении. Многие операции в нем. будут похожи на соответствующие операции
класса Liststring, описанного в листинге 3.1. Различия в основном сводятся
к тому, что вместо манипулирования с символами фрагментов строк работа
производится только с их индексами в пуле. Конечно, требует отдельного
представления и сам символьный пул.
Подобное представление возможно только в том случае, если сами элементы
строк не меняются, другими словами, если не предоставляется операций,
206
Глава 3
с помощью которых можно изменить содержимое строки, не меняя ее
расположение в памяти. В противном случае возможны эффекты, подобные
описанному в самом начале главы, когда изменение одной строковой константы
неожиданно повлияет на значение другой константы.
В дальнейшем в книге представления строк, подобные Liststring, нигде не
используются. В тех примерах, где производится работа со строками, чаще
всего используются стандартные строки языка C++, заданные классом string
библиотеки стандартных шаблонов и классов STL, либо массивы символов
char[j. Однако следует иметь в виду, что в случае длинных текстов такое
представление может оказаться неэффективным, и тогда лучше выбрать одно
из предложенных представлений или разработать свое собственное
представление, наиболее подходящее для задачи.
3.2. Хеширование и поиск в хеш-таблицах
В предыдущем разделе речь шла о представлении строк в случае, когда
требуется работать с длинными строками и их фрагментами. Довольно часто
встречаются также ситуации, когда обработке подлежат много маленьких
строк — слое, которые тоже надо сохранять в некоторой единой структуре —
словаре. Сами слова не требуют для своего представления сложной
структуры: для их представления вполне достаточно стандартных способов описания
строк. Однако для словаря необходимо выбрать такое представление, которое
бы обеспечило максимальную эффективность выполнения основной
операции над словарем — поиска слова в словаре.
Самым простым представлением для словаря будет, конечно, массив слов.
Для обеспечения эффективности поиска можно обеспечить упорядоченность
элементов этого массива, тогда поиск слов можно производить с помощью
алгоритма двоичного поиска, описанного в разд. 2.2, который обеспечивает
нахождение нужного слова среди множества из п слов за количество
операций, пропорциональное log2 п. К сожалению, эффективность работы со
словарем, организованным таким образом, резко снижается, когда требуется
добавлять в него новые слова или удалять имеющиеся. Каждая вставка или
удаление слова требуют сдвига в среднем около половины всех имеющихся в
массиве слов. Кроме того, при вставке слов возникает уже знакомая нам
проблема отведения новой памяти, если первоначально отведенной под словарь
памяти не хватает для размещения новых элементов.
Одним из способов решения этой проблемы является использование так
называемой функции расстановки или хеширования. Слово хеширование
происходит от английского hash, означающего "крошить", "резать на кусочки", и
отражает характер работы этой функции.
Обработка текстов
207
Основная идея применения функции расстановки состоит в следующем.
Словарь будем по-прежнему представлять в виде массива слов, но помещать
слова в него будем не в соответствии с алфавитным порядком, а в соответствии
со значениями некоторой простой функции, вычисленной над словом. Такая
функция — функция расстановки, — получая в качестве аргумента некоторое
слово, выдает в качестве результата некоторое целое число — индекс в
словаре, под которым следует хранить это слово. Если каждому слову будет
соответствовать свое значение функции расстановки, то поиск в словаре
становится ненужным. Вместо поиска осуществляется вычисление значения
функции расстановки, после чего слово находится сразу же по вычисленному
индексу.
К сожалению, на практике оказывается невозможным определить функцию
расстановки так, чтобы каждому слову соответствовало свое уникальное
значение индекса. Это приводит к тому, что два или более слов получают одно и
то же значение функции расстановки. Говорят, что в этом случае происходит
конфликт хеш-индексов. Один из самых простых способов разрешения этого
конфликта состоит в том, чтобы помещать слово, вызвавшее конфликт в один
из соседних элементов массива. Тогда поиск и вставка новых слов будет
производиться хотя и не за один шаг, но все же достаточно быстро.
Естественным требованием для функции расстановки будет требование
простоты ее вычисления. Но это далеко не единственное, что требуется от
хорошей функции расстановки. Прежде всего, надо обеспечить равномерное
распределение вычисленных индексов слов в словаре. Например, для обработки
слов русского языка функция, выдающая по заданному слову номер его
первой буквы в алфавите — это не очень удачный способ определения функции
расстановки. Во-первых, слова русского языка далеко не равномерно
распределяются по первым буквам. Некоторые буквы вообще не могут быть
первыми в слове, другие, как, например, "С", наоборот, очень часто бывают
первыми буквами. Во-вторых, к этой неравномерности обычно добавляется еще и
упорядоченность, присущая алгоритмам обработки текстов. Например, при
записи некоторого словаря довольно естественной будет ситуация, при
которой исходный поток слов уже как-то упорядочен. В такой ситуации лучше
всего было бы, чтобы даже незначительно отличающиеся друг от друга слова
имели бы существенно различные значения функции расстановки. Для этого,
по крайней мере, нужно, чтобы значение функции расстановки зависело не от
одной или нескольких символов слова, а от всех.
Пусть функция code (с) по заданной букве выдает ее номер в латинском
алфавите (если символ не является буквой латинского алфавита, то будем считать,
что функция code возвращает значение 0). Вот как, например, может
выглядеть эта функция на языке C++:
208
Глава 3
int code(char с) {
return strchr("abcdefghijklmnopqrstuvwxyz", c) + 1;
}
На самом деле, на практике обычно просто используют внутренний код
каждого символа, так что фактически в использовании функции code нет
необходимости.
Теперь можно использовать эту функцию как базовую для определения
функции расстановки. Прежде всего, обеспечим зависимость значения
функции расстановки от всех символов слова. Для этого сложим коды всех букв.
Дополнительно, для того чтобы позиция буквы в слове также играла
некоторую роль, будем добавлять к коду каждой буквы номер ее позиции. Далее
решим, словарь какого размера нам потребуется. В конечном итоге значение
функции хеширования должно получиться достаточно равномерно
распределенным на множестве всех обрабатываемых слов, и попадать в диапазон
[О, «-1], где п — размер словаря. Достаточно равномерное распределение
получается, если вычислять индекс по формуле (Р*Х+ Q) % п, где числа Р и
Q — это некоторые простые числа, по порядку близкие к п, X— вычисленная
сумма кодов букв слова, а символ % задает операцию вычисления остатка от
целочисленного деления. Если, например, считать, что словаря в 1000 слов
будет достаточно для наших целей, то можно выбрать для Р и Q значения 557
и 811, и тогда функция расстановки примет следующий вид:
int hash(const string & str) {
int sum = 0;
for (int i = 0; i < str.length(); i++) {
sum += code(str[i]) + i;
}
return (557 * sum + 811) % 1000;
}
Теперь можно задать словарь (листинг 3.2), в котором будет храниться
некоторое множество слов. Мы определим функцию добавления слова в словарь и
функцию, выясняющую, содержит ли словарь заданное слово. В обеих
функциях слово (или место для этого слова) ищется схожим образом: по слову
вычисляется значение функции расстановки, затем происходит перебор позиций
слов в массиве, предназначенном для их хранения, до тех пор, пока либо
искомое слово не будет найдено, либо не встретится пустое значение.
Если слова не удаляются из словаря, а только добавляются в него, то такой
алгоритм поиска будет корректным, хотя при большой загруженности
словаря поиск может осуществляться достаточно долго. В предельном случае,
когда все позиции в словаре заняты, при поиске слова может оказаться
необходимым перебрать все слова.
Обработка текстов
209
I Листинг 3.2. Определение простого словаря
// dictionary.h
// Класс, представляющий словарь в виде хеш-таблицы
class HashDictionary {
private :
static const int P = 557;
static const int Q = 811;
static const int LENGTH = 1000;
string *dict[1000]; // хранилище на 1000 слов
/***** **************************************** *********
* Функция определяет код буквы как ее
* порядковый номер в латинском алфавите
******************************************************/
static int code(const char с);
public :
/******************************************************
* Функция расстановки, основанная на сложении кодов
* букв со смещением, соответствующим их позиции
******************************************************/
static int hash(const string & str);
private :
/***********•******************************************
* Внутренняя функция поиска позиции слова в словаре
******************************************************/
int findPos(const string & word) const;
public :
/******************************************************
* Конструктор записывает в словарь пустые ссылки
******************************************************/
HashDictionary() { memset(dict, 0, sizeof(diet)); }
210
Глава 3
/kkiekkkkkkkkkkkicickk-kkk-kk-kkkkkk-kkkkk-k-kkkk-kkk-kk-k-kkkkk-kkkk
* Функция добавления слова в словарь. Если слово уже было
* в словаре, то второй раз оно в словарь не попадает
kkkk-kkkkkk-kkkkk-kk-kkkk-kk-kkk-kkk-kkk-kk-kkk-k-kkkkk + kk-k-kk-kk-kkk/
void add(const string & word);
/kk-kkk-kk-kk-kkkk-kkkkkk-k-k-kkkk-k-k-kkkkkkk-kkkkk-kkkkkk-k-kkk-k-kkkk
* Функция проверки наличия слова в словаре
****к-к*кккккк-к*ккккк-ккк-к-к-к-ккк*кк*кк*кк**к*ккк-к-к**-кк-к-кк/
bool hasWord(const string & word) const;
};
// dictionary.cpp
//zSBSsaaBSBSSSSSISSS^^
// Реализация функций
int HashDictionary::code(char c) {
return strchr("abcdefghijklmnopqrstuvwxyz", c) + 1;
}
int HashDictionary::hash(const string & str) {
int sum = 0;
for (int i = 0; i < str.length(); i++) {
sum += code(str[i]) + i;
}
return (P * sum + Q) % LENGTH;
}
int HashDictionary::findPos(const string & word) const {
int i = hash(word); // текущий индекс слова в словаре
for (int counter = 0; counter < LENGTH; counter++) {
if (dict[i] = NULL || *dict[i] == word) {
return i; // слово или позиция для его вставки найдено
} else if (++i = LENGTH) {
i = 0; // переход к следующему индексу
}
}
return -l; // перебраны все позиции в словаре!
}
void HashDictionary::add(const string & word) {
int i = findPos(word); // позиция слова в словаре
if (i == -1) return; // только в случае переполнения словаря!
if (dict[i] == NULL) dict[i] = new string();
Обработка текстов
211
*dict[i] = word; // добавление слова или его перезапись
}
bool HashDictionary::hasWord(const string & word) const {
int i = findPos(word); // позиция слова в словаре
return i != -1 && dict[i] != NULL;
}
Приведем пример использования класса HashDictionary для решения
следующей задачи (листинг 3.3). Пусть задано некоторое предложение на
английском языке. Требуется проверить, есть ли в нем слова, встречающиеся, по
крайней мере, дважды. Для решения этой задачи словарь, определенный
классом HashDictionary, будет достаточно удобным способом представления
множества слов в предложении. Действительно, мы можем исследовать
заданное предложение слово за словом, добавляя каждый раз слово в словарь.
Перед добавлением слова будем проверять, не встречалось ли уже это слово
в предложении раньше.
Для перебора слов определим итератор, который будет выдавать слова из
предложения по очереди слово за словом. Итератор возьмет исходное
предложение, разделит его на слова, вызвав стандартную функцию языка С
strtok, и, используя ту же функцию, будет последовательно выдавать
указатели на слова этого предложения.
Функция twice решает поставленную задачу и выдает слово, встретившееся
в предложении второй раз. Если все слова в предложении различны, функция
выдает в качестве результата пустое слово.
[^истингЖз. Поиск слова в предложении, встречающегося в нем
[болееодного раза ';/;Ч .-"■/:'"' t '* • : •
&;.;.,;....:; ; ....... :^::.:..;..;.;..: .- :....:. .-. .; •
// Класс WordsIterator представляет собой простой итератор слов,
// на которые делит заданный текст стандартная функция С strtok.
// Словом считается любая последовательность символов, отделенная
//от других слов разделителями: знаками препинания, пробелами и пр.
class WordsIterator {
private :
char * words; // Для хранения и обработки исходного текста
char * nextWord; // Указатель на очередное слово
const char * delimeters; // Массив символов-разделителей
212
Глава 3
public :
//
// Конструктор
//
WordsIterator(const char * phrase, // исходное предложение
const char * delim = ",.!?;:-() V\"\n\t\f") {
// Исходный текст сохраняется в буфере и обрабатывается
// стандартной функцией strtok.
words = new charfstrlen(phrase)+1];
strcpy(words/ phrase);
nextWord = strtok(words, delimeters = delim);
}
//
// Функция проверки наличия следующего слова
//
bool hasMoreElements () const { return nextWord != NULL; }
//
// Сдвиг итератора на слово вперед
//
Wordslterator & operator ++() {
nextWord = strtok(NULL, delimeters);
return *this;
}
//
// Доступ к очередному слову
//
char * operator *() const { return nextWord; }
};
/I Функция twice ищет в заданном предложении слово, встречающееся
// более одного раза. Если такое слово есть - функция его выдает,
// если такого слова нет - функция выдает пустое слово
//«„««.^^
string twice(const char * text) {
// Определяем словарь для исходного текста
HashDictionary diet;
Обработка текстов
213
II Итератор слов текста будет считать разделителями пробелы,
// знаки препинания, кавычки, скобки и другие пустые символы
for (Wordslterator iterator(text);
iterator.hasMoreElements();
++iterator) {
// Выберем очередное слово и преобразуем его к нижнему регистру букв,
string nextWord = strlwr(^iterator);
if (diet.hasWord(nextWord)) {
return nextWord; // слово уже встречалось
} else {
diet.add(nextWord); // добавляем новое слово
}
}
return string(); II в предложении нет одинаковых слов
}
Например, следующий вызов этой функции:
cout « twice("It was many and many a year ago") « endl;
приведет к тому, что будет напечатано слово many.
Вернемся еще раз к названиям функция расстановки и функция хеширования.
Первое название отражает цель использования функции — вычисление
некоторого индекса, с помощью которого можно расставить слова в словаре.
Второе название отражает метод, с помощью которого этот индекс
вычисляется— разрезание слова на части и перемешивание получившихся кусочков.
Дословно глагол hash можно перевести с английского языка как "крошить", а
в качестве существительного это слово можно перевести как "путаница",
"перефразирование" или даже "мясная окрошка".
Надо сказать, что выбранный нами в вышеизложенном примере метод
разрешения конфликтов при использовании функции расстановки не очень удобен.
Во-первых, как уже было упомянуто, при большой загруженности словаря
вся выгода, полученная от быстрого вычисления значения хеш-функции,
может свестись на нет, и придется перебирать значительную часть слов в
словаре как при линейном поиске. Во-вторых, если требуется не только добавлять,
но и удалять слова из словаря, метод оказывается вовсе неприемлемым,
поскольку дырки в словаре могут прервать поиск несмотря на то, что нужное
слово все-таки в нем содержится. Ликвидация же дырок— крайне
трудоемкий процесс.
Чаще используется другой метод разрешения конфликтов. Кроме основного
массива, содержащего слова, в словаре организуется так называемая область
переполнения, куда и помещаются все слова при возникновении конфликтов.
214
Глава 3
Чаще всего область переполнения организуется в виде списков слов с
одинаковым значением функции расстановки.
Еще одно важное замечание. Обычно требуется хранить слова не сами по
себе, а связывать с этими словами некоторую информацию, например
количество этих слов в тексте, или другую информацию, зависящую от задачи. Так,
например, если в компиляторе требуется организовать словарь из всех
используемых в тексте программы идентификаторов, то с каждым
идентификатором можно связать информацию о его типе, локализации и т. д. Поэтому
чаще всего словари (которые еще называют хеш-таблицами) содержат не
просто слова, а пары, в которых с каждым словом связан некоторый
дополнительный объект. В этом случае слово, по которому происходит поиск,
называют ключом поиска, а соответствующий объект — значением, связанным
с этим ключом. По смыслу операции поиска в хеш-таблице каждый ключ
находится в таблице в единственном экземпляре, однако несколько разных
ключей могут иметь одно и то же значение функции расстановки.
В листинге 3.4 приведена еще реализация хеш-таблицы, в которой с каждым
словом (ключом) связан некоторый объект, а конфликты разрешаются путем
организации списков для каждого значения ключа. Сама функция
расстановки тоже несколько модифицирована и сделана более универсальной: в
качестве кодов букв выбираются внутренние коды символов.
Обе представленные в тексте реализации словарей имеются на
приложенном КОМПаКТ-ДИСКе В папке "\Chapter3\3.2\HashDictionary" В файлах
"dictionary.h", "dictionary.cpp" И "hashtable.h". Очень полезно было бы
попробовать использовать приведенные реализации для сравнения их
эффективности при различном количестве обрабатываемых слов и разных размерах
словарей.
\Листинг3.4^ Рейй^цияхеш^^йць^л% / > -^> Vy' y'/;v -: '-.'*jc', /:; '';.; /;* 1^к>\
// Класс, представляющий хеш-таблицу пар (ключ, значение), причем
// ключом является строка, а значением может быть произвольный объект.
//В таблице хранятся не сами эти объекты, а ссылки на них.
//=======================:^====^==
template <class Object>
class HashTable {
private :
static const int P = 557;
static const int Q = 811;
static const int LENGTH = 1000;
Обработка текстов
215
// Элемент списка, содержащий ключ и связанный с ним объект
//assassSKgBSasssssKSg^^
struct ListElem {
string key; // ключ
Object *obj; // связанный объект
ListElem *next; // ссылка на следующий элемент
// Простой конструктор для создания элементов списка
ListElem(const string & k, Object *o, ListElem *n) {
key = k; obj = o; next = n;
}
};
// Массив списков, содержащих слова словаря
ListElem * diet[LENGTH];
public :
// Конструктор
//=====_=============^^
HashTableO { memset(diet, 0, sizeof(diet)); }
// Функция расстановки, основанная на сложении кодов символов
//==============================================================
static int hash(const char * str);
// Функция добавления нового объекта по ключу. Если объект,
// связанный с этим ключом, уже содержится в словаре,
//то новый объект замещает собою старый, а старое значение
// возвращается в качестве результата работы функции.
7/===============^==================================^========
Object * add(const char * key, Object * obj);
/7===================^^^
// Функция поиска объекта по ключу. Если ключ не найден,
//то функция возвращает пустую ссылку
/7==========================^===============^===============
Object * find(const char * key) const;
216
Глава 3
// Функция удаления объекта по заданному ключу.
// Результат функции - указатель на удаляемый объект.
// Если ключ не найден, то функция возвращает пустую ссылку
//===========^=-=========~=====^==-=================
Object * remove(const char * key);
};
template <class Object>
int HashTable<Object>::hash(const char * str) {
int sum = 0;
for (int i = 0; str[i]; i++) {
sum += str[i] + i;
}
return ((P * sum + Q) & 0x7FFF) % LENGTH;
}
template <class Object>
Object * HashTable<Object>::add(const char * key, Object * obj) {
if (key == NULL || key[0] = 0 || obj = NULL) {
throw NullValueException();
}
int index = hash(key); // Значение hash-функции
ListElem *current = diet[index]; // Текущий элемент списка
// Поиск ключа в словаре:
while (current && key != current->key) {
current = current->next;
}
Object * result = NULL;
if (current) { // Ключ уже есть в словаре
result = current->obj;
current->obj = obj;
} else {
// Создаем новый элемент списка и добавляем в начало списка
ListElem * newElem = new ListElem (key, obj, diet [index]) ;
diet[index] = newElem;
}
return result;
}
Обработка текстов
217
template <class Object>
Object * HashTable<Object>::find(const char * key) const {
if (key == NULL I I key[0] == 0) {
throw NullValueException();
}
int index = hash(key); // Значение hash-функции
ListElem *current = diet[index]; // Текущий элемент списка
// Поиск ключа в словаре:
while (current && key != current->key) {
current = current->next;
}
if (current == NULL) return NULL; // Ключ не найден
return current->obj;
}
template <class Object>
Object * HashTable<Object>::remove(const char * key) {
if (key == NULL || key[0] == 0) {
throw NullValueException();
}
int index = hash(key); // Значение hash-функции
ListElem *current = diet[index]; // Текущий элемент списка
ListElem *pred = NULL; // Предыдущий элемент списка
// Поиск ключа в словаре:
while (current && key != current->key) {
pred = current;
current = current->next;
} ,
if (current == NULL) return NULL; // Ключ не найден
// Исключение элемента из списка:
if (pred — NULL) {
diet[index] = current->next;
} else {
pred->next = current->next;
}
// Возврат результата:
Object * result = current->obj;
delete current;
return result;
}
218
Глава 3
С помощью такого определения словаря можно решать самые разнообразные
задачи по обработке текстов. Например, уже упомянутая задача компиляции
может использовать данный словарь для хранения и поиска информации об
идентификаторах, содержащихся в некоторой программе. Правда, для
большинства задач необходимо, по крайней мере, еще несколько функций,
которые выдавали бы информацию обо всем словаре в целом. Нетрудно
определить следующие функции:
int size(); // Количество слов в словаре
Iterator<string> keys(); // Итератор всех ключей словаря
Iterator<Object> objects(); // Итератор всех связанных объектов
Тем не менее у приведенной реализации имеется ряд недостатков. Наиболее
серьезный из них — то, что размер таблицы выбран произвольно, и он никак
не зависит от количества хранящихся в таблице объектов. При небольшом
количестве объектов это приводит к напрасному расходу памяти. Наоборот,
если количество хранимых объектов велико, то списки элементов с одним и
тем же значением функции расстановки для ключей становятся длинными, а
это приводит к сильному замедлению всех основных операций с таблицей.
Лучше всего в таких случаях связывать алгоритм работы функции
расстановки и общую организацию таблицы с количеством объектов, хранящихся в
ней. Например, если общее количество объектов более, чем в два раза
превышает размер массива списков объектов diet, то можно провести так
называемое перехеширование. При этом массив расширяется (например, в два
раза), функция расстановки изменяется в соответствии с новым размером
таблицы, а все слова вместе со связанными с ними объектами перемещаются
в новые списки в соответствии с новыми значениями функции расстановки.
Конечно, перехеширование — это серьезная и длительная операция, но зато
после ее выполнения все операции со словарем начинают работать быстрее.
На самом деле совершенно неважно, являются ли ключами для поиска в хеш-
таблице именно слова. Фактически ключами могут служить любые объекты,
лишь бы для них можно было определить подходящую функцию
расстановки. Поэтому определение шаблона классов HashTabie можно еще обобщить,
параметризовав его еще и типом ключей и считая, что для ключей определена
операция hash, вычисляющая целочисленное значение функции расстановки.
3.3. Словари, представленные списками
и деревьями
Таблица, формируемая с помощью функции расстановки, — это один из
основных способов представления словарей, да и вообще любых других таблиц,
требующих поиска по ключу. Тем не менее это далеко не единственный спо-
Обработка текстов
219
соб организации информации. В программах в зависимости от сложности,
частоты поиска, набора выполняемых операций используется множество
различных способов организации данных, которые чаще всего сводятся к
комбинации списков, массивов и деревьев. В данном разделе мы рассмотрим
несколько способов организации словарей от самых простых, таких как
упорядоченный список ключей, до достаточно сложно организованного бора —
дерева, в котором слова разделены на буквы, и поиск организован в
соответствии с внутренним составом слова.
Организация словаря в виде упорядоченного списка слов не представляет
никакой сложности. Практически все операции, включая вставку в
упорядоченный список, уже были нами рассмотрены в разд. 1.2, поэтому мы просто
приведем пример задачи, в которой упорядоченные списки будут использоваться
для хранения информации о словах некоторого текста. Мы будем
использовать упорядоченный список как абстрактный тип данных, а для этого
определим несколько интерфейсов, в которых будут заданы все основные операции.
Прежде всего, для того чтобы элементы списка можно было упорядочивать,
над его элементами должны быть определены основные операции сравнения.
Мы будем свободно использовать основные операторы сравнения значений
'==', '!=', '<', '>', '<=', '>=' над элементами списка, считая, что эти
операторы определены в виде методов соответствующего класса, или в виде
независимых функций, или, наконец, просто определены языком C++. Разумеется,
это справедливо для встроенных типов int, float, char и т. п., но, кроме того,
такие операции определены также для строк библиотеки STL (string) и для
многих других стандартных классов.
В нашем случае элементами списка выступают не просто слова, которые мы
будем представлять строками, а пары, состоящие из слова и связанного с ним
объекта. Поэтому определим сначала простой класс, представляющий такую
пару и содержащий помимо операции доступа к ключу и связанному с ним
объекту еще и операции сравнения. На самом деле сравнение пар будет
сводиться просто к сравнению ключей. Этот класс приведен в листинге 3.5.
! Листинг 3.5. Класс для представления пар (ключ, значение) *
// Класс Pair представляет элемент списка слов - пару из слова
//и связанного с ним объекта. Для сравнения пар предоставляются
// операторы сравнения, которые фактически сравнивают только
// первые элементы пар - слова.
template <class Object>
220
Глава 3
struct Pair {
string key; // Ключ
Object obj; // Связанный объект
// Конструкторы. Если какие-то аргументы конструктора не заданы,
//то соответствующие компоненты пары получают значения по умолчанию
Pair () {}
Pair(const string & _key) : key(_key) {}
Pair(const string & _key, const Object & __obj)
: key(_key), obj(_obj) {}
Pair(const Pair & pair2) : key(pair2.key), obj(pair2.obj) {}
// Оператор присваивания
Pair<Object> & operator = (const Pair<Object> & src) {
key = src.key; obj = src.obj;
return *this;
}
// Операторы сравнения:
bool operator == (const Pair<Object> & pair2) const {
return key == pair2.key;
}
bool operator < (const Pair<Object> & pair2) const {
return key < pair2.key;
}
// Остальные операторы сравнения - аналогично
};
Теперь определим словарь как упорядоченный список пар, элементами
которого являются объекты класса Pair, причем ключом в каждой паре служит
слово из словаря. Для работы с таким списком нам понадобятся операции
поиска и вставки в упорядоченный список нового объекта. Поскольку работа
операций с упорядоченным списком отличается от работы с
неупорядоченным списком, лучше всего будет определить новый абстрактный тип
данных — упорядоченный список. Этот тип данных, как обычно, можно будет
задать с помощью шаблона классов, похожего на обычный список List<T>,
описанный в конце разд. 1.2, однако в данном случае предполагается, что
элементы списка можно сравнивать друг с другом с помощью стандартных
операторов сравнения значений. Кроме того, некоторые определенные в
интерфейсе List<T> операции, такие как addFirst, addLast, insertBefore И др.,
Обработка текстов
221
для упорядоченных списков не имеют смысла, поскольку вставка новых
элементов в упорядоченный список должна всегда производиться с учетом этой
упорядоченности.
Дополнительно определим операцию поиска find, которая будет производить
поиск в списке первого элемента, значение которого совпадает с заданным.
Определение упорядоченного списка представлено в листинге 3.6 в виде
определения шаблона классов SortedList<Object>.
[Листинг 3.6. Шаблон кла^ - \
//—■——— =^^==^та===^ ^===^=^=========
II Класс SortedList представляет упорядоченный по возрастанию
// список произвольных объектов. Сравнение объектов производится
// согласно предоставляемым ими операторам сравнения объектов
template <class Object>
class SortedList {
private :
// Элемент списка состоит из объекта и ссылки на следующий элемент
struct ListElem {
Object item; // Значение элемента списка
ListElem *next; // Ссылка на следующий элемент
// Простой конструктор
ListElem(const Object & i, ListElem * n = NULL) : item(i), next(n) {}
};
// Список представлен ссылкой на первый элемент...
ListElem * first;
// ...и счетчиком числа элементов
int count;
public :
// Конструкторы
SortedList() : first(NULL), count(0) {}
SortedList(const SortedList & src) : first(NULL), count(0) {
*this = src;
}
// Деструктор
-SortedList() { destroy(); }
222
Глава 3
// число элементов списка
int getCountO { return count; }
// удалить все элементы, равные заданному
bool remove(const Object & item);
// вставить элемент в упорядоченный список
void insert(const Object & item);
// поиск первого элемента, равного заданному
Object * find(const Object & item);
private :
void destroy();
};
// Реализация методов для работы со списком
// удалить все элементы, равные заданному
template <class Object>
bool SortedList<Object>::remove(const Object & item) {
ListElem ** current = &first;
while (^current && item > (^current)->item) {
current = &(^current)->next;
}
bool deleted = false;
while (^current && item == (*current)->item) {
ListElem *toDelete = *current;
^current = (^current)->next;
delete toDelete;
deleted = true;
count—;
}
return deleted;
}
// вставить элемент в упорядоченный список
template <class Object>
void SortedList<Object>::insert(const Object & item) {
ListElem ** current = &first;
while (^current && item > (^current)->item) {
current = &(^current)->next;
}
Обработка текстов
223
^current = new ListElem(item, ^current);
count++;
}
// поиск первого элемента, равного заданному
template <class Object>
Object * SortedList<Object>::find(const Object & item) {
ListElem * current = first;
while (current && item > current->item) {
current = current->next;
}
if (current && item == current->item) {
return ¤t->item;
} else {
return NULL;
}
}
// Освобождение памяти, занятой элементами списка
template <class Object>
void SortedList<Object>::destroy() {
ListElem * current = first;
ListElem * pred;
while (current) {
pred = currents-
current = current->next;
delete pred;
}
count = 0;
}
Теперь словарь может быть реализован в виде упорядоченного списка пар из
ключа и значения примерно так, как это показано в листинге 3.7 в виде
описания шаблона классов ListDictionary. При занесении объекта в словарь
создается новая пара из ключа и значения, и она вставляется в список с
помощью операции insert. При выполнении деструктора словаря все созданные
копии будут удалены, поэтому нет необходимости программировать его для
описанного словаря отдельно.
j Листинг 3.7. Словарь, базирующийся на упорядоченном списке Л'\
// Класс представляет упорядоченный список пар (ключ, значение),
// где ключом является строка типа string, а значением может быть
224
Глава 3
// произвольный объект. Для повышения быстродействия
// при поиске список упорядочен по возрастанию ключей.
template <class Object>
class ListDictionary {
// Список list содержит упорядоченный список пар
SortedList<Pair<Object> > list;
public :
// Функция добавляет в упорядоченный список новую пару
void add(string key, const Object & obj) {
list.insert(Pair<Object>(key, obj));
}
// Функция осуществляет поиск пары по ключу и удаляет
// найденную пару, если она в списке была
void remove(string key) {
list.remove(Pair<Object>(key) ) ;
}
// Функция поиска связанного объекта по ключу
Object * find(string key) {
Pair<Object> * found = list.find(Pair<Object>(key));
return found ? found->obj : NULL;
}
};
Обратите внимание на то, как запрограммирована функция поиска по ключу.
Для поиска формируется новая пара с помощью выражения Pair<object>(key)
с неопределенным значением связанного с ключом объекта (точнее говоря,
это значение будет определено конструктором по умолчанию этого объекта).
Если пара с заданным ключом будет найдена в списке, то в результате поиска
будет выдана не эта новая пара, а пара, содержащаяся в списке в качестве
элемента. Это позволит при необходимости модифицировать найденный
объект.
Теперь можно перейти к самой задаче. Пусть задан текст, содержащий слова,
разделенные пробелами и знаками препинания. Требуется найти слово,
встречающееся в тексте максимальное число раз.
Для решения этой задачи организуем словарь, в котором с каждым словом
свяжем целое число— количество обнаруженных вхождений этого слова в
Обработка текстов
225
текст. Решение запишем в виде функции, получающей текст в качестве
аргумента и выдающей пару из самого часто встречающегося слова и количества
вхождений его в текст. Описание этой функции приводится в листинге 3.8,
полный текст программы можно также найти на приложенном компакт-диске
В папке "\Chapter3\3.3\ListDictionary".
[Листинг 3.8. Поиск самого часто встречающегося слова в тексте
// Функция frequentWord находит в заданном тексте слово,
// повторяющееся в нем максимальное число раз. В результате
// работы функция выдает пару из найденного слова и
// числа его повторений в тексте.
Pair<int> frequentWord(char * text) {
Wordslterator iterator(text); // Итератор слов текста
ListDictionary<int> diet; // Словарь со счетчиками слов
// 1. Занесение слов в словарь
while (iterator.hasMoreElements()) {
// Выбор очередного слова:
char * token = strlwr(^iterator);
// Ищем в словаре это слово и запоминаем связанный с ним счетчик:
int * wordCounter = diet.find(token);
if (wordCounter == NULL) {
// Слова еще не было; заносим его в словарь со счетчиком = 1
diet.add(token, 1);
} else {
// Слово уже было; увеличиваем значение счетчика на 1
++*wordCounter;
}
// переходим к следующему слову
++iterator;
}
// 2. Выбор слова с максимальной частотой
// Пара с максимальным числом повторений:
Pair<int> maxPair("", 0) ;
// Итератор словаря:
Iterator<Pair<int> > *i = diet.iterator();
while (i->hasMoreElements()) {
Pair<int> & nextPair = **i;
226
Глава 3
// Сравниваем счетчики двух пар
if (nextPair.obj > maxPair.obj) {
maxPair = nextPair;
}
// переходим к следующей паре
}
delete i;
// 3. Выдача результата
return maxPair;
}
У приведенной реализации есть два очевидных недостатка. Во-первых, само
представление словаря в виде линейного списка приводит к длительному
поиску в случае большого количества слов. Использование списка для этих
целей может быть оправдано, только если обрабатываемый текст не слишком
длинный. В этом случае простота реализации окупит наш выбор. Во-вторых,
в случае, когда слово встречается в списке впервые, поиск фактически
производится дважды: сначала список просматривается для того, чтобы выяснить,
что нового слова нет в словаре, а затем список просматривается еще раз при
вставке слова на свое место. Возможно, следовало бы описать функцию,
которая совместила бы в себе свойства поиска и вставки — функцию условной
вставки слова, которая в случае, если слово не найдено, вставляет его в
словарь, а если слово уже встречалось, выдает связанный с этим словом объект.
Нетрудно внести такое изменение, гораздо труднее решить, действительно ли
функция условной вставки имеет самостоятельное значение и может быть
использована вне рамок решаемой задачи.
Для случая более длинного текста можно предложить представление словаря
в виде бинарного дерева поиска, в котором узлами дерева служат те же самые
пары, состоящие из ключа и связанного с ним объекта, как и в случае
представления словаря в виде списка. Внешне описание словаря, основанного на
двоичном дереве поиска, очень похоже на описание словаря, основанного на
списке. Например, если за основу взять реализацию двоичного дерева,
приведенную в разд. 2.5 (см. листинг 2.28), то реализация словаря может выглядеть
так, как в листинге 3.9. В этой реализации используются операции для
поиска, вставки и итерации двоичного дерева поиска. Напомним основные
операции из определения шаблона класса тгее<т> для представления бинарного
дерева поиска:
template <class T>
class Tree {
public :
Обработка текстов
227
I/ Функция поиска по ключу
Т * search(const T & key) const;
// Построение внешнего итератора узлов
Iterator<T> *iterator() const;
// Вставка нового элемента в дерево, не нарушающая порядка
// элементов. Вставка производится в лист дерева
void insertLeaf(const T& elem) ;
// Удаление элемента из дерева, не нарушающее порядка элементов
void remove(const T & item);
};
Тогда класс TreeDictionary, представляющий словарь, реализованный на базе
бинарного дерева поиска, может быть описан следующим образом
(листинг 3.9).
Р£<тнг.9;9;.;П
// Определение шаблона TreeDictionary основано на бинарном
// дереве поиска, содержащем в узлах пары из строкового ключа и
// связанного с ним значения.
template <class Object>
class TreeDictionary {
// Дерево tree содержит упорядоченный список пар
Tree<Pair<Object> > tree;
public :
// Функция добавляет в дерево новую пару
void add(string key, const Object & obj) {
tree.insertLeaf(Pair<Object>(key, obj));
}
// Функция осуществляет поиск пары по ключу и удаляет
// найденную пару, если она в списке была
void remove(string key) {
tree.remove(Pair<Object>(key));
}
228
Глава 3
// Функция поиска связанного объекта по ключу
Object * find(string key) const {
Pair<Object> * found = tree.search(Pair<Object>(key));
return found ? &found->obj : NULL;
}
// Функция выдает внешний итератор пар, используя внешний
// итератор упорядоченного списка
Iterator<Pair<Object> > * iterator() const {
return tree.iterator();
}
};
Теперь, если мы имеем определение класса TreeDictionary, та же самая
функция поиска самого часто встречающегося слова в тексте может быть
реализована с использованием словаря TreeDictionary вместо словаря
ListDictionary. Эксперименты показывают, что уже для текста, содержащего
более 40—50 слов, реализация в виде двоичного дерева поиска оказывается
более выгодной, чем реализация в виде упорядоченного списка. Впрочем,
реальные цифры будут зависеть от характеристик используемого
компьютера, применяемого компилятора и т. п.
В этой главе мы предложим еще один способ организации словаря — бор. На
английском языке этот способ организации данных называется trie. Это
искусственно созданное слово, полученное вырезкой средней части из слова
retrieval — выборка. Аналогично, слово бор получено в русском языке путем
вырезки средней части из соответствующего русского термина. Эта
организация особенно хороша, если многие слова имеют одинаковые начала
(префиксы) и отличаются только своими окончаниями. Например, для слов
русского языка такая организация может оказаться выгодной, если в процессе
работы со словарем часто будут встречаться слова, отличающиеся только
падежными окончаниями. В боре слова хранятся не целиком, а побуквенно, и за
счет этого поиск слов в словаре может оказаться достаточно быстрым, а
количество используемой памяти тоже может сократиться. Конечно, если
хранимые слова могут сильно отличаться друг от друга, организация словаря в
виде бора будет невыгодна.
Пусть, например, в словаре надо хранить следующие 10 слов: "кон", "конь",
"корт", "кот", "кран", "крем", "крен", "крест", "крона", "крот". С каждым из
этих слов надо связать некоторый объект. Предлагается организовать
хранение этих слов так, как показано на рис. 3.3.
Из рисунка видно, что слова хранятся в виде списков составляющих их букв.
Объекты, связанные со словами, показаны в виде треугольников. В
реализации бор может быть представлен в виде списка деревьев, узлы которого
содержат отдельные буквы слов. Связанный со словом объект может быть
Обработка текстов
229
представлен либо в виде отдельного узла в этом же дереве, либо в виде
самостоятельного поля в каждом узле. Преимущество первого способа в том, что
связанный объект может быть представлен любым указателем, в том числе и
пустым, например, если с каким-либо словом не нужно связывать никакой
информации. Второй способ удобнее для реализации, однако он использует
больший объем памяти, и, кроме того, пустые указатели будут иметь
специальную трактовку. Если пустой указатель содержится в некотором узле, то
это будет означать, что слово не может заканчиваться им.
Рис. 3.3. Организация словаря в виде бора
В этой главе мы приведем фрагмент реализации словаря в виде бора, причем
выберем первый способ представления связанных объектов. Деревья будем
представлять в виде двоичных деревьев так, как описано в разд. 1.3, так что
бор, представленный на рис. 3.3, будет иметь физическое представление,
показанное на рис. 3.4. На этом рисунке имеются два типа узлов: терминальные
узлы, содержащие связанные со словами объекты, и промежуточные
навигационные узлы, содержащие буквы слов. Каждый терминальный узел
содержит указатель на сам объект (на рисунке соответствующая часть
изображения узла заштрихована) и указатель на следующий узел в списке братьев.
Каждый навигационный узел кроме буквы и ссылки на соседний узел в
списке братьев может содержать также ссылку на список узлов следующего
уровня. Для повышения скорости поиска все узлы в списке братьев
упорядочены по возрастанию кодов букв, при этом терминальный узел, не
содержащий буквы, всегда находится в списке братьев первым.
Итак, терминальные узлы содержат следующие поля:
□ obj — поле, содержащее указатель на связанный со словом объект;
□ brother — указатель на следующий узел в списке узлов одного уровня.
230
Глава 3
Рис. 3.4. Физическое представление бора
Навигационные узлы содержат следующие поля:
□ letter — буква слова;
□ son — указатель на список узлов следующего уровня;
□ brother — указатель на следующий узел в списке узлов одного уровня.
Пустые указатели на рисунке обозначены перечеркнутыми
прямоугольниками. Связанные со словами объекты обозначены заштрихованными
прямоугольниками.
В приведенной в листинге 3.10 реализации класса показано только
представление бора и реализация одного из методов — поиска связанного объекта по
ключу. Реализация остальных методов не слишком сложна, однако требует
аккуратности и хорошей техники программирования обработки сложных
структур данных. На приложенном компакт-диске в папке "\chapter3\3.3
\TrieDictionary" дополнительно представлена также функция добавления
новой пары из ключа и значения в бор.
|'листинг'&id;'^ "'"'*'£'..''; '■ ":"- '* !
// Класс представляет собой реализацию бора
//^^^^^^^^^^^^^^^^^^^^^^^
template <class Object>
class Trie {
Обработка текстов
231
// Класс TrieNode является базовым классом для двух основных
// типов узлов бора: узел-буква и узел-терминал
struct TrieNode {
TrieNode ^brother; // указатель на следующий узел того же уровня
// Конструктор узла:
TrieNode(TrieNode ^brother = NULL) : brother(brother) {}
// Функция проверки типа узла
virtual bool terminal() = 0;
};
// Два класса LetterNode и TerminalNode представляют два
// типа узлов бора: узел-буква и узел-терминал соответственно
struct LetterNode : TrieNode {
char letter; // очередная буква
TrieNode *son; // указатель на следующий уровень
// Конструктор узла:
LetterNode(char letter, TrieNode *son = NULL,
TrieNode ^brother = NULL)
: TrieNode(brother), letter(letter), son(son) {}
// Признак промежуточного узла
bool terminal() { return false; }
};
struct TerminalNode : TrieNode {
Object obj; // связанный со словом объект
// Конструктор узла:
TerminalNode(const Object & obj, TrieNode ^brother = NULL)
: TrieNode(brother), obj(obj) {}
// Признак терминального узла
bool terminal() { return true; }
};
TrieNode *root; // корень бора
public :
// Конструктор
Trie() { root = NULL; }
232
Глава 3
/I Функция добавления слова. Результат - значение, которое было
// связано с этим словом раньше, или NULL, если такового не было.
Object * add(string key, const Object & value);
// Функция поиска связанного объекта по ключу
Object * find(string key);
// Итератор пар (ключ, значение)
Iterator<Pair<Object> > * iterator();
};
template <class Object>
Object * Trie<Object>::find(string key) {
TrieNode *curNode = root; // текущий исследуемый узел бора
// Цикл поиска по очередной букве
for (int i = 0; i < key.length(); i++) {
// Ищем очередную букву в текущем уровне бора.
// При поиске используется упорядоченность списка.
while (curNode && (curNode->tenninal() I I
key[i] > ((LetterNode*)curNode)->letter)) {
curNode = curNode->brother;
}
if (curNode = NULL || key[i] != ((LetterNode*)curNode)->letter) {
return NULL; // очередная буква не найдена
}
// переходим на следующий уровень:
curNode = ((LetterNode*)curNode)->son;
}
// Все буквы ключа найдены. Теперь надо выдать связанный со словом
// объект, если он существует. Соответствующий терминальный узел
// обязательно будет первым узлом в списке узлов следующего уровня.
return (curNode && curNode->terminal()
? &((TerminalNode*)curNode)->obj
: NULL);
}
На тестах, содержащих большое количество сильно отличающихся друг от
друга слов, реализация словаря в виде бора показывает не очень хорошую
производительность, т. к. много памяти расходуется для хранения букв в
промежуточных узлах. Этот способ представления словаря будет
действительно полезным, только если обрабатываются похожие слова, так что в
общем случае следует выбирать более простые способы реализации. Тем не
менее, представление словаря в виде бора имеет достаточно общий характер,
так что, например, задачу нахождения слова с максимальной частотой ветре-
Обработка текстов
233
чаемости в заданном тексте можно решать с помощью словаря-бора
абсолютно так же, как и с помощью словаря-списка или словаря-дерева. Все, что
требуется изменить, — это описатель типа словаря. Такой пример
использования бора можно найти на приложенном компакт-диске.
На этом мы закончим описание способов представления словарей. Подведем
некоторый итог, сравнивая между собой 4 способа представления словаря:
упорядоченный список, бинарное дерево поиска, хеш-таблицу и бор.
Несмотря на некоторую условность приводимых оценок и отсутствие точных цифр,
эти оценки все же могут оказаться полезными при выборе представления для
словарных структур.
1. Упорядоченный список следует выбирать для представления словаря, если
в нем предполагается хранить небольшое число слов. С ростом словаря
этот способ реализации становится все более невыгодным, и, в конце
концов, оказывается наихудшим из всех, когда количество слов становится
очень большим.
2. Бинарное дерево поиска является хорошей простой альтернативой
упорядоченному списку для случая словаря среднего размера. К сожалению,
бинарное дерево может иметь "плохую" структуру, а методы,
позволяющие улучшать и поддерживать структуру дерева, уже далеко не так
просты, так что реализация может сильно потерять в простоте, не сильно
выиграв в эффективности.
3. Хеш-таблица обычно является наилучшим способом реализации словаря,
особенно в тех случаях, когда размер словаря можно легко оценить с
самого начала. Хеш-таблицу не следует использовать только в случае
небольших словарей, когда более простые методы оказываются и более
эффективными. Также в реализации, основанной на функциях расстановки,
много времени может уйти на "перехеширование", так что в случае
словарей, размер которых заранее оценить трудно, эффективность может
снизиться.
4. Бор стоит использовать для реализации словаря только в специфическом
случае, когда слова, содержащиеся в словаре, очень похожи друг на друга.
В этом случае общий объем данных уменьшается за счет склеивания
одинаковых символов и, соответственно, эффективность работы может
существенно улучшиться. Кроме того, организация данных в виде бора
достаточно специфична— она существенным образом опирается на то, что
слова действительно состоят из отдельных букв, в то время как все
остальные способы организации словарей используют лишь свойство
упорядоченности ключей. Таким образом, первые три способа организации
словаря можно применять в случае любого упорядоченного множества
ключей, а организация в виде бора предъявляет особые требования к
структуре самих ключей.
ГЛАВА 4
Символьные преобразования
В этой главе рассмотрено несколько классических алгоритмов символьных
преобразований, таких как вычисление значения символьного выражения,
упрощение формулы и др. Выражения представлены деревьями, узлами
которых служат элементы этих выражений, так что обработка дерева обычно
состоит в обходе всех узлов и выполнении обработки содержащейся в узлах
информации.
В разд. 4.1 рассматриваются способы представления формул (символьных
выражений) в виде деревьев. В разд. 4.2 приводится алгоритм вычисления
значения выражения по заданным значениям операндов. В разд. 4.3
рассматриваются несколько алгоритмов преобразования формул.
4.1. Представление выражений
Многие языки программирования используют понятие выражения. Хотя
синтаксис и семантика выражений в различных языках программирования
различны, тем не менее, при анализе выражений можно выделить общие черты
способов их представления. Обычно считается, что выражение состоит из
элементарных (или первичных) операндов, соединенных в некоторую
структуру с помощью пробелов, знаков операций и/или скобок. Часто требуется
написать программу, которая, получив в качестве исходных данных
текстовую строку, рассматривает ее как выражение, записанное по правилам
некоторого языка. От программы может потребоваться выявить структуру
выражения, преобразовать его в ту или иную форму записи, вычислить значение
выражения, преобразовать по определенным правилам и т. п.
Рассмотрим различные способы Представления формул и разберем некоторые
из методов их обработки.
Символьные преобразования
235
Будем считать, что в некотором языке формула состоит из:
□ буквенно-цифровых идентификаторов (букв латинского алфавита и цифр,
причем первым символом идентификатора должна быть буква);
□ констант, представляющих неотрицательные целые числа в виде
последовательности десятичных цифр;
□ круглых скобок;
□ знаков операций из допустимого набора операций.
Такую формулу можно рассматривать как строку символов (допустимыми
символами являются буквы латинского алфавита, десятичные цифры,
пробелы, скобки и знаки операций) и, соответственно, представлять строкой.
В этой главе мы будем всегда использовать строки, представленные классом
string.
Чтобы работать со структурой такой формулы надо, во-первых, выделить в
этой строке элементарные единицы (т. е. выполнить лексический анализ
формулы), а во-вторых, разобрать формулу в соответствии с приоритетом
используемых операций и расстановкой скобок (т. е. выполнить
синтаксический анализ). Обычно результатом лексического анализа будет
последовательность лексических единиц {лексем)— атомов, из которых состоит
выражение. Результатом синтаксического анализа является дерево,
представляющее структуру выражения.
Пусть, например, имеется выражение, записанное в виде следующей строки:
СА2 == АЛ2 + ВА2 - A*B*cos(alpha)
Последовательность символов, составляющих эту строку, может быть
изображена, как представлено на рис. 4.1.
1СП2ГНЧ |А|А|2| И |В|А|2| |-| 1А|ЧВ|*|с|о|в|(|а|1ЫНШД
Рис. 4.1. Выражение, представленное строкой символов
После лексического анализа эта последовательность символов будет
представлена следующей последовательностью лексем (рис. 4.2).
Рис. 4.2. Выражение, представленное последовательностью лексем
Дерево структуры этого выражения, будет содержать в узлах операнды и
операции, так что можно изобразить эту структуру, как показано на рис. 4.3.
В структуре синтаксического дерева выражения узлы, представляющие знаки
операций, имеют в качестве потомков операнды этих операций, которые,
236
Глава 4
в свою очередь, могут быть как элементарными выражениями (константами и
переменными), так и более сложными подвыражениями. Порядок
выполнения операций в синтаксическом дереве определяется структурой дерева,
поэтому такое дерево не будет содержать скобок.
'JC
|С| |2|
^~~~~~^ —-^^
^J^
^ХГ
г—Г Л—1
1 Л 1 1 Л 1
A Jr\
[а] [2_| |в| \21
1__
I?3-,
*
A r
|а| [в 1 [
COS
пг.
alpha |
Рис. 4.3. Выражение, представленное синтаксическим деревом
В следующих примерах алгоритмов и программ ограничимся не слишком
сложными выражениями. Будем считать допустимыми операциями только
арифметические операции над целыми числами: сложение, вычитание,
умножение, деление с отбрасыванием остатка, вычисление остатка от деления
одного числа на другое. Применение остальных операций, а также
элементарных функций будем считать недопустимым. Для определения порядка
выполнения операций будем пользоваться скобками, а при их отсутствии —
обычными правилами приоритетов арифметических операций (умножение и
деление выполняются раньше сложения и вычитания; операции одного
приоритета выполняются в порядке слева направо).
Тогда узлами дерева могут быть объекты следующих типов:
□ целое число, представленное объектом типа int;
□ переменная, представленная строкой (объектом типа string);
□ операция, содержащая символ знака операции и два операнда, каждый из
которых представлен деревом выражения.
В качестве выражения может выступать любой из этих типов узлов, причем
третий тип узла (операция) может быть описан с помощью следующего
определения класса:
class Operator {
char operSign; // Знак операции
Expression *operandi; // Первый операнд
Expression *operand2; // Второй операнд
Символьные преобразования
237
public :
// Конструктор:
Operator(char sign, Expression *opl, Expression *op2)
: operSign(sign), operandi(opl), operand2(op2) {}
// Функции доступа:
char getOperSign() { return operSign; }
Expression * getOperandl() { return operandi; }
Expression * get0perand2() { return operand2; }
};
В определении класса operator был использован идентификатор класса
Expression в качестве обобщающего понятия для выражений любого вида.
Удобно считать класс Expression базовым классом для всех типов узлов и
определять для него наиболее общие операции, выполнимые для каждого из
типов узлов синтаксического дерева. Будем считать, что сам класс operator
является прямым наследником класса Expression. Другими наследниками
будут класс integer, представляющий целые числа как операнды выражений и
класс variable, представляющий переменные выражения.
Если условиться изображать дерево выражения так, что узлам разных типов
будут соответствовать разные геометрические фигуры, то дерево выражения:
(а + Ь)*(1+х)
может быть изображено, как показано на рис. 4.4. На нем треугольниками
изображены узлы-операторы, квадратами представлены узлы-переменные и,
наконец, в кружках содержатся константы.
Рис. 4.4. Дерево простого выражения
Нашей первой задачей будет написать два преобразователя: из символьной
строки в синтаксическое дерево выражения и обратно из синтаксического
дерева в строку. Поэтому в классе Expression, представляющем
синтаксическое дерево выражения, будут содержаться определения соответствующих
методов, например, как это показано в следующем фрагменте программы:
238
Глава 4
class Expression {
public :
// Метод для построения синтаксического дерева выражения
static Expression * parse(const string & source);
// Оператор преобразования выражения в строковое представление
virtual operator string () const = 0;
};
Теперь определим классы, представляющие три типа узлов синтаксического
дерева. Один из этих классов — operator — уже был показан выше, теперь
добавим только ссылку на базовый класс Expression. Остальные два класса
определяются достаточно просто. Определения всех трех классов-
наследников показаны в листинге 4.1.
Оператор преобразования дерева в обычную строку типа string может быть
реализован сравнительно просто, если только мы не будем заботиться об
оптимальной расстановке скобок. Основное правило преобразования может
быть выражено следующим образом:
□ если дерево представлено константой или переменной, то его символьным
представлением будет строковое представление этой константы или
переменной;
□ если в корне дерева находится знак операции, то строковые представления
операндов соединяются знаком операции, и результат заключается в скобки.
Первое из этих правил поддерживается стандартными функциями
преобразования в строку (например, sprintf); реализация соответствующих операторов
приведена в листинге 4.1 в определении классов integer и variable. Второе
правило можно очень просто реализовать с помощью вызова оператора
преобразования в строку для операндов — объектов класса Expression, эта
реализация также приводится в листинге 4.1 в определении класса operator.
Ьеинтадсичёского^ '", % >- /: \
// Класс Integer представляет простейший тип выражения -
// целую константу.
7/======================^^
class Integer : public Expression {
int value;
public :
// Конструктор
Integer(int value) : value(value) {}
Символьные преобразования
239
// Функция доступа
int getValueO const { return value; }
// Оператор преобразования в строку
operator string () const {
char buffer[14] » {0};
sprintf(buffer, value < 0 ? "(%d)" : "%d", value);
return buffer;
}
};
// Класс Variable представляет простейший тип выражения -
// идентификатор переменной.
class Variable : public Expression {
string variable;
public :
// Конструктор
Variable(string ident) : variable(ident) {}
// Функция доступа
string getVariable() const { return variable; }
// Оператор преобразования в строку
operator string () const { return variable; }
};
// Класс Operator представляет выражение, состоящее из знака
// операции и двух операндов, каждый из которых в свою очередь
// является выражением
class Operator : public Expression {
char operSign; // Знак операции
Expression *operandi; // Первый операнд
Expression *operand2; // Второй операнд
public :
// Конструктор:
Operator(char sign, Expression *opl, Expression *op2)
: operSign(sign), operandi(opl), operand2(op2) {}
// Функции доступа:
char getOperSign() const { return operSign; }
240
Глава 4
Expression * getOperandl() const { return operandi; }
Expression * get0perand2() const { return operand2; }
// Оператор преобразования в строку
operator string () const {
return "(" + (string)*operandi + operSign + (string)*operand2 + ')';
}
};
Что же касается обратного преобразования из строки в дерево выражения, то
здесь ситуация намного сложнее. Тем не менее, мы можем воспользоваться
уже знакомым нам алгоритмом из разд. 2.3, в котором похожая задача
решалась для вычисления значения выражения, заданного строкой. Здесь мы
можем применить тот же самый алгоритм, в котором задача решалась с
использованием двух стеков— стека операндов и стека операций, только в роли
значения выражения будет выступать теперь построенная часть
синтаксического дерева выражения. В этом случае выполнение операции сводится к
построению узла синтаксического дерева, содержащего эту операцию.
Соответственно, в стек операндов вместо целых значений будут складываться
указатели на узлы синтаксического дерева. В остальном алгоритм оказывается
полностью пригодным для использования в этой задаче.
Так же, как и в разд. 2.3, для начала необходимо определить лексический
анализатор, который, получив на входе исходную строку, будет на выходе
генерировать лексемы: числа, переменные, скобки и знаки операций. Можно,
как и раньше использовать единственный класс Lexical, немного расширив
его определение, чтобы включить в него новые типы лексем. Но лучше
поступить по-другому — определить Lexical как абстрактный класс, а все
конкретные типы лексем определять в виде наследников этого абстрактного
класса. В листинге 4.2 представлены определения соответствующих классов.
! Листинг 4.2. Определение классов лексем
// Абстрактный класс Lexical представляет абстрактную лексему
//и определяет возможные лексические классы для анализатора
class Lexical {
public :
// Определяем возможные лексические классы
enum LexClass {
NUMBER = 1,
Символьные преобразования 241
VARIABLE,
OPERATOR,
LEFTPAR,
RIGHTPAR
};
virtual int getLexClassO const = 0;
I/ Класс LexNumber представляет лексему - целое число
class LexNumber : public Lexical {
protected :
int number; // представляемое число
public :
// Конструктор лексемы
LexNumber(int num) : number (num) {}
// Функция определения класса лексемы
int getLexClassO const { return Lexical::NUMBER; }
// Функция доступа
int getNumberO const { return number; }
};
// Класс LexVariable представляет лексему - идентификатор
class LexVariable : public Lexical {
protected :
string variable; // представляемый идентификатор
public :
// Конструктор лексемы
LexVariable(const string & v) : variable(v) {}
// Функция определения класса лексемы
int getLexClassO const { return Lexical::VARIABLE; }
// Функция доступа
string getVariable() const { return variable; }
};
242
Глава 4
// Класс LexOperator представляет лексему - знак операции или скобку
/ /==============^========^^^
class LexOperator : public Lexical {
protected :
char oper; // представляемый знак операции или скобка
public :
// Конструктор лексемы
LexOperator(char oper) : oper(oper) {}
// Функция определения класса лексемы
int getLexClass() const {
return
(oper =='('? Lexical:rLEFTPAR :
oper ==')'? Lexical:rRIGHTPAR :
Lexical::OPERATOR);
}
// Функция вычисления приоритета операции
int getPrio() const {
return (oper == f-f || oper == ' + ' ? 1 :
oper = '*' || oper == '/' II oper == '%' ? 2 : 0);
}
// Функция доступа
char getOperSign() const { return oper; }
};
Теперь можно определить лексический анализатор подобно тому, как мы это
делали в разд. 2.3. Анализатор берет на входе некоторую строку и
преобразует ее к последовательности лексем. Удобно представлять анализатор в виде
итератора, выдающего все лексемы из строки по очереди с помощью
обычного для итератора оператора operator * (). Дополнительно, для анализа
возможных ошибок определим исключительную ситуацию, которая позволит не
только запомнить причину возникновения ситуации, но и запомнит смещение
относительно начала той строки, в которой обнаружена ошибка. Здесь мы не
будем приводить никакой реализации работы лексического анализатора,
однако, ниже представлен класс, определяющий лексический анализатор (тела
его методов не показаны).
typedef Lexical * PLexical;
class LexAnalizer : public Iterator<PLexical> {
public :
// Конструктор
LexAnalizer(const string & source);
Символьные преобразования
243
I/ Функции, реализующие интерфейс итератора
bool hasMoreEleinents () const;
// Предполагается, что итератор производит объекты класса Lexical*
PLexical & operator *();
const PLexical & operator *() const;
Iterator<PLexical> & operator ++();
// Текущая позиция при анализе строки:
int getCurrentPosition() const;
};
Теперь все готово для того, чтобы реализовать функцию преобразования
выражения из строкового представления в представление в виде двоичного
дерева. Алгоритм вычисления выражения из разд. 2.3 модифицирован так,
чтобы стек операндов содержал в качестве результатов вычислений указатели на
построенные части синтаксического дерева выражения, а стек операций —
лексемы, представляющие знаки операций и левые скобки. Этот алгоритм
представлен в листинге 4.3 в виде функции parse из определения класса
Expression.
В алгоритме сделано еще одно важное дополнение: теперь он не только
строит синтаксическое дерево для правильного выражения, но и готов в случае
неправильного выражения возбудить исключительную ситуацию.
Анализ правильности выражения оказывается довольно простым. При
анализе каждой лексемы известно, допустима ли эта лексема в выражении в
текущей позиции. В программе определена переменная waitFlag, которая в любой
момент времени содержит значение, указывающее на то, лексема какого
класса ожидается на соответствующем месте. Возможны два варианта:
ожидается операнд и ожидается оператор. В первом случае допустимыми
являются лексемы, представляющие число, переменную и левую (открывающую)
скобку. Во втором случае допустимыми являются знак операции и правая
(закрывающая) скобка. Очевидно, что после очередного операнда или
закрывающей скобки должна следовать операция или закрывающая скобка, а после
знака операции или открывающей скобки должен следовать операнд.
Еще один тип ошибок возможен из-за нарушения скобочной структуры
выражения. В этом случае программа будет пытаться извлечь данные из пустого
стека операндов или из пустого стека операций. Таким образом, ошибка
этого типа распознается при возникновении ситуации исчерпания стека.
В программе имеются дополнительные пояснения, разъясняющие некоторые
детали алгоритма. Полностью текст программ, реализующих лексический
анализатор и построение синтаксического дерева выражения, можно найти на
Приложенном КОМПаКТ-ДИСКе В Папке "\Chapter4\4.1\Expression".
244
Глава 4
\ Листинг 4.3. Класс Expression и функция анализа выражения
// Класс для представления исключительной ситуации, которая может
// возникнуть при анализе ошибочного выражения.
class ParseException : public exception {
char * reason;
int position;
public :
ParseException(char * message, int pos)
: reason(message), position(pos) {}
const char * what() const throw() { return reason; }
int getPosition() const { return position; }
};
// Базовый класс для представления синтаксического дерева
// выражения.
class Expression {
public :
// Метод для построения синтаксического дерева выражения
static Expression * parse(const string & source);
// Оператор преобразования выражения в строковое представление
virtual operator string () const = 0;
};
// Переменные stExprFactory и stOperFactory задают фабрики стеков
// для генерации стека операндов (узлов синтаксического дерева)
//и стека операций (значений класса LexOperator)
StackFactory<Expression*> * stExprFactory =
new ListStackFactory<Expression*>;
StackFactory<LexOperator*> * stOperFactory =
new ListStackFactory<LexOperator*>;
// Функция "выполнения" операции на стеках
//=======================^^^
void doOperator(
Символьные преобразования
245
Stack<Expression*> *operands, // стек операндов содержит деревья
Stack<LexOperator*> ^operators // стек операторов
) {
// Операнды для операции извлекаются из стека операндов:
Expression *ор2 = **operands; operands->рор();
Expression *opl = **operands; operands->рор();
// Знак операции берется из стека операций:
LexOperator * op = **operators;
char opSign = op->getOperSign();
operators->pop();
delete op;
// В качестве результата выполнения операции строится новый
// узел, содержащий знак операции и два поддерева
operands->push(new Operator(opSign, opl, op2));
}
// Функция выполнения серии операций согласно их приоритетам
void doOperators(
Stack<Expression*> *operands, // стек операндов
Stack<LexOperator*> *operators, // стек операторов
int minPrio // граничный приоритет
) {
while (!operators->emptу() & &
(**operators)->getPrio() >= minPrio) {
doOperator(operands, operators);
}
}
// Основная функция анализа строки и построения дерева выражения
Expression * Expression::parse(const string & source) {
// Константы для значений ожидаемого класса лексемы
const int WAIT_OPERAND = 1;
const int WAITJDPERATOR = 2;
// Стеки операндов и операций создаются стандартным образом
//с помощью фабрик по производству стеков. Чтобы
// избежать утечки памяти, надо аккуратно уничтожить
// эти стеки при нормальном или аварийном завершении анализа.
Stack<Expression*> ^operands = stExprFactory->newStack();
Stack<LexOperator*> *operators = stOperFactory->newStack();
// лексический анализатор выражения:
LexAnalizer analizer(source);
// Флажок ожидаемой лексемы
int waitFlag = WAIT_OPERAND;
try {
// Цикл по лексемам исходной строки
while (analizer.hasMoreElements()) {
Lexical *lex = *analizer;
++analizer;
if (lex == NULL) {
delete operands;
delete operators;
throw ParseException("Unknown lexema",
analizer.getCurrentPosition());
}
if (waitFlag == WAIT_OPERAND) {
switch (lex->getLexClass()) {
case Lexical::NUMBER:
// число преобразуется в узел синтаксического дерева
//и записывается в стек операндов
operands->push(new Integer(
((LexNumber*)lex)->getNumber() )) ;
waitFlag = WAIT_OPERATOR;
delete lex;
break;
case Lexical::VARIABLE:
// переменная преобразуется в узел синтаксического дерева
//и записывается в стек операндов
operands->push(new Variable(
((LexVariable*)lex)->getVariable()))
waitFlag = WAIT_OPERATOR;
delete lex;
break;
case Lexical::LEFTPAR:
// скобка просто записывается в стек операций
operators->push((LexOperator*)lex);
break;
default:
// лексема неожидаемого класса свидетельствует об ошибке
delete lex;
delete operands;
delete operators;
Символьные преобразования
247
throw ParseException("Operand expected",
analizer.getCurrentPosition());
}
} else { // waitFlag == WAIT_OPERATOR
switch (lex->getLexClass()) {
case Lexical::RIGHTPAR:
// появление закрывающей скобки вызывает выполнение
// операций и удаление соответствующей открывающей скобки
{doOperators(operands, operators, 1);
LexOperator * op = **operators;
operators->pop();
delete op;
delete lex;
}
break;
case Lexical::OPERATOR:
// выполняем операции с приоритетом не меньше текущего
doOperators(operands, operators,
((LexOperator*)lex)->getPrio());
// знак очередной операции записываем в стек операций
operators->push((LexOperator*)lex);
waitFlag = WAIT_OPERAND;
break;
default:
// лексема неожидаемого класса свидетельствует об ошибке
delete lex;
delete operands;
delete operators;
throw ParseException("Operator expected",
analizer.getCurrentPosition());
}
}
}
// выполняем последние оставшиеся в стеке операции
doOperators(operands, operators, 0);
// результат - последнее значение в стеке операндов
Expression * result = **operands;
delete operands;
delete operators;
return result;
} catch (StackUnderflow ex) {
// исчерпание стека свидетельствует об ошибке в скобочной
// структуре выражения
delete operands;
248
Глава 4
delete operators;
throw ParseException("Expression structure error",
analizer.getCurrentPosition());
}
}
Операции преобразования выражения из строки в синтаксическое дерево и
наоборот являются в некотором смысле обратными друг к другу. Тем не
менее это верно только с точностью до возможных вариантов расстановки
скобок и наличия пробелов в строке. Так, например, в результате вычисления
следующего оператора:
Expression * tree = Expression::parse("(а + b)*(с + d) + 1 + 2*a")
будет построено дерево, изображенное на рис. 4.5.
Рис. 4.5. Синтаксическое дерево выражения (а+Ь) * (c+d) + 1 + 2*а
Если теперь преобразовать это дерево обратно в строку с помощью
выражения:
(string) tree
то в результате такого обратного преобразования получится строка:
"((((a+b)*(c+d))+l)+(2*a))"
в которой по сравнению с исходной строкой отсутствуют пробелы между
лексемами и добавлены лишние скобки.
Что же можно сделать с выражением, представленным в виде
синтаксического дерева?
Спектр возможных операций достаточно широк. Можно вычислить значение
этого выражения, если заданы значения входящих в него переменных; можно
Символьные преобразования
249
осуществить подстановку в исходное выражение некоторого подвыражения
вместо всех вхождений некоторой переменной; можно осуществить
преобразование этого выражения в последовательность команд процессора для
вычисления его значения и т. д.
Некоторые такие алгоритмы представлены в этой книге дальше. Конечно,
многие из операций над выражением можно выразить в виде обхода дерева
выражения некоторым итератором. Однако, в отличие от предыдущих
случаев, когда итератор использовался только для получения последовательности
элементов некоторой структуры в одном из возможных порядков, а сама
структура при этом оставалась неизменной, при обработке выражений
обработка узлов часто оказывается значительно более сложной. Здесь уже
недостаточно только получать элементы в некотором порядке, итератору может
потребоваться более глубокая обработка узлов. Поэтому напрямую технику
работы с итераторами удается применять довольно редко.
С другой стороны, выражения, определенные нами в виде синтаксического
дерева, содержат неоднородные узлы. Некоторые из этих узлов имеют
ссылки на поддеревья, причем количество этих поддеревьев фиксировано. Так,
узел, содержащий бинарную операцию, содержит ссылки на два ее операнда.
Другие узлы вообще не имеют поддеревьев (константы и переменные). Таким
образом, структура синтаксического дерева будет более строго определенной,
чем структура произвольного бинарного дерева, так что применяемый нами
ранее механизм итерации может оказаться слишком общим для
синтаксических деревьев.
Мы представим две различные технологии обработки синтаксических
деревьев — интерпретатор и посетитель. Первая из них применяется в том
случае, когда набор операций обработки выражения известен заранее, так что
можно каждую из этих операций определить в виде виртуальной функции
непосредственно в классе Expression. Технология "посетитель" применяется в
том случае, когда требуется описывать различные алгоритмы обработки
выражения, и при этом вмешательство в описания классов, определяющих
структуру выражения, по тем или иным причинам неудобно. Обе технологии
вместе с некоторыми примерами хорошо описаны в книге [3].
Применение обоих способов обработки выражения покажем на примере
простой операции, которая строит список всех переменных, использующихся в
заданном выражении. Полученный список будем хранить в словаре,
организованном в виде хеш-таблицы класса HashDictionary так, как было показано
в листинге 3.2.
В технологии интерпретатора метод, осуществляющий сбор информации
о переменных выражения, описывается в виде виртуальной функции
250
Глава 4
aiivariabies () в классе Expression, и эта функция переопределяется для
каждого из видов выражения. Определение этого метода для каждого из типов
узлов очень просто, так что мы приведем реализацию этого метода в
листинге 4.4 практически без комментариев. Фактически почти точно так же в
технике интерпретации реализована и другая, уже обсуждавшаяся выше
операция, — преобразование выражения в строку.
I Листинг 4.4. Сбор информации о переменных выражения
class Expression {
// ... (приводится только определение метода по
// составлению списка всех переменных)
public :
// Список переменных выражения
virtual HashDictionary * allVariables() const = 0;
};
class Integer : public Expression {
// ... (приводится только определение метода по
// составлению списка всех переменных)
public :
// Список переменных выражения
HashDictionary * allVariables() const { return new HashDictionary(); }
};
class Variable : public Expression {
string variable;
// ... (приводится только определение метода по
// составлению списка всех переменных)
public :
// Список переменных выражения
HashDictionary * allVariables() const {
HashDictionary * s = new HashDictionary();
s->add(variable);
return s;
}};
class Operator : public Expression {
char operSign; // Знак операции
Expression *operandi; // Первый операнд
Expression *operand2; // Второй операнд
Символьные преобразования
251
public :
// ... (приводится только реализация операции копирования)
// Операция получения списка переменных выражения
// реализуется с помощью рекурсивного применения
// той же операции к операндам бинарной операции
HashDictionary * allVariables() const {
HashDictionary * si = operandl->allVariables();
HashDictionary * s2 = operand2->allVariables();
Iterator<string> *i = s2->keys();
for (; i->hasMoreElements(); ++*i) {
if (!sl->hasWord(**i)) sl->add(**i);
}
delete i; // Уничтожение не нужного более итератора
delete s2; // Уничтожение не нужного более словаря
return si;
}
};
Технология "посетитель" несколько сложнее. В ней для обработки сложной
структуры используется специальный объект-посетитель, который отвечает
за обработку определенных типов узлов в соответствии со своим внутренним
алгоритмом. Такой посетитель несколько напоминает объект типа Actor,
который использовался нами для организации внутренней итерации сложных
структур данных. Разница состоит в том, что вместо одного виртуального
метода action, определяющего выполняемое действие в структуре
внутреннего итератора, в посетителе используется несколько методов, различающихся
по типу обрабатываемых узлов. Так, например, при обработке
синтаксического дерева выражения посетитель выполняет три разных действия при
обработке трех разных типов узлов.
Итак, опишем абстрактный класс visitor, в котором определим методы для
обработки различных типов узлов дерева выражения. Каждый метод
получает в виде аргумента узел соответствующего типа для обработки.
class Visitor {
public :
virtual void visit(Integer * intNode) = 0;
virtual void visit(Variable * varNode) = 0;
virtual void visit(Operator * opNode) = 0;
};
Теперь для каждого из типов узлов определим способ его посещения
некоторым посетителем. По существу, мы определяем методы, которые, ничего не
зная о конкретных процедурах обработки узлов, определяют лишь общую
252
Глава 4
схему посещения каждого из узлов подобно тому, как внутренний итератор
вызывал метод action, ничего не зная о том, для чего этот метод
предназначен и что именно он делает. Ниже показаны те части описания классов
Expression, Integer, Variable И Operator, КОТОрые ОТНОСЯТСЯ К обработке уЗЛОВ
посетителем:
class Expression {
public :
// Операция посещения узла
virtual void accept(Visitor & visitor) = 0;
};
class Integer : public Expression {
public :
// Операция посещения узла
void accept(Visitor & visitor) { visitor.visit(this); }
};
class Variable : public Expression {
public :
// Операция посещения узла
void accept(Visitor & visitor) { visitor.visit(this); }
};
class Operator : public Expression {
public :
// Операция посещения узла
void accept(Visitor & visitor) { visitor.visit(this); }
};
Видно, что определение операций accept выглядит совершенно одинаково
для всех типов узлов. На самом деле, во всех трех случаях вызываются
разные операции, просто потому, что для каждого типа узла в интерфейсе
visitor определена своя операция. Возможно, чтобы сделать это более
наглядным, следовало бы дать этим операциям различные имена. Но мы
использовали перегрузку идентификатора операции — visit, поэтому все три
определения методов accept выглядят одинаково.
Теперь попробуем определить операцию составления словаря переменных
выражения в виде посетителя, обходящего узлы и накапливающего
информацию о переменных, лежащих в этих узлах. Поскольку словарь переменных
будет постепенно пополняться в процессе обхода и накапливаться внутри
самого объекта-посетителя, то для доступа к этой копии надо будет
определить дополнительный метод.
Символьные преобразования
253
Соответствующая программа представлена в листинге 4.5. В нем определен
посетитель variabieCoiiector для сбора переменных. Словарь переменных,
как и раньше, представлен переменной класса HashDictionary. В классе
VariabieCoiiector операции посещения visit определены следующим
образом: посещение константы ничего не добавляет в словарь, посещение
переменной добавляет в словарь эту переменную, а посещение оператора
сводится к посещению операндов соответствующей операции, при этом тот же
самый экземпляр посетителя передается узлам поддеревьев с помощью вызова
метода accept.
:•_■•••'••• .ч. ..,„..„.,.»„..,..... .., „ ..,..,., , , ,..,....., , , ,.j
I Листинг 4.5. Реализация посетителя для сбора информации
I о переменных выражения
class VariabieCoiiector : public Visitor {
// Словарь переменных
HashDictionary diet;
public :
// Посетители накапливают информацию в словаре. Единственный
// узел, добавляющий переменные, - это узел типа Variable
void visit(Integer * intNode) {}
void visit(Variable * varNode) { diet.add(varNode->getVariable()); }
void visit(Operator * opNode) {
opNode->getOperandl()->accept(*this);
opNode->get0perand2()->accept(*this);
}
// Метод getResultO обеспечивает доступ к результату работы
HashDictionary & getResultO { return diet; }
};
Если теперь взять некоторое дерево, полученное из строки, например,
следующим образом:
Expression * tree = Expression::parse("(a + b) * (с + d) + 1 + 2*a");
то множество переменных этого выражения может быть напечатано с
помощью следующей последовательности операторов:
VariabieCoiiector collector;
exprCopy->accept(collector) ;
cout « collector.getResult() « endl;
Результатом исполнения этих операторов будет вывод следующей строки
(с точностью до способа вывода словаря в выходной поток):
[b, a, d, с]
254
Глава 4
Второй пример чуточку сложнее. Напишем функцию копирования дерева
выражения, причем сделаем это также с помощью технологии "посетитель".
Задачей посетителя при посещении им узла дерева выражения будет создание
копии этого узла. Для узлов простых типов (целое число и переменная) такая
копия может быть создана напрямую. Однако для узла-оператора наш старый
способ передачи одного и того же экземпляра посетителя всем узлам
поддеревьев не подходит. Вместо этого для создания копии узла-оператора следует
создать еще два экземпляра таких же посетителей и использовать их для
построения копий операндов, после чего уже легко создать копию исходного
узла.
В листинге 4.6 представлена реализация соответствующего посетителя в виде
Определения Класса Copier.
\ Листинг 4.6. Посетитель для копирования дерева выражения
class Copier : public Visitor {
// Построенная копия выражения
Expression * copy;
public :
// Конструктор
Copier() : copy(NULL) {}
// Посетители строят копию узла в переменной сору
void visit(Integer * intNode) {
copy = new Integer(intNode->getValue());
}
void visit(Variable * varNode) {
copy = new Variable(varNode->getVariable());
}
void visit(Operator * opNode) {
Copier oplCopier, op2Copier;
opNode->getOperandl()->accept(oplCopier);
opNode->getOperand2()->accept(op2Copier);
copy = new Operator(opNode->getOperSign(),
oplCopier.getResult(),
op2Copier.getResult());
}
// Метод getResult() обеспечивает доступ к результату работы
Expression * getResult() { return copy; }
};
Аппарат посетителей дает очень гибкие возможности для анализа и
построения выражения, но эти возможности все же следует дополнить некоторыми
Символьные преобразования
255
операциями, обычными для выражений. Так, например, реализованная выше
операция копирования выражения является очень общей и в том или ином
виде присутствует практически в любом классе (обычно в виде оператора
присваивания). Обычными для многих объектов, в том числе и для
выражений, будут также операции сравнения и другие. Конечно, их следует
реализовать в виде отдельных операций, и делать это все-таки удобнее не с помощью
очень общей технологии посещения узлов, а напрямую в виде рекурсивных
функций.
Мы рассмотрим две такие операции: операцию копирования выражения
(реализованную в виде функции сору) и операцию проверки выражений на
равенство (реализованную в виде оператора сравнения =).
По существу, метод сору, предназначенный для создания копии объекта, уже
реализован с помощью посетителя Copier. Таким образом, можно было бы
написать следующую простую реализацию этого метода:
Expression * сору() {
Copier copier;
accept(copier);
return copier.getResult();
}
Тем не менее имеет смысл написать отдельную реализацию этого метода,
независимую от наличия такого достаточно специфического класса, как copier.
Кроме того, такая реализация будет проще и эффективнее. Удобнее всего
реализовать метод с помощью рекурсивной функции.
Оператор проверки выражений на равенство также можно реализовать
похожим образом. Можно было бы определить посетителя, сравнивающего узлы,
однако более простым и эффективным методом будет реализация этого
метода в виде рекурсивной функции.
В листинге 4.7 представлены оба метода— сору и operator ==,
реализованные в виде простых рекурсивных функций. Для сравнения двух выражений
требуется динамически определять тип выражения, переданного оператору в
качестве аргумента. Это можно легко сделать, если определить виртуальную
функцию, которая для каждого из возможных типов выражения выдает его
тип. Такая функция представлена в нашей программе методом what о.
Листинг 4.7. Реализация операций копирования и сравнения деревьев :
|;Й»граженйй'; J/y/y\ f '■'■ ":--\'/'-v'4# Ь SV'^.t^'V'A''5)- "*"'•'." '•*.' ~*v'- V:' -- "'J
class Expression {
public :
256
Глава 4
// Типы выражений
enum Type {
INTEGER = 1,
VARIABLE,
OPERATOR
};
// Проверка типа выражения
virtual Type what() const = 0;
// Операция копирования выражения
virtual Expression * copyO const = 0;
// Операции сравнения выражений
virtual bool operator == (const Expression & src) const = 0;
virtual bool operator != (const Expression & src) const {
return !operator ==(s гс);
}
};
class Integer : public Expression {
int value;
public :
// Проверка типа
Type what() const { return INTEGER; }
// Функция доступа
int getValue() const { return value; }
// Операция копирования
Expression * copyO const { return new Integer(value); }
// Оператор сравнения выражений
bool operator = (const Expression & src) const {
return sre.what() = INTEGER &&
value == ((const Integer &)src).getValue();
}
class Variable : public Expression {
string variable;
public :
// Проверка типа
Type what() const { return VARIABLE; }
// Функция доступа
string getVariable() const { return variable; }
Символьные преобразования
257
// Операция копирования
Expression * соруО const { return new Variable(variable); }
// Оператор сравнения выражений
bool operator == (const Expression & src) const {
return sre.what() == VARIABLE &&
variable = ((const Variable &)src).getVariable();
}
};
class Operator : public Expression {
char operSign; // Знак операции
Expression *operandi; // Первый операнд
Expression *operand2; // Второй операнд
public :
// Проверка типа
Type what() const { return OPERATOR; }
// Функции доступа:
char getOperSignO const { return operSign; }
Expression * getOperandl() const { return operandi; }
Expression * get0perand2() const { return operand2; }
// Операция копирования
Expression * copy() const {
return new Operator(operSign, operandi->copy(), operand2->copy());
}
// Оператор сравнения выражений
bool operator == (const Expression & src) const {
return sre.what() == OPERATOR &&
operSign == ((const Operator &)src).getOperSignO &&
^operandi == *((const Operator &)src).getOperandl() &&
*operand2 == *((const Operator &)src).get0perand2();
}
};
В данной реализации сравнение выражений производится формально, так что
с точки зрения оператора сравнения выражения (а + Ь) +с и а + (Ь + с)
будут, разумеется, различными. Более интеллектуальная операция сравнения
могла бы учитывать коммутативность и ассоциативность операций сложения
и умножения, производить константные вычисления и т. п. Конечно, мы не в
состоянии написать алгоритм, который определял бы эквивалентность
произвольных выражений в смысле возможности преобразования их друг к другу.
258
Глава 4
Насколько общим является описанный в данном разделе подход к
определению дерева выражения и операций над ним? Что изменится, если перейти от
целочисленных выражений, скажем, к выражениям над логическими
значениями (пропозициональным формулам)? Насколько легко можно расширить
номенклатуру операций? Что, если, наряду с бинарными операциями,
понадобится включать в состав выражений унарные операции или вызовы
элементарных функций?
Для ответов на эти вопросы попробуем обобщить наше дерево так, чтобы оно
допускало произвольные типы операндов и различные операции. Для этого,
прежде всего, введем более общие, чем мы это делали раньше, узлы
синтаксического дерева. Будем считать, что все узлы синтаксического дерева
делятся на три типа:
□ узлы, представляющие константы;
□ узлы, представляющие переменные;
□ узлы, представляющие операции.
Для того чтобы различать узлы синтаксического дерева по типу, введем
дополнительно три простые операции проверки типа узла:
class Expression {
public :
// Проверка типа выражения
virtual bool isConst() const = 0;
virtual bool isVariableO const = 0;
virtual bool isOperator() const = 0;
};
Теперь обобщим понятие узла целого типа до понятия узла, содержащего
некоторую произвольную константу. Для этого введем абстрактный тип данных
"константа", который будет представлен в нашей программе интерфейсом
Constant. Поскольку ни тип значений, ни способы обработки константы
заранее не оговариваются, в нашем интерфейсе не будет определено никаких
операций (кроме тех, которые определяют константную природу этого узла).
Основная цель этого интерфейса состоит в том, чтобы обеспечить отличие
константных значений от других типов узлов дерева.
class Constant : public Expression {
public :
bool isConst() const { return true; }
bool isVariableO const { return false; }
bool isOperator() const { return false; }
};
Символьные преобразования
259
Понятие константы столь же абстрактно, как и понятие выражения — не
существует объектов, которые бы являлись просто константой, так же, как и не
существует просто выражений. Поэтому абстрактные операции, введенные
нами для класса Expression, остаются такими же абстрактными и в классе
constant. Для представления конкретных констант — целых и вещественных
чисел, логических, символьных и других стандартных значений, нам
потребуется описывать реализацию НОВЫХ классов Integer, Double, Logical,
character и т. п. Эти классы будут представлять уже конкретные объекты, так
что основные операции, определенные нами для выражений, должны быть в
них реализованы. Для иллюстрации представим лишь некоторые из
описанных нами ранее операций: преобразование в строку (operator string),
сравнение выражений (operator ==) и посещение узлов выражения абстрактным
посетителем (accept). Соответствующее описание класса Expression
содержится в листинге 4.8. Там же описан и класс integer, представляющий
значения целочисленных констант. Конечно, совершенно аналогичным образом
можно было бы описать и другие типы констант. На приложенном компакт-
диске в папке "\chapter4\4.i\ExpressionExt" дополнительно представлено
определение класса Double для констант вещественного типа.
рдарева выражения -.'/^f^i^'pг^:*f&V:}~^?'■ ':j:^/?'s7-'^^^У^^'-уШШ
/ /==========^=========^^
// Базовый класс для представления синтаксического дерева
// выражения. Подклассы этого класса реализуют разные частные
// случаи представления выражений.
class Expression {
public :
// Виртуальный деструктор
virtual ~Expression() {}
// Проверка типа выражения
virtual string what() const = 0;
virtual bool isConst() const = 0;
virtual bool isVariable() const = 0;
virtual bool isOperator() const = 0;
// Оператор преобразования выражения в строковое представление
virtual operator string () const = 0;
// Операции сравнения выражений
virtual bool operator == (const Expression & src) const = 0;
virtual bool operator != (const Expression & src) const {
return ! operator =(src);
}
260
Глава 4
// Операция посещения узла
virtual void accept(Visitor & visitor) = 0;
};
// Абстрактный класс Constant представляет понятие абстрактной
// константы - целые, вещественные и другие константы.
class Constant : public Expression {
public :
bool isConstO const { return true; }
bool isVariableO const { return false; }
bool isOperator() const { return false; }
};
// Класс Integer представляет простейший тип выражения -
// целую константу.
class Integer : public Constant {
int value;
public :
// Конструктор
Integer(int value) : value(value) {}
// Проверка типа
string what() const { return INTEGER; }
// Функция доступа
int getValue() const { return value; }
// Оператор преобразования в строку
operator string () const {
char buffer[14] = {0};
sprintf(buffer, value < 0 ? "(%d)" : "%d", value);
return buffer;
}
// Оператор сравнения выражений
bool operator == (const Expression & src) const {
return sre.what() = INTEGER &&
value == ((const Integer &) src) .getValue () ;
}
Символьные преобразования
261
II Операция посещения узла
void accept(Visitor & visitor) { visitor.visit(this); }
};
Теперь необходимо обобщить понятие оператора. Конечно, несложно ввести
новые бинарные операции, например сравнение (с получением значений
нового типа — логических значений), однако несколько сложнее ввести
операции с другим числом операндов (унарные или тернарные) или включить в
число операций элементарные функции, такие как логарифм или синус
вещественного числа. Можно сразу ввести понятие оператора с произвольным
числом операндов и знаком операции, состоящим из произвольного числа
символов. Тогда частные случаи, такие как бинарный оператор, могли бы
быть определены в виде наследника такого класса, задающего оператор в
самом общем виде.
Введем абстрактный класс Operator, имеющий в своем составе знак операции
в виде символьной строки и набор операндов, представленный массивом
элементов, каждый из которых является некоторым деревом выражения.
Такой класс представлен в листинге 4.9. Несмотря на то, что этот класс тоже
является абстрактным, в данном случае некоторые операции можно
определить уже непосредственно при определении класса operator. Например, такие
операции, как конструктор класса, доступ к знаку операции и его операндам,
определение количества операндов ("арности" оператора). Некоторые другие
операции, требующие знаний о природе операции реализуемого оператора,
остаются абстрактными, и их определений нет в описании данного класса.
Там же в листинге представлен и другой класс — Binary, описывающий одну
из возможных реализаций частного случая оператора — бинарную операцию.
Конечно, операции над объектами этого класса можно сделать практически
такими же, как было определено ранее для нашего дерева в классе operator.
| Листинг 4.9. Классы, определяющие операции в обобщенном дереве
| выражения
Класс Operator представляет
операции
является
и операндов,
выражением.
каждый
выражение, состоящее из
из
Это - тоже
знака
которых в свою очередь
абстрактный класс
class Operator : public Expression {
protected:
string operSign; // Знак операции
Expression **operands; // Массив ссылок на операнды
int arity; // Количество операндов в операции
262
Глава 4
typedef Expression * PExpression;
public :
// Конструктор:
Operator(string sign, Expression **ops, int arity);
// Деструктор
-Operator();
// Проверка типа
bool isConst() const { return false; }
bool isVariable() const { return false; }
bool isOperator() const { return true; }
// Функции доступа:
string getOperSign() const { return operSign; }
Expression * operator [] (int i) const;
int getArityO const { return arity; }
// Оператор преобразования в строку
operator string () const;
// Оператор сравнения выражений
bool operator = (const Expression & src) const;
};
// Класс Binary представляет выражение, состоящее из знака
// операции и двух операндов, каждый из которых в свою очередь
// является выражением
class Binary : public Operator {
public :
// Конструктор:
Binary(string sign, Expression *opl, Expression *op2)
: Operator(sign, &opl, 2) {}
// Проверка типа
string what() const { return BINARY; }
// Функции доступа:
Expression * getOperandl() const { return (*this)[0]; }
Expression * getOperand2() const { return (*this)[l]; }
Символьные преобразования
263
// Оператор преобразования в строку
operator string () const {
return "(" + (string)*(operands[0]) + operSign +
(string)*(operands[1]) + f)f;
}
// Операция посещения узла
void accept(Visitor & visitor) { visitor.visit(this); }
};
// Реализация операций, объявленных в классе Operator
// Конструктор
Operator::Operator(string sign, Expression **ops, int arity)
: operSign(sign), operands(new PExpression[arity]), arity(arity) {
for (int i = 0; i < arity; i++) {
operands[i] = ops[i];
}
}
// Деструктор
Operator::~Operator() {
for (int i = 0; i < arity; i++) {
delete operands[i];
}
}
// Доступ к операндам
Expression * Operator::operator [] (int i) const {
if (i < 0 || i >= arity) throw OutOfRangeException();
return operands[i];
}
// Оператор преобразования в строку
Operator::operator string () const {
string res = operSign + "(";
for (int i = 0; i < arity-1; i++) {
res += ((string)*(operands[i]) + ", ") ;
}
return res + (string)*(operands[arity-1]) + ")";
}
264
Глава 4
// Оператор сравнения выражений
bool Operator::operator == (const Expression & src) const {
if (!src.isOperator() || arity != ((const Operator &)src).getArity())
return false;
for (int i = 0; i < arity; i++) {
if (! (*operands[i] == *((const Operator &)src) [i])) return false;
}
return true;
}
Мы привели реализацию только бинарных операций, однако если в дереве
будут присутствовать унарные операции и элементарные функции, то можно
легко определить дополнительные классы-наследники примерно так же, как
это сделано для бинарных операторов. Например, на приложенном компакт-
диске В файле "\Chapter4\4.l\ExpressionExt\expressionext.h" определен
класс для представления функций одного аргумента.
Классы Expression, Constant, Variable И Operator В СОВОКУПНОСТИ определяют
абстрактное описание выражений (описание класса variable остается
практически без изменений, добавляются только функции определения типа узла).
Классы integer и Binary конкретизируют абстрактное выражение, определяя
его элементарные части и способы конструирования сложных выражений из
простых. Из нашего рассмотрения выпала только функция parse, которая
ранее определялась нами как одна из основных операций абстрактного
выражения. Это не случайно, т. к. функция parse, определенная нами ранее для
преобразования строки в дерево выражения, приспособлена для анализа
выражений вполне определенного вида. Вообще, в новых условиях определить
такую функцию в достаточно общем виде просто невозможно, поскольку она
всегда будет создавать выражения, составленные из заранее определенных
частей и в абстрактном виде не имеет смысла.
Лучше всего такую функцию определить во внешнем по отношению к дереву
выражения классе. Назовем этот класс Parser и просто перенесем в него из
класса Expression определения функций parse и вспомогательных функций
dooperator и doOperators. Ниже приведено описание класса Parser, однако
реализация его операций в тексте книги опущена — она практически ничем
не отличается от реализации операций, описанных ранее в классе Expression.
Полная реализация класса есть на приложенном компакт-диске. Конечно,
этот класс приспособлен только для построения дерева из выражений
конкретного вида, в нашем случае это выражения, содержащие бинарные
арифметические операции, целые константы, переменные и скобки.
class Parser {
public :
Символьные преобразования
265
/I Метод для построения синтаксического дерева выражения
static Expression * parse(const string & source);
private :
// Переменные stExprFactory и stOperFactory задают фабрики
// стеков для генерации стека операндов (узлов синтаксического
// дерева) и стека операций (значений класса LexOperator)
/ /=====================^^
static StackFactory<Expression*> * stExprFactory;
static StackFactory<LexOperator*> * stOperFactory;
static void doOperator(
Stack<Expression*> *operands, // стек операндов
Stack<LexOperator*> *operators); // стек операторов
static void doOperators(
Stack<Expression*> *operands, // стек операндов
Stack<LexOperator*> *operators, // стек операторов
int minPrio); // граничный приоритет
};
На этом закончим раздел о представлении деревьев выражений и перейдем к
описанию алгоритмов их обработки. Мы будем использовать обобщенное
дерево в тех ситуациях, когда алгоритмы обработки деревьев применимы в
общем случае, и будем возвращаться к частному случаю простых формул с
бинарными операторами тогда, когда алгоритм обработки не имеет
всеобщего характера.
4.2. Вычисления по формулам
В этом разделе мы рассмотрим алгоритмы вычисления значения выражения
при заданных значениях переменных.
Прежде всего отметим, что выражение, представленное в виде дерева,
гораздо легче обрабатывать, чем выражение, представленное символьной строкой.
Поэтому, несмотря на то, что в разд. 2.3 уже был представлен алгоритм
вычисления значения константного выражения, чаще всего вместо него удобнее
использовать алгоритм преобразования выражения в дерево, в котором уже и
производить все вычисления.
Значение выражения можно вычислить только в том случае, когда
определены значения всех входящих в него переменных, т. е. должен быть задан
контекст для вычисления выражения. Если мы хотим написать достаточно об-
266
Глава 4
щий алгоритм, то не надо ограничиваться каким-нибудь одним
представлением контекста, поэтому будем считать, что контекст переменных — это
некоторый абстрактный тип данных, включающий в себя операции выдачи и
записи значения переменной. Как обычно, этот абстрактный тип данных
будем представлять интерфейсом Context. Он представляет собой набор
операций для представления и обработки пар из имени и значения. Конечно,
контекст переменных— это разновидность словаря, который подробно
обсуждался нами в разд. 3.2. Для представления контекста можно также
воспользоваться шаблоном классов тар библиотеки стандартных шаблонов STL.
В листинге 4.10 приведен пример реализации абстрактного типа данных
context, основанной на описании шаблона классов HashTabie из разд. 3.2.
Фактически мы просто уточнили тип значений, содержащихся в
хеш-таблице, — в нашем случае это константы класса Constant. Тело определения
класса оказалось пустым, мы просто привели в нем в виде комментариев
заголовки методов класса HashTabie, уточненные в соответствии с типом
хранимых в хеш-таблице значений.
:"«*" •»» ........v......,,..,..,,,,. ,..,., .„ ..,....v.....y..,r„„.„,,,v „,..,»„..„„..,„...„„ ..,....,....„.« ,.„; .„„.„...j.........,....^
| Листинг 4.10. Абстрактный контекст переменных /V
class Context : public HashTable<Constant> {
// Constant * add(const char * key, Object * obj);
// Constant * find(const char * key) const;
// Constant * remove(const char * key);
};
В приведенной реализации метод find выдает значение null, если переменная
отсутствует в контексте. Таким образом, во всех алгоритмах, которые будут
использовать эту реализацию контекста, следует учитывать, что переменным,
значения которых в контексте не определены, не приписывается никакого
значения по умолчанию. Конечно, легко можно представить себе и другую
реализацию, которая, например, возбуждает специальную исключительную
ситуацию при попытке вычислить значение переменной, отсутствующей в
контексте. Еще один способ представления контекста — выдавать некоторую
заранее определенную константу в качестве неопределенного значения.
Например, если речь идет только о целочисленных константах, то для
неопределенных переменных можно в качестве значения выдавать нуль. Для того
чтобы перейти к подобной реализации, нужно всего лишь переопределить
функцию find для класса context. Различные способы реализации этой функции
будут определять различные стратегии вычисления значения выражения в
заданном контексте.
Еще одно замечание. В операции проверки деревьев выражений на
совпадение (оператор сравнения выражений на равенство класса Expression) мы счи-
Символьные преобразования
267
тали различными переменные, отличающиеся только регистром букв. Если
мы хотим, чтобы переменные, различающиеся только регистром букв, имели
бы одно и то же значение, то для этого тоже достаточно лишь переписать
операции над контекстом, например, таким образом, чтобы при добавлении
переменной в контекст ее имя записывалось бы строчными буквами.
Конечно, при этом было бы разумно соответствующим образом изменить и
реализацию оператора сравнения в определении класса Expression.
Теперь, имея аппарат для определения значений, можно заняться собственно
вычислением значения выражения.
Проще всего выполнить такую реализацию методом интерпретации, т. е.
непосредственно В классе Expression определить метод evaluate (const Context &),
который и будет производить необходимые вычисления. По-видимому,
в большинстве систем обработки выражений присутствие такой операции
будет оправдано. Трудность здесь состоит только в том, что мы определили
дерево в настолько общем виде, что теперь непонятно, сможем ли мы
корректно определить достаточно общие правила вычисления значения
выражения в заданном контексте.
Некоторые правила вычисления выражений можно запрограммировать в
общем виде для всех типов узлов синтаксического дерева. Можно считать, что
результатом вычисления значения константы всегда является сама эта
константа, а результатом вычисления значения переменной является значение
этой переменной в заданном контексте. Однако с такой же легкостью
определить значение выражения, представленного произвольным оператором, не
удается, поэтому операция вычисления значения evaluate останется
абстрактной для узлов типа Operator, и ее придется переопределять для каждого
конкретного способа представления узлов-операторов.
В листинге 4.11 приведены определения необходимых функций для
вычисления значения выражения В классах Expression, Constant, И Variable, а затем
сделаны необходимые уточнения в реализации такой функции в классе
Binary.
| Листинг 4.11. Операция вычисления значения выражения у':;.. ; ; 5 j
[Csзаданном контексте :/. ■';":-:--,/."• ^ууУу,<'С{^■:-1 \, < ,.у,]\,.,\iy. -" .UУ;\/.\]{\
class Expression {
public :
// ... (прочие операции не показаны) ...
// Операция вычисления значений переменной в заданном контексте
virtual Constant * evaluate(const Context &) const = 0;
};
268
Глава 4
class Constant : public Expression {
public :
// ... (прочие операции не показаны) ...
// Операция вычисления значений переменной в заданном контексте
Constant * evaluate(const Context & ctx) const {
return this;
class Variable : public Expression {
public :
// ... (прочие операции не показаны) ...
// Операция вычисления значений переменной в заданном контексте
Constant * evaluate(const Context & ctx) const {
return ctx.find(variable);
}
};
class Binary : public Operator {
public :
// ... (прочие операции не показаны) ...
// Операция вычисления значения выражения в заданном контексте
Constant * evaluate(const Context & ctx) const;
Constant * Binary::evaluate(const
Constant * valuel = getOperandl
Constant * value2 = get0perand2
if (valuel == NULL || value2 =
switch (operSign[0]) {
case ' +' : return new Integer(
case ' -' : return new Integer(
case f *': return new Integer(
case '/': return new Integer(
case ' %' : return new Integer(
default : return NULL;
Context & ctx) const {
()->evaluate(ctx);
()->evaluate(ctx);
NULL) return NULL;
((Integer*)
((Integer*)
((Integer*)
((Integer*)
((Integer*)
((Integer*)
((Integer*)
((Integer*)
((Integer*)
((Integer*)
valuel
value2
valuel
value2
valuel
value2
valuel
value2
valuel
value2
->getValue
->getValue
->getValue
->getValue
->getValue
->getValue
->getValue
->getValue
->getValue
->getValue
return valuel;
Символьные преобразования
269
В описанной реализации нет никаких других констант, кроме целых, поэтому
при реализации вычисления значений выражений, представленных в виде
бинарного оператора, вычисленные значения операндов типа Constant* сразу
же приводятся к типу integer*. Конечно, если понадобится обработка
констант других типов, потребуется и более тщательный анализ вычисленных
значений операндов.
Если задать дерево выражения и контекст входящих в него переменных с
помощью операторов:
Expression * tree = Parser::parse("(a + b) * (с + d) + 1 + 2*a");
Context ctx;
ctx.add("a", new Integer(1));
ctx.add("b", new Integer(3));
ctx.add("с", new Integer(2));
ctx.add("d", new Integer(-1));
(здесь предполагается, что Parser — это класс, определенный для операции
преобразования из строки в дерево выражения, содержащее переменные,
целые константы класса integer и бинарные арифметические операторы класса
Binary), то оператор:
cout « (string)*expr « " has a value of " «
(string)*(expr->evaluate(ctx)) « endl;
должен напечатать следующую строку:
((((a+b)*(c+d))+l)+(2*a)) has a value of 7
Приведенное решение достаточно естественное и гибкое, в чем можно
убедиться, при попытке расширить номенклатуру представленных выражений,
добавить новые операции и т. п. Тем не менее приведем еще один способ
вычисления значения выражения, представленного синтаксическим деревом.
Второй вариант операции для вычисления значения выражения состоит в
том, что вместо определения метода evaluate внутри самого класса
Expression описывается класс-посетитель, задачей которого будет посетить
узлы выражения и собрать значение выражения по ходу посещения узлов.
Такое решение является внешним по отношению к классу Expression, т. е. не
затрагивает его определения. Таким образом, правила вычисления выражения
(метод evaluate) выносятся из класса Expression и определяются вместо этого
в специально сконструированном для вычисления выражений посетителе.
Посетитель для вычисления значения выражения может быть построен по
той же схеме, что и посетитель для создания копии выражения из
предыдущей главы. При посещении простых узлов (узлов типа "константа" и
"переменная") он непосредственно выдает значение, хранящееся в узле или в пере-
270
Глава 4
данном ему контексте. При посещении узла типа "оператор" посетитель
создаст два новых экземпляра посетителя для вычисления значений операндов
операции, а затем вычислит искомое значение, выполнив нужную операцию
над этими значениями.
Определение вычислителя значения приведено в листинге 4.12.
; Листинг 4.12. Посетитель узлов дерева выражения
I для вычисления значения выражения ч _ ч Г >;|
;.» ,.....;...,,.........:,.............,...;...........!, .>..........,.....,.. *.....^.;....!.................................:. .....г ........»..;^
class Evaluator : public Visitor {
Context * context; // Контекст вычисления
Constant * result; // Результат вычисления
public :
// Конструктор:
Evaluator(Context * ctx) : context(ctx), result(NULL) {}
// Выдача результата
Constant * getResult() const { return result; }
// Операции посещения узлов
void visit(Constant * cNode) { result = cNode; }
void visit(Variable * varNode) {
result = context->find(varNode->getVariable());
}
void visit(Operator * opNode);
};
void Evaluator::visit(Operator * opNode) {
Evaluator evl(context);
Evaluator ev2(context);
((Binary*)opNode)->getOperandl()->accept(evl);
((Binary*)opNode)->get0perand2()->accept(ev2);
Integer * valuel = (Integer*)evl.getResult();
Integer * value2 = (Integer*)ev2.getResult();
if (valuel != NULL && value2 != NULL) {
switch (((Binary*)opNode)->getOperSign()[0]) {
case f +' :
result = new Integer(valuel->getValue() + value2->getValue());
break;
case ' -':
result = new Integer(valuel->getValue() - value2->getValue());
break;
case f *':
result = new Integer(valuel->getValue() * value2->getValue());
break;
Символьные преобразования
271
case ' /' :
result = new Integer(valuel->getValue() / value2->getValue());
break;
case '%':
result = new Integer(valuel->getValue() % value2->getValue());
break;
}
}
}
Реализация вычислителя выражений с помощью посетителя отличается от
реализации с помощью внутреннего метода evaluate только оформлением,
однако все же она носит более общий характер. Каким простым бы ни
казалось определение функции evaluate класса Expression, все же оно содержит в
себе, например, тот факт, что значением любой константы является она сама,
а, соответственно, значением любого выражения является ссылка на объект
класса constant. В то же время реализация вычисления выражения с
помощью посетителя может трактовать значение константы и немного по-
другому, например, считая значением выражения целое число типа int.
Пример использования операции вычисления значения выражения будет
теперь выглядеть чуть сложнее (считаем, что дерево tree и контекст
вычислений ctx заданы так же, как и в предыдущем случае):
Evaluator evaluator(&ctx);
expr->accept(evaluator);
cout « (string)*expr « " has a value of "
« (string)*evaluator.getResult() « endl;
Все это может показаться чересчур сложным для такой простой задачи, как
вычисление значения выражения по заданным значениям переменных, но вы
сможете ощутить преимущества подобной организации вычислений, если
попробуете расширить область применимости приведенного примера.
Попробуйте, например, заменить целочисленные константы на вещественные,
расширить набор допустимых операций, ввести унарные операции и
элементарные функции. Вы увидите, насколько просто и естественно
осуществляются такие изменения, и именно этот факт и является основным
достоинством приведенного подхода.
Основой для ваших экспериментов может послужить программа,
содержащаяся на КОМПакт-ДИСКе В папке "\Chapter4\4 .2\Evaluation".
272
Глава 4
4.3. Преобразование формул
В этом разделе мы приведем несколько алгоритмов различных
преобразований заданного дерева выражения.
Сразу же оговоримся, что под преобразованием всегда будет пониматься не
столько изменение структуры и содержания имеющегося выражения, сколько
построение нового дерева выражения, основанного на исходном дереве
выражения. Другими словами, наши выражения всегда будут константными.
Если внимательно изучить приведенные выше примеры обработки
выражений, то можно заметить, что практически все реализованные нами операции
над выражениями имеют атрибут const, т. е. не изменяют исходного
выражения. Единственной операцией, нарушающей это неявное соглашение,
является операция посещения дерева выражения accept. Таким образом, эта
операция не накладывает никаких ограничений на способы обработки узлов дерева
посетителем. В действительности до сих пор мы проектировали все наши
посетители таким образом, чтобы они не изменяли исходное выражение, так
что свойство константности выражения можно распространить и на эту
операцию, добавив описатели const в соответствующие места программы.
Такой подход к преобразованию формул может показаться не очень
естественным, можно подумать, что константность выражений приводит к
излишним расходам ресурсов памяти и времени, поскольку мы всегда вместо
локальных изменений внутренней структуры дерева будем строить новую
структуру данных. Однако и это не совсем верно. Определенные выгоды мы
сможем получить, например, в том случае, когда отдельные одинаковые
части строящегося дерева встречаются в дереве неоднократно. В этом случае мы
имеем возможность использовать только один экземпляр такой
повторяющейся части, а это будет означать и экономию памяти, и в некоторых случаях
даже экономию времени. Правда, это случится только в том случае, когда
некоторые изменения в структуре дерева будут допустимы; в данном разделе
такие ситуации оговорены особо.
В данном разделе мы рассмотрим три случая преобразования выражений:
подстановка в дерево вместо переменных других подвыражений; упрощение
формул и дифференцирование формулы.
Первая из этих задач — подстановка переменных — возникает в случаях
обработки выражений достаточно часто как результат ведения аналитических
преобразований. Все школьные задачи по физике обычно сводятся к тому,
что в одни известные формулы вместо некоторых переменных подставляются
другие формулы, а затем производится упрощение полученного результата.
Вторая задача — дифференцирование формулы — часто используется в
качестве примера программирования преобразования с хорошо известными про-
Символьные преобразования
273
стыми правилами, которые, тем не менее, могут привести к значительным
изменениям и усложнению исходного выражения.
Третья задача— это задача упрощения выражения. В большинстве случаев
упрощение совершенно необходимо делать после сложных преобразований,
например, таких, как в предыдущих двух задачах. Упрощение в обоих
случаях позволит представить результаты обработки в более компактном виде.
Рассмотрим эти три задачи более подробно.
Задача о подстановке переменных внешне выглядит очень похожей на задачу
о вычислении значения выражения. Различие состоит лишь в том, что в
случае вычисления значения контекст переменных хранит в качестве значения
переменных константы, и, соответственно, результатом всего процесса будет
вычисленное значение константы. В случае же подстановки контекст
переменных будет содержать в качестве значений переменных подставляемые
подвыражения и, соответственно, результатом вычислений будет измененное
дерево выражения.
Существенное отличие этой задачи от задачи вычисления выражения
заключается в том, что подстановку значений переменных можно произвести, даже
не зная, какие конкретно операторы используются в исходном выражении.
Действительно, все, что нужно сделать,— это выполнить подстановку во
всех операндах операции, независимо от ее семантики или внешнего
представления. На первый взгляд кажется, что это позволит написать функцию
подстановки независимо от информации о том, какие именно операторы
используются в выражении. Однако здесь имеется одна трудность.
Если мы хотим сохранить свойство константности деревьев выражений, то в
ходе операции подстановки нам надо будет создавать новые узлы,
содержащие те же операторы, что и узлы исходного дерева. Без знания о том, что это
за операции, невозможно использовать для этой цели конструктор. Правда,
в классе operator определен конструктор, однако с его помощью невозможно
создавать новые объекты, поскольку этот класс является абстрактным, в нем
недоопределены некоторые абстрактные операции родительского класса
Expression.
Очевидно, в определенном нами классе operator не хватает средств для того,
чтобы можно было более свободно манипулировать операндами. Такие
средства можно добавить разными путями, но нужно иметь в виду, что тогда
можно потерять свойство константности дерева выражения. В качестве
простого решения проблемы предлагается добавить в абстрактный класс
Operator еще одну абстрактную функцию — copywithNewArgs, которая должна
для заданного оператора построить новый оператор с тем же самым знаком
операции, но с другими значениями операндов. Можно сказать, что эта
функция представляет собой что-то вроде абстрактной фабрики,
позволяющей строить новые операторы на базе заданного.
274
Глава 4
Реализации абстрактного оператора (такие, как Binary) должны определять
реализацию этой функции так, чтобы она создавала копию оператора с
новыми значениями операндов. Каждая новая реализация функции copyWithNewArgs
будет задавать конкретную фабрику для создания операторов. Вот как будут
выглядеть теперь классы operator и Binary (показаны только определения
НОВОЙ функции copyWithNewArgs).
class Operator : public Expression {
public :
// Создание копии узла с тем же знаком операции, но другими операндами
virtual Expression * copyWithNewArgs(Expression** otherOps) const = 0;
};
class Binary : public Operator {
public :
// Создание копии узла с тем же знаком операции, но другими операндами
Expression * copyWithNewArgs(Expression** otherOps) const {
return new Binary(operSign, otherOps[0], otherOps[1]);
}
};
Теперь, имея возможность создавать копию оператора произвольного типа
с новыми значениями аргументов, мы можем полностью реализовать
подстановку переменных с помощью посетителя, который будет использовать
контекст переменных для выполнения этой задачи. В листинге 4.13 представлена
реализация контекста переменных со значениями в виде деревьев выражений
и реализация посетителя, формирующего при обходе узлов дерева выражения
новое дерево, в котором все переменные заменены поддеревьями, взятыми из
заданного контекста.
j Листинг 4.13. Решение задачи подстановки переменных , \:\\*:$#1&А
I в заданном выражении ;':W/'/; ..'/.*■'• ;-v:v^'■•^^Ц^'V:!v''г'^' - --> "'•- /-Л1
class Context : public HashTable<Expression> {
// Expression * add(const char * key, Object * obj);
// Expression * find(const char * key) const;
// Expression * remove(const char * key);
};
//■
Символьные преобразования
275
class Substitutor : public Visitor {
Context * context; // Контекст вычисления
Expression * result; // Результат вычисления
public :
// Конструктор:
Substitutor(Context * ctx) : context(ctx), result(NULL) {}
// Выдача результата
Expression * getResultO const { return result; }
// Операции посещения узлов
void visit(Constant * cNode) { result = cNode; }
void visit(Variable * varNode) {
Expression * substitution = context->find(varNode->getVariable());
result = substitution ? substitution : varNode;
}
void visit(Operator * opNode);
};
void Substitutor::visit(Operator * opNode) {
int nOps = opNode->getArity();
Expression * newOperands[nOps];
for (int i = 0; i < nOps; i++) {
Substitutor opSubs(context);
(*opNode)[i]->accept(opSubs);
newOperands[i] = opSubs.getResult();
}
result = opNode->copyWithNewArgs(newOperands);
}
С помощью посетителя Substitutor мы можем подставлять вместо некоторых
(быть может, всех) переменных выражения. Например, давайте подставим
в формулу для кинетической энергии материальной точки:
т х v2 / 2,
вместо переменной v формулу мгновенной скорости при равноускоренном
движении с начальной скоростью vo и ускорением а в момент времени t:
v = vo + a x /.
Если считать, что в выражении допустима операция деления, а операция
возведения в квадрат выражена с помощью умножения, то такую подстановку
можно выразить с помощью следующих операторов:
// Построим дерево формулы для кинетической энергии
Expression * energyO = Parser::parse("m * v * v / 2");
cout « "Initial expression: " « (string)*energy0 « endl;
276
Глава 4
// Создадим контекст, в котором переменной v соответствует формула
Context speeds-
speed, add("v", Parser::parse("vO + a*t"));
cout « "Substitution " « (string)*speed.find("v")
« " instead of variable v" « endl;
// Посетитель substitutor использует построенный контекст для замены
Substitutor substitutor(&speed);
energyO->accept(substitutor);
// Берем новую формулу и печатаем ее
Expression * energyl = substitutor.getResult();
cout « "Result: " « (string)*energyl « endl;
Исходное дерево выражения, полученное после анализа выражения т х v2 / 2,
будет выглядеть, как показано на рис. 4.6. Дерево, представляющее
выражение vo + a x /, используемое в подстановке вместо переменной v,
представлено на рис. 4.7. Наконец, результат подстановки приведен на рис. 4.8.
Обратите внимание, что при подстановке одно и то же дерево, содержащееся в
контексте переменных, было использовано дважды, поэтому в результирующем
дереве образуются две ссылки на одно и то же поддерево. Строго говоря,
результат подстановки не будет деревом — это уже ациклический
ориентированный граф.
То, что в результате подстановки получится уже не дерево, может довольно
неприятным образом повлиять на работу деструктора выражения. Дело в том,
что при исполнении деструктора происходит обход дерева выражения, и все
его компоненты последовательно уничтожаются. Поскольку в получившемся
дереве на некоторые из его поддеревьев могут существовать несколько
указателей, то при освобождении памяти деструктор для этих узлов тоже может
быть запущен неоднократно. Конечно, это может привести к краху
программы. В ситуации с подстановкой подвыражений может быть и другая
неприятность: при уничтожении дерева, полученного в результате подстановки,
будут затронуты не только узлы исходного дерева, но также и узлы тех
выражений, которые содержатся в контексте подстановки. Уничтожение этих
подвыражений, возможно, использующихся не только в уничтожаемом
дереве, может оказаться непредвиденным побочным эффектом уничтожения
дерева.
Можно предложить несколько решений этой проблемы. Во-первых, можно
написать операторы обработки деревьев, в том числе и деструктор дерева
выражения, более аккуратно, так, чтобы память, занятая узлами дерева,
фактически освобождалась бы только тогда, когда на нее не окажется больше ни
одной ссылки. Для этого в каждом узле дерева надо организовать счетчик
количества ссылок на этот узел и модифицировать все операции, в результате
Символьные преобразования
m
I V 1
V
Рис. 4.6. Исходное дерево выражения
I a I It
Рис. 4.7. Дерево выражения, подставляемого вместо переменной v
Рис. 4.8. Результат подстановки
278
Глава 4
которых появляются или уничтожаются ссылки на узлы, так, чтобы они
соответствующим образом изменяли этот счетчик. Такая техника применяется
довольно часто при обработке сложных объектов и носит название умных
указателей. Обсуждение техники умных указателей приведено в данной
книге в главе 8.
Во-вторых, можно модифицировать все операции над деревьями выражений
таким образом, чтобы каждый раз, когда на некоторый узел дерева
появляется новая ссылка, фактически создавалась бы новая копия выражения, на
которое эта ссылка указывает. Например, при подстановке в дерево вместо
заданной переменной выражения из контекста следовало бы каждый раз перед
подстановкой создавать копию подставляемого выражения. Соответственно,
перед удалением ссылки дерево, на которое ссылка указывала, необходимо
уничтожать с помощью оператора delete.
В-третьих, можно вообще не уничтожать никаких узлов деревьев выражений,
считая, что потери памяти из-за этого будут незначительными или
несущественными для заданных условий применения программы. Иногда можно
локализовать участки программы, где происходит создание и обработка деревьев
выражений, и для каждого такого участка использовать для размещения
узлов деревьев свою собственную систему распределения памяти. В этом
случае можно не заниматься текущим освобождением памяти по ходу обработки
деревьев, а вместо этого освобождать сразу всю захваченную в процессе
обработки выражений память в конце участка программы, занятого обработкой
выражений. Подробнее о системах распределения памяти и их использовании
для размещения различных объектов будет рассказано в главе 5.
Вернемся к нашему примеру. В результате исполнения подстановки и вывода
ее результата в выходном потоке будет получена следующая строка:
( ( (m* (v0+ (a*t) ) ) * (v0+ (a*t) ) ) /2)
Теперь можно продолжить пример. Добавим в контекст переменных
подстановку вместо начальной скорости vO нулевого значения и произведем новую
подстановку.
// Подставляем 0 вместо переменной vO
speed.add("vO", Parser::parse("0") ) ;
cout « "After substitution 0 instead of vO" « endl;
energyl->accept(substitutor);
Expression * energy2 = substitutor.getResult();
cout « (string)*energy2 « endl;
В процессе подстановки будет создана новая копия дерева выражения,
поэтому часть дерева, представляющая подвыражение vo + а х /, будет
дублирована, и дерево приобретет вид, показанный на рис. 4.9.
Символьные преобразования 279
Рис. 4.9. Результат подстановки в выражение значения v0= О
Результат исполнения всей последовательности операторов представлен в
следующей строке:
(((m*(0+(a*t)))*(0+(a*t)))/2)
Конечно, надо бы упростить полученное выражение, убрав нулевые
слагаемые, однако пока мы не имеем средств для того, чтобы сделать это. Нашей
следующей задачей как раз и будет упрощение формул.
При упрощении формулы производится замена узлов дерева выражения на
другие узлы, имеющие более простую структуру. Снова возникает вопрос
о константности, и опять будем разрешать его теми же методами. Вместо
изменения структуры имеющегося дерева построим на его основе новое дерево
с более простой структурой.
Насколько серьезными могут быть упрощения? Это, конечно, зависит от
глубины анализа выражения, который вы хотите и можете проделать. Мы
ограничимся следующими простыми преобразованиями:
□ добавление и вычитание нуля можно убрать, результатом упрощения
будет второй операнд;
□ умножение и деление на единицу можно убрать, результатом упрощения
также будет второй операнд;
□ умножение на ноль и ноль, деленный на ненулевое значение, дают в
результате ноль;
□ операции над константными значениями можно выполнить (для этого
пригодится метод evaluate с пустым контекстом переменных) и заменить
оператор на полученный результат.
280
Глава 4
Чтобы проделать все эти преобразования, запрограммируем посетителя,
который при обнаружении бинарной операции будет исследовать операнды в
поисках одного из четырех перечисленных случаев и, в случае удачного
поиска, станет выполнять соответствующие преобразования (точнее, строить
новый узел в качестве результата упрощения).
Несмотря на то, что наш посетитель будет написан для абстрактного дерева,
он, конечно, сможет выполнять преобразования только для тех операторов,
смысл которых ему известен. Поэтому преобразования будут выполнены
только для операторов, представляющих бинарные операции. Определение
соответствующего класса представлено в листинге 4.14.
| Листинг 4.14, Посетитель для упрощения дерева выражений <
// simplifier.h
//=============================^
// Класс Simplifier определяет посетителя, задачей которого служит
// преобразование операций в выражении там, где возможно их упрощение.
class Simplifier : public Visitor {
Expression * result; // Результат упрощения
static Constant * zero; // Константа - ноль
static Constant * one; // Константа - единица
public :
// Конструктор
Simplifier() : result(NULL) {}
// Функция доступа к результату упрощения
Expression * getResult() const { return result; }
// Функции посещения узлов
// Константа упрощению не подлежит:
void visit(Constant * cNode) { result = cNode; }
// Переменная также не упрощается:
void visit(Variable * vNode) { result = vNode; }
// Оператор можно упростить, если это бинарный оператор
void visit(Operator * opNode);
};
// simplifier.cpp
Constant * Simplifier::zero = new Integer(0);
Constant * Simplifier::one = new Integer(1);
Символьные преобразования
281
void Simplifier::visit(Operator * opNode) {
if (opNode->what() != BINARY) {
// Упрощаем только бинарные операторы
result = opNode;
} else {
Binary * binOperator = (Binary *)opNode;
// Выделяем операнды операции и упрощаем их с помощью
// созданных специально для этой цели посетителей:
Simplifier visitorl, visitor2;
binOperator->getOperandl()->accept(visitorl);
bin0perator->get0perand2()->accept(visitor2);
Expression * opl = visitorl.getResult();
Expression * op2 = visitor2.getResult();
string oper = binOperator->getOperSign();
// Теперь разбираем отдельные частные случаи упрощений:
// Случай 1: 0+е и 1*е заменяем на е
if ((*opl = *zero && oper == "+") ||
(•opl == *one && oper == "*")) {
result = op2;
// Случай 2: е+0, е-0, е*1 и е/1 заменяем на е
} else if ((*ор2 == *zero && (oper = "+" || oper = "-")) I I
(*op2 = *one && (oper == "*H || oper == '7"))) {
result = opl;
// Случай 3: e*0, 0*e и 0/е заменяем на ноль
} else if (((*opl == *zero || *op2 == *zero) && oper = "*") ||
((*opl = *zero && *op2 != *zero) && oper = "/")) {
result = zero;
// Случай 4: операнды - константы; производим вычисления
} else if (opl->isConst() && op2->isConst()) {
// Константные вычисления можно проводить в пустом контексте
Context empty;
result = (new Binary(oper, opl, op2))->evaluate(empty);
// Случай 5: общий. Узел строится заново из знака
// операции и упрощенных операндов
} else {
result = new Binary(oper, opl, op2);
}
}
}
В приведенном алгоритме сначала рекурсивно упрощаются операнды
бинарной операции, причем для этого используются дополнительно созданные эк-
282
Глава 4
земпляры посетителя simpiifier. На самом деле можно было взять тот же
самый экземпляр посетителя, "проталкивая" его в операнды и получая
результат на выходе, но приведенный вариант кажется нам более ясным и
естественным. После упрощения операндов разбираются частные случаи
возможного упрощения анализируемой операции, причем для этого
используются некоторые из ранее разобранных операций. Для сравнения с константами
служит оператор сравнения на совпадение, определенный для деревьев, а для
константных вычислений— операция evaluate с пустым контекстом
переменных.
Вспомним теперь, что при подстановке в формулу для вычисления
кинетической энергии вместо переменной vO нулевого значения мы получили
выражение
(((mM0+(a*t)))*(0+(a*t)))/2)
Давайте упростим теперь это выражение с помощью посетителя класса
simpiifier, продолжив пример, начатый несколькими страницами раньше.
Simpiifier simpiifier;
energy2->accept(simpiifier);
Expression * епегдуЗ = simpiifier.getResult();
cout « "After simplification: " « (string)*energy3 « endl;
Выражение упростится до:
(((m*(a*t))*(a*t))/2)
Если теперь подставить нулевое значение также и вместо переменной а, а
затем вновь упростить полученный результат:
speed.add("a", Parser::parse("0"));
energy3->accept(substitutor);
Expression * energy4 = substitutor.getResult();
cout « "0 substituted instead of \'a\': " « (string)*energy4 « endl;
energy4->accept(simpiifier);
cout « "After the last simplification: "
« (string)^simpiifier.getResult() « endl;
то выражение упростится до нуля, и в выходном потоке окажется строчка:
After the last simplification: 0
Заметим, что при проведении упрощения выражений также возможно
образование нескольких указателей на одни и те же подвыражения (иногда в
таких случаях говорят, что происходит разделение подвыражений). В данном
случае это касается, в основном, указателей на константы zero и one, ссылки
на которые могут появляться в результирующем выражении многократно.
Символьные преобразования
283
Таким образом, при уничтожении выражений, полученных после упрощения,
могут также возникнуть проблемы в отведении и освобождении памяти,
описанные выше при обсуждении операции подстановки подвыражений вместо
переменных в выражении.
Подстановка и упрощение — это, наверное, наиболее часто
употребляющиеся и естественные способы преобразования выражений. Однако так же
просто можно реализовать и более сложные преобразования. В качестве
последнего примера в этой главе рассмотрим дифференцирование (вычисление
первой производной) выражения по заданной переменной. Опять ограничимся
только дифференцированием бинарных операций, констант и переменных,
однако если выражение будет составлено из других элементов, включающих,
например, элементарные функции, то алгоритм легко можно распространить
и на эти дополнительные случаи.
Напомним правила построения первой производной для всех
использующихся нами типов выражений:
□ производная любой константы есть ноль;
□ производная переменной есть единица, если это та переменная, по которой
производится дифференцирование, в остальных случаях производная
переменной равна нулю;
□ производные для четырех арифметических операций вычисляются по
следующим формулам (операция получения остатка от целочисленного
деления по понятным причинам в список операций не включена):
• (u± v)5 = u± v5;
• fwxv)' = w'xv = wx v';
• (u I v)' = (u' x v - u x v') / v2.
Используя эти формулы, несложно написать соответствующий
преобразователь выражения по той же самой схеме, что уже использовалась нами во всех
примерах данного раздела. Посетитель Diff, представленный в листинге 4.15,
выполняет эту работу.
[Листинг4.15. Посетитель, вычисляющий первую производную выражения
// diff.h
// Класс Diff определяет посетителя, задачей которого служит
// вычисление первой производной выражения по заданной переменной.
/ /===============================^==========^=================^
class Diff : public Visitor {
string variable; // Переменная, по которой берется производная
284
Глава 4
Expression * result; // Результат упрощения
static Constant * zero; // Константа - ноль
static Constant * one; // Константа - единица
public :
// Конструктор
Diff(const string & var) : variable(var), result(NULL) {}
// Функция доступа к результату упрощения
Expression * getResult() const { return result; }
// Функции посещения узлов
// Производная константы равна нулю:
void visit(Constant * cNode) { result = zero; }
// Производная переменной равна нулю либо единице в зависимости
//от того, та ли это переменная, по которой берется производная
void visit(Variable * vNode) {
result = vNode->getVariable() == variable ? one : zero;
}
// Наиболее сложная работа выполняется при посещении бинарной операции
void visit(Operator * opNode);
};
// diff.cpp
Constant * Diff::zero = new Integer(0);
Constant * Diff::one = new Integer(1);
void Diff::visit(Operator * opNode) {
if (opNode->what() != BINARY) {
// Дифференцируем только бинарные операторы
result = opNode;
} else {
Binary * binOperator = (Binary *)opNotie;
// Выделяем операнды операции и вычисляем их производные
//с помощью созданных специально для этой цели посетителей:
Diff visitorl(variable), visitor2(variable);
Expression * opl = binOperator->getOperandl();
opl->accept(visitorl);
Expression * opldash = visitorl.getResult();
Символьные преобразования
285
Expression * ор2 = bin0perator->get0perand2();
op2->accept(visitor2) ;
Expression * op2dash = visitor2.getResult();
string oper = binOperator->getOperSign();
// Теперь разбираем отдельные частные случаи:
// Случай 1: производная суммы и разности
if (oper = "+" || oper == "-") {
result = new Binary(oper, opldash, op2dash);
// Случай 2: производная произведения
} else if (oper =="*") {
result = new Binary("+", new Binary("*", opldash, op2),
new Binary("*", opl, op2dash));
// Случай З: производная частного
} else if (oper =="/") {
result = new Binary("/",
new Binary("-", new Binary("*", opldash, op2),
new Binary{"*", opl, op2dash)),
new Binary("*", op2, op2));
// Для других знаков операций результат не определен
} else {
result = NULL;
}
}
}
Так же, как и в случае подстановки и упрощения выражения, после операции
дифференцирования дерево, строго говоря, может оказаться уже не деревом
из-за того, что фактически из разных его узлов могут образоваться указатели
на одни и те же подвыражения. Давайте проследим, как меняется дерево при
выполнении операции дифференцирования на простом примере.
На рис. 4.10 изображено дерево выражения (х + 1) / (х - 1). Узлы этого
дерева, как и раньше, изображены геометрическими фигурами разной формы
в зависимости от типа узла. Квадраты содержат переменные, круги —
константы, а треугольники — знаки бинарных операций. Такое дерево могло бы
быть порождено с помощью вызова функции:
Parser::parse("(x+l)/(x-l)"};
После дифференцирования этого выражения по переменной х будет
построено дерево, содержащее довольно большое количество нулей и единиц,
причем эти нули и единицы не строятся каждый раз заново, а постоянно
используются одни и те же константы, определенные как статические переменные
286
Глава 4
класса Dif f. Кроме того, во время дифференцирования частного одни и те же
выражения используются при построении бинарных операций многократно.
Поэтому после дифференцирования будет построена довольно сложная
конструкция, которую можно изобразить так, как показано на рис. 4.11.
© и ©
Рис. 4.10. Дерево выражения (х + 1)/(х - 1)
Рис. 4.11. Дерево, получившееся после дифференцирования выражения (х + 1)/(х - 1)
Конечно, получившееся дерево можно и нужно упростить, например, с
помощью посетителя класса simpiifier. В результате упрощения многие узлы
исчезнут, но некоторые узлы, наоборот, появятся. Это произойдет потому,
что посетитель simpiifier некоторые узлы дерева игнорирует, а другие
создает заново (копирует), так что после упрощения дерево приобретет вид, как
на рис. 4.12. Структура дерева после обработки становится действительно
более простой, однако количество узлов в обеих структурах оказалось
одинаковым. Правда, количество связей после упрощения несколько сократилось:
в дереве, построенном "упростителем", вместо 18 ссылок осталось всего 14.
Все примеры преобразований выражений этого раздела можно найти на
приложенном КОМПакт-ДИСКе В папке "\Chapter4\4.3\Conversion".
Символьные преобразования
287
Рис. 4.12. Дерево после упрощения результата дифференцирования
В заключение данного раздела приведем одно замечание.
Говоря в разд. 4.1 о представлении выражения, мы утверждали, что довольно
легко ввести новые типы узлов (вещественные константы, функции одного
аргумента и т. п.). Действительно, это сделать несложно. В данном разделе
мы тоже очень легко и просто вводили новые способы анализа и
преобразования выражений, оставляя неизменной структуру выражения. Однако теперь
оказывается, что после того, как определены многочисленные посетители
узлов выражения, изменить его структуру уже далеко не так просто.
Попробуйте ввести в набор используемых операторов операцию возведения в
целую неотрицательную степень. Соответственно, вам придется добавить
новые правила для константных вычислений, новые правила упрощения
(выражение в нулевой степени равно единице и др.), новые правила
дифференцирования формул. Если вы проделаете все это, то увидите, что изменение
состава и структуры выражений обходится довольно дорого. Надо внести
много мелких изменений в разные места реализации. Это говорит о
некоторой ограниченности нашего подхода к организации дерева выражения. Он
хорошо приспособлен только для случая, когда в процессе реализации
структура выражений меняется редко. Тем не менее в рамках этой книги мы не
будем рассматривать других подходов к организации сложных структур данных
вообще и, в частности, деревьев выражений.
ГЛАВА 5
Алгоритмы
распределения памяти
Системы распределения памяти необходимы для того, чтобы выделять место
под программные объекты и освобождать ранее выделенную память, когда
минует надобность в ней. В языке С эту задачу решали с помощью
системных вызовов maiioc, caiioc и free. В языке C++ тоже можно пользоваться
этими же функциями, однако гораздо удобнее использовать конструкции
языка new и delete, которые, кроме обеспечения распределения памяти,
позволяют еще и вызвать конструкторы и деструктор объектов. В большинстве
случаев вполне достаточно иметь встроенные механизмы работы этих
примитивов, но в языке разрешается переопределять алгоритмы работы этих
операторов для более эффективного решения той или иной задачи.
В разд. 2.2 описывалась цифровая сортировка элементов массива (ключей),
при которой требовалось многократно строить и уничтожать списки
элементов массива. Конечно, можно было бы при добавлении каждого элемента в
список отводить память под него с помощью оператора new, а когда элемент
возвращался в массив освобождать отведенную под него память с помощью
оператора delete. Однако такая реализация алгоритма имела бы очень
небольшую скорость работы из-за частых вызовов сложных системных
механизмов распределения памяти, да и количество фактически затрачиваемой
памяти тоже было бы довольно большим, поскольку система всегда
резервирует некоторое дополнительное количество памяти при каждом вызове
оператора new ДЛЯ СВОИХ НуЖД.
Для реализации цифровой сортировки мы построили небольшую
узкоспециализированную систему распределения памяти, которая отводила память под
элементы списков внутри некоторого пула памяти. Обращение к встроенной
системе распределения памяти при этом производилось только один раз,
когда надо было отвести память под весь пул. В дальнейшем вся работа с
элементами списков производилась нашей специализированной системой рас-
Алгоритмы распределения памяти
289
пределения памяти, и лишь в конце сортировки деструктор пула памяти
вновь обращался к системе программирования для возврата всей заказанной
памяти в систему.
В этой главе мы рассмотрим несколько способов организации систем
распределения памяти, которые позволят нам самим программировать различные
способы отведения и освобождения памяти в соответствии с потребностями
решаемой задачи. Кроме того, мы рассмотрим, как язык C++ позволяет
соединить удобство пользования операторами new и delete с преимуществами,
которые может предоставить система распределения памяти, реализованная
программистом.
5.1. Абстрактная система
распределения памяти
Дня того чтобы распределять память и управлять ею, необходимо иметь
некоторый ее ресурс в своем распоряжении. Везде в этой главе мы будем
считать, что такой ресурс памяти имеется в виде одного массива байтов
достаточной величины. В этих условиях система должна предоставлять
возможность выделения участков этой памяти требующейся длины (конечно, если
свободная память еще имеется в наличии) и утилизации ранее выделенных
участков памяти для последующего использования, когда у пользователя
системы отпадает надобность в них. Можно считать, что система
распределения памятью определяет абстрактный тип данных пул памяти, над
которым определены две основные операции: заказ и освобождение памяти. Если
считать, что пользователь системы получает участки памяти в виде
указателей неопределенного типа (void *), то интерфейс с такой абстрактной
системой может быть описан следующим образом:
class MemoryManagement {
public :
// Метод get обеспечивает заказ участка памяти размером bytes байтов
//и выдает указатель на выделенный фрагмент памяти. Данный фрагмент
// исключается из системы и может быть использован для хранения
// произвольной информации. Если система не может выделить непрерывный
// участок памяти запрошенного размера, то в качестве результата
// выдается пустой указатель NULL.
virtual void * get(size_t bytes) = 0;
// Метод release возвращает ранее заказанный участок памяти в систему.
// Система гарантирует правильную работу метода только в том случае,
// если указатель area, переданный методу в качестве аргумента,
// является указателем, ранее выданным методом get.
290
Глава 5
//В систему возвращается весь выделенный ранее участок памяти;
// возврат памяти по частям невозможен.
virtual void release(void * area) = 0;
};
Надо сказать, что наличие метода release для освобождения памяти и
утилизации ее в системе — это очень важный момент. Если такого метода нет или
он по тем или иным соображениям не нужен, то реализация системы
распределения памяти может быть очень простой. Именно такая простая система и
была представлена в разд. 2.2 при описании реализации цифровой
сортировки. Там надобности в операции release не возникало, поскольку весь пул
памяти очищался сразу.
Мы вводим интерфейс с системой распределения памяти на языковом уровне.
Функция такой системы — это создание надстройки над средствами
управления памятью, которые представлены в ядре языка. Функции такой
создаваемой в программе системы — это предоставление простых или
узкоспециализированных средств по управлению памятью для нужд программы или ее
отдельных частей. Однако можно заметить, что сами стандартные языковые
средства предоставляют некоторую реализацию подобного же интерфейса.
Действительно, роль методов get и release в языке С играют системные
функции maiioc (caiioc) и free. Ту же роль в языке C++ исполняют
операторы new И delete.
Здесь уместно заметить, что сами по себе операторы new и delete не
предполагают никакой реализации системы распределения памяти, они лишь
опираются на некоторую такую систему, предоставляемую языком. Однако если
у нас в программе представлена другая система управления памятью, то мы
можем вместо стандартной системы использовать именно ее. Такая
возможность реализуется с помощью переопределения операторов new и delete для
любого из классов, определяемых в программе.
Поясним сказанное на примере. Возьмем реализацию метода цифровой
сортировки, приведенную нами в разд. 2.2, и изменим в ней систему построения
списков отдельных элементов массива. В листинге 2.11 список ключей был
представлен шаблоном KeyList, параметризованным типом ключей Key, при
этом в классе были определены следующие основные три метода:
// добавляет ключ key в список в качестве последнего элемента:
void addKey(const Key & key);
// переносит все ключи из списка в массив array, начиная с индекса from:
int toArray(Key * array, int from);
// очищает список:
void clear();
Алгоритмы распределения памяти
291
Реализация этих трех методов непосредственно использовала работу с
буфером памяти класса ListBuffer<Key>, указатель на который мы должны были
передавать конструктору списка при его создании. Буфер памяти состоял из
элементов списков класса Е1ет<кеу>, и для того, чтобы поместить очередной
элемент списка в этот буфер, мы вызывали метод get:
int nextElem = buffer->get();
Теперь поступим по-другому. Будем строить список из ключей
традиционным образом, используя для связи элементов указатели. В листинге 5.1
приведен новый шаблон KeyList, в котором определение списка ключей сделано
вполне традиционно. Конструктор списка здесь очень близок к
традиционному. Подобный конструктор мы использовали уже многократно в примерах
разд. 1.2, 2.3, 2.4 и др. При добавлении нового ключа в список будем
применять оператор new так, как мы это обычно и делали, и, таким образом,
функция добавления нового элемента теперь тоже примет уже ставший
привычным вид.
| Листинг 5.1. Определение списка элементов для цифровой сортировки j
// Класс KeyList представляет список ключей, связанных указателями.
template <class Key>
class KeyList {
Elem<Key> * first; // Указатель на первый элемент списка
Elem<Key> * last; // Указатель на последний элемент списка
public :
// Конструктор
KeyList() : first(NULL), last(NULL) {}
// Операция добавления нового элемента в конец списка
void addKey(const Key & key) {
// Запрашиваем свободный элемент у системы распределения памяти
Elem<Key> * newElem = new Elem<Key>(key);
// Присоединяем новый элемент к уже имеющемуся списку
if (first = NULL) {
first = newElem;
} else {
last->next = newElem;
}
last = newElem;
}
292
Глава 5
// Операция toArray переносит все элементы списка во фрагмент
// массива array, начиная с элемента с индексом from.
//В качестве результата функция выдает индекс первого элемента
// массива, следующего за перенесенным фрагментом
int toArray(Key * array, int from) {
// Организуем просмотр элементов с помощью указателя ptr.
Elem<Key> * ptr = first;
while (ptr != NULL) {
array[from++] = ptr->value;
ptr = ptr->next;
}
return from;
}
// Функция очистки списка просто обнуляет указатели
//на первый и последний элементы списка.
void clear() { first = last = NULL; }
};
Немного необычно реализован только метод clear для удаления из списка
всех его элементов: в нем не происходит освобождения памяти с помощью
оператора delete, как это обычно делалось при реализации списков, вместо
этого просто обнуляются указатели на первый и последний элементы списка.
Если реализацию шаблона Е1ет<кеу> оставить без изменения (напомним, что
в разд. 2.2 он был описан в виде простой структуры с двумя полями,
представляющей элемент списка ключей), то цифровая сортировка будет
корректно работать с таким измененным определением шаблона KeyList, но мы
потеряем все преимущества, полученные нами ранее от реализации
собственной системы отведения памяти под элементы списков в буфере
ListBuffer<Key>. Для того чтобы оператор new Eiem<Key> обращался за
очередным элементом памяти не в стандартную систему распределения памяти,
а к нашему буферу, надо всего лишь переопределить оператор new в классе
Е1ет<Кеу>.
Если мы хотим использовать свою систему управления памятью, то мы
должны также несколько изменить реализацию шаблона ListBuffer,
приспособив его для использования в операторах new и delete. Изменим реализацию
метода get таким образом, чтобы вместо индекса свободного элемента
выдавать указатель на этот элемент. Если это сделать, то отпадает также
надобность в специальной операции доступа к элементам буфера, который был
реализован в листинге 2.11 в виде оператора индексации в классе ListBuffer.
Итак, реализация нашей системы принимает вид, показанный в листинге 5.2.
Алгоритмы распределения памяти
293
Чтобы упростить доступ к системе, в классе ListBuffer определена также
статическая переменная singleton, назначение которой — представлять
систему распределения памяти вне класса ListBuffer. Доступ к ней
осуществляется с помощью статической общедоступной функции getinstance. В такой
ситуации конструктор системы должен быть скрыт от ее пользователей;
вместо этого в открытом доступе имеется статическая функция setNewBuffer,
с помощью которой можно создать новый буфер памяти и записать указатель
на него в переменную singleton.
^Листинг 5.2. Простая система распределения памяти : /;j
\pj\n цифровой сортировки * ■„...;>';'< !• f; -'./ !- у.;;.\ / KV '?У\
I/ Класс ListBuffer представляет буферный пул памяти для
// организации в нем списков ключей (для цифровой сортировки).
template <class Key>
class ListBuffer {
Elem<Key> * buffer; // Указатель на буфер
int size; // Размер буфера
int freePtr; // Указатель первого свободного элемента
// Представитель системы распределения памяти вне класса
static ListBuffer<Key> * singleton;
// Скрытый конструктор, резервирующий память
//с помощью системного вызова calloc:
ListBuffer(int n) {
buffer = (Elem<Key>*)calloc(size = n, sizeof(Elem<Key>));
clear () ;
}
public :
// Методы доступа к статической переменной singleton представлены
// статическими функциями - членами класса getinstance и setNewBuffer
static ListBuffer<Key> * getinstance() {
return singleton;
}
static void setNewBuffer(int n) {
// Вызов скрытого конструктора класса
singleton = new ListBuffer<Key>(n);
}
294
Глава 5
// Деструктор освобождает занятую память с помощью
// системной функции free системы управления памятью:
-ListBufferO { free (buffer) ; }
// Очистка памяти сводится к сбросу указателя
//на первый свободный элемент буфера.
void clear() {
freePtr = 0;
}
// Операция выделения элемента свободной части буфера
Elem<Key> * get() {
return & buffer[freePtr++];
}
};
.template <class Key>
ListBuffer<Key> * ListBuffer<Key>::singleton = NULL;
Теперь описанная система распределения памяти под элементы типа
Е1ет<кеу> может быть использована в описании этого класса для
переопределения оператора new. В листинге 5.3 показано, как это можно
сделать в нашем случае. На приложенном компакт-диске в папке "Chapters
\5.i\DigitSort" вы можете найти полное описание всех приведенных выше
классов вместе с новым определением функции digitsort, которая
фактически осталась такой же, как и в разд. 2.2.
j Листинг 5.3. Определение элементов списка ..- ДД Д Д;Д:'г - ;Дг--Дхг д!
[х'га^опредадЛ^^^ гДчДД -;Ч'Ъ;Д ДД|^Я
//===============^=====================^========================
// Класс Elem представляет элемент списка ключей,
// формируемого для выполнения цифровой сортировки.
7/============================================^==^===========
template <class Key>
struct Elem {
Key value; // Сам элемент
Elem<Key> * next; // Указатель следующего элемента в пуле памяти
// Конструкторы:
Elem () { next = NULL; } //По умолчанию следующий элемент отсутствует
Elem(const Elem<Key> & src) : value(src.value), next(sre.next) {}
Elem(const Key & val,, Elem<Key> * n = NULL) : value(val), next(n) {}
Алгоритмы распределения памяти
295
// Оператор присваивания:
Elem<Key> & operator = (const Elem<Key> & src) {
value = src.value; next = src.next;
return *this;
}
// Запрос памяти
void * operator new(size_t sz) {
return ListBuffer<Key>::getlnstance()->get();
}
};
В шаблоне Eiem для объектов этого класса переопределен оператор new таким
образом, что теперь вместо обращения к встроенной системе распределения
памяти он обращается к функции get нашего класса ListBuffer. Заголовок
этого оператора имеет стандартную, определенную правилами языка форму.
Оператор возвращает значение типа void * (фактически возвращается
указатель на новый объект класса Е1ет<кеу>), а аргументом оператора является
целое число типа sizet, обозначающее количество байтов, которое
потребуется для размещения в памяти нового объекта. Поскольку наша система
распределения памяти узкоспециализирована и может размещать только
объекты класса Е1еш<кеу>, то в реализации оператора new значение этого
аргумента не используется.
Мы показали, как в языке может быть реализована собственная система
управления памятью, и показали, каким образом можно подменить
стандартную систему управления памятью своей для размещения в ней объектов
некоторого класса. В следующих разделах этой главы мы приведем несколько
различных способов реализации систем управления памятью. Подобные
системы используются не только для решения узкоспециализированных задач
вроде цифровой сортировки, но применяются и в качестве стандартных
систем при реализации ядра языка программирования, а также при реализации
соответствующих подсистем операционных систем компьютеров.
5.2. Распределение памяти
блоками постоянной длины
Рассмотрим сначала самый простой случай, когда все блоки памяти,
выделяемые системой, имеют один и тот же размер. В этом случае система
управления памятью может быть построена приблизительно так же, как это было
сделано при реализации алгоритма цифровой сортировки элементов массива.
В системе будет содержаться указатель f reeArea, отмечающий начало
свободного участка памяти, не занятого уже выделенными блоками памяти.
296
Глава 5
Если в систему поступает запрос на выделение блока памяти (вызывается
метод get), то система может просто выделить первый участок из свободной
области памяти и передвинуть указатель свободной области памяти на новое
место. Если участок памяти возвращается в систему (вызван метод release),
то, вообще говоря, его не удастся просто присоединить к имеющейся
свободной памяти, поскольку возвращаемый блок может и не примыкать к этой
области памяти. Проще всего в освобождаемых участках памяти организовать
список свободных блоков памяти. Тогда система при освобождении блока
может просто присоединять этот блок к списку свободных блоков. В
дальнейшем система может снова задействовать освобожденные блоки, выдавая
их по запросу get.
На рис. 5.1 показана схема организации пула памяти в такой системе.
Штриховкой выделены участки, содержащие блоки, уже отданные системой и
используемые другими частями программы. В нижней части рисунка в области
больших адресов по пулу памяти находится свободная область памяти, из
которой выделяются новые, еще не распределенные ранее блоки. Нарисован
также связанный список свободных блоков памяти, которые были когда-то
выделены системой, а потом возвращены обратно.
Пул памяти
Указатель списка
М h# 1 свободных
элементов
Указатель нераспределенной
области памяти
Рис. 5.1. Схема организации памяти для системы управления памятью
с блоками постоянной длины
Теперь уже легко запрограммировать соответствующий класс. В листинге 5.4
определен шаблон класса BiockBuffer и реализованы операции get и release
для управления памятью. Параметром шаблона является размер выделяемых
блоков памяти в байтах. Для того чтобы система работала правильно, необ-
Алгоритмы распределения памяти
297
ходимо, чтобы в свободных блоках памяти можно было организовать список,
а это возможно, только если в таком блоке достаточно места для размещения
указателя. Мы не делаем такую проверку, считая, что параметр шаблона
blockSize заведомо больше величины sizeof (void *).
Для организации работы системы используются арифметические операции
над указателями. Так, например, в реализации метода get для того, чтобы
получить указатель на первый байт свободной области памяти, используется
выражение (char *) buffer + freeArea*biocksize, где buffer— указатель на
всю распределяемую память, f reeArea — индекс первого свободного байта в
области нераспределенной памяти, a biocksize — размер выделяемых блоков
памяти. Работа со списком свободных блоков тоже требует аккуратной
работы с указателями. Например, чтобы извлечь из памяти указатель, хранящийся
в начале свободного блока с адресом freeptr, требуется написать выражение
*(void **)freePtr.
J Листинг 54. Система распределения памяти с блоками постоянной длины ;>^ 1
// Класс BlockBuffer представляет буферный пул памяти и систему
// управления ею для размещения в ней блоков постоянного размера
template <int blockSize>
class BlockBuffer {
void * buffer; // Указатель на буфер
int size; // Размер буфера в блоках
// Указатели свободных блоков:
void * freePtr; //в списке свободных блоков
int freeArea; //в области нераспределенной памяти
public :
// Конструктор:
BlockBuffer(int n) {
buffer = calloc(size = n, blockSize);
if (buffer == NULL) {
throw NoMoreMemoryException () ;
}
clear();
}
// Деструктор:
-BlockBuffer() { free(buffer); }
298
Глава 5
// Очистка памяти.
void clear() {
freePtr = NULL;
freeArea = 0;
}
// Операция выделения элемента свободной части буфера
void * get() {
if (freePtr) {
void * ret = freePtr;
freePtr = *(void **)freePtr;
} else if (freeArea < size) {
return (char *)buffer + (freeArea++)*blockSize;
} else {
throw NoMoreMemoryException () ;
}
}
void release(void * ptr) {
*(void **)ptr = freePtr;
freePtr = ptr;
}
};
Реализованную с помощью класса BiockBuffer систему управления памятью
можно использовать при отведении памяти под значения какого-либо одного
класса. Пусть, например, в программе организуется дерево поиска с
элементами типа string, причем известно, что количество элементов в этом дереве
не будет превышать тысячи. Используем только что реализованную систему
распределения памяти для размещения в ней элементов этого дерева.
Здесь мы не будем переопределять операторы new и delete для узлов дерева.
Вместо этого используем еще одну возможность языка C++: при вызове
стандартного оператора new будем явно указывать ту область памяти, в
которой следует размещать новый узел дерева. Разумеется, это будет область
памяти, выделенная нашей системой распределения памяти.
Итак, в нашей программе будут участвовать система распределения и
управления памятью, представленная объектом memoryManagement класса BiockBuf fer,
словарь, представленный двоичным деревом поиска класса TreeDictionary, и
узлы этого дерева, которые будут размещаться в областях памяти,
предоставляемых Системой memoryManagenient. Объект memoryManagement создадим В МО-
мент исполнения конструктора класса TreeDictionary. Именно дерево будет
отвечать за использование этой системы и размещать в ней узлы дерева. Зна-
Алгоритмы распределения памяти
299
чением параметра, определяющего размер выделяемых блоков, будет размер
узла дерева в байтах. Далее, при добавлении в словарь новых слов дерево
будет обычным образом вызывать оператор new для создания нового узла, но в
качестве памяти для размещения нового узла будет указывать блок,
выделенный системой memoryManagement.
В нашем маленьком примере слова не будут удаляться из словаря, однако
если бы удаление слов все же нужно было реализовать для класса
TreeDictionary, то использовать для этого оператор delete было бы
некорректно. Вместо этого надо было бы возвращать освободившийся блок памяти
в систему, вызывая метод release. Правда, в этом случае не отработает
деструктор класса Treeitem. На самом деле, если планируется реализовать
удаление слов из словаря, то лучше было бы отводить память под элементы нашего
дерева несколько по-другому, переопределив, как и раньше, операторы new и
delete для класса Treeitem. Если же, как в приведенном ниже примере, слова
в словарь только добавляются, а заботиться о том, чтобы для каждого
элемента словаря вызывался деструктор нет необходимости, то вместо
уничтожения отдельных узлов дерева можно просто в деструкторе дерева вызвать
деструктор системы распределения памяти, освободив разом всю
захваченную ею память.
В листинге 5.5 представлена реализация словаря в виде определения класса
TreeDictionary и реализация операции добавления нового слова в словарь. На
приложенном компакт-диске в папке "Chapter5\5.2\ConstBiocks" вы можете
найти и реализацию некоторых других операций, а также пример работы
с таким словарем.
[Листинг 5.5. Добавление слов в словарь с распределением памяти под узлы
! с помощью собственной системы управления памятью j
// treedictionary.h
//««^«^«^^
// Класс TreeDictionary реализует простое бинарное дерево слов
//с хранением узлов дерева в системе распределения памяти,
// представленной объектом класса BlockBuffer
class TreeDictionary {
// Вложенная структура Treeitem представляет узел дерева
struct Treeitem {
string word; // Хранящееся в словаре слово
Treeitem * left, // Ссылка на левое поддерево (меньшие слова)
* right; // Ссылка на правое поддерево (большие слова)
300
Глава 5
// Конструктор задает содержимое узла
Treeltem (string word,
Treeltem * left = NULL, Treeltem * right = NULL)
: word(word), left(left), right(right) {}
};
// Корень дерева:
Treeltem * root;
// Система управления памятью для узлов дерева
BlockBuffer <sizeof(Treeltem)> * memoryManagement;
public :
// Конструктор дерева создает пустое дерево и систему
// управления памятью. Аргумент capacity задает максимальное
// количество слов, которые могут одновременно храниться в словаре.
TreeDictionary(int capacity)
: root(NULL),
memoryManagement(new BlockBuffer<sizeof(Treeltem)>(capacity))
{}
// Деструктор словаря просто уничтожает систему управления памятью
-TreeDictionary() { delete memoryManagement; }
// Функция добавления нового слова в словарь:
void addWord(string w);
private:
// Вспомогательная рекурсивная функция, реализующая простой
// алгоритм добавления нового узла в дерево словаря
void addWord(const string & w, Treeltem ** root);
};
// treedictionary.cpp
// Функция добавления нового слова в словарь просто обращается
//к рекурсивному варианту функции с тем же именем
void TreeDictionary::addWord(string w) {
addWord(w, &root);
}
// Рекурсивная функция добавления нового слова в словарь
void TreeDictionary::addWord(const string & w, Treeltem ** root) {
if (*root == NULL) {
Алгоритмы распределения памяти
301
// Достигнут лист дерева. Новый узел создается в памяти,
// выделяемой системой управления памяти memoryManagement
Treeltem *item = new (memoryManagement->get () ) Treeltem(w) ;
*root = item;
} else if (w < (*root)->word) {
// Слово добавляется в левое поддерево
addWord(w, &(*root)->left);
} else {
// Слово добавляется в правое поддерево
addWord(w, &(*root)->right);
}
}
Распределение памяти блоками постоянной длины хорошо подходит для
случая, когда в системе управления памятью хранятся объекты одного и того же
типа. Если же система создается для случая, когда она должна выделять
память разного размера для хранения различных объектов, то простой
алгоритм, предложенный в этом разделе, не годится. В следующем разделе мы
рассмотрим другие алгоритмы распределения памяти и приведем способы их
реализации.
5.3. Распределение памяти
блоками переменной длины
Для распределения памяти блоками переменной длины можно использовать
практически тот же способ, что и для управления блоками одной и той же
длины. Разница состоит в том, что теперь в каждом свободном блоке
необходимо хранить длину этого блока, и, кроме того, свободный блок при
повторном его выделении системой может быть использован не полностью. Если
имеется свободный блок некоторого размера, а в систему поступил запрос на
блок памяти меньшего размера, то этот свободный блок можно разделить на
две части и, выдав одну из этих частей по запросу, оставить другую часть
блока в системе.
К сожалению, именно из-за возможного дробления блоков простая система
распределения памяти, приведенная в предыдущем разделе, оказывается
неэффективной. Если не предпринимать никаких дополнительных усилий, то в
процессе эксплуатации системы распределения памяти свободные блоки
становятся все меньше по размеру, и это в конце концов может привести к тому,
что система не сможет удовлетворить очередной запрос к памяти, даже
несмотря на то, что фактически свободная память в системе имеется. Такое
явление называют фрагментацией памяти, и борьба с фрагментацией является
302
Глава 5
одним из главных вопросов, которые ставят перед собой разработчики систем
распределения памяти.
Прежде всего, укажем на две возможные причины потерь памяти при ее
выделении системой. Во-первых, как уже было сказано, в системе может
содержаться большое количество отдельных свободных блоков маленького размера,
так что система не сможет найти непрерывный участок памяти достаточной
длины. Такой вид фрагментации обычно называют внешней фрагментацией
памяти. Возможный способ борьбы с внешней фрагментацией — это
всевозможные алгоритмы, позволяющие находить расположенные рядом
свободные блоки памяти и соединять их в блоки большего размера.
Второй вид фрагментации может возникнуть из-за того, что система
фактически выдает по запросу блок памяти размера большего, чем запрошенный.
Это может произойти из-за того, что система не в состоянии разделить блок
на две части так, чтобы учесть оставшуюся часть в системе в качестве
самостоятельного свободного блока. Конечно, когда этот блок вернется в систему,
то вся выделенная память вновь будет доступна для распределения, однако
если таких блоков оказывается много, то это также может привести к
значительным потерям памяти. Такой вид фрагментации обычно называют
внутренней фрагментацией. Память, потерянную в результате внутренней
фрагментации, невозможно вернуть в систему до тех пор, пока весь
выделенный блок не будет целиком отдан системе.
Очень часто алгоритмы систем распределения памяти представляют собой
различные способы достижения некоторого компромисса между внешней и
внутренней фрагментацией, при этом системы, которые позволяют сильно
уменьшить внешнюю фрагментацию памяти, теряют память из-за внутренней
фрагментации, и наоборот. Дело осложняется еще и тем, что теоретически
очень трудно точно оценить алгоритм по величине потерь памяти из-за
фрагментации. Иногда можно дать качественную оценку алгоритма, но все же
реальные потери можно оценить только при моделировании реальных запросов
к памяти. Не существует идеальных алгоритмов, которые были бы лучшими
независимо от конкретной ситуации. Некоторые алгоритмы ведут себя лучше
в условиях, когда имеется сравнительно небольшое число запросов к памяти
большими фрагментами, другие предпочитают много мелких запросов.
В этом разделе мы рассмотрим три метода организации памяти: метод дву-
связных списков, метод граничных маркеров и метод двоичных близнецов. Из
этих методов первые два имеют сравнительно небольшую внутреннюю
фрагментацию, но тратят много времени на борьбу с внешней
фрагментацией. В последнем методе внешняя фрагментация практически отсутствует,
однако довольно много памяти может оказаться неиспользуемой из-за сильной
внутренней фрагментации.
Алгоритмы распределения памяти
303
Все рассматриваемые и многие другие методы распределения памяти
используются не только в прикладных программах, но и в языковых системах
программирования и даже в операционных системах. Так, например, в простой
операционной системе MS DOS фирмы Microsoft для реализации системы
управления памятью процессов использовался метод, близкий к
описываемому методу двусвязных списков. Подобные методы используются и при
реализации систем динамического распределения памяти для различных
языков программирования, в частности для стандартных алгоритмов реализации
операторов new и delete в системах программирования на C++ или
реализации системных функций malloc, cailoc и free для систем программирования
на языке С.
Система распределения памяти с помощью двусвязного списка блоков
несколько напоминает рассмотренную в предыдущем разделе систему
управления памятью для запросов с постоянной длиной блоков. Как и в той системе,
свободная память организована в виде списка свободных блоков, однако,
поскольку блоки могут иметь разную длину, в этих блоках кроме указателей,
связывающих их в список, хранится еще и размер блока. Когда блок или его
часть выделяются по запросу к системе, в этом выделенном блоке также
сохраняется его длина, которая используется позже при возврате его в систему.
На рис. 5.2 показана структура свободных и выделенных блоков памяти в
этой системе.
Свободный блок памяти содержит информацию, используемую системой
управления памятью, а именно размер этого блока в байтах и адреса
предыдущего и следующего свободных блоков в буфере. Информация о длине в
каждом выделенном блоке памяти, которую необходимо хранить для
правильной работы алгоритма, — это непроизводительные расходы памяти,
расходы на внутреннюю фрагментацию.
Указатель на предыдущий Указатель на следующий
свободный блок в списке свободный блок в списке
Размер блока
\
^
Свободное пространство
а) Структура свободного блока памяти
Размер блока
Выделенное пространство
б) Структура выделенного блока памяти
Рис. 5.2. Структура свободного и выделенного блоков памяти в системе распределения памяти
методом организации двусвязного списка свободных блоков
304
Глава 5
Свяжем все свободные блоки памяти в двусвязный список, управление
которым и составляет основное содержание алгоритма распределения памяти по
данному методу. Когда в систему поступает запрос на выделение некоторого
участка памяти, система, прежде всего, пытается найти свободный блок в
списке, размер которого не меньше, чем запрошенное число байтов плюс
необходимое количество байтов для хранения длины блока. Если такой
свободный блок в системе отсутствует, то выделение памяти невозможно. Если
свободный блок подходящего размера найден, то можно либо целиком отдать
его в качестве результата запроса (это можно сделать, если размер
свободного блока лишь незначительно превышает запрашиваемый размер участка
памяти), либо выделить часть блока в качестве результата запроса и
соответственно уменьшить длину этого свободного блока.
Когда выделенный ранее участок памяти будет возвращен в систему вызовом
метода release, то в этом участке можно организовать свободный блок и
присоединить его к списку свободных блоков системы. В дальнейшем этот
свободный блок может быть снова выдан по запросу get, если его длина
окажется достаточно большой. Для того чтобы избежать потерь памяти из-за
внешней фрагментации, алгоритм пытается соединять расположенные рядом
в буфере свободные блоки в один. Такую попытку соединения можно делать
при каждом возврате выделенного ранее участка памяти в систему или же
только тогда, когда система начинает ощущать последствия фрагментации,
т. е. поиск свободного блока памяти происходит слишком долго. В нашей
реализаций мы будем делать попытку слияния свободных блоков при каждом
ВЫЗОВе метода release.
Если возвращаемый блок примыкает к какому-либо свободному блоку, то
можно просто расширить этот свободный блок за счет возвращаемого
участка памяти. Самая сложная ситуация возникает, если возвращаемый в систему
участок примыкает сразу к двум свободным блокам памяти, т. е. занимает
пространство буфера между этими блоками. В этом случае необходимо не
просто расширить свободные блоки, но соединить два блока в один, при этом
один из блоков исключается из списка свободных, и его память целиком
включается в состав другого блока.
На рис. 5.3 показаны все ситуации, которые могут возникнуть при возврате в
систему ранее выделенного блока памяти. Двойной штриховкой выделены те
участки памяти, которые система уже выделила ранее. Возвращаемый в
систему участок памяти показан однократной штриховкой. На рисунке показаны
ситуации перед началом работы метода release и сразу же после его работы.
Длины блоков обозначены /ь /2, /з.
При реализации методов get и release в системе распределения памяти со
свободными блоками, организованными в двусвязный список, удается срав-
Алгоритмы распределения памяти
305
нительно просто организовать добавление свободных блоков и удаление
блоков из системы. Проблема, однако, состоит в том, чтобы при возврате
занятого участка памяти в систему определить, где находятся соседние с ней блоки.
Для этого надо просмотреть весь список свободных блоков и найти в нем
блоки с максимальным адресом из всех лежащих левее освобождаемого и с
минимальным адресом из всех лежащих правее освобождаемого. Если список
блоков велик, то на его просмотр может потребоваться много времени.
Можно сократить затраты времени примерно вдвое, если поддерживать в списке
упорядоченность по возрастанию адресов блоков. Алгоритм возврата
занятого участка памяти становится немного сложнее, но зато и работает он
быстрее.
Рис. 5.3. Структура свободных и занятых блоков памяти
при возврате участка памяти в систему
В листинге 5.6 приводится реализация методов get и release для описанного
метода управления памятью. Большую часть текста занимает разбор
различных случаев, которые могут встретиться при захвате и освобождении памяти,
хотя сам по себе каждый из этих случаев достаточно прост. Структуры
свободного и занятого блоков памяти определены с помощью описаний типов
FreeBiock и BusyBiock, при этом на самом деле описаны только управляющие
306
Глава 5
части этих блоков, содержащие длину и указатели на соседние по списку
блоки. Работа с памятью требует аккуратности из-за того, что все время
приходится трактовать различные участки памяти по-разному, в зависимости от
того, какое содержание вложено в тот или иной участок. Для этого часто
приходится прибегать к явному приведению указателей на участки памяти к
разным типам: (char*), когда требуются побайтовые вычисления; (FreeBiock*),
когда требуется работа с управляющей частью свободного блока памяти, или
(BusyBiock*), когда нужно определить размер памяти, необходимой для
хранения управляющей части занятого участка памяти.
^Листинг5,6. Система распределения^mmin^.^-', \> 1 'Ц
; с помощью двусвязного списка свободных блоков
// bilistmemory.h
// Класс BiListMemory представляет буферный пул памяти и систему
// управления ею для размещения в ней блоков переменного размера.
// Свободные блоки связаны в двунаправленный упорядоченный список
/7====^===============^^
class BiListMemory {
// Структура свободного блока памяти:
struct FreeBiock {
size_t length;
FreeBiock * next, * pred;
};
// Структура выделенного блока памяти:
struct BusyBiock {
size_t length;
};
char * buffer; // Указатель на буфер
size_t size; // Размер буфера в байтах
// Указатель списка свободных блоков:
FreeBiock * freePtr;
public :
// Конструктор:
BiListMemory(size_t n) {
buffer = new char[size = nj;
clear();
}
■~$"n"№$
Алгоритмы распределения памяти
307
// Деструктор:
-BiListMemory() { delete[] buffer; }
// Очистка памяти.
void clear() {
//В буфере создается кольцевой список из единственного
// блока, размер которого равен размеру всего буфера
freePtr = (FreeBlock *)buffer;
freePtr->length = size;
freePtr->next = freePtr->pred = freePtr;
}
// Операция вьвделения свободного блока памяти заданного размера
void * get(size_t sz);
// Операция возврата выделенного блока памяти в систему
void release(void * ptr);
};
// bilistmemory. cpp
// Операция выделения свободного блока памяти заданного размера
void * BiListMemory: :get (size__t sz) {
// 1. Поиск свободного блока подходящей длины
if (freePtr == NULL) throw NoMoreMemoryException();
FreeBlock * current = freePtr,
* last = freePtr,
* found = NULL;
do {
if (current->length >= sz + sizeof(BusyBlock)) {
found = current; // свободный блок подходящей длины найден
} else {
current = current->next; // переход к следующему блоку
}
} while (found == NULL && current != last);
if (found == NULL) { // нет свободного блока памяти нужного размера!
throw NoMoreMemoryException();
}
// 2. Если блок не слишком велик, он выделяется целиком
if (found->length < sz + sizeof(BusyBlock) + sizeof(FreeBlock)) {
FreeBlock * next = found->next,
* pred = found->pred;
308
Глава 5
if (next == found) { // Это был последний блок!
freePtr = NULL;
} else {
next->pred = pred;
freePtr = pred->next = next;
}
return (char*)found + sizeof(BusyBlock);
// 3. Если блок достаточно велик, то он делится на два
} else {
// вычисление новой длины блока:
found->length -= sz + sizeof(BusyBlock);
// вычисление адреса возвращаемого блока:
char * retAddress = (char*)found + found->length;
// окончательное формирование выдаваемого блока и выдача результата:
((BusyBlock*)retAddress)->length = sz + sizeof(BusyBlock);
return (char*)retAddress + sizeof(BusyBlock) ;
}
}
// Операция возврата выделенного блока памяти в систему
void BiListMemory::release(void * ptr) {
// Сначала вычисляем адрес возвращаемого блока
FreeBlock *releaseBlock = (FreeBlock*)((char*)ptr - sizeof(BusyBlock));
// 1. Находим два соседних свободных блока, один из которых
// расположен перед возвращаемым блоком, а другой - после.
// Если в области больших адресов нет свободных блоков, то
// полагаем secondAddr = NULL. Аналогично, если в области
// меньших адресов нет свободных блоков, то считаем firstAddr = NULL
FreeBlock * firstAddr = freePtr,
* secondAddr = freePtr;
if (firstAddr != NULL) {
if (firstAddr > releaseBlock) {
while (firstAddr > releaseBlock) {
firstAddr = firstAddr->pred;
if (firstAddr >= freePtr) break;
}
secondAddr = firstAddr->next;
if (firstAddr > releaseBlock) {
firstAddr = NULL;
}
} else {
while (secondAddr < releaseBlock) {
secondAddr = secondAddr->next;
Алгоритмы распределения памяти
309
if (secondAddr <= freePtr) break;
}
firstAddr = secondAddr->pred;
if (secondAddr < releaseBlock) {
secondAddr = NULL;
}
}
}
// 2. Рассматриваем три случая:
// 1) возвращаемый блок удается соединить с обоими соседними блоками;
// 2) возвращаемый блок удается соединить с одним из соседних блоков;
// 3) ни одно из указанных соединений невозможно.
if (firstAddr != NULL
&& (char*)firstAddr + firstAddr->length == (char*)releaseBlock) {
// Первый из блоков примыкает к возвращаемому
if (secondAddr != NULL &&
(char*)releaseBlock + releaseBlock->length == (char*)secondAddr) {
// Второй блок тоже примыкает к возвращаемому - случай (1).
// Второй блок удаляем из системы, а первый расширяем:
firstAddr->length += releaseBlock->length + secondAddr->length;
firstAddr->next = secondAddr->next;
freePtr = secondAddr->next->pred = firstAddr;
} else {
// Случай (2), первый блок расширяется
firstAddr->length += releaseBlock->length;
}
} else if (secondAddr != NULL &&
(char*)releaseBlock + releaseBlock->length == (char*)secondAddr) {
// Только второй блок примыкает к возвращаемому - случай (2)
secondAddr->length += releaseBlock->length;
secondAddr->pred->next = releaseBlock;
*releaseBlock = *secondAddr;
freePtr = releaseBlock->next->pred = releaseBlock;
} else {
// Случай (З) - в системе появляется новый свободный блок.
if (firstAddr != NULL) {
releaseBlock->pred = firstAddr;
releaseBlock->next = firstAddr->next;
firstAddr->next->pred = releaseBlock;
firstAddr->next = releaseBlock;
} else if (secondAddr != NULL) {
releaseBlock->next = secondAddr;
releaseBlock->pred = secondAddr->pred;
310
Глава 5
secondAddr->pred->next = releaseBlock;
secondAddr->pred = releaseBlock;
} else {
freePtr = releaseBlock->pred = releaseBlock->next = releaseBlock;
}
}
}
Список свободных блоков в нашей реализации сделан не только двусвязным,
но также и кольцевым. Это позволяет считать началом списка любой из
блоков и тем самым начинать поиск с любого места. При выдаче свободного
блока по запросу в реализации метода get мы ищем блок подходящей длины,
и, если такой блок найден, то оставляем указатель на начало списка в том
месте, где был закончен поиск. Это позволяет перемещать начало списка
свободных блоков в разные места буфера. Если бы начало списка было всегда в
одном и том же месте, то из-за внешней фрагментации свободные блоки
небольшого размера скапливались бы около него, т. к. именно там наиболее
вероятно разбиение блока на две части. В конце концов это приводит к тому,
что функция get начинает работать все медленнее, т. к. приходится
просматривать все большее число свободных блоков, которые не будут
удовлетворять запросу из-за своей небольшой величины. Перемещение начала списка
в разные места буфера памяти позволяет более равномерно распределить по
буферу мелкие блоки, получающиеся в результате внешней фрагментации, и
тем самым повысить скорость работы системы.
Для того чтобы продемонстрировать работу метода двусвязных списков на
практике, можно попробовать использовать систему распределения памяти,
определенную с помощью класса BiListMemory, для размещения в ней
объектов разных классов. Например, в главе 4 обсуждались алгоритмы
представления и обработки выражений, представленных в виде деревьев. В них
описывались классы для представления узлов деревьев выражений— constant,
variable, Binary и др. Объекты этих классов имеют различное представление
и, соответственно, различную потребность в памяти, однако все они являются
наследниками абстрактного класса Expression. Если переопределить
операторы new и delete в классе Expression таким образом, чтобы вместо обращения
к системным функциям распределения памяти они использовали бы нашу,
только что описанную систему, то все объекты классов, наследующих класс
Expression, будут размещаться в ней.
Будем считать, что указатель на систему распределения памяти описан в виде
внешней переменной следующим образом:
extern BiListMemory * memoryManagement;
Алгоритмы распределения памяти
311
Тогда определения операторов new и delete в классе Expression могут
выглядеть следующим образом (приведены только определения этих операторов):
class Expression {
public :
void * operator new(size_t sz) { return memoryManagement->get(sz); }
void operator delete(void * ptr) { memoryManagement->release(ptr); }
};
Приятно, что больше ничего в определениях классов, реализующих деревья
выражений, менять не надо. Зато, имея свою собственную систему
распределения памяти, вы можете теперь не только ускорить работу алгоритмов,
которые часто создают новые узлы деревьев выражений, но также удобно и
просто контролировать затраты памяти. Например, если в реализацию
методов get и release вставить операторы, осуществляющие статистический учет
занятой и освобождаемой памяти, то можно легко проверить, сколько всего
памяти было затрачено на представление выражений, вся ли память была
возвращена в систему, каков средний размер блока памяти, выделяемого по
запросу из программы, и т. п.
На приложенном компакт-диске в папке "Chapter5\5.3\BiList"
реализованная нами система распределения памяти используется для алгоритмов
дифференцирования и упрощения выражений так, как это было описано в
разд. 4.3. При этом практически никаких изменений в тексты примеров,
кроме описанной выше вставки определений операторов new и delete, вносить не
потребовалось. Есть только еще одно небольшое изменение, которое касается
отведения памяти для глобально описанных объектов. При описании
посетителей для упрощения и дифференцирования выражений использовались
статические переменные класса, содержащие представления для констант 0 и 1.
Вот как, например, были описаны эти константы в файле diff.cpp:
Constant * Diff::zero = new Integer(0);
Constant * Diff::one = new Integer(1);
Однако это означает, что работа операторов new для создания этих констант
может начаться еще до того, как система распределения памяти вообще
инициализирована. Не поможет даже описание системы здесь же на глобальном
уровне:
BiListMemory * memoryManagement = new BiListMemory (20000) ;
поскольку порядок исполнения инициализаторов глобальных переменных
в языке не определен, и полагаться на какой-то определенный порядок
исполнения невозможно.
312
Глава 5
Но мы можем сделать исключение для наших глобальных констант и
распределить под них память с помощью системных механизмов. Это можно
сделать с помощью небольшой модификации приведенных выше строк:
Constant * Diff::zero = ::new Integer(O);
Constant * Diff::one = ::new Integer(1);
Теперь наши две константы будут размещаться в памяти, отведенной
встроенной системой управления памятью, а все остальные компоненты
выражения — в памяти, отведенной реализованной нами системой. Ничего
страшного в этом нет, и вполне может оказаться, что один из операндов выражения
расположен внутри памяти, которой управляет система реализации языка
C++, а другой— внутри памяти, управляемой классом BiListMemory. Надо
только быть аккуратным при освобождении памяти: эффект от возврата в
систему блока памяти, который ею не распределялся, может быть
непредсказуем. Конечно, опять самой простой и безопасной будет политика, при
которой до окончания обработки выражений возвратов памяти вообще не
происходит, а вся память освобождается в конце работы с помощью вызова:
memoryManagement->clear() ;
или даже (если и сама система больше не понадобится):
delete memoryManagement;
Однако если пользоваться освобождением памяти аккуратно и не путать
между собой области памяти, распределенные разными системами (т. е. не
освобождать память с помощью оператора :: delete, если она была выделена
оператором Expression: mew и наоборот), то наша система будет работать
исправно и надежно.
Реализация системы распределения памяти с помощью организации двусвяз-
ных списков свободных блоков памяти довольно проста и естественна. Она
обладает хорошими показателями внешней и внутренней фрагментации,
однако скорость работы методов get и release невысока. Это происходит из-за
того, что во время работы метода get требуется просматривать список
свободных блоков в поисках первого подходящего блока памяти, а при работе
метода release тот же список просматривается в поисках свободных блоков,
примыкающих к возвращаемому в систему блоку памяти. Все следующие
описываемые в этом разделе методы управления памятью используются для
борьбы за повышение скорости работы. Они также имеют небольшую
внешнюю фрагментацию, однако платой за эффективность становится увеличение
объемов внутренней фрагментации по сравнению с описанным выше
методом построения двусвязных списков свободных блоков.
Первый из таких алгоритмов распределения памяти называется методом
граничных маркеров. Для того чтобы избежать просмотра списка свободных
Алгоритмы распределения памяти
313
блоков при возврате ранее выданного участка памяти в систему, в этом
алгоритме каждый блок памяти — и свободный, и занятый — помечаются
специальными маркерами в начале и в конце блока. Всего существуют два типа
маркеров: маркер свободного блока и маркер занятого блока. Теперь метод
release может вместо просмотра списка сразу определить, какой из блоков —
занятый или свободный — примыкает к освобождаемому. Это может сильно
ускорить работу алгоритма, однако наличие маркеров несколько увеличивает
внутреннюю фрагментацию в системе. Поиск свободного блока в реализации
метода get в этой системе производится точно так же, как и в методе дву-
связных списков, поэтому вся организация этого списка остается без
изменений.
Описанная система распределения памяти реализована нами в листинге 5.7
в виде определения класса BoardMarkersMemory. По сравнению с реализацией
класса BiListMemory в этом классе мы не стали определять структуры
свободных и занятых участков памяти в виде явных описаний структур. Вместо
этого мы используем прямое обращение к байтовому буферу с помощью явно
заданных адресов и смещений. Строго говоря, такая реализация несколько
чище, поскольку не зависит от того, как размещает компилятор отдельные
элементы структуры внутри памяти, отведенной под структуру. На рис. 5.4
изображены структуры свободного и занятого участков памяти при
использовании метода граничных маркеров. На рисунке видно, что помимо
собственно граничных маркеров по сравнению с методом двусвязного списка
имеется еще одно дополнение: размер свободного блока содержится не только в
начале блока, но также и в его конце. Это нужно для того, чтобы алгоритм
возврата памяти в систему, найдя конец свободного блока, примыкающего к
возвращаемому участку, смог бы сразу же определить, где находится начало
блока, не просматривая при этом список свободных блоков.
Указатель на предыдущий Указатель на следующий
свободный блок в списке свободный блок в списке
d
а
^ Размер блока
\
/
Свободное пространство I
эазмер блока
) Структура свободного блока памяти
4] Размер блока
Выделенное пространство Щ
б) Структура выделенного блока памяти
1
Рис. 5.4. Структура блоков памяти в методе граничных маркеров
распределения памяти
314
Глава 5
Граничные маркеры показаны на рисунке в виде заштрихованных
квадратиков, при этом свободный блок отмечен штриховкой одного вида, а занятый
блок — штриховкой другого вида.
В алгоритмах, приведенных в листинге 5.7, трудным для понимания местом
является работа со списками. Так, например, для того чтобы по заданному
адресу свободного блока найти адрес следующего блока в списке,
используется такая конструкция:
*(char**)&freeBlock[MARKER_SIZE + SIZE_SIZE + PTR_SIZE]
Здесь подразумевается, что freeBiock— это адрес свободного блока типа
(char*), a marker_size, size_size и ptr_size— размеры в байтах полей,
содержащих маркер блока, размер блока и указатель на другой блок
соответственно. Индекс в этом выражении используется, чтобы позиционировать
указатель на нужное поле, содержащее адрес следующего блока; далее он
квалифицируется как указатель на поле, содержащее указатель; наконец, берется
значение по вычисленному указателю, чтобы извлечь нужный адрес.
| Листинг 5.7. Реализация методов get и release в алгоритме
I распределения памяти с помощью граничных маркеров f'-\
/ / boardmarkers. h
// Класс BiListMemory представляет буферный пул памяти и систему
// управления ею для размещения в ней блоков переменного размера.
// Свободные блоки связаны в двунаправленный упорядоченный список
class BoardMarkersMemory {
// Коды граничных маркеров можно выбрать произвольно:
static const unsigned char FREE_MARKER = OxCC;
static const unsigned char BUSY_MARKER = 0x33;
// Размеры в байтах элементов управляющей информации
static const int MARKER_SIZE = sizeof(unsigned char);
static const int SIZE_SIZE = sizeof(size_t);
static const int PTR_SIZE = sizeof(char*);
char * buffer; // Указатель на буфер
size_t size; // Размер буфера в байтах
// Указатель на элемент кольцевого списка свободных блоков:
char * freePtr;
public :
// Конструктор:
BoardMarkersMemory(size_t n) : size(n), buffer(new char[n]) {
Алгоритмы распределения памяти
315
clear();
}
// Деструктор:
-BoardMarkersMemory() { delete[] buffer; }
// Очистка памяти.
void clear() {
//В буфере создается кольцевой список из единственного
// блока, размер которого равен размеру всего буфера
freePtr = buffer;
// Формирование начального сектора блока
*freePtr = FREE_MARKER;
Msize_t*)&freePtr[MARKER_SIZE] = size;
Mchar**)&freePtr[MARKER_SIZE + SIZE_SIZE] = freePtr;
Mchar**)&freePtr[MARKER_SIZE + SIZE_SIZE + PTR_SIZE] = freePtr;
// Формирование конечного сектора блока
*(unsigned char *)&freePtr[size - MARKER_SIZE] = FREE_MARKER;
*(size_t*)&freePtr[size - MARKER_SIZE - SIZE_SIZE] = size;
}
// Операция выделения свободного блока памяти заданного размера
void * get(size__t sz) ;
// Операция возврата выделенного блока памяти в систему
void release(void * ptr);
};
// boardmarkers. cpp
// Операция выделения свободного блока памяти заданного размера
void * BoardMarkersMemory::get(size_t sz) {
// 1. Поиск свободного блока подходящей длины
if (freePtr == NULL) throw NoMoreMemoryException();
char * current = freePtr, // текущий блок при поиске
* last = freePtr, // отмечаем конец поиска
* found = NULL; // адрес найденного свободного блока
do {
if (Msize_t*)¤t[MARKER_SIZE] >=
sz + SIZE_SIZE + 2*MARKER_SIZE) {
found = current; // свободный блок подходящей длины найден
} else {
// переход к следующему блоку
current = *(char**)¤t[MARKER_SIZE + SIZE_SIZE + PTR_SIZE];
}
Глава
} while (found == NULL && current != last);
if (found = NULL) { // нет свободного блока памяти нужного размера!
throw NoMoreMemoryException () ;
}
// 2. Разметка блока: устанавливаем указатели на служебную информацию
size_t * pLength = (size_t*)&found[MARKER_SIZE];
char ** pPred = (char**) &found[MARKER_SIZE + SIZE_SIZE];
char ** pNext = (char**)&found[MARKER_SIZE + SIZE_SIZE + PTR_SIZE];
unsigned char * pEndMarker =
(unsigned char *)&found[*pLength - MARKERJSIZE];
// 3. Если блок не слишком велик, он выделяется целиком
if (*pLength < sz + 3*SIZE_SIZE + 4*MARKER_SIZE + 2*PTR_SIZE) {
if (*pNext == found) { // Это был последний свободный блок
freePtr = NULL;
} else { // Исключаем блок из списка
*(char**)(*pPred + MARKER_SIZE + SIZE_SIZE+PTR_SIZE) =
freePtr = *pNext;
*(char**)(*pNext + MARKER_SIZE + SIZE_SIZE) = *pPred;
}
// Маркируем блок как занятый
* found = *pEndMarker = BUSY__MARKER;
// Возвращаем указатель на выделенное пространство
return found + MARKER_SIZE + SIZE_SIZE;
// 4. Если блок достаточно велик, то он делится на два
} else {
// вычисление новой длины свободного блока:
*pLength -= sz + SIZE_SIZE + 2*MARKER_SIZE;
// дополнительная разметка служебной информации на границе блоков:
char * busyBlock = found + *pLength;
size_t* busyLength = (size_t*)(busyBlock + MARKER_SIZE);
unsigned char * pFreeEndMarker =
(unsigned char *)(busyBlock - MARKER_SIZE);
size_t * pEndLength = (size_t*)((char*)pFreeEndMarker - PTR_SIZE);
// формирование верхней границы нового блока:
*pEndLength = *pLength;
*pFreeEndMarker = FREE_MARKER;
// Переносим указатель списка свободных блоков
freePtr = found;
// окончательное формирование вьщаваемого блока и выдача результата:
*busyBlock = *pEndMarker = BUSY_MARKER;
*busyLength = sz + SIZE_SIZE + 2*MARKER_SIZE;
Алгоритмы распределения памяти
317
return busyBlock + MARKER_SIZE + SIZE_SIZE;
}
// Операция возврата выделенного блока памяти в систему
void BoardMarkersMemory::release(void * ptr) {
// Сначала вычисляем адрес возвращаемого блока
char * releaseBlock = (char*)ptr - SIZE_SIZE - MARKER_SIZE;
// 1. Проверяем соседние блоки и вычисляем их адреса, если это
// свободные блоки. Отсутствие свободного блока помечается
// пустой ссылкой.
char * firstAddr =
releaseBlock > buffer &&
((unsigned char *)releaseBlock)[-MARKER_SIZE] = FREE_MARKER ?
releaseBlock - *(size_t*)&releaseBlock[-MARKER_SIZE - SIZE_SIZE] :
NULL;
char * secondAddr = releaseBlock + (size_t*)&releaseBlock[MARKER_SIZE]
if (secondAddr == buffer + size ||
*(unsigned char *)secondAddr != FREE_MARKER) {
secondAddr = NULL;
// 2. Рассматриваем три случая:
// 1) возвращаемый блок удается соединить с обоими соседними блоками;
// 2) возвращаемый блок удается соединить с одним из соседних блоков;
// 3) ни один из соседних блоков не свободен.
if (firstAddr != NULL) {
// Первый из блоков примыкает к возвращаемому
if (secondAddr != NULL) {
// Второй блок тоже примыкает к возвращаемому - случай (1).
// Второй блок удаляем из системы, а первый расширяем:
size_t newLen = (* (size_t*) &firstAddr [MARKER_SIZE] +=
*(size_t*)&releaseBlock[MARKER_SIZE] +
*(size_t*)SsecondAddr[MARKER_SIZE]);
*(size_t*)&firstAddr[MARKER_SIZE] = newLen;
*(size_t*)&firstAddr[newLen - MARKER_SIZE - SIZE_SIZE] = newLen;
char * predBlock = *(char**)SsecondAddr[MARKER_SIZE + SIZE_SIZE];
char * nextBlock =
*(char**)&secondAddr[MARKER_SIZE + SIZE_SIZE + PTR_SIZE];
*(char**)&predBlock[MARKER_SIZE + SIZE_SIZE + PTR_SIZE] =
nextBlock;
Mchar**)&nextBlock[MARKER_SIZE + SIZE_SIZE] = predBlock;
} else {
// Случай (2): первый блок расширяется
size_t newLen = *(size_t*)SfirstAddr[MARKER_SIZE] +
Msize t*)&releaseBlock[MARKER SIZE];
318
Глава 5
*(size_t*)&firstAddr[MARKER_SIZE] = newLen;
firstAddr[newLen - MARKER_SIZE] = FREE_MARKER;
*(size_t*)&firstAddr[newLen - MARKER_SIZE - SIZE_SIZE] = newLen;
}
} else if (secondAddr != NULL) {
//Из примыкающих к возвращаемому есть только второй блок: случай (2)
*releaseBlock = FREE_MARKER;
size_t newLen = *(size_t*)&releaseBlock[MARKER_SIZE] +
* (size_t*)&secondAddr[MARKER_SIZE];
*(size_t*)&releaseBlock[MARKER_SIZE] = newLen;
*(size_t*)SreleaseBlock[newLen - MARKER_SIZE - SIZE_SIZE] = newLen;
char * predBlock = *(char**)SsecondAddr[MARKER_SIZE + SIZE_SIZE];
char * nextBlock =
*(cdiar**)&secondkddr[MARKER_SIZE + SIZE_SIZE + PTR_SIZE];
*(char**)&predBlock[MARKER_SIZE + SIZE_SIZE + PTR_SIZE] =
releaseBlock;
*(char**)&nextBlock[MARKER_SIZE + SIZE_SIZE] = releaseBlock;
*(char**)&releaseBlock[MARKER_SIZE + SIZE_SIZE] = predBlock;
*(char**)&releaseBlock[MARKER_SIZE + SIZE_SIZE + PTR_SIZE] =
nextBlock;
} else {
// Случай (З) - в системе появляется новый свободный блок.
^releaseBlock = FREE_MARKER;
size_t len = *(size_t*)&releaseBlock[MARKER_SIZE];
releaseBlock[len - MARKER_SIZE] = FREE_MARKER;
*(size_t*)&releaseBlock[len - MARKER_SIZE - SIZE_SIZE] = len;
if (freePtr == NULL) {
*(cdiar**)&releaseBlock[MARKER_SIZE + SIZE_SIZE] = releaseBlock;
*(cdiar**)&releaseBlock[MARKER_SIZE + SIZE_SIZE + PTR_SIZE] =
releaseBlock;
freePtr = releaseBlock;
} else {
char * predBlock = *(char**)&freePtr[MARKER_SIZE + SIZE_SIZE];
*(char**)&releaseBlock[MARKER_SIZE + SIZE_SIZE] = predBlock;
*(char**)&releaseBlock[MARKER_SlZE + SIZE_SIZE + PTR_SIZE] =
freePtr;
*(char**)&freePtr[MARKER_SIZE + SIZE_SIZE] = releaseBlock;
*(char**)&predBlock[MARKER_SIZE + SIZE_SIZE + PTR_SIZE] =
releaseBlock;
}
}
}
Работу метода граничных маркеров тоже можно проверить на примере,
подобном операциям преобразования выражений. На приложенном компакт-
Алгоритмы распределения памяти
319
диске метод граничных маркеров реализован программами папки "Chapters
\5.3\BoardMarkers". Реализованная система распределения памяти
применяется для хранения выражений так же, как это было сделано для предыдущего
случая.
Метод граничных маркеров удобно применять на практике. Он быстро
работает, обладает хорошими показателями фрагментации памяти. Пожалуй,
единственный его серьезный недостаток (впрочем, как и у остальных
описываемых здесь методов) — это невысокая надежность. Управляющая
информация о связях свободных блоков, граничные маркеры и т. п. хранятся по
соседству с выделяемыми участками памяти, а значит, любая ошибка в
адресации может привести к сбою в работе системы, который полностью нарушит
нормальную работу. Для повышения надежности следовало бы отделять
управляющую информацию от распределяемой памяти. Однако если в методе
двусвязных списков это сделать сравнительно несложно, то в методе
граничных маркеров это просто невозможно, поскольку хранение маркеров
непосредственно в блоках памяти составляет самую суть метода.
В заключение главы приведем пример еще одного алгоритма управления
памятью, который носит название метода двоичных близнецов. При этом
методе размеры всех выделяемых системой блоков памяти являются степенями
двойки, что, конечно, может привести к большим потерям памяти из-за
сильной внутренней фрагментации. Действительно, даже если реально требуется
блок памяти размером, скажем, в 75 байтов, то все равно система выделит
128 байтов, т. к. это ближайшая к 75 сверху степень двойки. Зато во всех
остальных отношениях система довольно привлекательна. Она работает
быстро, и так же быстро происходит слияние соседних свободных блоков, так что
борьба с внешней фрагментацией не занимает много времени.
Идея метода состоит в том, чтобы вместо единого списка свободных блоков
иметь несколько списков свободных блоков одного и того же размера: список
номер к будет содержать лишь свободные блоки размером 2к. На рис. 5.5
показана структура буфера и вспомогательных переменных при распределении
памяти этим методом. Массив f reeLists на этом рисунке содержит указатели
начал списков свободных блоков. А:-й элемент этого массива указывает на
список свободных блоков размером 2к, расположенных в буфере buffer
размером 2".
Наша система распределения памяти оказывается достаточно гибкой,
поскольку любой свободный блок памяти размером 2к+х байтов можно
разделить на два блока, каждый из которых будет иметь размер 2 байтов. Это
позволит легко варьировать размеры блоков, перемещая их при необходимости
из одного списка свободных блоков в другой.
Поиск свободного блока всегда начинается с того, что проверяется список
свободных блоков нужного размера, и если список не пуст, то первый же
320
Глава 5
блок из этого списка выдается в качестве свободного. Если блок подходящего
размера сразу же не находится, то система пробует найти блок вдвое
большего размера и разделить его на два равных по размеру блока. Одна из этих
половинок сохраняется в списке свободных блоков соответствующего размера,
а другая отдается в качестве результата запроса. Если и такого вдвое
большего блока нет, то система пробует находить все большие и большие блоки до
тех пор, пока либо не найдется какой-либо свободный блок, который можно
раздробить на более мелкие, либо не будет достигнут верхний предел
размеров блоков, и система откажет в запросе.
Возврат ранее выделенного участка памяти в систему происходит по той же
схеме. Когда участок возвращается, система определяет, свободен или нет
тот двойник (или близнец) возвращаемого блока, с которым вместе они могли
бы составить блок вдвое большего размера. Если блок-близнец оказывается
свободен, то происходит объединение блоков, и в систему возвращается уже
вдвое больший блок памяти.
Самым трудным в этом методе оказывается нахождение для каждого из
блоков его близнеца. Для этого используется арифметика относительных
адресов, в которой в качестве всех адресов (указателей) используются смещения в
байтах блоков внутри всего буфера памяти, отведенного для работы системы.
Если полный размер буфера равен 2", то все смещения внутри буфера
расположены в диапазоне от нуля до 2"-1, причем смещения блоков памяти, размер
которых равен 2*, будут всегда кратны значению 2к. Если рассматривать
смещения блоков в двоичной системе счисления, то младшие к битов в адресе
блока размером 2к всегда будут равны нулю.
freeLists
W,
/*,
f/
I
buffer
^-* | глпбпдный
блок размером 2к
свободный
блок размером 2к
Рис. 5.5. Структура памяти в методе двоичных близнецов распределения памяти
Алгоритмы распределения памяти
321
Чтобы для данного блока определить адрес его близнеца, рассмотрим тот
блок размером 2к+\ который мог бы получиться при объединении блока
размером 2к с его близнецом. Адрес такого объединенного блока будет иметь
уже (А+1) нулей в младших битах. Ясно, что если адрес блока размером 2к
имеет на конце (к+\) нуль, то он должен совпадать с адресом объединенного
блока, а значит, адрес его близнеца будет на 2к больше адреса самого блока.
Наоборот, если адрес блока имеет на конце лишь к нулей, а (А+1)-й бит
оказался равен единице, то адрес его близнеца должен совпасть с адресом
объединенного блока, т. е. его можно вычислить, отняв от адреса самого блока
2 . Другими словами, имея адрес блока, можно получить адрес его близнеца,
если инвертировать в этом адресе (А+1)-й бит.
Проверить, является ли близнец данного блока свободным блоком памяти,
можно просто просмотрев список свободных блоков данного размера.
Конечно, это чуть медленнее, чем в методе граничных маркеров, однако в
данном методе выигрыш достигается за счет того, что поиск по списку не
проводится при резервировании памяти. Кроме того, поскольку вместо одного
списка свободных блоков мы имеем несколько списков, причем поиск
производится только в одном из них, можно надеяться, что этот поиск будет не
очень долгим.
Алгоритмы резервирования и освобождения памяти в методе двоичных
близнецов удобно оформлять в виде рекурсивных функций, одним из параметров
которых служит размерность блока — показатель степени двойки,
определяющий размер выделяемого или освобождаемого блока. Размерность
блока — величина небольшая, Современные компьютеры не работают с блоками
памяти размером больше 2 2 байтов, так что для хранения размерности
достаточно всего одного байта памяти. Для того чтобы при возврате блока в
систему знать, какой величины блок был выделен, можно записать в первый
байт выделяемого блока его размерность.
Итак, реализация системы распределения памяти, основанной на методе
двоичных близнецов, приведена в листинге 5.8. В этом листинге операции get и
release просто вызывают соответствующие рекурсивные версии этих
функций. Вся работа с адресами блоков происходит с помощью операций
побитовой арифметики. Сами эти адреса представлены значениями типа sizet, a
окончание списка отмечается специальным адресом, равным Oxffffffff.
| Листинг 5.8. Реализация системы распределения памяти
| методом двоичных близнецов
// Класс BinTwinsMemory представляет буферный пул памяти и систему
// управления ею для размещения в ней блоков переменного размера.
// Свободные блоки образуют систему двоичных близнецов
322
Глава 5
/j bitwins.h
class BinTwinsMemory {
// Адрес, отмечающий конец списка:
static const size_t null = OxFFFFFFFF;
char * buffer; // Указатель на буфер
int power; // Двоичный показатель размера буфера
// Массив списков свободных блоков одного размера:
// элемент с индексом к содержит адрес списка
// свободных блоков размером 2**к
size__t * freeLists;
public :
// Конструктор:
BinTwinsMemory(int n)
: power(n), freeLists(new size_t[n +1]), buffer(new char[l « n]) {
clear();
}
// Деструктор:
^BinTwinsMemory() {
delete[] buffer;
delete[] freeLists;
}
// Очистка памяти: инициализация списков свободных
// блоков - все списки кроме одного - пустые.
void clear() {
for (int i = 0; i < power; i++) freeLists[ij = null;
freeLists[power] = 0;
*(size_t *)buffer = null;
}
// Операция выделения свободного блока памяти заданного размера
void * get(size_t sz);
// Операция возврата выделенного блока памяти в систему
void release (void * ptr) ;
private :
// Вспомогательные рекурсивные функции. Аргумент р - размерность блока.
size_t getRec(char p);
void BinTwinsMemory::releaseRec(size_t block, char p);
};
Алгоритмы распределения памяти
323
17 bitwins.срр
// Операция выделения свободного блока памяти заданного размера
void * BinTwinsMemory::get(size_t sz) {
sz += sizeof(char); // один байт резервируется дополнительно
// Вычисление размерности блока, который необходимо зарезервировать
char p = 2;
while (sz != (1 « р) && (~((lu « р)-1) & sz) != 0) р++;
// Обращение к рекурсивной функции, выдающей смещение свободного блока
size_t request = getRec(p);
if (request == null) throw NoMoreMemoryException();
// В начало блока помещаем его размерность и выдаем адрес
char * block = buffer + request;
*block = p;
return block + sizeof(char);
}
// Рекурсивная функция выделения свободного блока памяти
size_t BinTwinsMemory::getRec(char p) {
// 1. Проверка: если запрашивается блок слишком большого размера,
// значит, свободной памяти больше нет
if (p > power) return null;
// 2. Проверяем список свободных блоков нужного размера
if (freeLists[pj = null) {
// Список пуст; запрашиваем вдвое больший блок.
size_t doubleBlock = getRec(p + 1);
// Если запрос закончился неудачно, то возвращаем пустой указатель.
if (doubleBlock == null) return null;
// Блок вдвое большего размера делится пополам;
// адрес блока-близнеца записывается в соответствующий список
size_t twinBlock = doubleBlock + (1 « р);
*(size__t *) (buffer + twinBlock) = null;
freeLists[p] = twinBlock;
return doubleBlock;
} else {
// Свободный блок нужного размера найден; он исключается
//из списка и его адрес выдается в качестве результата
size_t result = freeLists[p];
freeLists[p] = *(size_t *)(buffer + freeLists[p]);
return result;
}
}
// Операция возврата выделенного блока памяти в систему
void BinTwinsMemory: : release (void * ptr) {
324
Глава 5
// Вычисляем адрес блока и его размерность р
char * block = (char*)ptr - sizeof(char);
char p = *block;
// Обращаемся к рекурсивной функции возврата блока в систему
releaseRec(block - buffer, p);
}
// Рекурсивная функция возврата выделенного блока памяти в систему
void BinTwinsMemory::releaseRec(size_t block, char p) {
// Вычисляем адрес близнеца
size_t twinBlock = (1 « p) Л block;
// Организуем поиск блока-близнеца в списке свободных блоков
size__t* ptr = &f reeLists [p] ;
while (*ptr != null && *ptr != twinBlock) {
ptr = (size_t*)(buffer + *ptr);
}
if (*ptr = null) {
// Адрес близнеца не найден - блок присоединяется к списку
*(size_t*)(buffer + block) = freeLists[p];
freeLists[p] = block;
} else {
// Блок-близнец исключается из списка свободных блоков
*ptr = *(size_t*)(buffer + *ptr);
// Рекурсивный вызов для освобождения блока удвоенного размера
releaseRec(block & ~(lu « р), р+1);
}
}
Мы можем проверить работу метода двоичных близнецов, так же как и
раньше, заставив отводить память внутри нашей системы с помощью
переопределения операторов new и delete для класса Expression. В папке "Chapters
\5.3\BinTwins" приложенного компакт-диска приведенная в листинге 5.8
система распределения памяти дополнена методами для подсчета некоторых
статистических данных. Если воспользоваться для трансляции примера
компилятором G++ фирмы Delorie Software, имеющемся на том же диске, то в
нашем небольшом примере, где система распределения памяти используется
для размещения в ней выражений при их дифференцировании и упрощении,
статистика запросов к памяти выгляди очень плохо. Из-за потерь,
вызванных внутренней фрагментацией памяти, память используется только на
50 процентов. Это происходит из-за того, что запросы к системе идут на
выделение блоков по 8 и 16 байтов, а поскольку система для каждого блока
вынуждена выделять еще один дополнительный байт, где помещается размер-
Алгоритмы распределения памяти
325
ность блока, то фактически каждый раз выделяется блок вдвое большей
длины, чем необходимо.
На самом деле таких потерь памяти можно избежать, если не хранить длину
блока в самом выделенном участке памяти. Вместо этого пользователи
системы могут сами запоминать информацию о том, сколько памяти они
запрашивали у системы, и передавать эту информацию позже в виде
дополнительного аргумента метода release при освобождении памяти. В методе
двоичных близнецов длины блоков всегда стандартные, а значит, система всегда
может вычислить размер освобождаемого блока по значению переданного
аргумента.
В папке "Chapter5\5.3\BinTwinsMod" приложенного компакт-диска такая
модификация сделана, в результате чего использование памяти резко
улучшилось: потерь памяти теперь нет вовсе. Изменения, которые пришлось сделать
в коде реализации и использования системы распределения памяти, очень
небольшие. Во-первых, теперь размерность блока не хранится в самом блоке,
а вычисляется по значению второго аргумента метода release, во-вторых,
оператор delete теперь приходится переопределять явно для всех классов,
наследующих класс Expression, поскольку размер освобождаемого объекта
теперь надо явно передавать системе в качестве аргумента, например:
class Varaible : public Expression {
public :
void operator delete(void * ptr) {
memoryManagement->release(ptr, sizeof(Variable));
}
};
Еще раз напомним (см. разд. 4.3), что применение деструктора к
выражениям, полученным в результате преобразования других выражений, опасно из-
за того, что некоторые части этих выражений могут использоваться в дереве
неоднократно, а значит, применение деструктора к такому дереву может
вызвать повторное использование деструктора к одному и тому же объекту.
Надежным способом очистки памяти после работы с выражениями служит
очистка всей памяти сразу. Если вы программируете систему распределения
памяти сами, то это можно легко сделать с помощью метода clear класса,
реализующего такую систему, или путем применения деструктора сразу ко
всей системе распределения памяти.
ГЛАВА 6
Алгоритмы
обработки графов
В этой главе представлены некоторые классические алгоритмы обработки
графов и приведены примеры их использования. Алгоритмы на графах
традиционно относятся к наиболее сложным алгоритмам, в этой области было
получено много интересных результатов. Большое количество литературы
посвящено описаниям этих алгоритмов, достаточно упомянуть такие
фундаментальные работы, как [1], [7] и [8]. В нашу задачу не входит глубокий
анализ этих алгоритмов, мы даже не будем пытаться находить во всех случаях
оптимальные решения. Задача этой главы состоит в том, чтобы определить,
как выбранное представление данных влияет на выбор алгоритмов, и, кроме
того, интересно посмотреть, как реализуются классические алгоритмы на
языке C++ с применением современных технологий создания программ.
6.1. Обходы и поиск в графах
Основу многих алгоритмов обработки сетевой информации составляют
алгоритмы обхода (итерации) графов, в процессе которого производится поиск
необходимой информации или определение каких-либо характеристик сети.
Если граф имеет N вершин и Мдуг, то говорят, что алгоритм обхода
индуцирует нумерацию вершин и дуг графа, приписывая им номера от 1 до N и от 1
до М соответственно в порядке обхода. Итераторы графа могут быть как
внешними, так и внутренними, при этом внешний итератор, как обычно,
выдает вершины или дуги графа в порядке обхода, а внутренний итератор,
обходя граф, посещает его вершины и дуги и выполняет в каждой из них
процедуру посещения.
В процессе обхода обычно используется информация о связях между
вершинами, т. е. алгоритмы обхода, просматривая граф, переходят всегда от
некоторой вершины к одной из связанных с нею вершин. Разумеется, это не обя-
Алгоритмы обработки графов
327
зательно означает, что порядок обхода вершин также таков, что вслед за
одной вершиной непременно в качестве следующей вершины следует одна из
связанных с ней. Более того, в большинстве случаев такой обход построить
просто невозможно. Обходы, описанные выше, называются Гамилыпоновыми
путями, для существования Гамильтонова пути граф должен удовлетворять
определенным условиям. Но алгоритмы обхода просто используют связи
между вершинами для перехода от одних вершин к другим.
Если дуги, связывающие вершины графа,— направленные (с технической
точки зрения это означает, что если от вершины А можно перейти к вершине
5, то это не значит, что от вершины В можно обязательно непосредственно
перейти к вершине А\ то даже для связного графа не всегда удается, выбрав
некоторую вершину в качестве исходной, обойти его весь, проходя только по
направлениям дуг. В этом случае часто поступают следующим образом.
Выбирают некоторую вершину графа и обходят все его вершины, достижимые
из выбранной. Если в графе остались еще непройденные вершины, то
выбирают одну из таких вершин и снова обходят все вершины, достижимые из
выбранной (разумеется, если в процессе обхода попадется одна из уже
обойденных вершин, то не только ее, но и все, достижимые из нее вершины
повторно рассматривать не надо). Процесс продолжается до тех пор, пока в
графе не останется ни одной непройденной вершины. Если дуги в графе не
направленные, то описанный алгоритм приведет к тому, что каждый раз
после выбора начальной вершины будет пройдена одна компонента связности
графа. Если граф — связный, то какую бы вершину ни выбрать в качестве
начальной, все остальные будут из нее достижимы, так что никогда не
потребуется после обхода всей компоненты связности производить еще один
выбор вершины.
Наряду с обходами, при которых посещаются все вершины графа, можно
также рассматривать обходы, предназначенные для прохождения всех дуг
(ребер) графа. Такие обходы индуцируют некоторую нумерацию на
множестве ребер подобно тому, как обходы вершин индуцируют нумерацию на
множестве вершин. На практике обычно можно использовать для обходов дуг и
вершин графа одни и те же алгоритмы. Действительно, практически все
алгоритмы обхода графа хотя бы по одному разу обязательно исследуют как
вершины, так и дуги графа. Для того чтобы понять, не находится ли на конце
некоторой дуги еще не пройденная вершина, требуется исследовать эту дугу.
И наоборот, если проходятся все дуги, значит, и все вершины будут
пройдены, поскольку каждая вершина находится на конце некоторой дуги.
Мы будем рассматривать алгоритмы обхода, использующие представление
графа, описанное в разд. 1.5 под названием i-графа, в котором для каждой
вершины имеется список дуг, выходящих из этой вершины. Для алгоритмов
обхода такое представление наиболее естественно, поскольку именно в этом
328
Глава 6
случае проще всего переходить от вершины графа к соседним с нею
вершинам. Действительно, зная номер некоторой вершины, мы легко сможем найти
все смежные с ней вершины, исследуя выходящие из этой вершины дуги.
В описании алгоритмов мы будем считать, что последовательно нумеруются
вершины графа, но фактически теми же самыми алгоритмами нумеруются и
дуги графа.
Представление в виде Z-графа описывает ориентированный граф, в котором
все дуги имеют направление. Таким образом, если некоторая дуга связывает
вершину А с вершиной В, то из этого еще не следует, что из вершины В
можно пройти в вершину А. Может оказаться, что даже для связного графа не
обязательно удастся пройти его весь, начав обход с произвольно выбранной
вершины. Если граф— неориентированный, то его представление наряду с
дугой, ведущей из вершины А в вершину В, обязательно имеется также и
дуга, ведущая из В в А. Это означает, что наши алгоритмы будут проходить
каждое ребро графа дважды: один раз в направлении от А к В, а другой раз —
в обратном направлении.
Как и в случае обхода деревьев, для графов существуют два основных класса
обходов: обходы в глубину и обходы в ширину.
Обходы в глубину пытаются каждый раз после прохождения некоторой
вершины А выбрать в качестве следующей такую вершину В, в которую из
вершины А ведет дуга. Таким образом, алгоритмы этого класса пытаются всегда
двигаться вглубь графа, находя все новые не пройденные ранее вершины.
Если таких непройденных вершин нет, то алгоритм возвращается к
предыдущей, ранее пройденной вершине, и пытается найти очередную дугу, ведущую
из нее в какую-либо другую еще не пройденную вершину. Если и таких
вершин нет, то снова происходит откат назад, и так до тех пор, пока либо все
вершины не будут пройдены, либо алгоритм вернется назад в вершину,
выбранную в качестве исходной.
Обходы в ширину пытаются, начав с некоторой вершины, в качестве
очередных рассматривать только вершины, непосредственно связанные с исходной.
Только после того, как все такие вершины будут рассмотрены, алгоритм
будет пытаться рассматривать следующий слой вершин, непосредственно
связанных с только что пройденными. Таким образом, алгоритм обхода в
ширину последовательно рассматривает все более удаленные от исходной
вершины до тех пор, пока либо все вершины не будут пройдены, либо не останется
больше вершин, достижимых из исходной.
Алгоритмы обхода графов (так же, как и деревьев, и списков) могут быть
реализованы как внешними, так и внутренними итераторами. Внешний
итератор в момент создания берет представление графа в качестве аргумента, а
затем осуществляет последовательную выдачу номеров вершин графа, ис-
Алгоритмы обработки графов
329
пользуя для обхода локальные объекты, такие, как стек или очередь.
Внутренний итератор чаще всего реализуется в виде рекурсивной функции,
которая, проходя по очереди вершины графа, выполняет для каждой из них
некоторое действие, заданное в качестве аргумента итератора.
Рассмотрим в качестве примера граф, изображенный на рис. 6.1.
Рис. 6.1. Пример ориентированного графа
Этот граф содержит 9 вершин и 12 дуг. Он состоит из двух компонент
связности, так что заведомо не удастся обойти его весь, выбрав некоторую
вершину в качестве исходной и проходя только по направлениям дуг. Тем не
менее если сначала выбрать в качестве исходной вершины вершину 1, то все
остальные вершины одной компоненты связности из нее будут достижимы,
так что по крайней мере эта компонента связности будет пройдена до того,
как потребуется вновь выбирать начальную вершину. Тем же свойством
обладают и все остальные вершины графа, кроме вершины 7. Если выбрать ее в
качестве начальной вершины для обхода, то будет пройдена только она сама,
а потом потребуется вновь выбирать некоторую вершину в качестве
начальной для обхода остальных.
Если в качестве алгоритма обхода взять алгоритм обхода в глубину, причем
договориться, что из всех возможных альтернатив он всегда выбирает проход
к вершине с наименьшим номером, то получится следующий порядок обхода
вершин графа:
0; 1; 4; б; 3; 7; 2; 8; 5
Порядок обхода в ширину в данном случае отличается от порядка обхода
в глубину незначительно:
0; 1; 4; б; 7; 3; 2; 8; 5
Но это, конечно, получается просто потому, что граф, выбранный нами для
примера, очень небольшой. Обычно в случае больших графов порядки обхода
отливаются весьма существенно.
330
Глава 6
На рис. 6.1 изображена логическая структура графа. Для того чтобы яснее
представить себе работу алгоритмов обхода, необходимо хорошо
представлять также и физическую структуру памяти соответствующего объекта.
В представлении графа рис. 6.1 в виде Z-графа имеются 9 списков дуг (для
каждой вершины существует список исходящих из нее дуг). На рис. 6.2
приведено физическое представление этого графа.
i ° s
! 1 *^
| 2 т^
| 3 #-
I i л л
| 5 #^.
! 6 •<
! 7 ш
| 8 #^
Л
^
w
1 А-
л ш-
8 »
0 »
R А-
о w
2 »
3 »
5 »
ь»
W
Л ш-
7 •
7 •
*-
^
6 •
Рис. 6.2. Физическое представление графа, изображенного на рис. 6.1
На этом рисунке изображен массив списков дуг (около каждого элемента
массива показан номер соответствующей вершины графа). Дуги
представлены прямоугольниками, содержащими номер той вершины, в которую дуга
входит.
В качестве первого примера рассмотрим внешний итератор вершин графа
при обходе в глубину. Для этого определим класс, объекты которого
получают исходный граф в виде аргумента конструктора, и затем осуществляют его
обход, реализуя обычный интерфейс итератора. Для того чтобы перейти от
текущей вершины графа к следующей, итератор должен, во-первых,
запоминать, какие из вершин уже пройдены, а во-вторых, для быстрого возврата к
предыдущей вершине помнить последовательность дуг на пути из выбранной
начальной вершины в текущую. Эту последовательность дуг нам будет
удобно представлять в виде стека, элементами которого будут содержать
информацию, необходимую для возврата по дугам.
Алгоритмы обработки графов
331
Пусть алгоритм выбрал в качестве начальной вершину с номером 0. Тогда
обход графа начнется в ситуации, приведенной в первой строке табл. 6.1.
Таблица 6.1. Последовательность состояний при обходе графа в глубину
Шаг
I 1
2
3
I 4
5
i 6
, 7
8
9
10
11
Список
пройденных вершин
—
0
0,1
0,1,4
0,1,4,6
0,1,4,6,3
0,1,4,6,3,7
0,1,4,6,3,7
0,1,4,6,3,7,2
0, 1,4,6,3,7,2,8
0,1,4,6,3,7,2,8,5
Текущая
вершина
0
1
4
6
3
7
—
2
8
5
—
Стек дуг
на пути к текущей вершине
—
0-И
0-И, 1-4
0-и, 1-+4, 4-6
0-1,1-4,4-6,6-3 |
0-1,1-4,4-7
—
—
2-8
2-8, 8-5
—
Итератор обходит текущую вершину (точнее, после выполнения каждого
шага итератора очередная вершина становится доступной для алгоритма,
запустившего итератор, с помощью метода operator *) и выбирает следующую
вершину из числа тех, в которые ведут дуги из текущей вершины. После
первого шага ситуация будет такой, как показано в строке 2 табл. 6.1.
Пройденная на этом шаге дуга 0-*1 помещается в стек. Следующие три шага
выполняются аналогично, а последовательное изменение ситуации показано в
табл. 6.1 в строках 3—5.
В тот момент, когда текущей стала вершина 3, оказывается, что единственная
дуга, ведущая из нее, ведет в уже пройденную вершину 0, поэтому следует,
используя стек, вернуться на шаг назад в вершину 6 и попытаться найти
следующую вершину по одной из дуг, ведущих из этой вершины. Поскольку
новых дуг, ведущих из вершины 6, тоже больше нет, то делаем еще шаг назад
к вершине 4. Теперь уже находится дуга, ведущая из этой вершины в еще не
пройденную вершину 7, так что после прохода по соответствующей дуге
образуется новая ситуация, показанная в табл. 6.1 в строке 6.
После прохождения вершины 7 оказывается, что больше нет дуг, ведущих в
еще не пройденные вершины ни из вершины 7, ни из всех предыдущих вер-
332
Глава 6
шин на пути в эту вершину. В строке 7 таблицы показана ситуация,
возникшая после проверки всех этих дуг. Теперь можно снова выбрать произвольно
одну из непройденных вершин, скажем, вершину 2, и далее алгоритм
последовательно переберет вершины 2, 8 и 5 так, как показано в строках 8—11.
Теперь все вершины графа оказываются пройденными.
В листинге 6.1 приведено описание класса, реализующего граф так, как это
было сделано в разд. 1.5, и описание класса, реализующего внешний итератор
вершин графа в соответствии с указанным алгоритмом. Основное содержание
алгоритма включено в реализацию оператора operator ++(), который и
осуществляет переход к очередной вершине графа. По сравнению с описанием,
приведенным выше, в определении функции сделано небольшое изменение:
вместо множества пройденных вершин во время работы алгоритма хранится
множество еще не пройденных вершин. Это изменение, разумеется,
ничего не меняет по существу алгоритма, но облегчает реализацию метода
hasMoreElements().
Стек дуг представлен объектом arcs класса stack<iterator<int>*>.
Элементами этого стека будут итераторы, задающие последовательности дуг в
представлении Z-графа. Это не очень традиционное, но довольно естественное
для данной задачи решение. Действительно, нас интересуют не столько сами
дуги, сколько множества тех вершин, в которые эти дуги приводят. Итератор
списка номеров дуг эффективно представляет не только само множество, но и
обеспечивает перебор элементов этого множества. Таким образом, итератор
используется так, как обычно используется указатель на очередной элемент
списка, при этом, однако, не требуется никакой информации о внутреннем
устройстве списка.
Множество непройденных вершин представлено объектом setNotPassed
класса Set, как он был определен в разд. 1.4. Элементами этого множества
являются номера вершин графа. Считаем определенными в классе Set два
дополнительных метода: метод, выдающий итератор элементов множества
iterator о (на самом деле такой итератор нами используется только для
выборки произвольного элемента непустого множества), и метод empty о,
проверяющий, есть ли элементы в множестве (т. е. пустое множество или нет).
Номер текущей вершины в процессе обхода графа содержится в переменной
current.
| Листинг 6.1. Внешний итератор вершин графа в алгоритме его обхода
! в глубину
// Определение класса ListGraph
class ListGraph {
Алгоритмы обработки графов
333
// Массив списков дуг
List<int> *graph;
// Количество вершин графа
int vertexNumber;
// Класс для реализации внешнего итератора вершин графа
class ExtGraphlterator : public Iterator<int> {
Set setNotPassed; // множество непройденных вершин
int current; // текущая вершина
Stack<Iterator<int>*> *arcs; // стек итераторов исходящих дуг
ListGraph *graph; // граф
public :
// Конструктор внешнего итератора
ExtGraphlterator(ListGraph *graph);
// Деструктор
//==============================================:================
-ExtGraphlterator() { delete arcs; }
// Функция проверки, остались ли еще не пройденные вершины
bool hasMoreElements() const {
return !setNotPassed.empty();
}
// Функция сдвига итератора на следующую вершину
//в порядке обхода вершин графа в глубину
Iterator<int> & operator ++();
// Функция, выдающая номер очередной вершины на пути обхода
const int & operator *() const { return current; }
};
public:
// Конструктор графа создает массив пустых списков
ListGraph(int n) : vertexNumber(n), graph(new List<int>[n]) {}
// Деструктор уничтожает списки дуг
-ListGraph() { delete [] graph; }
334
Глава 6
/I Функция подсчета числа вершин просто выдает
// ранее сохраненное значение
int vertexCount() const { return vertexNumber; }
// Основные методы работы с графом
void addArc(int from, int to);
bool hasArc(int from, int to) const;
Iterator<int> * iterator() { return new ExtGraphlterator(this); }
};
//=
// Методы класса ListGraph
// Добавление дуги
void ListGraph:raddArс(int from, int to) {
if (from < 0 || from >= vertexNumber I| to < 0
return; // Неправильно задана дуга
graph[from].addLast(to);
to >= vertexNumber)
}
// Поиск дуги
bool ListGraph: :hasArc (int from, int to) const {
if (from < 0 || from >= vertexNumber || to < 0 |I to >= vertexNumber)
return false; // Неправильно задана дуга
// Перебираем все дуги с помощью итератора элементов списка
Iterator<int> * ends = graph[from].iterator();
bool found = false;
while (ends->hasMoreElements() && !(found = (to == **ends))) ++*ends;
delete ends;
return found;
}
// Методы итератора вершин графа
/А
// Конструктор внешнего итератора вершин графа
ListGraph::ExtGraphlterator::ExtGraphlterator(ListGraph *graph)
// множество непройденных вершин:
setNotPassed(0, graph->vertexCount()-1),
// текущая вершина - произвольно берем нулевую вершину:
current(0),
Алгоритмы обработки графов
335
// стек непросмотренных дуг:
arcs(new ListStack<Iterator<int>*>),
// граф, подлежащий обходу:
graph(graph)
{
//В множество непройденных вершин помещаются все вершины графа
setNotPassed.addScale(0, graph->vertexCount()-1);
}
// Функция, устанавливающая в качестве текущей
// следующую вершину на пути обхода графа в глубину
Iterator<int> & ListGraph::ExtGraphIterator::operator ++() {
// 1. Проверка корректности вызова
if (IhasMoreElements()) return *this;
// 2. Проходим текущую вершину, удаляя ее из списка непройденных
setNotPassed -= current;
// 3. Находим очередную вершину. При этом сначала
// рассматриваем дуги, выходящие из текущей вершины
Iterator<int> *nextPoints = (graph->graph)[current].iterator();
for (;;) {
if (!nextPoints->hasMoreElements()) {
// Очередной дуги нет
delete nextPoints;
if (arcs->empty()) {
// стек дуг также пуст: компонента связности пройдена,
// выбираем следующую из множества непройденных вершин
//и переходим тем самым к следующей компоненте связности
Iterator<int> * setlterator = setNotPassed.iterator();
current = (setIterator->hasMoreElements() ? **setIterator : -1);
delete setlterator;
return *this;
} else {
// выбираем очередную дугу из стека
nextPoints = **arcs;
arcs->pop();
}
}
// Дуга, ведущая в некоторую вершину, найдена
int vertex = **nextPoints; // это будет следующая вершина
++*nextPoints;
if (setNotPassed.has(vertex)) {
336
Глава 6
/I это непройденная вершина; она объявляется текущей
current = vertex;
if (nextPoints->hasMoreElements()) {
// если есть еще дуги, ведущие из этой вершины,
//то итератор множества вершин записывается в стек
arcs->push(nextPoints);
} else {
delete nextPoints;
}
return *this;
}
Обход в глубину оказывается полезным в случае неориентированного графа
(напомним, что в представлении неориентированного графа вместе с любой
дугой (А, В) обязательно содержится и дуга (В, А)). В этом случае, какую бы
вершину ни взять в качестве исходной, в качестве следующих будут выбраны
все достижимые из нее вершины. Таким образом, все вершины,
принадлежащие одной и той же компоненте связности, получают последовательные
порядковые номера.
Момент перехода от одной компоненты связности к другой в алгоритме
перебора вершин отмечен в функции комментарием. Если добавить в итератор
счетчик компонент связности и увеличивать его при каждом переходе к
новой компоненте, то мы сможем легко получить функцию, которая будет
выдавать количество пройденных компонент связности. В листинге 6.2
приведен пример, в котором создается неориентированный граф, полученный из
графа рис. 6.1 добавлением всех обратных дуг, а затем его вершины
последовательно обходятся в глубину. В конце работы выдается количество
пройденных компонент связности графа (предполагается, что соответствующая
функция getComponentsPassedO уже Определена В классе ExtGraphlterator).
Листинг 6.2. Перечисление вершин графа по компонентам связности
int main() {
ListGraph testGraph(9)
testGraph.addArc(0/ I)
testGraph.addArc(0, 4)
testGraph.addArc(0, 6)
testGraph.addArcd, 4)
testGraph.addArcd, 7)
testGraph.addArc(2, 8)
testGraph.addArcd, 0)
testGraph.addArc(4, 0)
testGraph.addArc(6, 0)
testGraph.addArc(4, 1)
testGraph.addArc(7, 1)
testGraph.addArc(8, 2)
Алгоритмы обработки графов
337
testGraph. addArc (3, 0)
testGraph.addArc(4, б)
testGraph.addArc(4, 7)
testGraph.addArc(5, 2)
testGraph.addArc(6, 3)
testGraph.addArc(8, 5)
testGraph.addArc(0, 3)
testGraph.addArc(6, 4)
testGraph.addArc(7, 4)
testGraph.addArc(2, 5)
testGraph.addArc(3, 6)
testGraph.addArc(5, 8)
ListGraph::ExtGraphIterator * iter -
new ListGraph::ExtGraphIterator(StestGraph);
while (iter->hasMoreElements()) {
cout « **iter « '; ';
++*iter;
}
cout « endl « iter->getComponentsPassed()
« " components passed" « endl;
delete iter;
return 0;
}
При обходе вершин (и дуг) графа часто приходится выполнять более
сложную работу, чем элементарная нумерация вершин или подсчет числа
компонент связности. В Зтой ситуации более гибким оказывается аппарат
применения внутренних итераторов. При этом структура графа вне определения
класса, реализующего граф, обычно неизвестна. Поэтому техника, при которой
посетитель узла сам определяет порядок и содержание дальнейших
посещений, оказывается неприменимой. Более удобным и часто используемым
решением будет определение внутреннего итератора, который обходит узлы и
дуги графа по заданному им самим алгоритму, вызывая при этом различные
функции при заходе в узлы, при выходе из узлов, при прохождении по дуге
и т. п.
Определим, например, обход в глубину в виде внутреннего итератора,
который получает объект-посетитель в качестве аргумента и вызывает различные
методы этого посетителя во время прохождения вершин и дуг графа. Во
время обхода в глубину для каждой вершины графа есть два существенных
момента времени. Первый — это момент, когда вершина посещается в первый
раз. Именно в этот момент наш внешний итератор ExtGraphiterator выдавал
номер этой вершины в качестве очередной (в реализации метода operator ++
переменная current в этот момент получала новое значение). Второй
существенный момент— это момент окончательного покидания вершины, когда
уже рассмотрены все выходящие из нее дуги. Это тот момент, когда
происходит возврат по дуге, ведущей в эту вершину, а для вершины, выбранной
338
Глава 6
в качестве начальной, — это момент, когда заканчивается рассмотрение
одной компоненты связности.
Для каждой дуги также можно выделить два основных момента: момент,
когда происходит переход по этой дуге в новую или в уже посещенную ранее
вершину, и момент, когда происходит возврат по этой дуге. Если дуга ведет в
уже посещенную ранее вершину, то возврат происходит сразу же. Если же
дуга ведет в новую вершину, то возврат по этой дуге произойдет только
после того, как эта вершина будет покинута окончательно.
Существенным моментом при обходе в глубину является также момент
выбора произвольной вершины, когда уже исследованы все пути, ведущие из
ранее выбранной вершины. Для неориентированного графа это соответствует
моменту перехода к новой компоненте связности графа. Соответствующий
этому моменту метод также следует включить в интерфейс работы
посетителя.
Итак, определим интерфейс посетителя узлов и дуг графа таким образом,
чтобы в нем содержались методы для обработки вершин на входе и выходе,
для обработки дуг на пути туда и обратно, а также для обработки момента
выбора очередной вершины при переходе к новой компоненте связности
графа. Методы обработки вершин получат в качестве аргумента номер
обрабатываемой вершины, а методы обработки дуг получат в качестве аргументов
номера вершин, которые эта дуга соединяет, и информацию о том,
посещалась ли ранее та вершина, в которую ведет эта дуга. Вот как может выглядеть
определение такого интерфейса:
class GraphActor {
public :
virtual void vertexln(int vertex) {}
virtual void vertexOut(int vertex) {}
virtual void arcForward(int begin, int end, bool newVertex) {}
virtual void arcBackward(int begin, int end) {}
virtual void newSelection(int vertex) {}
};
Методы интерфейса GraphActor определены не как чистые функции, а как
пустые функции — функции, не совершающие никаких действий. Это
позволит при определении реальных посетителей задавать лишь необходимую
часть действий по обработке элементов графа, оставляя без внимания
несущественные для данной конкретной задачи моменты обхода.
Сам обход вершин и дуг графа может быть реализован по тому же самому
алгоритму, который был использован нами для реализации внешнего
итератора. В листинге 6.3 такая реализация приведена в виде метода traverseDepthO
класса ListGraph. Несколько изменена структура объектов, помещаемых в
Алгоритмы обработки графов
339
стек дуг. В представлении Z-графа дуга содержит информацию только о
номере вершины, в которую она входит. Однако при прохождении дуги
требуется знать также и вершину, из которой дуга исходит. Поэтому в стек помимо
итератора элементов списка дуг помещается также номер исходящей
вершины для обрабатываемого списка дуг. Структура Pair представляет тип
элементов стека — пару из номера вершины и указателя на итератор элементов
списка дуг.
j Листинг 6.3. Обход в глубину с помощью внутреннего итератора
class ListGraph {
public:
// Обход графа с помощью внутреннего итератора
void traverseDepth(GraphActor & actor);
protected :
// Пара из номера вершины и итератора исходящих дуг
// определяется для работы внутреннего итератора
struct Pair {
int sourceVertex;
Iterator<int>* iterator;
Pair(int v = -1, Iterator<int>* i = NULL)
: sourceVertex(v), iterator(i) {}
};
};
void ListGraph::traverseDepth(GraphActor & actor) {
// множество непройденных вершин:
Set setNotPassed(0/ vertexCount()-l);
setNotPassed.addscale(0, vertexCount()-1);
// стек дуг:
ListStack<Pair> arcs;
// Цикл по компонентам связности графа
while (IsetNotPassed.empty()) {
// Выбираем произвольную вершину из еще не пройденных
Iterator<int> * setlterator = setNotPassed.iterator();
int current = **setIterator;
// Отмечаем заход в новую компоненту связности
actor.newSelection(current);
delete setlterator;
// Список неисследованных дуг, исходящих из очередной вершины
Iterator<int> * curList = graph[current].iterator();
340
Глава 6
// Цикл по вершинам одной компоненты связности
while (current != -1) {
// Проходим текущую вершину
setNotPassed -= current;
actor.vertexln(current);
// Цикл прохода по дугам
for (;;) {
// Есть дуга, ведущая из текущей вершины?
if (curList->hasMoreElements()) {
int arcEnd = **curList;
bool newVertex = setNotPassed.has(arcEnd);
++*curList;
// Проходим вперед по выбранной дуге
actor.arcForward(current, arcEnd, newVertex);
if (newVertex) {
// Дуга ведет в новую ранее не рассмотренную вершину.
// Запоминаем ситуацию, делаем шаг вперед по выбранной дуге
//и переходим к рассмотрению следующей вершины
arcs.push(Pair(current, curList));
current = arcEnd;
curList = graph[current].iterator();
break;
} else {
// Дуга ведет в уже пройденную вершину.
// Выполняем обратный проход по этой дуге
actor.arcBackward(current, arcEnd);
}
} else {
// Покидаем окончательно текущую вершину и
// возвращаемся к предьщущим вершинам
actor.vertexOut(current);
delete curList;
if (arcs.empty()) {
// Компонента связности пройдена полностью
current = -1;
break;
} else {
// Делаем обратный проход по дуге, извлеченной из стека дуг
Pair previous = *arcs;
arcs.pop();
actor.arcBackward(previous.sourceVertex, current);
current = previous.sourceVertex;
Алгоритмы обработки графов
341
curList = previous.iterator;
}
}
}
}
}
}
Полный протокол работы итератора можно посмотреть, если определить
посетителя, который будет протоколировать все заявленные действия. Ниже
представлено описание такого класса Logger и приведен пример вызова
функции обхода графа с помощью такого посетителя.
class Logger : public GraphActor {
public :
void vertexln(mt vertex) {
cout « "In vertex: " « vertex « endl;
}
void vertexOut(int vertex) {
cout « "Out vertex: " « vertex « endl;
}
void arcForward(int begin, int end, bool newVertex) {
cout « "Arc forward (" « (newVertex ? "new" : "old")
« "): (" « begin « "-" « end « ")" « endl;
}
void arcBackward(int begin, int end) {
cout « "Arc backward: (" « begin « "-" « end « ")" « endl;
}
void newSelection(int vertex) {
cout « "New component started from vertex: " « vertex « endl;
}
} logger;
testGraph.traverseDepth(logger);
Для приведенного на рис. 6.1 примера в результате работы итератора будет
выведен следующий протокол:
New component started from vertex: 0
In vertex: 0
Arc forward (new): (0-1)
In vertex: 1
Arc forward (new): (1-4)
In vertex: 4
342
Глава 6
Arc forward (new): (4-6)
In vertex: 6
Arc forward (new): (6-3)
In vertex: 3
Arc forward (old): (3-0)
Arc backward: (3-0)
Out vertex: 3
Arc backward: (6-3)
Out vertex: 6
Arc backward: (4-6)
Arc forward (new): (4-7)
In vertex: 7
Out vertex: 7
Arc backward: (4-7)
Out vertex: 4
Arc backward: (1-4)
Arc forward (old): (1-7)
Arc backward: (1-7)
Out vertex: 1
Arc backward: (0-1)
Arc forward (old): (0-4)
Arc backward: (0-4)
Arc forward (old): (0-6)
Arc backward: (0-6)
Out vertex: 0
New component started from vertex: 2
In vertex: 2
Arc forward (new): (2-8)
In vertex: 8
Arc forward (new): (8-5)
In vertex: 5
Arc forward (old): (5-2)
Arc backward: (5-2)
Out vertex: 5
Arc backward: (8-5)
Out vertex: 8
Arc backward: (2-8)
Out vertex: 2
Задача о переборе всех компонент связности неориентированного графа
будет теперь решаться с помощью подходящего определения посетителя дуг и
вершин графа. Для этого определим класс Component Logger, реализующий
интерфейс GraphActor. Этот посетитель должен будет при выборе очередной
Алгоритмы обработки графов
343
вершины (newseiection) выдавать информацию о начале новой компоненты
связности, а при заходе в каждую новую вершину печатать ее номер. Ниже
показан соответствующий фрагмент программы.
class ComponentLogger : public GraphActor {
int compNumber;
public :
ComponentLogger() : compNumber(0) {}
void vertexln(int vertex) { cout « vertex « "; "; }
void newSelection(int vertex) {
cout « endl « "Component #: " « ++compNumber « endl;
}
} compLogger;
testGraph.traverseDepth(compLogger);
С помощью внутреннего итератора, определенного таким образом, можно
решать и более сложные задачи. Например, следующая задача известна под
названием топологической сортировки вершин графа.
Пусть ориентированный граф не имеет циклов, т. е., если, следуя по
направлению дуг, можно попасть из вершины А в вершину 5, то из вершины В
попасть в вершину А заведомо невозможно. В частности, это означает, что в
графе нет петель — дуг вида {А, А). Примером такого графа может служить
схема выполнения некоторого множества работ, причем элементарные
работы обозначаются вершинами графа, а дуга проводится из вершины А в
вершину 5, если работа В может быть выполнена только после того, как
закончится выполнение работы^.
Задача состоит в том, чтобы перенумеровать все вершины графа таким
образом, чтобы любая дуга вела из вершины с меньшим номером в вершину с
большим номером. Если граф представляет собой схему выполнения работ,
то можно сказать, что такая нумерация задает возможную линейную
последовательность работ, например, для последовательного выполнения всех
работ одним и тем же работником.
Несложно доказать, что для ориентированного графа без циклов задача
имеет, по крайней мере, одно решение. Одно из возможных решений можно
найти с помощью следующего алгоритма.
Будем нумеровать вершины, начиная с максимального номера. Дня этого
запустим алгоритм обхода в глубину и будем приписывать очередной в
порядке убывания номер вершине в тот момент, когда эта вершина окончательно
покидается алгоритмом обхода (vertexOut). В этот момент все вершины,
достижимые из покидаемой, уже будут пронумерованы и, таким образом, будут
иметь номер, заведомо больший номера, приписываемого текущей покидае-
344
Глава 6
мой вершине. Поскольку граф не имеет циклов, то эта вершина не достижима
из нее самой, поэтому все условия задачи оказываются выполненными.
Опишем класс TopSortActor, определяющий посетитель элементов графа,
который будет помечать вершины графа в соответствии с правилами
топологической сортировки вершин. Маркировка вершины будет осуществляться им
при выполнении метода vertexOut. Будем считать, что хранение номеров
вершин осуществляется объектом специально описанного класса Marker,
который содержит следующие методы:
□ setMark(int vertex, int number) — ДЛЯ приписывания номера number
вершине vertex;
□ getMark(int vertex) — выдающий приписанный вершине vertex номер
(-1 — если вершина еще не была пронумерована);
□ isMarked(int vertex) — проверяющий, приписан ли вершине некоторый
номер.
Разумеется, реализация этих методов не представляет никакого труда. Тогда
решение задачи топологической сортировки вершин графа может быть
записано так, как показано в листинге 6.4.
| Листинг 6.4. Топологическая сортировка вершин графа
// topsort.h
// Классы Marker и TopSortActor используются для топологической
// сортировки вершин графа, при этом класс Marker определяет
// объект для хранения пометок вершин графа, а класс TopSortActor
// определяет поведение посетителя вершин графа при выполнении
// топологической сортировки.
class Marker {
int nVertex; // Общее количество вершин графа
int * marks; // Массив меток вершин
class TopSortActor : public GraphActor {
Marker * marker; // объект для хранения меток вершин
int nextMark; // номер очередной метки при обходе
public :
// Конструктор получает в качестве параметра указатель на
// объект, в котором будут храниться метки вершин
TopSortActor(Marker * marker);
Алгоритмы обработки графов
345
// Пометка вершины происходит при окончательном
// уходе из вершины в процессе обхода;
void vertexOut(int vertex);
};
public :
// Конструктор получает ссылку на граф, помечает его вершины
// меткой -1, а затем обходит вершины графа с помощью
// специально спроектированного посетителя вершин
Marker(ListGraph & graph)
: nVertex(graph.vertexCount()), marks(new int[nVertex]), error(false)
{
// 1. Пометка вершин значением -1
for (int i = 0; i < nVertex; i++) marks[i] = -1;
// 2. Обход в глубину с топологической сортировкой вершин
TopSortActor actor(this);
graph.traverseDepth(actor);
}
// Деструктор уничтожает массив меток вершин
-Marker() { delete marks; }
// Число вершин в массиве
int vertexCount() const { return nVertex; }
H Функция для проверки, помечена ли вершина
bool isMarked(int vertex) const { return marks[vertex] != -1; }
// Функция выдачи метки вершины
int getMark(int vertex) const { return marks[vertex]; }
};
// topsort.cpp
// В конструкторе посетителя вершин первая метка
// получает значение, равное количеству вершин графа.
Marker::TopSortActor::TopSortActor(Marker * marker)
: marker(marker), nextMark(marker->vertexCount()) {}
// При пометке вершины текущее значение метки уменьшается на единицу
void Marker::TopSortActor::vertexOut(int vertex) {
marker->marks[vertex] = —nextMark;
}
346
Глава 6
Если применить эту процедуру к графу, изображенному на рис. 6.3, то
получится маркировка вершин, показанная ниже:
Vertex # 0 has a mark # 2
Vertex # 1 has a mark # 4
Vertex # 2 has a mark # 0
Vertex # 3 has a mark # 5
Vertex # 4 has a mark # 1
Vertex # 5 has a mark # 3
J 0 1 Ы 1 I
2 ) \ y( 3
Л 4 ] m 5 ]
Рис. 6.З. Пример ориентированного ациклического графа
С помощью того же самого алгоритма можно проверить, действительно ли
граф не имеет циклов. Для этого достаточно при прохождении дуги
(arcForward) проверять, не ведет ли эта дуга в уже пройденную, но еще не
пронумерованную вершину. Расширенный алгоритм топологической
сортировки будет выдавать ошибку, если исходный граф содержит цикл, а в
противном случае маркировка вершин будет проведена до конца без ошибок.
Такое расширение алгоритма реализовано в программе, содержащейся на
приложенном КОМПаКТ-ДИСКе В папке "Chapter6\6.1\GraphIterators".
Внутренний итератор для обхода вершин и дуг графа в глубину можно
реализовать даже проще, чем это представлено в листинге 6.3. Для этого
достаточно определить рекурсивную функцию, обходящую все вершины и дуги,
достижимые из заданной вершины. Эта рекурсивная функция и
соответствующий метод обхода в глубину (обе функции имеют идентификатор
traverseDepthRec) представлены в листинге 6.5.
Листинг 6.5. Рекурсивный алгоритм обхода графа в глубину
// listgraph.h
class ListGraph {
Алгоритмы обработки графов
347
/* показаны только новые функции traverseRec и traverseDepthRec */
public :
// Рекурсивная функция обхода графа с помощью внутреннего итератора
void traverseDepthRec(GraphActor & actor);
private :
void traverseDepthRec(int v, GraphActor & actor, Set & setNotPassed);
>;
// listgraph.cpp
void ListGraph::traverseDepthRec(GraphActor & actor) {
// множество непройденных вершин:
Set setNotPassed(0, vertexCount()-1);
setNotPassed.addScale(0, vertexCount()-1);
// Цикл по компонентам связности графа
while (!setNotPassed.empty()) {
// Выбираем произвольную вершину из еще не пройденных
Iterator<int> * setlterator = setNotPassed.iterator();
int first = **setIterator;
// Отмечаем заход в новую компоненту связности
actor.newSelection(first) ;
// Осуществляем обход компоненты связности
//с помощью вспомогательной рекурсивной функции
traverseDepthRec(first, actor, setNotPassed);
}
}
void ListGraph::traverseDepthRec(
int v, GraphActor & actor, Set & setNotPassed) {
// Отмечаем вход в вершину...
actor.vertexln(v);
// ...и удаляем ее из списка непройденных вершин
setNotPassed -= v;
// Организуем цикл по дугам, исходящим из этой вершины
for (Iterator<int> * arcList = graph[v].iterator();
arcList->hasMoreElements();
++*arcList) {
int e = **arcList; // номер вершины на конце очередной дуги
bool newVertex = setNotPassed.has(e); // вершина еще не пройдена?
348
Глава 6
// Проходим дугу по направлению ее следования
actor.arcForward(v, e, newVertex);
// Если попали в -еще не пройденную вершину,
//то производим рекурсивный вызов функции
if (newVertex) {
traverseDepthRec(e, actor, setNotPassed);
}
// Проходим дугу в обратном направлении
actor.arcBackward(v, e);
}
// Отмечаем выход из вершины
actor.vertexOut(v);
}
Эта реализация выглядит более естественной еще и потому, что вызовы
ОСНОВНЫХ Операций посетителя— vertexln, vertexOut, arcForward И
arcBackward— расположены внутри Определения функции traverseDepthRec
симметрично на своих естественных местах.
Теперь рассмотрим алгоритмы обхода графа в ширину. Для обходов в
ширину не существует такого простого и изящного рекурсивного алгоритма, как
для только что рассмотренного способа обхода в глубину. Поэтому мы
приведем два способа обхода графа в ширину — с помощью внешнего и
внутреннего итераторов, которые, по существу, представляют собой
модификации одного и того же алгоритма, схематично описанного ниже.
Выберем произвольную вершину графа и начнем обход с этой вершины.
В процессе работы алгоритма будем делить все вершины графа на три класса:
пройденные вершины ("черные"); вершины, которые будут пройдены на
следующем шаге алгоритма ("серые"); и все остальные непройденные вершины
("белые"). В начальный момент работы выбранная исходная вершина
помещается в множество "серых" вершин, а все остальные вершины будут пока
"белыми". Один шаг работы алгоритма состоит в том, что берутся все "серые"
вершины, проходятся (и помещаются й множество "черных" вершин), затем
находятся все "белые" вершины, в которые ведут дуги из только что
пройденных вершин, и все найденные верщины перекрашиваются в серый цвет,
т. е. помещаются в множество "серых" вершин.
Если "серых" вершин больше нет, то это означает, что пройдены уже все
вершины, достижимые из исходной. Если при этом в графе еще остались
непройденные "белые" вершины, можно снова выбрать одну из них в качестве
исходной и повторить работу алгоритма.
Алгоритмы обработки графов
349
На практике множество "серых" вершин можно организовать в виде очереди,
тогда вершины из очереди можно брать по одной из головы очереди, а новые
вершины, в которые ведут из нее дуги, сразу же помещать в конец очереди.
В листинге 6.6 представлен внешний итератор, реализующий описанный
алгоритм. В нем множество "черных" вершин физически никак не
представлено, поскольку пройденные вершины нигде в алгоритме не используются.
Множество "серых" вершин представлено очередью qNext и, наконец,
множество "белых" непройденных вершин представлено переменной setNotPassed
класса Set.
| Листинг 6.6. Внешний итератор для обхода графа в ширину
// Определение класса ListGraph
class ListGraph {
// Массив списков дуг
List<int> *graph;
// Количество вершин графа
int vertexNumber;
public:
class BreadthGraphlterator : public Iterator<int> {
ListGraph *graph; // граф для обхода
Queue<int> *qNext; // очередь вершин
Set setNotPassed; // множество непройденных вершин
public :
// Конструктор
// graph - граф для обхода
BreadthGraphlterator(ListGraph *graph);
// Деструктор
//======^======================================================:
-BreadthGraphlterator() { delete qNext; }
// Функция проверяет, остались ли еще не пройденные вершины
bool hasMoreElements() const {
return !qNext->empty() || !setNotPassed.empty();
}
350
Глава 6
// Функция сдвига итератора на следующую вершину
//в порядке обхода вершин графа в глубину
Iterator<int> & operator ++();
// Функция, выдающая номер очередной вершины на пути обхода
const int & operator *() const { return qNext->head(); }
};
// Создание внешнего итератора вершин графа (обход в ширину)
Iterator<int> * breadthlterator() {
return new BreadthGraphlterator(this) ;
}
};
// Методы внешнего итератора вершин графа (обход в ширину)
// Конструктор внешнего итератора вершин графа
ListGraph::BreadthGraphlterator::BreadthGraphlterator(ListGraph *graph)
// множество непройденных вершин:
setNotPassed(0, graph->vertexCount()-1) ,
// стек непросмотренных дуг:
qNext(new ListQueue<int>),
// граф, подлежащий обходу:
graph(graph)
{
//В множество непройденных вершин помещаются все вершины графа
setNotPassed.addScale(0, graph->vertexCount()-1);
// Выбираем очередную вершину
operator ++();
}
// Функция сдвига итератора на следующую вершину
//в порядке обхода вершин графа в глубину
Iterator<int> & ListGraph::BreadthGraphlterator::operator ++() {
// Сначала проверяем очередь; если она не пуста,
//то надо удалить изчнее очередную вершину и
// поставить в очередь ее непройденных потомков
if (!qNext->empty()) {
// Выбираем вершину из очереди
int vertex = qNext->head();
qNext->dequeue();
// Просматриваем дуги, ведущие из этой вершины
Iterator<int> *arcs = (graph->graph)[vertex].iterator();
Алгоритмы обработки графов
351
while (arcs->hasMoreElements ()) {
int next = **arcs;
if (setNotPassed.has(next)) {
qNext->enqueue(next);
setNotPassed -= next;
}
++*arcs;
}
}
// Теперь проверяем, есть ли еще непройденные вершины
if (IhasMoreElements()) {
return *this;
}
// Наконец, если очередь не содержит больше вершин,
//то очередная вершина берется из множества непройденных.
if (qNext->empty()) {
// Выбираем произвольную вершину из еще не пройденных
Iterator<int> * setlterator = setNotPassed.iterator();
int selected = **setIterator;
delete setlterator;
qNext->enqueue(selected);
setNotPassed -= selected;
}
return *this;
}
В листинге 6.7 тот же алгоритм приведен в виде внутреннего итератора,
представленного методом traverseBreadth С аргументом класса GraphActor.
Поскольку при обходах в ширину бессмысленно говорить о возвратах по
дуге, то методы arcBackward и vertexOut нигде в этой функции не вызываются.
Соответственно, нет смысла переопределять эти методы в наследниках этого
класса при использовании обхода в ширину.
| Листинг 6.7. Внутренний итератор для обхода графа в ширину -;;' \'г;-:, '**&/-$
class ListGraph {
// Массив списков дуг
List<int> *graph;
// Количество вершин графа
int vertexNuraber;
352
Глава 6
public:
// Функция обхода графа в ширину с помощью внутреннего итератора
void traverseBreadth(GraphActor & actor);
};
// Метод для внутренней итерации вершин графа (обход в ширину)
//-кк-к-к-к-к-к-к-к-к-к-кк-к-к-к-к-к-к-к-кк-к-к-к-к-к-к-к-к-кк-к-к-к-к-к-к-к-к -к-к-к-к-к-к-к-к-к-к-к-к-к-к-кк-к-к-к-кк-к
void ListGraph::traverseBreadth(GraphActor & actor) {
// Очередь вершин
Queue<int> *qNext = new ListQueue<int>;
// Множество еще не пройденных вершин
Set setNotPassed;
setNotPassed.addScale(0, vertexCount()-1);
while (!(qNext->empty() && setNotPassed.empty())) {
if (qNext->empty()) {
// Выбор очередной исходной вершины
Iterator<int> * setlterator = setNotPassed.iterator() ;
int selected = **setIterator;
delete setlterator;
// Вершина ставится в очередь qNext
actor.newSelection(selected);
qNext->enqueue(selected);
setNotPassed -= selected;
}
// Выбор вершины из очереди
int vertex = qNext->head();
qNext->dequeue();
actor.vertexln(vertex);
// Просмотр всех дуг, ведущих из этой вершины
Iterator<int> *iArcs = graph[vertex].iterator();
while (iArcs->hasMoreElements() ) {
int next = **iArcs;
bool newVertex = setNotPassed.has(next);
actor.arcForward(vertex, next, newVertex);
if (newVertex) {
qNext->enqueue(next);
setNotPassed -= next;
}
Алгоритмы обработки графов
353
++*iArcs;
}
delete iArcs;
}
}
Обходы графа в ширину применяются в следующем разделе для поиска
кратчайших путей в графе.
6.2. Поиск кратчайших путей
Путем в графе называют чередующуюся последовательность вершин и дуг
vi, eu v2, ^2v Vn i, en \, v„, в которой каждый элемент v,-— вершина графа, а
каждый элемент е, — дуга графа, ведущая из предыдущей вершины v, в
следующую вершину V/ f 1. Говорят, что такой путь соединяет вершину v\ с
вершиной v„. Чаще всего рассматриваются простые пути, т. е. такие, в которых
нет повторяющихся вершин, кроме, быть может, совпадения первой и
последней вершины. В случае такого совпадения путь называется замкнутым
или циклом.
Среди характеристик пути чаще всего рассматривается его длина —
количество дуг в этом пути. Иногда с каждой дугой связывается некоторое число,
которое можно интерпретировать как длину этой дуги. Граф в этом случае
называется нагруженным, а длиной пути в нагруженном графе будет
называться сумма длин входящих в этот путь дуг.
Вообще говоря, на длины дуг не накладывается никаких ограничений, так что
они могут быть даже отрицательными, однако более частым и интересным
является случай неотрицательных длин, при котором добавление новых
вершин и дуг в любой путь заведомо не укорачивает его.
Самая простая задача поиска кратчайшего пути в графе состоит в
следующем. Пусть заданы две вершины — начальная и конечная. Известно, что
имеется по крайней мере один путь, по которому можно пройти, чтобы попасть
из начальной вершины в конечную. Требуется указать один из таких путей,
содержащий наименьшее количество промежуточных вершин или имеющий
наименьшую длину. В случае с неотрицательными длинами дуг можно
утверждать, что найденный путь обязательно будет простым. Действительно,
если предположить, что кратчайший путь между двумя вершинами содержит
одну и ту же вершину дважды, то весь участок пути от первого вхождения
этой вершины в путь до второго ее вхождения можно выкинуть, причем
длина пути заведомо не увеличится.
354
Глава 6
( Примечание )
Для того чтобы кратчайшие пути в графе всегда были бы простыми, не
обязательно, чтобы длины всех его дуг были неотрицательными. Достаточно, чтобы
в графе не было циклов с отрицательной длиной.
В качестве первой задачи возьмем случай ненагруженного графа (длина
каждой дуги полагается равной единице), и будем искать кратчайший путь
между двумя заданными вершинами. Для решения этой задачи можно
использовать несколько модифицированный алгоритм обхода графа в ширину. Для
этого выберем в качестве исходной вершины для обхода начальную вершину.
Далее будем обходить вершины и дуги графа так, как это делалось в обычном
алгоритме обхода, только для каждой проходимой вершины будем отмечать
ту вершину, из которой она была достигнута по дуге (сохраняя тем самым
пути, ведущие из начальной вершины во все остальные, достижимые из нее).
В тот момент, когда будет достигнута конечная вершина, работу алгоритма
можно заканчивать. Теперь кратчайший путь можно получить, если
проследить все вершины, от конечной и до начальной по оставленным отметкам.
Приведенная в листинге 6.8 функция решает поставленную задачу, используя
основу алгоритма обхода в ширину. В качестве результата функция
возвращает вектор, содержащий номера всех вершин на пути из начальной
вершины в конечную (vector<int> библиотеки STL). Если такого пути не
существует, функция вернет в качестве результата пустой вектор. В реализации
листинга 6.8 используется метод класса vector<int> pushback, добавляющий
один элемент в конец вектора, а также работа с итератором этого вектора.
г.;»...,..^;..;....;........»....*.*....,.,...,.....». ...л;....;.../.;;.. „.«;;..:;.,'.£;.*х....;..7.;,^г^ « а
vector<int> ListGraph::getShortestPath(int beg, int end) {
vector<int> shortestPath;
// Тривиальный случай совпадения начальной и конечной вершин
// рассматривается отдельно
if (beg = end) {
shortestPath.push_back(end);
return shortestPath;
}
// Выбираем начальную вершину в качестве исходной
int selected = beg;
// Очередь qNext содержит вершины из второго
// множества вершин при обходе графа в ширину
Queue<int> *qNext = new ListQueue<int>;
Алгоритмы обработки графов
355
// Множество setNotPassed - это третье
// множество еще не просмотренных вершин графа
Set setNotPassed(0, vertexCount()-1);
setNotPassed.addScale(0, vertexCount()-1);
// backs - массив, содержащий метки
int *backs = new int[vertexCount()];
// Инициализация массива обратных меток
for (int i=0; i < vertexCount(); i++) {
backs[i] = -1;
}
// Помещаем первую вершину в очередь
setMotPassed -= selected;
qNext->enqueue(selected);
// Основной цикл обхода в ширину
bool finish = false;
while (!finish && !qNext->empty()) {
int vertex = qNext->head();
qNext->dequeue();
Iterator<int> *iNext = graph[vertex].iterator();
while (ifinish && iNext->hasMoreElements()) {
int next = **iNext;
bool newVertex = setNotPassed.has(next);
if (newVertex) {
backs[next] = vertex;
setNotPassed -= next;
qNext->enqueue(next);
}
// Здесь происходит проверка на достижение конечной вершины
if (next == end) finish = true;
++*iNext;
}
}
if (finish) {
// Найден путь из начальной в конечную вершину
// Формирование результирующего вектора
vector<int>::iterator it = shortestPath.begin();
356
Глава 6
for (int i = end; backs [i] !== -1; i = backs [i]) {
it = shortestPath.insert(it, i);
}
shortestPath.insert(it, beg);
}
return shortestPath;
}
Если запустить функцию нахождения кратчайшего пути на графе,
изображенном на рис. 6.1, с аргументами 0 и 3, то в результате работы функции
получится вектор с тремя компонентами: 0, 6, 3, а если в качестве аргументов
задать 0 и 8, то в результате работы будет получен пустой вектор.
Более интересная задача состоит в том, чтобы найти кратчайший путь между
двумя вершинами при условии, что каждая дуга имеет определенную длину,
так что мерой такого расстояния будет не количество дуг на кратчайшем
пути, а суммарная длина пути.
Опишем алгоритм нахождения такого пути при условии, что длины всех дуг
неотрицательны. Этот алгоритм был предложен и опубликован Э. Дейкстрой
(Е. W. Dijkstra), поэтому и носит его имя.
Алгоритм поиска кратчайшего пути в этом случае также несколько
напоминает алгоритм обхода графа в ширину и состоит в следующем. Будем
рассматривать два множества: множество вершин, для которых уже известен
кратчайший путь до них из начальной вершины (passed), и множество еще не
исследованных вершин (notPassed).
В начале работы первое множество будет пустым, а второе будет содержать
все вершины графа. На каждом шаге работы алгоритма из второго множества
в первое переводится одна вершина — та, расстояние до которой от исходной
вершины минимально. Эту вершину будем называть пограничной (border).
Для определения пограничной вершины во время работы алгоритма
постоянно корректируется массив расстояний dist, в котором для каждой вершины
хранится расстояние до нее от исходной вершины, если оно известно. В
начале работы алгоритма расстояние известно только для одной вершины —
начальной. Конечно, это расстояние равно нулю, так что эта вершина и будет
выбрана первой в качестве пограничной. В дальнейшем массив будет
корректироваться за счет просмотра вершин-кандидатов, в которые ведут дуги из
очередной пограничной вершины в еще не просмотренные вершины.
Как и в предыдущем алгоритме, сам путь из начальной вершины в конечную
будет сохраняться с помощью массива меток backs. Алгоритм заканчивает
работу, как только в качестве пограничной будет выбрана конечная вершина
Алгоритмы обработки графов
357
или, если окажется, что очередная пограничная вершина вообще не связана
с начальной (находится на бесконечно большом расстоянии от нее). Это
расстояние задается в программе константой infinity.
Реализация этого алгоритма приведена в листинге 6.9 в виде метода
getDijkstraPath класса ListGraph. В качестве аргументов методу передаются
номера начальной и конечной вершин на кратчайшем пути, а также
переменная, которая будет содержать вектор номеров промежуточных вершин на
пути из начальной вершины в конечную. Результатом работы метода будет
суммарная длина кратчайшего пути.
Чтобы задать нагрузку на дуги, не изменяя представления графа, введем
специальный абстрактный тип данных Graphweight, представляющий объекты,
задающие нагрузку на дуги графа. Главным методом соответствующего
класса будет функция arcLength, которая по заданным номерам вершин
определяет длину дуги, соединяющей эти вершины. Значение функции будем считать
неопределенным, если заданные вершины не соединены никакой дугой или
вообще не задают номеров вершин графа. Чтобы задать конкретную нагрузку
на граф, необходимо определить реализацию абстрактного типа данных
GraphWeight.
I Листинг 6.9. Алгоритм определения кратчайшего пути с учетом длин дуг
double ListGraph:rgetDijkstraPath(int beg, int end,
const GraphWeight & weights, vector<int> & path) const {
path.clear();
// Множество непросмотренных вершин:
Set notPassed(0, vertexCount()-l);
notPassed.addScale(0, vertexCount()-1);
// Массив обратных меток
int *backs = new int[vertexCount()];
// Массив минимальных расстояний. В начале работы все расстояния
// бесконечно большие, кроме расстояния до исходной вершины
double *dist = new double[vertexCount()];
for (int i = 0; i < vertexCount(); i++) dist[i] = INFINITY;
dist[beg] = 0;
// Цикл поиска и обработки пограничной вершины
while(!notPassed, empty()) {
// Поиск пограничной вершины: просматриваем массив dist
//в поисках вершины с минимальным расстоянием от beg
int border = -1;
358
Глава 6
double minDist = INFINITY;
Iterator<int> *check = notPassed.iterator();
while(check->hasMoreElements()) {
int next = **check;
if (dist[next] < minDist) {
minDist = dist[border = next];
}
++*check;
}
delete check;
// Если пограничная вершина не найдена, то пути нет
if (border == -1) return INFINITY;
// Если пограничная вершина совпала с конечной,
//то переходим к формированию результата
if (border == end) break;
// Обработка вершин, смежных с пограничной
notPassed -= border;
Iterator<int> *nextToBorder = graph[border].iterator();
while (nextToBorder->hasMoreElements()) {
int v = **nextToBorder;
if (notPassed.has(v) && dist[v] >
dist[border] + weights.arcLength(border, v)) {
backs[v] = border;
dist[v] = dist[border] + weights.arcLength(border, v);
}
++*nextToBorder;
}
delete nextToBorder;
}
// Формирование результирующего вектора
int current = end;
while(current != beg) {
path.insert(path.begin(), current);
current = backs[current];
}
path.insert(path.begin(), beg);
return dist[end];
}
Алгоритмы обработки графов 359
Для примера нагрузим дуги графа рис. 6.1 некоторыми целыми числами,
обозначающими длину дуги. В результате получится граф, изображенный на
рис. 6.4.
Рис. 6.4. Нагруженный граф
Если теперь попытаться найти кратчайший путь между вершинами 3 и 4, то
будет найден путь, проходящий через вершины 3, 0, 1, 4. Суммарная длина
этого пути составляет 6 единиц и, хотя и существует путь, проходящий через
меньшее число вершин (3, 0, 4), но его суммарная длина оказывается больше
(8 единиц), так что в качестве кратчайшего будет выбран именно первый
путь.
Вызовем для этого графа метод getDijkstraPath с параметрами для
нахождения кратчайшего пути из вершины 3 в вершину 4 (параметр arcweight задает
нагрузку на граф) и напечатаем получившийся путь:
vector<int> path;
cout « "The shortest path from 3 to 4 is "
« graph.getDijkstraPath(3, 4, arcsWeight, path)
« " and traverses the following vertices:" « endl;
for (vector<int>::iterator it = path.begin(); it != path.end(); ++it) {
cout « *it « "; ";
}
cout « endl;
Тогда в результате получится следующий текст:
The shortest path from 3 to 4 is 6 and traverses the following vertices:
3; 0; 1; 4;
На каждом шаге алгоритм Дейкстры выбирает пограничную вершину,
просматривая множество, состоящее из последовательно уменьшающегося
множества вершин, а после выбора пограничной вершины просматривает все
исходящие из нее дуги. Если граф имеет п вершин и m дуг, то на каждом из п
360
Глава 6
шагов алгоритма (в худшем случае) рассматривается вплоть до п вершин, а
поскольку каждая дуга, в конце концов, будет просмотрена по одному разу,
то всего будут просмотрены все т дуг. Это означает, что в худшем случае
скорость работы алгоритма приближается к п х п + т. Для больших графов
это оказывается довольно существенной величиной.
Некоторого ускорения можно добиться, если организовать поиск
пограничной вершины более эффективно. Например, массив минимальных расстояний
dist можно упорядочить по возрастанию (правда, тогда в массиве придется
хранить не только сами расстояния, но и номера соответствующих вершин).
В этом случае поиск пограничной вершины вообще будет производиться за
постоянное время, не зависящее от числа вершин и структуры графа, однако
при изменении содержимого массива потребуется перемещать в нем
измененный элемент, чтобы не нарушить упорядоченности элементов. Это может
потребовать времени, пропорционального log2«. Еще удобнее будет
организация массива расстояний в виде пирамиды так же, как мы делали это в
реализации алгоритма пирамидальной сортировки в разд. 2.2. В обоих случаях
наихудшее время работы алгоритма сокращается до п х log2« + m
элементарных шагов. В следующем разделе организация массива расстояний в виде
пирамиды используется в алгоритме определения минимального остовного
дерева графа.
Рассмотрим теперь задачу, в которой требуется найти всевозможные
кратчайшие пути из всех вершин графа во все другие вершины. Результатом
работы такого алгоритма должна быть матрица, в которой для каждой пары
вершин (/',/) содержится длина минимального пути из вершины / в вершину у.
Для нахождения самих путей можно также построить матрицу, в которой
в клетке (/',/) содержится номер первой вершины на кратчайшем пути из
вершины / в вершину j. Разумеется, можно воспользоваться алгоритмом
Дейкстры, применив его последовательно для всех пар вершин, однако
эффективность работы подобного способа будет невелика.
Прежде чем перейти к рассмотрению алгоритма для решения приведенной
задачи, рассмотрим еще одну задачу, вообще говоря, более простую. Ее
обобщение даст нам искомый алгоритм. Задача состоит в том, чтобы для
заданного графа G найти его транзитивное замыкание, т. е. граф G1 такой, что
в нем дуга ведет из вершины / в вершину у только в том случае, если в
исходном графе существовал путь из вершины / в вершину j. Разумеется, исходный
граф G будет содержаться в результирующем графе G1 в качестве подграфа.
Для решения этой задачи удобно воспользоваться представлением графа в
виде матрицы смежности (М-граф), т. к. операции над матрицами удачно
представляют собой отдельные шаги алгоритма.
Напомним, что матрица смежности — это матрица размером п х п
элементов (где п — число вершин графа), каждый элемент которой с индексами (/,/)
Алгоритмы обработки графов
361
содержит логическое значение true, если в графе имеется дуга, ведущая из
вершины i в вершину у, и значение false — в противном случае. Если ввести
операции сложения и умножения для логических значений, взяв в качестве
операции сложения операцию ИЛИ над логическими значениями, а в
качестве операции умножения — операцию И, то можно, используя обычные
правила для умножения матриц, определить и операцию умножения матриц
смежности друг на друга, в результате которой получается новая матрица
смежности.
Если матрица А представляет собой матрицу смежности графа GA, а
матрица В — матрицу смежности графа GB с теми же вершинами, то матрица А х В
будет представлять собой матрицу графа, в котором дуга ведет из вершины /
в вершину у в том и только в том случае, когда существует вершина к, такая,
что в графе А имеется дуга, ведущая из вершины / в вершину к, а в графе В —
дуга, ведущая из вершины к в вершину у. Это позволяет построить алгоритм
нахождения транзитивного замыкания графа, используя только операции
умножения над матрицами смежности различных промежуточных графов.
Для исходного графа G обозначим через Gk матрицу, представляющую собой
матрицу путей длины не большей к, т. е. в этой матрице элемент с индексами
(i,j) будет содержать значение true в том и только в том случае, если в
исходном графе существует путь длины не больше к, ведущий из вершины / в
вершину j. Заметим, что требуемый результат — матрица Gr есть не что иное,
как матрица Gn в наших обозначениях, где п — число вершин графа
(напомним, что мы рассматриваем только простые пути, ведущие из одной вершины
графа в другую, следовательно, никакой путь не может содержать больше,
чем п вершин).
Теперь заметим, что матрицу Gk нетрудно получить, имея исходную матрицу
G и предыдущую матрицу Gk . Действительно, можно убедиться, что
справедливо равенство:
Gk = Gk] *G + G.
То есть, имея предыдущую матрицу Gk x и исходную матрицу G, матрицу Gk
можно получить за одно матричное умножение и одно матричное сложение.
Если к тому же заметить, что исходная матрица G совпадает с матрицей G1,
то алгоритм построения транзитивного замыкания G1 можно построить
следующим образом. Построим последовательность матриц, начиная с матрицы
G = G1 и далее G2, G3, ..., G'7 = G7. Поскольку каждое умножение матриц
требует порядка п3 логических операций, а логическое сложение — порядка п
логических операций, то весь алгоритм займет приблизительно пА + пъ
элементарных логических операций, что при больших п примерно эквивалентно
г? операций.
362
Глава 6
Если определить операции умножения и сложения непосредственно над
графами (на самом деле операции будут производиться над их матрицами
смежности), то алгоритм получения транзитивного замыкания графа может
выглядеть, как представлено в листинге 6.10. В этом листинге мы напоминаем
определение М-графа, а также вводим операции для копирования графа,
сложения и умножения их матриц смежности и, наконец, операцию
нахождения транзитивного замыкания графа.
11 **************************************************************
// Определение класса MatrixGraph графа,
// представленного матрицей смежности
//**************************************************************
class MatrixGraph {
// двумерный массив логических значений
bool **graph;
// Число вершин графа
int vertexNumber;
public:
// Конструкторы
MatrixGraph(int n);
MatrixGraph(const MatrixGraph & src);
// Деструктор
-MatrixGraph();
// Операция присваивания графов
MatrixGraph & operator = (const MatrixGraph & src);
// Функция подсчета числа вершин
int vertexCount() const { return vertexNumber; }
// Основные методы работы с графом
void addArc(int from, int to);
void removeArc (int from, int to) ;
bool hasArc(int from., int to) const;
// Умножение матриц смежности графов
friend MatrixGraph mult (const MatrixGraph & Gl, const MatrixGraph & G2)
throw(IncompatibleSizes);
// Сложение матриц смежности графов
friend MatrixGraph add(const MatrixGraph & Gl, const MatrixGraph & G2)
throw(IncompatibleSizes);
Алгоритмы обработки графов
363
// Вычисление транзитивного замыкания перемножением матриц
friend MatrixGraph closure(const MatrixGraph & G);
};
// Конструктор
MatrixGraph::MatrixGraph(int n) {
graph = new bool*[vertexNumber = n];
for (int i=0; i<n; i++) {
// создание строки
bool *row = graph[i] = new bool[n];
// инициализация элементов строки
for (int j = 0; j < n; j++) {
row[j] = false;
}
}
}
// Конструктор копирования
MatrixGraph::MatrixGraph(const MatrixGraph & src) {
int n = src.vertexCount();
graph = new bool*[vertexNumber = n];
for (int i=0; i<n; i++) {
// создание строки
bool *row = graph[i] = new bool[nj;
bool *srcRow = src.graph[i];
// инициализация элементов строки
for (int j = 0; j < n; j++) {
row[j] = srcRow[j];
}
}
// Деструктор
MatrixGraph::-MatrixGraph() {
for (int i = 0; i < vertexNumber; i++) {
delete graph[i];
}
delete graph;
}
// Оператор копирования
MatrixGraph & MatrixGraph::operator = (const MatrixGraph & src) {
// Копирование структуры:
int n;
364
Глава 6
if (vertexNumber != (n = src.vertexCount())) {
this->~MatrixGraph();
graph = new bool*[vertexNumber = n];
for (int i = 0; i < n; i++) {
// создание строки
bool *row = graph[i] = new bool[n];
}
}
// Копирование элементов
for (int i = 0; i < n; i++) {
bool * row = graph[i];
bool * srcRow = src.graph[i];
for (int j = 0; j < n; j++) {
row[j] = srcRow[j];
}
}
}
// Добавление дуги - элемент массива устанавливается в true
void MatrixGraph::addArc(int from, int to) {
if (from < 0 I I from >= vertexNumber | j to < 0 | | to >= vertexNumber)
return; // Неправильно задана дуга
graph[from][to] = true;
}
// Удаление дуги - элемент массива устанавливается в false
void MatrixGraph: :removeArс (int from, int to) {
if (from < 0 || from >= vertexNumber || to < 0 |I to >= vertexNumber)
return; // Неправильно задана дуга
graph[from][to] = false;
}
// Проверка существования дуги
bool MatrixGraph::hasArc(int from, int to) const {
if (from < 0 || from >== vertexNumber || to < 0 I| to >= vertexNumber)
return false; // Неправильно задана дуга
return graph[from][to];
}
// Перемножение матриц смежности с использованием логических операций
MatrixGraph mult(const MatrixGraph & Gl, const MatrixGraph & G2)
throw(IncompatibleSizes) {
Алгоритмы обработки графов
365
int n; // Размерность матриц - число вершин в графе
MatrixGraph result(n = Gl.vertexCount());
if (n != G2.vertexCount()) {
throw IncompatibleSizes () ; // Перемножаются матрицы разных размеров!
}
// Далее следует обычное перемножение матриц
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
for (int к = 0; к < n; k++) {
if (Gl.graph[i][k] && G2.graph[k][j]) {
result.addArc(i, j);
break;
}
}
}
}
return result;
}
// Сложение матриц смежности с использованием логических операций
MatrixGraph add(const MatrixGraph & Gl, const MatrixGraph & G2)
throw(IncompatibleSizes) {
int n; // Размерность матриц - число вершин в графе
MatrixGraph result(n = Gl.vertexCount());
if (n != G2.vertexCount()) {
throw IncompatibleSizes(); // Складываются матрицы разных размеров!
}
// Логическое сложение матриц
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
result.graph[i][j] = Gl.graph[i][j] || G2.graph[i][j];
}
}
return result;
}
// Нахождение транзитивного замыкания графа с помощью перемножения матриц
MatrixGraph closure(const MatrixGraph & G) {
MatrixGraph Gi = G;
for (int i = 0; i < G.vertexCount(); i++) {
Gi = add(mult(Gi, G), G);
}
return Gi;
}
366
Глава 6
Если теперь взять в качестве исходного граф, изображенный на рис. 6.1, и
вывести как исходный граф, так и его транзитивное замыкание в стандартный
выходной поток (считаем определенными соответствующие операции
вывода), то в выходном потоке получится следующий текст:
Source
0 10
0 0 0
0 0 0
10 0
0 0 0
0 0 1
0 0 0
0 0 0
0 0 0
graph
0
0
0
0
0
0
1
0
0
1
1
0
0
0
0
0
0
0
Transitive
110
110
0 0 1
110
110
0 0 1
110
0 0 0
0 0 1
1
1
0
1
1
0
1
0
0
1
1
0
1
1
0
1
0
0
0
0
0
0
0
0
0
0
1
10 0
0 10
0 0 1
0 0 0
110
0 0 0
0 0 0
0 0 0
0 0 0
closure:
0
0
1
0
0
1
0
0
1
110
110
0 0 1
110
110
0 0 1
110
0 0 0
0 0 1
Описанный алгоритм нахождения транзитивного замыкания кажется очень
естественным, однако его эффективность на самом деле очень невысока.
Существует алгоритм, который выполняет ту же работу за гораздо меньшее
число операций, к тому же не расходуя столько памяти под большое
количество промежуточных матриц.
Идея этого алгоритма, называемого алгоритмом Флойда — Уоршалла,
состоит в следующем. Будем также рассматривать последовательность матриц
смежности, только теперь матрица Gk будет обозначать матрицу, в которой
элемент с индексами (/,у) будет содержать значение true в том и только в том
случае, когда в исходном графе существует путь, из вершины / в вершину у,
проходящий через вершины с номерами из множества {0, 1, ... к-\). Таким
образом, индекс к в обозначении Gk теперь ограничивает не длину пути, а
множество промежуточных вершин на таком пути. Очевидно, что сам
исходный граф G в этом случае совпадает с матрицей G0 (промежуточных вершин
нет), а искомая матрица Gr совпадает с матрицей G" (в качестве
промежуточных вершин могут использоваться любые вершины графа).
Алгоритмы обработки графов
367
Рассмотрим теперь, каким образом можно вычислить значение элементов
матрицы Gk с индексами (i,j), если известно содержимое матрицы Gk'\ Ясно,
что если в предыдущей матрице Gk Л значение этого элемента было true, то в
матрице Gk значение элемента также будет true. Если же значение этого
элемента было равно false, то это означает, что в исходном графе не было пути
из вершины i в вершину у, проходящего через промежуточные вершины из
множества {0, 1,... к-2}. В этом случае все же может существовать путь,
проходящий через промежуточные вершины из множества {0, 1,... к-\} при
условии, что существовал путь из вершины / в вершину к-\, проходящий через
промежуточные вершины из множества {0, 1,... к-2}, и путь из вершины к-\
в вершину j, проходящий через промежуточные вершины из множества
{О, 1,... к-2). Отсюда сразу же получается следующая формула:
к . . \true, если Gk~\i,j) = true
hJ ~{Gk-\i,k-l)AGk-](k-Uj), еслиGk'\iJ) = false
Из»этой формулы видно, что, во-первых, значения элементов /-го ряда зависят
только от элементов этого же и к- 1-го рядов, а во-вторых, что значения
в k-l-м ряду остаются неизменными. Также заведомо не меняется и значение
элемента Gkx{i, k-\) (проверьте эти утверждения!). Таким образом, новые
значения элементов матрицы зависят только от тех элементов, которые на
каждом шаге сами остаются неизменными. Это означает, что элементы новой
матрицы можно вычислять прямо на месте, не отводя дополнительного места
под новую матрицу. Отсюда сразу же следует алгоритм для вычисления
транзитивного замыкания графа, показанный в листинге 6.11 в виде функции
FloydWarshall.
void FloydWarshall(MatrixGraph & G) {
int n = G.vertexCount();
for (int k = 1; k < n; k++) {
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
if (i != k-1 && !G.graph[i][j]) {
G.graph[i][j] = G.graph[i][k-1] && G.graph[k-1][j3;
}
}
}
}
}
368
Глава 6
Алгоритм Флойда — Уоршалла, разумеется, выдает тот же самый результат,
что и предложенный ранее алгоритм, реализованный с помощью логического
умножения матриц смежности, однако работает он при этом на порядок
эффективнее, причем (редкий случай!) и по ресурсам памяти, и по затраченному
времени.
Этот же алгоритм можно положить и в основу алгоритма вычисления всех
кратчайших путей в графе между всеми парами вершин. Действительно,
кратчайший путь между вершинами / и у может либо проходить через
вершину с номером к-\9 либо нет. В первом случае длина кратчайшего пути равна
сумме длин кратчайших путей от вершины / до вершины к-\ и от вершины
к-\ до вершины у. Во втором случае кратчайший путь совпадает с длиной
кратчайшего пути, не содержащего вершину к-\ в качестве промежуточной.
Таким образом, мы можем получать матрицу длин кратчайших путей по тому
же самому алгоритму, только в качестве начальной матрицы берется
модифицированная матрица расстояний между вершинами в графе. В этой
модифицированной матрице элемент с индексами (/,/) равен +оо, если в графе нет
ребра, ведущего из вершины / в вершину у. В противном случае элемент
содержит длину ребра (расстояние) от вершины / к вершине/ Имея эту
начальную матрицу в качестве матрицы G0, будем получать далее
последовательность матриц G\ G2,..., G\ используя формулу
Gk(i,j) = mm(Gk-\iJ),Gk-\i,k-l) + Gk-l(k-l,j)).
В этой формуле считается, что если вершины / и у не соединены дугой, то
соответствующий элемент матрицы Gk(iJ) содержит бесконечно большое
значение +оо, операции с которым подчиняются естественным правилам: для
любого значения а (конечного или бесконечного) a + сю = со, min(a, со) = а.
Кроме матрицы длин кратчайших путей G надо получить еще и матрицу
направлений D для того, чтобы можно было определить сами найденные
кратчайшие пути. Ее можно получать сходным способом. В этой матрице элемент
Z/(/,y) равен номеру начальной промежуточной вершины на кратчайшем
пути из вершины i в вершину у, проходящему через промежуточные вершины из
множества {О, 1,... к-\). Ясно, что начальная матрица направлений D
совпадает с исходнЬй матрицей смежности графа, в которой элемент с индексами
(/,у) содержит значение у, если имеется ребро, ведущее из вершины / в
вершину у, и содержит -1, если такого ребра нет. В дальнейшем, при вычислении
матрицы кратчайших путей, как только обнаруживается, что элемент G (/,у)
следует заменить суммой Gk~\i,k-l) + Gk \k-\,j\ элемент £>*(/,/) меняется
на /)*~1(/, к-\). Таким образом, в конце концов в матрице If будут
содержаться направления по всем кратчайшим путям между всеми парами вершин
в исходном графе.
Алгоритмы обработки графов
369
Итак, имеем алгоритм, реализованный в виде функции getMinPaths в
листинге 6.12. Этот метод получает в качестве аргументов исходный граф G,
нагрузку на его дуги, представленную объектом класса Graphweight, и две
переменные, в которые в процессе работы алгоритма и будут записаны значения
элементов матрицы кратчайших путей и матрицы направлений.
j Листинг 6.12. Алгоритм нахождения матрицы кратчайших путей
| и матрицы направлений
void getMinPaths(const MatrixGraph & G, // Исходный граф
const Graphweight & w, // Нагрузка на дуги
double ** & P, // Переменные для матрицы путей
int ** & D // ...и для матрицы направлений
) {
int n = G.vertexCount(); // Число вершин графа
// Инициализация матриц.
//В матрицу длин путей записываются длины дуг исходного графа:
Р = new double*[n] ;
for (int i = 0; i < n; i++) {
double * row = P[i] = new double[n] ;
for (int j = 0; j < n; j++) {
row[j] = w.arcLength(i, j);
}
}
// В матрицу направлений записывается информация о направлениях
// дуг исходного графа (-1, если направление не определено):
D = new int*[n];
for (int i = 0; i < n; i++) {
int * row = D[i] = new int[n];
for (int j = 0; j < n; j++) {
row[j] = (G.hasArc(i, j) ? j : -1);
}
}
// Вычисление кратчайших путей и направлений
//по алгоритму Флойда — Уоршалла
for (int k = 1; k < n; k++) {
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
if (i != k-1 && P[i][k-1] < INFINITY && P[k-l][j] < INFINITY &&
P[i][j] > P[i][k-1] + P[k-1][j]) {
P[i][j] = P[i][k-1] + P[k-1][j];
370
Глава 6
D[i] [j] = D[i] [k-1];
}
}
}
}
}
Если применить этот алгоритм к графу, изображенному на рис. 6.4, то
получится следующий результат:
Кратчайшие пути:
62-55-27-
11 13 - 10 3 - 7 5 -
2
13-66-38-
8 10 -7 13 -4 4-
--1-----3
46-39-6 11-
--3--2--5
Направления:
61-61-61-
44-44-47-
--------8
00-00-00-
66-66-67-
--2-----2
33-33-33-
--5--5--5
Имея эти результаты, можно установить, что, например, кратчайший путь из
вершины 2 в вершину 6 не существует, а кратчайший путь из вершины 3
в вершину 7 содержит 8 единиц и проходит последовательно через вершины
3,0,1,7.
Имеется еще много интересных алгоритмов, связанных с поиском
минимальных характеристик путей в графе, однако давайте будем двигаться дальше и
рассмотрим несколько алгоритмов, связанных с поиском минимальных
подграфов заданного графа.
Алгоритмы обработки графов
371
6.3. Определение остовных деревьев
Остовиым деревом (скелетом) неориентированного графа называется его
подграф, не имеющий циклов и содержащий все вершины исходного графа.
Так, например, для нагруженного графа, изображенного на рис. 6.5, я,
скелетами являются все его подграфы, изображенные на рис. 6.5, б.
Рис. 6.5. Нагруженный неориентированный граф и его остовные поддеревья
Строго говоря, то, что изображено на рис. 6.5, б, не является деревом,
поэтому следовало бы говорить не об остовном дереве, а, скорее, об остовном лесе.
Тем не менее в дальнейшем мы не будем уточнять, идет ли речь об одном или
нескольких деревьях, представляющих остов графа.
372
Глава 6
Варианты скелетов исходного графа, изображенные на рис. 6.5, б, имеют
различный суммарный вес ребер графа. В первом варианте сумма весов ребер
скелета равна 15, во втором варианте— 27, и в третьем варианте— 19.
В разных ситуациях бывает необходимо найти остовное дерево с
максимальным или минимальным общим весом ребер. Так первое поддерево имеет
минимальный суммарный вес из всех возможных поддеревьев. Второе
поддерево — максимальный. Поставим задачу найти минимальное остовное дерево
(т. е. остовное дерево минимального суммарного веса ребер) для заданного
неориентированного графа. Разумеется, поиск максимального остовного
дерева будет производиться по точно такому же алгоритму.
Первый из предлагаемых алгоритмов принадлежит классу так называемых
жадных алгоритмов. Этот алгоритм пытается найти решение поставленной
задачи в лоб, стараясь найти минимальное остовное дерево
последовательным отбором ребер с минимальным весом, следя за тем, чтобы
получающийся граф не имел циклов. Разумеется, для этого необходимо сначала
отсортировать все ребра графа по возрастанию их весов. Лучше всего, если граф
с самого начала представлен списком ребер, в этом случае сортировка будет
наиболее естественной. Но даже и в других случаях сортировка ребер графа
по весу занимает не слишком много времени — в худшем случае Мх log2M,
где М— количество ребер графа.
Подходящим способом сортировки ребер будет организация списка ребер
в виде пирамиды — в этом случае сортировку можно совместить во времени
с основной работой алгоритма. После того как ребра будут выстроены в
упорядоченный список или в пирамиду, жадный алгоритм выбирает ребра по
очереди, начиная с ребер с наименьшим весом, и пытается включить каждое
очередное ребро в строящееся дерево. Очередное ребро не удается включить
в дерево, если оно замыкает какой-либо цикл в графе. Проверить этот факт
можно, если удастся выяснить, принадлежат ли оба конца очередного ребра
уже построенной части остовного дерева. Отсюда следует следующий
алгоритм, называемый также алгоритмом Крускала (J. B. Kruskal) [5].
После сортировки ребер по весу строящееся остовное дерево организуется в
виде отдельных фрагментов этого дерева, в которые в каждый момент
времени включены все вершины графа. В начале работы каждая вершина графа
представляет собой отдельную компоненту, а соединяющих их ребер пока
вообще нет. Каждое вновь включаемое ребро соединяет две отдельные
компоненты в одну, таким образом, полное остовное дерево будет построено
после N-\ включения ребра. Разумеется, очередное ребро можно включить в
дерево, только если его концы принадлежат разным компонентам, в
противном случае в компоненте образуется цикл. Таким образом, если очередное
ребро соединяет вершины, принадлежащие одной связной компоненте, то
такое ребро не включается в строящееся остовное дерево и игнорируется
Алгоритмы обработки графов
373
а) исходный граф
D
© © ©
© © ©
© © ©
2)
© ©
3 © ©
© 0 ©
© © © © 0
© © © © © ©
5)
©
© © ©
6)
©
© 0
7)
1 ) (2
2 ( 4
© ©
81 ^foV^Mi
© 4
3/^ 1
2(4) (5
б) 8 этапов построения минимального остовного дерева с помощью жадного алгоритма
Рис. 6.6. Алгоритм Крускала построения минимального остовного дерева графа
374
Глава 6
алгоритмом. Читателям в качестве упражнения предлагается доказать, что
описанный алгоритм действительно строит минимальное остовное дерево
заданного графа.
Для хранения информации о компонентах строящегося дерева предлагается
структура данных в виде массива, каждый /-й элемент которого содержит
ссылку на одну из вершин компоненты, содержащей /-ю вершину.
Фактически такой массив является одним из способов представления дерева
компонент (точнее, леса из нескольких деревьев), в котором ссылки из узлов дерева
проводятся от потомков к предкам.
На рис. 6.6 представлена последовательность этапов при построении
компонент остовного дерева согласно жадному алгоритму. В качестве исходного
графа выбран граф рис. 6.6, а. Первоначально остовное дерево состоит из
отдельных вершин исходного графа, жадный алгоритм постепенно добавляет
к нему ребра, начиная с ребер с наименьшим весом. На рис. 6.6, б показаны
8 последовательных этапов построения остовного дерева согласно жадному
алгоритму. На каждом этапе в остовное дерево включается новое ребро.
Ребра, замыкающие цикл в уже построенной части, игнорируются алгоритмом.
В листинге 6.13 показана реализация алгоритма Крускала построения
минимального остовного дерева. Метод minskeieton графа получает в качестве
аргумента выходной поток для печати в него информации о ребрах графа,
включаемых в остовное дерево. Второй аргумент— это объект, задающий
нагрузку на ребра графа. Информация о структуре минимального остовного
дерева выводится в выходной поток в виде последовательности пар целых
чисел — номеров вершин, задающих ребра графа, а общий полученный вес
этого остовного дерева выдается в качестве результата работы функции.
На первом этапе работы алгоритма строится пирамида из ребер графа. В
каждый узел пирамиды заносится информация о номерах соединяемых этим
ребром вершин и о весе ребра. Информация о ребрах графа берется из матрицы
смежности графа и объекта класса Graphweight, задающего нагрузку на ребра.
На втором этапе начинается построение остовного дерева, информация о
котором хранится в массиве. Каждый раз, как только очередное ребро попадает
в остовное дерево, информация о нем выводится в выходной поток.
\ Листинг 6.13. Реализации алгоритма Крускала построения минимального |
[остовного дерева ';v/\ * • /. ''"-/.- *•'', ■■[„ s.z-h\
/ / listgraph.h
//ss=s=ss—ss=^
// Класс ListGraph задает структуру L-графа
//===^====================^^==============================
class ListGraph {
Алгоритмы обработки графов
375
// Массив списков дуг
List<int> *graph;
// Количество вершин графа
int vertexNumber;
public :
// Конструктор создает массив пустых списков
ListGraph(int n) : vertexNumber(n), graph(new List<int>[n]) {}
// Деструктор уничтожает списки дуг
-ListGraphO { delete [] graph; }
// Функция подсчета числа вершин просто выдает
// ранее сохраненное значение
int vertexCount() const { return vertexNumber; }
// Основные методы работы с графом
void addArc(int from, int to);
bool hasArc(int from, int to) const;
// Алгоритм Крускала нахождения минимального остовного дерева
double minSkeleton(std::ostream & out, const GraphWeight & gw) ;
};
// arch
// Структура ребра для алгоритма Крускала: сравнение ребер
// происходит по их весам
struct Arc {
int from, to;
double weight;
Arc(int from = 0, int to = 0, double weight = 0)
: from(from), to(to), weight(weight) {}
Arc(const Arc & arc)
: from(arc.from), to (arc.to), weight(arc.weight) {}
Arc & operator = (const Arc & arc) {
from = arc.from; to = arc.to; weight = arc.weight;
}
376
Глава 6
bool operator < (const Arc & arc) { return weight < arc.weight; }
bool operator <= (const Arc & arc) { return weight <= are.weight; }
bool operator > (const Arc & arc) { return weight > arc.weight; }
bool operator >= (const Arc & arc) { return weight >= arc.weight; }
bool operator = (const Arc & arc) { return weight == a re. weight; }
bool operator != (const Arc & arc) { return weight != arc.weight; }
};
std: lostream & operator « (std: lostream & out, const Arc & arc);
// listgraph.cpp
//===============^==============^==========================
// Собственно алгоритм Крускала
double ListGraph::minSkeleton(
// Выходной поток для вывода результирующей информации:
std::ostream & out,
// Нагрузка на ребра графа:
const GraphWeight & gw) {
// Суммарный вес найденного минимального остовного дерева:
double weight = 0;
// Пирамида, содержащая информацию о ребрах графа:
ArrayHeap<Arc> arcs(vertexNumber * vertexNumber / 2);
//
// Структура узла в лесе, представляющем частично построенное
// минимальное остовное дерево
//
struct SkeletonNode {
int node; // номер узла исходного графа
int next; // ссылка на родительский узел
// Конструкторы:
SkeletonNode(int n = 0) : node(n), next(-l) {}
SkeletonNode(const SkeletonNode & node)
: node(node.node), next(node.next) {}
};
// Начальное заполнение пирамиды ребер:
// просматриваются все ребра графа,
//и информация о них заносится в пирамиду.
for (int i = 0; i < vertexNumber; i++) {
Iterator<int> ^neighbors = graph[i].iterator() ;
while (neighbors->hasMoreElements()) {
int j = **neighbors;
Алгоритмы обработки графов
377
// Граф неориентированный, поэтому для исключения дублирования
// информации рассматриваются только дуги, ведущие из вершины
//с меньшим номером в вершину с большим номером. Петли
// (если они есть) сразу же исключаются.
if (i < j) {
// Добавление ребра в пирамиду:
arcs += Arc(i, j, gw.arcLength(i, j));
}
++*neighbors;
}
delete neighbors;
}
// Начальное заполнение леса: каждая вершина графа представляет
// собой отдельное дерево, состоящее из единственной вершины.
SkeletonNode skeleton[vertexNumber];
for (int i = 0; i < vertexNumber; i++) {
skeleton[i] = SkeletonNode(i);
}
// Основной цикл по ребрам, включенным в пирамиду
while (!arcs.empty()) {
// Очередное ребро берется с вершины пирамиды и исключается из нее:
Arc nextArc = *arcs;
arcs.remove();
// u и v - концы выбранного ребра
int u = nextArc.from,
v = nextArc.to;
// Следующие два цикла находят корни деревьев,
// содержащих эти вершины:
while(skeleton[u].next != -1) и = skeleton[u].next;
while(skeleton[v].next != -1) v = skeleton[v].next;
if (u != v) {
// Ребро включается в остовное дерево,...
out « nextArc « "; ";
weight += nextArc.weight;
// ... а два дерева соединяются в одно,
skeleton[u].next = v;
}
}
return weight;
}
378
Глава 6
При попытке вычислить минимальное остовное дерево для графа,
приведенное на рис. 6.5, с помощью вызова метода:
cout « testGraph.minSkeleton(cout, arcsWeight);
В стандартный выходной поток будет выведена следующая информация:
(2,5); (0,3); (5,8); (0,6); (ОД); (1,4); (4,7); 15
что и свидетельствует о том, что минимальное остовное дерево
построено правильно, поскольку содержит ребра (2, 5), (0, 3), (5, 8), (0, б),
(0, 1), (1, 4), (4, 7)и суммарный вес всех ребер составляет минимально
возможную величину 15.
Скорость работы жадного алгоритма определяется скоростью, с которой
будут выбраны и отсортированы все ребра графа, т. к. вся остальная работа
обычно занимает меньше времени. Тем не менее для больших графов,
построение и проверка деревьев компонент строящегося остовного дерева
может занимать значительное время, если они вырождаются в линейные списки
вершин, что вполне возможно в некоторых специфических ситуациях. Это
может снизить эффективность работы алгоритма. К счастью, ситуацию
нетрудно исправить. Для этого надо лишь в процессе просмотра каждого из
деревьев компонент устраивать его локальное перестроение, продвигая
проходимые в процессе работы вершины ближе к корню дерева. Модификация
алгоритма в соответствии с этим замечанием предоставляется читателям.
Еще один алгоритм построения минимального остовного дерева напоминает
алгоритм Дейкстры для поиска наименьшего пути между двумя вершинами
в графе и носит название алгоритма Прима (R. С. Prim).
В этом алгоритме построение остовного дерева начинается с одной вершины,
к которой затем добавляются ребра таким образом, чтобы каждое новое
ребро одним своим концом опиралось в уже построенную часть дерева, а другой
конец лежал бы в множестве еще не присоединенных к дереву вершин. Из
всех таких ребер на каждом шаге выбирается ребро с наименьшим весом. Для
того чтобы выбор ребра был бы наиболее эффективен, так же, как и в
алгоритме Дейкстры, в алгоритме Прима используется промежуточная структура
данных — массив, элементы которого содержат информацию о расстоянии от
каждой из вершин до уже построенной части остовного дерева. Таким
образом, с помощью однократного просмотра такого массива всегда можно
выбрать ребро минимальной длины.
Будем считать, что вершины, не соединенные ребрами с уже построенной
частью остовного дерева, находятся от этой части на бесконечно большом
расстоянии. После того как очередная вершина будет выбрана и
присоединена к остовному дереву, все инцидентные ей ребра просматриваются, чтобы
скорректировать информацию о расстояниях. Эта часть алгоритма также
Алгоритмы обработки графов 379
очень похожа на соответствующую коррекцию массива расстояний из
алгоритма Дейкстры. Если оказывается, что очередная выбранная вершина
находится на бесконечно большом расстоянии от уже построенной части дерева,
то это означает, что завершено построение дерева для одной компоненты
связности графа и, значит, одного остовного дерева для графа построить
невозможно (в этом последнем случае стоит говорить об остовном лесе).
Если для графа, изображенного на рис. 6.5, начать поиск минимального
остовного дерева с вершины 0, то к дереву будут последовательно
присоединяться ребра 0—3 (длиной 1), 0—1 (2), 0—6 (2), 1—4 (3), А—7 (4), вершина 2
(+оо), 2—5(1), 2-* (2).
На рис. 6.7 показана последовательность построения минимального
остовного дерева для графа, изображенного на рис. 6.5.
Реализация алгоритма Прима показана в листинге 6.14 в виде определения
метода minSkeietonPrim, который так же, как и метод minSkeieton, в качестве
аргумента получает выходной поток для печати информации о найденных
ребрах минимального остовного дерева, а в качестве результата выдает
суммарный вес полученного остовного дерева.
^Листинг 6.14. Алгоритм Прима нахождения минимального остовного дерева 1
//
// Алгоритм Прима нахождения минимального остовного дерева
//
double ListGraph::minSkeietonPrim(
// Выходной поток для вывода результирующей информации:
std::ostream & out,
// Нагрузка на ребра графа:
const GraphWeight & gw) {
// Множество непройденных вершин (сначала - все вершины)
Set notPassed(0, vertexNumber-1);
notPassed.addScale(0, vertexNumber-1);
// Массив расстояний от вершин до уже построенной части
double *dist = new double[vertexNumber];
// Массив направлений от новых вершин к уже построенной части
double *ends = new double[vertexNumber];
// Инициализация массивов расстояний и направлений
for (int i = 0; i < vertexNumber; i++) {
dist[i] = INFINITY;
ends[1] = -1;
}
// Суммарный вес построенной части дерева
double sumWeight = 0;
// Основной цикл поиска новых вершин
while (!notPassed.empty()) {
// Поиск ближайшей вершины
double minDist = INFINITY;
Iterator<int> *iVertices = notPassed.iterator() ;
int minVertex = **iVertices;
while (iVertices->hasMoreElements()) {
int nextVertex = **iVertices;
if (dist[nextVertex] < minDist) {
minDist = dist[nextVertex];
minVertex = nextVertex;
}
++*iVertices;
}
delete iVertices;
if (dist[minVertex] < INFINITY) {
// Присоединяем очередное ребро
out « "(" « ends [minVertex] « "," « minVertex «
sumWeight += minDist;
}
notPassed -= minVertex;
// Новая вершина присоединена;
// корректируем информацию о расстояниях
Iterator<int> ^neighbors = graph[minVertex].iterator();
while (neighbors->hasMoreElements()) {
int next = **neighbors;
if (notPassed.has(next)
&& gw.arcLength(minVertex, next) < dist[next]) {
dist[next] = gw.arcLength(minVertex, next);
ends[next] = minVertex;
}
++*neighbors;
}
delete neighbors;
}
return sumWeight;
Алгоритмы обработки графов
381
а) исходный граф
4 0 © ©
© © ©
© © ©
210 0 ©
0 0®
© © ©
3)
0 ©
3 © ©
© 0 ©
41.0Н-О ©
I2® ©
© © ©
©
12(A) ©
0 © ©
Q
©
О )—*—( 1 ) (2)
б) 8 этапов построения минимального остовного дерева с помощью алгоритма Прима
Рис. 6.7. Этапы построения остовного дерева согласно алгоритму Прима
382
Глава 6
Если применить метод minSkeietonPrim к графу, изображенному на рис. 6.5
cout « testGraph.minSkeleton(cout, arcsWeight);
то, разумеется, результат будет примерно тем же самым, что и при
применении метода minskeieton, реализующего жадный алгоритм:
(0,3); (ОД); (0,6); (1,4); (4,7); (2,5); (2,8); 15
Алгоритмы имеют разную производительность на различных графах.
Скорость работы алгоритма Крускала зависит, прежде всего, от количества ребер
в графе и слабо зависит от количества вершин. Напротив, скорость работы
алгоритма Прима определяется количеством вершин и слабо зависит от числа
ребер. Следовательно, алгоритм Крускала следует применять в случае
неплотных или разреженных графов, у которых количество ребер мало по
сравнению с количеством ребер у полного графа. Если известно, что ребер в
графе достаточно много, то для поиска минимального остовного дерева лучше
применять алгоритм Прима.
Конечно, реальная скорость работы зависит и от представления графа, и от
реализации основных операций над ним, и от реализации самих алгоритмов
нахождения минимального остовного дерева, так что приведенные выше
рекомендации носят очень приблизительный характер. Лучше всего
поэкспериментировать немного самому, пробуя применить разные алгоритмы к
различным графам. Все описанные в книге алгоритмы можно найти на
приложенном компакт-диске во вложенных в папку "Chapter6\6.3" каталогах.
ГЛАВА 7
Обмен сообщениями
и обработка сообщений
При объектно-ориентированном подходе к проектированию и
программированию часто бывает удобно рассматривать программу как набор независимых
объектов, обменивающихся сообщениями. Особенно удобно это для
организации среды взаимодействия с пользователем, где источниками сообщений
могут служить внешние устройства, такие как мышь или клавиатура, однако
подобная организация программы может оказаться удобной и в других
ситуациях. Для того чтобы объекты могли обмениваться сообщениями,
необходимо, чтобы в программе присутствовал специальный объект — диспетчер
сообщений, который мог бы контролировать работу всей системы и
передавать сообщения от одних объектов к другим. Именно таким образом
построены многие современные операционные системы, например MS Windows,
в которой одни объекты (окна) посылают сообщения другим окнам,
используя для этого системные вызовы. В роли диспетчера сообщений в MS Windows
выступает сама операционная система. Подобным образом построены и
многие другие операционные системы и их подсистемы. В этой главе мы
рассмотрим, каким образом можно организовать свой собственный диспетчер
сообщений, и используем его для вычисления различных конечных сумм
несколько необычным способом.
7.1. Схема обмена сообщениями
Обычно сообщение, передаваемое от одного объекта другим, имеет
следующие характеристики:
□ код класса сообщения, отличающий сообщения одного класса от
сообщений другого класса;
□ адрес объекта, для которого это сообщение предназначено. Этот адрес
может быть не задан, если сообщение предназначено всем, кому оно
интересно;
384
Глава 7
П объект-параметр сообщения, содержащий информационную нагрузку
сообщения.
Для того чтобы сообщение могло быть передано по назначению, оно сначала
передается диспетчеру сообщений, который затем пытается найти объект-
адресат и передать ему это сообщение. Если сообщение предназначено для
всех, то диспетчер пытается передать его всем доступным ему объектам-
обработчикам.
( Замечание ^
Возможна также схема, при которой объекты-обработчики сообщений
подписываются у диспетчера на обработку только одного определенного или
нескольких классов сообщений. В этом случае диспетчер при получении сообщения
некоторого класса передает его только тем обработчикам, которые подписаны на
сообщения этого класса.
Примем следующую схему работы диспетчера сообщений:
1. Каждый объект, желающий генерировать и передавать сообщения другим
объектам, регистрируется у диспетчера в качестве генератора сообщений.
2. Каждый объект, желающий получать и обрабатывать сообщения от других
объектов, регистрируется у диспетчера в качестве обработчика
сообщений. Разумеется, один и тот же объект может выступать и в роли
генератора, и в роли обработчика сообщений.
3. Любой объект, даже тот, который не зарегистрирован у диспетчера, может
создать сообщение и передать его диспетчеру. Для таких сообщений
диспетчер ведет очередь сообщений.
4. Диспетчер в цикле пытается обнаружить одно из сообщений,
предназначенных для передачи объектам-обработчикам. Для этого он сначала
пытается выбрать очередное сообщение из очереди сообщений, а если эта
очередь пуста, то опрашивает зарегистрированные у него генераторы
сообщений.
5. Если сообщение найдено, то диспетчер просматривает список
зарегистрированных обработчиков сообщений и пытается передать это сообщение
адресату, а в случае, если сообщение предназначено всем, пытается
передать его всем обработчикам по очереди. Каждый из обработчиков в свою
очередь может после обработки сообщения уведомить диспетчер о том,
следует ли ему пытаться передавать это сообщение другим объектам.
При такой схеме вся работа программы контролируется диспетчером, и
реальная деятельность происходит только в результате исполнения реакций на
сообщения. Например, при работе в операционной системе MS Windows
стиль написания программ состоит в том, что определяется набор объектов-
окон, реагирующих на различные сообщения. Роль диспетчера сообщений
Обмен сообщениями и обработка сообщений
385
исполняет сама система, при этом в роли генераторов сообщений выступают
драйверы клавиатуры, мыши и других внешних устройств, а все приложения
определяют набор объектов-окон, регистрирующихся в системе в роли
обработчиков сообщений. Сообщения посылаются с помощью стандартной
системной функции sendMessage, при этом каждое сообщение имеет код класса
сообщения, указатель адресата сообщения и два информационных параметра
длиной в простое и двойное слово, содержимое которых определяется
классом сообщения и тем, как сообщение интерпретируется обработчиком.
Обычно один из классов сообщений выделяется как сообщение диспетчеру
об окончании работы. Так, например, в системе MS Windows нажатие
комбинации клавиш <Ctrl>+<Alt>+<Del> приводит к генерации специального
сообщения, которое обрабатывает сама система.
В этом разделе мы определим и запрограммируем простой диспетчер
сообщений, который затем используем в следующем разделе для задачи
вычисления конечных сумм зависящих друг от друга элементов.
Прежде всего, определим структуру и методы работы с сообщениями. Для
этого опишем класс Message и методы для создания и анализа сообщений.
Затем определим абстрактные типы данных для понятий генератора и
обработчика сообщений. Наконец, определим класс Dispatcher, содержащий методы
для регистрации объектов в роли генераторов и обработчиков сообщений и
для постановки сообщения в очередь сообщений диспетчера. Также опишем
основной цикл работы диспетчера в виде статической функции run
диспетчера.
Заметим, что диспетчер в системе всегда один, поэтому создание объекта-
диспетчера следует возложить на некоторую статическую функцию того же
самого класса. В этом разделе мы применим стандартный прием создания
объекта-одиночки (singleton), чтобы запретить непосредственное обращение
из программ к конструктору диспетчера. Для этого в классе Dispatcher
опишем статическую функцию доступа к диспетчеру, которая при первом
обращении к ней и создаст единственный объект-диспетчер. В дальнейшем эта
функция будет просто выдавать указатель на уже созданный объект.
Основная функция работы генератора сообщений состоит в том, что объект-
генератор способен создать объект-сообщение в ответ на запрос диспетчера.
Поэтому интерфейс, определяющий работу генератора сообщений, будет
выглядеть следующим образом:
class Generator {
public :
virtual Message generate() = 0;
};
386
Глава 7
Аналогично, обработчик сообщений должен уметь обработать сообщение,
переданное ему диспетчером, и сообщить в ответ, следует ли передавать это
сообщение другим обработчикам. Поэтому определим интерфейс
обработчика сообщения следующим образом:
class Handler {
virtual boolean handle(const Message & msg) = 0;
};
Условимся, что метод handle будет выдавать значение false, если диспетчер
должен передавать это сообщение другим обработчикам (в некоторых
случаях это будет означать, что обработчик отказался или не смог обработать
сообщение), и true, если дальнейший просмотр обработчиков следует
прекратить.
В нашей простой системе не будет адресных сообщений. Действительно,
если некий объект знает, кому он хочет передать некоторое сообщение, то он
может вызвать метод handle непосредственно, минуя диспетчер сообщений.
Поэтому в структуре сообщений останутся лишь два поля — класс и
аргумент сообщения.
Тип аргумента сообщения обычно определяется классом сообщения. В самом
общем классе мы вообще не будем определять никакого поля для
представления аргумента. Если для каких-либо конкретных классов сообщений
аргумент все же понадобится, то мы всегда сможем определить новый класс в
виде наследника класса Message, в котором уже и задать тип аргумента.
class Message {
int msgClass; // класс сообщения
public :
/* Конструкторы */
Message(int cl) : msgClass(cl) {}
Message(const Message & msg) : msgClass(msg.msgClass) {}
/* Деструктор */
virtual -Message() {}
/* Функции доступа */
int getMsgClass() const { return msgClass; }
>;
Определение класса Message стандартное и очень простое. Определить
диспетчер несколько сложнее. Прежде всего, поскольку мы проектируем объект-
одиночку, определим в этом классе статическую функцию getinstance, кото-
Обмен сообщениями и обработка сообщений
387
рая при первом обращении создаст единственный объект этого класса с
помощью конструктора, определенного с атрибутом private, а при всех
последующих обращениях будет просто выдавать объект, созданный при первом
обращении.
В диспетчере также будут определены системные классы сообщений. У нас
будет определен только один такой класс — команда диспетчеру об
окончании работы. Договоримся, что все системные сообщения будут иметь
отрицательные коды для того, чтобы каждая программа, использующая диспетчера
для организации работы, могла применять положительные числа для
кодирования своих собственных классов сообщений.
Списки сообщений, объектов-генераторов сообщений и
объектов-обработчиков сообщений будут представлены объектами класса List, который мы
использовали на протяжении всей книги.
В листинге 7.1 показано определение класса Dispatcher.
| Листинг 7.1. Определение диспетчера сообщений
// dispatcher.h
//:==================^^
// Класс Dispatcher реализует обработку очереди сообщений
class Dispatcher {
public :
static const int MSG_QUIT = -1; // Класс стандартного сообщения
private:
List<Generator *> generators; // Список генераторов
List<Handler *> handlers; // Список обработчиков
List<Message *> messages; // Очередь сообщений
// Реализация одиночки
7/==============================^=====================
static Dispatcher * dispatcher;
Dispatcher() {}
public :
static Dispatcher * getlnstance() {
388
Глава 7
if (dispatcher == NULL) {
dispatcher = new Dispatcher();
}
return dispatcher;
}
// Функции для работы со списком обработчиков
void addHandlerHead(Handler * hnd) {
handlers.addFirst(hnd);
}
void addHandlerTail(Handler * hnd) {
handlers.addLast(hnd);
}
Handler * removeHandler(Handler * hnd); // Реализация опущена
7/===================^============================
// Функции для работы со списком генераторов
void addGenerator(Generator * gen) {
generators.addLast(gen);
}
Generator * removeGenerator(Generator * gen); // Реализация опущена
//==================================================
// Передача сообщения в очередь сообщений
void sendMessage(Message * msg) {
messages.addLast(msg) ;
}
// Основной цикл работы диспетчера сообщений
void run();
// dispatcher.cpp
Dispatcher::Dispatcher * dispatcher = NULL;
Обмен сообщениями и обработка сообщений
389
// Основной цикл работы диспетчера сообщений
/7=^====^===========^=================================
void Dispatcher::run() {
// Цикл может закончиться только по обработке системной команды
for (;;) {
// 1. Попытаемся найти какое-либо сообщение
Message * msg = NULL;
if (!messages.isEmpty()) {
// Очередь сообщений не пуста - берем сообщение из нее
msg = messages.head();
messages.remove();
} else {
// Очередь сообщений пуста, запрашиваем генераторы
Iterator<Generator *> * it = generators.iterator();
while (it->hasMoreElements()) {
Generator * generator = **it;
if (msg = generator->generate()) {
break;
}
++*it;
}
}
if (msg) {
// 2. Проверка сообщения
if (msg->getMsgClass() = MSG_QUIT) {
// Заканчивается работа диспетчера,
//а вместе с ним и всей программы
break;
}
// 3. Передача сообщения на обработку
Iterator<Handler *> * it = handlers.iterator();
while (it->hasMoreElements()) {
Handler * handler = **it;
if (handler->handle(*msg)) {
break;
}
++*it;
}
}
}
}
390
Глава 7
Как же будет выглядеть программа, использующая определенный нами
диспетчер для организации работы?
Обычно есть смысл использовать предложенную организацию программы в
случаях, когда имеется достаточно большое число независимых
взаимодействующих объектов. В качестве примера рассмотрим реальную достаточно
сложную задачу моделирования работы пассажирского лифта в
многоэтажном доме. Мы не будем программировать эту задачу до конца, однако
рассмотрим организацию объектов в этой задаче и представим схему их
обработки. В задаче организуется таймер модельного времени. С течением
времени в системе появляются новые пассажиры, и происходит изменение
ситуации с уже существующими пассажирами и лифтом. Все изменения в
системе происходят под управлением сообщений, для чего организуется
диспетчер и очередь сообщений. Объекты системы — пассажиры и лифт —
меняют свое состояние, получая сообщения от таймера о прохождении
интервалов модельного времени, а также под воздействием сообщений, получаемых
друг от друга.
Опишем приблизительную структуру и поведение основных объектов
системы.
Лифт может находиться в следующих состояниях:
□ стоит; движется вниз; движется вверх;
□ находится на уровне этажа с некоторым заданным номером;
□ двери открыты (лифт стоит); двери закрыты (лифт стоит или движется);
□ в лифте находится некоторое заданное число человек.
Пассажир лифта также может находиться в некоторых состояниях,
характеризующихся следующими значениями:
□ пассажир находится на уровне этажа с некоторым заданным номером;
□ пассажир находится внутри лифта (и в этом случае его номер этажа
должен совпадать с номером этажа лифта) или вне лифта;
□ пассажир имеет желание попасть на этаж с некоторым заданным номером.
При каждом изменении состояния лифт извещает об этом пассажиров
отсылкой соответствующего сообщения. Получив такое сообщение, пассажир
может изменить также и свое состояние (например, войти в лифт). В свою
очередь, пассажир, изменив свое состояние (например, захотев поехать на
некоторый этаж), должен сообщить об этом лифту, который, получив запрос,
будет менять свое состояние. Поскольку лифт в системе всего один, то
сообщение об изменении состояния пассажира можно послать лифту и напрямую,
минуя диспетчер, однако удобнее воспользоваться для этого также общим
механизмом.
Обмен сообщениями и обработка сообщений
391
Итак, имеем следующую структуру объектов в системе:
□ один лифт (класса Elevator), который является обработчиком событий,
генерируемых пассажирами лифта и одновременно генератором событий,
обрабатываемых пассажирами;
□ несколько пассажиров (класса Passenger), каждый из которых является как
обработчиком событий (генерируемых лифтом), так и генератором
событий (например, если пассажир устал ждать лифт, он может отменить свою
заявку и отправиться по лестнице пешком).
Кроме этих основных классов объектов, в системе должны быть описаны
различные классы сообщений (мы представим описание двух таких классов:
сообщение от лифта и сообщение от пассажира), классы, представляющие
состояния пассажиров и лифта, и т. п. В листинге 7.2 представлены описания
необходимых классов, а в комментариях к ним даны дополнительные
пояснения о том, как эти классы могут использоваться в нашей системе.
П?::"ГТ"Т г:"? :^r;..;^^
(Листинг 7,2* Описание классов для моделирования работы лифта у : :л
// elevator.h
// Классы, описывающие состояние лифта
enum Estate {
STATE_STOP =1, // Лифт стоит
STATEJJP, // Лифт поднимается
STATE_DOWN // Лифт опускается
};
class ElevatorState {
Estate state; // Состояние лифта
int level; // Этаж, на котором он находится;
bool doorsOpen; // Состояние дверей
int people; // Количество людей в лифте
public :
// Конструкторы состояния лифта
ElevatorState(Estate state = STATE_STOP,
int level = 1,
bool doorsOpen = true,
int people = 0)
392
Глава 7
: state(state), level(level), doorsOpen(doorsOpen), people(people)
{}
ElevatorState(const ElevatorState & state)
: state(state.state), level(state.level),
doorsOpen(state.doorsOpen), people(state.people)
{}
// Функции доступа к элементам состояния и изменения состояния
// реализованы совершенно примитивно. На самом деле изменение
// состояния должно происходить согласно некоторым правилам
//====^^
Estate getState() const { return state; }
void setState(Estate st) { state = st; }
int getLevel() const { return level; }
void setLevel(int lev) { level = lev; }
bool isDoorsOpen() const { return doorsOpen; }
void setDoorsOpen() { doorsOpen = true; }
void setDoorsClose() { doorsOpen = false; }
int getPeopleO const { return people; }
void setPeople(int pple) { people = pple; }
};
//===================^^
// Класс ElevatorMessage задает тип сообщений, генерируемых
// лифтом при изменении своего состояния
class ElevatorMessage : public Message {
public :
// Следующие константы задают классы сообщений
//об изменении состояния лифта.
static const int MSG_STATE_CHANGED = 1;
// Возможны и другие классы сообщений. . .
private :
// Текущее состояние лифта
ElevatorState estate;
public :
// Конструктор
ElevatorMessage(int msgType, const ElevatorState & state)
: Message(msgType), estate(state)
{}
Обмен сообщениями и обработка сообщений
393
I/ Функция доступа к аргументу сообщения
ElevatorState getStateO const { return estate; }
);
// Класс Elevator реализует поведение лифта. Лифт генерирует
// новое сообщение при изменении состояния и обрабатывает
// сообщения, полученные от потенциальных пассажиров лифта
class Elevator : public Generator, public Handler {
// Состояние лифта:
ElevatorState state;
// Признак изменения состояния
bool stateChanged;
public :
// Конструктор
Elevator() : stateChanged(false) {}
// Функция генерации нового сообщения
Message * generate();
// Функция обработки полученного от пассажиров сообщения
bool handle(const Message & msg);
};
// elevator.cpp
// Функция генерации нового сообщения
Message * Elevator::generate() {
// Новое сообщение не генерируется, если состояние лифта не изменилось
if (!stateChanged) return NULL;
// Генерация нового сообщения
Message * newMessage =
new ElevatorMessage(ElevatorMessage::MSG_STATE_CHANGED, state);
// Сообщили об изменении состояния; теперь можно
// сбрасывать признак изменения состояния лифта
stateChanged = false;
return newMessage;
394
Глава 7
// Функция обработки сообщений от других объектов
bool Elevator::handle(const Message & msg) {
// Реализация этого метода отсутствует.
// Лифт может обрабатывать различные сообщения, например:
// - с течением времени могут приходить сообщения от таймера;
// таимерные сообщения могут повлиять на изменение состояния лифта
// просто потому, что лифт движется в некотором направлении;
// - лифт может запоминать и обрабатывать сообщения (заявки) от
// пассажиров, желающих переехать на другой этаж;
// - если в системе имеется несколько лифтов, то лифт может
// принимать сообщения от других лифтов о выполнении ими заявок.
return false;
}
// passenger.h
//=======_==========^=:_===^^
// Класс, описывающий состояние пассажира
class PassengerState {
bool inElevator; // Состояние пассажира (вне или внутри лифта)
int level; // Номер этажа
int wish; // Куда пассажир хочет попасть (если хочет)
public :
// Конструкторы состояния пассажира
PassengerState(bool inElevator = false,
int level = 1,
int wish = 0)
: inElevator(inElevator), level(level), wish(wish)
{}
PassengerState(const PassengerState & state)
: inElevator(state.inElevator), level(state.level), wish(state.wish)
{}
// Функции доступа к элементам состояния и изменения состояния
// реализованы совершенно примитивно. На самом деле изменение
// состояния должно происходить согласно некоторым правилам
int getLevelO const { return level; }
void setLevel(int lev) { level = lev; }
bool isInElevator() const { return inElevator; }
Обмен сообщениями и обработка сообщений
395
void enter () { inElevator = true; }
void exit() { inElevator = false; }
int getDestination() const { return wish; }
void setDestination(int newLevel) { wish = newLevel; }
);
// Класс PassengerMessage задает тип сообщений, генерируемых
// пассажиром при подаче заявок, перемещение в лифте и т. п.
class PassengerMessage : public Message {
public :
// Следующие константы задают классы сообщений пассажира
static const int MSG_DEMAND =101; // Заявка на обслуживание
static const int MSG_DEMAND_CANCELLED = 102; // Отмена заявки
/ / Возможны и другие классы сообщений. . .
private :
// Текущее состояние пассажира
PassengerState pState;
public :
// Конструктор
PassengerMessage(int msgType, const PassengerState & state)
: Message(msgType), pState(state)
{}
// Функция доступа к аргументу сообщения
PassengerState getState() const { return pState; }
};
// Класс Passenger описывает структуру модели пассажира и его поведение.
// Объекты этого класса могут создавать и обрабатывать сообщения
class Passenger : public Handler, public Generator {
PassengerState state;
long wait; // Время ожидания обслуживания
public :
// Конструктор нового пассажира
Passenger() : wait(0) {}
396
Глава 7
I/ Функции генерации и обработки сообщений
Message * generate();
bool handle(const Message & msg) ;
};
// passenger.cpp
// Функция генерации сообщений
Message * Passenger::generate() {
// Реализация метода отсутствует
// Новый появившийся в системе пассажир должен сгенерировать
// заявку на обслуживание его лифтом. Кроме того, с течением
// времени он может отменить заявку. Возможно, в системе
// обрабатываются и другие сообщения от пассажиров:
//об изменении состояния, об удовлетворении заявки и т. п.
return NULL;
}
// Функция обработки сообщений
bool Passenger::handle(const Message & msg) {
// Реализация метода отсутствует
// Обработка сообщения может заключаться в следующем:
// - с течением времени (сообщение от таймера) пассажир может
// отменить заявку и покинуть систему;
// - с приходом лифта (обработка сообщения от лифта) пассажир
// изменяет свое состояние (входит в лифт);
// - пассажир перемещается (изменяет состояние) вместе с лифтом;
// - когда лифт останавливается на нужном пассажиру этаже,
// пассажир тоже меняет свое состояние, выходя из лифта.
return false;
}
Программа, моделирующая работу системы, должна создать
объект-диспетчер и инициировать его работу, запустив метод run. Для того чтобы в системе
появлялись новые пассажиры, необходимо иметь специальный обработчик
таймерных сообщений, который смог бы в ответ на эти сообщения создавать
новые объекты класса Passenger, формировать их заявки на обслуживание,
удалять из системы обслуженных пассажиров и т. п. Этот же объект должен
будет также и прекратить процесс моделирования, передав диспетчеру
системное сообщение с кодом msgquit.
Довести до конца программу моделирования поведения лифта достаточно
сложно. В следующем разделе мы рассмотрим более простой пример взаимо-
Обмен сообщениями и обработка сообщений
397
действия объектов путем передачи сообщений. Этот пример мы уже сможем
запрограммировать до конца. Однако следует отметить, что данный пример
будет несколько надуманным. Поставленную в нем задачу гораздо легче
можно решить традиционными методами, не прибегая к аппарату генерации
и обмена сообщениями.
7.2. Об одном способе вычисления
конечных сумм
Рассмотрим задачу вычисления конечной суммы некоторых слагаемых.
Конечно, если эти слагаемые заданы с помощью некоторой формулы (функции),
то наиболее простым будет следующий обычный способ вычисления суммы:
double sum (double (*sumMember) (int), int n) {
double s = 0;
for (int i = 0; i < n; i++) {
s += (*sumMember) (i) ;
}
return s;
}
В этом решении предполагается, что функции суммирования передается
в качестве аргумента способ вычисления членов суммы в виде функции
sumMember, аргументом которой служит номер слагаемого. Однако может
оказаться, что значения некоторых членов суммы удобно вычислять, используя
уже вычисленные ранее значения других членов суммы. Например, в
известной формуле для приближенного вычисления числа е
Еп-\ 1
"л
каждый член суммы, кроме первого, легко может быть вычислен с
использованием значения предыдущего члена, таким образом, нецелесообразно
вычислять каждый из членов суммы независимо от других. Для вычисления
этой суммы было бы удобнее воспользоваться другим алгоритмом:
double sum (int n) {
double s = 0;
double u = 1;
for (int i = 1; i <= n; i++) {
s += u;
u /= i;
}
return s;
}
398
Глава 7
Если требуется вычислить сумму первых п членов последовательности
Фибоначчи, каждый из которых (кроме первых двух, равных единице) может
быть вычислен по известной формуле:
Fn = Zvi + iv-2,
то опять лучше воспользоваться специальным алгоритмом:
double suin(int n) {
double s = 2;
int Fl = 1, F2 = 1;
for (int i = 2/ i < n; i++) {
int F = Fl + F2;
s += F;
Fl = F2; F2 = F;
}
return s;
}
Хотелось бы воспользоваться общей схемой для вычисления сумм, несмотря
на то, что в каждом конкретном случае зависимость значений одних членов
суммы от других различна. Попробуем воспользоваться для этого схемой
обмена сообщениями между объектами разных классов.
Определим класс сумматор, объект которого будет ожидать сообщений от
объектов-членов суммы. В каждом сообщении объект-член суммы сообщает
свое значение и свой номер. Когда сумматор получит сообщения от всех
членов суммы, его работа будет закончена.
В свою очередь, каждый объект-член суммы, зависящий от других членов
суммы, ждет сообщений от них. Разумеется, в этой цепочке должны быть
члены, которые готовы выдать свое значение, не ожидая значений от других
членов, в противном случае было бы невозможно завершить вычисления.
Таким образом, в нашей системе объектов имеется один объект сумматор и п
объектов — членов суммы, каждый из которых порождает сообщения и
принимает сообщения. Сумматор является обработчиком сообщений,
единственное сообщение, которое он сам генерирует, — это сообщение об окончании
работы, возникающее тогда, когда он получит сообщения от всех членов
суммы. Члены суммы являются обработчиками сообщений от других членов
суммы, а, по крайней мере, один из членов является также генератором
сообщения, содержащего значение, не зависящее от других значений.
Получающиеся описания классов, участвующих в этой схеме объектов, выглядят
так, как показано в листинге 7.3.
Обмен сообщениями и обработка сообщений
399
// summator. h
//
// Класс, определяющий структуру параметра сообщения -
// объекта, содержащего номер и значение члена суммы
//
class Membervalue {
int number; // номер члена суммы
double value; // значение члена суммы
public :
// Конструкторы:
Membervalue(int num = 0, double val = 0.0)
: number(num), value(val) {}
MemberValue(const MemberValue & src)
: number(src.number), value(src.value) {}
// Доступ к элементам параметра сообщения
int getNumberO const { return number; }
double getValue() const { return value; }
};
//
// Класс, определяющий сообщение о готовности
// очередного члена суммы для суммирования
//
class SumMessage : public Message {
public :
// Определение нового класса сообщения от члена суммы
static const int msgMemberReady = 1;
private :
// Параметр сообщения
MemberValue value;
public :
// Конструктор сообщения
SumMessage(const MemberValue & val)
: Message(msgMemberReady), value(val) {}
// Доступ к параметру сообщения
MemberValue getValueO const { return value; }
};
400
Глава 7
I/
// Класс, определяющий абстрактный сумматор
//
class Suramator : public Handler {
int n; // количество членов суммы
int ready; // количество уже вычисленных членов суммы
double sum; // накапливаемая сумма
public :
// Конструктор сумматора задает количество ожидаемых им членов суммы
Summator(int n) : n(n), ready(0), sum(0) {}
// Метод обработки сообщения
bool handle(const Message & msg);
// Результат суммирования
double getResult() const { return sum; }
};
// summator.cpp
// Метод обработки сообщения
bool Summator::handle(const Message & msg) {
// Проверка класса сообщения: реагируем только на сообщения
// класса msgMemberReady
if (msg.getMsgClass () != SumMessage: :msgMemberReady) return false;
// Извлечение параметра сообщения
const SumMessage & myMsg = (const SumMessage &)msg;
MemberValue memVal = myMsg.getValue() ;
// Добавление очередного члена суммы
sum += memVal. get Value () ;
// Проверяем, не все ли необходимые члены суммы уже собраны
if (++ready == n) {
// Посылаем сообщение диспетчеру об окончании работы
Dispatcher::getlnstance()->
sendMessage(new Message(Dispatcher::msgQuit));
}
//Во всех случаях значение члена суммы передается другим объектам
return false;
}
Структура члена суммы не определена, поэтому можно задать пока лишь
абстрактный тип данных члена суммы в виде определения класса Member,
реализующего интерфейсы Handler И Generator.
Обмен сообщениями и обработка сообщений
401
class Member : public Handler, public Generator {
};
Теперь мы можем определить несколько различных конкретных классов для
суммирования последовательностей. Каждое новое определение будет
задавать свой способ вычисления суммы. Таким образом, мы можем определить
функцию суммирования как функцию, параметризованную абстрактной
фабрикой, порождающей те или иные члены конечной суммы. Эта абстрактная
фабрика должна порождать члены сумм по их номеру. Фактически она
является прямым аналогом функции, задающей значение члена суммы в нашем
первом самом простом способе суммирования.
class MemberFactory {
public :
virtual Member * createMember(int i) const = 0;
};
double sum (const MemberFactory & factory, int n) {
// Создаем диспетчер сообщений
Dispatcher * dispatcher = Dispatcher:rgetlnstance();
// Формируем среду для работы программы - набор
// обработчиков и генераторов сообщений:
// а) сумматор только обрабатывает сообщения; единственное
// сообщение, генерируемое им - сообщение об окончании
// работы он посылает диспетчеру напрямую.
Summator summator(n);
dispatcher->addHandlerTail(&summator);
// б) члены суммы порождают и обрабатывают сообщения
for (int i = 0; i < n; i++> {
Member * nextMember = factory.createMember(i);
dispatcher->addHandlerTail(nextMember);
dispatcher->addGenerator(nextMember);
}
// Запускаем процесс обмена сообщениями
dispatcher->run();
// Получаем готовый результат
return summator.getResult();
}
Для того чтобы функция могла работать и выдавать конкретный результат,
надо определить конкретный класс члена суммы и, соответственно, конкрет-
402
Глава 7
ную фабрику, порождающую объекты этого класса. Рассмотрим, например,
простую задачу вычисления приближенного значения числа е по формуле
-1 1
Для вычисления необходимо определить классы EMember И EMemberFactory,
которые будут определять алгоритмы вычисления членов суммы и порождать
объекты-члены суммы соответственно. В листинге 7.4 показано определение
этих классов.
// emember.h
//
// Класс EMember описывает поведение объекта-члена суммы ряда
// для приближенного вычисления числа е как участника системы
// обмена сообщениями для суммирования
//
class EMember : public Member {
int number; // Номер члена суммы (от нуля до некоторого л)
double pred; // Запомненное значение предыдущего члена
bool accepted; // Предыдущее значение получено?
bool generated; // Значение уже сгенерировано?
public :
// Конструктор запоминает номер
EMember (int i = 0) : number(i), gene rated (false), accepted (false) {}
EMember(const EMember & src)
: number(src.number), generated(src.generated),
pred(src.pred), accepted(src.accepted) {}
// Реализация операций по обмену сообщениями
Message * generate();
bool handle (const Message & msg);
};
//
// Класс EMemberFactory описывает фабрику для создания объектов-
// членов суммы ряда для приближенного вычисления числа е
//
class EMemberFactory : public MemberFactory {
public :
Обмен сообщениями и обработка сообщений
403
Member * createMember(int i) const { return new EMember(i); }
};
// emember. cpp
//
// Генератор порождает сообщение о готовом значении, только если
// номер члена ряда равен нулю или уже готово значение
// предыдущего члена суммы. Сообщение порождается только один раз.
//
Message * EMember::generate() {
// Проверка, не было ли уже порождено сообщение
if (generated) return NULL;
if (number ==0) {
// Для нулевого члена суммы значение порождается сразу же
generated = true;
return new SumMessage(MemberValue(0, 1));
} else if (accepted) {
// Предыдущий член суммы уже вычислен;
// вычисляем значение данного члена суммы на его основе
generated = true;
return new SumMessage(MemberValue(number, pred / number));
} else {
// Пока еще нельзя выдать значение члена суммы;
return NULL;
}
}
//
// Обработчик принимает значение некоторого члена суммы и,
// если номер этого члена ровно на единицу меньше собственного
// номера, запоминает его для дальнейшего вычисления.
//
bool EMember::handle(const Message & msg) {
// Проверка, не было ли уже запомнено значение предыдущего члена
if (accepted) return false;
// Проверка класса сообщения
if (msg.getMs^Class() != SumMessage:imsgMemberReady) return false;
const SumMessage & myMsg = (const SumMessage &)msg;
// Анализ и запоминание параметра сообщения
MemberValue value = myMsg.getValue();
404
Глава 7
if (value.getNumber() == number-1) {
pred = value.getValue();
accepted = true;
}
//Во всех случаях сообщение передается дальше для анализа его
// другими обработчиками (на самом деле это значение может
// понадобиться разве что сумматору).
return false;
}
Теперь для вызова функции суммирования нужно лишь создать фабрику для
порождения новых членов суммы ряда и задать общее количество
суммируемых членов:
EMemberFactory eFactory;
double e = sum (eFactory, 10) ;
cout « "Approximate value for e = " « e « endl;
Приведенный выше вызов выведет в стандартный выходной поток значение:
2.71828.
Как всем хорошо известно, это значение действительно является
приблизительным значением числа е.
Рекомендуется внимательно изучить программу в части порождения и
обработки событий, чтобы понять, как взаимодействуют между собой различные
члены суммы. На самом деле программа работает довольно медленно.
Действительно, в системе циркулирует довольно много сообщений, но почти все
они проходят впустую, т. к. каждое значение очередного члена суммы на
самом деле необходимо только двум объектам: сумматору для добавления его к
сумме и следующему члену суммы для вычисления очередного значения.
Всеми остальными объектами сообщение просто игнорируется. Аналогично,
все члены суммы являются потенциальными генераторами событий, однако
при просмотре списка генераторов диспетчером каждый раз лишь один из
членов суммы готов сгенерировать новое значение, остальные члены суммы
будут ждать своей очереди.
Приведем еще несколько примеров вычисления различных сумм практически
без комментариев. Работа соответствующих классов очень похожа на работу
класса ЕМетЬег.
Члены последовательности чисел Фибоначчи зависят друг от друга чуть
более сложным образом, чем члены ряда для вычисления числа е. Каждый член
последовательности Фибоначчи зависит не от одного, а от двух предыдущих
членов. Сумма п первых членов последовательности чисел Фибоначчи
получается, если определить класс FibMember так, как показано в листинге 7.5.
Обмен сообщениями и обработка сообщений
405
{Листинг 7.5. Суммирование членов последовательности чисел Фибоначчи
// fibmember.h
class FibMember : public Member {
int number; // Номер члена суммы (от нуля до некоторого п)
int pred; // Запомненное значение предыдущего члена
±nt predPred; // Запомненное значение пред-предыдущего члена
bool acceptedl; // Предыдущее значение получено?
bool accepted2; // Пред-предыдущее значение получено?
bool generated; // Значение уже сгенерировано?
public :
// Конструктор запоминает номер
FibMember(int i = 0)
: number(i), generated(false), acceptedl(false), accepted2(false)
{}
FibMember(const FibMember & src)
: number(src.number), generated(src.generated),
pred(src.pred), predPred(src.predPred),
acceptedl(src.acceptedl), accepted2(src.accepted2)
{}
// Реализация операций по обмену сообщениями
Message * generate();
bool handle(const Message & msg);
};
//
// Класс FibMemberFactory описывает фабрику для создания объектов-
// членов суммы последовательности первых л чисел Фибоначчи
//
class FibMemberFactory : public MemberFactory {
public :
Member * createMember(int i) const { return new FibMember(i); }
// fibmember.cpp
/ /
//
406
Глава 7
Message * FibMember::generate() {
// Проверка, не было ли уже порождено сообщение
if (generated) return NULL;
if (number ===== 0 I I number ==1) {
// Для первых членов суммы значение порождается сразу же
generated = true;
return new SumMessage (MemberValue (number, 1));
} else if (acceptedl && accepted2) {
// Предыдущие члены суммы уже вычислены;
// вычисляем значение данного члена суммы на их основе
generated = true;
return new SumMessage(MemberValue(number, pred + predPred));
} else {
// Пока еще нельзя выдать значение члена суммы;
return NULL;
}
}
//
// Обработчик принимает значение некоторого члена суммы и,
// если номер этого члена на 1 или 2 меньше собственного
// номера, запоминает его для дальнейшего вычисления.
//
bool FibMember: :handle (const Message & msg) {
// Проверка, не было ли уже запомнено значение предыдущих членов
if (acceptedl && accepted2) return false;
// Проверка класса сообщения
if (msg.getMsgClass () != SumMessage: imsgMemberReady) return false;
const SumMessage & myMsg = (const SuittMessage &)msg;
// Анализ и запоминание параметра сообщения
MemberValue value = myMsg.getValue();
if (lacceptedl && value.getNumber() == number-1) {
pred = (int)value.getValue();
acceptedl = true;
} else if (!accepted2 && value.getNumber() == number-2) {
predPred = (int)value.getValue();
accepted2 = true;
}
//Во всех случаях сообщение передается дальше для анализа его
// другими обработчиками.
return false;
}
Обмен сообщениями и обработка сообщений
407
Вызов сумматора:
FibMemberFactory fibFactory;
cout « "Fibonacci sum of 10 numbers = " « sum (fibFactory, 10) « endl;
породит результат суммирования:
Fibonacci sum of 10 numbers = 143
А вот пример, в котором члены суммы не зависят друг от друга. Конечно,
в этом случае данный подход к вычислению суммы совсем не оправдан,
однако схема все же работает правильно. Итак, вычислим сумму членов по
следующей формуле:
*=i;:oH>"77?
Эта формула при больших значениях п дает приближенное значение
натурального логарифма числа 2. Класс для вычисления значений членов этой
суммы, определение которого приведено в листинге 7.6, вообще не
обрабатывает приходящие к нему сообщения, вместо этого он сразу же генерирует
нужное значение по запросу диспетчера на основании знаний о собственном
номере.
! Листинг 7.6. Суммирование для приближенного вычисления значения In 2 ^ j
// Ininember. h *
class LnMember : public Member {
int number; // Номер члена суммы (от нуля до некоторого л)
bool generated; // Значение уже сгенерировано?
public :
// Конструктор запоминает номер
LnMember(int i = 0) : number(i), generated(false) {}
LnMember(const LnMember & src)
: number(src.number), generated(src.generated) {}
// Реализация операций по обмену сообщениями
Message * generate();
};
class LnMemberFactory : public MemberFactory {
public :
408
Глава 7
Member * createMember(int i) const { return new LnMember(i); }
};
// lnmember. cpp '
//
// Генератор порождает сообщение о готовом значении,
// сразу вычисляя его по номеру члена ряда.
// Сообщение порождается только один раз.
//
Message * LnMember::generate() {
// Проверка, не было ли уже порождено сообщение
if (generated) return NULL;
generated = true;
double value = 1.0 / (number + 1) ;
if (number & 1) value = -value;
return new SumMessage(MemberValue(number, value));
}
В результате соответствующего вызова функции суммирования:
LnMemberFactorу InFactory;
cout « "Approximate value for In 2 = " « sum (InFactory, 100) « endl;
будет получено приближенное значение числа In 2.
Approximate value for In 2 = 0.688172
Это не очень точное значение, несмотря на то, что на этот раз
просуммировано 100 членов ряда. Оно отличается от истинного значения более чем на
0,005. Но это просто потому, что соответствующий ряд очень медленно
сходится.
Заметим, что во всех описанных случаях объекты-члены суммы становились
пассивными сразу же после того, как выдадут свое значение в виде
сообщения. Это, конечно, правильно, в противном случае диспетчер может оказаться
засыпанным повторными сообщениями от этих членов. Тем не менее такое
поведение объектов снижает общую эффективность работы системы,
поскольку все обработчики и генераторы сообщений остаются в системе
балластом и диспетчер при появлении каждого нового сообщения все равно
вынужден опрашивать все эти объекты.
Можно организовать работу этих объектов немного по-другому. Породив
свое значение, член суммы может просто исключить себя из списка
генераторов и обработчиков сообщений. В этом случае немного упрощается и
реализация класса, поскольку теперь не требуется деактивировать объект, уже вы-
Обмен сообщениями и обработка сообщений
409
давший свое значение, этот объект просто убирается из списков генераторов
и обработчиков сообщений и тем самым автоматически деактивируется.
В листинге 7.7 представлена модифицированная реализация операции
генерации сообщений класса пьметЬег.
\ Листинг 7.7. Модифицированная версия суммирования чисел Фибоначчи
Message * FibMember::generate() {
Message * result = NULL;
if (number ===== 0 | 1 number ==1) {
// Для первых членов суммы значение порождается сразу же
result = new SumMessage(MemberValue(number, 1));
} else if (acceptedl && accepted2) {
// Предыдущие члены суммы уже вычислены;
// вычисляем значение данного члена суммы на их основе
result = new SumMessage(MemberValue(number, pred + predPred));
}
// После генерации сообщения объект убирается из списков
// генераторов и обработчиков сообщений.
if (result) {
Dispatcher::getlnstance()->removeGenerator(this);
Dispatcher::getInstance()->removeHandler(this);
}
return result;
}
В этой новой версии программа суммирования работает приблизительно
вдвое быстрее, чем исходная.
Следует обратить внимание на две существенные особенности нашей
реализации процесса суммирования и вообще системы взаимодействия объектов
через сообщения. Во-первых, работа со списками генераторов и
обработчиков сообщений должна производиться очень аккуратно. Потенциальный
источник ошибок в такой системе — это возможность изменения списков
генераторов и обработчиков сообщений прямо по ходу обработки сообщения.
Может оказаться, что если в процессе обработки сообщения список объектов-
обработчиков сильно изменится, то это может повлиять на стабильность
работы диспетчера. В нашем последнем примере член суммы может удалять
себя из списка обработчиков сообщения прямо по ходу обработки
сообщения. Когда диспетчер переходит к следующему элементу этого списка, то при
неаккуратной реализации он может просто потерять весь список.
410
Глава 7
В нашей реализации все будет происходить правильно, если итератор
списков сможет стабильно работать при уничтожении текущего элемента списка
прямо во время итерации. Вы можете проверить, что в программах,
записанных на приложенном компакт-диске в папке Mchapter7\7.2\sum", итератор
списка работает правильно в случае удаления текущего элемента списка,
поэтому все описанные программы действительно работают без ошибок. Тем не
менее абсолютной надежности в этой реализации нет. Во время обработки
сообщения список обработчиков может быть испорчен настолько, что
нормальное продолжение итерации будет невозможным.
Более аккуратным решением было бы такое, при котором списки генераторов
и обработчиков физически меняются только после завершения итерации.
Этого можно достичь, ерли перепрограммировать операции добавления и
удаления элементов из списков, контролируемых диспетчером, так, чтобы
физически изменения в составе списков происходили бы только между их
итерациями. Сделать это не очень сложно, но зато такое изменение позволит
делать любые модификации в списках генераторов и обработчиков
сообщений, не задумываясь о том, не повлияет ли это на стабильность системы.
Подобное изменение программы предлагается читателям в качестве
упражнения.
Второе замечание касается отведения и освобождения памяти под объекты
системы. Удаление объектов из списков не приводит к освобождению
занимаемой этими объектами памяти. Это означает, что наша программа работает
не очень чисто, оставляя после себя мусор в виде значительного числа
объектов, которые более недоступны программе, но занимают память процесса.
Более аккуратная реализация могла бы вызывать деструкторы объектов после
того, как они становятся не нужны. Так, например, наша функция sum создает
объекты, представляющие члены суммы, с помощью фабрики, переданной ей
в качестве одного из аргументов, однако деструкторы для этих объектов
после окончания работы не вызываются. Фактически каждый член суммы
становится не нужен сразу после того, как он сгенерировал сообщение о своей
величине и был удален из списков генераторов и обработчиков сообщений.
После этого его можно уничтожать. В последней реализации операции
generate () класса FibMember можно добавить оператор:
delete this;
сразу же вслед за операторами удаления объекта из списков диспетчера:
Dispatcher::getInstance()->removeGenerator(this);
Dispatcher::getlnstance()->removeHandler(this);
Такое уничтожение объекта будет нормально работать в нашей программе,
хотя оператор delete this; — это пример очень опасной конструкции.
Обмен сообщениями и обработка сообщений
411
В более общем виде задача аккуратного освобождения памяти может быть
решена с использованием так называемых умных указателей (smart pointers).
В следующей главе мы рассмотрим одну простую реализацию умных
указателей, которую можно применить и для только что рассмотренной задачи.
Еще один способ справиться с проблемой — это использовать собственную
систему распределения памяти под объекты системы, как было описано в
главе 5 этой книги.
Конечно, задача о суммировании конечного числа взаимозависимых величин
может быть решена и более простыми средствами, и в каждом конкретном
случае легко написать более эффективную программу. Наверное, и для
общего случая вы сможете придумать более эффективное решение. В реальной
жизни обмен сообщениями чаще всего используется в значительно более
сложных ситуациях: для моделирования систем реального времени, для
организации взаимодействия пользователя с программными объектами, такими
как диалоговые элементы, документы, рисунки, и т. п. Однако метод, при
котором программа представляет собой набор объектов, обменивающихся
сообщениями друг с другом, может с успехом применяться во многих случаях,
и следует всегда держать его в голове независимо от того, проектируется ли
сравнительно небольшая программа или большой программный комплекс.
Главное преимущество подобной организации программ состоит в том, что в
таких программах повышается степень независимости объектов друг от
друга. Действительно, элементы суммы в нашей программе знают о
существовании других элементов суммы, но физически они никак между собой не
связаны. Тем более, члены суммы оказываются совершенно независимыми от
сумматора. Вся связь между объектами осуществляется не напрямую, а через
диспетчер. Это позволяет легко расширять систему, дополняя ее новыми
объектами, поведение которых не зависит от существования или поведения
других объектов.
ГЛАВА 8
Функции
как носитель информации
Довольно часто самым удобным способом представления данных является
функция. Вообще, иногда говорят о двух формах представления знаний —
объектной и функциональной — причем часто функциональная форма
представления оказывается более удобной. Действительно, часто ли вы
предпочитаете на вопрос о том, где расположен нужный вам дом, получить
действительную информацию о его адресе? Пожалуй, чаще вам захочется услышать,
как туда добраться, а это, конечно, одна из форм функционального
представления знания. Часто люди, ориентируясь в знакомом месте, хорошо
представляют себе, как дойти до нужного им места, но не очень хорошо знают,
как это место расположено в пространстве. То есть их знание оказывается
преимущественно функциональным.
Если перейти к более точным математическим объектам, то и в этом случае
функциональное представление знания часто оказывается удобным.
Например, если описывается некоторое множество объектов, то иногда легче
описать их характеристическое свойство (задать функцию), чем перечислить эти
объекты явно. В случае бесконечных множеств функциональная форма
задания множества в большинстве случаев оказывается единственно возможной.
В первом разделе этой главы мы рассмотрим функциональное представление
множеств целых чисел и работу с таким представлением. Во втором разделе
попробуем применить функциональную форму представления знаний к более
традиционной области— для решения классической задачи о расстановке
восьми ферзей на стандартной шахматной доске (решение задачи восходит к
решению Г. С. Цейтина). Затем рассмотрим еще несколько задач, в которых
функциональное представление информации помогает написать короткие и
достаточно наглядные программы.
Функции как носитель информации
413
8.1. Еще о представлении множеств
Представление множеств мы рассматривали в главе 1 данной книги. Тогда же
мы договорились, что будем рассматривать только множества целых чисел из
заданного достаточно ограниченного диапазона. Говорить о представлении
множества всех целых чисел или хотя бы о множестве только четных чисел
смысла не было. Тем более описанное в разд. 1.4 представление совершенно
не пригодно для работы с множествами, состоящими из сложных объектов.
Однако часто встречаются задачи, в которых требуется работать с такими
сложными множествами. Например, весьма популярна задача о нахождении
множества всех простых чисел (обычно нет нужны в представлении всех
простых чисел сразу, но только о потенциальной возможности найти любое
по порядку простое число в пределах представимости целых чисел в
машине).
Во всех этих случаях возможно представление множества в виде функции
(или ряда функций). Например, вполне адекватным представлением
множества служит итератор, выдающий по очереди все элементы множества.
Конечно, может оказаться, что элементов бесконечно много, но это не помешает
исправно получать элементы один за другим. Правда, одного итератора явно
недостаточно, если надо проверить принадлежность заданного элемента
множеству, получить пересечение или разность множеств и т. д. Удобным
способом представления множества служит его характеристическая
функция — функция, которая по заданному элементу выдает логическое значение
true, если элемент принадлежит множеству, и false, если не принадлежит.
Такая функция позволяет легко проверить, принадлежит ли элемент
множеству, построить объединение или пересечение множеств, но, правда, она
практически бесполезна при попытке перечислить все элементы множества.
В этой главе мы будем рассматривать множества, содержащие только
неотрицательные целые числа. Будем представлять множество парой из его
характеристической функции и итератора элементов множества. На самом деле,
для того чтобы задать множество подобным образом, надо определить класс,
в котором характеристическая функция определена как один из его методов,
а итератор также определен в виде класса, наследующего свойства
абстрактного итератора целых.
Мы можем задать абстрактный тип данных "множество неотрицательных
целых чисел" заданием абстрактного класса, в котором два упомянутых метода
определены как чистые функции, например, так:
class IntSet {
public :
virtual bool contains(int n) const = 0;
414
Глава 8
virtual Iterator<int> * elements() = 0;
virtual -IntSet() {};
};
На самом деле, даже не зная, как определена характеристическая функция
множества, мы уже можем реализовать простой итератор его элементов.
Такой итератор может перебирать все неотрицательные целые числа по
очереди, проверяя каждое из них на принадлежность множеству с помощью его
характеристической функции. Очередное выдаваемое итератором
значение — это число, на котором характеристическая функция выдала значение
true.
Конечно, такое определение итератора обладает рядом существенных
недостатков: низкой эффективностью работы из-за того, что перебираются все
числа подряд, невозможностью проверки, остались ли в множестве еще
элементы для итерации (фактически нам не удастся реализовать один из
основных методов итератора— hasMoreEiements), и т. п. Однако у нас есть и
некоторые оправдания. Во-первых, во многих случаях, когда не требуется
непременно перебирать все элементы множества, даже такой итератор вполне
может выполнять свои функции. Во-вторых, наше представление множеств,
в основном, предназначено для представления бесконечных множеств, а в
этом случае перебор всех элементов все равно невозможен.
Итак, оставим в виде чистой функции метод contains, а итератор множества
и, соответственно, метод elements реализуем некоторым универсальным
способом. Конечно, для каждого класса, представляющего конкретное
множество и являющегося наследником класса intset, можно будет переопределить
этот итератор. Новое определение класса intset и соответствующего
стандартного итератора приведено в листинге 8.1.
ilv.r.«...*»;;;uw.»^;rrt%f^^u^.u.... ;..„.,;. :л...„ &..««».' »..../»....«,..;..„.^.^л«£^ .«....«.«.„.л
// intset.h
class IntSet {
protected :
//
// Внутренний класс IntSetlterator реализует итератор
// элементов множества с помощью абстрактной функции contains.
//
class IntSetlterator : public Iterator<int> {
int currentElenient; // Текущий проверяемый элемент
IntSet * currentSet; // Итерируемое множество
Функции как носитель информации
415
public :
// Конструктор
IntSetlterator(IntSet * set)
: currentElement(0), currentSet(set) {}
// Функция hasMoreElements всегда возвращает true, т. к.
// множество предполагается бесконечным
bool hasMoreElements() const { return true; }
// Оператор * возвращает текущий элемент множества,
// если он есть. Если в множестве на самом деле нет больше
// элементов, то эта функция может зациклиться.
int operator *() {
findNext(); // Зацикливается, если элементов больше нет
return currentElement;
}
// Оператор ++ осуществляет переход к следующему элементу,
// если он есть. Если в множестве на самом деле нет больше
// элементов, то эта функция может зациклиться
Iterator<int> & operator ++() {
findNext(); // Зацикливается, если элементов больше нет
currentElement++;
return *this;
}
private :
// Функция пробует определить текущий элемент множества
void findNext();
};
public :
// Виртуальный деструктор требуется описать для того,
// чтобы правильно работали деструкторы всех наследников
// при уничтожении объектов, представляющих множества
virtual -IntSet() {};
// Операция contains - характеристическая функция множества.
// Она выдает значение true, если элемент п принадлежит
// множеству, и false — в противном случае.
virtual bool contains(int n) const = 0;
// Операция elements выдает итератор элементов множества.
// Поскольку множество потенциально бесконечно, причем
// проверить этот факт перебором всех элементов невозможно,
// то, как правило, метод hasMoreElements этого итератора
// будет всегда выдавать значение true.
416
Глава 8
virtual Iterator<int> * elements() { return new IntSetlterator(this); }
};
// intset.cpp
void IntSet::IntSetlterator::findNext() {
while (!currentSet->contains(currentElement)) {
currentElement++;
}
}
Конкретные множества целых можно описывать путем определения
специальных классов. Вот как, например, будут выглядеть определения двух
классов, задающих пустое множество и универсальное множество, содержащее
все неотрицательные целые числа:
class EmptySet : public IntSet {
public :
bool contains(int n) const { return false; }
};
class UniversalSet : public IntSet {
public :
bool contains(int n) const { return n >= 0; }
};
Достаточно очевидно, что характеристическая функция, которая всегда
выдает значение false, действительно представляет пустое множество, а функция,
выдающая true для всех неотрицательных аргументов, — универсальное
множество.
Теперь мы можем определить теоретико-множественные операции над
абстрактными множествами, чтобы с их помощью можно было получать новые
множества из уже имеющихся. Действительно, фактически мы можем
построить характеристическую функцию, скажем, объединения двух множеств,
даже не зная, какие именно множества объединяются. Достаточно знать, что
элемент п принадлежит объединению множеств si и s2, если он принадлежит
множеству si или множеству s2. Это значит, что характеристическая функция
объединения должна выдавать значение true на элементе п тогда и только
тогда, когда хотя бы одна из двух характеристических функций исходных
множеств si и s2 выдает true на этом элементе.
Если имеются ссылки на объединяемые множества, то тем самым имеется и
доступ к их характеристическим функциям. Опишем класс, представляющий
объединение двух имеющихся множеств. Объекты этого класса запоминают
ссылки на объединяемые множества при своем создании, а работа метода
Функции как носитель информации
417
contains класса основана на вызовах метода contains объединяемых
множеств. После того как такой класс будет описан, можно определить функцию,
которая строит объединение двух множеств, создавая новый объект
описанного класса.
class Disjunction : public IntSet {
const IntSet & si; // Первое из объединяемых множеств
const IntSet & s2; // Второе из объединяемых множеств
public :
// Конструктор запоминает объединяемые множества
Disjunction(const IntSet & si, const IntSet & s2) : si (si), s2(s2) {}
// Элемент содержится в объединении, если он содержится хотя бы
//в одном из объединяемых множеств
bool contains(int n) const {
return si.contains(n) || s2.contains(n);
}
};
IntSet * disjunct(const IntSet & si, const IntSet & s2) {
return new Disjunction(si, s2);
}
Совершенно аналогичным образом можно определить и прочие операции над
множествами, позволяющие добавлять в множество и удалять из множества
отдельные элементы, инвертировать множество и т. п. Достаточно богатый
набор операций над множествами приведен на приложенном компакт-диске в
файлах "intset.h" И "intset.cpp", расположенных В папке "Chapter8\8.1
\IntSet".
В приведенном фрагменте стоит обратить внимание на следующее. При
объединении множеств функции disjunct передаются ссылки на два исходных
константных множества. При этом подразумевается, что эти исходные
множества не будут впоследствии ни изменяться, ни уничтожаться, в противном
случае объединение множеств не сможет правильно работать. Таким
образом, придется следить за правильной последовательностью создания и
уничтожения объектов-множеств.
Функция disjunct, получив ссылки на исходные множества, создает новый
объект — объединенное множество. После того как работа с этим
множеством будет закончена, объект следует уничтожить с помощью оператора
delete. К сожалению, бывает трудно решить, когда объект действительно
можно уничтожить, поскольку он может участвовать в качестве операнда в
418
Глава 8
других операциях над множествами. Например, следующий вызов функции,
сделанный для объединения трех множеств, приведет к потере указателя на
промежуточное множество.
IntSet * set = disjunct(si, ^disjunct(s2, s3));
Впоследствии уничтожить это промежуточное множество прямым
применением оператора delete будет невозможным.
Ситуация, при которой трудно или даже нереально точно отследить момент,
когда для некоторого объекта нужно вызвать деструктор, встречается
достаточно часто. Чаще всего она возникает из-за того, что указатели на один и тот
же объект хранятся в разных местах, и поэтому невозможно решить, можно
ли удалять ставший ненужным объект, поскольку он может быть еще
доступным и нужным в других частях системы.
Одно из возможных решений— это использовать специально
спроектированную структуру, в которой хранится единственный указатель на
рассматриваемый объект и счетчик ссылок на этот объект. Если удастся аккуратно
отследить те моменты, когда на уже существующий объект должен
возникнуть новый указатель, или наоборот, указатель становится более не нужным,
то можно будет в этот момент увеличивать или, соответственно, уменьшать
значение счетчика ссылок. Момент, когда счетчик ссылок окажется равным
нулю — это то время, когда можно и нужно уничтожать сам объект.
Наиболее просто эта идея представима с помощью описания пары шаблонов,
один из которых реализует счетчик ссылок и владеет единственным
настоящим указателем на объект, а другой выступает в роли заместителя указателя
на объект и является инициатором всех изменений счетчиков ссылок. Оба
шаблона представлены в листинге 8.2.
j Листинг8.2. Реализация умнЬ1Хуказателей :^ : Й I
//
// Шаблон RefCount представляет пару из указателя и счетчика
// ссылок на этот указатель. Реализованы только непустые указатели
//
template <class Object>
class RefCount {
Object * obj; // Указатель на объект
int cnt; // Счетчик количества ссылок
public :
// Конструктор инициализирует счетчик ссылок,
// устанавливая его равным единице
RefCount(Object * о) : obj(о), cnt(l) {}
Функции как носитель информации 419
// Деструктору делать нечего, поскольку к моменту уничтожения объекта
// класса RefCount указуемый им объект уже должен быть уничтожен
-RefCount() {}
// Функция инкрементации просто увеличивает значение счетчика
void addRef() { ++cnt; }
// Функция освобождения не только уменьшает значение счетчика
// ссылок, но и уничтожает объект, если этот счетчик стал равен нулю.
void release() {
if (—cnt ==0) {
delete obj; // Уничтожается объект, на который велся учет ссылок
delete this; // Уничтожается и сам объект класса RefCount
}
}
// Доступ к объекту реализован в виде оператора приведения к типу
operator Object*() const { return obj; }
};
//
// Шаблон SmartPtr представляет умный указатель. Он организует
// промежуточный счетчик ссылок и работает с ним при выполнении
// операций присваивания и уничтожения указателя.
//
template <class Object>
class SmartPtr {
RefCount<Object> * ptr; // Промежуточный счетчик ссылок
public :
// Конструктор создает счетчик ссылок и запоминает указатель на объект
SmartPtr(Object * о) : ptr(new RefCount<Object>(о)) {}
// Конструктор копирования увеличивает счетчик ссылок
//в связи с появлением новой копии указателя
SmartPtr(const SmartPtr<Object> & р) : ptr(p.ptr) { ptr->addRef(); }
// Деструктор уменьшает значение счетчика ссылок
// (в результате объект может быть уничтожен)
-SmartPtr() { ptr->release(); }
// Оператор присваивания уменьшает значение одного счетчика
// ссылок и увеличивает значение другого счетчика. Объект, ссылка
//на который уничтожается, в результате тоже может быть уничтожен
SmartPtr & operator = (const SmartPtr<Object> & p) {
420
Глава 8
if (ptr != p.ptr) {
ptr->release (); p.ptr->addRef(); ptr = p.ptr;
}
return *this;
}
// Операторы, дающие доступ к объекту как через обычный указатель
operator Object*() { return (Object*)*ptr; }
Object & operator *() { return *(Object*)*ptr; }
const Object & operator *() const { return *(Object*)*ptr; }
Object * operator ->() { return (Object*)*ptr; }
Object * operator ->() const { return (Object*)*ptr; }
};
Прежде чем приведенную реализацию можно будет использовать в наших
задачах, ее нужно внимательно изучить. В первую очередь следует обратить
внимание на то, как и в какие моменты создаются и уничтожаются различные
объекты, используемые в этой реализации.
Создание умного указателя может происходить двумя способами,
реализованными двумя различными конструкторами. При первом способе помимо
самого умного указателя создается счетчик ссылок — объект класса Ref Count,
при этом значением счетчика становится единица. Если мы хотим
пользоваться умными указателями на объекты из некоторого множества, то
создавать эти объекты нужно только в момент вызова конструктора класса
SmartPtr.
Например, указатель на объект, представляющий пустое множество, следует
создавать с помощью описания:
SmartPtr<IntSet> pEmptySet(new EmptySet());
Описаний вида:
IntSet * pEmptySet = new EmptySet();
SmartPtr<IntSet> spEmptySet(pEmptySet);
следует избегать, поскольку наличие независимого указателя pEmptySet на
множество может впоследствии привести к ошибке, т. к. легко нечаянно
уничтожить объект повторно или, наоборот, пытаться работать с уже
уничтоженным объектом.
Вызов конструктора множества при инициализации умного указателя с
помощью оператора new также обязателен. Последовательность описаний:
IntSet emptySet;
SmartPtr<IntSet> spEmptySet(& emptySet);
Функции как носитель информации
421
заведомо приведет к ошибке, поскольку описанное пустое множество
emptySet будет уничтожаться в программе дважды: один раз при выходе из
области действия описания этой переменной, а другой раз при уничтожении
последней ссылки на множество из умного указателя.
Второй способ создания умного указателя— это создание его с помощью
конструктора копирования,, как, например, в следующей последовательности
описаний:
SmartPtr<IntSet> pEmptySetl(new EmptySet());
SmartPtr<IntSet> pEmptySet2 = pEmptySetl;
При описании переменной pEmptySet2 не создается ни нового указателя на
пустое множество, ни нового счетчика ссылок, однако значение
существующего счетчика ссылок увеличивается на единицу, и в новом экземпляре
умного указателя появляется новый указатель на уже созданный счетчик
ссылок — объект класса RefCount<IntSet>.
При уничтожении любого из двух экземпляров умных указателей значение
счетчика ссылок уменьшится на единицу, а когда будет уничтожен
последний экземпляр умного указателя, и счетчик ссылок окажется равным нулю,
произойдет уничтожение как самого множества, так и счетчика ссылок на
него.
Операции над умными указателями определены таким образом, чтобы с ними
можно было работать практически так же, как с обычными указателями.
В частности, определение операторов '*' и '->' позволяют нормально
использовать следующие конструкции:
if (pEmptySetl->contains(10)) {...
или:
if ((*pEmptySet2).contains(25)) {...
Оператор явного приведения к классу обычного указателя позволяет
использовать умные указатели и в том случае, когда, например, некоторая функция
требует передачи ей указателя на множество в качестве аргумента. Например,
если заголовок функции processSet имеет вид:
void processSet(IntSet * set);
то вызов этой функции вполне можно сделать, используя вместо обычного
умный указатель:
processSet(pEmptySetl);
Конечно, таким способом работы не следует злоупотреблять: не надо без
крайней нужды размножать указатели на объект. Вместо законного, но
опасного описания:
IntSet * pEmptySet3 = pEmptySet2;
422
Глава 8
Следует пользоваться описанием:
SmartPtr<IntSet> pEmptySet3 = pEmptySet2;
В свете вышесказанного сделаем несколько изменений в наших описаниях
способов работы с множествами. Для этого в описании класса Dus junction и
функции disjunct, с помощью которых реализуется операция объединения
множеств, заменим на умные указатели все ссылки и указатели на множества.
В результате получатся следующие описания:
class Disjunction : public IntSet {
SmartPtr<IntSet> si; // Первое из объединяемых множеств
SmartPtr<IntSet> s2; // Второе объединяемое множество
public :
// Конструктор запоминает объединяемые множества
Disjunction(const SmartPtr<IntSet> & si, const SmartPtr<IntSet> & s2)
: si (si), s2(s2) {}
// Элемент содержится в объединении, если он содержится хотя бы
//в одном из объединяемых множеств
bool contains(int n) const {
return sl->contains(n) || s2->contains(n);
}
};
SmartPtr<IntSet> disjunct(const SmartPtr<IntSet> & si,
const SmartPtr<IntSet> & s2) {
return SmartPtr<IntSet>(new Disjunction(si, s2));
}
Аналогичным образом определяются и другие операции над множествами,
такие как пересечение, дополнение, разность, добавление элементов и др.
Теперь мы определили достаточно богатый набор операций, чтобы
попробовать запрограммировать какую-нибудь задачу, в которой происходит
обработка бесконечных множеств. В качестве примера рассмотрим задачу о
нахождении множества всех простых чисел.
Для нахождения простых чисел будем использовать алгоритм, известный под
названием решето Эратосфена. Сначала возьмем множество всех целых
чисел, больших единицы. Затем будем последовательно выполнять шаги по
"просеиванию" этого множества. Возьмем наименьший элемент
множества — число 2 — и вычеркнем из множества все числа, кратные двойке, кроме
самого числа 2. После этого возьмем следующий оставшийся в множестве
элемент — число 3 — и вычеркнем все элементы, кратные 3 (опять кроме
самого числа 3), потом берем следующий элемент — число 5, и т. д. Множество
Функции как носитель информации
423
всех простых чисел получается после выполнения бесконечного числа таких
шагов.
Конечно, мы не сможем выполнить бесконечное множество шагов, так что на
самом деле мы получим не множество всех простых чисел, а лишь некоторое
его подмножество, например, множество, в котором все числа, меньшие
1000, — простые.
Сначала определим исходное множество целых чисел, больших единицы. Это
просто:
class From2 : public IntSet {
public :
bool contains(int n) const { return n >= 2; }
};
Теперь определим функцию, которая вычеркивает из заданного множества
все числа, кратные заданному целому числу. Это оказывается тоже не очень
сложно, надо лишь сначала определить класс с соответствующей
характеристической функцией множества:
class Filter : public IntSet {
int f; // Множитель, задающий вычеркиваемые числа
SmartPtr<IntSet> set; // Исходное множество
public :
// Конструктор запоминает в классе исходные данные:
Filter(int f, const SmartPtr<IntSet> & set) : f(f), set (set) {}
// Характеристическая функция результирующего множества:
bool contains(int n) const {
return n % f != 0 && set->contains(n);
}
};
// Собственно функция вычеркивания:
SmartPtr<IntSet> filter(int n, const SmartPtr<IntSet> & set) {
return SmartPtr<IntSet>(new Filter(n, set));
}
Теперь мы наконец готовы определить основную рекурсивную функцию —
функцию просеивания, которая и выдает множество всех простых чисел,
меньших заданного. Эта функция немного сложнее:
SmartPtr<IntSet> sieve(int max, const SmartPtr<IntSet> & set) {
int first = **set->elements();
SmartPtr<IntSet> result = set;
424
Глава 8
if (first * first <= max) {
result = add(sieve(max, filter(first, set)), first);
}
return result;
}
Определение этой функции построено на том факте, что если из множества
уже вычеркнуты все числа, кратные корню квадратному из числа шах, то все
простые числа, меньшие шах, уже содержатся в качестве первых элементов
множества. Если из определения функции выбросить проверку на
максимальное значение, то функция при вызове зациклится в попытке определить
все бесконечное множество простых чисел. Если же в множестве еще
остались составные числа, меньшие шах, то сначала выбирается первый элемент
множества (для этого использован итератор элементов множества) и с
помощью вызова функции filter из множества выкидываются все числа,
кратные ему. Потом функция просеивания рекурсивно применяется к множеству
из оставшихся элементов, и, наконец, первый элемент вновь добавляется
в получившееся множество с помощью функции add. Предполагается, что эта
функция добавляет в множество один элемент. Определение ее нетрудно
запрограммировать самому, или можно посмотреть, как она реализована на
приложенном компакт-диске.
Теперь можно определить множество всех простых чисел, меньших 100,
с помощью вызова:
SmartPtr<IntSet> primes = sieve(100, SmartPtr<IntSet>(new From2));
Распечатать все значения из получившегося множества можно с помощью
следующего фрагмента программы:
int n;
Iterator<int> * it = primes->elements();
while ((n = **it) < 100) {
cout « " " « n;
++*it;
}
cout « endl;
В результате исполнения этого фрагмента в выходном потоке получится
следующая строка:
2 3 5 7 11 13 17 19 23 29 31 37 41 43 47 53 59 61 67 71 73 79 83 89 97
т. е. будут напечатаны все простые числа, меньшие 100.
Надо сказать, что эффективность работы этой программы невысока.
Функции, которые строятся по ходу ее работы, оказываются очень сложными, по-
Функции как носитель информации
425
этому в конце концов выяснение того, принадлежит ли некоторый элемент
множеству простых чисел, выливается в довольно длительную
последовательность проверок, не делится ли данное число на 2, не делится ли оно на 3,
5, т. е. последовательно на все простые числа. Кроме того, нам по существу
оказались не нужны бесконечные множества, т. к. на самом деле работа
ведется только с числами, меньшими некоторого фиксированного значения
(в приведенном примере 100). В свое оправдание можем только сказать, что
простые числа размещены не очень-то плотно в множестве всех натуральных
чисел, так что память нам все-таки удастся сэкономить, если исходное
множество достаточно велико. Например, среди первых 1000 чисел имеется всего
168 простых, причем для их порождения нам пришлось заказать память всего
для 23 объектов класса intset.
Несмотря на перечисленные недостатки, программа все-таки довольно
изящная и хорошо показывает, как сложные функционально заданные объекты
строятся из более простых.
Описанные множества целых несложно обобщить, заменив класс intset
шаблоном классов, в котором тип элементов множества будет параметром
шаблона. В этом случае удастся строить множества не только из таких простых
элементов, как целые числа, но и из элементов произвольной сложности.
В следующем разделе этой главы приведены еще несколько задач, в которых
схожие методы применяются для построения объектов, напоминающих
рассмотренные нами множества.
8.2. Задача о расстановке ферзей
на шахматной доске и другие задачи
Рассмотрим хорошо известную задачу о нахождении расстановки 8 ферзей на
стандартной шахматной доске так, чтобы никакие два ферзя не атаковали
друг друга. Под стандартной шахматной доской понимается квадратная доска
размером 8x8 клеток, в каждой клетке которой может располагаться одна
фигура (ферзь). Считается, что два ферзя атакуют друг друга, если они
расположены на одной вертикали, одной горизонтали или одной диагонали.
Существует несколько модификаций этой задачи. Согласно одной из них,
надо найти всевозможные правильные расстановки ферзей, причем
расстановки считаются совпадающими, если они получаются друг из друга
поворотом доски или ее зеркальным отражением. Известно, что таких позиций всего
92. Другая модификация требует лишь определить, существует ли такая
расстановка, и если существует, то найти одну из них. Любую из этих подзадач
можно также обобщить на случай п ферзей на доске размером п х п клеток.
426
Глава 8
Традиционно эта задача решается с помощью определения рекурсивной
функции, основная идея которой состоит в том, чтобы свести задачу о
расстановке ферзей на доске размером п х п клеток к задаче о расстановке
ферзей на доске меньшего размера. Рассмотрим сначала традиционные решения
задачи, причем будем искать только одну произвольную правильную
расстановку ферзей.
Прежде всего необходимо решить, как представлять в программе позиции на
шахматной доске с расставленными на ней ферзями. В традиционном
решении это представление в виде массива, содержащего информацию о позициях
ферзей, причем, поскольку никакие два ферзя в правильной расстановке не
могут находиться на одной горизонтали, то достаточно хранить для каждой
горизонтали только номер вертикали, на которой стоит ферзь. Понятно, что в
правильной расстановке все восемь чисел, представляющие номера
вертикалей в массиве, будут различны, таким образом, любая правильная
расстановка 8 ферзей на шахматной доске может быть представлена перестановкой
набора натуральных чисел от 1 до 8.
На рис. 8.1 показано представление некоторой правильной позиции в виде
массива номеров вертикалей, а рядом изображена соответствующая этому
представлению расстановка ферзей.
1 1
I 5
I 8
I 6
I 3
I 7
I 2
I 4
*
ш
l£ft
ЩВ&
щц
щщ
ш
•V*#
щ
щ
ш
#
^'"'ДН:'
япя
ш
ж
ш
ШПШШ
щ
Щ
*-slV"I
Т'. -' 1 1
$~щ
Рис. 8.1. Представление расстановки ферзей в виде массива
Самое простое решение задачи, очевидно, состоит в том, чтобы исследовать
все перестановки набора чисел от 1 до 8, причем для каждой из них
необходимо проверить, представляет ли она правильную расстановку ферзей. В
любой перестановке, очевидно, выполнены условия о том, что никакие два
ферзя не находятся на одной вертикали (поскольку все числа в перестановке
различны) и не находятся на одной горизонтали (просто по способу
представления позиции). Таким образом, необходимо лишь проверить, не
находятся ли два ферзя на одной диагонали. Если массив pos типа int[8]
представляет некоторую перестановку целых чисел 1, 2, ... 8, то в соответствую-
Функции как носитель информации
427
щей расстановке ферзей два ферзя с номерами / и у будут находиться на
одной диагонали, если abs (pos [i] - pos [ j ]) == abs (i - j). Отсюда можно
сразу же получить простую программу, приведенную в листинге 8.3. Заметим,
что на самом деле мы решаем общую задачу — нахождение правильной
расстановки п ферзей на доске размером п х п.
В этой программе сначала берется массив из п чисел 1, 2, ... и, а затем
программа последовательно переставляет числа в этом массиве, проверяя
каждый раз, не получилась ли правильная расстановка ферзей. Проверку позиции
производит функция correct, основную работу по генерации перестановок
выполняет рекурсивная функция recQueen, и наконец, функция queen
инициирует работу.
|Пйстйнг 8.3. Простая программа поиска расстановки ферзей - -
! на шахматной доске ■;./- ,}.у:\\:.;:^к '' >""у'-: [' --\ •'/ С :kl \
// Функция correct проверяет, представляет ли заданная перестановка чисел
// от 1 до п корректную расстановку ферзей на шахматной доске
bool correct(int * pos, int n) {
// Перебираем всевозможные пары двух различных ферзей
for (int i = 0; i < n; i++) {
for (int j = i+1; j < n; j++) {
if (abs(i-j) = abs(pos[i]-pos[j]))
// Ферзи стоят на одной диагонали
return false;
}
}
// Проверка завершена успешно, ни одной пары ферзей,
// стоящих на одной диагонали, не найдено.
return true;
}
// Функция recQueen находит правильную расстановку ферзей на доске
// размером n x n при условии, что первые к ферзей уже расставлены,
// так что переставлять можно только числа, находящиеся в элементах
//с индексами, большими к. Если такая расстановка существует, то функция
// выдает ее в качестве результата. Если расстановки с такими начальными
// данными не существует, функция выдает NULL.
int * recQueen(int * pos, int k, int n) {
// проверяем расстановку на корректность
if (!correct(pos, k)) return NULL;
// проверяем, не расставлены ли уже все ферзи?
if (к == п) {
return pos;
}
428
Глава 8
// находим всевозможные перестановки элементов с индексами
// от к до п-1
for (int i = к; i < n; i++) {
// Переставляем местами i-й и к-й элементы
int с = pos[k]; pos[k] = pos[i]; pos[i] = с;
// Пытаемся найти расстановку с фиксированными к+1 ферзями
int * per = recQueen(pos, k+1, n);
if (per) return per;
}
// Ничего не найдено
return NULL;
}
// Главная функция поиска расстановки п ферзей на доске размером n x n
int * queen(int n) {
int * pos = new int[n];
// Формирование начальной расстановки
for (int i = 0; i < n; i++) {
pos[i] = i+1;
}
// Вызов основной рекурсивной функции
return recQueen(pos, 0, n);
}
Теперь, имея эту программу, мы можем находить правильные расстановки
ферзей на досках разного размера и печатать их в удобном для человека виде.
Например, распечатка правильной расстановки восьми ферзей может быть
получена следующим образом:
int main () {
const int POS_LEN = 8;
int * pos = queen(POS_LEN);
if (pos) {
// Распечатываем найденную позицию в удобном виде
// Это цикл по всем горизонталям, начиная с первой
for (int i = 0; i < POS_LEN; i++) {
// Печать одной горизонтали:
int queenPos = pos[i];
for (int k = 1; k < queenPos; k++) {
cout « ". ";
}
cout « "Q ";
for (int k = queenPos+1; k <= POS_LEN; k++) {
cout « ". ";
}
Функции как носитель информации
429
cout « endl;
}
} else {
cout « "No correct position found!" « endl;
return 0;
}
Убедиться, что наша программа работает правильно, можно проверив
визуально результаты ее работы:
Q . .
. . Q
. Q .
. . . (
Q . . .
. . . Q
. Q . .
. . Q .
2 . . . .
Только что приведенное решение не самое эффективное. В нем при попытке
добавления очередного ферзя на доску проверка корректности расстановки
производится снова для всех расставленных ферзей. На самом деле есть
смысл проверять только, что вновь поставленный ферзь не атакует ни одного
из ранее поставленных ферзей. Эта идея сразу же приводит нас к похожей
программе, показанной в листинге 8.4, которая, однако, работает быстрее
первого варианта.
| Листинг 8.4. Модифицированная программа нахождения {
j правильной расстановки восьми ферзей на шахматной доске
// Функция recQueen находит правильную расстановку ферзей на доске
// размером n x n при условии, что первые к ферзей уже расставлены и
// друг друга не атакуют, так что расставлять нужно только ферзей
//с номерами, большими к. Если такая расстановка существует, то функция
// выдает ее в качестве результата. Если расстановки с такими начальными
// данными не существует, функция выдает NULL.
int * recQueen(int * pos, int k, int n) {
if (k == n) return pos; // Все ферзи уже расставлены корректно
for (int j = 1; j <= n; j++) {
// Пробуем поставить k+1-го ферзя последовательно на все вертикали
bool correct = true; // Признак корректной расстановки
for (int i = 0; i < k; i++) {
430
Глава 8
// Проверяем, не атакует ли новый ферзь уже поставленных
if (pos[i] == j || k - i == abs(j - pos[i])) {
// Если новый ферзь находится на одной вертикали или диагонали
//с некоторым i-м ферзем, то расстановка некорректна
correct = false;
break;
}
}
if (correct) {
pos[k] = j;
// Установили ферзя в свою позицию и пробуем расставить
// остальных ферзей с помощью рекурсивного вызова функции
int * posl = recQueen(pos, k+1, n);
if (posl) // Расстановка найдена!
return posl;
}
}
// Перебрали все варианты постановки k+1-го ферзя, но ничего не нашли!
return NULL;
}
Этот вариант программы выдает тот же самый результат, но работает
несколько быстрее. Так, на моем компьютере вариант расстановки ферзей на
доске 28x28 был получен за 9 секунд, в то время как программа, написанная
по первому варианту, работала целых 24 секунды, чтобы получить тот же
результат.
Теперь рассмотрим еще один вариант решения, из-за которого, собственно,
мы и начали весь этот разговор. В этом варианте речь идет о совершенно
ином способе представления позиции на шахматной доске. В
действительности нам совсем не обязательно иметь физическую информацию о том, где
конкретно расположен тот или иной ферзь. Эта информация нужна нам
только из-за:
1. Необходимости проверять, можно ли поставить очередного ферзя в
некоторую позицию (z,y).
2. Необходимости выводить информацию о расстановке ферзей в конце
работы.
Таким образом, на самом деле можно представлять позицию с помощью пары
функций: функции проверки возможности установки ферзя в позицию (/,у) и
функции распечатки позиции. Соответствующий (абстрактный) тип данных
может быть представлен следующим классом:
Функции как носитель информации
431
class Position {
public :
virtual ~Position() {}
virtual bool permits(int i, int j) const = 0;
virtual ostream & print(ostream & os) const = 0;
};
В этом интерфейсе функция permits представляет функцию проверки
возможности установки нового ферзя в позицию, а функция print — функцию
вывода позиции в выходной поток. Для удобства вывода позиции в выходной
поток можно также перегрузить оператор вывода следующим образом:
ostream & operator « (ostream & os, const Position & pos) {
return pos.print(os);
}
Теперь каждая конкретная позиция может быть представлена классом,
наследующим классу Position. Например, пустая позиция (позиция, на которой нет
ни одного ферзя) может быть представлена следующим классом:
class EmptyPosition : public Position {
public :
bool permits(int i, int j) const { return true; }
ostream & print(ostream & ps) const { return os « endl; }
};
Разумеется, это пример очень простой позиции — в ней нового ферзя можно
поставить в любую клетку, а при ее выводе просто ничего не печатается.
Однако и любая другая позиция будет представлена тоже в виде двух функций,
т. е. никаких массивов для представления позиции не требуется.
Пустая позиция может служить основой для построения другой, более
сложной позиции. Вообще, если уже имеется некоторая позиция, представленная
переменной р класса Position, и заданы координаты row и col положения
некоторого ферзя, то можно сформировать новую позицию, которая совпадает
с имеющейся позицией р, но в которой, кроме того, имеется еще один ферзь в
клетке с дополнительно заданными координатами (row, col). Мы будем
считать, что новый ферзь всегда добавляется на новую горизонталь, т. е. если
позиция нового ферзя имеет координаты (row, col), то в исходной позиции
имелся всего (row - 1) ферзь, расставленные в первых (row - 1)
горизонталях доски, а вновь поставленный ферзь становится единственным ферзем на
новой горизонтали с номером row. Для правильного вывода позиции в
выходной поток необходимо задать еще размерность доски maxQueen, и тогда класс,
описывающий процесс формирования новой позиции на основе уже
имеющейся, может быть описан следующим образом (обратите внимание на
использование умных указателей):
432
Глава 8
//
// Класс для представления позиции, созданной на основе уже
// имеющейся позиции с добавлением нового ферзя в позицию (row, col)
//
class NewPosition : public Position {
SmartPtr<Position> position; // Ссылка на старую позицию
int col, row; // Координаты нового ферзя
int maxQueens; // Размер доски
public :
// Конструктор получает данные для построения новой позиции
NewPosition(const SmartPtr<Position> & position,
int col, int row, int maxQueens)
: position(position), col(col), row(row), maxQueens(maxQueens) {}
// Функция проверки того, можно ли поставить
// еще одного ферзя в позицию (i, j).
bool permits(int i, int j) const {
// Проверяем (а) совместимость с прошлой позицией;
// (b) несовпадение номера вертикали с новой координатой j;
// (с) ненахождение проверяемого ферзя (i, j)
// на одной диагонали с новым (row, col)
return position->permits(i, j) && j != col &&
abs(i - row) != abs(j - col);
}
// Функция распечатки новой позиции
ostream & print(ostream & ps) const {
position->print(ps); // печать старой позиции
// Печатаем новую горизонталь
for (int i = 1; i < col; i++) { ps « ". "; }
ps « "Q ";
for (int i = col+1; i <= maxQueens; i++) { ps « ". "; }
ps « endl;
}
};
Теперь уже понятно, как можно построить некоторую сложную позицию,
постепенно наращивая имеющуюся простую. В решении, представленном в
листинге 8.5, класс NewPosition используется для получения новой позиции
из уже имеющейся добавлением еще одного ферзя. Функция recQueen2
проверяет, можно ли в некоторой позиции добавить одного ферзя на новую
горизонталь. Если некоторая возможная позиция находится, то формируется
новая позиция, и функция вызывается рекурсивно до тех пор, пока не будет по-
Функции как носитель информации
433
лучена позиция со всеми имеющимися в наличии горизонталями. По
существу, новый алгоритм повторяет ту же схему, которая использовалась нами в
алгоритмах листингов 8.3 и 8.4, разница состоит только в способе
представления позиции и, соответственно, в способе установки нового ферзя в уже
имеющуюся.
| Листинг 8.5. Расстановка ферзей с помощью <
[функционального представления позиции \г,
/7 Рекурсивная функция, получающая новую правильную позицию из старой
//с помощью добавления нового ферзя на следующую горизонталь.
// Аргумент row задает количество уже расставленных ферзей
// (заполненных горизонталей)
SmartPtr<Position> recQueen2(const SmartPtr<Position> & position,
int row, int maxQueens) {
if (row == maxQueens) {
// Уже расставлены все ферзи
return position;
} else {
// Пытаемся поставить нового ферзя в ряд с номером row
for (int col = 1; col <= maxQueens; col++) {
if (position->permits(row, col)) {
// Новый ферзь не атакует ни одного из уже расставленных.
// Формируем новую позицию и делаем рекурсивный вызов
SmartPtr<Position> nextPos(
new NewPosition(position, col, row, maxQueens));
SmartPtr<Position> newPos =
recQueen2(nextPos, row + 1, maxQueens);
// Если удалось обнаружить правильную позицию с таким ферзем
if (newPos) return newPos;
}
}
//He удалось поставить нового ферзя ни на одну вертикаль
return SmartPtr<Position>(NULL);
}
}
SmartPtr<Position> queen2(int n) {
// В начале работы позиция пуста и ни одного ферзя еще не расставлено
return recQueen2(SmartPtr<Position>(new EmptyPositionO), 0, n);
}
Новая функция нахождения расстановки работает медленнее обоих
предыдущих вариантов. Так, например, на моей машине расстановку ферзей на
434
Глава 8
доске 28x28 функциональный вариант программы нашел только за 48 секунд,
в то время как предыдущие версии справилась с этой работой за 24 и
9 секунд соответственно. Памяти новый вариант программы, несмотря на
отсутствие массивов в представлении позиции, тоже требует несколько больше,
поскольку представление позиции в виде массива целых чисел достаточно
компактно. Кроме того, удается в процессе работы функции один и тот же
массив использовать для хранения разных позиций. Таким образом, наш
последний вариант программы служит не столько для практического
применения, сколько для иллюстрации принципа функционального представления
информации.
Конечно, этот принцип применим не только к задаче о расстановке ферзей и
работе с бесконечными множествами. Почти в любой задаче наряду с
объектным представлением существует и параллельный вариант решения с
функциональным представлением информации. Вопрос лишь в том, какое
представление более выгодно и естественно для данной задачи. Конечно,
необходимо правильно определить набор функций, адекватно представляющий
нужную информацию. Для случая работы с бесконечными множествами
функциональное представление помогло в таком случае, когда физическое
представление элементов множества может быть вообще неприменимо.
В качестве еще одного примера рассмотрим классическую задачу о
назначениях, несколько напоминающую задачу о расстановке ферзей.
Пусть имеется п рабочих мест и п работников, каждый из которых может
выполнять любую работу, но производительность труда у каждого рабочего на
разных рабочих местах различна. Неотрицательная величина
производительности труда работников задается функцией, которая по заданным номеру
работника и номеру рабочего места выдает вещественное число,
характеризующее производительность труда работника на заданном рабочем месте. Мы
зададим тип этой функции f Prod с помощью следующего определения типа
данных:
typedef double (*fProd) (int, int);
Требуется расставить рабочих по рабочим местам так, чтобы их суммарная
производительность на всех рабочих местах была максимальной.
Так же, как и в случае расстановки ферзей, одно из возможных
представлений расстановки рабочих — это вектор длины я, в котором /-й элемент
представляет порядковый номер рабочего, поставленного на /'-е рабочее место.
Ясно, что если этот вектор обозначить Р, то все значения P[i] должны быть
различны, поскольку никакого рабочего нельзя поставить сразу на два
рабочих места. При таком представлении данных можно построить решение
задачи в виде рекурсивной функции, несколько напоминающей второй вариант
решения задачи о расстановке ферзей.
Функции как носитель информации
435
Другое возможное представление данных — функциональное. Прежде всего
надо решить, какие функции могут адекватно представлять расстановку
некоторого количества к рабочих по имеющимся п рабочим местам. Назовем
рангом этой расстановки число расставленных работников к, а ее
производительностью — суммарную производительность труда расставленных
работниках на тех рабочих местах, на которых они находятся в этой расстановке.
Все, что нам нужно знать о каждой расстановке, — это ее
производительность, информация о том, свободен ли некоторый /-й рабочий (если ранг
расстановки меньше п) и способ вывода расстановки в качестве результата в
выходной поток. Таким образом, как и в случае задачи о ферзях на шахматной
доске, тип данных, представляющий некоторую расстановку рабочих по
рабочим местам, может быть записан в виде следующего абстрактного класса:
class Assignment {
protected :
fProd output; // Функция, задающая производительность труда
// Базовый конструктор
Assignment(fProd output) : output(output) {}
public :
// Виртуальный деструктор для правильного освобождения памяти
virtual ~Assignment() {}
// Доступ к функции, задающей производительность труда
fProd getOutput() const { return output; }
// Вычисление суммарной производительности расстановки
virtual double prod() const = 0;
// Проверка, свободен ли рабочий с заданным номером i
virtual bool free(int i) const = 0;
// Вывод информации о расстановке рабочих в выходной поток
virtual void print(ostream & os) const = 0;
};
Конечно, для того чтобы задать некоторую конкретную расстановку, надо
задать конкретную функцию производительности труда и переопределить
абстрактные виртуальные методы класса. Например, как и в случае с
ферзями, можно задать пустую расстановку, в которой ни одно рабочее место не
занято, а все работники пока свободны:
class EmptyAssignment : public Assignment {
public :
// Конструктор
EmptyAssignment(fProd output) : Assignment(output) {}
436
Глава 8
// Вычисление суммарной производительности расстановки
double prod() const { return 0; }
// Проверка, свободен ли рабочий с заданным номером i
bool free(int i) const { return true; }
// Вывод информации о расстановке рабочих в выходной поток
void print(ostream & os) const {}
};
Снова видно, что для физического представления информации о расстановке
нам не требуется хранить информацию о номерах задействованных в
расстановке и свободных работников или о номерах занятых рабочих мест.
Теперь определим способ, с помощью которого можно по заданной
расстановке, в которой уже сделаны к назначений работников на первые к рабочих
мест, получить новую расстановку путем назначения нового работника на
(£+1)-е рабочее место. Это можно сделать с помощью описания нового
класса, в объектах которого будут храниться ссылка на базовую расстановку к
работников, рабочее место и номер нового работника, и в котором можно
соответствующим образом переопределить все интерфейсные функции. Как и
в предыдущих примерах, используем аппарат умных указателей для того,
чтобы не заботиться о своевременном освобождении памяти.
class NewAssignment : public Assignment {
int wr; // Номер нового рабочего
int wp; // Номер его рабочего места
SmartPtr<Assignnient> oldAssign; // Базовая расстановка
public :
// Конструктор
NewAssignment (int wr, int wp, const SmartPtr<Assignment> & oldAssign)
: Assignment(oldAssign->getOutput()),
wr(wr), wp(wp), oldAssign(oldAssign) {}
// К базовой производительности труда добавляется производительность
// труда рабочего wr на рабочем месте wp.
double prod() const {
return oldAssign->prod() + output(wp, wr);
}
// Свободны все те, кто был свободен раньше, кроме рабочего wr.
bool free(int i) const {
return i != wr && oldAssign->free(i);
}
Функции как носитель информации 437
//К выводу базовой расстановки добавляется информация о том,
// что рабочее место wp занято работником номер wr.
void print(ostream & os) const {
oldAssign->print(os);
os « "Place number: " « wp « "; worker number: " « wr « endl;
}
};
Теперь уже нетрудно написать рекурсивную функцию, которая будет
последовательно строить расстановки работников, выбирая среди них ту, которая
имеет наилучшую общую производительность труда. Каждая новая
расстановка (к +1) работника получается, если к некоторой расстановке к
работников добавляется один новый работник, в результате чего получается новый
объект класса NewAssignment. Полный текст функции с комментариями
приведен в листинге 8.6.
| Листинг 8.6. Решение задачи о назначениях
//
// Рекурсивная функция recAssignment строит и выдает лучшую
// расстановку maxWorkers работников при условии, что busy рабочих мест
// уже заняты, и эта расстановка определяется аргументом assign функции.
//
SmartPtг<Assignments recAssignment (
const SmartPtr<Assignment> assign, int busy, int maxWorkers) {
if (busy == maxWorkers) {
// Все рабочие уже расставлены, можно выдавать
// указатель на полученную расстановку работников.
return assign;
} else {
// Максимальная достигнутая производительность
double maxAchieved = -1;
// Лучшая расстановка, при которой была достигнута
// эта максимальная производительность
SmartPtr<Assignment> bestAchieved;
// Цикл перебора оставшихся рабочих - кандидатов на новое место
for (int num = 0; num < maxWorkers; num++) {
if (assign->free(num)) {
// Найден очередной свободный работник, пробуем ставить его на
// место busy+1 и вычислить наилучшую в этих условиях расстановку
SmartPtr<Assignment> next(new NewAssignment(num, busy, assign));
SmartPtr<Assignment> newAssign = recAssignment(
next, busy+1, maxWorkers);
438
Глава 8
/7 Проверяем достигнутую производительность:
double curAchieved = newAssign->prod();
if (curAchieved > maxAchieved) {
// Получили что-то лучшее, чем было
maxAchieved = curAchieved;
best Achieved = newAssign;
}
}
}
// Результат - лучшая из достигнутых расстановок
return bestAchieved;
}
}
// Функция, вычисляющая лучшую расстановку рабочих
SmartPtr<Assignment> best Assignment (fProd output, int n) {
SmartPtr<Assignment> basePtr(new EmptyAssignment (output)) ;
return recAssignment(basePtr, 0, n);
}
В приведенном решении программа перебирает всевозможные перестановки,
и поэтому она не слишком эффективна. Уже при 20 рабочих дождаться, пока
она вычислит лучшую расстановку, становится довольно затруднительно.
Некоторого ускорения работы можно достичь, если заранее вычислить
максимальную производительность для каждого рабочего. Тогда можно при
каждом очередном назначении грубо оценивать максимальную
производительность, которую в принципе можно достигнуть при расстановке
оставшихся рабочих. Если окажется, что эта максимальная производительность не
лучше, чем уже достигнутая, то не стоит и стараться.
При таком подходе становится существенным порядок, в котором находятся
расстановки. Если попытаться жадным алгоритмом сразу же найти
приемлемую расстановку, то возможно, что многие варианты будут отсечены на
ранних стадиях работы алгоритма. Конечно, многое зависит и от того, насколько
удачно будет найдена первая расстановка, и от того, насколько сильно
отличается производительность труда разных работников на разных рабочих
местах.
Так, если производительность труда распределена среди работников
довольно ровно, то существенного ускорения работы мы не получим. Наоборот,
усложнение алгоритма может привести даже к некоторому замедлению работы.
С другой стороны, если производительность труда может отличаться
довольно сильно, то наш метод позволит быстро отсечь заведомо неудачные
расстановки.
Функции как носитель информации
439
На приложенном к книге компакт-диске в папке "Chapter8\8.2\workers"
можно найти полный текст описанного решения и его применение к
конкретному случаю заданной производительности труда работников. Тестовый
пример пропускается для двух случаев: "мягкого", в котором работники
имеют мало отличающуюся друг от друга производительность труда на рабочих
местах, и "острого", в котором разные работники работают на одном и том же
рабочем месте с существенно отличающейся производительностью труда.
Подсчитывается время поиска решения в секундах для обоих случаев. В
папке "Chapter8\8.2\Optworkers" имеется оптимизированный вариант решения,
в котором описанным выше методом производятся отсечения заведомо
бесперспективных расстановок. В остром случае решение получается гораздо
быстрее, для мягкого же варианта оптимизированная программа работает
даже медленнее исходной версии.
При увеличении количества работников время нахождения решения растет в
любом случае очень быстро. Это говорит о том, что использованный нами
переборный вариант решения практического значения не имеет. На самом
деле поставленную задачу следует решать совершенно другими методами,
используя приемы линейного программирования.
Рассмотрим еще одну задачу, в которой используется функциональное
представление множества слов. Задача заимствована из книги [9] и использует
понятия и термины теории формальных грамматик. Опишем коротко
некоторые понятия, используемые нами для формулировки и решения этой
задачи.
Языком называется некоторое (конечное или бесконечное) множество слов,
каждое из которых состоит из конечной последовательности букв некоторого
фиксированного конечного алфавита. Мы будем использовать способ
задания языка с помощью так называемых регулярных выражений. Регулярные
выражения могут быть простыми или сложными, при этом более сложные
регулярные выражения строятся из более простых с помощью трех операций:
катенации, альтернации и итерации.
Простейшее регулярное выражение представлено одним словом. Такое
регулярное выражение описывает язык, который содержит только это слово.
Операция альтернации позволяет по заданным регулярным выражениям R1 и
R2 получить новое регулярное выражение, которое записывается в виде
(R11R2). Если исходные регулярные выражения задавали языки (множества
слов) Ы и 12, то их альтернация задает объединение языков Ы u L2. В
частности, с помощью операции альтернации в принципе можно получить любой
язык, состоящий из конечного числа слов.
Операция катенации регулярных выражений более сложная. Она основана на
операции катенации (соединения) слов, при которой из двух заданных слов
440
Глава 8
wl и w2 получается новое слово w = wl • w2 с помощью простого
приписывания одного слова к другому. Каждое из слов, участвующих в катенации,
может быть и пустым (не содержащим ни одной буквы), тогда результатом
катенации будет просто другое слово. Теперь мы готовы определить
операцию катенации над регулярными выражениями. Если выражения R1 и R2
задают языки L1 и Z2, то выражение (Rl & R2\ полученное с помощью
операции катенации '&', будет задавать язык Z, слова которого получены катена-
цией всевозможных слов языка L1 со всевозможными словами языка L2.
Пусть, например, имеются регулярные выражения i?/=("no" | "ки") и
&2=("ла" | "ка"), задающие языки, состоящие каждое из двух слов,
составленных из букв кириллического алфавита. Тогда их катенация (Rl & R2) будет
задавать язык, состоящий из четырех слов: {"пола", "пока", "кила", "кика"}.
Операция итерации '*' определяется следующим образом. Пусть регулярное
выражение R задает язык L. Тогда выражение (i?*) также является
регулярным выражением и задает язык, содержащий слова, которые можно разбить
на части, каждая из которых принадлежит языку L. В контексте примера из
предыдущего абзаца выражение (R1 *) задает бесконечный язык, содержащий
такие слова, как "по", "попо", "поки", "кикипо" и т. д., т. е. слова, состоящие
из частей "по" и "ки".
Введенных понятий нам хватит для описания следующей задачи. В ней
требуется проверить, принадлежит ли заданное слово языку, заданному
регулярным выражением.
Решение построим следующим образом. Сначала определим представление
для языков. Поскольку множества слов могут быть как конечными, так и
бесконечными, будем представлять эти множества так же, как мы это делали для
множеств неотрицательных целых чисел. Абстрактный класс Lang будет
представлять абстрактный тип данных язык с помощью интерфейсной
функции contains, проверяющей, принадлежит ли заданное слово этому языку.
В этой задаче нам не потребуются итераторы слов языка, поскольку не
предполагается перебор слов языка, однако операции над языками будут
определяться не набором теоретико-множественных операций, а специфическими
операциями над соответствующими регулярными выражениями.
Итак, класс Lang описывается так, как показано ниже:
class Lang {
public :
virtual ~Lang() {}
virtual bool contains(const string & word) const = 0;
};
Над языками определим операторы, соответствующие операциям над
регулярными выражениями, только вместо обычно применяемого знака операции
Функции как носитель информации
441
итерации '*' будем использовать знак унарной операции '-'. Сразу же вместо
обычных указателей будем использовать определенные нами в предыдущем
разделе умные указатели, что позволит нам не заботиться о своевременном
освобождении памяти.
SmartPtr<Lang> operator | (const SmartPtr<Lang> & langl,
const SmartPtr<Lang> & lang2);
SmartPtr<Lang> operator & (const SmartPtr<Lang> & langl,
const SmartPtr<Lang> & lang2);
SmartPtr<Lang> operator - (const SmartPtr<Lang> & lang);
Каждый из этих операторов порождает новый язык по ссылкам на ранее
построенные.
Кроме этих операторов можно задать еще несколько полезных операций над
языками, которые мы будем использовать в этой задаче. Среди таких
операций будут как вполне естественные, такие как добавление одного слова в
заданный язык (при этом, разумеется, получается новый язык) или удаление
слова из языка, так и не очень естественные, но полезные для данной задачи.
Одной из таких операций будет вычленение префикса. Ее мы будем
использовать в качестве вспомогательной при определении операции катенации.
В качестве операндов для этой операции задается исходный язык и некоторое
слово — префикс. В результате получается язык, содержащий только такие
слова, при добавлении к каждому из которых заданного префикса полученное
слово будет содержаться в исходном языке. Эту операцию мы определим
с помощью такого описания:
SmartPtr<Lang> operator A (const SmartPtr<Lang> & lang,
const string & prefix);
Например, если исходный язык L состоял из слов {"полка", "пола", "кашпо",
"по", "пополам", "каша"}, то язык, представленный выражением L Л "по",
будет содержать слова {"лка", "ла","", "полам"}.
Каждая из операций над языками будет реализована с помощью определения
подходящего класса, в котором функция contains будет определена так,
чтобы задавать нужное множество слов на базе значений операндов операции.
Простейшие регулярные выражения, состоящие из единственного слова,
можно задать с помощью такого описания класса:
class OneWordLang : public Lang {
string word; // Слово, представляющее язык
public :
// Конструктор языка запоминает слово
OneWordLang(const string & word) : word(word) {}
442
Глава 8
II Функция проверки принадлежности просто сравнивает слово с эталоном
bool contains(const string & w) const { return w = word; }
>;
Язык, задаваемый классом oneWordLang, содержит свое единственное слово в
виде атрибута класса, так что соответствующее представление следует
считать не столько функциональным, сколько традиционным.
В листинге 8.7 представлены описания классов, которые можно использовать
для построения новых языков с помощью операций над регулярными
выражениями, и функции, реализующие эти операции с помощью обращения к
конструкторам соответствующих классов. Реализация операции альтернации
совсем простая и в точности соответствует описанной в разд. 8.1 операции
объединения множеств. Операции катенации и итерации сложнее, читателям
рекомендуется внимательно разобраться, как они работают.
// Определения новых классов:
//
// Класс PlusWordLang добавляет в заданный язык заданное слово.
//
class PlusWordLang : public Lang {
SmartPtr<Lang> lang; // Ссылка на исходный язык
string word; // Добавленное слово
public :
// Конструктор просто запоминает параметры
PlusWordLang(const SmartPtr<Lang> & lang, const string & word)
: lang(lang), word(word) {}
// Функция проверки принадлежности: заданное слово должно
// совпадать с добавленным или принадлежать исходному языку,
bool contains(const string & w) const {
return w == word || lang->contains(w);
}
};
//
// Класс AltLang определяет альтернацию (объединение) двух языков
//
class AltLang : public Lang {
Функции как носитель информации
443
SmartPtr<Lang> langl,
lang2; // Исходные языки
public :
// Конструктор просто запоминает ссылки на два исходных языка
AltLang(const SmartPtr<Lang> & langl, const SmartPtr<Lang> & lang2)
: langl(langl), Iang2(lang2) {}
// Функция проверки принадлежности: слово принадлежит объединению,
// если оно принадлежит хотя бы одному из объединяемых языков.
bool contains(const string & word) const {
return langl->contains(word) I I lang2->contains(word);
}
};
//
// Класс PrefixLang определяет новый язык
//по исходному языку lang и префиксу prefix следующим образом.
//Из исходного языка выбираются все слова, начинающиеся на
// заданный префикс, после чего этот префикс из слов убирается.
// Получившееся множество слов и образует новый язык.
//
class PrefixLang : public Lang {
SmartPtr<Lang> lang; // Ссылка на исходный язык
string prefix; // Заданный префикс
public :
// Конструктор просто запоминает параметры
PrefixLang(const SmartPtr<Lang> & lang, const string & prefix)
: lang(lang), prefix(prefix) {}
// Функция принадлежности очень проста: слово принадлежит новому языку,
// если после добавления префикса слово принадлежит исходному языку.
bool contains(const string & word) const {
return lang->contains(prefix + word);
}
};
//
// Класс CatLang определяет катенацию двух языков
//
class CatLang : public Lang {
SmartPtr<Lang> langl,
lang2; // Исходные языки
444
Глава 8
public :
// Конструктор просто запоминает ссылки на два исходных языка
CatLang(const SmartPtr<Lang> & langl, const SmartPtr<Lang> & lang2)
: langl(langl), Iang2(lang2) {}
// Функция принадлежности слова языку
bool contains(const string & word) const {
// Если слово пусто, то оно может быть катенацией только
// двух пустых слов
if (word == "") {
return langl->contains(word) && lang2->contains(word);
}
// Непустое слово можно представить как катенацию пустого
// слова и себя самого...
if (langl->contains("") && lang2->contains(word)) {
return true;
}
// ... или слово можно представить как катенацию первой буквы...
string first(word.substr(0,1));
// ... и остатка слова,
string last(word.substr(l));
// Вычленим первую букву слова из первого языка и рекурсивным
// вызовом операции катенации построим новый язык, которому
// для анализа будем предъявлять слово без первой буквы.
return ((langl A first) & lang2)->contains(last);
}
};
//
// Класс IterLang определяет итерацию языка
//
class IterLang : public Lang {
SmartPtr<Lang> lang; // Ссылка на исходный язык
public :
// Конструктор запоминает исходный язык
IterLang(const SmartPtr<Lang> & lang) : lang(lang) {}
// Функция проверки принадлежности слова языку основана
//на уже определенных ранее операциях
bool contains(const string & word) const {
Функции как носитель информации
445
// Прежде всего проверяем, принадлежит ли слово исходной грамматике
if (lang->contains(word)) return true;
// Удаляем пустое слово из исходного языка, чтобы не зациклиться,
//и строим катенацию этого языка с самой итерацией (это рекурсия!)
// После этого можно проверить исходное слово на принадлежность
// получившемуся результату
return ((lang - "") &
SmartPtr<Lang>(new IterLang(lang)))->contains(word);
// Ошибкой было бы использовать
// SmartPtr<Lang>(this)
// т. к. this - это указатель на уже существующий объект!
}
};
// Определения операторов над языками:
//
// Операция выделения префикса
//
SmartPtr<Lang> operator A (const SmartPtr<Lang> & lang,
const string & prefix) {
return SmartPtr<Lang>(new PrefixLang(lang, prefix));
}
//
// Операция альтернации
//
SmartPtr<Lang> operator | (const SmartPtr<Lang> & langl,
const SmartPtr<Lang> & lang2) {
return SmartPtr<Lang>(new AltLang(langl, lang2));
}
//
// Операция катенации
//
SmartPtr<Lang> operator & (const SmartPtr<Lang> & langl,
const SmartPtr<Lang> & lang2) {
return SmartPtr<Lang>(new CatLang(langl, lang2));
}
//
// Операция итерации
//
SmartPtr<Lang> operator - (const SmartPtr<Lang> & lang) {
return SmartPtr<Lang>(new IterLang(lang));
}
446
Глава 8
/f
// Операция добавления слова в язык
//
SmartPtr<Lang> operator + (const SmartPtr<Lang> & lang,
const string & word) {
return SmartPtr<Lang>(new PlusWordLang(lang, word));
}
Теперь можно строить различные языки с помощью операций над
регулярными выражениями и проверять, принадлежит ли некоторое слово языку.
Определим, например, в качестве базовых языков два множества —
множество гласных букв и множество согласных букв (точнее, в обоих случаях
определяются множества однобуквенных слов). Такие множества можно задать
напрямую с помощью их характеристических функций.
// Множество слов, представленных одной гласной буквой.
//В этом примере гласными считаются буквы 'а1, 'о1 и 'е'
class Vowel : public Lang {
public :
bool contains(const string & word) {
return word = "a" || word == "o" |I word = "e";
}
};
// Множество слов, представленных одной согласной буквой.
//В этом примере согласными считаются буквы 'bf, 'с1 и 'd*
class Consonant : public Lang {
public :
bool contains(const string & word) {
return word = "b" || word — "с" I I word = "d";
}
};
SmartPtr<Lang> vow(new Vowel);
SmartPtr<Lang> cons(new Consonant);
Теперь на их базе можно определить более сложные множества, например,
множество открытых слогов и множество слов, составленных из этих
открытых слогов:
// Множество слов, представляющих последовательность открытых слогов
// английского языка. В этом примере считаем, что открытый слог состоит
//из одной согласной и одной или двух гласных букв.
SmartPtr<Lang> cyllable = (cons & vow) | (cons & vow & vow);
SmartPtr<Lang> word = -cyllable;
Функции как носитель информации
447
Проверка принадлежности слова множеству осуществляется путем вызова
метода contains этого множества.
// Функция проверки слова на правильность:
void info(const Lang & lang, const string & w) {
cout « w « " - a "
« (lang.contains(w) ? "write" : "wrong")
« " word\n";
}
Если проверить с помощью этой функции несколько слов, например:
info(*word, "do");
info(*word, "bee");
info(*word, "bead");
info(*word, "coca");
info(*word, "cola");
info(*word, "boodoo");
то получится следующий результат:
do - a write word
bee - a write word
bead - a wrong word
coca - a write word
cola - a wrong word
boodoo - a write word
В этом примере слово "bead" оказывается неправильным, поскольку не
состоит из открытых слогов, а слово "cola" неправильно, поскольку с точки
зрения программы буква '1' не является ни гласной, ни согласной.
Рассмотрим еще один пример построения языка. Алфавит этого языка будет
содержать символы десятичных цифр '0'—'9', знаки '+' и '-', а также символ
десятичной точки V. Язык, который мы собираемся построить, должен
содержать слова, представляющие изображения целых и дробных чисел со
Знаком.
Базовые языки для этого примера — это язык, представляющий множество
цифр, который можно описать следующим классом:
class Digit : public Lang {
public :
bool contains(const string & s) const {
return s.length() = 1 && s[0] >= '0' && s[0] <== '9';
}
};
SmartPtr<Lang> digit(new Digit);
448
Глава 8
а также языки, состоящие из одного слова и представляющие знаки '+' и '-' и
символ десятичной точки '.':
SmartPtr<Lang> plus(new OneWordLang("+")); // Знак "+"
SmartPtr<Lang> minus(new OneWordLang("-")); // Знак "-"
SmartPtr<Lang> point(new OneWordLang(".")); // Десятичная точка
Из этих базовых языков теперь можно строить более сложные языки с
помощью определенных нами ранее операций. Ниже представлен фрагмент
программы, в которой строятся все более сложные языки, пока, наконец, не
будет получен язык, представляющий изображения всех целых и дробных
чисел.
SmartPtr<Lang> sign = plus | minus; // Знак числа
SmartPtr<Lang> pos_sign = +sign; // Знак или пусто
SmartPtr<Lang> unsigned = -digit; // Беззнаковое целое
SmartPtr<Lang> integral = pos_sign & unsigned; // Целое со знаком
SmartPtr<Lang> fraction = point & unsigned; // Дробная часть числа
SmartPtr<Lang> pos_fraction = +fraction; // Дробная часть или пусто
SmartPtr<Lang> number = integral & pos_fraction; // Десятичное число
В этом примере использована еще одна специальная операция над языками,
представленная унарным оператором '+', которая добавляет в язык пустое
слово. Конечно, определить такую операцию очень просто, однако она
оказывается довольно полезной в том случае, когда в регулярном выражении
требуется выразить тот факт, что некоторая часть слова может отсутствовать.
Теперь можно проверить некоторые строки; при этом наша программа будет
подтверждать, что предъявленные слова действительно содержат
изображения чисел.
info(*number, "123");
info(*number, "-1");
info(*number, "+3.14");
info(*number, "+999999999999999999999999999999") ;
Неправильными с точки зрения программы будут строки, передаваемые
в функцию info в качестве второго аргумента следующим образом:
info(*number, "314e-2");
info(*number, " -1");
В обоих случаях причиной ошибки являются посторонние символы 'е' и
символ пробела. Впрочем, определение языка легко расширить таким образом,
чтобы приведенные строки тоже оказались правильными.
Полностью программа для определения регулярных языков содержится на
приложенном компакт-диске в папке "Chapter8\8.2\Grammar". Этот пример
Функции как носитель информации
449
можно использовать для экспериментов с различными регулярными
выражениями и языками.
Как и в случае задачи о расстановке работников, приведенное решение ни в
коей мере не является лучшим и не рекомендуется для практического
использования. Существуют гораздо более эффективные методы анализа языков,
построенных с помощью регулярных выражений, чем приведенное решение.
Однако это не недостаток функционального представления; просто наш
метод решения предлагает более мощный аппарат, чем это необходимо для
решения поставленной задачи. В действительности мы не ограничены рамками
регулярных выражений, приведенный аппарат можно применять и в более
сложных случаях, однако описание этих случаев увело бы нас в сторону от
основного материала книги.
Последняя задача этой главы, также заимствованная из книги [9], состоит
в следующем.
Пусть имеются объекты, главным назначением которых служит возможность
изображения их на некотором графическом устройстве (скажем, экране,
принтере или графопостроителе). Можно определить абстрактный тип
данных геометрическая фигура, в интерфейсе которого будет определена
функция для ее изображения:
class Figure {
public :
virtual ~Figure() {}
// Изображение фигуры на холсте, заданном параметром canvas.
// Аргументы х и у задают координаты "опорной точки" фигуры.
virtual void draw(Canvas & canvas, int x, int y) const = 0;
};
Аргументы x и у функции draw задают координаты опорной точки. Для
различных фигур такой опорной точкой могут служить разные точки фигуры,
например, для окружности это может быть ее центр, для прямоугольника —
координаты одного из его углов и т. д.
В определении абстрактного метода рисования draw используется еще один
абстрактный тип данных- Canvas (холст). Назначение объектов этого
типа— принимать графическую информацию для ее отображения на
некотором устройстве. Типичным примером реализации такого абстрактного типа
данных будет окно вывода графической информации операционной системы
MS Windows или драйвер печатающего устройства, позволяющий выводить
графическую информацию на принтер. В данном примере мы не будем
представлять никакой реализации типа данных Canvas, однако опишем интерфейс
для работы с ним, позволяющий рисовать точки и прямые линии в некоторой
абстрактной прямоугольной системе координат:
450
Глава 8
class Canvas {
public :
virtual ~Canvas() {}
// Рисует точку с координатами х и у
virtual void put(int x, int у) = 0;
// Проводит прямую линию от точки (xl, yl) в точку (х2, у2)
virtual void line(int xl, int yl, int x2, int y2) = 0;
};
Теперь, как и раньше, основная задача будет состоять в определении набора
операций, которые позволят строить сложные фигуры из более простых. Для
этого определим вспомогательные классы, в которых и зададим все
необходимые способы построения сложных фигур. Собственно, таких способов
будет немного — сдвиг опорной точки фигуры по горизонтали или вертикали
на некоторое количество точек и объединение фигур. Используя эти
операции над фигурами, можно получать достаточно сложные узоры даже из
весьма простых фигур. Совсем интересные узоры можно получить, если добавить
к операциям над фигурами несколько более сложных операций, таких как,
например, поворот или размещение нескольких экземпляров фигуры вдоль
окружности с заданными координатами центра и радиусом, изменение
масштаба.
Мы, впрочем, ограничимся заданием только самых простых операций, а в
качестве базовой фигуры возьмем единственную фигуру— прямоугольник
со сторонами, параллельными координатным осям. Определение всех
используемых нами классов приведено в листинге 8.8.
В этом примере мы тоже используем аппарат умных указателей, однако
вместо уже ставшего привычным описателя SmartPtr<Figure> будем
использовать более короткий описатель FigurePtr. Можно считать, что описание
идентификатора FigurePtr введено с помощью конструкции:
typedef SmartPtr<Figure> FigurePtr;
I Листинг 8.8. Определение простых операцийHty:$ffipa№'^kj ^/''/-^Z^S^A
//
// Определение класса для сдвига опорной точки фигуры вдоль оси X
//
class ShiftXFigure : public Figure {
FigurePtr baseFigure; // Базовая фигура
int shift; // Величина сдвига в точках
public :
// Конструктор
ShiftXFigure(const FigurePtr & base, int shift)
: baseFigure(base), shift(shift) {}
Функции как носитель информации
451
// Функция рисования сдвигает опорную точку
void draw(Canvas & с, int x, int у) const {
baseFigure->draw(c, x + shift, y);
}
};
//
// Определение класса для сдвига опорной точки фигуры вдоль оси Y
//
class ShiftYFigure : public Figure {
FigurePtr baseFigure; // Базовая фигура
int shift; // Величина сдвига в точках
public :
// Конструктор
ShiftYFigure(const FigurePtr & base, int shift)
: baseFigure(base), shift(shift) {}
// Функция рисования сдвигает опорную точку
void draw(Canvas & с, int x, int y) const {
baseFigure->draw(c, x, у + shift);
}
};
//
// Определение класса для объединения двух фигур
//
class JointFigure : public Figure {
FigurePtr baseFigurel, // объединяемые фигуры
baseFigure2;
public :
// Конструктор
JointFigure(const FigurePtr & basel, const FigurePtr & base2)
: baseFigurel(basel), baseFigure2(base2) {}
// Функция рисования последовательно рисует обе фигуры
void draw(Canvas & с, int х, int у) const {
baseFigurel->draw(c, x, у); baseFigure2->draw(c, x, y);
}
};
452
Глава 8
//
// Определение прямоугольника
//
class Rectangle : public Figure {
// Размеры сторон прямоугольника
int xSize, ySize;
public :
// Конструктор
Rectangle(int xSize = 10, int ySize = 10)
: xSize(xSize), ySize(ySize) {}
// Функции доступа к размерам прямоугольника
int getSizeXO const { return xSize; }
int getSizeYO const { return ySize; }
// Функция рисования прямоугольника на холсте
void draw(Canvas & с, int x, int y) const Л
с.line(x, y, x+xSize-1, y);
c.line(x, y, x, y+ySize-1) ;
c.line(x+xSize-1, y, x+xSize-1, y+ySize-1) ;
c.line(x, y+ySize-1, x+xSize-1, y+ySize-1);
}
};
Теперь, имея определение класса Rectangle, можно взять некоторый
конкретный прямоугольник и на его основе построить другие фигуры, состоящие из
прямоугольников. Вот как, например, выглядит описание функции для
построения фигуры, состоящей из некоторого количества экземпляров другой
фигуры, выстроенных в один ряд по горизонтали и расположенных в этом
ряду с заданным смещением друг относительно друга.
// Горизонтальный ряд из фигур:-
// fig - базовая фигура;
// number - количество фигур в ряду;
// shiftX - расстояние между опорными точками фигур в ряду
FigurePtr row(const FigurePtr & fig, int number, int shiftX) {
if (number == 1) return fig;
FigurePtr r = row(fig, number-1, shiftX);
FigurePtr sr(new ShiftXFigure(r, shiftX));
return FigurePtr(new JointFigure(fig, sr) ) ;
}
Если имеется некоторая базовая фигура, например такая, как изображенная
на рис. 8.2, а, то ряд, построенный из таких фигур, будет выглядеть так, как
показано на рис. 8.2, б.
Функции как носитель информации
453
V J
а)
б)
^
shift
Рис. 8.2. Базовая фигура и ряд, построенный из базовых фигур
Аналогично строится и колонка из базовых фигур:
// Вертикальный ряд из фигур:
// fig - базовая фигура;
// number - количество фигур в ряду;
// shiftY - расстояние между опорными точками фигур в столбце
FigurePtr column(const FigurePtr & fig, int number, int shiftY) {
if (number == 1) return fig;
FigurePtr с = column(fig, number-1, shiftY);
FigurePtr sc(new ShiftYFigure(c, shiftY));
return FigurePtr(new JointFigure(fig, sc));
}
Теперь можно рассмотреть фигуру, лежащую в основе построения кирпичной
стенки и состоящую из двух рядов базовых фигур, лежащих друг под другом
с некоторым смещением. Эта фигура будет выглядеть так, как показано на
рис. 8.3, а функция, с помощью которой можно задать такую фигуру, будет
выглядеть следующим образом:
// Фигура, построенная из двух рядов базовых фигур, лежащих
// друг над другом, причем нижний ряд сдвинут относительно
// верхнего на половину величины shiftX:
// base - базовая фигура;
// number - количество фигур в ряду;
454
Глава 8
// shiftX - расстояние между опорными точками фигур в ряду
// shiftY - расстояние между опорными линиями рядов
FigurePtr twoRows(const FigurePtr & base,
int number, int shiftX, int shiftY) {
FigurePtr rowl = row(base, number, shiftX);
FigurePtr row2(new ShiftYFigure(rowl, shiftY));
FigurePtr srow2(new ShiftXFigure(row2, shiftX/2));
return FigurePtr(new JointFigure(rowl, srow2));
}
Рис. 8.З. Два ряда из базовых фигур со сдвигом друг относительно друга
Наконец, кирпичная стенка строится из прямоугольных кирпичей и состоит
из четного количества рядов, в каждом из которых находится некоторое
количество кирпичей. Строится такая кирпичная стенка с помощью следующей
функции:
// Кирпичная стенка из экземпляров одной и той же фигуры
// base - базовая фигура;
// numberX - количество фигур в ряду;
// numberY - количество рядов (четное);
// shiftX - расстояние между опорными точками фигур в ряду
// shiftY - расстояние между рядами
FigurePtr wall(const FigurePtr & base,
int numberX, int numberY, int shiftX, int shiftY) {
FigurePtr rows = twoRows(base, numberX, shiftX, shiftY);
return column(rows, numberY / 2, 2*shiftY);
}
Если теперь задать базовый кирпич с помощью описания
FigurePtr brick(new Rectangle(100, 50));
Функции как носитель информации 455
то стенку из таких кирпичей можно получить и нарисовать с помощью
вызова:
wall(brick, 16, 10, brick->getSizeX()-1, brick->getSizeY()-1)
->draw(canvas, 0, 0);
Возможно, получится что-то похожее на рис. 8.4, но реальная картинка
зависит от того, как определены базовые функции рисования.
I
I
Рис. 8.4. Кирпичная стенка, полученная с помощью класса Wall
В приведенном выше примере интересно, что базовые фигуры
(прямоугольники или другие фигуры) имеют вполне традиционное представление, при
котором объекты строятся из некоторых базовых элементов. Так, например,
прямоугольник содержит в качестве элементов свои размеры. Однако
поскольку такой прямоугольник обладает функциональной возможностью быть
нарисованным, то он может использоваться в классах и функциях, которые
определены на основе только этой функциональной информации. В конечном
итоге мы строим объекты, которые сами по себе не имеют фактической
информации (у построенной нами кирпичной стенки невозможно определить
размеры или количество кирпичей), однако имеют некоторую
функциональность, определенную поведением метода draw. С помощью этого метода мы
можем манипулировать фигурами (строить сложные фигуры на базе более
простых и, конечно же, рисовать эти фигуры).
Одна из возможных простых реализаций холста для рисования фигур
приведена на приложенном компакт-диске в папке Mchapter8\8.2\Figures".
Описанный там холст представлен просто прямоугольным массивом символов,
содержащим изображения нарисованных и пустых точек. Такой холст можно
просто вывести в выходной поток, получив грубое изображение
нарисованных на холсте фигур. Вы можете попробовать реализовать и более сложную
графику и поэкспериментировать с созданием различных изображений.
На этом мы завершаем главу о функциональном представлении объектов,
а вместе с нею заканчивается и вся книга.
Заключение
В этой книге мы рассмотрели множество разных структур данных и привели
много алгоритмов их обработки. Многие из этих алгоритмов и способов
представления данных являются абсолютно необходимыми инструментами в
работе каждого программиста. Однако достаточно ли знать их, чтобы считать
себя опытным программистом, готовым разрабатывать любые новые
алгоритмы и программы? К сожалению, нет. Практически каждая
программистская задача требует для своего решения создания новых структур данных и
разработки новых алгоритмов.
Тогда, может быть, изучать предлагаемые алгоритмы и структуры данных по
книге вовсе не стоит? Да нет, конечно же, стоит! Базовые структуры данных
служат основой для разработки новых специализированных структур данных.
Разумеется, невозможно написать сложную программу, не имея
представления о массивах. Точно так же нереально написать сложную программу, не
зная алгоритмов обработки списков, не умея обходить деревья, или не зная
о способах хеширования информации.
Конечно, с развитием языков программирования все больше и больше
средств и методов программирования включаются либо в сам язык, либо в
библиотеки классов для этого языка. Многочисленные библиотеки шаблонов
и классов, разработанные для языка C++, являются хорошей иллюстрацией
этому, поскольку часто имеют в своем составе не только такие стандартные
структуры данных, как массивы, и такие известные классы, как списки или
потоки ввода/вывода, но и совсем новые средства— итераторы, адаптеры,
хеш-таблицы и многое другое. Конечно, можно пользоваться всем этим, не
очень хорошо представляя, что стоит за каждым из этих понятий, однако,
эффективность работы программиста будет существенно выше, если он
сможет правильно выбрать средства программирования, а это возможно лишь
в том случае, если он делает выбор осознанно, не только на основании знания
о внешнем поведении объектов, но и зная их поведение изнутри.
Заключение
457
Есть еще один вопрос: а так ли уж важно добиваться максимальной
эффективности работы программы? Производительность компьютеров постоянно
повышается, объемы доступной памяти растут. Сейчас возможно написание
таких программ, которые никогда бы не смогли выполняться на машинах еще
10-летней давности. Так что, может быть, не стоит так уж сильно стремиться
к эффективности работы программ, а следует больше заботиться об
эффективности работы программистов?
Отчасти, это, конечно, справедливо. Труд программиста дорог, и повышение
эффективности его работы является насущной задачей. Однако знание
основных принципов построения структур данных, алгоритмов их обработки
являются одним из основных залогов успешной и эффективной работы
программиста! Кроме того, не следует думать, что оптимизация программ —
дело далекого прошлого. Все время появляются не только более мощные
персональные и стационарные компьютеры, но и микрокомпьютеры,
встраиваемые во всевозможные приборы, начиная от сотового телефона и кончая
холодильником или кроватью. Такие микрокомпьютеры имеют весьма
ограниченные ресурсы памяти и невысокое быстродействие процессора, так что
вопрос эффективности работающих на них программ стоит чрезвычайно
остро.
Надеемся, что эта книга помогла вам глубже понять, что представляет собой
труд программиста, помогла научиться правильно выбирать структуры
данных для своих программ, обогатила знаниями современных технологий их
обработки. И все же, к сожалению, ни одна книга не может превратить
заурядного программиста в хорошего и грамотного специалиста, если только
он сам не будет постоянно совершенствовать свои знания практическим
путем.
Я не знаю лучшего способа научить программированию, чем показывать и
объяснять хорошие программы. Также, я не знаю другого способа стать
хорошим программистом, чем постоянно самому писать все новые и новые
программы, пытаясь применить в них свои знания практически. Вот это,
наверное, то, что мне хотелось сказать в качестве заключения к этой книге.
Будьте хорошими программистами, это так интересно!
ПРИЛОЖЕНИЕ
Содержание компакт-диска
Прилагаемый к книге компакт-диск имеет следующую структуру папок и
файлов:
□ Readme.txt — файл, содержащий инструкции по использованию диска;
□ DJGPPInstall\— файлы, необходимые для установки компилятора и
системы программирования DJGPP фирмы Delorie Software:
• readme. 1st— инструкция по установке и использованию DJGPP (на
английском языке);
• copying — лицензия GNU на использование системы DJGPP;
• copying.dj — лицензия GNU на использование продукции DJ Group;
• copying.lib — лицензия GNU на использование библиотеки GPL;
• unzip32.exe — программа распаковки ZIP-архивов;
• djdev203.zip — инструменты разработчика и программы поддержки
периода исполнения (DJGPP версии 2);
• faq230b.zip— список ответов на часто задаваемые вопросы (FAQ)
в различных форматах (Info, ASCII, HTML...);
• rhide 15b.zip — интерактивная среда разработки программ;
• bnu214b.zip — утилиты фильтрации, поиска строк и др.;
• gcc333b.zip — компилятор языков С и C++;
• gdb53b.zip — отладчик программ, компилированных системой DJGPP;
• gpp333b.zip — библиотеки C++ (STL и др.);
• mak3791b.zip — make — программа поддержки и сборки проектов;
• txi46b.zip — info — подсистема помощи;
• unzipAll.bat — пакетный файл для распаковки всех архивов;
Содержание компакт-диска 459
П Programs\ — файлы, содержащие тексты примеров программ, приводимых
в книге:
Chapter 1\-
Chapter2\ -
Chapter3\ -
Chapter4\ -
Chapter5\ -
Chapter6\ -
Chapter7\ -
Chapter8\ -
- примеры главы 1;
- примеры главы 2;
- примеры главы 3;
- примеры главы 4;
- примеры главы 5;
- примеры главы 6;
- примеры главы 7;
- примеры главы 8.
Литература
1. Ахо А., Хопкрофт Дж., Ульман Дж. Построение и анализ вычислительных
алгоритмов. — М: Мир, 1979.
2. Вирт Н. Алгоритмы = структуры данных = программы. — М.: Мир, 1985.
3. Гамма Э., Хелм Р., Джонсон Р., Влиссидес Дж. Приемы
объектно-ориентированного проектирования. Паттерны проектирования. — СПб.: Питер,
2001.
4. Дмитриева М. В., Кубенский А.А. Турбо Паскаль и Турбо Си: Построение
и обработка структур данных. — Учеб. пособие. — СПб.: СПбУ, 1996.
5. Кнут Д. Искусство программирования для ЭВМ. Т. 1. Основные
алгоритмы / 3-е издание. Серия: Искусство программирования. — Киев: Вильяме,
2000.
6. Кнут Д. Искусство программирования для ЭВМ. Т. 3. Сортировка и поиск
/ 2-е издание. Серия: Искусство программирования. — Киев: Вильяме,
2000.
7. Кормен Е., Лейзерсон Ч., Ривест Р. Алгоритмы: построение и анализ. —
М.: МЦНМО, 2000.
8. Рейнгольд Э., Нивергельт Ю., Део Н. Комбинаторные алгоритмы. Теория
и практика. — М.: Мир, 1980.
9. Хендерсон П. Функциональное программирование. Применение и
реализация. — М.: Мир, 1980.
Предметный указатель
Абстрактная фабрика 73, 115
О операторы 273
О членов сумм 401
Абстрактный тип данных 59, 100
о
о
о
о
о
о
о
о
о
о
о
о
о
о
о
о
о
о
о
внешний итератор 143
выражение 264
генератор сообщений 385
геометрическая фигура 449
дата 59
интерфейс 59
итератор 156
константа 258
контекст 266
множество 413
обработчик сообщений 386
оператор 261
реализация 59, 62, 72
сообщение 386
строка 192
упорядоченный список 220
холст 449
член суммы 400
язык 440
Алгоритмы на графах:
О алгоритм Дейкстры 356
О минимальное остовное дерево:
D алгоритм Крускала 372
D алгоритм Прима 378
О нахождение всех кратчайших путей
368
О нахождение транзитивного
замыкания 360
D алгоритм Флойда—Уоршалла 366
D матричными умножениями 361
О обход с помощью рекурсивной
функции 346
О подсчет числа компонент связности
336, 342
О поиск кратчайших путей 354, 360
О топологическая сортировка вершин
343
Алфавит 439
Анализ скобочной структуры текста 105
Бор 228
Буфер памяти 95
в
Выражение:
О вычисление в контексте 267
О дифференцирование 283
О константное 272
О копирование 254, 255
О лексический анализ 240
О синтаксический анализ 240, 264
О сравнение 255
О упрощение 279
Вычисление выражения на стеках 109
Гамильтонов путь 327
Генератор сообщений 384
Граф 44
О А-граф 53
Продолжение рубрики см. на с. 462
462
Предметный указатель
Граф (прод.)\
О L-граф 50
О М-граф 48
О S-граф 46
О базовые операции 47
О вершины 44
О дуги 44
О инцидентность 44
О матрица смежности 47, 360
О нагрузка 44
О неориентированный 44
О операции 52
О ориентированный 44
О остовное дерево 371
О представление 45, 47, 50, 52
О преобразование представлений 55
О ребра 44
О транзитивное замыкание 360
О экстремальное остовное дерево
372
Д
Дата:
О структурное представление 62
О счетчик дней 62
Двоичный поиск 75
Дерево 9, 26
0 2—3-дерево 182
D вставка элементов 184
D поиск по ключу 183
О удаление элементов 184
О выражение 235
О высота 30
О двоичное 27
О количество узлов на уровне 34
О корень 26
О непосредственный потомок 27
О непосредственный предок 26
О обратные ссылки 162
О определения 26
О поддерево 26
О представление 27, 28, 35
Дерево поиска:
О добавление элементов 172, 174
О определение 168
О оптимальное 181
О удаление элементов 175
Динамический массив 14
0 представление строк 191
Диспетчер сообщений 383
О реализация 387
и
Индекс 75
Индексация по ключу 75
Интерпретатор 249
Исключительная ситуация:
О исчерпание:
° очереди 118, 127
° стека 102, 106
О неправильный индекс 11
О неправильный размер стека 102
О несопоставимые множества 43
О переполнение:
D очереди 118
° стека 101, 106
Итератор 130, 134
О внешний 138
О внутренний 134
О слов в строке 211
Итерация:
О вершин графа с помощью стека 332
О графа 326
D с помощью стека 337
К
Ключ поиска 73
О операции сравнения 74
Кодовая таблица 10
Л
Лексема 109, 235
Лексикографический порядок 92
Лексический анализ 235
Лексический разбор 109
м
Массив 10
Множество 37
О операции 37
О представление 37
О реализация операций 39, 416
О символов 38
Моделирование 390
Предметный указатель
463
О
Обработчик сообщений 384
Обход графа:
О в глубину 328
О в ширину 328, 348
Обход дерева 161
О в глубину 149
О в ширину 149, 155
О внутренний итератор 150
О динамические обратные ссылки 165
О инфиксный 148
О левосторонний 150
О с помощью стека 152, 154
О сверху вниз 147, 151
О снизу вверх 148
Одиночка 385
Оператор new, переопределение 294
Очередь 116
О абстрактная фабрика 157
О голова 116
О для обхода дерева 155
О ограниченная 118
О реализация:
D в виде массива 118
D в виде списка 121
О хвост 116
Очередь сообщений 384
п
Пирамида 35, 84
О протаскивание 85
Поиск по ключу 169
Посетитель 249, 251
О вычисление значения выражения
269
О дифференцирование выражения
283
О для обхода графа 338
О упрощение выражения 280
Преобразование выражений 272
О подстановка 273
Пул памяти 95
Путь в графе:
О длина 353
О замкнутый 353
О кратчайший 353
О простой 353
О цикл 353
р
Регулярное выражение 439
О операция:
° альтернации 439
D итерации 440
° катенации 439
Решето Эратосфена 422
С
Синтаксический анализ 235
Синтаксическое дерево 235
Система распределения памяти 95, 278
О в языках С и C++ 288
0 для цифровой сортировки 292
О методы 302
D граничных маркеров 312
D двоичных близнецов 319
D двусвязного списка 303
Слияние упорядоченных массивов 80
Словарь 206
0 в виде бора 229
О в виде дерева поиска 226
О в виде списка слов 219
О переменных выражения 252
О представление 208, 233
О управление памятью 299
Сообщение 383
Сортировка 74
О алфавитная 92
О быстрая 88
D оценка памяти 91
D скорость работы 90
0 деревом поиска 178
О метод:
D двоичных вставок 78
D простых вставок 79
D слияния 82
О на месте 75
О пирамидальная 84
О цифровая 92
Список 9, 18
О внешний итератор 139, 141
О вставка элементов 24, 140
О кольцевой 25
0 обратная итерация 134
О однонаправленный 19
О операции 18
О поиск элемента 132, 136
Продолжение рубрики см. на с. 464
464
Предметный указатель
Список (прод.)\
О суммирование элементов 132
О удаление элементов 23, 140
0 упорядоченный 24
Стек 100
О абстрактная фабрика 115
0 использование для обхода дерева 151
О неограниченный 101
О ограниченный 101
О операции 104
О представление битовой шкалой 127
О реализация 109
D в виде массива 101
D в виде списка 107
Строки:
О представление:
п с символом-терминатором 191
п с хранимой длиной 189
° списковое 192
О список указателей в символьный пул
204
Структуры данных 9
О динамические 18
Сумматор 398
У
Умный указатель 278, 411, 431, 441, 450
О реализация 418
Упорядоченный список 220
Ф
Формальная грамматика 439
Формула 235
Фрагментация памяти 301
О внешняя 302
О внутренняя 302, 313, 319
Функциональное представление:
О множеств 413
О объектов 412
О позиций на шахматной доске 430
Функция расстановки 206
X
Хеширование:
О алгоритм 208
0 конфликт индексов 207
О область переполнения 213
О разрешение конфликтов 213
Хеш-таблица 214
О контекст значений переменных 266
О перехеширование 218
Хеш-функция 206
Я
Язык 439
О десятичных дробей 447
О слогов английского языка 446
Кубанский Александр Александрович
кандидат физико-математических наук, преподаватель
\ . Санкт-Петербургского государственного универ-
» ситета информационных технологий, механики
и оптики (I/ITMOJ и Санкт-Петербургского
государственного университета с более чем 15-летним
стажем. Имеет большой опыт как разработчик
программного обеспечения в компаниях NOVAVOX, Motorola,
TogetherLabs, Borland. Автор нескольких книг по обработке
структур данных, в том числе "Создание и обработка структур
данных в примерах на Java".
СТРУКТУРЫ и АЛГОРИТМЫ
ОБРАБОТКИ ДАННЫХ
объЕКТНО-ОрИЕНТИрОВАННЫЙ
ПОДХОД И РЕАЛИЗАЦИЯ НА £++
Основой для построения программ в учебном пособии
служит объектно-ориентированный подход [ООП)
к проектированию и программированию, широко
применяющийся в настоящее время для разработки
программ во всех областях программирования - от
простейших учебных программ до больших
программных комплексов и баз данных. Приводятся способы
описания сложных структур данных, а также
нетрадиционные представления данных, в частности
функциональное представление. Большое внимание
уделено современным технологиям обработки данных,
выбору оптимальных решений задач. Программы
реализованы на языке программирования C++. Книга
поможет начинающим ощутить вкус к
программированию и поиску элегантных решений программистских
задач, а опытным программистам - приобрести новые
знания и умения в области технологии работы со
сложными структурами программ и данных.
Компакт-диск содержит
свободно
распространяемый компилятор языка
C++ (лицензия GNU] и
примеры программ, ссылки на
которые имеются в книге
ISBN 5-94157-506-8
БХВ-Петербург
190005, Санкт-Петербург,
Измайловский пр., 29
E-mail: mail@bhv.ru
Internet: www.bhv.ru
тел.: (812)251-42-44
факс: (812) 251-12-95
*Ызу<
9"7 8 5 9 4 1 "5 7 5 0 6 0