Category Archives: Graphs

Один алгоритм раскраски графа II

Недавно я переписал алгоритм субоптимальной правильной вершинной раскраски графа на Python. Решил выложить его сюда.

Вершина графа отождествляется с её индексом. Граф задаётся индексируемой последовательностью (tuple или list) наборов соседей (массив списков соседей). Раскраска — массив (list в терминах Python) цветов.

Итак, функция раскраски у меня имеет следующий вид:

## Построить правильную вершинную раскраску графа.
## \param graph -- граф в виде массива множеств номеров смежных вершин
## \param forbidden -- массив множеств запрещённых цветов для каждой вершины
## \param color -- массив цветов вершин
## \return массив (новых) цветов вершин
def coloring(graph, forbidden=None, color=None):
    if forbidden is None: forbidden = []
    if color is None: color = []
    # Предусловия.
    assert len(forbidden) == 0 or len(forbidden) == len(graph)
    assert len(color) == 0 or len(color) == len(graph)

    if len(forbidden) == 0:
        forbidden = [frozenset()]*len(graph)
    #...

Передав раскраску color, можно задать желаемые цвета, а алгоритм разрешит конфликты, если они есть. Если color уже содержит правильную раскраску, то она и будет возвращена функцией coloring. Массив forbidden позволяет запретить раскрашивать какие-то вершины в какие-то цвета: forbidden[v] есть множество запрещённых цветов (числовых меток) для вершины с индексом v.

Всё, что описано ниже, определено внутри функции coloring.

Если пользователь не передал некую начальную раскраску color, то построим её в два цвета поиском в глубину.

    # Раскраска в два цвета поиском в глубину.
    def coloring2():
        color2 = [0]*len(graph)
        def dfsColoring2(u, c=1):
            if color2[u] == 0:
                for d in range(c, len(graph)+1):
                    if d not in forbidden[u]:
                        color2[u] = d
                        break
                c = 3 - c
                for v in graph[u]:
                    dfsColoring2(v, c)
    
        for start in range(0, len(graph)):
            dfsColoring2(start)
        return color2

    # Построить начальную раскраску, если требуется.
    if len(color) == 0:
        color = coloring2()

Теперь собственно алгоритм. Он заключается в повторении пары действий: выявлении конфликтов цветов (listConflicts) и разрешении конфликтов цветов (resolveConflicts), пока все конфликты не будут разрешены.

    # Перечислить текущие конфликты цветов.
    def listConflicts():
        conflicts = []
        for u in range(0, len(graph)):
            nc = [color[v] for v in graph[u]]
            conflict_rank = nc.count(color[u])
            if conflict_rank > 0:
                nc = frozenset(nc) | forbidden[u]
                new_color = max(nc) + 1
                for c in range(1, new_color-1):
                    if c not in nc:
                        new_color = c
                        break
                conflict = (conflict_rank, new_color, u)
                conflicts.append(conflict)
        conflicts.sort(
            key=lambda c: (c[0], -c[1], c[2]), reverse=True)
        return conflicts
    
    # Разрешить конфликты цветов (потенциально не все).
    def resolveConflicts(conflicts):
        closed = set()
        for _, new_color, u in conflicts:
            if u in closed:
                continue
            color[u] = new_color
            closed |= set(graph[u])
    
    # Собственно алгоритм: начиная с некоторой раскраски,
    # повторять обнаружение и разрешение конфликтов, 
    # пока список конфликтов не опустеет.
    while True:
        conflicts = listConflicts()
        if len(conflicts) == 0:
            return color
        resolveConflicts(conflicts)

Цвет 0 не используется.

Пример:

g = ((1,2), (0,2), (0,1,3,6), (2,4,5), (3,5,6), (3,4), (2,4))
c = coloring(g)
print(c)
> [1, 2, 3, 2, 1, 3, 2]

Раскрашенный граф:

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

Как-то так получилось, что именно записи на в общем-то побочную для меня тему алгоритмов на графах привлекают основной объём посещений блога «со стороны». Отчасти поэтому, а также потому, что на лекции я предложил было не слишком разумный вариант реализации одного из алгоритмов топологической сортировки вершин ориентированного ациклического графа (DAG — «directed acyclic graph»), я решил написать данный текст и соответствующий код.

