Skip to the chapter
←DSAGGroundworkΣDSA
DSA/Advanced/A4
38 / 48 written chapters
/

Levels and plan
  1. B1Complexity analysis
  2. B2JavaScript toolkit
  3. B3Arrays & strings
  4. B4Prefix sums
  5. B5Hashing
  6. B6Two pointers
  7. B7Sliding window
  8. B8Binary search
  9. B9Basic recursion
  10. B10Sorting algorithms
  11. B11Stacks & queues
  12. B12Linked lists
  1. I1Trees
  2. I2BST operations
  3. I3Tree problems in depth
  4. I4Heaps & priority queues
  5. I5Graphs: representation
  6. I6BFS on grids
  7. I7Graph problems
  8. I8Topological patterns
  9. I9Backtracking
  10. I10DP: 1D
  11. I11DP: 2D
  12. I12DP state machines
  13. I13Greedy algorithms
  14. I14Intervals
  15. I15Bit manipulation
  16. I16Matrix problems
  17. I17Tries
  18. I18Monotonic stack & queue
  19. I19Math for interviews
  20. I20Design & simulation
  21. I21Divide and conquer
  22. I22Randomization
  1. A1Advanced DP
  2. A2Union-Find
  3. A3Advanced graph algorithms
  4. A4Minimum Spanning Tree
  5. A5Advanced graphs
  6. A6Segment & Fenwick trees
  7. A7Sparse tables
  8. A8String algorithms
  9. A9Design problems
  10. A10Advanced backtracking
  11. A11Games & probability DP
  12. A12Ordered structures
  13. A13Sweep line & geometry
  14. A14Interview strategy

ΣA4 · Part 3 · Advanced

Minimum Spanning Tree

The cheapest wiring that reaches every node — greedy is provably optimal here, and there are exactly two ways to be greedy.

28 min read2 diagrams16 sections9 exercises
38 of 48

In this chapter

  • What a minimum spanning tree actually is
  • Why greedy is safe: the cut property
  • Kruskal's algorithm: sort every edge, take it if it doesn't close a cycle
  • Watching Kruskal build the tree, edge by edge
  • Prim's algorithm: grow one tree outward with a heap

and 11 more

about 28 min
16On this page
  1. What a minimum spanning tree actually is
  2. Why greedy is safe: the cut property
  3. Kruskal's algorithm: sort every edge, take it if it doesn't close a cycle
  4. Watching Kruskal build the tree, edge by edge
  5. Prim's algorithm: grow one tree outward with a heap
  6. Kruskal vs Prim: which one in the interview
  7. Min Cost to Connect All Points — the dense case done right
  8. The virtual-node trick, and other MST disguises
  9. Maximum spanning tree: negate the weights
  10. Why the MST path is also the minimax path
  11. Path with minimum effort: Kruskal over the grid edges
  12. Edge-length limited paths: sort the queries too
  13. Critical and pseudo-critical edges
  14. Second-best MST: one swap away
  15. Boruvka's algorithm: every component grabs its cheapest edge
  16. Recognizing it in an unseen problem

What a minimum spanning tree actually is

Given a connected, undirected, weighted graph on n vertices, a spanning tree is any subset of n−1 edges that keeps every vertex reachable — connected, acyclic, nothing left out. The minimum spanning tree is the spanning tree whose weights sum to the smallest possible total. Notice what is not being minimised: not the distance between any particular pair of vertices, only the total weight of the whole structure. An MST can easily make the trip from u to v much longer than the graph allows — it only promises the cheapest total wiring.

A B C D E 1 5 4 2 3 7 6 green = in the MST red = would close a cycle total = 1+2+3+5 = 11
Four edges for five vertices — always exactly n−1. A–D is cheap (4) but rejected because A and D are already connected through B.

Why greedy is safe: the cut property

Greedy algorithms usually need a proof before you trust them, and MST has a clean one. Split the vertices into two non-empty groups — call that a cut. The cut property says: the cheapest edge crossing that cut belongs to some MST. The intuition is an exchange argument. Suppose an MST T doesn't contain that cheapest crossing edge e. Add e to T anyway — now you have a cycle, and that cycle has to cross back over the cut on some other edge f. Since e was the cheapest crossing edge, weight(e) ≤ weight(f), so swapping f out for e leaves you with a spanning tree that is no heavier. The greedy choice was never a mistake.

The mirror image is the cycle property: on any cycle, the single heaviest edge is never needed — you can always delete it and stay connected. These two facts are the same fact seen from opposite ends, and they generate the two classic algorithms. Kruskal thinks in cycles ("take the cheapest edge unless it closes a cycle"), Prim thinks in cuts ("keep taking the cheapest edge leaving the tree I've built so far").

On uniqueness: if all edge weights are distinct, the MST is unique — the exchange argument above becomes a strict inequality and no swap can tie. With ties, several different MSTs can exist, but every one of them has the same total weight. That's the honest answer to "is the MST unique?" in an interview: the tree may not be, the cost always is.

