Инструменты, алгоритмы и структуры данных

Проектирование и инженерия алгоритма: топологическая сортировка

Разбить на страницы
Показывать лекцию целиком

Одна из тех радостей, что доставляет нам изучение информатики, – это знакомство с замечательными алгоритмами. В этой лекции мы займемся исследованием алгоритма, который обладает рядом достоинств, заслуживающих нашего внимания. Он полезен на практике, он, обладая хорошим математическим базисом, элегантен, он иллюстрирует технику решения задач, применимую в различных контекстах.

Познакомить вас с уже готовым решением неинтересно. Вместо этого мы начнем разрабатывать это решение шаг за шагом, как это делается для всякой научной проблемы. Мы начнем с постановки задачи, перейдем к ее математическому анализу, займемся поиском структуры данных, обеспечивающих корректность и эффективность решения. Мы не только спроектируем алгоритм, но, не останавливаясь на этом, получим полное инженерное решение, удовлетворяющее всем практическим требованиям. В конце лекции уроки этого примера позволят сформулировать некоторые общие принципы разработки алгоритмов и инженерии программ.

10.1. Постановка задачи

Вы решили сегодняшний день посвятить культуре: что может быть лучше, чем посещение Лувра и музея Орсе в Париже? Но для этого вам понадобится карта, и нужно приобрести проездной на метро, поскольку срок старого уже истек, но для проездного нужны деньги, так что придется зайти в банк или воспользоваться банкоматом. Можно выразить эти ограничения следующим образом:

[Map, Louvre], [Map, Orsay], [Pass, Louvre], [Pass, Orsay], [Money, Pass]
    

Здесь каждое ограничение задается парой [x, y], означающей, что "x должно случиться прежде чем y". Это можно отобразить графически:

(рис 10.1) Упорядочение ограничений

Топологической сортировкой множества элементов, подчиняющихся ограничениям порядка, называется перечисление элементов в порядке, удовлетворяющем ограничениям. В данном примере топологическими сортировками являются последовательности:

Money, Pass, Map, Louvre, Orsay
Map, Money, Pass, Orsay, Louvre
Money, Map, Pass, Louvre, Orsay
    

Заметьте: последовательность Pass, Money, Map, Louvre, Orsay некорректна, поскольку нарушается ограничение [Money, Pass].