На изображении ниже представлен DAG, использованный для проверки реализованной программы.

Пример DAG

Пример DAG

Рассматриваемый алгоритм весьма прост. Его можно описать следующим образом.

Вход: граф G = (V, E).
Выход: последовательность R.
Пусть R = ∅.
Пока |R| < |V|:
    пусть v ∈ V \ R такая что indeg(v) = 0;
    дописать v к R;
    удалить v из G.

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

Создать A = [ indeg(v) | v ∈ V ] — массив степений захода вершин.
Завести очередь Q и поместить туда все v ∈ V: A[v] = 0;
Пока Q ≠ ∅:
    извлечь очередную v из Q;
    поместить v в R;
    для всех w ∈ out(v):
        если A[w] = 1: поместить w в Q;
        если A[w] > 0: A[w] := A[w] – 1.

Как нетрудно видеть, данный алгоритм имеет сложность O(|V| + |E|), при предположении, что затраты на перечисление соседей вершины пропорциональны их количеству. Никакие «сложные» структуры данных не используются.

Ссылка на реализацию на C++.

Один алгоритм раскраски графа

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

Алгоритм

  1. Построить первоначальную раскраску (необязательно правильную). Эвристика: построить раскраску в два цвета поиском в ширину (чётный фронт → цвет «0», нечётный фронт → цвет «1»).
  2. Пока есть конфликты, производить разрешение конфликтов.

Под конфликтами понимается наличие одинаково окрашенных вершин-соседей. Проверка «есть конфликты» перебирает все вершины и составляет список C троек (вершина v, количество конфликтов n, минимальный неконфликтный цвет c), отвечающий конфликтующим вершинам (n = количество соседей v, имеющих тот же цвет, что и v). Если вершину v перекрасить в цвет c, то конфликт будет разрешён (при условии сохранения прежних цветов соседями v). Если конфликтов нет, то список C будет пуст, и алгоритм закончит работу — будет получена правильная раскраска вершин графа.

«Разрешение конфликтов» производится следующим образом.

  1. Завести множество соседей перекрашенных вершин A := Ø.
  2. Упорядочить список конфликтов C по убыванию числа конфликтов n.
  3. Для каждой тройки (v, n, c) из C, если v не принадлежит A, то:
    1. Назначить вершине v цвет c.
    2. Пополнить A соседями v.

Свойства алгоритма

По завершении работы алгоритма получаем правильную вершинную раскраску. Необходимо доказать, что работа алгоритма завершится. Строгого доказательства я выполнять не стал. Однако, нетрудно заметить, что всего конфликтов может быть не больше, чем всего вершин в графе (N), за одно разрешение конфликтов разрешается как минимум один конфликт и не добавляется новых, поэтому достаточно N итераций цикла. Одна итерация цикла требует O(N + M) действий (для каждой вершины проверить всех её соседей). Отсюда можно сделать вывод, что алгоритм имеет сложность O(N(N + M)) ≤ O(N3).

Реализация

Была написана реализация алгоритма на C++, включающая набор простых графов для проверки. Для всех простых тестов алгоритм даёт оптимальную раскраску.

Графы-5: паросочетания

Определения

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

Максимальным называется паросочетание, к которому нельзя добавить ни одного ребра из множества рёбер графа, или, что то же самое, максимальное паросочетание не является подмножеством никакого другого паросочетания.

Наибольшим называется паросочетание, содержащее наибольшее число рёбер. Наибольшее паросочетание является максимальным, обратное неверно.

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

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

Алгоритмы

Введённые определения позволяют поставить следующие задачи.

  1. Построить некоторое наибольшее паросочетание.
  2. Проверить, существует ли полное паросочетание.
  3. Построить оптимальное паросочетание для заданных весов рёбер.

Для простоты ограничимся рассмотрением двудольных графов.

Двудольным графом называется граф, вершины которого можно разделить на два непересекающихся множества X и Y таких, что все рёбра графа будут иметь вид (x, y), где x принадлежит X, а yY. Множества X и Y называются долями графа.

