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

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

Визуализация графов проводится с помощью graphonline.

Формальная постановка задачи

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

Исходный граф
Исходный граф

Неформальная постановка задачи

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

Алгоритм Краскала

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

  • Вначале мы производим сортировку рёбер по неубыванию по их весам.

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

  • Алгоритм завершит свою работу после того, как множество вершин нашего подграфа совпадет с множеством вершин исходного графа.

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

Разбор конкретного примера по шагам

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

1) D <--> B; w = 2
2) D <--> C; w = 6
3) A <--> B; w = 7
4) A <--> C; w = 8
5) C <--> E; w = 9
6) D <--> F; w = 9
7) F <--> E; w = 10
8) B <--> C; w = 11
9) D <--> E; w = 11


И начнем по списку добавлять эти ребра в наш остов:

Подграф после добавиления 1-го ребра
Подграф после добавиления 1-го ребра
Подграф после добавления 2-го и 3-го рёбер
Подграф после добавления 2-го и 3-го рёбер

При добавлении в наше остовное дерево ребра A <--> C, как вы можете заметить, образовывается цикл, поэтому мы просто пропускаем данное ребро.

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

Минимальный остов
Минимальный остов

Проводим проверку с помощью встроенного алгоритма для нахождения MST на graphonline, и видим, что подграфы идентичны.
И да, из-за того, что при равенстве весов рёбер мы можем выбрать любое из них, конечные подграфы, являющиеся минимальными остовными деревьями, могут различаться с точностью до некоторых рёбер.

Провели проверку
Провели проверку

Суммарный вес искомого MST равен 33.

Реализация

Реализовать представленный алгоритм проще всего с помощью СНМ(система непересекающихся отрезков).

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

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

O(ElogV + V + E) = O(ElogV), где:
sort() - O(ElogV)
make_set()- O(V)
find_set - O(1)
union_sets - O(1)

Псевдокод
vector<int> parent, rank;

void make_set(int v) {
    parent[v] = v;
    rank[v] = 0;
}

int find_set(int v) {
    if (v == parent[v])
        return v;
    return parent[v] = find_set(parent[v]);
}

void union_sets(int a, int b) {
    a = find_set(a);
    b = find_set(b);
    if (a != b) {
        if (rank[a] < rank[b])
            swap(a, b);
        parent[b] = a;
        if (rank[a] == rank[b])
            rank[a]++;
    }
}

struct Edge {
    int u, v, weight;
    bool operator<(Edge const& other) {
        return weight < other.weight;
    }
};

int n;
vector<Edge> edges;

int cost = 0;
vector<Edge> result;
parent.resize(n);
rank.resize(n);
for (int i = 0; i < n; i++)
    make_set(i);

sort(edges.begin(), edges.end());

for (Edge e : edges) {
    if (find_set(e.u) != find_set(e.v)) {
        cost += e.weight;
        result.push_back(e);
        union_sets(e.u, e.v);
    }
}

Алгоритм Прима

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

  • Изначально наш подграф состоит из одной любой вершины исходного графа.

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

  • Продолжаем выполнять предыдущий шаг до тех пор, пока не найдем искомое MST.

Разбор конкретного примера

Выбираем чисто случайно вершину E,далее рассмотрим все ребра исходящие из нее, включаем в наше остовное дерево ребро C <--> E; w = 9, так как данное ребро имеет минимальный вес из всех рёбер инцидентных множеству вершин нашего подграфа. Имеем следующее:

Подграф после добавления 1-го ребра
Подграф после добавления 1-го ребра

Теперь выборка производится из рёбер:
D <--> C; w = 6
A <--> C; w = 8
F <--> E; w = 10
B <--> C; w = 11
D <--> E; w = 11

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

Добавляем в наш подграф реброD <--> C и по аналогии добаляем ребро D <--> B. Получаем следующее:

Подграф, полученный после добавления рассмотренных рёбер
Подграф, полученный после добавления рассмотренных рёбер

Давайте добьем наш подграф до минимального остовного дерева. Вы, наверное, уже догадались о том, по каким ребрам мы будем связывать наши оставшиеся вершины:
A и F.

