Command Palette

Search for a command to run...

MTH 4300

Lecture 23

What Is a Minimum Spanning Tree?

A minimum spanning tree (MST) of a connected, weighted, undirected graph is a subset of edges that:

  1. Connects all vertices (it is spanning)
  2. Contains no cycles (it is a tree, so exactly V−1 edges)
  3. Has the minimum possible total edge weight

Every connected graph has at least one MST. If all edge weights are distinct, the MST is unique.

The MST of the graph above uses edges 0-1(2), 1-2(3), 1-4(5), 0-3(6) for a total weight of 16. The heavier edges — 1-3(8), 2-4(7), 3-4(9) — are excluded.

Two greedy algorithms find the MST. They differ in what they sort and what data structure they use to avoid cycles.

Kruskal's Algorithm

Kruskal's builds the MST by considering edges in ascending weight order. An edge is added to the MST only if its two endpoints are in different components — that is, adding it would not create a cycle. Union Find from lecture 21 tracks which component each vertex belongs to.

Steps:

  1. Sort all edges by weight ascending.
  2. For each edge (u, v, w): if find(u) != find(v), add the edge and unite(u, v).
  3. Stop after V−1 edges have been added.
Kruskal's — edge list, Union Find
#include <algorithm>
#include <iostream>
#include <vector>

struct Edge {
    int u, v, w;
    bool operator<(const Edge& other) const { return w < other.w; }
};

class UnionFind {
public:
    UnionFind(int n) : parent_(n), rank_(n, 0) {
        for (int i = 0; i < n; ++i) parent_[i] = i;
    }
    int find(int x) {
        if (parent_[x] != x) parent_[x] = find(parent_[x]);
        return parent_[x];
    }
    void unite(int x, int y) {
        int rx = find(x), ry = find(y);
        if (rx == ry) return;
        if (rank_[rx] < rank_[ry]) std::swap(rx, ry);
        parent_[ry] = rx;
        if (rank_[rx] == rank_[ry]) ++rank_[rx];
    }
private:
    std::vector<int> parent_, rank_;
};

// Returns MST edges and total weight.
std::pair<std::vector<Edge>, int> kruskal(int n, std::vector<Edge> edges) {
    std::sort(edges.begin(), edges.end());
    UnionFind uf(n);
    std::vector<Edge> mst;
    int total = 0;

    for (auto& e : edges) {
        if (uf.find(e.u) != uf.find(e.v)) {
            uf.unite(e.u, e.v);
            mst.push_back(e);
            total += e.w;
            if ((int)mst.size() == n - 1) break;
        }
    }
    return {mst, total};
}

Kruskal's trace on the graph above:

Kruskal's trace — 5 vertices
// Sorted edges: 0-1(2), 1-2(3), 1-4(5), 0-3(6), 2-4(7), 1-3(8), 3-4(9)
// Components: {0},{1},{2},{3},{4}
//
// 0-1(2): find(0)=0 ≠ find(1)=1 → add; unite → {0,1},{2},{3},{4}
// 1-2(3): find(1)=0 ≠ find(2)=2 → add; unite → {0,1,2},{3},{4}
// 1-4(5): find(1)=0 ≠ find(4)=4 → add; unite → {0,1,2,4},{3}
// 0-3(6): find(0)=0 ≠ find(3)=3 → add; unite → {0,1,2,3,4}
//
// 4 edges added = V-1; stop
// MST: {0-1(2), 1-2(3), 1-4(5), 0-3(6)}   total = 16

Edges 2-4(7), 1-3(8), and 3-4(9) are never reached — Kruskal's stops as soon as V−1 edges are added. Sorting guarantees that the first V−1 edges that don't form a cycle are the minimum-weight ones.

Prim's Algorithm

Prim's builds the MST by growing it one vertex at a time from a starting vertex. At each step it picks the minimum-weight edge that crosses the cut — the boundary between vertices already in the MST and those not yet included. This is structurally identical to Dijkstra's, with edge weight replacing accumulated distance.

Steps:

  1. Mark the source vertex as in the MST; push all its edges onto a min-heap keyed by weight.
  2. Pop the minimum-weight edge (w, v, from). If v is already in the MST, skip (stale).
  3. Add the edge to the MST, mark v as in the MST, push v's edges.
  4. Repeat until V−1 edges have been added.