Добавим к графу две вспомогательные вершины s и t. Соединим s с каждой вершиной из X, и каждую вершину из Y соединим с вершиной t. Зададим всем рёбрам единичную пропускную способность и будем искать целочисленный максимальный поток (максимальный 0-1-поток) из s в t на построенной таким образом сети. Рёбра с единичным потоком составят некоторое из наибольших паросочетаний. Поэтому задачу 1 можно решить с помощью алгоритма построения максимального потока. Построив же некоторое наибольшее паросочетание не сложно проверить его на полноту, решив и задачу 2.

Кроме алгоритмов построения максимального потока существует адаптация алгоритма Форда-Фалкерсона к задаче построения наибольшего паросочетания, известная как алгоритм Хопкрофта-Карпа. Для решения задачи 2, а именно определения отсутствия полного паросочетания без построения наибольшего паросочетания, также существуют специальные алгоритмы (например, алгоритм Куна). Для решения задачи 3 существует алгоритм Куна-Манкреса, также известный как венгерский алгоритм.

Пример реализации

Возьмём часть кода до строки 506 и поместим её в файл Graph4.h. Для использования алгоритмов построения максимального потока в качестве алгоритмов поиска наибольшего паросочетания нам понадобится три элемента: а) функция, формирующая матрицу пропускных способностей, б) функция-обёртка алгоритма построения максимального потока, в) функция, «извлекающая» паросочетание из матрицы потока.

Первая и третья функции названы prepareBipartiteForMaxFlow и matchingFromFlow соответственно. Размеры долей графа передаются как константы X и Y. Для представления паросочетания используется вектор индексов, отображающий индекс вершины из доли X (от 0 до X — 1) в индекс вершины из доли Y (от 0 до Y), при этом фиктивное значение Y используется, чтобы показать, что вершина не насыщена.

Собственно привязка осуществляется тривиальной функцией fullMatching, которая использует вспомогательную структуру-шаблон FullMatching<class MaxFlowWrapper>, функцию-член find которого и вызывает (так сделано из-за того, что в C++ нельзя определять специализации функций-шаблонов).

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

Определены следующие классы, подходящие в качестве параметра MaxFlowWrapper: три обёртки алгоритмов из Graph4.h EdmondsKarpMaxFlow, PreflowPushingMaxFlow и PushRelabelMaxFlow, а также реализация алгоритма Хопкрофта-Карпа HopcroftKarp.

Итак, код реализации и теста: codepad.

Графы-2x: алгоритм поиска пути A*

Введение

Ранее приводился алгоритм Дейкстры поиска кратчайших путей от заданной вершины до всех остальных вершин графа с неотрицательными весами рёбер. Существует алгоритм (алгоритм A*), развивающий идею алгоритма Дейкстры для ускорения поиска пути между двумя заданными вершинами графа, если известна оценка длины кратчайшего пути между любыми двумя вершинами, называемая «эвристикой». Алгоритм этот довольно популярен и используется в робототехнике и играх.

Реализация

Была написана реализация, отталкивающаяся от тех же принципов, что и ранее созданная реализация алгоритма Дейкстры. Итак, функция-шаблон, реализующая алгоритм А*, принимает следующие параметры (слова в кавычках, описывают поведение объектов, а не реальное представление их в памяти):

  • (вывод) ссылку на «массив» предшественников Pr, после выполнения алгоритма Pr[v] == u, если вершина с индексом v стоит сразу за вершиной с индексом u на искомом пути;
  • (ввод) ссылку на «матрицу» весов рёбер графа;
  • (ввод) ссылку на «массив» наборов смежности, каждый набор должен представлять собой STL-последовательность с input-итератором;
  • (ввод) индексы начальной и конечной (целевой) вершин искомого пути;
  • (ввод) количество вершин графа;
  • (ввод) эвристика, реализованная как бинарный функтор, принимающий два индекса вершин графа и возвращающий оценку расстояния;
  • (ввод, опционально) политика демонстрации работы алгоритма, предназначенная для визуализации (подробнее в AV-2).

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

