Delphi Готовые алгоритмы Род Стивене издательство
Род Стивене
Delphi Готовые алгоритмы
'
Ready-to-run Delphi® Algo...
12 downloads
374 Views
46MB Size
Report
This content was uploaded by our users and we assume good faith they have the permission to share this book. If you own the copyright to this book and it is wrongfully on our website, we offer a simple DMCA procedure to remove your content from our site. Start by pressing the button below!
Report copyright / DMCA form
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 (N0) 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]) 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 А / 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, функция рекурсивно вычисляет 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. Кривая Серпинского, образованная из меньших кривых С помощью стрелок типа —» и 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,.™^^^
)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; xmid) then begin // Восточный дочерний узел может быть достаточно близок. // Достаточно ли близок северо-восточный дочерний узел? if (Y-best_distymid) 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
:
' . ...
.
В