Shortest Path (Dijkstra)

BFS finds the shortest path in an unweighted graph because every edge "costs" the same one step. The moment edges have different weights — flight durations, road distances, network latencies — BFS breaks: a path with more edges can still be cheaper overall than a path with fewer, heavier ones. Dijkstra's algorithm solves shortest path for weighted graphs (as long as no edge weight is negative) by always expanding the closest unvisited node next, using a min-heap to efficiently find that node.

The Problem BFS Can't Solve

A --1-- B
|       |
10      1
|       |
C ------D
   1

BFS from A would report B and C as equally "close" (both one edge away) and never realize that A→B→D→C (cost 1+1+1=3) is far cheaper than A→C directly (cost 10). BFS counts edges, not weights — it has no concept of "cost" at all.

Dijkstra's Algorithm With heapq

Keep a dist dict initialized to infinity for every node (0 for the source). Python's heapq module turns a plain list into a min-heap — it always keeps the smallest item accessible at index 0, popped with heapq.heappop. We push (distance, node) tuples, since Python compares tuples element-by-element, so the heap naturally orders by distance first. Pop the smallest-distance node, and for each of its neighbors, if going through the current node gives a shorter path than what's currently recorded, update it — this update step is called relaxation.

import heapq

def dijkstra(adj, source):
    dist = {node: float("inf") for node in adj}
    dist[source] = 0

    # min-heap of (distance, node): smallest distance is always popped first
    heap = [(0, source)]

    while heap:
        d, node = heapq.heappop(heap)

        if d > dist[node]:
            continue  # stale entry, a shorter path was already found

        for neighbor, weight in adj[node]:
            new_dist = d + weight
            if new_dist < dist[neighbor]:
                dist[neighbor] = new_dist              # relax: found a shorter path
                heapq.heappush(heap, (new_dist, neighbor))

    return dist

Trace-Through on the Example Graph

Using A=0, B=1, C=2, D=3 and edges A-B(1), A-C(10), B-D(1), C-D(1), starting from A:

Initial:        dist = {A:0, B:inf, C:inf, D:inf}     heap: [(0,A)]

Pop (0,A):      relax B: 0+1=1  < inf -> dist[B]=1     heap: [(1,B), (10,C)]
                relax C: 0+10=10 < inf -> dist[C]=10
dist = {A:0, B:1, C:10, D:inf}

Pop (1,B):      relax D: 1+1=2  < inf -> dist[D]=2     heap: [(2,D), (10,C)]
dist = {A:0, B:1, C:10, D:2}

Pop (2,D):      relax C: 2+1=3  < 10 -> dist[C]=3      heap: [(3,C), (10,C)]
dist = {A:0, B:1, C:3, D:2}

Pop (3,C):      no shorter path found through C         heap: [(10,C)]
dist = {A:0, B:1, C:3, D:2}

Pop (10,C):     10 > dist[C]=3 -> stale entry, skipped  heap: []

Final shortest distances from A:  B=1, C=3, D=2

Notice C's shortest distance ends up as 3 (via A→B→D→C), not 10 (the direct A-C edge) — exactly the case a plain BFS-by-edge-count would have missed.

Time complexity: O((V + E) log V) — each of the up to E edge relaxations does a heap push/pop costing O(log V), and every vertex is popped from the heap at least once. Space complexity: O(V + E) for the adjacency list plus O(V) for the distance dict and heap contents.

The Key Limitation: Negative Weights

Dijkstra assumes distances only ever grow as you add edges. Once a node is popped from the heap with its current smallest distance, the algorithm treats that distance as final and never revisits it. A negative-weight edge discovered later could make an already-finalized path cheaper — but Dijkstra has no mechanism to go back and fix it, so it can silently produce a wrong answer, with no error or warning.

For graphs that may contain negative edge weights (but no negative cycles), Bellman-Ford is the standard alternative — it relaxes every edge V-1 times instead of relying on a heap's greedy ordering, which correctly handles negative weights at the cost of a slower O(V·E) runtime. It's not covered in depth here since Dijkstra covers the overwhelming majority of interview shortest-path questions, but it's worth knowing the name and the trade-off it exists for.

Next up: Dijkstra finds the cheapest path between two specific nodes. Minimum Spanning Tree tackles a related but different problem — connecting every node as cheaply as possible.