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

Delphi Готовые алгоритмы

Род Стивене
издательство
Род Стивене

Delphi
Готовые алгоритмы

'
Ready-to-run
Delphi®
Algorithms

Rod Stephens

WILEY COMPUTER PUBLISHING

JOHN WILEY & SONS, INC.


New York • Chichester • Weinheim • Brisbane • Singapore • Toronto
Delphi
Готовые
алгоритмы

Род Стивене

Издание второе,
стереотипное

Москва, 2004
УДК 004.438Delphi
ББК 32.973.26-018.1
С80

Стивене Р.
С80 Delphi. Готовые алгоритмы / Род Стивене; Пер. с англ. Мерещука П. А. - 2-е
изд., стер. - М.: ДМК Пресс ; СПб.: Питер, 2004. - 384 с.: ил.

ISBN 5-94074-202-5

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


га поможет вам легко преодолеть возникающие трудности с помощью биб-
лиотеки мощных алгоритмов, полностью реализованных в исходном коде
Delphi. Вы узнаете, как выбрать способ, наиболее подходящий для реше-
ния конкретной задачи, и как добиться максимальной производительнос-
ти вашего приложения. Рассматриваются типичные и наихудшие случаи
реализации алгоритмов, что позволит вам вовремя распознать возможные
трудности и при необходимости переписать или заменить часть программы.
Подробно описываются важнейшие элементы алгоритмов хранения и обра-
ботки данных (списки, стеки, очереди, деревья, сортировка, поиск, хеши-
рование и т.д.). Приводятся не только традиционные решения, но и мето-
ды, основанные на последних достижениях объектно-ориентированного
программирования.
Книга предназначена для начинающих программистов на Delphi, но
благодаря четкой структуризации материала и богатой библиотеке готовых
алгоритмов будет также интересна и специалистам.
УДК 004.438Delphi
ББК 32.973.26-018.1
ч '• . >
All Rights Reserved. Authorized translation from the English language edition published by
John Wiley & Sons, Inc.
Все права защищены. Любая часть этой книги не может быть воспроизведена в какой бы
то ни было форме и какими бы то ни было средствами без письменного разрешения владель-
цев авторских прав.
Материал, изложенный в данной книге, многократно проверен. Но, поскольку вероятность
технических ошибок все равно существует, издательство не может гарантировать абсолютную
точность и правильность приводимых сведений. В связи с этим издательство не несет ответ-
ственности за возможные ошибки, связанные с использованием книги.

ISBN 0-471-25400-2 (англ.) © By Rod Stephens. Published by John Wiley & Sons,
Inc.
© Обложка. Биржаков Н., 2004
ISBN 5-94074-202-5 (рус.) © Издание на русском языке, перевод на русский
язык, оформление. ДМК Пресс, 2004
Содержание
!

Введение 12

Глава 1. Основные понятия 18


Что такое алгоритмы 18
Анализ скорости выполнения, алгоритмов 19
Память или время 19
Оценка с точностью до порядка 20
Определение сложности 21
Сложность рекурсивных алгоритмов 23
Средний и наихудший случай 25
Общие функции оценки сложности 26
Логарифмы 27
Скорость работы алгоритма в реальных условиях 27
Обращение к файлу подкачки 28
Резюме 30

Глава 2. Списки 31
Основные понятия о списках 31
Простые списки 32
Изменение размеров массивов > 32
Список переменного размера 35
Класс SimpleList 39
Неупорядоченные списки 40
Связанные списки 45
Добавление элементов 47
Удаление элементов 48
DeljJhL JOTOB ые а л гор итм ы
Метки 49
Доступ к ячейкам ;. 50
Разновидности связанных списков 52
Циклические связанные списки 52
Двусвязные списки 53
Списки с потоками 55
Другие связанные структуры 58
Резюме . . .• ' . f • " '• " " ;
60

Глава 3. Стеки и очереди 61


Стеки 61
Стеки на связанных списках 63
Очереди 65
Циклические очереди 66
Очереди на основе связанных списков 70
Очереди с приоритетом 71
Многопоточные очереди .-. 73
Резюме 75

Глава 4. Массивы 77
Треугольные массивы 77
Диагональные элементы 78
Нерегулярные массивы 79
Линейное представление с указателем 80
Нерегулярные связанные списки 81
Динамические массивы Delphi 82
Разреженные массивы 83
Индексирование массива 84
Сильно разреженные массивы '.. 87
Резюме 89

Глава 5. Рекурсия эо
Что такое рекурсия 90
Рекурсивное вычисление факториалов 91
Анализ сложности . ..92
_Содержание

Рекурсивное вычисление
наибольшего общего делителя 93
Анализ сложности 94
Рекурсивное вычисление чисел Фибоначчи 95
Анализ сложности 96
Рекурсивное построение кривых Гильберта 97
Анализ сложности 99
Рекурсивное построение кривых Серпинского 102
Анализ сложности .....,....,.............„........>... 104
Недостатки рекурсии 105
Бесконечная рекурсия 106
Потери памяти 107
Необоснованное применение рекурсии 107
Когда нужно использовать рекурсию 108
Удаление хвостовой рекурсии 109
Нерекурсивное вычисление чисел Фибоначчи ш
Устранение рекурсии в общем случае 113
Нерекурсивное создание кривых Гильберта 118
Нерекурсивное построение кривых Серпинского 121
Резюме 125

Глава 6. Деревья 126


; - . " • - • • - • ' • " - '''.' •
Определения 126
Представления деревьев 127
Полные узлы 128
Списки дочерних узлов 129
Представление нумерацией связей ...;.. 130
Полные деревья 134
Обход дерева 135
Упорядоченные деревья 140
Добавление элементов 141
Удаление элементов 142
Обход упорядоченных деревьев 146
Delphi. Готовые алгоритмы
Деревья со ссылками 147
Особенности работы 150
Q-деревья ; 151
Изменение значения MAX_QTREE_NODES 157
Восьмеричные деревья 157
Резюме : 158

Глава?. Сбалансированные деревья 159


Балансировка 159
AVL-деревья 160
Добавление узлов к AVL-дереву 160
Удаление узлов из AVL-дерева 169
Б-деревья 174
Производительность Б-дерева 175
Удаление элементов из Б-дерева 176
Добавление элементов в Б-дерево 176
Разновидности Б-дерева 178
Усовершенствование Б-деревьев 180
Вопросы доступа к диску 181
База данных на основе Б+дерева 184
Резюме 187

Глава 8. Деревья решений 188


Поиск в игровых деревьях 188
Минимаксный перебор 190
Оптимизация поиска в деревьях решений 193
Поиск нестандартных решений 194
Ветви и границы 195
Эвристика 200
Сложные задачи 216
Задачао выполнимости 217
Задача о разбиении 217
Задача поиска Гамильтонова пути 218
Задача коммивояжера 219
Содержание ||
Задача о пожарных депо 220
Краткая характеристика сложных задач 220
Резюме 221

Глава 9. Сортировка , 222


Общие принципы 222
Таблицы указателей 222
Объединение и сжатие ключей 223
Пример программы 226
Сортировка выбором 226
Перемешивание 227
Сортировка вставкой 228
Вставка в связанных списках 229
Пузырьковая сортировка 231
Быстрая сортировка 234
Сортировка слиянием 239
Пирамидальная сортировка 241
Пирамиды 241
Очереди с приоритетом .' 245
Алгоритм пирамидальной сортировки 248
Сортировка подсчетом 250
Блочная сортировка 251
Блочная сортировка с использованием связанных списков 252
Резюме 255

Глава 10. Поиск 257


Примеры программ 257
Полный перебор 258
Перебор сортированных списков 259
Перебор связанных списков 259
Двоичный поиск 261
Интерполяционный поиск 263
Delphi. Jbro^
Строковые данные 267
Следящий поиск 268
Двоичное отслеживание и поиск 268
Интерполяционный следящий поиск 269
Резюме 270

Глава 11. Хеширование 272


Связывание 273
Преимущества и недостатки связывания 275
Блоки 277
Хранение хеш-таблиц на диске 280
Связывание блоков 283
Удаление элементов 285
Преимущества и недостатки использования блоков 286
Открытая адресация 286
Линейная проверка 287
Квадратичная проверка 294
Псевдослучайная проверка 297
Удаление элементов 299
Резюме 301

Глава 12. Сетевые алгоритмы зо4


Определения 304
Представления сетей 305
•. •. ' •/
Управление узлами и связями 307
Обход сети 308
Наименьший каркас дерева ., 311
Кратчайший путь 316
Расстановка меток 318
Коррекций меток 323
Варианты поиска кратчайшего пути 326
Применение алгоритмов поиска кратчайшего пути 331
Содержание
Максимальный поток 335
Сферы применения 342
Резюме , 345

Глава 13. Объектно-ориентированные


методы 346
Преимущества ООП ... 346
Инкапсуляция 346
Полиморфизм 349
Многократное использование и наследование 349
Парадигмы ООП ,. 351
Управляющие объекты 351
Контролирующий объект 353
Итератор ... 354
Дружественный класс 356
Интерфейс , 356
Фасад 357
Фабрика 357
Единственный объект 359
Сериализация 361
Парадигма Модель/Вид/Контроллер 364
Резюме 367

Приложение 1. Архив примеров зев


Содержание архива с примерами 368
Аппаратные требования — 368
Запуск примеров программ 368
Информация и поддержка пользователей 369

Приложение 2. Список примеров программ 370

Предметный указатель 373


Введение
Программирование под Windows всегда было достаточно сложной задачей. Интер-
фейс прикладного программирования (Application Programming Interface - API)
Windows предоставляет в ваше распоряжение набор мощных, но не всегда без-
опасных инструментов для разработки приложений. Эти инструменты в некото-
ром смысле можно сравнить с огромной и тяжелой машиной, при помощи которой
удается добиться поразительных результатов, но если водитель неосторожен или
не владеет соответствующими навыками, дело, скорее всего, закончится только
разрушениями и убытками.
С появлением Delphi ситуация изменилась. С помощью интерфейса для быст-
рой разработки приложений (Rapid Application development - RAD) Delphi позво-
ляет быстро и легко выполнять подобную работу. Используя Delphi, можно созда-
вать и тестировать приложения со сложным пользовательским интерфейсом без
прямого использования функций API. Освобождая программиста от проблем, свя-
занных с применением API, Delphi позволяет сконцентрироваться непосредствен-
но на приложении.
Несмотря на то, что Delphi упрощает создание пользовательского интерфейса,
писать остальную часть приложения — код для обработки действий пользователя
и отображения результатов - предоставляется программисту. И здесь потребуют-
ся алгоритмы.
Алгоритмы - это формальные команды, необходимые для выполнения на ком-
пьютере сложных задач. Например, с помощью алгоритма поиска можно найти
конкретную информацию в базе данных, состоящей из 10 млн записей. В зависимо-
сти от качества используемых алгоритмов искомые данные могут быть обнаруже-
ны за секунды, часы или вообще не найдены.
В этой книге не только подробно рассказывается об алгоритмах, написанных
на Delphi, но и приводится много готовых мощных алгоритмов. Здесь также ана-
лизируются методы управления структурами данных, такими как списки, стеки,
очереди и деревья; описываются алгоритмы для выполнения типичных задач -
сортировки, поиска и хеширования.
Для того чтобы успешно использовать алгоритмы, недостаточно просто ско-
пировать код в свою программу и запустить ее на выполнение. Необходимо знать,
как различные алгоритмы ведут себя в разных ситуациях. В конечном итоге имен-
но эта информация определяет выбор наиболее подходящего варианта.
Книга написана на достаточно простом языке. Здесь рассматривается поведе-
ние алгоритмов как в типичных, так наихудших случаях. Это позволит понять, чего
вы вправе ожидать от определенного алгоритма, вовремя распознать возможные
Совместимость версий Delphi ]||

трудности и при необходимости переписать или удалить алгоритм. Даже самый луч-
ший алгоритм не поможет в решении задачи, если использовать его неправильно.
Все алгоритмы представлены в виде исходных текстов на Delphi, которые вы мо-
жете включать в свои программы без каких-либо изменений. Тексты кода и приме-
ры приложений находятся на сайте издательства «ДМК Пресс» www.dmkpress.ru.
Они демонстрируют характерные особенности работы алгоритмов и их использо-
вание в различных программах.

Назначение книги /
Данная книга содержит следующий материал:
а полное введение в теорию алгоритмов. После прочтения книги и выполне-
ния приведенных примеров вы сможете использовать сложные алгоритмы
в своих проектах и критически оценивать новые алгоритмы, написанные
вами или кем-то еще;
а большую подборку исходных текстов. С помощью текстов программ, имею-
щихся на сайте издательства «ДМК Пресс», вы сможете быстро добавить го-
товые алгоритмы в свои приложения;
а готовые примеры программ позволят вам проверить алгоритмы. Работая с эти-
ми примерами, изменяя и совершенствуя их, вы лучше изучите принцип ра-
боты алгоритмов. Кроме того, вы можете использовать их как основу для
создания собственных приложений.

Читательская аудитория
Книга посвящена профессиональному программированию в Delphi. Она не
предназначена для обучения. Хорошее знание основ Delphi позволит вам сконцен-
трировать внимание на алгоритмах вместо того, чтобы погружаться в детали само-
го языка.
Здесь изложены важные принципы программирования, которые могут с успе-
хом применяться для решения многих практических задач. Представленные алго-
ритмы используют мощные программные методы, такие как рекурсия, разбиение
на части, динамическое распределение памяти, а также сетевые структуры данных,
что поможет вам создавать гибкие и сложные приложения.
Даже если вы еще не овладели Delphi, вы сможете выполнить примеры про-
грамм и сравнить производительность различных алгоритмов. Более того, любой
из приведенных алгоритмов будет нетрудно добавить к вашим проектам.

Совместимость версий Delphi


Выбор наилучшего алгоритма зависит от основных принципов программиро-
вания, а не от особенностей конкретной версии языка. Тексты программ в этой кни-
ге были проверены с помощью Delphi 3,4 и 5, но благодаря универсальности свойств
языка они должны успешно работать и в более поздних версиях Delphi.
Введение
Языки программирования, как правило, развиваются в сторону усложнения
и очень редко в противоположном направлении. Яркий тому пример - оператор
goto в языке С. Этот неудобный оператор является потенциальным источником
ошибок, он почти не используется большинством программистов на С, но сохранил-
ся в синтаксисе языка еще с 70-х годов. Оператор даже был встроен в C++ и позднее
в Java, хотя создание нового языка было хорошим предлогом избавиться от ненуж-
ного наследия.
Аналогично в старших версиях Delphi наверняка появятся новые свойства, но
вряд ли исчезнут стандартные блоки, необходимые для реализации алгоритмов,
описанных вэтой книге. Независимо от того, что добавлено в 4-й, 5-й, и будет до-
бавлено в 6-й версии Delphi, классы, массивы, и определяемые пользователем
типы данных останутся в языке. Большая часть, а может быть, и все алгоритмы из
этой книги не будут изменяться еще в течение многих лет. Если вам понадобится
обновить алгоритмы, то их можно будет найти на сайте www.vb-helper. com/da.htm.

Содержание глав
В главе 1 рассматриваются те основы, которые вам необходимо изучить, преж-
де чем приступать к анализу сложных алгоритмов. Здесь описываются методы
анализа вычислительной сложности алгоритмов. Некоторые алгоритмы, теорети-
чески обеспечивающие высокую производительность, в реальности дают не очень
хорошие результаты. Поэтому в этой главе обсуждаются и практические вопросы,
например, рассматривается обращение к файлу подкачки.
В главе 2 рассказывается, как можно сформировать различные виды списков
с помощью массивов и указателей. Эти структуры данных применяются во мно-
гих программах, что продемонстрировано в следующих главах книги. В главе 2
также показано, как обобщить методы, использованные для построения связан-
ных списков, для создания других, более сложных структуры данных, например,
деревьев и сетей.
В главе 3 рассматриваются два специализированных вида списков - стеки
и очереди, использующиеся во многих алгоритмах (некоторые их них описывают-
ся в последующих главах). В качестве практического примера приведена модель,
сравнивающая производительность двух типов очередей, которые могли бы ис-
пользоваться в регистрационных пунктах аэропортов.
Глава 4 посвящена специальным типам массивов. Треугольные, неправильные
и разреженные массивы позволяют использовать удобные представления данных
для экономии памяти.
В главе 5 рассматривается мощный, но довольно сложный инструмент - ре-
курсия. Здесь рассказывается, в каких случаях можно использовать рекурсию и как
ее можно при необходимости удалить.
В главе 6 многие из представленных выше алгоритмов, такие как рекурсия
и связанные списки, используются для изучения более сложного вопроса - дере-
вьев. Рассматриваются различные представления деревьев - с помощью полных
узлов и нумерации связей. Здесь содержатся также некоторые важные алгоритмы,
например, обход узлов дерева.
Архив примеров
В главе 7 затронута более широкая тема. Сбалансированные деревья обла-
дают некоторыми свойствами, которые позволяют им оставаться уравновешен-
ными и эффективными. Алгоритмы сбалансированных деревьев просто описать,
но довольно трудно реализовать в программе. В этой главе для построения слож-
ной базы данных используется одна из наиболее мощных структур - Б+ дерево.
В главе 8 рассматриваются алгоритмы, которые предназначены для поиска
ответа в дереве решений. Даже при решении маленьких задач эти деревья могут
быть поистине огромными, поэтому становится насущным вопрос эффективного
поиска нужных элементов. В этой главе сравнивается несколько различных мето-
дов подобного поиска.
Глава 9 посвящена наиболее сложному разделу теории алгоритмов. Алгорит-
мы сортировки интересны по нескольким причинам. Во-первых, сортировка - это
общая задача программирования. Во-вторых, различные алгоритмы сортировки
имеют свои достоинства и недостатки, и нет единого универсального алгоритма,
который бы работал одинаково в любых ситуациях. И наконец, в алгоритмах сор-
тировки используется множество разнообразных методов, таких как рекурсия,
бинарные деревья, применение генератора случайных чисел, что уменьшает веро-
ятность выпадения наихудшего случая.
Глава 10 посвящена вопросам сортировки. Как только список отсортирован, про-
грамме может потребоваться найти в нем какой-либо элемент. В этой главе сравни-
ваются наиболее эффективные методы поиска элементов в сортированных списках.
В главе 11 приводятся более быстрые, чем использование деревьев, способы
сортировки и поиска, методы сохранения и размещения элементов. Здесь описы-
вается несколько методов хеширования, включая использование блоков и связан-
ных списков, а также некоторые типы открытой адресации.
В главе 12 обсуждается другая категория алгоритмов - сетевая. Некоторые из
подобных алгоритмов, например, вычисление кратчайшего пути, непосредственно
применяются в физических сетях. Они могут косвенно использоваться для реше-
ния других проблем, которые на первый взгляд кажутся не относящимися к сетям.
Например, алгоритм поиска кратчайшего пути может делить сеть на районы или
находить критические точки в сетевом графике.
Глава 13 посвящена объектно-ориентированным алгоритмам. В них использу-
ются объектно-ориентированные способы реализации нетипичного для традици-
онных алгоритмов поведения.
В приложении 1 описывается содержание архива примеров, который находит-
ся на сайте издательства «ДМК Пресс» www.dmkpress.ru.
В приложении 2 содержатся все программы примеров, имеющихся в архиве.
Для того чтобы найти, какая из программ демонстрирует конкретные алгоритми-
ческие методы, достаточно обратиться к этому списку.

Архив примеров
Архив примеров, который вы можете загрузить с сайта издательства «ДМК
Пресс» www.dmkpress.ru. содержит исходный код в Delphi 3 для алгоритмов и при-
меров программ, описанных в книге.
Введение

Описанные в каждой главе примеры программ содержатся в отдельных под-


каталогах. Например, программы, демонстрирующие алгоритмы, которые рас-
сматриваются в главе 3, сохранены в каталоге \Ch3 \. В приложении 2 перечисля-
ются все приведенные в книге программы.

Аппаратные требования
Для освоения примеров необходим компьютер, конфигурация которого удов-
летворяет требованиям работы с Delphi, то есть почти каждый компьютер, рабо-
тающий с любой версией Windows.
На компьютерах с различной конфигурацией алгоритмы выполняются с неоди-
наковой скоростью. Компьютер с процессором Pentium Pro с частотой 200 МГц
и объемом оперативной памяти 64 Мб, безусловно, будет работать быстрее, чем
компьютер на базе процессора Intel 386 и объемом памяти 4 Мб. Вы быстро опре-
делите предел возможностей ваших аппаратных средств.

Как пользоваться этой книгой


В главе 1 дается базовый материал, поэтому необходимо начать именно с этой
главы. Даже если вам уже известны все тонкости теории алгоритмов, все равно не-
обходимо прочесть эту главу.
Следующими нужно изучить главы 2 и 3, поскольку в них рассматриваются
различные виды списков, используемых программами в следующих главах книги.
В главе 6 обсуждаются понятия, которые используются затем в главах 7,8, и 12.
Перед тем как заняться изучением этих глав, вы должны ознакомиться с главой 6.
Остальные главы можно читать в произвольном порядке.
В табл. 1 приведены три примерных плана работы с материалом. Вы можете
выбрать один из них, руководствуясь тем, насколько глубоко вы хотите изучить
алгоритмы. Первый план предполагает освоение основных методов и структур
данных, которые вы можете успешно использовать в собственных программах.
Второй план помимо этого включает в себя работу с фундаментальными алгорит-
мами, такими как алгоритмы сортировки и поиска, которые могут вам понадобить-
ся для разработки более сложных программ.
Последний план определяет порядок изучения всей книги. Несмотря на то, что
главы 7 и 8 по логике должны следовать за главой 6, она гораздо сложнее, чем бо-
лее поздние главы, поэтому их рекомендуется прочесть позже. Главы 7, 12 и 13
наиболее трудные в книге, поэтому к ним лучше обратиться в последнюю очередь.
Конечно, вы можете читать книгу и последовательно - от самой первой страницы
до последней.

Таблица 1. Планы работы


Изучаемый материал Главы
Основные методы 1 2 3 4
Базовые алгоритмы 1 2 3 4 5 6 9 10 13
Углубленное изучение 1 2 3 4 5 6 9 10 11 8 12 7 13
Обозначения, используемые в книге
В книге используются следующие шрифтовые выделения:
а курсивом помечены смысловые выделения в тексте;
а полужирным шрифтом выделяются названия элементов интерфейса: пунк-
тов меню, пиктограмм и т.п.;
а моноширинным шрифтом выделены листинги (программный код).

• •'

30
Глава 1. Основные понятия
В этой главе представлен базовый материал, который необходимо усвоить перед
началом более серьезного изучения алгоритмов. Она открывается вопросом «Что
такое алгоритмы?». Прежде чем погрузиться в детали программирования, стоит
вернуться на несколько шагов назад для того, чтобы более четко определить для
себя, что же подразумевается под этим понятием.
Далее приводится краткий обзор формальной теории сложности алгоритмов
(complexity theory). При помощи этой теории можно оценить потенциальную вы-
числительную сложность алгоритмов. Такой подход позволяет сравнивать различ-
ные алгоритмы и предсказывать их производительность в различных условиях
работы. В данной главе также приведено несколько примеров применения теории
сложности для решения небольших задач.
Некоторые алгоритмы на практике работают не так хорошо, как предполага-
лось при их создании, поэтому в данной главе обсуждаются практические вопросы
разработки программ. Чрезмерное разбиение памяти на страницы может сильно
уменьшить производительность хорошего в остальных отношениях приложения.
Изучив основные понятия, вы сможете применять их ко всем алгоритмам, опи-
санным в книге, а также для анализа собственных программ. Это позволит вам
оценить производительность алгоритмов и предупреждать различные проблемы
еще до того, как они приведут к катастрофе.

Что такое алгоритмы


Алгоритм - это набор команд для выполнения определенной задачи. Если вы
объясняете кому-то, как починить газонокосилку, вести автомобиль или испечь
пирог, вы создаете алгоритм действий. Подобные ежедневные алгоритмы можно
с некоторой точностью описать такого рода выражениями:
Проверьте, находится ли автомобиль на стоянке.
Убедитесь, что он поставлен на ручной тормоз.
Поверните ключ»
И т.д.
Предполагается, что человек, следующий изложенным инструкциям, может са-
мостоятельно выполнить множество мелких операций: отпереть и открыть двери,
сесть за руль, пристегнуть ремень безопасности, найти ручной тормоз и т.д.
Если вы составляете алгоритм для компьютера, то должны все подробно опи-
сать заранее, в противном случае машина вас не поймет. Словарь компьютера (язык
программирования) очень ограничен, и все команды должны быть сформулирова-
ны на доступном машине языке. Поэтому для написания компьютерных алгорит-
мов следует использовать более формализованный стиль.
выполнения алгоритмов i||
Увлекательно писать формализованный алгоритм для решения какой-либо бы-
товой, ежедневной задачи. Например, алгоритм вождения автомобиля мог бы начи-
наться примерно так:
Если дверь заперта, то:
Вставьте ключ в замок
Поверните ключ
Если дверь все еще заперта, то:
Поверните ключ в другую сторону
Потяните за ручку двери
и т.д.
Эта часть кода описывает только открывание двери; здесь даже не проверяет-
ся, та ли дверь будет открыта. Если замок заклинило или автомобиль оснащен про-
тивоугонной системой, алгоритм открывания двери может быть гораздо сложнее.
Алгоритмы были формализованы еще тысячи лет назад. Еще в 300 году до н.э.
Евклид описал алгоритмы для деления углов пополам, проверки равенства тре-
угольников и решения других геометрических задач. Он начал с небольшого сло-
варя аксиом, таких как «параллельные линии никогда не пересекаются», и создал
на их основе алгоритмы для решения более сложных задач.
Формализованные алгоритмы данного типа хорошо подходят для решения ма-
тематических задач, где нужно доказать истинность каких-либо положений или
возможность каких-нибудь действий, при этом скорость алгоритма не имеет зна-
чения. При решении реальных задач, где необходимо выполнить некоторые инст-
рукции, например сортировку на компьютере записей о миллионе покупателей,
эффективность алгоритма становится критерием оценки алгоритма.

Анализ скорости выполнения алгоритмов


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

Память или время


Многие алгоритмы предлагают выбор между объемом памяти и скоростью.
Задачу можно решить быстро, используя большой объем памяти, или медленнее,
занимая меньший объем.
Типичным примером в данном случае служит алгоритм поиска кратчайшего
пути. Представив карту города в виде сети, можно написать алгоритм для опреде-
ления кратчайшего расстояния между любыми двумя точками в этой сети. Чтобы
не вычислять эти расстояния всякий раз, когда они вам нужны, вы можете вывес-
ти кратчайшие расстояния между всеми точками и сохранить результаты в табли-
це. Когда вам понадобится узнать кратчайшее расстояние между двумя заданны-
ми точками, вы можете взять готовое значение из таблицы.
Ji Основные понятия
Результат будет получен практически мгновенно, но это потребует огромного
объема памяти. Карта улиц большого города, такогр как Бостон или Денвер, мо-
жет содержать несколько сотен тысяч точек. Таблица, хранящая всю информацию
о кратчайших расстояниях, должна иметь более 10 млрд ячеек. В этом случае вы-
бор между временем исполнения и объемом требуемой памяти очевиден: исполь-
зуя дополнительные 10 Гб памяти, можно сделать выполнение программы более
быстрым.
Из этой особенной зависимости между временем и памятью проистекает идея
объемо-временной сложности. При таком способе анализа алгоритм оценивается
как с точки зрения скорости, так и с точки зрения используемой памяти. Таким
образом находится компромисс между этими двумя показателями.
В данной книге основное внимание уделяется временной сложности, но также
указываются и некоторые Особые требования к объемам памяти для некоторых
алгоритмов. Например, сортировка слиянием (mergesort), рассматриваемая в гла-
ве 9, требует очень больших объемов оперативной памяти. Для других алгорит-
мов, например пирамидальной сортировки (heapsort), которая также описывается
в главе 9, достаточно обычного объема памяти.

Оценка с точностью до порядка


При сравнении различных алгоритмов важно понимать, как их сложность за-
висит от сложности решаемой задачи. При расчетах по одному алгоритму сорти-
ровка тысячи чисел занимает 1 с, сортировка миллиона чисел — 10 с, в то время
как на те же расчеты по другому алгоритму уходит 2 с и 5 с соответственно. В по-
добных случаях нельзя однозначно сказать, какая из этих программ лучше. Ско-
рость обработки зависит от вида сортируемых данных.
Хотя интересно иметь представление о точной скорости каждого алгоритма,
но важнее знать различие производительности алгоритмов при выполнении задач
различной сложности. В приведенном примере первый алгоритм быстрее сорти-
рует короткие списки, а второй - длинные.
Скорость алгоритма можно оценить по порядку величины. Алгоритм имеет
сложность O(f (N)) (произносится «О большое от F от N»), функция F от N, если
с увеличением размерности исходных данных N время выполнения алгоритма воз-
растает с той же скоростью, что и функция f (N). Например, рассмотрим следую-
щий код, который сортирует N положительных чисел:
for i := 1 to N do
begin
// Нахождение максимального элемента списка.
MaxValue := 0;
for j := 1 to N do
if (Value[j]>MaxValue) then
begin
MaxValue := Value[J];
MaxJ := J;
end;
Анализ скорости выполнения алгоритмов
// Печать найденного максимального элемента.
PrintValue(MaxValue);
// Обнуление элемента для исключения его из дальнейшего поиска.
Value[MaxJ] := 0;
end ;
В этом алгоритме переменная i последовательно принимает значения от 1 до N.
При каждом изменении i переменная j также изменяется от 1 до N. Во время каж-
дой из N-итераций внешнего цикла внутренний цикл выполняется N раз. Общее
2
количество, итераций внутреннего цикла равно N * N или N . Это определяет слож-
2 2
ность алгоритма O(N ) (пропорциональна N ).
Оценивая порядок сложности алгоритма, необходимо использовать только ту
часть уравнения рабочего цикла, которая возрастает быстрее всего. Предположим,
3
что рабочий цикл алгоритма представлен формулой N + N. В таком случае его
3
сложность будет равна O(N ). Рассмотрение быстро растущей части функции по-
зволяет оценить поведение алгоритма при увеличении N.
При больших значениях N для процедуры с рабочим циклом №+N первая часть
уравнения доминирует и вся функция сравнима со значением №. Если N = 100, то
3
разница между N +N = 1 000 100 и №= 1 000 000 равна всего лишь 100, что состав-
ляет 0,01%. Обратите внимание на то, что это утверждение истинно только для
3 3
больших N. При N = 2 разница между N + N = 10 и N = 8 равна 2, что составляет
уже 20%.
При вычислении значений «большого О» можно не учитывать постоянные
множители в выражениях. Алгоритм с рабочим циклом 3 * N2 рассматривается как
O(N2). Таким образом, зависимость отношения O(N) от изменения размера задачи
более очевидна. Если увеличить N в 2 раза, эта двойка возводится в квадрат (N2)
и время выполнения алгоритма увеличивается в 4 раза.
Игнорирование постоянных множителей также облегчает подсчет шагов вы-
полнения алгоритма. В приведенном ранее примере внутренний цикл выполняет-
ся N2 раз. Сколько шагов делает каждый внутренний цикл? Чтобы ответить на этот
вопрос, вы можете вычислить количество условных операторов if, потому что
только этот оператор выполняется в цикле каждый раз. Можно сосчитать общее
количество инструкций внутри условного оператора i f. Кроме того, внутри внеш-
него цикла есть инструкции, не входящие во внутренний цикл, такие как команда
PrintValue. Нужно ли считать и их?
С помощью различных методов подсчета можно определить, какую сложность
имеет алгоритм N2,3 * N2, или 3 * N2 + N. Оценка сложности алгоритма по порядку
величины даст одно и то же значение О(№), поэтому неважно, сколько точно ша-
гов имеет алгоритм.

Определение сложности
Наиболее сложными частями программы обычно является выполнение цик-
лов и вызовов процедур. В предыдущем примере весь алгоритм выполнен с помо-
щью двух циклов.
Если одна процедура вызывает другую, то необходимо более тщательно оценить
сложность последней. Если в ней выполняется определенное число инструкций,
Основные понятия
например, вывод на печать, то на оценку порядка сложности она практически не
влияет. С другой стороны, если в вызываемой процедуре выполняется O(N) ша-
гов, то функция может значительно усложнять алгоритм. Если процедура вызыва-
ется внутри цикла, то влияние может быть намного больше.
В качестве примера возьмем программу, содержащую медленную процедуру
Slow со сложностью порядка О(№) и быструю процедуру Fast со сложностью
порядка О(№). Сложность всей программы зависит от соотношения между этими
двумя процедурами.
Если при выполнении циклов процедуры Fast всякий раз вызывается проце-
дура Slow, то сложности процедур перемножаются. Общая сложность равна про-
изведению обеих сложностей. В данном случае сложность алгоритма составляет
O(N2) * O(N3) или О(№* N2) = О(№). Приведем соответствующий фрагмент кода:
procedure Slow;
var
i, j, k : Integer;
begin
for i := 1 to N do
for j := 1 to N do
for k := 1 to N do
1
// Выполнение каких-либо действий.
end;
procedure Fast;
var
i, j : Integer;
begin
for i := 1 to N do
for j := 1 to N do
Slow; // Вызов процедуры Slow.
end;
procedure RunBoth;
begin
Fast;
end;

С другой стороны, если основная программа вызывает процедуры отдельно,


их вычислительная сложность складывается. В этом случае итоговая сложность
по порядку величины равна O(N3) + O(N2) = O(N3). Следующий фрагмент кода
имеет именно такую сложность:
procedure Slow;
var
i, j, k : Integer;
begin
for i := 1 to N do
for j := 1 to N do
for k := 1 to N do
// Выполнение каких-либо действий.
end;
procedure Fast;
var
i, j : Integer;
begin
for i := 1 to N do
for j := 1 to N do
// Выполнение каких-либо действий.
end;
procedure RunBoth;
begin
Fast;
Slow;
end;

Сложность рекурсивных алгоритмов


Рекурсивные процедуры (recursive procedure) - это процедуры, которые вызы-
вают сами себя. Их сложность определяется очень тонким способом. Сложность
многих рекурсивных алгоритмов зависит именно от количества итераций рекур-
сии. Рекурсивная процедура может казаться достаточно простой, но она может
очень серьезно усложнять программу, многократно вызывая саму себя.
Следующий фрагмент кода описывает процедуру, которая содержит только две
операции. Тем не менее, если задается число N, то эта процедура выполняется N раз.
Таким образом, вычислительная сложность данного алгоритма равна O(N).
procedure CountDown(N : Integer);
begin
if (N<=0) then exit;
CountDown(N-l) ;
end;

Многократная рекурсия
Рекурсивный алгоритм, вызывающий себя несколько раз, называется много-
кратной рекурсией (multiple recursion). Процедуры множественной рекурсии слож-
нее анализировать, чем однократные алгоритмы, кроме того, они могут сделать ал-
горитм гораздо сложнее.
Следующая процедура аналогична процедуре Count Down, только она вызы-
вает саму себя дважды.
procedure DoubleCountDown(N : Integer) ;
begin
if (N<=0) then exit;
DoubleCountDown(N-l) ;
DoubleCountDown(N-l) ;
end;
Поскольку процедура вызывается дважды, можно было бы предположить, что
ее рабочий цикл будет вдвое больше, чем цикл процедуры CountDown. При этом
Основные понятия
сложность была бы равна 2 * O(N) = O(N). В действительности ситуация гораздо
сложнее.
Если количество итераций процедуры при входном значении N равно T(N), то
легко заметить, что Т(0) равно 1. Если процедура вызывается с параметром 0, то
программа просто закончит свою работу с первого шага.
Для больших значений N процедура запускается дважды с параметром N - 1.
Количество ее итераций при этом равно 1 + 2 * T(N - 1). В табл. 1.1 приведены
некоторые значения сложности алгоритма в соответствии с уравнениями Т(0) - 1
и T(N) = 1 + 2 * T(N - 1). При внимательном рассмотрении этих значений можно
заметить, что если T(N) = 2(N+'> - 1, то рабочий цикл процедуры будет равен O(2N).
Несмотря на то, что процедуры CountDown и DoubleCountDown выглядят почти
одинаково, DoubleCountDown выполняется гораздо дольше.
Таблица 1.1. Значения длительности рабочего цикла
для процедуры DoubleCountDown
N 0 1 2 3 4 5 6 7 8 9 10~
T(N) 1 3 7 15 31 63 127 255 511 1023 2047

Косвенная рекурсия
Рекурсивная процедура может выполняться косвенно, вызывая вторую про-
цедуру, которая, в свою очередь, вызывает первую. Косвенную рекурсию даже
сложнее анализировать, чем многократную. Алгоритм кривых Серпинского, рас-
сматриваемый в главе 5, включает в себя четыре процедуры, которые являются
одновременно и многократной и косвенной рекурсией. Каждая из этих процедур
вызывает себя и три другие процедуры до четырех раз. Такой значительный объем
работы выполняется в течение времени O(4N).
' : • . . '

Объемная сложность рекурсивных алгоритмов


Для некоторых рекурсивных алгоритмов особенно важна объемная сложность.
Очень просто написать рекурсивный алгоритм, который запрашивает небольшой
объем памяти при каждом вызове. Объем занятой памяти может увеличиваться
в процессе последовательных рекурсивных вызовов. По этой причине необходимо
провести хотя бы поверхностный анализ объемной сложности рекурсивных про-
цедур, чтобы убедиться, что программа не исчерпает при выполнении все доступ-
ные ресурсы.
Следующая процедура выделяет больше памяти при каждом вызове. После 100
или 200 рекурсивных обращений процедура займет всю свободную память компь-
ютера, и программа аварийно остановится, выдав сообщение об ошибке Out of
Memory (Недостаточно памяти).
procedure GobbleMemory;
var
x : Pointer;
begin
_С^едний и наихудший случай
GetMem(х,100000); // Выделяет 100000 байт.
GobbleMemory;
end;

В главе 5 вы найдете более подробную информацию о рекурсивных алгоритмах.


1

Средний и наихудший случай


Оценка сложности алгоритма до порядка является верхней границей сложнос-
ти алгоритмов. Если программа имеет больший порядок сложности, это не означа-
ет, что алгоритм будет действительно выполняться так долго. При задании правиль-
ных данных выполнение многих алгоритмов занимает гораздо меньше времени, чем
можно предположить на основании порядка их сложности. Например, следующий
код иллюстрирует простой алгоритм, который определяет расположение элемента
в списке.
function Locateltem(target : Integer) : Integer;
var
i : Integer;
begin
for i := 1 to N do
if (Value(i]=target) then
, .
begin
Result := i;
break;
end;
end;
Если искомый элемент находится в конце списка, то программе придется ис-
следовать все N элементов списка, чтобы обнаружить нужный. Это займет ty ша-
гов, и сложность алгоритма будет равна O(N). В данном, так называемом наихуд-
шем случае (worst case) время работы алгоритма будет максимальным.
С другой стороны, если искомый число расположено в самом начале списка,
алгоритм завершит работу почти сразу же. Он выполнит несколько шагов, прежде
чем найдет искомый номер и остановится. Это наилучший случай (best case) со
сложностью порядка О(1). Строго говоря, подобный случай не очень интересен,
поскольку он вряд ли произойдет в реальной жизни. Интерес представляет сред-
ний или ожидаемый вариант (expected case) поведения алгоритма.
Если номера элементов в списке изначально беспорядочно смешаны, то иско-
мый элемент может оказаться в любом месте списка. В среднем потребуется ис-
следовать N/2 элементов для того, чтобы найти требуемый. Значит, сложность это-
го алгоритма в усредненном случае будет порядка O(N/2), или O(N), если убрать
постоянный множитель.
Для некоторых алгоритмов наихудший случай сильно отличается от ожидаемо-
го случая. Например, алгоритм быстрой сортировки, описанный в главе 9, имеет
наихудший случай поведения О(№), а ожидаемое поведение равно O(N * log(N)),
что гораздо быстрее. Алгоритмы, подобные алгоритму быстрой сортировки, иногда
Il
lll
i Основные понятия
делают очень длинными, чтобы исключить возникновение наихудшего случая по-
ведения.

Общие функции оценки сложности


В табл. 1.2 приведены некоторые функции, которые наиболее часто исполь-
зуются для вычисления сложности. Функции перечислены в порядке возраста-
ния сложности. Это значит, что алгоритмы со сложностью, вычисляемой с помо-
щью функций, которые помещены вверху таблицы, будут выполняться быстрее
алгоритмов, сложность которых вычисляется с помощью ниже расположенных
функций.
Таблица 1.2. Общие функции оценки сложности
Функция Примечание
f(N) = С С - константа
f(N) = log(log(N))
f(N) = log(N)
f(N) = № С - константа между 0 и 1
f(N) = N
f(N) = N*log(N)
f(N) = Nc С - константа больше 1
f(N) = С" С - константа больше 1
f(N) = N! т.е. 1 * 2 * ... * N

Таким образом, уравнение сложности, которое содержит несколько этих функ-


ций, при приведении в систему оценки сложности по порядку величины будет со-
кращаться до функции, расположенной ниже в таблице. Например, O(log(N) + N2) -
это то же самое, что и О(№).
Сможет ли алгоритм работать быстрее, зависит от того, как вы его используе-
те. Если вы запускаете алгоритм раз в год для решения задач с достаточно малыми
объемами данных, то вполне приемлема производительность О(№). Если же алго-
ритм выполняется под наблюдением пользователя в интерактивном режиме, опе-
рируя большими объемами данных, то может быть недостаточно и производитель-
ности O(N).
Обычно алгоритмы со сложностью N * log(N) работают с очень хорошей ско-
ростью. Алгоритмы со сложностью Nc при небольших значениях С, например N2,
применяются, когда объемы данных ограничены. Вычислительная сложность ал-
горитмов, порядок которых определяется функциями CN и N! очень велика, поэто-
му эти алгоритмы пригодны только для решения задач с очень малым объемом
перерабатываемой информации.
Один из способов рассмотрения относительных размеров этих функций за-
ключается в определении времени, которое требуется для решения задач различных
размеров. Табл. 1.3 показывает, как долго компьютер, осуществляющий миллион
CK
£E°,<-Jb Работы алг°Ритлл§
операций в секунду, будет выполнять некоторые медленные алгоритмы. Из таб-
лицы видно, что только небольшие задачи можно решить с помощью алгоритмов
N
со сложностью O(C ), и самые маленькие - с помощью алгоритмов со сложнос-
тью O(N!). Для решения задач порядка O(N!), где N = 24, потребовалось бы боль-
ше времени, чем существует вселенная.
Таблица 1.3. Время выполнения сложных алгоритмов
N = 10 N = 20 N = 30 N = 40 N = 50
N
3
0,001 с 0,008 с 0,027 с 0,064 с 0,125с
2м 0,001 с 1,05с 17,9 мин 1 ,29 дней 35,7 лет
3" 0,059 с 58,1 мин 6,53 лет s
3,86 * 10 - лет 2,28* 1010лет
18
N! 3,63с 7,71 * 1 04 лет 8,41 * 10 лет 34
2,59 МО лет й
9,64* 10 лет
' • •
Логарифмы
Прежде чем продолжить изложение материала, необходимо рассмотреть ло-
гарифмы, так как они играют важную роль во многих алгоритмах. Логарифм чис-
ла N по основанию В - это степень Р, в которую нужно возвести число В, чтобы
выполнялось равенство Вр = N. Например, выражение Iog28 следует читать «сте-
пень, в которую необходимо возвести 2, чтобы получилось 8». В этом случае, 23= 8
или Iog28 = 3.
Преобразовывать логарифмы от одного основания к другому можно с помо-
щью зависимости logBN - logcN/logcB. Если вы хотите преобразовать Iog28 к осно-
ванию 10, то это будет выглядеть так: log,0N = log2N/log210. Значение Iog210 - кон-
станта, которая приблизительно равна 3,32. Поскольку постоянные множители
при оценке по порядку сложности можно опустить, допускается не учитывать член
bg210.
Для любого основания В значение log2B - константа. Это означает, что для
оценки по порядку сложности основание логарифма не имеет значения. Другими
словами, O(log2N) равно O(log10N) или O(logBN) для любого В. Поскольку основа-
ние логарифмов не имеет значения, часто просто пишут, что сложность алгоритма
составляет O(log-N).
В программировании используется двоичная система счисления, поэтому ло-
гарифмы, используемые при анализе сложности алгоритмов, обычно имеют осно-
вание 2. Для того чтобы упростить выражения, мы везде будем писать log N, под-
разумевая log2N. Если используется другое основание, это будет обозначено особо.

Скорость работы алгоритма в реальных условиях


Несмотря на то, что малые члены и постоянные множители отбрасываются при
изучении сложности алгоритмов, часто их необходимо учитывать для фактичес-
кого написания программ. Эти числа становятся особенно важными, когда размер
задачи мал, а константы большие.
Основные понятия
Предположим, нужно рассмотреть два алгоритма, которые выполняют одну
и ту же задачу. Первый выполняет ее за время O(N), а второй - за время O(N2).
Для больших N первый алгоритм, вероятно, будет работать быстрее.
При более близком рассмотрении обнаруживается, что первый описывается
функцией f(N) = 30 * N + 7000, а второй - f(N) = N2. В этом случае второй алго-
ритм при N меньше 100 существенно быстрее. Если вы знаете, что размер данных
задачи не превышает 100, то целесообразнее использовать второй алгоритм.
С другой стороны, время выполнения разных инструкций может сильно- отли-
чаться. Если первый алгоритм использует быстрые операции с памятью, а второй -
медленное обращение к диску, то первый алгоритм будет эффективнее в любом
случае.
Проблему выбора оптимального алгоритма осложняют и другие факторы. На-
пример, первый алгоритм может требовать больше памяти, чем установлено на
компьютере. Но на реализацию второго алгоритма, если он гораздо сложнее, мо-
жет уйти больше времени, а его отладка превратится в настоящий кошмар. Иногда
подобные практические соображения могут сделать теоретический анализ слож-
ности алгоритма почти бессмысленным.
Тем не менее анализ сложности помогает понять особенности алгоритмов и опре-
делить, в каком месте программы производится большая часть вычислений. Усовер-
шенствовав код в этих частях, можно существенно увеличить производительность
программы в целом.
Иногда лучшим способом для определения наиболее эффективного алгорит-
ма является тестирование. При этом важно, чтобы использовались данные, макси-
мально приближенные к реальным условиям. В обратном случае результаты тес-
тирования могут сильно отличаться от действительных.

Обращение к файлу подкачки


При работе в реальных условиях очень важным фактором является частота
обращения к файлу подкачки (page file). Операционная система Windows резер-
вирует определенный объем дискового пространства под виртуальную память
(virtual memory). Когда реальная память заполнена, Windows записывает часть ее
содержимого на диск. Этот процесс называется страничной подкачкой, потому что
Windows сбрасывает информацию в участки памяти, называемые страницами. Ос-
вободившуюся реальную память операционная система использует для других це-
лей. Страницы, записанные на диск, могут быть подгружены системой при обраще-
нии к ним обратно в память.
Поскольку доступ к диску намного медленнее, чем доступ к реальной памяти,
слишком частое обращение к файлу подкачки может очень сильно замедлять произ-
водительность приложения. Если программа работает с огромными объемами памя-
ти, система будет часто обращаться к диску, что существенно замедляет работу.
Приведенная в числе примеров программа Pager запрашивает все больше и боль-
ше памяти под создаваемые массивы, пока система не начнет обращаться к файлу
подкачки. Введите количество памяти в мегабайтах, которое программа должна
!
JPeanbH^CKopogrb работыма^^ ПН
запросить и нажмите кнопку Page (Подкачка). Если ввести небольшое значение,
например 1 или 2 Мб, программа создаст массив в оперативной памяти и будет
выполняться быстро.
Если вы введете значение, близкое к объему физической памяти вашего ком-
пьютера, программа начнет обращаться к файлу подкачки. При этом вы, вероятно,
услышите характерный звук работающего дисковода и сразу обратите внимание
на то, что программа выполняется намного дольше. Увеличение размера массива
на 10% может привести к увеличению времени выполнения до 100%.
Программа Pager использует память одним из двух способов. Если вы щелк-
нете по кнопке Page, программа начнет последовательно обращаться к элементам
массива. По мере перехода от одной части массива к другой части системе может
понадобиться подгружать их с диска. Как только страница загружена в оператив-
ную память, программа продолжает исследовать эту часть массива до тех пор, пока
не закончит работать с данной страницей.
Если вы щелкнете по кнопке Thrash (Пробуксовка), программа обращается
к разным участкам памяти случайным образом. В таком случае вероятность, что
нужный элемент будет расположен на диске, сильно возрастает. Система должна
будет постоянно обращаться к файлам подкачки для загрузки необходимых стра-
ниц в реальную память. Этот эффект называется пробуксовкой памяти (thrashing).
В табл. 1.4 приведено время выполнения программы Pager при обработке раз-
личных объемов памяти на компьютере с процессором Pentium 133 МГц и 32 Мб
оперативной памяти при одновременном выполнении нескольких других процес-
сов. Время работы будет зависеть от конфигурации компьютера, объема оператив-
ной памяти, скорости работы с диском, а также наличия других выполняющихся
в системе программ.

Таблица 1.4. Время выполнения программы Pager в секундах


Объем памяти (Мб) Подкачка Пробуксовка
4 0,62 0,75
8 1,35 1,56
12 2,03 2,33
16 4,50 39,46

Сначала время работы увеличивается пропорционально объему занятой памя-


ти. Когда начинается процесс создания файлов подкачки, скорость работы про-
граммы сильно падает. Обратите внимание, что до этого тесты с обращением к фай-
лу подкачки и пробуксовкой ведут себя одинаково, пока не начинается собственно
подкачка. Когда весь массив располагается в оперативной памяти, требуется оди-
наковое время для обращения к его элементам по порядку или случайным обра-
зом. Как только начинается подкачка, случайный доступ к памяти гораздо менее
эффективен.
Существует несколько способов минимизации эффектов подкачки. Основной
прием - экономное расходование памяти. Помните, что программа не может занять
Основные понятия
всю физическую память, так как часть ее используется под систему и другие
программы. Компьютер с характеристиками из предыдущего примера достаточ-
но тяжело работает уже тогда, когда программа занимает 16 из 32 Мб физичес-
кой памяти.
Второй способ - написать код так, чтобы программа обращалась к ячейкам фи-
зической памяти перед тем, как перейти к другим частям массива. Алгоритм сор-
тировки слиянием, описанный в главе 9, манипулирует данными в больших ячей-
ках памяти. Ячейки сортируются, а затем объединяются. Организованная работа
с памятью сокращает обращения к файлу подкачки.
Алгоритм пирамидальной сортировки, также описанный в главе 9, осуществ-
ляет переход от одной части списка к другой случайным образом. При очень боль-
ших списках это может приводить к перегрузке памяти. С другой стороны, сорти-
ровка слиянием требует большего объема памяти, чем пирамидальная сортировка.
Если список достаточно объемный, то при использовании памяти сортировкой сли-
янием программа будет обращаться к файлу подкачки.

Резюме
Анализ производительности помогает сравнить различные алгоритмы. Он по-
зволяет предсказать, как алгоритмы будут вести себя при различных обстоятель-
ствах. Указывая только те части алгоритма, на которые требуется наибольшее вре-
мя при выполнении программы, анализ помогает определить, доработка каких
участков кода увеличит производительность.
В программировании существует множество компромиссов, которые не допу-
стимы в реальных условиях. Один алгоритм может быть быстрее, но только за счет
использования огромного объема дополнительной памяти. Другой алгоритм про-
ще реализовать и поддерживать, но работать он будет медленнее, чем любой более
сложный алгоритм.
Проанализировав все имеющиеся алгоритмы, выяснив, как они ведут себя в раз-
личных условиях и какие требования предъявляются к ресурсам, вы сможете вы-
брать оптимальный вариант для решения поставленной перед вами задачи.
Глава 2. Списки
Массивы являются одним из самых важных инструментов программирования. Они
позволяют быстро и без особого труда управлять большими группами объектов,
используя стандартные приемы. К сожалению, массивы не обладают достаточной
гибкостью. Перераспределение элементов массива может быть сложным и зани-
мать много времени. Например, чтобы переместить элемент из одного конца мас-
сива в другой, необходимо перестроить весь массив. Нужно сдвинуть каждый эле-
мент на одну позицию, чтобы заполнить оставшуюся от другого элемента ячейку.
Затем необходимо поместить элемент в его новую позицию.
Динамические структуры данных позволяют быстро совершать такого рода
изменения. Всего за несколько шагов элемент из любой позиции в структуре дан-
ных перемещается в другое положение.
В этой главе описываются методы создания динамических списков в Delphi.
Различные виды списков имеют разные свойства. Некоторые достаточно Просты
и функционально ограничены, другие же, такие как циклические, двусвязные
списки и списки с указателями, сложнее и поддерживают более развитые средства
управления данными.
В следующих главах показано, как описанные здесь методы используются для
построения стеков, очередей, массивов, деревьев, хеш-таблиц и сетей.

Основные понятия о списках


Простейшая форма списка - это группа объектов. Она содержит некоторые
объекты и позволяет программе работать с ними. Если это все, что вам необходи-
мо, то вы можете в качестве списка использовать массив, отслеживая при помощи
переменной NumlnList число элементов в нем. Всякий раз, определив число име-
ющихся элементов, программа обращается к ним, используя цикл for, и выполня-
ет необходимые действия.
Если вы в своей программе можете обойтись этой простой стратегией, исполь-
зуйте ее. Этот метод эффективен, прост в отладке и эксплуатации. Однако многие
программы требуют более сложных версий даже для таких простых объектов, как
списки. В последующих разделах рассматриваются способы построения более
сложных и функциональных списков.
В первом разделе описываются варианты создания списков, которые можно
при необходимости увеличивать и сокращать. В некоторых программах нельзя за-
ранее определить, какого размера список потребуется. Решить эту проблему мож-
но, используя список, размер которого не зафиксирован.
|1 Списки
Следующий раздел посвящен неупорядоченным спискам (unordered list), ко-
торые позволяют удалять элементы из любой части списка. Неупорядоченные
списки позволяют управлять содержимым списка, как это возможно в простых
списках. Они более динамичны, потому что позволяют свободно изменять содер-
жимое списка в любое время.
Последующие разделы посвящены связанным спискам (linked list), которые
используют указатели для создания очень гибких структур данных. Вы можете
добавлять или удалять элементы из любой части связанного списка с минималь-
ными усилиями. В этих разделах также рассматриваются некоторые разновиднос-
ти связанных списков, такие как циклические, двусвязные и списки со ссылками.

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

Изменение размеров массивов


Delphi до версии 4.01 не позволяет изменять размеры массивов. После объяв-
ления размер массива остается постоянным. Однако с помощью указателей мож-
но создавать массивы с изменяемым размером - динамические массивы.
Сначала с помощью инструкции type следует определить тип массива с мак-
симальным размером. Чтобы индексы массива начинались с единицы, нужно уста-
новить его размер от 1 до 1 000 000, затем определить тип, который является указа-
телем на этот массив.
Для выделения памяти под массив используйте функцию GetMem. Ее второй
параметр указывает размер массива в байтах. Это значение должно быть равно
числу элементов массива, умноженному на размер каждого элемента. Определить
размер каждого элемента можно при помощи функции SizeOf.
Для освобождения памяти, выделенной под массив, необходимо использовать
процедуру FreeMem.
Программа SizeArr может служить примером изменения размеров массива. Вве-
дите количество элементов массива и нажмите кнопку Resize (Изменить размер).

1
Хотя, начиная с четвертой версии, Delphi поддерживает динамические массивы, вставка и удале-
ние элементов в середине такого массива иногда выполняется довольно долго, так как приходиться
переносить множество элементов, чтобы занять появившуюся пустую ячейку. Использование указа-
телей зачастую решает проблему низкой скорости алгоритма. - Прим. науч. ред.
Простые списки

Программа изменит размер массива. В следующем фрагменте кода представлены


наиболее интересные части программы.
type
// Определение типа массива.
.TIntArray = array [1.. 1000000] of Integer;
// Определение указателя на тип массив.
A
Pint Array = TInt Array;
// <часть кода пропущена>. . .
// Изменение размера массива.
procedure TSizeArrForm.CmdResizeClick(Sender : TObject) ;
var
Numltems : Integer; // Количество элементов массива.
Items : PIntArray; // Массив элементов.
I : Integer;
Txt : String;
begin
// Выделение памяти для массива.
Txt : =
Numltems := StrToInt (NumltemsText .Text) ;
GetMem(Items,NumItems*SizeOf (Integer) ) ;
// Заполнение массива значениями.
for i : = 1 to Numltems do
begin

Txt := txt+IntToStr(Items A [i] ) + ' ' ;


end;
ItemsLabel. Caption := txt;
// Освобождение массива.
FreeMemf Items) ;
end;
Изменение размеров массива — мощная, но несколько опасная методика. Ра-
ботая с массивом, Delphi не определяет его размер. В программе SizeArr Delphi
воспринимает массив как указатель, содержащий миллион ячеек. Если программа
фактически выделила память только для 10 элементов, Delphi не определит по-
пытку доступа к 1 00-му элементу как ошибку. Вместо того чтобы выдать при компи-
ляции сообщение о том, что индекс массива вышел за пределы, во время выполне-
ния программа будет пытаться сделать запись в 100-ю позицию массива. В лучшем
случае обращение к этой ячейке памяти просто остановит работу программы. В худ-
шем это вызовет неявный сбой, который будет очень сложно найти.
Подобная проблема возникает, если программа использует неверно заданную
нижнюю границу массива. Предположим, что тип массива определен так, как опи-
сано в следующем фрагменте кода:
TIntArray = array [1.. 1000000] of Integer;
I Списки
Подобную ошибку допустить очень просто. Неприятности начнутся, когда
программа попробует обратиться к элементу массива в нулевой позиции.
При объявлении в процедуре нового массива, такого как PIntArray, его гра-
ницы не указываются. Необходимо помнить, какой тип массива вы определили да-
лее в программе.
Программа освобождает выделенную для обычного массива память, когда он
выходит из области видимости. Например, массив, объявленный в пределах про-
цедуры, автоматически освобождается, когда процедура заканчивается.
С другой стороны, память, выделенная с помощью процедуры GetMem, остает-
ся таковой до тех пор, пока не освободится с помощью процедуры FreeMem. Пока
программа не будет завершена, доступа к памяти не будет. При этом неоднократ-
ный вызов процедуры занимает много системной памяти.
Наконец, существенную проблему создает обращение к памяти, освобожден-
ной процедурой FreeMem. Если программа освобождает память массива и затем
обращается к этому массиву, то следствием может быть либо ее остановка, либо
неявный сбой. Можно сократить вероятность возникновения такого эффекта,
сбрасывая указатель массива на нуль после освобождения памяти. В этом случае
вместо неявного сбоя попытка обращения к массиву вызовет ошибку нарушения
доступа.
Несмотря на подстерегающие опасности изменение размеров массива - очень
мощная методика. При работе со списками, меняющими свой размер, она позволя-
ет достигать очень высокой производительности.
Delphi, начиная с версии 4.0, поддерживает встроенный механизм изменяемых
массивов. По своему синтаксису работа со встроенными динамическими массива-
ми очень похожа на работу с обычными массивами языка Pascal.
Сначала следует объявить переменную массива, не указывая при этом его гра-
ниц. Изменение его размера производится с помощью процедуры SetLength. Так
как заранее длина массива не известна, потребуются еще три функции: Length,
возвращающая количество элементов массива, Low, возвращающая индекс перво-
го элемента (обычно 0) и High, возвращающая индекс последнего элемента.
// Изменение размера массива.
procedure TSizeArrForm.CmdResizeClick(Sender : TObject);
var
Numltems : Integer; // Количество элементов массива.
Items : Array Of Integer; // Массив элементов.
I : Integer;
Txt : String;
begin
// Инициализация массива.
NumIt ems : = S t rToInt(NumIt emsText.Text);
SetLength(Items,Numltems);
// Заполнение массива значениями.
Txt : =
for i := Low(Items) to High(Items) do
Простые списки

begin
Items[i] := i;
Txt := txt + IntToStr(Items[i]) + '' ;
end;
1
ItemsLabel.Caption := Txt;
// Освобождение массива.
SetLength(Items,0);
end;
* • •
Список переменного размера
С помощью динамических массивов вы можете построить простой список пе-
ременного размера. Новый элемент в список добавляется следующим образом. Со-
здайте новый массив, который на один элемент больше старого. Скопируйте эле-
менты старого массива в новый и добавьте новый элемент. Затем освободите старый
массив и установите указатель массива на новую страничку памяти.
Следующий фрагмент кода содержит операцию, которая добавляет элемент
в динамический массив. Для удаления элемента можно написать аналогичный код,
только массив необходимо сделать меньше.
var
List : PintArray; • // Массив.
Numltems : Integer; // Количество используемых элементов.
procedure Addltemfnew_item : Integer);
var
new_array : PintArray;
i : Integer;
begin
// Создание нового массива.
GetMem(new_array,(Numltems+l)*SizeOf(Integer)) ;
- // Копирование элементов в новый массив.
for i := 1 to Numltems do
new_array~ [ i ] := LisfMi];
// Сохранение нового элемента.
new_array [Numltems+l] := new_item,-
// Освобождение ранее выделенной памяти.
if (Numltems>0) then FreeMem(List);
// Установка указателя на новый массив.
List := new_array;
// Обновление размера.
NumItems := NumIt ems+1;
end;
Для динамических массивов Delphi 4 алгоритм добавления элемента в конец
списка будет еще проще - при изменении размера массива программа автомати-
чески создает новый и копирует в него содержимое старого.
Списки

List : Array Of Integer; // Массив.


procedure Addltem(new_item : Integer);
begin
// Увеличиваем размер массива на 1 элемент.
SetLength(List,Length(List)+1);
// Сохранение нового элемента.
List[High(List)] := new_item;
4
end;

Эта простая схема хорошо работает для небольших списков, но у нее есть два
существенных недостатка. Вр-первых, приходится часто менять размер массива.
Чтобы создать список из 1000 элементов, необходимо 1000 раз изменить размеры
массива. Ситуация осложняется еще тем, что чем больше становится список, тем
больше времени потребуется на изменение его размера, поскольку необходимо
каждый раз копировать растущий список в заново выделенную память.
Чтобы размер массива изменялся не так часто, при его увеличении можно
вставлять дополнительные элементы, например, по 10 элементов вместо 1. Когда
вы будете впоследствии прибавлять новые элементы к списку, они разместятся
в уже существующих в массиве неиспользованных ячейках, не увеличивая размер
массива. Новое приращение размера потребуется, только если пустые ячейки за-
кончатся.
Точно так же можно избежать изменения размера каждый раз при удалении
элемента из списка. Подождите, пока в массиве не накопится 20 неиспользован-
ных ячеек, и только потом уменьшайте его размер. При этом нужно оставить 10 пус-
тых ячеек для того, чтобы можно было добавлять новые элементы, не изменяя раз-
мер массива.
Обратите внимание, что максимальное число неиспользованных ячеек (20)
должно быть больше, чем минимальное (10). Это сокращает количество измене-
ний размера массива при добавлении или удалении элементов.
При такой схеме список будет содержать несколько свободных ячеек, однако
их число мало, и лишние затраты памяти невелики. Свободные ячейки позволяют
вам перестраховаться от изменения размеров массива всякий раз, когда необходи-
мо добавить или удалить элемент из списка. Фактически, если вы постоянно до-
бавляете или удаляете только один или два элемента, вам может никогда не пона-
добиться изменять размер массива.
Следующий код показывает применение этого способа для расширения списка:
var
List : PIntArray; // Массив.
Numltems : Integer; // Количество используемых элементов.
NumAllocated : Integer; // Количество заявленных элементов.
procedure Addltem(new_item : Integer);
var
new_array : PIntArray;
i : Integer;
begin
// Определение наличия свободных ячеек.
if (NumItems>=NumAllocated) then
begin
// Создание нового массива.
NumAllocated := NumAllocated+10;
GetMem(new_array,NumAllocated*SizeOf(Integer));
// Копирование существующих элементов в новый массив.
for i := 1 to NumIterns do
new.array*[i] := ListA[i];
// Освобождение ранее выделенной памяти.
if (Numltems>0) then FreeMem(List);
// Установка указателя на новый массив.
List := new_array,-
end;
// Обновление количества элементов.
NumIterns := NumIterns+1;
// Сохранение нового элемента.
пем_аггаул[Numltems] := new_item;
end;
Для Delphi, начиная с 4 версии, этот код будет выглядеть следующим образом:
var
List : Array Of Integer; // Массив.
Numltems : Integer; // Количество используемых элементов.
procedure Addltem(new_item : Integer);
begin
// Определение наличия свободных ячеек.
if (NumItems>=Length(List)) then
begin
// Создание нового массива.
SetLength(List,Length(List)+10)
end;
// Обновление количества элементов.
Numltems := NumIterns+1;
// Сохранение нового элемента.
List[Numltems] := new_item;
end;
i. -
Но для очень больших массивов это не самое удачное решение. Если вам ну-
жен список из 1000 элементов, к которому обычно добавляется по 100 элементов,
на изменение размеров массива будет тратиться слишком много времени. В этом
случае лучше всего увеличивать размер массива не на 10, а на 100 или более ячеек.
Списки
Тогда вы сможете прибавлять по 100 элементов одновременно без лишнего расхо-
да ресурсов.
Более гибкое решение состоит в том, чтобы сделать количество дополнитель-
ных ячеек зависящим от текущего размера списка. В таком случае для небольших
списков приращение окажется тоже небольшим. Размер массива будет изменять-
ся чаще, но на это не потребуется большого количества времени. Для больших
списков приращение размера будет больше, поэтому их размер станет изменять-
ся реже.
Следующая программа пытается поддерживать приблизительно 10% списка
свободными. Когда массив полностью заполнен, его размер увеличивается на 10%.
Если количество пустых ячеек возрастет до 20% от размера массива, программа
уменьшает его.
При увеличении размера массива добавляется как минимум 10 элементов,
даже если 10% от размера массива меньше 10. Это сокращает количество необхо-
димых изменений размера массива при малых размерах списка.
var
List : PIntArray; . // Массив.
Numltems : Integer; // Количество используемых элементов.
NumAl located : Integer; // Количество заявленных элементов.
ShrinkWhen : Integer; // Уменьшение массива если
// NumItems<ShrinkWhen.
procedure ResizeList;
const
WANT_FREE_PERCENT=0.1; // Установка 10% неиспользуемого
// размера.
MIN_FREE=10; // Минимальный размер неиспользуемого
// объема массива при изменении
// размера массива.
var '
want_free, new_size, i : Integer;
new_array : PIntArray;
begin
// Какого размера должен быть массив.
want_free := Round (WANT_FREE_PERCENT*NumItems );
if (want_free<MIN_FREE) then want_free := MIN_FREE;
new_size := Numltems+want_free;
// Изменение размера массива с сохранением старых значений.
// Создание нового масива.
GetMem(new_array,new_size*SizeOf (Integer) ) ;
// Копирование существующих значений в новый массив.
for i : = 1 to Numltems do
i] := List/4[i];
// Освобождение ранее выделенной памяти.
if ( NumAl located>0) then FreeMem(List) ;
NumAllocated := new_size;
Простые списки
// Установка 'указателя на новый массив.
List := new_array;
// Вычисление значения ShrinkWhen. Размер массива изменяется, если он
// уменьшается до значения NumItems<ShrinkWhen.
ShrinkWhen. := Numltems-want_free;
end;
Для Delphi, начиная с 4 версии, этот код будет выглядеть следующим образом:
var
List : Array Of Integer; // Массив.
Numltems : Integer; // Количество используемых элементов.
ShrinkWhen : Integer; // Уменьшение массива если
// Numltems<ShrinkWhen.
procedure ResizeList;
const
WANT_FREE_PERCENT=0.1; // Установка 10% неиспользуемого размера.
MIN_FREE=10; // Минимальный размер неиспользуемого
// объема массива при изменении
// размера массива.
var
want_free, new_size, i : Integer;
new_array : PIntArray;
begin
// Какого размера должен быть массив.
want_free := Round(WANT_FREE_PERCENT*Length(List));
if (want_free<MIN_FREE) then want_free := MIN_FREE;
• new_size := Length(List) +want_free;
// Изменение размера массива.
SetLength(List, new_size);
// Вычисление значения ShrinkWhen. Размер массива изменяется, если он
// уменьшается до значения Length(List)<ShrinkWhen.
ShrinkWhen := Length(List)-want_free;
end;

Класс SimpleList
Чтобы использовать изложенную выше стратегию, программе необходимо
знать все параметры списка, следить за размером массива, числом используемых
в настоящее время элементов и т.д. Если понадобится создавать несколько спис-
ков, то нужно многократно копировать все переменные и дублировать код, управ-
ляющий различными массивами.
Классы Delphi значительно упрощают эту задачу. Класс TSimpleList инкап-
сулирует структуру списков, облегчая управление ими. У этого класса есть мето-
ды Add и RemoveLast, используемые в основной программе.
Также в нем присутствует функция Item, которая возвращает значение опре-
деленного элемента списка. Она проверяет, чтобы индекс требуемого элемента был
Списки

в пределах установленных границ массива. Если это не так, то функция вызывает


ошибку диапазона (Out of bounds). При этом происходит остановка программы
вместо возникновения неявного сбоя.
Процедура ResizeList объявлена как частная внутри класса TSimpleList.
Это скрывает изменение размера списка от основной программы, поскольку код
должен функционировать только внутри класса.
С помощью класса TSimpleList в приложениях можно создавать несколько
списков. Для построения списка достаточно объявить объект типа TSimpleList
и далее использовать метод Create этого класса. Каждый объект имеет свои пере-
менные, поэтому любой из них может управлять отдельным списком.
var
Listl, List2 : TSimpleList;

procedure MakeLists;
begin
// Создание объектов TSimpleList.
Listl := TSimpleList.Create;
List2 := TSimpleList.Create;
end;

Программа SimList демонстрирует использование класса TSimpleList. Для


того чтобы добавить элемент к списку, укажите значение в поле ввода и щелкните
по кнопке Add (Добавить). Объект TSimpleList при необходимости изменяет
размеры массива. Если список еще не пуст, удалите последний элемент списка,
нажав кнопку Remove (Удалить).
Когда объект TSimpleList изменяет размеры массива, он выводит окно сооб-
щения, в котором содержится информация о размере массива, количестве неисполь-
зованных элементов в нем и значении переменной Shr inkWhen. Когда число исполь-
зованных ячеек массива падает ниже значения ShrinkWhen, программа уменьшает
размеры массива. Обратите внимание, что когда массив почти пуст, переменная
ShrinkWhen становится равной нулю или отрицательной. В этом случае размер мас-
сива не будет уменьшаться, даже если вы удалите из списка все элементы.
Программа SimList прибавляет к массиву 50% пустых ячеек, если необходимо
увеличить его размер, но всегда оставляет как минимум одну пустую ячейку. Эти
значения были выбраны для удобства работы с программой. В реальных приложе-
ниях процент свободной памяти должен быть меньше, а минимальное число сво-
бодных ячеек - больше.
Большие значения порядка 10% текущего размера списка и минимум 10 неис-
пользуемых записей были бы более приемлемы.

Неупорядоченные списки
В некоторых приложениях требуется удалять одни элементы из середины
списка, добавляя другие в его конец. Это может быть в случае, когда порядок эле-
ментов не важен, но необходимо иметь возможность удалять определенные эле-
менты из списка. Списки данного типа называются неупорядоченными списками
(unordered list).
Неупорядоченные списки
Неупорядоченный список должен поддерживать следующие операции:
а добавление элемента к списку;
о удаление элемента из списка;
Q определение наличия элемента в списке;
а выполнение каких-либо операций (например, печати или вывода не дис-
плей) для всех элементов списка.
Для управления подобным списком вы можете изменить простую схему, пред-
ставленную в предыдущем разделе. Когда удаляется элемент из середины списка,
оставшиеся элементы сдвигаются на одну позицию, запол-
няя образовавшийся промежуток. Это показано на рис. 2.1,
где из списка удаляется второй элемент, а третий, четвер-
тый и пятый элементы сдвигаются влево, занимая свобод- |А|С|Р|Ё"Г
ный участок.
ис
Удаление элемента массива подобным способом может за- ' Удаление
., элемента
нимать много времени, особенно если этот элемент находит- изсерединымассива
ся в начале списка. Чтобы удалить первый элемент массива,
содержащего 1000 записей, необходимо сдвинуть 999 элементов на одну позицию
влево. Гораздо быстрее удалять элементы при помощи простой схемы сборки мусора.
Вместо удаления элементов из списка отметьте их как неиспользуемые. Если
элементы списка - данные простых типов, например целочисленные, то можно
маркировать их с помощью так называемого «мусорное» значения. Для целых чи-
сел можно использовать значение -32767. Вы присваиваете это значение любому
неиспользуемому элементу. Следующий фрагмент кода показывает, как можно
•удалить элемент из подобного целочисленного списка.
const
GARBAGE_VALUE=-32767;
// Пометка элемента как ненужного.
procedure RemoveFromList(position : Integer);
begin
List*[position] := GARBAGE_VALUE ;
end;
: ••;-';••.-. v • • ,:.,; v ; - . .-, •• ,- ) .
И соответственно для динамических массивов:
const
GARBAGE_VALUE=-32767;

// Пометка элемента как ненужного.


procedure RemoveFromList(position : Integer);
begin
List[position] := GARBAGE_VALUE ;
end;
' , /
Если элементы списка - это структуры, определенные оператором Туре, то
можно добавить к ним новое поле IsGarbage. При удалении элемента из списка
значение поля IsGarbage устанавливается в True.
Списки
type
MyRecord = record
Name : String[20]; // Данные.
IsGarbage : Boolean; // Является ли элемент ненужным?
end;
// Пометка элемента как 'ненужного.
procedure RemoveFromList(position : Integers);
begin
List^[position].IsGarbage := true;
end;
И соответственно для динамических массивов:
type
MyRecord = record
Name : String[20]; // Данные.
• IsGarbage : Boolean; // Является ли элемент ненужным?
end;
var
List : Array Of MyRecord;
// Пометка элемента как ненужного.
procedure RemoveFromList(position : Integers);
begin
List[position].IsGarbage := true;
end;
Для упрощения примера далее в этом разделе предполагается, что все эле-
менты имеют целочисленный тип данных и их можно помечать «мусорным» зна-
чением.
Теперь необходимо изменить другие процедуры, использующие список, чтобы
они пропускали маркированные элементы. Например, так можно модифицировать
процедуру, отображающую элементы списка:
// Отображение элементов списка.
procedure Showlt ems;
var
i : Integer;
begin
For i := 1 to Numltems do
if (Lisf4 [i]<>GARBAGE_VALUE) then // Если элемент значащий.
ShowMessagedntToStr (List" [i]) ) ; // Печать этого элемента.
end;
И соответственно для динамических массивов:
// Отображение элементов списка.
procedure Showltems;
var
i : Integer;
Неупорядоченные списки
begin
For i := Low(List) to High(List) do
if (List [i] <>GARBAGE_VALUE) then // Если элемент значащий.
ShowMessagedntToStr (List [ i ] ) ) ; // Печать этого Элемента.
end;
Через некоторое время список может переполниться «мусором». В результате
процедуры, подобные приведенной выше, больше времени будут тратить на про-
пуск ненужных элементов, чем на обработку реальных данных.
Во избежание такой ситуации надо периодически выполнять процедуру сбор-
ки мусора (garbage collection routine). Эта процедура перемещает все непомечен-
ные элементы в начало массива. После этого они добавляются к неиспользуемым
элементам в конце массива. Когда вам потребуется включить в список дополни-
тельные элементы, можно повторно использовать помеченные ячейки без измене-
ния размера массива.
После добавления дополнительных неиспользуемых записей к другим свобод-
ным ячейкам полный объем свободного пространства может стать слишком боль-
шим. В этом случае следует уменьшить размер массива, чтобы освободив память
(для динамических массивов Delphi 4 код будет практически идентичным):
procedure CollectGarbage;
var
i, good : Longint;
begin
good := 1; / / Н а это место ставится первый значащий элемент.
for i := 1 to NumIterns do
' begin
// Если элемент значащий, то он перемещается на новую позицию.
if (not (List A [i]=GARBAGE_VALUE)) then
begin
if (goodoi) then
List A [good] := L i s t A [ i ] ;
Good := good+1;
end;
end;
// Позиция, где находится последний значащий элемент.
Numltems := good-1;
end;
Когда выполняется процедура сборки мусора, используемые элементы пере-
мещаются из конца списка в начало, заполняя пространство, которое занимали
помеченные элементы. Это означает, что позиции элементов в списке могут изме-
ниться во время этой операции. Если другие части программы обращаются к эле-
ментам списка по их исходным позициям, то необходимо модифицировать про-
цедуру сборки мусора так, чтобы она обновляла ссылки на положение элементов
в списке. Подобные преобразования достаточно сложны и затрудняют сопровож-
дение программ.
Списки
Существует несколько этапов в работе приложения, когда стоит выполнить
подобную чистку памяти. Один из них - когда массив достигнет определенного
размера, например, когда список содержит 30 000 записей.
Этому методу присущи некоторые недостатки. Во-первых, он требует большо-
го объема памяти. Если вы часто добавляете или удаляете элементы, то «мусор»
заполнит большую часть массива. Такое неэкономное расходование памяти может
привести к процессу подкачки, особенно если список не помещается полностью
в оперативной памяти. Это будет занимать, в свою очередь, больше времени при
перестройке массива.
Во-вторых, если список начинает заполняться ненужными данными, процеду-
ры, использующие его, станут очень неэффективными. Если в массиве из 30 000
элементов 25 000 не используются, то процедуры, подобные описанной ранее про-
цедуре Showltems, будут выполняться слишком медленно.
И наконец, сборка мусора в очень большом массиве может занимать значитель-
ное время, особенно если сканирование массива заставляет программу обращать-
ся к файлам подкачки. Это может вызвать остановку программы на несколько се-
кунд, пока не очистится память.
Чтобы решить подобную проблему, достаточно создать новую переменную
GarbageCount для отслеживания числа неиспользуемых элементов в списке. Ког-
да не используется существенная доля памяти списка, можно начать «сборку му-
сора». В следующем фрагменте кода переменная MaxGarbage сохраняет макси-
мальное число неиспользуемых записей, которые может содержать список:
// Удаление элемента из списка.
procedure Remove(index:Longint);
begin
List A [index] := GARBAGE_VALUE ;
NumGarbage := NumGarbage+1;
if (NumGarbage>MaxGarbage) then CollectGarbage;
end;
Программа Garbage демонстрирует метод сборки мусора. Она отображает не-
используемые элементы списка как <UNUSED>, а записи, помеченные как му-
сор - <GARBAGE>. Используемый программой класс TGarbageList аналоги-
чен классу TSimpleLi st, используемому программой SimList, но дополнительно
выполняет «сборку мусора».
Чтобы добавить элемент к списку, введите значение и нажмите кнопку Add. Для
удаления элемента выделите его, а затем нажмите кнопку Remove. Если список со-
держит слишком много «мусора», программа начнет выполнять чистку памяти.
Когда объект TGarbageList изменяет размер списка, программа выводит
окно сообщений, в котором приводится количество используемых и неиспользуе-
мых элементов списка и значения переменных MaxGarbage и ShrinkWhen. Если
удалить довольно много элементов и их количество превысит значение перемен-
ной MaxGarbage, то программа начинает «сборку мусора». Как только этот про-
цесс заканчивается, программа уменьшает размер массива, чтобы он содержал
меньшее, чем значение ShrinkWhen, число элементов.
Связанные списки

Программа Garbage при изменении размера массива добавляет еще 50% пус-
тых ячеек и всегда оставляет как минимум одну свободную ячейку при любом из-
менении размера. Эти значения были выбраны для упрощения работы пользова-
теля со списком. В реальной программе процент свободной памяти должен быть
меньше, и минимальное число свободных элементов - больше. Оптимальными
являются значения порядка 10% текущего размера списка и 10 свободных ячеек.

Связанные списки
При управлении связанными списками применяется другая стратегия. Связан-
ный список хранит элементы в структурах данных или объектах, названных ячей-
ками (cells). Каждая ячейка содержит указатель на следующую ячейку в списке.
В классе, задающем ячейку, должна быть переменная NextCell, которая ука-
зывает на следующую ячейку в списке. В нем также должны быть определены пере-
менные для хранения любых данных, с которыми будет работать программа. Напри-
мер, в связанном списке с записями о сотрудниках эти поля могли бы содержать имя
служащего, номер страхового полиса, должность и т.д. Определения для структуры
TEmpCell будут выглядеть таким образом:
type
PEmpCell = ЛТЕтрСе11;
TEmpCell = record
EmpName : String[20];
SSN : String[11];
JobTitle : String[10];
NextCell : PEmpCell;
end;

Для создания новых ячеек программа использует оператор New, выделяя под
них необходимое количество памяти.
Программа должна сохранять указатель на начало списка. Для того чтобы опре-
делить, где заканчивается список, она устанавливает значение NextCell для по-
следнего элемента в n i 1. Например, следующий фрагмент кода создает список, со-
держащий информацию о трех служащих:
var
top_cell, celll, cell2, cell3 : PEmpCell;
begin
// Построение ячеек.
New(celll);
ce111Л.EmpName : = 'Стивене';
celll^.SSN := '123-45-6789';
celll".JobTitle := 'Автор'; ,
New(cell2);
се!12Л.EmpName := 'Кэтс';
cell2~.SSN := '234-56-7890';
|; Списки
л
се!12 .JobTitle := 'Юрист';
New(cell3);
cell3~.EmpName := ' Т у л е ' ;
cell3".S.SN := '345-67-8901';
Л
се!13 .JobTitle := 'Менеджер';
\ - .
// Связывание элементов списка для построения связанного списка.
celll^NextCell := се!12;
A
cel!2 .NextCell := се113;
A
ce!13 .NextCell := nil;
// Установка указателя на начало списка.
top_cell := celll;
На рис. 2.2 изображено схематичное представление этого связанного списка.
Прямоугольники соответствуют ячейкам, а стрелки - указателям на объекты. Ма-
ленький перечеркнутый квадрат представляет значение nil, которое указывает на
конец списка. Обратите внимание, что top_cell, celll, се!12 и cells - это не
фактические объекты, а только указатели на них.

Первая ячейка
Ячейка 1

Рис. 2.2. Связанный список

Следующий код использует связанный список, сформированный при помощи


предыдущего примера, для отображения имен служащих. Переменная ptr пред-
ставляет собой указатель на элементы списка и первоначально отсылает в начало
списка. В коде применяется цикл while для перемещения через весь список до
тех пор, пока значение ptr не достигнет конца списка. Во время каждого шага
цикла процедура выводит поле EmpName для ячейки, указанной переменной ptr.
Затем программа передвигает ptr, чтобы указать следующую ячейку списка. В ко-
нечном итоге ptr достигает конца списка и получает значение nil, а цикл оста-
навливается.
var
ptr : PEmpCell;
begin
ptr := top_cell; // Начинает с начала списка.
while (ptronil) do
begin
Связанные списки
// Отображает поле EmpName текущей ячейки.
A
ShowMessage(ptr .EmpName);

// Переход к следующей ячейке списка.
Ptr := ptr~.NextCell;
end;
end;

Использование указателя на другой объект называется косвенной адресацией,


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

Добавление элементов
Простой связанный список, изображенный на рис, 2.2, обладает некоторыми
важными свойствами. Во-первых, в начало списка очень просто добавлять новые
ячейки. Установите значение переменной Next Се 11 для новой ячейки так, чтобы
она указывала на текущую вершину списка, затем указатель top_cell на новую
ячейку. Рис. 2.3 иллюстрирует эту операцию. Соответствующий код на Delphi для
этой операции достаточно прост:
new_cellA.NextCell := top_cell;
top_cell := new_cell;

Сравните этот код с кодом, который требовался для добавления элемента в спи-
сок на базе массива. Там вы должны были перемещать каждый элемент массива на
одну позицию, чтобы освободить место для нового элемента. Если список доста-
точно длинный, эта операция со сложностью O(N) может занимать много време-
ни. Используя связанный список, можно добавить новый элемент в начало списка
всего за два шага.

V
А•• —из

Новая ячейка
Рис. 2.3. Добавление элемента в начало связанного списка
If Списки
Так же легко вставить новый элемент и в середину связанного списка. Пред-
положим, вы хотите добавить новый элемент после ячейки, на которую указывает
переменная af ter_me. Установите значение переменной NextCell новой ячей-
ки равным af ter_me~ .NextCell. Затем установите указатель after_me^ .Next-
Cell на новую ячейку. На рис. 2.4 показана эта операция. И снова используется
простой код Delphi:
A л
new_cel! .NextCell := а^ег_те .NextCell;;
v
after'_me' .NextCell := new_cell;

Ячейка «после меня»

Первая ячейка• \/
А

Т
Новая ячейка
; - '

Рис. 2.4. Добавление элемента в середину связанного списка

Удаление элементов
Удалить элемент из начала связанного списка так же просто, как и доба-
вить его. Просто установите указатель top_cell на следующую ячейку спис-
ка (см. рис. 2.5). Исходный текст для этой операции еще проще, чем код для до-
бавления элемента:
top_cell := top_cell A .NextCell;

Удаленная ячейка

I
Верхняя ячейка —^^ »

Рис. 2.5. Удаление элемента из начала связанного списка

Когда указатель top_cel 1 перемещается на второй элемент списка, в програм-


ме больше не остается переменных, ссылающихся на первый объект. В этом случае
память для этого объекта останется выделенной, но доступа к нему не будет. Чтобы
избежать этого, программе требуется сохранить указатель на объект во временной
переменной. После сброса переменной top_cell программа должна использовать
директиву Dispose, чтобы освободить память, выделенную для данного объекта.
Связанные списки
target := top_cell;
top_cell := top_cell".NextCell;
Dispose(target);
Удалить элемент из середины списка так же просто. Предположим, вы хотите
удалить элемент после ячейки af ter_me. Просто установите значение NextCel 1
данной ячейки так, чтобы оно указывало на следующую ячейку. Для освобожде-
ния памяти под удаленную ячейку необходима временная переменная. На рис. 2.6
показана эта операция. Код Delphi имеет следующий вид:
A
target := after_me .NextCell;
A Л
after_me .NextCell := target .NextCel1;
Dispose(target) ;

Ччейка Удаленная

JL О:
«no еле меня» ячейка

Первая ^_ V
ячейка А *
'

Рис. 2.6. Удаление элемента из середины связанного списка

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

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

Обычно связанные списки удобнее, но списки на базе массивов имеют одно


существенное преимущество - они используют меньше памяти. Для связанного
списка необходимо добавить к каждому элементу поле NextCell. Каждый из этих
указателей занимает дополнительные четыре байта памяти. Для очень больших
массивов могут потребоваться очень большие ресурсы памяти.
Программа LListl демонстрирует простой связанный список с меткой. Введи-
те значение в текстовое поле и щелкните мышью по элементу списка или по метке.
Затем нажмите кнопку Add After (Добавить после), и программа добавит новый
элемент после указанного. Для удаления элемента выделите его и щелкните по
кнопке Remove After (Удалить после).

Доступ к ячейкам
Класс TLinkedList, используемый программой LListl, позволяет главной
программе обрабатывать список так же, как массив. Например, функция Item,
приведенная в следующем фрагменте кода, возвращает значение элемента, задан-
ного его позицией:
Function TLinkedList.Item(index : longint) : string;
var
cell_ptr : PLinkedListCell;
begin
// Нахождение ячейки.
cell_ptr := Sentinel.NextCell;
while (index>l) do
begin
index := index-1;
cell_ptr := cell^tr".NextCell;
end;
Item := cell.jitr' 4 .Value;
end;
Связанные списки

Эта процедура достаточно проста, но у нее нет преимуществ связанной струк-


туры списка. Например, программа должна последовательно перебрать все эле-
менты списка. Она может использовать процедуру Item, чтобы обращаться к эле-
ментам по порядку, как показано в следующем коде:
var
i : Integer;
begin
for i := 1 to the_l1st.Count do
begin
// Какие-то действия с the_list*Item(i).

end;

При каждом вызове процедура Item циклически исследует список в поиске


следующего элемента. Чтобы найти элемент I в списке, программа должна пропу-
стить 1 - 1 элементов. Чтобы проверить все элементы в списке из N элементов,
она исследует 0 + 1 + 2 + 3 .+ ... + N - l = N * ( N - l ) / 2 элемента. При больших
значениях N пропуск элементов займет очень много времени.
С помощью класса TLinkedList выполнить эту операцию можно гораздо бы-
стрее, применяя другие схемы доступа. Он использует локальную переменную
CurrentCell для отслеживания позиции в списке. Получение значения текущей
ячейки возможно с помощью функции Currentltem. Процедуры MoveFirst
и MoveNext позволяют основной программе устанавливать текущую позицию.
Функция EndOf List возвращает значение True, когда текущая позиция дости-
гает конца списка и пременная CurrentCell указывает на nil.
В следующем коде показана процедура MoveNext.
procedure TLinkedList.MoveNext;
begin
// Если текущая ячейка не определена, то действия не производятся.
if (CurrentCellonil) then
CurrentCell := CurrentCell.NextCell;
end;
С помощью этих процедур главная программа может обратиться к любому эле-
менту списка, используя следующий код. Он немного сложнее предыдущего, но
гораздо эффективнее. Вместо того чтобы исследовать N * ( N - l ) / 2 элементов
для обращения к каждой ячейки в списке из N элементов, данный код не исследу-
ет ни одного. Если список состоит из 1000 элементов, это экономит практически
полмиллиона шагов.
the_list.MoveFirst
while (not the_list.EndOfList) do
begin
// Какие-то действия с the_list.Currentltem.
the_list.MoveNext
end;
Списки
Программа LList2 использует эти новые методы для управления связанным
списком. Она аналогична программе Llistl, исключение составляет только более
эффективное обращение к элементам списка. При исследовании этой программой
маленьких списков разница незаметна, но при исследовании больших данная вер-
сия класса TLinkedList более эффективна. ,

Разновидности связанных списков


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

Циклические связанные списки


Вместо того, чтобы устанавливать поле Next Се 11 последнего элемента спис-
ка в nil, нужно сделать так, чтобы оно указывало на первый элемент списка, об-
разуя циклический список (circular list), как показано на рис. 2.7.

Первая ячейка

Рис. 2.7. Циклический связанный список

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


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

// Печать календаря для какого-нибудь месяца.


// first_day указывает на ячейку первого дня месяца.
// num_days - это количество дней месяца.
procedure ListMonth(first_day : PDayCell; num_days : Integer);
var
ptr : PDayCell;
i : Integer;
begin
ptr := first_day;
for i := 1 to num_days do
begin
PrintEntry(Format)'%d:%s',[i,ptrA.Value]));
Разновидности связанных списков j
Ptr := ptr~.NextCell;
end;
end;

Циклические списки также позволяют получить доступ ко всему списку, начи-


ная с любой позиции. Это придает списку некоторую симметрию. Программа мо-
жет работать со всеми элементами списка одинаково.
procedure ShowList(start_cell : PListCell);'
var
ptr : PListCell;
begin
ptr := start_cell;
repeat
ShowMessage(ptr A .Value);
Ptr := ptr-^.NextCell;
until (ptr=start_cell);
end;

Двусвязные списки
Вы, возможно, заметили, что при рассмотрении связанных списков большин-
ство операций было определено на основе каких-либо действий после указанной
ячейки в списке. Если задана определенная ячейка, очень просто добавить или
удалить ячейку после нее или перечислить идущие за ней. Но не так легко удалить
саму ячейку, вставить новую перед ней или перечислить находящиеся перед ней
ячейки. Однако небольшое изменение "кода позволит выполнить и эти операции.
Добавьте к каждой ячейке новое поле указателя, ссылающегося на предыду-
щую ячейку в списке. С помощью этих новых полей вы можете создать двусвязный
список, который позволит исследовать элементы в обратном порядке (см. рис. 2.8).
Теперь не составит труда удалить или вставить новую ячейку перед заданной и пе-
речислить ячейки в любом направлении.

Рис. 2.8. Двусвязный список

Тип записи TDoubleListCell, используемый для подобных списков, может


быть определен следующим кодом: ,
type
PDoubleListCell = '^TDoubleListCell;
TDoubleListCell = record
Value : String[20];
NextCell : PDoubleListCell;
PrevCell : PDoubleListCell;
end;
Списки
Часто бывает полезно сохранять указатели на начало и конец двусвязного
списка. Тогда вы сможете легко добавлять элементы с обеих сторон списка. Могут
пригодиться метки в начале и конце списка. Тогда вам не нужно будет заботиться
о том, работаете ли вы с его началом, серединой или концом.
На рис. 2.9 показан двусвязный список с метками. На этом рисунке неисполь-
зуемые указатели меток NextCell и PrevCell установлены в нуль. Поскольку
программа опознает концы списка, сравнивая указатели ячейки с метками, а не
отыскивая значение nil, устанавливать эти значения в нуль не обязательно. Тем
не менее это признак хорошего стиля программирования.

Метка Метка
начала конца

Рис. 2.9. Двусвязный список с метками

Код для вставки и удаления элементов из двусвязного списка подобен коду,


представленному ранее для односвязных списков. Необходимо лишь немного из-
менить процедуры, чтобы они могли обрабатывать указатели PrevCell.
Теперь вы можете написать новые процедуры для вставки элемента до или
после данного и его удаления. Например, следующие процедуры добавляют и уда-
ляют ячейки из двусвязного списка. Обратите внимание, что эти процедуры не
нуждаются в доступе ни к одной из меток списка. Им нужны только указатели на
узел, который будет добавлен или удален, и узел, расположенный рядом с точкой
вставки.
procedure Remove(t arget PDoubleListCell);
var
after_target, before_target PDoubleListCell;
begin
after_target := target*4.NextCell;
before_target := target*.PrevCell;
before_targetA.NextCell := after_target;
after_targetA.PrevCell := before_target;
end;
procedure AddAfter(new_cell, after_me PDoubleListCell);
var
before_me : PDoubleListCell;
begin
before_me := after_meA.NextCell,•
after_meA.NextCell := new_cell;
new.cellA.NextCell := before_me;
before_meA.PrevCell := new_cell;
new_cell A .prevCell := after_me;
end;
Е!*!^^^
procedure AddBefore(new_cell, before_me : PDoubleListCell);
var
after_me : PDoubleListCell;
begin
after_me := before_meA.PrevCell;
afterjneA.NextCell := new_cell;
new_cell/4.NextCell := before_me;
beforejne'4.PrevCell := new_cell;
new_cellA.PrevCell := after_me;
; ' . • • • • '
end;

Программа DblLiSt работает с двусвязным списком. Она позволяет добавлять


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

Списки с потоками
В некоторых приложениях необходимо передвигаться по связанному списку
не только в одном порядке. В разных частях приложения вам может понадобиться
выводить список служащих по их фамилиями, заработной плате, номеру системы
социального страхования или занимаемой должности.
Обычные связанные списки позволяют исследовать элементы только в одном
порядке. Используя указатель PrevCell, вы можете создать двусвязный список,
который позволяет продвигаться по списку в обратном порядке. Можно развить
этот подход далее, добавив больше указателей на другие структуры данных.
Набор связей, который задает какой-либо порядок исследования списка, назы-
вается потоком. Не путайте этот термин с потоком многозадачности в Windows NT.
Список может содержать любое число потоков, хотя существует определенное
число, после которого увеличение их количества будет просто бессмысленным. По-
ток, сортирующий список служащих по фамилии, есть смысл создавать в том слу-
чае, если ваше приложение часто использует этот запрос, в отличие от сортировки
по отчеству, которая вряд ли когда-то потребуется.
Использовать потоки не всегда выгодно. Например, поток, упорядочивающий
сотрудников по принадлежности к полу, не целесообразен, потому что этот порядок
легко реализовать и без помощи потока. Для того чтобы составить списки служа-
щих в соответствии с полом, нужно просто исследовать список любым другим по-
током, печатая фамилии женщин, а затем повторить обход еще раз, печатая фами-
лии мужчин. Чтобы получить такой реестр, вам нужно сделать всего два прохода по
списку.
Сравните этот случай с тем, когда необходимо создать список служащих про
фамилии. Если список не имеет потока фамилий, вам придется найти ту, которая
будет в списке первой, затем фамилию, появившуюся второй, и т.д. Этот процесс
имеет сложность порядка O(N2) и, безусловно, менее эффективен, чем сортировка
по полу со сложностью порядка O(N).
В общем случае создание потока требуется тогда, когда вам нужно часто его
использовать, а формировать тот же порядок каждый раз достаточно сложно. По-
ток не нужен, если его всегда легко сформировать заново.
Списки
Программа Threads демонстрирует простой связанный список^сотрудников.
Введите фамилию, имя, номер социального страхования, пол, специальность но-
вого служащего. Затем нажмите кнопку Add, чтобы добавить информацию о со-
труднике в список.
Программа содержит потоки, которые упорядочивают список по фамилии слу-
жащего о А до Z и наоборот, по номеру социального страхования и специальности
в прямом и обратном порядке. Для выбора потока, с помощью которого программа
будет отображать список, вы можете использовать дополнительные кнопки. На
рис. 2.10 показано окно программы Threads со списком служащих, упорядоченным
по фамилии.

Name: Able, Andy


-56-7890
M
6

Name: Baker, Brenda


SSN: 678-90-1234
SendeeF,v .:
Job Class: Э

Name: Comet Lalhrine


SSN: 456-78-3012
Gehdet: F •
Job Class: 5

Name: Stephens. Rod


ff Name SSN: 123-45-6789
С Name (reversed)
Job Class: 7
Г Social Security Numbet
С Job Class

Рис. 2.10. Окно программы Threads

Класс TThreadedList, используемый программой Threads, определяет ячейки


следующим образом:
TThreadedListCell = record
// Данные.
LastName : String[20] ; ,
FirstName : String[20] ;
SSN : String[11];
Gender : String[1];
JobClass : Integer;
// Указатели потоков.
NextName : PThreadedListCell;
PrevName : PThreadedListCell;
NextSSN : PThreadedListCell;
Разновидности связанных списков Ц|Ц|Н1
t•
NextJobClass : PThreadedListCell;
PrevJobClass : PThreadedListCell;
end;
Класс TThreadedList формирует список с потоками. Когда программа ис-
пользует процедуру Add, список обновляет свои потоки. Для каждого потока про-
грамма должна вставить элемент в правильном порядке. Например для вставки
записи, содержащей фамилию Смит, программа исследует список, используя по-
ток NextName, пока не найдет элемент с фамилией, которая должна идти после
Смит. Затем новая запись вставляется в поток NextName перед найденным эле-
ментом.
Метки играют важную роль при определении принадлежности новых записей
к определенному потоку. Конструктор класса устанавливает указатели начальной
и конечной метки так, чтобы они ссылались друг на друга. Потом для начальной
метки устанавливаются такие значения данных, чтобы они стояли перед любыми
допустимыми реальными записями для всех потоков.
Например, переменная LastName может содержать строковые значения. Пус-
тая строка'' по алфавиту находится перед любыми допустимыми строковыми зна-
чениями, поэтому программа устанавливает значение начальной метки в пустую
строку.
Таким же образом конструктор устанавливает значение данных для конечной
метки, превосходящее любые допустимые значения во всех потоках. Поскольку'-'
по алфавиту стоит позже всех видимых символов кода ASCII, программа устанав-
ливает значение LastName конечной метки в ' '.
Присваивая меткам такие значения, программа избегает необходимости про-
верять частные случаи, когда новый элемент должен добавляться it начало или
конец списка. Все новые значения будут попадать между значениями: переменной
LastName меток, поэтому программа будет всегда находить правильную позицию
нового элемента, не заботясь о том, как бы Не оказаться за концевой меткой и не
выйти за границы списка.
Следующий код показывает, как класс TThreadedList добавляет новый эле-
мент в поток. Поскольку потоки LastName и PrevName используют одинаковые
методы, программа может их модифицировать. Точно так же она может модифи-
цировать потоки NextJobClass и PrevJobClass.
procedure TThreadedList.Add( new_last_name, new^first_nam«, new_ssn,
new_gender : String; new_job_class : Integer);
var
cell_ptr, new_cell : PThreadedListCell;
combined_name : String;
begin
// Создание новой ячейки.
New(new_cell);
new_cell.LastName := new_last_name;
new_cell.FirstName := new_first_name;
new_cell.SSN := new_ssn;
new_cell.Gender := new_gender;
new_cell.JobClass := new_job_class;
// Вставка ячейки в потоки имен.
// Нахождение следующей ячейки.
cell_ptr:=8TopSentinel;
cornbined_name:=Format('%s,%s',[new_last_name,new_first_name]);
while (Format('%s,%s',
[cell_ptr A .LastName,cell_ptr A .FirstName])<combined_name) do
cell_ptr := cell_ptr A .NextName;
new_cell A .NextName := cell_ptr;
new_cell A .PrevName := cell_ptr A .PrevName;
new A cell A .PrevName A .NextName := new_cell;
cell_ptrA.PrevName := new_cell;
// Вставка ячейки в поток номера социального страхования.
// Нахождение предыдущей ячейки.
v
cell_ptr := STopSentinel; .
while (cell_ptr A .NextSSN A .SSN<new_ssn) do
cell_ptr := cell_ptr A .NextSSN;
new_cell A .NextSSN := cell_ptr A .NextSSN;
cell_ptr A .NextSSN := new_cell;
// Вставка ячейки в поток рода работы.
// Нахождение предыдущей ячейки.
cell_ptr := STopSentinel,-
while (cell_ptr A .JobClass<new_job_class) do
cell_ptr := cell_ptr A .NextJobClass;
new_cell A .NextJobClass := cell_ptr;
new_cell A .PrevJobClass := cell_ptr A .PrevJobClass;
new_cel! A .PrevJobClass A .NextJobClass := new_cell;
cell_ptr A .PrevJobClass := new_cell;
Numltems := Numltems+l;
end;
Чтобы такой подход работал, должна быть гарантия, что значения новой ячейки
всегда лежат между значениями меток. Например, если пользователь введет в каче-
стве фамилии'—', цикл выйдет за конечную метку, так как'—' идет после'-'. В этом
случае программа аварийно завершит работу при попытке обратиться к значению
се!1_рЬгЛ. LastName, когда cell_ptr установлено в nil.

Другие связанные структуры


С помощью указателей нетрудно построить множество других полезных ти-
пов связанных структур, таких как деревья, неоднородные и разреженные мас-
сивы, графы и сети. Ячейка может содержать любое число указателей на дру-
гие ячейки. Например, для создания двоичного дерева вы можете использовать
ячейку, содержащую два указателя - на правую и левую дочерние ячейки. Тип за-
писи BinaryCell может быть определен следующим образом:
type
PBinaryCell = "TBinaryCell;
TBinaryCell = record
LeftChild : PBinaryCell;
RightChild : PBinaryCell;
end;
На рис. 2.11 изображено дерево, сформированное из ячеек такого типа. В главе
6 деревья рассматриваются более подробно.
Ячейка может также содержать связанный список ячеек, каждая из которых
содержит указатель на другую ячейку. Это позволяет программе связывать ячейку
с любым количеством других ячеек. На рис. 2.12 приведены примеры различных
связанных структур данных. Вы встретите подобные структуры позже, в частно-
сти в главе 12.

Рис. 2.11. Двоичное дерево

Рис. 2.12. Связанные структуры


Резюме
Используя указатели, вы можете строить гибкие структуры данных, такие как
связанные списки, циклические связанные списки и двусвязные списки. Эти струк-
туры позволяют легко добавлять и удалять элементы из любой позиции списка.
Добавляя дополнительные указатели на класс ячеек, вы можете превратить
двусвязные списки в потоки. Если руководствоваться таким подходом, можно со-
здать такие экзотические структуры данных, как разреженные массивы, деревья,
хеш-таблицы и сети. Они подробно описаны в следующих главах.
Глава 3. Стеки и очереди
Эта глава продолжает тему, начатую в главе 2. Здесь-описываются две особых раз-
новидности списков: стеки и очереди. Стек - это список, в котором элементы до-
бавляются и удаляются с одного и того же конца списка. Очередью называется
список, в котором элементы добавляются с одного конца, а удаляются с противо-
положного. Многие алгоритмы, включая некоторые из представленных в следую-
щих главах, используют стеки и очереди.

Стеки
Стек (stack) - это упорядоченный список, где элементы всегда добавляются
и удаляются с одного конца. Стек можно сравнить со стопкой книг на полу. Вы
можете добавлять книги на вершину стопки и убрать их с вершины, но добавить
или убрать книгу из середины стопки вы не сможете.
Стеки часто называют списками типа последний пришел — первый вышел (Last-
In-First-Out list — LIFO). По историческим причинам добавление элемента в стек
называется проталкиванием (pushing), а удаление - выталкиванием (popping).
Первая реализация простого списка на основе массива, описанное в начале
главы 2, является стеком. Для отслеживания положения вершины списка исполь-
зуется счетчик. Затем с помощью счетчика осуществляется вставка и удаление эле-
ментов из вершины списка.
Единственное незначительное изменение, сделанное в данном случае, - это
введение новой функции Pop, которая удаляет элемент из стека и возвращает его
значение. Это позволяет другим процедурам отыскивать элемент и удалять его из
стека за один шаг. Во всем остальном следующий код совпадает с листингом, при-
веденным в главе 2.
// Проталкивание элемента в стек.
procedure TArrayStack.Push(value : String);
begin
// Убедиться, что для элемента есть место.
if (NumItems>=NumAllocated) then ResizeStack;
Numltems := Numltems+1;
Stack74 [Numltems] := value;
end;
// Выталкивание элемента из стека.
function TArrayStack.Pop : String;
begin
if (Numltems<l) then
raise EInvalidOperation.Create('Стек пустой.');
Result := Stack'4 [Numltems] ;
NumIterns := NumIterns-1;
if (NumItems<ShrinkWhen) then ResizeStack;
end;
Все предыдущее рассуждения о списках также относятся к этому способу реа-
лизации стека. В частности, вы можете экономить время, если не будете изменять
размеры массива всякий раз при каждом добавлении или выталкивании элемента.
Программа Astack с несколькими стеками на основе массивов позволяет без труда
вставлять и удалять элементы стека.
Программы часто используют стеки для хранения последовательности элемен-
тов, с которыми программа будет работать до тех пор, пока стек не опустеет. Рабо-
та с одним элементом может вызвать проталкивание в стек других элементов, но
в конце концов они все будут удалены. В качестве простого примера можно приве-
сти алгоритм обращения порядка элементов в массиве. Здесь каждый элемент по-
мещается в стек по порядку. Затем каждый элемент выталкивается из стека в об-
ратном порядке и записывается обратно в массив.
procedure ReverseArray;
var
, the_stack : TArrayStack;
i . : Integer;
begin
// Создание стека.
the_stack := TArrayStack.Create;
// Проталкивание элементов в стек.
for i := 1 to Numltems do
the_stack.Push(the_array[i]);
// Выталкивание элементов из стека и помещение их обратно в массив.
for i := 1 to Numltems do
the_array[i] := the_stack.Pop;
end;
В этом примере длина стека может многократно изменяться до тех пор, пока он
не опустеет. Если вы заранее знаете, каким должен быть размер массива, лучше сра-
зу создать подходящий стек. Тогда вместо изменения размера стека по мере того,
как он растет или уменьшается, достаточно будет выделить под него память в нача-
ле работы и очистить ее после окончания действий.
Следующий код позволяет заранее сформировать стек, если сразу известен его
максимальный размер. Функция Pop не изменяет размер массива. Когда програм-
ма заканчивает работу со стеком, она должна вызвать процедуру FreeStack для
освобождения занятой под стек памяти.
// Создание большого массива, достаточного для размещения всего стека.
procedure TArrayStack.PreallocateStack(entries : Integer);
begin
NumAllocated := entries;
GetMem(Stack,NumAllocated*SizeOf(Longint)); •
end;
// Освобождение массива стека.
procedure TSimpleStack.FreeStack;.
begin
NumAllocated := 0;
PreeMem(Stack);
end;
// Проталкивание элемента в стек.
procedure TArrayStack.Push(value : String);
begin
// Убедиться, что для элемента есть место.
if (NumItems>=NumAllocated) then
raise EInvalidOperation.Create('Стек заполнен.');
Numltems := Numltems+l;
Stack/N[NumItems] := value;
end;
', ,' , . . . • • • . • . . ; •
// Выталкивание элемента из стека.
function TArrayStack.Pop : String;
begin
if (Numltems<l) then
raise EInvalidOperation.Create('Стек пустой.');
Result := Stack*[Numltems];
NumIt ems : = NumIt ems-1;
end;
Этот способ реализации стеков весьма эффективен. Стек не расходует пона-
прасну память, и не требуется дополнительное время для частого изменения его
размера, особенно если сразу известно, насколько большим он должен быть.
Стеки на связанных списках
Вы можете управлять двумя стеками в одном массиве, размещая один в начале
массива, а другой - в конце. Сохраните отдельные счетчики вершин для каждого
стека, и сделайте так, чтобы стеки росли друг к другу, как показано на рис. 3.1. Этот
метод позволяет двум стекам увеличиваться, занимая один и тот же массив памя-
ти до тех пор, пока они не столкнутся друг с другом в тот момент, когда массив
полностью заполнится.

Стек 1 —>- -*— Стек 2

Вершина 1 -го стека Вершина 2-го стека

Рис. 3.1. Два стека в одном массиве


Стеки и очереди
К сожалению, менять размер подобных стеков непросто. Вы должны выделить
массив под новый стек и скопировать все элементы старого массива в новый. Из-
менение размера больших стеков может занимать очень много времени. Данный
способ совсем не подходит для управления несколькими стеками.
Связанные списки предоставляют более гибкий метод формирования несколь-
ких стеков. Чтобы протолкнуть элемент в стек, надо вставить его в начало связан-
ного списка. Чтобы вытолкнуть элемент из стека, следует удалить первый элемент
связанного списка. Поскольку все элементы добавляются и удаляются только в на-
чале списка, для реализации стеков такого типа не нужны метки или двусвязные
списки. Стеки, строящиеся на связанных списках, не требуют сложных схем пере-
распределения памяти, применяющихся в стеках на основе массивов. Следующий
код демонстрирует процедуры Push и Pop, используемые стеком на основе свя-
занных списков.
// Добавление элемента в стек.
procedure TStack.Push(new_value : String);
var
new_cell : PStackCell;
begin
// Создание новой ячейки.
New(new_cell);
Л
пем_се!1 .Value := new_value;

// Вставка ячейки в начало стека.


New_cell/v.NextCell := Top;
Тор := new_cell;
end;
// Удаление первого элемента из стека.
function TStack.Pop : String;
var
Target : PStackCell;
begin
if (Top=nil) then
raise EInvalidOperation.Create(
1
Невозможно получить элемент из пустого с т е к а . ' ) ;
// Сохранение значения удаляемого элемента.
Target := Тор;
Pop := Target".Value;
// Удаление первой ячейки из стека.
Тор := Target".NextCell;
// Освобождение памяти удаленной ячейки.
Dispose(Target);
end ;
Основной недостаток стеков, строящихся на связанных списках, состоит в том,
что они требуют дополнительной памяти для хранения указателей ячеек NextCell.
Очереди
Отдельный стек на основе массива, содержащий N целых чисел, требует всего 2 * N
байт памяти (по 2 байта на целое число). Тот же самый стек, реализованный как
связанный список, потребовал бы дополнительно 4 * N байт памяти для указате-
лей NextCell, что увеличивает затраты памяти, занятой под стек, втрое.
Программа LStack использует несколько стеков, реализованных в виде связан-
ных списков. С помощью этой программы вы можете вставлять и выталкивать эле-
менты из каждого списка.

Очереди
Очередь (Queue) - это упорядоченный список, где элементы добавляются
в один конец списка, а удаляются с другого конца. Группа людей у кассы ма-
газина образует очередь. Вновь прибывшие люди становятся в конец очереди.
Когда клиент доходит до начала очереди, кассир обслуживает его. Поэтому очере-
ди иногда называют списками типа первый вошел - первый вышел (First-In-First-
Out list - FIFO).
Вы можете реализовать очереди в Delphi, используя методы, аналогичные ме-
тодам реализации простых стеков. Выделите память для массива и сохраните счет-
чики, указывающие на начало и конец очереди. Переменная QueueFront указы-
вает индекс элемента в начале очереди. Переменная QueueBack определяет, куда
должен быть добавлен следующий элемент очереди. Размер массива нужно менять
только тогда, когда новые элементы приходят в самый конец очереди.
Как и в случае со списками, можно повысить производительность программы,
добавляя сразу несколько элементов при каждом увеличении массива. Второй спо-
соб сэкономить время - сокращать размер массива только тогда, когда он содер-
жит слишком много неиспользуемых записей.
В случае простого списка или стека элементы добавляются и удаляются на
одном конце массива. Если размер списка остается постоянным, то его не придет-
ся изменять слишком часто. С другой стороны, когда вы добавляете элементы в один
конец очереди, а удаляете их с другого, может потребоваться время от времени
перестраивать очередь, даже если ее размер остается постоянным.
// Добавление элемента в очередь.
procedure TArrayQueue.EnterQueue(new_value : String);
begin
// Убедиться, что есть место для нового элемента.
if (AddHere>=NumAllocated) then ResizeQueue;
Queue^[AddHere] := new_value;
AddHere := AddHere+1;
end;
// Удаление последнего элемента очереди.
function TArrayQueue.LeaveQueue : String;
begin
if (QueueEmpty) then
raise EInvalidOperation.Create!'Нет элементов для удаления.');
LeaveQueue := Queue74 [RemoveHere] ;
RemoveHere := RemoveHere+1;
Стеки и очереди
if (RemoveHere>ResizeWhen) then ResizeQueue;
end;
// Изменение размера очереди.
procedure TArrayQueue.ResizeQueue;
const
WANT_FREE_PERCENT = 0 . 5 ; // Изменение при 50% свободного места.
MIN_FREE = 2; // Минимальный размер неиспользуемой
// области при изменении размера.
var
want_free, new_size, i : Longint;
new_array : PQueueArray ;
begin
// Какого размера должен быть массив.
new_size := AddHere-RemoveHere;
want_free := Round(WANT_FREE_PERCENT*new_size);
if (want_free<MIN_FREE) then want_free := MIN_FREE;
new_size := new_size+want_free;
// Создание нового массива.
GetMem(new_array,new_size*SizeOf(String));
// Копирование существующих элементов в новый массив.
for i := RemoveHere to AddHere-1 do
new_array л [i-RemoveHere] : = Queue A [i};
AddHere := AddHere-RemoveHere;
RemoveHere := 0;
// Освобождение ранее выделенной памяти.
, if (NumAllocated>0) then FreeMem(Queue);
NumAllocated := new_size;
// Установка указателя Queue на новую область памяти.
Queue := new_array;
// Размеры очереди изменяются, когда RemoveHere>ResizeWhen.
ResizeWhen := want_free;
end;
Программа ArrayQ использует этот метод для создания простой очереди. Введи-
те строку и щелкните по кнопке Enter (Ввод), чтобы добавить новый элемент к кон-
цу очереди. Кнопка Leave (Покинуть) предназначена для удаления верхнего элемен-
та из очереди.
Работая с программой, обратите внимание, что размер очереди каждый раз
изменяется при добавлении и удалении элементов, даже если ее границы остают-
ся почти такими же, как и были. Фактически даже при многократном добавлении
и удалении одного элемента размер очереди будет изменяться.

Циклические очереди
Очереди, описанные в предыдущем разделе, время от времени требуется пере-
страивать, даже если размер очереди почти не меняется. Это приходится делать
даже при многократном добавлении и удалении одного элемента.
Очереди
Если вы заранее знаете, какого размера будет оче-
редь, вы можете избежать всех этих перестановок,
построив циклическую очередь (circular queue). Идея
состоит в том, чтобы массив очереди как будто «за-
вернуть», образовав круг. При этом последний эле-
мент массива будет идти как бы перед первым. На
рис. 3.2 схематично показана такая очередь.
Программа хранит в переменной RemoveHere
индекс элемента, который дольше всего находился
в очереди. Переменная AddHere содержит индекс
позиции в очереди, куда добавляется следующий
элемент. Рис. 3.2. Циклическая
В отличие от предыдущей реализации при обнов- очередь
лении значений переменных QueueFront и Queue-
Back необходимо использовать оператор Mod для того, чтобы индексы всегда оста-
вались в границах массива. Например, следующий код добавляет элемент к очереди:
Queue*[AddHere] := new_value;
AddHere := (AddHere+1) mod NumAllocated;
На рис. 3.3 показаны этапы добавления нового элемента к циклической очере-
ди, которая содержит четыре записи. Элемент С добавляется в конец очереди. За-
тем указатель на конец очереди сдвигается для того, чтобы ссылаться на следую-
щую запись в массиве.
• • '
Конец очереди

Начало очереди ~" Начало очереди


\
Конец очереди

Рис. 3.3. Добавление элемента к циклической очереди

Точно так же, когда программа удаляет элемент из очереди, необходимо изме-
нять значение RemoveHere при помощи следующего кода:
LeaveQueue := Оиеие Л [RemoveHere];
RemoveHere := (RemoveHere+1) mod NumAllocated;
На рис. 3.4 показан процесс удаления элемента из циклической очереди. Пер-
вый элемент, в данном случае элемент А, удаляется из начала очереди, а указатель
на начало очереди обновляется, чтобы ссылаться на следующий элемент массива.
11 Стеки и очереди
Начало очереди

Ч X
Начало очереди Конец очереди Конец очереди
Рис. 3.4. Удаление элемента из циклической очереди

Иногда сложно бывает отличить полную циклическую очередь от пустой. В обо-


их случаях начало и конец очереди совпадают. На рис. 3.5 показаны две цикличес-
кие очереди, одна пустая, а другая полная.

Начало очереди
S Конец очереди

Начало очереди
Конец очереди
Рис. 3.5. Пустая и полная циклические очереди

Самый простой вариант решения этой проблемы - сохранять число элемен-


тов в очереди с помощью отдельной переменной NumIterns. Эта переменная бу-
дет сообщать о том, остались ли элементы в очереди и есть ли место, чтобы доба-
вить новый элемент.
Следующий код использует эти методы для управления циклической очередью:
// Добавление элемента в очередь.
procedure TCircleQueue.EnterQueue(new_value : String);
begin
if (NumItems>=NumAllocated) then ResizeQueue;
Queue"[AddHere] := new_value;
AddHere := (AddHere+1) mod NumAllocated;
NumIterns := NumIterns+1;;
end;
// Удаление первого элемента очереди.
function TCircleQueue.LeaveQueue : String;
Очереди S|

begin
if (QueueEmpty) then
raise EInvalidOperation.Create('Нет элементов для удаления.');
LeaveQueue := Queue*[RemoveHere];
RemoveHere := (RemoveHere+1) mod NumAllocated;
NumIterns := Numltems-l;
if (NumItems<ShrinkWhen) then ResizeQueue;
end;

// Если очередь пуста, то данная функция возвращает True.


function TCircleQueue.QueueEmpty : Boolean;
begin
QueueEmpty := (Numltems<=0);
end;

Как и в случае со списками на основе массивов, можно изменять размеры мас-


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

Начало очереди ., Новые элементы

Конец
массива

Конец очереди

Конец очереди
Рис. 3.6. Неправильное увеличение размера циклической очереди

Аналогичные проблемы возникают при уменьшении массива. Если элементы


огибают конец массива, то элементы, расположенные там, окажутся в начале оче-
реди и будут потеряны.
Чтобы избежать подобных проблем, убедитесь, что копируя записи очереди,
вы копируете их в правильные позиции нового массива.
// Изменение размера очереди.
procedure TCircleQueue.ResizeQueue;
Стеки и очереди
const
want_free_percent =0.5; // Изменение при 50% свободного места.
MIN_FREE = 2; // Минимальный размер неиспользуемой
// области при изменении размера.
var
want_free, new_size, i : Integer;
new_array : PCircleQueueArray;
begin
// Создание нового массива.
want_free := Round(WANT_FREE_PERCENT*NumItems);
if (want_free<MIN_FREE) then want_free:=min_free;
new_size := Numltems+want_free;
GetMem(new_array,new_size*SizeOf(String));
// Копирование элементов в позиции от new_array[0]
// до new_array[NumItems-l] .
for i := 0 to Numltems-l do
A A
new_array [i] := Queue [(i+RemoveHere) mod NumAllocated];
// Освобождение ранее выделенной памяти.
if (NumAllocated>0) then FreeMem(Queue);
NumAllocated := new_size;
// Установка указателя Queue, чтобы он указывал
// на новый массив памяти.
Queue := new_array;
RemoveHere := 0;
AddHere := Numltems;
// Размеры очереди изменяются, когда RemoveHere > ResizeWhen.
ShrinkWhen := NumAllocated-2*want_free;
if (ShrinkWhen<3) then ShrinkWhen := 0;
end;
. . . ' '
Программа CircleQ демонстрирует этот подход для реализации циклической
очереди. Введите строку и щелкните по кнопке Enter, чтобы добавить к очереди
новый элемент. С помощью кнопки Leave удаляется первый элемент. Программа
будет при необходимости изменять размер очереди.
Если число элементов очереди меняется незначительно и если правильно за-
дать параметры изменения размера, может никогда не понадобиться менять раз-
мер массива.

Очереди на основе связанных списков


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

Метка начала
Элементы
удаляются здесь
Элемент 1

I
Элемент 2
Элементы
добавляются здесь
Метка конца

Рис. 3.7. Очередь на основе связанного списка

с циклической очередью на основе массива. Недостатком данного способа явля-


ется то, что требуется дополнительная память для указателей связанного списка
NextCell и PrevCell. Это делает очереди на основе связанных списков менее
эффективными, чем циклические очереди.
Программа LinkedQ работает с очередью при помощи двусвязного списка. Вве-
дите строку и щелкните по кнопке Enter, чтобы добавить новый элемент в конец
очереди. Щелкните по кнопке Leave для удаления из очереди первого элемента.
/
Очереди с приоритетом
Каждый элемент в очереди с приоритетом (priority queue) имеет определен-
ный приоритет. Когда программа должна удалить элемент из очереди, она выби-
рает элемент с самым высоким приоритетом. При этом не имеет значения, в каком
порядке элементы хранятся в очереди, так как программа всегда может найти эле-
мент с самым высоким приоритетом.
Некоторые операционные системы используют очереди с приоритетом для
планирования задания. В операционной системе UNIX все процессы имеют раз-
ные приоритеты. Когда процессор освобождается, выбирается готовый к исполне-
нию процесс с максимальным приоритетом. Процессы с меньшим приоритетом
должны ждать завершения или блокировки (например, внешнего события, такого
как чтение данных с диска) процессов с более высокими приоритетами.
Концепция очередей с приоритетами также используется при управлении
авиаперевозками. Самолеты, идущие на посадку из-за отсутствия топлива, име-
ют высший приоритет. Второй приоритет присваивается самолетам, заходящим
на посадку. Самолеты на земле имеют третий приоритет, потому что они нахо-
дятся в более безопасном положении, чем самолеты в воздухе. Через какое-то вре-
мя некоторые приоритеты могут измениться, так как у самолетов, которые пыта-
ются приземлиться, может кончится топливо.
Простой способ организации очереди с приоритетами - поместить всё элемен-
ты в список. Если требуется удалить элемент из очереди, надо найти в списке эле-
мент с наивысшем приоритетом. При использовании этого метода новый элемент
добавляется в очередь всего за один шаг. Чтобы добавить элемент к очереди, вы
|; Стеки и очереди
размещаете новый элемент в начале списка. Если очередь содержит N элементов,
требуется O(N) шагов, чтобы определить положение и удалить из очереди элемент
с максимальным приоритетом.
Немного удобнее использовать связанный список и хранить элементы, распо-
лагая их в порядке уменьшения приоритета. Тип данных списка TPriorityCell
можно определить следующим образом:
type
StringlO = String[10];
PPriorityQCell = "TPriorityQCell;
TPriorityQCell = record
Value : StringlO; // Данные.
Priority : Longint; // Приоритет элемента.
NextCell : PPriorityQCell; // Следующая ячейка.
end;
Чтобы добавить элемент в очередь, необходимо найти для него правильную по-
зицию в списке и поместить его туда. Упростить поиск положения элемента можно
с помощью меток в начале и конце списка, присвоив им соответствующие приори-
теты. Например, если элементы имеют приоритеты от 0 до 100, можно присвоить
метке начала приоритет 101, а метке конца - приоритет -1. Любые приоритеты
реальных элементов будут находиться между этими значениями.
На рис. 3.8 показана очередь с приоритетами, реализованная с помощью свя-
занного списка.

кЧетка начала 1Иетка конце


1
\ 1
Приоритет: - 10 7 4 -32,768

Данные: - + Задача В Задача D -*. Задача А -


-^

Рис. 3.8. Очередь с приоритетами на основе связанного списка

В следующем фрагменте кода приведена основа подпрограммы поиска:


var
new_cell, cell_ptr, next_cell : PPriorityQCell;
begin

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


Cell_ptr := @TopSentinel;
next_cell := cell_ptrA.NextCell;
while (next_cell/4.Priority>new_priority) do
begin
cell_ptr := next_cell;
next_cell := cell_ptrA.NextCell;
end;
,Очереди_Л
// Вставка новой ячейки.
cell_ptr^.NextCell := new_cell;
A
new_cell .NextCell := next_cell;

. f
Чтобы удалить из списка .элемент с самым высоким приоритетом, достаточно
удалить элемент после метки начала. Поскольку список отсортирован в порядке
уменьшения приоритета, первый элемент всегда имеет наивысший приоритет.
Добавление нового элемента в эту очередь в среднем занимает N/2 шагов. Иног-
да новый элемент оказывается в начале списка, а иногда ближе к концу, но в сред-
нем он будет попадать приблизительно в середину. Предыдущая простая очередь
с приоритетом на основе списка требовала О( 1) шагов для добавления нового эле-
мента в очередь и O(N) шагов для удаления элемента с максимальным приорите-
том. В версии на основе сортированного связанного списка элемент добавляется
за O(N) шагов и за О(1) удаляется верхний элемент. Обеим версиям требуется
O(N) шагов для одной из этих операций, но в случае упорядоченного связанного
списка обычно приходится выполнять только N/2 шагов.
Программа PriorQ использует сортируемый связанный список для обработки
очереди с приоритетом. Вы можете задать приоритет и значение элемента данных
и с помощью кнопки Enter добавить его в очередь. Для удаления из очереди эле-
мента с наивысшим приоритетом щелкните по кнопке Leave.
Немного доработав этот пример, можно сформировать очередь с приоритетом,
где добавление и удаление элементов будут занимать O(logN) шагов. Для очень
больших очередей ускорение работы окупит затраченные усилия. Этот тип очере-
дей с приоритетом использует структуры данных в виде пирамиды, которые так-
же применяются в алгоритмах с древовидной сортировкой. Пирамиды и очереди
на их базе более подробно обсуждаются в главе 9.

Многопоточные очереди
Другой интересный тип очередей -многопоточная очередь (multi-headed queue).
Элементы, как обычно, вводятся в конец очереди, но очередь имеет несколько пе-
редних концов (front end), или голов (head). Программа может удалять элементы из
любой головы.
Примером многопоточной очереди в реальной жизни является очередь клиен-
тов в банке. Все клиенты стоят в одной очереди, но обслуживаются несколькими
кассирами. Освободившийся банковский работник выполняет заказ клиента, ко-
торый находится в очереди первым. Такой порядок кажется справедливым, пото-
му что клиенты обслуживаются в порядке прибытия. Это очень эффективно, по-
скольку все кассиры заняты, пока есть клиенты.
Сравните этот тип очереди с множеством обычных очередей в супермаркете.
Здесь люди не обязательно обслуживаются в порядке прибытия. Покупатель в мед-
ленно двигающейся очереди может прождать дольше, чем тот, который прибыл
позже, но оказался в очереди, которая движется быстрее. Кассиры также могут
быть не всегда заняты, ведь какая-либо очередь может оказаться пустой, тогда как
в других еще будут находиться покупатели.
Стеки и очереди
В общем случае многопоточная очередь более эффективна, чем несколько од-
нопоточных. Последние используются в супермаркетах потому, что тележки для
покупок занимают много места. В многопоточной очереди все покупатели должны
были бы построиться друг за другом. Когда кассир освободится, покупателю при-
шлось бы перемещаться к нему с громоздкой тележкой вдоль всего переднего края
отдела, что нежелательно. В банке же посетители, как правило, не обременены по-
купками, поэтому легко могут уместиться в одной очереди.
Очереди на регистрацию в аэропорту иногда представляют собой комбинацию
этих двух вариантов. Хотя пассажиры везут большой багаж, авиакомпании все же
предпочитают многопоточные очереди, поэтому приходится отводить дополни-
тельное место, чтобы пассажиры могли образовать одну колонну.
Можно легко построить многопоточную очередь с помощью обычной. Сохра-
ните элементы, представляющие клиентов, в однопоточной очереди. Когда агент
(банковский служащий, кассир и т.д.) освобождается, удалите первый элемент из
начала очереди и присвойте его этому агенту.
Моделирование очередей
Предположим, что вы отвечаете за разработку регистрационного счетчика для
нового терминала авиакомпании и хотите сравнить возможности одной многопо-
точной очереди и нескольких обычных очередей. Вам нужны были бы какие-то
модели поведения пассажиров. При рассмотрении этого примера можно исходить
из следующих предположений:
а каждый клиент обслуживается от двух до пяти минут;
а при использовании нескольких однопоточных очередей прибывающие пас-
сажиры встают в самую короткую;
а скорость поступления пассажиров примерно одинакова.
Программа HeadedQ моделирует данную ситуацию. Вы можете изменить не-
которые параметры моделирования, такие как:
а число прибывающих в течение часа клиентов;
а минимальное и максимальное время, затрачиваемое на обслуживание каж-
дого клиента;
о количество свободных служащих;
Q паузу между шагами программы в миллисекундах.
При выполнении программы модель показывает прошедшее, среднее и макси-
мальное время ожидания пассажирами обслуживания и процент времени, в тече-
ние которого служащие заняты.
Поскольку вы проводите эксперименты с разными параметрами, обратите вни-
мание на несколько любопытных фактов. Во-первых, для многопоточной очереди
среднее и максимальное время ожидания будет меньше. При этом служащие так-
же оказываются немного более загружены, чем в случае однопоточной очереди.
Оба типа очереди имеют некоторый порог, после которого время ожидания пас-
сажиров значительно увеличивается. Предположим, что на обслуживание одного
Резюме
пассажира требуется от 2 до 10 мин (в среднем 6 мин). Если поток пассажиров со-
ставляет 60 человек в час, то персонал потратит около 6 * 60 = 360 мин в час, чтобы
обслужить всех клиентов. Разделив это значение на 60 мин в часе, получим, что для
обслуживания клиентов в этом случае потребуется 6 клерков.
Если запустить программу HeadedQ, с этими параметрами, то вы обнаружите,
что очереди движутся достаточно быстро. Для многопоточной очереди среднее
время ожидания составит всего несколько минут. Если добавить еще одного слу-
жащего, чтобы их было 7, среднее и максимальное время ожидания значительно
уменьшится. Среднее время ожидания для многопоточной очереди упадет на де-
сятки минут.
С другой стороны, если сократить число служащих до 5, это приведет к боль-
шому увеличению среднего и максимального времени ожидания. Кроме того, вре-
мя ожидания возрастает и с увеличением времени тестирования. Чем дольше вы-
полняется тестирование, тем больше будут задержки.
В табл. 3.1 приведены значения среднего и максимального времени ожидания
для различных типов очередей. Здесь программа выполняла моделирование в те-
чение трех часов, предполагалось, что в час обслуживается 60 пассажиров и на об-
служивание каждого из них уходит от 2 до 10 мин.

Таблица 3.1. Время ожидания в минутах для одно- и многопоточных очередей


Многопоточная очередь Однопоточная очередь
Количество Среднее Максимальное Среднее Максимальное
служащих время время время время
5 11,37 20 12,62 20
6 1,58 5 3,93 13
7 0,11 2 0,54 6

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


ку пассажиры обслуживаются в порядке прибытия. На рис. 3.9 показано окно про-
граммы HeadedQ, моделирующей работу терминала. В многопоточной очереди
пассажир с номером 100 стоит следующим в очереди. Все клиенты, которые при-
были до него, уже обслужены или обслуживаются в настоящее время.
В обычной очереди обслуживается клиент 97. Клиент 96 все еще ждет, несмот-
ря на то, что он прибыл перед клиентом 97.

Резюме
Различные реализации стеков и очередей обладают неодинаковыми свойства-
ми. Стеки и циклические очереди на основе массивов просты и эффективны, в осо-
бенности если заранее известен их потенциальный размер. Связанные списки обес-
печивают большую гибкость, если необходимо часто изменять размер списка. Вы
можете выбрать структуру стека или очереди, более подходящую по возможнос-
тям вашему приложению.
I Стеки и очереди
/* HeadedQ
Efc Help
tie Multi-Headed Queue™ -Multiple Single-Headed Queues- .........
Cusl Wailing: 2 Cleik Cust Waiting: 5
93 100101 33 99
9? 94 101
95 92 96
93 ' 97 1QO;
35 98

AveWait 2,10 * Clerk Busy 94 AveWait 5,03 92


Time 01:57' - M a x Wait 5 MaxWait 16

Рис. З.9. Окно программы HeadedQ


Глава 4. Массивы
В этой главе описываются такие структуры данных, как массивы (array). С помо-
щью Delphi вы можете легко создавать массивы стандартных или определяемых
пользователем типов данных. Кроме того, размер массива допускается изменять.
Эти свойства делают применение массивов Delphi очень эффективным.
Некоторые программы используют специальные типы массивов, которые не
поддерживаются Delphi, например треугольные, нерегулярные и разреженные
массивы. В этой главе объясняется, как можно использовать гибкие структуры
массивов, чтобы значительно снизить объем памяти, занимаемой программой.

Треугольные массивы
Некоторым программам необходимы значения только половины записей в дву-
мерном массиве. Предположим, что у вас есть карта, на которой 10 городов обозна-
чены цифрами от 0 до 9. При помощи массива можно построить матрицу смежнос-
ти (adjacency matrix), хранящую информацию о наличии между парами городов
благоустроенных дорог. Элемент A[i, j] равен True, если между городами i и j есть
шоссе.
В таком случае значения в одной половине матрицы будут дублировать значе-
ния в другой, потому что A[i, j] = A[j, i]. Таким же образом в программу не будет
включен элемент A[i, i], так как нет смысла строить автостраду от города i в тот же
самый город. Значит, потребуются только элементы A[i, j] в левом нижнем углу,
для которых i > j. Можно с таким же успехом использовать элементы, находящие-
ся в правом верхнем углу. Поскольку все они образуют
треугольник, этот тип массивов называется треугольным
массивом (triangular array).
X
На рис. 4.1 изображен треугольный массив. Элемен-
ты со значимыми данными обозначены как X, ячейки, со- X X
ответствующие дублирующимся элементам, оставлены X X X
пустыми. Незначащие диагональные записи A[i, i] обо-
X X X X
значены тире.
Затраты памяти на хранение таких данных для не- Рис. 4.1. Треугольный
больших двумерных массивов не слишком существенны. массив
Если же на карте много городов, то напрасный расход па-
мяти может оказаться значительным. Для N городов будет N * ( N - l)/2 дублиро-
ванных элементов и N элементов, подобных A[i, i], которые не являются значи-
мыми. Если карта содержит 1000 городов, то в массиве будет храниться больше
полумиллиона ненужных элементов.
Массивы
Вы можете избежать таких потерь памяти, создав одномерный массив В и упа-
ковав в него значимые элементы массива А.
Разместите записи в массиве В построчно, как показано на рис. 4.2. Обратите
внимание, что индексы массива перечисляются, начиная с 0. Это делает следую-
щие формулы немного проще.
Чтобы еще более упростить это представление треугольного массива, можно
написать функции для преобразования индексов массива А в индексы массива
В. Формула для преобразования A[i, j] в В[х] имеет следующий вид:
X := R o u n d ( i * ( i - l ) / 2 ) + j ; // Для

Например, если i = 2 и j = 1, то получится х = 2* ( 2 - 1 ) /2 + 1 = 2. Это


означает, что А[2,1] отображается в позицию 2 в массиве В, как показано на рис. 4.2.
Помните, что массивы нумеруются, начиная с 0.
Эта формула справедлива только при i > j. Значения других записей массива А
не передаются в массив В, потому что они избыточны или незначимы.

Массив А

А(1,0)
А(2, 0) А(2,1)
А(3,0) А(3,1) А(3, 2)
А(4, 0) А(4,1) А(4, 2) А(4, 3)

Массив В
А(1,0) А(2, 0) А(2,1) А(3, 0) А(3, 1) А(3, 2)

Рис. 4.2. Упаковка треугольного массива в одномерный массив

Если нужно получить значение A[i, j], где i < j, вы можете вычислить значение
AU,i].
Подобные вычисления достаточно сложны. Здесь требуются операции вычи-
тания, сложения, умножения и деления. На выполнение программы будет уходить
намного больше времени, если придется часто прибегать к таким операциям. Это
пример компромисса между пространством и временем. Упаковка треугольного
массива в одномерный экономит память, хранение данных в двумерной матрице
занимает больший объем памяти, но экономит время.

Диагональные элементы
В некоторых программах используются треугольные массивы, которые вклю-
чают диагональные элементы A[i, i]. В этом случае необходимо сделать всего два
изменения в формуле преобразования индексов. Во-первых, преобразование не
должно отклонять случаи с i = j. Кроме того, необходимо перед вычислением ин-
декса в массиве В добавить к i единицу.
i := i+1;
x = Round(i*(i-l)/2)+j; // Для i > j.
Используя приведенные формулы, можно написать функцию для преобразо-
вания координат двух массивов таким образом:
// Преобразование индексов i и j двумерного массива А
// в индекс х одномерного массива В.
function TTriangularArray.AtoBU, j : Integer) : Integer;
var
tmp : Integer;
begin
if ((i<0) or (i>=Rows) or
(j<0) or (j>=Rows))
then
raise EInvalidOperation.CreateFmt(
'Индексы %d и %d не в промежутке от %d до % d . ' , [ i , j , 0 , R o w s - l ] ) ;
if ((not UseDiagonal) and ( i = j ) ) then
raise EInvalidOperation.Create(
1
Этот массив не содержит диагональных элементов.');
// Сделать так, чтобы i > j .
if ( i < j ) then
begin
tmp := i;
i := j;
> j := tmp;
end;
if (UseDiagonal) then i := i + 1;
AtoB := Round(i*(i - 1) / 2 ) + j ;
end;
Программа Triang использует эту функцию для отображения треугольных мас-
сивов. Она хранит строки в объекте TTr iangularArray для каждого допустимого
значения в массиве А. Затем она восстанавливает значения, чтобы отобразить вид
массива. Если вы нажмете кнопку выбора With Diagonal (Учитывать диагональ),
программа сохранит в массиве А метки для диагональных записей. Если вы нажме-
те кнопку Without Diagonal (He учитывать диагональ), то этого не произойдет.

Нерегулярные массивы
В некоторых программах требуются массивы с нестандартным размером и фор-
мой. В первой строке двумерного массива может быть шесть элементов, три - во
второй, четыре - в третьей и т.д. Это может понадобиться, например, для хране-
ния множества многоугольников, каждый из которых имеет различное число вер-
шин. В таком случае массив будет выглядеть, как на рис. 4.3.
Delphi не способен обрабатывать массивы с такими неровными краями. Можно
было бы использовать массив, достаточно большой для того, чтобы разместить в нем
все строки, но при этом появится множество неиспользуемых ячеек. Например,
приведенный на рис. 4.3 массив может быть объявлен с помощью переменной
Массивы
Polygons : array [1. .3,1. .6] of TPoint, четыре ячейки при этом останутся
неиспользованными.

Многоугольник 1 (2,5) (3,6) (4,6) (5,5) (4, 4) (4, 5)

Многоугольник 2 (1,1) (4,1) (2,3)

Многоугольник 3 (2,2) (4,3) (5,4) (1,4)

Рис. 4.3. Нерегулярный массив

Для представления нерегулярных массивов существует несколько способов.

Линейное представление с указателем


Один способ избежания пустого расхода памяти - упаковать данные в одно-
мерном массиве В. В отличие от треугольных непостоянные массивы нельзя опи-
сать с помощью формул для вычисления соответствия элементов в разных масси-
вах. Чтобы решить эту проблему, можно создать другой массив, который содержит
значения смещения каждой строки в одномерном массиве В.
Если добавить метку в конце массива В, которая указывает точку сразу за по-
следним элементом, в нем будет проще определять положения точек, соответ-
ствующих каждой строке. Затем точки, которые составляют многоугольник i,
займут в массиве В позиции от A[i] до A[i + 1] - 1. Например, программа может
перечислить элементы, которые составляют строку i, используя следующий код:
for j := A [ i ] to A [ i + l ] - l do
// Вывод записи B[j].

Этот метод называется нумерацией связей (forward star). На рис. 4.4 показано
представление непостоянного массива, изображенного на рис. 4.3, с помощью ну-
мерации связей. Метка закрашена серым цветом.

Массив А
9

1
(2. 5) (3, 6) (4. 6) (5, 5) (4, 4) (4, 5) (1, 1) (4, 1) (2, 3) (2. 2) (4, 3) (5, 4) (1, 4)
Массив В
Рис. 4.4. Представление непостоянного массива с помощью нумерации связей

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


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

На рис. 4.5 схематически показана трехмерная структура данных, представлен-


ная с помощью нумерации связей. Метки закрашены серым цветом. Они указыва-
ют на позицию позади значащих данных
следующего массива.
Представление нерегулярных масси-
вов в линейном виде требует минималь-
ных затрат памяти. «Впустую» расходу-
ется только память, занимаемая метками.
С помощью подобной структуры дан-
ных можно быстро и легко перечислить
вершины многоугольника. Так же просто Рис. 4.5. Трехмерный нерегулярный
сохранять эти данные на диске и загру- массив
жать их обратно в память. Но модифицировать массивы с нумерацией связей до-
статочно сложно. Предположим, вы хотите добавить новую вершину к первому
многоугольнику, изображенному на рис. 4.4. Для этого понадобится сдвинуть все
точки справа от новой на одну позицию, освобождая место для вводимого эле-
мента. Затем нужно добавить единицу ко всем элементам, следующим после пер-
вого в массиве, чтобы высчитать новый указатель. Наконец, следует вставить но-
вый элемент. Такие же трудности возникают при удалении точки из первого
многоугольника.
На рис. 4.6 показано представление в виде нумерации связей массива с рис. 4.4
после добавления одной точки к первому многоугольнику. Измененные элементы
закрашены серым цветом. Как видно из рисунка, такими являются почти все эле-
менты обоих массивов.

Рис. 4.6. Добавление точки при линейном представлении

Нерегулярные связанные списки


Другой метод создания нерегулярных массивов - использование связанных
списков. Каждая ячейка содержит указатель на следующую на своем уровне иерар-
хии и указатель на список ячеек, находящихся на более низком уровне иерархии.
Например, ячейка многоугольника может содержать указатель на следующий мно-
гоугольник и указатель на ячейку, в которой определены координаты его первой
вершины.
Следующий код приводит объявления типа данных, которые можно использо-
вать для построения изображений, состоящих из многоугольников на основе свя-
занных списков.
type
PPictureCell = ATPictureCell;
TPictureCell = record
NextPicture : PPictureCell; // Следующее изображение.
FirstPolygon : PPolygonCell; // Первый многоугольник
// на данном изображении.
end;
PPolygonCell = ATPolygonCell;
TPolygonCell = record
NextPolygon : PPolygonCell; // Следующий многоугольник.
FirstPoint : PPointCell; // Первая вершина данного
// многоугольника.
end;
PPointCell = "TPointCell;
TPointCell = record
X, Y : Integer; // Координаты точки.
NextPoint : PPointCell; // Следующая точка.
end;
С помощью этой методики можно без труда добавлять и удалять рисунки, мно-
гоугольники или точки в любом месте структуры данных.
Программа Poly использует^тот подход (см. рис. 4.7). Она позволяет форми-
ровать связанный список из переменных типа TPolyLineCells, каждая из кото-
рых содержит связанный список TPointCells. Для рисования ломаных линий
следует использовать левую кнопку мыши: при каждом нажатии на нее к ломан-
ной линии добавляется новая точка. Нажатие правой кнопки соответствует окон-
чанию рисования линии.

Рис. 4.7. Окно программы Poly .

Динамические массивы Delphi


Еще одним способом хранения нерегулярных массивов в Delphi, начиная с 4 вер-
сии, является применение динамических массивов. Например, двумерный массив
записывается как одномерный массив строк, каждая из которых является дина-
мическим массивом.
type
TPoint = record
X : Integer;
Y : Integer;
end;
var
Polygons : Array Of Array Of TPoint;

Разреженные массивы
Многие приложения используют большие массивы, которые содержат всего
несколько ненулевых элементов. Такие массивы называют разреженными (sparce).
Например, матрица смежности для авиалиний может содержать 1 в позиции A[i, j],
если есть воздушная трасса между городом i и городом j. Многие авиакомпании
обслуживают сотни городов, но число фактически выполняемых рейсов намного
меньше, чем N2 возможных комбинаций. На рис. 4.8 показана небольшая карта
авиалиний, на которой изображены только 11 существующих рейсов из 100 воз-
можных пар сочетаний городов, i

Рис. 4.8. Карта рейсов авиакомпании

Можно сформировать матрицу смежности для этого примера с помощью мас-


сива 10x10, но большая его часть окажется пустой. Избежать потерь памяти при
создании такого разреженного массива помогут указатели. Каждая строка массива
представлена связанным списком ячеек, представляющих ненулевые записи в стро-
ках. Метки для каждого списка строки хранятся в массиве. На рис. 4.9 показана раз-
реженная матрица смежности, соответствующая карте рейсов с рис. 4.8.
Следующий код показывает, как можно определить тип данных ячейки, ис-
пользуемой для хранения списка строк.
Массивы
type
StringlO = String[10];
PSparseCell = лТЗрагзеСе11;
TSparseCell = Record
Col : Longint; // Количество столбцов.
Value : StringlO; // Значение данных.
NextCell : PSparseCell; // Следующая ячейка в столбце.
end;
TCellArray = array [0..100000000] of TSparseCell;
PCellAfray = "TCellArray;

8 9 10

Рис. 4.9. Разреженная матрица смежности

Индексирование массива
Нормальное индексирование массива типа A(I, J) не будет работать со струк-
турами, описанными выше. Чтобы упростить нумерацию, потребуется написать
процедуры, которые устанавливают и извлекают значения элементов массива.
Если массив представляет собой матрицу, могут также понадобиться процедуры
для сложения, умножения и других матричных операций.
Специальное значение DEFAULT_VALUE соответствует пустому элементу мас-
сива. Процедура, которая извлекает элементы массива, должна возвращать значе-
ние DEFAULT_VALUE при попытке получить значение элемента, не содержащегося
в массиве. Точно так же процедура, которая устанавливает значения элементов, дол-
жна удалять ячейку из массива, если его значение установлено в DEFAULT_VALUE.
Конкретное значение константы DEFAULT_VALUE зависит от природы дан-
ных приложения. Для матрицы смежности авиалинии пустые записи могут иметь
Разреженные массивы
значение False. При этом значение A[i, j] = True, если существует рейс между
городами i HJ.
Функция GetValue класса TSparseArray возвращает значение элемента
массива. Она начинает с первой ячейки в указанной строке и перемещается по свя-
занному списку ячеек строки. Как только найдется ячейка с нужным номером
столбца, это и будет искомая ячейка. Поскольку ячейки в списке расположены по
порядку, процедура может остановиться, если найдется та, номер столбца кото-
рой больше искомого.
// Возвращает значение записи массива.
function TSparseArray.GetValue(г, с : Longint) : StringlO;
var
cell_ptr : PSparseCell;
begin
if ((r<0) or (c<0)) then
raise EInvalidOperation.Create(
'Индекс колонки и строки должен быть больше или равен нулю. ');
// Имеется ли метка для данного столбца.
if (r>Max_Row) then
GetValue. := DEFAULT_VALUE
else begin
. фКШяЕ* ЛП.1ЯВ*

// Нахождение ячейки со значением^'Цолбца >= с.


cell_ptr := RowSentinelA[r].NextCeil;
while (cell_ptr/4.Col<c) do
cell_ptr:= cell_ptrA.NextCell;
//Если такая ячейка не найдена, возвращается default_value.
if (celljJtr^.Col^c) then
GetValue := cell_ptr.Value
Else
GetValue := DEFAULT_VALUE;
end;
end;
Процедура SetValue устанавливает новое значение ячейки. Она исследует
строку в поисках значения столбца большего или равного нужному столбцу.
Если новое значение равно DEFAULT_VALUE, процедура удаляет ячейку из
списка строк. Если ячейка уже существует, процедура обновляет ее значение. В про-
тивном случае она создает новую.
// Установка значения записи массива.
procedure TSparseArray.SetValue(г, с : Longint; new_value : StringlO);
var
cell_ptr, next_ptr : PSparseCell;
i : Longint;
new_array : PCellArray;
bottom_sentinel : PSparseCell;
begin
Массивы
if ((r<0) or (c<0)) then
raise EInvalidOperation.Create)
'Индекс колонки и строки должен быть больше или равен нулю.');
// Нужно ли формировать больший массив меток.
if (r>Max_Row) then
begin
// Копирование старых значений в новый массив.
GetMera(new_array,(r+1)*SizeOf(TSparseCell));
for i := 0 to Max_Row do
new_array/4[i] := RowSentinel*[i];
// Освобождение старого массива.
if (Max_Row>=0) then FreeMem(RowSentinel) ;
RowSentinel := new_array;
// Создание новых меток.
for i := Max_Row+l to r do
begin
New(bottom_sentinel);
bottom_sentinel'4.Col := 2147483647;
bottoitusentinel'>.NextCell := nil;
RowSentinel"[i].NextCell := bottom_sentinel;
RowSentinelA[it.~eal := -1;
end;
Max_Row := r;
end;
, \
// Нахождение ячейки со столбцом >= с.
cell_ptr := @RowSentinelA[r] ;
next_ptr := cell_ptr/v.NextCell;
while (next_ptrA.Col<c) do
begin
cell_ptr := next_ptr;
next_ptr := cell_ptrA.NextCell;
end;
IIT9RHO
// Если значение равн^^н&^ению по умолчанию.
if (new_value=DEFAULT_VALUE) then
begin
// Если мы нашли ячейку для данного столбца, удаляем ее.
if (next_ptr/4.Col=c) then
begin
cell_ptrA.NextCell := next_ptrx.NextCell;
Dispose(next_ptr);
end;
end else begin
// Значение не является значением по умолчанию.
//Если мы не нашли нужную ячейку, создаем новую.
if (next_ptrA.Coloc) then
begin
New(next_ptr);
Сильно разреженные массивы
N
next_ptr' .Col := с;
v A
next_ptr' .NextCell := cell_ptr .NextCell;
A
cell_ptr .NextCell := next_ptr;
end;
ГГ ;
// Сохранение нового значения.
next_ptr^.Value := new_value;
end;
end;
Программа Sparse, окно которой представлено на рис. 4.10, использует класс
TSparseArray для управления разреженным массивом. С ее помощью вы може-
те устанавливать и выбирать записи массива. Значение DEFAULT_VALUE в этой
программе равно пробелу, поэтому если вы установите значение записи в пустую
строку, то программа удалит элемент из массива.

Рис. 4.10. Окно программы Sparse

Сильно разреженные массивы


Некоторые массивы содержат так мало заполненных элементов, что многие
строки являются полностью пустыми. В таком случае метки строк лучше хранить
в связанном списке, а не в массиве. Это позволяет программе полностью пропус-
кать пустые строки. На рис. 4.11 показан масс¥гё ЮОхЮО, который содержит всего
7 ненулевых записей.
Для работы с массивами данного типа необходимо немного изменить преды-
дущий код. Большая часть кода остается неизменной, и вы можете использовать
тот же самый тип данных TSparseCell для элементов массива. Но метки строки
не хранятся в массивах, а записываются в связанных списках. Список составлен из
записей TRowCell. Каждая из этих записей имеет указатель на следующую и мет-
ку начала для связанного списка строки.
TRowCell = Record
Row : Longint;
FirstCell : PSparseCell;
NextRow : PRowCell;
end;
Массивы
Колонки
Метка начала 3 7 32 73 95

Рис. 4.11. Сильно разреженный массив

Чтобы расположить элемент в массиве, нужно вначале просмотреть связан-


ный список ячеек TRowCell, пока не найдется требуемая строка. Затем просмат-
ривается связанный список строк, пока не отыщется нужный столбец.
// Возвращает значение элемента массива.
function TVerySparseArray.GetValue(r, с : Longint) : StringlO;
var
row_ptr : PRowCell;
col_ptr : PSparseCell;
begin
if ((r<0) or (c<0)) then
raise EInvalidOperation.Create)
'Индекс колонки и строки должен быть больше или равен нулю.');
// Нахождение ячейки строки.
row_ptr := TopSentinelA.NextRow;
while (row_ptrA.Row<r) do
row_ptr := row_ptrA.NextRow;
// Если найдена нужная ячейка.
Result := bEFAULT_VALUE;
if (row_ptr/s.Rowor) then exit;
// Нахождение ячейки столбца.
col_ptr := row_ptr/4.FirstCell'v.NextCell;
while (col_ptr/s.Col<c) do
col_ptr := col_ptrA.NextCell,-
// Если найдена нужная ячейка.
/4
if (col_ptr .Coloc) then exit;
// Ячейка найдена.
Result := col.ptr'4.Value;
end;
Резюме
Программа VSparse использует этот код для работы с сильно разреженным
массивом. С ее помощью можно устанавливать и извлекать элементы массива.
Значение DEFAULT_VALUE для данной программы равно пробелу, поэтому если
установить значение элемента в пустую строку, программа удалит его из массива.

Резюме
Некоторые программы используют массивы, которые содержат очень мало
значащих элементов. Применение обычных массивов Delphi для хранения таких
пустых ячеек привело бы к существенной потере памяти. С помощью же треуголь-
ных, нерегулярных, разреженных и сильно разреженных массивов вы можете со-
здавать мощные представления массивов, которые требуют минимальных объемов
памяти.

;ЗШ
Глава 5. Рекурсия
Рекурсия (Recursion) - это мощный метод программирования, который позволя-
ет делить проблему на части все меньшего и меньшего размера до тех пор, пока
они не станут настолько малы, что решение этих подзадач сведется к набору про-
стых операций.
После того как вы поработаете с рекурсией, вы обнаружите, что она встречается
достаточно часто. Многие программисты-новички иногда чрезмерно увлекаются
рекурсией и начинают применять ее в ситуациях, где она не нужна и даже вредна.
В первых разделах этой главы рассматривается вычисление факториалов, чи-
сел Фибоначчи и наибольшего общего делителя. Приводятся примеры неправиль-
ного использования рекурсии (нерекурсивные версии более эффективны). Они
интересны и наглядны, поэтому имеет смысл поговорить о них.
Затем в главе рассматривается несколько примеров, в которых применение
рекурсии более уместно. Алгоритмы построения кривых Гильберта и Серпинско-
го используют рекурсию должным образом и очень эффективно.
В заключительных разделах этой главы объясняется, почему факториалы, чис-
ла Фибоначчи и наибольший общий делитель лучше вычислять без применения
рекурсии. Также говорится о том, когда не следует использовать рекурсию и при-
водятся способы ее устранения.

Что такое рекурсия ,


Рекурсия возникает, еслщфБ^ндщя или процедура вызывает саму себя. Пря-
мая рекурсия может вызывать себя непосредственно, как в данном примере:
function Factorial (num ;.л bongint) : Longint; ,
begin
Factorial := num*Factorial(num-i);
end;
Рекурсивная процедура также может вызывать себя косвенно, вызывая вторую
процедуру, которая, в свою очередь, вызывает первую:
procedure Ping(num : Integer);
begin
Pong(num-1);
end;
procedure Pong(num Integer);
begin
Ping(num div 2);
end; ^
Рекурсия полезна при решении задач, которые могут быть разложены на не-
сколько подзадач. Например, дерево, изображенное на рис. 5.1, можно представить
в виде «ствола», откуда выходят два дерева меньших размеров. Таким образом мож-
но написать рекурсивную процедуру для рисования деревьев.
i
procedure DrawTree;
begin
// Рисование "ствола"
// Рисование маленького дерева, повернутого на -45 градусов
// Рисование маленького дерева, повернутого на 45 градусов
end;

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


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

if
завершения. Например, вы начнете красить забор
с одного края и продолжите двигаться в другую
сторону до завершения работы. Скорее всего, во
время выполнения подобной задачи вы даже не
думаете о возможности рекурсивной окраски,-
вначале левой половины изгороди, а затем, рекур- '
Рис. 5.1. Дерево,
п

то, правой <i-\ доставленное из двух деревьев


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

Рекурсивное вычисление факториалов


Л RC30 d
Факториал числа N записывается как N! (читается N факториал). Значение О!
равно 1. Остальные значения определяются следующим образом:
N! = N * (N - 1) * (N - 2) * ... * 2 * 1

Как уже говорилось в главе 1, эта функция растет чрезвычайно быстро. В табл. 5.1
приведены первые 10 значений функции факториала.
' •• •
Таблица 5.1. Значения функции факториала
N 1 2 3 4 5 6 7 8 9 1 0
N! 1 2 6 24 120 720 5.040 40.320 362.880 3.628.800

Функцию факториала можно определять с помощью рекурсии:


О! = 1
N! = N * (N - 1 ) ! , для N > 0.
Рекурсия
Преобразовать это определение в рекурсивную функцию очень просто:
function Factorial(num : Integer) : Integer;
begin
if (num<=0) then
Factorial := 1 •
else
Factorial := num*Factorial(num-1);
end;
Функция сначала проверяет число на условие N < 0. Для чисел меньше 0 фак-
ториал не определен, но это условие проверяется для подстраховки. Если бы функ-
ция проверила только условие равенства числа нулю, то для отрицательных чисел
рекурсия была бы бесконечной.
Если входное значение меньше или равно 0, функция возвращает значение 1.
В противном случае значение функции равно произведению входного значения на
факториал от входного значения, уменьшенного на единицу.
Существуют два фактора, которые гарантируют, что эта рекурсивная функция
в конце концов остановится. Во-первых, при каждом последующем вызове значе-
ние параметра num уменьшается на единицу. Во-вторых, значение num ограничено
нулем. Когда значение доститет'О, функция заканчивает рекурсию. Такое усло-
вие, как num < 0, которое останавливает рекурсию, называется основным условием
или условием остановки (base Ease или stopping case).
При каждом вызове подпрограммы система сохраняет некоторые значения
в стеке, как описывалось в главе 3. Поскольку этот стек имеет очень важное значе-
ние, иногда его называют просто стеком. Если рекурсивная процедура вызывает-
ся много раз, она может заполнить весь стек и вызвать ошибку переполнения сте-
ка (Out of stack space).
Количество вызовов рекурсивной функции зависит от объема памяти компь-
ютера и количества данных, которые программа помещает в стек. В Delphi под-
программа обычно может вызыв.ат,ь£Я много раз перед тем, как возникнет ошибка
переполнения стека. В одном ^.те^тов программа исчерпала стековое простран-
ство только после 2356 рекурсивных вызовов.

Анализ сложности
Для функции факториала необходим только один параметр — число, фактори-
ал которого нужно вычислить. При анализе вычислительной сложности алгорит-
ма обычно рассматривается сложность как функция размера задачи или количества
входных параметров. Поскольку в данном .случае имеется только один параметр,
расчет сложности может показаться немного странным.
Поэтому алгоритмы с одним параметром обычно рассматриваются не с точ-
ки зрения количества входных параметров, а через число битов, необходимых для
хранения входного значения. В некотором смысле это и есть размер входного па-
раметра, потому что он равен числу битов для записи входного параметра. Одна-
ко описанный способ не очень наглядно представляет данную задачу. Кроме того,
теоретически компьютер может сохранить входное число N в log2N бит, но на самом
НОД
деле Числу N соответствует некоторое фиксированное число битов. Например, длин-
ное целое (Longint) сохраняется в 32 битах, независимо от того, равно оно 1 или
2 147 483 647.
Поэтому данный тип алгоритмов рассматривается с точки зрения входного
значения, а его размера. Если вы хотите пересчитать результат на основе размера
м
входного параметра, то это можно сделать с помощью выражения N = 2 , где М -
число битов, необходимых для хранения числа N. Если сложность алгоритма рав-
2
на O(N ) в терминах входной величины N, то относительно размера входного па-
М 2 2м 2 м М
раметра М она составит О((2 ) ) = О(2 ' ) = О((2 ) ) = О(4 ).
В данном алгоритме функция факториала вызывается для N, N - 1, N - 2 и т д.
до тех пор, пока входной параметр не достигает 0 и рекурсия не заканчивается.
Если начальное значение равно N, то функция вызывается всего N + 1 раз, поэто-
му ее сложность равна O(N). Относительно размера входного параметра М слож-
М
ность будет равна О(2 ).
Функции вида O(N) растут довольно медленно, поэтому можно было бы ожи-
дать хорошей производительности этого алгоритма. В действительности это толь-
ко теория. Функция вызывает ошибку, когда она исчерпывает весь ресурс стека,
выполняясь много раз, или когда значение N! становится слишком большим, что-
бы тип Integer мог вместить это число, и программа генерирует ошибку пере-
полнения. НГ.НЙН
Поскольку N! увеличивается очень быстро, то переполнение возникает, если
стек интенсивно применяется для других целей. При использовании целочислен-
ного типа данных Integer переполнение происходит для числа 8!, потому что
8! = 40 320, а это больше максимального числа Integer - 32 767. Чтобы програм-
ма могла вычислить приближенные значения для больших чисел, функцию надо
изменить так, чтобы она использовала тип Double вместо типа Integer. Тогда
самое большое число, для которого алгоритм может вычислить N!, будет равно
170!=.7,257Е + 306.
Программа Facto 1 демонстрирует рекурсивную функцию факториала. Введите
число и нажмите кнопку Compute (Вычислить^Тчтоёы вычислить факториал с по-
мощью рекурсии.

Рекурсивное вычисление
наибольшего общего делителя
Наибольший общий делитель (НОД) (Greatest Common Divisor - GCD) двух
чисел - это наибольшее целое число, на которое делятся два числа без остатка.
Например, НОД чисел 12 и 9 равен 3, потому что 3 - наибольшее целое число, на
которое 12 и 9 делятся без остатка. Два числа являются взаимно простыми (relatively
prime), если их наибольший общий делитель равен 1.
Математик Эйлер (восемнадцатый век) обнаружил интересный факт:
Если В делится на А нацело, то НОД(А, В) = А.
В противном случае НОД(А, В) = НОД(В mod А, А ) .
Это положение можно использовать для быстрого вычисления наибольшего
общего делителя. Например:
НОД(9, 12) = НОД(12 mod 9, 9) = НОД(3, 9) = 3

На каждом шаге сравниваемые числа уменьшаются, потому что 1 < В mod A < A,
если А не делится на В нацело. Если параметры продолжают уменьшаться, то А в ко-
нечном счете достигает значения 1. Поскольку на 1 делится нацело любое число В,
рекурсия завершается.
Открытие Эйлера привело к достаточно простому рекурсивному алгоритму
для вычисления НОД:
function Gcd(A, В : Longint) : Longint;
begin
if (В mod A ) = 0 then // Если А делит В нацело, то значение вычислено.
Gcd := A
else // В противном случае функция вычисляется
// рекурсивно.
Gcd := Gcd(B mod A , A ) ;
end;

Анализ сложности > ,


Чтобы проанализировать сложность этого алгоритма, необходимо определить,
как быстро уменьшается числб-А. Поскольку функция останавливается, если А
становится равным 1, скорость, с которой убывает число А, определяет верхнюю
границу оценки времени работы алгоритма. Оказывается, что при каждом втором
вызове функции Gcd параметр А уменьшается по крайней мере в два раза.
Рассмотрим это на конкретном примере. Допустим, А < В. Это условие всегда
выполняется при первом вызове функции Gcd. Если В mod А < А / 2, то при следу-
ющем вызове функции Gcd первый параметр уменьшится, по крайней мере, в два
раза, что и требовалось доказать.
Предположим обратное. Допустим, В mod А > А / 2. Первым рекурсивным
вызовом Gcd будет Gcd (В mod А , А ) .
Подставляя значения В mod А'и А в функцию вместо А и В, получим второе
рекурсивное обращение Gcd (A mod (В Mod A) , В mod А ) .
Но мы приняли что В mod А > А / 2. Тогда В mod А делится на А один раз с остат-
ком А - (В mod А). Поскольку В mod А больше А / 2, значение А - (В mod А) должно
быть меньше А / 2. Значит, первый параметр при втором рекурсивном обращении
к Gcd становится меньше, чем А/2, что и требовалось доказать.
Теперь предположим, что N - первоначальное значение параметра А. После
двух вызовов Gcd значение параметра А будет уменьшено максимум до N / 2. Пос-
ле четырех вызовов значение будет не больше (N / 2) / 2 = N / 4. После шести
вызовов значение будет максимум (N / 4) / 2 = N / 8. В целом, после 2 * К вызовов
Gcd значение параметра А будет максимум N / 2К.
Поскольку алгоритм должен остановиться, когда значение параметра А дой-
дет до 1, он может продолжать работу только до тех пор, пока N / 2К = 1. Это про-
исходит, когда N = 2К или К - log2N. Так как алгоритм выполняется за 2 * К шагов,
он остановится не более чем через 2 * log2N шагов. Опуская постоянный множи-
тель, получим, что время выполнения алгоритма равно O(logN).
Этот алгоритм - один из множества рекурсивных алгоритмов, которые выпол-
няются за время порядка O(logN). Каждый раз после выполнения некоторого фик-
сированного числа шагов, в данном случае 2, размер задачи уменьшается вдвое.
В общем случае, если размер задачи уменьшается, по крайней мере, на коэффици-
ент 1/D после выполнения S шагов, то задача требует S * logDN шагов.
Поскольку постоянные множители и основания логарифмов в системе оценки
сложности по порядку игнорируются, любой алгоритм, который выполняется в те-
чение времени S * logDN, будет алгоритмом со сложностью O(logN). Это не озна-
чает, что такими константами можно полностью пренебречь при фактической реа-
лизации алгоритма. Алгоритм, который сокращает размер задачи на каждом шаге
в 10 раз, очевидно, будет быстрее алгоритма, который имеет коэффициент 1/2 на
каждые пять шагов. Тем не менее оба алгоритма имеют сложность O(logN).
Алгоритмы O(logN) обычно выполняются очень быстро, и алгоритм НОД не
исключение. Чтобы определить, что НОД чисел 1 736 751 235 и 2 135 723 523 ра-
вен 71, функция вызывается всего 17 раз. Алгоритм практически мгновенно вы-
числяет значения, не превышающие максимального значения числа двойного це-
лочисленного типа данных (Double), равного 2 147 483 647. Оператор Delphi mod
не может работать с большими значениями, следовательно, это предел для данной
реализации алгоритма. ;,кс,яэ
Программа Gcdl использует этот алгоритм для рекурсивного вычисления НОД.
Введите значения А и В, нажмите кнопку Compute (Вычислить), и программа вы-
числит НОД этих двух чисел.

Рекурсивное вычисление чисел Фибоначчи


•... . . . ' .
Числа Фибоначчи (Fibonacci numbers) можно рекурсивно определить с помо-
щью следующих формул:
Fib(O) = 0
Fib(l) = 1 (A,Afo.
Fib(N) = Fib(N - 1) + Fib(N - 2 ) ,
Третье уравнение дважды использует функцию Fib рекурсивно, один раз со
значением N - 1 и один раз со значением N - 2. В данном случае необходимо иметь
два граничных значения для рекурсии: Fib(O) - 0 и Fib(l) = 1. Если задать только
одно из них, рекурсия может оказаться бесконечной. Например, если установить
только Fib(O) - 0, то вычисление Fib(2) будет выглядеть следующим образом:
Fib(2) = Fib(l) + F i b ( O )
= [Fib(O) + Fib(-l)] + 0
= 0 + [Fib(-2) + F i b ( - 3 ) ]
= [Fib(-3) + F i b ( - 4 ) ] + [Fib(-4) + Fib(-5)]
И т.д.
Данное определение чисел Фибоначчи легко преобразовать в рекурсивную
функцию:
I Рекурсия
function Fibofn : Double) : Double;
begin
if (n <= 1) then
Fibo := n
else
Fibo := Fibo(n - l)+Fibo(n - 2) ;
end;
^
Анализ сложности
Анализ этого алгоритма необычен. Сначала надо определить, сколько раз вы-
полняется условие остановки n < 1. Пусть G(N) - число шагов, за которые алго-
ритм достигает основного условия для входного значения N. Когда N < 1, функция
сразу достигает основного условия и не требует рекурсии.
Если N > 1, функция рекурсивно вычисляет Fib(N - 1) и Fib(N - 2) и заканчи-
вает работу. При первоначальном вызове функции основное условие не выполня-
ется - оно достигается только при других рекурсивных обращениях. Общее коли-
чество шагов для достижения основного условия при входном значении N - это
число шагов для значения N - 1 плюс число раз для значения N - 2. Все это можно
записать так:
G(0) = 1
G(l) = 1
G ( N ) = G(N - 1) + G(N - 2 ) , для N > 1.
Это рекурсивное определение очень похоже на определение чисел Фибонач-
чи. В табл. 5.2 приведены некоторые значения для G(N) и Fib(N). Из этих значе-
ний можно легко увидеть, что G(N) = Fib(N +1).

Таблица 5.2. Значения чисел Фибоначчи и функции G(N)


N 0 1 2 3 4 5 6 7 8
Fib(N) 0 1 1 2 3 5 8 13 21
G(N) 1 1 2 3 5 8 13 21 34

Затем рассмотрим, сколько раз алгоритм обращается к рекурсии. Если N < 1,


то функция его не достигает. Если N > 1, то функция один раз обращается к рекур-
сии и затем рекурсивно вычисляет Fib(N - 1) и Fib(N - 2).
Пусть H(N) - это число раз, когда алгоритм обращается к рекурсии для вход-
ного значения N. Тогда H(N) = 1 + H(N - 1) + Н (N - 2). Для определения H(N)
можно воспользоваться следующими уравнениями:
•ЩО) = О
Н(1) = О
H ( N ) = 1 + H(N - 1) +Н (N - 2 ) , для N > 1.

В табл. 5.3 приведены некоторые значения для Fib(N) и H(N). Как видите,
H(N) = Fib(N +!)-!.
Рекурсивное построение кривых Гильберта
Таблица 5.3. Значения чисел Фибоначчи и функции H(N)
N 0 1 2 3 4 5 6 7 8
Fib(N) 0 1 1 2 3 5 8 13 21
H(N) 0 0 1 2 4 7 12 20 33

Объединяя результаты для G(N) и H(N), получим общую сложность алгоритма.


Сложность = G(N) + H.(N)
= Fib(N + 1) + Fib(N + 1 ) - 1 = 2 * Fib(N + 1) - 1

Так как Fib(N + 1) > Fib(N) для всех значений N, то:


Сложность > 2 * Fib(N) - 1

При вычислении с точностью до порядка это составит O(Fib(N)). Интересно,


что данная функция не только рекурсивная, но и используется для вычисления ее
собственной сложности.
Чтобы определить скорость, с которой возрастает функция Фибоначчи, можно
воспользоваться формулой Fib(M)>0M~2, где 0 -константа, примерно равная 1,6.
Следовательно, сложность сравнима с значением показательной функции О(0М).
Как и другие экспоненциальные функции, эта функция растет быстрее полиноми-
альных функций и медленнее функций факториала.
Поскольку время выполнения увеличивается очень быстро, этот алгоритм для
больших входных значений работает достаточно медленно, настолько медленно,
что на практике почти невозможно вычислить значения Fib(N) для N, которые
больше 40. В табл. 5.4 показано время выполнения этого алгоритма с различными
входными параметрами на компьютере, где'установлен процессор Pentium, с так-
товой частотой 133 МГц.

Таблица 5.4. Время выполнения программы по вычислению чисел Фибоначчи


м 30 32 34 36 38 40
Rb(M) 832.040 2.18Е + 6 5.70Е + 6 4.49Е + 7 3.91Е + 7 1.02Е + 8
Время, с 1,32 3,30 8,66 22,67 59,35 155,5

Программа Fibol использует этот рекурсивный алгоритм для вычисления чи-


сел Фибоначчи. Введите целое число, нажмите кнопку Compute (Вычислить).
Начните с небольших значений, пока не оцените, насколько быстро ваш компью-
тер может выполнять эти операции.

Рекурсивное построение кривых Гильберта


Кривые Гильберта (Hilbert curves) - это самоподобные кривые, которые обыч-
но определяются рекурсивно. На рис. 5.2 изображены кривые Гильберта 1-го, 2-го,
и 3-го порядка.
Рекурсия

1 -го порядка 2-го порядка 3-го порядка


Рис. 5.2. Кривые Гильберта

Кривую Гильберта или любую другую самоподобную кривую можно создать раз-
биением большой кривой на меньшие части. Затем для построения следующих час-
тей необходимо использовать эту же кривую с соответствующим размером и углом
вращения. Полученные части допускается разбивать на более мелкие фрагменты до
тех пор, пока процесс не достигнет нужной глубины рекурсии. Порядок кривой опре-
деляется как максимальная глубина рекурсии, которой достигает процедура.
Процедура Hilbert управляет глубиной рекурсии, используя соответствую-
щий параметр глубины. При каждом рекурсивном вызове процедура уменьшает
данный параметр на единицу. Если процедура вызывается с глубиной рекурсии,
равной 1, она выводит простую кривую 1-го порядка, показанную слева на рис. 5.2,
и завершает работу. Это основное условие остановки рекурсии.
Например, кривая Гильберта 2-го порядка состоит из четырех кривых Гильбер-
та 1-го порядка. Точно так же кривая Гильберта 3-го порядка составлена из четы-
рех кривых Гильберта 2-го порядка, каждая из которых включает четыре кривых
Гильберта 1-го порядка. На рис. 5.3 изображены кривые Гильберта 2-го и 3-го по-
рядка. Меньшие кривые, из которых построены кривые большего размера, выде-
лены жирными линиями.

LTZI
Рис. 5.3: Кривые Гильберта, составленные из меньших кривых

Следующий код строит кривую Гильберта 1-го порядка:


with DrawArea . Canvas .do
begin
LineTo(PenPos.X + Length, PenPos.Y);
LineTofPenPos.X, PenPos.Y + Length);
LineTofPenPos.X - Length, PenPos.Y);
end;
I

Предполагается, что рисунок начинается с левого верхнего угла области и что


переменная Length для каждого сегмента линии определена должным образом.
Метод для рисования кривой Гильберта более высоких порядков будет вы-
глядеть следующим образом:
procedure Hilbert (Depth : Integer);
begin
if (Depth = 1) then
Рисование кривой Гильберта глубины 1
else
Рисование и соединение четырех кривых Гильберта Hilbert (Depth - 1)
end;
Необходимо слегка усложнить этот метод, чтобы процедура Hilbert могла
определять направление, в каком будет рисоваться кривая - по часовой стрелке
или против. Это требуется для того, чтобы выбрать тип используемых кривых
Гильберта.
Эту информацию можно передать процедуре, добавив параметры dx и dy,
определяющие направление вывода первой линии в кривой. Если кривая имеет
глубину, равную единице, процедура выводит ее первую линию в соответствии
с функцией LineTo ( PenPos . X+dx , PenPos . Y+dy ) . Если кривая имеет большую
глубину, ей то процедура присоединяет первые две меньшие кривые с помощью
вызова LineTo ( PenPos . X+dx , PenPos . Y+dy ) . В любом случае процедура может
использовать dx и dy для того, чтобы определить направление рисования состав-
ляющих кривую линий.
Код Delphi для рисования Гильбертовых кривых короткий, но достаточно слож-
ный. Чтобы точно отследить, как изменяются dx и dy для построения различных
частей кривой, вам необходимо несколько раз пройти этот алгоритм в отладчике
для кривых 1-го и 2-го порядка.
procedure THilblForm.DrawHilbert (depth, dx, dy : Integer);
begin
with DrawArea . Canvas do
begin
if (depth > 1) then DrawHilbert (depth - l,dy,dx);
LineTo ( PenPos . X+dx , PenPos . Y+dy ) ;
if (depth > 1) then DrawHilbert (depth - l,dx,dy);
LineTo ( PenPos . X+dy , PenPos . Y+dx) ;
if (depth > 1) then DrawHilbert (depth - l,dx,dy);
LineTo ( PenPos . X-dx , PenPos . Y-dy ) ;
if (depth > 1) then DrawHilbert (depth - l,-dy,-dx);
end;
end;

Анализ сложности
Чтобы проанализировать сложность этой процедуры, необходимо определить
число вызовов процедуры Hilbert. На каждом шаге рекурсии эта процедура
ЕВЗННИНК Рекурсия
вызывает себя четыре раза. Если T(N) - это число вызовов процедуры, выполня-
емой с глубиной рекурсии N, то:
Т(1) = 1
Т ( М ) = 1 + 4 * T(N - 1), для N > 1.

Если развернуть определение T(N), то получим следующее:


Т(М) = 1 + 4 * T(N - 1)
= 1 + 4* '(1 + 4 * T(N - 2 ) )
= 1 + 4 + 16 * T(N - 2)
= 1 + 4 + 16 *(1 + 4 * T(N - 3 ) )
= 1 + 4 + 16 + 64 * T(N - 3)
2 3
= 4 0 + 4 1 + 4 + 4 + . . . + 4к * T { N _ K )

Раскрывая это уравнение, пока не будет достигнуто основное условие Т(1) = 1,


получим:
1 2 3 1
T ( N ) = 4 ° + 4 + 4 + 4 + . . . + 4""

Чтобы упростить это уравнение, можно использовать следующую математи-


ческую формулу:
1 2 3 м м 1
Х° + X + X + X +. . .+ X = (X * - 1) / (X - 1)

Используя эту формулу, получим:


]
T ( N ) = ( 4 ( N - m l - 1) / (4 - 1) = ( 4 N - 1) / 3

Опуская константы, получим сложность этой процедуры O(4N). В табл. 5.5


приведено несколько первых значений функции сложности. Если вы внимательно
посмотрите на эти числа, то увидите, что они соответствуют рекурсивному опре-
делению.

Таблица 5.5. Количество рекурсивных обращений к процедуре Hilbert


N 1 2 3 4 5 6 7 8 9
Т(М) 1 _ 5 _ 21 85 341 1365 5461 21.845 87.381

Этот алгоритм типичен для многих рекурсивных алгоритмов со сложностью


O(CN), где С - некоторая константа. При каждом вызове процедуры Hilbert раз-
мер проблемы увеличивается в 4 раза. В общем случае, если при каждом выполне-
нии некоторого числа шагов алгоритма размер задачи увеличивается не менее чем
в С раз, то его сложность будет O(CN).
Такое поведение абсолютно противоположно поведению алгоритма поиска
НОД. Функция Gcd уменьшает размер задачи, по крайней мере, вдвое при каждом
втором вызове, поэтому сложность этого алгоритма равна O(logN). Процедура
рисования кривых Гильберта увеличивает размер задачи в 4 раза при каждом вы-
зове, поэтому сложность равна O(4N).
Рекурсивное построение кривых Гильберта !|

Функция (4N- 1) / 3 - это показательная функция, которая растет очень быстро.


Фактически, эта функция растет настолько быстро, что вызывает сомнения в своей
эффективности. Выполнение этого алгоритма в действительности требует много
времени, но есть две причины, по которым он не так уж плох.
Во-первых, ни один алгоритм для построения кривых Гильберта не может вы-
полняться быстрее. Гильбертовы кривые состоят из множества сегментов линий,
и любой рисующий их алгоритм будет занимать очень много времени. При каж-
дом вызове процедура Hi Ibert рисует три линии. Пусть L(N) - суммарное чис-
ло выводимых линий Гильбертовой кривой глубины N. Тогда L(N) = 3 * T(N) = 4N
- 1, так что L(N) также равно O(4N). Любой алгоритм, который рисует Гильберто-
вы кривые, должен выводить O(4N) линий, выполнив при этом O(4N) шагов. Су-
ществуют другие алгоритма для рисования Гильбертовых кривых, но все они ра-
ботают дольше рекурсивного алгоритма.
Второй факт, который доказывает достоинства описанного алгоритма, заклю-
чается в следующем: кривая Гильберта порядка 9 содержит так много линий, что
большинство компьютерных мониторов становятся полностью закрашенными.
Это не удивительно, поскольку кривая содержит 262 143 сегментов линий. По-
этому вам, вероятно, никогда не понадобится выводить на экран кривые Гильбер-
та 9-го или более высоких порядков. При глубине выше 9 вы исчерпаете все ре-
сурсы компьютера.
И в заключение можно добавить, что строить Гильбертовы кривые сложно. Ри-
сование четверти миллиона линий - огромная работа, которая занимает много вре-
мени независимо от того, насколько хорош ваш алгоритм. •
Для рисования кривых Гильберта с помощью этого рекурсивного алгоритма
предназначена программа Hilbl, показанная на рис. 5.4. При запуске этой програм-
мы не задавайте слишком большую глубину рекурсии (больше 6) до тех пор, пока
вы не определите, насколько быстро она работает на вашем компьютере.

Рис. 5.4. Окно программы ННЫ


Рекурсия

Рекурсивное построение кривых Серпинского


Подобно Гильбертовым кривым, кривые Серпинского - это самоподобные кри-
вые, которые обычно определяются рекурсивно. На рис. 5.5 изображены кривые
Серпинского с глубиной 1, 2, и 3.

1 -го порядка 2-го порядка 3-го порядка

Рис. 5.5. Кривые Серпинского

Алгоритм построения Гильбертовых кривых использует одну процедуру для


рисования кривых. Кривые Серпинского проще строить с помощью четырех отдель-
ных процедур, работающих совместно, - SierpA, SierpB, SierpC. и SierpD. Эти
процедуры косвенно рекурсивные - каждая из них вызывает другие, которые после
этого вызывают первоначальную процедуру. Они выводят верхнюю, левую, ниж-
нюю и правую части кривой Серпинского соответственно.
На рис. 5.6 показано, как эти процедуры образуют кривую глубины 1. Отрез-
ки, составляющие кривую, изображены со стрелками, которые указывают направ-
ление их рисования. Сегменты, используемые для соединения частей, представ-
лены пунктирными линиями.
Каждая из четырех основных кривых составлена из линий диагонального сег-
мента, вертикального или горизонтального и еще одного диагонального сегмента.
При глубине рекурсии больше 1 необходимо разложить каждую кривую на мень-
шие части. Это можно сделать, разбивая каждую из двух линий диагональных сег-
ментов на две подкривые.
Например, чтобы разбить кривую типа А, первый диагональный отрезок де-
лится на кривую типа А, за которой следует кривая типа В. Затем без изменения
выведите линию горизонтального сегмента так же, как и в исходной кривой типа
А. И наконец, второй диагональный отрезок разбивается на кривую типа D, за ко-
торой следует кривая типа А. На рис. 5.7 изображен процесс построения кривой
2-го порядка, сформированной из кривых 1-го порядка. Подкривые показаны
жирными линиями.
На рис. 5.8 показано, как из четырех кривых 1-го порядка формируется пол-
ная кривая Серпинского 2-го порядка. Каждая из подкривых обведена пунктир-
ными линиями.
I

Рис. 5.6. Части кривой Рис. 5.7. Составление кривой


Серпинского типа А из меньших частей

Рис. 5.8. Кривая Серпинского, образованная из меньших кривых

С помощью стрелок типа —» и <—, отображающих типы линий, которые соединя-


ют части кривых между собой (тонкие линии на рис. 5.8), можно перечислить рекур-
сивные зависимости между четырьмя типами кривых, как показано на рис. 5.9.
Все процедуры для построения подкривых Серпинс-
кого очень похожи друг на друга, поэтому здесь приведе- A: AXB-D4A
на только одна из них. Зависимости, показанные на рис. BrBXCtAXB
5.9, показывают, какие операции нужно выполнить, что- С: CXD-BXC
D:D\AtCXD
бы нарисовать кривые различных типов. Соотношения
для кривой типа А реализованы в следующем коде. Ос- рис g g рекурсивные
тальные зависимости можно использовать, чтобы изме- зависимости между
нить код для вывода других типов кривых. кривыми Серпинского
procedure TSierplForm.SierpA(depth, dist Integer);
begin
with DrawArea.Canvas do
begin
Рекурсия
if (depth = 1) then
begin
LineTo(PenPos.X-dist,PenPos.Y+dist);
LineTo(PenPos.X-dist,PenPos.Y+0);
LineTo(PenPos.X-dist,PenPos.Y-dist);
end else begin
SierpA(depth-l,dist);
LineTo(PenPos.X-dist,PenPos.Y+dist);
SierpB(depth-l,dist);
LineTo(PenPos.X-dist,PenPos.Y+0);
SierpD(depth-l,dist);
LineTo(PenPos.X-dist,PenPos.Y-dist);
SierpA(depth-l,dist);
end;
end;
end;

Кроме процедур, которые выводят каждую из основных кривых, требуется


процедура, которая использует эти четыре процедуры для построения полной
кривой Серпинского.
procedure TSierplForm.DrawSierp(depth, dist : Integer);
begin
with DrawArea.Canvas do
begin
SierpB(depth,dist);
LineTo(PenPos.X+dist,PenPos.Y+dist);
SierpC(depth,dist);
LineTo(PenPos.X+dist,PenPos.Y-dist);
SierpD(depth,dist);
LineTo(PenPos.X-dist,PenPos.Y-dist);
SierpA(depth,dist);
LineTo(PenPos.X-dist,PenPos.Y+dist);
end; \
end;

Анализ сложности
Для проведения анализа сложности этого алгоритма необходимо определить,
сколько раз вызывается каждая из четырех процедур рисования кривых. Пусть
T(N) — число вызовов любой из четырех основных процедур или основной проце-
дуры Draws ierp при рисовании кривой глубины N.
Когда глубина кривой равна 1, каждая кривая выводится один раз. При этом
Т(1) = 5.
При каждом рекурсивном вызове процедура вызывает саму себя или другую
процедуру четыре раза. Поскольку эти процедуры практически одинаковые, T(N)
для них тоже будет одинаковым независимо от того, какая процедура вызывается
первой. Это обусловлено тем, что кривые Серпинского симметричны и содержат
Недостатки рекурсии
одинаковое количество кривых каждого типа. Рекурсивные уравнения для T(N)
выглядят так:
Т(1)=5
T(N)=1+4*T(N-1) для N> 1.
Эти уравнения очень похожи на уравнения для вычисления сложности алго-
ритма Гильбертовых кривых. Единственная разница в том, что для Гильбертовых
кривых Т(1) = 1. Сравнение нескольких значений этих формул обнаружит равен-
ствоT
cePn_(N) - Tr^oJN + 1). Так как ТГиль6ерта(М) = (4N - 1) / 3, следователь-
но, ТСерпинского(М) = (4N - 1) / 3, что дает такую же сложность, что и для алгоритма
кривых Гильберта - O(4N).
Как и алгоритм построения кривых Гильберта, этот алгоритм выполняется в те-
чение времени O(4N), но это не означает, что он не эффективен. Кривая Серпин-
ского имеет O(4N) линий, так что ни один алгоритм не сможет вывести кривую Сер-
пинского быстрее, чем за время O(4N).
Кривые Серпинского также полностью заполняют экран большинства компь-
ютеров при порядке кривой, большем или равном 9. В какой-то момент при неко-
торой глубине выше 9 вы столкнетесь с ограничениями возможностей вашей ма-
шины.
Программа Sierpl, окно которой показано на рис. 5.10, использует этот рекур-
сивный алгоритм для рисования кривых Серпинского. При выполнении програм-
мы задавайте вначале небольшую глубину рекурсии (меньше 6), пока не определи-
те, насколько быстро ваш компьютер осуществляет необходимые операции.

Рис. 5.10. Окно программы Sierpl

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

Бесконечная рекурсия
Наиболее очевидная опасность заключается в бесконечной рекурсии. Если вы
неверно построите алгоритм, то функция может пропустить основное условие и вы-
полняться бесконечно.
Проще всего допустить эту ошибку, если не указать условие установки, как это
сделано в следующей ошибочной версии функции вычисления факториала. По-
скольку функция не проверяет, достигнуто ли условие остановки рекурсии, она
будет бесконечно вызывать саму себя.
function BadFactoriaKnum : Integer) : Integer;
begin
BadFactorial := num*BadFactorial(num-1);
end;
Функция будет зацикливаться, если основное условие не учитывает все воз-
можные пути рекурсии. В следующей версии функция вычисления факториала
будет бесконечной, если входное значение - не целое число или оно меньше 0. Эти
значения неприемлемы для функции факториала, поэтому в программе, которая
использует эту функцию, может потребоваться проверка входных значений на до-
пустимость.
function BadFactorial2(num : Double) : Double;
begin
if (num=0) then
BadFactorial2 := 1
else
BadFactorial2 := num*BadFactoria!2(num-1);
end;
Следующий пример функции Фибоначчи более сложен. Здесь условие оста-
новки учитывает только некоторые пути развития рекурсии. При выполнении этой
функции возникают все те же проблемы, что и при выполнении функции факто-
риала BadFactorial2, когда задано нецелое или отрицательное число.
function BadFib(num : Double) : Double;
begin
if (num=0) then
BadFib := 0
else
BadFib := BadFib(num-1)+BadFib(num-2);
end;
Последняя проблема, связанная с бесконечной рекурсией, состоит в том, что
«бесконечная» в действительности означает «до тех пор, пока не будет исчерпана
вся память стека». Даже корректно написанные рекурсивные процедуры иногда
приводят к переполнению стека и аварийному завершению работы. Следующая
функция, которая вычисляет сумму N + (N-1) + ... + 2 + 1, исчерпывает память
стека компьютера при больших значениях N. Максимальное значение N, при кото-
ром программа еще будет работать, зависит от конфигурации вашего компьютера.
function BigAdd(n : Double) : Double;
begin
if (n<=l) then
BigAdd := 1
elee
BigAdd := n+BigAdd(n-l) ;
end;

Программа BigAdd 1 демонстрирует этот алгоритм. Проверьте, насколько боль-


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

Потери памяти
Другая, опасность рекурсии - это бесполезный расход памяти. При каждом
вызове подпрограммы система выделяет память для локальных переменных но-
вой процедуры. Для обработки сложной цепочки рекурсивных обращений компь-
ютеру потребуется значительная часть ресурсов для размещения и освобождения
памяти для переменных. Даже если программа при этом не исчерпывает всю па-
мять стека, на управление переменными будет затрачено много времени.
Есть несколько способов сокращения этих затрат. Во-первых, не используйте
ненужные переменные. Следующая версия функции BigAdd заполняет всю па-
мять стека быстрее, чем предыдущая версия. Команда а [ Random ( 9 9 ) ] : = 1 не
дает компилятору оптимизировать код и удалить массив.
function BigAdd(n : Double) : Double;
var
a : array[0..99] of Longint;
begin
a[Random!99)] := 1;
if (n<=l) then
BigAdd := 1
else ,
BigAdd := n+BigAdd(n-l) ;
end;

Объявляя переменные глобально, можно сократить использование стека. Если


вы объявляете переменные вне процедуры, компьютеру не надо при каждом вызо-
ве процедуры выделять новую память.

Необоснованное применение рекурсии


Необоснованное применение рекурсии является не такой очевидной опаснос-
тью. Использование рекурсии - не всегда лучший способ решить задачу. Функ-
ции вычисления факториала, чисел Фибоначчи, НОД и BigAdd, представленные
ранее, на самом деле не должны быть рекурсивными. Лучшие, нерекурсивные вер-
сии этих функций будут описаны позже.
Рекурсия
В функциях вычисления факториала и НОД ненужная рекурсия по большо-
му счету безопасна. Обе эти функции выполняются достаточно быстро для отно-
сительно больших входных чисел. Их работа также не будет ограничена размером
стека, если вы не использовали большую часть стекового пространства в других
частях программы.
С другой стороны, алгоритм вычисления чисел Фибоначчи рекурсия разруша-
ет. Чтобы вычислить Fib(N), алгоритм сначала вычисляет Fib(N - 1) и Fib(N - 2).
Но для подсчета Fib(N - 1) он должен вычислить Fib(N - 2) и Fib(N - 3). При
этом Fib(N - 2) вычисляется дважды.
Анализ, проведенный ранее, показал, что Fib(l) и Fib(O) вычисляются всего
Fib(N + 1) раз в течение вычисления Fib(N). Fib(30) = 832 040, поэтому при вычис-
лении Fib(29) программа фактически вычисляет значения Fib(O) и Fib(l) 832 040
раз. Алгоритм подсчета чисел Фибоначчи тратит огромное количество времени на
определение этих промежуточных значений снова и снова.
При выполнении функции BigAdd возникает другая проблема. Данная функ-
ция, хотя и работает достаточно быстро, вызывает глубокую рекурсию, которая
может исчерпать память стека. Если бы не переполнение стека, можно было бы
вычислить значения суммы для больших входных значений.
Похожая проблема существует и в функции вычисления факториала. При
входном числе N глубина рекурсии для функции BigAdd и факториала равна N.
Функция вычисления факториала не может обрабатывать такие большие значе-
ния, как функция BigAdd. Значение 170! = 7,257Е + 306 - самое большое значе-
ние, которое поддерживается переменной типа Double, поэтому функция б,удет
вычислять значение не больше этого. Когда функция приводит к глубокой рекур-
сии, наступает переполнение стека.

Когда нужно использовать рекурсию


Вероятно, приведенные выше рассуждения заставили вас усомниться в пользе
рекурсии. Но это не так. Многие алгоритмы являются рекурсивными по своей при-
роде. Даже если возможно переписать какой-нибудь алгоритм, чтобы он не содер-
жал рекурсии, многие из них сложнее понимать, анализировать, отлаживать и ре-
ализовывать, если они написаны без использования рекурсии.
В следующих разделах описываются методы для устранения рекурсии из лю-
бого алгоритма. Некоторые нерекурсивные алгоритмы понять всего лишь чуть-
чуть труднее, чем рекурсивные. Нерекурсивные функции вычисления факториа-
ла, НОД, чисел Фибоначчи и BigAdd относительно просты.
С другой стороны, нерекурсивные версии алгоритмов рисования кривых Гиль-
берта и Серпинского достаточно сложны. Их тяжелее понять и сложнее реализо-
вать. Эти версии представлены для того, чтобы продемонстрировать методы, с по-
мощью которых при необходимости можно устранить рекурсию.
Если алгоритм рекурсивен по природе, то лучше записать его именно так. Если
все будет сделано корректно, то вы не встретитесь ни с одной из описанных проб-
лем. Если же возникают какие-либо затруднения, вы можете переписать алгоритм
Удаление хвостовой рекурсии
без использования рекурсии, применяя методы, представленные в следующих раз-
делах. Иногда проще переделать алгоритм, чем с самого начала написать его нере-
курсивно.
. •, '

Удаление хвостовой рекурсии


Вернемся к ранее представленным функциям для вычисления факториалов
и наибольших общих делителей. Также вспомним функцию BigAdd, которая
исчерпывает память стека для относительно малых входных значений.
function Factorial(num : Integer) : Integer;
begin
if (num<=0) then
Factorial := 1
else
Factorial := num*Factorial(num-1);
end;
function Gcd(A, В : Longint) : Longint;
begin
if (B mod A)=0 then
Gcd := A
else
Gcd := Gcd(B mod A,A);
end;
function BigAdd(n : Double) : Double;
begin
if (n<=l) then
BigAdd := 1
else
BigAdd := n+BigAdd(n-l) ,-
end;

Во всех этих функциях перед окончанием работы делается один рекурсивный


шаг. Этот тип рекурсии в конце процедуры называется остаточной или хвостовой
рекурсией (tail recursion).
Поскольку после рекурсивного шага в процедуре ничего не происходит, мож-
но запросто ее удалить. Вместо рекурсивного вызова функции процедура изменя-
ет свои параметры, устанавливая в них те значения, которые бы она получила при
рекурсивном вызове, и затем выполняется снова.
Рассмотрим эту общую рекурсивную процедуру:
procedure Recurse(A : Integer);
begin
// Выполнение каких-либо действий, вычисление В, и т.д.
Recurse(B);
end;
Эту процедуру можно переписать без рекурсии следующим образом:
Рекурсия
procedure NoRecurse(A : Integer);
begin
while (не выполнено) do
begin
// Выполнение каких-либо действий, вычисление В, и т.д.
А := В;
end;
end;
Данный процесс называется устранением остаточной рекурсии или устране-
нием хвостовой рекурсии (tail recursion removal или recursion removal). Такой при-
ем не уменьшает время выполнения программы. Просто рекурсивные шаги заме-
нены циклом while.
Устранение остаточной рекурсии тем не менее позволяет избежать вызова про-
цедуры, поэтому может увеличить скорость алгоритма. Важно то, что этот метод
экономит стековое пространство. Алгоритмы, подобные функции BigAdd, кото-
рые ограничены глубиной рекурсии, могут от этого значительно выиграть.
Некоторые компиляторы автоматически удаляют остаточную рекурсию, но
компилятор Delphi этого не делает. Иначе функция BigAdd, представленная в пре-
дыдущем разделе, не вызывала бы переполнение стека.
Если устранить хвостовую рекурсию, переписать функции вычисления фак-
ториала, НОД и BigAdd достаточно просто.
function Factorial(num : Integer) : Integer;
begin
Result := 1;
while (num>l) do
begin
Result := Result*num,- /
Num := num-1; // Подготовка к рекурсии.
end;
end;
function Gcd(A, В : Longint) : Longint;
var
b_mod_a : Longint ;
begin
b_mod_a := В mod A;
while (b_mod_a<>0) do
begin
// Подготовка аргументов к рекурсии.
В := А;
А := b_mod_a;
b_mod_a := В mod A;
end;
Result := А;
end;
function BigAdd(n : Double) : Double;
begin
Result := 1;
Нерекурсивное вычисление чисел Фибоначчи
while (n>l) do
begin
Result :=. Result+N;
N := n-1;
end;
end;
Алгоритмы вычисления факториала и НОД практически не отличаются в сво-
их рекурсивном и нерекурсивном вариантах. Обе версии выполняются достаточ-
но быстро и могут оперировать большими (в разумных пределах) значениями.
Однако для функции BigAdd разница существенна. Рекурсивная версия ис-
черпывает память стека при достаточно малых входных значениях. В то время как
нерекурсивная версия, вообще не использующая стек, может вычислить значения
154
суммы для N = 10 . После этого наступит переполнение для данных типа Double.
154
Конечно, выполнение алгоритма для 10 шагов будет занимать много времени,
поэтому поверьте на слово и не экспериментируйте с большими числами. Обрати-
те внимание, что эта функция дает такое же значение, что и функция N * (N + 1) / 2,
которую вычислить гораздо проще.
Программы Facto2, Gcd2, и BigAdd2 демонстрируют эти нерекурсивные алго-
ритмы.
'."';,' • • • • ' .

Нерекурсивное вычисление чисел Фибоначчи


К сожалению, рекурсивный алгоритм для подсчета чисел Фибоначчи содержит
не только хвостовую рекурсию. Алгоритм использует два рекурсивных обращения
для вычисления значения. Второй вызов следует после завершения первого. По-
скольку первый вызов находится не в самом конце функции, это не хвостовая ре-
курсия, поэтому ее нельзя удалить обычным способом.
Возможно, рекурсии нельзя избежать еще потому, что рекурсивный алгоритм
Фибоначчи ограничен скорее вычислением слишком большого количества проме-
жуточных значений, чем глубиной рекурсии. Удаление хвостовой рекурсии умень-
шает глубину рекурсии, но это не изменяет время выполнения алгоритма. Даже
без хвостовой рекурсии он все равно будет чрезвычайно медленным.
Проблема заключается в том, что алгоритм повторно вычисляет одни и те же
значения много раз. Значения Fib(l) и Fib(O) просчитываются Fib(N +1) раз при
вычислении Fib(N). Для определения Fib(29) алгоритм вычисляет значения Fib(O)
и Fib( 1)832 040 раз.
Поскольку алгоритм повторно высчитывает одни и те же значения много раз,
необходимо отыскать способ, который бы позволил избежать повторных вычисле-
ний. Простой и конструктивный прием - сформировать таблицу расчетных значе-
ний. Когда потребуется промежуточное значение, можно взять его из таблицы, а не
вычислять заново.
В этом примере показано, как создать таблицу для хранения значений функ-
ции Фибоначчи Fib(N) для N < 1477. Для N > 1477 происходит переполнение пе-
ременных типа Double, используемых функцией. В следующем коде представле-
на функция Фибоначчи с соответствующими изменениями:
Рекурсия
Const
MAX_FIB=1476; // Самое большое значение, которое можно вычислить.
var
FiboValues : Array [O..MAX_FIB] of Double;
function Fibofn : Integer) : Double;
begin
// Вычисление значения, если его нет в таблице.
if (FiboValues[n]<0.0) then
FiboValues[n] := Fibo(n-1)+Fibo(n-2);
Fibo := FiboValues[n];
end;
При запуске программа присваивает каждому элементу массива FiboValues
значение - единицу. Затем она устанавливает значение FiboValues [ 0 ] в нуль,
a FiboValues [ 1 ] - в единицу. Эти значения составляют основное условие ре-
курсии.
При выполнении функции массив проверяется на наличие необходимого зна-
чения. Если его там нет, функция рекурсивно вычисляет это значение и сохраняет
его для дальнейшего использования.
Программа Fibo2 использует этот метод для подсчета чисел Фибоначчи. Про-
грамма может быстро вычислять Fib(N) для N порядка 100 или 200. Но если вы
попробуете определить Fib(1476), то программа выполнит последовательность
рекурсивных вызовов глубиной 1476 уровней, в результате чего стек вашей систе-
мы, скорее всего, переполнится.
Альтернативный метод заполнения таблицы чисел Фибоначчи позволяет из-
бежать такой глубокой рекурсии. Когда программа инициализирует массив Fibo-
Values, она заранее вычисляет все числа Фибоначчи.
// Инициализация таблицы соответствия.
procedure TFiboForm.FormCreate(Sender : TObject);
var
i : Integer;
begin
FiboValues [0] := 0,-
FiboValues [ 1 ] := 1;
for i := 2 to MAX_FIB do
FiboValues[i] := FiboValues[i-1]+FiboValues[i-2];
end;
// Поиск чисел Фибоначчи в таблице.
function Fibofn : Integer) : Double;
begin
1
Fibo : = ^FiboValues[n] ;
end;
При выполнении этого алгоритма определенное время занимает создание мас-
сива поиска. Как только массив готов, для обращения к значению массива требу-
ется всего один шаг. Ни процедура инициализации, ни функция Fib не использу-
ют рекурсию, поэтому ни одна из них не исчерпывает память стека. Программа
Fibo3 демонстрирует предложенный подход.
Устранение рекурсии в общем случае
.., . .,-. „.'.,. ..,.„.,,. . , , „ . , . . „ . , , . . » . . . " * * - . . . , . . - . . , . *
II
Стоит рассмотреть еще один метод вычисления чисел Фибоначчи. Первое ре-
курсивное определение функции Фибоначчи использует нисходящий способ.
Чтобы получить значение для Fib(N), алгоритм рекурсивно вычисляет Fib(N - 1)
и Fib(N - 2) и складывает их.
Процедура InitializeFibValues работает снизу вверх. Она начинает со
значений Fib(O) и Fib(l). Затем она использует меньшие значения для вычисле-
ния больших, пока таблица не заполнится.
Можно использовать эту стратегию, чтобы непосредственно вычислять значе-
ние функции Фибоначчи. Данный метод занимает больше времени, чем поиск
значений в массиве, но не требует дополнительной памяти для размещения масси-
ва. Это пример пространственно-временного компромисса. Чем больше памяти
используется для хранения таблицы, тем быстрее выполняется алгоритм.
function Fibo(n : Integer) : Double;
var
fib_i, fib_i_minus_l, fib_i_minus_2 : Double;
i : Integer;
begin
- if (n<=l) then
Fibo := n
else
begin
fib_i := 0; // Предотвращает предупреждение компилятора.
fib_i_minus_2 := 0; //Начальное значение fib(O).
fib_i_minus_l := 1; //Начальное значение fib(l).
for i := 2 to n do
begin
fib_i := fib_i_minus_l+fib_i_minus_2;
fib_i_itiinus_2 := f ib_i_minus_l ;
fib_i_minus_l := fib_i;
end;
Fibo := fib_i;
end;
end;
Для вычисления значения Fib(N) этой версии необходимо O(N) шагов. Это
больше, чем один шаг, который требовался в предыдущей версии, но гораздо быс-
трее, чем O(Fib(N)) в исходной версии алгоритма.
На компьютере, имеющем процессор Pentium с тактовой частотой 133 МГц,
первоначальный рекурсивный алгоритм вычислял бы Fib(40) = 1.02E + 8 более
155 с. Новый алгоритм не требует большого количества времени, чтобы опреде-
лить Р1Ь(Й76) = 1,31Е + 308. Этот метод вычисления чисел Фибоначчи иллюст-
рирует программа Fibo4.

Устранение рекурсии в общем случае


Функции факториала, НОД и BigAdd могут быть упрощены устранением
хвостовой рекурсии. Упростить функцию, просчитывающую числа Фибоначчи,
можно с помощью таблицы значений или решения задачи восходящим способом.
EEEHHHIIIIi Рекурсия
"•"^^ ™"
Некоторые рекурсивные алгоритмы настолько сложны, что применение этих
методов затруднено или невозможно. Проблематично создать нерекурсивные ал-
горитмы для рисования кривых Гильберта и Серпинского. Другие рекурсивные
алгоритмы еще более сложны.
В предыдущих разделах показывалось, что любой алгоритм, который выводит
кривые Гильберта или Серпинского, занимает O(N4) шагов, поэтому первоначаль-
ные рекурсивные алгоритмы вполне годятся для работы. Они достигают максималь-
ной производительности при приемлемой глубине рекурсии. Однако встречаются
сложные алгоритмы, которые имеют большую глубину рекурсии, но устранение хво-
стовой рекурсии в них невозможно. В этом случае можно преобразовать рекурсив-
ный алгоритм в нерекурсивный. Базовый метод состоит в том, чтобы исследовать
пути, по которым компьютер выполняет рекурсию и попытаться повторить шаги,
выполняемые компьютером. Новый алгоритм будет выполнять мнимую рекурсию
вместо того, чтобы всю работу делал компьютер.
Поскольку новый алгоритм выполняет практически те же самые шаги, что
и компьютер, стоит поинтересоваться, увеличится ли скорость. Скорее всего, нет.
Компьютер может выполнять определенные задачи с помощью рекурсии быстрее,
чем вы можете имитировать их. Самостоятельная обработка всех деталей алгорит-
мов позволяет вам лучше контролировать распределение локальных переменных
и позволяет избежать большой глубины рекурсии.
Обычно вызов процедуры осуществляется в три этапа. Во-первых, система со-
храняет всю информацию, которая ей нужна для продолжения выполнения проце-
дуры после ее завершения. Во-вторых, она подготавливает вызов процедуры и пере-
дает управление ей. И наконец, когда вызванная процедура завершается, система
восстанавливает сохраненную информацию и передает управление назад в соот-
ветствующую точку программы. Если вы преобразовываете рекурсивную проце-
дуру в нерекурсивную, вы должны самостоятельно выполнить эти три шага.
Рекурсивную процедуру можно обобщить следующим образом:
procedure Recurse(num : Integer);
begin
<Кодовый блок 1>
Recurse(<параметры>)
<Кодовый блок 2>
end;

Поскольку после рекурсивного шага есть еще операторы, в данном алгоритме


нельзя устранить хвостовую рекурсию.
Начните с маркировки первых строк в 1-ом и 2-ом блоках кода. Затем эти мет-
ки будут использоваться для определения места, с которого требуется продолжить
выполнение при возврате из мнимой рекурсии. Метки нужны только для того, что-
бы вам было проще понять, что делает алгоритм; они не являются частью кода
Delphi. В данном примере метки будут выглядеть таким образом:
procedure Recurse(num : Integer);
begin
1 <Кодовый блок 1>
Recur8е(<параметры>);
j^ _J|
2 <Кодовый блок 2>
end;
Используйте специальную метку 0 для обозначения конца мнимой рекурсии.
Затем можно переписать процедуру без рекурсии:
procedure Recurse(num : Integer);
var
рс : Integer; // Говорит о том, где необходимо возобновить
// выполнение.
begin
рс := 1; // Начинаем сначала.
repeat // Бесконечный цикл.
case pc of
1: // Выполняется 1 кодовый блок.
begin
<Кодовый блок 1>
if (достигнуто основное условие) then begin
// Выход из рекурсии и возврат к основному коду.
// Блок 2.
рс := 2
end else begin
// Сохранение необходимых после рекурсии переменных.
// Сохранить рс = 2 - где необходимо возобновить
// выполнение после окончания рекурсии.
// установка переменных для рекурсивного обращения.
// Например, num = num - 1.

//Переход к блоку 1 для запуска рекурсии


рс := 1;
end;
end;
2: // Выполнение 2 кодового блока.
begin
<Кодовый блок 2>
РС := 0;
end;
О: // Рекурсия закончена.
begin
if (Это последняя рекурсия) then break;
// В противном случае восстанавливаются рс и другие
// переменные, сохраненные перед рекурсией.

end;
end; // End case.
until ( F a l s e ) ; // Конец бесконечного цикла.
end;
Переменная рс - это программный счетчик, который сообщает процедуре, ка-
кой шаг она должна выполнить следующим. Например, когда рс = 1, процедура
должна выполнить кодовый блок 1.
ПНИ!.....
Когда процедура достигает основного условия, она не выполняет рекурсию.
Вместо этого она изменяет значение рс на 2, и программа продолжает выполне-
ние кодового блока 2.
Если процедура еще не достигла основного условия, осуществляется мнимая
рекурсия. Для этого процедура сохраняет значения любых локальных переменных,
которые потребуются после завершения мнимой рекурсии. Она также сохраняет
значение рс для участка кода, который она должна выполнить после окончания
мнимой рекурсии. В данном примере следующим выполняется кодовый блок 2,
поэтому программа сохраняет 2 как следующее значение рс. Самый простой спо-
соб хранить значения локальных переменных и рс состоит в использовании сте-
ков, таких как описанные в главе 3.
Это проще понять на конкретном примере. Рассмотрим немного измененную
версию функции факториала. Здесь она написана как процедура, которая возвра-
щает свое значение через переменную, а не через саму функцию, что немного упро-
щает процедуру.
procedure Factorial(var num, value : Integer);
var
partial : Integer;
begin
1 if (num<=l) then
value := 1 else
begin
Factorial(num-1,partial);
2 value := num*partial;
end;
end;

После возврата процедуры из рекурсии требуется узнать первоначальное зна-


чение переменной num, чтобы выполнить умножение value: = num * partial.
Поскольку процедуре необходим доступ к значению num после окончания рекур-
сии, она должна сохранить значения рс и num перед началом рекурсии.
Следующая процедура сохраняет эти значения в двух стеках на основе масси-
ва. При подготовке рекурсии процедура помещает значения num и рс в стеки. Когда
мнимая рекурсия заканчивается, процедура выталкивает из стеков недавно добав-
ленные значения. Следующий код демонстрирует нерекурсивную версию процеду-
ры вычисления факториала:
procedure Factorial(var num, value : Integer);
var
num_stack : array [1..200] of Integer;
pc_stack : array [1..200] of Integer;
stack_top : Integer; // Начало стека.
рс : Integer;
begin
stack_top := 0;
pc := 1;
рекурсии в общем случае
repeat // Бесконечный цикл.
case pc of
1 :
begin
if (n'um<=l) then // Основное условие.
begin
value := 1;
pc := 0; // Окончание рекурсии.
end else // Рекурсия.
begin
// Сохранение значений пит и следующего рс.
stack_top := stack_top+l;
num_stack[stack_top] := num;
pc_stack[stack_top] := 2; //Начинать с 2.
// Начало рекурсии.
num := num-1;
// Передает управление обратно в начало процедуры.
рс := 1;
end;
end;
2 :
begin
// Значения переменных содержат результат недавно
// оконченной рекурсии. Оно умножается на num.
value := value*num;
// Выход из рекурсии.
рс := 0;
end;
О :
.
begin
// Конец рекурсии.
// Если стеки пусты, то процедура заканчивает выполнение.
if (stack_top<=0) then break;
// В противном случае восстанавливаются значения локальных
// переменных и рс.
num := num_stack[stack_top],-
рс := pc_stack[stack_top];
stack_top := stack_top-l;
end;
end; // End case.
Until (False)-; // Конец бесконечного цикла.
end;
Как и алгоритм устранения хвостовой рекурсии, этот метод имитирует пове-
дение рекурсивного алгоритма. Процедура заменяет каждое рекурсивное обраще-
ние итерацией цикла repeat. Поскольку число шагов остается тем же самым, пол-
ное время выполнения алгоритма не изменяется. •
I Рекурсия
Как и в случае удаления хвостовой рекурсии, этот метод позволяет избежать
глубокой рекурсии, которая может переполнить стек.

Нерекурсивное создание кривых Гильберта


Пример вычисления факториала в предыдущем разделе превращал простую,
но неэффективную рекурсивную функцию факториала в сложную и неэффектив-
ную нерекурсивную процедуру. Более лучший нерекурсивный алгоритм вычисле-
ния факториала был представлен в этой главе ранее.
Труднее найти простую нерекурсивную версию для более сложных алгорит-
мов. Методы, описанные в предыдущем разделе, используются, когда алгоритм
содержит многократную или косвенную рекурсию.
Более интересный пример устранения рекурсии - рекурсивный алгоритм Гиль-
бертовых кривых.
procedure THilblForm.DrawHilbert(depth, dx, dy : Integer);
begin
with DrawArea.Canvas do
begin ,
if (depth>l) then DrawHilbertfdepth-l,dy,dx);
EineTofPenPos.X+dx,PenPos.Y+dy);
if (depth>l) then DrawHilbert(depth-l,dx,dy) ;
LineTo(PenPos.X+dy,PenPos.Y+dx);
if (depth>l) then DrawHilbert(depth-l,dx,dy);
LineTo(PenPos.X-dx,PenPos.Y-dy);
if (depth>l) then DrawHilbert(depth-!,-dy,-dx),•
end;
end;
В следующем фрагменте кода первые строки каждого блока между рекурсив-
ными шагами пронумерованы. Это первая строка процедуры и любых других то-
чек, в которых возможно продолжение работы алгоритма после окончания мни-
мой рекурсии.
procedure THilblForm.DrawHilbert(depth, dx, dy : Integer);
begin
with DrawArea.Canvas do
begin
1 if (depth>l) then DrawHilbert(depth-l,dy,dx);
2 LineTo(PenPos.X+dx,PenPos.Y+dy);
if (depth>l) then DrawHilbert(depth-l,dx,dy);
3 LineTo(PenPos.X+dy,PenPos.Y+dx);
if (depth>l) then DrawHilbert(depth-l,dx,dy);
4 LineTo(PenPos.X-dx,PenPos.Y-dy);
if (depth>l) then DrawHilbert (depth-l,-dy,-dx) ,-
end;
end;
Каждый раз, когда нерекурсивная процедура начинает мнимую рекурсию,
она должна сохранить значения локальных переменных depth, dx и dy, а также
_JE!!^^ |:
следующего значения переменной рс. После возврата из мнимой рекурсии эти
значения восстанавливаются. Для упрощения подобных операций морено напи-
сать пару вспомогательных процедур для проталкивания и выталкивания этих
значений из группы стеков.
const
STACK_SIZE=10; // Максимальная глубина рекурсии.
type
THilb2Form = class(TForm) // Код опущен...

private
pc_stack, depth_stack : Array [1..STACK_SIZE] of Integer;
dx_stack, dy_stack: Array [1..STACK_SIZE] of Integer;
top_of_stack : Integer;
// Код опущен... :

end;
// Проталкивание значений в стеки.
procedure.THilb2Form.PushValues(pc, depth, dx, dy : Integer):
begin
top_of_stack := top_of_stack+l;
depth_stack[top_of_stack] := depth;
dx_stack[top_of_stack] := dx;
dy_stack[top_of_stack] := dy;
pc_stack[top_of_stack] := pc;
end;
// Выталкивание значений из стеков.
procedure THilb2Form.PopValues(var pc, depth, dx, dy : Integer);
begin
depth • := depth_stack[top_of_stack];
dx := dx_stack[top_of_stack];
dy := dy_stack[top_of_stack];
pc := pc_stack[top_of_stack];
top_of_stack := tpp_of_stack-l;
end;
Следующий код иллюстрирует нерекурсивную версию процедуры рисования
кривых Гильберта.
procedure THilbSForm.DrawHilbert(depth, dx, dy : Integer);
var
pc, tmp : Integer;
begin
pc := 1;
with DrawArea.Canvas do
while (True) do
begin
Case pc of
1 :
Рекурсия
begin
if (depth>l) then // Рекурсия.
begin
// Сохранение текущих значений.
PushValues(2,depth,dx,dy);
// Подготовка к рекурсии.
depth := depth-1;
tmp := dx;
dx := dy;
dy := tmp;
pc := 1; // Возврат к началу рекурсивного
// обращения. ^
end else begin // Основное условие.
// Достигли достаточной глубины рекурсии.
// Продолжаем с блоком 2.
рс := 2;
end;
end;
2:
begin
LineTo(PenPos.X+dx,PenPos.Y+dy);
if (depth>l) then // Рекурсия.
begin
// Сохранение текущих значений.
PushValues (3 , depth, dx, dy) ;
// Подготовка к рекурсии.
depth := depth-1;
// dx и dy остаются теми же.
pc := 1 // Возврат к началу рекурсивного
// обращения.
end else begin // Основное условие.
// Достигли достаточной глубины рекурсии.
// Продолжаем с блоком 3.
рс := 3;
end;
end;
3 :
begin
LineTo(PenPos.X+dy,PenPos.Y+dx);
if (depth>l) then // Рекурсия.
begin
// Сохранение текущих значений. •
PushValues(4,depth,dx,dy);
// Подготовка к рекурсии.
depth :=d epth-1;
// dx и dy остаются без изменения.
pc := 1; //В начало рекурсивного обращения.
end else begin //Основное условие.
// Достигли достаточной глубины рекурсии.
// Продолжаем с блоком 4.
pc := 4;
end;
end;
4:
begin
LineTo(PenPos.X-dx,PenPos.Y-dy);
if (depth>l) then // Рекурсия.
begin
// Сохранение текущих значений.
PushValues(0,depth,dx,dy);
// Подготовка к рекурсии.
depth := depth-1;
tmp := dx;
dx := -dy;
dy := -tmp;
pc := 1; // В начало рекурсивного обращения.
end else begin // Основное условие.
// Достигли достаточной глубины рекурсии.
// Конец этого рекурсивного обращения.
рс := 0;
end;
end;
О : // Возврат из рекурсии.
begin
if (top_of_stack>0) then
PopValues (pc,depth,dx,dy)
else
// Стек пустой. Задача выполнена.
break;
end; // Конец case pc of.
end; // Конец while (True).
end; // Конец with DrawArea.Canvas do.
end;
Сложность этого алгоритма достаточно трудно анализировать напрямую. По-
скольку методы преобразования рекурсивных процедур в нерекурсивные не ме-
няют сложности алгоритма, эта процедура так же, как и предыдущая, имеет время
выполнения порядка O(N4).
Программа Hilb2 демонстрирует нерекурсивный алгоритм построения Гильбер-
товых кривых. Вначале задавайте построение несложных кривых (глубина менее 6),
пока не узнаете, насколько быстро программа будет работать на вашем компьютере.

Нерекурсивное построение кривых Серпинского


Алгоритм рисования кривых Серпинского, представленный ранее, включает
в себя и множественную, и косвенную рекурсию. Поскольку алгоритм состоит из
четырех подпрограмм, которые вызывают друг друга, нельзя просто пронумеро-
вать важные строки программы, как в случае с алгоритмом Гильбертовых кривых.
Можно справиться с этой проблемой, переписав алгоритм с самого сначала.
Рекурсия
Рекурсивная-версия алгоритма состоит из четырех подпрограмм- SierpA,
SierpB, SierpC и SierpD. Процедура SierpA выглядит следующим образом:
procedure TSierplForm.SierpA(depth, diet : Integer);
begin
with DrawArea.Canvas do
begin
if (depth=l) then
begin
LineTo(PenPos.X-dist,PenPos.Y+dist);
LineTo(PenPos.X-dist,PenPos.Y+0);
LineTo(PenPos.X-dlst,PenPos.Y-dist);
end else
begin
SierpA(depth-l,dist) ;
LineTo(PenPos.X-dist,PenPos.Y+dist);
SierpB(depth-1,dist); ,
LineTo(PenPos.X-dist,PenPos.Y+0);
SierpD(depth-1,dist);
LineTo(PenPos.X-dist,PenPos.Y-dist);
SierpA(depth-l,dist) ;
end;
end;
end;
Остальные три процедуры аналогичны. Объединить их все в одну не слишком
сложно.
procedure DrawSubcurve(depth, dist, func : Integer);
begin
case func of
1 :
// <код SierpA>.
2 :
// <код SierpB>.
3 :
// <код SierpC>.
4 :
// <код SierpD>.
end;
end;
Параметр Func указывает процедуре, какая часть кода должна выполняться.
Можно заменить вызовы подпрограмм вызовом SierpAll с соответствующим
значением func. Например, вместо подпрограммы SierpA будет вызываться про-
цедура SierpAll, где значение func установлено в 1.
Новая процедура рекурсивно вызывает себя в 16 различных точках. Эта про-
цедура намного сложнее, чем процедура Hi Ibert, но с другой стороны, она имеет
схожую структуру. Поэтому для того чтобы сделать ее нерекурсивной, вы можете
применить те же методы.
Нерекурсивные кривые Серпинского
Используйте первую цифру меток рс, чтобы указать общий блок кода, кото-
рый должен выполняться. Пронумеруйте строки в пределах кода SierpA числа-
ми И, 12, 13 и т.д., а в коде SierpB - соответственно числами 21, 22, 23 и т.д.
Теперь можно маркировать ключевые строки программы в пределах каждого
блока. Для кода подпрограммы SierpA ключевые строки будут такими:
// Код SierpA.
with DrawArea . Canvas do
begin
11 if (depth=l) then
begin
LineTo ( PenPos . X-dist , PenPos . Y+dist ) ;
LineTo ( PenPos . X-dist , PenPos . Y+0 ) ;
LineTo ( PenPos . X-dist , PenPos . Y-dist ) ;
end else begin
SierpA (depth-l,dist) ;
12 LineTo (PenPos. X-dist, PenPos. Y+dist) ;
SierpB(depth-l,dist) ;
13 LineTo (PenPos. X-dist, PenPos. Y+0) ;
SierpD (depth-l,dist) ;
14 LineTo (PenPos. X-dist, PenPos. Ydist) ;
SierpA (depth-l,dist) ;
end;
end;
Типичная мнимая рекурсия из кода подпрограммы SierpA в код подпрограм-
мы SierpB выглядит так:
PushValues (depth, 13) // По окончанию рекурсии начать с шага 13.
depth := depth- 1;
рс := 21; // Отправиться в начало кода SierpB.
Метка 0 зарезервирована для обозначения окончания мнимой рекурсии. Сле-
дующий код представляет собой часть нерекурсивной версии процедуры Sierp-
А11. Код для SierpB, SierpC, и SierpD подобен коду для SierpA, поэтому он
опущен. Полный текст этой процедуры вы можете найти в архиве с примерами
к данной книге на сайте издательства «ДМК Пресс» www.dmkpress.ru.
procedure TSierpinskiForm.DrawSubcurve (depth, pc, dist : Integer);
begin
with DrawArea. Canvas do
begin
while (true) do
begin
case pc of
//**************
//* SierpA *

11 :
begin
if (depth<=l) then
ШЭ1 I Рекурсия
begin
LineTo(PenPos.X-dist,PenPos.Y+dist);
LineTo(PenPos.X-di st,PenPos.Y+0);
LineTo(PenPos.X-dist,PenPos.Y-di st);
pc := 0;
end else begin
PushValues(12,depth); // Запуск SierpA.
depth := depth-1;
pc := 11;
end;
end;
12
begin
LineTo(PenPos.X-dist,PenPos.Y+dist);
PushValues(13,depth); // Запуск SierpB.
depth := depth-1;
pc := 21;
end;
13
begin
LineTo(PenPos.X-dist,PenPos.Y+0);
PushValues(14,depth); // Запуск SierpD.
depth := depth-1;
pc := 41;
end;
14 :
begin
LineTo(PenPos.X-di st,PenPos.Y-di st);
PushValues(0,depth); // Запуск SierpA.
depth := depth-1;
pc := 11;
end;
// Код SierpB, SierpC и SierpD опущен.

IГ Конец рекурсии
11*
0 :
begin
if (top^of_stack<=0) then break; // Готово.
PopValues(pc,depth);
end;
end; // case pc of. , '
end; // while (true) do.
end; // with DrawArea.Canvas do.
end;
Как и в случае с алгоритмом построения Гильбертовых кривых, преобразова-
ние алгоритма рисования кривых Серпинского в нерекурсивную форму не изменя-
ет его сложности. Новый алгоритм имитирует рекурсивный,, который выполняется
Резюме
4
в течение O(N ) времени, поэтому новая версия также имеет сложность порядка
О(№).
Нерекурсивная версия позволила бы достичь большей глубины рекурсии, но
вывести кривые Серпинского с глубиной больше чем 8 или 9 практически невоз-
можно. Все эти факты определяют преимущество рекурсивного алгоритма.
Программа Sierp2 использует нерекурсивный алгоритм для вывода кривых
Серпинского. Задавайте вначале построение несложных кривых (глубина ниже 6),
пока не определите, насколько быстро будет выполняться эта программа на вашем
компьютере.

Резюме
При работе с рекурсивными алгоритмами следует избегать трех основных опас-
ностей:
а бесконечная рекурсия. Убедитесь, что ваш алгоритм имеет надежное условие
остановки;
о глубокая рекурсия. Если алгоритм вызывает слишком глубокую рекурсию, он
исчерпает всю память стека. Сократите использование стека, уменьшив коли-
чество переменных, которые размещает процедура, или описывая переменные
глобально. Если процедура все еще исчерпывает память стека, перепишите
алгоритм без рекурсии с помощью устранения хвостовой рекурсии;
а неуместная рекурсия. Обычно это происходит, когда алгоритм, подобный
рекурсивному алгоритму подсчета чисел Фибоначчи, много раз вычисляет
одни и те же промежуточные значения. Если в вашей программе возникают
проблемы подобного рода, попытайтесь переписать алгоритм методом сни-
зу вверх. Если алгоритм нельзя преобразовать с помощью восходящего спо-
соба, создайте таблицу соответствия промежуточных значений.
Но применение рекурсии не всегда бывает неоправданным. Многие задачи ре-
курсивны по своей природе. В этих случаях рекурсивный алгоритм будет проще
понять, отладить и реализовать, чем нерекурсивный. Алгоритмы построения кри-
вых Гильберта и Серпинского демонстрируют именно такую рекурсию. Оба они
естественно рекурсивны, и их гораздо проще понять в рекурсивном представлении.
Если имеется алгоритм, который является рекурсивным по своей природе, но
вы не уверены, можно ли с помощью рекурсивной версии решить задачу, перепи-
шите ее рекурсивно и выясните это. Проблемы может и не возникнуть. Если ка-
кие-либо трудности все же имеются, будет гораздо проще преобразовать рекурсив-
ный алгоритм в нерекурсивную форму, чем сразу создать нерекурсивную версию.
Глава 6. Деревья
В главе 2 описывались способы создания динамических связанных структур, та-
ких как изображенные на рис. 6.1. Такие структуры данных называются графами
(graphs). В главе 12 алгоритмы работы с графами и сетями обсуждаются более под-
робно. В этой же главе рассматриваются графы особого типа, так называемые де-
ревья (tree).
В начале главы приводится определение дерева и разъясняются основные тер-
мины. Затем описываются некоторые способы реализации различных видов дере-
вьев в Delphi.
В последующих разделах рассказывается об алгоритмах обхода вершин дере-
вьев, записанных в различных форматах. Глава заканчивается рассмотрением не-
которых специальных типов деревьев, таких как упорядоченные деревья (sorted
trees), деревья со ссылками (threaded trees) и Q-деревья (quadtrees).
Главы 7 и 8 посвящены более глубоким вопросам, относящимся к деревьям.

Рис. 6. /. Графы

Определения
Можно рекурсивно определить дерево как пустую структуру или узел (node),
называемый корнем (root) дерева, который связан с одним или более поддеревьев
(subtrees).
Представления деревьев

На рис. 6.2 изображено дерево. Корневой узел А соединен с тремя поддеревья-


ми, начинающимися узлами В, С и D. Эти узлы соединены с поддеревьями, имею-
щими корни в узлах Е, F и G, а которые в свою очередь
связаны с поддеревьями с корнями Н, I и J.
Данная терминология является смесью терминов, за-
имствованных из ботаники и генеалогии. Из ботаники
взято определение узла (node), который представляет
собой точку, где может возникнуть ветвь. Ветвь (brunch)
описывает связь между двумя узлами, лист (leaf) - узел,
откуда не выходят другие ветви.
Из генеалогии пришли термины, описывающие отно-
шения. Если узел находится непосредственно над дру- рис g 2 Леоево
гим, то он называется родительским (parent), а нижний
узел называется дочерним (child). Узлы на пути вверх от узла до корня принято счи-
тать предками узла. Например, на рис. 6.2 предками узла I являются узлы Е, В и А.
Все узлы, расположенные ниже какого-либо узла, называются его потомками. На
рис. 6.2 потомками узла В являются узлы Е, Н, I и J. Узлы, имеющие одного и того
же родителя, называются сестринскими (sibling nodes).
Кроме того, существует несколько понятий, возникших собственно в програм-
мистской среде. Внутренний узел (internal node) - это узел, не являющийся лис-
том. Порядком узла (node degree) или его степенью называется количество его до-
черних узлов. Степень дерева - это максимальный порядок его узлов. Степень
дерева, изображенного на рис. 6.2, равна 3, так как узлы А и Е, имеющие макси-
мальную степень, имеют по три дочерних узла.
Глубина (depth) узла равна числу его предков плюс 1. На рис. 6.2 узел Е имеет
глубину 3. Глубина дерева - это наибольшая глубина всех узлов. Глубина дерева,
изображенного на рис. 6.2, равна 4.
Дерево степени 2 называется двоичным (binary) деревом. Деревья степени 3
называются троичными (ternary). Аналогично дерево степени N называется N-ич-
ным (N-ary) деревом. Например, дерево степени 12 называется 12-ричным дере-
вом, но не додекадным. Некоторые предпочитают избегать подобных формулиро-
вок и просто говорят «дерево степени 12».
Рис. 6.3 иллюстрирует некоторые из этих терминов.

Представления деревьев
Теперь, когда вы познакомились с основными терминами, можно начать разго-
вор о способах реализации деревьев в Delphi. Один из способов заключается в со-
здании отдельного класса для каждого типа узлов дерева. Чтобы построить дере-
во, изображенное на рис. 6.3, необходимо определить структуры данных для узлов,
которые имеют нуль, один, два или три дочерних узла. Этот подход не слишком
удобен. Кроме того что требуется управлять четырьмя различными классами, не-
обходимо иметь некоторый индикатор внутри класса, который указывал бы тип
дочернего узла. Алгоритмы, оперирующие подобными деревьями, должны быть
способны работать со всеми типами узлов.
Деревья

Рис. 6.3. Части троичного (степени 3) дерева

Полные узлы
Наиболее просто определить один тип узлов, который содержит достаточное
число указателей на дочерние узлы, чтобы отобразить все необходимые узлы. Я на-
звал этот метод методом полных узлов, так как некоторые узлы могут быть больше-
го размера, чем это необходимо на самом деле. Дерево, изображенное на рис. 6.3,
имеет степень 3. Чтобы построить его методом полных узлов, потребуется опреде-
лить один класс, в котором содержатся указатели на три дочерних узла. Следую-
щий код демонстрирует, как можно определить тип данных для хранения узлов
дерева.
type
PTernaryNode = PTernaryNode ;
TTernaryNode = record
LeftChild : PTernaryNode;
MiddleChild : PTernaryNode;
RightChild : PTernaryNode;
end;

При помощи типа TTernaryNode можно создать дерево, используя элементы


потомков узла для соединения узлов друг с другом. Следующий фрагмент кода
строит два верхних уровня дерева, изображенного на рис. 6.3.
var
А, В, С, D : PTernaryNode;
begin
// Размещение узлов.
GetMem(A,SizeOf(TTernaryNode));
GetMemfВ,SizeOf(TTernaryNode));
GetMem(С,SizeOf(TTernaryNode));
GetMem(D,SizeOf(TTernaryNode));
Представления деревьев |
// Соединение узлов.
A
A .LeftChild := В;
A'.MiddleChild := С;
A
A .RightChild := D;
.
Программа Binary, окно которой показано на рис. 6.4, использует метод пол-
ных узлов для управления двоичным деревом. Вместо типа TTernaryNode для
хранения информации об узлах используется класс TBinaryNode. При щелчке
мышью по какому-нибудь узлу программа подсвечивает кнопку Add Left (До-
бавить слева), если узел не имеет левого дочернего узла, или кнопку Add Right
(Добавить справа), если у узла нет правого
потомка. Кнопка Remove (Удалить) станет
доступной, если выбранный узел не является
корнем. При ее нажатии удаляется выделен-
ный узел и все его потомки.
Так как эта программа позволяет созда-
вать узлы с одним, двумя потомками или без
таковых, она использует представление пол-
ных узлов. Данный пример достаточно про-
сто изменить для построения деревьев боль-
шей степени. Рис. 6.4. Окно программы Binary

Списки дочерних узлов


Если степени узлов дерева различны, то метод полных узлов приводит к напрас-
ному расходованию большого количества памяти. Для построения дерева, изобра-
женного на рис. 6.5, с помощью этого метода каждому узлу требуется присвоить по
шесть указателей на дочерние узлы, хотя только в одном из них используются все
шесть. Для представления этого дерева пона-
добится 72 указателя на дочерние узлы, из ко-
торых в действительности будут работать все-
го 11.
Некоторые программы позволяют добав-
лять и удалять узлы, изменяя степень узлов
в процессе своего выполнения. В этом случае
метод полных узлов не подходит. Такие дина-
мически изменяющиеся деревья можно пред-
ставить, поместив все дочерние узлы в списки.
Существует несколько способов для построе-
ния списков дочерних узлов. Рис. 6.5. Дерево с узлами
Один из них - организовать в классе узла различных степеней
общедоступный массив дочерних узлов с изме-
няемым размером, как показано в следующем фрагменте кода. Чтобы управлять
дочерними узлами, можно использовать методы работы со списками на основе
массива. .;
IMIll! Деревья
type
PNAryNodeArray = лТНАгуNodeArray;
TNAryNode = class(TObject)
private
public
NumChildren : Integer;
Children : PNAryNodeArray;
-

end;
Программа NAry, окно которой изображено на рис. 6.6, использует эту методи-
ку для управления N-ичным деревом в основном так же, как программа Binary
оперирует двоичным деревом. Однако в этой
программе вы можете добавить к каждому узлу
любое количество дочерних.
Чтобы не усложнять без особой необходи-
мости пользовательский интерфейс, програм-
ма NAry всегда добавляет новые узлы в конец
коллекции дочерних узлов. Вы можете изме-
нить программу, реализовав вставку дочерних
зловв
Рис. 6.6. Окно программы NAry У ^редину дерева, но пользовательский
интерфейс при этом усложнится.
Альтернативный подход заключается в том, чтобы хранить указатели на до-
черние узлы в связанных списках. Каждый узел содержит указатель на первого
потомка, а также на следующего потомка на том же уровне дерева. Эти связи обра-
зуют связанный список дочерних узлов, поэтому я назвал эту методику представ-
лением связанных потомков (linked sibling). Подробная информация о связанных
списках содержится в главе 2.
: :
: .' . ' - • • . .

Представление нумерацией связей


Представление нумерацией связей (forward star), описанное в главе 4, обеспе-
чивает компактное представление деревьев, графов и сетей на основе массивов.
Для хранения дерева с помощью метода нумерации связей в массиве FirstLink
записывается индекс первой ветви, исходящей из каждого узла. В другой массив,
ToNode, заносятся узлы, к которым ведет данная ветвь.
Метка в конце массива FirstLink указывает на точку, расположенную сразу
за последним элементом массива ToNode. Это позволяет легко определить, какие
ветви выходят из каждого узла. Ветви, начинающиеся в узле i, указаны под номе-
рами о т F i r s t L i n k [ i ] до FirstLink [i+l]-l.
На рис. 6.7 показано дерево и его представление нумерацией связей. Связи от
узла 3 (обозначенного как D) - это ветви от FirstLink [3 ] до FirstLink [4] -1.
Значение FirstLink [3] =9, a FirstLink [4] =11, поэтому эти ветви обозна-
чены как 9 и 10. Записи массива ToNode для них составляют ToNode [ 9 ] =10
и ToNode [10] =11, следовательно, для узла 3 дочерними являются узлы 10 и 11,
обозначенные как К и L. Это означает, что узел D соединен с К и L.
Представления деревьев |

Массив FirstLink

Индекс 0 1 2 3 4 5 6 7 8 9 10 11 12
Метка А В С D Е F G Н I J К L
Первая ветвь 0 2 3 9 11 11 11 11 11 11 11 11 11

Массив ToNode

Индекс 0 1 2 3 4 5 6 7 8 9 10
Конечный узел 1 2 3 4 5 6 7 8 9 10 11
Рис. 6.7. Дерево и его представление нумерацией связей

Представление дерева с помощью метода нумерации связей компактно и ос-


новано на массиве, что облегчает запись и чтение деревьев, представленных та-
ким образом, из файлов. Операции над массивами выполняются быстрее, чем опе-
рации с указателями, необходимые при некоторых других представлениях.
По этой причине большая часть литературы по сетевым алгоритмам использу-
ет представление нумерацией связей. Многие статьи о поиске кратчайшего пути
предполагают, что данные записаны в подобном формате. Прежде чем изучать эти
алгоритмы по журналам, таким как Management Science или Operations Research,
вы должны освоить представление нумерацией связей.
С помощью данного метода можно быстро отыскать ветви, исходящие из опре-
деленного узла. С другой стороны, очень сложно изменять структуру данных,
представленных в таком виде. Чтобы добавить новый дочерний узел к узлу А
(см. рис. 6.7), необходимо изменить практически каждый элемент в массивах
ToNode и FirstLink. Сначала необходимо сдвинуть все элементы в массиве ToNode
на одну позицию вправо, чтобы освободить место для новой ветви, затем вставить
новую запись ToNode, которая указывает на новый узел. И наконец, необходимо про-
смотреть весь массив FirstLink, обновив каждый элемент так, чтобы он указывал
на новую позицию соответствующей записи ToNode. Поскольку все записи ToNode
сдвинулись на одну позицию вправо, чтобы освободить место для новой ветви, нуж-
но добавить единицу ко всем задействованным записям FirstLink.
|; Деревья
На рис. 6.8 показано дерево после добавления нового узла. Измененные эле-
менты закрашены серым цветом.

Массив RrstLink

Массив ToNode

Рис. 6.8. Добавление узла

Удалить узел из начала дерева, представленного нумерацией связей, столь же


трудно, как и добавить. Если удаляемый узел имеет потомков, процесс занимает
еще больше времени, потому что вы также должны удалить и дочерние записи.
Если нужно часто модифицировать дерево, то лучше использовать класс с мас-
сивом или связанным списком указателей на потомков. Такое представление про-
цедур обычно проще понимать и отлаживать. С другой стороны, представление
в виде нумерации связей иногда обеспечивает более высокую производительность
для сложных алгоритмов. Кроме того, это стандартная структура данных, которая
широко освещена в литературе, поэтому вы должны обязательно изучить ее, если
хотите и далее исследовать алгоритмы работы с сетями и деревьями.
Программа FStar использует представление нумерацией связей, чтобы управ-
лять деревом с узлами различных степеней. Она аналогична программе NAry, но
реализует представление на основе массивов. Если вы посмотрите на код програм-
мы FStar, то увидите, насколько сложно в ней добавлять и удалять узлы. Следую-
щий код показывает, каким образом удаляется узел.
// Удаление выделенного узла из дерева.
// У узла не должно быть дочерних.
procedure TFStarTree.RemoveSelected;
Представления деревьев
var
i, first_link, last._link : Integer;
parent_node, parent_link : Integer;
begin
// Нахождение родительского узла.
parent_node := Node"[Selected].Parent;
first_link := Node"[parent_node].FirstLink;
last_link := Node"[parent_node+l].FirstLink-1;
for parent_link := first_link to last_link do^
if (ToNodeA[parent_link]=Selected) then break;
// Если родительский узел найден, удаляем его.
if (parent_link<=last_link) then
begin
// Заполнение пустого места в массиве ToNode.
for i := parent_link to NumLinks-1 do
ToNode"[i] := ToNode" [i+1] ;
NumLinks := NumLinks-1; .
// He стоит изменять размеры массива ToNode.
.
// Обновление записей массива FirstLink.
for i : = 1 to NumNodes do
if (NodeA[i].FirstLink>parent_link) then
Node"[i].FirstLink := Node*[i].FirstLink-1;
end;
// Удаление самого узла заполнением пустого места в массиве ToNode.
for i := Selected to NumNodes-1 do
Node"[i] := Node"[i+l] ;
NumNodes := NumNodes-1;
// Обновление записей массива ToNode.
for i := 1 to NumLinks do
if (ToNode'4 [i] >Selected) then
ToNode'4 [i] := ToNode" [i] -1;
Selected := 0 ,-
end;

Этот код гораздо сложнее, чем соответствующий код программы NAry, исполь-
зующийся для изменения узла с массивом указателей на потомков.
// Удаление дочернего узла.
procedure TNAryNode.RemoveChild(target : TNAryNode);
var
num, I : Integer;
begin
// Нахождение дочернего узла.
for num := 1 to NumChildren do
begin
if (Children"[num] = target) then break;
end;
Деревья
if (num>NumChildren) then
raise EInvalidOperation.Create(
'Удаляемый узел не является дочерним узлом найденного родителя.');
// Освобождение памяти, занимаемой дочерним узлом.
Children"[num].Free;
// Сдвиг оставшихся дочерних узлов для заполнения пустого места.
for i := num+1 to NumChildren do
Children"[i-1] := Children"[i];
Children"[NumChildren] := nil;
NumChildren := NumChildren-1;
end;
„• - ' '

Полные деревья
Полное дерево (complete tree) содержит максимально возможное число узлов
на каждом уровне, за исключением того, что на нижнем уровне некоторые узлы
могут не иметь потомков. Все узлы на нижнем уровне сдвигаются влево. Напри-
мер, каждый уровень троичного дерева кроме листьев включает в себя три дочер-
них узла, и, возможно, один узел на уровень выше листьев. На рис. 6.9 изображены
полные двоичное и троичное деревья.

Полное двоичное дерево Полное троичное дерево

Рис. 6.9. Полные деревья

Полные деревья обладают рядом важных свойств. Во-первых, это самые ко-
роткие деревья, которые могут содержать заданное количество узлов. Двоичное
дерево на рис. 6.9 - одно из самых коротких двоичных деревьев с шестью узлами.
Существуют другие двоичные деревья глубины 3 с шестью узлами, но нет ни од-
ного дерева глубиной, меньшей 3.
Во-вторых, если полное дерево степени D содержит N узлов, оно будет иметь
глубину O(logD(N)) и O(N) листов. Эти факты очень важны, потому что многие
алгоритмы исследуют деревья с вершины до самого низа или наоборот. Алгоритм,
который выполняет подобное действие один раз, имеет сложность O(log(N)).
Особенно полезное свойство полных деревьев заключается в том, что их можно
хранить в очень компактной форме в массивах. Если вы пронумеруете узлы в «ес-
тественном» порядке, сверху вниз и слева направо, то допускается разместить эле-
менты дерева в массиве в этой же очередности. Рис. 6.10 изображает, как записыва-
ется полное двоичное дерево в массиве.
Обход дерева
Корень дерева стоит в позиции 0. Дочерние узлы i стоят в позициях 2 * i + 1 и 2 *
i + 2. Например, на рис. 6.10 дочерние узлы для узла в позиции 1 (узел В) находят-
ся в позициях 3 и 4 (узлы D и Е).
Можно достаточно просто обобщить это пред-
ставление для полных деревьев больших степе-
ней. Корневой узел стоит в позиции 0. Дочерние
узлы для дерева степени D и узла i стоят в позици-
ях от D * i + 1 до D * i + D. Например, в троичном
дереве дочерние узлы для узла в позиции 2 были
бы расположены в позициях 7,8 и 9. На рис. 6.11
Индекс 0 1 2 3 4 5
изображено полное троичное дерево и его пред-
ставление в виде массива. Узел А В С D Е F
Можно легко получить доступ к дочерним
Рис. 6.10. Размещение полного
узлам, используя методику хранения в массиве. двоичного дерева в массиве
При этом не требуется дополнительной памяти
для дочерних узлов или меток. Сохранение и загрузка дерева из файла сводится
просто к записи или чтению массива дерева. Поэтому такое представление, несом-
ненно, лучшее для программ, которые сохраняют данные в полных деревьях.

Индекс 0 1 2 3 4 5 6 7 8 9 10 11 12
Узел А В С D Е F G Н I J К L М

Рис. 6.11. Размещение полного троичного дерева в массиве

Обходдерева
Последовательное обращение ко всем узлам называется обходом (traversing)
дерева. Существует несколько последовательностей обхода узлов двоичного де-
рева. Три самых простых - прямой, симметричный и обратный - простые рекур-
сивные алгоритмы. Для каждого заданного узла алгоритм выполняет следующие
действия:
Прямой порядок:
1. Обращение к узлу.
2. Рекурсивный прямой обход левого поддерева.
3. Рекурсивный прямой обход правого поддерева.
Деревья

Симметричный порядок:
1. Рекурсивный симметричный обход левого поддерева.
2. Обращение к узлу.
3. Рекурсивный симметричный обход правого поддерева.
Обратный порядок:
1. Рекурсивный обратный обход левого поддерева.
2. Рекурсивный обратный обход правого поддерева.
3. Обращение к узлу.
Все эти три типа обхода являются примерами обхода в глубину (depth-first tra-
versal). Процесс начинается с прохода вглубь дерева, пока алгоритм не достигнет
листьев. Когда рекурсивная процедура снова вызывается, алгоритм проходит де-
рево вверх, посещая пропущенные ранее узлы.
Обход в глубину используется в алгоритмах, где необходимо сначала обратить-
ся ко всем листьям. Например, алгоритм ветвей и границ, описанный в главе 8, вна-
чале посещает листья. Для сокращения времени поиска в оставшейся части дерева
используются результаты, полученные на уровне листьев.
Четвертый метод обхода узлов дерева - обход в ширину (breadth-first traversal).
Этот метод сначала обращается ко всем узлам на данном уровне дерева и только
потом переходит к более глубоким уровням. Обход
в ширину часто используют алгоритмы, осуществляю-
щие полный поиск в дереве. В алгоритме поиска крат-
чайшего пути с установкой меток (см. главу 12) при-
меняется поиск в ширину кратчайшего дерева внутри
сети.
На рис. 6.12 изображено небольшое дерево и по-
Прямой ABDECFG рядок посещения узлов при прямом, симметричном,
Симметричный D B E A F C G обратном обходе и поиске в ширину.
Обратный DEBFGCA Для деревьев, степень которых больше 2, имеет
В ширину A B C D E F G смысл определять прямой, обратный обход и обход
Рис. 6.12. Обходы дерева в ширину. Что касается симметричного обхода, суще-
ствует некоторая неоднозначность, потому что каж-
дый узел посещается после того, как алгоритм обратится к одному, двум или трем
его потомкам. Например, в троичном дереве обращение к узлу может происходить
после обращения к его первому потомку или после обращения ко второму.
Детали реализации обхода зависят от того, как записано дерево. Чтобы обойти
дерево на основе массива указателей на дочерние узлы, программа будет исполь-
зовать несколько более сложный алгоритм, чем для обхода дерева, сформирован-
ного при помощи нумерации связей.
Особенно просто обходить полные деревья, записанные в массивах. Алгоритм
обхода в ширину, который требует выполнения дополнительной работы для дру-
гих представлений дерева, для представления на основе массива достаточно три-
виален, потому что узлы записаны в таком же «естественном» порядке. Следую-
щий код демонстрирует алгоритм обхода полного двоичного дерева.
1ШМНЕШ
type
StringlO = StringflO];
TStringArray = array [1..1000000] of StringlO;
PStringArray = ATStringArray;
var
NumNodes : Integer;
NodeLabel : PStringArray; // Массив меток узлов.
i
procedure Preorder(node : Integer);
begin
VisitNode(NodeLabel A [node]); // Посещение узла.
if (node*2+l<=NumNodes) then
Preorder(node*2+l); // Посещение дочернего узла 1.
if (node*2+2<=NumNodes) then
Preorder(node*2+2); // Посещение дочернего узла 2.
end;
procedure Inorder(node : Integer);
begin
if (node*2+l<=NumNodes) then
Inorder(node*2+l); // Посещение дочернего узла 1.
VisitNodefNodeLabel"[node]); // Посещение узла.
if (node*2+2<=NumNodes) then
Inorder(node*2+2); // Посещение дочернего узла 2.
end;
procedure Postorder(node : Integer);
begin
if (node*2+l<=NumNodes) then
Postorder(node*2 + l) ; // Посещение дочернего узла 1.
if (node*2+2<=NumNodes) then
Postorder(node*2+2); // Посещение дочернего узла 2.
VisitNode(NodeLabel A [node]); // Посещение узла.
end;
procedure BreadthFirst(node : Integer);
var
' I : Integer;
begin
for i := 0 to NumNodes do
VisitNode(NodeLabel A [i]);
end;
Программа Travl демонстрирует прямой, симметричный и обратный порядок
обхода и обход в ширину для полных двоичных деревьев. Введите высоту дерева
и щелкните по кнопке Create Tree (Создать дерево) для построения полного дво-
ичного дерева. Затем нажмите кнопку Preorder (Прямой ооход), Inorder (Симмет-
ричный обход), Postorder (Обратный обход) или Breadth First (Обход в ширину),
чтобы увидеть, как происходит обход. На рис. 6.13 показано окно программы, ото-
бражающее симметричный обход для дерева глубиной, равной 5.
Деревья

Рис. 6.13. Пример симметричного обхода дерева в программе Travl

Прямой и обратный обходы для деревьев, сохраненных в других форматах,


осуществляется еще проще. Следующий код показывает процедуру прямого обхо-
да для дерева, представленного в виде нумерации связей:
procedure Preorder(node : Integer);
var
link : Integer;
begin
VisitNode(NodeLabel*[node]);
for link := FirstLink*[node] to FirstLink*[node+1]-1 do
Preorder(ToNode~[link]);
end;
Как уже говорилось, сложно дать определение симметричного обхода для де-
ревьев больше 2-го порядка. Но если вы разберетесь, что такое симметричный об-
ход, у вас не должно возникнуть затруднений с его реализацией. Следующий код
показывает процедуру обхода, которая сначала обращается к половине потомков
узла, затем посещает сам узел, а после этого - остальные дочерние узлы.
procedure Inorder(node : Integer);
var
mid_link, link : Integer;
begin
// Нахождение среднего дочернего узла.
mid_link := (FirstLink-[node+1]-l+FirstLink / v [node]) div 2;
// Посещение первой группы дочерних узлов.
for link := FirstLink~[node] to mid_link do
Inorder(ToNode A [link]);
// Посещение узла.
VisitNode (NodeLabel'4 [node] ) ;
Обход дерева Ц|
||
// Посещение второй группы дочерних узлов.
for link := mid_link+l to FirstLink" [node+1]- 1 do
Inorder (ToNode" [link] ) ;
end;
В полных деревьях, сохраненных в массиве, узлы уже расположены в порядке
обхода в ширину. Это облегчает обход в ширину для деревьев такого типа. Для
других представлений деревьев подобный обход несколько сложнее.
При обходе других типов деревьев вы можете использовать очередь для хране-
ния узлов, которые необходимо посетить. Сначала поместите в очередь корневой
узел. После обращения он будет удален из начала очереди, а его потомки помеще-
ны в ее конец. Процесс повторяется до тех пор, пока очередь не опустеет. Следую-
щий код демонстрирует процедуру обхода в ширину для деревьев, которые хранят
указатели на дочерние узлы в массивах изменяемого размера:
type
PTrav2NodeArray = ATTrav2NodeArray;
TTrav2Node = claee(TObject)
// Код опущен...

public
NumChildren : Integer;
Children : PTrav2NodeArray;
// Код опущен...
\
i . •
end;
TTrav2NodeArray = array [1..100000000] of TTrav2Node;
function TTrav2Node.BreadthFirstTraverse : String;
var
i, oldest, next_spot : Integer;
queue : PTrav2NodeArray;
begin
Result :=
. - • • '
// Создание массива очереди, достаточно большого для хранения
// всех узлов дерева.
GetMem(queue,NumNodes*SizeOf(TTrav2Node));
// Начинаем с данным узлом в очереди.
queue"[I] := TTrav2Node.Create ;
queue"[1] := Self;
oldest := 1;
next_spot := 2;
// Циклически обрабатывается элемент очереди oldest,
// пока очередь не опустеет.
while (oldest<next_spot) do
begin
with queue"[oldest] do
ЕШНН111 Деревья
begin
// Посещение узла oldest.
Result := Result+Id+'';
// Добавление дочерних узлов данного узла к очереди.
for i := 1 to NumChildren do
begin
queueл[next_spot] := Children* [i] ;
next_spot := next_spot+l;
end;
end; // Конец with queue*[oldest]* do...
oldest := oldest+1;
end;
FreeMemfqueue);
end;

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


емого размера для хранения дочерних узлов. Программа является комбинацией
программы NAry, управляющей N-ичными деревьями, и программы Travl, Кото-
рая демонстрирует обход дерева.
Выберите узел и нажмите кнопку Add Child (Добавить дочерний узел), чтобы
добавить к нему потомка. Нажатие кнопки Remove удаляет узел и всех его потом-
ков. Воспользовавшись кнопками Preorder, Inorder, Postorder или Breadth First,
просмотрите примеры соответствующих обходов дерева. На рис. 6.14 показано
окно программы Trav2, где отображается обратный обход.

Рис. 6.14. Пример обратного обхода дерева в программе Trav2

Упорядоченные деревья
Двоичные деревья - обычный способ хранения и обработки информации в ком-
пьютерных программах. Поскольку многие компьютерные операции являются дво-
ичными, они естественно отображаются в виде двоичных деревьев. Например, в дво-
ичное дерево можно преобразовать двоичную зависимость «меньше чем». Если
Упорядоченные деревья
использовать внутренние узлы дерева, чтобы обозначить утверждение «левый до-
черний узел меньше правого», то вы сможете использовать двоичное дерево, что-
бы построить и сохранить сортированный список. На рис. 6.15 показано двоичное
дерево, хранящее сортированный список с числами 1, 2,4, 6, 7, 9.

Добавление элементов
Алгоритм добавления нового элемента в такой тип деревьев достаточно прост.
Начните с корневого узла. По очереди сравните значения всех узлов со значением
нового элемента. Если новое значение меньше или равно значению в рассматри-
ваемом узле, продолжайте движение вниз по левой ветви. Если новое значение
больше значения узла, переходите вниз по правой ветви. Когда вы достигнете кон-
ца дерева, вставьте элемент в эту позицию.
Чтобы включить значение 8 в дерево, изображенное на рис. 6.15, начните с кор-
ня, который имеет значение 4. Поскольку 8 больше 4, переходите по правой ветви
к следующему узлу - 9. Поскольку 8 меньше 9, продолжайте двигаться налево -
к узду 7. Поскольку 8 больше 7, программа пытается идти направо, но этот узел не
имеет правого дочернего. Поэтому новый элемент вставляется именно в этой точ-
ке, и образуется дерево, изображенное на рис. 6.16.

Рис. 6.15. Упорядоченный Рис. 6.16. Упорядоченный


список: 1, 2, 4, 6,7,9 список: 1, 2, 4, 6, 7,8,9

Следующий код добавляет новое значение под узлом в упорядоченном дере-


ве. Программа начинает вставлять элемент с корневого узла, используя функцию
Insertltem(Root,new_value).
procedure Insertltem(var node : PSortNode; new_value : Integer);
begin
if (node=nil) then
begin «-
// Достигли листа.
// Вставка элемента в этом месте.
GetMem(node,SizeOf(TSortNode));
nodeA.Value := new_value;
end else if (new_value<=nodeл.Value) then
begin
// Левая ветвь.
InsertItem(nodeA.LeftChild,new_value);

\
ЕШННМН1 Деревья
end else begin
// Правая ветвь.
InsertItem(node~.RightChild,new_value);
end;
end;
Когда процедура достигает конца дерева, происходит нечто довольно неожи-
данное. Параметр node этой процедуры объявлен с ключевым словом var. Это
означает, что процедура работает с той же копией переменной node, которую ис-
пользует вызывающая процедура. Если процедура изменяет значение параметра
node, то значение изменяется и для вызывающей процедуры.
Когда процедура Insertltem рекурсивно вызывает саму себя, она передает
указатель на дочерний узел. Например, в следующих операторах процедура пере-
дает указатель на правый дочерний узел в качестве параметра. Если вызванная
процедура изменяет значение параметра node, в вызывающей процедуре указа-
тель на потомка также автоматически обновляется, что добавляет созданный но-
вый узел к дереву.
A
InsertItem(node .RightChild,new_value);
•. - •

Удаление элементов
Удаление элемента из сортированного дерева немного сложнее, чем добавле-
ние. После этой операции программа должна перестроить другие узлы, чтобы со-
хранить в дереве соотношение «меньше чем». Следует рассмотреть несколько су-
ществующих вариантов.
Во-первых, если удаляемый узел не имеет потомков, допустимо просто убрать
его из дерева. При этом порядок остальных узлов не изменяется.
Во-вторых, если удаляемый узел имеет один дочерний узел, можно заменить
его дочерним узлом. При этом порядок потомков данного узла остается тем же,
так как эти узлы также являются и потомками дочернего узла. На рис. 6.17 показа-
но дерево, где удаляется узел 4, имеющий только один дочерний узел.

Рис. 6.17. Удаление узла с единственным потомком

Если удаляемый узел имеет два дочерних, вовсе не обязательно, что один из них
займет его место. Если потомки узла также имеют по два дочерних, то для размеще-
ния всех дочерних узлов в позиции удаленного узла просто нет места. У удаленного
Упорядоченные деревья
узла есть один лишний потомок, а дочерний узел, которым вы могли бы заменить
исходный, имеет два дочерних узла, поэтому вы должны объявить три дочерних
записи узла в данной позиции.
Чтобы решить эту проблему, вы должны заменить удаленный узел крайним
правым узлом дерева из левой ветви. Другими словами, двигайтесь вниз от уда-
ленного узла по левой ветви. Затем двигайтесь вниз по правым ветвям до тех пор,
пока не найдете узел без правой ветви. Это и есть нужный узел. В дереве, изобра-
женном слева на рис. 6.18, нужный узел - крайний правый на ветви слева от узла 4.
Вы можете заменить узел 4 узлом 3 и сохранить порядок следования элементов
дерева.

Рис. 6.18. Удаление узла с двумя потомками

Остается последний вариант - когда заменяющий узел имеет левого потомка.


В этом случае вы можете переместить данного потомка в позицию, освобожден-
ную в результате перемещения узла, и дерево снова будет упорядочено. Крайний
правый узел не имеет правого дочернего узла, иначе он не являлся бы таковым.
Следовательно, не нужно беспокоиться, сколько потомков имеет замещающий
узел.
На рис. 6.19 проиллюстрирована эта сложная ситуация. В данном примере
удаляется узел 8. Крайний правый узел слева - узел 7, который имеет дочерний
узел 5. Чтобы удалить узел 8, сохранив порядок элементов дерева, замените узел 8
узлом 7, а узел 7 - узлом 5.

Рис. 6.19. Удаление узла, если замещающий его узел имеет дочерние
Деревья
Обратите внимание, что узел 7 получает абсолютно новые дочерние записи,
а узел 5 остается с одним дочерним узлом.
При помощи следующего кода удаляется узел из сортированного двоичного
дерева:
// Удаление элемента ниже выделенного узла.
procedure TSortTree.RemoveNode(var node : TSortNode;
target_value : Integer);
var
target : TSortNode;
begin
// Если элемент не найден, вывести сообщение.
if (node = nil) then
begin
ShowMessage(Format('Элемент %d не является узлом дерева.'
,[target^value]));
exp-
end;
if (target_value<node.Id) then
.// Продолжаем с левым нижним поддеревом.
RemoveNode(node.LeftChild,target_value)
else if (target_value>node.Id) then
// Продолжаем с правым нижним поддеревом.
RemoveNode(node.RightChiId,target_value)
else begin
// Это искомый элемент.
if (node.LeftChild = nil) then
begin
// Если у элемента нет левого дочернего узла,
// перемещаем его с правым дочерним.
target := node;
node := node.RightChiId;
target.RightChild := nil;
target.Free;
end else if (node.RightChild = nil) then
begin
// Если у элемента нет правого дочернего узла,
// перемещаем его с левым дочерним.
target := node;
node := node.LeftChild;
target.LeftChild := nil;
target.Free;
end else begin
// Если элемент имеет два дочерних узла,
// используем ReplaceRightmost для замены элемента
// его крайним правым потомком слева.
ReplaceRightmost(node,node.LeftChild);
end; // Конец замены элемента.
end; // Конец if левый ... else if правый ... else ...
end;
Упорядоченные деревья
// Заменяем элемент его крайним правым потомком слева.
procedure TSortTree.ReplaceRightmost(var target, repL : TSortNode) ,v
var
old_repl, tmp : TSortNode;
begin
if (repl.RightChildonil) Then
begin
// Сдвигаем родительский узел вниз вправо.
ReplaceRightmost(target,repl.RightChild);
end else begin
// Достигли конца дерева. Запоминаем узел repl.
old_repl : = repl ;
// Заменяем repl левым дочерним узлом.
repl := repl.LeftChild;
// Заменяем нужный элемент repl.
old_repl.LeftChild := target.LeftChild;
old_repl.RightChild := target.RightChild;
tmp := target;
target := old_repl;
tmp.LeftChild := nil;
tmp.RightChild := nil;
tmp.Free;
end;
end;
В этом алгоритме дважды применяется способ передачи параметров в рекур-
сивные процедуры по ссылке. Сначала процедура RemoveNode использует этот
способ, чтобы родительский узел удаляемого элемента указывал на заменяющий
узел. Следующая команда показывает, как вызывается процедура RemoveNode:
RemoveNode(node.LeftChild,target_value)
Когда процедура находит искомый узел (узел 8 на рис. 6.19), она получает в ка-
честве параметра узла node указатель родителя на искомый узел. Установив этот
параметр для замещающего узла (узел 7), процедура Deleteltem задает дочерний
узел для родителя так, чтобы он указывал на новый узел.
Следующая команда показывает, как процедура ReplaceRightmost рекур-
сивно вызывает себя:
ReplaceRightmost(target,repl.RightChild);

Когда эта процедура находит самый правый узел в левой от удаляемого узла
ветви (7-й), параметр repl сохраняет указатель родительского узла на крайний
правый узел. Когда процедура устанавливает значение repl в repl. LeftChild,
она автоматически соединяет родителя крайнего правого узла с левым потомком
крайнего правого узла (узел 5).
Программа TrSort использует эти процедуры для управления сортированны-
ми двоичными деревьями. Введите целое число и нажмите кнопку Add, чтобы доба-
вить элемент к дереву. Введите целое число и щелкните по кнопке Remove, чтобы
Деревья
удалить элемент. После этого дерево автоматически перестраивается, чтобы со-
хранить порядок «меньше чем».

Обход упорядоченных деревьев


При симметричном обходе упорядоченных деревьев узлы посещаются в по-
рядке сортировки, что очень полезно. Например, при симметричном обходе дере-
ва, изображенного на рис. 6.20, узлы посещаются в порядке 2, 4, 5, 6, 7, 8, 9.
Это свойство симметричного обхода упорядоченных деревьев приводит к про-
стому алгоритму сортировки:
1. В сортированное дерево добавляется элемент.
2. Элементы выводятся с помощью симметричного обхода.
Этот алгоритм работает достаточно хорошо. Но если вы добавляете элементы
в каком-либо определенном порядке, то дерево может стать длинным и тонким.
На рис. 6.21 изображено дерево, которое может получиться при добавлении элемен-
тов в порядке 1,6,5,2,3,4. Другие последовательности тоже могут привести к появ-
лению тонких и длинных деревьев.

Рис. 6.20. Симметричный Рис. 6.21. Дерево, образованное


обход сортированного при добавлении элементов
дерева: 2, 4, 5, 6, 7,8,9 в порядке 1, 6, 5, 2, 3, 4

Чем длиннее становится дерево, тем больше понадобится времени, чтобы до-
бавить элемент в его конец. В худшем случае после того, как вы добавите N эле-
ментов, дерево будет иметь высоту O(N). Общее время, необходимое для разме-
щения всех элементов в дереве, будет равно O(N2).
Поскольку для обхода дерева требуется время O(N), полное время, необходи-
мое для сортировки чисел с помощью дерева, будет порядка O(N2) + O(N) = O(N2).
Если дерево достаточно короткое, его высота будет порядка O(log N). В таком
случае для добавления элемента требуется всего O(N) шагов.» На вставку всех N
элементов необходимо O(N * log N) шагов. И для сортировки элементов с помо-
щью дерева потребуется O(N * log N) + O(N) = O(N * log N) шагов.
Деревья со ссылками
,:-- ,. ---'•^-:-™-:--,™™~>,.™^^^
)1|ЩПМКЕЕ]
{
• •^
• ••••^
^•••МИВ

Это гораздо меньше, чем О(№). Например, для построения высокого, тонкого
дерева, содержащего 1000 элементов, потребовалось бы около миллиона шагов.
Формирование короткого дерева высоты O(log N) займет всего порядка 10 000
шагов.
Если элементы дерева изначально расположены в случайном порядке, форма
дерева будет чем-то средним между этими двумя крайними случаями. Поскольку
его высота может оказаться несколько больше, чем log N, оно не будет слишком
высоким и тонким, поэтому алгоритм сортировки выполнится достаточно быстро.
В главе 7 описываются способы такой балансировки деревьев, чтобы они не ста-
новились высокими и тонкими независимо от того, в каком порядке добавляются
элементы. Однако эти методы достаточно сложны, и не стоит применять их к алго-
ритму сортировки на основе деревьев. Многие алгоритмы сортировки, описанные
в главе 9, более просты в реализации и обеспечивают при этом лучшую производи-
тельность.

Деревья со ссылками
В главе 2 объясняется, как добавление ссылок к связанным спискам позволяет
упростить вывод элементов в различном порядке. Вы можете использовать тот же
прием, чтобы облегчить обращение к узлам дерева в произвольном порядке. На-
пример, если поместить ссылки в листья двоичного дерева, то выполнение сим-
метричного и обратного обходов упростится. Если дерево упорядоченное, то это
обход в прямом и обратном порядке сортировки.
При создании ссылок указатели на предшественников узла (симметричный
порядок) и потомков должны помещаться в неиспользованных указателях дочер-
них узлов. Если узел имеет неиспользованный левый указатель на потомка, сохра-
ните ссылку в позиции, указывающей на предшественника узла при симметрич-
ном обходе. Если узел имеет неиспользованный правый указатель на потомка,
сохраните ссылку в позиции, указывающей на дочерний узел при симметричном
обходе. Поскольку ссылки симметричны и ссылки левых потомков указывают на
предыдущих правых, а правых - на следующие узлы, этот тип деревьев называет-
ся деревом с симметричными ссылками (symmetrically threaded tree). На рис. 6.22
показано подобное дерево (потоки выделены пунктирными линиями).

Рис. 6.22. Дерево с симметричными ссылками


Деревья
Поскольку ссылки занимают позиции указателей на дочерние узлы, необхо-
димо найти какое-то различие между ссылками и обычными указателями на до-
черние узлы. Проще всего это сделать, добавив в узлы новые поля, такие как Bo-
olean - HasLef tChi Id и HasRightChild, указывающие, есть ли у узла правые
и левые потомки.
Чтобы использовать ссылки для нахождения предшественника узла, необхо-
димо проверить левый указатель на дочерний узел. Если указатель - ссылка, то он
указывает на предшественника узла. Если указатель имеет значение nil, то этот
узел является первым в дереве, поэтому не имеет предшественника. В обратном
случае двигайтесь по направлению этого левого указателя. Затем следуйте за пра-
вым указателем потомка, пока не достигнете узла, в котором вместо правого по-
томка имеется ссылка. Этот узел (а не тот, на который указывает ссылка) - пред-
шественник первоначального узла. Он, в свою очередь, является самым правым
в левой от исходного узла ветви дерева. Следующий код показывает, как можно
найти предшественника узла в Delphi.
• function Predecessor(node : PThreadedNode) : PThreadedNode;
var
child : PThreadedNode;
begin
A
if (node .LeftChild=nil) then
// Это первый узел при симметричном обходе.
Predecessor := nil
else if (node A .HasLeftChild) then begin
// Это указатель на узел.
// Нахождение крайнего правого узла слева.
child := node^.LeftChild;
while (child' 4 .HasRightChild) do
child := child^.RightChild;
Predecessor := child;
end else
// Ссылка указывает на предшественника.
Predecessor := node'4 .LeftChild;
end;
Аналогично выполняется поиск следующего узла. Если правый указатель на
дочерний узел - ссылка, то он указывает на потомка узла. Если указатель имеет
значение nil, этот узел - последний в дереве, поэтому он не имеет потомка. В об-
ратном случае следуйте за указателем на правый дочерний узел. Затем двигайтесь
за указателями на левый дочерний узел до тех пор, пока не достигнете узла со
ссылкой для указатель левого дочернего узла. Тогда найденный узел окажется сле-
дующим за исходным. Это будет самый левый узел в правой от исходного узла
ветви дерева.
Удобно также ввести функции, определяющие положение первого и последне-
го узлов дерева. Чтобы найти первый узел, просто следуйте за левыми указателя-
ми на дочерние узлы вниз от корня, пока не достигнете узла с нулевым указате-
лем. Чтобы найти последний узел, следуйте за правыми указателями на дочерние
узлы вниз от корня, пока не достигнете узла с нулевым указателем.
Деревья со ссылками ШННННЕШ
function FirstNode : PThreadedNode;
begin
Result := Root;
While (Result".LeftChildonil) do
Result := Result".LeftChild;
end;
function LastNode : PThreadedNode;
begin
Result := Root;
While (Result".Right Childonil) do
Result:=Result".RightChild;
end;
:. . ' • ' '. • '
Используя эти функции, можно легко записать процедуры, которые отобра-
жают узлы дерева в прямом и обратном порядках.
procedure Inorder;
var
. node : PThreadedNode;
begin
// Нахождение первого узла.
node := FirstNode;
// Обход списка.
while (nodeonil) do
begin
VisitNode(nodeA.Value);
node := Successor(node) ,•
end;
end;
procedure Reverselnorder;
var
node : PThreadedNode;
begin
// Нахождение последнего узла.
node := LastNode;
// Обход списка.
while (nodeonil) do
begin Ч
VisitNode(Node".Value);
node := Predecessor(node);
end; ч
end;
<'.',.''••'-
Процедура вывода узлов в порядке симметричного обхода, описанная ранее,
использует рекурсию. Устранить рекурсию вы можете с помощью этих новых про-
цедур, которые не используют ни рекурсию, ни системный стек.
Каждый указатель на потомка в дереве содержит либо связь с дочерним узлом,
либо поток для предшественника или потомка. Так как каждый узел имеет два ука-
зателя на дочерние узлы, то если в дереве всего N узлов, оно будет содержать 2 * N
Деревья
связей и потоков. Приведенные алгоритмы обхода посещают каждую связь и поток
в дереве один раз, поэтому для их выполнения требуется О(2 * N) = O(N) шагов.
Эти процедуры можно немного ускорить, если отследить индексы первого и пос-
леднего узлов дерева. Тогда не нужно будет искать первый или последний узел пе-
ред выводом узлов по порядку. Поскольку эти алгоритмы обращаются ко всем N
узлам в дереве, время выполнения для алгоритмов также будет порядка O(N), но
на практике они работают немного быстрее.

Особенности работы
Для обработки дерева с потоками необходимо иметь возможность добавлять
и удалять узлы дерева, сохраняя при этом верные связи.
Предположим, вы хотите добавить нового левого потомка узла А. Так как это
место не занято, то на месте указателя на левого потомка узла А находится ссылка,
которая указывает на предшественника узла А. Поскольку новый узел будет ле-
вым потомком узла А, он станет предшественником узла А. Узел А станет новым
потомком нового узла. Узел, который был предшественником узла А, теперь ста-
новится предшественником нового узла. На рис. 6.23 показано дерево с рис. 6.22
после добавления нового узла X в качестве левого потомка узла Н.
ЙН ." :. . И

Рис. 6.23. Дерево с потоками после добавления узла X

Если отслеживать индекс первого и последнего узлов дерева, то потребуется


проверить, не является ли новый узел первым узлом дерева. Если поток предше-
ственника нового узла имеет значение nil, то это новый первый узел дерева.
Учитывая все вышеизложенное, можно легко написать процедуру для добав-
ления нового левого потомка узла. Вставка правого потомка выполняется таким
же образом.
procedure AddLeftChild(parent, child PThreadedNode);
begin
// Предшественник родительского узла
// становится предшественником нового узла.
..child" 4 .LeftChild := parent A .LeftChiId;
child",HasLeftChild := False;

Q-деревья
// Вставка узла.
parent*.LeftChild := child;
parenf.HasLeftChild := True;
// Родительский узел является потомком нового узла.
child".RightChild := parent;
child*.HasRightChild := False;
// He является ли новый узел первым узлом дерева?
4 •
if (child' .LeftChild = nil) then
FirstNode := child;
end;
Прежде чем удалить узел из связанного дерева, вы должны удалить его по-
томков. Если узел не имеет дочерних, ликвидировать его очень легко.
Предположим, что удаляемый узел - левый дочерний узел. Его левый указа-
тель - ссылка на его предшественника. Когда данный узел удален, этот предшествен-
ник становится предшественником родительского узла. Чтобы удалить узел, просто
замените левый указатель на дочерний узел указателем потомка удаленного узла.
Указатель на правого потомка удаляемого узла - ссылка, указывающая на сле-
дующий узел в дереве. Поскольку этот узел - левый потомок родительского узла
и у него нет дочерних, эта ссылка указывает на родительский узел, следовательно,
ее можно просто опустить. На рис. 6.24 показано дерево с рис. 6.23 после удаления
узла F. Способ удаления правого наследника аналогичен.

Рис. 6.24. Дерево с потоками после удаления узла F

procedure RemoveLeftChild(parent : PThreadedNode);


var
target : PThreadedNode;
begin
target := parent*.LeftChild;
parent 7 4 .LeftChild := target .LeftChild;
end;

Q-деревья
Q-depeeo (quadtree) описывает пространственные отношения между элемен-
тами в пределах какой-либо ограниченной площади. Например, область может
Деревья

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


на ней.
Каждый узел в Q-дереве является частью общей области, представленной дан-
ным деревом. Каждый узел, который не является листом, имеет четыре дочерних,
узла которые соответствуют северо-западному, северо-восточному, юго-восточно-
му и юго-западному квадранту области узла. Лист может хранить элементы в свя-
занном списке. Следующий код показывает ключевые части объявления класса
TQtreeNode.
type
NorthOrSouth = (North,South);
EastOrWest = (East,West);
TPointArray = array [1..MAX_QTREE_NODES] of TPoint;
PPointArray = ATPointArray;
TQtreeNode = class(TObject)
private
public
Children : array [NorthOrSouth, EastOrWest] of TQtreeNode;
Xmin, Xmax ,Ymin ,Ymax : Integer;
Pts : PPointArray; // Элементы ^
// данных.
NumPts : Integer;

end;
Чтобы построить Q-дерево, разместите все элементы в корневом узле. Затем
определите, содержит ли данный узел достаточно элементов, которые можно еще
поделить на несколько других. Если это так, создайте четыре дочерних записи для
этого узла. Распределите элементы среди этих четырех потомков в соответствии
с позициями элементов в пределах четырех квадрантов исходной области. Потом
рекурсивно проверьте эти четыре дочерних записи, чтобы отследить, можно ли их
еще разделить. Продолжайте разбивать узлы до тех пор, пока каждый лист не бу-
дет содержать не более определенного числа элементов.
На рис. 6.25 показано несколько элементов данных, размещенных в Q-дереве.
Каждая область разбивается до тех пор, пока не будет содержать не более двух
элементов.

Рис. 6.25. Q-дерево


Q-деревья
Q-деревья используются для поиска близлежащих объектов. Предположим,
имеется программа, которая рисует карту со многими участками. Когда пользова-
тель щелкает мышью по карте, программа должна найти ближайший к выбранной
точке населенный пункт. Система может перебрать весь список участков, прове-
ряя для каждого расстояние от заданной точки. Если имеется N участков, то это
алгоритм сложности O(N).
При помощи Q-дерева эту операцию можно выполнить намного быстрее. Нач-
ните с корневого узла. При каждой проверке Q-дерева определяйте, какой из квад-
рантов узла содержит точку, где пользователь щелкнул мышью. Затем двигайтесь
вниз к соответствующему дочернему узлу. Если бы пользователь щелкнул в пра-
вом верхнем углу области узла, вы бы перешли к северо-восточному дочернему
узлу. Двигайтесь вниз, пока не достигнете листа, где находится точка, которую
выбрал пользователь.
Функция LocateLeaf класса TQtreeNode использует этот метод для обнару-
жения листа, который содержит данную точку. Программа вызывает эту функцию
в строке t h e _ l e a f : = R o o t . L o c a t e L e a f ( X , Y ) :
// Какой лист содержит точку.
function TQtreeNode.LocateLeaf(X, У : Integer) : TQtreeNode;
var
xmid, ymid : Integer;
ns : NorthOrSouth;
ew : EastOrWest;
begin
if (Children[North,West] = nil) then
begin
// У данного узла нет дочерних. Он должен быть листом.
Result := Self;
exit;
end;
// Нахождение соответствующего дочернего узла.
xmid := (Xmax+Xmin) div 2;
ymid := (Ymax+Ymin) div 2;
if (Y<=ymid) then
ns := North
else
ns := South;
if (X<=xmid) then
ew := West
else
ew := East;
Result := Children[ns,ew].LocateLeaf(X,Y) ;
end;
Когда будет найден лист, содержащий искомую точку, рассмотрите участки в пре-
делах этого листа, чтобы найти самый близкий элемент. Это делается при помощи
процедуры NearPointlnLeaf:
// Возвращает индекс ближайшей к заданным координатам точки
// в данном узле.
procedure TQtreeNode.NearPointlnLeaf(X, Y: Integer;
var best_i, best_dist2 : Integer; var comp : Longint);
var
I : Longint;
dist2, dx, dy : Integer;
begin
best_dist2 := 32767;
best_I := 0;
for I := 1 to NumPts do
begin
dx := X-PtsA[i].X;
dy := Y-PtSA[i].Y;
dist2 := dx*dx+dy*dy;
if (best_dist2>dist2) then
begin
best_I := i;
best_dist2 := dist2;
end;
end;
comp := comp+NumPts;
end;
Элемент, найденный функцией NearPointlnLeaf, обычно является именно
тем элементом, который пытался выбрать пользователь. Но если точка располо-
жена близко к границе между двумя листами, то ближайшим к выбранной точке
пунктом может оказаться точка другого узла.
Предположим, что Droin - это расстояние от выбранной точки до самого близ-
кого участка, найденного на данный момент. Если Dmin меньше, чем расстояние от
точки до края листа, то искомый элемент найден. Населенный пункт находится
при этом слишком далеко от края листа, чтобы в каком-либо другом листе мог су-
ществовать пункт, расположенный ближе к заданной точке.
В противном случае вернитесь к корневому узлу и двигайтесь по дереву, изу-
чая все узлы, которые находятся в пределах расстояния Dmin от выбранной точки.
Если вы нашли несколько ближайших к точке элементов, повторите операцию для
меньшего значения Dmin. Когда закончится проверка ближайших к точке листьев,
нужный элемент будет найден. Процедура CheckNearbyLeaves использует этот
метод для завершения поиска.
// Исследование ближайших узлов. Нет ли среди них лучшей точки?
procedure TQtreeNode.CheckNearbyLeaves(exclude : TQtreeNode;
var best_leaf : TQtreeNode; X, Y : Integer;
var best_i, best_dist2 : Integer; var comp : Longint);
var
xmid, ymid, i, dist2, best_dist : Integer;
begin
jQ-деревья |
// Если лист исключается, то ничего не происходит.
if (exclude=Self) then exit;
\~ ' • . ' • • • ' .
// Если это лист, то рассматриваются ближайшие узлы.
if (Children[North,West] = nil) then
begin
NearPointlnLeaf(X,Y,i,dist2,comp);
if (best_dist2>dist2) then
begin
best_dlst2 := dist2;
best_leaf := Self;
best_i := i;
end;
end else begin
// Рассматриваются дочерние узлы, которые лежат в пределах
// best_dist искомой точки.
xmid := (Xmax+Xmin) div 2;
ymid := (Ymax+Ymin) div 2;
best_dist :=Round(Sqrt(best_dist2)+0.5);
if (X-best_dist<=xmid) then
begin
// Западный дочерний узел может быть достаточно близок.
// Достаточно ли близок северо-западный дочерний узел?
if (Y-best_dist<=ymid) Then
Children[North,West].CheckNearbyLeaves(
exclude, best_leaf, X, Y, best_i, best_dis,t2, comp) ;
// Достаточно ли близок юго-западный дочерний узел?
if (Y+best_dist>ymid) Then ,; • •.
Children[South,West].CheckNearbyLeaves( .,
exclude,best_leaf,X,Y,best_i,best_di&t2.,! comp) ;
end; // Конец проверки западного дочернего узла.
• -:- ' . .1 • ' : . • ' • . . . " " ' ,<• : •'• SI О

if. (X+bestf_dist>xmid) then


begin
// Восточный дочерний узел может быть достаточно близок.
// Достаточно ли близок северо-восточный дочерний узел?
if (Y-best_dist<=ymid) Then
Children[North,East].CheckNearbyLeaves(
exclude,best_leaf,X,Y,best_i,best_dist2,comp);
// Достаточно ли близок юго-восточный дочерний узел?
if (Y+best_dist>ymid) Then
ChiIdren[South,East].CheckNearbyLeaves(
exclude,best_leaf,X,Y,best_i,best_dist2,comp);
end; // Конец проверки восточного дочернего узла.
end; // Конец if лист ... else проверка дочерних'. ..
end;
Процедура FindPoint использует процедуры LocateLeaf, NearPointln-
Leaf и CheckNearbyLeaves из класса QtreeNode, чтобы быстро определить
положение точки в Q-дереве.
Деревья
// Нахождение ближайшей точки к точке с заданными координатами.
procedure TQtreeNode.FindPo.int(var X, Y : Integer;
var comp : Longint) ;
var
best_dist2, best_i : Integer;
leaf : TQtreeNode;
begin
// Какой лист содержит точку.
leaf := LocateLeaf(X,Y) ;
• , • / . • . ' . . .
// Нахождение ближайшей точки в пределах листа.
comp := 0;
leaf.NearPointlnLeaf(X,Y,best_i,best_dist2,comp);
// Проверка ближайших листов на наличие ближайшей точки.
CheckNearbyLeaves(leaf,leaf,X,Y,best_i,best_dist2,comp);

X := leaf.PtsA[best_i].X;
Y := leaf.PtsA[best_i].Y;
end;
Программа Qtree использует Q-дерево. При старте она запрашивает число эле-
ментов данных, которое ей необходимо создать. Затем создает элементы, отобра-
жая их на экране в виде точек. Начинайте с небольшого числа элементов (около
1000), пока не определите, насколько быстро ваш компьютер может сформировать
элементы.
Q-деревья представляют наибольший интерес для наблюдения, когда элемен-
ты распределены неравномерно и программа выбирает точки с помощью странно-
го аттрактора (strange attractor) из теории хаоса (chaos theory). Она выбирает
точки данных способом, который кажется случайным, но все же содержит набор
интересных значений.
При выборе при помощи мыши какой-либо точки на форме программа Qtree
определяет положение самого ближнего элемента к ячейке, по которой вы щелк-
нули. Она подсвечивает этот пункт и выводит число проверенных при его поиске
элементов. •
В меню Options (Опции) программы можно задать, должна ли она использо-
вать Q-дерево. Если вы отмечаете опцию Use Quadtree (Использовать Q-дерево),
программа отображает Q-дерево и с его помощью ищет элементы. В обратном слу-
чае программа не отображает дерево и определяет положение элемента путем пе-
ребора.
При наличии Q-дерева программа исследует гораздо меньшее количество эле-
ментов и работает намного быстрее. Если быстродействие вашего компьютера на-
столько велико, что вы не можете отследить этот эффект, запустите программу
с 100 000 элементов. Даже на компьютере, где установлен процессор Pentium с так-
товой частотой 90 МГц, вы заметите разницу.
На рис. 6.26 показано окно программы Qtree с отображением 100 000 эле-
ментов. Маленький белый прямоугольник отображает выбранный элемент. Метка
Q-деревья
в левом верхнем углу указывает, что про-
грамма исследовала только 25 элемен-
тов из 100 000, прежде чем нашла вы-
бранный.

Изменение значения
MAX_QTREE_NODES
С программой Qtree можно провести
интересный эксперимент, изменяя значе-
ние параметра MAX_QTREE_NODES, опре-
деленного в разделе класса QtreeNode.
Это максимальное число элементов, ко-
торые будут размещаться в пределах узла
дерева без его разбиения. Программа из-
начально использует значение парамет-
ра равное 100. Если вы уменьшите это
число, скажем, до 10, то в каждом узле
количество элементов сократится, поэто-
Рис
му программа будет исследовать мень- - 6-26- Окно программы Qtree
шее количество элементов, чтобы определить положение самого близкого элемен-
та к выбранной ячейке. Поиск будет выполняться быстрее. С другой стороны,
программа будет формировать гораздо больше узлов дерева, следовательно, воз-
растет объем используемой памяти.
И наоборот, если вы увеличиваете MAX_QTREE_NODES до 1000, программа со-
здаст меньше узлов. Она будет работать немного дольше, чтобы найти искомый
элемент, но дерево будет не таким разветвленным и займет меньше памяти.
Это образец компромисса между временем и памятью. Использование боль,-
шего количества узлов дерева делает поиск элементов быстрее, но занимает боль-
шие объемы памяти. В этом примере при значении MAX_QTREE_NODES, равном
100, достигается достаточно разумное соотношение скорости работы и использо-
вания памяти. Поэкспериментируйте со значением MAX_QTREE_NODES, чтобы
найти правильное соотношение для других приложений. *

Восьмеричные деревья
Восьмеричные деревья (octtree) похожи на Q-деревья за исключением того, что
делится трехмерное пространство, а не двумерная область. Узлы Q-дерева содер-
жат по четыре дочерних записи, а узлы восьмеричного дерева - по восемь, разде-
ляя объем области соответственно на восемь частей - верхнюю северо-западную,
нижнюю северо-западную, верхнюю северо-восточную, нижнюю северо-восточ-
ную и т.д.
Восьмеричные деревья используются для управления объектами в трех из-
мерениях. Робот, например, способен с помощью восьмеричного дерева отслежи-
вать близлежащие объекты. Программа трассировки лучей может использовать
восьмеричное дерево, чтобы быстро определить, проходит ли луч около объекта,
перед тем как начнет медленный процесс вычисления точного пересечения двух
лучей.
Вы можете построить восьмеричное дерево с помощью тех же методов, что
и Q-деревья.

Резюме
Существует много способов представления деревьев. Полные деревья, сохра-
ненные в массивах, используют наиболее эффективное и компактное представ-
ление. Представление дерева в виде коллекций дочерних узлов упрощает работу
с ними, но при этом программа выполняется медленнее и требует большего объе-
ма памяти. Формат нумерации связей позволяет быстро выполнять обход дерева
и расходует меньше памяти, чем коллекции потомков, но в таком случае алгоритм
сложно модифицировать. Проанализировав все типы операций с деревьями, вы
можете выбрать представление, которое позволить достичь лучшего компромис-
са между гибкостью и простотой использования.
• , . . . . . . . . В<|9Ш ;GH
:
.. •:• i . • i ' . ... . • • V.-jCpli
.
• : ! . ' . | ' - • • . . ' . • • • • . ' • . ' . • : • > •

• -'i'.-9'П
Глава 7. Сбалансированные
деревья
После выполнения ряда операций с упорядоченным деревом, вставки и удаления
узлов, оно может стать несбалансированным. Если подобное происходит, алгорит-
мы обработки дерева становятся менее эффективными. При сильной степени раз-
балансировки дерево фактически представляет собой всего лишь сложную форму
связанного списка, а у программы, использующей дерево, может резко снизиться
производительность.
В этой главе рассматриваются методы сохранения баланса дерева, даже при
постоянной вставке и удалении элементов. Балансировка обеспечивает эффектив-
ную работу алгоритма.
Глава начинается с определения разбалансированных деревьев, показывается,
как они снижают производительность программы. Затем рассматриваются AVL-
деревья. В AVL-дереве высота левого и правого поддеревьев в любом узле всегда
отличается максимум на единицу. Сохраняя это свойство, вы можете без особого
труда поддерживать баланс дерева.
Затем описываются Б-деревья и Б+деревья, в которых все листья имеют одина-
ковую глубину. Такие деревья самостоятельно поддерживают баланс, сохраняя чис-
ло ветвей в каждом узле в определенных пределах. Б-деревья и Б+деревья обычно
используются при программировании баз данных. Последняя программа в этой гла-
ве при помощи Б+дерева реализует простую, но достаточно мощную базу данных.

Балансировка
Как уже говорилось в главе 6, форма упорядоченного дерева зависит от поряд-
ка добавления элементов. На рис. 7.1 показаны два различных дерева, построенных
из одинаковых элементов в разном порядке.
Высокие, тонкие деревья, такие как дере-
во, изображенное слева, могут иметь глубину
до O(N). Добавление или размещение элемен-
та в таком разбалансированном дереве может
занимать O(N) шагов. Даже если новые эле-
менты размещаются беспорядочно, в среднем
они дадут дерево с глубиной N/2, обработка
которого потребует так же порядка O(N) опе-
Порядок: 1 9 4 6 7 Порядок: 6 4 1 9 7
раций.
Предположим, что вы строите упорядочен- Рис. 7J. Деревья, построенные
ное двоичное дерево, содержащее 1000 узлов. в различном порядке
Сбалансированные деревья

Если дерево сбалансировано, высота дерева будет порядка Iog2(1000), или при-
близительно 10. Добавление нового элемента к дереву будет занимать 10 шагов.
Если дерево высокое и тонкое, его высота равна 1000. В этом случае для вставки
нового элемента потребуется 1000 шагов.
Теперь предположим, что вы хотите добавить еще 1000 узлов. Если дерево ос-
тается сбалансированным, все 1000 узлов будут размещены на нескольких уров-
нях дерева. При этом для добавления новых элементов потребуется приблизитель-
но 10 * 1000 = 10 000 шагов. Если дерево было не сбалансировано и остается таким
в процессе роста, то при вставке каждого нового элемента оно будет становиться
все выше. Добавление элементов займет приблизительно 1000 + 1001 + ... + 2000 =
= 1,5 млн шагов.
Хотя неизвестно, в каком порядке элементы будут добавляться и удаляться из
дерева, в любом случае можно использовать методы, которые поддержат его сба-
лансированным.
• . . • ' • . . ' ' •
AVL-деревья
AVL-деревья (AVL-tree) были названы по имени российских математиков Адель-
сона-Вельского (Adelson-Velskii) и Ландау (Landis), которые их изобрели. В каждом
узле AVL-дерева глубина левого и правого поддеревьев отличаются не более чем на
единицу На рис. 7.2 изображено несколько AVL-деревьев.
Несмотря на то что AVL-дерево может быть несколько выше, чем полное дерево,
содержащее то же самое количество узлов, оно имеет такую же глубину O(logN).
Следовательно, поиск узла в AVL-дереве занимает время порядка O(log(N)), то есть
достаточно мало. Не так очевидно, что можно добавлять или удалять элементы из
AVL-дерева за время O(logN) при сохранении баланса дерева.

Рис. 7.2. AVL-деревья

Добавление узлов к AVL-дереву


Каждый раз при добавлении узла к AVL-дереву вы должны проверять, соблю-
даются ли условия, описывающие AVL-дерево. После вставки узла вы можете ис-
следовать узлы в обратном порядке - к корню, проверяя, чтобы глубина поддере-
вьев отличалась не более чем на единицу. Если вы находите ячейку, где это условие
AVL-деревья ШЩМКШ
не выполняется, вы можете сдвинуть элементы по кругу, чтобы сохранить выпол-
няемость условия AVL-дерева.
Процедура добавления нового узла рекурсивно спускается вниз по дереву в по-
исках места для размещения элемента. После добавления элемента рекурсивные
обращения к процедуре заканчиваются и дерево исследуется в обратном порядке.
После окончания каждого вызова процедура проверяет свойство AVL на самом
высоком уровне. Эта разновидность обратной рекурсии, при которой процедура
выполняет важное действие вне цепочки рекурсивных обращений, называется
восходящей рекурсией (bottom-up recursion).
При обратном проходе вверх по дереву процедура также проверяет, не изме-
нилась ли глубина исследуемого поддерева. Если процедура достигает точки, где
глубина поддерева не изменилась, то глубина любого поддерева на более высоких
уровнях также не могла измениться. В этом случае дерево необходимо еще раз сба-
лансировать таким образом, чтобы процедура могла прекратить проверку.
Например, дерево на рис. 7.3 слева — это правильно сбалансированное AVL-
дерево. При добавлении нового элемента Е получится дерево, изображенное в се-
редине. Затем выполняется проход вверх по дереву от нового узла Е. Дерево в узле
Е сбалансировано, потому что два поддерева здесь пусты и имеют одинаковую глу-
бину 0.
Дерево в узле D тоже сбалансировано. Левое поддерево в узле D пустое, поэто-
му глубина его равна 0. Правое поддерево содержит один узел Е, поэтому его глу-
бина равна 1. Глубина этих поддеревьев отличается на 1, поэтому дерево в узле D
сбалансировано.
В узле С дерево не сбалансировано. Левое поддерево в узле С имеет глубину О,
в то время как глубина правого поддерева равна 2. Вы можете сбалансировать эти,
поддеревья, как показано на рис. 7.3 справа, при этом узел С заменяется узлом D.

Рис. 7.3. Добавление узла в AVL-дерево

Поддерево с корнем в узле D теперь содержит узлы С, D и Е и имеет глубину,


равную 2. Обратите внимание, что глубина исходного поддерева, расположенного
в этой позиции с корнем в узле С, была равна 2 еще до того, как был добавлен но-
вый узел. Поскольку глубину поддерева не изменилась, дерево сбалансировано во
всех узлах выше узла D.
t .
Вращение A У/, -деревьев
При вставке узла в AVL-дерево в зависимости от того, в какую часть дерева до-
бавляется узел, существует четыре варианта балансировки. Методы перебаланси-
рования называются правым и левым вращением, вращением влево-вправо и впра-
во-влево. Сокращено они обозначаются R, L, LR и RL.
|< Сбалансированные деревья

Предположим, что вы добавляете новый узел к AVL-


дереву и оно теперь разбалансировано в узле X, как по-
казано на рис. 7.4. На рисунке изображены только узел X
и два его дочерних узла, остальные части дерева обозначе-
ны треугольниками, так как нет необходимости их рассмат-
ривать.
Новый узел может быть вставлен в любое из этих че-
тырех поддеревьев, изображенных в виде треугольников
ниже узла X. Когда вы помещаете новый узел в один из
RL
этих треугольников, необходимо использовать соответ*
Рис. 7.4. Анализ ствующий сдвиг для перебалансирования дерева. Но если
разбалансированного вставка нового узла не нарушает упорядоченность дере-
AVL-дерева ва, балансировка не нужна.
Правое вращение
Сначала предположим, что новый узел добавляется к поддереву R на рис. 7.4.
В этом случае два правых поддерева узла X изменяться не будут, поэтому их мож-
но сгруппировать в один треугольник, как показано на рис. 7.5. Новый узел был
добавлен к дереву Т,, при этом поддерево ТА с корнем в узле А становится по край-
ней мере на два уровня длиннее, чем поддерево Т3.
Так как дерево перед добавлением узла было AVL-деревом, ТА должно быть мак-
симум на один уровень длиннее поддерева Т3. Вы добавили всего один узел к дереву,
поэтому ТА должно быть ровно на два уровня выше, чем поддерево Т3.
Также известно, что поддерево Т, не более чем на один уровень выше поддере-
ва Т2. В противном случае узел X не был бы самым низким узлом в дереве с разба-
лансированными поддеревьями. Если бы поддерево Т, было на два уровня выше
Т2, дерево было бы разбалансировано в узле А.
Вы можете поменять узлы местами с помощью правого вращения (right rotation),
как показано на рис. 7.6. Это вращение называется правым, поскольку узлы А и X
как бы сдвинуты на одну позицию вправо.

Узел вставляется здесь

Рис. 7.5. Добавление


нового узла в поддерево R Рис. 7.6. Правое вращение
__ AVL-деревья l\\
Обратите внимание, что это вращение сохраняет порядок расположения эле-
ментов дерева «меньше чем».'Симметричный обход любого из этих деревьев про-
исходит таким образом: Тг А, Т2, X, Т3.
Поскольку обход обоих деревьев происходит одинаково, то и порядок распо-
ложения элементов в них будет идентичным.
Также необходимо обратить внимание, что глубина поддерева, с которым вы
работаете, осталась той же самой. Перед добавлением нового узла глубина подде-
рева была равна глубине поддерева Т2 плюс 2. После добавления узла и примене-
ния правого вращения глубина поддерева не увеличивается. Любая часть дерева,
лежащая выше узла X, при этом остается сбалансированной, поэтому дальнейшая
балансировка не нужна.
Левое вращение
Левое вращение (left rotation) аналогично правому. Левое вращение исполь-
зуется, чтобы перебалансировать дерево, когда новый узел добавляется к подде-
реву L, показанному на рис. 7.4. На рис. 7.7. изображено AVL-дерево до и после
левого вращения.

Узел вставляется здесь

Рис. 7.7. Левое вращение

Вращение влево-вправо
Когда узел добавляется в поддерево LR (см. рис. 7.4), необходимо рассмот-
реть еще один нижележащий уровень. На рис. 7.8 показано дерево, в котором но-
вый узел вставляется в левую часть Т2 поддерева LR. Он с той же вероятностью
может быть добавлен в правое поддерево Т3. В любом случае поддеревья ТА и Тс
удовлетворяют свойству AVL, а поддерево Тх - нет.
Так как дерево до вставки узла являлось AVL-деревом, ТА должно быть макси-
мум на один уровень длиннее, чем Т4. Был добавлен всего один узел, так что ТА
выросло всего на один уровень. Это означает, что ТА должно быть ровно на два
уровня выше Т4.
Также известно, что глубина Т2 максимум на единицу больше, чем глубина Т3.
Иначе Тс будет разбалансировано и узел X не будет самым нижним узлом в дере-
ве с разбалансированными поддеревьями.
ЕШ1 Сбалансированные деревья
Кроме того, поддерево Т, должно достичь той же самой глубины, что и Т3.
Если оно будет короче, ТА будет разбалансированным, а это снова противоречит
предположению, что узел X является самым нижним узлом в дереве с разбалан-
сированными поддеревьями. Если глубина поддерева Т( больше глубины Т3, то Т,
будет иметь глубину на 2 уровня больше, чем глубина Т4. В том случае дерево
было бы разбалансировано еще до добавления нового узла.
Следовательно, основания деревьев расположены в точности так, как показано
на рис. 7.8. Поддерево Т2 достигает самой большой глубины, Т, и Т3 достигают глу-
бины на один уровень выше, а глубина Т4 на единицу превышает глубину Т1 и Т3.
Используя изложенные выше факты, можно перебалансировать дерево, как по-
казано на рис. 7.9. Это вращение называется влево-вправо, потому что узлы А и С
как бы сдвигаются на одну позицию влево, а узлы С и X - на одну позицию вправо.

Узел вставляется здесь

Рис. 7.8. Добавление Рис. 7.9. Вращение влево-вправо


нового узла в поддерево LR

Это вращение так же не изменяет Порядка расположения элементов дерева.


Симметричный обход дерева до и после вращения происходит в порядке Т,, А, Т2,
С, Т3, X, Т4.
Глубина перебалансированного поддерева не изменилась. Перед добавлением
нового узла глубина поддерева была равна глубине поддерева Т4 плюс 2. После
того как дерево перебалансируется, глубина поддерева осталась той же. Это озна-
чает, что остальная часть дерева сбалансирована. Следовательно, нет необходимо-
сти продолжать балансировку дальше.
Вращение вправо-влево
Вращение вправо-влево (right-left rotation) аналогично вращению влево-впра-
во. Оно используется для балансировки дерева после вставки узла в поддерево RL,
изображенное на рис. 7.4. На рис. 7.10 показано AVL-дерево до и после вращения
вправо-влево.
Обобщение материала по вращению
На рис. 7.11 показаны все варианты вращения в AVL-деревьях. Каждое враще-
ние сохраняет порядок симметричного обхода дерева и оставляет глубину дерева
без изменения. После добавления нового элемента и применения соответствую-
щего вращения дерево становится сбалансированным.
AVL-деревья

t
Узел вставляется здесь

Рис. 7.10. Вращение вправо-влево

Добавление узлов в Delphi


Перед рассмотрением способов удаления узлов из AVL-деревьев в этом разде-
ле обсуждаются некоторые детали добавления узлов к AVL-дереву с помощью
Delphi.
' Кроме обычных полей Lef tChild и RightChild класс TAVLNode содержит
также поле Balance, которое указывает, какое поддерево в узле длиннее. Пере-
менная Balance принимает значение Lef tHeavy, если левое поддерево длиннее,
RightHeavy, если правое поддерево длиннее, и Balanced, если оба поддерева
имеют одинаковую глубину.
type
TBalance = (LeftHeavy, Balanced, RightHeavy) ;
TAVLNode = class(TObject)
private
public ,
Id : Integer;
LeftChild, RightChild : TAVLNode;
Balance : TBalance;
Position : TPoint;
// Код опущен...
'•••'. . • '• •
end;'

Процедура AddNode, показанная ниже, рекурсивно обращается к дереву в по-


исках места для нового элемента. Дойдя до нижнего уровня дерева, процедура со-
здает новый узел и добавляет его к дереву.
Затем AddNode использует восходящую рекурсию, чтобы перебалансировать
дерево. Когда заканчивается рекурсивное обращение, процедура перемещается на-
зад по дереву. При каждом возврате она устанавливает параметр grew в значение
True, если поддерево, которое она покидает, стало глубже. Процедура использует
этот параметр, чтобы определить, сбалансировано ли рассматриваемое поддерево.
Если это не так, применяется соответствующее вращение, чтобы перебалансировать
лоддерево.
J; Сбалансированные деревья

Узел вставляется здесь

Рис. 7.11. Различные виды вращения в AVL-деревьях


AVL-деревья
Предположим, процедура в настоящее время исследует узел X. Допустим, что
она только что возвратилась из правого поддерева ниже узла X и параметр grew
установлен в True, указывая на то, что правое поддерево стало глубже. Если подде-
ревья ниже узла X до этого имели одинаковую глубину, то правое поддерево теперь
длиннее левого. Дерево сбалансировано в этой точке, но поддерево с корнем в узле
X также выросло, так как его правое поддерево стало длиннее.
Если левое поддерево ниже узла X было длиннее правого, то сейчас левое и пра-
вое поддеревья имеют одинаковую глубину. Глубина поддерева с корнем в узле X
не изменилась - она также равна глубине левого поддерева плюс единица. В этом
случае процедура AddNode установит переменную grew в значение False, указы-
вая, что дерево сбалансировано.
И наконец, если правое поддерево ниже узла X было до этого длиннее левого,
новый узел разбалансирует дерево в узле X. Процедура AddNode вызывает проце-
дуру RebalanceRightGrew, чтобы перебалансировать дерево. Данная процедура
выполняет левое вращение или вращение вправо-влево, в зависимости от конкрет-
ной ситуации.
Процедура AddNode работает по такому же сценарию, если новый элемент до-
бавляется в левое поддерево. Следующий код показывает выполнение процедур
AddNode и RebalanceRightGrew. Процедура RebalanceLef tGrew аналогич-
на процедуре RebalanceRightGrew.
procedure TAVLTree.AddNode(var parent : TAVLNode; new_id : Integer;
var grew : Boolean);
begin
// Если это основание дерева, то создаем новый узел и заставляем
// родительский_узел указывать на новый.
if (parent = nil) then
begin
parent := TAVLNode.Create;
parent.Id := new_id;
parent.Balance := Balanced;
grew := True;
exp-
end;
// Продолжаем двигаться вниз по соответствующему поддереву.
if (new_id<=parent.Id) then
begin
// Вставка дочернего узла в левое поддерево.
AddNode(parent.LeftChiId,new_id,grew);
// Нужна ли перебалансировка?
if (not grew) then exit;
if (parent.Balance = RightHeavy) then
begin
// Правое поддерево было длиннее. Левое поддерево выросло,
// поэтому дерево сбалансировано.
parent.Balance := Balanced;
Сбалансированные деревья
grew := False;
end else if (parent.Balance = Balanced) then
begin
// Был баланс. Левое поддерево выросло,
// поэтому левое поддерево длиннее. Дерево все еще
// сбалансировано, но оно выросло, поэтому необходимо
// продолжить проверку баланса еще выше.
parent.Balance := LeftHeavy;
end else begin
// Левое поддерево длиннее. Оно выросло, поэтому имеем
// разбалансированное дерево слева. Необходимо выполнить
// соответствующее вращение для перебалансирования.
RebalanceLeftGrew(parent);
grew := False;
end; // Конец проверки баланса родительского узла.
end else begin
// Вставка дочернего узла в правое поддерево.
AddNode(parent.RightChild,new_id,grew);
// Нужна ли перебалансировка?
if (not grew) then exit;
if (parent.Balance = LeftHeavy) then
begin
// Левое поддерево было длиннее. Правое поддерево выросло,
// поэтому дерево сбалансировано.
parent.Balance := Balanced;
grew := False;
end else if (parent.Balance = Balanced) then
begin
// Был баланс. Правое поддерево выросло,
// поэтому оно длиннее. Дерево все еще сбалансировано,
// но оно выросло, поэтому необходимо продолжить проверку
// баланса еще выше.
parent.Balance := RightHeavy;
end else begin
// Правое поддерево длиннее. Оно выросло, поэтому имеем
// разбалансированное дерево справа. Необходимо выполнить
// соответствующий сдвиг для перебалансирования.
RebalanceRightGrew(parent);
grew := false;
end; / // Конец проверки баланса родительского узла.
end; • // Конец if (левое поддерево) ... else (правое поддерево) ..
end;
// Выполнение левого вращения или вращения вправо-влево
// для перебалансирования дерева в данном узле.
/procedure TAVLTree.RebalanceRightGrew(var parent : TAVLNode);
var
child, grandchild : TAVLNode;
begin
AVL-деревья
child := parent.RightChild;
if (child.Balance'= RightHeavy) then
begin
// Вращение влево.
parent.RightChild := child.LeftChild;
child.LeftChild := parent;
parent.Balance := Balanced;
parent := child;
end else begin
// Вращение вправо-влево.
Grandchild := child.LeftChild;
child.LeftChild := grandchild.RightChild;
grandchild.RightChild := child;
parent.RightChild := grandchild.LeftChild;
grandchild.LeftChild := parent;
if (grandchild.Balance=RightHeavy) then
parent.Balance := LeftHeavy
else ,
parent.Balance := Balanced;
if (grandchild.Balance=LeftHeavy) then
child.Balance := RightHeavy
else
child.Balance := Balanced;
parent := grandchild;
end; // Конец if ... else ...
parent.Balance := Balanced;
end;

Удаление узлов из A VL-дерева


В главе 6 было показано, что удалить элемент из упорядоченного дерева слож-
нее, чем добавить. Если удаляемый узел имеет один дочерний, то его можно заме-
нить этим дочерним узлом и все же сохранить порядок расположения элементов
дерева. Если узел имеет две дочерних записи, его нужно заменить крайним правым
в левой ветви узлом. Если у этого узла существует левый потомок, то левый пото-
мок также занимает его место.
Поскольку AVL-деревья - это один из видов упорядоченных деревьев, потре-
буется выполнить те же самые шаги. Но после их завершения необходимо прове-
рить баланс дерева. Если найдется узел, где не выполняется свойство AVL, необхо-
димо осуществить соответствующее вращение, чтобы перебалансировать дерево.
Хотя это те же самые вращения, использовавшиеся ранее для вставки узла в дере-
во, рассматриваемые случаи немного отличаются.
Левое вращение
Предположим, что вы удаляете узел из левого поддерева под узлом X. Допус-
тим, что правое поддерево либо точно сбалансировано, либо его правая половина
имеет глубину на единицу больше, чем левая. Тогда левое вращение, показанное
на рис. 7.12, перебалансирует дерево в узле X.
ШЭННННН Сбалансированные деревья

Узел вставляется здесь

Рис. 7.12. Вращение влево при удалении узла

Нижний уровень поддерева Т2 на рис. 7.12 закрашен серым цветом, таким об-
разом показывается, что поддерево Т в либо точно сбалансировано (Т2 и Т3 имеют
одинаковую глубину), либо его правая половина длиннее (Т3 длиннее Т2). То есть
закрашенный уровень может существовать в поддереве Т2 или отсутствовать.
Если Т2 и Т3 имеют одинаковую глубину, то поддерево Тх с корнем в узле X не
изменяет глубину при удалении узла. Высота Тх была и остается 2 плюс глубина
поддерева Т2. Поскольку глубина не изменяется, дерево выше этого узла сбалан-
сировано.
Если Т3 длиннее Т2, поддерево Тх возрастает на единицу. В этом случае дерево
выше узла X может быть разбалансировано, поэтому необходимо проверить дере-
во, чтобы все предки узла X удовлетворяли свойству AVL.
Вращение вправо-влево
Предположим, что узел удаляется из левого поддерева под узлом X, но левая
половина правого поддерева длиннее правой половины. В этом случае для переба-
лансирования дерева необходимо использовать вращение вправо-влево, изобра-
женное на рис. 7.13.

Узел вставляется здесь

Рис. 7.13. Вращение вправо-влево при удалении узла

Если левое или правое поддеревья Т2 длиннее Т3 или наоборот, вращение


вправо-влево перебалансир""т поддерево Тх и сократит при этом глубину Тх на 1.
AVL-деревья
Это означает, что дерево выше узла X может быть разбалансировано, поэтому необ-
ходимо продолжить проверку выполнения свойства AVL для всех предков узла X.
Другие типы вращения
Другие типы вращения подобны описанным выше. Допустим, удаляемый узел
находится в правом поддереве ниже узла X. Все четыре типа вращения те же са-
мые, которые использовались для балансировки дерева при вставке узла, за одним
исключением.
Когда вы добавляете новый узел к дереву, первое вращение перебалансирует
поддерево Тх без изменения его глубины. Это означает, что дерево выше Тх долж-
но оставаться сбалансированным. Когда вы используете вращение после удаления
узла, оно может уменьшить глубину поддерева Тх на 1. В этом случае нельзя быть
уверенным, что дерево выше узла X все еще сбалансировано. Нужно продолжить
проверку выполнения свойства AVL.
Удаление узлов в Delphi
Процедура RemoveFromNode/ удаляет элемент из дерева. Она рекурсивно об-
ращается к дереву и когда находит искомый узел, удаляет его. Если у него нет до-
черних узлов, то процедура заканчивается. Если имеется один дочерний узел, то
удаляемый узел заменяется его потомком.
Если узел имеет двух потомков, то процедура RemoveFromNode вызывает про-
цедуру ReplaceRightmost, чтобы заменить удаляемый узел крайним правым уз-
лом в его левой ветви. Выполнение процедуры ReplaceRightmost описано в гла-
ве 6, где элементы удаляются из обычного (несбалансированного) сортированного
дерева. Основное отличие возникает при возврате из процедуры и рекурсивном
проходе вверх по дереву. При этом процедура ReplaceRightmost использует
восходящую рекурсию, чтобы проверить баланс в каждом узле дерева.
Когда вызов процедуры завершается, вызывающая ReplaceRightmost под-
программа использует процедуру RebalanceRightShrunk для проверки балан-
са во всех точках дерева. Так как ReplaceRightmost исследует правые ветви де-
рева, она всегда использует процедуру RebalanceRightShrunk.
Процедура RemoveFromNode первый раз вызывает ReplaceRightmost, зас-
тавляя ее двигаться влево вниз от удаляемого узла. Когда первый вызов процеду-
ры ReplaceRightmost завершается, RemoveFromNode вызывает процедуру
RebalanceLef tShrunk, чтобы проверить баланс во всех точках дерева.
После этого рекурсивные обращения к RemoveFromNode завершаются и про-
цедура выполняется обычным способом снизу вверх. Как и ReplaceRightmost,
RemoveFromNode использует восходящую рекурсию для проверки баланса дерева.
После каждого вызова этой процедуры следует вызов процедуры Rebalance-
RightShrunk или RebalanceLef tShrunk в зависимости от того, по какому пути
происходит спуск по дереву.
Процедура RebalanceLef tShrunk аналогична RebalanceRightShrunk,
поэтому она не приведена в следующем коде.
// Удаление значения ниже выделенного узла.
procedure TAVLTree.RemoveFromNode(var node : TAVLNode;
I Сбалансированные деревья
target_id : Integer; var shrunk : Boolean);
var
target : TAVLNode;
begin
/X Если мы у основания дерева, то искомый узел находится не здесь.
if (node = nil) then
begin
shrunk := False;
exp-
end;
if (target_id<node.Id) then
begin
// Поиск левого поддерева.
RemoveFromNode(node.LeftChiId,target_id,shrunk);
if (shrunk) then RebalanceLeftShrunk(node,shrunk) '; .
end else if (target_id>node.Id) then
begin
// Поиск правого поддерева.
RemoveFromNode(node.RightChild,target_id,shrunk);
if (shrunk) then RebalanceRightShrunk(node,shrunk);
end else begin
// Это искомый узел.
target : = node ;
if (node.RightChild = nil) then
begin
// Узел или не имеет дочерних, или имеет только левый.
node := node.LeftChild;
shrunk := True;
end else if (node. Lef tChild=ii) then
begin
// Узел имеет только правый дочерний.
node := node.RightChild;
shrunk := True;
end else begin
// Узел имеет два дочерних.
ReplaceRightmost(node,node.LeftChiId,shrunk);
if (shrunk) then RebalanceLeftShrunk(node,shrunk);
end; // Завершение удаления искомого узла.
// Удаление искомого узла.
target.LeftChild := nil;
target.Rightchild := nil;
target.Free;
end;
end;
// Замена искомого узла крайним правым потомком слева.
procedure TAVLTree.ReplaceRightmost(var target, repi : TAVLNode;
var shrunk : Boolean);
AVL-деревья
var
old_repl : TAVLNode;
begin
if (repl.RightChild = nil) then
begin
// repl - это узел, которым заменят искомый.
// Запоминание положения узла.
old_ijepl := repl;
// Замена repl его левым дочерним узлом.
repl := r epl.LeftChild;
// Заменить искомый узел переменной old_repl.
old_repl.LeftChild := target.LeftChild;
old_repl.RightChi!d := target.RightChild;
old_repl.Balance := target.Balance;
target := old_repl;
shrunk := True;
end else begin
// Рассмотрение правых ветвей.
ReplaceRightmost(target,repl.RightChild,shrunk) ;
if (shrunk) then RebalanceRightShrunk(repl,shrunk) ,•
end;
end;
// Выполнение вращения вправо или влево-вправо после сокращения
// правой ветви.
procedure TAVLTree.RebalanceRightShrunk(var node : TAVLNode;
var shrunk : Boolean);
var
child, grandchild :T AVLNode;
child_bal, grandchild_bal : TBalance;
begin
if (node.Balance = RightHeavy) then
begin
// Правое поддерево было длиннее. Теперь сбалансировано.
node.Balance := Balanced;
end else if (node.Balance=Balanced) then
begin
// Был баланс. Теперь левое поддерево длиннее.
node.Balance := LeftHeavy;
shrunk := False;
end else begin
// Левое поддерево было длиннее. Теперь разбалансировано.
Child := node.LeftChild;
child_bal := child.Balance;
if (child_bal<>RightHeavy) then
begin
// Вращение вправо.
node.LeftChild := child.RightChild;
child.RightChild := node;
if (child_bal = Balanced) then
Сбалансированные деревья
begin
node.Balance := LeftHeavy;
child.Balance := RightHeavy;
shrunk := False;
end else begin
node.Balance := Balanced;
child.Balance := Balanced;
end;
node := child;
end else begin
// Вращение влево-вправо.
grandchild := child. RightChild;
grandchild_bal := grandchild.Balance;
child.RightChild := grandchild.LeftChild;
grandchild.LeftChild := child;
node.LeftChild := grandchild.RightChild;
grandchild.RightChild := node;
if (grandchild_bal = LeftHeavy) then
node.Balance := RightHeavy
else
node.Balance := Balanced;
if (grandchild_bal = RightHeavy) then
child.Balance := LeftHeavy
else
child.Balance := Balanced;
node := grandchild;
grandchild.Balance := Balanced;
end; // Конец if ... else .. .
end; // Конец if balanced/left heavy/left unbalanced
end;

Программа AVL управляет AVL-деревом. Введите имя нового элемента и на-


жмите кнопку Add, чтобы добавить его к дереву. Введите имя существующего эле-
мента и нажмите Remove, чтобы удалить этот элемент из дерева. На рис. 7.14 изоб-
ражено окно программы AVL.

Б-деревья
Б-деревья (B-tree) - другая форма сбалансированного дерева, которая более
наглядна, чем AVL-деревья. Каждый узел в Б-дереве содержит ключи данных
и несколько указателей на дочерние узлы. По-
скольку каждый узел хранит несколько эле-
ментов, узлы часто называются сегментами.
Между каждой парой смежных дочерних
указателей в узле находится ключ, который вы
можете использовать для определения вет-
ви, по которой следует двигаться при добав-
лении или поиске элемента. Например, в де-
Рис. 7.14. Окно программы AVL реве, изображенном на рис. 7.15, корневой
Б-деревья
узел содержит два ключа - G и R. Чтобы разместить элемент со значением мень-
ше G, нужно следовать вниз по первой ветви. Чтобы найти значение между G и R,
необходимо пройти вниз по второй ветви. Разместить элемент со значением боль-
ше R можно, выбрав третью ветвь.
Б-дерево порядка К обладает следующими свойствами:
а каждый узел содержит максимум 2 * К ключей;
а каждый узел, за исключением корня, содержит не менее К ключей;
' а внутренний узел, где расположено М ключей, имеет М -£ 1 дочерних узлов;
О все листья дерева находятся на одном уровне.
Б-дерево на рис. 7.15 имеет порядок 2. Каждый узел может содержать до че-
тырех ключей. Каждый узел, кроме корня, должен иметь не менее двух ключей.
Для удобства в узлы Б-дерева обычно поме-
щают четное количество ключей, поэтому по-
рядок является, как правило, целым числом.
Требование, чтобы каждый узел в Б-де- •—•—• ^гт— ^п—
реве порядка К содержал от К до 2 * К клю- № N 1 МШИМ [T|Y| | |
чей, поддерживает баланс дерева. Поскольку РИС. 7.15. Б-дерево
каждый узел должен содержать, по крайней
мере, К ключей, он должен иметь не меньше К + 1 дочерних узлов, поэтому дерево не
может стать слишком высоким и тонким. Б-дерево, содержащее N узлов, может
иметь глубину максимум O(logK+(N). Следовательно, сложность алгоритма поиска
в таком дереве будет порядка O(logN). Хотя это и не так очевидно, добавление и уда-
ление элементов из Б-дерева также имеют сложность порядка O(logN).

Производительность Б-дерева
Применение Б-деревьев особенно полезно при создании приложений, пред-
назначенных для работы с базами данных. При большом порядке Б-дерева вы смо-
жете найти любой элемент после рассмотрения всего нескольких узлов. Напри-
мер, Б-дерево 10-го порядка, содержащее миллион записей, может быть глубиной
максимум log]((l 000 000), или приблизительно шесть уровней. Для нахождения
конкретного элемента вам необходимо исследовать максимум шесть узлов.
Сбалансированное двоичное дерево, содержащее тот же самый миллион эле-
ментов, имело бы глубину Iog2(l 000 000), или приблизительно 20. Однако эти
узлы содержат всего одно ключевое значение. Чтобы найти элемент в двоичном
дереве, вы исследовали бы 20 узлов и 20 значений. Чтобы найти элемент в Б-де-
реве, понадобится проверить 5 узлов и 100 ключей.
Применение Б-деревьев обеспечивает более высокую скорость работы, по-
скольку проверку ключей выполнить проще, чем проверку узлов. Например, если
база данных сохранена на жестком диске, считывание данных происходит доста-
точно медленно. Если данные находятся в памяти, их исследование выполняется
значительно быстрее.
Чтение данных с диска происходит большими блоками, и считывание цело-
го блока занимает столько же времени, сколько и чтение одного байта. Если узлы
Б-дерева не слишком велики, то считывание узла Б-дерева с диска займет не больше
Сбалансированные деревья
времени, чем считывание узла двоичного дерева. В этом случае поиск 5 узлов в Б-
дереве будет требовать 5 медленных обращений к диску плюс порядка 100 быстрых
обращений к оперативной памяти. Поиск 20 узлов в двоичном дереве будет требо-
вать 20 медленных обращений к диску плюс 20 обращений к оперативной памяти.
Двоичный поиск медленнее, потому что время, потраченное на 15 обращений к дис-
ку, намного больше, чем сэкономленное время 80 обращений к памяти. Проблемы
доступа к диску обсуждаются в этой главе чуть позже.

Добавление элементов в Б-дерево


Чтобы вставить новый элемент в Б-дерево, определите лист, в который должен
быть помещен новый элемент. Если этот узел содержит менее чем 2 * К ключей, то
в нем есть место для вставки нового элемента. Добавьте новый элемент в соответ-
ствующую позицию, чтобы элементы узла были упорядочены.
Если узел уже содержит 2 * К элементов, то места для нового элемента в узле
уже не остается. Чтобы создать необходимое пространство, поделите узел на два
новых узла. Теперь имеется 2 * К + 1 элементов для распределения между двумя
новыми узлами. Разместите К элементов в каждом узле, сохраняя' соответствую-
щий порядок их расположения. Переместите средний элемент в родительский узел.
Например, нужно вставить элемент Q в Б-дерево, показанное на рис. 7.15. Этот
новый элемент принадлежит второму листу, который уже заполнен. Чтобы разбить
узел, поделите элементы J, К, N, Р и Q между двумя новыми узлами. Расположите
элементы J и К в левом узле, а Р и Q - в правом. Затем переместите средний эле-
мент N в родительский узел. На рис. 7.16 изображено полученное дерево.

IBIEI I I IJIKI I IINIQI I IIT1YI I I

Рис. 7.16. Б-дерево после добавления элемента О

Деление узла на два называется дроблением сегмента. Когда оно происхо-


дит, родительский узел получает новый ключ и новый указатель. Если родитель-
ский узел уже полон, то добавление нового ключа и указателя также может при-
вести к его дроблению, что, в свою очередь, потребует добавления новой записи
на более высоком уровне и т.д. В наихудшем случае добавление элемента вызы-
вает «цепную реакцию» дробления узлов вплоть до корня.
Когда происходит дробление корня, Б-дерево становится глубже. Это един-
ственный способ увеличить его глубину. Поэтому Б-деревья обладают необычным
свойством - они всегда растут от листьев к корню.

Удаление элементов из Б-дерева


Теоретически, удалить элемент из Б-дерева так же просто, как и добавить. На
практике все гораздо сложнее.
Если удаляемый элемент находится не в листе, вы должны заменить его дру-
гим элементом, чтобы сохранить соответствующий порядок расположения. Это
похоже на случай удаления элемента из сортированного дерева или AVL-дерева,
поэтому можно обрабатывать этот случай подобным образом. Замените элемент
крайним правым элементом из левой ветки. Этот элемент будет всегда находиться
в листе. После замены элемента можно считать, что вместо него просто удален за^
менивший его лист.
Чтобы удалить элемент из листа, сначала нужно сдвинуть все Другие элемен-
ты влево, чтобы заполнить оставшееся место. Помните, что каждый узел в Б-дере-
ве порядка К должен содержать от К до 2 * К элементов. После того как вы удаля-
ете элемент из листа, он может содержать всего К - 1 элементов.
В этом случае попробуйте взять несколько элементов из узлов на том же уровне.
Затем перераспределите элементы в этих двух узлах так, чтобы они имели не менее
К элементов. На рис. 7.17 элемент был удален из крайнего левого листа в дереве,
при этом узел остался всего с одним элементом. Перераспределение элементов меж-
ду узлом и его правым сестринским дает обоим узлам, по крайней мере, по два клю-
ча. Обратите внимание, что средний элемент J сдвинут в родительский узел.

Узел удаляется отсюда

Рис. 7.17. Перераспределение узлов после удаления одного из них

При попытке сбалансировать дерево таким образом может оказаться, что со-
седний узел на том же уровне содержит всего К элементов. Между искомым уз-
лом и его сестринским имеется всего 2 * К - 1 элементов, поэтому недостаточно ис-
пользовать эти два узла. В этом случае все элементы в обоих узлах могут поместиться
в пределах одного узла, поэтому вы можете объединить их. Удалите ключ, который
отделяет эти два узла от родительского. Поместите этот элемент и 2 * К - 1 элемен-
тов этих двух узлов в один общий. Процесс объединения двух узлов называется
слиянием сегментов, или объединением сегментов (bucket merge, или bucket join).
На рис. 7.18 показано, как объединять Два узла.

IBIEI IIJIKI ITIYI

Рис. 7.18. Объединение после удаления узла


Сбалансированные деревья
При слиянии двух узлов из родительского удаляется ключ, и там остается
К - 1 элементов. В этом случае необходимо перебалансировать или объединить
его с одним из сестринских узлов. Но если после этого в узле на более высоком
уровне все равно останется К - 1 элементов, процесс
0е Help
повторится. В наихудшем случае удаление вызовет
Value p 1 ю* «цепную реакцию» слияния сегментов узлов вплоть
30
40 до корня.
1 ™. 1 50
60 Если вы удаляете последний элемент из корне-
_ е | 70 :
±±
80
вого узла, объедините два оставшихся дочерних узла
Nodes: 9 90;
100
корня в новый корень и сократите дерево на один
Ш • ПО уровень. Единственный способ уменьшения глуби-
120
130 ны дерева - это объединение дочерних узлов корня,
«^Н 140
при котором образуется новый корень.
150
160
170 Программа Btree позволяет управлять Б-деревом.
180
190 Введите текстовое значение и нажмите кнопку Add,
"1ПП
чтобы добавить элемент. Введите значение и щелк-
ните по кнопке Remove, чтобы удалить элемент. На
Рис. 7.19. Окно программы рис 7.19 показано окно программы Btree, управляю-
Btree щей Б-деревом 2-го порядка.

Разновидности Б-дерева
Имеется несколько вариаций Б-деревьев, но здесь описаны только самые
распространенные. Нисходящие Б-деревъя (top-down B-tree) немного иначе уп-
равляют структурой Б-дерева. За счет разбиения встречающихся полных узлов
эта разновидность алгоритма использует при вставке элементов более нагляд-
ную нисходящую рекурсию вместо восходящей. Это также сокращает риск фор-
мирования длинных каскадов разбиения сегментов.
Другой разновидностью Б-деревьев являются Б+деревья. Они хранят только
ключи данных во внутренних узлах, а записи данных - в листах. Это позволяет
Б+деревьям поддерживать большее количество элементов в каждом сегменте, по-
этому они короче соответствующих Б-деревьев.
Нисходящие Б-деревья
Процедура добавления нового элемента к Б-дереву сначала рекурсивно отыс-
кивает по всему дереву сегмент, в который нужно поместить элемент. Когда она
пытается вставить новый элемент на свое место, ей может понадобиться разбить
блок и переместить один из элементов узла в его родительский узел.
При возврате из рекурсивных вызовов вызывающая процедура проверяет,
требуется ли разбиение родительского узла. Если не нет, то элемент помещается
в родительский узел. При каждом возврате из рекурсивного вызова вызываю-
щая процедура должна проверять, не требуется ли разбиение следующего роди-
теля. Поскольку разбиение сегментов происходит, когда процедура заканчивает
рекурсивное обращение, такой процесс называется восходящей рекурсией. Б-де-
ревья, управляемые таким образом, иногда называются также восходящими Б-де-
ревъями (bottom-up B-tree) .
Б-деревья
Альтернативная стратегия состоит в том, чтобы разбить любые полные узлы,
встречающиеся на пути вниз. Когда процедура ищет сегмент, чтобы поместить в него
новый элемент, она разбивает встречающийся узел, который уже заполнен. Каж-
дый раз при дроблении узла она передает элемент в родительский узел. Так как ;
все расположенные выше полные узлы уже разбиты, то в родительском узле все-
гда есть место для нового элемента.
Когда процедура достигает листа, в который нужно поместить элемент, в ро-
дительском узле обязательно будет место для размещения нового элемента. Если
программа должна разбить лист, то всегда есть место для размещения среднего
элемента листа в родительском узле. Поскольку эта система работает от вершины
вниз, этот тип Б-деревьев называется нисходящим Б-деревом (top-down B-trees).
При этом разбиение блоков происходит чаще, чем необходимо. Нисходящее
Б-дерево разбивает полный узел, даже если в его дочерних узлах достаточно много
свободного места. При нисходящем методе дерево содержит большее количество
пустых записей, чем при восходящем. С другой стороны, разбивая узлы заранее,
этот метод сокращает риск возникновения длинного каскада разбиений сегментов.
К сожалению, не существует нисходящей версии объединения узлов. Проце-
дура удаления узлов не может объединять встречающиеся полупустые узлы на
пути вниз, потому что в этот момент еще неизвестно, нужно ли будет объединить
два дочерних узла и удалить элемент из их родителя. Поскольку также неизвест-
но, будет ли удален элемент из родительского узла, нельзя заранее сказать, потре-
буется ли слияние родителя с одним из узлов, находящимся на том же уровне.
Б+деревья
Б-деревья часто используются для хранения больших записей. Типичное Б-де-
рево может содержать записи о сотрудниках, каждая из которых занимает несколь-
ко килобайт памяти. Записи упорядочиваются по некоторому ключевому полю,
например по имени служащего или идентификационному номеру. В этом случае
переупорядочивание элементов будет происходить медленно. Чтобы объединить
два сегмента, программа должна переместить много записей, каждая из которых
довольно большая. Аналогично, для разбиения блока придется обработать не мень-
шее число записей большого объема.
Чтобы избежать перемещения больших блоков данных, программа записыва-
ет во внутренних узлах Б-дерева только ключи записей. Узлы также содержат ука-
затели на фактические записи данных, сохраненные в другом месте. Теперь, если
программе требуется переупорядочить блоки, нужно будет переместить только
ключи и указатели, а не сами записи. Данный тип Б-дерева называется Б+деревом
(B+tree).
Поскольку элементы Б+дерева довольно малы, программа сохраняет большее
количество ключей в каждом узле. При том же размере узла программа может уве-
личить порядок дерева и сделать его короче.
Например, имеется Б-дерево 2-го порядка, так что каждый узел имеет от трех
до пяти дочерних. Чтобы хранить миллион записей, дерево должно иметь глубину
от Iog5(l 000 000) до Iog3(l 000 000), или от 9 до 13. Чтобы разместить элемент в этом
дереве, программа должна выполнить 13 обращений к диску.
Сбалансированные деревья

Предположим, что вы сохраняете тот же самый миллион записей в Б+дереве,


используя для узлов приблизительно тот же размер в байтах. Поскольку Б+дере-
во сохраняет только ключи в узлах, это дерево может содержать ключи для 20 за-
писей в каждом узле. В этом случае каждый узел будет иметь от 11 до 21 дочерних
узлов, поэтому глубина дерева будет от Iog21(l 000 000) до logn(l 000 000), или от 5
до 6. Чтобы разместить элемент, программе потребуется только шесть обращений
к диску для нахождения ключа элемента и одно дополнительное обращение, что-
бы восстановить сам элемент.
Сохранение одних указателей, на данные в узлах Б+дерева также облегчает со-
поставление ключей с наборами записей. В системе, оперирующей записями о слу-
жащих, одно Б+дерево может использовать в качестве ключей фамилию, а другое -
номер социального страхования. Оба дерева содержат указатели на фактические за-
писи, сохраненные где-то за пределами деревьев.

Усовершенствование Б-деревьев
Этот раздел посвящен двум методам для улучшения производительности Б-де-
ревьев и Б+деревьев. Первый метод позволяет перестраивать элементы в пределах
узла и сестринских узлов, чтобы избежать разбиения сегментов. Используя вто-
рой, можно загружать или перезагружать данные, чтобы добавлять в дерево сво-
бодные ячейки. Это сокращает вероятность разбиения сегментов впоследствии.
Перебалансирование без дробления сегментов
При добавлении элемента заполненный блок разбивается на два. Можно из-
бежать разбиения сегмента, если перебалансировать узел с одним из его сестрин-
ских. Например, добавление нового элемента Q к Б-дереву, изображенному слева
на рис. 7.20, обычно вызывает дробление сегмента. Избежать этого можно, переба-
лансировав узел, содержащий J, К, L и N, с его левым сестринским, содержащим
В и Е. В результате получится дерево, изображенное справа на рис. 7.20.

IBIEIGI MKILINIQIITIYI
Рис. 7.20. Перебалансирование без разбиений сегментов

Этот тип балансировки имеет пару преимуществ. Во-первых, сегменты ис-


пользуются более эффективно. В них находится меньше пустых ячеек, поэтому
сокращается трата памяти. Во-вторых, если вы не разбиваете сегмент, то нет необ-
ходимости перемещать элемент в родительский сегмент. Это гарантирует* что если
каскад разбиений сегментов и возникнет, то он не будет слишком длительным.
С другой стороны, сокращение числа пустых ячеек уменьшает объем потра-
ченной впустую памяти, но увеличивает вероятность разбиения сегментов в буду-
щем. Поскольку в дереве остается меньше свободных ячеек, возрастает вероят-
ность, что при добавлении нового элемента узлы будут переполнены.
Добавление свободного пространства
Предположим, что имеется небольшая база данных Клиентов, которая содер-
жит 10 записей. Вы можете загрузить записи в Б-дерево, чтобы они заполнили каж-
дый сегмент, как показано на рис. 7.21. Это дерево содержит немного свободных
ячеек, но добавление нового элемента немедленно вызывает разбиение блоков.
Поскольку все блоки заполнены, возникнет последовательность дробления сег-
ментов, которая дойдет до корневого узла.
Вместо того чтобы плотно заполнять дерево, вы можете добавить несколько
дополнительных пустых записей в каждый узел, как показано на рис. 7.22. Дерево
становится немного больше, но это позволяет добавлять новые элементы без по-
рождения длинной цепочки разбиений сегментов. После того как дерево некото-
рое время используется, количество свободного пространства может уменьшить-
ся до точки, когда вероятность разбиения сегментов возрастет. Тогда вы можете
перестроить дерево, чтобы добавить большее количество свободных ячеек.

Рис. 7.21. Плотное заполнение Рис. 7.22. Неплотное заполнение


Б-дерева Б-дерева

Б-деревья в реальных приложениях обычно имеют намного больший порядок,


чем приведенные здесь деревья. Добавление свободного пространства в дерево со-
кращает необходимость разбиения сегментов и балансировки. Например, если до-
бавить 10% свободного пространства в Б-дерево порядка 10, в каждом узле по-
явится место еще для двух элементов. Вы можете работать с этим деревом очень
долго, прежде чем возникнет необходимость в длинных цепочках дробления сег-
ментов.
Это очередной пример пространственно-временного компромисса. Добавле-
ние свободного пространства в узлы увеличивает размер дерева, но сокращает ве-
роятность разбиений сегментов.

Вопросы доступа к диску


Б-деревья и Б+деревья широко применяются в созданий приложений боль-
ших баз данных. Типичное Б+дерево может содержать сотни тысяч или даже мил-
лионы записей. В любой момент в памяти будет находиться только небольшая
часть дерева. При каждом обращении к узлу программе требуется считать его с
жесткого диска. Этот раздел посвящен обсуждению некоторых вопросов, которые
особенно важно учитывать при хранении данных на жестком диске: использова-
ние псевдоуказателей, выбор размера сегмента и кэширование корневого узла.
Псевдоуказатели
Объекты и указатели облегчают построение дерева в памяти, но они не годятся
для его сохранения на жестком диске. Нельзя создать указатель на часть файла.
Сбалансированные деревья^

Вместо этого можно использовать методы работы с псевдоуказателями и за-


менить указатели номерами записей. Указателем на узлы дерева ссылок служат
не объекты, а номер записи узла в файле. Например, Б+дерево 12-го порядка опе-
рирует с 80-байтовыми ключами. Структуру данных узла можно определить в сле-
дующем коде:
const
ORDER = 12;
KEYS_PER_NODE = 2*ORDER;
type
String80 = String[80];
TBtreeNode = record
Key : array [1..KEYS_PER_NODE] of StringSO; //Ключи.
Child : array [0..KEYS_PER_NODE] of Longint; // Указатели на
// узлы.
end;
Элементы массива Chi Id указывают номер записи из дочерних узлов в файле.
Случайный доступ к файлу данных Б+дерева возможен при помощи записей,
которые соответствуют структуре BtreeNode.
AssignFile(IdxFile,file_name);
Reset(IdxFile);

Когда файл открыт, вы можете выбрать конкретную запись с помощью команд


Seek и Read.
Seek(IdxFile,node_number);
Read(IdxFile,node_record);
Чтобы упростить управление Б+деревом, можно сохранять узлы и записи дан-
ных в отдельных файлах и использовать для управления каждым из них псевдо-
указатели.
Когда программе больше не нужна запись в файле, она не может просто очис-
тить все ссылки на индекс этого элемента. Если сделать так, то программа больше
не сможет использовать эту запись, хотя та все еще занимает место в файле.
Программа должна отслеживать пустые ячейки, чтобы потом можно было по-
вторно использовать их. Один из простых способов сделать это - вести связан-
ный список неиспользуемых записей. Когда запись больше не нужна, ее добавля-
ют к списку. Когда программе нужна новая запись, она удаляет одну запись из
этого списка. Если программе нужен новый элемент, а список пуст, программа рас-
ширяет файл.
Выбор размера сегмента
Дисководы считывают данные блоками, которые называются кластерами. Раз-
мер кластера обычно составляет 512 или 1024 байта, или другое число байтов, рав-
ное степени двойки.
Б-деревья

Вы можете воспользоваться этим, создав сегменты, размер которых равен це-


лому числу кластеров. Затем следует поместить в этот сегмент максимальное ко-
личество записей или ключей. Предположим, что вы решили создавать сегменты
размером приблизительно по 2048 байт. Если вы строите Б+дерево с 80-байтовы-
ми ключами, вы можете поместить до 24 ключей плюс 25 указателей (если указа-
тель представляет собой 4-байтовое число типа Longint) в каждый сегмент. Вы
можете создать Б+дерево 12-го порядка с сегментами, которые объявляются в сле-
дующем коде:
const
ORDER = 12;
KEYS_PER_NODE = 2"ORDER;
type
StringSO = String[80];
TBtreeNode = record
Key : array [1..KEYS_PER_NODE] of StringSO; // Ключи.
Child : array [0..KEYS_PER_NODE] of Longint; // Указатели на
// узлы.
end;

Кэширование узла
Каждый поиск в Б+дереве начинается с корневого узла. Ускорить поиск можно,
если корневой узел будет все время находиться в памяти. Тогда при поиске элемента
программа обращается к диску меньше на один раз. При этом все равно следует за-
писывать корневой узел при каждом его изменении на диск, в обратном случае при
повторной загрузке после отказа программы изменения в дереве будут потеряны.
Можно также кэшировать в памяти другие узлы Б+дерева. Если хранить в па-
мяти все дочерние записи корневого узла, то программе не нужно будет считывать
их с диска. Для Б+дерева порядка К корневой узел содержит от 1 до 2 * К ключей
и поэтому имеет от 2 до 2 * К + 1 дочерних узла. Это означает, что необходимо
кэшировать до 2 * К + 1 узлов.
Программа может также кэшировать узлы при обходе дерева. При прямом об-
ходе, например, программа обращается к каждому узлу и затем рекурсивно обхо-
дит все его дочерние узлы. Программа спускается к первому дочернему узлу, а пос-
ле возврата переходит к следующему. При каждом возврате программа должна
снова обратиться к родительскому узлу, чтобы определить, к какому из дочерних
узлов обращаться в следующую очередь. Кэшируя родительский узел, программа
избегает необходимости снова считывать его с диска.
Рекурсия позволяет программе автоматически сохранять узлы в памяти без
использования сложной схемы кэширования. При каждом обращении к рекурсив-
ному алгоритму обхода объявляется локальная переменная, в которой находится
узел до тех пор, пока он не понадобится. При возврате из рекурсивного вызова
Delphi автоматически освобождает эту переменную. Следующий код демонстри-
рует, как можно реализовать этот алгоритм в Delphi:
EEQHHHI1I Сбалансированные деревья^
procedure Preorder(node_number : Longint);
var
i : Integer;
node : BtreeNode;
begin
// Считывание узла.
Seek(IdxFile,node_nuniber);
Read(IdxFile,node);
// Посещение узла.
VisitNode(node_number);
//Посещение дочерних узлов;
for i := 0 to KEYS_PER_NODE do
begin
if (node.Child[i] < 0) then break; // Нет дочернего узла.
Preorder(node.Child[i]);
end;
end;

База данных на основе Б+дерева


Программа Bplus управляет базой данных на основе Б+дерева с помощью двух
файлов данных - Gusts. dat, содержащего записи данных клиентов, и Gusts. idx,
где находятся узлы Б+дерева.
Введите данные в поле Customer Record (Запись о клиенте) и нажмите кнопку
Add, чтобы добавить в базу данных новый элемент. Введите имя и фамилию в верх-
ней части формы, и нажмите Find (Найти), чтобы отыскать соответствующую запись.
На рис. 7.23 показано окно программы после нахождения записи Rod Step-
hens. Метка Accesses (Обращения) в поле Search (Поиск) определяет, что про-
грамме понадобилось всего три обращения к диску, чтобы отыскать запись. Ста-
тистика внизу указывает, что данные были
найдены в записи номер 354. Глубина Б+де-
рева равна 3, оно содержит 731 запись данных
и 67 сегмента.
Когда вы вносите запись или проводите
поиск, программа Bplus выбирает эту запись
из файла. После нажатия кнопки Remove про-
грамма удаляет запись из базы данных.
Если выбрать команду Internal Nodes
(Внутренние узлы) в меню Display (Показать),
программа выведет список внутренних узлов
дерева. Она отображает также ключи для каж-
дого узла, чтобы показать внутреннюю струк-
туру дерева.
При помощи команды Complete Tree (Все
дерево) меню Display можно вывести полную
структуру дерева. Данные о клиенте отобра-
Рис. 7.23. Окно программы Bplus жаются в скобках.
Б-деревья
Записи индексного файла содержат 41-байтовые ключи. Каждый ключ - это
фамилия клиента, дополненная до 20 символов, после чего следует запятая, а пос-
ле запятой - имя клиента, дополненное до 20 символов.
Программа Bplus считывает данные блоками по 1024 байта. Если предполо-
жить, что блок содержит К ключей, то в каждом сегменте будет К ключей длиной
41 байт, К + 1 указателей на дочерние узлы по 4 байта и двухбайтовое целое число
NumKeys. При этом полный размер блоков должен быть максимальным, но не пре-
вышать 1024 байт.
Решая уравнение 4 1 * К + 4 * ( К + 1 ) + 2< 1024 относительно К, вы получаете
К < 22,62, поэтому К должно быть равно 22. В этом случае Б+дерево имеет поря-
док 11, поэтому оно содержит по 22 ключа в каждом блоке. Каждый сегмент зани-
мает 41 * 22 + 4 * (22 + 1) + 2 = 996 байт. Следующий код демонстрирует опреде-
ление блоков в программе Bplus:
const
KEY_SIZE = 41;
ORDER = 11;
KEYS_PER_NODE = 2*ORDER;

Чтобы упростить управление этими двумя файлами данных, программа Bplus


использует два различных типа записей для представления записи в каждом файле.
Тип данных TBucket представляет запись в индексном файле Б+дерева -
Gusts . idx. Первая запись в Custs. idx содержит заголовок, который описывает
текущее состояние Б+дерева. В заголовок входит число сегментов, содержащихся
в Custs . dat, количество записей данных Gusts . dat, указатели на первый пус-
той блок в каждом файле и т.д.
Остальные записи в файле Custs . idx содержат ключи и индексы. Перемен-
ная NumKeys возвращает число реально используемых в записи ключей.
TBucket = record
case IsHeader : Boolean of
True :
( // Информация заголовка.
NumBuckets •: Longint; // Число сегментов в Custs.idx.
NumRecords : Longint; // Число записей в Custs..
Root : Longint; // Индекс корня в Custs.idx.
NextTreeRecord : Longint; // Следующий неиспользуемый в Custs.idx.
NextCustRecord : Longint; // Следующий неиспользуемый в Custs.dat.
FirstTreeGarbage : Longint; // Первый неиспользуемый в Custs. idx.
FirstCustGarbage : Longint; // Первый неиспользуемый в Custs.dat.
Height : Integer; // Глубина дерева.
);
False :
< // Сегмент, содержащий ключи.
// Число используемых ключей в данном сегменте.
NumKeys : Integer;
// Key = Last Name, First Name.
Key : array [1..KEYS_PER_NODE] of String[KEY_SIZE];
|i Сбалансированные деревья
// Индексы дочерних сегментов.
Child : array [0..KEYS_PER_NODE] of Longint;

end;
end; // Конец объявления записи TBucket.

Тип данных TCustomer представляет запись в файле данных В+дерева —


Gusts. dat. Эта запись немного проще, чем тип данных TBucket. Каждая запись
находится либо в связанном списке свободных ячеек файла, либо содержит дан-
ные о клиентах. Свободные ячейки содержат только индекс следующей записи
связанного списка.
TCustomer = record
case IsGarbage : Boolean of
True :
( // В списке неиспользуемых элементов.
NextGarbage : Longint; // Индекс следующей
// неиспользуемой записи.
)i *
False :

// Запись о клиенте.
LastName : Strlng[20];
FirstName : String[20];
Address : String[40];
City : String[20];
State : String[2];
Zip : String[10];
Phone .: String [12] ;

end;
end; // Конец определения записи TCustomer.

При запуске программа Bplus запрашивает путь к базе данных, затем открыва-
ет файлы данных Б+дерева Gusts. dat и Gusts . idx в указанном каталоге. Если
файлы не существуют, программа создает их. Если они уже есть, программа счи-
тывает заголовок с информацией о дереве из файла Gusts . idx. Затем она считы-
вает корневой узел Б+дерева и кэширует его в памяти.
Когда программа начинает исследовать дерево, чтобы вставить или удалить
элемент, она кэширует все узлы, к которым обращается. При рекурсивном возвра-
те эти узлы могут понадобиться снова, если произошло разбиение сегмента, слия-
ние или другое переупорядочивание узлов. Поскольку программа кэширует узлы
на пути вниз, они доступны и на пути вверх.
Увеличение размера сегментов делает Б+дерево более эффективным, но при
этом его сложнее проверить «вручную». Чтобы увеличить Б+дерево 11-го порядка
на 2 уровня, вам необходимо добавить в базу данных 23 элемента. Чтобы высота
дерева стала равной 3, необходимо добавить более 250 дополнительных элементов.
Резюме
Тестировать программу Bplus будет намного легче, если изменить порядок
Б+дерева и сделать его равным 2. В файле BplusC. pas закомментируйте строку,
которая определяет 11-й порядок, и снимите атрибут комментария со строки, за-
дающей 2-й порядок.
// ORDER = 11;
ORDER = 2;

Меню Data (Данные) программы Bplus содержит команду Create Data (Со-
здать данные), которая позволяет быстро создать большое количество записей дан-
ных. Введите число записей, которые вы хотите создать и порядковый номер пер-
вого элемента. «
Программа организует записи и вставляет их в Б+дерево. Например, если вы
задаете в программе создание 100 записей, начиная с номера 200, программа обра-
зует записи с порядковыми номерами 200, 201,... ,299, которые будут выглядеть
следующим образом:
FirstName : FirSt_200
LastName : Last_200
Address : Addr_200
City : City_200

Вы можете использовать эти записи для экспериментов с достаточно больши-


ми Б+деревьями.

Резюме
Сбалансированные деревья позволяют программе эффективно управлять дан-
ными. Б+деревья высокого порядка особенно удобны для хранения больших баз
данных на жестких дисках или других относительно медленных запоминающих
устройствах. Более того, можно использовать несколько Б+деревьев для создания
нескольких индексов одного и того же большого набора данных.
В главе 11 описана альтернатива сбалансированным деревьям. Хеширование
при некоторых обстоятельствах обеспечивает даже более быстрый доступ к дан-
ным, хотя оно не позволяет выполнять такие операции, как последовательный
вывод записей.
Глава 8. Деревья решений
Многие сложные реальные задачи можно смоделировать с помощью деревьев ре-
шений (decision trees). Каждый узел в дереве представляет собой один шаг реше-
ния задачи. Ветвь в дереве соответствует решению, которое ведет к более полному
решению. Листы представляют собой окончательное решение. Цель состоит в том,
чтобы найти «наилучший» путь от корня до листа при выполнении некоторых ус-
ловий. Естественно, что условия и «наилучший» путь зависят от сложности конк-
ретной задачи.
Деревья решений обычно огромны. Подобное дерево для игры в крестики-но-
лики содержит более полумиллиона узлов. Многие же реальные задачи несравни-
мо сложнее этой игры, Соответствующие им деревья решений могут содержать
больше узлов, чем атомов во вселенной.
Эта глава посвящена методам работы с этими огромными деревьями. Снача-
ла рассматриваются игровые деревья (game trees). На примере крестиков-ноликов
показаны способы поиска в деревьях игры наилучшего возможного хода. После-
дующие разделы описывают более общие способы исследования деревьев реше-
ний. Для самых маленьких деревьев можно использовать метод полного перебора
(exhaustive searching) всех возможных решений. Для работы с большими деревья-
ми более подходит метод ветвей и границ (brunch-and-bound technique), позволя-
ющий отыскивать лучшее возможное решение без поиска по всему дереву.
Для огромных деревьев лучше использовать эвристический метод (heuristic).
При этом найденное решение может и не быть наилучшим из возможных, но дол-
жно быть достаточно близким к нему. Данный метод позволяет исследовать прак-
тически любое дерево.
В конце главы обсуждается несколько очень сложных задач, которые вы мо-
жете попробовать решить с помощью методов ветвей и границ или эвристического
метода. Многие из этих задач имеют важное практическое значение, поэтому на-
хождение наилучших решений для них крайне необходимо.

Поиск в игровых деревьях


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

из девяти квадратов. Каждому из девяти ходов соответствует ветвь, исходящая из


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

/Ж/Ж/Ж/ЖхЖ
о о 0
X X X Xо X о X X X
0 0 о
/Ж/Ж/Ж/Ж/Ж/Ж/Ж/Ж
Рис. 8.1. Фрагмент дерева игры в крестики-нолики

Как можно видеть на рис. 8.1, дерево игры в крестики-нолики растет чрезвы-
чайно быстро. Если дерево продолжает расти таким образом (то есть каждый узел
имеет на одну ветвь меньше, чем его родитель), то во всем дереве будет содержать-
ся 9 * 8 * 7 ... * 1 = 362 880 листьев. В дереве окажется 362 880 возможных путей,
соответствующих 362 880 сценариям развития игры.
На самом деле многие возможные узлы в дереве игры в крестики-нолики от-
сутствуют, потому что они запрещены правилами игры. Если игрок, ходивший пер-
вым, за три своих хода ставит крестик в ле-
вый верхний, средний верхний и правый
верхний квадраты, то крестики побеждают
и игра заканчивается. Узел, соответствую-
щий этой позиции на игровом поле, не име-
ет дочерних узлов, потому что игра завер-
шилась (см. рис. 8.2).
Удаление всех невозможных узлов сокра-
щает дерево практически до четверти милли-
она листов. Однако это все еще довольно боль-
шое дерево, и исчерпывающий поиск займет Рис. 8.2. Быстрое завершение игры
Деревья решений
достаточно много времени. Для более сложных игр, таких как шашки, шахматы
или го, игровые деревья имеют просто огромный размер. Если бы во время каждо-
го хода в шахматах игрок имел 16 возможных вариантов, в дереве игры было бы
более триллиона узлов после пяти ходов каждого из игроков. В конце этой главы
более подробно разъясняется поиск в таких огромных деревьях, а следующий раз-
дел посвящен простому примеру - игре в крестики-нолики.

Минимаксный перебор
Чтобы выполнить поиск в игровом дереве, нужно иметь возможность опре-
делить значение позиции игрового поля. В крестиках-ноликах для первого игро-
ка большое значение имеют позиции, в которых три крестика расположены в ряд,
так как при этом первый игрок выигрывает. Значение игрока, который ставит
нолик, в этих позициях поля очень мало, потому что при этом он проигрывает.
Каждому игроку можно назначить одно из четырех значений для конкретной
позиции поля. Значение 4 предполагает, что в данной ситуации игрок выиграет.
Если значение равно 3, то из текущего положения на доске не ясно, кто в конечном
счете победит. Значение, равное 2, предполагает, что позиция приведет к ничьей.
И наконец, значение 1 соответствует выигрышу противника.
Для исчерпывающего исследования игрового дерева можно использовать стра-
тегию минимакса (minimax). При этом вы пытаетесь минимизировать максималь-
ное значение, которое может иметь позиция для противника после следующего
хода. Сначала определяется максимальное значение, которое может набрать про-
тивник после каждого из ваших возможных ходов. Затем выбирается ход, при ко-
тором противник получает минимальное значение.
Процедура BoardValue, приведенная ниже, вычисляет значение позиции
поля. Эта процедура исследует каждый возможный ход. Для каждого хода она ре-
курсивно обращается к себе, чтобы определить значение, которое будет иметь но-
вая позиция для противника. Затем она выбирает ход, который дает противнику
минимальное значение.
Для определения значения позиции поля подпрограмма BoardValue рекур-
сивно вызывает себя до тех пор, пока не произойдет одно из трех событий. Во-
первых, может быть найдена позиция, в которой игрок побеждает. В этом случае
процедура устанавливает значение позиции поля в 4, указывая, что игрок, кото-
рый сделал ход, выиграл.
Во-вторых, BoardValue может найти позицию, в которой ни один игрок не
может сделать ход. Игра заканчивается ничьей, поэтому процедура устанавливает
значение позиции в 2.
И наконец, процедура может достичь заранее установленной максимальной
глубины рекурсии. Если она превышает допустимую глубину, BoardValue уста-
навливает для позиции поля значение 3, что указывает на ничью. Максимальная
глубина рекурсии предохраняет программу от траты слишком большого количе-
ства времени на поиск. Это особенно важно для более сложных игр, таких как шах-
маты, в которых поиск в дереве игры может продолжаться практически бесконеч-
но. Максимальная глубина также позволяет задавать уровень мастерства. Чем
глубже программа может исследовать дерево, тем лучше будут ее ходы.
Поиск в игровых деревьях

На рис. 8.3 показано дерево игры крестики-нолики в конце партии. В данный


.уюмент ходит игрок, играющий крестиками, и у него есть три возможных хода. Что-
бы выбрать лучший ход, процедура BoardValue рекурсивно исследует каждый из
трех возможных ходов. Первый и третий ходы (левая и правая ветви в дереве) при-
водят к победе для игрока, играющего ноликами, поэтому эти ходы имеют для него
значение 4. Второй возможный ход приводит к ничьей и имеет значение 2 для игро-
ка «ноликов». Подпрограмма BoardValue выбирает второй ход, потому что он дает
игроку «ноликов» самое маленькое значение поля.

Вес для игрока О

Вес для игрока X


X ОX X оX X 0X X 0X
X ОX X оо Xо X 0о
о о оX оX о о X
Вес для игрока О 2 2 2 2 О выигрывает
X 0X X 0X X оX X оX
X 0X X 06 X оX X 00
оX 0 0X X оX 0 оX X
Ничья Ничья Ничья Ничья

Рис. 8.3 Основание игрового дерева

procedure TTicTacForm.BoardValue(var best_move : Integer;


var best_value : TBoardValue;
playerl, player2 : TPlayer;
depth : Integer);
var
good_value, enemy_value : TBoardValue;
i, good_i, enemy_I : Integer;
player : TPlayer;
begin
// Если достигли большой глубины, то результат неизвестен.
if (depth >= SkillLevel) then begin
best_value := bvUnknown,-
exit;
end; (

// Если поле заполнено, то мы знаем, как будем действовать.


player := Winner;
if (playeroplNone) then
Ji Деревья решений

begin
// Преобразование значения выигравшего р! в значение игрока playerl.
if (player = playerl) then
best_value := bvWin
else if (player = player2) then
best_value :VbvLose
else
best_value := bvDraw; •'-.,:'
exit;
end;
// Исследуются все разрешенные ходы. , .
good_I := -1;
good_value := bvTooBig; //Больше, чем возможно.
for i := 1 to NUM_SQUARES do
begin
// Если ход разрешен, то он исследуется.
if (Boardfi] - plNone) then
begin
// Какое значение это даст противнику?
// Ход. :
',;-.
Board!i] :=.playerl;
BoardValue(enemy_i,enemy_value,player2,playerl,depth + 1) ;
//Отмена хода.
Board[i] := plNone;
// Меньше ли это значение для противника, чем предыдущее?
if (enemy_value < good_value) then
begin
good_i := i;
gobd_value := enemy_value;
//Если противник проиграет, то это лучший вариант хода.
if (good_value <= bvLose) then break;
end;
end; // Конец if (Boardli]:= None) then...
end; //Конец for i:=l to NUM_SQUARES do.
// Перевести значение противника в наше.
if (good_value = bvWin) then
// Противник выиграл, мы проиграли.
best_value := bvbose
else if (enemy_value = bvLose) then
// Противник проиграл, мы. выиграли.
best^value := bvWin
else
// Ничья или неизвестно для обоих игроков.
best_value .•= gpod_value; ;'
best_move := good_i; •
end;
Поиск в игровых деревьях

Программа TicTac использует процедуру BoardValue для игры в крестики-


нолики. Большая часть кода программы обеспечивает взаимодействие с пользо-
вателем, рисует игровое поле, позволяет пользователю указывать нужный квад-
рат, устанавливать опции и т.д. Команды в меню Options (Опции) позволяют
играть либо крестиками, либо ноликами и устанавливать уровень мастерства про-
граммы (максимальная глубина рекурсии).

Оптимизация поиска в деревьях решений


Если бы минимаксная стратегия была бы единственным инструментом для
исследования игровых деревьев, то перебор больших деревьев был бы довольно
сложным. Такие игры, как шахматы, настолько сложны, что программа может
перебрать максимум несколько уровней дерева. К счастью, существует несколь-
ко приемов, которые вы можете использовать для поиска в больших игровых
деревьях.
Предварительное вычисление начальных ходов
Во-первых, программа может запомнить некоторые начальные ходы, выбран-
ные экспертами игры. Например, задано, что программа игры в крестики-нолики
должна делать первый ход в центральную клетку. Это определяет первую ветвь
игрового дерева, поэтому программа может не учитывать другие пути, которые не
включают эту первую ветвь. В результате дерево игры в крестики-нолики умень-
шится в 9 раз.
Фактически, программа не должна перебирать дерево до тех пор, пока против-
ник не сделал ход. В этом случае и компьютер, и его противник уже выбрали ветви,
поэтому дерево становится намного меньше и будет содержать менее 7! = 5040 пу-
тей. Вычислив заранее всего один ход, вы сокращаете размер игрового дерева от
почти четверти миллиона до 5040 путей.
Точно так же можно записать ответы на первые ходы, если противник начи-
нает игру. Пользователь имеет девять вариантов первого хода, поэтому вы долж-
ны записать девять ответных ходов. Теперь программе не придется проводить
поиск по дереву, пока противник не сделает два хода, а компьютер - один. В этом
случае игровое дерево содержит менее чем 6! = 720 путей. Записано всего девять
ходов, а размер игрового дерева сильно уменьшился. Это еще один пример про-
странственно-временного компромисса. Использование дополнительных объе-
мов памяти для хранения нескольких ходов очень сокращает время, необходимое
для поиска в игровом дереве. В программе TicTac предусмотрено 10 заранее вы-
численных ходов: один для первого хода и девять - для ответного, если против-
ник ходит первым.
Коммерческие шахматные программы тоже начинают с заранее определенных
ходов и ответов, рекомендованных опытными шахматистами. Эти программы мо-
гут делать первые ходы очень быстро. Как только все предусмотренные заранее
ходы будут исчерпаны, программа начинает перебирать игровое дерево, поэтому
далее ходы становятся медленнее.
Деревья решений
Определение важных позиций
Другой способ улучшить поиск в игровых деревьях - указать важные шабло-
ны. После идентификации одного из этих шаблонов программа может предпри-
нять определенное действие или изменить способ поиска в дереве игры.
Во время игры в шахматы игроки часто выстраивают фигуры так, чтобы они
защищали другие фигуры. Если противник захватывает фигуру, игрок может зах-
ватить одну из фигур противника. Часто это взятие позволяет противнику захва-
тывать другую фигуру, что приводит к серии обменов.
Некоторые программы ищут возможные последовательности обменов. Если
программа распознает один из вариантов обмена, то она временно изменяет мак-
симальную глубину, на которую просматривается дерево, чтобы исследовать пос-
ледовательность обменов до самого конца. Это позволяет программе решать, бу-
дет ли обмен выгодным. Если обмен все же происходит, количество оставшихся
фигур становится меньше и поиск в дереве игры упрощается.
Некоторые шахматные программы также ищут шаблоны типа ходов ладьей,
ходов, которые угрожают нескольким фигурам противника одновременно, ходов,
которые угрожают королю противника или ферзю и т.д.
Эвристика
В более сложных играх, чем крестики-нолики, практически невозможно пере-
брать даже крошечную долю игрового дерева. В этих случаях вы должны исполь-
зовать различные эвристики. Эвристика - это алгоритм или эмпирическое прави-
ло, которое вероятно, но не обязательно дает хороший результат.
Например, в шахматах обычной эвристикой является «усиление преимуще-
ства». Когда у противника меньше сильных фигур и одинаковое с вами число ос-
тальных, то следует идти на размен при каждой возможности. Например, если вы
можете захватить коня, но потеряете своего коня при обмене, то вы должны обме-
няться. Сокращая число оставшихся фигур, вы уменьшаете дерево решений и уве-
личиваете относительное преимущество в силе. Эта стратегия не гарантирует, что
вы выиграете игру, но увеличивает ваши возможности. Другое эвристическое пра-
вило, используемое во многих стратегических играх, - присвоение различным час-
тям игрового поля разных значений. В шахматах значение ближайших к центру
поля квадратов выше, потому что фигуры в этих позициях могут атаковать фигу-
ры на большей части поля. Когда процедура BoardValue вычисляет значение по-
зиции на поле, она может присвоить большее значение фигурам, которые стоят на
этих ключевых квадратах.

Поиск нестандартных решений


Некоторые методы поиска в игровых деревьях неприменимы для большинства
общих деревьев решений. Многие из этих деревьев не учитывают принципы чере-
дующихся ходов игроков, поэтому минимаксный метод и предварительное высчи-
тывание ходов не имеет смысла. Последующие разделы посвящены методам, ко-
торые вы можете использовать для поиска в других видах деревьев решений.
Поиск нестандартных решений
Ветви и границы
Метод ветвей и границ (brunch-and-bound technique) является одним из мето-
дов упрощения деревьев решений таким образом, чтобы не рассматривать все вет-
ви дерева. Общая стратегия состоит в том, чтобы отслеживать границы уже обна-
руженных и возможных решений. Если вы достигаете точки, где лучшее решение
на данный момент эффективнее, чем лучшее возможное решение в нижних вет-
вях, вы можете проигнорировать все пути ниже данного узла.
Например, вы имеете 100 млн долларов, которые нужно вложить в несколько
возможных инвестиций. Каждое из этих вложений имеет различную стоимость
и различный ожидаемый доход. Вы должны решить, как потратить деньги, чтобы
получить максимальную прибыль.
Задача такого типа называется задачей формирования портфеля (knapsack
problem). У вас есть несколько позиций (инвестиций), которые должны поместить-
ся в портфеле с фиксированным размером (100 млн долларов). Каждая позиция
имеет некоторую стоимость (деньги) и значение (тоже деньги). Необходимо най-
ти набор позиций, которые помещаются в портфеле и дают максимально возмож-
ное значение.
Вы можете смоделировать эту задачу с помощью дерева решений. Каждый узел
в дереве соответствует определенным комбинациям позиций, помещенных в пор-
тфель. Каждая ветвь — это принятое решение о том, поместить элемент в портфель
или извлечь его оттуда. Левая ветвь в первом узле соответствует расходу денег на
первое вложение. Правая ветвь представляет отказ в первом вложении. На рис. 8.4
показано дерево решений для четырех возможных вложений.

Рис. 8.4. Дерево решений для инвестиций

Дерево решений для этой задачи представляет собой полное двоичное дерево,
глубина которого равна числу инвестиций. Каждая вершина соответствует полно-
му набору инвестиций.
Размер этого дерева очень быстро растет с увеличением числа инвестиций. Для
10 возможных инвестиций дерево содержит 2'°= 1024 листа. Для 20 инвестиций
дерево будет иметь более миллиона листьев. Полный поиск по такому дереву еще
|< Деревья решений

допустим, но при дальнейшем увеличении числа возможных инвестиций размер


дерева станет очень большим.
Чтобы использовать метод ветвей и границ, создайте массив, который будет
отслеживать позиции из наилучшего найденного до сих пор решения. При иници-
ализации массив должен быть пуст. Используйте переменную для отслеживания
значения этого решения. Вначале эта переменная может иметь небольшое значе-
ние, чтобы первое же найденное реальное решение было лучше исходного.
Если во время поиска вы вдруг достигнете точки, где рассматриваемое реше-
ние не может быть достаточно хорошим, чтобы конкурировать с текущим луч-
шим решением, то можно прекратить исследование этого пути. То же самое отно-
сится к ситуации, когда в какой-то точке выбранные позиции имеют значение
более 100 млн.
В качестве конкретного примера предположим, что вы можете вложить деньги
в любую из отраслей, приведенных в табл. 8.1. На рис. 8.4 показано соответствую-
щее дерево. Некоторые из этих инвестиционных пакетов выходят за рамки усло-
вия задачи. Крайний левый путь, например, предполагает потратить 178 млн для
всех четырех вариантов.

Таблица 8.1. Возможные инвестиции


Инвестиция Стоимость (млн) Прибыль (млн)
А 45 10
В 52 13
С 46 8
D 35 ^ 4

Предположим, что вы начали перебор дерева, изображенного на рис. 8.4. Вы


увидели, что можете потратить 97 млн на сделках А и В при прибыли 23 млн. Это
соответствует четвертому листу слева на рис. 8.4.
Продолжив поиск, можно дойти до второго узла, обозначенного как С на рис. 8.4.
Это соответствует инвестиционным пакетам, которые включают сделку А, не вклю-
чают сделку В, и могут включать или не включать сделки С и D. В этой точке пакет
уже стоит 45 млн для сделки А и дает прибыль 10 млн долларов.
Единственные оставшиеся сделки - это С и D. Вместе они могут улучшить
решение на 12 млн. Значение текущего решения равно 10 млн, поэтому лучшее
возможное решение ниже этого узла стоит почти 22 млн. Это меньше уже найден-
ного решения на 23 млн, поэтому не следует продолжать рассматривать этот путь.
По мере продвижения программы по дереву ей не нужно постоянно проверять,
является ли частное рассматриваемое решение лучше, чем наилучшее найденное
до сих пор. Если частное решение лучше, то лучше будет и самый правый узел
внизу от него. Этот узел представляет собой ту же самую комбинацию позиций,
что и частное решение, поскольку все остальные позиции в данном случае исклю-
чены. Следовательно, программе необходимо искать лучшее решение только тог-
да, когда она достигает листа.
Поиск нестандартных решений
Фактически любой лист, которого достигает программа, всегда является улуч-
шенным решением. Если бы это было не так, то ветвь, на которой находится лист,
была бы отсечена, когда программа рассматривала родительский узел. В этой точ-
ке перемещение к листу уменьшит цену невыбранных позиций до нуля. Если зна-
чение решения не больше, чем лучшее решение на данный момент, проверка ниж-
ней границы остановит продвижение программы к листу. Используя этот факт,
программа может модифицировать лучшее решение, когда достигает листа.
Следующий код использует проверку верхней и нижней границ для реализа-
ции алгоритма ветвей и границ.
type
Tit em = record
Cost : Integer;
Profit : Integer;
end;
TItemArray = array [1..1000000] of TItem;
PItemArray = "TltemArray;
TBoolArray = array [1..1000000] of Boolean;
PBoolArray = Л ТВоо1Аггау;
TBandBForm = class(TForm)
// Код опущен.. .

private
Numltems : Integer;
Items : PItemArray;
AllowedCost : Integer;
/ / Переменные поиска.
NodesVisited : Longint;
UnassignedProfit : Integer; // Общее число необъявленных доходов.
BestSolution : PBoolArray; // True для элементов лучшего решения.
BestCost : Integer;
BestProfit : Integer;
TestSolution : PBoolArray; // True для элементов исследуемого
// решения.
TestCost : Integer; // Стоимость исследуемого решения.
TestProfit : Integer; // Доход от рассматриваемого решения.
// Код опущен...

end;
// Инициализация тестовых значений и начало исчерпывающего поиска или
// перебора методом ветвей и границ.
procedure TBandBForm.Search(b_and_b : Boolean);
var
i : Integer;
Деревья решений
begin
NodesVisited := 0;
BestProfit := 0;
BestCost := 0;
TestProfit •:= 0;
TestCost := 0;
UnassignedProfit := 0;
for i := 1 to NumIterns do
UnassignedProfit := UnassignedProfit+Items[i].Profit;
// Начало перебора с первого элемента.
if (b_and_b) then
BranchAndBound(1)
else
ExhaustiveSearch(l);
end;
// Выполнение перебора методом ветвей и границ,
// начиная с указанного элемента.
procedure TBandBForm.BranchAndBound(item_num : Integer);
var
i : Integer;
begin
NodesVisited := NodesVisited+1;
// Если это лист, то он должен быть лучшим решением, чем решение,
// которое имеется на данный момент, или он должен был быть
// отрезан раньше.
if (item_num > NumIterns) then
begin
// Сохранение улучшенного решения.
for i := 1 to NumIterns do
BestSolution[i] := TestSolutionfi];
BestProfit := TestProfit;
BestCost := TestCost;
exit ;
end;
// В противном случае продолжаем исследовать ветви к дочерним узлам.
// Сначала пробуем включить этот элемент в расход, чтобы убедиться,
// что он вписывается в границу стоимости.
if (TestCost + Items[item_num].Cost <= AllowedCost) then
begin
// Добавляем элемент в исследуемое решение.
TestSolution[item_num] := True;
TestCost := TestCost+Items[item_num].Cost;
TestProfit := TestProfit + Items[item_num].Profit,-
UnassignedProfit := UnassignedProfit - Items[item_num].Profit;
// Рекурсивно определяем, какой результат может получиться.
.BranchAndBound(item_num+l);
// Удаление элемента из исследуемого решения.'
TestSolution[item_num] := False;
JToHCK нестандартных решений
TestCost := TestCost - Items[item_num].Cost;
TestProfit := TestProfit - Items[item_num].Profit;
UnassignedProfit := UnassignedProfit + Items[item_num].Profit;
end;
// Пытаемся исключить элемент. Если оставшиеся элементы
// имеют'достаточную прибыль для построения пути по этой ветви
// вниз, то мы достигли нижней границы.
UnassignedProfit := UnassignedProfit-Items[item_num].Profit;
if (TestProfit + UnassignedProfit > BestProfit) then
BranchAndBound(i t em_num + 1) ;
UnassignedProfit := UnassignedProfit + Items[item_num].Profit;
end;
Программа BandB использует полный перебор и метод ветвей и границ, чтобы
решить задачу формирования Портфеля. Введите минимальную и максимальную
стоимость и значения, которые вы хотите назначить позициям, и число позиций,
которое требуется создать. Затем нажмите кнопку Make Data (Создать данные),
и программа сгенерирует элементы.
Затем при помощи группы переключателей внизу формы выберите алгоритм
перебора. Когда вы нажимаете кнопку Go (Начать), программа при помощи выб-
ранного вами метода найдет лучшее решение. Далее она выведет на экран это реше-
ние, общее число узлов в дереве и число узлов, которые были исследованы.
На рис. 8.5 изображено окно программы BandB после решения задачи о фор-
мировании портфеля с двадцатью элементами. В данном случае алгоритм ветвей
и границ нашел лучшее решение после исследования всего 1613 из более 2 млн узлов
дерева. Перед тем как запустить исчерпывающий перебор дерева для 20 элементов,
попробуйте запустить примеры меньшего размера. На компьютере, где установлен
процессор Pentium с тактовой частотой 90 МГц, поиск решения задачи формирова-
ния портфеля для 20 позиций методом полного перебора занял более 30 с.

Values Solution
Profit . j* .Cost . Profit

e.v/ .:,s:; ,;.:. IB 9


8 9 14 3
7 14 : 10
i 1 70, 7
4 3' 32 8 ;
5 2 -• 21 8
S 8 68 Э
S .vl — ; 69 ' . г
2 3
Allowed cost [Ш 4 10
0 .4
Г Exhaustive Search 0 7
2 8 т|
<? Branch And Bound
Best Cost: 35
Я Best Profit 38

Рис. 8.5 Окно программы BandB


Деревья решений
Перебор методом ветвей и границ исследует гораздо меньше узлов, чем пол-
ный перебор. Дерево решений для задачи о формировании портфеля с 20 элемен-
тами содержит 2 097 151 узел. В то время как полный перебор всегда исследует все
узлы, метод ветвей и границ может перебрать только примерно 1 600 узлов.
Число исследуемых узлов методом ветвей и границ зависит от точных значе-
ний данных. Если стоимость элемента большая, то в правильном решении окажет-
ся немного элементов. Как только эти элементы добавляются в исследуемое реше-
ние, оставшиеся элементы уже не вписываются в статью расходов, поэтому большая
часть дерева будет отрезана.
С другой стороны, если элементы имеют низкую стоимость, многие из них
смогут поместиться в правильном решении, поэтому программа должна исследо-
вать множество допустимых комбинаций. В табл. 8.2 приведено количество узлов,
проверенное программой BandB в серии тестов при различной стоимости позиций.
Программа случайно генерировала 20 элементов, а общая допустимая стоимость
решения равна 100.

Таблица 8.2. Число исследуемых узлов при полном переборе


и переборе по методу ветвей и границ
Средняя стоимость элемента Полный перебор Ветви и границы
60 2.097.151 203
50 2.097.151 520
40 2.097.151 1322
30 2.097.151 4269
20 2.097.151 13.286
10 2.097.151 40.589

Эвристика
Иногда даже алгоритм ветвей и границ не может полностью перебрать дерево
решения. Дерево для задачи о формировании портфеля с 65 элементами содержит
более 7 * 1019 узлов. Если алгоритм ветвей и границ перебирает только десятую
часть процента этих узлов, а компьютер проверяет-миллион узлов в секунду, то
потребовалось бы более 2 млн лет, чтобы решить эту задачу. В задачах, где алго-
ритм ветвей и границ работает недостаточно быстро, можно использовать эврис-
тику (heuristic).
Если качество решения не критично, то приемлемым считается результат, дан-
ный эвристикой. В некоторых случаях вы не можете знать входные данные с абсо-
лютной точностью. Тогда хорошее эвристическое решение может иметь такую же
силу, как и лучшее теоретическое решение.
В предыдущем примере метод ветвей и границ использовался для выбора инве-
стиционных комбинаций. Однако вложения могут быть рискованными, и точные
результаты заранее чаще всего неизвестны. Вы не можете знать точную прибыль
или даже стоимость некоторых инвестиций. В этом случае эффективное эвристи-
ческое решение может быть столь же надежно, как и лучшее точно вычисленное
решение.
Поиск нестандартных решений
В этом разделе рассматривается эвристика, которая используется для решения
многих сложных задач. Программа Неиг демонстрирует каждый из эвристических
подходов. Кроме того, она позволяет сравнить эвристику с полным перебором и ме-
тодом ветвей и границ. Введите информацию в области Parameters (Параметры),
чтобы задать параметры создаваемых данных. Выберите алгоритмы, которые вы
хотите протестировать, и щелкните по кнопке Go. Программа отображает общую
стоимость и прибыль для наилучшего решения, найденного каждым из выбран-
ных алгоритмов. Она также сортирует решения по максимальному полученному
доходу и отображает время работы каждого алгоритма. Используйте метод ветвей
и границ только для небольших задач, а метод полного перебора только для задач
еще меньшего объема.
На рис. 8.6 изображено окно программы Неиг после решения задачи портфеля
с 30 элементами. В данном тесте ни один эвристический метод не нашел лучшего
возможного решения, хотя некоторые найденные решения достаточно хороши.

Rank Ptofit Cost, Time


Мй Max: Г" Exhaustive Search

1 196 200 0,07


|
|Н1 ||30"
W Hil Climbing 152 200 0,00
• Лгок"
P/ Least Cost 165 195 0.00
AJowed cost I2UO
[7 Balanced Profit 133 1ЭЭ 0,00

F Random 146 200 0,00

(7 Fixed 1 130 200 0,01

f? Rxed2: 190 200 0,00

17 No Change 1 130 200 0.01

R No Change 2 163 194 0.00

|7 Simulated Annealing 190 200 0,06

Рис. 8.6. Окно программы Hear

Восхождение на холм
Эвристический метод восхождения на холм (hill climbing) вносит изменения
в текущее решение, продвигая его максимально близко к цели. Этот процесс назы-
вается восхождением на холм, потому что он похож на то, как заблудившийся путе-
шественник пытается ночью добраться до вершины горы. Даже если уже слишком
темно, чтобы разглядеть что-то вдали, он может попробовать достигнуть вершины
горы, постоянно двигаясь вверх.
Конечно, существует вероятность, что путник остановится на вершине мень-
шего холма и не доберется до пика. Эта проблема существует и при использовании
данного эвристического метода. Алгоритм может найти решение, которое кажется
локально приемлемым, но не будет лучшим возможным решением.
В задаче формирования портфеля инвестиций цель состоит в том, чтобы вы-
брать набор позиций с общей стоимостью не более допустимого предела; а общая
прибыль максимальна. Эвристика восхождения на холм для этой задачи выбирает
Деревья решений
позицию, которая дает максимальную прибыль на каждом шаге. При этом реше-
ние будет все лучше соответствовать цели - получению максимальной прибыли.
Программа сначала добавляет к решению позицию с максимальной прибылью.
Затем добавляется следующая позиция с максимальной прибылью, если при этом
полная цена еще остается в допустимых пределах. Она присоединяет позиции с мак-
симальной прибылью до тех пор, пока не будет исчерпан лимит стоимости.
Для списка инвестиций из табл. 8.3 программа сначала выбирает сделку А, по-
тому что она имеет самую большую прибыль - 9 млн долларов. Затем выбирается
сделка С, потому что она имеет самую большую прибыль из оставшихся - 8 млн.
В этой точке из допустимых 100 млн потрачено уже 93 млн, и программа больше
не может выбирать какие-либо сделки. Решение, вычисленное с помощью этой эв-
ристики, включает элементы А и С, стоит 93 млн и дает прибыль в 17 млн.
Эвристика восхождения на холм заполняет портфель очень быстро. Если эле-
менты изначально сортируются в порядке уменьшения прибыли, то сложность
этого алгоритма будет порядка O(N). Программа просто перемещается по списку,
добавляя каждую позицию, пока не будет исчерпан лимит средств. Если список не
отсортирован, то сложность этого алгоритма составляет всего лишь O(N2). Это
намного лучше, чем O(2N) шагов, необходимых для полного перебора всех узлов
дерева. Для 20 позиций эта эвристика использует около 400 шагов, метод ветвей
и границ - несколько тысяч, а полный перебор - более чем 2 млн.

Таблица 8.3. Возможные инвестиции


Инвестиция Стоимость Отдача Прибыль
А 63 72 9
В 35 42 7
С 30 38 8
D 27 34 7
Е 23 26 3

// Перебор дерева с использованием эвристики восхождения на холм.


procedure THeurForm.HillClimbing(node : Integer);
var
i, j, big_value, big_j : Integer;
begin
// Неоднократное исследование списка в поисках элементов
// с максимальной прибылью, удовлетворяющих границам стоимости.
for i := 1 to Numltems do
begin
big_value := 0;
big_j := -1;
for j := 1 to Numltems do
// Проверяем, нет ли данного элемента в решении.
if ((not BestSolutiontj]) and
(big_value < Items[j].Profit) and
(BestCost + Items[j].Cost <= AllowedCost))
fOMCK нестандэртных
then begin
big_value := Items[j].Profit;
big_j := j;
end;
// Остановка, если больше ни один элемент не может быть включен
// в решение.
if (big_j<0) then break;
// Добавление выделенного элемента в решение. ,
BestCost := BestCost + Items[big_j].Cost;
BestSolution[big_j] := True;
BestProfit := BestProfit + Items[big_j].Profit;
end; // Конец for i:=l to Numltems do...
end;

Метод наименьшей стоимости


Стратегия, которая в некотором смысле является противоположностью мето-
ду восхождения на холм, называется методом минимальной стоимости (least cost).
Вместо того чтобы на каждом шаге приближать решение максимально близко
к цели, можно попробовать уменьшить стоимость решения. В примере с форми-
рованием портфеля инвестиций на каждом шаге к решению добавляется позиция
с минимальной стоимостью.
Данная стратегия будет помещать в решение максимально возможное число по-
зиций. Это хорошо работает в случае, если все позиции имеют примерно одинако-
вую стоимость. Но если дорогая сделка приносит большую прибыль, эта стратегия
может пропустить выпавший шанс, давая не лучший из возможных результатов.
Для инвестиций, показанных в табл. 8.3, стратегия минимальной стоимости
начинает с того, что сначала добавляет к решению сделку Е стоимостью 23 млн.
Затем она выбирает позицию D стоимостью 27 млн и С стоимостью 30 млн. В этой
точке алгоритм уже потратил 80 из 100 млн лимита и не может больше сделать ни
одного вложения.
Полученное решение стоит 80 млн и дает прибыль 18 млн. Это на миллион луч-
ше, чем решение, которое дает эвристика восхождения на холм, но алгоритм мини-
мальной стоимости не всегда работает эффективнее, чем алгоритм восхождения
на холм. Какой из методов даст лучшее решение, зависит от конкретных данных.
Структура программ, реализующих эвристики минимальной стоимости и эв-
ристики восхождения на холм, почти идентична. Единственная разница заключа-
ется в выборе следующей позиции, которая добавляется к имеющемуся решению.
Метод минимальной стоимости вместо позиции с максимальной прибылью выби-
рает позицию, которая имеет самую низкую стоимость. Поскольку эти два метода
очень похожи, сложность их одинакова. Если позиции должным образом отсорти-
рованы, оба алгоритма имеют сложность порядка O(N). При случайном располо-
жении позиций их сложность составит порядка O(N2).
Поскольку код Delphi для этих двух методов практически идентичный, ниже
приводятся только строки, в которых происходит выбор очередной позиции.
II Деревья решений
if ((not BestSolution[j]) and
(small_cost > Items[j].Cost) and
(BestCost+Items[j].Cost <= AllowedCost))-
then begin
small_cost := Items[j].Cost;
small_j := j;
end;

Сбалансированная прибыль
Стратегия восхождения на холм не учитывает стоимости добавляемых пози-
ций. Она выбирает позиции с максимальной прибылью, даже если они имеют боль-
шую стоимость. Стратегия минимальной стоимости не берет в расчет приносимую
позицией прибыль. Она выбирает элементы с небольшими затратами, даже если
они имеют маленькую прибыль.
Эвристика сбалансированной прибыли (balanced profit) сравнивает как при-
быль, так и стоимость позиций, чтобы определить, какие позиции необходимо выб-
рать. На каждом шаге эвристика выбирает элемент с самым большим отношением
прибыли к стоимости.
В табл. 8.4 приведены те же значения, что и в табл. 8.3, но с дополнительным
столбцом отношения прибыль/стоимость. При этом подходе вначале выбирается
позиция С, потому что она имеет самое высокое отношение - 0,27. Затем добавля-
ется D с отношением 0,26 и В с отношением 0,20. В этой точке потрачено 92 млн из
100 млн, и в решение больше нельзя добавить ни одной позиции.

Таблица 8.4. Возможные инвестиции с отношением прибыль/стоимость


Инвестиция Стоимость Отдача Прибыль Прибыль/стоимость
А 63 72 9 0,14
В 35 42 7 0,20
С 30 38 8 0,27
D 27 34 7 0,26
Е 23 26 3 ' 0,13

Это решение имеет стоимость 92 млн и дает прибыль в 22 млн. Это на 4 млн
лучше, чем решение, найденное с помощью метода минимальной стоимости и на
5 млн лучше, чем решение, найденное эвристикой восхождения на холм. Более того,
полученное решение вообще будет наилучшим из всех возможных, что подтвердят
результаты поиска полным перебором или методом ветвей и границ. Однако сба-
лансированная прибыль - это все же эвристика, поэтому не всегда отыскивает луч-
шее возможное решение. Она часто находит лучшие решения, чем методы восхож-
дения на холм и минимальной стоимости, но это случается не всегда.
Структура программы, реализующей эвристику сбалансированной прибыли,
почти идентична структуре программ восхождения на холм и минимальной сто-
имости. Единственная разница заключается в способе выбора следующей позиции,
которая добавляется к решению.
Поиск нестандартных решений
test_ratio := Items [j ] .Profit/Items [j ] .Cost,-
if ((not BestSolution[j]) and
(good_ratio < test_ratio) and
(BestCost + Items[j].Cost <= AllowedCost))
then begin
good_ratio := test_ratio;
good_j := j;
end;

Случайный поиск
Случайный поиск (random search) выполняется в соответствии со своим назва-
нием. На каждом шаге алгоритм добавляет случайно выбранную позицию, кото-
рая удовлетворяет границам стоимости. Этот вид перебора также называется ме-
тодом Монте-Карло или моделированием Монте-Карло.
Поскольку случайно выбранное решение вряд ли окажется наилучшим, то для
получения приемлемого результата необходимо повторить поиск несколько раз.
Хотя может казаться, что вероятность нахождения хорошего решения очень мала,
использование этого метода иногда приносит удивительно хорошие результаты.
В зависимости от исходных данных и числа проверенных случайных решений, эта
эвристика часто работает лучше, чем методы восхождения на холм или минималь-
ной стоимости.
Преимущество случайного поиска состоит также и в том, что этот метод лёгок
в понимании и реализации. Иногда трудно представить, как реализовать для кон-
кретной задачи метод восхождения на холм, минимальной стоимости или приве-
денной прибыли. Но всегда легко генерировать решения наугад. Даже для реше-
ния крайне сложных задач случайный поиск является наиболее простым методом.
Процедура RandomSearch в программе Heur для добавления к решению слу-
чайной позиции использует функцию AddToSolution. Эта функция возвращает
значение True, если может найти элемент, который удовлетворяет допустимой сто-
имости, и False в обратном случае. Подпрограмма RandomSearch вызывает про-
цедуру AddToSolut ion до тех пор, пока нельзя будет добавить ни одной позиции.
// Добавление случайного элемента к исследуемому решению. Возвращает
// true в случае успеха, false, если больше нельзя добавить элементы.
function THeurForm.AddToSolution : Boolean;
var
num_left, j , selection : Integer;
begin
// Сколько элементов еще можно добавить в решение, чтобы они
// уместились в пределах границ стоимости.
num_lert := 0;
for j := 1 to Numltems do
if ((not TestSolution[j]) and
(TestCost + Items[j].Cost <= AllowedCost)) then
num_left := num_left + 1;
// Если достигли границ стоимости, то программа останавливается.
Result := (num_left > 0);
if (not Result) then exit;
f
Деревья решений
// Определение одного элемента, который случайно удовлетворяет
// стоимости.
selection := Random(num_left - 1) + 1;
// Нахождение выбранного элемента.
for j : = 1 to NumIterns do
if ((not TestSolutiontj]) and
(TestCost + Items[j].Cost <= AllowedCost)) then
begin
selection := selection - 1;
if (selection < 1) then break;
end;
TestProfit := TestProfit + Items[j].Profit;
TestCost := TestCost + Items[j].Cost;
TestSolution[j] := Int-
end;
// Перебор дерева случайным способом.
procedure THeurForm.RandomSearch(node : Integer);
var
num_trials, trial, i : Integer;
begin
// Делает несколько испытаний и сохраняет лучшее.
num_trials := Numltems;
for trial := 1 to num_trials do
begin
// Производит случайный выбор до тех пор, пока не исчерпан
// лимит средств.
while (AddToSolution) do ;
// Лучше ли полученное решение, чем предыдущее.
if (TestProfit > BestProfit) then
begin
BestProfit := TestProfit;
BestCost := TestCost;
for i := 1 to Numltems do
BestSolutionti] := TestSolutionti];
end;
// Сброс исследуемого решения для следующего испытания.
TestProfit := 0;
TestCost := 0;
for i := 1 to Numltems do
TestSolution[i] := False;
end; // Конец for trial:= 1 to num_trials do...
end;
i
Последовательное приближение
Другая стратегия состоит в том, чтобы начать со случайного решения, а затем
производить последовательное приближение (incremental improvement). Начав
Поиск нестандартных решений ||
с произвольно сгенерированного решения, программа делает случайный выбор.
Если новое решение является улучшением предыдущего, программа закрепляет
изменение и продолжает проверку других позиций. Если изменение не улучшает
решение, программа отказывается от него и делает новую попытку.
Особенно просто реализовать метод последовательного приближения для за-
дачи формирования портфеля инвестиций. Программа всего-навсего выбирает
случайную позицию из пробного решения и удаляет ее из текущего. Затем она слу-
чайным образом добавляет в решение позиции до тех пор, пока не будет исчерпан
лимит средств. Если удаленная позиция имела очень высокую стоимость, то на ее
место программа может добавить несколько позиций.
Как и случайный поиск, эта эвристика проста для понимания и реализации.
Для решения сложной задачи бывает нелегко создать алгоритмы восхождения на
холм, минимальной стоимости и приведенной прибыли, но довольно просто напи-
сать эвристический алгоритм последовательного приближения.
Момент остановки
Существует несколько хороших способов определить момент, когда необхо-
димо прекратить случайные изменения. Например, допускается выполнять фик-
сированное число изменений. Для задачи из N-элементов можно выполнить N или
N2 случайных изменений и затем остановить выполнение программы.
В программе Неиг этот подход реализован в процедуре MakeChangesFixed.
Она выполняет определенное количество случайных изменений на множестве раз-
личных исследуемых решений.
// Одновременное изменение k элементов, чтобы улучшить исследуемое
// решение.
// Выполненить num_trials испытаний, сделав num_changes изменений
// для каждого.
procedure THeurForm.MakeChangesFixedfk, num_trials, num_changes : Integer) ;
var
trial, change, i, removal : Integer;
begin
for trial := 1 to num_trials do
begin
// Определение случайного исследуемого решения, с которого
// необходимо начать.
while (AddtoSolution) do ;
// Начинаем работать с этим решением как с экспериментальным
// решением.
TrialProfit := TestProfit;
TrialCost := TestCost;
for i := 1 to NumIterns do
TrialSolution[i] := TestSolution[i] ;
for change := 1 to num_changes do
begin
// Удаление k случайных элементов.
for removal := 1 to k do
RemoveFromSolution;
Деревья решений
// Добавление случайных элементов, пока они помещаются
// в пределах границы стоимости.
while (AddtoSolution) do ;
// Если решение улучшается, эксперимент сохраняется.
// В противном случае восстанавливаются - исходные значения.
if (TestProfit > TrialProfit) then
begin
// Сохранение улучшения.
TrialProfit := TestProfit;
TrialCost := TestCost;
for i := 1 to Numltems do
TrialSolution[i] := TestSolution[i] ;
end else begin
// Восстановление исходных значений.
TestProfit := TrialProfit;
TestCost := TrialCost;
for i := 1 to Numltems do
TestSolution[i]:= TrialSolution[i] ;
end;
end; // Конец for change:= 1 to num_changes do...
// Если данное решение лучше решения на этот момент,
// сохраняем его.
if (TrialProfit > BestProfit) then
begin
BestProfit := TrialProfit;
BestCost := TrialCost;
for i := 1 to .Numltems do
BestSolution[i] := TrialSolutionti] ;
end;
// Сброс исследуемого решения для следующего испытания.
TestProfit := 0;
TestCost := 0;
for i := 1 to Numltems do
TestSolution[i] := False;
end; // Конец for trial:= 1 to num_trials do...
end;
// Удаление случайных элементов из исследуемого решения.
procedure THeurForm.RemoveFromSolution;
var
num, j, selection : Integer;
begin
// Сколько элементов в решении. if*,.
num := 0;
for j := 1 to Numltems do
if (TestSolutiontj]) then
num : = num + 1;
if (num < 1) then exit;
// Случайный выбор одного из элементов.
Selection := Random(num) + 1;
Поиск нестандартных решений
// Нахождение случайно выбранного элемента.
for j := 1 to NumIterns do
' ; If (TestSolutiontJ]) then
begin
selection := selection - 1; '.
if (selection < 1) then break;
end;
// Удаление элемента из решения.
Test-Profit := TestProfit.Items[j] .Profit;
TestCost := TestCost .Items [j ] .Cost,-
TestSolutiontJ] := False,- ,
end;
Программа Heur вызывает процедуру MakeChangesFixed двумя способами.
Процедура Fixedl использует MakeChangesFixed для выполнения N испыта-
ний. В течение каждого испытания она пытается улучшить испытательное реше-
ние, заменяя один элемент решения/Она повторяет эту замену 2, * N раз.
Процедура Fixed2 использует MakeChangesFixed ДЛЯ выполнения одного
испытания. В течение испытания она пытается улучшить пробное решение, заме-
няя два элемента решения. Она повторяет эту замену 10 * N раз в поиске хорошего
решения.
// Перебор дерева с помощью эвристики возрастающего улучшения,
// которая производит N испытаний с 2 * N замен одного элемента.
procedure THeurform.Fixedl(node .: Integer);
begin
MakeChangesFixed(1,NumItems,2 * Numltems);
end;
// Перебор дерева с помощью эвристики возрастающего улучшения, .
// которая производит 1 испытание с 10 * N замен двух элементов.
procedure THeurform.Fixed2(node : Integer);
begin
MakeChangesFixed(2,1,10 * Numltems);
end;
Другая стратегия состоит в том, чтобы делать изменения до тех пор, пока не-
сколько последовательных изменений будут приносить улучшения. Для решения
задачи из N элементов программа может вносить изменения, пока не будет улуч-
шения для N изменений в строке.
Процедура MakeChangesNoChange программы Heur реализует эту стратегию.
Она выполняет испытания, пока определенное число последовательных попыток
не даст никаких улучшений. Для каждой попытки подпрограмма вносит случай-
ные изменения в пробное решение, пока после определенного числа изменений не
наступит каких-либо улучшений.
// Одновременное изменение k элементов для улучшения испытательного
// решения.
// Испытания повторяются до тех пор, пока не достигнем max_bad_trials

\ : '"" .. - - ,
ШННИ11! Деревья решений
// испытаний в строке без улучшения.

// В течение каждого испытания вносятся случайные изменения, пока не


// попробуем Max_non_changes изменений в строке без улучшения.
procedure THeurform.MakeChangesNoChange(k, max_bad_trials,
max_noh_changes : Integer);
var
i, removal : Integer;
bad_trials : Integer; // Число последовательных неэффективных
// испытаний.
non_changes : Integer; // Число последовательных неэффективных
// изменений.
begin
// Испытания повторяются до тех пор, пока не достигнем .
// max_bad_trials испытаний в строке без улучшения.
bad^trials := 0;
repeat
// Нахождение случайного исследуемого решения, с которого надо
// начать.
while (AddtoSolution) do ,-
// Начинаем работать с этим решением как с испытываемым.
TrialProfit := TestProfit;
TrialCost := TestCost;
for i := 1 to NumIterns do
TrialSolutionti] := TestSolution[i] ;
// Повторяем до тех пор, пока не попробуем Max_non_changes
// изменений в строке без улучшения.
non_changes := 0;
while (non_changes < max_non_changes) do
begin
// Удаление k случайных элементов.
for removal := 1 to k do
RemoveFromSolution;
••//• Добавление случайных элементов до тех пор, пока не
// исчерпан лимит средств.
while (AddtoSolution) do ;
// Если это улучшает решение, сохраняем его.
// В противном случае восстанавливаем исходные значения.
if (TestProfit > TrialProfit) then
begin
// Сохраняем улучшение.
TrialProfit := TestProfit;
TrialCost := TestCost;
for i := 1 to Numltems do
TrialSolutionti] := TestSolution[i];
non_changes := 0; // Это хорошее изменение.
end else begin
// Восстановление исходных значений.
S^^
TestProfit := TrialProfit;
TestCost := TrialCost;
for i := 1 to NumIterns do
TestSolution[i] := TrialSolution[i] ;
non_changes := non_changes + 1 ; // Плохое изменение.
end;
end; // Конец while попытки внесения изменений.
// Если испытание является наилучшим решением на данный момент,
// сохраняем его.
if (TrialProfit > BestProfit) then
begin.
BestProfit := TrialProfit;
BestCost := TrialCost;
for i := 1 to NumIterns do
BestSolutionti] := TrialSolutionfi] ;
bad_trials := 0; // Это хорошее испытание.
end elsen
bad_trials := bad_trials+l; // Плохое испытание.
// Сброс исследуемого решения для следующего испытания.
TestProfit := 0;
TestCost := 0;"
for i :=1 to Numltems do
TestSolutionti] := False;
until (bad_trials >= max_bad_trials) ;
end;
Программа Heur использует процедуру MakeChangeNoChange двумя спо-
собами. Процедура NoChange§l производит испытания до тех пор, пока N по-
следовательных испытаний не дают улучшений. В течение каждого испытания
она произвольно заменяет один элемент до тех пор, пока не будут улучшения при
N последовательных заменах.
Процедура NoChanges2 производит, одно испытание. В это время она произ-
вольно заменяет два элемента до тех пор, пока не будет улучшения при N последо-
вательных заменах.
procedure THeurform.NoChangel(node : Integer);
begin
MakeChangesNoChange(1,Numltems,Numltems);
end;
procedure THeurform.NoChange2(node : Integer);
begin
MakeChangesNoChange(2,0,Numltems);
end;

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

Таблица 8.5. Возможные инвестиции

Инвестиция Стоимость Отдача Прибыль


А 47 56 9
В 43 51 8
С 35 40 5
D 32 39 7
Е 31 37 6

Предположим, что алгоритм случайно выбирает позиции А и В в качестве на-


чального решения. Его стоимость будет равна 90 млн долларов, оно принесет при-
быль в 17 млн.
Если программа удаляет или А, или В, то решение будет иметь достаточно
большую стоимость, поэтому программа сможет добавить только одну новую по-
зицию. Поскольку позиции А и В имеют самую большую прибыль, замена их дру-
гой позицией уменьшит полную прибыль. Случайное удаление одной позиции из
этого решения никогда не приведет к улучшению.
Лучшее решение содержит позиции С, D и Е. Его полная стоимость равна
98 млн долларов, а полная прибыль - 18 млн. Чтобы найти это решение, алгоритм
должен удалить из решения сразу обе позиции А и В и затем добавить на их место
новые.
Такие решения - когда небольшие изменения не могут улучшить решения -
называются локальным оптимумом (local optimum). Есть два способа, при приме-
нении которых программа не остановится в локальном оптимуме, а будет искать
глобальный оптимум (global optimum).
Во-первых, вы можете изменить программу так, чтобы она удаляла из реше-
ния несколько позиций. Если программа удалит две случайно выбранные позиции,
она сможет найти правильное решение для данного примера. Однако для задач
большего размера удалить две позиции обычно недостаточно. Программа должна
будет удалить три, четыре или даже большее количество позиций.
Более простой способ состоит в том, чтобы выполнить большее количество
испытаний с различными исходными решениями. Некоторые из начальных реше-
ний могут привести к локальным оптимумам, но одно из них позволит достичь
глобального оптимума.
Программа Неиг демонстрирует четыре стратегии последовательных прибли-
жений. Метод Fixedl (Фиксированный 1) производит N испытаний. В течение
каждого испытания он выбирает случайное решение и пробует улучшить решение
в 2 * N раз, случайно заменяя один элемент.
Метод Fixed2 (Фиксированный 2) производит всего одно испытание. Он вы-
бирает случайное решение и пробует улучшить его в 10 * N раз, случайно заменяя
два элемента.
Поиск нестандартных решений
Эвристика NoChangesl (Без изменений 1) выполняет испытания до тех пор,
пока в N последовательных испытаниях не будет улучшения. В течение каждого,
программа выбирает случайное решение и затем пробует улучшить его, случайно
заменяя один элемент до тех пор, пока в течение N последовательных изменений
не будет никаких улучшений.
Эвристика NoChanges2 (Без изменений 2) выполняет одно испытание. При
этом программа выбирает случайное решение и пытается улучшить его, произ-
вольным образом удаляя по две позиции до тех пор, пока в течение N последова-
тельных изменений не будет никаких улучшений. .
Названия и описания эвристических методов обобщены в табл. 8.6.

Таблица 8.6. Стратегии последовательных приближений


Название Число испытаний Число изменений Число удаляемых
элементов
Fixed 1 N 2*N 1
Fixed 2 1 10 *N 2
No changes 1 Пока не будет улуч- Пока не будет улучшения 1
шения за N испытаний за N изменений
No changes 2 1 Пока не будет улучшения 2
за N изменений

Метод отжига
Метод отжига (simulated annealing) заимствован из термодинамики. При от-
жиге металл нагревается до высокой температуры. Молекулы в горячем металле
совершают быстрые колебания. Если металл медленно охлаждать, то молекулы
начинают выстраиваться в линии, образуя кристаллы. При этом молекулы посте-
пенно переходят в состояние с минимальной энергией.
Когда металл остывает, соседние кристаллы сливаются друг с другом. Молеку-
лы одного кристалла временно покидают свои позиции с минимальной энергией
и соединяются с молекулами другого кристалла. Энергия получившегося кристал-
ла большего размера будет меньше, чем сумма энергий двух исходных кристаллов.
Если металл охлаждать достаточно медленно, кристаллы станут просто огромны-
ми. Конечное расположение молекул имеет очень низкую суммарную энергию, по-
этому металл становится очень прочным.
Начиная с состояния с высокой энергией, молекулы в конечном счете достига-
ют состояния с низкой энергией. На пути к окончательному положению они про-
ходят через множество локальных минимумов энергии. Каждая комбинация кри-
сталлов представляет локальный минимум. Довести кристалл до минимального
энергетического состояния можно только временным разрушением структуры
меньших кристаллов, увеличивая тем самым энергию системы, в результате чего
кристаллы могут объединиться.
Метод отжига использует аналогичный способ для поиска лучшего решения за-
дачи. Когда программа ищет путь решения, она может «застрять» в локальном оп-
тимуме. Чтобы избежать этого, она время от времени вносит в решение случайные
|i Деревья решений
изменения, даже если очередной вариант и не приводит к мгновенному улучше-
нию результата. Это позволит программе выйти из локального оптимума и отыс-
кать лучшее решение. Если изменение не приводит к лучшему решению, она обя-
зательно отменит это изменение.
Чтобы программа не зациклилась на этих модификациях, алгоритм через ка-
кое-то время изменяет вероятность внесения случайных изменений. Вероятность
внесения одного изменения равна Р = 1 / Ехр(Е / (k * Т)), где Е - количество
«энергии», добавленной к системе, k - константа, выбранная в зависимости от рода
задачи и Т - переменная, соответствующая «температуре».
Сначала величина Т должна быть довольно высокой, поэтому величина Р = 1 /
Ехр(Е / (k * Т)) также достаточно велика. Иначе случайных изменений не будет.
Через какое-то время значение Т постепенно снижается, и вероятность случайных
изменений уменьшается. Как только процесс достиг точки, в которой никакие из-
менения не смогут улучшить решение и значение Т станет настолько мало, что
случайные изменения будут очень редкими, алгоритм закончит работу.
Для задачи формирования портфеля инвестиций энергия Е - это величина, на
которую сокращается прибыль в результате изменения. Например, если вы удаля-
ете позицию, прибыль которой равна 10 млн долларов, и заменяете ее позицией,
имеющей прибыль в 7 млн, добавленная к системе энергия будет равна 3.
Обратите внимание, что если величина Е велика, то вероятность Р = 1 / Ехр(Е /
(k * Т)) небольшая,- поэтому вероятность больших изменений ниже.
Метод отжига в программе Неиг устанавливает константу k = 1. Значение Т
изначально задается равным 0,75, умноженным на разность между максимальной
и минимальной прибылью от возможных вариантов инвестиций. После выполне-
ния определенного числа случайных изменений температура Т уменьшается умно-
жением на постоянную 0,8.
// Одновременное изменение k элементов для улучшения испытательного
// решения.
// Если это дает улучшение, сохраняем изменение.
// В противном случае сохраняем изменение с некоторой вероятностью.
// После max_slips таких безусловных сохранений уменьшаем t.
// После max_unchanged несохраненных изменений останавливаемся.
procedure THeurform.AnnealTrial(k, max_unchanged, max_slips : Integer);
const
TFACTOR = 0.8;
var
i, removal, num_unchanged, num_slips : Integer;
max_profit, min_profit : Integer;
save_changes, slipped : Boolean;
, t : Single;
begin
// Нахождение максимальной и минимальной прибыли.
max_profit := Items[1].Profit;
min_profit := max_profit;
for i := 2 to Numltems do
begin
if (max_profit < Items[i].Profit) then
max_profit := Items[i].Profit;
if (min_profit > Items[i].Profit) then
min_profit := Items[i].Profit;
end;
// Инициализация t.
t := 0.75 * (max_profit - min_profit);
// Нахождение случайного исследуемого решения, с которого следует
// начать.
while (AddtoSolution) do ;
// Начинаем с ним работать как с лучшим решением.
BestProfit := TestProfit;
BestCost := TestCost;
for i := 1 to NumIterns do
BestSolution[i] := TestSolution[i];
// Повторяем до тех пор, пока не исследуем max_unchanged
// без улучшения.
num_slips := 0;
num_unchanged := 0;
while (num_urichanged < max_unchanged) do
begin
// Удаляем k случайных элементов.
for removal := 1 to k do
RemoveFromSolution;
// Добавляем элементы до тех пор, пока не исчерпан лимит средств.
while (AddtoSolution) do ;
// Есть ли улучшение.
if (TestProfit > BestProfit) then
begin
save_changes := True;
slipped := False;
end else if (TestProfit = BestProfit) then
begin
// Формула вероятности даст 1.
save_changes : = False,-
slipped := False;
end else begin
// Должны ли мы сохранить изменение?
save_changes := (Random < Ехр( (TestProfit-BestProfit)Xt) ) ;
slipped := save_changes;
end;
// Если мы должны сохранить решение.
if (save_changes) then
begin
// Сохраняем новое решение.
BestProfit := TestProfit;
|! Деревья решений
BestCost := TestCost;
for i := 1 to NumIterns do
BestSolution[i] := TestSolution[i]';
num_unchanged := 0; //Мы сохранили изменение.
end else begin
// Восстанавливаем предыдущее решение.
TestProfit := BestProfit;
TestCost := BestCost;
for i := 1 to Numltems do
TestSolution[i] := BestSolution[i];
num_unchanged := num_unchanged+l;
end;
// Если ошиблись (сохранили решение, которое не лучше
// предыдущего).
if (slipped) then
begin
num_slips := num_slips+l;
if (num_slips > max_slips) then
begin
num_slips := 0;
t := t * TFACTOR;
num_unchanged := 0;
end;
end;
end; // Попробуем еще раз.
end;

Сравнение эвристических методов


Различные эвристические методы ведут себя по-разному в различных задачах.
Для решения задачи о формировании портфеля инвестиций эвристика сбалансиро-
ванной прибыли достаточно хороша, учитывая ее простоту. Стратегия последова-
тельного приближения обычно работает так же хорошо, но требует гораздо больше-
го времени. Для других задач наилучшей может быть какая-либо другая эвристика,
в том числе из тех, которые не обсуждались в этой главе.
Эвристика выполняется гораздо быстрее, чем методы полного перебора и вет-
вей и границ. Некоторые эвристические подходы, такие как восхождение на холм,
минимальная стоимость и сбалансированная прибыль, работают чрезвычайно бы-
стро, потому что рассматривают только одно возможное решение/Они занимают
так мало времени, что имеет смысл выполнить их все по очереди и затем выбрать
наилучшее из трех полученных решений. Конечно, нет гарантий, что это решение
будет наилучшим из всех возможных, но оно будет достаточно хорошим.

Сложные задачи
Многие задачи, отличающиеся от задачи формирования портфеля, решить го-
раздо труднее. Некоторые из них имеют сложность неизвестной степени. Другими
словами, нет алгоритмов решения проблем, сложность которых оценивается как
O(NC) для любой константы С, и даже O(N1000).
Сложные задачи
В следующих разделах кратко описаны некоторые из этих задач. В общих чер-
тах объясняется, чем сложна каждая задача и насколько большим может быть де-
рево для ее решения. На некоторых из них вы можете попробовать проверить ал-
горитмы ветвей и границ и некоторые эвристические методы.

Задача о выполнимости
Дано логическое утверждение, например (А и не В) или С. Требуется опреде-
лить, есть ли какое-либо сочетание истинных и ложных значений переменных А,
В и С, при котором выражение принимает истинное значение. В данном примере
легко увидеть, что выражение будет истинным, если А = True, В = False и С = False.
В случаях более сложных выражений, включающих сотни переменных, сложно
сказать, может ли утверждение быть истинным.
Используя метод, сходный с методом для решения задачи формирования порт-
феля, вы можете построить дерево решений для задачи о выполнимости. Каждая
ветвь дерева представляет решение о присвоении переменной значения True или
False. Например, левая ветвь, выходящая из корня, соответствует установке зна-
чения первой переменной в True.
Если в логическом выражении N переменных, решающее дерево будет двоич-
ным деревом глубины N + 1. Это дерево имеет 2N листов, каждый из которых пред-
ставляет собой различное соотношение значений переменных.
В задаче о формировании портфеля можно было использовать метод ветвей
и границ, чтобы не перебирать все дерево. Однако для задачи о выполнимости вы-
ражение либо истинно, либо ложно. Оно не дает вам частное решение, при кото-
ром можно отрезать некоторые ветви от дерева.
Для поиска приближенных решений задачи о выполнимости нельзя использо-
вать эвристику. Любые соотношения значений, выработанные эвристикой, будут
делать выражение или истинным, или ложным. А в логике нет такого понятия, как
приближенное решение.
Так как метод ветвей и границ в данном случае неэффективен, а эвристика бес-
полезна, найти решение задачи о выполнимости вообще весьма сложно. Подобную
задачу можно решить только в случае ее небольшого размера.

Задача о разбиении
Задан набор элементов со значениями X,, Х2, ..., XN. Требуется определить,
можно ли разделить элементы на две группы, так чтобы общее значение элемен-
тов в каждой группе было одинаковым? Например, если элементы имеют значе-
ния 3, 4, 5 и 6, вы можете разделить их на группы {3, 6} и {4, 5}. При этом в обеих
группах общее значение равно 9.
Чтобы смоделировать эту задачу как дерево, допустим, что ветвям соответству-
ет размещение элемента в'одной из этих двух групп. Левая ветвь, исходящая из
корневого узла, соответствует размещению первого элемента в первой группе.
Правая ветвь соответствует размещению первого элемента во второй группе.
Если имеется N элементов, дерево решений будет бинарным глубиной N + 1.
Оно содержит 2N листов и 2 Т + ' узлов. Каждый лист соответствует общему распре-
делению элементов в этих двух группах.
Для решения этой задачи можно использовать метод ветвей и границ. Когда
вы исследуете частные решения, следите за разностью общих значений двух групп.
Если вы достигаете ветви, где размещение всех оставшихся элементов в меньшей
группе не сможет сделать ее, по крайней мере, равной большой группе по размеру,
эту ветвь можно не отслеживать.
Как и в случае задачи о выполнимости, для задачи о разбиении (partition
problem) нельзя получить приближенное решение. Распределение элементов со-
здает две группы, в которых суммарное значение элементов не обязательно бу-
дет одинаковым. Это означает, что для решения такой задачи неприменимы эв-
ристики, использовавшиеся в задаче о формировании портфеля.
Задачу о разбиении можно обобщить. Дан набор элементов со значениями Х1(
Х2 XN. Требуется найти способ распределения этих элементов, при котором об-
щие значения элементов двух групп будут как можно ближе друг к другу.
Получить точное решение этой задачи труднее, чем исходной задачи о разбие-
нии. Если бы существовал простой способ решения задачи в общем случае, то он
подошел бы и для решения исходной задачи. Вы просто находите группы, общие
значения элементов которых максимально близки, и смотрите, не равны ли эти
значения.
Чтобы не перебирать все дерево, вы можете использовать методику ветвей и гра-
ниц, как и в предыдущем примере. Также можно применить эвристику, чтобы найти
приближенные решения. Один из способов заключается в том, чтобы исследовать
элементы в порядке уменьшения значений, помещая следующий элемент в мень-
шую из двух групп. Кроме того, можно использовать случайный перебор, метод по-
следовательного приближения или метод отжига для поиска приближенного ре-
шения этого общего случая задачи.
Задача поиска Гамильтонова пути
Например, задана сеть. Гомильтонов путь (Hamiltonian path) - это путь, кото-
рый проходит через каждый узел в сети ровно один раз и возвращается к исходной
точке. На рис. 8.7 показана небольшая сеть с Гамиль-
тоновым путем, обозначенным жирной линией.
Задача поиска Гамильтонова пути заключается
в следующем: если задана сеть, существует ли для
нее Гамильтонов путь?
Поскольку Гамильтонов путь обходит каждый
узел сети, не нужно определять, какие узлы в него
попадают. Вы должны установить только порядок
посещения узлов.
Чтобы
Рис. 8.7. Гамильтонов путь смоделировать эту проблему при помощи
дерева, допустим, что ветви соответствуют выбору
следующего узла. Корневой узел имеет N ветвей, соответствующих началу пути
в каждом из N узлов. Узлы ниже корня имеют по N - 1 ветвей, по одной для каждого
из оставшихся N - 1 узлов. Узлы на следующем уровне дерева имеют по N - 2 ветвей
и т.д. Основание дерева содержит N! листов, соответствующих N! возможных по-
рядков посещения узлов. Всего дерево содержит O(N!) узлов.
Сложные задачи

Каждый лист соответствует Гамильтонову пути, но число листьев может быть


разным для различных сетей. Если два узла в сети не соединены, ветвей дерева,
соответствующих перемещению от одного узла к другому, не будет. Это сокращает
число путей через дерево и количество листьев.
Как и в случае задач о выполнимости и разбиении, нельзя генерировать час-
тичные или приближенные решения. Путь может либо являться Гамильтоно-
вым, либо нет. Это означает, что методы ветвей и границ и эвристика не помогут
найти Гамильтонов путь. Усугубляет положение и то, что дерево решений поис-
ка Гамильтонова пути вмещает O(N!) узлов. Это гораздо больше, чем O(2N) уз-
лов, содержащихся в деревьях решений задач о выполнимости и разбиении.
Например, 220 приблизительно равно 1 * 106, в то время как 20! приблизительно
равно 2,4 * 1018 - в миллион раз больше. Поскольку подобное дерево огромно, с его
помощью можно решать только самые небольшие задачи Гамильтонова пути.

Задача коммивояжера
Задача коммивояжера (travelling salesman problem) тесно связана с проблемой
поиска Гамильтонова пути. Она формулируется так: найти самый короткий Га-
мильтонов путь для сети.
Эта задача соотносится с задачей поиска Гамильтонова пути, как и обобщен-
ный случай задачи о разбиении с простой задачей о разбиении. В первом варианте
возникает вопрос, есть ли решение. Во втором - каково лучшее приближенное ре-
шение. Если есть простое решение второй задачи, то можно использовать его для
решения первой.
Как правило, задача коммивояжера возникает только для сетей, которые содер-
жат множество Гамильтоновых путей. В типичном примере коммивояжер должен
посетить нескольких клиентов, используя самый короткий маршрут. В обычной
сети улиц любые две точки будут связаны между собой, поэтому любой порядок
расположения точек является Гамильтоновым путем. Задача состоит в том, чтобы
найти самый короткий.
Как и в задаче поиска Гамильтонова пути, дерево решений для этой задачи со-
держит O(N!) узлов. На обобщенную задачу о разбиении рассматриваемый при-
мер похож тем, что для отсечения ветвей дерева и ускорения поиска решения задач
средних размеров можно использовать метод
ветвей и границ.
Для решения данной задачи существует
несколько хороших эвристик последователь-
ных приближений. 2-х оптимумная стратегия
улучшения исследует пары связей пути. Про-
грамма проверяет, станет ли маршрут короче,
если удалить пару отрезков и заменить их
двумя новыми, так чтобы маршрут при этом
оставался замкнутым. На рис. 8.8 показано, Яис. в.8. Улучшение Гамильтонова
с
как изменится путь, если связи X, и Х 2 заме- "У™ помощью 2-х оптимумов
нить связями YJ и Y2. Подобные стратегии последовательных приближений рас-
сматривают одновременную замену трех или большего количества связей.
Деревья решений
Как правило, этот метод выполняется многократно или до тех пор, пока не бу-
дут проверены все возможные пары отрезков пути. Когда дальнейшие шаги уже не
приводят к улучшениям, вы сохраняете результат и начинаете работу с различны-
ми случайно выбранными начальными путями. После проверки большого числа
различных исходных маршрутов, вероятно, будет найден достаточно короткий путь.

Задача о пожарных депо


Задана сеть, некоторое число F и расстояние D. Существует ли способ разме-
щения F пожарных депо в узлах сети таким образом, чтобы все узлы были от бли-
жайшей пожарной конторы не дальше, чем на расстоянии D?
Вы можете смоделировать задачу о пожарных депо (firehouse problem) с помо-
щью дерева решений, в котором каждая ветвь определяет местоположение соответ-
ствующего пожарного депо в сети. Корневой узел будет иметь N ветвей, соответству-
ющих размещению первого депо в одном из N узлов сети. Узлы на следующем
уровне будут иметь по N - 1 ветвей, соответствующих размещению второго депо
в одном из оставшихся N - 1 узлов. Если имеется F пожарных депо, то дерево будет
иметь глубину F и содержать O(NF) узлов. В дереве будет N * (N - 1) * ... * (N - F)
листов, соответствующих возможным местам расположения пожарных депо.
Подобно задачам о выполнимости, разбиении и поиске Гамильтонова пути,
в этом примере нужно дать положительный или отрицательный ответ на вопрос.
Это означает, что нельзя применять частные или приближенные решения при ис-
следовании дерева решений.
Можно использовать определенный тип методики ветвей и границ, если зара-
нее известно, какие места размещения контор не приведут к хорошим решениям.
Например, вы ничего не получите, помещая новое пожарное депо между двумя
другими, расположенными близко друг от друга. Если все узлы в пределах рас-
стояния D от нового депо находятся также в пределах расстояния D от другого
депо, значит, новое депо нужно поместить в какое-то иное место. Однако подоб-
ные вычисления потребуют большого количества времени, и задача все еще оста-
ется очень сложной.
Так же, как и для задачи разбиения и поиска Гамильтонова пути, для задачи
о пожарных депо существует обобщенный случай. В обобщенном случае вопрос
звучит следующим образом: если задана сеть и некоторое число F, в каких узлах
сети нужно разместить F депо, чтобы наибольшее расстояние между любым узлом
и пожарным депо было минимальным?
Как и и в обобщенных случаях других задач, вы можете использовать методы
ветвей и границ и эвристику, чтобы найти частные и приближенные решения. Это
немного упрощает исследование дерева решения. Если решающее дерево все же
очень велико, вы можете, по крайней мере, найти приближенные решения, даже
если они и не являются наилучшими.

Краткая характеристика сложных задач


Читая предыдущие разделы, вы, наверное, заметили, что для многих задач есть
парные варианты. Первый вариант задачи задает вопрос: «Есть ли решение задачи,
_____ Резюме
удовлетворяющее определенным условиям?» Второй уточняет: «Каково лучшее
решение этой проблемы?» \
Обе задачи при этом используют одинаковые деревья решений. В первой зада-
че исследуется дерево, пока не будет найдено какое-либо решение. Поскольку эти
задачи не имеют частных или приближенных решений, нельзя применить метод
ветвей и границ или эвристику для уменьшения объема работы. Обычно только
несколько путей в дереве приводят к решению, поэтому решение этих задач -
очень длительный и сложный процесс.
При решении более обобщенной задачи можно использовать частные решения,
чтобы применить метод ветвей и границ. Это не облегчает поиск наилучшего ре-
шения, поэтому не поможет получить точное решение для частной задачи. Напри-
мер, самый короткий Гамильтонов путь через сеть найти сложнее, чем любой Га-
мильтонов путь через ту же сеть.
С другой стороны, эти вопросы обычно относятся к различным входным дан-
ным. Если сеть сильно разрежена, то вообще трудно сказать, существует ли такой
путь. Вопрос о кратчайшем Гамильтоновом пути актуален в случае, когда сеть
плотная и имеется много таких путей. При таких условиях частные решения най-
ти легко и метод ветвей и границ сильно упростит решение задачи.

Резюме
Вы можете использовать деревья решений для моделирования сложных задач.
Нахождение лучшего решения соответствует нахождению лучшего пути через де-
рево. К сожалению, для многих интересных задач деревья решений имеют огром-
ный размер, поэтому решить такие задачи методом полного перебора очень сложно.
С помощью метода ветвей и границ можно сокращать множество ветвей неко-
торых деревьев, что позволяет точно решать задачи большой сложности.
Однако в решении самых больших задач не поможет даже применение этого
метода. В таких случаях следует использовать эвристику, чтобы получить прибли-
женные решения. Используя методы типа случайного поиска и последовательных
приближений, можно найти приемлемое решение, даже если неизвестно, будет ли
оно наилучшим.
Глава 9. Сортировка
Сортировка (sorting) - один из наиболее сложных для изучения алгоритмов. Во-
первых, сортировка - это общая задача многих компьютерных приложений.
Практически любой список данных ценнее, когда он отсортирован по какому-либо
определенному принципу. Часто требуется, чтобы данные были упорядочены не-
сколькими различными способами.
Во-вторых, многие алгоритмы сортировки являются интересными примерами
программирования. Они демонстрируют важные методы, такие как частное упо-
рядочение, рекурсия, объединение списков и сохранение двоичных деревьев в мас-
сивах.
У каждого алгоритма сортировки есть свои преимущества и недостатки. Про-
изводительность различных алгоритмов зависит от типа данных, начального рас-
положения, размера и значений. Важно выбрать тот алгоритм, который лучше все-
го подходит для решения конкретной задачи.
И наконец, сортировка - одна из немногих задач с точными теоретическими
границами производительности. Любой алгоритм сортировки, который использует
сравнения, занимает, по крайней мере, O(N * logN) времени. Некоторые алгоритмы
действительно имеют такую сложность, то есть являются оптимальными в отноше-
нии порядка сложности. Существует даже несколько алгоритмов, которые осуще-
ствляют сортировку не с помощью сравнений, и при этом работают быстрее, чем
0(N * logN).

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

Таблицы указателей
При сортировке элементов программа перестраивает их в некоторую струк-
туру данных. Скорость этого процесса зависит от типа элементов. Перемещение
целого числа на новую позицию в массиве может произойти намного быстрее,
чем перемещение структуры данных, определяемой пользователем. Если струк-
тура данных является записью, содержащей тысячи байт данных, то перемещение
_ ____.. Общие принципы
одного элемента может занять достаточно много времени. Гораздо проще сорти-
ровать указатели на реальные данные, копируя указатели из одной части массива
в другую.
Чтобы отсортировать массив объектов в определенном порядке, создайте мас-
сив указателей на объекты. Затем сортируйте указатели с помощью значений в со-
ответствующих записях данных. Например, предположим, что вы собираетесь от-
сортировать записи о служащих, определенные следующей структурой:
type
PEmployee = ЛТЕтр1оуее;
TEmployee = record
ID : Integer;
LastName : String[40];
, . FirstName : String[40];
// Множество других элементов.

end;

// Размещение записей.
var
EmployeeData : array [1..10000] of TEmployee;
Чтобы сортировать служащих по идентификационному номеру, создайте мас-
сив указателей на данные служащего.
var 11
IDIndex : array [1..10000] of PEmployee;
Инициализируйте массив так, чтобы первый элемент указывал на первую за-
пись данных, второй - на вторую запись данных и т.д.
for i := 1 to 10000 do IDIndex[i] := @EmployeeData[i];
Затем отсортируйте массив индексов по идентификационному номеру. Пос-
ле этого индексный элемент будет указывать на соответствующую запись дан-
ных в заданном вами порядке. Например, первой записью данных в сортирован-
ном списке будет являться Л IDIndex [ 1 ].
Чтобы сортировать данные несколькими способами, создайте несколько ин-
дексных массивов и управляйте ими по отдельности. В приведенном примере мож-
но было бы организовать отдельный индексный массив, упорядочивающий слу-
жащих по фамилии. Этот способ подобен тому, с помощью которого потоки могут
сортировать списки в различном порядке (см. главу 2). При вставке и удалении
записи необходимо отдельно обновлять каждый индексный массив.
Обратите внимание, что индексные массивы занимают дополнительную па-
мять. Если создать массив для каждого поля записи данных, то объем занимаемой
памяти более чем удвоится.

Объединение и сжатие ключей


Иногда удобнее хранить ключи списка в комбинированной или сжатой фор-
ме. Например, можно было бы объединить (combine) ключевые элементы списка.
Сортировка

Чтобы сортировать список служащих по имени и фамилии, программа может объе-


динить эти два поля, связав их в один ключ. Это значительно ускорит сравнение
элементов. Обратите внимание на различия двух кодовых фрагментов, которые
сравнивают две записи о сотрудниках.
// Использование раздельных ключей:
if ((етр1 Л .ЬавЬЫате > етр2 л .LastName) or
((empl A .LastName = emp2 A .LastName) and
(empl".FirstName > етр2 л .FirstName))) then
DoSomething; ,
// Использование объединенного ключа:
if (етр! л .CombinedName > етр2 л .СотЫпесЮате) then
DoSomething;
v
Иногда допускается сжимать (compress) ключи. Сжатые ключи занимают
меньше места, уменьшая массивы данных, что позволяет сортировать большие
списки без перерасхода памяти, ускоряет перемещение и сравнение элементов
списка.
Популярные методы сжатия строк - кодирование их целыми числами или
данными другого числового формата. Числовые типы данных занимают меньше
места, и компьютер может сравнить два числовых значения намного быстрее, чем
две строки. Конечно, обычные строковые операции не выполняют числового ко-
дирования, поэтому необходимо перевести строку в кодированную форму и об-
ратно при изменении значений.
Предположим, что нужно закодировать строки, состоящие из прописных анг-
лийских букв. Можно считать, что каждый символ - это число по основанию 27.
Основание 27 используется потому, чтобы представить 26 букв алфавита и еще
одну цифру для обозначения конца слова. Без отметки конца слова закодирован-
ная строка АА следовала бы после В, потому что АА имеет два разряда, а В - толь-
ко один.
Кодирование по модулю 27 строки из трех символов выглядит как 272 * (пер-
вый символ - А + 1) + 27 * (второй символ - А +1 ) + (третий символ - А + 1).
Если в строке меньше трех символов, используйте 0 вместо (символ - А + 1). На-
пример, код слова FOX выглядит следующим образом:
2 7 2 * (F - А + 1) + . 2 7 * (О - А + 1) + (X - А + 1) = 4 8 0 3
Код слова NO равен:
2
27 * (N - А + 1) + 27 * (О - А + 1) + ( 0 ) =• 10611 .
Обратите внимание, что 10 611 больше, чем 4 803, потому что NO > FOX.
Таким же образом вы можете кодировать строки из шести прописных букв
в длинное целое (Long Int) и строки из десяти символов в двойное число с плаваю-
щей точкой (Double). Следующие две процедуры преобразовывают строки в числа
формата Double и обратно.
Const .
STRING_BASE =27;
:
ASC_A =65; . - // ASCII код для 'А'.
Общие принципы
// Преобразование строки в тип double.

// Переменная full_len дает общую длину строки. Например,


// 'АХ' как строка из трех символов имеет общую длину full_len = 3.
function TEncodeForm.StringToDbHtxt : String;
full_len : Integer) : Double;
var
len, i : Integer;
ch : Char;
begin
len := Length(txt);
if (len > full_len) then len := full_len;
Result := 0.0;
for i := 1 to len do
begin
ch := txt[i];
Result := Result * STRING_BASE+Ord(ch) - ASC_A + 1;
end;
for i := len + 1 to full_len do
Result := Result * STRING_BASE;
end;
// Преобразование кода в строку.
function TEncodeForm.DblToString(value : Double) : String;
var
ch : Integer;
new_value : Double;
begin
Result :=
while (value > 0) do
begin
new_value := Round(value/STRING_BASE);
ch := Round(value-new_value * STRING_BASE);
if (ch<>0) then
Result := Chr(ch + ASC_A - 1) + Result;
Value := new_value;
end;
end ;
Программа Encode позволяет создавать список из случайных строк и сортиро-
вать их с помощью числового кодирования. В программе используются все воз-
можные алгоритмы кодирования, и вы можете сравнить результаты их выполне-
ния. Например, если задать длину строки, равную 10, программа сортирует список,
используя кодирование в виде строк и чисел в формате Double.
В табл. 9.1 приведено время работы программы Encode для сортировки 2000
строк различной длины на компьютере с процессором Pentium и тактовой часто-
той 133 МГц. Обратите внимание, что каждый тип кодирования дает сходные ре-
зультаты. Сортировка 2000 чисел в формате Double занимает примерно одинако-
вое время независимо от того, представляют ли они строки из 3 или 10 символов.
Сортировка
Таблица 9.1. Время для сортировки 2000 строк с помощью различных типов
кодирования
Длина строки 3 6 10 20
String 4,81 4,92 5,08 5,24

Double 0,23 0,26 0,26


' •
Longint 0,05 0,05
Integer 0,05

Можно также кодировать строки, содержащие другие символы, а не только заг-


лавные буквы. Строку из прописных букв и цифр допускается закодировать, ис-
пользуя модуль 37 вместо 27. Код буквы А будет равен 1, В - 2,..., Z - 26, 0 - 27,...
и 9 - 36. Строка АН7 будет закодирована как 372 * 1 + 37 * 8 + 35 = 1700.
При использовании больших строковых модулей самая длинная строка, кото-
рую вы можете закодировать числом типа Integer, Long или Double, будет со-
ответственно короче. По основанию 37 можно закодировать два символа в типе
Integer, пять символов в Long и в типе Double - десять символов.

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

Сортировка выбором
Сортировка выбором (selection sort) - это простой алгоритм O(N2). Его зада-
ча - искать наименьший элемент, который затем меняется местами с элементом из
начала списка. Затем находится наименьший из оставшихся элементов и меняется
местами со вторым элементом. Процесс продолжается до тех пор, пока все элемен-
ты не займут свое конечное положение.
procedure TSortForm.Selectionsort(list : PLongintArray;
min, max : Longint);
var
i, j , best_value, best_j : Longint;
begin
for i := min to max-1 do
begin
Перемешивание
// Нахождение наименьшего из оставшихся элементов.
A
best_value := l i s t [ i ] ;
best_j := i;
for j := i + 1 to max do
A
if ( l i s t [ j ] < best_value) then
begin
A
best_value := l i s t [ j ] ;
best_j := j ;
end;
// Перемещение его в нужную позицию.
A A
list [best_j] .-= l i s t [ i ] ;
A
list [i] := best_value,-
end;
end;
При поиске i-го наименьшего элемента алгоритм должен проверить каждый из
N - i оставшихся. Время выполнения алгоритма равно N + (N - 1) + (N - 2) + ... + 1
2
или O(N ).
Сортировка выбором работает достаточно хорошо со списками, где элементы
расположены случайно или в прямом порядке, но для обратно сортированных
списков производительность этого алгоритма немного хуже. Для поиска мини-
мального элемента списка сортировка выбором выполняет следующий код:
A
if ( l i s t [ j ] < best_value) then
begin
best_value := l i s t A [ j ] ;
best_j := j ;
end;
Если список отсортирован в обратном порядке, условие lisf 4 [ j ] <best_value
выполняется большую часть времени. Во время первого прохода через список оно
будет истинно для всех элементов, потому что каждый элемент меньше, чем преды-
дущий. Программа должна выполнять сравнение много раз, что приводит к некото-
рому замедлению работы алгоритма.
Это не самый быстрый алгоритм, описанный в этой главе, но он очень прост.
Его нетрудно реализовать и отладить, он также очень быстро сортирует неболь-
шие списки. Многие другие алгоритмы так сложны, что при сортировке даже очень
маленьких списков работают намного медленнее.
i •
Перемешивание
В некоторых приложениях требуется выполнять операцию, противоположную
сортировке. Задается список элементов, которые программа должна расположить
в случайном порядке. Перемешивание (unsorting) списка можно достаточно про-
сто реализовать с помощью алгоритма, немного похожего на сортировку выбором.
Для каждой позиции списка алгоритм случайным образом выбирает элемент.
При этом рассматриваются только элементы из еще не помещенных на свое мес-
то. Затем выбранный элемент меняется местами с элементом, стоящим в данной
позиции.
|| Сортировка
// Перемешивание массива.
procedure RandomizeList (list : PIntArray; min, max : Integer);
var
i, range, pos, tmp : Integer;
begin
range : = max - min + 1 ;
for i : = min to max - 1 do
begin
pos := min + Trunc (Random ( r ange) );
tmp := list* [pos] ;

list~[i] := tmp;
end;
end;
Поскольку алгоритм заполняет каждую позицию в массиве один раз, его слож-
ность составляет порядка O(N).
Вероятность появления любого элемента в любой позиции равна 1 / N. Поэто-
му алгоритм действительно приводит к случайному размещению элементов.
Результат зависит также от генератора случайных чисел. Он должен выра-
батывать только равновероятные случайные числа. Функция Delphi Random в боль-
шинстве случаев дает приемлемый результат. Вы должны убедиться, что для ини-
циализации этой функции используется оператор Randomize. В противном случае
Random будет выдавать одну и ту же последовательность псевдослучайных зна-
чений.
Обратите внимание, что для алгоритма не имеет значения, как изначально рас-
положены элементы. Если вы собираетесь неоднократно перемешивать список, нет
необходимости его предварительно сортировать.
Программа Unsort использует этот алгоритм для перемешивания сортирован-
ного списка. Введите число элементов, которые вы хотите рандомизировать, и на-
жмите кнопку Go. Программа показывает исходный отсортированный список чи-
сел и результат перемешивания.

Сортировка вставкой
Сортировка вставкой (insertion sort) - еще один алгоритм сложности О(№).
Идея состоит в том, чтобы сформировать новый сортированный список, просмат-
ривая все элементы в исходном списке в обратном порядке. Алгоритм просматри-
вает исходный список в порядке возрастания и ищет место, где необходимо вста-
вить новый элемент. Затем он помещает новый элемент в найденную позицию.
procedure TSortForm. Insertionsort (list : PLonglntArray ;
min , max : Longint ) ;
var
i, j , k, max_sorted, next_num : Longint;
begin
max_sorted := min - 1;
for i := min to max do
Сортировка вставкой
begin
// Это число, которое мы вставляем.
next_mm :=
// Где должен стоять данный элемент.
for j : = min to max_sorted do
if (listA[j] >= next_num) then break;
// Большие элементы сдвигаем вниз, чтобы освободить место для
// нового элемента.
for k := max_sorted downto j do
A
list"[k + 1] := list [k];
,
// Вставка нового элемента.
list74!]] := next_num;
. // Увеличение счетчика сортированных элементов.
max_sorted := max_sorted + 1;
end;
end;
Может оказаться, что для каждого из элементов в исходном списке алгоритму
придется проверять все уже отсортированные записи. Это случается, например,
если элементы в исходном списке были уже отсортированы. В таком случае алго-
ритм помещает каждый новый элемент в конец списка, отсортированного по воз-
растанию.
Общее количество выполняемых шагов составляет 1 + 2 + 3 + ... + (N - 1), что
равно O(N2). Это не очень эффективно по сравнению с теоретической возможной
сложностью O(N * logN) для алгоритмов сортировки сравнением. Фактически этот
алгоритм работает даже медленнее, чем другой алгоритм сложности O(N2), напри-
мер сортировка выбором.
Алгоритм сортировки вставкой тратит много времени на поиск правильной по-
зиции для нового элемента. В главе 10 описано несколько алгоритмов поиска в сор-
тированных списках. Использование алгоритма интерполяционного поиска для
нахождения положения элемента значительно ускоряет сортировку со вставкой.
Интерполяционный поиск подробно описан в главе 10, поэтому мы не будем сей-
час на нем останавливаться.

Вставка в связанных списках


Существует вариант сортировки вставкой, позволяющий упорядочивать эле-
менты не в массиве, а в связанном списке. Алгоритм ищет позицию нового элемен-
та в возрастающем связанном списке и затем помещает туда новый элемент, ис-
пользуя операции работы со связанными списками.
procedure TSortForm.LLInsertionsort (var top : PCell);
var
new_top, cell, after_me, nxt : PCell;
new_Value : Longint;
begin
Hill! Сортировка
// Построение нового списка с меткой конца.
New(new_top);
/4
New(new_top .NextCell) ;
A
new.top*.NextCell .Value := INFINITY;
A A
new.top .NextCell .NextCell := nil;
A
cell := top .NextCell;
while (cellonil) do
begin
top/4.NextCell := cell~.NextCell;
л
new_value := се!1 .Value;
// Где должен стоять элемент. ,
after_me := new_top;
A
nxt := after_me .NextCell;
A
while (nxt .Value < new_value) do
begin
after_me := nxt;
nxt := after_me".NextCell;
end;
// Вставка ячейки в,новый список.
A
after_me .NextCell := cell;
A
cel! .NextCell := nxt;
// Исследование следующей ячейки в старом списке.
Cell := topA.NextCell;
end; 1 4

// Освобождение начала старого списка.


Dispose(top);
top := new_top;
end;
Поскольку алгоритм перебирает все элементы, ему, возможно, потребуется
сравнивать элемент с каждым элементом сортированного списка. В этом наихуд-
шем случае сложность алгоритма составляет порядка O(N2).
Наилучший случай возникает, когда исходный список первоначально отсор-
тирован в обратном порядке. Тогда каждый новый рассматриваемый элемент бу-
дет меньше, чем предыдущий, поэтому алгоритм помещает его в начало сортиро-
ванного списка. При этом требуется выполнить только одну операцию сравнения
элементов, и в наилучшем случае сложность алгоритма будет порядка O(N).
В среднем случае алгоритму придется исследовать приблизительно половину
сортированного списка, чтобы найти правильное положение элемента. Поэтому
выполняется приблизительно 1 + 1 + 2 + 2 + .,. + N / 2 , или O(N2) шагов.
Сортировка вставкой в массивах выполняется гораздо быстрее, чем в связан-
ных списках. Версию для связанных списков лучше использовать, когда ваша про-
грамма уже хранит элементы в связанном списке.
Преимущество вставки при помощи связанных списков в том, что она переме-
щает только указатели на объекты, а не сами записи данных. Если элементы явля-
ются большими структурами данных, переместить указатели гораздо быстрее, чем
скопировать целые записи.
Пузырьковая сортировка

Пузырьковая сортировка
Пузырьковая сортировка (bubble sort) - это алгоритм, предназначенный для
сортировки списков, которые уже находятся в почти упорядоченном состоянии.
Если исходный список уже отсортирован, алгоритм выполняется очень быстро за
время порядка O(N). Если часть элементов находится не на своих местах, алгоритм
работает медленнее. Если элементы изначально расположены в произвольном по-
2
рядке, алгоритм выполняется за O(N ) шагов. По этой причине перед использова-
нием пузырьковой сортировки очень важно убедиться, что элементы в основном
отсортированы.
При пузырьковой сортировке список просматривается до тех пор, пока не най-
дутся два смежных элемента, которые следуют не по порядку. Они меняется мес-
тами, и процедура продолжает исследовать список. Алгоритм повторяет этот про-
цесс, пока не упорядочит все элементы.
В примере, показанном на рис. 9.1, алгоритм сначала обнаруживает, что элемен-
ты 6 и 3 следуют не по порядку, и меняет их местами. Во время следующего прохо-
да алгоритм меняет элементы 5 и 3, в следующем - 4 и 3. После еще одного прохода
алгоритм обнаруживает, что все элементы упорядочены и завершает работу.
Можно проследить за перемещениями элемента, который первоначально был
расположен ниже, чем после сортировки, например элемента 3 на рис. 9.1. Во вре-
мя каждого прохода элемент перемещается на одну позицию ближе к своему ко-
нечному положению. Элемент двигается к вершине массива, как пузырек воздуха
к поверхности воды в стакане. Этот эффект и дал название алгоритму пузырько-
вой сортировки.
Вы можете немного усовершенствовать алгоритм. Во-первых, если элемент
расположен в списке выше, чем должно быть, вы увидите изображение, отличаю-
щееся от рис. 9.1. На рис. 9.2 показано следующее: алгоритм сначала обнаружива-
ет, что элементы 6 и 3 не упорядочены, и меняет их местами. Затем он продолжает
исследовать массив и меняет элементы 6 и 4. Затем меняются местами элементы
6 и 5, и элемент 6 становится на свое место.

Рис. 9.1. «Всплытие» элемента Рис. 9.2. «Погружение» элемента


Сортировка
Во время прохода через массив сверху вниз элементы, которые должны пере-
меститься вверх, сдвигаются только на одну позицию. А элементы, которые долж-
ны двигаться вниз, перемещаются на несколько позиций. Используя этот факт,
можно существенно ускорить работу алгоритма пузырьковой сортировки. Если че-
редовать порядок прохождения через массив сверху вниз и снизу вверх, то элемен-
ты будут двигаться быстрее и в прямом, и в обратном направлениях.
Во время прохода сверху вниз в нужную позицию будет перемещен наиболь-
ший элемент, который стоит в неправильной позиции. Во время прохода сверху
вниз в нужную позицию будет перемещен наименьший элемент. Если М элемен-
тов списка расположены не на своих позициях, алгоритму потребуется не более М
проходов для того, чтобы упорядочить все данные. Если в списке N элементов каж-
дый проход алгоритма будет осуществляться за N шагов. Получается, что его об-
щая сложность равна О(М * N).
Если список изначально неупорядочен, то большая часть элементов будет рас-
пределено случайно. Число М будет сравнимо с N, поэтому время выполнения
О(М * N) становится равно O(N2).
Следующее усовершенствование - хранение элементов во временной пере-
менной, если они подвергаются множественным перестановкам. В примере, пока-
занном на рис. 9.2, элемент 6 три раза меняется местами с другими элементами.
Вместо выполнения трех отдельных перестановок, программа может сохранить
значение б во временной переменной, пока не найдет новую позицию для этого
элемента. Такой прием позволит сэкономить много шагов алгоритма, если элемен-
ты внутри массива перемещаются на большие расстояния.
И последнее усовершенствование состоит в ограничении прохода через мас-
сив. После просмотра массива последние переставленные элементы обозначают
часть списка, которая содержит неупорядоченные элементы. Например, при про-
ходе сверху вниз в правильную, позицию перемещен наибольший неупорядочен-
ный элемент. Так как перемещаемых элементов больше этого в массиве нет, алго-
ритм может начать следующий проход снизу вверх с этой позиции и здесь же
заканчивать следующие проходы сверху вниз.
Точно так же после прохода снизу вверх можно скорректировать позицию, с ко-
торой будут начинаться последующие проходы сверху вниз и заканчиваться про-
ходы снизу вверх.
Реализация алгоритма пузырьковой сортировки в Delphi использует перемен-
ные min и max для обозначения первого и последнего элемента списка, которые
могут быть неупорядочены. При проходе через список алгоритм изменяет эти пе-
ременные, чтобы указать, где произошли последние перестановки.
procedure TSortForm.Bubblesort(list : PLonglntArray;
min, max : Longint);
var
i, j, tmp, last_swap : Longint;
begin
// Повторяем до тех пор, пока не закончим.
while (min < max) do
сортировка
begin
// Всплытие.
last_swap := min - 1;
// For i := min + 1 To max.
i := min + 1;
while (i <= max) do
begin
// Нахождение "пузырька".
if (lisf[j - 1] > list-4!!]) then
begin
// Куда сдвинуть "пузырек".
tmp := listA[i - 1];
j == i;
repeat

, J == J + l;
if (j > max) then break;
until (listA[j] >= tmp) ;
lisf[j - 1] := tmp;
last_swap := j - 1;
I := j + 1;
end else
i := i + 1;
end; // Конец "всплытия"
// Обновление max.
max := last_swap - 1;
// "Погружение " .
last_swap := max + 1;
// For i := max - 1 To min Step - 1.
i : = max - 1 ;
while (i >= min) do
begin
// Нахождение "пузырька".
if (list'Ii + 1] < listA[i]) then
begin
// Куда сдвинуть "пузырек".
tmp := list^ti + 1] ;
j := i;
repeat

j = = j - 1;
if (j < min) then break;
until ( l i s t A [ j ] <= tmp) ;
list-MJ + 1] := tmp;
last_swap := j + 1;
i := j - 1;
end else
i s= i - 1; "
Сортировка
end; // Конец погружения.
// Обновление min.
min := last_swap + 1;
end; // Конец проходов снизу вверх и сверху вниз.
end;

Чтобы протестировать алгоритм пузырьковой сортировки с помощью програм-


мы Sort, выберите поле Sorted (Отсортированные) в области Initial Ordering (Пер-
воначальный порядок). Введите число элементов в поле #Unsorted (Число несор-
тированных). Когда вы нажимаете кнопку Make List (Создать список), программа
создает список. Она сортирует элементы, если выбрана опция Sorted (Сортиро-
вать), или сортирует список в обратном порядке, если помечена опция Reversed
(В обратном порядке). Затем она случайным образом меняет местами некоторые
элементы, чтобы в списке было некоторое число неупорядоченных элементов. На-
пример, если вы вводите число 10 в поле #Unsorted, программа делает неупорядо-
ченными 10 элементов.
В табл. 9.2 приведено время работы программы на компьютере с процессором
Pentium-133 для пузырьковой сортировки 20 000 элементов в зависимости от сте-
пени первоначальной упорядоченности списка. Из таблицы видно, что пузырько-
вая сортировка выполняется достаточно хорошо только тогда, когда список изна-
чально отсортирован. Алгоритм быстрой сортировки, описанный далее, может
сортировать тот же самый список из 20 000 элементов приблизительно за 0,05 с,
если элементы изначально расположены беспорядочно. Пузырьковая сортировка
может справиться с этой задачей за такое же время, если список почти полностью
отсортирован.
Несмотря на то, что пузырьковая сортировка работает медленнее, чем многие
другие алгоритмы, она все же используется. Пузырьковая сортировка обычно дает
наилучшие результаты в двух случаях: если список изначально уже почти упоря-
дочен и если программа управляет списком, который сортируется при создании,
а затем к нему добавляются новые элементы.
i
Таблица 9.2. Время пузырьковой сортировки 20 000 элементов
Уже отсортировано (%) 50 60 70 80 90 95 96 97 98 99 99,5
Время (с) 2,78 2,21 1,91 1,18 0.6J2 0,32 0,26 0,20 0,14 0,070,04

Быстрая сортировка
Быстрая сортировка (quick sort) - это рекурсивный алгоритм, который ис-
пользует подход «разделяй и властвуй». Даже если список элементов, который
нужно отсортировать, имеет некоторый минимальный размер, процедура быстрой
сортировки делит его на два подсписка, а затем рекурсивно вызывает себя для их
сортировки.
Быстрая сортировка

. Исходная версия рассматриваемого алгоритма быстрой сортировки весьма


проста. Если алгоритм вызывается для подсписка, содержащего нуль или один
элемент, подсписок уже отсортирован и процедура заканчивается. В противном
случае процедура выбирает элемент списка и использует его для разбиения спис-
ка на два подсписка. Она помещает элементы, которые меньше, чем разделяющий
элемент, в первый подсписок, а оставшиеся элементы - во второй подсписок. За-
тем она рекурсивно вызывает себя для сортировки обоих подсписков.
procedure TSortForm.Quicksort(list : PLonglntArray;
min, max : Longint);
var
med_value, hi, lo : Longint;
begin
// Если список содержит 0 или 1 элемент, заканчиваем.
if (min >= max) then exit;
// Определение разделяющего значения.
med_value := list"[min];
lo := min
hi := max
repeat // Бесконечный цикл.
// Просматриваем список от hi в поисках значения < med_value.
while (list'4[hi] >= med_value) do
begin
hi := hi - 1;
if (hi <= lo) then break;
end;
if (hi <= lo) then
begin
list"[lo] : = ' med_value;
break; .
end;
// Меняем значения lo и hi.
listA[lo] := list*[hi];
// Просматриваем список от lo в поисках значения >= med_value.
lo := lo + 1;
while (list^lo] < med_value) do
begin
lo := lo + 1;
if (lo >= hi) then break;
end;
if (lo >= hi) then
begin
lo := hi;
A
list [hi] := med_value;
break;
end;
Сортировка
// Меняем значения 1о и hi.
A
list [hi] := list*[lo];
until (False);
// Сортировка двух подсписков.
Quicksort(list,min,lo - 1);
Quicksort(list,lo + l,max);
end;
В этой версии алгоритма есть несколько важных моментов, о которых стоит
упомянуть. Во-первых, разделяющийся элемент med_value не включен ни в один
подсписок. Это означает, что в двух подсписках содержится на один элемент мень-
ше, чем в первоначальном списке. Поскольку общее количество рассматриваемых
элементов становится меньше, алгоритм в конечном счете закончит работу.
Данная версия алгоритма использует в качестве разделителя первый элемент
списка. В идеале это значение должно быть где-нибудь в середине списка, так что
два подсписка будут иметь приблизительно равный размер. Однако если элементы
изначально отсортированы, первый элемент будет наименьшим. В первый под-
список алгоритм не поместит ни одного элемента, и все элементы окажутся во
втором. Последовательность действий ал-
QuickSort( 1-2-3-4-5) горитма будет примерно такой, как пока-
зано на рис. 9.3.
В этом случае каждый вызов проце-
QuickSortO QuickSort(2-3-4-5) дуры занимает O(N) шагов для переме-
щения всех элементов во второй подсписок.
Поскольку алгоритм должен рекурсивно
QuickSort(3-4-5) вызывать себя всего N - 1 раз, сложность
его равна О(№), что не быстрее, чем у ра-
нее рассмотренных алгоритмов. Еще ху-
QuickSort(4-5)" же тот факт, что рекурсия погружается на
N - 1 уровней. Для больших списков ог-
ромная глубина рекурсии приведет к пе-
QuickSort(S) реполнению стека и аварийному завер-
шению программы.
Рис. 9.З. Быстрая сортировка Существует много способов выбора
упорядоченного списка разделительного элемента. Программа мо-
жет использовать элемент, который на данный момент находится в середине списка.
Но может случиться так, что им окажется наименьший или наибольший элемент
списка. При этом один подсписок будет намного больше другого, и в случае большо-
го количества неудачных выборов, что приведет к сложности алгоритма О(№) и вы-
зовет глубокую рекурсию.
Другой вариант состоит в том, чтобы просматривать список, вычислять сред-
нее арифметическое всех значений и использовать его как разделитель. Этот под-
ход обычно дает неплохие результаты, но требует много дополнительной работы.
Еще один проход со сложностью порядка O(N) не изменит теоретическое время
выполнения алгоритма, но снизит общую производительность.
Третья стратегия заключается в том, чтобы выбрать средний из элементов в на-
чале, конце и середине списка. Этот метод обладает значительным преимущество
в скорости, так как потребуется выбрать только три элемента. Кроме того, гаранти-
руется, что выбранный элемент не обязательно будет самым большим или самым
маленьким элементом и скорее всего окажется где-нибудь в середине списка.
И наконец, последний способ, используемый программой Sort, состоит в том,
чтобы выбрать разделительный элемент случайным образом. Возможно, подходя-
щий элемент будет получен с первой же попытки. Даже если это не так, в следую-
щий раз, когда алгоритм поделит список, вероятно, будет сделан лучший выбор.
Вероятность постоянного выпадения наихудшего случая очень мала.
Интересно, что этот метод превращает ситуацию «небольшая вероятность того,
что всегда будет плохая производительность» в ситуацию «всегда небольшая ве-
роятность плохой производительности». Попробуем пояснить это довольно запу-
танное утверждение.
Когда разделяющая точка выбирается одним из способов, описанных ранее, есть
небольшой шанс, что при определенной организации списка время выполнения бу-
дет О(№). В то время как вероятность такого начального упорядочения списка очень
мала, если вы все же столкнетесь с таким распределением элементов, время выпол-
нения алгоритма в любом случае будет O(N2). Именно это и называется «неболь-
шой вероятностью того, что всегда будет плохая производительность».
Если точка разделения выбирается случайным образом, то начальное распре-
деление элементов не влияет на работу алгоритма. Существует небольшая вероят-
ность неудачного выбора элемента, однако вероятность такого выбора каждый раз
чрезвычайно мала. Это и есть «всегда небольшая вероятность плохой производи-
тельности». Независимо от первоначаль-
ной организации списка существует очень QuiekSort(1-1-1-1-1)
маленький шанс, что время выполнения
алгоритма будет порядка O(N2).
Все же есть еще одна ситуация, кото- QuickSort(1-1-1-1)
рая может вызвать трудности при исполь-
зовании любого из вышеперечисленных
методов. Если в списке очень мало раз- QuickSort(1-1-1)
личных значений, то алгоритм при каждом
вызове будет помещать много идентич-
ных значений в один подсписок. Напри- QuickSort(1-1)
мер, если каждый элемент списка имеет
значение 1, последовательность выпол-
нения алгоритма будет такой, как показа- QuickSort(l)
но на рис. 9.4. Это приводит к большому
уровню вложенности рекурсии и дает про- Рис. 9.4. Быстрая сортировка списка,
изводительность порядка О(№). состоящего из единиц
Такая же ситуация возникает, если су-
ществует множество дубликатов некоторых значений. Если список из 10 000 эле-
ментов содержит только значения от 1 до 10, то алгоритм быстро поделит список на
подсписки, в которых будет находиться только одно значение.
Сортировка
Самый простой способ справиться с этой проблемой - просто игнорировать ее.
Если вы знаете, что данные не имеют такого распределения, то ничего изменять не
надо. Если данные имеют небольшой диапазон значений, то вам стоит рассмотреть
другой алгоритм сортировки. Алгоритмы сортировки подсчетом и блочной сорти-
ровки, описанные в этой главе чуть позже, очень эффективны для списков, где диа-
пазон значений данных невелик.
Можно внести еще одно небольшое улучшение в алгоритм быстрой сортиров-
ки. Как и многие другие более сложные алгоритмы, описанные в этой главе, дан-
ный алгоритм - не самый лучший способ для небольших списков. Например, сор-
тировка выбором выполняется быстрее при обработке небольшого количества
элементов.
Вы можете улучшить работу алгоритма быстрой сортировки, останавливая ре-
курсию перед тем, как подсписки будут пусты, и использовать сортировку выбо-
ром, чтобы завершить процесс. В табл. 9.3 приведено время выполнения програм-
мы для ускоренной сортировки миллиона элементов на компьютере с процессором
Pentium- 133, если останавливать сортировку при достижении подсписками опре-
деленного размера. В данном примере размер подсписка для остановки рекурсии
был равен 15.

Таблица 9.3. Время быстрой сортировки одного миллиона элементов


Минимальное число элементов 1 5 10 15 20 25 30
Время (с) 2,62 2,31 2,17 2,09 2,15 2,17 2,25

Следующий код демонстрирует алгоритм быстрой сортировки с описанными


изменениями:
procedure TSortFom. Quicksort (list : PLonglnt Array;
min, max : Longint) ;
var
med_value, hi, lo, i : Longint;
begin
// Если в списке менее Cutoff элементов, останавливаем рекурсию
// и начинаем сортировку выбором.
if (max - min < Cutoff) then
begin
Selectionsort (list, min, max) ;
exp-
end;
// Определение разделяющего значения.
I := min + Trunc (Random (max - min + 1));
med_value :=
// Помещаем его в начало.
listA[i] := listA[min];
lo := min;
hi : = max ;
Сортировка слиянием
repeat // Бесконечный цикл.
// Просмотр списка от hi в поисках значения < med_value.
while (listA[hi] >= med_value) do
begin
hi := hi - 1;
if (hi <= lo) then break;
end;
if (hi <= lo) then
begin
listA[lo] := med_value;
break;
end;
// Меняем значения lo и hi.
:= listen!].;
// Просмотр списка от lo в поисках значения >= med_value.
lo := lo + 1;
while (HstA[lo] < med_value) do
begin
lo := lo + 1;
if (lo >= hi) then break;
end;
if (lo >= hi) then
begin
lo := hi;
list" [hi] := med_value;
break ;
end;
// Меняем значения lo и hi.
lisfMhi] := l i s t A [ l o ] ;
until (False) ;
// Сортировка двух подсписков.
Quicksort (list,min,lo - 1);
Quicksort (list, lo + l , m a x ) ;
end;
Многие программисты выбирают именно алгоритм быстрой сортировки, по-
скольку во многих случаях он обеспечивает хорошую производительность.

Сортировка слиянием
Как и быстрая сортировка, сортировка слиянием (merge sort) - это рекурсив-
ный алгоритм. Он так же делит список на два подсписка и рекурсивно их сортирует.
Сортировка слиянием делит список пополам, чтобы сформировать два под-
списка равного размера. Затем подсписки рекурсивно сортируются и сливаются,
образуя полностью отсортированный список.
Кроме того, что процесс объединения несложно понять, это также наиболее ин-
тересная часть алгоритма. Подсписки объединяются в рабочий массив, результат
Сортировка
копируется в исходный список. При создании рабочего массива иногда возника-
ют некоторые проблемы, особенно, если размер списка велик. Программе прихо-
дится обращаться к файлу подкачки, что значительно снижает ее производитель-
ность. Работа с временным массивом также приводит к тому, что большая часть
времени уходит на копирование элементов между массивами.
Как и в случае быстрой сортировки, вы можете ускорить сортировку слияни-
ем, останавливая рекурсию, если подсписки достигают некоторого минимального
размера, после чего можно использовать сортировку выбором.
procedure TSortForm.Mergesort (list, scratch : PLongintArray;
min, max : Longint);
var
middle, il, 12, 13 : Longint;
begin
// Если список содержит не более Cutoff элементов,
// останавливаем рекурсию и используем сортировку выбором.
if (max - min < Cutoff) then
begin <
Selectionsort(list,min,max);
exit;
end;
// Рекурсивно сортируем подсписки.
middle := max div 2 + min div 2;
Mergesort(list,scratch,min,middle);
Mergesort(list,scratch,middle + l,max);
// Объединение сортированных списков.
11 := min; // Указатель на список 1.
12 := middle + 1; // Указатель на список 2.
13 := min; // Указатель на объединенный список.
while ((il <= middle), and (i2 <= max)) do
begin
if (listA[il] <= Iist/4[i2]) then
begin
scratchA[i3] := listA[il];
11 := 11 + 1;
end else begin
scratchA[i3] := listA[12];
12 := 12 + 1;
end;
13 := 13 + 1;
end;
// Очистка списка, который еще не пустой.
while (il <= middle) do
begin
A
scratch [13] :=
il := 11 + 1;
13 := 13 + 1;
end;
Пирамидальная сортировка ЦЕВВНЕХВ
while (12 <= max) do
begin
scratch*[13] := Iist"[i2];
12 := 12 + 1;
13 := 13 + 1;
end;
// Перемещение объединенного списка в исходный список.
for 13 := min to max do
A
list [13] := scratch"[13];;
end;

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


ка. В тесте на компьютере с процессором Pentium-133 сортировка слиянием заня-
ла 4,72 с для обработки миллиона элементов со значениями от 1 до 10 млн. Быст-
рая сортировка была выполнена всего за 2,75 с.
Преимущество сортировки слиянием в том, что время работы остается од-
ним и тем же для различных представлений данных и начального распределе-
ния. Если в списке имеется много дублированных значений, то быстрая сорти-
ровка имеет время работы O(N2) и входит в глубокую рекурсию. Если список
большой, то алгоритм может переполнить стек и вызвать аварийную остановку.
Поскольку сортировка слиянием всегда делит список на равные части, она ни-
когда не входит в глубокую рекурсию. Для списка из N элементов сортировка
слиянием достигает глубины рекурсии всего log(N).
В другом тесте, в котором использовался 1 млн элементов со значениями от
1 до 1000, сортировка слиянием заняла 4,67 с, то есть приблизительно такое же
время, как и сортировка элементов со значениями от 1 до 10 млн. Быстрая сорти-
ровка заняла 16,12 с. Если значения лежали между 1 и 100, сортировка слиянием
была выполнена за 4,75 с, в то время как быстрая сортировка - за 194,18 с.

Пирамидальная сортировка
Пирамидальная сортировка (heap sort) для организации элементов в списке
использует специальную структуру данных, называемую пирамидой. Подобные
алгоритмы очень интересны и полезны при реализации очередей с приоритетом.
Этот раздел начинается с описания пирамид и способов их реализации в Del-
phi. Затем рассказывается, как с помощью пирамиды построить эффективные оче-
реди с приоритетом. Написать код алгоритма пирамидальной сортировки очень
просто, располагая средствами для управления пирамидами и очередями с при-
оритетом.

Пирамиды
Пирамида (heap) — полное двоичное дерево, в котором каждый узел больше,
чем его два дочерних. Это ничего не говорит об отношениях между дочерними уз-
лами. Хотя оба узла должны быть меньше, чем родительский, любой из них может
быть больше другого. На рис. 9.5 показана небольшая пирамида.
|j Сортировка

Рис. 9.5. Пирамида

Поскольку каждый узел больше, чем его два дочерних, корневой узел - всегда
самый большой в пирамиде. Это делает пирамиду удобной структурой данных для
реализации очередей с приоритетом. Если вам понадобится элемент очереди с са-
мым высоким приоритетом, он всегда находится на вершине пирамиды.
Поскольку пирамида является полным двоичным деревом, для ее сохранения
в массиве вы можете использовать методы, описанные в главе 6. Поместите корне-
вой узел на первую позицию массива. Дочерние узлы узла i расположите в позициях
2 * 1 и 2 * 1 + 1 . Н а рис. 9.6 показана пирамида с рис. 9.5, сохраненная в массиве.

Индекс 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
Значение 15 14 13 9 10 12 4 3 1 8 6 7 11 2 5

Рис. 9.6. Представление пирамиды в виде массива

Чтобы понять, как формируется пирамида, обратите внимание, что она стро-
ится из пирамид меньшего размера. Поддерево, начинающееся в любом узле пира-
миды, тоже является пирамидой. Например, в пирамиде, показанной на рис. 9.7,
поддерево с корнем в узле 13 - тоже пирамида.
Используя этот факт, можно построить пирамиду снизу вверх. Сначала разме-
стите элементы в дереве, как показано на рис. 9.8. Затем из поддеревьев с тремя
узлами сформируйте пирамиды. Поскольку в них всего по три узла, сделать это
достаточно просто. Сравните верхний узел с двумя его дочерними. Если любой из
дочерних узлов больше, поменяйте его с верхним узлом. Если оба дочерних узла
больше, поменяйте родительский узел с большим дочерним. Этот шаг повторяет-
ся до тех пор, пока все поддеревья из трех узлов не станут пирамидами, как пока-
зано на рис. 9.9.
Теперь соедините маленькие пирамиды для создания более крупных пирамид.
На рис. 9.9 маленькие пирамиды с вершинами 15 и 5 объединяются с элементом 7.
Пирам и да л ьн а я сорти ров ка

Рис. 9.7. Пирамида, составленная из меньших пирамид

А /V /\
Рис. 9.S. Несортированный список в полном дереве

11

14 15

Ma 9 V
I' 1
13 12 '/ 2 4

Рис. 9.9. Поддеревья второго уровня являются пирамидами


Сравните новый верхний элемент - 7 с каждым из его дочерних. Если один из
потомков больше, поменяем его местами с вершиной. В данном случае 15 больше,
чем 7 и 4, поэтому узел 15 меняется местами с узлом 7.
Поскольку правое поддерево с корнем в узле 4 не изменилось, оно по-прежне-
му является пирамидой. Однако левое поддерево изменилось. Чтобы определить,
является ли оно пирамидой, сравните его новую вершину 7 с дочерними узлами 13
и 12. Поскольку 13 больше, чем 7 и 12, следует поменять узлы 7 и 13.
Если бы поддерево было более высокое, вы продолжили бы перемещать узел 7
вниз. В конечном счете либо будет достигнута точка, в которой узел 7 больше обо-
их своих потомков, либо алгоритм достигнет основания дерева. На рис. 9.10 пока-
зано дерево после того, как поддеревья стали пирамидами.

,'•;
4
3 9
i'
»'«
8 6
Ч
7 12
;
Рис. 9.10. Объединение пирамид в пирамиду большего размера

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


одной большой пирамидой, как на рис. 9.5.
Следующий код перемещает элемент в позиции Queue [parent ] вниз по пи-
рамиде. Если поддеревья ниже Queue [parent ] являются пирамидами, процеду-
ра объединяет их, чтобы сформировать большую пирамиду.
type
QueueEntry = record
value .-String [10] ;
priority:Integer;
end;
// Перемещение элемента вниз по пирамиде, пока он не сможет
// переместиться еще глубже.
procedure HeapPushDowntparent : Integer);
var
child, top_priority : Integer;
top_value : String;
begin
top_priority := Queuefparent].priority;
top_value := Queue[parent].value;
repeat // Бесконечный цикл.^
Child := 2 * parent; ^^
if (child > Numltems) then -it >• -"-ч-, ,
-• ' . break . '. •,<.-...? • • .
else begin
// Формируем дочерний узел узлом с большим приоритетом.
if (child < Numltems) then
if (Queue[child + 1] .priority > Queue [child] .priority)
then
, child := child + 1;
if ( Queue [child] .priority > top_priority) then
begin
// Дочерний узел имеет больший приоритет.
// Меняем местами родительский и дочерний узлы.
Queue [parent] := Queue [child] ;
// Перемещаем данный дочерний узел вверх.
parent := child;
end else
// Родительский узел имеет больший приоритет. Готово.
j Break;
end;
until (False) ;
Queue [parent] .priority := top_priority;
Queue [parent] .value := top_value;
end;
Полный алгоритм, в котором используется процедура HeapPushDown для по-
строения пирамиды из деревьев, необычайно прост:
procedure BuildHeap;
var
I : Integer;
begin
for i := (max + min) div 2 downto min do
HeapPushDown ( i ) ;
end;

Очереди с приоритетом
С помощью процедур Bui IdHeap и HeapPushDown управлять очередью с при-
оритетом очень легко. Если в качестве такой очереди используется пирамида, то
элемент с самым высоким приоритетом находится всегда на вершине. Найти эле-
мент с максимальным приоритетом просто. Но если его удалить, получившееся
дерево без корня уже не будет пирамидой.
Чтобы снова превратить это дерево в пирамиду, возьмите последний элемент
(крайний справа элемент на нижнем уровне) и поместите его на вершину пирами-
ды. Затем используйте процедуру HeapPushDown, чтобы переместить новый кор-
невой элемент вниз до тех пор, пока дерево не станет пирамидой. В этот момент мож-
но получить на выходе очереди следующий элемент с наивысшим приоритетом.
Сортировка
" ™™,.,,. f. . .о--.-" "-.-.-. -.,-. .- ~ .- - -

// Удаление из очереди элемента с максимальным приоритетом.


function Pop : String;
begin дишщп
Result := Queue[1].value;
// Перемещение последнего элемента на вершину, очереди.
Queue[1] := Queue[Numltems];
NumIterns := Numltems - 1;
// Перемещение элемента вниз до тех пор, пока не получится пирамида.
HeapPushDown (1) ;
end;
Чтобы добавить новый элемент к очереди, увеличьте пирамиду. Поместите но-
вый элемент на свободную позицию в конце массива. Получившееся дерево пира-
мидой не является.
Чтобы снова преобразовать его в пирамиду, сравните новый элемент с его ро-
дительским узлом. Если новый элемент больше, поменяйте их местами. Заранее
известно, что второй дочерний узел меньше, чем родительский, поэтому не нужно
сравнивать новый элемент с другим дочерним узлом. Если новый элемент больше,
чем родительский узел, он также больше другого дочернего узла.
Продолжите сравнивать новый элемент с родительскими узлами и переме-
щать его по дереву, пока не найдется родительский узел больше, чем новый эле-
мент. В этой точке дерево снова становится пирамидой, и очередь с приоритетами
готова к работе.
// Перемещение последнего элемента вверх к корню.
procedure HeapPushUp;
var
child,parent : Integer;
bottom_priority : Integer;
bottom_value : String;
begin
bottom_priority := Queue[Numltems].priority;
bottom_value := Queue[NumIterns].value;
child := Numltems;
repeat // Бесконечный цикл.
parent := child div 2;
if (parent < 1) then break;
if (Queue[parent].priority < bottom_priority) then
begin
Queue[child] := Queue[parent];
Child := parent;
end else break;
until (False);
Queue[child].priority := bottom_priority;
Queue[child].value := bottom_value;
end;
Пирамидальная сортировка
Процедура Push добавляет к дереву новый элемент и использует процедуру
HeapPushUp для формирования из дерева пирамиды.
// Добавление элемента к очереди.
procedure Push(new_value : String; priority : Integer);
begin
NumIterns := NumIterns + 1;
Queue[Numltems].value := new_value;
Queue[Numltems].priority :- priority;
HeapPushUp;
end;

Анализ пирамид
Вначале превращение списка в пирамиду осуществляется формированием ма-
леньких пирамид. Для каждого внутреннего узла в дереве стоится пирамида с кор-
нем в этом узле. Если дерево содержит N элементов, то в дереве O(N) внутренних
узлов, и в итоге приходится создать O(N) пирамид.
При формировании отдельных пирамид может потребоваться продвигать выс-
ший элемент вниз, иногда до того момента, когда он станет листом. Самые боль-
шие пирамиды имеют высоту O(logN). Поскольку строится O(N) пирамид, а для
построения самой высокой из них требуется максимум O(logN) шагов, все пира-
миды можно создать за время порядка O(N*logN).
На самом деле для построения пирамиды требуется не так много времени.
Только некоторые пирамиды имеют высоту O(logN). Большая часть пирамид на-
много короче. Только одна пирамида реально имеет высоту, равную logN, а поло-
вина имеет высоту всего 2. Если суммировать все шаги, необходимые для постро-
ения всех пирамид, потребуется не больше O(N) шагов.
Чтобы проверить истинность этого выражения, предположим, что дерево со-
держит N узлов. Пусть Н - глубина дерева. Это дерево является полным двоич-
ным, поэтому Н = logN.
Теперь предположим, что вы строите все большие и большие пирамиды. Вы
строите пирамиду глубиной i для каждого внутреннего узла, отстоящего на Н - i
уровней от корня дерева. Всего 2Н *' таких узлов, поэтому всего формируется 2 Н "'
пирамид глубиной i.
Для построения этих пирамид может понадобиться передвигать верхний эле-
мент вниз до тех пор, пока он не станет листом. Перемещение элемента вниз через
всю пирамиду глубины i может потребовать до i шагов. Общее число шагов для
построения 2 Н "' пирамид глубины i, максимум i шагов для формирования каждой,
равно i * 2Н "'.
Сложив все шаги, необходимые для построения пирамид разного размера, по-
лучим 1 * 2 н -'+ 2 * 2"- 2 + 3 * 2 Н - 3 + ... + (Н - 1) * 21. Вынеся множитель 2Н за
скобки, получим 2 Н * (1 / 2 + 2 /2 2 + 3 /2 3 + ... + (Н - 1) /2"-').
Можно показать, что (1/ 2 + 2/2 2 + 3/23+... + (Н-1)/2 н -')<2.Тогда общее
число шагов для построения пирамид меньше, чем 2Н * 2. Поскольку Н - глубина
I Сортировка
дерева, равная logN, общее количество шагов будет меньше 2logN * 2 = N * 2. Это
означает, что требуется всего (JfN)JHiaroB для первоначального построения пи-
рамиды.
Чтобы удалить элемент из очереди с приоритетом, последний элемент пере-
мещается на вершину дерева. Затем он продвигается вниз, пока не достигнет сво-
ей конечной позиции и дерево снова не станет пирамидой. Поскольку дерево
имеет глубину logN, этот процесс может занять максимум logN шагов. Следо-
вательно, элемент из очереди с приоритетом на основе пирамиды удаляется за
O(logN) шагов.
Когда новый элемент добавляется в пирамиду, он помещается внизу дерева
и передвигается к вершине, пока не приходит в состояние покоя. Поскольку глубина
дерева равна logN, это может занять максимум logN шагов. Это означает, что новый
элемент добавляется к очереди с приоритетом на основе пирамиды за O(logN)
шагов.
Можно управлять очередью с приоритетом с помощью упорядоченного спис-
ка. Используя быструю сортировку, можно построить начальную очередь за время
порядка O(N * logN). При удалении или вставке элемента можно использовать
пузырьковую сортировку, чтобы снова упорядочить список за время порядка O(N).
Это достаточно быстро, но не так быстро, как с помощью пирамиды. Добавле-
ние или удаление элемента из очереди с приоритетом на основе упорядоченного
списка из миллиона элементов занимает приблизительно миллион шагов. Вставка
или удаление элемента из соответствующей очереди на основе пирамиды занима-
ет всего 20 шагов.
Программа HeapQ использует пирамиду для управления очередью с приори-
тетом. Введите строку и приоритет и нажмите кнопку Push (Втолкнуть), чтобы
добавить новый элемент к очереди. Щелкните по кнопке Pop (Вытолкнуть), что-
бы удалить из очереди элемент с самым высоким приоритетом.

Алгоритм пирамидальной сортировки


По уже описанным алгоритмам для управления пирамидами довольно просто
представить алгоритм пирамидальной сортировки. Идея состоит в том, чтобы по-
строить очередь с приоритетом и затем удалять каждый элемент по порядку.
Чтобы удалить элемент, алгоритм меняет его местами с последним элементом
в пирамиде. Он помещает недавно удаленный элемент в позицию в конце массива.
Затем алгоритм сокращает счетчик числа элементов в пирамиде, чтобы исключить
из рассмотрения последнюю позицию.
После того как наибольший элемент поменялся местами с последним, массив
уже вовсе не обязательно является пирамидой, поскольку новый элемент на вер-
шине может оказаться меньше, чем его потомки. Поэтому алгоритм использует
процедуру HeapPushDown для продвижения элемента на его место. Алгоритм про-
должает перемещать элементы и восстанавливать пирамиду до тех пор, пока в ней
не останется элементов.
Пирамидальная сортировка
procedure TSortForm.Heapsortdist
min, max : Longint);
var
i, tmp : Longint;
begin
// Построение пирамиды (за исключением корневого узла).
for i := (max + min) div 2 downto min + 1 do
HeapPushDown(list,i,max);
// Повторить:
// 1. HeapPushDown.
// 2. Вывод корневого узла.
for i := max downto min + 1 do
begin
// 1. HeapPushDown.
HeapPushDown(list,min,i);
// 2. Вывод корневого узла. . £
tmp := list^min] ;
list A [min] := list*[i];
lisfMi] := tmp;
end; ^
end;
Приведенные выше рассуждения относительно очередей с приоритетом пока-
зали, что первоначальное формирование пирамиды занимает O(N) шагов. После
этого требуется O(logN) шагов, чтобы восстановить пирамиду. Пирамидальная сор-
тировка выполняет это действие N раз, поэтому всего требуется O(N) * O(logN) =
O(N * logN) шагов, чтобы переместить сортируемый список из пирамиды. Полное
время выполнения для алгоритма пирамидальной сортировки составляет порядка
O(N) + O(N * logN) - O(N * logN).
Сложность этого алгоритма такая же, как и сложность алгоритма сортировки