Go Guide

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
  1. 01Minimum spanning trees
  2. 02Bellman-Ford: negative edges
  3. 03Floyd-Warshall: every pair at once
  4. 040-1 BFS
  5. 05A: Dijkstra with a sense of direction
  6. 06Choosing a shortest-path algorithm
  7. 07Summary
  8. 08Check your understanding
  9. 09Practice in Go

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.

AlgorithmIdeaBest forTime
Kruskalsort all edges; take each one that joins two different components (Union-Find)sparse graphs, edge listsO(m log m)
Primgrow one tree; always add the cheapest edge leaving it (a heap)dense graphs, adjacency listsO(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:

prim.go
Go
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] {
			if !inTree[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]])
	}
}
Output
MST weight: 11
  A-C
  C-B
  B-D
  D-E

Same 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)).

bellmanford.go
Go
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))
		if !changed {
			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)
}
Output
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: false

Dijkstra 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:

Go
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].

floyd.go
Go
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)
	}
}
Output
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.

zeroonebfs.go
Go
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{"..", ".."}))
}
Output
2
0

Prepending 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#

SituationAlgorithmTime
all edges cost the sameBFSO(n + m)
edges cost 0 or 10-1 BFSO(n + m)
non-negative weights, one sourceDijkstra (A* with a good heuristic and one target)O(m log n)
negative weights, or a limit on edges usedBellman-FordO(n · m)
all pairs, n ≤ ~400Floyd-WarshallO(n³)
a DAG (no cycles)relax edges in topological orderO(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 k as 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.

  1. Q1.Prim's algorithm grows the spanning tree by repeatedly adding:

  2. Q2.Why can't Dijkstra handle negative edge weights?

  3. Q3.Bellman-Ford relaxes every edge n-1 times. If an n-th round still improves a distance:

  4. Q4.In Floyd-Warshall, the outermost loop must be over:

  5. 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.

Finished this lesson?

Mark it done to track your progress. It is saved in this browser only.