⚠ An MST is not a shortest-paths tree This is the mix-up interviewers actively probe for. Dijkstra from a source s builds a tree where the root-to-v path is the cheapest s→v path. An MST minimises the sum of all its edges and has no source at all. In the diagram above, the MST path from A to C is A→B→C = 6, and that happens to be optimal — but take a triangle with A–B = 4, B–C = 4 and A–C = 5. The MST keeps the two 4s and drops the 5, so its A→C path costs 8 while the direct edge costs 5. If the problem says "shortest path from X," it is not an MST problem.

Kruskal's algorithm: sort every edge, take it if it doesn't close a cycle

Kruskal is the cycle property applied greedily. Sort all E edges by weight, walk them cheapest first, and accept an edge only when its two endpoints are currently in different components. "Different components?" is exactly the question the union-find structure from the previous chapter answers in near-constant time — here is a compact version with path halving and union by rank so this file stands alone.

class DSU {
  constructor(n) {
    this.parent = Array.from({ length: n }, (_, i) => i);
    this.rank = new Array(n).fill(0);
  }
  find(x) {
    while (this.parent[x] !== x) {
      this.parent[x] = this.parent[this.parent[x]]; // path halving — flatten as we climb
      x = this.parent[x];
    }
    return x;
  }
  union(a, b) {
    let ra = this.find(a), rb = this.find(b);
    if (ra === rb) return false; // already connected — this edge would close a cycle
    if (this.rank[ra] < this.rank[rb]) [ra, rb] = [rb, ra];
    this.parent[rb] = ra;
    if (this.rank[ra] === this.rank[rb]) this.rank[ra]++;
    return true;
  }
}
// edges: [u, v, weight][] with 0-indexed vertices — O(E log E) time, O(V) extra space
function kruskalMST(n, edges) {
  const sorted = [...edges].sort((a, b) => a[2] - b[2]); // the sort IS the algorithm's cost; the copy leaves the caller's edges in order

  const dsu = new DSU(n);
  const tree = [];
  let total = 0;

  for (const [u, v, w] of sorted) {
    if (dsu.union(u, v)) { // union returns false when u and v already share a root
      tree.push([u, v, w]);
      total += w;
      if (tree.length === n - 1) break; // n-1 edges = spanning, stop early
    }
  }

  // fewer than n-1 accepted edges means the graph was disconnected
  return tree.length === n - 1 ? { total, tree } : null;
}

Complexity is O(E log E) dominated entirely by the sort — the union-find work is O(E · α(V)), and the inverse Ackermann function α is below 5 for any input that fits in memory, so treat it as constant when you say the number out loud. Space is O(V) for the DSU (the edge list is given, not built). Since E ≤ V², log E ≤ 2 log V, so you'll also see this written O(E log V) — same thing.

Watching Kruskal build the tree, edge by edge

Running it on the graph above, with edges sorted 1, 2, 3, 4, 5, 6, 7:

edgewfind(u) === find(v)?actioncomponents aftertotal
A–B1notake{AB} {C} {D} {E}1
B–D2notake{ABD} {C} {E}3
B–E3notake{ABDE} {C}6
A–D4yesskip — cycle{ABDE} {C}6
B–C5notake{ABCDE}11
C–E6never examined — 4 = n−1 edges already accepted, loop breaks
Kruskal on the graph above — only the accepted edges are drawn 1. take A–B (1) 2. take B–D (2) 3. take B–E (3) 4. take B–C (5) 4 components 3 components 2 components 1 — spanning A–D (4) is skipped between panels 3 and 4: both ends were already connected
The component count drops by exactly one per accepted edge — that is why the loop can stop the instant it hits n−1.
Say it like this → "I'll sort the edges by weight and sweep cheapest-first, using union-find to reject any edge whose endpoints are already connected — that's Kruskal. The cut property guarantees the greedy choice is never wrong, and the cost is O(E log E) dominated by the sort, since each union-find operation is effectively constant time."

Prim's algorithm: grow one tree outward with a heap

Prim keeps a single growing tree instead of a forest. At each step the cut is "vertices in the tree" versus "vertices outside," and the cut property says to take the cheapest edge crossing it. A min-heap keyed by edge weight produces that edge in O(log E). Here is a compact binary heap of [weight, from, to] triples so the code below runs as written.

class MinHeap {
  constructor() { this.a = []; }
  get size() { return this.a.length; }
  push(item) {
    const a = this.a;
    a.push(item);
    let i = a.length - 1;
    while (i > 0) {
      const p = (i - 1) >> 1;
      if (a[p][0] <= a[i][0]) break;
      [a[p], a[i]] = [a[i], a[p]];
      i = p;
    }
  }
  pop() {
    const a = this.a, top = a[0], last = a.pop();
    if (a.length) {
      a[0] = last;
      for (let i = 0; ; ) {
        const l = 2 * i + 1, r = l + 1;
        let s = i;
        if (l < a.length && a[l][0] < a[s][0]) s = l;
        if (r < a.length && a[r][0] < a[s][0]) s = r;
        if (s === i) break;
        [a[s], a[i]] = [a[i], a[s]];
        i = s;
      }
    }
    return top;
  }
}
// adj[u] = [[v, weight], ...] — O(E log V) time, O(E) space for the heap
function primMST(n, adj) {
  const inTree = new Array(n).fill(false);
  const heap = new MinHeap();
  const tree = [];
  let total = 0;

  inTree[0] = true; // seed with any vertex — MST is the same regardless of start
  for (const [v, w] of adj[0]) heap.push([w, 0, v]);

  while (heap.size > 0 && tree.length < n - 1) {
    const [w, u, v] = heap.pop();
    if (inTree[v]) continue; // STALE entry — v got absorbed by a cheaper edge already

    inTree[v] = true;
    tree.push([u, v, w]);
    total += w;

    for (const [next, nw] of adj[v]) {
      if (!inTree[next]) heap.push([nw, v, next]); // only frontier edges matter
    }
  }

  return tree.length === n - 1 ? { total, tree } : null;
}