Основное отличие алгоритма поиска A* от алгоритма Дейкстры состоит в том, что при выборе ещё не пройденных вершин приоритет отдаётся тем вершинам, которые минимизируют сумму веса уже известного на момент пути (хранится в массиве gscore) и оценки остатка пути до целевой вершины. Эти суммы хранятся в массиве fscore, который используется компаратором ByArrayComparator при сравнении индексов вершин, помещаемых в объект openset (имеющий тип std::set<unsigned, ByArrayComparator<(…)>>).

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

Эта очередь называется «открытым множеством» (openset). Вершины, извлечённые из открытого множества, попадают в «закрытое множество» (closedset), после чего они уже не могут попасть в открытое множество.

Известно, что если эвристика даёт оценку кратчайшего пути снизу ( такая эвристика называется «допустимой» ), то алгоритм поиска A* находит кратчайший путь. В частности, эвристика может всегда возвращать 0, в этом случае алгоритм А* будет эквивалентен алгоритму Дейкстры с остановом при достижении целевой вершины.

С другой стороны, алгоритм А* позволяет ускорить процесс поиска с возможной потерей оптимальности результирующего пути. Известно, что если эвристика даёт оценку кратчайшего пути, которая не больше, чем в q раз превосходит вес реального кратчайшего пути, то алгоритм А* находит путь веса не более, чем в q раз большего, чем кратчайший путь (субоптимальное решение).

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

Графы-4: максимальный поток

Введение

Рассматривается ориентированный граф («сеть»), в котором есть две выделенные вершины: исток s и сток t. Исток не имеет входящих рёбер, сток — исходящих рёбер. Кроме того, если в сети существует ребро (u, v), то не существует ребра (v, u). Для каждого ребра (u, v) задана пропускная способность («capacity»), обозначаемая обычно c(u, v) или A(u, v). Ниже она задаётся матрицей, так же как и сам граф: пропускная способность несуществующих рёбер равна нулю.

Требуется найти максимальный поток F, который может пропустить сеть в силу заданных пропускных способностей. Поток в сети задаётся матрицей потоков, проходящих по рёбрам. Должно выполняться условие F(u, v) ≤ A(u, v).

Алгоритмы

Известно два основных класса алгоритмов, решающих задачу максимизации потока на сети:

  1. Класс алгоритмов Форда-Фалкерсона (Ford-Fulkerson algorithm), строящих дополняющие цепи.
  2. Алгоритмы проталкивания предпотока (preflow pushing algorithm, push-relabel algorithm), пытающихся максимально заполнить сеть, оперируя избытком «жидкости» в вершинах.

Алгоритмы Форда-Фалкерсона

Алгоритмы основаны на теореме Форда-Фалкерсона. Общий принцип работы:

  1. Устанавливается F(u, v) = 0, для всех пар u, v.
  2. Пока можно построить новую дополняющую цепь на остаточном графе, дополняем F(u, v) в силу построенной цепи.

Остаточный граф (residual graph) — граф с пропускными способностями рёбер равными A(u, v) — F(u, v), если A(u, v) > 0 (прямая дуга), или F(v, u), если A(u, v) = 0 (обратная дуга).

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

Разные реализации общей идеи по-разному решают вопрос поиска очередной дополняющей цепи. Существует два классических алгоритма 1970-х: алгоритм Эдмондса-Карпа (реализован в BGL) сложности O(N M2) ≤ O(N5) и алгоритм Ефима Диница сложности O(N2 M) ≤ O(N4). К новым алгоритмам этого семейства можно отнести алгоритм Бойкова-Колмогорова (описание из BGL, статья).

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

В 1980-х появилось новое семейство алгоритмов построения максимального потока типа, отличного от ранее известных алгоритмов Форда-Фалкерсона. В данном контексте будем называть F предпотоком. Для предпотока выполняются следующие условия: F(u, v) ≤ A(u, v) и F(u, v) = —F(v, u).

