Boruvka's Algorithm: Parallel-Friendly MST
Otakar Borůvka developed his minimum spanning tree algorithm in 1926 to solve an electrical network optimization problem in Moravia. Nearly a century later, this algorithm is experiencing a...
Key Insights
- Borůvka’s algorithm, the oldest MST algorithm (1926), is uniquely suited for parallel computing because all components can independently find their minimum outgoing edges in each round
- The algorithm completes in O(log V) rounds with O(E) work per round, making it ideal for GPU and distributed implementations where Prim’s and Kruskal’s struggle
- Modern graph processing frameworks like Spark GraphX use Borůvka-style approaches because the algorithm maps naturally to the bulk synchronous parallel model
Introduction to Borůvka’s Algorithm
Otakar Borůvka developed his minimum spanning tree algorithm in 1926 to solve an electrical network optimization problem in Moravia. Nearly a century later, this algorithm is experiencing a renaissance—not because we’ve discovered new theoretical properties, but because modern computing architectures finally match its execution model.
While Kruskal’s and Prim’s algorithms dominate textbooks and interviews, they share a fundamental limitation: sequential dependencies. Kruskal’s requires processing edges in sorted order, checking each against a union-find structure. Prim’s grows the tree one vertex at a time, each step depending on the previous. These algorithms resist parallelization.
Borůvka’s algorithm takes a different approach. In each round, every component of the growing forest independently finds its cheapest outgoing edge. All these edges are added simultaneously, components merge, and the process repeats. This independence is exactly what parallel and distributed systems need.
How Borůvka’s Algorithm Works
The algorithm operates in rounds. Initially, each vertex is its own component. In each round:
- Every component finds its minimum-weight edge connecting it to a different component
- All these edges are added to the MST simultaneously
- Connected components merge
- Repeat until only one component remains
The key insight: the number of components at least halves each round (each component connects to at least one other), guaranteeing O(log V) rounds.
Here’s a clean sequential implementation:
class BoruvkaMST:
def __init__(self, vertices: int):
self.vertices = vertices
self.parent = list(range(vertices))
self.rank = [0] * vertices
def find(self, x: int) -> int:
if self.parent[x] != x:
self.parent[x] = self.find(self.parent[x])
return self.parent[x]
def union(self, x: int, y: int) -> bool:
px, py = self.find(x), self.find(y)
if px == py:
return False
if self.rank[px] < self.rank[py]:
px, py = py, px
self.parent[py] = px
if self.rank[px] == self.rank[py]:
self.rank[px] += 1
return True
def compute_mst(self, edges: list[tuple[int, int, float]]) -> list[tuple[int, int, float]]:
mst = []
num_components = self.vertices
while num_components > 1:
# Phase 1: Find minimum outgoing edge for each component
cheapest = {} # component -> (weight, u, v)
for u, v, weight in edges:
comp_u, comp_v = self.find(u), self.find(v)
if comp_u == comp_v:
continue
# Update minimum for both components
for comp, edge_data in [(comp_u, (weight, u, v)), (comp_v, (weight, v, u))]:
if comp not in cheapest or edge_data[0] < cheapest[comp][0]:
cheapest[comp] = edge_data
# Phase 2: Add all minimum edges
for comp, (weight, u, v) in cheapest.items():
if self.union(u, v):
mst.append((u, v, weight))
num_components -= 1
return mst
Notice the clear separation between finding minimum edges (Phase 1) and merging components (Phase 2). This separation is what enables parallelization.
Why Borůvka’s is Naturally Parallelizable
The parallelization opportunity lies in Phase 1. When finding minimum outgoing edges, each component’s computation is completely independent. No component needs to know what edges other components are considering. This is embarrassingly parallel.
Compare this to Prim’s algorithm, where adding vertex k to the tree changes the frontier for vertex k+1. Or Kruskal’s, where processing edge k might affect whether edge k+1 creates a cycle. These sequential dependencies kill parallelism.
Borůvka’s work structure looks like this:
Round 1: V components, each scans ~E/V edges → O(E) total work, O(E/P) parallel time
Round 2: ≤V/2 components, each scans ~2E/V edges → O(E) total work
...
Round log(V): 2 components → O(E) total work
Total: O(E log V) work, O((E/P) log V) parallel time
Here’s pseudocode showing the parallel structure:
parallel function find_minimum_edges(edges, component_of):
local_min = new ConcurrentHashMap<Component, Edge>()
parallel for each edge (u, v, w) in edges:
comp_u = component_of[u]
comp_v = component_of[v]
if comp_u ≠ comp_v:
atomic_update_minimum(local_min, comp_u, edge)
atomic_update_minimum(local_min, comp_v, edge)
return local_min
The critical operation is atomic_update_minimum, which uses compare-and-swap to update the minimum edge for a component without locks.
Parallel Implementation Strategies
For shared-memory parallelism, OpenMP provides a straightforward approach. The main challenges are concurrent updates to the cheapest map and efficient union-find with path compression.
#include <omp.h>
#include <vector>
#include <atomic>
#include <limits>
struct Edge {
int u, v;
double weight;
};
struct AtomicEdge {
std::atomic<double> weight{std::numeric_limits<double>::max()};
std::atomic<int> u{-1}, v{-1};
void try_update(int new_u, int new_v, double new_weight) {
double current = weight.load(std::memory_order_relaxed);
while (new_weight < current) {
if (weight.compare_exchange_weak(current, new_weight,
std::memory_order_release, std::memory_order_relaxed)) {
u.store(new_u, std::memory_order_relaxed);
v.store(new_v, std::memory_order_relaxed);
break;
}
}
}
};
class ParallelBoruvka {
std::vector<std::atomic<int>> parent;
int vertices;
int find(int x) {
int root = x;
while (parent[root].load(std::memory_order_relaxed) != root) {
root = parent[root].load(std::memory_order_relaxed);
}
// Path compression (best-effort, not critical for correctness)
while (parent[x].load(std::memory_order_relaxed) != root) {
int next = parent[x].load(std::memory_order_relaxed);
parent[x].store(root, std::memory_order_relaxed);
x = next;
}
return root;
}
public:
ParallelBoruvka(int v) : vertices(v), parent(v) {
for (int i = 0; i < v; i++) parent[i].store(i);
}
std::vector<Edge> compute_mst(const std::vector<Edge>& edges) {
std::vector<Edge> mst;
std::vector<AtomicEdge> cheapest(vertices);
int num_components = vertices;
while (num_components > 1) {
// Reset cheapest edges
#pragma omp parallel for
for (int i = 0; i < vertices; i++) {
cheapest[i].weight.store(std::numeric_limits<double>::max());
cheapest[i].u.store(-1);
}
// Parallel minimum edge finding
#pragma omp parallel for schedule(dynamic, 1000)
for (size_t i = 0; i < edges.size(); i++) {
const Edge& e = edges[i];
int comp_u = find(e.u);
int comp_v = find(e.v);
if (comp_u != comp_v) {
cheapest[comp_u].try_update(e.u, e.v, e.weight);
cheapest[comp_v].try_update(e.v, e.u, e.weight);
}
}
// Sequential merge phase (simpler, still fast)
for (int i = 0; i < vertices; i++) {
if (cheapest[i].u.load() != -1) {
int u = cheapest[i].u.load();
int v = cheapest[i].v.load();
int pu = find(u), pv = find(v);
if (pu != pv) {
parent[pu].store(pv);
mst.push_back({u, v, cheapest[i].weight.load()});
num_components--;
}
}
}
}
return mst;
}
};
For distributed systems, the algorithm maps naturally to the Bulk Synchronous Parallel (BSP) model. Each machine holds a partition of edges, finds local minimums, then a global reduction determines the true minimum per component. Spark GraphX implements exactly this pattern.
Performance Analysis and Trade-offs
The theoretical complexity tells part of the story:
| Algorithm | Sequential | Parallel Depth | Parallelizable Work |
|---|---|---|---|
| Prim’s | O(E log V) | O(V log V) | Limited |
| Kruskal’s | O(E log E) | O(E) | Sort only |
| Borůvka’s | O(E log V) | O(log² V) | Full |
Here’s a simple benchmark comparing implementations:
import time
import random
from concurrent.futures import ThreadPoolExecutor
def benchmark_mst(vertices: int, edge_density: float, num_trials: int = 5):
edges = []
for i in range(vertices):
for j in range(i + 1, vertices):
if random.random() < edge_density:
edges.append((i, j, random.random()))
# Sequential Boruvka
seq_times = []
for _ in range(num_trials):
solver = BoruvkaMST(vertices)
start = time.perf_counter()
solver.compute_mst(edges)
seq_times.append(time.perf_counter() - start)
print(f"Vertices: {vertices}, Edges: {len(edges)}")
print(f"Sequential: {sum(seq_times)/len(seq_times)*1000:.2f}ms")
# Run benchmarks
for v in [1000, 5000, 10000]:
benchmark_mst(v, 0.01)
Borůvka’s wins when you have available parallelism and large graphs. It loses on small graphs where parallel overhead dominates, or when edges are already sorted (giving Kruskal’s an advantage). Memory usage is higher than Prim’s due to storing per-component state across all processors.
Real-World Applications
Graph Processing Frameworks: Spark GraphX’s connected components and MST implementations use Borůvka-style iterations. The algorithm’s round-based structure matches Pregel’s superstep model perfectly.
Image Segmentation: Felzenszwalb’s efficient graph-based segmentation uses a Borůvka variant. Each pixel region finds its most similar neighbor, regions merge, creating hierarchical segmentations.
Network Design: When designing physical networks (fiber, electrical grids), the problem naturally distributes across geographic regions. Each region can independently find its cheapest external connection.
Clustering at Scale: Hierarchical clustering on million-node graphs uses Borůvka’s structure. The log V round guarantee means even massive graphs complete in reasonable iterations.
Conclusion
Reach for Borůvka’s algorithm when three conditions align: your graph is large enough that parallelization overhead pays off (typically 10,000+ vertices), you have parallel resources available (multiple cores, GPUs, or distributed nodes), and you’re not memory-constrained (the algorithm needs per-component state).
For single-threaded code on small graphs, Kruskal’s with a good union-find implementation remains hard to beat. For dense graphs where you’re building incrementally, Prim’s with a Fibonacci heap excels. But for the increasingly common case of massive sparse graphs processed on parallel hardware, Borůvka’s century-old algorithm is the modern choice.