Dijkstra’s Algorithm - Implementation Patterns
Priority Queue Choice Matters
The priority queue implementation fundamentally determines Dijkstra’s performance:
| Implementation | Extract-Min | Decrease-Key | Total Complexity |
|---|---|---|---|
| Binary Heap | O(log V) | O(log V) | O((V + E) log V) |
| Fibonacci Heap | O(log V) | O(1) amortized | O(E + V log V) |
| Array (linear search) | O(V) | O(1) | O(V²) |
Practical insight: Binary heap is usually best unless E ≈ V² (dense graphs), where array-based is simpler and competitive.
The Visited Set Pattern
A common implementation mistake is processing nodes multiple times:
# WRONG: No visited check
while pq:
dist, node = heappop(pq)
for neighbor, weight in graph[node].items():
new_dist = dist + weight
if new_dist < distances[neighbor]:
distances[neighbor] = new_dist
heappush(pq, (new_dist, neighbor))This creates duplicate entries in the priority queue, degrading from O((V+E) log V) to O((V+E) log E).
# CORRECT: Check if visited
while pq:
dist, node = heappop(pq)
if node in visited: # Skip duplicates
continue
visited.add(node)
for neighbor, weight in graph[node].items():
new_dist = dist + weight
if new_dist < distances[neighbor]:
distances[neighbor] = new_dist
heappush(pq, (new_dist, neighbor))Lazy vs Eager Updates
Lazy (Python heapq default)
# Just push new entries, filter at extraction
if new_dist < distances[neighbor]:
distances[neighbor] = new_dist
heappush(pq, (new_dist, neighbor)) # Create duplicatePros: Simpler, works with heapq Cons: More memory, more log operations
Eager (requires decrease-key)
# Update existing entry in place
if new_dist < distances[neighbor]:
distances[neighbor] = new_dist
pq.decrease_key(neighbor, new_dist) # Needs custom heapPros: Fewer operations, theoretically optimal Cons: Complex implementation, rarely worth it in practice
Path Reconstruction Pattern
Track predecessors during relaxation:
def dijkstra_with_path(graph, start, target):
distances = {node: float('inf') for node in graph}
distances[start] = 0
predecessors = {node: None for node in graph}
pq = [(0, start)]
visited = set()
while pq:
dist, current = heappop(pq)
if current == target: # Early termination
break
if current in visited:
continue
visited.add(current)
for neighbor, weight in graph[current].items():
new_dist = dist + weight
if new_dist < distances[neighbor]:
distances[neighbor] = new_dist
predecessors[neighbor] = current
heappush(pq, (new_dist, neighbor))
# Reconstruct path
path = []
current = target
while current is not None:
path.append(current)
current = predecessors[current]
path.reverse()
return distances[target], pathEarly Termination for Single Target
If you only need the path to one target, stop when you extract it:
while pq:
dist, current = heappop(pq)
if current == target:
return dist # Found it!
# ... rest of algorithmThis is correct because Dijkstra’s Algorithm - The Greedy Invariant guarantees the first extraction has the optimal distance.
Graph Representation Choice
Adjacency List (dict of dict)
graph = {
'A': {'B': 4, 'C': 2},
'B': {'C': 1, 'D': 5},
'C': {'D': 8},
}Best for: Sparse graphs (E << V²)
Adjacency Matrix (2D array)
# INF represents no edge
graph = [
[0, 4, 2, INF],
[INF, 0, 1, 5 ],
[INF, INF, 0, 8 ],
]Best for: Dense graphs (E ≈ V²)
For Dijkstra, adjacency list is typically better since we iterate over neighbors frequently.
Handling Disconnected Graphs
def dijkstra(graph, start):
distances = {node: float('inf') for node in graph}
distances[start] = 0
# ... run algorithm ...
# Check for unreachable nodes
unreachable = [node for node, dist in distances.items()
if dist == float('inf')]
return distances, unreachableBidirectional Dijkstra Optimization
For single source-target queries, run Dijkstra from both ends:
def bidirectional_dijkstra(graph, start, target):
# Forward search from start
forward = dijkstra_partial(graph, start)
# Backward search from target (needs reverse graph)
backward = dijkstra_partial(reverse_graph, target)
# Find meeting point with minimum total distance
meeting = min(graph.nodes,
key=lambda n: forward[n] + backward[n])
return forward[meeting] + backward[meeting]This can cut search space roughly in half for large graphs.
When Dijkstra is the Right Choice
Use Dijkstra when:
- Single-source shortest paths needed
- Non-negative weights (or you’ll need Bellman-Ford Algorithm - Negative Weight Handling)
- Finding paths to multiple targets from one source
- Dense graphs where E = Ω(V²) and simple array-based PQ works
Don’t use Dijkstra when:
- Unweighted graphs → Use BFS (simpler and faster)
- Negative weights → Use Bellman-Ford Algorithm - Edge Relaxation Pattern
- All-pairs shortest paths → Consider Floyd-Warshall Algorithm
- A heuristic available* → Use A* (Dijkstra with guidance)
Common Implementation Bugs
- Forgetting visited check → O((V+E) log E) instead of O((V+E) log V)
- Using wrong tuple order →
(node, dist)instead of(dist, node)breaks heapq - Not initializing all nodes → Missing nodes get no distance estimate
- Comparing to
visitedbefore processing → Can prevent finding paths
The Testing Pattern
def test_dijkstra():
# Triangle inequality test
graph = {'A': {'B': 4, 'C': 2}, 'B': {'C': 1}, 'C': {}}
distances = dijkstra(graph, 'A')
assert distances['B'] <= distances['A'] + 4
assert distances['C'] <= distances['A'] + 2
assert distances['C'] <= distances['B'] + 1
# Shortest path optimality
assert distances['C'] == 2 # Direct is better than A→B→CRelated Concepts
- Dijkstra’s Algorithm - The Greedy Invariant
- Shortest Path Relaxation Operation
- Union Find (for Kruskal’s MST algorithm)
- BFS as Wavefront Propagation (unweighted case)
Reconstruction: The visited set optimization prevents duplicate processing. Binary heap is typically best due to good constant factors despite Fibonacci heap’s better asymptotic complexity.