Пусть для каждой вершины v задана высота h[v]  ≥ 0 и избыток e[v] ≥ 0. Вершины, в которых избыток больше нуля, называются активными. Для любых вершин u, v, кроме стока и истока, должно быть выполнено условие: если (u, v) принадлежит остаточному графу, то h[u] ≤ h[v] + 1. Обычно полагают h[s] = N, h[t] = 0. Избыток в вершине равен разностью между суммой потоков на исходящих рёбрах и суммой потоков на входящих рёбрах.

Основная идея алгоритма состоит в первоначальном максимальном заполнении исходящих рёбер истока и «проталкивании» избытка из активных вершин по рёбрам остаточного графа в вершины, имеющими высоту, меньшую на 1. Если весь избыток вывести не удалось, то высота вершины увеличивается. Алгоритм останавливается, когда не остаётся активных вершин.

Базовая версия алгоритма (реализация на e-maxx.ru) без упорядочивания активных вершин имеет оценку сложности O(N2 M) ≤ O(N4). Вариант, «прогоняющий» вершины через очередь имеет оценку сложности O(N3). Вариант, поднимающий в начало очереди активных вершин вершину, в случае увеличения её высоты (реализация на e-maxx.ru, описание реализации BGL), имеет оценку O(N2 sqrt(M)) ≤ O(N3).

Реализация

Была написана реализация алгоритмов Эдмондса-Карпа и проталкивания предпотока с переупорядочиванием поднятых вершин (maxFlowPushRelabel) и с обработкой активных вершин в порядке очереди («базовый», maxFlowPushing).

Алгоритмы реализованы как функции-шаблоны и имеют параметр шаблона WeightMatrix, задающий тип матрицы пропускных способностей.

Кроме того, алгоритм Форда-Фалкерсона принимает тип-параметр Augmentor, который считается классом, определяющим статическую функцию augment, строящую следующую дополняющую цепь. Имеется одна реализация: EdmondsKarp, строящая цепь поиском в ширину. Таким образом, функция maxFlowFordFulkerson<EdmondsKarp> реализует алгоритм Эдмондса-Карпа.

Аналогично, алгоритмы проталкивания предпотока принимают тип-параметр Heights, который считается классом, определяющим статическую функцию heights, инициализирующую массив высот вершин. Определён один такой класс: HeightsBFS, который вычисляет высоты поиском в ширину в обратном направлении (против направления рёбер), начиная со стока. Впрочем, эксперименты не обнаружили существенного влияния способа инициализации высот на скорость работы алгоритмов.

Задания

  1. Прочитать и понять код, почему первое выводимое число всегда 1, а последнее — 0?
  2. Каким ещё образом можно реализовать выборку наивысшей активной вершины?
  3. Можно ли объединить код maxFlowPushing и maxFlowPushRelabel в одну функцию-шаблон?

Графы-3: эйлеровы и гамильтоновы циклы

Цель

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

Код

Для удобства будем считать, что часть кода из поста «Графы-2» до комментария

// тесты

выделена в отдельный заголовочный файл «Graph2.h».

1. Функция-шаблон findEulerLoop<AdjVec, OutIt> реализует алгоритм, строящий некоторый эйлеров цикл путём отбрасывания рёбер. Принимаемый граф представлен вектором наборов соседей AdjVec (первый параметр; например, в качестве него подходит объект std::vector<std::set<unsigned>>, тж. см. «Графы-1» ), результат в виде последовательности вершин (отождествляемых с индексами в объекте AdjVec) возвращается через объект итератора вывода OutIt (второй параметр).

Для корректной работы алгоритма требуется, чтобы исходный граф был связным, неориентированным,  с чётными степенями всех вершин. Пример использования этой функции дан в TestCase3::testEulerLoop.

2. Класс-шаблон HamiltonianLoopEnumerator<AdjVec> реализует перебор всех гамильтоновых циклов в связном неориентированном графе, заданном вектором наборов соседей (AdjVec, аналогично функции findEulerLoop). Конструктор осуществляет «привязку» к исходному графу и инициализацию внутренних структур данных.

Функция-член next() находит следующий (или первый, если это первый вызов next() после создания объекта HamiltonianLoopEnumerator<AdjVec>) цикл, если таковой существует (в этом случае возвращает true). Если же искомый цикл не существует, то функция возвращает false. Алгоритм поиска основан на рекурсивном переборе с возвратом, переменная k отражает глубину рекурсии — количество вершин в строимой цепи (ещё не замкнутой в цикл).

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

