Text
                    Содержание
Предисловие...................................................... 11
ЧАСТЬ I. Основы...................................................15
Глава 1. Введение................................................ 17
1.1.	Обзор задач..............................................17
1.2.	Применение языка программирования C++.....................19
1.3.	Надежность................................................20
Глава 2. Анализ алгоритмов....................................... 22
2.1.	Модель вычислений.........................................23
2.2.	Мера сложности............................................24
2.2.1.	Время работы в наихудшем случае.................... 25
2.2.2.	Время работы в среднем............................. 25
2.2.3.	Средневзвешенное время работы...................... 26
2.3.	Асимптотический анализ....................................27
2.3.1.	Функции скорости роста............................. 27
2.3.2.	Асимптотическая нотация............................ 29
2.4.	Анализ рекурсивных алгоритмов.............................31
2.4.1.	Поэтапное суммирование............................. 32
2.4.2.	Способ подстановки................................. 32
2.5.	Сложность задачи..........................................34
2.5.1.	Верхние границы.................................... 34
2.5.2.	Нижние границы..................................... 35
2.6.	Замечания по главе........................................39
2.7.	Упражнения................................................40
Глава 3. Структуры данных........................................ 42
3.1.	Что такое структуры данных?...............................42
3.2.	Связанные списки..........................................44
3.3.	Списки....................................................47

6 Вычислительная геометрия и компьютерная графика на C++ 3.3.1. Конструкторы и деструкторы............................. 50 3.3.2. Изменение списков...................................... 50 3.3.3. Доступ к элементам списка.............................. 52 3.3.4. Примеры списков........................................ 53 3.4. Стеки.........................................................54 3.5. Двоичные деревья поиска.......................................57 3.5.1. Двоичные деревья....................................... 57 3.5.2. Двоичные деревья поиска................................ 59 3.5.3. Класс SearchTree (дерево поиска)....................... 60 3.5.4. Конструкторы и деструкторы............................. 61 3.5.5. Поиск.................................................. 61 3.5.6. Симметричный обход..................................... 63 3.5.7. Включение элементов.................................... 64 3.5.8. Удаление элементов..................................... 65 3.6. Связанные двоичные деревья поиска.............................68 3.6.1. Класс BraidedNode...................................... 69 3.6.2. Класс BraidedSearchTree................................ 70 3.6.3. Конструкторы и деструкторы............................. 70 3.6.4. Использование ленты.................................... 70 3.6.5. Поиск.................................................. 72 3.6.6. Включение элементов.................................... 72 3.6.7. Удаление элементов..................................... 73 3.7. Деревья со случайным поиском..................................75 3.7.1. Класс RandomizedNode................................... 77 3.7.2. Класс RandomizedSearchTree............................. 80 3.7.3. Конструкторы и деструкторы............................. 81 3.7.4. Использование ленты.................................... 82 3.7.5. Поиск.................................................. 83 3.7.6. Включение элементов.................................... 84 3.7.7. Удаление элементов..................................... 85 3.7.8. Ожидаемые характеристики............................... 86 3.7.9. Словарь АТД............................................ 87 3.8. Замечания.....................................................87 3.9. Упражнения....................................................88 Глава 4. Структуры геометрических данных............................. 89 4.1. Векторы.......................................................89 4.2. Точки.........................................................93 4.2.1. Класс Point (Точка).................................... 93 4.2.2. Конструкторы........................................... 94 4.2.3. Векторная арифметика................................... 94 4.2.4. Операции отношения..................................... 95 4.2.5. Относительное положение точки и прямой линии........... 96 4.2.6. Полярные координаты.................................... 97
Содержание 7 4.3. Полигоны (многоугольники).....................................99 4.3.1. Что такое полигон?..................................... 99 4.3.2. Выпуклые полигоны...................................... 100 4.3.3. Класс Vertex (Вершина)................................. 101 4.3.4. Класс Polygon (Полигон)................................ 103 4.3.5. Точки, входящие в состав выпуклого полигона.......... 108 4.3.6. Определение наименьшей вершины в полигоне............ 108 4.4. Ребра........................................................109 4.4.1. Класс Edge (Ребро)................................... 110 4.4.2. Поворот ребер........................................ 110 4.4.3. Определение точки пересечения двух прямых линий...... 112 4.4.4. Расстояние от точки до прямой линии.................. 116 4.4.5. Дополнительные утилиты............................... 116 4.5. Геометрические объекты в пространстве........................117 4.5.1. Точки................................................ 117 4.5.2. Треугольники......................................... 118 4.5.3. Ребра................................................ 122 4.6. Определение пересечения прямой линии и треугольника...........123 4.7. Замечания.....................................................125 4.8. Упражнения....................................................126 ЧАСТЬ II. Применение............................................... 129 Глава 5. Пошаговый ввод.............................................. 131 5.1. Сортировка при вводе..........................................132 5.1.1. Анализ................................................. 133 5.2. Образование звездчатого полигона..............................134 5.2.1. Что такое звездчатый полигон?.......................... 134 5.2.2. Построение звездчатого полигона........................ 135 5.3. Поиск выпуклой оболочки.......................................137 5.3.1. Что такое выпуклая оболочка?........................... 137 5.3.2. Ввод оболочки.......................................... 138 5.3.3. Анализ................................................. 140 5.4. Принадлежность точки: метод луча..............................142 5.5. Принадлежность точки: метод углов.............................146 5.6. Отсечение линий: алгоритм Цируса-Бека.........................148 5.7. Отсечение полигона: алгоритм Сазерленда-Ходжмана..............151 5.8. Триангуляция монотонных полигонов.............................155 5.8.1. Что такое монотонный полигон?.......................... 155 5.8.2. Алгоритм триангуляции.................................. 156 5.8.3. Корректность........................................... 160 5.8.4. Анализ................................................. 161
8 Вычислительная геометрия и компьютерная графика на C++ 5.9. Замечания по главе...........................................162 5.10. Упражнения.................................................163 Глава 6. Пошаговая выборка......................................... 165 6.1. Сортировка выборкой.........................................166 6.1.1. Автономные и оперативные программы................... 167 6.2. Построение выпуклой оболочки способом «заворачивание подарка»..........................................................169 6.2.1. Анализ............................................... 169 6.3. Построение выпуклой оболочки: метод обхода Грэхема..........156 6.4. Удаление невидимых поверхностей: алгоритм сортировки по глубине........................................................174 6.4.1. Предварительные замечания............................ 174 6.4.2. Алгоритм сортировки по глубине....................... 175 6.4.3. Сравнение двух треугольников......................... 180 6.4.4. Уточнение списка треугольников....................... 182 6.5. Пересечение выпуклых полигонов..............................184 6.5.1. Анализ и корректность................................ 190 6.5.2. Затруднения.......................................... 192 6.6. Определение триангуляции Делоне.............................193 6.6.1. Поиск сопряженной точки для ребра.................... 199 6.7. Замечания по главе..........................................202 6.8. Упражнения..................................................203 Глава 7. Алгоритмы сканирования на плоскости....................... 205 7.1. Определение точек пересечения отрезков прямых линий..........206 7.1.1. Представление точек-событий.......................... 206 7.1.2. Программа верхнего уровня............................ 208 7.1.3. Структура сканирующей линии.......................... 209 7.1.4. Переходы............................................. 210 7.1.5. Анализ............................................... 213 7.2. Поиск выпуклой оболочки (построение оболочки при вводе) .... 214 7.2.1. Анализ............................................... 215 7.3. Контур объединения прямоугольников...........................216 7.3.1. Представление прямоугольников........................ 217 7.3.2. Программа верхнего уровня............................ 219 7.3.3. Переходы............................................. 221 7.3.4. Анализ............................................... 224 7.4. Декомпозиция полигонов на монотонные части................225 7.4.1. Программа верхнего уровня............................ 226 7.4.2. Структура сканирующей линии.......................... 229 7.4.3. Переходы............................................. 232 7.4.4. Анализ............................................... 235
Содержание 9 7.5. Замечания по главе...........................................235 7.6. Упражнения...................................................236 Глава 8. Алгоритмы типа «разделяй и властвуй»...................... 238 8.1. Сортировка путем слияния....................................239 8.2. Вычисление области пересечения полуплоскостей...............241 8.2.1. Анализ................................................ 243 8.3. Определение ядра полигона...................................243 8.3.1. Анализ................................................ 245 8.4. Определение многоугольника Вороного.........................245 8.4.1. Диаграммы Вороного.................................... 246 8.4.2. Анализ................................................ 248 8.5. Слияние оболочек............................................248 8.5.1. Программа верхнего уровня............................. 249 8.5.2. Слияние двух выпуклых оболочек........................ 250 8.5.3. Анализ................................................ 252 8.6. Ближайшие точки.............................................252 8.6.1. Программа верхнего уровня............................. 253 8.6.2. Обработка точек в пределах вертикальной полосы........ 255 8.6.3. Анализ................................................ 256 8.7. Триангуляция полигона.......................................257 8.7.1. Программа верхнего уровня............................. 258 8.7.2. Поиск вторгающейся вершины............................ 259 8.7.3. Анализ................................................ 260 8.8. Замечания по главе..........................................261 8.9. Упражнения..................................................262 Глава 9. Способы пространственного разделения....................... 264 9.1. Задача поиска по области.....................................265 9.2. Метод сетки..................................................267 9.2.1. Представление......................................... 267 9.2.2. Конструкторы и деструкторы............................ 267 9.2.3. Выполнение запросов по области........................ 269 9.2.4. Анализ................................................ 269 9.3. Квадрантное дерево..........................................270 9.3.1. Представление......................................... 272 9.3.2. Конструкторы и деструкторы............................ 273 9.3.3. Выполнение запросов по области........................ 276 9.3.4. Обеспечивающие функции................................ 277 9.3.5. Анализ................................................ 278 9.4. Двухмерные деревья поиска....................................282 9.4.1. Представление......................................... 283 9.4.2. Конструкторы и деструкторы............................ 284
10 Вычислительная геометрия и компьютерная графика на C++ 9.4.3. Выполнение запроса по области.......................... 285 9.4.4. Вспомогательные функции................................ 286 9.4.5. Анализ................................................. 287 9.5. Удаление невидимых поверхностей: деление пространства........288 9.5.1. Представление.......................................... 290 9.5.2. Конструкторы и деструкторы............................. 291 9.5.3. Выполнение удаления невидимой поверхности.............. 293 9.5.4. Анализ................................................. 293 9.6. Замечания по главе............................................294 9.7. Упражнения....................................................294 Литература........................................................... 296
Предисловие ^^сновная цель книги заключается в описании некоторых основных про- блем, возникающих в задачах компьютерной графики и вычислительной гео- метрии с представлением ряда практических и сравнительно простых способов их решения. Мы не будем анализировать сложные ситуации в этих областях, ограничившись рассмотрением только узловых проблем и приме- ров их решения, что может оказаться более интересным и приемлемым для читателя. Другое назначение этой книги — подвести читателя к проектированию и анализу алгоритмов (алгоритм — это средство или способ решения вы- числительной задачи). Тем самым определяются границы рассматриваемых вопросов при изучении алгоритмов. В число обсуждаемых тем включены во- просы описания элементарных структур данных (списки и деревья поиска), алгоритмических парадигм (деление и захват) и методов анализа характе- ристик алгоритмов и структур данных. Рассматриваемые задачи вытекают из областей применения компьютер- ной графики и вычислительной геометрии. Возникшая из потребностей рынка и развития компьютерной техники (и не только персональных ком- пьютеров) компьютерная графика стала очень быстро развиваться после ее появления в 1960-х годах. Напротив, вычислительная геометрия имеет очень древнее происхождение, уходя корнями к построениям Эвклида. Но и она подверглась стремительному развитию в течение последних десятков лет под воздействием науки об алгоритмах и растущим пониманием ее ши- рокой применимости. Компьютерная графика исследует методы для моделирования и постро- ения сцен. Моделирование используется для построения описания сцен, при- рода которых может быть самой различной: обычные геометрические объ- екты в двух- и трехмерном пространстве, естественные явления (например, облака или деревья), страницы текста, огромные массивы чисел, получае- мых от устройств чтения изображений или в процессе исследований, и мно- гие другие. Процесс отображения используется для преобразования описа- ния сцен в картинку или в кинофильм. В этой книге мы рассмотрим некоторые задачи компьютерной графики, относящиеся к отображению. Для определения частей геометрического объ- екта, выходящих за пределы заданной области (или окна) используется опе- рация отсечения, которая должна выполняться перед формированием изо- бражения. Операция удаления невидимых поверхностей применяется для определения таких объектов (или их частей) в пространстве, которые закры-
12 Вычислительная геометрия и компьютерная графика на C++ ты для наблюдения другими объектами, расположенными ближе к наблю- дателю, и поэтому подлежащими исключению перед отображением объекта. Вычислительная геометрия рассматривает алгоритмы для решения геомет- рических задач. Здесь будут проанализированы такие задачи, которые просто сформулировать и которые относятся к простым геометрическим объектам: точкам, линиям, многоугольникам и окружностям на плоскости, точкам, линиям и треугольникам в пространстве. Будут также исследованы задачи декомпозиции многоугольников на треугольники, определения поверхностей (многоугольников, их декомпозиций и выпуклых оболочек), «скрытых» среди конечного набора точек и образующих линии пересечения различных геометрических объектов, поиска проекций на плоскость для геометричес- ких объектов, удовлетворяющих определенным условиям. Связь между компьютерной графикой и вычислительной геометрией не ограничивается только тем фактом, что они рассматривают геометрические объекты. Хотя вполне очевидно, что некоторые методы явно относятся к той или иной области, но существуют и общие методы. Более того, неко- торые методы вычислительной геометрии были мотивированы и плодотвор- но использованы в компьютерной графике. Ярким примером этого является задача удаления невидимых поверхностей, которая еще совсем недавно представляла очень сложную проблему, в последнее время успешно реша- ется точными методами вычислительной геометрии. Предпосылки Излагаемый материал основан на самых последних достижениях вычис- лительной математики. Некоторые понятия тригонометрии и линейной алгеб- ры были использованы при определении классов для геометрических объектов в главе 4. При этом изложены лишь самые необходимые математические понятия, достаточные для восприятия материала читателями со слабыми математическими знаниями. Обладание опытом работы с основными струк- турами данных, такими как связанные списки и двоичные деревья, полезно, но не необходимо. В главе 3 мы описываем роль структур данных и раз- виваем в самых общих чертах те сведения, которые потребуются при чтении остальных глав книги: списки, стеки и двоичные деревья. В этой книге приводятся работающие программы на языке C++ для каж- дого рассматриваемого алгоритма и каждой используемой структуры дан- ных. Существуют две основные причины для включения текстов работаю- щих программ в книгу об алгоритмах. Во-первых, каждая реализация дополняет сухое изложение работы алгоритма, высвечивая ключевые идеи и формальные основы алгоритма, предоставляя детали, которые отсутствуют в сухом изложении. Во-вторых, читатель может исполнить программу, мо- дифицировать ее и экспериментировать с нею, чтобы лучше понять, как описываемый алгоритм ведет себя на практике. Программы обеспечивают стартовую площадку для развития творчества и изобретательности. Читатели, уже знакомые с языком C++, получат преимущества в изучении программ, приведенных в тексте. Однако и те читатели, которые имеют опыт работы с языком С, при изучении этих программ также могут почерпнуть очень много полезных сведений, если они хотят расширить свои знания. Читатели, совсем не имеющие опыта в программировании, могут пропустить конкретные реализации и все-таки получить удовольствие от чтения книги.
Предисловие 13 Обзор рассматриваемых задач Книга состоит из двух частей. Первая часть «Основы» (главы с 1 по 4) дает общее представление о структурах данных и алгоритмах, в ней при- водятся необходимые определения геометрических концепций и средств. Во второй части «Применение» ставятся задачи и описываются возможные пути их решения. В главе 1 формулируются общие контуры рассматриваемых вопросов, да- ется определение таких терминов, как алгоритм, структура данных, ана- лиз. Здесь же обосновывается применение языка C++ и показывается на- дежность нашей реализации. В главе 2, которая касается анализа алгоритмов, приводятся концепции и методы анализа характеристик алго- ритмов и структур данных. В главе 3 представлены классы языка C++, ко- торые охватывают абстрактные типы данных, применяемые нами в после- дующем изложении, и структуры данных для реализации: связанные списки, стеки, несколько вариантов двоичных деревьев поиска. В главе 4 описаны классы для представления и обработки базовых геометрических объектов на плоскости и в пространстве. Кроме всего прочего, эти классы обеспечивают средства для вычисления точек пересечения двух прямых линий на плоскости и для классификации точек относительно прямой линии на плоскости или треугольника в пространстве. Вторая часть книги организована по принципу алгоритмической парадиг- мы — в каждой главе рассматриваются алгоритмы, относящиеся к данной парадигме. Так в главе 5 рассматриваются пошаговые методы ввода, в ко- торых при вводе обработка каждого вводимого элемента производится отдель- но, без анализа всего массива ввода целиком. В число рассматриваемых ал- горитмов входят определения выпуклой оболочки для конечного набора точек, алгоритмы отсечения прямой линии и произвольного полигона по гра- нице выпуклого полигона, алгоритм декомпозиции на треугольники специ- ального класса полигонов, известных под названием монотонных полигонов. Глава 6 содержит описания способов пошаговой выборки, в которых весь вводимый массив данных сканируется перед обработкой. Здесь излагаются еще два способа для поиска выпуклой оболочки конечного набора точек (способ «заворачивание подарка» и сканирование по методу Грэхема), ал- горитм вычисления пересечения двух выпуклых полигонов за линейное время и пошаговый способ триангуляции набора точек на плоскости. В главе 7 раскрываются алгоритмы сканирования на плоскости, в про- цессе работы которых сканирующая прямая линия перемещается по плос- кости слева направо и решается подзадача для области, лежащей слева от сканирующей прямой линии. В одном из алгоритмов сканирования плоскости находится объединение коллекции прямоугольников на плоскости, в другом выполняется декомпозиция произвольного полигона на монотонные части. В главе 8 описываются алгоритмы «разделяй и властвуй», когда поиск решения задачи осуществляется путем деления ее на две подзадачи, размер каждой из которых примерно вдвое меньше, решения их по отдельности и затем объединения полученных результатов в одно общее решение исходной задачи. Алгоритмы такого типа применяются в способе поиска выпуклой обо- лочки для конечного набора точек (способ слияния оболочек), для декомпо- зиции произвольного полигона на треугольники, для разбиения плоскости на полигональные участки, известные под названием областей Вороного.
14 Вычислительная геометрия и компьютерная графика на C++ В главе 9 представлены способы, основанные на пространственном под- разделении. Мы опишем три способа подразделения — сетки, квадрантные деревья и 2-d деревья — для решения задач поиска по области на плоскости: для заданных конечного набора точек на плоскости и прямоугольника со сто- ронами, параллельными координатным осям, найти те точки, которые лежат внутри прямоугольника. Также будут рассмотрены пространственные двоич- ные деревья для решения задачи удаления невидимых поверхностей в про- странстве. Благодарности Автор благодарен целому ряду лиц, которые прочитали отдельные части этой книги на разных этапах ее написания и сделали полезные замечания: Давиду Допкину, Эрику Бриссону, Барту Розенбургу, Деборе Силвер и Шай Симонсон и другим рецензентам. Автор также благодарит факультет мате- матики и вычислительной техники Университета Майами (Корал Габлс, Флорида) за предоставленное время и ресурсы для написания этой книги, а также Школу вычислительной техники и информатики Нова Саузистерн Университета (Фт. Лодердейл) за поддержку на стадии разработки этого проекта. Автор благодарит за энтузиазм и поддержку редакторов издатель- ства Прентис-Холл Билла Зобрис (на этапе написания книги), Розу Кернан и Алана Алта и их помощников Филлис Морган и Ширли МакГури. Автор чрезвычайно признателен за любовь и поддержку жены Элизы, без чьей помощи он даже не попытался бы написать эту книгу. Он также бла- годарен маленькой дочери Арианне и своим родителям Морису и Филлис.
Часть I Введение
Глава 1 Введение В этой главе мы представим основы для изучения алгоритмов, будь они геометрическими или какими-либо иными. Будут рассмотрены такие вопро- сы: что такое алгоритмы, что такое структуры данных, что подразумевается под анализом алгоритма? В нашу задачу входит правильно ориентировать читателя, показать основные концепции и сформулировать вопросы, относя- щиеся к задаче. Ответы и определения, представленные в данной главе, будут сжатыми и эскизными. Они будут подробно проанализированы в пос- ледующих главах, где будут даны более детальные объяснения со многими примерами. Будет обосновано применение языка C++ и показано, насколько мощным может быть его применение. 1.1. Обзор задач Рассмотрим вкратце область наших интересов. Вычислительная зада- ча — это задача, которая может быть решена путем выполнения согласо- ванного набора команд. Вычислительная задача формулируется в виде по- становки задачи с описанием всей необходимой входной информации и желаемого результата как функции этой входной информации. Например, рассмотрим задачу, которую назовем ПЕРЕСЕЧЕНИЕ ГРАНИЦ: для двух заданных на плоскости многоугольников определить, пересекаются ли их границы. Требуемая входная информация содержит описания двух много- угольников в некотором заданном формате. Для двух заданных многоуголь- ников результат будет «ДА», если их границы пересекаются, и «НЕТ» в противном случае. Алгоритмом называется способ решения вычислительной задачи. Алго- ритмы всегда абстрактны — данный алгоритм может быть реализован раз- личными путями, например, в виде компьютерной программы на различных языках программирования и выполнен на различных компьютерах. Каждая компьютерная программа основана на алгоритме и, если программа хорошо составлена, заложенный алгоритм может быть понятен из текста програм- мы. В качестве примера алгоритма можно привести алгоритм решения за- дачи ПЕРЕСЕЧЕНИЯ ГРАНИЦ: для заданных на входе двух многоуголь- ников каждая сторона первого многоугольника сравнивается с каждой стороной второго. Положительный результат получается в случае, когда какая-либо пара сторон имеет общую точку пересечения, и отрицательный
18 Глава 1 результат в противном случае. Заметим, что при этом потребуется еще дру- гой алгоритм для принятия решения о пересечении двух сторон многоуголь- ников. Алгоритмическая парадигма является некоторой конструкцией, на осно- ве которой реализуется алгоритм: как различные программы могут быть ос- нованы на одном и том же алгоритме, так и различные алгоритмы могут быть основаны на одной парадигме. Знакомство с явлением парадигмы по- могает нам понять и объяснить алгоритм, упомянутый ранее и помогает в разработке новых алгоритмов. Алгоритм, приведенный для решения задачи ПЕРЕСЕЧЕНИЯ ГРАНИЦ, интерактивно использует парадигму пошагового выбора: на каждом шаге выбирается сторона первого многоугольника, ко- торая затем последовательно сравнивается с каждой стороной второго многоугольника. В этой книге будут применяться и другие алгоритмические парадигмы. Алгоритмы используют объекты для упорядоченных данных, называемых структурами данных. Алгоритмы и структуры данных всегда используются совместно: алгоритмы мотивируют разработку и изучение структур данных, а структуры данных служат в качестве строительных блоков, из которых формируются алгоритмы. Наш алгоритм ПЕРЕСЕЧЕНИЯ ГРАНИЦ основан на структурах данных для представления многоугольников и представления его сторон. Абстрактные типы данных (АТД) дают общее представление структуры данных, независимо от ее применения. АТД охватывают набор операций, которые поддерживаются структурой данных и он служит в качестве ин- терфейса между алгоритмом и его структурами данных: алгоритм выпол- няет операции, поддерживаемые АТД, а структура данных реализует эти операции. Если эти действия рассматривать раздельно, то каждый АТД представляется в виде вычислительной задачи (набора операций), а каждая структура данных, которая обеспечивает эти операции, выдает решения для АТД. Наш алгоритм ПЕРЕСЕЧЕНИЯ ГРАНИЦ требует использования двух АТД: АТД многоугольника ( или полигона), который включает операции до- ступа к сторонам многоугольника и АТД для ребер многоугольника, который содержит операции по принятию решения о взаимном пересечении двух за- данных отрезков прямой линии. Для большинства задач существует несколько конкурирующих алгорит- мов, а для многих типов абстрактных данных есть несколько видов струк- тур данных. Как же выбрать наиболее эффективное решение? Анализ ал- горитмов позволяет выбрать способ описания и измерения свойств алгоритмов и структур данных. Анализ дает основу для выбора наилучшего решения заданной задачи и оценки возможного наилучшего решения. На- пример, анализ позволяет установить, что время выполнения нашего алго- ритма ПЕРЕСЕЧЕНИЯ ГРАНИЦ будет пропорционально произведению раз- мерности многоугольников (размерность многоугольника определяется числом образующих его сторон). Такой алгоритм не оптимален, в главе 6 будет представлено решение, которое выполняется за время, пропорциональ- ное сумме размерностей многоугольника.
Введение 19 1.2. Применение языка программирования C++ В этой книге для каждого описываемого алгоритма и структуры данных будут представлены работающие программы на языке C++. Это позволило значительно повысить качество изложения материала книги. Во-первых, каждая программа дополняет сухое описание работы заложенного в нее ал- горитма, что помогает раскрывать ключевые идеи формального описания алгоритма и представить детали, обычно опускаемые при сухом перечисле- нии свойств алгоритма. Во-вторых, читатель может исполнить программу, модифицировать ее и провести с ней некоторые эксперименты для лучшего понимания поведения заложенного в нее алгоритма на практике. В приводимых программах будет использован целый ряд специфических свойств языка C++, отсутствующих в более раннем языке С. Мы будем при- менять классы, компонентные функции, управление доступом, конструкто- ры и деструкторы, операции-функции, механизм перегрузки стандартных операций и ссылочные типы. Мы также будем создавать новые классы на основе других (базовых) классов, когда новые классы наследуют атрибуты и свойства своих базовых классов. Также будут использованы шаблоны классов и функций, определяющие правила построения семейства классов или функций, которые могут работать с различными типами данных, пере- даваемых им в момент обращения, но в остальном ведущих себя аналогич- но. Данная книга не имеет своей целью изучение языка C++. Объяснение некоторых языковых свойств дается лишь попутно для пояснения причин принятия определенных решений или в качестве напоминания читателю, но не для обучения языку. Хорошее введение в язык C++ изложено в книгах [23, 79], а также в аннотированном руководстве [26]. Также эта книга не может служить учебником по объектно-ориентиро- ванному программированию (ООП). Программы, представленные в книге, со- ставлены так, чтобы их можно было легко понять и проследить их работу. Хотя обычно на практике универсальность сопровождается строгим соблю- дением требований ООП, мы иногда будем отступать от этой практики ради простоты и краткости (без потери общности). При реализации ООП обычно тратится много времени и усилий на программирование при разработке классов с тем, чтобы впоследствии можно было сократить время на состав- ление прикладных программ, в которых эти классы применяются. В этом смысле некоторые из приведенных в книге классов могут оказаться «бро- совыми», предназначенными для одного-двух применений. В таких случаях мы далеки от намерения создавать класс для удовлетворения универсальных запросов. Объектно-ориентированное программирование освещается в учеб- никах [11, 13, 15, 32]. Теперь на очереди замечания относительно эффективности. Наши про- граммы в принципе эффективны. Однако мы намеренно пошли на некото- рый компромисс ради ясности, нашему основному условию. Это может при- вести, например, к добавлению совсем не необходимой локальной переменной для соответствия концепции, излагаемой в тексте, или к вклю- чению излишнего обращения к некоторой процедуре, вместо того, чтобы сохранить результат предыдущего обращения, или использование ранее оп- ределенной функции вместо более специализированной функции, которую
90 Глава 1 нам почему-то не хотелось разрабатывать. Конечно, приветствуются любые усилия читателя, направленные на уточнение таких программ. Все программы в этой книге были опробованы на компьютере Macintosh LCIII с использованием компилятора Symantec C++. При разработке про- грамм учитывалась реализация языка C++ , описанная в Аннотированном руководстве [26]. Все программы работают в том виде, как они приведены в книге, только с одним исключением: имена классов Point и Polygon, при- меняемые в книге, в реализации были заменены на Point2D и Polygon2D. Это было сделано исключительно во избежание конфликтов имен. 1.3. Надежность Алгоритм надежен, если он всегда работает правильно, даже если на входе встречаются вырожденные и специальные случаи. Конечно, одной из возможных причин сбоев может быть просто ошибка в алгоритме. Но если алгоритм верен, он может давать ошибку из-за округления при вычисле- ниях с вещественными числами. Ошибка округления возникает из-за пред- ставления чисел с использованием ограниченного размера памяти (напри- мер, 8 байт для типа double). Так что все вещественные числа, которых бесконечно много, выражаются конечным числом представлений. Ошибка округления равна разности между вещественным числом и его приближен- ным представлением в формате с плавающей точкой. Ошибки округления могут стать серьезной помехой для геометрических алгоритмов, подобных рассматриваемым в этой книге, которые работают в непрерывном пространстве, например, на плоскости или в трехмерном объе- ме. Идеальные точки могут быть описаны только приближенно, поскольку их компоненты являются вещественными числами. Идеальные отрезки пря- мых линий также могут быть лишь приближенными, так как они опреде- ляются своими концевыми точками и т. д. Ошибки округления сказываются наиболее сильно при осуществлении операций ввода, а в некоторых особых случаях они даже имеют тенденцию к относительному возрастанию, что вы- нуждает алгоритм выдавать возможное неправильное решение. Например, при заданных идеальной прямой линии и идеальной точке, через которую эта прямая линия проходит, компьютерная программа может определить, что точка расположена слева или справа от этой прямой линии, либо лежит на ней в зависимости от того, как в программе представлены сама прямая линия и точка, принадлежащая ей. Из-за этого в некоторых случаях вы- даваемое программой решение может быть неверным даже тогда, когда по- ложенный в основу программы алгоритм совершенно правильно обрабаты- вает идеальные геометрические объекты. В этой книге были введены некоторые ограничения (например, на ввод данных) для исключения возможных особых случаев. Хотя такие програм- мы становились менее универсальными, но это не наносило особого вреда, поскольку именно общие случаи и являются наиболее интересными. Более того, обработка специальных случаев могла бы потребовать включения це- лого ряда дополнительных операторов, что затуманивает общую идею. Исключаемые особые случаи на практике встречаются сравнительно редко. Тем не менее некоторые из рассматриваемых в книге программ спе-
Введение 91 циально были переработаны для возможности работы в особых ситуациях, в которых иначе пришлось бы применять другие программы. Другой подход к обеспечению надежности работы в особых ситуациях заключается в их исключении путем некоторого изменения условий задачи: небольшого из- менения длины отрезка прямой линии, смещения прямой линии влево на бесконечно малое расстояние, перенос точки, лежащей очень близко от пря- мой линии, до совпадения с ней и т. д. Цель заключается в таком изме- нении условий, чтобы исключить двусмысленность и сделать решение не- зависимым от представления геометрического объекта. Задаче обеспечения надежности вычислений в последние годы уделялось очень много внимания. Хотя принятый в этой книге подход и является об- щепринятым, но его нельзя считать окончательным. Существуют и другие приемы повышения надежности, но их описание выходит за рамки данной книги.
Глава 2 Анализ алгоритмов ализ алгоритмов означает измерение и описание их характеристик. По- нимание, насколько хорошо работает алгоритм, показывает, будет ли алго- ритм практичен, будут ли достаточны предоставляемые ресурсы. Более того, анализ алгоритмов предоставляет основу для сравнения различных алгорит- мических решений одной и той же задачи, ранжирования их по характерис- тикам и выявления, какие из них наиболее эффективны. При анализе алгоритмов обычно наибольшее внимание уделяется необ- ходимому объему памяти и затратам времени на выполнение алгоритма. В этой главе основное внимание мы уделим затратам времени на выполнение алгоритма. Очень часто время является самым критичным ресурсом. Сис- темы реального времени, подобные автоматам управления полетом или пере- мещения робота, должны срабатывать моментально, чтобы избежать, воз- можно катастрофической, критической ситуации. Даже в более спокойных системах, подобных текстовым редакторам, ожидается, что ответ на запрос оператора должен появиться практически немедленно, а другие программы, например, для анализа медицинских изображений, должны выполняться не более, чем за несколько часов. Иногда полагают, что время обработки ста- новится менее критичным по мере разработки более высокоскоростных ком- пьютеров. Но это не совсем так. Задачи, возлагаемые на компьютер сегодня, гораздо сложнее, чем раньше, и рост их сложности опережает возможности компьютерных технологий, от которых часто зависит получение быстрого решения. Объем требуемой памяти в общем случае менее критичен, чем время. Многие алгоритмы, используемые на практике, а также почти все, описан- ные в данной книге, используют объем памяти, пропорциональный размеру входных данных, так что если имеется достаточно памяти, чтобы загрузить задачу, то обычно ее хватает и на решение. В любом случае, большая часть последующего изложения может быть применима и для анализа требований к объему памяти. Память и время как правило являются независимыми ресурсами. Часто оказывается возможным ограничить затраты одного из видов ресурсов за счет другого, что иногда оценивается в виде отношения память/время. Ко- нечно, в этом есть некоторые преимущества, особенно если одного из ре- сурсов не хватает или он дорогой. В этой главе мы представим три существующих подхода, которые упро- щают задачу анализа без снижения полезности его результата. Первый за- ключается в признании абстрактной модели вычислений, второй — в опреде-
Анализ алгоритмов 93 лении зависимости времени работы от объема входных данных, третий — в выражении времени работы в виде простой функции отношений. В пос- леднем разделе обсудим проблему сложности, которая определяет, сколько времени необходимо и достаточно для решения заданной задачи. 2.1. Модель вычислений Для анализа алгоритма нам сначала нужно идентифицировать операции, которые в нем используются, и стоимость каждой из них. Это определяется моделью вычислений. В идеале нам нужно выбрать такую модель вычисле- ний, которая абстрагирует наиболее существенные операции алгоритма. На- пример, чтобы проанализировать алгоритм, работающий с полигонами, мы могли бы подсчитать, сколько раз происходит обращение к вершинам поли- гона. Числовые алгоритмы обычно анализируются путем подсчета арифме- тических операций. Алгоритмы сортировки и поиска анализируются путем подсчета количества сравнений пар элементов. Стоимость каждой операции, учитываемой моделью вычислений, устанав- ливается в абстрактных единицах времени, называемых шагами. В некото- рых моделях вычислений каждая операция стоит определенное постоянное число шагов, стоимость некоторых операций может зависеть от значений аргументов, с которыми выполняется данная операция. Время работы ал- горитма для указанных входных данных равно общему числу выполняемых шагов. Хотя принятие абстрактной модели вычислений упрощает анализ, модель не должна быть настолько абстрактной, чтобы она игнорировала существен- ную часть работы, выполняемой алгоритмом. В идеале время между пос- ледовательными операциями, учитываемыми моделью вычислений, постоян- но, так что его можно игнорировать. Тогда время работы — общая стоимость операций, учитываемых моделью вычислений — пропорциональ- но фактическому времени выполнения алгоритма. Поскольку время работы зависит от модели вычислений, то было бы ес- тественным принять одинаковую модель вычислений для различных реше- ний одной и той же задачи. Тогда путем сравнения их времени работы мы могли бы выявить наиболее эффективное решение (если бы модель вычис- лений менялась, то нам бы пришлось сравнивать яблоки с апельсинами). По этой причине в основном мы будем применять модель вычислений — хотя довольно часто только неявно — как часть формулировки постановки задачи. Чтобы показать эту идею более конкретно, рассмотрим задачу по- иска заданного целого числа х в отсортированном массиве целых чисел. Присвоим этой задаче имя ПОИСК: Для заданного целого числа х и отсортированного массива а различных целых чисел сообщить индекс числа х внутри массива а, а если такого числа в массиве а нет, то возвращаемое значение должно быть равно -1. Целое число х здесь служит в качестве ключа поиска. Модель вычислений, которую мы примем для задачи ПОИСК, содержит только операцию типа проба-, сравнение ключа поиска х с некоторым целым числом в массиве. В предположении, что целые числа ограничены по ве-
94 Глава 2 личине, одиночная проба может быть выполнена за постоянное время. Так что мы можем сказать, что стоимость пробы составляет один шаг. Простейшее решение ПОИСКА может быть реализовано путем последо- вательного поиска', пошаговый просмотр массива а с пробой каждого целого числа. Если число х найдено (поиск завершен), то возвращается его индекс в массиве а, в противном случае возвращается число -1, показывающее, что искомое число не обнаружено. Возможная реализация может быть следую- щей: int seguentialSearch(int х, int а[], int n) { for (int i = 0; i < n; i++) if (a[i] == x) return i; return -1 ; } Если время работы программы seguentialSearch обозначить в функции от введенных данных через / (х, а, п) , то имеем , , ' ( k + 1 если число х обнаружено в позиции k , f (х, а, п) = < . . (2.1) In в противном случае (поиск неудачен) Заметим, что выражение (2.1) определяет время работы — общее число проб, или шагов — как функцию всех допустимых входных данных. За- метим также, что поскольку программа seguentialSearch затрачивает только постоянное время на операции между последовательными пробами, то это выражение может служить хорошей мерой для фактической харак- теристики программы. 2.2. Мера сложности Выбор достаточно абстрактной модели вычислений упрощает задачу ана- лиза. Для дальнейшего упрощения анализа мы будем выражать время ра- боты как функцию только размера входного массива, а не всех допустимых вводимых значений. Этому есть две основные причины. Во-первых, чем проще выражение для определения времени работы, тем легче будет срав- нивать времена работы различных алгоритмов. Описание времени работы в функции всех допустимых вводимых значений может быть громоздким и сложным. Даже выражение (2.1) несколько неудобно, а очень мало алго- ритмов, таких же простых, как последовательный поиск. Во-вторых, может оказаться довольно сложным анализировать время работы в терминах всех допустимых вводимых значений. Поведение многих алгоритмов очень чув- ствительно к вводимым значениям и было бы практически невозможным проследить мириады путей, по которым выполняются вычисления при раз- личных входных значениях. Размер входного массива характеризует объем памяти, используемый для хранения вводимых чисел. Способ оценки размера входного массива самым натуральным образом зависит от задачи и его можно рассматривать как не- явную часть формулировки задачи. Если вводится только массив чисел, то
Анализ алгоритмов 95 его размер определяется длиной массива, если вводится полигон, то в ка- честве размера ввода может быть взято число вершин, входящих в состав полигона. Здесь подойдет любая разумная схема, если она удовлетворитель- но применима для всех решений данной задачи. Выражение времени работы в зависимости от размера ввода уменьшает количество учитываемых параметров до одного. Поскольку это может быть реализовано самыми различными способами, то определение конкретного значения времени работы становится несущественным. Мы будем применять его оценку лишь в качестве определенной меры сложности, описывающей только тот или иной аспект оцениваемой характеристики. Мера сложности обычно определяется для наихудшего случая, в среднем или при среднев- звешенном анализе. 2.2.1. Время работы в наихудшем случае Время работы в наихудшем случае определяется для самого длительного выполнения алгоритма для любых входных данных при каждом размере ввода. Это очень полезная мера, поскольку производится оценка работы про- граммы в самой плохой ситуации. Например, поскольку программа seguentialSearch осуществляет п проб в наихудшем случае (когда работа завершается безуспешно), то время ее работы Т(п) в наихудшем случае равно Т(п) = п. Вычислить время работы в наихудшем случае иногда сравнительно про- сто, поскольку нет необходимости рассматривать каждую возможную ситуа- цию, а только самую неблагоприятную для каждого размера ввода. Более того, время работы в наихудшем случае часто представляется в виде точно определяемой границы, поскольку наихудшие характеристики явно опреде- ляются видом входных данных, используемых для анализа. 2.2.2. Время работы в среднем Время работы в среднем определяет усредненное значение времени ра- боты для всех видов входных данных для каждого размера. Время работы в среднем для программы seguentialSearch равно в предположении, что каждый из п элементов может быть выбран с равной вероятностью и что поиск завершается успешно. Если второе предположение не удовле- творяется, то время работы в среднем лежит в диапазоне [ п ] в зави- симости от причины. Анализ ситуации в среднем обычно более трудный по сравнению с ана- лизом наихудшего случая, поскольку он существенно зависит от определе- ния, что считать типичным вводом. Даже если мы примем сравнительно простые предположения (например, о том, что равновероятно поступление на вход данных заданного размера), то все равно вычисления остаются сложными и, кроме того, если программе придется работать с данными, для которых не обеспечены предполагаемые условия, то анализ может дать ре- зультат, сильно отличающийся от действительности.
96 Глава 2 2.2.3. Средневзвешенное время работы Операции, выполняемые алгоритмом, обычно взаимозависимы. Парамет- ры каждой операции — их стоимость в частности — зависят от того, что происходило перед этим. В некоторых алгоритмах невозможно обеспечить наихудшие условия для всех операций одновременно. В более общем случае, стоимость сравнительно дорогих операций может быть перекрыта большим числом недорогих операций. При средневзвешенном анализе делается по- пытка учесть такую ситуацию: учитывается средняя стоимость каждой опе- рации в худшем случае. Рассмотрим алгоритм, в котором выполняются п операций с многодо- ступным стеком s. (Более подробно стеки рассмотрены в разделе 3.4). В этой структуре данных выполняются такие две операции: • s.push(x) — занесение элемента х на верх стека s. • s.pop(i) — выборка i самых верхних элементов из стека s. Операция не определена, если стек s содержит менее i элементов. Как часть нашей модели вычислений предположим, что операция s.push(x) выполняется за постоянное время (или за один шаг), а операция s.pop(i) — за время, пропорциональное i (т. е. за I шагов). Средневзвешенный анализ может быть выполнен несколькими способами. Мы будем использовать подход, основанный на бухгалтерской метафоре, в которой различным операциям присваиваются некоторые «веса». При так называемой взвешенной стоимости операции каждый такой вес может быть больше или меньше фактической стоимости операции. Недорогим операци- ям обычно приписывается взвешенная стоимость, которая превышает их действительную стоимость, а наиболее дорогим — меньше. Когда взвешен- ная стоимость операции превышает ее фактическую стоимость, то разность представляет собой кредит, который может быть использован для погаше- ния повышенных затрат на выполнение последующих дорогих операций. Идея заключается в том, чтобы назначать взвешенные стоимости операциям так, чтобы накопленные кредиты за счет недорогих операций перекрывали стоимость дорогих операций. В отношении стека фактические и взвешенные стоимости могут соотно- ситься следующим образом: Операция Фактическая стоимость Взвешенная стоимость s.push(x) 1 2 s.pop(i) I 0 Из таблицы видно, что взвешенная стоимость для каждой операции за- несения push(x) равна двум шагам, а для операции выборки pop — нулю. При каждой записи элемента в стек на счет оплаты операции относится один шаг, а второй шаг хранится в резерве. Тогда при выборке из стека i элементов стоимость операции будет оплачена i шагами, которые были на- коплены в резерве — по одному шагу за каждый вызванный элемент. В любой момент времени общий кредит, хранящийся в резерве, равен числу элементов в стеке.
Анализ алгоритмов 91 Общая взвешенная стоимость достигает максимума при непрерывности последовательности п операций записи в стек и составляет 2п шагов. Сле- довательно, в среднем в наихудшем случае каждая операция выполняется за два шага, т. е. каждая операция выполняется за постоянное время при оценке в смысле взвешенной стоимости. Ключ к такому бухгалтерскому подходу лежит в выборе взвешенной сто- имости, а это, исключая предыдущий пример,— довольно трудная задача. Необходимо найти определенный компромисс. Во-первых, общая взвешен- ная стоимость любой последовательности операций должна быть не больше верхней оценки общей действительной стоимости этой последовательности. Тем самым обеспечивается, что при анализе будет получена некоторая оцен- ка, соответствующая общей фактической стоимости. В нашем случае общий доступный кредит первоначально нулевой и никогда не может быть меньше нуля, так что общая взвешенная стоимость никогда не может оказаться меньше общей действительной стоимости. Во-вторых, необходимо обеспе- чить, чтобы принятое решение было как можно более строгим и выбор взве- шенной стоимости был таковым, чтобы она наиболее близко соответствовала верхней оценке. В нашем примере общая взвешенная стоимость превышает фактическую стоимость на число элементов в стеке, поэтому она никогда не может быть более, чем удвоенная фактическая стоимость. В третьих, общий кредит никогда не должен быть отрицательным, иначе могла бы по- явиться ситуация, когда было бы нечем платить за некоторую последова- тельность операций. Взвешенный анализ часто применяется для поиска верхней границы вре- мени работы алгоритма в наихудшем случае: устанавливается зависимость количества операций, выполняемых алгоритмом, от размера ввода и затем вычисляется взвешенная стоимость каждой операции. Если затраты времени между двумя последовательными операциями постоянны, то время работы алгоритма в наихудшем случае ограничено сверху общей взвешенной сто- имостью. Анализ такого вида часто обеспечивает получение более точной оценки для верхней оценки времени работы в наихудшем случае, чем ос- тальные более простые виды анализа. Относительно нашего последнего при- мера многократной выборки из стека мы могли бы наивно утверждать сле- дующее: поскольку в наихудшем случае операция выборки pop требует до п шагов в наихудшем случае, то на выполнение последовательности п опе- раций могло бы потребоваться п2 шагов. Однако взвешенный анализ вы- являет, что такая последовательность операций невозможна и последова- тельность п операций может быть выполнена не более, чем за 2п шагов. 2.3. Асимптотический анализ 2.3.1. функции скорости роста До сих пор мы применяли два известных подхода к упрощению анализа: использование абстрактной модели вычислений и представление времени работы алгоритма в виде зависимости от размера ввода. При третьем подходе рассмат- ривается асимптотическая эффективность алгоритмов: как изменяется время работы алгоритма при возрастании размера ввода до бесконечности. Пусть время
28 Глава 2 работы алгоритма (например, в наихудшем случае) выражается функцией f(ri). Нас будет интересовать скорость роста функции f и мы будем ее опре- делять через упрощенную функцию скорости роста Т(п). Например, предположим, что f(ri) - ап* 2 + Ьп + с при некоторых констан- тах а, Ъ и с. По мере роста параметра п влияние членов малого порядка будет уменьшаться и ими можно пренебречь, т. е. функцию f(n) можно счи- тать равной ап2. Более того, даже коэффициент а в главном члене стано- вится сравнительно незначащим при увеличении размера ввода, так что функцию /(п) можно вполне заменить простой функцией Т(п) = п2. Нетрудно убедиться в том, что коэффициентом при главном члене можно пренебречь. Если принять, что Т(п) = ап2, то каждое удвоение параметра п приводит к увеличению времени работы в зависимости от Т(п), а не от а. Действительно, мы имеем Т(2п) = а(2п)2 - 4ап2 = 4Т(п) Т. е. время работы учетверяется при каждом удвоении размера ввода и не зависит от величины коэффициента а. В более общем случае, если Т(п) является функцией скорости роста, со- стоящей из главного члена с коэффициентом а, то Т(2п) будет функцией от Т(п) и, возможно, с компонентом более низкого порядка, зависящим от п и а. Это показано в следующей таблице, в которой функции упорядочены по скорости роста. Т(п) Т(2п) а Т(п) a log п Т(п) + а ап 2Т(п) an log п 2Т(п) + 2ап ап2 4Т(п) ап3 8Т(п) 2п[Т(п)]2 Различные функции Т(п) показаны в логарифмическом масштабе на рис. 2.1 при а = 1 и log п с основанием 2. Время работы в наихудшем случае для алгоритмов, рассматриваемых в этой книге, пропорционально одной из нижеследующих простейших функ- ций скорости роста. В этом списке отражены также наиболее общие при- чины, почему отнесенные к данному виду алгоритмы работают именно таким образом: 1 (постоянное время) — такие алгоритмы выполняются за время, неза- висимое от размера ввода. log п (логарифмическое время) — алгоритмы этого класса выполняются путем многократного разбиения задачи на подзадачи фиксированного раз- мера.
Анализ алгоритмов 99 п (линейное время) — такие алгоритмы обычно затрачивают одинаковое время на обработку каждого элемента ввода. п log п (время, пропорциональное п log п) — к этому классу принад- лежат алгоритмы типа «разделяй и властвуй», которые выполняются путем декомпозиции исходной задачи на более мелкие подзадачи, решения их в отдельности и последующего объединения полученных результатов. п2 (квадратичное время) — большинство таких алгоритмов затрачивает постоянное время на обработку всех пар входных элементов. п3 (кубическое время) — в большинстве таких алгоритмов на обработку каждой пары входных элементов затрачивается линейное время. 2.3.2. Асимптотическая нотация Асимптотическая нотация представляет удобный язык для описания функций скорости роста. Кроме многочисленных других применений, эта нотация используется для выражения времени работы алгоритмов и слож- ности задачи, она часто применяется в доказательствах, предусматриваю- щих ограничения количества или размера объектов. Здесь мы обсудим ос- новные элементы такой нотации. Рис. 2.1. Различные функции Т(п) в логарифмическом масштабе О-нотация О-нотация (или болъшое-о-нотация) используется для обозначения верх- них оценок скорости роста функций. Если есть функция то запись О(/(п)) обозначает множество всех функций, которые растут не быстрее, чем f(n). Функция g(n) принадлежит O(ftn)), если g(n) не более, чем в некоторое постоянное число раз превышает f(ri) при достаточно большом п. Более фор-
30 Глава 2 мально, g(n) G О(/(тг)), если для некоторого вещественного числа с > 0 су- ществует целое число по > 1 такое, что g(ri) < с f(n) для всех п > по. Например, ап + Ь G О(п) для всех констант а и Ь. Чтобы убедиться в этом, выберем с = а + 1 и по = Ь. Тогда будем иметь ап + b = (а + 1)тг + b - 1 = сп + по - п < СП для всех п > по- Выражение времени работы с помощью О-нотации часто применяется без указания меры сложности. Когда говорится, например, что алгоритм вы- полняется за время O(f(n)), то это означает, что время его выполнения ог- раничено сверху значением О(/(тг)) для всех входов с размером п. При этом предполагается, естестветственно, что время выполнения в наихудшем слу- чае также ограничено сверху величиной О(/(тг)). о-нотация о-нотация (или малое-о-нотация) используется для обозначения точных верхних оценок скорости роста функций. Если есть функция f(n), то запись о(/(тг)) обозначает множество всех функций, которые определенно растут медленнее, чем f(n). Более формально, g(n) принадлежит о(/(тг)), если для любой константы с > 0 существует целое число по > 1 такое, что g(n) < с f(n) для всех п > по- Легко видеть, что o(f(n)) С O(f(n)) для любой функции f. о-нотация привлекает внимание к главным составляющим функции. На- пример, для выделения главного члена функции /(тг) = anlog2 п + bn + с ее можно переписать в виде f(n) = anlog2 п + о(п log п). Это означает, что член низшего порядка существует и он может быть опущен. ©-нотация ©-нотация (или омега-нотация) используется для обозначения нижних оценок скорости роста функций. Если есть функция f(n), то запись ©(/(тг)) обозначает множество всех функций, которые растут не более мед- ленно, чем f(n). Определение Q(/(n)) симметрично определению O(f(n)): функция g(n) принадлежит Q(/(n)), если для некоторого постоянного числа с > 0 существует целое число по > 1 такое, что g(n) > с f(n) для всех 71 > по- ©-нотация О-нотация и ©-нотация могут быть использованы совместно для опре- деления множество функций, для которых скорость роста лежит в опре- деленных пределах. Например, запись О(тг2) n ©(тг) обозначает класс функ- ций, которые обладают скоростью роста не менее, чем п, но не более, чем п2. ©-нотация (или тэта-нотация) используется тогда, когда разность между оценками уменьшается до нуля и скорость роста может быть опре- делена точно. Так если функция будет f(n), то Q(f(n)) будет обозначать класс всех функций, которые имеют такую же скорость роста, что и f(n). Более формально эту нотацию можно определить как O(f(n)) = O(f(n)) n ©(/(тг)).
Анализ алгоритмов 31 2.4. Анализ рекурсивных алгоритмов Очень многие алгоритмы решают большую задачу путем ее декомпозиции на более мелкие подзадачи того же вида, решения их и последующего объ- единения полученных решений для формирования решения исходной задачи. Поскольку подзадачи относятся к тому же виду, что и исходная задача, они решаются рекурсивно тем же самым алгоритмом. Для обеспечения безуслов- ного завершения процесса подзадача в конце концов должна стать настолько малой или достаточно простой, чтобы ее можно было решить непосредствен- но. Такой общий подход известен как рекурсивная декомпозиция. Алгоритмы, основанные на таком подходе, могут быть проанализированы с использованием рекуррентных отношений. Рекуррентная формула пред- ставляется в виде последовательности целочисленных членов, причем каждый член последовательности определяется через предыдущие члены той же функции. В качестве примера можно привести известную функ- цию для вычисления факториала n! = n*(n - 1)*. . . *2*1*1, которая опи- сывается рекуррентным отношением: п! _ [ п*(п - 1)! если п > 1 [ 1 в противном случае (при п - 0) Для анализа алгоритма, основанного на рекурсивной декомпозиции, мы будем выражать время работы при вводе данных размера п в виде рекур- рентного отношения. Например, рассмотрим сортировку путем выборки, при которой выполняется сортировка п целых чисел выделением из набора сначала наименьшего числа, и затем применения этого же алгоритма к ос- тавшимся п — 1 числам: void selectionsort (int а[] , int n) { if (n > 0) { int i = positionOfSmallest(a, n); swap(a[0], a[i]); selectionSort (a+1, n-1); } } При обращении к функции positionOfSmallest(а, п) возвращается индекс наименьшего целого числа в массиве а с длиной п. Функция вы- полняет сканирование массива, сохраняя индекс позиции, в которой до те- кущего момента было обнаружено наименьшее число, и время ее работы составляет 0(п). Функция swap выполняет взаимную замену двух целых чисел за постоянное время. Время работы сортировки выборкой как функция Т(п) при размере ввода п выражается рекуррентным отношением T(n) = J 2Т (п - 1) + ап если п > 0 [ b в противном случае (п - 0) где а и b — константы. В выражении 2.2 член Т (п - 1) соответствует сто- имости рекурсивного вызова, ап — стоимости обнаружения наименьшего це- лого числа и взаимного обмена его с а [ 0 ] и Ь — стоимости возврата, если п==0.
38 Глава 2 Для целей сравнения сортировки выборкой с другими алгоритмами сор- тировки предпочтительно преобразовать выражение 2.2 в закрытую форму, т. е. выразить Т(п) только в терминах п, а и Ь. Мы вкратце рассмотрим два способа получения решения рекуррентных отношений в закрытой форме: способ поэтапного суммирования и способ подстановки. 2.4.1. Поэтапное суммирование Идея способа поэтапного суммирования заключается в постепенном раскрытии рекуррентного отношения до тех пор, пока оно не будет выражено суммой членов, которые зависят только от п и начальных условий. Практически для этого бывает необходимо выполнить раскрытие только несколько раз, пока зависимость не ста- нет очевидной. Например, выражение 2.2 может быть решено так: Т(п) = Т (п - 1) + ап = Т (п - 2) + а(п - 1) + ап = Т (п - 3) + а(п - 2) + а(п - 1) + ап = Т (0) + а + 2а + ... + па = Ь + а(1 + 2 + ... 4- а) = Ь + а[п(п + 1)/2] CL о CL , = 2п+2п + Ь Следовательно, Т(п) 6 О(п2). Откуда получаем, что сортировка выбор- кой — а также все другие алгоритмы, описываемые выражением 2.2 — вы- полняется за время О(п2). Заметим, что в этом выводе используется тот факт, что j i = Суммирование не всегда выполняется так просто. В некоторых случаях мы можем сделать это не лучше, чем ограничить суммирование сверху, что при- водит к верхней оценке для Т(п). 2.4.2. Способ подстановки Алгоритмы, основанные на подходе «разделяй и властвуй», используют особую форму рекуррентной декомпозиции. В большинстве простейших спо- собов в режиме «разделяй и властвуй» исходная задача разделяется на две подзадачи, каждая из которых примерно в два раза меньше по размеру, чем исходная задача. Предположим, что каждый из двух процессов — процесс де- композиции исходной задачи размера п на две подзадачи и процесс комби- нации их решений в общее решение исходной задачи — занимают время О(п). Примером этого может служить задача сортировки слиянием. Для сор- тировки массива длиной п при сортировке слиянием рекурсивно сортируются левый и правый подмассивы и затем сливаются вместе оба отсортированных подмассива: void mergeSort(int а[], int n) { if (n > 1) { int m = n / 2; mergeSort(a, m); mergeSort(a+m, n-m);
Анализ алгоритмов 33 merge(a, m, n) ; } } Шаг слияния выполняется при обращении к функции merge (а, т, и), которая сливает два отсортированных подмассива а[0..т-1] и а[т..п-1] в единый массив а [ 0. .п-1 ]. (Здесь мы используем запись а [ 1. .и] для обо- значения подмассива а между нижним индексом 1 и верхним индексом и включительно.) Функция merge работает путем сохранения указателя на на- именьший еще не слитый элемент в каждом из двух отсортированных под- массивов. Указатели итеративно перемещаются слева направо до тех пор, пока не будет выполнено слияние. Работа шага слияния, выполнение кото- рого занимает линейное время, во многом похожа на складывание вместе двух отсортированных частей колоды игральных карт. (Сортировка слиянием более подробно будет рассмотрена в главе 8.) Время работы Т(п) сортировки слиянием описывается следующим рекур- рентным соотношением, в котором для простоты мы предположим, что число п выражается степенью числа 2: Т(Л)=| 2ТФ + “" а=ЛИП>1 (2.3) Ь в противном случае (п - 1) Для решения уравнения 2.3 воспользуемся способом подстановки. Идея заключается в выборе некоторой закрытой формы решения рекуррентного уравнения и последующем применении математической индукции, чтобы убедиться в правильности решения.* Для решения уравнения 2.3 путем ин- дукции мы покажем, что Т(П) < С1 П log П + С2 (2-4) при соответствующим образом выбранных константах ci и сг- Пусть ci = а + b С2 = Ь будут пригодны для нашего случая. На индуктивном шаге предположим в качестве индуктивной гипотезы, что выражение 2.4 является решением уравнения 2.3 для всех степеней числа 2, меньших, чем п. Тогда будем иметь T(ri) = 2Т | | + ап I п Iс1« 1 п 1 < 2 -g- log -g + С2 + an * Математическая индукция представляет собой двухшаговый способ для доказательства справедливости последовательности утверждений Р( 1), Р(2), ..., Р(п), .... Основной шаг («база индукции») заключается в доказательстве первого утверждения Р(1). На следующем, индуктивном шаге, показывается, что верно любое утверждение Р(п), тогда, если Р(п) верно, то должно быть верным и утверждение Р(п+1). Здесь выражение для индуктивного шага Р(п) называется индуктивной гипотезой. 2 Заказ 2794
34 Глава 2 = сщ log + 2с2 + ап - cin log п - суп + С2 + b - Ьп < СуП log П + С2 Это и будет решением индуктивного шага. Основной шаг индукции также следует из нашего выбора ci и сг. Ниже показано, что уравнение 2.4 удовлетворяется при п = 1: Т(1) = Ъ - С2 < Cl (1 • log 1) + С2 Из приведенного обсуждения следует, что время работы для сортировки слиянием равно О(п log п) в наихудшем случае. 2.5. Сложность задачи Если нам известны параметры алгоритма, то мы можем сравнить его с другими алгоритмами решения такой же задачи. Но предположим, что нам нужно было бы определить, будет ли алгоритм оптимальным, и не суще- ствует ли более эффективное решение. Одним из способов решения, явля- ется ли данный алгоритм оптимальным, заключается в сравнении его с дру- гим алгоритмом, который по какой-то причине считается оптимальным, но это срабатывает только в том случае, если его оптимальность действительно можно доказать. Необходим другой подход, если мы хотим избежать такой бесконечной последовательности. Как же определить, будет ли алгоритм оп- тимальным? Или, в более общей постановке, как можно определить, сколь- ко максимально потребуется времени на поиск любого решения для данной задачи? Сложность задачи выражается в величине необходимых и достаточных затрат времени для решения задачи. Если на решение задачи затрачено вре- мени меньше, чем необходимо, то это может служить основанием для по- дозрения, что задача решена неверно. Зная достаточное время, можно оце- нить эффективность конкретного решения и определить возможные пути усовершенствования. Когда сложность задачи известна точно, ее можно выразить в форме 0(/(п)). Сложность большинства задач обычно известна довольно точно. Од- нако в ряде случаев единственное, что можно сделать, это установить ниж- ние и верхние границы сложности и затем попытаться сближать эти две оценки до тех пор, пока они не совпадут. 2.5.1. Верхние границы Любое решение заданной задачи ограничивает ее сложность сверху: если в худшем случае решение получено за время О(/(п)), то тогда время О(/(л)) достаточно для решения задачи. Если более быстрое решение полу- чить невозможно, в предположении, что данное решение является опти- мальным, тогда верхняя оценка будет точной. В противном случае, если ре-
Анализ алгоритмов 35 шение почти оптимальное, то верхняя оценка может быть снижена по мере открытия более эффективного способа решения. Напомним о задаче ПОИСК, в которой поиск числа в отсортированном массиве О(п) различных чисел осуществлялся на основе проб. Поскольку последовательный поиск выполняется за время О(п) в наихудшем случае, то значение О(п) представляет собой верхнюю оценку для этой задачи. Од- нако, О(п) не будет точным решением, поскольку существуют более эффек- тивные решения. При двоичном поиске используется то преимущество, что массив уже отсортирован. При заданном подмассиве а [ 1. . и ] в процессе двоичного поиска ключ поиска х сравнивается с элементом а [т], располо- женным в позиции m примерно посередине между позициями 1 и и. Если х не больше, чем а [т], то двоичный поиск рекурсивно продолжается в левом подмассиве а[1..ш], в противном случае (х больше, чем а[т]) ре- курсивный двоичный поиск применяется для правого подмассива а[т+1. .и]. В конечной ситуации (1==и) задача решается непосредственно. Приведенная ниже функция возвращает индекс х в подмассиве а [ 1. . и ] или -1, если поиск закончился неудачно: int binarysearch(int х, int а[], int 1, int u) { if (1 == u) return (x == a[u]) ? u : -1; int m = (1 + u) / 2; if (x <= a[m]) return binarysearch(x, a, 1, m); else return binarysearch(x, a, m+1, u); } Чтобы найти число x в массиве а длиной п, обращение к этой функции на верхнем уровне должно быть binarysearch (х, а, 0, п-1). Время работы двоичного поиска в наихудшем случае определяется рекур- рентным соотношением: Т(„) = | 2Т©+° : ' (2.5) Ъ в противном случае (n = 1) Нетрудно показать, используя любой из описанных ранее способов, что Т(п) 6 O(log п). Из нашего обсуждения следует, что O(log п) будет верхней оценкой для задачи ПОИСК. Будет ли эта оценка точной или может существовать более эффективный способ, чем двоичный поиск? На самом деле эта оценка точ- ная — двоичный поиск оптимален. Чтобы убедиться почему, обратимся к определению нижней границы сложности задачи. 2.5.2. Нижние границы Определение хороших нижних границ может оказаться сложным делом. Значение £2(g(n)) для нижней границы сложности некоторых задач предпо- лагает, что для их решения необходимо время, равное по крайней мере &(ё(пУ). Но можем ли мы быть уверенными, что каждое решение требует
36 Глава 2 по крайней мере Q(g(n)) времени и что не существует какое-либо иное (воз- можно, неизвестное) решение, которое потребует меньше времени? Мы рассмотрим здесь два подхода к определению нижних границ: до- казательство с помощью дерева решения и доказательство путем редукции. Деревья решений Вновь вернемся к задаче ПОИСК. Любой алгоритм, основанный на своей модели вычислений (проба с постоянным временем выполнения) может рас- сматриваться в качестве дерева решения. В дереве решения отражаются все возможные вычисления для всех входов данного размера (для каждого раз- мера входа имеется свое дерево решения). Каждый узел дерева решения со- ответствует пробе. Пара ребер, выходящая из каждого узла, соответствует оператору ветвления по условию, полученному на основе результата пробы, и вычисления продолжаются, переходя по одному из двух ребер к следу- ющему узлу. Внешние узлы дерева решения — конечные узлы, не имеющие выходящих ребер — соответствуют окончательному результату. На рис. 2.2 показано дерево решения для программы binarysearch, применяемой для поиска в отсортированном массиве длиной 3. Фактически дерево решения является двоичным деревом, в котором пробы ассоциированы с каждым внутренним узлом (изображенным в виде овала), а принимаемые решения ассоциированы с каждым внешним узлом (изображенным в виде квадрата). Двоичные деревья более подробно ассоциируются в разделе 3.5. Дерево решения можно использовать при определении нижней границы для задачи ПОИСК. Каждый путь от самого верхнего узла (или корня) вниз до некоторого внешнего узла отображает возможные вычисления и длина пути равна числу проб, выполняемых при этих вычислениях. (Здесь под длиной пути понимается количество ребер, содержащихся в нем.) Следова- тельно, длина самого длинного пути в дереве решения представляет коли- Рис. 2.2. Дерево решения для программы binarysearch, применяемой для поиска в отсортированном /пас- сиве длиной 3
Анализ алгоритмов 37 чество проб, выполняемых в наихудшем случае. Насколько коротким может быть этот самый длинный путь? Для задачи ПОИСК с массивом длиной и дерево решения должно содер- жать по крайней мере п внешних узлов: каждое их п отдельных решений (или п + 1, если мы включаем отрицательный результат поиска) связано по крайней мере с одним уникальным внешним узлом. Покажем, что в любом двоичном дереве, содержащем п внешних узлов, существует некоторый путь с длиной, равной по крайней мере log п. При высоте двоичного дерева, оп- ределенной как длина самого длинного пути от корня до внешнего узла, можно утверждать, что нам необходимо показать следующее: Теорема 1. Дроичное дерево, содержащее п внешних узлов, имеет высоту по крайней мере log2 п. Доказательство этой теоремы можно выполнить путем индукции числа п. В качестве основы индукции берем и = 1, в этом случае дерево состоит из единственного внешнего узла. В этом случае единственный путь имеет длину 0 = log2 1. т. е. базисное условие соблюдено. Для индуктивного шага предположим в качестве индуктивной гипотезы, что теорема удовлетворяется для всех двоичных деревьев с числом внешних узлов меньше, чем п. Нам нужно показать, что она удовлетворяется для каждого двоичного дерева Т, содержащего п внешних узлов. Корневой узел дерева Т соединяется с двумя меньшими двоичными деревьями Ti и Тг, содержащими деревьями zii и П2 внешних узлов соответственно. Поскольку П1 + П2 = п, то либо zii, либо П2 (или оба) больше или равны j. Предполо- жим без потери общности, что ni > Кроме того, поскольку П2 > 0 (поче- му?), имеем zzi < п, так что можем применить индуктивную гипотезу. Обо- значив через высота (Т) длину самого длинного пути в двоичном дереве Т, будем иметь высота (Т) - 1 + шах { высота (Ti), высота (Тг) } > 1 + высота (Ti) > 1 + log2 zii > 1 +log2 у - log п Применим этот результат к нашему обсуждению нижних границ. Любой алгоритм для решения задачи ПОИСК соответствует (при размере входа п) дереву решения, содержащему по крайней мере п внешних узлов. Из теоремы 1 следует, что дерево решения будет иметь высоту не менее, чем log2 п. Сле- довательно, алгоритм выполняет по крайней мере log2 п проб для некоторого входа с размером п. Поскольку это соблюдается для всех возможных алго- ритмов решения задачи ПОИСК, то Q(log zz) будет нижней границей для этой задачи. И конечно, двоичный поиск будет оптимальным решением. Редукция При другом способе определения нижней границы для некоторой задачи Рв используется известная нижняя граница для некоторой другой задачи Ра- Идея заключается в том, чтобы показать, что задача Рв может быть решена не быстрее, чем задача Ра и поэтому нижнюю границу для Ра
38 Глава 2 можно отнести и к Рв- Чтобы показать такое соотношение между двумя задачами, выберем такой эффективный алгоритм А для решения задачи Ра, который не более, чем постоянное число раз вызывает процедуру, ре- шающую задачу Рв- Алгоритм А будем называть редукцией от Ра к Рв- Перед формализацией этой идеи в качестве примера рассмотрим сначала задачу УНИКАЛЬНОСТЬ ЭЛЕМЕНТА: Для заданного неупорядоченного набора п чисел решить, будут ли одина- ковы любые два из них. Построим эффективную редукцию от задачи УНИКАЛЬНОСТЬ ЭЛЕМЕНТА к задаче СОРТИРОВКА: Для заданного неупорядоченного набора п чисел перестроить числа в не- убывающем порядке. Для обеих задач будем предполагать, что модель вычислений основана на сравнении-, единственной операцией, которую мы будем учитывать, яв- ляется операция сравнения, в которой два числа сравниваются за постоян- ное время (за один шаг). Простейшая редукция А от задачи УНИКАЛЬНОСТЬ ЭЛЕМЕНТА к за- даче СОРТИРОВКА работает следующим образом: для определения, будет ли некоторое число появляться в массиве из п чисел более одного раза, сна- чала отсортируем массив и затем пошагово будем просматривать его, срав- нивая все пары последовательных чисел. Число считается появляющимся в исходном массиве дважды, если и только если оно обнаруживается в со- седних позициях только что отсортированного массива. Существование такой редукции показывает, что известная нижняя грани- ца Q(zi log zi) для задачи УНИКАЛЬНОСТЬ ЭЛЕМЕНТА относится и к задаче СОРТИРОВКА. Предположим, что должен существовать некоторый алгоритм В для задачи СОРТИРОВКА с временем работы o(zi log п). Тогда редукция А решила бы задачу УНИКАЛЬНОСТЬ ЭЛЕМЕНТА за время о(п log zi), если бы в ней для сортировки применялся алгоритм В и тогда на сортировку по- требовалось бы время о(п log ri) и на проверку соседних чисел в только что отсортированном массиве потребовалось бы время O(zi) с o(zi log п). Но это приводит к противоречию, поскольку нижняя граница Q(zi log zi) для задачи УНИКАЛЬНОСТЬ ЭЛЕМЕНТА предполагает, что невозможно существование какого-либо решения с временем выполнения o(zi log п). Теперь формализуем эти идеи. Алгоритм А для задачи Ра будет т.(п)-временной редукцией от Ра к Рв, если 1. А использует некоторый (гипотетический) алгоритм В для Рв в виде некоторого постоянного числа обращений к функции и 2. А выполняется за время O(t(zi)), причем стоимость каждого обращения к В составляет один шаг. В результате мы заменим модель вычислений для задачи Ра на некоторое постоянное число раз обращение к операции В, что приводит к решению за- дачи Рв- Мы вполне можем иметь в виду операцию В без конкретной фор- мулировки решения задачи Рв (может быть даже и не зная его совсем). В нашем случае задачей Ра является задача УНИКАЛЬНОСТЬ ЭЛЕМЕНТА, за- дачей Рв — задача СОРТИРОВКА, А представляет собой zi-кратную (или ли- нейную по времени) редукцию от задачи УНИКАЛЬНОСТЬ ЭЛЕМЕНТА к за- даче СОРТИРОВКА, а В является гипотетическим алгоритмом для задачи СОРТИРОВКА.
Анализ алгоритмов 39 Предположим, что для задачи Ра известна нижняя граница (zi)) и что мы можем составить т(п)-кратную по времени редукцию А от Ра к Рв- Тогда, предполагая, что T(zi) €= o(f (zz)), задача Рв также должна иметь ниж- нюю границу (zi)). Если бы существовал некоторый алгоритм В для Рв, который выполняется за время o(f (zz)), то алгоритм А также выполнял- ся бы за время o(f (п)), если бы он использовал алгоритм В и тогда бы А не соответствовал бы нижней границе для задачи Ра. Этот аргумент зависит от предположения, что t(zi) G o(f (zi)). Если бы это предположение не было удовлетворено, то алгоритм А выполнялся бы за время (zz)), независимо от свойств алгоритма В, и известная нижняя граница для Ра была бы на- рушена. В этом и заключается причина, по которой редукция должна быть эффективной, чтобы быть полезной (см. рис. 2.3). Эффективная редукция от задачи Ра к задаче Рв также может быть ис- пользована для переадресации известной нижней границы для Рв к задаче Ра- Вернемся к нашему примеру. Задача СОРТИРОВКА имеет верхнюю гра- ницу О(п log zi). Сортировка слиянием является единственным алгоритмом, который решает задачу за время О(п log п). Основываясь на любом алгорит- ме сортировки, выполняемом за время O(zi log zz), редукция А приводит к решению задачи УНИКАЛЬНОСТЬ ЭЛЕМЕНТА за время О(п log zi). Следо- вательно, задача УНИКАЛЬНОСТЬ ЭЛЕМЕНТА наследует верхнюю границу СКп log zz) для задачи СОРТИРОВКА. В общем случае предположим, что задача Рв имеет известную верхнюю границу и мы можем предложить O(g (zi)), редукцию по времени от задачи Ра к задаче Рв- Тогда, полагая T(zi) G: O(g (n)), задача Ра должна иметь такую же верхнюю границу O(g (zz)). Рис. 2.3. т(п)-кратная по времени редукция от задачи Ра к задаче Рв 2.6. Замечания по главе В книгах [2, 20, 56, 73, 83, 89, 90] излагаются вопросы анализа алго- ритмов и анализируются приведенные в них алгоритмы. Можно также ре- комендовать статью [82], в которой описывается взвешенный анализ и пред- ставлено несколько примеров. Кнут использовал О-нотацию и асимптотический анализ алгоритмов еще в 1973 году [48] и ввел разновидности О-, Q-, 0- и о-нотаций три года спустя в [49]. В обоих источниках описывается история асимптотической нотации.
40 Глава 2 Математическая индукция определена и описана в [57], а основы для раз- работки алгоритмов и их анализа заложены в [56]. 2.7. Упражнения 1. Примените математическую индукцию для доказательства У t i = га^”+1\ 2. Примените математическую индукцию, чтобы показать, что двоичное дерево с п внутренними узлами должно иметь п + 1 внешних узлов (см. раздел 3.5 для получения более подробных сведений о двоичных деревьях). 3. Обсудите преимущества и недостатки оценки времени работы програм- мы в единицах времени при ее выполнении в компьютере. Является ли такая мера надежной характеристикой? 4. Исследуйте функции seguentialSearch и binarysearch и постройте графики зависимости времени их выполнения от размера ввода. По- скольку обе программы имеют параметры наихудшего случая при не- удачном поиске, то вам вероятно захочется использовать ключи по- иска, не встречающиеся во входном массиве. Подтверждают ли ваши графики времени выполнения в наихудшем случае O(zz) для последо- вательного поиска O(log zz) для двоичного поиска? 5. Исследуйте алгоритмы сортировки insertionsort (выполняемый за время O(n2)), selectionsort (выполняемый за время O(zz2)) и mergeSort (выполняемый за время О(п log zz)) и постройте графики зависимости времени их выполнения от размера ввода. Подтверждают ли ваши графики их времена выполнения? (Эти программы обсужда- ются в начале глав 5, 6 и 8.) 6. Решите рекуррентное уравнение Г(д) = Г Т (п - 1) + а если п > 0 [ b в противном случае (zz = 0) для констант а и Ъ. 7. Решите рекуррентное уравнение Т(п) = Tty + a b если п > 1 в противном случае (п - 1) для констант а и Ъ. 8. Решите рекуррентное уравнение T(zi) = 2 Т (п - 2) + а b если п > 1 в противном случае (zz = 0 или п = 1) для констант а и Ь. 9. Покажите, что ft G O(g (zz)) и fz G O(g (zz)) приводят к (fi + fz) (n) G O(g (zz)), если (fi + fz) (n) = fi(n) + fz(n). 10. Покажите, что fi G O(gi (zz)) и fz G O(gz (zz)) приводят к (fi fz) (n) G O((gi • gz) (n)), если (fi fz) (n) = fi(n) fz(n).
Анализ алгоритмов 41 11. Покажите, что f(n) G O(g(n)) и g(n) G О(Л(п)) приводят к f(n) G О(Л(п)). 12. Покажите, что log& п G 0(logc п) для всех b, с > 1. 13. Покажите, что атпт + am-iziml + ... + ао G O(zim) для любых кон- стант щ. 14. Покажите, что log п G O(nk) для всех k > 0. 15. Покажите, что nh G О(2П) для всех k > 0. 16. Покажите, что f(n) G O(g(n)) приводит к g(n) G Q(f(zi)). 17. Покажите с помощью деревьев решения, что сортировка на основе опе- раций сравнения (задача СОРТИРОВКА) имеет нижнюю границу Q(zi log zi). (Подсказка: можно использовать свойство log (zi!) G Q(zi log zz).) 18. Покажите с помощью деревьев решения, что будет определена нижняя граница Q(log zi) решения задачи ПОИСК для версии "да/нет": для за- данного отсортированного массива а из п различных целых чисел и ключа поиска х, определить, имеется ли в массиве а число х. (Под- сказка: хотя возможны только два варианта ответа, утверждается, что дерево решения должно содержать по крайней мере п внешних узлов.)
Глава 3 Структуры данных С Х^труктуры данных являются главными строительными блоками, из кото- рых формируются алгоритмы. Структуры данных (т. е. способы описания упорядоченных данных), используемые алгоритмом, влияют на эффектив- ность алгоритма, а также на простоту его понимания и программной реали- зации. Поэтому очень важно изучить особенности структур данных. Язык программирования C++ имеет ряд предопределенных структур дан- ных. В качестве примера можно назвать целые числа вместе с операциями по их обработке: арифметические операторы, например, сложение и умно- жение, операторы отношений, операторы присвоения и т. д. В качестве дру- гих примеров можно назвать числа с плавающей точкой, символы, типы указателей, типы ссылок. Предопределенные структуры могут быть исполь- зованы в программах в «чистом виде» или комбинированно через такие средства, как классы и массивы для образования структур данных более вы- сокой сложности. В этой главе мы представим и будем использовать такие структуры дан- ных: списки, стеки и деревья двоичного поиска. Эти структуры данных хотя и элементарны, но достаточно мощные, а их реализация является стандарт- ной и практичной. Мы не будем пытаться давать в этой главе сложной трак- товки структур данных нашей целью в данном случае является подготовка к применению структур данных, необходимых для последующих программ. 3.1. Что такое структуры данных? Структура данных состоит из структуры памяти для хранения данных, способов ее формирования, модификации и доступа к данным. Более фор- мально структура данных состоит из следующих трех компонентов: 1. Набор операций для манипуляций над специфическими типами аб- страктных объектов. 2. Структура памяти, в которой хранятся абстрактные объекты. 3. Реализация каждой из операций в терминах структуры памяти. Первая компонента определения — набор операций над абстрактными объектами — называется абстрактным типом данных (АТД). Вторая и третья компоненты вместе образуют реализацию структуры данных. Напри- мер, массив АТД обеспечивает манипуляции над набором значений одного и того же типа посредством операций доступа и модификации значений,
Структуры данных 43 задаваемых с помощью индекса. Реализация, используемая в языке C++, хранит массив в непрерывном блоке памяти (структура памяти) и исполь- зует арифметику указателей для преобразования индекса в адрес внутри блока (реализация операций). Тип абстрактных данных определяет, что делает структура данных — какие операции она поддерживает — не раскрывая, как они выполняются. Поэтому при составлении программы программист может думать о необхо- димых для программы типах абстрактных данных, а работу по их реализации отложить на некоторое время. Такая практика программирования, заключаю- щаяся в разделении алгоритма и его структуры данных так, что они могут рассматриваться раздельно, называется абстракцией, данных. Абстракция данных предполагает различные уровни абстрактного представления. Так в целочисленной арифметике абстрактный тип данных, предусмотренный в большинстве языков программирования (включая C++), позволяет нам раз- мышлять на уровне операций сложения, умножения и сравнения целых чисел без необходимости рассмотрения как эти целые числа представлены, как они складываются или умножаются и т. д. АТД стека позволяет думать на уровне организации стека, откладывая вопрос (более низкого уровня) о применении стека. Использование абстрактного типа данных приводит к применению модуль- ного программирования, т. е. к практике разбиения программы на отдельные модули с хорошо проработанным интерфейсом. Модульность имеет целый ряд преимуществ. Модули могут быть написаны и отлажены отдельно от остальной программы, другим программистом и в другое время. Модули до- пускают многократное применение, так что они могут быть загружены из библиотеки или скопированы из другой программы. Кроме того модуль может быть заменен другим модулем, который функционально эквивален- тен, но более эффективен, надежен или, по каким-либо другим соображе- ниям, просто лучше исходного. Кроме этих преимуществ, не каждая структура данных должна рассмат- риваться как абстрактный тип данных. АТД массив обеспечивает индексный способ доступа и модификации данных для манипуляций с набором данных, но часто в такой абстрактной трактовке массива нет никаких преимуществ. В этих случаях нарушается принцип использования для массива непрерыв- ного блока памяти и вызывает часто излишний уровень абстракции. Это и неэффективно, поскольку дополнительный вызов функции может привести к значительному удорожанию и нельзя использовать эффективный способ применения указателей, который основан на жесткой связи между указа- телем и массивом. Первой нашей задачей в этой главе будет представить абстрактный тип данных, используемых алгоритмами в этой книге и эффективно их приме- нить. Знакомство с этими АТД позволит нам описывать наши алгоритмы на высоком, более абстрактном уровне, в терминах абстрактных операций. Зна- комство с вопросами применения АТД — которые интересны сами по себе — позволит нам применить алгоритмы, использующие их. Кроме того, зная, как применяются АТД, мы сможем применить структуры данных и в тех слу- чаях, когда они лишь отдаленно соответствуют нашим нуждам.
44 Глава 3 3.2. Связанные списки Связанные списки, подобно массивам, используются для манипуляций со списками элементов. Элементы хранятся в виде узлов связанных спис- ков, упорядоченных последовательно в виде списка. Каждый узел обладает указателем на следующий узел (часто называемым ссылкой). Последний узел отличается специальным значением в его поле ссылки (нулевая ссылка на рис. 3.1) или ссылкой на самого себя. При обработке списков связанные списки имеют два преимущества перед массивами. Во-первых, изменение порядка элементов в связанном списке выполняется очень просто и быстро, обычно реализуется путем редактиро- вания небольшого числа ссылок (например, для включения нового узла b после некоторого узла а устанавливается ссылка от а к Ъ, и ссылка от b на тот элемент, на который только что была ссылка от а). Для сравнения выполняемых действий внесем один элемент в массив а, представляющий список п элементов. Для внесения элемента в позицию i каждый элемент от a[i] до а[п-1] должен быть сдвинут на один элемент вправо, чтобы образовать «дырку» для нового элемента в позиции i. В отличие от массивов связанные списки динамичны — они могут сжи- маться и расти в течение их жизни. При работе со связанными списками нет необходимости следить за переполнением отведенного участка памяти. Существует несколько видов связанных списков. Каждый узел для одно- связных списков имеет ссылку на следующий узел, его последователя. Каж- дый узел двухсвязных списков имеет ссылку как на следующий, так и на предшествующий узел, его предшественника. В кольцевых связанных спис- ках последний узел связан с первым узлом, если кольцевой список является двухсвязным, то первый узел также имеет ссылку на последний. Например, на рис. 3.1 показан простой односвязный список, на рис. 3.2 — кольцевой двухсвязный список. Здесь в основном будут рассматриваться кольцевые двухсвязные списки, поскольку они лучше подходят к алгоритмам, описан- ным в этой книге. Для краткости мы их будем называть просто связанными списками. Мы будем использовать их, например, для представления много- угольников, где каждый узел в связанном списке соответствует вершине многоугольника, а узлы упорядочены в порядке обхода вершин многоуголь- ника. В реализации связанных списков, ориентированных на представление точек, узел является объектом класса Node: Рис. 3.1. Простой односвязный список Рис. 3.2. Кольцевой двухсвязный список
Структуры данных 45 class Node ( protected: Node *_next; Node *_prev; public: Node (void); virtual ~Node (void); Node ‘next(void); Node *prev(void); Node *insert(Node*); Node ‘remove(void); void splice(Node*); ); // связь к последующему узлу // связь к предшествующему узлу Конструктор класса Node формирует новый узел, который имеет двойную ссылку на самого себя — новый узел представляет собой одноузловой свя- занный список. Node::Node(void) _next(this), _prev(this) { } Внутри компонентной функции переменная this автоматически указы- вает на принимающий объект, объект, для которого инициируется компо- нентная функция. В конструкторе переменная this указывает на объект, который подлежит внесению. Соответственно при обсуждении компонентных функций мы будем ссылаться на принимающий объект как на текущий объект (объект с именем this). Деструктор ~Node отвечает за удаление этого объекта. При описании класса он объявлен виртуальным (virtual), так что удаляемый объект (объект класса, полученного из класса Node) будет удален корректно: Node::~Node(void) { } Компонентные функции next и prev используются для перемещения из этого узла к последователю или предшественнику: Node *Node::next(void) { return _next; ) Node *Node::prev(void) { return _prev; } Компонентная функция insert включает узел b сразу же после текущего (this) узла (рис. 3.3):
46 Глава 3 Рис. 3,3. Включение узла в связанный список Node *Node::insert(Node *Ь) { Node *c = _next; b->_next = c; b->_prev = this; _next = b; c->_prev = b; return b; } Узел можно удалить из списка. Компонентная функция remove удаляет текущий узел из данного связанного списка (рис. 3.4). При этом возвра- щается указатель на текущий узел, так что впоследствии он может быть исключен: Node *Node::remove(void) { _prev->_next = _next; _next->_prev = _prev; _next = _prev = this; return this; ) Компонентная функция splice используется для соединения двух узлов а и Ь. Операция соединения приводит к разным результатам, зависящим от принадлежности этих двух узлов к одному и тому же списку. Если принад- Рис. 3.4. Удаление узла из связанного списка Рис. 3.5. Соединение двух узлов а и b
Структуры данных 47 лежат, то связанный список разделяется на два меньших списка. И наобо- рот, если узлы а и b принадлежат разным спискам, то оба списка объеди- няются в один более крупный список. Для слияния двух узлов а и b мы устанавливаем ссылку из а на b->__next (на последователя узла Ь) и из Ь на a->_next (на последователя узла а). Также необходимо изменить и соответствующие ссылки на предшественни- ка. Для этого для нового последователя узла а устанавливаем обратную ссылку на а, а для нового последователя узла Ь — обратную ссылку на Ь. Эта операция схематически отображена на рис. 3.5. Из рисунка очевидно, что операция соединения splice взаимно обратна: соединение узлов а и Ь на левой схеме приводит к образованию правой схемы, а повторное соеди- нение узлов а и b (на правой схеме) приводит к левой схеме. В следующей реализации компонентной функции splice текущий узел играет роль узла айв качестве аргумента функции указывается только узел Ь. Переменная а введена в программу исключительно для отображения симметрии операции: для заданных указателей узлов а и Ь операции a->splice(b) и b->splice(a) дают одинаковый результат. void Node::splice (Node *b) { Node *a = this; Node *an = a->_next; Node *bn = b->_next; a->_next = bn; b->_next = an; an->_prev = b; bn->_prev = a; } Заметим, что в случаях, когда узел а предшествует узлу b в связанном списке, то операция слияния приводит к удалению узла Ь. Слияние одно- узлового связанного списка b с узлом а некоторого другого связанного спис- ка приводит к фактическому включению узла b после узла а. Это явление приводит к выводу, что операции включения узла в связанный список и удаления узла из связанного списка фактически являются частными случая- ми операции слияния. Но это только замечание — компонентные функции insert и remove предусмотрены для удобства. И, наконец, заметим, что слияние узла с самим собой, например, при обращении a->splice(а), не приводит ни к каким изменениям. Это легко проверить по реализации функции splice. 3.3. Списки В этом разделе для представления списков введен новый класс List. Спи- сок — это упорядоченный набор конечного числа элементов. Длина списка равна числу элементов, содержащихся в списке, список нулевой длины на- зывается пустым списком. При нашем подходе каждый элемент списка занимает одну позицию — первый элемент в первой позиции, второй элемент — во второй и т. д. Кроме того существует головная позиция, которая одновременно располагается перед
48 Глава 3 Рис. 3.6. Структура списка длиной в семь элементов. Элементы в списке располагаются с 1 по 7 позицию. Голов- ная позиция О располагается между первой и последней позициями. Окно, изображенное в виде квад- рата, в данный момент находится в положении 2. первой и после последней позиции. Объект List обеспечивает доступ к его элементам через окно (window), которое в любой данный момент относится к некоторой позиции в списке. Большинство операций со списками выпол- няется либо с окном, либо с элементом в окне. Например, мы можем вызвать элемент, находящийся в окне, сдвинуть окно на следующую или предыдущую позицию или передвинуть его на первую или последнюю позицию. Мы также можем выполнить операцию удаления из списка элемента, находящегося в окне, или внести новый элемент в список сразу же после окна. На рис. 3.6 отражен наш подход к организации списка. Для реализации класса List будем использовать связанные списки. Каж- дый узел соответствует позиции в списке и содержит элемент, сохраняемый в этой позиции (узел, соответствующий головной позиции, называется го- ловной узел). Узел представляется объектом класса ListNode, который по- лучается из класса Node. Класс ListNode обладает компонентом данных _val, который указывает на фактический элемент. Список является коллекцией элементов некоторого заданного типа. Од- нако нет необходимости встраивать специфический тип элемента в описание класса ListNode или List, поскольку операции над списками действуют совершенно независимо от типа элемента. По этой причине мы определим эти классы как шаблоны класса. Тип элемента задается в виде параметра шаблона класса. Впоследствии, когда нам потребуется список элементов не- которого специфичного типа, мы обратимся к шаблону класса с заданным типом элемента, и тогда шаблон класса будет использован для создания фактического класса с этим типом элемента. Шаблон класса ListNode определяется следующим образом: template<class Т> class ListNode : public Node { public: T _val; ListNode(T val); friend class List<T>; }; Здесь через T обозначен параметр типа. Для объявления конкретной ре- ализации ListNode вместо параметра Т необходимо подставить название типа. Например, объявление List Node <int *> a, b;
Структуры данных 49 определяет а и Ь как объекты ListNode, каждый из них содержит указа- тель_на_целое (pointer_to_int). Конструктор ListNode может быть определен подобно template<class Т> ListNode::ListNode(Т val) _val(val) { } Конструктор ListNode неявно инициирует конструктор для базового класса Node, поскольку последний конструктор не имеет аргументов. Мы не определяем здесь деструктора класса ListNode. Как только уда- ляется объект ListNode, автоматически инициируется деструктор базового класса Node::~Node, объявленный как виртуальный. Заметим, что эле- мент, указанный элементом данных ListNode: :_val не разрушается. Было бы безопасно разрушить элемент только в том случае, если бы было известно, что он создан как новый (new), но, нет никакой гарантии такой ситуации. Вернемся к определению шаблона класса List. Класс List содержит три компонента данных: header указывает на головной узел, соответствующий головной позиции; win указывает на узел, который находится в позиции те- кущего окна; параметр _length содержит длину списка. Шаблон класса оп- ределяется следующим образом: template<class Т> class List { private: ListNode<T> *header; ListNode<T> *win; int _length; public: List (void); -List (void); T insert(T); T append(T); List *append(List *); T prepend(T); T remove(void); void val(T); T val(void); T next(void); T prev(void); T first(void); T last(void); int length(void); bool isFirst(void); bool isLast(void); bool isHead(void); Для упрощения нашей реализации будем предполагать, что элементами списка являются указатели на объекты данного типа. Тогда объявление List<Polygon *> р;
50 Глава 3 определяет, что р будет списком указателей_на_полигоны (Polygons), тогда как объявление List<Polygon> q; будет ошибочным. 3.3.1. Конструкторы и деструкторы Конструктор List создает и инициализирует пустой список, представлен- ный одним головным узлом, со ссылками на самого себя: template<class Т> List<T>::List(void) _length(0) { header = new ListNode<T> (NULL); win = header; ) Деструктор класса разрушает узлы связанного списка: template<class Т> List<T>::~List (void) { while (length() > 0) { first(); remove (); ) delete header; ) Заметим, что деструктор не разрушает сами элементы данных. 3.3.2. Изменение списков Компонентные функции insert, prepend и append используются для включения в список новых элементов. Ни одна из этих трех функций не изменяет позиции окна. Функция insert заносит новый элемент после окна и возвращает указатель на новый элемент: template<class Т> Т List<T>::insert(Т val) { win->insert(new ListNode<T>(val) ); ++_length; return val; ) Компонентные функции prepend и append вставляют новый элемент в начало или в конец списка соответственно и возвращают указатель на новый элемент: template<class Т> Т List<T>::prepend(Т val) { header->insert (new ListNode<T> (val) ); ++_length;
Структуры данных 51 return val; ) template<class T> T List<T>::append(T val) { header->prev() ->insert (new ListNode<T> (val) ); ++_length; return val; ) Вторая версия компонентной функции append используется для добав- ления списка 1 в конец текущего списка — первый элемент списка 1 по- мещается после последнего элемента текущего списка. В процессе выполнения список 1 становится пустым. Возвращается указатель на текущий список: template<class Т> List<T>* List<T>::append(List<T> *1) { ListNode<T> *a = (ListNode<T>*) header->prev(); a->splice(l->header); —length += l-> _length; l->header->remove(); l->_length =0; l->win = header; return this; ) Компонентная функция remove удаляет элемент, находящийся в окне, перемещает окно в предыдущую позицию и возвращает указатель на только что удаленный элемент. Если окно находится в головной позиции, то таких действий не производится: template<class Т> Т List<T>::remove(void) { if (win == header) return NULL; void *val = win->_val; win = (ListNode<T>*)win->prev(); delete (ListNode<T>*)win->next()->remove(); —_length; return val; ) Если вызывается компонентная функция val с именем некоторого эле- мента v, то происходит замена элемента, находящегося в текущем окне, на элемент v. Если окно находится в головной позиции, то никаких действий не производится. void List<T>::val (Т v) { if (win != header) win->_val = v; )
52 Глава 3 3.3.3. Доступ к элементам списка Если обращение к компонентной функции val происходит без аргумен- тов, то возвращается элемент, находящийся в окне, или нуль NULL, если окно располагается в головной позиции: template<class Т> Т List<T>::val(void) { return win—>_val; } Компонентные функции next и prev перемещают окно в следующую или предыдущую позиции соответственно. Каждая из них возвращает указатель на элемент, расположенный в новой позиции окна. Заметим, что класс List обеспечивает операцию «заворачивания». Например, если окно находится в последней позиции, то выполнение операции next переместит окно в го- ловную позицию, еще одно обращение к next переместит окно в первую позицию. template<class Т> Т List<T>::next(void) { win = (ListNode<T>*) win—>next(); return win—>_val; ) template<class T> T List<T>::prev(void) { win = (ListNode<T>*) win—>prev(); return win->_val; ) Компонентные функции first и last переносят окно в первую и пос- леднюю позиции соответственно, они не производят никаких действий, если лист пустой. Каждая из этих функций возвращает указатель на элемент, записанный в новом положении окна: template<class Т> Т List<T>::first(void) { win = (ListNode<T>*) header—>next(); return win->_val; ) template<class T> T List<T>::last(void) { win = (ListNode<T>*) header->prev(); return win—>_val; ) Компонентная функция length возвращает значение длины списка: template<class Т> int List<T>::length(void) { return _length; )
Структуры данных 53 Компонентные функции isFirst, isLast и isHead возвращают значе- ние TRUE (истина) только в случаях, если окно находится в первой, пос- ледней или головной позициях соответственно. template<class Т> bool List<T>::isFirst(void) { return ( (win == header->next() ) && (_length >0) ); } template<class T> bool List<T>::isLast(void) { return ( (win == header->prev() ) && (_length >0) ); ) template<class T> bool List<T>::isHead(void) ( return (win == header); } 3.3.4. Примеры списков Два простых примера иллюстрируют использование списков. В первом примере шаблон функции arrayToList загружает п элементов массива а в список и возвращает указатель на этот список: template<class Т> List<T> *arrayToList(Т а[], int п) { List<T> *s = new List<T>; for (int i = 0; i < n; i++) s->append(a[i]); return s; ) Например, если а представляет собой массив строк, то следующий фраг- мент программы преобразует массив а в список строк s : char *а[20]; // здесь должен быть определен массив а List<char*> *s = arrayToList(а, 20); Шаблон функции arrayToList мы будем впоследствии использовать как утилиту в некоторых программах. В качестве второго примера рассмотрим шаблон функции leastitem, ко- торая возвращает наименьший элемент в списке s. Два элемента сравни- ваются с помощью функции стр, в результате работы которой возвраща- ются значения -1, 0 или 1, если первый аргумент соответственно меньше, равен или больше второго аргумента: template<class Т> Т leastitem(List<T> &s, int(*cmp) (T,T) ) {
54 Глава 3 int i; if (s.length() == 0) return NULL; T v = s.firstO ; for (s.nextO; !s.isHead(); s.next() ) if (amp(s.val(), v) < 0) v = s.val(); return v; } Для определения, какой из списков строк появляется раньше в алфавит- ном порядке, следует обратиться к функции leastitem с функцией срав- нения strcmp. Это стандартная библиотечная функция языка C++, которая при обработке двух строк выдает значения -1, 0 или 1 в зависимости от того, будет ли первая строка меньше, равна или больше второй строки в алфавитном порядке. Например, в результате работы следующего фрагмента программы будет напечатана строка ant-. List<char*> s; s.append("bat") ; s.append("ant"); s.append("cat") ; cout « leastitem(s, strcmp); 3.4. Стеки В списках доступ к любому элементу ничем не ограничивается. Но суще- ствуют также и такие списковые структуры данных, в которых доступ к эле- ментам ограничен, наиболее важная из них — стековый список, или просто стек (stack). В стеке доступ ограничен только к элементу, который внесен в него самым последним. По этой причине он имеет также название списка по принципу «последним пришел, первым вышел» (списки типа LIFO). Основными операциями для стека являются операции проталкивания (push) и выталкивания (pop). При выполнении операции проталкивания элемент заносится в стек, а операция выталкивания выбирает из стека эле- мент, записанный в него самым последним. Образно это можно представить как укладывание элементов одного над другим таким образом, что имеется доступ только к самому верхнему элементу. Операция push заносит элемент на самый верх, а операция pop удаляет верхний элемент и возвращает его вызывающей программе. Из операций со стеком можно назвать операцию empty (пусто), которая возвращает значение TRUE (истина), если стек пуст, и специальные операции для вызова элементов из стека без их удаления (см. рис. 3.7). push(2) push(3) POPO push(5) Рис. 3.7. Стек в процессе работы
Структуры данных 55 Самая простейшая реализация стека использует массив. Стек из п эле- ментов хранится в элементах некоторого массива s от s [0] до s[n-l], при этом число элементов (п) записывается в некоторой целочисленной перемен- ной top. Элемент массива s [0] содержит самый нижний элемент стека, а s[top-l] — самый верхний элемент. Список элементов растет до самого верхнего индекса по мере выполнения операций проталкивания (push) и уменьшается при выполнении операций выталкивания. Практически для за- писи элемента х в стек выполняется оператор s[top++]=x, а для вытал- кивания возвращается значение s[—top]. Единственной проблемой при такой реализации остается то, что она нединамична — длина массива ог- раничивает размер стека. Далее мы рассмотрим реализацию „стека на основе шаблона класса List из предыдущего раздела. Тогда наши стеки будут ди- намичными, поскольку объекты класса List динамичны. Шаблон класса Stack содержит собственный элемент данных s, который указывает на объект List, представляющий собой стек. Список упорядочен с верха стека до его низа, в частности верхний элемент стека находится в первой позиции списка, а нижний элемент — в последней позиции списка. template<class Т> class Stack { private: List<T> *s; public: Stack(void); -Stack(void); void push(T v); T pop(void); bool empty(void); int size(void); T top(void); T nextToTop(void); T bottom(void); J; Реализация компонентных функций очевидна. Конструктор Stack созда- ет объект List и приписывает его элементу данных s : template<class Т> Stack<T>::Stack(void) s(new List<T>) { ) Деструктор -Stack удаляет объект List, на который указывает элемент данных s: template<class Т> Stack<T>::-Stack(void) ( delete s ) Компонентная функция push проталкивает элемент v в текущий стек, а компонентная функция pop выбирает из текущего стека самый верхний элемент и возвращает его:
56 Глава 3 template<class Т> void Stack<T>::push(Т v) { s->prepend(v); ) template<class T> T Stack<T>::pop(void) { s->first(); return s->remove(); ) Компонентная функция empty возвращает значение TRUE (истина) в том случае, когда текущий стек пустой, а компонентная функция size возвра- щает число элементов в текущем стеке: template<class Т> bool Stack<T>::empty(void) { return (s->length() == 0); ) template<class T> int Stack<T>::size(void) { return s->length(); ) Полезность операций «вызова» будет очевидна из следующих глав. Функ- ция top возвращает самый верхний элемент в стеке, функция nextToTop возвращает элемент, лежащий непосредственно под самым верхним, а функ- ция bottom возвращает самый нижний элемент. Ни одна из этих функций вызова не изменяет состояния стека (ничто не заносится и не выбирается). template<class Т> Т Stack::top(void) { return s->first(); ) template<class T> T Stack::nextToTop(void) { s->first(); return s->next(); ) template<class T> T Stack::bottom(void) { return s->last(); ) На простейшем примере использования стека покажем, как с помощью следующей функции можно изменить порядок следования строк в массиве а: void reverse(char *а[], int n) { Stack<char*> s; for (int i = 0; i < n; i++)
Структуры данных 57 s.push(a[i]); for (i = 0; i < n; i++) a[i] = s.pop(); } Класс Stack является хорошим примером абстрактного типа данных. Класс обеспечивает общедоступный интерфейс, состоящий из возможных операций push, pop, empty, конструктора и деструктора класса и опе- раций вызова. Стеком можно манипулировать только через этот общий ин- терфейс. Структура памяти (список) скрыта в собственной части определе- ния класса и не может быть изменена или доступна иным путем, кроме как через интерфейс. Программам, использующим стеки, совсем не нужно ничего знать о реализации стека. Например, функция reverse будет ра- ботать соверхенно одинаково, независимо от того, будет ли стек реализован на основе списка или массива. Заметим, что мы реализовали АТД стека в терминах АТД списка. Такая реализация гораздо проще, чем реализация, основанная непосредственно на таких программных блоках низкого уровня, как связанные списки или мас- сивы. Опасность реализации АТД в терминах другого АТД заключается в том, что первый АТД наследует характеристики второго, реализация кото- рого может быть неэффективной. Но в данном случае, применяя свою соб- ственную реализацию АТД списка, мы хорошо представляем ее свойства и поэтому можем легко показать, что каждая из операций, поддерживаемая классом Stack, выполняется за постоянное время. 3.5. Двоичные деревья поиска 3.5.1. Двоичные деревья Хотя списки очень эффективны для включения и удаления элементов и переустройства их порядка, они не эффективны для целей поиска. Поиск конкретного элемента х требует пошагового просмотра всего списка элемен- тов на предмет их соответствия х. Поиск соответствия может потребовать обращения ко многим элементам, и если список не содержит заданного эле- мента х, то и ко всем элементам списка. Даже если список отсортирован, то при поиске приходится обратиться к каждому элементу, предшествую- щему искомому. Двоичные деревья представляют более эффективный способ поиска. Дво- ичное дерево представляет собой структурированную коллекцию узлов. Кол- лекция может быть пустой и в этом случае мы имеем пустое двоичное де- рево. Если коллекция непуста, то она подразделяется на три раздельных семейства узлов: корневой узел п (или просто корень), двоичное дерево, на- зываемое левым поддеревом для п, и двоичное дерево, называемое правым поддеревом для п. На рис. 3.8а узел, обозначенный буквой А, является кор- невым, узел В называется левым потомком А и является корнем левого поддерева А, узел С называется правым потомком А и является корнем правого поддерева А. Двоичное дерево на рис. 3.8а состоит из четырех внутренних узлов (обо- значенных на рис. кружками) и пяти внешних (конечных) узлов (обозна-
58 Глава 3 Рис. 3.8. Двоичное дерево с показанными внешними узллами (а) и без них (6) чены квадратами). Размер двоичного дерева определяется числом содержа- щихся в нем внутренних узлов. Внешние узлы соответствуют пустым дво- ичным деревьям. Например, левый потомок узла В — непустой (содержит узел D), тогда как правый потомок узла В — пустое дерево. В некоторых случаях внешние узлы обозначаются каким-либо образом, в других — на них совсем не ссылаются и они считаются пустыми двоичными деревьями (на рис. 3.86 внешние узлы не показаны). Основанная на генеалогии метафора дает удобный способ обозначения узлов внутри двоичного дерева. Узел р является родителем (или предком) узла п, если п — потомок узла р. Два узла являются братьями, если они принадлежат одному и тому же родителю. Для двух заданных узлов щ и nk, таких, что узел nk принадлежит поддереву с корнем в узле ni, говорят, что узел nk является потомком узла п\, а узел щ — предком узла пк. Су- ществует уникальный путь от узла п\ вниз к каждому из потомков nk, а именно: последовательность узлов ni и П2,... пу. такая, что узел щ является родителем узла щ+1 для i = 1, 2,..., /г-1. Длина пути равна числу ребер (/г-1), содержащихся в нем. Например, на рис. 3.8а уникальный путь от узла А к узлу D состоит из последовательности А, В, D и имеет длину 2. Глубина узла п определяется рекурсивно: глубина (П) = ( ? , ... если П ~ корневой Узел 11+ глубина (родителя (п)) в противном случае Глубина узла равна длине уникального пути от корня к узлу. На рис. 3.8а узел А имеет глубину 0, а узел D имеет глубину, равную 2. Высота узла п также определяется рекурсивно: _ „ „ _ I 0 если п — внешний узел высота (л,) < * v 1 1 + max (высота {лев (п)), высота (прав (п))) в противном случае где через лев(п) обозначен левый потомок узла п и через прав(п) — правый потомок узла п. Высота узла п равна длине самого длинного пути от узла п вниз до внешнего узла поддерева п. Высота двоичного дерева определяется
Структуры данных 59 как высота его корневого узла. Например, двоичное дерево на рис. 3.8а имеет высоту 3, а узел D имеет высоту 1. При реализации двоичных деревьев, основанной на точечном представ- лении, узлы являются объектами класса TreeNode. template<class Т> class TreeNode { protected: TreeNode *_1chiId; TreeNode *_rchild; T val ; public: TreeNode(T) ; virtual -TreeNode(void); friend class SearchTree<T>; friend class BraidedSearchTree<T>; ); Элементы данных _lchild и _rchild обозначают связи текущего узла с левым и правым потомками соответственно, а элемент данных val содер- жит сам элемент. Конструктор класса формирует двоичное дерево единичного размера — единственный внутренний узел имеет два пустых потомка, каждое из ко- торых представлено нулем NULL : template<class Т> TreeNode<T>::TreeNode(Т v) : val(v), _lchild(NULL), _rchild(NULL) { ) Деструктор -TreeNode рекурсивно удаляет оба потомка текущего узла (если они существуют) перед уничтожением самого текущего узла: template<class Т> TreeNode<T>::-TreeNode(void) { if (_lchild) delete _lchild; if (_rchild) delete _rchild; ) 3.5.2. Двоичные деревья поиска Основное назначение двоичных деревьев заключается в повышении эффек- тивности поиска. При поиске выполняются такие операции, как нахождение заданного элемента из набора различных элементов, определение наибольшего или наименьшего элемента в наборе, фиксация факта, что набор содержит за- данный элемент. Для эффективного поиска внутри двоичного дерева его эле- менты должны быть организованы соответствующим образом. Например, дво- ичное дерево будет называться двоичным деревом поиска, если его элементы расположены так, что для каждого элемента п все элементы в левом под- дереве п будут меньше, чем п, а все элементы в правом поддереве — будут больше, чем п. На рис. 3.9 изображены три двоичных дерева поиска, каж- дое из которых содержит один и тот же набор целочисленных элементов.
М Глава 3 Рис. 3.9. Три двоичных дерева поиска с одним и тем же набором элементов В общем случае существует огромное число двоичных деревьев поиска (раз- личной формы) для любого заданного набора элементов. Предполагается, что элементы располагаются в линейном порядке и, сле- довательно, любые два элемента можно сравнить между собой. Примерами линейного порядка могут служить ряды целых или вещественных чисел в порядке возрастания, а также слов или строк символов, расположенных в лексикографическом (алфавитном, или словарном) порядке. Поиск осущест- вляется путем обращения к функции сравнения для сопоставления любых двух элементов относительно их линейного порядка. (Напомним о разде- ле 3.3, где была описана функция, которой передаются два элемента и ко- торая возвращает значения -1, 0 или 1 в зависимости от того, будет ли пер- вый элемент меньше, равен или больше второго соответственно). В нашей версии деревьев поиска действие функции сравнения ограничено только явно определенными объектами деревьев поиска. Также очень полезны функции для обращения к элементам дерева по- иска и воздействия на них. Такие функции обращения могут быть полезны для вывода на печать, редактирования и доступа к элементу или воздей- ствия на него каким-либо иным образом. Функции обращения не принад- лежат деревьям поиска, к элементам одного и того же дерева поиска могут быть применены различные функции обращения. 3.5.3. Класс SearchTree (дерево поиска) Определим шаблон нового класса SearchTree для представления двоич- ного дерева поиска. Класс содержит элемент данных root, который ука- зывает на корень двоичного дерева поиска (объект класса TreeNode) и эле- мент данных стр, который указывает на функцию сравнения. template<class Т> class SearchTree { private: TreeNode *root; int (*) (T,T) cmp; TreeNode<T> *_findMin(TreeNode<T> *); void _remove(T, TreeNode<T> * &) ; void _inorder(TreeNode<T> *, void (*) (T) ); public:
Структуры данных 61 SearchTree (Т,Т) ); -SearchTree(void); int isEmpty(void); T find(T); T findMin(void); void inorder(void(*) (T) ); void insert(T); void remove(T); T removeMin(void); ); Для упрощения реализации предположим, что элементами в дереве по- иска являются указатели на объект заданного типа, когда шаблон класса SearchTree используется для создания действительного класса. Параметр Т передается в виде типа указателя. 3.5.4. Конструкторы и деструкторы Конструктор SearchTree инициализирует элементы данных стр для функции сравнения и root для пустого дерева поиска: template<class Т> SearchTree<T>::SearchTree(int(*с) (Т,Т) ) : cmp(с), root(NULL) { ) Дерево поиска пусто только, если в элементе данных root содержится нуль (NULL) вместо разрешенного указателя: template<class Т> int SearchTree<T>::isEmpty(void) { return (root == NULL); ) Деструктор удаляет все дерево путем обращения к деструктору корня: template<class Т> SearchTree<T>::-SearchTree(void) { if (root) delete root; ) 3.5.5. Поиск Чтобы найти заданный элемент val, мы начинаем с корня и затем сле- дуем вдоль ломаной линии уникального пути вниз до узла, содержащего val. В каждом узле п вдоль этого пути используем функцию сравнения для данного дерева на предмет сравнения val с элементом n->val, записанном в п. Если val меньше, чем n->val, то поиск продолжается, начиная с ле- вого потомка узла п, если val больше, чем n->val, то поиск продолжается, начиная с правого потомка п, в противном случае возвращается значение n->val (и задача решена). Путь от корневого узла вниз до val называется путем поиска для val.
69 Глава 3 Этот алгоритм поиска реализуется в компонентной функции find, кото- рая возвращает обнаруженный ею указатель на элемент или NULL, если такой элемент не существует в дереве поиска. template<class Т> Т SearchTree::find(Т val) { TreeNode *n = root; while (n) { int result = (*cmp) (val, n->val); if (result < 0) n = n->_lchild; else if (result > 0) n = n->_rchild; else return n->val; ) return NULL; ) Этот алгоритм поиска можно сравнить с турниром, в котором участвуют некоторые кандидаты. В начале, когда мы начинаем с корня, в состав кан- дидатов входят все элементы в дереве поиска. В общем случае для каждого узла п в состав кандидатов входят все потомки п. На каждом этапе произ- водится сравнение val с n->val. Если val меньше, чем n->val, то состав кандидатов сужается до элементов, находящихся в левом поддереве, а эле- менты в правом поддереве п, как и сам элемент n->val, исключаются из соревнования. Аналогичным образом, если val больше, чем n->val, то состав кандидатов сужается до правого поддерева п. Процесс продолжается до тех пор, пока не будет обнаружен элемент val или не останется ни одного кан- дидата, что означает, что элемент val не существует в дереве поиска. Для нахождения наименьшего элемента мы начинаем с корня и просле- живаем связи с левым потомком до тех пор, пока не достигнем узла п, левый потомок которого пуст — это означает, что в узле п содержится на- именьший элемент. Этот процесс также можно уподобить турниру. Для каждого узла п состав кандидатов определяется потомками узла п. На каж- дом шаге из состава кандидатов удаляются те элементы, которые больше или равны n->val и левый потомок п будет теперь выступать в качестве нового п. Процесс продолжается до тех пор, пока не будет достигнут не- который узел п с пустым левым потомком и, полагая, что не осталось кан- дидатов меньше, чем n->val, и будет возвращено значение n->val. Компонентная функция findMin возвращает наименьший элемент в дан- ном дереве поиска, в ней происходит обращение к компонентной функ- ции _findMin, которая реализует описанный ранее алгоритм поиска, начи- ная с узла п : template<class Т> Т SearchTree<T>::findMin(void) { TreeNode<T> *n = _f indMin(root); return (n ? n->val : NULL); ) template<class T> TreeNode<T> *SearchTree<T>:: findMin(TreeNode<T> *n)
Структуры данных 63 { if (n == NOLL) return NULL; while (n->_lchild) n = n->_lchild; return n; ) Наибольший элемент в дереве поиска может быть найден аналогично, только отслеживаются связи с правым потомком вместо левого. 3.5.6. Симметричный обход Обход двоичного дерева — это процесс, при котором каждый узел по- сещается точно только один раз. Компонентная функция inorder выпол- няет специальную форму обхода, известную как симметричный обход. Стра- тегия заключается сначала в симметричном обходе левого поддерева, затем посещения корня и потом в симметричном обходе правого поддерева. Узел посещается путем применения функции обращения к элементу, записанному в узле. Компонентная функция inorder служит в качестве ведущей функции. Она обращается к собственной компонентной функции _inorder, которая выполняет симметричный обход от узла п и применяет функцию visit к каждому достигнутому узлу. template<class Т> void SearchTree<T>::inorder(void(*visit) (T) ) { _inorder (root, visit); ) template<class T> void SearchTree::inorder(TreeNode<T> *n, void(*visit) (T) ) ( if (n) { _inorder(n->_lchild, visit); (♦visit) (n->val); _inorder(n->_rchild, visit); ) ) При симметричном обходе каждого из двоичных деревьев поиска, пока- занных на рис. 3.9, узлы посещаются в возрастающем порядке: 2, 3, 5, 7, 8. Конечно, при симметричном обходе любого двоичного дерева поиска все его элементы посещаются в возрастающем порядке. Чтобы выяснить, почему это так, заметим, что при выполнении симметричного обхода в некотором узле п элементы меньше, чем n->val посещаются до п, поскольку они при- надлежат к левому поддереву п, а элементы больше, чем n->val посеща- ются после п, поскольку они принадлежат правому поддереву п. Следова- тельно, узел п посещается в правильной последовательности. Поскольку п — произвольный узел, то это же правило соблюдается для каждого узла. Компонентная функция inorder обеспечивает способ перечисления элемен- тов двоичного дерева поиска в отсортированном порядке. Например, если а яв-
64 Глава 3 ляется деревом поиска SearchTree для строк, то эти строки можем напечатать в лексикографическом порядке одной командой a. inorder (printstring). Для этого функция обращения print St ring может быть определена как: void printstring(char *s) { cout « s « "\n"; } При симметричном обходе двоичного дерева узел, посещаемый после не- которого узла п, называется последователем узла п, а узел, посещаемый непосредственно перед п, называется предшественником узла п. Не суще- ствует никакого предшественника для первого посещаемого узла и ника- кого последователя для последнего посещаемого узла (в двоичном дереве поиска эти узлы содержат наименьший и наибольший элемент соответст- венно). 3.5.7. Включение элементов Для включения нового элемента в двоичное дерево поиска вначале нужно определить его точное положение — а именно внешний узел, который дол- жен быть заменен путем отслеживания пути поиска элемента, начиная с корня. Кроме сохранения указателя п на текущий узел мы будем хранить указатель р на предка узла п. Таким образом, когда п достигнет некоторого внешнего узла, р будет указывать на узел, который должен стать предком нового узла. Для осуществления включения узла мы создадим новый узел, содержащий новый элемент, и затем свяжем предок р с этим новым узлом (рис. 3.10). Компонентная функция insert включает элемент val в это двоичное дерево поиска: template<class Т> void SearchTree<T>::insert(Т val) { if (root == NULL) { root = new TreeNode<T>(val); return; ) else { int result; TreeNode<T> *p, *n = root; Рис. 3.10. Включение элемента в двоичное дерево поиска
Структуры данных 65 while (n) { Р = n; result = (*cmp) (val, p->val); if (result < 0) n = p—>_lchild; else if (result > 0) n = p->_rchild; else return; } if (result < 0) p->_lchild = new TreeNode<T>(val); else p->_rchild = new TreeNode<T>(val); 3.5.8. Удаление элементов Удаление элемента из двоичного дерева поиска гораздо сложнее, чем включение, поскольку может быть значительно изменена форма дерева. Уда- ление узла, у которого есть не более чем один непустой потомок, является сравнительно простой задачей — устанавливается ссылка от предка узла на этот потомок. Однако ситуация становится гораздо сложнее, если у подле- жащего удалению узла есть два непустых потомка: предок узла может быть связан с одним из потомков, а что делать с другим? Решение может за- ключаться не в удалении узла из дерева, а скорее в замене элемента, со- держащегося в нем, на последователя этого элемента, а затем в удалении узла, содержащего этот последователь. Чтобы удалить элемент из дерева поиска, вначале мы отслеживаем путь поиска элемента, начиная с корня и вниз до узла п, содержащего элемент. В этот момент могут возникнуть три ситуации, показанные на рис. 3.11: 1. Узел п имеет пустой левый потомок. В этом случае ссылка на п (за- писанная в предке п, если он есть) заменяется на ссылку на правого потомка п. Рис. 3.11. Три ситуации, возникающие при удалении элемента из двоичного дерева поиска 3 Заказ 2794
66 Глава 3 2. У узла п есть непустой левый потомок, но правый потомок пустой. В этом случае ссылка вниз на п заменяется ссылкой на левый потомок узла п. 3. Узел п имеет два непустых потомка. Найдем последователя для п (назовем его т), скопируем данные, хранящиеся в т, в узел п и затем рекурсивно удалим узел т из дерева поиска. Очень важно проследить, как будет выглядеть результирующее двоичное дерево поиска в каждом случае. Рассмотрим случай 1. Если подлежащий удалению узел п, является левым потомком, то элементы, относящиеся к правому поддереву п будут меньше, чем у узла р, предка узла п. При уда- лении узла п его правое поддерево связывается с узлом р и элементы, хра- нящиеся в новом левом поддереве узла р конечно остаются меньше элемента в узле р. Поскольку никакие другие ссылки не изменяются, то дерево ос- тается двоичным деревом поиска. Аргументы остаются подобными, если узел п является правым потомком, и они тривиальны, если п — корневой узел. Случай 2 объясняется аналогично. В случае 3 элемент v, записанный в узле п, перекрывается следующим большим элементом, хранящимся в узле m (назовем его w), после чего элемент w удаляется из дерева. В по- лучающемся после этого двоичном дереве значения в левом поддереве узла и будут меньше w, поскольку они меньше v. Более того, элементы в правом поддереве узла п больше, чем w, поскольку (1) они больше, чем v, (2) нет ни одного элемента двоичного дерева поиска, лежащего между v и w и (3) из них элемент w был удален. Заметим, что в случае 3 узел m должен обязательно существовать, по- скольку правое поддерево узла п непустое. Более того, рекурсивный вызов для удаления m не может привести к срыву рекурсивного вызова, поскольку если узел m не имел бы левого потомка, то был бы применен случай 1, когда его нужно было бы удалить. На рис. 3.12 показана последовательность операций удаления, при ко- торой возникают все три ситуации. Напомним, что симметричный обход каждого дерева в этой последовательности проходит все узлы в возраста- ющем порядке, проверяя, что в каждом случае это двоичные деревья по- иска. Компонентная функция remove является общедоступной компонентной функцией для удаления узла, содержащего заданный элемент. Она обраща- ется к собственной компонентной функции remove, которая выполняет фак- тическую работу: Рис. 3.12. Последовательность операций удаления элемента: (а) и (б) — Случай 1: удаление из двоичного дерева элемента 8; (б) и (в) — Случай 2: удаление элемента 5; (в) и (г) — Случай 3: удаление элемента 3
Структуры данных 67 template<class Т> void SearchTree<T>::remove(T val) { remove(val, root); } template<class T> void SearchTree<T>::_remove(T val, TreeNode<T> * &n) ( if (n == NULL) return; int result = (*стр) (val, n->val); if (result < 0) remove(val, n->_lchild); else if (result > 0) _remove(val, n->_rchild); else ( // случаи 1 if (n->_lchild == NULL) { TreeNode<T> *old = n; n = old->rchiId; delete old; } else if (n->_rchild == NULL) { // случаи 2 TreeNode<T> *old = n; n = old->lchild; delete old; ) else ( // случаи 3 TreeNode<T> *m = _findMin(n->_ n->val = m->val; rchild); _remove(m->val, n->_rchild); } } } Параметр n (типа ссылки) служит в качестве псевдонима для поля ссыл- ки, которое содержит ссылку вниз на текущий узел. При достижении узла, подлежащего удалению (old), п обозначает поле ссылки (в предке узла old), содержащее ссылку вниз на old. Следовательно команда n=old->rchild за- меняет ссылку на old ссылкой на правого потомка узла old. Компонентная функция removeMin удаляет из дерева поиска наимень- ший элемент и возвращает его: template<class Т> Т SearchTree<T>::removeMin(void) { Т v = findMin() ; remove(v); return v; } Древовидная сортировка — способ сортировки массива элементов — реализуется в виде простой программы, использующей деревья поиска. Идея заключается в занесении всех элементов в дерево поиска и затем в интерактивном удалении наименьшего элемента до тех пор, пока не будут удалены все элементы. Программа heapSort сортирует массив s из п эле- ментов, используя функцию сравнения стр :
68 Глава 3 template<class Т> void heapSort (T s[], int n, int(*cmp) (T,T) ) { SearchTree<T> t(cmp); for (int i = 0; i < n; i++) t.insert(s[i]) ; for (i = 0; i < n; i++) s[i) = t.removeMin(); } 3.6. Связанные двоичные деревья поиска Существует одна задача, которая не может быть эффективно решена с по- мощью двоичных деревьев поиска: для заданного элемента в двоичном де- реве поиска найти ближайшие больший и меньший элементы. Хотя при симметричном обходе все элементы выстраиваются в порядке увеличения, это не позволяет эффективно определить предшественника и последователя для произвольного элемента. В этом разделе мы рассмотрим связанные дво- ичные деревья поиска, которые являются двоичными деревьями поиска со связным списком, охватывающим все узлы в порядке возрастания. Для краткости связанное двоичное дерево поиска будем называть связанным деревом поиска, а охватывающий его связный список — лентой. Рис. 3.13. Связанное двоичное дерево поиска с головным узлом. Связи ленты представлены более светлыми дугами Связанное дерево поиска реализуем в виде класса BraidedSearchTree. Этот класс отличается от класса SearchTree из предыдущего раздела по трем существенным признакам. Во-первых, объекты класса BraidedSearchTree обладают связным списком (лентой), который для каждого узла устанавли- вает ссылки на предшественника и последователя. Во-вторых, объекты класса BraidedSearchTree поддерживают окно, которое в любой момент времени располагается над каким-либо элементом в дереве. Окно служит той же цели, что и в классе List: существует целый ряд операций с окном или с элемен- том в окне. В-третьих, элемент root класса BraidedSearchTree указывает на головной узел — «псевдокорневой узел», правый потомок которого явля- ется фактическим корнем связанного дерева поиска. Если посмотреть на ленту, то узел, содержащий наименьший элемент в дереве, следует за голов- ным узлом, а узел, содержащий самый большой элемент, предшествует го- ловному узлу. Следовательно, головной узел соответствует головной позиции, которая располагается одновременно перед первой позицией и после послед- ней (рис. 3.13).
Структуры данных 69 3.6.1. Класс BraidedNode Узлы связанного дерева поиска являются объектами класса BraidedNode. Поскольку эти узлы действуют одновременно как узлы дерева и как узлы списка, то шаблон класса BraidedNode составим на основе классов TreeNode и Node: template<class Т> class BraidedNode : public Node, public TreeNode<T> { public: BraidedNode (T); BraidedNode<T> *rchild(void); BraidedNode<T> *lchild (void); BraidedNode<T> ‘next (void); BraidedNode<T> *prev(void); friend class BraidedSearchTree<T>; ); Конструктор класса BraidedNode явно инициализирует базовый класс TreeNode по своему списку инициализации, а базовый класс Node инициализируется неявно, поскольку его конструктор не имеет параметров: template<class Т> BraidedNode<T>::BraidedNode(Т val) : TreeNode<T> (val) { ) Компонентные функции rchild, Ichild, next и prev устанавливают четыре ссылки для текущего узла — первые два для дерева поиска, а пос- ледние два — для ленты: template<class Т> BraidedNode<T> *BraidedNode<T>::rchild(void) { return (BraidedNode<T> *)_rchild; ) template<class T> BraidedNode<T> *BraidedNode<T>::Ichild(void) { return (BraidedNode<T> *) )_lchild; ) template<class T> BraidedNode<T> *BraidedNode<T>::next(void) { return (BraidedNode<T> *)_next; ) template<class T> BraidedNode<T> *BraidedNode<T>::prev(void) { return (BraidedNode<T> *)_prev; )
70 Глава 3 3.6.2. Класс BraidedSearchTree Шаблон класса BraidedSearchTree определяется следующим образом: template<class Т> class BraidedSearchTree { private: BraidedNode<T> ‘root; // головной увел BraidedNode<T> ‘win; // текущее окно int (*cmp) (T,T); // функция сравнения void _remove(T, TreeNode<T> * &) ; public: BraidedSearchTree(int(*) (T,T) ); 'BraidedSearchTree(void); T next(void); T prev(void); void inorder(void(*) (T) ); T val(void); bool isFirst (void); bool isLast(void); bool isHead(void); bool isEmpty(void); T find(T); T findMin(void); T insert(T); void remove(void); T removeMin(void); ) ; 3.6.3. Конструкторы и деструкторы Конструктор класса BraidedSearchTree инициализирует элементы данных стр для функции сравнения и root для пустого дерева, представ- ленного в виде изолированного головного узла: template<class Т> BraidedSearchTree<T>::BraidedSearchTree(int(*c) (T,T) ) : cmp (c) { win = root = new BraidedNode<T>(NULL); ) Деструктор класса удаляет дерево целиком, обращаясь к деструктору го- ловного узла: template<class Т> BraidedSearchTree<T>::'BraidedSearchTree(void) { delete root; ) 3.6.4. Использование ленты Элемент данных win описывает окно для дерева — т. е. элемент win ука- зывает на узел, над которым располагается окно. Компонентные функции
Структуры данных 71 next и prew перемещают окно на следующую или предыдущую позиции. Если окно находится в головной позиции, то функция next перемещает его на первую позицию, а функция prew — на последнюю позицию. Обе функ- ции возвращают ссылку на элемент в новой позиции окна: template<class Т> Т BraidedSearchTree<T>::next(void) { win = win->next(); return win->val; } template<class T> T BraidedSearchTree<T>: :prev(void) { win = win->prev(); return win->val; ) Компонентная функция val возвращает ссылку на элемент внутри окна. Если окно находится в головной позиции, то возвращается значение NULL. template<class Т> Т BraidedSearchTree<T>::val(void) { return win->val; ) Симметричный обход выполняется путем прохода по ленте от первой по- зиции до последней, применяя функцию visit для каждого элемента на своем пути: template<class Т> void BraidedSearchTree<T>::inorder(void (*visit) (T) ) ( BraidedNode<T> *n = root->next(); while (n != root) { (♦visit) (n->val); n = n->next(); ) ) Компонентные функции isFirst, isLast и isHead возвращают значение TRUE (истина), если окно находится в первой, последней или головной по- зициях соответственно. template<class Т> bool BraidedSearchTree<T>::isFirst(void) ( return (win == root->next() ) && (root != root->next() ); ) template<class T> bool BraidedSearchTree<T>::isLast(void) ( return (win == root->prev() ) && (root != root->next() ); ) template<class T> bool BraidedSearchTree<T>::isHead(void) (
п Глава 3 return (win == root); ) Функция isEmpty возвращает значение TRUE (истина) только если теку- щее дерево пусто и состоит только из одиночного головного узла: template<class Т> bool BraidsdSearchTree<T>::isEmpty() { return (root == root->next() ); ) 3.6.5. Поиск Для поиска элемента используется компонентная функция find. Эта функция аналогична функции SearchTree: : find за исключением того, что она начинает свой поиск с root->rchild (), фактического корня. Если эле- мент найден, то окно устанавливается на этом элементе. template<class Т> Т BraidedSearchTree<T>::find(T val) { BraidedNode<T> *n = root->rchild(); while (n) { int result = (*cmp) (val, n->val); if (result < 0) n = n->lchild(); else if (result > 0) n = n->rchild(); else ( win=n; return n->val; ) ) return MULL; ) Наименьший элемент в связанном дереве поиска располагается в первой позиции ленты. Компонентная функция findMin перемещает окно на этот наименьший элемент и возвращает указатель на элемент. Если дерево по- иска пусто, то возвращается NULL : template<class Т> Т BraidedSearchTree<T>::findMin(void) ( win = root->next(); return win->val; ) 3.6.6. Включение элементов Новый элемент должен быть внесен в его правильной позиции как в дерево поиска, так и в ленту. Если новый элемент становится левым потомком, то тем самым он становится предшественником предка в ленте. Если элемент стано- вится правым потомком, то он становится последователем предка в ленте. Ре- ализация функции insert одинакова с функцией SearchTree: :insert за ис-
Структуры данных 73 ключением дополнительного включения нового узла в ленту. Однако следует заметить, что в функции insert нет необходимости проверки включения в пустое дерево, поскольку головной узел присутствует всегда. Функция рас- полагает окно над только что внесенным элементом и возвращает указатель на элемент, если включение произошло успешно. template<class Т> Т BraidedSearchTree<T>::insert(Т val) { int result = 1; BraidedNode<T> *p = root; BraidedNcde<T> *n = root->rchild(); while (n) { P = n; result = (*cmp) (val, p->val); if (result < 0) n = p->lchild(); else if (result > 0) n = p->rchild()t else return NULL; ) win = new BraidedNode<T>(val); if (result < 0) { p->_lchild = win; p->prev()->Node::insert(win); ) else { p->_rchild = win; p->Node::insert(win); ) return val; ) 3.6.7. Удаление элементов Компонентная функция remove удаляет элемент, находящийся в окне и перемещает окно в предыдущую позицию: template<class Т> void BraidedSearchTree< Т>::remove(void) { if (win != root) remove(win->val, root->_rchild); ) Элемент val, подлежащий удалению, и указатель п на корень дерева по- иска, в котором находится этот элемент, передаются в качестве параметров собственной функции _remove. Эта функция работает подобно своему ана- логу с тем же именем в классе SearchTree. Однако при работе со связан- ным деревом поиска элемент, конечно, должен быть удален как из дерева поиска, так и из ленты: template<class Т> void BraidedSearchTree<T>::_remove(Т val, TreeNode<T>* &n) {
74 Глава 3 int result = (*cmp) (val, n->val); if (result < 0) _remove(val, n->_lchild) ; else if (result > 0) _remove(val, n->_rchild); else ( // случай 1 if (n->_lchild == NULL) { BraidedNode<T> *old = (BraidedNode<T>*)n; if (win == old) win = old->prev(); n = old->rchild(); old->Node::remove(); delete old; ) else if (n->_rchild == NULL) { // случай 2 BraidedNode<T> *old = (BraidedNode<T>*)n; if (win == old) win = old->prev(); n = old->lchild(); old->Node::remove(); delete old; ) else { // случай 3 BraidedNode<T> *m = ( (BraidedNode<T>*)n)->next(); n->val = m->val; _remove(m->val, n->_rchild); ) ) ) Заметим, что функция _remove использует ленту для поиска последо- вателя узла п в случае 3, когда подлежащий удалению узел имеет два непустых потомка. Отметим также, что параметр п является ссылкой на тип TreeNode*, а не на тип BraidedNode*. Это происходит потому, что п указывает на ссылку, хранящуюся в предке узла, подлежащего удале- нию, а эта ссылка имеет тип TreeNode*. Если бы вместо этого параметру п был присвоен тип BraidedNode*, то это была бы ошибочная ссылка на анонимный объект. При этом поле ссылки в узле предка было бы недо- ступно. Компонентная функция removeMin удаляет из дерева наименьший эле- мент и возвращает указатель на него, функция возвращает значение NULL, если дерево пусто. Если окно содержало удаляемый наименьший элемент, то окно перемещается в головную позицию, в ином случае оно остается без изменения: template<class Т> Т BraidedSearchTree<T>::removeMin(void) { Т val = root->next()->val; if (root != root->next() ) _remove(val, root->_rchild); return val; )
Структуры данных 75 3.7. Деревья со случайным поиском Поиск осуществляется тем быстрее, чем ближе к корню расположен ис- комый элемент. В идеале двоичное дерево поиска сбалансировано, т. е. его форма такова, что каждый элемент располагается сравнительно близко к корню. Сбалансированное двоичное дерево поиска размера п имеет высоту O(log zi), в предположении, что путь от корня до каждого узла имеет длину не более, чем O(log п). Но совсем несбалансированное дерево поиска имеет высоту П(п); поиск в таком дереве чуть более эффективен, чем в связанном списке (рис. 3.9в). Двоичные деревья поиска, сформированные совместным использованием функций insert, remove и removeMin имеют высоту Q(zi) в наихудшем случае. Однако, если предположить, что из этих трех операций применялась только одна функция insert и что п вводимых элементов поступали в слу- чайном порядке и что все п! перестановок одинаково вероятны, тогда дво- ичное дерево в среднем будет иметь высоту O(log п). Это очень важный ре- зультат, хотя и основанный на предположениях с серьезными ограничениями, в результате чего полученные двоичные деревья поиска не всегда эффективны на практике. Во-первых, во многих приложениях вход- ной поток элементов не обязательно будет случайным (в крайнем случае эле- менты вводятся в возрастающем или убывающем порядке и получающееся двоичное дерево в большинстве случаев оказывается несбалансированным). Во-вторых, порядок операций поиска (перемежающихся с операциями ввода) может быть непредсказуем (поиск недавно введенных элементов может оказаться особенно дорогим, поскольку элемент может оказаться на самом нижнем уровне двоичного дерева). В третьих, ожидаемые свойства деревьев поиска, получившихся после выполнения операций удаления re- move, если она допустима, не всегда хорошо понятны. В этом разделе мы рассмотрим деревья случайного поиска. Идея заклю- чается в том, чтобы сделать поведение дерева поиска зависимым от неко- торого числа, полученного от генератора случайных чисел, а не от порядка ввода. При вводе элемента в дерево случайного поиска этому элементу при- сваивается приоритет — некоторое вещественное число с равномерным рас- пределением в диапазоне [0, 1] (вероятность выбора любого числа в заданном диапазоне одинакова). Приоритеты элементов в дереве случайного поиска оп- Рис. 3.14. Дерево случайного поиска. Приоритеты каждого элемента указаны в виде нижнего индекса
76 Глава 3 ределяют их положение в дереве в соответствии с правилом: приоритет каж- дого элемента в дереве не должен быть более приоритета любого из его пос- ледователей. Правило двоичного дерева поиска также остается справедли- вым: для каждого элемента х элементы в левом поддереве х будут меньше, чем х, а в правом поддереве — больше, чем х. На рис. 3.14 приведен при- мер такого дерева случайного поиска. Как же осуществить ввод некоторого элемента х в дерево случайного по- иска? Он выполняется в два этапа. На первом этапе будем учитывать при- оритет и введем элемент х уже известным нам способом: проследуем по пути поиска элемента х от корня вниз до внешнего узла, которому принадле- жит х, и затем заменим внешний узел новым внутренним узлом, содержа- Рис. 3.16. Внесение элемента 6 в дерево случайного поиска: (а) и (б)—левая ротация элемента 5; (б) и (в) — правая ротация элемента 7; (в) и (г)—левая ротация элемента 4
Структуры данных 77 щим х. На втором этапе мы изменим форму дерева в соответствии с при- оритетами элементов. Сначала случайным образом зададим приоритет для х. В простейшем случае, приоритет х может быть больше и равен приоритету предка, тогда задача оказывается решенной. В противном случае, если при- оритет х оказывается меньше приоритета его предка у, то правило приори- тета для у оказывается нарушенным. Предположим, что элемент х оказался левым наследником для у. В этом случае применим правую ротацию для элемента у, переместив элемент х в дереве на один уровень выше (рис. 3.15). Теперь правило приоритета может оказаться нарушенным для нового предка элемента х. Если это так, то применим ротацию для пред- ка — правую ротацию, если х является левым потомком, или левую ро- тацию, если х является правым потомком, переместив таким образом эле- мент х еще на один уровень вверх. Такой подъем элемента х будем осуществлять до тех пор, пока он не станет либо корневым, либо его при- оритет будет не меньше, чем у его предка. На рис. 3.16 показан процесс внесения элемента. На этой схеме показано, как элемент 6 заменяет соответствующий внешний узел, а затем поднима- ется вверх в соответствии с его приоритетом (.10) и приоритетами его те- кущих предков. Определим теперь соответствующие классы. Несколько позже рассмот- рим, как удалить элемент из дерева случайного поиска. 3.7.1. Класс RandomizedNode Узлы в дереве случайного поиска являются объектами шаблона класса RandomizedNode: template<class Т> class RandomizedNode : public BraidedNode<T> { protected: RandomizedNode *_parent; double _priority; void rotateRight(void); void rotateLeft(void); void bubbleUp(void); void bubbleDown(void); public: RandomizedNode(T v, int seed = -1); RandomizedNode *lchild(void); RandomizedNode *rchild(void); RandomizedNode ‘next(void); RandomizedNode *prev( void); RandomizedNode *parent(void); double priority(void); friend class RandomizedSearchTree<T>; }; Класс RandomizedNode наследует пять элементов данных из его базового класса: val, _lchild, _rchild, _prev и _next. В класс включены два дополнительных элемента данных: _parent, который указывает на узел- предок текущего узла, и _priority, приоритет текущего узла.
78 Глава 3 Конструктор присваивает новому узлу RandomizedNode случайное значе- ние приоритета, используя стандартную функцию языка C++ rand, которая генерирует случайное число в пределах от 0 до RAND__MAX : template<class Т> RandomizedNode<T>::RandomizedNode(Т v, int seed) : BraidedNode<T>(v) { if (seed != -1) srand(seed); _priority = (rand() % 32767) / 32767,0; _parent = NULL; } Наибольший интерес представляют компонентные функции класса RandomizedNode, объявленные как private (собственные). Первые две выпол- няют два вида ротации — правую и левую. Правая ротация является ло- кальной операцией, которая изменяет форму дерева поиска, оставляя не- изменным порядок следования элементов — до и после выполнения операции предшественник и последователь для каждого узла остаются без изменения. Правая ротация для узла у приводит к повороту поддерева с корнем в узле у вокруг связи от узла у к его левому потомку х (рис. 3.15). Операция выполняется путем изменения небольшого числа связей: связь с левым потомком узла у заменяется на связь с поддеревом Т%, связь с правым потомком узла х заменяется на связь с узлом у, а связь с узлом у (от его предка) заменяется на связь с узлом х. Компонентная функция rotateRight выполняет правую ротацию для те- кущего узла, который играет роль узла у. При этом узел х должен быть левым потомком узла у. Для надежности предполагается, что предок узла у существует, поскольку головной узел должен иметь минимальный приори- тет — ни одного другого узла не должно быть на уровне головного узла, поэтому все остальные узлы должны иметь своего предка. template<class Т> void RandomizedNode<T>::rotateRight(void) ( RandomizedNode<T> *y = this; RandomizedNode<T> *x = y->lchild(); RandomizedNode<T> *p = y->parent(); y->_lchild = x->rchild(); if (y->lchild() != NULL) y->lchild()->_parent = y; if (p->rchild() == y) p->_rchild = x; else p->_lchild = x; x->_parent = p; x->_rchild = y; y->_parent = x; ) Действие компонентной функции rotateLeft для левой ротации также показано на рис. 3.15 и она определяется симметрично: template<class Т> void RandomizedNode<T>::rotateLeft(void) (
Структуры данных 7» RandomizedNode<T> *х = this; RandomizedNode<T> *у = x->rchild(); RandomizedNode<T> *p = x->parent(); x->_rchild = y->lchild(); if (x->rchild() != NULL) x->_rchild()->_parent = x; if (p->lchild() == x) p->_lchild = y; else p->_rchild = y; y->_parent = p; y->_lchild = x; x->_parent = y; } Компонентная функция bubbleUp переносит текущий узел вверх по на- правлению к корню путем многократных ротаций до тех пор, пока приори- тет текущего узла не станет болыпе или равным, чем у его предка. Функция применяется в тех случаях, когда элемент в дереве случайного поиска рас- полагается ниже, чем это следует в соответствии со значением его приори- тета. Отметим, что ротация применяется к предку текущего узла. template<class Т> void RandomizedNode<T>::bubbleUp(void) { RandomizedNode<T> *p = parent (); if (priority() < p->priority() ) { if (p->lchild() = this) p->rotateRight() ; else p->rotateLeft(); bubbleUp(); } } Компонентная функция bubbleDown переносит текущий узел вниз по направлению к внешним узлам дерева путем многократных ротаций до тех пор, пока приоритет текущего узла не станет меньше или равным приори- тетам обоих его потомков. Для ротации ее направление (вправо или влево) зависит от того, какой из потомков текущего узла имеет меньший приори- тет. Если, например, приоритет левого потомка меньше, то правая ротация переносит левый потомок вверх на один уровень, а текущий узел переме- щается на один уровень вниз. Каждому внешнему узлу приписывается при- оритет 2.0, достаточно большой, чтобы ошибочно не переместить его вверх. Функция bubbleDown применяется в тех случаях, когда элемент в дереве случайного поиска располагается выше, чем это следует в соответствии со значением его приоритета. Эта функция будет использована впоследствии для удаления элемента из дерева поиска. template<class Т> void RandomizedNode<T>::bubbleDown(void) ( float IcPriority = Ichildf) ? Ichild()->priority() : 2.0; float rePriority = rchildO ? rchild()->priority() : 2.0; float minPriority = (IcPriority < rePriority) ? IcPriority : rePriority;
80 Глава 3 if (priority() <= minPriority) return; if (IcPriority < rePriority) rotateRight(); else rotateLeft(); bubbleDown (); } Общедоступные компонентные функции rchild, Ichild, next, prev и parent определяют связи текущего узла с его правым и левым потомками, с последующим и предшествующим узлами и с предком соответственно: template<class Т> RandomizedNode<T> *RandomizedNode<T>::rchild(void) { return (RandomizedNode<T>*) _rchild; } template<class T> RandomizedNode<T> *RandomizedNode<T>::Ichild(void) ( return (RandomizedNode<T>*) _lchild; } templateCclass T> RandomizedNode<T> *RandomizedNode<T>::next(void) { return (RandomizedNode<T>*)_next; ) template<class T> RandomizedNode<T> *RandomizedNode<T>::prev(void) ( return (RandomizedNode<T>*)_prev; ) template<class T> RandomizedNode<T> *RandomizedNode<T>::parent(void) ( return (RandcaaizedNode<T>*)_parent; ) Компонентная функция priority возвращает приоритет текущего узла: template<class Т> double RandomizedNode<T>: -.priority(void) ( return _priority; ) 3.7.2. Класс RandomizedSearchTree Деревья случайного поиска представляются в виде объектов класса RandomizedSearchTree. Во многих отношениях шаблон класса напоминает
Структуры данных 81 класс BraidedSearchTree: элемент данных root указывает на головной узел, win представляет окно, а элемент стр указывает на функцию срав- нения деревьев. template<class Т> class RandomizedSearchTree { private: RandomizedNode<T> ‘root; // головной узел RandomizedNode<T> *win; // окно int (*cmp) (T,T); // функция сравнения void _remove(RandomizedNode<T>*); public: RandomizedSearchTree(int(*) (T,T), int = -1); -RandomizedSearchTree(void); T next(void); T prev(void); void inorder(void(*) (T) ); T val (void); bool isFirst(void); bool isLast(void); bool isHead(void); bool isEmpty(void); T find(T); T findMin(void); T locate(T); T insert(T); void remove(void); T remove(T) ; T removeMin(void); }; 3.7.3. Конструкторы и деструкторы Конструктор RandomizedSearchTree инициализирует новое дерево случайного поиска, представляемое изолированным головным узлом с ми- нимальным приоритетом, равным -1,0: template<class Т> RandomizedSearchTree<T>::RandomizedSearchTree(int (*с) (Т,Т), int seed) : cmp (с) { win = root = new RandomizedNode<T> (NULL, seed); root->_priority = -1.0; } Деструктор удаляет дерево поиска: template<class Т> RandomizedSearchTree<T>::-RandomizedSearchTree(void) { delete root; }
89 Глава 3 3.7.4. Использование ленты Компонентные функции next, prev, val, inorder, isFirst, isLast, isHead и isEmpty определяются подобно их аналогам в классе BraidedSearchTree : template<class Т> Т RandomizedSearchTree<T>::next(void) { win = win->next(); return win->val; } template<class T> T RandomizedSearchTree<T>::prev(void) { win = win->prev(); return win->val; } template<class T> T RandomizedSearchTree<T>::val(void) { return win->val; ) template<class T> void RandomizedSearchTree<T>::inorder(void (*visit) (T) ) ( RandomizedNode<T> *n = root->next(); while (n != root) { (♦visit) (n->val); n = n->next(); } ) template<class T> bool RandomizedSearchTree<T>::isFirst(void) { return (win == root->next() ) && (root != root->next() ); } template<class T> bool RandomizedSearchTree<T>::isLast(void) { return (win == root->prev() ) && (root 1= root->next() ); ) template<class T> bool RandomizedSearchTree<T>::isHead(void) { return (win == root); } template<class T> bool RandomizedSearchTree<T>::isEmpty(void) { return (root = root->next() ); }
Структуры данных 83 3.7.5. Поиск Компонентные функции find и findMin применяются также подобно их аналогам в классе BraidedSearchTree : templateCclass Т> Т RandomizedSearchTree<T>::find(Т val) ( RandomizedNode<T> *n = root->rchild(); while (n) { int result = (*cmp) (val, n->val); if (result < 0) n = n->lchild(); else if (result > 0) n = n->rchild(); else ( win = n; return n->val; } ) return NULL; } template<class T> T RandomizedSearchTree<T>::findMin(void) { win = root->next(); return win->val; } Теперь мы введем новую операцию поиска locate. При обращении к функции locate с аргументом val возвращается наибольший элемент в де- реве, который не больше, чем значение val. Если в дереве присутствует элемент со значением val, то возвращается значение val. Если такого зна- чения нет, то возвращается значение меньше, чем val, если же в дереве и такого значения нет, то возвращается NULL. Для выполнения этой операции будем отслеживать путь по дереву поиска до значения val. По мере продвижения вниз запоминается последний (самый нижний) узел Ь, в котором поворачиваем вправо — узел Ь является самым нижним узлом, обнаруженным на пути поиска, для которого правый потомок также лежит на пути поиска. Если в дереве находится значение val, то оно просто возвращается. В противном случае, если мы не найдем значение val в дереве — путь поиска завершается на внешнем узле — то возвращается элемент, запомненный в узле Ь. Операция реализуется компонентной функцией locate, в которой окно перемещается на обнаруженный элемент и возвращается этот элемент. template<class Т> Т RandomizedSearchTree<T>::Locate(Т val) { RandomizedNode<T> *Ь = root; RandomizedNode<T> *n = root->rchild(); while (n) { int result = (*cmp) (val, n->val); if (result < 0) n = n->lchild(); else if (result > 0) {
84 Глава 3 Ь = n; n = n->rchild(); } else { win = n; return win->val; } } win = b; return win->val; } Каким же образом получается правильный результат? Он вполне очеви- ден, если значение val точно находится в дереве. Но предположим, что та- кого значения в дереве нет, поэтому поиск значения val завершается на внешнем узле. Поскольку указатель п сигнализирует об окончании поиска, значение b показывает на наибольший элемент, который меньше всех ос- тальных, содержащихся в поддереве с корнем в узле п. Легко видеть, что это условие сохраняется всегда. Каждый раз, когда в процессе поиска в узле п мы переходим на левую ветвь, условие продолжает сохраняться, посколь- ку каждый элемент в левом поддереве узла п меньше элемента в узле п. При переходе в узле п на правую ветвь присваивание Ь значения, равного п, восстанавливает условие, поскольку: (1) каждый элемент в правом под- дереве узла п больше элемента, хранящегося в п и (2) элемент, хранящийся в узле п больше, чем элемент, на который указывало значение b до его изменения. И, наконец, когда п указывает на внешний узел, то Ь будет наи- большим элементом в дереве, который меньше любого другого элемента, ко- торый может легально заменить внешний узел, среди которых находится также и val. 3.7.6. Включение элементов Для внесения нового элемента в дерево случайного поиска сначала най- дем внешний узел, к которому он принадлежит (этап 1), а затем будем его поднимать по дереву по направлению к корню в соответствии с его при- оритетом (этап 2). Компонентная функция insert заносит элемент val в текущее дерево случайного поиска и затем перемещает окно на этот элемент и возвращает указатель на этот элемент: template<class Т> Т RandomizedSearchTree<T>::insert(Т val) ( // этап 1 int result = 1; RandomizedNode<T> *p = root; RandomizedNode<T> *n - root->rchild(); while (n) { p = n; result = (*cmp) (val, p->val); if (result < 0) n = p->lchild(); else if (result > 0) n = p->rchild(); else return NULL;
Структуры данных 85 } win - new RandomizedNode<T>(val) ; win->_parent = p; if (result < 0) { p->_lchild = win; p->prev()->Node::insert(win); } else ( p->_rchild = win; p->Node::insert(win); } win- >bubbleUp (); return val; 11 этап 2 3.7.7. Удаление элементов Компонентная функция remove удаляет элемент в окне и затем переме- шает окно в предшествующую позицию. Компонентная функция removeMin удаляет наименьший элемент и возвращает его. Если окно находилось на этом элементе, то оно перемещается в головную позицию: template<class Т> void RandomizedSearchTree<T>::remove(void) { if (win != root) _remove(win); } template<class T> T RandomizedSearchTree<T>::removeMin(void) { T val = root->next()->val; if (root != root->next() ) _remove(root->next() ); return val; } Обе эти функции основаны на внутренней компонентной функции _remove. Для удаления узла и функция _remove увеличивает приоритет узла п до 1.5 и затем опускает его вниз до тех пор, пока он не станет внеш- ним узлом. Приоритет 1.5 превышает приоритет любого элемента в дереве, но меньше, чем приоритет внешних узлов (2.0). После этого функция уда- ляет узел п из дерева случайного поиска. Если окно было на узле, подле- жащем удалению, то окно перемещается на предшествующую позицию. Ал- горитм реализуется в виде функции _remove, которая удаляет узел п из текущего дерева поиска. template<class Т> void RandomizedSearchTree<T>::_remove(RandomizedNode<T> *n) { n->_priority =1. 5; n->bubbleDown(); RandomizedNode<T> *p = n->parent(); if (p->lchild() = n)
86 Г лава 3 p->_lchild = NULL; else p->_rchild = NULL; if (win == n) win = n->prev(); n->Node::remove(); delete n; } На рис. 3.16, если его анализировать от (г) до (а), показано удаление эле- мента 6 из самого правого дерева поиска, для которого приоритет был уве- личен до 1.5. Процесс удаления обратен процессу внесения элемента 6 в дерево (а), поскольку правая и левая ротации взаимно обратны. Для удобства введем вторую компонентную функцию remove, которая, при передаче ей элемента, удаляет его из дерева случайного поиска. Если окно находится на элементе, подлежащем удалению, то окно перемещается в предыдущую позицию, в противном случае окно не перемещается: templateCclass T> T RandomizedSearchTree<T>::remove(T val) { T v = find (val) ; if (v) { remove (); return v; } return NULL; ) 3.7.8. Ожидаемые характеристики В дереве случайного поиска размера п средняя глубина узла равна O(log п). Это справедливо независимо от изменений в порядке ввода элементов или положения в дереве, куда эти элементы вводятся или из которого удаля- ются. Поскольку выполнение каждой из операций ввода и удаления insert, remove и removeMin, а также операций поиска find и findMin занимает время, пропорциональное глубине обрабатываемого элемента, то в среднем затраты времени равны O(log п). Почему же средняя длина поиска равна O(log п) ? Чтобы ответить на этот вопрос, найдем ожидаемую глубину элемента в дереве случайного поиска Т. Переименуем элементы в дереве поиска Т по увеличивающемуся приоритету как Xi, Х2,..-, хп. Для определения ожидаемой глубины элемента хк предпо- ложим, что дерево Т строилось путем занесения элементов в указанном по- рядке в первоначально пустое дерево случайного поиска Т '. По мере ввода каждого элемента xi определим вероятность того, что оно лежит на пути по- иска элемента хк в дереве Т. Прежде, чем продолжать дальше, обсудим три момента. Во-первых, про- цесс имеет место в дереве Т, поскольку дерево случайного поиска полностью определяется его элементами и их приоритетами (если даже некоторые зна- чения приоритетов встречаются более одного раза, то элементы все равно могут быть упорядочены таким образом, что дерево Т существует). Во-вто- рых, вследствие принятого порядка включения элементов не требуется ни- каких операций ротаций. В-третьих, наш анализ требует, чтобы были вне-
Структуры данных 87 сены только элементы xi,..., xki, поскольку элемент хк всегда обязательно находится в конце своего пути поиска и внесение элементов Xk+i,..., хп не может увеличить глубину элемента хк, поскольку ни один из этих элемен- тов не может быть предшественником элемента хк. Начнем с пустого двоичного дерева Тпредставленного единственным внешним узлом. Для внесения элемента xi в Т' заменим внешний узел на Xi. Поскольку элемент xi находится в корне дерева Т то он будет предше- ственником любого элемента хк, так что вероятность того, что элемент xi лежит на пути поиска элемента Хк, равна единице. Внесем теперь в дерево Т' элемент хг. Очевидно, что элемент хг может заменить любой из двух внешних узлов, которые являются последователями элемента xi, так что только одна из этих двух позиций приведет к тому, что элемент хг будет лежать на пути поиска элемента Хк. Поэтому вероятность того, что элемент хг будет лежать на этом пути поиска, равна |. В общем случае, при вводе в дерево Т' элемента Xi, где i изменяется от 1 до k, элемент Xi с равной вероятностью замещает один из i внешних узлов. Поскольку только одна из этих позиций приводит к тому, что элемент Xi будет лежать на пути поиска элемента хк, то вероятность того, что элемент Xi лежит на пути поиска эле- мента хк будет равна Из этого следует, что ожидаемая глубина элемента Хк равна 1 + £ и р что соответствует O(log п) для k < п. 3.7.9. Словарь АТД Словарь АТД используется для манипулирования набором элементов, выстроенных в линейном порядке. Он поддерживает динамическое редак- тирование элементов (создание, внесение и удаление) и различные формы поиска внутри набора (например, поиск заданного элемента, наименьшего или наибольшего элемента, предшественника или последователя элемента). Конкретная реализация операции поиска и ее поведение лишь слегка от- личаются в разных версиях. Для целей данного изложения в качестве сло- варя будем использовать дерево случайного поиска. С помощью директивы препроцессора #define для словаря зададим иден- тификатор RandomizedSearchTree: #define Dictionary RandomizedSearchTree Применение в наших программах идентификатора Dictionary для обо- значения словаря более очевидно определяет его назначение, чем иденти- фикатор RandomizedSearchTree. Более того, это дает нам возможность за- менять одну реализацию словаря на другую путем простой замены соответствующей директивы # de fine. 3.8. Замечания Структуры данных хорошо описаны в работах [2, 20, 73, 78, 83, 89, 90]. В монографии Роберта Тарьи [83], более полной по сравнению с другими, очень деликатно описана взаимосвязь между структурами данных и алго- ритмами.
88 Глава 3 Из совместного анализа упомянутых книг можно сделать вывод, что целый ряд сбалансированных деревьев поиска гарантированно имеет высо- ту ©(log п), где значение п определяется размером дерева. Деревья случай- ного поиска обсуждаются в [58]. Ротации, на которых они основаны, также использовались в ранних схемах сбалансированных деревьев поиска, которые относятся к 1962 г. [1], и в других деревьях [6, 35]. 3.9. Упражнения 1. Доказать, что двоичное дерево размера п содержит п + 1 внешних узла. 2. Доказать, что двоичное дерево высоты h имеет размер по крайней мере 2П - 1. (Подсказка: выполните индукцию по значению Л.) 3. Доказать, что двоичное дерево размера п имеет высоту по крайней мере Г log(n + 1)"]. (Подсказка: использовать предыдущее упражнение.) 4. Напишите компонентную функцию для вычисления высоты объекта класса SearchTree. 5. Определите конструктор копирования для класса SearchTree. 6. Измените определение RandomizedNode и RandomizedSearchTree так, чтобы каждый объект RandomizedSearchTree содержал свой соб- ственный генератор случайных чисел, который бы срабатывал однаж- ды при инициализации объекта. 7. Покажите, что набор элементов с различными приоритетами полнос- тью определяет дерево случайного поиска. 8. Покажите, что ротация сохраняет порядок узлов в двоичном дереве. 9. Для двух данных двоичных деревьев Т и Т' размера п показать, что существует последовательность левых и правых ротаций, которая трансформирует Т в Т 10. Предположим, что мы определили двоичное дерево поиска, узлы ко- торого соединены только связями с левым и правым потомками и с предком. Напишите функцию next, которая определяет последователя для заданного узла п. (Подсказка: если у узла п нет правого потомка, то его наследник является предком.) 11. Показать, что У, у = O(log п). 12. Утверждается, что высота дерева случайного поиска размера п в сред- нем равна 2.99 log2 п + O(log log п). Попробуйте проверить это экс- периментально, проанализировав несколько деревьев случайного поис- ка разного размера. Какой числовой коэффициент скрыт в выражении O(log2 log2 п)? 13. В утверждении, что ожидаемая длина пути поиска в дереве случайного поиска размера п равна O(log п), используется тот факт, что внесение элемента Xi эквивалентно равновероятной замене одного из внешних узлов i дерева Т Почему это так?
Глава 4 Структуры геометрических данных В этой главе определим классы, которые нам потребуются при работе с гео- метрическими объектами в двух- и трехмерном пространствах. В двухмерном пространстве эти классы будут обеспечивать выполнение таких операций, как разбиение полигона хордой на два меньших полигона, вычисление точки пересечения двух непараллельных прямых линий, определение положения точки относительно прямой линии. В трехмерном пространстве будем опреде- лять положение точки относительно плоскости и искать точку пересечения прямой линии и треугольника. Здесь же приводятся минимально необходи- мые сведения из линейной алгебры. 4.1» Векторы Определение положения точки на плоскости производится с помощью сис- темы координат. В декартовой системе координат плоскость содержит две координатные оси с общим началом (точка их пересечения), имеющие оди- наковые единицы длины. Оси перпендикулярны друг другу и ориентирова- ны так, как показано на рис. 4.1а. Это устанавливает однозначное соответ- ствие между упорядоченными парами чисел (х, у) и точками на плоскости. Первая координата х точки показывает ее смещение вдоль горизонтальной оси, а вторая координата у — ее смещение вдоль вертикальной оси. Упорядоченная пара чисел (х, у) может также рассматриваться как век- тор, что показано на рис. 4.16. Геометрически вектор (х, у) — это направ- ленный отрезок прямой линии, начинающийся в точке (О, О) и заканчива- ющийся в точке (х, у). Точка начала координат (0, 0) иногда обозначается как 0 и называется нулевым вектором. Для работы с векторами существуют две основных операции: сложение векторов и скалярное умножение (рис. 4.2). Для двух заданных векторов а = (ха, г/а) и Ъ = (хь, уь) сложение векторов определяется как а + Ъ = (ха + хь, г/а + уь)- Геометрически векторы а и Ь определяют параллелограмм с вершинами 0, а, Ъ и а + Ь. Скалярное умножение заключается в умножении вектора на вещественное число, скаляр (рис. 4.2). Для заданных скаляра t и вектора b = (хь, уь) ска- лярное умножение определяется как tb = (/хь, /уь). При выполнении этой операции длина вектора b изменяется в t раз. Направление вектора не из- меняется, если t > 0 и изменяется на обратное, если t < 0.
90 Глава 4 Рис. 4.1. Интерпретация упорядоченной пары чисел (2,1) в виде точки (а) и в виде вектора (б) Поскольку вектор начинается в точке начала координат, он полностью опи- сывается точкой, в которой он оканчивается. И наоборот, вектор может быть описан его длиной, и направлением. Длина вектора а = (ха, уа) обозначается через ||а|| и определяется как ||а|| = ^Ха + Уа- Длина вектора равна расстоянию от начала координат 0 до точки а. Вектор называется единичным вектором, если его длина равна единице. Умножение ненулевого вектора а на величину, об- а ратную его длине, приводит к получению единичного вектора д с тем же на- правлением. Эта операция известна под названием нормализация. Направление вектора а описывается его полярным углом 6а — углом, об- разуемым между положительной осью х и этим вектором. Полярный угол измеряется в направлении против часовой стрелки, начиная от положитель- ного направления оси х, и лежит в диапазоне 0 < 6а < 360 (углы мы будем всегда измерять в градусах). На рис. 4.3 приведено несколько примеров. Вычитание векторов определяется в терминах сложения векторов и скаляр- ного умножения. Для заданных векторов а и Ъ будем иметь b - а = b + (~1)а. Практически такая операция выполняется путем вычитания значений коор- динат: b - а = (хь - ха, уъ ~ у&)- Геометрически операция вычитания опреде- ляет направленный отрезок прямой линии aS, начинающийся в точке а и заканчивающийся в точке Ь, эквивалентный вектору b - а (рис. 4.4). Рис. 4.2. Сложение векторов и скалярное умножение
Структуры геометрических данных 91 Рис. 4.3. Различные векторы, заданные в полярных координатах а = (Hall, 0 а) Рис. 4.4. Вычитание векторов Направленный отрезок прямой линии aS является вектором, фиксирован- ным на плоскости. Концевая точка а называется началом отрезка aS, а его концевая точка b — концом. Два направленных отрезка прямой линии aS и сЗ, имеющие одинаковые длину и направление, могут быть совмещены и оп- ределены одним и тем же направленным отрезком — вектором b - а = d - с. Векторная арифметика предоставляет средство для решения ряда задач, включающих в себя использование свойств направленных отрезков оставать- ся неизменными при их переносе. Покажем это на следующем примере. Пусть даны три точки ро, pi, рг, не лежащие на одной прямой. Они оп- ределяют треугольник ДроР1Р2, который называется положительно ориенти рованным, если точка рг лежит слева от отрезка прямой popi, и отрица телъно ориентированным, если точка рг лежит справа от отрезка прямой popi (рис. 4.5). Проблема заключается в описании процедуры для опреде- ления ориентации. Рационально решать эту проблему с помощью векторов, поскольку ориентация треугольника не меняется при переносе. Пусть a - pi - ро и b = рг - ро и задача сводится к определению угла ваь между векторами, измеренными в направлении против часовой стрелки, начиная от направления вектора а. Если 0 < ваь < 180, тогда ДроР1Рг имеет положи-
92 Глава 4 Рис. 4.6. Четыре возможных случая взаимного расположения векторов для определения ориентации треугольника тельную ориентацию, в противном случае (180 < 8аъ < 360) треугольник имеет отрицательную ориентацию. Предполагается, что существуют четыре варианта расположения векторов а и Ъ относительно друг друга (рис. 4.6). В случаях 1 и 3 имеем 0 < 6а& < 180, а в случаях 2 и 4 — 180 < Bab < 360. В случаях 1 и 2 поло- жительная ось х проходит внутри угла 6а&, а в случаях 3 и 4 — нет. Четыре возможные конфигурации соответствуют четырем возможным диапазонам, в пределах которых находится угол Q = 6& - 0а: Случай Диапазон Q= 9ь - 9а Ориентация треугольника A po pt рг sin Q 1 -360<Q<-180 + + 2 -180<Q<0 - - 3 0<Q<180 + + 4 180<Q<360 - - Для определения ориентации треугольника нам нужно было бы вычис- лить угол Q = 6& - 6а и затем получить ответ на основе определения, в каком же из четырех диапазонов лежит этот угол. Однако значительно проще решить этот вопрос на основе заметного из таблицы соотношения, что знак синуса этого угла sin(<2) совпадает со знаком ориентации треуголь- ника. Поскольку sin(0& - 6а) = sin(6&) cos(6a) - cos(6&) sin(6a)
Структуры геометрических данных 93 о Ха . _ Уа _ хь . . УЬ c°s6a = H, Sin0e = w cos6b = w = H будем иметь sin(6& - 6a) = или(Xa yb " Xb Уа} Длины векторов всегда положительны, следовательно sign(sin(6& - 6a)) = Sign(xa УЬ - Xb У а)- Отсюда следует, что выражение ха уь - хь уа имеет тот же знак, что и ориентация треугольника. В следующем разделе мы запишем это соотноше- ние в виде функции языка C++, определяющей ориентацию треугольника. Стоит отметить, что выражение ха уь - хь у а имеет простую геометрическую интерпретацию — оно равно площади (со знаком) параллелограмма с вер- шинами 0, а, Ь и а + Ъ. 4.2. Точки 4.2.1. Класс Point (Точка) KnaccPoint содержит элементы данных х и у для хранения координат точки. Компонентные функции обеспечивают выполнение операций по опре- делению положения точки относительно заданного отрезка прямой линии и вычисления расстояния от заданной точки до прямой линии. Дополнитель- ные компонентные функции рассматривают текущую точку как вектор и оп- ределяют функцию специального вида — «операцию-функцию» (operator function) для реализации векторной арифметики, используя ключевое слово operator. Включены также компонентные функции, возвращающие значе- ния полярного угла и длины. class Point { public: double x; double y; Point(double _x = 0.0, double _y = 0.0); Point operator+ (Points); Point operator- (Points); friend Point operator* (double, Points); double operator[] (int); int operator== (Points); int operator!= (Points); int operator< (Points); int operator> (Points); int classify(Points, Points); int classify(Edges); double polarAngle(void); double length(void); double distance(Edges); );
94 Глава 4 4.2.2. Конструкторы Конструктор инициализирует новую точку с координатами х и у: Point:: Point (double _х, double _у) х (_х) , у (_у) { ) Если аргумент не указан, то по умолчанию инициализируется точка в начале координат (0, 0). Можно инициализировать точку как дубль другой точки. Например, об- ращение Point p(q) инициализирует новую точку р с теми же коорди- натами, как и точка q. В этом случае инициализация осуществляется ко- пирующим конструктором по умолчанию (существующем в компиляторе языка C++), который выполняет копию со всеми элементами. 4.2.3. Векторная арифметика Векторное сложение и векторное вычитание выполняется с помощью опе- раций-функций со знаками операций + и - соответственно: Point Point:: operators- (Point &p) { return Point(x + p.x, у + p.y); ) Point Point:: operator- (Point &p) { return Point(x - p.x, у - p.y); ) Операция скалярного умножения реализована в виде дружественной функции классу Point, а не члена класса, поскольку ее первый операнд не относится к типу Point. Оператор определен следующим образом: Point operator*(double s, Point &p) { return point(s * p.x, s * p.y); ) Операция-функция operator [] возвращает координату x текущей точки, если в обращении в качестве индекса координаты было указано значение 0, или координату у при указании значения индекса 1: double Point::operator[] (int i) { return (i == 0) ? x : y; )
Структуры геометрических данных 95 4.2.4. Операции отношения Операции отношения == и != используются для определения эквивалент- ности двух точек: int Point::operator== (Point &p) { return (x == p.x) && (y == p.y); } int Point::operator!= (Point &p) { return !(*this == p); } Операции < и > реализуют лексикографический порядок отношений, когда считается, что точка а меньше точки Ь, если либо а.х < Ь.х, либо а.х — Ь.х и а.у < Ъ.у. Для двух данных точек сначала сравниваются их х-координаты, и, если они равны, то сравниваются у-координаты. Такой по- рядок иногда называется словарным порядком отношений, поскольку точно по такому же правилу располагаются в словаре двухбуквенные слова. int Point::operator< (Point &p) { return ((x < p.x) || ((x — p.x) && (y < p.y))); ) int Point::operator> (Point &p) { return ((x > p.x) || ((x — p.x) && (У > py))); ) Упорядочение точек на плоскости может быть выполнено бесконечным числом способов. Тем не менее удобно использовать операции < и > для канонического упо- рядочения, поскольку нам часто придется сохранять точки в словарях и эти опера- торы можно будет применять для упрощения необходимых функций сравнения. Прежде, чем переходить к остальным компонентным функциям класса Point, рассмотрим следующий простой пример, который показывает при- менение объектов класса Point. Функция orientation возвращает значе- ние 1, если обрабатываемые три точки ориентированы положительно, -1, если они ориентированы отрицательно, или 0, если они коллинеарны. В функции реализован способ, обсуждавшийся в конце предыдущего раздела. int orientation(Point &р0, Point &pl, Point &р2) { Point а = pl - рО; Point Ь = у2 - рО; double за = а.х * Ь.у - Ь.х * а.у; if (за >0.0) return 1; if (за < 0.0) return -1; return 0; )
96 Глава 4 4.2.5. Относительное положение точки и прямой линии Одна из важнейших операций заключается в определении положения точки относительно направленного отрезка прямой линии. Результат вы- полнения операции показывает, лежит ли точка слева или справа от на- правленного отрезка прямой линии или, если она совпадает с прямой ли- нией, располагается она после конца направленного отрезка этой прямой линии, до начала, а если ни то, ни другое, то не совпадает ли она с на- чалом или с концом или лежит между ними. Направленный отрезок пря- мой линии четко разделяет плоскость на семь непересекающихся областей и эта операция сообщает, какой из этих областей принадлежит точка (рис. 4.7). Для определения положения текущей точки относительно отрезка прямой линии роръ направленного от точки рО к точке pl, используется компо- нентная функция classify. Возвращается значение типа перечисления, указывающее на положение текущей точки: Рис. 4.7. Разделение плоскости на семь областей направленным отрезком прямой линии enum {LEFT,RIGHT,BEYOND,BEHIND,BETWEEN,ORIGIN,DESTINATION}; //СЛЕВА,СПРАВА,ВПЕ РЕДИ,ПОЗАДИ,МЕЖДУ,НАЧАЛО,КОНЕЦ int Point::classify(Point SpO, Point Spl) { Point p2 = *this; Point a = pl - pO; Point b = p2 - pO; double sa = a. x * b.y - b.x * a.y; if (sa >0.0) return LEFT; if (sa < 0.0) return RIGHT; if ((a.x * b.x < 0.0) I| (a.y * b.y < 0.0)) return BEHZND; if (a.length() < b.length()) return BEYOND; if (pO == p2)
Структуры геометрических данных return ORIGIN; if (pl == p2) return DESTINATION return BETWEEN; Вначале проверяется ориентация точек рО, pl и р2, чтобы определить, располагается ли точка р2 слева или справа, или она коллинеарна с от- резком popi- В последнем случае необходимы дополнительные вычисления. Если векторы а=р1-р0 и Ь=р2-р0 имеют противоположное направление, то точка р2 лежит позади направленного отрезка popi, если вектор а короче вектора Ь, то точка р2 расположена после отрезка popi- В противном случае точка р2 сравнивается с точками рО и pl для определения, совпадает ли она с одной из этих концевых точек или лежит между ними. Для удобства предусмотрена несколько иная версия компонентной функ- ции classify, которой в качестве аргумента передается ребро вместо пары точек. int Point::classify(Edge &e) { return classify(e.org, e.dest); } Определение относительного положения точки и прямой линии в этой книге используется очень часто. В некоторых применениях достаточна более грубая классификация (т. е. достаточно индикации положения точки слева или справа от прямой линии, или просто фиксации совпадения с ней). В других же применениях требуется полная схема этой классификации. 4.2.6. Полярные координаты Система полярных координат обеспечивает второй способ задания поло- жения точки на плоскости. Полярная ось выходит из точки начала коор- динат О и направлена в виде горизонтального луча вправо, как показано на рис. 4.8. Точка а представляется парой чисел (га, 6а). Принимая точку а за вектор, начинающийся в точке начала координат, число га определяет длину вектора, а 0а — его полярный угол (угол, образуемый между век- тором а и полярной осью и отсчитываемый в направлении вращения против часовой стрелки). Соответствие между парами чисел (га, 6а) и точками не является однознач- ным: множество пар могут представлять одну и ту же точку. Пара (0, 6) соот- ветствует началу координат для любого значения 0. Более того, пары чисел (г, 0 + 360/г) соответствуют одной и той же точке для любого целого числа k. Точка может быть представлена как в декартовых, так и в полярных координатах и иногда бывает необходимо переходить от одной системы к другой. Как это очевидно из рис. 4.8, два выражения х = г cos 6, у = г sin 0 осуществляют преобразования из полярных координат (г, 6) в декартовы координаты (х, у). 4 Заказ 2794
98 Глава 4 Рис, 4.8. Точка р описывается полярными координатами (г, 0) и декартовыми координатами (х, у) Для обратного преобразования координата расстояния определяется как Чтобы выразить полярный угол 6 в функции от х и у заметим, что су- ществует соотношение tan 6 = из которого следует 6 = arctanх^О (4.1) Для использования выражения 4.1 в функции polarAngle необходимо различать квадранты на плоскости и учитывать случай возможного равен- ства нулю координаты х: double Point::polarAngle(void) { if ( (x == 0.0) && (y == 0.0)) return -1.0; if (x == 0.0) return ((y > 0.0) ? 90 : 270); double theta = atan(y/x); //в радианах theta *= 360 / (2 * 3.1415926); // перевод в градусы if (х >0.0) // 1 и 4 квадранты return ((у >= 0.0) ? theta : 360 + theta); else // 2 и 3 квадранты return (180 + theta); Заметим, что функция polarAngle возвращает значение -1.0, если те- кущий вектор является нулевым вектором (в противном случае возвраща- ется неотрицательное значение). Это обстоятельство мы используем впослед- ствии для упрощения описания функции сравнения, основанной на задании полярного угла. Компонентная функция length возвращает длину текущего вектора double Point::length(void) ( return sqrt(x*x + y*y); ) Компонентная функция distance возвращает значение расстояния (со зна- ком) от текущей точки до ребра. Эту функцию определим в разделе 4,5.3.
Структуры геометрических данных 99 4.3. Полигоны (многоугольники) Полигоны (многоугольники) являются просто очаровательными объекта- ми, причем очень удивительно, что они чрезвычайно просты концептуально. В этом разделе представим основные определения и концепции, чтобы можно было обсуждать свойства полигонов и средства для их обработки. 4.3.1. Что такое полигон? Полигоном называется замкнутая кривая на плоскости, образуемая от- резками прямых линий. Отрезки называются ребрами или сторонами поли- гона, концевые точки отрезков, совпадающие для двух соседних ребер, на- зываются вершинами. Количество вершин (или сторон, что эквивалентно), содержащихся в полигоне, определяет его размер. Для краткости мы иногда будем использовать название п-гон (или п-угольник) для обозначения поли- гона размера п и обозначение |Р| для размера некоторого полигона Р. Полигон будет простым, если он не пересекает самого себя. Простой полигон охватывает непрерывную область плоскости, которая считается внутренней частью полигона. Неограниченная область, окружающая про- стой полигон, образует внешнюю часть, а набор точек, лежащих на самом полигоне, образует границу между этими двумя частями. В этой книге тер- мин полигон будет использоваться в смысле простого заполненного полиго- на, т. е. объединения границы и внутренней части простого полигона. На- пример, если говорится, что точка находится в полигоне, то это означает принадлежность точки либо границе простого полигона, либо его внутренней части. Вершины упорядочены циклически вдоль границы полигона. Две верши- ны, являющиеся концевыми точками одного и того же ребра, являются со- седями и про них также говорят, что они смежные. Вершина, соседняя при обходе по часовой стрелке, называется последователем, а соседняя при об- ходе против часовой стрелки — ее предшественником. Цепи вершин, или просто цепь — это часть границы полигона. Обход полигона заключается в перемещении вдоль цепи от одной вершины к соседней к ней в направ- Рис. 4.9. Основные определения для полигонов: внутренняя часть, внешняя часть, граница, выпуклые вершины, вогнутые вершины, хорда
100 Глава 4 лении по движению часовой стрелки или в противоположном. Обход часто делает полный круг вдоль всей границы полигона, если необходимо посе- тить все вершины полигона. Вершины полигона подразделяются на выпуклые и вогнутые. Вершина называется выпуклой, если внутренний угол при этой вершине (находящий- ся в пределах внутренней области) меньше или равен 180 градусов. В про- тивном случае вершина считается вогнутой (внутренний угол более 180 гра- дусов). Отрезок прямой линии между двумя не соседними вершинами называ- ется диагональю. Диагональ называется хордой или внутренней диагональю, если она лежит внутри полигона, не захватывая внешнюю область полигона. Добавление к полигону хорды делит его на два меньших подполигона. На рис. 4.9 проиллюстрированы некоторые определения, относящиеся к поли- гонам. Точку или отрезок прямой линии иногда удобно рассматривать как вы- рожденный полигон. 1-гон состоит из единственной вершины и имеется един- ственное ребро нулевой длины, соединяющее вершину саму с собой. 2-гон со- держит две вершины и два совпадающих ребра, соединяющих две вершины. Кроме других преимуществ использование вырожденных полигонов часто уп- рощает формирование полигона: начиная с 1-гона, вносим вторую вершину для образования 2-гона, добавление последующих вершин приводит к фор- мированию обычных полигонов размера 3 и более. Если считать точки и от- резки прямых линий полигонами, то начальные этапы формирования поли- гона ничем не отличаются от последующих, поскольку каждый этап заключается в модификации полигонов. 4.3.2. Выпуклые полигоны Область на плоскости считается выпуклой, если для любых двух точек внутри области отрезок прямой линии между этими двумя точками пол- ностью лежит внутри области. На рис. 4.9 полигон (а) является выпуклым в отличие от полигона (б), который таковым не будет, поскольку отрезок прямой линии pq выходит за пределы полигона. Заметим, что граница вы- пуклого полигона не является выпуклой областью, тогда как внутренняя часть выпуклого полигона будет выпуклой. Выпуклость приводит к появлению целого ряда полезных свойств, кото- рые приводят к упрощению работы с выпуклыми полигонами по сравнению с произвольными полигонами. Например, любая диагональ в выпуклом полигоне является хордой. Кроме того, любая вершина в выпуклом поли- гоне будет выпуклой (в невыпуклом полигоне по крайней мере одна вер- шина вогнутая). Из этого следует, что при обходе границы выпуклого поли- гона в направлении по часовой стрелке в каждой вершине каждое следующее ребро либо продолжается по прямой линии, либо поворачивает вправо. Другое полезное свойство заключается в том, что пересечение АПВ любых двух выпуклых областей А и В является выпуклым. (Чтобы убедить- ся в этом, предположим, что р и q любые две точки в АПВ. Поскольку обе точки р и q лежат в А, а область А — выпуклая, то отрезок прямой линии pq лежит в области А. Аналогично, отрезок pq лежит внутри области В.
Структуры геометрических данных 101 Следовательно, отрезок pq лежит внутри АПВ и область АПВ должна быть выпуклой). Из этого следует, что пересечение двух выпуклых полигонов яв- ляется выпуклым и фактически образует выпуклый полигон (возможно вы- рожденный). Более того, поскольку прямая линия является выпуклой об- ластью, пересечение прямой линии и полигона должно быть выпуклой областью — в виде отрезка прямой линии или одинокой точки, если прямая линия только касается полигона в некоторой вершине. Имея в виду эти и другие свойства, в данной книге мы будем чаще всего работать именно с вы- пуклыми полигонами. 4.3.3. Класс Vertex (Вершина) Мы будем представлять полигон в виде кольца вершин, хранящегося в кольцевом списке с двойными связями. Каждый узел соответствует вер- шине и связан со своими двумя соседями. Следуя связям, мы можем обойти границу полигона в любом направлении, а включая или удаляя узлы (редактируя связи соответствующим образом), мы можем создавать полигоны и динамически их модифицировать. Эту схему обеспечивают классы Vertex и Polygon. Полигон хранится в виде циклического списка объектов класса Vertex с двойными связями. Поскольку вершины полигона существуют одновременно как точки на плос- кости и узлы в связном списке, класс Vertex формируется на основе класса Point и класса Node. Класс Polygon содержит элемент данных, указываю- щий на некоторую вершину в связном списке, представляющем полигон. Класс Polygon служит как общедоступный (public) интерфейс для поли- гонов. Класс Vertex наследует элементы данных _next и _prev из базового класса Node и х и у из базового класса Point. По соглашению _next ука- зывает на последователя текущей вершины (ее соседа в направлении по ча- совой стрелке), a _prev — на предшественника текущей вершины (соседа в направлении против часовой стрелки). class Vertex: public Node, public Point { public: Vertex(double x, double y); Vertex(Points); Vertex *cw(void); Vertex *ccw(void); Vertex *neighbor(int rotation); Point point(void); Vertex *insert(Vertex*); Vertex *remove(void); void splice(Vertex*); Vertex *split(Vertex*); friend class Polygon; } Объект класса Vertex может быть сформирован на основе точки или по ее координатам х та. у. Vertex::Vertex(double х, double у) Point (х, у)
юя Глава 4 { } Vertex::Vertex(Point Sp) Point (p) { } Компонентные функции cw и ccw возвращают указатели на последова- теля и предшественника текущей вершины соответственно. Vertex *Vertex::cw(void) ( return (Vertex*)_next; ) Vertex *Vertex::ccw(void) { return (Vertex*)_prev; } Компонентная функция neighbor сообщает, какой из соседей определен параметром rotation, возвращая одно из значений типа перечисления CLOCKWISE или COUNTER_CLOCKWISE Vertex *Vertex::neighbor(int rotation) ( return ((rotation == CLOCKWISE) ? cw() : ccw()); ) Компонентная функция point возвращает точку на плоскости, в которой находится текущая вершина Point Vertex: .-point(void) ( return *((Point*)this); ) Компонентные функции insert , remove и splice соответствуют своим аналогам, определенным в базовом классе Node. Vertex *Vertex::insert(Vertex *v) ( return (Vertex *)(Node::insert(v)); ) Vertex *Vertex::remove(void) ( return (Vertex *)(Node::remove()); ) void Vertex::splice(Vertex *b) ( Node::splice(b); )
Структуры геометрических данных 103 Заметим, что в функциях insert и remove перед выводом производится преобразование возвращаемых значений к типу указатель_на_УегЬех. Явное преобразование необходимо здесь потому, что язык C++ не может автома- тически преобразовать указатель из базового класса, чтобы указать на объект производного класса. Причина заключается в том, что компилятор языка C++ не может быть уверенным в том, что имеется объект производ- ного класса, на который нужно указать, поскольку объект базового класса не обязательно должен быть частью объекта производного класса (но, с дру- гой стороны, компилятор языка C++ автоматически преобразует указатель на производный класс для указания на объект базового класса, поскольку каждый объект производного класса включает внутри себя объект базового класса). Последняя компонентная функция Vertex: : split будет определена несколько ниже. 4.3.4. Класс Polygon (Полигон) Полигон представляется в виде объекта класса Polygon. Класс содержит два элемента данных. Первый из них, _v, указывает на некоторую вершину полигона — текущую позицию окна полигона. Большинство операций с поли- гонами относятся либо к текущему окну, либо к вершите в окне. Иногда мы будем ссылаться на вершину в окне как на текущую вершину. Во вто- ром элементе данных, size, хранится размер полигона: class Polygon { private: Vertex *_v; int _size; void resize(void); public: Polygon(void); Polygon(Polygons); Polygon(Vertex*) ; -Polygon(void); Vertex *v(void); int size (void); Point point (void); Edge edge(void); Vertex *cw(void); Vertex *ccw(void); Vertex *neighbor(int rotation); Vertex *advance(int rotation); Vertex *setV(Vertex*); Vertex *insert(Points); void remove(void); Polygon *split(Vertex*); ) Конструкторы и деструкторы Имеется несколько конструкторов для класса Polygon. Конструктор, не имеющий аргументов, инициирует пустой полигон
104 Глава 4 Polygon::Polygon(void) _v(NULL), _size(0) { ) Копирующий конструктор берет некоторый полигон рис его помощью инициализирует новый полигон. Он осуществляет полную копию, дублируя связный список, в котором хранится полигон р. Окно нового полигона по- мещается над вершиной, соответствующей текущей вершине полигона р: polygon::Polygon(Polygon Sp) { _size = p._size; if (_size == 0) _y = NULL; else { _v = new Vertex(p.point()); for (int i = 1; i < _size; i++) ( p.advance(CLOCKWISE); _v = _v->insert(new Vertex(p.point())); ) p.advance(CLOCKWISE); __v = _v->cw () ; ) Третий конструктор инициализирует полигон с кольцевым двухсвязным списком вершин: Polygon: .-Polygon (Vertex *v) : _v(v) { resize(); ) Конструктор вызывает собственную компонентную функцию resize для изменения значения элемента _size. В принципе, функция resize должна вызываться всегда, когда к полигону добавляется или из него удаляется це- почка вершин неизвестной длины. Эта функция определяется следующим образом: void Polygon::resize(void) { if (_v = NULL) __size = 0; else { Vertex *v = _v->cw(); for (_size = 1; v != _v; ++ size, v = v->cw()) ) Деструктор ~Polygon освобождает вершины текущего полигона, прежде чем удалить сам объект Polygon: Polygon::~Polygon (void) {
Структуры геометрических данных 105 if <_У) ( Vertex *w = _v->cw(); while(_v != w) { delete w->remove(); w = _v->cw(); } delete _v; } Функции доступа Следующие несколько компонентных функций позволяют получить не- которые сведения о текущем полигоне. Функция v возвращает текущую вер- шину данного полигона, а функция size — его размер: Vertex *Polygon::v(void) { return _v; } int Polygon::size(void) { return _size; } Указатель, возвращаемый функцией v, может быть использован в виде дополнительного окна для полигона как дополнение к неявному окну поли- гона. В некоторых применениях требуется одновременное использование не- скольких окон для одного и того же полигона — единственного окна, обес- печиваемого в классе неявно, не всегда оказывается достаточно. Компонентная функция point возвращает точку на плоскости, которая соответствует текущей вершине. Компонентная функция edge возвращает текущее ребро. Текущее ребро начинается в текущей вершине и заканчи- вается в следующей после нее вершине: Point Polygon::point(void) ( return _v->point(); ) Edge Polygon::edge(void) ( return Edge(point(), _v->cw()->point()); > Класс Edge (ребро) будет определен в следующем разделе. Компонентные функции cw и ccw возвращают последователя и предше- ственника для текущей вершины, оставляя без изменения положение окна. Функция neighbor (сосед) возвращает указатель на последователя или пред- шественника текущей вершины в зависимости от значения аргумента, с ко- торым она вызывается (CLOCKWISE или COUNTER_CLOCKWISE — по часовой стрелке или против):
106 Глава 4 Vertex *Polygon::cw(void) { return _v->cw(); ) Vertex *Polygon::cow(void) < return _v->ccw(); ) Vertex *Polygon::neighbor(int rotation) { return _v->neighbor(rotation); ) Функции редактирования Компонентные функции advance и setV перемещают окно на различные вершины. Функция advance (продвинуть) перемещает окно на последова- теля или предшественника текущей вершины в зависимости от заданного аргумента: Vertex *Polygon::advance(int rotation) < return _v = _v->neighbor(rotation); ) Компонентная функция setV перемещает окно на вершину v, указанную в качестве аргумента: Vertex *Polygon::setV(Vertex *v) < return _v = v; } В программе должно быть обеспечено, чтобы вершина м принадлежала текущему полигону. Компонентная функция insert вносит новую вершину после текущей и затем перемещает окно на новую вершину: Vertex *Polygon::insert(Point &p) { if (_size++ == 0) _v = new Vertex(p); else _v = _v->insert(new Vertex(p)); return _v; ) Компонентная функция remove удаляет текущую вершину. Окно переме- щается на предшественника или остается неопределенным, если полигон ста- новится пустым: void Polygon::remove(void) {
Структуры геометрических данных 107 Vertex *v = _v; _v = (—_size == 0) ? NULL : _v->ccw(); delete v->remove(); Расщепление полигонов Расщепление полигонов заключается в делении полигонов на два мень- ших полигона. Разрезание осуществляется по некоторой хорде. Чтобы раз- резать полигон вдоль хорды ао, вначале внесем после вершины а ее дуб- ликат, а дубликат вершины b внесем перед вершиной b (эти дубликаты назовем ар и Ьр). Затем произведем разделение в вершинах а и Ьр. Этот процесс проиллюстрирован на рис. 4.10. Рис. 4.10. Разрезание полигона вдоль хорды ab. Текущие вершины (в окне каждого полигона) обведены кружком Компонентная функция Polygon: : split определена в терминах функ- ции Vertex:: split. Эта последняя функция разделяет полигон вдоль хорды, соединяющей текущую вершину (которая играет роль вершины а) с вершиной Ь. Она возвращает указатель на вершину Ьр, являющуюся дуб- лем вершины Ь: Vertex *Vertex::split(Vertex *b) { // занесение bp перед вершиной b Vertex *bp = b->ccw()->insert(new Vertex(b->point())); insert(new Vertex(point())) ; // занесение ар после текущей вершины splice(bp); return bp; ) Компонентная функция Polygon:: split разрезает текущий полигон вдоль хорды, соединяющей ее текущую вершину с вершиной Ь. Она воз- вращает указатель на новый полигон, окно которого размещено над верши- ной Ьр, являющейся дубликатом вершины Ь. Положение окна над текущим полигоном не изменяется. Polygon ‘Polygon::split(Vertex *b) { Vertex *bp = _v->split(b); resize(); return new Polygon(bp); )
108 Глава 4 Компонентная функция Polygon: : split должна использоваться с некото- рой осторожностью. Если вершина Ь является последователем текущей вер- шины _у, то после выполнения операции полигон остается неизменным. Если разрезание производится вдоль диагонали, не являющейся хордой, то один или даже оба результирующих «полигона» могут оказаться взаимно пересекающимися. Если вершины Ь и _v принадлежат разным полигонам, то операция разделения приводит к соединению двух полигонов по двум со- впадающим ребрам, которые соединяют эти две вершины. 4.3.5. Точки, входящие в состав выпуклого полигона В этом и последующем подразделах будут представлены две простые программы для работы с полигонами. Программа pointInConvexPolygon обсчитывает точку s и полигон р и возвращает значение TRUE только в том случае, если точка лежит внутри полигона р (в том числе и на его границе): bool pointlnConvexPolygon(Point &s, Polygon &p) { if (p.sizeO == 1) return (s —= p.point()); if (p.sizeO == 2) { int c = s.classify(p.edge()) ; return ((c==BETWEEN) || (c==ORIGIN) || (c==DESTINATION)); ) Vertex *org = p.v(); for (int i = 0; i < p.sizeO; i++, p.advance(CLOCKWISE)) if (s.classify(p.edge О) == LEFT) { p.setV(org); return FALSE; ) return TRUE; } В этой функции вначале анализируются специальные случаи на принад- лежность полигона р вырожденным полигонам — 1-гону или 2-гону. В общем случае в процессе работы алгоритма обходится граница полигона, т. е. окно перемещается от вершины к соседней вершине и производится поочередный анализ взаимного положения точки s и каждого ребра. По- скольку предполагается, что полигон р выпуклый, то точка s лежит вне полигона только в том случае, если она окажется слева от какого-либо узла. Заметим, что перед выходом в программе восстанавливается первоначальное положение окна в полигоне р. 4.3.6. Определение наименьшей вершины в полигоне В следующую функцию в качестве аргументов передаются полигон р и функция сравнения стр и в ней производится поиск наименьшей вершины в полигоне р. Здесь под термином наименьшая вершина подразумевается вершина, которая меньше всех остальных при линейном упорядочении точек в смысле, задаваемом функцией стр. Функция leastvertex переме-
Структуры геометрических данных 109 щает окно полигона р на эту наименьшую вершину и возвращает указатель на эту вершину: Vertex *leastvertex(Polygon &р, int (*cmp)(Point*,Point*)) ( Vertex *bestV = p.v(); p.advance(CLOCKWISE); for (int i = 1; i < p.size(); p.advance(CLOCKWISE), i++) if ((*cmp)(p.v(), bestV) < 0) bestV = p.v(); p.setV(bestV); return bestV; } Например, для обнаружения самой левой вершины следует обратиться к функции leastvertex со следующей функцией сравнения: int leftToRightCmp(Point *a, Point *b) < if (*a < *b) return -1; if (*a > *b) return 1; return 0; ) Для нахождения самой правой вершины будем использовать такую функ- цию сравнения: int rightToLeftCmp(Point *a, Point *b) { return leftToRightCmp(b, a); } В этой книге функции pointInConvexPolygon и leastvertex будут использоваться довольно часто. Также будут применяться и приведенные здесь две функции сравнения, другие функции сравнения будут определены по мере необходимости. 4.4. Ребра Большинство из рассматриваемых здесь алгоритмов затрагивает прямые линии в той или иной форме. Отрезок прямой линии popi состоит из двух концевых точек ро и pi вместе с точками, лежащими между ними. Когда важен порядок следования точек ро и pi, то мы говорим о направленном отрезке прямой линии popi- Концевая точка ро является началом направ- ленного отрезка прямой линии, а точка pi — его концом. Обычно направ- ленный отрезок прямой линии будем называть ребром, если он представляет собой сторону некоторого полигона. Ребро направлено таким образом, что внутренняя часть полигона располагается всегда справа от него. Бесконеч- ная (направленная) прямая линия определяется двумя точками и направ- лена от первой точки ко второй. Луч является полубесконечной прямой ли- нией, начинающейся в точке начала и проходящей через вторую точку.
110 Глава 4 4.4.1. Класс Edge (Ребро) Класс Edge, применяется для представления всех форм прямых линий. Он определяется следующим образом: class Edge ( public: Point org; Point dest; Edge(Point S_org, Point S_dest); Edge(void); Edge Srot(void); Edge Sflip(void); Point point(double); int intersect(Edges, doubles); int cross(Edges, doubles); bool isVertical(void); double slope(void); double у(double); }; Концевые точки начала и конца хранятся в элементах данных org и dest соответственно. Эти элементы инициализируются конструктором Edge: Edge:’.Edge (Point S_org, Point S_dest) : org(_org), dest(_dest) < ) Полезно также иметь конструктор для класса Edge, не требующий аргу- ментов: Edge::Edge(void) org(Point(0,0)) , dest(Point(1,0)) < ) 4.4.2. Поворот ребер Под термином поворот ребра подразумевается вращение ребра на 90 гра- дусов в направлении по часовой стрелке вокруг его средней точки. Два пос- ледовательных поворота ребра приводят к его перевороту, поскольку при этом происходит смена направления ребра на противоположное. Три после- довательных поворота ребра соответствуют повороту ребра на 90 градусов в направлении против движения часовой стрелки вокруг его середины. Че- тыре последовательных поворота оставляют ребро неизменным. Это иллю- стрируется рис. 4.11. На рис. 4.12 показано, как путем поворота ребра формируется ребро сЙ. Если вектор Ь - а = (х, у), то вектор п, перпендикулярный вектору b — а, определяется как п = (у, -х). Средняя точка т между точками а и b определяется как т - ±(а + Ь). Тогда точки с и d определяются как c = zn--|-n и с = т + ±п. Поворот реализуется компонентной функцией rot следующим образом:
Структуры геометрических данных 111 Edge &Edge::rot(void) { Point m = 0.5 * (org + dest); Point v = dest - org; Point n(v.y, -v.x); org = m - 0.5 * n; dest = m + 0.5 * n; return *this; Рис. 4.11. Ребро e, является результатов применения i последовательных поворотов ребра ео Рис. 4.12. Применение векторов при повороте ребра ab. Повернутое ребро cd имеет концевые точки c = m-|n и c = m + j-n Заметим, что функция rot является разрушающей. Она изменяет теку- щее ребро вместо того, чтобы формировать новое ребро. Функция возвра- щает ссылку на текущее ребро, поэтому обращение к функции rot может стоять непосредственно в более сложных выражениях. Это позволяет, на- пример, записать следующее краткое определение компонентной функции flip для изменения направления текущего ребра на обратное:
119 Глава 4 Edge &Edge::flip(void) { return rot().rot(); } При таком определении компонентной функции flip первое обращение к функции rot (слева от оператора доступа к элементу) поворачивает те- кущее ребро, а затем второе обращение поворачивает его еще раз. 4.4.3. Определение точки пересечения двух прямых линий Бесконечная прямая линия at>, проходящая через две точки а и Ь, может быть записана в параметрической форме как P(t) = a +t(b - а) (4.2) где параметр t может принимать любое значение в диапазоне вещественных чисел (если значения t ограничены диапазоном 0 < t < 1, то уравнение 4.2 описывает отрезок прямой линии об). Параметрическая форма описания прямой линии устанавливает соответствие между вещественными числами и точками на прямой линии. На рис. 4.13 показаны точки на прямой линии, соответствующие различным значениям параметра t. Компонентные функции Edge: : intersect и Edge:: point предусмотре- ны для совместной работы при поиске точки пересечения двух бесконечных прямых линий е и f. Если е и f являются объектами класса Edge, то фраг- мент программы double t; Point р; if (е.intersect(f,t) == SKEW) p = e.point(t); определяет t как параметрическое значение (вдоль прямой линии е) точки, в которой пересекаются прямые линии е и f и затем эта точка р становится текущей точкой. Функция intersect возвращает значения типа перечис- ления: SKEW (наклон), если прямые линии пересекаются в некоторой точке, COLLINEAR, если прямые линии коллинеарны, или PARALLEL, если они па- раллельны. Компонентная функция point обрабатывает параметрическое значение t и возвращает соответствующую точку. Задача решается обраще- нием к двум координатным функциям вместо обращения к одной общей функции, поскольку иногда нам достаточно определить параметрическое значение точки пересечения, а не саму точку пересечения. Рис. 4.13. Различные точки на прямой линии, проходящие через точки а и b
Структуры геометрических данных 113 Реализация компонентной функции point очень проста — значение параметра t подставляется в параметрическое уравнение для этой линии: Point Edge::point(double t) < return Point(org + t * (dest - org)); ) Реализация компонентной функции intersect основана на записи скаляр- ного произведения а Ь для двух векторов а = (ха, уа) и b — (хь, уь), которое определяется как а • b = хахь + УаУЬ- Скалярное произведение имеет несколь- ко важных свойств, включая основные: 1. Если а, b и с — векторы, то а • b = b • а и 2. а • (Ъ + с) = а • Ь + а • с = (Ь + с) • а. 3. Если s — скаляр, то (sa) b = s(a Ь) и а • (sb) = s(a b). 4. Если а — нулевой вектор, то а • а = 0, в противном случае а • а > 0. 5. ||а||2 = а • а. На основании этих основных свойств можно получить очень важное свой- ство, на котором основан целый ряд операций с прямыми линиями: два век- тора а и b перпендикулярны, если, и только если их скалярное произве- дение равно нулю а • Ъ = 0. Чтобы доказать это положение, заметим, что два вектора а и b перпендикулярны, если, и только если 11« - Ь[[ = ||а + Ь|| Это проиллюстрировано на рис. 4.14а. Возводя в квадрат правую и левую части, получим (а - Ь) • (а - Ь) = (а + Ь) • (а + Ь) Учитывая ранее упомянутые свойства с 1 по 3, получим после раскрытия скобок aa-2ab + bb = aa + 2a b + b b или, сокращая подобные члены, Рис. 4.14. Угол между векторами а и Ь: (а)—равен 90 градусов, если II а - b II = II а + b II; (б)—меньше 90 градусов, если II а - bll < II а + ЬП ; (в)—больше 90 градусов, если II а - b II > II а + ЬП;
114 Глава 4 4а • b = О отсюда а • b = О Следовательно условие а b = 0 будет удовлетворяться, если, и только если векторы а и b перпендикулярны. Мы можем сказать даже больше. Если угол между векторами а и b мень- ше 90 градусов, то ||а - Ь|| < ||а + Ь\\ (рис. 4.146). Подобные аргументы могут быть использованы для того, чтобы показать, что это эквивалентно условию а • b > 0. Аналогично можно доказать, что угол между векторами а и Ь будет больше 90 градусов, если, и только если а • b < 0 (рис. 4.14в). Эти резуль- таты обобщены в следующей теореме: Теорема 2. (Теорема о скалярном произведении). Пусть а и b - векторы и 0 - угол между ними. Тогда а Ь 0, если, и только если 0 90 градусов. Теорема о скалярном произведении может быть использована для нахож- дения точтси пересечения двух прямых линий аЪ и cd . Если прямая линия ab описывается уравнением(Р(р = а(+ ^(Ь - а), то будем искать зна- чение t такое, что прямые линии ab и cd пересекаются ;в уточке P(J). Поскольку вектор P(f) - с должен ^совпадать с прямой линией cd , то и век- тор P(J) - с и прямая линия cd должны быть перпендикулярны одному и тому же вектору п. Следовательно, на основании теоремы о скалярном произведении нам необходимо решить относительно t уравнение п (P(t) - с) = 0 (4.3) Поскольку P(t) = а + t(b - а), уравнение 4.3 можно переписать в виде п • ((а + t(b - а)) - с) = 0 Учитывая основные свойства скалярного произведения, получим п • (а - с) + п • (t(b - а)) = 0 Выводя за скобки параметр t, имеем п • (а - с) + £[(п • (Ь - а)] = 0 Откуда следует п • (а — с) t ------п • (Ь - а) * 0 (4.4) п • (Ь - а) Уравнение 4.4 удовлетворяется, если и только если бесконечные прямые линии ab и cd не параллельны и они пересекаются в единственной точке. Если две прямые линии параллельны или совпадают, то этот факт соответствует выполнению условия п • (Ь - а) = 0, поскольку оба вектора
Структуры геометрических данных 115 (Ъ - а) и (d - с) перпендикулярны одному и тому же вектору п. В результате получаем следующую реализацию компонентной функции intersect: enum { COLLINEAR, PARALLEL, SKEW, SKEW_CROSS, SKEW_NO_CROSS ); int Edge::intersect(Edge &e, double &t) { Point a = org; Point b = dost; Point c = e.org; Point d = e.dest; Point n = Point((d-c). y, (c-d). x); double denom = dotProduct(n, b-a); if (denom ==0.0) { int aclass = org.classify(e); if ((aclass==LEFT) | I (aclass=RIGHT)) return PARALLEL; else return COLLINEAR; } double num = dotProduct(n, a-c); t = -num / denom; return SKEW; ) Реализация функции dotProduct (скалярное произведение) очень проста: double dotProduct(Point &p, Point &q) { return (p.x * q.x + p.y * q.y); ) Компонентная функция Edge::cross возвращает значение SKEW_CROSS (наклонены и пересекаются), если и только если текущий отрезок прямой линии пересекает отрезок прямой линии е. Если отрезки прямой линии пересекаются, то возвращается значение параметра t вдоль этого отрезка прямой линии, соответствующее точке пересечения. В противном случае функция возвращает одно из следующих подходящих значений COLLINEAR (коллинеарны), PARALLEL (параллельны) или SKEW_NO_CROSS (наклоне- ны, но без пересечения). int Edge::cross(edge &e, double &t) ( double s; int crossType = e.intersect(*this, s); if ( (crossType=COLLINEAR) | | (crossType=PARALLEL)) return crossType; if ((s < 0.0) I I (s > 1.0)) return SKEW_NO_CROSS; intersect(e, t); if ((0.0 <= t) && (t <= 1.0)) return SKEW_CROSS; else return SKEW_NO_CROSS; )
116 Глава 4 4.4.4. Расстояние от точки до прямой линии Определение функции Point:: distance иллюстрирует некоторые из только что описанных свойств. В эту компонентную функцию класса Point передается ребро е и возвращается значение расстояния от текущей точки до ребра е со знаком. Здесь под термином расстояние от точки р до ребра е понимается минимальное расстояние от точки р до некоторой точки на бесконечной прямой линии, определяемой ребром е. Расстояние со знаком будет положительным, если точка р лежит справа от ребра е, отрицатель- ным, если точка р лежит слева от ребра е, либо нулевым, если точка р принадлежит прямой линии. Компонентная функция distance определяется следующим образом: double Point::distance(Edge &e) ( Edge ab=e; ab.flip().rot(); // поворот ab на 90 градусов // против часовой стрелки Point n(ab.dest - ab.org); // n = вектор, перпендикулярный // ребру е п — (1.0 / n.length()) * п; // нормализация вектора п Edge f(*this, *this + n); // ребро f = n позиционируется // на текущей точке double t; // t = расстоянию co знаком f.intersect(e, t) ; // вдоль вектора f до точки, // в которой ребро f пересекает return t; // ребро е } В функции сначала формируется единичный вектор п такой, что он пер- пендикулярен ребру е и направлен влево от ребра е. Затем вектор п пере- носится до совпадения его начала с текущей точкой, образуя ребро f. И, на- конец, вычисляется параметрическое значение точки пересечения ребра f с ребром е. Поскольку ребро f перпендикулярно ребру е, имеет единичную длину и начинается в текущей точке, то параметрическое значение t точки пересечения равно расстоянию со знаком от текущей точки до ребра е. 4.4.5. Дополнительные утилиты Последние три компонентные функции класса Edge введены для удоб- ства. Компонентная функция isVertical возвращает значение TRUE (исти- на) только в том случае, если текущее ребро вертикально: bool Edge::isVertical (void) { return (org.x == dest.x); } Компонентная функция slope возвращает величину наклона текущего ребра или значение DBL_MAX, если текущее ребро вертикально:
Структуры геометрических данных 117 double Edge::slope(void) { if (org.x != dest.x) return (dest.у - org.у) / (dest.x - org.x); return DBL_MAX; } Для компонентной функции у задается значение х и она возвращает зна- чение у, соответствующее точке (х, у) на текущей бесконечной прямой линии. Функция действует только в том случае, если текущее ребро не вер- тикально. double Edge::у(double х) { return slope() * (х - org.x) + org.у; ) 4.5. Геометрические объекты в пространстве Хотя в основном мы будем проводить исследования объектов на плос- кости, несколько последующих разделов этой книги будут посвящены объ- ектам в трехмерном пространстве. В данном разделе представим классы Point3D, Triangle3D и Edge3D для работы с точками, треугольниками и ребрами, расположенными в пространстве. Определение классов будет дано очень схематично, что обеспечивает лишь те функциональные свойства, которые нам будут необходимы. Более того, ради краткости многие из ком- понентных функций будут определены внутри определения их классов с не- многословным объяснением их работы. Это не должно помешать ясности из- ложения, поскольку большинство аналогичных концепций уже объяснялось в связи с двухмерными объектами на плоскости, более детально будут опи- саны лишь новые ситуации. 4.5.1. Точки В декартовой системе координат точка в трехмерном пространстве пред- ставляется упорядоченной триадой (х, у, г) вещественных чисел. Класс Point3D содержит компонентные данные х, у и z для хранения координат точки, конструктор, операции-функции для выполнения основных операций с векторами, операцию-функцию [ ] для обеспечения доступа к координа- там, компонентную функцию для вычисления скалярного произведения и еще одну функцию для определения положения точки относительно плос- кости. class Point3D { public: double x; double у; double z; Point3D(double _x, double _y, double _z) : x(_x), y(_y), z(_z) {} Point3D(void)
118 Глава 4 {} Point3D operator+ (Point3D &p) { return Point3D(x + p.x, y+p.y, z+p.z); } Point3D operator- (Point3D &p) { return Point3D(x -p.x, у - p.y, z-p.z); } friend Point3D operator*(double, Point3D &); int operator==(Point3D &p) { return ((x == p.x) && (y == p.y) && (z == p.z)); ) int operator!=(Point3D &p) { return !(*this == p); } double operator[](int i) { return ((i == 0) ? x : ((i == 1) ? у : z)); } double dotProduct(Point3D &p) ( return (x*p.x + y*p.y + z*p.z); } int classify(Triangle3D &t); ); Скалярное умножение реализуется функцией Point3D operator* (double s, Point3D &p) { return Point3D(s * p.x, s * p.y, s * p.z); ) Компонентная функция classify сообщает, с какой стороны плоскости, задаваемой треугольником t, располагается текущая точка. Ее определение дается в следующем подразделе. 4.5.2. Треугольники Треугольник полностью описывается своими тремя вершинами. Для ра- боты с треугольниками в пространстве для каждого треугольника кроме его вершин полезно отслеживать ограничивающий параллелепипед и его вектор нормали. Ограничивающий параллелепипед геометрического объекта — это наименьший параллелепипед, охватывающий объект, со сторонами, парал- лельными главным координатным осям. На рис. 4.15 приведено несколько примеров. Вектор, перпендикулярный заданной плоскости Р называется нормалью к Р. Для любых заданных трех неколлинеарных точек ро, pi и р2, лежащих в плоскости Р, нормаль к Р определяется векторным произведением axb для векторов а - рх - ро и Ь - р2 - ро- Пусть а = (ха, уа, za} и b - (хь, уь, 2ь), тогда векторное произведение векторов определяется как а X Ъ = (уа2ь ~ 2ауъ, ZaXb - XaZb, Хауъ - УаХЬ) (4.5) Векторное произведение векторов а и Ь вычисляется и возвращается функцией Point3D crossProduct(Point3D &а, Point3D &b) { return Point3D(a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.у * b.x) ; )
Структуры геометрических данных 119 Рис. 4.15. Ограничивающий прямоугольник (а) для кривой на плоскости,- (б) для треугольника на плоскости; (в) ограничивающий параллелепипед для треугольника в пространстве Чтобы доказать, что вектор векторного произведения а х Ъ перпендику- лярен плоскости, определяемой векторами а и Ъ, необходимо только пока- зать, что а • (а х Ь) = 0 и b • (а х Ь) - 0. Мы имеем а (а X &) = (Ха, Уа, 2а) (уа2Ъ ~ 2ауь, 2аХЪ - Ха2ъ, Хауъ ~ УаХЪ) = о поскольку все члены равны нулю. Равенство b • (а х Ь) - 0 доказывается ана- логично. Направление вектора векторного произведения показано на рис. 4.16. Треугольник Д0а& ориентирован положительно, если на него смотреть из точки а х & в пространстве. Вектор нормали, имеющий такую же длину, но противоположное направление, определяется как -а х Ъ = b х а . Заметим, что если векторы а и & лежат в плоскости ху, то длина вектора их векторного произведения равна ||а х &|| = ]хауь - Уа^ь] — площади парал- лелограмма с вершинами 0, а, b и а + Ь. Рис. 4.16. Векторное произведение а х b векторов а и b Обсудив понятия ограничивающего параллелепипеда и нормали, можно дать определение класса Triangle3D: class Triangle3D { private:
190 Глава 4 Point3D _v[3]; Edge3D —boundingBox; Point3D _n; public: int id; int mark; Triangle3D(Point3D &v0, Point3D &vl, Point3D &v2, int id); Triangle3D(void) {) Point3D operator[] (int i) ( return v[i]; } Edge3D boundingBox() ( return _boundingBox; } Point3D n(void) ( return _n; } double length(void) ( return sgrt(x*x + y*y + z+z); } ); Вершины текущего треугольника хранятся в массиве _v. Ограничиваю- щий параллелепипед представляется в виде ребра _boundingBox между углом с минимальными значениями координат и углом с максимальными значениями координат. Единичная нормаль к треугольнику записывается в элементе данных _п, она равна вектору векторного произведения (_v[l]-_v[0]) х (_v [2 ] -_v [ 0]), деленному на его длину. Элемент данных id является идентификатором этого треугольника. Первый конструктор Triangle3D требует определения макрофункций тахЗ и min3 для выбора наибольшего и наименьшего числа из трех чисел: #define min3 (А, В, С ) \\ ((А)<(В) ? ((А)<(С)?(А) : (С)) : ( (В) < (С) ? (В) : (С) ) ) #define тахЗ (А, В, С ) \\ ((А)>(В) ? ((А)>(С)?(А) : (С)) : ( (В) > (С) ? (В) : (С) ) ) Triangle3D::Triangle3D(Point3D &v0, Point3D &vl, Point3D &v2, int _id) { id = _id; mark = 0; _v[0] = vO; _v[l] = vl; _v[21 = v2; _boundingBox.org.x = min3(vO.x, vl.x, v2.x); _boundingBox.org.у = min3(v0.y, vl.y, v2.y); —boundingBox.org.z — min3(vO.z, vl.z, v2.z) ; —boundingBox.dest.x - max3(v0.x, vl.x, v2.x); —boundingBox.dest.у = тахЗ(vO.у, vl.y, v2 . y) ; —boundingBox.dest.z — тахЗ(uO.z, vl.z, v2.z); _n - crossProduct(vl - vO, v2 - vO) ; _n = (1.0 / _n.length()) * _n; ) Вершины объекта Triangle3D доступны через операцию-функцию [], для которой указывается индекс вершины (0, 1 или 2). Например, если t представляет собой объект Triangle3D, то t [0] соответствует его первой
Структуры геометрических данных 191 вершине. Доступ к ограничивающему параллелепипеду и единичному век- тору нормали осуществляется через компонентные функции boundingBox и п соответственно. Элементы геометрических данных объявлены как собст- венные, так что такое определение класса обеспечивает его надежность. Плоскость, заданная треугольником, делит все трехмерное пространство на два полупространства. Полупространство, на которое указывает вектор нормали треугольника, называется положительным полупространством треугольника, поскольку треугольник кажется положительно ориентирован- ным, если на него смотреть из этого полупространства. Другое полупро- странство называется отрицательным полупространством треугольника. Имея определение класса Triangle3D, можно приступить к определению компонентной функции Point 3: :classify. Напомним, что эта функция ус- танавливает, в каком полупространстве относительно заданного треугольни- ка р лежит текущая точка. Функция возвращает значение POSITIVE или NEGATIVE в зависимости от того, лежит ли точка в положительном или от- рицательном полупространствах плоскости р. Возвращается значение ON, если текущая точка лежит в плоскости, определяемой р: #define EPSILONl 1Е-12 enum { POSITIVE, NEGATIVE, ON } ; int Point3::classify(Triangle3 &p) { Points v = *this - p[0]; double len = v.length(); if (len == 0.0) return ON; v = (1.0 / len) * v; double d = v.dotProduct(p.n()) ; if (d > EPSILONl) return POSITIVE; else if (d < -EPSILONl) return NEGATIVE; else return ON; ) Вектор v представляет собой направленный отрезок прямой линии, ко- торый начинается в некоторой точке (р [ 0 ]) на плоскости и заканчивается в точке, которая должна быть классифицирована (*this). Для принятия ре- шения следует применить теорему о скалярном произведении, которая по- зволяет определить, будет ли угол между вектором v и вектором п нормали плоскости меньше, равен или больше 90 градусов. В функции предполагается, что плоскость треугольника tri располага- ется внутри слоя толщиной 2*EPSILON1. Точки, лежащие внутри этого слоя, считаются принадлежащими плоскости. Это сделано для того, чтобы избежать возможной ошибки округления, когда из-за ограничений представ- ления значений координат точки, принадлежащие плоскости, могут быть классифицированы как находящиеся вне плоскости.
199 Глава 4 4.5.3. Ребра Класс Edge 3D определен следующим образом: class Edge3D { public: Point3D org; Point3D dest; Edge3D(Point3D &_org, Point3D &_dest) org(_org), dest(_dest) {} Edge3D(void) {} int intersect(Triangle3D &p, double &t); Point3D point(double t); }; Первый конструктор инициализирует ребро с концевыми точками начала и конца, хранящимися в элементах данных org и dest. Компонентные функции intersect и point играют ту же роль, что их аналоги в классе Edge. Функция intersect находит значение параметра для точки на бес- конечной прямой линии, определяемой текущим ребром, в которой эта пря- мая линия пересекает плоскость треугольника р. Если такая точка пересе- чения прямой линии и плоскости существует, то нвйденное параметрически значение передается через параметр t и функция возвращает значение SKEW (наклон), в противном случае могут быть выданы возвращаемые значения PARALLEL или COLLINEAR. Как и ее аналог Edge: : intersect компонентная функция intersect работает на основе уравнения 4.4: int Edge3D::intersect(Triangle3D &p, double &t) { Point3D a = org; Point3D b = dest; Point3D c = p[0]; // произвольная точка на плоскости Point3 n — р.п(); double denom = n.dotProduct(b - a); if (denom ==0.0) ( int aclass = org.classify(p); if. (aclass != ON) return PARALLEL; else return COLLINEAR; } double num = n.dotProduct(a - c); t = —num / denom; return SKEW; } Компонентная функция point возвращает точку на текущей прямой линии, соответствующую параметрическому значению t: Point3D Edge3D::point(double t) { return org + t * (dest - org); }
Структуры геометрических данных 193 4.6. Определение пересечения прямой линии и треугольника В этом разделе мы обсудим решение одной интересной проблемы с ис- пользованием уже описанных средств — проходит ли прямая линия в про- странстве сквозь треугольник. Решение такой задачи окажется очень полез- ным в последующем изложении. Проекцией называется отражение пространства более высокого порядка на пространство более низкого порядка. Проекция часто используется для преобразования задачи в пространство более низкого порядка, где сущест- вуют способы ее решения. Рассмотрим проблему. Существует задача: опре- делить, проходит ли данная бесконечная прямая линия внутри заданного треугольника р. На рис. 4.17 показан один из подходов к решению этой задачи. Вначале вычисляется точка q, в которой бесконечная прямая линия пересекает плоскость треугольника р. Затем выполняется ортогональное про- ецирование треугольника р и точки q на плоскость ху, что дает треугольник р' и точку q'. Получающаяся задача в двухмерном пространстве — принад- лежит ли точка q' треугольнику р' — эквивалентна исходной задаче: ответ для двухмерного пространства будет да, если и только если ответ да будет в исходном пространстве. Преимущество такого преобразования заключается в том, что поиск решения в двухмерном пространстве гораздо проще, чем в исходном трехмерном пространстве. При проецировании иногда возникает неоднозначность и из-за потери ин- формации могут возникнуть сложности. Например, на рис. 4.176 два тре- угольника вырожденно проецируются на плоскости ху в один и тот же от- резок прямой линии. Нетрудно видеть, почему эта двухмерная задача не эквивалентна исходной задаче. Для двух заданных треугольников pi и рг, проецирующихся в один и тот же отрезок прямой линии, и бесконечной прямой линии I в пространстве, существуют две разных задачи в трехмер- ном пространстве (одна относится к определению взаимного соотношения I и pi, другая — к I и рг) и обе преобразуются в одинаковую задачу в двух- мерной плоскости ху. Если, скажем, прямая линия I проходит сквозь тре- угольник pi, но не проходит сквозь треугольник рг, то решение двухмерной задачи в одном из двух случаев даст неправильный результат. Чтобы оставить алгоритм решения задачи неизменным, перед проецированием необходимо выполнить проверку на вырождение. Треуголь- ник р проецируется в отрезок прямой линии на плоскости ху, если вектор нормали п треугольника перпендикулярен оси z. Этот тест должен быть вы- полнен до проецирования и, если окажется, что вектор нормали перпенди- кулярен оси г, то проецирование будем выполнять на плоскость уг. Если эта проекция также окажется вырожденной, то будем использовать проек- цию на плоскость гх. Поскольку вектор нормали не может быть перпен- дикулярен одновременно всем трем осям, по крайней мере одна из проекций будет невырожденной. Алгоритм реализуется в следующей функции, в которой возвращаемое значение типа перечисления PARALLEL, COLLINEAR, SKEW_CROSS или SKEW_NO_CROSS показывает соотношение между бесконечной прямой линией е и треугольником р. Если функция возвращает значение SKEW_CROSS, по- казывающее, что прямая линия проходит внутри треугольника, или SKEW_NO_CROSS, показывающее, что прямая линия пересекает плоскость
194 Глава 4 Рис. 4.17. (а) — будет ли прямая линии проходить внутри треугольника р?; (б) — оба треугольника вырожденно проецируются в один и тот же отрезок прямой линии треугольника, но точка пересечения лежит вне треугольника, то парамет- рическое значение точки пересечения, лежащей на прямой линии е, пере- дается через ссылочный параметр t: int lineTrianglelntersect (Edge3D &e, Triangle3D &p, double &t) { Point3D q; int aclass - e.intersect(p, t) ; if ((aclass==PARALLEL) I I (aclass==COLLINEAR)) return aclass; q = e.point(t); int h, v; if (p.n().dotProduct (Point3D(0, 0, 1)) != 0.0) { h = 0; v = 1 ; } else if (p.n().dotProduct(Point3D(l, 0, 0)) != 0.0) { h = 1; v = 2; } else { h = 2; v = 0; ) Polygon *pp - project(p, h, v); Point qp = Point(q[h], q[v]); int answer = pointlnConvexPolygon(qp, *pp); delete pp; return (answer ? SKEW_CROSS : SKEW_NO_CROSS); ) При обращении к функции project (р, h, v) возвращается полигон, представляющий собой проекцию треугольника р на плоскость hv. Аргумен- ты h и v являются индексами осей: например, оператор project (р, 0, 1) определяет проецирование треугольника р на плоскость ху. В функции project
Структуры геометрических данных 195 предполагается, что проекция треугольника р не вырождена, поэтому его про- екция также является треугольником. Функция определяется следующим об- разом: Polygon ‘project(Triangle3D &р, int h, int v) { // проецирование вершин треугольника р Point3D а; Point pts[3]; for (int i = 0; i < 3; i++) { a = p.v(i) ; pts[i] = Point (a[h], a[v]); } // сначала включение в полигон двух спроецированных вершин Polygon *рр = new Polygon; for (i = 0; i < 2; i++) pp->insert(pts[i)); // включение в полигон третьей спроецированной вершины if (pts[2].classify(pts[0], pts[l]) == LEFT) pp->advance(CLOCKWISE); pp->insert(pts[2]); return pp; ) Здесь есть очень интересный момент, заключающийся во включении пос- ледней из трех проецируемых вершин (pts [2]) в состав полигона при его формировании. Если три проецируемые вершины ориентированы отрица- тельно, то pts [2] включается после pts [1], если же они ориентированы положительно, то pts [2] включается после pts[0]. Этим обеспечивается, что внутренняя часть результирующего полигона рр лежит всегда справа от каждого образующего его ребра — так что последующее обращение к оператору pp->advance (CLOCKWISE) соответствует обходу по часовой стрел- ке. Проталкиванием окна для полигона рр, если проецируемые вершины ориентированы положительно, и обеспечивается выполнение этой операции. 4.7. Замечания Большинство математических операций, описанных в этой главе, отно- сится к элементам линейной алгебры. Векторы являются элементами алгеб- раической структуры, известной под названием векторного пространства. Хотя большинство аспектов линейной алгебры подразумевает геометричес- кую интерпретацию (наше представление и было направлено именно на такую интерпретацию), все результаты линейной алгебры могут быть полу- чены на основе обычной алгебры, не обращаясь к геометрическому пред- ставлению. Введение в линейную алгебру содержится в книгах [41, 44]. В ряде других книг описываются геометрические свойства и работающие программы для их использования в геометрических алгоритмах [3, 20, 61, 66, 73]. В них можно найти обоснование некоторых идей, изложенных в данной главе.
196 Глава 4 4.8. Упражнения 1. Показать, что выражение хауь — хьУа равно площади (со знаком) па- раллелограмма, определяемого векторами а - (ха, у а) и b - (хь, уь)- 2. Для заданных ненулевых векторов а и b показать, что а • b = ||а|| ||fe|| cos 0, где 0 — угол между векторами а и Ь. 3. Показать, что sin (а - Р) - sin а cos Р - cos а sin р 4. Показать, что выпуклый полигон с вершинами vi, ..., щ состоит из набора точек вида р = aivi + ... + akVk, где од + ... + од = 1 и каждый од > О (это выражение известно как выпуклая комбинация точек VI, ..., Vk). 5. Показать, что теорема о скалярном произведении остается справедли- вой для векторов в трехмерном пространстве. 6. Почему копирующий конструктор Polygon:: Polygon (Polygons) вы- полняет полную копию? (Подсказка: что плохого в том, что два объ- екта типа полигона ссылаются на один и тот же связанный список вершин?). 7. В чем заключаются преимущества и недостатки представления различ- ных видов прямых линий (бесконечных прямых линий, отрезков пря- мых линий и лучей) при использовании единственного класса Edge? 8. Напишите версию функции Polygon:: split, в которой осуществля- ется проверка ошибочных ситуаций. 9. На основе операции расщепления двухсвязного кольцевого списка на- пишите (разрушающую) функцию join (Polygon &р, Polygon &q), которая сливает полигоны р и q в один и возвращает указатель на новый полигон. 10. Напишите функцию, которая будет проверять полигон на выпуклость. 11. Дайте структуру данных для представления выпуклого n-гона, позволяю- щую определить за время O(log п) принадлежность полигону заданной точки. 12. Напишите функцию для определения, будет ли хордой заданная диа- гональ в заданном полигоне. 13. Напишите функцию, которая будет определять, не является ли объект класса Polygon недопустимым n-угольником, пересекающим сам себя. (Очевидный подход, который проверяет все пары ребер, занимает время О(п2). Подумайте об алгоритме, на выполнение которого требу- ется время О(п log п). 14. Придумайте алгоритм определения принадлежности точки произволь- ному (выпуклому или не выпуклому) n-угольнику, работающий за время О(п). 15. Придумайте алгоритм, работающий за время О(п log п), для опреде- ления взаимного пересечения двух полигонов, где п равно сумме их размеров.
Структуры геометрических данных 187 16. Придумайте алгоритм для определения взаимного пересечения двух выпуклых полигонов, выполняемый за время О(п), где п равно сумме размеров этих полигонов. 17. Напишите функцию для нахождения точки пересечения прямой линии и треугольника в пространстве без использования их проекций на плоскость. 18. Напишите функцию для определения факта пересечения двух тре- угольников в пространстве.
Часть II Применение
Глава 5 Пошаговый ввод С Х^уществует особый подход к разработке алгоритма ввода, носящий назва- ние пошаговый ввод, при котором весь процесс ввода сводится к обработке вво- димых элементов по одному, сохраняя принятое состояние всех ранее введенных элементов. На каждом следующем шаге анализируется и обрабаты- вается очередной вводимый элемент и текущее состояние модифицируется для восприятия нового элемента. После обработки всех введенных элементов задача оказывается выполненной. Здесь можно привести аналогию с детективным романом. Читателю со- общается рабочая гипотеза о потенциальном убийце и как и почему это убийство произошло. Каждая новая улика либо подтверждает гипотезу, либо требует ее уточнения, либо вообще исключения ее и выдвижения новой. К концу книги, когда приведены все улики, читатель сможет раскрыть пре- ступление, если он или она достаточно умны, а писатель был талантлив. Может оказаться, что в некоторых случаях алгоритм способен поддержи- вать только текущее состояние, в противоположность текущему решению, поскольку введенные на текущий момент данные могут оказаться далеко не полными, чтобы представить логически связную ситуацию. Например, такая ситуация часто возникает при решении задач с полигонами: если для зада- ния границы полигона вершины вводятся по одной, то нельзя анализировать даже простой полигон, пока не будут введены все вершины. Возвращаясь к нашей аналогии с детективной историей, этот пример может быть аналогичен ситуации, когда мнение читателя формируется еще до того, как произошло убийство — хотя еще и нечего раскрывать, но уже существуют улики и по- дозрения как первые сигналы для тревоги. Наиболее очевидный способ поиска наименьшего целого числа в массиве заключается в пошаговом просмотре массива, с сохранением ранее получен- ной последовательность наименьших чисел — это вычислительный пример пошагового ввода. Процесс ввода включает условное присвоение значения переменной, хранящей текущий минимум. На каждом этапе эта переменная содержит решение проблемы на основании тех чисел, которые уже введены и обработаны. Однако в общем случае процесс пошагового ввода не всегда так прост. В этой главе будет рассмотрен ряд ситуаций (иногда весьма ин- тересных), в которых используется рассматриваемая стратегия. Сначала рас- смотрим сортировку при вводе, хорошо известный способ сортировки, наи- более полезный для сортировки сравнительно небольших списков элементов. Остальные алгоритмы будут применяться для решения геометрических задач: определение звездчатого полигона среди конечного набора точек, на-
139 Глава 5 хождение огибающего многоугольника для набора точек, определение при- надлежности заданной точки внутренней области полигона, отсечение гео- метрических объектов (прямых линий и многоугольников) по границам вы- пуклого полигона, триангуляция монотонного полигона. 5.1. Сортировка при вводе Сортировка при вводе работает так же, как игрок в карты держит в руках карты. Вначале колода карт лежит на столе, лицом вниз. По мере взятия карт из колоды игрок располагает их в руке, помещая в опреде- ленном порядке. Перед взятием очередной карты все уже взятые карты от- сортированы по порядку. Рассмотрим, как использовать сортировку при вводе для упорядочения элементов массива а [0] , ..., а[п-1] в порядке увеличения. (Для крат- кости этот набор элементов будем обозначать как а [ 0 . . . п-1 ].) Для каждого i от 1 до п-1 в начале итерации i подмассив а[0. . . i-1] считается от- сортированным. Наша задача на шаге итерации i заключается в сортировке массива а [ 0 . . . i ] путем занесения элемента а [ i ] в его соответствующую позицию. Чтобы выполнить это, мы запишем элемент a[i] в некоторой переменной v и затем по очереди будем перемещать элементы a [i-1], a[i-2], . . . на один элемент вправо до тех пор, пока не найдем первый элемент a[j—1], не больший, чем v. Наконец скопируем значение v в «дырку», которая образовалась в позиции j. На рис. 5.1 показано, как этот алгоритм сортирует короткий массив целый чисел. 3 2 5 9 4 3 6 © 5 9 4 2 3 6 © 9 4 2 3 5 6 4 2 3 5 6 9 (Z 2 3 4 5 6 9 Рис. 5.1. Сортировка при вводе массива из шести целых чисел. Кружком отмечены очередные числа, подлежащие вводу Алгоритм реализован шаблоном функции insertionsort, которая сор- тирует массив а[0...п-1]. Аргумент стр устанавливает функцию сравне- ния, которая возвращает значения -1, 0 или 1, если ее первый аргумент меньше, равен или больше второго аргумента соответственно: templateCclass Т> void insertionsort(Т а[], int n, int (*cmp)(T,T)) { for (int i = 1; i < n; i++) { T v = a[i]; int j « i;
Пошаговый ввод 133 while ((j >0) && ((*cmp) (v, a[j-l]) < 0)) { a[j] = a[j-1]; j—; } a[j] = v; } } На каждой итерации i от 1 до п-1 цикл while вводит элемент a[i] в сортируемый подмассив а[0. . . i-1]. Проверка j > 0 в цикле while га- рантирует, что работа программы не нарушится при достижении начального элемента массива а в процессе ввода. В представленной здесь программе сортировки предполагается, что пара- метр типа Т в шаблоне является типом указателя. Тем не менее шаблон функции insertionsort, может быть использован для сортировки объектов любого типа, для которых существует оператор присвоения = и копирую- щий конструктор. Например, следующий фрагмент программы считывает 100 строк в массив s и затем сортирует их с помощью стандартной биб- лиотечной программы strcpy языка C++, которая сравнивает строки сим- волов в словарном порядке. char buffer[80]; char *s[100]; for (int i = 0; i < 100 ; i++) ( cin » buffer; s[i] = new char[strlen(buffer)+1]; strcpy(s[i], buffer); } insertionsort(s, 100, strcmp); Заметим, что в этом фрагменте программы сортируется скорее массив указателей_на_строки (массив s), чем сами строки. Очень часто оказыва- ется, что гораздо эффективнее сортировать указатели вместо самих объек- тов, на которые они указывают. Если объекты не очень малы (четыре байта или меньше), то сортирующая программа может перемещать указатели бы- стрее, чем сами объекты. 5.1.1. Анализ Для анализа процесса сортировки при вводе достаточно сосчитать коли- чество обращений к функции сравнения (предполагая, что сравнение выпол- няется за постоянное время). Время работы функции insertionSort равно Т(п) = £,"=1 ДО, где чеРез ДО обозначено время, необходимое для ввода i-того элемента. Поскольку I(i) соответствует по крайней мере i сравнениям, то на выполнение сортировки при вводе потребуется Т(п) = у "= t i = д<2±1> сравне- ний, или около nz/2 сравнений в худшем случае. Наихудший случай фак- тически получается тогда, когда вводимый массив отсортирован в обратном порядке (в порядке уменьшения). В среднем i-тый элемент сравнивается примерно с i/2 элементами, преж- де чем будет найдено его положение. Следовательно, в среднем в процессе сортировки при вводе будет выполнено около п2/2 сравнений, т. е. вдвое меньше, чем в худшем случае. Если входной массив вначале почти отсор- тирован, то ожидаемое время работы программы почти линейное, поскольку
134 Глава 5 г-тый элемент в среднем сравнивается только с постоянным числом элемен- тов перед достижением нужной позиции. Следовательно, сортировка при вводе является очень хорошим способом сортировки входного массива, о ко- тором известно, что он почти отсортирован. 5.2. Образование звездчатого полигона Конечный набор точек на плоскости может быть различным образом со- единен ребрами для образования полигона. Формирование каждого такого полигона можно назвать полигонизацией набора точек. В этом разделе мы разработаем способ звездчатой полигонизации, т. е. «соединения» точек таким образом, чтобы получить звездчатый полигон. 5.2.1. Что такое звездчатый полигон? Предположим, что точки р и q лежат внутри некоторого полигона. Будем говорить, что точка q видна из точки р, если отрезок прямой линии pq лежит внутри полигона. Здесь мы предполагаем, что граница полигона Р составлена из непрозрачных стен, а его внутренность заполнена прозрачной средой, подобной воздуху. Каждая точка может видеть другую точку только в том случае, если между ними нет никаких стен. Операция видимости сим- метрична (если точка р видит точку q, то и точка q видит р), но не тран- зитивна (если р видит q, a q видит г, то из этого не следует, что р видит г) (см. рис. 5.2). Рис. 5.2. Точки р и q, как и точки q и г, видят друг друга, но прямой видимости между точками р и г нет Рис. 5.3. Полигоны с выделенным ядром: (а) — выпуклый полигон,- (б) — веерообразный невыпуклый полигон; (в)—звездчатый полигон,- (г) - незвездчатый полигон
Пошаговый ввод 135 Набор точек внутри полигона, из которых видны все точки полигона, на- зывается ядром полигона. Говорят, что полигон звездчатый (рис. 5.3), если его ядро не пустое. Полигон называется веерообразным, если его непустое ядро со- держит одну или более вершин (каждая такая вершина называется корнем полигона). Каждый выпуклый полигон является веерообразным, поскольку его ядро включает несколько вершин (на самом деле все вершины). Каждый вее- рообразный полигон является звездчатым, поскольку его ядро не пустое. 5.2.2. Построение звездчатого полигона Для заданного набора S точек so, si,..., sn-i на плоскости задача заклю- чается в выполнении звездчатой полигонизации набора S. Нетрудно видеть, что может существовать более одного такого полигона. Но мы ограничимся поиском одного такого, ядро которого содержит первую точку so- Алгоритм работает итеративно, формируя текущий полигон из точек набо- ра S. Вначале текущий полигон является одноугольником so. На каждой ите- рации i от 1 до п-1 в текущий полигон добавляется следующая точка s/. По окончании обхода всех точек получается искомый звездчатый полигон. Чтобы ввести новую точку s/ в текущий полигон, будем выполнять обход текущего полигона по часовой стрелке, начиная с вершины so- Обход со- вершается по часовой стрелке вдоль границы полигона до тех пор, пока не будет достигнута вершина, которая должна стать последователем точки st, тогда точка st вставляется перед этой вершиной. Если завершен полный кру- говой обход и произошел возврат в точку so, тогда st- вставляется перед sq. Таким образом, вершина so служит в качестве стража, гарантирующего Рис. 5.4. Построение звездчатого полигона по набору точек
136 Глава 5 завершение обхода. На рис. 5.4 показано несколько моментов работы ал- горитма с небольшим числом точек. Функция starPolygon обрабатывает массив s из п точек и возвращает звездчатый полигон, ядро которого содержит точку s [ 0 ]: Point originPt; // global: originPt = s[0] Polygon *starPolygon(Point s[], int n) ( Polygon *p = new Polygon; p->insert(s[0]); Vertex *origin = p->v(); originPt = origin- >point(); for (int i = 1; i < n; i++) ( p->setV(origin); p->advance(CLOCKWISE); while (polarCmp(&s[i], p->v()) < 0} p->advance(CLOCKWISE); p->advance(COUNTER-CLOCKWISE); p->insert(s[i]); ) return p; ) Какие же действия нужно выполнить при каждой итерации i, чтобы оп- ределить, в какое место границы текущего полигона вставить точку st 1 Здесь применяется правило, что вершины звездчатого полигона радиально упорядочены вокруг любой точки его ядра. Поскольку точка so должна при- надлежать ядру, то определим функцию polarCmp, основанную на вычис- лении полярных координат точек относительно точки so (т. е. точка so счи- тается началом координат). В этих условиях точка р = (rp, Q р) считается меньше точки q = (rg, 0 g), если (1) 0 р < 0 q или (2) 0 р - 0 g и гр < гд.При таком упорядочении обход текущего полигона по часовой стрелке произво- дится от больших точек к меньшим точкам. В функцию сравнения polarCmp передаются две точки р и q, которые сравниваются по их радиальному упорядочению относительно точки originPt, являющейся глобальной переменной. Функция возвращает зна- чения -1, 0 или 1 в зависимости от того, будет ли ее первый аргумент р меньше, равен или больше второго аргумента q: int polarCmp(Point *p, Point *q) { Point vp = *p - originPt; Point vq = *q - originPt; double pPolar = vp.polarAngle(); double qPolar = vq.polarAngle() ; if (pPolar < qPolar) return -1; if (pPolar > qPolar) return 1; if (vp.length() < vq.length()) return -1; if (vp.length() > vq.length()) return 1; return 0; ) С точки зрения функции polarCmp точка originPt меньше любой другой точки на плоскости. Это происходит потому, что функция Point: :polarAngle
Пошаговый ввод 137 возвращает значение -1, если текущая точка совпадает с точкой originPt, и возвращает значение в диапазоне [0, 360] в противном случае. Это по- зволяет использовать точку s [0] (=originPt), совпадающую с началом ко- ординат, в качестве стража. Время работы функции starPolygon состав- ляет О(п2). Итерация i требует выполнения i сравнений и должно быть выполнено п - 1 итераций (здесь анализ аналогичен анализу сортировки при вводе). Алгоритм построения звездчатого полигона очень похож на сортировку при вводе. При работе обоих алгоритмов текущее решение, представляемое в виде упорядоченных элементов, постоянно растет до полного решения. При занесении нового элемента в текущую упорядоченную последователь- ность в обоих алгоритмах производится последовательный обход упорядо- ченного списка от большего значения к меньшему до тех пор, пока не будет найдено соответствующее место для нового элемента. Более того, оба алго- ритма выполняются за квадратичное время в худшем случае. 5.3. Поиск выпуклой оболочки Алгоритм поиска выпуклой оболочки для набора точек, который будет рассмотрен в этой главе, значительно сложнее алгоритмов сортировки при вводе и построения звездчатого полигона. Во-первых, определение правиль- ного положения каждого нового элемента дело более сложное. Во-вторых, иногда требуется удаление элементов из текущего решения, так что в про- цессе работы алгоритма текущее решение растет и сжимается. 5.3.1. Что такое выпуклая оболочка? Пусть S будет конечным набором точек на плоскости. Выпуклая оболочка набора S, обозначаемая как CH (S), равна пересечению всех выпуклых поли- гонов, которые содержат S. Иными словами, CH (S) — это выпуклый поли- гон минимальной площади, содержащий все точки S. Существует и другое определение, утверждающее, что CH (S) равно объединению всех треуголь- ников, определяемых точками набора S. Вообразим, что плоскость представляет собой деревянную доску, утыкан- ную гвоздями в каждой точке из набора S. Теперь натянем вокруг гвоздей Рис. 5.5. Конечный набор точек и его выпуклая оболочка
138 Глава 5 резиновую нить, связав ее в кольцо. Резиновая нить образует выпуклую обо- лочку, ее пример показан на рис. 5.5. Выпуклые оболочки оказываются очень полезными в целом ряде геомет- рических приложений, поскольку они представляют способ аппроксимации набора точек или других невыпуклых оболочек. Например, при распозна- вании образов неизвестная форма может быть представлена ее выпуклой оболочкой или набором выпуклых оболочек, которые затем могут быть со- поставлены с набором известных форм. В качестве другого примера можно назвать моделирование движения. Работа среди некоторых объектов значи- тельно упрощается, если робот описывается своей выпуклой оболочкой. Точки из набора S могут быть классифицированы относительно выпуклой оболочки CH (S). Точки называются граничными точками, если они лежат на границе выпуклой оболочки, или внутренними точками, если они при- надлежат внутренней области внешней оболочки. Граничные точки, обра- зующие «угловые» вершины выпуклой оболочки, можно назвать выступа- ющими точками. Граничные точки являются выступающими, если они не лежат на одной прямой между двумя другими точками из набора S. Эти замечания отображены на рис. 5.5. Заметим, что такая схема классифика- ции точек применима и тогда, когда не требуется находить саму выпуклую оболочку. 5.3.2. Ввод оболочки Ввод оболочки представляет собой специальный подход к поиску выпук- лой оболочки для конечного набора точек S путем последовательного ввода по одной точке и поддержание выпуклой оболочки для всех точек, введен- ных до этого момента. Такую оболочку будем называть текущей оболочкой. Вначале текущая оболочка состоит из одной точки, принадлежащей S. По завершении ввода всех точек текущая оболочка становится эквивалентной выпуклой оболочке CH (S) и задача оказывается решенной. При вводе каждой новой точки s в текущую оболочку могут возникнуть две ситуации. В первом случае точка s может принадлежать текущей обо- лочке (находиться на ее границе или внутри) и тогда текущую оболочку изменять не требуется. Во втором случае точка s лежит вне текущей оболочки и ее требуется модифицировать, как показано, например, на рис. 5.6. Через точку s могут Рис. 5.6. Включение точки s в текущую оболочку
Пошаговый ввод 139 быть проведены две вспомогательные прямые линии, каждая из которых образует касательную к текущей оболочке. (Линия является касательной к выпуклому полигону Р, если она проходит через вершину Р и внутренняя область полигона Р полностью лежит по одну сторону от этой прямой линии). Левая (правая) касательная sr проходит через некоторую вершину £ (г) текущей оболочки и лежит слева (справа) от текущей оболочки. Если наблюдатель находится в точке s и смотрит на выпуклую оболочку, то левая касательная будет видна слева, а правая касательная — справа. Две вершины £ и г, через которые проходят касательные, делят границу текущей оболочки на две цепочки вершин: ближняя цепочка, которая рас- положена ближе к точке s, и дальняя цепочка, которая находится дальше от точки s. (Ближняя цепочка расположена с той же стороны от отрезка прямой I г , что и точка s, дальняя цепочка располагается по другую сто- рону от отрезка £ г). Для модификации текущей оболочки сначала необхо- димо найти две вершины £ и г, определяющие границу между ближней и дальней цепочками. Затем мы должны удалить вершины ближней цепочки (за исключением вершин £ и г) и внести точку s в нужном месте. Следующая программа insertionHull возвращает текущую оболочку для массива s из п точек: Point soznePoint; // global Polygon *insertionHull (Point s[], int n} ( Polygon *p = new Polygon; p->insert(s[0]); for (int i = 1; i < n; i++) { if (pointlnConvexPolygon(s[i], *p)) continue; soznePoint = s[ij; leastvertex(*p, closestToPolygonCmp); supportingLine(s[i], p, LEFT); Vertex *1 = p->v(); supportingLine(s[i] , p, RIGHT); delete p->split(l); p->insert(s[i]); ) return p; На шаге i точка s [i] вносится в текущую оболочку р. Обращение к функ- ции leastvertex перемещает окно полигона р на вершину, которая распо- ложена ближе всего к точке s [ i ]. При этом осуществляется подготовка к последующему обращению supportingLine (s [i], р, LEFT), переносяще- му окно на вершину £, через которую проходит левая касательная. При вто- ром обращении к функции supportingLine окно перемещается на вершину г. Операция расщепления split применяется для удаления полигона р по его диагонали / г , отделяя таким образом ближнюю цепочку от дальней. Часть полигона, содержащая ближнюю цепочку, возвращается функцией split и удаляется. После этого точка s[i] вставляется в полигон р, который, после выполнения функции split, состоит только из дальней цепочки. Рассмотрим функцию supportingLine более детально. Для определения вершины £, через которую проходит левая касательная, начнем с некоторой
140 Глава 5 вершины в ближней цепочке и затем будем совершать обход по часовой стрелке вдоль текущей оболочки до тех пор, пока не дойдем до первой вер- шины V, последователь которой не лежит ни слева, ни после направленного отрезка прямой линии si. Вершина v и будет вершиной I, которую мы ищем. Заметим, что если последователь для вершины v (например, верши- на w) располагается за отрезком si, то процесс поиска продолжается, по- скольку вершина v не может быть крайней точкой, если она лежит между точками s и w. При обращении к функции supportingLine в качестве аргументов задается полигон р, точка s вне полигона р и один из параметров типа перечисления LEFT или RIGHT (слева или справа), показывающий, какая из вершин (£ или г) имеется в виду. Предполагается, что вершина, находящаяся в окне для поли- гона р, принадлежит ближней цепочке, что обеспечивается предварительным обращением к функции leastvertex. Функция перемещает окно полигона р на вершину, которую она находит (£ или г): void supportingLine(Point &s, Polygon *p, int side} ( int rotation = (side==LEFT) ? CLOCKWISE : COUNTERCLOCKWISE; Vertex *a = p->v(); Vertex *b = p->neighbor(rotation); int c = b->classify(s, *a); while ((c == side) || (c = BEYOND) |I (c == BETWEEN)) { p->advance(rotation); a = p->v() ; b = p->neighbor(rotation); c = b->classify(s, *a);} } } Функция leastvertex, описанная в подразделе 4.3.6, используется програм- мой insertionHull для нахождения в полигоне р вершины, расположенной ближе всего к точке, хранящейся в глобальной переменной somePoint. Функ- ция сравнения closestToPolygonCmp, вместе с которой вызывается функция leastvertex, сравнивает две точки и выбирает, какая из них находится ближе к точке somePoint: int closestToPolygonCmp(Point *a, Point *b} ( double distA = (somePoint - *a).length(); double distB = (somePoint - *b).length(); if (distA < distB) return -1; else if (distA > distB) return 1; return 0; } 5.3.3. Анализ В процессе своей работы программа insertionHull может построить очень большую оболочку, которая к концу работы программы может ока- заться полностью исключенной. Рассмотрим ситуацию, показанную на рис. 5.7а, когда в состав оболочки внесены все точки, кроме точек р, q и г. Включение каждой из этих последних трех точек приводит к исключению части ранее внесенной цепочки до тех пор, пока не останется один тре-
Пошаговый ввод 141 угольник (рис. 5.7б-г). Очевидно, что если бы эти три точки р, q и г были внесены первыми, перед всеми остальными точками, то треугольная обо- лочка была бы сформирована с самого начала и внесение каждой из пос- ледующих точек было бы выполнено быстрее, так как потребовалось бы только определять, что точки находятся внутри треугольника. Таким об- разом, порядок включения точек может очень сильно влиять на эффек- тивность. Следует отметить, что стоимость выполнения операций по формированию выпуклой оболочки мало зависит от операций insert и split, использу- емых при включении и исключении точек в процессе отслеживания теку- щей оболочки. Кроме того, каждая точка может быть включена и исклю- чена не более одного раза. Из этого следует, что общая цена операций insert и split при реализации алгоритма ограничена сверху величиной О(п). Аналогично и обращения к функции supportingLine сравнительно не- дорогие: оба обращения к ней на каждой итерации занимают время, про- порциональное длине ближней цепочки, затрачиваемое на обработку вершин в ближней цепочке, которые затем удаляются на этом же шаге. Поскольку каждая вершина может быть удалена только однажды, стоимость выпол- нения всех обращений к функции supportingLine в процессе работы ал- горитма ограничена сверху величиной О(п). Оказывается, что функция insertionHull тратит основное время на выполнение функций pointInConvexPolygon и leastvertex. Для обра- ботки i-той точки si обращение к каждой из этих двух функций занимает время, пропорциональное числу i в худшем случае (когда выпуклая обо- лочка содержит i вершин). Этот случай относится ко всем точкам 8/, если они являются внешними, поскольку текущая оболочка увеличивается на одну вершину при занесении каждой последующей точки. Следовательно, в худшем случае функция insertionHull работает в течение време- ни О(п2). Несколько позже в этой книге мы рассмотрим два других алгоритма — сканирование Грэхема и слияние оболочек, которые формируют выпуклую оболочку из п точек в течение оптимального времени О(п log п). Рис. 5.7. Три точки р, q и г внесены последними
142 Глава 5 5.4. Принадлежность точки: метод луча В главе 4 был рассмотрен простой алгоритм для решения проблемы при- надлежности точки выпуклому полигону. Этот алгоритм определял, лежит ли заданная точка внутри, вне или на границе выпуклого полигона р. Он работает по принципу поочередной проверки положения точки а относитель- но каждого ребра р. Если обнаруживается, что точка а находится в отри- цательной области какого-либо ребра, то считается, что она находится вне полигона, в противном случае она считается принадлежащей полигону р. Алгоритм использует тот факт, что внутренняя часть полигона лежит це- ликом с одной и той же (положительной) стороны каждого ребра, так что любая точка, расположенная с другой стороны хотя бы одного ребра, не может находиться внутри полигона. Но этот алгоритм не может правильно решить задачу принадлежности точки в более общем случае произвольного полигона (когда он может быть как выпуклым, так и звездчатым). Напри- мер, на рис 5.8 показан случай, когда внутренняя часть полигона распо- ложена с той и другой стороны от ребра, обозначенного буквой е, и по- скольку точка а лежит с «другой» стороны от ребра е, то упомянутый алгоритм будет считать, что точка а находится вне полигона. Проблема определения принадлежности точки к выпуклому полигону сво- . дится к задаче анализа списка неотсортированных чисел — для выпуклого поли- гона они должны быть больше нуля или равны нулю. Для решения такой задачи следует поочередно проверить каждое число и при обнаружении первого отри- цательного числа выдается ответ «нет», если такого числа не обнаруживается, то выдается ответ «да». Ответ «да» будет получен только в том случае, если каждое из ряда проверяемых условий оказывается истинным. С другой стороны, проблема определения принадлежности точки произ- вольному полигону подобна задаче определения, будет ли сумма чисел в не- отсортированном списке больше нуля или равна нулю. Задача не может счи- таться выполненной, пока не будут просуммированы все числа. Суммирование только нескольких чисел, даже всех, но хотя бы без одного, не может привести к правильному результату, поскольку оставшееся число может полностью изменить ситуацию. Точно так же, при частичном иссле- довании полигона можно ошибочно считать, что он не включает некоторую удаленную точку, тогда как может оказаться, что несколько последних ребер образуют своего рода «палец», простирающийся далеко от основной части полигона, и он включает эту удаленную точку. Решение о принад- лежности точки произвольному полигону может быть принято только на ос- нове одного условия, рассматривающего полигон целиком. Рис. 5.8. Как определить, лежит ли точка а внутри полигона?
Пошаговый ввод 143 Рис. 5.9. Каждый луч, исходящий из точки а, пересекает границу нечетное число раз, а каждый луч, исходящий из точки Ь, имеет четное число пересечений с границей полигона В этом разделе для решения проблемы принадлежности точки произволь- ному полигону мы рассмотрим метод трассировки луча. Предположим, что нам необходимо определить принадлежность точки а полигону р. Для этого из некоторой удаленной точки проведем прямую линию в точку а. На этом пути может встретиться нуль или несколько пересечений границы полигона: при первом пересечении мы входим внутрь полигона, при втором — вы- ходим из него, при третьем пересечении снова входим внутрь и так до тех пор, пока не достигнем точки а. Таким образом каждое нечетное пересе- чение означает попадание внутрь полигона р, а каждое четное — выход из него. Если мы попадаем в точку а с нечетным числом пересечений границы полигона, то точка а лежит внутри полигона, а если получается четное число пересечений, то точка находится вне полигона р. Например, на рис. 5.9 луч Та пересекает границу только однажды, поскольку единица число нечетное, то точка а находится внутри полигона. Относительно точки Ъ мы прийдем к заключению, что она лежит вне полигона, поскольку луч гъ пересекает границу четное число раз (дважды). Построение алгоритма на основе этой идеи базируется на двух особен- ностях. Во-первых, для решения задачи подходит любой, луч, начинающийся в анализируемой точке а (рис. 5.9). Поэтому для прпстоты выберем правый горизонтальный луч г%, начинающийся в точке а и направленный вправо параллельно положительной полуоси х. Во-вторых, порядок расположения ребер, пересекаемых лучом Та безраз- личен, имеет значение лишь парность (четное или нечетное количество) их общего числа. Следовательно, вместо моделирования движения вдоль луча Га для алгоритма достаточно определить все пересечения ребер в любом порядке, определяя четность по мере продвижения. Простейшим Рис. 5.10. Ребро с является пересекающим ребром, ребро d — касательное, ребро е — безразличное
144 Глава 5 решением будет обход границы полигона с переключением бита четности при каждом обнаружении ребра, пересекаемого лучом г%. Относительно горизонтального луча г^> направленного вправо, будем разли- чать три типа ребер полигона: касательное ребро, содержащее точку а; пере- секающее ребро, не содержащее точку а; безразличное ребро, которое совсем не имеет пересечения с лучом Гд. Например, на рис. 5.10 ребро с является пере- секающим ребром, ребро d — касательным, а ребро е — безразличным. Функция pointlnPolygon решает задачу о принадлежности для точки а и полигона р. В процессе работы алгоритма обходится граница полигона и переключается переменная parity для каждого обнаруженного факта пере- сечения ребра лучом. Возвращается значение INSIDE типа перечисления, если окончательное значение переменной parity равно 1 (означающее нечет- ное число), или возвращает OUTSIDE, если это конечное значение равно 0 (означающее четность). Если обнаруживается касательное ребро, то алгоритм немедленно возвращает значение типа перечисления BOUNDARY (граница). enum { INSIDE, OUTSIDE, BOUNDARY }; // положение точки // ВНУТРИ, ВНЕ, НА ГРАНИЦЕ enum { TOUCHING, CROSSING, INESSENTIAL }; // положение ребра // КАСАТЕЛЬНОЕ, ПЕРЕСЕКАЮЩЕЕ, НЕСУЩЕСТВЕННОЕ int pointlnPolygon(Point &а, Polygon &р} { int parity = 0; for (int i = 0; i < p.size(); i++, p.advance(CLOCKWISE)) { Edge e = p.edge() ; switch (edgeType(a, e)) { case TOUCHING: return BOUNDARY; case CROSSING: parity = 1 - parity; ) ) return (parity ? INSIDE : OUTSIDE); ) Обращение к функции edgeType(a, e) классифицирует положение ребра е относительно горизонтального луча Га, направленного вправо, воз- вращая значения TOUCHING, CROSSING или INESSENTIAL (касательное, пересекающее или безразличное) типа перечисления. Определение функции edgeType несколько хитроумное, поскольку функция pointlnPolygon должна правильно обрабатывать особые ситуации, возникающие при про- хождении луча точно через вершины. Рассмотрим рис. 5.11. В случае (а) бит четности должен быть переключен — луч пересекает границу только однажды, хотя при этом на самом деле пересекает два ребра. В случаях (б) и (в) четность не изменяется. Это может быть достигнуто путем неко- торого уточнения схемы классификации ребер: • Ребро е — касательное ребро, если оно содержит точку а. • Ребро е — пересекающее ребро, если (1) ребро е не горизонтальное и (2) луч Га пересекает ребро е в некоторой точке, отличающейся от его нижней конечной точки. • Ребро е — безразличное, если оно не касательное и не пересекающее.
Пошаговый ввод 145 Обращаясь к рис. 5.11, мы можем видеть, что в случае (а) переменная parity должна переключиться однажды, в случае (б) parity не изменя- ется, в случае (в) parity переключается дважды и ситуация остается без изменения. Заметим, что горизонтальное ребро, не включающее точку а, считается безразличным и оно игнорируется функцией pointlnPolygon. Поэтому случаи (г), (д) и (е) обрабатываются точно так же, как соответст- вующие случаи (а), (б) и (в). Итак, функция edgeType классифицирует ребро е как TOUCHING, CROSSING или INESSENTIAL (пересекающее, касательное или безразличное) относительно точки а: int edgeType(Point &а, Edge &е) ( Point v = e.org; Point w = e.dest; switch (a.classify(e)) { case LEFT : return ((v.y<a.y)&&(a.y<=w.^6 ) ? CROSSING : INESSENTIAL; case RIGHT: return ((w.y<a.y)&&(a.y<=v.y)) ? CROSSING : INESSENTIAL; case BETWEEN : case ORIGIN : case DESTINATION : return TOUCHING; default : return INESSENTIAL; } } Обратите внимание, как функция edgeType обнаруживает пересекающие ребра. Если точка а лежит слева от ребра е, то ребро является пересека- ющим, только если вершина начала ребра v(=e.org) лежит ниже луча г£, а вершина конца w(=e.dest) расположена на луче или лежит выше его. Тогда ребро не может быть горизонтальным и луч должен пересекать ребро в некоторой точке, отличающейся от его нижнего конца. Если точка а находится справа от ребра е, то роли конечных точек v и w взаимно ме- няются. Рис. 5.11. Специальные случаи: (а) и (г) — переменная parity переключается однажды; (б) и (А) — parity не изменяется,- (в) и (е) — parity переключается дважды и ситуация остается без изменения
146 Глава 5 В худшем случае (когда точка а не принадлежит границе полигона) время выполнения программы point InPolygon пропорционально размеру полигона. 5.5. Принадлежность точки: метод углов Рассмотрим другой подход к решению проблемы принадлежности точки. При этом подходе требуется определить понятие угла со знаком. Пусть даны направленный отрезок прямой линии Ъс и некоторая точка а и предполо- жим, что угол между векторами ab и а? равен 0. Тогда угол со знаком для точки а относительно отрезка Ъс равен 0, если точка с лежит слева от вектора ао или коллинеарна с ним, или -0, если точка с лежит справа от вектора аЬ. Заметим, что угол со знаком и ориентация треугольника Aabc имеют одинаковые знаки. Расширим определение угла со знаком для цепочки вершин. Угол со зна- ком для точки а относительно цепочки вершин равен сумме углов со знаком для точки а относительно ребер, входящих в эту цепочку. На рис. 5.12 по- казан ряд примеров. Функция signedAngle вычисляет и возвращает значение угла со знаком для точки а относительно ребра е. После исключения случая, когда точка а коллинеарна с ребром е, функция различает ситуации, показанные на рис. 4.6: double signedAngle (Point &а, Edge &е) ( Point v = е.org - a; Point w = e.dest - a; double va = v.polarAngle(); double wa = w.polarAngle(); if ((va == -1.0) II (wa == -1.0)) return 180.0; double x = wa - va; ii ((x == 180.0) || (x—— -180.0)) return 180.0; else if (x < -180.0} return (x + 360.0); else if (x > 180.0) return (x - 360.0); else return x; } Рассмотрим теперь, как углы со знаком могут быть использованы для определения положения точки а относительно заданного полигона р. Пред- положим, что точка а не лежит на границе полигона р. Обозначим буквой А величину угла со знаком в точке а относительно границы полигона р, причем обход полигона р совершается в направлении по часовой стрелке. Значение угла А позволяет определить принадлежность точки: если А = —360 градусов, то точка а лежит внутри полигона, и А = 0, если точка а расположена вне полигона. Очень легко показать справедливость этого ут- верждения для выпуклых полигонов. Если точка а находится внутри вы- пуклого полигона р, то при обходе границы полигона р совершается поворот
Пошаговый ввод 147 (а) Рис. 5.12. (а) Угол со знаком для точки а равен 20; (б) Угол со знаком для точки b равен -90; (в) Угол со знаком Мя точки с равен 20-90+40=-30 на полные 360 градусов. В противном случае, если точка а расположена вне полигона р, то граница полигона может быть расщеплена на две це- почки, ближнюю и дальнюю к точке а (определение ближней и дальней цепочки было дано в разделе 5.3). Если через Ап обозначить угол со знаком относительно ближней цепочки, а через А/ — угол со знаком относительно дальней цепочки, то будем иметь Ап = -А/, откуда следует иметь А — Ап + Af — 0. Менее очевидно, что это условие сохраняется и для невыпуклых полиго- нов. Вначале предположим, что точка а находится вне полигона. Вообразим проведение лучей из точки а через каждую вершину полигона, разбивая таким образом полигон на несколько треугольников и выпуклых четырех- угольников pi, Р2, .... Рк (рис. 5.13а). Поскольку точка а лежит вне каждого из pi, а каждый pi является выпуклым, то угол со знаком А для точки а относительно границ pi равен нулю (т. е. А/ = 0). Но А - Ai + ... + Ak посколь- ку при суммировании каждое ребро исходного полигона р учитывается точно один раз. Обратите внимание, что новые ребра, образованные при включении лучей, добавляют лишь нулевые значения к А. Из этого следует, что А = 0, если точка а находится вне полигона. Из рис. 5.136 ясно, почему А = -360, если точка а находится внутри поли- гона р. Как и раньше, предположим разделение полигона лучами, исходя- Рис. 5.13. (а) — точка а вне полигона, поэтому А = At + Az + Аз = О + 0 + О - О; (б) — точка а внутри полигона, поэтому А = А> + А|+/^ + Аз + Аг =-360 + 0 + 0 + 0 + 0 =-360
148 Глава 5 щими из точки а, но на этот раз предусмотрим небольшой выпуклый поли- гон в окрестности точки а (на рис. он обозначен как ро)- Поскольку ро — это выпуклый полигон и внутри него содержится точка а, то А = -360. Далее, поскольку точка а располагается вне оставшихся (выпуклых) поли- гонов pt, то будем иметь At = 0) для всех i = 1, 2, . . ., k. Из этого следует, что А = Ao + Ai + ... + Ak - -360. Функция pointln₽olygon2 решает задачу принадлежности для точки а и полигона р. Угол со знаком для точки а относительно полигона накап- ливается в переменной total по мере обхода каждого ребра по очереди. Если будет обнаружено, что точка а располагается на каком-либо ребре (угол со знаком относительно такого ребра будет составлять 180 градусов), то функция немедленно возвращает значение типа перечисления BOUNDARY (граница). В противном случае после обработки всех ребер полигона возвра- щается одно из значений INSIDE или OUTSIDE (внутри или снаружи) в за- висимости от окончательного значения, накопленного в переменной total. int pointInPolygon2(Point &a, Polygon &p) { double total = 0.0; for (int i = 0; i < p.sizeO; i++, p.advance(CLOCKWISE)) { Edge e = p.edge(); double x = signedAngle(a, e) ; if (x == 180.0) return BOUNDARY; total += x; } return ((total < -180.0) ? INSIDE : OUTSIDE); ) В худшем случае время работы программы point InPol ygon2 находится в линейной зависимости от размера полигона. 5.6. Отсечение линий: алгоритм Цируса-Бека Процесс выделения той части геометрического объекта, которая лежит вне заданной области, называется отсечением. Отсечение очень часто при- меняется в машинной графике. В оконных системах окно может служить для выделения небольшой части общей панорамы, далеко простирающейся во все стороны. При просмотре панорамы необходимо отсечь все те части объектов, которые не попадают в область окна. В некоторых текстовых ре- дакторах необходимо отсечь те литеры, которые не умещаются в пределах длины строки. В трехмерной графике объекты отсекаются по заданному объему до их проецирования на плоскость проекции, чтобы избежать не- нужных затрат на проецирование тех частей, которые все равно не будут видны. В этом разделе будет описан алгоритм Цируса-Бека для отсечения отрез- ков прямых линий по границам выпуклого полигона. В следующем разделе раскроем алгоритм Сазерленда-Ходжмана для отсечения произвольного полигона по границам выпуклого полигона. Обозначим через s отрезок прямой линии и р — выпуклый п-угольник, по границам которого должен быть отсечен заданный отрезок. Здесь р на-
Пошаговый ввод 149 зывается отсекающим полигоном, a s является субъектом действия. Мы будем искать s А р, т. е. ту часть s, которая лежит внутри р. Пусть через s* будет обозначена одна из двух направленных полубеско- нечных прямых линий, определяемых заданным отрезком s. Предположим, что мы как бы продляем каждое из п ребер полигона до бесконечности в обоих направлениях. Прямая линия s* пересекает все эти продолженные ребра полигона р не более, чем в п различных точках пересечения. (Если полупрямая s* параллельна или коллинеарна некоторому ребру полигона р, то ребро не образует точки пересечения, и, более того, если прямая в* про- ходит через вершину полигона р, то две точки пересечения совпадают). Отсекающий алгоритм Цируса-Бека находит эти точки пересечения и классифицирует каждую из них либо как потенциально входящую (ПВ), либо как потенциально покидающую (ПП). Предположим, что линия 7* пересекает продолжение ребра е в точке I. Точка i будет типа ПВ, если полу- прямая "s* переходит из области слева от е в область справа от е. Учитывая наше соглашение о том, что внутренняя часть полигона лежит справа от каждого из его ребер, полупрямая 7* «потенциально входит» внутрь поли- гона в точке пересечения i. Поэтому если часть s А р не пуста, то s А р должна лежать после точки i (см. рис. 5.14). Точка пересечения i будет типа ПП, если полупрямая s проходит из области справа от е в область слева от е. В этом случае полупрямая s «потенциально покидает» полигон р и тогда если часть s А р не пуста, то s А р должна лежать позади точки пересечения I. Классифицировать точку пересечения по ее типу ПВ или ПП сравнитель- но легко. Пусть вектор п будет перпендикулярен к некоторому ребру е от- секающего полигона и направлен вправо от ребра е. Определим вектор v = b - а, где 7* = а&. Тогда точка пересечения i (в которой полупрямая 7* пересекает ребро е) будет типа ПВ, если угол между векторами п и v будет меньше 90 градусов, или типа ПП, если этот угол больше 90 градусов. (Если угол равен точно 90 градусов, то точки пересечения не существует, поскольку в этом случае полупрямая ~s* должна быть параллельной или кол- Рис. 5.14. Отсечение отрезков прямой линии si, S2 и S3 по границам прямоугольного отсекающего полигона. Точки пересечения обозначены цифрами в соответствии с порядком их расположения вдоль каждой прямой линии S,. Обозначения для потенциально входящих точек пересечения обведены кружком, ос- тальные точки относятся к типу потенциально покидающих
150 Глава 5 линеарной с ребром е). В терминах скалярного произведения точка i отно- сится к типу ПВ, если п v > 0, и к типу ПП, если п • v < 0. Как же можно использовать такую классификацию точки пересечения? Предположим, что точки пересечения упорядочены вдоль полупрямой ~s*. Тогда полупрямая 7* пересекает отсекающий полигон р только тогда, когда после последовательности точек пересечения типа ПВ следует последователь- ность точек пересечения типа ПП. Более того, искомый отсекаемый сегмент отрезка прямой линии s А р начинается в последней точке типа ПВ и про- должается до первой точки типа ПП (отрезки si и 82 на рис 5.14). С другой стороны, если точки пересечений типов ПВ и ПП встречаются вдоль полупрямой ~s* поочередно, то отсекаемый сегмент s А р будет пустым (отрезок аз на рис. 5.14). Поскольку в этом случае на прямой s* должна существовать некоторая точка а такая, что можно найти точку типа ПП, лежащую позади от точки а и точку типа ПВ, расположенную после точки а. Но поскольку отсекаемая часть s С\ р находится сзади от точки типа ПП, сегмент s А р должен также находиться после точки а, и, кроме того, поскольку сегмент s А р должен быть после точки типа ПВ, то сегмент s А р также должен лежать после точки а. Это возможно только в том случае, если сегмент s А р пустой. Вместо непосредственной работы с точками пересечения алгоритм Циру- са-Бека работает с параметрическими величинами, соответствующими поло- жению этих точек пересечения вдоль полупрямой "s*. Алгоритм обеспечивает работу в диапазоне параметрических значений [io, ii], соответствующих те- кущему отрезку прямой линии, который преобразуется в искомый отсечен- ный сегмент отрезка прямой линии s А р по мере выполнения алгоритма. Вначале текущий сегмент отрезка прямой линии s задается равным исход- ному отрезку прямой линии с диапазоном параметрических значений [0, 1]. По ходу обработки алгоритмом каждого ребра е отсекающего полигона те- кущий диапазон либо остается неизменным, либо усекается (т. е. его нижняя граница увеличивается или верхняя граница уменьшается). На самом деле при каждом появлении точки пересечения типа ПВ с параметром t изменя- ется нижняя граница io текущего диапазона io - max(io, i). Аналогично, при обнаружении точки пересечения типа ПП с параметрическим значением i требуется заменить верхнюю границу текущего диапазона: ii = min(ii, i). После обработки всех ребер (т. е. после вычисления всех точек пересечения) текущий диапазон [io, ii] соответствует искомому отсеченному сегменту s А р. Если to < ti, то отсеченный сегмент существует, в противном случае (io > il) он пуст. Следующая функция выполняет отсечение заданного отрезка прямой линии s по границам отсекающего полигона р и возвращает значение TRUE (истина), если в результате получен не пустой сегмент, или значение FALSE в противном случае. Если отсеченный отрезок не пустой, то отсеченный от- резок прямой линии возвращается в вызывающую программу через ссылоч- ный параметр result: bool clipLineSegment(Edge &s, Polygon Sp, Edge Sresult} { double tO = 0.0; double tl = 1.0; double t; Point v = s.dest — s.org; for (int i — 0; i < p.sizeO; i++, p. advance (CLOCKWISE)) {
Пошаговый ввод 151 Edge е = р.edge(); if (s.intersect(е, t)==SKEW) { // s и e пересекаются Edge f = e; f. rot() ; Point n = f.dest - f.org; if (dotProduct(n, v) >0.0) { if (t > t0) tO = t; } else { if (t < tl) tl = t; ) } else { // s и e параллельны или коллинеарны if (s.org.classify(e) == LEFT) return FALSE; } ) if (tO <= tl) { result = Edge(s.point(tO), s.point(tl)); return TRUE; ) return FALSE; ) Обратите внимание на то, как функция clipLineSegment обрабатывает случай, когда прямая линия s параллельна некоторому ребру е отсекающего полигона. Если прямая линия s расположена слева от ребра е, то функция немедленно возвращает значение FALSE и прекращает свою работу. В про- тивном случае это ребро игнорируется и функция переходит к обработке следующего ребра. Очевидно, что время работы алгоритма линейно пропор- ционально размеру отсекающего полигона. 5.7. Отсечение полигона: алгоритм Сазерленда-Ходжмана Отсечение полигона, т. е. процесс отсечения частей заданного полигона по границам отсекающего полигона,— более интересная задача, чем отсе- чение отрезка прямой линии, поскольку в результате получается не просто набор отрезков прямой линии, а коллекция полигонов. Более того, задача отсечения полигонов заставляет нас использовать структуры, заключенные в заданном полигоне и интерпретировать их не только как простые кол- лекции отрезков прямых линий. В этом разделе мы опишем алгоритм Са- зерленда-Ходжмана для отсечения полигонов. По заданным выпуклому от- секающему полигону р и исходному произвольному полигону s алгоритм формирует область s П р в виде коллекции полигонов, содержащей нуль или несколько полигонов. Алгоритм Сазерленда-Ходжмана выполняет отсечение исходного полигона относительно каждого ребра отсекающего полигона поочередно. Исходный полигон сначала отсекается по первому ребру отсекающего полигона, затем полученный новый полигон отсекается по следующему ребру и так далее: полученный в результате выполнения операции новый полигон «пересыла- ется» в следующую операцию отсечения. Задача считается выполненной, когда исходный полигон будет отсечен по каждому ребру отсекающего поли-
152 Глава 5 гона. Работа алгоритма иллюстрируется на рис. 5.15, где в качестве отсе- кающего полигона взят квадрат. Алгоритм реализован в виде функции clipPolygon, которой передается исходный (обрабатываемый) полигон s и выпуклый отсекающий полигон р. Результат работы передается через ссылочный параметр result. Функция возвращает значение TRUE, только в том случае, если результат не пустой: bool clipPolygon(Polygon &s, Polygon &p, Polygon* Sresult) { Polygon *q = new Polygon(s); Polygon *r; int flag = TRUE; for (int i = 0; i < p.sizeO; i++, p.advance(CLOCKWISE)) { Edge e = p.edge(); if (clipPolygonToEdge(*q, e, r)) { delete q; q = r; } else ( delete q; flag = FALSE; break; } } if (flag) ( result = q; return TRUE; } return FALSE; ) Рис. 5.15. Алгоритм отсечения Сазерленда-Ходжмана
Пошаговый ввод 153 На каждой итерации работы функции clipPolygon переменная q ука- зывает на текущий обрабатываемый полигон, а переменная г — на полигон, полученный в результате отсечения полигона q по ребру е отсекающего полигона. Вначале переменная q устанавливается как указатель на копию исходного полигона s (она указывает на копию полигона s, так что поли- гон s не разрушается). Отсечение полигона q по ребру е выполняется путем обращения к функции clipPolygonToEdge, которая выдает значение TRUE, если получаемый в результате полигон г непуст. Если оказывается, что полигон г не пустой, то он снова передается для выполнения следующей итерации отсечения путем операции присваивания q=r. В противном случае осуществляется выход из функции clipPolygon с возвращением значения FALSE. Функция clipPolygonToEdge отсекает исследуемый полигон з по правой стороне ребра е отсекающего полигона. Результирующий полигон постепен- но превращается в отсеченный полигон, который нам и нужен. Идея за- ключается в попарном поочередном сравнении каждого ребра s с каждым ребром е. В зависимости от результата каждого сравнения в создаваемый полигон добавляются две, одна или ни одной вершины. На рис. 5.16 показаны четыре различных случая взаимного расположе- ния ребра е и ребра s. Если вектор aS соответствует текущему ребру з, то могут возникнуть такие ситуации при построении результирующего поли- гона: 1. Ребро aS полностью лежит справа от е. На выходе добавляется вер- шина Ъ. 2. Ребро aS пересекает е, переходя с правой стороны на левую. На вы- ходе добавляется точка i, в которой вектор ао пересекает ребро е. 3. Ребро at лежит полностью слева от е. На выход ничего не посыла- ется. 4. Ребро aS пересекает е, переходя с левой стороны на правую от ребра е. На выходе добавляется точка i, в которой пересекаются ребро е и вектор aS, а затем точка Ь. Функция clipPolygonToEdge выполняет отсечение исходного полигона s по ребру е. Она возвращает результирующий полигон через ссылочный параметр result и возвращает значение TRUE только в том случае, если полигон result не пустой. Рис. 5.16. Возможное взаимное расположение ребра и отсекающей полуплоскости
154 Глава 5 bool clipPolygonToEdge(Polygon &s, Edge &e, Polygon* aresult) { Polygon *p = new Polygon; Point crossingPt; for (int i = 0; i < s.size(); s.advance(CLOCKWISE), i++) Point org = s.point(); Point dest = s.cw()->point(); int orglslnside = (org.classify(e) != LEFT); int destlslnside = (dest.classify(e) != LEFT); if (orglslnside != destlslnside) { double t; e.intersect(s.edge(), t); crossingPt = e.point(t); ) if (orglslnside && destlslnside) // случай 1 p->insert(dest); else if (orglslnside &a !destlslnside) ( // случай 2 if (org != crossingPt) p->insert(crossingPt); ) else if (!orglslnside && !destlslnside) // случай 3 else { // случай 4 p->insert(crossingPt); if (dest != crossingPt) p->insert(dest); ) ) result = p; return (p->size() > 0) ; Что произойдет, если функция clipPolygonToEdge должна обработать такой полигон, для которого после отсечения должно появиться несколько самостоятельных полигонов? В этом случае функция clipPolygonToEdge создаст единственный полигон, содержащий вырожденные граничные ребра. Ситуация поясняется на рис. 5.17. Чтобы разделить такой полигон на не- вырожденные части, сначала сортируются концевые точки вырожденных ребер вдоль общей прямой линии, с которой они коллинеарны. Затем про- изводится многократное обращение к функции Vertex: : splice для исклю- чения вырожденных ребер. Поскольку такая шлифовка не потребуется для конкретного применения, описанного в главе 8 и использующего функцию clipPolygonToEdge, то нет смысла описывать эту операцию более подроб- но. Рис. 5.17. (а) — Постановка задачи отсечения,- (б) - полигон с тремя вырожденными ребрами (на рис. они раз- несены по горизонтали); (в)—результирующая коллекция из двух полигонов
Пошаговый ввод 155 Проанализируем время работы программы в терминах размера |s| заданного полигона s и размера |р| отсекающего полигона р. Функция clipPolygonToEdge выполняется за время O(|s|) и она вызывается по крайней мере однажды для каждого ребра отсекающего полигона, т. е. не более |р| раз. Следовательно, программа Функция clipPolygon будет выполнена за время O(js| |р|) в наи- худшем случае. 5,8. Триангуляция монотонных полигонов Триангуляцией полигона называется декомпозиция полигона в набор тре- угольников. Триангуляция часто используется для упрощения решения за- дачи в пределах области со сложной конфигурацией, и сведения ее к более простой задаче в пределах треугольника, поскольку треугольник относится к простейшей области и в этом случае задачу можно решить гораздо проще. Например, для определения, лежит ли точка внутри невыпуклого полигона, можно осуществить триангуляцию полигона и ответить «да» только в том случае, если точка принадлежит по крайней мере хотя бы одному треуголь- нику. Или при анализе сложных поверхностей, расположенных в простран- стве, можно эти поверхности аппроксимировать сеткой треугольников, про- анализировать которые значительно легче. В этом разделе рассмотрим алгоритм для триангуляции полигонов спе- циального типа, известных под названием монотонные полигоны. Этот ал- горитм появился в 1978 году и он впервые дал возможность осуществить триангуляцию произвольного n-угольника за время O(n log h) путем выпол- нения двух операций: 1. Декомпозиция полигона на монотонные части за время О(п log п). 2. Триангуляция монотонных частей за общее время О(п). Алгоритм декомпозиции полигона на монотонные части, работающий за время О(п log п), будет представлен в главе 7. Совсем еще недавно была сформулирована задача: возможно ли разрабо- тать всеобщий алгоритм триангуляции, работающий быстрее, чем O(n log п). Были созданы достаточно быстрые алгоритмы, но либо одни из них были пригодны только для специальных случаев обрабатываемых поли- гонов, либо улучшенные характеристики других зависели от свойств поли- гонов (например, от количества вогнутых вершин). Но несколько лет назад появилось несколько общих алгоритмов триангуляции, которые выполнялись за время o(n log п). В 1991 году Бернард Чезелле разработал оптимальный алгоритм с временем работы О(п). 5.8.1. Что такое монотонный полигон? Говорят, что цепочка вершин монотонная, если любая вертикальная линия пересекает ее не более одного раза. Начиная с самого левого конца, все вершины монотонной цепочки расположены в порядке увеличения их координаты х. Полигон называется монотонным, если его граница составлена из двух монотонных цепочек вершин: верхней и нижней цепочек вершин полигона. Каждая цепочка заканчивается в самой левой и в самой правой вершинах
156 Глава 5 полигона и содержит либо ни одной, либо несколько вершин между ними. На рис. 5.18 показано несколько примеров. Заметим, что (обязательно не- пустое) пересечение вертикальной прямой линии и монотонного полигона со- стоит либо из отрезка прямой вертикальной линии, либо из точки. Рис. 5.18. Два монотонных полигона. Верхняя цепочка полигона (б) состоит из единственного ребра 5.8.2. Алгоритм триангуляции Пусть р будет монотонный полигон и переименуем его вершины как Vl, V2,..., vn в порядке увеличения координаты х, поскольку наш алгоритм будет обрабатывать эти вершины именно в таком порядке. Алгоритм фор- мирует последовательность монотонных полигонов р = pi, Рп = 0. Полигон pt, как результат обработки вершины vi, получается путем отсе- чения нуля или нескольких треугольников от предыдущего полигона pi-i. Алгоритм заканчивает свою работу после выхода с рп, пустым полигоном, а коллекция треугольников, накопленная в процессе обработки, представ- ляет собой триангуляцию исходного полигона р. Алгоритм хранит стек s вершин, которые были проверены, но еще не обработаны полностью (возможно, обнаружены еще не все треугольники, связанные с этой вершиной). Перед началом обработки вершины vt в стеке содержатся некоторые вершины, принадлежащие полигону pt~i. По мере вы- полнения алгоритма сохраняются определенные инварианты стека.* Пусть вершины в стеке обозначены как si, S2,..., st в порядке снизу вверх, тогда могут встретиться четыре ситуации: 1. si, S2,..., st упорядочены по возрастанию координаты х и содержат каждую из вершин полигона pt-i, расположенную справа от si и слева от Sf. 2. si, S2,..., st являются последовательными вершинами либо в верхней, либо в нижней цепочках полигона pi-i. 3. Вершины si, S2,..., St являются вогнутыми вершинами полигона pt-i (величина внутреннего угла при каждой из них превышает 180 гра- дусов). 4. Следующая подлежащая обработке вершина V/ в полигоне pt-i нахо- дится в следующем соотношении с вершинами st и si: a. vi соседняя с st, но не с si, б. vi соседняя с si, но не с st, в. vi соседняя с обеими вершинами si и st. Все эти три случая условия 4 показаны на рис. 5.19. Действия по обработке вершины и/ будут различны для каждого из этих трех случаев, отражающих текущее состояние стека: * Инвариантом называется состояние, существующее на определенной стадии работы алгоритма, например, в начале каждой итерации данного цикла.
Пошаговый ввод 157 Рис. 5.19. Три случая, которые могут возникнуть при триангуляции монотонного полигона: Вершина v(a) - со- седняя с st, но не с si; (б) - соседняя с я, но не с st; (в) — соседняя с обеими вершинами я и st Случай 4а. Вершина Vi соседняя с st, но не с si: Если t > 1 и внутренний угол ZvjStSt-i меньше 180 градусов, то отсекается треугольник AvjSfSt-i и st исключается из стека. После этого в стек заносится о;. В алгоритме ис- пользуется тот факт, что угол ZvjStSt-i < 180 только в том случае, когда либо st-i лежит слева от вектора если v; принадлежит полигону pt-i из верхней цепочки, либо st-i лежит справа от вектора v^t, если v; при- надлежит нижней цепочке. Случай 46. Вершина vt соседняя с si, но не с st: Отсекаем полигон, оп- ределяемый вершинами vt, si, S2,..., st, очищаем стек и затем заносим в него сначала st, потом щ. Полигон, определяемый вершинами щ, si, S2..st фак- тически является веерообразным с узлом в точке щ (т. е. вершина vt при- надлежит корню веера). После этого алгоритм выполняет триангуляцию по- лученного полигона. Случай 4в. Вершина vt соседняя с обеими вершинами si и st- В этом слу- чае vt - vn и полигон pi-i, определяемый вершинами щ, si, S2,..., st, явля- ется веерообразным с узлом в точке vn. Алгоритм непосредственно выпол- няет триангуляцию этого полигона и заканчивается. На рис. 5.20 показан процесс работы алгоритма при решении простой задачи (порядок выполнения шагов сверху вниз и слева направо). На каж- дом шаге обрабатываемые вершины отмечены кружком, а вершины в стеке обозначены последовательностью si, S2,..., st. Для приведенной ниже программы triangulateMonotonePolygon зада- ется монотонный полигон р и она возвращает список треугольников, пред- ставляющий результат триангуляции полигона. В программе предполагает- ся, что окно для полигона р расположено на его самой левой вершине. enum ( UPPER, LOWER ); List<Polygon*> *triangulateMonotonePolygon(Polygon &p} { Stack<Vertex*> s; List<Polyyon*> *triangles = new List<Polygon*>; Vertex *v, *vu, *vl; leastvertex(p, leftToRightCmp); v = vu = vl = p.v(); s.push(v); int chain = advancePtr(vl, vu, v); s .push (v) ; while (1) { // внешний цикл while chain = advancePtr(vl, vu, v); if (adjacent(v, s.topO) &&
158 Глава 5 {adjacent(v, s.bottom())) { // случай 4a int side = (chain = UPPER) ? LEFT : RIGHT; Vertex *a = s.topO; Vertex *b = s.nextToTop(); while ((s.sizeO > 1) && (b->classify(v->point(),a->point()) = side)) { if (chain == UPPER) { p.setV(b); triangles->append(p.split(v)); } else { p.setv(v); triangles->append(p.split(b)); } s.pop() ; a = b; b = s.nextToTop(); ) s.push(v); } else if ({adjacent (v, s.topO)) { // случай 46 Polygon *q; Vertex *t = s.pop(); if (chain == UPPER) { p.setV(t); q = p.split(v); } else { p.setv(v); q = p.split(t); q->advance(CLOCKWISE); ) triangulateFanPolygon(*q, triangles); while (!s.empty()) s.popO ; s.push(t) ; s . push (v) ; } else { // случай 4в p. setV (v) ; triangulateFanPolygon(p, triangles); return triangles; ) } // конец внешнего цикла while ) Функция adjacent возвращает значение TRUE только в том случае, если две указанные в обращении вершины, являются соседними. bool adjacent(Vertex *v, Vertex *w) { return ((w == v->cw() ) || (w = v->ccw())); ) Программа triangulateMonotonePolygon при обработке полигона p анализирует одновременно верхнюю и нижнюю цепочки полигона, исполь- зуя преимущества уже выполненного упорядочения вершин по возрастанию координаты х (в противном случае потребовалась бы дополнительная сор- тировка за время O(n log п)). На каждой итерации переменная v указывает на обрабатываемую вершину. В программе формируются еще две перемен- ные vu и vl, которые указывают на последнюю обрабатываемую вершину
Пошаговый ввод 159 Рис. 5.20. Триангуляция небольшого монотонного полигона. Треугольники, обнаруженные на каждом шаге, выделены толстыми линиями. Порядок выполнения шагов сверху вниз, слева направо в верхней и нижней цепочках полигона р соответственно. По мере работы программы эти указатели функцией advance Pt г продвигаются слева напра- во. При каждом обращении к функции advancePtr она изменяет значение либо vu, либо vl и корректирует указатель v в соответствии с произведен- ным изменением: int advancePtr(Vertex* &vu, Vertex* &vl, Vertex* Sv} { Vertex *vun = vu->cw(); Vertex *vln = vl->ccw();
160 Глава 5 if (vun->point() < vln->point()) { v = vu = vun; return UPPER; } else { v = vl = vln; return LOWER; } Функция advancePtr возвращает значение UPPER или LOWER, показываю- щее, к какой из двух цепочек вершин v относится произведенное действие. Это значение используется в программе triangulateMonotonePolygon для контроля, чтобы ее последующее обращение к функции split вызывало воз- врат части, выделенной из основного полигона, а не основного полигона, из которого эта часть исключена. При триангуляции веерообразного полигона последовательно находятся все треугольники, имеющие одну корневую вершину V. Для этого полигон обхо- дится, начиная с вершины V, и в каждой вершине w, не являющейся соседней с V, отсекается треугольник по хорде, соединяющей вершины v и w. Это выполняется функцией triangulateFanPolygon, которая деструктивно раз- бивает n-угольник р на п — 2 треугольников и добавляет их в список треуголь- ников . В функции предполагается, что полигон р веерообразный с окном, расположенным на его корне: void triangulateFanPolygon(Polygon Sp, List<Polygon*> ♦triangles) { Vertex *w = p.v ()->cw()->cw() ; int size = p.size(); for (int i = 3; i < size; i++) { triangles->append(p.split(w)); w = w->cw(); } triangles->append(Sp); } На рис. 5.21 показана триангуляция, полученная с помощью описанного алгоритма. Монотонный полигон содержит 35 вершин. Рис. 5.21. Триангуляция монотонного 35-угольника 5.8.3. Корректность Нам необходимо доказать два положения: (1) каждая диагональ, которую алгоритм находит на шаге i, является внутренней хордой для полигона Pi-i; (2) алгоритм восстанавливает четыре состояния стека при переходе от
Пошаговый ввод 161 одной итерации к следующей. (То, что найденные хорды преобразуют ис- ходный полигон в треугольники, очевидно из рис. 5.19). Сначала рассмотрим диагональ v/St-i на рис. 5.19а (здесь t = 5). Обо- значим треугольник Т = Ast-istvi и заметим, что ни одна из вершин поли- гона pi-1 не может лежать внутри Т: вершины so,..., St-2 расположены слева от самой левой вершины St-i треугольника Т, а вершины Vj для j > i лежат справа от самой правой вершины vi треугольника Т. Следовательно, любое ребро, пересекающее диагональ vist-i, должно выходить из треугольника Т через одно из ребер st-ist или зщ/, что невозможно, поскольку они явля- ются граничными ребрами полигона pt-i. Таким образом, диагональ ViSt^i является хордой. Отсечем треугольник Т от полигона pz-i. Те же аргументы показывают, что оставшиеся диагонали, получаемые в случае 4а, также яв- ляются хордами. Теперь рассмотрим случай 46, показанный на рис. 5.196. Согласно усло- виям 2 и 3 для стека, полигон Т, определяемый вершинами Vi, si, S2,..., St, является веерообразным с корнем в вершине о/. Заметим, что ни одна из вер- шин полигона pi-i не может лежать внутри полигона Т — если бы там ока- залась одна или несколько вершин, то самая правая из таких вершин должна бы оказаться записанной в стеке и следовательно находиться на границе полигона pi-i. Поскольку внутри Т нет ни одной вершины, то любое ребро, пересекающее ViSt, должно также пересекать одно из ребер v/si, S1S2 , . . . , St-iSi , что невозможно, поскольку они являются граничными ребрами поли- гона pi-i. Аналогично также можно показать, что в случае 4в (рис. 5.19в) полигон pi-i является веерообразным с корнем в вершине Vi и внутри него нет ни одной другой вершины. Затем докажем, что условия стека сохраняются от одной итерации к дру- гой. По крайней мере две вершины и/ и st должны быть записаны в стеке с обязательным учетом порядка их расположения по горизонтали. Тем самым удовлетворяется условие 1. Вершины образуют цепочку вершин в pi-i по ин- дукции (в случае 4а) или вследствие того, что стек сокращен до двух со- седних вершин (в случае 46), тем самым оказывается удовлетворенным ус- ловие 2. Вершины стека являются вогнутыми (за исключением верхней и нижней вершин), поскольку вершина vt записывается в стек только в том случае, если верхняя вершина стека (над которой будет записана новая вер- шина) станет вогнутой. Следовательно, в этом случае удовлетворяется ус- ловие 3 (при этом безусловно удовлетворяется случай 46, поскольку в стеке содержатся лишь две вершины). Наконец, вершина st должна быть соседней либо si, либо st, поскольку монотонность полигона pi-i гарантирует, что вер- шина Vi имеет соседа слева, а все вершины в стеке за исключением si и St уже имеют двух соседей. Следовательно, удовлетворяется условие 4 для стека. 5.8.4. Анализ Программа triangulateMonotonePolygon выполняется за время 0(п), если задаваемый на входе полигон содержит п вершин. Для получения верх- ней границы О(п) заметим, что при каждой итерации двух внутренних цик- лов while из стека исключается одна вершина (в случаях 4а и 46). Однако каждая вершина записывается в стек только однажды (при первой ее об- работке) и, следовательно, может быть выбрана из стека тоже только од- 6 Заказ 2794
169 Глава 5 нажды. Поскольку алгоритм выполняет О(п) операций со стеком одинаковой длительности и затрачивает одинаковое время между двумя такими после- довательными операциями, то время работы программы пропорционально О(п). Нижняя граница определяется тем, что должна быть обработана каж- дая из п вершин. 5.9. Замечания по главе Необходимость сортировки возникает в самых различных ситуациях и такая задача возникла задолго до появления компьютеров и даже возможно раньше появления письменности. И неудивительно, что многие из рассмат- риваемых в книге задач основаны на той или иной сортировке. Здесь были рассмотрены три способа сортировки, используемые в различных примене- ниях компьютерных программ: сортировка при вводе, сортировка выборки, сортировка при слиянии. Эти способы настолько фундаментальны, что они не требуют специальной атрибуции. Однако, несмотря на их кажущуюся простоту, до сих пор продолжается их исследование и разрабатываются новые базовые алгоритмы сортировки. Количество полигонов, образуемых набором из п вершин, возрастает экс- поненциально в зависимости от числа п, однако количество звездчатых полигонов ограничено сверху числом О(п4). Если в наборе точек нет ни одной коллинеарной тройки точек, то ядро различных частичных звездча- тых полигонов для такого набора точек имеет выпуклую оболочку. Алго- ритм формирования такого разделения в течение времени О(п4) представлен в [22], он приводит к созданию алгоритма поиска звездчатых полигонов, выполняемого за время О(п5). Алгоритм определения ядра любого п-сторон- него полигона за время О (п) описан в [53]. В главе 8 мы предложим более простой, но менее эффективный алгоритм, выполняемый за время O(n log п). В прекрасной книге [60] исследуются проблемы видимости внут- ри полигона. Определение выпуклой оболочки позволяет расширить это понятие на набор точек в любом d-мерном пространстве при d > 1: выпуклая оболочка представляет собой пересечение всех выпуклый политопов, содержащих эти точки. Некоторые способы поиска выпуклой оболочки для набора точек на плоскости формулируются в терминах d-мерного пространства. Формирова- ние оболочки, например, может рассматриваться как частный случай ме- тода впереди-позади для нахождения выпуклой оболочки точек в d-мерном пространстве [45]. Способ отсечения прямых линий, описанный в разделе 5.6, известен как алгоритм отсечения Цируса-Бека, а способ отсечения полигонов из разде- ла 5.7 — как алгоритм Сазерленда-Ходжмана [75]. Из других хорошо из- вестных способов отсечения следует упомянуть алгоритм отсечения прямых линий Коэна-Сазерленда и способ деления по средней точке [77], а также алгоритм отсечения полигонов Вейлера-Атертона [88]. Этот последний ал- горитм является общим, он допускает обработку любых исходных и отсе- кающих полигонов, которые не обязательно должны быть выпуклыми и могут включать отверстия. Такая общность позволяет использовать его в алгоритме удаления невидимых поверхностей, также представленном в [88].
Пошаговый ввод 163 Алгоритм для триангуляции монотонных полигонов описан в [31], где дано более общее определение монотонности, чем предполагалось в нашем представлении: цепочка вершин с является монотонной, относительно неко- торой прямой линии I, если любая прямая линия, перпендикулярная пря- мой линии I, пересекает ломаную линию с не более, чем в одной точке. Полигон является монотонным, если его граница сформирована из двух це- почек вершин, являющихся монотонными относительно одной и той же пря- мой линии. В нашем случае рассматривался полигон, который являлся мо- нотонным относительно горизонтальной оси. В статье [67] описан алгоритм, выполняемый за линейное время, решающий, существует ли прямая линия, относительно которой полигон будет монотонным. Алгоритм триангуляция Хазеля [19] выполняется за оптимальное время О(п). Обзор методов разбиения полигонов представлен в [60, 61]. 5.10. Упражнения 1. Программа starPolygon находит только веерообразные полигоны. Обобщите программу так, чтобы при обработке любой точки з, лежа- щей внутри выпуклой оболочки набора точек, она находила звездча- тый полигон, содержащий точку з внутри его ядра. 2. Определите алгоритм, выполняемый за время O(n log п), для опреде- ления звездчатого полигона в заданном наборе точек. 3. Докажите, что ядро полигона является выпуклым. 4. Напишите версию программы ввода оболочки, выполняемой за время O(n log п). (Подсказка: предварительно отсортируйте точки, чтобы не было необходимости обращаться к функциям pointlnConvexPolygon и closestVertex). 5. Покажите, что для решения задачи поиска выпуклой оболочки необ- ходимо время £l(n log п). (Подсказка: найдите эффективный способ перехода от сортировки к поиску выпуклой оболочки, используя тот факт, что время Q(n log п) определяет нижнюю границу для сортиров- ки). 6. Покажите, что точка р принадлежит выпуклой оболочке CH (S), если и только если существуют такие три точки в наборе S, которые об- разуют треугольник, включающий точку р. 7. Следующий вопрос относится к алгоритму отсечения прямых линий Цируса-Бека: покажите, что прямая линия Т" пересекает выпуклый полигон р только в том случае, если точки пересечения, упорядочен- ные вдоль прямой линии V, состоят из последовательности точек пересечения типа ПВ, за которой следует последовательность точек пересечения типа ПП. 8. Измените программу clipLineSegment так, чтобы она отсекала по заданному выпуклому полигону бесконечную прямую линию, а не от- резок прямой линии. 9. Разработайте алгоритм для удаления вырожденных ребер, которые иногда формируются программой clipPolygonToEdge.
164 Глава 5 10. Для заданного полигона р и некоторой точки а внутри р разработайте способ нахождения некоторого луча, исходящего из точки а и пере- секающего минимальное число ребер полигона р (Подсказка: рассмот- рите радиальную сортировку вершин полигона р вокруг точки а). 11. Покажите, что при любом способе триангуляции n-угольника исполь- зуются п - 3 хорд для декомпозиции полигона в п - 2 треугольника.
Глава 6 Пошаговая выборка С пособи пошаговой выборки решают задачу постепенно, понемногу за каж- дый шаг. При этих способах входной поток обрабатывается по собственным правилам, а не в том порядке, в каком данные следуют во входном потоке. При пошаговой выборке производится просмотр всего входного потока для выбора «наилучшего» элемента, который будет обрабатываться следующим. В некоторых приложениях пошаговой выборки порядок обработки эле- ментов может быть определен заранее. В таких случаях входной поток может быть подвергнут предварительной, сортировке. В других приложени- ях порядок следования нельзя предвидеть и выбор следующего элемента для обработки зависит от полученного предыдущего результата. В этой главе мы рассмотрим оба этих вида приложений. Начнем с сор- тировки выборки как примера пошаговой выборки. Затем проанализируем еще два способа для формирования выпуклой оболочки конечного набора точек: способ «заворачивания подарка» и метод обхода Грэхема. Третьим геометрическим алгоритмом, который будет нами описан, является способ сортировки по глубине для реализации задачи удаления невидимых поверх- ностей из заданного набора треугольников в пространстве. Следующий ал- горитм вычисляет пересечение двух выпуклых полигонов в плоскости. И последний алгоритм выполняет специальную триангуляцию конечного на- бора точек на плоскости, известную как триангуляция Делоне. 6.1. Сортировка выборкой Сортировка выборкой, еще один алгоритм сортировки, выполняется путем многократной выборки наименьшего элемента из набора до тех пор, пока набор не окажется пустым. При сортировке массива элементов нахо- дится наименьший элемент и производится взаимообмен с элементом в пер- вой позиции массива. Затем находится наименьший элемент среди остав- шихся элементов (справа от первой позиции) и выполняется взаимная замена с элементом во второй позиции. Эти операции повторяются до тех пор, пока не будет отсортирован весь массив. Шаблон функции selectionSort сортирует элементы в массиве а[0. .п-1]. Для каждого i от 0 до п—1 на шаге i выбирается наименьший элемент среди a[i. .п-1] и затем выполняется взаимная замена с элементом a[i]:
166 Глава 6 template*: class T> void selectionSort(T a[], int n, int (*cmp) (T,T)) { for (int i = 0; i < n-1; i++) { int min = i; for (int j = i+1; j < n; j++) if ((*cmp) (a[j], a[min]) < 0) min = j; swap(a[i], a[min]); } } Тело внутреннего цикла for осуществляет операцию выбора на каждом шаге i. В переменной min хранится индекс наименьшего элемента, обнаруженного в ходе текущей проверки. Здесь лучше сохранять индекс эле- мента, а не сам элемент, чтобы можно было осуществить последующую за- мену. Функция swap выполняет взаимную замену для указанных ей двух аргу- ментов template Cclass Т> void swap(T Sa, Т Sb) { Т t = а; а = Ь; b = t; } Время выполнения сортировки выборкой составляет Т(п) = X ” - J i — Д0> где I(i) — время, необходимое для выбора i-того наименьшего элемента. Поскольку для выбора этого элемента необходимо сделать п - i сравнений, то всего в программе выполняется Т(п) = (п - 1) + (п - 2) + ... + 1 = п(п - 1)/2 или всего около п2/2 сравнений. Хотя это время работы сравнимо со вре- менем работы сортировки при вводе, в общем случае применение сортиров- ки выборкой предпочтительнее: здесь будет сделано только п замен, тогда как для выполнения сортировки при вводе потребуется около п2/2 полуза- мен в худшем случае (понимая под полузаменой сдвиг элемента на одну позицию вправо). 6.1.1. Автономные и оперативные программы Сортировка выборки является примером автономной программы (off-line). Это означает, что все входные данные должны быть доступны с самого на- чала. И легко видеть почему: если наименьший элемент поступит только после того, как некоторые другие (и большие!) элементы уже выбраны, то возможность поместить наименьший элемент в первую позицию была бы ут- рачена. Все методы пошагового выбора реализуются автономными програм- мами, поскольку правильный выбор элемента не может быть гарантирован, если не все элементы доступны в каждый данный момент. Сортировка при вводе, подобно другим способам пошагового ввода, явля- ется примером оперативной программы (работа в реальном времени — on- line). Оперативные программы не заглядывают вперед. Это означает, что на вход поступает поток данных во времени и совсем не обязательно, чтобы
Пошаговая выборка 167 весь поток был получен к моменту обработки. Хотя в принципе вполне воз- можно, что программе insertionSort может быть доступен весь вводимый массив с самого начала, но предварительный просмотр массива в ней не про- изводится и каждый элемент анализируется поочередно, не заглядывая впе- ред. Оперативные программы наиболее полезны для работы в системах реаль- ного времени, когда входные данные постоянно поступают в процессе ра- боты. Текстовые редакторы и системы моделирования полетов самолета яв- ляются примерами оперативных программ, поскольку входные данные для таких систем генерируются пользователем в реальном времени и его дей- ствия предсказать нельзя. Иногда программы реального времени должны ра- ботать на основе входных данных, которые поступят только впоследствии (зачастую даже слишком поздно), что приводит к напрасным затратам. В качестве примера вспомним программу формирования выпуклой оболочки insertionHull в предыдущей главе, которая может создавать большие те- кущие оболочки только для того, чтобы потом их отбросить. Так что рас- смотрим теперь способ формирования выпуклой оболочки, в которой исклю- чается подобная ненужная работа, поскольку она основана на подходе, характерном для пошаговой выборки. 6.2. Построение выпуклой оболочки способом «заворачивание подарка» Один из способов формирования выпуклой оболочки конечного набора точек -S на плоскости напоминает действия по вычерчиванию выпуклой обо- лочки с помощью линейки и карандаша. Вначале выбирается некоторая точка а G S такая, что она явно должна принадлежать границе выпуклой оболочки — для этого годится самая левая точка. Затем вертикальный луч поворачивается по направлению движения часовой стрелки вокруг этой точки до тех пор, пока он не наткнется на некоторую другую точку Ъ в наборе S; отрезок а5 будет ребром выпуклой оболочки. Для поиска следу- ющего ребра будем продолжать вращение луча по часовой стрелке, на этот раз вокруг точки b до встречи со следующей точкой с из набора S, отрезок 5с будет следующим ребром выпуклой оболочки. Этот процесс продолжается до тех пор, пока не будет достигнута точка а. Процесс изображен на рис. 6.1, он получил название способ «заворачивание подарка». Процесс поворота луча вокруг каждой точки является «выбирающей» частью алгоритма. При выборе точки, следующей за точкой а на границе выпуклой оболочки, ищем такую точку Ь, для которой нет ни одной точки, лежащей слева от луча at. Все точки анализируются по очереди, пока ал- горитм не проследит весь путь, вплоть до самой левой точки, обнаруженной к этому моменту. При этом проверке подвергаются только те еще не из- вестные точки, которые должны принадлежать границе выпуклой оболочки. Следующая программа giftwrapHull возвращает полигон, представ- ляющий выпуклую оболочку п точек в массиве s. Массив s должен иметь длину п+1, поскольку в элементе s[n] программа хранит ограничивающий элемент («страж»):
168 Глава 6 Polygon *giftwrapHull(Point s[], int n) ( int a, i; for (a = 0, i = 1; i < n; i++) if (s[i] < s[a]) a = i; s[n] = s[a]; Polygon *p = new Polygon; for (int m = 0; m < n; m++) { swap(s[a], s[m]); p->insert(s[m]); a = m+1; for (int i = m + 2; i <= n; i++) { int c = s[i].classify(s[m], s[a]); if (c == LEFT || c == BEYOND) a = i ; } if (a == n) return p; } return NULL; } Вращение луча вокруг некоторой точки s [m] моделируется внутренним циклом for. Точка s [а] должна быть самой левой, которая встречается на пути луча. Если новая точка s [ i ] лежит слева от луча, начинающегося в точке s [ш] и проходящего через точку s [а], тогда вращаемый луч должен поместить точку s [ i ] перед точкой s [ а ], так что точка а будет изменена. Рис. 6.1. Заворачивание подарка для набора точек на плоскости
Пошаговая выборка 169 Переменная а также изменяется, если точка s [ i ] лежит позади s [ а ] — точка s [а] не может быть вершиной выпуклой оболочки, если она лежит между точками s [m] и s[i] Заметим, что функция giftwrapHull реорганизует точки в массиве s. В конце шага m подмассив s [0. . m] содержит известные вершины выпуклой оболочки, упорядоченные в направлении по движению часовой стрелки, а подмассив s[m+l..n-l] содержит остальные точки, которые принадлежат или нет вершинам выпуклой оболочки. Именно эти последние точки и должны проверяться на последующих шагах. 6.2.1. Анализ Чтобы проанализировать способ заворачивания подарка, заметим, что по- ворот вокруг тп-той точки требует классификации п - т - 2 точек, каждая из которых занимает одинаковое время. Поскольку выполняется только й поворотов (где h — число вершин в выпуклой оболочке Cft (S) ), то общее время работы составляет О(йп) . Если каждая из п точек является вершиной выпуклой оболочки CH (S) (т. е. h — п), то время работы составляет О(га2), сравнимое с временем работы функции insertionHull. С другой стороны, если величина h мала по сравнению с п, то способ заворачивания подарка работает быстрее, чем способ ввода оболочки. Говорят, что время работы, определяемое как О(йга), зависит от резуль- тата, поскольку формула содержит коэффициент й, зависящий от длины выходного массива. С точки зрения анализа программы, она работает бы- стрее, если на выходе будет сформировано меньше данных (но так работают отнюдь не все программы). При учете результата работы получаются более правильные оценки поведения программы. Так в случае анализа програм- мы, работающей по принципу «заворачивания подарка», оценка О(йп) оз- начает, что программа более эффективна, если выпуклая оболочка опреде- ляется наибольшим числом вершин — в этом заключается отличие от оценки О(п2), когда учитывается величина только входного файла и не при- нимается во внимание объем выходных данных. 6.3. Построение выпуклой оболочки: метод обхода Грэхема В этом разделе мы раскроем сущность способа построения выпуклой обо- лочки по методу обхода Грэхема, названному так по имени его создателя. В методе обхода Грэхема выпуклая оболочка конечного набора точек S нахо- дится в два этапа. На первом этапе предварительной сортировки алгоритм выбирает экстремальную точку ро G S и сортирует остальные точки S в со- ответствии с углом направленного к ним луча относительно точки ро. На этапе построения выпуклой оболочки алгоритм выполняет пошаговую обра- ботку отсортированных точек, формируя последовательность текущих выпук- лых оболочек, которые в конце концов образуют искомую выпуклую оболоч- ку CH (S). Предварительная сортировка упрощает выполнение этапа построения выпуклой оболочки — каждая обрабатываемая на этом этапе точка включается в текущую оболочку без дополнительных условий. Более того, при этом оказывается очень легко обнаружить те точки, которые еле-
170 Глава 6 Рис. 6.2. Обозначение точек на основе их полярной координаты относительно точки ро дует удалить из текущей оболочки. Это даст положительный результат по сравнению со способом ввода оболочки, описанным в предыдущей главе, при котором обрабатываются все точки и каждый раз принимается решение о включении точки в текущую оболочку и, если она включается, то просмат- ривается вся граница текущей оболочки для определения вершин, подлежа- щих удалению. Для заданного набора точек S при методе обхода Грэхема сначала на- ходится некоторая экстремальная точка ро €Е S. Выберем в качестве такой точки точку ро из S с минимальной координатой у или самую правую в случае сильно вытянутого набора. Затем остальные точки сортируются в по- рядке полярного угла относительно точки ро. Если две точки имеют оди- наковый полярный угол, то точка, расположенная ближе к точке ро, счи- тается меньшей, чем более дальняя точка. Это обычное словарное правило для определения соотношения точек по их полярной координате относи- тельно точки ро, которое реализуется функцией сравнения polarCmp, при- веденной в разделе 5.2. Переименуем теперь оставшиеся точки Р1, Р2,-„, Рп в соответствии с их упорядочением, как показано на рис. 6.2. На этапе построения выпуклой оболочки при методе Грэхема текущая выпуклая оболочка строится на основе уже обработанных точек. На рис. 6.3 показан процесс работы алгоритма. Рассмотрим обработку точки pq (рис. 6.Зе). Поскольку точки упорядочены в порядке возрастания полярного угла относительно точки ро, то очевидно, что точка pq должна быть включена в состав оболочки и что точка ро должна быть ее предшественницей, но какая точка будет включена после точки pq? Для ответа на этот вопрос используем тот факт, что каждая вершина должна соответствовать повороту влево при обходе границы выпуклой оболочки в направлении против движения часовой стрелки. Рассмотрим сначала первого кандидата — точку ро. Поскольку угол ^Р5Р0Р7 представляет не-левый поворот (pq лежит справа от ребра pspo), точка ро удаляется из текущей оболочки. Затем рассмотрим точку ps- Поскольку угол Lpopopq также представляет не-левый поворот, то удаляется и точка ро. Аналогичным образом удаляется точка р4. Ситуация изменяется при до- стижении точки рз: угол /.pipspq обозначает поворот влево и таким образом находится последователь для точки pq в текущей оболочке (точка рз). В программу grahamScan передается массив pts из п точек и возвра- щается полигон, представляющий собой выпуклую оболочку для точек pts. Программа выполняется за пять шагов: первые два образуют этап предварительной сортировки, а в течение оставшихся трех находится вы- пуклая оболочка:
Пошаговая выборка 171 (ж) Рис. 6.3. Работа по методу обхода Грэхема 1. Поиск экстремальной точки ро- 2. Сортировка остальных точек по их полярному углу относительно точки ро.
178 Глава 6 3. Инициализация текущей оболочки. 4. Формирование текущей оболочки, пока она не станет равной выпуклой оболочке для всех п элементов. 5. Преобразование текущей оболочки в объект типа полигон (Polygon) и возврат его. Программа выглядит следующим образом: Point originPt; Polygon *grahamScan(Point pts[], int n) { // шаг 1 int m = 0; for (int i = 1; i < n; i++) if ((pts[ij.y < pts[m].y) ((pts [i] .y == pts [m] .y) && (pts[i].x < pts[m].x))) m = i; swap(pts[0], pts[m]); originPt = pts[0]; Point **p = new (Point*) [n] ; // шаг 2 for (i = 0; i < n; i++) p[i] = &pts[i]; selectionSort(&p[1], n-1, polarCmp); // или любой другой способ сортировки // шаг 3 for (i = 1; p[i+l]->classify(*р[0], *p[i]) == BEYOND; i++) Stack<Point*> s; s.push(p[0]); s.push(p[i]); for (i = i+1; i < n; i++> ( // шаг 4 while (p[i]->classify(*s.nextToTop(), *s.top()) != LEFT) s.pop(); s.push(p[i]); } Polygon *q = new Polygon; // шаг 5 while (!s.empty()) q->insert(*s.pop()); delete p; return q; } Первый шаг ясен. На шаге 2 создается массив р и его элементы ини- циализируются равными точкам Point в массиве pts. Нам требуется массив указателей, чтобы можно было использовать одну из обобщенных подпро- грамм сортировки. Затем массив р сортируется с помощью функции срав- нения polarCmp, которая определена в разделе 5.2 в контексте построения звездчатого полигона по заданному набору точек. Напомним, что глобальная переменная originPt используется для того, чтобы передать в функцию polarCmp начальную точку — в данном случае точку ро- На 3 и 4 шагах в стеке s создается текущая оболочка. Пусть имеем набор Si = {pi, Р2,---, Pi} и стек представляет текущую оболочку CH(S) следующим
Пошаговая выборка 173 образом. Если точки в стеке обозначены как si, S2,..., st в порядке от низа стека к его верху, то стек удовлетворяет таким условиям стека: 1. Точки ро = si, S2,..., st = pt являются вершинами текущей оболочки CH (S) в направлении обхода против часовой стрелки; 2. Ребро siS2 является ребром окончательной выпуклой оболочки CH (S). На шаге 3 эти условия определяются предварительно. В цикле for осу- ществляется пошаговое продвижение по лучу popi до тех пор, пока не будет достигнута последняя (самая дальняя) точка pt, после чего точки ро и pt будут записаны в стек. Первое условие стека будет удовлетворено по той причине, что отрезок прямой линии popi является выпуклой оболочкой для Si поскольку точки pi, Р2,---, Pi-1 расположены между ро м. pi. Второе ус- ловие выполнено потому, что popt является ребром для CH (S) . На шаге 4, проиллюстрированном на рис. 6.4, обрабатывается точка pt для формирования текущей оболочки CH (Si). Программа grahamScan выбирает из стека точки st, st-i,..., Sk-1 до тех пор, пока не будет достигнута точка Sk , самая верхняя точка в стеке такая, что угол Lsk-iskPi представляет левый поворот. Поскольку все эти вызванные точки лежат внутри треуголь- ника ApoPiSfe или вдоль одного из ребер popi или p/s*, то ни одна из них не может быть вершиной выпуклой оболочки CH (Si). Так как из стека будут удалены только эти точки и никакие другие, оставшиеся точки вместе с pt будут вершинами выпуклой оболочки CH (S/). Вследствие того, что первое ус- ловие стека гарантирует корректность упорядочения точек si, ..s* внутри стека, а точка pt следует за ними в порядке увеличения полярного угла, то новое содержимое стека si, S2,..., Sfe, pi правильно упорядочено в порядке об- хода против часовой стрелки. Из этого следует, что первое условие соблюдено. Назначение условия 2 заключается в гарантии существования точки sfe. Поскольку ребро siS2 является ребром оболочки CH (S), то каждая точка pi, прошедшая обработку, лежит слева от отрезка sis£. Поскольку угол Z-S\S2Pt представляет левый поворот, то из этого следует, что точка sk существует для некоторого k > 2. Более того, так как исходные точки si и S2 никогда не вызываются из стека, условие 2 сохраняется. На шаге 5 программа grahamScan формирует объект q типа Polygon путем итеративного вызова точки из стека и записи ее в q. Согласно ус- ловию 1 точки вызываются из стека в порядке обхода по часовой стрелке. Рис. 6.4. Включение точки p/для образования текущей оболочки (WCSi)
174 Глава 6 Что касается времени работы, то легко видеть, что каждый из шагов 1, 3 и 5 выполняется за время О(п). На шаге 4 тело цикла while выполняется не более одного раза для каждой точки (будучи вызванной из стека, точка никогда вторично в стек не записывается). Следовательно, выполнение шага 4 также занимает время О(п) и. общее время выполнения в основном зависит от начальной сортировки на шаге 2, поэтому метод обхода Грэхема выпол- няется за время О(п log п), если используется подходящий способ сортиров- ки. Стоит еще раз напомнить, что метод обхода Грэхема будет выполняться за линейное время, если известно, что набор точек изначально отсортирован. 6.4. Удаление невидимых поверхностей: алгоритм сортировки по глубине 6.4.1. Предварительные замечания В трехмерной машинной графике рассматриваются задачи моделирования сцен в пространстве и затем формируется изображение сцен, отображая их на плоскость путем закрашивания. Для осуществления отображения выби- рается точка в пространстве, из которой наблюдается сцена и, на основе задания этой точки зрения и ряда дополнительных параметров, сцена про- ецируется на плоскость, где и формируется искомое изображение. Формирование изображения часто осложняется тем, что некоторые объ- екты в сцене или их части закрыты для наблюдателя и поэтому они не должны появляться в окончательном изображении. Некоторые из объектов могут находиться вне поля зрения (обнаружение таких объектов относится к проблеме отсечения). Кроме того, некоторые объекты (или их части) могут быть закрыты другими непрозрачными объектами, расположенными между ними и наблюдателем. Задача идентификации таких закрытых объектов из- вестна как проблема удаления невидимых поверхностей. В этом разделе будем решать проблему удаления невидимых поверхнос- тей путем сортировки по глубине. Сцена будет представлена в виде набора треугольников, расположенных в пространстве. Такая модель получила очень широкое распространение в основном потому, что она охватывает очень большое разнообразие сцен. Например, любая поверхность может быть аппроксимирована сеткой треугольников и, если такая сетка достаточно хо- рошо составлена, то с ее помощью можно представить любую поверхность с необходимой точностью. Даже достаточно грубая сетка полезна на прак- тике, поскольку методы сглаживания, применяемые в процессе отображе- ния, могут значительно улучшить представление о кривизне поверхности. Будем использовать способ проецирования, при котором точки в про- странстве проецируются на плоскость вдоль лучей, параллельных оси z: точка (х, у, г) проецируется в точку (х, у, 0) . Такой способ, известный под названием ортогональное параллельное проецирование, обеспечивает со- хранение универсальности: для заданного набора параметров проецирования и получения требуемого изображения может быть выполнена некоторая пос- ледовательность преобразований, которая сводит проблему отображения к выполнению ортогонального проецирования. По соглашению можно предпо- ложить, что точка наблюдения лежит в отрицательной части полупростран-
Пошаговая выборка 175 ства относительно оси z (позади плоскости ху), а сцена расположена в по- ложительной части полупространства по оси z («выше» плоскости ху) и. что глубина увеличивается (т. е. объекты расположены дальше) по мере уве- личения координаты г. В дальнейшем будем также предполагать, что треугольники в сцене ори- ентированы так, что они наблюдаются со стороны их отрицательного полу- пространства — их вектор нормали направлен в противоположную сторону от точки наблюдения (рис. 6.5). Такое предположение не очень ограничи- вает ситуацию, как это может показаться вначале. При использовании сетки треугольников для моделирования поверхности твердого тела треугольники всегда одинаково ориентированы относительно внутренней части твердого тела, например, нормаль направлена внутрь твердого тела. На предваритель- ном шаге, известном как отбор задних поверхностей, такие треугольники, нормали которых направлены в сторону точки наблюдения, исключаются, поскольку они не могут быть видны — между ними и точкой наблюдения лежит непрозрачное тело. Будем оставлять только такие треугольники, нор- мали которых направлены в другую сторону от точки наблюдения. Даже когда сетка треугольников используется для представления «свободных» по- верхностей, не являющихся границей твердого тела (т. е. не существует не- прозрачной субстанции, закрывающей треугольники), треугольники должны быть переориентированы, чтобы обеспечить направления их нормали в сто- рону от точки наблюдения. 6.4.2. Алгоритм сортировки по глубине Удаление невидимых поверхностей осуществляется очень просто для на- бора треугольников, которые не перекрывают друг друга по координате г. Сначала треугольники сортируются по уменьшению координаты г (в поряд- ке от более далеких к более близким) и затем они закрашиваются в этом порядке. Если треугольник виден, то он закрашивает все, что он закрывает, и ничто не закрывает его самого. Такой способ иногда называют алгорит- мом закрашивания, поскольку он аналогичен работе художника, который сначала закрашивает фон, затем рисует средние планы и поверх всего на- носит детали переднего плана. Каждый слой наносится поверх предыдущего, более далекого слоя. В алгоритме закрашивания используется тот факт, что совершенно без- опасно нарисовать что-либо, если оно не закрывает ничего из того, что будет Рис. 6.5. Размещение элементов в пространстве для решения задачи удаления невидимых поверхностей
176 Глава 6 нарисовано позднее. Мы говорим, что список треугольников упорядочен в порядке видимости, если каждый из них можно нарисовать в установлен- ном порядке, т. е. ни один из нарисованных треугольников не закрывает ни одного из тех, что будут рисоваться позже. Более формально список тре- угольников Ру -ч Р2 -< -< Рп упорядочен в порядке видимости относительно точки наблюдения р, если и только если соблюдается правило: если Р/ -ч Р;, то Pi не закрывает Р; при наблюдении из точки р. Сортировкой по глубине называется процесс организации набора треугольников в порядке видимос- ти. Некоторые наборы треугольников допускают более одного порядка види- мости. В простейшем примере это могут быть два треугольника, не закры- вающие друг друга. В других ситуациях может существовать единственный порядок видимости, а иногда его не существует вовсе. Процесс удаления невидимых поверхностей становится более сложным для реализации при наличии треугольников, перекрывающихся по координате г. На основе алгоритма закрашивания нам нужно было определить, какой из двух данных треугольников закрывает другой путем сравнения канонических значений, полученных на основе протяженности треугольника по оси г (про- тяженность треугольника по оси z равна диапазону координат 2, занимае- мому этим треугольником или, другими словами, она равна ортогональной проекции на ось 2 обрамляющего прямоугольника). Однако, это правило ра- ботает не всегда. Если, например, мы будем использовать величину гм — мак- симальное значение протяженности по оси г для каждого треугольника, то треугольники на рис. 6.6 были бы упорядочены как А -< В, посколь- ку Za > Zb- Но упорядочения по видимости при этом не достигается, так как Рис. 6.6. Порядок видимости этих треугольников равен В -< А, даже если А>г'в Рис. 6.7. Никакая коллекция треугольников не может быть упорядочена по видимости, если в ней окажется любой из показанных наборов
Пошаговая выборка 177 Рис. 6.8. Уточнение исходных наборов треугольников из рис. 6.7 дает порядок видимости: (а)—Д, ВАз; (6) - G -<Ь<Е< Сг< Сз А закрывает В и поэтому не может быть нарисован первым (их действительный порядок видимости равен В -ч А). Этот пример показывает, что сортировка по глубине в общем случае требует, чтобы заданный список треугольников был реорганизован, даже если исходный список был предварительно упоря- дочен от более далеких к более близким треугольникам. Ниже мы представим алгоритм, который реорганизует предварительно упорядоченный список путем выполнения последовательности операций перетасовки. Некоторые наборы треугольников совсем не допускают никакого упоря- дочения по видимости. Если два треугольника пересекают друг друга, как для примера показано на рис. 6.7а, то невозможно осуществить какое-либо упорядочение по видимости — ни один из треугольников не может пред- шествовать другому ни в каком допустимом порядке видимости, поскольку каждый из них закрывает часть другого. Определение порядка видимости может оказаться невозможным и тогда, когда треугольники взаимно не пересекаются. Ни один из треугольников, показанных на рис. 6.76, не может считаться находящимся перед другими двумя, поскольку каждый из них закрывает часть остальных двух. Выход из этого затруднительного положения может быть найден в пере- определении исходного набора треугольников путем деления некоторых тре- угольников на треугольные части таким образом, чтобы полученный новый набор треугольников мог быть отсортирован по глубине. Если треугольник А на рис. 6.7а делится плоскостью треугольника В на части Ау, Аг и Аз (как показано на рис. 6.8а), то полученный набор треугольников может быть упорядочен в порядке видимости как Ау. -< В -< Аг s Аз. Если треуголь- ник С на рис. 6.76 делится плоскостью D на части Ci, Сг а Сз (рис. 6.86), то получим порядок видимости Су -< D < Е -ч Сг -< Сз. Получение набора треугольников путем разбиения треугольников на части можно назвать пере- определением (уточнением) исходного набора. Описанные идеи могут послужить основой для создания алгоритма сорти- ровки по глубине для набора треугольников. Сначала сортируем треуголь- ники в соответствии с их максимальной глубиной гм от более далеких к более близким. Полученный список дает первое приближение (или предва- рительное) упорядочения по видимости. Затем этот список преобразуется в действительно упорядоченный по видимости путем пошаговой перетасовки списка и, при необходимости, уточнением списка. Алгоритм работает следующим образом. Пусть S будет предварительно отсортированный в порядке видимости список и пусть р будет первый тре- угольник в списке S. Нам нужно определить, будет ли безопасно нарисо-
178 Глава 6 вать р — т. е. не будет ли р закрывать какой-либо q для каждого q G S. Для этого мы будем сравнивать р с каждым треугольником q в списке S, для которого протяженность по оси z перекрывает протяженность р по той же оси. Для каждого треугольника q мы будем проверять, не будет ли р закрывать q. Если окажется, что р не закрывает ни одного из треугольни- ков q, то треугольник р можно безопасно нарисовать, следовательно, мы вы- бираем р из S с удалением, рисуем его и снова запускаем алгоритм, ис- пользуя первый элемент в списке S в качестве нового р. В противном случае, если случилось так, что р закрывает какой-нибудь из треугольников q, то мы должны проверить, не будет ли q также закры- вать р, или не был ли треугольник q уже однажды перетасован. Если об- наружена любая из этих ситуаций, то треугольник q разбивается на части плоскостью р и. затем внутри списка S треугольник q заменяется его соот- ветствующими частями (т. е. выполняется операция уточнения). Проверка на имевшую место перетасовку треугольника q необходима для того, чтобы исключить бесконечное зацикливание, которое может появиться при обра- ботке ситуации, подобной изображенной на рис. 6.7. Если не было ни одной такой ситуации, то положение треугольников р и q в списке S взаимно за- меняется (операция перетасовки) и работа алгоритма возобновляется, но те- перь в качестве первого элемента списка S используется треугольник q, иг- рающий роль нового р. Программа depthSort осуществляет сортировку по глубине для массива tri из п указателей_на_треугольники и возвращает список треугольников, упорядоченных в порядке видимости. Локальная переменная s указывает на предварительно упорядоченный список треугольников, а окончательно упорядоченный по глубине список определяется переменной result: List<Triangle3D*> *depthSort (Triangle3D *tri[], int n) { List<Triangle3D*> *result = new List<Triangle3D*>; Triangle3D **t = new (Triangle3D*) [n]; for (int i = 0; i < n; i++) t[i] = new Triangle3D(*tri[i]); insertionsort(t, n, triangleCmp); List<Triangle3D*> *s = arrayToList (t, n); while (s->length() >0) { /* начало цикла while */ Triangle3D *p = s->first(); Triangle3D *q = s->next(); int hasShuffled = FALSE; for (; !s->isHead()SSoverlappingExtent(p,q,2);q=s->next()) if (mayObscure(p, q)) { if (q->mark I I mayObscure(q, p)) refineList(s, p); else { shuffleList(s, p); hasShuffled = TRUE; break; ) ) if (!hasShuffled) { s->first() ; s->remove() ; result->append(p);
Пошаговая выборка 17» } } /* конец цикла while */ return result; Сортировка применяется для формирования начального предварительно упорядоченного списка. Функция сравнения triangleCmp сравнивает два треугольника Triangle3D в соответствии с их максимальной глубиной: int triangleCmp(Triangle3D *а, Triangle3D *Ь) ( if ( a->boundingBox().dest.z > b->boundingBox().dest.z) return -1; else if (a->boundingBox().dest.z < b->boundingBox().dest.z) return 1; else return 0; После этого используется функция arrayToList, определенная ранее в главе 3, для преобразования отсортированного массива указателей в спи- сок. Обращение к функции overlappingExtent (р, q, 2) возвращает зна- чение TRUE, если треугольники р и q перекрываются по координате г (тре- тий аргумент определяет координатную ось через соответствующие индексы О, 1 или 2). При реализации функции overlappingExtent использован тот факт, что два интервала вещественных чисел пересекаются, если и только если левая точка одного из интервалов содержится внутри другого интер- вала: bool overlappingExtent(Triangle3D *р, Triangle3D *q, int i) ( Edge3D pbox = p->boundingBox(); Edge3D qbox = q->boundingBox(); return ( ((pbox.org[i] <= qbox.org[i]) && (qbox.org[i] <= pbox.dest[i])) ((qbox.org[i] <= pbox.org[i]) (pbox.org[i) <= qbox.dest[i]))); } Список s тасуется с помощью функции shuffleList, которая осущест- вляет взаимную замену первого элемента р в списке с элементом q, нахо- дящимся в окне списка: void shuffleList (List<Triangle3D*> *s, Triangle3D *p) } Triangle3D *q = s->val(); q->mark = TRUE; s->val(p); s->first(); s->val(q);
180 Глава 6 6.4.3. Сравнение двух треугольников Ранее в программе depthSort было использовано обращение к функции mayObscure (р, q) для определения, не будет ли треугольник р потенци- ально закрывать треугольник q. Функция mayObscure выполняет пять про- верок в порядке увеличения сложности. Если какая-либо из проверок за- канчивается с положительным результатом, то считается, что р не закрывает q. В противном случае, если ни одна из проверок не удовлетво- ряется, то потенциально р может закрывать q. Эти пять проверок следую- щие: 1. Не перекрываются ли зоны расположения треугольников р и q по оси х? 2. Не перекрываются ли зоны расположения треугольников р и q по оси у? 3. Расположен ли треугольник р полностью позади или в плоскости тре- угольника Q? 4. Расположен ли треугольник q полностью перед или в плоскости тре- угольника р? 5. Не перекрываются ли проекции треугольников р и q? Ситуации для тестов 3 и 4 показаны на рис. 6.9. Рис. 6.9. Два теста из функции mayObscure: Тест 3 - р находится позади плоскости q; Тест 4 - q находится перед плоскостью р Большинство средств для проведения тестов уже готово. Тесты 1 и 2 тре- буют применения описанных прямоугольников для сравнения их протяжен- ностей вдоль осей х и у соответственно. Тест 3 классифицирует положение трех вершин треугольника р относительно плоскости q и он оказывается вы- полненным, если ни одна из вершин не оказывается перед плоскостью (иначе говоря, в отрицательном полупространстве треугольника q). Анало- гично, тест 4 оказывается выполненным, если ни одна из вершин треуголь- ника q не находится позади плоскости р (т. е. в положительном полупро- странстве треугольника р). Тест 5 выполняется с использованием функции projectionsOverlap, которая возвращает значение TRUE, если передавае- мые ей два треугольника имеют пересекающиеся проекции. В функции mayObscure последовательно выполняются все тесты взаим- ного расположения треугольников р и q до тех пор, пока один из тестов не окажется удовлетворенным. Если не будет выполнен ни один из них,
Пошаговая выборка 181 то возвращается значение TRUE, показывающее, что возможно закрытие тре- угольника q треугольником р. bool mayObscure(Triangle3D *р, Triangle3D *q) { int // тест 1 if (!overlappingExtent(p, q, 0)) return FALSE; // тест 2 if (!overlappingExtent(p, q, 1)) return FALSE; // тест 3 for (i = 0; i < 3; i++) if ((*p) [i].classify(*q) == NEGATIVE) break; if (i == 3) return FALSE; // тест 4 for (i = 0; i < 3; i++) if ((*q)[i].classify{*p) == POSITIVE) break; if (i == 3) return FALSE; // тест 5 if (,'projectionsOverlap (p, q) ) return FALSE; return TRUE; ) Рассмотрим более подробно тест 5. Чтобы определить наличие перекры- тия проекций треугольников р и д, осуществим сначала проецирование тре- угольников на плоскость ху и получим треугольники Р и Q на плоскости. Затем выполним для них три проверки для обнаружения факта перекрытия. Если какая-либо из этих проверок окажется удовлетворенной, значит суще- ствует взаимное перекрытие проекций р и д. Эти три теста заключаются в следующем: 1. Находится какая-нибудь вершина Р внутри Q? 2. Находится какая-нибудь вершина Q внутри Р? 3. Пересекает ли какое-нибудь ребро Р какое-нибудь ребро Q? В первом тесте обнаруживается случай, когда внутри Q содержится не- которая часть Р, а во втором — что внутри Р содержится некоторая часть Q. Перекрытие из-за любых других взаимных положений определяется третьим тестом (хотя некоторые из возможных ситуаций сначала обнаруживаются при выполнении первого или второго тестов). В функцию proj ectionsOverlap передаются треугольники р и q и воз- вращается значение TRUE, если и только если их проекции перекрываются. Внутри используется функция project (описанная в разделе 4.6) для по- лучения проекций р и д\ bool projectionsOverlap(Triangle3D *p, Triangle3D *q) { int answer = TRUE; Polygon *P = project(*p, 0, 1);
182 Глава 6 Polygon *Q = project(*q, 0, 1); for (int i = 0; i < 3; i++, P->advance(CLOCKWISE)) if (pointlnConvexPolygon(P->point(), *Q)) goto finish; for (i = 0; i < 3; i++, Q->advance(CLOCKWISE)) if (pointlnConvexPolygon(Q->point(), *P)) goto finish; for (i = 0; i < 3; i++, P—>advance(CLOCKWISE) ) ( double t; Edge ep = P->edge(); for (int j = 0; j < 3; j++, Q->advance(CLOCKWISE)) ( Edge eq = Q->edge(); if (ep.cross(eq, t) == SKEW_CROSS) goto finish; ) ) answer = FALSE; finish: delete P; delete Q; return answer; 6.4.4. Уточнение списка треугольников В алгоритме сортировки по глубине уточнение списка s заключается в разбиении треугольника q плоскостью рассматриваемого полигона р на две или три части и последующей замене треугольника q (внутри списка s) на эти части. Это реализуется функцией refineList, которой передается те- кущий список s и рассматриваемый треугольник р. Предполагается, что треугольник q является текущим элементом в списке s: void refineList (List<Triangle3D*> *s, Triangle3D *p) { Triangle3D q = s->val(); Triangle3D *ql, *q2, *q3; int nbrPieces = splitTriangleByPlane(*q, *p, ql, q2, q3); if (nbrPieces >1) ( delete s->remove(); s->insert(ql); s->insert(q2); if (nbrPieces == 3) s->insert(q3); ) ) Разбиение треугольника осуществляется определяемой ниже функцией splitTriangleByPlane. В качестве входных параметров задается треуголь- ник q, подлежащий разбиению, и разбивающий треугольник р. Части тре- угольника q, получаемые в результате разбиения, возвращаются через ссы- лочные параметры ql, q2 и q3 (параметр q3 не используется, если треугольник q делится только на две части). В качестве возвращаемого зна- чения выдается количество полученных частей:
Пошаговая выборка 183 int splitTriangleByPlane(Triangle3D &q, Triangle3D &p, Triangle3D* &ql, Triangle3D* &q2, Triangle3D* &q3) { Point3D crossingPts[2]; int edgelds[2], cl[3]; double t; int nbrPts = 0; for (int i = 0; i < 3; i++) cl[i] = q[i].classify(p); for (i = 0; i < 3; i++) if (((cl[i]==POSITIVE) && (cl[(i+1)%3]==NE GAT I'VE)) || ((cl [i] “NEGATIVE) && (cl [ (i+1) %3]—POSITIVE) )) { Edge3D e(q[i], q[(i+l)%3]); e.intersect(p, t); crossingPts[nbrPts] — e.point(t); edgelds[nbrPts++] = i; ) if (nbrPts == 0) return 1; Point3D a = q[edgelds[0]]; Point3D b = q[(edgelds[0J+1) % 3] ; Point3D c = q[(edgelds[0]+2) % 3]; if (nbrPts == 1) ( Point3D d = crossingPts[0]; ql = new Triangle3D(d, b, c, q.id); q2 = new Triangle3D(a, d, c, q.id); } else ( Point3D d = crossingPts[0]; Point3D e = crossingPts[1]; if (edgelds[1] — (edgelds[0]+1)%3) ( ql = new Triangle3D(d, b, e, q.id); q2 = new Triangle3D(a, d, e, q.id); q3 = new Triangle3D(a, e, c, q.id); ) else ( ql = new Triangle3D(a, d, e, q.id); q2 = new Triangle3D(b, e, d, q.id); q3 = new Triangle3D(c, e, b, q.id); } ) return (nbrPts + 1); В первых двух фазах функция splitTriangleByPlane вычисляет точки, в которых плоскость р пересекает ребра треугольника q. Эти точки пере- сечения сохраняются в массиве crossingPts , а ребра q, содержащие точки пересечения, хранятся в массиве edgelds. Ребра здесь идентифицируются по индексу вершины начала (0, 1 или 2) в треугольнике q. Во второй фазе функция splitTriangleByPlane вычисляет части тре- угольника q. Обозначим вершины треугольника q буквами а, Ь и с относи- тельно первой точки nepeceneHHHd, как показано на рис. 6.10. При такой системе обозначений треугольник q затем разбивается в соответствии со схе- мой (а) рис. 6.10, если существует одна точка пересечения d, или в соответ- ствии со схемами (б) и (в), если существуют две точки пересечения d и е. Что касается характеристик сортировки по глубине, то в некоторых слу- чаях п треугольников в пространстве потребуют от алгоритма разбить весь
184 Глава 6 Рис. 6.10. Разбиение треугольника: (а) — на две части,- (б) и (в) — на три части список на 0(п2) частей. Поскольку список s теперь будет иметь длину 0(п2), то обработка каждого возможного треугольника р может занять время, пропорциональное длине списка и в худшем случае сортировка по глубине будет выполнена за время О(п4). Однако такие ситуации возникают редко и практически алгоритм работает достаточно хорошо. Кроме того, список полигонов, полученных в результате сортировки по глубине, может быть передан по линиям связи непосредственно в любую графическую сис- тему для отображения. Этого нельзя сказать о всех других методах удале- ния невидимых поверхностей, поскольку работа некоторых из них зависит от разрешающей способности устройства отображения. 6.5. Пересечение выпуклых полигонов Теперь рассмотрим проблему вычисления области пересечения Р П Q двух выпуклых полигонов Р и Q. За исключением особо оговоренных случаев будем предполагать, что два полигона пересекаются невырожденно: пересе- чение двух ребер происходит в одной единственной точке, не являющейся вершиной какого-либо полигона. Учитывая такое предположение о невырож- денности, всегда получим, что полигон Р П Q состоит из попеременных це- почек из Р и Q. Каждая пара последовательных цепочек соединяется в точке пересечения, в которой пересекаются границы полигонов Р и Q (рис. 6.11). Существует несколько решений этой задачи с линейной зависимостью времени выполнения от суммарного числа вершин. Описываемый здесь ал- горитм обладает особым изяществом и его легко применять. Для двух за- данных на входе выпуклых полигонов Р и Q алгоритм определяет окно на ребре полигона Р и еще одно окно на ребре полигона Q. Идея заключается Рис. 6.11. Структура полигона пересечения Р n Q
Пошаговая выборка 185 в продвижении этих окон вдоль границ полигона по мере формирования полигона пересечения Р A Q: окна как бы проталкивают друг друга вдоль границы своих соответствующих полигонов в направлении по часовой стрел- ке для поиска точек пересечения ребер. Поскольку точки пересечения об- наруживаются в том порядке, в котором они располагаются вокруг полигона Р A Q, полигон пересечения оказывается сформированным, когда некоторая точка пересечения будет обнаружена вторично. В противном случае, если не будет обнаружено ни одной точки пересечения после достаточного числа итераций, то значит границы полигонов не пересекаются. В этом случае по- требуется дополнительный простой тест, не содержится ли один полигон внутри другого или они вовсе не пересекаются. Для объяснения работы оказывается весьма полезным ввести понятие серпа. На рис. 6.12 серпами будут шесть затененных полигонов. Каждый из них ограничен цепочкой, взятой от полигона Р, и цепочкой от полигона Q, ограниченных двумя последовательными точками пересечения. Внутрен- ней цепочкой серпа будет та часть, которая принадлежит полигону пересе- чения. Отметим, что полигон пересечения окружен четным числом серпов, внутренние цепочки которых попеременно являются частями границ поли- гонов Р и Q. Рис. 6.12. Серпы, окружающие полигон пересечения В терминах серпов алгоритм поиска полигона пересечения проходит две фазы. В процессе первой фазы окно р полигона Р и окно q полигона Q пере- мещаются в направлении по движению часовой стрелки до тех пор, пока они не будут установлены на ребрах, принадлежащих одновременно одному и тому же серпу. Каждое окно начинает свое движение с произвольной по- зиции. Здесь для краткости будем использовать один и тот же символ р для обозначения как окна полигона Р, так и ребра в этом окне. Тогда тер- мин «начало р» будет относиться к точке начала ребра в окне полигона Р, а команда «продвинуть р» будет означать, что необходимо переместить окно полигона Р на следующее ребро. Аналогичным образом буквой q будем обо- значать как окно полигона Q, так и ребро в окне. Иногда ребра р и q будем считать текущими ребрами. Во время фазы 2 р и q продолжают перемещаться в направлении по ча- совой стрелке, но на этот раз они двигаются в унисон от одного серпа к соседнему серпу. Перед тем как любое окно перейдет из текущего серпа в соседний, ребра р и q пересекутся в точке пересечения, соединяющей оба серпа. Полигон пересечения строится именно во время второй фазы. Перед
186 Глава 6 каждым перемещением р конечная точка ребра р заносится в полигон пере- сечения, если ребро р принадлежит внутренней цепочке текущего серпа. Аналогичным образом перед перемещением q фиксируется конечная точка ребра q, если q принадлежит внутренней цепочке текущего серпа. При каж- дом пересечении ребер р и q точка пересечения, в которой они пересека- ются, записывается в полигон пересечения. Для принятия решения, какое из окон должно перемещаться, в алгорит- ме используется правило перемещения. Это правило основано на следующих замечаниях: говорят, что ребро а нацелено на ребро Ь, если бесконечная прямая линия, определяемая ребром Ь, расположена перед а (рис. 6.13). Рис. 6.13. Только показанные толстыми линиями ребра нацелены на ребро q, остальные — нет Ребро а нацелено на Ь, если выполняется одно из условий: • ~а х b > 0 и точка a.dest не лежат справа от 7> или • а х о < 0 и точка a.dest не лежат слева от о . Заметим, что соотношение^^ х_7? > 0 соответствует случаю, при кото- ром угол между векторами а и Ь меньше 180 градусов Функция aims At возвращает значение TRUE, если и только если ребро а нацелено на ребро Ь. Параметр a class указывает на положение конечной точки a. dest относительно ребра Ь. Параметр crossType принимает значение COLLINEAR, если и только если ребра а и Ь коллинеарны. bool aimsAt(Edge Sa, Edge Sb, int aclass, int crossType) { Point2 va = a.dest a.org; Point2 vb = b.dest b.org; if (crossType != COLLINEAR) { if ((va.x * vb.y) >= (vb.x * va.y)) return (aclass != RIGHT); else return (aclass != LEFT); } else ( return (aclass != BEYOND); ) Если ребра а и Ъ коллинеарны, то ребро а нацелено на Ъ, если конечная точка a. de st не лежит после Ъ. Это обстоятельство используется для того, чтобы продвинуть а вместо Ь, когда два ребра пересекаются вырожденно
Пошаговая выборка 187 более, чем в одной точке. Позволяя а «догонять» Ъ, мы обеспечиваем, что ни одна точка пересечения не будет пропущена. Возвратимся к обсуждению правил перемещения. Они сформулированы таким образом, чтобы не пропустить следующую точку пересечения. Пра- вила отличают текущее ребро, которое может содержать следующую точку пересечения, от текущего ребра, которое возможно не может содержать сле- дующей точки пересечения, причем в этом случае окно переносится вполне безопасно. Правила перемещения различают четыре ситуации, показанные на рис. 6.14. Здесь ребро а считается находящимся вне ребра Ъ, если ко- нечная точка a. de st расположена слева от Ъ. Рис. 6.14. Четыре правила перемещения: (а) — продвинуть р; б — продвинуть р; в — продвинуть р,- г — продвинуть р 1. р и q нацелены друг на друга: перемещается окно, соответствующее тому ребру (р или q), которое находится снаружи другого. В ситуации рис. 6.14а должно быть перенесено окно на ребре р. Следующая точка пересечения не может лежать на р, поскольку ребро р находится вне полигона пересечения. 2. р нацелено на q, но q не нацелено на р: конечная концевая точка ребра р заносится в полигон пересечения, если р не находится снаружи q и затем окно р переносится. На рис. 6.146 ребро р не может содержать следующей, точки пересечения (хотя оно может содержать некоторую точку пересечения, если р находится не снаружи от q). На рис. по- казана ситуация, в которой ребро р, окно которого должно быть пере- несено, не находится снаружи от ребра q. 3. q нацелено на р, но р не нацелено на q: конечная концевая точка ребра q заносится в полигон пересечения, если q не находится снаружи от р, после чего переносится окно q (рис. 6.14в). Этот случай симметри- чен предыдущему. На рис. показана ситуация, при которой ребро q, окно которого должно быть перенесено, находится снаружи от ребра Р- 4. р и q не нацелены друг на друга: переносится то окно, которое от- носится к ребру, расположенному снаружи от другого. Согласно рис. 6.14 необходимо перенести окно р, поскольку ребро р находится снаружи от ребра q. Работа алгоритма показана на рис. 6.15. Каждое ребро имеет метку i, если оно обрабатывается на шаге i (у некоторых ребер показана двойная метка, поскольку они обрабатываются дважды). Два начальных ребра имеют
188 Глава 6 Рис. 6.15. Поиск полигона пересечения. Для ребра указана метка i, если оно обрабатывается на шаге i. Два на- чальных ребра обозначены меткой О метку 0. На этом рисунке фаза 2 (когда два текущих ребра оказываются принадлежащими одному и тому же серпу) начинается после трех итераций. Алгоритм реализован в программе convexPolygonlntersect. Программе передаются полигоны Р и Q, она возвращает указатель на результирующий полигон пересечения R. Обращение к функции advance использовано для переноса одного из двух текущих ребер и для включения конечной конце- вой точки ребра в полигон R в соответствии с выполнением определенных условий. Используются окна, существующие внутри класса Polygon. enum ( UNKNOWN, P_IS_INSIDE, Q_IS_INSIDE); Polygon *convexPolygon!ntersect(Polygon SP, Polygon SQ) { Polygon *R; Point iPnt, startPnt; int inflag = UNKNOWN; int phase = 1; int maxltns = 2 * (P.sizeO + Q.sizeO); // начало цикла for for (int i = 1; (i<=maxltns) || (phase==2); i++) { Edge p = P.edge(); Edge q = Q.edgeO ; int pclass = p.dest.classify(q); int qclass = q.dest.classify(p); int crossType = crossingpoint(p, q, iPnt); if (crossType == SKEW_CROSS) { if (phase =1) { phase = 2; R = new Polygon; R->insert(iPnt); startPnt = iPnt; } else if (iPnt != R->point()) ( if (iPnt != startPnt) R->insert(iPnt); else return R; ) if (pclass==R!GHT) inflag = P_IS_INSIDE; else if (qclass—RIGHT) inflag = Q_IS_INSIDE; else inflag = UNKNOWN; } else if ((crossType==COLLINEAR) &&
Пошаговая выборка 189 (pciass != BEHIND) ЕЕ (qclass != BEHIND)) inflag = UNKNOWN; bool pAIMSq = aimsAt(p, q, pclass, crossType); bool qAIMSp = aimsAt(q, p, qclass, crossType); if (pAIMSq && qAIMSp) { if ((inflag == Q_IS_INSIDE) || ((inflag == UNKNOWN) && (pclass == LEFT))) advance(P, *R, FALSE); else advance(Q, *R, FALSE); ) else if (pAIMSq) { advance(P, *R, inflag == P_IS_INSIDE); } else if (qAIMSp) { advance(Q, *R, inflag == Q_IS_INSIDE); } else { if ((inflag = Q_IS_INSIDE) ((inflag == UNKNOWN) ££ (pclass == LEFT))) advance(P, *R, FALSE); else advance(Q, *R, FALSE); } } // конец цикла for if (pointlnConvexPolygon(P.point(), Q)) return new Polygon(P); else if (pointlnConvexPolygon(Q.point(), P)) return new Polygon(Q); return new Polygon; } Если после выполнения 2(|P| + |Q|) итераций не будет обнаружено ни одной точки пересечения, то основной цикл завершается, поскольку это оз- начает, что границы полигона не пересекаются. Последующие обращения к функции pointlnConvexPolygon производятся с целью обнаружения ситуа- ций Р С Q, Q A Р или Р A Q = 0. В противном случае, если найдена некоторая точка пересечения iPnt, то алгоритм продолжает построение полигона пересечения R и останавливается только после того, как точка iPnt будет обнаружена вторично. Переменная inflag показывает, какой из двух входных полигонов на- ходится в данный момент внутри другого — т. е. указывает на полигон, текущее ребро которого находится в составе внутренней цепочки текущего серпа. Более того, переменная inflag принимает значение UNKNOWN (неиз- вестно) во время первой фазы, а также всякий раз, когда оба текущих ребра коллинеарны или перекрывают друг друга. Значение этой переменной ме- няется при каждом обнаружении новой точки пересечения. Процедура advance продвигает текущее ребро полигона А, представляю- щее либо Р, либо Q. Эта же процедура заносит конечную концевую точку ребра х в полигон пересечения R, если А находится внутри другого полигона и х не была последней точкой, записанной в R: void advance(Polygon2 &А, Polygon2 ER, int inside) { A.advance(CLOCKWISE);
190 Глава 6 if (inside && (R.point() != A.point())) R.insert(A.point()); } 6.5.1. Анализ и корректность Доказательство корректности показывает наиболее важные моменты ра- боты алгоритма — один и тот же набор правил продвижения работает в течение обеих фаз. Правило продвижения заносит р и q в один и тот же серп и затем передвигает р и q в унисон из одного серпа в другой. Корректность алгоритма следует из двух утверждений: 1. Если текущие ребра р и q принадлежат одному и тому же серпу, тогда можно будет найти следующую точку пересечения, в которой закан- чивается текущий серп, и эта точка будет именно следующей. 2. Если границы полигонов Р и Q пересекаются, то текущие ребра р и q пересекутся в некоторой точке пересечения после не более, чем 2(|Р| + |Q|) итераций. Утверждение 2 обеспечивает, что алгоритм найдет некоторую точку пере- сечения, если таковая существует. Поскольку ребра р и q принадлежат одно- му и тому же серпу, если они пересекаются, то из утверждения 1 следует, что остальные точки пересечения будут найдены в нужном порядке. Рассмотрим сначала утверждение 1. Предположим, что р и q принадле- жат одному и тому же серпу и q достигает некоторой точки пересечения раньше, чем р. Мы покажем, что тогда q останется неподвижным, пока р не достигнет точки пересечения после ряда последовательных продвижений. Могут возникнуть две ситуации. Сначала предположим, что р находится снаружи от q (рис. 6.16а). При этом q останется фиксированным, пока р будет продвигаться согласно нулю или нескольким применениям правила 4, затем нулю или нескольким применениям правила 1 и потом нулю или не- скольким применениям правила 2. Во второй ситуации предположим, что р не находится снаружи от q (рис. 6.166). Здесь q будет оставаться фик- сированным, пока р будет продвигаться путем нуля или нескольких при- менений правила 2. В симметричной ситуации, когда р достигает точки пересечения раньше, чем q, ребро q остается фиксированным, а ребро q про- двигается до точки встречи. Это можно показать аналогичным образом, только меняется роль р и q и правило 3 заменяет правило 2. Из этого сле- дует утверждение 1. Чтобы показать утверждение 2, предположим, что границы Р и Q пере- секаются. После |Р| + |Q| итераций либо р, либо q должны совершить пол- ный оборот вокруг своего полигона. Предположим, что это произошло с р. В некоторый момент времени ребро р должно расположиться так, что оно будет содержать точку пересечения, в которой полигон Q переходит извне полигона Р внутрь его. Это происходит потому, что существуют по крайней мере две точки пересечения и направление пересечения меняется. Пусть q будет ребром внутри окна полигона Q в момент обнаружения та- кого р. На рис. 6.17 граница полигона Q разбита на две цепочки Сг и Cs. Первая цепочка Сг заканчивается в ребре qr, в том ребре полигона Q, которое вхо- дит внутрь полигона Р через его ребро р. Другая цепочка Cs заканчивается
Пошаговая выборка 191 Рис. 6.16. Продвижение к следующей точке пересечения в ребре qs, конечная точка которого лежит справа от бесконечной прямой линии, определяемой ребром р, и она наиболее удалена от этой прямой линии. Следует рассмотреть два случая в зависимости от того, какой из двух цепочек принадлежит ребро q: Случай 1 [q е Сг]. Здесь р остается фиксированным, тогда как q продвигается согласно нулю или нескольким применениям правила 3, затем правила 4, потом правила 1 и, наконец, правила 3, когда обнаруживается точка пересечения. Случай 2 [qj е Сг]. Здесь q остается фиксированным, а р продвигается согласно нулю или не- скольких применений правила 2, затем правила 4, потом оравила 1 и, наконец, правила 2 в мо- мент, когда р окажется внутри q. С этого момента оба ребра р и q могут продвигаться несколько раз, однако ребро q не может быть продвинуто за его следующую точку пересечения, пока р сначала не достигнет предыдущей точки пересечения ребра q (если этого уже не произошло с ребром р). Поскольку р и q заканчиваются в одном и том же серпе, утверждение 1 гарантирует, что после некоторого числа дополнительных продвижений они пересекутся в точке пересечения, в которой заканчивается текущий серп. Чтобы показать, что достаточно 2(|Р| + |Q|) итераций для поиска неко- торой точки пересечения, заметим, что при доказательстве утверждения 2 (о том, что граница полигона Q входит внутрь полигона Р через ребро р при произвольном положении ребрв q) начальные позиции р и q достига- лись при выполнении не более |Р| + |Q| итераций. Фактически такая ситуа- ция, либо симметричная ей, в оторой роли р и q взаимно заменяются, достигается за меньшее число итераций. Поскольку после этого ни р, ни q не будут продвинуты на полный оборот прежде, чем будет достигнута первая точка пересечения, потребуется не более |Р| + |Q| дополнительных продвижений. Рис. 6.17. Иллюстрация к доказательству, что можно найти точку пересечения, если границы Р и Q пересекаются
19S Глава 6 6.5.2. Затруднения Наш алгоритм определения пересечения двух выпуклых полигонов очень чувствителен к ошибкам округления, когда два полигона пересекаются в точке, являющейся вершиной одного или обоих полигонов. Одна из проблем заключается в том, что такая точка может быть пропущена. В одном из случаев на рис. 6.13 ребра р и q пересекаются в точке х, являющейся ко- нечной концевой точкой ребра р. При использовании точной арифметики параметрическое значение х вдоль отрезка р равно единице. Однако в ариф- метике с плавающей точкой фактически вычисленное значение может не- сколько превышать единицу, что соотвотствует положению точки х после конца ребра р и такая точка может оказаться необнаруженной. С целью обойти подобное затруднение при вычислении точки пересечения двух ребер в программе convexPolygonlntersect применяется функция crossingpoint. Для заданных двух ребер е и f функция сначала вычисляет точку, в которой пересекаются бесконечные прямые линии, определяемые ребрами ей/. Если эта точка оказывается в непосредственной близости одного из четырех концов упомянутых ребер, то в качестве точки пересе- чения берется ближайшая концевая точка. В силу особенностей применения функция работает с параметрическими значениями, а не с самими точками. Путем расширения диапазона параметрических значений вдоль ребра / ребро фактически удлиняется на величину EPSILON2 в обоих направлениях. Если точка пересечения при вычислении находится в пределах EPSILON2 от одной из концевых точек ребра /, то точка пересечения принудительно ♦привязывается» к этой концевой точке. То же самое правило применяется и к ребру е. Функция crossingPoint возвращает одно из значений COLLINEAR, PARALLEL, SKEW_NO_CROSS или SKEW_CROSS (коллинеарны, параллельны, не пересекаются, пересекаются), показывающее взаимное положение ребер е и f. Если возвращается значение SKEW_CROSS, показывающее, что ребра имеют точку пересечения, то эта точка пересечения возвращается через ссы- лочный параметр р: #define EPSILON2 1Е-10 int crossingPoint(Edge &e, Edge Sf, Point Sp) { double s, t; int classe = e.intersect(f, s); if ((classe == COLLINEAR) |I (classe = PARALLEL)) return classe; double lene = (e.dest-e.org).length() ; if ((s < -EPSILON2*lene) (((s > 1.0+EPSILON2*lene)) return SKEW_NO_CROSS; f.intersect(e, t); double lenf = (f.org-f.dest).length(); if ((-EPSILON2+lenf <= t) && (t <= 1.0+EPSILON2*lenf)) { if (t <= EPSILON2*lenf) p = f.org; else if (t >= 1.0-EPSZLON2*lenf) p = f.dest; else if (s <= EPSILON2*lene) p = e.org; else if (s >= 1.0—EPSILON2*lene) p = e.dest; else p = f.point(t);
Пошаговая выборка 193 return SKEW_CROSS; } else return SKEW_NO_CROSS; } Если в качестве основы для вычисления точек пересечения брать функ- цию crossingpoint, то наша программа определения пересечения выпук- лых полигонов будет устойчиво работать даже тогда, когда полигоны пере- секаются в вершинах. Это особенно важно для нас, так как в главе 8 эту программу будем использовать в приложениях, в которых неизбежно может встретиться такая ситуация. Но заметим, что наша программа может дать ошибку, если вершины одного из полигонов располагаются очень близко (в пределах EPSILON2) от границ другого полигона, но практически не касаясь границы. 6.6. Определение триангуляции Делоне Триангуляция для конечного набора точек S является задачей триангу- ляции выпуклой оболочки CH (S), охватывающей все точки набора S. От- резки прямых линий при триангуляции не могут пересекаться — они могут только встречаться в общих точках, принадлежащих набору S. Поскольку отрезки прямых линий замыкают треугольники, мы будем считать их реб- рами. На рис. 6.18 показаны два различных варианта триангуляции для одного и того же набора точек (временно проигнорируем окружности, про- веденные на этих рисунках). Для данного набора точек S мы можем видеть, что все точки из набора S могут быть подразделены на граничные точки — те точки, которые лежат на границе выпуклой оболочки CH (S), и внутренние точки — лежащие внутри выпуклой оболочки CH (S). Также можно классифицировать и ребра, полученные в результате триангуляции S, как ребра оболочки и внутренние ребра. К ребрам оболочки относятся ребра, расположенные вдоль границы выпуклой оболочки CH (S), а к внутренним ребрам — все остальные ребра, образующие сеть треугольников внутри выпуклой оболочки. Отметим, что каждое ребро оболочки соединяет две соседние граничные точки, тогда как внутренние ребра могут соединять две точки любого типа. В частности, если внутреннее ребро соединяет две граничные точки, то оно является хордой выпуклой оболочки CH(S). Заметим также, что каждое ребро триангуляции Рис. 6.18. Два различных варианта триангуляции для одного набора точек 7 Заказ 2794
194 Глава 6 является границей двух областей: каждое внутреннее ребро находится между двумя треугольниками, а каждое ребро оболочки — между треуголь- ником и бесконечной плоскостью. Любой набор точек, за исключением некоторых тривиальных случаев, до- пускает более одного способа триангуляции. Но при этом существует заме- чательное свойство: любой способ триангуляции для данного набора опре- деляет одинаковое число треугольников, что следует из теоремы: Теорема 3. (Теорема о триангуляции набора точек). Предположим, что набор точек S содер- жит п>3 точек и не все из них коллинеарны. Кроме того, i точек из них являются внутренними (т. е. лежащими внутри выпуклой оболочки C#(S). Тогда при любом способе триангуляции набора S будет получено точно Л + /-2 треугольников. Для доказательства теоремы рассмотрим сначала триангуляцию п - i граничных точек. Поскольку все они являются вершинами выпуклого полигона, то при такой триангуляции будет получено (n - i) - 2 треуголь- ников. (В этом нетрудно удостовериться и, более того, в главе 8 мы по- кажем, что любая триангуляция произвольного m-стороннего полигона — выпуклого или невыпуклого — содержит т - 2 треугольника). Теперь про- верим, что будет происходить с триангуляцией при добавлении оставшихся i внутренних точек, каждый раз по одной. Мы утверждаем, что добавление каждой такой точки приводит к увеличению числа треугольников на два. При добавлении внутренней точки могут возникнуть две ситуации, пока- занные на рис. 6.19. Во-первых, точка может оказаться внутри некоторого треугольника и тогда такой треугольник заменяется тремя новыми треуголь- никами. Во-вторых, если точка совпадает с одним из ребер триангуляции, то каждый из двух треугольников, примыкающих к этому ребру, заменяется двумя новыми треугольниками. Из этого следует, что после добавления всех i точек, общее число треугольников составит (п - i - 2) + (2i), или просто п - i - 2. Рис. 6.19. Две ситуации, возникающие при триангуляции после добавления новой внутренней точки В этом разделе мы представим алгоритм формирования специального вида триангуляции, известный как триангуляция Делоне. Эта триангуляция хорошо сбалансирована в том смысле, что формируемые треугольники стре- мятся к равноугольности. Так например, триангуляцию, изображенную на рис. 6.18а, можно отнести к типу триангуляции Делоне, а на рис. 6.186 триангуляция содержит несколько сильно вытянутых треугольников и ее нельзя отнести к типу Делоне. На рис. 6.20 показан пример триангуляции Делоне для набора большого числа точек.
Пошаговая выборка 195 Рис. 6.20. Триангуляция Делоне для 250 точек, выбранных случайным образом в пределах прямоугольника. Всего образовано 484 треугольника Для формирования триангуляции Делоне нам потребуется несколько новых определений. Набор точек считается круговым, если существует не- которая окружность, на которой лежат все точки набора. Такая окружность будет описанной для данного набора точек. Описанная окружность для тре- угольника проходит через все три ее (не коллинеарные) вершины. Говорят, что окружность будет свободной от точек в отношении к заданному набору точек S, если внутри окружности нет ни одной точки из набора S. Но, од- нако, точки из набора S могут располагаться на самой свободной от точек окружности. Триангуляция набора точек S будет триангуляцией Делоне, если описан- ная окружность для каждого треугольника будет свободна от точек. На схеме триангуляции рис. 6.18а показаны две окружности, которые явно не содержат внутри себя других точек (можно провести окружности и для дру- гих треугольников, чтобы убедиться, что они также свободны от точек на- бора). Это правило не соблюдается на схеме рис. 6.186 — внутрь проведен- ной окружности попала одна точка другого треугольника, следовательно, эта триангуляция не относится к типу Делоне. Можно сделать два предположения относительно точек в наборе S, чтобы упростить алгоритм триангуляции. Во-первых, чтобы вообще существовала триангуляция, мы должны полагать, что набор S содержит по крайней мере три точки и они не коллинеарны. Во-вторых, для уникальности триангуля- ции Делоне необходимо, чтобы никакие четыре точки из набора S не лежали на одной описанной окружности. Легко видеть, что без такого предположения триангуляция Делоне не будет уникальной, ибо 4 точки на одной описанной окружности позволяют реализовать две различные триангуляции Делоне. Наш алгоритм работает путем постоянного наращивания текущей триан- гуляции по одному треугольнику за один шаг. Вначале текущая триангу- ляция состоит из единственного ребра оболочки, по окончании работы ал- горитма текущая триангуляция становится триангуляцией Делоне. На каждой итерации алгоритм ищет новый треугольник, который подключа- ется к границе текущей триангуляции.
196 Глава 6 Определение границы зависит от следующей схемы классификации ребер триангуляции Делоне относительно текущей триангуляции. Каждое ребро может быть спящим, живым или мертвым'. • спящие ребра: ребро триангуляции Делоне является спящим, если оно еще не было обнаружено алгоритмом; • живые ребра: ребро живое, если оно обнаружено, но известна только одна примыкающая к нему область; • мертвые ребра: ребро считается мертвым, если оно обнаружено и из- вестны обе примыкающие к нему области. Вначале живым является единственное ребро, принадлежащее выпуклой обо- лочке — к нему примыкает неограниченная плоскость, а все остальные ребра — спящие. По мере работы алгоритма ребра из спящих становятся живыми, а затем мертвыми. Граница на каждом этапе состоит из набора живых ребер. На каждой итерации выбирается любое одно из ребер е границы и оно подвергается обработке, заключающейся в поиске неизвестной области, ко- торой принадлежит ребро е. Если эта область окажется треугольником t, определяемым концевыми точками ребра е и некоторой третьей вершиной v, то ребро е становится мертвым, поскольку теперь известны обе примы- кающие к нему области. Каждое из двух других ребер треугольника t пере- водятся в следующее состояние: из спящего в живое или из живого в мерт- вое. Здесь вершина v будет называться сопряженной с ребром е. В противном случае, если неизвестная область оказывается бесконечной плос- костью, то ребро е просто умирает. В этом случае ребро е не имеет сопря- женной вершины. На рис. 6.21 показана работа алгоритма, где действие происходит сверху вниз и слава направо. Граница на каждом этапе выделена толстой линией. Алгоритм реализован в программе delaunayTriangulate. Программе задается массив s из п точек и она возвращает список треугольников, пред- ставляющих триангуляцию Делоне: List<Polygon*> * (Point s[], int n) { Point p; List<Polygon*> ‘triangles = new List<Polygon*>; Dictionary<Edge*> frontier(edgeCmp); Edge *e = hullEdge(s, n); frentier.insert(e); while (!frontier.isEmpty()) { e = frontier.removeMin(); if (mate(*e, s, n, p)) { updateFrontier(frontier, p, e->org); updateFrontier(frontier, e->dest, p); triangles->insert(triangle(e->org, e->dest, p)); } delete e; } return triangles; } Треугольники, образующие триангуляцию, записываются в список triangles. Граница представлена словарем frontier живых ребер. Каждое
Пошаговая выборка 197 Рис. 6.21. Наращивание триангуляции Делоне. Ребра, входящие в состав границы, выделены толстой линией
198 Главе 6 ребро направлено, так что неизвестная область для него (подлежащая оп- ределению) лежит справа от ребра. Функция сравнения edgeCmp использу- ется для просмотра словаря. В ней сравниваются начальные точки двух ребер, если они оказываются равными, то потом сравниваются их концевые точки: int edgeCmp(Edge *а, Edge *b) { if (a->org < b->org) return 1; if (a->org > b->org) return 1; if (a->dest < b->dest) return -1; if (a->dest > b->dest) return 1; return 0; Как же изменяется граница от одного шага к другому и как функция updateFrontier изменяет словарь ребер границы для отражения этих изменений? При подсоединении к границе нового треугольника t изменя- ются состояния трех ребер треугольника. Ребро треугольника t, примыкаю- щее к границе, из живого становится мертвым. Функция updateFrontier может игнорировать это ребро, поскольку оно уже должно быть удалено из словаря при обращении к функции removeMin. Каждое из двух остав- шихся ребер треугольника t изменяют свое состояние из спящего на живое, если они уже ранее не были записаны в словарь, или из живого в мертвое, если ребро уже находится в словаре. На рис. 6.22 показаны оба случая. В соответствии с рисунком мы обрабатываем живое ребро и, после обнаружения, что точка Ъ является сопряженной ему, добавляем треугольник kafb к текущей триангуляции. Затем ищем ребро /о в словаре и, поскольку его там еще нет и оно обнаружено впервые, его состояние изменяется от спящего к живому. Для редактирования сло- варя мы повернем ребро ft так, чтобы примыкающая к нему неизвестная область лежала справа от него и запишем это ребро в словарь. Затем оты- щем в словаре ребро Ъа — поскольку оно есть в нем, то оно уже живое (известная примыкающая к нему область — треугольник Да&с). Так как неизвестная для него область, треугольник kafb, только что была обнару- жена, это ребро удаляется из словаря. Функция updateFrontier редактирует словарь frontier, в котором изменяется состояние ребра из точки а в точку Ь: Рис. 6.22. Подключение треугольника Да/Ъ к живому ребру аТ
Пошаговая выборка 199 void updateFrontier (Dictionary<Edge*> &frontier, Point &a, Point &b) { Edge *e = new Edge(a, b) ; if (frentier.find(e)) frontier.remove(e); else { e->flip(); frontier.insert(e); } } Функция hullEdge обнаруживает ребро оболочки среди п точек массива s. В этой функции фактически применяется этап инициализации и первой итерации метода заворачивания подарка: Edge *hullEdge(Point s[], int n) { int m = 0; for (int i = 1; i < n; i++) if (s[i] < s[m]) m = i; swap(s[0], s[m]); for (m = 1, i = 2; i < n; i++) ( int c = s [i].classify(s[0], s[m]); if ((c == LEFT) || (c == BETWEEN)) m = i; } return new Edge(s[0], s[m]); } Функция triangle просто формирует и возвращает полигон для трех точек, передаваемых ей в качестве параметров: Polygon *triangle(Point &а, Point &b, Point &c) ( Polygon *t = new Polygon; t->insert(a); t->insert(b); t->insert(c) ; return t; } 6 .6.1. Поиск сопряженной точки для ребра Обратим внимание на задачу, решаемую функцией mate, которая опре- деляет, существует ли для данного живого ребра сопряженная точка и, если она есть, находит ее. Рассмотрим определение: любое ребро al определяет бесконечное семейство окружностей, проходящих через его концевые точки а и Ъ. Обозначим это семейство окружностей через С(а,Ь) (рис. 6.23). Центры окружностей семейства С(а,Ъ) лежат на прямой линии, перпен- дикулярной отрезку al и проходящей через его центр, и для них можно установить однозначное соотношение с точками на этом перпендикуляре. Для спецификации окружностей семейства параметризуем перпендикуляр —
200 Глава 6 Рис. 6.23. Четыре окружности из семейства С(а,Ь), определяемого ребром аб и их параметрические значения припишем каждой окружности параметрическое значение положения ее центра. Средства, описанные в главе 4, позволяют определить естественную параметризацию: ребро aS поворачивается вокруг своей средней точки на 90 градусов до совпадения с перпендикуляром и затем можно использовать параметрические значения точек вдоль этого ребра. На рис. 6.23 использу- ется запись Сг для обозначения окружности, соответствующей параметри- ческому значению г. Как же найти сопряженную точку для некоторого живого ребра aS среди множества точек набора S? Предположим, что окружность Сг является описан- ной окружностью для известной области ребра aS (на рис. 6.24 такой известной областью будет треугольник Aabc). Если известная область для ребра aS является неограниченной, то г = и Сг представляет собой полуплоскость, лежащую слева от aS. Нам нужно найти такое наименьшее значение t > г, чтобы неко- торая точка из набора S (отличная от точек а и &), принадлежала окружнос- ти Ct. Если не существует такого значения t, то ребро ab не имеет сопряженной точки. Более образно это соответствует надуванию двухмерного пузыря, при- вязанного к отрезку aS. Если такой пузырь достигает некоторой точки из на- бора S, то эта точка является сопряженной отрезку aS (точка d на рис. 6.24). В противном случае, если не встретится ни одной такой точки из S, то пузырь разрастается до заполнения всей бесконечной полуплоскости справа от отрез- ка aS и тогда отрезок aS не имеет сопряженной точки. Почему же работает такой алгоритм? Пусть Сг обозначает описанную ок- ружность известной области отрезка aS и Ct — описанную окружность не- Рис. 6.24. Определение сопряженной точки (d) для ребра аб
Пошаговая выборка SOI известной области отрезка ab. Здесь t > г и t = если отрезок ab не имеет сопряженной точки. Будет ли окружность Ct свободной от точек, что нам нужно? Слева от отрезка aS окружность Ct должна быть свободной от точек, поскольку Сг свободна от точек, а часть Ct, лежащая слева от ребра aS, находится внутри Сг. Справа от ребра aS Ct также должна быть свободна от точек, поскольку если бы некоторая точка q попала бы внутрь этой ок- ружности, то она бы принадлежала окружности Cs G С(а,Ъ), где г < s < t, что противоречило бы нашему выбору t. В нашей аналогии с пузырем рас- ширяющийся пузырь достиг бы точки q до того, как он достиг сопряженной точки ребра aS. При поиске сопряженной точки для ребра aS мы будем рассматривать только те точки р G S, которые лежат справа от aS. Центр окружности, опи- санной вокруг любых трех точек а, Ь, и с, лежит на дересечении перпенди- куляров, проведенных через середины отрезков aS и Ьр. (Здесь используется тот факт, что перпендикуляры в серединах ребер треугольников пересекаются в центре описанной окружности треугольника.) Вместо вычисления положе- ния центра окружности мы будем вычислять его параметрическое значение вдоль перпендикуляра к середине ребра aS. Таким образом мы можем осу- ществить поиск наименьшего параметрического значения. Эта методика реализована в функции mate, которая возвращает значение TRUE, если ребро е имеет сопряженную точку, и FALSE, если такой точки нет. Если сопряженная точка существует, то она возвращается через ссы- лочный параметр р: bool mate(Edge &е, Point s[], int n, Point &p) { Point *bestp = NULL; double t, bestt = FLT_MAX; Edge f = e; f.rot(); // f — перпендикуляр в середине отрезка е for (int i = 0; i < n; i++) if (s[i].classify(e) == RIGHT) { Edge g(e.dest, s[i]); g.rot(); f.intersect(g, t); if (t < bestt) { bestp = &s[i]; bestt = t; } ) if (bestp) { p = *bestp; return TRUE; ) return FALSE; ) В функции mate переменная bestp указывает на самую лучшую точку, найденную к данному моменту, а в переменной bestt хранится парамет- рическое значение для окружности, которое проходит через эту точку. На- помним, что при этом анализируются только те точки, что лежат справа от ребра е.
SOS Глава 6 Этот алгоритм для вычисления триангуляции Делоне по набору из п точек выполняется за время О(п2), поскольку при каждой итерации из гра- ницы исключается одно ребро. Поскольку каждое ребро исключается из гра- ницы точно однажды — каждое ребро относится к границе однажды и затем исключается из нее, никогда не возвращаясь — число итераций равно числу ребер в триангуляции Делоне. Согласно теореме о триангуляции набора точек любая триангуляция содержит не более, чем О(п) ребер, поэтому ал- горитм выполняет О(п) итераций. Поскольку на каждую итерацию тратится время О(п), то полностью алгоритм выполняется за время О(п2). 6.7. Замечания по главе Метод заворачивания подарка (известный также как обход по методу Джарвиса) назван по способу обхода вокруг границы выпуклой оболочки, он описан в [43]. Аналогичная основная идея может быть применена для поиска выпуклой оболочки точек более высоких размерностей [17]. В трех- мерном пространстве этот способ как раз и напоминает нам действие по за- ворачиванию подарка. Сканирование по методу Грэхема представлено в [33]. Существует много алгоритмов для поиска выпуклой оболочки и некото- рые из них описаны в этой книге: построение оболочки при вводе, выпол- няемой за время О(п2), где п — количество точек; сканирование плоскости в течение времени O(n log п); сканирование по методу Грэхема за время О(п log п)\ заворачивание подарка за время О(пй), когда выпуклая оболочка содержит h < п вершин; слияние оболочек за время O(n log п). Здесь не был рассмотрен один из интереснейших алгоритмов быстрого построения обо- лочки. Подобно алгоритму быстрой сортировки, на основе которой он со- здан, быстрое построение оболочки занимает время О(п2) в худшем случае, но наиболее вероятно время О(п log п) [14, 25, 34]. Оптимальный алгоритм поиска выпуклой оболочки был сформулирован Киркпатриком и Зейделем [46]. Если формируемая в этом алгоритме оболочка содержит h вершин, то он выполняется за время O(n log й) в худшем случае. Поскольку алгоритмы удаления невидимых поверхностей обязательно присутствуют при реалистичном отображении сцен в трехмерной графике, то эта проблема была в центре многих исследований, что привело к различным решениям. Эти решения зависят от применяемой системы моделирования трехмерной сцены, эффективности, требуемой степени реализма и других факторов. Способ сортировки по глубине, описанный в данной книге, взят из [59]. Другие хорошо известные методы включают буферизацию по оси z [16], метод деления областей Варнока [86], метод Вейлера-Атертона «слоеной булочки» [88], метод построчного сканирования [50, 87] и трасси- ровки луча. Описания некоторых алгоритмов можно найти в различных учебниках по компьютерной графике [28, 39, 68]. При буферизации по оси z глубина объекта, отображаемого в виде пиксела, сохраняется в буфере для значений глубины (в так называемом z-буфере). При необходимости рисо- вания нового объекта, значение каждого пиксела селективно редактирует- ся — новый объект перекрывает только те пикселы, которые принадлежат более удаленному объекту и в z-буфер новое значение записывается только для более близкого объекта. Поскольку этот способ наиболее простой и
Пошаговая выборка S03 общий (с точки зрения обеспечения работы с широким диапазоном моделей сцен), то z-буферизация была реализована аппаратным путем в нескольких новейших графических системах. При трассировке луча, другом распростра- ненном способе удаления невидимых поверхностей, на сцену направляется как бы луч света, отражение которого обрабатывается в компьютере. Трас- сировка луча может быть использована для формирования изображений, включающих такие свойства, как прозрачность, зеркальное отражение и за- тенение. Алгоритмы для нахождения пересечения двух выпуклых полигонов Р и Q представлены в [61, 62], хотя наше описание более точно соответствует [66]. Более ранний алгоритм для решения такой же задачи был опубликован в [75]. При этом способе через каждую вершину проводится вертикальная прямая линия, разделяющая плоскость на две части, а каждый полигон на треугольники и трапецоиды. Задача пересечения затем решается по очереди внутри каждой полуплоскости, а результирующие полигональные куски суммируются. Поскольку пересечение двух полигонов ограниченного разме- ра может быть вычислено за постоянное время и может быть произведено не более, чем |Р| + |Q| делений по вертикали, то этот алгоритм, подобно опи- санному в этой книге, будет выполнен за время О(|Р| + |Q|). Триангуляция Делоне является дуальной диаграмме Вороного, полиго- нальной декомпозиции плоскости, которая играет очень важную роль в вы- числительной геометрии. Эта связь будет использована в главе 8, где будет описан алгоритм формирования диаграммы Вороного. Представленный в этой главе алгоритм триангуляции Делоне основан на работах [5, 55]. Ал- горитм расширен на трехмерное пространство в [24], а структура данных, пригодная для расширения до d-мерного пространства, дана в [12]. Алго- ритм для построения триангуляции Делоне на плоскости за время O(n log п), использующий метод «разделяй и властвуй», описан в [36]. Обзор методов формирования диаграмм Вороного и триангуляций Делоне дан в [4]. 6.8. Упражнения 1. Измените программу giftwrapHull так, чтобы выпуклая оболочка CMS), которую она формирует, включала все граничные точки набора S, а не только экстремальные точки. 2. Измените программу grahamScan для выполнения условий, аналогич- ных предыдущему примеру. 3. Глубина точки р в конечном наборе S определяется числом выпуклых оболочек, которые требуется удалять до тех пор, пока р не станет гра- ничной точкой. Например, граничные точки набора S имеют глубину ноль, а точки, которые станут граничными после удаления точек, принадлежащих границе набора S, имеют глубину единица. Самый грубый подход к определению глубины всех точек заключается в многократном определении выпуклой оболочки набора точек и удале- нию граничных точек из набора, пока он не станет пустым. Измените
804 Глава 6 функцию giftwrapHull так, чтобы она вычисляла глубину каждой точки за время О(п2). 4. Диаметр набора точек равен максимальному расстоянию между любой парой точек. а) покажите, что диаметр набора точек определяется парой экстре- мальных точек. б) дайте алгоритм, выполняемый за время O(n log п), для вычисления диаметра набора п точек на плоскости. 5. Опишите конфигурацию п треугольников в пространстве, которую функция depthsort может расщепить на П(п2) кусков. 6. В программе depthSort при обращении к функции mayObscure (р, q) возвращается значение TRUE, если возможно, что треугольник р за- крывает треугольник q. Что произойдет с программой depthSort, если сделать функцию mayObscure более строгой, например, чтобы она возвращала значение TRUE, если и только если р закрывает q. Какие при этом получаются преимущества и недостатки? 7. Отметим, что второе обращение к функции depthSort в программе mayObscure является неэффективным, поскольку повторные тесты 1, 2 и 5 не нужны. Перепишите программу, чтобы исключить эту не- эффективность . 8. Рассмотрим следующее утверждение относительно полигона пересечения двух выпуклых полигонов Р и Q: если границы двух полигонов Р и Q пересекаются, то алгоритм находит все точки их пересечения не более, чем за 2(|Р| + |Q|) итераций. Либо докажите это утверждение, либо из- мените соответствующим образом программу convexPolygonlntersect, либо опровергните утверждение, дав контрпример. 9. Опишите возможные входные данные, при обработке которых про- граммой convexPolygonlntersect будет получен неправильный ре- зультат в терминах значения EPSIL0N2. 10. Покажите на примере, как в доказательстве корректности программы convexPolygonlntersect используется предположение, что входные полигоны Р и Q являются выпуклыми. 11. Покажите, что триангуляция Делоне является уникальной, если ни- какие четыре точки набора S (при (|S| > 3)) не принадлежат одной опи- санной окружности. 12. Покажите, что при любой триангуляции конечного набора точек S получается 3|S| - 3 - h ребер, если граница CH(S) содержит h ребер. Из этого, кстати, следует, что триангуляция содержит O(|S|) ребер — факт, использованный в нашем доказательстве выполнения алгоритма триангуляции Делоне за время О(п2). 13. Покажите, что из всех триангуляций конечного набора точек S при триангуляции Делоне максимизируется минимальное значение внут- ренних углов.
Глава 7 Алгоритмы сканирования на плоскости с ^сканирование на плоскости является очень полезным инструментом реше- ния задач обработки геометрических объектов на плоскости. Воображаемая вертикальная линия сканирования перемещается слева направо, пересекая объекты. В процессе обработки линии сканирования решаются задачи, отно- сящиеся только к частям объектов, лежащих слева от линии сканирования. В структуре линии сканирования, представляющей состояние алгоритма для каждого положения линии сканирования, собирается информация об услови- ях слева от линии сканирования, которые должны учитываться при обработке объектов, располагающихся справа от нее. Когда линия сканирования продви- нута достаточно далеко, обычно за все объекты, то вся исходная задача оказы- вается решенной полностью. Линия сканирования продвигается шагами, останавливаясь в позиции, носящей название точка-событие. Точки-события это те точки на плоскос- ти, достижение которых линией сканирования вызывает изменение состо- яния алгоритма. Это могут быть, например, вершины полигонов, концевые точки отрезков прямых линий или точки пересечений в зависимости от ре- шаемой задачи. Точки-события хранятся в перечне точек-событий, который упорядочен по возрастанию координаты х и используется для принятия ре- шения о виде точки-события, которая должна будет обнаружена линией ска- нирования. В некоторых применениях перечень точек-событий является ста- тичным, поскольку можно заранее установить все виды точек-событий, в других он динамичный, поскольку будущая точка-событие обнаруживается только по мере выполнения сканирования. В процессе выполнения алгоритма при каждом появлении точки-события происходит переход. Переход заключается в выполнении трех видов дейст- вий: 1. Редактирование перечня точек-событий: занесение только что обнару- женных точек-событий, удаление текущей точки-события, а также всех других, которые стали ненужными. 2. Редактирование структуры линии сканирования для представления из- менений состояния в алгоритме из-за нового положения линии ска- нирования. 3. Решение остальных вопросов задачи. При сканировании задача на плоскости сводится к серии аналогичных задач в одномерном пространстве (на линии сканирования). Это достаточно мощный подход, поскольку возникающие одномерные задачи обычно решить гораздо
806 Глава 7 проще, чем исходную двухмерную задачу. Более того, поскольку изменение задач на каждом шаге сканирования бывает очень малым и в большинстве случаев их изменение от одной линии сканирования к следующей бывает предсказуемым, то одномерные задачи могут рассматриваться как серия свя- занных задач, позволяющих получить общее решение с помощью абстракт- ных типов данных (часто типовых). В этой главе мы рассмотрим несколько случаев применения сканирова- ния плоскости. В первом примере найдем все пары пересекающихся отрез- ков прямых линий из заданного набора отрезков на плоскости. Во втором случае найдем выпуклую оболочку для набора точек на плоскости с исполь- зованием способа, аналогичного способу ввода оболочки в разделе 5.3, но гораздо более эффективного. В третьей реализации вычисляется граница объединения набора прямоугольников, стороны которых параллельны двум координатным осям. В четвертом и последнем случае выполняется деком- позиция произвольного полигона на монотонные части. Если эту последнюю реализацию использовать совместно с алгоритмом триангуляции монотонно- го полигона, описанным в разделе 5.8, то в результате будет получен эф- фективный способ триангуляции произвольных полигонов. 7.1. Определение точек пересечения отрезков прямых линий В этом разделе мы коснемся следующей задачи: для заданной коллек- ции из п отрезков прямых линий найти все пары взаимопересекающихся отрезков. При наиболее грубом подходе для обнаружения факта пересечения проверяются все возможные пары отрезков. Это занимает время 0(п2), по- скольку имеется пар, а каждая проверка выполняется за фиксиро- ванное время. В этом разделе применим способ сканирования на плоскости для решения этой задачи за время O((r + п) log п) , где г равно числу об- наруженных пар. Способ сканирования по сравнению со способом «грубой силы» имеет большое преимущество (особенно при малом г), за счет умень- шения числа проверок на пересечения: полностью исключается проверка таких пар, составные части которых расположены далеко друг от друга и поэтому их пересечение невозможно. Для упрощения обсуждения сделаем несколько допущений. Во-первых, ни один из отрезков прямой линии не должен быть вертикальным (в любом конкретном случае такой ситуации можно избежать путем некоторого по- ворота плоскости). Во-вторых, любые два отрезка прямой линии могут пере- секаться только в единственной точке. В-третьих, никакие три отрезка пря- мой линии не пересекаются в одной и той же точке. 7.1.1. Представление точек-событий В процессе сканирования плоскости линия сканирования при своем пере- мещении слева направо пересекает п отрезков прямой линии, останавлива- ясь в точках-событиях. В данном примере могут появиться три вида точек- событий: левые концевые точки, правые концевые точки и пересечения (или, более точно, точки пересечения). Точки-события будем представлять
Алгоритмы сканирования на плоскости 207 тремя классами, получаемыми из общего базового абстрактного класса EventPoint: class EventPoint { public: Point p; virtual void handleTransition(Dictionary<Edge*>&, Dictionary<EventPoint*>&, List<EventPoint*>*) = 0; }; Элемент данных p содержит точку-событие. Абстрактная компонентная функция handleTransition будет конкретизирована в классах, полученных из EventPoint. Класс LeftEndpoint запоминает левую концевую точку отрезка прямой линии, в элементе данных р, наследуемом из базового класса. Сам отрезок прямой линии сохраняется в элементе данных е: class LeftEndpoint: public EventPoint { public: Edge e; LeftEndpoint(Edge*) ; void handleTransition(Dictionary<Edge*>&, Dictionary<EventPoint*>&, List<EventPoint*>*); ) ; Конструктор LeftEndpoint инициализирует элементы данных: LeftEndpoint::LeftEndpoint(Edge *_e) : e(*_e) ( p = (e.org < e.dest) ? e.org : e.dest; } Класс RightEndpoint определяется аналогично: class RightEndpoint: public EventPoint ( public: Edge e; RightEndpoint(Edge*); void handleTransition(Dictionary<Edge*>&, Dictionary<EventPoint*>&, List<EventPoint*>*); ); RightEndpoint::RightEndpoint(Edge *_e) e (*_e) { p = (e.org < e.dest) ? e.dest : e.org; ) Класс Crossing запоминает в элементах данных el и е2 оба пересека- ющихся отрезка прямой линии, а в элементе данных р — их точку пере- сечения:
908 Глава 7 class Crossing: public EventPoint { public: Edge el, e2; Crossing(Edge*, Edge*, Points); void handleTransition(Dictionary<Edge*>£, Dictionary<EventPoint*>S, List<EventPoint*>*); ); Его конструктор инициализирует элементы данных: Crossing::Crossing(Edge *_el, Edge *_e2, Point S_p) el(*_el), e2(*_e2) { p = _p; ) 7.1.2. Программа верхнего уровня Целиком задача решается программой intersectSegments. Программе передается массив s из п отрезков прямых линий и она возвращает список объектов Crossing, представляющих точки пересечения, обнаруженные программой. В программе используется глобальная переменная curx для хранения текущей координаты х сканирующей линии: double curx; / / текущая координата х на сканирующей линии List<EventPoint*> «intersectSegments(Edge s[], int n) { Dictionary<EventPoint*> schedule = buildSchedule(s, n) ; Dictionary<Edge*> sweepline(edgeCmp2); List<EventPoint*> *result = new List<EventPoint*>; while (!schedule.isEmpty()) { EventPoint *ev = schedule.removeMin(); curx = ev->p.x; ev—>handleTransition(sweepline, schedule, result); ) return result; ) Программа intersectSegments инициализирует перечень точек-событий и структуру сканирующей линии и затем пошагово выполняет переход. По- скольку функция handleTransition объявлена виртуальной, то версия этой функции на каждом шаге цикла while зависит от типа события ev. Перечень точек-событий нами представлен в виде словаря, поскольку перечень изменяется динамически: по мере перемещения сканирующей линии точки пересечения должны заноситься в перечень точек-событий и удаляться из него. Но перед началом сканирования в него должны быть предварительно занесены все п левых и все п правых концевых точек. Функция buildSchedule заносит эти 2п точек-событий в перечень точек- событий, который затем и возвращается: Dictionary<EventPoint*> SbuildSchedule (Edge s[], int n) {
Алгоритмы сканирования на плоскости 80» Dictionary<EventPoint*> *schedule = new Dictionary<EventPoint*>(eventCmp); for (int i = 0; i < n; i++) { schedule.insert(new LeftEndpoint(&s[i])); schedule.insert(new RightEndpoint(&s[i])); } return * schedule; Функция сравнения eventCmp, используемая функцией buildSchedule для инициализации перечня точек-событий, упорядочивает точки-события в порядке возрастания координаты х, а в случае совпадения учитывается воз- растание координаты у. int eventCmp(EventPoint *а, EventPoint *Ь) { if (а—>р < Ь—>р) return -1; else if (а->р > Ь->р) return 1; return 0; } 7.1.3. Структура сканирующей линии Набор всех отрезков прямых линий может быть классифицирован отно- сительно любого данного положения сканирующей линии. У спящих отрез- ков обе концевые точки располагаются справа от сканирующей линии. Ак- тивные отрезки имеют по одной концевой точке с каждой стороны сканирующей линии. У мертвых отрезков обе концевые точки находятся слева от сканирующей линии. Структура сканирующей линии sweepline фактически является словарем, содержащим активные отрезки прямых линий, т. е. таких, которые в данный момент пересекаются сканирующей линией, упорядоченным в соответствии с координатой у точек их пересе- чения со сканирующей линией. Например, для случая, показанного на рис. 7.1, активные сегменты записываются в порядке а < b < с, когда ска- нирующая линия находится в позиции xi (поскольку отрезок прямой линии d не является активным, то в данный момент он игнорируется). Словарь sweepline инициализируется с помощью функции сравнения edgeCmp2, которая использует текущее вертикальное упорядочение. Как уже упоминалось, в глобальной переменной curx хранится текущая коор- дината х сканирующей линии: Рис. 7.1. Активные отрезки упорядочены по вертикали как а < b < с, если сканирующая линия находится в позиции xi, или как b < а < с, если в позиции Х2
910 Глава 7 #define EPSILON3 IE-10 int edgeCmp2 (Edge *a, Edge *b) { double ya = a->y(curx EPSILON3); double yb = b->y(curx EPSILON3); if (ya < yb) return -1; else if (ya > yb) return 1; double ma = a->slope(); double mb = b->slope(); if (ma > mb) return -1; else if (ma < mb) return 1; return 0; Функция сравнения edgeCmp2 оказалась неожиданно сложной, поскольку она может выполнять сравнение любых пар отрезков прямых линий, даже тех, которые пересекаются со сканирующей линией в совпадающей точке. Если обнаруживается такая ситуация, то функция сравнивает наклоны от- резков прямых линий и считает, что отрезок прямой линии с наибольшим наклоном находится ниже всех остальных отрезков. Это моделируется не- большим сдвигом сканирующей линии влево. Во избежание появления оши- бок округления применяется константа EPSIL0N3. 7.1.4. Переходы Следующее наблюдение оказывается очень важным для нашего алгорит- ма. Предположим, что отрезки прямых линий а и Ь пересекаются в точке р и эта точка р находится справа от сканирующей линии. Тогда, если между точкой р и сканирующей линией нет никаких других концевых точек или точек пересечения, т. е. точка р является следующей точкой-событием, об- наруживаемой при перемещении сканирующей линии, тогда отрезки пря- мых линий а и b должны быть соседними при их вертикальной сортировке в структуре сканирующей линии. Почему это так? Легко видеть, что оба отрезка а и b должны быть ак- тивными отрезками. Чтобы удостовериться, что они должны располагаться рядом по вертикали в структуре сканирующей линии, предположим про- тивное — на следующем шаге сканирующая линия обнаружит их пересе- чение, но они не являются соседними по вертикали. В этом случае неко- торый другой активный отрезок прямой линии с должен пересекать Рис. 7.2. (а) — правая концевая точка отрезка с лежит между сканирующей линией и точкой р; (б) - отрезок прямой линии с пересекает отрезок а между сканирующей линией и точкой р
Алгоритмы сканирования на плоскости 911 сканирующую линию между а и Ъ. Это означает, что либо правая концевая точка отрезка с оказывается между точкой р и сканирующей линией (рис. 7.2а), либо отрезок с пересекает а или b в некоторой точке, лежащей между р и сканирующей линией (рис. 7.26). В любом случае сканирующая линия обнаружит концевую точку или точку пересечения до того, как она достигнет точки р, что противоречит нашему предположению. В алгоритме это наблюдение используется в форме инварианта, вклю- чающего перечень точек-событий: для каждой пары соседних по вертикали ребер, имеющих точку пересечения справа от сканирующей линии, в пере- чне точек-событий содержится точка их пересечения. Это гарантирует, что перед тем, как сканирующая линия достигнет точки пересечения, эта точка уже должна быть в перечне точек-событий. Никакая точка пересечения не будет пропущена. Основная работа по выполнению перехода направлена на реализацию этого инварианта. Рассмотрим три вида перехода, начиная с левой концевой точки (рис. 7.3а). Когда сканирующая линия обнаруживает левую концевую точку отрезка прямой линии Ь, то активные отрезки а и с (ниже и выше отрезка b соответственно) перестают быть соседними по вертикали вдоль линии ска- нирования. Если существуют оба отрезка а и с (а их может и не быть) и они пересекаются справа от сканирующей линии, то их точка пересечения удаляется из перечня точек-событий. Более того, поскольку теперь отрезки а и b стали соседними по вертикали и необходимо проверить, не пересекаются ли они, и, если пересекаются, то нужно занести их точку пересечения в пере- чень точек-событий. Точно также, если пересекаются отрезки b и с, то их точка пересечения заносится в перечень точек-событий. Итак, сформируем функцию LeftEndpoint::handleTransition: void LeftEndpoint::handleTransition(Dictionary<Edge*> &sweepline, Dictionary<EventPoint*> &schedule, List<EventPoint*> ‘result) { Edge *b = sweepline.insert(&e); Edge *c = sweepline.next(); sweepline.prev(); Edge *a = sweepline.prev(); double t; if (a SS c && (a->cross(*c, t)==SKEW_CROSS)) { Point p = a->point(t); Рис. 7.3. (a) - перенос левой концевой точки; (б) — перенос пересечения,- (в) - перенос правой концевой точки
919 Глава 7 if (curx < p.x) { Crossing cev(a, c, p); delete schedule.remove(Scev); ) ) if (c && (b->cross(*c, t) == SKEW_CROSS)) schedule.insert(new Crossing(b, c, b~>point(t))); if (a && (b->cross(*a, t) == SKEW_CROSS)) schedule.insert(new Crossing(a, b, b->point(t))); Теперь рассмотрим случай перехода для пересечения. Когда сканирую- щая линия достигает точки пересечения отрезков прямых линий Ь и с, оба отрезка должны поменяться местами при их упорядочении по вертикали (рис. 7.36). Поскольку отрезок b пересекает вышележащий отрезок с, мы должны проверить, не будут ли теперь отрезок Ь и активный отрезок d (те- перь располагающийся над Ь) пересекаться справа от сканирующей линии и если да, то их точка пересечения должна быть занесена в перечень. Ана- логично, если отрезок с и активный отрезок а (теперь оказавшийся ниже отрезка с) пересекаются справа от сканирующей линии, то их точка пере- сечения также заносится в перечень. Кроме того, мы должны удалить из перечня точек-событий точки пересечения отрезков а и Ъ, а также с и d, если они имеются. Это приводит к следующему определению функции: void Crossing::handleTransition(Dictionary<Edge*> &sweepline, Dictionary<EventPoint*> &schedule, List<EventPoint*> *result) { Edge *b = sweepline.find(&el); Edge *a = sweepline.prev(); Edge *c = sweepline.find(Se2); Edge *d = sweepline.next(); double t; if (a && (a->cross(*c, t)==SKEW_CROSS)) { Point p = a->point(t); if (curx < p.x) schedule.insert(new Crossing(a, c, p)); ) if (d && (d->cross(*b, t)==SKEW_CROSS)) ( Point p = d->point(t); if (curx < p.x) schedule.insert(new Crossing(b, d, p)); ) if (a (a->cross(*b, t)==SKEW_CROSS)) { Point p = a->point(t); if (curx < p.x) { Crossing cev(a, b, p); delete schedule.remove(Scev); ) ) if (d SS (d->cross(*c, t)==SKEW_CROSS)) { Point p = d->point(t); if (curx < p.x) { Crossing cev(c, d, p); delete schedule.remove(Scev);
Алгоритмы сканирования на плоскости 913 ) ) sweepline.remove(Ь); curx += 2*EPSILON3; sweepline.insert(h); curx -= 2*EPSILON3; result->append(this); ) Несколько последних операторов функции осуществляют взаимную заме- ну ребер b и с в структуре сканирующей линии. Это выполняется путем удаления ребра b и его повторного занесения после предварительного не- большого смещения сканирующей линии вправо. Наконец, рассмотрим переход для правой концевой точки. При дости- жении сканирующей линией правой концевой точки отрезка прямой линии Ь, активные отрезки а и с (ниже и выше отрезка b соответственно) станут соседними по вертикали вдоль сканирующей линии (рис. 7.Зв). Если отрезки а и с существуют и пересекаются справа от сканирующей линии, то их точка пересечения записывается в перечень. Эта операция осуществляется функцией RightEndpoint::handleTransition: void RightEndpoint::handleTransition(Dictionary<Edge*> &sweepline, Dictionary<EventPoint*> &schedule, List<EventPoint*> *result) { Edge *b = sweepline.find(&e); Edge *c = sweepline.next(); sweepline.prev(); Edge *a = sweepline.prev(); double t; if (a && c && (a->cross(*c, t)==SKEW_CROSS)) { Point p = a—>point(t); if (curx < p.x) schedule.insert(new Crossing(a, c, p)); ) ) 7.1.5. Анализ Если г определяет общее число пересечений среди п отрезков прямых линий, то всего выполняется г + 2п операций перехода. При каждой опера- ции перехода выполняется постоянное (небольшое) число действий над струк- турой сканирующей линии и перечнем точек-событий. Каждое действие над структурой занимает время O(log п), поскольку все п отрезков прямых линий могут оказаться активными одновременно. Такое же время O(log п) может занимать и каждое действие над перечнем точек-событий. Действительно, можно определить, что перечень точек-событий может содержать до п — 1 точек-событий типа пересечения в некоторый момент времени, поскольку су- ществует не более п — 1 пар вертикально соседних активных элементов. Пере- чень точек-событий может также содержать до 2п концевых точек. Следо- вательно, перечень точек-событий содержит О(п) элементов, поэтому действия над ним выполняются за время O(log п). Поскольку каждая из г + 2п операций
914 Глава 7 перехода занимает время O(log п), алгоритм целиком будет выполнен за время O((r + п) log п). Характеристики этого алгоритма отражают его глубокую стратегию: потра- тить дополнительное время на исключение пар отрезков прямых линий, ко- торые возможно не могут пересекаться, чтобы сократить время на их тести- рование. Пары отрезков, расположенные далеко друг от друга так, что их проекции на ось х взаимно не перекрываются и не тестируются, поскольку такие отрезки не могут оказаться активными одновременно. Пары отрезков, расположенные далеко друг от друга по вертикали в том смысле, что между ними могут оказаться другие отрезки, также не подвергаются тестированию, поскольку они не могут оказаться соседними в структуре сканирующей линии. Расплата за исключение таких неперспективных пар отрезков прямых линий учитывается коэффициентом замедления log п. Такая2 стратегия оправдывается, когда число г достаточно мало: если г 6 то алгоритм выполняется за время о(п2), что лучше параметров прямолинейного грубого подхода к решению задачи. Однако, если на самом деле будут пересекаться много отрезков прямых линий (например, г 6 О(п2)), то такой алгоритм менее эффективен, чем прямолинейный под- ход, поскольку цена исключения неперспективных пар становится слиш- ком высокой и такие действие непродуктивны. 7.2, Поиск выпуклой оболочки (построение оболочки при вводе) В разделе 5.3 была описана программа insertionHull, которая опреде- ляет выпуклую оболочку набора из п точек на плоскости путем включения их по очереди в текущую оболочку. Анализ этой программы показал, что ее параметры определяются главным образом двумя операциями: (1) определени- ем, не будет ли каждая из вновь вводимых точек р попадать внутрь текущей оболочки (т. е. «абсорбироваться» ею), и (2) поиском некоторой вершины в ближней цепочке текущей оболочки, если точка р лежит вне текущей обо- лочки. Поскольку выполнение каждой из этих операций занимает время, пропорциональное размеру текущей оболочки и, в худшем случае, размер оболочки увеличивается на одну вершину за каждую итерацию, можно за- ключить, что программа insertionHui I выполняется за время О(п2). В этом разделе мы кратко рассмотрим версию алгоритма ввода оболочки, ра- ботающую в автономном режиме (off-line) и не требующую выполнения этих двух операций, что в результате дает более эффективную программу, вы- полняемую за время О(п log п). Идея заключается в предварительной сортировке точек слева направо и в последующем включении их в текущую оболочку именно в этом порядке. Это исключает необходимость в проверке попадания каждой вводимой точки р внутрь выпуклой оболочки: поскольку точка р находится справа от всех ранее введенных точек (или по крайней мере выше последней введенной точки), то эта точка р обязательно лежит вне текущей оболочки. Определив, что точка р находится вне текущей оболочки, нам нужно будет включить ее в состав текущей оболочки, для чего нам потребуется определить неко- торую вершину в ближней цепочке текущей оболочки. Но вследствие ус-
Алгоритмы сканирования на плоскости 915 тановленного порядка ввода точек, такая вершина уже известна: последняя точка, включенная в текущую оболочку, и является такой вершиной. Программа insertionHull2 возвращает выпуклую оболочку для п точек в массиве pts. Для практики весьма полезно сравнить эту новую программу с программой insertionHull из раздела 5.3. Polygon *insertionHull2(Point pts[], int n) { Point **s = new (Point*) (n); for (int i = 0; i < n; i++) s[i] = Sptsfi]; selectionsort(s, n, leftToRightCmp); Polygon *p = new Polygon; p->insert(new Vertex(*s[0))); for (i = 1; i < n; i++) { if (*s[i] == *s[i-l]) continue; supportingLine(*s[i], p, LEFT); Vertex *1 = p->v(); supportingLine(*s[i], p, RIGHT); delete p->split(l); p->insert(new Vertex(*s[i])); ) return p; ) Заметим, что новая версия ввода оболочки является примером сканиро- вания на плоскости, хотя при ее объяснении не были использованы тер- мины, принятые для метода сканирования на плоскости. Действительно, от- сортированный массив точек здесь выступает в роли перечня точек-событий (в данном случае статического), а текущая оболочка служит в качестве структуры сканирующей линии. По мере обработки каждой точки изменя- ется текущая оболочка, тем самым неявно редактируя как структуру ска- нирующей линии, так и полного решения задачи для всех точек, лежащих слева от сканирующей линии. 7.2.1. Анализ Проанализируем программу insertionHull2. В пределах одного шага цикла for выполнение только двух обращений к функции supportingLine занимает время больше, чем константа — вместе они выполняются за время, пропорциональное длине ближней цепочки текущей оболочки. Для упрощения анализа приравняем затраты на обработку каждой вершины ближней цепочки затратам на выполнение двух обращений к функции sup- portingLine. Поскольку эти вершины удаляются при последующем обра- щении к функции split, то такое приравнивание для каждой точки по- требуется сделать только один раз — точка, однажды удаленная из оболочки, вторично в нее заноситься не будет. Всего имеется п точек, поэ- тому стоимость всех обращений к функции supportingLine за все п - 1 итерации составит по времени О(п). Отсюда следует, что время выполнения функции insertionHull2 в ос- новном определяется временем начальной сортировки. При использовании
916 Глава 7 оптимального метода сортировки, например, mergeSort, который будет опи- сан в следующей главе, программа insertionHull2 выполняется за время О(п log п). 7.3. Контур объединения прямоугольников Параллельный осям прямоугольник — это такой прямоугольник, стороны которого параллельны координатным осям на плоскости, две его стороны вертикальны и две — горизонтальны. В этом разделе будем использовать метод сканирования плоскости для решения задачи поиска контура-, вы- числения границы (или контура) области, образованной объединением кол- лекции параллельных осям прямоугольников. Такая задача возникает при проектировании интегральных схем (ИС) в электронике при использовании масок ИС в виде параллельных осям прямоугольников. Подобные алгорит- мы применяются при раскладке масок ИС для обеспечения определенных ограничений, например, экономических (минимизация общей площади) или электрических (изоляция или контакт). Контур формируется из одной или нескольких петель, каждая из кото- рых образуется различным числом горизонтальных и вертикальных сегмен- тов контура. Петли не пересекают друг друга, но могут быть вложенными до любой глубины. На рис. 7.4 показаны шесть прямоугольников и контур их объединения. Контур состоит из четырех петель: первая охватывает вто- рую, которая в свою очередь охватывает третью; четвертая лежит в стороне от остальных. На рис. 7.5 показан контур 120 прямоугольников, располо- женных случайным образом в прямоугольной области на плоскости. Для решения задачи определения контура воспользуемся методом скани- рования плоскости и будем продвигать сканирующую линию слева направо через все п прямоугольников при соблюдении следующего инварианта: в каждом положении сканирующей линии все сегменты контура, целиком ле- жащие слева от сканирующей линии, полностью определены. Горизонталь- ный сегмент контура, пересекающий сканирующую линию, не определен до тех пор, пока сканирующая линия не достигнет его правого конца. Инва- риант должен быть пересмотрен при обнаружении сканирующей линией каждого из 2п вертикальных ребер, что означает необходимость поиска таких сегментов контура, которые теперь впервые оказываются слева от ска- Рис. 7.4. Контур объединения шести параллельных осям прямоугольников
Алгоритмы сканирования на плоскости Я17 Рис. 7.5. Контур объединения 120 прямоугольников нирующей линии. Таким образом, левые и правые ребра прямоугольников служат в качестве точек-событий. На рис. 7.6 показаны различные ситуации с двумя прямоугольниками с выделением толстой линией сегментов конту- ра, выявленных в последовательных позициях сканирующей линии. Для яс- ности рисунка сканирующая линия на каждой схеме несколько смещена вправо. Рис. 7.6. Сегменты контура, выявленные в каждой позиции сканирующей линии, выделены толстой линией 7.3.1. Представление прямоугольников Прямоугольники будем представлять следующим классом: class Rectangle { public: Point sw; // юго-западный (нижний левый) угол Point пе; // северо-восточный (верхний правый) угол int id; // идентификатор Rectangle(Point &_sw, Point S_ne, int _id = -1); Rectangle(void) { } Прямоугольник определяется двумя противоположными углами sw и пе и ему приписывается идентификатор id. Конструктор класса определяется как:
918 Глава 7 Rectangle::Rectangle(Point &_sw, Point &_ne, int _id) sw(_sw), ne(_ne), id(_id) ( } Стороны прямоугольника представим в виде класса AxisParallelEdge: enum ( LEFT_SIDE, RIGHT_SIDE, BOTTOM_SIDE, TOP_SIDE); // левая , правая , нижняя , верхняя стороны class AxisParallelEdge ( public: Rectangle *r; // прямоугольник,обладающий ребром int count; double m; int type; // тип стороны прямоугольника AxisParallelEdge(Rectangle*, int); double pos(void); double min(void); double max(void); void setMin (double) ; void handleLeftEdge(Dictionary<AxisParallelEdge*>&, List<Edge*>*); void handleRightEdge(Dictionary<AxisParallelEdge*>&, List<Edge*>*); ); Конструктор класса может быть определен как: AxisParallelEdge::AxisParallelEdge(Rectangle *_r, int _type) r(_r), count(0), m(-DBL_MAX), type(_type) ( ) Компонентная функция pos возвращает позицию текущего ребра по оси, к которой оно перпендикулярно, а компонентные функции min и max воз- вращают значения минимальной и максимальной координат концевых точек ребра вдоль оси, которой оно параллельно. Например, для вертикального ребра с концевыми точками (1, 2) и (1, 4) функция pos возвращает значение 1, функция min возвращает 2 и функция max возвращает 4. Эти функции определяются следующим образом: double AxisParallelEdge::pos(void) ( switch (type) { case LEFT_SIDE: return r->sw.x; break; case RIGHT_SIDE: return r->ne.x; break; case TOP_SIDE: return r->ne.y; break; case BOTTOM_SIDE: default: return r->sw.y; break; ) double AxisParallelEdge::min(void)
Алгоритмы сканирования на плоскости 919 { if (m > —DBL_MAX) return m; switch (type) { case LEFT_SIDE: case RIGHT_SIDE: return r->sw.y; break; case TOP_SIDE: case BOTTOM_SIDE: default: return r->sw.x; break; ) ) double AxisParallelEdge::max(void) { switch (type) { case LEFT_SIDE: case RIGHT_SIDE: return r->ne.y; break; case TOP_SIDE: case BOTTOM_SIDE: default: return r->ne.x; break; ) ) При инициализации верхнего или нижнего ребер оно совпадает с верхней или нижней стороной прямоугольника, которому принадлежит это ребро. Однако, наш алгоритм должен иногда изменить левую концевую точку го- ризонтальных ребер. Это выполняется компонентной функцией setMin. Она используется для изменения значения, возвращаемого последующими обра- щениями к компонентной функции min. Компонентная функция setMin оп- ределяется как void AxisParallelEdge::setMin(double f) { m = f; ) 7.3.2. Программа верхнего уровня Программа findContour решает задачу определения контура массива г из п прямоугольников. Функция накапливает обнаруживаемые сегменты контура в списке сегментов segments, который возвращается по окончании работы: List<Edge*> *findContour(Rectangle r[], int n) { AxisParallelEdge **schedule = buildSchedule(r, n); List<Edge*> ‘segments = new List<Edge*>; Dictionary<AxisParallelEdge*> sweepline(axisParallelEdgeCmp); Rectangle ‘sentinel = new Rectangle(Point(~DBL_MAX,—DBL_MAX), Point(DBL_MAX, DBL_MAX) , -1) ; sweepline.insert(new AxisParallelEdge(sentinel,BOTTOM_SIDE)) ;
990 Глава 7 for (int i = 0; i < 2*n; i++) switch (schedule[i]->type) { case LEFT_SIDE: schedule[i]->handleLeftEdgeTransition(sweepline,segments); break; case RIGHT_SIDE: schedule[i]->handleRightEdgeTransition(sweepline,segments); break; } return segments; ) Функция buildSchedule создает перечень точек-событий в виде массива из 2п вертикальных ребер, отсортированных в соответствии с увеличением координаты х. Функции задается массив г из п прямоугольников и она воз- вращает перечень точек-событий: AxisParallelEdge **buildSchedule(Rectangle r[], int n) ( AxisParallelEdge **schedule = new AxisParallelEdgePtr[2*n]; for (int i = 0; i < n; i++) { schedule[2*i] = new AxisParallelEdge(&r[i], LEFT_SIDE); schedule[2*i+l]=new AxisParallelEdge(&r[i],RIGHT_SIDE); } insertionsort(schedule, 2*n, axisParallelEdgeCmp); return schedule; } Функция buildSchedule сортирует перечень точек-событий, используя функцию сравнения axisParallelEdgeCmp: int axisParallelEdgeCmp(AxisParallelEdge *a, AxisParallelEdge *b) { if (a->pos() < b->pos()) return -1; else if (a->pos() > b->pos()) return 1; else if (a->type < b->type) return -1; else if (a->type > b->type) return 1; else if (a->r->id < b->r->id) return -1; else if (a->r->id > b->r->id) return 1; return 0; } Функция сравнения axisParallelEdgeCmp сравнивает два вертикальных ребра по величине их координаты х и, если они одинаковы, то ребра срав- ниваются по типу (левые ребра считаются меньшими, чем правые). И, на- конец, если и типы одинаковые, то ребра сравниваются по отношению к идентификаторам их соответствующих прямоугольников. Горизонтальные ребра сравниваются аналогично, но роль х-координат играют координаты у, а нижние ребра считаются меньшими, чем верхние ребра. (Алгоритм ни- когда не сравнивает вертикальные ребра с горизонтальными.) Если левое ребро ei некоторого прямоугольника и правое ребро ег пря- моугольника Т?2 имеют одинаковую координату х, то почему нужно считать, что ребро ei меньше, чем ребро 62? Причина заключается в том, что ребро ej
Алгоритмы сканирования на плоскости 991 должно быть занесено в перечень перед е%, чтобы сканирующая линия вхо- дила в прямоугольник 7?1 до того, как она покинет прямоугольник Т?2- Если этого не делать, то алгоритм ошибочно сочтет вертикальное ребро, по ко- торому соприкасаются два прямоугольника, за сегмент контура. Структура сканирующей линии строится по словарному принципу и со- держит активные горизонтальные ребра, т. е. такие горизонтальные ребра, которые пересекают сканирующую линию в текущей позиции. Функция сравнения axisParallelEdgeCmp используется не только для формирования перечня точек-событий, но и для создания и поддержания структуры ска- нирующей линии. 7.3.3. Переходы Переходы, возникающие при каждом обнаружении сканирующей линией вертикального ребра, обрабатываются по-разному для левого и правого ребер. В обоих случаях, однако, мы должны воспользоваться полем счет- чика count активных горизонтальных ребер. Параметр count ребра равен числу прямоугольников, которые перекрывают участок сканирующей линии от данного ребра до ближайшего следующего ребра сверху. Эти числа по- казаны на рис. 7.7 и рис. 7.8. Рассмотрим сначала обработку левых ребер, когда сканирующая линия входит в некоторый прямоугольник R. Для объяснения ситуации обратимся к рис. 7.7а. Для начала внесем в структуру сканирующей линии оба го- ризонтальных ребра а и h прямоугольника R. Затем используем структуру сканирующей линии для анализа последовательности активных горизонталь- ных ребер на промежутке от а до h. Вдоль этого пути можно обнаружить три вида сегментов контура. Во-первых, мы найдем горизонтальный сегмент контура вдоль каждого нижнего ребра, счетчик для которого равен 1 (ребра Ъ и d). Во-вторых, найдем горизонтальный сегмент контура вдоль каждого верхнего ребра, счетчик для которого равен 0 (ребра с и g). Поскольку оба вида сегментов контура далее будут закрываться прямоугольником R, то их пересечение со сканирующей линией определяет их правые концевые точки. е d Рис. 7.7. Обработка ситуации для левого ребра с указанием значений счетчика. Найденные сегменты контура выделены толстой линией
999 Глава 7 В-третьих, мы можем найти вертикальные сегменты контура, которые за- канчиваются в нижнем горизонтальном ребре со значением счетчика 1 (между ребрами а и Ъ), или которые заканчиваются на верхнем ребре со значением счетчика 0 (между ребрами g и й), или которые ограничиваются обоими типами ребер (между ребрами с и d). Все такие вертикальные ребра располагаются вдоль левого ребра прямоугольника R. Полученный результат показан на рис. 7.76. В добавление к определению сегментов контура мы должны отредакти- ровать значения счетчиков, ассоциированных с активными горизонтальны- ми ребрами. Счетчики всех активных горизонтальных ребер, лежащих между а и h, получают приращение на единицу к значению, имевшему место до встречи с прямоугольником R. Значения счетчиков для а и h ини- циализируются с учетом значений счетчиков активных горизонтальных ребер, которые располагаются непосредственно под каждым из них. Левое ребро обрабатывается компонентной функцией handl eLeft Edge. Этой функции передается структура сканирующей линии и список уже из- вестных сегментов контура, к которому она добавляет новые обнаруженные сегменты: void AxisParallelEdge::handleLeftEdge( Dictionary<AxisParallelEdge*> &sweepline, List<Edge*> *segs) ( sweepline.insert(new AxisParallelEdge(r, TOP_SIDE)); AxisParallelEdge *u = sweepline.val(); sweepline.insert(new AxisParallelEdge(r, BOTTOM_SIDE)); AxisParallelEdge *1 = sweepline.val(); AxisParallelEdge *p = sweepline.prev(); float curx = pos(); l->count = p->count + 1; p = sweepline.next(); 1 = sweepline.next (); for ( ; 1 != u; p = 1, 1 = sweepline.next()) { if ((l->type==BOTTOM_SIDE) && (l->count++ == 1)) { segs->append(new Edge(Point(curx, p->pos()), Point(curx, l->pos()))); segs->append(new Edge(Point(l->min(), l->pos()), Point(curx, l->pos()))); } else if ((l->type == TOP_SIDE) && (l->count++ == 0)) segs->append(new Edge(Point(l->min(), l->pos ()), Point(curx, l->pos()))); } if ((l->count = p->count 1) == 0) segs->append(new Edge(Point(curx, p->pos()), Point(curx, l->pos()))); Теперь рассмотрим процесс обработки правого ребра, когда сканирующая линия покидает некоторый прямоугольник R (рис. 7.8). Сначала обратимся к горизонтальным ребрам прямоугольника R в структуре сканирующей линии (а и g на рисунке). Сначала рассмотрим последовательность актив- ных горизонтальных ребер от а вверх до g и будем искать два вида сег- ментов контура. Во-первых, горизонтальные ребра прямоугольника R могут
Алгоритмы сканирования на плоскости 993 (6) Рис. 7.8. Обработка ситуации для правого ребра с указанием значений счетчика. Сегменты контура выделены толстой линией содержать сегменты контура — нижнее ребро, если его счетчик равен 1 и верхнее ребро, если его счетчик равен 0 (на рисунке а является сегментом контура, a g — нет). Во-вторых, найдем вертикальные сегменты контура, соединяющие каждое верхнее горизонтальное ребро со значением счетчика 1 с нижним горизонтальным ребром, счетчик которого равен 2 (между реб- рами а и Ъ и. между ребрами с и d). Для каждого из рассмотренных на этом пути ребер (между а и g), выходящих из пределов прямоугольника R, счетчики уменьшаются на единицу. Попутно, просматривая список от а до g, решим дополнительную задачу: для верхнего горизонтального ребра с уменьшенным значением счетчика, равным 0, и для нижнего горизонтального ребра с уменьшенным значением счетчика, равным 1, применим функцию setMin для смещения левой кон- цевой точки вправо до положения сканирующей линии. На рис. 7.8 таким образом смещены левые концевые точки для горизонтальных ребер Ь, с и d. Чтобы убедиться, что это необходимо, отметим, что каждое из ребер Ъ, с и d содержат сегменты контура, которые еще предстоит обнаружить, и что левые концевые точки каждого из этих сегментов располагаются на пра- вом ребре прямоугольника R, совпадающем с текущей позицией сканирую- щей линии. Чтобы закончить вычисления, связанные с правым ребром, удалим из струк- туры сканирующей линии горизонтальные ребра прямоугольника R (а и g) и тем самым будет завершена обработка прямоугольника R. Компонентная функция handleRightEdge предназначена для обработки правого ребра. Обнаруженные ею сегменты контура будут добавлены в спи- сок segs: void AxisParallelEdge::handleRightEdge( D'ictionary<AxisParallelEdge*> &sweepline, List<Edge*> *segs) { AxisParallelEdge uedge(r, TOP_SIDE); AxisParallelEdge ledge(r, BOTTOM_SIDE]; AxisParallelEdge *u = sweepline.find(&uedge); AxisParallelEdge *1 = sweepline.find(&ledge);
994 Глава 7 float curx = pos(); if (l->count == 1) segs->append(new Edge(Point(l->min() , l->pos()), Point(curx, l->pos()))); if (u->count == 0) segs->append(new Edge(Point(u->min(), u->pos()), Point(curx, u->pos()))); AxisParallelEdge *initl = 1; AxisParallelEdge *p = 1; 1 = sweepline.next(); for ( ; 1 != u; p = 1, 1 = sweepline.next()) { if ((l->type == BOTTOM_SIDE) && (—l->count == 1)) { segs->append(new Edge(Point(curx, p->pos()), Point(curx, l->pos()))); l->setMin(curx); } else if ((l->type == TOP_SIDE) && (—l->count == 0)) l->setMin(curx); ) if (l->count == 0) segs->append(new Edge(Point(curx, p->pos()), Point(curx, l->pos()))); sweepline.remove(u); sweepline.remove(initl); ) 7.3.4. Анализ Для заданных n прямоугольников рассмотрим затраты на выполнение переходов при обработке некоторого прямоугольника R. Переход любого типа требует выполнения небольшого постоянного числа операций со струк- турой сканирующей линии с затратами времени O(log п) плюс время, про- порциональное числу активных горизонтальных ребер, расположенных между нижним и верхним ребрами прямоугольника R. Поскольку число таких ребер может составлять П(и), то в худшем случае обработка этих ак- тивных горизонтальных ребер и будет занимать основное время. Следова- тельно, каждый переход будет занимать время до О(и). Поскольку может потребоваться до 2и переходов, то полностью алгоритм выполняется за время О(и2) в худшем случае. В самом плохом случае у высоких и узких прямоугольников и ши- роких прямоугольников образуют сетку, в которой каждый высокий пря- моугольник пересекает каждый широкий прямоугольник. Поскольку контур такой сетки содержит П(и2) сегментов контура, то потребуется время П(и2) только на сообщение о каждом сегменте контура. Можно попытаться выразить время работы в функциональной зависимос- ти от числа п и размера формируемого контура. Однако, даже если контур составлен из очень небольшого числа сегментов, на его формирование может потребоваться время П(и2). Рассмотрим работу программы findContour, если в качестве входных данных для нее задана сетка из п прямоугольни- ков, описанная в предыдущем абзаце плюс большой прямоугольник R, ох- ватывающий эту сетку. Хотя контур объединения этих и + 1 прямоуголь- ников состоит только из четырех ребер прямоугольника R, для его выявления потребуется время П(п2). По мере продвижения сканирующей
Алгоритмы сканирования на плоскости 995 линии слева направо алгоритм должен осуществлять редактирование струк- туры сканирующей линии в соответствии с обнаруженными ребрами сетки, даже если эта сетка перекрывается прямоугольником R. Это оказывается не- обходимым потому, что если бы сканирующая линия достигла бы правой границы прямоугольника R чуть раньше, чем кончилась бы сетка, то со- стояние алгоритма должно оказаться в сильной зависимости от структуры сетки. Хотя в нашем примере сканирующая линия сначала проходит всю сетку полностью и только после этого выходит за пределы прямоугольника R, но знать заранее этой ситуации алгоритм не может. 7.4, Декомпозиция полигонов на монотонные части В этом разделе представим алгоритм для декомпозиции произвольного полигона на монотонные подполигоны, этот процесс иногда называют регу- ляризацией. Напомним из раздела 5.8, что полигон называется монотонным, если его граница состоит из монотонных верхней и нижней цепочек. Ал- горитм использует сканирование на плоскости для регуляризации и-уголь- ника за время O(n log и). Используя этот алгоритм совместно с описанным в разделе 5.8 способом триангуляции монотонных полигонов, выполняемым за линейное время, мы получим алгоритм для триансуляции произвольного полигон за время O(n log и): сначала выполним декомпозицию заданного полигона на монотонные подполигоны, затем осуществим триангуляцию каждого из них по очереди. Для упрощения описания ниже в этом разделе будем предполагать, что никакие две вершины в полигоне не имеют одинаковой координаты х. Наш алгоритм основан на определении понятия излома. Вогнутая вер- шина — вершина, внутренний угол которой превышает 180 градусов — яв- ляется изломом, если обе ее соседние вершины лежат либо слева, либо спра- ва от нее (рис. 7.9). Легко видеть, что никакой монотонный полигон не может содержать излом. Это правило обратимо и выражается следующей теоремой, лежащей в основе алгоритма: Теорема 4. (Теорема о монотонности полигона): любой полигон, не содержащий излома, яв- ляется монотонным. Теорема доказывается от противного: если полигон Р немонотонный, тогда в нем есть излом. Предположим, что полигон Р немонотонный и его немонотонность является следствием того, что его верхняя цепочка не яв- ляется монотонной. Обозначим вершины вдоль верхней цепочки полигона Р как vi, V2,--., Vk и пусть Vj будет первой вершиной вдоль цепочки такой, что вершина Vi+i будет лежать слева от vt (рис. 7.10). Если вершина Vj+i лежит над ребром Vj-iVi, тогда V; будет изломом, так что мы предположим, что вершина V/+1 находится под Vj-iv;. Пусть Vj будет самой левой вершиной цепочки от Vi до Vk, прежде чем цепочка пересечет ребро vtvk. Вершина Vj является вогнутой, поскольку она локально самая левая и внутренность полигона лежит слева от нее, эта же вершина Vj является изломом, посколь- ку она вогнутая и обе соседние с ней вершины находятся справа от нее. Таким образом теорема доказана. 8 Заказ 2794
996 Глава 7 7.4.1. Программа верхнего уровня Наш алгоритм выполняется путем декомпозиции полигона Р на подполи- гоны без изломов. В первой из двух фаз по мере продвижения сканирующей линии по прямоугольнику R слева направо алгоритм удаляет изломы, на- правленные влево, формируя набор подполигонов Pi, Рг....Рт, ни один из которых не содержит изломов, направленных влево. Во время второй фазф алгоритм удаляет изломы, направленные вправо, продвигая сканирующую линию справа налево по подполигонам Pt по очереди. Полученная коллек- ция полигонов и будет декомпозицией исходного полигона Р на подполи- гоны, в которых нет ни право-, ни лево-направленных изломов и, следова- тельно, совсем никаких изломов. В программу regularize передается полигон р и она возвращает список монотонных полигонов, представляющих регуляризацию исходного полиго- на р. В процессе работы программы исходный полигон р разрушается: enum ( LEFT_TO_RIGHT, RIGHT_TO_LEFT); // слева—направо, справа-налево List<Polygon*> ‘regularize(Polygon &р) ( // фаза 1 List<Polygon*> *polysl = new List<Polygon‘>; semiregularize(p, LEFT_TO_RIGHT, polysl); // фаза 2 List<Polygon*> *polys2 = new List<Polygon‘>; polysl->last(); while (!polysl->isHead()) { Polygon *q = polysl->remove(); semiregularize(*q, RIGHT_TO_LEFT, polys2); ) return polys2; Рис. 7.9. Вершины a, b и с соответствуют лево-направленным изломам, а вершина d - право-направленному излому Рис. 7.10. Иллюстрация обязательного наличия излома в немонотонном полигоне
Алгоритмы сканирования на плоскости 997 В этой программе полигоны, создаваемые в первой фазе, накапливаются в списке polysl, а формируемые во второй фазе — в списке polys2. Основное назначение функции semiregularize заключается в исключении всех изломов, направленных в одну сторону. Например, при обращении semiregularize (р, LEFT_TO_RIGHT, polysl) выполняется сканирование полигона р слева направо и удаляются изломы, направленные влево, а полу- чающиеся подполигоны добавляются в список polysl. Каждый раз, когда ска- нирующая линия обнаруживает направленные влево изломы v, полигон разби- вается вдоль хорды, соединяющей v с некоторой вершиной w, расположенной слева от v. Чтобы отрезок прямой линии vw был действительно хордой (т. е. внутренней диагональю) полигона, выбор вершины w должен быть оправдан- ным. Если вершина v лежит между активными ребрами а и d в качестве вер- шины w выбирается самая правая из тех вершин, что лежат между ребрами а и d и расположены слева от сканирующей линии (рис. 7.11а). Выбор вершины w можно пояснить и другим способом: рассмотрим тра- пецоид, образованный сканирующей линией, ребром а, ребром d и прямой линией, параллельной сканирующей линии и проходящей через точку х. Здесь в качестве точки х можно выбрать левую концевую точку ребер а или d, лежащую правее другой. Вершина w должна быть самой правой вершиной, лежащей внутри этого трапецоида, но если внутри трапецоида нет других вершин, то такой вершиной w будет вершина х. Вершину w будем называть целевой вершиной пары ребер a-d (вертикально соседней пары ребер а и d). На рис. 7.116 показаны действия, предпринимаемые, когда сканирующая линия обнаруживает излом v: полигон расщепляется по хорде vw. Ни в одном из полученных новых полигонов вершина v уже не будет изломом — в каждом новом полигоне одна из соседних с v вершин лежит слева, дру- гая — справа. Конечно, такая ситуация обеспечивается при перемещении сканирующей линии слева направо: ни одна из вершин, лежащих слева от сканирующей линии, не может быть изломом, указывающим влево. Переход выполняется каждый раз как только сканирующая линия об- наруживает вершину полигона. Тип перехода зависит от типа вершины, если вершины классифицировать в соответствии с положением их двух со- седей относительно направления сканирования: • Начальная вершина', обе соседние вершины для вершины v распола- гаются после сканирующей линии, т. е. в направлении перемещения сканирующей линии. Рис. 7.11. Действия, выполняемые при обнаружении излома v, направленного влево
SS8 Глава 7 • Вершина перегиба: одна из соседних вершин находится сзади от ска- нирующей линии, другая — впереди. • Концевая вершина: обе соседние с о вершины расположены позади ска- нирующей линии. Три взаимосвязанных перехода — начальный переход, переход при пере- гибе и концевой переход — показаны на рис. 7.12, где предполагается, что сканирование осуществляется в направлении слева направо. Типы перехода определены в терминах направления сканирования, так что функция semiregularize может быть использована при сканировании в любом на- правлении. В обращении к функции semiregularize задается произвольный поли- гон р и указывается направление сканирования: LEFTTORIGHT для об- наружения изломов, указывающих влево, и RIGHTTOLEFT для обнару- жения изломов, указывающих вправо. Получающиеся в результате подполигоны добавляются в список polys. В функции используются три глобальные переменные: int sweepdirection; // текущее направление сканирования double curx; // текущая позиция сканирующей линии int curtype; 11 текущий тип перехода void semiregularize(Polygon &p, int direction, List<Polygon*> *polys) { sweepdirection = direction; int (*cmp)(Vertex*, Vertex*); if (sweepdirection==LEFT_TO_RIGHT) cmp = leftToRightCmp; else cmp = rightToLeftCmp; Vertex **schedule — buildSchedule(p, cmp); Dictionary<ActiveElement*> sweepline = buiIdSweepline(); for (int i = 0; i < p.sized; i++) { Vertex *v = schedule[i]; curx = v->x; switch (curtype = typeEvent(v, cmp)) { case START_TYPE: startTransition(v, sweepline); break; case BEND_TYPE: bendTransition(v, sweepline); Рис. 7.12. Три вида переноса, возникающие при перемещении сканирующей линии слева направо
Алгоритмы сканирования на плоскости 229 case END_TY₽E: endTransition(v, sweepline, polys); break; } } p.setV(NULL); } Точки-события возникают при обнаружении вершин полигона. Поскольку все они известны заранее, то перечень точек-событий записан в массиве вер- шин, предварительно отсортированном в порядке увеличения координаты х (поэтому нет необходимости в динамической пересортировке словаря). Перечень формируется функцией buildSchedule, которой передается одна из функций сравнения точек leftToRightCmp или rightToLeftCmp в за- висимости от направления сканирования: Vertex **buildSchedule(Polygon &р, int(*cmp)(Vertex*, Vertex*)) { Vertex **schedule = new (Vertex*)[p. size()]; for (int i = 0; i < p.sizeO; i++, p.advance(CLOCKWISE)) schedule[i] = p.v(); insertionSort (schedule, p.sizeO, cmp) ; return schedule; ) Чтобы определить, какой тип перехода должен быть соотнесен с данной вершиной, в функции semiregularize используется функция typeEvent. Для классификации вершины v функция typeEvent проверяет положение двух соседних с ней вершин относительно направления сканирования: int typeEvent(Vertex *v, int(*cmp)(Vertex*,Vertex*)) { int a = (*cmp)(v->cw(), v); int b = (*cmp)(v->ccw(), v); if ((a <= 0) && (b <= 0)) return END_TY₽E; else if ((a > 0) && (b > 0)) return START_TYPE; else return BEND_TYPE; ) 7.4.2. Структура сканирующей линии Структура сканирующей линии представляется в виде словаря активных ребер, т. е. тех ребер полигона, которые пересекают сканирующую линию в данный момент, упорядоченных по увеличению координаты у. Кроме того, структура сканирующей линии содержит точку с минимальной коор- динатой у (лежащую ниже всех ребер), служащую в качестве стража. Мы будем представлять структуру сканирующей линии в виде словаря объектов ActiveElement. class ActiveElement { public: int type; // ACTIVE_EDGE (ребро) или ACTIVE_₽OINT (точка) ActiveElement(int type); virtual double у(void) = 0;
S30 Глава 7 virtual Edge edge(void) { return Edge(); }; virtual double slope(void) { return 0.0; }; ); Конструктор ActiveElement инициализирует элемент данных type, ко- торый показывает, является ли элемент ребром или точкой: ActiveElement::ActiveElement(int t) type(t) ( ) Остальные компонентные функции класса ActiveElement являются вир- туальными и они будут обсуждены несколько позже, в контексте двух про- изводных классов. Активные ребра представляются в виде объектов класса ActiveEdge: class ActiveEdge : public ActiveElement { public: Vertex *v; Vertex *w; int rotation; ActiveEdge(Vertex *_v, int _r, Vertex *_w); Edge2 edge(void); double у(void); double slope(void); ); Элемент данных v указывает на одну из концевых точек текущего ребра, a v->cw () — на другой конец. Элемент данных w является целевой вер- шиной пары ребер, состоящей из текущего ребра и активного ребра, рас- положенного непосредственно над ним. Элемент данных rotation исполь- зуется для отслеживания связи от текущего ребра к ребру, которое может пересекать текущее ребро за сканирующей линией: если такое ребро сущест- вует, то v->neighbor (rotation) определяет окно на ребре. Конструктор ActiveEdge определяется непосредственно: ActiveEdge::ActiveEdge(Vertex *_v, int _r, Vertex *_w) ActiveElement(ACTIVE_EDGE), v(_y), rotation(_r), w(_w) { ) Компонентная функция у возвращает координату у точки пересечения те- кущего ребра со сканирующей линией. Компонентные функции edge и slope возвращают текущее ребро и наклон текущего ребра соответственно. Эти три функции определяются как: double ActiveEdge::у(void) { return edge().у(curx); ) Edge ActiveEdge::edge(void) (
Алгоритмы сканирования на плоскости «31 return Edge(v->point(), v->cw()>point()) ; } double ActiveEdge::slope(void) { return edge().slope(); } Структура сканирующей линии формируется так, чтобы она могла содер- жать точки наряду с ребрами, поскольку она должна обеспечивать операции определения положения точки в такой форме: для данной точки на ска- нирующей линии найти пару активных ребер, между которыми находится данная точка. Вот поэтому мы и определили класс ActiveElement так, чтобы получить из него производные классы, один для представления ребер и второй — для представления точек. Для целей поиска внутри структуры сканирующей линии мы будем представлять точку в виде объекта класса ActivePoint: class ActivePoint : public ActiveElement { public: Point p; ActivePoint (Points); double у(void); }; Конструктор класса определим как: ActivePoint: .-ActivePoint (Point &_p) ActiveElement(ACTIVE_POINT), p(_p) { } Компонентная функция у просто возвращает координату у текущей точки double ActivePoint::у(void) { return p.y; } Определив необходимые классы, перейдем к рассмотрению структуры сканирующей линии. Структура сканирующей линии создается с помощью функции buildSweepline. Функция инициализирует новый словарь и со- храняет активную точку, служащую в качестве стража — точку, располо- женную ниже любого ребра, которое будет записано в словарь впоследствии: Dictionary<ActiveElement*> SbuildSweepline() { Dictionary<ActiveElement*> *sweepline = new Dictionary<ActiveElement*> (activeElementCmp); sweepline->insert(new ActivePoint(Point(0.0, —DBL_MAX))); return *sweepline; }
S3S Глава 7 Функция сравнения activeElementCmp применяется для сравнения двух активных элементов: int activeElementCmp(ActiveElement *а, ActiveElement *b) { double ya = a—>y(); double yb = b->y(); if (ya < yb) return -1; else if (ya > yb) return 1; if ((a->type == ACTIVE_POINT) && (b->type == ACTIVE_POINT)) return 0; else if (a->type == ACTIVE_POINT) return -1; else if (b—>type == ACTIVE_POINT) return 1; int rval =1; if ((sweepdirection==LEFT_TO_RIGHT&&curtype==START_TYPE)|| (sweepdirection==RIGHT_TO_LEFT&&curtype==END_TYPE)) rval = -1; double ma = a->slope(); double mb = b->slope(); if (ma < mb) return rval; else if (ma > mb) return -rval; return 0; ) Функция activeElementCmp вначале сравнивает активные элементы а и b на основе координат у их точек пересечения со сканирующей линией. Если они пересекаются в одной и той же точке, то считается, что активная точка расположена под активным ребром. Если оба элемента а и & являются активными ребрами, то для определения, какое из них лежит ниже другого, используются их соответствующие параметры наклона. Если предположим, что сканирование осуществляется слева направо, то ребро с меньшим ко- эффициентом наклона расположено ниже другого, если точка-событие яв- ляется начальной вершиной, и выше другого, если точка-событие является конечной вершиной. Если предположим, что сканирование производится справа налево, то роли начальной и конечной вершин взаимно меняются местами (например, начальная вершина при сканировании слева направо становится конечной вершиной при сканировании в противоположном на- правлении). 7.4.3. Переходы Рассмотрим теперь, как обрабатывать переходы, начиная с первого (рис. 7.12а). Когда сканирующая линия достигает начальной вершины о, то мы должны сначала отыскать в структуре сканирующей линии два актив- ных ребра а и d, между которыми лежит вершина v. Если вершина v яв- ляется точкой встречи ребер & и с, то & и с заносятся в структуру скани- рующей линии и вершина о будет считаться целевой вершиной для пар ребер а-b, b-с и c-d. Кроме того, если вершина о является вогнутой (озна- чающее, что о является изломом), то полигон, которому принадлежит вер- шина о, расщепляется вдоль хорды, соединяющей вершины о и w. В ре- зультате получается функция startTransition:
Алгоритмы сканирования на плоскости S33 void startTransition(Vertex* v, Dictionary<ActiveElement*> &sweepline) { ActivePoint ve(v->point()); ActiveEdge *a » (ActiveEdge*)sweepline.locate(&ve); Vertex *w = a->w; if (!isConvex(v)) { Vertex *wp = v->split(w); sweepline.insert(new ActiveEdge(wp->cw(), CLOCKWISE,wp->cw())) ; sweepline.insert(new ActiveEdge(v->ccw(), COUNTER_CLOCKWISE,v)); a->w = (sweepdirection==LEFT_TO_RIGHT)?wp->ccw():v; } else ( sweepline.insert(new ActiveEdge(v->ccw(), COUNTER_CLOCKWISE,v)); sweepline.insert(new ActiveEdge(v, CLOCKWISE, v) ); a—>w = v; } } Разбиение полигона v в функции startTransition выполняется с по- мощью функции Vertex: : split, вместо Polygon: : split, поэтому нет не- обходимости знать, какому полигону принадлежит вершина v. По мере перемещения сканирующей линии в сторону вершины v в результате вы- полнения операции split формируются новые полигоны и вершина v может мигрировать из одного полигона в другой. Работая на уровне вершин, а не погигонов, мы можем избежать необходимости отслеживать, какому полигону принадлежит каждая вершина. При обращении к функции isConvex (v) возвращается значение TRUE, если вершина v выпуклая, или значение FALSE в противном случае (вер- шина v вогнутая). Функция определяется в виде: bool isConvex(Vertex *v) { Vertex *u = v->ccw(); Vertex *w = v->cw() ; int c = w->classify(*u, *v); return ((c == BEYOND) II (c == RIGHT)); ) Для обработки перехода для точки перегиба в вершине перегиба v сна- чала требуется найти в структуре сканирующей линии ребра а, b и с и при- нять вершину v в качестве целевой вершины для пар ребер а-b и Ь—с (рис. 7.126). Затем ребро b в структуре сканирующей линии заменяется на ребро b', которое встречается с ребром b в вершине и. Таким образом по- лучаем функцию bendTransition: void bendTransition(Vertex *v, Dictionary<ActiveElement*> &sweepline) { ActivePoint ve(v->point());
234 Глава 7 ActiveEdge *а = (ActiveEdge*)sweepline.locate(Sve); ActiveEdge *b = (ActiveEdge*)sweepline.next(); a->w = v; b->w = v; b->v = b->v->neighbor(b->rotation) ; ) Для обработки концевого перехода в концевой вершине v сначала в струк- туре сканирующей линии найдем активные ребра а, Ь, с и d (рис. 7.12в). С одной стороны, если вершина v выпуклая, тогда и должна быть самой край- ней правой вершиной полигона. Если это не так, то полигон, которому при- надлежит вершина v, должен был бы обладать изломом, указывающим влево и расположенным слева от v, что противоречило бы условию сканирования. Поскольку вершина v является самой правой вершиной в полигоне, то на этот раз полигон добавляется в список polys. С другой стороны, если вер- шина v вогнутая, то v становится целевой вершиной для пары ребер a-d, а ребра & и с удаляются из структуры сканирующей линии. Функцию endTransition получаем в следующем виде: void endTransition(Vertex *v, Dictionary<ActiveElement*> Sisweepline, List<Polygon*> *polys) { ActivePoint ve(v->point()); ActiveElement *a = sweepline.locate(Sve); ActiveEdge *b = (ActiveEdge*)sweepline.next(); ActiveEdge *c = (ActiveEdge*)sweepline.next(); if (isConvex(v)) polys->append(new Polygon(v)) ; else ((ActiveEdge*)a)->w = v; sweepline.remove(b); sweepline.remove(c); ) На рис. 7.13 показан результат регуляризации 25-угольника. При ска- нировании слева направо удаляются четыре излома, направленных влево, и один — направленный вправо (одна из операций расщепления split уда- ляет два излома одновременно). При последующем сканировании справа на- лево удаляются два оставшихся излома, направленных вправо, и получа- ются семь монотонных полигонов. Каждый из этих полигонов подвергается триангуляции с использованием алгоритма из раздела 5.8. Рис. 7.13. Результат разбиения 25-угольника на семь монотонных областей с их последующей триангуляцией
Алгоритмы сканирования на плоскости S35 7.4.4. Анализ Проведем анализ работы программы regularize для входного полигона, имеющего п вершин. По мере выполнения программы после ряда последова- тельных обращений к функции semiregularize общее число вершин уве- личивается на две вершины после каждого выполнения операции расщеп- ления split. Однако, поскольку не более, чем j вершин исходного полигона могут быть изломами, то может быть добавлено не более, чем 2(^) = п вершин, что дает в результате не более 2п вершин. Так как каждая вершина может возбудить не более двух переходов (по одному на каждое направление сканирования), то всего может потребоваться выполнить не более 4п переходов. Время выполнения перехода каждого типа определяется в основном не- большим постоянным числом операций со словарем, что занимает время O(log п). Поскольку всего выполняется О(п) переходов, то весь алгоритм вы- полняется за время О(п log п). 7.5. Замечания по главе Алгоритмы из раздела 7.1 для точек пересечения п отрезков прямых линий описан в [9]. Основа нашего алгоритма определения контура объединения п параллель- ных осям прямоугольников заимствована из [54]. Путем представления ска- нирующей линии с более сложной структурой поиска (с деревьями сегмен- тов), Липски и Препарата получили алгоритм, выполняемый за время О(п log п + г log(n2/r)), если контур состоит из г ребер. Дерево сегментов было использовано для представления «пустых» участков вдоль сканирую- щей линии, т. е. таких интервалов, которые не перекрываются ни одним из прямоугольников. (В нашей терминологии «пустые» участки определя- ются интервалом между активным горизонтальным ребром со значением счетчика 0 и другим ребром со значением счетчика 1). Время, затрачивае- мое на поиск внутри структуры сканирующей линии, будет зависеть от числа обнаруженных вертикальных ребер контура. Гютинг [37] впоследст- вии разработал оптимальный алгоритм, который выполняется за время O(r + п log п). Наш алгоритм регуляризации полигона взят из [52], а фор- мулировка и доказательство теоремы о монотонности полигона — из [31]. Хертел и Мелхорн [40] описали алгоритм сканирования на плоскости для триангуляции произвольного полигона за время О(п + г log г), где г равно числу вогнутых вершин в полигоне. Сканирующая линия перемещается слева направо по частям (а не целиком в виде единственной вертикальной линии) и останавливается не более, чем на О(г) вершинах. Из других спо- собов триангуляции полигонов стоит отметить способ Тарьяна и ванВика [84], который выполняется за время О(п log log п), та. недавно появившийся способ Хазелла [19], выполняемый за оптимальное время О(п). Обзор ме- тодов разбиения полигонов представлен в [60, 61].
S36 Глава 7 7.6. Упражнения 1. Измените программу поиска точек пересечения в коллекции отрезков прямых линий, чтобы она осталась корректной, даже если опустить предположение, что в одной точке могут пересекаться только два отрезка прямых линий. 2. Разработайте алгоритм сканирования на плоскости для вычисления пло- щади объединения коллекции прямоугольников, параллельных осям координат. 3. Используя нотацию «большого О», выразите пространственную слож- ность нашего алгоритма поиска пересечения отрезков прямых линий в функции от числа отрезков прямых линий и количества найденных пересечений. Какова была бы пространственная сложность, если функ- ция обработки переходов была модифицирована так, чтобы точка-со- бытие удалялась из перечня точек-событий только при необходимости ее обработки и не удалялась в противном случае. Изменится ли слож- ность времени выполнения алгоритма? 4. Разработайте алгоритм для поиска всех точек пересечения в наборе из п горизонтальных и вертикальных отрезков прямых линий. Алгоритм должен выполняться за время О(п log п + г) , где г — число сообщений о пересечениях. 5. Для двух заданных выпуклых полигонов Р и Q составьте алгоритм сканирования на плоскости для вычисления пересечения Р и Q за время, пропорциональное |Р| + |Q|. (Подсказка: границы полигонов Р и Q совместно могут служить в качестве перечня точек-событий. По мере обнаружения точек пересечения они могут служить для объеди- нения границ Р П Q ). 6. Для чего предназначен элемент Rectangle: :id в алгоритме поиска кон- тура? 7. Покажите, что задача поиска контура для п прямоугольников имеет нижнюю границу Q(n log и). (Подсказка: функция сортировки должна быть адаптирована к условиям задачи. Для ряда чисел xi, хг,..., хп, подлежащего сортировке, каждое число х/ соответствует прямоуголь- нику с параллельными осям сторонами с координатами углов (0, 0) и (х/,М), где М = тахДхД.) 8. Составьте алгоритм сканирования на плоскости для триангуляции про- извольного полигона, выполняемый за время О(п log и). (Подсказка: триангуляция полигона должна осуществляться в процессе его регу- ляризации.) 9. Для заданного полигона Р триангуляция набора вершин полигона Р яв- ляется триангуляцией вершин, которые считаются точками на плоскос- ти, причем ограничением здесь будет то, что каждое ребро Р должно принадлежать триангуляции. Составьте алгоритм сканирования на плос- кости, выполняемый за время О(п log и), для триангуляции набора вер- шин любого полигона. (Подсказка: модифицируйте алгоритм предыду- щего упражнения.)
Алгоритмы сканирования на плоскости 837 10. Для заданного набора п дисков переменного радиуса на плоскости со- ставьте алгоритм сканирования на плоскости для определения за время О(п log п) будут ли пересекаться любые два диска. 11. Говорят, что точка р - (рх,ру) доминирует над точкой q = (qx,qy), если рх > qx и ру > qy. Когда р принадлежит набору точек S, то точка р на- зывается максимальной, если ни одна из точек в S не доминирует над р. Составьте алгоритм сканирования на плоскости для сообщения о всех максимальных точках в наборе из п точек за время О(п log п).
Глава 8 Алгоритмы типа «разделяй и властвуй» При реализации подхода «разделяй и властвуй» задача делится на не- сколько подзадач, которые решаются рекурсивно и затем достигнутые ре- зультаты объединяются для получения решения исходной задачи. По своему виду подзадачи сходны с исходной задачей, но они меньше по размеру и, ес- тественно, сумма размеров подзадач равна размеру исходной задачи. Если за- дача достаточно мала или проста, она может быть решена непосредственно и нет необходимости ее дальнейшего деления — это условие является критери- ем для окончания процесса деления. В наиболее обычной форме при исполь- зовании принципа «разделяй и властвуй» задача делится на две подзадачи, каждая из которых по размеру вдвое меньше исходной задачи. Рассмотрим алгоритм «разделяй и властвуй» для нахождения минималь- ного значения в массиве целых чисел. Идея заключается в делении массива на левый и правый подмассивы и в рекурсивном применении к ним алго- ритма поиска минимального значения в каждом из подмассивов, после чего возвращается наименьшее из полученных двух значений. Базовая ситуация возникает при длине подмассива, равной единице — тогда единственное зна- чение, содержащееся в нем и возвращается в качестве решения. Алгоритм реализуется в функции fMin, которая возвращает наименьшее целое значе- ние в подмассиве а[1. .г]. Функция findMin является ведущей функцией, которая на верхнем уровне осуществляет обращение к функции fMin: int findMin(int a[], int n) ( return fMin(a, 0, n-1); } int fMin(int a[], int 1, int r) { if (1 == r) return a[l]; else { int m = (1 + r) / 2; int i = fMin(a, 1, m); int j = fMin(a, m+1, r); return (i < j) ? i : j; } В отношении числа выполняемых сравнений функция findMin также эф- фективна, как и простой просмотр массива слева направо с сохранением те-
Алгоритмы типа «разделяй и властвуй 939 кущего найденного наименьшего значения (при обоих способах выполняется п - 1 сравнений). Практически рекурсивный подход менее эффективен из-за возможного переполнения при рекурсивном обращении к функции, но он служит хорошим примером использования метода «разделяй и властвуй». Эту главу мы начнем с описания способа сортировки путем слияния — классического алгоритма сортировки методом «разделяй и властвуй». Затем представим способ формирования области пересечения коллекции полуплос- костей и применим этот способ к задачам определения ядра полигона и по- строения полигонов Вороного (определение которых будет дано ниже). Далее мы вернемся к задаче построения выпуклой оболочки для планарного на- бора точек. Затем применим метод «разделяй и властвуй» в эффективном алгоритме поиска пары ближайших точек для набора точек на плоскости. И, наконец, используем метод «разделяй и властвуй» для триангуляции произвольного полигона. 8.1. Сортировка путем слияния Сортировка путем слияния является единственным алгоритмом сортиров- ки, который выполняется за оптимальное время O(n log и) в наихудшем слу- чае. Заданный для сортировки массив элементов при сортировке путем сли- яния делится на левый и правый подмассивы примерно равного размера, рекурсивно сортируется каждый из двух подмассивов и затем оба только что отсортированных подмассива сливаются в один отсортированный массив (рис. 8.1). Поскольку отсортированные подмассивы могут быть объединены за линейное время, время работы определяется рекуррентным выражением Т(п) = 2Т (£) + ап, которое сводится к Т(п) G O(n log и). Рис. 8.1. Сортировка путем слияния Шаблон функции mSort выполняет сортировку путем слияния подмас- сивов а[1. .ш], используя функцию сравнения стр. Шаблон функции mer- geSort определяет ведущую функцию: template<class Т> void mergeSort(Т а[], int n, int (*cmp)(T,T)) ( mSort(а, 0, п-1, стр); )
940 Глава 8 template<class Т> void mSort(T a[], int 1, int r, int (*cmp)(T,T)) { if (1 < r) { int m = (1 + r) / 2; mSort(a, 1, m, cmp); mSort(a, m+1, r, cmp); merge(a, 1, m, r, cmp); ) ) При обращении к функции merge (а, 1, m, г, cmp) происходит сортировка путем слияния двух подмассивов а[1..ш] и а[т+1..г]. Рас- смотрим, как можно соединить два отсортированных массива а и Ь в тре- тий массив с достаточной длины. Для реализации этого будем поддержи- вать индекс aindx, который указывает на наименьший (самый левый) элемент в массиве а, еще не скопированный в массив с, и индекс bindx, определенный аналогично для массива Ь, а также индекс cindx, указы- вающий на следующую (самую левую) свободную позицию в массиве с. На каждом шаге будем копировать меньший из двух элементов a [aindx] и b [bindx] в с [cindx] и затем увеличивать на единицу cindx, а также либо aindx, либо bindx в зависимости от того, какой элемент был только что скопирован. Если aindx выходит за пределы самого правого элемента массива а, то оставшиеся элементы в массиве b (начиная с индек- са bindx) копируются в оставшиеся позиции массива с. Аналогично, если индекс bindx выходит за пределы самого правого элемента массива Ь, ос- тавшиеся элементы массива а копируются в массив с. Следующий шаблон функции осуществляет слияние отсортированных подмассивов х[1..т] и х[т+1..г] в отдельный массив с, который затем копируется обратно в х[1..г]: template<class Т> void merge(Т х[], int 1, int m, int r, int (*cmp)(T,T)) { T *a = x+1; T *b = x+m+1; T *c = new T[r-1+1]; int aindx = 0, bindx = 0, cindx = 0; int alim = m-1+1, blim = r-m; while ((ainChc < alim) && (bindx < blim)) if ((*cmp) (a[aindx], b[bindx]) < 0) c[cindx++] = a[aindx++]; else c[cindx++] = b[bindx++]; while (aindx < alim) // копирование остатка a c[cindx++] = a[aindx++]; while (bindx < blim) // копирование остатка b c[cindx++] = b[bindx++]; for (aindx=cindx=0; aindx <= r-1; a[aindx++] = c[cindx++]) ; // обратное копирование ) delete c;
Алгоритмы типа «разделяй и властвуй; 941 Сортировка путем слияния оказывается эффективной потому, что два отсортированных массива сливаются за время, пропорциональное сумме их размеров. Функция merge выполняется за время O(r - Z), поскольку каждый из г - Z + 1 элементов, подлежащих слиянию, пересылается только дважды: сначала в массив с и потом обратно в массив х. Если через Т(п) обозначить время, затрачиваемое на сортировку путем слияния массива из п элементов, то на выполнение программы потребуется время Т(п) = 2Т (|) + ап Ь если п > 1 если п - 1 при подходящих константах а и Ъ. Здесь элемент 2Т (j) представляет время рекурсивной сортировки двух подмассивов, а элемент ап — время на их последующее слияние. Таким образом мы имеем Т(п) G О(п log zi). 8.2. Вычисление области пересечения полуплоскостей Прямая линия делит плоскость на две полуплоскости, по одной с каждой стороны прямой линии. Полуплоскости являются самыми простейшими из выпуклых планарных областей, но путем выполнения операций пересечения и объединения соответствующим образом выбранных полуплоскостей из них может быть сформирован любой полигон. В этом разделе мы ограничимся задачей формирования области пересечения для коллекции полуплоскостей. Пусть Н будет коллекцией полуплоскостей и через 1(H) обозначим об- ласть их пересечения. Если она непуста, то область 1(H) называется выпук- лым политопом. Область 1(H) безусловно является выпуклой, поскольку пересечение выпуклых областей всегда выпуклое. На рис. 8.2 изображены два выпуклых политопа, образованных пересечением полуплоскостей. На рис. 8.2а это выпуклый полигон, тогда как на рис. 8.26 получился неог- раниченный выпуклый политоп. Полуплоскость в Н является лишней, если ее удаление не изменит 1(H). На рис. 8.2 лишняя только полуплоскость, определяемая прямой линией е. Один из способов формирования пересечения п полуплоскостей заключа- ется в их поочередной обработке. Самая непосредственная реализация та- Рис. 8.2. Два выпуклых политопа, образованных пересечением полуплоскостей 9 Заказ 2794
949 Глава 8 кого подхода будет выполняться за время О(п2). Но здесь с успехом можно применить способ «разделяй и властвуй», поскольку набор пересечений ас- социативен — область пересечения п полуплоскостей может быть получена вычислением пересечения пар полуплоскостей в любом порядке. Следова- тельно, при любом делении набора Н на два непустых поднабора Hi и Н2 будем иметь 1(H) = I(Hi) Г) 1(Н2)- Для вычисления 1(H) с использова- нием способа «разделяй и властвуй» мы будем разбивать коллекцию полу- плоскостей Н на два непустых поднабора Hi и Н2 примерно одинакового размера, затем рекурсивно вычислять I(Hi) и 1(Н2), после чего комбини- ровать их для получения I(Hi) Г) 1(Нг). В конечном случае (п = 1) будем просто возвращать чистую полуплоскость из Н. Возвратимся к реализации. Нам необходим способ представления границ выпуклых политопов, получаемых в процессе выполнения алгоритма, а также окончательного политопа 1(H). К сожалению, для этого мы не можем использовать объекты класса Polygon, поскольку границы неограниченных выпуклых политопов не замкнуты. Но вместо определения нового класса мы будем отсекать 1(H) по границам выпуклого ограничивающего прямоуголь- ника В. Тем самым будет обеспечено, что каждый политоп, выполняемый по мере выполнения алгоритма, будет ограничен и определен как пересе- чение некоторого выпуклого политопа и ограничивающего прямоугольни- ка В. В программе halfplanelntersect мы будем представлять полуплоскость в виде объектов класса Edge и считать, что полуплоскость лежит справа от ребра. Программе задается массив Н из п ребер и выпуклый ограничи- вающий прямоугольник box. Программа возвращает выпуклый полигон, равный пересечению прямоугольника box и п полуплоскостей в наборе Н: Polygon «halfplanelntersect(Edge Н[], int n, Polygon &box) { Polygon *c; if (n == 1) clipPolygonToEdge(box, H[0), c) ; else { int m = n / 2; Polygon *a = halfplaneZntersect(H, m, box) ; Polygon *b = halfplaneZntersect(H+m, n-m, box); c = convexPolygonlntersect(*a, *b); delete a; delete b; ) return c; ) В программе используются две функции, которые были определены ранее. Функция clipPolygonToEdge из раздела 5.7 применяется для вы- деления части ограничивающего прямоугольника, лежащего справа от ребра (для каждой полуплоскости). Функция convexPolygonlntersect из разде- ла 6.5 формирует область пересечения для двух выпуклых полигонов. Ограничивающий прямоугольник, который будет передаваться в програм- му halfplanelntersect, должен быть достаточно большим, чтобы не поте- рять нужную информацию. В идеале полуплоскости, образованные ребрами
Алгоритмы типа «разделяй и властвуй 943 ограничивающего прямоугольника, являются лишними, так что решение, возвращаемое программой, равно 1(H). Если это невозможно, т. е. когда пере- сечение 1(H) безгранично, ограничивающий прямоугольник должен охваты- вать все вершины, принадлежащие 1(H) и в этом случае будет отброшена только неинтересная часть 1(H). Ответственность за установку подходящих границ ограничивающего прямоугольника возлагается на прикладную про- грамму, которая вызывает функцию halfplanelntersect. 8.2.1. Анализ При размере входного массива, равном п, время работы Т(п) программы halfplanelntersect определяется рекуррентным выражением Т(п) = 2Т (|) + ап Ъ если п > 1 в противном случае (п = 1). Здесь элемент ап соответствует времени, которое затрачивается функцией convexPolygonlntersect для формирования области пересечения двух выпуклых полигонов. Отсюда следует, что программа halfplanelntersect выполняется за время О(п log п). Этот алгоритм оптимален, поскольку для подобных задач нижний предел составляет Q(n log п). Чтобы показать справедливость этого высказывания, выполним переход от задачи сортировки к рассмотренной задаче формиро- вания выпуклого полигона, что можно осуществить за линейное время. Для заданного ряда чисел xi, хг,..., хп, подлежащих сортировке, отобразим каж- дое число xt в точку с координатами (х/, х2), располагающуюся на параболе у = х2. Пусть Hi будет полуплоскость, ограниченная снизу прямой линией, касательной к параболе в точке (х/, х(2). Затем сформируем выпуклый полит- оп 1(H), равный пересечению полуплоскостей Hi,H2,...,Hn. Здесь ребра 1(H) упорядочены по их наклону, а точки их контакта с параболой (х,, х?) упо- рядочены вдоль оси абсцисс. В результате получим значения координат ребер xi в порядке обхода 1(H). 8.3. Определение ядра полигона Напомним из раздела 5.2, что ядро полигона определяется как множество точек, из которых видны все точки внутри полигона. Если оно непустое, то ядро полигона Р представляется в виде выпуклого полигона, лежащего внут- ри полигона Р, при этом говорят, что полигон Р является звездчатым (см. рис. 5.3). В этом разделе мы будем применять функцию halfplanelntersect в алгоритме построения ядра полигона, который выполняется за время О(п log п). Наш алгоритм основан на следующей теореме: Теорема 5. (Теорема о построении ядра). Предположим, что Р является n-угольником и Н — набор п полуплоскостей, определяемых ребрами Р. Тогда ядро для Р равно пере- сечению 1(H) этих полуплоскостей.
944 Глава 8 Здесь предполагается, что полуплоскость, задаваемая ребром, ограничена этим ребром и располагается справа от него. Теорема следует из такой цепи эквивалентных утверждений: (1) точка q принадлежит ядру Р, (2) точка q видит каждую точку полигона Р, (3) ни одно из ребер Р не нарушает видимости точки q, (4) точка q не может ле- жать слева от какого-либо ребра, (5) точка q принадлежит каждой полу- плоскости, определяемой ребром из набора Н, (6) точка q принадлежит 1(H) (см. рис. 8.3). Рис. 8.3. Ядро полигона Р равно пересечению полуплоскостей, определяемых ребрами Р Основываясь на этой теореме, мы можем сконструировать ядро полигона путем применения функции halfplanelntersect к ребрам Р. Любой ог- раничивающий прямоугольник, содержащий внутри себя полигон Р, будет достаточен, поскольку ядро располагается внутри Р. В приводимую ниже функцию kernel передается полигон р. Она возвращает полигон, представ- ляющий ядро полигона р, или пустой полигон, если р не является звезд- чатым полигоном: Polygon *kernel(Polygon fip) ( Edge *edges = new Edge[p.size()]; for (int i = 0; i < p.size(); i++, p.advance(CLOCKWISE)) edges[i] = p.edgeO; Polygon box; box.insert(Point(-DBL_MAX, -DBL_MAX)); box.insert(Point(-DBL_MAX, DBL_MAX)); box.insert(Point(DBL_MAX, DBL_MAX)); box.insert(Point(DBL_MAX, -DBL_MAX)); Polygon *r = halfplanelntersect(edges, p.size(), box) ; delete edges; return r; )
Алгоритмы типа «разделяй и властвуй; 245 8.3.1. Анализ Программа kernel вычисляет ядро n-угольника за время О(п log zi), это время в основном определяется обращением к функции halfplanelntersect. Интересно, что программа не оптимальна — ниже в разделе замечаний по главе упоминается оптимальный алгоритм определения ядра, который вы- полняется за время O(zi). Можно было бы ожидать, что существует ли- нейное сведение задачи поиска области пересечения полуплоскостей к за- даче определения ядра и таким образом трансформировать нижнюю границу оценки Q(zi log zi) предыдущей задачи к нашей последней задаче. Однако такого сведения не существует. Ребра полигона расположены не произвольно, поскольку они соединяются в вершинах. Оказывается, что легче сформировать область пересечения полуплоскостей, определяемых ребрами полигона, чем вычислять пересечение произвольной коллекции полуплоскостей. 8.4. Определение многоугольника Вороного Пусть S будет конечным набором точек на плоскости и есть еще точка р, не принадлежащая S. Многоугольник Вороного, соответствующий точке р относительно множества S, обозначается как k%s(p) , он состоит из ло- кусов точек на плоскости, которые расположены ближе к точке р, чем к любой другой точке из множества S. Для каждой точки q G S локус точек, лежащих ближе к р, чем к q, равен полуплоскости, которая ограничена пер- пендикуляром, проведенным через середину отрезка прямой линии pq, и ко- торая содержит точку р. Многоугольник Вороного k%s(p) равен пересечению всех таких полуплоскостей, образованных точками q множества S (см. рис. 8.4а). Этот факт можно зафиксировать в виде теоремы: Теорема 6. (Теорема о многоугольнике Вороного). Для двух заданных различных точек р и q /многоугольник ЮТ(Ч)(р) равен полуплоскости, ограниченной перпендикуляром к середине отрезка pq и расположенной со стороны от перпендикуляра, в кото- рой находится точка р. Более того, для заданных непересекающихся наборов точек А и В и точки р £ A U В будем иметь к^лив (р) = кЯд(р) Л И%в(р). Рис. 8.4. (а)-многоугольник Вороного,- (б)-диаграмма Вороного,- (в)— та же диаграмма Вороного и двойственная ей триангуляция Делоне
946 Глава 8 Первое утверждение теоремы следует из того факта, что все точки пер- пендикуляра к середине отрезка pq находятся на равном расстоянии от точек р и q. Что же касается второго утверждения теоремы, то нетрудно видеть, что ЮТлив (Р) с №а(р) П ^в(р)- Для доказательства К%а(р) Г) К%в(р) С k%AUB (р) предположим, что точка s принадлежит К%а(р) П ^в(р). Тогда точка s будет ближе к р, чем к любой точке из множества А, а также ближе к р, чем к любой точке из множества В. Следовательно, точка s должна быть ближе к р, чем к любой точке из A U В, т. е. s G K%aub (р). Как следствие этой теоремы мы можем вычислить k%s(p) путем образо- вания пересечения полуплоскостей И%{9}(р) для всех точек q из множества S. Полуплоскость К%{д}(р) ограничена перпендикуляром к середине отрезка прямой линии pq, определяемым ребром Edge (р, q) . rot (). Интересующая нас полуплоскость находится справа от этого ребра. Программе voronoiRegion задается точка р, массив s из п точек и ог- раничивающий прямоугольник box. Программа возвращает многоугольник Вороного для точки р относительно набора точек s: Polygon *voronoiRegion(Point ip, Point s[], int n, Polygon ibox) { Edge *edges = new Edgefn]; for (int i = 0; i < n; i++) ( edges[i] = Edge(p, s[i]); edges[i].rot(); } Polygon *r = halfplanelntersect(edges, n, box); delete edges; return r; ) Конечно, в случае неограниченного многоугольника Вороного его части, выходящие за пределы ограничивающего прямоугольника, будут отсечены, это делается обязательно. В идеале ограничивающий прямоугольник должен охватывать все вершины многоугольника Вороного, так что будут отсечены только неинтересные части. Однако, для заданной фиксированной точки р совсем несложно сформировать такой набор точек S, что вершины ffls(p) окажутся произвольно далеко от нее. 8.4.1. Диаграммы Вороного Для заданного набора точек S диаграмма Вороного, обозначаемая как KZ>(S) состоит из коллекции многоугольников Вороного для каждой из точек набора S относительно остальных точек из этого набора. Т. е. многоуголь- ники Вороного в KZ)(S) определяются в форме VRs-{p}(P) для каждой точки р в наборе S. Диаграмма Вороного (O(S) разбивает всю плоскость на вы- пуклые политопы (рис. 8.46 и 8.5). Можно привести очень интересную аналогию из области кристаллогра- фии. Предположим, что точки представляются в виде ядер зерен кристалла, которые растут с одинаковой постоянной скоростью во всех направлениях. Предположим также, что рост зерен кристалла продолжается до тех поря- док, пока два или более зерен не встретятся. Через некоторое достаточное
Алгоритмы типа «разделяй и властвуй 947 Рис. 8.5. Диаграмма Вороного для набора из 80 точек время каждое выросшее зерно будет представлено в виде многоугольника Вороного для своего ядра (вполне очевидно, что рост крайних неограничен- ных областей будет продолжаться бесконечно). В результате будет получена диаграмма Вороного для набора S. (Весьма забавно представить более бы- стрый процесс роста в одном из направлений и при расположении ядер в фиксированных точках в пространстве. Полученная в результате диаграмма Вороного в трехмерном пространстве будет состоять из ограниченных и не- ограниченных полиэдральных областей.) Для краткости мы будем писать КЯ(р) вместо VRs-{p}(p)- С целью упро- щения рассуждений будем предполагать, что никакие четыре точки исход- ного множества S не лежат на одной окружности. (Напомним из раздела 6.6, что набор точек считается принадлежащим окружности, если все точки лежат на этой окружности, и если такая окружность существует и она уни- кальна, то она называется описанной окружностью для этих точек.) Ребро диаграммы Вороного KZ>(S), лежащее между двумя многоугольни- ками Вороного КЯ(р) и ЮТ(д), состоит из точек на плоскости, эквидистантных точкам р и q, и расположено ближе к этим точкам, чем к остальным точкам из набора S. Каждая вершина v диаграммы Вороного K3(S) является точкой встречи трех многоугольников Вороного КЯ(р), ЮТ(д) и №(г). Будучи экви- дистантной от трех точек р, q и г, вершина v является центром описанной окружности для этих трех точек. Более того, поскольку точка v является ближайшей к точкам р, q и г, чем к остальным точкам из набора S, то внутри этой описанной окружности нет никаких других точек из заданного набора. Из этого следует, что треугольник kpqr является треугольником Де- лоне для набора S. (Триангуляция Делоне была описана в разделе 6.6.) Таким образом, каждой вершине диаграммы Вороного соответствует тре- угольник триангуляции Делоне, а каждому многоугольнику Вороного соот- ветствует вершина триангуляции Делоне (точки из набора S). Говорят, что диаграмма Вороного и триангуляция Делоне двойственны друг к другу. Это показано на рис. 8.4в, где две схемы наложены друг на друга. Программа voronoiDiagram формирует диаграмму Вороного для массива s из п точек. Она возвращает список многоугольников Вороного, из которых состоит диаграмма.
948 Глава 8 List<Polygon*> *voronoiDiagram(Point s[], int n, Polygon &box) { List<Polygon*> *regions = new List<Polygon*>; for (int i = 0; i < n; i++) { Point p = s [i] ; s [i] = s [n-1] ; regions—>append(voronoiRegion(p, s, n-1, box)); s[i] = p; } return regions; } 8.4.2. Анализ Затраты времени на выполнение программы voronoiRegion в основном оп- ределяются обращениями к функции halfplanelntersect, так что она выполняется за время О(п log и). Конечно, это оптимальный результат. Чтобы убедиться, что задача определения многоугольников Вороного имеет нижний предел Q(n log и), рассмотрим следующую линейную по времени редукцию задачи сортировки. Пусть для сортировки задана последовательность чисел xi, Х2,..., хп, отобразим каждое из этих чисел Xi на точки p(xi), расположенные на окружности так, чтобы точки р(х/) оказались упорядоченными вдоль окруж- ности в соответствии с возрастанием х/. Затем построим многоугольник Вороного для точки центра окружности относительно этих п точек. Многоугольник Во- роного в данном случае будет представлен в виде выпуклого n-угольника, каж- дое ребро которого расположено между центром окружности и одной из точек р(х/) на окружности. Теперь можно составить список точек р(х/) в порядке рас- положения ребер вокруг многоугольника Вороного. Хотя для вычисления многоугольника Вороного по набору S из п точек потребуется время Q(n log и), но в пределах тех же самых временных гра- ниц можно выполнить гораздо больше. Так в замечаниях по главе будет упомянут алгоритм, позволяющий полностью определить диаграмму Воро- ного для S за время О(п log и). Это позволяет улучшить время выполнения программы voronoiDiagram в п раз. 8.5. Слияние оболочек В этом разделе мы представим алгоритм «разделяй и властвуй» для вы- числения выпуклой оболочки CH (S) для набора точек S. Идея заключается в разделении массива S воображаемой вертикальной прямой линией на два равных по размеру поднабора Slj и Sr, затем рекурсивно сформировать из них CH (Sl) к CH (Sr) и затем объединить их для образования CH (S). В ко- нечном счете набор будет состоять из одной точки (|S| = 1) и будет просто возвращаться одноугольник, единственная вершина которого принадлежит набору S. Эта точка является собственной выпуклой оболочкой. Алгоритм асимптотически эффективен, поскольку выпуклые оболочки CH (Sl) и. CH (Sr) могут быть эффективно объединены за время O(|S|). Для выполнения слияния нужно удалить правую цепочку CH (Sl) и левую цепоч- ку CH (Sr) и заменить их на верхний мостик м. нижний мостик (рис. 8.6). Такие мостики обязательно существуют, поскольку CH (Sl) и CH (Sr) разъ-
Алгоритмы типа «разделяй и властвуй' 949 единены — они лежат по разные стороны воображаемой прямой линии. По- добное слияние представляет самую сложную задачу в алгоритме и мы об- судим ее несколько ниже. 8.5.1. Программа верхнего уровня Программе mergeHull передается массив pts из п точек и она возвра- щает полигон, представляющий выпуклую оболочку для этих точек: Polygon *mergeHull(Point pts[], int n) { Point **p = new (Point*)[n]; for (int i = 0; i < n; i++) p[i] = &pts[i]; msrgeSort(p, n, leftToRightCmp); return mHull(p, n); ) Программа mergeHull осуществляет предварительную сортировку точек слева направо, чтобы впоследствии наборы точек можно было бы быстро разделить на правый и левый поднаборы. В функции mHull реализуется рекурсивная часть алгоритма: Polygon *mHull(Point *р[], int n) { if (n == 1) { Polygon *q = new Polygon; q->insert(*p[0]); return q; } else { int m = n / 2; Polygon *L = mHull(p, m); Polygon *R = mHull(p+m, n-m); return merge(L, R); ) ) В конечной ситуации (n==l) функция mHull возвращает одноугольник. В общем случае функция делит набор точек на левый и правый поднаборы
950 Глава 8 точек p[0..m-l] и p[m..n-l], затем формирует для них выпуклые обо- лочки L и R и выполняет слияние полученных двух оболочек. 8.5.2. Слияние двух выпуклых оболочек Обращение к функции merge(L, R), которая объединяет две выпуклые оболочки L и R, зависит от способа записи мостика. Напомним из раздела 5.3 определение касательной прямой линии: прямая линия £ будет каса- тельной прямой линией выпуклого полигона Р, если прямая линия £ про- ходит через вершину полигона Р и. внутренняя часть полигона Р целиком располагается по одну сторону прямой линии £. Прямая линия £ будет мос- тиком для выпуклых полигонов Р и Q, если £ является касательной прямой линией для обоих Р и Q. Прямая линия £ будет верхним мостиком, если оба полигона расположены ниже прямой линии £, или нижним мостиком — если они оба расположены над прямой линией £. Для слияния полигонов L n R нам нужно найти верхний мостик, со- единяющий некоторую вершину h 6 L с некоторой вершиной ri 6 R, а также нижний мостик, соединяющий вершины 6 и гг 6 й. Вершины ll и 12 делят границы полигона L на левую и правую цепочки и, анало- гично, вершины ri и Г2 делят границы полигона R на левую и правую це- почки. Для слияния L n R в CH (S) нам потребуется заменить правую це- почку полигона L и левую цепочку полигона R на верхний и нижний мостики. Функция merge определяет общую выпуклую оболочку для выпуклых полигонов L и R и возвращает ее по завершении работы функции. Пред- полагается, что полигон L расположен слева от полигона R. Параметры UPPER и LOWER имеют тип перечисления: Polygon *merge(Polygon *L, Polygon *R) { Vertex *11, *rl, *12, *r2; Vertex *vl = leastvertex(*L, rightToLeftCmp); Vertex *vr = leastvertex(*R, leftToRightCmp); bridge(L, R, 11, rl, UPPER); L->setV(vl); R->setV(vr); bridge(L, R, 12, r2, LOWER); L->setV(ll); L->split(rl); R->setV(r2); delete R—>split(12); return R; ) При обращениях к функции leastvertex находятся самая правая вер- шина полигона L и самая левая вершина полигона R. Эта функция была определена в разделе 4.3.6. Две операции split в функции merge осуществляют замену правой це- почки полигона L и левой цепочки полигона R на верхний и нижний мос- тики. Как это делается, показано на рис. 8.7.
Алгоритмы типа «разделяй и властвуй; 951 Рис. 8.7. Слияние выпуклых оболочек L и R с помощью двух операций split Посмотрим, как функция bridge находит верхний мостик полигонов L и. R. Функция находит некоторую касательную прямую линию для полигона L, затем для полигона R и. последовательно их проверяет до тех пор, пока не будет найдена одна общая касательная прямая линия, т. е. верхний мос- тик (рис. 8.8а). Для этого функция bridge сначала размещает окно vl на самой правой вершине полигона L и. окно vr на самой левой вершине поли- Рис. 8.8. Последовательность поиска верхнего и нижнего /иостиков для двух выпуклых полигонов гона R. Затем она итеративно выполняет следующую группу операций до тех пор, пока нельзя будет переместить ни окно vl, ни окно vr: 1. Найти такую касательную прямую линию Дх к S, которая проходит через вершину vl и лежит выше полигона S. 2. Установить окно vr на вершине полигона S, которой касается пря- мая £]_. 3. Найти такую касательную прямую линию £2 к полигону L, которая располагается выше полигона L и проходит через вершину vr. 4. Установить окно vl на вершину полигона L, которой касается пря- мая £2. Нижний мостик для полигонов Ди/? находится аналогично, только с заменой параметра выше на ниже. На рис. 8.8 показана последовательность определения касательных прямых линий в процессе поиска верхнего и ниж- него мостиков. Функция bridge находит мостик между выпуклыми полигонами Ди/?. Вершины, в которых мостик касается полигонов Ди/?, возвращаются через ссылочные параметры vl и vr соответственно. Параметр type указывает на тип искомого мостика — верхнего UPPER или нижнего LOWER. В функции предполагается, что полигон Д находится слева от полигона R и. что окно vl
959 Глава 8 первоначально расположено на некоторой вершине правой цепочки полиго- на L, а окно vr — на некоторой вершине левой цепочки полигона R: void bridge(Polygon *L, Polygon *R, Vertex* &vl, Vertex* &vr, int type) { int sides[2] = { LEFT, RIGHT }; int indx = (type == UPPER) ? 0 : 1; do ( vl = L->v() ; vr = R->v() ; supportingLine(L->point(), R, sides[indx]); supportingLine(R->point(), L, sides[1-indx]); } while ((vl != L->v()) II (vr != R->v())); ) Функция supportingLine была определена в разделе 5.3 как часть ал- горитма ввода оболочки. 8.5.3. Анализ Нетрудно видеть, что полигоны L и R сливаются за время, пропорцио- нальное |L| + |В|. Чтобы найти каждый мостик, функция bridge попере- менно проверяет каждую вершину в полигонах L и R. Если вершина про- смотрена, то снова к ней не возвращаются. Следовательно, каждый из двух мостиков находится за время О (|L| + |Я|), так что оба обращения из функ- ции merge к функции bridge занимают линейное время. Более того, функ- ция merge затрачивает линейное время на обращение к функции leastvertex и постоянное время на свои другие операции. Это означает, что функция merge выполняется за линейное время. Из сказанного следует, что обращение на верхнем уровне к функции mHull выполняется за время Т(п), где Т(п) = 2Т (f) + ап Ь если п > 1 в противном случае (и = 1) В результате получаем решение T’(n) G O(n log и). На параметры алгоритма не влияет начальная сортировка точек, посколь- ку она также занимает время О(п log л). Следовательно, в худшем случае программа слияния оболочек выполняется за время O(n log и). 8.6. Ближайшие точки В этом разделе мы рассмотрим задачу поиска ближайших пар точек: для заданного набора 8 из п точек найти такие две точки, расстояние между которыми меньше (или равно) расстояния в любой другой паре точек. Такие две точки будем называть ближайшей парой в наборе S, а расстояние между ними — расстоянием ближайшей пары. Если п = 1, то расстояние бли- жайшей пары в наборе S равно бесконечности. Самым грубым решением этой задачи является непосредственное вычис- ление расстояний для каждой пары точек, отслеживая значение минималь-
Алгоритмы типа «разделяй и властвуй' 953 ного расстояния (для краткости под термином расстояние будем понимать «расстояние для некоторой пары точек»). Поскольку существует - пар, то поиск решения при таком подходе займет время 0(п2) . Здесь мы пред- ставим решение, основанное на методе «разделяй и властвуй», которое вы- полняется за время O(n log и). В общем случае наш алгоритм работает следующим образом. При задан- ном наборе точек S размера |S| > 1 мы будем делить S воображаемой вер- тикальной прямой линией I на такие два набора Sl и Sr примерно оди- накового размера, что все точки в Sl лежат слева от прямой линии I, а все точки в Sr — справа от нее. Затем алгоритм рекурсивно применяется к обоим наборам, чтобы найти ближайшую пару в Sl и ближайшую пару в Sr. Может оказаться, что либо одна из этих пар является ближайшей парой для всего набора S, либо ближайшую пару будут образовывать раз- несенные точки, одна из которых принадлежит Sl, а другая — Sr. Воз- никшая ситуация анализируется с помощью операции слияния. Пусть 5l и 8r будут означать расстояния ближайших пар в поднаборах Sl и Sr соответственно, обозначим также 5 = min (8l, 8r). Если каждая бли- жайшая пара в S состоит из точек, разнесенных между Sl и Sr, тогда рас- стояние в каждой такой паре точек должно быть обязательно меньше, чем 5. Поэтому мы должны ограничиться рассмотрением только тех точек, ко- торые лежат внутри вертикальной полосы шириной 28, расположенной сим- метрично вдоль разделяющей прямой I. Если какая-либо точка из Sl лежит вне этой полосы (а именно левее ее), то это означает, что она находится по крайней мере на расстоянии 8 от прямой линии I, т. е. не ближе этого расстояния от любой точки из Sr. Аналогично не потребуется рассматри- вать точки из Sr, также лежащие вне этой полосы (рис. 8.9а). Для обработки точек в пределах полосы мы будем хранить текущее зна- чение расстояния ближайшей пары D, вначале установленное равным 8. Значение D будет изменяться каждый раз, когда внутри полосы будет об- наружена пара точек, расстояние между которыми точно меньше D. Инте- ресно, что совсем нет необходимости вычислять расстояние для каждой пары точек в полосе. Так как, если расстояние по вертикали между двумя точками превышает D, то и фактическое расстояние между ними будет больше D. Переобозначим точки в полосе в порядке возрастания координаты у в виде ряда pi, Р2,..., рт- Для каждой точки pt будем вычислять расстояние от текущей точки pt до каждой pj для j - i + 1, i + 2, . . k пока не будет достигнут конец последовательности (т. е. k = т), либо координаты у для точек р£- и pk+i будут отличаться на D или более. Т. е. будут анализироваться только те пары точек, для которых расстояние по вертикали меньше, чем D. 8.6.1. Программа верхнего уровня Программе closestPoints передается массив s из п точек. Она возвра- щает расстояние для ближайшей пары точек из массива s, а также неко- торую ближайшую пару через ссылочный параметр с: double closestPoints(Point s[], int n, Edge &c) { Point **x = new (Point*)[n];
954 Глава 8 Рис. 8.9. (а) — Полоса шириной 28, где 8 = min (8i, 8r). (б) — В полосу 28 х 8, могут попасть не более восьми точек из массива S (здесь две точки — одна из St, другая из Sk- могут совпадать в точке а, две другие могут совпадать с точкой Ь) Point **у = new (Point*)[n]; for (int i = 0; i < n; i++) x[i] = y[i] = &s[i]; mergeSort(x, n, leftToRightCmp); mergeSort(y, n, bottomToTopCmp); return cPoints(x, y, n, c); ) Программа closestPoints осуществляет предварительную сортировку точек в соответствии с их координатами по оси х для возможности разде- ления на поднаборы точек и в соответствии с их координатами по оси у для обработки точек в пределах вертикальной полосы. Сортировка по уве- личению координаты у выполняется с использованием следующей функции сравнения: int bottomToTopCmg(Point *а, Point *b) { if ((a->y < b->y) || ((a->y — b->y) && (a->x < b->x))) return -1; else if ((a->y > b->y) ||((a->y == b->y) && (a->x > b->x))) return 1; return 0; ) В функцию ePoints передается массив x из n точек, отсортированных по координате х, и массив у тех же самых точек, отсортированных по коор- динате у. Функция возвращает расстояние для ближайшей пары из п точек, а также через ссылочный параметр с возвращает некоторую ближайшую пару: double ePoints(Point *х[], Point *у[], int n, Edge &c) { if (n == 1) return DBL_MAX; else ( int m = n / 2;
Алгоритмы типа «разделяй и властвуй 955 Point **yL = new (Point*)[m]; Point **yR = new (Point*)[n-m]; splitY (y, n, x[m], yL, yR); Edge a, b; double deltaL = cPoints(x, yL, m, a); double deltaR = ePoints(x+m, yR, n-m, b); delete yL; delete yR; double delta; if (deltaL < deltaR) { delta = deltaL; c = a; } else { delta = deltaR; c = b; ) return checkstrip(y, n, x[m] , delta, c); Вертикальная разделительная прямая линия I проходит через точку х [т], медиану набора точек относительно их координат по оси х. Функция splitY делит набор точек относительно вертикальной прямой линии I в ка- честве подготовки к двум рекурсивным обращениям к функции ePoints. 8.6.2. Обработка точек в пределах вертикальной полосы Функция splitY делит массив у на два массива yL и yR. Точка р яв- ляется делящей точкой: после выполнения всех операций массив yL будет содержать те точки из массива у, которые меньше р (иначе говоря, лежа- щие слева от р), отсортированные по их координатам у, а массив yR будет содержать точки, которые больше или равны р, также отсортированные по координате у. Функция выполняет действия, обратные операции слияния при сортировке методом слияния: void splitY(Point *у[], int n, Point *p, Point *yL[], Point *yR[J) { int i, lindx, rindx; i = lindx = rindx = 0; while (i < n) if (*y[i] < *p) yL[lindx++] = y[i++]; else yR[rindx++] = y[i++]; ) Функция checkstrip осуществляет проверку тех точек массива у, ко- торые лежат внутри полосы шириной 2*delta, симметричной относительно вертикальной прямой линии, проходящей через точку р. Если она находит, что наилучшая пара внутри полосы имеет расстояние между точками мень- ше, чем delta, то функция возвращает это значение, а также найденную пару через ссылочный параметр с. В противном случае функция возвращает только значение delta, без изменения с:
256 Глава 8 double checkstrip(Point *y[], int n, Point *p, double delta, Edge &c) { int i, striplen; Point *s = new Point[n]; for (i = striplen =0; i < n; i++) if ((p->x delta < y[i]->x) && (y[i]->x < p->x + delta)) s[striplen++] = *y[i]; for (i = 0; i < striplen; i++) for (int j = i+1; j < striplen; j++) { if (s[j].y s[i].y > delta) break; if ((s[i] s[j]).length() < delta) { delta = (s[i] s[j]).length(); c = Edge(s[i], s[j]); ) ) delete s; return delta; ) 8.6.3. Анализ Мы здесь покажем, что функция ePoints выполняется за время O(n log п) при задании п точек. Поскольку этап предварительной сортиров- ки программой closestPoints также занимает время O(n log и), то из этого следует, что программа целиком выполняется за время O(n log и). Время работы Т’(п) функции с Points определяется известным рекуррент- ным соотношением T’(n) = 271 (у) + ап, следовательно Т(п) G O(n log и). Вы- ражение ап представляет собой время, необходимое для слияния результата, которое меньше, чем затраты на обращение к функциям splitY и checkstrip. Ясно, что функция splitY выполняется за линейное время. Рассмотрим функцию checkstrip. Тело этой функции состоит из двух последовательных циклов for. Первый цикл for накапливает в массиве s точки, которые лежат внутри полосы, и очевидно, что он выполняется за линейное время О(п). Внешний второй цикл for выполняет одну итерацию для каждой точки в полосе, всего не более п итераций. А сколько времени тратится на выполнение внутреннего цикла for? Рассмотрим полосу в ок- рестности точки Si, а именно полоску размером 28 х 8, простирающуюся от si.у вверх до Si-y + 8 (рис. 8.96). Эта полоска делится вертикальной прямой линией I на левый квадрат Bl и правый квадрат Br, каждый из них с размером 8x8. Поскольку никакие две точки слева от прямой линии I не могут быть ближе друг к другу, чем 8, то квадрат Bl может содержать не более четырех точек из массива S. Аналогично, квадрат Br не может со- держать более четырех точек из массива S. Следовательно, вся полоска может содержать не более восьми точек из массива S, одна из которых яв- ляется самой точкой а/. Поскольку в пределах расстояния по вертикали, равном 8, над точкой а/ может находиться не более семи точек, число ите- раций во внутреннем цикле ограничено сверху константой. Из этого следует, что второй внешний цикл for выполняется за линейное время, это же ут- верждение относится и ко всей функции checkstrip.
Алгоритмы типа «разделяй и властвуй' 957 В общем алгоритм целиком выполняет основную часть своей деятельнос- ти в окрестности разделяющей прямой линии I. При первом рекурсивном обращении до анализа пар точек вблизи разделяющей прямой линии сна- чала вычисляется верхняя граница б расстояния в ближайшей паре. Это значение 5 затем используется для ограничения сверху О(п) числа пар, рас- положенных вблизи вертикальной прямой линии I, для которых должно вычисляться расстояние. 8.7. Триангуляция полигона В этом разделе используем метод «разделяй и властвуй» для триангуля- ции произвольного полигона Р. Идея заключается в расщеплении полигона Р вдоль некоторой хорды и затем в рекурсивной триангуляции получивших- ся двух подполигонов. В конце возникает ситуация, когда подлежащий три- ангуляции полигон является треугольником. Алгоритм менее эффективен, чем способ, описанный в разделе 7.4, использующий расщепление полигона на монотонные части и их поочередной триангуляции, но его гораздо проще запрограммировать. Алгоритм основан на непосредственном доказательстве следующей теоремы: Теорема 7. (Теорема о триангуляции полигона): n-угольник может быть разбит на п - 2 тре- угольника проведением п - 3 хорд. Почувствовать справедливость этой теоремы можно путем выполнения триангуляции для некоторого придуманного полигона. Наиболее просто можно осуществить триангуляцию выпуклого полигона. Нужно выбрать одну из п вершин полигона и соединить ее с каждой из п - 3 несоседних вершин, получив п - 2 треугольников. Другой способ триангуляции выпук- лого полигона основан на том факте, что каждая диагональ выпуклого поли- гона является его хордой: необходимо расщепить полигон вдоль любой диа- гонали и затем рекурсивно выполнить триангуляцию полученных двух выпуклых полигонов. Триангуляция невыпуклых полигонов не так проста. Действительно, ут- верждение, что можно осуществить триангуляцию любого полигона, не так очевидна, если учитывать вогнутые полигоны. Мы будем доказывать тео- рему о триангуляции как для того, чтобы убедиться в справедливости тео- ремы, так и для мотивации разработки алгоритма триангуляции. Доказательство теоремы ведется на основе индукции числа п, количества сторон полигона. Теорема тривиально справедлива для п = 3, что является основой для индукции. Так что пусть Р будет полигон с числом сторон п > 4 и предположим (в качестве гипотезы индукции), что условия теоремы удовлетворяются для всех полигонов с числом сторон меньше, чем п. Будем искать хорду полигона Р. Пусть Ь будет некоторая выпуклая вершина и пусть а и Ь будут ее со- седи. Могут возникнуть две ситуации (рис. 8.10). В первом случае диагональ ас является хордой Р. Пусть Р' будет n-1-сторонний полигон, получающий- ся в результате отсечения треугольника Дабе. По гипотезе индукции суще- ствует набор из п - 4 хорд, которые делят полигон на п - 3 треугольников.
958 Глава 8 Рис. 8.10. Две ситуации, рассматриваемые при доказательстве теоремы о триангуляции Добавляя к ним хорду ас и треугольник ДаЬс, мы получим набор из п - 2 хорд, которые делят исходный полигон Р на п - 2 треугольника. Во втором случае диагональ ас не является хордой Р, полагая, что внутрь тре- угольника ДаЬс должна попасть по крайней мере одна вершина полигона Р. Из всех вершин внутри треугольника ДаЬс пусть d будет вершиной, расположенной дальше всех от прямой линии са . Эту вершину d будем называть вторгающейся вершиной. При таком выборе вершины d диагональ bd должна лежать внутри Р — ибо если бы какое-либо другое ребро полигона вторглось бы между Ъ и d, то по крайней мере один из его концов должен находиться внутри треугольника Да&с на расстоянии от прямой са дальше, чем вершина d, что противоречило бы нашему выбору d. Следовательно, отрезок bd должен быть хордой. Теперь хорда bd делит полигон Р на два меньших полигона Pi и Рг с ni и П2 сторонами соответственно. Поскольку ni + П2 = п + 2 и п\, П2 > 3, то каждое из ni и П2 должно быть меньше п. Теперь мы можем применить гипотезу индукции к Pi и к Р2. Подсчет числа хорд приводит к (ni -3) + (zi2-3) + l = n- 3 хордам, a подсчет треугольников — к общему числу (ni - 2) + (П2 - 2) = п - 2 треугольни- ков. Теорема о триангуляции доказана. 8.7.1. Программа верхнего уровня Вышеприведенное доказательство теоремы прямо подводит к следующей программе для триангуляции полигонов. Программе передается полигон р и она возвращает список треугольников, представляющих его триангуля- цию. Треугольники накапливаются в списке triangles: List<Polygon*> *triangulate (Polygon &p) { List<Polygon*> *triangles = new List<Polygon*>; if (p.sizeO == 3) triangles->append(&p); else { findConvexVertex(p); Vertex *d = findlntrudingVertex(p); if (d == NULL) { // нет вторгающихся вершин Vertex *c = p.neighbor(CLOCKWISE); p. advance (COUNTERCLOCKWISE) ; Polygon *q = p.split(c); triangles->append(triangulate(p)); triangles->append(q); } else { // d - вторгающаяся вершина Polygon *q = p.split(d);
Алгоритмы типа «разделяй и властауй 959 triangles->append(triangulate(*q)); triangles->append(triangulate(p)); } } return triangles; } 8.7.2. Поиск вторгающейся вершины Используются функции f indConvexVertex и f indlntrudingVertex. При обработке полигона р функция findConvexVertex перемещает окно в полигоне р на некоторую выпуклую вершину. Это реализуется путем пере- мещения трех окон а, Ь и с, расположенных над тремя соседними верши- нами, в направлении по часовой стрелке вокруг полигона до тех пор, пока не будет обнаружен поворот направо в вершине Ь: void findConvexVertex(Polygon &p) { Vertex *a = p.neighbor(COUNTER_CLOCKWISE); Vertex *b = p.v(); Vertex *c = p.neighbor(CLOCKWISE); while (c->classify(*a, *b) ! = RIGHT) { a = b; b = p.aduance(CLOCKWISE); c = p.neighbor(CLOCKWISE); } } В функцию findlntrudingVertex передается полигон з, для которого предполагается, что его текущая вершина является выпуклой. Функция воз- вращает указатель на вторгающуюся вершину, если она существует или NULL в противном случае. Vertex *findlntrudingVertex(Polygon &p) { Vertex *a = p. neighbor (COUNTERCLOCKWISE) ; Vertex *b = p.v(); Vertex *c = p.advance(CLOCKWISE); Vertex *d = NULL; // лучший кандидат на данный момент double bestD =-1.0; // расстояние до лучшего кандидата Edge са(c->point(), a->point()); Vertex *v = p.advance(CLOCKWISE); while (v != a) { if (pointlnTriangle(*v, *a, *b, *c)) { double dist = v->distance(ca); if (dist > bestD) { d = v; bestD = dist; } } v = p.advance(CLOCKWISE); } p.setV(b); return d; }
260 Глава 8 Функция pointlnTriangle проверяет точки на попадание внутрь тре- угольника: функция возвращает значение TRUE, если точка р находится внутри треугольника с вершинами в точках а, Ь и с, в противном случае возвращаемое значение равно FALSE. bool pointlnTriangle(Point p, Point a, Point b, Point c) { return ((p.classify(a, b) != LEFT) && (p.classify(b, c) != LEFT) && (p.classify(c, a) !=LEFT)); } Заметьте, что триангуляция, формируемая программой triangulate, за- висит как от вида передаваемого ей полигона, так и от начальной текущей точки этого полигона. На рис. 8.11 представлены три различных результата триангуляции одного и того же полигона путем изменения его начальной текущей вершины. Эти результаты триангуляции полезно также сравнить с триангуляцией, показанной на рис. 7.13. Рис. 8.11. Три различных результата триангуляции, формируемых программой triangulate 8.7.3. Анализ При обработке n-угольника каждая из функций findConvexVertex и findlntrudingVertex затрачивает время О(п). Если не найдено ни одной вторгающейся вершины, то одиночный треугольник удаляется из п-угольника и подвергается рекурсивной триангуляции результирующий п-1-угольник. В худшем случае вторгающаяся вершина никогда не обнаруживается в про- цессе выполнения программы и размер полигона уменьшается на единицу на каждом последующем шаге. Это приводит к времени работы в худшем случае T(n) = п + (п-1) + ... + 1, что равно О(п2). Такой самый плохой по- казатель получается при триангуляции выпуклого полигона. Похоже, что программа будет работать значительно быстрее, если обна- руживается вторгающаяся вершина, поскольку в этом случае задача разби- вается на две подзадачи, вполне возможно, примерно равного размера. По- скольку только вогнутая вершина может вторгаться в треугольник, то программа работает быстрее, если полигон имеет много вогнутых вершин. Однако не представляет труда составить такой полигон с Q(n) вогнутыми вершинами, для триангуляции которого потребуется время Q(n2), хотя во- гнутые вершины и существуют, но алгоритм их не обнаруживает или на-
Алгоритмы типа «разделяй и властвуй' 961 ходит такие «плохие», что они приводят к разбиению на очень неравные части, одну большую, а другую маленькую. 8.8. Замечания по главе Оптимальный алгоритм для вычисления ядра полигона, выполняемый за время 0(п), разработали Ли и Препарата [53], его описание имеется в [67]. При обходе границ полигона этот алгоритм постепенно формирует пересе- чение полуплоскостей, определяемых ребрами полигона. В нем используют- ся преимущества, представляемые структурой полигона для достижения ли- нейных характеристик. Метод «разделяй и властвуй» может быть использован для формирования диаграммы Вороного для набора точек S за время O(n log и), где |S| = п. Идея заключается в разделении всего набора точек S воображаемой верти- кальной прямой линией на два набора точек Sl и Sr, рекурсивного фор- мирования диаграмм Вороного K2(Si.) и VZ)(Sr) с последующим их слиянием для образования K3(Sr). Такое слияние занимает линейное время, что делает этот алгоритм интересным и эффективным [51, 66, 74]. Для упрощения опе- рации слияния диаграммы Вороного представляются в виде структуры дан- ных, более сложной, чем простой список полигонов, применяемый в нашей программе voronoiDiagram. Иллюстрация диаграмм Вороного, основанная на явлениях кристаллогра- фии, приведена в [61], которая в свою очередь ссылается на [72]. Существует еще один алгоритм, работающий по методу «разделяй и вла- ствуй» для формирования выпуклой оболочки планарного набора точек S, который работает следующим образом: набор S сначала разбивается на два поднабора Si и S2, причем разбиение осуществляется произвольным образом за исключением того, что оба поднабора должны быть примерно одинакового размера. Затем рекурсивно находятся выпуклые оболочки GV(Si) и £#(82), ко- торые затем сливаются для образования общей оболочки CH (S) [67]. Поскольку выпуклые оболочки CH (Si) и CH (S2) в общем случае пересекаются, шаг сли- яния значительно отличается от описанного в данной главе. Но поскольку этот шаг слияния также выполняется за линейное время, полностью алгоритм вы- полняется за время O(n log и), подобно описанному в данной главе. Решение задачи поиска ближайшей пары точек, представленное в этой главе, впервые было описано в [74]. Решение задачи было расширено для d-мерного пространства в [10], где было показано, что задача может быть решена за время O(n log и), где п равно числу точек. Эти решения также были приведены в [67]. Доказательство теоремы, на котором основан алгоритм триангуляции, взято из [60]. Чазелле [18] описал линейный способ поиска хорошей хорды (но на предварительную обработку требуется время О(п log и)). Если полигон Р расщепить такой хордой, то получающиеся два полигона будут иметь раз- мер не менее Описанный в этой главе алгоритм триангуляции будет вы- полняться за время О(п log и), если наш способ поиска вторгающейся вер- шины заменить на поиск хорды по методу Чазелле.
969 Глава 8 8.9» Упражнения 1. При доказательстве теоремы о триангуляции полигона мы предпола- гали, что каждый полигон содержит по крайней мере несколько вы- пуклых вершин. Докажите это. 2. Покажите, что любой полигон может быть сформирован комбинацией соответствующим образом выбранных полуплоскостей путем выполне- ния операций пересечения и объединения. 3. Определите новый класс для представления ограниченных и неогра- ниченных двухмерных выпуклых политопов. Какие будут получены преимущества при представлении выпуклых политопов с использова- нием этого класса вместо класса Polygon, как в этой книге? 4. Задача поиска самой удаленной пары формулируется следующим об- разом: для заданного набора точек S на плоскости найти некоторую пару точек такую, расстояние для которой больше или равно рассто- янию между точками в любой другой паре. Покажите, что расстояние самой удаленной пары определяется двумя экстремальными точками (вершинами CH (S)). 5. Для заданного планарного набора S из п точек разработайте алгоритм для решения задачи поиска самой удаленной пары в S за время O(n log п). (Подсказка: сначала постройте выпуклую оболочку CH (S). Затем создайте два окна, размещенных на противоположных верши- нах набора S, и перемещайте их вокруг полигона до тех пор, пока не будут рассмотрены все противоположные пары. Две вершины вы- пуклого полигона будут противоположными, если через них можно провести параллельные касательные прямые линии, между которыми располагается полигон.) 6. Создайте n-угольник, обладающий Q(n) вогнутыми вершинами, для ко- торого алгоритм триангуляции из раздела 8.7 будет выполняться за время Q(n2). 7. Предположим, что вторгающуюся вершину мы определим следующим образом: для заданной выпуклой вершины & и ее соседних вершин а и с вершина d будет вторгающейся, если (1) вершина d лежит внутри треугольника Да&с и (2) вершина d находится ближе к вершине Ъ, чем все другие вершины, попавшие внутрь треугольника Да&с. Будет ли корректным алгоритм триангуляции при таком пересмотренном опре- делении? Докажите Ваш ответ. 8. Создайте алгоритм по методу «разделяй и властвуй» для поиска всех максимальных точек в заданном наборе за время О(п log п) (понятие максимальной точки для набора было определено в упражнении 11 раздела 7.6). 9. Создайте алгоритм по методу «разделяй и властвуй» поиска всех точек пересечения для заданного набора п горизонтальных и вертикальных отрезков прямых линий на плоскости. Алгоритм должен выполняться за время O(n log п + г) , где г — число обнаруженных точек пересе- чения.
Алгоритмы типа «разделяй и властвуй» 963 10. Разработайте пошаговый алгоритм для вычисления ядра п-угольника за время О(п). 11. Пусть задан набор точек S на плоскости. а) покажите, что если р G S и q — ближайшая к р точка в наборе S, то №(р) и К%(д) имеют общее ребро в диаграмме Вороного K3(S). б) для заданной E3(S) разработайте алгоритм решения задачи поиска ближайшей пары в S. Каким будет время выполнения Вашего алго- ритма? 12. Разработайте алгоритм сканирования плоскости для решения задачи поиска ближайшей пары. (Подсказка: когда сканирующая линия до- стигает точки р, определите, нет ли некоторой точки слева от скани- рующей линии, которая вместе с точкой р образует ближайшую пару.)
Глава 9 Способы пространственного разделения При реализации способа пространственного разделения выполняется де- композиция некоторого пространственного домена на более мелкие части. Этот способ известен также под названием расщепление, или просто деление. Он позволяет свести решение задачи, относящейся ко всему домену или гео- метрического объекта, лежащего внутри домена, к более простым или к меньшим подзадачам. Например, площадь полигона может быть легко вы- числена путем суммирования площадей треугольников, полученных в ре- зультате триангуляции полигона. Способы пространственного разделения очень широко варьируются по своей форме и структуре. Иерархическое деление получается очень легко путем рекурсивного разделения домена на меньшие и меньшие части и оно является очень мощным средством, поскольку обеспечивает реализацию ре- курсивных способов решения задач. Хорошо известным примером одномер- ной задачи является двоичное дерево для набора вещественных чисел. Каж- дый уровень дерева соответствует разделению строки вещественных чисел: корневой уровень соответствует неразделенной строке вещественных чисел, а каждый следующий уровень — уточняет разделение соответствующего более высокого уровня. Самый нижний уровень дерева соответствует на- именьшему делению из всех возможных (см. рис. 9.1). В этой главе мы рассмотрим три способа иерархического разделения. Два из них — квадрантное дерево и двухмерное дерево — будут применены для решения задач поиска на плоскости. Эти же задачи будут также исследованы с приме- нением сетки — неиерархического деления домена на более мелкие квадраты. Рис. 9.1. Двоичное дерево поиска представляет иерархическое разделение строки вещественных чисел 4 6 8 4 5 6 7 8 9 Н—I—I—I—I—I-
Способы пространственного разделения 965 В заключение рассмотрим третий способ иерархического деления, заклю- чающийся в построении двоичного дерева пространственного разделения. Он часто применяется при решении практической задачи в компьютерной гра- фике: для заданной статической сцены размещения треугольников в про- странстве при перемещении точки наблюдения найти упорядоченную види- мость треугольников для каждого последовательного положения точки наблюдения. Получаемое при этом решение может быть использовано для генерации последовательности изображений почти в реальном времени для сцен различной сложности. 9.1. Задача поиска по области Для заданного набора точек иногда требуется определить, какие из них лежат в пределах заданной области. Например, может потребоваться узнать, какие города находятся между указанными значениями широты и долготы. Или мы можем запросить названия всех звезд, расположенных на заданном расстоянии от некоторой заданной звезды или точки в пространстве. Такие вопросы называются запросами по области. Если точки принадлежат плоскости, а область определяется прямоуголь- ником со сторонами, параллельными координатным осям, то запрос по об- ласти будет называться запросом в двухмерной области, а прямоугольник будем называть просто областью. На рис. 9.2 показан запрос по области на плоскости. Для выполнения запроса мы будем использовать тот факт, что точка лежит в указанной области, если и только если точка одновре- менно находится в заданном диапазоне области как по координате х, так и по координате у. На рис. 9.2 точки е, f, g и h попадают в диапазон об- ласти по координате х, но из них только точки f и h также находятся в диапазоне области и по координате у. 9» ев de Рис. 9.2. Запрос по области на плоскости Стратегия весьма проста для реализации. Функция pointlnRectangle возвращает значение TRUE, если и только если точка р лежит в пределах прямоугольника R: bool pointlnRectangle(Point &р, Rectangle &R) { return ((R.sw.x <= p.x) && (p.x <= R.ne.x) && (R.sw.y <= p.y) && (p.y <= R.ne.y)); }
866 Глава 9 Запросы по области часто возникают в таких ситуациях, которые непо- средственно не относятся к геометрическим задачам, но могут быть интер- претированы как таковые. В этих случаях в качестве «точек» выступают некоторые количественные параметры. Например, может потребоваться уз- нать имена всех звезд, яркости и температуры которых лежат в пределах некоторых диапазонов. Областью в этом случае будет прямоугольник со сто- ронами, параллельными координатным осям на плоскости в системе коор- динат яркость-температура. Системы баз данных поддерживают такие запро- сы, в которых точки представлены в виде записей, а их компонентами являются поля. До сих пор имелись в виду запросы по области в виде «одноразовой» задачи: для заданных набора точек S и области R сообщить о точках из набора S, попадающих в область R. Однако во многих ситуациях бывает необходимо выполнить серию запросов по области для некоторого фиксиро- ванного набора точек S. В таких ситуациях в общем случае лучше сначала организовать точки в S в структуру данных, которая бы обеспечила выпол- нение последующих серий запросов по области, чем отвечать на каждый за- прос независимо. Такая «многоразовая» версия известна как задача поиска области. Задача поиска в двухмерной области и определяется статическим набором S точек на плоскости. Проблема заключается в организации точек набора S в пространственной структуре данных, которая эффективно обеспечивает повторяющиеся запросы по области. Пространственные струк- туры данных обычно используются для выполнения серий операций и при- меняются для реализации геометрических абстрактных типов данных (АТД). Тогда стоимость их образования, которая может быть значительной, окупается при выполнении большого числа операций. При самом упрощенном подходе к поиску области набор точек S мог бы быть организован в виде списка. Тогда для запроса по области R можно было бы проверять каждую точку в списке на попадание в область R. Это занимает время, пропорциональное |S|, т. е. размеру S, что оказывается не- эффективным, когда число выбранных точек оказывается малым по срав- нению с |S|. Проблема здесь в том, что не делается никаких попыток связать поиск с близостью к области и обрабатываются даже те точки, которые на- ходятся далеко от заданной области. В следующих трех разделах мы представим более эффективные решения, в которых используются пространственные структуры данных для локали- зации поиска. Для упрощения описания будем предполагать, что точки на- бора S лежат в положительном квадранте на плоскости, так что они могут быть заключены в квадратный домен Д левый нижний угол которого со- впадает с началом координат. Следовательно будет достаточно рассматривать не всю плоскость, а только выделенный домен Д который может подверг- нуться делению. Хотя мы всегда будем говорить о задаче поиска области, но очень важно понимать, что исследуемые нами пространственные структуры данных могут быть использованы также для решения многих других задач. В упражне- ниях в конце главы обращается внимание на некоторые из них.
Способы пространственного разделения Я67 9.2. Метод сетки 9.2.1. Представление Простейший способ деления квадратного домена 2) заключается в разбие- нии его на сетку из малых квадратов, или ячеек. Для подготовки сетки для поиска по области в наборе точек S «бросим» каждую точку из набора S в соответствующую ячейку — для каждой ячейки построим список точек, лежащих в ячейке (рис. 9.3). Заметим, для выполнения запроса по области R будем рассматривать только те ячейки, которые пересекаются с Я и для каждой такой ячейки будем проверять входящие в нее точки на предмет включения в R. Рис. 9.3. Сетка Сетка из т х т ячеек представляется классом, содержащим массив спис- ков с размерностью т х т, по одному списку на каждую ячейку. Каждый список содержит те точки из набора S, которые лежат в соответствующей ячейке: class Grid { private: int m; // число ячеек на сторону домена double cellSize; // размер стороны ячейки List<Point*> ***g; // сетка m х m элементов void _Grid(double domainSize, Point s[], int n) ; public: Grid(double domainSize, Point s[], int n, int m = 10); Grid(double domainSize, Point s[], int n, double M = 1.0); -Grid (void); List<Point*> *rangeQuery(Rectangle &range); friend class Quadtree; }; 9.2.2. Конструкторы и деструкторы Первому конструктору передается массив s из п точек, длина domainSize стороны квадратного домена 2> и размер сетки _т: Grid::Grid(double domainSize, Point s[], int n, int _m) : m(_m)
968 Глава 9 { _Grid(domainSize, s, n) ; Фактическая работа конструктора выполняется собственной компонент- ной функцией _Grid: void Grid::_Grid(double domainSize, Point s[], int n) ( cellsize = domainSize / m; g = new (List<Point*>**)[m]; for (int i = 0; i < m; i++) { g[i] = new (List<Point*>*)[m]; for (int j = 0; j < m; j++) g[i][j] = new List<Point*>; for (i = 0; i < n; i++) { int a = int(s[i].x / cellSize); int b = int(s[i).y / cellSize); g[a][b]->append(new Point(s[i])); ) ) Сетка хранится в массиве g указателей на списки размером т х т. По- скольку д реализуется в виде массива массивов, то можно использовать стандартную нотацию. Так запись g [ i ] [ j ] указывает на список в строке i столбца j сетки. После инициализации нового списка для каждой ячейки сетки конструктор в цикле просматривает все п точек, добавляя каждую из них в соответствующий список. Размер сетки т влияет на эффективность. Если т выбрано слишком большим, то ячейки сетки окажутся настолько малыми, что многие из них окажутся без точек. Если же т будет выбрано очень малым, то ячейки сетки получатся настолько большими, что они будут переполненными, со- держащими неразумно большое количество точек. Один из путей регули- рования размера сетки может заключаться в определении коэффициента за- полняемости ячейки, вещественного числа М, устанавливающего среднее желаемое число точек на одну ячейку. Если S содержит п точек, то будем иметь М = Чтобы выразить зна- чение т в функции от М, перепишем это выражение в виде т = [ "]. Это выражение приводит нас ко второму конструктору Grid, который по задан- ному коэффициенту заполняемости М вычисляет по этой формуле размер сетки: Grid::Grid(double domainSize, Point s[], int n, double M) m(int(ceil(sqrt(n / M)))) ( _Grid(domainSize, s, n); ) Деструктор -Grid удаляет все точки из сетки, списки, содержащие эти точки, и массивы, из которых была сформирована сама сетка: Grid::~Grid(void) (
Способы пространственного разделения 969 for (int i = 0; i < m; i++) { for (int j = 0; j < m; j++) { g[i][j]->last(); while (g[i](j]->length() > 0) delete g(i][j]->remove(); delete g(i][j]; delete g[i] delete g; 9.2.3. Выполнение запросов по области Для выполнения запроса по заданной области R компонентная функция rangeQuery преобразует координаты углов области R в целочисленные ин- дексы сетки, используемые для ограничения области поиска по строкам и столбцам сетки. Например, области протяженности области R по координате х составляет от столбца int (R. sw. x/cellSize) сетки до столбца int (R. sw. y/cellSize). Для каждой ячейки, которую будет обрабатывать функция rangeQuery, проверяется каждая точка в списке ячейки для вклю- чения в область R. Обнаруженные точки накапливаются в списке result: List<Point*> *Grid::rangeQuery(Rectangle &R) { List<Point*> *result = new List<Point*>; int ilimit = int(R.ne.x / cellsize); int jlimit = int(R.ne.y / cellsize); for (int i = int (R.sw.x/cellSize); i <= ilimit; i++) for (int j = int (R.sw.y/cellSize); j <= jlimit; j++) { List<Point*> *pts = g(i][j]; for (pts->first (); !pts->isHead(); pts->next()) { Point *p = pts->val(); if (pointlnRectangle(*p, R)) result->append(p); ) ) return result; } 9.2.4. Анализ Конструктор строит сетку с размером т х т по п точкам за время О(т2 + п). В самом худшем случае одиночный запрос по области занимает время О(т2 + п). Конечно, не представляет сложности разработать такую процедуру запроса по области, которая проверяет все т2 ячеек и все п точек без обнаружения хотя бы единственной точки. Тем не менее, запросы по области гораздо более эффективны в среднем: запрос по области, определяющий г точек, выполняется в среднем за время О(г), когда точки распределены равномерно. Чтобы убедиться в этом, за- метим, что время работы в среднем пропорционально ожидаемому числу проверяемых ячеек плюс ожидаемое число проверяемых точек. В каждой
970 Глава 9 проверяемой ячейке в среднем обнаруживается небольшое постоянное число таких точек, которые включаются в список, следовательно ожидаемое число проверяемых ячеек равно О(г). В среднем отбирается только некоторая фик- сированная часть точек в проверяемых ячейках, следовательно, ожидаемое число проверяемых точек также равно О(г). Свойства сетки несколько хуже, если точки в наборе S распределены не- равномерно. Если имеется слишком много пустых ячеек, то ожидаемое число проверяемых ячеек в процессе выполнения запроса по области теперь уже не будет ограничено сверху величиной О(г). Это происходит потому, что приходится проверять много пустых ячеек, а в результате нужные точки не будут обнаружены. И наоборот, если очень многие ячейки будут пере- гружены или заданный области будет узким и длинным, как бы вытянутым между точек в наборе S, тогда ожидаемое число проверяемых точек не будет ограничено сверху величиной О(г). Если ячейки содержат случайное боль- шое число точек из набора S, то нельзя предположить, что в каждой про- веряемой ячейке будет обнаружена фиксированное число точек. 9.3. Квадрантное дерево Сетки сравнительно неэффективны, если точки в наборе S распределены неравномерно — плотно в некоторых частях домена 2) и редко в других. В таких случаях никакой выбор размера сетки не гарантирует идеала, когда каждая ячейка содержит некоторое небольшое число точек из набора S. Де- ление с помощью квадрантного дерева позволяет разрешить это затруднение путем изменения размера ячеек в соответствии с распределением точек, обеспечивая деление на мелкие ячейки там, где точки расположены плотно, и более крупные ячейки — в разреженных местах. Квадрантное деление получается путем рекуррентного деления квадрат- ного домена 2) на квадранты. Данный квадрант делится путем проведения двух секущих прямых линий, одна из которых вертикальная, другая — го- ризонтальная, пересекающихся в центре квадранта и таким образом полу- чаются четыре одинаковых по размеру квадратных подквадранта. Так на рис. 9.4а домен 2) поделен на северо-восточный, юго-восточный, юго-запад- ный и северо-западный подквадранты. Северо-восточный и юго-западный квадранты, в свою очередь, подвергнуты дальнейшему делению. Рис. 9.4. (а)—деление домена на квадранты,- (б) — представление в виде квадрантного дерева
Способы пространственного разделения 971 Квадрантное деление представим с помощью квадрантного дерева, у ко- торого каждый узел имеет четыре ветви (рис. 9.46). Каждый узел п ква- дрантного дерева ассоциируется с областью ЯЦп) и про такой узел говорят, что он накрывает область: корневой узел накрывает квадратный домен 2 целиком, а каждый некорневой узел накрывает один из четырех подква- дрантов области своего предка. Внутренние узлы квадрантного дерева на- крывают квадранты, которые также могут быть поделены, внешние узлы накрывают квадранты, не подлежащие дальнейшему делению. Соответствен- но, мы будем ссылаться на квадрант как на внутренний, если он имеет внутреннее деление, и на внешний, если он больше не делится. На рис. 9.46 ветви, исходящие из каждого внутреннего узла квадрант- ного дерева, обозначены цифрами 0, 1, 2 и 3, относящимися к северо-вос- точному, юго-восточному, юго-западному и северо-западному подквадрантам соответственно. Обозначения ветвей могут быть использованы в качестве простой схемы для обозначения узлов в квадрантном дереве и, следователь- но, соответствующих подквадрантов, получаемых в результате деления ква- дранта. Корневой узел обозначается меткой 0. Метка некорневого узла, ко- торый связан с ветвью, обозначенной меткой i, получается путем добавления i к метке своего родителя. Таким образом для получения метки любого узла мы начинаем с 0 и последовательно добавляем метку ветви вдоль уникального пути от корня вниз до нужного узла. На рис. 9.4а показаны метки некоторых квадрантов. Какова реальная глубина квадрантного дерева? Если снова обратиться к точкам, то чем нам нужно руководствоваться при построении квадрантного дерева в процессе деления квадрантов? Построение квадрантного дерева ос- новано на идее адаптивного деления — квадранты подвергаются делению до тех пор, пока не будет выполнено некоторое условие. Для целей решения задач поиска по области нам необходимо контролировать количество точек из набора S, попадающих в квадрант. В данном случае мы можем использовать такой критерий деления: квадрант подвергается дальнейше- му делению только в том случае, если в нем содержится более М точек из набора S. Здесь целое число М, коэффициент заполняемости ячейки, за- дается как условие формирования квадрантного дерева. Если квадрантное дерево построено очень глубоким, то стоимость поиска по области будет значительной. К сожалению, наш критерий деления, взя- тый отдельно, недостаточен для обеспечения построения квадрантного де- рева с разумной глубиной. Если М + 1 точек из набора S могут быть сгруп- пированы произвольно близко друг к другу, то получаемое в результате квадрантное дерево может оказаться произвольно глубоким, чтобы отделить все точки. Для ограничения глубины квадрантного дерева введем понятие предельной глубины D, ниже которой квадрантное дерево расти не может. Коэффициент заполняемости ячейки М и предельная глубина D исполь- зуются совместно для получения квадрантного дерева, удовлетворяющего следующим условиям для каждого внешнего узла п: 1. п лежит не глубже, чем D. 2. Если глубина п определенно меньше, чем D, тогда п накрывает не более, чем М точек из S. 3. Если п не является корнем, то родитель узла п накрывает больше, чем М точек из набора S.
979 Глава 9 Совместно все эти свойства определяют уникальное квадрантное дерево для любого заданного набора точек S, содержащегося в домене 2. Нефор- мально можно сказать, что деление квадрантов производится до тех пор, пока в них будет попадать не более, чем М точек, или не будет достигнута глубина D. Такой пример показан на рис. 9.5. (а) (6) Рис. 9.5. (а) — набор из 16 точек; (б) — квадрантное дерево для этих точек при М = 2 и D = 3 9.3.1. Представление Применим квадрантное дерево для решения задачи поиска по области. Определим класс Quadtree: class Quadtree { private: QuadtreeNode *root; Rectangle domain; QuadtreeNode *buildQuadtree(Grid &G, int M, int D, int level,int, int, int, int); public: Quadtree (Grid &G, int M, int D); «.Quadtree () ; List<Point*> *rangeQuery(Rectangle &range); }; Узлы квадрантного дерева представляются в виде объектов класса QuadtreeNode: class QuadtreeNode { private: QuadtreeNode *child[4]; List<Point*> *pts; // точки в S, если узел внешний // NULL, если узел внутренний int size; // число точек в S, накрываемых узлом List<Point*> *rangeQuery(Rectangle firange, Rectangle Squadrant); Rectangle quadrant(Rectangles, int); int isExternal(); public: QuadtreeNode(List<Point*>*); QuadtreeNode(void); "QuadtreeNode(void); friend class Quadtree; );
Способы пространственного разделения 973 Если текущий узел квадрантного дерева внешний, то элемент данных pts представляется в виде списка точек, которые накрываются этим узлом (на- пример, точек, лежащих в квадранте, накрываемом узлом). Если узел внут- ренний, то элемент данных child указывает на своих четырех потомков, a pts равен NULL. Заметим, что нет необходимости для хранения точек для внутренних узлов, поскольку эти же точки будут записаны в последователях этих узлов — каждый внутренний узел накрывает квадранты своих после- дователей. 9.3.2. Конструкторы и деструкторы Конструктору Quadtree передается сетка G, коэффициент заполняемости ячеек М и предельная глубина D. Он формирует квадрантное дерево по точкам в сетке G, обеспечивая ограничения, налагаемые значениями М и D. В про- цессе работы точки удаляются из сетки G. Предполагается, что количество ячеек вдоль каждой стороны сетки G равно степени 2, т. е. G.m = 2^ для некоторого целого числа k. Quadtree::Quadtree(Grid &G, int M, int D) { root = buildQuadtree(G, M, D, 0, 0, G.m-1, 0, G.m-1); domain = Rectangle(Point(0,0), Point (G.m*G.cellSize, G.m*G.cellsize)); ) Компонентная функция buildQuadtree строит квадрантное дерево по сетке G снизу вверх, от уровня отдельной ячейки сетки до нулевого уровня, при котором домен 2) рассматривается как единая ячейка. На самом нижнем уровне — уровне k, когда сетка имеет размер 2* х 2*— каждая ячейка сетки представлена своим собственным одно-узловым квадрантным деревом. Узел содержит список тех точек из набора S, которые попадают в его ячейку. Для построения квадрантного дерева, корни которого лежат на уровне £, будем компоновать группы из четырех квадрантных деревьев, корни кото- рых лежат на следующем более низком уровне £ + 1. Когда достигнем уров- ня 0, самого верхнего уровня, то получим единое квадрантное дерево, ко- торое накрывает весь домен 2) целиком. Каждый из внешних узлов квадрантного дерева будет содержать список тех точек набора S, которые он накрывает. Для заданных четырех квадрантных деревьев с корнями по, ni, пг и пз на уровне € + 1 нам необходимо найти способ объединения их в единое квадрантное дерево с корнем п на уровне £. Это реализуется с помощью одного из следующих двух действий: • Привязать квадрантные деревья к общему предку: узлы по, «1, «2 и пз представляются в виде потомков узла п. • Слить квадрантные деревья в один узел: объединить точки из набора S, накрываемые узлами no, ni, пг и пз в единый список точек и ас- социировать этот список с узлом п. Затем удалить узлы no, ni, пг и пз. Как решить, какое из этих двух действий следует предпринять? Цель заключается в построении квадрантного дерева, удовлетворяющего трем ус- 10 Заказ 2794
974 Глава 9 ловиям, упомянутым ранее. Для выполнения этого мы будем сливать ква- дрантные деревья по, «1, пг и пз в единый узел п, если (1) область, на- крываемая узлом п будет содержать не более, чем М точек набора S, или (2) узел п лежит на уровне D или более. В противном случае узлы no, ni, пг и пз связываются с узлом п. Процесс формирования квадрантного дерева по сетке может быть реали- зован следующим образом. Начиная с самого мелкого возможного деления сетки G, когда каждая ячейка сетки представляется в виде отдельного ква- дранта, мы производим обработку, последовательно переходя с уровня на уровень, объединяя группы из четырех квадрантов, когда это необходимо, ис- ходя из значений коэффициентов заполняемости ячеек М и предельной глу- бины D. Этот процесс показан на рис. 9.6. В самом начале процесса наи- более мелкие ячейки имеют уровень 4. Уровень 3 получается путем слияния всех групп из четырех квадрантов, поскольку предельный уровень задан равным 3 и все узлы на уровне 4 должны быть объединены. Остальные уровни (2, 1 и 0) получаются в результате слияния только тех групп из четырех квадрантов, которые после их объединения будут содержать не более М точек из набора S. В компонентную функцию Quadtree::buildQuadtree передаются сетка G, коэффициент заполняемости ячейки М и предельная глубина D. Она фор- мирует квадрантное дерево для подсетки G [imin. . imax, jmin..jmax] и возвращает его корневой узел. Параметр level указывает уровень, на ко- тором расположен этот корневой узел внутри основного квадрантного дерева верхнего уровня. • • • • • • • • • • • • • • Уровень 2 Рис. 9.6. формирование квадрантного дерева по сетке. Здесь М = 2 и D = 3
Способы пространственного разделения 975 QuadtreeNode ‘Quadtree::buildQuadtree(Grid &G, int M, int D, int level, int imin, int imax, int jmin, int jmax) { if (imin == imax) { QuadtreeNode *q = new QuadtreeNode(G.g[imin][jmin]); G,g[imin][jmin] = new List<Point*>; return g; } else { QuadtreeNode *p = new QuadtreeNode; int imid = (imin + imax) / 2; int jmid = (jmin + jmax) /2; p->child[O] = // северо-восток buildQuadtree(G,M,D,level+1,imid+1,imax,jmid+1,jmax); p->child[l] = // юго-восток buildguadtree(G,M,D,level+1,imid+1,imax,jmin,jmid); p->child[2] = // юго-запад buildguadtree(G,M,D,level+1,imin,imid,jmin,jmid); p->child[3] = // северо-запад buildQuadtree(G,M,D,level+1,imin,imid,jmid+1,jmax); for (int i = 0; i < 4; i++) p->size += p->child[i]->size; if ((p->size <= M) || (level > = D)){ //слияние потомков p->pts = new List<Point*>; for (i = 0; i < 4; i++) { p->pts->append(p->child[i]->pts); delete p->child[i]; p->child[i] = NULL; ) } // Завершение слияния потомков return p; ) ) В общем случае функция buildQuadtree рекурсивно образует корень р для четырех потомков и связывает их с узлом р. При необходимости вы- полняется слияние четырех потомков ври они удаляются. Заметим, что в базовом случае (imin==imax), функция «изымает» списки точек из сетки G, оставляя сетку пустой, но в таком состоянии, что она может быть безопасно удалена. Деструктор -Quadtree образуется тривиально: Quadtree::-Quadtree() { delete root; } Первый конструктор QuadtreeNode инициализирует свои элементы дан- ных класса при передаче ему списка точек: QuadtreeNode::QuadtreeNode(List<Point*> *_pts) pts(_pts), size(_pts->length()) ( for (int i = 0; i < 4; i++) child[i] = NULL; )
976 Глава 9 Конструктор QuadtreeNode, не имеющий аргументов, используется при формировании списка точек внутреннего узла внутри компонентной функ- ции buildQuadtree: QuadtreeNode::QuadtreeNode(void) pts(NULL), size(O) { for (int i = 0; i < 4; i++) child[i] = NULL; ) Деструктор -QuadtreeNode исключает список точек текущего узла, если текущий узел является внешним, или рекурсивно удаляет потомков теку- щего узла, если текущий узел внутренний: QuadtreeNode::-QuadtreeNode() { if (isExternal()) ( // узел внешний pts->last(); while (pts->length() > 0) delete pts->remove(); delete pts; ) else // узел внутренний for (int i = 0; i < 4; i++) delete child[i]; ) 9.3.3. Выполнение запросов по области Для осуществления запроса по заданной области R будем выполнять за- прос к корневому узлу квадрантного дерева: List<Point*> ‘Quadtree::rangeQuery(Rectangle &R) ( return root->rangeQuery(R, domain); ) Для запроса по области R, начинающейся с узла п, необходимо сначала проверить, пересекается ли область R с областью Дп). Если нет, то выпол- няется возврат. Если п — внешний узел, то каждая точка в его списке про- веряется на предмет включения в R. В противном случае (п — внутренний узел) запрос по области рекурсивно применяется к каждому из четырех по- томков узла п, объединяя вместе все обнаруженные точки в единый список, который и возвращается: List<Point*> ‘QuadtreeNode::rangeQuery(Rectangle &R, Rectangle &span) { List<Point*> ‘result = new List<Point*>; if (!intersect(R, span)) return result; else if (isExternal()) // узел внешний for (pts->first(); !pts->isHead(); pts->next()) { Point *p = pts->val();
Способы пространственного разделения S77 if (pointlnRectangle(*р, R)) result->append(p); ) else for (int i = 0; i < 4; i++) { List<Point*> *1 = child[i]->rangeQuery( R,quadrant(span, i) ); result->append(l); } return result; Параметр span определяет квадрант, накрываемый текущим узлом. Вместо того, чтобы явно хранить квадрант каждого узла в виде элемента данных класса QuadtreeNode, будем вычислять квадранты «на ходу»: когда узел запрашивает каждого из своих потомков, потомку сообщается имя его подквадранта. Подквадранты вычисляются с помощью компонентной функ- ции quadrant, определяемого в следующем разделе. 9.3.4. Обеспечивающие функции Компонентная функция QuadtreeNode:: quadrant используется во время выполнения запроса по области для вычисления квадранта, накры- ваемого каждым узлом. Функция возвращает квадрант, накрываемый по- томком i текущего узла, где предполагается, что текущий узел накрывает квадрант s: Rectangle QuadtreeNode::quadrant(Rectangle Ss, int i) { Point c = 0.5 * (s.sw + s.ne); switch (i) { case 0: return Rectangle(c, s.ne); case 1: return Rectangle(Point(c.x,s. sw.y) , Point(s.ne.x, c.y)); case 2: return Rectangle(s.sw, c) ; case 3: return Rectangle(Point(s.sw.x, c.y), Point(c.x,s.ns.y)); ) ) Функция intersect определяет, не будут ли пересекаться два прямо- угольника а и Ь. Они пересекаются только в том случае, если одновременно налагаются друг на друга их проекции на координатные оси х и у. bool intersect(Rectangle &а, Rectangle &b) { return (overlappingExtent(a, b, X) && overlappingExtent(a, b, Y)); ) Два интервала налагаются, если и только если левая концевая точка одного интервала лежит в пределах другого интервала. Функция overlappingExtent
978 Глава 9 возвращает значение TRUE, если и только если проекции прямоугольников а и Ь налагаются по координате i: bool overlappingExtent(Rectangle fia, Rectangle fib, int i) { return ((a.sw[ij <= b.sw[i]) fifi (b.sw[i] <= a.ne[i])) || ((b.sw[i] <- a.swfij) fifi (a.sw[ij <= b.ne[i))); } Из других обеспечивающих функций следует упомянуть функцию QuadtreeNode:: isExternal, которая возвращает значение TRUE, если текущий узел является внешним узлом: int QuadtreeNode::isExternal() { return (pts != NULL); } 9.3.5. Анализ Рассмотрим время работы конструктора Quadtree, которому передается сетка размером т х т. Время работы Т(т) определяется рекуррентным выражением Т(т) = 4Т (^) + а Ъ если т > 1 если (т = 1) о и Ъ. Нетрудно показать, что Т(т) €Е О(т ). Заметим, что для констант а Т(т) не зависит от размера набора точек S. Это происходит из-за того, что функция buildQuadtree занимает только постоянное время, не считая ре- курсивных вызовов. Каждый раз, когда функция выполняет слияние четы- рех узлов в узел их общего предка, четыре списка точек сливаются за по- стоянное время с помощью функции List: : append. При каждом завершении рекурсивных обращений, функция «изымает» списки точек из сетки также за постоянное время. Рассмотрим затраты на выполнение запроса по области. Запрос с облас- тью R занимает время, пропорциональное числу проверяемых точек плюс число посещаемых узлов. В наихудшем случае будут проверяться все п точек набора S, но ни одна из них может не попасть в заданную область. Это может произойти, например, когда все точки из S случайно попадут в один и тот же внешний квадрант, а область R пересекается с этим ква- дрантом, но не захватывает ни одной точки, расположенной в нем. Т. е. в худшем случае на запрос по области будет затрачено время О(п), что не лучше, чем грубый непосредственный поиск по области. Однако, использо- вание квадрантных деревьев в среднем дает существенное преимущество перед непосредственным поиском. Мы здесь не будем анализировать ожи- даемые характеристики при использовании квадрантных деревьев для по- иска по области, поскольку эта задача сложна, а ответ очень сильно зависит от определения, что понимать под ожидаемым распределением точек на плоскости. Но все-таки стоит узнать, а сколько же узлов будет обработано в процессе выполнения запроса по области. При заданной области R, каждый узел п
Способы пространственного разделения ЯП в квадрантном дереве может быть отнесен к одной из трех категорий, ко- торые определяются соотношением между R и областью узлов Л(п). • Несоединенные узлы характеризуются соотношением R Г) Жп) = 0. Здесь Жп) лежит полностью вне области R. • Окруженные узлы характеризуются выражением R Г) Л(п) = Я(п). Здесь Жп) лежит полностью внутри области R. • Пересекающиеся узлы характеризуются выражением 0 С R Г) Жп) С Здесь Жп) и R частично пересекаются. Оказывается, что мы можем игнорировать несоединенные и окруженные узлы. Процесс поиска не продолжается после несоединенных узлов: при до- стижении несоединенного узла нет смысла обрабатывать его последователей. С другой стороны, когда достигается окруженный узел, то это означает, что должны быть обработаны все его последователи. Это происходит потому, что каждая точка из набора S, принадлежащая последователю окруженного узла по необходимости, должна лежать внутри области R. Тем не менее, мы можем легко модифицировать нашу реализацию квадрантного дерева так, чтобы точки из набора S хранились не только во внешних узлах, но также и во внутренних: мы допустим, чтобы каждый узел п — будь он внутренним или внешним — мог обладать списком точек, которые лежат в области Я(п). Тогда при обнаружении окруженного узла в процессе вы- полнения запроса по области, его список точек выводится непосредственно, что делает ненужной обработку последователей этого узла. Таким образом, мы можем предположить, что при поиске не обраба- тываются последователи ни несоединенных узлов, ни окруженных узлов. Мы все-таки должны учитывать сами окруженные и несоединенные узлы п, но это значительно легче. Если узел п является корнем, то ос- тальные узлы квадрантного дерева не обрабатываются. В противном случае, если узел п не корневой, тогда стоимость обработки этого узла может быть отнесена к стоимости обработки его предка, который должен быть пере- секающимся узлом. Поскольку этот узел-предок имеет только четырех по- томков, то таких отнесенных долей может быть не более четырех. Следо- вательно общее число узлов, обрабатываемых в процессе выполнения запроса по области пропорционально общему числу обнаруженных пересе- кающихся узлов. Нам осталось решить задачу подсчета числа пересекающихся узлов, об- рабатываемых в процессе выполнения запроса по области. На рис. 9.7 по- казаны возможные пять типов пересекающихся узлов. Тип узла п опреде- ляется видом взаимного пересечения области R и области Л(п). Тип 0 Тип 2 Тип 4 Рис. 9.7. Пять возможных типов взаимного пересечения областей R и JBJn),
980 Глава 9 Для пересекающегося узла п каждого типа (от 0 до 4) область R может пересекать четыре подквадранта для ^(п) только небольшим числом спосо- бов, показанных на рис. 9.8. Под каждой схемой указан тип узла, полу- чающегося в результате такого пересечения и лежащего на один уровень ниже. Заштрихованные подквадранты охватываются областью, так что они соответствуют окруженным узлам. При каждом посещении узла типа г, об- работка переходит на одного или нескольких его потомков. Но во всех слу- чаях, по крайней мере два обрабатываемых потомка будут также типа г, а остальные относятся к типу, более высокому, чем г. Если узел имеет тип 4, то возможно только двух-вариантное ветвление. Тип узла соответст- вует возможности для трех-вариантного или четырех-вариантного ветвления во время выполнения запроса по области. Когда происходит трех-вариантное или четырех-вариантное ветвление, то возможность для последующего трех- вариантного или четырех-вариантного ветвления уменьшается для всех, кроме, может быть, двух потомков. Поиск продолжается вниз подобно как — |||д Рис. 9.8. Способы, которыми область R может пересекать четыре подквадоанта для каждого типа пересекающе- го узла п (от 0 до 4)
Способы пространственного разделения 981 чество посещаемых узлов пропорционально 2D, если квадрантное дерево имеет глубину D. Это же можно показать более формально. Каждая строка на рис. 9.8 при- водит к возрастанию рекуррентного соотношения, характеризующего Tt(D) — число узлов, посещаемых в квадрантном дереве с глубиной D, корень кото- рого имеет тип г. Например, будем иметь 2Т3 (В - 1) 4Т4 (D - 1) W) = если в строке 3 обнаружен первый случай если в строке 3 обнаружен второй случай Поскольку рекуррентное выражение для Ti содержит элемент Tj для j > i, то эти рекуррентные выражения будем решать в порядке Т4, Тз,..., То. Методом индукции можно показать, что T4(D) < 21м- T3(D) < 2^2 Tz(D) < 2^ Ti(D) < 2mS T0(D) < 21*3 Из этого следует, что независимо от типа корневого узла в процессе выпол- нения запроса по области посещаются по крайней мере 2'&+'3 = 8(2^) G O(2D) узла. В свете нашего анализа, как бы следовало наилучшим образом выбирать допустимую глубину D. Мы хотим выбрать D достаточно большим, чтобы только несколько внешних узлов содержали больше, чем М точек, и до- статочно малым, чтобы квадрантное дерево не было слишком глубоким. При наиболее мелком делении квадрантное дерево с глубиной D содержит 4-° внешних узла. Чтобы каждый внешний квадрант содержал в среднем М точек, потребуется квадрантов, т. е. 4° = Решение относительно D приводит к получению выражения D = |"log4^. На основе этого значения D получим, что число посещаемых узлов при выпол- нении запроса по области пропорционально 2Iogi м, что приводит к О(У-^). Мы здесь показали, что при выполнении запроса по области посещаются не более О(^1п) узлов при предположении, что для каждого узла — как внут- реннего, так и внешнего — хранится список точек, которые он накрывает. К сожалению, как мы видели, в худшем случае потребуется обработать Q (п) точек и это может произойти даже в том случае, если ни одна из точек не попадает в заданную область. Причина заключается в том, что деление с помощью квадрантного дерева является пространственным: положение и ориентация отсекающих линий зависит от пространственного домена Д а не от точек набора S. Хотя количество посещаемых узлов в процессе выполне- ния запроса по области ограничено сверху значением О(^Гп), на обработку этих узлов могут потребоваться значительные затраты — в них может ока- заться большое количество точек из набора S, а выбираются из них только несколько, или вообще ни одной. В следующем разделе мы рассмотрим за- дачу поиска по области с помощью двухмерных деревьев, которые объект- но-ориентированы: положение и ориентация отсекающих линий зависят как от набора точек S, так и от домена 3). Следовательно, мы можем выбирать отсекающие линии для выделения точек из набора S совершенно обдуманно и таким образом обеспечить, чтобы посещение узлов было недорогим — об-
989 Глава 9 рабатываемым практически за постоянное время. В результате получим про- странственную структуру данных, которая обеспечивает выполнение запроса по области в наихудшем случае за время O(r + Vnj , где г равно числу об- наруженных точек. 9.4. Двухмерные деревья поиска Двухмерное двоичное дерево поиска, или 2-d дерево, является двоичным деревом, в котором выполняется рекурсивное деление плоскости вертикаль- ными и горизонтальными отсекающими линиями. Направление отсекающих линий попеременно меняется по мере продвижения вниз по дереву: четный уровень (начиная с корня) содержит вертикальные линии, нечетный уро- вень — горизонтальные линии деления. Для целей поиска по области в на- боре точек S каждый узел соответствует точке в наборе S и отсекающей линии, проходящей через эту точку. На рис. 9.9 показано разбиение плоскости на соответствующее 2-d дерево. Вертикальная отсекающая прямая линия, проходящая через точку а и со- ответствующая корню дерева, разделяет набор точек S на два поднабора Sl и Sr, располагающиеся слева и справа от вертикальной линии соответ- ственно. Горизонтальная отсекающая линия через точку Ь, левого потомка узла а, делит набор Sl на два набора, располагающиеся сверху и снизу от этой отсекающей прямой. Горизонтальная прямая линия, проходящая через точку с, отражающую правый потомок узла а, аналогичным образом делит набор Sr. Остальные отсекающие прямые линии выполняют дальнейшее более мелкое деление. Заметьте, что каждая отсекающая линия разделяет остаю- щиеся точки на два набора примерно равного размера. Как и в случае с квадрантными деревьями, полезно рассмотреть области, ассоциированные с каждым узлом. В 2-d дереве корень охватывает весь домен Д а каждый некорневой узел накрывает часть области своего предка, лежа- щей с той или иной стороны отсекающей прямой линии, относящейся к предку. На рис. 9.96 узел а относится ко всему домену, а узел Ъ накрывает полудомен, расположенный слева от вертикальной отсекающей прямой е< : * - ^1 / с d< h (а) Рис. 9.9. (а) — деление плоскости отсекающими прямыми линиями,- (6)—соответствующее 2-d дерево
Способы пространственного разделения 983 линии, проходящей через точку а, тогда как узел d накрывает ту часть об- ласти Ъ, которая лежит под горизонтальной прямой линией, проходящей через точку Ъ. На рис. 9.10 показано 2-d дерево для набора их 200 точек. Для срав- нения приведено также квадрантное дерево для этого же набора точек. (а) Рис. 9.10. (а) — набор S из 200 точек,- (6) — 2-d дерево для набора точек S,- (в) - квадрантное дерево для набора S (6) TF Е 5 4- — ЕЕ fl Е — п 1 -U 4* * 4* ± Е — Е "Е _L J Т 5 ± 1 • (в) 9.4.1. Представление 2-d дерево будем представлять в виде объектов класса TwoDTree: class TwoDTree { private: TwoDTreeNode *root; TwoDTreeNode *buildTwoDTree(Point *x[], Point *y[], int n, int cutType); public: TwoDTree (Point p[], int n); ~TwoDTree(void); List<Point*> *rangeQuery(Rectangle &range); }; Узлы представляются в виде объектов класса TwoDTreeNode: class TwoDTreeNode { private: Point *pnt; // точка, ассоциированная с узлом TwoDTreeNode *lchild; // слева или под линией отсечения TwoDTreeNode *rchild; // справа или над линией отсечения List<Point*> *rangeQuery(Rectangle йгапде, int cutType); public: TwoDTreeNode(Point*); ~TwoDTreeNode(void); friend class TwoDTree; }; Если отсекающая прямая линия текущего узла вертикальна, то узел, указываемый параметром Ichild (или rchild), накрывает область слева (или справа) от отсекающей прямой линии; если отсекающая прямая линия
884 Глава 9 горизонтальна, то узел Ichild (или rchild) накрывает область ниже (или выше) отсекающей прямой. Элемент данных pnt является точкой текущего узла. 9.4.2. Конструкторы и деструкторы Конструктор TwoDTree строит 2-d дерево по массиву р из п отдельных точек. Конструктор выполняет предварительную сортировку точек незави- симо по х-координатам и по у-координатам, а затем вызывает компонентную функцию buildTwoDTree: TwoDTree::TwoDTree(Point p[], int n) ( Point **x = new (Point*)[n]; Point **y = new (Point*)[n]; for (int i = 0; i < n; i++) x[i) = y[i] = new Point(p[i]); znergeSort (x, n, leftToRightCmp); znergeSort(y, n, bottomToTopCmp); root = buildTwoDTree(x, y, n, VERTICAL); } Функции сравнения n leftToRightCmp и bottomToTopCmp выполняют упорядочение точек на плоскости по увеличению координаты х и координа- ты у соответственно. Первая из них была определена в разделе 4.3.6, а вто- рая — в разделе 8.6.1. Для построения 2-d дерева компонентная функция buildTwoDTree (которая будет определена несколько позже) инициализирует корневой узел дерева и затем рекурсивно строит два поддерева для этого корня, по одному для каждой стороны отсекающей прямой линии, соответствующей корню. Если т = п/2, то корневой узел ассоциируется с точкой х[т] и с вертикальной прямой линией, проходящей через эту точку. Поскольку массив х был предварительно отсор- тирован, то точка х[т] будет средней точкой по координате х, а вертикальная отсекающая прямая линия через точку х[т] разделяет оставшиеся п — 1 точек набора S на левый и правый поднаборы примерно равного размера. Перед рекурсивным формированием двух поднаборов корня функция buildTwoDTree разделяет оба массива х и у на два отсортированных под- массива, по одному для каждой стороны от вертикальной отсекающей пря- мой линии. Массив х делится на х[0. . т - 1] и х[т + 1. . п - 1]. Деление массива у выполняется функцией splitPointSet, которая будет определена ниже. Имея два разделенных массива, функция строит левое (правое) под- дерево для корня путем рекурсивного обращения к самой себе с отсорти- рованными подмассивами х и у точек, расположенных слева (справа) от от- секающей прямой линии. Для получения горизонтальной отсекающей линии на следующем более низ- ком уровне взаимно меняется роль массивов х и у. В общем, по мере продвижения по дереву на следующий уровень, каждый раз меняются роли массивов х и у для получения вертикальной и горизонтальной отсекающих прямых линий. В функцию buildTwoDTree передаются массивы х и у для п точек, отсортированных по координатам х и у соответственно. Параметр cutType указывает на тип отсекающей прямой линии с помощью значений типа
Способы пространственного разделения 985 перечисления VERTICAL (вертикальная) или HORIZONTAL (горизонтальная). Функция возвращает указатель на корень сформированного ею 2-d дерева: enum ( VERTICAL = О, HORIZONTAL = 1) ; TwoDTreeNode *TwoDTree::buildTwoDTree(Point *x[], Point *y[], int n, int cutType) ( if (n == 0) return NULL; else if (n == 1) return new TwoDTreeNode(x[0J); int m = n / 2; int (*cmp)(Point*, Point*); if (cutType == VERTICAL) cmp = leftToRightCmp; else cmp = bottomToTopCmp; TwoDTreeNode *p = new TwoDTreeNode(x[m]); Point **yL = new (Point*)[m]; Point **yR = new (Point*)[n-m]; splitPointSet(y, n, x[m], yL, yR, cmp); p->lchild = buildTwoDTree(yL, x, m, 1-cutType); p->rchild = buildTwoDTree(yR, x+m+1, n-m-1, 1—cutType); delete yL; delete yR; return p; } Деструктор -TwoDTree тривиален: TwoDTree::-TwoDTree() ( delete root; } Конструктор TwoDTreeNode инициализирует элементы данных для узла: TwoDTreeNode::TwoDTreeNode(Point *_pnt) : pnt(_pnt), Ichild(NULL), rchild(NULL) ( } Деструктор -TwoDTreeNode рекурсивно удаляет оба потомка текущего узла, после чего удаляет точку текущего узла: TwoDTreeNode::-TwoDTreeNode() { if (Ichild) delete Ichild; if (rchild) delete rchild; delete pnt; } 9.4.3. Выполнение запроса по области Рассмотрим, как осуществить запрос с областью R, начинающейся с не- которого узла 2-d дерева. Сначала проверим, будет ли точка, связанная с
986 Глава 9 узлом, лежать в области R— и, если да, то эта точка выводится. Затем, если часть области R лежит слева (или снизу) от вертикальной (горизон- тальной) отсекающей прямой линии узла, то будем рекурсивно выполнять запрос из левого потомка узла. И симметрично, если часть области R рас- положена справа (выше) от вертикальной (горизонтальной) отсекающей пря- мой линии узла, то будем рекурсивно выполнять запрос из левого потомка узла. Цель заключается в ограничении обращения запроса к одной из сторон от отсекающей прямой линии узла, пока это возможно сделать — т. е. пока область R не перестанет пересекать отсекающую прямую линию. На самом верхнем уровне запрос с областью R применяется к корню 2-d дерева: List<Point*> *TwoDTree::rangeQuery(Rectangle SR) ( return root->rangeQuery(R, VERTICAL); } Функция TwoDTreeNode: : rangeQuery выполняет запрос с областью R, начиная с текущего узла. Предполагается, что отсекающая прямая линия через текущий узел будет иметь тип cutType, либо VERTICAL, либо HORIZONTAL. Функция возвращает список тех точек, которые находятся в области R: List<Point*> *TwoDTreeNode::rangeQuery(Rectangle SR, int cutType) ( List<Point*> *result = new List<Point*>; if (pointlnRectangle(*pnt, R)) result->append(pnt); int (*cmp)(Point*, Point*); cmp = (cutType==VERTICAL) ? leftToRightCmp : bottomToTopCmp; if (Ichild && ((*cmp) (SR.sw, pnt) < 0)) result->append(lchild->rangeQuery(R, 1-cutType)); if (rchild && ((*cmp)(SR.ne, pnt) > 0)) result->append(rchild->rangeQuery(R, 1-cutType)); return result; } Функция накапливает точки, которые она находит, в списке result. Об- ратите внимание, как в функции используются углы области для опреде- ления взаимного расположения области R и отсекающей прямой линии. На- пример, часть области R лежит слева (ниже) от вертикальной (горизонтальной) отсекающей прямой линии только в том случае, если юго- западный угол области R меньше, чем pnt. Здесь смысл понятия меньше, чем зависит от ориентации отсекающей прямой линии и определяется функ- циями сравнения leftToRightCmp или bottomToTopCmp. 9.4.4. Вспомогательные функции Функция splitPointSet используется для разделения точек отсекающей прямой линией. Предположим, что отсекающая линия вертикальная. Если она проходит через точку р, а массив у для точек отсортирован по увели-
Способы пространственного разделения 887 чению координаты у, то в результате работы функции будут получены два массива: массив yL содержит те точки из массива у, которые лежат слева от р, отсортированные по координате у, а массив yR содержит те точки из массива у, которые лежат справа от р, также отсортированные по коорди- нате у. void splitPointSet(Point *у[], int n, Point *p, Point *yL[], Point *yR[], int (*cmp) (Point*, Point*)) ( int lindx = 0, rindx = 0; for (int i = 0; i < n; i++) { if ((*cmp) (y[i], p) < 0) yL[lindx++] — y[i]; else if ((*cmp) (y[i], p) > 0) yR[rindx++] = y[ij; } } Функция выполняет действия, обратные операции слияния при сортировке слиянием и отличается от функции splitY из раздела 8.6 только двумя момен- тами: во-первых, точка р не включается ни в один из массивов yL и yR, даже если точка р присутствует в массиве у. Во-вторых, функция splitPointSet разделяет точки в соответствии с передаваемой ей функцией сравнения стр. Мы будем передавать ей функцию сравнения leftToRightCmp для разделе- ния точек с помощью вертикальной отсекающей прямой линии, проходящей через точку р, или функцию bottomToTopCmp для деления относительно го- ризонтальной линии, проходящей через точку р. 9.4.5. Анализ Рекурсивная функция TwoDTree:: buildTwoDTree строит 2-d дерево по набору из п точек за время Т(п), где Т(п) определяется рекуррентным вы- ражением . [ 2Т (4) + ап если п > 1 I, если (n = 1) с подходящими константами а и Ъ. Следовательно, Т(п) €Е О(п log п). По- скольку предварительная сортировка, осуществляемая конструктором TwoDTree также занимает время O(n log п), то построение 2-d дерева вы- полняется за время O(n log п). Запрос по области, в результате которого выдаются г точек, выполняется за время O(r + Vnj в самом худшем случае. Это можно показать с помощью тех же самых рассуждений, которые были использованы в предыдущем раз- деле при анализе квадрантных деревьев. Каждый из узлов 2-d дерева может быть классифицирован как несоединенный, окруженный или пересекаю- щийся на основании его положения относительно данной области R. Можно показать, что число пересекающихся узлов, посещаемых в процессе выпол- нения запроса по области ограничено сверху значением O(Vn), а число по- сещаемых окруженных узлов ограничено сверху значением О(г).
988 Глава 9 9.5. Удаление невидимых поверхностей: деление пространства В этом разделе мы обратимся к задаче, тесно связанной с проблемой уда- ления невидимых поверхностей, рассматривавшейся в разделе 6.4. Нашей целью будет составить пространственную структуру данных по статичной коллекции треугольников в пространстве для реализации следующих опе- раций: для заданной точки зрения р в пространстве вычислить упорядоче- ние в порядке видимости треугольников относительно точки р. (Напомним из раздела 6.4 важность упорядочения в порядке видимости: если при таком упорядочении треугольник Р предшествует треугольнику Q, то треугольник Р не должен закрывать треугольник Q при его наблюдении из точки р — рисование треугольников согласно этому порядку приводит к получению изображения, видимого из точки р с удалением невидимых поверхностей.) Такое «многоплановое» решение проблемы удаления невидимых поверхнос- тей весьма часто применяется в компьютерной графике, поскольку это очень хорошо подходит к случаю перемещения точки наблюдения относительно статичной сцены. Для каждого положения точки наблюдения структура дан- ных обеспечивает генерацию изображения почти в реальном времени с уда- лением невидимых поверхностей. Наша структура данных основана на двоичном делении пространства. Для визуализации того, что происходит, мы будем рассматривать двоичное деление в пространстве, на одну размерность меньше, т. е. на плоскости. При этом общность рассматриваемых вопросов при концентрации внимания на плоскости не будет потеряна, поскольку анализируемая идея пригодна для пространства любой размерности. Конечно, когда мы будем рассматри- вать конкретную реализацию, мы вернемся к трехмерному пространству, так что полученные программы могут быть использованы в компьютерной графике. Двухмерный аналог нашей задачи заключается в следующем: заданный набор отрезков прямых линий на плоскости должен быть организован в виде структуры данных для вычисления упорядочения в порядке видимости отрезков прямых линий на плоскости относительно любой точки наблюде- ния р на плоскости. Вычерчивание отрезков прямых линий в порядке их видимости предполагает их одномерную проекцию из точки р с удаленными невидимыми частями. Здесь отрезки прямых линий предполагаются непро- зрачными, а видимость ограничена плоскостью. Рассмотрим структуру данных, которая будет применяться. Двухмерное дерево двоичного пространственного деления, или дерево ДПД, является дво- ичным деревом, которое рекурсивно делит пространство путем проведения от- секающих прямых линий. Каждый узел дерева ДПД связан одновременно с отсекающей прямой линией и областью. Как и в случае с 2-d деревом, об- ласть, соответствующая корню, представляет всю плоскость, а каждая об- ласть некорневого узла — часть области своего предка с той или другой сто- роны отсекающей прямой линии. Однако, в отличие от 2-d дерева, отсекающие прямые линии в дереве ДПД имеют произвольные ориентацию и положение. На рис. 9.11 изображены двоичное деление пространства и со- ответствующее дерево ДПД. Отметим, что отсекающие прямые линии слева от данной отсекающей прямой линии £ лежат в левом поддереве для £, а те, что находятся справа от £ — в его правом поддереве.
Способы пространственного разделения 989 Рис. 9.11. (а)—двоичное деление пространства; (б) — соответствующее дерево ДПД Теперь посмотрим, как нужно строить дерево ДПД для решения нашей задачи видимости на плоскости. Для заданного набора S отрезков прямых линий сначала выберем из S произвольный отрезок прямой линии и ассоциируем его с корневым узлом дерева. Корневая отсекающая прямая линия определяется этим отрезком прямой линии путем продолжения отрезка с обеих сторон. Эта прямая линия разделяет оставшиеся отрезки прямых линий на две группы: Sl, расположенную слева от отсекающей прямой линии, и Sr— справа. Любой отрезок, пересекаемый отсекающей прямой линией, разделяется на две части и часть, лежащая слева от отсекающей прямой линии, добавляется к Sl, а его другая часть — к Sr. Затем будем рекурсивно строить левое поддерево корня по набору Sl и правое поддерево — по набору Sr. На рис. 9.126 отсекающая прямая линия по отрезку а делит отрезок прямой линии d на d\ и dz. Набор Sl содержит отрезки прямых линий Ь, с и di, а набор Sr— dz, е и f. Левое и правое поддеревья узла а также будут деревьями ДПД, построенными по на- борам Sl и Sr соответственно. Для любой точки наблюдения р на плоскости мы используем модифици- рованный порядок обхода дерева ДПД, чтобы организовать отрезки прямых линий в порядке видимости. Рассмотрим, как обходить дерево ДПД на рис. 9.13, начиная с его корня. Поскольку точка р лежит справа от отрезка прямой линии а, то ни один из отрезков прямых линий слева от а не может закрывать а, они не могут также закрывать ни одного из отрезков прямых линий, лежащих справа от а. Следовательно, все отрезки прямых линий, лежащие слева от а, мы должны вычерчивать в первую очередь. Затем, по- скольку отрезок а не может закрывать какие-либо отрезки, лежащие справа от него, следующим будет вычерчен отрезок прямой линии а. И, наконец, вычертим все отрезки прямых линий, которые лежат справа от отрезка а. Конечно, если бы точка наблюдения р оказалась бы слева от отрезка прямой линии а, то нам бы пришлось все вычерчивать в обратном порядке: сначала все отрезки прямых линий, лежащие справа от отрезка а, затем сам отрезок а и потом отрезки прямых линий, расположенные слева от а. Этот моди- фицированный порядок обхода дерева ДПД, начинающийся в узле п, от- слеживается следующей псевдопрограммой: removeHiddenLines (node n, viewpoint p) если p лежит справа от отрезка узла п, то
990 Глава 9 Рис. 9.12. (а) - набор отрезков прямых линий S; (б) — двоичное деление пространства для набора S; (в) — соот- ветствующее дерево ДПД Рис. 9.13. (а) - двоичное деление пространства и точка наблюдения р; (б) - части отрезков прямых линий, ви- димых из точки р, выделены толстой линией; (в) — соответствующее дерево ДПД, в котором отмечен порядок обхода узлов removeHiddenLines(leftchild(п), р) draw(п) removeBiddenLines(rightchild(п), р) иначе removeHiddenLines(rightchild(п), у) draw(п) removeHiddenLines(leftchild(п), р) Заметим, что блок «иначе» выполняется в том случае, если точка наблю- дения р коллинеарна с отрезком прямой линии а. Но и в этом случае было бы вполне корректно выполнять блок «если», поскольку отрезок а был бы виден со стороны его конца. 9.5.1. Представление После этих предварительных объяснений вернемся к задаче реализации деревьев ДПД. Будем анализировать задачу сортировки по видимости для статичной коллекции треугольников в пространстве и перемещения точки наблюдения. Расширение описанных способов на трехмерное пространство
Способы пространственного разделения 991 не представляет труда: отрезки прямых линий превращаются в треуголь- ники, а отсекающие прямые линии — в отсекающие плоскости. Дерево ДПД представляется классом BspTree. class BspTree { private: BspTreeNode ‘root; BspTreeNods *buildBspTree(List<Triangle3D*>*); public: BspTree(Triangle3D *t[], int n); ~BspTree(void); List<Triangle3D*> *visibilitySort(Point3D p) ; ); Узлы представляются объектами такого класса: class BspTreeNode { private: BspTreeNode *poschild; BspTreeNode *negchild; Triangle3D *tri; BspTreeNode(Triangle3D*) ; ~BspTreeNode(void); List<Triangle3D*> *visibilitySort(Point3D); friend class BspTree; ) ; Треугольник текущего узла — указываемый элементом данных tri — делит все пространство на положительное и отрицательное полупространства, о чем говорилось в разделе 4.5, Элемент данных poschild указывает на по- томок текущего узла в положительном полупространстве tri, a negchild — на потомки в его отрицательном полупространстве. 9.5.2. Конструкторы и деструкторы Конструктор BspTree создает новое дерево ДПД по массиву t из п тре- угольников. Он просто преобразует t в список, который передается в функ- цию buildBspTree: BspTree::BspTree(Triangle3D *t[], int n) { List<Triangle3D*> *tris = new List<Triangle3D*>; for (int i = 0; i < n; i++) tris->append(new Triangle3D(*t[i])); root = buildBspTree(tris); delete tris; ) Компонентная функция buildBspTree формирует дерево ДПД по списку s треугольников, который ему передается. Функция возвращает указатель на корневой узел дерева: BspTreeNode *BspTree::buildBspTree(List<Triangle3D*> *s) (
999 Глава 9 if (s->length() == 0) return NULL; if (s->length() == 1) return new BspTreeNode(s->first()); List<Triangle3D*> *sP = new List<Triangle3D*>; List<Triangle3D*> *sN = new List<Triangle3D*>; Triangle3D *p = s->first(); for (s->next(); !s->isHead(); s->next()) { Triangle3D *q = s->val(); int cl[3) ' for (int i = 0; i < 3; i++) cl[i) = (*q) [i].classify(*p); if ((cl[0] != NEGATIVE) && (cl[l] != NEGATIVE) && (cl[2] != NEGATIVE)) sP->append(q); else if ((cl[0] != POSITIVE) && (cl[l] != POSITIVE) && (cl[2] != POSITIVE)) sN->append(q); else refineList(s, P); } BspTreeNode *n = new BspTreeNode(p); n->poschild = buildBspTree(sP); n->negchild = buildBspTree(sN); delete sP; delete sN; return n; } В общем случае, когда список s содержит более одного Треугольника, пер- вый треугольник р в списке s запоминается в качестве корневого узла, а все остальные треугольники q добавляются либо в список sP, либо в список sN, в зависимости от того, лежит ли q в положительном, либо в отрицательном полупространстве от р. Если плоскость р пересекает треугольник q, то путем обращения к функции refineList осуществляется расщепление треугольни- ка q плоскостью р и, в пределах списка s, треугольник q заменяется его час- тями. Функция refineList была определена в разделе 6.4. Деструктор -BspTree удаляет корень дерева: BspTree::-BspTree() { delete root; } Конструктор BspTreeNode инициализирует элементы данных: BspTreeNode::BspTreeNode(Triangle3D *_tri) tri(_tri), poschild(NULL), negchild(NULL) { } Деструктор класса рекурсивно удаляет оба потомка текущего узла и затем удаляет его треугольник:
Способы пространственного разделения 993 BspTreeNode : : -BspTreeNode () { if (poschild) delete poschild; if (negchild) delete negchild; delete tri; } 9.5.3. Выполнение удаления невидимой поверхности В компонентную функцию BspTree: : visibilitySort передается точка наблюдения и она возвращает упорядоченный по видимости список треуголь- ников: List<Triangle3D*> *BspTree::visibilitySort(Point3D p) { return root->visibilitySort(p); ) Компонентная функция BspTreeNode:: visibilitySort реализует опи- санный ранее модифицированный порядок обхода для дерева ДПД, имею- щего корень в текущем узле, и для точки наблюдения р. Функция возвра- щает упорядоченный по видимости список треугольников: List<Triangle3D*> *BspTreeNode::visibilitySort(Point3D p) { List<Triangle3D*> *s = new List<Triangle3D*>; if (p.classify(*tri) == POSITIVE) { if (negchild) s->append(negchild->visibilitySort(p)); s->append(tri); if (poschild) s->append(poschild->visibilitySort(p)); ) else { if (poschild) s->append(poschild->visibilitySort(p)); s->append(tri) ; if (negchild) s->append(negchild->visibilitySort(p)); ) return s; ) 9.5.4. Анализ Мы здесь только перечислим некоторые результаты, поскольку сам ана- лиз выходит далеко за уровень нашего изложения. Формирование дерева ДПД по п непересекающимся отрезкам прямых линий занимают в среднем время O(n2 log и). Ожидаемый размер дерева ДПД составляет О(п log и) в предположении, что упорядочение по видимости вычисляется в среднем за время О(п log и). Для трехмерного случая предполагается, что п треугольников в простран- стве взаимно не пересекаются. Тогда ожидаемый размер дерева ДПД состав- ляет О(п2), а ожидаемое время его формирования — O(n3 log и).
994 Глава 9 9.6. Замечания по главе Широкий области пространственных структур данных, а также огромное число их вариантов и применений, описан Хананом Саметом в [69, 70, 71]. Обзор структур данных для решения задач поиска по области можно найти в [8]. Применение многомерных деревьев поиска исследовано в [7]. Термин квадрантное дерево иногда используется в связи с любой про- странственной структурой данных, основанной на рекурсивной декомпози- ции пространства. Такие структуры данных могут варьироваться как в от- ношении типов данных, представленных в них, так и по принципу отслеживания декомпозиции. В свете таког