Algorithms · Lesson 12 of 17

Shortest Paths: Dijkstra and Bellman-Ford

Learn Dijkstra's algorithm and Bellman-Ford in Python to find shortest paths in weighted graphs, handle negative edges and detect negative cycles.

  • Advanced
  • 20 min read
  • 4 objectives

Before this lessonLesson 11: Graph Traversal: BFS and DFS

What you will learn

  • Explain why BFS fails on weighted graphs
  • Implement Dijkstra with a heap and rebuild the path
  • Implement Bellman-Ford and detect negative cycles
  • Choose the right algorithm and state its complexity

Your Progress

0 of 17 lessons 0%

  • Lessons0 / 17
  • Completed0
  • Est. time left~ 5 hours

Create a free account to keep your progress on every device.

Tip: pressing Next marks this lesson complete automatically.

BFS finds the path with the fewest edges. But real graphs have weights: road distances, network latency, ticket prices. A route with three short roads can beat one long highway, and BFS cannot see that. This lesson covers the two shortest-path algorithms every developer should know: Dijkstra, the fast default for non-negative weights, and Bellman-Ford, the slower but more general one that handles negative weights.

Both are built on one operation called relaxation. Keep a best-known distance dist[v] for every node, starting at infinity. When you look at an edge u to v with weight w, ask: is going through u better? If dist[u] + w < dist[v], update dist[v]. The algorithms differ only in the order they relax edges.

Why BFS is not enough

# delivery times in minutes between stackcone warehouses
roads = {
    "A": [("B", 10), ("C", 3)],
    "B": [("D", 2)],
    "C": [("B", 4), ("D", 8), ("E", 2)],
    "D": [("E", 7)],
    "E": [],
}
# BFS would pick A -> B -> D (2 edges, 12 minutes)
# but A -> C -> B -> D is 3 edges and only 9 minutes
print(10 + 2, 3 + 4 + 2)
Output
12 9

Dijkstra's algorithm

Dijkstra is BFS with a priority queue instead of a plain queue. It always expands the unfinished node with the smallest known distance. With non-negative weights, that node's distance can never improve later (any other route would go through a node that is already at least as far), so it is final.

# from earlier in this lesson
# delivery times in minutes between stackcone warehouses
roads = {
    "A": [("B", 10), ("C", 3)],
    "B": [("D", 2)],
    "C": [("B", 4), ("D", 8), ("E", 2)],
    "D": [("E", 7)],
    "E": [],
}
# BFS would pick A -> B -> D (2 edges, 12 minutes)
# but A -> C -> B -> D is 3 edges and only 9 minutes

# new code
import heapq

def dijkstra(graph, source):
    dist = {node: float("inf") for node in graph}
    parent = {source: None}
    dist[source] = 0
    heap = [(0, source)]
    while heap:
        d, u = heapq.heappop(heap)
        if d > dist[u]:
            continue                  # stale entry, already found better
        for v, w in graph[u]:
            if d + w < dist[v]:       # relax
                dist[v] = d + w
                parent[v] = u
                heapq.heappush(heap, (dist[v], v))
    return dist, parent

def path_to(parent, target):
    path = []
    while target is not None:
        path.append(target)
        target = parent[target]
    return path[::-1]

dist, parent = dijkstra(roads, "A")
print(dist)
print(path_to(parent, "D"), dist["D"])
Output
{'A': 0, 'B': 7, 'C': 3, 'D': 9, 'E': 5}
['A', 'C', 'B', 'D'] 9

Python's heapq has no "decrease key" operation, so instead of updating an entry we push a new one and skip stale entries when popped (the d > dist[u] check). This "lazy deletion" is the standard Python approach.

Time O((V + E) log V): each edge can push one heap entry, and each push or pop costs O(log V). Space O(V + E). This is fast enough for graphs with millions of edges, and it is the core of real routing engines (which add tricks like A* heuristics and precomputed shortcuts).

You can see it with the textbook version of Dijkstra, which marks each node as finished the moment it is popped and never touches it again:

import heapq

def dijkstra_textbook(graph, source):
    dist = {node: float("inf") for node in graph}
    dist[source] = 0
    done = set()
    heap = [(0, source)]
    while heap:
        d, u = heapq.heappop(heap)
        if u in done:
            continue
        done.add(u)                        # u is now final
        for v, w in graph[u]:
            if v not in done and d + w < dist[v]:
                dist[v] = d + w
                heapq.heappush(heap, (dist[v], v))
    return dist