Prim's — adjacency list, min-heap
#include <iostream>
#include <queue>
#include <tuple>
#include <vector>

// adj[u] = list of {neighbor, weight}  (undirected: add both directions)
std::pair<std::vector<std::tuple<int,int,int>>, int> prim(
    int n,
    const std::vector<std::vector<std::pair<int,int>>>& adj,
    int src = 0)
{
    std::vector<bool> in_mst(n, false);
    // min-heap: {weight, to, from}
    std::priority_queue<std::tuple<int,int,int>,
                        std::vector<std::tuple<int,int,int>>,
                        std::greater<>> pq;

    std::vector<std::tuple<int,int,int>> mst_edges;
    int total = 0;

    in_mst[src] = true;
    for (auto [neighbor, w] : adj[src])
        pq.push({w, neighbor, src});

    while (!pq.empty() && (int)mst_edges.size() < n - 1) {
        auto [w, v, from] = pq.top();
        pq.pop();
        if (in_mst[v]) continue;  // stale — already added via a cheaper edge

        in_mst[v] = true;
        mst_edges.push_back({from, v, w});
        total += w;

        for (auto [neighbor, weight] : adj[v])
            if (!in_mst[neighbor])
                pq.push({weight, neighbor, v});
    }
    return {mst_edges, total};
}

Prim's trace on the same graph starting at vertex 0:

Prim's trace — source=0
// in_mst={0}, heap={(2,1,0),(6,3,0)}
//
// Pop (2,1,0): v=1 not in MST → edge (0,1,2); in_mst={0,1}
//   push (3,2,1),(5,4,1),(8,3,1) from vertex 1
//   heap={(3,2,1),(5,4,1),(6,3,0),(8,3,1)}
//
// Pop (3,2,1): v=2 not in MST → edge (1,2,3); in_mst={0,1,2}
//   push (7,4,2) from vertex 2
//   heap={(5,4,1),(6,3,0),(7,4,2),(8,3,1)}
//
// Pop (5,4,1): v=4 not in MST → edge (1,4,5); in_mst={0,1,2,4}
//   heap={(6,3,0),(7,4,2),(8,3,1),(9,3,4)}
//
// Pop (6,3,0): v=3 not in MST → edge (0,3,6); in_mst={0,1,2,3,4}
//
// 4 edges added = V-1; stop
// MST: {(0,1,2), (1,2,3), (1,4,5), (0,3,6)}   total = 16

For undirected graphs, add_edge(u, v, w) must add {v, w} to adj[u] and {u, w} to adj[v]. Omitting one direction means some edges are invisible to the algorithm when it expands from the neighbor's side.

Kruskal's vs Prim's

PropertyKruskal'sPrim's
InputEdge listAdjacency list
Core operationSort edges, skip cyclesGrow from source, pick min cut
Supporting DSUnion FindMin-heap
Time complexityO(E log E)O((V + E) log V)
Better forSparse graphs (few edges)Dense graphs (many edges)

Both are greedy and always produce a correct MST for connected graphs. The choice is usually driven by how the graph is stored.

Exercises

Q1.

Without running the code, predict what this program prints.

#include <algorithm>
#include <iostream>
#include <vector>

int main() {
    int n = 5;
    std::vector<Edge> edges = {
        {0, 1, 1}, {1, 2, 2}, {0, 2, 3},
        {2, 3, 4}, {1, 3, 5}, {3, 4, 6}, {2, 4, 8}
    };

    auto [mst, total] = kruskal(n, edges);
    for (auto& e : mst)
        std::cout << "(" << e.u << "," << e.v << "," << e.w << ") ";
    std::cout << "\n" << total << "\n";
    return 0;
}

Q2.

Without running the code, predict what this program prints.

#include <iostream>
#include <queue>
#include <tuple>
#include <vector>

int main() {
    int n = 4;
    std::vector<std::vector<std::pair<int,int>>> adj(n);
    auto add = [&](int u, int v, int w) {
        adj[u].push_back({v, w});
        adj[v].push_back({u, w});
    };
    add(0, 1, 1);
    add(0, 2, 4);
    add(1, 2, 2);
    add(1, 3, 5);
    add(2, 3, 1);

    auto [mst_edges, total] = prim(n, adj, 0);
    for (auto [from, to, w] : mst_edges)
        std::cout << "(" << from << "," << to << "," << w << ") ";
    std::cout << "\n" << total << "\n";
    return 0;
}