Для удобства восприятия часть кода вынесена в закрытые функции rollback() (возврат с уменьшением k) и nextK() (увеличение k с инициализаций соответствующих структур данных).

3. Класс-шаблон MinimalHamiltonianLoop<AdjVec> использует класс HamiltonianLoopEnumerator<AdjVec> для перебора гамильтоновых циклов с выбором среди них цикла минимального веса (задаётся матрицей весов). Для запуска перебора всех циклов предназначена функция-член-шаблон run(const WeightMatrix &W), где W(i, j) задаёт вес ребра (i, j).

Есть также «ограниченный» вариант run(const WeightMatrix &W, std::size_t maxloops), перебирающий не больше, чем maxloops, гамильтоновых циклов. Её можно использовать, чтобы перебирать все циклы «пачками». После каждого вызова ограниченного варианта run, текущий цикл (доступный через диапазон итераторов begin(), end()) будет минимальным среди всех циклов, перебранных после создания объекта MinimalHamiltonianLoop<AdjVec>.

Итак, код целиком: codepad.org, paste.org.

Задания и вопросы

  1. Прочитать и понять приведённый код. Почему последние два цикла, выведенные на экран, совпадают?
  2. Что общего у эйлерова и гамильтонова циклов? Пусть дан граф G = (V, E), построим его рёберный граф Г = (V’, E’), такой, что |E| = |V’|, существует взаимно-однозначное отображение p из V’ в E, и E’ = {(a, b) из V’ x V’ | рёбра pa и pb инцидентны в G}. Написать функцию, строящую рёберный граф заданного графа. Реализуема ли обратная операция?
  3. Какие существуют методы оптимизации поиска гамильтонова цикла минимального веса?

Графы-2: минимизация на матрице весов

Цель

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

Код

Алгоритмы выполнены в виде функций-шаблонов, принимающих аргументы пяти основных типов:

  1. Индексы/количества вершин. Используется тип unsigned.
  2. Матрица весов/расстояний (параметр шаблона WeightMatrix), доступ к элементам осуществляется с помощью operator()(unsigned, unsigned), иными словами, a(i, j) — ij-элемент матрицы a. Для простоты полагается представление весов в виде double.
  3. Матрица предшественников (параметр шаблона PreviousMatrix), например, Pr. Тогда Pr(i, j) хранит индекс вершины, предшествующей j на пути из вершины i. Таким образом, элементы имеют тип unsigned.
  4. Массив («вектор») весов/расстояний (параметр шаблона WeightVector), доступ к элементам осуществляется с помощью operator[](unsigned), иными словами, d[i] — i-элемент массива d.
  5. Массив предшественников (параметр шаблона Previous). Аналог матрицы предшественников и массива весов.

По ссылке приведён код, где реализованы алгоритмы Ярника-Прима-Дейкстры построения минимального остова («minimal spanning tree»), Дейкстры поиска кратчайших путей от одной вершины до всех остальных, Флойда-Уоршелла поиска кратчайших путей между всеми парами вершин графа, а также приведён пример их использования.

Дополнительная ссылка на код.

Тест

Кроме того, данный код включает тест времени выполнения приведённых алгоритмов, который можно использовать, например, для тестирования различных реализаций матриц. Его точность не очень высока, однако результаты могут представлять определённый интерес. Далее приведён усреднённый (на 4 запусках) результат для двух машин (компилятор Visual C++ 2010):

  1. AMD Phenom2 955 @ 3.2GHz, 880G, 2x 2Gb DDR3-1333 9-9-9-24 — 512Mb IGP UMA.
  2. Intel Celeron U3400 @ 1.07GHz, H55, 4Gb DDR3-800.
Вариант Остов Дейкстра Флойд
AMD 32-bit 23.054с 23.167с 17.873с
AMD 64-bit 21.239с 20.137с 12.822с
Intel 32-bit 34.538с 68.543с 44.223с
Intel 64-bit 28.860с 62.888с 29.882с

