курсовые,контрольные,дипломы,рефераты
Введение.................................................................................................. 8
Целевая аудитория.............................................................................. 10
Глава 1. Основные понятия.................................................................. 15
Что такое алгоритмы?........................................................................ 15
Анализ скорости выполнения алгоритмов...................................... 16
Пространство — время..................................................................... 17
Оценка с точностью до порядка....................................................... 17
Поиск сложных частей алгоритма................................................... 19
Сложность рекурсивных алгоритмов............................................... 20
Многократная рекурсия.................................................................... 21
Косвенная рекурсия.......................................................................... 22
Требования рекурсивных алгоритмов к объему памяти................. 22
Наихудший и усредненный случай.................................................. 23
Часто встречающиеся функции оценки порядка сложности....... 24
Логарифмы........................................................................................ 25
Реальные условия — насколько быстро?........................................ 25
Обращение к файлу подкачки.......................................................... 26
Псевдоуказатели, ссылки на объекты и коллекции......................... 27
Резюме................................................................................................... 29
Глава 2. Списки..................................................................................... 30
Знакомство со списками.................................................................... 31
Простые списки................................................................................... 31
Коллекции.......................................................................................... 32
Список переменного размера........................................................... 33
Класс SimpleList................................................................................ 36
Неупорядоченные списки.................................................................. 37
Связные списки................................................................................... 41
Добавление элементов к связному списку....................................... 43
Удаление элементов из связного списка.......................................... 44
Уничтожение связного списка.......................................................... 44
Сигнальные метки............................................................................. 45
Инкапсуляция связных списков........................................................ 46
Доступ к ячейкам.............................................................................. 47
Разновидности связных списков...................................................... 49
Циклические связные списки............................................................ 49
Проблема циклических ссылок........................................................ 50
Двусвязные списки............................................................................ 50
Потоки............................................................................................... 53
Другие связные структуры................................................................ 56
Псевдоуказатели................................................................................. 56
Резюме................................................................................................... 59
Глава 3. Стеки и очереди...................................................................... 60
Стеки..................................................................................................... 60
Множественные стеки....................................................................... 62
Очереди................................................................................................. 63
Циклические очереди........................................................................ 65
Очереди на основе связных списков................................................ 69
Применение коллекций в качестве очередей................................... 70
Приоритетные очереди..................................................................... 70
Многопоточные очереди.................................................................. 72
Резюме................................................................................................... 74
Глава 4. Массивы.................................................................................. 75
Треугольные массивы........................................................................ 75
Диагональные элементы................................................................... 77
Нерегулярные массивы...................................................................... 78
Прямая звезда.................................................................................... 78
Нерегулярные связные списки.......................................................... 79
Разреженные массивы........................................................................ 80
Индексирование массива.................................................................. 82
Очень разреженные массивы............................................................ 85
Резюме................................................................................................... 86
Глава 5. Рекурсия.................................................................................. 86
Что такое рекурсия?........................................................................... 87
Рекурсивное вычисление факториалов........................................... 88
Анализ времени выполнения программы........................................ 89
Рекурсивное вычисление наибольшего общего делителя............. 90
Анализ времени выполнения программы........................................ 91
Рекурсивное вычисление чисел Фибоначчи................................... 92
Анализ времени выполнения программы........................................ 93
Рекурсивное построение кривых Гильберта................................... 94
Анализ времени выполнения программы........................................ 96
Рекурсивное построение кривых Серпинского.............................. 98
Анализ времени выполнения программы...................................... 100
Опасности рекурсии......................................................................... 101
Бесконечная рекурсия..................................................................... 101
Потери памяти................................................................................. 102
Необоснованное применение рекурсии......................................... 103
Когда нужно использовать рекурсию............................................ 104
Хвостовая рекурсия.......................................................................... 105
Нерекурсивное вычисление чисел Фибоначчи............................. 107
Устранение рекурсии в общем случае............................................ 110
Нерекурсивное построение кривых Гильберта............................ 114
Нерекурсивное построение кривых Серпинского....................... 117
Резюме................................................................................................. 121
Глава 6. Деревья.................................................................................. 121
Определения...................................................................................... 122
Представления деревьев................................................................... 123
Полные узлы.................................................................................... 123
Списки потомков............................................................................. 124
Представление нумерацией связей................................................ 126
Полные деревья............................................................................... 129
Обход дерева...................................................................................... 130
Упорядоченные деревья................................................................... 135
Добавление элементов.................................................................... 135
Удаление элементов........................................................................ 136
Обход упорядоченных деревьев.................................................... 139
Деревья со ссылками........................................................................ 141
Работа с деревьями со ссылками.................................................... 144
Квадродеревья................................................................................... 145
Изменение MAX_PER_NODE......................................................... 151
Использование псевдоуказателей в квадродеревьях..................... 151
Восьмеричные деревья................................................................... 152
Резюме................................................................................................. 152
Глава 7. Сбалансированные деревья.................................................. 153
Сбалансированность дерева............................................................ 153
АВЛ‑деревья....................................................................................... 154
Удаление узла из АВЛ‑дерева........................................................ 161
Б‑деревья............................................................................................ 166
Производительность Б‑деревьев.................................................... 167
Вставка элементов в Б‑дерево........................................................ 167
Удаление элементов из Б‑дерева.................................................... 168
Разновидности Б‑деревьев.............................................................. 169
Улучшение производительности Б‑деревьев................................. 171
Балансировка для устранения разбиения блоков.......................... 171
Вопросы, связанные с обращением к диску.................................. 173
База данных на основе Б+дерева.................................................... 176
Резюме................................................................................................. 179
Глава 8. Деревья решений.................................................................. 179
Поиск в деревьях игры..................................................................... 180
Минимаксный поиск........................................................................ 181
Улучшение поиска в дереве игры.................................................. 185
Поиск в других деревьях решений................................................. 187
Метод ветвей и границ.................................................................... 187
Эвристики........................................................................................ 191
Другие сложные задачи.................................................................... 207
Задача о выполнимости.................................................................. 207
Задача о разбиении......................................................................... 208
Задача поиска Гамильтонова пути................................................. 209
Задача коммивояжера..................................................................... 210
Задача о пожарных депо................................................................. 211
Краткая характеристика сложных задач........................................ 212
Резюме................................................................................................. 212
Глава 9. Сортировка........................................................................... 213
Общие соображения......................................................................... 213
Таблицы указателей........................................................................ 213
Объединение и сжатие ключей....................................................... 215
Примеры программ........................................................................... 217
Сортировка выбором........................................................................ 219
Рандомизация.................................................................................... 220
Сортировка вставкой....................................................................... 221
Вставка в связных списках.............................................................. 222
Пузырьковая сортировка................................................................. 224
Быстрая сортировка......................................................................... 227
Сортировка слиянием....................................................................... 232
Пирамидальная сортировка............................................................ 234
Пирамиды........................................................................................ 235
Приоритетные очереди................................................................... 237
Алгоритм пирамидальной сортировки.......................................... 240
Сортировка подсчетом..................................................................... 241
Блочная сортировка.......................................................................... 242
Блочная сортировка с применением связного списка................... 243
Блочная сортировка на основе массива......................................... 245
Резюме................................................................................................. 248
Глава 10. Поиск................................................................................... 248
Примеры программ........................................................................... 249
Поиск методом полного перебора................................................... 249
Поиск в упорядоченных списках.................................................... 250
Поиск в связных списках................................................................ 251
Двоичный поиск................................................................................ 253
Интерполяционный поиск............................................................... 255
Строковые данные............................................................................ 259
Следящий поиск................................................................................ 260
Интерполяционный следящий поиск............................................. 261
Резюме................................................................................................. 262
Глава 11. Хеширование...................................................................... 263
Связывание........................................................................................ 265
Преимущества и недостатки связывания....................................... 266
Блоки.................................................................................................. 268
Хранение хеш‑таблиц на диске...................................................... 270
Связывание блоков.......................................................................... 274
Удаление элементов........................................................................ 275
Преимущества и недостатки применения блоков......................... 277
Открытая адресация......................................................................... 277
Линейная проверка.......................................................................... 278
Квадратичная проверка................................................................... 284
Псевдослучайная проверка............................................................. 286
Удаление элементов........................................................................ 289
Резюме................................................................................................. 291
Глава 12. Сетевые алгоритмы............................................................ 292
Определения...................................................................................... 292
Представления сети.......................................................................... 293
Оперирование узлами и связями.................................................... 295
Обходы сети....................................................................................... 296
Наименьшие остовные деревья...................................................... 298
Кратчайший маршрут...................................................................... 302
Установка меток.............................................................................. 304
Коррекция меток............................................................................. 308
Другие задачи поиска кратчайшего маршрута.............................. 311
Применения метода поиска кратчайшего маршрута.................... 316
Максимальный поток...................................................................... 319
Приложения максимального потока.............................................. 325
Резюме................................................................................................. 327
Глава 13. Объектно‑ориентированные методы................................. 327
Преимущества ООП......................................................................... 328
Инкапсуляция.................................................................................. 328
Полиморфизм.................................................................................. 330
Наследование и повторное использование.................................... 333
Парадигмы ООП............................................................................... 335
Управляющие объекты................................................................... 335
Контролирующий объект............................................................... 336
Итератор.......................................................................................... 337
Дружественный класс..................................................................... 338
Интерфейс....................................................................................... 340
Фасад............................................................................................... 340
Порождающий объект..................................................................... 340
Единственный объект...................................................................... 341
Преобразование в последовательную форму................................ 341
Парадигма Модель/Вид/Контроллер............................................. 344
Резюме................................................................................................. 346
Требования к аппаратному обеспечению...................................... 346
Выполнение программ примеров.................................................... 346
programmer@newmail.ru
Далее следует «текст», который любой уважающий себя программист должен прочесть хотя бы один раз. (Это наше субъективное мнение)
ВведениеПрограммирование под Windows всегда было нелегкой задачей. Интерфейс прикладного программирования (Application Programming Interface) Windows предоставляет в распоряжение программиста набор мощных, но не всегда безопасных инструментов для разработки приложений. Можно сравнить его с бульдозером, при помощи которого удается добиться поразительных результатов, но без соответствующих навыков и осторожности, скорее всего, дело закончится только разрушениями и убытками.
Эта картина изменилась с появлением Visual Basic. Используя визуальный интерфейс, Visual Basic позволяет быстро и легко разрабатывать законченные приложения. При помощи Visual Basic можно разрабатывать и тестировать сложные приложения без прямого использования функций API. Избавляя программиста от проблем с API, Visual Basic позволяет сконцентрироваться на деталях приложения.
Хотя Visual Basic и облегчает разработку пользовательского интерфейса, задача написания кода для реакции на входные воздействия, обработки их, и представления результатов ложится на плечи программиста. Здесь начинается применение алгоритмов.
Алгоритмы представляют собой формальные инструкции для выполнения сложных задач на компьютере. Например, алгоритм сортировки может определять, как найти конкретную запись в базе из 10 миллионов записей. В зависимости от класса используемых алгоритмов искомые данные могут быть найдены за секунды, часы или вообще не найдены.
В этом материале обсуждаются алгоритмы на Visual Basic и содержится большое число мощных алгоритмов, полностью написанных на этом языке. В ней также анализируются методы обращения со структурами данных, такими, как списки, стеки, очереди и деревья, и алгоритмы для выполнения типичных задач, таких как сортировка, поиск и хэширование.
Для того чтобы успешно применять эти алгоритмы, недостаточно их просто скопировать в свою программу. Необходимо кроме этого понимать, как различные алгоритмы ведут себя в разных ситуациях, что в конечном итоге и будет определять выбор наиболее подходящего алгоритма.
В этом материале поведение алгоритмов в типичном и наихудшем случаях описано доступным языком. Это позволит понять, чего вы вправе ожидать от того или иного алгоритма и распознать, в каких условиях встречается наихудший случай, и в соответствии с этим переписать или поменять алгоритм. Даже самый лучший алгоритм не поможет в решении задачи, если применять его неправильно.
=============xi
Все алгоритмы также представлены в виде исходных текстов на Visual Basic, которые вы можете использовать в своих программах без каких‑либо изменений. Они демонстрируют использование алгоритмов в программах, а также важные характерные особенности работы самих алгоритмов.
Что дают вам эти знания
После ознакомления с данным материалом и примерами вы получите:
1. Понятие об алгоритмах. После прочтения данного материала и выполнения примеров программ, вы сможете применять сложные алгоритмы в своих проектах на Visual Basic и критически оценивать другие алгоритмы, написанные вами или кем‑либо еще.
2. Большую подборку исходных текстов, которые вы сможете легко добавить к вашим программам. Используя код, содержащийся в примерах, вы сможете легко добавлять мощные алгоритмы к вашим приложениям.
3. Готовые примеры программ дадут вам возможность протестировать алгоритмы. Вы можете использовать эти примеры и модифицировать их для углубленного изучения алгоритмов и понимания их работы, или использовать их как основу для разработки собственных приложений.
В этом материале обсуждаются углубленные вопросы программирования на Visual Basic. Они не предназначена для обучения программированию на этом языке. Если вы хорошо разбираетесь в основах программирования на Visual Basic, вы сможете сконцентрировать внимание на алгоритмах вместо того, чтобы застревать на деталях языка.
В этом материале изложены важные концепции программирования, которые могут быть с успехом применены для решения новых задач. Приведенные алгоритмы используют мощные программные методы, такие как рекурсия, разбиение на части, динамическое распределение памяти и сетевые структуры данных, которые вы можете применять для решения своих конкретных задач.
Даже если вы еще не овладели в полной мере программированием на Visual Basic, вы сможете скомпилировать примеры программ и сравнить производительность различных алгоритмов. Более того, вы сможете выбрать удовлетворяющие вашим требованиям алгоритмы и добавить их к вашим проектам на Visual Basic.
Совместимость с разными версиями Visual Basic
Выбор наилучшего алгоритма определяется не особенностями версии языка программирования, а фундаментальными принципами программирования.
=================xii
Некоторые новые понятия, такие как ссылки на объекты, классы и коллекции, которые были впервые введены в 4-й версии Visual Basic, облегчают понимание, разработку и отладку некоторых алгоритмов. Классы могут заключать некоторые алгоритмы в хорошо продуманных модулях, которые легко вставить в программу. Хотя для того, чтобы применять эти алгоритмы, необязательно разбираться в новых понятиях языка, эти новые возможности предоставляют слишком большие преимущества, чтобы ими можно было пренебречь.
Поэтому примеры алгоритмов в этом материале написаны для использования в 4-й и 5-й версиях Visual. Если вы откроете их в 5-й версии Visual Basic, среда разработки предложит вам сохранить их в формате 5-й версии, но никаких изменений в код вносить не придется. Все алгоритмы были протестированы в обеих версиях.
Эти программы демонстрируют использование алгоритмов без применения объектно-ориентированного подхода. Ссылки и коллекции облегчают программирование, но их применение может приводить к некоторому замедлению работы программ по сравнению со старыми версиями.
Тем не менее, игнорирование классов, объектов и коллекций привело бы к упущению многих действительно мощных свойств. Их использование позволяет достичь нового уровня модульности, разработки и повторного использования кода. Их, безусловно, необходимо иметь в виду, по крайней мере, на начальных этапах разработки. В дальнейшем, если возникнут проблемы с производительностью, вы сможете модифицировать код, используя более быстрые низкоуровневые методы.
Языки программирования зачастую развиваются в сторону усложнения, но редко в противоположном направлении. Замечательным примером этого является наличие оператора goto в языке C. Это неудобный оператор, потенциальный источник ошибок, который почти не используется большинством программистов на C, но он по‑прежнему остается в синтаксисе языка с 1970 года. Он даже был включен в C++ и позднее в Java, хотя создание нового языка было хорошим предлогом избавиться от него.
Так и новые версии Visual Basic будут продолжать вводить новые свойства в язык, но маловероятно, что из них будут исключены строительные блоки, использованные при применении алгоритмов, описанных в данном материале. Независимо от того, что будет добавлено в 6-й, 7-й или 8-й версии Visual Basic, классы, массивы и определяемые пользователем типы данных останутся в языке. Большая часть, а может и все алгоритмы из приведенных ниже, будут выполняться без изменений в течение еще многих лет.
Обзор глав
В 1 главе рассматриваются понятия, которые вы должны понимать до того, как приступить к анализу сложных алгоритмов. В ней изложены методы, которые потребуются для теоретического анализа вычислительной сложности алгоритмов. Некоторые алгоритмы с высокой теоретической производительностью на практике дают не очень хорошие результаты, поэтому в этой главе также затрагиваются практические соображения, например обращение к файлу подкачки и сравнивается использование коллекций и массивов.
Во 2 главе показано, как образуются различные виды списков с использованием массивов, объектов, и псевдоуказателей. Эти структуры данных можно с успехом применять во многих программах, и они используются в следующих главах
В 3 главе описаны два особых типа списков: стеки и очереди. Эти структуры данных используются во многих алгоритмах, включая некоторые алгоритмы, описанные в последующих главах. В конце главы приведена модель очереди на регистрацию в аэропорту.
В 5 главе обсуждается мощный инструмент — рекурсия. Рекурсия может быть также запутанной и приводить к проблемам. В 5 главе объясняется, в каких случаях следует применять рекурсию и показывает, как можно от нее избавиться, если это необходимо.
В 6 главе используются многие из ранее описанных приемов, такие как рекурсия и связные списки, для изучения более сложной темы — деревьев. Эта глава также охватывает различные представления деревьев, такие как деревья с полными узлами (fat node) и представление в виде нумерацией связей (forward star). В ней также описаны некоторые важные алгоритмы работы с деревьями, таки как обход вершин дерева.
В 7 главе затронута более сложная тема. Сбалансированные деревья обладают особыми свойствами, которые позволяют им оставаться уравновешенными и эффективными. Алгоритмы сбалансированных деревьев удивительно просто описываются, но их достаточно трудно реализовать программно. В этой главе используется одна из наиболее мощных структур подобного типа — Б+дерево (B+Tree) для создания сложной базы данных.
В 8 главе обсуждаются задачи, которые можно описать как поиск ответов в дереве решений. Даже для небольших задач, эти деревья могут быть гигантскими, поэтому необходимо осуществлять поиск в них максимально эффективно. В этой главе сравниваются некоторые различные методы, которые позволяют выполнить такой поиск.
Глава 9 посвящена, пожалуй, наиболее изучаемой области теории алгоритмов — сортировке. Алгоритмы сортировки интересны по нескольким причинам. Во‑первых, сортировка — часто встречающаяся задача. Во‑вторых, различные алгоритмы сортировок обладают своими сильными и слабыми сторонами, поэтому не существует одного алгоритма, который показывал бы наилучшие результаты в любых ситуациях. И, наконец, алгоритмы сортировки демонстрируют широкий спектр важных алгоритмических методов, таких как рекурсия, пирамиды, а также использование генератора случайных чисел для уменьшения вероятности выпадения наихудшего случая.
В главе 10 рассматривается близкая к сортировке тема. После выполнения сортировки списка, программе может понадобиться найти элементы в нем. В этой главе сравнивается несколько наиболее эффективных методов поиска элементов в сортированных списках.
=========xiv
В главе 11 обсуждаются методы сохранения и поиска элементов, работающие даже быстрее, чем это возможно при использовании деревьев, сортировки или поиска. В этой главе также описаны некоторые методы хэширования, включая использование блоков и связных списков, и несколько вариантов открытой адресации.
В главе 12 описана другая категория алгоритмов — сетевые алгоритмы. Некоторые из этих алгоритмов, такие как вычисление кратчайшего пути, непосредственно применимы к физическим сетям. Эти алгоритмы также могут косвенно применяться для решения других задач, которые на первый взгляд не кажутся связанными с сетями. Например, алгоритмы поиска кратчайшего расстояния могут разбивать сеть на районы или определять критичные задачи в расписании проекта.
В главе 13 объясняются методы, применение которых стало возможным благодаря введению классов в 4‑й версии Visual Basic. Эти методы используют объектно‑ориентированный подход для реализации нетипичного для традиционных алгоритмов поведения.
===================xv
Аппаратные требования
Для работы с примерами вам потребуется компьютер, конфигурация которого удовлетворяет требованиям для работы программной среды Visual Basic. Эти требования выполняются почти для всех компьютеров, на которых может работать операционная система Windows.
На компьютерах разной конфигурации алгоритмы выполняются с различной скоростью. Компьютер с процессором Pentium Pro с тактовой частотой 2000 МГц и 64 Мбайт оперативной памяти будет работать намного быстрее, чем машина с 386 процессором и всего 4 Мбайт памяти. Вы быстро узнаете, на что способно ваше аппаратное обеспечение.
Изменения во втором издании
Самое большое изменение в новой версии Visual Basic — это появление классов. Классы позволяют рассмотреть некоторые задачи с другой стороны, позволяя использовать более простой и естественный подход к пониманию и применению многих алгоритмов. Изменения в коде программ в этом изложении используют преимущества, предоставляемые классами. Их можно разбить на три категории:
1. Замена псевдоуказателей классами. Хотя все алгоритмы, которые были написаны для старых версий VB, все еще работают, многие из тех, что были написаны с применением псевдоуказателей (описанных во 2 главе), гораздо проще понять, используя классы.
2. Инкапсуляция. Классы позволяют заключить алгоритм в компактный модуль, который легко использовать в программе. Например, при помощи классов можно создать несколько связных списков и не писать при этом дополнительный код для управления каждым списком по отдельности.
3. Объектно‑ориентированные технологии. Использование классов также позволяет легче понять некоторые объектно‑ориентированные алгоритмы. В главе 13 описываются методы, которые сложно реализовать без использования классов.
Как пользоваться этим материалом
В главе 1 даются общие понятия, которые используются на протяжении всего изложения, поэтому вам следует начать чтение с этой главы. Вам стоит ознакомиться с этой тематикой, даже если вы не хотите сразу же достичь глубокого понимания алгоритмов.
В 6 главе обсуждаются понятия, которые используются в 7, 8 и 12 главах, поэтому вам следует прочитать 6 главу до того, как браться за них. Остальные главы можно читать в любом порядке.
=============xvi
В табл. 1 показаны три возможных учебных плана, которыми вы можете руководствоваться при изучении материала в зависимости от того, насколько широко вы хотите ознакомиться с алгоритмами. Первый план включает в себя освоение основных методов и структур данных, которые могут быть полезны при разработке вами собственных программ. Второй кроме этого описывает также основные алгоритмы, такие как алгоритмы сортировки и поиска, которые могут понадобиться при написании более сложных программ.
Последний план дает порядок для изучения всего материала целиком. Хотя 7 и 8 главы логически вытекают из 6 главы, они сложнее для изучения, чем следующие главы, поэтому они изучаются несколько позже.
Почему именно Visual Basic?
Наиболее часто встречаются жалобы на медленное выполнение программ, написанных на Visual Basic. Многие другие компиляторы, такие как Delphi, Visual C++ дают более быстрый и гибкий код, и предоставляют программисту более мощные средства, чем Visual Basic. Поэтому логично задать вопрос — «Почему я должен использовать именно Visual Basic для написания сложных алгоритмов? Не лучше было бы использовать Delphi или C++ или, по крайней мере, написать алгоритмы на одном из этих языков и подключать их к программам на Visual Basic при помощи библиотек?» Написание алгоритмов на Visual Basic имеет смысл по нескольким причинам.
Во‑первых, разработка приложения на Visual C++ гораздо сложнее и проблематичнее, чем на Visual Basic. Некорректная реализация в программе всех деталей программирования под Windows может привести к сбоям в вашем приложении, среде разработки, или в самой операционной системе Windows.
Во‑вторых, разработка библиотеки на языке C++ для использования в программах на Visual Basic включает в себя много потенциальных опасностей, характерных и для приложений Windows, написанных на C++. Если библиотека будет неправильно взаимодействовать с программой на Visual Basic, она также приведет к сбоям в программе, а возможно и в среде разработки и системе.
В-третьих, многие алгоритмы достаточно эффективны и показывают неплохую производительность даже при применении не очень быстрых компиляторов, таких, как Visual Basic. Например, алгоритм сортировки подсчетом,
@Таблица 1. Планы занятий
===============xvii
описываемый в 9 главе, сортирует миллион целых чисел менее чем за 2 секунды на компьютере с процессором Pentium с тактовой частотой 233 МГц. Используя библиотеку C++, можно было бы сделать алгоритм немного быстрее, но скорости версии на Visual Basic и так хватает для большинства приложений. Скомпилированные при помощи 5‑й версией Visual Basic исполняемые файлы сводят отставание по скорости к минимуму.
В конечном счете, разработка алгоритмов на любом языке программирования позволяет больше узнать об алгоритмах вообще. По мере изучения алгоритмов, вы освоите методы, которые сможете применять в других частях своих программ. После того, как вы овладеете в совершенстве алгоритмами на Visual Basic, вам будет гораздо легче реализовать их на Delphi или C++, если это будет необходимо.
=============xviii
Глава 1. Основные понятияВ этой главе содержатся общие понятия, которые нужно усвоить перед началом серьезного изучения алгоритмов. Начинается она с вопроса «Что такое алгоритмы?». Прежде чем углубиться в детали программирования алгоритмов, стоит потратить немного времени, чтобы разобраться в том, что это такое.
Затем в этой главе дается введение в формальную теорию сложности алгоритмов (complexity theory). При помощи этой теории можно оценить теоретическую вычислительную сложность алгоритмов. Этот подход позволяет сравнивать различные алгоритмы и предсказывать их производительность в разных условиях. В главе приводится несколько примеров применения теории сложности к небольшим задачам.
Некоторые алгоритмы с высокой теоретической производительностью не слишком хорошо работают на практике, поэтому в данной главе также обсуждаются некоторые реальные предпосылки для создания программ. Слишком частое обращение к файлу подкачки или плохое использование ссылок на объекты и коллекции может значительно снизить производительность прекрасного в остальных отношениях приложения.
После знакомства с основными понятиями, вы сможете применять их к алгоритмам, изложенным в последующих главах, а также для анализа собственных программ для оценки их производительности и сможете предугадывать возможные проблемы до того, как они обернутся катастрофой.
Алгоритм – это последовательность инструкций для выполнения какого‑либо задания. Когда вы даете кому‑то инструкции о том, как отремонтировать газонокосилку, испечь торт, вы тем самым задаете алгоритм действий. Конечно, подобные бытовые алгоритмы описываются неформально, например, так:
Проверьте, находится ли машина на стоянке.
Убедитесь, что машина поставлена на ручной тормоз.
Поверните ключ.
И т.д.
==========1
При этом по умолчанию предполагается, что человек, который будет следовать этим инструкциям, сможет самостоятельно выполнить множество мелких операций, например, отпереть и открыть дверь, сесть за руль, пристегнуть ремень, найти ручной тормоз и так далее.
Если же составляется алгоритм для исполнения компьютером, вы не можете полагаться на то, что компьютер поймет что‑либо, если это не описано заранее. Словарь компьютера (язык программирования) очень ограничен и все инструкции для компьютера должны быть сформулированы на этом языке. Поэтому для написания компьютерных алгоритмов используется формализованный стиль.
Интересно попробовать написать формализованный алгоритм для обычных ежедневных задач. Например, алгоритм вождения машины мог бы выглядеть примерно так:
Если дверь закрыта:
Вставить ключ в замок
Повернуть ключ
Если дверь остается закрытой, то:
Повернуть ключ в другую сторону
Повернуть ручку двери
И т.д.
Этот фрагмент «кода» отвечает только за открывание двери; при этом даже не проверяется, какая дверь открывается. Если дверь заело или в машине установлена противоугонная система, то алгоритм открывания двери может быть достаточно сложным.
Формализацией алгоритмов занимаются уже тысячи лет. За 300 лет до н.э. Евклид написал алгоритмы деления углов пополам, проверки равенства треугольников и решения других геометрических задач. Он начал с небольшого словаря аксиом, таких как «параллельные линии не пересекаются» и построил на их основе алгоритмы для решения сложных задач.
Формализованные алгоритмы такого типа хорошо подходят для задач математики, где должна быть доказана истинность какого‑либо положения или возможность выполнения каких‑то действий, скорость же исполнения алгоритма не важна. Для выполнения реальных задач, связанных с выполнением инструкций, например задачи сортировки на компьютере записей о миллионе покупателей, эффективность выполнения становится важной частью постановки задачи.
Есть несколько способов оценки сложности алгоритмов. Программисты обычно сосредотачивают внимание на скорости алгоритма, но важны и другие требования, например, к размеру памяти, свободному месту на диске или другим ресурсам. От быстрого алгоритма может быть мало толку, если под него требуется больше памяти, чем установлено на компьютере.
Многие алгоритмы предоставляют выбор между скоростью выполнения и используемыми программой ресурсами. Задача может выполняться
быстрее, используя больше памяти, или наоборот, медленнее, заняв меньший объем памяти.
===========2
Хорошим примером в данном случае может служить алгоритм нахождения кратчайшего пути. Задав карту улиц города в виде сети, можно написать алгоритм, вычисляющий кратчайшее расстояние между любыми двумя точками в этой сети. Вместо того чтобы каждый раз заново пересчитывать кратчайшее расстояние между двумя заданными точками, можно заранее просчитать его для всех пар точек и сохранить результаты в таблице. Тогда, чтобы найти кратчайшее расстояние для двух заданных точек, достаточно будет просто взять готовое значение из таблицы.
При этом мы получим результат практически мгновенно, но это потребует большого объема памяти. Карта улиц для большого города, такого как Бостон или Денвер, может содержать сотни тысяч точек. Для такой сети таблица кратчайших расстояний содержала бы более 10 миллиардов записей. В этом случае выбор между временем исполнения и объемом требуемой памяти очевиден: поставив дополнительные 10 гигабайт оперативной памяти, можно заставить программу выполняться гораздо быстрее.
Из этой связи вытекает идея пространственно‑временной сложности алгоритмов. При этом подходе сложность алгоритма оценивается в терминах времени и пространства, и находится компромисс между ними.
В этом материале основное внимание уделяется временной сложности, но мы также постарались обратить внимание и на особые требования к объему памяти для некоторых алгоритмов. Например, сортировка слиянием (mergesort), обсуждаемая в 9 главе, требует больше временной памяти. Другие алгоритмы, например пирамидальная сортировка (heapsort), которая также обсуждается в 9 главе, требует обычного объема памяти.
При сравнении различных алгоритмов важно понимать, как сложность алгоритма соотносится со сложностью решаемой задачи. При расчетах по одному алгоритму сортировка тысячи чисел может занять 1 секунду, а сортировка миллиона — 10 секунд, в то время как расчеты по другому алгоритму могут потребовать 2 и 5 секунд соответственно. В этом случае нельзя однозначно сказать, какая из двух программ лучше — это будет зависеть от исходных данных.
Различие производительности алгоритмов на задачах разной вычислительной сложности часто более важно, чем просто скорость алгоритма. В вышеприведенном случае, первый алгоритм быстрее сортирует короткие списки, а второй — длинные.
Производительность алгоритма можно оценить по порядку величины. Алгоритм имеет сложность порядка O(f(N)) (произносится «О большое от F от N»), если время выполнения алгоритма растет пропорционально функции f(N) с увеличением размерности исходных данных N. Например, рассмотрим фрагмент кода, сортирующий положительные числа:
For I = 1 To N
'Поиск наибольшего элемента в списке.
MaxValue = 0
For J = 1 to N
If Value(J) > MaxValue Then
MaxValue = Value(J)
MaxJ = J
End If
Next J
'Вывод наибольшего элемента на печать.
Print Format$(MaxJ) & ":" & Str$(MaxValue)
'Обнуление элемента для исключения его из дальнейшего поиска.
Value(MaxJ) = 0
Next I
===============3
В этом алгоритме переменная цикла I последовательно
принимает значения от 1 до N. Для каждого приращения I переменная J в свою
очередь также принимает значения от 1 до N. Таким образом, в каждом внешнем
цикле выполняется еще N внутренних циклов. В итоге внутренний цикл выполняется
N*N или N2 раз и, следовательно, сложность алгоритма порядка O(N2).
При оценке порядка сложности алгоритмов используется только наиболее быстро растущая часть уравнения алгоритма. Допустим, время выполнения алгоритма пропорционально N3+N. Тогда сложность алгоритма будет равна O(N3). Отбрасывание медленно растущих частей уравнения позволяет оценить поведение алгоритма при увеличении размерности данных задачи N.
При больших N вклад второй части в уравнение N3+N становится все менее заметным. При N=100, разность N3+N=1.000.100 и N3 равна всего 100, или менее чем 0,01 процента. Но это верно только для больших N. При N=2, разность между N3+N =10 и N3=8 равна 2, а это уже 20 процентов.
Постоянные множители в соотношении также игнорируются. Это позволяет легко оценить изменения в вычислительной сложности задачи. Алгоритм, время выполнения которого пропорционально 3*N2, будет иметь порядок O(N2). Если увеличить N в 2 раза, то время выполнения задачи возрастет примерно в 22, то есть в 4 раза.
Игнорирование постоянных множителей позволяет также упростить подсчет числа шагов алгоритма. В предыдущем примере внутренний цикл выполняется N2 раз, при этом внутри цикла выполняется несколько инструкций. Можно просто подсчитать число инструкций If, можно подсчитать также инструкции, выполняемые внутри цикла или, кроме того, еще и инструкции во внешнем цикле, например операторы Print.
Вычислительная сложность алгоритма при этом будет пропорциональна N2, 3*N2 или 3*N2+N. Оценка сложности алгоритма по порядку величины даст одно и то же значение O(N3) и отпадет необходимость в точном подсчете количества операторов.
Обычно наиболее сложным является выполнение циклов и вызовов процедур. В предыдущем примере, весь алгоритм заключен в двух циклах.
============4
Если процедура вызывает другую процедуру, необходимо учитывать сложность вызываемой процедуры. Если в ней выполняется фиксированное число инструкций, например, осуществляется вывод на печать, то при оценке порядка сложности ее можно не учитывать. С другой стороны, если в вызываемой процедуре выполняется O(N) шагов, она может вносить значительный вклад в сложность алгоритма. Если вызов процедуры осуществляется внутри цикла, этот вклад может быть еще больше.
Приведем в качестве примера программу, содержащую медленную процедуру Slow со сложностью порядка O(N3) и быструю процедуру Fast со сложностью порядка O(N2). Сложность всей программы будет зависеть от соотношения между этими двумя процедурами.
Если процедура Slow вызывается в каждом цикле процедуры Fast, порядки сложности процедур перемножаются. В этом случае сложность алгоритма равна произведению O(N2) и O(N3) или O(N3*N2)=O(N5). Приведем иллюстрирующий этот случай фрагмент кода:
Sub Slow()
Dim I As Integer
Dim J As Integer
Dim K As Integer
For I = 1 To N
For J = 1 To N
For K = 1 To N
' Выполнить какие‑либо действия.
Next K
Next J
Next I
End Sub
Sub Fast()
Dim I As Integer
Dim J As Integer
Dim K As Integer
For I = 1 To N
For J = 1 To N
Slow ' Вызов процедуры Slow.
Next J
Next I
End Sub
Sub MainProgram()
Fast
End Sub
С другой стороны, если процедуры независимо вызываются из основной программы, их вычислительная сложность суммируется. В этом случае полная сложность будет равна O(N3)+O(N2)=O(N3). Такую сложность, например, будет иметь следующий фрагмент кода:
Sub Slow()
Dim I As Integer
Dim J As Integer
Dim K As Integer
For I = 1 To N
For J = 1 To N
For K = 1 To N
' Выполнить какие‑либо действия.
Next K
Next J
Next I
End Sub
Sub Fast()
Dim I As Integer
Dim J As Integer
For I = 1 To N
For J = 1 To N
' Выполнить какие‑либо действия.
Next J
Next I
End Sub
Sub MainProgram()
Slow
Fast
End Sub
==============5
Рекурсивными процедурами (recursive procedure) называются процедуры, вызывающие сами себя. Во многих рекурсивных алгоритмах именно степень вложенности рекурсии определяет сложность алгоритма, при этом не всегда легко оценить порядок сложности. Рекурсивная процедура может выглядеть простой, но при этом вносить большой вклад в сложность программы, многократно вызывая саму себя.
Следующий фрагмент кода содержит подпрограмму всего из двух операторов. Тем не менее, для заданного N подпрограмма выполняется N раз, таким образом, вычислительная сложность фрагмента порядка O(N).
Sub CountDown(N As Integer)
If N <= 0 Then Exit Sub
CountDown N - 1
End Sub
===========6
Рекурсивный алгоритм, вызывающий себя несколько раз, является примером многократной рекурсии (multiple recursion). Процедуры с множественной рекурсией сложнее анализировать, чем просто рекурсивные алгоритмы, и они могут давать больший вклад в общую сложность алгоритма.
Нижеприведенная подпрограмма похожа на предыдущую подпрограмму CountDown, только она вызывает саму себя дважды:
Sub DoubleCountDown(N As Integer)
If N <= 0 Then Exit Sub
DoubleCountDown N - 1
DoubleCountDown N - 1
End Sub
Можно было бы предположить, что время выполнения этой процедуры будет в два раза больше, чем для подпрограммы CountDown, и оценить ее сложность порядка 2*O(N)=O(N). На самом деле ситуация немного сложнее.
Если T(N) — число раз, которое выполняется процедура DoubleCountDown с параметром N, то легко заметить, что T(0)=1. Если вызвать процедуру с параметром N равным 0, то она просто закончит свою работу после первого шага.
Для больших значений N процедура вызывает себя дважды с параметром, равным N-1, выполняясь 1+2*T(N-1) раз. В табл. 1.1 приведены некоторые значения функции T(0)=1 и T(N)=1+2*T(N-1). Если обратить внимание на эти значения, можно увидеть, что T(N)=2(N+1)-1, что дает оценку сложности процедуры порядка O(2N). Хотя процедуры CountDown и DoubleCountDown и похожи, вторая процедура требует выполнения гораздо большего числа шагов.
@Таблица 1.1. Значения функции времени выполнения для подпрограммы DoubleCountDown
Процедура также может вызывать другую процедуру, которая в свою очередь вызывает первую. Такие процедуры иногда даже сложнее анализировать, чем процедуры с множественной рекурсией. Алгоритм вычисления кривой Серпинского, который обсуждается в 5 главе, включает в себя четыре процедуры, которые используют как множественную, так и непрямую рекурсию. Каждая из этих процедур вызывает себя и другие три процедуры до четырех раз. После довольно сложных подсчетов можно показать, что этот алгоритм имеет сложность порядка O(4N).
Для некоторых рекурсивных алгоритмов важен объем доступной памяти. Можно легко написать рекурсивный алгоритм, который будет запрашивать
============7
небольшой объем памяти при каждом своем вызове. Объем занятой памяти может увеличиваться в процессе последовательных рекурсивных вызовов.
Поэтому для рекурсивных алгоритмов необходимо хотя бы приблизительно оценивать требования к объему памяти, чтобы убедиться, что программа не исчерпает при выполнении всю доступную память.
Приведенная ниже подпрограмма запрашивает память при каждом вызове. После 100 или 200 рекурсивных вызовов, процедура займет всю свободную память, и программа аварийно остановится с ошибкой «Out of Memory».
Sub GobbleMemory(N As Integer)
Dim Array() As Integer
ReDim Array (1 To 32000)
GobbleMemory N + 1
End Sub
Даже если внутри процедуры память не запрашивается, система выделяет память из системного стека (system stack) для сохранения параметров при каждом вызове процедуры. После возврата из процедуры память из стека освобождается для дальнейшего использования.
Если в подпрограмме встречается длинная последовательность рекурсивных вызовов, программа может исчерпать стек, даже если выделенная программе память еще не вся использована. Если запустить на исполнение следующую подпрограмму, она быстро исчерпает всю свободную стековую память и программа аварийно прекратит работу с сообщением об ошибке «Out of stack Space». После этого вы сможете узнать значение переменной Count, чтобы узнать, сколько раз подпрограмма вызывала себя перед тем, как исчерпать стек.
Sub UseStack()
Static Count As Integer
Count = Count + 1
UseStack
End Sub
Определение локальных переменных внутри подпрограммы также может занимать память из стека. Если изменить подпрограмму UseStack из предыдущего примера так, чтобы она определяла три переменных при каждом вызове, программа исчерпает стековое пространство еще быстрее:
Sub UseStack()
Static Count As Integer
Dim I As Variant
Dim J As Variant
Dim K As Variant
Count = Count + 1
UseStack
End Sub
В 5 главе рекурсивные алгоритмы обсуждаются более подробно.
==============8
Оценка с точностью до порядка дает верхний предел сложности алгоритма. То, что программа имеет определенный порядок сложности, не означает, что алгоритм будет действительно выполняться так долго. При определенных исходных данных, многие алгоритмы выполняются гораздо быстрее, чем можно предположить на основании их порядка сложности. Например, следующий код реализует простой алгоритм выбора элемента из списка:
Function LocateItem(target As Integer) As Integer
For I = 1 To N
If Value(I) = target Then Exit For
Next I
LocateItem = I
End Sub
Если искомый элемент находится в конце списка, придется перебрать все N элементов для того, чтобы его найти. Это займет N шагов, значит сложность алгоритма порядка O(N). В этом, так называемом наихудшем случае (worst case) время выполнения алгоритма будет наибольшим.
С другой стороны, если искомое число в начале списка, алгоритм завершит работу практически сразу, совершив всего несколько итераций. Это так называемый наилучший случай (best case) со сложностью порядка O(1). Обычно и наилучший, и наихудший случаи встречаются относительно редко, и интерес представляет оценка усредненного или ожидаемого (expected case) поведения.
Если первоначально числа в списке распределены случайно, искомый элемент может оказаться в любом месте списка. В среднем потребуется проверить N/2 элементов для того, чтобы его найти. Значит, сложность этого алгоритма в усредненном случае порядка O(N/2), или O(N), если убрать постоянный множитель.
Для некоторых алгоритмов порядок сложности для наихудшего и наилучшего вариантов различается. Например, сложность алгоритма быстрой сортировки из 9 главы в наихудшем случае порядка O(N2), но в среднем его сложность порядка O(N*log(N)), что намного быстрее. Иногда алгоритмы типа быстрой сортировки бывают очень длинными, чтобы наихудший случай достигался крайне редко.
В табл. 1.2 приведены некоторые функции, которые обычно встречаются при оценке сложности алгоритмов. Функции приведены в порядке возрастания вычислительной сложности сверху вниз. Это значит, что алгоритмы со сложностью порядка функций, расположенных вверху таблицы, будут выполняться быстрее, чем те, сложность которых определяется функциями из нижней части таблицы.
==============9
@Таблица 1.2. Часто встречающиеся функции оценки порядка сложности
Сложность алгоритма, определяемая уравнением, которое представляет собой сумму функций из таблицы, будет сводиться к сложности той из функций, которая расположена в таблице ниже. Например, O(log(N)+N2) — это то же самое, что и O(N2).
Обычно алгоритмы со сложностью порядка N*log(N) и менее сложных функций выполняются очень быстро. Алгоритмы порядка NC при малых C, например N2 выполняются достаточно быстро. Вычислительная же сложность алгоритмов, порядок которых определяется функциями CN или N! очень велика и эти алгоритмы пригодны только для решения задач с небольшим N.
В качестве примера в табл. 1.3 показано, как долго компьютер, выполняющий миллион инструкций в секунду, будет выполнять некоторые медленные алгоритмы. Из таблицы видно, что при сложности порядка O(CN) могут быть решены только небольшие задачи, и еще меньше параметр N может быть для задач со сложностью порядка O(N!). Для решения задачи порядка O(N!) при N=24 потребовалось бы время, большее, чем время существования вселенной.
Перед тем, как продолжить дальше, следует остановиться на логарифмах, так как они играют важную роль в различных алгоритмах. Логарифм числа N по основанию B это степень P, в которую надо возвести основание, чтобы получить N, то есть BP=N. Например, если 23=8, то соответственно log2(8)=3.
==================10
@Таблица 1.3. Время выполнения сложных алгоритмов
Можно привести логарифм к другому основанию при помощи соотношения logB(N)=logC(N)/logC(B). Например, чтобы вычислить логарифм числа по основанию 10, зная его логарифм по основанию 2, можно воспользоваться формулой log10(N)=log2(N)/log2(10). При этом log2(10) — это табличная константа, примерно равная 3,32. Так как постоянные множители при оценке сложности алгоритма можно опустить, то O(log2(N)) — это же самое, что и O(log10(N)) или O(logB(N)) для любого B. Поскольку основание логарифма не имеет значения, часто просто пишут, что сложность алгоритма порядка O(log(N)).
В программировании часто встречаются логарифмы по основанию 2, что обусловлено применяемой в компьютерах двоичной системой исчисления. Поэтому мы для упрощения выражений будем везде писать log(N), подразумевая под этим log2(N). Если используется другое основание алгоритма, это будет обозначено особо.
Хотя при исследовании сложности алгоритма обычно полезно отбросить малые члены уравнения и постоянные множители, иногда их все‑таки необходимо учитывать, особенно если размерность данных задачи N мала, а постоянные множители достаточно велики.
Допустим, мы рассматриваем два алгоритма решения одной задачи. Один выполняется за время порядка O(N), а другой — порядка O(N2). Для больших N первый алгоритм, вероятно, будет работать быстрее.
Тем не менее, если взять конкретные функции оценки времени выполнения для каждого из двух алгоритмов, например, для первого f(N)=30*N+7000, а для второго f(N)=N2, то в этом случае при N меньше 100 второй алгоритм будет выполняться быстрее. Поэтому, если известно, что размерность данных задачи не будет превышать 100, возможно будет целесообразнее применить второй алгоритм.
С другой стороны, время выполнения разных инструкций может сильно отличаться. Если первый алгоритм использует быстрые операции с памятью, а второй использует медленное обращение к диску, то первый алгоритм будет быстрее во всех случаях.
==================11
Другие факторы могут также осложнить проблему выбора оптимального алгоритма. Например, первый алгоритм может требовать большего объема памяти, чем установлено на компьютере. Реализация второго алгоритма, в свою очередь, может потребовать намного больше времени, если этот алгоритм намного сложнее, а его отладка может превратиться в настоящий кошмар. Иногда подобные практические соображения могут сделать теоретический анализ сложности алгоритма почти бессмысленным.
Тем не менее, анализ сложности алгоритма полезен для понимания особенностей алгоритма и обычно обнаруживает части программы, занимающие большую часть компьютерного времени. Уделив внимание оптимизации кода в этих частях, можно внести максимальный эффект в увеличение производительности программы в целом.
Иногда тестирование алгоритмов является наиболее подходящим способом определить наилучший алгоритм. При таком тестировании важно, чтобы тестовые данные были максимально приближены к реальным данным. Если тестовые данные сильно отличаются от реальных, результаты тестирования могут сильно отличаться от реальных.
Важным фактором при работе в реальных условиях является частота обращения к файлу подкачки (page file). Операционная система Windows отводит часть дискового пространства под виртуальную память (virtual memory). Когда исчерпывается оперативная память, Windows сбрасывает часть ее содержимого на диск. Освободившаяся память предоставляется программе. Этот процесс называется подкачкой, поскольку страницы, сброшенные на диск, могут быть подгружены системой обратно в память при обращении к ним.
Поскольку операции с диском намного медленнее операций с памятью, слишком частое обращение к файлу подкачки может значительно снизить производительность приложения. Если программа часто обращается к большим объемам памяти, система будет часто использовать файл подкачки, что приведет к замедлению работы.
Приведенная в числе примеров программа Pager запрашивает все больше и больше памяти под создаваемые массивы до тех пор, пока программа не начнет обращаться к файлу подкачки. Введите количество памяти в мегабайтах, которое программа должна запросить, и нажмите кнопку Page (Подкачка). Если ввести небольшое значение, например 1 или 2 Мбайт, программа создаст массив в оперативной памяти, и будет выполняться быстро.
Если же вы введете значение, близкое к объему оперативной памяти вашего компьютера, то программа начнет использовать файл подкачки. Вполне вероятно, что она будет при этом обращаться к диску постоянно. Вы также заметите, что программа выполняется намного медленнее. Увеличение размера массива на 10 процентов может привести к 100‑процентному увеличению времени исполнения.
Программа Pager может использовать память одним из двух способов. Если вы нажмете кнопку Page, программа начнет последовательно обращаться к элементам массива. По мере перехода от одной части массива к другой, системе может потребоваться подгружать их с диска. После того, как часть массива оказалась в памяти, программа может продолжить работу с ней.
============12
Если же вы нажмете на кнопку Thrash (Пробуксовка), программа будет случайно обращаться к разным участкам памяти. При этом вероятность того, что нужная страница находится в этот момент на диске, намного возрастает. Это избыточное обращение к файлу подкачки называется пробуксовкой[RP2] памяти (thrashing). В табл. 1.4 приведено время исполнения программы Pager на компьютере с процессором Pentium с тактовой частотой 90 МГц и 24 Мбайт оперативной памяти. В зависимости от конфигурации вашего компьютера, скорости работы с диском, количества установленной оперативной памяти, а также наличия других запущенных параллельно приложений время выполнения программы может сильно различаться.
Вначале время выполнения теста растет почти пропорционально размеру занятой памяти. Когда начинается обращение к файлу подкачки, скорость работы программы резко падает. Заметьте, что до этого тесты с обращением к файлу подкачки и пробуксовкой ведут себя практически одинаково, то есть когда весь массив находится в оперативной памяти, последовательное и случайное обращение к элементам массива занимает одинаковое время. При подкачке элементов массива с диска случайный доступ к памяти намного менее эффективен.
Для уменьшения числа обращений к файлу подкачки есть несколько способов. Основной прием — экономное расходование памяти. При этом надо помнить, что программа обычно не может занять всю физическую память, потому что часть ее занимает система и другие программы. Компьютер, на котором были получены результаты, приведенные в табл. 1.4, начинал интенсивно обращаться к диску, когда программа занимала 20 Мбайт из 24 Мбайт физической памяти.
Иногда можно написать код так, что программа будет обращаться к блокам памяти последовательно. Алгоритм сортировки слиянием, описанный в 9 главе, манипулирует большими блоками данных. Эти блоки сортируются, а затем сливаются вместе. Упорядоченная работа с памятью уменьшает число обращений к диску.
@Таблица 1.4. Время выполнения программы Pager в секундах
===============13
Алгоритм пирамидальной сортировки, также описанный в 9 главе, произвольно переходит от одной части списка к другой. Для очень больших списков это может приводить к перегрузке памяти. С другой стороны, сортировка слиянием требует большего объема памяти, чем пирамидальная сортировка. Если список достаточно большой, это также может приводить к обращению к файлу подкачки.
В некоторых языках, например в C, C++ или Delphi, можно определять переменные, которые являются указателями (pointers) на участки памяти. В этих участках могут содержаться массивы, строки, или другие структуры данных. Часто указатель ссылается на структуру, которая содержит другой указатель и так далее. Используя структуры, содержащие указатели, можно организовывать всевозможные списки, графы, сети и деревья. В последующих главах рассматриваются некоторые из этих сложных структур.
До третьей версии Visual Basic не содержал средств для прямого создания ссылок. Тем не менее, поскольку указатель всего лишь ссылается на какой‑либо участок данных, то можно, создав массив, использовать целочисленный индекс массива в качестве указателя на его элементы. Это называется псевдоуказателем (fake pointer).
В 4-й версии Visual Basic были впервые введены классы. Переменная, указывающая на экземпляр класса, является ссылкой на объект. Например, в следующем фрагменте кода переменная obj — это ссылка на объект класса MyClass. Эта переменная не указывает ни на какой объект, пока она не определяется при помощи зарезервированного слова New. Во второй строке оператор New создает новый объект и записывает ссылку на него в переменную obj.
Dim obj As MyClass
Set obj = New MyClass
Ссылки в Visual Basic — это разновидность указателей.
Объекты в Visual Basic используют счетчик ссылок (reference counter) для упрощения работы с объектами. Когда создается новая ссылка на объект, счетчик ссылок увеличивается на единицу. После того, как ссылка перестает указывать на объект, счетчик ссылок соответственно уменьшается. Когда счетчик ссылок становится равным нулю, объект становится недоступным программе. В этот момент Visual Basic уничтожает объект и возвращает занятую им память.
В следующих главах более подробно обсуждаются ссылки и счетчики ссылок.
Кроме объектов и ссылок, в 4-й версии Visual Basic также появились коллекции. Коллекцию можно представить как разновидность массива. Они
================14
предоставляют в распоряжение программиста удобные возможности, например можно менять размер коллекции, а также осуществлять поиск объекта по ключу.
Псевдоуказатели, ссылки и коллекции упоминаются в этой главе потому, что они могут сильно влиять на производительность программы. Ссылки и коллекции могут упрощать программирование определенных операций, но они могут потребовать дополнительных расходов памяти.
Программа Faker на диске с примерами демонстрирует взаимосвязь между псевдоуказателями, ссылками и коллекциями. Когда вы вводите число и нажимаете кнопку Create List (Создать список), программа создает список элементов одним из трех способов. Вначале она создает объекты, соответствующие отдельным элементам, и добавляет ссылки на объекты к коллекции. Затем она использует ссылки внутри самих объектов для создания связанного списка объектов. И, наконец, она создает связный список при помощи псевдоуказателей. Пока не будем останавливаться на том, как работают связные списки. Они будут подробно разбираться во 2 главе.
После нажатия на кнопку Search List (Поиск в списке), программа Faker выполняет поиск по всем элементам списка, а после нажатия на кнопку Destroy List (Уничтожить список) уничтожает все списки и освобождает память.
В табл. 1.5 приведены значения времени, которое требуется программе для выполнения этих задач на компьютере с процессором Pentium с тактовой частотой 90 МГц. Из таблицы видно, что за удобство работы с коллекциями приходится платить ценой большего времени, затрачиваемого на создание и уничтожение коллекций.
Коллекции также содержат индекс списка. Часть времени, затрачиваемого при создании коллекции, и уходит на создание индекса. При уничтожении коллекции сохраняемые в ней ссылки освобождаются. При этом система проверяет и обновляет счетчики ссылок для всех объектов. Если они равны нулю, то сам объект также уничтожается. Все это занимает дополнительное время.
При использовании псевдоуказателей создание и уничтожение списка происходит так быстро, что этим временем можно практически пренебречь. Системе при этом не надо заботиться о ссылках, счетчиках ссылок и об освобождении объектов.
С другой стороны, поиск в коллекции осуществляется гораздо быстрее, чем в двух остальных случаях, поскольку коллекция использует быстрое хеширование (hashing) построенного индекса, в то время как список ссылок и список псевдоуказателей используют медленный последовательный поиск. В 11 главе объясняется, как можно добавить хеширование к своей программе без использования коллекций.
@Таблица 1.5. Время Создания/Поиска/Уничтожения списков в секундах
==============15
Хотя применение псевдоуказателей обычно обеспечивает лучшую производительность, оно менее удобно, чем использование ссылок. Если в программе нужен лишь небольшой список, ссылки и коллекции могут работать достаточно быстро. При работе с большими списками можно получить более высокую производительность, используя псевдоуказатели.
Анализ производительности алгоритмов позволяет сравнить разные алгоритмы. Он также помогает оценить поведение алгоритмов при различных условиях. Выделяя только части алгоритма, которые вносят наибольший вклад во время исполнения программы, анализ помогает определить, доработка каких участков кода позволяет внести максимальный вклад в улучшение производительности.
В программировании часто приходится идти на различные компромиссы, которые могут сказываться на производительности. Один алгоритм может быть быстрее, но за счет использования большого объема памяти. Другой алгоритм, использующий коллекции, может быть более медленным, но зато его проще разрабатывать и поддерживать.
После анализа доступных алгоритмов, понимания того, как они ведут себя в различных условиях и их требований к ресурсам, вы можете выбрать оптимальный алгоритм для вашей задачи.
==============16
Глава 2. СпискиСуществует четыре основных способа распределения памяти в Visual Basic: объявление переменных стандартных типов (целые, с плавающей точкой и т.д.); объявление переменных типов, определенных пользователем; создание экземпляров классов при помощи оператора New и изменение размера массивов. Существует еще несколько способов, например, создание нового экземпляра формы или элемента управления, но эти способы не дают больших возможностей при создании сложных структур данных.
Используя эти методы, можно легко строить статические структуры данных, такие как большие массивы определенных пользователем типов. Вы также можете изменять размер массива при помощи оператора ReDim. Тем не менее, перераспределение данных может быть достаточно сложным. Например, для того, чтобы переместить элемент с одного конца массива на другой, потребуется переупорядочить весь массив, сдвинув все элементы на одну позицию, чтобы заполнить освободившееся пространство. Затем можно поместить элемент на его новое место.
Динамические структуры данных позволяют быстро и легко выполнять такого рода изменения. Всего за несколько шагов можно переместить любой элемент в структуре данных в любое другое положение.
В этой главе описаны методы создания динамических списков в Visual Basic. Различные типы списков обладают разными свойствами. Некоторые из них просты и обладают ограниченной функциональностью, другие же, такие как циклические списки, одно‑ или двусвязные списки, являются более сложными и поддерживают более развитые средства управления данными.
В последующих главах описанные методы используются для построения стеков, очередей, массивов, деревьев, хэш‑таблиц и сетей. Вам необходимо усвоить материал этой главы перед тем, как продолжить чтение.
Простейшая форма списка — это группа объектов. Она включает в себя объекты и позволяет программе обращаться к ним. Если это все, что вам нужно от списка, вы можете использовать массив в качестве списка, отслеживая при помощи переменной NumInList число элементов в списке. Определив при помощи этой переменной число имеющихся элементов, программа затем может по очереди обратиться к ним в цикле For и выполнить необходимые действия.
=============17
Если вы в своей программе можете обойтись этим подходом, используйте его. Этот метод эффективен, и его легко отлаживать и поддерживать благодаря его простоте. Тем не менее, большинство программ не столь просты, и в них требуются более сложные конструкции даже для таких простых объектов, как списки. Поэтому в последующих разделах этой главы обсуждаются некоторые пути создания списков с большей функциональностью.
В первом параграфе описываются пути создания списков, которые могут расти и уменьшаться со временем. В некоторых программах нельзя заранее определить, насколько большой список понадобится. Вы можете справиться с такой ситуацией при помощи списка, который может при необходимости изменять свой размер.
В следующем параграфе обсуждаются неупорядоченные списки (unordered list), которые позволяют удалять элементы из любой части списка. Неупорядоченные списки дают больший контроль над содержимым списка, чем простые списки. Они также являются более динамичными, так как позволяют изменять содержимое в произвольный момент времени.
В последующих разделах обсуждаются связные списки (linked list), которые используют указатели для создания чрезвычайно гибких структур данных. Вы можете добавлять или удалять элементы из любой части связного списка с минимальными усилиями. В этих параграфах также описаны некоторые разновидности связных списков, такие как циклические, двухсвязные списки или списки со ссылками.
Если в вашей программе необходим список постоянного размера, вы можете создать его, просто используя массив. В этом случае можно при необходимости опрашивать его элементы в цикле For.
Многие программы используют списки, которые растут или уменьшаются со временем. Можно создать массив, соответствующий максимально возможному размеру списка, но такое решение не всегда будет оптимальным. Не всегда можно заранее знать, насколько большим может стать список, кроме того, вероятность, что список станет очень большим, может быть невелика, и созданный массив гигантских размеров может большую часть времени лишь понапрасну занимать память.
Программа может использовать коллекции Visual Basic для хранения списка переменного размера. Метод Add Item добавляет элемент в коллекцию. Метод Remove удаляет элемент. Следующий фрагмент кода демонстрирует программу, которая добавляет три элемента к коллекции и затем удаляет второй элемент.
Dim list As New Collection
Dim obj As MyClass
Dim I As Integer
‘ Создать и добавить 1 элемент.
Set obj = New MyClass
list.Add obj
‘ Добавить целое число.
i = 13
list.Add I
‘ Добавить строку.
list.Add "Работа с коллекциями"
‘ Удалить 2 элемент (целое число).
list.Remove 2
===============18
Коллекции пытаются обеспечить поддержку любых приложений, и выполняют замечательную работу. Их легко использовать, они позволяют извлекать элементы, проиндексированные по ключу, и дают приемлемую производительность, если не содержат слишком много элементов.
Тем не менее, коллекциям свойственны и определенные недостатки. Для больших списков, коллекции могут работать медленнее, чем массивы. Если в вашей программе не нужны все свойства, предоставляемые коллекцией, более быстрым может быть использование простого массива.
Схема хэширования, которую коллекции используют для управления ключами, также накладывает ряд ограничений. Во-первых, коллекции не позволяют дублировать ключи. Во-вторых, для коллекции можно определить, какой элемент имеет заданный ключ, но нельзя узнать, какой ключ соответствует данному элементу. И, наконец, коллекции не поддерживают множественных ключей. Например, может быть, вам хотелось бы, чтобы программа могла производить поиск по списку служащих, используя имя сотрудника или его идентификационный номер в системе социального страхования. Коллекция не сможет поддерживать оба метода поиска, так как она способна оперировать только одним ключом.
В последующих параграфах описываются методы построения списков, свободных от этих ограничений.
Оператор Visual Basic ReDim позволяет изменять размер массива. Вы можете использовать это свойство для построения простого списка переменного размера. Начните с объявления безразмерного массива для хранения элементов списка. Также определите переменную NumInList для отслеживания числа элементов в списке. При добавлении элементов к списку используйте оператор ReDim для увеличения размера массива, чтобы новый элемент мог поместиться в нем. При удалении элемента также используйте оператор ReDim для уменьшения массива и освобождения ненужной больше памяти.
Dim List() As String ‘ Список элементов.
Dim NumInList As Integer ‘ Число элементов в списке.
Sub AddToList(value As String)
‘ Увеличить размер массива.
NumInList = NumInList + 1
ReDim Preserve List (1 To NumInList)
‘ Добавить новый элемент к концу списка.
List(NumInList) = value
End Sub
Sub RemoveFromList()
‘ Уменьшить размер массива, освобождая память.
NumInList = NumInList – 1
ReDim Preserve List (1 To NumInList)
End Sub
==================19
Эта простая схема неплохо работает для небольших списков, но у нее есть пара недостатков. Во-первых, приходится часто менять размер массива. Для создания списка из 1000 элементов, придется 1000 раз изменять размер массива. Хуже того, при увеличении размера списка, на изменение его размера потребуется больше времени, поскольку придется каждый раз копировать растущий список в памяти.
Для уменьшения частоты изменений размера массива, можно добавлять дополнительные элементы к массиву при увеличении его размера, например, по 10 элементов вместо одного. При этом, когда вы будете добавлять новые элементы к списку в будущем, массив уже будет содержать неиспользуемые ячейки, в которые вы сможете поместить новые элементы без увеличения размера массива. Новое увеличение размера массива потребуется, только когда пустые ячейки закончатся.
Подобным же образом можно избежать изменения размера массива при каждом удалении элемента из списка. Можно подождать, пока в массиве не накопится 20 неиспользуемых ячеек, прежде чем уменьшать его размер. При этом нужно оставить 10 свободных ячеек для того, чтобы можно было добавлять новые элементы без необходимости снова увеличивать размер массива.
Заметим, что максимальное число неиспользуемых ячеек (20) должно быть больше, чем минимальное число (10). Это уменьшает число изменений размера массива при удалении или добавлении его элементов.
При такой схеме в списке обычно есть несколько свободных ячеек, тем не менее их число достаточно мало, и лишние затраты памяти невелики. Свободные ячейки гарантируют возможность добавления или удаления элементов без изменения размера массива. Фактически, если вы неоднократно добавляете к списку, а затем удаляете из него один или два элемента, вам может никогда не понадобиться изменять размер массива.
Dim List() As String ‘ Список элементов.
Dim ArraySize As Integer ‘ Размер массива.
Dim NumInList As Integer ‘ Число используемых элементов.
‘ Если массив заполнен, увеличить его размер, добавив 10 ячеек.
‘ Затем добавить новый элемент в конец списка.
Sub AddToList(value As String)
NumInList = NumInList + 1
If NumInList > ArraySize Then
ArraySize = ArraySize + 10
ReDim Preserve List(1 To ArraySize)
End If
List(NumInList) = value
End Sub
‘ Удалить последний элемент из списка. Если осталось больше
‘ 20 пустых ячеек, уменьшить список, освобождая память.
Sub RemoveFromList()
NumInList = NumInList – 1
If ArraySize – NumInList > 20 Then
ArraySize = ArraySize –10
ReDim Preserve List(1 To ArraySize)
End If
End Sub
=============20
Для очень больших массивов это решение может также оказаться не самым лучшим. Если вам нужен список, содержащий 1000 элементов, к которому обычно добавляется по 100 элементов, то все еще слишком много времени будет тратиться на изменение размера массива. Очевидной стратегией в этом случае было бы увеличение приращения размера массива с 10 до 100 или более ячеек. Тогда можно было бы добавлять по 100 элементов одновременно без частого изменения размера списка.
Более гибким решением будет изменение приращения в зависимости от размера массива. Для небольших списков это приращение было бы также небольшим. Хотя изменения размера массива происходили бы чаще, они потребовали бы относительно немного времени для небольших массивов. Для больших списков, приращение размера будет больше, поэтому их размер будет изменяться реже.
Следующая программа пытается поддерживать примерно 10 процентов списка свободным. Когда массив заполняется, его размер увеличивается на 10 процентов. Если свободное пространство составляет более 20 процентов от размера массива, программа уменьшает его.
При увеличении размера массива, добавляется не меньше 10 элементов, даже если 10 процентов от размера массива составляют меньшую величину. Это уменьшает число необходимых изменений размера массива, если список очень мал.
Const WANT_FREE_PERCENT = .1 ‘ 10% свободного места.
Const MIN_FREE = 10 ‘ Минимальное число пустых ячеек.
Global List() As String ‘ Массив элементов списка.
Global ArraySize As Integer ‘ Размер массива.
Global NumItems As Integer ‘ Число элементов в списке.
Global ShrinkWhen As Integer ‘ Уменьшить размер, если NumItems < ShrinkWhen.
‘ Если массив заполнен, увеличить его размер.
‘ Затем добавить новый элемент в конец списка.
Sub Add(value As String)
NumItems = NumItems + 1
If NumItems > ArraySize Then ResizeList
List(NumItems) = value
End Sub
‘ Удалить последний элемент из списка.
‘ Если в массиве много пустых ячеек, уменьшить его размер.
Sub RemoveLast()
NumItems = NumItems – 1
If NumItems < ShrinkWhen Then ResizeList
End Sub
‘ Увеличить размер массива, чтобы 10% ячеек были свободны.
Sub ResizeList()
Dim want_free As Integer
want_free = WANT_FREE_PERCENT * NumItems
If want_free < MIN_FREE Then want_free = MIN_FREE
ArraySize = NumItems + want_free
ReDim Preserve List(1 To ArraySize)
‘ Уменьшить размер массива, если NumItems < ShrinkWhen.
ShrinkWhen = NumItems – want_free
End Sub
===============21
Чтобы использовать этот простой подход, программе необходимо знать все параметры списка, при этом нужно следить за размером массива, числом используемых элементов, и т.д. Если необходимо создать больше одного списка, потребуется множество копий переменных и код, управляющий разными списками, будет дублироваться.
Классы Visual Basic могут сильно облегчить выполнение этой задачи. Класс SimpleList инкапсулирует эту структуру списка, упрощая управление списками. В этом классе присутствуют методы Add и Remove для использования в основной программе. В нем также есть процедуры извлечения свойств NumItems и ArraySize, с помощью которых программа может определить число элементов в списке и объем занимаемой им памяти.
Процедура ResizeList объявлена как частная внутри класса SimpleList. Это скрывает изменение размера списка от основной программы, поскольку этот код должен использоваться только внутри класса.
Используя класс SimpleList, легко создать в приложении несколько списков. Для того чтобы создать новый объект для каждого списка, просто используется оператор New. Каждый из объектов имеет свои переменные, поэтому каждый из них может управлять отдельным списком:
Dim List1 As New SimpleList
Dim List2 As New SimpleList
Когда объект SimpleList увеличивает массив, он выводит окно сообщения, показывающее размер массива, количество неиспользуемых элементов в нем, и значение переменной ShrinkWhen. Когда число использованных ячеек в массиве становится меньше, чем значение ShrinkWhen, программа уменьшает размер массива. Заметим, что когда массив практически пуст, переменная ShrinkWhen иногда становится равной нулю или отрицательной. В этом случае размер массива не будет уменьшаться, даже если вы удалите все элементы из списка.
=============22
Программа SimList добавляет к массиву еще 50 процентов пустых ячеек, если необходимо увеличить его размер, и всегда оставляет при этом не менее 1 пустой ячейки. Эти значения был выбраны для удобства работы с программой. В реальном приложении, процент свободной памяти должен быть меньше, а число свободных ячеек больше. Более разумным в таком случае было бы выбрать значения порядка 10 процентов от текущего размера списка и минимум 10 свободных ячеек.
В некоторых приложениях может понадобиться удалять элементы из середины списка, добавляя при этом элементы в конец списка. В этом случае порядок расположения элементов может быть не важен, но при этом может быть необходимо удалять определенные элементы из списка. Списки такого типа называются неупорядоченными списками (unordered lists). Они также иногда называются «множеством элементов».[RP3]
Неупорядоченный список должен поддерживать следующие операции:
* добавление элемента к списку;
* удаление элемента из списка;
* определение наличия элемента в списке;
* выполнение каких‑либо операций (например, вывода на дисплей или принтер) для всех элементов списка.
Простую структуру, представленную в предыдущем параграфе, можно легко изменить для того, чтобы обрабатывать такие списки. Когда удаляется элемент из середины списка, остальные элементы сдвигаются на одну позицию, заполняя образовавшийся промежуток. Это показано на рис. 2.1, на котором второй элемент удаляется из списка, и третий, четвертый, и пятый элементы сдвигаются влево, заполняя свободный участок.
Удаление из массива элемента при таком подходе может занять достаточно много времени, особенно если удаляется элемент в начале списка. Чтобы удалить первый элемент из массива с 1000 элементов, потребуется сдвинуть влево на одну позицию 999 элементов. Гораздо быстрее удалять элементы можно при помощи простой схемы чистки памяти (garbage collection)[RP4] .
Вместо удаления элементов из списка, пометьте их как неиспользуемые. Если элементы списка — данные простых типов, например целые, можно помечать элементы, используя определенное, так называемое «мусорное» значение (garbage value).
@Рисунок 2.1 Удаление элемента из середины массива
===========23
Для целых чисел можно использовать для этого значение ‑32.767. Для переменной типа Variant можно использовать значение NULL. Это значение присваивается каждому неиспользуемому элементу. Следующий фрагмент кода демонстрирует удаление элемента из подобного целочисленного списка:
Const GARBAGE_VALUE = -32767
‘ Пометить элемент как неиспользуемый.
Sub RemoveFromList(position As Long)
List(position) = GARBAGE_VALUE
End Sub
Если элементы списка — это структуры, определенные оператором Type, вы можете добавить к такой структуре новое поле IsGarbage. Когда элемент удаляется из списка, значение поля IsGarbage устанавливается в True.
Type MyData
Name As Sring ‘ Данные.
IsGarbage As Integer ‘ Этот элемент не используется?
End Type
‘ Пометить элемент, как не использующийся.
Sub RemoveFromList (position As Long)
List(position).IsGarbage = True
End Sub
Для простоты далее в этом разделе предполагается, что элементы данных являются данными универсального типа и их можно помечать значением NULL.
Теперь можно изменить другие процедуры, которые используют список, чтобы они пропускали помеченные элементы. Например, так можно модифицировать процедуру, которая печатает список:
‘ Печать элементов списка.
Sub PrintItems()
Dim I As Long
For I = 1 To ArraySize
If Not IsNull(List(I)) [RP5] Then ‘ Если элемент не помечен
Print Str$(List(I)) ‘ напечатать его.
End If
Next I
End Sub
После использования в течение некоторого времени схемы пометки «мусора», список может оказаться полностью им заполнен. В конце концов, подпрограммы вроде этой процедуры больше времени будут тратить на пропуск ненужных элементов, чем на обработку настоящих данных.
=============24
Для того, чтобы избежать этого, можно периодически запускать процедуру очистки памяти (garbage collection routine). Эта процедура перемещает все непомеченные записи в начало массива. После этого можно добавить их к свободным элементам в конце массива. Когда потребуется добавить к массиву дополнительные элементы, их также можно будет использовать без изменения размера массива.
После добавления помеченных элементов к другим свободным ячейкам массива, полный объем свободного пространства может стать достаточно большим, и в этом случае можно уменьшить размер массива, освобождая память:
Private Sub CollectGarbage()
Dim i As Long
Dim good As Long
good = 1 ‘ Первый используемый элемент.
For i = 1 To m_NumItems
‘ Если он не помечен, переместить его на новое место.
If Not IsNull(m_List(i)) Then
m_List(good) = m_list(i)
good = good + 1
End If
Next i
‘ Последний используемый элемент.
m_NumItems(good) = good - 1
‘ Необходимо ли уменьшать размер списка?
If m_NumItems < m_ShrinkWhen Then ResizeList
End Sub
При выполнении чистки памяти, используемые элементы перемещаются ближе к началу списка, заполняя пространство, которое занимали помеченные элементы. Значит, положение элементов в списке может измениться во время этой операции. Если другие часть программы обращаются к элементам списка по их положению в нем, необходимо модифицировать процедуру чистки памяти, с тем, чтобы она также обновляла ссылки на положение элементов в списке. В общем случае это может оказаться достаточно сложным, приводя к проблемам при сопровождении программ.
Можно выбирать разные моменты для запуска процедуры чистки памяти. Один из них — когда массив достигает определенного размера, например, когда список содержит 30000 элементов.
Этому методу присущи определенные недостатки. Во‑первых, он использует большой объем памяти. Если вы часто добавляете или удаляете элементы, «мусор» будет занимать довольно большую часть массива. При таком неэкономном расходовании памяти, программа может тратить время на свопинг, хотя список мог бы целиком помещаться в памяти при более частом переупорядочивании.
===========25
Во-вторых, если список начинает заполняться ненужными данными, процедуры, которые его используют, могут стать чрезвычайно неэффективными. Если в массиве из 30.000 элементов 25.000 не используются, подпрограмма типа описанной выше PrintItems, может выполняться ужасно медленно.
И, наконец, чистка памяти для очень большого массива может потребовать значительного времени, в особенности, если при обходе элементов массива программе приходится обращаться к страницам, выгруженным на диск. Это может приводить к «подвисанию» вашей программы на несколько секунд во время чистки памяти.
Чтобы решить эту проблему, можно создать новую переменную GarbageCount, в которой будет находиться число ненужных элементов в списке. Когда значительная часть памяти, занимаемой списком, содержит ненужные элементы, вы может начать процедуру «сборки мусора».
Dim GarbageCount As Long ‘ Число ненужных элементов.
Dim MaxGarbage As Long ‘ Это значение определяется в ResizeList.
‘ Пометить элемент как ненужный.
‘ Если «мусора» слишком много, начать чистку памяти.
Public Sub Remove(position As Long)
m_List(position) = Null
m_GarbageCount = m_GarbageCount + 1
‘ Если «мусора» слишком много, начать чистку памяти.
If m_GarbageCount > m_MaxGarbage Then CollectGarbage
End Sub
Программа Garbage демонстрирует этот метод чистки памяти. Она пишет рядом с неиспользуемыми элементами списка слово «unused», а рядом с помеченными как ненужные — слово «garbage». Программа использует класс GarbageList примерно так же, как программа SimList использовала класс SimpleList, но при этом она еще осуществляет «сборку мусора».
Чтобы добавить элемент к списку, введите его значение и нажмите на кнопку Add (Добавить). Для удаления элемента выделите его, а затем нажмите на кнопку Remove (Удалить). Если список содержит слишком много «мусора», программа начнет выполнять чистку памяти.
При каждом изменении размера списка объекта GarbageList, программа выводит окно сообщения, в котором приводится число используемых и свободных элементов в списке, а также значения переменных MaxGarbage и ShrinkWhen. Если удалить достаточное количество элементов, так что больше, чем MaxGarbage элементов будут помечены как ненужные, программа начнет выполнять чистку памяти. После ее окончания, программа уменьшает размер массива, если он содержит меньше, чем ShrinkWhen занятых элементов.
Если размер массива должен быть увеличен, программа Garbage добавляет к массиву еще 50 процентов пустых ячеек, и всегда оставляет хотя бы одну пустую ячейку при любом изменении размера массива. Эти значения были выбраны для упрощения работы пользователя со списком. В реальной программе процент свободной памяти должен быть меньше, а число свободных ячеек — больше. Оптимальными выглядят значения порядка 10 процентов и 10 свободных ячеек.
==========26
Другая стратегия используется при управлении связанными списками. Связанный список хранит элементы в структурах данных или объектах, которые называются ячейками (cells). Каждая ячейка содержит указатель на следующую ячейку в списке. Так как единственный тип указателей, которые поддерживает Visual Basic — это ссылки на объекты, то ячейки в связном списке должны быть объектами.
В классе, задающем ячейку, должна быть определена переменная NextCell, которая указывает на следующую ячейку в списке. В нем также должны быть определены переменные, содержащие данные, с которыми будет работать программа. Эти переменные могут быть объявлены как открытые (public) внутри класса, или класс может содержать процедуры для чтения и записи значений этих переменных. Например, в связном списке с записями о сотрудниках, в этих полях могут находиться имя сотрудника, номер социального страхования, название должности, и т.д. Определения для класса EmpCell могут выглядеть примерно так:
Public EmpName As String
Public SSN As String
Public JobTitle As String
Public NextCell As EmpCell
Программа создает новые ячейки при помощи оператора New, задает их значения и соединяет их, используя переменную NextCell.
Программа всегда должна сохранять ссылку на вершину списка. Для того, чтобы определить, где заканчивается список, программа должна установить значение NextCell для последнего элемента списка равным Nothing (ничего). Например, следующий фрагмент кода создает список, представляющий трех сотрудников:
Dim top_cell As EmpCell
Dim cell1 As EmpCell
Dim cell2 As EmpCell
Dim cell3 As EmpCell
‘ Создание ячеек.
Set cell1 = New EmpCell
cell1.EmpName = "Стивенс”
cell1.SSN = "123-45-6789"
cell1.JobTitle = "Автор"
Set cell2 = New EmpCell
cell2.EmpName = "Кэтс”
cell2.SSN = "123-45-6789"
cell2.JobTitle = "Юрист"
Set cell3 = New EmpCell
cell3.EmpName = "Туле”
cell3.SSN = "123-45-6789"
cell3.JobTitle = "Менеджер"
‘ Соединить ячейки, образуя связный список.
Set cell1.NextCell = cell2
Set cell2.NextCell = cell3
Set cell3.NextCell = Nothing
‘ Сохранить ссылку на вершину списка.
Set top_cell = cell1
===============27
На рис. 2.2 показано схематическое представление этого связного списка. Прямоугольники представляют ячейки, а стрелки — ссылки на объекты. Маленький перечеркнутый прямоугольник представляет значение Nothing, которое обозначает конец списка. Имейте в виду, что top_cell, cell1 и cell2 – это не настоящие объекты, а только ссылки, которые указывают на них.
Следующий код использует связный список, построенный при помощи предыдущего примера для печати имен сотрудников из списка. Переменная ptr используется в качестве указателя на элементы списка. Она первоначально указывает на вершину списка. В коде используется цикл Do для перемещения ptr по списку до тех пор, пока указатель не дойдет до конца списка. Во время каждого цикла, процедура печатает поле EmpName ячейки, на которую указывает ptr. Затем она увеличивает ptr, указывая на следующую ячейку в списке. В конце концов, ptr достигает конца списка и получает значение Nothing, и цикл Do останавливается.
Dim ptr As EmpCell
Set ptr = top_cell ‘ Начать с вершины списка.
Do While Not (ptr Is Nothing)
‘ Вывести поле EmpName этой ячейки.
Debug.Print ptr.Empname
‘ Перейти к следующей ячейке в списке.
Set ptr = ptr.NextCell
Loop
После выполнения кода вы получите следующий результат:
Стивенс
Кэтс
Туле
@Рис. 2.2. Связный список
=======28
Использование указателя на другой объект называется косвенной адресацией (indirection), поскольку вы используете указатель для косвенного манипулирования данными. Косвенная адресация может быть очень запутанной. Даже для простого расположения элементов, такого, как связный список, иногда трудно запомнить, на какой объект указывает каждая ссылка. В более сложных структурах данных, указатель может ссылаться на объект, содержащий другой указатель. Если есть несколько указателей и несколько уровней косвенной адресации, вы легко можете запутаться в них
Для того, чтобы облегчить понимание, в изложении используются иллюстрации, такие как рис. 2.2,(для сетевой версии исключены, т.к. они многократно увеличивают размер загружаемого файла) чтобы помочь вам наглядно представить ситуацию там, где это возможно. Многие из алгоритмов, которые используют указатели, можно легко проиллюстрировать подобными рисунками.
Простой связный список, показанный на рис. 2.2, обладает несколькими важными свойствами. Во‑первых, можно очень легко добавить новую ячейку в начало списка. Установим указатель новой ячейки NextCell на текущую вершину списка. Затем установим указатель top_cell на новую ячейку. Рис. 2.3 соответствует этой операции. Код на языке Visual Basic для этой операции очень прост:
Set new_cell.NextCell = top_cell
Set top_cell = new_cell
@Рис. 2.3. Добавление элемента в начало связного списка
Сравните размер этого кода и кода, который пришлось бы написать для добавления нового элемента в начало списка, основанного на массиве, в котором потребовалось бы переместить все элементы массива на одну позицию, чтобы освободить место для нового элемента. Эта операция со сложностью порядка O(N) может потребовать много времени, если список достаточно длинный. Используя связный список, моно добавить новый элемент в начало списка всего за пару шагов.
======29
Так же легко добавить новый элемент и в середину связного списка. Предположим, вы хотите вставить новый элемент после ячейки, на которую указывает переменная after_me. Установим значение NextCell новой ячейки равным after_me.NextCell. Теперь установим указатель after_me.NextCell на новую ячейку. Эта операция показана на рис. 2.4. Код на Visual Basic снова очень прост:
Set new_cell.NextCell = after_me.NextCell
Set after_me.NextCell = new_cell
Удалить элемент из вершины связного списка так же просто, как и добавить его. Просто установите указатель top_cell на следующую ячейку в списке. Рис. 2.5 соответствует этой операции. Исходный код для этой операции еще проще, чем код для добавления элемента.
Set top_cell = top_cell.NextCell
Когда указатель top_cell перемещается на второй элемент в списке, в программе больше не останется переменных, указывающих на первый объект. В этом случае, счетчик ссылок на этот объект станет равен нулю, и система автоматически уничтожит его.
Так же просто удалить элемент из середины списка. Предположим, вы хотите удалить элемент, стоящий после ячейки after_me. Просто установите указатель NextCell этой ячейки на следующую ячейку. Эта операция показана на рис. 2.6. Код на Visual Basic прост и понятен:
after_me.NextCell = after_me.NextCell.NextCell
@Рис. 2.4. Добавление элемента в середину связного списка
=======30
@Рис. 2.5. Удаление элемента из начала связного списка
Снова сравним этот код с кодом, который понадобился бы для выполнения той же операции, при использовании списка на основе массива. Можно быстро пометить удаленный элемент как неиспользуемый, но это оставляет в списке ненужные значения. Процедуры, обрабатывающие список, должны это учитывать, и соответственно быть более сложными. Присутствие чрезмерного количества «мусора» также замедляет работу процедуры, и, в конце концов, придется проводить чистку памяти.
При удалении элемента из связного списка, в нем не остается пустых промежутков. Процедуры, которые обрабатывают список, все так же обходят список с начала до конца, и не нуждаются в модификации.
Можно предположить, что для уничтожения связного списка необходимо обойти весь список, устанавливая значение NextCell для всех ячеек равным Nothing. На самом деле процесс гораздо проще: только top_cell принимает значение Nothing.
Когда программа устанавливает значение top_cell равным Nothing, счетчик ссылок для первой ячейки становится равным нулю, и Visual Basic уничтожает эту ячейку.
Во время уничтожения ячейки, система определяет, что в поле NextCell этой ячейки содержится ссылка на другую ячейку. Поскольку первый объект уничтожается, то число ссылок на второй объект уменьшается. При этом счетчик ссылок на второй объект списка становится равным нулю, поэтому система уничтожает и его.
Во время уничтожения второго объекта, система уменьшает число ссылок на третий объект, и так далее до тех пор, пока все объекты в списке не будут уничтожены. Когда в программе уже не будет ссылок на объекты списка, можно уничтожить и весь список при помощи единственного оператора Set top_cell = Nothing.
@Рис. 2.6. Удаление элемента из середины связного списка
========31
Для добавления или удаления элементов из начала или середины списка используются различные процедуры. Можно свести оба этих случая к одному и избавиться от избыточного кода, если ввести специальную сигнальную метку (sentinel) в самом начале списка. Сигнальную метку нельзя удалить. Она не содержит данных и используется только для обозначения начала списка.
Теперь вместо того, чтобы обрабатывать особый случай добавления элемента в начало списка, можно помещать элемент после метки. Таким же образом, вместо особого случая удаления первого элемента из списка, просто удаляется элемент, следующий за меткой.
Использование сигнальных меток пока не вносит особых различий. Сигнальные метки играют важную роль в гораздо более сложных алгоритмах. Они позволяют обрабатывать особые случаи, такие как начало списка, как обычные. При этом требуется написать и отладить меньше кода, и алгоритмы становятся более согласованными и более простыми для понимания.
В табл. 2.1 сравнивается сложность выполнения некоторых типичных операций с использованием списков на основе массивов со «сборкой мусора» или связных списков.
Списки на основе массивов имеют одно преимущество: они используют меньше памяти. Для связных списков необходимо добавить поле NextCell к каждому элементу данных. Каждая ссылка на объект занимает четыре дополнительных байта памяти. Для очень больших массивов это может потребовать больших затрат памяти.
Программа LnkList1 демонстрирует простой связный список с сигнальной меткой. Введите значение в текстовое поле ввода, и нажмите на элемент в списке или на метку. Затем нажмите на кнопку Add After (Добавить после), и программа добавит новый элемент после указанного. Для удаления элемента из списка, нажмите на элемент и затем на кнопку Remove After (Удалить после).
@Таблица 2.1. Сравнение списков на основе массивов и связных списков
=========32
Программа LnkList1 управляет списком явно. Например, следующий код показывает, как программа удаляет элемент из списка. Когда подпрограмма начинает работу, глобальная переменная SelectedIndex дает положение элемента, предшествующего удаляемому элементу в списке. Переменная Sentinel содержит ссылку на сигнальную метку списка.
Private Sub CmdRemoveAfter_Click()
Dim ptr As ListCell
Dim position As Integer
If SelectedIndex < 0 Then Exit Sub
‘ Найти элемент.
Set ptr = Sentinel
position = SelectedIndex
Do While position > 0
position = position - 1
Set ptr = ptr.nextCell
Loop
‘ Удалить следуюший элемент.
Set ptr.NextCell = ptr.NextCell.NextCell
NumItems = NumItems - 1
SelectItem SelectedIndex ‘ Снова выбрать элемент.
DisplayList
NewItem.SetFocus
End Sub
Чтобы упростить использование связного списка, можно инкапсулировать его функции в классе. Это реализовано в программе LnkList2 . Она аналогична программе LnkList1, но использует для управления списком класс LinkedList.
Класс LinekedList управляет внутренней организацией связного списка. В нем находятся процедуры для добавления и удаления элементов, возвращения значения элемента по его индексу, числа элементов в списке, и очистки списка. Этот класс позволяет обращаться со связным списком почти как с массивом.
Это намного упрощает основную программу. Например, следующий код показывает, как программа LnkList2 удаляет элемент из списка. Только одна строка в программе в действительности отвечает за удаление элемента. Остальные отображают новый список. Сравните этот код с предыдущей процедурой:
Private sub CmdRemoveAfter_Click()
Llist.RemoveAfter SelectedIndex
SelectedItem SelectedList ‘ Снова выбрать элемент.
DisplayList
NewItem.SetFocus
CmdClearList.Enabled
End Sub
=====33
Класс LinkedList, используемый программой LnkLst2, позволяет основной программе использовать список почти как массив. Например, подпрограмма Item, приведенная в следующем коде, возвращает значение элемента по его положению:
Function Item(ByVal position As Long) As Variant
Dim ptr As ListCell
If position < 1 Or position > m_NumItems Then
‘ Выход за границы. Вернуть NULL.
Item = Null
Exit Function
End If
‘ Найти элемент.
Set ptr = m_Sentinel
Do While position > 0
position = position - 1
Set ptr = ptr.NextCell
Loop
Item = ptr.Value
End Function
Эта процедура достаточно проста, но она не использует преимущества связной структуры списка. Например, предположим, что программе требуется последовательно перебрать все объекты в списке. Она могла бы использовать подпрограмму Item для поочередного доступа к ним, как показано в следующем коде:
Dim i As Integer
For i = 1 To LList.NumItems
‘ Выполнить какие‑либо действия с LList.Item(i).
:
Next i
При каждом вызове процедуры Item, она просматривает список в поиске следующего элемента. Чтобы найти элемент I, программа должна пропустить I‑1 элементов. Чтобы проверить все элементы в списке из N элементов, процедура пропустит 0+1+2+3+…+N-1 =N*(N-1)/2 элемента. При больших N программа потеряет много времени на пропуск элементов.
Класс LinkedList может ускорить эту операцию, используя другой метод доступа. Можно использовать частную переменную m_CurrentCell для отслеживания текущей позиции в списке. Для возвращения значения текущего положения используется подпрограмма CurrentItem. Процедуры MoveFirst, MoveNext и EndOfList позволяют основной программе управлять текущей позицией в списке.
=======34
Например, следующий код содержит подпрограмму MoveNext:
Public Sub MoveNext()
‘ Если текущая ячейка не выбрана, ничего не делать.
If Not (m_CurrentCell Is Nothing) Then _
Set m_CurrentCell = m_CurrentCell.NextCell
End Sub
При помощи этих процедур, основная программа может обратиться ко всем элементам списка, используя следующий код. Эта версия несколько сложнее, чем предыдущая, но она намного эффективнее. Вместо того чтобы пропускать N*(N-1)/2 элементов и опрашивать по очереди все N элементов списка, она не пропускает ни одного. Если список состоит из 1000 элементов, это экономит почти полмиллиона шагов.
LList.MoveFirst
Do While Not LList.EndOfList
‘ Выполнить какие‑либо действия над элементом LList.Item(i).
:
LList.MoveNext
Loop
Программа LnkList3 использует эти новые методы для управления связным списком. Она аналогична программе LnkList2, но более эффективно обращается к элементам. Для небольших списков, используемых в программе, эта разница незаметна. Для программы, которая обращается ко всем элементам большого списка, эта версия класса LinkedList более эффективна.
Связные списки играют важную роль во многих алгоритмах, и вы будете встречаться с ними на протяжении всего материала. В следующих разделах обсуждаются несколько специальных разновидностей связных списков.
Вместо того, чтобы устанавливать указатель NextCell равным Nothing, можно установить его на первый элемент списка, образуя циклический список (circular list), как показано на рис. 2.7.
Циклические списки полезны, если нужно обходить ряд элементов в бесконечном цикле. При каждом шаге цикла, программа просто перемещает указатель на следующую ячейку в списке. Допустим, имеется циклический список элементов, содержащий названия дней недели. Тогда программа могла бы перечислять дни месяца, используя следующий код:
===========35
@Рис. 2.7. Циклический связный список
‘ Здесь находится код для создания и настройки списка и т.д.
:
‘ Напечатать календарь на месяц.
‘
‘ first_day — это индекс структуры, содержащей день недели для
‘ первого дня месяца. Например, месяц может начинаться
‘ в понедельник.
‘
‘ num_days — число дней в месяце.
Private Sub ListMonth(first_day As Integer, num_days As Integer)
Dim ptr As ListCell
Dim i As Integer
Set ptr = top_cell
For i = 1 to num_days
Print Format$(i) & ": " & ptr.Value
Set ptr = ptr.NextCell
Next I
End Sub
Циклические списки также позволяют достичь любой точки в списке, начав с любого положения в нем. Это вносит в список привлекательную симметрию. Программа может обращаться со всеми элементами списка почти одинаковым образом:
Private Sub PrintList(start_cell As Integer)
Dim ptr As Integer
Set ptr = start_cell
Do
Print ptr.Value
Set ptr = ptr.NextCell
Loop While Not (ptr Is start_cell)
End Sub
========36
Уничтожение циклического списка требует немного больше внимания, чем удаление обычного списка. Если вы просто установите значение переменной top_cell равным Nothing, то программа не сможет больше обратиться к списку. Тем не менее, поскольку счетчик ссылок первой ячейки не равен нулю, она не будет уничтожена. На каждый элемент списка указывает какой‑либо другой элемент, поэтому ни один из них не будет уничтожен.
Это проблема циклических ссылок (circular referencing problem). Так как ячейки указывают на другие ячейки, ни одна из них не будет уничтожена. Программа не может получить доступ ни к одной из них, поэтому занимаемая ими память будет расходоваться напрасно до завершения работы программы.
Проблема циклических ссылок может встретиться не только в этом случае. Многие сети содержат циклические ссылки — даже одиночная ячейка, поле NextCell которой указывает на саму эту ячейку, может вызвать эту проблему.
Решение ее состоит в том, чтобы разбить цепь ссылок. Например, вы можете использовать в своей программе следующий код для уничтожения циклического связного списка:
Set top_cell.NextCell = Nothing
Set top_cell = Nothing
Первая строка разбивает цикл ссылок. В этот момент на вторую ячейку списка не указывает ни одна переменная, поэтому система уменьшает счетчик ссылок ячейки до нуля и уничтожает ее. Это уменьшает счетчик ссылок на третий элемент до нуля, и соответственно, он также уничтожается. Этот процесс продолжается до тех пор, пока не будут уничтожены все элементы списка, кроме первого. Установка значения top_cell элемента в Nothing уменьшает его счетчик ссылок до нуля, и последняя ячейка также уничтожается.
Во время обсуждения связных списков вы могли заметить, что большинство операций определялось в терминах выполнения чего‑либо после определенной ячейки в списке. Если задана определенная ячейка, легко добавить или удалить ячейку после нее или перечислить идущие за ней ячейки. Удалить саму ячейку, вставить новую ячейку перед ней или перечислить идущие перед ней ячейки уже не так легко. Тем не менее, небольшое изменение позволит облегчить и эти операции.
Добавим новое поле указателя к каждой ячейке, которое указывает на предыдущую ячейку в списке. Используя это новое поле, можно легко создать двусвязный список (doubly linked list), который позволяет перемещаться вперед и назад по списку. Теперь можно легко удалить ячейку, вставить ее перед другой ячейкой и перечислить ячейки в любом направлении.
@Рис. 2.8. Двусвязный список
============37
Класс DoubleListCell, который используется для таких типов списков, может объявлять переменные так:
Public Value As Variant
Public NextCell As DoubleListCell
Public PrevCell As DoubleListCell
Часто бывает полезно сохранять указатели и на начало, и на конец двусвязного списка. Тогда вы сможете легко добавлять элементы к любому из концов списка. Иногда также бывает полезно размещать сигнальные метки и в начале, и в конце списка. Тогда по мере работы со списком вам не нужно будет заботиться о том, работаете ли вы с началом, с серединой или с концом списка.
На рис. 2.9 показан двусвязный список с сигнальными метками. На этом рисунке неиспользуемые указатели меток NextCell и PrevCell установлены в Nothing. Поскольку программа опознает концы списка, сравнивая значения указателей ячеек с сигнальными метками, и не проверяет, равны ли значения Nothing, установка этих значений равными Nothing не является абсолютно необходимой. Тем не менее, это признак хорошего стиля.
Код для вставки и удаления элементов из двусвязного списка подобен приведенному ранее коду для односвязного списка. Процедуры нуждаются лишь в незначительных изменениях для работы с указателями PrevCell.
@Рис. 2.9. Двусвязный список с сигнальными метками
Теперь вы можете написать новые процедуры для вставки нового элемента до или после данного элемента, и процедуру удаления заданного элемента. Например, следующие подпрограммы добавляют и удаляют ячейки из двусвязного списка. Заметьте, что эти процедуры не нуждаются в доступе ни к одной из сигнальных меток списка. Им нужны только указатели на узел, который должен быть удален или добавлен и узел, соседний с точкой вставки.
Public Sub RemoveItem(ByVal target As DoubleListCell)
Dim after_target As DoubleListCell
Dim before_target As DoubleListCell
Set after_target = target.NextCell
Set before_target = target.PrevCell
Set after_target.NextCell = after_target
Set after_target.PrevCell = before_target
End Sub
Sub AddAfter (new_Cell As DoubleListCell, after_me As DoubleListCell)
Dim before_me As DoubleListCell
Set before_me = after_me.NextCell
Set after_me.NextCell = new_cell
Set new_cell.NextCell = before_me
Set before_me.PrevCell = new_cell
Set new_cell.PrevCell = after_me
End Sub
Sub AddBefore(new_cell As DoubleListCell, before_me As DoubleListCell)
Dim after_me As DoubleListCell
Set after_me = before_me.PrevCell
Set after_me.NextCell = new_cell
Set new_cell.NextCell = before_me
Set before_me.PrevCell = new_cell
Set new_cell.PrevCell = after_me
End Sub
===========39
Если снова взглянуть на рис. 2.9, вы увидите, что каждая пара соседних ячеек образует циклическую ссылку. Это делает уничтожение двусвязного списка немного более сложной задачей, чем уничтожение односвязных или циклических списков. Следующий код приводит один из способов очистки двусвязного списка. Вначале указатели PrevCell всех ячеек устанавливаются равными Nothing, чтобы разорвать циклические ссылки. Это, по существу, превращает список в односвязный. Когда ссылки сигнальных меток устанавливаются в Nothing, все элементы освобождаются автоматически, так же как и в односвязном списке.
Dim ptr As DoubleListCell
' Очистить указатели PrevCell, чтобы разорвать циклические ссылки.
Set ptr = TopSentinel.NextCell
Do While Not (ptr Is BottomSentinel)
Set ptr.PrevCell = Nothing
Set ptr = ptr.NextCell
Loop
Set TopSentinel.NextCell = Nothing
Set BottomSentinel.PrevCell = Nothing
Если создать класс, инкапсулирующий двусвязный список, то его обработчик события Terminate сможет уничтожать список. Когда основная программа установит значение ссылки на список равным Nothing, список автоматически освободит занимаемую память.
Программа DblLink работает с двусвязным списком. Она позволяет добавлять элементы до или после выбранного элемента, а также удалять выбранный элемент.
=============39
В некоторых приложениях бывает удобно обходить связный список не только в одном порядке. В разных частях приложения вам может потребоваться выводить список сотрудников по их фамилиям, заработной плате, идентификационному номеру системы социального страхования, или специальности.
Обычный связный список позволяет просматривать элементы только в одном порядке. Используя указатель PrevCell, можно создать двусвязный список, который позволит перемещаться по списку вперед и назад. Этот подход можно развить и дальше, добавив больше указателей на структуру данных, позволяя выводить список в другом порядке.
Набор ссылок, который задает какой‑либо порядок просмотра, называется потоком (thread), а сам полученный список — многопоточным списком (threaded list). Не путайте эти потоки с потоками, которые предоставляет система Windows NT.
Список может содержать любое количество потоков, хотя, начиная с какого‑то момента, игра не стоит свеч. Применение потока, упорядочивающего список сотрудников по фамилии, будет обосновано, если ваше приложение часто использует этот порядок, в отличие от расположения по отчеству, которое вряд ли когда будет использоваться.
Некоторые расположения не стоит организовывать в виде потоков. Например, поток, упорядочивающий сотрудников по полу, вряд ли целесообразен потому, что такое упорядочение легко получить и без него. Для того, чтобы составить список сотрудников по полу, достаточно просто обойти список по любому другому потоку, печатая фамилии женщин, а затем повторить обход еще раз, печатая фамилии мужчин. Для получения такого расположения достаточно всего двух проходов списка.
Сравните этот случай с тем, когда вы хотите упорядочить список сотрудников по фамилии. Если список не включает поток фамилий, вам придется найти фамилию, которая будет первой в списке, затем следующую и т.д. Это процесс со сложностью порядка O(N2), который намного менее эффективен, чем сортировка по полу со сложностью порядка O(N).
В общем случае, задание потока может быть целесообразно, если его необходимо часто использовать, и если при необходимости получить тот же порядок достаточно сложно. Поток не нужен, если его всегда легко создать заново.
Программа Treads демонстрирует простой многопоточный список сотрудников. Заполните поля фамилии, специальности, пола и номера социального страхования для нового сотрудника. Затем нажмите на кнопку Add (Добавить), чтобы добавить сотрудника к списку.
Программа содержит потоки, которые упорядочивают список по фамилии по алфавиту и в обратном порядке, по номеру социального страхования и специальности в прямом и обратном порядке. Вы можете использовать дополнительные кнопки для выбора потока, в порядке которого программа выводит список. На рис. 2.10 показано окно программы Threads со списком сотрудников, упорядоченным по фамилии.
Класс ThreadedCell, используемый программой Threads, определяет следующие переменные:
Public LastName As String
Public FirstName As String
Public SSN As String
Public Sex As String
Public JobClass As Integer
Public NextName As TreadedCell ‘ По фамилии в прямом порядке.
Public PrevName As TreadedCell ‘ По фамилии в обратном порядке.
Public NextSSN As TreadedCell ‘ По номеру в прямом порядке.
Public NextJobClass As TreadedCell ‘ По специальности в прямом порядке.
Public PrevJobClass As TreadedCell ‘ По специальности в обратном порядке.
Класс ThreadedList инкапсулирует многопоточный список. Когда программа вызывает метод AddItem, список обновляет свои потоки. Для каждого потока программа должна вставить элемент в правильном порядке. Например, для того, чтобы вставить запись с фамилией «Смит», программа обходит список, используя поток NextName, до тех пор, пока не найдет элемент с фамилией, которая должна следовать за «Смит». Затем она вставляет в поток NextName новую запись перед этим элементом.
При определении местоположения новых записей в потоке важную роль играют сигнальные метки. Обработчик событий Class_Initialize класса ThreadedList создает сигнальные метки на вершине и в конце списка и инициализирует их указатели так, чтобы они указывали друг на друга. Затем значение метки в начале списка устанавливается таким образом, чтобы оно всегда находилось до любого значения реальных данных для всех потоков.
Например, переменная LastName может содержать строковые значения. Пустая строка "" идет по алфавиту перед любыми действительными значениями строк, поэтому программа устанавливает значение сигнальной метки LastName в начале списка равным пустой строке.
Таким же образом Class_Initialize устанавливает значение данных для метки в конце списка, превосходящее любые реальные значения во всех потоках. Поскольку "~" идет по алфавиту после всех видимых символов ASCII, программа устанавливает значение поля LastName для метки в конце списка равным "~".
Присваивая полю LastName сигнальных меток значения "" и "~", программа избавляется от необходимости проверять особые случаи, когда нужно вставить новый элемент в начало или конец списка. Любые новые действительные значения будут находиться между значениями LastValue сигнальных меток, поэтому программа всегда сможет определить правильное положение для нового элемента, не заботясь о том, чтобы не зайти за концевую метку и не выйти за границы списка.
@Рис. 2.10. Программа Threads
=====41
Следующий код показывает, как класс ThreadedList вставляет новый элемент в потоки NextName и PrevName. Так как эти потоки используют один и тот же ключ — фамилии, программа может обновлять их одновременно.
Dim ptr As ThreadedCell
Dim nxt As ThreadedCell
Dim new_cell As New ThreadedCell
Dim new_name As String
Dim next_name As String
' Записать значения новой ячейки.
With new_cell
.LastName = LastName
.FirstName = FirstName
.SSN = SSN
•Sex = Sex
.JobClass = JobClass
End With
' Определить место новой ячейки в потоке NextThread.
new_name = LastName & ", " & FirstName
Set ptr = m_TopSentinel
Do
Set nxt = ptr.NextName
next_name = nxt.LastName & ", " & nxt.FirstName
If next_name >= new_name Then Exit Do
Set ptr = nxt
Loop
' Вставить новую ячейку в потоки NextName и prevName.
Set new_cell.NextName = nxt
Set new_cell.PrevName = ptr
Set ptr.NextName = new_cell
Set nxt.PrevName = new_cell
Чтобы такой подход работал, программа должна гарантировать, что значения новой ячейки лежат между значениями меток. Например, если пользователь введет в качестве фамилии "~~", цикл выйдет за метку конца списка, т.к. "~~" идет после "~". Затем программа аварийно завершит работу при попытке доступа к значению nxt.LastName, если nxt было установлено равным Nothing.
========42
Используя указатели, можно построить множество других полезных разновидностей связных структур, таких как деревья, нерегулярные массивы, разреженные массивы, графы и сети. Ячейка может содержать любое число указателей на другие ячейки. Например, для создания двоичного дерева можно использовать ячейку, содержащую два указателя, один на левого потомка, и второй – на правого. Класс BinaryCell может состоять из следующих определений:
Public LeftChild As BinaryCell
Public RightChild As BinaryCell
На рис. 2.11 показано дерево, построенное из ячеек такого типа. В 6 главе деревья обсуждаются более подробно.
Ячейка может даже содержать коллекцию или связный список с указателями на другие ячейки. Это позволяет программе связать ячейку с любым числом других объектов. На рис. 2.12 приведены примеры других связных структур данных. Вы также встретите похожие структуры далее, в особенности в 12 главе.
При помощи ссылок в Visual Basic можно легко создавать связные структуры, такие как списки, деревья и сети, но ссылки требуют дополнительных ресурсов. Счетчики ссылок и проблемы с распределением памяти замедляют работу структур данных, построенных с использованием ссылок.
Другой стратегией, которая часто обеспечивает лучшую производительность, является применение псевдоуказателей (fake pointers). При этом программа создает массив структур данных. Вместо использования ссылок для связывания структур, программа использует индексы массива. Нахождение элемента в массиве осуществляется в Visual Basic быстрее, чем выборка его по ссылке на объект. Это дает лучшую производительность при применении псевдоуказателей по сравнению с соответствующими методами ссылок на объекты.
С другой стороны, применение псевдоуказателей не столь интуитивно, как применение ссылок. Это может усложнить разработку и отладку сложных алгоритмов, таких как алгоритмы сетей или сбалансированных деревьев.
@Рис. 2.11. Двоичное дерево
========43
@Рис. 2.12. Связные структуры
Программа FakeList управляет связным списком, используя псевдоуказатели. Она создает массив простых структур данных для хранения ячеек списка. Программа аналогична программе LnkList1, но использует псевдоуказатели.
Следующий код демонстрирует, как программа FakeList создает массив клеточных структур:
' Структура данных ячейки.
Type FakeCell
Value As String
NextCell As Integer
End Type
' Массив ячеек связного списка.
Global Cells(0 To 100) As FakeCell
' Сигнальная метка списка.
Global Sentinel As Integer
Поскольку псевдоуказатели — это не ссылки, а просто целые числа, программа не может использовать значение Nothing для маркировки конца списка. Программа FakeList использует постоянную END_OF_LIST, значение которой равно -32.767 для обозначения пустого указателя.
Для облегчения обнаружения неиспользуемых ячеек, программа FakeList также использует специальный «мусорный» список, содержащий неиспользуемые ячейки. Следующий код демонстрирует инициализацию пустого связного списка. В нем сигнальная метка NextCell принимает значение END_OF_LIST. Затем она помещает неиспользуемые ячейки в «мусорный» список.
========44
' Связный список неиспользуемых ячеек.
Global TopGarbage As Integer
Public Sub InitializeList()
Dim i As Integer
Sentinel = 0
Cells(Sentinel).NextCell = END_OF_LIST
' Поместить все остальные ячейки в «мусорный» список.
For i = 1 To UBound (Cells) - 1
Cells(i).NextCell = i + 1
Next i
Cells(UBound(Cells)).NextCell = END_OF_LIST
TopGarbage = 1
End Sub
При добавлении элемента к связному списку, программа использует первую доступную ячейку из «мусорного» списка, инициализирует поле ячейки Value и вставляет ячейку в список. Следующий код показывает, как программа добавляет элемент после выбранного:
Private Sub CmdAddAfter_Click()
Dim ptr As Integer
Dim position As Integer
Dim new_cell As Integer
' Найти место вставки.
ptr = Sentinel
position = Selectedlndex
Do While position > 0
position = position - 1
ptr = Cells(ptr).NextCell
Loop
' Выбрать новую ячейку из «мусорного» списка.
new_cell = TopGarbage
TopGarbage = Cells(TopGarbage).NextCell
' Вставить элемент.
Cells (new_cell).Value = NewItem.Text
Cells(new_cell).NextCell = Cells(ptr).NextCell
Cells(ptr).NextCell = new_cell
NumItems = NumItems + 1
DisplayList
SelectItem SelectedIndex + 1 ' Выбрать новый элемент.
NewItem.Text = ""
NewItem.SetFocus
CmdClearList.Enabled = True
End Sub
После удаления ячейки из списка, программа FakeList помещает удаленную ячейку в «мусорный» список, чтобы ее затем можно было легко использовать:
Private Sub CmdRemoveAfter_Click()
Dim ptr As Integer
Dim target As Integer
Dim position As Integer
If SelectedIndex < 0 Then Exit Sub
' Найти элемент.
ptr = Sentinel
position = SelectedIndex
Do While position > 0
position = position - 1
ptr = Cells(ptr).NextCell
Loop
' Пропустить следующий элемент.
target = Cells(ptr).NextCell
Cells(ptr).NextCell = Cells(target).NextCell
NumItems = NumItems - 1
' Добавить удаленную ячейку в «мусорный» список.
Cells(target).NextCell = TopGarbage
TopGarbage = target
SelectItem Selectedlndex ' Снова выбрать элемент.
DisplayList
CmdClearList.Enabled = NumItems > 0
NewItem.SetFocus
End Sub
Применение псевдоуказателей обычно обеспечивает лучшую производительность, но является более сложным. Поэтому имеет смысл сначала создать приложение, используя ссылки на объекты. Затем, если вы обнаружите, что программа значительную часть времени тратит на манипулирование ссылками, вы можете, если необходимо, преобразовать ее с использованием псевдоуказателей.
=======45-46
Используя ссылки на объекты, вы можете создавать гибкие структуры данных, такие как связные списки, циклические связные списки и двусвязные списки. Эти списки позволяют легко добавлять и удалять элементы из любого места списка.
Добавляя дополнительные ссылки к классу ячеек, можно превратить двусвязный список в многопоточный. Развивая и дальше эти идеи, можно создавать экзотические структуры данных, включая разреженные массивы, деревья, хэш‑таблицы и сети. Они подробно описываются в следующих главах.
========47
Глава 3. Стеки и очередиВ этой главе продолжается обсуждение списков, начатое во 2 главе, и описываются две особых разновидности списков: стеки и очереди. Стек — это список, в котором добавление и удаление элементов осуществляется с одного и того же конца списка. Очередь — это список, в котором элементы добавляются в один конец списка, а удаляются с противоположного конца. Многие алгоритмы, включая некоторые из представленных в следующих главах, используют стеки и очереди.
Стек (stack) — это упорядоченный список, в котором добавление и удаление элементов всегда происходит на одном конце списка. Можно представить стек как стопку предметов на полу. Вы можете добавлять элементы на вершину и удалять их оттуда, но не можете добавлять или удалять элементы из середины стопки.
Стеки часто называют списками типа первый вошел — последний вышел (Last‑In‑First‑Out list). По историческим причинам, добавление элемента в стек называется проталкиванием (pushing) элемента в стек, а удаление элемента из стека — выталкиванием (popping) элемента из стека.
Первая реализация простого списка на основе массива, описанная в начале 2 главы, является стеком. Для отслеживания вершины списка используется счетчик. Затем этот счетчик используется для вставки или удаления элемента из вершины списка. Небольшое изменение — это новая процедура Pop, которая удаляет элемент из списка, одновременно возвращая его значение. При этом другие процедуры могут извлекать элемент и удалять его из списка за один шаг. Кроме этого изменения, следующий код совпадает с кодом, приведенным во 2 главе.
Dim Stack() As Variant
Dim StackSize As Variant
Sub Push(value As Variant)
StackSize = StackSize + 1
ReDim Preserve Stack(1 To StackSize)
Stack(StackSize) = value
End Sub
Sub Pop(value As Variant)
value = Stack(StackSize)
StackSize = StackSize - 1
ReDim Preserve Stack(1 To StackSize)
End Sub
=====49
Все предыдущие рассуждения о списках также относятся к этому виду реализации стеков. В частности, можно сэкономить время, если не изменять размер при каждом добавлении или выталкивании элемента. Программа SimList на описанная во 2 главе, демонстрирует этот вид простой реализации списков.
Программы часто используют стеки для хранения последовательности элементов, с которыми программа будет работать до тех пор, пока стек не опустеет. Действия с одним из элементов может приводить к тому, что другие будут проталкиваться в стек, но, в конце концов, они все будут удалены из стека. В качестве простого примера можно привести алгоритм обращения порядка элементов массива. При этом все элементы последовательно проталкиваются в стек. Затем все элементы выталкиваются из стека в обратном порядке и записываются обратно в массив.
Dim List() As Variant
Dim NumItems As Integer
' Инициализация массива.
:
' Протолкнуть элементы в стек.
For I = 1 To NumItems
Push List(I)
Next I
' Вытолкнуть элементы из стека обратно в массив.
For I = 1 To NumItems
Pop List(I)
Next I
В этом примере, длина стека может многократно изменяться до того, как, в конце концов, он опустеет. Если известно заранее, насколько большим должен быть массив, можно сразу создать достаточно большой стек. Вместо изменения размера стека по мере того, как он растет и уменьшается, можно отвести под него память в начале работы и уничтожить его после ее завершения.
Следующий код позволяет создать стек, если заранее известен его максимальный размер. Процедура Pop не изменяет размер массива. Когда программа заканчивает работу со стеком, она должна вызвать процедуру EmptyStack для освобождения занятой под стек памяти.
======50
Const WANT_FREE_PERCENT = .1 ' 10% свободного пространства.
Const MIN_FREE = 10 ' Минимальный размер.
Global Stack() As Integer ' Стековый массив.
Global StackSize As Integer ' Размер стекового массива.
Global Lastltem As Integer ' Индекс последнего элемента.
Sub PreallocateStack(entries As Integer)
StackSize = entries
ReDim Stack(1 To StackSize)
End Sub
Sub EmptyStack()
StackSize = 0
LastItem = 0
Erase Stack ' Освободить память, занятую массивом.
End Sub
Sub Push(value As Integer)
LastItem = LastItem + 1
If LastItem > StackSize Then ResizeStack
Stack(LastItem) = value
End Sub
Sub Pop(value As Integer)
value = Stack(LastItem)
LastItem = LastItem - 1
End Sub
Sub ResizeStack()
Dim want_free As Integer
want_free = WANT_FREE_PERCENT * LastItem
If want_free < MIN_FREE Then want_free = MIN_FREE
StackSize = LastItem + want_free
ReDim Preserve Stack(1 To StackSize)
End Sub
Этот вид реализации стеков достаточно эффективен в Visual Basic. Стек не расходует понапрасну память, и не слишком часто изменяет свой размер, особенно если сразу известно, насколько большим он должен быть.
=======51
В одном массиве можно создать два стека, поместив один в начале массива, а другой — в конце. Для двух стеков используются отдельные счетчики длины стека Top, и стеки растут навстречу друг другу, как показано на рис. 3.1. Этот метод позволяет двум стекам расти, занимая одну и ту же область памяти, до тех пор, пока они не столкнутся, когда массив заполнится.
К сожалению, менять размер этих стеков непросто. При увеличении массива необходимо сдвигать все элементы в верхнем стеке, чтобы выделять память под новые элементы в середине. При уменьшении массива, необходимо вначале сдвинуть элементы верхнего стека, перед тем, как менять размер массива. Этот метод также сложно масштабировать для оперирования более чем двумя стеками.
Связные списки предоставляют более гибкий метод построения множественных стеков. Для проталкивания элемента в стек, он помещается в начало связного списка. Для выталкивания элемента из стека, удаляется первый элемент из связного списка. Так как элементы добавляются и удаляются только в начале списка, для реализации стеков такого типа не требуется применение сигнальных меток или двусвязных списков.
Основной недостаток применения стеков на основе связных списков состоит в том, что они требуют дополнительной памяти для хранения указателей NextCell. Для стека на основе массива, содержащего N элементов, требуется всего 2*N байт памяти (по 2 байта на целое число). Тот же стек, реализованный на основе связного списка, потребует дополнительно 4*N байт памяти для указателей NextCell, увеличивая размер необходимой памяти втрое.
Программа Stack использует несколько стеков, реализованных в виде связных списков. Используя программу, можно вставлять и выталкивать элементы из каждого из этих списков. Программа Stack2 аналогична этой программе, но она использует класс LinkedListStack для работы со стеками.
Упорядоченный список, в котором элементы добавляются к одному концу списка, а удаляются с другой стороны, называется очередью (queue). Группа людей, ожидающих обслуживания в магазине, образует очередь. Вновь прибывшие подходят сзади. Когда покупатель доходит до начала очереди, кассир его обслуживает. Из‑за их природы, очереди иногда называют списками типа первый вошел — первый вышел (First‑In‑First‑Out list).
@Рис. 3.1. Два стека в одном массиве
=======52
Можно реализовать очереди в Visual Basic, используя методы типа использованных для организации простых стеков. Создадим массив, и при помощи счетчиков будем определять положение начала и конца очереди. Значение переменной QueueFront дает индекс элемента в начале очереди. Переменная QueueBack определяет, куда должен быть добавлен очередной элемент очереди. По мере того как новые элементы добавляются в очередь и покидают ее, размер массива, содержащего очередь, изменяется так, что он растет на одном конце и уменьшается на другом.
Global Queue() As String ' Массив очереди.
Global QueuePront As Integer ' Начало очереди.
Global QueueBack As Integer ' Конец очереди.
Sub EnterQueue(value As String)
ReDim Preserve Queue(QueueFront To QueueBack)
Queue(QueueBack) = value
QueueBack = QueueBack + 1
End Sub
Sub LeaveQueue(value As String)
value = Queue(QueueFront)
QueueFront = QueueFront + 1
ReDim Preserve Queue (QueueFront To QueueBack - 1)
End Sub
К сожалению, Visual Basic не позволяет использовать ключевое слово Preserve в операторе ReDim, если изменяется нижняя граница массива. Даже если бы Visual Basic позволял выполнение такой операции, очередь при этом «двигалась» бы по памяти. При каждом добавлении или удалении элемента из очереди, границы массива увеличивались бы. После пропускания достаточно большого количества элементов через очередь, ее границы могли бы в конечном итоге стать слишком велики.
Поэтому, когда требуется увеличить размер массива, вначале необходимо переместить данные в начало массива. При этом может образоваться достаточное количество свободных ячеек в конце массива, так что увеличение размера массива может уже не понадобиться. В противном случае, можно воспользоваться оператором ReDim для увеличения или уменьшения размера массива.
Как и в случае со списками, можно повысить производительность, добавляя сразу несколько элементов при увеличении размера массива. Также можно сэкономить время, уменьшая размер массива, только когда он содержит слишком много неиспользуемых ячеек.
В случае простого списка или стека, элементы добавляются и удаляются на одном его конце. Если размер списка остается почти постоянным, его не придется изменять слишком часто. С другой стороны, так как элементы добавляются на одном конце очереди, а удаляются с другого конца, может потребоваться время от времени переупорядочивать очередь, даже если ее размер остается неизменным.
=====53
Const WANT_FREE_PERCENT = .1 ' 10% свободного пространства.
Const MIN_FREE = 10 ' Минимум свободных ячеек.
Global Queue() As String ' Массив очереди.
Global QueueMax As Integer ' Наибольший индекс массива.
Global QueueFront As Integer ' Начало очереди.
Global QueueBack As Integer ' Конец очереди.
Global ResizeWhen As Integer ' Когда увеличить размер массива.
' При инициализации программа должна установить QueueMax = -1
' показывая, что под массив еще не выделена память.
Sub EnterQueue(value As String)
If QueueBack > QueueMax Then ResizeQueue
Queue(QueueBack) = value
QueueBack = QueueBack + 1
End Sub
Sub LeaveQueue(value As String)
value = Queue(QueueFront)
QueueFront = QueueFront + 1
If QueueFront > ResizeWhen Then ResizeOueue
End Sub
Sub ResizeQueue()
Dim want_free As Integer
Dim i As Integer
' Переместить записи в начало массива.
For i = QueueFront To QueueBack - 1
Queue(i - QueueFront) = Queue(i)
Next i
QueueBack = QueueBack - QueuePront
QueueFront = 0
' Изменить размер массива.
want_free = WANT_FREE_PERCENT * (QueueBack - QueueFront)
If want_free < MIN_FREE Then want_free = MIN_FREE
Max = QueueBack + want_free - 1
ReDim Preserve Queue(0 To Max)
' Если QueueFront > ResizeWhen, изменить размер массива.
ResizeWhen = want_free
End Sub
При работе с программой, заметьте, что когда вы добавляете и удаляете элементы, требуется изменение размера очереди, даже если размер очереди почти не меняется. Фактически, даже при неоднократном добавлении и удалении одного элемента размер очереди будет изменяться.
Имейте в виду, что при каждом изменении размера очереди, вначале все используемые элементы перемещаются в начало массива. При этом на изменение размера очередей на основе массива уходит больше времени, чем на изменение размера описанных выше связных списков и стеков.
=======54
Программа ArrayQ2 аналогична программе ArrayQ, но она использует для управления очередью класс ArrayQueue.
Очереди, описанные в предыдущем разделе, требуется переупорядочивать время от времени, даже если размер очереди почти не меняется. Даже при неоднократном добавлении и удалении одного элемента будет необходимо переупорядочивать очередь.
Если заранее известно, насколько большой может быть очередь, этого можно избежать, создав циклическую очередь (circular queue). Идея заключается в том, чтобы рассматривать массив очереди как будто он заворачивается, образуя круг. При этом последний элемент массива как бы идет перед первым. На рис. 3.2 изображена циклическая очередь.
Программа может хранить в переменной QueueFront индекс элемента, который дольше всего находится в очереди. Переменная QueueBack может содержать конец очереди, в который добавляется новый элемент.
В отличие от предыдущей реализации, при обновлении значений переменных QueueFront и QueueBack, необходимо использовать оператор Mod для того, чтобы индексы оставались в границах массива. Например, следующий код добавляет элемент к очереди:
Queue(QueueBack) = value
QueueBack = (QueueBack + 1) Mod QueueSize
На рис. 3.3 показан процесс добавления нового элемента к циклической очереди, которая может содержать четыре записи. Элемент C добавляется в конец очереди. Затем конец очереди сдвигается, указывая на следующую запись в массиве.
Таким же образом, когда программа удаляет элемент из очереди, необходимо обновлять указатель на начало очереди при помощи следующего кода:
value = Queue(QueueFront)
QueueFront = (QueueFront + 1) Mod QueueSize
@Рис. 3.2. Циклическая очередь
=======55
@Рис. 3.3. Добавление элемента к циклической очереди
На рис. 3.4 показан процесс удаления элемента из циклической очереди. Первый элемент, в данном случае элемент A, удаляется из начала очереди, и указатель на начало очереди обновляется, указывая на следующий элемент массива.
Для циклических очередей иногда бывает сложно отличить пустую очередь от полной. В обоих случаях значения переменных QueueBottom и QueueTop будут равны. На рис. 3.5 показаны две циклические очереди, пустая и полная.
Простой вариант решения этой проблемы — сохранять число элементов в очереди в отдельной переменной NumInQueue. При помощи этого счетчика можно узнать, остались ли в очереди еще элементы, и осталось ли в очереди место для новых элементов.
@Рис. 3.4. Удаление элемента из циклической очереди
@Рис. 3.5 Полная и пустая циклическая очереди
=========56
Следующий код использует все эти методы для управления циклической очередью:
Queue() As String ' Массив очереди.
QueueSize As Integer ' Наибольший индекс в очереди.
QueueFront As Integer ' Начало очереди.
QueueBack As Integer ' Конец очереди.
NumInQueue As Integer ' Число элементов в очереди.
Sub NewCircularQueue(num_items As Integer)
QueueSize = num_items
ReDim Queue(0 To QueueSize - 1)
End Sub
Sub EnterQueue(value As String)
' Если очередь заполнена, выйти из процедуры.
' В настоящем приложении потребуется более сложный код.
If NumInQueue >= QueueSize Then Exit Sub
Queue(QueueBack) = value
QueueBack = (QueueBack + 1) Mod QueueSize
NumInQueue = NumInQueue + 1
End Sub
Sub LeaveQueue (value As String)
' Если очередь пуста, выйти из процедуры.
' В настоящем приложении потребуется более сложный код.
If NumInQueue <= 0 Then Exit Sub
value = Queue (QueueFront)
QueueFront = (QueueFront + 1) Mod QueueSize
NumInQueue = NumInQueue - 1
End Sub
Так же, как и в случае со списками на основе массивов, можно изменять размер массива, когда очередь полностью заполнится или если в массиве будет слишком много неиспользуемого пространства. Тем не менее, изменение размера циклической очереди сложнее, чем изменить размер стека или списка, основанного на массиве.
Когда изменяется размер массива, конец очереди может не совпадать с концом массива. Если просто увеличить массив, то вставляемые элементы будут находиться в конце массива, так что они попадут в середину списка. На рис. 3.6 показано, что может произойти при таком увеличении массива.
===========57
При уменьшении размера массива возникают похожие проблемы. Если элементы огибают конец массива, то элементы в конце массива, которые будут находиться в начале очереди, будут потеряны.
Для того чтобы избежать этих затруднений, необходимо переупорядочить массив перед тем, как изменять его размер. Проще всего это сделать, используя временный массив. Скопируем элементы очереди во временный массив в правильном порядке, поменяем размер массива очереди, и затем скопируем элементы из временного массива обратно в массив очереди.
Private Sub EnterQueue(value As String)
If NumInQueue >= QueueSize Then ResizeQueue
Queue(QueueBack) = value
QueueBack = (QueueBack + 1) Mod QueueSize
NumInQueue = NumInQueue + 1
End Sub
Private Sub LeaveQueue(value As String)
If NumInQueue <= 0 Then Exit Sub
value = Queue (QueueFront)
QueueFront = (QueueFront + 1) Mod QueueSize
NumInQueue = NumInQueue - 1
If NumInQueue < ShrinkWhen Then ResizeQueue
End Sub
Sub ResizeQueue()
Dim temp() As String
Dim want_free As Integer
Dim i As Integer
' Скопировать элементы во временный массив.
ReDim temp(0 To NumInQueue - 1)
For i = 0 To NumInQueue - 1
temp(i) = Queue((i + QueueFront) Mod QueueSize)
Next i
' Изменить размер массива.
want_free = WANT_FREE_PERCENT * NumInQueue
If want_free < MIN_PREE Then want_free = MIN_FREE
QueueSize = NumInQueue + want_free
ReDim Queue(0 To QueueSize - 1)
For i = 0 To NumInQueue - 1
Queue(i) = temp(i)
Next i
QueueFront = 0
QueueBack = NumInQueue
' Уменьшить размер массива, если NunInQueue < ShrinkWhen.
ShrinkWhen = QueueSize - 2 * want_free
' Не менять размер небольших очередей. Это может вызвать
' проблемы с "ReDim temp(0 To NumInQueue - 1)" выше и
' просто глупо!
If ShrinkWhen < 3 Then ShrinkWhen = 0
End Sub
Программа CircleQ демонстрирует этот подход к реализации циклической очереди. Введите строку и нажмите кнопку Enter (Ввести) для добавления нового элемента в очередь. Нажмите на кнопку Leave (Покинуть) для удаления верхнего элемента из очереди. Программа будет при необходимости изменять размер очереди.
Программа CircleQ2 аналогична программе CircleQ, но она использует для работы с очередью класс CircleQueue.
Помните, что при каждом изменении размера очереди в программе, она копирует элементы во временный массив, изменяет размер очереди, а затем копирует элементы обратно. Эти дополнительные шаги делают изменение размера циклических очередей более медленным, чем изменение размера связных списков и стеков. Даже очереди на основе массивов, в которых также требуются дополнительные действия для изменения размера, не требуют такого объема работы.
С другой стороны, если число элементов в очереди не сильно меняется, и если правильно задать параметры изменения размера, может никогда не понадобиться менять размер массива. Даже если иногда это все‑таки придется делать, уменьшение частоты этих изменений стоит дополнительных усилий на программирование.
Совсем другой подход к реализации очередей состоит в использовании двусвязных списков. Для отслеживания начала и конца списка можно использовать сигнальные метки. Новые элементы добавляются в очередь перед меткой в конце очереди, а элементы, следующие за меткой начала очереди, удаляются. На рис. 3.7 показан двусвязный список, который используется в качестве очереди.
===========58-59
Добавлять и удалять элементы из двусвязного списка легко, поэтому в этом случае не потребуется применять сложных алгоритмов для изменения размера. Преимущество этого метода также в том, что он интуитивно понятнее по сравнению с циклической очередью на основе массива. Недостаток его в том, что для указателей связного списка NextCell и PrevCell требуется дополнительная память. В отношении занимаемой памяти очереди на основе связных списков немного менее эффективны, чем циклические списки.
Программа LinkedQ работает с очередью при помощи двусвязного списка. Введите строку, нажмите на кнопку Enter, чтобы добавить элемент в конец очереди. Нажмите на кнопку Leave для удаления элемента из очереди.
Программа LinkedQ2 аналогична программе LinkedQ, но она использует для управления очередью класс LinkedListqueue.
Коллекции Visual Basic представляют собой очень простую форму очереди. Программа может использовать метод Add коллекции для добавления элемента в конец очереди, и метод Remove с параметром 1 для удаления первого элемента из очереди. Следующий код управляет очередью на основе коллекций:
Dim Queue As New Collection
Private Sub EnterQueue(value As String)
Queue.Add value
End Sub
Private Function LeaveQueue() As String
LeaveQueue = Queue.Item(1)
Queue.Remove 1
Еnd Function
@Рис. 3.7. Очередь на основе связного списка
=======60
Несмотря на то, что этот код очень прост, коллекции в действительности не предназначены для использования в качестве очередей. Они предоставляют дополнительные возможности, такие как ключи элементов, и поддержка этих дополнительных возможностей делает коллекции более медленными, чем другие реализации очередей. Тем не менее, очереди на основе коллекций настолько просты, что они могут быть приемлемым выбором для приложений, в которых производительность не является проблемой.
Программа CollectQ демонстрирует очередь на основе коллекций.
Каждый элемент в приоритетной очереди (priority queue) имеет связанный с ним приоритет. Если программе нужно удалить элемент из очереди, она выбирает элемент с наивысшим приоритетом. Как хранятся элементы в приоритетной очереди, не имеет значения, если программа всегда может найти элемент с наивысшим приоритетом.
Некоторые операционные системы использую приоритетные очереди для планирования заданий. В операционной системе UNIX все процессы имеют разные приоритеты. Когда процессор освобождается, выбирается готовый к исполнению процесс с наивысшим приоритетом. Процессы с более низким приоритетом должны ждать завершения или блокировки (например, при ожидании внешнего события, такого как чтение данных с диска) процессов с более высокими приоритетами.
Концепция приоритетных очередей также используется при управлении авиаперевозками. Наивысший приоритет имеют самолеты, у которых кончается топливо во время посадки. Второй приоритет имеют самолеты, заходящие на посадку. Третий приоритет имеют самолеты, находящиеся на земле, так как они находятся в более безопасном положении, чем самолеты в воздухе. Приоритеты изменяются со временем, так как у самолетов, которые пытаются приземлиться, в конце концов, закончится топливо.
Простой способ организации приоритетной очереди — поместить все элементы в список. Если требуется удалить элемент из очереди, можно найти в списке элемент с наивысшем приоритетом. Чтобы добавить элемент в очередь, он помещается в начало списка. При использовании этого метода, для добавления нового элемента в очередь требуется только один шаг. Чтобы найти и удалить элемент с наивысшим приоритетом, требуется O(N) шагов, если очередь содержит N элементов.
Немного лучше была бы схема с использованием связного списка, в котором элементы были бы упорядочены в прямом или обратном порядке. Используемый в списке класс PriorityCell мог бы объявлять переменные следующим образом:
Public Priority As Integer ' Приоритет элемента.
Public NextCell As PriorityCell ' Указатель на следующий элемент.
Public Value As String ' Данные, нужные программе.
Чтобы добавить элемент в очередь, нужно найти его правильное положение в списке и поместить его туда. Чтобы упростить поиск положения элемента, можно использовать сигнальные метки в начале и конце списка, присвоив им соответствующие приоритеты. Например, если элементы имеют приоритеты от 0 до 100, можно присвоить метке начала приоритет 101 и метке конца — приоритет ‑1. Приоритеты всех реальных элементов будут находиться между этими значениями.
На рис. 3.8 показана приоритетная очередь, реализованная на основе связного списка.
=====61
@Рис. 3.8. Приоритетная очередь на основе связного списка
Следующий фрагмент кода показывает ядро этой процедуры поиска:
Dim cell As PriorityCell
Dim nxt As PriorityCell
' Найти место элемента в списке.
cell = TopSentinel
nxt = cell.NextCell
Do While cell.Priority > new_priority
cell = nxt
nxt = cell.NextCell
Loop
' Вставить элемент после ячейки в списке.
:
Для удаления из списка элемента с наивысшим приоритетом, просто удаляется элемент после сигнальной метки начала. Так как список отсортирован в порядке приоритетов, первый элемент всегда имеет наивысший приоритет.
Добавление нового элемента в эту очередь занимает в среднем N/2 шагов. Иногда новый элемент будет оказываться в начале списка, иногда ближе к концу, но в среднем он будет оказываться где‑то в середине. Простая очередь на основе списка требовала O(1) шагов для добавления нового элемента и O(N) шагов для удаления элементов с наивысшим приоритетом из очереди. Версия на основе упорядоченного связного списка требует O(N) шагов для добавления элемента и O(1) шагов для удаления верхнего элемента. Обеим версиям требует O(N) шагов для одной из этих операций, но в случае упорядоченного связного списка в среднем требуется только (N/2) шагов.
Программа PriList использует упорядоченный связный список для работы с приоритетной очередью. Вы можете задать приоритет и значение элемента данных и нажать кнопку Enter для добавления его в приоритетную очередь. Нажмите на кнопку Leave для удаления из очереди элемента с наивысшим приоритетом.
Программа PriList2 аналогична программе PriList, но она использует для управления очередью класс LinkedPriorityQueue.
========63
Затратив еще немного усилий, можно построить приоритетную очередь, в которой добавление и удаление элемента потребуют порядка O(log(N)) шагов. Для очень больших очередей, ускорение работы может стоить этих усилий. Этот тип приоритетных очередей использует структуры данных в виде пирамиды, которые также применяются в алгоритме пирамидальной сортировки. Пирамиды и приоритетные очереди на их основе обсуждаются более подробно в 9 главе.
Интересной разновидностью очередей являются многопоточные очереди (multi‑headed queues). Элементы, как обычно, добавляются в конец очереди, но очередь имеет несколько потоков (front end) или голов (heads). Программа может удалять элементы из любого потока.
Примером многопоточной очереди в обычной жизни является очередь клиентов в банке. Все клиенты находятся в одной очереди, но их обслуживает несколько служащих. Освободившийся банковский работник обслуживает клиента, который находится в очереди первым. Такой порядок обслуживания кажется справедливым, поскольку клиенты обслуживаются в порядке прибытия. Он также эффективен, так как все служащие остаются занятыми до тех пор, пока клиенты ждут в очереди.
Сравните этот тип очереди с несколькими однопоточными очередями в супермаркете, в которых покупатели не обязательно обслуживаются в порядке прибытия. Покупатель в медленно движущейся очереди, может прождать дольше, чем тот, который подошел позже, но оказался в очереди, которая продвигается быстрее. Кассиры также могут быть не всегда заняты, так как какая‑либо очередь может оказаться пустой, тогда как в других еще будут находиться покупатели.
В общем случае, многопоточные очереди более эффективны, чем несколько однопоточных очередей. Последний вариант используется в супермаркетах потому, что тележки для покупок занимают много места. При использовании многопоточной очереди всем покупателям пришлось бы построиться в одну очередь. Когда кассир освободится, покупателю пришлось бы переместиться с громоздкой тележкой к кассиру. С другой стороны, в банке посетителям не нужно двигать большие тележки для покупок, поэтому они легко могут уместиться в одной очереди.
Очереди на регистрацию в аэропорту иногда представляют собой комбинацию этих двух ситуаций. Хотя пассажиры имеют с собой большое количество багажа, в аэропорту все‑таки используются многопоточные очереди, при этом приходится отводить дополнительное место, чтобы пассажиры могли выстроиться в порядке очереди.
Многопоточную очередь просто построить, используя обычную однопоточную очередь. Элементы, представляющие клиентов, хранятся в обычной однопоточной очереди. Когда агент (кассир, банковский служащий и т.д.) освобождается, первый элемент в начале очереди удаляется и передается этому агенту.
Предположим, что вы отвечаете за разработку счетчика регистрации для нового терминала в аэропорту и хотите сравнить возможности одной многопоточной очереди или нескольких однопоточных. Вам потребуется какая‑то модель поведения пассажиров. Для этого примера можно сделать следующие предположения:
=====63
* регистрация каждого пассажира занимает от двух до пяти минут;
* при использовании нескольких однопоточных очередей, прибывающие пассажиры встают в самую короткую очередь;
* скорость поступления пассажиров примерно неизменна.
Программа HeadedQ моделирует эту ситуацию. Вы можете менять некоторые параметры модели, включая следующие:
* число прибывающих в течение часа пассажиров;
* минимальное и максимальное затрачиваемое время;
* число свободных служащих;
* паузу между шагами программы в миллисекундах.
При выполнении программы, модель показывает прошедшее время, среднее и максимальное время ожидания пассажирами обслуживания, и процент времени, в течение которого служащие заняты.
При экспериментировании с различными значениями параметров, вы заметите несколько любопытных моментов. Во-первых, для многопоточной очереди среднее и максимальное время ожидания будет меньше. При этом, служащие также оказываются немного более загружены, чем в случае однопоточной очереди.
Для обоих типов очереди есть порог, при котором время ожидания пассажиров значительно возрастает. Предположим, что на обслуживание одного пассажира требуется от 2 до 10 минут, или в среднем 6 минут. Если поток пассажиров составляет 60 человек в час, тогда персонал потратит около 6*60=360 минут в час на обслуживание всех пассажиров. Разделив это значение на 60 минут в часе, получим, что для обслуживания клиентов в этом случае потребуется 6 клерков.
Если запустить программу HeadedQ с этими параметрами, вы увидите, что очереди движутся достаточно быстро. Для многопоточной очереди время ожидания составит всего несколько минут. Если добавить еще одного служащего, чтобы всего было 7 служащих, среднее и максимальное время ожидания значительно уменьшатся. Среднее время ожидания упадет примерно до одной десятой минуты.
С другой стороны, если уменьшить число служащих до 5, это приведет к большому увеличению среднего и максимального времени ожидания. Эти показатели также будут расти со временем. Чем дольше будет работать программа, тем дольше будут задержки.
@Таблица 3.1. Время ожидания в минутах для одно‑ и многопоточных очередей
======64
@Рис. 3.9. Программа HeadedQ
В табл. 3.1 приведены среднее и максимальное время ожидания для 2 разных типов очередей. Программа моделирует работу в течение 3 часов и предполагает, что прибывает 60 пассажиров в час и на обслуживание каждого из них уходит от 2 до 10 минут.
Многопоточная очередь также кажется более справедливой, так как пассажиры обслуживаются в порядке прибытия. На рис. 3.9 показана программа HeadedQ после моделирования чуть более, чем двух часов работы терминала. В многопоточной очереди первым стоит пассажир с номером 104. Все пассажиры, прибывшие до него, уже обслужены или обслуживаются в настоящий момент. В однопоточной очереди, обслуживается пассажир с номером 106. Пассажиры с номерами 100, 102, 103 и 105 все еще ждут своей очереди, хотя они и прибыли раньше, чем пассажир с номером 106.
Разные реализации стеков и очередей обладают различными свойствами. Стеки и циклические очереди на основе массивов просты и эффективны, в особенности, если заранее известно насколько большим может быть их размер. Связные списки обеспечивают большую гибкость, если размер списка часто изменяется.
Стеки и очереди на основе коллекций Visual Basic не так эффективны, как реализации на основе массивов, но они очень просты. Коллекции могут подойти для небольших структур данных, если производительность не критична. После тестирования приложения, можно переписать код для стека или очереди, если коллекции окажутся слишком медленными.
Глава 4. МассивыВ этой главе описаны структуры данных в виде массивов. С помощью Visual Basic вы можете легко создавать массивы данных стандартных или определенных пользователем типов. Если определить массив без границ, затем можно изменять его размер при помощи оператора ReDim. Эти свойства делают применение массивов в Visual Basic очень полезным.
Некоторые программы используют особые типы массивов, которые не поддерживаются Visual Basic непосредственно. К этим типа относятся треугольные массивы, нерегулярные массивы и разреженные массивы. В этой главе объясняется, как можно использовать гибкие структуры массивов, которые могут значительно снизить объем занимаемой памяти.
Некоторым программам требуется только половина элементов в двумерном массиве. Предположим, что мы располагаем картой, на которой 10 городов обозначены цифрами от 0 до 9. Можно использовать массив для создания матрицы смежности (adjacency matrix), показывающей наличие автострады между парами городов. Элемент A(I,J) равен True, если между городами I и J есть автострада.
В этом случае, значения в половине матрицы будут дублировать значения в другой ее половине, так как A(I, J)=A(J, I). Также элемент A(I, I) не имеет смысла, так как бессмысленно строить автостраду из города I в тот же самый город. В действительности потребуются только элементы A(I,J) из верхнего левого угла, для которых I > J. Вместо этого можно также использовать элементы из верхнего правого угла. Поскольку эти элементы образуют треугольник, этот тип массивов называется треугольным массивом (triangular array).
На рис. 4.1 показан треугольный массив. Элементы со значащими данными обозначены буквой X, ячейки, соответствующие дублирующимся элементам, оставлены пустыми. Незначащие элементы A(I,I) обозначены тире.
Для небольших массивов потери памяти при использовании обычных двумерных массивов для хранения таких данных не слишком существенны. Если же на карте много городов, потери памяти могут быть велики. Для N городов эти потери составят N*(N-1)/2 дублирующихся элементов и N незначащих диагональных элементов A(I,I). Если карта содержит 1000 городов, в массиве будет более полумиллиона ненужных элементов.
====67
@Рис. 4.1. Треугольный массив
Избежать потерь памяти можно, создав одномерный массив B и упаковав в него значащие элементы из массива A. Разместим элементы в массиве B по строкам, как показано на рис. 4.2. Заметьте, что индексы массивов начинаются с нуля. Это упрощает последующие уравнения.
Для того, чтобы упростить использование этого представления треугольного массива, можно написать функции для преобразования индексов массивов A и B. Уравнение для преобразования индекса A(I,J) в B(X) выглядит так:
X = I * (I - 1) / 2 + J ' Для I > J.
Например, для I=2 и J=1 получим X = 2 * (2 - 1) / 2 + 1 = 2. Это значит, что A(2,1) отображается на 2 позицию в массиве B, как показано на рис. 4.2. Помните, что массивы нумеруются с нуля.
Уравнение остается справедливым только для I > J. Значения других элементов массива A не сохраняются в массиве B, потому что они являются избыточными или незначащими. Если вам нужно получить значение A(I,J) при I < J, вместо этого следует вычислять значение A(J,I).
Уравнения для обратного преобразования B(X) в A(I,J) выглядит так:
I = Int((1 + Sqr(1 + 8 * X)) / 2)
J = X - I * (I - 1) / 2
@Рис. 4.2. Упаковка треугольного массива в одномерном массиве
=====68
Подстановка в эти уравнения X=4 дает I = Int((1 + Sqr(1 + 8 * 4)) / 2) = 3 и J = 4 – 3 * (3 ‑ 1) / 2 = 1. Это означает, что элемент B(4) отображается на позицию A(3,1). Это также соответствует рис. 4.2.
Эти вычисления не слишком просты. Они требуют нескольких умножений и делений, и даже вычисления квадратного корня. Если программе придется выполнять эти функции очень часто, это внесет определенную задержку скорости выполнения. Это пример компромисса между пространством и временем. Упаковка треугольного массива в одномерный массив экономит память, хранение данных в двумерном массиве требует больше памяти, но экономит время.
Используя эти уравнения, можно написать процедуры Visual Basic для преобразования координат между двумя массивами:
Private Sub AtoB(ByVal I As Integer, ByVal J As Integer, X As Integer)
Dim tmp As Integer
If I = J Then ' Незначащий элемент.
X = -1
Exit Sub
ElseIf I < J Then ' Поменять местами I и J.
tmp = I
I = J
J = tmp
End If
X = I * (I - 1) / 2 + J
End Sub
Private Sub BtoA(ByVal X As Integer, I As Integer, J As Integer)
I = Int((1 + Sqr(1 + 8 * X)) / 2)
J = X - I * (I - 1) /2
End Sub
Программа Triang использует эти подпрограммы для работы с треугольными массивами. Если вы нажмете на кнопку A to B (Из A в B), программа пометит элементы в массиве A и скопирует эти метки в соответствующие элементы массива B. Если вы нажмете на кнопку B to A (Из B в A), программа пометит элементы в массиве B, и затем скопирует метки в массив A.
Программа Triangc использует класс TriangularArray для работы с треугольным массивом. При старте программы, она записывает в объект TriangularArray строки, представляющие собой элементы массива. Затем она извлекает и выводит на экран элементы массива.
Некоторые программы используют треугольные массивы, которые включают диагональные элементы A(I, I). В этом случае необходимо внести только три изменения в процедуры преобразования индексов. Процедура преобразования AtoB не должна пропускать случаи с I=J, и должна добавлять к I единицу при подсчете индекса массива B.
=====69
Private Sub AtoB(ByVal I As Integer, ByVal J As Integer, X As Integer)
Dim tmp As Integer
If I < J Then ' Поменять местами I и J.
tmp = I
I = J
J = tmp
End If
I = I + 1
X = I * (I - 1) / 2 + J
End Sub
Процедура преобразования BtoA должна вычитать из I единицу перед возвратом значения.
Private Sub BtoA(ByVal X As Integer, I As Integer, J As Integer)
I = Int((1 + Sqr(1 + 8 * X)) / 2)
J = X - I * (I - 1) / 2
I = J - 1
End Sub
Программа Triang2 аналогична программе Triang, но она использует для работы с диагональными элементами в массиве A эти новые функции. Программа TriangC2 аналогична программе TriangC, но использует класс TriangularArray, который включает диагональные элементы.
В некоторых программах нужны массивы нестандартного размера и формы. Двумерный массив может содержать шесть элементов в первом ряду, три — во втором, четыре — в третьем, и т.д. Это может понадобиться, например, для сохранения ряда многоугольников, каждый из которых состоит из разного числа точек. Массив будет при этом выглядеть, как на рис. 4.3.
Массивы в Visual Basic не могут иметь такие неровные края. Можно было бы использовать массив, достаточно большой для того, чтобы в нем могли поместиться все строки, но при этом в таком массиве было бы множество неиспользуемых ячеек. Например, массив на рис. 4.3 мог бы быть объявлен при помощи оператора Dim Polygons(1 To 3, 1 To 6), и при этом четыре ячейки останутся неиспользованными.
Существует несколько способов представления нерегулярных массивов.
@Рис. 4.3. Нерегулярный массив
=====70
Один из способов избежать потерь памяти заключается в том, чтобы упаковать данные в одномерном массиве B. В отличие от треугольных массивов, для нерегулярных массивов нельзя записать формулы для определения соответствия элементов в разных массивах. Чтобы справиться с этой задачей, можно создать еще один массив A со смещениями для каждой строки в одномерном массиве B.
Для упрощения определения в массиве B положения точек, соответствующих каждой строке, в конец массива A можно добавить сигнальную метку, которая указывает на точку сразу за последним элементом в массиве B. Тогда точки, образующие многоугольник I, занимают в массиве B позиции с A(I) до A(I+1)-1. Например, программа может перечислить элементы, образующие строку I, используя следующий код:
For J = A(I) To A(I + 1) - 1
‘ Внести в список элемент I.
:
Next J
Этот метод называется прямой звездой (forward star). На рис. 4.4 показано представление нерегулярного массива с рис. 4.3 в виде прямой звезды. Сигнальная метка закрашена серым цветом.
Этот метод можно легко обобщить для создания многомерных нерегулярных массивов. Для хранения набора рисунков, каждый из которых состоит из разного числа многоугольников, можно использовать трехмерную прямую звезду.
На рис. 4.5 схематически представлена трехмерная структура данных в виде прямой звезды. Две сигнальных метки закрашены серым цветом. Они указывают на одну позицию позади значащих данных в массиве.
Такое представление в виде прямой звезды требует очень небольших затрат памяти. Только память, занимаемая сигнальными метками, расходуется «впустую».
При использовании структуры данных прямой звезды легко и быстро можно перечислить точки, образующие многоугольник. Так же просто сохранять такие данные на диске и загружать их обратно в память. С другой стороны, обновлять массивы, записанные в формате прямой звезды, очень сложно. Предположим, вы хотите добавить новую точку к первому многоугольнику на рис. 4.4. Для этого понадобится сдвинуть все элементы справа от новой точки на одну позицию, чтобы освободить место для нового элемента. Затем нужно добавить по единице ко всем элементам массива A, которые идут после первого, чтобы учесть сдвиг, вызванный добавлением точки. И, наконец, надо вставить новый элемент. Сходные проблемы возникают при удалении точки из первого многоугольника.
@Рис. 4.4. Представления нерегулярного массива в виде прямой звезды
=====71
@Рис. 4.5. Трехмерная прямая звезда
На рис. 4.6 показано представление в виде прямой звезды с рис. 4.4 после добавления одной точки к первому многоугольнику. Элементы, которые были изменены, закрашены серым цветом. Как видно из рисунка, почти все элементы в обоих массивах были изменены.
Другим методом создания нерегулярных массивов является использование связных списков. Каждая ячейка содержит указатель на следующую ячейку на том же уровне иерархии, и указатель на список ячеек на более низком уровне иерархии. Например, ячейка многоугольника может содержать указатель на следующий многоугольник и указатель на ячейку, содержащую координаты первой точки.
Следующий код приводит определения переменных для классов, которые можно использовать для создания связного списка рисунков. Каждый из рисунков содержит связный список многоугольников, каждый из которых содержит связный список точек.
В классе PictureCell:
Dim NextPicture As PictureCell ' Следующий рисунок.
Dim FirstPolygon As PolyfonCell ' Первый многоугольник на этом рисунке.
В классе PolygonCell:
Dim NextPolygon As PolygonCell ' Следующий многоугольник.
Dim FirstPoint As PointCell ' Первая точка в этом многоугольнике.
В классе PointCell:
@Рис. 4.6. Добавление точки к прямой звезде
======72
Dim NextPoint As PointCell ' Следующая точка в этом многоугольнике.
Dim X As Single ' Координаты точки.
Dim Y As Single
Используя эти методы, можно легко добавлять и удалять рисунки, многоугольники или точки в любом месте структуры данных.
Программа Poly на диске содержит связный список многоугольников. Каждый многоугольник содержит связный список точек. Когда вы закрываете форму, ссылка на список многоугольников из формы уничтожается. Это уменьшает счетчик ссылок на верхнюю ячейку многоугольников до нуля. Она уничтожается, поэтому ее ссылки на следующий многоугольник и его первую точку также уничтожаются. Счетчики ссылок на эти ячейки также уменьшаются до нуля, и они тоже уничтожаются. Уничтожение каждой ячейки многоугольника или точки приводит к уничтожению следующей ячейки. Этот процесс продолжается до тех пор, пока все многоугольники и точки не будут уничтожены.
Во многих приложениях требуются большие массивы, которые содержат лишь небольшое число ненулевых элементов. Матрица смежности для авиалиний, например, может содержать 1 в позиции A(I, J) если есть рейс между городами I и J. Многие авиалинии обслуживают сотни городов, но число существующих рейсов намного меньше, чем N2 возможных комбинаций. На рис. 4.8 показана небольшая карта рейсов авиалинии, на которой изображены только 11 существующих рейсов из 100 возможных пар сочетаний городов.
@Рис. 4.7. Программа Poly
====73
@Рис. 4.8. Карта рейсов авиалинии
Можно построить матрицу смежности для этого примера при помощи массива 10 на 10 элементов, но этот массив будет по большей части пустым. Можно избежать потерь памяти, используя для создания разреженного массива указатели. Каждая ячейка содержит указатели на следующий элемент в строке и столбце массива. Это позволяет программе определить положение любого элемента в массиве и обходить элементы в строке или столбце. В зависимости от приложения, может оказаться полезным также добавить обратные указатели. На рис. 4.9 показана разреженная матрица смежности, соответствующая карте рейсов с рис. 4.8.
Чтобы построить разреженный массив в Visual Basic, создайте класс для представления элементов массива. В этом случае, каждая ячейка представляет наличие рейсов между двумя городами. Для представления связи, класс должен содержать переменные с индексами городов, которые связаны между собой. Эти индексы, в сущности, дают номера строк и столбцов ячейки. Каждая ячейка также должна содержать указатели на следующую ячейку в строке и столбце.
Следующий код показывает объявление переменных в классе ConnectionCell:
Public FromCity As Integer ' Строка ячейки.
Public ToCity As Integer ' Столбец ячейки.
Public NextInRow As ConnectionCell
Public NextInCol As ConnectionCell
Строки и столбцы в этом массиве по существу представляют собой связные списки. Как это часто случается со связными списками, с ними проще работать, если они содержат сигнальные метки. Например, переменная RowHead(I) должна содержать сигнальную метку для строки I. Для обхода строки I в массиве можно использовать следующий код:
Private Sub PrintRow(I As Integer)
Dim cell As ConnectionCell
Set Cell = RowHead(I).Next ' Первый элемент данных.
Do While Not (cell Is Nothing)
Print Format$(cell.FromCity) & " -> " & Format$(cell.ToCity)
Set cell = cell.NextInRow
Loop
End Sub
====74
@Рис. 4.9. Разреженная матрица смежности
Нормальное индексирование массива типа A(I, J) не будет работать с такими структурами. Можно облегчить индексирование, написав процедуры, которые извлекают и устанавливают значения элементов массива. Если массив представляет матрицу, могут также понадобиться процедуры для сложения, умножения, и других матричных операций.
Специальное значение NoValue представляет пустой элемент массива. Процедура, которая извлекает элементы массива, должна возвращать значение NoValue при попытке получить значение элемента, не содержащегося в массиве. Аналогично, процедура, которая устанавливает значения элементов, должна удалять ячейку из массива, если ее значение установлено в NoValue.
Значение NoValue должно выбираться в зависимости от природы данных приложения. Для матрицы смежности авиалинии пустые ячейки могут иметь значение False. При этом значение A(I, J) может устанавливаться равным True, если существует рейс между городами I и J.
Класс SparseArray определяет процедуру get для свойства Value для возвращения значения элемента в массиве. Процедура начинает с первой ячейки в указанной строке и затем перемещается по связному списку ячеек строки. Как только найдется ячейка с нужным номером столбца, это и будет искомая ячейка. Так как ячейки в списке строки расположены по порядку, процедура может остановиться, если найдется ячейка, номер столбца которой больше искомого.
=====75
Property Get Value(t As Integer, c As Integer) As Variant
Dim cell As SparseArrayCell
Value = NoValue ' Предположим, что мы не найдем элемент.
If r < 1 Or c < 1 Or _
r > NumRows Or c > NumCols _
Then Exit Property
Set cell = RowHead(r).NextInRow ' Пропустить метку.
Do
If cell Is Nothing Then Exit Property ' Не найден.
If cell.Col > c Then Exit Property ' Не найден.
If cell.Col = c Then Exit Do ' Найден.
Set cell = cell.NextInRow
Loop
Value = cell. Data
End Property
Процедура let свойства value присваивает ячейке новое значение. Если новое значение равно NoValue, процедура вызывает для удаления элемента из массива. В противном случае, она ищет требуемое положение элемента в нужной строке. Если элемент уже существует, процедура обновляет его значение. Иначе, она создает новый элемент и добавляет его к списку строки. Затем она добавляет новый элемент в правильное положение в соответствующем списке столбцов.
Property Let Value (r As Integer, c As Integer, new_value As Variant)
Dim i As Integer
Dim found_it As Boolean
Dim cell As SparseArrayCell
Dim nxt As SparseArrayCell
Dim new_cell As SparseArrayCell
' Если value = MoValue, удалить элемент из массива.
If new_value = NoValue Then
RemoveEntry r, c
Exit Property
End If
' Если нужно, добавить строки.
If r > NumRows Then
ReDim Preserve RowHead(1 To r)
' Инициализировать метку для каждой новой строки.
For i = NumRows + 1 To r
Set RowHead(i) = New SparseArrayCell
Next i
End If
' Если нужно, добавить столбцы.
If c > NumCols Then
ReDim Preserve ColHead(1 To c)
' Инициализировать метку для каждой новой строки.
For i = NumCols + 1 To c
Set ColHead(i) = New SparseArrayCell
Next i
NumCols = c
End If
' Попытка найти элемент.
Set cell = RowHead(r)
Set nxt = cell.NextInRow
Do
If nxt Is Nothing Then Exit Do
If nxt.Col >= c Then Exit Do
Set cell = nxt
Set nxt = cell.NextInRow
Loop
' Проверка, найден ли элемент.
If nxt Is Nothing Then
found_it = False
Else
found_it = (nxt.Col = c)
End If
' Если элемент не найден, создать его.
If Not found_it Then
Set new_cell = New SparseArrayCell
' Поместить элемент в список строки.
Set new_cell.NextInRow = nxt
Set cell.NextInRow = new_cell
' Поместить элемент в список столбца.
Set cell = ColHead(c)
Set nxt = cell.NextInCol
Do
If nxt Is Nothing Then Exit Do
If nxt.Col >= c Then Exit Do
Set cell = nxt
Set nxt = cell.NextInRow
Loop
Set new_cell.NextInCol = nxt
Set cell.NextInCol = new_cell
new_cell.Row = r
new_cell.Col = c
' Поместим значение в элемент nxt.
Set nxt = new_cell
End If
' Установим значение.
nxt.Data = new_value
End Property
Программа Sparse, показанная на рис. 4.10, использует классы SparseArray и SparseArrayCell для работы с разреженным массивом. Используя программу, можно устанавливать и извлекать элементы массива. В этой программе значение NoValue равно нулю, поэтому если вы установите значение элемента равным нулю, программа удалит этот элемент из массива.
Некоторые массивы содержат так мало непустых элементов, что многие строки и столбцы полностью пусты. В этом случае, лучше хранить заголовки строк и столбцов в связных списках, а не в массивах. Это позволяет программе полностью пропускать пустые строки и столбцы. Заголовки строки и столбцов указывают на связные списки элементов строк и столбцов. На рис. 4.11 показан массив 100 на 100, который содержит всего 7 непустых элементов.
@Рис. 4.10. Программа Sparse
=====76-78
@Рис. 4.11. Очень разреженный массив
Для работы с массивами этого типа можно довольно просто доработать предыдущий код. Большая часть кода остается неизменной, и для элементов массива можно использовать тот же самый класс SparseArray.Тем не менее, вместо хранения меток строк и столбцов в массивах, они записываются в связных списках.
Объекты класса HeaderCell представляют связные списки строк и столбцов. В этом классе определяются переменные, содержащие число строк и столбцов, которые он представляет, сигнальная метка в начале связного списка элементов строк или столбцов, и объект HeaderCell, представляющий следующий заголовок строки или столбца.
Public Number As Integer ' Номер строки или столбца.
Public Sentinel As SparseArrayCell ' Метка для строки или
' столбца.
Public NextHeader As HeaderCell ' Следующая строка или
' столбец.
Например, чтобы обратиться к строке I, нужно вначале просмотреть связный список заголовков HeaderCells строк, пока не найдется заголовок, соответствующий строке I. Затем продолжается работа со строкой I.
Private Sub PrintRow(r As Integer)
Dim row As HeaderCell
Dim cell As SparseArrayCell
' Найти правильный заголовок строки.
Set row = RowHead. NextHeader ' Список первой строки.
Do
If row Is Nothing Then Exit Sub ' Такой строки нет.
If row.Number > r Then Exit Sub ' Такой строки нет.
If row.Number = r Then Exit Do ' Строка найдена.
Set row = row.NextHeader
Loop
' Вывести элементы в строке.
Set cell = row.Sentinel. NextInRow ' Первый элемент в строке.
Do While Not (cell Is Nothing)
Print Format$(cell.FromCity) & " -> " & Format$(cell.ToCity)
Set cell = cell.NextInRow
Loop
End Sub
Некоторые программы используют массивы, содержащие только небольшое число значащих элементов. Использование обычных массивов Visual Basic привело бы к большим потерям памяти. Используя треугольные, нерегулярные, разреженные и очень разреженные массивы, вы можете создавать мощные представления массивов, которые требуют намного меньших объемов памяти.
=========80
Глава 5. РекурсияРекурсия — мощный метод программирования, который позволяет разбить задачу на части все меньшего и меньшего размера до тех пор, пока они не станут настолько малы, что решение этих подзадач сведется к набору простых операций.
После того, как вы приобретете опыт применения рекурсии, вы будете обнаруживать ее повсюду. Многие программисты, недавно овладевшие рекурсией, увлекаются, и начинают применять ее в ситуациях, когда она является ненужной, а иногда и вредной.
В первых разделах этой главы обсуждается вычисление факториалов, чисел Фибоначчи, и наибольшего общего делителя. Все эти алгоритмы являются примерами плохого использования рекурсии — нерекурсивные версии этих алгоритмов намного эффективнее. Эти примеры интересны и наглядны, поэтому имеет смысл обсудить их.
Затем, в главе рассматривается несколько примеров, в которых применение рекурсии более уместно. Алгоритмы построения кривых Гильберта и Серпинского используют рекурсию правильно и эффективно.
В последних разделах этой главы объясняется, почему реализацию алгоритмов вычисления факториалов, чисел Фибоначчи, и наибольшего общего делителя лучше осуществлять без применения рекурсии. В этих параграфах объясняется также, когда следует избегать рекурсии, и приводятся способы устранения рекурсии, если это необходимо.
Рекурсия происходит, если функция или подпрограмма вызывает сама себя. Прямая рекурсия (direct recursion) выглядит примерно так:
Function Factorial(num As Long) As Long
Factorial = num * Factorial(num - 1)
End Function
В случае косвенной рекурсии (indirect recursion) рекурсивная процедура вызывает другую процедуру, которая, в свою очередь, вызывает первую:
Private Sub Ping(num As Integer)
Pong(num - 1)
End Sub
Private Sub Pong(num As Integer)
Ping(num / 2)
End Sub
===========81
Рекурсия полезна при решении задач, которые естественным образом разбиваются на несколько подзадач, каждая из которых является более простым случаем исходной задачи. Можно представить дерево на рис. 5.1 в виде «ствола», на котором находятся два дерева меньших размеров. Тогда можно написать рекурсивную процедуру для рисования деревьев:
Private Sub DrawTree()
Нарисовать "ствол"
Нарисовать дерево меньшего размера, повернутое на -45 градусов
Нарисовать дерево меньшего размера, повернутое на 45 градусов
End Sub
Хотя рекурсия и может упростить понимание некоторых проблем, люди обычно не мыслят рекурсивно. Они обычно стремятся разбить сложные задачи на задачи меньшего объема, которые могут быть выполнены последовательно одна за другой до полного завершения. Например, чтобы покрасить изгородь, можно начать с ее левого края и продолжать двигаться вправо до завершения. Вероятно, во время выполнения подобной задачи вы не думаете о возможности рекурсивной окраски — вначале левой половины изгороди, а затем рекурсивно — правой.
Для того чтобы думать рекурсивно, нужно разбить задачу на подзадачи, которые затем можно разбить на подзадачи меньшего размера. В какой‑то момент подзадачи становятся настолько простыми, что могут быть выполнены непосредственно. Когда завершится выполнение подзадач, большие подзадачи, которые из них составлены, также будут выполнены. Исходная задача окажется выполнена, когда будут все выполнены образующие ее подзадачи.
Факториал числа N записывается как N! (произносится «эн факториал»). По определению, 0! равно 1. Остальные значения определяются формулой:
N! = N * (N - 1) * (N - 2) * ... * 2 * 1
Как уже упоминалось в 1 главе, эта функция чрезвычайно быстро растет с увеличением N. В табл. 5.1 приведены 10 первых значений функции факториала.
Можно также определить функцию факториала рекурсивно:
0! = 1
N! = N * (N - 1)! для N > 0.
@Рис. 5.1. Дерево, составленное из двух деревьев меньшего размера
===========82
@Таблица 5.1. Значения функции факториала
Легко написать на основе этого определения рекурсивную функцию:
Public Function Factorial(num As Integer) As Integer
If num <= 0 Then
Factorial = 1
Else
Factorial = num * Factorial(num - 1)
End If
End Function
Вначале эта функция проверяет, что число меньше или равно 0. Факториал для чисел меньше нуля не определен, но это условие проверяется для подстраховки. Если бы функция проверяла только условие равенства числа нулю, то для отрицательных чисел рекурсия была бы бесконечной.
Если входное значение меньше или равно 0, функция возвращает значение 1. В остальных случаях, значение функции равно произведению входного значения на факториал от входного значения, уменьшенного на единицу.
То, что эта рекурсивная функция в конце концов остановится, гарантируется двумя фактами. Во‑первых, при каждом последующем вызове, значение параметра num уменьшается на единицу. Во‑вторых, значение num ограничено снизу нулем. Когда num становится равным 0, функция останавливает рекурсию. Условие, например, в данном случае условие num<=0, называется или условием остановки рекурсии (base case или stopping case).
При каждом вызове подпрограммы, система сохраняет ряд параметров в системном стеке, как описывалось в 3 главе. Так как этот стек играет важную роль, иногда его называют просто стеком. Если рекурсивная функция вызовет себя слишком много раз, она может исчерпать стековое пространство и аварийно завершить работу с ошибкой «Out of stack space».
Число раз, которое функция может вызвать сама себя до того, как использует все стековое пространство, зависит от объема установленной на компьютере памяти и количества данных, помещаемых программой в стек. В одном из тестов, программа исчерпала стековое пространство после 452 рекурсивных вызовов. После изменения рекурсивной функции таким образом, чтобы она определяла 10 локальных переменных при каждом вызове, программа могла вызвать себя только 271 раз.
Функции факториала требуется единственный аргумент: число, факториал от которого требуется вычислить. Анализ вычислительной сложности алгоритма обычно исследует зависимость времени выполнения программы как функции от размерности (size) задачи или числа входных значений (number of inputs). Поскольку в данном случае входное значение всего одно, такие расчеты могли бы показаться немного странными.
========83
Поэтому, алгоритмы с единственным входным параметром обычно оцениваются через число битов, необходимых для хранения входного значения, а не число входных значений. В некотором смысле, это и есть размер входа, так как столько бит требуется для того, чтобы записать входное значение. Тем не менее, это не очень наглядный способ представления этой задачи. Кроме того, теоретически компьютер мог бы записать входное значение N в log2(N) бит, но в действительности вероятнее всего N занимает фиксированное число битов. Например, все числа формата long занимают 32 бита.
Поэтому в этой главе алгоритмы этого типа анализируются на основе значения входа, а не его размерности. Если вы хотите переписать результаты в терминах размерности входа, вы можете это сделать, воспользовавшись тем, что N=2M, где М — число битов, необходимое для записи N. Если время выполнения алгоритма порядка O(N2) в терминах входного значения N, то оно составит порядка O((22M)2) = O(22*M) = O((22)M) = O(4M) в терминах размерности входа M.
Функции порядка O(N) растут довольно медленно, поэтому можно ожидать от этого алгоритма хорошей производительности. Так оно и есть. Эта функция приводит к проблемам только при переполнении стека после множества рекурсивных вызовов, или когда значение N! становится слишком большим и не помещается в формат целого числа, вызывая ошибку переполнения.
Так как N! растет очень быстро, переполнение наступает раньше, если только стек не используется интенсивно для других целей. При использовании данных целого типа, переполнение наступает для 8!, поскольку 8! = 40.320, что больше, чем наибольшее целое число 32.767. Для того чтобы программа могла вычислять приближенные значения факториала больших чисел, можно изменить функцию, используя вместо целых чисел значения типа double. Тогда максимальное число, которое сможет вычислить алгоритм, будет равно 170! = 7,257E+306.
Программа Facto демонстрирует рекурсивную функцию факториала. Введите значение и нажмите на кнопку Go, чтобы вычислить его факториал.
Наибольшим общим делителем (greatest common divisor, GCD) двух чисел называется наибольшее целое, на которое делятся два числа без остатка. Например, наибольший общий делитель чисел 12 и 9 равен 3. Два числа называются взаимно простыми (relatively prime), если их наибольший общий делитель равен 1.
Математик Эйлер, живший в восемнадцатом веке, обнаружил интересный факт:
Если A нацело делится на B, то GCD(A, B) = A.
Иначе GCD(A, B) = GCD(B Mod A, A).
Этот факт можно использовать для быстрого вычисления наибольшего общего делителя. Например:
GCD(9, 12) = GCD(12 Mod 9, 9)
= GCD(3, 9)
= 3
========84
На каждом шаге числа становятся все меньше, так как 1<=B Mod A<A, если A не делится на B нацело. По мере уменьшения аргументов, в конце концов, A примет значение 1. Так как любое число делится на 1 нацело, на этом шаге рекурсия остановится. Таким образом, в какой то момент B разделится на A нацело, и работа процедуры завершится.
Открытие Эйлера закономерным образом приводит к рекурсивному алгоритму вычисления наибольшего общего делителя:
public Function GCD(A As Integer, B As Integer) As Integer
If B Mod A = 0 Then ' Делится ли B на A нацело?
GCD = A ' Да. Процедура завершена.
Else
GCD = GCD(B Mod A, A) ' Нет. Рекурсия.
End If
End Function
Чтобы проанализировать время выполнения этого алгоритма, необходимо определить, насколько быстро убывает переменная A. Так как функция останавливается, когда A доходит до значения 1, то скорость уменьшения A дает верхнюю границу оценки времени выполнения алгоритма. Оказывается, при каждом втором вызове функции GCD, параметр A уменьшается, по крайней мере, в 2 раза.
Допустим, A < B. Это условие всегда выполняется при первом вызове функции GCD. Если B Mod A <= A/2, то при следующем вызове функции GCD первый параметр уменьшится, по крайней мере, в 2 раза, и доказательство закончено.
Предположим обратное. Допустим, B Mod A > A / 2. Первым рекурсивным вызовом функции GCD будет GCD(B Mod A, A).
Подстановка в функцию значения B Mod A и A вместо A и B дает следующий рекурсивный вызов GCD(B Mod A, A).
Но мы предположили, что B Mod A > A / 2. Тогда B Mod A разделится на A только один раз, с остатком A – (B Mod A). Так как B Mod A больше, чем A / 2, то A – (B Mod A) должно быть меньше, чем A / 2. Значит, первый параметр второго рекурсивного вызова функции GCD меньше, чем A / 2, что и требовалось доказать.
Предположим теперь, что N — это исходное значение параметра A. После двух вызовов функции GCD, значение параметра A должно уменьшится, по крайней мере, до N / 2. После четырех вызовов, это значение будет не больше, чем (N / 2) / 2 = N / 4. После шести вызовов, значение не будет превосходить (N / 4) / 2 = N / 8. В общем случае, после 2 * K вызовов функции GCD, значение параметра A будет не больше, чем N / 2K.
Поскольку алгоритм должен остановиться, когда значение параметра A дойдет до 1, он может продолжать работу только до тех, пока не выполняется равенство N/2K=1. Это происходит, когда N=2K или когда K=log2(N). Так как алгоритм выполняется за 2*K шагов это означает, что алгоритм остановится не более, чем через 2*log2(N) шагов. С точностью до постоянного множителя, это означает, что алгоритм выполняется за время порядка O(log(N)).
=======85
Этот алгоритм — один из множества рекурсивных алгоритмов, которые выполняются за время порядка O(log(N)). При выполнении фиксированного числа шагов, в данном случае 2, размер задачи уменьшается вдвое. В общем случае, если размер задачи уменьшается, по меньшей мере, в D раз после каждых S шагов, то задача потребует S*logD(N) шагов.
Поскольку при оценке по порядку величины можно игнорировать постоянные множители и основания логарифмов, то любой алгоритм, который выполняется за время S*logD(N), будет алгоритмом порядка O(log(N)). Это не обязательно означает, что этими постоянными можно полностью пренебречь при реализации алгоритма. Алгоритм, который уменьшает размер задачи при каждом шаге в 10 раз, вероятно, будет быстрее, чем алгоритм, который уменьшает размер задачи вдвое через каждые 5 шагов. Тем не менее, оба эти алгоритма имеют время выполнения порядка O(log(N)).
Алгоритмы порядка O(log(N)) обычно выполняются очень быстро, и алгоритм нахождения наибольшего общего делителя не является исключением из этого правила. Например, чтобы найти, что наибольший общий делитель чисел 1.736.751.235 и 2.135.723.523 равен 71, функция вызывается всего 17 раз. Фактически, алгоритм практически мгновенно вычисляет значения, не превышающие максимального значения числа в формате long — 2.147.483.647. Функция Visual Basic Mod не может оперировать значениями, большими этого, поэтому это практический предел для данной реализации алгоритма.
Программа GCD использует этот алгоритм для рекурсивного вычисления наибольшего общего делителя. Введите значения для A и B, затем нажмите на кнопку Go, и программа вычислит наибольший общий делитель этих двух чисел.
Можно рекурсивно определить числа Фибоначчи (Fibonacci numbers) при помощи уравнений:
Fib(0) = 0
Fib(1) = 1
Fib(N) = Fib(N - 1) + Fib(N - 2) для N > 1.
Третье уравнение рекурсивно дважды вызывает функцию Fib, один раз с входным значением N-1, а другой — со значением N-2. Это определяет необходимость 2 условий остановки рекурсии: Fib(0)=0 и Fib(1)=1. Если задать только одно из них, рекурсия может оказаться бесконечной. Например, если задать только Fib(0)=0, то значение Fib(2) могло бы вычисляться следующим образом:
Fib(2) = Fib(1) + Fib(0)
= [Fib(0) + Fib(-1)] + 0
= 0 + [Fib(-2) + Fib(-3)]
= [Fib(-3) + Fib(-4)] + [Fib(-4) + Fib(-5)]
И т.д.
Это определение чисел Фибоначчи легко преобразовать в рекурсивную функцию:
Public Function Fib(num As Integer) As Integer
If num <= 1 Then
Fib = num
Else
Fib = Fib(num – 1) + Fib(num - 2)
End If
End Function
=========86
Анализ этого алгоритма достаточно сложен. Во‑первых, определим, сколько раз выполняется одно из условий остановки num <=1. Пусть G(N) — количество раз, которое алгоритм достигает условия остановки для входа N. Если N <= 1, то функция достигает условия остановки один раз и не требует рекурсии.
Если N > 1, то функция рекурсивно вычисляет Fib(N-1) и Fib(N-2), и завершает работу. При первом вызове функции, условие остановки не выполняется — оно достигается только в следующих, рекурсивных вызовах. Полное число выполнения условия остановки для входного значения N, складывается из числа раз, которое оно выполняется для значения N-1 и числа раз, которое оно выполнялось для значения N-2. Все это можно записать так:
G(0) = 1
G(1) = 1
G(N) = G(N - 1) + G(N - 2) для N > 1.
Это рекурсивное определение очень похоже на определение чисел Фибоначчи. В табл. 5.2 приведены некоторые значения функций G(N) и Fib(N). Легко увидеть, что G(N) = Fib(N+1).
Теперь рассмотрим, сколько раз алгоритм достигает рекурсивного шага. Если N<=1, функция не достигает этого шага. При N>1, функция достигает этого шага 1 раз и затем рекурсивно вычисляет Fib(n-1) и Fib(N-2). Пусть H(N) — число раз, которое алгоритм достигает рекурсивного шага для входа N. Тогда H(N)=1+H(N-1)+H(N-2). Уравнения, определяющие H(N):
H(0) = 0
H(1) = 0
H(N) = 1 + H(N - 1) + H(N - 2) для N > 1.
В табл. 5.3 показаны некоторые значения для функций Fib(N) и H(N). Можно увидеть, что H(N)=Fib(N+1)-1.
@Таблица 5.2. Значения чисел Фибоначчи и функции G(N)
======87
@Таблица 5.3. Значения чисел Фибоначчи и функции H(N)
Объединяя результаты для 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)>ÆM-2 где Æ — константа, примерно равная 1,6. Это означает, что время выполнения не меньше, чем значение экспоненциальной функции O(ÆM). Как и другие экспоненциальные функции, эта функция растет быстрее, чем полиномиальные функции, но медленнее, чем функция факториала.
Поскольку время выполнения растет очень быстро, этот алгоритм довольно медленно выполняется для больших входных значений. Фактически, настолько медленно, что на практике почти невозможно вычислить значения функции Fib(N) для N, которые намного больше 30. В табл. 5.4 показано время выполнения для этого алгоритма на компьютере с процессором Pentium с тактовой частотой 90 МГц при разных входных значениях.
Программа Fibo использует этот рекурсивный алгоритм для вычисления чисел Фибоначчи. Введите целое число и нажмите на кнопку Go для вычисления чисел Фибоначчи. Начните с небольших чисел, пока не оцените, насколько быстро ваш компьютер может выполнять эти вычисления.
Кривые Гильберта (Hilbert curves) — это самоподобные (self‑similar) кривые, которые обычно определяются при помощи рекурсии. На рис. 5.2. показаны кривые Гильберта с 1, 2 или 3 порядка.
@Таблица 5.4. Время выполнения программы Fibonacci
=====88
@Рис. 5.2. Кривые Гильберта
Кривая Гильберта, как и любая другая самоподобная кривая, создается разбиением большой кривой на меньшие части. Затем вы можете использовать эту же кривую, после изменения размера и поворота, для построения этих частей. Эти части можно разбить на более мелкие части, и так далее, пока процесс не достигнет нужной глубины рекурсии. Порядок кривой определяется как максимальная глубина рекурсии, которой достигает процедура.
Процедура Hilbert управляет глубиной рекурсии, используя соответствующий параметр. При каждом рекурсивном вызове, процедура уменьшает параметр глубины рекурсии на единицу. Если процедура вызывается с глубиной рекурсии, равной 1, она рисует простую кривую 1 порядка, показанную на рис. 5.2 слева и завершает работу. Это условие остановки рекурсии.
Например, кривая Гильберта 2 порядка состоит из четырех кривых Гильберта 1 порядка. Аналогично, кривая Гильберта 3 порядка состоит из четырех кривых 2 порядка, каждая из которых состоит из четырех кривых 1 порядка. На рис. 5.3 показаны кривые Гильберта 2 и 3 порядка. Меньшие кривые, из которых построены кривые большего размера, выделены полужирными линиями.
Следующий код строит кривую Гильберта 1 порядка:
Line -Step (Length, 0)
Line -Step (0, Length)
Line -Step (-Length, 0)
Предполагается, что рисование начинается с верхнего левого угла области и что Length — это заданная длина каждого отрезка линий.
Можно набросать черновик метода, рисующего кривые Гильберта более высоких порядков:
Private Sub Hilbert(Depth As Integer)
If Depth = 1 Then
Нарисовать кривую Гильберта 1 порядка
Else
Нарисовать и соединить 4 кривые порядка (Depth - 1)
End If
End Sub
====89
@Рис. 5.3. Кривые Гильберта, образованные меньшими кривыми
Этот метод требует небольшого усложнения для определения направления рисования кривых. Это требуется для того, чтобы выбрать тип используемых кривых Гильберта.
Эту информацию можно передать процедуре при помощи параметров Dx и Dy для определения направления вывода первой линии в кривой. Для кривой 1 порядка, процедура рисует первую линию при помощи функции Line-Step(Dx, Dy). Если кривая имеет более высокий порядок, процедура соединяет первые две подкривых, используя функцию Line-Step(Dx, Dy). В любом случае, процедура может использовать параметры Dx и Dy для выбора направления, в котором она должна рисовать линии, образующие кривую.
Код на языке Visual Basic для рисования кривых Гильберта короткий, но сложный. Вам может потребоваться несколько раз пройти его в отладчике для кривых 1 и 2 порядка, чтобы увидеть, как изменяются параметры Dx и Dy, при построении различных частей кривой.
Private Sub Hilbert(depth As Integer, Dx As Single, Dy As Single)
If depth > 1 Then Hilbert depth - 1, Dy, Dx
HilbertPicture.Line -Step(Dx, Dy)
If depth > 1 Then Hilbert depth - 1, Dx, Dy
HilbertPicture.Line -Step(Dy, Dx)
If depth > 1 Then Hilbert depth - 1, Dx, Dy
HilbertPicture.Line -Step(-Dx, -Dy)
If depth > 1 Then Hilbert depth - 1, -Dy, -Dx
End Sub
Чтобы проанализировать время выполнения этой процедуры, вы можете определить число вызовов процедуры Hilbert. При каждой рекурсии она вызывает себя четыре раза. Если T(N) — это число вызовов процедуры, когда она вызывается с глубиной рекурсии N, то:
T(1) = 1
T(N) = 1 + 4 * T(N - 1) для N > 1.
Если раскрыть определение T(N), получим:
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)
= ...
= 40 + 41 + 42 + 43 + ... + 4K * T(N - K)
Раскрыв это уравнение до тех пор, пока не будет выполнено условие остановки рекурсии T(1)=1, получим:
T(N) = 40 + 41 + 42 + 43 + ... + 4N-1
Это уравнение можно упростить, воспользовавшись соотношением:
X0 + X1 + X2 + X3 + ... + XM = (XM+1 - 1) / (X - 1)
После преобразования, уравнение приводится к виду:
T(N) = (4(N-1)+1 - 1) / (4 - 1)
= (4N - 1) / 3
=====90
С точностью до постоянных, эта процедура выполняется за время порядка O(4N). В табл. 5.5 приведены несколько первых значений функции времени выполнения. Если вы внимательно посмотрите на эти числа, то увидите, что они соответствуют рекурсивному определению.
Этот алгоритм является типичным примером рекурсивного алгоритма, который выполняется за время порядка O(CN), где C — некоторая постоянная. При каждом вызове подпрограммы Hilbert, она увеличивает размерность задачи в 4 раза. В общем случае, если при каждом выполнении некоторого числа шагов алгоритма размер задачи увеличивается не менее, чем в C раз, то время выполнения алгоритма будет порядка O(CN).
Это поведение противоположно поведению алгоритма поиска наибольшего общего делителя. Процедура GCD уменьшает размерность задачи в 2 раза при каждом втором своем вызове, и поэтому время ее выполнения порядка O(log(N)). Процедура построения кривых Гильберта увеличивает размер задачи в 4 раза при каждом своем вызове, поэтому время ее выполнения порядка O(4N).
Функция (4N-1)/3 — это экспоненциальная функция, которая растет очень быстро. Фактически, она растет настолько быстро, что вы можете предположить, что это не слишком эффективный алгоритм. В действительности работа этого алгоритма занимает много времени, но есть две причины, по которым это не так уж и плохо.
Во-первых, ни один алгоритм для построения кривых Гильберта не может быть намного быстрее. Кривые Гильберта содержат множество отрезков линий, и любой рисующий их алгоритм будет требовать достаточно много времени. При каждом вызове процедуры Hilbert, она рисует три линии. Пусть L(N) — суммарное число линий, из которых состоит кривая Гильберта порядка N. Тогда L(N) = 3 * T(N) = 4N - 1, поэтому L(N) также порядка O(4N). Любой алгоритм, рисующий кривые Гильберта, должен вывести O(4N) линий, выполнив при этом O(4N) шагов. Существуют другие алгоритмы построения кривых Гильберта, но они занимают почти столько же времени, сколько и этот алгоритм.
@Таблица 5.5. Число рекурсивных вызовов подпрограммы Hilbert
=====91
Второй факт, который показывает, что этот алгоритм не так уж плох, заключается в том, что кривые Гильберта 9 порядка содержат так много линий, что экран большинства компьютерных мониторов при этом оказывается полностью закрашенным. Это неудивительно, так как эта кривая содержит 262.143 отрезков линий. Это означает, что вам вероятно никогда не понадобится выводить на экран кривые Гильберта 9 или более высоких порядков. На каком‑то порядке вы столкнетесь с ограничениями языка Visual Basic и вашего компьютера, но, скорее всего, вы еще раньше будете ограничены максимальным разрешением экрана.
Программа Hilbert, показанная на рис. 5.4, использует этот рекурсивный алгоритм для рисования кривых Гильберта. При выполнении программы не задавайте слишком большую глубину рекурсии (больше 6) до тех пор, пока вы не определите, насколько быстро выполняется эта программа на вашем компьютере.
Как и кривые Гильберта, кривые Серпинского (Sierpinski curves) — это самоподобные кривые, которые обычно определяются рекурсивно. На рис. 5.5 показаны кривые Серпинского 1, 2 и 3 порядка.
Алгоритм построения кривых Гильберта использует всего одну подпрограмму для рисования кривых. Кривые Серпинского проще рисовать, используя четыре отдельных процедуры, которые работают совместно. Эти процедуры называются SierpA, SierpB, SierpC и SierpD. Это процедуры с косвенной рекурсией — каждая процедура вызывает другие, которые затем вызывают первоначальную процедуру. Они рисуют верхнюю, левую, нижнюю и правую части кривой Серпинского, соответственно.
На рис. 5.6 показано, как эти процедуры работают совместно, образуя кривую Серпинского 1 порядка. Подкривые изображены стрелками, чтобы показать направление, в котором они рисуются. Отрезки, соединяющие четыре подкривые, нарисованы пунктирными линиями.
@Рис. 5.4. Программа Hilbert
=====92
@Рис. 5.5. Кривые Серпинского
Каждая из четырех основных кривых состоит из диагонального отрезка, затем вертикального или горизонтального отрезка, и еще одного диагонального отрезка. Если глубина рекурсии больше единицы, каждая из этих кривых разбивается на меньшие части. Это осуществляется разбиением каждого из двух диагональных отрезков на две подкривые.
Например, для разбиения кривой типа A, первый диагональный отрезок разбивается на кривую типа A, за которой следует кривая типа B. Затем рисуется без изменений горизонтальный отрезок из исходной кривой типа A. Наконец, второй диагональный отрезок разбивается на кривую типа D, за которой следует кривая типа A. На рис. 5.7 показано, как кривая типа A второго порядка образуется из нескольких кривых 1 порядка. Подкривые изображены жирными линиями.
На рис. 5.8 показано, как полная кривая Серпинского 2 порядка образуется из 4 подкривых 1 порядка. Каждая из подкривых обведена контурной линией.
Можно использовать стрелки ä и ã для обозначения типа линий, соединяющих подкривые (тонкие линии на рис. 5.8), тогда можно будет изобразить рекурсивные отношения между четырьмя типами кривых так, как это показано на рис. 5.9.
@Рис. 5.6. Части кривой Серпинского
=====93
@Рис. 5.7. Разбиение кривой типа A
Все процедуры для построения подкривых Серпинского очень похожи, поэтому мы приводим здесь только одну из них. Соотношения на рис. 5.9 показывают, какие операции нужно выполнить для рисования кривых различных типов. Соотношения для кривой типа A реализованы в следующем коде. Вы можете использовать остальные соотношения, чтобы определить, какие изменения нужно внести в код для рисования кривых других типов.
Private Sub SierpA(Depth As Integer, Dist As Single)
If Depth = 1 Then
Line -Step(-Dist, Dist)
Line -Step(-Dist, 0)
Line -Step(-Dist, -Dist)
Else
SierpA Depth - 1, Dist
Line -Step(-Dist, Dist)
SierpB Depth - 1, Dist
Line -Step(-Dist, 0)
SierpD Depth - 1, Dist
Line -Step(-Dist, -Dist)
SierpA Depth - 1, Dist
End If
End Sub
@Рис. 5.8. Кривые Серпинского, образованные из меньших кривых Серпинского
=====94
@Рис. 5.9. Рекурсивные соотношения между кривыми Серпинского
Кроме процедур, которые рисуют каждую из основных кривых, потребуется еще процедура, которая по очереди вызывает их все для создания законченной кривой Серпинского.
Sub Sierpinski (Depth As Integer, Dist As Single)
SierpB Depth, Dist
Line -Step(Dist, Dist)
SierpC Depth, Dist
Line -Step(Dist, -Dist)
SierpD Depth, Dist
Line -Step(-Dist, -Dist)
SierpA Depth, Dist
Line -Step(-Dist, Dist)
End Sub
Чтобы проанализировать время выполнения этого алгоритма, необходимо определить число вызовов для каждой из четырех процедур рисования кривых. Пусть T(N) — число вызовов любой из четырех основных подпрограмм основной процедуры Sierpinski при построении кривой порядка N.
Если порядок кривой равен 1, кривая каждого типа рисуется только один раз. Прибавив сюда основную процедуру, получим T(1) = 5.
При каждом рекурсивном вызове, процедура вызывает саму себя или другие процедуры четыре раза. Так как эти процедуры практически одинаковые, то T(N) будет одинаковым, независимо от того, какая процедура вызывается первой. Это обусловлено тем, что кривые Серпинского симметричны и содержат одно и то же число кривых разных типов. Рекурсивные уравнения для T(N) выглядят так:
T(1) = 5
T(N) = 1 + 4 * T(N-1) для N > 1.
Эти уравнения почти совпадают с уравнениями, которые использовались для оценки времени выполнения алгоритма, рисующего кривые Гильберта. Единственное отличие состоит в том, что для кривых Гильберта T(1) = 1. Сравнение значений этих уравнений показывает, что TSierpinski(N) = THilbert(N+1). В конце предыдущего раздела было показано, что THilbert(N) = (4N - 1) / 3, поэтому TSierpinski(N) = (4N+1 - 1) / 3, что также составляет O(4N).
=====95
Так же, как и алгоритм построения кривых Гильберта, этот алгоритм выполняется за время порядка O(4N), но это не так уж и плохо. Кривая Серпинского состоит из O(4N) линий, поэтому ни один алгоритм не может нарисовать кривую Серпинского быстрее, чем за время порядка O(4N).
Кривые Серпинского также полностью заполняют экран большинства компьютеров при порядке кривой, большем или равном 9. При каком‑то порядке, большем 9, вы столкнетесь с ограничениями языка Visual Basic и возможностей вашего компьютера, но, скорее всего, вы еще раньше будете ограничены предельным разрешением экрана.
Программа Sierp, показанная на рис. 5.10, использует этот рекурсивный алгоритм для рисования кривых Серпинского. При выполнении программы, задавайте вначале небольшую глубину рекурсии (меньше 6), до тех пор, пока вы не определите, насколько быстро выполняется эта программа на вашем компьютере.
Рекурсия может служить мощным методом разбиения больших задач на части, но она таит в себе несколько опасностей. В этом разделе мы пытаемся охватить некоторые из этих опасностей и объяснить, когда стоит и не стоит использовать рекурсию. В последующих разделах приводятся методы устранения от рекурсии, когда это необходимо.
Наиболее очевидная опасность рекурсии заключается в бесконечной рекурсии. Если неправильно построить алгоритм, то функция может пропустить условие остановки рекурсии и выполняться бесконечно. Проще всего совершить эту ошибку, если просто забыть о проверке условия остановки, как это сделано в следующей ошибочной версии функции факториала. Поскольку функция не проверяет, достигнуто ли условие остановки рекурсии, она будет бесконечно вызывать сама себя.
@Рис. 5.10 Программа Sierp
=====96
Private Function BadFactorial(num As Integer) As Integer
BadFactorial = num * BadFactorial (num - 1)
End Function
Функция также может вызывать себя бесконечно, если условие остановки не прекращает все возможные пути рекурсии. В следующей ошибочной версии функции факториала, функция будет бесконечно вызывать себя, если входное значение — не целое число, или если оно меньше 0. Эти значения не являются допустимыми входными значениями для функции факториала, поэтому в программе, которая использует эту функцию, может потребоваться проверка входных значений. Тем не менее, будет лучше, если функция выполнит эту проверку сама.
Private Function BadFactorial2(num As Double) As Double
If num = 0 Then
BadFactorial2 = 1
Else
BadFactorial2 = num * BadFactorial2(num-1)
End If
End Function
Следующая версия функции Fibonacci является более сложным примером. В ней условие остановки рекурсии прекращает выполнение только нескольких путей рекурсии, и возникают те же проблемы, что и при выполнении функции BadFactorial2, если входные значения отрицательные или не целые.
Private Function BadFib(num As Double) As Double
If num = 0 Then
BadFib = 0
Else
BadFib = BadPib(num - 1) + BadFib (num - 2)
End If
End Function
И последняя проблема, связанная с бесконечной рекурсией, заключается в том, что «бесконечная» на самом деле означает «до тех пор, пока не будет исчерпано стековое пространство». Даже корректно написанные рекурсивные процедуры будут иногда приводить к переполнению стека и аварийному завершению работы. Следующая функция, которая вычисляет сумму N + (N - 1) + … + 2 +1, приводит к исчерпанию стекового пространства при больших значениях N. Наибольшее возможное значение N, при котором программа еще будет работать, зависит от конфигурации вашего компьютера.
Private Function BigAdd(N As Double) As Double
If N <= 1 Then
BigAdd = 1
Else
BigAdd = N + BigAdd(N - 1)
End If
End Function
=====97
Программа BigAdd демонстрирует этот алгоритм. Проверьте, насколько большое входное значение вы можете ввести в этой программе до того, как наступит переполнение стека на вашем компьютере.
Другая опасность рекурсии заключается в потерях памяти. При каждом вызове подпрограммы, система выделяет память для локальных переменных новой процедуры. Во время сложной последовательности рекурсивных вызовов, значительная часть времени и памяти компьютера будет уходить на выделение и освобождение памяти для этих переменных во время рекурсии. Даже если это не приведет к исчерпанию стекового пространства, время, потраченное на работу с переменными, может быть значительным.
Существует несколько способов уменьшения этих накладных расходов. Во‑первых, не следует использовать большого количества ненужных переменных. Даже если подпрограмма не использует их, Visual Basic все равно будет отводить память под эти переменные. Следующая версия функции BigAdd еще быстрее приводит к переполнению стека, чем предыдущая.
Private Function BigAdd(N As Double) As Double
Dim I1 As Integer
Dim I2 As Integer
Dim I3 As Integer
Dim I4 As Integer
Dim I5 As Integer
If N <= 1 Then
BigAdd = 1
Else
BigAdd = N + BigAdd (N - 1)
End If
End Function
Если вы не уверены, нужна ли переменная, используйте оператор Option Explicit и закомментируйте определение переменной. При попытке выполнить программу, Visual Basic сообщит об ошибке, если переменная используется в программе.
Вы также можете уменьшить использование стека за счет применения глобальных переменных. Если вы определите переменные в секции Declarations модуля вместо того, чтобы определять их в подпрограмме, то системе не понадобится отводить память при каждом вызове подпрограммы.
Лучшим решением будет определение переменных в процедуре при помощи зарезервированного слова Static. Статические переменные используются совместно всеми экземплярами процедуры, и системе не нужно отводить память под новые копии переменных при каждом вызове подпрограммы.
Менее очевидной опасностью является необоснованное применение рекурсии. При этом использование рекурсии не является наилучшим способом решения задачи. Приведенные выше функции факториала, наибольшего общего делителя, чисел Фибоначчи и функции BigAdd не обязательно должны быть рекурсивными. Лучшие, не рекурсивные версии этих функций описываются позже в этой главе.
=====98
В случае факториала и наибольшего общего делителя, ненужная рекурсия является по большей части безвредной. Обе эти функции выполняются достаточно быстро для достаточно больших выходных значений. Их выполнение также не будет ограничено размером стека, если вы не использовали большую часть стекового пространства в других частях программы.
С другой стороны, применение рекурсии ухудшает алгоритм вычисления чисел Фибоначчи. Для вычисления Fib(N), алгоритм вначале вычисляет Fib(N - 1) и Fib(N - 2). Но для вычисления Fib(N - 1) он должен сначала вычислить Fib(N - 2) и Fib(N - 3). При этом Fib(N - 2) вычисляется дважды.
Предыдущий анализ этого алгоритма показал, что Fib(1) и Fib(0) вычисляются Fib(N + 1) раз во время вычисления Fib(N). Так как Fib(30) = 832.040 то, чтобы вычислить Fib(29), приходится вычислять одни и те же значения Fib(0) и Fib(1) 832.040 раз. Алгоритм вычисления чисел Фибоначчи тратит огромное количество времени на вычисление этих промежуточных значений снова и снова.
В функции BigAdd существует другая проблема. Хотя она выполняется быстро, она приводит к большой глубине вложенности рекурсии, и очень быстро приводит к исчерпанию стекового пространства. Если бы не переполнение стека, то эта функция могла бы вычислять результаты для больших входных значений.
Похожая проблема существует и в функции факториала. Для входного значения N глубина рекурсии для факториала и функции BigAdd равна N. Функция факториала не может быть вычислена для таких больших входных значений, которые допустимы для функции BigAdd. Максимальное значение факториала, которое может уместиться в переменной типа double, равно 170! » 7,257E+306, поэтому это наибольшее значение, которое может вычислить эта функция. Хотя эта функция приводит к глубокой рекурсии, она вызывает переполнение до того, как наступит переполнение стека.
Эти рассуждения могут заставить вас думать, что рекурсия всегда нежелательна. Но это определенно не так. Многие алгоритмы являются рекурсивными по своей природе. И хотя любой алгоритм можно переписать так, чтобы он не содержал рекурсии, многие алгоритмы сложнее понимать, анализировать, отлаживать и поддерживать, если они написаны нерекурсивно.
В следующих разделах приведены методы устранения рекурсии из любого алгоритма. Некоторые из полученных нерекурсивных алгоритмов также просты в понимании. Функции, вычисляющие без применения рекурсии факториал, наибольший общий делитель, числа Фибоначчи, и функцию BigAdd, относительно просты.
С другой стороны, нерекурсивные версии алгоритмов построений кривых Гильберта и Серпинского намного сложнее. Их труднее понять, поддерживать, и они даже выполняются немного медленнее, чем рекурсивные версии. Они приведены лишь для того, чтобы продемонстрировать методы, которые вы можете использовать для устранения рекурсии из сложных алгоритмов, а не потому, что они лучше, чем рекурсивные версии соответствующих алгоритмов.
Если алгоритм рекурсивен по природе, записывайте его с использованием рекурсии. В лучшем случае, вы не встретитесь ни одной из описанных проблем. Если же вы столкнетесь с некоторыми из них, вы сможете переписать алгоритм без использования рекурсии при помощи методов, представленных в следующих разделах. Переписать алгоритм часто гораздо проще, чем с самого начала написать его без применения рекурсии.
======99
Вспомним представленные ранее функции для вычисления факториалов и наибольшего общего делителя, а также функцию BigAdd, которая приводит к переполнению стека даже для относительно небольших входных значений.
Private Function Factorial(num As Integer) As Integer
If num <= 0 Then
Factorial = 1
Else
Factorial = num * Factorial(num - 1)
End If
End Function
Private Function GCD(A As Integer, B As Integer) As Integer
If B Mod A = 0 Then
GCD = A
Else
GCD = GCD(B Mod A, A)
End If
End Function
Private Function BigAdd(N As Double) As Double
If N <= 1 Then
BigAdd = 1
Else
BigAdd = N + BigAdd(N - 1)
End If
End Function
Во всех этих функциях, последнее действие перед завершением функции — это рекурсивный шаг. Этот тип рекурсии в конце процедуры называется хвостовой рекурсией (tail recursion или end recursion).
Так как после рекурсии в процедуре ничего не происходит, существует простой способ ее устранения. Вместо рекурсивного вызова функции, процедура сбрасывает свои параметры, устанавливая те, которые бы она получила при рекурсивном вызове, и затем выполняется снова.
Рассмотрим общий случай рекурсивной процедуры:
Private Sub Recurse(A As Integer)
' Выполняются какие‑либо действия, вычисляется B, и т.д.
Recurse B
End Sub
======100
Эту процедуру можно переписать без рекурсии как:
Private Sub NoRecurse(A As Integer)
Do While (not done)
' Выполняются какие‑либо действия, вычисляется B, и т.д.
A = B
Loop
End Sub
Эта процедура называется устранением хвостовой рекурсии (tail recursion removal или end recursion removal). Этот прием не изменяет время выполнения программы. Рекурсивные шаги просто заменяются проходами в цикле While.
Устранение хвостовой рекурсии, тем не менее, устраняет вызовы подпрограмм, и поэтому может увеличить скорость работы алгоритма. Что более важно, этот метод также уменьшает использование стека. Алгоритмы типа функции BigAdd, которые ограничены глубиной рекурсии, могут от этого значительно выиграть.
Некоторые компиляторы автоматически устраняют хвостовую рекурсию, но компилятор Visual Basic этого не делает. В противном случае, функция BigAdd, приведенная в предыдущем разделе, не приводила бы к переполнению стека.
Используя устранение хвостовой рекурсии, легко переписать функции факториала, наибольшего общего делителя, и BigAdd без рекурсии. Эти версии используют зарезервированное слово ByVal для сохранения значений своих параметров для вызывающей процедуры.
Private Function Factorial(ByVal N As Integer) As Double
Dim value As Double
value = 1# ' Это будет значением функции.
Do While N > 1
value = value * N
N = N - 1 ' Подготовить аргументы для "рекурсии".
Loop
Factorial = value
End Function
Private Function GCD(ByVal A As Double, ByVal B As Double) As Double
Dim B_Mod_A As Double
B_Mod_A = B Mod A
Do While B_Mod_A <> 0
' Подготовить аргументы для "рекурсии".
B = A
A = B_Mod_A
B_Mod_A = B Mod A
Loop
GCD = A
End Function
Private Function BigAdd(ByVal N As Double) As Double
Dim value As Double
value = 1# ' ' Это будет значением функции.
Do While N > 1
value = value + N
N = N - 1 ' подготовить параметры для "рекурсии".
Loop
BigAdd = value
End Function
=====101
Для алгоритмов вычисления факториала и наибольшего общего делителя практически не существует разницы между рекурсивной и нерекурсивной версиями. Обе версии выполняются достаточно быстро, и обе они могут оперировать задачами большой размерности.
Для функции BigAdd, тем не менее, разница огромна. Рекурсивная версия приводит к переполнению стека даже для довольно небольших входных значений. Поскольку нерекурсивная версия не использует стек, она может вычислять результат для значений N вплоть до 10154. После этого наступит переполнение для данных типа double. Конечно, выполнение 10154 шагов алгоритма займет очень много времени, поэтому возможно вы не станете проверять этот факт сами. Заметим также, что значение этой функции совпадает со значением более просто вычисляемой функции N * N(N + 1) / 2.
Программы Facto2, GCD2 и BigAdd2 демонстрируют эти нерекурсивные алгоритмы.
К сожалению, нерекурсивный алгоритм вычисления чисел Фибоначчи не содержит только хвостовую рекурсию. Этот алгоритм использует два рекурсивных вызова для вычисления значения, и второй вызов следует после завершения первого. Поскольку первый вызов не находится в самом конце функции, то это не хвостовая рекурсия, и от ее нельзя избавиться, используя прием устранения хвостовой рекурсии.
Это может быть связано и с тем, что ограничение рекурсивного алгоритма вычисления чисел Фибоначчи связано с тем, что он вычисляет слишком много промежуточных значений, а не глубиной вложенности рекурсии. Устранение хвостовой рекурсии уменьшает глубину рекурсии, но оно не изменяет время выполнения алгоритма. Даже если бы устранение хвостовой рекурсии было бы применимо к алгоритму вычисления чисел Фибоначчи, этот алгоритм все равно остался бы чрезвычайно медленным.
Проблема этого алгоритма в том, что он многократно вычисляет одни и те же значения. Значения Fib(1) и Fib(0) вычисляются Fib(N + 1) раз, когда алгоритм вычисляет Fib(N). Для вычисления Fib(29), алгоритм вычисляет одни и те же значения Fib(0) и Fib(1) 832.040 раз.
Поскольку алгоритм многократно вычисляет одни и те же значения, следует найти способ избежать повторения вычислений. Простой и конструктивный способ сделать это — построить таблицу вычисленных значений. Когда понадобится промежуточное значение, можно будет взять его из таблицы, вместо того, чтобы вычислять его заново.
=====102
В этом примере можно создать таблицу для хранения значений функции Фибоначчи Fib(N) для N, не превосходящих 1477. Для N >= 1477 происходит переполнение переменных типа double, используемых в функции. Следующий код содержит измененную таким образом функцию, вычисляющую числа Фибоначчи.
Const MAX_FIB = 1476 ' Максимальное значение.
Dim FibValues(0 To MAX_FIB) As Double
Private Function Fib(N As Integer) As Double
' Вычислить значение, если оно не находится в таблице.
If FibValues(N) < 0 Then _
FibValues(M) = Fib(N - 1) + Fib(N - 2)
Fib = FibValues(N)
End Function
При запуске программы, она присваивает каждому элементу в массиве FibValues значение -1. Затем она присваивает FibValues(0) значение 0, и FibValues(1) — значение 1. Это условия остановки рекурсии.
При выполнении функции, она проверяет, находится ли уже в массиве значение, которое ей требуется. Если его там нет, она, как и раньше, рекурсивно вычисляет это значение и сохраняет его в массиве для дальнейшего использования.
Программа Fibo2 использует этот метод для вычисления чисел Фибоначчи. Программа может быстро вычислить Fib(N) для N до 100 или 200. Но если вы попытаетесь вычислить Fib(1476), то программа выполнит последовательность рекурсивных вызовов глубиной 1476 уровней, которая вероятно переполнит стек вашей системы.
Тем не менее, по мере того, как программа вычисляет новые значения, она заполняет массив FibValues. Значения из массива позволяют функции вычислять все большие и большие значения без глубокой рекурсии. Например, если вычислить последовательно Fib(100), Fib(200), Fib(300), и т.д. то, в конце концов, можно будет заполнить массив значений FibValues и вычислить максимальное возможно значение Fib(1476).
Процесс медленного заполнения массива FibValues приводит к новому методу вычисления чисел Фибоначчи. Когда программа инициализирует массив FibValues, она может заранее вычислить все числа Фибоначчи.
Private Sub InitializeFibValues()
Dim i As Integer
FibValues(0) = 0 ' Инициализация условий остановки.
FibValues(1) = 1
For i = 2 To MAX_FIB
FibValues(i) = FibValues(i - 1) + FibValues(i - 2)
Next i
End Sub
Private Function Fib(N As Integer) As Duble
Fib - FibValues(N)
End Function
=====104
Определенное время в этом алгоритме занимает составление массива с табличными значениями. Но после того как массив создан, для получения элемента из массива требуется всего один шаг. Ни процедура инициализации, ни функция Fib не используют рекурсию, поэтому ни одна из них не приведет к исчерпанию стекового пространства. Программа Fibo3 демонстрирует этот подход.
Стоит упомянуть еще один метод вычисления чисел Фибоначчи. Первое рекурсивное определение функции Фибоначчи использует подход сверху вниз. Для получения значения Fib(N), алгоритм рекурсивно вычисляет Fib(N - 1) и Fib(N - 2) и затем складывает их.
Подпрограмма InitializeFibValues, с другой стороны, работает снизу вверх. Она начинает со значений Fib(0) и Fib(1). Она затем использует меньшие значения для вычисления больших, до тех пор, пока таблица не заполнится.
Вы можете использовать тот же подход снизу вверх для прямого вычисления значений функции Фибоначчи каждый раз, когда вам потребуется значение. Этот метод требует больше времени, чем выборка значений из массива, но не требует дополнительной памяти для таблицы значений. Это пример пространственно‑временного компромисса. Использование большего объема памяти для хранения таблицы значений делает выполнение алгоритма более быстрым.
Private Function Fib(N As Integer) As Double
Dim Fib_i_minus_1 As Double
Dim Fib_i_minus_2 As Double
Dim fib_i As Double
Dim i As Integer
If N <= 1 Then
Fib = N
Else
Fib_i_minus_2 = 0 ' Вначале Fib(0)
Fib_i_minus_1 = 1 ' Вначале Fib(1)
For i = 2 To N
fib_i = Fib_i_minus_1 + Fib_i_minus_2
Fib_i_minus_2 = Fib_i_minus_1
Fib_i_minus_1 = fib_i
Next i
Fib = fib_i
End If
End Function
Этой версии требуется порядка O(N) шагов для вычисления Fib(N). Это больше, чем один шаг, который требовался в предыдущей версии, но намного быстрее, чем O(Fib(N)) шагов в исходной версии алгоритма. На компьютере с процессором Pentium с тактовой частотой 90 МГц, исходному рекурсивному алгоритму потребовалось почти 52 секунды для вычисления Fib(32) = 2.178.309. Время вычисления Fib(1476) » 1,31E+308 при помощи нового алгоритма пренебрежимо мало. Программа Fibo4 использует этот метод для вычисления чисел Фибоначчи.
=====105
Функции факториала, наибольшего общего делителя, и BigAdd можно упростить устранением хвостовой рекурсии. Функцию, вычисляющую числа Фибоначчи, можно упростить, используя таблицу значений или переформулировав задачу с использованием подхода снизу вверх.
Некоторые рекурсивные алгоритмы настолько сложны, то применение этих методов затруднено или невозможно. Достаточно сложно было бы написать нерекурсивный алгоритм для построения кривых Гильберта или Серпинского с нуля. Другие рекурсивные алгоритмы более просты.
Ранее было показано, что алгоритм, который рисует кривые Гильберта или Серпинского, должен включать порядка O(N4) шагов, так что исходные рекурсивные версии достаточно хороши. Они достигают почти максимальной возможной производительности при приемлемой глубине рекурсии.
Тем не менее, встречаются другие сложные алгоритмы, которые имеют высокую глубину вложенности рекурсии, но к которым неприменимо устранение хвостовой рекурсии. В этом случае, все еще возможно преобразование рекурсивного алгоритма в нерекурсивный.
Основной подход при этом заключается в том, чтобы рассмотреть порядок выполнения рекурсии на компьютере и затем попытаться сымитировать шаги, выполняемые компьютером. Затем новый алгоритм будет сам осуществлять «рекурсию» вместо того, чтобы всю работу выполнял компьютер.
Поскольку новый алгоритм выполняет практически те же шаги, что и компьютер, можно поинтересоваться, возрастет ли скорость вычислений. В Visual Basic это обычно не выполняется. Компьютер может выполнять задачи, которые требуются при рекурсии, быстрее, чем вы можете их имитировать. Тем не менее, оперирование этими деталями самостоятельно обеспечивает лучший контроль над выделением памяти под локальные переменные, и позволяет избежать глубокого уровня вложенности рекурсии.
Обычно, при вызове подпрограммы, система выполняет три вещи. Во‑первых, сохраняет данные, которые нужны ей для продолжения выполнения после завершения подпрограммы. Во‑вторых, она проводит подготовку к вызову подпрограммы и передает ей управление. В‑третьих, когда вызываемая процедура завершается, система восстанавливает данные, сохраненные на первом шаге, и передает управление назад в соответствующую точку программы. Если вы преобразуете рекурсивную процедуру в нерекурсивную, вам приходится выполнять эти три шага самостоятельно.
Рассмотрим следующую обобщенную рекурсивную процедуру:
Sub Subr(num)
<1 блок кода>
Subr(<параметры>)
<2 блок кода>
End Sub
Поскольку после рекурсивного шага есть еще операторы, вы не можете использовать устранение хвостовой рекурсии для этого алгоритма.
=====105
Вначале пометим первые строки в 1 и 2 блоках кода. Затем эти метки будут использоваться для определения места, с которого требуется продолжить выполнение при возврате из «рекурсии». Эти метки используются только для того, чтобы помочь вам понять, что делает алгоритм — они не являются частью кода Visual Basic. В этом примере метки будут выглядеть так:
Sub Subr(num)
1 <1 блок кода>
Subr(<параметры>)
2 <2 блок кода>
End Sub
Используем специальную метку «0» для обозначения конца «рекурсии». Теперь можно переписать процедуру без использования рекурсии, например, так:
Sub Subr(num)
Dim pc As Integer ' Определяет, где нужно продолжить рекурсию.
pc = 1 ' Начать сначала.
Do
Select Case pc
Case 1
<1 блок кода>
If (достигнуто условие остановки) Then
' Пропустить рекурсию и перейти к блоку 2.
pc = 2
Else
' Сохранить переменные, нужные после рекурсии.
' Сохранить pc = 2. Точка, с которой продолжится
' выполнение после возврата из "рекурсии".
' Установить переменные, нужные для рекурсии.
' Например, num = num - 1.
:
' Перейти к блоку 1 для начала рекурсии.
pc = 1
End If
Case 2 ' Выполнить 2 блок кода
<2 блок кода>
pc = 0
Case 0
If (это последняя рекурсия) Then Exit Do
' Иначе восстановить pc и другие переменные,
' сохраненные перед рекурсией.
End Select
Loop
End Sub
======106
Переменная pc, которая соответствует счетчику программы, сообщает процедуре, какой шаг она должна выполнить следующим. Например, при pc = 1, процедура должна выполнить 1 блок кода.
Когда процедура достигает условия остановки, она не выполняет рекурсию. Вместо этого, она присваивает pc значение 2, и продолжает выполнение 2 блока кода.
Если процедура не достигла условия остановки, она выполняет «рекурсию». Для этого она сохраняет значения всех локальных переменных, которые ей понадобятся позже после завершения «рекурсии». Она также сохраняет значение pc для участка кода, который она будет выполнять после завершения «рекурсии». В этом примере следующим выполняется 2 блок кода, поэтому она сохраняет 2 в качестве следующего значения pc. Самый простой способ сохранения значений локальных переменных и pc состоит в использовании стеков, подобных тем, которые описывались в 3 главе.
Реальный пример поможет вам понять эту схему. Рассмотрим слегка измененную версию функции факториала. В нем переписана только подпрограмма, которая возвращает свое значение при помощи переменной, а не функции, для упрощения работы.
Private Sub Factorial(num As Integer, value As Integer)
Dim partial As Integer
1 If num <= 1 Then
value = 1
Else
Factorial(num - 1, partial)
2 value = num * partial
End If
End Sub
После возврата процедуры из рекурсии, требуется узнать исходное значение переменной num, чтобы выполнить операцию умножения value = num * partial. Поскольку процедуре требуется доступ к значению num после возврата из рекурсии, она должна сохранять значение переменных pc и num до начала рекурсии.
Следующая процедура сохраняет эти значения в двух стеках на основе массивов. При подготовке к рекурсии, она проталкивает значения переменных num и pc в стеки. После завершения рекурсии, она выталкивает добавленные последними значения из стеков. Следующий код демонстрирует нерекурсивную версию подпрограммы вычисления факториала.
Private Sub Factorial(num As Integer, value As Integer)
ReDim num_stack(1 to 200) As Integer
ReDim pc_stack(1 to 200) As Integer
Dim stack_top As Integer ' Вершина стека.
Dim pc As Integer
pc = 1
Do
Select Case pc
Case 1
If num <= 1 Then ' Это условие остановки. value = 1
pc = 0 ' Конец рекурсии.
Else ' Рекурсия.
' Сохранить num и следующее значение pc.
stack_top = stack_top + 1
num_stack(stack_top) = num
pc_stack(stack_top) = 2 ' Возобновить с 2.
' Начать рекурсию.
num = num - 1
' Перенести блок управления в начало.
pc = 1
End If
Case 2
' value содержит результат последней
' рекурсии. Умножить его на num.
value = value * num
' "Возврат" из "рекурсии".
pc = 0
Case 0
' Конец "рекурсии".
' Если стеки пусты, исходный вызов
' подпрограммы завершен.
If stack_top <= 0 Then Exit Do
' Иначе восстановить локальные переменные и pc.
num = num_stack(stack_top)
pc = pc_stack(stack_top)
stack_top = stacK_top - 1
End Select
Loop
End Sub
Так же, как и устранение хвостовой рекурсии, этот метод имитирует поведение рекурсивного алгоритма. Процедура заменяет каждый рекурсивный вызов итерацией цикла While. Поскольку число шагов остается тем же самым, полное время выполнения алгоритма не изменяется.
Так же, как и в случае с устранением хвостовой рекурсии, этот метод устраняет глубокую рекурсию, которая может переполнить стек.
Пример вычисления факториала из предыдущего раздела превратил простую, но неэффективную рекурсивную функцию вычисления факториала в сложную и неэффективную нерекурсивную процедуру. Намного лучший нерекурсивный алгоритм вычисления факториала, был представлен ранее в этой главе.
=======107-108
Может оказаться достаточно трудно найти простую нерекурсивную версию для более сложных алгоритмов. Методы из предыдущего раздела могут быть полезны, если алгоритм содержит многократную или косвенную рекурсию.
В качестве более интересного примера, рассмотрим нерекурсивный алгоритм построения кривых Гильберта.
Private Sub Hilbert(depth As Integer, Dx As Single, Dy As Single)
If depth > 1 Then Hilbert depth - 1, Dy, Dx
HilbertPicture.Line -Step(Dx, Dy)
If depth > 1 Then Hilbert depth - 1, Dx, Dy
HilbertPicture.Line -Step(Dy, Dx)
If depth > 1 Then Hilbert depth - 1, Dx, Dy
HilbertPicture.Line -Step(-Dx, -Dy)
If depth > 1 Then Hilbert depth - 1, -Dy, -Dx
End Sub
В следующем фрагменте кода первые строки каждого блока кода между рекурсивными шагами пронумерованы. Эти блоки включают первую строку процедуры и любые другие точки, в которых может понадобиться продолжить выполнение после возврата после «рекурсии».
Private Sub Hilbert(depth As Integer, Dx As Single, Dy As Single)
1 If depth > 1 Then Hilbert depth - 1, Dy, Dx
2 HilbertPicture.Line -Step(Dx, Dy)
If depth > 1 Then Hilbert depth - 1, Dx, Dy
3 HilbertPicture.Line -Step(Dy, Dx)
If depth > 1 Then Hilbert depth - 1, Dx, Dy
4 HilbertPicture.Line -Step(-Dx, -Dy)
If depth > 1 Then Hilbert depth - 1, -Dy, -Dx
End Sub
Каждый раз, когда нерекурсивная процедура начинает «рекурсию», она должна сохранять значения локальных переменных Depth, Dx, и Dy, а также следующее значение переменной pc. После возврата из «рекурсии», она восстанавливает эти значения. Для упрощения работы, можно написать пару вспомогательных процедур для заталкивания и выталкивания этих значений из нескольких стеков.
====109
Const STACK_SIZE =20
Dim DepthStack(0 To STACK_SIZE)
Dim DxStack(0 To STACK_SIZE)
Dim DyStack(0 To STACK_SIZE)
Dim PCStack(0 To STACK_SIZE)
Dim TopOfStack As Integer
Private Sub SaveValues (Depth As Integer, Dx As Single, _
Dy As Single, pc As Integer)
TopOfStack = TopOfStack + 1
DepthStack(TopOfStack) = Depth
DxStack(TopOfStack) = Dx
DyStack(TopOfStack) = Dy
PCStack(TopOfStack) = pc
End Sub
Private Sub RestoreValues (Depth As Integer, Dx As Single, _
Dy As Single, pc As Integer)
Depth = DepthStack(TopOfStack)
Dx = DxStack(TopOfStack)
Dy = DyStack(TopOfStack)
pc = PCStack(TopOfStack)
TopOfStack = TopOfStack - 1
End Sub
Следующий код демонстрирует нерекурсивную версию подпрограммы Hilbert.
Private Sub Hilbert(Depth As Integer, Dx As Single, Dy As Single)
Dim pc As Integer
Dim tmp As Single
pc = 1
Do
Select Case pc
Case 1
If Depth > 1 Then ' Рекурсия.
' Сохранить текущие значения.
SaveValues Depth, Dx, Dy, 2
' Подготовиться к рекурсии.
Depth = Depth - 1
tmp = Dx
Dx = Dy
Dy = tmp
pc = 1 ' Перейти в начало рекурсивного вызова.
Else ' Условие остановки.
' Достаточно глубокий уровень рекурсии.
' Продолжить со 2 блоком кода.
pc = 2
End If
Case 2
HilbertPicture.Line -Step(Dx, Dy)
If Depth > 1 Then ' Рекурсия.
' Сохранить текущие значения.
SaveValues Depth, Dx, Dy, 3
' Подготовиться к рекурсии.
Depth = Depth - 1
' Dx и Dy остаются без изменений.
pc = 1 Перейти в начало рекурсивного вызова.
Else ' Условие остановки.
' Достаточно глубокий уровень рекурсии.
' Продолжить с 3 блоком кода.
pc = 3
End If
Case 3
HilbertPicture.Line -Step(Dy, Dx)
If Depth > 1 Then ' Рекурсия.
' Сохранить текущие значения.
SaveValues Depth, Dx, Dy, 4
' Подготовиться к рекурсии.
Depth = Depth - 1
' Dx и Dy остаются без изменений.
pc = 1 Перейти в начало рекурсивного вызова.
Else ' Условие остановки.
' Достаточно глубокий уровень рекурсии.
' Продолжить с 4 блоком кода.
pc = 4
End If
Case 4
HilbertPicture.Line -Step(-Dx, -Dy)
If Depth > 1 Then ' Рекурсия.
' Сохранить текущие значения.
SaveValues Depth, Dx, Dy, 0
' Подготовиться к рекурсии.
Depth = Depth - 1
tmp = Dx
Dx = -Dy
Dy = -tmp
pc = 1 Перейти в начало рекурсивного вызова.
Else ' Условие остановки.
' Достаточно глубокий уровень рекурсии.
' Конец этого рекурсивного вызова.
pc = 0
End If
Case 0 ' Возврат из рекурсии.
If TopOfStack > 0 Then
RestoreValues Depth, Dx, Dy, pc
Else
' Стек пуст. Выход.
Exit Do
End If
End Select
Loop
End Sub
======111
Время выполнения этого алгоритма может быть нелегко оценить непосредственно. Поскольку методы преобразования рекурсивных процедур в нерекурсивные не изменяют время выполнения алгоритма, эта процедура так же, как и предыдущая версия, имеет время выполнения порядка O(N4).
Программа Hilbert2 демонстрирует нерекурсивный алгоритм построения кривых Гильберта. Задавайте вначале построение несложных кривых (меньше 6 порядка), пока не узнаете, насколько быстро будет выполняться эта программа на вашем компьютере.
Приведенный ранее алгоритм построения кривых Серпинского включает в себя косвенную и множественную рекурсию. Так как алгоритм состоит из четырех подпрограмм, которые вызывают друг друга, то нельзя просто пронумеровать важные строки, как это можно было сделать в случае алгоритма построения кривых Гильберта. С этой проблемой можно справиться, слегка изменив алгоритм.
Рекурсивная версия этого алгоритма состоит из четырех подпрограмм SierpA, SierpB, SierpC и SierpD. Подпрограмма SierpA выглядит так:
Private Sub SierpA(Depth As Integer, Dist As Single)
If Depth = 1 Then
Line -Step(-Dist, Dist)
Line -Step(-Dist, 0)
Line -Step(-Dist, -Dist)
Else
SierpA Depth - 1, Dist
Line -Step(-Dist, Dist)
SierpB Depth - 1, Dist
Line -Step(-Dist, 0)
SierpD Depth - 1, Dist
Line -Step(-Dist, -Dist)
SierpA Depth - 1, Dist
End If
End Sub
Три другие процедуры аналогичны. Несложно объединить эти четыре процедуры в одну подпрограмму.
Private Sub SierpAll(Depth As Integer, Dist As Single, Func As Integer)
Select Case Punc
Case 1 ' SierpA
<код SierpA code>
Case 2 ' SierpB
<код SierpB>
Case 3 ' SierpC
<код SierpC>
Case 4 ' SierpD
<код SierpD>
End Select
End Sub
======112
Параметр Func сообщает подпрограмме, какой блок кода выполнять. Вызовы подпрограмм заменяются на вызовы процедуры SierpAll с соответствующим значением Func. Например, вызов подпрограммы SierpA заменяется на вызов процедуры SierpAll с параметром Func, равным 1. Таким же образом заменяются вызовы подпрограмм SierpB, SierpC и SierpD.
Полученная процедура рекурсивно вызывает себя в 16 различных точках. Эта процедура намного сложнее, чем процедура Hilbert, но в других отношениях она имеет такую же структуру и поэтому к ней можно применить те же методы устранения рекурсии.
Можно использовать первую цифру меток pc, для определения номера блока кода, который должен выполняться. Перенумеруем строки в коде SierpA числами 11, 12, 13 и т.д. Перенумеруем строки в коде SierpB числами 21, 22, 23 и т.д.
Теперь можно пронумеровать ключевые строки кода внутри каждого из блоков. Для кода подпрограммы SierpA ключевыми строками будут:
' Код SierpA.
11 If Depth = 1 Then
Line -Step(-Dist, Dist)
Line -Step(-Dist, 0)
Line -Step(-Dist, -Dist)
Else
SierpA Depth - 1, Dist
12 Line -Step(-Dist, Dist)
SierpB Depth - 1, Dist
13 Line -Step(-Dist, 0)
SierpD Depth - 1, Dist
14 Line -Step(-Dist, -Dist)
SierpA Depth - 1, Dist
End If
Типичная «рекурсия» из кода подпрограммы SierpA в код подпрограммы SierpB выглядит так:
SaveValues Depth, 13 ' Продолжить с шага 13 после завершения.
Depth = Depth - 1
pc = 21 ' Передать управление на начало кода SierpB.
======113
Метка 0 зарезервирована для обозначения выхода из «рекурсии». Следующий код демонстрирует нерекурсивную версию процедуры SierpAll. Код для подпрограмм SierpB, SierpC, и SierpD аналогичен коду для SierpA, поэтому он опущен.
Private Sub SierpAll(Depth As Integer, pc As Integer)
Do
Select Case pc
' **********
' * SierpA *
' **********
Case 11
If Depth <= 1 Then
SierpPicture.Line -Step(-Dist, Dist)
SierpPicture.Line -Step(-Dist, 0)
SierpPicture.Line -Step(-Dist, -Dist)
pc = 0
Else
SaveValues Depth, 12 ' Выполнить SierpA
Depth = Depth - 1
pc = 11
End If
Case 12
SierpPicture.Line -Step(-Dist, Dist)
SaveValues Depth, 13 ' Выполнить SierpB
Depth = Depth - 1
pc = 21
Case 13
SierpPicture.Line -Step(-Dist, 0)
SaveValues Depth, 14 ' Выполнить SierpD
Depth = Depth - 1
pc = 41
Case 14
SierpPicture.Line -Step(-Dist, -Dist)
SaveValues Depth, 0 ' Выполнить SierpA
Depth = Depth - 1
pc = 11
' Код для SierpB, SierpC и SierpD опущен.
:
' *******************
' * Конец рекурсии. *
' *******************
Case 0
If TopOfStack <= 0 Then Exit Do
RestoreValues Depth, pc
End Select
Loop
End Sub
=====114
Так же, как и в случае с алгоритмом построения кривых Гильберта, преобразование алгоритма построения кривых Серпинского в нерекурсивную форму не изменяет время выполнения алгоритма. Новая версия алгоритма имитирует рекурсивный алгоритм, который выполняется за время порядка O(N4), поэтому порядок времени выполнения новой версии также составляет O(N4). Она выполняется немного медленнее, чем рекурсивная версия, и является намного более сложной.
Нерекурсивная версия также могла бы рисовать кривые более высоких порядков, но построение кривых Серпинского с порядком выше 8 или 9 непрактично. Все эти факты определяют преимущество рекурсивного алгоритма.
Программа Sierp2 использует этот нерекурсивный алгоритм для построения кривых Серпинского. Задавайте вначале построение несложных кривых (меньше 6 порядка), пока не определите, насколько быстро будет выполняться эта программа на вашем компьютере.
При применении рекурсивных алгоритмов следует избегать трех основных опасностей:
· Бесконечной рекурсии. Убедитесь, что условия остановки вашего алгоритма прекращают все рекурсивные пути.
· Глубокой рекурсии. Если алгоритм достигает слишком большой глубины рекурсии, он может привести к переполнению стека. Минимизируйте использование стека за счет уменьшения числа определяемых в процедуре переменных, использования глобальных переменных, или определения переменных как статических. Если процедура все равно приводит к переполнению стека, перепишите алгоритм в нерекурсивном виде, используя устранение хвостовой рекурсии.
· Ненужной рекурсии. Обычно это происходит, если алгоритм типа рекурсивного вычисления чисел Фибоначчи, многократно вычисляет одни и те же промежуточные значения. Если вы столкнетесь с этой проблемой в своей программе, попробуйте переписать алгоритм, используя подход снизу вверх. Если алгоритм не позволяет прибегнуть к подходу снизу вверх, создайте таблицу промежуточных значений.
Применение рекурсии не всегда неправильно. Многие задачи являются рекурсивными по своей природе. В этих случаях рекурсивный алгоритм будет проще понять, отлаживать и поддерживать, чем его нерекурсивную версию. В качестве примера можно привести алгоритмы построения кривых Гильберта и Серпинского. Оба по своей природе рекурсивны и намного понятнее, чем их нерекурсивные модификации. При этом рекурсивные версии даже выполняются немного быстрее.
Если у вас есть алгоритм, который рекурсивен по своей природе, но вы не уверены, будет ли рекурсивная версия лишена проблем, запишите алгоритм в рекурсивном виде и выясните это. Может быть, проблемы не возникнут. Если же они возникнут, то, возможно, окажется проще преобразовать эту рекурсивную версию в нерекурсивную, чем написать нерекурсивную версию с нуля.
======115
Глава 6. ДеревьяВо 2 главе приводились способы создания динамических связных структур, таких, как изображенные на рис 6.1. Такие структуры данных называются графами (graphs). В 12 главе алгоритмы работы с графами и сетями обсуждаются более подробно. В этой главе рассматриваются графы особого типа, которые называются деревьями (trees).
В начале этой главы приводится определение дерева и разъясняются некоторые термины. Затем в ней описываются некоторые методы реализации деревьев различных типов на языке Visual Basic. В последующих разделах рассматривается несколько алгоритмов обхода для деревьев, записанных в этих разных форматах. Глава заканчивается обсуждением некоторых специальных типов деревьев, включая упорядоченные деревья (sorted trees), деревья со ссылками[RV7] (threaded trees), боры[RV8] (tries) и квадродеревья[RV9] (quadtrees).
В 7 и 8 главе обсуждаются более сложные темы — сбалансированные деревья и деревья решений.
@Рис. 6.1. Графы
=====117
Можно рекурсивно определить дерево как:
* Пустую структуру или
* Узел, называемый корнем (node) дерева, связанный с нулем или более поддеревьев (subtrees).
На рис. 6.2 показано дерево. Корневой узел A связан с тремя поддеревьями, начинающимися в узлах B, C и D. Эти узлы связаны с поддеревьями с корнями E, F и G, и эти узлы, в свою очередь связаны с поддеревьями с корнями H, I и J.
Терминология деревьев представляет собой смесь терминов, позаимствованных из ботаники и генеалогии. Из ботаники пришли термины, такие как узел (node), определяемый как точка, в которой может начинаться ветвление, ветвь (branch), определяемая как связь между двумя узлами, и лист (leaf) — узел, из которого не выходят другие ветви.
Из генеалогии пришли термины, которые описывают родство. Если один узел находится непосредственно над другим, верхний узел называется родителем (parent), а нижний дочерним узлом (child). Узлы на пути вверх от узла до корня называются предками (ancestors) узла. Например, на рис. 6.2 узлы E, B и A — это все предки узла I.
Узлы, которые находятся ниже какого‑либо узла дерева, называются потомками (descendants) этого узла. Узлы E, H, I и J на рис. 6.2 — это все потомки узла B.
Иногда узлы, имеющие одного родителя, называются узлами‑братьями или узлами‑сестрами (sibling nodes).
Существует еще несколько терминов, которые не пришли из ботаники или генеалогии. Внутренним узлом (internal node) называется узел, который не является листом. Порядком узла (node degree) называется число его дочерних узлов. Порядок дерева — это наибольший порядок его узлов. Дерево на рис. 6.2 — третьего порядка, потому что узлы с наибольшим порядком, узлы A и E, имеют по 3 дочерних узла.
Глубина (depth) дерева равна числу его предков плюс 1. На рис. 6.2 глубина узла E равна 3. Глубиной (depth) или высотой (height) дерева называется наибольшая глубина его узлов. Глубина дерева на рис. 6.2 равна 4.
Дерево 2 порядка называется двоичным деревом (binary tree). Деревья третьего порядка иногда называются троичными[RV10] (ternary) деревьями. Более того, деревья порядка N иногда называются N‑ичными (N‑ary) деревьями.
@Рис. 6.2. Дерево
======118
Дерево порядка 12, например, называется 12‑ричным (12‑ary) деревом, а не додекадеричным (dodecadary) деревом. Некоторые избегают употребления лишних терминов и просто говорят «деревья 12 порядка».
Рис. 6.3 иллюстрирует некоторые из этих терминов.
Теперь, когда вы познакомились с терминологией, вы можете представить себе способы реализации деревьев на языке Visual Basic. Один из способов — создать отдельный класс для каждого типа узлов дерева. Для построения дерева, показанного на рис. 6.3, вы можете определить структуры данных для узлов, которые имеют ноль, один, два или три дочерних узла. Этот подход был бы довольно неудобным. Кроме того, что нужно было бы управлять четырьмя различными классами, в классах потребовались бы какие‑то флаги, которые бы указывали тип дочерних узлов. Алгоритмы, которые оперировали бы этими деревьями, должны были бы уметь работать со всем различными типами деревьев.
В качестве простого решения можно определить один тип узлов, который содержит достаточное число указателей на потомков для представления всех нужных узлов. Я называю это методом полных узлов, так как некоторые узлы могут быть большего размера, чем необходимо на самом деле.
Дерево, изображенное на рис 6.3, имеет 3 порядок. Для построения этого дерева с использованием метода полных узлов (fat nodes), требуется определить единственный класс, который содержит указатели на три дочерних узла. Следующий код демонстрирует, как эти указатели могут быть определены в классе TernaryNode.
Public LeftChild As TernaryNode
Public MiddleChild As TernaryNode
Public RightChild As TernaryNode
@Рис. 6.3. Части троичного (3 порядка) дерева
======119
При помощи этого класса можно построить дерево, используя записи Child узлов, для связи их друг с другом. Следующий фрагмент кода строит два верхних уровня дерева, показанного на рис. 6.3.
Dim A As New TernaryNode
Dim B As New TernaryNode
Dim C As New TernaryNode
Dim D As New TernaryNode
:
Set A.LeftChild = B
Set A.MiddleChild = C
[RV11] :
Программа Binary, показанная на рис. 6.4, использует метод полных узлов для работы с двоичным деревом. Когда вы выбираете узел с помощью мыши, программа подсвечивает кнопку Add Left (Добавить слева), если узел не имеет левого потомка и кнопку Add Right (Добавить справа), если узел не имеет правого потомка. Кнопка Remove (Удалить) разблокируется, если выбранный узел не является корневым. Если вы нажмете на кнопку Remove, программа удалит узел и всех его потомков.
Поскольку программа позволяет создать узлы с нулевым числом, одним или двумя дочерними узлами, она использует представление в виде полных узлов. Вы можете легко распространить этот пример на деревья более высоких порядков.
Если порядки узлов в дереве сильно различаются, метод полных узлов приводит к напрасному расходованию большого количества памяти. Чтобы построить дерево, показанное на рис. 6.5 с использованием полных узлов, вам понадобится определить в каждом узле по шесть указателей, хотя только в одном узле все шесть из них используются. Это представление дерева потребует 72 указателей на дочерние узлы, из которых в действительности будет использоваться только 11.
@Рис. 6.4. Программа Binary
======120
Некоторые программы добавляют и удаляют узлы, изменяя порядок узлов в процессе выполнения. В этом случае метод полных узлов не будет работать. Такие динамически изменяющиеся деревья можно представить, поместив дочерние узлы в списки. Есть несколько подходов, которые можно использовать для создания списков дочерних узлов. Наиболее очевидный подход заключается в создании в классе узла открытого (public) массива дочерних узлов, как показано в следующем коде. Тогда для оперирования дочерними узлами можно использовать методы работы со списками на основе массивов.
Public Children() As TreeNode
Public NumChildren As Integer
К сожалению, Visual Basic не позволяет определять открытые массивы в классах. Это ограничение можно обойти, определив массив как закрытый (private), и оперируя элементами массива при помощи процедур свойств.
Private m_Chirdren() As TreeNode
Private m_NumChildren As Integer
Property Get Children(Index As Integer) As TreeNode
Set Children = m_Children(Index)
End Property
Property Get NumChildren() As Integer
NumChildren = m_NumChildren()
End Property
Второй подход состоит в том, чтобы сохранять ссылки на дочерние узлы в связных списках. Каждый узел содержит ссылку на первого потомка. Он также содержит ссылку на следующего потомка на том же уровне дерева. Эти связи образуют связный список узлов одного уровня, поэтому я называю этот метод представлением в виде связного списка узлов одного уровня (linked sibling). За информацией о связных списках вы можете обратиться ко 2 главе.
@Рис. 6.5. Дерево с узлами различных порядков
======121
Третий подход заключается в том, чтобы определить в классе узла открытую коллекцию, которая будет содержать дочерние узлы:
Public Children As New Collection
Это решение позволяет использовать все преимущества коллекций. Программа может при этом легко добавлять и удалять элементы из коллекции, присваивать дочерним узлам ключи, и использовать оператор For Each для выполнения циклов со ссылками на дочерние узлы.
Программа NAry, показанная на рис. 6.6, использует коллекцию дочерних узлов для работы с деревьями порядка N в основном таким же образом, как программа Binary работает с двоичными деревьями. В этой программе, тем не менее, можно добавлять к каждому узлу любое количество потомков.
Для того чтобы избежать чрезмерного усложнения пользовательского интерфейса, программа NAry всегда добавляет новые узлы в конец коллекции дочерних узлов родителя. Вы можете модифицировать эту программу, реализовав вставку дочерних узлов в середину коллекции, но пользовательский интерфейс при этом усложнится.
Представление нумерацией связей (forward star), впервые упомянутое в 4 главе, позволяет компактно представить деревья, графы и сети при помощи массива. Для представления дерева нумерацией связей, в массиве FirstLink записывается индекс для первых ветвей, выходящих из каждого узла. В другой массив, ToNode, заносятся узлы, к которым ведет ветвь.
Сигнальная метка в конце массива FirstLink указывает на точку сразу после последнего элемента массива ToNode. Это позволяет легко определить, какие ветви выходят из каждого узла. Ветви, выходящие из узла I, находятся под номерами от FirstLink(I) до FirstLink(I+1)-1. Для вывода связей, выходящих из узла I, можно использовать следующий код:
For link = FirstLink(I) To FirstLink(I + 1) - 1
Print Format$(I) & " -> " & Format$(ToNode(link))
Next link
@Рис. 6.6. Программа Nary
=======123
На рис. 6.7 показано дерево и его представление нумерацией связей. Связи, выходящие из 3 узла (обозначенного буквой D) это связи от FirstLink(3) до FirstLink(4)-1. Значение FirstLink(3) равно 9, а FirstLink(4) = 11, поэтому это связи с номерами 9 и 10. Записи ToNode для этих связей равны ToNode(9) = 10 и ToNode(10) = 11, поэтому узлы 10 и 11 будут дочерними для 3 узла. Это узлы, обозначенные буквами K и L. Это означает, что связи, покидающие узел D, ведут к узлам K и L.
Представление дерева нумерацией связей компактно и основано на массиве, поэтому деревья, представленные таким образом, можно легко считывать из файлов и записывать в файл. Операции для работы с массивами, которые используются при таком представлении, также могут быть быстрее, чем операции, нужные для использования узлов, содержащих коллекции дочерних узлов.
По этим причинам большая часть литературы по сетевым алгоритмам использует представление нумерацией связей. Например, многие статьи, касающиеся вычисления кратчайшего пути, предполагают, что данные находятся в подобном формате. Если вам когда‑либо придется изучать эти алгоритмы в журналах, таких как “Management Science” или “Operations Research”, вам необходимо разобраться в этом представлении.
@Рис. 6.7. Дерево и его представление нумерацией связей
=======123
Используя представление нумерацией связей, можно быстро найти связи, выходящие из определенного узла. С другой стороны, очень сложно изменять структуру данных, представленных в таком виде. Чтобы добавить к узлу A на рис. 6.7 еще одного потомка, придется изменить почти все элементы в обоих массивах FirstLink и ToNode. Во‑первых, каждый элемент в массиве ToNode нужно сдвинуть на одну позицию вправо, чтобы освободить место под новый элемент. Затем, нужно вставить новую запись в массив ToNode, которая указывает на новый узел. И, наконец, нужно обойти массив ToNode, обновив каждый элемент, чтобы он указывал на новое положение соответствующей записи ToNode. Поскольку все записи в массиве ToNode сдвинулись на одну позицию вправо, чтобы освободить место для новой связи, потребуется добавить единицу ко всем затронутым записям FirstLink.
На рис. 6.8 показано дерево после добавления нового узла. Записи, которые изменились, закрашены серым цветом.
Удаление узла из начала представления нумерацией связей так же сложно, как и вставка узла. Если удаляемый узел имеет потомков, процесс занимает еще больше времени, поскольку придется удалять и все дочерние узлы.
Относительно простой класс с открытой коллекцией дочерних узлов лучше подходит, если нужно часто модифицировать дерево. Обычно проще понимать и отлаживать процедуры, которые оперируют деревьями в этом представлении. С другой стороны, представление нумерацией связей иногда обеспечивает более высокую производительность для сложных алгоритмов работы с деревьями. Оно также являются стандартной структурой данных, обсуждаемой в литературе, поэтому вам следует ознакомиться с ним, если вы хотите продолжить изучение алгоритмов работы с сетями и деревьями.
@Рис. 6.8. Вставка узла в дерево, представленное нумерацией связей
=======124
Программа Fstar использует представление нумерацией связей для работы с деревом, имеющим узлы разного порядка. Она аналогична программе NAry, за исключением того, что она использует представление на основе массива, а не коллекций.
Если вы посмотрите на код программы Fstar, вы увидите, насколько сложно в ней добавлять и удалять узлы. Следующий код демонстрирует удаление узла из дерева.
Sub FreeNodeAndChildren(ByVal parent As Integer, _
ByVal link As Integer, ByVal node As Integer)
' Recursively remove the node's children.
Do While FirstLink(node) < FirstLink(node + 1)
FreeNodeAndChildren node, FirstLink(node), _
ToNode(FirstLink(node))
Loop
' Удалить связь.
RemoveLink parent, link
' Удалить сам узел.
RemoveNode node
End Sub
Sub RemoveLink(node As Integer, link As Integer)
Dim i As Integer
' Обновить записи массива FirstLink.
For i = node + 1 To NumNodes
FirstLink(i) = FirstLink(i) - 1
Next i
' Сдвинуть массив ToNode чтобы заполнить пустую ячейку.
For i = link + 1 To NumLinks - 1
ToNode(i - 1) = ToNode(i)
Next i
' Удалить лишний элемент из ToNode.
NumLinks = NumLinks - 1
If NumLinks > 0 Then ReDim Preserve ToNode(0 To NumLinks - 1)
End Sub
Sub RemoveNode(node As Integer)
Dim i As Integer
' Сдвинуть элементы массива FirstLink, чтобы заполнить
' пустую ячейку.
For i = node + 1 To NumNodes
FirstLink(i - 1) = FirstLink(i)
Next i
' Сдвинуть элементы массива NodeCaption.
For i = node + 1 To NumNodes - 1
NodeCaption(i - 1) = NodeCaption(i)
Next i
' Обновить записи массива ToNode.
For i = 0 To NumLinks - 1
If ToNode(i) >= node Then ToNode(i) = ToNode(i) - 1
Next i
' Удалить лишнюю запись массива FirstLink.
NumNodes = NumNodes - 1
ReDim Preserve FirstLink(0 To NumNodes)
ReDim Preserve NodeCaption(0 To NumNodes - 1)
Unload FStarForm.NodeLabel(NumNodes)
End Sub
Это намного сложнее, чем соответствующий код в программе NAry:
Public Function DeleteDescendant(target As NAryNode) As Boolean
Dim i As Integer
Dim child As NAryNode
' Является ли узел дочерним узлом.
For i = 1 To Children.Count
If Children.Item(i) Is target Then
Children.Remove i
DeleteDescendant = True
Exit Function
End If
Next i
' Если это не дочерний узел, рекурсивно
' проверить остальных потомков.
For Each child In Children
If child.DeleteDescendant(target) Then
DeleteDescendant = True
Exit Function
End If
Next child
End Function
=======125-126
Полное дерево (complete tree) содержит максимально возможное число узлов на каждом уровне, кроме нижнего. Все узлы на нижнем уровне сдвигаются влево. Например, каждый уровень троичного дерева содержит в точности три дочерних узла, за исключением листьев, и возможно, одного узла на один уровень выше листьев. На рис. 6.9 показаны полные двоичное и троичное деревья.
Полные деревья обладают рядом важных свойств. Во‑первых, это кратчайшие деревья, которые могут содержать заданное число узлов. Например, двоичное дерево на рис. 6.9 — одно из самых коротких двоичных деревьев с шестью узлами. Существуют другие двоичные деревья с шестью узлами, но ни одно из них не имеет высоту меньше 3.
Во‑вторых, если полное дерево порядка D состоит из N узлов, оно будет иметь высоту порядка O(logD(N)) и O(N) листьев. Эти факты имеют большое значение, поскольку многие алгоритмы обходят деревья сверху вниз или в противоположном направлении. Время выполнения алгоритма, выполняющего одно из этих действий, будет порядка O(N).
Чрезвычайно полезное свойство полных деревьев заключается в том, что они могут быть очень компактно записаны в массивах. Если пронумеровать узлы в «естественном» порядке, сверху вниз и слева направо, то можно поместить элементы дерева в массив в этом порядке. На рис. 6.10 показано, как можно записать полное дерево в массиве.
Корень дерева находится в нулевой позиции. Дочерние узлы узла I находятся на позициях 2 * I + 1 и 2 * I + 2. Например, на рис. 6.10, потомки узла в позиции 1 (узла B), находятся в позициях 3 и 4 (узлы D и E).
Легко обобщить это представление на полные деревья более высокого порядка D. Корень дерева также будет находиться в позиции 0. Потомки узла I занимают позиции от D * I + 1 до D * I +(I - 1). Например, в троичном дереве, потомки узла в позиции 2, будут занимать позиции 7, 8 и 9. На рис. 6.11 показано полное троичное дерево и его представление в виде массива.
@Рис. 6.9. Полные деревья
=========127
@Рис. 6.10. Запись полного двоичного дерева в массиве
При использовании этого метода записи дерева в массиве легко и просто получить доступ к потомкам узла. При этом не требуется дополнительной памяти для коллекций дочерних узлов или меток в случае представления нумерацией связей. Чтение и запись дерева в файл сводится просто к сохранению или чтению массива. Поэтому это несомненно лучшее представление дерева для программ, которые сохраняют данные в полных деревьях.
Последовательное обращение ко всем узлам называется обходом (traversing) дерева. Существует несколько последовательностей обхода узлов двоичного дерева. Три простейших из них — прямой (preorder), симметричный (inorder), и обратный (postorder)обход, описываются простыми рекурсивными алгоритмами. Для каждого заданного узла алгоритмы выполняют следующие действия:
Прямой обход:
1. Обращение к узлу.
2. Рекурсивный прямой обход левого поддерева.
3. Рекурсивный прямой обход правого поддерева.
Симметричный обход:
1. Рекурсивный симметричный обход левого поддерева.
2. Обращение к узлу.
3. Рекурсивный симметричный обход левого поддерева.
Обратный обход:
1. Рекурсивный обратный обход левого поддерева.
2. Рекурсивный обратный обход правого поддерева.
3. Обращение к узлу.
@Рис. 6.11. Запись полного троичного дерева в массиве
=======128
Все три порядка обхода являются примерами обхода в глубину (depth‑first traversal). Обход начинается с прохода вглубь дерева до тех пор, пока алгоритм не достигнет листьев. При возврате из рекурсивного вызова подпрограммы, алгоритм перемещается по дереву в обратном направлении, просматривая пути, которые он пропустил при движении вниз.
Обход в глубину удобно использовать в алгоритмах, которые должны вначале обойти листья. Например, метод ветвей и границ, описанный в 8 главе, как можно быстрее пытается достичь листьев. Он использует результаты, полученные на уровне листьев для уменьшения времени поиска в оставшейся части дерева.
Четвертый метод перебора узлов дерева — это обход в ширину (breadth‑first traversal). Этот метод обращается ко всем узлам на заданном уровне дерева, перед тем, как перейти к более глубоким уровням. Алгоритмы, которые проводят полный поиск по дереву, часто используют обход в ширину. Алгоритм поиска кратчайшего маршрута с установкой меток, описанный в 12 главе, представляет собой обход в ширину, дерева кратчайшего пути в сети.
На рис. 6.12 показано небольшое дерево и порядок, в котором перебираются узлы во время прямого, симметричного и обратного обхода, а также обхода в ширину.
@Рис. 6.12. Обходы дерева
======129
Для деревьев больше, чем 2 порядка, все еще имеет смысл определять прямой, обратный обход, и обход в ширину. Симметричный обход определяется неоднозначно, так как обращение к каждому узлу может происходить после обращения к одному, двум, или трем его потомкам. Например, в троичном дереве, обращение к узлу может происходить после обращения к его первому потомку или после обращения ко второму потомку.
Детали реализации обхода зависят от того, как записано дерево. Для обхода дерева на основе коллекций дочерних узлов, программа должна использовать несколько другой алгоритм, чем для обхода дерева, записанного при помощи нумерации связей.
Особенно просто обходить полные деревья, записанные в массиве. Алгоритм обхода в ширину, который требует дополнительных усилий в других представлениях деревьев, для представлений на основе массива тривиален, так как узлы записаны в таком же порядке.
Следующий код демонстрирует алгоритмы обхода полного двоичного дерева:
Dim NodeLabel() As String ' Запись меток узлов.
Dim NumNodes As Integer
' Инициализация дерева.
:
Private Sub Preorder(node As Integer)
Print NodeLabel (node) ' Узел.
' Первый потомок.
If node * 2 + 1 <= NumNodes Then Preorder node * 2 + 1
' Второй потомок.
If node * 2 + 2 <= NumNodes Then Preorder node * 2 + 2
End Sub
Private Sub Inorder(node As Integer)
' Первый потомок.
If node * 2 + 1 <= NumNodes Then Inorder node * 2 + 1
Print NodeLabel (node) ' Узел.
' Второй потомок.
If node * 2 + 2 <= NumNodes Then Inorder node * 2 + 2
End Sub
Private Sub Postorder(node As Integer)
' Первый потомок.
If node * 2 + 1 <= NumNodes Then Postorder node * 2 + 1
' Второй потомок.
If node * 2 + 2 <= NumNodes Then Postorder node * 2 + 2
Print NodeLabel (node) ' Узел.
End Sub
Private Sub BreadthFirstPrint()
Dim i As Integer
For i = 0 To NumNodes
Print NodeLabel(i)
Next i
End Sub
======130
Программа Trav1 демонстрирует прямой, симметричный и обратный обходы, а также обход в ширину для двоичных деревьев на основе массивов. Введите высоту дерева, и нажмите на кнопку Create Tree (Создать дерево) для создания полного двоичного дерева. Затем нажмите на кнопки Preorder (Прямой обход), Inorder (Симметричный обход), Postorder (Обратный обход) или Breadth-First (Обход в ширину) для того, чтобы увидеть, как происходит обход дерева. На рис. 6.13 показано окно программы, в котором отображается прямой обход дерева 4 порядка.
Прямой и обратный обход для других представлений дерева осуществляется так же просто. Следующий код демонстрирует процедуру прямого обхода для дерева, записанного в формате с нумерацией связей:
Private Sub PreorderPrint(node As Integer)
Dim link As Integer
Print NodeLabel(node)
For link = FirstLink(node) To FirstLink(node + 1) - 1
PreorderPrint ToNode (link)
Next link
End Sub
@Рис. 6.13. Пример прямого обхода дерева в программе Trav1
=======131
Как упоминалось ранее, сложно дать определение симметричного обхода для деревьев больше 2 порядка. Тем не менее, после того, как вы поймете, что имеется в виду под симметричным обходом, реализовать его достаточно просто. Следующий код демонстрирует процедуру симметричного обхода, которая обращается к половине потомков узла (с округлением в большую сторону), затем к самому узлу, а потом — к остальным потомкам.
Private Sub InorderPrint(node As Integer)
Dim mid_link As Integer
Dim link As Integer
' Найти средний дочерний узел.
mid_link - (FirstLink(node + 1) - 1 + FirstLink(node)) \ 2
' Обход первой группы потомков.
For link = FirstLink(node) To mid_link
InorderPrint ToNode(link)
Next link
' Обращение к узлу.
Print NodeLabel(node)
' Обход второй группы потомков.
For link = mid_link + 1 To FirstLink(node + 1) - 1
InorderPrint ToNode(link)
Next link
End Sub
Для полных деревьев, записанных в массиве, узлы уже находятся в порядке обхода в ширину. Поэтому обход в ширину для этих типов деревьев реализуется просто, тогда как для других представлений реализовать его несколько сложнее.
Для обхода деревьев других типов можно использовать очередь для хранения узлов, которые еще не были обойдены. Вначале поместим в очередь корневой узел. После обращения к узлу, он удаляется из начала очереди, а его потомки помещаются в ее конец. Процесс повторяется до тех пор, пока очередь не опустеет. Следующий код демонстрирует процедуру обхода в ширину для дерева, которое использует узлы с коллекциями потомков:
Dim Root As TreeNode
' Инициализация дерева.
:
Private Sub BreadthFirstPrint(}
Dim queue As New Collection ' Очередь на основе коллекций.
Dim node As TreeNode
Dim child As TreeNode
' Начать с корня дерева в очереди.
queue.Add Root
' Многократная обработка первого элемента
' в очереди, пока очередь не опустеет.
Do While queue.Count > 0
node = queue.Item(1)
queue.Remove 1
' Обращение к узлу.
Print NodeLabel(node)
' Поместить в очередь потомков узла.
For Each child In node.Children
queue.Add child
Next child
Loop
End Sub
=====132
Программа Trav2 демонстрирует обход деревьев, использующих коллекции дочерних узлов. Программа является объединением программ Nary, которая оперирует деревьями порядка N, и программы Trav1, которая демонстрирует обходы деревьев.
Выберите узел, и нажмите на кнопку Add Child (Добавить дочерний узел), чтобы добавить к узлу потомка. Нажмите на кнопки Preorder, Inorder, Postorder или Breadth First, чтобы увидеть примеры соответствующих обходов. На рис. 6.14 показана программа Trav2, которая отображает обратный обход.
Двоичные деревья часто являются естественным способом представления и обработки данных в компьютерных программах. Поскольку многие компьютерные операции являются двоичными, они естественно преобразуются в операции с двоичными деревьями. Например, можно преобразовать двоичное отношение «меньше» в двоичное дерево. Если использовать внутренние узлы дерева для обозначения того, что «левый потомок меньше правого» вы можете использовать двоичное дерево для записи упорядоченного списка. На рис. 6.15 показано двоичное дерево, содержащее упорядоченный список с числами 1, 2, 4, 6, 7, 9.
@Рис. 6.14. Пример обратного обхода дерева в программе Trav2
======133
@Рис. 6.15. Упорядоченный список: 1, 2, 4, 6, 7, 9.
Алгоритм вставки нового элемента в дерево такого типа достаточно прост. Начнем с корневого узла. По очереди сравним значения всех узлов со значением нового элемента. Если значение нового элемента меньше или равно значению узла, перейдем вниз по левой ветви дерева. Если новое значение больше, чем значение узла, перейдем вниз по правой ветви. Когда этот процесс дойдет до листа, элемент помещается в эту точку.
Чтобы поместить значение 8 в дерево, показанное на рис. 6.15, мы начинаем с корня, который имеет значение 4. Поскольку 8 больше, чем 4, переходим по правой ветви к узлу 9. Поскольку 8 меньше 9, переходим затем по левой ветви к узлу 7. Поскольку 8 больше 7, снова пытаемся пойти по правой ветви, но у этого узла нет правого потомка. Поэтому новый элемент вставляется в этой точке, и получается дерево, показанное на рис. 6.16.
Следующий код добавляет новое значение ниже узла в упорядоченном дереве. Программа начинает вставку с корня, вызывая процедуру InsertItem Root, new_value.
Private Sub InsertItem(node As SortNode, new_value As Integer)
Dim child As SortNode
If node Is Nothing Then
' Мы дошли до листа.
' Вставить элемент здесь.
Set node = New SortNode
node.Value = new_value
MaxBox = MaxBox + 1
Load NodeLabel(MaxBox)
Set node.Box = NodeLabel(MaxBox)
With NodeLabel(MaxBox)
.Caption = Format$(new_value)
.Visible = True
End With
ElseIf new_value <= node.Value Then
' Перейти по левой ветви.
Set child = node.LeftChild
InsertItem child, new_value
Set node.LeftChild = child
Else
' Перейти по правой ветви.
Set child = node.RightChild
InsertItem child, new_value
Set node.RightChild = child
End If
End Sub
Когда эта процедура достигает конца дерева, происходит нечто совсем неочевидное. В Visual Basic, когда вы передаете параметр подпрограмме, этот параметр передается по ссылке, если вы не используете зарезервированное слово ByVal. Это означает, что подпрограмма работает с той же копией параметра, которую использует вызывающая процедура. Если подпрограмма изменяет значение параметра, значение в вызывающей процедуре также изменяется.
Когда процедура InsertItem рекурсивно вызывает сама себя, она передает указатель на дочерний узел в дереве. Например, в следующих операторах процедура передает указатель на правого потомка узла в качестве параметра узла процедуры InsertItem. Если вызываемая процедура изменяет значение параметра узла, указатель на потомка также автоматически обновляется в вызывающей процедуре. Затем в последней строке кода значение правого потомка устанавливается равным новому значению, так что созданный новый узел добавляется к дереву.
Set child = node.RightChild
Insertltem child, new_value
Set node.RightChild = child
Удаление элемента из упорядоченного дерева немного сложнее, чем его вставка. После удаления элемента, программе может понадобиться переупорядочить другие узлы, чтобы соотношение «меньше» продолжало выполняться для всего дерева. При этом нужно рассмотреть несколько случаев.
=====134-135
@Рис. 6.17. Удаление узла с единственным потомком
Во‑первых, если у удаляемого узла нет потомков, вы можете просто убрать его из дерева, так как порядок оставшихся узлов при этом не изменится.
Во‑вторых, если у узла всего один дочерний узел, вы можете поместить его на место удаленного узла. Порядок остальных потомков удаленного узла останется неизменным, поскольку они являются также потомками и дочернего узла. На рис. 6.17 показано дерево, из которого удаляется узел 4, который имеет всего один дочерний узел.
Если удаляемый узел имеет два дочерних, то не обязательно один из них займет место удаленного узла. Если потомки узла также имеют по два дочерних узла, то все потомки не смогут занять место удаленного узла. Удаленный узел имеет одного лишнего потомка, и дочерний узел, который вы хотели бы поместить на его место, также имеет двух потомков, так что на узел пришлось бы три потомка.
Чтобы решить эту проблему, удаленный узел заменяется самым правым узлом из левой ветви. Другими словами, нужно сдвинуться на один шаг вниз по левой ветви, выходившей из удаленного узла. Затем нужно двигаться по правым ветвям вниз до тех пор, пока не найдется узел, который не имеет правой ветви. Это самый правый узел на ветви слева от удаляемого узла. В дереве, показанном слева на рис. 6.18, узел 3 является самым правым узлом в левой от узла 4 ветви. Можно заменить узел 4 листом 3, сохранив при этом порядок дерева.
@Рис. 6.18. Удаление узла, который имеет два дочерних
=======136
@Рис. 6.19. Удаление узла, если заменяющий его узел имеет потомка
Остается последний вариант — когда заменяющий узел имеет левого потомка. В этом случае, вы можете переместить этого потомка на место, освободившееся в результате перемещения замещающего узла, и дерево снова будет расположено в нужном порядке. Уже известно, что самый правый узел не имеет правого потомка, иначе он не был бы таковым. Это означает, что не нужно беспокоиться, не имеет ли замещающий узел двух потомков.
Эта сложная ситуация показана на рис. 6.19. В этом примере удаляется узел 8. Самый правый элемент в его левой ветви — это узел 7, который имеет потомка — узел 5. Чтобы сохранить порядок дерева после удаления узла 8, заменим узел 8 узлом 7, а узел 7 — узлом 5. Заметьте, что узел 7 получает новых потомков, а узел 5 сохраняет своих.
Следующий код удаляет узел из упорядоченного двоичного дерева:
Private Sub DeleteItem(node As SortNode, target_value As Integer)
Dim target As SortNode
Dim child As SortNode
' Если узел не найден, вывести сообщение.
If node Is Nothing Then
Beep
MsgBox "Item " & Format$(target_value) & _
" не найден в дереве."
Exit Sub
End If
If target_value < node.Value Then
' Продолжить для левого поддерева.
Set child = node.LeftChild
DeleteItem child, target_value
Set node.LeftChild = child
ElseIf target_value > node.Value Then
' Продолжить для правого поддерева.
Set child = node.RightChild
DeleteItem child, target_value
Set node.RightChild = child
Else
' Искомый узел найден.
Set target = node
If target.LeftChild Is Nothing Then
' Заменить искомый узел его правым потомком.
Set node = node.RightChild
ElseIf target.RightChild Is Nothing Then
' Заменить искомый узел его левым потомком.
Set node = node.LeftChild
Else
' Вызов подпрограмы ReplaceRightmost для замены
' искомого узла самым правым узлом
' в его левой ветви.
Set child = node.LeftChild
ReplaceRightmost node, child
Set node.LeftChild = child
End If
End If
End Sub
Private Sub ReplaceRightmost(target As SortNode, repl As SortNode)
Dim old_repl As SortNode
Dim child As SortNode
If Not (repl.RightChild Is Nothing) Then
' Продолжить движение вправо и вниз.
Set child = repl.RightChild
ReplaceRightmost target, child
Set repl.RightChild = child
Else
' Достигли дна.
' Запомнить заменяющий узел repl.
Set old_repl = repl
' Заменить узел repl его левым потомком.
Set repl = repl.LeftChild
' Заменить искомый узел target with repl.
Set old_repl.LeftChild = target.LeftChild
Set old_repl.RightChild = target.RightChild
Set target = old_repl
End If
End Sub
======137-138
Алгоритм использует в двух местах прием передачи параметров в рекурсивные подпрограммы по ссылке. Во‑первых, подпрограмма DeleteItem использует этот прием для того, чтобы родитель искомого узла указывал на заменяющий узел. Следующие операторы показывают, как вызывается подпрограмма DeleteItem:
Set child = node.LeftChild
DeleteItem child, target_value
Set node.LeftChild = child
Когда процедура обнаруживает искомый узел (узел 8 на рис. 6.19), она получает в качестве параметра узла указатель родителя на искомый узел. Устанавливая параметр на замещающий узел (узел 7), подпрограмма DeleteItem задает дочерний узел для родителя так, чтобы он указывал на новый узел.
Следующие операторы показывают, как процедура ReplaceRightMost рекурсивно вызывает себя:
Set child = repl.RightChild
ReplaceRightmost target, child
Set repl.RightChild = child
Когда процедура находит самый правый узел в левой от удаляемого узла ветви (узел 7), в параметре repl находится указатель родителя на самый правый узел. Когда процедура устанавливает значение repl равным repl.LeftChild, она автоматически соединяет родителя самого правого узла с левым дочерним узлом самого правого узла (узлом 5).
Программа TreeSort использует эти процедуры для работы с упорядоченными двоичными деревьями. Введите целое число, и нажмите на кнопку Add, чтобы добавить элемент к дереву. Введите целое число, и нажмите на кнопку Remove, чтобы удалить этот элемент из дерева. После удаления узла, дерево автоматически переупорядочивается для сохранения порядка «меньше».
Полезное свойство упорядоченных деревьев состоит в том, что их порядок совпадает с порядком симметричного обхода. Например, при симметричном обходе дерева, показанного на рис. 6.20, обращение к узлам происходит в порядке 2-4-5-6-7-8-9.
@Рис. 6.20. Симметричный обход упорядоченного дерева: 2, 4, 5, 6, 7, 8, 9
=========139
Это свойство симметричного обхода упорядоченных деревьев приводит к простому алгоритму сортировки:
1. Добавить элемент к упорядоченному дереву.
2. Вывести элементы, используя симметричный обход.
Этот алгоритм обычно работает достаточно хорошо. Тем не менее, если добавлять элементы к дереву в определенном порядке, то дерево может стать высоким и тонким. На рис. 6.21 показано упорядоченное дерево, которое получается при добавлении к нему элементов в порядке 1, 6, 5, 2, 3, 4. Другие последовательности также могут приводить к появлению высоких и тонких деревьев.
Чем выше становится упорядоченное дерево, тем больше времени требуется для добавления новых элементов в нижнюю часть дерева. В наихудшем случае, после добавления N элементов, дерево будет иметь высоту порядка O(N). Полное время вставки всех элементов в дерево будет при этом порядка O(N2). Поскольку для обхода дерева требуется время порядка O(N), полное время сортировки чисел с использованием дерева будет равно O(N2)+O(N)=O(N2).
Если дерево остается достаточно коротким, оно имеет высоту порядка O(log(N)). В этом случае для вставки элемента в дерево потребуется всего порядка O(log(N)) шагов. Вставка всех N элементов в дерево потребует порядка O(N * log(N)) шагов. Тогда сортировка элементов при помощи дерева потребует времени порядка O(N * log(N)) + O(N) = O(N * log(N)).
Время выполнения порядка O(N * log(N)) намного меньше, чем O(N2). Например, построение высокого и тонкого дерева, содержащего 1000 элементов, потребует выполнения около миллиона шагов. Построение короткого дерева с высотой порядка O(log(N)) займет всего около 10.000 шагов.
Если элементы первоначально расположены в случайном порядке, форма дерева будет представлять что‑то среднее между этими двумя крайними случаями. Хотя его высота может оказаться несколько больше, чем log(N), оно, скорее всего, не будет слишком тонким и высоким, поэтому алгоритм сортировки будет выполняться достаточно быстро.
@Рис. 6.21. Дерево, полученное добавлением элементов в порядке 1, 6, 5, 2, 3, 4
==========140
В 7 главе описываются способы балансировки деревьев, для того, чтобы они не становились слишком высокими и тонкими, независимо от того, в каком порядке в них добавляются новые элементы. Тем не менее, эти методы достаточно сложны, и их не имеет смысла применять в алгоритме сортировки при помощи дерева. Многие из алгоритмов сортировки, описанных в 9 главе, более просты в реализации и обеспечивают при этом лучшую производительность.
Во 2 главе показано, как добавление ссылок к связным спискам позволяет упростить вывод элементов в разном порядке. Вы можете использовать тот же подход для упрощения обращения к узлам дерева в различном порядке. Например, помещая ссылки в листья двоичного дерева, вы можете облегчить выполнение симметричного и обратного обходов. Для упорядоченного дерева, это обход в прямом и обратном порядке сортировки.
Для создания ссылок, указатели на предыдущий и следующий узлы в порядке симметричного обхода помещаются в неиспользуемых указателях на дочерние узлы. Если не используется указатель на левого потомка, то ссылка записывается на его место, указывая на предыдущий узел при симметричном обходе. Если не используется указатель на правого потомка, то ссылка записывается на его место, указывая на следующий узел при симметричном обходе. Поскольку ссылки симметричны, и ссылки левых потомков указывают на предыдущие, а правых — на следующие узлы, этот тип деревьев называется деревом с симметричными ссылками (symmetrically threaded tree). На рис. 6.22 показано дерево с симметричными ссылками, которые обозначены пунктирными линиями.
Поскольку ссылки занимают место указателей на дочерние узлы дерева, нужно как‑то различать ссылки и обычные указатели на потомков. Проще всего добавить к узлам новые переменные HasLeftChild и HasRightChild типа Boolean, которые будут равны True, если узел имеет левого или правого потомка соответственно.
Чтобы использовать ссылки для поиска предыдущего узла, нужно проверить указатель на левого потомка узла. Если этот указатель является ссылкой, то ссылка указывает на предыдущий узел. Если значение указателя равно Nothing, значит это первый узел дерева, и поэтому он не имеет предшественников. В противном случае, перейдем по указателю к левому дочернему узлу. Затем проследуем по указателям на правый дочерний узел потомков, до тех пор, пока не достигнем узла, в котором на месте указателя на правого потомка находится ссылка. Этот узел (а не тот, на который указывает ссылка) является предшественником исходного узла. Этот узел является самым правым в левой от исходного узла ветви дерева. Следующий код демонстрирует поиск предшественника:
@Рис. 6.22. Дерево с симметричными ссылками
==========141
Private Function Predecessor(node As ThreadedNode) As ThreadedNode Dim child As ThreadedNode
If node.LeftChild Is Nothing Then
' Это первый узел в порядке симметричного обхода.
Set Predecessor = Nothing
Else If node.HasLeftChild Then
' Это указатель на узел.
' Найти самый правый узел в левой ветви.
Set child = node.LeftChild
Do While child.HasRightChild
Set child = child.RightChild
Loop
Set Predecessor = child
Else
' Ссылка указывает на предшественника.
Set Predecessor = node.LeftChild
End If
End Function
Аналогично выполняется поиск следующего узла. Если указатель на правый дочерний узел является ссылкой, то она указывает на следующий узел. Если указатель имеет значение Nothing, то это последний узел дерева, поэтому он не имеет последователя. В противном случае, переходим по указателю к правому потомку узла. Затем перемещаемся по указателям дочерних узлов до тех, пор, пока очередной указатель на левый дочерний узел не окажется ссылкой. Тогда найденный узел будет следующим за исходным. Это будет самый левый узел в правой от исходного узла ветви дерева.
Удобно также ввести функции для нахождения первого и последнего узлов дерева. Чтобы найти первый узел, просто проследуем по указателям на левого потомка вниз от корня до тех пор, пока не достигнем узла, значение указателя на левого потомка для которого равно Nothing. Чтобы найти последний узел, проследуем по указателям на правого потомка вниз от корня до тех пор, пока не достигнем узла, значение указателя на правого потомка для которого равно Nothing.
Private Function FirstNode() As ThreadedNode
Dim node As ThreadedNode
Set node = Root
Do While Not (node.LeftChild Is Nothing)
Set node = node.LeftChild
Loop
Set PirstNode = node
End Function
Private Function LastNode() As ThreadedNode
Dim node As ThreadedNode
Set node = Root
Do While Not (node.RightChild Is Nothing)
Set node = node.RightChild
Loop
Set FirstNode = node
End Function
=========142
При помощи этих функций вы можете легко написать процедуры, которые выводят узлы дерева в прямом или обратном порядке:
Private Sub Inorder()
Dim node As ThreadedNode
' Найти первый узел.
Set node = FirstNode()
' Вывод списка.
Do While Not (node Is Nothing)
Print node.Value
Set node = Successor(node)
Loop
End Sub
Private Sub PrintReverseInorder()
Dim node As ThreadedNode
' Найти последний узел
Set node = LastNode
' Вывод списка.
Do While Not (node Is Nothing)
Print node. Value
Set node = Predecessor(node)
Loop
End Sub
Процедура вывода узлов в порядке симметричного обхода, приведенная ранее в этой главе, использует рекурсию. Для устранения рекурсии вы можете использовать эти новые процедуры, которые не используют ни рекурсию, ни системный стек.
Каждый указатель на дочерние узлы в дереве содержит или указатель на потомка, или ссылку на предшественника или последователя. Так как каждый узел имеет два указателя на дочерние узлы, то, если дерево имеет N узлов, то оно будет содержать 2 * N ссылок и указателей. Эти алгоритмы обхода обращаются ко всем ссылкам и указателям дерева один раз, поэтому они потребуют выполнения O(2 * N) = O(N) шагов.
Можно немного ускорить выполнение этих подпрограмм, если отслеживать указатели на первый и последний узлы дерева. Тогда вам не понадобится выполнять поиск первого и последнего узлов перед тем, как вывести список узлов по порядку. Так как при этом алгоритм обращается ко всем N узлам дерева, время выполнения этого алгоритма также будет порядка O(N), но на практике он будет выполняться немного быстрее.
========143
Для работы с деревом со ссылками, нужно, чтобы можно было добавлять и удалять узлы из дерева, сохраняя при этом его структуру.
Предположим, что требуется добавить нового левого потомка узла A. Так как это место не занято, то на месте указателя на левого потомка узла A находится ссылка, которая указывает на предшественника узла A. Поскольку новый узел займет место левого потомка узла A, он станет предшественником узла A. Узел A будет последователем нового узла. Узел, который был предшественником узла A до этого, теперь становится предшественником нового узла. На рис. 6.23 показано дерево с рис. 6.22 после добавления нового узла X в качестве левого потомка узла H.
Если отслеживать указатель на первый и последний узлы дерева, то требуется также проверить, не является ли теперь новый узел первым узлом дерева. Если ссылка на предшественника для нового узла имеет значение Nothing, то это новый первый узел дерева.
@Рис. 6.23. Добавление узла X к дереву со ссылками
=========144
Учитывая все вышеизложенное, легко написать процедуру, которая добавляет нового левого потомка к узлу. Вставка правого потомка выполняется аналогично.
Private Sub AddLeftChild(parent As ThreadedNode, child As ThreadedNode)
' Предшественник родителя становится предшественником нового узла.
Set child. LeftChild = parent.LeftChild
child.HasLeftChild = False
' Вставить узел.
Set parent.LeftChild = child
parent.HasLeftChild = True
' Родитель является последователем нового узла.
Set child.RightChild = parent
child.HasRightChild = False
' Определить, является ли новый узел первым узлом дерева.
If child.LeftChild Is Nothing Then Set FirstNode = child
End Sub
Перед тем, как удалить узел из дерева, необходимо вначале удалить всех его потомков. После этого легко удалить уже сам узел.
Предположим, что удаляемый узел является левым потомком своего родителя. Его указатель на левого потомка является ссылкой, указывающей на предыдущий узел в дереве. После удаления узла, его предшественник становится предшественником родителя удаленного узла. Чтобы удалить узел, просто заменяем указатель на левого потомка его родителя на указатель на левого потомка удаляемого узла.
Указатель на правого потомка удаляемого узла является ссылкой, которая указывает на следующий узел в дереве. Так как удаляемый узел является левым потомком своего родителя, и поскольку у него нет потомков, эта ссылка указывает на родителя, поэтому ее можно просто опустить. На рис. 6.24 показано дерево с рис. 6.23 после удаления узла F. Аналогично удаляется правый потомок.
Private Sub RemoveLeftChild(parent As ThreadedNode)
Dim target As ThreadedNode
Set target = parent.LeftChild
Set parent.LeftChild = target.LeftChild
End Sub
@Рис. 6.24. Удаление узла F из дерева со ссылками
=========145
Квадродеревья (quadtrees) описывают пространственные отношения между элементами на площади. Например, это может быть карта, а элементы могут представлять собой положение домов или предприятий на ней.
Каждый узел квадродерева представляет собой участок на площади, представленной квадродеревом. Каждый узел, кроме листьев, имеет четыре потомка, которые представляют четыре квадранта. Листья могут хранить свои элементы в коллекциях связных списков. Следующий код показывает секцию Declarations для класса QtreeNode.
' Потомки.
Public NWchild As QtreeNode
Public NEchild As QtreeNode
Public SWchild As QtreeNode
Public SEchild As QtreeNode
' Элементы узла, если это не лист.
Public Items As New Collection
Элементы, записанные в квадродереве, могут содержать пространственные данные любого типа. Они могут содержать информацию о положении, которую дерево может использовать для поиска элементов. Переменные в простом классе QtreeItem, который представляет элементы, состоящие из точек на местности, определяются так:
Public X As Single
Public Y As Single
Чтобы построить квадродерево, вначале поместим все элементы в корневой узел. Затем определим, содержит ли этот узел достаточно много элементов, чтобы его стоило разделить на несколько узлов. Если это так, создадим четыре потомка узла и распределим элементы между четырьмя потомками в соответствии с их положением в четырех квадрантах исходной области. Затем рекурсивно проверяем, не нужно ли разбить на несколько узлов дочерние узлы. Продолжим разбиение до тех пор, пока все листья не будут содержать не больше некоторого заданного числа элементов.
На рис. 6.25 показано несколько элементов данных, расположенных в виде квадродерева. Каждая область разбивается до тех пор, пока она не будет содержать не более двух элементов.
Квадродеревья удобно применять для поиска близлежащих объектов. Предположим, имеется программа, которая рисует карту с большим числом населенных пунктов. После того, как пользователь щелкнет мышью по карте, программа должна найти ближайший к выбранной точке населенный пункт. Программа может перебрать весь список населенных пунктов, проверяя для каждого его расстояние от заданной точки. Если в списке N элементов, то сложность этого алгоритма порядка O(N).
====146
@Рис. 6.25. Квадродерево
Эту операцию можно выполнить намного быстрее при помощи квадродерева. Начнем с корневого узла. При каждой проверке квадродерева определяем, какой из квадрантов содержит точку, которую выбрал пользователь. Затем спустимся вниз по дереву к соответствующему дочернему узлу. Если пользователь выбрал верхний правый угол области узла, нужно спуститься к северо‑восточному потомку. Продолжим движение вниз по дереву, пока не дойдем до листа, который содержит выбранную пользователем точку.
Функция LocateLeaf класса QtreeNode использует этот подход для поиска листа дерева, который содержит выбранную точку. Программа может вызвать эту функцию в строке Set the_leaf = Root.LocateLeaf(X, Y, Gxmin, Gxmax, Gymax), где Gxmin, Gxmax, Gymin, Gymax — это границы представленной деревом области.
Public Function LocateLeaf (X As Single, Y As Single, _
xmin As Single, xmax As Single, ymin As Single, ymax As Single) _
As QtreeNode
Dim xmid As Single
Dim ymid As Single
Dim node As QtreeNode
If NWchild Is Nothing Then
' Узел не имеет потомков. Искомый узел найден.
Set LocateLeaf = Me
Exit Function
End If
' Найти соответстующего потомка.
xmid = (xmax + xmin) / 2
ymid = (ymax + ymin) / 2
If X <= xmid Then
If Y <= ymid Then
Set LocateLeaf = NWchild.LocateLeaf( _
X, Y, xmin, xmid, ymin, ymid)
Else
Set LocateLeaf = SWchild.LocateLeaf _
X, Y, xmin, xmid, ymid, ymax)
End If
Else
If Y <= ymid Then
Set LocateLeaf = NEchild.LocateLeaf( _
X, Y, xmid, xmax, ymin, ymid)
Else
Set LocateLeaf = SEchild.LocateLeaf( _
X, Y, xmid, xmax, ymid, ymax)
End If
End If
End Function
После нахождения листа, который содержит точку, проверяем населенные пункты в листе, чтобы найти, который из них ближе всего от выбранной точки. Это делается при помощи процедуры NearPointInLeaf.
Public Sub NearPointInLeaf (X As Single, Y As Single, _
best_item As QtreeItem, best_dist As Single, comparisons As Long)
Dim new_item As QtreeItem
Dim Dx As Single
Dim Dy As Single
Dim new_dist As Single
' Начнем с заведомо плохого решения.
best_dist = 10000000
Set best_item = Nothing
' Остановиться если лист не содержит элементов.
If Items.Count < 1 Then Exit Sub
For Each new_item In Items
comparisons = comparisons + 1
Dx = new_item.X - X
Dy = new_item.Y - Y
new_dist =Dx * Dx + Dy * Dy
If best_dist > new_dist Then
best_dist = new_dist
Set best_item = new_item
End If
Next new_item
End Sub
======147-148
Элемент, который находит процедура NearPointLeaf, обычно и есть элемент, который пользователь пытался выбрать. Тем не менее, если элемент находится вблизи границы между двумя узлами, может оказаться, что ближайший к выбранной точке элемент находится в другом узле.
Предположим, что Dmin — это расстояние между выбранной пользователем точкой и ближайшим из найденных до сих пор населенных пунктов. Если Dmin меньше, чем расстояние от выбранной точки до края листа, то поиск закончен. Населенный пункт находится при этом слишком далеко от края листа, чтобы в каком‑либо другом листе мог существовать пункт, расположенный ближе к заданной точке.
В противном случае нужно снова начать с корня и двигаться по дереву, проверяя все узлы квадродеревьев, которые находятся на расстоянии меньше, чем Dmin от заданной точки. Если найдутся элементы, которые расположены ближе, изменим значение Dmin и продолжим поиск. После завершения проверки ближайших к точке листьев, нужный элемент будет найден. Подпрограмма CheckNearByLeaves использует этот подход для завершения поиска.
Public Sub CheckNearbyLeaves(exclude As QtreeNode, _
X As Single, Y As Single, best_item As QtreeItem, _
best_dist As Single, comparisons As Long, _
xmin As Single, xmax As Single, ymin As Single, ymax As Single)
Dim xmid As Single
Dim ymid As Single
Dim new_dist As Single
Dim new_item As QtreeItem
' Если это лист, который мы должны исключить,
' ничего не делать.
If Me Is exclude Then Exit Sub
' Если это лист, проверить его.
If SWchild Is Nothing Then
NearPointInLeaf X, Y, new_item, new_dist, comparisons
If best_dist > new_dist Then
best_dist = new_dist
Set best_item = new_item
End If
Exit Sub
End If
' Найти потомков, которые удалены не больше, чем на best_dist
' от выбранной точки.
xmid = (xmax + xmin) / 2
ymid = (ymax + ymin) / 2
If X - Sqr(best_dist) <= xmid Then
' Продолжаем с потомками на западе.
If Y - Sqr(best_dist) <= ymid Then
' Проверить северо-западного потомка.
NWchild.CheckNearbyLeaves _
exclude, X, Y, best_item, _
best_dist, comparisons, _
xmin, xmid, ymin, ymid
End If
If Y + Sqr(best_dist) > ymid Then
' Проверить юго-западного потомка.
SWchiId.CheckNearbyLeaves _
exclude, X, Y, best_item, _
best_dist, comparisons, _
xmin, xmid, ymid, ymax
End If
End If
If X + Sqr(best_dist) > xmid Then
' Продолжить с потомками на востоке.
If Y - Sqr(best_dist) <= ymid Then
' Проверить северо-восточного потомка.
NEchild.CheckNearbyLeaves _
exclude, X, Y, best_item, _
best_dist, comparisons, _
xmid, xmax, ymin, ymid
End If
If Y + Sqr(best_dist) > ymid Then
' Проверить юговосточного потомка.
SEchild.CheckNearbyLeaves _
exclude, X, Y, best_item, _
best_dist, comparisons, _
xmid, xmax, ymid, ymax
End If
End If
End Sub
=====149-150
Подпрограмма FindPoint использует подпрограммы LocateLeaf, NearPointInLeaf, и CheckNearbyLeaves, из класса QtreeNode для быстрого поиска элемента в квадродереве.
Function FindPoint(X As Single, Y As Single, comparisons As Long) _ As QtreeItem
Dim leaf As QtreeNode
Dim best_item As QtreeItem
Dim best_dist As Single
' Определить, в каком листе находится точка.
Set leaf = Root.LocateLeaf( _
X, Y, Gxmin, Gxmax, Gymin, Gymax)
' Найти ближайшую точку в листе.
leaf.NearPointInLeaf _
X, Y, best_item, best_dist, comparisons
' Проверить соседние листья.
Root.CheckNearbyLeaves _
leaf, X, Y, best_item, best_dist, _
comparisons, Gxmin, Gxmax, Gymin, Gymax
Set FindPoint = best_item
End Function
Программа Qtree использует квадродерево. При старте программа запрашивает число элементов данных, которое она должна создать, затем она создает элементы и рисует их в виде точек. Задавайте вначале небольшое (около 1000) число элементов, пока вы не определите, насколько быстро ваш компьютер может создавать элементы.
Интересно наблюдать квадродеревья, элементы которых распределены неравномерно, поэтому программа выбирает точки при помощи функции странного аттрактора (strange attractor) из теории хаоса (chaos theory). Хотя кажется, что элементы следуют в случайном порядке, они образуют интересные кластеры.
При выборе какой‑либо точки на форме при помощи мыши, программа Qtree находит ближайший к ней элемент. Она подсвечивает этот элемент и выводит число проверенных при его поиске элементов.
В меню Options (Опции) программы можно задать, должна ли программа использовать квадродеревья или нет. Если поставить галочку в пункте Use Quadtree (Использовать квадродерево), то программа выводит на экран квадродерево и использует его для поиска элементов. Если этот пункт не выбран, программа не отображает квадродерево и находит нужные элементы путем перебора.
Программа проверяет намного меньшее число элементов и работает намного быстрее при использовании квадродерева. Если этот эффект не слишком заметен на вашем компьютере, запустите программу, задав при старте 10.000 или 20.000 входных элементов. Вы заметите разницу даже на компьютере с процессором Pentium с тактовой частотой 90 МГц.
На рис. 6.26 показано окно программа Qtree на котором изображено 10.000 элементов. Маленький прямоугольник в верхнем правом углу обозначает выбранный элемент. Метка в верхнем левом углу показывает, что программа проверила всего 40 из 10.000 элементов перед тем, как найти нужный.
Интересно поэкспериментировать с программой Qtree, изменяя значение MAX_PER_NODE, определенное в разделе Declarations класса QtreeNode. Это максимальное число элементов, которые могут поместиться в узле квадродерева без его разбиения. Программа обычно использует значение MAX_PER_NODE = 100.
======151
@Рис. 6.26. Программа Qtree
Если вы уменьшите это число, например, до 10, то в каждом узле будет находиться меньше элементов, поэтому программа будет проверять меньше элементов, чтобы найти ближайший к выбранной вами точке. Поиск будет выполняться быстрее. С другой стороны, программе придется создать намного больше узлов квадродерева, поэтому она займет больше памяти.
Наоборот, если вы увеличите MAX_PER_NODE до 1000, программа создаст намного меньше узлов. При этом потребуется больше времени на поиск элементов, но дерево будет меньше, и займет меньше памяти.
Это пример компромисса между временем и пространством. Использование большего числа узлов квадродерева ускоряет поиск, но занимает больше памяти. В этом примере, при значении переменной MAX_PER_NODE примерно равном 100, достигается равновесие между скоростью и использованием памяти. Для других приложений вам может потребоваться поэкспериментировать с различными значениями переменной MAX_PER_NODE, чтобы найти оптимальное.
Программа Qtree использует большое число классов и коллекций. Каждый внутренний узел квадродерева содержит четыре ссылки на дочерние узлы. Листья включают большие коллекции, в которых находятся элементы узла. Все эти объекты и коллекции замедляют работу программы, если она содержит большое числе элементов. Создание объектов отнимает много времени и памяти. Если программа создает множество объектов, она может начать обращаться к файлу подкачки, что сильно замедлит ее работу.
К сожалению, выигрыш от использования квадродеревьев будет максимальным, если программа содержит много элементов. Чтобы улучшить производительность больших приложений, вы можете использовать методы работы с псевдоуказателями, описанные во 2 главе.
=====152
Программа Qtree2 создает квадродерево при помощи псевдоуказателей. Узлы и элементы находятся в массивах определенных пользователем структур данных. В качестве указателей, эта программа использует индексы массивов вместо ссылок на объекты. В одном из тестов на компьютере с процессором Pentium с тактовой частотой 90 МГц, программе Qtree потребовалось 25 секунд для построения квадродерева, содержащего 30.000 элементов. Программе Qtree2 понадобилось всего 3 секунды для создания того же дерева.
Восьмеричные деревья (octtrees) аналогичны квадродеревьям, но они разбивают область не двумерного, а трехмерного пространства. Восьмеричные деревья содержат не четыре потомка, как квадродеревья, а восемь, разбивая объем области на восемь частей — верхнюю северо‑западную, нижнюю северо‑западную, верхнюю северо‑восточную, нижнюю северо‑восточную и так далее.
Восьмеричные деревья полезны при работе с объектами, расположенными в пространстве. Например, робот может использовать восьмеричное дерево для отслеживания близлежащих объектов. Программа рейтрейсинга может использовать восьмеричное дерево для того, чтобы быстро оценить, проходит ли луч поблизости от объекта перед тем, как начать медленный процесс вычислений точного пересечения объекта и луча.
Восьмеричные деревья можно строить, используя примерно те же методы, что и для квадродеревьев.
Существует множество способов представления деревьев. Наиболее эффективным и компактным из них является запись полных деревьев в массивах. Представление деревьев в виде коллекций дочерних узлов упрощает работу с ними, но при этом программа выполняется медленнее и использует больше памяти. Представление нумерацией связей позволяет быстро выполнять обход дерева и использует меньше памяти, чем коллекции потомков, но его сложно модифицировать. Тем не менее, его важно представлять, потому что оно часто используется в сетевых алгоритмах.
=====153
Глава 7. Сбалансированные деревьяПри работе с упорядоченным деревом, вставке и удалении узлов, дерево может стать несбалансированным. Когда это происходит, то алгоритмы, работы с деревом становятся менее эффективными. Если дерево становится сильно несбалансированным, оно практически представляет всего лишь сложную форму связного списка, и программа, использующая такое дерево, может иметь очень низкую производительность.
В этой главе обсуждаются методы, которые можно использовать для балансировки деревьев, даже если узлы удаляются и добавляются с течением времени. Балансировка дерева позволяет ему оставаться при этом достаточно эффективным.
Глава начинается с описания того, что понимается под несбалансированным деревом и демонстрации ухудшения производительности для несбалансированных деревьев. Затем в ней обсуждаются АВЛ‑деревья, высота левого и правого поддеревьев в каждом узле которых отличается не больше, чем на единицу. Сохраняя это свойство АВЛ‑деревьев, можно поддерживать такое дерево сбалансированным.
Затем в главе описываются Б‑деревья и Б+деревья, в которых все листья имеют одинаковую глубину. Если число ветвей, выходящих из каждого узла находится в определенных пределах, такие деревья остаются сбалансированными. Б‑деревья и Б+деревья обычно используются при программировании баз данных. Последняя программа, описанная в этой главе, использует Б+дерево для реализации простой, но достаточно мощной базы данных.
Как упоминалось в 6 главе, форма упорядоченного дерева зависит от порядка вставки в него новых узлов. На рис. 7.1 показано два различных дерева, созданных при добавлении одних и тех же элементов в разном порядке.
Высокие и тонкие деревья, такие как левое дерево на рис. 7.1, могут иметь глубину порядка O(N). Вставка или поиск элемента в таком несбалансированном дереве может занимать порядка O(N) шагов. Даже если новые элементы вставляются в дерево в случайном порядке, в среднем они дадут дерево с глубиной N / 2, что также порядка O(N).
Предположим, что строится упорядоченное двоичное дерево, содержащее 1000 узлов. Если дерево сбалансировано, то высота дерева будет порядка log2(1000), или примерно равна 10. Вставка нового элемента в дерево займет всего 10 шагов. Если дерево высокое и тонкое, оно может иметь высоту 1000. В этом случае, вставка элемента в конец дерева займет 1000 шагов.
======155
@Рис. 7.1. Деревья, построенные в различном порядке
Предположим теперь, что мы хотим добавить к дереву еще 1000 узлов. Если дерево остается сбалансированным, то все 1000 узлов поместятся на следующем уровне дерева. При этом для вставки новых элементов потребуется около 10 * 1000 = 10.000 шагов. Если дерево было не сбалансировано и остается таким в процессе роста, то при вставке каждого нового элемента оно будет становиться все выше. Вставка элементов при этом потребует порядка 1000 + 1001 + … +2000 = 1,5 миллиона шагов.
Хотя нельзя быть уверенным, что элементы будут добавляться и удаляться из дерева в нужном порядке, можно использовать методы, которые будут поддерживать сбалансированность дерева, независимо от порядка вставки или удаления элементов.
АВЛ‑деревья (AVL trees) были названы в честь русских математиков Адельсона‑Вельского и Лэндиса, которые их изобрели. Для каждого узла АВЛ‑дерева, высота левого и правого поддеревьев отличается не больше, чем на единицу. На рис. 7.2 показано несколько АВЛ‑деревьев.
Хотя АВЛ‑дерево может быть несколько выше, чем полное дерево с тем же числом узлов, оно также имеет высоту порядка O(log(N)). Это означает, что поиск узла в АВЛ‑дереве занимает время порядка O(log(N)), что достаточно быстро. Не столь очевидно, что можно вставить или удалить элемент из АВЛ‑дерева за время порядка O(log(N)), сохраняя при этом порядок дерева.
======156
@Рис. 7.2. АВЛ‑деревья
Процедура, которая вставляет в дерево новый узел, рекурсивно спускается вниз по дереву, чтобы найти местоположение узла. После вставки элемента, происходят возвраты из рекурсивных вызовов процедуры и обратный проход вверх по дереву. При каждом возврате из процедуры, она проверяет, сохраняется ли все еще свойство АВЛ‑деревьев на верхнем уровне. Этот тип обратной рекурсии, когда процедура выполняет важные действия при выходе из цепочки рекурсивных вызовов, называется восходящей (bottom‑up) рекурсией.
При обратном проходе вверх по дереву, процедура также проверяет, не изменилась ли высота поддерева, с которым она работает. Если процедура доходит до точки, в которой высота поддерева не изменилась, то высота следующих поддеревьев также не могла измениться. В этом случае, снова требуется балансировка дерева, и процедура может закончить проверку.
Например, дерево слева на рис. 7.3 является сбалансированным АВЛ‑деревом. Если добавить к дереву новый узел E, то получится среднее дерево на рисунке. Затем выполняется проход вверх по дереву от нового узла E. В самом узле E дерево сбалансировано, так как оба его поддерева пустые и имеют одинаковую высоту 0.
В узле D дерево также сбалансировано, так как его левое поддерево пустое, и имеет поэтому высоту 0. Правое поддерево содержит единственный узел E, и поэтому его высота равна 1. Высоты поддеревьев отличаются не больше, чем на единицу, поэтому дерево сбалансировано в узле D.
В узле C дерево уже не сбалансировано. Левое поддерево узла C имеет высоту 0, а правое — высоту 2. Эти поддеревья можно сбалансировать, как показано на рис. 7.3 справа, при этом узел C заменяется узлом D. Теперь поддерево с корнем в узле D содержит узлы C, D и E, и имеет высоту 2. Заметьте, что высота поддерева с корнем в узле C, которое ранее находилось в этом месте, также была равна 2 до вставки нового узла. Так как высота поддерева не изменилась, то дерево также окажется сбалансированным во всех узлах выше D.
При вставке узла в АВЛ‑дерево, в зависимости от того, в какую часть дерева добавляется узел, существует четыре варианта балансировки. Эти способы называются правым и левым вращением, и вращением влево‑вправо и вправо‑влево, и обозначаются R, L, LR и RL.
Предположим, что в АВЛ‑дерево вставляется новый узел, и теперь дерево становится несбалансированным в узле X, как показано на рис. 7.4. На рисунке изображены только узел X и два его дочерних узла, а остальные части дерева обозначены треугольниками, так как их не требуется рассматривать подробно.
Новый узел может быть вставлен в любое из четырех поддеревьев узла X, изображенных в виде треугольников. Если вы вставляете узел в одно из этих поддеревьев, то для балансировки дерева потребуется выполнить соответствующее вращение. Помните, что иногда балансировка не нужна, если вставка нового узла не нарушает упорядоченность дерева.
Вначале предположим, что новый узел вставляется в поддерево R на рис. 7.4. В этом случае не нужно изменять два правых поддерева узла X, поэтому их можно объединить, изобразив одним треугольником, как показано на рис. 7.5. Новый узел вставляется в дерево T1, при этом поддерево TA с корнем в узле A становится не менее, чем на два уровня выше, чем поддерево T3.
На самом деле, поскольку до вставки нового узла дерево было АВЛ‑деревом, то TA должно было быть выше поддерева T3 не больше, чем на один уровень. После вставки одного узла TA должно быть выше поддерева T3 ровно на два уровня.
Также известно, что поддерево T1 выше поддерева T2 не больше, чем на один уровень. Иначе узел X не был бы самым нижним узлом с несбалансированными поддеревьями. Если бы T1 было на два уровня выше, чем T2, то дерево было бы несбалансированным в узле A.
@Рис. 7.4. Анализ несбалансированного АВЛ‑дерева
========158
@Рис. 7.5. Вставка нового узла в поддерево R
В этом случае, можно переупорядочить узлы при помощи правого вращения (right rotation), как показано на рис. 7.6. Это вращение называется правым, так как узлы A и X как бы вращаются вправо.
Заметим, что это вращение сохраняет порядок «меньше» расположения узлов дерева. При симметричном обходе любого из таких деревьев обращение ко всем поддеревьям и узлам дерева происходит в порядке T1, A, T2, X, T3. Поскольку симметричный обход обоих деревьев происходит одинаково, то и порядок расположения элементов в них будет одинаковым.
Важно также заметить, что высота поддерева, с которым мы работаем, остается неизменной. Перед тем, как был вставлен новый узел, высота поддерева была равна высоте поддерева T2 плюс 2. После вставки узла и выполнения правого вращения, высота поддерева также остается равной высоте поддерева T2 плюс 2. Все части дерева, лежащие ниже узла X при этом также остаются сбалансированными, поэтому не требуется продолжать балансировку дерева дальше.
Левое вращение (left rotation) выполняется аналогично правому. Оно используется, если новый узел вставляется в поддерево L, показанное на рис. 7.4. На рис. 7.7 показано АВЛ‑дерево до и после левого вращения.
@Рис. 7.6. Правое вращение
========159
@Рис. 7.7. До и после левого вращения
Если узел вставляется в поддерево LR, показанное на рис. 7.4, нужно рассмотреть еще один нижележащий уровень. На рис. 7.8. показано дерево, в котором новый узел вставляется в левую часть T2 поддерева LR. Так же легко можно вставить узел в правое поддерево T3. В обоих случаях, поддеревья TA и TC останутся АВЛ‑поддеревьями, но поддерево TX уже не будет таковым.
Так как дерево до вставки узла было АВЛ‑деревом, то TA было выше T4 не больше, чем на один уровень. Поскольку добавлен только один узел, то TA вырастет только на один уровень. Это значит, что TA теперь будет точно на два уровня выше T4.
Также известно, что поддерево T2 не более, чем на один уровень выше, чем T3. Иначе TC не было бы сбалансированным, и узел X не был бы самым нижним в дереве узлом с несбалансированными поддеревьями.
Поддерево T1 должно иметь ту же глубину, что и T3. Если бы оно было короче, то поддерево TA было бы не сбалансировано, что снова противоречит предположению о том, что узел X — самый нижний узел в дереве, имеющий несбалансированные поддеревья. Если бы поддерево T1 имело большую глубину, чем T3, то глубина поддерева T1 была бы на 2 уровня больше, чем глубина поддерева T4. В этом случае дерево было бы несбалансированным до вставки в него нового узла.
Все это означает, что нижние части деревьев выглядят в точности так, как показано на рис. 7.8. Поддерево T2 имеет наибольшую глубину, глубина T1 и T3 на один уровень меньше, а T4 расположено еще на один уровень выше, чем T3 и T3.
@Рис. 7.8. Вставка нового узла в поддерево LR
==========160
@Рис. 7.9. Вращение влево‑вправо
Используя эти факты, можно сбалансировать дерево, как показано на рис. 7.9. Это называется вращением влево‑вправо (left‑right rotation), так как при этом вначале узлы A и C как бы вращаются влево, а затем узлы C и X вращаются вправо.
Как и другие вращения, вращение этого типа не изменяет порядок элементов в дереве. При симметричном обходе дерева до и после вращения обращение к узлам и поддеревьям происходит в порядке: T1, A, T2, C, T3, X, T4.
Высота дерево после балансировки также не меняется. До вставки нового узла, правое поддерево имело высоту поддерева T1 плюс 2. После балансировки дерева, высота этого поддерева снова будет равна высоте T1 плюс 2. Это значит, что остальная часть дерева также остается сбалансированной, и нет необходимости продолжать балансировку дальше.
Вращение вправо‑влево (right‑left rotation) аналогично вращению влево‑вправо (). Оно используется для балансировки дерева после вставки узла в поддерево RL на рис. 7.4. На рис. 7.10 показано АВЛ‑дерево до и после вращения вправо‑влево.
На рис. 7.11 показаны все возможные вращения АВЛ‑дерева. Все они сохраняют порядок симметричного обхода дерева, и высота дерева при этом всегда остается неизменной. После вставки нового элемента и выполнения соответствующего вращения, дерево снова оказывается сбалансированным.
Перед тем, как перейти к обсуждению удаления узлов из АВЛ‑деревьев, в этом разделе обсуждаются некоторые детали реализации вставки узла в АВЛ‑дерево на языке Visual Basic.
Кроме обычных полей LeftChild и RightChild, класс AVLNode содержит также поле Balance, которое указывает, которое из поддеревьев узла выше. Его значение равно -1, если левое поддерево выше, 1 — если выше правое, и 0 — если оба поддерева имеют одинаковую высоту.
======161
@Рис. 7.10. До и после вращения вправо‑влево
Public LeftChild As AVLNode
Public RightChild As AVLNode
Public Balance As Integer
Чтобы сделать код более простым для чтения, можно использовать постоянные LEFT_HEAVY, RIGHT_HEAVY, и BALANCED для представления этих значений.
Global Const LEFT_HEAVY = -1
Global Const BALANCED = 0
Global Const RIGHT_HEAVY = 1
Процедура InsertItem, представленная ниже, рекурсивно спускается вниз по дереву в поиске нового местоположения элемента. Когда она доходит до нижнего уровня дерева, она создает новый узел и вставляет его в дерево.
Затем процедура InsertItem использует восходящую рекурсию для балансировки дерева. При выходе из рекурсивных вызовов процедуры, она движется назад по дереву. При каждом возврате из процедуры, она устанавливает параметр has_grown, чтобы определить, увеличилась ли высота поддерева, которое она покидает. В экземпляре процедуры InsertItem, который вызвал этот рекурсивный вызов, процедура использует этот параметр для определения того, является ли проверяемое дерево несбалансированным. Если это так, то процедура применяет для балансировки дерева соответствующее вращение.
Предположим, что процедура в настоящий момент обращается к узлу X. Допустим, что она перед этим обращалась к правому поддереву снизу от узла X и что параметр has_grown равен true, означая, что правое поддерево увеличилось. Если поддеревья узла X до этого имели одинаковую высоту, тогда правое поддерево станет теперь выше левого. В этой точке дерево сбалансировано, но поддерево с корнем в узле X выросло, так как выросло его правое поддерево.
Если левое поддерево узла X вначале было выше, чем правое, то левое и правое поддеревья теперь будут иметь одинаковую высоту. Высота поддерева с корнем в узле X не изменилась — она по‑прежнему равна высоте левого поддерева плюс 1. В этом случае процедура InsertItem установит значение переменной has_grown равным false, показывая, что дерево сбалансировано.
========162
@Рис. 7.11 Различные вращения АВЛ‑дерева
======163
В конце концов, если правое поддерево узла X было первоначально выше левого, то вставка нового узла делает дерево несбалансированным в узле X. Процедура InsertItem вызывает подпрограмму RebalanceRigthGrew для балансировки дерева. Процедура RebalanceRigthGrew выполняет левое вращение или вращение вправо‑влево, в зависимости от ситуации.
Если новый элемент вставляется в левое поддерево, то подпрограмма InsertItem выполняет аналогичную процедуру.
Public Sub InsertItem(node As AVLNode, parent As AVLNode, _
txt As String, has_grown As Boolean)
Dim child As AVLNode
' Если это нижний уровень дерева, поместить
' в родителя указатель на новый узел.
If parent Is Nothing Then
Set parent = node
parent.Balance = BALANCED
has_grown = True
Exit Sub
End If
' Продолжить с левым и правым поддеревьями.
If txt <= parent.Box.Caption Then
' Вставить потомка в левое поддерево.
Set child = parent.LeftChild
InsertItem node, child, txt, has_grown
Set parent.LeftChild = child
' Проверить, нужна ли балансировка. Она будет
' не нужна, если вставка узла не нарушила
' балансировку дерева или оно уже было сбалансировано
' на более глубоком уровне рекурсии. В любом случае
' значение переменной has_grown будет равно False.
If Not has_grown Then Exit Sub
If parent.Balance = RIGHT_HEAVY Then
' Перевешивала правая ветвь, теперь баланс
' восстановлен. Это поддерево не выросло,
' поэтому дерево сбалансировано.
parent.Balance = BALANCED
has_grown = False
ElseIf parent.Balance = BALANCED Then
' Было сбалансировано, теперь перевешивает левая ветвь.
' Поддерево все еще сбалансировано, но оно выросло,
' поэтому необходимо продолжить проверку дерева.
parent.Balance = LEFT_HEAVY
Else
' Перевешивала левая ветвь, осталось несбалансировано.
' Выполнить вращение для балансировки на уровне
' этого узла.
RebalanceLeftGrew parent
has_grown = False
End If ' Закончить проверку балансировки этого узла.
Else
' Вставить потомка в правое поддерево.
Set child = parent.RightChild
InsertItem node, child, txt, has_grown
Set parent.RightChild = child
' Проверить, нужна ли балансировка. Она будет
' не нужна, если вставка узла не нарушила
' балансировку дерева или оно уже было сбалансировано
' на более глубоком уровне рекурсии. В любом случае
' значение переменной has_grown будет равно False.
If Not has_grown Then Exit Sub
If parent.Balance = LEFT_HEAVY Then
' Перевешивала левая ветвь, теперь баланс
' восстановлен. Это поддерево не выросло,
' поэтому дерево сбалансировано.
parent.Balance = BALANCED
has_grown = False
ElseIf parent.Balance = BALANCED Then
' Было сбалансировано, теперь перевешивает правая
' ветвь. Поддерево все еще сбалансировано,
' но оно выросло, поэтому необходимо продолжить
' проверку дерева.
parent.Balance = RIGHT_HEAVY
Else
' Перевешивала правая ветвь, осталось несбалансировано.
' Выполнить вращение для балансировки на уровне
' этого узла.
RebalanceRightGrew parent
has_grown = False
End If ' Закончить проверку балансировки этого узла.
End If ' End if для левого поддерева else правое поддерево.
End Sub
========165
Private Sub RebalanceRightGrew(parent As AVLNode)
Dim child As AVLNode
Dim grandchild As AVLNode
Set child = parent.RightChild
If child.Balance = RIGHT_HEAVY Then
' Выполнить левое вращение.
Set parent.RightChild = child.LeftChild
Set child.LeftChild = parent
parent.Balance = BALANCED
Set parent = child
Else
' Выполнить вращение вправо‑влево.
Set grandchild = child.LeftChild
Set child.LeftChild = grandchild.RightChild
Set grandchild.RightChild = child
Set parent.RightChild = grandchild.LeftChild
Set grandchild.LeftChild = parent
If grandchild.Balance = RIGHT_HEAVY Then
parent.Balance = LEFT_HEAVY
Else
parent.Balance = BALANCED
End If
If grandchild.Balance = LEFT_HEAVY Then
child.Balance = RIGHT_HEAVY
Else
child.Balance = BALANCED
End If
Set parent = grandchild
End If ' End if для правого вращения else двойное правое
' вращение.
parent.Balance = BALANCED
End Sub
В 6 главе было показано, что удалить элемент из упорядоченного дерева сложнее, чем вставить его. Если удаляемый элемент имеет всего одного потомка, можно заменить его этим потомком, сохранив при этом порядок дерева. Если у дерева два дочерних узла, то он заменяется на самый правый узел в левой ветви дерева. Если у этого узла существует левый потомок, то этот левый потомок также занимает его место.
======166
Так как АВЛ‑деревья являются особым типом упорядоченных деревьев, то для них нужно выполнить те же самые шаги. Тем не менее, после их завершения необходимо вернуться назад по дереву, чтобы убедиться в том, что оно осталось сбалансированным. Если найдется узел, для которого не выполняется свойство АВЛ‑деревьев, то нужно выполнить для балансировки дерева соответствующее вращение. Хотя это те же самые вращения, которые использовались раньше для вставки узла в дерево, они применяются в других случаях.
Предположим, что мы удаляем узел из левого поддерева узла X. Также предположим, что правое поддерево либо уравновешено, либо высота его правой половины на единицу больше, чем высота левой. Тогда левое вращение, показанное на рис. 7.12, приведет к балансировке дерева в узле X.
Нижний уровень поддерева T2 закрашен серым цветом, чтобы показать, что поддерево TB либо уравновешено (T2 и T3 имеют одинаковую высоту), либо его правая половина выше (T3 выше, чем T2). Другими словами, закрашенный уровень может существовать в поддереве T2 или отсутствовать.
Если T2 и T3 имеют одинаковую высоту, то высота поддерева TX с корнем в узле X не меняется после удаления узла. Высота TX при этом остается равной высоте поддерева T2 плюс 2. Так как эта высота не меняется, то дерево выше этого узла остается сбалансированным.
Если T3 выше, чем T2, то поддерево TX становится ниже на единицу. В этом случае, дерево может быть несбалансированным выше узла X, поэтому необходимо продолжить проверку дерева, чтобы определить, выполняется ли свойство АВЛ‑деревьев для предков узла X.
Предположим теперь, что узел удаляется из левого поддерева узла X, но левая половина правого поддерева выше, чем правая. Тогда для балансировки дерева нужно использовать вращение вправо‑влево, показанное на рис. 7.13.
Если левое или правое поддеревья T2 или T3 выше, то вращение вправо‑влево приведет к балансировке поддерева TX, и уменьшит при этом высоту TX на единицу. Это значит, что дерево выше узла X может быть несбалансированным, поэтому необходимо продолжить проверку выполнения свойства АВЛ‑деревьев для предков узла X.
@Рис. 7.12. Левое вращение при удалении узла
========167
@Рис. 7.13. Вращение вправо‑влево при удалении узла
Остальные вращения выполняются аналогично. В этом случае удаляемый узел находится в правом поддереве узла X. Эти четыре вращения — те же самые, которые использовались для балансировки дерева при вставке узла, за одним исключением.
Если новый узел вставляется в дерево, то первое выполняемое вращение осуществляет балансировку поддерева TX, не изменяя его высоту. Это значит, что дерево выше узла TX будет при этом оставаться сбалансированным. Если же эти вращения используются после удаления узла из дерева, то вращение может уменьшить высоту поддерева TX на единицу. В этом случае, нельзя быть уверенным, что дерево выше узла X осталось сбалансированным. Нужно продолжить проверку выполнения свойства АВЛ‑деревьев вверх по дереву.
Подпрограмма DeleteItem удаляет элементы из дерева. Она рекурсивно спускается по дереву в поиске удаляемого элемента и когда она находит искомый узел, то удаляет его. Если у этого узла нет потомков, то процедура завершается. Если есть только один потомок, то процедура заменяет узел его потомком.
Если узел имеет двух потомков, процедура DeleteItem вызывает процедуру ReplaceRightMost для замены искомого узла самым правым узлом в его левой ветви. Процедура ReplaceRightMost выполняется примерно так же, как и процедура из 6 главы, которая удаляет элементы из обычного (неупорядоченного) дерева. Основное отличие возникает при возврате из процедуры и рекурсивном проходе вверх по дереву. При этом процедура ReplaceRightMost использует восходящую рекурсию, чтобы убедиться, что дерево остается сбалансированным для всех узлов.
При каждом возврате из процедуры, экземпляр процедуры ReplaceRightMost вызывает подпрограмму RebalanceRightShrunk, чтобы убедиться, что дерево в этой точке сбалансировано. Так как процедура ReplaceRightMost опускается по правой ветви, то она всегда использует для выполнения балансировки подпрограмму RebalanceRightShrunk, а не RebalanceLeftShrunk.
При первом вызове подпрограммы ReplaceRightMost процедура DeleteItem направляет ее по левой от удаляемого узла ветви. При возврате из первого вызова подпрограммы ReplaceRightMost, процедура DeleteItem использует подпрограмму RebalanceLeftShrunk, чтобы убедиться, что дерево сбалансировано в этой точке.
=========168
После этого, один за другим происходят рекурсивные возвраты из процедуры DeleteItem при проходе дерева в обратном направлении. Так же, как и процедура ReplaceRightmost, процедура DeleteItem вызывает подпрограммы RebalanceRightShrunk или RebalanceLeftShrunk в зависимости от того, по какому пути происходит спуск по дереву.
Подпрограмма RebalanceLeftShrunk аналогична подпрограмме RebalanceRightShrunk, поэтому она не показана в следующем коде.
Public Sub DeleteItem(node As AVLNode, txt As String, shrunk As Boolean)
Dim child As AVLNode
Dim target As AVLNode
If node Is Nothing Then
Beep
MsgBox "Элемент " & txt & " не содержится в дереве."
shrunk = False
Exit Sub
End If
If txt < node.Box.Caption Then
Set child = node.LeftChild
DeleteItem child, txt, shrunk
Set node.LeftChild = child
If shrunk Then RebalanceLeftShrunk node, shrunk
ElseIf txt > node.Box.Caption Then
Set child = node.RightChild
DeleteItem child, txt, shrunk
Set node.RightChild = child
If shrunk Then RebalanceRightShrunk node, shrunk
Else
Set target = node
If target.RightChild Is Nothing Then
' Потомков нет или есть только правый.
Set node = target.LeftChild
shrunk = True
ElseIf target.LeftChild Is Nothing Then
' Есть только правый потомок.
Set node = target.RightChild
shrunk = True
Else
' Есть два потомка.
Set child = target.LeftChild
ReplaceRightmost child, shrunk, target
Set target.LeftChild = child
If shrunk Then RebalanceLeftShrunk node, shrunk
End If
End If
End Sub
Private Sub ReplaceRightmost(repl As AVLNode, shrunk As Boolean, target As AVLNode)
Dim child As AVLNode
If repl.RightChild Is Nothing Then
target.Box.Caption = repl.Box.Caption
Set target = repl
Set repl = repl.LeftChild
shrunk = True
Else
Set child = repl.RightChild
ReplaceRightmost child, shrunk, target
Set repl.RightChild = child
If shrunk Then RebalanceRightShrunk repl, shrunk
End If
End Sub
Private Sub RebalanceRightShrunk(node As AVLNode, shrunk As Boolean)
Dim child As AVLNode
Dim child_bal As Integer
Dim grandchild As AVLNode
Dim grandchild_bal As Integer
If node.Balance = RIGHT_HEAVY Then
' Правая часть перевешивала, теперь баланс восстановлен.
node.Balance = BALANCED
ElseIf node.Balance = BALANCED Then
' Было сбалансировано, теперь перевешивает левая часть.
node.Balance = LEFT_HEAVY
shrunk = False
Else
' Левая часть перевешивала, теперь не сбалансировано.
Set child = node.LeftChild
child_bal = child.Balance
If child_bal <= 0 Then
' Правое вращение.
Set node.LeftChild = child.RightChild
Set child.RightChild = node
If child_bal = BALANCED Then
node.Balance = LEFT_HEAVY
child.Balance = RIGHT_HEAVY
shrunk = False
Else
node.Balance = BALANCED
child.Balance = BALANCED
End If
Set node = child
Else
' Вращение влево‑вправо.
Set grandchild = child.RightChild
grandchild_bal = grandchild.Balance
Set child.RightChild = grandchild.LeftChild
Set grandchild.LeftChild = child
Set node.LeftChild = grandchild.RightChild
Set grandchild.RightChild = node
If grandchild_bal = LEFT_HEAVY Then
node.Balance = RIGHT_HEAVY
Else
node.Balance = BALANCED
End If
If grandchild_bal = RIGHT_HEAVY Then
child.Balance = LEFT_HEAVY
Else
child.Balance = BALANCED
End If
Set node = grandchild
grandchild.Balance = BALANCED
End If
End If
End Sub
Программа AVL оперирует АВЛ‑деревом. Введите текст и нажмите на кнопку Add, чтобы добавить элемент к дереву. Введите значение, и нажмите на кнопку Remove, чтобы удалить этот элемент из дерева. На рис. 7.14 показана программа AVL.
Б‑деревья (B‑trees) являются другой формой сбалансированных деревьев, немного более наглядной, чем АВЛ‑деревья. Каждый узел в Б‑дереве может содержать несколько ключей данных и несколько указателей на дочерние узлы. Поскольку каждый узел содержит несколько элементов, такие узлы иногда называются блоками.
=======171
@Рис. 7.14. Программа AVL
Между каждой парой соседних указателей находится ключ, который можно использовать для определения ветви, по которой нужно следовать при вставке или поиске элемента. Например, в дереве, показанном на рис. 7.15, корневой узел содержит два ключа: G и R. Чтобы найти элемент со значением, которое идет перед G, нужно искать в первой ветви. Чтобы найти элемент, имеющий значение между G и R, проверяется вторая ветвь. Чтобы найти элемент, который следует за R, выбирается третья ветвь.
Б‑дерево порядка K обладает следующими свойствами:
· Каждый узел содержит не более 2 * K ключей.
· Каждый узел, кроме может быть корневого, содержит не менее K ключей.
· Внутренний узел, имеющий M ключей, имеет M + 1 дочерних узлов.
· Все листья дерева находятся на одном уровне.
Б‑дерево на рис. 7.15 имеет 2 порядок. Каждый узел может иметь до 4 ключей. Каждый узел, кроме может быть корневого, должен иметь не менее двух ключей. Для удобства, узлы Б‑дерева обычно имеют четное число ключей, поэтому порядок дерева обычно является целым числом.
Выполнение требования, чтобы каждый узел Бдерева порядка K содержал от K до 2 * K ключей, поддерживает дерево сбалансированным. Так как каждый узел должен иметь не менее K ключей, он должен при этом иметь не менее K + 1 дочерних узлов, поэтому дерево не может стать слишком высоким и тонким. Наибольшая высота Б‑дерева, содержащего N узлов, может быть равна O(logK+1(N)). Это означает, что сложность алгоритма поиска в таком дереве порядка O(log(N)). Хотя это и не так очевидно, операции вставки и удаления элемента из Б‑дерева также имеют сложность порядка O(log(N)).
@Рис. 7.15. Б‑дерево
=======172
Применение Б‑деревьев особенно полезно при разработке больших приложений, работающих с базами данных. При достаточно большом порядке Б‑дерева, любой элемент в дереве можно найти после проверки всего нескольких узлов. Например, высота Б‑дерева 10 порядка, содержащего миллион записей, не может быть больше log11(1.000.000), или выше шести уровней. Чтобы найти определенный элемент, потребуется проверить не более шести узлов.
Сбалансированное двоичное дерево с миллионом элементов имело бы высоту log2(1.000.000), или около 20. Тем не менее, узлы двоичного дерева содержат всего по одному ключевому значению. Для поиска элемента в двоичном дереве, пришлось бы проверить 20 узлов и 20 значений. Для поиска элемента в Б‑дереве пришлось бы проверить 5 узлов и 100 ключей.
Применение Б‑деревьев может обеспечить более высокую скорость работы, если проверка ключей выполняется относительно просто, в отличие от проверки узлов. Например, если база данных находится на диске, чтение данных с диска может происходить достаточно медленно. Когда же данные находятся в памяти, их проверка может происходить очень быстро.
Чтение данных с диска происходит большими блоками, и считывание целого блока занимает столько же времени, сколько и чтение одного байта. Если узлы Б‑дерева не слишком велики, то чтение узла Б‑дерева с диска займет не больше времени, чем чтение узла двоичного дерева. В этом случае, для поиска 5 узлов в Б‑дереве потребуется выполнить 5 медленных обращений к диску, плюс 100 быстрых обращений к памяти. Поиск 20 узлов в двоичном дереве потребует 20 медленных обращений к диску и 20 быстрых обращений к памяти, при этом поиск в двоичном дереве будет более медленным, поскольку время, затраченное на 15 лишних обращений к диску будет намного больше, чем сэкономленное время 80 обращений к памяти. Вопросы, связанные с обращением к диску, позднее обсуждаются в этой главе более подробно.
Чтобы вставить новый элемент в Б‑дерево, найдем лист, в который он должен быть помещен. Если этот узел содержит менее, чем 2 * K ключей, то в этом узле остается место для добавления нового элемента. Вставим новый узел на место так, чтобы порядок элементов внутри узла не нарушился.
Если узел уже содержит 2 * K элементов, то места для нового элемента в узле уже не остается. Разобьем тогда узел на два новых узла, поместив в каждый из них K элементов в правильном порядке. Затем средний элемент переместим в родительский узел.
Например, предположим, что мы хотим поместить новый элемент Q в Б‑дерево, показанное на рис. 7.15. Этот новый элемент должен находиться во втором листе, который уже заполнен. Для разбиения этого узла, разделим элементы J, K, L, N и Q между двумя новыми узлами. Поместим элементы J и K в левый узел, а элементы N и Q — в правый. Затем переместим средний элемент, L[RV13] в родительский узел. На рис. 7.16 показано новое дерево.
@Рис. 7.16. Б‑дерево после вставки элемента Q
=========173
Разбиение узла на два называется разбиением блока. Когда оно происходит, к родительскому узлу добавляется новый ключ и новый указатель. Если родительский узел уже заполнен, то это также может привести к его разбиению. Это, в свою очередь, потребует добавления новой записи на более высоком уровне и так далее. В наихудшем случае, вставка элемента вызовет «цепную реакцию», которая приведет к изменениям на всех вышележащих уровнях вплоть до разбиения корневого узла.
Когда происходит разбиение корневого узла, Б‑дерево становится выше. Это единственный случай, при котором его высота увеличивается. Поэтому Б‑деревья обладают необычным свойством — они всегда растут от листьев к корню.
Теоретически, удалить узел из Б‑дерева так же просто, как и вставить его. На практике, детали этого процесса достаточно сложны.
Если удаляемый узел не является листом, то его нужно заменить другим элементом, чтобы сохранить порядок элементов. Это похоже на случай удалений элемента из упорядоченного дерева или АВЛ‑дерева и его можно обрабатывать аналогично. Заменим элемент самым крайним правым элементом из левой ветви. Этот элемент всегда будет листом. После замены элемента, можно просто считать, что вместо него просто удален заменивший его лист.
Чтобы удалить элемент из листа, вначале нужно при необходимости сдвинуть все другие элементы влево, чтобы заполнить образовавшееся пространство. Помните, что каждый узел в Б‑дереве порядка K должен иметь от K до 2 * K элементов. После удаления элемента из листа, может оказаться, что он содержит всего K - 1 элементов.
В этом случае, можно попробовать взять несколько элементов из узлов на том же уровне. Затем можно распределить элементы в двух узлах так, чтобы они оба имели не меньше K элементов. На рис. 7.17 элемент удаляется из самого левого листа дерева, при этом в нем остается всего один элемент. После перераспределения элементов между узлом и правым узлом на том же уровне, оба узла имеют не меньше двух ключей. Заметьте, что средний элемент J перемещается в родительский узел.
@Рис. 7.17. Балансировка после удаления элемента
=======174
@Рис. 7.18. Слияние после удаления элемента
При попытке сбалансировать дерево таким образом, может оказаться, что соседний узел на том же уровне содержит всего K элементов. Тогда два узла вместе содержат всего 2 * K - 1 элементов, что недостаточно для заполнения двух узлов. В этом случае, все элементы из обоих узлов могут поместиться в одном узле, поэтому их можно слить. Удалим ключ, который отделяет два узла от родителя. Поместим этот элемент и 2 * K - 1 элементов из двух узлов в один общий узел. Этот процесс называется слиянием узлов (bucket merge или bucket join). На рис. 7.18 показано слияние двух узлов.
При слиянии двух узлов, из родительского узла удаляется ключ, при этом в родительском узле может остаться K - 1 элементов. В этом случае, может потребоваться балансировка или слияние родителя с одним из узлов на его уровне. Это также может привести к тому, что в узле на более высоком уровне также останется K - 1 элементов, и процесс повторится. В наихудшем случае, удаление приведет к «цепной реакции» слияний блоков, которая может дойти до корневого узла.
При удалении последнего элемента из корневого узла, два его оставшихся дочерних узла сливаются, образуя новый корень, и дерево при этом становится короче на один уровень. Единственный способ уменьшения высоты Б‑дерева — слияние двух дочерних узлов корня и образование нового корня.
Программа Btree позволяет вам оперировать Б‑деревом. Введите текст, и нажмите на кнопку Add, чтобы добавить элемент в дерево. Для удаления элемента введите его значение и нажмите на кнопку Remove. На рис. 7.19 показано окно программы Btree с Б‑деревом 2 порядка.
@Рис. 7.19. Программа Btree
========175
Существует несколько разновидностей Б‑деревьев, из которых здесь описаны только некоторые. Нисходящие Б‑деревья (top‑down B‑trees) немного иначе управляют структурой Б‑дерева. За счет разбиения встречающихся полных узлов, эта разновидность алгоритма использует при вставке элементов более наглядную нисходящую рекурсию вместо восходящей. Эта также уменьшает вероятность возникновения длительной последовательности разбиений блоков.
Другой разновидностью Б‑деревьев являются Б+деревья (B+trees). В Б+деревьях внутренние узлы содержат только ключи данных, а сами записи находятся в листьях. Это позволяет Б+деревьям хранить в каждом блоке больше элементов, поэтому такие деревья короче, чем соответствующие Б‑деревья.
Подпрограмма, которая добавляет новый элемент в Б‑дерево, вначале выполняет рекурсивный поиск по дереву, чтобы найти блок, в который его нужно поместить. Когда она пытается вставить новый элемент на его место, ей может понадобиться разбить блок и переместить один из элементов узла в его родительский узел.
При возврате из рекурсивных вызовов процедуры, вызывающая процедура проверяет, требуется ли разбиение родительского узла. Если да, то элемент помещается в родительский узел. При каждом возврате из рекурсивного вызова, вызывающая процедура должна проверять, не требуется ли разбиение следующего предка. Так как эти разбиения блоков происходят при возврате из рекурсивных вызовов процедура, это восходящая рекурсия, поэтому иногда Б‑деревья, которыми манипулируют таким образом, называются восходящими Б‑деревьями (bottom‑up B‑trees).
Другая стратегия состоит в том, чтобы разбивать все полные узлы, которые встречаются процедуре на пути вниз по дереву. При поиске блока, в который нужно поместить новый элемент, процедура разбивает все повстречавшиеся полные узлы. При каждом разбиении узла, она помещает один из его элементов в родительский узел. Так как она уже разбила все выше расположенные полные узлы, то в родительском узле всегда есть место для нового элемента.
Когда процедура доходит до листа, в который нужно поместить элемент, то в его родительском узле всегда есть свободное место, и если программе нужно разбить лист, то всегда можно поместить средний элемент в родительский узел. Так как при этом процедура работает с деревом сверху вниз, Б‑деревья такого типа иногда называются нисходящими Б‑деревьями (top‑down B‑trees).
При этом разбиение блоков происходит чаще, чем это абсолютно необходимо. В нисходящем Б‑дереве полный узел разбивается, даже если в его дочерних узлах достаточно много свободного места. За счет предварительного разбиения узлов, при использовании нисходящего метода в дереве содержится больше пустого пространства, чем в восходящем Б‑дереве. С другой стороны, такой подход уменьшает вероятность возникновения длительной последовательности разбиений блоков.
К сожалению, не существует нисходящей версии для слияния узлов. При продвижении вниз по дереву, процедура удаления узлов не может объединять встречающиеся наполовину пустые узлы, потому что в этот момент еще неизвестно, нужно ли будет объединить два дочерних узла и удалить элемент из их родителя. Так как неизвестно также, будет ли удален элемент из родительского узла, то нельзя заранее сказать, потребуется ли слияние родителя с одним из узлов, находящимся на том же уровне.
==========176
Б+деревья часто используются для хранения больших записей. Типичное Б‑дерево может содержать записи о сотрудниках, каждая из которых может занимать несколько килобайт памяти. Записи могли бы располагаться в Б‑дереве в соответствии с ключевым полем, например фамилией сотрудника или его идентификационным номером.
В этом случае упорядочение элементов может быть достаточно медленным. Чтобы слить два блока, программе может понадобиться переместить множество записей, каждая из которых может быть достаточно большой. Аналогично, для разбиения блока может потребоваться переместить множество записей большого объема.
Чтобы избежать перемещения больших блоков данных, программа может записывать во внутренних узлах Б‑дерева только ключи. При этом узлы также содержат ссылки на сами записи данных, которые записаны в другом месте. Теперь, если программе требуется переупорядочить блоки, то нужно переместить только ключи и указатели, а не сами записи. Этот тип Б‑дерева называется Б+деревом (B+tree).
То, что элементы в Б+дереве достаточно малы, также позволяет программе хранить больше ключей в каждом узле. При том же размере узла, программа может увеличить порядок дерева и сделать его более коротким.
Например, предположим, что имеется Б‑дерево 2 порядка, то есть каждый узел имеет от трех до пяти дочерних узлов. Такое дерево, содержащее миллион записей, должно было бы иметь высоту между log5(1.000.000) и log3(1.000.000), или между 9 и 13. Чтобы найти элемент в таком дереве, программа должна выполнить от 9 до 13 обращений к диску.
Теперь допустим, что те же миллион записей находятся в Б+дереве, узлы которого имеют примерно тот же размер в байтах. Поскольку в узлах Б+дерева содержатся только ключи, то в каждом узле дерева может храниться до 20 ключей к записям. В этом случае, каждый узел будет иметь от 11 до 21 дочерних узлов, поэтому высота дерева будет от log21(1.000.000) до log11(1.000.000), или между 5 и 6. Чтобы найти элемент, программе понадобится всего 6 обращений к диску для нахождения его ключа, и еще одно обращение к диску, чтобы считать сам элемент.
В Б+деревьях также просто связать с набором записей множество ключей. В системе, оперирующей записями о сотрудниках, одно Б+дерево может использовать в качестве ключей фамилии, а другое — идентификационные номера социального страхования. Оба дерева будут содержать указатели на записи данных, которые будут находиться за пределами деревьев.
В этом разделе описаны два метода улучшения производительности Б‑ и Б+деревьев. Первый метод позволяет перераспределить элементы между узлами одного уровня, чтобы избежать разбиения блоков. Второй позволяет помещать пустые ячейки в дерево, чтобы уменьшить вероятность необходимости разбиения блоков в будущем.
=======177
При добавлении элемента к блоку, который уже заполнен, блок разбивается на два. Этого можно избежать, если выполнить балансировку этого узла с одним из узлов на том же уровне. Например, вставка нового элемента Q в Б‑дерево, показанное слева на рис. 7.20 обычно вызывает разбиение блока. Этого можно избежать, выполнив балансировку узла, содержащего J, K, L и N и левого узла на том же уровне, содержащего B и E. При этом получается дерево, показанное на рис. 7.20 справа.
Такая балансировка имеет ряд преимуществ. Во‑первых, при этом блоки используются более эффективно. В них находится меньше пустых ячеек, при этом уменьшится количество расходуемой понапрасну памяти.
Что более важно, если не нужно будет разбиение блоков, то не понадобится и перемещение элемента в родительский узел. Это предотвращает возникновение длительной последовательности разбиений блоков.
С другой стороны, уменьшение числа неиспользуемых элементов в дереве увеличивает вероятность необходимости разбиения блоков в будущем. Так как в дереве остается меньше свободных ячеек, то более вероятно, что узел окажется уже полон, когда понадобится вставить новый элемент.
Предположим, что имеется небольшая база данных клиентов, содержащая 10 записей. Можно загружать записи в Б‑дерево так, чтобы они заполняли каждый блок целиком, как показано на рис. 7.21. При этом дерево содержит мало свободного пространства, и вставка нового элемента сразу же приводит к разбиению блоков. Фактически, так как все блоки заполнены, она вызовет последовательность разбиения блоков, которая дойдет до корневого узла.
Вместо плотного заполнения дерева, можно добавлять к каждому узлу некоторое количество пустых ячеек, как показано на рис. 7.22. Хотя при этом дерево будет несколько больше, в него можно будет добавлять элементы, не вызывая сразу же последовательность разбиений блоков. После работы с деревом в течение некоторого времени, количество свободного пространства может уменьшиться до такой степени, при которой разбиения блоков могут возникнуть. Тогда можно перестроить дерево, добавив больше свободного пространства.
В реальных приложениях Б‑деревья обычно имеют намного больший порядок, чем деревья, приведенные здесь. Добавление свободного пространства в дерево значительно уменьшает необходимость балансировки и разбиения блоков. Например, можно добавить в Б‑дерево 10 порядка 10 процентов свободного пространства, чтобы в каждом узле было место еще для двух элементов. С таким деревом можно будет работать достаточно долго, прежде чем возникнут длинные цепочки разбиений блоков.
Это очередной пример пространственно‑временного компромисса. Добавка в узлы пустого пространства увеличивает размер дерева, но уменьшает вероятность разбиения блоков.
@Рис. 7.20. Балансировка для устранения разбиения блоков
=======178
@Рис. 7.21. Плотное заполнение Б‑дерева
Б‑ и Б+деревья хорошо подходят для создания больших приложений баз данных. Типичное Б+дерево может содержать сотни, тысячи и даже миллионы записей. В этом случае в любой момент времени в памяти будет находиться только небольшая часть дерева и при каждом обращении к узлу, программе понадобится загрузить его с диска. В этом разделе описаны три момента, учитывать которые особенно важно, если данные находятся на диске: применение псевдоуказателей, выбор размера блоков, и кэширование корневого узла.
Коллекции и ссылки на объекты удобны для построения деревьев в памяти, но они могут быть бесполезны при хранении дерева на диске. Нельзя создать ссылку на запись в файле.
Вместо этого можно использовать методы работы с псевдоуказателями, похожие на те, которые были описаны во 2 главе. Вместо использования в качестве указателей на узлы дерева ссылок на объекты при этом используется номер записи узла в файле. Предположим, что Б+дерево 12 порядка использует 80‑байтные ключи. Структуру данных узла можно определить в следующем коде:
Global Const ORDER = 12
Global Const KEYS_PER_NODE = 2 * ORDER
Type BtreeNode
Key (1 To KEYS_PER_NODE) As String * 80 ' Ключи.
Child (0 To KEYS_PER_NODE) As Integer ' Указатели потомков.
End Type
Значения элементов массива Child представляют собой номера записей из дочерних узлов в файле. Произвольный доступ к данным Б+дерева из файла осуществляется при помощи записей, которые соответствуют структуре BtreeNode.
@Рис. 7.22. Свободное заполнение Б‑дерева
======179
Dim node As BtreeNode
Open Filename For Random As #filenum Len = Len(node)
После открытия файла, при помощи оператора Get можно выбрать любую запись:
Dim node As BtreeNode
' Выбрать запись с номером recnum.
Get #filenum, recnum, node
Чтобы упростить работу с Б+деревьями, можно хранить узлы Б+дерева и записи данных в разных файлах и использовать для управления каждым из них псевдоуказатели.
Когда счетчик ссылок на объект становится равным нулю, то Visual Basic автоматически уничтожает его. Это облегчает работу со структурами данных в памяти. С другой стороны, если программе больше не нужна какая‑либо запись в файле, то она не может просто очистить все ссылки на нее. Если сделать так, то программа больше не сможет использовать эту запись, но запись по‑прежнему будет занимать место в файле.
Программа должна следить за неиспользуемыми записями, чтобы позднее можно было использовать их. Один из простых способов сделать это — вести связный список неиспользуемых записей. Если запись больше не нужна, она добавляется в список. Если программе нужно место для новой записи, она удаляет одну запись из списка. Если программе нужно вставить еще один элемент, а список пуст, она увеличивает файл данных.
Чтение данных с диска происходит блоками, которые называются кластерами. Размер кластера обычно составляет 512 или 1024 байта, или еще какое‑либо число байтов, равное степени двойки. Чтение всего кластера занимает столько же времени, сколько и чтение одного байта.
Можно воспользоваться этим фактом и создавать блоки, размер которых составляет целое число кластеров, а затем уместить в этот размер максимальное число ключей или записей. Например, предположим, что мы решили создавать блоки размером 2048 байт. При создании Б+дерева с 80‑байтными ключами в каждый блок можно поместить 24 ключа и 25 указателей (если указатель представляет собой 4‑байтное число типа long). Затем можно создать Б+дерево 12 порядка с блоками, которые определяются в следующем коде:
Global Const ORDER = 12
Global Const KEYS_PER_NODE = 2 * ORDER
Type BtreeNode
Key(1 To KEYS_PER_NODE) As String * 80 ' Ключ данных.
Child(0 To KEYS_PER_NODE) As Integer ' Указатели потомков.
End Type
=======180
Для того, чтобы считывать данные максимально быстро, программа должна использовать оператор Visual Basic Get для чтения узла целиком. Если использовать цикл For для чтения ключей и данных для каждого элемента по очереди, то программе придется обращаться к диску при чтении каждого элемента. Это намного медленнее, чем считывание всего узла сразу. В одном из тестов, для массива из 1000 элементов определенного пользователем типа чтение элементов по одиночке заняло в 27 раз больше времени, чем чтение их всех сразу. Следующий код демонстрирует оба способа чтения данных из узла:
Dim i As Integer
Dim node As BtreeNode
' Медленный способ доступа к данным.
For i = 1 To KEYS_PER_NODE
Get #filenum, , node.Key(i)
Next i
' Быстрый способ доступа к данным.
Get #filenum, , node
Каждый поиск в Б‑дереве начинается с корневого узла. Можно ускорить поиск, если корневой узел будет все время находиться в памяти. Тогда во время поиска придется на один раз меньше обращаться к диску. При этом все равно необходимо записывать корневой узел на диск при каждом его изменении, иначе при повторной загрузке после отказа программы изменения в Б‑дереве будут потеряны.
Можно также кэшировать в памяти и другие узлы Б‑дерева. Если хранить в памяти все дочерние узлы корня, то их также не потребуется считывать с диска. Для Б‑дерева порядка K, корневой узел будет иметь от 1 до 2 * K ключей и поэтому у него будет от 2 до 2 * K + 1 дочерних узлов. Это значит, что в этом случае придется кэшировать до 2 * K + 1 узлов.
Программа также может кэшировать узлы при обходе Б‑дерева. Например, при прямом обходе программа обращается к каждому узлу и затем рекурсивно обходит все его дочерние узлы. При этом она вначале спускается к первому дочернему узлу, а после возврата переходит к следующему. При каждом возврате, программа должна снова обратиться к родительскому узлу, чтобы определить, к какому из дочерних узлов обращаться в следующую очередь. Кэшируя родительский узел в памяти, программа избегает необходимости снова считывать его с диска.
Применение рекурсии позволяет программе автоматически сохранять узлы в памяти без использования сложной схемы кэширования. При каждом вызове рекурсивного алгоритма обхода, определяется локальная переменная, в которой находится узел до тех пор, пока он не понадобится. При возврате из рекурсивного вызова Visual Basic автоматически освобождает эту переменную. Следующий код демонстрирует, как можно реализовать этот алгоритм обхода на языке Visual Basic.
=======181
Private Sub PreorderPrint(node_index As Integer)
Dim i As Integer
Dim node As BtreeNode
Get #filenum, node_index, node ' Кэшировать узел.
Print node_index ' Обращение к узлу.
For i = 0 To KEYS_PER_NODE
If node.Child(i) < 0 Then Exit For ' Вызов потомков.
PreorderPrint node.Child(i) ' Вызов потомка.
Next i
End Sub
Программа Bplus работает с базой данных на основе Б+дерева, используя два файла данных. Файл Custs.DAT содержит записи с данными о клиентах, а файл Custs.IDX — узлы Б+дерева.
Чтобы добавить новую запись в базу данных, введите данные в поле Customer Record (Запись о клиенте), и затем нажмите на кнопку Add. Для поиска записи заполните поля Last Name (Фамилия) и First Name (Имя) в верхней части формы и нажмите на кнопку Find (Найти).
На рис. 7.23 показано окно программы после выполнения поиска записи для Рода Стивенса. Статистика внизу показывает, что данные были найдены в записи номер 302 после всего лишь трех обращений к диску. Высота Б+дерева в программе равна 3, и оно содержит 1303 записей данных и 118 блоков.
Когда вы вводите запись или проводите поиск, программа Bplus выбирает эту запись из файла. После нажатия на кнопку Remove программа удаляет запись из базы данных.
@Рис. 7.23. Программа Bplus
========182
Если выбрать в меню Display (Показать) команду Internal Nodes (Внутренние узлы), то программа выведет список внутренних узлов дерева. Она также выводит рядом с каждым узлом ключи, чтобы показать внутреннюю структуру дерева.
При помощи команды Complete Tree (Все дерево) из меню Display можно вывести структуру дерева целиком. Данные о клиентах выводятся внутри пунктирных скобок.
Кроме обычных полей адреса и фамилии, программа Bplus также включает поле NextGarbage, которое программа использует для работы со связным списком неиспользуемых в файле записей.
Type CustRecord
LastName As String * 20
FirstName As String * 20
Address As String * 40
City As String * 20
State As String * 2
Zip As String * 10
Phone As String * 12
NextGarbage As Long
End Type
' Размер записи данных о клиенте.
Global Const CUST_SIZE = 20 + 20 + 40 + 20 + 2 + 10 + 12 + 4
Внутренние узлы Б+дерева содержат ключи, которые используются для поиска данных о клиенте. Ключом для записи является фамилия клиента, дополненная в конце пробелами до 20 символов и заканчивающаяся запятой, за которой следует имя клиента, дополненное пробелами до 20 символов. Например, "Washington..........,George..............". При этом полная длина ключа составляет 41 символ.
Каждый внутренний узел также содержит указатели на дочерние узлы. Эти указатели определяют положение записей с данными о клиенте в файле Custs.DAT. Узлы также включают переменную NumKeys, которая содержит число используемых ключей.
Программа читает и пишет данные блоками примерно по 1024 байта. Если предположить, что блок содержит K ключей, то в каждом блоке будет K ключей длиной 41 байт, K + 1 указателей на дочерние узлы длиной по 4 байта, и двухбайтное целое число NumKeys. При этом блоки должны иметь максимально возможный размер и быть не больше 1024 байт.
Решив уравнение 41 * K + 4 * (K + 1) + 2 <= 1.024, получим K <= 22,62, поэтому K должно быть равно 22. В этом случае Б+дерево должно иметь 11 порядок, поэтому оно содержит по 22 ключа в каждом блоке. Каждый блок занимает 41 * 22 + 4 * (22 + 1) + 2 = 996 байт. Следующий код демонстрирует определение блоков в программе Bplus.
=======183
Const KEY_SIZE = 41
Const ORDER = 11
Global Const KEYS_PER_NODE = 2 * ORDER
Type Bucket
NumKeys As Integer
Key(1 To KEYS_PER_NODE) As String * KEY_SIZE
Child(0 To KEYS_PER_NODE) As Long
End Type
Global Const BUCKET_SIZE = 2 + _
KEYS_PER_NODE * KEY_SIZE + _
(KEYS_PER_NODE + 1) * 4
Программа Bplus записывает блоки Б+дерева в файле Custs.IDX. Первая запись в этом файле содержит заголовок, который описывает текущее состояние Б+дерева. В заголовок входит указатель на корневой узел, текущая высота дерева, указатель на первый пустой блок в файле Custs.IDX, и указатель на первый пустой блок в файле Custs.DAT.
Чтобы упростить чтение и запись заголовка, можно определить еще одну структуру, которая имеет в точности такой же размер, что и блоки данных, но содержит поля заголовка. Последнее поле в определении — это строка, которая заполняет конец структуры, чтобы ее размер был точно равен размеру блока.
Global Const HEADER_PADDING = _
BUCKET_SIZE - (7 * 4 + 2)
Type HeaderRecord
NumBuckets As Long
NumRecords As Long
Root As Long
NextTreeRecord As Long
NextCustRecord As Long
FirstTreeGarbage As Long
FirstCustGarbage As Long
Height As Integer
Padding As String * HEADER_PADDING
End Type
При запуске программы она запрашивает директорию, в которой находятся данные, и затем открывает файлы Custs.DAT файлы Custs.IDX в этой директории. Если эти файлы не существуют, то программа их создает. В противном случае, она считывает заголовок с информацией о дереве из файла Custs.IDX. Затем она считывает корневой узел Б+дерева и кэширует его в памяти.
Спускаясь по дереву при вставке или удалении элемента, программа кэширует элементы, к которым она обращается. При рекурсивном возврате эти узлы могут понадобиться снова, если происходило разбиение, слияние или другое переупорядочение узлов. Так как программа кэширует узлы на пути сверху вниз, они будут доступны при возвращении обратно.
Увеличение размера блоков позволяет сделать Б+деревья более эффективными, но при этом тестировать их вручную будет сложнее. Чтобы высота Б+дерева 11 порядка стала равна 2, необходимо добавить к базе данных 23 элемента. Чтобы увеличить высоту дерева до 3 уровня, необходимо добавить более 250 дополнительных элементов.
=======184
Чтобы было проще тестировать программу Bplus, вы можете захотеть уменьшить порядок Б+дерева до 2. Для этого закомментируйте в файле Bplus.BAS строку, которая определяет 11 порядок, и уберите комментарий из строки, которая задает 2 порядок:
'Const ORDER = 11
Const ORDER = 2
Команда Create Data (Создать данные) в меню Data (Данные) позволяет быстро создать множество записей данных. Введите число записей, которые вы хотите создать, и число, которое программа должна использовать для создания первого элемента. Затем программа создаст записи и вставит их в Б+дерево. Например, если задать в программе создание 100 записей, начиная со значения 200, то программа создаст записи 200, 201, … 299, которые будут выглядеть так:
FirstName: First 0000200
LastName: Last 0000200
Address: Addr 0000200
Cuty: City 0000200
Применение сбалансированных деревьев в программе позволяет эффективно работать с данными. Для записи больших баз данных на дисках или других относительно медленных запоминающих устройствах особенно удобны Б+деревья высокого порядка. Более того, можно использовать несколько Б+деревьев для создания нескольких индексов одного и того же большого набора данных.
В главе 11 описана альтернатива сбалансированным деревьям. Хеширование в некоторых случаях позволяет добиться еще более быстрого доступа к данным, хотя оно и не позволяет выполнять такие операции, как последовательный вывод записей.
========185
Глава 8. Деревья решенийМногие сложные реальные задачи можно смоделировать при помощи деревьев решений (decision trees). Каждый узел дерева представляет один шаг решения задачи. Каждая ветвь в дереве представляет решение, которое ведет к более полному решению. Листья представляют собой окончательное решение. Цель заключается в том, чтобы найти «наилучший» путь от корня к листу при выполнении определенных условий. Эти условия и значение понятия «наилучший» для пути зависит от задачи.
Деревья решений обычно имеют громадный размер. Дерево решений для игры в крестики‑нолики содержит более полумиллиона узлов. Эта игра довольно проста, и многие реальные задачи намного более сложны. Соответствующие им деревья решений могли бы содержать больше узлов, чем число атомов во вселенной.
В этой главе обсуждаются методы, которые можно использовать для поиска в таких огромных деревьях. Во‑первых, в ней вначале рассматриваются деревья игры (game trees). На примере игры в крестики‑нолики обсуждаются способы поиска в деревьях игры для нахождения наилучшего возможного хода.
В следующих разделах описываются способы поиска в более общих деревьях решений. Для самых маленьких деревьев, можно использовать метод полного перебора (exhaustive searching) всех возможных решений. Для деревьев большего размера, можно использовать метод ветвей и границ (branch‑and‑bound technique) позволяет найти наилучшее решение без необходимости выполнять поиск по всему дереву.
Для очень больших деревьев нужно использовать эвристический метод или эвристику (heuristic). При этом полученное решение может быть не наилучшим из возможных решений, но оно, тем не менее, лежит достаточно близко к наилучшему, чтобы его можно было использовать. Используя эвристики, можно проводить поиск практически в любых деревьях решений.
В конце этой главы обсуждаются некоторые очень сложные задачи, которые вы можете попытаться решить при помощи метода ветвей и границ или эвристического метода. Многие из этих задач имеют важные применения, и нахождение хороших решений для них крайне необходимо.
Стратегию настольных игр, таких как шахматы, шашки, или крестики‑нолики можно смоделировать при помощи деревьев игры. Если в какой то момент игры существует 30 возможных ходов, то соответствующий узел в дереве игры будет иметь 30 ветвей.
========187
Например, для игры в крестики‑нолики корневой узел соответствует начальной позиции, при которой доска пуста. Первый игрок может поместить крестик в любую из девяти клеток доски. Каждому из этих девяти возможных ходов соответствует выходящая из корня ветвь. Девять узлов на конце эти ветвей соответствуют девяти различным позициям после первого хода игрока.
После того, как первый игрок сделал ход, второй может поставить нолик в любую из оставшихся восьми клеток. Каждому из этих ходов соответствует ветвь, выходящая из узла, соответствующего текущей позиции игры. На рис. 8.1 показан небольшой фрагмент дерева игры в крестики‑нолики.
Как можно увидеть на рис. 8.1, дерево игры в крестики‑нолики растет очень быстро. Если оно продолжит расти таким образом, так что каждый следующий узел в дереве будет иметь на одну ветвь меньше, чем его родитель, то дерево целиком будет иметь 9 * 8 * 7 … * 1 = 362.880 листьев. В дереве будет 362.880 возможных путей, соответствующих 362.800 возможным играм.
В действительности многие из узлов дерева будут отсутствовать, так как соответствующие им ходы запрещены правилами игры. Если игрок, ходивший первым, за три своих хода поставит крестики в верхней левой, верхней средней и верхней правой клетках, то он выиграет и игра закончится. Узел, соответствующий этой позиции, не будет иметь потомков, так как игра завершается на этом шаге. Эта игра показана на рис. 8.2.
После удаления всех невозможных узлов в дереве остается около четверти миллиона листьев. Это все еще очень большое дерево, и поиск его методом полного перебора занимает достаточно много времени. Для более сложных игр, таких как шашки, шахматы или го, деревья игры имеют огромный размер. Если бы во время каждого хода в шахматах игрок имел 16 возможных вариантов, то дерево игры имело бы более триллиона узлов после пяти ходов каждого из игроков. В конце этой главы обсуждается поиск в таких огромных деревьях игры, а следующий раздел посвящен более простому примеру игры в крестики‑нолики.
@Рис. 8.1. Фрагмент дерева игры в крестики‑нолики
========188
@Рис. 8.2. Быстрое окончание игры
Для выполнения поиска в дереве игры, нужно иметь возможность определить вес позиции на доске. Для игры в крестики‑нолики, для первого игрока больший вес имеют позиции, в которых три крестика расположены в ряд, так как при этом первый игрок выигрывает. Вес тех же позиций для второго игрока мал, потому, что в этом случае он проигрывает.
Для каждого игрока, можно присвоить позиции один из четырех весов. Если вес равен 4, то это значит, что игрок в этой позиции выигрывает. Если вес равен 3, то из текущего положения на доске неясно, кто из игроков выиграет в конце концов. Вес, равный 2, означает, что позиция приводит к ничьей. И, наконец, вес, равный 1, означает, что выигрывает противник.
Для поиска дерева методом полного перебора можно использовать минимаксную (minimax) стратегию, при которой делается попытка минимизировать максимальный вес, который может иметь позиция для противника после следующего хода. Это можно сделать, определив максимально возможный вес позиции для противника после каждого из своих возможных ходов, и затем выбрав ход, который дает позицию с минимальным весом для противника.
Подпрограмма BoardValue, приведенная ниже, вычисляет вес позиции на доске, проверяя все возможные ходы. Для каждого хода она рекурсивно вызывает себя, чтобы найти вес, который будет иметь новая позиция для противника. Затем она выбирает ход, при котором вес полученной позиции для противника будет наименьшим.
Для определения веса позиции на доске процедура BoardValue рекурсивно вызывает себя до тех пор, пока не произойдет одно из трех событий. Во‑первых, она может дойти до позиции, в которой игрок выигрывает. В этом случае, она присваивает позиции вес 4, что указывает на выигрыш игрока, совершившего последний ход.
======189
Во‑вторых, процедура BoardValue может найти позицию, в которой ни один из игроков не может совершить следующий ход. Игра при этом заканчивается ничьей, поэтому процедура присваивает этой позиции вес 2.
И наконец, процедура может достигнуть заданной максимальной глубины рекурсии. В этом случае, процедура BoardValue присваивает позиции вес 3, что указывает, что она не может определить победителя. Задание максимальной глубины рекурсии ограничивает время поиска в дереве игры. Это особенно важно для более сложных игр, таких как шахматы, в которых поиск в дереве игры может продолжаться практически вечно. Максимальная глубина поиска также может задавать уровень мастерства программы. Чем дальше вперед программа сможет анализировать ходы, тем лучше она будет играть.
На рис. 8.3 показано дерево игры в крестики‑нолики в конце партии. Ходит игрок, играющий крестиками, и у него есть три возможных хода. Чтобы выбрать наилучший ход, процедура BoardValue рекурсивно проверяет каждый из трех возможных ходов. Первый и третий возможные ходы (левая и правая ветви дерева) приводят к выигрышу противника, поэтому их вес для противника равен 4. Второй возможный ход приводит к ничьей, и его вес для противника равен 2. Процедура BoardValue выбирает этот ход, так как он имеет наименьший вес для противника.
@Рис. 8.3. Нижняя часть дерева игры
Private Sub BoardValue(best_move As Integer, best_value As Integer, pl1 As Integer, pl2 As Integer, Depth As Integer)
Dim pl As Integer
Dim i As Integer
Dim good_i As Integer
Dim good_value As Integer
Dim enemy_i As Integer
Dim enemy_value As Integer
DoEvents ' Не занимать 100% процессорного времени.
' Если глубина рекурсии слишком велика, результат неизвестен.
If Depth >= SkillLevel Then
best_value = VALUE_UNKNOWN
Exit Sub
End If
' Если игра завершается, то результат известен.
pl = Winner()
If pl <> PLAYER_NONE Then
' Преобразовать вес для победителя pl в вес для игрока pl1.
If pl = pl1 Then
best_value = VALUE_WIN
ElseIf pl = pl2 Then
best_value = VALUE_LOSE
Else
best_value = VALUE_DRAW
End If
Exit Sub
End If
' Проверить все допустимые ходы.
good_i = -1
good_value = VALUE_HIGH
For i = 1 To NUM_SQUARES
' Проверить ход, если он разрешен правилами.
If Board(i) = PLAYER_NONE Then
' Найти вес полученного положения для противника.
If ShowTrials Then _
MoveLabel.Caption = _
MoveLabel.Caption & Format$(i)
' Сделать ход.
Board(i) = pl1
BoardValue enemy_i, enemy_value, pl2, pl1, Depth + 1
' Отменить ход.
Board(i) = PLAYER_NONE
If ShowTrials Then _
MoveLabel.Caption = _
Left$(MoveLabel.Caption, Depth)
' Меньше ли этот вес, чем предыдущий.
If enemy_value < good_value Then
good_i = i
good_value = enemy_value
' Если мы выигрываем, то лучшего решения нет,
' поэтому выбирается этот ход.
If good_value <= VALUE_LOSE Then Exit For
End If
End If ' End if Board(i) = PLAYER_NONE ...
Next i
' Преобразовать вес позиции для противника в вес для игрока.
If good_value = VALUE_WIN Then
' Противник выигрывает, мы проиграли.
best_value = VALUE_LOSE
ElseIf enemy_value = VALUE_LOSE Then
' Противник проиграл, мы выиграли.
best_value = VALUE_WIN
Else
' Вес ничьей или неопределенной позиции
' одинаков для обоих игроков.
best_value = good_value
End If
best_move = good_i
End Sub
Программа TicTac использует процедуру BoardValue. Основная часть кода программы обеспечивает взаимодействие с пользователем, рисует доску, позволяет пользователю выбрать ход, задавать опции и так далее.
Если не выбрана команда Show Test Moves (Показывать проверяемые ходы) из меню Options (Опции), то производительность программы будет намного выше. Если выбрана эта опция, то программа выводит каждый анализируемый ход. Постоянное обновление экрана занимает намного больше времени, чем действительный поиск в дереве.
Другие команды в меню Options позволяют вам, выбрать уровень мастерства программы (максимальную глубину рекурсии) и выбрать игру крестиками или ноликами. При высоком уровне мастерства первый ход занимает намного больше времени.
=====192
Подпрограмма BoardValue имеет интересный побочный эффект. Если она находит два одинаково хороших хода, то она выбирает из них первый попавшийся. Иногда это приводит к странному поведению программы. Например, если программа определяет, что при любом своем ходе она проигрывает, то она выбирает первый из них. Иногда этот ход может показаться человеку глупым. Может создаться впечатление, что компьютер выбрал случайный ход и сдается. В какой то степени это действительно так.
Например, запустим программу TicTac с третьим уровнем мастерства. Перенумеруем клетки так, как показано на рис. 8.4. Сделаем первых ход в клетку 6. Программа выберет клетку 1. Выберем клетку 3, программа ответит ходом на клетку 9. Теперь, если занять клетку 5, то наступает выигрыш, если следующим ходом пойти на клетку 4 или 7.
Компьютер теперь может просмотреть дерево игры до конца и убедиться в своем проигрыше. В такой ситуации человек попытался бы заблокировать один из выигрышных ходов, либо поместить два нолика в ряд, чтобы попытаться выиграть на следующем ходу. В более сложной игре, такой как шахматы, человек также может выбрать одну из этих стратегий, в надежде на то, что соперник не увидит пути к победе. Соперник может ошибиться, давая игроку тем самым шанс на победу.
Программа же считает, что противник играет безошибочно и также знает о своем выигрыше. Так как ни один ход не приводит к победе, то программа выбирает первый попавшийся ход, в данном случае занимает клетку 2. Этот ход кажется глупым, так как он не блокирует ни одного из возможных выигрышных ходов, и не делает попытку выиграть на следующем ходу. При этом кажется, что компьютер сдается. Эта игра показана на рис. 8.5.
Один из способов предотвращения такого поведения состоит в том, чтобы задать больше различных весов позиций. В программе TicTac все проигрышные позиции имеют одинаковый вес. Можно присвоить позиции, в которой проигрыш происходит за два хода, больший вес, чем позиции, в которой проигрыш наступает на следующем ходу. Тогда программа сможет выбирать ходы, которые приведут к затягиванию игры. Также можно присваивать больший вес позиции, в которой имеются два возможных выигрышных хода, чем позиции, в которой есть только один выигрышный ход. В таком случае компьютер попытался бы заблокировать один из возможных выигрышных ходов.
Если бы для поиска в дереве игры мы располагали только минимаксной стратегией, то выполнить поиск в больших деревьях было бы очень сложно. Такие игры, как шахматы, настолько сложны, что программа может провести поиск всего лишь на нескольких уровнях дерева. К счастью, существуют несколько приемов, которые можно использовать для поиска в больших деревьях игры.
@Рис. 8.4. Нумерация клеток доски игры в крестики‑нолики
======193
@Рис. 8.5. Программа игры в крестики‑нолики сдается
Во‑первых, в программе могут быть записаны начальные ходы, выбранные экспертами. Можно решить, что программа игры в крестики‑нолики должна делать первый ход в центральную клетку. Это определяет первую ветвь дерева игры, поэтому программа может игнорировать все пути, не проходящие через первую ветвь. Это уменьшает дерево игры в крестики‑нолики в 9 раз.
Фактически, программе не нужно выполнять поиск в дереве до того, пока противник не сделает свой ход. В этот момент и компьютер и противник выбрали каждый свою ветвь, поэтому оставшееся дерево станет намного меньше, и будет содержать менее чем 7! = 5040 путей. Просчитав заранее всего один ход, можно уменьшить размер дерева игры от четверти миллиона до менее чем 5040 путей.
Аналогично, можно записать ответы на первые ходы, если противник ходит первым. Есть девять вариантов первого хода, следовательно, нужно записать девять ответных ходов. При этом программе не нужно поводить поиск по дереву, пока противник не сделает два хода, а компьютер — один. Тогда дерево игры будет содержать менее чем 6! = 720 путей. Записано всего девять ходов, а размер дерева при этом уменьшается очень сильно. Это еще один пример пространственно‑временного компромисса. Использование большего количества памяти уменьшает время, необходимое для поиска в дереве игры.
Программа TicTac2 использует 10 записанных ходов. Задайте 9 уровень мастерства, и пусть программа делает первый ход. Затем задайте те же опции в программе TicTac. Вы увидите громадную разницу в скорости работы этих двух программ.
Коммерческие программы игры в шахматы также начинают с записанных ходов и ответов, рекомендованных гроссмейстерами. Такие программы могут делать первые ходы очень быстро. После того, как программа исчерпает все записанные заранее ходы, она начнет делать ходы намного медленнее.
Другой способ улучшения поиска в дереве игры состоит в том, чтобы определять важные позиции. Если программа распознает одну из этих позиций, она может выполнить определенные действия или изменить способ поиска в дереве игры.
========194
Во время игры в шахматы игроки часто располагают фигура так, чтобы они защищали другие фигуры. Если противник берет фигуру, то игрок берет фигуру противника взамен. Часто такое взятие позволяет противнику в свою очередь взять другую фигуру, что приводит к серии обменов.
Некоторые программы находят возможные последовательностей обменов. Если программа распознает возможность обмена, она на время изменяет максимальную глубину, на которую она просматривает дерево, чтобы проследить до конца цепочку обменов. Это позволяет программе решить, стоит ли идти на обмен. После обмена фигур их количество также уменьшается, поэтому поиск в дереве игры становится в будущем более простым.
Некоторые шахматные программы также отслеживают рокировки, ходы, при которых под боем оказывается сразу несколько фигур, шах или нападение на ферзя и так далее.
В играх, более сложных, чем крестики‑нолики, практически невозможно провести поиск даже в небольшом фрагменте дерева игры. В этих случаях, можно использовать различные эвристики. Эвристикой называет алгоритм или эмпирическое правило, которое вероятно, но не обязательно даст хороший результат.
Например, в шахматах обычной эвристикой является «усиление преимущества». Если у противника меньше сильных фигур и одинаковое число остальных, то следует идти на размен при каждой возможности. Например, если вы берете коня противника, теряя при этом своего, то такой обмен следует выполнить. Уменьшение числа оставшихся фигур делает дерево решений короче и может увеличить относительное преимущество. Эта стратегия не гарантирует выигрыша, но повышает его вероятность.
Другая часто используемая эвристика заключается в присвоении разных весов различным частям доски. В шахматах вес клеток в центре доски выше, так как фигуры, находящиеся на этих позициях, могут атаковать большую часть доски. Когда процедура BoardValue вычисляет вес текущей позиции на доске, она может присваивать больший вес фигурам, которые занимают клетки в центре доски.
Некоторые методы поиска в деревьях игры неприменимы к обобщенным деревьям решений. Многие их этих деревьев не включают поочередных ходов игроков, поэтому минимаксный метод и вычисленные заранее ходы в данном случае бессмысленны. В следующих разделах описаны методы, которые можно использовать для поиска в этих типах деревьев решений.
=======195
Метод ветвей и границ (branch and bound) является одним из методов отсечения (pruning) ветвей в дереве решений, чтобы не было необходимо рассматривать все ветви дерева. Общий подход при этом состоит в том, чтобы отслеживать границы уже обнаруженных и возможных решений. Если в какой‑то точке наилучшее из уже найденных решений лучше, чем наилучшее возможное решение в нижних ветвях, то можно игнорировать все пути вниз от узла.
Например, допустим, что имеет 100 миллионов долларов, которые нужно вложить в несколько возможных инвестиций. Каждое из вложений имеет разную стоимость и дает разную прибыль. Необходимо решить, как вложить деньги наилучшим образом, чтобы суммарная прибыль была максимальной.
Задачи такого типа называются задачей формирования портфеля[RV14] (knapsack problem). Имеется несколько позиций (инвестиций), которые должны поместиться в портфель фиксированного размера (100 миллионов долларов). Каждая из позиций имеет стоимость (деньги) и цену (тоже деньги). Необходимо найти набор позиций, который помещается в портфель и имеет максимально возможную цену.
Эту задачу можно смоделировать при помощи дерева решений. Каждый узел дерева соответствует определенной комбинации позиций в портфеле. Каждая ветвь соответствует принятию решения о том, чтобы удалить позицию из портфеля или добавить ее в него. Левая ветвь первого узла соответствует первому вложению. На рис. 8.6 показано дерево решений для четырех возможных инвестиций.
Дерево решений для этой задачи представляет собой полное двоичное дерево, глубина которого равна числу инвестиций. Каждый лист соответствует полному набору инвестиций.
Размер этого дерева очень быстро растет с увеличением числа инвестиций. Для 10 возможных инвестиций, в дереве будет находиться 210 = 1024 листа. Для 20 инвестиций, в дереве будет уже более миллиона листьев. Можно провести полный поиск по такому дереву, но при дальнейшем увеличении числа возможных инвестиций размер дерева станет очень большим.
@Рис. 8.6. Дерево решений для инвестиций
=======196
Чтобы использовать метод ветвей и границ, создадим массив, который будет содержать позиции из наилучшего найденного до сих пор решения. При инициализации массив должен быть пуст. Можно также использовать переменную для отслеживания цены этого решения. Вначале эта переменная может иметь небольшое значение, чтобы первое же найденное реальное решение было лучше исходного.
При поиске в дереве решений, если в какой‑то точке анализируемое решение не может быть лучше, чем существующее, то можно прекратить дальнейший поиск по этому пути. Также, если в какой‑то точке выбранные позиции стоят более 100 миллионов, то можно также прекратить поиск.
В качестве конкретного примера, предположим, что имеются инвестиции, приведенные в табл. 8.1. На рис. 8.6 показано соответствующее дерево решений. Некоторые из этих инвестиционных пакетов нарушают граничные условия задачи. Например, самый левый путь привел бы к вложению 178 миллионов долларов во все четыре возможных инвестиции.
Предположим, что мы начали поиск в дереве, изображенном на рис. 8.6 и обнаружили, что можно потратить 97 миллионов долларов на позиции A и B, получив 23 миллиона прибыли. Это соответствует четвертому листу слева на рис. 8.6.
При продолжении поиска в дереве, можно дойти до второго слева узла B на рис. 8.6. [RV15] Это соответствует инвестиционному пакету, который включает позицию A, не включает позицию B, и может включать или не включать позиции C и D. В этой точке пакет уже стоит 45 миллионов долларов за счет позиции A, и приносит 10 миллионов прибыли.
Оставшиеся позиции C и D вместе взятые могут повысить прибыль еще на 12 миллионов. Текущее решение приносит 10 миллионов прибыли, поэтому наилучшее возможное решение ниже этого узла принесет не больше 11 миллионов прибыли. Это меньше, чем доход в 23 миллиона для уже найденного решения, поэтому нет смысла продолжать поиск вниз по этому пути.
По мере продвижения программы по дереву ей не нужно постоянно проверять, будет ли частичное решение, которое она рассматривает, лучше, чем наилучшее найденное до сих пор решение. Если частичное решение лучше, то лучше будет и самый правый узел внизу от этого частичного решения. Этот узел представляет тот же самый набор позиций, как и частичное решение, так как все остальные позиции при этом исключены. Это означает, что программе необходимо искать лучшее решение только тогда, когда она достигает листа.
@Таблица 8.1. Возможные инвестиции
======197
Фактически, любой лист, до которого доходит программа всегда является более хорошим решением. Если бы это было не так, то ветвь, на котором находится этот лист, была бы отсечена, когда программа рассматривала родительский узел. В этой точке перемещение к листу уменьшит цену невыбранных позиций до нуля. Если цена решения не больше, чем наилучшее найденное до сих пор решение, то проверка нижней границы остановит продвижение программы к листу. Используя этот факт, программа может обновлять наилучшее решение при достижении листа.
Следующий код использует проверку верхней и нижней границы для реализации алгоритма ветвей и границ:
' Полная нераспределенная прибыль.
Private unassigned_profit As Integer
Public NumItems As Integer
Public MaxItem As Integer
Global Const OPTION_EXHAUSTIVE_SEARCH = 0
Global Const OPTION_BRANCH_AND_BOUND = 1
Type Item
Cost As Integer
Profit As Integer
End Type
Global Items() As Item
Global NodesVisited As Long
Global ToSpend As Integer
Global best_cost As Integer
Global best_profit As Integer
' Равно True для позиций в текущем наилучшем решении.
Public best_solution() As Boolean
' Решение, которое мы проверяем.
Private test_solution() As Boolean
Private test_cost As Integer
Private test_profit As Integer
' Инициализация переменных и начало поиска.
Public Sub Search(search_type As Integer)
Dim i As Integer
' Задание размера массивов решения.
ReDim best_solution(0 To MaxItem)
ReDim test_solution(0 To MaxItem)
' Инициализация - пустой список инвестиций.
NodesVisited = 0
best_profit = 0
best_cost = 0
unassigned_profit = 0
For i = 0 To MaxItem
unassigned_profit = unassigned_profit + Items(i).Profit
Next i
test_profit = 0
test_cost = 0
' Начнем поиск с первой позиции.
BranchAndBound 0
End Sub
' Выполнить поиск методом ветвей и границ начиная с этой позиции.
Public Sub BranchAndBound(item_num As Integer)
Dim i As Integer
NodesVisited = NodesVisited + 1
' Если это лист, то это лучшее решение, чем
' то, которое мы имели раньше, иначе он был бы
' отсечен во время поиска раньше.
If item_num > MaxItem Then
For i = 0 To MaxItem
best_solution(i) = test_solution(i)
best_profit = test_profit
best_cost = test_cost
Next i
Exit Sub
End If
' Иначе перейти по ветви вниз по ветвям потомка.
' Вначале попытаться добавить эту позицию. Убедиться,
' что она не превышает ограничение по цене.
If test_cost + Items(item_num).Cost <= ToSpend Then
' Добавить позицию к тестовому решению.
test_solution(item_num) = True
test_cost = test_cost + Items(item_num).Cost
test_profit = test_profit + Items(item_num).Profit
unassigned_profit = unassigned_profit - Items(item_num).Profit
' Рекурсивная проверка возможного результата.
BranchAndBound item_num + 1
' Удалить позицию из тестового решения.
test_solution(item_num) = False
test_cost = test_cost - Items(item_num).Cost
test_profit = test_profit - Items(item_num).Profit
unassigned_profit = unassigned_profit + Items(item_num).Profit
End If
' Попытаться исключить позицию. Выяснить, принесут ли
' оставшиеся позиции достаточный доход, чтобы
' путь вниз по этой ветви превысил нижний предел.
unassigned_profit = unassigned_profit - Items(item_num).Profit
If test_profit + unassigned_profit > best_profit Then BranchAndBound item_num + 1
unassigned_profit = unassigned_profit + Items(item_num).Profit
End Sub
Программа BandB использует метод полного перебора и метод ветвей и границ для решения задачи о формировании портфеля. Введите максимальную и минимальную стоимость и цену, которые вы хотите присвоить позициям, а также число позиций, которое требуется создать. Затем нажмите на кнопку Randomize (Рандомизировать), чтобы создать список позиций.
Затем при помощи переключателя внизу формы выберите либо Exhaustive Search (Полный перебор), либо Branch and Bound (Метод ветвей и границ). Когда вы нажмете на кнопку Go (Начать), то программа найдет наилучшее решение при помощи выбранного метода. Затем она выведет на экран это решение, а также число узлов в полном дереве решений и число узлов, которые программа в действительности проверила. На рис. 8.7 показано окно программы BindB после решения задачи портфеля для 20 позиций. Перед тем, как выполнить полный перебор для 20 позиций, попробуйте вначале запустить примеры меньшего размера. На компьютере с процессором Pentium с тактовой частотой 90 МГц поиск решения задачи портфеля для 20 позиций методом полного перебора занял более 30 секунд.
При поиске методом ветвей и границ число проверяемых узлов намного меньше, чем при полном переборе. Дерево решений для задачи портфеля с 20 позициями содержит 2.097.151 узел. При полном переборе придется проверить их все, при поиске методом ветвей и границ понадобится проверить только примерно 1.500 из них.
@Рис. 8.7. Программа BindB
======200
Число узлов, которые проверяет программа при использовании метода ветвей и границ, зависит от точных значений данных. Если цена позиций высока, то в правильное решение будет входить немного элементов. После помещения нескольких позиций в пробное решение, оставшиеся позиции слишком дорого стоят, чтобы поместиться в портфеле, потому большая часть дерева будет отсечена.
С другой стороны, если элементы имеют низкую стоимость, то в правильное решение войдет большое их число, поэтому программе придется исследовать множество комбинаций. В табл. 8.2 приведено число узлов, проверенное программой BindB в серии тестов при различной стоимости позиций. Программа создавала 20 случайных позиций, и полная стоимость решения была равна 100.
Иногда даже алгоритм ветвей и границ не может провести полный поиск в дереве. Дерево решений для задачи портфеля с 65 позициями содержит более 7 * 1019 узлов. Если алгоритм ветвей и границ проверяет только одну десятую процента этих узлов, и если компьютер проверяет миллион узлов в секунду, то для решения этой задачи потребовалось бы более 2 миллионов лет. В задачах, для которых алгоритм ветвей и границ выполняется слишком медленно, можно использовать эвристический подход.
Если качество решения не так важно, то приемлемым может быть результат, полученный при помощи эвристики. В некоторых случаях точность входных данных может быть недостаточной. Тогда хорошее эвристическое решение может быть таким же правильным, как и теоретически «наилучшее» решение.
В предыдущем примере метод ветвей и границ использовался для выбора инвестиционных возможностей. Тем не менее, вложения могут быть рискованными, и точные результаты часто заранее неизвестны. Может быть, что заранее будет неизвестен точный доход или даже стоимость некоторых инвестиций. В этом случае, эффективное эвристическое решение может быть таким же надежным, как и наилучшее решение, которое вы может вычислить точно.
@Таблица 8.2. Число узлов, проверенных при поиске методами полного перебора и ветвей и границ
=======201
В этом разделе обсуждаются эвристики, которые полезны при решении многих сложных задач. Программа Heur демонстрирует каждую из эвристик. Она также позволяет сравнить результаты, полученные при помощи эвристик и методов полного перебора и ветвей и границ. Введите значения минимальной и максимальной стоимости и дохода, а также число позиций и полную стоимость портфеля в соответствующих полях области Parameters (Параметры), чтобы задать параметры создаваемых данных. Затем выберите алгоритмы, которые вы хотите протестировать, и нажмите на кнопку Go. Программа выведет полную стоимость и доход для наилучшего решения, найденного при помощи каждого из алгоритмов. Она также сортирует решения по максимальному полученному доходу и выводит время выполнения для каждого из алгоритмов. Используйте метод ветвей и границ только для небольших задач, а метод полного перебора только для задач еще меньшего объема.
На рис. 8.8 показано окно программы Heur после решения задачи формирования портфеля для 20 позиций. Эвристики Fixed1, Fixed2 и No Changes 1, которые будут вскоре описаны, дали наилучшие эвристические решения. Заметьте, что эти решения немного хуже, чем точные решения, которые получены при использовании метода ветвей и границ.
Эвристика восхождения на холм (hill‑climbing) вносит изменения в текущее решение, чтобы максимально приблизить его к цели. Этот процесс называется восхождением на холм, так как он похож на то, как заблудившийся путешественник пытается ночью добраться до вершины горы. Даже если уже слишком темно, чтобы еще можно было разглядеть что‑то вдали, путешественник может попытаться добраться до вершины горы, постоянно двигаясь вверх.
Конечно, существует вероятность, что путешественник застрянет на вершине меньшего холма и не доберется до пика. Эта проблема всегда может возникать при использовании этой эвристики. Алгоритм может найти решение, которое может оказаться локально приемлемым, но это не обязательно наилучшее возможное решение.
В задаче о формировании портфеля, цель заключается в том, чтобы подобрать набор позиций, полная стоимость которых не превышает заданного предела, а общая цена максимальна. На каждом шаге эвристика восхождения на холм будет выбирать позицию, которая приносит наибольшую прибыль. При этом решение будет все лучше соответствовать цели — получению максимальной прибыли.
@Рис. 8.8. Программа Heur
========202
Вначале программа добавляет к решению позицию с максимальной прибылью. Затем она добавляет следующую позицию с максимальной прибылью, если при этом полная цена еще остается в допустимых пределах. Она продолжает добавлять позиции с максимальной прибылью до тех пор, пока не останется позиций, удовлетворяющих условиям.
Для списка инвестиций из табл. 8.3, программа вначале выбирает позицию A, так как она дает максимальную прибыль — 9 миллионов долларов. Затем программа выбирает следующую позицию C, которая дает прибыль 8 миллионов. В этот момент потрачены уже 93 миллиона из 100, и программа не может приобрести больше позиций. Решение, полученное при помощи эвристики, включает позиции A и C, имеет стоимость 93 миллиона, и приносит 17 миллионов прибыли.
@Таблица 8.3. Возможные инвестиции
Эвристика восхождения на холм заполняет портфель очень быстро. Если позиции изначально были отсортированы в порядке убывания приносимой прибыли, то сложность этого алгоритма порядка O(N). Программа просто перемещается по списку, добавляя каждую позицию, если под нее есть место. Даже если список не упорядочен, то это алгоритм со сложностью порядка O(N2). Это намного лучше, чем O(2N) шагов, которые требуются для полного перебора всех узлов в дереве. Для 20 позиций эта эвристика требует всего около 400 шагов, метод ветвей и границ — несколько тысяч, а полный перебор — более чем 2 миллиона.
Public Sub HillClimbing()
Dim i As Integer
Dim j As Integer
Dim big_value As Integer
Dim big_j As Integer
' Многократный обход списка и поиск следующей
' позиции, приносящей наибольшую прибыль,
' стоимость которой не превышает верхней границы.
For i = 1 To NumItems
big_value = 0
big_j = -1
For j = 1 To NumItems
' Проверить, не находится ли он уже
' в решении.
If (Not test_solution(j)) And _
(test_cost + Items(j).Cost <= ToSpend) And _
(big_value < Items(j).Profit)
Then
big_value = Items(j).Profit
big_j = j
End If
Next j
' Остановиться, если не найдена позиция,
' удовлетворяющая условиям.
If big_j < 0 Then Exit For
test_cost = test_cost + Items(big_j).Cost
test_solution(big_j) = True
test_profit = test_profit + Items(big_j).Profit
Next i
End Sub
Стратегия, которая в каком‑то смысле противоположна стратегии восхождения на холм, называется стратегией наименьшей стоимости (least‑cost). Вместо того чтобы на каждом шаге пытаться максимально приблизить решение к цели, можно попытаться уменьшить стоимость решения, насколько это возможно. В примере с формированием портфеля, на каждом шаге к решению добавляется позиция с минимальной стоимостью.
Эта стратегия пытается поместить в решение максимально возможное число позиций. Это будет неплохим решением, если все позиции имеют примерно одинаковую стоимость. Если дорогая позиция приносит большую прибыль, то эта стратегия может упустить эту возможность, давая не лучший из возможных результатов.
Для инвестиций, показанных в табл. 8.3, алгоритм наименьшей стоимости начинает с добавления к решению позиции E со стоимостью 23 миллиона долларов. Затем он выбирает позицию D, стоящую 27 миллионов, и затем позицию C со стоимостью 30 миллионов. В этой точке алгоритм уже потратил 80 миллионов из 100 возможных, поэтому больше он не может выбрать ни одной позиции.
Это решение имеет стоимость 80 миллионов и дает 18 миллионов прибыли. Это на миллион лучше, чем решение для эвристики восхождения на холм, но стратегия наименьшей стоимости не всегда дает лучшее решение, чем восхождение на холм. Какая из эвристик дает лучшие результаты, зависит от значений входных данных.
Структура программы, реализующей эвристику наименьшей стоимости, почти идентична структуре программы для эвристики восхождения на холм. Единственное различие между ними заключается в выборе следующей позиции для добавления к решению. Эвристика наименьшей стоимости выбирает позицию с минимальной ценой; метод восхождения на холм выбирает позицию с максимальной прибылью. Так как эти два метода очень похожи, они выполняются за одинаковое время. Если позиции упорядочены соответствующим образом, то оба алгоритма выполняются за время порядка O(N). Если позиции расположены случайным образом, то оба выполняются за время порядка O(N2).
========203-204
Так как код на языке Visual Basic для этих двух эвристик очень похож, то мы приводим только строки, в которых происходит выбор очередной позиции.
If (Not test_solution(j)) And _
(test_cost + Items(j).Cost <= ToSpend) And _
(small_cost > Items(j).Cost)
Then
small_cost = Items(j).Cost
small_j = j
End If
Стратегия восхождения на холм не учитывает стоимость добавляемых позиций. Она выбирает позиции с максимальной прибылью, даже если их стоимость велика. Стратегия наименьшей стоимости не учитывает приносимую позицией прибыль. Она выбирает позиции с низкой стоимостью, даже если они приносят мало прибыли.
Эвристика сбалансированной прибыли (balanced profit) сравнивает при выборе стоимость позиций и приносимую ими прибыль. На каждом шаге эвристика выбирает позицию с наибольшим отношением прибыль‑стоимость.
В табл. 8.4 приведены те же данные, что и в табл. 8.3, но в ней добавлена еще одна колонка с отношением прибыль‑стоимость. При этом подходе вначале выбирается позиция C, так как она имеет максимальное соотношение прибыль‑стоимость — 0,27. Затем к решению добавляется позиция D с отношением 0,26, и позиция B с отношением 0,20. В этой точке, будет потрачено 92 миллиона из 100 возможных, и в решение нельзя будет добавить больше ни одной позиции.
Решение будет иметь стоимость 92 миллиона и давать 22 миллиона прибыли. Это на 4 миллиона лучше, чем решение с наименьшей стоимостью и на 5 миллионов лучше, чем решение методом восхождения на холм. В этом случае, это будет также наилучшим возможным решением, и его также можно найти полным перебором или методом ветвей и границ. Метод сбалансированной прибыли тем не менее, является эвристическим, поэтому он не обязательно находит наилучшее возможное решение. Он часто находит лучшее решение, чем методы наименьшей стоимости и восхождения на холм, но это не обязательно так.
@Таблица 8.4. Возможные инвестиции с соотношением прибыль‑стоимость
=========205
Структура программы, реализующей эвристику сбалансированной прибыли, почти идентична структуре программ для восхождения на холм и наименьшей стоимости. Единственное отличие заключается в методе выбора следующей позиции, которая добавляется к решению:
If (Not test_solution(j)) And _
(test_cost + Items(j).Cost <= ToSpend) And _
(good_ratio < Items(j).Profit / CDbl(Items(j).Cost)) _
Then
good_ratio = Items(j).Profit / CDbl(Items(j).Cost)
good_j = j
End If
Случайный поиск (random search) выполняется в соответствии со своим названием. На каждом шаге алгоритм добавляет случайную позицию, которая удовлетворяет верхнему ограничению на суммарную стоимость позиций в портфеле. Этот метод поиска также называется методом Монте‑Карло (Monte Carlo search или Monte Carlo simulation).
Так как маловероятно, что случайно выбранное решение окажется наилучшим, необходимо многократно повторять этот поиск, чтобы получить приемлемый результат. Хотя может показаться, что вероятность нахождения хорошего решения при этом мала, этот метод иногда дает удивительно хорошие результаты. В зависимости от значений данных и числа проверенных случайных решений результат, полученный при помощи этой эвристики, часто оказывается лучше, чем в случае применения методов восхождения на холм или наименьшей стоимости.
Преимущество случайного поиска состоит также и в том, что этот метод легок в понимании и реализации. Иногда сложно представить, как реализовать решение задачи при помощи эвристик восхождения на холм, наименьшей стоимости, или сбалансированного дохода, но всегда просто выбирать решения случайным образом. Даже для очень сложных проблем, случайный поиск является простым эвристическим методом.
Подпрограмма RandomSearch в программе Heur использует функцию AddToSolution для добавления к решению случайной позиции. Эта функция возвращает значение True, если она не может найти позицию, которая удовлетворяет условиям, и False в другом случае. Подпрограмма RandomSearch вызывает функцию AddToSolution до тех пор, пока больше нельзя добавить ни одной позиции.
Public Sub RandomSearch()
Dim num_trials As Integer
Dim trial As Integer
Dim i As Integer
' Сделать несколько попыток и выбрать наилучший результат.
num_trials = NumItems ' Использовать N попыток.
For trial = 1 To num_trials
' Случайный выбор позиций, пока это возможно.
Do While AddToSolution()
' Всю работу выполняет функция AddToSolution.
Loop
' Определить, лучше ли это решение, чем предыдущее.
If test_profit > best_profit Then
best_profit = test_profit
best_cost = test_cost
For i = 1 To NumItems
best_solution(i) = test_solution(i)
Next i
End If
' Сбросить пробное решение и сделать еще одну попытку.
test_profit = 0
test_cost = 0
For i = 1 To NumItems
test_solution(i) = False
Next i
Next trial
End Sub
Private Function AddToSolution() As Boolean
Dim num_left As Integer
Dim j As Integer
Dim selection As Integer
' Определить, сколько осталось позиций, которые
' удовлетворяют ограничению максимальной стоимости.
num_left = 0
For j = 1 To NumItems
If (Not test_solution(j)) And _
(test_cost + Items(j).Cost <= ToSpend) _
Then num_left = num_left + 1
Next j
' Остановиться, если нельзя найти новую позицию.
If num_left < 1 Then
AddToSolution = False
Exit Function
End If
' Выбрать случайную позицию.
selection = Int((num_left) * Rnd + 1)
' Найти случайно выбранную позицию.
For j = 1 To NumItems
If (Not test_solution(j)) And _
(test_cost + Items(j).Cost <= ToSpend) _
Then
selection = selection - 1
If selection < 1 Then Exit For
End If
Next j
test_profit = test_profit + Items(j).Profit
test_cost = test_cost + Items(j).Cost
test_solution(j) = True
AddToSolution = True
End Function
Еще одна стратегия заключается в том, чтобы начать со случайного решения и затем делать последовательные приближения (incremental improvements). Начав со случайно выбранного решения, программа делает случайный выбор. Если новое решение лучше предыдущего, программа закрепляет изменения и продолжает проверку других случайных изменений. Если изменение не улучшает решение, программа отбрасывает его и делает новую попытку.
Для задачи формирования портфеля особенно просто порождать случайные изменения. Программа просто выбирает случайную позицию из пробного решения, и удаляет ее из текущего решения. Она затем снова добавляет случайные позиции в решение до тех пор, пока они помещаются. Если удаленная позиция имела очень высокую стоимость, то на ее место программа может поместить несколько позиций.
Есть несколько хороших способов определить момент, когда следует прекратить случайные изменения. Для проблемы с N позициями, можно выполнить N или N2 случайных изменений, перед тем, как остановиться.
=====206-208
В программе Heur этот подход реализован в процедуре MakeChangesFixed. Она выполняет определенное число случайных изменений с рядом случайных пробных решений:
Public Sub MakeChangesFixed(K As Integer, num_trials As Integer, num_changes As Integer)
Dim trial As Integer
Dim change As Integer
Dim i As Integer
Dim removal As Integer
For trial = 1 To num_trials
' Найти случайное пробное решение и использовать его
' в качестве начальной точки.
Do While AddToSolution()
' All the work is done by AddToSolution.
Loop
' Начать с этого пробного решения.
trial_profit = test_profit
trial_cost = test_cost
For i = 1 To NumItems
trial_solution(i) = test_solution(i)
Next i
For change = 1 To num_changes
' Удалить K случайных позиций.
For removal = 1 To K
RemoveFromSolution
Next removal
' Добавить максимально возможное
' число позиций.
Do While AddToSolution()
' All the work is done by AddToSolution.
Loop
' Если это улучшает пробное решение, сохранить его.
' Иначе вернуть прежнее значение пробного решения.
If test_profit > trial_profit Then
' Сохранить изменения.
trial_profit = test_profit
trial_cost = test_cost
For i = 1 To NumItems
trial_solution(i) = test_solution(i)
Next i
Else
' Сбросить пробное решение.
test_profit = trial_profit
test_cost = trial_cost
For i = 1 To NumItems
test_solution(i) = trial_solution(i)
Next i
End If
Next change
' Если пробное решение лучше предыдущего
' наилучшего решения, сохранить его.
If trial_profit > best_profit Then
best_profit = trial_profit
best_cost = trial_cost
For i = 1 To NumItems
best_solution(i) = trial_solution(i)
Next i
End If
' Сбросить пробное решение для
' следующей попытки.
test_profit = 0
test_cost = 0
For i = 1 To NumItems
test_solution(i) = False
Next i
Next trial
End Sub
Private Sub RemoveFromSolution()
Dim num_in_solution As Integer
Dim j As Integer
Dim selection As Integer
' Определить число позиций в решении.
num_in_solution = 0
For j = 1 To NumItems
If test_solution(j) Then num_in_solution = num_in_solution + 1
Next j
If num_in_solution < 1 Then Exit Sub
' Выбрать случайную позицию.
selection = Int((num_in_solution) * Rnd + 1)
' Найти случайно выбранную позицию.
For j = 1 To NumItems
If test_solution(j) Then
selection = selection - 1
If selection < 1 Then Exit For
End If
Next j
' Удалить позицию из решения.
test_profit = test_profit - Items(j).Profit
test_cost = test_cost - Items(j).Cost
test_solution(j) = False
End Sub
======209-210
Другая стратегия заключается в том, чтобы вносить изменения до тех пор, пока несколько последовательных изменений не приносят улучшений. Для задачи с N позициями, программа может вносить изменения до тех пор, пока в течение N изменений подряд улучшений не будет.
Эта стратегия реализована в подпрограмме MakeChangesNoChange программы Heur. Она повторяет попытки до тех пор, пока определенное число последовательных попыток не даст никаких улучшений. Для каждой попытки она вносит случайные изменения в пробное решение до тех пор, пока после определенного числа изменений не наступит никаких улучшений.
Public Sub MakeChangesNoChange(K As Integer, _
max_bad_trials As Integer, max_non_changes As Integer)
Dim i As Integer
Dim removal As Integer
Dim bad_trials As Integer ' Неэффективных попыток подряд.
Dim non_changes As Integer ' Неэффективных изменений подряд.
' Повторять попытки, пока не встретится max_bad_trials
' попыток подряд без улучшений.
bad_trials = 0
Do
' Выбрать случайное пробное решение для
' использования в качестве начальной точки.
Do While AddToSolution()
' All the work is done by AddToSolution.
Loop
' Начать с этого пробного решения.
trial_profit = test_profit
trial_cost = test_cost
For i = 1 To NumItems
trial_solution(i) = test_solution(i)
Next i
' Повторять, пока max_non_changes изменений
' подряд не даст улучшений.
non_changes = 0
Do While non_changes < max_non_changes
' Удалить K случайных позиций.
For removal = 1 To K
RemoveFromSolution
Next removal
' Вернуть максимально возможное число позиций.
Do While AddToSolution()
' All the work is done by
' AddToSolution.
Loop
' Если это улучшает пробное значение, сохранить его.
' Иначе вернуть прежнее значение пробного решения.
If test_profit > trial_profit Then
' Сохранить улучшение.
trial_profit = test_profit
trial_cost = test_cost
For i = 1 To NumItems
trial_solution(i) = test_solution(i)
Next i
non_changes = 0 ' This was a good change.
Else
' Reset the trial.
test_profit = trial_profit
test_cost = trial_cost
For i = 1 To NumItems
test_solution(i) = trial_solution(i)
Next i
non_changes = non_changes + 1 ' Плохое изменение.
End If
Loop ' Продолжить проверку случайных изменений.
' Если эта попытка лучше, чем предыдущее наилучшее
' решение, сохранить его.
If trial_profit > best_profit Then
best_profit = trial_profit
best_cost = trial_cost
For i = 1 To NumItems
best_solution(i) = trial_solution(i)
Next i
bad_trials = 0 ' Хорошая попытка.
Else
bad_trials = bad_trials + 1 ' Плохая попытка.
End If
' Сбросить тестовое решение для следующей попытки.
test_profit = 0
test_cost = 0
For i = 1 To NumItems
test_solution(i) = False
Next i
Loop While bad_trials < max_bad_trials
End Sub
Если программа заменяет случайно выбранную позицию в пробном решении, то может встретиться решение, которое она не может улучшить, но которое при этом не будет наилучшим из возможных решений. Например, рассмотрим список инвестиций, приведенный в табл. 8.5.
Предположим, что алгоритм случайно выбрал позиции A и B в качестве начального решения. Его стоимость будет равно 90 миллионам долларов, и оно принесет 17 миллионов прибыли.
Если программа удалит позиции A и B, то стоимость решения будет все еще настолько велика, что программа сможет добавить всего лишь одну позицию к решению. Так как наибольшую прибыль приносят позиции A и B, то замена их другими позициями уменьшит суммарную прибыль. Случайное удаление одной позиции из этого решения никогда не приведет к улучшению решения.
Наилучшее решение содержит позиции C, D и E. Его полная стоимость равно 98 миллионам долларов и суммарная прибыль составляет 18 миллионов долларов. Чтобы найти это решение, алгоритму бы понадобилось удалить из решения сразу обе позиции A и B и затем добавить на их место новые позиции.
Решения такого типа, для которых небольшие изменения решения не могут улучшить его, называются локальным оптимумом (local optimum). Можно использовать два способа для того, чтобы программа не застревала в локальном оптимуме, и могла найти глобальный оптимум (global optimum).
@Таблица 8.5. Возможные инвестиции
=============213
Во‑первых, можно изменить программу так, чтобы она удаляла более одной позиции во время случайных изменений. В этом примере, программа могла бы найти правильное решение, если бы она одновременно удаляла бы по две случайно выбранных позиции. Тем не менее, для задач большего размера, удаления двух позиций может быть недостаточно. Программе может понадобиться удалять три, четыре, или больше позиций.
Второй, более простой способ заключается в том, чтобы делать больше попыток, начиная с разных начальных решений. Некоторые из начальных решений будут приводить к локальным оптимумам, но одно из них позволит достичь глобального оптимума.
Программа Heur демонстрирует три стратегии последовательных приближений. При выборе метода Fixed 1 (Фиксированный 1) делается N попыток. Во время каждой попытки выбирается случайно решение, которое программа затем пытается улучшить за 2 * N попыток, случайно удаляя по одной позиции.
При выборе эвристики Fixed 2 (Фиксированный 2)делается всего одна попытка. При этом программа выбирает случайное решение и пытается улучшить его, случайным образом удаляя по одной позиции до тех пор, пока в течение N последовательных изменений не будет никаких улучшений.
При выборе эвристики No Changes 1 (Без изменений 1) программа выполняет попытки до тех пор, пока после N последовательных попыток не будет никаких улучшений. Во время каждой попытки программа выбирает случайное решение и затем пытается улучшить его, случайным образом удаляя по одной позиции до тех пор, пока в течение N последовательных изменений не будет никаких улучшений.
При выборе эвристики No Changes 2 (Без изменений 2)делается одна попытка. При этом программа выбирает случайное решение и пытается улучшить его, случайным образом удаляя по две позиции до тех пор, пока в течение N последовательных изменений не будет никаких улучшений.
Названия эвристик и их описания приведены в табл. 8.6.
Метод отжига (simulated annealing) ведет свое начало из термодинамики. При отжиге металла он нагревается до высокой температуры. Молекулы в нагретом металле совершают быстрые колебания, а при медленном остывании они начинают располагаться упорядоченно, образуя кристаллы. При этом молекулы постепенно переходят в состояние с минимальной энергией.
@Таблица 8.6. Стратегии последовательных приближений
===========214
При медленном остывании металла, соседние кристаллы сливаются друг с другом. Молекулы в одном из кристаллов покидают состояние с минимальной энергией и принимают порядок молекул в другом кристалле. Энергия получившегося кристалла большего размера будет меньше, чем сумма энергий двух исходных кристаллов. Если охлаждение происходит достаточно медленно, то кристаллы становятся очень большими. Окончательное распределение молекул представляет состояние с очень низкой энергией, и металл при этом будет очень твердым.
Начиная с состояния с высокой энергией, молекулы в конце концов достигают состояния с очень низкой энергией. На пути к конечному положению, они проходят множество локальных минимумов энергии. Каждое сочетание кристаллов образует локальный минимум. Кристаллы могут объединяться друг с другом только за счет временного повышения энергии системы, чтобы затем перейти к состоянию с меньшей энергией.
Метод отжига использует аналогичный подход для поиска наилучшего решения задачи. Во время поиска решения программой, она может застрять в локальном оптимуме. Чтобы избежать этого, программа время от времени вносит в решение случайные изменения, даже если очередное изменение и не приводит к мгновенному улучшению результата. Это может помочь программе выйти из локального оптимума и отыскать лучшее решение. Если это изменение не ведет к лучшему решению, то вероятно, через некоторое время программа его отбросит.
Чтобы эти изменения не возникали постоянно, алгоритм изменяет вероятность возникновения случайных изменений со временем. Вероятность P возникновения одного из подобных изменений определяется формулой P = 1 / Exp(E / (k * T)), где E — увеличение «энергии» системы, k — некоторая постоянная, и T — переменная, соответствующая «температуре».
Вначале температура должна быть высокой, поэтому и вероятность изменений P = 1 / Exp(E / (k * T)) также достаточно велика. Иначе случайные изменения могли бы никогда не возникнуть. С течением времени значение переменной T постепенно снижается, и вероятность случайных изменений также уменьшается. После того, как модель дойдет до точки, в которой она никакие изменения не смогут улучшить решение, и температура T станет достаточно низкой, чтобы вероятность случайных изменений была мала, алгоритм заканчивает работу.
Для задачи о формирования портфеля, в качестве прибавки «энергии» E выступает уменьшение прибыли решения. Например, при удалении позиции, которая дает прибыль 10 миллионов, и замене ее на позицию, которая приносит 7 миллионов прибыли, энергия, добавленная к системе, будет равна 3.
Заметьте, что если энергия велика, то вероятность изменений P = 1 / Exp(E / (k * T)) мала, поэтому вероятность больших изменений ниже.
Алгоритм отжига в программе Heur устанавливает значение постоянной k равным разнице между наибольшей и наименьшей прибылью возможных инвестиций. Начальная температура T задается равной 0,75. После выполнения определенного числа случайных изменений, температура T уменьшается умножением на постоянную 0,95.
=========215
Public Sub AnnealTrial(K As Integer, max_non_changes As Integer, _
max_back_slips As Integer)
Const TFACTOR = 0.95
Dim i As Integer
Dim non_changes As Integer
Dim t As Double
Dim max_profit As Integer
Dim min_profit As Integer
Dim doit As Boolean
Dim back_slips As Integer
' Найти позицию с минимальной и максимальной прибылью.
max_profit = Items(1).Profit
min_profit = max_profit
For i = 2 To NumItems
If max_profit < Items(i).Profit Then max_profit = Items(i).Profit
If min_profit > Items(i).Profit Then min_profit = Items(i).Profit
Next i
t = 0.75 * (max_profit - min_profit)
back_slips = 0
' Выбрать случайное пробное решение
' в качестве начальной точки.
Do While AddToSolution()
' Вся работа выполняется в процедуре AddToSolution.
Loop
' Использовать в качестве пробного решения.
best_profit = test_profit
best_cost = test_cost
For i = 1 To NumItems
best_solution(i) = test_solution(i)
Next i
' Повторять, пока в течение max_non_changes изменений
' подряд не будет улучшений.
non_changes = 0
Do While non_changes < max_non_changes
' Удалить случайную позицию.
For i = 1 To K
RemoveFromSolution
Next i
' Добавить максимально возможное число позиций.
Do While AddToSolution()
' Вся работа выполняется в процедуре AddToSolution.
Loop
' Если изменение улучшает пробное решение, сохранить его.
' Иначе вернуть прежнее значение решения.
If test_profit > best_profit Then
doit = True
ElseIf test_profit < best_profit Then
doit = (Rnd < Exp((test_profit - best_profit) / t))
back_slips = back_slips + 1
If back_slips > max_back_slips Then
back_slips = 0
t = t * TFACTOR
End If
Else
doit = False
End If
If doit Then
' Сохранить улучшение.
best_profit = test_profit
best_cost = test_cost
For i = 1 To NumItems
best_solution(i) = test_solution(i)
Next i
non_changes = 0 ' Хорошее изменение.
Else
' Reset the trial.
test_profit = best_profit
test_cost = best_cost
For i = 1 To NumItems
test_solution(i) = best_solution(i)
Next i
non_changes = non_changes + 1 ' Плохое изменение.
End If
Loop ' Продолжить проверку случайных изменений.
End Sub
Различные эвристики по‑разному ведут себя в различных задачах. Для задачи о формировании портфеля, эвристика сбалансированной прибыли работает достаточно хорошо, учитывая ее простоту. Стратегии последовательного приближения обычно дают сравнимые результаты, но для больших задач их выполнение занимает намного больше времени. Для других задач наилучшей может быть какая‑либо другая эвристика, в том числе из тех, которые не обсуждались в этой главе.
========216-217
Эвристические методы обычно выполняются быстрее, чем метод ветвей и границ. Некоторые из них, например методы восхождения на холм, наименьшей стоимости и сбалансированной прибыли, выполняются очень быстро, так как они рассматривают только одно возможное решение. Они выполняются настолько быстро, что имеет смысл выполнить их все по очереди, и затем выбрать наилучшее из трех полученных решений. Это не гарантирует того, что это решение будет наилучшим, но дает некоторую уверенность, что оно окажется достаточно хорошим.
Существует множество очень сложных задач, большинство из которых не имеет решений с полиномиальной вычислительной сложностью. Другими словами, не существует алгоритмов, которые решали бы эти задачи за время порядка O(NC) для любых постоянных C, даже за O(N1000).
В следующих разделах кратко описаны некоторые из этих задач. В них также показано, почему они являются сложными в общем случае и насколько большим может оказаться дерево решений задачи. Вы можете попробовать применить метод ветвей и границ или эвристики для решения некоторых из этих задач.
Если имеется логическое утверждение, например “(A And Not B) Or C”, то существуют ли значения переменных A, B и C, при которых это утверждение истинно? В данном примере легко увидеть, что утверждение истинно, если A = true, B = false и C = false. Для более сложных утверждений, содержащих сотни переменных, бывает достаточно сложно определить, может ли быть утверждение истинным.
При помощи метода, похожего на тот, который использовался при решении задачи о формировании портфеля, можно простроить дерево решений для задачи о выполнимости (satisfiability problem). Каждая ветвь дерева будет соответствовать решению о присвоении переменной значения true или false. Например, левая ветвь, выходящая из корня, соответствует значению первой переменной true.
Если в логическом выражении N переменных, то дерево решений представляет собой двоичное дерево высотой N + 1. Это дерево имеет 2N листьев, каждый из которых соответствует разной комбинации значений переменных.
В задаче о формировании портфеля можно было использовать метод ветвей и границ для того, чтобы избежать поиска в большей части дерева. В задаче о выполнимости выражение либо истинно, либо ложно. При этом нельзя получить частичное решение, которое можно использовать для отсечения путей в дереве.
Нельзя также использовать эвристики для поиска приблизительного решения для задачи о выполнимости. Любое значение переменных, полученное при помощи эвристики, будет делать выражение истинным или ложным. В математической логике не существует такого понятия, как приближенное решение.
Из‑за неприменимости эвристик и меньшей эффективности метода ветвей и границ, задача о выполнимости обычно является очень сложной и решается только в случае небольшого размера задачи.
Если задано множество элементов со значениями X1, X2, … , XN, то существует ли способ разбить его на два подмножества, так чтобы сумма значений всех элементов в каждом из подмножеств была одинаковой? Например, если элементы имеют значения 3, 4, 5 и 6, то их можно разбить на два подмножества {3, 6} и {4, 5}, сумма значений элементов в каждом из которых равна 9.
Чтобы смоделировать эту задачу при помощи дерева, предположим, что ветвям соответствует помещение элемента в одно из двух подмножеств. Левая ветвь, выходящая из корневого узла, соответствует помещению первого элемента в первое подмножество, а правая ветвь — во второе подмножество.
Если всего существует N элементов, то дерево решение будет представлять собой двоичное дерево высотой N + 1. Оно будет содержать 2N листьев и 2N+1 узлов. Каждый лист соответствует одному из вариантов размещения элементов в двух подмножествах.
При решении этой задачи можно применить метод ветвей и границ. При рассмотрении частичных решений задачи можно отслеживать, насколько различаются суммарные значения элементов в двух подмножествах. Если в какой‑то момент суммарное значение элементов для одного из подмножеств настолько меньше, чем для другого, что добавление всех оставшихся элементов не позволяет изменить это соотношение, то нет смысла продолжать движение вниз по этой ветви.
Так же, как и в случае с задачей о выполнимости, для задачи о разбиении (partition problem) нельзя получить приближенное решение. В результате всегда должно получиться два подмножества, суммарное значение элементов в которых будет или не будет одинаковым. Это означает, что для решения этой задачи неприменимы эвристики, которые использовались для решения задачи о формировании портфеля.
Задачу о разбиении можно обобщить следующим образом: если имеется множество элементов со значениями X1, X2, … , XN, как разбить его на два подмножества, чтобы разница суммы значений элементов в двух подмножествах была минимальной?
Получить точное решение этой задачи труднее, чем для исходной задачи о разбиении. Если бы существовал простой способ решения задачи в общем случае, то его можно было бы использовать для решения исходной задачи. В этом случае можно было бы просто найти два подмножества, удовлетворяющих условиям, а затем проверить, совпадают ли суммы значений элементов в них.
Для решения общего случая задачи можно использовать метод ветвей и границ, примерно так же, как он использовался для решения частного случая задачи, чтобы избежать поиска по всему дереву. Можно также использовать при этом эвристический подход. Например, можно проверять элементы в порядке убывания их значения, помещая очередной элемент в подмножество с меньшей суммой значений элементов. Также можно было бы легко использовать случайный поиск, метод последовательных приближений, или метод отжига для поиска приближенного решения этого общего случая задачи.
Если задана сеть, то Гамильтоновым путем (Hamiltonian path) для нее называется путь, обходящий все узлы в сети только один раз и затем возвращающийся в начальную точку.
На рис. 8.9 показана небольшая сеть и Гамильтонов путь для нее, нарисованный жирной линией.
Задача поиска Гамильтонова пути формулируется так: если задана сеть, существует ли для нее Гамильтонов путь?
==============219
@Рис. 8.9. Гамильтонов путь
Так как Гамильтонов путь обходит все узлы в сети, то не нужно определять, какие из узлов попадают в него, а какие нет. Необходимо установить только порядок, в котором их нужно обойти для создания Гамильтонова пути.
Для моделирования этой задачи при помощи дерева, предположим, что ветви соответствуют выбору следующего узла в пути. Корневой узел тогда будет содержать N ветвей, соответствующих началу пути в каждом из N узлов. Каждый из узлов первого уровня будет иметь N – 1 ветвей, по одной ветви для каждого из оставшихся N – 1 узлов. Узлы на следующем уровне дерева будут иметь N – 2 ветвей, и так далее. Нижний уровень дерева будет содержать N! листьев, соответствующих N! возможных путей. Всего в дереве будет находиться порядка O(N!) узлов.
Каждый лист соответствует Гамильтонову пути, но число листьев может быть разным для различных сетей. Если два узла в сети не связаны друг с другом, то в дереве будут отсутствовать ветви, которые соответствуют переходам между этими двумя узлами. Это уменьшает число путей в дереве и соответственно, число листьев.
Так же, как и в задачах о выполнимости и о разбиении, для задачи поиска Гамильтонова пути нельзя получить приближенное решение. Путь может либо являться Гамильтоновым, либо нет. Это означает, что эвристический подход и метод ветвей и границ не помогут при поиске Гамильтонова пути. Что еще хуже, дерево решений для задачи поиска Гамильтонова пути содержит порядка O(N!) узлов. Это намного больше, чем порядка O(2N) узлов, которые содержат деревья решений для задач о выполнимости и разбиении. Например, 220 примерно равно 1 * 10 6, тогда как 20! составляет около 2,4 * 1018 — в миллион раз больше. Из‑за очень большого размера дерева решений задачи нахождения Гамильтонова пути, поиск в нем можно выполнить только для задач очень небольшого размера.
Задача коммивояжера (traveling salesman problem) тесно связана с задачей поиска Гамильтонова пути. Она формулируется так: найти самый короткий Гамильтонов путь для сети.
========220
Эта задача имеет примерно такое же отношение к задаче поиска Гамильтонова пути, как обобщенный случай задачи о разбиении к простой задаче о разбиении. В первом случае возникает вопрос о существовании решения. Во втором — какое приближенное решение будет наилучшим. Если бы существовало простое решение второй задачи, то его можно было бы использовать для решения первого варианта задачи.
Обычно задача коммивояжера возникает только в сетях, содержащих большое число Гамильтоновых путей. В типичном примере, коммивояжеру требуется посетить несколько клиентов, используя кратчайший маршрут. В случае обычной сети улиц, любые две точки в сети связаны между собой, поэтому любой маршрут представляет собой Гамильтонов путь. Задача заключается в том, чтобы найти самый короткий из них.
Так же как и в случае поиска Гамильтонова пути, дерево решений для этой задачи содержит порядка O(N!) узлов. Так же, как и в обобщенной задаче о разбиении, для отсечения ветвей дерева и ускорения поиска решения задач средних размеров можно использовать метод ветвей и границ.
Существует также несколько хороших эвристических методов последовательных приближений для задачи коммивояжера. Например, использование стратегии пар путей, при которой перебираются пары отрезков маршрута. Программа проверяет, станет ли маршрут короче, если удалить пару отрезков и заменить их двумя новым, так чтобы маршрут при этом оставался замкнутым. На рис. 8.10 показано как изменяется маршрут, если отрезки X1 и X2 заменить отрезками Y1 и Y2. Аналогичные стратегии последовательных приближений рассматривают замену трех или более отрезков пути одновременно.
Обычно такие шаги последовательного приближения повторяются многократно или до тех пор, пока не будут проверены все возможные пары отрезков пути. После того, как дальнейшие шаги не приводят к улучшениям, можно сохранить результат и начать работу снова, случайным образом выбрав другой исходный маршрут. После проверки достаточно большого числа различных случайных исходных маршрутов, вероятно будет найден достаточно короткий путь.
Задача о пожарных депо (firehouse problem) формулируется так: если задана сеть, некоторое число F, и расстояние D, то существует ли способ размесить F пожарных депо таким образом, чтобы все узлы сети находились не дальше, чем на расстоянии D от ближайшего пожарного депо?
@Рис. 8.10. Последовательное приближение при решении задачи коммивояжера
========221
Эту задачу можно смоделировать при помощи дерева решений, в котором каждая ветвь определяет местоположение соответствующего пожарного депо в сети. Корневой узел будет иметь N ветвей, соответствующих размещению первого пожарного депо в одном из N узлов сети. Узлы на следующем уровне дерева будут иметь N – 1 ветвей, соответствующих размещению второго пожарного депо в одном из оставшихся N – 1 узлов. Если всего существует F пожарных депо, то высота дерева решений будет равна F, и оно будет содержать порядка O(NF) узлов. В дереве будет N * (N – 1) * … * (N – F) листьев, соответствующих разным вариантам размещения пожарных депо в сети.
Так же, как и в задачах о выполнимости, разбиении, и поиске Гамильтонова пути, в этой задаче нужно дать положительный или отрицательный ответ на вопрос. Это означает, что при проверке дерева решений нельзя использовать частичные или приближенные решения.
Можно, тем не менее, использовать разновидность метода ветвей и границ, если на ранних этапах решения определить, какие из вариантов размещения пожарных депо не приводят к решению. Например, бессмысленно помещать очередное депо между двумя другими, расположенными рядом. Если все узлы на расстоянии D от нового пожарного депо уже находятся в пределах этого расстояния от другого депо, значит, новое депо нужно поместить в какое‑то другое место. Тем не менее, такого рода вычисления также отнимают достаточно много времени, и задача все еще остается очень сложной.
Так же, как и для задач о разбиении и поиске Гамильтонова пути, существует обобщенный случай задачи о пожарных депо. В обобщенном случае задача формулируется так: если задана сеть и некоторое число F, в каких узлах сети нужно поместить F пожарных депо, чтобы наибольшее расстояние от любого узла до пожарного депо было минимальным?
Так же, как и обобщенных случаях других задач, для поиска частичного и приближенного решений этой задачи можно использовать метод ветвей и границ и эвристический подход. Это несколько упрощает проверку дерева решений. Хотя дерево решений все еще остается огромным, можно по крайней мере найти приблизительные решения, даже если они и не являются наилучшими.
Во время чтения предыдущих параграфов вы могли заметить, что существует два варианта многих сложных задач. Первый вариант задачи задает вопрос: «Существует ли решение задачи, удовлетворяющее определенным условиям?». Второй, более общий случай дает ответ на вопрос: «Какое решение задачи будет наилучшим?»
Обе задачи при этом имеют одинаковое дерево решений. В первом случае дерево решений просматривается до тех пор, пока не будет найдено какое‑либо решение. Так как для этих задач не существует частичного или приближенного решения, то обычно нельзя использовать для уменьшения объема работы эвристический подход или метод ветвей и границ. Обычно всего лишь несколько путей в дереве ведут к решению, поэтому решение этих задач — очень трудоемкий процесс.
При решении же обобщенного случая задачи, часто можно использовать частичные решения и применить метод ветвей и границ. Это не облегчает поиск наилучшего решения задачи, поэтому не поможет получить точное решение для частной задачи. Например, сложнее найти самый короткий Гамильтонов путь в сети, чем найти произвольный Гамильтонов путь для той же сети.
==========222
С другой стороны, эти вопросы обычно относятся к различным входным данным. Обычно вопрос о существовании Гамильтонова пути возникает, если сеть разрежена, и сложно сказать, существует ли такой путь. Вопрос о кратчайшем Гамильтоновом пути возникает обычно, если сеть достаточно плотная и существует множество таких путей. В этом случае легко найти частичные решения, и метод ветвей и границ может сильно упростить решение задачи.
Можно использовать деревья решений для моделирования различных задач. Поиск наилучшего решения задачи соответствует при этом поиску наилучшего пути в дереве. К сожалению, деревья решений для многих интересных задач имеют огромный размер, поэтому решить такие задачи методом полного перебора можно только для очень небольших задач.
Метод ветвей и границ позволяет отсекать большую часть ветвей в некоторых деревьях решений, что позволяет получать точное решение для задач гораздо большего размера.
Тем не менее, для самых больших задач, даже применение метода ветвей и границ не может помочь. В этом случае, для получения приблизительного решения необходимо использовать эвристический подход для получения приблизительных решений. При помощи методов случайного поиска и последовательных приближений можно найти приемлемое решение, даже если неизвестно, будет ли оно наилучшим возможным решением задачи.
==========223
Глава 9. СортировкаСортировка — одна из наиболее активно изучаемых тем в компьютерных алгоритмах по ряду причин. Во-первых, сортировка — это задача, которая часть встречается во многих приложениях. Почти любой список данных будет нести больше смысла, если его отсортировать каким‑либо образом. Часто требуется сортировать данные несколькими различными способами.
Во‑вторых, многие алгоритмы сортировки являются интересными примерами программирования. Они демонстрируют важные методы, такие как частичное упорядочение, рекурсия, слияние списков и хранение двоичных деревьев в массиве.
Наконец, сортировка является одной из немногих задач с точными теоретическими ограничениями производительности. Можно показать, что время выполнения любого алгоритма сортировки, который использует сравнения, составляет порядка O(N * log(N)). Некоторые алгоритмы достигают теоретического предела, то есть они являются оптимальными в этом смысле. Есть даже ряд несколько алгоритмов, которые используют другие методы вместо сравнений, которые выполняются быстрее, чем за время порядка O(N * log(N)).
В этой главе описаны некоторые алгоритмы сортировки, которые ведут себя по‑разному в различных обстоятельствах. Например, пузырьковая сортировка опережает быструю сортировку по скорости работы, если сортируемые элементы уже были почти упорядочены, но работает медленнее, если элементы были расположены хаотично.
Особенности каждого алгоритма описаны в параграфе, в котором он обсуждается. Перед тем как перейти к рассмотрению отдельных алгоритмов, вначале в этой главе обсуждаются вопросы, которые влияют на все алгоритмы сортировки.
При сортировке элементов данных, программа организует из них некоторое подобие структуры данных. Этот процесс может быть быстрым или медленным в зависимости от типа элементов. Перемещение целого числа на новое положение в массиве может быть намного быстрее, чем перемещение определенной пользователем структуры данных. Если эта структура представляет собой список данных о сотруднике, содержащий тысячи байт информации, копирование одного элемента может занять достаточно много времени.
========225
Для повышения производительности при сортировке больших объектов можно помещать ключевые поля данных, используемые для сортировки, в таблицу индексов. В этой таблице находятся ключи к записям и индексы элементов другого массива, в котором и находятся записи данных. Например, предположим, что вы собираетесь отсортировать список записей о сотрудниках, определяемый следующей структурой:
Type Emloyee
ID As Integer
LastName As String
FirstName As String
<и т.д.>
End Type
‘ Выделить память под записи.
Dim EmloyeeData(1 To 10000)
Чтобы отсортировать сотрудников по идентификационному номеру, нужно создать таблицу индексов, которая содержит индексы и значения ID values из записей. Индекс элемента показывает, какая запись в массиве EmployeeData содержит соответствующие данные.
Type IdIndex
ID As Integer
Index As Integer
End Type
‘ Таблица индексов.
Dim IdIndexData(1 To 10000)
Проинициализируем таблицу индексов так, чтобы первый индекс указывал на первую запись данных, второй — на вторую, и т.д.
For i = 1 To 10000
IdIndexData(i).ID = EmployeeData(i).ID
IdIndexData(i).Index = i
Next i
Затем, отсортируем таблицу индексов по идентификационному номеру ID. После этого, поле Index в каждом элементе IdIndexData указывает на соответствующую запись данных. Например, первая запись в отсортированном списке — это EmployeeData(IdIndexData(1).Index). На рис. 9.1 показана взаимосвязь между индексом и записью данных до, и после сортировки.
=======226
@Рисунок 9.1. Сортировка с помощью таблицы индексов
Для того, чтобы сортировать данные в разном порядке, можно создать несколько различных таблиц индексов и управлять ими по отдельности. В приведенном примере можно было бы создать еще одну таблицу индексов, упорядочивающую сотрудников по фамилии. Подобно этому списки со ссылками могут сортировать список различными способами, как показано во 2 главе. При добавлении или удалении записи необходимо обновлять каждую таблицу индексов независимо.
Помните, что таблицы индексов занимают дополнительную память. Если создать по таблице индексов для каждого из полей данных, объем занимаемой памяти более чем удвоится.
Иногда можно хранить ключи списка в комбинированной или сжатой форме. Например, можно было бы объединить (combine) в программе два поля, соответствующих имени и фамилии, в одни ключ. Это позволило бы упростить и ускорить сравнение. Обратите внимание на различия между двумя следующими фрагментами кода, которые сравнивают две записи о сотрудниках:
‘ Используя разные ключи.
If emp1.LastName > emp2.LastName Or _
(emp1.LastName = emp2.LastName And _
And emp1.FirstName > emp2.FirstName) Then
DoSomething
‘ Используя объединенный ключ.
If emp1.CominedName > emp2.CombinedName Then
DoSomething
========227
Также иногда можно сжимать (compress) ключи. Сжатые ключи занимают меньше места, уменьшая размер таблиц индексов. Это позволяет сортировать списки большего размера без перерасхода памяти, быстрее перемещать элементы в списке, и часто также ускоряет сравнение элементов.
Одни из методов сжатия строк — кодирование их целыми числами или данными другого числового формата. Числовые данные занимают меньше места, чем строки и сравнение двух численных значений также происходит намного быстрее, чем сравнение двух строк. Конечно, строковые операции неприменимы для строк, представленных числами.
Например, предположим, что мы хотим закодировать строки, состоящие из заглавных латинских букв. Можно считать, что каждый символ — это число по основанию 27. Необходимо использовать основание 27, чтобы представить 26 букв и еще одну цифру для обозначения конца слова. Без отметки конца слова, закодированная строка AA шла бы после строки B, потому что в строке AA две цифры, а в строке B — одна.
Код по основанию 27 для строки из трех символов дает формула 272 * (первая буква - A + 1) + 27 * (вторая буква - A + 1) + 27 * (третья буква - A + 1). Если в строке меньше трех символов, вместо значения (третья буква - A + 1) подставляется 0. Например, строка FOX кодируется так:
272 * (F - A + 1) + 27 * (O - A + 1) + (X - A +1) = 4803
Строка NO кодируется следующим образом:
272 * (N - A + 1) + 27 * (O - A + 1) + (0) = 10.611
Заметим, что 10.611 больше 4803, поскольку NO > FOX.
Таким же образом можно закодировать строки из 6 заглавных букв в виде числа в формате long и строки из 10 букв — как число в формате double. Две следующие процедуры конвертируют строки в числа в формате double и обратно:
Const STRING_BASE = 27
Const ASC_A = 65 ‘ ASCII код для символа "A".
‘ Преобразование строки с число в формате double.
‘
‘ full_len — полная длина, которую должна иметь строка.
‘ Нужна, если строка слишком короткая (например "AX" —
‘ это строка из трех символов).
Function StringToDbl (txt As String, full_len As Integer) As Double
Dim strlen As Integer
Dim i As Integer
Dim value As Double
Dim ch As String * 1
strlen = Len(txt)
If strlen > full_len Then strlen = full_len
value = 0#
For i = 1 To strlen
ch = Mid$(txt, i, 1)
value = value * STRING_BASE + Asc(ch) - ASC_A + 1
Next i
For i = strlen + 1 To full_len
value = value * STRING_BASE
Next i
End Function
‘ Обратное декодирование строки из формата double.
Function DblToString (ByVal value As Double) As String
Dim strlen As Integer
Dim i As Integer
Dim txt As String
Dim Power As Integer
Dim ch As Integer
Dim new_value As Double
txt = ""
Do While value > 0
new_value = Int(value / STRING_BASE)
ch = value - new_value * STRING_BASE
If ch <> 0 Then txt = Chr$(ch + ASC_A - 1) + txt
value = new_value
Loop
DblToString = txt
End Function
===========228
В табл. 9.1 приведено время выполнения программой Encode сортировки 2000 строк различной длины на компьютере с процессором Pentium и тактовой частотой 90 МГц. Заметим, что результаты похожи для каждого типа кодирования. Сортировка 2000 чисел в формате double занимает примерно одинаковое время независимо от того, представляют ли они строки из 3 или 10 символов.
========229
@Таблица 9.1. Время сортировки 2000 строк с использованием различных кодировок в секундах
Можно также кодировать строки, состоящие не только из заглавных букв. Строку из заглавных букв и цифр можно закодировать по основанию 37 вместо 27. Код буквы A будет равен 1, B — 2, … , Z — 26, код 0 будет 27, … , и 9 — 36. Строка AH7 будет кодироваться как 372 * 1 + 37 * 8 + 35 = 1700.
Конечно, при использовании большего основания, длина строки, которую можно закодировать числом типа integer, long или double будет соответственно короче. При основании равном 37, можно закодировать строку из 2 символов в числе формата integer, из 5 символов в числе формата long, и 10 символов в числе формата double.
Чтобы облегчить сравнение различных алгоритмов сортировки, программа Sort демонстрирует большинство алгоритмов, описанных в этой главе. Сортировка позволяет задать число сортируемых элементов, их максимальное значение, и порядок расположения элементов - прямой, обратный или расположение в случайном порядке. Программа создает список случайно расположенных чисел в формате long и сортирует его, используя выбранный алгоритм. Вначале сортируйте короткие списки, пока не определите, насколько быстро ваш компьютер может выполнять операции сортировки. Это особенно важно для медленных алгоритмов сортировки вставкой, сортировки вставкой с использованием связного списка, сортировки выбором, и пузырьковой сортировки.
Некоторые алгоритмы перемещают большие блоки памяти. Например, алгоритм сортировки вставкой перемещает элементы списка для того, чтобы можно было вставить новый элемент в середину списка. Для перемещения элементов программе, написанной на Visual Basic, приходится использовать цикл For. Следующий код показывает, как сортировка вставкой перемещает элементы с List(j) до List(max_sorted) для того, чтобы освободить место под новый элемент в позиции List(j):
For k = max_sorted To j Step -1
List(k + 1) = List(k)
Next k
List(j) = next_num
==========230
Интерфейс прикладного программирования системы Windows включает две функции, которые позволяют намного быстрее выполнять перемещение блоков памяти. Программы, скомпилированные 16‑битной версией компилятора Visual Basic 4, могут использовать функцию hmemcopy. Программы, скомпилированные 32‑битными компиляторами Visual Basic 4 и 5, могут использовать функцию RtlMoveMemory. Обе функции принимают в качестве параметров конечный и исходный адреса и число байт, которое должно быть скопировано. Следующий код показывает, как объявлять эти функции в модуле .BAS:
#if Win16 Then
Declare Sub MemCopy Lib "Kernel" Alias _
"hmemcpy" (dest As Any, src As Any, _
ByVal numbytes As Long)
#Else
Declare Sub MemCopy Lib "Kernel32" Alias _
"RtlMoveMemory" (dest As Any, src As Any, _
ByVal numbytes As Long)
#EndIf
Следующий фрагмент кода показывает, как сортировка вставкой может использовать эти функции для копирования блоков памяти. Этот код выполняет те же действия, что и цикл For, приведенный выше, но делает это намного быстрее:
If max_sorted >= j Then _
MemCopy List(j + 1), List(j), _
Len(next_num) * (max_sorted - j + 1)
List(j) = next_num
Программа FastSort аналогична программе Sort, но она использует функцию MemCopy для ускорения работы некоторых алгоритмов. В программе FastSort алгоритмы, использующие функцию MemCopy, выделены синим цветом.
Сортировка выбором (selectionsort) — простой алгоритм со сложность порядка O(N2). Идея состоит в поиске наименьшего элемента в списке, который затем меняется местами с элементом на вершине списка. Затем находится наименьший элемент из оставшихся, и меняется местами со вторым элементом. Процесс продолжается до тех пор, пока все элементы не займут свое конечное положение.
Public Sub Selectionsort(List() As Long, min As Long, max As Long)
Dim i As Long
Dim j As Long
Dim best_value As Long
Dim best_j As Long
For i = min To max - 1
‘ Найти наименьший элемент из оставшихся.
best_value = List(i)
best_j = i
For j = i + 1 To max
If List(j) < best_value Then
best_value = List(j)
best_j = j
End If
Next j
‘ Поместить элемент на место.
List(best_j) = List(i)
List(i) = best_value
Next i
End Sub
========231
При поиске I-го наименьшего элемента, алгоритму приходится перебрать N-I элементов, которые еще не заняли свое конечное положение. Время выполнения алгоритма пропорционально N + (N - 1) + (N - 2) + … + 1, или порядка O(N2).
Сортировка выбором неплохо работает со списками, элементы в которых расположены случайно или в прямом порядке, но несколько хуже, если список изначально отсортирован в обратном порядке. Для поиска наименьшего элемента в списке сортировка выбором выполняет следующий код:
If list(j) < best_value Then
best_value = list(j)
best_j = j
End If
Если первоначально список отсортирован в обратном порядке, условие list(j) < best_value выполняется большую часть времени. Например, при первом проходе оно будет истинно для всех элементов, поскольку каждый элемент меньше предыдущего. Алгоритм будет многократно выполнять строки с оператором If, что приведет к некоторому замедлению работы алгоритма.
Это не самый быстрый алгоритм из числа описанных в главе, но он чрезвычайно прост. Это не только облегчает его разработку и отладку, но и делает сортировку выбором достаточно быстрой для небольших задач. Многие другие алгоритмы настолько сложны, что они сортируют очень маленькие списки медленнее.
В некоторых программах требуется выполнение операции, обратной сортировке. Получив список элементов, программа должна расположить их в случайном порядке. Рандомизацию (unsorting) списка несложно выполнить, используя алгоритм, похожий на сортировку выбором.
Для каждого положения в списке, алгоритм случайным образом выбирает элемент, который должен его занять из тех, которые еще не были помещены на свое место. Затем этот элемент меняется местами с элементом, который, находится на этой позиции.
Public Sub Unsort(List() As Long, min As Long, max As Long)
Dim i As Long
Dim Pos As Long
Dim tmp As Long
For i - min To max - 1
pos = Int((max - i + 1) * Rnd + i)
tmp = List(pos)
List(pos) = List(i)
List(i) = tmp
Next i
End Sub
==============232
Т.к. алгоритм заполняет каждую позицию только один раз, его сложность порядка O(N).
Несложно показать, что вероятность того, что элемент окажется на какой‑либо позиции, равна 1/N. Поскольку элемент может оказаться в любом положении с равной вероятностью, этот алгоритм действительно приводит к случайному размещению элементов.
Результат зависит от того, насколько хорошим является генератор случайных чисел. Функция Rnd в Visual Basic дает приемлемый результат для большинства случаев. Следует убедиться, что программа использует оператор Randomize для инициализации функции Rnd, иначе при каждом запуске программы функция Rnd будет выдавать одну и ту же последовательность «случайных» значений.
Заметим, что для алгоритма не важен первоначальный порядок расположения элементов. Если вам необходимо неоднократно рандомизировать список элементов, нет необходимости его предварительно сортировать.
Программа Unsort показывает использование этого алгоритма для рандомизации отсортированного списка. Введите число элементов, которые вы хотите рандомизировать, и нажмите кнопку Go (Начать). Программа показывает исходный отсортированный список чисел и результат рандомизации.
Сортировка вставкой (insertionsort) — еще один алгоритм со сложностью порядка O(N2). Идея состоит в том, чтобы создать новый сортированный список, просматривая поочередно все элементы в исходном списке. При этом, выбирая очередной элемент, алгоритм просматривает растущий отсортированный список, находит требуемое положение элемента в нем, и помещает элемент на свое место в новый список.
Public Sub Insertionsort(List() As Long, min As Long, max As Long)
Dim i As Long
Dim j As Long
Dim k As Long
Dim max_sorted As Long
Dim next_num As Long
max_sorted = min -1
For i = min To max
‘ Это вставляемое число.
Next_num = List(i)
‘ Поиск его позиции в списке.
For j = min To max_sorted
If List(j) >= next_num Then Exit For
Next j
‘ Переместить большие элементы вниз, чтобы
‘ освободить место для нового числа.
For k = max_sorted To j Step -1
List(k + 1) = List(k)
Next k
‘ Поместить новый элемент.
List(j) = next_num
‘ Увеличить счетчик отсортированных элементов.
max_sorted = max_sorted + 1
Next i
End Sub
=======233
Может оказаться, что для каждого из элементов в исходном списке, алгоритму придется проверять все уже отсортированные элементы. Это происходит, например, если в исходном списке элементы были уже отсортированы. В этом случае, алгоритм помещает каждый новый элемент в конец растущего отсортированного списка.
Полное число шагов, которые потребуется выполнить, составляет 1 + 2 + 3 + … + (N - 1), то есть O(N2). Это не слишком эффективно, если сравнить с теоретическим пределом O(N * log(N)) для алгоритмов на основе операций сравнения. Фактически, этот алгоритм не слишком быстр даже в сравнении с другими алгоритмами порядка O(N2), такими как сортировка выбором.
Достаточно много времени алгоритм сортировки вставкой тратит на перемещение элементов для того, чтобы вставить новый элемент в середину отсортированного списка. Использование для этого функции API MemCopy увеличивает скорость работы алгоритма почти вдвое.
Достаточно много времени тратится и на поиск правильного положения для нового элемента. В 10 главе описано несколько алгоритмов поиска в отсортированных списках. Применение алгоритма интерполяционного поиска намного ускоряет выполнение алгоритма сортировки вставкой. Интерполяционный поиск подробно описывается в 10 главе, поэтому мы не будем сейчас на нем останавливаться.
Программа FastSort использует оба этих метода для улучшения производительности сортировки вставкой. С использованием функции MemCopy и интерполяционного поиска, эта версия алгоритма более чем в 15 раз быстрее, чем исходная.
Можно использовать вариант сортировки вставкой для упорядочения элементов не в массиве, а в связном списке. Этот алгоритм ищет требуемое положение элемента в растущем связном списке, и затем помещает туда новый элемент, используя операции работы со связными списками.
=========234
Public Sub LinkInsertionSort(ListTop As ListCell)
Dim new_top As New ListCell
Dim old_top As ListCell
Dim cell As ListCell
Dim after_me As ListCell
Dim nxt As ListCell
Set old_top = ListTop.NextCell
Do While Not (old_top Is Nothing)
Set cell = old_top
Set old_top = old_top.NextCell
‘ Найти, куда необходимо поместить элемент.
Set after_me = new_top
Do
Set nxt = after_me.NextCell
If nxt Is Nothing Then Exit Do
If nxt.Value >= cell.Value Then Exit Do
Set after_me = nxt
Loop
‘ Вставить элемент после позиции after_me.
Set after_me.NextCll = cell
Set cell.NextCell = nx
Loop
Set ListTop.NextCell = new_top.NextCell
End Sub
Т.к. этот алгоритм перебирает все элементы, может потребоваться сравнение каждого элемента со всеми элементами в отсортированном списке. В этом наихудшем случае вычислительная сложность алгоритма порядка O(N2).
Наилучший случай для этого алгоритма достигается, когда исходный список первоначально отсортирован в обратном порядке. При этом каждый последующий элемент меньше, чем предыдущий, поэтому алгоритм помещает его в начало отсортированного списка. При этом требуется выполнить только одну операцию сравнения элементов, и в наилучшем случае время выполнения алгоритма будет порядка O(N).
В усредненном случае, алгоритму придется провести поиск примерно по половине отсортированного списка для того, чтобы найти местоположение элемента. При этом алгоритм выполняется примерно за 1 + 1 + 2 + 2 + … + N/2, или порядка O(N2) шагов.
Улучшенная процедура сортировки вставкой, использующая интерполяционный поиск и функцию MemCopy, работает намного быстрее, чем версия со связным списком, поэтому последнюю процедуру лучше использовать, если программа уже хранит элементы в связном списке.
Преимущество использования связных списков для вставки в том, что при этом перемещаются только указатели, а не сами записи данных. Передача указателей может быть быстрее, чем копирование записей целиком, если элементы представляют собой большие структуры данных.
=======235
Пузырьковая сортировка (bubblesort) — это алгоритм, предназначенный для сортировки списков, которые уже находятся в почти упорядоченном состоянии. Если в начале процедуры список полностью отсортирован, алгоритм выполняется очень быстро за время порядка O(N). Если часть элементов находятся не на своих местах, алгоритм выполняется медленнее. Если первоначально элементы расположены в случайном порядке, алгоритм выполняется за время порядка O(N2). Поэтому перед применением пузырьковой сортировки важно убедиться, что элементы в основном расположены по порядку.
При пузырьковой сортировке список просматривается до тех пор, пока не найдутся два соседних элемента, расположенных не по порядку. Тогда они меняются местами, и процедура продолжается дальше. Алгоритм повторяет этот процесс до тех пор, пока все элементы не займут свои места.
На рис. 9.2 показано, как алгоритм вначале обнаруживает, что элементы 6 и 3 расположены не по порядку, и поэтому меняет их местами. Во время следующего прохода, меняются местами элементы 5 и 3, в следующем — 4 и 3. После еще одного прохода алгоритм обнаруживает, что все элементы расположены по порядку, и завершает работу.
Можно проследить за перемещениями элемента, который первоначально был расположен ниже, чем после сортировки, например элемента 3 на рис. 9.2. Во время каждого прохода элемент перемещается на одну позицию ближе к своему конечному положению. Он движется к вершине списка подобно пузырьку газа, который всплывает к поверхности в стакане воды. Этот эффект и дал название алгоритму пузырьковой сортировки.
Можно внести в алгоритм несколько улучшений. Во‑первых, если элемент расположен в списке выше, чем должно быть, вы увидите картину, отличную от той, которая приведена на рис. 9.2. На рис. 9.3 показано, что алгоритм вначале обнаруживает, что элементы 6 и 3 расположены в неправильном порядке, и меняет их местами. Затем алгоритм продолжает просматривать массив и замечает, что теперь неправильно расположены элементы 6 и 4, и также меняет их местами. Затем меняются местами элементы 6 и 5, и элемент 6 занимает свое место.
@Рис. 9.2. «Всплывание» элемента
========236
@Рис. 9.3. «Погружение» элемента
При просмотре массива сверху вниз, элементы, которые перемещаются вверх, сдвигаются всего на одну позицию. Те же элементы, которые перемещаются вниз, сдвигаются на несколько позиций за один проход. Этот факт можно использовать для ускорения работы алгоритма пузырьковой сортировки. Если чередовать просмотр массива сверху вниз и снизу вверх, то перемещение элементов в прямом и обратном направлениях будет одинаково быстрым.
Во время проходов сверху вниз, наибольший элемент списка перемещается на место, а во время проходов снизу вверх — наименьший. Если M элементов списка расположены не на своих местах, алгоритму потребуется не более M проходов для того, чтобы расположить элементы по порядку. Если в списке N элементов, алгоритму потребуется N шагов для каждого прохода. Таким образом, полное время выполнения для этого алгоритма будет порядка O(M * N).
Если первоначально список организован случайным образом, большая часть элементов будет находиться не на своих местах. В примере, приведенном на рис. 9.3, элемент 6 трижды меняется местами с соседними элементами. Вместо выполнения трех отдельных перестановок, можно сохранить значение 6 во временной переменной до тех пор, пока не будет найдено конечное положение элемента. Это может сэкономить большое число шагов алгоритма, если элементы перемещаются на большие расстояния внутри массива.
Последнее улучшение — ограничение проходов массива. После просмотра массива, последние переставленные элементы обозначают часть массива, которая содержит неупорядоченные элементы. При проходе сверху вниз, например, наибольший элемент перемещается в конечное положение. Поскольку нет больших элементов, которые нужно было бы поместить за ним, то можно начать очередной проход снизу вверх с этой точки и на ней же заканчивать следующие проходы сверху вниз.
========237
Таким же образом, после прохода снизу вверх, можно сдвинуть позицию, с которой начнется очередной проход сверху вниз, и будут заканчиваться последующие проходы снизу вверх.
Реализация алгоритма пузырьковой сортировки на языке Visual Basic использует переменные min и max для обозначения первого и последнего элементов списка, которые находятся не на своих местах. По мере того, как алгоритма повторяет проходы по списку, эти переменные обновляются, указывая положение последней перестановки.
Public Sub Bubblesort(List() As Long, ByVal min As Long, ByVal max As Long)
Dim last_swap As Long
Dim i As Long
Dim j As Long
Dim tmp As Long
‘ Повторять до завершения.
Do While min < max
‘ «Всплывание».
last_swap = min - 1
‘ То есть For i = min + 1 To max.
i = min + 1
Do While i <= max
‘ Найти «пузырек».
If List(i - 1) > List(i) Then
‘ Найти, куда его поместить.
tmp = List(i - 1)
j = i
Do
List(j - 1) = List(j)
j = j + 1
If j > max Then Exit Do
Loop While List(j) < tmp
List(j - 1) = tmp
last_swap = j - 1
i = j + 1
Else
i = i + 1
End If
Loop
‘ Обновить переменную max.
max = last_swap - 1
‘ «Погружение».
last_swap = max + 1
‘ То есть For i = max -1 To min Step -1
i = max - 1
Do While i >= min
‘ Найти «пузырек».
If List(i + 1) < List(i) Then
‘ Найти, куда его поместить.
tmp = List(i + 1)
j = i
Do
List(j + 1) = List(j)
j = j - 1
If j < min Then Exit Do
Loop While List(j) > tmp
List(j + 1) = tmp
last_swap = j + 1
i = j - 1
Else
i = i - 1
End If
Loop
‘ Обновить переменную min.
Min = last_swap + 1
Loop
End Sub
==========238
Для того чтобы протестировать алгоритм пузырьковой сортировки при помощи программы Sort, поставьте галочку в поле Sorted (Отсортированные) в области Initial Ordering (Первоначальный порядок). Введите число элементов в поле #Unsorted (Число несортированных). После нажатия на кнопку Go (Начать), программа создает и сортирует список, а затем переставляет случайно выбранные пары элементов. Например, если вы введете число 10 в поле #Unsorted, программа переставит 5 пар чисел, то есть 10 элементов окажутся не на своих местах.
Для второго варианта первоначального алгоритма, программа сохраняет элемент во временной переменной при перемещении на несколько шагов. Этот происходит еще быстрее, если использовать функцию API MemCopy. Алгоритм пузырьковой сортировки в программе FastSort, используя функцию MemCopy, сортирует элементы в 50 или 75 раз быстрее, чем первоначальная версия, реализованная в программе Sort.
В табл. 9.2 приведено время выполнения пузырьковой сортировки 2000 элементов на компьютере с процессором Pentium с тактовой частотой 90 МГц в зависимости от степени первоначальной упорядоченности списка. Из таблицы видно, что алгоритм пузырьковой сортировки обеспечивает хорошую производительность, только если список с самого начала почти отсортирован. Алгоритм быстрой сортировки, который описывается далее в этой главе, способен отсортировать тот же список из 2000 элементов примерно за 0,12 сек, независимо от первоначального порядка расположения элементов в списке. Пузырьковая сортировка может превзойти этот результат, только если примерно 97 процентов списка было упорядочено до начала сортировки.
=====239
@Таблица 9.2. Время пузырьковой сортировки 2.000 элементов
Несмотря на то, что пузырьковая сортировка медленнее, чем многие другие алгоритмы, у нее есть свои применения. Пузырьковая сортировка часто дает наилучшие результаты, если список изначально уже почти упорядочен. Если программа управляет списком, который сортируется при создании, а затем к нему добавляются новые элементы, пузырьковая сортировка может быть лучшим выбором.
Быстрая сортировка (quicksort) — рекурсивный алгоритм, который использует подход «разделяй и властвуй». Если сортируемый список больше, чем минимальный заданный размер, процедура быстрой сортировки разбивает его на два подсписка, а затем рекурсивно вызывает себя для сортировки двух подсписков.
Первая версия алгоритма быстрой сортировки, обсуждаемая здесь, достаточно проста. Если алгоритм вызывается для подсписка, содержащего не более одного элемента, то подсписок уже отсортирован, и подпрограмма завершает работу.
Иначе, процедура выбирает какой‑либо элемент из списка и использует его для разбиения списка на два подсписка. Она помещает элементы, которые меньше, чем выбранный элементы в первый подсписок, а остальные — во второй, и затем рекурсивно вызывает себя для сортировки двух подсписков.
Public Sub QuickSort(List() As Long, ByVal min as Integer, _
ByVal max As Integer)
Dim med_value As Long
Dim hi As Integer
Dim lo As Integer
‘ Если осталось менее 1 элемента, подсписок отсортирован.
If min >= max Then Exit Sub
‘ Выбрать значение для деления списка.
med_value = list(min)
lo = min
hi = max
Do
Просмотр от hi до значения < med_value.
Do While list(hi) >= med_value
hi = hi - 1
If hi <= lo Then Exit Do
Loop
If hi <= lo Then
list(lo) = med_value
Exit Do
End If
‘ Поменять местами значения lo и hi.
list(lo) = list(hi)
‘ Просмотр от lo до значения >= med_value.
lo = lo + 1
Do While list(lo) < med_values
lo = lo + 1
If lo >= hi Then Exit Do
Loop
If lo >= hi Then
lo = hi
list(hi) = med_value
Exit Do
End If
‘ Поменять местами значения lo и hi.
list(hi) = list(lo)
Loop
‘ Рекурсивная сортировка двух подлистов.
QuickSort list(), min, lo - 1
QuickSort list(), lo + 1, max
End Sub
=========240
Есть несколько важных моментов в этой версии алгоритма, которые стоит упомянуть. Во‑первых, значение med_value для деления списка не входит ни в один подсписок. Это означает, что в двух подсписках содержится на одни элемент меньше, чем в исходном списке. Т.к. число рассматриваемых элементов уменьшается, то в конечном итоге алгоритм завершит работу.
Эта версия алгоритма использует в качестве разделителя первый элемент в списке. В идеале, это значение должно было бы находиться где‑то в середине списка, так чтобы два подсписка были примерно равного размера. Тем не менее, если элементы первоначально почти отсортированы, то первый элемент — наименьший в списке. При этом алгоритм не поместит ни одного элемента в первый подсписок, и все элементы во второй. Последовательность действий алгоритма будет примерно такой, как показано на рис. 9.4.
В этом случае каждый вызов подпрограммы требует порядка O(N) шагов для перемещения всех элементов во второй подсписок. Т.к. алгоритм рекурсивно вызывает себя N - 1 раз, время его выполнения будет порядка O(N2), что не лучше, чем у ранее рассмотренных алгоритмов. Ситуацию еще более ухудшает то, что уровень вложенности рекурсии алгоритма N - 1. Для больших списков огромная глубина рекурсии приведет к переполнению стека и сбою в работе программы.
=========242
@Рис. 9.4. Быстрая сортировка упорядоченного списка
Существует много стратегий выбора разделительного элемента. Можно использовать элемент из середины списка. Это может оказаться неплохим выбором, тем не менее, может оказаться и так, что это окажется наименьший или наибольший элемент списка. При этом один подсписок будет намного больше, чем другой, что приведет к снижению производительности до порядка O(N2) и глубокому уровню рекурсии.
Другая стратегия может заключаться в том, чтобы просмотреть весь список, вычислить среднее арифметическое всех значений, и использовать его в качестве разделительного значения. Этот подход будет давать неплохие результаты, но потребует дополнительных усилий. Дополнительный проход со сложностью порядка O(N) не изменит теоретическое время выполнения алгоритма, но снизит общую производительность.
Третья стратегия — выбрать средний из элементов в начале, конце и середине списка. Преимущество этого подхода в быстроте, потому что потребуется выбрать всего три элемента. При этом гарантируется, что этот элемент не является наибольшим или наименьшим в списке, и вероятно окажется где‑то в середине списка.
И, наконец, последняя стратегия, которая используется в программе Sort, заключается в случайном выборе элемента из списка. Возможно, это будет неплохим выбором. Даже если это не так, возможно на следующем шаге алгоритм, возможно, сделает лучший выбор. Вероятность постоянного выпадения наихудшего случая очень мала.
Интересно, что этот метод превращает ситуацию «небольшая вероятность того, что всегда будет плохая производительность» в ситуацию «всегда небольшая вероятность плохой производительности». Это довольно запутанное утверждение объясняется в следующих абзацах.
При использовании других методов выбора точки раздела, существует небольшая вероятность того, что при определенной организации списка время сортировки будет порядка O(N2), Хотя маловероятно, что подобная организация списка в начале сортировки встретится на самом деле, тем не менее, время выполнения при этом будет определенно порядка O(N2), неважно почему. Это то, что можно назвать «небольшой вероятностью того, что всегда будет плохая производительность».
===========242
При случайном выборе точки раздела первоначальное расположение элементов не влияет на производительность алгоритма. Существует небольшая вероятность неудачного выбора элемента, но вероятность того, что это будет происходить постоянно, чрезвычайно мала. Это можно обозначить как «всегда небольшая вероятность плохой производительности». Независимо от первоначальной организации списка, очень маловероятно, что производительность алгоритма будет порядка O(N2).
Тем не менее, все еще остается ситуация, которая может вызвать проблемы при использовании любого из этих методов. Если в списке очень мало различных значений в списке, алгоритм заносит множество одинаковых значений в подсписок при каждом вызове. Например, если каждый элемент в списке имеет значение 1, последовательность выполнения будет такой, как показано на рис. 9.5. Это приводит к большому уровню вложенности рекурсии и дает производительность порядка O(N2).
Похожее поведение происходит также при наличии большого числа повторяющихся значений. Если список состоит из 10.000 элементов со значениями от 1 до 10, алгоритм довольно быстро разделит список на подсписки, каждый из которых содержит только одно значение.
Наиболее простой выход — игнорировать эту проблему. Если вы знаете, что данные не имеют такого распределения, то проблемы нет. Если данные имеют небольшой диапазон значений, то вам стоит рассмотреть другой алгоритм сортировки. Описываемые далее в этой главе алгоритмы сортировки подсчетом и блочной сортировки очень быстро сортируют списки, данных в которых находятся в узком диапазоне.
Можно внести еще одно небольшое улучшение в алгоритм быстрой сортировки. Подобно многих другим более сложным алгоритмам, описанным далее в этой главе, быстрая сортировка — не самый лучший выбор для сортировки небольших списков. Благодаря своей простоте, сортировка выбором быстрее при сортировке примерно десятка записей.
@Рис. 9.5. Быстрая сортировка списка из единиц
==========243
@Таблица 9.3. Время быстрой сортировки 20.000 элементов
Можно улучшить производительность быстрой сортировки, если прекратить рекурсию до того, как подсписки уменьшатся до нуля, и использовать для завершения работы сортировку выбором. В табл. 9.3 приведено время, которое занимает выполнение быстрой сортировки 20.000 элементов на компьютере с процессором Pentium с тактовой частотой 90 МГц, если останавливать сортировку при достижении подсписками определенного размера. В этом тесте оптимальное значение этого параметра было равно 15.
Следующий код демонстрирует доработанный алгоритм:
Public Sub QuickSort*List() As Long, ByVal min As Long, ByVal max As Long)
Dim med_value As Long
Dim hi As Long
Dim lo As Long
Dim i As Long
‘ Если в списке больше, чем CutOff элементов,
‘ завершить его сортировку процедурой SelectionSort.
If max - min < cutOff Then
SelectionSort List(), min, max
Exit Sub
End If
‘ Выбрать разделяющее значение.
i = Int((max - min + 1) * Rnd + min)
med_value = List(i)
‘ Переместить его вперед.
List(i) = List(min)
lo = min
hi = max
Do
‘ Просмотр сверху вниз от hi до значения < med_value.
Do While List(hi) >= med_value
hi = hi - 1
If hi <= lo Then Exit Do
Loop
If hi <= lo Then
List(lo) = med_value
Exit Do
End If
‘ Поменять местами значения lo и hi.
List(lo) = List(hi)
‘ Просмотр снизу вверх от lo до значения >= med_value.
lo = lo + 1
Do While List(lo) < med_value
lo = lo + 1
If lo >= hi Then Exit Do
Loop
If lo >= hi Then
lo = hi
List(hi) = med_value
Exit Do
End If
‘ Поменять местами значения lo и hi.
List(hi) = List(lo)
Loop
‘ Сортировать два подсписка.
QuickSort List(), min, lo - 1
QuickSort List(), lo + 1, max
End Sub
=======244
Многие программисты выбирают алгоритм быстрой сортировки, т.к. он дает хорошую производительность в большинстве обстоятельств.
Как и быстрая сортировка, сортировка слиянием (mergesort) — это рекурсивный алгоритм. Он также разделяет список на два подсписка, и рекурсивно сортирует подсписки.
Сортировка слиянием делит список пополам, формируя два подсписка одинакового размера. Затем подсписки рекурсивно сортируются, и отсортированные подсписки сливаются, образуя полностью отсортированный список.
Хотя этап слияния легко понять, это наиболее интересная часть алгоритма. Подсписки сливаются во временный массив, и результат копируется в первоначальный список. Создание временного массива может быть недостатком, особенно если размер элементов велик. Если размер временного размера очень большой, он может приводить к обращению к файлу подкачки и значительно снижать производительность. Работа с временным массивом также приводит к тому, что большая часть времени уходит на копирование элементов между массивами.
Так же, как и в случае с быстрой сортировкой, можно ускорить выполнение сортировки слиянием, остановив рекурсию, когда подсписки достигают определенного минимального размера. Затем можно использовать сортировку выбором для завершения работы.
=========245
Public Sub Mergesort(List() As Long, Scratch() As Long, _
ByVal min As Long, ByVal max As Long)
Dim middle As Long
Dim i1 As Long
Dim i2 As Long
Dim i3 As Long
‘ Если в списке больше, чем CutOff элементов,
‘ завершить его сортировку процедурой SelectionSort.
If max - min < CutOff Then
Selectionsort List(), min, max
Exit Sub
End If
‘ Рекурсивная сортировка подсписков.
middle = max \ 2 + min \ 2
Mergesort List(), Scratch(), min, middle
Mergesort List(), Scratch(), middle + 1, max
‘ Слить отсортированные списки.
i1 = min ‘ Индекс списка 1.
i2 = middle + 1 ‘ Индекс списка 2.
i3 = min ‘ Индекс объединенного списка.
Do While i1 <= middle And i2 <= max
If List(i1) <= List(i2) Then
Scratch(i3) = List(i1)
i1 = i1 + 1
Else
Scratch(i3) = List(i2)
i2 = i2 + 1
end If
i3 = i3 + 1
Loop
‘ Очистка непустого списка.
Do While i1 <= middle
Scratch(i3) = List(i1)
i1 = i1 + 1
i3 = i3 + 1
Loop
Do While i2 <= max
Scratch(i3) = List(i2)
i2 = i2 + 1
i3 = i3 + 1
Loop
‘ Поместить отсортированный список на место исходного.
For i3 = min To max
List(i3) = Scratch(i3)
Next i3
End Sub
========246
Сортировка слиянием тратит много времени на копирование временного массива на место первоначального. Программа FastSort использует функцию API MemCopy, чтобы немного ускорить эту операцию.
Даже с использованием функции MemCopy, сортировка слиянием немного медленнее, чем быстрая сортировка. В нашем тесте на компьютере с процессором Pentium с тактовой частотой 90 МГц, сортировка слиянием потребовала 2,95 сек для упорядочения 30.000 элементов со значениями в диапазоне от 1 до 10.000. Быстрая сортировка потребовала всего 2,44 сек.
Преимущество сортировки слиянием в том, что время ее выполнения остается одинаковым независимо от различных распределений и начального расположения данных. Быстрая же сортировка дает производительность порядка O(N2) и достигает глубокого уровня вложенности рекурсии, если список содержит много одинаковых значений. Если список большой, быстрая сортировка может переполнить стек и привести к аварийному завершению работы программы. Сортировка слиянием никогда не достигает слишком глубокого уровня вложенности рекурсии, т.к. всегда делит список на равные части. Для списка из N элементов, глубина вложенности рекурсии для сортировки слиянием составляет всего лишь log(N).
В другом тесте, в котором использовались 30.000 элементов со значениями от 1 до 100, сортировка слиянием потребовала столько же времени, сколько и для элементов со значениями от 1 до 10.000 — 2,95 секунд. Быстрая сортировка заняла 15,82 секунды. Если значения лежали между 1 и 50, сортировка слиянием потребовала 2,95 секунд, тогда как быстрая сортировка — 138,52 секунды.
Пирамидальная сортировка (heapsort) использует специальную структуру, называемую пирамидой (heap), для организации элементов в списке. Пирамиды интересны сами по себе и полезны при реализации приоритетных очередей.
В начале этой главы описываются пирамиды, и объясняется, как вы можете реализовать пирамиды на языке Visual Basic. Затем показано, как использовать пирамиду для построения эффективной приоритетной очереди. Располагая средствами для управления пирамидами и приоритетными очередями, легко реализовать алгоритм пирамидальной сортировки.
Пирамида (heap) — это полное двоичное дерево, в котором каждый узел не меньше, чем оба его потомка. Это ничего не говорит о взаимосвязи между потомками. Они должны быть меньше родителя, но любой из них может быть больше, чем другой. На рис. 9.6 показана небольшая пирамида.
Поскольку каждый узел не меньше, чем два нижележащих узла, корень дерева — всегда наибольший элемент в пирамиде. Это делает пирамиды удобной структурой данных для реализации приоритетных очередей. Если вам нужен элемент очереди с самым высоким приоритетом, он всегда находится на вершине пирамиды.
=========247
Рис. 9.6. Пирамида
Поскольку пирамида является полным двоичным деревом, вы можете использовать методы, изложенные в 6 главе, для сохранения пирамиды в массиве. Поместите корневой узел в 1 позицию массива. Потомки узла I размещаются в позициях 2 * I и 2 * I + 1. Рис. 9.7 показывает пирамиду с рис. 9.6, записанную в виде массива.
Чтобы понять, как устроена пирамида, заметим, что пирамида создана из пирамид меньшего размера. Поддерево, начинающееся с любого узла пирамиды, также является пирамидой. Например, в пирамиде, показанной на рис. 9.8, поддерево с корнем в узле 13 также является пирамидой.
Используя этот факт, можно построить пирамиду снизу вверх. Вначале, разместим элементы в виде дерева, как показано на рис. 9.9. Затем организуем пирамиды из небольших поддеревьев внизу дерева. Поскольку в них всего по три узла, сделать это достаточно просто. Сравним вершину с каждым из потомков. Если один из потомков больше, он меняется местами с родителем. Если оба потомка больше, больший потомок меняется местами с родителем. Этот шаг повторяется до тех пор, пока все поддеревья, имеющие по 3 узла, не будут преобразованы в пирамиды, как показано на рис. 9.10.
Теперь объединим маленькие пирамиды для создания более крупных пирамид. Соединим на рис. 9.10 маленькие пирамиды с вершинами 15 и 5 и элемент, создав пирамиду большего размера. Сравним новую вершину 7 с каждым из потомков. Если один из потомков больше, поменяем его местами с вершиной. В нашем случае 15 больше, чем 7 и 4, поэтому узел 15 меняется местами с узлом 7.
Поскольку правое поддерево, начинающееся с узла 4, не изменялось, это поддерево по‑прежнему является пирамидой. Левое же поддерево изменилось. Чтобы определить, является ли оно все еще пирамидой, сравним его новую вершину 7 с потомками 13 и 12. Поскольку 13 больше, чем 7 и 12, необходимо поменять местами узлы 7 и 13.
@Рис. 9.7. Представление пирамиды в виде массива
========248
@Рис. 9.8. Пирамида образуется из меньших пирамид
@Рис. 9.9. Неупорядоченный список в полном дереве
@Рис. 9.10. Поддеревья второго уровня являются пирамидами
=========249
@Рис. 9.11. Объединение пирамид в пирамиду большего размера
Если поддерево выше, можно продолжить перемещение узла 7 вниз по поддереву. В конце концов, либо будет достигнута точка, в которой узел 7 больше обоих своих потомков, либо алгоритм достигнет основания дерева. На рис. 9.11 показано дерево после преобразования этого поддерева в пирамиду.
Продолжим объединение пирамид, образуя пирамиды большего размера до тех пор, пока все элементы не образуют одну большую пирамиду, такую как на рис. 9.6.
Следующий код перемещает элемент из положения List(min) вниз по пирамиде. Если поддеревья ниже List(min) являются пирамидами, то процедура сливает пирамиды, образуя пирамиду большего размера.
Private Sub HeapPushDown(List() s Long, ByVal min As Long, _
ByVal max As Long)
Dim tmp As Long
Dim j As Long
tmp = List(min)
Do
j = 2 * min
If j <= max Then
‘ Разместить в j указатель на большего потомка.
If j < max Then
If List(j + 1) > List(j) Then _
j = j + 1
End If
If List(j) > tmp Then
‘ Потомок больше. Поменять его местами с родителем.
List(min) = List(j)
‘ Перемещение этого потомка вниз.
min = j
Else
‘ Родитель больше. Процедура закончена.
Exit Do
End If
Else
Exit Do
End If
Loop
List(min) = tmp
End Sub
Полный алгоритм, использующий процедуру HeapPushDown для создания пирамиды из дерева элементов, необычайно прост:
Private Sub BuildHeap()
Dim i As Integer
For i = (max + min) \ 2 To min Step -1
HeapPushDown list(), i, max
Next i
End Sub
Приоритетной очередью (priority queue) легко управлять при помощи процедур BuildHeap и HeapPushDown. Если в качестве приоритетной очереди используется пирамида, легко найти элемент с самым высоким приоритетом — он всегда находится на вершине пирамиды. Но если его удалить, получившееся дерево без корня уже не будет пирамидой.
Для того, чтобы снова превратить дерево без корня в пирамиду, возьмем последний элемент (самый правый элемент на нижнем уровне) и поместим его на вершину пирамиды. Затем при помощи процедуры HeapPushDown продвинем новый корневой узел вниз по дереву до тех пор, пока дерево снова не станет пирамидой. В этот момент, можно получить на выходе приоритетной очереди следующий элемент с наивысшим приоритетом.
Public Function Pop() As Long
If NumInQueue < 1 Then Exit Function
' Удалить верхний элемент.
Pop = Pqueue(1)
' Переместить последний элемент на вершину.
PQueue(1) = PQueue(NumInPQueue)
NumInPQueue = NumInPQueue - 1
' Снова сделать дерево пирамидой.
HeapPushDown PQueue(), 1, NumInPQueue
End Function
Чтобы добавить новый элемент к приоритетной очереди, увеличьте пирамиду. Поместите новый элемент на свободное место в конце массива. Полученное дерево также не будет пирамидой.
Чтобы снова преобразовать его в пирамиду, сравните новый элемент с его родителем. Если новый элемент больше, поменяйте их местами. Заранее известно, что второй потомок меньше, чем родитель, поэтому нет необходимости сравнивать новый элемент с другим потомком. Если элемент больше родителя, то он также больше и второго потомка.
Продолжайте сравнение нового элемента с родителем и перемещение его по дереву, пока не найдется родитель, больший, чем новый элемент. В этот момент, дерево снова представляет собой пирамиду, и приоритетная очередь готова к работе.
Private Sub HeapPushUp(List() As Long, ByVal max As Integer)
Dim tmp As Long
Dim j As Integer
tmp = List (max)
Do
j = max \ 2
If j < 1 Then Exit Do
If List(j) < tmp Then
List (max) = List(j)
max = j
Else
Exit Do
End If
Loop
List(max) = tmp
End Sub
Подпрограмма Push добавляет новый элемент к дереву и использует подпрограмму HeapPushDown для восстановления пирамиды.
Public Sub Push (value As Long)
NumInPQueue = NumInPQueue + 1
If NumInPQueue > PQueueSize Then ResizePQueue
PQueue(NumInPQueue) = value
HeapPushUp PQueue(), NumInPQueue
End Sub
========252
При первоначальном превращении списка в пирамиду, это осуществляется при помощи создания множества пирамид меньшего размера. Для каждого внутреннего узла дерева строится пирамида с корнем в этом узле. Если дерево содержит N элементов, то в дереве O(N) внутренних узлов, и в итоге приходится создать O(N) пирамид.
При создании каждой пирамиды может потребоваться продвигать элемент вниз по пирамиде, возможно до тех пор, пока он не достигнет концевого узла. Самые высокие из построенных пирамид будут иметь высоту порядка O(log(N)). Так как создается O(N) пирамид, и для построения самой высокой из них требуется O(log(n)) шагов, то все пирамиды можно построить за время порядка O(N * log(N)).
На самом деле времени потребуется еще меньше. Только некоторые пирамиды будут иметь высоту порядка O(log(N)). Большинство из них гораздо ниже. Только одна пирамида имеет высоту, равную log(N), и половина пирамид — высоту всего в 2 узла. Если суммировать все шаги, необходимые для создания всех пирамид, в действительности потребуется не больше O(N) шагов.
Чтобы увидеть, так ли это, допустим, что дерево содержит N узлов. Пусть H — высота дерева. Это полное двоичное дерево, следовательно, H=log(N).
Теперь предположим, что вы строите все большие и большие пирамиды. Для каждого узла, который находится на расстоянии H-I уровней от корня дерева, строится пирамида с высотой I. Всего таких узлов 2H-I, и всего создается 2H-I пирамид с высотой I.
Для построения этих пирамид может потребоваться передвигать элемент вниз до тех пор, пока он не достигнет концевого узла. Перемещение элемента вниз по пирамиде с высотой I требует до I шагов. Для пирамид с высотой I полное число шагов, которое потребуется для построения 2H-I пирамид, равно I*2H-I.
Сложив все шаги, затрачиваемые на построение пирамид разного размера, получаем 1*2H-1+2*2H-2+3*2H-3+…+(H-1)* 21. Вынеся за скобки 2H, получим 2H*(1/2+2/22+3/23+…+(H-1)/2H-1).
Можно показать, что (1/2+2/22+3/23+…+(H-1)/2H-1) меньше 2. Тогда полное число шагов, которое нужно для построения всех пирамид, меньше, чем 2H*2. Так как H — высота дерева, равная log(N), то полное число шагов меньше, чем 2log(N)*2=N*2. Это означает, что для первоначального построения пирамиды требуется порядка O(N) шагов.
Для удаления элемента из приоритетной очереди, последний элемент перемещается на вершину дерева. Затем он продвигается вниз, пока не займет свое окончательное положение, и дерево снова не станет пирамидой. Так как дерево имеет высоту log(N), процесс может занять не более log(N) шагов. Это означает, что новый элемент к приоритетной очереди на основе пирамиды можно добавить за O(log(N)) шагов.
Другим способом работы с приоритетными очередями является использование упорядоченного списка. Вставка или удаление элемента из упорядоченного списка с миллионом элементов занимает примерно миллион шагов. Вставка или удаление элемента из сопоставимой по размерам приоритетной очереди, основанной на пирамиде, занимает всего 20 шагов.
======253
Алгоритм пирамидальной сортировки просто использует уже описанные алгоритмы для работы с пирамидами. Идея состоит в том, чтобы создать приоритетную очередь и последовательно удалять по одному элементу из очереди.
Для удаления элемента алгоритм меняет его местами с последним элементом в пирамиде. Это помещает удаленный элемент в конечное положение в конце массива. Затем алгоритм уменьшает счетчик элементов списка, чтобы исключить из рассмотрения последнюю позицию
После того, как наибольший элемент поменялся местами с последним, массив больше не является пирамидой, так как новый элемент на вершине может оказаться меньше, чем его потомки. Поэтому алгоритм использует процедуру HeapPushDown для продвижения элемента на его место. Алгоритм продолжает менять элементы местами и восстанавливать пирамиду до тех пор, пока в пирамиде не останется элементов.
Public Sub Heapsort(List() As Long, ByVal min As Long, ByVal max As Long)
Dim i As Long
Dim tmp As Long
' Создать пирамиду (кроме корневого узла).
For i = (max + min) \ 2 To min + 1 Step -1
HeapPushDown List(), i, max
Next i
' Повторять:
' 1. Продвинуться вниз по пирамиде.
' 2. Выдать корень.
For i = max To min + 1 Step -1
' Продвинуться вниз по пирамиде.
HeapPushDown List(), min, i
' Выдать корень.
tmp = List(min)
List(min) = List(i)
List(i) = tmp
Next i
End Sub
Предыдущее обсуждение приоритетных очередей показало, что первоначальное построение пирамиды требует O(N) шагов. После этого требуется O(log(N)) шагов для восстановления пирамиды, когда элемент продвигается на свое место. Пирамидальная сортировка выполняет это действие N раз, поэтому требуется всего порядка O(N)*O(log(N))=O(N*log(N)) шагов, чтобы получить из пирамиды упорядоченный список. Полное время выполнения для алгоритма пирамидальной сортировки составляет порядка O(N)+O(N*log(N))=O(N*log(N)).
=========254
Такой же порядок сложности имеет алгоритм сортировки слиянием и в среднем алгоритм быстрой сортировки. Так же, как и сортировка слиянием, пирамидальная сортировка тоже не зависит от значений или распределения элементов до начала сортировки. Быстрая сортировка плохо работает со списками, содержащими большое число одинаковых элементов, а сортировка слиянием и пирамидальная сортировка лишены этого недостатка.
Хотя обычно пирамидальная сортировка работает немного медленнее, чем сортировка слиянием, для нее не требуется дополнительного пространства для хранения временных значений, как для сортировки слиянием. Пирамидальная сортировка создает первоначальную пирамиду и упорядочивает элементы в пределах исходного массива списка.
Сортировка подсчетом (countingsort) — специализированный алгоритм, который очень хорошо работает, если элементы данных — целые числа, значения которых находятся в относительно узком диапазоне. Этот алгоритм работает достаточно быстро, например, если значения находятся между 1 и 1000.
Если список удовлетворяет этим требованиям, сортировка подсчетом выполняется невероятно быстро. В одном из тестов на компьютере с процессором Pentium с тактовой частотой 90 МГц, быстрая сортировка 100.000 элементов со значениями между 1 и 1000 заняла 24,44 секунды. Для сортировки тех же элементов сортировке подсчетом потребовалось всего 0,88 секунд — в 27 раз меньше времени.
Выдающаяся скорость сортировки подсчетом достигается за счет того, что при этом не используются операции сравнения. Ранее в этой главе отмечалось, что время выполнения любого алгоритма сортировки, использующего операции сравнения, порядка O(N*log(N)). Без использования операций сравнения, алгоритм сортировки подсчетом позволяет упорядочивать элементы за время порядка O(N).
Сортировка подсчетом начинается с создания массива для подсчета числа элементов, имеющих определенное значение. Если значения находятся в диапазоне между min_value и max_value, алгоритм создает массив Counts с нижней границей min_value и верхней границей max_value. Если используется массив из предыдущего прохода, необходимо обнулить значения его элементов. Если существует M значений элементов, массив содержит M записей, и время выполнения этого шага порядка O(M).
For i = min To max
Counts(List(i)) = Counts(List(i)) + 1
Next i
В конце концов, алгоритм обходит массив Counts, помещая соответствующее число элементов в отсортированный массив. Для каждого значения i между min_value и max_value, он помещает Counts(i) элементов со значением i в массив. Так как этот шаг помещает по одной записи в каждую позицию в массиве, он требует порядка O(N) шагов.
new_index = min
For i = min_value To max_value
For j = 1 To Counts(i)
sorted_list(new_index) = i
new_index = new_index + 1
Next j
Next i
======255
Алгоритм целиком требует порядка O(M)+O(N)+O(N)=O(M+N) шагов. Если M мало по сравнению с N, он выполняется очень быстро. Например, если M<N, то O(M+N)=O(N), что довольно быстро. Если N=100.000 и M=1000, то M+N=101.000, тогда как N*log(N)=1,6 миллиона. Шаги, выполняемые алгоритмом сортировки подсчетом, также относительно просты по сравнению с шагами быстрой сортировки. Все эти факты объединяются, обеспечивая вместе невероятно высокую скорость выполнения сортировки подсчетом.
С другой стороны, если M больше, чем O(N*log(N)), тогда O(M+N) будет больше, чем O(N*log(N)). В этом случае сортировка подсчетом может оказаться медленнее, чем алгоритмы со сложностью порядка O(N*log(N)), такие как быстрая сортировка. В одном из тестов быстрая сортировка 1000 элементов со значениями от 1 до 500.000 потребовал 0,054 сек, в то время как сортировка подсчетом потребовала 1,76 секунд.
Сортировка подсчетом опирается на тот факт, что значения данных — целые числа, поэтому этот алгоритм не может просто сортировать данные других типов. В Visual Basic нельзя создать массив с границами от AAA до ZZZ.
Ранее в этой главе в разделе «объединение и сжатие ключей» было продемонстрировано, как можно кодировать строковые данные при помощи целых чисел. Если вы может закодировать данные при помощи данных типа Integer или Long, вы все еще можете использовать сортировку подсчетом.
Как и сортировка подсчетом, блочная сортировка (bucketsort) не использует операций сравнения элементов. Этот алгоритм использует значения элементов для разбиения их на блоки, и затем рекурсивно сортирует полученные блоки. Когда блоки становятся достаточно малыми, алгоритм останавливается и использует более простой алгоритм типа сортировки выбором для завершения процесса.
По смыслу этот алгоритм похож на быструю сортировку. Быстрая сортировка разделяет элементы на два подсписка и рекурсивно сортирует подсписки. Блочная сортировка делает то же самое, но делит список на множество блоков, а не на всего лишь два подсписка.
Для деления списка на блоки, алгоритм предполагает, что значения данных распределены равномерно, и распределяет элементы по блокам равномерно. Например, предположим, что данные имеют значения в диапазоне от 1 до 100 и алгоритм использует 10 блоков. Алгоритм помещает элементы со значениями 1‑10 в первый блок, со значениями 11‑20 — во второй, и т.д. На рис. 9.12 показан список из 10 элементов со значениями от 1 до 100, которые расположены в 10 блоках.
@Рис. 9.12. Расположение элементов в блоках.
=======256
Если элементы распределены равномерно, в каждый блок попадает примерно одинаковое число элементов. Если в списке N элементов, и алгоритм использует N блоков, в каждый блок попадает всего один или два элемента. Программа может отсортировать их за конечное число шагов, поэтому время выполнения алгоритма в целом порядка O(N).
На практике, распределение данных обычно не является равномерным. В некоторые блоки попадает больше элементов, в другие меньше. Тем не менее, если распределение в целом близко к равномерному, то в каждом из блоков окажется лишь небольшое число элементов.
Проблемы могут возникать, только если список содержит небольшое число различных значений. Например, если все элементы имеют одно и то ж значение, они все будут помещены в один блок. Если алгоритм не обнаружит это, он снова и снова будет помещать все элементы в один и тот же блок, вызвав бесконечную рекурсию и исчерпав все стековое пространство.
Реализовать алгоритм блочной сортировки на Visual Basic можно различными способами. Во-первых, можно использовать в качестве блоков связные списки. Это облегчает перемещение элементов между блоками в процессе работы алгоритма.
Этот метод может быть более сложным, если элементы изначально расположены в массиве. В этом случае, необходимо перемещать элементы из массива в связный список и обратно в массив после завершения сортировки. Для создания связного списка также требуется дополнительная память. Следующий код демонстрирует алгоритм блочной сортировки с применением связных списков:
Public Sub LinkBucketSort(ListTop As ListCell)
Dim count As Long
Dim min_value As Long
Dim max_value As Long
Dim Value As Long
Dim item As ListCell
Dim nxt As ListCell
Dim bucket() As New ListCell
Dim value_scale As Double
Dim bucket.num As Long
Dim i As Long
Set item = ListTop.NextCell
If item Is Nothing Then Exit Sub
' Подсчитать элементы и найти значения min и max.
count = 1
min_value = item.Value
max_value = min_value
Set item = item.NextCell
Do While Not (item Is Nothing)
count = count + 1
Value = item.Value
If min_value > Value Then min_value = Value
If max_value < Value Then max_value = Value
Set item = item.NextCell
Loop
' Если min_value = max_value, значит, есть единственное
' значение, и список отсортирован.
If min_value = max_value Then Exit Sub
' Если в списке не более, чем CutOff элементов,
' завершить сортировку процедурой LinkInsertionSort.
If count <= CutOff Then
LinkInsertionSort ListTop
Exit Sub
End If
' Создать пустые блоки.
ReDim bucket(1 To count)
value_scale = _
CDbl(count - 1) / _
CDbl(max_value - min_value)
' Разместить элементы в блоках.
Set item = ListTop.NextCell
Do While Not (item Is Nothing)
Set nxt = item.NextCell
Value = item.Value
If Value = max_value Then
bucket_num = count
Else
bucket_num = _
Int((Value - min_value) * _
value_scale) + 1
End If
Set item.NextCell = bucket (bucket_num).NextCell
Set bucket(bucket_num).NextCell = item
Set item = nxt
Loop
' Рекурсивная сортировка блоков, содержащих
' более одного элемента.
For i = 1 To count
If Not (bucket(i).NextCell Is Nothing) Then _
LinkBucketSort bucket(i)
Next i
' Объединить отсортированные списки.
Set ListTop.NextCell = bucket(count).NextCell
For i = count - 1 To 1 Step -1
Set item = bucket(i).NextCell
If Not (item Is Nothing) Then
Do While Not (item.NextCell Is Nothing)
Set item = item.NextCell
Loop
Set item.NextCell = ListTop.NextCell
Set ListTop.NextCell= bucket(i).NextCell
End If
Next i
End Sub
=========257-258
Эта версия блочной сортировки намного быстрее, чем сортировка вставкой с использованием связных списков. В тесте на компьютере с процессором Pentium с тактовой частотой 90 МГц сортировке вставкой потребовалось 6,65 секунд для сортировки 2000 элементов, блочная сортировка заняла 1,32 секунды. Для более длинных списков разница будет еще больше, так как производительность сортировки вставкой порядка O(N2).
Блочную сортировку также можно реализовать в массиве, используя идеи подобные тем, которые используются при сортировке подсчетом. При каждом вызове алгоритма, вначале подсчитывается число элементов, которые относятся к каждому блоку. Потом на основе этих данных рассчитываются смещения во временном массиве, которые затем используются для правильного расположения элементов в массиве. В конце концов, блоки рекурсивно сортируются, и отсортированные данные перемещаются обратно в исходный массив.
Public Sub ArrayBucketSort(List() As Long, Scratch() As Long, _
min As Long, max As Long, NumBuckets As Long)
Dim counts() As Long
Dim offsets() As Long
Dim i As Long
Dim Value As Long
Dim min_value As Long
Dim max_value As Long
Dim value_scale As Double
Dim bucket_num As Long
Dim next_spot As Long
Dim num_in_bucket As Long
' Если в списке не более чем CutOff элементов,
' закончить сортировку процедурой SelectionSort.
If max - min + 1 < CutOff Then
Selectionsort List(), min, max
Exit Sub
End If
' Найти значения min и max.
min_value = List(min)
max_value = min_value
For i = min + 1 To max
Value = List(i)
If min_value > Value Then min_value = Value
If max_value < Value Then max_value = Value
Next i
' Если min_value = max_value, значит, есть единственное
' значение, и список отсортирован.
If min_value = max_value Then Exit Sub
' Создать пустой массив с отсчетами блоков.
ReDim counts(l To NumBuckets)
value_scale = _
CDbl (NumBuckets - 1) / _
CDbl (max_value - min_value)
' Создать отсчеты блоков.
For i = min To max
If List(i) = max_value Then
bucket_num = NumBuckets
Else
bucket_num = _
Int((List(i) - min_value) * _
value_scale) + 1
End If
counts(bucket_num) = counts(bucket_num) + 1
Next i
' Преобразовать отсчеты в смещение в массиве.
ReDim offsets(l To NumBuckets)
next_spot = min
For i = 1 To NumBuckets
offsets(i) = next_spot
next_spot = next_spot + counts(i)
Next i
' Разместить значения в соответствующих блоках.
For i = min To max
If List(i) = max_value Then
bucket_num = NumBuckets
Else
bucket_num = _
Int((List(i) - min_value) * _
value_scale) + 1
End If
Scratch (offsets (bucket_num)) = List(i)
offsets(bucket_num) = offsets(bucket_num) + 1
Next i
' Рекурсивная сортировка блоков, содержащих
' более одного элемента.
next_spot = min
For i = 1 To NumBuckets
If counts(i) > 1 Then ArrayBucketSort _
Scratch(), List(), next_spot, _
next_spot + counts(i) - 1, counts(i)
next_spot = next_spot + counts(i)
Next i
' Скопировать временный массив назад в исходный список.
For i = min To max
List(i) = Scratch(i)
Next i
End Sub
Из‑за накладных расходов, которые требуются для работы со связными списками, эта версия блочной сортировки работает намного быстрее, чем версия с использованием связных списков. Тем не менее, используя методы работы с псевдоуказателями, описанные во 2 главе, можно улучшить производительность версии с использованием связных списков, так что обе версии станут практически эквивалентными по скорости.
Новую версию также можно сделать еще быстрее, используя функцию API MemCopy для копирования элементов из временного массива обратно в исходный список. Эта усовершенствованную версию алгоритма демонстрирует программа FastSort.
===========259-261
В таб. 9.4 приведены преимущества и недостатки алгоритмов сортировки, описанных в этой главе, из которых можно вывести несколько правил, которые могут помочь вам выбрать алгоритм сортировки.
Эти правила, изложенные в следующем списке, и информация в табл. 9.4 может помочь вам подобрать алгоритм, который обеспечит максимальную производительность:
* если вам нужно быстро реализовать алгоритм сортировки, используйте быструю сортировку, а затем при необходимости поменяйте алгоритм;
* если более 99 процентов списка уже отсортировано, используйте пузырьковую сортировку;
* если список очень мал (100 или менее элементов), используйте сортировку выбором;
* если значения находятся в связном списке, используйте блочную сортировку на основе связного списка;
* если элементы в списке — целые числа, разброс значений которых невелик (до нескольких тысяч), используйте сортировку подсчетом;
* если значения лежат в широком диапазоне и не являются целыми числами, используйте блочную сортировку на основе массива;
* если вы не можете тратить дополнительную память, которая требуется для блочной сортировки, используйте быструю сортировка
Если вы знаете структуру данных и различные алгоритмы сортировки, вы можете выбрать алгоритм, наиболее подходящий для ваших нужд.
@Таблица 9.4. Преимущества и недостатки алгоритмов сортировки
=========263
Глава 10. ПоискПосле того, как список элементов отсортирован, может понадобиться найти определенный элемент в списке. В этой главе описаны некоторые алгоритмы для поиска элементов в упорядоченных списках. Она начинается с краткого описания сортировки методом полного перебора. Хотя этот алгоритм выполняется не так быстро, как другие, метод полного перебора является очень простым, что облегчает его реализацию и отладку. Из‑за простоты этого метода, сортировка полным перебором также выполняется быстрее других алгоритмов для очень маленьких списков.
Далее в главе описан двоичный поиск. При двоичном поиске список многократно разбивается на части, при этом для больших списков такой поиск выполняется намного быстрее, чем полный перебор. Заключенная в этом методе идея достаточно проста, но реализовать ее довольно сложно.
Затем в главе описан интерполяционный поиск. Так же, как и в методе двоичного поиска, исходный список при этом многократно разбивается на части. При использовании интерполяционного поиска, алгоритм делает предположения о том, где может находиться искомый элемент, поэтому он выполняется намного быстрее, если данные в списках распределены равномерно.
В конце главы обсуждаются методы следящего поиска. Применение этого метода иногда уменьшает время поиска в несколько раз.
Программа Search демонстрирует все описанные в главе алгоритмы. Введите значение элементов, которые должен содержать список, и затем нажмите на кнопку Make List (Создать список), и программа создаст список на основе массива, в котором каждый элемент больше предыдущего на число от 0 до 5. Программа выводит значение наибольшего элемента в списке, чтобы вы представляли диапазон значений элементов.
После создания списка выберите алгоритмы, которые вы хотите использовать, установив соответствующие флажки. Затем введите значение, которое вы хотите найти и нажмите на кнопку Search (Поиск), и программа выполнит поиск элемента при помощи выбранного вами алгоритма. Так как список содержит не все возможные элементы в заданном диапазоне значений, то вам может понадобиться ввести несколько различных значений, прежде чем одно из них найдется в списке.
Программа также позволяет задать число повторений для каждого из алгоритмов поиска. Некоторые алгоритмы выполняются очень быстро, поэтому для того, чтобы сравнить их скорость, может понадобиться задать для них большое число повторений.
=======265
На рис. 10.1 показано окно программы Search после поиска элемента со значением 250.000. Этот элемент находился на позиции 99.802 в списке из 100.000 элементов. Чтобы найти этот элемент, потребовалось проверить 99.802 элемента при использовании алгоритма полного перебора, 16 элементов — при использовании двоичного поиска и всего 3 — при выполнении интерполяционного поиска.
При выполнении линейного (linear) поиска или поиска методом полного перебора (exhaustive search), поиск ведется с начала списка, и элементы перебираются последовательно, пока среди них не будет найден искомый.
Public Function LinearSearch(target As Long) As Long
Dim i As Long
For i = 1 To NumItems
If List(i) >= target Then Exit For
Next i
If i > NumItems Then
Search = 0 ' Элемент не найден.
Else
Search = i ' Элемент найден.
End If
End Function
Так как этот алгоритм проверяет элементы последовательно, то он находит элементы в начале списка быстрее, чем элементы, расположенные в конце. Наихудший случай для этого алгоритма возникает, если элемент находится в конце списка или вообще не присутствует в нем. В этих случаях, алгоритм проверяет все элементы в списке, поэтому время его выполнения сложность в наихудшем случае порядка O(N).
@Рис. 10.1. Программа Search
========266
Если элемент находится в списке, то в среднем алгоритм проверяет N/2 элементов до того, как обнаружит искомый. Таким образом, в усредненном случае время выполнения алгоритма также порядка O(N).
Хотя алгоритмы, которые выполняются за время порядка O(N), не являются очень быстрыми, этот алгоритм достаточно прост, чтобы давать на практике неплохие результаты. Для небольших списков этот алгоритм имеет приемлемую производительность.
Если список упорядочен, то можно слегка модифицировать алгоритм полного перебора, чтобы немного повысить его производительность. В этом случае, если во время выполнения поиска алгоритм находит элемент со значением, большим, чем значение искомого элемента, то он завершает свою работу. При этом искомый элемент не находится в списке, так как иначе он бы встретился раньше.
Например, предположим, что мы ищем значение 12 и дошли до значения 17. При этом мы уже прошли тот участок списка, в котором мог бы находится элемент со значением 12, значит, элемент 12 в списке отсутствует. Следующий код демонстрирует доработанную версию алгоритма поиска полным перебором:
Public Function LinearSearch(target As Long) As Long
Dim i As Long
NumSearches = 0
For i = 1 To NumItems
NumSearches = NumSearches + 1
If List(i) >= target Then Exit For
Next i
If i > NumItems Then
LinearSearch = 0 ' Элемент не найден.
ElseIf List(i) <> target Then
LinearSearch = 0 ' Элемент не найден.
Else
LinearSearch = i ' Элемент найден.
End If
End Function
Эта модификация уменьшает время выполнения алгоритма, если элемент отсутствует в списке. Предыдущей версии поиска требовалось проверить весь список до конца, если искомого элемента в нем не было. Новая версия остановится, как только обнаружит элемент больший, чем искомый.
Если искомый элемент расположен случайно между наибольшим и наименьшим элементами в списке, то в среднем алгоритму понадобится порядка O(N) шагов, чтобы определить, что искомый элемент отсутствует в списке. Время выполнения при этом имеет тот же порядок, но на практике его производительность будет немного выше. Программа Search использует эту версию алгоритма.
======267
Поиск методом полного перебора — это единственный способ поиска в связных списках. Так как доступ к элементам возможен только при помощи указателей NextCell на следующий элемент, то необходимо проверить по очереди все элементы с начала списка, чтобы найти искомый.
Так же, как и в случае поиска полным перебором в массиве, если список упорядочен, то можно прекратить поиск, если найдется элемент со значением, большим, чем значение искомого элемента.
Public Function LListSearch(target As Long) As SearchCell
Dim cell As SearchCell
NumSearches = 0
Set cell = ListTop.NextCell
Do While Not (cell Is Nothing)
NumSearches = NumSearches + 1
If cell.Value >= target Then Exit Do
Set cell = cell.NextCell
Loop
If Not (cell Is Nothing) Then
If cell.Value = target Then
Set LListSearch = cell ' Элемент найден.
End If
End If
End Function
Программа Search использует этот алгоритм для поиска элементов в связном списке. Этот алгоритм выполняется немного медленнее, чем алгоритм полного перебора в массиве из‑за дополнительных накладных расходов, которые связаны с управлением указателями на объекты. Заметьте, что программа Search строит связные списки, только если список содержит не более 10.000 элементов.
Чтобы алгоритм выполнялся немного быстрее, в него можно внести еще одно изменение. Если хранить указатель на конец списка, то можно добавить в конец списка ячейку, которая будет содержать искомый элемент. Этот элемент называется сигнальной меткой (sentinel), и служит для тех же целей, что и сигнальные метки, описанные во 2 главе. Это позволяет обрабатывать особый случай конца списка так же, как и все остальные.
В этом случае, добавление метки в конец списка гарантирует, что в конце концов искомый элемент будет найден. При этом программа не может выйти за конец списка, и нет необходимости проверять условие Not (cell Is Nothing) в каждом цикле While.
Public Function SentinelSearch(target As Long) As SearchCell
Dim cell As SearchCell
Dim sentinel As New SearchCell
NumSearches = 0
' Установить сигнальную метку.
sentinel.Value = target
Set ListBottom.NextCell = sentinel
' Найти искомый элемент.
Set cell = ListTop.NextCell
Do While cell.Value < target
NumSearches = NumSearches + 1
Set cell = cell.NextCell
Loop
' Определить найден ли искомый элемент.
If Not ((cell Is sentinel) Or _
(cell.Value <> target)) _
Then
Set SentinelSearch = cell ' Элемент найден.
End If
' Удалить сигнальную метку.
Set ListBottom.NextCell = Nothing
End Function
Хотя может показаться, что это изменение незначительно, проверка Not (cell Is Nothing) выполняется в цикле, который вызывается очень часто. Для больших списков этот цикл вызывается множество раз, и выигрыш времени суммируется. В Visual Basic, этот версия алгоритма поиска в связных списках выполняется на 20 процентов быстрее, чем предыдущая версия. В программе Search приведены обе версии этого алгоритма, и вы можете сравнить их.
Некоторые алгоритмы используют потоки для ускорения поиска в связных списках. Например, при помощи указателей в ячейках списка можно организовать список в виде двоичного дерева. Поиск элемента с использованием этого дерева займет время порядка O(log(N)), если дерево сбалансировано. Такие структуры данных уже не являются просто списками, поэтому мы не обсуждаем их в этой главе. Чтобы больше узнать о деревьях, обратитесь к 6 и 7 главам
Как уже упоминалось в предыдущих разделах, поиск полным перебором выполняется очень быстро для небольших списков, но для больших списков намного быстрее выполняется двоичный поиск. Алгоритм двоичного поиска (binary search) сравнивает элемент в середине списка с искомым. Если искомый элемент меньше, чем найденный, то алгоритм продолжает поиск в первой половине списка, если больше — в правой половине. На рис. 10.2 этот процесс изображен графически.
Хотя по своей природе этот алгоритм является рекурсивным, его достаточно просто записать и без применения рекурсии. Так как этот алгоритм прост для понимания в любом варианте (с рекурсией или без), то мы приводим здесь его нерекурсивную версию, которая содержит меньше вызовов функций.
Основная заключенная в этом алгоритме идея проста, но детали ее реализации достаточно сложны. Программе приходится аккуратно отслеживать часть массива, которая может содержать искомый элемент, иначе она может его пропустить.
Алгоритм использует две переменные, min и max, в которых находятся минимальный и максимальный индексы ячеек массива, которые могут содержать искомый элемент. Во время выполнения алгоритма, индекс искомой ячейки всегда будет лежать между min и max. Другими словами, min <= target index <= max.
==========269
@Рис. 10.2. Двоичный поиск элемента со значением 44
Во время каждого прохода, алгоритм выполняет присвоение middle = (min + max) / 2 и проверяет ячейку, индекс которой равен middle. Если ее значение равно искомому, то цель найдена и алгоритм завершает свою работу.
Если значение искомого элемента меньше, чем значение среднего, то алгоритм устанавливает значение переменной max равным middle – 1 и продолжает поиск. Так как теперь индексы элементов, которые могут содержать искомый элемент, находятся в диапазоне от min до middle – 1, то программа при этом выполняет поиск в первой половине списка.
В конце концов, программа либо найдет искомый элемент, либо наступит момент, когда значение переменной min станет больше, чем значение max. Поскольку индекс искомого элемента должен находиться между минимальным и максимальным возможными индексами, это означает, что искомый элемент отсутствует в списке.
Следующий код демонстрирует выполнение двоичного поиска в программе Search:
Public Function BinarySearch(target As Long) As Long
Dim min As Long
Dim max As Long
Dim middle As Long
NumSearches = 0
' Во время поиска индекс искомого элемента будет находиться
' между Min и Max: Min <= target index <= Max
min = 1
max = NumItems
Do While min <= max
NumSearches = NumSearches + 1
middle = (max + min) / 2
If target = List(middle) Then ' Мы нашли искомый элемент!
BinarySearch = middle
Exit Function
ElseIf target < List(middle) Then ' Поиск в левой половине.
max = middle - 1
Else ' Поиск в правой половине.
min = middle + 1
End If
Loop
' Если мы оказались здесь, то искомого элемента нет в списке.
BinarySearch = 0
End Function
На каждом шаге число элементов, которые еще могут иметь искомое значение, уменьшается вдвое. Для списка размера N, алгоритму может потребоваться максимум O(log(N)) шагов, чтобы найти любой элемент или определить, что его нет в списке. Это намного быстрее, чем в случае применения алгоритма полного перебора. Полный перебор списка из миллиона элементов потребовал бы в среднем 500.000 шагов. Алгоритму двоичного поиска потребуется не больше, чем log(1.000.000) или 20 шагов.
Двоичный поиск обеспечивает значительное увеличение скорости поиска по сравнению с полным перебором, так как он исключает большие части списка, не проверяя при этом значения исключаемых элементов. Если, кроме того, известно, что значения элементов распределены достаточно равномерно, то можно исключать на каждом шаге еще больше элементов, используя интерполяционный поиск (interpolation search).
Интерполяцией называется процесс предсказания неизвестных значений на основе имеющихся. В данном случае, индексы известных значений в списке используются для определения возможного положения искомого элемента в списке.
Например, предположим, что имеется тот же самый список значений, показанный на рис. 10.2. Этот список содержит 20 элементов со значениями между 1 и 70. Предположим теперь, что требуется найти элемент в списке, имеющий значение 44. Значение 44 составляет 64 процента расстояния между 1 и 70 на шкале чисел. Если считать, что значения элементов распределены равномерно, то можно предположить, что искомый элемент расположен примерно в точке, которая составляет 64 процента от размера списка, и занимает позицию 13.
Если позиция, выбранная при помощи интерполяции, оказывается неправильной, то алгоритм сравнивает искомое значение со значением элемента в выбранной позиции. Если искомое значение меньше, то поиск продолжается в первой части списка, если больше — во второй части. На рис. 10.3 графически изображен интерполяционный поиск.
При двоичном поиске список последовательно разбивается посередине на две части. Интерполяционный поиск каждый раз разбивает список, пытаясь найти ближайший к искомому элемент в списке, при этом точка разбиения определяется следующим кодом:
middle = min + (target - List(min)) * _
((max - min) / (List(max) - List(min)))
========270-271
@Рис. 10.3 Интерполяционный поиск значения 44
Этот оператор помещает значение middle между min и max в таком же соотношении, в каком искомое значение находится между List(min) и List(max). Если искомый элемент находится рядом с List(min), то разность target – List(min) почти равна нулю. Тогда все соотношение целиком выглядит почти как middle = min + 0, поэтому значение переменной middle почти равно min. Смысл этого заключается в том, что если индекс элемента почти равен min, то его значение почти равно List(min).
Аналогично, если искомый элемент находится рядом с List(max), то разность target – List(min) почти равна разности List(max) – List(min). Их частное почти равно единице, и соотношение выглядит почти как middle = min + (max – min), или middle = max, если упростить выражение. Смысл этого соотношения заключается в том, что если значение элемента близко к List(max), то его индекс почти равен max.
После того, как программа вычислит значение middle, она сравнивает значение элемента в этой позиции с искомым так же, как и в алгоритме двоичного поиска. Если эти значения совпадают, то искомый элемент найден и процесс закончен. Если значение искомого элемента меньше, чем значение найденного, то программа устанавливает значение max равным middle – 1 и продолжает поиск элементов списка с меньшими значениями. Если значение искомого элемента больше, чем значение найденного, то программа устанавливает значение min равным middle + 1 и продолжает поиск элементов списка с большими значениями.
Заметьте, что в знаменателе соотношения, которое находит новое значение переменной middle, находится разность (List(max) – Lsit(min)). Если значения List(max) и List(min) одинаковы, то произойдет деление на ноль и программа аварийно завершит работу. Такое может произойти, если два элемента в списке имеют одинаковые значения. Так как алгоритм поддерживает соотношение min <= target index <= max, то эта проблема может также возникнуть, если min будет расти, а max уменьшаться до тех пор, пока их значения не сравняются.
Чтобы справиться с этой проблемой, программа перед выполнением операции деления проверяет, не равны ли List(max) и List(min). Если это так, значит осталось проверить только одно значение. При этом программа просто проверяет, совпадает ли оно с искомым.
Еще одна тонкость заключается в том, что вычисленное значение middle не всегда лежит между min и max. В простейшем случае это может быть так, если значение искомого элемента выходит за пределы диапазона значений элементов в списке. Предположим, что мы пытаемся найти значение 300 в списке из элементов 100, 150 и 200. На первом шаге вычислений min = 1 и max = 3. Тогда middle = 1 + (300 – List(1)) * (3 – 1) / (List(3) – List(1)) = 1 + (300 – 100) * 2 / (200 – 100) = 5. Индекс 5 не только не находится в диапазоне между min и max, он также выходит за границы массива. Если программа попытается обратиться к элементу массива List(5), то она аварийно завершит работу с сообщением об ошибке “Subscript out of range”.
===========272
Похожая проблема возникает, если значения элементов распределены между min и max очень неравномерно. Предположим, что мы хотим найти значение 100 в списке 0, 1, 2, 199, 200. При первом вычислении значения переменной middle, мы получим в программе middle = 1 + (100 – 0) * (5 – 1) / (200 – 0) = 3. Затем программа сравнивает значение элемента List(3) с искомым значением 100. Так как List(3) = 2, что меньше 100, она задает min = middle + 1, то есть min = 4.
При следующем вычисления значения переменной middle, программа находит middle = 4 + (100 – 199) * (5 – 4) / (200 – 199) = -98. Значение –98 не попадает в диапазон min <= target index <= max и также далеко выходит за границы массива.
Если рассмотреть процесс вычисления переменной middle, то можно увидеть, что существуют два варианта, при которых новое значение может оказаться меньше, чем min или больше, чем max. Вначале предположим, что middle меньше, чем min.
min + (target - List(min)) * ((max - min) / (List(max) - List(min))) < min
После вычитания min из обеих частей уравнения, получим:
(target - List(min)) * ((max - min) / (List(max) - List(min))) < 0
Так как max >= min, то разность (max – min) должна быть больше нуля. Так как List(max) >= List(min), то разность (List(max) – List(min)) также должна быть больше нуля. Тогда все значение может быть меньше нуля, только если (target – List(min)) меньше нуля. Это означает, что искомое значение меньше, чем значение элемента List(min). В этом случае, искомый элемент не может находиться в списке, так как все элементы списка со значением меньшим, чем List(min) уже были исключены.
Теперь предположим, что middle больше, чем max.
min + (target - List(min)) * ((max - min) / (List(max) - List(min))) > max
После вычитания min из обеих частей уравнения, получим:
(target - List(min)) * ((max - min) / (List(max) - List(min))) > 0
Умножение обеих частей на (List(max) – List(min)) / (max – min) приводит соотношение к виду:
target – List(min) > List(max) – List(min)
И, наконец, прибавив к обеим частям List(min), получим:
target > List(max)
Это означает, что искомое значение больше, чем значение элемента List(max). В этом случае, искомое значение не может находиться в списке, так как все элементы списка со значениями большими, чем List(max) уже были исключены.
==========273
Учитывая все эти результаты, получаем, что новое значение переменной middle может выйти из диапазона между min и max только в том случае, если искомое значение выходит за пределы диапазона от List(min) до List(max). Алгоритм может использовать этот факт при вычислении нового значения переменной middle. Он вначале проверяет, находится ли новое значение между min и max. Если нет, то искомого элемента нет в списке и работа алгоритма завершена.
Следующий код демонстрирует реализацию интерполяционного поиска в программе Search:
Public Function InterpSearch(target As Long) As Long
Dim min As Long
Dim max As Long
Dim middle As Long
min = 1
max = NumItems
Do While min <= max
' Избегаем деления на ноль.
If List(min) = List(max) Then
' Это искомый элемент (если он есть в списке).
If List(min) = target Then
InterpSearch = min
Else
InterpSearch = 0
End If
Exit Function
End If
' Найти точку разбиения списка.
middle = min + (target - List(min)) * _
((max - min) / (List(max) - List(min)))
' Проверить, не вышли ли мы за границы.
If middle < min Or middle > max Then
' Искомого элемента нет в списке.
InterpSearch = 0
Exit Function
End If
NumSearches = NumSearches + 1
If target = List(middle) Then ' Искомый элемент найден.
InterpSearch = middle
Exit Function
ElseIf target < List(middle) Then ' Поиск в левой части.
max = middle - 1
Else ' Поиск в правой части.
min = middle + 1
End If
Loop
' Если мы дошли до этой точки, то элемента нет в списке.
InterpSearch = 0
End Function
Двоичный поиск выполняется очень быстро, а интерполяционный еще быстрее. В одном из тестов, двоичный поиск потребовал в 7 раз больше времени для поиска значений в списке из 100.000 элементов. Эта разница могла бы быть еще больше, если бы данные находились на диске или каком‑либо другом медленном устройстве. Хотя при интерполяционном поиске на вычисления уходит больше времени, чем в случае двоичного поиска, за счет меньшего числа обращений к диску мы сэкономили бы гораздо больше времени.
Если данные в списке представляют собой строки, можно применить два различных подхода. Более простой состоит в применении двоичного поиска. При двоичном поиске значения элементов сравниваются непосредственно, поэтому этот метод может легко работать со строковыми данными.
С другой стороны, интерполяционный поиск использует численные значения элементов данных для вычисления возможного положения искомого элемента в списке. Если элементы представляют собой строки, то этот алгоритм не может непосредственно использовать значения данных для вычисления предполагаемого положения искомого элемента.
Если строки достаточно короткие, то можно закодировать их при помощи целых чисел или чисел формата long или double, используя методы, которые были описаны в 9 главе. После этого можно использовать для нахождения элементов в списке интерполяционный поиск.
Если строки слишком длинные, и их нельзя закодировать даже числами в формате double, то все еще можно использовать для интерполяции значения строк. Вначале найдем первый отличающийся символ для строк List(min) и List(max). Затем закодируем его и следующие два символа в каждой строке при помощи методов из 9 главы. Затем можно использовать эти значения для выполнения интерполяционного поиска.
Например, предположим, что мы ищем строку TARGET в списке TABULATE, TANTRUM, TARGET, TATTERED, TAXATION. Если min = 1 и max = 5, то проверяются значения TABULATE и THEATER. Эти строки отличаются во втором символе, поэтому нужно рассматривать три символа, начинающиеся со второго. Это будут символы ABU для List(1), AXA для List(5) и ARG для искомой строки.
Эти значения кодируются числами 804, 1378 и 1222 соответственно. Подставляя эти значения в формулу для переменной middle, получим:
middle = min + (target - List(min)) * ((max - min) / (List(max) - List(min)))
= 1 + (1222 – 804) * ((5 – 1) / (1378 – 804))
= 2,91
=========275
Это примерно равно 3, поэтому следующее значение переменной middle равно 3. Это положение строки TARGET в списке, поэтому поиск при этом заканчивается.
Чтобы начать двоичный следящий поиск (binary hunt and search), сравним искомое значение из предыдущего поиска с новым искомым значением. Если новое значение меньше, начнем слежение влево, если больше — вправо.
Для выполнения слежения влево, установим значения переменных min и max равными индексу, полученному во время предыдущего поиска. Затем уменьшим значение min на единицу и сравним искомое значение со значением элемента List(min). Если искомое значение меньше, чем значение List(min), установим max = min и min = min –2, и сделаем еще одну проверку. Если искомое значение все еще меньше, установим max = min и min = min –4, если это не поможет, установим max = min и min = min –8 и так далее. Продолжим устанавливать значение переменной max равным значению переменной min и вычитать очередные степени двойки из значения переменной min до тех пор, пока не найдется значение min, для которого значение элемента List(min) будем меньше искомого значения.
Необходимо следить за тем, чтобы не выйти за границы массива, если min меньше, чем нижняя граница массива. Если в какой‑то момент это окажется так, то min нужно присвоить значение нижней границы массива. Если при этом значение элемента List(min) все еще больше искомого, значит искомого элемента нет в списке. На рис. 10.4 показан следящий поиск элемента со значением 17 влево от предыдущего искомого элемента со значением 44.
Слежение вправо выполняется аналогично. Вначале значения переменных min и max устанавливаются равными значению индекса, полученного во время предыдущего поиска. Затем последовательно устанавливается min = max и max = max + 1, min = max и max = max + 2, min = max и max = max + 4, и так далее до тех пор, пока в какой‑то точке значение элемента массива List(max) не станет больше искомого. И снова необходимо следить за тем, чтобы не выйти за границу массива.
После завершения фазы слежения известно, что индекс искомого элемента находится между min и max. После этого можно использовать обычный двоичный поиск для нахождения точного положения искомого элемента.
@Рис. 10.4. Следящий поиск значения 17 из значения 44
===============276
Если новый искомый элемент находится недалеко от предыдущего, то алгоритм следящего поиска очень быстро найдет значения max и min. Если новый и старый искомые элементы отстоят друг от друга на P позиций, то потребуется порядка log(P) шагов для следящего поиска новых значений переменных min и max.
Предположим, что мы начали обычный двоичный поиск без фазы слежения. Тогда потребуется порядка log(NumItems) – log(P) шагов для того, чтобы значения min и max были на расстоянии не больше, чем P позиций друг от друга. Это означает, что следящий поиск будет быстрее обычного двоичного поиска, если log(P) < log(NumItems) – log(P). Прибавив к обеим частям уравнения log(P), получим 2 * log(P) > log(NumItems). Если возвести обе части уравнения в степень двойки, получим 22*log(P) < 2log(NumItems) или (2log(P))2 < NumItems, или после упрощения P2 < NumItems.
Из этого соотношения видно, что следящий поиск будет выполняться быстрее, если расстояние между последовательными искомыми элементами будет меньше, чем квадратный корень из числа элементов в списке. Если следующие друг за другом искомые элементы расположены далеко друг от друга, то лучше использовать обычный двоичный поиск.
Используя методы из предыдущих разделов можно выполнить следящий интерполяционный поиск (interpolative hunt and search). Вначале, как и раньше, сравним искомое значение из предыдущего поиска с новым. Если новое искомое значение меньше, начнем слежение влево, если больше — вправо.
Для слежения влево будем теперь использовать интерполяцию, чтобы предположить, где может находиться искомое значение в диапазоне между предыдущим значением и значением элемента List(1). Но это будет просто интерполяционный поиск, в котором min = 1 и max равно индексу, полученному во время предыдущего поиска. После первого шага, фаза слежения заканчивается и дальше можно продолжить обычный интерполяционный поиск.
Аналогично выполняется слежение вправо. Просто приравниваем max = Numitems и устанавливаем min равным индексу, полученному во время предыдущего поиска. Затем продолжаем обычный интерполяционный поиск.
На рис. 10.5 показан интерполяционный поиск элемента со значением 17, начинающийся с предыдущего элемента со значением 44.
Если значения данных расположены почти равномерно, то интерполяционный поиск всегда выбирает значение, которое находится рядом с искомым на первом или последующем шаге. Это означает, что начиная с предыдущего найденного значения, нельзя значительно улучшить этот алгоритм. На первом шаге, даже без использования результата предыдущего поиска, интерполяционный поиск, вероятно, выберет индекс, который находится достаточно близко от индекса искомого элемента.
@Рис. 10.5. Интерполяционный поиск значения 17 из значения 44
=============277
С другой стороны, использование предыдущего значения может помочь в случае, если данные распределены неравномерно. Если известно, что новое искомое значение находится близко к старому, интерполяционный поиск, начинающийся с предыдущего значения, обязательно найдет элемент, который находится рядом с предыдущим найденным. Это означает, что использование в качестве стартовой точки предыдущего найденного значения может давать определенное преимущество.
Результат предыдущего поиска также сильнее ограничивает диапазон возможных положений нового элемента, по сравнению с диапазоном от 1 до NumItems, поэтому алгоритм может сэкономить при этом один или два шага. Это особенно важно, если список находится на диске или каком‑либо другом медленном устройстве. Если сохранять результат предыдущего поиска в памяти, то можно, по крайней мере, сравнить новое искомое значение с предыдущим без обращения к диску.
Если элементы находятся в связном списке, используйте поиск методом полного перебора. По возможности используйте сигнальную метку в конце списка для ускорения поиска.
Если вам нужно время от времени проводить поиск в списке, содержащем десятки элементов, также используйте поиск методом полного перебора. Алгоритм в этом случае будет проще отлаживать и поддерживать, чем более сложные методы поиска, и он будет давать приемлемые результаты.
Если требуется проводить поиск в больших списках, используйте интерполяционный поиск. Если значения данных распределены достаточно равномерно, то интерполяционный поиск обеспечит наилучшую производительность. Если список находится на диске или каком‑либо другом медленном устройстве, разница в скорости между интерполяционным поиском и другими методами поиска может быть достаточно велика.
Если используются строковые данные, можно попытаться закодировать их числами в формате integer, long или double, при этом для их поиска можно будет использовать интерполяционный метод. Если строки слишком длинные и не помещаются даже в числа формата double, то проще всего может оказаться использовать двоичный поиск. В табл. 10.1 перечислены преимущества и недостатки для различных методов поиска.
Используя двоичный или интерполяционный поиск, можно очень быстро находить элементы даже в очень больших списках. Если значения данных распределены равномерно, то интерполяционный поиск позволяет всего за несколько шагов найти элемент в списке, содержащем миллион элементов.
@Таблица 10.1 Преимущества и недостатки различных методов поиска.
===========278
Тем не менее, в такой большой список трудно вносить изменения. Вставка или удаление элемента из упорядоченного списка займет время порядка O(N). Если элемент находится в начале списка, выполнение этих операций может потребовать очень большого количества времени, особенно если список находится на каком‑либо медленном устройстве.
Если требуется вставлять и удалять элементы из большого списка, следует рассмотреть возможность замены его на другую структуру данных. В 7 главе обсуждаются сбалансированные деревья, вставка и добавление элемента в которые требует времени порядка O(log(N)).
В 11 главе обсуждаются методы, позволяющие выполнять вставку и удаление элементов еще быстрее. Для достижения такой высокой скорости, в этих методах используется дополнительное пространство для хранения промежуточных данных. Хеш‑таблицы не хранят информацию о порядке расположения данных. В хеш‑таблицу можно вставлять, удалять, и находить элементы, но сложно вывести элементы из таблицы по порядку.
Если список будет неизменным, то применение упорядоченного списка и использование метода интерполяционного поиска даст прекрасные результаты. Если требуется часто вставлять и удалять элементы из списка, то стоит рассмотреть возможность применения хеш‑таблицы. Если при этом также нужно выводить элементы по порядку или перемещаться по списку в прямом или обратном направлении, то оптимальную скорость и гибкость может обеспечить применение сбалансированных деревьев. Решив, какие типа операций вам понадобятся, вы можете выбрать алгоритм, который вам лучше всего подходит.
=============279
Глава 11. ХешированиеВ предыдущей главе описывался алгоритм интерполяционного поиска, который использует интерполяцию, чтобы быстро найти элемент в списке. Сравнивая искомое значение со значениями элементов в известных точках, этот алгоритм может определить вероятное положение искомого элемента. В сущности, он создает функцию, которая устанавливает соответствие между искомым значением и индексом позиции, в которой он должен находиться. Если первое предположение ошибочно, то алгоритм снова использует эту функцию, делая новое предположение, и так далее, до тех пор, пока искомый элемент не будет найден.
Хеширование (hashing) использует аналогичный подход, отображая элементы в хеш‑таблице (hash table). Алгоритм хеширования использует некоторую функцию, которая определяет вероятное положение элемента в таблице на основе значения искомого элемента.
Например, предположим, что требуется запомнить несколько записей, каждая из которых имеет уникальный ключ со значением от 1 до 100. Для этого можно создать массив со 100 ячейками и проинициализировать каждую ячейку нулевым ключом. Чтобы добавить в массив новую запись, данные из нее просто копируются в соответствующую ячейку массива. Чтобы добавить запись с ключом 37, данные из нее просто копируются в 37 позицию в массиве. Чтобы найти запись с определенным ключом, просто выбирается соответствующая ячейка массива. Для удаления записи ключу соответствующей ячейки массива просто присваивается нулевое значение. Используя эту схему, можно добавить, найти и удалить элемент из массива за один шаг.
К сожалению, в реальных приложениях значения ключа не всегда находятся в небольшом диапазоне. Обычно диапазон возможных значений ключа достаточно велик. База данных сотрудников может использовать в качестве ключа идентификационный номер социального страхования. Теоретически можно было бы создать массив, каждая ячейка которого соответствовала одному из возможных девятизначных чисел; но на практике для этого не хватит памяти или дискового пространства. Если для хранения одной записи требуется 1 килобайт памяти, то такой массив занял бы 1 терабайт (миллион мегабайт) памяти. Даже если можно было бы выделить такой объем памяти, такая схема была бы очень неэкономной. Если штат вашей компании меньше 10 миллионов сотрудников, то более 99 процентов массива будут пусты.
=======281
Чтобы справиться с этой проблемой, схемы хеширования отображают потенциально большое число возможных ключей на достаточно компактную хеш‑таблицу. Если в вашей компании работает 700 сотрудников, вы можете создать хеш‑таблицу с 1000 ячеек. Схема хеширования устанавливает соответствие между 700 записями о сотрудниках и 1000 позициями в таблице. Например, можно располагать записи в таблице в соответствии с тремя первыми цифрами идентификационного номера в системе социального страхования. При этом запись о сотруднике с номером социального страхования 123‑45‑6789 будет находиться в 123 ячейке таблицы.
Очевидно, что поскольку существует больше возможных значений ключа, чем ячеек в таблице, то некоторые значения ключей могут соответствовать одним и тем же ячейкам таблицы. Например, оба значения 123‑45‑6789 и 12399‑9999 отображаются на одну и ту же ячейку таблицы 123. Если существует миллиард возможных номеров системы социального страхования, и таблица имеет 1000 ячеек, то в среднем каждая ячейка будет соответствовать миллиону записей.
Чтобы избежать этой потенциальной проблемы, схема хеширования должна включать в себя алгоритм разрешения конфликтов (collision resolution policy), который определяет последовательность действий в случае, если ключ соответствует позиции в таблице, которая уже занята другой записью. В следующих разделах описываются несколько различных методов разрешения конфликтов.
Все обсуждаемые здесь методы используют для разрешения конфликтов примерно одинаковый подход. Они вначале устанавливают соответствие между ключом записи и положением в хеш‑таблице. Если эта ячейка уже занята, они отображают ключ на какую‑либо другую ячейку таблицы. Если она также уже занята, то процесс повторяется снова о тех пор, пока в конце концов алгоритм не найдет пустую ячейку в таблице. Последовательность проверяемых при поиске или вставке элемента в хеш‑таблицу позиций называется [RV16] тестовой последовательностью (probe sequence).
В итоге, для реализации хеширования необходимы три вещи:
· Структура данных (хеш‑таблица) для хранения данных;
· Функция хеширования, устанавливающая соответствие между значением ключа и положением в таблице;
· Алгоритм разрешения конфликтов, определяющий последовательность действий, если несколько ключей соответствуют одной ячейке таблицы.
В следующих разделах описаны некоторые структуры данных, которые можно использовать для хеширования. Каждая из них имеет соответствующую функцию хеширования и один или более алгоритмов разрешения конфликтов. Так же, как и в большинстве компьютерных алгоритмов, каждый из этих методов имеет свои преимущества и недостатки. В последнем разделе описаны преимущества и недостатки разных методов, чтобы помочь вам выбрать наилучший для данной ситуации метод хеширования.
Один из методов разрешения конфликтов заключается в хранении записей, которые занимают одинаковое положение в таблице, в связных списках. Чтобы добавить в таблицу новую запись, при помощи функции хеширования выбирается связный список, который должен его содержать. Затем запись добавляется в этот список.
На рис. 11.1 показан пример связывания хеш‑таблицы, которая содержит 10 ячеек. Функция хеширования отображает ключ K на ячейку K Mod 10 в массиве. Каждая ячейка массива содержит указатель на первый элемент связного списка. При вставке элемента в таблицу он помещается в соответствующий список.
======282
@Рис. 11.1. Связывание
Чтобы создать хеш‑таблицу в Visual Basic, используйте оператор ReDim для размещения сигнальных меток начала списков. Если вы хотите создать в хеш‑таблице NumLists связных списков, задайте размер массива ListTops при помощи оператора ReDim ListTops(0 To NumLists - 1). Первоначально все списки пусты, поэтому указатель NextCell каждой метки должен иметь значение Nothing. Если вы используете для изменения массива меток оператор ReDim, то Visual Basic автоматически инициализирует указатели NextCell значением Nothing.
Чтобы найти в хеш‑таблице элемент с ключом K, нужно вычислить K Mod NumLists, получив индекс метки связного списка, который может содержать искомый элемент. Затем нужно просмотреть список до тех пор, пока искомый элемент не будет найден или процедура не дойдет до конца списка.
Global Const HASH_FOUND = 0
Global Const HASH_NOT_FOUND = 1
Global Const HASH_INSERTED = 2
Private Function LocateItemUnsorted(Value As Long) As Integer
Dim cell As ChainCell
' Получить вершину связного списка.
Set cell = m_ListTops(Value Mod NumLists).NextCell
Do While Not (cell Is Nothing)
If cell.Value = Value Then Exit Do
Set cell = cell.NextCell
Loop
If cell Is Nothing Then
LocateItemUnsorted = HASH_NOT_FOUND
Else
LocateItemUnsorted = HASH_FOUND
End If
End Function
Функции для вставки и удаления элементов из связных списков аналогичны функциям, описанным во 2 главе.
========283
Одно из преимуществ этого метода состоит в том, что при его использовании хеш‑таблицы никогда не переполняются. При этом вставка и поиск элементов всегда выполняется очень просто, даже если элементов в таблице очень много. Для некоторых методов хеширования, описанных ниже, производительность значительно падает, если таблица почти заполнена.
Из хеш‑таблицы, которая использует связывание, также просто удалять элементы, при этом элемент просто удаляется из соответствующего связного списка. В некоторых других схемах хеширования удалить элемент непросто или невозможно.
Один из недостатков связывания состоит в том, что если число связных списков недостаточно велико, то размер списков может стать большим, при этом для вставки или поиска элемента необходимо будет проверить большое число элементов списка. Если хеш‑таблица содержит 10 связных списков и к ней добавляется 1000 элементов, то средняя длина связного списка будет равна 100. Чтобы найти элемент в таблице, придется проверить порядка 100 ячеек.
Можно немного ускорить поиск, если использовать упорядоченные списки. Тогда можно использовать для поиска элементов в упорядоченных связных списках методы, описанные в 10 главе. Это позволяет прекратить поиск, если во время его выполнения встретится элемент со значением, большим искомого. В среднем потребуется проверить только половину связного списка, чтобы найти элемент или определить, что его нет в списке.
Private Function LocateItemSorted(Value As Long) As Integer
Dim cell As ChainCell
' Получить вершину связного списка.
Set cell = m_ListTops(Value Mod NumLists).NextCell
Do While Not (cell Is Nothing)
If cell.Value >= Value Then Exit Do
Set cell = cell.NextCell
Loop
If cell Is Nothing Then
LocateItemSorted = HASH_NOT_FOUND
ElseIf cell.Value = Value Then
LocateItemSorted = HASH_FOUND
Else
LocateItemSorted = HASH_NOT_FOUND
End If
End Function
Использование упорядоченных списков позволяет ускорить поиск, но не снимает настоящую проблему, связанную с переполнения таблицы. Лучшим, но более трудоемким решением будет создание хеш‑таблицы большего размера и повторное хеширование элементов в новой таблице так, чтобы связные списки в ней имели меньший размер. Это может занять довольно много времени, особенно если списки записаны на диске или каком‑либо другом медленном устройстве, а не в памяти.
========284
В программе Chain реализована хеш‑таблица со связыванием. Введите число списков в поле области Table Creation (Создание таблицы) на форме и установите флажок Sort Lists (Упорядоченные списки), если вы хотите, чтобы программа использовала упорядоченные списки. Затем нажмите на кнопку Create Table (Создать таблицу). Затем вы можете ввести новые значения и снова нажать на кнопку Create Table, чтобы создать новую хеш‑таблицу.
Так как интересно изучать хеш‑таблицы, содержащие большое число значений, то программа Chain позволяет заполнять таблицу случайными элементами. Введите число элементов, которые вы хотите создать и максимальное значение элементов в области Random Items (Случайные элементы), затем нажмите на кнопку Create Items (Создать элементы), и программа добавит в хеш‑таблицу случайно созданные элементы.
И, наконец, введите значение в области Search (Поиск). Если вы нажмете на кнопку Add (Добавить), то программа вставит элемент в хеш‑таблицу, если он еще не находится в ней. Если вы нажмете на кнопку Find (Найти), то программа выполнит поиск элемента в таблице.
После завершения операции поиска или вставки, программа выводит статус операции в нижней части формы — была ли операция успешной и число проверенных во время ее выполнения элементов.
В строке статуса также выводится средняя длина успешной (если элемент есть в таблице) и безуспешной (если элемента в таблице нет) тестовых последовательностей. Программа вычисляет эти значения, выполняя поиск для всех чисел между единицей и наибольшим числом в хеш‑таблице, и затем подсчитывая среднее значение длины тестовой последовательности.
На рис. 11.2 показано окно программы Chain после успешного поиска элемента 414.[RV17]
Другой способ разрешения конфликтов заключается в том, чтобы выделить ряд блоков, каждый из которых может содержать несколько элементов. Для вставки элемента в таблицу, он отображается на один из блоков и затем помещается в этот блок. Если блок уже заполнен, то используется обработка переполнения.
[RV18]
======285
Возможно, самый простой метод обработки переполнения состоит в том, чтобы поместить все лишние элементы в специальные блоки в конце массива «нормальных» блоков. Это позволяет при необходимости легко увеличивать размер хеш‑таблицы. Если требуется больше дополнительных блоков, то размер массива блоков просто увеличивается, и в конце массива создаются новые дополнительные блоки.
Например, чтобы добавить новый элемент K в хеш‑таблицу, которая содержит пять блоков, вначале мы пытаемся поместить его в блок с номером K Mod 5. Если этот блок заполнен, элемент помещается в дополнительный блок.
Чтобы найти элемент в таблице, вычислим K Mod 5, чтобы найти его положение, и затем выполним поиск в этом блоке. Если элемента в этом блоке нет, и блок не заполнен, значит элемента в хеш‑таблице нет. Если элемента в блоке нет и блок заполнен, необходимо проверить дополнительные блоки.
На рис. 11.3 показаны пять блоков с номерами от 0 до 4 и один дополнительный блок. Каждый блок может содержать по 5 элементов. В этом примере в хеш‑таблицу были вставлены следующие элементы: 50, 13, 10 ,72, 25, 46, 68, 30, 99, 85, 93, 65, 70. При вставке элементов 65 и 70 блоки уже были заполнены, поэтому эти элементы были помещены в первый дополнительный блок.
Чтобы реализовать метод блочного хеширования в Visual Basic, можно использовать для хранения блоков двумерный массив. Если требуется NumBuckets блоков, каждый из которых может содержать BucketSize ячеек, выделим память под блоки при помощи оператора ReDim TheBuckets(0 To BucketSize -1, 0 To NumBuckets - 1). Второе измерение соответствует номеру блока. Оператор Visual Basic ReDim позволяет изменить только размер массива, поэтому номер блока должен быть вторым измерением массива.
Чтобы найти элемент K, вычислим номер блока K Mod NumBuckets. Затем проведем поиск в блоке до тех пор, пока не найдется искомый элемент, или пустая ячейка блока, или блок не закончится. Если элемент найден, поиск завершен. Если встретится пустая ячейка, значит элемента в хеш‑таблице нет, и процесс также завершен. Если проверен весь блок, и не найден искомый элемент или пустая ячейка, требуется проверить дополнительные блоки.
@Рис. 11.3. Хеширование с использованием блоков
======286
Public Function LocateItem(Value As Long, _
bucket_probes As Integer, item_probes As Integer) As Integer
Dim bucket As Integer
Dim pos As Integer
bucket_probes = 1
item_probes = 0
' Определить, к какому блоку он относится.
bucket = (Value Mod NumBuckets)
' Поиск элемента или пустой ячейки.
For pos = 0 To BucketSize - 1
item_probes = item_probes + 1
If Buckets(pos, bucket).Value = UNUSED Then
LocateItem = HASH_NOT_FOUND ' Элемент отсутствует.
Exit Function
End If
If Buckets(pos, bucket).Value = Value Then
LocateItem = HASH_FOUND ' Элемент найден.
Exit Function
End If
Next pos
' Проверить дополнительные блоки.
For bucket = NumBuckets To MaxOverflow
bucket_probes = bucket_probes + 1
For pos = 0 To BucketSize - 1
item_probes = item_probes + 1
If Buckets(pos, bucket).Value = UNUSED Then
LocateItem = HASH_NOT_FOUND ' Not here.
Exit Function
End If
If Buckets(pos, bucket).Value = Value Then
LocateItem = HASH_FOUND ' Элемент найден.
Exit Function
End If
Next pos
Next bucket
' Если элемент до сих пор не найден, то его нет в таблице.
LocateItem = HASH_NOT_FOUND
End Function
======287
Программа Bucket демонстрирует этот метод. Эта программа очень похожа на программу Chain, но она использует блоки, а не связные списки. Когда эта программа выводит длину тестовой последовательности, она показывает число проверенных блоков и число проверенных элементов в блоках. На рис. 11.4 показано окно программы после успешного поиска элемента 661 в первом дополнительном блоке. В этом примере программа проверила 9 элементов в двух блоках.
Многие запоминающие устройства, такие как стримеры, дисководы и жесткие диски, могут считывать большие куски данных за одно обращение к устройству. Обычно эти блоки имеют размер 512 или 1024 байта. Чтение всего блока данных занимает столько же времени, сколько и чтение одного байта.
Если имеется большая хеш‑таблица, записанная на диске, то этот факт можно использовать для улучшения производительности. Доступ к данным на диске занимает намного больше времени, чем доступ к данным в памяти. Если сразу загружать все элементы блока, то можно будет прочитать их все во время одного обращения к диску. После того, как все элементы окажутся в памяти, их проверка может выполняться намного быстрее, чем если бы пришлось их считывать с диска по одному.
Если для чтения элементов с диска используется цикл For, то Visual Basic будет обращаться к диску при чтении каждого элемента. С другой стороны, можно использовать оператор Visual Basic Get для чтения всего блока сразу. При этом потребуется всего одно обращение к диску, и программа будет выполняться намного быстрее.
Можно создать тип данных, который будет содержать массив элементов, представляющий блок. Так как во время работы программы нельзя изменять размер массива в определенном пользователем типе, то необходимо заранее определить, сколько элементов сможет находиться в блоке. При этом возможности изменения размеров блоков ограничены по сравнению с предыдущим вариантом алгоритма.
Global Const ITEMS_PER_BUCKET = 10 ' Число элементов в блоке.
Global Const MAX_ITEM = 9 ' ITEMS_PER_BUCKET - 1.
Type ItemType
Value As Long
End Type
Global Const ITEM_SIZE = 4 ' Размер данных этого типа.
Type BucketType
Item(0 To MAX_ITEM) As ItemType
End Type
Global Const BUCKET_SIZE = ITEMS_PER_BUCKET * ITEM_SIZE
Перед тем, как начать чтение данных из файла, он открывается для произвольного доступа:
Open filename For Random As #DataFile Len = BUCKET_SIZE
=========288
@Рис. 11.4. Программа Bucket
Для удобства работы можно написать функции для чтения и записи блоков. Эти функции читают и пишут данные в глобальную переменную TheBucket, которая содержит данные одного блока. После того, как данные загружены в эту переменную, можно выполнить поиск среди элементов этого блока в памяти.
Так как при произвольном обращении к файлу записи нумеруются с единицы, а не с нуля, то эти функции должны добавлять к номеру блока в хеш‑таблице единицу перед считыванием данных из файла. Например, нулевому блоку в хеш‑таблице будет соответствовать запись с номером 1.
Private Sub GetBucket(num As Integer)
Get #DataFile, num + 1, TheBucket
End Sub
Private Sub PutBucket(num As Integer)
Put #DataFile, num + 1, TheBucket
End Sub
Используя функции GetBucket и PutBucket, можно переписать процедуру поиск в хеш‑таблице для чтения записей из файла:
Public Function LocateItem(Value As Long, _
bucket_probes As Integer, item_probes As Integer) As Integer
Dim bucket As Integer
Dim pos As Integer
item_probes = 0
' Определить, к какому блоку принадлежит элемент.
GetBucket Value Mod NumBuckets
bucket_probes = 1
' Поиск элемента или пустой ячейки.
For pos = 0 To MAX_ITEM
item_probes = item_probes + 1
If TheBucket.Item(pos).Value = UNUSED Then
LocateItem = HASH_NOT_FOUND ' Элемента нет в таблице.
Exit Function
End If
If TheBucket.Item(pos).Value = Value Then
LocateItem = HASH_FOUND ' Элемент найден.
Exit Function
End If
Next pos
' Проверить дополнительные блоки
For bucket = NumBuckets To MaxOverflow
' Проверить следующий дополнительный блок.
GetBucket bucket
bucket_probes = bucket_probes + 1
For pos = 0 To MAX_ITEM
item_probes = item_probes + 1
If TheBucket.Item(pos).Value = UNUSED Then
LocateItem = HASH_NOT_FOUND ' Элемента нет.
Exit Function
End If
If TheBucket.Item(pos).Value = Value Then
LocateItem = HASH_FOUND ' Элемент найден.
Exit Function
End If
Next pos
Next bucket
' Если элемент все еще не найден, его нет в таблице.
LocateItem = HASH_NOT_FOUND
End Function
Программа Bucket2 аналогична программе Bucket, но она хранит блоки на диске. Она также не вычисляет и не выводит на экран среднюю длину тестовой последовательности, так как эти вычисления потребовали бы большого числа обращений к диску и сильно замедлили бы работу программы.
============290
Так как при обращении к блокам происходит чтение с диска, а обращение к элементам блока происходит в памяти, то число проверяемых блоков гораздо сильнее влияет на время выполнения программы, чем полное число проверенных элементов. Для сравнения среднего числа проверенных блоков и элементов при поиске элементов можно использовать программу Bucket.
Каждый блок в программе Bucket2 может содержать до 10 элементов. Это позволяет легко вставлять элементы в блоки до тех пор, пока они не переполнятся. В реальной программе следует попытаться поместить в блок максимально возможное число элементов так, чтобы размер блока оставался при этом равным целому числу кластеров диска.
Например, можно читать данные блоками по 1024 байта. Если элемент данных имеет размер 44 байта, то в один блок может поместиться 23 элемента данных, и при этом размер блока будет меньше 1024 байт.
Global Const ITEMS_PER_BUCKET = 23 ' Число элементов в блоке.
Global Const MAX_ITEM = 22 ' ITEMS_PER_BUCKET - 1.
Type ItemType
LastName As String * 20 ' 20 байт.
FirstName As String * 20 ' 20 байт.
EmloyeeId As Long ' 4 байта (это ключ).
End Type
Global Const ITEM_SIZE = 44 Размер данных этого типа.
Type BucketType
Item(0 To MAX_ITEM) As ItemType
End Type
Global Const BUCKET_SIZE = ITEMS_PER_BUCKET * ITEM_SIZE
Размещение в каждом блоке большего числа элементов позволяет считывать больше данных при каждом обращении к диску. При этом в таблице также может быть больше элементов, прежде чем будет необходимо использовать дополнительные блоки. Доступ к дополнительным блокам требует дополнительных обращений к диску, поэтому следует по возможности избегать его.
С другой стороны, если блоки достаточно велики, то они могут содержать большое число пустых ячеек. Если данные неравномерно распределены по блокам, то одни блоки могут быть переполнены, а другие — практически пусты. Использование другого варианта размещения с большим числом блоков меньшего размера может уменьшить эту проблему. Даже если некоторые блоки все еще будут переполнены, а некоторые пусты, то почти пустые блоки будут иметь меньший размер, потому они не будут содержать так много пустых ячеек.
На рис. 11.5 показаны два варианта расположения одних и тех же данных в блоках. В расположении наверху используются 5 блоков, каждый из которых содержит по 5 элементов. При этом дополнительные блоки не используются, и всего имеется 12 пустых ячеек. Расположение внизу использует 10 блоков, каждый из которых содержит по 2 элемента. В нем имеется 9 пустых ячеек и один дополнительный блок.
========291
@Рис. 11.5. Два варианта расположения элементов в блоках
Это пример пространственно‑временного компромисса. При первом расположении все элементы расположены в обычных (не дополнительных) блоках, поэтому можно быстро найти любой из них. Второе расположение занимает меньше места, но помещает некоторые элементы в дополнительные блоки, при этом доступ к ним занимает больше времени.
Можно использовать другой подход, если при переполнении блоков создавать цепочки из блоков. Для каждого заполненного блока создается своя цепочка блоков, вместо того, чтобы хранить все лишние элементы в одних и тех же дополнительных блоках. При поиске элемента в заполненном блоке нет необходимости проверять элементы в дополнительных блоках, которые были помещены туда в результате переполнения других блоков. Если множество блоков переполнено, то это может сэкономить довольно много времени.
На рис. 11.6 показано применение двух разных схем хеширования для одних и тех же данных. Вверху лишние элементы помещаются в общие дополнительные блоки. Чтобы найти элементы 32 и 30, нужно проверить три блока. Во‑первых, проверяется блок, в котором элемент должен находится. Элемента в этом блоке нет, поэтому проверяется первый дополнительный блок, в котором элемента тоже нет. Поэтому требуется проверить второй дополнительный блок, в котором, наконец, находится искомый элемент.
В нижнем расположении заполненные блоки связаны со своими собственными дополнительными блоками. При таком расположении любой элемент можно найти после обращения не более чем к двум блокам. Как и раньше, вначале проверяется блок, в котором элемент должен находиться. Если его там нет, то проверяется связный список дополнительных блоков. В этом примере чтобы найти искомый элемент нужно проверить только один дополнительный блок.
=========292
@Рис. 11.6. Связные дополнительные блоки
Если дополнительные блоки хеш‑таблицы содержит большое число элементов, то организация цепочек из дополнительных блоков может сэкономить достаточно много времени. Предположим, что имеется относительно большая хеш‑таблица, содержащая 1000 блоков, в каждом из которых находится 10 элементов. Предположим также, что в дополнительных блоках находится 1000 элементов, для которых понадобится 100 дополнительных блоков. Чтобы найти один из последних элементов в дополнительных блоках, потребуется проверить 101 блок.
Более того, предположим, что мы пытались найти элемент K, которого нет в таблице, но который должен был бы находиться в одном из заполненных блоков. В этом случае пришлось бы проверить все 100 дополнительных блоков, прежде чем выяснилось бы, что элемент отсутствует в таблице. Если программа часто пытается найти элементы, которых нет в таблице, то значительная часть времени будет тратиться на проверку дополнительных блоков.
Если дополнительные блоки связаны между собой и ключевые значения распределены равномерно, то можно будет находить элементы намного быстрее. Если максимальное число дополнительных элементов для одного блока равно 10, то каждый блок может иметь не больше одного дополнительного. В этом случае можно найти элемент или определить, что его нет в таблице, проверив не более двух блоков.
С другой стороны, если хеш‑таблица только слегка переполнена, то многие блоки будут иметь дополнительные блоки, содержащие всего один или два элемента. Допустим, что в каждом блоке должно находиться 11 элементов. Так как каждый блок может вместить только 10 элементов, для каждого обычного блока нужно будет создать один дополнительный. В этом случае потребуется 1000 дополнительных блоков, каждый из которых будет содержать всего один элемент, и всего в дополнительных блоках будет 900 пустых ячеек.
Это еще один пример пространственно‑временного компромисса. Связывание блоков друг с другом позволяет быстрее вставлять и находить элементы, но оно также может заполнять хеш‑таблицу пустыми ячейками. Конечно, можно избежать этой проблемы, создав новую хеш‑таблицу большего размера и разместив в ней все элементы таблицы.
=====293
Удаление элементов из блоков сложнее, чем из связных списков, но оно возможно. Во‑первых, найдем элемент, который требуется удалить из хеш‑таблицы. Если блок не заполнен, то на место удаленного элемента помещается последний элемент блока, при этом все непустые ячейки блока будет находиться в его начале. Тогда, если при поиске элемента в блоке позднее найдется пустая ячейка, то можно будет заключить, что элемента в таблице нет.
Если блок, содержащий искомый элемент, заполнен, то нужно провести поиск заменяющего его элемента в дополнительных блоках. Если ни один из элементов в дополнительных блоках не принадлежит к данному блоку, то искомый элемент заменяется последним элементом в блоке, и последняя ячейка блока становится пустой.
Иначе, если в дополнительном блоке существует элемент, который принадлежит к данному блоку, то найденный элемент из дополнительного блока помещается на место удаленного элемента. При этом в дополнительном блоке образуется пустое пространство, но это легко исправить — в образовавшуюся пустую ячейку помещается последний элемент из последнего дополнительного блока.
На рис. 11.7 показан процесс удаления элемента из заполненного блока. Во‑первых, из блока 0 удаляется элемент 24. Так как блок 0 был заполнен, то нужно попытаться найти элемент из дополнительных блоков, который можно было бы вставить на его место в блок 0. В данном случае блок 0 содержит все четные элементы, поэтому любой четный элемент из дополнительных блоков подойдет. Первый четным элементом в дополнительных блоках будет элемент 14, поэтому можно заменить элементы 24 в блоке 0 элементом 14.
При этом в третьей позиции первого дополнительного блока образуется пустая ячейка. Заполним ее последним элементом из последнего дополнительного блока, в данном случае элементом 79. В этот момент хеш‑таблица снова готова к работе.
Другой метод состоит в том, чтобы вместо удаления элемента помечать его как удаленный. Для поиска элементов в таком блоке нужно игнорировать удаленные элементы. Если позднее в блок будут добавляться новые элементы, можно будет помещать их на место элементов, помеченных как удаленные.
@Рис. 11.7. Удаление элемента из блока
=========294
Быстрее и легче вместо удаления элемента просто помечать его как удаленный, но, в конце концов, таблица может оказаться заполненной неиспользуемыми ячейками. Если добавить в хеш‑таблицу ряд элементов и затем удалить большинство из них в порядке первый вошел — первый вышел, то расположение элементов в блоках может оказаться «перевернутым». Большая часть настоящих данных будет находиться в конце блоков и в дополнительных блоках. Добавлять новые элементы в таблицу будет просто, но при поиске элемента довольно много времени будет тратиться на пропуск удаленных элементов.
В качестве компромисса при удалении элемента из блока можно перемещать последний элемент блока на освободившееся место и затем помечать последний элемент блока как удаленный. Тогда при поиске в блоке можно прекратить дальнейший поиск в блоке, если при этом встретится элемент, помеченный, как удаленный. После этого можно провести поиск в дополнительных блоках, если они существуют.
Вставка и удаление элемента в хеш‑таблицу с блоками выполняется достаточно быстро, даже если таблица почти заполнена. Фактически, хеш‑таблица, использующая блоки, обычно будет быстрее, чем таблица со связыванием (связыванием из предыдущей главы, а не связыванием блоков). Если хеш‑таблица находится на диске, блочный алгоритм может считывать за одно обращение к диску весь блок. При использовании связных списков, следующий элемент может находиться на диске не обязательно рядом с предыдущим. При этом для каждой проверки элемента потребуется обращение к диску.
Удаление элемента из таблицы сложнее выполнить с использованием блоков, чем при применении связных списков. Чтобы удалить элемент из заполненного блока, может понадобиться проверить все дополнительные блоки в поиске элемента, который нужно поместить на его место.
И еще одно преимущество хеш‑таблицы, использующей блоки, состоит в том, что если таблица переполняется, то можно легко увеличить ее размер. Когда все дополнительные блоки заполнятся, можно просто изменить размер массива и создать в его конце новый дополнительный блок.
Если многократно увеличивать размер таблицы подобным образом, то большая часть данных может находиться в дополнительных блоках. Тогда для того, чтобы найти или вставить элемент, потребуется проверить множество блоков, и производительность упадет. В этом случае, может быть лучше создать новую хеш‑таблицу с большим числом основных блоков и поместить элементы в нее.
[RV19] Иногда элементы данных слишком велики, чтобы их было удобно размещать в блоках. Если требуется список из 1000 элементов, каждый из которых занимает на диске 1 Мбайт, может быть сложно использовать блоки, которые содержали бы более одного или двух элементов. Если каждый из блоков будет содержать всего один или два элемента, то для поиска или вставки элемента потребуется проверить множество блоков.
При использовании открытой адресации (open addressing) хеш‑функция используется для непосредственного вычисления положения элементов данных в массиве. Например, можно использовать в качестве хеш‑таблицы массив с нижним индексом 0 и верхним 99. Тогда хеш‑функция может сопоставлять ключу со значением K индекс массива, равный K Mod 100. При этом элемент со значением 1723 окажется в таблице на 23 позиции. Затем, когда понадобится найти элемент 1723, проверяется 23 позиция в массиве.
==========295
Различные схемы открытой адресации используют разные методы для формирования тестовых последовательностей. В следующих разделах рассматриваются три наиболее важных метода: линейная, квадратичная и псевдослучайная проверка.
Если позиция, на которую отображается новый элемент в массиве, уже занята, то можно просто просмотреть массив с этой точки до тех пор, пока не найдется незанятая позиция. Этот метод разрешения конфликтов называется линейной проверкой (linear probing), так как при этом таблица просматривается последовательно.
Рассмотрим снова пример, в котором имеется массив с нижней границей 0 и верхней границей 99, и хеш‑функция отображает элемент K в позицию K Mod 100. Чтобы вставить элемент 1723, вначале проверяется позиция 23. Если эта ячейка заполнена, то проверяется позиция 24. Если она также занята, то проверяются позиции 25, 26, 27 и так далее до тех пор, пока не найдется свободная ячейка.
Чтобы вставить новый элемент в хеш‑таблицу, применяется выбранная тестовая последовательность до тех пор, пока не будет найдена пустая ячейка. Чтобы найти элемент в таблице, применяется выбранная тестовая последовательность до тех пор, пока не будет найден элемент или пустая ячейка. Если пустая ячейка встретится раньше, значит элемент в хеш‑таблице отсутствует.
Можно записать комбинированную функцию проверки и хеширования:
Hash(K, P) = (K + P) Mod 100 где P = 0, 1, 2, ...
Здесь P — число элементов в тестовой последовательности для K. Другими словами, для хеширования элемента K проверяются элементы Hash(K, 0), Hash(K, 1), Hash(K, 2), … до тех пор, пока не найдется пустая ячейка.
Можно обобщить эту идею для создания таблицы размера N на основе массива с индексами от 0 до N - 1. Хеш‑функция будет иметь вид:
Hash(K, P) = (K + P) Mod N где P = 0, 1, 2, ...
Следующий код показывает, как выполняется поиск элемента при помощи линейной проверки:
Public Function LocateItem(Value As Long, pos As Integer, _
probes As Integer) As Integer
Dim new_value As Long
probes = 1
pos = (Value Mod m_NumEntries)
Do
new_value = m_HashTable(pos)
' Элемент найден.
If new_value = Value Then
LocateItem = HASH_FOUND
Exit Function
End If
' Элемента в таблице нет.
If new_value = UNUSED Or probes >= NumEntries Then
LocateItem = HASH_NOT_FOUND
pos = -1
Exit Function
End If
pos = (pos + 1) Mod NumEntries
probes = probes + 1
Loop
End Function
Программа Linear демонстрирует открытую адресацию с линейной проверкой. Заполнив поле Table Size (Размер таблицы) и нажав на кнопку Create table (Создать таблицу), можно создавать хеш‑таблицы различных размеров. Затем можно ввести значение элемента и нажать на кнопку Add (Добавить) или Find (Найти), чтобы вставить или найти элемент в таблице.
Чтобы добавить в таблицу сразу несколько случайных значений, введите число элементов, которые вы хотите добавить и максимальное значение, которое они могут иметь в области Random Items (Случайные элементы), и затем нажмите на кнопку Create Items (Создать элементы).
После завершения программой какой‑либо операции она выводит статус операции (успешное или безуспешное завершение) и длину тестовой последовательности. Она также выводит среднюю длину успешной и безуспешной тестовой последовательностей. Программа вычисляет среднюю длину тестовой последовательности, выполняя поиск всех значений от 1 до максимального значения в таблице.
В табл. 11.1 приведена средняя длина успешных и безуспешных тестовых последовательностей, полученных в программе Linear для таблицы со 100 ячейками, элементы в которых находятся в диапазоне от 1 до 999. Из таблицы видно, что производительность алгоритма падает по мере заполнения таблицы. Является ли производительность приемлемой, зависит от того, как используется таблица. Если программа тратит большую часть времени на поиск значений, которые есть в таблице, то производительность может быть неплохой, даже если таблица практически заполнена. Если же программа часто ищет значения, которых нет в таблице, то производительность может быть очень низкой, если таблица переполнена.
Как правило, хеширование обеспечивает приемлемую производительность, не расходуя при этом слишком много памяти, если заполнено от 50 до 75 процентов таблицы. Если таблица заполнена больше, чем на 75 процентов, то производительность падает. Если таблица заполнена меньше, чем на 50 процентов, то она занимает больше памяти, чем это необходимо. Это делает открытую адресацию хорошим примером пространственно‑временного компромисса. Увеличивая хеш‑таблицу, можно уменьшить время, необходимое для вставки или поиска элементов.
=======297
@Таблица 11.1. Длина успешной и безуспешной тестовых последовательностей
Линейная проверка имеет одно неприятное свойство, которое называется первичной кластеризацией (primary clustering). После добавления большого числа элементов в таблицу, возникает конфликт между новыми элементами и уже имеющимися кластерами, при этом для вставки нового элемента нужно обойти кластер, чтобы найти пустую ячейку.
Чтобы увидеть, как образуются кластеры, предположим, что вначале имеется пустая хеш‑таблица, которая может содержать N элементов. Если выбрать случайное число и вставить его в таблицу, то вероятность того, что элемент займет любую заданную позицию P в таблице, равна 1/N.
При вставке второго случайно выбранного элемента, он может отобразиться на ту же позицию с вероятностью 1/N. Из‑за конфликта в этом случае он помещается в позицию P + 1. Также существует вероятность 1/N, что элемент и должен располагаться в позиции P + 1, и вероятность 1/N, что он должен находиться в позиции P - 1. Во всех этих трех случаях новый элемент располагается рядом с предыдущим. Таким образом, в целом существует вероятность 3/N того, что 2 элемента окажутся расположенными вблизи друг от друга, образуя небольшой кластер.
По мере роста кластера вероятность того, что следующие элементы будут располагаться вблизи кластера, возрастает. Если в кластере находится два элемента, то вероятность того, что очередной элемент присоединится к кластеру, равна 4/N, если в кластере четыре элемента, то эта вероятность равна 6/N, и так далее.
Что еще хуже, если кластер начинает расти, то его рост продолжается до тех пор, пока он не столкнется с соседним кластером. Два кластера сливаются, образуя кластер еще большего размера, который растет еще быстрее, сливается с другими кластерами и образует еще большие кластеры.
======298
В идеальном случае хеш‑таблица должна быть наполовину пуста, и элементы в ней должны чередоваться с пустыми ячейками. Тогда с вероятностью 50 процентов алгоритм сразу же найдет пустую ячейку для нового добавляемого элемента. Также существует 50‑процентная вероятность того, что он найдет пустую ячейку после проверки всего лишь двух позиций в таблице. Средняя длина тестовой последовательности равна 0,5 * 1 + 0,5 * 2 = 1,5.
В наихудшем случае все элементы в таблице будут сгруппированы в один гигантский кластер. При этом все еще есть 50‑процентная вероятность того, что алгоритм сразу найдет пустую ячейку, в которую можно поместить новый элемент. Тем не менее, если алгоритм не найдет пустую ячейку на первом шаге, то поиск свободной ячейки потребует гораздо больше времени. Если элемент должен находиться на первой позиции кластера, то алгоритму придется проверить все элементы в кластере, чтобы найти свободную ячейку. В среднем для вставки элемента при таком распределении потребуется гораздо больше времени, чем когда элементы равномерно распределены по таблице.
На практике, степень кластеризации будет находиться между этими двумя крайними случаями. Вы можете использовать программу Linear для исследования эффекта кластеризации. Запустите программу и создайте хеш‑таблицу со 100 ячейками, а затем добавьте 50 случайных элементов со значениями до 999. Вы обнаружите, что образовалось несколько кластеров. В одном из тестов 38 из 50 элементов стали частью кластеров. Если добавить еще 25 элементов к таблице, то большинство элементов будут входить в кластеры. В другом тесте 70 из 75 элементов были сгруппированы в кластеры.
При выполнении поиска в упорядоченном списке методом полного перебора, можно остановить поиск, если найдется элемент со значением большим, чем искомое. Так как при этом возможное положение искомого элемента уже позади, значит искомый элемент отсутствует в списке.
Можно использовать похожую идею при поиске в хеш‑таблице. Предположим, что можно организовать элементы в хеш‑таблице таким образом, что значения в каждой тестовой последовательности находятся в порядке возрастания. Тогда при выполнении тестовой последовательности во время поиска элемента можно прекратить поиск, если встретится элемент со значением, большим искомого. В этом случае позиция, в которой должен был бы находиться искомый элемент, уже осталась позади, и значит элемента нет в таблице.
Public Function LocateItem(Value As Long, pos As Integer, _
probes As Integer) As Integer
Dim new_value As Long
probes = 1
pos = (Value Mod m_NumEntries)
Do
new_value = m_HashTable(pos)
' Элемента в таблице нет.
If new_value = UNUSED Or probes > NumEntries Then
LocateItem = HASH_NOT_FOUND
pos = -1
Exit Function
End If
' Элемент найден или его нет в таблице.
If new_value >= Value Then Exit Do
pos = (pos + 1) Mod NumEntries
probes = probes + 1
Loop
If Value = new_value Then
LocateItem = HASH_FOUND
Else
LocateItem = HASH_NOT_FOUND
End If
End Function
Для того, чтобы этот метод работал, необходимо организовать элементы в хеш‑таблице так, чтобы при выполнении тестовой последовательности они встречались в возрастающем порядке. Существует достаточно простой метод вставки элементов, который гарантирует такое расположение элементов.
Когда в таблицу вставляется новый элемент, для него выполняется тестовая последовательность. Если найдется свободная ячейка, то элемент вставляется в эту позицию и процедура завершена. Если встречается элемент, значение которого больше значения нового элемента, то они меняются местами и продолжается выполнение тестовой последовательности для большего элемента. При этом может встретиться элемент с еще большим значением. Тогда элементы снова меняются местами, и выполняется поиск нового местоположения для этого элемента. Этот процесс продолжается до тех пор, пока, в конце концов, не найдется свободная ячейка, при этом возможно несколько элементов меняются местами.
========299-300
Public Function InsertItem(ByVal Value As Long, pos As Integer,_ probes As Integer) As Integer
Dim new_value As Long
Dim status As Integer
' Проверить, заполнена ли таблица.
If m_NumUnused < 1 Then
' Поиск элемента.
status = LocateItem(Value, pos, probes)
If status = HASH_FOUND Then
InsertItem = HASH_FOUND
Else
InsertItem = HASH_TABLE_FULL
pos = -1
End If
Exit Function
End If
probes = 1
pos = (Value Mod m_NumEntries)
Do
new_value = m_HashTable(pos)
' Если значение найдено, поиск завершен.
If new_value = Value Then
InsertItem = HASH_FOUND
Exit Function
End If
' Если ячейка свободна, элемент должен находиться в ней.
If new_value = UNUSED Then
m_HashTable(pos) = Value
HashForm.TableControl(pos).Caption = Format$(Value)
InsertItem = HASH_INSERTED
m_NumUnused = m_NumUnused - 1
Exit Function
End If
' Если значение в ячейке таблицы больше значения
' элемента, поменять их местами и продолжить.
If new_value > Value Then
m_HashTable(pos) = Value
Value = new_value
End If
pos = (pos + 1) Mod NumEntries
probes = probes + 1
Loop
End Function
Программа Ordered демонстрирует открытую адресацию с упорядоченной линейной проверкой. Она идентична программе Linear, но использует упорядоченную хеш‑таблицу.
В табл. 11.2 приведена средняя длина успешной и безуспешной тестовых последовательностей при использовании линейной и упорядоченной линейной проверок. Средняя длина успешной проверки для обоих методов почти одинакова, но в случае неуспеха упорядоченная линейная проверка выполняется намного быстрее. Разница в особенности заметна, если хеш‑таблица заполнена более, чем на 70 процентов.
=========301
@Таблица 11.2. Длина поиска при использовании линейной и упорядоченной линейной проверки
В обоих методах для вставки нового элемента требуется примерно одинаковое число шагов. Чтобы вставить элемент K в таблицу, каждый из методов начинает с позиции (K Mod NumEntries) и перемещается по таблице до тех пор, пока не найдет свободную ячейку. Во время упорядоченного хеширования может потребоваться поменять вставляемый элемент на другие в его тестовой последовательности. Если элементы представляют собой записи большого размера, то на это может потребоваться больше времени, особенно если записи находятся на диске или каком‑либо другом медленном запоминающем устройстве.
Упорядоченная линейная проверка определенно является лучшим выбором, если вы знаете, что программе придется совершать большое число безуспешных операций поиска. Если программа будет часто выполнять поиск элементов, которых нет в таблице, или элементы таблицы имеют большой размер и перемещать их достаточно сложно, то можно получить лучшую производительность при использовании неупорядоченной линейной проверки.
Один из способов уменьшить первичную кластеризацию состоит в том, чтобы использовать хеш‑функцию следующего вида:
Hash(K, P) = (K + P2) Mod N где P = 0, 1, 2, ...
Предположим, что при вставке элемента в хеш‑таблицу он отображается в кластер, образованный другими элементами. Если элемент отображается в позицию возле начала кластера, то возникнет еще несколько конфликтов прежде, чем найдется свободная ячейка для элемента. По мере роста параметра P в тестовой функции, значение этой функции быстро меняется. Это означает, что позиция, в которую попадет элемент в конечном итоге, возможно, окажется далеко от кластера.
=======302
На рис. 11.8 показана хеш‑таблица, содержащая большой кластер элементов. На нем также показаны тестовые последовательности, которые возникают при попытке вставить два различных элемента в позиции, занимаемые кластером. Обе эти тестовые последовательности заканчиваются в точке, которая не прилегает к кластеру, поэтому после вставки этих элементов размер кластера не увеличивается.
Следующий код демонстрирует поиск элемента с использованием квадратичной проверки (quadratic probing):
Public Function LocateItem(Value As Long, pos As Integer, probes As Integer) As Integer
Dim new_value As Long
probes = 1
pos = (Value Mod m_NumEntries)
Do
new_value = m_HashTable(pos)
' Элемент найден.
If new_value = Value Then
LocateItem = HASH_FOUND
Exit Function
End If
' Элемента нет в таблице.
If new_value = UNUSED Or probes > NumEntries Then
LocateItem = HASH_NOT_FOUND
pos = -1
Exit Function
End If
pos = (Value + probes * probes) Mod NumEntries
probes = probes + 1
Loop
End Function
Программа Quad демонстрирует открытую адресацию с использованием квадратичной проверки. Она аналогична программе Linear, но использует квадратичную, а не линейную проверку.
В табл. 11.3 приведена средняя длина тестовых последовательностей, полученных в программах Linear и Quad для хеш‑таблицы со 100 ячейками, значения элементов в которой находятся в диапазоне от 1 до 999. Квадратичная проверка обычно дает лучшие результаты.
@Рис. 11.8. Квадратичная проверка
======303
@Таблица 11.3. Длина поиска при использовании линейной и квадратичной проверки
Квадратичная проверка также имеет некоторые недостатки. Из‑за способа формирования тестовой последовательности, нельзя гарантировать, что она обойдет все ячейки в таблице, что означает, что иногда в таблицу нельзя будет вставить элемент, даже если она не заполнена до конца.
Например, рассмотрим небольшую хеш‑таблицу, состоящую всего из шести ячеек. Тестовая последовательность для числа 3 будет следующей:
3
3 + 12 = 4 = 4 (Mod 6)
3 + 22 = 7 = 1 (Mod 6)
3 + 32 = 12 = 0 (Mod 6)
3 + 42 = 19 = 1 (Mod 6)
3 + 52 = 28 = 4 (Mod 6)
3 + 62 = 39 = 3 (Mod 6)
3 + 72 = 52 = 4 (Mod 6)
3 + 82 = 67 = 1 (Mod 6)
3 + 92 = 84 = 0 (Mod 6)
3 + 102 = 103 = 1 (Mod 6)
и так далее.
Эта тестовая последовательность обращается к позициям 1 и 4 дважды перед тем, как обратиться к позиции 3, и никогда не попадает в позиции 2 и 5. Чтобы пронаблюдать этот эффект, создайте в программе Quad хеш‑таблицу с шестью ячейками, а затем вставьте элементы 1, 3, 4, 6 и 9. Программа определит, что таблица заполнена целиком, хотя две ячейки и остались неиспользованными. Тестовая последовательность для элемента 9 не обращается к элементам 2 и 5, поэтому программа не может вставить в таблицу новый элемент.
=======304
Можно показать, что квадратичная тестовая последовательность будет обращаться, по меньшей мере, к N/2 ячеек таблицы, если размер таблицы N — простое число. Хотя при этом гарантируется некоторый уровень производительности, все равно могут возникнуть проблемы, если таблица почти заполнена. Так как производительность для почти заполненной таблицы в любом случае сильно падает, то возможно лучше будет просто увеличить размер хеш-таблицы, а не беспокоиться о том, сможет ли тестовая последовательность найти свободную ячейку.
Не столь очевидная проблема, которая возникает при применении квадратичной проверки, заключается в том, что хотя она устраняет первичную кластеризацию, во время нее может возникать похожая проблема, которая называется вторичной кластеризацией (secondary clustering). Если два элемента отображаются в одну ячейку, для них будет выполняться одна и так же тестовая последовательность. Если множество элементов отображаются на одну из ячеек таблицы, они образуют вторичный кластер, который распределен по хеш‑таблице. Если появляется новый элемент с тем же самым начальным значением, для него приходится выполнять длительную тестовую последовательность, прежде чем он обойдет элементы во вторичном кластере.
На рис. 11.9 показана хеш‑таблица, которая может содержать 10 ячеек. В таблице находятся элементы 2, 12, 22 и 32, которые все изначально отображаются в позицию 2. Если попытаться вставить в таблицу элемент 42, то нужно будет выполнить длительную тестовую последовательность, которая обойдет все эти элементы, прежде чем найдет свободную ячейку.
Степень кластеризации растет, если в кластер добавляются элементы, которые отображаются на уже занятые кластером ячейки. Вторичная кластеризация возникает, когда для элементов, которые первоначально должны занимать одну и ту же ячейку, выполняется одна и та же тестовая последовательность, и образуется вторичный кластер, распределенный по хеш‑таблице. Можно устранить оба эти эффекта, если сделать так, чтобы для разных элементов выполнялись различные тестовые последовательности, даже если элементы первоначально и должны были занимать одну и ту же ячейку.
Один из способов сделать это заключается в использовании в тестовой последовательности генератора псевдослучайных чисел. Для вычисления тестовой последовательности для элемента, его значение используется для инициализации генератора случайных чисел. Затем для построения тестовой последовательности используются последовательные случайные числа, получаемые на выходе генератора. Это называется псевдослучайной проверкой (pseudo‑random probing).
Когда позднее требуется найти элемент в хеш‑таблице, генератор случайных чисел снова инициализируется значением элемента, при этом на выходе генератора мы получим ту же самую последовательность чисел, которая использовалась для вставки элемента в таблицу. Используя эти числа, можно воссоздать исходную тестовую последовательность и найти элемент.
@Рис. 11.9. Вторичная кластеризация
==========305
Если используется качественный генератор случайных чисел, то разные значения элементов будут давать различные случайные числа и соответственно разные тестовые последовательности. Даже если два значения изначально отображаются на одну и ту же ячейку, то следующие позиции в тестовой последовательности будут уже различными. В этом случае в хеш‑таблице не будет возникать первичная или вторичная кластеризация.
Можно проинициализировать генератор случайных чисел Visual Basic, используя начальное число, при помощи двух строчек кода:
Rnd -1
Randomize seed_value
Оператор Rnd дает одну и ту же последовательность чисел после инициализации одним и тем же начальным числом. Следующий кода показывает, как можно выполнять поиск элемента с использованием псевдослучайной проверки:
Public Function LocateItem(Value As Long, pos As Integer, _
probes As Integer) As Integer
Dim new_value As Long
' Проинициализировать генератор случайных чисел.
Rnd -1
Randomize Value
probes = 1
pos = Int(Rnd * m_NumEntries)
Do
new_value = m_HashTable(pos)
' Элемент найден.
If new_value = Value Then
LocateItem = HASH_FOUND
Exit Function
End If
' Элемента нет в таблице.
If new_value = UNUSED Or probes > NumEntries Then
LocateItem = HASH_NOT_FOUND
pos = -1
Exit Function
End If
pos = Int(Rnd * m_NumEntries)
probes = probes + 1
Loop
End Function
=======306
Программа Rand демонстрирует открытую адресацию с псевдослучайной проверкой. Она аналогична программам Linear и Quad, но использует псевдослучайную, а не линейную или квадратичную проверку.
В табл. 11.4 приведена примерная средняя длина тестовой последовательности, полученной в программах Quad или Rand для хеш‑таблицы со 100 ячейками и элементами, значения которых находятся в диапазоне от 1 до 999. Обычно псевдослучайная проверка дает наилучшие результаты, хотя разница между псевдослучайной и квадратичной проверками не так велика, как между линейной и квадратичной.
Псевдослучайная проверка также имеет свои недостатки. Так как тестовая последовательность выбирается псевдослучайно, нельзя точно предсказать, насколько быстро алгоритм обойдет все элементы в таблице. Если таблица меньше, чем число возможных псевдослучайных значений, то существует вероятность того, что тестовая последовательность обратится к одному значению несколько раз до того, как она выберет другие значения в таблице. Возможно также, что тестовая последовательность будет пропускать какую‑либо ячейку в таблице и не сможет вставить новый элемент, даже если таблица не заполнена до конца.
Так же, как и в случае квадратичной проверки, эти эффекты могут вызвать затруднения, только если таблица почти заполнена. В этом случае увеличение таблицы дает гораздо больший прирост производительности, чем поиск неиспользуемых ячеек таблицы.
@Рис. 11.4. Длина поиска при использовании квадратичной и псевдослучайной проверки
=======307
Удаление элементов из хеш‑таблицы, в которой используется открытая адресация, выполняется не так просто, как удаление их из таблицы, использующей связные списки или блоки. Просто удалить элемент из таблицы нельзя, так как он может находиться в тестовой последовательности другого элемента.
Предположим, что элемент A находится в тестовой последовательности элемента B. Если удалить из таблицы элемент A, найти элемент B будет невозможно. Во время поиска элемента B встретится пустая ячейка, которая осталась после удаления элемента A, поэтому будет сделан неправильный вывод о том, что элемент B отсутствует в таблице.
Вместо удаления элемента из хеш‑таблицы можно просто пометить его как удаленный. Можно использовать эту ячейку позднее, если она встретится во время выполнения вставки нового элемента в таблицу. Если помеченный элемент встречается во время поиска другого элемента, он просто игнорируется и тестовая последовательность продолжится.
После того, как большое число элементов будет помечено как удаленные, в хеш‑таблице может оказаться множество неиспользуемых ячеек, и при поиске элементов достаточно много времени будет уходить на пропуск удаленных элементов. В конце концов, может потребоваться рехеширование таблицы для освобождения неиспользуемой памяти.
Чтобы освободить удаленные элементы из хеш‑таблицы, можно выполнить ее рехеширование (rehashing) на месте. Чтобы этот алгоритм мог работать, нужно иметь какой‑то способ для определения, было ли выполнено рехеширование элемента. Простейший способ сделать это — определить элементы в виде структур данных, содержащих поле Rehashed.
Type ItemType
Value As Long
Rehashed As Boolean
End Type
Вначале присвоим полю Rehashed значение false. Затем выполним проход по таблице в поиске ячеек, которые не помечены как удаленные, и для которых еще не было выполнено рехеширование.
Если такой элемент встретится, то выполняется его удаление из таблицы и повторное хеширование, при этом выполняется обычная тестовая последовательность для элемента. Если встречается свободная или помеченная как удаленная ячейка, элемент размещается в ней, помечается как рехешированный, и продолжается проверка остальных элементов, для которых еще не было выполнено рехеширование.
Если при выполнении рехеширования найдется элемент, который уже был помечен как рехешированный, то тестовая последовательность продолжается. Если затем встретится элемент, для которого еще не было выполнено рехеширование, то элементы меняются местами, текущая ячейка помечается как рехешированная и процесс начинается снова.
======308
Если хеш‑таблица становится почти заполненной, производительность значительно падает. В этом случае может понадобиться увеличение размера таблицы, чтобы в ней было больше места для элементов. И наоборот, если в таблице слишком мало ячеек, может потребоваться уменьшить ее, чтобы освободить занимаемую память. Используя методы, похожие на те, которые использовались при рехешировании таблицы на месте, можно увеличивать и уменьшать размер хеш‑таблицы.
Чтобы увеличить хеш‑таблицу, вначале размер массива, в котором она находится, увеличивается при помощи оператора Dim Preserve. Затем выполняется рехеширование таблицы, при этом элементы могут занимать ячейки в созданной свободной области в конце таблицы. После завершения рехеширования таблица будет готова к использованию.
Чтобы уменьшить размер таблицы, вначале определим, сколько элементов должно содержаться в массиве таблицы после уменьшения. Затем выполняем рехеширование таблицы, причем элементы помещаются только в уменьшенную часть таблицы. После завершения рехеширования всех элементов, размер массива уменьшается при помощи оператора ReDim Preserve.
Следующий код демонстрирует рехеширование таблицы с использованием линейной проверки. Код для рехеширования таблицы с использованием квадратичной или псевдослучайной проверки выглядит почти так же:
Public Sub Rehash()
Dim i As Integer
Dim pos As Integer
Dim probes As Integer
Dim Value As Long
Dim new_value As Long
' Пометить все элементы как нерехешированные.
For i = 0 To NumEntries - 1
m_HashTable(i).Rehashed = False
Next i
' Поиск нерехешированных элементов.
For i = 0 To NumEntries - 1
If Not m_HashTable(i).Rehashed Then
Value = m_HashTable(i).Value
m_HashTable(i).Value = UNUSED
If Value <> DELETED And Value <> UNUSED Then
' Выполнить тестовую последовательность
' для этого элемента, пока не найдется свободная,
' удаленная или нерехешированная ячейка.
probes = 0
Do
pos = (Value + probes) Mod NumEntries
new_value = m_HashTable(pos).Value
' Если ячейка свободна или помечена как
' удаленная, поместить элемент в нее.
If new_value = UNUSED Or _
new_value = DELETED _
Then
m_HashTable(pos).Value = Value
m_HashTable(pos).Rehashed = True
Exit Do
End If
' Если ячейка не помечена как рехешированная,
' поменять их местами и продолжить.
If Not m_HashTable(pos).Rehashed Then
m_HashTable(pos).Value = Value
m_HashTable(pos).Rehashed = True
Value = new_value
probes = 0
Else
probes = probes + 1
End If
Loop
End If
End If
Next i
End Sub
Программа Rehash использует открытую адресацию с линейной проверкой. Она аналогична программе Linear, но позволяет также помечать объекты как удаленные и выполнять рехеширование таблицы.
Различные типы хеш‑таблиц, описанные в этой главе, имеют свои преимущества и недостатки.
Для хеш‑таблиц, которые используют связные списки или блоки можно легко изменять размер таблицы и удалять из нее элементы. Использование блоков также позволяет легко работать с таблицами на диске, позволяя считать за одно обращение к диску сразу множество элементов данных. Тем не менее, оба эти метода являются более медленными, чем открытая адресация.
Линейная проверка проста и позволяет достаточно быстро вставлять и удалять элементы из таблицы. Применение упорядоченной линейной проверки позволяет быстрее, чем в случае неупорядоченной линейной проверки, установить, что элемент отсутствует в таблице. С другой стороны, вставку элементов в таблицу при этом выполнить сложнее.
Квадратичная проверка позволяет избежать кластеризации, которая характерна для линейной проверки, и поэтому обеспечивает более высокую производительность. Псевдослучайная проверка обеспечивает еще более высокую производительность, так как при этом удается избавиться как от первичной, так и от вторичной кластеризации.
В табл. 11.5 приведены преимущества и недостатки различных методов хеширования.
======310
@Таблица 11.5. Преимущества и недостатки различных методов хеширования
Выбор наилучшего метода хеширования для данного приложения зависит от данных задачи и способов их использования. При применении разных схем достигаются различные компромиссы между занимаемой памятью, скоростью и простотой изменений. Табл. 11.5 может помочь вам выбрать наилучший алгоритм для вашего приложения.
=======311
Глава 12. Сетевые алгоритмыВ 6 и 7 главах обсуждались алгоритмы работы с деревьями. Данная глава посвящена более общей теме сетей. Сети играют важную роль во многих приложениях. Их можно использовать для моделирования таких объектов, как сеть улиц, телефонная или электрическая сеть, водопровод, канализация, водосток, сеть авиаперевозок или железных дорог. Менее очевидна возможность использования сетей для решения таких задач, как разбиение на районы, составление расписания методом критического пути, планирование коллективной работы или распределения работы.
Как и в определении деревьев, сетью (network) или графом (graph) называется набор узлов (nodes), соединенных ребрами (edges) или связями (links). Для графа, в отличие от дерева, не определено понятие родительского или дочернего узла.
С ребрами сети может быть связано соответствующее направление, тогда в этом случае сеть называется ориентированной сетью (directed network). Для каждой связи можно также определить ее цену (cost). Для сети дорог, например, цена может быть равна времени, которое займет проезд по отрезку дороги, представленному ребром сети. В телефонной сети цена может быть равна коэффициенту электрических потерь в кабеле, представленном связью. На рис. 12.1 показана небольшая ориентированная сеть, в которой числа рядом с ребрами соответствуют цене ребра.
Путем (path) между узлами A и B называется последовательность ребер, которая связывает два этих узла между собой. Если между любыми двумя узлами сети есть не больше одного ребра, то путь можно однозначно описать, перечислив входящие в него узлы. Так как такое описание проще представить наглядно, то пути по возможности описываются таким образом. На рис. 12.1 путь, проходящий через узлы B, E, F, G,E и D, соединяет узлы B и D.
Циклом (cycle) называется путь который связывает узел с ним самим. Путь E, F, G, E на рис. 12.1 является циклом. Путь называется простым (simple), если он не содержит циклов. Путь B, E, F, G, E, D не является простым, так как он содержит цикл E, F, G, E.
Если существует какой‑либо путь между двумя узлами, то должен существовать и простой путь между ними. Этот путь можно найти, если удалить все циклы из исходного пути. Например, если заменить цикл E, F, G, E в пути B, E, F, G, E, D на узел E, то получится простой путь B, E, D, связывающий узлы B и D.
=======313
@Рис. 12.1. Ориентированная сеть с ценой ребер
Сеть называется связной (connected), если между любыми двумя узлами существует хотя бы один путь. В ориентированной сети не всегда очевидно, является ли сеть связной. На рис. 12.2 сеть слева является связной. Сеть справа не является связной, так как не существует пути из узла E в узел C.
В 6 главе было описано несколько представлений деревьев. Большинство из них применимо также и для работы с сетями. Например, представления полными узлами, списком потомков (списком соседей для сетей) или нумерацией связей также могут использоваться для хранения сетей. За описанием этих представлений обратитесь к 6 главе.
@Рис. 12.2. Связная (слева) и несвязная (справа) сети
======314
Для различных приложений могут лучше подходить разные представления сети. Представление полными узлами обеспечивает хорошие результаты, если каждый узел в сети связан с небольшим числом ребер. Представление списком соседних узлов обеспечивает большую гибкость, чем представление полными узлами, а представление нумерацией связей, хотя его сложнее модифицировать, обеспечивает более высокую производительность.
Кроме этого, некоторые варианты представления ребер могут упростить работу с определенными типами сетей. Эти представления используют один класс для узлов и другой — для представления связей. Применение класса для связей облегчает работу со свойствами связей, такими, как цена связи.
Например, ориентированная сеть с ценой связей может использовать следующее определения для класса узла:
Public Id As Integer ' Номер узла.
Public Links As Collection ' Связи, ведущие к соседним узлам.
Можно использовать следующее определение класса связей:
Public ToNode As NetworkNode ' Узел на другом конце связи.
Public Cost As Integer ' Цена связи.
Используя эти определения, программа может найти связь с наименьшей ценой, используя следующий код:
Dim link As NetworkLink
Dim best_link As NetworkLink
Dim best_cost As Integer
best_cost = 32767
For Each link In node.Links
If link.cost < best_cost Then
Set best_link = link
best_cost = link.cost
End If
Next link
Классы node и link часто расширяются для удобства работы с конкретными алгоритмами. Например, к классу node часто добавляется флаг Marked. Если программа обращается к узлу, то она устанавливает значение поля Marked равным true, чтобы знать, что узел уже был проверен.
Программа, управляющая неориентированной сетью, может использовать немного другое представление. Класс node остается тем же, что и раньше, но класс link включает ссылку на оба узла на концах связи.
Public Node1 As NetwokNode ' Один из узлов на конце связи.
Public Node2 As NetwokNode ' Другой узел.
Public Cost As Integer ' Цена связи.
Для неориентированной сети, предыдущее представление использовало бы два объекта для представления каждой связи — по одному для каждого из направлений связи. В новой версии каждая связь представлена одним объектом. Это представление достаточно наглядно, поэтому оно используется далее в этой главе.
=======315
Используя это представление, программа NetEdit позволяет оперировать неориентированными сетями с ценой связей. Меню File (Файл) позволяет загружать и сохранять сети в файлах. Команды в меню Edit (Правка) позволяют вам вставлять и удалять узлы и связи. На рис. 12.3 показано окно программы NetEdit.
Директория OldSrc\Ch12 содержит программы, которые используют представление нумерацией связей. Эти программы немного сложнее понять, но они обычно работают быстрее. Они не описаны в тексте, но использованные в них методы похожи на те, которые применялись в программах, написанных для 4 версии Visual Basic. Например, обе программы Src\Ch12\Paths и OldSrc\Ch12\Paths находят кратчайший маршрут, используя описанный ниже алгоритм установки меток. Основное отличие между ними заключается в том, что первая программа использует коллекции и классы, а вторая — псевдоуказатели и представление нумерацией связей.
Корень дерева — это единственный узел, не имеющий родителя. Можно найти любой узел в сети, начав от корня и следуя по указателям на дочерние узлы. Таким образом, узел представляет основание дерева. Если ввести переменную, которая будет содержать указатель на корневой узел, то впоследствии можно будет получить доступ ко всем узлам в дереве.
Сети не всегда содержат узел, который занимает такое особое положение. В несвязной сети может не существовать способа обойти все узлы по связям, начав с одного узла.
Поэтому программы, работающие с сетями, обычно содержат полный список всех узлов в сети. Программа также может хранить полный список всех связей. При помощи этих списков можно легко выполнить какие‑либо действия над всеми узлами или связями в сети. Например, если программа хранит указатели на узлы и связи в коллекциях Nodes и Links, она может вывести сеть на экран при помощи следующего метода:
@Рис. 12.3. Программа NetEdit
=======316
Dim node As NetworkNode
dim link As NetworkLink
For Each link in links
' Нарисовать связь.
:
Next link
For Each node in nodes
' Нарисовать узел.
:
Next node
Программа NetEdit использует коллекции Nodes и Links для вывода сетей на экран.
Обход сети выполняется аналогично обходу дерева. Можно обходить сеть, используя либо обход в глубину, либо обход в ширину. Обход в ширину обычно похож на прямой обход дерева, хотя для сетей можно определить также обратный и даже симметричный обход.
Алгоритм для выполнения прямого обхода двоичного дерева, описанный в 6 главе, формулируется так:
Обратиться к узлу.
Выполнить рекурсивный прямой обход левого поддерева.
Выполнить рекурсивный прямой обход правого поддерева.
В дереве между связанными между собой узлами существует отношение родитель‑потомок. Так как алгоритм начинается с корневого узла и всегда выполняется сверху вниз, он не обращается дважды ни к одному узлу.
В сети узлы не обязательно связаны в направлении сверху вниз. Если попытаться применить к сети алгоритм прямого обхода, может возникнуть бесконечный цикл.
Чтобы избежать этого, алгоритм должен помечать узел после обращения к нему, при этом при поиске в соседних узлах, обращение происходит только к узлам, которые еще не были помечены. После того, как алгоритм завершит работу, все узлы в сети будут помечены (если сеть является связной). Алгоритм прямого обхода сети формулируется так:
Пометить узел.
Обратиться к узлу.
Выполнить рекурсивный обход не помеченных соседних узлов.
========317
В Visual Basic можно добавить флаг Marked к классу NetworkNode.
Public Id As Long
Public Marked As Boolean
Public Links As Collection
Класс NetworkNode может включать открытую процедуру для обхода сети, начиная с этого узла. Процедура узла PreorderPrint обращается ко всем непомеченным узлам, которые доступны из данного узла. Если сеть является связной, то при таком обходе произойдет обращение ко всем узлам сети.
Public Sub PreorderPrint()
Dim link As NoworkLink
Dim node As NetworkNode
' Пометить узел.
Marked = True
' Обратиться к непомеченным узлам.
For Each link In Links
' Найти соседний узел.
If link.Node1 Is Me Then
Set node = link.Node2
Else
Set node = link.Node1
End If
' Определить, требуется ли обращение к соседнему узлу.
If Not node.Marked Then node.PreorderPrint
Next link
End Sub
Так как эта процедура не обращается ни к одному узлу дважды, то коллекция обходимых связей не содержит циклов и образует дерево.
Если сеть является связной, то дерево будет обходить все узлы сети. Так как это дерево охватывает все узлы сети, то оно называется остовным деревом (spanning tree). На рис. 12.4 показана небольшая сеть с остовным деревом с корнем в узле A, которое изображено жирными линиями.
Можно использовать похожий подход с пометкой узлов для преобразования обхода дерева в ширину в сетевой алгоритм. Алгоритм обхода дерева начинается с помещения корневого узла в очередь. Затем первый узел удаляется из очереди, происходит обращение к узлу, и затем в конце очереди помещаются его дочерние узлы. Затем этот процесс повторяется до тех пор, пока очередь не опустеет.
======318
@Рис. 12.4. Остовное дерево
В алгоритме обхода сети нужно вначале убедиться, что узел не проверялся раньше или он уже не находится в очереди. Для этого мы помечаем каждый узел, который помещается в очередь. Сетевая версия этого алгоритма выглядит так:
Пометить первый узел (который будет корнем остовного дерева) и добавить его в конец очереди.
Повторять следующие шаги до тех пор, пока очередь не опустеет:
a) Удалить из очереди первый узел и обратиться к нему.
b) Для каждого из непомеченных соседних узлов, пометить его и добавить в конец очереди.
Следующая процедура печатает список узлов сети в порядке обхода в ширину:
Public Sub BreadthFirstPrint(root As NetworkNode)
Dim queue As New Collection
Dim node As NetworkNode
Dim neighbor As NetworkNode
Dim link As NetworkLink
' Поместить корень в очередь.
root.Marked = True
queue.Add root
' Многократно помещать верхний элемент в очередь
' пока очередь не опустеет.
Do While queue.Count > 0
' Выбрать следующий узел из очереди.
Set node = queue.Item(1)
queue.Remove 1
' Обратиться к узлу.
Print node.Id
' Добавить в очередь все непомеченные соседние узлы.
For Each link In node.Links
' Найти соседний узел.
If link.Node1 Is Me Then
Set neighbor = link.Node2
Else
Set neighbor = link.Node1
End If
' Проверить, нужно ли обращение к соседнему узлу.
If Not neighbor.Marked Then queue.Add neighbor
Next link
Loop
End Sub
Если задана сеть с ценой связей, то наименьшим остовным деревом (minimal spanning tree) называется остовное дерево, в котором суммарная цена всех связей в дереве будет наименьшей. Наименьшее остовное дерево можно использовать, чтобы связать все узлы в сети путем с наименьшей ценой.
Например, предположим, что требуется разработать телефонную сеть, которая должна соединить шесть городов. Можно проложить магистральный кабель между всеми парами городов, но это будет неоправданно дорого. Меньшую стоимость будет иметь решение, при котором города будут соединены связями, которые содержатся в наименьшем остовном дереве. На рис. 12.5 показаны шесть городов, каждые два из которых соединены магистральным кабелем. Жирными линиями нарисовано наименьшее остовное дерево.
Заметьте, что сеть может иметь несколько наименьших остовных деревьев. На рис. 12.6 показаны два изображения сети с двумя различными наименьшими остовными деревьями, которые нарисованы жирными линиями. Полная цена обоих деревьев равна 32.
@Рис. 12.5. Магистральные телефонные кабели, связывающие шесть городов
========320
@Рис. 12.6. Два различных наименьших остовных дерева для одной сети
Существует простой алгоритм поиска наименьшего остовного дерева для сети. Вначале поместим в остовное дерево любой узел. Затем найдем связь с наименьшей ценой, которая соединяет узел в дереве с узлом, который еще не помещен в дерево. Добавим эту связь и соответствующий узел в дерево. Затем эта процедура повторяется до тех пор, пока все узлы не окажутся в дереве.
Этот алгоритм похож на эвристику восхождения на холм, описанную в 8 главе. На каждом шаге оба алгоритма изменяют решение, пытаясь его максимально улучшить. Алгоритм остовного дерева на каждом шаге выбирает связь с наименьшей ценой, которая добавляет новый узел в дерево. В отличие от эвристики восхождения на холм, которая не всегда находит наилучшее решение, этот алгоритм гарантированно находит наименьшее остовное дерево.
Подобные алгоритмы, которые находят глобальный оптимум, при помощи серии локально оптимальных приближений называются поглощающими алгоритмами[RV20] (greedy algorithms). Можно представлять себе поглощающие алгоритмы как алгоритмы типа восхождения на холм, не являющиеся при этом эвристиками — они гарантированно находят наилучшее возможное решение.
Алгоритм наименьшего остовного дерева использует коллекцию для хранения списка связей, которые могут быть добавлены к остовному дереву. Вначале алгоритм помещает в этот список связи корневого узла. Затем проводится поиск связи с наименьшей ценой в этом списке. Чтобы максимально ускорить поиск, программа может использовать приоритетную очередь типа описанной в 9 главе. Или наоборот, чтобы упростить реализацию, программа может использовать для хранения списка возможных связей коллекцию.
Если узел на другом конце связи еще не находится в остовном дереве, то программа добавляет его и соответствующую связь в дерево. Затем она добавляет связи, выходящие из нового узла, в список возможных узлов.
Алгоритм использует флаг Used в классе link, чтобы определить, попадала ли эта связь ранее в список возможных связей. Если да, то она не заносится в этот список снова.
Может оказаться, что список возможных связей опустеет до того, как все узлы будут добавлены в остовное дерево. В этом случае сеть является несвязной, и не существует путь, который связывает корневой узел со всеми остальными узлами сети.
=========321
Private Sub FindSpanningTree(root As SpanNode)
Dim candidates As New Collection
Dim to_node As SpanNode
Dim link As SpanLink
Dim i As Integer
Dim best_i As Integer
Dim best_cost As Integer
Dim best_to_node As SpanNode
If root Is Nothing Then Exit Sub
' Сбросить флаг Marked для всех узлов и флаги
' Used и InSpanningTree для всех связей.
ResetSpanningTree
' Начать с корня остовного дерева.
root.Marked = True
Set best_to_node = root
Do
' Добавить связи последнего узла в список
' возможных связей.
For Each link In best_to_node.Links
If Not link.Used Then
candidates.Add link
link.Used = True
End If
Next link
' Найти самую короткую связь в списке возможных
' связей, которая ведет к узлу, которого еще нет
' в дереве.
best_i = 0
best_cost = INFINITY
i = 1
Do While i <= candidates.Count
Set link = candidates(i)
If link.Node1.Marked Then
Set to_node = link.Node2
Else
Set to_node = link.Node1
End If
If to_node.Marked Then
' Связь соединяет два узла, которые
' оба находятся в дереве.
' Удалить ее из списка возможных связей.
candidates.Remove i
Else
If link.Cost < best_cost Then
best_i = i
best_cost = link.Cost
Set best_to_node = to_node
End If
i = i + 1
End If
Loop
' Если больше не осталось связей, которые можно
' было бы добавить, то мы сделали все, что могли.
If best_i < 1 Then Exit Do
' Добавить наилучшую связь и узел на ее конце в дерево.
Set link = candidates(best_i)
link.InSpanningTree = True
candidates.Remove best_i
best_to_node.Marked = True
Loop
GotSpanningTree = True
' Перерисовать сеть.
DrawNetwork
End Sub
Этот алгоритм проверяет каждую связь не более одного раза. При проверке каждой связи, она добавляется в список возможных связей, а затем удаляется из него. Если этот список находится в приоритетной очереди на основе пирамид, то для вставки или удаления элемента из очереди потребуется время порядка O(log(N)), где — число связей в сети. В этом случае полное время выполнения алгоритма будет порядка O(N * log(N)).
Если список возможных связей находится в коллекции, как в вышеприведенном коде, то для поиска в списке связи с наименьшей ценой потребуется время порядка O(N), при этом полное время выполнения алгоритма будет порядка O(N2). Для малых N производительность будет приемлемой. Если же число связей в сети достаточно велико, то список возможных связей следует хранить в приоритетной очереди, а не в коллекции.
Программа Span использует этот алгоритм для поиска наименьшего остовного дерева. Эта программа аналогична программе NetEdit. Она позволяет загружать, редактировать и сохранять на диске файлы, представляющие сеть. Если выбрать какой‑либо узел в программе двойным щелчком мыши, то программа найдет и выведет на экран наименьшее остовное дерево с корнем в этом узле. На рис. 12.7 показано окно программы Span, в котором показано наименьшее остовное дерево с корнем в узле 9.
======322-323
@Рис. 12.7. Программа Span
Алгоритмы поиска кратчайшего маршрута, которые обсуждаются в следующих разделах, находят все кратчайшие пути из заданной точки до всех остальных точек сети, при этом предполагается, что сеть является связанной. Набор связей, используемый всеми кратчайшими маршрутами, называется деревом кратчайшего маршрута (shortest path tree).
На рис. 12.8 показано дерево, в котором дерево кратчайшего маршрута с корнем в узле A нарисовано жирной линией. Это дерево изображает кратчайший маршрут из узла A до всех остальных узлов в сети. Например, кратчайший маршрут из узла A в узел F проходит через узлы A, C, E, F.
Многие алгоритмы поиска кратчайшего маршрута начинают с пустого дерева, к которому затем добавляется по одной связи до тех пор, пока дерево не будет заполнено. Эти алгоритмы можно разбить на две категории в соответствии со способом выбора следующей связи, которая должна быть добавлена к растущему дереву кратчайшего маршрута.
Алгоритмы установки меток (label setting) всегда выбирают связь, которая гарантированно окажется частью конечного кратчайшего маршрута. Этот метод работает аналогично методу поиска наименьшего остовного дерева. Если связь добавлена в дерево, то она не будет удалена позже.
Алгоритмы коррекции меток (label correcting) добавляют связи, которые могут быть или не быть частью конечного кратчайшего маршрута. В процессе рабы алгоритма он может определить, что на место уже находящейся в дереве связи нужно поместить другую связь. В этом случае алгоритм заменяет старую связь новой и продолжает работу. Замена связи в дереве может сделать возможными пути, которые не были возможны до этого. Чтобы проверить эти пути, алгоритму приходится снова проверить пути, которые были добавлены в дерево раньше и использовали удаленную связь.
=====324
@Рис. 12.8. Дерево кратчайшего маршрута
Алгоритмы установки и коррекции меток, описанные в следующих разделах, используют похожие классы для представления узлов и связей. Класс узла включает поле Dist, которое определяет расстояние от корня до узла в растущем дереве кратчайшего маршрута. В алгоритме установки меток, после вставки узла в дерево полю Dist присваивается правильное значение, и оно в дальнейшем не меняется. В алгоритме коррекции меток, значение поля Dist может понадобиться обновить, если алгоритм заменит связь.
Класс узла также включает поле NodeStatus, которое указывает, находится ли узел в дереве кратчайшего маршрута, списке возможных связей, или ни в одной из этих структур. Поле InLink указывает на связь, которая ведет к узлу в дереве кратчайшего маршрута.
Public Id As Integer
Public X As Single
Public Y As Single
Public Links As Collection
Public Dist As Integer ' Расстояние от корня дерева пути.
Public NodeStatus As Integer ' Статус дерева маршрута.
Public InLink As PathSLink ' Связь, ведущая к узлу.
======325
Используя поле InLink, программа может перечислить узлы в пути от корня до узла I в обратном порядке при помощи следующего кода:
Dim node As PathSNode
Set node = I
Do
' Вывести узел.
Print node.Id
If node Is Root Then Exit Do
' Перейти к следующему узлу вверх по дереву.
If node.IsLink.Node1 Is node Then
Set node = node.InLink.Node2
Else
Set node = node.InLink.Node1
End If
Loop
Класс link в алгоритме включает поле InPathTree, которое указывает, является ли связь частью дерева кратчайшего маршрута.
Public Node1 As PathSNode
Public Node2 As PathSNode
Public Cost As Integer
Public InPathTree As Boolean
Оба алгоритма установки и коррекции меток используют список возможных связей, в котором находятся связи, которые могут быть добавлены в дерево кратчайшего маршрута, но они по‑разному оперируют этим списком. Алгоритм установки меток всегда выбирает связь, которая обязательно окажется частью дерева кратчайшего маршрута. Алгоритм коррекции меток выбирает элемент, который находится на вершине списка.
В начале этого алгоритма значения поля Dist корневого узла устанавливается равным 0. Затем корневой узел помещается в список возможных узлов, при этом значение поля NodeStatus этого узла принимает значение NOW_IN_LIST, указывая на то, что он находится в списке.
После этого выполняется поиск в списке узла с наименьшим значением Dist. Первоначально это будет корневой узел, так как он единственный в списке.
Затем алгоритм удаляет этот узел из списка, и устанавливает значение поля NodeStatus для этого узла равным WAS_IN_LIST, указывая на то, что этот узел теперь является частью дерева кратчайшего маршрута. Поля Dist и IsLink узла уже имеют правильные значения. Для каждого корневого узла, значение поля IsLink равно Nothing, а значение поля Dist равно нулю.
После этого алгоритм проверяет все связи, выходящие из выбранного узла. Если соседний узел на другом конце связи никогда не находился в списке возможных узлов, то алгоритм добавляет его к списку. Он устанавливает значение поля NodeStatus соседнего узла равным NOW_IN_LIST., а значение поля Dist — расстоянию от корневого узла до выбранного узла плюс цене связи. И, наконец, он присваивает значение полю InLink соседнего узла так, чтобы оно указывало на связь с соседним узлом.
========326
Во время проверки алгоритмом связей, выходящих из выбранного узла, если значение поля NodeStatus соседнего узла равно NOW_IN_LIST, то этот узел уже находится в списке возможных узлов. Алгоритм проверяет текущее значение Dist соседнего узла, проверяя, не будет ли путь через выбранный узел короче. Если это так, то он обновляет поля InLink и Dist соседнего узла и оставляет соседний узел в списке возможных узлов.
Алгоритм повторяет этот процесс, удаляя узлы из списка возможных узлов, проверяя соседние с ними узлы и добавляя соседние узлы в список до тех пор, пока список не опустеет.
На рис. 12.9 показана часть дерева кратчайшего маршрута. В этой точке алгоритм проверил узлы A и B, удалил их из списка возможных узлов, и проверил их связи. Узлы A и B уже добавлены к дереву кратчайшего маршрута, и теперь в списке возможных узлов находятся узлы C, D и E. Жирные стрелки на рис. 12.9 соответствуют значениям полей InLink узлов в этой точке. Например, значение поля InLink для узла E соответствует связи между узлами E и B.
После этого алгоритм ищет в списке возможных узлов узел с наименьшим значением Dist. В данной точке значения полей Dist узлов C, D и E равны 10, 21 и 22 соответственно, поэтому алгоритм выбирает узел C. Узел C удаляется из списка возможных узлов, и его полю NodeStatus присваивается значение WAS_IN_LIST. Теперь узел C является частью дерева кратчайшего маршрута, и его поля Dist и InLink имеют правильные значения.
Затем алгоритм проверяет связи, выходящие из узла C. Единственная связь, выходящая из узла C, идет к узлу E, который уже содержится в списке возможных узлов, поэтому алгоритм не добавляет его в список снова.
Текущий кратчайший маршрут от корня в узел E — это путь A, B, E, полная цена которого равна 22. Но цена пути A, C, E равна всего 17., что меньше, чем текущая цена 22, поэтому алгоритм обновляет значение InLink для узла E, и присваивает полю Dist этого узла значение 17.
@Рис. 12.9. Часть дерева кратчайшего маршрута
=========327
Private Sub FindPathTree(root As PathSNode)
Dim candidates As New Collection
Dim i As Integer
Dim best_i As Integer
Dim best_dist As Integer
Dim new_dist As Integer
Dim node As PathSNode
Dim to_node As PathSNode
Dim link As PathSLink
If root Is Nothing Then Exit Sub
' Сбросить значения полей Marked и NodeStatus всех узлов,
' и флаги Used и InPathTree всех связей.
ResetPathTree
' Начать с корня дерева кратчайшего маршрута.
root.Dist = 0
Set root.InLink = Nothing
root.NodeStatus = NOW_IN_LIST
candidates.Add root
Do While candidates.Count > 0
' Найти ближайший к корню узел‑кандидат.
best_dist = INFINITY
For i = 1 To candidates.Count
new_dist = candidates(i).Dist
If new_dist < best_dist Then
best_i = i
best_dist = new_dist
End If
Next i
' Добавить узел к дерева кратчайшего маршрута.
Set node = candidates(best_i)
candidates.Remove best_i
node.NodeStatus = WAS_IN_LIST
' Проверить соседние узлы.
For Each link In node.Links
If node Is link.Node1 Then
Set to_node = link.Node2
Else
Set to_node = link.Node1
End If
If to_node.NodeStatus = NOT_IN_LIST Then
' Узел раньше не был в списке возможных
' узлов. Добавить его в список.
candidates.Add to_node
to_node.NodeStatus = NOW_IN_LIST
to_node.Dist = best_dist + link.Cost
Set to_node.InLink = link
ElseIf to_node.NodeStatus = NOW_IN_LIST Then
' Узел находится в списке возможных узлов.
' Обновить значения его полей Dist и inlink,
' если это необходимо.
new_dist = best_dist + link.Cost
If new_dist < to_node.Dist Then
to_node.Dist = new_dist
Set to_node.InLink = link
End If
End If
Next link
Loop
GotPathTree = True
' Пометить входящие узлы, чтобы их было проще вывести на экран.
For Each node In Nodes
If Not (node.InLink Is Nothing) Then _
node.InLink.InPathTree = True
Next node
' Перерисовать сеть.
DrawNetwork
End Sub
Важно, чтобы алгоритм обновлял поля InLink и Dist только для узлов, в которых поле NodeStatus равно NOW_IN_LIST. Для большинства сетей нельзя получить более короткий путь, добавляя узлы, которые не находятся в списке возможных узлов. Тем не менее, если сеть содержит цикл, полная длина которого отрицательна, алгоритм может обнаружить, что можно уменьшить расстояние до некоторых узлов, которые уже находятся в дереве кратчайшего маршрута, при этом две ветви дерева кратчайшего маршрута окажутся связанными друг с другом, так что оно перестанет быть деревом.
На рис. 12.10 показана сеть с циклом отрицательной цены и «дерево» кратчайшего маршрута, которое получилось бы, если бы алгоритм обновлял цену узлов, которые уже находятся в дереве.
=======329
@Рис. 12.10. Неправильное «дерево» кратчайшего маршрута для сети с циклом отрицательной цены
Программа PathS использует этот алгоритм установки меток для вычисления кратчайшего маршрута. Она аналогична программам NetEdit и Span. Если вы не вставляете или не удаляете узел или связь, то можно выбрать узел при помощи мыши и программа при этом найдет и выведет на экран дерево кратчайшего маршрута с корнем в этом узле. На рис. 12.11 показано окно программы PathS с деревом кратчайшего маршрута с корнем в узле 3.
@Рис. 12.11. Дерево кратчайшего маршрута с корнем в узле 3
=======330
Узкое место этого алгоритма заключается в поиске узла с наименьшим значением поля Dist в списке возможных узлов. Некоторые варианты этого алгоритма используют другие структуры данных для хранения списка возможных узлов. Например, можно было бы использовать упорядоченный связный список. При использовании этого метода потребуется только один шаг для того, чтобы найти следующий узел, который будет добавлен к дереву кратчайшего маршрута. Этот список будет всегда упорядоченным, поэтому узел на вершине списка всегда будет искомым узлом.
Это облегчит поиск нужного узла в списке, но усложнит добавление узла в него. Вместо того чтобы просто помещать узел в начало списка, его придется поместить в нужную позицию.
Иногда также требуется перемещать узлы в списке. Если в результате добавления узла в дерево кратчайшего маршрута уменьшилось кратчайшее расстояние до другого узла, который уже был в списке, то нужно переместить этот элемент ближе к вершине списка.
Предыдущий алгоритм и этот его новый вариант представляют собой два крайних случая управления списком возможных узлов. Первый алгоритм совсем не упорядочивает список и тратит достаточно много времени на поиск узлов в сети. Второй тратит много времени на поддержание упорядоченности списка, но может очень быстро выбирать из него узлы. Другие варианты используют промежуточные стратегии.
Например, можно использовать для хранения списка возможных узлов приоритетную очередь на основе пирамид, тогда можно будет просто выбрать следующий узел с вершины пирамиды. Вставка нового узла в пирамиду и ее переупорядочение будет выполняться быстрее, чем аналогичные операции для упорядоченного связного списка. Другие стратегии используют сложные схемы организации блоков для того, чтобы упростить поиск возможных узлов.
Некоторые из этих вариантов достаточно сложны. Из‑за этой их сложности эти алгоритмы для небольших сетей часто выполняются медленнее, чем более простые алгоритмы. Тем не менее, для очень больших сетей или сетей, в которых каждый узел имеет очень большое число связей, выигрыш от применения этих алгоритмов может стоить дополнительного усложнения.
Как и алгоритм установки меток, этот алгоритм начинает с обнуления значения поля Dist корневого узла и помещает корневой узел в список возможных узлов. При этом значения полей Dist остальных узлов устанавливаются равными бесконечности. Затем для вставки в дерево кратчайшего маршрута выбирается первый узел в списке возможных узлов.
После этого алгоритм проверяет узлы, соседние с выбранным, выясняя, будет ли расстояние от корня до выбранного узла плюс цена связи меньше, чем текущее значение поля Dist соседнего узла. Если это так, то поля Dist и InLink соседнего узла обновляются так, чтобы кратчайший маршрут к соседнему узлу проходил через выбранный узел. Если соседний узел при этом не находился в списке возможных узлов, то алгоритм также добавляет его к списку. Заметьте, что алгоритм не проверяет, попадал ли этот узел в список раньше. Если путь от корня до соседнего узла становится короче, узел всегда добавляется в список возможных узлов.
Алгоритм продолжает удалять узлы из списка возможных узлов, проверяя соседние с ними узлы и добавляя соседние узлы в список до тех пор, пока список не опустеет.
Если внимательно сравнить алгоритмы установки меток и коррекции меток, то видно, что они похожи. Единственное отличие заключается в том, как каждый из них выбирает элементы из списка возможных узлов для вставки в дерево кратчайшего маршрута.
=====331
Алгоритм установки меток всегда выбирает связь, которая гарантированно находится в дереве кратчайшего маршрута. При этом после того, как узел удаляется из списка возможных узлов, он навсегда помещается в дерево и больше не попадает в список возможных узлов.
Алгоритм корректировки всегда выбирает первый узел из списка возможных узлов, который не всегда может быть наилучшим выбором. Значения полей Dist и InLink этого узла могут быть не наилучшими из возможных. В этом случае алгоритм, в конце концов, найдет в списке узел, через который проходит более короткий путь к выбранному узлу. Тогда алгоритм обновляет поля Dist и InLink и снова помещает обновленный узел в список возможных узлов.
Алгоритм может использовать новый путь для создания других путей, которые он мог пропустить раньше. Помещая обновленный узел снова в список обновленных узлов, алгоритм гарантирует, что этот узел будет проверен снова и будут найдены все такие пути.
Private Sub FindPathTree(root As PathCNode)
Dim candidates As New Collection
Dim node_dist As Integer
Dim new_dist As Integer
Dim node As PathCNode
Dim to_node As PathCNode
Dim link As PathCLink
If root Is Nothing Then Exit Sub
' Сбросить поля Marked и NodeStatus для всех узлов,
' и флаги Used и InPathTree для всех связей.
ResetPathTree
' Начать с корня дерева кратчайшего маршрута.
root.Dist = 0
Set root.InLink = Nothing
root.NodeStatus = NOW_IN_LIST
candidates.Add root
Do While candidates.Count > 0
' Добавить узел в дерево кратчайшего маршрута.
Set node = candidates(1)
candidates.Remove 1
node_dist = node.Dist
node.NodeStatus = NOT_IN_LIST
' Проверить соседние узлы.
For Each link In node.Links
If node Is link.Node1 Then
Set to_node = link.Node2
Else
Set to_node = link.Node1
End If
' Проверить, существует ли более короткий
' путь через этот узел.
new_dist = node_dist + link.Cost
If to_node.Dist > new_dist Then
' Путь лучше. Обновить значения Dist и InLink.
Set to_node.InLink = link
to_node.Dist = new_dist
' Добавить узел в список возможных узлов,
' если его там еще нет.
If to_node.NodeStatus = NOT_IN_LIST Then
candidates.Add to_node
to_node.NodeStatus = NOW_IN_LIST
End If
End If
Next link
Loop
' Пометить входящие связи, чтобы их было проще вывести.
For Each node In Nodes
If Not (node.InLink Is Nothing) Then _
node.InLink.InPathTree = True
Next node
' Перерисовать сеть.
DrawNetwork
End Sub
В отличие от алгоритма установки меток, этот алгоритм не может работать с сетями, которые содержат циклы с отрицательной ценой. Если встречается такой цикл, то алгоритм бесконечно перемещается по связям внутри него. При каждом обходе цикла расстояние до входящих в него узлов уменьшается, при этом алгоритм снова помещает узлы в список возможных узлов, и снова может проверять их в дальнейшем. При следующей проверке этих узлов, расстояние до них также уменьшится, и так далее. Этот процесс будет продолжаться до тех пор, пока расстояние до этих узлов не достигнет нижнего граничного значения -32.768, если длина пути задана целым числом. Если известно, что в сети имеются циклы с отрицательной ценой, то проще всего просто использовать для работы с ней метод установки, а не коррекции меток.
Программа PathC использует этот алгоритм коррекции меток для вычисления кратчайшего маршрута. Она аналогична программе PathS, но использует метод коррекции, а не установки меток.
=======333
Алгоритм коррекции меток позволяет очень быстро выбрать узел из списка возможных узлов. Он также может вставить узел в список всего за один или два шага. Недостаток этого алгоритма заключается в том, что когда он выбирает узел из списка возможных узлов, он может сделать не слишком хороший выбор. Если алгоритм выбирает узел до того, как его поля Dist и InLink получат свои конечный значения, он должен позднее скорректировать значения этих полей и снова поместить узел в список возможных узлов. Чем чаще алгоритм помещает узлы назад в список возможных узлов, тем больше времени это занимает.
Варианты этого алгоритма пытаются повысить качество выбора узлов без большого усложнения алгоритма. Один из методов, который неплохо работает на практике, состоит в том, чтобы добавлять узлы одновременно в начало и конец списка возможных узлов. Если узел раньше не попадал в список возможных узлов, алгоритм, как обычно, добавляет его в конец списка. Если узел уже был раньше в списке возможных узлов, но сейчас его там нет, алгоритм вставляет его в начало списка. При этом повторное обращение к узлу выполняется практически сразу, возможно при следующем же обращении к списку.
Идея, заключенная в таком подходе, состоит в том, чтобы если алгоритм совершает ошибку, она исправлялась как можно быстрее. Если ошибка не будет исправлена в течение достаточно долгого времени, алгоритм может использовать неправильную информацию для построения длинных ложных путей, которые затем придется исправлять. Благодаря быстрому исправлению ошибок, алгоритм может уменьшить число неверных путей, которые придется перестроить. В наилучшем случае, если все соседние узлы все еще находятся в списке возможных узлов, повторная проверка этого узла до проверки соседей предотвратит построение неверных путей.
Описанные выше алгоритмы поиска кратчайшего маршрута вычисляли все кратчайшие пути из корневого узла до всех остальных узлов в сети. Существует множество других типов задачи нахождения кратчайшего маршрута. В этом разделе обсуждаются три из них: двухточечный кратчайший маршрут[RV21] (point‑to‑point shortest path), кратчайший маршрут для всех пар(all pairs shortest path) и кратчайший маршрут со штрафами за повороты.
В некоторых приложениях может понадобиться найти кратчайший маршрут между двумя точками, при этом остальные пути в полном дереве кратчайшего маршрута не важны. Простой способ решить эту задачу — вычислить полное дерево кратчайшего маршрута при помощи метода установки или коррекции меток, а затем выбрать из дерева кратчайший путь между двумя точками.
Другой способ заключается в использовании метода установки меток, который останавливался бы, когда будет найден путь к конечному узлу. Алгоритм установки меток добавляет к дереву кратчайшего маршрута только те пути, которые обязательно должны в нем находиться, следовательно, в тот момент, когда алгоритм добавит конечный узел в дерево, будет найден искомый кратчайший маршрут. В алгоритме, который обсуждался раньше, это происходит, когда алгоритм удаляет конечный узел из списка возможных узлов.
=======334
Единственное изменение требуется внести в часть алгоритма установки меток, которая выполняется сразу же после того, как алгоритм находит в списке возможных узлов узел с наименьшим значением Dist. Перед удалением узла из списка возможных узлов, алгоритм должен проверить, не является ли этот узел искомым. Если это так, то дерево кратчайшего маршрута уже содержит кратчайший маршрут между начальным и конечным узлами, и алгоритм может закончить работу.
' Найти ближайший к корню узел в списке возможных узлов.
:
' Проверить, является ли этот узел искомым.
If node = destination Then Exit Do
' Добавить этот узел в дерево кратчайшего маршрута.
:
На практике, если две точки в сети расположены далеко друг от друга, то этот алгоритм обычно будет выполняться дольше, чем займет вычисление полного дерева кратчайшего маршрута. Алгоритм выполняется медленнее из‑за того, что в каждом цикле выполнения алгоритма проверяется, достигнут ли искомый узел. С другой стороны, если узлы расположены рядом, то выполнение этого алгоритма может потребовать намного меньше времени, чем построение полного дерева кратчайшего маршрута.
Для некоторых сетей, таких как сеть улиц, можно оценить, насколько близко расположены две точки, и затем решить, какую версию алгоритма выбрать. Если сеть содержит все улицы южной Калифорнии, и две точки расположены на расстоянии 10 миль, следует использовать версию, которая останавливается после того, как найдет конечный узел. Если же точки удалены друг от друга на 100 миль, возможно, меньше времени займет вычисление полного дерева кратчайшего маршрута.
В некоторых приложениях может потребоваться быстро найти кратчайший маршрут между всеми парами узлов в сети. Если нужно вычислить большую часть из N2 возможных путей, может быть быстрее вычислить все возможные пути вместо того, чтобы находить только те, которые нужны.
Можно записать кратчайшие маршруты, используя два двумерных массива, Dist и InLinks. В ячейке Dist(I, J) находится кратчайший маршрут из узла I в узел J, а в ячейке InLinks(I, J) — связь, которая ведет к узлу J в кратчайшем пути из узла I в узел J. Эти значения аналогичны значениям Dist и InLink в классе узла в предыдущем алгоритме.
Один из способов найти все кратчайшие маршруты заключается в том, чтобы построить деревья кратчайшего маршрута с корнем в каждом из узлов сети при помощи одного из предыдущих алгоритмов, и затем сохранить результаты в массивах Dists и InLinks.
========335
Другой метод вычисления всех кратчайших маршрутов последовательно строит пути, используя все больше и больше узлов. Вначале алгоритм находит все кратчайшие маршруты, которые используют только первый узел и узлы на концах пути. Другими словами, для узлов J и K алгоритм находит кратчайший маршрут между этими узлами, который использует только узел с номером 1 и узлы J и K, если такой путь существует
Затем алгоритм находит все кратчайшие маршруты, которые используют только два первых узла. Затем он строит пути, используя первые три узла, первые четыре узла, и так далее до тех пор, пока не будут построены все кратчайшие маршруты, используя все узлы. В этот момент, поскольку кратчайшие маршруты могут использовать любой узел, алгоритм найдет все кратчайшие маршруты в сети.
Заметьте, что кратчайший маршрут между узлами J и K, использующий только первые I узлов, включает узел I, только если Dist(J, K) > Dist(J, I) + Dist(I, K). Иначе кратчайшим маршрутом будет предыдущий кратчайший маршрут, который использовал только первые I - 1 узлов. Это означает, что когда алгоритм рассматривает узел I, требуется только проверить выполнение условия Dist(J, K) > Dist(J, I) + Dist(I, K). Если это условие выполняется, алгоритм обновляет кратчайший маршрут из узла J в узел K. Иначе старый кратчайший маршрут между этими двумя узлами остался бы таковым.
В некоторых сетях, в особенности сетях улиц, бывает полезно добавить штраф и запреты на повороты (turn penalties) В сети улиц автомобиль должен замедлить движение перед тем, как выполнить поворот. Поворот налево может занимать больше времени, чем поворот направо или движение прямо. Некоторые повороты могут быть запрещены или невозможны из‑за наличия разделительной полосы. Эти аспекты можно учесть, вводя в сеть штрафы за повороты.
Часто важны только некоторые штрафы за повороты. Может понадобиться предотвратить выполнение запрещенных или невозможных поворотов и присвоить штрафы за повороты лишь на нескольких ключевых перекрестках, не определяя штрафы для всех перекрестков в сети. В этом случае можно разбить каждый узел, для которого заданы штрафы, на несколько узлов, которые будут неявно учитывать штрафы.
Предположим, что требуется добавить один штраф за поворот на перекрестке налево и другой штраф за поворот направо. На рис. 12.12 показан перекресток, на котором требуется применить эти штрафы. Число рядом с каждой связью соответствует ее цене. Требуется применить штрафы за вход в узел A по связи L1, и затем выход из него по связям L2 или L3.
Для применения штрафов к узлу A, разобьем этот узел на два узла, по одному для каждой из покидающих его связей. В данном примере, из узла A выходят две связи, поэтому узел A разбивается на два узла A1 и A2, и связи, выходящие из узла A, заменяются соответствующими связями, выходящими из полученных узлов. Можно представить, что каждый из двух образовавшихся узлов соответствует входу в узел A и повороту в сторону соответствующей связи.
======336
@Рис. 12.12. Перекресток
Затем связь L1, входящая в узел A, заменяется на две связи, входящие в каждый из двух узлов A1 и A2. Цена этих связей равна цене исходной связи L1 плюс штрафу за поворот в соответствующем направлении. На рис. 12.13 показан перекресток, на котором введены штрафы за поворот. На этом рисунке штраф за поворот налево из узла A равен 5, а за поворот направо —2.
Помещая информацию о штрафах непосредственно в конфигурацию сети, мы избегаем необходимости модифицировать алгоритмы поиска кратчайшего маршрута. Эти алгоритмы будут находить правильные кратчайшие маршруты с учетом штрафов за повороты.
При этом придется все же слегка изменить программы, чтобы учесть разбиение узлов на несколько частей. Предположим, что требуется найти кратчайший маршрут между узлами I и J, но узел I оказался разбит на несколько узлов. Полагая, что можно покинуть узел I по любой связи, можно создать ложный узел и использовать его в качестве корня дерева кратчайшего маршрута. Соединим этот узел связями с нулевой ценой с каждым из узлов, получившихся после разбиения узла I. Тогда, если построить дерево кратчайшего маршрута с корнем в ложном узле, то при этом будут найдены все кратчайшие маршруты, содержащие любой из этих узлов. На рис. 12.14 показан перекресток с рис. 12.13, связанный с ложным корневым узлом.
@Рис. 12.13. Перекресток со штрафами за повороты
=======337
@Рис. 12.14. Перекресток, связанный с ложным корнем
Обрабатывать случай поиска пути к узлу, который был разбит на несколько узлов, проще. Если требуется найти кратчайший маршрут между узлами I и J, и узел J был разбит на несколько узлов, то вначале, как обычно, нужно найти дерево кратчайшего маршрута с корнем в узле I. Затем проверяются все узлы, на которые был разбит узел J и находится ближайший из них к корню дерева. Путь к этому узлу и есть кратчайший маршрут к исходному узлу J.
Предыдущий метод будет не слишком эффективным, если вы хотите ввести штрафы за повороты для большинства узлов в сети. Лучше будет создать совершенно новую сеть, которая будет включать информацию о штрафах.
· Для каждой связи между узлами A и B в исходной сети в новой сети создается узел AB;
· Если в исходной сети соответствующие связи были соединены, то полученные узлы также соединяются между собой. Например, предположим, что в исходной сети одна связь соединяла узлы A и B, а другая — узлы B и C. Тогда в новой сети нужно создать связь, соединяющую узел AB с узлом BC;
· Цена новой связи складывается из цены второй связи в исходной сети и штрафа за поворот. В этом примере цена связи между узлом AB и узлом BC будет равна цене связи, соединяющей узлы B и C в исходной сети плюс штрафу за поворот при движении из узла A в узел B и затем в узел C.
На рис. 12.15 изображена небольшая сеть и соответствующая новая сеть, представляющая штрафы за повороты. Штраф за поворот налево равен 3, за поворот направо — 2, а за «поворот» прямо — нулю. Например, так как поворот из узла B в узел E — это левый поворот в исходной сети, штраф для связи между узлами BE и EF в новой сети равен 3. Цена связи, соединяющей узлы E и F в исходной сети, равна 3, поэтому полная цена новой связи равна 3 + 3 = 6.
=======338
@Рис. 12.15. Сеть и соответствующая ей сеть со штрафами за повороты
Предположим теперь, что требуется найти для исходной сети дерево кратчайшего маршрута с корнем в узле D. Чтобы сделать это, создадим в новой сети ложный корневой узел, затем построим связи, соединяющие этот узел со всеми связями, которые покидают узел D в исходной сети. Присвоим этим связям ту же цену, которую имеют соответствующие связи в исходной сети. На рис. 12.16 показана новая сеть с рис. 12.15 с ложным корневым узлом, соответствующим узлу D. Дерево кратчайшего маршрута в этой сети нарисовано жирной линией.
Чтобы найти кратчайший маршрут из узла D в узел C, необходимо проверить все узлы в новой сети, которые соответствуют связям, заканчивающимся в узле C. В этом примере это узлы BC и FC. Ближайший к ложному корню узел соответствует кратчайшему маршруту к узлу C в исходной сети. Узлы в кратчайшем маршруте в новой сети соответствуют связям в кратчайшем маршруте в исходной сети.
@Рис. 12.16. Дерево кратчайшего маршрута в сети со штрафами за повороты
========339
На рис. 12.16 кратчайший маршрут начинается с ложного корня, идет в узел DE, затем узлы EF и FC и имеет полную цену 16. Этот путь соответствует пути D, E, F, C в исходной сети. Прибавив один штраф за левый поворот E, F, C, получим, что цена этого пути в исходной сети также равна 16.
Заметьте, что вы не нашли бы этот путь, если бы построили дерево кратчайшего маршрута в исходной сети. Без учета штрафов за повороты, кратчайшим маршрутом из узла D в узел C был бы путь D, E, B, C с полной ценой 12. С учетом штрафов цена этого пути равна 17.
Вычисления кратчайшего маршрута используются во многих приложениях. Очевидным примером является поиск кратчайшего маршрута между двумя точками в уличной сети. Многие другие приложения используют метод поиска кратчайшего маршрута менее очевидными способами. Следующие разделы описывают некоторые из этих приложений.
Предположим, что имеется карта города, на которую нанесены все пожарные депо. Может потребоваться определить для каждой точки города ближайшее к ней депо. На первый взгляд это кажется трудной задачей. Можно попытаться рассчитать дерево кратчайшего маршрута с корнем в каждом узле сети, чтобы найти, какое депо расположено ближе всего к каждому из узлов. Или можно построить дерево кратчайшего маршрута с корнем в каждом из пожарных депо и записать расстояние от каждого из узлов до каждого из депо. Но существует намного более быстрый метод.
Создадим ложный корневой узел и соединим его с каждым из пожарных депо связями с нулевой ценой. Затем найдем дерево кратчайшего маршрута с корнем в этом ложном узле. Для каждой точки в сети кратчайший маршрут из ложного корневого узла к этой точке пройдет через ближайшее к этой точке пожарное депо. Чтобы найти ближайшее к точке пожарное депо, нужно просто проследовать по кратчайшему маршруту от этой точки к корню, пока на пути не встретится одно из депо. Построив всего одно дерево кратчайшего маршрута, можно найти ближайшие пожарные депо для каждой точки в сети.
Программа District использует этот алгоритм для разбиения сети на районы. Так же, как и программа PathC и другие программы, описанные в этой главе, она позволяет загружать, редактировать и сохранять на диске ориентированные сети с ценой связей. Если вы не добавляете и не удаляете узлы или связи, вы можете выбрать депо для разделения на районы. Добавьте узлы к списку пожарных депо щелчком левой кнопки мыши, затем щелкните правой кнопкой в любом месте формы, и программа разобьет сеть на районы.
На рис. 12.17 показано окно программы, на котором изображена сеть с тремя депо. Депо в узлах 3, 18 и 20 обведены жирными кружочками. Разбивающие сеть на районы деревья кратчайшего маршрута изображены жирными линиями.
=====340
@Рис. 12.17. Программа District
Во многих задачах, в том числе в больших программных проектах, определенные действия должны быть выполнены раньше других. Например, при строительстве дома до установки фундамента нужно вырыть котлован, фундамент должен застыть до того, как начнется возведение стен, каркас дома должен быть собран прежде, чем можно будет выполнять проводку электричества, водопровода и кровельные работы и так далее.
Некоторые из этих задач могут выполняться одновременно, другие должны выполняться последовательно. Например, можно одновременно проводить электричество и прокладывать водопровод.
Критическим путем (critical path) называется одна из самых длинных последовательностей задач, которая должна быть выполнена для завершения проекта. Важность задач, лежащих на критическом пути, определяется тем, что сдвиг сроков выполнения этих задач приведет к изменению времени завершения проекта в целом. Если заложить фундамент на неделю позже, то и здание будет завершено на неделю позже. Для определения заданий, которые находятся на критическом пути, можно использовать модифицированный алгоритм поиска кратчайшего маршрута.
Вначале создадим сеть, которая представляет временные соотношения между задачами проекта. Пусть каждой задаче соответствует узел. Нарисуем связь между задачей I и задачей J, если задача I должна быть выполнена до начала задачи J, и присвоим этой связи цену, равную времени выполнения задачи I.
После этого создадим два ложных узла, один из которых будет соответствовать началу проекта, а другой — его завершению. Соединим начальный узел связями с нулевой ценой со всеми узлами в проекте, в которые не входит ни одна другая связь. Эти узлы соответствуют задачам, выполнение которых можно начинать немедленно, не ожидая завершения других задач.
Затем создадим ложные связи нулевой длины, соединяющие все узлы, из которых не выходит не одной связи, с конечным узлом. Эти узлы представляют задачи, которые не тормозят выполнение других задач. После того, как все эти задачи будут выполнены, проект будет завершен.
Найдя самый длинный маршрут между начальным и конечным узлами сети, мы получим критический путь проекта. Входящие в него задачи будут критичными для выполнения проекта.
========341
@Таблица 12.1. Этапы сборки дождевальной установки
Рассмотрим, например, упрощенный проект сборки дождевальной установки, состоящий из пяти задач. В табл. 12.1 приведены задачи и временные соотношения между ними. Сеть для этого проекта показана на рис. 12.18.
В этом простом примере легко увидеть, что самый длинный маршрут в сети выполняет следующую последовательность задач: выкопать канавы, смонтировать трубы, закопать их. Это критические задачи, и если в выполнении какой‑либо из них наступит задержка, выполнение проекта также задержится.
Длина этого критического пути равна ожидаемому времени завершения проекта. В данном случае, если все задачи будут выполнены вовремя, выполнение проекта займет пять дней. При этом предполагается также, что если это возможно, несколько задач будут выполняться одновременно. Например, один человек может копать канавы, пока другой будет закупать трубы.
В более значительном проекте, таком как строительство небоскреба или съемка фильма, могут содержаться тысячи задач, и критические пути при этом могут быть совсем не очевидны.
Предположим, что требуется набрать несколько сотрудников для ответов на телефонные звонки, при этом каждый из них будет занят не весь день. При этом нужно, чтобы суммарная зарплата была наименьшей, и нанятый коллектив сотрудников отвечал на звонки с 9 утра до 5 вечера. В табл. 12.2 приведены рабочие часы сотрудников, и их почасовая оплата.
@Рис. 12.18. Сеть задач сборки дождевальной установки
======342
@Таблица 12.2. Рабочие часы сотрудников и их почасовая оплата
Для построения соответствующей сети, создадим один узел для каждого рабочего часа. Соединим эти узлы связями, каждая из которых соответствует рабочим часам какого‑либо сотрудника. Если сотрудник может работать с 9 до 11, нарисуем связь между узлом 9:00 и узлом 11:00, и присвоим этой связи цену, равную зарплате, получаемой данным сотрудником за соответствующее время. Если сотрудник получает 6,5 долларов в час, и отрезок времени составляет два часа, то цена связи равна 13 долларам. На рис. 12.19 показана сеть, соответствующая данным из табл. 12.2.
Кратчайший маршрут из первого узла в последний позволяет набрать коллектив сотрудников с наименьшей суммарной зарплатой. Каждая связь в пути соответствует работе сотрудника в определенный промежуток времени. В данном случае кратчайший маршрут из узла 9:00 в узел 5:00 проходит через узлы 11:00, 12:00 и 3:00. Этому соответствует следующий график работы: сотрудник A работает с 9:00 до 11:00, сотрудник D работает с 11:00 до 12:00, затем сотрудник A снова работает с 12:00 до 3:00 и сотрудник E работает с 3:00 до 5:00. Полная зарплата всех сотрудников при таком графике составляет 52,15 доллара.
@Рис. 12.19. Сеть графика работы коллектива
======343
Во многих сетях связи имеют кроме цены, еще и пропускную способность (capacity). Через каждый узел сети может проходить поток (flow), который не превышает ее пропускной способности. Например, по улицам может проехать только определенной число машин. Сеть с заданными пропускными способностями ее связей называется нагруженной сетью [RV22] (capacitated network). Если задана нагруженная сеть, задача о максимальном потоке заключается в определении наибольшего возможного потока через сеть из заданного источника (source) в заданный сток (sink).
На рис. 12.20 показана небольшая нагруженная сеть. Числа рядом со связями в этой сети — это не цена связи, а ее пропускная способность. В этом примере максимальный поток, равный 4, получается, если две единицы потока направляются по пути A, B, E,F и еще две — по пути A, C, D, F.
Описанный здесь алгоритм начинается с того, что поток во всех связях равен нулю и затем алгоритм постепенно увеличивает поток, пытаясь улучшить найденное решение. Алгоритм завершает работу, если нельзя улучшить имеющееся решение.
Для поиска путей способов увеличения полного потока, алгоритм проверяет остаточную пропускную способность (residual capacity) связей. Остаточная пропускная способность связи между узлами I и J равна максимальному дополнительному потоку, который можно направить из узла I в узел J, используя связь между I и J и связь между J и I. Этот суммарный поток может включать дополнительный поток по связи I‑J, если в этой связи есть резерв пропускной способности, или исключать часть потока из связи J‑I, если по этой связи идет поток.
Например, предположим, что в сети, соединяющей узлы A и C на рис. 12.20, существует поток, равный 2. Так как пропускная способность этой связи равна 3, то к этой связи можно добавить единицу потока, поэтому остаточная пропускная способность этой связи равна 1. Хотя сеть, показанная на рис. 12.20 не имеет связи C‑A, для этой связи существует остаточная пропускная способность. В данном примере, так как по связи A‑C идет поток, равный 2, то можно удалить до двух единиц этого потока. При этом суммарный поток из узла C в узел A увеличился бы на 2, поэтому остаточная пропускная способность связи C‑A равна 2.
@Рис. 12.20. Нагруженная сеть
========344
@Рис. 12.21. Потоки в сети
Сеть, состоящая из всех связей с положительной остаточной пропускной способностью, называется остаточной сетью (residual network). На рис. 12.21 показана сеть с рис. 12.20, каждой связи в которой присвоен поток. Для каждой связи, первое число равно потоку через связь, а второе — ее пропускной способности. Надпись «1/2», например, означает, что поток через связь равен 1, и ее пропускная способность равна 2. Связи, поток через которые больше нуля, нарисованы жирными линиями.
На рис. 12.22 показана остаточная сеть, соответствующая потокам на рис. 12.21. Нарисованы только связи, которые действительно могут иметь остаточную пропускную способность. Например, между узлами A и D не нарисовано ни одной связи. Исходная сеть не содержит связи A‑D или D‑A, поэтому эти связи всегда будут иметь нулевую остаточную пропускную способность.
Одно из свойств остаточных сетей состоит в том, что любой путь, использующий связи с остаточной пропускной способностью больше нуля, который связывает источник со стоком, дает способ увеличения потока в сети. Так как этот путь дает способ увеличения или расширения потока в сети, он называется расширяющим путем (augmenting path). На рис. 12.23 показана остаточная сеть с рис. 12.22 с расширяющим путем, нарисованным жирной линией.
Чтобы обновить решение, используя расширяющий путь, найдем наименьшую остаточную пропускную способность в пути. Затем скорректируем потоки в пути в соответствии с этим значением. Например, на рис. 12.23 наименьшая остаточная пропускная способность сетей в расширяющем пути равна 2. Чтобы обновить потоки в сети, к любой связи I‑J на пути добавляется поток 2, а из всех обратных им связей J‑I вычитается поток 2.
@Рис. 12.22. Остаточная сеть
========345
@Рис. 12.23. Расширяющий путь через остаточную сеть
Вместо того, чтобы корректировать потоки, и затем перестраивать остаточную сеть, проще просто скорректировать остаточную сеть. Затем после завершения работы алгоритма можно использовать результат для вычисления потоков для связей в исходной сети.
Чтобы скорректировать остаточную сеть в этом примере, проследуем по расширяющему пути. Вычтем 2 из остаточной пропускной способности всех связей I‑J вдоль пути, и добавим 2 к остаточной пропускной способности соответствующей связи J‑I. На рис. 12.24 показана скорректированная остаточная сеть для этого примера.
Если больше нельзя найти ни одного расширяющего пути, то можно использовать остаточную сеть для вычисления потоков в исходной сети. Для каждой связи между узлами I и J, если остаточный поток между узлами I и J меньше, чем пропускная способность связи, то поток должен равняться пропускной способности минус остаточный поток. В противном случае поток должен быть равен нулю.
Например, на рис. 12.24 остаточный поток из узла A в узел C равен 1 и пропускная способность связи A‑C равна 3. Так как 1 меньше 3, то поток через узел будет равен 3 - 1 = 2. На рис. 12.25 показаны потоки в сети, соответствующие остаточной сети на рис. 12.24.
@Рис. 12.24. Скорректированная остаточная сеть
========346
@Рис. 12.25. Максимальные потоки
Полученный алгоритм еще не содержит метода для поиска расширяющих путей в остаточной сети. Один из возможных методов аналогичен методу коррекции меток для алгоритма кратчайшего маршрута. Вначале поместим узел‑источник в список возможных узлов. Затем, если список возможных узлов не пуст, будем удалять из него по одному узлу. Проверим все соседние узлы, соединенные с выбранным узлом по связи, остаточная пропускная способность которой больше нуля. Если соседний узел еще не был помещен в список возможных узлов, добавить его в список. Продолжить этот процесс до тех пор, пока список возможных узлов не опустеет.
Этот метод имеет два отличия от метода поиска кратчайшего маршрута коррекцией меток. Во‑первых, этот метод не прослеживает связи с нулевой остаточной пропускной способностью. Алгоритм же кратчайшего маршрута проверяет все пути, независимо от их цены.
Во‑вторых, этот алгоритм проверяет все узлы не больше одного раза. Алгоритм поиска кратчайшего маршрута коррекцией меток, будет обновлять узлы и помещать их снова в список возможных узлов, если он позднее найдет более короткий путь от корня к этому узлу. При поиске расширяющего пути нет необходимости проверять его длину, поэтому не нужно обновлять пути и помещать узлы назад в список возможных узлов.
Следующий код демонстрирует, как можно вычислять максимальные потоки в программе на Visual Basic. Этот код предназначен для работы с неориентированными сетями, похожими на те, которые использовались в других программах примеров, описанных в этой главе. После завершения работы алгоритма он присваивает связи цену, равную потоку через нее, взятому со знаком минус, если поток течет в обратном направлении. Другими словами, если сеть содержит объект, представляющий связь I‑J, а алгоритм определяет, что поток должен течь в направлении связи J‑I, то потоку через связь I‑J присваивается значение, равное потоку, который должен был бы течь через связь J‑I, взятому со знаком минус. Это позволяет программе определять направление потока, используя существующую структуру узлов.
=======347
Private Sub FindMaxFlows()
Dim candidates As Collection
Dim Residual() As Integer
Dim num_nodes As Integer
Dim id1 As Integer
Dim id2 As Integer
Dim node As FlowNode
Dim to_node As FlowNode
Dim from_node As FlowNode
Dim link As FlowLink
Dim min_residual As Integer
If SourceNode Is Nothing Or SinkNode Is Nothing _
Then Exit Sub
' Задать размер массива остаточной пропускной способности.
num_nodes = Nodes.Count
ReDim Residual(1 To num_nodes, 1 To num_nodes)
' Первоначально значения остаточной пропускной способности
' равны значениям пропускной способности.
For Each node In Nodes
id1 = node.Id
For Each link In node.Links
If link.Node1 Is node Then
Set to_node = link.Node2
Else
Set to_node = link.Node1
End If
id2 = to_node.Id
Residual(id1, id2) = link.Capacity
Next link
Next node
' Повторять до тех пор, пока больше
' не найдется расширяющих путей.
Do
' Найти расширяющий путь в остаточной сети.
' Сбросить значения NodeStatus и InLink всех узлов.
For Each node In Nodes
node.NodeStatus = NOT_IN_LIST
Set node.InLink = Nothing
Next node
' Начать с пустого списка возможных узлов.
Set candidates = New Collection
' Поместить источник в список возможных узлов.
candidates.Add SourceNode
SourceNode.NodeStatus = NOW_IN_LIST
' Продолжать, пока список возможных узлов не опустеет.
Do While candidates.Count > 0
Set node = candidates(1)
candidates.Remove 1
node.NodeStatus = WAS_IN_LIST
id1 = node.Id
' Проверить выходящие из узла связи.
For Each link In node.Links
If link.Node1 Is node Then
Set to_node = link.Node2
Else
Set to_node = link.Node1
End If
id2 = to_node.Id
' Проверить, что residual > 0, и этот узел
' никогда не был в списке.
If Residual(id1, id2) > 0 And _
to_node.NodeStatus = NOT_IN_LIST _
Then
' Добавить узел в список.
candidates.Add to_node
to_node.NodeStatus = NOW_IN_LIST
Set to_node.InLink = link
End If
Next link
' Остановиться, если помечен узел‑сток.
If Not (SinkNode.InLink Is Nothing) Then _
Exit Do
Loop
' Остановиться, если расширяющий путь не найден.
If SinkNode.InLink Is Nothing Then Exit Do
' Найти наименьшую остаточную пропускную способность
' вдоль расширяющего пути.
min_residual = INFINITY
Set node = SinkNode
Do
If node Is SourceNode Then Exit Do
id2 = node.Id
Set link = node.InLink
If link.Node1 Is node Then
Set from_node = link.Node2
Else
Set from_node = link.Node1
End If
id1 = from_node.Id
If min_residual > Residual(id1, id2) Then _
min_residual = Residual(id1, id2)
Set node = from_node
Loop
' Обновить остаточные пропускные способности,
' используя расширяющий путь.
Set node = SinkNode
Do
If node Is SourceNode Then Exit Do
id2 = node.Id
Set link = node.InLink
If link.Node1 Is node Then
Set from_node = link.Node2
Else
Set from_node = link.Node1
End If
id1 = from_node.Id
Residual(id1, id2) = Residual(id1, id2) _
- min_residual
Residual(id2, id1) = Residual(id2, id1) _
+ min_residual
Set node = from_node
Loop
Loop ' Повторять, пока больше не останется расширяющих путей.
' Вычислить потоки в остаточной сети.
For Each link In Links
id1 = link.Node1.Id
id2 = link.Node2.Id
If link.Capacity > Residual(id1, id2) Then
link.Flow = link.Capacity - Residual(id1, id2)
Else
' Отрицательные значения соответствуют
' обратному направлению движения.
link.Flow = Residual(id2, id1) - link.Capacity
End If
Next link
' Найти полный поток.
TotalFlow = 0
For Each link In SourceNode.Links
TotalFlow = TotalFlow + Abs(link.Flow)
Next link
End Sub
=======348-350
Программа Flow использует метод поиска расширяющего пути для нахождения максимального потока в сети. Она похожа на остальные программы в этой главе. Если вы не добавляете или не удаляете узел или связь, вы можете выбрать источник при помощи левой кнопки мыши, а затем выбрать сток при помощи правой кнопки мыши. После выбора источника и стока программа вычисляет и выводит на экран максимальный поток. На рис. 12.26 показано окно программы, на котором изображены потоки в небольшой сети.
Вычисления максимального потока используются во многих приложениях. Хотя для многих сетей может быть важно знать максимальный поток, этот метод часто используется для получения результатов, которые на первый взгляд имеют отдаленное отношение к пропускной способности сети.
Большие сети связи должны обладать избыточностью (redundancy). Для заданной сети, например такой, как на рис. 12.27, может потребоваться найти число непересекающихся путей из источника к стоку. При этом, если между двумя узлами сети есть множество непересекающихся путей, все связи в которых различны, то соединение между этими узлами останется, даже если несколько связей в сети будут разорваны.
Можно определить число различных путей, используя метод вычисления максимального потока. Создадим сеть с узлами и связями, соответствующими узлам и связям в коммуникационной сети. Присвоим каждой связи единичную пропускную способность.
@Рис. 12.26. Программа Flow
=====351
@Рис. 12.27. Сеть коммуникаций
Затем вычислим максимальный поток в сети. Максимальный поток будет равен числу различных путей от источника к стоку. Так как каждая связь может нести единичный поток, то ни один из путей, использованных при вычислении максимального потока, не может иметь общей связи.
При более строгом определении избыточности можно потребовать, чтобы различные пути не имели ни общих связей, ни общих узлов. Немного изменив предыдущую сеть, можно использовать вычисление максимального потока для решения и этой задачи.
Разделим каждый узел за исключением источника и стока на два узла, соединенных связью единичной пропускной способности. Соединим первый из полученных узлов со всеми связями, входящими в исходный узел. Все связи, выходящие из исходного узла, присоединим ко второму полученному после разбиения узлу. На рис. 12.28 показана сеть с рис. 12.27, узлы на которой разбиты таким образом. Теперь найдем максимальный поток для этой сети.
Если путь, использованный для вычисления максимального потока, проходит через узел, то он может использовать связь, которая соединяет два получившихся после разбиения узла. Так как эта связь имеет единичную пропускную способность, никакие два пути, полученные при вычислении максимального потока, не могут пройти по этой связи между узлами, поэтому в исходной сети никакие два пути не могут использовать один и тот же узел.
@Рис. 12.28. Коммуникационная сеть после преобразования
======352
@Рис. 12.29. Сеть распределения работы
Предположим, что имеется группа сотрудников, каждый из которых обладает определенными навыками. Предположим также, что существует ряд заданий, которые требуют привлечения сотрудника, обладающего заданным набором навыков. Задача распределения работы (work assignment) состоит в том, чтобы распределить работу между сотрудниками так, чтобы каждое задание выполнял сотрудник, имеющий соответствующие навыки.
Чтобы свести эту задачу к вычислению максимального потока, создадим сеть с двумя столбцами узлов. Каждый узел в левом столбце представляет одного сотрудника. Каждый узел в правом столбце представляет одно задание.
Затем сравним навыки каждого сотрудника с навыками, необходимыми для выполнения каждого из заданий. Создадим связь между каждым сотрудником и каждым заданием, которое он способен выполнить, и присвоим всем связям единичную пропускную способность.
Создадим узел‑источник и соединим его с каждым из сотрудников связью единичной пропускной способности. Затем создадим узел‑сток и соединим с ним каждое задание, снова при помощи связей с единичной пропускной способностью. На рис. 12.29 показана соответствующая сеть для задачи распределения работы с четырьмя сотрудниками и четырьмя заданиями.
Теперь найдем максимальный поток из источника в сток. Каждая единица потока должна пройти через один узел сотрудника и один узел задания. Этот поток представляет распределение работы для этого сотрудника.
@Рис. 12.30. Программа Work
=======353
Если сотрудники обладают соответствующими навыками для выполнения всех заданий, то вычисления максимального потока распределят их все. Если невозможно выполнить все задания, то в процессе вычисления максимального потока работа будет распределена так, чтобы было выполнено максимально возможное число заданий.
Программа Work использует этот алгоритм для распределения работы между сотрудниками. Введите фамилии сотрудников и их навыки в текстовом поле слева, а задания, которые требуется выполнить и требующиеся для них навыки в текстовом поле посередине. После того, как вы нажмете на кнопку Go (Начать), программа распределит работу между сотрудниками, используя для этого сеть максимального потока. На рис. 12.30 показано окно программы с полученным распределением работы.
Некоторые сетевые алгоритмы можно применить непосредственно к сетеподобным объектам. Например, можно использовать алгоритм поиска кратчайшего маршрута для нахождения наилучшего пути в уличной сети. Для определения наименьшей стоимости построения сети связи или соединения городов железными дорогами можно использовать минимальное остовное дерево.
Многие другие сетевые алгоритм находят менее очевидные применения. Например, можно использовать алгоритмы поиска кратчайшего маршрута для разбиения на районы, составления плана работ методом кратчайшего пути, или графика коллективной работы. Алгоритмы вычисления максимального потока можно использовать для распределения работы. Эти менее очевидные применения сетевых алгоритмов обычно оказываются более интересными и перспективными.
======354
Глава 13. Объектно‑ориентированные методыИспользование функций и подпрограмм позволяет программисту разбить код большой программы на части. Массивы и определенные пользователем типы данных позволяют сгруппировать элементы данных так, чтобы упросить работу с ними.
Классы, которые впервые появились в 4-й версии Visual Basic, позволяют программисту по‑новому сгруппировать данные и логику работы программы. Класс позволяет объединить в одном объекте данные и методы работы с ними. Этот новый подход к управлению сложностью программ позволяет взглянуть на алгоритмы с другой точки зрения.
В этой главе рассматриваются вопросы объектно‑ориентированного программирования, возникающие при применении классов Visual Basic. В ней описаны преимущества объектно‑ориентированного программирования (ООП) и показано, какую выгоду можно получить от их применения в программах на языке Visual Basic. Затем в главе рассматривается набор полезных объектно‑ориентированных примеров, которые вы можете использовать для управления сложностью ваших приложений.
К традиционным преимуществам объектно‑ориентированного программирования относятся инкапсуляция или скрытие (encapsulation), полиморфизм (polymorphism) и повторное использование (reuse). Реализация их в классах Visual Basic несколько отличается от того, как они реализованы в других объектно‑ориентированных языках. В следующих разделах рассматриваются эти преимущества ООП и то, как можно ими воспользоваться в программах на Visual Basic.
Объект, определенный при помощи класса, заключает в себе данные, которые он содержит. Другие части программы могут использовать объект для оперирования его данными, не зная о том, как хранятся или изменяются значения данных. Объект предоставляет открытые (public) процедуры, функции, и процедуры изменения свойств, которые позволяют программе косвенно манипулировать или просматривать данные. Так как при этом данные являются абстрактными с точки зрения программы, это также называется абстракцией данных (data abstraction).
Инкапсуляция позволяет программе использовать объекты как «черные ящики». Программа может использовать открытые методы объекта для проверки и изменения значений без необходимости разбираться в том, что происходит внутри черного ящика.
=========355
Поскольку действия внутри объектов скрыты от основной программы, реализация объекта может меняться без изменения основной программы. Изменения в свойствах объекта происходят только в модуле класса.
Например, предположим, что имеется класс FileDownload, который скачивает файлы из Internet. Программа сообщает классу FileDownload положение объекта, а объект возвращает строку с содержимым файла. В этом случае программе не требуется знать, каким образом объект производит загрузку файла. Он может скачивать файл, используя модемное соединение или соединение по выделенной линии, или даже извлекать файл из кэша на локальном диске. Программа знает только, что объект возвращает строку после того, как ему передается ссылка на файл.
Для обеспечения инкапсуляции класс должен предотвращать непосредственный доступ к своим данным. Если переменная в классе объявлена как открытая, то другие части программы смогут напрямую изменять и считывать данные из нее. Если позднее представление данных изменится, то любые части программы, которые непосредственно взаимодействуют с данными, также должны будут измениться. При этом теряется преимущество инкапсуляции.
Чтобы обеспечить доступ к данным, класс должен использовать процедуры для работы со свойствами. Например, следующие процедуры позволяют другим частям программы просматривать и изменять значение DegreesF объекта Temperature.
Private m_DegreesF As Single ' Градусы Фаренгейта.
Public Property Get DegreesF() As Single
DegreesF = m_DegreesF
End Property
Public Property Let DegreesF(new_DegreesF As Single)
m_DegreesF = new_DegreesF
End Property
Различия между этими процедурами и определением m_DegreesF как открытой переменной пока невелики. Тем не менее, использование этих процедур позволяет легко изменять класс в дальнейшем. Например, предположим, что вы решите измерять температуру в градусах Кельвина, а не Фаренгейта. При этом можно изменить класс, не затрагивая остальных частей программы, в которых используются процедуры свойства DegreesF. Можно также добавить код для проверки ошибок, чтобы убедиться, что программа не попытается передать объекту недопустимые значения.
Private m_DegreesK As Single ' Градусы Кельвина.
Public Property Get DegreesF() As Single
DegreesF = (m_DegreesK - 273.15) * 1.8
End Property
Public Property Let DegreesF(ByVal new_DegreesF As Single)
Dim new_value As Single
new_value = (new_DegreesF / 1.8) + 273.15
If new_value < 0 Then
' Сообщить об ошибке ‑ недопустимое значении.
Error.Raise 380, "Temperature", _
"Температура должна быть неотрицательной."
Else
m_DegreesK = new_value
End If
End Property
======357
Программы, описанные в этом материале, безобразно нарушают принцип инкапсуляции, используя в классах открытые переменные. Это не слишком хороший стиль программирования, но так сделано по трем причинами.
Во‑первых, непосредственное изменение значений данных выполняется быстрее, чем вызов процедур свойств. Большинство программ уже и так несколько теряют в производительности из‑за использования ссылок на объекты вместо применения более сложного метода псевдоуказателей. Применения процедур свойств еще сильнее замедлит их работу.
Во‑вторых, многие программы демонстрируют методы работы со структурами данных. Например, сетевые алгоритмы, описанные в 12 главе, непосредственно используют данные объекта. Указатели, которые связывают узлы в сети друг с другом, составляют неотъемлемую часть алгоритмов. Было бы бессмысленно менять способ хранения этих указателей.
И, наконец, благодаря использованию открытых значений данных, код становится проще. Это позволяет вам сконцентрироваться на алгоритмах, и этому не мешают лишние процедуры работы со свойствами.
Второе преимущество объектно‑ориентированного программирования — это полиморфизм (polymorphism), что означает «имеющий множество форм». В Visual Basic это означает, что один объект может иметь различный формы в зависимости от ситуации. Например, следующий код представляет собой подпрограмму, которая может принимать в качестве параметра любой объект. Объект obj может быть формой, элементом управления, или объектом определенного вами класса.
Private Sub ShowName(obj As Object)
MsgBox TypeName(obj)
End Sub
Полиморфизм позволяет создавать процедуры, которые могут работать буквально со всеми типами объектов. Но за эту гибкость приходится платить. Если определить обобщенный (generic) объект, как в этом примере, то Visual Basic не сможет определить, какие типы действий сможет выполнять объект, до запуска программы.
========357
Если Visual Basic заранее знает, с объектом какого типа он будет иметь дело, он может выполнить предварительные действия для того, чтобы более эффективно использовать объект. Если используется обобщенный (generic) объект, то программа не может выполнить подготовки, и в результате этого потеряет в производительности.
Программа Generic демонстрирует разницу в производительности между объявлением объектов как принадлежащих к определенному типу или как обобщенных объектов. Тест выполняется одинаково, за исключением того, что в одном из случаев объект определяется, как имеющий тип Object, а не тип SpecificClass. При этом установка значения данных объекта с использованием обобщенного объекта выполняется в 200 раз медленнее.
Private Sub TestSpecific()
Const REPS = 1000000 ' Выполнить миллион повторений.
Dim obj As SpecificClass
Dim i As Long
Dim start_time As Single
Dim stop_time As Single
Set obj = New SpecificClass
start_time = Timer
For i = 1 To REPS
obj.Value = I
Next i
stop_time = Timer
SpecificLabel.Caption = _
Format$(1000 * (stop_time - start_time) / REPS, "0.0000")
End Sub
В 5‑й версии Visual Basic зарезервированное слово Implements (Реализует) позволяет программе использовать полиморфизм без использования обобщенных объектов. Например, программа может определить интерфейс Vehicle (Средство передвижения), Если классы Car (Автомобиль) и Truck (Грузовик) оба реализуют интерфейс Vehicle, то программа может использовать для выполнения функций интерфейса Vehicle объекты любого из двух классов.
Создадим вначале класс интерфейса, в котором определим открытые переменные, которые он будет поддерживать. В нем также должны быть определены прототипы открытых процедур для всех методов, которые он будет поддерживать. Например, следующий код демонстрирует, как класс Vehicle может определить переменную Speed (Скорость) и метод Drive (Вести машину):
Public Speed Long
Public Sub Drive()
End Sub
=======358
Теперь создадим класс, который реализует интерфейс. После оператора Option Explicit в секции Declares добавляется оператор Implements определяющий имя класса интерфейса. Этот класс должен также определять все необходимые для работы локальные переменные.
Класс Car реализует интерфейс Vehicle. Следующий код демонстрирует, как в нем определяется интерфейс и закрытая (private) переменная m_Speed:
Option Explicit
Implements Vehicle
Private m_Speed As Long
Когда к классу добавляется оператор Implements, Visual Basic считывает интерфейс, определенный указанным классом, а затем создает соответствующие заглушки в коде класса. В этом примере Visual Basic добавит новую секцию Vehicle в исходный код класса Car, и определит процедуры let и get свойства Vehicle_Speed для представления переменной Speed, определенной в интерфейсе Vehicle. В процедуре let Visual Basic использует переменную RHS, которая является сокращением от Right Hand Side (С правой стороны), в которой задается новое значение переменной.
Также определяется процедура Vehicle_Drive. Чтобы реализовать функции этих процедур, нужно написать код для них. Следующий код демонстрирует, как класс Car может определять процедуры Speed и Drive.
Private Property Let Vehicle_Speed(ByVal RHS As Long)
m_Speed = RHS
End Property
Private Property Get Vehicle_Speed() As Long
Vehicle_Speed = m_Speed
End Property
Private Sub Get Vehicle_Drive()
' Выполнить какие‑то действия.
:
End Property
После того, как интерфейс определен и реализован в одном или нескольких классах, программа может полиморфно использовать элементы в этих классах. Например, допустим, что программа определила классы Car и Track, которые оба реализуют интерфейс Vehicle. Следующий код демонстрирует, как программа может проинициализировать значения переменной Speed для объекта Car и объекта Truck.
Dim obj As Vehicle
Set obj = New Car
obj.Speed = 55
Set obj = New Truck
obj .Speed =45
==========359
Ссылка obj может указывать либо на объект Car, либо на объект Truck. Так как в обоих этих объектах реализован интерфейс Vehicle, то программа может оперировать свойством obj.Speed независимо от того, указывает ли ссылка obj на Car или Truck.
Так как ссылка obj указывает на объект, который реализует интерфейс Vehicle, то Visual Basic знает, что этот объект имеет процедуры, работающие со свойством Speed. Это означает, что он может выполнять вызовы процедур свойства Speed более эффективно, чем это было бы в случае, если бы obj была ссылкой на обобщенный объект.
Программа Implem является доработанной версией программы описанной выше программы Generic. Она сравнивает скорость установки значений с использованием обобщенных объектов, определенных объектов и объектов, которые реализуют интерфейс. В одном из тестов на компьютере с процессором Pentium с тактовой частотой 166 МГц, программе потребовалось 0,0007 секунды для установки значений при использовании определенного типа объекта. Для установки значений при использовании объекта, реализующего интерфейс, потребовалось 0,0028 секунды (в 4 раза больше). Для установки значений при использовании обобщенного объекта потребовалось 0,0508 секунды (в 72 раза больше). Использование интерфейса является не таким быстрым, как использование ссылки на определенный объект, но намного быстрее, чем использование обобщенных объектов.
Процедуры и функции поддерживают повторное использование (reuse). Вместо того, чтобы каждый раз писать код заново, можно поместить его в подпрограмму, тогда вместо блока кода можно просто подставить вызов подпрограммы.
Аналогично, определение процедуры в классе делает ее доступной во всей программе. Программа может использовать эту процедуру, используя объект, который является экземпляром класса.
В среде программистов, использующих объектно‑ориентированный подход, под повторным использованием обычно подразумевается нечто большее, а именно наследование (inheritance). В объектно‑ориентированных языках, таких как C++ или Delphi, один класс может порождать (derive) другой. При этом второй класс наследует (inherits) всю функциональность первого класса. После этого можно добавлять, изменять или убирать какие‑либо функции из класса‑наследника. Это также является формой повторного использования кода, поскольку при этом программисту не нужно заново реализовать функции родительского класса, для того, чтобы использовать их в классе‑наследнике.
Хотя Visual Basic и не поддерживает наследование непосредственно, можно добиться примерно тех же результатов, используя ограничение (containment) или делегирование (delegation).[RP23] При делегировании объект из одного класса содержит экземпляр класса из другого объекта, и затем передает часть своих обязанностей заключенному в нем объекту.
Например, предположим, что имеется класс Employee, который представляет данные о сотрудниках, такие как фамилия, идентификационный номер в системе социального страхования и зарплата. Предположим, что нам теперь нужен класс Manager, который делает то же самое, что и класс Employee, но имеет еще одно свойство secretary (секретарь).
Для использования делегирования, класс Manager должен включать в себя закрытый объект типа Employee с именем m_Employee. Вместо прямого вычисления значений, процедуры работы со свойствами фамилии, номера социального страхования и зарплаты передают соответствующие вызовы объекту m_Employee. Следующий код демонстрирует, как класс Manager может оперировать процедурами свойства name (фамилия):
==========360
Private m_Employee As New Employee
Property Get Name() As String
Name = m_Employee.Name
End Property
Property Let Name (New_Name As String)
m_Employee.Name = New_Name
End Property
Класс Manager также может изменять результат, возвращаемый делегированной функцией, или выдавать результат сама. Например, в следующем коде показано, как класс Employee возвращает строку текста с данными о сотруднике.
Public Function TextValues() As String
Dim txt As String
txt = m_Name & vbCrLf
txt = txt & " " & m_SSN & vbCrLf
txt = txt & " " & Format$(m_Salary, "Currency") & vbCrLf
TextValues = txt
End Function
Класс Manager использует функцию TextValues объекта Employee, но добавляет перед возвратом информацию о секретаре в строку результата.
Public Function TextValues() As String
Dim txt As String
txt = m_Employee.TextValues
txt = txt & " " & m_Secretary & vbCrLf
TextValues = txt
End Function
Программа Inherit демонстрирует классы Employee и Manager. Интерфейс программы не представляет интереса, но ее код включает простые определения классов Employee и Manager.
В первой главе мы дали определение алгоритма как «последовательности инструкций для выполнения какого‑либо задания». Несомненно, класс может использовать алгоритмы в своих процедурах и функциях. Например, можно использовать класс для упаковки в него алгоритма. Некоторые из программ, описанных в предыдущих главах, используют классы для инкапсуляции сложных алгоритмов.
=========361
Классы также позволяют использовать новый стиль программирования, при котором несколько объектов могут работать совместно для выполнения задачи. В этом случае может быть бессмысленным задание последовательности инструкций для выполнения задачи. Более адекватным может быть задание модели поведения объектов, чем сведение задачи к последовательности шагов. Для того чтобы отличать такое поведение от традиционных алгоритмов, мы назовем их «парадигмами».
Следующие раздела описывают некоторые полезные объектно‑ориентированные парадигмы. Многие из них ведут начало из других объектно‑ориентированных языков, таких как C++ или Smalltalk, хотя они могут также использоваться в Visual Basic.
Управляющие объекты (command) также называются объектами действия (action objects), функций (function objects) или функторами (functors). Управляющий объект представляет какое‑либо действие. Программа может использовать метод Execute (Выполнить) для выполнения объектом этого действия. Программе не нужно знать ничего об этом действии, она знает только, что объект имеет метод Execute.
Управляющие объекты могут иметь множество интересных применений. Программа может использовать управляющий объект для реализации:
· Настраиваемых элементов интерфейса;
· Макрокоманд;
· Ведения и восстановления записей;
· Функций «отмена» и «повтор».
Чтобы создать настраиваемый интерфейс, форма может содержать управляющий массив кнопок. Во время выполнения программы форма может загрузить надписи на кнопках и создать соответствующий набор управляющих объектов. Когда пользователь нажимает на кнопку, обработчику событий кнопки нужно всего лишь вызвать метод Execute соответствующего управляющего объекта. Детали происходящего находятся внутри класса управляющего объекта, а не в обработчике событий.
Программа Command1 использует управляющие объекты для создания настраиваемого интерфейса для нескольких не связанных между собой функций. При нажатии на кнопку программа вызывает метод Execute соответствующего управляющего объекта.
Программа может использовать управляющие объекты для создания определенных пользователем макрокоманд. Пользователь задает последовательность действий, которые программа запоминает в коллекции в виде управляющих объектов. Когда затем пользователь вызывает макрокоманду, программа вызывает методы Execute объектов, которые находятся в коллекции.
Управляющие объекты могут обеспечивать ведение и восстановление записей. Управляющий объект может при каждом своем вызове записывать информацию о себе в лог‑файл. Если программа аварийно завершит работы, она может затем использовать записанную информацию для восстановления управляющих объектов и выполнения их для повторения последовательности команд, которая выполнялась до сбоя программы.
И, наконец, программа может использовать набор управляющих объектов для реализации функций отмены (undo) и повтора (redo).
=========362
Программа использует переменную LastCmd для отслеживания последнего управляющего объекта в коллекции. Если вы выбираете команду Undo (Отменить) в меню Draw (Рисовать), то программа уменьшает значение переменной LastCmd на единицу. Когда программа потом выводит рисунок, она вызывает только объекты, стоящие до объекта с номером LastCmd.
Если вы выбираете команду Redo (Повторить) в меню Draw, то программа увеличивает значение переменной LastCmd на единицу. Когда программа выводит рисунок, она выводит на один объект больше, чем раньше, поэтому отображается восстановленный рисунок.
При добавлении новой фигуры программа удаляет любые команды из коллекции, которые лежат после позиции LastCmd,. затем добавляет новую команду рисования в конце и запрещает команду Redo, так как нет команд, которые можно было бы отменить. На рис. 13.1 показано окно программы Command2 после добавления новой фигуры.
Контролирующий объект (visitor object) проверяет все элементы в составном объекте (aggregate object). Процедура, реализованная в составном классе, обходит все объекты, передавая каждый из них контролирующему объекту в качестве параметра.
Например, предположим, что составной объект хранит элементы в связном списке. Следующий код показывает, как его метод Visit обходит список, передавая каждый объект в качестве параметра методу Visit контролирующего объекта ListVisitor:
Public Sub Visit(obj As ListVisitor)
Dim cell As ListCell
Set cell = TopCell
Do While Not (cell Is Nothing)
obj.Visit cell
Set cell = cell.NextCell
Loop
End Sub
@Рис. 13.1. Программа Command2
=========363
Следующий код демонстрирует, как класс ListVisitor может выводить на экран значения элементов в окне Immediate (Срочно).
Public Sub Visit(cell As ListCell)
Debug.Print cell.Value
End Sub
Используя парадигму контролирующего объекта, составной класс определяет порядок, в котором обходятся элементы. Составной класс может определять несколько методов для обхода содержащих его элементов. Например, класс дерева может обеспечивать методы VisitPreorder (Прямой обход), VisitPostorder (Обратный обход), VisitInorder (Симметричный обход) и VisitBreadthFirst (Обход в глубину) для обхода элементов в различном порядке.
Итератор обеспечивает другой метод обхода элементов в составном объекте. Объект‑итератор обращается к составному объекту для обхода его элементов, и в этом случае итератор определяет порядок, в котором проверяются элементы. С составным классом могут быть сопоставлены несколько классов итераторов для того, чтобы выполнять различные обходы элементов составного класса.
Чтобы выполнить обход элементов, итератор должен представлять порядок, в котором элементы записаны, чтобы определить порядок их обхода. Если составной класс представляет собой связный список, то объект‑итератор должен знать, что элементы находятся в связном списке, и должен уметь перемещаться по списку. Так как итератору известны детали внутреннего устройства списка, это нарушает скрытие данных составного объекта.
Вместо того чтобы каждый класс, которому нужно проверять элементы составного класса, реализовал обход самостоятельно, можно сопоставить составному классу класс итератора. Класс итератора должен содержать простые процедуры MoveFirst (Переместиться в начало), MoveNext (Переместиться на следующий элемент), EndOfList (Переместиться в конец списка) и CurrentItem (Текущий элемент) для обеспечения косвенного доступа к списку. Новые классы могут включать в себя экземпляр класса итератора и использовать его методы для обхода элементов составного класса. На рис. 13.2 схематически показано, как новый объект использует объект‑итератор для связи со списком.
Программа IterTree, описанная ниже, использует итераторы для обхода полного двоичного дерева. Класс Traverser (Обходчик) содержит ссылку на объект‑итератор. Они использует обеспечиваемые итератором процедуры MoveFirst, MoveNext, CurrentCaption и EndOfTree для получения списка узлов в дереве.
@Рис. 13.2. Использование итератора для косвенной связи со списком
=========364
Итераторы нарушают скрытие соответствующих им составных объектов, в отличие от новых классов, которые содержат итераторы. Для того, чтобы избавиться от потенциальной путаницы, можно рассматривать итератор как надстройку над составным объектом.
Контролирующие объекты и итераторы обеспечивают выполнение похожих функций, используя различные подходы. Так как парадигма контролирующего объекта оставляет детали составного объекта скрытыми внутри него, она обеспечивает лучшую инкапсуляцию. Итераторы могут быть полезны, если порядок обхода может часто изменяться или он должен переопределяться во время выполнения программы. Например, составной объект может использовать методы порождающего класса (который описан позднее) для создания объекта‑итератора в процессе выполнения программы. Содержащий итератор класс не должен знать, как создается итератор, он всего лишь использует методы итератора для доступа к элементам составного объекта.
Многие классы тесно работают с другими. Например, класс итератора тесно взаимодействует с составным классом. Для выполнения работы, итератор должен нарушать скрытие составного класса. При этом, хотя эти связанные классы иногда должны нарушать скрытие данных друг друга, другие классы должны не иметь такой возможности.
Дружественный класс (friend class) — это класс, имеющий специальное разрешение нарушать скрытие данных для другого класса. Например, класс итератора является дружественным классом для соответствующего составного класса. Ему, в отличие от других классов, разрешено нарушать скрытие данных для составного класса.
В 5‑й версии Visual Basic появилось зарезервированное слово Friend для разрешения ограниченного доступа к переменным и процедурам, определенным внутри модуля. Элементы, определенные при помощи зарезервированного слова Friend, доступны внутри проекта, но не в других проектах. Например, предположим, что вы создали классы LinkedList (Связный список) и ListIterator (Итератор списка) в проекте ActiveX сервера. Программа может создать сервер связного списка для управления связными списками. Порождающий метод класса LinkedList может создавать объекты типа ListIterator для использования в программе.
Класс LinkedList может обеспечивать в программе средства для работы со связными списками. Этот класс объявляет свои свойства и методы открытыми, чтобы их можно было использовать в основной программе. Класс ListIterator позволяет программе выполнять итерации над объектами, которыми управляет класс LinkeList. Процедуры, используемые классом ListIterator для оперирования объектами LinkedList, объявляются как дружественные в модуле LinkedList. Если классы LinkedList и ListIterator создаются в одном и том же проекте, то класс ListIterator может использовать эти дружественные процедуры. Поскольку основная программа находится в другом проекте, она этого сделать не может.
Этот очень эффективный, но довольно громоздкий метод. Она требует создания двух проектов, и установки одного сервера ActiveX. Он также не работает в более ранних версиях Visual Basic.
Наиболее простой альтернативой было бы соглашение о том, что только дружественные классы могут нарушать скрытие данных друг друга. Если все разработчики будут придерживаться этого правила, то проектом все еще можно будет управлять. Тем не менее, искушение обратиться напрямую к данным класса LinkedList может быть сильным, и всегда существует вероятность, что кто‑нибудь нарушит скрытие данных из‑за лени или по неосторожности.
Другая возможность заключается в том, чтобы дружественный объект передавал себя другому классу в качестве параметра. Передавая себя в качестве параметра, дружественный класс тем самым показывает, что он является таковым. Программа Fstacks использует этот метод для реализации стеков.
=======365
При использовании этого метода все еще можно нарушить скрытие данных объекта. Программа может создать объект дружественного класса и использовать его в качестве параметра, чтобы обмануть процедуры другого объекта. Тем не менее, это достаточно громоздкий процесс, и маловероятно, что разработчик сделает так случайно.
В этой парадигме один из объектов выступает в качестве интерфейса (interface) между двумя другими. Один объект может использовать свойства и методы первого объекта для взаимодействия со вторым. Интерфейс иногда также называется адаптером (adapter), упаковщиком (wrapper), или мостом (bridge). На рис. 13.3 схематически изображена работа интерфейса.
Интерфейс позволяет двум объектам на его концах изменяться независимо. Например, если свойства объекта слева на рис. 13.3 изменятся, интерфейс должен быть изменен, а объект справа — нет.
В этой парадигме процедуры, используемые двумя объектами, поддерживаются разработчиками, которые отвечают за эти объекты. Разработчик, который реализует левый объект, также занимается реализацией процедур интерфейса, которые взаимодействуют с левым объектом.
Фасад (Facade) аналогичен интерфейсу, но он обеспечивает простой интерфейс для сложного объекта или группы объектов. Фасад также иногда называется упаковщиком (wrapper). На рис. 13.4. показана схема работы фасада.
Разница между фасадом и интерфейсом в основном умозрительная. Основная задача интерфейса — обеспечение косвенного взаимодействия между объектами, чтобы они могли развиваться независимо. Основная задача фасада — облегчение использования каких‑то сложных вещей за счет скрытия деталей.
Порождающий объект (Factory) — это объект, который создает другие объекты. Порождающий метод — это процедура или функция, которая создает объект.
Порождающие объекты наиболее полезны, если два класса должны тесно работать вместе. Например, составной класс может содержать порождающий метод, который создает итераторы для него. Порождающий метод может инициализировать итератор таким образом, чтобы он был готов к работе с экземпляром класса, который его создал.
@Рис. 13.3 Интерфейс
========366
@Рис. 13.4. Фасад
Программа IterTree создает полное двоичное дерево, записанное в массиве. После нажатия на одну из кнопок, задающих направление обхода, программа создает объект Traverser (Обходчик). Она также использует один из порождающих методов дерева для создания соответствующего итератора. Объект Traverser использует итератор для обхода дерева и вывода списка узлов в правильном порядке. На рис. 13.5 приведено окно программы IterTree, показывающее обратный обход дерева.
Единственный объект (singleton object) — это объект, который существует в приложении в единственном экземпляре. Например, в Visual Basic определен класс Printer (Принтер). Он также определяет единственный объект с тем же названием. Этот объект представляет принтер, выбранный в системе по умолчанию. Так как в каждый момент времени может быть выбран только один принтер, то имеет смысл определить объект Printer как единственный объект.
Один из способов создания единственного объекта заключается в использовании процедуры, работающей со свойствами в модуле BAS. Эта процедура возвращает ссылку на объект, определенный внутри модуля как закрытый. Для других частей программы эта процедура выглядит как просто еще один объект.
@Рис. 13.5. Программа IterTree, демонстрирующая обратный обход
=======367
Программа WinList использует этот подход для создания единственного объекта класса WinListerClass. Объект класса WinListerClass представляет окна в системе. Так как операционная система одна, то нужен только один объект класса WinListerClass. Модуль WinList.BAS использует следующий код для создания единственного объекта с названием WindowLister.
Private m_WindowLister As New WindowListerClass
Property Get WindowLister() As WindowListerClass
Set WindowLister = m_WindowLister
End Property
Единственный объект WindowLister доступен во всем проекте. Следующий код демонстрирует, как основная программа использует свойство WindowList этого объекта для вывода на экран списка окон.
WindowListText.Text = WindowLister.WindowList
Многие приложения сохраняют объекты и восстанавливают их позднее. Например, приложение может сохранять копию своих объектов в текстовом файле. При следующем запуске программы, она считывает это файл и загружает объекты.
Объект может содержать процедуры, которые считывают и записывают его в файл. Общий подход может заключаться в том, чтобы создать процедуры, которые сохраняют и восстанавливают данные объекта, используя строку. Поскольку запись данных объекта в одной строке преобразует объект в последовательность символов, этот процесс иногда называется преобразованием в последовательную форму (serialization).
Преобразование объекта в строку обеспечивает большую гибкость основной программы. При этом она может сохранять и считывать объекты, используя текстовые файлы, базу данных или область памяти. Она может переслать представленный таким образом объект по сети или сделать его доступным на Web‑странице. Программа или элемент ActiveX на другом конце может использовать преобразование объекта в строку для воссоздания объекта. Программа также может дополнительно обработать строку, например, зашифровать ее после преобразования объекта в строку и расшифровать перед обратным преобразованием.
Один из подходов к преобразованию объекта в последовательную форму заключается в том, чтобы объект записал все свои данные в строку заданного формата. Например, предположим, что класс Rectangle (Прямоугольник) имеет свойства X1, Y1, X2 и Y2. Следующий код демонстрирует, как класс может определять процедуры свойства Serialization:
Property Get Serialization() As String
Serialization = _
Format$(X1) & ";" & Format$(Y1) & ";" & _
Format$(X2) & ";" & Format$(Y2) & ";"
End Property
Property Let Serialization(txt As String)
Dim pos1 As Integer
Dim pos2 As Integer
pos1 = InStr(txt, ";")
X1 = CSng(Left$(txt, pos1 - 1))
pos2 = InStr(pos1 + 1, txt, ";")
Y1 = CSng(Mid$(txt, pos1 + 1, pos2 – pos1 - 1))
pos1 = InStr(pos2 + 1, txt, ";")
X2 = CSng(Mid$(txt, pos2 + 1, pos1 - pos2 - 1))
pos2 = InStr(pos1 + 1, txt, ";")
Y2 = CSng(Mid$(txt, pos1 + 1, pos2 – pos1 - 1))
End Property
Этот метод довольно простой, но не очень гибкий. По мере развития программы, изменения в структуре объектов могут заставить вас перетранслировать все сохраненные ранее преобразованные в последовательную форму объекты. Если они находятся в файлах или базах данных, для загрузки старых данных и записи их в новом формате может потребоваться написание программ‑конверторов.
Более гибкий подход заключается в том, чтобы сохранять вместе со значениями элементов данных объекта их имена. Когда объект считывает данные, преобразованные в последовательную форму, он использует имена элементов для определения значений, который необходимо установить. Если позднее в определение элемента будут добавлены какие‑либо элементы, или удалены из него, то не придется преобразовывать старые данные. Если новый объект загрузит старые данные, то он просто проигнорирует не поддерживаемые более значения.
Определяя значения данных по умолчанию, иногда можно уменьшить размер преобразованных в последовательную форму объектов. Процедура get свойства Serialization сохраняет только значения, которые отличаются от значений по умолчанию. Перед тем, как процедура let свойства начнет выполнение преобразования в последовательную форму, она инициализирует все элементы объекта значениями по умолчанию. Значения, не равные значениям по умолчанию, обновляются по мере обработки данных процедурой.
Программа Shapes использует этот подход для сохранения и загрузки с диска рисунков, содержащих эллипсы, линии, и прямоугольники. Объект ShapePicture представляет весь рисунок целиком. Он содержит коллекцию управляющих объектов, которые представляют различные фигуры.
Следующий код демонстрирует процедуры свойства Serialization объекта ShapePicture. Объект ShapePicture сохраняет имя типа для каждого из типов объектов, а затем в скобках — представление объекта в последовательной форме.
Property Get Serialization() As String
Dim txt As String
Dim i As Integer
For i = 1 To LastCmd
txt = txt & _
TypeName(CmdObjects(i)) & "(" & _
CmdObjects(i).Serialization & ")"
Next I
Serialization = txt
End Property
==========369
Процедура let свойства Serialization использует подпрограмму GetSerialization для чтения имени объекта и списка данных в скобках. Например, если объект ShapePicture содержит команду рисования прямоугольника, то его представление в последовательной форме будет включать строку “RectangleCMD”, за которой будут следовать данные, представленные в последовательной форме.
Процедура использует подпрограмму CommandFactory для создания объекта соответствующего типа, а затем заставляет новый объект преобразовать себя из последовательной формы представления.
Property Let Serialization(txt As String) Dim pos As Integer Dim token_name As String Dim token_value As String Dim and As Object
' Start a new picture.
NewPicture
' Read values until there are no more.
GetSerialization txt, pos, token_name, token_value Do While token_name <> ""
' Make the object and make it unserialize itself.
Set and = ConiniandFactory(token_name)
If Not (and Is Nothing) Then _
and.Serialization = token_value
GetSerialization txt, pos, token_name, tokerL-value Loop
LastCmd = CmdObjects.Count End Property
Парадигма Модель/Вид/Контроллер (МВК) (Model/View/Controller) позволяет программе управлять сложными соотношениями между объектами, которые сохраняют данные, объектами, которые отображают их на экране, и объектами, которые оперируют данными. Например, приложение работы с финансами может выводить данные о расходах в виде таблицы, секторной диаграммы, или графика. Если пользователь изменяет значение в таблице, приложение должно автоматически обновить изображение на экране. Может также понадобиться записать измененные данные на диск.
Для сложных систем управление взаимодействием между объектами, которые хранят, отображают и оперируют данными, может быть достаточно запутанным. Парадигма Модель/Вид/Контроллер разбивает взаимодействия, так что можно работать с ними по отдельности, при этом используются три типа объектов: модели, виды, и контроллеры.
Модель (Model) представляет данные, обеспечивая методы, которые другие объекты могут использовать для проверки и изменения данных. В приложении работы с финансовыми данными, модель содержит данные о расходах. Она обеспечивает процедуры для просмотра и изменения значений расходов и ввода новых значений. Она также может обеспечивать функции для вычисления суммарных величин, таких как полные издержки, расходы по подразделениям, средние расходы за месяц, и так далее
Модель включает в себя набор видов, которые отображают данные. При изменении данных, модель сообщает об этом видам, которые изменяют изображение на экране соответствующим образом.
Вид (View) отображает представленные в модели данные. Так как виды обычно выводят данные для просмотра пользователем, иногда удобнее создавать их, используя форму, а не класс.
Когда программа создает вид, она должна добавить его к набору видов модели.
Контроллер (Controller) изменяет данные в модели. Контроллер должен всегда обращаться к данным модели через ее открытые методы. Эти методы могут затем сообщать об изменении видам. Если контроллер изменял бы данные модели непосредственно, то модель не смогла бы сообщить об этом видам.
Многие объекты одновременно отображают и изменяют данные. Например, текстовое поле позволяет пользователю вводить и просматривать данные. Форма, содержащая текстовое поле, является одновременно и видом, и контроллером. Переключатели, поля выбора опций, полосы прокрутки, и многие другие элементы пользовательского интерфейса позволяют одновременно просматривать и оперировать данными.
Видами/контроллерами проще всего управлять, если попытаться максимально разделить функции просмотра и управления. Когда объект изменяет данные, он не должен сам обновлять изображение на экране. Он может сделать это позднее, когда модель сообщит ему как виду о произошедшем изменении.
Эти методы достаточно громоздки для реализации стандартных объектов пользовательского интерфейса, таких как текстовые поля. Когда пользователь вводит значение в текстовом поле, оно немедленно обновляется, и выполнятся его обработчик события Change. Этот обработчик событий может модель об изменении. Модель затем сообщает виду/контроллеру (выступающему теперь как вид) о произошедшем изменении. Если при этом объект обновит текстовое поле, то произойдет еще одно событие Change, о котором снова будет сообщено модели и программа войдет в бесконечный цикл.
Чтобы предотвратить эту проблему, методы, изменяющие данные в модели, должны иметь необязательный параметр, указывающий на контроллер, который вызвал эти изменения. Если виду/контроллеру требуется сообщить об изменении, которое он вызывает, он должен передать значение Nothing процедуре, вносящей изменения. Если этого не требуется, то в качестве параметра объект должен передавать себя.
=========371
@Рис. 13.6. Программа ExpMVC
Программа ExpMVC, показанная на рис. 13.6, использует парадигму Модель/Вид/Контроллер для вывода данных о расходах. На рисунке показаны три вида различных типов. Вид/контроллер TableView отображает данные в таблице, при этом можно изменять названия статей расходов и их значения в соответствующих полях.
Вид/контроллер GraphView отображает данные при помощи гистограммы, при этом можно изменять значения расходов, двигая столбики при помощи мыши вправо.
Вид PieView отображает секторную диаграмму. Это просто вид, поэтому его нельзя использовать для изменения данных.
Классы позволяют программистам на Visual Basic рассматривать старые задачи с новой точки зрения. Вместо того чтобы представлять себе длинную последовательность заданий, которая приводит к выполнению задачи, можно думать о группе объектов, которые работают, совместно выполняя задачу. Если задача правильно разбита на части, то каждый из классов по отдельности может быть очень простым, хотя все вместе они могут выполнять очень сложную функцию. Используя описанные в этой главе парадигмы, вы можете разбить классы так, чтобы каждый из них оказался максимально простым.
==============372
Для запуска и изменения примеров приложений вам понадобится компьютер, который удовлетворяет требованиям Visual Basic к аппаратному обеспечению.
Алгоритм выполняются с различной скоростью на компьютерах разных конфигураций. Компьютер с процессором Pentium Pro и 64 Мбайт памяти будет быстрее компьютера с 386 процессором и 4 Мбайт памяти. Вы быстро узнаете ограничения вашего оборудования.
Один из наиболее полезных способов выполнения программ примеров — запускать их при помощи встроенных средств отладки Visual Basic. Используя точки останова, просмотр значений переменных и другие свойства отладчика, вы можете наблюдать алгоритмы в действии. Это может быть особенно полезно для понимания наиболее сложных алгоритмов, таких как алгоритмы работы со сбалансированными деревьями и сетевые алгоритмы, представленные в 7 и 12 главах соответственно.
Некоторые и программ примеров создают файлы данных или временные файлы. Эти программы помещают такие файлы в соответствующие директории. Например, некоторые из программ сортировки, представленные в 9 главе, создают файлы данных в директории Src\Ch9/. Все эти файлы имеют расширение “.DAT”, поэтому вы можете найти и удалить их в случае необходимости.
Программы примеров предназначены только для демонстрационных целей, чтобы помочь вам понять определенные концепции алгоритмов, и в них не почти не реализована обработка ошибок или проверка данных. Если вы введете неправильное решение, программа может аварийно завершить работу. Если вы не знаете, какие данные допустимы, воспользуйтесь для получения инструкций меню Help (Помощь) программы.
========374
A
addressing
indirect, 49
open, 314
adjacency matrix, 86
aggregate object, 382
ancestor, 139
array
irregular, 89
sparse, 92
triangular, 86
augmenting path, 363
B
B+Tree, 12
balanced profit, 222
base case, 101
best case, 27
binary hunt and search, 294
binary search, 286
branch, 139
branch‑and‑bound technique, 204
bubblesort, 254
bucketsort, 275
C
cells, 47
child, 139
circular referencing problem, 58
collision resolution policy, 299
command, 380
complexity theory, 17
controller, 391
countingsort, 273
critical path, 359
cycle, 331
D
data abstraction, 372
decision tree, 203
delegation, 378
descendant, 139
E
edge, 331
encapsulation, 371
exhaustive search, 204, 282
expected case, 27
F
facade, 386
factorial, 100
factory, 386
fake pointer, 32, 65
fat node, 12, 140
Fibonacci numbers, 105
firehouse problem, 239
First‑In‑First‑Out, 72
forward star, 12, 90, 143
friend class, 384
functors, 380
G
game tree, 204
garbage collection, 43
garbage value, 43
generic, 374
graph, 138, 331
greatest common divisor, 103
greedy algorithms, 339
H
Hamiltonian path, 237
hashing, 298
heap, 266
heapsort, 265
heuristic, 204
Hilbert curves, 108
hill‑climbing, 219
I
implements, 375
incremental improvements, 225
inheritance, 378
insertionsort, 251
interface, 385
interpolation search, 288
interpolative hunt and search, 295
K
knapsack problem, 212
L
label correcting, 342
label setting, 342
Last‑In‑First‑Out list, 69
least‑cost, 220
linear probing, 314
link, 331
list
circular, 56
doubly linked, 58
linked, 36
threaded, 61
unordered, 36, 43
M
mergesort, 263
minimal spanning tree, 338
minimax, 206
model, 391
Model/View/Controller, 390
Monte Carlo search, 223
N
network, 331
capacitated, 361
capacity, 361
connected, 332
directed, 331
flow, 361
residual, 362
node, 139, 331
degree, 139
internal, 139
sibling, 139
O
octtree, 172
optimum
global, 230
local, 230
P
page file, 30
parent, 139
partition problem, 236
path, 331
pointers, 32
point‑to‑point shortest path, 352
polymorphism, 371, 374
primary clustering, 317
priority queue, 268
probe sequence, 300
pruning, 212
pseudo‑random probing)., 324
Q
quadratic probing, 322
quadtree, 138, 165
queue, 72
circular, 75
multi-headed, 83
priority, 80
quicksort, 258
R
random search, 223
recursion
direct, 99
indirect, 25, 99
multiple, 24
tail recursion, 121
recursive procedure, 23
redundancy, 368
reference counter, 33
rehashing, 327
relatively prime, 103
residual capacity, 362
reuse, 371, 378
S
satisfiability problem, 235
secondary clustering, 324
selectionsort, 248
sentinel, 52
serialization, 388
shortest path, 342
Sierpinski curves, 112
simulated annealing, 231
singleton object, 387
sink, 361
source, 361
spanning tree, 336
stack, 69
subtree, 139
T
tail recursion removal, 121
thrashing, 31
thread, 61
traveling salesman problem, 238
traversal
breadth-first, 149
depth-first, 149
inorder, 148
postorder, 148
preorder, 148
tree, 138
AVL tree, 174
B+tree, 192
binary, 140
bottom-up B-trees, 192
B-tree, 187
complete, 147
depth, 140
left rotation, 177
left-right rotation, 178
right rotation, 176
right-left rotation, 178
symmetrically threaded, 160
ternary, 140
threaded, 138
top-down B-tree, 192
traversing, 148
tries, 138
turn penalties, 354
U
unsorting, 250
V
view, 391
virtual memory, 30
visitor object, 382
W
work assignment, 369
worst case, 27
А
Абстракция данных, 372
Адресация
косвенная, 49
открытая, 314
Алгоритм
поглощающий, 339
Г
Гамильтонов путь, 237
Граф, 138, 331
Д
Делегирование, 378
Деревья, 138
АВЛ-деревья, 174
Б+деревья, 12, 192, 193
Б-деревья, 187
ветвь, 139
внутренний узел, 139
восьмеричные, 172
вращения, 176
двоичные, 140
дочерний узел, 139
игры, 204
квадродеревья, 165
корень, 139
лист, 139
нисходящие Б-деревья, 192
обратный обход, 148
обход, 148
обход в глубину, 149
обход в ширину, 149
поддерево, 139
полные, 147
порядок, 139
потомок, 139
предок, 139
представление нумерацией связей, 12, 143
прямой обход, 148
решений, 203
родитель, 139
с полными узлами, 12
с симметричными ссылками, 160
симметричный обход, 148
троичные, 140
узел, 139
упорядоченные, 153
Дружественный класс, 384
З
Задача
коммивояжера, 238
о выполнимости, 235
о пожарных депо, 239
о разбиении, 236
поиска Гамильтонова пути, 237
распределения работы, 369
формирования портфеля, 212
Значение
"мусорное", 43
И
Инкапсуляция, 372
К
Ключи
объединение, 244
сжатие, 244
Коллекция, 37
Кратчайший маршрут
двухточечный, 352
дерево кратчайшего маршрута, 341
для всех пар, 352, 353
коррекция меток, 342, 348
со штрафами за повороты, 352, 354
установка меток, 342, 344
Кривые
Гильберта, 108
Серпинского, 112
М
Массив
нерегулярный, 89
представление в виде прямой звезды, 90
разреженный, 92
треугольный, 86
Матрица смежности, 86
Метод
ветвей и границ, 204, 212
восхождения на холм, 219
минимаксный, 206
Монте-Карло, 223
наименьшей стоимости, 220
отжига, 231
полного перебора, 204
последовательных приближений, 225
сбалансированной прибыли, 222
случайного поиска, 223
эвристический, 204
Модель/Вид/Контроллер, 390
Н
Наибольший общий делитель, 103
Наследование, 378
О
Объект
вид, 391
единственный, 387
интерфейс, 385
итератор, 383
контролирующий, 382
контроллер, 391
модель, 391
порождающий, 386
преобразование в последовательную форму, 388
составной, 382
управляющий, 380
фасад, 386
Ограничение, 378
Оптимум
глобальный, 230
локальный, 230
Очередь, 72
многопоточная, 83
приоритетная, 80, 268
циклическая, 75
П
Память
виртуальная, 30
пробуксовка, 31
чистка, 43
Пирамида, 265
Повторное использование, 378
Поиск
двоичный, 286
интерполяционный, 288
методом полного перебора, 282
следящий, 294
Полиморфизм, 374
Потоки, 61
Проблема циклических ссылок, 58
Процедура
очистки памяти, 45
рекурсивная, 23
Псевдоуказатели, 32, 65
Р
Разрешение конфликтов, 299
Рекурсия
восходящая, 175
косвенная, 25, 99
многократная, 24
прямая, 99
условие остановки, 101
хвостовая, 121
С
Сеть, 331
избыточность, 368
источник, 361
кратчайший маршрут, 341
критический путь, 359
нагруженная, 361
наименьшее остовное дерево, 338
ориентированная, 331
остаточная, 362
остаточная пропускная способность, 362
остовное дерево, 336
поток, 361
пропускная способность, 361
простой путь, 332
путь, 331
расширяющий путь, 363
ребро, 331
связная, 332
связь, 331
сток, 361
узел, 331
цена связи, 331
цикл, 331
Сигнальная метка, 52
Системный стек, 26
Случай
наилучший, 27
наихудший, 27
ожидаемый, 27
Сортировка
блочная, 275
быстрая, 258
вставкой, 251
выбором, 248
пирамидальная, 265
подсчетом, 273
пузырьковая, 254
рандомизация, 250
слиянием, 263
Список
двусвязный, 58
многопоточный, 61
неупорядоченный, 36, 43
первый вошел-первый вышел, 72
первый вошел-последний вышел, 69
связный, 36
циклический, 56
Стек, 69
Странный аттрактор, 170
Счетчик ссылок, 33
Т
Теория
сложности алгоритмов, 17
хаоса, 170
Тестовая последовательность
вторичная кластеризация, 324
квадратичная проверка, 321
линейная проверка, 314
первичная кластеризация, 317
псевдослучайная проверка, 324
У
Указатели, 32, 36
Ф
Файл подкачки, 30
Факториал, 100
Х
Хеширование, 298
блоки, 303
открытая адресация, 314
разрешение конфликтов, 299
рехеширование, 327
связывание, 300
тестовая последовательность, 300
хеш-таблица, 298
Ч
Числа
взаимно простые, 103
Фибоначчи, 105
Я
Ячейка, 47
Стр: 19
[RP1]Вариант – временная
и ёмкостная сложность
Page: 31
[RP2]Вариант –
перегрузкой памяти.
Стр: 43
[RP3]Вероятно,
жаргонизм, может выбросить вообще?
Стр: 43
[RP4]Вариант: «сборка
мусора»
Стр: 44
[RP5]Исправлена опечатка
в книге – см. http://www.vb-helper.com/vbaupd.htm
Ñòð: 83
[RV6]Может есть более
удачный вариант термина?
Ñòð: 138
[RV7]Вариант:
многопоточные деревья.
Ñòð: 138
[RV8]Варианты:
TRIE-структуры, ТРАЙ-структуры
Ñòð: 138
[RV9]Варианты: деревья
квадрантов, Q-деревья.
Ñòð: 140
[RV10]Вариант:
тернарными
Ñòð: 141
[RV11]Исправлена ошибка
в исходном листинге - Left заменено на Right
Стр: 165
[RP12]Варианты: деревья
квадратов, Q-деревья
Стр: 190
[RV13]Исправлена ошибка
– в оригинале буквы элементов не соответствуют рисунку.
Стр: 212
[RV14]Вариант: задача о
ранце
Стр: 214
[RV15]Исправлена
смысловая ошибка в оригинале — вместо узла B в нем
написано узел C.
Стр: 300
[RV16]Варианты ‑
последовательностью проверок, последовательностью проб
Стр: 303
[RV17]Ошибка в
оригинале - на рисунке приведен скриншот другой программы, искомое значение не
соответствует тексту.
Стр: 304
[RV18]Ошибка в
оригинале - на рисунке приведен скриншот другой программы.
Стр: 314
[RV19]Возможно, имеется
в виду хеш‑адресация.
Стр: 339
[RV20]Вариант -
«жадными» алгоритмами.
Стр: 352
[RV21]Вариант:
кратчайший маршрут между двумя точками
Стр: 361
[RV22]Вариант:
потоковой сетью (flow network)
Стр: 378
[RP23]Не уверен в
точности терминов.
Web-серверы, базы данных в Интернет, Поиск информации в Интернет, Основные системы и средства
Win'95 и способы его настройки
Windows 2000
Windows 95
Windows NT - ОС нового поколения
Windows NT 4.0 Server
Windows XP
Текстовый редактор Word для Windows
Word 7.0 Редактирование текста
Word 97
Copyright (c) 2024 Stud-Baza.ru Рефераты, контрольные, курсовые, дипломные работы.