Проводим последние штрихи и получили тот же самый подграф в качестве минимального остовного дерева. Но как мы раннее говорили, сам подграф ничего не решает, главное тут - множество рёбер, которые включены в наше остовное дерево.

Искомое минимальное остовное дерево
Искомое минимальное остовное дерево

Суммарный вес искомого MST равен 33.

Источники

Инструмент для работы с графами
Лекция Павла Маврина

Комментарии (7)


  1. AmartelRU
    25.07.2021 01:07
    +3

    Формально правильно, а по сути издевательство ​​(с)

    Рассмотрим возможные улучшения Вашей версии алгоритма Краскала.

    1. "Добавляем i-ое ребро в наше остовное дерево" - Вы не говорите, откуда у нас вообще взялось "остовное дерево" (на самом деле, некий частичный граф, который станет остовным деревом в конце алгоритма) и как оно инициализировано.

    2. Проверять каждый раз возникновение цикла - плохая идея, так что Ваш пункт 2 лучше переформулировать как-то так: "Добавляем i-ое ребро в частичный граф G', если оно соединяет две различные компоненты связности". Изначально G' не содержит ребёр, т.е. каждая вершина является отдельной компонентой связности. Можно сопоставить им номера, и корректировать их при добавлении ребра (добавляем ребро, инцидентное вершинам A и B с номерами 3 и 6 - значит, все вершины с номерами 3 и 6 получают некий новый номер, это может быть и 3, и 6, и некое f(3, 6)).

    3. Рассматривать все рёбра графа вместо необходимого количества - это не просто детали реализации, это верный путь существенно ухудшить асимптотику. Полный граф с m вершинами содержит m*(m-1)/2 ребёр. В то время как остовное дерево любого графа с m вершинами содержит m - 1 ребро. Таким образом, очевидно, что необходимо выполнить ровно m - 1 шаг алгоритма (если рассуждать иначе - изначально есть m компонент связности и на каждом шаге две компоненты связности объединяются в одну, что уменьшает их количество на 1). Т.е. количество ребёр G' растёт с 0 до m - 1, а количество компонент связности G' уменьшается с m до 1.

    В общем и целом, мне кажется, алгоритмы не сложные и на поверхностном уровне понимания "бери это, добавляй туда" всё понятно. А вот нюансы Вы не осветили в должной мере.


    1. m_Rassska Автор
      25.07.2021 11:31
      +1

      Здравствуйте! Учел ваш комментарий, кажется, статья стала немного лучше выглядеть :)
      С 1-ым пунктом абсолютно согласен, исправил свою ошибку.
      Со 2-ым пунктом тоже согласен, но вот попытка объяснить все то, что возможно на неформальном языке, кажется, провалилась :)
      С 3-им пунктом согласен полностью - поправил.


  1. sashagil
    25.07.2021 01:42
    +1

    Скажите, если вы изложили (наглядно!) два алгоритма, сводящихся к жадному перебору рёбер, не рассматривали ли вы возможность сравнить какие-либо характеристики (память, время, логическая сложность реализации)? Также любопытен (мне лично) аспект набора практических задач, в которых возникает необходимость находить остовное дерево (так как мне лично за многолетнюю карьеру программиста не попадалось сколь-либо близко, задача кажется довольно академичной). На лекцию "клик" не делаю, если есть возможность общения в текстовом режиме.


    1. m_Rassska Автор
      25.07.2021 12:56

      Добрый день! Спасибо большое за ваш фидбек, статью поправляю!


  1. Olegun
    25.07.2021 08:48
    +1

    В части описания алгоритма Прима отсутствуют правила включения в дерево вершин вида F (см. пример). А в разборе конкретного примера алгоритма Прима про случай F предлагается догадаться. В начале написано - "что вы после прочтения данной статьи, примерно понимали, как работают жадные алгоритмы нахождения MST". У меня не получилось. Мне кажется цель статьи не достигнута и второй алгоритм "не разжеван".


    1. m_Rassska Автор
      25.07.2021 16:41

      Работаю над этим, спасибо большое за ваш отзыв!


    1. Dimonovych
      26.07.2021 04:51

      Поддержу - непонятно каким образом произошло разветвление во втором примере.