Minimum Spanning Trees
The Problem
You are given a weighted, undirected graph with \(n\) nodes and \(m\) edges. Find a spanning tree — a subset of edges that connects all nodes — with the minimum total edge weight.
A spanning tree of \(n\) nodes has exactly \(n - 1\) edges and no cycles. Among all possible spanning trees, the one with the smallest sum of edge weights is the Minimum Spanning Tree (MST).
The Greedy Choice: At every step, pick the cheapest available edge that does not create a cycle. This locally cheapest choice is globally optimal because of the cut property: for any partition of the nodes into two groups, the lightest edge crossing the partition must belong to some MST.
Both Kruskal's and Prim's algorithms exploit this property — they just apply it in different orders.
Why Brute Force Fails
Without cycle detection, you would simply take the \(n - 1\) cheapest edges. But cheap edges can form cycles, wasting slots. In the example graph below, edges 1--3 (weight 1), 4--5 (weight 2), and 1--2 (weight 3) are the three cheapest. If the fourth cheapest is 2--3 (weight 5), adding it creates a cycle \(1 - 2 - 3 - 1\) and leaves node 6 disconnected. You need Union-Find (or equivalent) to skip cycle-forming edges and ensure every added edge actually connects a new component.
Kruskal's Algorithm
Idea: Sort all edges by weight. Scan them from lightest to heaviest. For each edge, add it to the MST if it connects two different components. Skip it if it would create a cycle.
To check whether two nodes are already connected, use Union-Find (Disjoint Set Union). This data structure supports two operations:
findRoot(node): return the representative (root) of the component containingnode.uniteComponents(nodeA, nodeB): merge the two components. Returntrueif they were separate (edge is useful),falseif they were already connected (edge would create a cycle).
With path compression and union by rank, both operations run in nearly \(O(1)\) amortized time — specifically \(O(\alpha(n))\), where \(\alpha\) is the inverse Ackermann function.
Overall complexity: \(O(m \log m)\) for sorting, plus \(O(m \cdot \alpha(n))\) for the union-find operations. The sort dominates.
Kruskal Trace

