In the previous lesson we minimized the cost of one route between two points. This lesson changes the question entirely: given a set of nodes that must all be connected to one another, which connections do we pick so the total cost is minimal? The answer is the minimum spanning tree (MST), a problem on undirected graphs that shows up whenever a network is being designed: wiring offices, laying fiber between sites, or — in the most "infrastructure-flavored" TaskFlow scenario of the whole course — deciding which coordination channels to keep between teams. We'll see the two classic algorithms, Prim (Dijkstra's first cousin, once again with heapq) and Kruskal, which will force us to build a delightful new auxiliary structure: Union-Find.
Contents
- What a minimum spanning tree is
- MST versus shortest paths: don't confuse them
- The example graph: coordinating TaskFlow's teams
- Prim: growing the tree from a vertex
- Kruskal: cheap edges first, without forming cycles
- Union-Find: the structure that makes Kruskal fast
- Kruskal trace
- Prim versus Kruskal
What a minimum spanning tree is
Given an undirected, weighted, connected graph, a spanning tree is a subgraph that:
- includes all the vertices,
- is connected (everything reachable from everything, possibly with stopovers),
- is acyclic — and from lesson 07-01 we know "connected + acyclic" = tree, with exactly
n − 1edges.
Of all possible spanning trees, the minimum one has the smallest sum of weights. The intuition for why a tree suffices: if a selection of edges contains a cycle, you can remove the cycle's most expensive edge without disconnecting anything — so the optimal solution never has cycles.
Two important restrictions of the problem:
- Undirected graphs only. "Connecting" is symmetric; on directed graphs the analogous problem (arborescences) is a different beast and we don't cover it.
- Connected graphs only. If there are islands, no tree can span everything; in that case you compute a spanning forest, one MST per component (Kruskal does this for free, as we'll see).
MST versus shortest paths: don't confuse them
They are the two big optimization problems on weighted graphs and are easily confused:
| Shortest paths (07-04) | MST (this lesson) | |
|---|---|---|
| Question | Cheapest route from A to B? | Cheapest network connecting everything? |
| Optimizes | The cost of each route from the source | The total cost of the chosen edges |
| Graph | Directed or not | Undirected |
| Result | Shortest-path tree from one source | Tree with no privileged source |
| Guarantee it does NOT give | — | The path between two nodes inside the MST may not be their shortest path |
The last point is the treacherous one: inside the MST, getting from one node to another may require a detour more expensive than their direct shortest path in the original graph. The MST saves on the network's total bill, not on each individual trip.
The example graph: coordinating TaskFlow's teams
Five teams use TaskFlow: backend, frontend, data, qa, and design. Keeping a stable coordination channel between two teams (meetings, integrations, shared documentation) has a weekly cost in hours, different for each pair. We want information to be able to flow between any pair of teams (directly or through others) while paying the minimum total:
teams = Graph(directed=False) # coordinating is symmetric
teams.add_edge("backend", "frontend", 4)
teams.add_edge("backend", "data", 2)
teams.add_edge("backend", "qa", 7)
teams.add_edge("data", "qa", 3)
teams.add_edge("frontend", "qa", 5)
teams.add_edge("frontend", "design", 1)
teams.add_edge("design", "qa", 6)graph LR
B[backend] ---|4| F[frontend]
B ---|2| D[data]
B ---|7| Q[qa]
D ---|3| Q
F ---|5| Q
F ---|1| S[design]
S ---|6| Q
With 5 vertices, the MST will have exactly 4 edges. Let's give away the solution up front so we can check both algorithms against it: frontend–design (1), backend–data (2), data–qa (3), and backend–frontend (4), total 10 hours per week.
Prim: growing the tree from a vertex
Prim builds the MST like a spreading oil stain: it starts at an arbitrary vertex and, at each step, adds the cheapest edge connecting the current tree to a vertex outside it. "The cheapest available"? heapq again:
import heapq
def prim(graph, start):
"""Returns (mst_edges, total_cost). Undirected, connected graph."""
in_tree = {start}
mst_edges = []
total = 0
# (weight, source, target): the heap orders by weight, as in Dijkstra
heap = [(weight, start, target)
for target, weight in graph.neighbors(start).items()]
heapq.heapify(heap) # heap in O(n), module 6
while heap and len(in_tree) < len(graph.vertices()):
weight, source, target = heapq.heappop(heap)
if target in in_tree:
continue # stale edge: already inside
in_tree.add(target) # bring in the new vertex
mst_edges.append((source, target, weight))
total += weight
for neighbor, w in graph.neighbors(target).items():
if neighbor not in in_tree: # the newcomer's offers
heapq.heappush(heap, (w, target, neighbor))
return mst_edges, total
edges, total = prim(teams, "backend")
print(edges)
# [('backend', 'data', 2), ('data', 'qa', 3), ('backend', 'frontend', 4),
# ('frontend', 'design', 1)]
print(total) # 10If this code sounds familiar, it's because it is Dijkstra's skeleton with a single difference: the priority. Dijkstra orders the heap by accumulated distance from the source (dist + weight); Prim, by the loose edge's weight (weight). Dijkstra minimizes routes; Prim minimizes the connection bill. Same machinery, different question — it's worth comparing the two pieces of code side by side until you see the one-line difference.
Trace from backend (the oil stain):
| Step | Leaves the heap | Accepted? | Tree after the step | Total |
|---|---|---|---|---|
| 1 | (2, backend, data) | Yes | {backend, data} | 2 |
| 2 | (3, data, qa) | Yes | + qa | 5 |
| 3 | (4, backend, frontend) | Yes | + frontend | 9 |
| 4 | (1, frontend, design) | Yes | + design: tree complete | 10 |
Look at step 4: when frontend joins, its cheap edge to design (weight 1) enters the heap and jumps ahead of the expensive pending offers — (5, frontend, qa), (6, qa, design), and (7, backend, qa) stay inside without ever surfacing, because with all 5 vertices in, the loop ends.
Cost: O(a · log n), like Dijkstra.
Kruskal: cheap edges first, without forming cycles
Kruskal attacks from the other flank: it sorts all edges from lightest to heaviest (here the edge list mentioned in 07-02 shines) and accepts them one by one, with a single rule: reject any edge whose two endpoints are already connected (it would form a cycle). It stops after accepting n − 1.
The difficulty lies in the rule: "are u and v already connected?" must be answered thousands of times, very fast. BFS per query? O(n + a) each time: too much. The answer is a new structure.
Union-Find: the structure that makes Kruskal fast
Union-Find (or disjoint sets) maintains a collection of sets that only know how to do two things, both in nearly O(1):
find(x): what is the representative of x's set? (two elements are connected if they share a representative),union(x, y): merge the sets of x and y.
The implementation is surprisingly small: each element points to a "parent" and the representative is the root of that chain — internally it's a forest of little trees, one more nod to module 6. Two optimizations make it fly:
- Path compression: while looking for the root, re-hook every visited node directly onto it; the next lookup will be nearly instant.
- Union by rank: when merging, the tree with the smaller estimated height (rank) hangs from the taller one, avoiding long chains.
class UnionFind:
def __init__(self, elements):
self.parent = {x: x for x in elements} # each its own root
self.rank = {x: 0 for x in elements}
def find(self, x):
root = x
while self.parent[root] != root: # climb to the root
root = self.parent[root]
while self.parent[x] != root: # path compression:
self.parent[x], x = root, self.parent[x] # re-hook straight to the root
return root
def union(self, x, y):
rx, ry = self.find(x), self.find(y)
if rx == ry:
return False # already connected
if self.rank[rx] < self.rank[ry]: # union by rank:
rx, ry = ry, rx # rx becomes the taller one
self.parent[ry] = rx # the short one hangs from the tall one
if self.rank[rx] == self.rank[ry]:
self.rank[rx] += 1 # only grows on a tie
return TrueWith both optimizations, a sequence of operations costs in practice nearly constant time per operation (technically O(α(n)), where α is the inverse Ackermann function: ≤ 4 for any conceivable n). Note the design detail: union returns False if the elements were already connected — exactly Kruskal's question, answered as a side effect.
def kruskal(graph):
edges = sorted( # edge list ordered by weight
{tuple(sorted((u, v))) + (w,) # (u, v, w) without u-v / v-u duplicates
for u in graph.vertices()
for v, w in graph.neighbors(u).items()},
key=lambda e: e[2])
uf = UnionFind(graph.vertices())
mst, total = [], 0
for u, v, w in edges:
if uf.union(u, v): # False = would form a cycle: skip
mst.append((u, v, w))
total += w
if len(mst) == len(graph.vertices()) - 1:
break # tree complete: n - 1 edges
return mst, total
print(kruskal(teams))
# ([('design', 'frontend', 1), ('backend', 'data', 2), ('data', 'qa', 3),
# ('backend', 'frontend', 4)], 10)A note on how edges is built: since the undirected graph stores every edge in both directions, tuple(sorted((u, v))) normalizes the pair and the set removes the duplicate. Kruskal's total cost: O(a · log a), dominated by the sort.
Kruskal trace
Sorted edges: (design–frontend, 1), (backend–data, 2), (data–qa, 3), (backend–frontend, 4), (frontend–qa, 5), (design–qa, 6), (backend–qa, 7).
| Edge | Endpoints already connected? | Decision | Sets after the step |
|---|---|---|---|
| design–frontend (1) | No | Accept | {design, frontend} {backend} {data} {qa} |
| backend–data (2) | No | Accept | {design, frontend} {backend, data} {qa} |
| data–qa (3) | No | Accept | {design, frontend} {backend, data, qa} |
| backend–frontend (4) | No | Accept → 4 edges: done | {everyone} |
| frontend–qa (5) | (never gets evaluated) | — | — |
Same tree and same total (10) as Prim — as it should be: when no weights repeat, the MST is unique. Notice the difference in style: Prim maintains one growing tree; Kruskal maintains a forest of fragments that keep merging (which is why, on a disconnected graph, Kruskal ends up with one MST per island without changing a single line).
Prim versus Kruskal
| Prim | Kruskal | |
|---|---|---|
| Strategy | Grow a tree from a vertex | Accept globally cheap edges |
| Supporting structure | heapq (module 4) |
Sorting + Union-Find |
| Cost | O(a · log n) |
O(a · log a) |
| Comfortable when... | Dense graph, adjacency list | Sparse graph, edges already as a list |
| Disconnected graph | Only covers the starting component | Gives the whole forest for free |
| Resembles | Dijkstra (swap the priority) | A greedy filter over sorted edges |
Both are greedy algorithms: at every step they take the locally cheapest option and, for this problem — unlike so many others — that provably leads to the global optimum.
Common Mistakes and Tips
- Applying MST to a directed graph. The problem is defined for undirected graphs; build the graph with
Graph(directed=False)or the algorithms will produce nonsense. - Confusing the MST with shortest paths and using Prim's tree to answer "cheapest route from A to B?". Revisit the table in section 2: they optimize different things.
- Forgetting the stale-edge
continuein Prim: you'd accept edges to vertices already in the tree, creating cycles and overcounting cost. - Implementing Union-Find without compression or rank. It works, but the internal trees degenerate into chains and
findbecomesO(n)— the same degeneration the unbalanced BST suffered in module 6. - Duplicating edges in Kruskal when extracting them from an undirected graph (u–v and v–u): either normalize as we did, or the algorithm evaluates everything twice (it doesn't break the result, but it betrays sloppiness).
- Tip: Union-Find is worth far more than Kruskal alone: dynamic connectivity, on-the-fly cycle detection, clustering... In the final exercises (07-07), the BFS alternative for connected components will help you appreciate when each one shines.
Exercises
Exercise 1: MST by hand
Add a sixth team: mobile, with edges mobile–frontend (2) and mobile–qa (4). Compute the new MST with Kruskal by hand (decision table) and its total cost.
Exercise 2: the MST's detour
In the lesson's MST, what is the path between design and qa and how much does it cost, adding up its edges? Compare it with the direct edge design–qa (6) and explain why the MST "prefers" the detour.
Exercise 3: Union-Find as a cycle detector
Using only UnionFind (no DFS), write has_undirected_cycle(edges, vertices) that detects whether an undirected graph given as an edge list contains a cycle. Hint: what does it mean when union returns False?
Solutions
Solution 1: Sorted edges: 1 (design–frontend), 2 (backend–data), 2 (mobile–frontend), 3 (data–qa), 4 (backend–frontend), 4 (mobile–qa), 5, 6, 7. Decisions: accept 1, 2, 2, 3, 4 (backend–frontend joins {design, frontend, mobile} with {backend, data, qa}); with 5 edges for 6 vertices, done. mobile–qa (4) is never evaluated (connected via frontend...backend...qa). Total: 1 + 2 + 2 + 3 + 4 = 12.
Solution 2: In the MST, from design to qa you travel design–frontend–backend–data–qa: 1 + 4 + 2 + 3 = 10, against 6 for the direct edge. The MST discarded design–qa because by the time its turn came, its endpoints were already connected: for the network's total bill that edge was redundant. It's the practical proof of section 2's warning: the MST doesn't promise good individual trips, only the cheapest complete network.
Solution 3:
def has_undirected_cycle(edges, vertices):
uf = UnionFind(vertices)
for u, v in edges:
if not uf.union(u, v): # already connected: this edge closes a cycle
return True
return False
print(has_undirected_cycle(
[("a", "b"), ("b", "c")], ["a", "b", "c"])) # False
print(has_undirected_cycle(
[("a", "b"), ("b", "c"), ("c", "a")], ["a", "b", "c"])) # TrueIf union returns False, the endpoints already shared a set: a path existed between them and the new edge closes it into a cycle. It's the course's third cycle-detection technique (Floyd on lists, white/gray/black on directed graphs, Union-Find on undirected ones), each on its own turf.
Conclusion
The MST answers a different question than shortest paths — minimize the total network, not each trip — and lives only on undirected graphs. Prim builds it like an oil stain with the usual heapq (one line away from Dijkstra); Kruskal sorts the edges and filters out cycles with Union-Find, the new auxiliary structure that, with path compression and union by rank, answers "are these connected?" in nearly constant time. With this, the module's toolbox is complete: traversals, cycles, orderings, paths, and networks. The next lesson adds no new algorithm: it puts all of them to work together — TaskFlow's full scheduler (order, cycles, parallelism, and the critical path), social-network-style collaborator suggestions, and even a miniature PageRank.
Data Structures Course
Module 1: Introduction to Data Structures
- What Are Data Structures?
- The Importance of Data Structures in Programming
- Types of Data Structures
- Algorithmic Complexity and Big O Notation
- Arrays and Memory: the Foundation of Data Structures
Module 2: Lists
Module 3: Stacks
- Introduction to Stacks
- Basic Stack Operations
- Stack Implementation
- Stack Applications
- Stack Exercises
Module 4: Queues
- Introduction to Queues
- Basic Queue Operations
- Circular Queues
- Priority Queues
- Double-Ended Queues (Deques)
- Queue Exercises
Module 5: Hash Tables and Dictionaries
- Introduction to Hash Tables
- Hash Functions and Collision Resolution
- Dictionaries and Sets in Practice
- Hash Table Exercises
Module 6: Trees
- Introduction to Trees
- Binary Trees
- Tree Traversals
- Binary Search Trees
- AVL Trees
- B-Trees
- Heaps
- Tree Exercises
Module 7: Graphs
- Introduction to Graphs
- Graph Representation
- Graph Search Algorithms
- Shortest Path Algorithms
- Minimum Spanning Trees
- Graph Applications
- Graph Exercises
