Trees & graphs · Lesson 34 of 62 · about 60 min
Spanning trees & more shortest paths
Minimum spanning trees with Prim and Kruskal, shortest paths with negative edges (Bellman-Ford), all-pairs distances (Floyd-Warshall), and 0-1 BFS.
On this page · 9 sections
Minimum spanning trees#
A spanning tree uses exactly n-1 edges to connect n nodes with no cycle. Among all of them, the minimum spanning tree (MST) has the smallest total weight. Both classic algorithms are greedy, and both are correct because of the cut property: for any split of the nodes into two groups, the cheapest edge crossing the split belongs to some MST.
| Algorithm | Idea | Best for | Time |
|---|---|---|---|
| Kruskal | sort all edges; take each one that joins two different components (Union-Find) | sparse graphs, edge lists | O(m log m) |
| Prim | grow one tree; always add the cheapest edge leaving it (a heap) | dense graphs, adjacency lists | O(m log n), or O(n²) with an array |
Kruskal was covered with Union-Find. Step through it again here, then compare with Prim below:
package main
import (
"container/heap"
"fmt"
)
type edge struct{ to, w int }
type item struct{ w, to, from int }
type pq []item
func (p pq) Len() int { return len(p) }
func (p pq) Less(i, j int) bool { return p[i].w < p[j].w }
func (p pq) Swap(i, j int) { p[i], p[j] = p[j], p[i] }
func (p *pq) Push(x any) { *p = append(*p, x.(item)) }
func (p *pq) Pop() any {
old := *p
x := old[len(old)-1]
*p = old[:len(old)-1]
return x
}
func prim(adj [][]edge) (int, [][2]int) {
inTree := make([]bool, len(adj))
h := &pq{{0, 0, -1}} // start from node 0 with a free "edge"
total := 0
var used [][2]int
for h.Len() > 0 {
it := heap.Pop(h).(item)
if inTree[it.to] {
continue // a cheaper edge already brought this node in
}
inTree[it.to] = true
total += it.w
if it.from >= 0 {
used = append(used, [2]int{it.from, it.to})
}
for _, e := range adj[it.to] {
ifinTree[e.to] {
heap.Push(h, item{e.w, e.to, it.to})
}
}
}
return total, used
}
func main() {
// A=0 B=1 C=2 D=3 E=4, the same graph as the Kruskal visualizer
edges := [][3]int{{0, 1, 4}, {0, 2, 1}, {1, 2, 2}, {1, 3, 5}, {2, 3, 8}, {3, 4, 3}, {2, 4, 9}}
adj := make([][]edge, 5)
for _, e := range edges {
adj[e[0]] = append(adj[e[0]], edge{e[1], e[2]})
adj[e[1]] = append(adj[e[1]], edge{e[0], e[2]})
}
total, used := prim(adj)
fmt.Println("MST weight:", total)
name := "ABCDE"
for _, u := range used {
fmt.Printf(" %c-%c\n", name[u[0]], name[u[1]])
}
}MST weight: 11
A-C
C-B
B-D
D-ESame weight as Kruskal (11), found in a different order: Prim grows outward from A (A-C, C-B, B-D, D-E) while Kruskal picks globally cheapest edges first.
Bellman-Ford: negative edges#
Dijkstra finalizes a node as soon as it is popped. With a negative edge, a path discovered later could still be cheaper, so that shortcut breaks. Bellman-Ford makes no such assumption: it relaxes every edge, n-1 times. A shortest path visits at most n nodes and so uses at most n-1 edges; after round i, every shortest path with up to i edges is correct.
Run one more round. If anything still improves, the graph has a negative cycle reachable from the source: you could loop around it forever, getting cheaper each time, so "shortest path" is undefined. This is exactly how currency arbitrage is detected (with weights -log(rate)).
package main
import (
"fmt"
"math"
)
type Edge struct{ U, V, W int }
const inf = math.MaxInt / 2 // big, but safe to add to
func bellmanFord(n int, edges []Edge, src int) ([]int, bool) {
dist := make([]int, n)
for i := range dist {
dist[i] = inf
}
dist[src] = 0
for round := 1; round <= n-1; round++ {
changed := false
for _, e := range edges {
if dist[e.U] != inf dist[e.U]+e.W < dist[e.V] {
dist[e.V] = dist[e.U] + e.W
changed = true
}
}
fmt.Printf(" after round %d: %v\n", round, show(dist))
ifchanged {
break // already stable: stop early
}
}
for _, e := range edges { // the extra round
if dist[e.U] != inf dist[e.U]+e.W < dist[e.V] {
return dist, false
}
}
return dist, true
}
func show(d []int) []string {
out := make([]string, len(d))
for i, x := range d {
if x == inf {
out[i] = "∞"
} else {
out[i] = fmt.Sprint(x)
}
}
return out
}
func main() {
edges := []Edge{{0, 1, 4}, {0, 2, 5}, {1, 3, 3}, {2, 1, -3}, {3, 4, 2}}
fmt.Println("with a negative edge 2->1 (-3):")
dist, ok := bellmanFord(5, edges, 0)
fmt.Println(" ok:", ok, "dist:", show(dist))
cyc := append(edges, Edge{4, 2, -6}) // 2 -> 1 -> 3 -> 4 -> 2 sums to -4
fmt.Println("with a negative cycle:")
_, ok = bellmanFord(5, cyc, 0)
fmt.Println(" ok:", ok)
}with a negative edge 2->1 (-3):
after round 1: [0 2 5 7 9]
after round 2: [0 2 5 5 7]
after round 3: [0 2 5 5 7]
ok: true dist: [0 2 5 5 7]
with a negative cycle:
after round 1: [0 2 3 7 9]
after round 2: [0 0 1 5 7]
after round 3: [0 -2 -1 3 5]
after round 4: [0 -4 -3 1 3]
ok: falseDijkstra would have finalized node 1 at distance 4 (the direct edge) before discovering 0→2→1 costs only 2. Bellman-Ford gets it right at the price of O(n·m) time.
Floyd-Warshall: every pair at once#
When you need distances between all pairs of nodes and n is small (up to about 400), Floyd-Warshall is three nested loops over a distance matrix:
for k := range n { // allow k as an intermediate stop
for i := range n {
for j := range n {
dist[i][j] = min(dist[i][j], dist[i][k]+dist[k][j])
}
}
}The invariant: after the iteration for k, dist[i][j] is the shortest path from i to j that only passes through nodes 0..k. Adding one more allowed intermediate node can only help if the path goes through it, which is exactly dist[i][k] + dist[k][j].
package main
import (
"fmt"
"math"
)
const inf = math.MaxInt / 2
func show(row []int) []string {
out := make([]string, len(row))
for i, x := range row {
out[i] = fmt.Sprint(x)
if x >= inf {
out[i] = "∞"
}
}
return out
}
func main() {
n := 4
dist := make([][]int, n)
for i := range dist {
dist[i] = make([]int, n)
for j := range dist[i] {
if i != j {
dist[i][j] = inf
}
}
}
for _, e := range [][3]int{{0, 1, 3}, {1, 2, 1}, {2, 3, 2}, {0, 3, 10}, {3, 0, 1}} {
dist[e[0]][e[1]] = e[2] // directed edges
}
for k := range n {
for i := range n {
for j := range n {
dist[i][j] = min(dist[i][j], dist[i][k]+dist[k][j])
}
}
fmt.Printf("allowing stops 0..%d: row 0 = %v\n", k, show(dist[0]))
}
fmt.Println("full matrix:")
for _, row := range dist {
fmt.Println(" ", row)
}
}allowing stops 0..0: row 0 = [0 3 ∞ 10]
allowing stops 0..1: row 0 = [0 3 4 10]
allowing stops 0..2: row 0 = [0 3 4 6]
allowing stops 0..3: row 0 = [0 3 4 6]
full matrix:
[0 3 4 6]
[4 0 1 3]
[3 6 0 2]
[1 4 5 0]The 0→3 distance improves from 10 to 6 only once node 2 is allowed as a stop (0→1→2→3). Negative edges are fine; a negative value on the diagonal afterwards means a negative cycle.
0-1 BFS#
If every edge costs 0 or 1, you do not need a heap. Use a deque: pop from the front; push neighbors reached by a 0-cost edge to the front (same distance) and those reached by a 1-cost edge to the back (one more). The deque stays sorted by distance, so it behaves like Dijkstra in O(n + m).
Typical shape: "minimum number of walls to break", "minimum arrow changes to reach the corner", or moving through a grid where some moves are free.
package main
import "fmt"
// Minimum walls (#) to break to get from the top-left to the bottom-right.
func minBreaks(grid []string) int {
R, C := len(grid), len(grid[0])
dist := make([][]int, R)
for i := range dist {
dist[i] = make([]int, C)
for j := range dist[i] {
dist[i][j] = -1
}
}
type cell struct{ r, c int }
dq := []cell{{0, 0}}
dist[0][0] = 0
for len(dq) > 0 {
cur := dq[0]
dq = dq[1:]
for _, d := range []cell{{1, 0}, {-1, 0}, {0, 1}, {0, -1}} {
nr, nc := cur.r+d.r, cur.c+d.c
if nr < 0 nc < 0 nr >= R nc >= C {
continue
}
w := 0
if grid[nr][nc] == '#' {
w = 1 // breaking a wall costs 1
}
nd := dist[cur.r][cur.c] + w
if dist[nr][nc] == -1 nd < dist[nr][nc] {
dist[nr][nc] = nd
if w == 0 {
dq = append([]cell{{nr, nc}}, dq...) // same distance: front
} else {
dq = append(dq, cell{nr, nc}) // one more: back
}
}
}
}
return dist[R-1][C-1]
}
func main() {
fmt.Println(minBreaks([]string{
"..#..",
"###.#",
"..#..",
".####",
"....#",
}))
fmt.Println(minBreaks([]string{"..", ".."}))
}2
0Prepending with append([]cell{x}, dq...) copies the slice, which is fine for a lesson. A long-running solution would use the ring-buffer deque from the deques lesson.
A*: Dijkstra with a sense of direction#
A* is Dijkstra that orders the heap by dist[v] + h(v), where h is a heuristic estimate of the remaining distance to the goal, such as the straight-line or Manhattan distance on a map. If h never overestimates, A* still finds a shortest path, but it explores far fewer nodes because it prefers nodes that are heading toward the goal. Games, robot navigation and map routing all use it.
Choosing a shortest-path algorithm#
| Situation | Algorithm | Time |
|---|---|---|
| all edges cost the same | BFS | O(n + m) |
| edges cost 0 or 1 | 0-1 BFS | O(n + m) |
| non-negative weights, one source | Dijkstra (A* with a good heuristic and one target) | O(m log n) |
| negative weights, or a limit on edges used | Bellman-Ford | O(n · m) |
| all pairs, n ≤ ~400 | Floyd-Warshall | O(n³) |
| a DAG (no cycles) | relax edges in topological order | O(n + m) |
Summary#
- An MST connects every node at minimum total cost; Kruskal sorts edges, Prim grows a tree with a heap.
- Bellman-Ford handles negative edges in O(n·m) and detects negative cycles with one extra round.
- Floyd-Warshall gives all-pairs distances in O(n³); keep
kas the outer loop. - 0-1 BFS replaces the heap with a deque when weights are 0 or 1.
- Pick the algorithm from the edge weights and the question (one target, one source, all pairs).
Check your understanding
Answer before scrolling back up. If you miss one, reread the matching section, then try again.
Q1.Prim's algorithm grows the spanning tree by repeatedly adding:
Q2.Why can't Dijkstra handle negative edge weights?
Q3.Bellman-Ford relaxes every edge n-1 times. If an n-th round still improves a distance:
Q4.In Floyd-Warshall, the outermost loop must be over:
Q5.0-1 BFS pushes a neighbor reached by a 0-cost edge to:
Practice in Go
Write a table test first, then the solution. On LeetCode, choose Go as the language. Open a hint only after 15 minutes stuck.
Show hint
Dense graph: O(n²) Prim with a dist array beats sorting n² edges.
- Network Delay Time ↗medium
Show hint
Dijkstra from k; the answer is the largest distance.
Show hint
Bellman-Ford for k+1 rounds, relaxing from a copy of the previous round.
Show hint
Floyd-Warshall, then count per row.
Show hint
Following the arrow costs 0, changing it costs 1: 0-1 BFS.
Show hint
Dijkstra where a path's cost is its maximum step, not its sum.
Finished this lesson?
Mark it done to track your progress. It is saved in this browser only.