Задания

  1. Прочитать и понять приведённый код. Из шести выводимых программой чисел три выводятся без поясняющего текста. Что означают эти три числа? Почему два последних совпадают?
  2. Реализовать алгоритм построения минимального остова Борувки-Краскала.
  3. Модифицировать алгоритм Флойда-Уоршелла для построения матрицы достижимости из матрицы смежности.
  4. Подумать об оптимизации рассмотренных алгоритмов для работы с разряжёнными графами. Разряжёнными называют графы, у которых число рёбер сопоставимо с числом вершин (например, планарные или графы с ограниченной степенью вершин).

Графы-1: списки смежности, поиск в ширину

Цель

Понять, что такое представление графа, называемое «списки смежности». Научиться его реализовывать на C++, использовать его для организации поиска.

Код

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

Пока будем считать, что вершина – это просто номер. Список смежности вершины находится в массиве по соответствующему индексу.

/// naive Adjacency Lists implementation
class AdjacencyLists
{
public:
  /// vertex representation, just its index for now
  typedef std::size_t Vertex;

Принято называть набор соседей «списком смежности», однако нетрудно видеть, что в нашем случае удобнее использовать библиотечный класс set, чем list. Собственно «представление» есть массив (vector) «списков смежности».

private:
  typedef std::set<Vertex> Adjacency;
  typedef std::vector<Adjacency> Repr;
  Repr repr; // the representation itself

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

public:
  /// acquire space for the given amount of vertices
  explicit AdjacencyLists(std::size_t vertices_ = 0u)
    : repr(vertices_) {}

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

  /// tests if there are any vertices
  bool empty() const
  {
    return repr.empty();
  }

  /// tell us, how many vertices are there
  std::size_t vertices() const
  {
    return repr.size();
  }

Нерекурсивный (использует стандартный класс queue) поиск в ширину. Начинает с вершины start. Для каждой найденной вершины вызывает функтор visit.

  /// breadth-first search
  /**
   * @param visit the functor to be called for each vertex
   * @param start the vertex to start search from
   * @return how many vertices have been visited 
   */
  template<class Visitor>
  std::size_t bfs(Visitor visit, Vertex start) const
  {
    if (vertices() <= start)
      return 0u;

    std::vector<bool> visited(vertices(), false);
    std::queue<Vertex> Q;
    Q.push(start);

    std::size_t res = 1u;
    visit(start);
    visited[start] = true;

    while (!Q.empty())
    {
      const Vertex v = Q.front();
      Q.pop();
      for (const Vertex w : repr[v]) // C++11
      {
        if (!visited[w])
        {
          Q.push(w);
          visit(w);
          visited[w] = true;
          ++res;
        }
      }
    }

    return res;
  }
};

Возможный код небольшого теста поиска в ширину. Должны быть
последовательно выведены числа от 0 до 12.

template <class T>
void println(const T &what)
{
  std::cout << what << "\n";
}

void test1()
{
  // 12 vertices
  Graphs::AdjacencyLists G(12);

  // add edges
  G.biconnect(0, 1);
  G.biconnect(0, 2);
  G.biconnect(0, 3);
  G.biconnect(1, 4);
  G.biconnect(1, 5);
  G.biconnect(3, 6);
  G.biconnect(3, 7);
  G.biconnect(4, 8);
  G.biconnect(4, 9);
  G.biconnect(6, 10);
  G.biconnect(6, 11);

  // should result into numbers 0 … 12
  std::cout << G.bfs(println<unsigned>, 0);
}

Этот же код целиком: codepad.

 

Задания

  1. Прочитать и понять приведённый код. Почему в конце выводится число 12?
  2. Реализовать поиск в глубину аналогично реализованному поиску в ширину bfs.
  3. Как использовать bfs, чтобы определить связность графа?
  4. Написать код, использующий bfs для выделения компонент связности.
  5. Найти недостатки приведённой выше реализации.
  6. Написать тест на скорость работы, позволяющий сравнить std::set и std::unordered_set (C++0x) или boost::unordered_set в качестве реализации «списка смежности».