Q3.

Each snippet has a bug. Identify what's wrong.

// Snippet A — Kruskal's adds the edge without checking find(u) != find(v)
for (auto& e : edges) {
    uf.unite(e.u, e.v);   // always unites, even if same component
    mst.push_back(e);
    total += e.w;
    if ((int)mst.size() == n - 1) break;
}

// Snippet B — Kruskal's checks components but forgets to call unite
for (auto& e : edges) {
    if (uf.find(e.u) != uf.find(e.v)) {
        mst.push_back(e);   // adds edge but never merges components
        total += e.w;
    }
}

// Snippet C — Prim's marks a vertex in_mst before checking if it's stale
while (!pq.empty() && (int)mst_edges.size() < n - 1) {
    auto [w, v, from] = pq.top(); pq.pop();
    in_mst[v] = true;              // set before the stale check
    if (in_mst[v]) continue;       // condition is now always false
    mst_edges.push_back({from, v, w});
    total += w;
    for (auto [nb, wt] : adj[v])
        if (!in_mst[nb]) pq.push({wt, nb, v});
}

// Snippet D — Prim's uses a max-heap instead of a min-heap
std::priority_queue<std::tuple<int,int,int>> pq; // default: max-heap

Q4.

Implement kruskal for the edge list below.

#include <algorithm>
#include <iostream>
#include <vector>

struct Edge {
    int u, v, w;
    bool operator<(const Edge& other) const { return w < other.w; }
};

class UnionFind {
public:
    UnionFind(int n) : parent_(n), rank_(n, 0) {
        for (int i = 0; i < n; ++i) parent_[i] = i;
    }
    int find(int x) {
        if (parent_[x] != x) parent_[x] = find(parent_[x]);
        return parent_[x];
    }
    void unite(int x, int y) {
        int rx = find(x), ry = find(y);
        if (rx == ry) return;
        if (rank_[rx] < rank_[ry]) std::swap(rx, ry);
        parent_[ry] = rx;
        if (rank_[rx] == rank_[ry]) ++rank_[rx];
    }
private:
    std::vector<int> parent_, rank_;
};

std::pair<std::vector<Edge>, int> kruskal(int n, std::vector<Edge> edges) {
    // TODO:
    // 1. Sort edges by weight
    // 2. For each edge: if endpoints in different components, add it and unite
    // 3. Stop after n-1 edges
    // Return {mst edges, total weight}
}

int main() {
    int n = 6;
    std::vector<Edge> edges = {
        {0,1,4},{0,2,2},{1,2,5},{1,3,10},{2,4,3},{3,4,7},{3,5,8},{4,5,6}
    };
    auto [mst, total] = kruskal(n, edges);
    for (auto& e : mst)
        std::cout << "(" << e.u << "," << e.v << "," << e.w << ") ";
    std::cout << "\n" << total << "\n";
    return 0;
}

Q5.

Implement prim for the graph below.

#include <iostream>
#include <queue>
#include <tuple>
#include <vector>

std::pair<std::vector<std::tuple<int,int,int>>, int> prim(
    int n,
    const std::vector<std::vector<std::pair<int,int>>>& adj,
    int src = 0)
{
    // TODO:
    // 1. Mark src as in_mst; push all its edges onto a min-heap {weight, to, from}
    // 2. While heap non-empty and mst_edges.size() < n-1:
    //    pop {w, v, from}; skip if v already in MST (stale)
    //    mark v in_mst; record edge; push v's unvisited neighbors
    // Return {mst edges, total weight}
}

int main() {
    int n = 5;
    std::vector<std::vector<std::pair<int,int>>> adj(n);
    auto add = [&](int u, int v, int w) {
        adj[u].push_back({v, w});
        adj[v].push_back({u, w});
    };
    add(0, 1, 3);
    add(0, 3, 1);
    add(1, 2, 5);
    add(1, 3, 2);
    add(2, 4, 4);
    add(3, 4, 6);

    auto [mst_edges, total] = prim(n, adj, 0);
    for (auto [from, to, w] : mst_edges)
        std::cout << "(" << from << "," << to << "," << w << ") ";
    std::cout << "\n" << total << "\n";
    return 0;
}

Practice Problems