Algorithm 15 – Minimum Spanning Tree

Author:

Algorithm 15 – Minimum Spanning Trees

Here introduce 2 types of algorithm based on different strategies to generate the MST given a graph. The best-known MST algorithm — Prim’s algorithm and Kruskal’s algorithm.

15.1 Problem Definition

The MST problem is about connecting a bunch of nodes as cheaply as possible. And this chapter assumes that the input graph is represented using adjacency lists.

15.1.2 Spanning Trees

  1. A tree shouldn’t contain a cycle.
  2. for every pair $v, w \in V$ of vertices, $T$ should include a path between $v$ and $w$.

Thus we can define the MST problem as:

  • Input: A connected undirected graph $G =(V, E)$ and a real-valued cost $c_e$ for each edge $e \in E$.
  • Output: A spanning tree $T \subset E$ of $G$ with the minimum-possible sum $\sum_{e\in T}c_e$ of edge costs.

15.2 Prim’s Algorithm

 

Input: connected undirected graph G = (V, E) in adjacency-list representation and a cost c_e for each edge e in E.
Output: the edges of a minimum spanning tree of G.
  
# Initialization
X = [s] # s is an arbitrarily chosen vertex.
T = [] # invariant: the edges in T span X
# Main Loop
while there is an edge (v, w) with v in X and w not in X:
  (v*, w*) = a minimum-cost such edge
  X.append(w*)
  T.append((v*, w*))
 	return T

 

Tips: it can be implemented with an extra list containing frontier edges, and the worst time complexity of calculating minimum cost of frontier edges is $O(m)$. And the while actually halts when all edges have been explored, which means $O(n)$ (Percisely it has $n-1$ iterations). Thus, it can be concluded as $O(mn)$.

Proposition 15.2 (Prim Running Time (Straightforward))

For every graph $G = (V, E)$ and real-valued edge costs, the straightforward implementation of Prim runs in $O(mn)$ time, where $m = |E|$ and $n = |V|$.

15.3 Speeding Up Prim’s Algorithm via Heaps.

Here we introduce an algorithm with near-linear running time.

Theorem 15.3 (Running Time of Three Heap Operations)

In a heap with $n$ objects, the INSERT, EXTRACTMIN, DELETE operations run in $O(\log n)$ time. More importantly, the hidden constant behind the big-O notation is competitively small.

Theorem 15.4 (Prim Running Time (Heap-based))

For every graph $G = (V, E)$ and real-valued edge costs, the heap-based implementation of prim runs in $O((m+ n)\log n)$ time.

 

class GroupVertice(Graph.Vertice):

    def __init__(self, key=None, winner=None):
        self.key = key
        self.winner = winner


class Prim:

    def __init__(self, graph):
        self._graph = graph

        self._min_span_tree = []
        self._explored_vertex = []

        # arbitrarily choose the initial vertice
        start = random.choice(graph.vertex)

        # update the unexplored vertex
        self._unexplored_vertex = [
            vertice for vertice in graph.vertex if vertice != start]

        self._explored_vertex.append(start)

        # initilization the keys and winners in terms of vertex
        vertex = self._graph.vertex
        for vertice in vertex:
            edge = graph.get_edge_by_vertex(start, vertice)
            if vertice != start and edge:
                vertice.key = edge.weight
                vertice.winner = edge
            else:
                vertice.key = math.inf
                vertice.winner = None

        self._heap = Heap.MinHeap(self._unexplored_vertex, lambda x: x.key)

        assert self._heap.heap is not []

    def run(self):

        # Main Loop

        # if Heap is non-empty
        while self._heap.heap:

            # the default mode is extract max
            opt_vertice = self._heap.extract_root()

            # add w_opt into the explored node set
            self._explored_vertex.append(opt_vertice)

            # add the local winner edge leading to w_opt to tree
            self._min_span_tree.append(opt_vertice.winner)

            # update keys to maintain invariant
            # likewise in the dijkstra
            for index in range(len(self._unexplored_vertex)):

                # get target vertice from index
                unexplored_vertice = self._unexplored_vertex[index]

                # get edge (opt_vertice, unexplored_vertice)
                edge_probe = self._graph.get_edge_by_vertex(
                    opt_vertice, unexplored_vertice)

                if edge_probe is None:
                    continue

                # check if the edge satisfies the update condition
                if edge_probe.weight < unexplored_vertice.key:
                    self._heap.delete(index)
                    unexplored_vertice.key = edge_probe.weight
                    unexplored_vertice.winner = edge_probe
                    self._heap.insert(unexplored_vertice)

        return self._min_span_tree

 