Dry run: primMST(5, adj) on the same A–B–C–D–E graph, starting at A

Popw, u, vStale?Actiontotal
11, A, Bnoaccept B; push B–C(5), B–D(2), B–E(3)1
22, B, Dnoaccept D; push D–E(7)3
33, B, Enoaccept E; push E–C(6)6
44, A, Dyesskip — D already in tree6
55, B, Cnoaccept C — 4 edges reached, stop11

Prim lands on the identical total (11) and even the identical edge set as Kruskal's table above, just discovered in a different order — Kruskal sorts globally first, Prim only ever looks at the cheapest edge leaving its current tree. The stale pop at step 4 (A–D, weight 4) is exactly why the heap can hold more entries than vertices: D found a cheaper route through B before its direct edge from A was ever popped.

⚠ The stale-entry check is not optional This is the "lazy" heap variant: instead of decreasing a key in place (which a plain binary heap can't do), you push a new entry and let obsolete ones rot in the heap. Delete if (inTree[v]) continue; and you will happily add a second edge into a vertex that is already in the tree — the result has n−1 edges, a cycle, and a wrong total. The heap can hold up to E entries because of this, which is why the space is O(E) and not O(V).

Prim also gives the wrong answer silently on a disconnected graph: it fills one component and stops. Kruskal's loop, by contrast, naturally builds a minimum spanning forest — one tree per component (the version above returns null because of its final n - 1 check; return tree without that check to get the forest). If the input might be disconnected and you're using Prim, you must loop over unvisited seeds yourself, or check tree.length === n - 1 as above.

Kruskal vs Prim: which one in the interview

KruskalPrim (binary heap)Prim (no heap, O(V²))
TimeO(E log E)O(E log V)O(V²)
SpaceO(V) DSUO(E) lazy heapO(V)
Input wantededge listadjacency listadjacency matrix / on-the-fly weights
Sparse (E ≈ V)greatgreatwasteful
Dense (E ≈ V²)O(V² log V) — the sort hurtsO(V² log V)best — beats both
Disconnected inputbuilds a spanning forest (drop the n - 1 check)needs an outer restart loopneeds an outer restart loop
Depends onunion-finda priority queuenothing

The one that actually decides interviews is the last row of the dense column. When the graph is implicit and complete — "n points, cost between any two is their distance" — materialising all V²/2 edges to sort them is the mistake. With V = 1000 that's half a million edges to build and sort when O(V²) Prim never stores a single one.

Min Cost to Connect All Points — the dense case done right

The classic version: given points on a plane, connecting two costs their Manhattan distance, connect them all as cheaply as possible. Every pair is an edge, so this is a complete graph — reach for O(V²) Prim, which keeps one number per vertex ("cheapest known edge from the tree to you") and rescans instead of heaping.

// O(V²) time, O(V) space — never builds the edge list at all
function minCostConnectPoints(points) {
  const n = points.length;
  const minDist = new Array(n).fill(Infinity);
  const inTree = new Array(n).fill(false);
  minDist[0] = 0; // start vertex costs nothing to attach
  let total = 0;

  for (let step = 0; step < n; step++) {
    // pick the cheapest vertex still outside the tree — this is the cut property
    let u = -1;
    for (let v = 0; v < n; v++) {
      if (!inTree[v] && (u === -1 || minDist[v] < minDist[u])) u = v;
    }

    inTree[u] = true;
    total += minDist[u];

    // relax: u joining the tree may give every outsider a cheaper attachment
    for (let v = 0; v < n; v++) {
      if (inTree[v]) continue;
      const d = Math.abs(points[u][0] - points[v][0]) + Math.abs(points[u][1] - points[v][1]);
      if (d < minDist[v]) minDist[v] = d;
    }
  }
  return total;
}

The relax step is why this works without a heap: minDist[v] is always "cheapest edge from the current tree to v," so scanning it for the minimum is finding the cheapest edge across the cut. That linear scan costs O(V) per step for O(V) steps — the same O(V²) as building the matrix, so the heap buys nothing.

The virtual-node trick, and other MST disguises

MST problems rarely announce themselves. A recurring twist: each node has a standalone cost as well as connection costs — "each village can dig its own well for cost w[i], or lay a pipe to another village for cost c." That looks like it isn't a spanning tree at all, until you add a virtual node 0 representing "the water source" and connect it to village i with weight w[i]. Now "dig a well" is just another edge, and a plain MST over n+1 nodes is the answer.

function minCostToSupplyWater(n, wells, pipes) {
  // vertex 0 is virtual: edge 0→i with cost wells[i-1] means "dig a well at i"
  const edges = pipes.slice();
  for (let i = 0; i < n; i++) edges.push([0, i + 1, wells[i]]);

  const result = kruskalMST(n + 1, edges); // n+1 vertices now, so n edges in the tree
  return result.total;
}

Dry run: minCostToSupplyWater(3, [1, 2, 2], [[1, 2, 1], [2, 3, 1]])

Village 1 has a cheap well (1), villages 2 and 3 have dearer wells (2 each), and two pipes cost 1 apiece. With the virtual vertex 0 added, the edge list is the two pipes followed by 0–1 (1), 0–2 (2) and 0–3 (2). Sorting is stable, so equal weights keep their input order:

edgewsame component?actiontotal
1–2 (pipe)1notake1
2–3 (pipe)1notake2
0–1 (well at 1)1notake — 3 = n edges accepted, stop3
0–2, 0–3 (wells)2never examined

The plan it found: dig one well at village 1 and run pipes 1–2 and 2–3, for 1 + 1 + 1 = 3. Digging all three wells would cost 5. In the tree every village has a path to vertex 0 whose last edge is a well, so each village is supplied by a chain of pipes from a village that has a well.

The one-line separation MST answers "what's the cheapest wiring for the whole town?" Dijkstra answers "what's my fastest commute from my house?" Cheapest total wiring will happily route your commute the long way around. Whenever a graph problem shows up, decide which of those two sentences it is before writing a line of code.

Two more disguises worth recognising instantly. "Remove the maximum number of edges while keeping the graph connected" → build an MST, the answer is E − (V−1). "Minimise the largest edge on a path between all pairs" → the MST is also a minimax spanning tree, so the answer is the heaviest edge on the MST path, not a shortest-path computation.

Say it like this → "Connecting everything at minimum total cost is a minimum spanning tree. The graph here is complete — every pair has a weight — so I'll use the O(V²) form of Prim and skip materialising the half-million edges Kruskal would need to sort. If the graph were sparse and given as an edge list, I'd sort and run Kruskal with union-find instead."

Maximum spanning tree: negate the weights

Some problems want the heaviest wiring that still connects everything: the most reliable links, the widest pipes, the highest total capacity. Both properties flip cleanly. The heaviest edge crossing any cut belongs to some maximum spanning tree, and the lightest edge on any cycle is never needed. You do not need a second algorithm. Every spanning tree has exactly n−1 edges, so negating every weight multiplies every tree's total by −1 and reverses the ranking of the trees: the tree with the smallest negated total is the tree with the largest original total.

function maxSpanningTreeWeight(n, edges) {
  const flipped = edges.map(([u, v, w]) => [u, v, -w]);
  const result = kruskalMST(n, flipped); // the same function as above, untouched
  return result === null ? null : -result.total; // flip the sign back
}

This works because Kruskal only compares weights; it never relied on them being positive, unlike Dijkstra. For the same reason you can add one constant to every weight, or subtract one, without changing which tree Kruskal picks: all spanning trees contain the same number of edges, so every total moves by the same amount. Sorting in descending order and keeping the rest of the loop is the equivalent one-line change.

⚠ Negation is for spanning trees only Do not carry this over to paths. Negating weights turns "shortest path" into "longest path", which is NP-hard on a general graph and is not what Dijkstra solves. The trick is safe here because a spanning tree always has the same number of edges, whatever you choose.

Why the MST path is also the minimax path

The warning at the top said an MST route between two vertices need not be the cheapest route. It is still the best route by a different measure. Define the bottleneck of a path as its single heaviest edge. For any two vertices u and v, the path between them inside the MST has the smallest bottleneck of all u–v paths in the whole graph.

Here is the proof, using nothing but the cut property. Let e = (a, b) be the heaviest edge on the tree path from u to v, and suppose some other path P in the graph had a bottleneck lighter than w(e). Delete e from the tree: the tree falls into two pieces, and because e was on the tree path, u is in one piece and v is in the other. P walks from u to v, so at least one of its edges f crosses between the two pieces. Then w(f) ≤ bottleneck(P) < w(e), and swapping e for f gives a spanning tree that is strictly lighter than the MST. That is a contradiction, so no such P exists.

This turns a family of problems into union-find. If you only need the bottleneck between two specific vertices, run Kruskal's sweep and stop the moment u and v land in the same component: the weight of the edge you just accepted is the answer. You never need the tree itself.

Way to get the minimum bottleneckTimeNotes
Kruskal's sweep until u and v connectO(E log E)no priority queue, a dozen lines, answers many pairs offline
Dijkstra with max instead of +O(E log V)needs adjacency lists; the cost of a path is its largest edge
Binary search on the limit, then BFSO(E log W)W is the number of distinct weights; works when E is huge and weights are small integers

Path with minimum effort: Kruskal over the grid edges

Given a grid of heights, the effort of a route is the largest absolute height difference between consecutive cells, and you want the route from the top-left to the bottom-right with the least effort. The graph is the grid itself: each cell is a vertex, each pair of neighbours is an edge, and the weight is the height difference. "Smallest possible largest edge on a path" is exactly the bottleneck from the last section, so the answer is the weight of the edge that first connects the two corners in a Kruskal sweep. (The shortest-path version of this problem is the Dijkstra exercise in the graph chapter; the two solutions are worth writing side by side.)

// O(RC log RC) time for R rows and C columns, O(RC) space
function minimumEffortPath(heights) {
  const rows = heights.length, cols = heights[0].length;
  if (rows * cols === 1) return 0; // start is already the finish

  const edges = [];
  for (let r = 0; r < rows; r++) {
    for (let c = 0; c < cols; c++) {
      const id = r * cols + c; // flatten (r, c) into one vertex number
      if (c + 1 < cols) edges.push([id, id + 1, Math.abs(heights[r][c] - heights[r][c + 1])]);
      if (r + 1 < rows) edges.push([id, id + cols, Math.abs(heights[r][c] - heights[r + 1][c])]);
    }
  }
  edges.sort((a, b) => a[2] - b[2]);

  const dsu = new DSU(rows * cols);
  for (const [u, v, w] of edges) {
    dsu.union(u, v);
    if (dsu.find(0) === dsu.find(rows * cols - 1)) return w; // the corners just connected: w is the bottleneck
  }
  return 0; // unreachable: a grid is connected
}

Each cell contributes at most two edges (right and down), which covers every neighbouring pair once, so there are fewer than 2RC edges and the sort dominates. Running it on [[1,2,2],[3,8,2],[5,3,5]], with cells numbered 0 to 8 row by row:

edgewactioncorners 0 and 8 connected?
1–20unionno
2–50unionno
0–11unionno
0–32unionno
3–62unionno
6–72unionno
7–82unionyes — answer 2

The route it found is 0 → 3 → 6 → 7 → 8, down the left side and across the bottom, with every step costing 2. The heavier edges (3, 5, 6) are never looked at: the sweep stops as soon as the answer is known.

⚠ Three small traps in the grid version One cell. With a single cell the first and last vertex are the same and there are no edges, so the loop never runs; the early return handles it. The id formula. r * cols + c uses the column count, not the row count; swapping them makes two different cells share an id on a non-square grid. Check after every edge. The answer is the weight of the edge that completes the connection, so testing the corners only at the end would return the weight of the last edge in the sweep, which can be much heavier.

Edge-length limited paths: sort the queries too

Here is a more general shape. You have one fixed graph and many queries [p, q, limit], each asking "is there a path from p to q whose every edge is strictly shorter than limit?" Answering each query with its own search is O(Q · (V + E)). The bottleneck fact above says a query is yes exactly when p and q are connected using only edges lighter than the limit, and a union-find can maintain exactly that, provided edges are only ever added.

Union-find cannot delete, so you cannot sweep the limit downward. Sweep it upward instead. Sort the edges once by weight, sort the queries by limit, and walk the queries from smallest limit to largest: before answering a query, union every edge whose weight is below its limit. The set of unioned edges only grows, which is what the structure supports. Because you reordered the queries, record each one's original index and write its answer there.

// O(E log E + Q log Q) time, O((E + Q) α(V)) for the unions and finds
function distanceLimitedPathsExist(n, edgeList, queries) {
  const edges = [...edgeList].sort((a, b) => a[2] - b[2]);
  const order = queries.map((_, i) => i).sort((a, b) => queries[a][2] - queries[b][2]);

  const dsu = new DSU(n);
  const answer = new Array(queries.length).fill(false);
  let next = 0; // edges[0..next-1] are already unioned

  for (const qi of order) {
    const [p, q, limit] = queries[qi];
    while (next < edges.length && edges[next][2] < limit) { // strictly shorter than the limit
      dsu.union(edges[next][0], edges[next][1]);
      next++;
    }
    answer[qi] = dsu.find(p) === dsu.find(q); // answer goes back to the ORIGINAL position
  }
  return answer;
}

Dry run: n = 3, edges [[0,1,2],[1,2,4],[2,0,8],[1,0,16]], queries [[0,1,2],[0,2,5]]

query (original index)limitedges unioned before answeringfind(p) = find(q)?answer
[0, 1, 2] (0)2none: no edge is lighter than 2nofalse
[0, 2, 5] (1)50–1 (2) and 1–2 (4)yestrue

The result is [false, true]. Note the first query's edge 0–1 has weight exactly 2 and the limit is exactly 2: the comparison must be < to match "strictly shorter". If the statement says "at most", change it to <=; this is the one place the problem text decides the code.

⚠ Sorting the queries in place loses their order queries.sort(...) permutes the array you were given and you can no longer say which answer belongs to which query. Sort an array of indices (order above) and write answer[qi]. Also sort a copy of the edges, as kruskalMST does, so the caller's list is left alone.

The skeleton is reusable: whenever every query is a threshold on the same graph and you can answer it by "union everything below the threshold", sort queries by threshold and sweep once. It shows up as offline connectivity, and it is the same trick as sorting events in a sweep line.

Critical and pseudo-critical edges

Ties make the MST non-unique, so a sharper question becomes meaningful: which edges must be in every MST, and which are in some MST? Call an edge critical if it is in every MST, pseudo-critical if it is in some MSTs but not all, and neither if it is in none. The two tests fall straight out of the definitions, with a fresh Kruskal run each:

  • Critical: build the MST while forbidding the edge. If the total goes up (or the graph disconnects), the edge was irreplaceable.
  • In some MST: build the MST after forcing the edge in first. If the total still equals the unconstrained minimum, there is an MST that contains it.
// O(E² α(V)): the sort happens once, each of the 2E runs is a linear sweep. Assumes no self-loops.
function findCriticalAndPseudoCriticalEdges(n, edges) {
  const order = edges.map(([u, v, w], i) => [u, v, w, i]).sort((a, b) => a[2] - b[2]); // carry the original index

  function weightOfTree(skip, force) {
    const dsu = new DSU(n);
    let cost = 0, used = 0;
    if (force !== -1) {
      const [u, v, w] = edges[force];
      dsu.union(u, v);
      cost += w;
      used++;
    }
    for (const [u, v, w, i] of order) {
      if (i === skip) continue;
      if (dsu.union(u, v)) { cost += w; used++; }
    }
    return used === n - 1 ? cost : Infinity; // disconnected without this edge
  }

  const best = weightOfTree(-1, -1);
  const critical = [], pseudo = [];
  for (let i = 0; i < edges.length; i++) {
    if (weightOfTree(i, -1) > best) critical.push(i);        // check critical FIRST
    else if (weightOfTree(-1, i) === best) pseudo.push(i);   // in some MST but not in all
  }
  return [critical, pseudo];
}

Dry run: n = 5, edges 0: (0,1,1), 1: (1,2,1), 2: (2,3,2), 3: (0,3,2), 4: (0,4,3), 5: (3,4,3), 6: (1,4,6)

The unconstrained MST weighs 7. Running both tests on every edge:

edgetotal without ittotal with it forcedverdict
0: (0,1,1)87critical
1: (1,2,1)87critical
2: (2,3,2)77pseudo-critical
3: (0,3,2)77pseudo-critical
4: (0,4,3)77pseudo-critical
5: (3,4,3)77pseudo-critical
6: (1,4,6)710neither

The answer is [[0, 1], [2, 3, 4, 5]]. Edges 2 and 3 are interchangeable: they both close the cycle 0–1–2–3 and an MST needs one of them, so neither is critical. Edges 4 and 5 are interchangeable the same way. Edge 6 is the heaviest edge on a cycle, so the cycle property says it is never needed, and forcing it costs 3 more.

⚠ Order of the two tests, and skipping by index A critical edge also passes the "forced total equals the best" test, because it is in every MST. If you test pseudo-critical first, every critical edge is reported in both lists. Test critical first and use else. Separately, skip and force by the edge's index, never by comparing endpoints or weights: parallel edges and equal weights are exactly the situations this problem is about.

There is a faster solution that groups equal-weight edges and looks for bridges inside each group, but when the edge count is a few hundred the 2E-runs version above is the one to write: each piece is a function you already have, and the two definitions are the whole proof.

Second-best MST: one swap away

The second-best spanning tree is the cheapest tree that is not the MST itself (it may weigh the same, if the MST was not unique). The natural worry is that it could differ from the MST in many edges, so finding it would mean searching a huge space. It never has to: some second-best tree differs from the MST by exactly one edge.

The argument uses the exchange property of spanning trees. Let T be an MST and T′ a cheapest tree other than T. For any edge e that is in T′ but not in T, there is an edge f in T but not in T′ such that both T − f + e and T′ − e + f are spanning trees. Their weights add up to w(T) + w(T′), the same as the originals. T − f + e is a tree other than T, so w(T − f + e) ≥ w(T′). That forces w(T′ − e + f) ≤ w(T), and since T is minimum it equals w(T). So w(T − f + e) = w(T′): the one-swap tree is just as cheap as T′. Therefore it is enough to try every single swap.

Which swaps are legal? Add a non-tree edge e = (u, v) to T and you create one cycle, the tree path from u to v plus e. You must delete an edge on that cycle to get a tree again, and the best choice is the heaviest tree edge on the path. So:

second best = w(T) + min over non-tree edges e of ( w(e) − heaviest edge on the tree path between e's endpoints )

// O(E log E + E·V): one DFS per non-tree edge. Returns null if the graph is disconnected,
// Infinity if the graph is already a tree (no swap exists).
function secondBestMSTWeight(n, edges) {
  const order = edges.map(([u, v, w], i) => [u, v, w, i]).sort((a, b) => a[2] - b[2]);
  const dsu = new DSU(n);
  const adj = Array.from({ length: n }, () => []);
  const inTree = new Array(edges.length).fill(false);
  let total = 0, used = 0;

  for (const [u, v, w, i] of order) {
    if (dsu.union(u, v)) {
      inTree[i] = true;
      adj[u].push([v, w]); // keep the tree itself, to walk paths on it
      adj[v].push([u, w]);
      total += w;
      used++;
    }
  }
  if (used !== n - 1) return null;

  function heaviestOnPath(from, to) {
    const stack = [[from, -1, 0]]; // [node, parent, heaviest edge so far]; iterative, so a path graph cannot overflow the call stack
    while (stack.length > 0) {
      const [node, parent, heaviest] = stack.pop();
      if (node === to) return heaviest;
      for (const [next, w] of adj[node]) {
        if (next !== parent) stack.push([next, node, Math.max(heaviest, w)]);
      }
    }
    return -Infinity;
  }

  let best = Infinity;
  for (let i = 0; i < edges.length; i++) {
    if (inTree[i]) continue;
    const [u, v, w] = edges[i];
    best = Math.min(best, total + w - heaviestOnPath(u, v)); // swap e in, the path's heaviest edge out
  }
  return best;
}

Dry run: n = 4, edges (0,1,1), (1,2,2), (0,2,3), (2,3,4), (1,3,6)

stepresult
Kruskal takes(0,1,1), (1,2,2), (2,3,4); the others close cycles. total = 7
non-tree (0,2,3)tree path 0–1–2 has heaviest edge 2, swap cost 7 + 3 − 2 = 8
non-tree (1,3,6)tree path 1–2–3 has heaviest edge 4, swap cost 7 + 6 − 4 = 9
answermin(8, 9) = 8

A swap candidate can tie with the MST: if w(e) equals the heaviest edge on its path, the swap costs exactly w(T) and you have found a second MST. That is correct for "cheapest tree other than this one". Some problems ask for the cheapest tree strictly heavier than the MST; then you need the heaviest path edge that is strictly lighter than w(e), so each path query has to track the two largest distinct weights, not one. Read which version is asked before writing the code. For large V, replace the DFS with binary lifting over the tree (store, for each vertex and each power of two, the ancestor and the heaviest edge on the way up) and each query drops to O(log V).

Boruvka's algorithm: every component grabs its cheapest edge

There is a third classic MST algorithm, and it is the one that parallelises. Kruskal needs one global sorted order, and Prim grows one tree at a time. Boruvka works in rounds. At the start of a round, every current component looks at all edges leaving it and picks the cheapest one. By the cut property each of those picks belongs to some MST, so all of them can be accepted at once, merging components. Repeat until one component is left.

Each component merges with at least one other, so the component count at least halves each round and there are at most log₂ V rounds. A round scans the whole edge list once, so the total is O(E log V), the same as Prim with a binary heap, with no heap and no sort.

// O(E log V) time, O(V) extra space. Returns the MST weight, or null if the graph is disconnected.
function boruvkaMST(n, edges) {
  const dsu = new DSU(n);
  const lighter = (a, b) => edges[a][2] < edges[b][2] || (edges[a][2] === edges[b][2] && a < b); // ties broken by edge index
  let components = n, total = 0;

  while (components > 1) {
    const cheapest = new Array(n).fill(-1); // cheapest[root] = index of that component's best outgoing edge
    for (let i = 0; i < edges.length; i++) {
      const ru = dsu.find(edges[i][0]), rv = dsu.find(edges[i][1]);
      if (ru === rv) continue; // inside one component, not outgoing
      if (cheapest[ru] === -1 || lighter(i, cheapest[ru])) cheapest[ru] = i;
      if (cheapest[rv] === -1 || lighter(i, cheapest[rv])) cheapest[rv] = i;
    }

    let merged = false;
    for (let r = 0; r < n; r++) {
      if (cheapest[r] === -1) continue;
      const [u, v, w] = edges[cheapest[r]];
      if (dsu.union(u, v)) { total += w; components--; merged = true; } // two components may have picked the same edge
    }
    if (!merged) return null; // no component had an outgoing edge: disconnected
  }
  return total;
}

Dry run: 6 vertices, edges 0–1 (1), 2–3 (1), 4–5 (1), 1–2 (5), 3–4 (6), 0–5 (9)

roundcomponents at the starteach component's cheapest edgeacceptedtotal
1{0} {1} {2} {3} {4} {5}0 and 1 pick 0–1; 2 and 3 pick 2–3; 4 and 5 pick 4–50–1, 2–3, 4–5 (each picked twice, accepted once)3
2{0,1} {2,3} {4,5}{0,1}: 1–2 (5); {2,3}: 1–2 (5); {4,5}: 3–4 (6)1–2, 3–414

Six components became three and then one, and the total 1 + 1 + 1 + 5 + 6 = 14 matches Kruskal. The line that checks dsu.union before counting is not optional: two components often pick the same edge, as above, and it must be added once.

⚠ Ties need a rule The textbook argument assumes all weights are different. With equal weights, components can pick different edges that together form a cycle, and the cut property no longer tells you the whole batch is safe. Breaking ties by edge index makes every comparison strict, so the weights behave as if they were distinct. The union check in the merge loop also rejects any edge that would close a cycle, but do not lean on that alone.

You will rarely be asked to write Boruvka in an interview. It is worth one sentence as an answer to "is there another way?": each round every component picks its cheapest outgoing edge independently, so rounds map well onto parallel or distributed systems, and the idea of "cheapest outgoing edge per component" is a building block in faster MST algorithms.

Recognizing it in an unseen problem

  • "Connect all," "minimum cost to link every," "cheapest network/wiring/roads," "keep everything reachable" — total cost over the whole structure, not a route between two nodes
  • The graph is undirected and weighted. MST is undefined on a directed graph — that's the arborescence / Chu-Liu-Edmonds problem, and no interviewer expects it
  • Brute force would enumerate spanning trees — Cayley's formula says a complete graph on n vertices has nn−2 of them, so exhaustive search is hopeless and greedy is the whole point
  • Distinguish from Dijkstra: if a source vertex is named, or the answer is "distance from A to B," it's shortest paths, not MST
  • Distinguish from plain union-find connectivity: if weights are ignored and the question is just "are these connected / how many components," you need the DSU but not the sort
  • Per-node costs alongside per-edge costs → add a virtual node and turn the node cost into an edge cost
  • Complete/implicit graph on ≥ ~1000 points → O(V²) Prim; explicit sparse edge list → Kruskal
  • "Minimise the largest edge on a path," "minimum effort," "smallest limit so that A and B connect" → minimax path: the MST path is optimal, so sweep Kruskal until the endpoints join
  • Many queries of the form "connected using only edges below this limit?" → sort the queries by limit as well and answer them in one sweep of union-find
  • "Which edges are in every / in some / in no minimum spanning tree" → rerun Kruskal once per edge, skipping it and forcing it
  • "Second-cheapest spanning tree," "cost of the best tree that differs from the MST" → one edge swap: add a non-tree edge, remove the heaviest edge on the cycle it closes
  • "Maximise the total" or "widest path" on a connectivity problem → negate the weights, or sort descending
Before you move on
  • State the cut property from memory and use it to explain why both Kruskal and Prim are provably optimal, not just heuristically reasonable.
  • Write Kruskal with union-find, and explain why the sort — not the union-find work — dominates its O(E log E) cost.
  • Write Prim with a lazy min-heap, and explain what the stale-entry check (inTree[v]) prevents.
  • Explain why an MST is not a shortest-path tree, with a concrete case where the MST route between two nodes isn't the cheapest path between them.
  • Given a new problem, decide whether it's MST (minimize total wiring cost) or Dijkstra (minimize one source's route) — and separately, whether the graph is sparse (Kruskal) or dense/implicit (O(V²) Prim).
  • Prove from the cut property that the path between two vertices inside an MST has the smallest possible heaviest edge, and use it to solve minimum-effort grid paths with a Kruskal sweep that stops when the corners connect.
  • Answer many "is there a path using only edges below L" queries offline by sorting both edges and queries, and say why the queries must keep their original index.
  • Classify an edge as critical, pseudo-critical or neither with two Kruskal runs, and say why the critical test comes first.
  • Compute the second-best MST by one swap, and say what changes if the problem wants a tree strictly heavier than the MST.
  • Describe Boruvka's rounds in two sentences and say why there are at most log V of them.

09Practice this chapter

Opens in the editor: write it, run it, and check it against real tests.

  • 8 testsMin Cost to Connect All Pointsadvanced
  • 9 testsConnecting Cities With Minimum Cost (Kruskal)intermediate
  • 9 testsPath With Minimum Effortintermediate
  • 8 testsOptimize Water Distribution in a Villageintermediate
  • 8 testsChecking Existence of Edge-Length Limited Pathsadvanced
  • 8 testsCritical and Pseudo-Critical Edges in an MSTadvanced
  • 8 testsSecond-Best Minimum Spanning Treeadvanced
  • 7 testsMinimum Bottleneck Between Two Verticesintermediate
  • 8 testsHighest-Capacity Networkintermediate
All 159 for this level →

✓Check yourself

5 questions, pass at 4. The check runs in your browser and the result stays on this device, so it is a self-check and not a certificate.

A4

Finished reading?

0 of 48 chapters read in this series

Mark as read

→Next stop

  1. A3Previous · A3Advanced graph algorithms
  2. A4you are here
  3. Next · A5Advanced graphs: SCC, bridges, Euler paths, LCAOne DFS that stamps each vertex with the time it was reached answers questions that look like they need a search per vertex.40 min read

01On this page

16 sections · 0%

  1. 01What a minimum spanning tree actually is
  2. 02Why greedy is safe: the cut property
  3. 03Kruskal's algorithm: sort every edge, take it if it doesn't close a cycle
  4. 04Watching Kruskal build the tree, edge by edge
  5. 05Prim's algorithm: grow one tree outward with a heap
  6. 06Kruskal vs Prim: which one in the interview
  7. 07Min Cost to Connect All Points — the dense case done right
  8. 08The virtual-node trick, and other MST disguises
  9. 09Maximum spanning tree: negate the weights
  10. 10Why the MST path is also the minimax path
  11. 11Path with minimum effort: Kruskal over the grid edges
  12. 12Edge-length limited paths: sort the queries too
  13. 13Critical and pseudo-critical edges
  14. 14Second-best MST: one swap away
  15. 15Boruvka's algorithm: every component grabs its cheapest edge
  16. 16Recognizing it in an unseen problem
Mark as read

[] chapters · t top