Consider this 6-node graph:
Nodes: 1, 2, 3, 4, 5, 6
Edges:
1--2 weight 3
1--3 weight 1
2--3 weight 5
2--4 weight 4
3--5 weight 6
4--5 weight 2
4--6 weight 7
5--6 weight 8
After sorting edges by weight: \((1,3,1),\ (4,5,2),\ (1,2,3),\ (2,4,4),\ (2,3,5),\ (3,5,6),\ (4,6,7),\ (5,6,8)\).
We need \(n - 1 = 5\) edges for the MST.
| Step | Edge | Weight | Creates Cycle? | Action | Components |
|---|---|---|---|---|---|
| 1 | 1–3 | 1 | No | Add | {1,3}, {2}, {4}, {5}, {6} |
| 2 | 4–5 | 2 | No | Add | {1,3}, {2}, {4,5}, {6} |
| 3 | 1–2 | 3 | No | Add | {1,2,3}, {4,5}, {6} |
| 4 | 2–4 | 4 | No | Add | {1,2,3,4,5}, |
| 5 | 2–3 | 5 | Yes (both in {1,2,3,4,5}) | Skip | {1,2,3,4,5}, |
| 6 | 4–6 | 7 | No | Add | {1,2,3,4,5,6} |
MST edges: 1–3, 4–5, 1–2, 2–4, 4–6. Total weight = \(1 + 2 + 3 + 4 + 7 = 17\).
Notice that edge 2–3 (weight 5) was skipped because nodes 2 and 3 were already connected through node 1. Edge 3–5 (weight 6) and 5–6 (weight 8) were never even reached — we already had 5 edges.
Kruskal Code
#include <bits/stdc++.h>
using namespace std;
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
int numNodes, numEdges;
cin >> numNodes >> numEdges;
vector<tuple<long long, int, int>> edgeList(numEdges);
for (int i = 0; i < numEdges; i++) {
int nodeA, nodeB;
long long edgeWeight;
cin >> nodeA >> nodeB >> edgeWeight;
edgeList[i] = {edgeWeight, nodeA, nodeB};
}
sort(edgeList.begin(), edgeList.end());
vector<int> parentNode(numNodes + 1);
vector<int> treeRank(numNodes + 1, 0);
iota(parentNode.begin(), parentNode.end(), 0);
function<int(int)> findRoot = [&](int node) -> int {
if (parentNode[node] != node)
parentNode[node] = findRoot(parentNode[node]);
return parentNode[node];
};
auto uniteComponents = [&](int nodeA, int nodeB) -> bool {
int rootA = findRoot(nodeA);
int rootB = findRoot(nodeB);
if (rootA == rootB) return false;
if (treeRank[rootA] < treeRank[rootB]) swap(rootA, rootB);
parentNode[rootB] = rootA;
if (treeRank[rootA] == treeRank[rootB]) treeRank[rootA]++;
return true;
};
long long totalMSTWeight = 0;
int edgesUsed = 0;
for (auto& [mstEdgeWeight, nodeA, nodeB] : edgeList) {
if (uniteComponents(nodeA, nodeB)) {
totalMSTWeight += mstEdgeWeight;
edgesUsed++;
if (edgesUsed == numNodes - 1) break;
}
}
if (edgesUsed == numNodes - 1)
cout << totalMSTWeight << "\n";
else
cout << "IMPOSSIBLE\n";
return 0;
}
Key points:
- Edges are stored as
(weight, nodeA, nodeB)so the default tuple comparison sorts by weight first. findRootuses path compression: every node on the path to the root gets directly attached to the root.uniteComponentsuses union by rank: the shorter tree is attached under the taller tree, keeping the structure flat.- We break early once \(n - 1\) edges are collected.
- If the graph is disconnected,
edgesUsed < numNodes - 1and we outputIMPOSSIBLE.
Prim's Algorithm
Idea: Start from any node. Maintain a set of nodes already in the MST. At each step, add the cheapest edge that connects a node inside the MST to a node outside the MST. Repeat until all nodes are included.
Use a min-heap of (weight, node) pairs. When you pop the cheapest entry, if that node is already in the MST, skip it. Otherwise, add it and push all its neighbors into the heap.
Overall complexity: \(O(m \log m)\) with a binary heap, or \(O(m + n \log n)\) with a Fibonacci heap (rarely used in competitive programming).
Prim Trace
Same graph as before. Start from node 1.
| Step | Pop from Heap | Weight | Already in MST? | Action | MST Nodes | Push Neighbors |
|---|---|---|---|---|---|---|
| 0 | (0, 1) | 0 | No | Add node 1 | {1} | (3,2), (1,3) |
| 1 | (1, 3) | 1 | No | Add node 3 | {1,3} | (5,2), (6,5) |
| 2 | (3, 2) | 3 | No | Add node 2 | {1,2,3} | (4,4) |
| 3 | (4, 4) | 4 | No | Add node 4 | {1,2,3,4} | (2,5), (7,6) |
| 4 | (2, 5) | 2 | No | Add node 5 | {1,2,3,4,5} | (8,6) |
| 5 | (5, 2) | 5 | Yes | Skip | {1,2,3,4,5} | — |
| 6 | (6, 5) | 6 | Yes | Skip | {1,2,3,4,5} | — |
| 7 | (7, 6) | 7 | No | Add node 6 | {1,2,3,4,5,6} | — |
MST edges added with weights: \(1 + 3 + 4 + 2 + 7 = 17\). Same total as Kruskal's — both find an MST.
Notice Prim's explores the graph outward from a starting node, while Kruskal's processes edges globally by weight. They reach the same result because of the cut property.
Prim Code
#include <bits/stdc++.h>
using namespace std;
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
int numNodes, numEdges;
cin >> numNodes >> numEdges;
vector<vector<pair<long long, int>>> adjacencyList(numNodes + 1);
for (int i = 0; i < numEdges; i++) {
int nodeA, nodeB;
long long edgeWeight;
cin >> nodeA >> nodeB >> edgeWeight;
adjacencyList[nodeA].push_back({edgeWeight, nodeB});
adjacencyList[nodeB].push_back({edgeWeight, nodeA});
}
vector<bool> inMST(numNodes + 1, false);
long long totalMSTWeight = 0;
int edgesUsed = 0;
auto heapComparator = [](pair<long long, int>& left, pair<long long, int>& right) {
return left.first > right.first;
};
priority_queue<pair<long long, int>,
vector<pair<long long, int>>,
decltype(heapComparator)> minHeap(heapComparator);
minHeap.push({0, 1});
while (!minHeap.empty() && edgesUsed < numNodes) {
auto [mstEdgeWeight, currentNode] = minHeap.top();
minHeap.pop();
if (inMST[currentNode]) continue;
inMST[currentNode] = true;
totalMSTWeight += mstEdgeWeight;
edgesUsed++;
for (auto& [neighborWeight, neighborNode] : adjacencyList[currentNode]) {
if (!inMST[neighborNode]) {
minHeap.push({neighborWeight, neighborNode});
}
}
}
if (edgesUsed == numNodes)
cout << totalMSTWeight << "\n";
else
cout << "IMPOSSIBLE\n";
return 0;
}
Key points:
- The adjacency list stores
(weight, neighbor)pairs, matching the heap's format. - The lambda
heapComparatormakes the priority queue a min-heap (smallest weight on top). - We push
(0, 1)to start from node 1 with zero cost — this initial node contributes no edge weight. - The
inMSTcheck after popping handles stale heap entries: a node might be pushed multiple times with different weights, but only the first pop (cheapest) matters. edgesUsedcounts nodes added to the MST. We need all \(n\) nodes (including the start node with weight 0).
Why Greedy Works — The Cut Property
Both algorithms are correct because of one fundamental theorem.
Cut Property: For any cut \((S, V \setminus S)\) of the graph — any partition of nodes into two non-empty groups — the minimum-weight edge crossing the cut is safe to include in some MST.
Proof sketch: Suppose edge \(e = (u, v)\) is the minimum-weight edge crossing a cut, but some MST \(T\) does not include \(e\). Add \(e\) to \(T\). This creates a cycle. That cycle must cross the cut at least twice (it goes from \(u\)'s side to \(v\)'s side and back). So there's another edge \(f\) in the cycle that also crosses the cut. Since \(e\) is the minimum crossing edge, \(w(e) \leq w(f)\). Remove \(f\) from \(T \cup \{e\}\). The result is still a spanning tree, and its weight is \(\leq w(T)\). So \(T' = T \cup \{e\} \setminus \{f\}\) is also an MST that includes \(e\). \(\square\)
How Kruskal's exploits the cut property: When Kruskal's considers edge \((u, v)\) and \(u, v\) are in different components, define \(S\) = the component containing \(u\). Edge \((u, v)\) crosses this cut, and it's the minimum-weight crossing edge (all lighter edges were processed earlier and either added or were internal to components). By the cut property, \((u, v)\) is safe.
How Prim's exploits the cut property: At each step, define \(S\) = the set of nodes already in the MST. The edge popped from the min-heap is the cheapest edge from \(S\) to \(V \setminus S\). By the cut property, it's safe.
Kruskal vs Prim
| Kruskal's | Prim's | |
|---|---|---|
| Approach | Global: sort all edges, process lightest first | Local: grow MST from a starting node |
| Data structure | Union-Find | Min-heap + adjacency list |
| Complexity | \(O(m \log m)\) | \(O(m \log m)\) with binary heap |
| Best for | Sparse graphs (\(m \approx n\)) | Dense graphs (\(m \approx n^2\)) |
| Edge list input | Natural fit (edges already listed) | Needs adjacency list conversion |
| Multiple components | Handles naturally (forest) | Needs to restart from unvisited nodes |
| Implementation | Simpler (sort + union-find) | More bookkeeping (heap + visited array) |
Rule of thumb: In competitive programming, Kruskal's is the default choice. It's shorter to code, works directly with edge lists, and handles disconnected graphs. Use Prim's when the graph is dense or when you're given an adjacency matrix.
Try It
The starter code has Kruskal's framework with the cycle-check logic left empty. Fill in the findRoot and uniteComponents functions, then use them in the loop. The snippet shows the Union-Find pattern.
Test on the example graph:
Disconnected graph (no spanning tree):
Single node:
Predict before running: What happens if you feed a graph where all edge weights are equal? How many valid MSTs exist for a complete graph on 4 nodes with all weights = 1?
Problems
| Problem | Link | Difficulty |
|---|---|---|
| CSES — Road Reparation | cses.fi/problemset/task/1675 | Easy |
| CF 1513D — GCD and MST | codeforces.com/contest/1513/problem/D | 1700 |
| CF 609E — Minimum Spanning Tree For Each Edge | codeforces.com/contest/609/problem/E | 1900 |
| CSES — New Roads Queries | cses.fi/problemset/task/2101 | Medium |
| Kattis — Min Spanning Tree | open.kattis.com/problems/minspantree | Easy |
| LC 1167 — Min Cost to Connect Sticks | leetcode.com/problems/minimum-cost-to-connect-sticks | Medium |
| LC 743 — Network Delay Time | leetcode.com/problems/network-delay-time | Medium |
| LC 1584 — Min Cost to Connect All Points | leetcode.com/problems/min-cost-to-connect-all-points | Medium |