Кафедра информационных систем и технологий http://edu.msiu.ru Выпуклая оболочка — часть 1 Е.А. Роганов roganov@msiu.ru 6 декабря 2013 Более подробное изложение этого материала содержится во втором параграфе третьей главы учебного пособия «Основы информатики и программирования» плоскости выпуклая оболочка, а также её периметр и площадь, уже известны. После добавления новой точки x ∈ X возможны две ситуации: либо точка x попадает внутрь оболочки, либо вне её. В первом случае выпуклая оболочка (а также её периметр Выпуклые множества и выпуклая оболочка и площадь) не изменяются. Во втором изменения происходят, Множество M называется выпуклым, если для любых двух но информации, хранящейся в функции F (выпуклой оболочки, его точек отрезок прямой, соединяющий эти точки, целиком её периметра и площади) вместе с координатами точки x явно принадлежит этому множеству. Иначе говоря, M выпукло ⇔ достаточно для определения новой выпуклой оболочки и её изменившихся характеристик. ∀x1 , x2 ∈ M [x1 , x2 ] ∈ M . Хорошей моделью для описания изменяющейся оболочки яв Примеры выпуклых множеств: отрезок прямой, прямоуголь ник и круг на плоскости, куб, шар и пирамида в пространстве. ляется следующая: предположим, что во вновь добавляемой Выпуклыми множествами не являются: окружность, граница точке x расположена лампочка, освещающая часть рёбер старой оболочки, которые мы так и будем называть — освещёнными. квадрата и тор (прямое произведение двух окружностей). Для получения новой оболочки необходимо, как это хорошо Выпуклой оболочкой conv(M ) множества M называется видно из следующего рисунка, удалить все освещённые рёбра, а наименьшее выпуклое множество, содержащее M . Выпуклая оболочка любого выпуклого множества совпадает концы оставшейся ломаной соединить двумя новыми рёбрами с с ним. Для произвольного множества выпуклая оболочка мо добавляемой точкой x: жет быть получена как пересечение всех выпуклых множеств, его содержащих. Выпуклой оболочкой двух точек на плоскости является отрезок, их соединяющий; выпуклая оболочка окруж ности — круг; выпуклая оболочка трёх точек, не лежащих на одной прямой, — внутренность треугольника. Постановка задачи Напишите программу, находящую выпуклую оболочку после довательно поступающих точек плоскости и вычисляющую её периметр и площадь. Решение должно быть индуктивным, что означает определение выпуклой оболочки и вычисление её ха рактеристик сразу после поступления очередной точки с исполь зованием методов теории индуктивных функций. Обсуждение задачи Если добавляемая точка лежит на продолжении одного из рёбер, то оболочка должна измениться, поэтому ребро, на про Если представить себе точки конечного множества в виде вби должении которого лежит точка x, мы также будем считать тых в доску гвоздей, то выпуклая оболочка — это многоугольник, освещённым: форму которого принимает натянутое на гвозди резиновое коль цо: Обозначим множество R2 точек плоскости через X, а сово купность всех выпуклых фигур на плоскости через P. Тогда тройка функций f : X ∗ → P — выпуклая оболочка последова тельности точек, g: X ∗ → R — её периметр и h: X ∗ → R — ∗ её площадь, задаёт индуктивную функцию F : X → P × R × R, f F = g . h Функцию перевычисления G для неё более точно мы опишем чуть позже, а пока ограничимся следующим предварительным рассуждением. Пусть для некоторой последовательности точек Точки на плоскости — математические аспекты Для работы с точками на плоскости мы создадим класс R2Point. В нём необходим ряд методов, которые позволяли бы вычислять расстояния между точками, сравнивать их на совпадение, выяснять, лежат ли три точки на одной прямой и находится ли некоторая точка на прямой между двумя другими. Кроме того, необходимо уметь вычислять площадь треуголь ника и находить все освещённые ребра многоугольника. 1 2 Решение задач на модификацию эталонного проекта потребует реализации ещё целого ряда методов, связанных с различными геометрическими характеристиками. Факты из аналитической геометрии и векторной алгебры являются необходимой базой для решения сформулированных задач, однако только знание элементов вычислительной геометрии позволяет получить достаточно эффективные решения. Например, известная из средней школы формула Герона вы числения площади треугольника p S = p(p − a)(p − b)(p − c) требует двух сложений, трёх вычитаний, четырёх умножений и относительно медленно выполняемой операции извлечения квадратного корня. В то же время основанная на связи площади с векторным произведением формула S = 12 |~a × ~b| использует всего три умножения и пять вычитаний (это объяснено чуть ниже), что позволяет найти площадь треугольника значительно быстрее. Ещё важнее порой оказывается то, что вычисления с помощью второго из этих методов оказываются гораздо более точными. Для векторов в пространстве ~a = (ax , ay , az ) и ~b = (bx , by , bz ) координаты вектора ~c находятся по формуле ~i ~k ~j ~c = ax ay az = bx by bz = (ay bz − az by )~i + (az bx − ax bz )~j + (ax by − ay bx )~k. Однако, в том случае, когда векторы ~a и ~b расположены на плоскости, их векторное произведение имеет единственную отличную от нуля компоненту, вычисление которой и должно быть реализовано в методе area. Обратите внимание на то, что так вычисленная площадь является ориентированной, то есть может быть отрицательной. Выяснение того факта, лежат ли три точки на одной прямой (это должен делать метод класса triangle?), сводится к вычис лению площади треугольника и сравнению её с нулём, а для того, чтобы точка T прямой находилась на отрезке [A, B] этой же прямой (проверять справедливость этого факта должен метод экземпляра inside?), необходимо и достаточно принадлежности проекций этой точки проекциям на оси координат отрезка [A, B]. Для того чтобы реализовать метод light?, позволяющий вы яснить, освещено ли ребро [A, B] выпуклой оболочки из точки T , дадим строгое определение освещённости. Ребро [A, B] называ ется освещённым из точки T , если ориентированная площадь треугольника, образованного точками A, B и T отрицательна, либо если точка T расположена на прямой, проходящей через точки A и B, вне отрезка [A, B]. Заметим, что при таком формальном определении понятия освещённости никакое ребро многоугольника не освещено ни из одной точки внутри или на границе многоугольника, что хорошо видно на следующем рисунке: private def input(prompt) print "#{prompt} -> " readline.to_f end end Благодаря этому новую точку на плоскости (объект клас са R2Point) можно создавать как с явным указанием аргумен тов (что делается, например, так: t = R2Point.new(1.0, 2.0)), так и без их указания (например, так: t = R2Point.new). В по следнем случае координаты точки будет предложено ввести с клавиатуры. Для того чтобы можно было извне класса работать с коор динатами точки, хранящиеся в компонентах @x и @y каждого из экземпляров (например, так: t = R2Point.new(1.0, 2.0); puts t.x) необходимы следующие методы: def x @x end def y @y end Нужда в подобных методах возникает достаточно часто и по этому Ruby «умеет» создавать их автоматически. Достаточно поместить в определение класса строку attr_reader :x, :y Заметим, что изменять значения координат точки с помощью указанных методов нельзя вне зависимости от того, написаны они вручную или созданы автоматически. Для этого в классе должны быть определены другие методы: def x=(x) @x=x end def y=(y) @y=y end Их тоже можно создать автоматически. Это делается так: attr_writer :x, :y Если нужно иметь возможность извне и читать и писать координаты точки, то создать все необходимые для этого методы тоже можно с помощью единственной строки: attr_accessor :x, :y Ранее мы уже разобрались с тем, как следует находить ори ентированную площадь треугольника и проверять, лежат ли три заданные точки на одной прямой: def R2Point.area(a, b, c) 0.5*((a.x-c.x)*(b.y-c.y)-(a.y-c.y)*(b.x-c.x)) end def R2Point.triangle?(a, b, c) area(a, b, c) != 0.0 end Точки на плоскости — программистские аспекты Конструктор класса R2Point имеет аргументы со значениями по умолчанию: class R2Point def initialize(x = input("x"), y = input("y")) @x, @y = x, y end Обратите внимание, что оба эти метода являются методами класса. Это означает, что при их вызове не существует ника кой конкретной точки на плоскости (объекта класса R2Point), для которого эти методы вызываются. В качестве такого объ екта в этих случаях следует использовать имя класса: s = R2Point.area(a, b, c). Можно переписать определение метода area так, что он станет методом экземпляра. При этом количество аргументов у него уменьшится с трёх до двух, ибо третьей точкой треугольника в этой ситуации будет являться та точка плоскости, от которой такой модифицированный метод будет вызываться. Вот как будет выглядеть определение этого, нового метода: 3 def area(b, c) 0.5*((@x-c.x)*(b.y-c.y)-(@y-c.y)*(b.x-c.x)) end Вызвать этот метод можно так: s = a.аrea(b, c). Методом экземпляра (в отличие от ранее рассмотренных ме тодов класса) в классе R2Point является, в частности, метод inside?: мы не задумывались над одним весьма важным вопросом: где должны храниться точки выпуклой оболочки? Первое, что приходит в голову, — массив. В случае реализа ции на языке Ruby такой выбор даже допусти́м, ибо с объектами класса Array «можно сделать почти всё», но в большинстве других языков нельзя, например, удалить из середины массива несколько элементов, заменив их на один новый. Именно та кие действие необходимо выполнить при удалении нескольких освещённых рёбер и добавлении вместо них двух новых: def inside?(a, b) ((a.x <= @x and @x <= b.x) or (a.x >= @x and @x >= b.x)) and ((a.y <= @y and @y <= b.y) or (a.y >= @y and @y >= b.y)) end Для него, как и для любого иного метода экземпляра, опре делено понятие объекта self — того экземпляра класса, от которого этот метод вызывается (например, во время выполнения вызова c.inside?(a,b) объектом self является c). Файл r2point.rb # Точка (Point) на плоскости (R2) class R2Point attr_reader :x, :y # конструктор def initialize(x = input("x"), y = input("y")) @x, @y = x, y end # площадь треугольника (метод класса) def R2Point.area(a, b, c) 0.5*((a.x-c.x)*(b.y-c.y)-(a.y-c.y)*(b.x-c.x)) end # лежат ли точки на одной прямой? (метод класса) def R2Point.triangle?(a, b, c) area(a, b, c) != 0.0 end # расстояние до другой точки def dist(other) Math.sqrt((other.x-@x)**2 + (other.y-@y)**2) end На Ruby подобные действия реализуются с помощью опера тора []= (для простоты в этом примере массив содержит просто целые числа вместо точек плоскости): arr = [0,1,2,3,4] p arr arr[2..3] = 100 p arr Здесь мы предполагаем, что нулевой элемент массива arr, в котором содержатся вершины выпуклой оболочки, соответствует самой нижней вершине на приведённом рисунке. При добавлении новой точки из массива arr удаляются вершины с индексами 2 и 3, на место которых помещается вновь появившаяся вершина. Вот результат работы этой программы: [0, 1, 2, 3, 4] [0, 1, 100, 4] Полезно отметить, что одной операцией присваивания замену нескольких элементов массива на один новый даже в языке # лежит ли точка внутри "стандартного" прямоугольника? Ruby реализовать нельзя, если удаляемые элементы содержатся def inside?(a, b) как в начале, так и в конце массива. Для выпуклой оболочки ((a.x <= @x and @x <= b.x) or на приведённом выше рисунке такая ситуация возникает при (a.x >= @x and @x >= b.x)) and добавлении новой точки снизу от оболочки. ((a.y <= @y and @y <= b.y) or В общем случае (например, при написании программы на (a.y >= @y and @y >= b.y)) языке C) массив для хранения вершин выпуклой оболочки кате end горически не годится. В каком же контейнере следует хранить точки выпуклой оболочки? # освещено ли из данной точки ребро (a,b)? Один контейнер (так называют объекты, предназначенные def light?(a, b) прежде всего для хранения других объектов и различных ма s = R2Point.area(a, b, self) s < 0.0 or (s == 0.0 and !inside?(a, b)) нипуляций с ними) нам уже известен. Это стек, реализующий end дисциплину обслуживания LIFO (Last in — first out, последним пришёл — первым ушёл). # совпадает ли точка с другой? Другим, хорошо известным из обычной жизни контейнером def == (other) является очередь (queue), изображённая на следующем рисунке: @x == other.x and @y == other.y end private def input(prompt) print "#{prompt} -> " readline.to_f end end Контейнеры Дисциплина обслуживания в очереди иная: FIFO (First in — Обсуждая действия, которые необходимо предпринимать для first out, первым пришёл — первым ушёл). Для очереди обычно перевычисления выпуклой оболочки при добавлении новой точки, определяют такой набор основных методов: «добавить элемент 4 в очередь (в конец)», «взять элемент из очереди (из начала)», которые позволяют, соответственно, добавить элемент в конец массива и удалить его оттуда: «получить элемент из начала очереди (не удаляя его)». Ни стек, ни очередь, впрочем, не кажутся подходящими кон # Реализация стека на базе вектора тейнерами для хранения вершин выпуклой оболочки. # (для языка Ruby она тривиальна) Симбиозом стека и очереди является дек (deq, double ended class Stack queue) — двусторонняя очередь, в которой элементы могут и def initialize добавляться и удаляться (обслуживаться) с обоих концов: @array = Array.new end def push(c) @array.push(c) end def pop @array.pop end Оказывается, что именно дек является наиболее подходящим контейнером для хранения точек выпуклой оболочки. Понять это легче всего так: свернём дек в кольцо и будем всегда считать «текущим» то ребро оболочки, которое соединяет начало и конец дека. С этим ребром мы всегда и будем работать, выполняя различные операции: def top @array.last end end Аналогичные действия с началом массива позволяют вы полнить методы unshift и shift. Методы first и last дают возможность получить первый (с индексом 0) и последний (с индексом −1) элементы, не удаляя их. Используя их, легко пред ложить реализации на базе класса Array как очереди, так и дека. Реализация на языке Ruby очереди является совсем простой задачей: # Реализация очереди на основе Array в Ruby # (эта реализация тривиальна) class Queue def initialize @array = Array.new end def enqueue(c) @array.push(c) end def dequeue @array.shift end Взяв элемент из конца дека и поместив его в начало, мы тем самым «поворачиваем» выпуклую оболочку, делая «текущим» новое ребро, имеющее своим началом точку, находящуюся в конце дека, а концом — точку, размещённую в его начале. Что же надо сделать с деком, чтобы удалить все освещённые рёбра? «Повернув» выпуклую оболочку несколько раз, всегда можно добиться того, чтобы концы одного из освещённых рёбер на ходились в конце и начале дека соответственно (если только освещённые рёбра вообще существуют). После этого нужно удалить найденное ребро. Следует удалить также и все другие освещённые рёбра как из начала, так и из конца дека. Затем надо добавить два новых ребра, соединяющих вновь пришедшую точку с концом и началом дека, что реализуется просто добавлением новой точки в начало дека. Заметим, что при выполнении всех этих операций нужно не забывать пересчитывать периметр и площадь выпуклой оболоч ки. Реализация контейнеров O «настоящих» реализациях различных контейнеров речь пойдёт позже. Сейчас же нам нужны реализации на языке Ruby, которые являются чрезвычайно простыми в силу наличия у класса Array нескольких полезных в данном случае методов. При реализации стека мы уже использовали методы push и pop, def first @array.first end end Заметим, что в «классическом» варианте дек не имеет метода size, который позволяет узнать количество элементов, в нём со держащихся. Очень часто, однако, такой метод может оказаться полезным. В нашем случае он тоже нужен. По этой причине мы дополним им реализацию дека, которая будет иметь такой вид: Файл deq.rb # Реализация дека на основе Array в Ruby # (эта реализация тривиальна) class Deq def initialize @array = Array.new end def size @array.size end def push_last(c) @array.push(c) end def push_first(c) @array.unshift(c) end 5 def pop_last @array.pop end def pop_first @array.shift end def last @array.last end def first @array.first end end Тестирование Мы уже знаем, что техника тестирования помогает разработ чикам сделать код их программ лучше. Эта техника начинает помогать ещё до написания кода, потому что сами мысли о тестах естественным образом приводят вас к выбору более правильной организации программы. Она помогает при написании кода, ибо позволяет обнаруживать неточности и ошибки. Наконец, эта техника помогает после того, как код уже напи сан: вы имеете возможность проверить, что код всё ещё работает (после внесения каких-либо модификаций в него или измене ний внешних условий), а другие люди с помощью тестов могут понять, как следует использовать ваш код. Было бы совершенно неправильным немедленно после созда ния классов R2Point и Deq начать их использовать в нашем проекте. Ведь если в них имеются ошибки, то правильно вычис лить выпуклую оболочку, её площадь и периметр заведомо не удастся. Необходимы тесты, подтверждающие правильность ра боты методов, определённых в этих классах. В реальной жизни, как это уже было сказано выше, тесты создаются одновременно с тестируемой программой. Более точно говоря, рекомендуется следующая последователь ность действий при создании кода: • написать определения необходимых методов в создаваемом классе, в которых пока будет отсутствовать какой-либо содержательный код; на этом этапе создаются просто «за глушки»; • написать достаточно полные тесты для каждого из этих методов; • написать код, реализующий необходимую функциональ ность каждого из методов; • добиться безошибочного выполнения всех написанных те стов. Тесты для класса R2Point настоятельно рекомендуется изу чить самым подробным образом. Содержимое файла r2point_spec.rb приведено в гипертек стовых материалах к лекции. Задачи для самостоятельного решения 1. Разберитесь во всех деталях (вплоть до каждого символа) тестов, содержащихся в файле r2point_spec.rb и опишите как именно (и почему именно так) они работают. Добавьте тесты, иллюстрирующие правильность работы тех методов класса R2Point, для которых тесты отсутствуют. 2. Реализуйте класс R3Point, позволяющий работать с точ ками в трёхмерном пространстве. В этом классе должны быть определены все методы, которые имеются в классе R2Point за исключением метода light?, а также методы volume и tetrahedron?, первый из которых вычисляет объ ём тетраэдра, образованного заданными четырьмя точками, а второй выясняет, лежат ли заданные четыре точки в одной плоскости. Решение должно сопровождаться тестами, ил люстрирующими правильность предложенной реализации. 3. Напишите достаточно полные тесты, иллюстрирующие пра вильность реализаций стека, очереди и дека на основе клас са Array, приведённых в гипертекстовых материалах к этой лекции.