Graph Representation#
An adjacency list using integer node IDs is the right choice for Dijkstra in Go. String keys are convenient for small examples but add unnecessary hashing overhead on graphs with thousands of nodes. We represent weighted edges as structs and store the graph as a slice of slices (indexed by node ID).
package graph
const Infinity = int(^uint(0) >> 1)
// Edge represents a directed weighted connection from one node to another.
type Edge struct {
To int
Weight int
}
// Graph is an adjacency list indexed by node ID.
type Graph struct {
Edges [][]Edge
Size int
}
func NewGraph(n int) *Graph {
return &Graph{
Edges: make([][]Edge, n),
Size: n,
}
}
// AddEdge adds a directed edge. For undirected graphs, call twice.
func (g *Graph) AddEdge(from, to, weight int) {
g.Edges[from] = append(g.Edges[from], Edge{To: to, Weight: weight})
}Naive O(V²) Implementation#
The naive approach maintains an unvisited set and scans it linearly on every iteration to find the node with the minimum tentative distance. This is O(V) per iteration, giving O(V²) overall. It is acceptable for dense graphs (E close to V²) but impractical for sparse graphs with millions of nodes.
package graph
// DijkstraNaive returns the shortest distance from start to every reachable node.
// Time complexity: O(V^2). Use DijkstraHeap for large sparse graphs.
func DijkstraNaive(g *Graph, start int) (dist []int, prev []int) {
n := g.Size
dist = make([]int, n)
prev = make([]int, n)
visited := make([]bool, n)
for i := range dist {
dist[i] = Infinity
prev[i] = -1
}
dist[start] = 0
for range n {
// Find the unvisited node with the smallest tentative distance.
u := -1
for v := 0; v < n; v++ {
if !visited[v] && (u == -1 || dist[v] < dist[u]) {
u = v
}
}
if u == -1 || dist[u] == Infinity {
break // All remaining nodes are unreachable.
}
visited[u] = true
for _, e := range g.Edges[u] {
if alt := dist[u] + e.Weight; alt < dist[e.To] {
dist[e.To] = alt
prev[e.To] = u
}
}
}
return dist, prev
}Priority Queue Optimization: O((E+V) log V)#
Replacing the linear scan with a min-heap reduces the complexity from O(V²) to O((E+V) log V). Go’s container/heap package requires implementing the heap.Interface, which is five methods. The key insight: we do not decrease-key in the heap (that would require tracking indices). Instead we push a new entry and skip stale entries when they are popped.
package graph
import "container/heap"
// heapItem is an entry in the priority queue.
type heapItem struct {
node int
dist int
}
type minHeap []heapItem
func (h minHeap) Len() int { return len(h) }
func (h minHeap) Less(i, j int) bool { return h[i].dist < h[j].dist }
func (h minHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *minHeap) Push(x any) {
*h = append(*h, x.(heapItem))
}
func (h *minHeap) Pop() any {
old := *h
n := len(old)
item := old[n-1]
*h = old[:n-1]
return item
}
// DijkstraHeap returns shortest distances and the predecessor map from start.
// Time complexity: O((E + V) log V).
func DijkstraHeap(g *Graph, start int) (dist []int, prev []int) {
n := g.Size
dist = make([]int, n)
prev = make([]int, n)
for i := range dist {
dist[i] = Infinity
prev[i] = -1
}
dist[start] = 0
pq := &minHeap{{node: start, dist: 0}}
heap.Init(pq)
for pq.Len() > 0 {
item := heap.Pop(pq).(heapItem)
u := item.node
// Skip stale entries (a shorter path was already found).
if item.dist > dist[u] {
continue
}
for _, e := range g.Edges[u] {
if alt := dist[u] + e.Weight; alt < dist[e.To] {
dist[e.To] = alt
prev[e.To] = u
heap.Push(pq, heapItem{node: e.To, dist: alt})
}
}
}
return dist, prev
}The stale-entry check (if item.dist > dist[u]) is critical. Without it, you process the same node multiple times with outdated distances, producing incorrect results. This is the most common bug in Dijkstra heap implementations.
Path Reconstruction#
The prev array returned by both implementations records the predecessor of each node on the shortest path. To reconstruct the actual path from start to end, walk backwards through prev and reverse.
package graph
// ReconstructPath returns the node sequence on the shortest path from start to end.
// Returns nil if end is unreachable from start.
func ReconstructPath(prev []int, start, end int) []int {
if prev[end] == -1 && end != start {
return nil // end is unreachable
}
var path []int
for at := end; at != -1; at = prev[at] {
path = append(path, at)
}
// Reverse in place.
for i, j := 0, len(path)-1; i < j; i, j = i+1, j-1 {
path[i], path[j] = path[j], path[i]
}
return path
}Full usage:
package main
import (
"fmt"
"graph"
)
func main() {
g := graph.NewGraph(6) // nodes 0-5
g.AddEdge(0, 1, 7)
g.AddEdge(0, 2, 9)
g.AddEdge(0, 5, 14)
g.AddEdge(1, 2, 10)
g.AddEdge(1, 3, 15)
g.AddEdge(2, 3, 11)
g.AddEdge(2, 5, 2)
g.AddEdge(3, 4, 6)
g.AddEdge(4, 5, 9)
dist, prev := graph.DijkstraHeap(g, 0)
fmt.Println("Distance from 0 to 4:", dist[4]) // 26
path := graph.ReconstructPath(prev, 0, 4)
fmt.Println("Path:", path) // [0 2 5 4] -- but wait, 5->4 is not in our edges
// Correct path for this graph: 0->2->3->4, cost = 9+11+6 = 26
}Real-World Use Case: Service Dependency Resolution#
Consider a microservice platform where services declare dependencies on other services. During deployment, you want to find the minimum-latency path through the dependency graph to determine the optimal startup order and bottlenecks. The graph nodes are services, and edge weights represent measured inter-service call latency in milliseconds.
package deps
import "graph"
// ServiceGraph models service-to-service dependencies with latency weights.
type ServiceGraph struct {
g *graph.Graph
nameToID map[string]int
idToName []string
}
func NewServiceGraph(services []string) *ServiceGraph {
n := len(services)
sg := &ServiceGraph{
g: graph.NewGraph(n),
nameToID: make(map[string]int, n),
idToName: make([]string, n),
}
for i, name := range services {
sg.nameToID[name] = i
sg.idToName[i] = name
}
return sg
}
func (sg *ServiceGraph) AddDependency(from, to string, latencyMs int) {
sg.g.AddEdge(sg.nameToID[from], sg.nameToID[to], latencyMs)
}
// CriticalPath returns the highest-latency path from entry to target service.
func (sg *ServiceGraph) CriticalPath(from, to string) ([]string, int) {
start := sg.nameToID[from]
end := sg.nameToID[to]
dist, prev := graph.DijkstraHeap(sg.g, start)
nodeIDs := graph.ReconstructPath(prev, start, end)
names := make([]string, len(nodeIDs))
for i, id := range nodeIDs {
names[i] = sg.idToName[id]
}
return names, dist[end]
}Sample Graph and Shortest Path#
graph LR
A["A (0)"] -->|7| B["B (1)"]
A -->|9| C["C (2)"]
A -->|14| F["F (5)"]
B -->|10| C
B -->|15| D["D (3)"]
C -->|11| D
C -->|2| F
D -->|6| E["E (4)"]
E -->|9| F
style A fill:#4CAF50,color:#fff
style C fill:#2196F3,color:#fff
style F fill:#2196F3,color:#fff
style E fill:#FF5722,color:#fff
Shortest path from A to E: A(0) -> C(2) -> F(5) -> … wait, the edges do not connect F to E in this direction. The shortest path is A -> C -> D -> E with cost 9+11+6 = 26. The highlighted path shows the optimal route through nodes C and D.
Algorithm Comparison#
Best for: graphs with non-negative edge weights, especially sparse graphs.
Time complexity: O((E+V) log V) with binary heap.
Space complexity: O(V+E).
Limitations: fails with negative edge weights (use Bellman-Ford). No ability to bias search toward the goal (use A* if a good heuristic is available).
Go implementation: container/heap provides all necessary primitives. No external dependencies required.
Best for: graphs with negative edge weights, detecting negative cycles.
Time complexity: O(V * E). Much slower than Dijkstra on large graphs.
Space complexity: O(V).
Key property: can detect negative-weight cycles (Dijkstra diverges on them). Required for financial arbitrage detection and certain network routing protocols (BGP path analysis).
When to use it in Go: currency exchange rate graphs where negative log-weights encode arbitrage opportunities. Rarely needed in typical platform engineering.
Best for: pathfinding when a heuristic estimate of the distance to the goal is available (geographic coordinates, Manhattan distance in a grid).
Time complexity: O(E) in the best case with a perfect heuristic; degrades to Dijkstra with a zero heuristic.
Key property: the heuristic must be admissible (never overestimates true distance). An inadmissible heuristic produces suboptimal paths.
When to use it in Go: spatial pathfinding (game maps, physical network topology with geo-coordinates). Not useful for dependency graphs where no heuristic distance is computable.
Limitations#
Negative edge weights. Dijkstra assumes that once a node is finalized, no shorter path to it exists. A negative-weight edge can create a shorter path to an already-finalized node, which Dijkstra ignores, producing wrong results. Use Bellman-Ford or Johnson’s algorithm for graphs with negative weights.
Disconnected graphs. Nodes unreachable from the start retain their initial Infinity distance. Always check dist[target] == graph.Infinity before using the result. ReconstructPath returns nil for unreachable nodes, which the caller must handle.
Integer overflow. Using int(^uint(0) >> 1) as infinity works until you add two such values together (Infinity + weight overflows to a negative number). Guard additions: if dist[u] != Infinity && dist[u]+e.Weight < dist[v].
If you want to go deeper on any of this, I offer 1:1 coaching sessions for engineers working on AI integration, cloud architecture, and platform engineering. Book a session (50 EUR / 60 min) or reach out at manuel.fedele+website@gmail.com.