backend / dsa / graphs / 02_dijkstra_shortest_path.md

Dijkstra's algorithm — shortest path in weighted graph

4 interview angles 4 min read source

Dijkstra’s algorithm — shortest path in weighted graph

For non-negative weights. With a binary heap (Python’s heapq): O((V+E) log V).

Setup

import heapq
from collections import defaultdict
from typing import Hashable

# Adjacency list with weights:
# graph[u] = [(weight, neighbor), ...]
graph: dict[Hashable, list[tuple[int, Hashable]]] = defaultdict(list)
for u, v, w in edges:
    graph[u].append((w, v))
    graph[v].append((w, u))   # for undirected

Standard Dijkstra

def dijkstra(graph, start):
    """Returns dict of shortest distances from start to every reachable node."""
    dist = {start: 0}
    heap = [(0, start)]   # (distance, node)
    
    while heap:
        d, node = heapq.heappop(heap)
        if d > dist.get(node, float("inf")):
            continue   # stale entry — already found a better path
        for w, neighbor in graph[node]:
            nd = d + w
            if nd < dist.get(neighbor, float("inf")):
                dist[neighbor] = nd
                heapq.heappush(heap, (nd, neighbor))
    
    return dist

Why the staleness check? Python’s heap doesn’t support decrease_key. When a shorter path is found, we push a new entry instead of updating the old one. The old entry stays in the heap but gets ignored when popped.

Path reconstruction

def dijkstra_with_path(graph, start, target):
    dist = {start: 0}
    prev = {}
    heap = [(0, start)]
    
    while heap:
        d, node = heapq.heappop(heap)
        if node == target: break
        if d > dist.get(node, float("inf")): continue
        for w, neighbor in graph[node]:
            nd = d + w
            if nd < dist.get(neighbor, float("inf")):
                dist[neighbor] = nd
                prev[neighbor] = node
                heapq.heappush(heap, (nd, neighbor))
    
    # Reconstruct path
    if target not in dist:
        return None
    path = [target]
    while path[-1] != start:
        path.append(prev[path[-1]])
    path.reverse()
    return dist[target], path

When NOT to use Dijkstra

  • Negative weights → Bellman-Ford. Dijkstra’s greedy choice fails when a longer path can become shorter via a negative edge.
  • All edges equal weight → BFS is simpler and just as fast.
  • All-pairs shortest path on dense graphs → Floyd-Warshall (O(V³)).
  • Heuristic available toward target → A* (Dijkstra + heuristic).

A* — Dijkstra with a heuristic

When you have an admissible heuristic h(node) (lower bound on remaining cost), A* prioritizes nodes likely on the optimal path:

import heapq

def astar(graph, start, target, heuristic):
    g_score = {start: 0}
    heap = [(heuristic(start), 0, start)]   # (f, g, node)
    
    while heap:
        f, g, node = heapq.heappop(heap)
        if node == target: return g
        if g > g_score.get(node, float("inf")): continue
        for w, neighbor in graph[node]:
            ng = g + w
            if ng < g_score.get(neighbor, float("inf")):
                g_score[neighbor] = ng
                heapq.heappush(heap, (ng + heuristic(neighbor), ng, neighbor))
    return None

For grid pathfinding: heuristic = Manhattan distance (grid) or Euclidean (continuous).

Heuristic must be admissible (never overestimates) for A* to find optimal paths. Manhattan is admissible on a grid with 4-connectivity.

Bellman-Ford — handles negative edges

For graphs with negative edges (no negative cycles). O(VE):

def bellman_ford(num_nodes, edges, start):
    INF = float("inf")
    dist = [INF] * num_nodes
    dist[start] = 0
    
    for _ in range(num_nodes - 1):
        updated = False
        for u, v, w in edges:
            if dist[u] + w < dist[v]:
                dist[v] = dist[u] + w
                updated = True
        if not updated:
            break
    
    # Check for negative cycle
    for u, v, w in edges:
        if dist[u] + w < dist[v]:
            return None   # negative cycle reachable
    
    return dist

Negative cycles are detected on a final iteration (anything still updates → cycle).

Problem: cheapest flights with K stops

import heapq

def find_cheapest_price(n, flights, src, dst, k):
    graph = defaultdict(list)
    for u, v, w in flights:
        graph[u].append((w, v))
    
    # (cost, node, stops_remaining)
    heap = [(0, src, k + 1)]
    while heap:
        cost, node, stops = heapq.heappop(heap)
        if node == dst:
            return cost
        if stops == 0:
            continue
        for w, neighbor in graph[node]:
            heapq.heappush(heap, (cost + w, neighbor, stops - 1))
    return -1

Pitfalls

  • Negative weights with Dijkstra — silent wrong answers. Check the problem.
  • Forgetting the staleness check — works but can degenerate to O(V × E log V).
  • Off-by-one in stop count — “K stops” usually means K+1 edges; verify with examples.
  • Dense graphs — heap-based Dijkstra is O((V+E) log V). For dense (E ~ V²), array-based is O(V²) and faster.
  • Heuristic that overestimates in A* — A* gives wrong answers; must be admissible.

Interview angle

  • “When does Dijkstra apply, and when does it break?” - non-negative edge weights only. A negative edge invalidates the greedy assumption that the minimum-distance unvisited node is final, so you need Bellman-Ford instead, which also detects negative cycles.
  • “Dijkstra or BFS?” - BFS is correct and faster when all edges have equal weight, since it’s Dijkstra with a queue instead of a heap. Reaching for Dijkstra on an unweighted graph is over-engineering.
  • “What’s the complexity?” - O((V + E) log V) with a binary heap. Note the lazy-deletion detail: Python’s heapq has no decrease-key, so you push duplicates and skip any popped node already finalised.
  • “How do you recover the actual path?” - keep a predecessor map updated whenever you improve a distance, then walk it backwards from the target. Returning only the distance when the question asked for the route is a common miss.