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 (англ.) ISBN 5-94074-202-5 (рус.)
© By Rod Stephens. Published by John Wiley & Sons, Inc. © Обложка. Биржаков Н., 2004 © Издание на русском языке, перевод на русский язык, оформление. ДМК Пресс, 2004
Содержание !
Введение
12
Глава 1. Основные понятия
18
Что такое алгоритмы Анализ скорости выполнения, алгоритмов
18 19
Память или время Оценка с точностью до порядка Определение сложности Сложность рекурсивных алгоритмов
19 20 21 23
Средний и наихудший случай Общие функции оценки сложности
25 26
Логарифмы
27
Скорость работы алгоритма в реальных условиях Обращение к файлу подкачки
28
Резюме
30
Глава 2. Списки
31
Основные понятия о списках Простые списки Изменение размеров массивов Список переменного размера Класс SimpleList
Неупорядоченные списки Связанные списки Добавление элементов Удаление элементов
27
31 32 >
32 35 39
40 45 47 48
DeljJhL JOTOB ые а л гор итм ы Метки Доступ к ячейкам
49 50
;.
Разновидности связанных списков
52
Циклические связанные списки Двусвязные списки Списки с потоками
52 53 55
Другие связанные структуры Резюме .
58 60
. .• ' .
f
•
"
'•
"
"
Глава 3. Стеки и очереди
61
Стеки
61
Стеки на связанных списках
63
Очереди Циклические очереди Очереди на основе связанных списков Очереди с приоритетом Многопоточные очереди
Резюме
Глава 4. Массивы
;
65
.-.
66 70 71 73
75
77
Треугольные массивы
77
Диагональные элементы
78
Нерегулярные массивы
79
Линейное представление с указателем Нерегулярные связанные списки Динамические массивы Delphi
Разреженные массивы Индексирование массива
Сильно разреженные массивы Резюме
Глава 5. Рекурсия Что такое рекурсия Рекурсивное вычисление факториалов Анализ сложности .
80 81 82
83 84
'.. 87 89
эо 90 91 ..92
_Содержание
Рекурсивное вычисление наибольшего общего делителя
93
Анализ сложности
94
Рекурсивное вычисление чисел Фибоначчи
95
Анализ сложности
96
Рекурсивное построение кривых Гильберта Анализ сложности
97 99
Рекурсивное построение кривых Серпинского Анализ сложности
102
.....,....,.............„........>... 104
Недостатки рекурсии
105
Бесконечная рекурсия Потери памяти Необоснованное применение рекурсии Когда нужно использовать рекурсию
106 107 107 108
Удаление хвостовой рекурсии Нерекурсивное вычисление чисел Фибоначчи Устранение рекурсии в общем случае Нерекурсивное создание кривых Гильберта Нерекурсивное построение кривых Серпинского Резюме
Глава 6. Деревья
109 ш 113 118 121 125
126 ; - . " • - • • - • ' • " -
Определения Представления деревьев
Полные узлы Списки дочерних узлов Представление нумерацией связей Полные деревья
Обход дерева Упорядоченные деревья Добавление элементов Удаление элементов Обход упорядоченных деревьев
'''.'
•
...;..
126 127 128 129 130 134
135 140 141 142 146
Delphi. Готовые алгоритмы Деревья со ссылками
147
Особенности работы
150
Q-деревья
151
;
Изменение значения MAX_QTREE_NODES
157
Восьмеричные деревья
157
Резюме
Глава?.
Сбалансированные деревья
Балансировка AVL-деревья
:
158
159 159 160
Добавление узлов к AVL-дереву
160
Удаление узлов из AVL-дерева
169
Б-деревья
174
Производительность Б-дерева
175
Удаление элементов из Б-дерева
176
Добавление элементов в Б-дерево
176
Разновидности Б-дерева Усовершенствование Б-деревьев
178 180
Вопросы доступа к диску
181
База данных на основе Б+дерева
184
Резюме
Глава 8. Деревья решений Поиск в игровых деревьях Минимаксный перебор Оптимизация поиска в деревьях решений
Поиск нестандартных решений
187
188 188 190 193
194
Ветви и границы
195
Эвристика
200
Сложные задачи
216
Задачао выполнимости Задача о разбиении
217 217
Задача поиска Гамильтонова пути Задача коммивояжера
218 219
Содержание
||
Задача о пожарных депо Краткая характеристика сложных задач
220 220
Резюме
Глава 9. Сортировка
221
,
222
Общие принципы
222
Таблицы указателей
222
Объединение и сжатие ключей
223
Пример программы Сортировка выбором Перемешивание Сортировка вставкой
226 226 227 228
Вставка в связанных списках
229
Пузырьковая сортировка Быстрая сортировка Сортировка слиянием Пирамидальная сортировка
231 234 239 241
Пирамиды Очереди с приоритетом
241 .'
Алгоритм пирамидальной сортировки
Сортировка подсчетом Блочная сортировка Блочная сортировка с использованием связанных списков
Резюме
Глава 10. Поиск Примеры программ Полный перебор Перебор сортированных списков Перебор связанных списков
Двоичный поиск Интерполяционный поиск
245 248
250 251 252
255
257 257 258 259 259
261 263
Delphi. Jbro^ Строковые данные Следящий поиск
267 268
Двоичное отслеживание и поиск Интерполяционный следящий поиск
268 269
Резюме
270
Глава 11. Хеширование
272
Связывание
273
Преимущества и недостатки связывания
275
Блоки
277
Хранение хеш-таблиц на диске Связывание блоков Удаление элементов Преимущества и недостатки использования блоков
Открытая адресация
286
Линейная проверка Квадратичная проверка Псевдослучайная проверка Удаление элементов
287 294 297 299
Резюме
301
Глава 12. Сетевые алгоритмы Определения Представления сетей Управление узлами и связями
Обход сети Наименьший каркас дерева Кратчайший путь
280 283 285 286
•.
зо4 304 305
•. ' •/
307
.,
Расстановка меток Коррекций меток Варианты поиска кратчайшего пути Применение алгоритмов поиска кратчайшего пути
308 311 316 318 323 326 331
Содержание Максимальный поток
335
Сферы применения
342
Резюме
,
345
Глава 13. Объектно-ориентированные методы Преимущества ООП
346
...
346
Инкапсуляция Полиморфизм Многократное использование и наследование
346 349 349
Парадигмы ООП Управляющие объекты Контролирующий объект Итератор Дружественный класс Интерфейс Фасад Фабрика Единственный объект Сериализация Парадигма Модель/Вид/Контроллер
,.
... ,
Резюме
Приложение 1. Архив примеров Содержание архива с примерами Аппаратные требования — Запуск примеров программ Информация и поддержка пользователей
351 351 353 354 356 356 357 357 359 361 364
367
зев 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 1 1
2 2 2
3
4
3
4
5
6
9
10
13
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) var i : Integer;
: Integer;
begin
for i := 1 to N do if (Value(i]=target) , . begin Result := i; break; end; end;
then
Если искомый элемент находится в конце списка, то программе придется исследовать все N элементов списка, чтобы обнаружить нужный. Это займет ty шагов, и сложность алгоритма будет равна O(N). В данном, так называемом наихудшем случае (worst case) время работы алгоритма будет максимальным. С другой стороны, если искомый число расположено в самом начале списка, алгоритм завершит работу почти сразу же. Он выполнит несколько шагов, прежде чем найдет искомый номер и остановится. Это наилучший случай (best case) со сложностью порядка О(1). Строго говоря, подобный случай не очень интересен, поскольку он вряд ли произойдет в реальной жизни. Интерес представляет средний или ожидаемый вариант (expected case) поведения алгоритма. Если номера элементов в списке изначально беспорядочно смешаны, то искомый элемент может оказаться в любом месте списка. В среднем потребуется исследовать N/2 элементов для того, чтобы найти требуемый. Значит, сложность этого алгоритма в усредненном случае будет порядка O(N/2), или O(N), если убрать постоянный множитель. Для некоторых алгоритмов наихудший случай сильно отличается от ожидаемого случая. Например, алгоритм быстрой сортировки, описанный в главе 9, имеет наихудший случай поведения О(№), а ожидаемое поведение равно O(N * log(N)), что гораздо быстрее. Алгоритмы, подобные алгоритму быстрой сортировки, иногда
I l l l l 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) = С" f(N) = N!
С - константа больше 1 т.е. 1 * 2 * ... * N
Таким образом, уравнение сложности, которое содержит несколько этих функций, при приведении в систему оценки сложности по порядку величины будет сокращаться до функции, расположенной ниже в таблице. Например, O(log(N) + N2) это то же самое, что и О(№). Сможет ли алгоритм работать быстрее, зависит от того, как вы его используете. Если вы запускаете алгоритм раз в год для решения задач с достаточно малыми объемами данных, то вполне приемлема производительность О(№). Если же алгоритм выполняется под наблюдением пользователя в интерактивном режиме, оперируя большими объемами данных, то может быть недостаточно и производительности O(N). Обычно алгоритмы со сложностью N * log(N) работают с очень хорошей скоростью. Алгоритмы со сложностью Nc при небольших значениях С, например N2, применяются, когда объемы данных ограничены. Вычислительная сложность алгоритмов, порядок которых определяется функциями CN и N! очень велика, поэтому эти алгоритмы пригодны только для решения задач с очень малым объемом перерабатываемой информации. Один из способов рассмотрения относительных размеров этих функций заключается в определении времени, которое требуется для решения задач различных размеров. Табл. 1.3 показывает, как долго компьютер, осуществляющий миллион
£E°,<-Jb Работы алг°Ритлл§
CK
операций в секунду, будет выполнять некоторые медленные алгоритмы. Из таблицы видно, что только небольшие задачи можно решить с помощью алгоритмов N со сложностью O(C ), и самые маленькие - с помощью алгоритмов со сложностью O(N!). Для решения задач порядка O(N!), где N = 24, потребовалось бы больше времени, чем существует вселенная. Таблица 1.3. Время выполнения сложных алгоритмов 3
N
2м 3" N!
N = 10
N = 20
N = 30
N = 40
N = 50
0,001 с 0,001 с
0,008 с
0,027 с
0,064 с
0,125с
1,05с 58,1 мин 7,71 * 1 04 лет
17,9 мин 6,53 лет
1 ,29 дней
0,059 с 3,63с
s
18
8,41 * 10 лет
3,86 * 10 - лет 34 2,59 МО лет
35,7 лет 2,28* 1010лет й 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 12
1,35 2,03
1,56 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
1
Items[i] := i; Txt := txt + IntToStr(Items[i]) + '' ; end; 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; end;
4
Эта простая схема хорошо работает для небольших списков, но у нее есть два существенных недостатка. Вр-первых, приходится часто менять размер массива. Чтобы создать список из 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; MIN_FREE=10;
// Установка 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; MIN_FREE=10;
// Установка 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 if
списки
:= Low(List) to High(List) do (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 демонстрирует метод сборки мусора. Она отображает неиспользуемые элементы списка как
, а записи, помеченные как мусор - . Используемый программой класс 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^ .NextCell на новую ячейку. На рис. 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) ; Ччейка «no еле меня»
Первая ячейка
^_
JL
Удаленная ячейка
О:
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 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;
PDoubleListCell);
Е!*!^^^ 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 SSN: 123-45-6789
ff Name С 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])
.
v
new_cell A .NextSSN := cell_ptr A .NextSSN; cell_ptr A .NextSSN := new_cell; // Вставка ячейки в поток рода работы. // Нахождение предыдущей ячейки. cell_ptr := STopSentinel,while (cell_ptr A .JobClass
Другие связанные структуры С помощью указателей нетрудно построить множество других полезных типов связанных структур, таких как деревья, неоднородные и разреженные массивы, графы и сети. Ячейка может содержать любое число указателей на другие ячейки. Например, для создания двоичного дерева вы можете использовать
ячейку, содержащую два указателя - на правую и левую дочерние ячейки. Тип записи BinaryCell может быть определен следующим образом: type PBinaryCell = "TBinaryCell; TBinaryCell = record LeftChild : PBinaryCell; RightChild : PBinaryCell; end; На рис. 2.11 изображено дерево, сформированное из ячеек такого типа. В главе 6 деревья рассматриваются более подробно. Ячейка может также содержать связанный список ячеек, каждая из которых содержит указатель на другую ячейку. Это позволяет программе связывать ячейку с любым количеством других ячеек. На рис. 2.12 приведены примеры различных связанных структур данных. Вы встретите подобные структуры позже, в частности в главе 12.
Рис. 2.11. Двоичное дерево
Рис. 2.12. Связанные структуры
Резюме Используя указатели, вы можете строить гибкие структуры данных, такие как связанные списки, циклические связанные списки и двусвязные списки. Эти структуры позволяют легко добавлять и удалять элементы из любой позиции списка. Добавляя дополнительные указатели на класс ячеек, вы можете превратить двусвязные списки в потоки. Если руководствоваться таким подходом, можно создать такие экзотические структуры данных, как разреженные массивы, деревья, хеш-таблицы и сети. Они подробно описаны в следующих главах.
Глава 3. Стеки и очереди Эта глава продолжает тему, начатую в главе 2. Здесь-описываются две особых разновидности списков: стеки и очереди. Стек - это список, в котором элементы добавляются и удаляются с одного и того же конца списка. Очередью называется список, в котором элементы добавляются с одного конца, а удаляются с противоположного. Многие алгоритмы, включая некоторые из представленных в следующих главах, используют стеки и очереди.
Стеки Стек (stack) - это упорядоченный список, где элементы всегда добавляются и удаляются с одного конца. Стек можно сравнить со стопкой книг на полу. Вы можете добавлять книги на вершину стопки и убрать их с вершины, но добавить или убрать книгу из середины стопки вы не сможете. Стеки часто называют списками типа последний пришел — первый вышел (LastIn-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
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
Этот способ реализации стеков весьма эффективен. Стек не расходует понапрасну память, и не требуется дополнительное время для частого изменения его размера, особенно если сразу известно, насколько большим он должен быть.
Стеки на связанных списках Вы можете управлять двумя стеками в одном массиве, размещая один в начале массива, а другой - в конце. Сохраните отдельные счетчики вершин для каждого стека, и сделайте так, чтобы стеки росли друг к другу, как показано на рис. 3.1. Этот метод позволяет двум стекам увеличиваться, занимая один и тот же массив памяти до тех пор, пока они не столкнутся друг с другом в тот момент, когда массив полностью заполнится. Стек 1 —>-
Вершина 1 -го стека
-*— Стек 2
Вершина 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-FirstOut 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 и QueueBack необходимо использовать оператор 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; MIN_FREE = 2;
// Изменение при 50% свободного места. // Минимальный размер неиспользуемой // области при изменении размера.
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 7
10
+ Задача В
-^
4
Задача D -*. Задача А
-32,768
-
Рис. 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
7
0,11
5 2
3,93 0,54
13 6
Многопоточная очередь также кажется более удобной, чем обычная, поскольку пассажиры обслуживаются в порядке прибытия. На рис. 3.9 показано окно программы HeadedQ, моделирующей работу терминала. В многопоточной очереди пассажир с номером 100 стоит следующим в очереди. Все клиенты, которые прибыли до него, уже обслужены или обслуживаются в настоящее время. В обычной очереди обслуживается клиент 97. Клиент 96 все еще ждет, несмотря на то, что он прибыл перед клиентом 97.
Резюме Различные реализации стеков и очередей обладают неодинаковыми свойствами. Стеки и циклические очереди на основе массивов просты и эффективны, в особенности если заранее известен их потенциальный размер. Связанные списки обеспечивают большую гибкость, если необходимо часто изменять размер списка. Вы можете выбрать структуру стека или очереди, более подходящую по возможностям вашему приложению.
I
Стеки и очереди
/* HeadedQ Efc
Help tie Multi-Headed Queue™ Cusl Wailing: 2 93 100101
Time
-Multiple Single-Headed Queues- .........
Cleik Cust Waiting: 5 33
99
9?
94
101
95 93
92
96
97
1QO;
35
98
AveWait 2,10 01:57' - M a x Wait 5
'
* Clerk Busy 94
AveWait 5,03 MaxWait 16
Рис. З.9. Окно программы HeadedQ
92
Глава 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
var
: Integer)
: Integer;
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)
Многоугольник 2
(1,1)
(4,1)
(2,3)
Многоугольник 3
(2,2)
(4,3)
(5,4)
(5,5)
(4, 4)
(4, 5)
(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
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
// Если значение равн^^н&^ению по умолчанию. 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
// Если найдена нужная ячейка. Result := bEFAULT_VALUE; if (row_ptr/s.Rowor) then exit;
// Нахождение ячейки столбца. col_ptr := row_ptr/4.FirstCell'v.NextCell; while (col_ptr/s.Col
Резюме Программа 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 begin Ping(num div 2); end; ^
Integer);
Рекурсия полезна при решении задач, которые могут быть разложены на несколько подзадач. Например, дерево, изображенное на рис. 5.1, можно представить в виде «ствола», откуда выходят два дерева меньших размеров. Таким образом можно написать рекурсивную процедуру для рисования деревьев. i procedure DrawTree; begin // Рисование "ствола" // Рисование маленького дерева, повернутого на -45 градусов // Рисование маленького дерева, повернутого на 45 градусов end;
Хотя рекурсия и упрощает понимание некоторых явлений, люди обычно мыслят нерекурсивно. Они обычно стремятся разбить сложные задачи на задачи меньшего объема, которые можно выполнить по порядку одну за другой до полного завершения. Например, вы начнете красить забор с одного края и продолжите двигаться в другую сторону до завершения работы. Скорее всего, во время выполнения подобной задачи вы даже не думаете о возможности рекурсивной окраски,вначале левой половины изгороди, а затем, рекур- ' п Рис. 5.1. Дерево, то, правой
if
Рекурсивное вычисление факториалов Л 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
N!
1
2
6
24
120
720
5.040
40.320
9
0
362.880 3.628.800
Функцию факториала можно определять с помощью рекурсии: О! = 1 N! = N * (N - 1 ) ! , для N > 0.
1
•
Рекурсия Преобразовать это определение в рекурсивную функцию очень просто: 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 Fib(N) = Fib(N - 1) + Fib(N - 2 ) ,
(A,Afo.
Третье уравнение дважды использует функцию 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) 2
0
1 1
1
1
N
0
Fib(N) G(N)
5
6
7
8
3
5
8
5
8
13
13 21
34
3
4
1
2
2
3
21
Затем рассмотрим, сколько раз алгоритм обращается к рекурсии. Если 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
2
0
5 7
8 12
21
0
3 4
13
H(N)
1 1
20
33
2
Объединяя результаты для 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 begin with DrawArea . Canvas do begin if (depth > 1) then DrawHilbert (depth LineTo ( PenPos . X+dx , PenPos . Y+dy ) ; if (depth > 1) then DrawHilbert (depth LineTo ( PenPos . X+dy , PenPos . Y+dx) ; if (depth > 1) then DrawHilbert (depth LineTo ( PenPos . X-dx , PenPos . Y-dy ) ; if (depth > 1) then DrawHilbert (depth end; end;
: Integer);
l,dy,dx); l,dx,dy); l,dx,dy); l,-dy,-dx);
Анализ сложности Чтобы проанализировать сложность этой процедуры, необходимо определить число вызовов процедуры 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 1
2 _
5
_
3
4
5
6
7
8
9
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 на только одна из них. Зависимости, показанные на рис. С: CXD-BXC 5.9, показывают, какие операции нужно выполнить, чтоD:D\AtCXD бы нарисовать кривые различных типов. Соотношения для кривой типа А реализованы в следующем коде. Ос- рис g g рекурсивные тальные зависимости можно использовать, чтобы измезависимости между нить код для вывода других типов кривых. кривыми Серпинского procedure TSierplForm.SierpA(depth, dist begin with DrawArea.Canvas do begin
Integer);
Рекурсия 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) begin
: Integer;
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 уровней, в результате чего стек вашей системы, скорее всего, переполнится. Альтернативный метод заполнения таблицы чисел Фибоначчи позволяет избежать такой глубокой рекурсии. Когда программа инициализирует массив FiboValues, она заранее вычисляет все числа Фибоначчи. // Инициализация таблицы соответствия.
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 fib_i_minus_l for i := 2 to begin
// Предотвращает предупреждение компилятора. := 0; //Начальное значение fib(O). := 1; //Начальное значение fib(l). n do
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. В данном примере метки будут выглядеть таким образом:
1
procedure Recurse(num : Integer); begin <Кодовый блок 1> Recur8е(<параметры>);
j^ 2
_J|
<Кодовый блок 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; until ( F a l s e ) ; end;
// End case. // Конец бесконечного цикла.
Переменная рс - это программный счетчик, который сообщает процедуре, какой шаг она должна выполнить следующим. Например, когда рс = 1, процедура должна выполнить кодовый блок 1.
ПНИ!..... Когда процедура достигает основного условия, она не выполняет рекурсию. Вместо этого она изменяет значение рс на 2, и программа продолжает выполнение кодового блока 2. Если процедура еще не достигла основного условия, осуществляется мнимая рекурсия. Для этого процедура сохраняет значения любых локальных переменных, которые потребуются после завершения мнимой рекурсии. Она также сохраняет значение рс для участка кода, который она должна выполнить после окончания мнимой рекурсии. В данном примере следующим выполняется кодовый блок 2, поэтому программа сохраняет 2 как следующее значение рс. Самый простой способ хранить значения локальных переменных и рс состоит в использовании стеков, таких как описанные в главе 3. Это проще понять на конкретном примере. Рассмотрим немного измененную версию функции факториала. Здесь она написана как процедура, которая возвращает свое значение через переменную, а не через саму функцию, что немного упрощает процедуру.
1
2
procedure Factorial(var num, value : Integer); var partial : Integer; begin if (num<=l) then value := 1 else begin
Factorial(num-1,partial); 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;
В следующем фрагменте кода первые строки каждого блока между рекурсивными шагами пронумерованы. Это первая строка процедуры и любых других точек, в которых возможно продолжение работы алгоритма после окончания мнимой рекурсии.
1 2 3 4
procedure THilblForm.DrawHilbert(depth, dx, dy : Integer); begin with DrawArea.Canvas do begin if (depth>l) then DrawHilbert(depth-l,dy,dx); LineTo(PenPos.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-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 :
end; end;
// <код SierpD>.
Параметр 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
12 13 14
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) ; 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. Ydist) ; SierpA (depth-l,dist) ; end; end;
Типичная мнимая рекурсия из кода подпрограммы SierpA в код подпрограммы SierpB выглядит так: PushValues (depth, 13) depth := depth- 1; рс := 21;
// По окончанию рекурсии начать с шага 13. // Отправиться в начало кода 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
1 2 3 4 5
6 7 8 9 10 11 12
Индекс
0
Метка
А В С D Е F G Н
Первая ветвь
0 2 3 9 11 11 11 11 11 11 11 11 11
I
J К
L
Массив 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 traversal). Процесс начинается с прохода вглубь дерева, пока алгоритм не достигнет листьев. Когда рекурсивная процедура снова вызывается, алгоритм проходит дерево вверх, посещая пропущенные ранее узлы. Обход в глубину используется в алгоритмах, где необходимо сначала обратиться ко всем листьям. Например, алгоритм ветвей и границ, описанный в главе 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); VisitNodefNodeLabel"[node]); if (node*2+2<=NumNodes) then Inorder(node*2+2); end;
// Посещение дочернего узла 1. // Посещение узла. // Посещение дочернего узла 2.
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
ЕШНН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. Упорядоченный список: 1, 2, 4, 6,7,9
Рис. 6.16. Упорядоченный список: 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])); expend; 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. Симметричный обход сортированного дерева: 2, 4, 5, 6, 7,8,9
Рис. 6.21. Дерево, образованное при добавлении элементов в порядке 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. Дерево с симметричными ссылками
Деревья Поскольку ссылки занимают позиции указателей на дочерние узлы, необходимо найти какое-то различие между ссылками и обычными указателями на дочерние узлы. Проще всего это сделать, добавив в узлы новые поля, такие как Boolean - 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 var best_leaf : TQtreeNode; X, Y var best_i, best_dist2 : Integer; var
xmid, ymid, i, dist2, best_dist : Integer; begin
них лучшей точки? : TQtreeNode; : Integer; var comp : Longint);
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 ,; • •.
• -:-
end; '
Children[South,West].CheckNearbyLeaves( ., exclude,best_leaf,X,Y,best_i,best_di&t2.,! comp) ; // Конец проверки западного дочернего узла. .
.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, NearPointlnLeaf и 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-деревья.
Резюме Существует много способов представления деревьев. Полные деревья, сохраненные в массивах, используют наиболее эффективное и компактное представление. Представление дерева в виде коллекций дочерних узлов упрощает работу с ними, но при этом программа выполняется медленнее и требует большего объема памяти. Формат нумерации связей позволяет быстро выполнять обход дерева и расходует меньше памяти, чем коллекции потомков, но в таком случае алгоритм сложно модифицировать. Проанализировав все типы операций с деревьями, вы можете выбрать представление, которое позволить достичь лучшего компромисса между гибкостью и простотой использования.
• , .
. . . . . . . .. •:•
i
.
•
i
:
' . ...
.
В<|9Ш ;GH •
• 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. Добавление нового узла в поддерево LR
Рис. 7.9. Вращение влево-вправо
Это вращение так же не изменяет Порядка расположения элементов дерева. Симметричный обход дерева до и после вращения происходит в порядке Т,, А, Т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; expend; // Продолжаем двигаться вниз по соответствующему поддереву. 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 использует восходящую рекурсию для проверки баланса дерева. После каждого вызова этой процедуры следует вызов процедуры RebalanceRightShrunk или 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; expend; 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 90; корня в новый корень и сократите дерево на один Nodes: 9 100 Ш • ПО уровень. Единственный способ уменьшения глуби120 ны дерева - это объединение дочерних узлов корня, 130 140 «^Н при котором образуется новый корень. 150 160 Программа Btree позволяет управлять Б-деревом. 170 180 Введите текстовое значение и нажмите кнопку Add, 190 чтобы добавить элемент. Введите значение и щелкните по кнопке Remove, чтобы удалить элемент. На Рис. 7.19. Окно программы рис 7.19 показано окно программы Btree, управляюBtree щей Б-деревом 2-го порядка. ±±
"1ПП
Разновидности Б-дерева Имеется несколько вариаций Б-деревьев, но здесь описаны только самые распространенные. Нисходящие Б-деревъя (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 Stephens. Метка 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 показан небольшой фрагмент дерева игры в крестики-нолики.
/Ж/Ж/Ж/ЖхЖ о X
X
о
X
0
Xо
X
о
X 0
X
0
X
о
/Ж/Ж/Ж/Ж/Ж/Ж/Ж/Ж
Рис. 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
о о Вес для игрока О
2
X X
оX оо
оX 2
X 0X Xо оX о 2
оX оX
X 0X X 0X оX 0
X 0X X 06 0X X
X X
Ничья
Ничья
Ничья
оX 0
X 0X X 0о о X 2
О выигрывает
о
X X X 00
о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. Возможные инвестиции Инвестиция
Стоимость (млн)
Прибыль (млн)
А В С D
45 52 46 35
10 13 8 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; TestCost : Integer; TestProfit : Integer;
// True для элементов исследуемого // решения. // Стоимость исследуемого решения. // Доход от рассматриваемого решения.
// Код опущен... 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 .Cost .
e.v/ .:,s:; ,;.:.
IB
8
Allowed cost [Ш Г Exhaustive Search Branch And Bound
Я
Solution
Profit . j*
i 4 5 S S 2 4 0 0 2
9 7 1 3' 2 -• 8 .vl — 3 10 .4 7 8 т|
14 14 70,
32 21 68 ; 69
Profit
:
9 3
10 7 8
8 Э
' . г
Best Cost: 35 Best Profit 38
Рис. 8.5 Окно программы BandB
;
Деревья решений Перебор методом ветвей и границ исследует гораздо меньше узлов, чем полный перебор. Дерево решений для задачи о формировании портфеля с 20 элементами содержит 2 097 151 узел. В то время как полный перебор всегда исследует все узлы, метод ветвей и границ может перебрать только примерно 1 600 узлов. Число исследуемых узлов методом ветвей и границ зависит от точных значений данных. Если стоимость элемента большая, то в правильном решении окажется немного элементов. Как только эти элементы добавляются в исследуемое решение, оставшиеся элементы уже не вписываются в статью расходов, поэтому большая часть дерева будет отрезана. С другой стороны, если элементы имеют низкую стоимость, многие из них смогут поместиться в правильном решении, поэтому программа должна исследовать множество допустимых комбинаций. В табл. 8.2 приведено количество узлов, проверенное программой BandB в серии тестов при различной стоимости позиций. Программа случайно генерировала 20 элементов, а общая допустимая стоимость решения равна 100. Таблица 8.2. Число исследуемых узлов при полном переборе и переборе по методу ветвей и границ Средняя стоимость элемента 60
50 40 30 20 10
Полный перебор 2.097.151 2.097.151 2.097.151 2.097.151 2.097.151 2.097.151
Ветви и границы 203 520 1322 4269 13.286 40.589
Эвристика Иногда даже алгоритм ветвей и границ не может полностью перебрать дерево решения. Дерево для задачи о формировании портфеля с 65 элементами содержит более 7 * 1019 узлов. Если алгоритм ветвей и границ перебирает только десятую часть процента этих узлов, а компьютер проверяет-миллион узлов в секунду, то потребовалось бы более 2 млн лет, чтобы решить эту задачу. В задачах, где алгоритм ветвей и границ работает недостаточно быстро, можно использовать эвристику (heuristic). Если качество решения не критично, то приемлемым считается результат, данный эвристикой. В некоторых случаях вы не можете знать входные данные с абсолютной точностью. Тогда хорошее эвристическое решение может иметь такую же силу, как и лучшее теоретическое решение. В предыдущем примере метод ветвей и границ использовался для выбора инвестиционных комбинаций. Однако вложения могут быть рискованными, и точные результаты заранее чаще всего неизвестны. Вы не можете знать точную прибыль или даже стоимость некоторых инвестиций. В этом случае эффективное эвристическое решение может быть столь же надежно, как и лучшее точно вычисленное решение.
Поиск нестандартных решений В этом разделе рассматривается эвристика, которая используется для решения многих сложных задач. Программа Неиг демонстрирует каждый из эвристических подходов. Кроме того, она позволяет сравнить эвристику с полным перебором и методом ветвей и границ. Введите информацию в области Parameters (Параметры), чтобы задать параметры создаваемых данных. Выберите алгоритмы, которые вы хотите протестировать, и щелкните по кнопке Go. Программа отображает общую стоимость и прибыль для наилучшего решения, найденного каждым из выбранных алгоритмов. Она также сортирует решения по максимальному полученному доходу и отображает время работы каждого алгоритма. Используйте метод ветвей и границ только для небольших задач, а метод полного перебора только для задач еще меньшего объема. На рис. 8.6 изображено окно программы Неиг после решения задачи портфеля с 30 элементами. В данном тесте ни один эвристический метод не нашел лучшего возможного решения, хотя некоторые найденные решения достаточно хороши.
Max:
Мй
Rank
1 | | Н 1
||30"
• Лгок" AJowed cost I2UO
Ptofit
Cost,
Time
Г" Exhaustive Search
196
200
0,07
W Hil Climbing
152
200
0,00 0.00
P/ Least Cost
165
195
[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. Возможные инвестиции Инвестиция
Стоимость
Отдача
Прибыль
А В С D Е
63
35 30 27 23
72 42 38 34 26
9 7 8 7 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. Возможные инвестиции с отношением прибыль/стоимость Инвестиция
Стоимость
Отдача
Прибыль
Прибыль/стоимость
А В С D Е
63 35 30 27 23
72 42 38 34 26
9
0,14 0,20 0,27 0,26 0,13
7 8 7 3 '
Это решение имеет стоимость 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] := Intend; // Перебор дерева случайным способом. 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; Последовательное приближение Другая стратегия состоит в том, чтобы начать со случайного решения, а затем производить последовательное приближение (incremental improvement). Начав
i
Поиск нестандартных решений
||
с произвольно сгенерированного решения, программа делает случайный выбор. Если новое решение является улучшением предыдущего, программа закрепляет изменение и продолжает проверку других позиций. Если изменение не улучшает решение, программа отказывается от него и делает новую попытку. Особенно просто реализовать метод последовательного приближения для задачи формирования портфеля инвестиций. Программа всего-навсего выбирает случайную позицию из пробного решения и удаляет ее из текущего. Затем она случайным образом добавляет в решение позиции до тех пор, пока не будет исчерпан лимит средств. Если удаленная позиция имела очень высокую стоимость, то на ее место программа может добавить несколько позиций. Как и случайный поиск, эта эвристика проста для понимания и реализации. Для решения сложной задачи бывает нелегко создать алгоритмы восхождения на холм, минимальной стоимости и приведенной прибыли, но довольно просто написать эвристический алгоритм последовательного приближения. Момент остановки Существует несколько хороших способов определить момент, когда необходимо прекратить случайные изменения. Например, допускается выполнять фиксированное число изменений. Для задачи из 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 // Сколько элементов в решении. num := 0; for j := 1 to Numltems do if (TestSolutiontj]) then num : = num + 1; if (num < 1) then exit;
// Случайный выбор одного из элементов. Selection := Random(num) + 1;
*,.
if
Поиск нестандартных решений // Нахождение случайно выбранного элемента. 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. Возможные инвестиции
Инвестиция
Стоимость
Отдача
Прибыль
А В С D
47 43 35 32 31
56 51 40 39 37
9 8 5 7 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
Fixed 2
1
No changes 1
Пока не будет улучшения за N испытаний 1
10 *N Пока не будет улучшения за N изменений
2 1
Пока не будет улучшения за N изменений
2
No changes 2
Число удаляемых элементов 1
Метод отжига Метод отжига (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 Longint Integer
0,23 0,05 0,05
0,26 0,05
0,26
'
•
Можно также кодировать строки, содержащие другие символы, а не только заглавные буквы. Строку из прописных букв и цифр допускается закодировать, используя модуль 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 ]
Перемешивание
В некоторых приложениях требуется выполнять операцию, противоположную сортировке. Задается список элементов, которые программа должна расположить в случайном порядке. Перемешивание (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) ; expend; // Определение разделяющего значения. 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) для организации элементов в списке использует специальную структуру данных, называемую пирамидой. Подобные алгоритмы очень интересны и полезны при реализации очередей с приоритетом. Этот раздел начинается с описания пирамид и способов их реализации в Delphi. Затем рассказывается, как с помощью пирамиды построить эффективные очереди с приоритетом. Написать код алгоритма пирамидальной сортировки очень просто, располагая средствами для управления пирамидами и очередями с приоритетом.
Пирамиды Пирамида (heap) — полное двоичное дерево, в котором каждый узел больше, чем его два дочерних. Это ничего не говорит об отношениях между дочерними узлами. Хотя оба узла должны быть меньше, чем родительский, любой из них может быть больше другого. На рис. 9.5 показана небольшая пирамида.
|j
Сортировка
Рис. 9.5. Пирамида Поскольку каждый узел больше, чем его два дочерних, корневой узел - всегда самый большой в пирамиде. Это делает пирамиду удобной структурой данных для реализации очередей с приоритетом. Если вам понадобится элемент очереди с самым высоким приоритетом, он всегда находится на вершине пирамиды. Поскольку пирамида является полным двоичным деревом, для ее сохранения в массиве вы можете использовать методы, описанные в главе 6. Поместите корневой узел на первую позицию массива. Дочерние узлы узла i расположите в позициях 2 * 1 и 2 * 1 + 1 . Н а рис. 9.6 показана пирамида с рис. 9.5, сохраненная в массиве. Индекс Значение
1
2
3
15 14 13
6
7
8
9 10 11
12 13
14 15
9 10 12
4
3
1
7 11
2
4
5
8
6
5
Рис. 9.6. Представление пирамиды в виде массива Чтобы понять, как формируется пирамида, обратите внимание, что она строится из пирамид меньшего размера. Поддерево, начинающееся в любом узле пирамиды, тоже является пирамидой. Например, в пирамиде, показанной на рис. 9.7, поддерево с корнем в узле 13 - тоже пирамида. Используя этот факт, можно построить пирамиду снизу вверх. Сначала разместите элементы в дереве, как показано на рис. 9.8. Затем из поддеревьев с тремя узлами сформируйте пирамиды. Поскольку в них всего по три узла, сделать это достаточно просто. Сравните верхний узел с двумя его дочерними. Если любой из дочерних узлов больше, поменяйте его с верхним узлом. Если оба дочерних узла больше, поменяйте родительский узел с большим дочерним. Этот шаг повторяется до тех пор, пока все поддеревья из трех узлов не станут пирамидами, как показано на рис. 9.9. Теперь соедините маленькие пирамиды для создания более крупных пирамид. На рис. 9.9 маленькие пирамиды с вершинами 15 и 5 объединяются с элементом 7.
Пирам и да л ьн а я сорти ров ка
Рис. 9.7. Пирамида, составленная из меньших пирамид
А
/V
/\
Рис. 9.S. Несортированный список в полном дереве
11
14
Ma
15
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 показано дерево после того, как поддеревья стали пирамидами.
3
9
i' 8 »'«
6
,'•;
7
12
4
;
Ч
Рис. 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 var
BuildHeap;
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). Сложность этого алгоритма такая же, как и сложность алгоритма сортировки слиянием и быстрой сортировки. Как и сортировка слиянием, пирамидальная сортировка не зависит от значений или представления сортируемых элементов. Помните, что быстрая сортировка некорректно работает со списками, содержащими много дублированных значений; сортировка слиянием и пирамидальная сортировка лишены этого недостатка. Хотя пирамидальная сортировка работает обычно немного медленнее, чем сортировка слиянием, она не требует дополнительного рабочего пространства, которое нужно для сортировки слиянием. Пирамидальная сортировка формирует начальную пирамиду и перемещает элементы в сортированном порядке в пределах первоначального массива данных. Для очень больших списков дополнительное рабочее пространство, необходимое для сортировки слиянием, может вызвать обращение к файлу подкачки, в то время как при использовании пирамидальной сортировки этого не происходит.
ИИИ с°РтиР°вка Сортировка подсчетом Сортировка подсчетом (counting sort) - специализированный алгоритм, который работает очень хорошо, если элементами данных являются целые числа со значениями, которые занимают относительно узкий диапазон. Например, алгоритм выполняется быстро, если все значения находятся между 1 и 1000. Пока выполняются эти условия, алгоритм сортировки подсчетом работает невероятно быстро. В одном испытании на компьютере с процессором Pentium-133 для быстрой сортировки миллиона элементов со значениями от 1 до 10 000 потребовалось 3,93 секунды. Чтобы отсортировать те же самые элементы, сортировка подсчетом заняла всего 0,37 секунды - то есть в 10 раз меньше. Большая скорость сортировки подсчетом достигается за счет того, что при этом не применяются операции сравнения. Ранее в этой главе упоминалось, что любой алгоритм сортировки, включающий в себя подобные операции, должен занимать, по крайней мере, O(N * logN). Без использования операций сравнения алгоритм сортировки подсчетом позволяет упорядочивать элементы за время порядка O(N). Полный исходный текст программы для сортировки подсчетом настолько короток, что сам алгоритм кажется очень простым. В действительности он очень тонок. Сортировка подсчетом начинается с создания массива для подсчета количества элементов, имеющих определенное значение. Если диапазон значений элементов от 1 до М, то алгоритм создает массив счетчиков с нижней границей, равной 1, и верхней - М. Алгоритм устанавливает все записи счетчиков в нуль. Если всего М значений элементов, то массив содержит М записей, и время выполнения этого шага будет порядка О(М). Затем алгоритм вычисляет, сколько раз в списке встречается каждое значение. Рассматривается каждый элемент, и увеличивается значение соответствующей записи счетчика. При исследовании элемента i программа увеличивает значение counts" [ list" [ i ] ]. Алгоритм во время этого процесса исследует каждый элемент один раз, и время выполнения этого шага равно O(N). И наконец, алгоритм проходит через массив счетчиков, помещая соответствующее число элементов в отсортированный массив. Для каждого значения i от 1 до М он добавляет в массив counts" [i] элементов со значением i. Поскольку на этом этапе в каждую позицию массива вставляется всего одна запись, для данной операции потребуется O(N) шагов. type TCountArray = array [1..10000000] of Longint; PCountArray = ATCountArray; procedure Countingsort(list : PLongintArray; counts : PCountArray; min, max, min_value, max_value : Longint); var i, j, new_index : Longint;
Блочная сортировка begin MOT9 // Установка счетчиков в 0. for i := min_value to max_value do 4 counts'!;!] := 0; // Подсчет значений. for i := min to max do 4 4 A counts' [lisf [i] ] := counts~[list [i] ] + 1; // Помещение значений в правильную позицию. new_index := min; for i := min_value to max_value do 4 for j := 1 to counts' !!] do begin list"[new_index] := i; new_index := new_index + 1; end; end; Для выполнения всего алгоритма необходимо порядка О(М) + O(N) + O(N) = О(М + N) шагов. Если М мало по сравнению с N, алгоритм работает чрезвычайно эффективно. Например, если М < N, то О(М + N) = O(N), что довольно быстро. Если N = 100 000, а М = 1000, то М + N = 101 000, в то время как N * logN = 1,6 млн. Кроме того, шаги, выполняемые алгоритмом сортировки подсчетом, также относительно просты по сравнению с шагами быстрой сортировки. Сумма всех этих фактов обеспечивает высокую скорость сортировки подсчетом. С другой стороны, если М больше O(N * logN), то О(М + N) будет больше, чем O(N * logN). В этом случае скорость выполнения сортировки подсчетом может быть ниже, чем скорость O(N * logN) алгоритмов быстрой сортировки. В одном из тестов для сортировки 10 000 элементов со значениями от 1 до 1 млн быстрая сортировка потребовала 0,016 с, в то время как сортировка подсчетом заняла 0,16 с. Алгоритм сортировки подсчетом основывается на том, что значения данных являются целыми числами, поэтому алгоритм не может сортировать данные других типов. В Delphi нельзя создать массив с границами от ААА до ZZZ. Ранее в этой главе в разделе «Объединение и сжатие ключей» было продемонстрировано, как можно кодировать строковые данные с помощью целых чисел. Если вам удастся закодировать данные как целые числа или длинные целые числа, вы сможете использовать сортировку подсчетом. \
Блочная сортировка Как и сортировка подсчетом, блочная сортировка (bucket sort) не использует операций сравнения элементов. Она основывается на значениях элемента, разбивает их на блоки, которые затем рекурсивно сортирует. Когда блоки становятся достаточно маленькими, алгоритм останавливается и использует для завершения процесса более простой метод, например, сортировку выбором.
Сортировка В некотором смысле этот irtfujMi^n подобен алгоритму быстрой сортировки. Быстрая сортировка делит элементы' на два подсписка и рекурсивно сортирует их. Блочная сортировка выполняет почти те же самые действия, только она делит список не на два, а на множество блоков. Чтобы поделить список на блоки, алгоритм предполагает, что значения данных распределены равномерно, и так же равномерно распределяет элементы по блокам. Например, предположим, что элементы данных имеют значения от 1 до 100 и алгоритм использует 10 блоков. Алгоритм размещает элементы со значениями 1-10 в первом блоке, элементы со значениями 11-20 во втором блоке и т.д. На рис. 9.11 показан список из 10 элементов со значениями от 1 до 100, помещенный в 10 блоков. Неупорядоченный список
1
74 38 72
Номер блока
1
2
Блок
1 7
3 38 31
4
63 100 89
57
5
6
7
8
57
63
74 72
89
7 9
31 10 100
Рис. 9.11. Помещение элементов в блоки
Если элементы распределены равномерно, каждый блок будет содержать приблизительно одинаковое число элементов. Если в списке всего N элементов и алгоритм использует N блоков, то каждый блок будет содержать только один или два элемента. Программа может сортировать один или два элемента за постоянное число шагов, поэтому полное время работы алгоритма составляет порядка O(N). Однако на практике значения данных не всегда распределяются равномерно. Некоторые блоки содержат большее количество элементов, другие - меньшее количество. Но если распределение близко к равномерному, то в каждом из блоков окажется лишь небольшое число элементов. Даже если данные распределены так, что один блок содержит много элементов, элементы в пределах блока, возможно, будут распределены равномерно. Когда алгоритм рекурсивно сортирует этот больший блок, они равномерно распределяются среди новых блоков. То есть элементы будут легко обработаны на втором круге алгоритма. Проблемы могут возникать, когда список содержит мало отличающихся друг от друга значений. Например, если все элементы имеют одинаковые значения, алгоритм помещает их в один блок. Если алгоритм не определит ошибку, он снова и снова будет располагать все элементы в одном и том же блоке, что вызовет бесконечную рекурсию и в конечном итоге исчерпает стековое пространство.
Блочная сортировка с использованием связанных списков Реализовать алгоритм блочной сортировки в Delphi возможно несколькими способами. Один из самых простых использует связанные списки. Это облегчает
Блочная сортировка перемещение элементов из одного блока адругоц в процессе работы алгоритма. Следующий код демонстрирует алгоритм рлочно^ сортировки с использованием связанным списков: type л
PCell = ТСе11; TCell = record Value : Longint; NextCell : PCell;' end;
// Данные. // Следующая ячейка.
TCellArray = array [1..1000000] of TCell; PCellArray = "TCellArray; procedure LLBucketsort(top : PCell); var count, min_value, max_value : Longint; i, value, ,bucket_num : Longint; cell, nxt : PCell; bucket : PCellArray; scale : Double; begin cell := top*.NextCell; if (cell = nil) then exit;
// Подсчет ячеек и поиск минимального и максимального значений. count := 1; min_value := се!1л.Value; max_value := min_value; cell := се!1л.NextCell; while (cellonil) do begin count := count + 1; value := се!1л.value; if (min_value > value) then min_value := value; if (max_value < value) then max_value := value; cell := се!1л.NextCell; end ;
// Если min_value = max_value, то имеется только одно значение, // поэтому список отсортирован. if (min_value = max_value)' then exit;
// Если список содержит не более Cutoff ячеек, заканчиваем // сортировку с помощью LLInsertionsort. if (count <= Cutoff) then begin LLInsertionsort(top); exit; / end;
с
°Р™Р°вка
// Размещение пустых блоков,j GetMem (bucket, count *f,Si z.eO^ (TCel 1)) ;
for i := 1 to count do bucket:[i].NextCell := nil;
// Перемещение ячеек в блоки. Scale := (count - 1) / (max_value - min_value); Cell := top".NextCell; while (cellonil) do begin nxt := cell".NextCell; value := cell".value;
if (value = max_value) then bucket_num : = count else
bucket_num := Trunc((value - min_value) * scale) + 1; се!1л.NextCell := bucket"[bucket_num].NextCell; bucket"[bucket_num].NextCell := cell; cell := nxt;
end;
// Рекурсивная сортировка блоков, содержащих более одной ячейки. for i := 1 to count do if (bucketл[1] .NextCellonil) then LLBucketsort (©bucket" [i]) ,-
i
// Объединение сортированных списков. top".NextCell := bucket"[count].NextCell; for i := count - 1 downto 1 do begin cell := bucket"[i].NextCell; if (cellonil) then begin nxt :=.cell".NextCell; while ((nxtonil) and (nxt"'.value
cell".NextCell := top".NextCell,• top".NextCell := bucket"[i].NextCell; // Освобождаем метку конца, если она есть. if (nxtonil) then Dispose(nxt) ; end; end; // Освобождаем память, выделенную для блоков. FreeMem(bucket); end;
Этот метод может быть громоздким, если элементы изначально сохранены в массиве. В этом случае необходимо переместить элементы из массива в связанный
Резюме список и обратно в массив после завершения сортировки. Кроме того, требуется дополнительная память для создания связанного списка. Если элементы изначально сохранены в массиве, то проще и обычно быстрее использовать один из алгоритмов для массивов, представленных ранее. Но если элементы сохранены в связанном списке, блочная сортировка работает намного быстрее, чем сортировка вставкой для связанных списков. В одном их тестов на компьютере с процессором Pentium-133 сортировка вставкой заняла 15,85 с для обработки 20 000 элементов; для блочной сортировки понадобилось 0,039 с. Для более длинных списков производительность O(N2) сортировки со вставкой делает эту разницу еще заметнее.
Резюме В табл. 9.4 приведены преимущества и недостатки алгоритмов сортировки, описанных в этой главе. На основе этих сведений можно вывести несколько правил, которые помогут вам правильно выбрать алгоритм сортировки: а если вам нужно быстро реализовать какой-либо алгоритм, используйте быструю сортировку, а затем в случае необходимости смените алгоритм; а если список более чем на 99% уже отсортирован, используйте пузырьковую сортировку; а если список очень мал (менее 100 элементов), примените сортировку выбором; а если программа хранит значения в связанном списке, используйте блочную сортировку на основе связанных списков; Q если значения элементов списка лежат в небольшом диапазоне (порядка нескольких тысяч), используйте сортировку подсчетом. Итак, зная структуру данных и различные алгоритмы сортировки, вы можете выбрать тот алгоритм, который лучше всего подходит для решения конкретной задачи. Приведенные выше основные правила выбора алгоритмов и данные, представленные в табл. 9.4, помогут вам найти наиболее эффективный алгоритм. Таблица 9.4. Преимущества и недостатки алгоритмов сортировки Алгоритм
Преимущества
Недостатки
Сортировка вставкой
Очень прост Быстро сортирует небольшие списки
Очень медленно работает с большими списками
Сортировка вставкой Прост на основе связанного списка Быстро сортирует небольшие списки Перемещает не данные, а указатели Сортировка выбором Очень прост • Быстро сортирует небольшие списки
Медленно работает с большими списками
Пузырьковая сортировка
Медленно работает ;• во всех остальных случаях
Быстро работает для почти отсортированных списков
Медленно работает с большими списками
Сортировка Таблица 9.4. Преимущества и недостатки алгоритмов сортировки (окончание) Алгоритм
Преимущества
Недостатки
Быстрая сортировка
Быстро сортирует большие списки
Сортировка слиянием
Быстро сортирует большие списки
Работает некорректно при большом количестве одинаковых значений Требует пространства под временные значения Работает медленнее, чем быстрая сортировка
Пирамидальная сортировка
Быстро сортирует большие списки Не требует пространства для временных значений
Работает медленнее, чем сортировка слиянием
Сортировка подсчетом
Очень быстро работает, если разброс входных значений невелик
Работает медленно, если диапазон значений > log(N) Требует дополнительной памяти Работает только с данными целого типа
Блочная сортировка
Очень быстро работает, если данные распределены равномерно Работает с данными, диапазон значений которых велик Работает с данными любого типа
Медленнее, чем сортировка подсчетом
Глава 10. Поиск После того как список элементов отсортирован, может понадобиться найти в нем один из элементов. В этой главе рассматривается несколько алгоритмов для поиска (search) элементов в сортированных списках. Она начинается с краткого описания поиска методом полного перебора. Хотя данный алгоритм работает не так быстро, как другие алгоритмы, он очень прост, что облегчает его написание и отладку. Данный алгоритм очень удобен для обработки совсем небольших списков. Далее в главе описан двоичный поиск. Для того чтобы найти элемент, двоичный поиск несколько раз разбивает список на части, при этом в больших списках такой поиск выполняется намного быстрее, чем полный перебор. Идея, которая лежит в основе метода, достаточно проста, но реализовать ее гораздо сложнее. Затем в главе описывается интерполяционный поиск. Как и двоичный поиск, интерполяционный поиск многократно разбивает список на части. При использовании такого поиска алгоритм делает предположения о том, где должен находится искомый элемент, поэтому для списков, в которых элементы распределены равномерно, он выполняется намного быстрее, чем двоичный поиск. В конце главы рассматриваются методы следящего поиска. Применение этого метода иногда уменьшает время поиска в несколько раз.
Пример программы Программа Search демонстрирует все алгоритмы, описанные в этой главе. Введите число элементов, которое должен содержать список, и нажмите кнопку Create List (Создать список). Программа создаст список на основе массива, где каждый элемент больше предыдущего на значение от 0 до 5, при этом отображается значение наибольшего элемента списка, чтобы вы представляли диапазон значений элементов. После создания списка выберите алгоритмы, которые хотите использовать, отметив соответствующие опции. Введите искомое значение и нажмите кнопку Search (Поиск), чтобы программа выполнила поиск элемента при помощи выбранного вами алгоритма. Поскольку не все элементы присутствуют в списке, вам, возможно, придется ввести несколько различных значений, прежде чем найдется одно из них. Программа также позволяет задавать число повторений для каждого алгоритма. Некоторые алгоритмы выполняются очень быстро, поэтому для того, чтобы сравнить их скорость, необходимо задать для них большое число повторений. На рис. 10.1 изображено окно программы Search после нахождения элемента со значением 250 000. Элемент находился в позиции 83 498 в списке из 100 000
J! Поиск элементов. Чтобы отыскать его, потребовалось проверить 83 498 записей списка алгоритмом полного перебора, 19 записей - алгоритмом двоичного поиска и всего 4 при выполнении интерполяционного поиска.
Полный перебор Чтобы выполнить полный, или последовательный, перебор (exhaustive search), поиск ведется с начала списка, и элементы перебираются последовательно, пока не будет найден искомый. type TLongArray = array [1..10000000] of Longint; ELongArray = "TLongArray; function LinearSearch(target : Longint; List : PLongArray; min, max : Longint) : Longint; var i : Longint; begin for i := min to max do if (list~[i] = target) then , begin // Нашли. Result := I exit; end; // Элемента в списке нет. Result := О
end; Поскольку этот алгоритм исследует список по порядку, он ищет элементы в начале списка быстрее, чем элементы, находящиеся в конце. Наихудший случай для этого алгоритма возникает, когда элемент заканчивает список или вообще отсутствует. Так как алгоритм исследует все элементы списка, время его выполнения в наихудшем случае составляет порядка O(N). Если элемент содержится в списке, то алгоритм должен в среднем исследовать N / 2 записей до того, как обнаружит искомую. Таким образом, в усредненном случае поиск осущеLocation Searches Time ствляется также за время порядка O(N). Хо83498 83498': 0.44 тя алгоритмы, которые выполняются за вре83498 19 О.М мя порядка O(N), нельзя назвать быстрыми, 83498 0.00 этот алгоритм достаточно прост и дает непло83498; 1.65 82498 хие результаты на практике. Для небольших «/Sentinel 83498 83498:: 1,64 списков данный алгоритм показывает приемРис. 10.1. Окно программы Search лемую производительность. ;
перебор Перебор сортированных списков Если список сортирован, то можно немного изменить алгоритм полного перебора, чтобы улучшить его производительность. Если во время поиска алгоритм находит элемент со значением большим, чем значение искомого элемента, то он завершает свою работу. Следовательно, искомый элемент не находится в списке, так как иначе он бы встретился раньше. Например, предположим, что вы ищете значение 12 и доходите до значения 17. Вы пропустили позицию, где должен стоять элемент 12, поэтому можно сказать, что значения 12 в списке нет. Следующий код демонстрирует улучшенный алгоритм полного перебора: function LinearSearchf target : Longint; List : PLongArray; min, max : Longint) : Longint; var I : Longint ; begin for I := min to max do if (list^[i] >= target) then break; if (i > max) then Result := 0 else if (listA[i] = target) then Result := I else Result := 0; end;
Эта модификация уменьшает время выполнения алгоритма, если искомого элемента нет в списке. Предыдущая версия поиска при отсутствии элемента проверила бы весь список до конца. Этот же алгоритм останавливается, как только находит элемент больший, чем искомый. Если искомый элемент расположен случайно между минимальным и максимальным элементами списка, алгоритму в среднем потребуется N / 2 шагов, чтобы определить, что элемента в списке нет. Сложность все еще равна O(N), но в действительности алгоритм работает быстрее. Программа Search использует улучшенную версию алгоритма.
Перебор связанных списков Поиск методом полного перебора - это единственный способ поиска в связанных списках. Поскольку доступ к элементам возможен только через указатели на следующую ячейку, то следует перебрать все элементы с начала списка и до конца, пока не отыщется искомый элемент. Как и в случае полного поиска в массиве, если список уже отсортирован, можно прекратить поиск, когда обнаружится элемент со значением большим, чем значение искомого элемента. Как только пропущена позиция, где должен быть искомый элемент, это будет означать, что элемента в списке нет.
Поиск type PCell = ATCell; TCell = record Value : Longint; NextCell : PCell; PrevCell : PCell; end; function TSearchForm.LListSearch(target : Longint; top : PCell) : PCell; begin top := top^.NextCell;; while (toponil) do begin • if (top".Value >= target) then break; top := top".NextCell; end; if (top = nil) then Result := nil else if (top".Value = target) then Result := top else Result := nil; end;
Программа Search использует этот алгоритм для нахождения элемента в связанном списке. Лишние указатели, предназначенные для управления связанным списком, замедляют выполнение алгоритма, поэтому он работает медленнее, чем алгоритм полного перебора в массиве. Вы можете внести еще одно изменение в алгоритм перебора связанного списка, чтобы алгоритм выполнялся немного быстрее. Если сохранять указатель на конец списка, то можно добавить в конец списка новую ячейку, которая будет содержать искомое значение. Данный элемент называется меткой и выполняет такую же роль, что и метки, описанные в главе 2. Это позволяет программе обрабатывать частные случаи так же, как и все остальные. В данном случае добавление метки в конец списка гарантирует, что алгоритм в конце концов найдет искомый элемент. Программа не может выйти за пределы конца списка, поэтому нет необходимости каждый раз при выполнений цикла while проверять условие top = nil. function SentinelSearch(target : Longint; top : PCell) : PCell; var bottom_sentinel : TCell; begin // Добавление метки. BottomCell".NextCell := @bottom_sentinel; bottom_sentinel.Value := target;
Двоичный поиск // Обычный поиск top := top^NextCell; i while (top".Value < target) do top := top A .NextCell; if ((top A .Valueotarget) or (top = @bottom_sentinel) ,,then Result := nil else Result := top; // Удаление метки. BottomCell'.NextCell := nil; end;
Несмотря на то что такое изменение незначительно, проверка top = nil содержится в часто выполняемом цикле. Для больших списков этот цикл повторяется много раз, поэтому подобная экономия становится значительной. В Delphi такой перебор связанного списка осуществляется приблизительно на 10 % быстрее, чем предыдущая версия. Программа Search демонстрирует обе версии алгоритмов перебора связанных списков, поэтому можно легко сравнить их друг с другом. Некоторые алгоритмы используют потоки для оптимизации перебора. Например, при помощи указателей в ячейках можно организовать список в виде двоичного дерева. Поиск элемента с помощью дерева займет O(logN) шагов, если дерево сбалансировано. Такие структуры данных уже не являются просто списками, поэтому здесь не рассматриваются. Подробную информацию о деревьях можно получить в главах 6 и 7.
Двоичный поиск Как уже упоминалось в предыдущих разделах, поиск полным перебором выполняется очень быстро для небольших списков. Большие списки намного быстрее обрабатывает алгоритм двоичного поиска (binary search) . Алгоритм двоичного поиска сравнивает элемент в середине списка с искомым. Если искомый элемент меньше, алгоритм продолжает перебирать первую половину списка, если же он больше, чем найденный элемент, поиск продолжается во второй половине списка. На рис. 10.2 процесс поиска элемента со значением 44 изображен графически.
9
9
12 13
17
19
21
24
32 | 36 | 44 | 45 [ 54 | 55 | 63 | 66 | 70
Рис. 10.2. Двоичный поиск элемента со значением 44
Поиск Хотя этот алгоритм естественно рекурсивен, его довольно просто записать без рекурсии. Поскольку он достаточно прост для понимания, здесь приводится нерекурсивная версия, которая содержит меньше вызовов функции. Идея, положенная в основу этого алгоритма, проста, но детали ее реализации достаточно сложны. Программа должна аккуратно отслеживать часть массива, которая может содержать искомый элемент. В противном случае элемент может быть пропущен. Для бтслеживания минимального и максимального индекса записей части массива, которая может содержать искомый элемент, алгоритм использует две переменные - min и max. Во время выполнения алгоритма индекс искомого элемента всегда будет находиться между значениями min и max. Другими словами, min <= индекс элемента <= max. Вовремя каждого прохода алгоритм присваивает middle = (min + max) / 2 и проверяет ячейку с индексом middle. Если ее значение равно искомому, то цель найдена и алгоритм завершает свою работу. Если искомый элемент меньше, то алгоритм устанавливает max = middle - 1 и продолжает поиск. Поскольку индексы элементов, которые могут содержать искомый элемент, находятся теперь в диапазоне от min до middle - 1, программа перебирает первую половину списка. Если искомый элемент больше, чем средний, программа устанавливает значение переменной min = middle + 1 и продолжает поиск. Диапазон элементов, который может содержать искомый элемент, лежит теперь в пределах от middl e + 1 до max, поэтому программа продолжает поиск во второй половине списка. В конце концов, программа либо найдет элемент, либо наступит момент, когда значение переменной min будет больше, чем значение max. Значения min и max постоянно корректируются таким образом, чтобы индекс искомого элемента находился всегда между ними. Поскольку в данной точке больше нет индексов между min и max, элемента в списке нет. Следующий код демонстрирует выполнение двоичного поиска в программе Search: function Binary-Search(target : Longint; List : PLongArray;
min, max : Longint) : Longint; var middle-: Longint; begin searches := 0;
// Во время поиска индекс искомого элемента будет между min и max: // min <= искомый индекс <= max. while (min <= max) do begin searches := searches + 1; middle := Round((max+min) / ,2);
if (target = list*[middle]) then begin Result := middle; exit; end else if target < List[middle] then // Перебираем левую половину.
// Мы нашли его.
Интерполяционный поиск
|||
max := middle - 1
else
// Перебираем правую половину. min := middle + 1;
end; // Если мы оказались здесь, то искомого элемента в списке нет. Result := 0; end;
На каждом шаге алгоритм сокращает число элементов, которые все еще могут содержать искомый элемент, вполовину. Для списка размера N алгоритму потребуется максимум O(logN) шагов, чтобы найти любой элемент или определить, что его нет в списке. При этом двоичный поиск работает намного быстрее, чем полный перебор. Полный перебор списка из миллиона элементов занимает в среднем 500 000 шагов. Алгоритму двоичного поиска потребуется максимум log(l 000 000), или 20 шагов.
Интерполяционный поиск Двоичный поиск оптимизирует поиск полным перебором, так как исключает большие части списка, не проверяя значения пропускаемых элементов. Если известно, что значения распределены достаточно равномерно, то можно на каждом шаге исключить еще большее количество элементов, используя интерполяционный поиск (interpolation search) . Интерполяция - это процесс предсказания неизвестных значений на основе имеющихся. В данном случае вы используете индексы известных значений в списке, чтобы определить, какой индекс должно иметь искомое значение. Предположим, что вы имеете такой же список, как на рис. 10.2. Этот список содержит 20 элементов со значениями от 1 до 70. Допустим, что вы хотите найти в этом списке элемент со значением 44. Значение 44 составляет 64% размера диапазона от 1 до 70. Если считать, что значения элементов распределены равномерно, то искомый элемент, предположительно, будет находиться в позиции, составляющей 64% от размера списка - то есть в позиции с индексом 13. Если найденная алгоритмом позиция неверна, то он сравнивает искомое значение со значением в выбранной позиции. Если искомое значение меньше, алгоритм продолжает искать элемент в первой части списка, если больше, то поиск продолжается во второй части списка. На рис. 10.3 графически представлен процесс интерполяционного поиска.
1 [ 4 | 7 | 9 [ 9 12 | 13 | 17 | 19 [ 21 24 32 | 36 | 44 | 45 | 54 55 | 63 | 66 | 70|
Рис. 10.3. Интерполяционный поиск значения 44
Поиск Двоичный поиск разбивает список пополам. Интерполяционный поиск делит список, пытаясь найти ближайший к искомому элемент в списке, при этом точка разбиения определяется следующим кодом: Middle := Round(min + ((target - l i s t A [ m i n ] ) * ((max - m i n ) / ( l i s t ^ [ m a x ] - l i s t * [ m i n ] ) ) ) ) ; Эта операция помещает значение элемента middle между min и max, что соответствует положению искомого элемента между 11зЬЛ [min] и list 1 " 4 [max]. Если искомый элемент близок к list 7 4 [min], то разность target - list~ [min] близка к 0. Тогда middle = min + 0 близко к 0, поэтому значение middle почти равно min. Можно ожидать, что индекс элемента будет близок к min, если его значение почти равно lisf 4 [min]. Аналогично, если искомый элемент находится рядом с list~ [max], разность target - list^ [min] практически такая же, как и разность lisf 4 [max] list^ [min]. Их частное близко к единице, и соотношение выглядит почти как middle = min + (max - min), что упрощается до middle = max. Смысл этого соотношения заключается в том, что если значение элемента близко к list Л [max], то его индекс практически равен max. Когда программа вычислит значение middle, она сравнивает значение элемента в этой позиции с искомым так же, как и при двоичном поиске. Если элемент равен искомому, то программа завершает работу. Если искомый элемент меньше, то программа устанавливает max = middle - 1 и продолжает искать в списке меньшие элементы. Если искомый элемент больше найденного, программа устанавливает min = middle + 1 и продолжает искать в списке большие элементы. Обратите внимание, что соотношение, которое вычисляет новое значение middle, делится на (list' 4 [max] - lisf^min] ). Если list" [min] = list~ [max], то происходит попытка деления на нуль и программа аварийно завершит работу. Это может случаться, если в списке имеется два идентичных значения. Поскольку алгоритм следует условию min < = искомый индекс < = max, эта проблема может возникнуть, если значение min возросло, а значение max уменьшилось до уровня min = max. Чтобы справиться с этой проблемой, программа перед делением проверяет условие list^ [min] = lisf 4 [max]. Если условие выполняется, значит, осталось проверить только одно значение. Программа просто проверяет его на равенство искомому. Еще одна тонкость заключается в том, что вычисленное значение middle не всегда находится между min и max. Самый простой случай, когда это происходит, - это когда искомый элемент лежит за пределами диапазона значений списка. Предположим, что вы ищете значение 300 в списке 100,150,200. Первый раз, когда программа вычисляет средний элемент, min = l,amax= 3. Тогда middle = 1 + (300 -List [1]) * ( 3 - 1 ) / (List[3] - L i s t [ l ] ) = 1 + ( 3 0 0 - 1 0 0 ) *2 / ( 2 0 0 - 100) =5. Индекс 5 находится не только за пределами диапазона min <= искомый индекс <= max, но также за пределами границ массива. Если программа попробует обратиться 4 к элементу Lisf [ 5 ], то она аварийно завершит работу с сообщением об ошибке Subscript out of range.
Интерполяционный поиск Подобная проблема может возникать, если значения между min и max распределены очень неравномерно. Предположим, что надо найти значение 100 в списке О, 1,2, 199, 200. При первом вычислении значения переменной middle вы получите 1+ ( 1 0 0 - 0 ) , * ( 5 - 1 ) / ( 2 0 0 - 0 ) =3. Затем она сравнивает list* [3] с искомым элементом 100. Так как List* [3 ] = 2 меньше, чем 100, программа устанавливает min = middle + 1 = 4. Затем программа вычисляет значение middle - middle = 4+ ( 1 0 0 - 1 9 9 ) * ( 5 - 4 ) / (200-199) =-98. Значение -98 лежит за пределами диапазона mi n <= искомый индекс <= max и далеко за границами массива. Если рассмотреть вычисление среднего значения, то можно увидеть два варианта, при которых новое значение может быть меньше min или больше max. Предположим, что middle меньше min. min + ((target - list*[min]) * ((max - min) / (list* [max] - l i s t * [ m i n ] ) ) ) < min V
Вычитая min из обеих частей, получим: (target - list / 4 [min]) * ((max-min) / (list* [max] - list 7 4 [min]) < 0 Поскольку max >= min, разность (max-min) должна быть больше 0. Поскольку list* [max] >=list* [min], разность (list* [max] - list* [min] ) тоже должна быть больше 0. Тогда единственный вариант, при котором все значение может быть меньше 0, только если разность (target - list* [min] ) менынеО. Это означает, что искомое значение меньше, чем list* [min]. В этом случае искомый элемент не может находиться в списке, так как все записи со значением меньше list* [min] были уже устранены. Теперь предположим, что middle больше, чем max. min + ((target-list*[min]) * ((max - m i n ) / ( l i s t * [ m a x ] - l i s t * [ m i n ] ) ) ) >max
Вычитая min из обеих частей, получим: (target - list*[min]) * (max-min)/(list*[max] - list*[min]) > max - min Умножение обеих частей на (list* [max] -list* [min] ) / (max-min) приводит соотношение к виду: target - List*[min] > List*[max] - list*[min] И наконец, добавляя list* [min] к обеим частям, получаем: target > List*[max] Это означает, что искомое значение больше, чем 1 i s t * [ max ]. Следовательно, искомый элемент не может быть в списке, потому что все записи списка со значениями большими, чем list* [max], были уже устранены. Объединяя эти результаты, получаем, что единственный вариант, при котором новое значение middle может быть вне диапазона от min до max, если искомое значение л ежит вне диапазона от list* [min] до list* [max]. Алгоритм использует этот факт при каждом вычислении нового значения middle. Сначала он проверяет, лежит ли новое значение в диапазоне от min до max. Если нет, то искомого элемента нет в списке, и работа алгоритма завершена.
Поиск Следующий код показывает, как выполняет интерполяционный поиск программа Search: function InterpolationSearchf target : Longint; List : PLongArray; min, max : Longint) : Longint; var middle : Longint ; begin while (min <= max) do begin
// Предотвращение деления на нуль. if (listA [min] = listA[max]) then begin // Это должен быть искомый элемент (если он есть в списке) . if Listtmin] = target then Result := min else Result := 0; exit; end; // Вычисление точки деления. middle := Round (min + ((target - list/4[min])* ((max - min) / (listA[max] - listA [min] ) ) ) ) ; // Удостовериться, что мы не вышли за пределы диапазона. if ( (middle < min) or (middle > max) ) then begin
// Элемента в списке нет. Result := 0; exit; end; if target = List [middle] then
// Элемент найден.
begin
Result := middle; exit; end else if target < List [middle] then // Перебор левой половины. max := middle - 1 else // Перебор правой половины. min := middle + 1; end; // Конец while (min <= max) do. . . // Если мы достигли этой точки, то элемента в списке нет. Result := 0; end;
Строковые данные
Ц|Щ
Двоичный поиск выполняется очень быстро, но интерполяционный поиск еще быстрее. В одном из тестов двоичный поиск занимал в 3 раза больше времени для нахождения значения в списке из 100 000 элементов. Эта разница была бы еще большей, если бы данные были сохранены на жестком диске или другом устройстве, с которого информация считывается медленно. Хотя алгоритм интерполяционного поиска тратит гораздо больше времени на вычисления, за счет меньшего числа обращений к диску он оказывается эффективнее.
Строковые данные Если элементы данных в списке представлены строками, можно использовать два различных варианта. Самый простой - применить двоичный поиск. При двоичном поиске значения элементов непосредственно сравниваются друг с другом, благодаря чему метод может легко обрабатывать строковые данные. С другой стороны, интерполяционный поиск использует числовые значения данных элементов для вычисления индекса искомого элемента. Если элементы представляют собой строки, то алгоритм не может непосредственно по значениям данных вычислить местоположение искомого элемента. Короткие строки можно закодировать как Integer, Longlnt, или Double с помощью методов, описанных в главе 9. После этого допускается использовать интерполяционный поиск. Если строки слишком длинные, чтобы их можно было закодировать даже числами в формате Double, то все еще можно использовать для интерполяции строковые значения. Начните с определения первого символа, которым отличаются lisf 4 [min] и list Л [max]. Закодируйте следующие три символа каждой строки и соответствующие три символа искомого значения. Затем используйте эти значения для интерполяции. Например, предположим, что вы ищете строку TARGET в следующем списке: TABULATE, TANTRUM, TARGET, TATTERED, THEATRE. Если min = 1 И max = 5, то проверяются значения TABULATE и THEATRE. Эти значения отличаются, начиная со второго символа, поэтому необходимо рассматривать три символа, начиная с символа 2. Это АВидля lisf 4 [1],НЕАдля list" 4 [5] и ARG для искомой строки. Эти строки кодируются как 804, 5968 и 1222 соответственно. Подставляя эти значения для вычисления среднего значения в алгоритме интерполяционного поиска, получим: Middle = min + (target - L i s t ( m i n ) ) * ((max - min) / (List(max) - L i s t ( m i n ) ) ) = = 1 + (1222 - 804) * «5 - 1) / (5968 - 8 0 4 ) ) = 1,3 Это значение округляется до 1, поэтому следующее среднее равно 1. ПоскольA ку l i s t [I] = TABULATE меньше, чем TARGET, поиск продолжается для значений min = 2 и max = 5.
I
Поиск
Следящий поиск Если программа должна найти в списке много элементов, и известно заранее, что элементы будут близки друг к другу, то метод отслеживания может значительно ускорить поиск. Вместо того чтобы начинать поиск, проверяя весь список, можно использовать результаты предыдущего поиска, чтобы начать поиск поблизости от искомой позиции.
Двоичное отслеживание и поиск Чтобы начать двоичный следящий поиск (binary hunt search), сравните искомое значение из предыдущего поиска с новым искомым значением. Если новое значение меньше, начните поиск слева, если больше - справа. Для выполнения слежения влево возьмите значения min и max из предыдущего поиска. Затем установите min в min - 1 и сравните искомое значение с list Л [min]. Если искомый элемент меньше, чем 1 i st Л [min], задайте max = min, a min = min - 2 и попробуйте еще раз. Если элемент все же меньше, установите max = min, a min = min - 4 . Если и это не помогает, укажите max = min, a min = min - 8 и т.д. Продолжайте устанавливать max = min и вычитать из min следующую степень 2, пока вы не найдете значение, где list^ [min] будет меньше искомого значения. Убедитесь, что программа не вышла за нижнюю границу массива. Если же она достигла этой точки, установите значение min равным нижней границе массива. Если 1 i s t A [ min ] все еще больше искомого элемента, то данного элемента в списке нет. На рис. 10.4 показан следящий поиск элемента со значением 17 слева от предыдущего искомого элемента со значением 44.
Е
4
7
9
13 | 17 24 32 36 44 45 54J55 63 | 66 9 | 12 19 | 21
J
Рис. 10.4. Двоичный следящий поиск значения 17 из значения 44 Поиск справа аналогичен поиску слева. Возьмите значения min и max от предыдущего поиска. Затем установите min = max и max = max + 1, min = max и max = max + 2, min = max и max = max + 4 и т.д, пока не достигнете точки, где lisf 4 [max] будет больше искомого элемента. Еще раз проверьте, что программа не вышла за пределы массива. По завершении фазы отслеживания уже известно, что индекс искомого элемента находится между min и max. После этого можно использовать обычный двоичный поиск для нахождения точной позиции элемента. Если новый элемент расположен близко к старому, алгоритм следящего поиска быстро отыщет правильные значения для min и max. Если индексы нового и старого элементов отличаются на Р, поиск займет приблизительно log(P) шагов.
Следящий поиск Предположим, что вы начали двоичный поиск без фазы отслеживания. Чтобы сузить диапазон поиска до момента, где min и max будут в пределах Р позиций друг от друга, потребуется приблизительно log (Numltems) - log(P) шагов. Это означает, что отслеживание и поиск будут быстрее, чем обычный двоичный поиск, если log (Р) < log (Numltems) - log ( Р ) . Добавляя log(P) к обеим частям этого уравнения, получим 2 * log(P) < log (Numltems). Если обе части уравнения возвести в степень, получим 2 2 *1од(Р) < 2 log (Numltems) или (21од(Р) ) 2 < Numltems. После упрощения результат будет следующим: Р2 < Numltems. Из этого соотношения видно, что следящий поиск будет выполняться быстрее, если расстояние между двумя последовательно отыскиваемыми элементами меньше квадратного корня из числа элементов в списке. Если следующие друг за другом искомые элементы будут расположены далеко друг от друга, то лучше использовать обычный двоичный поиск.
Интерполяционный следящий поиск Чтобы выполнить интерполяционный следящий поиск (interpolar hunt search), вы можете воспользоваться методами, описанными в предыдущем разделе. Начните со сравнения искомого значения из предыдущего поиска с новым искомым значением. Если новое искомое значение меньше, начните отслеживание слева, если больше - справа. Для слежения слева используйте интерполяцию, чтобы предположить, где может находиться значение в диапазоне между предыдущим значением и значением элемента 1 i s t Л [ 1 ]. Но это обычный интерполяционный поиск, в котором значение min = 1 и max равно индексу из предыдущего поиска. После первого шага фаза отслеживания заканчивается, и можно продолжать обычный интерполяционный поиск. Слежение справа выполняется аналогично. Установите max = Numltems и min равным индексу из предыдущего поиска. Затем продолжайте обычный интерполяционный поиск. На рис. 10.5 изображен процесс интерполяционного поиска элемента со значением 17, начинающийся с предыдущего искомого значения 44.
9 I 9 I 12 13 [ 17 I 19 I 21 [ 24 | 32 | 36 I 44 I 45 | 54 I 55 63 66 70
Рис. 10.5. Интерполяционный поиск значения 17 из значения 44 Если значения данных распределены достаточно равномерно, то интерполяционный поиск всегда выбирает значение, близкое к искомому на первом шаге или на любом последующем. Из этого следует, что начиная с предыдущего найденного
•13
Поиск
значения нельзя значительно улучшить этот алгоритм. На первом шаге, даже без использования результата предыдущего поиска, интерполяционный поиск, вероятно, выберет индекс, который находится достаточно близко от индекса искомого элемента. С другой стороны, использование предыдущего искомого значения может помочь предохранить алгоритм от неравномерности распределения данных. Если известно, что значение нового искомого элемента близко к значению старого, интерполяционный поиск, начинающийся с предыдущего значения, обязательно найдет элемент, который расположен рядом с предыдущим найденным. Это означает, что использование старого элемента в качестве стартовой точки все же дает определенный выигрыш. , Кроме того, результат предыдущего поиска больше ограничивает диапазон возможного положения нового искомого элемента, поэтому алгоритм позволяет сэкономить только один-два шага. Это особенно важно, если список хранится на жестком диске или другом устройстве, с которого информация считывается медленно и где имеет значение каждое обращение к диску. Если сохранять в памяти результаты предыдущего поиска, то можно, по крайней мере, сравнить новое искомое значение с предыдущим без обращения к диску.
Резюме Если элементы сохранены в связанном списке, самым лучшим методом поиска является полный перебор. По возможности используйте метку конца, чтобы немного ускорить поиск. Если требуется время от времени проводить поиск в списке, содержащем десятки элементов, также используйте полный перебор. Данный алгоритм проще отлаживать и поддерживать, чем более сложные алгоритмы поиска, и его применение дает достаточно хорошие результаты. Для больших списков используйте интерполяционный поиск. Если значения данных распределены достаточно равномерно, то он обеспечит наилучшую производительность. Если список хранится на жестком диске или другом медленном запоминающем устройстве, разница во времени между интерполяционным поиском и другими методами поиска может быть весьма значительной. Если значения данных являются строками, попробуйте закодировать их их числами в формате Integer, Longlnt или Double. Тогда вы сможете использовать интерполяционный поиск. Если значения данных слишком длинные и не помещаются даже в числах формата Double, то проще выполнить двоичный поиск. В табл. 10.1 приведены преимущества и недостатки каждого из методов поиска. С помощью двоичного или интерполяционного поиска можно почти мгновенно найти элемент даже в очень большом списке. Если значения данных распределены равномерно, интерполяционный поиск позволяет найти элемент в списке, содержащем миллионы элементов, всего за несколько шагов. Таким большим списком сложно управлять, если необходимо вносить в него какие-либо изменения. Добавление или удаление элемента из сортированного
Резюме списка занимает O(N) шагов. Если элемент находится в начале списка, это отнимет достаточно много времени, особенно если список хранится на медленном устройстве. Таблица 10.1. Преимущества и недостатки различных методов поиска Метод
Преимущества
Полный перебор
Прост Высокая скорость для небольших списков Высокая скорость для больших списков Не зависит от распределения данных Просто обрабатывает строковые данные
Низкая скорость для больших списков
Очень высокая скорость для больших списков
Очень сложен Данные должны быть распределены равномерно Сложно работать со строковыми данными ,
Двоичный поиск
Интерполяционный поиск
Недостатки
Более сложен, чем полный перебор
Если нужно вставлять и удалять элементы из большого списка, следует рассмотреть использование других структур данных. В главе 7 описаны структуры данных, которые позволяют добавлять и удалять элементы всего за O(logN) шагов. В главе 11 обсуждаются методы, позволяющие выполнять вставку и удаление элементов еще быстрее. Такая скорость достигается использованием дополнительных объемов памяти. Кроме того, хеш-таблицы не дают информации о порядке расположения данных. Можно добавлять, находить и удалять элементы из хештаблицы, но нельзя легко вывести элементы в сортированном порядке. Если список никогда не изменяется, то использование упорядоченного списка и интерполяционного поиска даст прекрасные результаты. Если вам нужно постоянно добавлять и удалять элементы, лучше работать с хеш-таблицами. Если при этом нужно выводить элементы по порядку или перемещаться по списку в прямом и обратном направлении, то оптимальную скорость и гибкость может обеспечить применение сбалансированных деревьев. После того как вы определите, какие операции будете выполнять, вы можете выбрать алгоритм, который лучше всего справится с решением поставленной перед вами задачи.
Глава 11. Хеширование
г
В главе 10 описывался алгоритм поиска, использующий интерполяцию для быстрого поиска элемента в списке. Сравнивая искомое значение со значениями в известных позициях, алгоритм может определить позицию, где должен быть искомый элемент. В сущности, создается функция, которая вводит соответствие между искомым значением и индексом позиции, где он должен находиться. Если первое предположение неверно, алгоритм снова использует эту функцию, чтобы сделать новое предположение, и т.д. до тех пор, пока не находит искомый элемент. В алгоритме хеширования (hashing) используется подобный принцип для преобразования элементов в хеш-таблицу. При помощи функции алгоритм хеширования определяет положение элемента в таблице на основе значения искомого элемента. Предположим, что нужно сохранить несколько записей, которые имеют уникальные ключи со значениями от 1 до 100. Вы можете создать массив записей со 100 элементами и установить ключи каждой записи в 0. Чтобы добавить новую запись, вы просто копируете ее данные в соответствующую позицию. Для вставки записи с ключевым значением 37 следует скопировать запись в 37-ю позицию массива. Чтобы найти запись с конкретным значением ключа, программа исследует соответствующую запись массива. Для удаления записи нужно просто установить ее ключевое значение в 0. Используя такую схему, вы можете добавлять, находить и удалять элементы массива всего за один шаг. К сожалению, в реальных приложениях ключевые значения не всегда располагаются в диапазонах от 1 до 100. Возможные ключевые значения обычно охватывают очень широкий диапазон. В качестве ключа база данных, содержащая записи о сотрудниках, может использовать номер социального страхования. Существует 1 млрд возможных комбинаций девятизначных чисел подобно номеру социального страхования. Теоретически можно создать массив с одной записью для каждого возможного девятизначного номера, но на практике для этого не хватит памяти и дискового пространства. При том, что каждая запись занимает 1 Кбайт памяти, для массива потребовалось бы 1 Тбайт (1 млн мегабайт) памяти. Даже если бы компьютер и выделил такой объем памяти, эта схема оказалась бы очень неэкономной. Если в штате компании меньше 10 млн служащих, массив на 99% всегда будет пуст. Для решения подобных задач схемы хеширования отображают потенциально большое количество возможных ключей в относительно компактной хеш-таблице. Если в вашей компании работает 700 рабочих, вы можете объявить хеш-таблицу с 1000 записями. Схема хеширования устанавливает соответствие между 700 записями о служащих и 1000 позициями таблицы. Хеш-функция может заносить записи в ячейки
Связывание таблицы rfo первым трем цифрам номера социального страхования. Запись о сотруднике с номером социального страхования 123-45-6789 будет находиться в позиции 123. Конечно, если возможных ключевых значений больше, чем ячеек таблицы, некоторые ключевые значения должны отобразиться в одну и ту же позицию в хештаблице. Например, значения 123-45-6789 и 123-99-9999 отображаются в таблице в позицию 123. Если есть 1 млрд возможных номеров социального страхования, а в таблице всего 1000 позиций, в среднем каждую позицию будет занимать 1 млн записей. Во избежание подобной проблемы схема хеширования должна включать алгоритм разрешения конфликтов (collision resolution policy), определяющий порядок действий, если ключ отображается на занятую другой записью позицию. В следующих разделах рассматривается несколько различных методов обработки конфликтных ситуаций. Данные методы используют сходные способы разрешения конфликтных ситуаций. Сначала ключ записи отображается на позицию хеш-таблицы. Если позиция уже занята, то ключ заносится в новую позицию. Эта операция повторяется многократно до тех пор, пока алгоритм, наконец, не найдет пустую позицию в таблице. Последовательность действий при нахождении или добавлении элемента в хештаблицу называется последовательностью проверки (probe sequence). . Для реализации хеширования необходимы три вещи: О структура данных, называемая хеш-таблицей, для хранения данных; а хеш-функция для отображения ключевых значений на ячейки таблицы; Q алгоритм разрешения конфликтных ситуаций, который определяет порядок действий в ситуации, если ключи отображаются на одну и ту же позицию. В следующих разделах описывается несколько различных структур данных, которые можно использовать для хеширования. Каждой соответствует определенная хеш-функция и один или более алгоритмов для разрешения конфликтных ситуаций. Как и в большинстве компьютерных алгоритмов, каждый метод имеет свои преимущества и недостатки. В заключительном разделе главы эти методы сравниваются, чтобы вы могли выбрать методику хеширования для решения конкретных задач.
Связывание
.
.
.
. .
•
,
•
.
•
: .-
Один из методов разрешения конфликтов состоит в том, чтобы сохранять записи, отображаемые на одну позицию таблицы, в связанных списках. Для вставки новой записи с помощью хеш-функции выбирается связанный список, в котором будет находиться эта запись. Затем запись добавляется в этот список. На рис. 11.1 показан пример связывания хеш-таблицы, которая содержит 10 ячеек. Хеш-функция отображает ключ К на позицию массива К mod 10. Каждая позиция массива содержит указатель на первый элемент связанного списка. Чтобы вставить элемент в таблицу, вы добавляете его в соответствующий список.
Хеширование 50
81
312 93
425 76
378 609
Рис. 11.1. Связывание Чтобы создать хеш-таблицу в Delphi, нужно объявить массив ячеек, начинающийся с нуля. Этот массив и будет являться списком меток. Если хеш-таблица будет содержать NumChains списков, объявите массив с границами от 0 до NumChains - 1. Установите каждое значение NextCell ячеек в nil. Чтобы найти в хеш-таблице элемент с ключом К, необходимо вычислить К mod NumChains. Таким образом вы получите индекс метки связанного списка, в котором может содержаться элемент. Затем нужно просматривать список, пока не найдется искомый элемент или не будет достигнут конец списка.
var cell : PChainCell; begin // Определение цепи, содержащей значение. cell := ListTci^ [value mod NumChains].NextCell; while (cellonil) do begin
if (се!1Л.value = value) then break; cell := cellA.NextCell; end; if (cellonil) then begin // Какие-либо действия с ячейкой. end;
Чтобы вставить в таблицу элемент с ключом К, сначала вычислите К mod NumChains, определив таким образом, какой список должен содержать данное значение. Затем, добавьте элемент, используя методы, описанные в главе 2. procedure Insertltem(value : TTableData); var cell, new_cell : PChainCell; begin .// Определение цепи, содержащей значение. cell := OListTops"[value mod NumChains]; // Вставка элемента в начало цепи. New(new_cell); .Value := value;
new_cell*.NextCell := се!1л.NextCell,• се!1л.NextCell := new_cell; end; Хеш-таблицы обычно содержат только одну запись с данным ключом. В этом случае процедуре InsertIteih перед добавлением элемента необходимо проверить, нет ли такого элемента в таблице. Чтобы удалить элемент из хеш-таблицы, вычислите К mod NumChains, определив содержащий его список. Затем элемент удаляется из списка при помощи методов, описанных в главе 2. , procedure Removeltem(value : TTableData);
var
cell, nxt_cell : PChainCell; begin // Определение цепи, содержащей значение. cell := @ListTops/4 [value mod NumChains]; nxt_cell := се!1Л.NextCell; // Поиск элемента. while (nxt_cellonil) do begin if (nxt_cel!A.Value = value) then break; cell := nxt_cell; nxt_cell := се!1л.NextCell; end; if (nxt_cellonil) then begin // Ячейка найдена. Удаляем ее. Се11л.NextCell := nxt_cellA.NextCell; Dispose(nxt_cell); end; end;
,
Преимущества и недостатки связывания Одно из преимуществ этого метода заключается в том, что связанные хеш-таблицы никогда не переполняются. Всегда проще осуществить вставку и поиск элементов, даже если элементов в таблице много. Но производительность некоторых методов хеширования сильно падает, если таблица практически заполнена. Удалить элемент из связанной таблицы также очень просто. Для этого достаточно удалить ячейку элемента из соответствующего связанного списка, в то время как в некоторых схемах хеширования удалить элемент трудно или даже невозможно. Один из недостатков связывания в том, что если число связанных списков относительно невелико, то размер списков может стать огромным. Чтобы добавить или найти элемент, придется исследовать большое число элементов списка. Если хеш-таблица содержит 10 связанных списков и вы добавляете к таблице 10 000 элементов, средняя длина связанного списка составит 1000. Всякий раз, когда вам нужно будет найти элемент в таблице, потребуется исследовать 1000 или более ячеек.
11111-
Хеширование
Можно ускорить процесс поиска, отсортировав связанные списки. Тогда для поиска элементов вы сможете использовать методы, описанные в главе 10. Это позволяет прекратить поиск, если программа находит элемент со значением больше искомого. В среднем потребуется проверить только половину списка, чтобы найти элемент или определить, что его нет в списке. var
cell : PChainCell; begin // Определение цепи, содержащей значение. cell := ListTops* [value mod NumChains].NextCell; // Поиск элемента. while (cellonil) do begin if (се11Л.Value >= value) then break; cell := се!1Л.NextCell; end; if (cellonil) then if (cell.Value = value) then begin // Какие-либо действия с ячейкой. , end; end;
Использование упорядоченных списков ускоряет поиск, но не устраняет саму проблему переполнения таблиц. Лучшим решением будет создание хеш-таблицы большего размера и повторное хеширование элементов в новой таблице так, чтобы связанные списки в ней имели меньший размер. Но эта операция может занять много времени, особенно в том случае, если списки сохранены на жестком диске или каком-либо другом медленном устройстве, а не в оперативной памяти. Программа Chain формирует связанную хеш-таблицу. Введите число создаваемых списков в поле Table Creation (Создание таблицы). Отметьте опцию Sort Lists (Упорядоченные списки), если хотите, чтобы программа использовала сортированные списки. Затем щелкните по кнопке Create Table (Создать таблицу), и программа сформирует хеш-таблицу. Можно вводить другие значения и неоднократно использовать кнопку Create Table, чтобы создавать новые хеш-таблицы. Хеш-таблицы, содержащие большое количество элементов, наиболее интересны, поэтому программа Chain позволяет заполнять таблицу случайными элементами. Введите число элементов и максимальное значение элементов в области Random Items (Случайные элементы). Затем щелкните по кнопке Create Items (Создать элементы), и программа добавит случайные элементы в хеш-таблицу. И наконец, введите значение в поле Search Area (Поле поиска). При щелчке по кнопке Add (Добавить) программа вставляет элемент в хеш-таблицу, если такого элемента в таблице нет. Если вы нажимаете кнопку Find (Найти), программа выполняет поиск элемента в таблице. При нажатии кнопки Remove (Удалить) программа удаляет элемент.
Блоки После окончания операций вставки, поиска или удаления программа отображает сводку о выполнении работы в нижней части формы, где сообщается, успешно ли прошла операция, а также показывает число исследованных во время ее выполнения элементов. В данной строке также указывается текущая средняя длина успешной (если элемент есть в таблице) и неудачной (если элемента в таблице нет) последовательностей зондирования. Программа вычисляет это среднее значение, выполняя поиск для всех чисел между единицей и наибольшим числом в хеш-таблице и подсчитывая затем среднюю длину последовательности зондирования. На рис. 11.2 показано окно программы Chain после успешного поиска элемента 777.
Hie
Help
Table Creation в Chain* [To (7 Sort Lilts Create Table I Random Items' ft Items [3D Ma»
I .-
~~~i
0: 10 1: 131
80 731 402 23 824 615 166 117 346
2: 262 3 4: 474 5: 315 6: 16 7: 107 8: 128 9: 449 .919
3:
710 752 553
Э53
736 .•; 776 «777»: Э17 958 "999
ЗЭЭ
Create Items
-Search^
Found. Т his probe: 3. Ave successful probe: 2,10. Ave unsuccessful probe: 2,40.
Рис. 11.2. Окно программы Chain
Блоки Другой способ обработки конфликтных ситуаций заключается в том, чтобы объявить некоторое число блоков, каждый из которых может содержать несколько элементов. Для вставки в таблицу элемент отображается на блок и затем помещается в этот блок. Если блок уже заполнен, то выполняется обработка переполнения. Самая простая обработка ситуации переполнения блока - помещать все лишние элементы в блоки переполнения в конце массива обычных блоков. Когда требуется больше блоков переполнения, необходимо создать новый, больший массив и скопировать в него текущие записи. : Например, чтобы добавить новый элемент К к хеш-таблице, содержащей пять блоков, сначала попытайтесь добавить его в блок с номером К mod 5. Если этот блок заполнен, поместите его в блок переполнения. Чтобы найти элемент К в таблице, вычислите К mod 5 и затем выполните поиск в этом блоке. Если элемента в данном блоке нет, а блок не заполнен, то элемента в хеш-таблице нет. Если элемента в данном блоке нет и блок заполнен, необходимо проверить блоки переполнения. На рис. 11.3 показано пять блоков, пронумерованных от 0 до 4, и один блок переполнения. Каждый блок может содержать пять элементов. В хеш-таблицу были добавлены следующие элементы в указанном порядке: 50, 13, 10, 72, 25, 46, 68,30,99,85,93,65,70. При вставке элементов 65 и 70 блоки были уже заполнены, поэтому они были помещены в первый блок переполнения. Чтобы реализовать схему блочного хеширования в Delphi, вы можете использовать массив указателей на блоки, которые представляют собой массивы изменяемого размера. Если потребуется изменить размеры массива, следует только
Хеширование Основные блоки 1
2
3
4
46
72
13
99
Дополнительные блоки
68
65
I
70
93 . -
.
'
Рис. 11.3. Хеширование с использованием блоков
скопировать указатели на старые блоки в новый массив, не копируя при этом все содержимое блоков. type TTableData = Longint; TBucket = array [0..1000000] of TTableData; PBucket = "TBucket;
TBucketArray = array [0..1000000] of PBucket; PBucketArray = "TBucketArray; Чтобы найти элемент К, вычислите номер блока К mod NumBuckets. Затем перебирайте этот блок до тех пор, пока не обнаружите искомый элемент, пустую ячейку блока или конец блока. Если элемент найден, алгоритм завершает работу. Если встретится пустая ячейка, значит, элемента в хеш-таблице нет, и процесс также заканчивается. Если проверен весь блок, а искомый элемент или пустая ячейка не найдены, проверьте блоки переполнения.
var bucket, pos : Integer; begin
// Какой блок содержит искомый элемент. bucket := (value mod NumBuckets); // Ищем элемент или неиспользуемую запись. for pos := 0 to BucketSize - 1 do begin item_probes := item_probes + 1; if (Buckets'4 [bucket]74 [pos] = UNUSED) then begin
// Элемента здесь нет. exit; end; л
if (Buckets"[bucket] [pos] = value) then begin
// Элемент найден. Какие-либо действия с наиденным элементом. exit; end; end;
// Если элемент не найден, проверяем сегменты переполнения. for bucket := NumBuckets to MaxOverflow do begin bucket_probes := bucket_probes + 1; for pos := 0 to Buckets!ze - 1 do begin item_probes := item_probes 1; л if (Buckets"[bucket] [pos] UNUSED) then begin // Элемента здесь нет. exit; end; л if (Buckets*[bucket] [pos] = value) then begin // Элемент найден. Какие-либо действия с найденным // элементом. exit;
end; end; end;
// Если элемент все же не нашли, то его в таблице нет. end;
Программа Bucket демонстрирует применение данной схемы хеширования. Эта программа очень похожа на программу Chain, но использует блоки, а не связанные списки. Когда программа выводит длину последовательности зондирования, она выдает число проверенных блоков и элементов в них. На рис. 11.4 изображена программа после успешного поиска элемента 668 в первом блоке переполнения. В данном примере было исследовано семь элементов в двух блоках.
0: 885 Z45 1: 821 ; 34i
73S i 81 '•' 1S6
г: S27 , ;' 17 ,; в'£7'""".1р7:",:' '£ 3: 213 4: 464
ЕЗв; 664
,: 73 S4
783 : -- : 404 J
probe: 2/9. tucceuful probe: 1,30/4.30. unsuccessful probe: 2,60/11,18
Рис. 11.4. Окно программы Bucket
|{
Хеширование
Хранение хеш-таблиц на диске Большинство запоминающих устройств, таких как накопители на магнитной ленте, флоппи-дисководы и жесткие диски, могут считывать большие объемы данных за одно обращение к устройству. Обычно эти блоки имеют размер 512 или 1024 байта. При этом на чтение всего блока данных потребуется столько же времени, сколько и на чтение одного байта. Если хеш-таблица большого размера хранится на жестком диске, этим можно воспользоваться, чтобы увеличить производительность программы. Доступ к данным на диске занимает гораздо больше времени, чем доступ к данным в оперативной памяти. Если сразу загружать все элементы блока, то их можно считывать за одно обращение к диску. Как только элементы загружены в память, с ними можно работать намного быстрее, чем если бы компьютер считывал их с диска по одному. Следующий код определяет тип блока как массив с фиксированным числом элементов: const
,
BUCKET_SIZE = 5; type
TTableData = Longint; TBucket = array [0..BUCKET_SIZE-1] of TTableData;
Затем объявляется файл данных того же типа. Переменная TBucket должна иметь постоянный размер, чтобы система могла определить размер записи в файле. Поэтому размер блока вводится в код программы.
var DataFile : file of TBucket;
Для открытия файла программа использует процедуры Delphi AssignFile и Reset. Новый файл создается с помощью переменных AssignFile и Rewrite. Чтобы было удобнее работать, можно написать функции для считывания и за<писи блоков. Эти функции помещают информацию в глобальную переменную TheBucket, которая содержит данные одного блока. После того как данные загружены в переменную, можно выполнить поиск элементов в пределах блока, хранящегося в памяти.
var
TheBucket : TBucket;
// Считывание блока. procedure GetBucket(num : Longint); begin Seek(DataFile,num);
Read(DataFile,TheBucket); end; // Сохранение блока в файле. procedure PutBucket(num : Longint);
begin Seek(DataFile,num); Write(DataFile,TheBucket); end; Используя процедуры GetBucket и PutBucket, перепишите процедуру поиска в хеш-таблице, чтобы считывать записи из файла.
var bucket, pos : Integer; begin // Определение блока, в котором находятся элемент. bucket := (value mod NumBuckets); GetBucket(bucket); '••г*''' ' // Поиск элемента или неиспользуемой позиции. for pos := 0 to BUCKET_SIZE - 1 do begin if (TheBucket[pos] = UNUSED) then begin // Элемента в списке нет. exit; end ; if (TheBucket[pos] = value) then begin // Элемент найден. Какие-либо действия с этим элементом. v
exit; end; end; ', :
'
...
'
'.
'
'
' .
''• ••
• ' • : • . -
// Если не нашли элемент, проверяем блоки переполнения. for bucket := NumBuckets to MaxOverflow do begin bucket_probes := bucket_probes + 1; GetBucket(bucket); for pos := 0 to BUCKET_SIZE - 1 do begin item_probes := item_probes + 1; if (TheBucket[pos] = UNUSED) then ', begin // Элемента в списке нет. exit; end; if (TheBucket[pos] = value) then begin // Элемент найден. Какие-либо действия с элементом. exit; end;
V
Хеширование end; end; // Если элемент так и не найден, то его в хеш-таблице нет. end;
Программа Bucket2 аналогична программе Bucket за исключением того, что она сохраняет блоки на диске. Кроме того, она не вычисляет и не выводит на экран среднюю длину последовательности проверки, так как эти вычисления требуют большого количества обращений к диску и очень сильно замедляют программу. Поскольку блоки хранятся на диске, а элементы - в памяти, число обращений к блокам при определении времени работы программы гораздо важнее, чем общее количество проверенных элементов. Каждый блок в программе Bucket2 может содержать до пяти элементов. Это позволяет легко вставлять элементы в блоки до тех пор, пока те не переполнятся. В реальной программе нужно уместить в блок как можно больше элементов так, чтобы размер блока оставался при этом кратным целому числу кластеров диска. Например, можно читать и записывать данные блоками по 1024 байт. Следующий код показывает, как программа может определить размер блоков по типу записи о служащем.
type TTableData = record LastName : String[20]; FirstName : String[20]; EmployeelD : Integer; end; const ITEM_SIZE = SizeOf(TTableData) ; ITEMS_PER_BUCKET = 1024 div ITEM_SIZE; type TBucket = array [0..ITEMS_PER_BUCKET - 1] of TTableData;'
Размещение большего количества элементов в каждом блоке позволяет за одно обращение к диску считывать сразу большой объем данных. Это также позволяет записать в таблицу больше элементов, не используя раньше времени блоки переполнения. Доступ к блокам переполнения требует дополнительных обращений к диску, поэтому следует по возможности избегать его. С другой стороны, если блоки достаточно большие, они могут содержать множество пустых ячеек. Если элементы данных распределены среди блоков неравномерно, некоторые блоки могут переполниться, а другие могут быть почти пустыми. Различное размещение большего числа меньших блоков может устранить эту проблему. Хотя некоторые элементы данных будут все же находиться в блоках переполнения, а некоторые блоки будут почти пустыми, то частично заполненные блоки будут меньше, поэтому они не будут содержать много неиспользуемых записей. На рис. 11.5 показаны два варианта размещения одних и тех же данных в блоках. В первом варианте задействованы пять блоков, которые содержат по пять
Блоки элементов. Блоки переполнения не используются, всего имеется 12 пустых ячеек. Во втором варианте, изображенном в нижней части рисунка, задействованы десять блоков, содержащих по два элемента. Здесь используется один блок переполнения и имеется девять пустых ячеек. Это пример компромисса между памятью и временем. В первом варианте все элементы расположены в обычных блоках (не в блоках переполнения), поэтому любой элемент всегда можно быстро найти. Второй вариант размещения экономит память, но располагает некоторые элементы в блоках переполнения, обращение к которым занимает больше времени. Основные блоки
О
50
46
10
2
3
4
72
13
99
57
68
25
93
35
28
65
73
Основные блоки 0
50 50
1
2
3
72
4
Дополнительные 9
блоки
5
6
7
8
93
25
46
57
18
28
73
35
68
65
Рис. 11.5. Два варианта размещения данных в блоках
Связывание блоков Немного другой способ управления заполненными блоками заключается в связывании их с блоками переполнения. Каждому заполненному блоку соответствует определенный набор блоков переполнения, при таком подходе общие блоки переполнения не используются. При поиске элемента в заполненном блоке не нужно исследовать все элементы, которые записаны из других блоков в общий блок переполнения. Если переполнено множество блоков, то этот прием может сэкономить достаточно много времени. На рис. 11.6 изображено использование двух разных схем хеширования для одних и тех же данных. На верхней части рисунка элементы, вызывающие переполнение, помещаются в общие блоки переполнения. Чтобы найти элементы 32 или 30, следует обратиться к трем блокам. Сначала исследуется блок, в котором должен находиться элемент. Его там нет, поэтому необходимо проверить первый блок переполнения. Элемента там тоже нет, и программа исследует второй блок переполнения, в котором, наконец, находится искомый элемент.
|i
Хеширование
0
Основные блоки 1 2 3
55
77
93
65
32
10
52
78
28
30
0
Основные блоки 1 2 3
55
77
93
10
52
78
4
4
Дс полнительные блоки
Дополнительные блоки 28
65
32
30
Рис. 11.6. Связанные блоки переполнения
На нижней части рисунка показан вариант, в котором заполненные блоки связаны со своими собственными блоками переполнения. В этом случае можно отыскать любой элемент после исследования не более чем двух блоков. Как и в прошлый раз, вначале проверяется блок, где должен находиться элемент. Если его там нет, требуется проверить связанный список блоков переполнения. В этом примере, чтобы найти элемент, нужно проверить всего один блок переполнения. Если в блоках переполнения хеш-таблицы содержится много элементов, то связывание блоков переполнения может сэкономить много времени. Предположим, что имеется достаточно большая хеш-таблица, содержащая 1000 блоков, каждый из которых вмещает по 10 элементов. Предположим также, что в блоках переполнения находятся 1000 элементов, для которых понадобится 100 блоков переполнения. Чтобы найти один из последних элементов в блоках переполнения, необходимо исследовать 101 блок. Рассмотрим еще более худший вариант. Предположим, что вы хотите найти элемент К, которого нет в таблице, но он отображается на полный блок. В этом случае вы должны перебрать все 100 блоков переполнения, прежде чем вы узнаете, что элемента в таблице нет. Если ваша программа часто пытается найти элементы, которых нет в таблице, то значительная часть времени будет тратиться на проверку блоков переполнения. Если блоки переполнения связаны между собой и ключевые значения распределены достаточно равномерно, то найти любой элемент можно гораздо быстрее. Если максимальное число элементов, вызывающих переполнение, для одного блока равно 10, каждый блок будет иметь максимум один блок переполнения. В этом случае, чтобы,найти любой элемент или определить, что элемента в таблице нет, понадобится проверить не более двух блоков. , С другой стороны, если хеш-таблица не сильно переполнена, многие блоки будут иметь блоки переполнения, содержащие всего один или два элемента. Предположим, что в каждом блоке находится по 11 элементов. Поскольку в блок
помещается только 10 элементов, для каждого обычного блока создается блок переполнения. В этом случае необходимо 1000 блоков переполнения, в каждом из которых будет 900 пустых ячеек. Это еще один пример компромисса между памятью и временем. Связывание блоков позволяет добавлять и находить элементы более быстро, но при этом в хештаблице может быть много пустых ячеек. Можно избежать этой проблемы, создав новую хеш-таблицу большего размера и поместив в нее все записи старой таблицы.
Удаление элементов Удалить элементы из блоков не так просто, как из связанных списков, но все же возможно. Во-первых, нужно найти элемент, который требуется удалить. Если содержащий его блок не полный, то следует просто заменить нужный элемент последним. При этом все заполненные ячейки блока будут храниться в его начале. Тогда, если позднее при поиске элемента в блоке найдется пустая ячейка, это будет означать, что элемента в таблице нет. Если блок, содержащий искомый элемент, заполнен, нужно провести поиск элемента, который может заменить удаляемый, в блоках переполнения. Если ни один из элементов в блоках переполнения не принадлежит к данному блоку, то необходимо заменить нужный элемент конечным элементом блока, оставив последнюю ячейку пустой. Если же в блоке переполнения есть элемент, принадлежащий к этому блоку, следует переместить выбранный элемент в соответствующий блок на место удаляемого элемента. При этом в блоке переполнения образуется пустое пространство, но это легко исправить. Нужно переместить последний элемент блока переполнения в образовавшуюся пустую ячейку. Рис. 11.7 иллюстрирует процесс удаления элемента из заполненного блока. Сначала элемент 24 удаляется из блока 0. Поскольку блок 0 был заполнен, нужно искать элемент в блоках переполнения, который можно было бы вставить в блок 0. В данном примере блок 0 содержит все четные элементы, поэтому подойдет любой четный элемент из блоков переполнения. Первый четный элемент в блоках переполнения - элемент 14, поэтому можно заменить элемент 24 в блоке 0 элементом 14. Основные блоки О Удаляется
Дополнительные блоки
63
84
93
98
14
31
37
79
42
РИС. 11.7. Удаление элемента из заполненного блока
При этом в третьей позиции первого блока переполнения остается пустая ячейка. Ее следует заполнить последним элементом из последнего блока переполнения, в данном случае элементом 79. Итак, хеш-таблица снова готова к использованию. Альтернативный метод удаления элементов состоит в том, чтобы отметить элемент как удаленный, но оставить его в блоке. Чтобы найти элементы в этом блоке, нужно игнорировать удаленные элементы. Позже, когда новые элементы добавляются в данный блок, можно будет помещать их на место элементов, помеченных как удаленные. Маркировать элемент как удаленный быстрее и проще, чем удалять его из хештаблицы, но в конце концов таблица может заполниться пустыми ячейками. Большинство настоящих данных будет находиться в конце блоков и в блоках переполнения. В таком случае добавлять новые элементы в таблицу просто, но при поиске элемента много времени уйдет на пропуск удаленных элементов. Компромиссом является следующее решение этой проблемы: когда элемент удаляется из блока, можно переместить последний элемент в освободившуюся позицию и затем отметить последний элемент как удаленный. В этом случае можно прекратить поиск в блоке, как только программа найдет элемент, помеченный как удаленный.
Преимущества и недостатки использования блоков Добавление или поиск элемента в хеш-таблице с блоками происходит достаточно быстро, даже когда таблица заполнена. Фактически хеш-таблица, использующая блоки, будет работать быстрее, чем хеш-таблица со связыванием (связывание описано в предыдущем разделе, не путайте со связыванием блоков). Если хеш-таблица сохранена на жестком диске, можно считывать весь блок за одно обращение к диску. При использовании связывания следующий элемент в цепочке может и не быть на диске рядом с предыдущим и нужно обращаться к диску каждый раз, когда нужно найти какой-либо элемент. Удалить элемент из таблицы, использующей блоки, сложнее, чем удалить его из таблицы на основе связанных списков. Чтобы удалить элемент из заполненного блока, понадобится проверить все блоки переполнения, чтобы найти подходящий элемент для замены. Еще одно преимущество хеш-таблицы с использованием блоков, сохраненных на диске, состоит в том, что расширить таблицу, если она переполняется, очень просто. Когда все резервные блоки заполнятся, нужно просто создать новый блок переполнения в конце файла. Если вы часто увеличиваете размер таблицы подобным образом, большая часть данных будет храниться в блоках переполнения. Тогда для того, чтобы найти или добавить элемент, требуется исследовать множество блоков и производительность программы резко снизится. В этом случае лучше создать новую хеш-таблицу с большим количеством блоков и перенести в нее элементы.
Открытая адресация Иногда элементы данных слишком велики, чтобы их можно было разместить в блоках. Если необходим список из 1000 элементов, каждый из которых занимает
Открытая адресация 1 Мбайт дискового пространства, трудно использовать блоки, которые могут содержать более одного или двух элементов. Если блоки содержат всего один или два элемента, то для поиска или вставки элемента потребуется проверить множество блоков. При открытой адресации (open addressing) хеш-функция вычисляет положение элементов данных в массиве. Например, в качестве хеш-таблицы можно использовать массив с нижним индексом 0 и верхним 99. Тогда хеш-функция будет сопоставлять ключу со значением К индекс массива, равный К mod 100. При этом программа вставляет значение 1723 в таблицу на позицию 23. Затем, когда понадобится найти элемент 1723, в массиве исследуется именно эта позиция. Различные схемы открытой адресации используют разные методы для генерации последовательностей зондирования. В следующих разделах описываются три наиболее важных метода: линейная, квадратичная и псевдослучайная проверка.
Линейная проверка Если новый элемент отображается на занятую позицию массива, то можно просто просмотреть массив от этой точки, пока не найдется незанятая позиция. Эта методика разрешения конфликтных ситуаций названа линейной проверкой (linear probing), потому что поиск в таблице осуществляется линейным способом (последовательно). Рассмотрим снова пример с массивом размерами от 0 до 99 и хеш-функцией, отображающей элемент К на позицию К mod 100. Чтобы добавить элемент 1723, сначала исследуется позиция массива 23. Если она занята, то проверяется позиция 24. Если и она используется, рассматриваются позиции 25, 26, 27 и т.д., пока не найдется пустая позиция. Чтобы вставить в хеш-таблицу новый элемент, таблица проверяется с помощью линейной последовательности, пока не обнаружится свободная ячейка. Чтобы найти в таблице элемент, выполняется то же действие, пока не отыщется элемент или пустая ячейка. Если пустая ячейка встретится раньше, значит, элемент в хеш-таблице отсутствует. Можно записать объединенную функцию хеширования и проверки таким образом: H a s h ( K , P ) = (К + Р> mod 100, где Р = 0 , 1 , 2 , . . . '
Здесь Р - это номер элемента в последовательности проверки для элемента К. Другими словами, для хеширования элемента К проверяются элементы Hash (К, 0), H a s h ( K , l ) , Hash (К, 2) и следующие до тех пор, пока не найдется незанятая позиция. Можно обобщить эту идею, построив таблицу размера N с использованием массива размерами от 0 до N - 1. Хеш-функция выглядит следующим образом: Hash(K,P) = (К + Р) mod N, где Р = 0 , 1 , 2 , . . .
Следующий код показывает, как можно найти элемент в Delphi, используя линейную проверку.
Хеширование Давайте рассмотрим, как образуются кластеры. Предположим, что у вас есть пустая хеш-таблица, которая может содержать N ячеек. Если вы выбираете случайное число и вставляете его в таблицу, вероятность того, что элемент будет расположен в любой позиции Р, равна 1/N. При вставке второго случайно выбранного элемента вероятность того, что он отображается на ту же самую позицию, равна 1/N. В случае конфликта он помещается в позицию Р + 1. Вероятность того, что элемент располагается точно в позиции Р + 1, равна 1/N, а вероятность того, что он находится в позиции Р - 1, тоже равна 1/N. Во всех трех случаях новый элемент отображается рядом с ранее вставленным элементом. Общая вероятность того, что новый элемент окажется рядом с предыдущим, образуя небольшой кластер, составляет 3/N. Эта вероятность немного больше, чем вероятность 1/N того, что элемент будет располагаться на любой другой конкретной позиции. Как только кластер начинает расти, вероятность того, что следующие элементы будут располагаться вблизи него, увеличивается. Если кластер содержит два элемента, вероятность того, что следующий элемент добавится к нему, равна 4/N. Если в кластере четыре элемента, вероятность увеличения группы равна 6/N и т.д. Приращение кластера продолжается до тех пор, пока он не встретится со смежным кластером. Два кластера объединяются и образуют кластер еще большего размера, который увеличивается еще быстрее, сливается с другими кластерами и образует еще большие кластеры. В идеале, если хеш-таблица заполнена наполовину, элементы в таблице будут занимать каждую вторую позицию массива. Тогда вероятность того, что алгоритм сразу же найдет пустую позицию для следующего добавленного элемента равна 50%. Также существует 50-процентная вероятность того, что он найдет пустую позицию после исследования всего двух позиций таблицы. Средняя длина последовательности проверок равна 0,5 * 1 + 0,5 * 2 - 1,5. В наихудшем случае все элементы в таблице будут сгруппированы вместе в один огромный кластер. Вероятность того, что алгоритм сразу же найдет пустую ячейку для следующего элемента, все еще равна 50%. Но если он не находит пустую ячейку сразу, то для ее поиска потребуется гораздо больше времени. Если элемент должен быть на первой позиции кластера, то алгоритму придется проверить все элементы в кластере, чтобы найти свободную ячейку. В среднем это займет намного больше времени, чем вставка элемента в хеш-таблицу с равномерным распределением. На практике степень кластеризации будет чем-то средним между этими крайними случаями. Вы можете использовать программу Linear, чтобы исследовать эффект кластеризации. Запустите программу и постройте хеш-таблицу со 100 ячейками. Затем добавьте 50 случайных элементов со значениями до 999. Вы обнаружите, что сформировалось несколько кластеров. В одном из тестов 38 из 50 элементов стали частью кластера. Если добавить еще 25 элементов, то большинство элементов будут входить в кластеры. В другом тесте 70 из 75 элементов были сгруппированы в кластеры.
Открыггая^адресация Упорядоченная линейная проверка Когда программа выполняет полный поиск в сортированном списке, можно остановить процесс, если обнаружится элемент со значением большим, чем искомое. Позиция, где должен был находиться искомый элемент, позади, значит, элемента в списке нет. Эта идея пригодится при поиске в хеш-таблице. Предположим, элементы помещаются в хеш-таблицу таким образом, что значения в каждой последовательности проверок располагаются в порядке возрастания. В таком случае при выполнении последовательной проверки во время поиска какого-либо элемента программа может остановить поиск, если найдет элемент со значением, большим искомого. Позиция, где должен был помещаться искомый элемент, пройдена, следовательно, его нет в таблице. function Findltemfvalue : TTableData; var probes : Integer) : TOrderedRetumValue; var
new_value : TTableData; pos : Integer; begin probes := 1; pos := (value mod TableSize); repeat new_value := HashTable"[pos];
// Бесконечный цикл.
// Если мы нашли элемент, то готово. if (new_value = value) then begin Result := ordFound; exit; end; // Если элемент не используется, то значения в таблице нет. if (new_value = UNUSED) then begin Result := ordNotFound; exit; end; // Если исследуемое значение больше, чем новое. if (new_value > value) then begin Result := ordNotFound; expend;
// Пытаемся проверить следующую позицию последовательности. pos := (pos + 1) mod TableSize; probes := probes + 1;
Хеширование // Если мы рассмотрели все ячейки. if (probes > TableSize) then begin Result •;= ordNotFound; exit; end; until(False); // Конец бесконечного цикла поиска значения.
end;
Реализация этого метода возможна только в том случае, если элементы в хештаблице отсортированы так, чтобы программа исследовала их всегда в порядке возрастания. Существует достаточно простой способ добавления элементов, который гарантирует такое расположение. Когда в таблицу вставляется новый элемент, для него выполняется последовательность проверок. Если будет найдена свободная ячейка, программа помещает элемент в эту позицию, и процедура заканчивается. Если вы встречается элемент со значением большим, чем значение нового элемента, эти два элемента меняются местами. Затем выполняется последовательность проверок для большего элемента. При этом может встретиться элемент с еще большим значением. Элементы меняются местами, а поиск возобновляется для большего элемента. Процесс продолжается до тех пор, пока не найдется пустая ячейка для размещения текущего элемента, при этом, возможно, несколько элементов поменяются местами. function Insertltem(value : TTableData; var probes : Integer) : TOrderedReturnValue; var new_value : TTableData; pos : Integer; begin probes := 1; pos := (value mod TableSize);
repeat new_value := HashTable'4 [pos] ;
// Бесконечный цикл.
// Если мы нашли значение, то оно уже здесь. if (new_value = value) then begin Result := ordFound; exit; end;
// Если ячейка не используется, то значение должно быть здесь. if (new_value = UNUSED) then begin НазпТаЫеЛ[роз] := value;
Result := ordlnserted; NumUnused := NumUnused - 1; exit; end;
Открытая адресация // Если исследуемое значение больше, чем новое. if (new_value > value) then begin // Меняем их и продолжаем. HashTable A [pos] := value; Value := new_value; end; // Исследуем следующую позицию в последовательности проверок. pos := (pos + 1) mod TableSize; probes := probes + 1; // Если мы проверили все ячейки. if (probes > TableSize) then begin Result := ordTableFull expend; until(False); // Конец бесконечного цикла поиска значения. end;
Программа Ordered демонстрирует открытую адресацию с упорядоченной линейной проверкой. Она идентична программе Linear, за исключением того, что использует упорядоченную хеш-таблицу. В табл. 11.2 приведена средняя длина тестовой последовательности успешных и неудачных поисков с использованием линейных и упорядоченных линейных проверок. Средняя длина успешных поисков для этих двух методов одинакова, но в случае неудачи упорядоченная линейная проверка выполняется намного быстрее. Разница особенно заметна, если хеш-таблица заполнена больше чем на 70%. Таблица 11.2. Длина поиска для линейной и упорядоченной линейной проверок Число занятых ячеек 10 20 30 40 50 60 70 80 90 100
Линейная Успешно
Неудачно
1,10 1,15 1,20 1,35 1,92 2,03 2,61 3,41 3,81 6,51
1,12 1,26 1,50 1,83 2,64 3,35 5,17 8,00 10,74 100,00
Упорядоченная линейная Успешно Неудачно 1,10 1,10 1,23
1,38 1,36 1,53 1,64 2,04 3,42 6,16
1,04 1,09 1,13 1,23 1,35 1,56 1,76 2,18 3,88 6,20
Оба метода при вставке нового элемента совершают приблизительно одинаковое число шагов. Чтобы добавить к таблице элемент К, каждый метод начинает с позиции К mod NumEntries и проходит по хеш-таблице, пока не встречает
|i
Хеширование
пустую ячейку. Во время упорядоченного хеширования, возможно, понадобится менять элементы местами. Если элементы представляют собой записи большого размера, это может занимать достаточно много времени, особенно если записи хранятся на жестком диске или другом медленном запоминающем устройстве. Упорядоченные линейные проверки, безусловно, лучший выбор, если известно, что ваша программа будет совершать большое число безуспешных операций поиска. Если часто будет выполняться поиск несуществующих элементов или элементы таблицы слишком объемны и перемещать их сложно, то можно улучшить производительность, используя неупорядоченную линейную проверку.
Квадратичная проверка Один из способов уменьшить эффект первичной кластеризации заключается в том, чтобы использовать хеш-функцию следующего вида: 2
Hash(K,P) = (К + Р ) mod N, где Р = 0 , 1 , 2 , . . .
Предположим, что при вставке в хеш-таблицу элемент отображается в кластер, сформированный другими элементами. Если элемент отображается на позицию возле начала кластера, то возникнет несколько конфликтных ситуаций, прежде чем найдется пустая ячейка для этого элемента. Поскольку параметр Р в функции хеширования растет, значение этой функции изменяется очень быстро. Это означает, что конечное положение элемента, возможно, и не будет смежным с данным кластером. На рис. 11.8 показана хеш-таблица, содержащая большой кластер элементов. На нем также изображены последовательности проверок, которые возникают при попытке вставки двух различных элементов в позиции, заполненные элементами кластера. Обе эти последовательности заканчиваются в точке, которая не является смежной с кластером, поэтому после добавления элементов размер кластера не увеличивается.
I
I
I
I
I
Рис. 11.8. Квадратичная проверка В следующем коде показывается, как найти элементы, используя квадратичную проверку (quadratic probing). function Findltem(value : TTableData; var probes : Integer) : TQuadraticReturnValue; var new_value : TTableData; pos : Integer; begin probes := 1; pos := (value mod TableSize);
repeat // Бесконечный цикл. л new_value := НазпТаЫе [pos] ,« // Если мы нашли элемент, то готово. if (new_value = value) then begin Result:= qFound; exit; end;
// Если ячейка не используется, то элемента в таблице нет. if (new_value = UNUSED) then begin Result := qNotFound; expend;
// Пытаемся найти его в следующей позиции последовательности. pos := (value + probes * probes) mod TableSize; probes := probes + 1; // Если мы исследовали все ячейки. if (probes > TableSize) then begin Result := qNotFound; exit; * end;
until(False);
// Конец бесконечного цикла поиска значения.
end;
Программа Quad демонстрирует открытую адресацию с квадратичной проверкой. Она аналогична программе Linear, но использует не линейную, а квадратичную проверку. В табл. 11.3. приведена средняя последовательность проверок программ Linear и Quad для хеш-таблицы со 100 ячейками и значениями элементов от 1 до 999. В целом квадратичная проверка дает лучшие результаты. Таблица 11.3. Длины поиска для линейной и квадратичной проверок Число занятых ячеек
Линейная Успешно
10
1,10 1,15 1,20 1,35 1,92 2,03 2,61 3,41 3,81 6,51
20 30 40 50 60 70 80 90 100
Неудачно 1,12 1,26 1,50 1,83 2,64 3,35 5,17 8,00 10,74
100,00
Квадратичная Успешно Неудачно 1,00 1,10 1,33 1,77 1,80 1,88 2,09 2,30 2,77 3,79
1,11
1,21 1,44 1,75 2,14 2,67 3,43 5,05 15,03
101,00
Хеширование Квадратичная проверка имеет и некоторые недостатки. Поскольку последовательность проверок генерируется, то нельзя гарантировать, что она обойдет все ячейки в таблице. Следовательно, иногда невозможно будет вставить элемент, даже если таблица еще не заполнена. Рассмотрим небольшую хеш-таблицу, содержащую всего шесть ячеек. Последовательность проверки для числа 3 такова: 3 2 3 -к I = • 4 == 2 3 -г 2 = 7 == 2 н З = 12 = 2 3 -h 4 = 19 = 2 3 -1- 5 = 28 = 2 3 -^ б = 39 = 2 3 -1- 7 = 52 = 2 3 -н 8 = 67 = 2 3 -н 9 = 84 = 2 3 -н 10 = 103
з -
И
4 (mod 6) 1 (mod 6) 0 (mod 6) 1 (mod 6) 4 (mod 6) 3 (mod 6) 4 (mod 6) 1 (mod 6) 0 (mod 6) = 1 (mod 6)
т.д.
Эта последовательность обращается к элементам 4 и 1 дважды перед тем, как обратиться к элементу 3, и никогда не попадает в позиции 2 и 5. Чтобы представить себе этот эффект наглядно, создайте с помощью программы Quad хеш-таблицу с шестью элементами. Затем добавьте в нее элементы 1, 3, 4, 6 и 9 в указанном порядке. Программа определит, что таблица заполнена целиком, хотя вы знаете, что еще есть две свободные ячейки. Последовательность зондирования для элемента 9 не обращается к позициям 5 и 2, поэтому нельзя вставить этот элемент в таблицу. Квадратичная последовательность проверок посетит по крайней мере N/2 записей таблицы из N элементов. Хотя это в некоторой степени увеличивает производительность, но остается нерешенной проблема, которая возникает, если таблица почти заполнена. Поскольку производительность в любом случае заметно упадет, лучше увеличить таблицу, а не беспокоиться о том, сможет ли последовательность найти свободную ячейку. Не столь очевидная проблема квадратичной проверки состоит в том, что хотя этот метод устраняет первичную кластеризацию, во время проверки может возникать вторичная кластеризация (secondary clustering). Если два элемента изначально отображаются на одну позицию, то для них будет выполняться одна и та же последовательность зондирования. Если на одну позицию отображается много элементов, то они образуют вторичный кластер, который распределен по всей таблице. Когда появляется элемент с таким же значением, для него приходится выполнять длительную последовательность проверки, пока для него не будет найдена соответствующая позиция во вторичном кластере. На рис. 11.9. изображена хеш-таблица, содержащая 10 ячеек. В таблице находятся элементы 2,12,22 и 32, каждый из которых изначально отображался в позицию 2. Если вы попытаетесь добавить в таблицу элемент 42, придется выполнить
Открытая адресация длинную последовательность зондирования, которая обращается к каждому из указанных элементов, прежде чем найдет пустую ячейку.
Рис. 11.9. Вторичная кластеризация
Псевдослучайная проверка Кластеризация возникает, когда в кластер добавляются элементы, отображающиеся на уже занятые кластером ячейки. При вторичной кластеризации элементы изначально отображаются на одну позицию и проходят одну и ту же последовательность проверок, образуя вторичный кластер, распределенный по всей таблице. Можно устранить оба эффекта, сделав так, чтобы для различных элементов выполнялись разные последовательности проверок, даже если элементы изначально отображаются на одну позицию. Один из способов реализации такого подхода состоит в использовании генератора псевдослучайных чисел для формирования последовательности проверок. Для того чтобы вычислить последовательность проверок для элемента, используйте его числовое значение, выполнив начальную установку для генератора псевдослучайных чисел. Затем последовательность зондирования строится на основе случайных чисел, получаемых на выходе генератора. Это называется псевдослучайной проверкой (pseudo-random probe). Когда позднее потребуется найти элемент в хеш-таблице, еще раз установите генератор случайных чисел, используя значение элемента. Генератор выдаст ту же самую последовательность чисел, которую вы применяли для добавления элемента. С помощью этих чисел можно воссоздать исходную последовательность проверки и найти элемент. Качественный генератор при разных значениях элементов будет производить различные случайные числа и, следовательно, различные последовательности проверки. Даже если два значения элемента первоначально отображаются на одну позицию, следующие позиции в их последовательностях проверки будут различны. В этом случае в хеш-таблице не будет возникать кластеризация. Вы можете установить генератор случайных чисел Delphi в начальное значение, используя инструкцию RandSeed. Оператор Random генерирует одинаковые последовательности каждый раз, когда генератор инициализирован одним и тем же начальным числом. Следующий код показывает, как можно найти элемент с помощью псевдослучайной проверки:
Хеширование function FindOrInsert(value : TTableData; var probes : Integer) : TRandomReturnValue; var new_value : TTableData; pos : Integer; begin
// Установка генератора случайных чисел. RandSeed := value; probes := 0;
repeat
// Бесконечный цикл.
// Генерируем следующее значение в псевдослучайной // последовательности. pos := Random(TableSize); probes := probes + 1; new_value := HashTable"[pos];
// Если мы нашли элемент, то готово. if (new_value = value) then begin Result := rndFound; exit; end;
// Если ячейка не используется, то элемента в таблице нет. if (new_value = UNUSED) then begin Result := rndNotFound;
expend;
// Если мы исследовали все записи таблицы. if (probes > TableSize) then begin Result := rndNotFound; exit; end; until(False); //-Конец бесконечного цикла поиска значения.
end;
Программа Rand демонстрирует открытую адресацию с псевдослучайной проверкой. Она аналогична программам Linear и Quad, но использует псевдослучайную, а не линейную и квадратичную проверки. В табл. 11.4 показана приблизительная средняя длина последовательности проверки, полученной в программах Quad и Random для хеш-таблицы со 100 ячейками и элементами в пределах от 1 до 999. Псевдослучайная проверка обычно дает лучшие результаты, хотя разница между квадратичной и псевдослучайной проверками не так велика, как между линейной и квадратичной. Псевдослучайная проверка также имеет и недостатки. Поскольку последовательность проверки выбирается псевдослучайно, нельзя точно предсказать, сколько раз алгоритм обратится к каждому элементу таблицы.
Открытая адресация
||
Таблица 11.4. Длина поиска для квадратичной и псевдослучайной проверок Число занятых ячеек
Квадратичная Успешно Неудачно
10 20 30 40 50 60 70 80 90 100
1,00 1,10 1,33 1,77 1,80 1,88 2,09 2,30 2,77 3,79
:
1,11
1,21 .1,44 1,75 2,14 2,67 3,43 5,05 15,03 101,00
Псевдослучайная Успешно Неудачно 1,00 1,15 1,13 1,23 1,36 1,47 1,70 1,90 2,30 3,79
1,10 1,24 1,41 1,63 1,91 2,37 3,17 4,70 9,69
101,00
Если таблица мала по сравнению с числом возможных псевдослучайных чисел, существует шанс, что последовательность проверки посетит несколько раз одно значение и только потом перейдет к другим значениям в таблице. Также возможно, что последовательность проверки вообще пропустит ячейку таблицы, поэтому невозможно будет вставить элемент, даже если таблица имеет свободные ячейки. Как и в случае с квадратичной проверкой, этот эффект может вызвать затруднения, только если таблица практически заполнена. В этом случае расширение таблицы обеспечивает лучшую производительность, чем поиск свободного места в таблице.
Удаление элементов Удалить элемент из хеш-таблицы, в которой используется открытая адресация, не так легко, как из таблицы на основе связывания или блоков. Нельзя просто удалить элемент из таблицы, потому что он может находиться в последовательности проверки для другого элемента. Предположим, что элемент А находится в последовательности проверки для элемента В. Если удалить из таблицы элемент А, невозможно будет найти элемент В. Во время его поиска вы обнаружите пустую оставшуюся от элемента А позицию, и сделаете неправильное заключение, что элемента В в таблице нет. Вместо удаления элемента из таблицы можно пометить его как удаленный. Допускается использовать эту ячейку позднее, если она встретится во время вставки нового элемента в таблицу. Если помеченный элемент обнаруживается во время поиска другого элемента, он просто игнорируется, и последовательность проверки продолжится. После того как вы пометите как удаленные большое количество элементов, хеш-таблица может заполниться «мусором» и на поиск элементов будет уходить много времени. В конце концов потребуется перераспределение элементов в таблице для освобождения неиспользуемого пространства.
Хеширование Перераспределение Чтобы освободить записи хеш-таблицы, помеченные как удаленные, можно перераспределить элементы таблицы, или выполнить ее рехеширование (rehashing). Но прежде нужно выяснить, не было ли выполнено перераспределение элемента раньше. Один из способов реализации такого подхода заключается в использовании массива переменных Boolean, указывающих ячейки, которые еще не перераспределены. Начните с установки всех этих значений в True. Это означает, что все элементы должны быть перераспределены. Затем следует просмотреть таблицу в поисках записей, которые не отмечены как удаленные и еще не перераспределены. Если подобный элемент обнаружится, он удаляется из таблицы, которая повторно хешируется, при этом выполняется обычная последовательность проверок для элемента. Если встречается пустая или помеченная как удаленная ячейка, то элемент помещается в нее, помечается как перераспределенный, и продолжается поиск других элементов, которые еще не перераспределены. Если при перераспределении элемента встречается элемент, который уже отмечен как перераспределенный, то последовательность проверки продолжается. При обнаружении элемента, который еще не перераспределен, элементы меняются местами, текущая позиция маркируется как перераспределенная, и процесс начинается снова.
type TBoolArray = array [0..1000000] of Boolean; PBoolArray = ЛТВоо1Аггау; procedure Rehash; var not_rehashed : PBoolArray; i, pos : Integer; value, new_value : TTableData; begin // Выделение места для флагов перераспределения. GetMem(not_rehashed,TableSize*SizeOf(Boolean)); // Пометка всех элементов как неперераспределенных. for i -.= 0 to TableSize - 1 do not_rehashedA[i] := True; // Поиск неперераспределенных элементов. for i := 0 to TableSize - 1 do begin A if (not_rehashed [i]) then begin value := HashTableA[i]; HashTableA[i] := UNUSED; // He перераспределяем удаленные или неиспользуемые ячейки. if ((value = DELETED) or (value = UNUSED)) then continue;
Резюме ,
// В противном случае проходим по последовательности проверки, // пока не найдем пустую, неиспользуемую или // неперераспределенную ячейку. pos := value mod TableSize» repeat // Бесконечный цикл. new_value := НавЬТаЫел [pos] ; // Если данная ячейка пустая или удаленная, помещаем // элемент здесь. if ((new_value = UNUSED) or (new_value = DELETED» then begin HashTableA[pos] := value; not^rehashed^tpos] := False; break; end; ,
// Если данная ячейка содержит неперераспределенный // элемент, меняем его и продолжаем. if (not_rehashed"[pos]) then begin НазЬТаЫеЛ [pos] := value; notArehashed's [pos] := False; value := new_value; pos := value mod TableSize; end else pos := (pos + 1) mod TableSize; until (False); // Конец бесконечного цикла. end; // Конец if (not_rehashed*[i]) then... end; // Конец for i:=0 to TableSize-1 do... end;
«•
,
Изменение размеров хеш-таблиц Если хеш-таблица почти заполнена, производительность резко снижается. В этом случае лучше увеличить таблицу и создать дополнительное место для большего числа ячеек. И наоборот, если хеш-таблица содержит очень мало записей, можно уменьшить ее, освободив память. Используя методы, подобные методу перераспределения элементов, вы можете увеличить или уменьшить хеш-таблицу. Чтобы изменить размеры хеш-таблицы, объявите новый массив. Затем перераспределите элементы, переместив их в новую таблицу. Программа Rehash использует открытую адресацию с линейной проверкой. Она аналогична программе Linear, но также позволяет помечать объекты как удаленные и перераспределять элементы таблицы.
Резюме Различные типы хеш-таблиц, описанные в этой главе, имеют свои преимущества и недостатки.
Хеширование Хеш-таблицы на основе связывания или блоков легко увеличить или удалять из них элементы. Использование блоков также упрощает работу с таблицами, сохраненными на диске. За одно обращение к диску считывается сразу множество элементов данных. Однако оба этих метода выполняются медленнее, чем методы открытой адресации. Линейную проверку просто реализовать, и она позволяет довольно быстро добавлять элементы в хеш-таблицу и выполнять их поиск. Упорядоченная линейная проверка позволяет определить, что элемента в таблице нет, быстрее, чем неупорядоченная. С другой стороны, вставку элементов в таблицу при этом выполнить сложнее. Квадратичная проверка устраняет первичную кластеризацию, влияющую на линейную проверку, поэтому при использовании этого метода обеспечивается лучшая производительность. Псевдослучайная проверка устраняет как первичную, так и вторичную кластеризацию, и обеспечивает еще более высокую производительность. В табл. 11.5 приведены преимущества и недостатки различных методов хеширования. •
Таблица 11.5. Преимущества и недостатки различных методов хеширования Метод
Преимущества
Недостатки
Связывание
Легко увеличить размер Легко удалять элементы Нет пустых ячеек
Медленно работает с большими списками
Блочный
Легко увеличить размер Легко удалять элементы Работает с данными на диске
Работает медленно, если создано много дополнительных блоков Содержит пустые ячейки
Связывание блоков
Легко увеличить размер блоков Работает с данными на диске
Содержит больше пустых ячеек Легко удалять элементы
Линейная проверка
Быстрый доступ Сложно удалять элементы
Сложно увеличить размер таблицы Содержит пустые ячейки
Упорядоченная линейная проверка
Быстрый доступ Короткие безуспешные проверки
Сложно увеличить размер таблицы Сложно удалять элементы Вставка элементов выполняется медленнее Содержит пустые ячейки
Квадратичная проверка
Более быстрый доступ
Сложно увеличить размер таблицы Сложно удалять элементы Содержит пустые ячейки
Псевдослучайная проверка
Самый быстрый доступ
Сложно увеличить размер таблицы Сложно удалять элементы Содержит пустые ячейки
Резюме Выбор наиболее подходящей для конкретного приложения схемы хеширования зависит от данных и способа их использования. При применении разных схем достигаются различные компромиссы между занимаемой памятью, скоростью и простотой модификации. Табл. 11.5 поможет вам выбрать наилучший алгоритм для вашего приложения.
Глава 12. Сетевые алгоритмы В главах 6 и 7 рассматривались алгоритмы обработки деревьев. В данной главе обсуждается более общая тема сетей. Сети играют важную роль во многих приложениях. Их можно использовать для моделирования различных объектов, например сети улиц, телефонной и электрической линий, водных каналов, коллекторов, ливневых стоков, авиалиний и железных дорог. Сети можно использовать для решения многих практических задач, таких как разбиение на районы, а также для различных видов планирования и распределения работ.
Определения Сеть (network), или граф (graph) - это набор узлов, связанных ребрами, или дугами (edges), или связями (link). В отличие от дерева, в сети нет предков и потомков. Узлы, соединенные с другими узлами, являются скорее соседями, чем родительскими или дочерними узлами. Каждое звено в сети может иметь соответствующее направление. В этом случае сеть называется направленной сетью (directed network). Каждая дуга может также иметь соответствующую стоимость (cost). В сети улиц, например, стоимость равна времени, которое требуется, чтобы проехать по участку дороги, представленному дугой сети. В телефонной сети стоимость могла бы быть затуханием на участке кабеля, представленного дугой. На рис. 12.1 показана небольшая направленная сеть, в которой числа рядом с дугами соответствуют их стоимости.
10
13 11
15 Рис. 12.1. Направленная сеть со стоимостью связей
Представления сетей
||
Путь (path) между узлами А и В - это последовательность дуг, которые соединяют эти узлы. Если между любыми двумя узлами сети есть не больше одного ребра, то путь можно описать, перечислив входящие в него узлы. Поскольку такое описание проще представить наглядно, пути по возможности описываются именно так. На рис. 12.1 путь, содержащий узлы В, Е, F, G, Е, и D, соединяет узлы В и D. Цикл (cycle) - это путь, который соединяет узел с самим собой. Путь Е, F, G, E на рис. 12.1 является циклом. Путь называется простым (simple), если он не содержит циклов. Путь В, Е, F, G, E, D не является простым, потому что он содержит цикл Е, F, G, E. Если между двумя узлами существует какой-либо путь, то должен существовать и простой путь между ними. Можно найти его, удалив все циклы из первоначального пути. Например, если заменить цикл Е, F, G, Е узлом Е в пути В, Е, F, G, Е, D, получится простой путь В, Е, D между узлами В и D. Сеть называется связанной (connected), если между любыми двумя узлами сети есть хотя бы один путь. В направленной сети не всегда очевидно, существует такая связь или нет. На рис. 12.2 сеть слева связана. Сеть справа не является таковой, потому что от узла Е к узлу С нет ни одного пути.
Рис. 12.2. Связанная (слева) и несвязанная (справа) сети
Представления сетей В главе 6 описаны некоторые представления для деревьев. Большинство этих представлений подходит также и для управления сетями. Например, для сохранения сетей могут использоваться такие представления, как метод полных узлов, списки дочерних узлов (для сетей список соседних узлов) и представление нумерацией связей. Более подробная информация об этом содержится в главе 6. Для разных приложений лучше подходят разные представления сети. Представление полными узлами приводит к хорошим результатам, если каждый узел сети связан с ограниченным числом ребер. Список соседних узлов обеспечивает большую гибкость, чем метод полных узлов. Представление с помощью нумерации связей, хотя его сложнее изменять, требует меньше памяти для сохранения сети.
Сетевые алгоритмы Кроме того, несколько вариантов представления ребер могут упростить управление определенными типами сетей. Эти форматы используют один класс для представления узлов и другой - для представления связей. Применение класса для связей облегчает работу со свойствами ребра, такими как стоимость. Например, направленная сеть со стоимостью ребер может использовать следующее определение для класса узла и дуги. Каждое ребро хранит указатели на узлы, где оно начинается и заканчивается. Каждый узел хранит связанный список ребер, исходящих из него. Узел также имеет указатель NextNode, и программа может сохранять все узлы сети в связанном списке. type PLink = ЛТЫп)с; PNode = ATNode; TLink = record ToNode : PNode; Cost : Integer; NextLink : PLink; \ end;
// Конечный узел звена. • // Стоимость. // Следующее звено в списке звеньев // начального узла.
TNode = record Id : Integer; X : Integer; Y : Integer; LinkSentinel : TLink; NextNode : PNode;
end,-
// Идентификатор узла. // Позиция. // Звенья, исходящие из данного узла. // Следующий узел в списке всех узлов.
Используя такие определения, программа может найти ребро с минимальной стоимостью при помощи следующего кода.
var link, best_link : PLink; best_cost : Integer; begin best_cost := 32767; best_link := nil; link := node~.LinkSentinel.NextLink; while (linkonil) do begin if (link~.Cost < best_cost) then begin best_link := link; best_cost := link^Cost; end; link := 1ink".NextLink; end; end;
,
Классы узла и дуги часто расширяют для удобства работы с конкретными алгоритмами. Например, к классу узла часто добавляется флаг Marked. Когда программа
Представления сетей
! | | И |
обращается к узлу, она устанавливает Marked в True, чтобы впоследствии легко можно было его отыскать. Для ненаправленной сети используется несколько другое представление. Класс узла остается таким же, а вот класс дуги включает указатели на оба соединяемых узла, которые на каждом конце ребра могут указывать на одну и ту же его структуру. type TLink = record Nodel : PNode; 'Node2 : PNode ; Cost : Integer; NextLink : PLink;
// // // // //
Узел на одном конце. Узел на другом конце. Стоимость. Следующее звено в списке звеньев начального узла.
end;
Для ненаправленной сети предыдущее представление использовало бы два объекта для хранения каждого ребра - по одному для каждого направления связи. В новой версии каждое ребро представлено одним объектом. Этот способ описания достаточно нагляден, поэтому он используется далее в главе. Программа NetEdit применяет такое представление для управления ненаправленной сетью со стоимостями связей. Меню File (Файл) позволяет открывать и сохранять сети в файлах. Команды меню Edit (Правка) позволяют добавлять и удалять узлы и связи. Окно программы NetEdit показано на рис. 12.3.
Рис. 12.3. Окно программы NetEdit
Управление узлами и связями Корень дерева уникален, это единственный узел в дереве, который не имеет родителя. Начав от корневого узла и следуя дочерним указателям, можно найти все остальные узлы дерева. Это делает корень удобным дескриптором дерева. Если вы сохраните указатель на корневой узел, то сможете потом обратиться ко всем узлам дерева.
Сетевые алгоритмы Сети не всегда содержат узел, который обладает такими уникальными свойствами. В несвязанной сети может и не быть способа обойти все узлы по связям, начав с одной точки. По этой причине сетевые программы часто включают в себя полный список всех узлов сети, а также могут хранить список всех ребер. Данные списки существенно упрощают работу со всеми связями и узлами сети. Например, если программа хранит связанные списки всех узлов и ребер, она может вывести сеть на экран при помощи следующего метода:
var node : PNode ; link : PLink; begin // Сначала рисуем связи. link := top_link; while (linkonil) do begin // Рисование связи. A
link := link .Next_Link; end; / // Рисование узлов. node := top_node; while (nodeonil) do begin // Рисование узла. node := nodeA.NextNode; end;
Обход сети Обход сети подобен обходу дерева. Можно обойти сеть, используя обход либо в глубину, либо в ширину. Обход в ширину обычно похож на прямой обход деревьев, хотя для сети можно также определить также обратный и симметричный обход. Алгоритм прямого обхода двоичного дерева, описанный в главе 6, формулируется так: 1. Обратиться к узлу. 2. Выполнить рекурсивный прямой обход левого поддерева. 3. Выполнить рекурсивный прямой обход правого поддерева. В дереве между связанными узлами существует отношение «родительскийдочерний». Поскольку алгоритм начинает с корня и всегда движется вниз через дочерние узлы, он никогда не обратится к узлу дважды. В сети узлы не обязательно соединены сверху вниз. Если вы попытаетесь реализовать в сети алгоритм прямого обхода сети, то возникнет бесконечный цикл.
Обход сети Чтобы предотвратить это, алгоритм должен пометить посещаемый узел. При поиске в соседних узлах обращение происходит только к узлам, которые еще не были помечены. Когда алгоритм заканчивается, все узлы в сети будут помечены как посещенные (если сеть связана). Алгоритм прямого обхода сети выполняется в следующем порядке: 1. Пометить узел. 2. Посетить узел. 3. Выполнить рекурсивный обход непомеченных соседних узлов; В Delphi можно добавить флаг V i s i t e d к классу TNode: type A
PNode = TNode; TNode = record
Id : Integer;
// Идентификатор узла.
X : Integer; Y : Integer;
// Позиция.
LinkSentinel : TLink; NextNode : PNode;
// Звенья, исходящие из данного узла. •// Следующий узел списка всех узлов.
Visited : Boolean; end;
// Был ли узел посещен?
Следующий код демонстрирует, как процедура может обойти все непомеченные узлы, начиная с данного. В связанной сети алгоритм обратится к каждому узлу. procedure Traverse(node : PNode);' , var link : PLink; , neighbor : PNode; begin
// Помечаем узел как посещенный. node*.Visited := True; // Посещение непомеченных соседних узлов. link := node'4.LinkSentinel .NextLink; while (linkonil) do begin
// Какой узел является соседним для данного. if (linkA.Nodel= node) then neighbor := link/4.Node2 else neighbor := linkA.Nodel; // Посещаем соседний узел/ если он еще не помечен. If (not neighborA.Visited) then Traverse(neighbor); // Исследуем следующее ребро. link := link74.NextLink; end; . end;
Сетевые алгори™ы_ Поскольку эта процедура не обращается дважды ни к одному узлу, набор обходимых связей не содержит циклов и образует дерево. В связанной сети дерево будет обходить каждый узел. Поскольку дерево охватывает каждый узел сети, оно названо остовным деревом (spanning tree), или каркасом. На рис. 12.4 показана небольшая сеть. Каркас ее дерева с корнем в узле А изобраРис. 12.4. Каркас дерева жен жирными линиями. Вы можете использовать методику пометки узлов, чтобы преобразовать алгоритм обхода дерева в ширину в сетевой алгоритм. Алгоритм обхода дерева начинает работу, помещая корневой узел дерева в очередь. Затем первый узел из очереди удаляется, происходит обращение к узлу, и его дочерние узлы помещаются в конце очереди. Этот процесс повторяется до тех пор, пока очередь не опустеет. Прежде чем выполнять обход сети, необходимо убедиться, что узел не проверялся раньше или уже не находится в очереди. Чтобы удостовериться в этом, помечайте каждый узел, который помещается в очередь. Ниже приводится сетевая версия алгоритма: 1. Пометить первый узел (это будет корень остовного дерева) и добавить его в конец очереди. 2. Повторять следующие шаги, пока очередь не опустеет: - удалить первый узел из очереди и обратится к нему; - пометить каждый из непомеченных соседних узлов и добавить его в конец очереди. Следующая процедура выводит список узлов сети в порядке обхода в ширину: procedure BreadthFirstPrint(root : PNode); var queue : TNodeQueue; node, neighbor : PNode; link : PLink;
begin
// Помещаем корень в очередь. гoot*.Marked := True;
queue.Ent erQueue(root); // Многократно обрабатываем верхний элемент очереди, пока очередь // не опустеет. while (queue.NumIterns > 0) do begin
// Получаем следующий узел из очереди. node := queue.LeaveQueue;
Наименьший каркас дерева // Вывод идентификатора узла. // Добавляем непомеченные соседние узлы в очередь. A link := node .LinkSentinel.NextLink; while (linkonil) do begin // Какие узлы являются соседними? if (link*.Nodel = node-) then A neighbor := lirik .Node2 else
neighbor := link*.Nodel; // Если соседний узел еще не был посещен, добавляем его // в очередь. if (not neighbor.Visited) then queue.EnterQueue(neighbor); // Переходим к следующему звену. link := link'^.NextLink; end; // Конец проверки связей, исходящих из данного узла. end,// Конец проверки узлов в очереди. end;
Наименьший каркас дерева Если задана сеть со стоимостями ребер, минимальным, или наименьшим, каркасом дерева (minimal spanning tree) именуется каркас, общая стоимость всех ребер в котором минимальна. Вы можете использовать минимальный каркас, чтобы выбрать самый дешевый способ соединения всех узлов сети. Предположим, что требуется спроектировать телефонную сеть, соединяющую шесть городов. Можно проложить магистральный кабель между каждой парой городов, но это нерентабельно. Следует соединить города связями, которые содержатся в минимальном каркасе дерева. На рис. 12.5 показано шесть городов, каждые два из которых соединены междугородными линиями. Наименьшее остовное дерево выделено жирными линиями. Обратите внимание, что сеть может содержать больше одного минимального каркаса. На рис. 12.6 представлены два варианта одной сети с двумя различными минимальными остовными деревьями, выделенными жирными линиями. Суммарная стоимость обоих деревьев равна 32. Существует простой алгоритм поиска минимального остовного дерева для сети. Сначала поместите любой узел в остовное дерево. Затем найдите связь с минимальной стоимостью, которая соединяет узел дерева с узлом, еще не помещен- рис. 12.5. Телефонные линии, ным в него. соединяющие шесть городов
Сетевые алгоритмы
ю
Рис. 12.6. Два различных минимальных остовныхдерева для одной сети Добавьте это ребро и соответствующий узел к дереву. Процедура повторяется до тех пор, пока к дереву не добавятся все узлы. Этот алгоритм похож на эвристический алгоритм восхождения на холм, описанный в главе 8. На каждом шаге оба алгоритма изменяют решение, чтобы максимально улучшить его. Алгоритм построения остовного дерева выбирает связь, которая добавляет к дереву новый узел, с наименьшей ценой. В отличие от эвристики восхождения на холм, с помощью которой не всегда можно найти оптимальное решение, этот алгоритм гарантированно находит минимальное остовное дерево. Подобные алгоритмы, достигающие глобального оптимума при помощи локальных оптимальных решений, названы каскадными алгоритмами (greedy algorithms). Каскадные алгоритмы можно рассматривать как алгоритмы типа восхождения на холм, не являющиеся при этом эвристиками, - они также гарантированно находят лучшее возможное решение. Алгоритм построения минимального остовного дерева использует связанный список, чтобы сохранять связи, которые могут быть добавлены к каркасу. Сначала алгоритм помещает в список связи корневого узла. Затем проводится поиск связи с минимальной стоимостью. Если узел на другом конце этого ребра не находится в дереве, программа добавляет и его, и соответствующее ребро. После этого программа вносит в список связи, исходящие из нового узла, поэтому в дальнейшем будут рассматриваться уже эти ребра. Алгоритм использует в классе, описывающем ребра, поле BeenlnList, указывая таким образом, заносилось ли ранее данное ребро в список. Это делается для того, чтобы ребро вторично не оказалось в списке. Возможно, список возможных связей опустеет прежде, чем все узлы будут добавлены к остовному дереву. В этом случае сеть признается несвязанной, и пути от корневого узла ко всем остальным узлам сети не существует. type PLink = ATLink; PNode = ЛТМос1е; TLink = record Model : PNode; Node2 : PNode;
Наименьший каркас дерева Cost : Integer; NextLink : PLink; BeenlnList : Boolean; InTree : Boolean; end; TNode = record Id : Integer; X : Integer; Y : Integer; LinkSentinel : TLink; NextNode : PNode; InTree : Boolean; end;
// Следующее ребро в списке связей узла. // Был ли узел в списке возможных // связей? // Есть ли данный узел в дереве?
// Дуги, исходящие из данного узла. // Следующий узел в списке всех узлов. // Есть ли данный узел в дереве?
// Ячейки связанного списка кандидатов. PCandidate = ^Candidate; TCandidate = record Link : PLink; NextCandidate : PCandidate; end; procedure FindSpanningTree(root : PNode); const INFINITY = 32767; var candidate_sentinel : TCandidate; new_candidate, before : PCandidate; best_before : PCandidate; best_node, from_node : PNode;
link : PLink; best_cost : Integer; begin if (root = nil) then exit; // Сброс«всех флагов узлов InTree и всех флагов ребер BeenlnList // и InTree. ResetSpanningTree; // Начинаем с корня остовного дерева. RootNode := rdot;
candidate_sentinel.NextCandidate := nil; root.InTree := True; best_node := root;
// Бесконечный цикл рассмотрения списка возможных связей. repeat // Добавление связей узла best_node в список возможных связей. Link := best_node".LinkSentinel.NextLink; while (linkonil) do
begin if (not link^.BeenlnList) then begin
// Добавление ребра в список возможных связей. GetMem(new_candidate,SizeOf(TCandidate)); new_candidateA.NextCandidate:= candidate_.sentinel. NextCandidate; candidate_sentinel.NextCandidate := new_candidate; new_candidate*.Link := link; link~.Been!nList := True; end; link := link^NextLink,end;
// Нахождение связи минимальной стоимости в списке возможных // связей, которая ведет' к узлу, которого еще.'нет в дереве. before := @candidate_sentinel; best_before := before; new_candidate := before1"1.NextCandidate; best_cost := INFINITY; while (new_candidateonil) do begin link := new_candidateA.Link; if (link'4.Node2A.InTree) then begin
// Этот узел уже в дереве. // Удаляем ребро из списка. before*.NextCandidate := new~candidateA.NextCandidate; FreeMem(new_candidate); new_candidate := Ье£огел.NextCandidate,• end else begin // Если ребро имеет меньшую стоимость. if (link*.Cost < best_cost) then begin best_before := before; best_cost := 1 ink'4.Cost; end; before := new_candidate; new_candidate := new_candidate'4 .NextCandidate; end; end; '// Конец рассмотрения кандидатов.
// Если нет больше нерассмотренных ребер, то готово. // В этой точке список возможных связей пуст. if (best_cost = INFINITY) then break; // Добавление найденного ребра и узла к дереву. new_candidate := Ьез^Ье^гел .NextCandidate; link := new_candidate^.Link,linkA.InTree :.= True;
Наименьший каркас дерева^ best_node := HnkA.Node2; best_node~.InTree := True; // Удаление ячейки из списка возможных связей. best_beforeA.NextCandidate := new_candidate~.NextCandidate; FreeMem(new_candidate); // Добавление обратного ребра к дереву. from_node := link".Nodel; link := best_node/4.LinkSentinel.NextLink; while (linkonil) do begin if (link A .Node2 = from_node) then break; link := link A .NextLink; end;
if (linkonil) then begin НгЛл.1пТгее := True; Hnk A .BeenInList := True; end;
until (False);
// Конец бесконечного цикла исследования списка // возможных связей.
// Перерисовка сети. GotTree := True; DrawNetwork; end;
Этот алгоритм поверяет каждое ребро максимум один раз. При проверке ребро добавляется в список возможных связей и затем удаляется из него. Если список возможных связей сохранен в связанном списке как в предыдущем коде, то для поиска в списке ребра с минимальной стоимостью потребуется время порядка 0(N). Общая сложность алгоритма будет при этом равна O(N2). Если N относительно мало, то производительность вполне приемлема. Если список возможных связей сохранен как очередь с приоритетом на основе пирамиды, то для добавления или удаления элемента потребуется время порядка O(logN), где N - число связей в сети. В этом случае общая сложность алгоритма равна O(N * logN). Если же число связей в сети достаточно велико, такой подход существенно экономит время. Программа Span использует этот алгоритм для поиска минимальных остовных деревьев. Она аналогична программе NetEdit и позволяет загружать, редактировать и сохранять на диске файлы, представляющие сеть. Если выбрать какой-либо узел в программе двойным щелчком мыши, то программа найдет и отобразит на экране минимальное остовное дерево с корнем в выделенном узле. На рис. 12.7 показано окно программы Span, отображающее минимальное остовное дерево с корнем в узле 9.
1|>
Сетевые алгоритмы
Рис. 12.7. Окно программы Span
Кратчайший путь Алгоритмы поиска кратчайшего пути (shortest path), рассмотренные в следующих разделах, находят все кратчайшие пути от одной точки сети до любой другой, конечно, если сеть связана. Набор связей, используемых всеми кратчайшими путями, образует дерево кратчайших путей. На рис. 12.8 изображена сеть, в которой дерево кратчайших путей с корнем в узле А выделено жирными линиями. Оно показывает кратчайшие пути от узла А до любого другого узла сети. Например, кратчайший путь от узла А к узлу F проходит через узлы А, С, Е, F. Корень
10
13
11
Рис. 12.8. Дерево кратчайших путей
Кратчайший путь
;|!
Большинство алгоритмов поиска кратчайшего пути начинают с пустого дерева и затем добавляют к дереву по одному ребру то тех пор, пока дерево не будет построено. Эти алгоритмы можно разделить на две категории по способу выбора следующего ребра, которое прибавляется к дереву. Алгоритм расстановки меток (label setting) всегда выбирает ребро, которое гарантированно является частью конечного дерева кратчайших путей. Он работает аналогично алгоритму построения минимального остовного дерева. Если ребро было добавлено к дереву, оно уже не будет удалено. Алгоритм коррекции меток (label correcting) добавляет ребра, которые могут быть, а могут и не быть частью конечного дерева кратчайших путей. В процессе выполнения алгоритм может определить, что вместо уже имеющегося ребра в дерево должно быть добавлено другое. В этом случае алгоритм заменяет старое ребро новым и продолжает работу. Замена ребра в дереве может открыть дополнительные пути. Чтобы проверить их, алгоритму приходится повторно исследовать пути, которые были добавлены к дереву раньше и использовали удаленное ребро. Алгоритмы расстановки и коррекции меток используют аналогичные классы для представления узлов и связей. Класс узла TNode содержит поле Dist, которое указывает расстояние от корня до узла в растущем дереве кратчайшего пути. В алгоритме расстановки меток для Di s t задается True, как только узел добавлен к дереву и этот параметр уже не будет изменяться. В алгоритме коррекции меток параметр Dist може'т быть исправлен позже, когда ребро будет заменено другим. Класс TNode также содержит поле Status, которое определяет, есть ли в настоящее время данный узел в дереве или списке возможных связей. В поле InLink указывается ребро, ведущее к узлу в растущем дереве кратчайшего пути.
var TStatus = (nsNotInList,nsWasInList,nsNow!nList); TNode = record Id : Integer; X : Integer; Y : Integer; LinkSentinel : TLink; // Ребра, исходящие из данного узла. NextNode : PNode; // Следующий узел в списке всех узлов. Status : TStatus; // Есть ли узел в дереве? Dist : Integer; // Расстояние от корня. InLink : PLink; // Ребро, ведущее в данный узел. end;
С помощью поля InLink программа может перечислять узлы на пути от корня до узла в обратном порядке, используя следующий код: procedure ListPath(node : PNode); var / prev_node : PNode; begin while (True) do // Бесконечный цикл. Begin // Вывести узел. if (node = RootNode) then break;
IIIIII
Сетевые алгоритмы
// Переход к следующему узлу вверх по дереву. if (node*.InLink*.Model = node) then node : = node* . InLink*, Node2 else node := node*.InLink*.Model; end; Класс TLink включает поле InTree, которое указывает, является ли ребро частью дерева кратчайшего пути. type TLink = record Nodel : PNode; Node2 : PNode; Cost : Integer; NextLink. : PLink; InTree : Boolean; end;
// Следующее ребро в списке связей узла. . // Есть ли ребро в дереве?
Алгоритмы расстановки И коррекции меток используют список возможных связей, чтобы отслеживать узлы, которые могут быть добавлены к дереву кратчайшего пути, но по-разному управляют этим списком. Алгоритм расстановки меток всегда выбирает связь, которая обязательно окажется частью дерева кратчайшего пути. Алгоритм коррекции меток, описанный в этом разделе, выбирает любой узел в начале списка возможных связей.
Расстановка меток Алгоритм расстановки меток (label setting) начинает с присвоения полям Status всех узлов значения nsNotlnList и полями Dist значения INFINITY. Затем он присваивает полю Dist корневого узла значение 0 и помещает корневой узел в список возможных связей. Полю Status корня присваивается значение nsNowInList - это указывает, что корень в настоящее время находится в списке возможных связей. Затем алгоритм выполняет поиск узла с минимальным значением Dist. Сначала будет найден корневой узел, так как он единственный в списке. Алгоритм удаляет выбранный узел из списка и устанавливает для него значение поля Status в значение nsWasInList, поскольку теперь он является постоянной частью дерева кратчайшего пути. Поля Dist и InLink узла уже имеют правильные значения. Для каждого корневого узла значение поля InLink равно ni 1, а значение поля Dist - нулю. После этого алгоритм исследует каждую связь, исходящую из выбранного узла. Если соседний узел на другом конце ребра не был в списке возможных связей, то алгоритм добавляет его к списку. Он устанавливает значение поля Status соседнего узла равным nsNowInList, а значение поля Dist - расстоянию от корневого узла до выбранного узла плюс стоимость ребра. И наконец, он присваивает полю соседнего узла InLink значение, которое указывает на связь с соседним узлом.
Кратчайший путь Если во время проверки алгоритмом связей, исходящих из выбранного узла, значение поля соседнего узла Status равно nsNowInList, то он уже занесен в список возможных. Алгоритм исследует текущее значение поля Dist соседнего узла, определяя, будет ли новый путь через выбранный узел короче. Если это так, он обновляет поля InLink и Dist соседнего узла и оставляет его в списке возможных связей. Алгоритм повторяет весь описанный процесс, удаляя узлы из списка возможных связей, исследуя соседние с ними узлы и добавляя их в список, пока он не опустеет. На рис. 12.9 показана часть дерева кратчайшего пути. В этой точке алгоритм проверил узлы А и В, удалил их из списка возможных и исследовал их связи. Узлы А и В уже добавлены к дереву кратчайшего пути, и список возможных связей теперь содержит узлы С, D и Е. Жирные стрелки на рис. 12.9 указывают значение InLink в этой точке. Например, значение поля InLink для узла Е соответствует связи между узлами Е и В. Корень
13
Рис. 12.9. Часть дерева кратчайшего пути Затем алгоритм перебирает список кандидатов в поисках узла с минимальным значением поля Dist. В этой точке значения поля Dist узлов С, D и Е равны 10, 21 и 22 соответственно, поэтому алгоритм выбирает узел С. Узел С удаляется из списка возможных связей, и поле Status данного узла устанавливается в значение nsWasInList. Теперь узел С является частью дерева кратчайшего пути, и его поля Dist и InLink имеют правильные значения. Затем алгоритм проверяет ребра, исходящие из узла С. Единственная такая связь идет к узлу Е, который уже содержится в списке возможных узлов, поэтому алгоритм не добавляет его в список.
Сетевые алгоритмы Текущий кратчайший путь от корня до узла Е - это путь А, В, Е, общая стоимость которого составляет.22. Но стоимость пути А, С, Е равна всего 17, что меньше, чем текущая стоимость 22, поэтому алгоритм модифицирует значение InLink для узла Е и устанавливает поле узла Е Dist в значение 17. procedure FindPathTreetroot : PNode); var candidate_sentinel : TCarididate; new_candidate : PCandidate; before, best_before : PCandidate; best_dist, new_dist : Integer; node, to_node : PNode; link : PLink; begin if (root = nil) then exit; RootNode := root; // Сбрасываем дерево. ResetPathTree; // Начинаем с корня дерева кратчайшего пути. roof4.Dist := 0; roof .InLink := nil.; roof4.Status := nsNowInList ; GetMem(new_candidate,SizeOf(TCandidate)); candidate_sentinel.NextCandidate := new_candidate; new_candidateл.NextCandidate .:= nil; new_candidateA.Node := root; // Повторяем, пока список возможных связей не опустеет. while (candidate_sentinel.NextCandidateonil.)4 do begin i // Нахождение ближайшего к корню узла-кандидата. best_dist := INFINITY; best_before := nil; before := @candidate_sentinel; new_candidate := beforeA.NextCandidate; while (new_candidateonil) do begin new_dist := new_candidate^.Node".Dist; if (new_dist < best_dist) then begin best_before := before; besf dist :=; new_dist; end ;
before := new_candidate; . new_candidate := before".NextCandidate; end; //. Добавляем данный узел к дереву. new_candidate := bes^before".NextCandidate;
Кратчайший путь node := newicandidate".Node; nods'4.Status := nsWasInList; // Удаляем узел из списка возможных связей. best_beforeA.NextCandidate := new_candidate/4 .NextCandidate; FreeMem(new_candidate); // Рассматриваем соседние узлы. link := node".LinkSentinel.NextLink; while (linkonil) do begin to_node := linkA.Node2;
// Если узла не было в списке, добавляем его. if (to_nodeA.Status=nsNot!nList) then begin // Обновляем параметры status и distance. to_nodeA.Status := nsNowInList; 'to_nodeA.Dist := best_dist+link'N .Cost ; to_nodeA.InLink := link; // Добавляем его к списку. GetMem(new_candidate,SizeOf(TCandidate)); new_candidateA.Node := to_node; new_candidate~.NextCandidate := candidate_sentinel.NextCandidate; candidate_sentinel.NextCandidate := new_candidate; end else if (to_node".Status = nsNowInList) then begin
// Если узел сейчас в списке кандидатов, обновляем // значения Dist и InLink. new_dist := best_dist + link.Cost; if (new_dist < to_node.Dist) then begin to_node.Dist := new_dist; to_node.InLink := link; end; end; link := link A .NextLink; end; // Конец рассмотрения соседних узлов. end; // Конец while (список кандидатов не опустеет)... end ;
Важно, чтобы алгоритм обновлял значения полей I n L i n k n D i s t только для узлов со значением Status = nsNowInList. Для большинства сетей нельзя получить более короткий путь, добавляя узлы, не имеющиеся в списке возможных. Однако если сеть содержит цикл с отрицательной общей длиной, алгоритм обнаружит, что можно уменьшить расстояние до некоторых узлов, которые уже находятся в дереве. Он соединит две ветви дерева таким образом, чтобы оно перестало быть деревом.
Сетевые алгоритмы На рис. 12.10 показана сеть с отрицательной стоимостью цикла и «дерево» кратчайшего пути, которое получится, если алгоритм модифицирует стоимость уже имеющихся в дереве узлов. Корень
10
-1
11
Рис. 12.10. Неправильное «дерево» кратчайшего пути для сети с циклом отрицательной стоимости Программа PathS использует алгоритм установки меток для вычисления кратчайшего пути. Она похожа на программы NetEdit и Span. Если вы не вставляете и не удаляете узлы или связи, то можно выбрать узел при помощи мыши, и программа найдет и отобразит дерево кратчайших путей с корнем в этом узле. На рис. 12.11 показано окно программы PathS, отображающее дерево кратчайших путей с корнем в узле 3. f Paths [D:\lmikle\dmk\p й1оо\Ехатр1е*\СН1г\!«Т1.М.4И|5| Ejte
Edit
Help
Рис. 12.11. Дерево кратчайших путей с корнем в узле 3
Кратчайший путь
SJ
Вариации алгоритма расстановки меток Основной проблемный момент в этом алгоритме - нахождение узла в списке возможных связей, который имеет минимальное значение поля Dist. Несколько вариаций этого алгоритма используют различные структуры данных для сохранения списка возможных связей, например упорядоченный связанный список. В этом случае потребуется всего один шаг, чтобы найти следующий узел, который будет добавлен-к дереву кратчайших путей. Список всегда будет отсортирован, поэтому искомый узел всегда будет в начале списка. Это облегчает поиск правильного узла в списке, но усложняет вставку узла. Вместо того чтобы просто добавлять узел в начало списка, его придется помещать в соответствующую позицию. Иногда необходимо перераспределять узлы в списке. Если при добавлении узла к дереву уменьшилось кратчайшее расстояние до другого узла, который уже есть в списке, то нужно переместить этот элемент ближе к началу списка. Предыдущий алгоритм и его только что описанный новый вариант представляют два крайних случая управления списком возможных связей. Первый алгоритм совершенно не упорядочивает список и тратит много времени на поиск узла в сети. Второй выполняет множество операций для поддержания упорядоченного связанного списка, на что тратится значительная часть времени, но это окупается возможностью очень быстро выбирать узлы. Другие варианты алгоритма используют промежуточную стратегию. Например, можно хранить список кандидатов в очереди с приоритетом на основе пирамиды. В этом случае программа будет просто выбирать следующий узел из вершины пирамиды. Добавление элемента в пирамиду и ее пересортировка будут выполняться быстрее, чем такие же операции над упорядоченным связанным списком. Другие стратегии используют блочное расположение, чтобы упростить поиск возможных узлов. Некоторые из этих вариантов достаточно сложны. Часто для небольших сетей данные алгоритмы выполняются медленнее, чем более простые алгоритмы. Но для очень большой сети или для сети, в которой каждый узел имеет огромное число связей, выигрыш во времени от использования этих алгоритмов может стоить дополнительного усложнения.
Коррекция меток Как и алгоритм расстановки меток, метод коррекции меток (label correcting) начинает работать, присваивая полю Dist корневого узла нулевое значение и помещая корневой узел в список возможных. Значения Dist для других узлов устанавливается в бесконечность. Затем из списка возможных узлов выбирается первый узел и добавляется к дереву кратчайшего пути. После этого алгоритм исследует все соседние узлы, сравнивая расстояние от корня до выбранного узла плюс стоимость связи с текущим значением Dist соседнего узла. Если это расстояние меньше Dist, то алгоритм обновляет значения Dist и InLink соседнего узла таким образом, чтобы кратчайший путь к соседнему узлу проходил через выбранный узел. Если соседнего узла в настоящее время нет в списке возможных узлов, то он также добавляется к списку. Обратите внимание,
Сетевые алгоритмы что этот алгоритм не проверяет, был ли элемент в списке раньше. Если в результате подстановки путь от корня до соседнего узла становится короче, алгоритм всегда добавляет данный узел в список возможных. Алгоритм продолжает удалять узлы из списка возможных, проверяя соседние узлы и добавляя их в список до тех пор, пока список не опустеет. Если сравнить алгоритмы расстановки и коррекции меток, видно, как они похожи. Разница заключается в том, как каждый из них выбирает элементы из списка возможных узлов для вставки в дерево кратчайшего пути. Алгоритм расстановки меток всегда выбирает связь, которая гарантированно находится в дереве кратчайших путей. После удаления из списка возможных узел добавляется к дереву и уже не будет помещен в список. Алгоритм коррекции меток всегда выбирает первый узел из списка возможных, который не всегда является лучшим выбором. Значения полей Di st и InLink данного узла могут и не быть лучшими возможными значениями. Но в конце концов алгоритм отыщет в списке узел, через который проходит более короткий путь к выбранному узлу. Тогда алгоритм обновляет поля Dist и InLink неправильно выбранного узла и помещает этот узел обратно в список возможных. Алгоритм может использовать новый путь для формирования других путей, которые ранее могли быть пропущены. Помещая обновленный узел опять в список возможных, алгоритм гарантирует, что этот узел снова будет проверен и найдутся все такие пути. procedure FindPathTree(root : PNode); var top_candidate : PCandidate; new_candidate : PCandidate; node_dist, new_dist : Integer; node, to_node : PNode; link : PLink; begin if (root = nil) then exit; // Сброс дерева. ResetPathTree; // Начинаем с корневого узла дерева кратчайших путей. root*.Dist := 0; root^.InLink := nil; roof4.Status := nsNowInList; GetMem(new_candidate,SizeOf(TCandidate)); top_candidate := new_candidate; /4 new_candidate .NextCandidate := nil; new_candidate*.Node := root;
// Повторяем, пока список кандидатов не опустеет. while (top_candidateonil) do begin
// Добавляем первый элемент списка в дерево. // Удаляем узел из списка кандидатов.
_ „,._..
Кратчайший путь
node := top_candidate^.Node; new_candidate := top_candidate; top_candidate := top_candidate^.NextCandidate; FreeMem(new_candidate); node_dist := nodeA.Dist; '.Status := nsNotlnList; // Исследуем соседние узлы. link := node^.LinkSentinel.NextLink; while (linkonil) do begin // Получается ли с использованием данного узла путь // лучше чем прежде. to_node := link'4 .Node2 ; new_dist := node_dist+link.Cost; if (new_dist
Сетевые алгоритмы Программа PathC использует алгоритм коррекции меток для вычисления кратчайших путей. Она похожа на программу PathS, но использует алгоритм коррекции, а не расстановки меток.
Варианты алгоритма коррекции меток Данный алгоритм позволяет быстро выбирать узлы из списка возможных. Он может также добавлять узел в список всего за один или два шага. Недостаток этого алгоритма состоит в том, что когда он выбирает узел из списка возможных, этот выбор не всегда оказывается удачным. Если алгоритм выбирает узел до того, как поля D i s t n l n L i n k этого узла получат свои конечные значения, он должен потом исправить значения этих полей и снова поместить узел в список возможных. Чем чаще алгоритм помещает узлы назад в список, тем больше времени занимает его выполнение. Варианты этого алгоритма направлены на улучшение качества выбора узлов без выполнения дополнительной работы. Один из методов, который на практике работает достаточно хорошо, заключается в том, чтобы добавлять узлы одновременно и в начало, и в конец списка возможных узлов. Если узел прежде не был в списке, он как обычно добавляется в конец списка. Если узел уже был в списке возможных узлов, но в данный момент его там нет, алгоритм помещает его в начало списка. При этом повторное обращение к узлу выполняется практически сразу, иногда при следующем же обращении к списку. Основанный на этом методе подход состоит в следующем: если алгоритм совер' шил ошибку, она должна исправляться как можно скорее. Если ошибка не будет устранена в течение долгого времени, алгоритм может использовать неправильную информацию при построении длинных ложных путей, которые затем придется исправлять. Благодаря быстрому устранению ошибок число неверных путей, нуждающихся в исправлении, будет сокращено. В наилучшем случае, если соседние узлы все еще находятся в списке возможных, повторная проверка данного узла перед исследованием соседних устраняет построение неправильных путей.
Варианты поиска кратчайшего пути Предыдущие алгоритмы поиска кратчайшего пути вычисляют все кратчайшие пути от одного корневого узла до всех остальных узлов сети. Есть много других типов задач по нахождению кратчайшего пути. В этом разделе обсуждаются три из них: двухточечный кратчайший путь, кратчайший путь для всех пар и кратчайший путь со штрафами за повороты.
Двухточечный кратчайший путь В некоторых приложениях необходимо находить кратчайший путь между двумя точками, при этом остальные пути в полном дереве кратчайшего маршрута не важны. Простой способ нахождения двухточечного кратчайшего пути (point-topoint shortest path) состоит в том, чтобы вычислить полное дерево кратчайших путей с помощью алгоритмов расстановки или коррекции меток, а затем выбрать из дерева кратчайший путь между двумя точками.
Кратчайший путь Другой способ заключается в использовании метода расстановки меток, который прекращает работу, когда находит путь к конечному узлу. Алгоритм расстановки меток помещает в дерево кратчайшего маршрута только действительно существующие пути, следовательно, в тот момент, когда алгоритм добавит конечный узел в дерево, будет найден искомый кратчайший маршрут. В алгоритме, описанном ранее, это происходит, когда удаляется конечный узел из списка возможных узлов. Единственное изменение требуется внести в ту часть алгоритма расстановки меток, которая выполняется сразу после того, как алгоритм нашел в списке возможных узлов узел с наименьшим значением Dist. Перед удалением узла из списка алгоритм должен проверить, является ли данный узел конечным. Если это так, то дерево кратчайшего пути уже содержит правильный путь от начального до конечного узла, и алгоритм может завершить работу. // Находим ближайший к корню узел в списке возможных узлов. // Является ли это узел конечным? if (node = destination) then break;
>
// Добавляем этот узел к дереву кратчайшего пути. На практике, если две точки находятся далеко друг от друга, этот алгоритм обычно выполняется дольше, чем вычисление полного дерева кратчайших путей. Это объясняется тем, что в каждом цикле алгоритма проверяется, достигнут ли искомый узел. С другой стороны, если узлы расположены близко друг к другу, выполнение этого алгоритма займет меньше времени, чем построение полного дерева кратчайших путей. Для некоторых сетей, например сети улиц, можно определить, как далеко друг от друга находятся две точки и затем решить, какую версию алгоритма выбрать. Если сеть содержит все улицы южной Калифорнии и две точки находятся на удалении 10 миль друг от друга, следует использовать версию, которая останавливается, как только удаляется узел назначения из списка возможных узлов. Если точки удалены на 100 миль, то меньше времени займет вычисление полного дерева кратчайшего пути.
Вычисление кратчайшего пути для всех пар В некоторых приложениях требуется быстро найти кратчайший путь между всеми парами узлов (all pairs shortest path) сети. Если необходимо вычислять большую часть из всех возможных N2 путей, то проще заранее найти все возможные кратчайшие пути, а не высчитывать их каждый по мере надобности. Можно сохранить кратчайшие пути в двумерных массивах Dists и InLinks. В ячейке Dists [ i, j ] содержится кратчайшее расстояние от узла i до узла], а в ячейке InLinks [ i, j ] - связь, которая ведет к узлу j в кратчайшем пути от узла i до узла j. Эти значения подобны значениям Dist и InLink в классе узла из предыдущих алгоритмов.
|i
Сетевые алгоритмы
Один из способов поиска кратчайших путей состоит в том, чтобы построить деревья кратчайших путей с корнями в каждом узле сети при помощи одного из предыдущих алгоритмов, а затем сохранить результаты в массивах InLinks и Dists. Другой метод вычисления всех кратчайших путей последовательно строит пути через сеть, используя все более увеличивающееся количество узлов. Сначала алгоритм отыскивает все кратчайшие пути, которые обходят только первый узел плюс конечные узлы пути. Другими словами, для узлов j и k алгоритм находит кратчайший путь между этими узлами, который использует только узел с номером 1 и узлы j и k, если такой путь существует. Затем алгоритм находит все кратчайшие пути, содержащие только первые два узла. Потом он строит пути, использующие первые три узла, первые четыре узла и т.д., пока не построит все самые кратчайшие пути, обходящие все узлы. На этом этапе, поскольку кратчайшие пути могут включать в себя любой узел, алгоритм найдет все кратчайшие пути в сети. Обратите внимание, что кратчайший путь от узла j к узлу k, использующий только первые i узлов, включает узел i, если D i s t [ j , k ] > D i s t [ j , i ] + D i s t [ i , k ] . В противном случае кратчайшим будет предыдущий кратчайший путь, использующий только первые i — 1 узлов. Это означает, что когда алгоритм рассматривает узел i, надо проверить только условие Dist [j ,k] > D i s t [ j , i ] + D i s t [ i , k ] . Если оно выполняется, алгоритм обновляет кратчайший путь от узла j к узлу k. Иначе старый кратчайший путь между этими двумя узлами все еще является таковым.
Штрафы за повороты В некоторых сетях, особенно сетях улиц, бывает удобно добавить запреты и штрафы за повороты (shortest path with turn penalty). В сети улиц автомобиль, перед тем как повернуть, должен слегка притормозить. Поворот налево может занять больше времени, чем поворот направо или движение прямо. Некоторые повороты могут быть запрещены или невозможны из-за наличия разделительной полосы. Подобные ситуации можно обрабатывать, введя в сеть штрафы за повороты.
Небольшое число штрафов за повороты Часто важны штрафы только за некоторые повороты. Вы можете предотвратить выполнение запрещенных или невозможных поворотов и добавить штрафы за повороты лишь на нескольких основных перекрестках. В этом случае можно разбить каждый узел, для которого заданы штрафы, на несколько узлов, которые будут неявно учитывать штрафы. Предположим, необходимо добавить штраф за поворот на перекрестке налево и другой штраф за поворот направо. На рис. 12.12 показан перекресток, на котором требуется использовать эти штрафы. Число, стоящее рядом с каждым ребром, соответствует его стоимости. Штрафы будут налагаться за вход в узел А по звену L, и выход из него по звеньям L2 или L3.
Кратчайший путь Чтобы добавить штрафы за повороты в узел А, разбейте его на два узла, по одному для каждого исходящего из него ребра. В данном примере из узла А выходит два ребра, поэтому необходимо разделить его на два узла - А, и A.J. Связи, выходящие из узла А, заменяются соответствующими связями, выходящими из полученных узлов. Узлы можно рассматривать как вход в узел А и поворот на соответствующее ребро. Затем замените связь !_,, входящую в узел А, ребрами, ведущими в каждый из узлов А, и А2. Стоимость этих связей равна первоначальной стоимости связи L, плюс штраф за поворот в соответствующем направлении. На рис. 12.13 изображен перекресток, на котором введены штрафы за повороты. На этом рисунке штраф за поворот налево от узла А равен 5, а за поворот направо - 2.
10
Рис. 12.12. Перекресток
Рис. 12.13. Перекресток со штрафами за повороты
Поместив информацию о штрафах за повороты непосредственно в сети, можно избежать необходимости изменять алгоритмы поиска кратчайшего пути. Эти алгоритмы будут правильно находить кратчайшие пути с учетом штрафов за повороты. Однако программу все же придется слегка изменить, чтобы учесть разбиение узлов на несколько частей. Предположим, нужно найти кратчайший путь между узлами i и j, но узел i был разбит на части. Учитывая, что узел i разрешается покинуть по любому ребру, можно создать фиктивный узел, чтобы использовать его как корневой узел дерева кратчайшего пути. Соедините этот узел связями нулевой стоимости с каждым из подузлов узла i. В этом случае, если построить дерево кратчайшего пути с корнем в фиктивном узле, будут найдены все кратчайшие пути, включающие любой из этих подузлов. На рис. 12.14 показан перекресток с рис. 12.13, соединенный с фиктивным Рис. 12.14. Перекресток, соединенный с фиктивным корневым узлом корневым узлом.
Сетевые алгоритмы Найти кратчайший путь к узлу, разбитому на несколько узлов, несколько проще. Чтобы отыскать кратчайший путь между узлами i и j (узел j был разбит на подузлы), сначала найдите обычное дерево кратчайшего пути с корнем в узле i. Затем проверьте каждый из частей узла j, чтобы определить, какой из них ближе к корню. Путь к этому подузлу и является кратчайшим к исходному узлу].
Большое число штрафов за повороты Если вы хотите ввести штрафы за повороты для большинства узлов сети, предыдущий метод будет не очень эффективным. Лучше создать абсолютно новую сеть, в которую включить информацию о штрафах: О для каждого связи исходной сети, соединяющей узлы А и В, создается узел АВ новой сети; D если соответствующие связи в исходной сети были соединены, то полученные узлы также соединяются между собой. Предположим, что в исходной сети одно ребро соединяет узлы А и В, а другое - В и С. Тогда следует создать в новой сети ребро, соединяющее узлы АВ и ВС; а стоимость новой связи складывается из стоимости второй связи в исходной сети и штрафа за поворот. В данном примере стоимость ребра от узла АВ к узлу ВС равна стоимости ребра, соединяющего узлы В и С в исходной сети плюс штраф за поворот при перемещении от узла А к В и затем к С. На рис. 12.15 показана небольшая сеть и соответствующая новая сеть со штрафами за повороты. Штраф за поворот налево составляет 3, за поворот направо - 2, а за отсутствие поворотов - нулю. Например, так как поворот от узла В к Е и затем к F в исходной сети - это левый поворот, штраф для связи между узлами BE и EF в новой сети равен 3. Стоимость связи, соединяющей узлы Е и F в исходной сети, равна 3, поэтому общая стоимость нового звена равна 3 + 3 = 6. Теперь предположим, что требуется найти для исходной сети дерево кратчайшего пути с корнем в узле D. Для этого создайте в новой сети фиктивный корневой узел, затем постройте связь, соединяющую этот узел со всеми связями, исходящими из узла D в исходной сети. Присвойте этим связям такую же стоимость, как и у соответствующих связей в исходной сети. На рис. 12.16 изображена новая сеть, сформированная из сети на рис. 12.15, с фиктивным корневым узлом, соответствующим узлу D. Дерево кратчайшего пути через эту сеть обведено жирными линиями. Чтобы найти кратчайший путь от узла D к узлу С, исследуйте все узлы новой сети, которые соответствуют ребрам, заканчиваРис. 12.15. Сеть и соответствующая ющимся в узле С. В данном примере этими ей сеть со штрафами за повороты узлами являются ВС и FC. Узел, который
Кратчайший путь расположен ближе всего к фиктивному корню, соответствует кратчайшему пути к узлу С в исходной сети. Узлы в кратчайшем пути новой сети соответствуют ребрам в кратчайшем пути в исходной сети. На рис. 12.16 кратчайший путь идет от ложного корневого узла к узлу DE, затем к узлу EF и к узлу FC, и имеет общую стоимость 16. Этот путь соответствует пути D, Е, F, С в исходной сети. После добавления одного штрафа за левый поворот Е, F, С этот путь также имеет стоимость 16 в исходной Рис. 12.16. Дерево кратчайшего пути сети. в сети со штрафами за повороты Вы не нашли бы этот путь, если бы построили дерево кратчайшего пути в исходной сети. Без штрафов за повороты кратчайший путь от узла D к узлу С был бы D, E, В, С с общей стоимостью 12. Со штрафами за повороты этот путь имеет стоимость 17.
Применение алгоритмов поиска кратчайшего пути Вычисления кратчайшего пути используются во многих приложениях. Один из наглядных примеров - нахождение самого короткого маршрута между двумя точками в сети улиц. Другие приложения используют кратчайшие пути через сети менее наглядными способами. В следующих разделах описываются некоторые из этих приложений.
Разбиение на районы Предположим, что имеется карта города, которая показывает расположение всех пожарных депо. Вам необходимо определить для каждой точки города ближайшее к ней депо. На первый взгляд эта задача кажется сложной. Можно попытаться вычислить дерево кратчайших путей с корнями в каждом узле сети, чтобы найти, какое из пожарных депо расположено ближе всего к тому или "иному узлу. Или создать дерево кратчайших путей с корнями в каждом из пожарных депо и сохранить расстояния от каждого пожарного депо до каждого узла сети. Но есть и более эффективный метод. Создайте ложный корневой узел и соедините его с каждым пожарным депо связями нулевой стоимости. Затем найдите дерево кратчайших путей с корнем в этом фиктивном узле. Для каждой точки сети кратчайший путь от ложного корневого узла к данной точке пройдет через ближайшее к ней пожарное депо. Чтобы найти ближайшее к данной точке пожарное депо,, просто следуйте по кратчайшему пути от точки к корню, пока на пути не встретится одно из депо. Построив всего одно дерево кратчайших путей, вы можете найти ближайшее пожарное депо к каждой точке сети. Программа Distr использует этот алгоритм для разбиения сети на округа. Подобно программе PathC и другим программам, описанным в этой главе, она позволяет
Сетевые алгоритмы загружать, редактировать и сохранять на диске ориентированные сети со стоимостью связей. Вы можете выбрать депо для разбиения на районы. Чтобы добавить узел в список депо, следует щелкнуть по нему левой кнопкой мыши. Щелкните в любом месте формы правой кнопкой мыши, и программа поделит сеть на районы. На рис. 12.17 в окне программы показана сеть с тремя депо - в узлах 3,18 и 20. Деревья кратчайших путей для районов выделены жирными линиями.
(Т>— 5—@
5—<Э)
Рис. 12.17. Окно программы District ,
Планирование критического пути
Во многих задачах, в том числе в больших программных проектах, определенные операции должны быть выполнены ранее других. При построении дома, например, прежде чем заливать фундамент, необходимо вырыть котлован; возводить стены можно только после того, как фундамент застынет; проводить электричество и трубопровод можно лишь после окончания йозведения стен, и т.д. Некоторые из этих задач могут быть выполнены одновременно, в то время как другие должны выполняться последовательно. Например, разрешается одновременно проводить электричество и прокладывать трубопровод. Критический путь (critical path) - это одна из самых длинных последовательностей задач, которые необходимо выполнить, чтобы закончить проект. Элементы критического пути очень важны, потому что задержка при выполнении любого из них вызовет сдвиг сроков завершения всего проекта. Если заложить фундамент на неделю позже, то и срок сдачи дома в эксплуатацию сдвинется на неделю. Для определения задач критического пути можно использовать алгоритм поиска кратчайшего пути, модифицированный для нахождения самого длинного. Сначала постройте сеть, которая представляет временные отношения между задачами проекта. Пусть каждой задаче соответствует узел. Если задача i должна быть завершена перед началом выполнения задачи j, то между задачей i и задачей j
Кратчайший путь должна быть связь. Установите стоимость этой связи равной времени выполнения задачи i. Затем создайте два фиктивных узла. Один из них будет представлять начало проекта, а второй - его завершение. Соедините начальный узел связями с нулевой стоимостью со всеми узлами-действиями в проекте, в которые не входит ни одна другая связь. Эти узлы соответствуют задачам, выполнение которых может начинаться незамедлительно без ожидания завершения других задач. Затем создайте фиктивные ребра нулевой стоимости, соединяющие каждый узел, который не имеет исходящих из него связей, с конечным узлом. Эти узлы представляют задачи, не тормозящие выполнение других задач. Как только все эти задачи будут выполнены, завершится весь проект. Определив в этой сети самый длинный путь между начальным и конечным узлами, вы можете найти критический путь для данного проекта. Входящие в него задачи будут критичными для выполнения проекта. В качестве примера рассмотрим упрощенный проект сборки дождевальной установки, состоящий из пяти задач. Задачи и временные соотношения между ними приведены в табл. 12.1. Сеть для этого проекта изображена на рис. 12.18. Таблица 12.1. Этапы сборки дождевальной установки
Задача
Время
Должно
1 . Купить трубы
ничего
2. Вырыть канавы
1 день 2 дня
3. Отрезать трубы
1 день
1
4. Смонтировать трубы 5. Закопать трубы
2 дня 1 день
2,3
быть выполнено затем
ничего
4
Рис. 12.18. Сеть задач сборки дождевальной установки
На этом простом примере легко увидеть, что самый длинный путь через сеть охватывает следующую последовательность задач: вырыть канавы, смонтировать трубы, закопать их. Это критические задачи, и если любая из них будет выполнена не в срок, то выполнение всего проекта тоже затянется. Длина этого критического пути равна ожидаемому времени завершения проекта. В данном случае, если все задачи будут выполнены вовремя, проект займет
Сетевые алгоритмы всего пять дней. Также предполагается, что задачи будут по мере возможности выполняться одновременно. Например, когда один человек станет копать траншею, пока другой будет закупать трубы. В более значительных проектах, например строительстве небоскреба или создании фильма, таких задач может содержаться тысячи, и найти критический путь не так просто.
Планирование коллективной работы Предположим, что требуется набрать несколько сотрудников для ответов на телефонные звонки, при этом каждый из них будет занят не весь день. У каждого определенное количество свободных часов, и каждый требует различной оплаты в час. Вашей фирме нужна бригада служащих, которые могут отвечать на телефонные звонки с девяти часов утра и до пяти часов вечера за приемлемую плату. В табл. 12.2 приведены рабочие часы сотрудников и их почасовая оплата. Таблица 12.2. Рабочие часы сотрудников и их почасовая оплата Сотрудник
Рабочие часы
Почасовая оплата
9-11
$6,50
12-3
с
В
9-2 2-5
D Е
11-12 9-12
$6,75 $7,00 $6,25 $6,70
3-5
Чтобы построить соответствующую сеть, создайте узел для каждого рабочего часа. Соедините эти узлы связями, каждая из которых соответствует рабочим часам какого-либо сотрудника. Если служащий может работать с 9 до 11 часов, создайте связь между узлами 9 и 11 и установите ей цену, равную зарплате, получаемой данным сотрудником за соответствующее время. Если сотрудник получает 6,50 долларов в час и отрезок времени составляет два часа, стоимость связи равна 13,00 долларам. На рис. 12.19 показана сеть, соответствующая данным из табл. 12.2. 13.00
6.25
19.50
13.40
21.10
33.75 Рис. 12.19. Сеть планирования бригады
Максимальный поток Кратчайший путь от первого до последнего узла позволяет набрать коллектив сотрудников с наименьшей суммарной зарплатой. Каждая связь в этом пути соответствует работе сотрудника в определенный период времени. В данном случае кратчайший путь от узла 9 до узла 5 следует через узлы 11, 12 и 3. Соответствующий график работы следующий: сотрудник А принимает звонки с 9:00 до 11:00, D работает с 11:00 до 12:00, А снова дежурит с 12:00 до 3:00 и Е работает с 3:00 до 5:00. Суммарная зарплата всех служащих при таком графике составляет 52,15 доллара.
Максимальный поток Во многих сетях звенья имеют кроме стоимости еще и пропускную способность (capacity). Через каждый узел сети проходит поток (flow), который не превышает ее пропускной способности. Например, каждая улица может пропустить только определенное количество автомобилей в час. Если число машин превышает пропускную способность связи, образуется автомобильная пробка. Сеть с заданными пропускными способностями связей называется нагруженной сетью (capacitated network). Если задана нагруженная сеть, то задачей о максимальном потоке будет определение самого большего потока через сеть от заданного источника (source) до заданного стока (sink). На рис. 12.20 изображена небольшая нагруженная сеть. Числа рядом с ребрами - это не стоимость связи, а ее пропускная способность. В данном примере максимальный поток, равный 4, получается, если две единицы потока направляются по пути А, В, Е, F и еще две - по пути А, С, D, F.
Сток
Источник
Рис. 12.20. Нагруженная сеть Описанный здесь алгоритм начинает работу с того, что поток во всех связях равен нулю. Затем он постепенно увеличивает потоки, чтобы улучшить найденное решение. Когда сделать это уже невозможно, алгоритм завершает работу. Чтобы найти способы увеличения полного потока, алгоритм исследует разностную пропускную способность связи. Разностная пропускная способность (residual capacity) связи между узлами i и j равна максимальному дополнительному сетевому потоку, который можно направить из узла i в узел j, используя связь между i и j и связь между j и i. Этот сетевой поток может включать дополнительный поток через связь i—j, если в ней есть резерв пропускной способности. Он может также исключать часть потока из связи j-i, если по данной связи идет поток.
Сетевые алгоритмы Например, предположим, что в сети, соединяющей узлы А и С на рис. 12.20, существует поток, равный 2. Поскольку пропускная способность этой связи равна 3, к ней можно добавить единицу потока, поэтому ее разностная пропускная способность равна 1. Хотя сеть, изображенная на рис. 12.20, не имеет связи С-А, разностная пропускная способность для этой связи существует. В данном примере, так как по связи С-А идет поток, равный 2, вы можете удалить до двух единиц данного потока. Это увеличит сетевой поток от узла С к узлу А на 2, поэтому разностная пропускная способность связи С—А равна 2. Сеть, состоящая из всех связей с положительной разностной пропускной способностью, называется разностном сетью (residual network). Ha рис. 12.21 изображена сеть с рис. 12.20, каждой связи в которой присвоен поток. Для каждой связи первое число равно потоку через связь, а второе — ее пропускной способности. Метка «1/2», например, означает, что связь проводит поток 1 и имеет пропускную способность 2. Связи, несущие потоки больше нуля, нарисованы жирными линиями.
Источник
Рис. 72.2/. Сетевые потоки На рис. 12.22 изображена разностная сеть, соответствующая потокам, приведенным на рис. 12.21. Показаны только те связи, которые действительно могут иметь разностную пропускную способность. Например, между узлами А и D не нарисовано ни одной связи. Исходная сеть не имеет связи A-D или связи D-A, поэтому эти связи всегда будут иметь нулевую разностную пропускную способность.
Источник
Рис. 12.22. Разностная сеть
Максимальный поток Важное свойство разностных сетей заключается в том, что любой путь, использующий связи с разностной пропускной способностью больше нуля, который соединяет источник со стоком, показывает способ увеличения потока сети. Этот путь называется расширяющим путем (augmenting path). На рис. 12.23 изображена разностная сеть с рис. 12.22, расширяющий путь в ней выделен жирными линиями.
Рис. 12.23. Расширяющий путь через разностную сеть Чтобы улучшить решение с помощью расширяющего пути, найдите наименьшую разностную пропускную способность на этом пути. Затем скорректируйте потоки в пути в соответствии с данной величиной. На рис. 12.23, например, наименьшая разностная пропускная способность любого ребра на расширяющем пути равна 2. Чтобы обновить потоки в сети, следует прибавить поток 2 к любой связи пути i—j, а из всех обратных им связей j—i вычесть поток 2. Гораздо проще изменить разностную сеть, а не корректировать потоки и затем перестраивать разностную сеть. Затем после завершения работы алгоритма можно использовать результат для вычисления потоков для связей в исходной сети. Чтобы изменить разностную сеть в данном примере, следуйте по расширяющему пути. Вычтите 2 из разностной пропускной способности любого ребра i—j на этом пути, и добавьте 2 к разностной проаускной способности соответствующего ребра j—i. На рис. 12.24 изображена измененная разностная сеть для данного примера. • • • • • ' • .
Рис. 12.24. Измененная разностная сеть
Сетевые алгоритмы Если нельзя больше найти ни одного расширяющего пути, то можно использовать разностную сеть для вычисления потоков в исходной сети. Для каждой связи между узлами i и j, если разностный поток между узлами i и j меньше, чем пропускная способность связи, поток должен равняться пропускной способности минус разностный поток. В противном случае поток должен быть равен нулю. Например, на рис. 12.24 разностный поток от узла А к узлу С равен 1, а пропускная способность связи А-С равна 3. Поскольку 1 меньше 3, поток через узел будет равен 3 - 1 = 2. На рис. 12.25 показаны сетевые потоки» соответствующие разностной сети на рис. 12.24.
Рис. 12.25. Максимальные потоки
На данный момент алгоритм не содержит методов поиска расширяющих путей в разностной сети. Один из подходящих для этой задачи методов похож на алгоритм коррекции меток для поиска кратчайшего пути. Сначала поместите узелисточник в список возможных узлов. Затем удаляйте элементы из списка, пока список не опустеет. Исследуйте все соседние узлы, соединенные с выбранным узлом связью с разностной пропускной способностью больше нуля. Если соседний узел еще не был в списке возможных, добавьте его туда. Данный процесс продолжается, пока список возможных узлов не опустеет. Есть два отличия этого метода от алгоритма коррекции меток для поиска кратчайшего пути. Во-первых, этот метод не рассматривает связи с нулевой разностной пропускной способностью. Алгоритм поиска кратчайшего пути проверяет все пути независимо от их стоимости. Во-вторых, этот алгоритм проверяет все узлы не больше одного раза. Алгоритм коррекции меток для поиска кратчайшего пути будет обновлять узел и помещать его снова в список возможных узлов, если позднее обнаружится улучшенный путь от корня до этого узла. При поиске расширяющего пути не нужно проверять его длину, поэтому нет необходимости обновлять пути и помещать узлы назад в список возможных. Следующий код показывает, как можно вычислять максимальные потоки в Delphi. Этот код разработан для ненаправленных сетей, подобных тем, которые используются программами, описанными в этой главе. После завершения работы алгоритм устанавливает для связи стоимость, равную потоку через нее, взятому со знаком минус, если поток течет в обратном направлении. Другими словами, если
Максимальный поток сеть содержит связь i-j, и алгоритм определяет, что поток должен течь в направлении связи j-i, потоку через связь i-j присваивается значение, равное потоку, который должен был бы течь через связь j-i, взятому со знаком минус. Это позволяет программе определять направление потока, используя описанные ранее классы для хранения узлов. procedure FindMaxFlows; var top_candidate, candidate : PCandidate; node, to_node : PNode; link, rev_link : PLink; min_residual : Integer; begin if. ((SourceNode = nil) or (SinkNpde = nil)) then exit;
// Изначально разностные .значения равны пропускной способности. node := NodeSentinel.Nextttode,while (nodeonil) do begin link := nodeA.LinkSentinel.Nextj,ink; while (linkonil) do begin , / link74.Residual := link'4 .Capacity; link := linkA.NextLink; end; node := node".NextNode; end; •
// Повторяем до тех пор, пока больше не найдется расширяющих путей. Repeat // Находим расширяющий путь в разностной сети. // Сброс значений узла NodeStatus и InLink. node := NodeSentinel.NextNode; while (nodeonil) do •; begin node*.Status := nsNotlnList; nodeA.InLink := nil; node := nodeЛ.NextNode; end ; // Помещаем, источник в список возможных узлов. SourceNode^.Status := nsNowInList; GetMem(top_candidate,SizeOf(TCandidate)); top_candidate/4. Node : = SourceNode ; top^andidate^.NextCandidate := nil;
// Повтор, пока список возможных узлов не опустеет. while (top_candidateonil) do begin
// Удаляем первый узел из списка. node := top_candidate".Node;
Сетевые алгоритмы^ nodeA.Status := nsWasInList;
candidate := top_c
top_candidate := candidate; // Рассматриваем связи, исходящие из данного узла. link := ncxleA.LinkSentinel.NextLink; while (linkonil) do begin // Если residual > 0 и этот узел не был в списке. to_node : = link'4.Node2 ; if ((link*.Residual > 0) and (to.node".Status = nsNotlnList)) then begin // Добавляем его в список. to"node/4.Status := nsNowInList; to_node*.InLink := link; GetMem(candidate,SizeOf(TCandidate)); candidate^Node := to_node; candidate".NextCandidate := top_candidate; top_candidate := candidate; end;
link := linkA.NextLink; end; // Конец рассмотрения исходящих из данного узла связей. // Остановка, если источник помечен. if (SinkNode.InLinkonil) then break; end; // Конец while (top_candidateonil) do ... // Остановка, если не нашли расширяющий путь. if (SinkNode.InLink = nil) then break; // Отслеживаем расширяющий путь от SinkNode обратно к SourceNode, // чтобы найти минимальную разность. min_residual := INFINITY; to_node := SinkNode; while (to_node<>SourceNode) do begin
link := to_node'v.InLink; if (link".Residual<min_residual) then min_residual := linkA.Residual; to_node := linkA.Nodel; end; // Обновляем разностные пропускные способности, // используя расширяющий путь. to_node := SinkNode; while (to_node<>SourceNode) do begin link := to_node / v .InLink;
link".Residual := link A .Residual-min_residual;
Максимальный поток
Ш111ИНЕШ1
// Находим и изменяем обратное ребро. node := link^.Nodel; rev_link := to^node'M.inkSentinel.NextLink;. while (rev_linkonll) do begin if (rev_link*.Node2 = node) then break; rev_link := rev_linkA.NextLink; end; if (rev_linkonil) then rev_link".Residual := rey_link'4.Residual+min_residual;
// Изменяем следующее ребро увеличивающего пути. to_node := link'4. Model; end; // Освобождаем все элементы,.оставшиеся в списке возможных узлов. while (top_candidateonil) do begin
candidate := top_candidate'4 .NextCandidate; FreeMem(top_candidate); top_candidate := candidate; end; until (False); // Конец бесконечного цикла поиска расширяющий путей. // Цикл завершается, когда больше нет расширяющий путей. // Вычисляем потоки из разностных пропускных способностей. node := NodeSentinel.NextNode; while (nodeonil) do begin link := nodeA.LinkSentinel.NextLink,while (linkonil) do begin if (link'4.Capacity > linkA-Residual) then link".Flow := link'4 .Capacity - link" .Residual else
// Обратное число, указывающее на противоположное // направление потока. linkA.Flow:= НпКЛ.Residual - HnkA.Capacity; link := linkA .NextLink,end; node : = node'4, NextNode ; end;
// Вычисляем общий поток. TotalFlow := 0; link := SourceNode A .LinkSentinel.NextLink; while (linkonil) do begin TotalFlow := TotalFlow+linkA.Flow; link := link^.NextLink end; end;
•
I
Сетевые алгоритмы^
Программа Flow использует метод расширяющего пути для вычисления максимальных потоков в сети. Принцип ее действия схож с другими программами, описанными в этой главе. Используете левую кнопку мыши, чтобы выбрать узел источника. Правой кнопкой мыши выделите узел стока. После выбора источника и стока программа вычисляет и выводит максимальный поток. На рис. 12.26 показано окно программы, отображающей потоки для небольшой сети.
Рис. 12.26. Окно программы Flaw
Сферы применения Вычисления максимального потока используются во многих приложениях. Данный метод для многих сетей задействован напрямую, но также он применяется косвенно для получения результатов, которые на первый взгляд могут показаться не имеющими никакого отношения к пропускной способности сети. Непересекающиеся пути В больших сетях коммуникаций очень важна избыточность (redundancy). Для заданной сети, например такой, как на рис. 12.27, может потребоваться найти количество непересекающихся путей между источником и стоком. Если между двумя узлами существует множество непересекающихся путей, которые не используют общие связи, то соединение между этими узлами останется, даже если несколько связей в сети будут разорваны. Вы можете определить количество различных путей с помощью метода вычисления максимального потока. Постройте сеть с узлами и связями, соответствующими узлам и связям в сети коммуникаций. Присвойте каждой связи единичную пропускную способность Затем выполните вычисление максимального потока в сети. Он равен числу различных путей от источника к стоку. Поскольку каждое ребро может пропустить
Максимальный поток
Рис. 12.27. Сеть коммуникаций единичный поток, ни один из путей, используемых при вычислении максимального потока, не может, иметь общей связи. При более строгом определении избыточности нужно, чтобы различные пути не имели ни общих ребер, ни общих узлов. Немного изменив предыдущую сеть, можно с помощью вычисления максимального потока справиться и с этой проблемой. Каждый узел, кроме узлов источника и стока, следует разделить на два подузла с общей связью, равной единичной пропускной способности. Соедините первый подузел со всеми ребрами, входящими в исходный узел. Свяжите все ребра исходного узла со вторым подузлом. На рис. 12.28 показана сеть с рис. 12.27, узлы которой разделены таким образом. Затем найдите максимальный поток для этой новой сети.
Источник Сток
Рис. 12.28. Преобразованная сеть коммуникаций Если путь, применявшийся для вычисления максимального потока, проходит через узел, то он может использовать связь, которая соединяет два получившихся после разбиения узла. Поскольку это ребро имеет единичную пропускную способность, не существует двух путей, которые могут проходить через него, поэтому не бывает двух путей, которые могут использовать один и тот же узел в исходной сети.
Распределение работ Предположим, что имеется группа служащих, каждый из которых обладает определенными навыками. Существует набор заданий, которые требуют привлечения
Сетевые алгоритмы сотрудника, обладающего специфическим набором навыков. Задача распределения работы (work assignment) состоит в том, чтобы назначить сотруднику задание в соответствии с имеющимися у него навыками. Чтобы преобразовывать эту задачу в вычисление максимального потока, постройте сеть 'с двумя столбцами узлов. В левом столбце разместите узлы, представляющие каждого служащего. В правом столбце - узлы, представляющие каждое задание. Затем сравните навыки каждого служащего с навыками, необходимыми для выполнения каждого задания. Создайте связи между каждым служащим и каждым заданием, которое предположительно может выполнить служащий, и установите этим связям единичную пропускную способность. Создайте узел источника и соедините его с каждым служащим связью с единичной пропускной способностью. Затем создайте узел стока и соедините с ним каждое задание, опять используя связи с единичной пропускной способностью. На рис. 12.29 показана соответствующая сеть для задачи распределения работ с четырьмя заданиями и четырьмя служащими. Сотрудники
Задания
Рис. 12.29. Сеть распределения работы Теперь найдите максимальный поток от узла источника до узла получателя. Каждая результирующая единица потока должна пройти через один узел служащего и один узел задания. Этот поток представляет распределение работы для этого сотрудника. Если служащие обладают соответствующими навыками для выполнения всех заданий, алгоритм вычисления максимального потока найдет способ распределить все задания. Если невозможно выполнить все задания, в процессе вычисления максимального потока работа будет распределена так, чтобы было выполнено максимально возможное число заданий. Программа Work использует этот алгоритм для распределения работы между сотрудниками. Введите имена служащих и их навыки в левом текстовом поле, а задания, которые необходимо выполнить, и требуемые для этого навыки - в среднем текстовом поле. Затем щелкните по кнопке Assign (Распределить), и программа
распределит работу между служащими с помощью сети максимальных потоков. На рис. 12.30 показано окно программы, отображающей полученное распределение работы. В данном примере сотрудники не обладали необходимыми навыкам для выполнения всех заданий, поэтому одно задание осталось невыполненным.
Ann SUn SUB SUM Bob Skil SUB SUE
Jobl Skil SUB JobZSUnSUBSUB Job3 SkI2 SUE JoMSUBSkl7 JobS SUB SUM Job6 Skill Skl3 Job? Skil SUM
Oov SUB sue sue
Dave SkU SUB SklS Evan SUB SkK SU7 Fran SUn SUB SUB Gin SUB SUB SUM
Job2 , :"'Job4 Arm
Job3
!
Рис. 12.30. Окно программы Work
Резюме Некоторые сетевые алгоритмы можно применять непосредственно к сетеподобным объектам. Например, с помощью алгоритма поиска кратчайшего пути вы сможете найти наилучший путь в уличной сети. Используя минимальное остовное дерево, можно определить наименьшую стоимость построения сети связи или соединения городов железной дорогой. Многие другие сетевые алгоритмы применяются не так очевидно. Например, алгоритмы поиска кратчайшего пути подходят для разбиения на районы, составления плана работ и графика коллективной работы. Алгоритмы вычисления максимального потока можно использовать для распределения работ. Подобные менее очевидные способы использования сетевых алгоритмов обычно оказываются более интересными и перспективными.
Глава 13. Объектноориентированные методы Использование функций и процедур позволяет программисту разбивать код большой программы на управляемые части. С помощью массивов и структур данных, определяемых пользователем, можно упростить работу с элементами данных, сгруппировав их особым образом. Благодаря классам возможно группировать логику работы программы и данные различными способами. Класс обеспечивает объединение данных и методов в одном объекте. Этот новый подход к управлению сложностью программ позволяет рассматривать алгоритмы с другой точки зрения. Данная глава посвящена разъяснению некоторых наиболее важных вопросов, связанных с применением классов Delphi. Здесь описываются преимущества объектно-ориентированного программирования (Object Oriented Programming OOP) и демонстрируется, какую выгоду можно получить от использования ООП в программах Delphi. Затем в главе рассматривается набор полезных парадигм объектно-ориентированного программирования, с помощью которых вы сможете управлять сложностью ваших приложений.
Преимущества ООП К традиционно выделяемым преимуществам объектно-ориентированного программирования относятся инкапсуляция, полиморфизм и многократное использование. В следующих разделах эти преимущества объясняются, также рассказывается, как можно лучше всего воспользоваться ими в программах Delphi.
Инкапсуляция Объект, определенный при помощи класса, инкапсулирует (incapsulation) данные, которые он содержит. Другие части программы могут использовать объект для управления его данными, не зная о том, как сохраняются или изменяются значения данных. Объект предоставляет открытые (public) процедуры и функции, которые позволяют программе косвенно управлять данными и просматривать их. Поскольку данные в таком случае являются абстрактными с точки зрения программы, это также называется абстракцией данных (data abstraction). Инкапсуляция позволяет программе обращаться с объектами как с «черными ящиками». Программа может использовать открытые методы объектов для исследования и изменения значений без необходимости разбираться в процессах, происходящих внутри этого черного ящика.
Преимущества ООП Поскольку действия внутри объектов скрыты от основной программы, можно модифицировать реализацию объекта без внесения изменений в основную программу. Изменения внутренней структуры объекта затрагивают только модуль класса. В качестве примера рассмотрим класс Fi leDownload, который загружает файлы из Internet. Программа передает объекту FileDownload информацию о расположении файла, а объект возвращает строку с содержимым файла. В этом случае программе не требуется знать, каким образом объект производит загрузку файла. Он может выбрать файл, используя модемное соединение или соединение по выделенной линии, или даже извлекать файл из локального буфера на диске. Программа знает только то, что объект возвращает строку после того, как ему передается информация о расположении файла.
Обеспечение инкапсуляции Для обеспечения инкапсуляции класс не должен позволять прямого доступа к своим данным. Если переменная объявлена внутри класса как открытая, то другие части программы могут напрямую считывать и изменять данные. Позже, если изменяется представление данных, любые части программы, которые взаимодействуют с данными непосредственно, также должны будут измениться. Это лишает инкапсуляцию одного из главных преимуществ. Чтобы поддерживалось отделение данных от основной программы, переменные класса объявляются как частные (private) или защищенные (protected). При этом основная программа будет обращаться к значениям косвенно через общие процедуры. Данные подпрограммы также называются процедурами свойств, потому что они позволяют основной программе изменять свойства класса. Этот термин используется для определения подобной концепции и в языке программирования Visual Basic. Следующий код показывает, как класс TTemperature позволяет другим частям программы просматривать и изменять значение DegreesFahrenheit.
interface type
' TTemperature = claee(TObject) private // Частные данные. P_DegreesFahrenheit : Single; public function DegreesFahrenheit : Single; procedure SetDegreesFahrenheit(new_value : Single); end; implementation // Возвращает температуру в градусах Фаренгейта. function TTemperature.DegreesFahrenheit : Single; begin . Result := P_DegreesFahrenheit; end; .1
Объектно-ориентированные методы // Устанавливает температуру в градусах Фаренгейта. procedure TTemperature.SetDegreesFahrenheit(new_value : Single); begin
P_DegreesFahrenheit := new_value; end; Различия между этими процедурами и определением P_DegreesFahrenheit как открытой переменной пока несущественны. Но с помощью процедур можно легко изменять класс впоследствии. Предположим, что вы решили сохранять температуру в градусах Кельвина, а не в градусах Фаренгейта. Вы можете изменить класс, не модифицируя остальные части программы, которые используют процедуры свойств DegreesFahrenheit. Можно также добавить код для проверки ошибок, чтобы удостовериться, что программа не передает объекту неправильные значения. interface uses SysUtils,type
TTemperature = class(TObject) private // Частные данные. p_DegreesKelvin:Single; public function DegreesFahrenheit : Single; procedure SetDegreesFahrenheit(new_value : Single).; end; implementation
// Возвращает, температуру в градусах Фаренгейта. function TTemperature.DegreesFahrenheit : Single; begin Result := (P_DegreesKelvin - 273.15) * 1.8; end; // Устанавливает температуру в градусах Фаренгейта. procedure TTemperature.SetDegreesFahrenheit(new_value : Single); var
new_Kelvin : Single; begin new_Kelvin := (new_value / 1.8) + 273.15; if (new_kelvin < 0) then // Ошибка. raise ERangeError.CreateFmt( 1 'Температура %f должна быть больше абсолютного нуля , [new_value]); P_DegreesKelvin: = new_kelvin; end;
Программы, описанные в книге, нарушают принцип инкапсуляции, используя открытые переменные в классах. Это не очень хороший стиль программирования, но он все же немного упрощает алгоритмы, что позволяет концентрироваться на самих алгоритмах, а не на связывании процедур свойств.
Полиморфизм Второе главное преимущество объектно-ориентированного программирования - это полиморфизм (polymorphism), что означает «имеющий много форм». То есть процедура Delphi может иногда управлять объектом, не зная, что он из себя представляет. Предположим, что вы создаете класс TReport. Из него вы выделяете классы TExpenditureReport и TBudgetReport, наследующие некоторые возможности, которые определены классом TReport. Теперь процедуре, в качестве параметра которой выступает объект класса TReport, можно передавать как объект класса TReport, так и объекты класса TBudgetReport или TExpenditureReport. Процедура вызывает только методы класса TReport, ей не требуется определять, каким видом объекта она управляет. В некоторых случаях базовый класс может только объявлять метод, но не реализовывать его. Реализация возлагается на классы-наследники. Данный метод объявляется в базовом классе для того, чтобы определить форму метода, который будет реализовываться классами-наследниками. Это позволяет программе полиморфно обращаться с объектами полученных классов. Базовым классом может создаваться и пустая реализация данного метода. В таком случае будет объявлен метод с ключевым словом abstract, по которому Delphi определяет, что базовый класс не будет реализовывать метод.
type TReport = claee(TObject)
public procedure PrintReport; virtual; abstract; end;
Применение абстрактного виртуального метода не только позволяет классу объявлять метод без реализации, но и контролирует использование базового класса программой. Если программа создает копию базового класса, то компилятор выдает предупреждение, указывающее на формирование объекта с абстрактным методом. Программа может все же создавать объекты и использовать их неабстрактные методы, но при попытке оперировать абстрактным методом возникает исключительная ситуация. Парадигма единственного объекта, описанная ниже, использует такую ситуацию, чтобы управлять доступом к уникальному объекту.
Многократное использование и наследование Функции и процедуры поддерживают многократное использование (reuse). Чтобы каждый раз не писать код заново, лучше поместить его в процедуру. В таком случае вместо блока кода можно просто подставить эту подпрограмму.
Объектно-ориентированные методы Точно так же допускается создавать класс, который делает процедуру доступной из любой части программы. Работая с объектом, который является экземпляром класса, программа может использовать процедуру. Наследование (inheritance) - это объектно-ориентированная версия многократного использования кода. Классы, которые наследуют функциональные возможности от родительских классов, не должны повторно реализовывать эти возможности. Ключевое слово virtual обеспечивает особенно эффективные средства многократного использования через наследование. Родительский класс объявляет некоторые методы как виртуальные. Классы-наследники с помощью ключевого слова override способны заменить эти методы в полученном классе. Новый метод может использовать ключевое слово inherited, чтобы вызвать версию метода родительского класса. Предположим, что класс TReport имеет метод PrintReport, который выводит на принтер различные заголовки для отчетов. Класс TBudgetReport использует данный метод для печати бюджетной информации, то есть оперирует ключевым словом inherited, чтобы заставить родительский класс выводить те же самые заголовки для бюджетного отчета. interface type TReport = class(TObject) protected procedure PrintReport; virtual; end; TBudgetReport = class(TReport) protected procedure PrintReport; override; end; implementation // Печать заголовка. procedure TReport.PrintReport; begin
// Печать заголовка. end ;
// Печать бюджетного отчета. procedure TBudgetReport.PrintReport; begin
// Использует метод родительского класса PrintReport для печати // заголовков. inherited PrintReport ;
// Печать оставшейся части отчета. end;
Парадигмы ООП Этот подход обеспечивает особенно хорошие результаты при объединении с полиморфизмом. Так как метод PrintReport объявлен виртуальным, процедура может вызвать объект подпрограммы PrintReport, не зная, каковы свойства этого объекта. Процедура в качестве параметра использует объект TRepprt. Если она передает объект TBudgetReport и вызывает метод PrintReport, то реализуется метод класса TBudgetReport. Таким образом, и без точной информации о типе объекта процедура получает преимущества версии метода переданного объекта.
Парадигмы ООП В главе 1 алгоритм определялся как «набор команд для выполнения определенной задачи». Безусловно, класс может использовать алгоритмы в своих процедурах и функциях. Например, многие алгоритмы успешно выполняются, будучи упакованы в класс. Некоторые программы, описанные в предыдущих главах, используют классы, чтобы инкапсулировать сложные алгоритмы. Классы также позволяют реализовать новый стиль программирования, при котором несколько объектов работают над задачей совместно. В этом случае нет необходимости указывать последовательность команд для выполнения задачи. Более правильным решением будет разработка модели поведения объектов, а не разбивание задачи на последовательность шагов. Чтобы отделить такие варианты от традиционных алгоритмов, их называют парадигмами (paradigms). В следующих разделах описываются некоторые полезные парадигмы объектно-ориентированного программирования. Многие из них были взяты из других объектно-ориентированных языков, таких как C++ и Smalltalk, но они могут использоваться и в Delphi.
Управляющие объекты Управляющие объекты (command object) также называются объектами действия (action objects), функцией (function objects) или функторами (functors). Управляющий объект представляет собой действие. Программа может использовать метод объекта Execute, чтобы объект выполнил предписанное ему действие. Программе ничего не нужно знать о действии, достаточно данных о том, что у объекта определен метод Execute. Управляющие объекты имеют множество интересных применений. Программа может использовать их для реализации следующих функций: D настраиваемых интерфейсов; а макросов; а регистрации и восстановления записей; а средств отмены и повтора действия. Чтобы создать настраиваемый интерфейс, в форму нужно включить управляющий набор кнопок. Во время выполнения программы форма загружает надписи на кнопках и создает соответствующий массив управляющих объектов. Когда пользователь щелкает по кнопке, обработчику событии кнопки надо лишь вызвать
Объектно-ориентированные методы соответствующий метод управляющего объекта Execute. Детали происходящего содержатся внутри класса управляющего объекта, а не в обработчике событий. Программа Cmdl использует управляющие объекты для реализации настраиваемого интерфейса для нескольких не связанных между собой функций. При щелчке по кнопке вызывается соответствующая процедура Execute управляющего объекта. Программа может оперировать управляющим объектом для создания макросов, определяемых пользователем. Пользователь задает последовательность действий, которые программа сохраняет как объекты Command в массиве или другой структуре данных. Позже, когда пользователь запускает макрос, программа вызывает метод Execute соответствующего управляющего объекта в массиве. Управляющие объекты могут обеспечивать регистрацию и восстановление записей. При каждом вызове управляющий объект способен сохранять информацию о себе в журнале. Если программа аварийно завершит работу, она может использовать записанную информацию, чтобы восстановить управляющие объекты и запустить их для повторения последовательности команд, которые выполнялись перед сбоем программы. Наконец, программа может использовать набор управляющих объектов для реализации функций отмены и повтора действий. Программа Cmd2 позволяет строить прямоугольники, эллипсы и линии в области рисования. При построении каждой следующей фигуры программа сохраняет управляющий объект рисунка в связанном списке. Чтобы вывести изображение, программа повторно запускает команды в массиве. Класс TDrawingCommand определяет переменные, используемые каждым типом команд рисования. Классы TRectangleCmd, TEllipseCmd и TLineCmd созданы из класса TDrawingCommand и реализуют собственные методы рисования. Массив Commands в основной программе хранит до 1000 объектов TDrawingCommand. Они представлены полиморфно как объекты TDrawingCommand, но в действительности каждый из них является образцом одного из конкретных классов рисования формы. Эта программа использует простой массив с 1000 записями, чтобы хранить команды рисования. Более надежная программа строится на основе связанного списка или массива изменяемого размера, сохраняющего любое число команд. Программа Cmd2 использует переменную LastCommand, чтобы отслеживать последний управляющий объект в массиве. Когда пользователь выбирает команду Undo (Отменить) в меню Draw (Рисовать), программа уменьшает значение переменной LastCommand на единицу. Когда программа выводит изображение, она вызывает только объекты, стоящие до объекта с номером LastCommand. Если пользователь выбирает команду Redo (Повторить) в меню Draw, то программа увеличивает значение переменной LastCommand на единицу, если какие-то команды были недавно отменены. Когда программа выводит рисунок, она применяет на один объект больше, чем в прошлый раз, поэтому отображается восстановленный рисунок.
Парадигмы ООП При добавлении новой фигуры программа удаляет любые команды из массива, которые лежат после позиции LastCommand. Затем она добавляет новую команду рисования и отключает команду Redo, так как нет команд, которые можно восстановить. На рис. 13.1 показано окно программы Cmd2 после добавления новой фигуры.
Рис. 13.1. Окно программы Cmd2
Контролирующий объект Контролирующий объект (visitor object) посещает элементы в составном объекте, или агрегате (aggregate object). Процедура, реализованная классом агрегата, в качестве параметра принимает контролирующий объект. Она обходит объекты агрегата, передавая каждый из них контролирующему объекту в качестве параметра. Например, предположим, что объекты агрегаты хранят элементы в связанном списке. Следующий код показывает, как его метод V i s i t обходит список, передавая в качестве параметра каждый элемент методу V i s i t объекта TVisitor. procedure TAggregate.Visit(visitor : TVisitor); var cell : TCell; begin cell := TopCell; while (cellonil) do
begin visitor.Visit(cell.Value); cell := cell.NextCell;
end; end;
Следующий код показывает, как метод Visit класса TVi s i t or может отображать значения элементов списка в ряде диалоговых окон.
|i
Объектно-ориентированные методы
procedure TVisitor.Visit(value : String); begin ShowMessage(value); end; При помощи парадигмы контролирующего объекта класс агрегата определяет порядок обхода элементов. Контролирующий объект не может управлять этим порядком. Агрегат может определять несколько методов для передачи элементов контролирующему объекту. Так класс дерева обеспечивает методы VisitPreorder (Прямой обход), VisitPostorder (Обратный обход), Visitlnorder (Симметричный обход) и VisitBreadthFirst (Обход в глубину), чтобы контролирующие объекты обходили элементы в различном порядке.
Итератор Итератор (iterator) обеспечивает альтернативный метод обхода элементов в объекте агрегата. Объект итератора обращается к агрегату для обхода его элементов и определяет порядок, в котором проверяются элементы. Множество классов итератора может быть сопоставлено с классом агрегата, чтобы обеспечить различный порядок обхода элементов. Итератор должен знать порядок записи элементов, чтобы определить последовательность их обхода. Если агрегат - это связанный список, то объект итератора должен знать, что элементы сохранены в связанном списке, и уметь по этому списку перемещаться. Так как итератору известны детали внутренней организации списка, он нарушает инкапсуляцию агрегата. Вместо того чтобы каждый класс, который должен проверять элементы агрегата, реализовывал обход самостоятельно, можно сопоставить класс-итератор с классом-агрегатом. Итератор должен содержать простае процедуры MoveFirst (Переместиться в начало), MoveNext (Переместиться на следующий элемент), EndOf List (Переместиться в конец списка) и Currentltem (Текущий элемент), чтобы обеспечить косвенный доступ к списку. Новые классы могут включать в себя экземпляр класса итератора и использовать его методы для обхода элементов агрегата. На рис. 13.2 схематически показано, как новый объект использует объект итератора для связи со списком. Нов э!й объект Объект-итератор
Объект-список
Рис. 13.2. Использование итератора для косвенной связи со списком
Парадигмы ООП Программа Iter использует итераторы для обхода полного двоичного дерева. Параметр процедуры Traverse указывает на тип обхода. Она использует метод объекта дерева Createlterator, чтобы создать объект итератора. Итератор - это объект одного из нескольких классов, полученных из класса TIterator. Процедура Traverse применяет полиморфизм, чтобы оперировать данным объектом, как объектом типа TIterator. Она использует методы итератора EndOfTree, CurrentNode и MoveNext, чтобы вывести узлы дерева. // Отображает обход дерева. procedure TIterForm.Traverse(it_type : TIteratorType);
var
trav : TIterator; txt : String; begin // Создание итератора. trav := TheTree.Createlterator(it_type); // Используем итератор для обхода дерева-. txt : = "';
while (not trav.EndOfTree) do .begin txt, : = txt + IntToStr(trav.CurrentNode) +' ' ; trav.MoveNext; end;
ResultLabel.Caption := txt; // Разрушаем итератор. trav.Free,•
end;
Итераторы нарушают принцип инкапсуляции своих агрегатов в отличие от новых классов, которые содержат итераторы. Можно рассматривать итератор как надстройку над агрегатом, для того чтобы избежать потенциальной путаницы. В программе Iter процедуры главной формы не распознают, как сохранено дерево. Только итераторы должны содержать информацию о внутренней структуре дерева. Итераторы вместе с классом дерева объявляются внутри одного модуля, чтобы их взаимодействие было наглядным. В этом примере итераторы и класс дерева также определены в одном модуле. Класс дерева объявляет метод Createlterator, который возвращает объект-итератор. Все классы итераторов используют ссылки на класс дерева и должны быть определены в одном объявлении типа, как и все классы Delphi, которые содержат циклические ссылки друг на друга. .Контролирующие объекты и итераторы обеспечивают выполнение похожих функций, используя различные подходы. Поскольку парадигма контролирующего объекта оставляет структуру агрегата внутри него, она обеспечивает лучшую инкапсуляцию. Итераторы полезны, если порядок обхода часто изменяется или если он должен определяться во время работы программы. Например, агрегат может
Объектно-ориентированные методы использовать метод фабрики (который описан позднее), чтобы создать объектитератор в процессе выполнения программы. Класс, содержащий итератор, не должен обладать информацией, как создан итератор, он всего лишь использует методы итератора для обращения к элементам агрегата.
Дружественный класс Многие классы применяются совместно с другими. Например, класс-итератор тесно взаимодействует с классом агрегатом. Чтобы выполнить свою задачу, итератор должен нарушить инкапсуляцию агрегата. Хотя для этих связанных классов такое иногда допустимо, другие классы этого делать не должны. Дружественный класс (friend class) - это класс, который имеет специальное разрешение нарушать инкапсуляцию другого класса. Например, класс-итератор является дружественным для соответствующего агрегата. Ему в отличие от других классов разрешено нарушать принцип сокрытия данных для агрегата. Самый простой способ реализации дружественных классов в Delphi состоит в создании соглашения, в соответствии с которым только дружественные классы будут нарушать инкапсуляцию друг друга. Если все разработчики будет придерживаться этого соглашения, то проектом все еще можно будет управлять. Однако всегда есть вероятность, что кто-то нарушит инкапсуляцию из-за лени или по неосторожности. Альтернативная стратегия состоит в том, чтобы объект дружественного класса передавал себя другому объекту как параметр. Тем самым дружественный класс доказывает, что имеет допустимый тип. Delphi всегда проверяет, является ли параметр объектом допустимого типа. Однако все еще возможно нарушить инкапсуляцию объекта, даже применяя последний из описанных методов. Программа может создать объект дружественного класса и использовать его в качестве параметра, чтобы обмануть процедуры другого объекта. Но это довольно сложный процесс, так что маловероятно, что разработчик сделает это случайно.
Интерфейс В этой парадигме один объект действует в качестве интерфейса (interface) между двумя другими. Он может использовать свойства и методы первого объекта, чтобы взаимодействовать со вторым. Интерфейс иногда также называется адаптером (adapter), оболочкой (wrapper), или мостом (bridge). На рис. 13.3 схематически изображена работа интерфейса. Объект 1
Интерфейс
Рис. 13.3. Интерфейс
Объект2
Парадигмы ООП Интерфейс позволяет двум объектам с обеих сторон независимо изменяться. Например, если методы объекта слева на рис. 13.3 переопределяются, интерфейс должен модифицироваться, но нет необходимости меняться объекту справа. Процедуры, используемые двумя объектами, поддерживаются разработчиками, которые обслуживают эти объекты. Разработчик, управляющий объектом слева, должен также оперировать процедурами интерфейса, которые взаимодействуют с объектом слева. Не путайте парадигму интерфейса с понятием класса, имеющего абстрактный метод. Как было описано ранее, можно использовать базовый класс с абстрактными методами для определения функциональных возможностей классов-наследников. Часто считают, что базовый класс определяет интерфейс для наследников; парадигма интерфейса - способ совместной работы двух классов; класс с абстрактным методом - интерфейс между этим методом и остальной частью программы.
Фасад Фасад (facade) обеспечивает простой интерфейс для сложного объекта или группы объектов. Фасад также иногда называется оболочкой. На рис. 13.4 показана схема работы фасада.
Объект
Фасад
Рис. 13.4. Фасад '
.,:•
-
.
-
К
-
-
-
'
'
'
Разница между фасадом и интерфейсом главным образом умозрительная. Задача интерфейса - обеспечение косвенного взаимодействия объектов друг с другом, чтобы они могли независимо развиваться. Основная цель фасада состоит в том, чтобы упростить использование чего-либо сложного за счет скрытия подробностей.
Фабрика Фабрика (factory) - это объект, который создает другие объекты. Метод фабрики - это процедура или функция, которая непосредственно формирует объект.
Объектно-ориентированные методы Фабрики наиболее полезны, когда два класса должны выполнять совместную работу. Например, класс-агрегат может содержать метод фабрики, который создает для него итераторы. Метод фабрики будет инициализировать итератор таким образом, чтобы он был готов работать с конкретной копией агрегата, который его создал. Программа Iter строит полные двоичные деревья, сохраненные в массиве. После щелчка по одной из кнопок, задающих направление обхода, программа при помощи метода фабрики Createlterator класса TcompleteTree создает соответствующий тип итератора в зависимости от нажатой кнопки. Метод фабрики Createlterator весьма прост. Он создает соответствующий объект-итератор, передавая себя в его конструктор как параметр. Конструктор итератора сохраняет объект дерева для последующих ссылок и инициализирует его позицию в дереве. Следующий код демонстрирует метод фабрики Createlterator и конструктор для базового класса TIterator. Индивидуальные подклассы итератора TPreorderlterator, TInorderlterator, TPostorderIterator и TBreadthFirstIterator наследуют этот конструктор без всяких изменений. // Этот метод фабрики создает и возвращает соответствующий итератор. function TCompleteTree.Createlterator (it_type : TIteratorType) : TIterator; begin case (it_type) of itPreorder : Result := TPreorderlterator.Create(Self); itlnorder : Result := TInorderlterator.Create(Self); ItPostorder : Result := TPostorderIterator.Create(Self); ItBreadthFirst : Result := TBreadthFirstlterator.Create(Self); else Result := nil; end; end;
// Сохранение дерева и порядка расположения узлов для данного обхода. constructor TIterator.Create(tree : TCompleteTree); begin inherited Create; TheTree := tree; OrderNodes; Currentlndex := 0; end; После создания фабрикой соответствующего итератора программа Iter использует его для обхода дерева и перечисления узлов в соответствующем порядке. На рис. 13.5 показано окно программы Iter, отображающее обратный обход дерева.
Рис. 13.5. Окно программы IterTree, отображающее обратный обход дерева
Единственный объект Единственный объект (singleton object) - это объект, который существует в приложении в единственном экземпляре. Например, в Delphi определен объект Printer (Принтер). Поскольку в одно и то же время может быть выбран только один принтер, указанный объект уникален. Один из способов создания единственного объекта состоит в определении функции, которая возвращает конкретный объект, объявленный в программном модуле. Чтобы не создавать большого количества копий этого класса, профамма должна объявить виртуальный абстрактный конструктор. Если профамма пытается непосредственно создавать объект этого класса, будет вызван неопределенный конструктор, LI* НФ что приведет к возникновению исключительTitle ной ситуации. 131260 48132049 2753294 var nim_links - Span*.pas I44S Для создания объекта программа фор328164 SysFader 1245626 SysFader мирует класс-наследник, который опреде65564 NetPDE Agent лен в разделе implementation. Это делает 4391708 Single 6554532 Single ,*»*< полученный объект видимым для процедур 524708 Delphi 5 - Single [Running] 3081170 Single в пределах модуля, но скрытым от внешних 3933002 SingleF.pas процедур. Полученный класс должен обес524634 Object Inspector 918118 Database Engine Error печить неабстрактный конструктор, поэто852444 Translation Repository - c:\lanc! 1114534 Translation Manager му процедура в модуле может создать толь2556584 Select a Font 983474 Search result: ко один-единственный объект. 1966626 Search Профаммы Single реализует класс для 1376864 Menu Designer создания единственного объекта, который возвращает список всех окон, выполняюРис. 13.6. Окно программы Single щихся в Windows. Следующий код ПОКаЗЫвает, как программа реализует единственный объект класса TWindowLister. На рис. 13.6 показано окно программы Single, отображающее список окон.
ШЗИНИИ11'
Объектно-ориентированные методы
type
// Поскольку класс имеет виртуальный абстрактный конструктор, // программа не может создать его напрямую. TWindowLister = class (TObject) public constructor Create; virtual; abstract; function WindowList : String; end;
// Общая функция, которая возвращает единственный объект. function WindowLister : TWindowLister; implementation type
// Этот подкласс может быть создан, // но он виден только внутри данного модуля. TAWindowLister = class (TWindowLister) public constructor Create; override; end;
// Одна копия TAWindowLister. var TheWindowLister : TAWindowLister; // Общая функция, которая создает и возвращает объект. function WindowLister : TWindowLister; begin if (TheWindowLister=nll) then TheWindowLister := TAWindowLister .Create; Result := TheWindowLister; end; // TwindowLister / /_ _ _ _ _ _ _ _ _ _ _ _ _ // Возвращает список окон. function TWindowLister. WindowList : String; const CR = #13110; MAX_CHAR = 256; var
desktop_hWnd, next_hWnd, buflen : Longint; buf : array [0. .MAX_CHAR] of Char; begin Result := 'hWnd Title '+CR+' // Получаем дескриптор окна рабочего стола. desktop_hWnd := GetDesktopWindow;
Парадигмы ООП // Получаем первое дочернее окно рабочего стола. next_hWnd := GetWindow(desktop_hWnd,GW_CHILD) ; while (next_hWnd<>0) do begin buflen := GetWindowText(next_hWhd,@buf ,MAX_CHAR) ; if (buflen > 0) then begin Result := Result + CR+Format ( ' %10d %s', [next_hWnd,String(buf ) end; // Получаем следующее дочернее окно. next_hWnd := GetWindow(next_hWnd,GW_HWNDNEXT) ; end; end;
// TAWindowLister // _ _ _ _ _ _ _ _ _ _ _ _ // Конструктор, который ничего не выполняет. constructor TAWindowLister. Create; begin end;
Единственный объект WindowLister доступен во всем проекте. Следующий код показывает, как основная программа использует свойство WindowList этого объекта для вывода на экран списка окон. ListMemo.Text := WindowLister .WindowList ;
Сериализация Многие приложения сохраняют объекты и восстанавливают их. Например, приложение может сохранить представление своих объектов в текстовом файле. При следующем запуске программа считывает файл и перегружает объекты. В объекте могут содержаться процедуры, которые считывают и записывают его в файл. Более общий подход состоит в том, чтобы создать процедуры, которые сохраняют и восстанавливают данные объекта с помощью строки String. Поскольку при сохранении данных в строке объект преобразуется в последовательность символов, этот процесс иногда называется сериолизацией (serialization). Преобразование объекта в строку обеспечивает большую гибкость основной программы. При этом она может сохранять и восстанавливать объект, используя текстовый файл, базу данных или ячейку памяти. Разрешается пересылать представленный таким образом объект по сети или сделать его доступным на Web-странице. Программа или элемент управления Act iveX на другом конце могут использовать сериализацию для воссоздания объекта. Программа может также дополнительно обработать строки, например, зашифровать строку после преобразования объекта в строку и расшифровать перед обратным преобразованием. Один из приемов сериализации объекта состоит в том, чтобы объект записал все свои данные в строку заданного формата. Например, предположим, что класс
Объектно-ориентированные методы ТВох имеет свойства xl, yl, х2 и у2. Следующий код показывает, как класс определяет процедуры свойства Serialization. function TBox.Serialization : String; begin
Result := Рой(Йй(;1%(а?%<а:;%^;1к1;*;1х1,у1,хЗ,у2-]); end;
procedure TBox.SetSerialization(txt : String); var posl : Integer; begin posl := Pos(';',txt); xl := StrToInt(Copy(txt,l,posl - 1)) ; txt := Copy(txt,posl + 1,Length(txt - posl)); posl := Pos(';',txt); yl := StrToInt(Copy(txt,l,posl - 1)); txt := Copy(txt,posl + l,Length(txt - posl)); posl := Pos(';',txt) ; x2 := StrToInt(Copy(txt,1,posl - 1)); txt := Copy(txt,posl + l,Length(txt - posl)); posl := Pos(';',txt); y2 := StrToInt(Copy(txt,1,posl - 1)); txt := Copy(txt,posl + l,Length(txt - posl)); end;
Этот метод относительно простой, но не очень гибкий. В процессе развития программы изменения в структуре объекта заставят вас преобразовывать все предварительно сохраненные сериализации. Если объекты сохранены в файлах или базах данных, потребуется написать программы преобразования, чтобы считать старые данные и сохранить их в новом формате. Более гибкий метод состоит в том, чтобы сохранять имена элементов данных объекта вместе с их значениями. Когда объект читает данные, преобразованные в последовательную форму, он использует имена элементов для определения значений, которые следует установить. Если впоследствии будут добавлены или удалены элементы из описания объекта, то не придется преобразовывать старые данные. Когда новый объект считывает старые данные, он просто игнорирует все значения, которые не поддерживает. Определяя значения элементов по умолчанию, иногда можно уменьшить размер сериализованных объектов. Процедура свойств Serialization сохраняет только элементы со значениями, которые отличаются от значений по умолчанию. Перед тем, как начать сериализацию, процедура SetSerialization устанавливает значения элементов всего объекта по умолчанию. Эти значения обновляются, когда процедура обрабатывает сериализованные данные. Программа Serial использует этот метод сохранения и восстановления изображения, содержащего эллипсы, линии и прямоугольники. Следующий код демонстрирует процедуры свойств сериализации объекта TDrawingCommand. Процедура
GetToken — это вспомогательная подпрограмма, которая удаляет первый маркер из строки и возвращает его имя и значение. Каждый маркер сохранен в строке следующим образом - имя и в скобках его значение, например token_name (token_value). Процедура Set Serialization пропускает пробелы и переводит каретку. Функция Serialization использует указанные подпрограммы, чтобы сделать результаты более читаемыми. const // Константы для сериализации и десериализации. DEF_xl = 0; DEF_yl = 0; DEF_x2 = 100; DEF_y2 = 100; DEF_color = clBlack; DEF_style = bsSolid;
// Возвращает сериализованные данные, которые хранят цвет, позицию // и информацию о стиле. function TDrawingCommand.Serialization : String,const
CR = #13#10; begin Result := ' ' ;
if (xl<>DEF_xl) then Result := Result + Format('xl(%d)',[xl]) + CR; if (yloDEF_yl) и Result := Result + Format('yl(%d)',[yl]) + CR; if (x2<>DEF_x2) then Result := Result + Format('x2(%d)',[x2]) + CR; if (y2<>DEF_y2) then Result := Result + Format('y2(%d)',[y2]) + CR; if (color<>DEF_color) then Result := Result + Format ('color(%d)',[Integer(color)]) + CR; if (styleoDEF_style) then Result := Result + Format('style(%d)',[Integer(style)]) + CR; end; // Загрузка цвета, позиции и информации о стиле из основной сериализации. procedure TDrawingCommand.Deserialize(txt : String); var
token_name, token_value : String; begin // Установка значений по умолчанию. Xl := yl := X2 := y2 := color style
DEF_xl; DEF_yl; DEF_x2; DEF_y2; := DEF_Color; := DEF_styLe;
Объектно-ориентированные методы while (txto1 ') do
;
,L
begin // Считывает маркер из строки сериализации. .GetToken(txt,token_name,token_value); // Какой это маркер. if (token_name = 'xl') then xl := StrToInt(token_value) else if (token_name = 'yl') then yl := StrToInt(token_value) else if (token_name = 'x2') then x2 := StrToInt(token_value) else if (token_name = 'y2') then y2 := StrToInt(token_value) else if (token_name = 'color') then color := TColor(StrToInt(token_value)) else if (token_name = 'style') then style := TBrushStyle(StrToInt(token_value)); end; end;
Парадигма Модель/Вид/Контроллер Парадигма Модель/Вид/Контроллер (Model/View/Controller - МУС) позволяет программе управлять сложными отношениями между объектами, которые сохраняют данные, объектами, отображающими их на экране, и объектами, которые управляют данными. Например, приложение для работы с финансами может выводить расходные данные в виде таблицы, круговой диаграммы или гистограммы. Если пользователь изменяет значение в таблице, приложение должно автоматически обновить изображение на экране. Программа может также записать измененные данные на диск. В сложных системах достаточно трудно управлять взаимодействием между объектами, которые сохраняют информацию, выводят ее на экран и оперируют данными. Парадигма MVC разделяет взаимоотношения так, чтобы их можно было обработать по отдельности, используя при этом три вида объектов: модели, виды и контроллеры. Модели . '• • Модель (model) .представляет данные, обеспечивая методы, которые другие объекты используют для проверки и изменения данных. В приложении для работы с финансовыми данными модель хранит данные о расходах. Она обеспечивает процедуры для просмотра и изменения значений расходов и ввода новых значений. Модель может также предоставить функции, которые вычисляют суммарные значения, такие как полные издержки, расходы по подразделениям, средние расходы за месяц и т.д. Модель содержит работающие со списком процедуры AddView и Remove View, которые выводят данные на экран. Эти процедуры, также называемые видами, могут быть сохранены в связанном списке или другой динамической структуре
Парадигмы ООП данных. Всякий раз при изменении данных модель сообщает об этом видам в списке, которые обновляют изображения соответствующим образом.
Виды Bud (view) отображает данные, представленные моделью. Поскольку виды обычно выводят данные для просмотра пользователем, иногда удобнее создавать их, используя форму, а не класс. Когда программа создает новый вид, она должна добавить его к набору видов модели. Один из способ реализации этого подхода заключается в том, чтобы конструктор вида принимал модель в качестве параметра. Тогда конструктор может вызвать процедуру модели AddView, чтобы программа вывела его на экран вместе с моделью. Вид в своем деструкторе вызывает процедуру модели RemoveView, что позволяет модели удалять вид из списка видов. Если этого не происходит, модель при следующем изменении данных попытается обратиться к разрушенному виду.
Контроллеры Контроллер (controller) изменяет данные в модели. Контроллер должен всегда обращаться к данным модели через ее открытые методы. Эти методы могут уведомлять виды о произошедшем изменении. Если бы контроллер изменял данные напрямую, то модель не смогла бы сообщить об этом видам.
Виды/Контроллеры Многие объекты и отображают и изменяют данные. Например, текстовое поле позволяет пользователю просматривать и вводить информацию. Форма, содержащая текстовое поле, может использоваться и как вид, и как контроллер. Кнопки опций, переключатели, полосы прокрутки и многие другие элементы пользовательского интерфейса также позволяют одновременно просматривать данные и управлять ими. Виды/контроллеры являются самым простым способом управления, если попытаться максимально разделить функции вида и контроллера. Когда объект изменяет данные, он не должен сам обновлять свое отображение на экране. Это можно сделать и позже, когда модель сообщает ему как виду о произошедшем изменении. Рассмотрим в качестве примера гистограмму, которая визуально отображает данные. Пользователь может перетаскивать столбцы с помощью мыши, изменяя значения данных. При передвижении столбцов объект гистограммы использует открытые процедуры доступа к данным модели, чтобы изменять выводимые на экран данные. Затем модель передает эту информацию видам, включая гистограмму. Гистограмма обновляет свое изображение на экране, чтобы показать новое значение, которое выбрал пользователь. Описанные методы очень неудобны для реализации стандартных объектов интерфейса пользователя, таких как текстовые поля. Когда пользователь вводит в текстовое поле какие-либо значения, оно немедленно обновляется и выполняется его обработчик события OnChange. Этот обработчик события может информировать модель об изменении. Модель сообщает о произошедшем изменении виду/ контроллеру (действующему как вид). Если при этом объект обновит текстовое
\
V
Объектно-ориентированные методы поле, то произойдет еще одно событие OnChange, о котором снова будет сообщено модели, и программа войдет в бесконечный цикл. Для предотвращения этой проблемы методы модификации данных модели должны иметь необязательный параметр, указывающий на контроллер, который вызвал эти изменение. Когда контроллер информирует виды об изменении, модель пропускает его при передачи сообщения об обновлении. Если виду/контроллеру, подобному гистограмме, требуется сообщить об изменении, которое он вызывает, он должен передать значение nil процедуре', вносящей изменения вид/контроллер, используемый текстовым полем, может передавать себя в качестве параметра, чтобы не возникало необходимости сообщать об изменении, которое он вызывает. Программа МУС, окно которой показано на рис. 13.7, использует парадигму Модель/Вид/Контроллер для вывода данных о расходах. На рисунке показаны три вида различных типов. Класс TMvcPieView отображает данные при помощи круговой диаграммы. Это достаточно простой вид, просто выводящий на экран данные модели.
Binary Transmission Lab Computer Center
Рис. 13.7. Окно программы Mvc
Класс TMvcTableyiew - это вид/контроллер таблицы. Он отображает названия категорий расхода и их значения в текстовых полях, а когда значения в текстовом поле изменяются, он обновляет данные модели. Поскольку текстовые поля и отображают, и изменяют значения данных, объекты этого класса при изменении данных передают себя в качестве параметра модели. В этом случае модель игнорирует сообщение объекта, вызвавшего изменение. Класс TMvcGraphView является видом/контроллером гистограммы. Он выводит данные на экран в виде графика. Пользователь может изменять данные модели,
Резюме перетаскивая при помощи мыши столбцы гистограммы. Этот класс не обновляет изображение мгновенно при изменении данных, выступая в роли контроллера. Вместо этого он передает подпрограммам изменения данных модели значение ni 1. При этом модель информирует гистограмму об изменении данных, и гистограмма, действующая в качестве вида, обновляется. С помощью этой программы можно создать любое количество окон таблиц, круговых диаграмм и гистограмм. Если вы изменяете какие-либо данные в одной из таблиц или гистограмм, то все окна автоматически обновляются.
Резюме Классы позволяют программистам на Delphi применить новые методы для решения старых задач. Вместо того чтобы размышлять над последовательностью алгоритмических шагов, можно оперировать группой взаимодействующих объектов. Если задачу правильно разбить на более мелкие задачи, то каждый класс по отдельности будет достаточно простым, хотя вместе они могут выполнять очень сложную функцию. Используя описанные в этой главе парадигмы, вы сможете разбить классы так, чтобы каждый из них оказался максимально простым.
I ',.:"
Приложение 1. Архив примеров Это приложение описывает содержание архива с примерами, который вы можете загрузить с сайта издательства «ДМК Пресс» www.dmkpress.ru и объясняет, как использовать помещенные в архив программы. В приложении 2 приведен список программ, в котором также содержится их краткое описание.
Содержание архива с примерами В архиве с примерами находятся исходные тексты для алгоритмов на языке Object Pascal, сохраненые в формате Delphi 3, и программы примеров, описанные в этой книге. Все алгоритмы были протестированы в 3-й, 4-й и 5-й версиях Delphi. Программы примеров, которые обсуждались в каждой главе, содержатся в отдельных подкаталогах. Например, программы, демонстрирующие алгоритмы, описанные в главе 3, хранятся в подкаталоге \Ch3. Все программы кратко описаны в приложении 2.
Аппаратные требования Чтобы запускать и изменять примеры приложений, вам понадобится компьютер, который удовлетворяет требованиям выбранной вами версии Delphi к аппаратному обеспечению. На компьютерах разных конфигураций алгоритмы выполняются с различной скоростью. Компьютер с процессором Pentium Pro с частотой 200 МГц и 64 Мб памяти будет работать быстрее, чем компьютер на базе 386-го процессора с 4 Мб памяти. Вы быстро узнаете возможности вашего оборудования.
Запуск примеров программ Один из наиболее применяемых способов запуска программ примеров - это использование возможностей компилятора Delphi. Используя точки останова, окна просмотра и другие свойства отладчика, вы можете изучить внутреннюю структуру работающих алгоритмов. Это позволит вам быстро понять даже самые сложные примеры, такие как представленные в главах 7 и 12 алгоритмы работы со сбалансированными деревьями и сетевые алгоритмы. Некоторые программы создают файлы данных или временные файлы и помещают их в соответствующий каталог. Например, алгоритмы сортировки, рассмотренные в главе 9, создают файлы данных в каталоге \Ch9. Все эти файлы имеют расширение . dat, поэтому вы можете найти и удалить их в случае необходимости.
Программы примеров предназначены только для демонстрационных целей, чтобы помочь вам понять конкретные принципы работы алгоритмов, и в них почти не реализована обработка ошибок или проверка данных. При введении недопустимых данных программа может аварийно завершить свою работу. Если вы не знаете, какие данные допустимы, воспользуйтесь меню Help (Помощь) для получения подробной информации.
Информация и поддержка пользователей Программное обеспечение, сопровождающее эту книгу, предоставляется без гарантии или поддержки. Если у вас возникли проблемы при установке программ, вы можете связаться со службой поддержки по следующему адресу электронной почты: [email protected]. Получить подробную информацию о книгах издательства «ДМК Пресс» можно по телефону (095) 956-38-45. Вы можете послать комментарии или вопросы автору по адресу [email protected]. Посетите Web-страницы www.vb-helper.com/da.html или www.wiley.com/compbooks/stephens. если вы хотите больше узнать о книгах, написанных Родом Стивенсом. Эти сайты включают обновления и приложения для представленных в книге материалов. Здесь также находится описание того, чего добились другие читатели при помощи книг Рода Стивенса. Если вы нашли интересное применение для материалов этой книги, пошлите электронное сообщение по адресу [email protected]. Ваши достижения обязательно будут помещены на сайт.
Приложение 2. Список примеров программ В архиве с примерами содержится 72 программы, записанные в формате Delphi 3.0. Все программы были протестированы на 3-й, 4-й и 5-й версиях Delphi. В данном приложении приводится список и краткое описание демонстрируемых алгоритмов. Глава 1 Pager
Подкачка памяти и пробуксовка
Глава 2 SizeArr SimList Garbage LListl LList2 DblList Threads
Массивы с изменяемыми размерами Простые списки изменяемого размера на основе массивов Список изменяемого размера с алгоритмом «сборки мусора» Связанный список, инкапсулированный в класс Связанный список с процедурами MoveFirst и MoveNext Двусвязный список Связанный список с потоками
Глава 3 AStack LStack ArrayQ CircleQ LinkedQ PriorQ HeadedQ Глава 4 Triang Poly
Стек на основе массива Стек на основе связанного списка Очередь на основе массива Циклическая очередь Очередь на основе связанного списка Очередь с приоритетом на основе связанного списка Многопоточная очередь
Sparse VSparse
Класс треугольного массива Связанный список многоугольников, содержащий связанный список точек Разреженные массивы Сильно разреженные массивы
Глава 5 Facto1 Gcdl Fibol
Рекурсивное вычисление факториала Рекурсивное вычисление НОД Рекурсивное вычисление чисел Фибоначчи
Список примеров программ Hilbl Sierpl BigAddl Facto2 Gcd2 BigAdd2 Fibo2 Fibo3 Fibo4 Hilb2 Sierp2 Глава6 Binary NAry
Рекурсивное построение кривых Гильберта Рекурсивное построение кривых Серпинского Рекурсивное сложение Нерекурсивное вычисление факториала с удалением хвостовой рекурсии Нерекурсивное вычисление НОД с удалением хвостовой рекурсии Нерекурсивное сложение с удалением хвостовой рекурсии Нерекурсивное вычисление чисел Фибоначчи с применением таблицы соответствия Нерекурсивное вычисление чисел Фибоначчи с применением заранее вычисленной таблицы соответствия Нерекурсивное вычисление чисел Фибоначчи снизу вверх Нерекурсивное построение кривых Гильберта Нерекурсивное построение кривых Серпинского
FStar Travl Trav2 TrSort Qtree
Двоичное дерево N-ичное дерево, использующее массивы дочерних узлов изменяемого размера N-ичное дерево, использующее прямую звезду Обход полных двоичных деревьев Обход N-ичного дерева Сортированное двоичное дерево Q-дерево
Глава 7 AVL Btree Bplus
AVL-деревья Б-дерево Б+дерево
Глава 8 TicTac BandB Heur
Поиск в дереве игры с заданными начальными ходами Поиск методом полного перебора и методом ветвей и границ Эвристика
Глава 9 Encode Sort Unsort HeapQ
Кодирование строк Сортировка Перемешивание массивов Очередь с приоритетом на основе пирамиды
Глава 10 Search
Поиск в списке
Глава 11 Chain Bucket
Хеш-таблица со связыванием Хеш-таблица с блоками
Delphi. Готовые алгоритмы Bucket2 Linear Ordered Quad Rand Rehash Глава 12 NetEdit Span PathS PathC Distr Flow Work Глава 13 Cmdl Cmd2 Iter Single Serial Mvc
Хеш-таблица с блоками, сохраненными на диске Открытая адресация с линейной проверкой Открытая адресация с упорядоченной линейной проверкой Открытая адресация с квадратичной проверкой Открытая адресация с псевдослучайной проверкой Открытая адресация с линейной проверкой, удалением и переформированием Редактор сети Минимальное остовное дерево Поиск кратчайшего пути методом расстановки меток Поиск кратчайшего пути методом коррекции меток Разбиение на районы с помощью дерева кратчайших путей Вычисление максимального сетевого потока Распределение работ с помощью максимального сетевого потока Пользовательский интерфейс на базе управляющих объектов Отмена/повтор с помощью управляющих объектов Итераторы и методы фабрики для полного двоичного дерева Использование единственного объекта для перечисления окон Рисование фигур с помощью сериализованных управляющих объектов Парадигма Модель/Вид/Контроллер
Предметный указатель Абстракция данных 346 Алгоритм 18 каскадный 312
Граф 126
д Дерево 126 AVL 160 вращение влево-вправо 163 вращение вправо-влево 164 левое вращение 163 правое вращение 162 N-ичное 127 Q-дерево 151 Б+дерево 179 Б-дерево 174 восходящее 178 нисходящие 178 ветвь 127 восьмеричное 157 глубина 127 двоичное 127 корень 126 обход 135 в глубину 136 в ширину 136 поддерево 126 представление 127 нумерация связей 130 полное дерево 134 .
полные узлы 128 списки дочерних узлов 129 решений 188 метод ветвей и границ 195 минимаксный перебор 190 оптимизация поиска 193 эвристика 200 с симетричными ссылками 147 сбалансированное 159 AVL 160, 162, 163', 164 троичное 127 узел 126 внутренний 127 глубина 127 дочерний 127 лист 127 порядок 127 родительский 127 сестринский 127 степень 127
М Массив 31, 77 динамический 32 нерегулярный 79 разреженный 83 треугольный 77 формула преобразования индексов 78 Метки 49
О Объектно-ориентированное Г', программирование. См, ООП
Delphi. Готовые алгоритмы ООП 346 абстракция данных 346 инкапсуляция 346 многократное использование 349 наследование 350 парадигмы 351 агрегат 353 дружественный класс 356 единственный объект 359 интерфейс 356 итератор 354 контролирующий объект 353 Модель/Вид/Контроллер 364 сериализация 361 составной объект 353 управляющий объект 351 фабрика 357 фасад 357 полиморфизм 349 Очередь 65 FIFO 65 многопоточная 73 на основе связанного списка 70 с приоритетом 71 циклическая 67
Рекурсия 90 вычисление наибольшего общего делителя (НОД) 93 анализ сложности 94 вычисление факториала 91 анализ сложности 92 вычисление чисел Фибоначчи 95 анализ сложности 96 коственная 90 кривые Гильберта 97 анализ сложности 99 кривые Серпинского 102 анализ сложности 104 общая 114 устранение 114 опасности использования 105 бесконечная рекурсия 106 необоснованное применение 107 потери памяти 107 условие остановки 92 условия использования 108 хвостовая 109 устранение 110
П
Сборка мусора 41 процедура 43 Связанный список 45 двусвязный 53 метки 49 циклический 52 ячейки 45 Сеть 304 дуга 304 избыточность 342 каркас наименьший 311 дерева 310 кратчайший путь 316 двухточечный 326 коррекция меток 323 между всеми парами 327 применение 331
Поиск 257 в строковых данных 267 двоичный 261 интерполяционный 263 полный перебор 258 связанных списков 259 сортированных списков 259 следящий 268 двоичный 268 интерполяционный 269 Поток 55
Рекурсивные процедуры 23 коственная рекурсия 24 многократная рекурсия 23
расстановка меток 318 со штрафами за повороты 328 критический путь 332 нагруженная 335 источник 335 сток 335 направленная 304 обход 308 прямой 309 остовное дерево 310 минимальное 311 поток 335 представление 305 пропускная способность 335 разностная 335 путь 305 кратчайший 316 простой 305 цикл 305 разностная 336 расширяющий путь 337 ребро 304 связанная 305 связь 304 цикл 305 Сортировка 222 алгоритм 226 блочная сортировка 251 быстрая сортировка 234 перемешивание 227 пирамидальная сортировка 241 пузырьковая сортировка 231 сортировка вставкой 228 сортировка выбором 226 сортировка подсчетом 250 сортировка слиянием 239 ключи 223 объединение 223 сжатие 224 таблица указателей 222 Список 31 двусвязный 53 на основе массива 32 неупорядоченный 40
очередь 65 многопоточная 73 на основе связанного списка 70 циклическая 67 поток 55 с потоками 55 сборка мусора 41 процедура 43 циклический 52 Стек 61 LIFO 61, 65 выталкивание 61 на основе массива 61 на основе связанных списков 63 проталкивание 61
Теория сложности 19 , общие функции оценки 26 объемо-временная сложность 20 оценка по порядку величины 20 рекурсивные процедуры 23 коственная рекурсия 24 многократная рекурсия 23 Троичное дерево 127
Хеширование 272 блоки 277 связывание 283 удаление элементов 285 хранимые на диске 280 открытая адресация 287. квадратичная проверка 294 линейная проверка 287 псевдослучайная проверка 297 разрешение конфликтов 273 связывание 273
Эвристика 200 восхождение на холм 201 задача коммивояжера 219 задача о выполнимости 217
Delphi. Готовые алгоритмы задача о пожарных депо 220 задача о разбиении 218 метод Монте-Карло 205 метод отжига 213 минимальная стоимость 203 поиск Гамильтонова пути 218 последовательное приближение 206 сбалансированной прибыли 204 случайный поиск 205
Я Ячейки 45
Algorithm 18 greedy 312 Array 31, 77 dynamic 32 irregular 79 sparce 83 triangular 77
В Binary tree 127
С Cells 45
D Data abstraction 346
G Garbage collection 41 процедура 43 Graph 126 Greedy algorithm 312
H Hashing 272 buckets 277
elements deleting 285 linking 283 saved on disk 280 linking 273 open addressing 287 linear probing 287 pseudo-random probing 297 quadratic probing 294 Heuristic 200 balanced profit 204 firehouse problem 220. Hamiltonian path search 218 hill climbing 201 incremental improvement 206 least cost 203 Monte-Carlo search 205 partition problem 218 random search 205 satisfiable problem 217 simulated annealing 213 travelling salesman problem 219
Linked list 45 cells 45 circular 52 doubly linked 53 sentinel 49 List 31 circular 52 doubly linked 53 garbage collection 41 процедура 43 queue 65 circular 67 UFO 65 multi-headed 73 stack 61 LIFO 61 popping 61 pushing 61 thread 55 threaded 55 unordered 40
Предметный указатель
N Network 304 capacitated 335 sink 335 source 335 capacity 335 residual 335 connected 305 critical path 332 cycle 305 directed 304 edges 304 flow 335 link 304 navigation 308 path 305 cycle 305 shortest 316 simple 305 redunancy 342 representation 305 residual 336 augmenting path 337 shortest path 316 all pairs 327 applications 331 label correcting 323 label setting 318 point-to-point 326 with turn penalty 328 spanning tree 310 minimal 311 Object oriented programming. CM. OOP OOP 346 data abstraction 346 incapsulation 346 inheritance 350 paradigms 351 aggregate object 353 command object 351 facade 357
factory 357 friend class 356 interface 356 iterator 354 Model/View/Controller 364 serialization 361 singlrton object 359 visitor object 353 polymorphism 349 reuse 349 Queue 65 circular 67 LIFO 65 multi-headed 73 priority 71
•
Recursion 90 common 114 Jail 109 removal 110 Search 257 binary 261 exhaustive search 258 linked list 259 ordered lists 259 hunt 268 binary 268 interpolar 269 in string data 267 interpolation 263 Sentinel 49 Sorting 222 algorithm 226 bubble sort 231 Bucket sort 251 Counting sort 250 heap sort 241 insertion sort 228
:
a
merge sort 239 quicksort 234 selection sort 226 unsorting 227 keys 223 combine 223 compress 224 pointers table 222 Stack 61 LIFO 61 popping 61 pushing 61
Ternary tree 127 Thread 55 Tree 126 AVL 160 B+tree 179 N B-tree 174 bottom-up 178 top-down 178 balanced 159 AVL 160 B+tree 179
B-tree 174 bottom-up B-tree 178 top-down B-tree 178 binary 127 brunch 127 decision 188 branch-and-bound technique 195 heuristic 200 depth 127 N-ary 127 node 126 child 127 degree 127 depth 127 internal 127 leaf 127 parent 127 sibling 127 octtree 157 quadtree 151 root 126 subtree 126 symmetrically threaded tree 147 ternary 127
КНИГА-ПОЧТОЙ ЗАКАЗАТЬ КНИГИ ИЗДАТЕЛЬСКОГО ДОМА «ПИТЕР» МОЖНО ЛЮБЫМ УДОБНЫМ ДЛЯ ВАС СПОСОБОМ: • • • •
по телефону: (812) 103-73-74; по электронному адресу: [email protected]; на нашем сервере: www.piter.com; по почте: 197198, Санкт-Петербург, а/я 619 ЗАО «Питер Пост».
ВЫ МОЖЕТЕ ВЫБРАТЬ ОДИН ИЗ ДВУХ СПОСОБОВ ДОСТАВКИ И ОПЛАТЫ ИЗДАНИЙ: Наложенным платежом с оплатой заказа при получении посылки на ближайшем почтовом отделении. Цены на издания приведены ориентировочно и включают в себя стоимость пересылки по почте (но без учета авиатарифа). Книги будут высланы нашей службой «Книга-почтой» в течение двух недель после получения заказа или выхода книги из печати. > Оплата наличными при курьерской доставке (для жителей Москвы и Санкт-Петербурга). Курьер доставит заказ по указанному адресу в удобное для вас время в течение трех дней. ПРИ ОФОРМЛЕНИИ ЗАКАЗА УКАЖИТЕ: • фамилию, имя, отчество, телефон, факс, e-mail; • почтовый индекс, регион, район, населенный пункт, улицу, дом, корпус, квартиру; • название книги, автора, код, количество заказываемых экземпляров. Вы можете заказать бесплатный журнал «Клуб Профессионал».
НЗаАТЕПЬСКПП
ДОМ
WWW.PITER.COM
КЛУБ
П Р(0 УМ Jf С ЛИ О Н А Л
В1997 году по инициативе генерального директора Издательского дома «Питер» Валерия Степанова и при поддержке деловых кругов города в Санкт-Петербурге был основан «Книжный клуб Профессионал». Он собрал под флагом клуба профессионалов своего дела, которых объединяет постоянная тяга к знаниям и любовь к книгам. Членами клуба являются лучшие студенты и известные практики из разных сфер деятельности, которые хотят стать или уже стали профессионалами в той или иной области. Как и все развивающиеся проекты, с течением времени книжный клуб вырос в «Клуб Профессионал». Идею клуба сегодня формируют три основные «клубные» функции: • неформальное общение и совместный досуг интересных людей; • участие в подготовке специалистов высокого класса (семинары, пакеты книг по специальной литературе); • формирование и высказывание мнений современного профессионала (при встречах и на страницах журнала). КАК ВСТУПИТЬ В КЛУБ?
Для вступления в «Клуб Профессионал» вам необходимо: • ознакомиться с правилами вступления в «Клуб Профессионал» на страницах журнала или на сайте www.piter.com; • выразить свое желание вступить в «Клуб Профессионал» по электронной почте [email protected] или по тел. (812) 103-73-74; • заказать книги на сумму не менее 500 рублей в течение любого времени или приобрести комплект «Библиотека профессионала». «БИБЛИОТЕКА ПРОФЕССИОНАЛА»
Мы предлагаем вам получить все необходимые знания, подписавшись на «Библиотеку профессионала». Она для тех, кто экономит не только время, но и деньги. Покупая комплект - книжную полку «Библиотека профессионала», вы получаете: • • • •
скидку 15% от розничной цены издания, без учета почтовых расходов; при покупке двух или более комплектов - дополнительную скидку 3%; членство в «Клубе Профессионал»; подарок - журнал «Клуб Профессионал». пзалтЕльскпп аом @ Закажите бесплатный журнал &^ПИТЕР «Клуб Профессионал». ^^ W W W . P I T E R . C O M
пзалтЕпьскпп аом
Ь^ППТЕР® r^^^ W WW W. P DIIT TE PR D. C ГТ»М WW OM
УВАЖАЕМЫЕ ГОСПОДА! КНИГИ ИЗДАТЕЛЬСКОГО ДОМА «ПИТЕР» ВЫ МОЖЕТЕ ПРИОБРЕСТИ ОПТОМ И В РОЗНИЦУ У НАШИХ РЕГИОНАЛЬНЫХ ПАРТНЕРОВ.
Башкортостан Уфа, «Азия», ул. Зенцова, д. 70 (оптовая продажа), маг. «Оазис», ул. Чернышевского, д. 88, тел./факс (3472) 50-39-00. E-mail: [email protected]
Дальний Восток
Красноярск, «Книжный мир», тел./факс (3912) 27-39-71. E-mail: [email protected]
Владивосток, «Приморский торговый дом книги», тел./факс (4232) 23-82-12. E-mail: [email protected]
Нижневартовск, «Дом книги», тел. (3466) 23-27-14, факс 23-59-50. E-mail: [email protected]
Хабаровск, «Мире», тел. (4212) 30-54-47, факс 22-73-30. E-mail: [email protected]
Новосибирск, «Топ-книга», тел. (3832) 36-10-26, факс 36-10-27. E-mail: [email protected] http://www.top-kniga.ru
Хабаровск, «Книжный мир», тел. (4212) 32-85-51, факс 32-82-50. E-mail: [email protected]
Европейские регионы России
Тюмень, «Друг», тел./факс (3452) 21-34-82. E-mail: [email protected]
Архангельск, «Дом книги», тел. (8182) 65-41 -34, факс 65-41 -34. E-mail: [email protected]
Тюмень, «<&олиант», тел. (3452) 27-36-06, факс 27-36-11. E-mail: [email protected]
Калининград, «Вестер», тел./факс (0112) 21-56-28.21-62-07. E-mail: [email protected] http://www.vester.ru
Челябинск, ТД «Эврика», ул. Барбюса, д. 61, тел./факс (3512) 52-49-23. E-mail:[email protected]
Татарстан Северный Кавказ Ессентуки, «Россы», ул. Октябрьская, 424, тел./факс (87934) 6-93-09. E-mail: [email protected]
Казань, «Таис», тел. (8432) 72-34-55, факс 72-27-82. E-mail: [email protected]
Урал Сибирь Иркутск, «ПродаЛитЪ», тел. (3952) 59-13-70, факс 51 -30-70.
E-mail: [email protected] http://www.prodalit.irk.ru
Иркутск, «Антей-книга», тел./факс (3952) 33-42-47. E-mail: [email protected]
Екатеринбург, магазин 14, ул. Челюскинцев, д. 23, тел./факс (3432) 53-24-90. E-mail: [email protected] Екатеринбург, «Валео-книга», ул. Ключевская, д. 5, тел./факс (3432) 42-56-00. E-mail: [email protected]
С^ППТЕР*
Нет времени ходить по магазинам?
www.piter.com Здесь вы найдете: Все книги издательства сразу Новые книги — в момент выхода из типографии Информацию о книге — отзывы, рецензии, отрывки Старые книги — в библиотеке и на CD И, наконец, вы нигде не купите наши книги дешевле!
пзалтЕльскпй а ом r^^nWri-D® М"Р'ЛЫм
СПЕЦИАЛИСТАМ КНИЖНОГО БИЗНЕС
ПРЕДСТАВИТЕЛЬСТВА ИЗДАТЕЛЬСКОГО ДОМА «ПИТЕР» предлагают эксклюзивный ассортимент компьютерной, медицинской, психологической, экономической и популярной литературы РОССИЯ Москва м. «Калужская», ул. Бутлерова, д. 176, офис 207, 240; тел./факс (095) 777-54-67; e-mail: [email protected] Санкт-Петербург м. «Выборгская», Б. Сампсониевский пр., д. 29а; тел. (812) 103-73-73, факс (812) 103-73-83; e-mail: [email protected] Воронеж ул. 25 января, д. 4; тел. (0732) 27-18-86; e-mail: [email protected]; [email protected] Екатеринбург
ул. 8 Марта, д. 2676; тел./факс (3432) 25-39-94; e-mail: [email protected]
Нижний Новгород ул. Премудрова, д. 31а; тел. (8312) 58-50-15, 58-50-25; e-mail: [email protected] Новосибирск ул. Немировича-Данченко, д. 104, офис 502; тел/факс (3832) 54-13-09, (3832) 47-92-93; e-mail: [email protected] Ростов-на-Дону ул. Калитвинская, д. 17в; тел. (8632) 95-36-31, (8632) 95-36-32; e-mail: [email protected] Самара ул. Новосадовая, д. 4; тел. (8462)37-06-07; e-mail: [email protected] УКРАИНА Харьков ул. Суздальские ряды, д. 12, офис 10-11, т. (057) 712-27-05; e-mail: [email protected] Киев пр. Красных Казаков, д. 6, корп. 1; тел./факс (044) 490-35-68,490-35-69; e-mail: [email protected] БЕЛАРУСЬ Минск ул. Бобруйская д., 21', офис 3; тел./факс (37517) 226-19-53; e-mail: [email protected] МОЛДОВА Кишинев «Ауратип-Питер»; ул. Митрополит Варлаам, 65, офис 345; тел. (3732) 22-69-52, факс (3732) 27-24-82; e-mail: [email protected] Ищем зарубежных партнеров или посредников, имеющих выход на зарубежный рынок. Телефон для связи: (812) 103-73-73. E-mail: [email protected] Издательский дом «Питер» приглашает к сотрудничеству авторов. Обращайтесь по телефонам: Санкт-Петербург— (812) 327-13-11, Москва - (095) 777-54-67. Заказ книг для вузов и библиотек: (812) 103-73-73. Специальное предложение - e-mail: [email protected]
Род Стивене
"Cv . •;•-. • ; :;'.; /
Delphi Готовые алгоритмы
Главный редактор Захаров И. М. [email protected] Перевод с английского Мерещук Д. А. Научный редактор Нилов М. В. Выпускающий редактор Морозова Я. В. Верстка ДудатийА.М. Графика Салкмонов Р. В.
Подписано в печать 20.03.2004. Формат 70х100у,6. Гарнитура «Петербург». Печать офсетнай. Усл. печ. я. 24. Зак. № 42 Издательство «ДМК Пресс», 105023, Москва, пл. Журавлева, д. 2/8. Web-сайт издательства: www.dmk.ru. Internet-магазин: www.dmk.ru, www.abook.ru.
U GIP ПI Готовые алгоритмы В книге изложены важные концепции программирования, которые могут быть с успехом применены для решения многих практических задач. Подробно описываются важнейшие элементы алгоритмов хранения и обработки данных (списки, стеки, очереди, деревья, сортировка, поиск, хеширование и т. д.). Рассматриваются типичные и наихудшие случаи реализации алгоритмов, что позволит вам вовремя распознать возможные трудности и при необходимости переписать или заменить часть программы. Приводятся не только традиционные решения, но и методы, основанные на последних достижениях объектно-ориентированного программирования. Книга содержит большое количество примеров, которые вы можете использовать в собственных приложениях в исходном виде или изменить по своему усмотрению. Издание предназначено для начинающих программистов на Delphi, но благодаря четкой структуризации материала и богатой библиотеке готовых алгоритмов будет также интересно и специалистам.
WILEY ISBN 5-94074-202-5
т | ,i, ;_<£издательство
WWW.PITER.COM
Уровень пользователя: начинающий/опытный Категория: Программирование
9"785940"742029"
Посетите наш web-магазин: www.piter.comч