tricky = {"S": [("A", 2), ("B", 5)], "A": [("C", 1)], "B": [("A", -4)], "C": []}
print(dijkstra_textbook(tricky, "S"))
print("true answer: A = 1, C = 2 (S -> B -> A -> C = 5 - 4 + 1)")
Output
{'S': 0, 'A': 2, 'B': 5, 'C': 3}
true answer: A = 1, C = 2 (S -> B -> A -> C = 5 - 4 + 1)

Dijkstra finalised A at distance 2 and C at 3 before it discovered the cheaper route through B. (The lazy version above sometimes recovers by re-pushing nodes, but that can take exponential time and is not something to rely on.)

Bellman-Ford

Bellman-Ford is beautifully simple: relax every edge, and repeat that V - 1 times. Why V - 1? A shortest path without cycles has at most V - 1 edges, and after round k, every path with k edges has been fully relaxed. It does not care about the order, so negative weights are fine.

# from earlier in this lesson
# delivery times in minutes between stackcone warehouses
roads = {
    "A": [("B", 10), ("C", 3)],
    "B": [("D", 2)],
    "C": [("B", 4), ("D", 8), ("E", 2)],
    "D": [("E", 7)],
    "E": [],
}
# BFS would pick A -> B -> D (2 edges, 12 minutes)
# but A -> C -> B -> D is 3 edges and only 9 minutes

tricky = {"S": [("A", 2), ("B", 5)], "A": [("C", 1)], "B": [("A", -4)], "C": []}

# new code
def bellman_ford(graph, source):
    edges = [(u, v, w) for u in graph for v, w in graph[u]]
    dist = {node: float("inf") for node in graph}
    dist[source] = 0
    for _ in range(len(graph) - 1):
        changed = False
        for u, v, w in edges:
            if dist[u] + w < dist[v]:
                dist[v] = dist[u] + w
                changed = True
        if not changed:
            break                     # early exit: nothing improved
    # one more pass: any improvement means a negative cycle
    for u, v, w in edges:
        if dist[u] + w < dist[v]:
            raise ValueError("negative cycle reachable from source")
    return dist

print(bellman_ford(tricky, "S"))
print(bellman_ford(roads, "A"))
Output
{'S': 0, 'A': 1, 'B': 5, 'C': 2}
{'A': 0, 'B': 7, 'C': 3, 'D': 9, 'E': 5}

Time O(V * E), space O(V + E). Much slower than Dijkstra on big graphs, which is why you only reach for it when weights can be negative.

Detecting negative cycles

If a cycle has a negative total, you can loop around it forever and the "shortest" path is minus infinity. Bellman-Ford detects this with one extra pass: if anything still improves after V - 1 rounds, there must be a negative cycle. A real use is currency arbitrage: take edge weights as -log(rate), and a negative cycle is a sequence of trades that ends with more money than it started.

# from earlier in this lesson
def bellman_ford(graph, source):
    edges = [(u, v, w) for u in graph for v, w in graph[u]]
    dist = {node: float("inf") for node in graph}
    dist[source] = 0
    for _ in range(len(graph) - 1):
        changed = False
        for u, v, w in edges:
            if dist[u] + w < dist[v]:
                dist[v] = dist[u] + w
                changed = True
        if not changed:
            break                     # early exit: nothing improved
    # one more pass: any improvement means a negative cycle
    for u, v, w in edges:
        if dist[u] + w < dist[v]:
            raise ValueError("negative cycle reachable from source")
    return dist

# new code
loop = {"X": [("Y", 1)], "Y": [("Z", -3)], "Z": [("X", 1)]}
try:
    bellman_ford(loop, "X")
except ValueError as e:
    print("error:", e)
Output
error: negative cycle reachable from source

Choosing an algorithm

  • All edges equal weight: BFS, O(V + E).
  • Non-negative weights, one source: Dijkstra, O((V + E) log V).
  • Negative weights or you need negative-cycle detection: Bellman-Ford, O(V * E).
  • Edges of weight 0 or 1 only: 0-1 BFS with a deque, O(V + E).
  • All pairs on a small graph (a few hundred nodes): Floyd-Warshall, O(V3).

Recap

  • Weighted shortest paths are built on relaxation: improve dist[v] if going through u is cheaper.
  • Dijkstra always expands the closest unfinished node using a heap, and requires non-negative weights.
  • In Python, push duplicates onto the heap and skip stale entries instead of decreasing keys.
  • Bellman-Ford relaxes all edges V - 1 times and handles negative weights.
  • An extra Bellman-Ford pass that still improves something proves a negative cycle.
# Write your solution here

Finished reading? Mark this lesson complete to track your progress.

Up next · Lesson 13Topological SortLearn topological sort in Python with Kahn's algorithm and DFS to order tasks, build systems and course prerequisites in a DAG and detect cycles.