Поиск минимального остовного дерева
Остовное дерево (spanning tree), или остов, связного неориентированного графа - это остовный подграф этого графа, являющийся деревом, или, другими словами, связный остовный подграф без циклов. Вес $w(T)$ остова $T$ взвешенного графа - это сумма весов его рёбер.
Пусть дан связный неориентированный граф с неотрицательными весами рёбер, и мы хотим выбрать подмножество рёбер как можно меньшего суммарного веса, с помощью которого можно было бы добраться из любой вершины в любую. Если в подмножестве рёбер есть цикл, из него всегда можно выкинуть одно из рёбер, не увеличив суммарный вес и не нарушив связности. Значит, мы хотим найти остовное дерево минимально возможного веса, или минимальное остовное дерево (minimum spanning tree).
Лемма о разрезе
Поставленную задачу мы научимся решать жадно сразу двумя способами. В основе обоих алгоритмов лежит одна и та же лемма.
Разрез (cut) $(A, B)$ графа $G=(V, E)$ - это произвольное разбиение множества вершин графа на два непересекающихся множества: $A \cup B=V, A \cap B=\emptyset$. Будем говорить, что ребро $e$ проходит через разрез $(A, B)$, если оно соединяет вершину из $A$ с вершиной из $B$.
Лемма о разрезе
Пусть множество рёбер $F \subset E$ входит в некоторый минимальный остов графа $G=(V, E)$. Пусть $(A, B)$ - разрез $G$, причём ни одно ребро из $F$ не проходит через разрез $(A, B)$. Пусть $e$ - ребро с наименьшим весом из всех, проходящих через разрез $(A, B)$. Тогда $F \cup\{e\}$ входит в некоторый минимальный остов графа $G$.
Алгоритм Прима
Алгоритм Прима (Jarnik, 30; Prim, 57; Dijkstra, 59) постепенно расширяет множество вершин $A$ и множество рёбер $F$, образующих дерево на $A$. Вначале $A$ состоит из одной любой вершины, $F$ пусто. На каждом шаге алгоритм находит ребро $e=(u, v)$ минимального веса, ведущее из $A$ в $V \backslash A$. Конец этого ребра $v \in V \backslash A$ алгоритм добавляет в $A$, а само ребро $e-$ к множеству $F$. При этом $F \cup\{e\}$ образует дерево на $A \cup\{v\}$.
По лемме о разрезе для множества рёбер $F$ и разреза ( $A, V \backslash A$ ) если $F$ входило в какой-то минимальный остов, то и $F \cup\{e\}$ тоже входит в какой-то минимальный остов. Когда $A=V$, рёбра $F$ образуют остовное дерево всего графа $G$, при этом $F$ входит в какое-то минимальное остовное дерево. Значит, минимальное остовное дерево состоит ровно из рёбер множества $F$.
Для того, чтобы быстро находить вершину $v$ и вес ребра $w_{e}$, для каждой вершины $u \in V$ алгоритм поддерживает вес минимального ребра, ведущего из $A$ в $u$ :
$$ d_{u}=\min \left\{w_{f}: f: a \rightarrow u, a \in A\right\} . $$Тогда на очередном шаге $v$ - это вершина из $V \backslash A$ с минимальным значением $d_{v}$, а само $d_{v}$ - вес ребра $e$. Для перехода к следующему шагу нужно обновить значения $d_{u}$ весами рёбер, исходящих из $v$.
Получается алгоритм, практически идентичный алгоритму Дейкстры (меняются лишь приоритеты вершин). Соответственно, в зависимости от реализации, можно получить оценки времени работы $O\left(V^{2}+E\right), O((V+E) \log V)(O(V \log V+E)$ при использовании фибоначчиевой кучи).
|
|
Если нужно восстановить не только вес минимального остова, но и множество входящих в него рёбер, нужно для каждой вершины $u$ дополнительно запоминать, весу какого ребра равняется $d_{u}$.
Алгоритм Краскала
Алгоритм Kраскала (Kruskal, 56) действует тоже жадно, но по-другому: он начинает с пустого множества $F$, рассматривает рёбра в порядке возрастания веса и добавляет очередное ребро $e$ в $F$, если при этом в $F$ не появится цикла.
В любой момент времени рёбра $F$ образуют лес, и, поскольку граф связен, в конце алгоритма $F$ будет образовывать остовное дерево. Из леммы о разрезе снова следует, что в любой момент времени $F$ входит в какой-то минимальный остов (на этот раз нужно в качестве разреза взять ( $A, V \backslash A$ ), где $A$ - компонента связности одного из концов ребра $e$ в графе, образованном рёбрами $F$ ).
Для реализации алгоритма Краскала нам не хватает ещё одной детали: нужно научиться быстро понимать, образуется ли цикл при добавлении ребра $e$ к множеству $F$. Цикл образуется тогда и только тогда, когда концы ребра $e$ находятся в одной компоненте связности графа, образованного рёбрами $F$. Таким образом, наша ближайшая цель научиться быстро проверять, находятся ли две вершины в одной компоненте связности (а также быстро объединять две компоненты связности в одну).
Система непересекающихся множеств
Система непересекающихся множеств (disjoint set union, DSU) - структура данных, позволяющая поддерживать разбиение $n$ элементов на непересекающиеся множества, а именно:
- выдавать по элементу идентификатор множества, в котором лежит этот элемент;
- объединять два множества в одно.
Будем считать, что элементы пронумерованы числами от 0 до $n-1$. Необходимо реализовать две функции: get (a) - функцию, возвращающую идентификатор множества, где лежит $a$ (в частности, с помощью этой функции можно проверять, лежат ли два элемента в одном множестве); а также join( $\mathrm{a}, \mathrm{b}$ ) - функцию, объединяющую множества, в которых лежат элементы $a$ и $b$. Пусть в случае, когда $a$ и $b$ лежат в одном множестве, функция join(a,b) ничего не делает.
Также будем считать, что после инициализации структуры каждое множество состоит из одного элемента и get(i) = i (если в решаемой задаче это не так, можно после инициализации объединить элементы в нужные множества с помощью функции join).
Реализация связными списками
Будем поддерживать $i d[i]$ - номер множества, в котором лежит элемент $i$, а для множества с номером $i$ будем поддерживать список его элементов elems $[i]$. Единственная хитрость: при объединении множеств сохраним для объединённого множеств идентификатор большего множества из объединяемых, тогда нужно поменять значения $i d$ лишь у элементов меньшего множества. Объединение списков же осуществляется за $O(1)$ (достаточно соединить ссылками конец одного списка и начало другого).
|
|
При такой реализации функция get работает за $O(1)$.
Предложение
Суммарное время работы $m$ вызовов јоіп есть $O(m+n \log n)$.
Заметим, что такая реализация DSU уже позволяет реализовать алгоритм Краскала за время $O(E \log E+E+E \log V)=O(E \log V)$ : первое слагаемое - сортировка рёбер по весу - мажорирует время работы последующих шагов.
✍️ $O(E \log E)=O(E \log V)$, так как $E \leqslant V^{2}$, если в графе нет кратных рёбер (а если они есть, от них можно предварительно избавиться, оставив лишь самое лёгкое ребро между каждой парой вершин).
Тем не менее, мы изучим ещё одну реализацию DSU, которая даёт лучшую амортизированную оценку времени работы операций, чем реализация связными списками, и ничуть не сложнее с точки зрения технической реализации.
Реализация деревьями
Каждое множество будем хранить в виде подвешенного дерева, вершины которого - это элементы множества. Для каждого элемента $v$ будем хранить ссылку $p[v]$ на родителя в его дереве-множестве; условимся, что для корня дерева ссылка на родителя ведёт на сам корень: $p[v]=v$. В качестве идентификатора множества удобно использовать корень дерева.
Теперь функция get (v) - это просто подъём в корень дерева, где лежит $v$, а функцию join(a, b) можно реализовать, например, подвесив корень одного дерева ребёнком к корню другого.
|
|
Время работы join - это $O(1)$ плюс время работы двух запусков get. K сожалению, время работы get пока в худшем случае оценивается как $\Theta(n)$, так как высота дерева может равняться его размеру. Для улучшения оценки на время работы get применяются следующие две эвристики.
Ранговая эвристика
При объединении деревьев логично подвешивать более низкое дерево к более высокому, тогда высота будет расти медленнее. Для каждой вершины $v$ будем дополнительно хранить ещё одно значение: ранг $r k[v]$, равный высоте поддерева вершины $v$. Вначале $r k[v]=0$ для всех вершин, а при объединении деревьев будем подвешивать корень с меньшим рангом ребёнком к корню с большим рангом. При равенстве рангов неважно, какой корень к какому подвешивать, но надо увеличить ранг корня получившегося дерева на единицу.
|
|
Предложение
Для рангов вершин выполняются следующие свойства:
- если $v \neq p[v]$, то $r k[v] < r k[p[v]]$;
- вершина ранга $k$ имеет поддерево размера хотя бы $2^{k}$;
- количество вершин ранга $k$ не превосходит $n / 2^{k}$.
Из третьего свойства, в частности, следует, что ранг любой вершины не превосходит $\log _{2} n$. Значит, высота любого дерева не превосходит $\log _{2} n$, и время работы функции get теперь оценивается как $O(\log n)$.
Эвристика сжатия путей
Другая идея: когда функция get(v) проходит путь к корню дерева $v$ и находит его, можно переподвесить $v$ напрямую к корню, чтобы при следующем запуске не проходить этот путь заново; структура дерева изменится, но само множество, соответствующее дереву, при этом останется прежним, поэтому такая операция корректна.
|
|
Если использовать эвристику сжатия путей без ранговой эвристики, то тоже можно получить оценку времени работы get $O(\log n)$, но уже амортизированную.
Предложение
При использовании эвристики сжатия путей без ранговой эвристики $m$ запросов get имеют суммарное время работы $O(m+(m+n) \log n)$.
Совместное использование эвристик
Что будет, если использовать обе эвристики одновременно? Заметим, что в этом случае ранг уже не всегда равен высоте поддерева вершины. Тем не менее, большая часть свойств остаётся верной.
Предложение
Для рангов вершин выполняются следующие свойства:
- если $v \neq p[v]$, то $r k[v] < r k[p[v]]$;
- корневая вершина ранга $k$ имеет поддерево размера хотя бы $2^{k}$;
- количество вершин ранга $k$ не превосходит $n / 2^{k}$.
Определение
Итерированный логарифм $n$ по основанию $a$
$$ \log _{a}^{*} n= \begin{cases}0, & \text { если } n \leqslant 1, \\ 1+\log _{a}^{*}\left(\log _{a} n\right), & \text { если } n>1 .\end{cases} $$✍️ На практике можно считать, что если $n$ - число элементов, то $\log _{2}^{*} n \leqslant 5$, так как даже $\log _{2}^{*}\left(2^{65536}\right)=5$.
Теорема
При использовании обеих эвристик суммарное время работы $m$ запросов get есть $O\left(m+n \log _{2}^{*} n\right)$.
✍️ Можно показать, что суммарное время работы $m$ запросов get не превосходит $O(m \cdot \alpha(n))$, где $\alpha(n)$ - обратная функция Аккермана, растущая ещё медленнее, чем итерированный логарифм.
Возвращаясь к алгоритму Краскала
|
|
При использовании обеих эвристик получаем время работы $O\left(E \log V+E+V \log _{2}^{*} V\right)$ (или $O(E \log V+E \cdot \alpha(V))$ ). В случае, когда рёбра уже даны в порядке возрастания веса, либо когда из-за особенностей задачи рёбра можно отсортировать быстрее, чем за $O(E \log V)$, реализация DSU деревьями позволяет улучшить эффективность алгоритма.
✍️ Помимо изученных нами алгоритмов, известен рандомизированный алгоритм поиска минимального остова с линейным математическим ожиданием временем работы (Karger, Klein, Tarjan, 1995), а также алгоритм с временем работы $O(E \cdot \alpha(E, V))$ (Chazelle, 2000), где $\alpha$ - двупараметрическая версия обратной функции Аккермана (которая тоже растёт очень медленно).