the whole code could be found on Github.

15.5 Kruskal’s Algorithm

while Prim’s algorithm was constrained to choose the cheapest edge crossing the current frontier, Kruskal’s algorithm is free to choose the cheapest remaining edge in the entire graph.

15.5.2 Pseudocode

Input: an undirected graph G = (V, E)
Output: the edges of a minimum spanning tree of G

# preprocessing
T := []
sort edges of E by cost # e.g. using QuickSort
# Main Loop
for e in E:
	if T + {e} is acyclic:
		T = T + {e}
return T

15.5.3 Straightforward Implementation

In the preprocessing step, the sorting algorithm contributes $O(m\log n)$ work to the overall running time. (Why $O(m\log n)$ instead of $O(m\log m)$? Because there’s no difference between the two.)

In the main loop, we want to check if the graph has a cycle after adding a new edge into it. We can use breadth- or depth-first searching to check if there are any cycle, and these algorithms are $O(m+n)$, and we have $n$ iterations. Thus, the overall running time is $O((m+n)n)=O(mn)$(Because $n-1\le m\le n(n-1)/2$)

15.12 Kruskal Run Time (Straightforward)

For every graph $G = (V, E)$ and real-valued edge costs, the straightforward implementation of Kruskal runs in $O(mn)$ time, where $m=|E|$ and $n=|V|$.

15.6 Union-Find

15.13 Kruskal Run Time (Union-Find-Based)

For every graph $G = (V, E)$ and real-valued edge costs, the straightforward implementation of Kruskal runs in $O((m+n)\log n)$ time, where $m=|E|$ and $n=|V|$.

15.6.1 The Union Find Data Structure

The raison detre of the union-find data structure is to maintain a partition of a static set of objects. In the initial partition, each object is in its own set.

 

class Wrapper:

    def __init__(self, item, parent):
        self.item = item
        self.parent = parent
        # to track the size of the union tree
        self.size = 1

    def __str__(self):
        return "item: {0}, parent: {1}, size: {2}".format(self.item, self.parent, self.size)

class UnionFind:
    """
    This data structure is implemented with list.
    key: the position in the list.
    value: a user-defined object containing the parent position it directs to.
    """

    def __init__(self, array) -> None:
        """
        Args:
            array (List<Wrapper(item, parent)>): A list of object desired
        """

        self.array = array
        self.wrapper_dict = {item: Wrapper(item, item) for item in array}

    def find(self, target):
        return self._find_wrapper(target).item
    
    def _find_wrapper(self, target):
        target_wrapper = self.wrapper_dict[target]

        # recursive-based find
        if target_wrapper.item != target_wrapper.parent:
            return self._find_wrapper(target_wrapper.parent)

        # base case
        return target_wrapper

    def union(self, cand1, cand2):
        root1 = self._find_wrapper(cand1)
        root2 = self._find_wrapper(cand2)

        if root1 == root2:
            return

        if root1.size > root2.size:
            # merge the less deep one into the deep one
            root2.parent = root1.item
            root1.size = root1.size + root2.size
        elif root1.size <= root2.size:
            root1.parent = root2.item
            root2.size = root1.size + root2.size
        return

 

Theorem 15.14 (Running Time of Union-Find Operations)

Operation Running time
INITIALIZE $O(n)$
FIND $O(\log n)$
UNION $O(\log n)$

15.6.2 Pseudocode for Kruskal

 

Input: an undirected graph G = (V, E)
Output: the edges of a minimum spanning tree of G

# preprocessing
T := []
U := INITIALIZE(V)
sort edges of E by cost # e.g. using QuickSort

# Main Loop
for (v, w) in E:
	if FIND(U, v) ≠ FIND(U, w):
		 T := T + {(v, w)}
     UNION(U, v, w) 
return T

 

15.6.3 Running Time Analysis

preprocessing $O(n) + O(m\log n)$, $2m$ FIND operations $O(m\log n)$, $n-1$ UNION operations $O(n\log n)$, remaining bookkeeping $O(m)$.

Then total $O((m+n)\log n)$

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注