Задача топологической сортировки может иметь:

  • несколько решений, как в данном примере;
  • в точности одно решение;
  • ни одного решения, как было бы, в случае если и только если ограничения образовывали бы цикл – множество ограничений: $$[e_1,e_2], [e_2, e_3], \ldots [e_n, e_1]$$ для некоторого $$n >= 1$$. Если мы добавим в наш пример ограничение [Orsay, Money], создавая тем самым цикл, то никакого решения больше существовать не будет, так как мы, с одной стороны, требуем, чтобы "утром – стулья, вечером – деньги", а с другой стороны, требуем "деньги вперед".
  • Если существует более одного решения, то возникает проблема выбора одного из них. Обычно с любым решением связывается некоторая стоимость, тогда целью может быть поиск решения с минимальной стоимостью. Вскоре при анализе алгоритма мы увидим, где может применяться этот критерий при поиске нужного решения. Другой подход может состоять в том, чтобы найти все решения.

    Примеры применения

    Задача топологической сортировки возникает всякий раз, когда требуется упорядочить элементы при некоторых ограничениях на их порядок следования. Вот несколько примеров.

  • Для графического дисплея рассмотрим окна – графические прямоугольники, которые могут пересекаться. Нам необходим алгоритм, рисующий эти прямоугольники с учетом их наложения друг на друга. Это задача топологической сортировки. Зададим следующие ограничения: [B, A], [D, A], [D, C], [B, D], [E, C]. Здесь ограничение [x, y] содержательно означает "x не должно скрывать никакую часть y". Возможным решением является последовательность: B, D, E, A, C. (рис 10.2) Прямоугольники с ограничением предшествования
  • При промышленной сборке сложных изделий – турбины или самолета – множество задач по управлению сборкой сопровождается ограничениями, например, структурная работа над элементом должна предшествовать его окраске. Топологическая сортировка дает расписание, совместимое с этими ограничениями.
  • Подобная задача возникает при управлении проектами, особенно при управлении программными проектами. Если проект связан с некоторой проблемной областью, то он должен сопровождаться глоссарием (словарем) технических терминов (непонимание между экспертами проблемной области и разработчиками ПО является главным источником ошибок и провалов). Определение любого термина может ссылаться на другие термины. Термины в словаре могут идти в алфавитном порядке, как принято в словарях, но также полезно иметь версию словаря, которая может читаться в последовательности, где каждый термин объясняется через уже определенные термины. Создание такого словаря предполагает топологическую сортировку.
  • При описании класса вы, возможно, хотели бы, чтобы его компоненты шли в порядке, гарантирующем, что никакой вызов компонента не встречается до его описанияВ некоторых языках программирования таково требование синтаксиса. Но взаимная рекурсия методов, когда А вызывает В, а В вызывает А, приводит к циклу, так что в язык приходится вводить специальные конструкции предварительного объявления (предварительно объявляется В, затем объявляется А, затем дается полное объявление В). .
  • Топологическая сортировка позволяет улучшить эффективность компиляции ОО-программ при реализации наследования и в особенности – динамического связывания, обсуждаемого в последующих лекциях. Проблема актуальна для больших программ со многими классами и состоит в нумерации классов таким образом, чтобы номер, приписанный классу, был близок к номерам классов, являющихся его потомками – классами, которые наследуют от него прямо или косвенно. Используя "наследует от" как отношение порядка, компилятор EiffelStudio, основываясь на топологической сортировке, достигает существенной оптимизации требуемой памяти, что является основой жизнеспособности ОО-подхода.
  • Точки на плоскости

    Вот пример, дающий удобную визуализацию задачи. Рассмотрим точки на плоскости:

    (рис 10.3) Конечное множество точек

    Введем для точек $$p_1$$ с координатами $$(x_1, y_1)$$ и $$p_2$$ с координатами $$(x_2, y_2)$$ отношение $$\ll$$ ("строго меньше"), устанавливающее, что $$p_1 \ll p_2$$, если:

  • $$x_1 \le x_2$$;
  • $$y_1 \le y_2$$;
  • $$p_1 \ne p_2$$ (точки не совпадают).
  • Для четырех точек, показанных на рисунке, справедливо следующее:

    $$a\ll b\qquad\qquad a\ll d\qquad\qquad b\ll d\qquad\qquad c\ll d$$

    Топологическая сортировка для этого отношения дает любое перечисление точек, для которого если p « q, то в списке перечисления p предшествует q. Для наших четырех точек существуют три таких перечисления:

    a, b, c, d
    a, c, b, d
    c, a, b, d
            

    Три возможных обхода точек можно визуализировать:

    (рис 10.4) Три типологических сортировки множества точек

    С другой стороны, перечисление a, d, b, c не совместимо с отношением, поскольку свойство c « d требует, чтобы c предшествовало d.

    10.2. Основы топологической сортировки

    Задача, обсуждаемая в этой главе, имеет точную математическую формулировку.

    Определение: задача топологической сортировки

    Для заданного на конечном множестве ациклического отношения r требуется найти отношение полного порядка, для которого r является подмножеством.

    Это определение использует математические понятия – отношения как множества, ациклические отношения, отношения порядка (полного или частичного), которые теперь предстоит рассмотреть.

    Бинарные отношения

    Определение: отношение

    Отношение на множестве А (для простоты рассматривается только бинарное отношение) задается множеством пар в форме [x, y], где оба элемента пары являются элементами А.

    Примером отношения на множестве {1, 2, 3} является:

    {[1, 2], [1, 3], [2, 3]}
            

    Мы можем назвать это отношением < ("меньше", так как x и y принадлежат множеству {1, 2, 3} и x меньше y).

    Мы можем использовать отношения для описания предыдущих примеров.

  • Отношение below (ниже) на множестве прямоугольников, содержащее все пары прямоугольников [x, y], таких, что в области наложения прямоугольников должны показываться точки y, а не x.
  • Отношение before (прежде) – множество пар {[Map, Louvre], [Map, Orsey], …} – является отношением на множестве {Pass, Money, Map, Louvre, Orsay}, содержащим пары [x, y], такие, что событие x должно произойти раньше y.
  • Отношение used_in ("используется в"), заданное на множестве терминов словаря, содержит все пары [x, y], такие, что определение термина y содержит термин x.
  • Отношение called_by ("вызывается в"), заданное на множестве методов класса, содержит все пары [x, y], такие, что тело метода y содержит вызов метода x.
  • Отношение « над точками – множество пар {[a, b], [a, d], [b, d], [c, d]}.
  • Ациклические отношения

    Все наши примеры были до сих пор ациклическими отношениями. Это понятие определяется следующим образом:

    Определение: ациклическое отношение

    Отношение является ациклическим, если у него нет циклов.

    Это определение следует дополнить.

    Определение: цикл в отношении

    Циклом для отношения r над множеством A является последовательность $$x_1,\;\ldots x_m (m\ge 2)$$ из элементов $$A$$, такая, что все последовательные пары $$[x_i,x_{i+1}]$$ для $$1\le i < m$$ принадлежат $$r$$, и $$x_m = x_1$$.

    Отношение before, введенное ранее, циклов не имеет:

    (рис 10.5) Отношение, описывающее ограничение порядка Простейший случай цикла для отношения r встречается (при m = 2) для элемента x, такого, что [x, x] принадлежит r.

    Для успеха топологической сортировки требуется ациклическое отношение, хотя мы будем рассматривать алгоритм, который может частично обрабатывать отношение, включающее цикл. Если множество, на котором определено отношение, конечно, то ациклическое отношение имеет важное свойство, критичное для алгоритма топологической сортировки.

    Теорема об отсутствии предшественника

    Для любого ациклического отношения r над непустым, конечным множеством А существует элемент x из А, не имеющий предшественника в r.

    Определение: предшественник

    Предшественником элемента y для отношения r является элемент x, такой, что пара [x, y] принадлежит r.

    Теорема доказывается от противного. Предположим противное, что каждый элемент в $$А$$ имеет по меньшей мере одного предшественника. Пусть $$x_1$$ – элемент из $$А$$ (он существует, так как множество не пусто). По предположению, у него есть хоть один предшественник, выберем любой и назовем его $$x_2$$. По тем же причинам есть предшественник $$x_3$$ и у $$x_2$$, так что в $$r$$ существует пара $$[x_3, x_2]$$. Продолжая вывод, следует признать существование бесконечной последовательности $$[x_{i+1}, x_i]$$, принадлежащей $$r$$ для каждого $$i >= 1$$. Поскольку $$А$$ – конечное множество, последовательность должна иметь повторяющиеся элементы. Более точно: у последовательности $$x_1, x_2, \ldots x_{n+1}$$, где $$n$$ – число элементов $$A$$, все элементы не могут быть различными; поэтому должны быть целые $$i$$ и $$j$$, для $$1\le i<j\le n+1$$, такие, что $$x_i = x_j$$, но тогда последовательность $$x_j, x_{j-1}, \ldots x_i$$ является циклом. Пришли к противоречию.

    Доказательство конструктивно, и мы будем его использовать при проектировании алгоритма топологической сортировки. Для выработки перечисления алгоритм будет выискивать на каждой итерации элемент, не имеющий предшественника в оставшемся отношении порядка.

    Предположение о конечности множества $$А$$ существенно. Теорема неприменима к бесконечным множествам, например, отношение "меньше" для целых чисел в математике ациклическое, но каждый его элемент имеет предшественника.

    Отношения порядка

    Идея топологической сортировки встраивает данное ациклическое отношение в отношение полного (тотального) порядка. Для определения этого понятия предварительно определим простое отношение порядка.

    Определение: отношение порядка (строгое, возможно частичное)

    Отношение является отношением порядка, если оно удовлетворяет следующим свойствам для любых элементов x, y, z из множества X, на котором оно определено.
    О1 Антирефлексивность: отношение не имеет пар вида [x,x].
    O2 Транзитивность: из того, что отношению принадлежат пары [x, y] и [y, z], следует, что отношению принадлежит и пара [x, z].

    Такое отношение порядка является также:

    О3 асимметричным: из того, что отношению принадлежит пара [x, y] следует, что отношению не принадлежит пара [y, x].

    (Доказательство. Если бы обе пары принадлежали отношению, то из транзитивности следовала бы рефлексивность – существование пары [x, x], что противоречит свойству антирефлексивности)

    Полное имя для отношения порядка – строгое, возможно частичное отношение порядка. Наши отношения порядка (вскоре мы познакомимся с полным или тотальным отношением) являются строгими, в том же смысле, как отношение < – "строго меньше, чем". Также возможно работать с нестрогими версиями отношения, такими как отношение <= ("меньше или равно").

    Отношение "<" на {1, 2, 3} (или на любом другом множестве целых) является отношением порядка. Таким же является отношение « на точках. Наши другие ациклические отношения – before для задач, used_in для терминов, called_by для методов – антирефлексивны и асимметричны, но они не обязательно транзитивны, так что они не являются отношениями порядка. Вскоре мы увидим, как можно получить транзитивную версию.

    Отношения порядка в сравнении с ациклическими отношениями

    Отношения порядка тесно связаны с ациклическими отношениями. В одном направлении эта связь – прямая.

    Теорема: "Ацикличность и отношение порядка" (1)

    Любое отношение порядка (более того, любое его подмножество) ациклично.

    Доказательство от противного. Предположим, существует цикл $$x_1, x_2, \ldots x_n$$, где $$x_n$$ то же, что и $$x_1$$. По транзитивности это влечет рефлексивность, что противоречит антирефлексивности.

    Это обобщает доказательство асимметрии: невозможность иметь обе пары [x,y] и [y, x]. Такой случай является частным случаем цикла с двумя элементами. Подобно, пара [x, x], нарушающая антирефлексивность, является циклом с одним элементом.

    Здесь еще одно интересное свойство: транзитивное замыкание ациклического отношения является отношением порядка. Неформально транзитивное замыкание отношения представляет собой отношение порядка, полученное применением свойства транзитивности с помощью расширения исходного отношения настолько, насколько это возможно.

    Это может быть проиллюстрировано на отношении before, выражающего ограничения порядка между задачами. Отношение антирефлексивно, асимметрично и ациклично, но оно не транзитивно, поскольку содержит пары [Money, Pass] и [Pass, Louvre], но не содержит пары [Money, Louvre]. Мы можем расширить отношение до транзитивного, добавив все пары вида [x, z], для которых в исходном отношении есть пары [x, y] и [y, z], выполняя эту операцию, пока добавление не перестанет быть возможным. Результатом этого процесса является транзитивное замыкание исходного отношения. Для нашего примера добавятся ровно две связи:

    (рис 10.6) Транзитивное замыкание ограничений порядка

    Для отношения called_by между процедурами транзитивное замыкание является отношением, которое выполняется между x и y, если у вызывает x прямо или косвенно. Для отношения child между людьми, обозначающего множество пар [x, y], таких, что x является потомком y, транзитивное замыкание служит отношением, связывающим двух людей, таких, что один из них является потомком, непосредственным или косвенным – прапраправнуком. Транзитивное замыкание отношения r обозначается как $$r^+$$, так что мы можем полагать, что $$child^+ = descendant$$ (потомок представляет транзитивное замыкание отношения "потомок").

    Определение: транзитивное замыкание отношения

    Транзитивным замыканием отношения $$r$$ на множестве $$А$$ является отношение, содержащее все пары вида $$[x_1, x_m]$$ для некоторой последовательности элементов $$x_1, x_2, \ldots x_m (m >= 2)$$, такой, что пары $$[x_i, x_{i+1}]$$ для $$1 <= i <= m$$ принадлежат $$r$$.

    Транзитивное замыкание высвечивает другую сторону отношения между ацикличностью и порядком.

    Теорема: "Ацикличность и отношение порядка" (2)

    Транзитивное замыкание любого ациклического отношения является отношением порядка.

    Доказательство. Транзитивное замыкание любого отношения $$r$$ очевидно транзитивно. Так что нам остается доказать антирефлексивность. Предположим, что это не так и существует элемент $$x$$, такой, что пара $$[x, x]$$ принадлежит $$r^+$$. По определению транзитивного замыкания существует последовательность $$x_1, x_2, … x_m (m >= 2)$$, такая, что все пары $$[x_i, x_{i+1}]$$ для $$1<= i <= m$$ принадлежат $$r$$ и что $$x_1$$ и $$x_m$$ равны $$x$$. Но это означает существование цикла для $$r$$. Пришли к противоречию.

    Этот результат позволяет нам рассматривать ациклическое отношение как "ядро" отношения порядка. Взятие транзитивного замыкания дает нам настоящее отношение порядка. Это соответствует интуитивному пониманию отношения, например, такого как before между стоящими перед нами задачами, – если задача Money должна предшествовать задаче Pass, а задача Pass должна предшествовать задаче Louvre, то мы понимаем, что задача Money должна предшествовать задаче Louvre. Другими словами, мы инстинктивно принимаем транзитивное замыкание. Но когда готовятся входные данные для задачи расписания или для другой задачи, в которой предполагается топологическая сортировка, то, естественно, хотелось бы перечислить только базисные ограничения, а не полное транзитивное замыкание. Вот почему топологическая сортировка может использовать ациклическое отношение на входе (многие представления топологической сортировки начинают с отношения порядка, но это избыточное требование, более строгое, чем требуется).

    Вычисление транзитивного замыкания является "дорогой" вычислительной операцией, но нам не потребуется выполнять ее явно – алгоритм топологической сортировки будет работать с ациклическим отношением.

    Тотальный порядок

    Для описания выхода топологической сортировки нам понадобится уточнение понятия отношения порядка, приводящее к полному или тотальному отношению порядка. Для конечного множества полный порядок можно было бы рассматривать просто как нумерацию всех элементов множества, в котором каждый элемент появляется один раз. Но концепция полного порядка более широко применима.

    Определение: отношение тотального порядка (строгое)

    Тотальным порядком является отношение порядка, обладающее дополнительным свойством.
    О4 Для любых a и b имеет место одно из следующих свойств:
  • [a, b] принадлежит r;
  • [b, a] принадлежит r;
  • a = b.
  • Для понимания О4 заметьте: из асимметрии (О3) следует, что только одна из первых двух возможностей может существовать. Из антирефлексивности (О1) следует, что последняя возможность исключает две первые. Так что только одна из трех возможностей может существовать. Новое условие добавляет, что одна из трех возможностей должна существовать.

    Отношение "<" на целых является тотальным отношением. Но не каждое отношение порядка является таковым. Отношение "«" на точках плоскости таковым не является, так как это означало бы, что для любых двух различных точек плоскости p1 и p2 должно выполняться p1 « p2 или p2 « p1.

    (рис 10.7) Точки на плоскости и отношение полного порядка

    Заметьте, пара точек [a, c] не принадлежит отношению, так как не верно, что a « c; точно так же неверно, что и c « a. Другим контрпримером является пара точек [b, c].

    На множестве четырех точек можно установить различные отношения порядка, являющиеся полными. В частности, любое перечисление, в котором каждая точка появляется ровно один раз, можно рассматривать как задание полного порядка t. Вот как можно определить этот порядок: пара точек [p, q] принадлежит t, если и только если p предшествует q в перечислении. Например, перечисление [a, b, c, d] определяет полный порядок:

    {[a, b], [a, c], [a, d],                 [1]
        [b, c], [b, d],
                        [c, d]}
            

    Верно и обратное: каждый полный порядок определяет единственную нумерацию.

    Такой тотальный порядок является топологической сортировкой исходного отношения "« ", если и только если тотальный порядок совместим с исходным. Совместимость означает, что когда p « q в исходном порядке, то p предшествует q в тотальном порядке.

    (рис 10.8) Точки на плоскости и отношение «

    Мы уже видели, что три тотальных порядка удовлетворяют этому требованию для нашего примера: соответствующими перечислениями являются a, b, c, d (в примере 10.1 представлено в виде множества пар); a, c, b, d и c, a, b, d.

    Что означает "совместимость", если это понятие рассматривать более точно? Его нетрудно специфицировать, благодаря определению отношения как множества пар. Сказать, что тотальный порядок, заданный перечислением a, b, c, d, совместим с заданным ациклическим отношением, – это все равно, что сказать, что множество пар этого отношения является подмножеством множества пар тотального отношения. Каждая пара в отношении порядка является парой в тотальном отношении порядка. В нашем примере отношение « является множеством пар:

    {[a, b], [a, d], [b, d], [c, d]}             [2]
            

    Это множество действительно является подмножеством множества пар (пример 6.1), задающего тотальный порядок. Это свойство выражает тот факт, что когда ограничение задает некоторый порядок между двумя элементами, то на выходе алгоритм должен перечислять эти элементы в заданном порядке.

    Так вырабатывается определение топологической сортировки, описывающей нашу задачу, как поиск тотального порядка, для которого заданный порядок является подмножеством.

    Ациклические отношения имеют топологическую сортировку.

    Отсутствие циклов является очевидным необходимым условием существования топологической сортировки (тотального порядка, включающего исходное отношение). Но достаточно ли этого? Если мы имеем ациклическое отношение, можем ли мы всегда произвести топологическую сортировку – полный порядок, включающий отношение?

    Справедлива следующая теорема.

    Теорема о топологической сортировке

    Для любого ациклического отношения r над конечным множеством А существует тотальное отношение порядка t над А, такое, что $$r\subseteq t$$

    Для доказательства этой теоремы можно было бы использовать то наблюдение, что $$r\subseteq r^+$$, где $$r^+$$ – это транзитивное замыкание r, и предыдущее доказательство того, что $$r^+$$ является отношением порядка. Этого достаточно, чтобы расширить $$r^+$$ до полного отношения порядка. Но для наших целей более интересно другое доказательство. Это конструктивное доказательство (основанное на теореме об отсутствии предшественника), позволяющее нам непосредственно получить схему алгоритма.

    Доказательство ведется индукцией по числу элементов n множества A. Если n = 0, множество пусто, единственно возможным отношением является пустое отношение (пустое множество пар элементов из A), которое является полным порядком. Это доказывает базис индукции.

    Если вы предпочитаете иное, в качестве базиса можно выбрать случай n = 1, для которого A состоит из одного элемента x. Хотя теперь A не пусто, единственное ациклическое отношение в A снова является пустым отношением, так как единственная пара [x, x], которую можно создать, образует цикл.

    Для индукционного шага предположим, что теорема выполняется над множествами из n элементов, и рассмотрим ациклическое отношение на множествах из n + 1 элементов. Рисунок поясняет идею доказательства: (рис 10.9) Расширение, дополняющее топологическую сортировку

    Теорема об отсутствии предшественника говорит нам, что А имеет по крайней мере один элемент, не имеющий предшественника. Пусть x – такой элемент. Пусть А' – множество элементов А за исключением x. Пусть r' – отношение на А', содержащее все пары из r, за исключением тех, что содержат x. Очевидно, что r' является ациклическим отношением на A'. По предположению индукции, так как A' имеет n элементов, существует полный порядок t' над A', совместимый с r' (это то же самое, что сказать $$r^\prime\subseteq t^\prime$$). Теперь рассмотрим отношение t над A, состоящее из следующих пар:

  • все пары в t';
  • все пары вида [x, y], где y – элемент из A'.
  • Если вы предпочитаете думать о полном порядке как о перечислении, то можно рассматривать t как перечисление элементов A, которое начинается с x и продолжается перечислением A', заданным t'.

    Нетрудно видеть, что t – это полный порядок, и что $$r\subseteq t$$; это дает нам полный порядок, совместимый с r, что и доказывает теорему.

    Теорема о топологической сортировке является математическим обоснованием программы, которую мы собираемся построить. Более того, доказательство непосредственно приводит к основной идее алгоритма.

    10.3. Практические соображения

    С теоретическими основами все ясно, так что можно приступать к поиску инженерного решения. Ядром является алгоритм топологической сортировки, но прежде следует проанализировать ограничения производительности и определить рамки инженерии программы.

    Требования производительности

    Что можно сказать об ожидаемой емкостной и временной сложности алгоритма?

    Входом для алгоритма является множество элементов и множество ограничений. Обозначим через n число элементов, m – число ограничений.

    Алгоритм должен (в случае ациклического отношения) выполнить:

  • по меньшей мере одну операцию для каждого ограничения (так как игнорирование любого из ограничений могло бы привести к ошибочному порядку построения вывода);
  • по меньшей мере одну операцию для каждого элемента, хотя бы для того, чтобы добавить элемент в вывод.
  • Так что наилучшее время, на которое можно надеяться, – это O(n + m).

    Самое удивительное, что разрабатываемый ниже алгоритм топологической сортировки достигает этой нижней теоретической оценки, как по времени, так и по памяти.

    Каркас класса

    Чисто алгоритмическое решение могло бы использовать функцию в форме:

    topologically_sorted (elements:…; constraints:…): LIST[…]
        — Перечисление элементов множества elements
        — в порядке, совместимом с множеством ограничений constraints.
            

    Предполагается, что входные типы позволяют задать множества элементов и ограничений – elements и costraints, соответствующие нашим прежним обозначениям A и r.

    Лучшее решение – как покажет дальнейшая разработка – дает ОО-подход с построением класса TOPOLOGICAL_SORTED, любой экземпляр которого представляет задачу топологической сортировки. Структуры данных, представляющие элементы и ограничения, будут атрибутами класса, формируемые процедурами инициализации, такими как record_element и record_constraint.

    Вместо функции topologically_sorted, как выше, будем иметь в классе:

  • процедуру process, выполняющую процесс топологической сортировки;
  • запрос sorted, который возвращает список элементов, созданный при работе процедуры process.
  • Этот каркас даст нам больше гибкости, и мы получим возможность дополнить его многими полезными свойствами.

    Вход и выход

    Множества элементов A и ограничений r могут поступать из различных источников. Например, мы могли бы иметь файл, перечисляющий ограничения, каждое из которых задается одной строкой файла:

    Map Louvre
    Map Orsay
    Pass Louvre
    Pass Orsay
    Money Pass
            
    (рис 10.10) Элементы и ограничения

    Возможно, было бы полезно иметь отдельный файл, перечисляющий все элементы или, по меньшей мере, элементы, не включенные ни в одно из ограничений (мы не можем догадаться о существовании таких элементов, анализируя ограничения, но они должны быть частью вывода).

    В других условиях ввод мог бы осуществляться интерактивно, используя программу или Web-форму. В примерах, таких как упорядочение прямоугольников, отображаемых на экране, упорядочение терминов словаря или методов класса, формат данных будет различным.

    Для обеспечения общности сделаем наш базовый класс универсальным: TOPOLOGICAL_SORTED[G], где параметр G задает тип элементов. Тогда результат запроса sorted, который обозначает топологически отсортированный список элементов, созданный в process, имеет тип LIST[G]. Две процедуры инициализации, о которых говорилось выше, имеют сигнатуры:

    record_element (e: G)
    record_constraint (e, f: G)
            

    Полная форма алгоритма

    Рассмотрим ациклическое отношение r на множестве элементов A. Так как нам необходимы имена, используемые в программе, то позвольте полагать, не предрешая выбор реализации, что класс TOPOLOGICAL_SORTED имеет доступные запросы elements и constraints. Общая схема для алгоритма топологической сортировки в процедуре process такова:

    from …until elements.is_empty loop
        "Пусть x – элемент без предшественников в ограничениях"
        "Рассматривать x как следующий элемент sorted"
        "Удалить x из множества элементов"
        "Удалить все пары, начинающиеся x, из множества ограничений)"
    end
            

    Уточненная нужным образом эта форма будет работать при условии, что мы начинаем с ациклического отношения (или в специальном случае – с отношения порядка).

    Четыре предложения псевдокода, показанные выше, будут повторяться в оставшейся части лекции.

    Теоремы о топологической сортировке и об отсутствии предшественника дают нам обоснование решения, но его еще нужно выразить в идее инварианта цикла и варианта (надеюсь, цикл без инварианта – для вас ужасная картина, не правда ли?)

    process
            — Выполняет в sorted перечисление элементов множества elements
            — в порядке, совместимом с множеством ограничений constraints.
        require
                — "constraints описывают ациклическое отношение"
        do
            from
                create {….} sorted. make
            invariant
                — "constraints описывают ациклическое отношение"
            until
                elements. is_empty
            loop — Как и ранее, за тем исключением, что явно используется результат sorted:
                "Пусть x – элемент без предшественников в ограничениях"
                sorted. extend (x)
                "Удалить x из множества элементов"
                "Удалить все пары, начинающиеся x, из множества ограничений"
            variant
                elements. count
            end
            ensure
            — "sorted представляет топологическую сортировку elements, согласованную
            — с constraints"
        end
            

    Циклы в ограничениях

    Версия схемы алгоритма, в принципе, корректна, но не подходит для большинства приложений, встречающихся в реальной жизни.

    Проблема в предусловии, требующем, чтобы на вход было подано ациклическое отношение. Программа топологической сортировки получает вход в виде индивидуально упорядоченных пар, например, [Map, Louvre] или [Map, Orsey], как выше. Такой вход может готовиться человеком, а ему свойственно ошибаться, и он имеет право на ошибку, что недопустимо в программе (при рассмотрении сборки промышленных изделий могут встречаться тысячи узлов и десятки тысяч ограничений между ними, задающих порядок сборки).

    В примере с терминами словаря мы не можем надеяться, что не встретятся два или более термина, ссылающихся друг на друга в своих определениях (следовательно, создающих циклы), но у нас нет способа принуждать авторов словаря. Справедливо обратное – автор может требовать от программы: "Упорядочьте в словаре термины так, чтобы определение термина встречалось раньше его использования. Но, кстати, если вы обнаружите взаимные ссылки терминов, сообщите мне о них, чтобы я мог скорректировать определения".

    Аналогично задача упорядочения методов класса, чтобы определение метода предшествовало его вызову, невозможна в случае косвенной рекурсии, хотя это и не является ошибкой. Так что интерес может представлять применение топологической сортировки к нециклической части графа вызовов и получение отчета о любых оставшихся циклах.

    Эти рассмотрения приводят к изменениям контракта метода process, более благосклонного к своим клиентам. Прежний контракт:

    require
                — "constraints описывают ациклическое отношение"
    ensure
                — "sorted представляет топологическую сортировку elements,
                — согласованную с constraints"
            

    заменяется новым, более реалистичным контрактом:

    — (Нет предусловия)
    ensure
                — "sorted представляет топологическую сортировку, согласованную
                — с constraints, для всех членов elements, не входящих в цикл"
            

    Этого все же недостаточно. Класс должен предоставлять своим клиентам отчет о возникающих циклах и указывать, какие элементы в них включены. Одним из способов обеспечения такой функциональности является введение булевской функции.

    has_cycle: BOOLEAN
                — Содержат ли циклы отношение, заданное elements и constraints?
        do …end
            

    Говоря неформально, нужна функция, которая будет возвращать список элементов, включенных в цикл (void, если и только если has_cycle имеет значение false). Концептуально это звучит разумно, но это не лучший способ, так как вычислительно представляет дорогое решение. Нахождение циклов – задача функции has_cycle, фактически является столь же трудной по времени и памяти, как и сама задача топологической сортировки, но если мы попытаемся выполнять топологическую сортировку без предусловия, то без особых затрат можем обнаружить циклы в процессе работы.

    Теорема об отсутствии предшественника подсказывает нам, что цикл можно найти как побочный бонус процесса топологической сортировки.

  • Как следует из приведенного выше цикла, на каждом шаге разыскивается элемент, не имеющий предшественника, и это делается до тех пор, пока множество не станет пустым.
  • Теорема говорит нам, что для ацикличного отношения процесс завершится, то есть на каждом шаге непустого множества такой элемент существует.
  • Если мы не можем найти элемент, не имеющий предшественника, а множество еще не пусто, то это означает, что оставшиеся элементы составляют, по меньшей мере, один цикл. Мы можем завершить алгоритм, отчитавшись, что полная топологическая сортировка невозможна. Это благоприятная форма завершения, так как мы топологически отсортировали элементы, не создающие цикл, и имеем возможность сказать клиенту, какие именно элементы и ограничения создают проблему, – оставшиеся после сортировки.
  • Рассматриваемая далее программа топологической сортировки не имеет предусловия, а ее инвариант цикла также изменен.

    Вместо прежнего инварианта:

    —"constraints описывают ациклическое отношение на elements"
            

    будем теперь использовать его ослабленную форму:

    —"constraints описывают подмножество исходного отношения на elements"
            

    Отсюда следует, что

    —"Любой цикл в constraints присутствует в исходном отношении"
            

    а также:

    —"constraints описывает ациклическое отношение, если исходное отношение ациклично".
            

    Это та основа, которой мы будем придерживаться. Как следствие, мы больше не будем использовать в качестве условия выхода из цикла elements.is_empty, как ранее, так как непустое множество elements не гарантирует больше, что можно корректно выполнить оператор:

    "Пусть x – элемент без предшественников в ограничениях"
            

    В качестве нового условия выхода будем иметь:

    "Нет элементов без предшественников в ограничениях"
            

    Отрицание этого условия означает, что есть по крайней мере один элемент без предшественников, – а это гарантия того, что в теле цикла можно найти очередного кандидата, включаемого в результат выхода.

    Полная организация класса

    Мы можем теперь определить полную форму класса, который будет служить каркасом нашего решения:

    class
        TOPOLOGICAL_SORTER [G –> HASHABLE]
    feature {NONE} — Внутренние структуры данных
                …Смотри следующие разделы этой главы…
    feature — Инициализация
        record_element (a: G)
                — Включить а в множество элементов.
        require
                not_sorted: not done
        do
                …Смотри следующие разделы…
        end
      record_constraint (a, b: G)
        — Включить [a, b] в ограничения.
        require
                not_sorted: not done
        do
                …Смотри следующие разделы…
        end
    feature — Status report
      done: BOOLEAN
                — Выполнена ли топологическая сортировка?
    feature — Element change
      process
                — Выполнить топологическую сортировку над всеми применимыми элементами.
                — Результаты доступны через sorted, cycle-found и cyclists.
        require
                not_sorted: not done
        do
                …Смотри следующие разделы…
        ensure
                sorted: done
        end
    feature — Access
      cycle_found: BOOLEAN
                — Исходное ограничение приводит ли к циклу?
      cyclists: LIST [G]
                — Элементы, включенные в какой-либо цикл.
      sorted: LIST [G]
                — Список из всех элементов, которые допускают упорядочивание,
                — согласованное с ограничениями
    feature — Status setting
        reset
                — Допустить дальнейшее обновление элементов и ограничений.
                do
                    done:= False
                    cycle_found:= False; cyclists:= Void; processed_count:= 0
                ensure
                    fresh: not done
                end
    invariant
        elements_exist: elements /= Void
        constraints_exist: constraints /= Void
        cyclists_only_if_cycle: done implies (cycle_found = (cyclists /= Void))
    end
            
    Компоненты класса были перечислены в порядке, облегчающем последовательное чтение. Порядок, рекомендуемый стандартом, будет восстановлен в окончательной версии класса.

    Класс является универсальным, его родовой параметр G представляет тип элементов. Запись G -> HASHTABLE означает, что мы "ограничиваем" G (понятие, вводимое в следующей главе) типом HASHTABLE. Причина ограничения типа прояснится немного позже.

    Алгоритм будет основываться на внутренних структурах данных, проектируемых в следующих разделах этой главы. Поскольку соответствующие методы создаются для внутреннего использования, они не будут доступны клиентам класса и потому объявлены как feature {NONE}.

    Сразу же после того, как метод process выполнит свою работу, его результаты будут доступны клиентам через несколько связанных запросов.

  • Булевский запрос done, позволяющий клиентам выяснить, выполнена ли топологическая сортировка. При инициализации он получает значение false.
  • Список, формируемый запросом sorted, дает порядок, совместимый со всеми ограничениями, и включает все элементы, не входящие в цикл.
  • Булевский запрос cycle_found показывает, что есть элементы, включенные в какой-либо цикл.

  • Список всех таких элементов, включенных в цикл, представлен запросом cyclists. Предложение инварианта cyclists_only_if_cycle говорит нам, что запрос имеет смысл только при условии истинности cycle_found.
  • Клиент, желающий выполнить топологическую сортировку, обычно будет использовать наш класс следующим образом:

    your_structure: TOPOLOGICAL_SORTER [YOUR_ELEMENT_TYPE]
    …
    create your_structure
    …Вызовы вида your_structure.record_element(x) для записи элементов …
    …и your_structure.record_constraint(x, y) для записи ограничений…
    your_structure. process
    — Теперь становятся доступными результаты топологической сортировки
    — your_structure.sorted
    if your_structure. cycle_found then
        — Теперь становятся доступными элементы, включенные в цикл
        — your_structure.cyclists …
    end
            

    Было бы желательно для согласованности поставлять запросы sorted, cycle_found и cyclists с предусловием done, но мы временно будем опускать предусловие.

    Однако предусловие not done задано для процедур инициализации record_element и record_constraint, так же как и для процедуры топологической сортировки process, имеющей постусловие done. Как результат, попытка повторного вызова процедуры process на одном и том же экземпляре класса будет приводить к ошибке. Конечно, запросы можно вызывать сколь угодно много раз. Процедура reset добавлена на случай, когда вы хотите явно добавлять элементы и ограничения уже после вызова process для подготовки нового его вызова.

    Процедура reset просто устанавливает done равным false, не производя очистки предыдущих элементов и ограничений. Мы можем добавить процедуру forget, которая вызовет reset и очистит структуры данных. Но в этом случае клиенту проще создать новый экземпляр TOPOLOGICAL_SORTER.

    10.4. Базисный алгоритм

    Теперь мы можем приступить к полной реализации ключевой части нашего решения – процедуре process.

    Цикл

    Общий алгоритм этого метода уже написан, осталось адаптировать его с учетом всех сделанных уточнений (ослабление инварианта, использование метода sorted, представляющего результат в TOPOLOGICAL_SORTER). В результате получаем:

    from
            create {…} sorted. make
    until
        "Нет элемента без предшественников"
    Loop
        "Пусть x — элемент без предшественников"
            sorted. extend (x)
        "Удалить x из множества элементов"
        Удалить все ограничения, начинающиеся с x
    end
    if "остался хоть один элемент" then — Отчет о цикле:
        cycle_found:= True
        "Вставьте эти элементы в cyclists"
    end
            

    Все, что осталось, – но не спешите слишком радоваться, главные трудности еще впереди, – это уточнить псевдокод, превратив его в код программный. Заключительная часть (отчет о циклах) будет прямым следствием всего остального. Главная задача состоит в уточнении четырех операторов псевдокода, точнее, трех, поскольку мы можем рассматривать первые два как одну операцию. Сосредоточимся на этих операциях в процессе поиска эффективного алгоритма.

    Топологическая сортировка: базисные операции

    Т1 Найти элемент без предшественников – или отчитаться об отсутствии такового.
    Т2 Для заданного элемента x удалить его из множества элементов.
    Т3 Для заданного элемента x удалить из множества ограничений все ограничения, начинающиеся этим элементом (все пары вида [x, y]).

    Мы должны найти такие представления данных (множеств элементов и ограничений), которые делают эти операции эффективными, насколько это возможно. Структуры данных будут появляться как закрытые компоненты класса в разделах, резервированных для этих целей.

    Приведенная выше схема класса оставила незаполненными тела двух методов класса: add_element и add_constraint. Нам предстоит дополнить методы, основываясь на структурах данных, которые мы сейчас спроектируем.

    "Естественный" выбор структур данных

    Для нашей первой попытки построения структуры данных естественно выбрать представление, которое непосредственно моделирует входные данные в том виде, как они к нам приходят (наш небольшой опыт в изучении программирования говорит, что не следует утверждать, что некоторое решение является "естественным". То, что кажется естественным мне, вовсе может не казаться таковым для вас, а то, что естественно для меня и для вас, может оказаться довольно посредственным решением).

    Чаще всего входные данные могли бы поступать в виде списка элементов и списка ограничений. Мы могли бы использовать атрибуты класса, которые непосредственно отображают эти структуры данных (сделав их закрытыми для клиентов, размещая их в секции feature {NONE}):

    elements: LINKED_LIST [G]
    constraints: LINKED_LIST [TUPLE [G, G]]
            

    В нашем примере структуры данных будут выглядеть подобным образом:

    (рис 10.11) Элементы и ограничения

    Для простоты предполагается, что мы присвоили номера нашим элементам следующим образом (рис 10.12).

    (рис 10.12) Нумерация элементов

    Анализ производительности естественного решения

    Можем ли мы реализовать при таком представлении данных операции Т1, Т2, Т3 и процедуры record_element и record_constaint, и если да, то какова цена решения – сложность по времени и по памяти?

    Две процедуры реализуются непосредственно. Например, для выполнения record_constaint достаточно вызывать стандартный метод:

    constraints. extend ([x, y])
            

    Новая пара [x,y] добавится в конец списка ограничений. Подобным образом record_element(x) реализуется вызовом elements.extend(x).

    При применении этих операторов, как обычно, следует быть уверенным, что объекты elements и constaints имеют значения, отличные от void. Соответствующие операторы создания create могут появляться в форме default_create или вызываться по требованию, когда в них возникает необходимость. Все это справедливо и для других структур данных, рассматриваемых ниже.

    Это представление можно использовать и при реализации других операций. Давайте проанализируем их стоимость, когда у нас есть n элементов и m ограничений.

  • Для поиска элемента без предшественников (T1) можно обойти список ограничений constaints и считать предшественников для каждого элемента, затем обойти список элементов для поиска тех, у кого число предшественников равно нулю. Первый обход потребует $$O(m)$$ операций, второй – $$O(n)$$, и поскольку это нужно делать на каждом шаге, в целом понадобится операций $$O(n^*m + n^2)$$.
  • Удаление элемента (T2) требует на каждом шаге $$O(n)$$ операций, а в целом – $$O(n^2)$$.
  • Удаление ограничений (T3) требует на каждом шаге $$O(m)$$ операций, а в целом – $$O(m^*n)$$. Предполагается, что мы имеем единый список ограничений, а потому для удаления элементов, начинающихся с некоторого заданного $$x$$, потребуется обход всего списка.
  • В практических приложениях следует ожидать, что для каждого элемента задается несколько ограничений, так что $$m > n$$, а потому $$O(m^*n)$$ хуже, чем $$O(n^2)$$. Поскольку в реальных задачах $$n$$ может быть велико, сложность $$O(n^2)$$ представляется неприемлемо высокой.

    Так что наш первый выбор структуры данных дает решение задачи, но его производительность не позволяет построить масштабируемое приложение, пригодное для задач большой размерности.

    Дублирование информации

    К счастью, мы можем найти решение лучшее, чем "естественное". Как показывает опыт, мы не всегда должны использовать структуры данных в том виде, как они к нам приходят. Списки elements и constraints – это непосредственное отражение данных внешнего мира, естественного представления данных человеком, который вводит задачи и описывает ограничения между задачами. Но форма данных, простая и естественная для внешнего мира, вовсе не является лучшей формой для алгоритма, предназначенного для обработки этих данных некоторым специальным образом. Вместо того чтобы слепо следовать форме приходящих данных, алгоритм может начинаться с этапа инициализации, который преобразует данные в формат, наилучшим образом приспособленный к их обработке.

    Следующие структуры данных помогают выполнить работу топологической сортировки – задачи Т1-Т3 – удобно и быстро:

    successors: ARRAY [LINKED_LIST [INTEGER]]
                — Индексами массива являются номера элементов. Для каждого элемента x
                — задается список его непосредственных последователей – элементов y,
                — таких, что есть ограничение [x, y].
    predecessor_count: ARRAY [INTEGER]
                — Индексами массива являются номера элементов.
                — Для каждого элемента x указывается число его непосредственных
                — предшественников.
            

    Следующий рисунок иллюстрирует представление данных для нашего примера.

    (рис 10.13) Число предшественников и список последователей

    На следующем рисунке приведена уже хорошо нам знакомая схема, где введена нумерация элементов. Она поясняет предыдущий рисунок. По схеме ясно, что, например, у задачи 1 предшественников нет, но есть два последователя, а у задачи 5 – один предшественник и те же два последователя (2 и 4).

    (рис 10.14) Знакомая схема с нумерацией элементов

    В этом представлении интересно то, что массив, задающий число предшественников, является избыточным, поскольку всю хранимую в нем информацию можно извлечь из массива последователей. Но ничего ошибочного в таком представлении информации нет, поскольку, как мы увидим, за счет этого дублирования можно добиться существенного улучшения времени вычислений.

    Нахождение компромисса память-время является ключевой составляющей проектирования хорошего алгоритма. Конечно, компромисс должен быть приемлемым. В данном случае наша цель — получить алгоритм с временной сложностью O(m + n), сохраняя ту же сложность и для памяти, требуемой для хранения данных. Такая память нужна для хранения массива successors, добавление массива predecessor_count с емкостью O(n) не меняет емкостной сложности алгоритма в целом.

    Исходные структуры данных – elements и constraints – также требуют памяти O(m + n).

    Украсим алгоритм инвариантом класса

    Удобно для пущей ясности добавить запрос, доступный клиентам:

    count: INTEGER
                 — Число элементов
            

    Также полезно для улучшения читабельности добавить инварианты класса. Два последних предложения выражены неформально:

    elements.count = count
    predecessor_count. count = count
    successors.count = count
    — Для каждого i из интервала 1..count элемент predecessor_count[i]
    — задает число предшественников i-го элемента для заданных ограничений.
    — Для каждого i из интервала 1..count элемент successors[i]
    — задает список всех последователей i-го элемента для заданных ограничений
    — или void, если последователей нет.
            

    Нумерация элементов

    Чтобы использовать массив, нужно некоторым образом пронумеровать наши элементы. Ранее это было сделано в интересах удобства обращения. Теперь это становится необходимостью, связанной с нашим выбором структуры данных.

    Значит ли это, что теперь требуется изменить родовой параметр класса TOPOLOGICAL_SORTER[G], поскольку все манипуляции над элементами будут теперь использовать их целочисленные номера? Абсолютно нет. Остается необходимым для выразительности создать механизм, применимый к элементам любого типа. На практике для этого потребуется хэш-таблица и массив:

    index_of_element: HASH_TABLE [INTEGER, G]
                — Для каждого элемента дает его индекс
    element_of_index: ARRAY [G]
                — Для каждого индекса дает ассоциированный с ним элемент
            

    Элемент хэш-таблицы index_of_element[e] дает целое x – индекс элемента e типа G. В свою очередь, element_of_index[x] = e.

    В обоих случаях используется нотация с квадратными скобками.

    (рис 10.15) Взаимное отображение элементов и индексов

    Обе эти структуры при разумной реализации требуют памяти O(n).

    Для определения хэш-таблицы с элементами типа G требуется, чтобы тип G удовлетворял ограничениям наследования – был потомком класса HASHTABLE и объявлен как G -> HASHTABLE (ограниченная универсальность будет подробно рассматриваться в следующей главе, сейчас же понятно, что элементы должны допускать построение хэш-функции).

    Класс TOPOLOGICAL_SORTER[G -> HASHTABLE] не будет экспортировать компоненты index_of_element и element_of_index, так как они необходимы только для целей реализации, но мы должны позволять клиентам находить элементы, поскольку это является частью общей задачи работы с элементами, поэтому мы будем экспортировать запрос:

    has_element (e: G): BOOLEAN
                — Является ли e одним из элементов топологической сортировки?
        do
                Result:= index_of_element. has (e)
        ensure
                consistent: Result = index_of_element.has (e) and then
                        index_of_element [e] >= 1 and then
                        index_of_element [e] <= element_of_index. count and then
                        element_of_index [index_of_element [e]] = e
    end
            

    Убедитесь, что вы понимаете постусловие.

    Давайте теперь докажем, что новые структуры данных позволяют добиться нашей цели – времени O(m + n). Необходимо рассмотреть два аспекта: выполнение операций Т1-Т3 и затраты на инициализацию – создание структур данных predecessor_count и successors. Оба аспекта важны. Если бы для новых структур данных операции Т1-Т3 выполнялись бы за время O(m + n), но для создания структур требовалось бы $$O(m^*n)$$ времени, то в целом никакого выигрыша мы бы не получили.

    Представляется, что несложно реализовать построение требуемых структур данных за время O(m + n). Обрабатывая последовательно каждое ограничение [x, y], зная y, можно увеличить на 1 значение соответствующего элемента predecessor_count, а зная x, можно добавить последователя y в список соответствующего элемента successors. Обе операции выполняются за константное время. Опуская детали, будем считать далее, что построение структур данных за время O(m + n) возможно и сосредоточимся на рассмотрении операций Т1-Т3.

    Базисные операции

    Начнем с Т3: "Для заданного элемента x удалить из множества ограничений все ограничения, начинающиеся этим элементом (все пары вида [x, y])". Если мы знаем номер x, то выполнить удаление совсем просто.

    L1 Нам больше не нужен список последователей элемента x. Мы можем очистить этот список простым присваиванием successors[x] = Void. На практике и присваивание не нужно, так как алгоритм никогда не посетит вход x этого массива. Но даже если и выполнять эту операцию, то она выполняется за время O(1) для одного элемента и O(n) для всех элементов. Прекрасно!
    L2 Нам требуется также обновить соответствующие элементы в массиве predecessor_count. Для каждого последователя y элемента x нужно уменьшить predecessor_count[y] на 1, так как в связи с удалением ограничения уменьшается число предшественников, и для выполнения операции потребуется обход списка, связанного с элементом x. Это будет делаться непосредственно в цикле, чей код появится ниже. Процесс будет выполняться самое большее один раз за всю обработку для каждого ограничения, так что он потребует времени O(m). Снова прекрасно!

    В целом операция Т3 требует времени O(m + n) в худшем случае.

    Вся только что описанная обработка сопровождается инвариантами класса, выражающими тот факт, что массивы predecessor_count и successors в полной мере отражают структуру оставшихся ограничений отношения.

    Рассмотрим теперь операцию Т2: "Для заданного элемента x удалить его из множества элементов". Фактически, для нашей новой структуры данных нам ничего не нужно делать. Выполняя Т3, мы уже позаботились обо всем, что нужно было сделать, удаляя ограничение, начинающееся с x. Отлично!

    Осталась операция T1: "Найти элемент без предшественников – или уведомить об отсутствии такового". Для этого достаточно выполнить обход массива predecessor_count и найти элемент со значением 0. Но для этого понадобится время O(n), а для всех элементов $$O(n^2)$$. Это плохо!

    Нам недостает еще одной структуры данных!

    Кандидаты

    Нам не избежать O(n) обхода массива predecessor_count при инициализации (предложения from нашего главного цикла) для поиска первоначальных кандидатов – элементов без предшественников в исходном отношении. Если только не каждый элемент включен в цикл, то такие элементы найдутся. Это потребует O(n) времени, но выполнить эту операцию придется лишь один раз, так что пока все хорошо. По ходу процесса топологической сортировки обнаружение кандидатов на удаление – элементов без предшественников – можно получить в качестве побочного эффекта выполняемой операции Т3. Действительно, на этапе L2 мы уменьшаем на 1 число предшественников. Если при этом это число становится равным нулю, то найден новый кандидат. Если ранее этап L2 можно было бы записать в виде:

        — Уменьшить на единицу число предшественников y:
    predecessor_count [y]:= predecessor_count [y] – 1
            

    то теперь соответствующий код будет выглядеть так:

      — Уменьшить на единицу число предшественников y
      — и проверить, не становится ли y кандидатом:
    predecessor_count [y]:= predecessor_count [y] – 1
                [3]
    if predecessor_count [y] = 0 then
                "Записать, что y не имеет предшественников"
    end
            

    "Записать, что y не имеет предшественников" может быть реализовано как добавление в структуру candidates, которая будет заполняться при инициализации и пополняться по ходу обработки. Ее элементами являются еще не обработанные элементы, не имеющие предшественников. Какую конкретную структуру следует выбрать для candidates? Для алгоритма топологической сортировки точный выбор не имеет значения. Важно лишь, чтобы эта структура поддерживала следующие 5 операций.

    feature — Access
        item: G
                    — Получить ранее вставленный элемент.
          require
                    not_empty: not is_empty
    feature — Measurement
        count: INTEGER
                    — Число элементов.
          ensure
                    non_negative: Result >= 0
    feature — Status report
        is_empty: BOOLEAN
                    — Пуста ли структура?
          ensure
                    definition: Result = (count = 0)
    feature –- Element change
        put (x: G)
                    — Вставить элемент x.
          ensure
                    one_more: count = old count + 1
        remove: G
                    — Удалить прочитанный элемент.
          require
                    not_empty: not is_empty
          ensure
                    one_fewer: count = old count – 1
            

    Структуры данных с такими свойствами называются распределителями.

    (рис 10.16) Распределитель

    Как вы помните, основная идея распределителя в том, что не вы задаете, какой элемент будет получен и удален из распределителя, – стратегия задается типом распределителя. Стеки характеризуются политикой LIFO, очереди – FIFO.

    Для топологической сортировки любой распределитель будет выполнять нужную работу. Выбор определенного вида влияет на фактический порядок, в котором будут выводиться элементы (решений, совместимых с ограничениями, может быть несколько). Так что это тот рычаг, которым можно управлять, оптимизируя результат по некоторому критерию. Он позволяет рассматривать семейство алгоритмов топологической сортировки.

    Мы можем рассматривать распределитель кандидатов в виде:

    candidates: PRIORITY_QUEUE [INTEGER]
                — Элементы без предшественников, готовые к удалению
                — Дополнительное предложение для инварианта:
                — predecessor_count[x] = 0 для каждого элемента x из массива candidates
            

    Реализация распределителя стеком или очередью (STACK или QUEUE) допустима, но очередь с приоритетами является более общим видом, где каждый элемент может сопровождаться приоритетом. В такой очереди элементы отсортированы по приоритетам, и операции item и remove выполняются для элемента с наибольшим приоритетом. Стек и очередь – это специальные случаи, когда приоритет задается порядком поступления элементов. Распределитель PRIORITY_QUEUE позволяет вам, играя приоритетами, управлять политикой выбора.

    Цикл, заключительный вид

    Мы можем теперь выписать главный цикл алгоритма топологической сортировки – тело процедуры process со всеми деталями. Операторы псевдокода для наглядности сохраним и в этой версии как комментарии. Процедура должна объявить локальные переменные x и y типа INTEGER и x_successors типа LIST[INTEGER], сохраняющего последователей конкретного элемента. Мы также добавим целочисленную переменную processed_count, используемую далее, чтобы сохранять историю того, как много элементов уже обработано.

    from
        create sorted. make
        find_initial_candidates — Смотри далее
    invariant
        — "Структуры данных представляют подмножество исходных элементов
        — и соответствующее подмножество исходного отношения"
    until
        candidates.is_empty
    loop
                            — "Пусть x – элемент без предшественников в ограничениях"
        x:= candidates.item; candidates.remove
        sorted.extend (element_of_index [x])
                            — "Удалим x и все пары в ограничениях, начинающиеся с x"
        x_successors:= successors [x] — список
        from x_successors.start until x_successors. after loop
                y:= x_successors. item
                                    — Следующие несколько строчек взяты из [3]:
                predecessor_count [ y]:= predecessor_count [y] – 1
                if predecessor_count [ y] = 0 then
                                    — "Записать, что теперь y не имеет предшественников"
                            candidates. put (y)
                end
                x_successors. forth end
                processed_count:= processed_count + 1
    variant
                count – processed_count
    end
    report_cycles — Смотри далее
    done:= True
            

    Этот алгоритм предполагает, что массивы predecessor_count и successors правильно сформированы, как и должно быть перед любым вызовом process. Детали инициализации появятся чуть позже.

    Процедура find_initial_candidates должна наполнить распределитель candidates элементами, изначально не имеющими предшественников. Она реализуется просто:

    find_initial_candidates
                    — Поместите в массив элементы без предшественников.
        local
                    x: INTEGER
        do
            if candidates = Void then create candidates end
            from x:= 1 until x > count loop
                    if predecessor_count [x] = 0 then
                            candidates. put (x)
                    end
                    x:= x + 1
            end
        end
            

    Это обход за время O(n). Без введения такого массива пришлось бы выполнять такой обход на каждом шаге цикла. Теперь достаточно выполнить его один раз в самом начале работы.

    Не является ошибкой, если в процедуре не будут найдены элементы, удовлетворяющие условию predecessor_count[x] = 0. Это просто означает, что структура candidates пуста, что цикл завершится незамедлительно и что все элементы включены в какой-либо цикл.

    Процедура process по завершении цикла должна выполнить еще одну важную часть работы – она должна уведомить клиента о циклах, встречающихся в отношении. Это и делается при вызове процедуры report_cycles. Чтобы ее реализовать, предварительно заметим, что цикл завершается, когда в массиве candidates не остается элементов. Если исходное отношение было ациклическим, то будут обработаны все элементы, так что можно использовать введенную ранее переменную processed_count, чтобы понять, остались ли элементы, и, если да, то сколько их:

    report_cycles
                — Сделать информацию о циклах доступной клиентам.
        do
                if processed_count < count then
                                        — В исходном отношении есть цикл!!
                        cycle_found:= True
                        create {LINKED_LIST [G]} cyclists. make
                        from x:= 1 until x > count loop
                                if predecessor_count [x] /= 0 then
                                        — x включен в цикл
                                cyclists. extend (element_of_index [x])
                                x:= x + 1
                        end
                end
        end
            

    Инициализация и время ее выполнения

    Мы достигли эффективной реализации за время O(m + n) ядра топологической сортировки – ее основного цикла. Этому способствовали три структуры данных, специально спроектированные для этих целей – массивы predecessor_count и successors и распределитель candidates. Дополняя работу, следует убедиться, что инициализация не нарушает требуемых ограничений на время работы.

    Инициализация должна выполнять:

  • record_element(e) для каждого элемента – всего n раз;
  • record_constraint(e, f) для каждого ограничения – всего m раз.
  • Работа record_element(e) состоит в том, чтобы присвоить номер элементу e, так чтобы в дальнейшей работе можно было бы использовать целые, а не сами элементы, имеющие тип G.

    (рис 10.17) Массив и хеш-таблица

    Это делается согласованным заполнением массива element_of_index и хэш-таблицы index_of_element, задающих взаимное отображение:

    record_element (e: G)
            — Добавить e в множество элементов, если там его еще нет.
        require
            not_sorted: not done
        do
            if not has_element (e) then
                    count:= count + 1
                    index_of_element. extend (count, e)
                    element_of_index. force (e, count)
            —extend и force расширяют структуры при необходимости; это означает,
            —что нам не требуется знать, сколь много элементов может появиться.
            end
        ensure
                    inserted: has_element (e)
                    one_more: not (old has_element (e)) implies (count = old count + 1)
        end
            

    Начальный тест должен убеждать, что процедура игнорирует повторную попытку вставки данного элемента. Эта политика позволяет record_constraint(e, f) стартовать, вызывая record_element как на e, так и на f, просто для того, чтобы убедиться, что элементы вставлены надлежащим образом. В упражнении вас попросят найти способ, позволяющий избежать дублирования работы между has_element и extend.

    (рис 10.18) Добавление ограничения

    При подходящей реализации extend и force код процедуры record_element выполняется за O(1), что для всех элементов дает время O(n). Это согласуется с нашими требованиями.

    Оставшийся механизм инициализации дается процедурой для ввода ограничений. Вызов record_constraint(e, f) должен увеличивать на 1 число предшественников f в массиве predecessor_count и добавлять f в список последователей e. Этот список является одним из элементов массива successors:

    Вот текст процедуры:

    record_constraint (e, f: G)
          — Добавить ограничение [e, f]
        require
          not_sorted: not done
          exist: e /= Void and f /= Void
        local
          x, y: INTEGER
        do
          — Убедиться, что e и f вставлены (нет эффекта, если они уже там присутствуют):
          record_element (e); record_element (f )
          x:= index_of_element [e]
          y:= index_of_element [f ]
          predecessor_count [ y]:= predecessor_count [ y] + 1
          add_successor (x, y)
    ensure
          both_there: has_element (e) and has_element (f)
    end
            

    Дополнительная процедура, которую можно не экспортировать:

    add_successor (x, y: INTEGER)
          — Запись y как последователя x.
        require
          1 <= x; x <= count
          1 <= y; y <= count
        local
          x_successors: LINKED_LIST [INTEGER]
        do
          x _successors:= successors [x]
    — Список последователей для x может быть еще не создан:
          if x_successors = Void then
                          create x_successors. make
                          successors [x]:= x_successors
          end
          x_successors. extend (y)
        end
            

    Как уже отмечалось, работа record_constraint начинается с двух вызовов record_element для пары аргументов, задаваемых в ограничении. Из-за способа проектирования record_element эффекта от вызова не будет, если элементы уже присутствовали. Эта политика делает возможным для клиентского приложения начинать непосредственно со списка ограничений, никогда явно не обращаясь к записи элементов.

    Мы не можем, однако, полагать, что так будет всегда, и удалить record_element из интерфейса, доступного клиенту. Для некоторых вариантов задачи, о чем ранее говорилось, в исходном множестве элементов могут встречаться такие элементы, которые не входят ни в одно из ограничений. Понятно, что после топологической сортировки множества они являются полноценными участниками вывода. Для таких вариантов ввод должен включать помимо списка ограничений и список элементов, по крайней мере, тех, что не входят в ограничения.

    Говоря о дублировании, заметим, что процедура record_constraint не пытается определить, встречалось ли уже вводимое ограничение. Можете убедиться, что алгоритм сортировки корректно будет работать и в случае, когда ограничение повторяется. Применение другой политики, запрещающей дублирование, возлагается на клиента, ответственного за ввод данных.

    Вернемся к эффективности. Код каждой из двух дополнительных процедур выполняется за время O(1): доступ к элементу массива, запись в конец списка во втором случае (при хорошей организации списка с курсором в конце операция выполняется за константное время). Так что время работы record_constraint также задается O(1), а поскольку процедура должна отработать для каждого ограничения, в целом получаем O(m). Таким образом, достигнута наша цель: получить алгоритм, выполняющийся за время O(m + n) как на этапе инициализации, так и при выполнении основной задачи – топологической сортировки.

    Собираем все вместе

    Мы уже познакомились со всеми элементами нашей программы, необходимыми для реализации топологической сортировки. Класс, построенный в полном соответствии с данным обсуждением, доступен в EiffelBase и используется в Traffic, но, полагаю, для проверки вашего понимания концепций следует самостоятельно написать его реализацию, собирая все вместе.

    Время программирования!

    Реализация топологической сортировки

    Напишите класс TOPOLOGICAL_SORTER, обеспечивающий универсальную, практичную топологическую сортировку.

    Убедитесь, что решение отвечает принципам инженерии программ, не только обеспечивает эффективный алгоритм, но и включает процедуры инициализации (record_element, record_constraint). Для тестирования решения используйте файл, доступный на сайте, который связан с курсом. Этот файл содержит несколько сотен ограничений и все возможные варианты топологической сортировки.

    10.5. Уроки

    Из алгоритма топологической сортировки можно вывести важные следствия, общезначимые для проектирования алгоритмов и для программной инженерии в целом.

    Интерпретация в сравнении с компиляцией

    Мы уже рассматривали два стиля выполнения программ, написанных на некотором языке программирования S.

  • Интерпретация. Написать программу, называемую Интерпретатором, которая может выполнять произвольную S-программу для произвольных входных данных.
  • Компиляция. Написать программу, называемую Компилятором, которая может преобразовать произвольную S-программу в программу с эквивалентной семантикой, записанную на целевом языке T. Если T – это машинный язык для применяемой платформы, то результат компиляции может быть непосредственно выполнен. В противном случае T-программа может быть интерпретирована или подвергнута дальнейшей компиляции.
  • (рис 10.19) Интерпретация и компиляция программ

    Как отмечалось, практическая реализация языков часто комбинирует оба подхода.

    Эти концепции обобщаются на многие проблемные области, отличающиеся от выполнения программ, которые написаны на языках высокого уровня. Для выполнения некоторой обработки на некотором входе мы можем использовать структуры данных, которые непосредственно отображают вход, или можем разбить процесс обработки на два этапа.

  • Компиляция: преобразовать данные в форму, более подходящую для целей алгоритма.
  • Интерпретация: применять требуемые операции к исходной структуре данных.
  • Этот прием (который, как и при обработке языков, может и в общем случае включать несколько итераций процесса) в точности был применен для топологической сортировки. Вначале мы рассмотрели интерпретационное решение, которое использует кажущиеся естественными структуры данных, непосредственно следуемые из постановки задачи. Но оказалось, что эти структуры приводят к "плохому" по производительности решению. "Компиляция" их в представление, подогнанное под наши цели, привело к решению с прекрасной производительностью.

    В таком двухэтапном решении шаг "компиляции", инициализирующий структуры данных, может быть столь же сложным для проектирования, как и фактическая обработка, основанная на этих структурах, – он может требовать столь же много времени, а иногда и больше, чем время самой обработки. Этот подход хорош, если общая производительность соответствует вашим целям, но, конечно же, нельзя перепрыгивать к заключению о производительности алгоритма, не учитывая время инициализации, а не только самой обработки.

    (рис 10.20) "Интерпретация" и "компиляция" данных

    Подход может быть обобщен в виде эвристики – универсальной стратегии, подобной "образцу проектирования", но в существенно более абстрактной форме.

    Почувствуй эвристику

    Вначале компилируй данные!

    Хорошие алгоритмы часто создаются с использованием двухэтапной стратегии, где:

  • на первом этапе преобразуется вход из его исходной формы во внутренние структуры, тщательно спроектированные и подогнанные под цели задачи;
  • на втором шаге обрабатываются преобразованные данные для получения конечного результата.
  • Мы увидим еще одно применение этой идеи при обсуждении архитектуры процесса проектирования, управляемого событиями.

    Компромисс "время-память"

    Выбора тяжко бремя – память иль время!

    Тесно связанным с эвристикой "Вначале компилируй данные" является наблюдение, что идеальная структура данных, та, которая наилучшим способом помогает реализации второго шага, часто не является наиболее экономичным по памяти представлением информации. В топологической сортировке информация об ограничениях может сохраняться в трех различных частях. Ограничение [x, y] ведет к тому, что y появится также в списке последователей x в массиве successors; некоторая информация хранится в массиве predecessor_count[y]. Если для заданного y ограничения отсутствуют, то это приводит к добавлению элемента в массив candidates. При таком представлении нам приходится жертвовать памятью для достижения требуемой по времени производительности. Компромиссы такого вида в ту или иную сторону – ключевые моменты проектирования эффективных алгоритмов.

    Алгоритмы в сравнении с программами и компонентами

    Вполне возможно было бы привести описание алгоритма топологической сортировки, которое игнорирует многие из аспектов, рассмотренных в этой лекции, концентрируясь только на процессе сортировки. Для практического решения необходимо принимать в расчет практические потребности приложений. ОО-подход позволяет нам действовать в соответствии с этими целями. Вместо написания процедуры топологической сортировки мы проектируем класс TOPOLOGICAL_SORTER. Экземпляр этого класса характеризует задачу топологической сортировки, предоставляя не только алгоритм ее решения (процедуру process), но и весь инструментарий, необходимый клиенту для того, чтобы он мог:

  • поставить задачу, записав элементы и ограничения удобным для себя способом;
  • применить process для выполнения топологической сортировки заданных элементов, удовлетворяющих заданным ограничениям;
  • запросить данные о результирующем состоянии, выяснить, существуют ли циклы, и если да, то получить информацию об элементах, входящих в цикл.
  • Разница между этим подходом и чистым алгоритмом является частью разницы между программной инженерией и чистым программированием. В инженерии программ недостаточно спроектировать умный алгоритм и связанные с ним структуры данных. Целью становится подготовка решения, которое может быть интегрировано в успешные программные системы.

    Наши решения должны допускать повторное использование, так что они не просто представляют "образцы проектирования", которые программист может интегрировать в свою систему, купив и прочитав книги (особенно выдающиеся книги, такие как эта, например). Решения должны быть реализованы как компоненты, доступные всегда и для всех, непосредственно доступные, а не стоящие на полках.

    Вы также обратили внимание на то, как в этом процессе контракты позволяют нам точно знать, что нам следует делать, – что мы ожидаем, что гарантируется и что мы поддерживаем.

    10.6. Ключевые концепции этой лекции

  • Топологическая сортировка является перечислением множества элементов, которое совместимо с множеством ограничений порядка, заданного на этих элементах.
  • Задача имеет простое математическое описание: задано (строгое) отношение порядка, требуется найти полное отношение, чьим подмножеством является исходное отношение.
  • На практике отношение обычно является не отношением порядка, а в лучшем случае ациклическим отношением. Взятие транзитивного замыкания дает отношение порядка.
  • Реалистичное, хорошее инженерное решение должно принимать, возможно, ошибочный вход, содержащий циклы. Оно должно выполнять топологическую сортировку ацикличной части исходного отношения и дополнительно выдать информацию об элементах, входящих в цикл.
  • Такое решение должно предоставлять не только алгоритм топологической сортировки, но и механизмы построения экземпляра задачи с вводом элементов и ограничений.
  • Для n элементов и m ограничений топологическую сортировку можно выполнить за O(m + n) время, используя память того же порядка.
  • Ключом эффективности алгоритма является построение структур данных, специально приспособленных для решения задачи сортировки:
  • массив, который для каждого элемента исходного множества содержит список его последователей;
  • массив, который для каждого элемента исходного множества содержит число его предшественников;
  • распределитель (стек, список или очередь с приоритетами), который содержит множество элементов, не имеющих предшественников.
  • На примере задачи сортировки показано, что хорошее алгоритмическое решение часто получается путем "компиляции" исходных данных задачи в специально спроектированные структуры данных, которые затем могут быть эффективно "интерпретируемы".
  • Новый словарь

    Acyclic Ациклический Antisymmetric Антисимметричность
    Asymmetric Асимметричность Binary relation Бинарное отношение
    Cycle Цикл Irreflexive Иррефлексивность
    Order relation Отношение порядка Partial order Частичный порядок
    Relation Отношение Strict order Строгий порядок
    Topological sort Топологическая сортировка Total order Тотальный или полный порядок
    Transitive closure Транзитивное замыкание

    10.7. Приложение. Терминологические замечания об отношениях порядка

    Для обсуждения топологической сортировки удобно – как показано в этой лекции – использовать отношение строгого порядка: "строго меньше, чем". Классическим примером такого отношения является отношение "<", заданное на числах. В некоторых задачах может быть более полезным иметь дело с нестрогим отношением, как отношение "<=" для чисел. Пара элементов связана нестрогим отношением x <= y, если и только если либо x < y, либо x = y. Общепринятое соглашение для "отношения порядка" предполагает нестрогую версию. В этой главе, так как мы использовали только строгое отношение порядка, слово "строгое" обычно опускалось, так что "порядок" означал "строгий порядок".

    Для отношения строгого порядка (иррефлексивного и транзитивного) в литературе иногда используется термин "квазипорядок". Конечно, каждый может дать любое имя понятию, задав его точное определение, но это конкретное имя неудачно, так как ничего "квази" в таком порядке нет. Если и есть что-нибудь характерное для элементов в этом порядке, то это то, что они "более" упорядочены, чем для случая нестрогого порядка, поскольку имеет место иррефлексивность, запрещающая элементу находиться в отношении с самим собой. Чтобы совсем запутать дело, некоторые авторы используют термин "квазипорядок" для отношений, которые являются рефлексивными и транзитивными. Так что лучше этот термин "квази" не использовать и вместо этого квалифицировать отношение порядка как "строгое", когда это необходимо.

    Еще одна проблема: является ли отношение тотальным или нет. Тотальность означает, что для любых пар различных элементов x и y имеет место одно из двух – либо x < y, либо y < x. Отношение порядка, удовлетворяющее этому свойству, называется тотальным порядком. Частичным порядком должно было бы называться отношение, которое не удовлетворяет этому свойству, такое, что для него найдется по крайней мере одна пара различных элементов, для которой ни [x, y], ни [y, x] не принадлежат отношению. Но не так определяется частичный порядок в большинстве литературных источников – там частичный порядок определяется как такой порядок, о котором неизвестно, является ли он тотальным. Другими словами, это отношение возможно частичного порядка. Это приводит к путанице, так как теперь тотальный порядок является также частичным порядком! Поэтому лучше писать так, как принято в этой лекции:

  • тотальный порядок – в тех случаях, когда известно, что он тотальный;
  • частичный порядок – для отношения порядка, о котором известно, что порядок не является тотальным.
  • Если же мы не знаем, каков порядок, или хотим включить оба случая – лучше применять термин "порядок". В случае неопределенности –использовать термин "возможно частичный порядок".

    10.8. Упражнения

    10.8.1. Словарь

    Дайте точные определения символам словаря.

    10.8.2. Иррефлексивность и асимметричность

    Отношение порядка было определено как транзитивное и иррефлексивное. Было доказано, что следствием является асимметричность отношения. Докажите, что если отношение определяется как транзитивное и асимметричное, то следствием является иррефлексивность.

    10.8.3. Тотальный порядок и перечисление

    Докажите, что если отношение r является отношением строгого тотального порядка на конечном множестве, то существует единственное перечисление элементов, такое, что для любых элементов x и y элемент x появится перед y в перечислении, если и только если пара [x, y] принадлежит r.

    10.8.4. Строгое отношение в сравнении с нестрогим отношением порядка

    Обсуждение в этой лекции строилось на строгом отношении порядка (частичном или полном), таком, как отношение "<" на числах – "меньше чем". Также возможно использовать нестрогое отношение порядка, такое, как "≤" – "меньше или равно". Определение строгого частичного порядка, которое мы будем называть отношением "<", хотя оно и не должно быть обычным отношением на числах, должно удовлетворять свойствам:

    О1 иррефлексивности: x < x не выполняется для любого x.
    О2 транзитивности: из того, что x < y и y < z, следует x < z.

    Для этого отношения также выполняется свойство:

    О3 асимметричности: не могут одновременно иметь место x < y и y < x.

    Свойство О3 является следствием двух предыдущих свойств. Для частичного нестрогого отношения порядка "≤" имеют место три независимых свойства:

    N1 рефлексивности: x≤x для любого x.
    N2 транзитивности: из того, что x≤y и y≤z, следует x≤z.
    N3 антисимметричности: из того, что x≤y и y≤x, следует x = y.

    Для любого частичного строгого отношения порядка "<" существует связанное с ним нестрогое отношение "≤", определенное как

    x ≤ y если и только если: x < y или x = y           [4]
            

    Справедливо и обратное утверждение: для любого частичного строгого отношения порядка "≤" существует связанное с ним строгое отношение "<", определенное как

    x < y если и только если: x ≤ y и x ≠ y            [5]
            

    В этом упражнении требуется исследовать связь между этими ассоциированными отношениями – строгим "<" и нестрогим "≤"

  • Докажите, что если "<" является частичным строгим отношением порядка, то "≤", определенное в примере 10.4, является частичным нестрогим отношением порядка.
  • Докажите, что если "≤" является частичным нестрогим отношением порядка, то "<", определенное в примере 10.5, является частичным строгим отношением порядка.
  • В строгом случае определение требует выполнения только двух свойств: иррефлексивности и транзитивности. Третье свойство асимметричности является следствием двух остальных. В нестрогом случае требуется выполнение всех трех свойств. Докажите, что антисимметричность не является следствием двух остальных. Другими словами, приведите пример нестрогого отношения, для которого первые два свойства выполняются, а антисимметричность не имеет места.
  • Докажите, что замена "рефлексивности" на "иррефлексивность" в определении нестрогого порядка приводит к определению строгого порядка.
  • Приводит ли замена "иррефлексивности" на "рефлексивность" в определении строгого порядка к определению нестрогого порядка?
  • 10.8.5. Ацикличность и отношение тотального порядка

    Докажите теорему о топологической сортировке, используя вторую теорему об ацикличности и отношениях порядка.

    10.8.6. Интересное постусловие

    Объясните постусловие функции has_element.

    10.8.7. Оптимизация использования хэш-таблицы

    В процедуре record_element проверяется, присутствует ли уже вставляемый элемент в таблице index_of_element, и запись осуществляется только в случае, когда такого элемента нет. В реализации используются две операции поиска – одна при вызове has_element, другая при вызове extend. Проанализируйте контрактную форму класса HASH_TABLE, найдите нужные компоненты, перепишите процедуры так, чтобы устранить эту небольшую неэффективность.

    10.8.8. Программирование топологической сортировки

    Постройте класс TOPOLOGICAL_SORTER в соответствии с обсуждением этой лекции.

    10.8.9. Параметризация топологической сортировки

    (Это упражнение предполагает, что выполнено предыдущее)

    Расширьте реализацию топологической сортировки, позволяя клиентам задавать стратегию выбора элемента из множества соревнующихся "кандидатов".

    Страницы:

    Одна из тех радостей, что доставляет нам изучение информатики, – это знакомство с замечательными алгоритмами. В этой лекции мы займемся исследованием алгоритма, который обладает рядом достоинств, заслуживающих нашего внимания. Он полезен на практике, он, обладая хорошим математическим базисом, элегантен, он иллюстрирует технику решения задач, применимую в различных контекстах.

    Познакомить вас с уже готовым решением неинтересно. Вместо этого мы начнем разрабатывать это решение шаг за шагом, как это делается для всякой научной проблемы. Мы начнем с постановки задачи, перейдем к ее математическому анализу, займемся поиском структуры данных, обеспечивающих корректность и эффективность решения. Мы не только спроектируем алгоритм, но, не останавливаясь на этом, получим полное инженерное решение, удовлетворяющее всем практическим требованиям. В конце лекции уроки этого примера позволят сформулировать некоторые общие принципы разработки алгоритмов и инженерии программ.

    10.1. Постановка задачи

    Вы решили сегодняшний день посвятить культуре: что может быть лучше, чем посещение Лувра и музея Орсе в Париже? Но для этого вам понадобится карта, и нужно приобрести проездной на метро, поскольку срок старого уже истек, но для проездного нужны деньги, так что придется зайти в банк или воспользоваться банкоматом. Можно выразить эти ограничения следующим образом:

    [Map, Louvre], [Map, Orsay], [Pass, Louvre], [Pass, Orsay], [Money, Pass]
        

    Здесь каждое ограничение задается парой [x, y], означающей, что "x должно случиться прежде чем y". Это можно отобразить графически:

    (рис 10.1) Упорядочение ограничений

    Топологической сортировкой множества элементов, подчиняющихся ограничениям порядка, называется перечисление элементов в порядке, удовлетворяющем ограничениям. В данном примере топологическими сортировками являются последовательности:

    Money, Pass, Map, Louvre, Orsay
    Map, Money, Pass, Orsay, Louvre
    Money, Map, Pass, Louvre, Orsay
        

    Заметьте: последовательность Pass, Money, Map, Louvre, Orsay некорректна, поскольку нарушается ограничение [Money, Pass].

    Задача топологической сортировки может иметь:

  • несколько решений, как в данном примере;
  • в точности одно решение;
  • ни одного решения, как было бы, в случае если и только если ограничения образовывали бы цикл – множество ограничений: $$[e_1,e_2], [e_2, e_3], \ldots [e_n, e_1]$$ для некоторого $$n >= 1$$. Если мы добавим в наш пример ограничение [Orsay, Money], создавая тем самым цикл, то никакого решения больше существовать не будет, так как мы, с одной стороны, требуем, чтобы "утром – стулья, вечером – деньги", а с другой стороны, требуем "деньги вперед".
  • Если существует более одного решения, то возникает проблема выбора одного из них. Обычно с любым решением связывается некоторая стоимость, тогда целью может быть поиск решения с минимальной стоимостью. Вскоре при анализе алгоритма мы увидим, где может применяться этот критерий при поиске нужного решения. Другой подход может состоять в том, чтобы найти все решения.

    Примеры применения

    Задача топологической сортировки возникает всякий раз, когда требуется упорядочить элементы при некоторых ограничениях на их порядок следования. Вот несколько примеров.

  • Для графического дисплея рассмотрим окна – графические прямоугольники, которые могут пересекаться. Нам необходим алгоритм, рисующий эти прямоугольники с учетом их наложения друг на друга. Это задача топологической сортировки. Зададим следующие ограничения: [B, A], [D, A], [D, C], [B, D], [E, C]. Здесь ограничение [x, y] содержательно означает "x не должно скрывать никакую часть y". Возможным решением является последовательность: B, D, E, A, C. (рис 10.2) Прямоугольники с ограничением предшествования
  • При промышленной сборке сложных изделий – турбины или самолета – множество задач по управлению сборкой сопровождается ограничениями, например, структурная работа над элементом должна предшествовать его окраске. Топологическая сортировка дает расписание, совместимое с этими ограничениями.
  • Подобная задача возникает при управлении проектами, особенно при управлении программными проектами. Если проект связан с некоторой проблемной областью, то он должен сопровождаться глоссарием (словарем) технических терминов (непонимание между экспертами проблемной области и разработчиками ПО является главным источником ошибок и провалов). Определение любого термина может ссылаться на другие термины. Термины в словаре могут идти в алфавитном порядке, как принято в словарях, но также полезно иметь версию словаря, которая может читаться в последовательности, где каждый термин объясняется через уже определенные термины. Создание такого словаря предполагает топологическую сортировку.
  • При описании класса вы, возможно, хотели бы, чтобы его компоненты шли в порядке, гарантирующем, что никакой вызов компонента не встречается до его описанияВ некоторых языках программирования таково требование синтаксиса. Но взаимная рекурсия методов, когда А вызывает В, а В вызывает А, приводит к циклу, так что в язык приходится вводить специальные конструкции предварительного объявления (предварительно объявляется В, затем объявляется А, затем дается полное объявление В). .
  • Топологическая сортировка позволяет улучшить эффективность компиляции ОО-программ при реализации наследования и в особенности – динамического связывания, обсуждаемого в последующих лекциях. Проблема актуальна для больших программ со многими классами и состоит в нумерации классов таким образом, чтобы номер, приписанный классу, был близок к номерам классов, являющихся его потомками – классами, которые наследуют от него прямо или косвенно. Используя "наследует от" как отношение порядка, компилятор EiffelStudio, основываясь на топологической сортировке, достигает существенной оптимизации требуемой памяти, что является основой жизнеспособности ОО-подхода.
  • Точки на плоскости

    Вот пример, дающий удобную визуализацию задачи. Рассмотрим точки на плоскости:

    (рис 10.3) Конечное множество точек

    Введем для точек $$p_1$$ с координатами $$(x_1, y_1)$$ и $$p_2$$ с координатами $$(x_2, y_2)$$ отношение $$\ll$$ ("строго меньше"), устанавливающее, что $$p_1 \ll p_2$$, если:

  • $$x_1 \le x_2$$;
  • $$y_1 \le y_2$$;
  • $$p_1 \ne p_2$$ (точки не совпадают).
  • Для четырех точек, показанных на рисунке, справедливо следующее:

    $$a\ll b\qquad\qquad a\ll d\qquad\qquad b\ll d\qquad\qquad c\ll d$$

    Топологическая сортировка для этого отношения дает любое перечисление точек, для которого если p « q, то в списке перечисления p предшествует q. Для наших четырех точек существуют три таких перечисления:

    a, b, c, d
    a, c, b, d
    c, a, b, d
            

    Три возможных обхода точек можно визуализировать:

    (рис 10.4) Три типологических сортировки множества точек

    С другой стороны, перечисление a, d, b, c не совместимо с отношением, поскольку свойство c « d требует, чтобы c предшествовало d.

    10.2. Основы топологической сортировки

    Задача, обсуждаемая в этой главе, имеет точную математическую формулировку.

    Определение: задача топологической сортировки

    Для заданного на конечном множестве ациклического отношения r требуется найти отношение полного порядка, для которого r является подмножеством.

    Это определение использует математические понятия – отношения как множества, ациклические отношения, отношения порядка (полного или частичного), которые теперь предстоит рассмотреть.

    Бинарные отношения

    Определение: отношение

    Отношение на множестве А (для простоты рассматривается только бинарное отношение) задается множеством пар в форме [x, y], где оба элемента пары являются элементами А.

    Примером отношения на множестве {1, 2, 3} является:

    {[1, 2], [1, 3], [2, 3]}
            

    Мы можем назвать это отношением < ("меньше", так как x и y принадлежат множеству {1, 2, 3} и x меньше y).

    Мы можем использовать отношения для описания предыдущих примеров.

  • Отношение below (ниже) на множестве прямоугольников, содержащее все пары прямоугольников [x, y], таких, что в области наложения прямоугольников должны показываться точки y, а не x.
  • Отношение before (прежде) – множество пар {[Map, Louvre], [Map, Orsey], …} – является отношением на множестве {Pass, Money, Map, Louvre, Orsay}, содержащим пары [x, y], такие, что событие x должно произойти раньше y.
  • Отношение used_in ("используется в"), заданное на множестве терминов словаря, содержит все пары [x, y], такие, что определение термина y содержит термин x.
  • Отношение called_by ("вызывается в"), заданное на множестве методов класса, содержит все пары [x, y], такие, что тело метода y содержит вызов метода x.
  • Отношение « над точками – множество пар {[a, b], [a, d], [b, d], [c, d]}.
  • Ациклические отношения

    Все наши примеры были до сих пор ациклическими отношениями. Это понятие определяется следующим образом:

    Определение: ациклическое отношение

    Отношение является ациклическим, если у него нет циклов.

    Это определение следует дополнить.

    Определение: цикл в отношении

    Циклом для отношения r над множеством A является последовательность $$x_1,\;\ldots x_m (m\ge 2)$$ из элементов $$A$$, такая, что все последовательные пары $$[x_i,x_{i+1}]$$ для $$1\le i < m$$ принадлежат $$r$$, и $$x_m = x_1$$.

    Отношение before, введенное ранее, циклов не имеет:

    (рис 10.5) Отношение, описывающее ограничение порядка Простейший случай цикла для отношения r встречается (при m = 2) для элемента x, такого, что [x, x] принадлежит r.

    Для успеха топологической сортировки требуется ациклическое отношение, хотя мы будем рассматривать алгоритм, который может частично обрабатывать отношение, включающее цикл. Если множество, на котором определено отношение, конечно, то ациклическое отношение имеет важное свойство, критичное для алгоритма топологической сортировки.

    Теорема об отсутствии предшественника

    Для любого ациклического отношения r над непустым, конечным множеством А существует элемент x из А, не имеющий предшественника в r.

    Определение: предшественник

    Предшественником элемента y для отношения r является элемент x, такой, что пара [x, y] принадлежит r.

    Теорема доказывается от противного. Предположим противное, что каждый элемент в $$А$$ имеет по меньшей мере одного предшественника. Пусть $$x_1$$ – элемент из $$А$$ (он существует, так как множество не пусто). По предположению, у него есть хоть один предшественник, выберем любой и назовем его $$x_2$$. По тем же причинам есть предшественник $$x_3$$ и у $$x_2$$, так что в $$r$$ существует пара $$[x_3, x_2]$$. Продолжая вывод, следует признать существование бесконечной последовательности $$[x_{i+1}, x_i]$$, принадлежащей $$r$$ для каждого $$i >= 1$$. Поскольку $$А$$ – конечное множество, последовательность должна иметь повторяющиеся элементы. Более точно: у последовательности $$x_1, x_2, \ldots x_{n+1}$$, где $$n$$ – число элементов $$A$$, все элементы не могут быть различными; поэтому должны быть целые $$i$$ и $$j$$, для $$1\le i<j\le n+1$$, такие, что $$x_i = x_j$$, но тогда последовательность $$x_j, x_{j-1}, \ldots x_i$$ является циклом. Пришли к противоречию.

    Доказательство конструктивно, и мы будем его использовать при проектировании алгоритма топологической сортировки. Для выработки перечисления алгоритм будет выискивать на каждой итерации элемент, не имеющий предшественника в оставшемся отношении порядка.

    Предположение о конечности множества $$А$$ существенно. Теорема неприменима к бесконечным множествам, например, отношение "меньше" для целых чисел в математике ациклическое, но каждый его элемент имеет предшественника.

    Отношения порядка

    Идея топологической сортировки встраивает данное ациклическое отношение в отношение полного (тотального) порядка. Для определения этого понятия предварительно определим простое отношение порядка.

    Определение: отношение порядка (строгое, возможно частичное)

    Отношение является отношением порядка, если оно удовлетворяет следующим свойствам для любых элементов x, y, z из множества X, на котором оно определено.
    О1 Антирефлексивность: отношение не имеет пар вида [x,x].
    O2 Транзитивность: из того, что отношению принадлежат пары [x, y] и [y, z], следует, что отношению принадлежит и пара [x, z].

    Такое отношение порядка является также:

    О3 асимметричным: из того, что отношению принадлежит пара [x, y] следует, что отношению не принадлежит пара [y, x].

    (Доказательство. Если бы обе пары принадлежали отношению, то из транзитивности следовала бы рефлексивность – существование пары [x, x], что противоречит свойству антирефлексивности)

    Полное имя для отношения порядка – строгое, возможно частичное отношение порядка. Наши отношения порядка (вскоре мы познакомимся с полным или тотальным отношением) являются строгими, в том же смысле, как отношение < – "строго меньше, чем". Также возможно работать с нестрогими версиями отношения, такими как отношение <= ("меньше или равно").

    Отношение "<" на {1, 2, 3} (или на любом другом множестве целых) является отношением порядка. Таким же является отношение « на точках. Наши другие ациклические отношения – before для задач, used_in для терминов, called_by для методов – антирефлексивны и асимметричны, но они не обязательно транзитивны, так что они не являются отношениями порядка. Вскоре мы увидим, как можно получить транзитивную версию.

    Отношения порядка в сравнении с ациклическими отношениями

    Отношения порядка тесно связаны с ациклическими отношениями. В одном направлении эта связь – прямая.

    Теорема: "Ацикличность и отношение порядка" (1)

    Любое отношение порядка (более того, любое его подмножество) ациклично.

    Доказательство от противного. Предположим, существует цикл $$x_1, x_2, \ldots x_n$$, где $$x_n$$ то же, что и $$x_1$$. По транзитивности это влечет рефлексивность, что противоречит антирефлексивности.

    Это обобщает доказательство асимметрии: невозможность иметь обе пары [x,y] и [y, x]. Такой случай является частным случаем цикла с двумя элементами. Подобно, пара [x, x], нарушающая антирефлексивность, является циклом с одним элементом.

    Здесь еще одно интересное свойство: транзитивное замыкание ациклического отношения является отношением порядка. Неформально транзитивное замыкание отношения представляет собой отношение порядка, полученное применением свойства транзитивности с помощью расширения исходного отношения настолько, насколько это возможно.

    Это может быть проиллюстрировано на отношении before, выражающего ограничения порядка между задачами. Отношение антирефлексивно, асимметрично и ациклично, но оно не транзитивно, поскольку содержит пары [Money, Pass] и [Pass, Louvre], но не содержит пары [Money, Louvre]. Мы можем расширить отношение до транзитивного, добавив все пары вида [x, z], для которых в исходном отношении есть пары [x, y] и [y, z], выполняя эту операцию, пока добавление не перестанет быть возможным. Результатом этого процесса является транзитивное замыкание исходного отношения. Для нашего примера добавятся ровно две связи:

    (рис 10.6) Транзитивное замыкание ограничений порядка

    Для отношения called_by между процедурами транзитивное замыкание является отношением, которое выполняется между x и y, если у вызывает x прямо или косвенно. Для отношения child между людьми, обозначающего множество пар [x, y], таких, что x является потомком y, транзитивное замыкание служит отношением, связывающим двух людей, таких, что один из них является потомком, непосредственным или косвенным – прапраправнуком. Транзитивное замыкание отношения r обозначается как $$r^+$$, так что мы можем полагать, что $$child^+ = descendant$$ (потомок представляет транзитивное замыкание отношения "потомок").

    Определение: транзитивное замыкание отношения

    Транзитивным замыканием отношения $$r$$ на множестве $$А$$ является отношение, содержащее все пары вида $$[x_1, x_m]$$ для некоторой последовательности элементов $$x_1, x_2, \ldots x_m (m >= 2)$$, такой, что пары $$[x_i, x_{i+1}]$$ для $$1 <= i <= m$$ принадлежат $$r$$.

    Транзитивное замыкание высвечивает другую сторону отношения между ацикличностью и порядком.

    Теорема: "Ацикличность и отношение порядка" (2)

    Транзитивное замыкание любого ациклического отношения является отношением порядка.

    Доказательство. Транзитивное замыкание любого отношения $$r$$ очевидно транзитивно. Так что нам остается доказать антирефлексивность. Предположим, что это не так и существует элемент $$x$$, такой, что пара $$[x, x]$$ принадлежит $$r^+$$. По определению транзитивного замыкания существует последовательность $$x_1, x_2, … x_m (m >= 2)$$, такая, что все пары $$[x_i, x_{i+1}]$$ для $$1<= i <= m$$ принадлежат $$r$$ и что $$x_1$$ и $$x_m$$ равны $$x$$. Но это означает существование цикла для $$r$$. Пришли к противоречию.

    Этот результат позволяет нам рассматривать ациклическое отношение как "ядро" отношения порядка. Взятие транзитивного замыкания дает нам настоящее отношение порядка. Это соответствует интуитивному пониманию отношения, например, такого как before между стоящими перед нами задачами, – если задача Money должна предшествовать задаче Pass, а задача Pass должна предшествовать задаче Louvre, то мы понимаем, что задача Money должна предшествовать задаче Louvre. Другими словами, мы инстинктивно принимаем транзитивное замыкание. Но когда готовятся входные данные для задачи расписания или для другой задачи, в которой предполагается топологическая сортировка, то, естественно, хотелось бы перечислить только базисные ограничения, а не полное транзитивное замыкание. Вот почему топологическая сортировка может использовать ациклическое отношение на входе (многие представления топологической сортировки начинают с отношения порядка, но это избыточное требование, более строгое, чем требуется).

    Вычисление транзитивного замыкания является "дорогой" вычислительной операцией, но нам не потребуется выполнять ее явно – алгоритм топологической сортировки будет работать с ациклическим отношением.

    Тотальный порядок

    Для описания выхода топологической сортировки нам понадобится уточнение понятия отношения порядка, приводящее к полному или тотальному отношению порядка. Для конечного множества полный порядок можно было бы рассматривать просто как нумерацию всех элементов множества, в котором каждый элемент появляется один раз. Но концепция полного порядка более широко применима.

    Определение: отношение тотального порядка (строгое)

    Тотальным порядком является отношение порядка, обладающее дополнительным свойством.
    О4 Для любых a и b имеет место одно из следующих свойств:
  • [a, b] принадлежит r;
  • [b, a] принадлежит r;
  • a = b.
  • Для понимания О4 заметьте: из асимметрии (О3) следует, что только одна из первых двух возможностей может существовать. Из антирефлексивности (О1) следует, что последняя возможность исключает две первые. Так что только одна из трех возможностей может существовать. Новое условие добавляет, что одна из трех возможностей должна существовать.

    Отношение "<" на целых является тотальным отношением. Но не каждое отношение порядка является таковым. Отношение "«" на точках плоскости таковым не является, так как это означало бы, что для любых двух различных точек плоскости p1 и p2 должно выполняться p1 « p2 или p2 « p1.

    (рис 10.7) Точки на плоскости и отношение полного порядка

    Заметьте, пара точек [a, c] не принадлежит отношению, так как не верно, что a « c; точно так же неверно, что и c « a. Другим контрпримером является пара точек [b, c].

    На множестве четырех точек можно установить различные отношения порядка, являющиеся полными. В частности, любое перечисление, в котором каждая точка появляется ровно один раз, можно рассматривать как задание полного порядка t. Вот как можно определить этот порядок: пара точек [p, q] принадлежит t, если и только если p предшествует q в перечислении. Например, перечисление [a, b, c, d] определяет полный порядок:

    {[a, b], [a, c], [a, d],                 [1]
        [b, c], [b, d],
                        [c, d]}
            

    Верно и обратное: каждый полный порядок определяет единственную нумерацию.

    Такой тотальный порядок является топологической сортировкой исходного отношения "« ", если и только если тотальный порядок совместим с исходным. Совместимость означает, что когда p « q в исходном порядке, то p предшествует q в тотальном порядке.

    (рис 10.8) Точки на плоскости и отношение «

    Мы уже видели, что три тотальных порядка удовлетворяют этому требованию для нашего примера: соответствующими перечислениями являются a, b, c, d (в примере 10.1 представлено в виде множества пар); a, c, b, d и c, a, b, d.

    Что означает "совместимость", если это понятие рассматривать более точно? Его нетрудно специфицировать, благодаря определению отношения как множества пар. Сказать, что тотальный порядок, заданный перечислением a, b, c, d, совместим с заданным ациклическим отношением, – это все равно, что сказать, что множество пар этого отношения является подмножеством множества пар тотального отношения. Каждая пара в отношении порядка является парой в тотальном отношении порядка. В нашем примере отношение « является множеством пар:

    {[a, b], [a, d], [b, d], [c, d]}             [2]
            

    Это множество действительно является подмножеством множества пар (пример 6.1), задающего тотальный порядок. Это свойство выражает тот факт, что когда ограничение задает некоторый порядок между двумя элементами, то на выходе алгоритм должен перечислять эти элементы в заданном порядке.

    Так вырабатывается определение топологической сортировки, описывающей нашу задачу, как поиск тотального порядка, для которого заданный порядок является подмножеством.

    Ациклические отношения имеют топологическую сортировку.

    Отсутствие циклов является очевидным необходимым условием существования топологической сортировки (тотального порядка, включающего исходное отношение). Но достаточно ли этого? Если мы имеем ациклическое отношение, можем ли мы всегда произвести топологическую сортировку – полный порядок, включающий отношение?

    Справедлива следующая теорема.

    Теорема о топологической сортировке

    Для любого ациклического отношения r над конечным множеством А существует тотальное отношение порядка t над А, такое, что $$r\subseteq t$$

    Для доказательства этой теоремы можно было бы использовать то наблюдение, что $$r\subseteq r^+$$, где $$r^+$$ – это транзитивное замыкание r, и предыдущее доказательство того, что $$r^+$$ является отношением порядка. Этого достаточно, чтобы расширить $$r^+$$ до полного отношения порядка. Но для наших целей более интересно другое доказательство. Это конструктивное доказательство (основанное на теореме об отсутствии предшественника), позволяющее нам непосредственно получить схему алгоритма.

    Доказательство ведется индукцией по числу элементов n множества A. Если n = 0, множество пусто, единственно возможным отношением является пустое отношение (пустое множество пар элементов из A), которое является полным порядком. Это доказывает базис индукции.

    Если вы предпочитаете иное, в качестве базиса можно выбрать случай n = 1, для которого A состоит из одного элемента x. Хотя теперь A не пусто, единственное ациклическое отношение в A снова является пустым отношением, так как единственная пара [x, x], которую можно создать, образует цикл.

    Для индукционного шага предположим, что теорема выполняется над множествами из n элементов, и рассмотрим ациклическое отношение на множествах из n + 1 элементов. Рисунок поясняет идею доказательства: (рис 10.9) Расширение, дополняющее топологическую сортировку

    Теорема об отсутствии предшественника говорит нам, что А имеет по крайней мере один элемент, не имеющий предшественника. Пусть x – такой элемент. Пусть А' – множество элементов А за исключением x. Пусть r' – отношение на А', содержащее все пары из r, за исключением тех, что содержат x. Очевидно, что r' является ациклическим отношением на A'. По предположению индукции, так как A' имеет n элементов, существует полный порядок t' над A', совместимый с r' (это то же самое, что сказать $$r^\prime\subseteq t^\prime$$). Теперь рассмотрим отношение t над A, состоящее из следующих пар:

  • все пары в t';
  • все пары вида [x, y], где y – элемент из A'.
  • Если вы предпочитаете думать о полном порядке как о перечислении, то можно рассматривать t как перечисление элементов A, которое начинается с x и продолжается перечислением A', заданным t'.

    Нетрудно видеть, что t – это полный порядок, и что $$r\subseteq t$$; это дает нам полный порядок, совместимый с r, что и доказывает теорему.

    Теорема о топологической сортировке является математическим обоснованием программы, которую мы собираемся построить. Более того, доказательство непосредственно приводит к основной идее алгоритма.

    10.3. Практические соображения

    С теоретическими основами все ясно, так что можно приступать к поиску инженерного решения. Ядром является алгоритм топологической сортировки, но прежде следует проанализировать ограничения производительности и определить рамки инженерии программы.

    Требования производительности

    Что можно сказать об ожидаемой емкостной и временной сложности алгоритма?

    Входом для алгоритма является множество элементов и множество ограничений. Обозначим через n число элементов, m – число ограничений.

    Алгоритм должен (в случае ациклического отношения) выполнить:

  • по меньшей мере одну операцию для каждого ограничения (так как игнорирование любого из ограничений могло бы привести к ошибочному порядку построения вывода);
  • по меньшей мере одну операцию для каждого элемента, хотя бы для того, чтобы добавить элемент в вывод.
  • Так что наилучшее время, на которое можно надеяться, – это O(n + m).

    Самое удивительное, что разрабатываемый ниже алгоритм топологической сортировки достигает этой нижней теоретической оценки, как по времени, так и по памяти.

    Каркас класса

    Чисто алгоритмическое решение могло бы использовать функцию в форме:

    topologically_sorted (elements:…; constraints:…): LIST[…]
        — Перечисление элементов множества elements
        — в порядке, совместимом с множеством ограничений constraints.
            

    Предполагается, что входные типы позволяют задать множества элементов и ограничений – elements и costraints, соответствующие нашим прежним обозначениям A и r.

    Лучшее решение – как покажет дальнейшая разработка – дает ОО-подход с построением класса TOPOLOGICAL_SORTED, любой экземпляр которого представляет задачу топологической сортировки. Структуры данных, представляющие элементы и ограничения, будут атрибутами класса, формируемые процедурами инициализации, такими как record_element и record_constraint.

    Вместо функции topologically_sorted, как выше, будем иметь в классе:

  • процедуру process, выполняющую процесс топологической сортировки;
  • запрос sorted, который возвращает список элементов, созданный при работе процедуры process.
  • Этот каркас даст нам больше гибкости, и мы получим возможность дополнить его многими полезными свойствами.

    Вход и выход

    Множества элементов A и ограничений r могут поступать из различных источников. Например, мы могли бы иметь файл, перечисляющий ограничения, каждое из которых задается одной строкой файла:

    Map Louvre
    Map Orsay
    Pass Louvre
    Pass Orsay
    Money Pass
            
    (рис 10.10) Элементы и ограничения

    Возможно, было бы полезно иметь отдельный файл, перечисляющий все элементы или, по меньшей мере, элементы, не включенные ни в одно из ограничений (мы не можем догадаться о существовании таких элементов, анализируя ограничения, но они должны быть частью вывода).

    В других условиях ввод мог бы осуществляться интерактивно, используя программу или Web-форму. В примерах, таких как упорядочение прямоугольников, отображаемых на экране, упорядочение терминов словаря или методов класса, формат данных будет различным.

    Для обеспечения общности сделаем наш базовый класс универсальным: TOPOLOGICAL_SORTED[G], где параметр G задает тип элементов. Тогда результат запроса sorted, который обозначает топологически отсортированный список элементов, созданный в process, имеет тип LIST[G]. Две процедуры инициализации, о которых говорилось выше, имеют сигнатуры:

    record_element (e: G)
    record_constraint (e, f: G)
            

    Полная форма алгоритма

    Рассмотрим ациклическое отношение r на множестве элементов A. Так как нам необходимы имена, используемые в программе, то позвольте полагать, не предрешая выбор реализации, что класс TOPOLOGICAL_SORTED имеет доступные запросы elements и constraints. Общая схема для алгоритма топологической сортировки в процедуре process такова:

    from …until elements.is_empty loop
        "Пусть x – элемент без предшественников в ограничениях"
        "Рассматривать x как следующий элемент sorted"
        "Удалить x из множества элементов"
        "Удалить все пары, начинающиеся x, из множества ограничений)"
    end
            

    Уточненная нужным образом эта форма будет работать при условии, что мы начинаем с ациклического отношения (или в специальном случае – с отношения порядка).

    Четыре предложения псевдокода, показанные выше, будут повторяться в оставшейся части лекции.

    Теоремы о топологической сортировке и об отсутствии предшественника дают нам обоснование решения, но его еще нужно выразить в идее инварианта цикла и варианта (надеюсь, цикл без инварианта – для вас ужасная картина, не правда ли?)

    process
            — Выполняет в sorted перечисление элементов множества elements
            — в порядке, совместимом с множеством ограничений constraints.
        require
                — "constraints описывают ациклическое отношение"
        do
            from
                create {….} sorted. make
            invariant
                — "constraints описывают ациклическое отношение"
            until
                elements. is_empty
            loop — Как и ранее, за тем исключением, что явно используется результат sorted:
                "Пусть x – элемент без предшественников в ограничениях"
                sorted. extend (x)
                "Удалить x из множества элементов"
                "Удалить все пары, начинающиеся x, из множества ограничений"
            variant
                elements. count
            end
            ensure
            — "sorted представляет топологическую сортировку elements, согласованную
            — с constraints"
        end
            

    Циклы в ограничениях

    Версия схемы алгоритма, в принципе, корректна, но не подходит для большинства приложений, встречающихся в реальной жизни.

    Проблема в предусловии, требующем, чтобы на вход было подано ациклическое отношение. Программа топологической сортировки получает вход в виде индивидуально упорядоченных пар, например, [Map, Louvre] или [Map, Orsey], как выше. Такой вход может готовиться человеком, а ему свойственно ошибаться, и он имеет право на ошибку, что недопустимо в программе (при рассмотрении сборки промышленных изделий могут встречаться тысячи узлов и десятки тысяч ограничений между ними, задающих порядок сборки).

    В примере с терминами словаря мы не можем надеяться, что не встретятся два или более термина, ссылающихся друг на друга в своих определениях (следовательно, создающих циклы), но у нас нет способа принуждать авторов словаря. Справедливо обратное – автор может требовать от программы: "Упорядочьте в словаре термины так, чтобы определение термина встречалось раньше его использования. Но, кстати, если вы обнаружите взаимные ссылки терминов, сообщите мне о них, чтобы я мог скорректировать определения".

    Аналогично задача упорядочения методов класса, чтобы определение метода предшествовало его вызову, невозможна в случае косвенной рекурсии, хотя это и не является ошибкой. Так что интерес может представлять применение топологической сортировки к нециклической части графа вызовов и получение отчета о любых оставшихся циклах.

    Эти рассмотрения приводят к изменениям контракта метода process, более благосклонного к своим клиентам. Прежний контракт:

    require
                — "constraints описывают ациклическое отношение"
    ensure
                — "sorted представляет топологическую сортировку elements,
                — согласованную с constraints"
            

    заменяется новым, более реалистичным контрактом:

    — (Нет предусловия)
    ensure
                — "sorted представляет топологическую сортировку, согласованную
                — с constraints, для всех членов elements, не входящих в цикл"
            

    Этого все же недостаточно. Класс должен предоставлять своим клиентам отчет о возникающих циклах и указывать, какие элементы в них включены. Одним из способов обеспечения такой функциональности является введение булевской функции.

    has_cycle: BOOLEAN
                — Содержат ли циклы отношение, заданное elements и constraints?
        do …end
            

    Говоря неформально, нужна функция, которая будет возвращать список элементов, включенных в цикл (void, если и только если has_cycle имеет значение false). Концептуально это звучит разумно, но это не лучший способ, так как вычислительно представляет дорогое решение. Нахождение циклов – задача функции has_cycle, фактически является столь же трудной по времени и памяти, как и сама задача топологической сортировки, но если мы попытаемся выполнять топологическую сортировку без предусловия, то без особых затрат можем обнаружить циклы в процессе работы.

    Теорема об отсутствии предшественника подсказывает нам, что цикл можно найти как побочный бонус процесса топологической сортировки.

  • Как следует из приведенного выше цикла, на каждом шаге разыскивается элемент, не имеющий предшественника, и это делается до тех пор, пока множество не станет пустым.
  • Теорема говорит нам, что для ацикличного отношения процесс завершится, то есть на каждом шаге непустого множества такой элемент существует.
  • Если мы не можем найти элемент, не имеющий предшественника, а множество еще не пусто, то это означает, что оставшиеся элементы составляют, по меньшей мере, один цикл. Мы можем завершить алгоритм, отчитавшись, что полная топологическая сортировка невозможна. Это благоприятная форма завершения, так как мы топологически отсортировали элементы, не создающие цикл, и имеем возможность сказать клиенту, какие именно элементы и ограничения создают проблему, – оставшиеся после сортировки.
  • Рассматриваемая далее программа топологической сортировки не имеет предусловия, а ее инвариант цикла также изменен.

    Вместо прежнего инварианта:

    —"constraints описывают ациклическое отношение на elements"
            

    будем теперь использовать его ослабленную форму:

    —"constraints описывают подмножество исходного отношения на elements"
            

    Отсюда следует, что

    —"Любой цикл в constraints присутствует в исходном отношении"
            

    а также:

    —"constraints описывает ациклическое отношение, если исходное отношение ациклично".
            

    Это та основа, которой мы будем придерживаться. Как следствие, мы больше не будем использовать в качестве условия выхода из цикла elements.is_empty, как ранее, так как непустое множество elements не гарантирует больше, что можно корректно выполнить оператор:

    "Пусть x – элемент без предшественников в ограничениях"
            

    В качестве нового условия выхода будем иметь:

    "Нет элементов без предшественников в ограничениях"
            

    Отрицание этого условия означает, что есть по крайней мере один элемент без предшественников, – а это гарантия того, что в теле цикла можно найти очередного кандидата, включаемого в результат выхода.

    Полная организация класса

    Мы можем теперь определить полную форму класса, который будет служить каркасом нашего решения:

    class
        TOPOLOGICAL_SORTER [G –> HASHABLE]
    feature {NONE} — Внутренние структуры данных
                …Смотри следующие разделы этой главы…
    feature — Инициализация
        record_element (a: G)
                — Включить а в множество элементов.
        require
                not_sorted: not done
        do
                …Смотри следующие разделы…
        end
      record_constraint (a, b: G)
        — Включить [a, b] в ограничения.
        require
                not_sorted: not done
        do
                …Смотри следующие разделы…
        end
    feature — Status report
      done: BOOLEAN
                — Выполнена ли топологическая сортировка?
    feature — Element change
      process
                — Выполнить топологическую сортировку над всеми применимыми элементами.
                — Результаты доступны через sorted, cycle-found и cyclists.
        require
                not_sorted: not done
        do
                …Смотри следующие разделы…
        ensure
                sorted: done
        end
    feature — Access
      cycle_found: BOOLEAN
                — Исходное ограничение приводит ли к циклу?
      cyclists: LIST [G]
                — Элементы, включенные в какой-либо цикл.
      sorted: LIST [G]
                — Список из всех элементов, которые допускают упорядочивание,
                — согласованное с ограничениями
    feature — Status setting
        reset
                — Допустить дальнейшее обновление элементов и ограничений.
                do
                    done:= False
                    cycle_found:= False; cyclists:= Void; processed_count:= 0
                ensure
                    fresh: not done
                end
    invariant
        elements_exist: elements /= Void
        constraints_exist: constraints /= Void
        cyclists_only_if_cycle: done implies (cycle_found = (cyclists /= Void))
    end
            
    Компоненты класса были перечислены в порядке, облегчающем последовательное чтение. Порядок, рекомендуемый стандартом, будет восстановлен в окончательной версии класса.

    Класс является универсальным, его родовой параметр G представляет тип элементов. Запись G -> HASHTABLE означает, что мы "ограничиваем" G (понятие, вводимое в следующей главе) типом HASHTABLE. Причина ограничения типа прояснится немного позже.

    Алгоритм будет основываться на внутренних структурах данных, проектируемых в следующих разделах этой главы. Поскольку соответствующие методы создаются для внутреннего использования, они не будут доступны клиентам класса и потому объявлены как feature {NONE}.

    Сразу же после того, как метод process выполнит свою работу, его результаты будут доступны клиентам через несколько связанных запросов.

  • Булевский запрос done, позволяющий клиентам выяснить, выполнена ли топологическая сортировка. При инициализации он получает значение false.
  • Список, формируемый запросом sorted, дает порядок, совместимый со всеми ограничениями, и включает все элементы, не входящие в цикл.
  • Булевский запрос cycle_found показывает, что есть элементы, включенные в какой-либо цикл.

  • Список всех таких элементов, включенных в цикл, представлен запросом cyclists. Предложение инварианта cyclists_only_if_cycle говорит нам, что запрос имеет смысл только при условии истинности cycle_found.
  • Клиент, желающий выполнить топологическую сортировку, обычно будет использовать наш класс следующим образом:

    your_structure: TOPOLOGICAL_SORTER [YOUR_ELEMENT_TYPE]
    …
    create your_structure
    …Вызовы вида your_structure.record_element(x) для записи элементов …
    …и your_structure.record_constraint(x, y) для записи ограничений…
    your_structure. process
    — Теперь становятся доступными результаты топологической сортировки
    — your_structure.sorted
    if your_structure. cycle_found then
        — Теперь становятся доступными элементы, включенные в цикл
        — your_structure.cyclists …
    end
            

    Было бы желательно для согласованности поставлять запросы sorted, cycle_found и cyclists с предусловием done, но мы временно будем опускать предусловие.

    Однако предусловие not done задано для процедур инициализации record_element и record_constraint, так же как и для процедуры топологической сортировки process, имеющей постусловие done. Как результат, попытка повторного вызова процедуры process на одном и том же экземпляре класса будет приводить к ошибке. Конечно, запросы можно вызывать сколь угодно много раз. Процедура reset добавлена на случай, когда вы хотите явно добавлять элементы и ограничения уже после вызова process для подготовки нового его вызова.

    Процедура reset просто устанавливает done равным false, не производя очистки предыдущих элементов и ограничений. Мы можем добавить процедуру forget, которая вызовет reset и очистит структуры данных. Но в этом случае клиенту проще создать новый экземпляр TOPOLOGICAL_SORTER.

    10.4. Базисный алгоритм

    Теперь мы можем приступить к полной реализации ключевой части нашего решения – процедуре process.

    Цикл

    Общий алгоритм этого метода уже написан, осталось адаптировать его с учетом всех сделанных уточнений (ослабление инварианта, использование метода sorted, представляющего результат в TOPOLOGICAL_SORTER). В результате получаем:

    from
            create {…} sorted. make
    until
        "Нет элемента без предшественников"
    Loop
        "Пусть x — элемент без предшественников"
            sorted. extend (x)
        "Удалить x из множества элементов"
        Удалить все ограничения, начинающиеся с x
    end
    if "остался хоть один элемент" then — Отчет о цикле:
        cycle_found:= True
        "Вставьте эти элементы в cyclists"
    end
            

    Все, что осталось, – но не спешите слишком радоваться, главные трудности еще впереди, – это уточнить псевдокод, превратив его в код программный. Заключительная часть (отчет о циклах) будет прямым следствием всего остального. Главная задача состоит в уточнении четырех операторов псевдокода, точнее, трех, поскольку мы можем рассматривать первые два как одну операцию. Сосредоточимся на этих операциях в процессе поиска эффективного алгоритма.

    Топологическая сортировка: базисные операции

    Т1 Найти элемент без предшественников – или отчитаться об отсутствии такового.
    Т2 Для заданного элемента x удалить его из множества элементов.
    Т3 Для заданного элемента x удалить из множества ограничений все ограничения, начинающиеся этим элементом (все пары вида [x, y]).

    Мы должны найти такие представления данных (множеств элементов и ограничений), которые делают эти операции эффективными, насколько это возможно. Структуры данных будут появляться как закрытые компоненты класса в разделах, резервированных для этих целей.

    Приведенная выше схема класса оставила незаполненными тела двух методов класса: add_element и add_constraint. Нам предстоит дополнить методы, основываясь на структурах данных, которые мы сейчас спроектируем.

    "Естественный" выбор структур данных

    Для нашей первой попытки построения структуры данных естественно выбрать представление, которое непосредственно моделирует входные данные в том виде, как они к нам приходят (наш небольшой опыт в изучении программирования говорит, что не следует утверждать, что некоторое решение является "естественным". То, что кажется естественным мне, вовсе может не казаться таковым для вас, а то, что естественно для меня и для вас, может оказаться довольно посредственным решением).

    Чаще всего входные данные могли бы поступать в виде списка элементов и списка ограничений. Мы могли бы использовать атрибуты класса, которые непосредственно отображают эти структуры данных (сделав их закрытыми для клиентов, размещая их в секции feature {NONE}):

    elements: LINKED_LIST [G]
    constraints: LINKED_LIST [TUPLE [G, G]]
            

    В нашем примере структуры данных будут выглядеть подобным образом:

    (рис 10.11) Элементы и ограничения

    Для простоты предполагается, что мы присвоили номера нашим элементам следующим образом (рис 10.12).

    (рис 10.12) Нумерация элементов

    Анализ производительности естественного решения

    Можем ли мы реализовать при таком представлении данных операции Т1, Т2, Т3 и процедуры record_element и record_constaint, и если да, то какова цена решения – сложность по времени и по памяти?

    Две процедуры реализуются непосредственно. Например, для выполнения record_constaint достаточно вызывать стандартный метод:

    constraints. extend ([x, y])
            

    Новая пара [x,y] добавится в конец списка ограничений. Подобным образом record_element(x) реализуется вызовом elements.extend(x).

    При применении этих операторов, как обычно, следует быть уверенным, что объекты elements и constaints имеют значения, отличные от void. Соответствующие операторы создания create могут появляться в форме default_create или вызываться по требованию, когда в них возникает необходимость. Все это справедливо и для других структур данных, рассматриваемых ниже.

    Это представление можно использовать и при реализации других операций. Давайте проанализируем их стоимость, когда у нас есть n элементов и m ограничений.

  • Для поиска элемента без предшественников (T1) можно обойти список ограничений constaints и считать предшественников для каждого элемента, затем обойти список элементов для поиска тех, у кого число предшественников равно нулю. Первый обход потребует $$O(m)$$ операций, второй – $$O(n)$$, и поскольку это нужно делать на каждом шаге, в целом понадобится операций $$O(n^*m + n^2)$$.
  • Удаление элемента (T2) требует на каждом шаге $$O(n)$$ операций, а в целом – $$O(n^2)$$.
  • Удаление ограничений (T3) требует на каждом шаге $$O(m)$$ операций, а в целом – $$O(m^*n)$$. Предполагается, что мы имеем единый список ограничений, а потому для удаления элементов, начинающихся с некоторого заданного $$x$$, потребуется обход всего списка.
  • В практических приложениях следует ожидать, что для каждого элемента задается несколько ограничений, так что $$m > n$$, а потому $$O(m^*n)$$ хуже, чем $$O(n^2)$$. Поскольку в реальных задачах $$n$$ может быть велико, сложность $$O(n^2)$$ представляется неприемлемо высокой.

    Так что наш первый выбор структуры данных дает решение задачи, но его производительность не позволяет построить масштабируемое приложение, пригодное для задач большой размерности.

    Дублирование информации

    К счастью, мы можем найти решение лучшее, чем "естественное". Как показывает опыт, мы не всегда должны использовать структуры данных в том виде, как они к нам приходят. Списки elements и constraints – это непосредственное отражение данных внешнего мира, естественного представления данных человеком, который вводит задачи и описывает ограничения между задачами. Но форма данных, простая и естественная для внешнего мира, вовсе не является лучшей формой для алгоритма, предназначенного для обработки этих данных некоторым специальным образом. Вместо того чтобы слепо следовать форме приходящих данных, алгоритм может начинаться с этапа инициализации, который преобразует данные в формат, наилучшим образом приспособленный к их обработке.

    Следующие структуры данных помогают выполнить работу топологической сортировки – задачи Т1-Т3 – удобно и быстро:

    successors: ARRAY [LINKED_LIST [INTEGER]]
                — Индексами массива являются номера элементов. Для каждого элемента x
                — задается список его непосредственных последователей – элементов y,
                — таких, что есть ограничение [x, y].
    predecessor_count: ARRAY [INTEGER]
                — Индексами массива являются номера элементов.
                — Для каждого элемента x указывается число его непосредственных
                — предшественников.
            

    Следующий рисунок иллюстрирует представление данных для нашего примера.

    (рис 10.13) Число предшественников и список последователей

    На следующем рисунке приведена уже хорошо нам знакомая схема, где введена нумерация элементов. Она поясняет предыдущий рисунок. По схеме ясно, что, например, у задачи 1 предшественников нет, но есть два последователя, а у задачи 5 – один предшественник и те же два последователя (2 и 4).

    (рис 10.14) Знакомая схема с нумерацией элементов

    В этом представлении интересно то, что массив, задающий число предшественников, является избыточным, поскольку всю хранимую в нем информацию можно извлечь из массива последователей. Но ничего ошибочного в таком представлении информации нет, поскольку, как мы увидим, за счет этого дублирования можно добиться существенного улучшения времени вычислений.

    Нахождение компромисса память-время является ключевой составляющей проектирования хорошего алгоритма. Конечно, компромисс должен быть приемлемым. В данном случае наша цель — получить алгоритм с временной сложностью O(m + n), сохраняя ту же сложность и для памяти, требуемой для хранения данных. Такая память нужна для хранения массива successors, добавление массива predecessor_count с емкостью O(n) не меняет емкостной сложности алгоритма в целом.

    Исходные структуры данных – elements и constraints – также требуют памяти O(m + n).

    Украсим алгоритм инвариантом класса

    Удобно для пущей ясности добавить запрос, доступный клиентам:

    count: INTEGER
                 — Число элементов
            

    Также полезно для улучшения читабельности добавить инварианты класса. Два последних предложения выражены неформально:

    elements.count = count
    predecessor_count. count = count
    successors.count = count
    — Для каждого i из интервала 1..count элемент predecessor_count[i]
    — задает число предшественников i-го элемента для заданных ограничений.
    — Для каждого i из интервала 1..count элемент successors[i]
    — задает список всех последователей i-го элемента для заданных ограничений
    — или void, если последователей нет.
            

    Нумерация элементов

    Чтобы использовать массив, нужно некоторым образом пронумеровать наши элементы. Ранее это было сделано в интересах удобства обращения. Теперь это становится необходимостью, связанной с нашим выбором структуры данных.

    Значит ли это, что теперь требуется изменить родовой параметр класса TOPOLOGICAL_SORTER[G], поскольку все манипуляции над элементами будут теперь использовать их целочисленные номера? Абсолютно нет. Остается необходимым для выразительности создать механизм, применимый к элементам любого типа. На практике для этого потребуется хэш-таблица и массив:

    index_of_element: HASH_TABLE [INTEGER, G]
                — Для каждого элемента дает его индекс
    element_of_index: ARRAY [G]
                — Для каждого индекса дает ассоциированный с ним элемент
            

    Элемент хэш-таблицы index_of_element[e] дает целое x – индекс элемента e типа G. В свою очередь, element_of_index[x] = e.

    В обоих случаях используется нотация с квадратными скобками.

    (рис 10.15) Взаимное отображение элементов и индексов

    Обе эти структуры при разумной реализации требуют памяти O(n).

    Для определения хэш-таблицы с элементами типа G требуется, чтобы тип G удовлетворял ограничениям наследования – был потомком класса HASHTABLE и объявлен как G -> HASHTABLE (ограниченная универсальность будет подробно рассматриваться в следующей главе, сейчас же понятно, что элементы должны допускать построение хэш-функции).

    Класс TOPOLOGICAL_SORTER[G -> HASHTABLE] не будет экспортировать компоненты index_of_element и element_of_index, так как они необходимы только для целей реализации, но мы должны позволять клиентам находить элементы, поскольку это является частью общей задачи работы с элементами, поэтому мы будем экспортировать запрос:

    has_element (e: G): BOOLEAN
                — Является ли e одним из элементов топологической сортировки?
        do
                Result:= index_of_element. has (e)
        ensure
                consistent: Result = index_of_element.has (e) and then
                        index_of_element [e] >= 1 and then
                        index_of_element [e] <= element_of_index. count and then
                        element_of_index [index_of_element [e]] = e
    end
            

    Убедитесь, что вы понимаете постусловие.

    Давайте теперь докажем, что новые структуры данных позволяют добиться нашей цели – времени O(m + n). Необходимо рассмотреть два аспекта: выполнение операций Т1-Т3 и затраты на инициализацию – создание структур данных predecessor_count и successors. Оба аспекта важны. Если бы для новых структур данных операции Т1-Т3 выполнялись бы за время O(m + n), но для создания структур требовалось бы $$O(m^*n)$$ времени, то в целом никакого выигрыша мы бы не получили.

    Представляется, что несложно реализовать построение требуемых структур данных за время O(m + n). Обрабатывая последовательно каждое ограничение [x, y], зная y, можно увеличить на 1 значение соответствующего элемента predecessor_count, а зная x, можно добавить последователя y в список соответствующего элемента successors. Обе операции выполняются за константное время. Опуская детали, будем считать далее, что построение структур данных за время O(m + n) возможно и сосредоточимся на рассмотрении операций Т1-Т3.

    Базисные операции

    Начнем с Т3: "Для заданного элемента x удалить из множества ограничений все ограничения, начинающиеся этим элементом (все пары вида [x, y])". Если мы знаем номер x, то выполнить удаление совсем просто.

    L1 Нам больше не нужен список последователей элемента x. Мы можем очистить этот список простым присваиванием successors[x] = Void. На практике и присваивание не нужно, так как алгоритм никогда не посетит вход x этого массива. Но даже если и выполнять эту операцию, то она выполняется за время O(1) для одного элемента и O(n) для всех элементов. Прекрасно!
    L2 Нам требуется также обновить соответствующие элементы в массиве predecessor_count. Для каждого последователя y элемента x нужно уменьшить predecessor_count[y] на 1, так как в связи с удалением ограничения уменьшается число предшественников, и для выполнения операции потребуется обход списка, связанного с элементом x. Это будет делаться непосредственно в цикле, чей код появится ниже. Процесс будет выполняться самое большее один раз за всю обработку для каждого ограничения, так что он потребует времени O(m). Снова прекрасно!

    В целом операция Т3 требует времени O(m + n) в худшем случае.

    Вся только что описанная обработка сопровождается инвариантами класса, выражающими тот факт, что массивы predecessor_count и successors в полной мере отражают структуру оставшихся ограничений отношения.

    Рассмотрим теперь операцию Т2: "Для заданного элемента x удалить его из множества элементов". Фактически, для нашей новой структуры данных нам ничего не нужно делать. Выполняя Т3, мы уже позаботились обо всем, что нужно было сделать, удаляя ограничение, начинающееся с x. Отлично!

    Осталась операция T1: "Найти элемент без предшественников – или уведомить об отсутствии такового". Для этого достаточно выполнить обход массива predecessor_count и найти элемент со значением 0. Но для этого понадобится время O(n), а для всех элементов $$O(n^2)$$. Это плохо!

    Нам недостает еще одной структуры данных!

    Кандидаты

    Нам не избежать O(n) обхода массива predecessor_count при инициализации (предложения from нашего главного цикла) для поиска первоначальных кандидатов – элементов без предшественников в исходном отношении. Если только не каждый элемент включен в цикл, то такие элементы найдутся. Это потребует O(n) времени, но выполнить эту операцию придется лишь один раз, так что пока все хорошо. По ходу процесса топологической сортировки обнаружение кандидатов на удаление – элементов без предшественников – можно получить в качестве побочного эффекта выполняемой операции Т3. Действительно, на этапе L2 мы уменьшаем на 1 число предшественников. Если при этом это число становится равным нулю, то найден новый кандидат. Если ранее этап L2 можно было бы записать в виде:

        — Уменьшить на единицу число предшественников y:
    predecessor_count [y]:= predecessor_count [y] – 1
            

    то теперь соответствующий код будет выглядеть так:

      — Уменьшить на единицу число предшественников y
      — и проверить, не становится ли y кандидатом:
    predecessor_count [y]:= predecessor_count [y] – 1
                [3]
    if predecessor_count [y] = 0 then
                "Записать, что y не имеет предшественников"
    end
            

    "Записать, что y не имеет предшественников" может быть реализовано как добавление в структуру candidates, которая будет заполняться при инициализации и пополняться по ходу обработки. Ее элементами являются еще не обработанные элементы, не имеющие предшественников. Какую конкретную структуру следует выбрать для candidates? Для алгоритма топологической сортировки точный выбор не имеет значения. Важно лишь, чтобы эта структура поддерживала следующие 5 операций.

    feature — Access
        item: G
                    — Получить ранее вставленный элемент.
          require
                    not_empty: not is_empty
    feature — Measurement
        count: INTEGER
                    — Число элементов.
          ensure
                    non_negative: Result >= 0
    feature — Status report
        is_empty: BOOLEAN
                    — Пуста ли структура?
          ensure
                    definition: Result = (count = 0)
    feature –- Element change
        put (x: G)
                    — Вставить элемент x.
          ensure
                    one_more: count = old count + 1
        remove: G
                    — Удалить прочитанный элемент.
          require
                    not_empty: not is_empty
          ensure
                    one_fewer: count = old count – 1
            

    Структуры данных с такими свойствами называются распределителями.

    (рис 10.16) Распределитель

    Как вы помните, основная идея распределителя в том, что не вы задаете, какой элемент будет получен и удален из распределителя, – стратегия задается типом распределителя. Стеки характеризуются политикой LIFO, очереди – FIFO.

    Для топологической сортировки любой распределитель будет выполнять нужную работу. Выбор определенного вида влияет на фактический порядок, в котором будут выводиться элементы (решений, совместимых с ограничениями, может быть несколько). Так что это тот рычаг, которым можно управлять, оптимизируя результат по некоторому критерию. Он позволяет рассматривать семейство алгоритмов топологической сортировки.

    Мы можем рассматривать распределитель кандидатов в виде:

    candidates: PRIORITY_QUEUE [INTEGER]
                — Элементы без предшественников, готовые к удалению
                — Дополнительное предложение для инварианта:
                — predecessor_count[x] = 0 для каждого элемента x из массива candidates
            

    Реализация распределителя стеком или очередью (STACK или QUEUE) допустима, но очередь с приоритетами является более общим видом, где каждый элемент может сопровождаться приоритетом. В такой очереди элементы отсортированы по приоритетам, и операции item и remove выполняются для элемента с наибольшим приоритетом. Стек и очередь – это специальные случаи, когда приоритет задается порядком поступления элементов. Распределитель PRIORITY_QUEUE позволяет вам, играя приоритетами, управлять политикой выбора.

    Цикл, заключительный вид

    Мы можем теперь выписать главный цикл алгоритма топологической сортировки – тело процедуры process со всеми деталями. Операторы псевдокода для наглядности сохраним и в этой версии как комментарии. Процедура должна объявить локальные переменные x и y типа INTEGER и x_successors типа LIST[INTEGER], сохраняющего последователей конкретного элемента. Мы также добавим целочисленную переменную processed_count, используемую далее, чтобы сохранять историю того, как много элементов уже обработано.

    from
        create sorted. make
        find_initial_candidates — Смотри далее
    invariant
        — "Структуры данных представляют подмножество исходных элементов
        — и соответствующее подмножество исходного отношения"
    until
        candidates.is_empty
    loop
                            — "Пусть x – элемент без предшественников в ограничениях"
        x:= candidates.item; candidates.remove
        sorted.extend (element_of_index [x])
                            — "Удалим x и все пары в ограничениях, начинающиеся с x"
        x_successors:= successors [x] — список
        from x_successors.start until x_successors. after loop
                y:= x_successors. item
                                    — Следующие несколько строчек взяты из [3]:
                predecessor_count [ y]:= predecessor_count [y] – 1
                if predecessor_count [ y] = 0 then
                                    — "Записать, что теперь y не имеет предшественников"
                            candidates. put (y)
                end
                x_successors. forth end
                processed_count:= processed_count + 1
    variant
                count – processed_count
    end
    report_cycles — Смотри далее
    done:= True
            

    Этот алгоритм предполагает, что массивы predecessor_count и successors правильно сформированы, как и должно быть перед любым вызовом process. Детали инициализации появятся чуть позже.

    Процедура find_initial_candidates должна наполнить распределитель candidates элементами, изначально не имеющими предшественников. Она реализуется просто:

    find_initial_candidates
                    — Поместите в массив элементы без предшественников.
        local
                    x: INTEGER
        do
            if candidates = Void then create candidates end
            from x:= 1 until x > count loop
                    if predecessor_count [x] = 0 then
                            candidates. put (x)
                    end
                    x:= x + 1
            end
        end
            

    Это обход за время O(n). Без введения такого массива пришлось бы выполнять такой обход на каждом шаге цикла. Теперь достаточно выполнить его один раз в самом начале работы.

    Не является ошибкой, если в процедуре не будут найдены элементы, удовлетворяющие условию predecessor_count[x] = 0. Это просто означает, что структура candidates пуста, что цикл завершится незамедлительно и что все элементы включены в какой-либо цикл.

    Процедура process по завершении цикла должна выполнить еще одну важную часть работы – она должна уведомить клиента о циклах, встречающихся в отношении. Это и делается при вызове процедуры report_cycles. Чтобы ее реализовать, предварительно заметим, что цикл завершается, когда в массиве candidates не остается элементов. Если исходное отношение было ациклическим, то будут обработаны все элементы, так что можно использовать введенную ранее переменную processed_count, чтобы понять, остались ли элементы, и, если да, то сколько их:

    report_cycles
                — Сделать информацию о циклах доступной клиентам.
        do
                if processed_count < count then
                                        — В исходном отношении есть цикл!!
                        cycle_found:= True
                        create {LINKED_LIST [G]} cyclists. make
                        from x:= 1 until x > count loop
                                if predecessor_count [x] /= 0 then
                                        — x включен в цикл
                                cyclists. extend (element_of_index [x])
                                x:= x + 1
                        end
                end
        end
            

    Инициализация и время ее выполнения

    Мы достигли эффективной реализации за время O(m + n) ядра топологической сортировки – ее основного цикла. Этому способствовали три структуры данных, специально спроектированные для этих целей – массивы predecessor_count и successors и распределитель candidates. Дополняя работу, следует убедиться, что инициализация не нарушает требуемых ограничений на время работы.

    Инициализация должна выполнять:

  • record_element(e) для каждого элемента – всего n раз;
  • record_constraint(e, f) для каждого ограничения – всего m раз.
  • Работа record_element(e) состоит в том, чтобы присвоить номер элементу e, так чтобы в дальнейшей работе можно было бы использовать целые, а не сами элементы, имеющие тип G.

    (рис 10.17) Массив и хеш-таблица

    Это делается согласованным заполнением массива element_of_index и хэш-таблицы index_of_element, задающих взаимное отображение:

    record_element (e: G)
            — Добавить e в множество элементов, если там его еще нет.
        require
            not_sorted: not done
        do
            if not has_element (e) then
                    count:= count + 1
                    index_of_element. extend (count, e)
                    element_of_index. force (e, count)
            —extend и force расширяют структуры при необходимости; это означает,
            —что нам не требуется знать, сколь много элементов может появиться.
            end
        ensure
                    inserted: has_element (e)
                    one_more: not (old has_element (e)) implies (count = old count + 1)
        end
            

    Начальный тест должен убеждать, что процедура игнорирует повторную попытку вставки данного элемента. Эта политика позволяет record_constraint(e, f) стартовать, вызывая record_element как на e, так и на f, просто для того, чтобы убедиться, что элементы вставлены надлежащим образом. В упражнении вас попросят найти способ, позволяющий избежать дублирования работы между has_element и extend.

    (рис 10.18) Добавление ограничения

    При подходящей реализации extend и force код процедуры record_element выполняется за O(1), что для всех элементов дает время O(n). Это согласуется с нашими требованиями.

    Оставшийся механизм инициализации дается процедурой для ввода ограничений. Вызов record_constraint(e, f) должен увеличивать на 1 число предшественников f в массиве predecessor_count и добавлять f в список последователей e. Этот список является одним из элементов массива successors:

    Вот текст процедуры:

    record_constraint (e, f: G)
          — Добавить ограничение [e, f]
        require
          not_sorted: not done
          exist: e /= Void and f /= Void
        local
          x, y: INTEGER
        do
          — Убедиться, что e и f вставлены (нет эффекта, если они уже там присутствуют):
          record_element (e); record_element (f )
          x:= index_of_element [e]
          y:= index_of_element [f ]
          predecessor_count [ y]:= predecessor_count [ y] + 1
          add_successor (x, y)
    ensure
          both_there: has_element (e) and has_element (f)
    end
            

    Дополнительная процедура, которую можно не экспортировать:

    add_successor (x, y: INTEGER)
          — Запись y как последователя x.
        require
          1 <= x; x <= count
          1 <= y; y <= count
        local
          x_successors: LINKED_LIST [INTEGER]
        do
          x _successors:= successors [x]
    — Список последователей для x может быть еще не создан:
          if x_successors = Void then
                          create x_successors. make
                          successors [x]:= x_successors
          end
          x_successors. extend (y)
        end
            

    Как уже отмечалось, работа record_constraint начинается с двух вызовов record_element для пары аргументов, задаваемых в ограничении. Из-за способа проектирования record_element эффекта от вызова не будет, если элементы уже присутствовали. Эта политика делает возможным для клиентского приложения начинать непосредственно со списка ограничений, никогда явно не обращаясь к записи элементов.

    Мы не можем, однако, полагать, что так будет всегда, и удалить record_element из интерфейса, доступного клиенту. Для некоторых вариантов задачи, о чем ранее говорилось, в исходном множестве элементов могут встречаться такие элементы, которые не входят ни в одно из ограничений. Понятно, что после топологической сортировки множества они являются полноценными участниками вывода. Для таких вариантов ввод должен включать помимо списка ограничений и список элементов, по крайней мере, тех, что не входят в ограничения.

    Говоря о дублировании, заметим, что процедура record_constraint не пытается определить, встречалось ли уже вводимое ограничение. Можете убедиться, что алгоритм сортировки корректно будет работать и в случае, когда ограничение повторяется. Применение другой политики, запрещающей дублирование, возлагается на клиента, ответственного за ввод данных.

    Вернемся к эффективности. Код каждой из двух дополнительных процедур выполняется за время O(1): доступ к элементу массива, запись в конец списка во втором случае (при хорошей организации списка с курсором в конце операция выполняется за константное время). Так что время работы record_constraint также задается O(1), а поскольку процедура должна отработать для каждого ограничения, в целом получаем O(m). Таким образом, достигнута наша цель: получить алгоритм, выполняющийся за время O(m + n) как на этапе инициализации, так и при выполнении основной задачи – топологической сортировки.

    Собираем все вместе

    Мы уже познакомились со всеми элементами нашей программы, необходимыми для реализации топологической сортировки. Класс, построенный в полном соответствии с данным обсуждением, доступен в EiffelBase и используется в Traffic, но, полагаю, для проверки вашего понимания концепций следует самостоятельно написать его реализацию, собирая все вместе.

    Время программирования!

    Реализация топологической сортировки

    Напишите класс TOPOLOGICAL_SORTER, обеспечивающий универсальную, практичную топологическую сортировку.

    Убедитесь, что решение отвечает принципам инженерии программ, не только обеспечивает эффективный алгоритм, но и включает процедуры инициализации (record_element, record_constraint). Для тестирования решения используйте файл, доступный на сайте, который связан с курсом. Этот файл содержит несколько сотен ограничений и все возможные варианты топологической сортировки.

    10.5. Уроки

    Из алгоритма топологической сортировки можно вывести важные следствия, общезначимые для проектирования алгоритмов и для программной инженерии в целом.

    Интерпретация в сравнении с компиляцией

    Мы уже рассматривали два стиля выполнения программ, написанных на некотором языке программирования S.

  • Интерпретация. Написать программу, называемую Интерпретатором, которая может выполнять произвольную S-программу для произвольных входных данных.
  • Компиляция. Написать программу, называемую Компилятором, которая может преобразовать произвольную S-программу в программу с эквивалентной семантикой, записанную на целевом языке T. Если T – это машинный язык для применяемой платформы, то результат компиляции может быть непосредственно выполнен. В противном случае T-программа может быть интерпретирована или подвергнута дальнейшей компиляции.
  • (рис 10.19) Интерпретация и компиляция программ

    Как отмечалось, практическая реализация языков часто комбинирует оба подхода.

    Эти концепции обобщаются на многие проблемные области, отличающиеся от выполнения программ, которые написаны на языках высокого уровня. Для выполнения некоторой обработки на некотором входе мы можем использовать структуры данных, которые непосредственно отображают вход, или можем разбить процесс обработки на два этапа.

  • Компиляция: преобразовать данные в форму, более подходящую для целей алгоритма.
  • Интерпретация: применять требуемые операции к исходной структуре данных.
  • Этот прием (который, как и при обработке языков, может и в общем случае включать несколько итераций процесса) в точности был применен для топологической сортировки. Вначале мы рассмотрели интерпретационное решение, которое использует кажущиеся естественными структуры данных, непосредственно следуемые из постановки задачи. Но оказалось, что эти структуры приводят к "плохому" по производительности решению. "Компиляция" их в представление, подогнанное под наши цели, привело к решению с прекрасной производительностью.

    В таком двухэтапном решении шаг "компиляции", инициализирующий структуры данных, может быть столь же сложным для проектирования, как и фактическая обработка, основанная на этих структурах, – он может требовать столь же много времени, а иногда и больше, чем время самой обработки. Этот подход хорош, если общая производительность соответствует вашим целям, но, конечно же, нельзя перепрыгивать к заключению о производительности алгоритма, не учитывая время инициализации, а не только самой обработки.

    (рис 10.20) "Интерпретация" и "компиляция" данных

    Подход может быть обобщен в виде эвристики – универсальной стратегии, подобной "образцу проектирования", но в существенно более абстрактной форме.

    Почувствуй эвристику

    Вначале компилируй данные!

    Хорошие алгоритмы часто создаются с использованием двухэтапной стратегии, где:

  • на первом этапе преобразуется вход из его исходной формы во внутренние структуры, тщательно спроектированные и подогнанные под цели задачи;
  • на втором шаге обрабатываются преобразованные данные для получения конечного результата.
  • Мы увидим еще одно применение этой идеи при обсуждении архитектуры процесса проектирования, управляемого событиями.

    Компромисс "время-память"

    Выбора тяжко бремя – память иль время!

    Тесно связанным с эвристикой "Вначале компилируй данные" является наблюдение, что идеальная структура данных, та, которая наилучшим способом помогает реализации второго шага, часто не является наиболее экономичным по памяти представлением информации. В топологической сортировке информация об ограничениях может сохраняться в трех различных частях. Ограничение [x, y] ведет к тому, что y появится также в списке последователей x в массиве successors; некоторая информация хранится в массиве predecessor_count[y]. Если для заданного y ограничения отсутствуют, то это приводит к добавлению элемента в массив candidates. При таком представлении нам приходится жертвовать памятью для достижения требуемой по времени производительности. Компромиссы такого вида в ту или иную сторону – ключевые моменты проектирования эффективных алгоритмов.

    Алгоритмы в сравнении с программами и компонентами

    Вполне возможно было бы привести описание алгоритма топологической сортировки, которое игнорирует многие из аспектов, рассмотренных в этой лекции, концентрируясь только на процессе сортировки. Для практического решения необходимо принимать в расчет практические потребности приложений. ОО-подход позволяет нам действовать в соответствии с этими целями. Вместо написания процедуры топологической сортировки мы проектируем класс TOPOLOGICAL_SORTER. Экземпляр этого класса характеризует задачу топологической сортировки, предоставляя не только алгоритм ее решения (процедуру process), но и весь инструментарий, необходимый клиенту для того, чтобы он мог:

  • поставить задачу, записав элементы и ограничения удобным для себя способом;
  • применить process для выполнения топологической сортировки заданных элементов, удовлетворяющих заданным ограничениям;
  • запросить данные о результирующем состоянии, выяснить, существуют ли циклы, и если да, то получить информацию об элементах, входящих в цикл.
  • Разница между этим подходом и чистым алгоритмом является частью разницы между программной инженерией и чистым программированием. В инженерии программ недостаточно спроектировать умный алгоритм и связанные с ним структуры данных. Целью становится подготовка решения, которое может быть интегрировано в успешные программные системы.

    Наши решения должны допускать повторное использование, так что они не просто представляют "образцы проектирования", которые программист может интегрировать в свою систему, купив и прочитав книги (особенно выдающиеся книги, такие как эта, например). Решения должны быть реализованы как компоненты, доступные всегда и для всех, непосредственно доступные, а не стоящие на полках.

    Вы также обратили внимание на то, как в этом процессе контракты позволяют нам точно знать, что нам следует делать, – что мы ожидаем, что гарантируется и что мы поддерживаем.

    10.6. Ключевые концепции этой лекции

  • Топологическая сортировка является перечислением множества элементов, которое совместимо с множеством ограничений порядка, заданного на этих элементах.
  • Задача имеет простое математическое описание: задано (строгое) отношение порядка, требуется найти полное отношение, чьим подмножеством является исходное отношение.
  • На практике отношение обычно является не отношением порядка, а в лучшем случае ациклическим отношением. Взятие транзитивного замыкания дает отношение порядка.
  • Реалистичное, хорошее инженерное решение должно принимать, возможно, ошибочный вход, содержащий циклы. Оно должно выполнять топологическую сортировку ацикличной части исходного отношения и дополнительно выдать информацию об элементах, входящих в цикл.
  • Такое решение должно предоставлять не только алгоритм топологической сортировки, но и механизмы построения экземпляра задачи с вводом элементов и ограничений.
  • Для n элементов и m ограничений топологическую сортировку можно выполнить за O(m + n) время, используя память того же порядка.
  • Ключом эффективности алгоритма является построение структур данных, специально приспособленных для решения задачи сортировки:
  • массив, который для каждого элемента исходного множества содержит список его последователей;
  • массив, который для каждого элемента исходного множества содержит число его предшественников;
  • распределитель (стек, список или очередь с приоритетами), который содержит множество элементов, не имеющих предшественников.
  • На примере задачи сортировки показано, что хорошее алгоритмическое решение часто получается путем "компиляции" исходных данных задачи в специально спроектированные структуры данных, которые затем могут быть эффективно "интерпретируемы".
  • Новый словарь

    Acyclic Ациклический Antisymmetric Антисимметричность
    Asymmetric Асимметричность Binary relation Бинарное отношение
    Cycle Цикл Irreflexive Иррефлексивность
    Order relation Отношение порядка Partial order Частичный порядок
    Relation Отношение Strict order Строгий порядок
    Topological sort Топологическая сортировка Total order Тотальный или полный порядок
    Transitive closure Транзитивное замыкание

    10.7. Приложение. Терминологические замечания об отношениях порядка

    Для обсуждения топологической сортировки удобно – как показано в этой лекции – использовать отношение строгого порядка: "строго меньше, чем". Классическим примером такого отношения является отношение "<", заданное на числах. В некоторых задачах может быть более полезным иметь дело с нестрогим отношением, как отношение "<=" для чисел. Пара элементов связана нестрогим отношением x <= y, если и только если либо x < y, либо x = y. Общепринятое соглашение для "отношения порядка" предполагает нестрогую версию. В этой главе, так как мы использовали только строгое отношение порядка, слово "строгое" обычно опускалось, так что "порядок" означал "строгий порядок".

    Для отношения строгого порядка (иррефлексивного и транзитивного) в литературе иногда используется термин "квазипорядок". Конечно, каждый может дать любое имя понятию, задав его точное определение, но это конкретное имя неудачно, так как ничего "квази" в таком порядке нет. Если и есть что-нибудь характерное для элементов в этом порядке, то это то, что они "более" упорядочены, чем для случая нестрогого порядка, поскольку имеет место иррефлексивность, запрещающая элементу находиться в отношении с самим собой. Чтобы совсем запутать дело, некоторые авторы используют термин "квазипорядок" для отношений, которые являются рефлексивными и транзитивными. Так что лучше этот термин "квази" не использовать и вместо этого квалифицировать отношение порядка как "строгое", когда это необходимо.

    Еще одна проблема: является ли отношение тотальным или нет. Тотальность означает, что для любых пар различных элементов x и y имеет место одно из двух – либо x < y, либо y < x. Отношение порядка, удовлетворяющее этому свойству, называется тотальным порядком. Частичным порядком должно было бы называться отношение, которое не удовлетворяет этому свойству, такое, что для него найдется по крайней мере одна пара различных элементов, для которой ни [x, y], ни [y, x] не принадлежат отношению. Но не так определяется частичный порядок в большинстве литературных источников – там частичный порядок определяется как такой порядок, о котором неизвестно, является ли он тотальным. Другими словами, это отношение возможно частичного порядка. Это приводит к путанице, так как теперь тотальный порядок является также частичным порядком! Поэтому лучше писать так, как принято в этой лекции:

  • тотальный порядок – в тех случаях, когда известно, что он тотальный;
  • частичный порядок – для отношения порядка, о котором известно, что порядок не является тотальным.
  • Если же мы не знаем, каков порядок, или хотим включить оба случая – лучше применять термин "порядок". В случае неопределенности –использовать термин "возможно частичный порядок".

    10.8. Упражнения

    10.8.1. Словарь

    Дайте точные определения символам словаря.

    10.8.2. Иррефлексивность и асимметричность

    Отношение порядка было определено как транзитивное и иррефлексивное. Было доказано, что следствием является асимметричность отношения. Докажите, что если отношение определяется как транзитивное и асимметричное, то следствием является иррефлексивность.

    10.8.3. Тотальный порядок и перечисление

    Докажите, что если отношение r является отношением строгого тотального порядка на конечном множестве, то существует единственное перечисление элементов, такое, что для любых элементов x и y элемент x появится перед y в перечислении, если и только если пара [x, y] принадлежит r.

    10.8.4. Строгое отношение в сравнении с нестрогим отношением порядка

    Обсуждение в этой лекции строилось на строгом отношении порядка (частичном или полном), таком, как отношение "<" на числах – "меньше чем". Также возможно использовать нестрогое отношение порядка, такое, как "≤" – "меньше или равно". Определение строгого частичного порядка, которое мы будем называть отношением "<", хотя оно и не должно быть обычным отношением на числах, должно удовлетворять свойствам:

    О1 иррефлексивности: x < x не выполняется для любого x.
    О2 транзитивности: из того, что x < y и y < z, следует x < z.

    Для этого отношения также выполняется свойство:

    О3 асимметричности: не могут одновременно иметь место x < y и y < x.

    Свойство О3 является следствием двух предыдущих свойств. Для частичного нестрогого отношения порядка "≤" имеют место три независимых свойства:

    N1 рефлексивности: x≤x для любого x.
    N2 транзитивности: из того, что x≤y и y≤z, следует x≤z.
    N3 антисимметричности: из того, что x≤y и y≤x, следует x = y.

    Для любого частичного строгого отношения порядка "<" существует связанное с ним нестрогое отношение "≤", определенное как

    x ≤ y если и только если: x < y или x = y           [4]
            

    Справедливо и обратное утверждение: для любого частичного строгого отношения порядка "≤" существует связанное с ним строгое отношение "<", определенное как

    x < y если и только если: x ≤ y и x ≠ y            [5]
            

    В этом упражнении требуется исследовать связь между этими ассоциированными отношениями – строгим "<" и нестрогим "≤"

  • Докажите, что если "<" является частичным строгим отношением порядка, то "≤", определенное в примере 10.4, является частичным нестрогим отношением порядка.
  • Докажите, что если "≤" является частичным нестрогим отношением порядка, то "<", определенное в примере 10.5, является частичным строгим отношением порядка.
  • В строгом случае определение требует выполнения только двух свойств: иррефлексивности и транзитивности. Третье свойство асимметричности является следствием двух остальных. В нестрогом случае требуется выполнение всех трех свойств. Докажите, что антисимметричность не является следствием двух остальных. Другими словами, приведите пример нестрогого отношения, для которого первые два свойства выполняются, а антисимметричность не имеет места.
  • Докажите, что замена "рефлексивности" на "иррефлексивность" в определении нестрогого порядка приводит к определению строгого порядка.
  • Приводит ли замена "иррефлексивности" на "рефлексивность" в определении строгого порядка к определению нестрогого порядка?
  • 10.8.5. Ацикличность и отношение тотального порядка

    Докажите теорему о топологической сортировке, используя вторую теорему об ацикличности и отношениях порядка.

    10.8.6. Интересное постусловие

    Объясните постусловие функции has_element.

    10.8.7. Оптимизация использования хэш-таблицы

    В процедуре record_element проверяется, присутствует ли уже вставляемый элемент в таблице index_of_element, и запись осуществляется только в случае, когда такого элемента нет. В реализации используются две операции поиска – одна при вызове has_element, другая при вызове extend. Проанализируйте контрактную форму класса HASH_TABLE, найдите нужные компоненты, перепишите процедуры так, чтобы устранить эту небольшую неэффективность.

    10.8.8. Программирование топологической сортировки

    Постройте класс TOPOLOGICAL_SORTER в соответствии с обсуждением этой лекции.

    10.8.9. Параметризация топологической сортировки

    (Это упражнение предполагает, что выполнено предыдущее)

    Расширьте реализацию топологической сортировки, позволяя клиентам задавать стратегию выбора элемента из множества соревнующихся "кандидатов".

    Вернуться к учебному плану