Shortest Paths: Dijkstra, Bellman-Ford, A* and More
After this lesson, you will be able to:
- Say what a shortest path is in a weighted graph, and why fewest edges is a different question
- Run Dijkstra's algorithm by hand with a heap, write it with lazy deletion, and rebuild the route from a prev table
- Show with a three-vertex example why Dijkstra needs non-negative weights
- Order a dependency graph with Kahn's algorithm and with DFS, and find shortest and longest paths in a DAG
- Use Bellman-Ford for negative edges and to detect a negative cycle, and Floyd-Warshall for every pair at once
- Solve grid problems with 0-1 BFS, multi-source BFS and A*, and choose between all of these with a decision table
Before You Start
#The Shortest Path Question
Shortest does not mean fewest edges. A road with six short hops can beat a road with one long hop. All the examples in this lesson use one graph, written as a list of its edges. Each edge works both ways.
A-B 4 A-C 1 B-C 2 B-D 5
C-D 8 C-E 10 D-E 2
The question comes in a few shapes: one source to every vertex, one source to one target, or every vertex to every other. The edges may all be equal, some may be zero, or some may be negative. Each shape has its own best tool. That is why this lesson has a table at the end.
#Recap: BFS Counts Edges, Not Weights
Breadth-first search (BFS) visits the source, then every vertex one edge away, then two edges away, and so on. It uses a queue, a line where the first one in is the first one out. The first time BFS reaches a vertex, no route with fewer edges exists. So when every edge counts the same, BFS is already a shortest-path algorithm.
from collections import deque
edges = [("A", "B", 4), ("A", "C", 1), ("C", "B", 2), ("B", "D", 5),
("C", "D", 8), ("C", "E", 10), ("D", "E", 2)]
graph = {} # vertex -> list of (neighbor, weight)
for u, v, w in edges:
graph.setdefault(u, []).append((v, w))
graph.setdefault(v, []).append((u, w)) # undirected: store the edge both ways
def bfs_hops(graph, source):
"""Fewest edges from source to every reachable vertex. O(V + E) time, O(V) space."""
hops = {source: 0}
queue = deque([source])
while queue:
u = queue.popleft()
for v, _ in graph[u]:
if v not in hops:
hops[v] = hops[u] + 1
queue.append(v)
return hops
print(bfs_hops(graph, "A"))
# Output:
# {'A': 0, 'B': 1, 'C': 1, 'D': 2, 'E': 2}BFS says E is 2 edges from A, by the route A, C, E. That route costs 1 + 10 = 11. The cheapest route to E costs 10, and it uses four edges. BFS cannot see weights, so we need a new idea. The cost of BFS itself is O(V + E) time and O(V) space, because each vertex enters the queue once and each edge is looked at a constant number of times.
#Dijkstra: Always Settle the Closest Vertex
Here is the whole run by hand, from A. The heap shows its pairs, smallest first.
| Step | Pop | Table after (A, B, C, D, E) | Heap after |
|---|---|---|---|
| start | none | 0, inf, inf, inf, inf | (0, A) |
| 1 | (0, A) | 0, 4, 1, inf, inf | (1, C), (4, B) |
| 2 | (1, C) | 0, 3, 1, 9, 11 | (3, B), (4, B), (9, D), (11, E) |
| 3 | (3, B) | 0, 3, 1, 8, 11 | (4, B), (8, D), (9, D), (11, E) |
| 4 | (4, B) is stale, skip | unchanged | (8, D), (9, D), (11, E) |
| 5 | (8, D) | 0, 3, 1, 8, 10 | (9, D), (10, E), (11, E) |
| 6 | (9, D) is stale, skip | unchanged | (10, E), (11, E) |
| 7 | (10, E) | unchanged | (11, E) |
| 8 | (11, E) is stale, skip | unchanged | empty |
heapq cannot lower or delete a pair in the middle without scanning the list, so we leave old pairs in place and skip them when they surface. A pair is stale when its distance is bigger than the table says. Skipping stale pairs is called lazy deletion.The viewer below runs Dijkstra on this same graph. It picks the smallest distance by scanning a list, while our code asks a heap, but the choices are identical.
Step through the run and watch the distance table. Each vertex is settled in order of distance, and a settled distance never changes again.
prev table remembers which vertex each best distance came from, so the route can be rebuilt by walking backward from the target.import heapq
def dijkstra(graph, source):
"""Cheapest cost from source to every reachable vertex. Weights must not be negative.
Time O((V + E) log V). Extra space O(V + E): the heap can hold one pair per edge.
"""
dist = {source: 0}
prev = {source: None}
heap = [(0, source)]
while heap:
d, u = heapq.heappop(heap)
if d > dist[u]: # stale pair: a better one was pushed later
continue
for v, w in graph[u]:
new_d = d + w
if v not in dist or new_d < dist[v]:
dist[v] = new_d # relax the edge
prev[v] = u
heapq.heappush(heap, (new_d, v))
return dist, prev
def build_path(prev, target):
"""Walk back through prev, then reverse. Returns None when target is unreachable."""
if target not in prev:
return None
path = []
while target is not None:
path.append(target)
target = prev[target]
return path[::-1]
dist, prev = dijkstra(graph, "A")
print(dist)
print(build_path(prev, "E"), dist["E"])
print(build_path(prev, "Z"))
# Output:
# {'A': 0, 'B': 3, 'C': 1, 'D': 8, 'E': 10}
# ['A', 'C', 'B', 'D', 'E'] 10
# NoneThe cost has a clear reason. Each successful relaxation pushes one pair, so there are at most E + 1 pairs, and each push or pop costs O(log V). A vertex scans its edges only when its one non-stale pair is popped, so edge scanning adds up to E. The total is O((V + E) log V) time. The distance table holds V entries and the heap up to E, so the extra space is O(V + E).
#Why Dijkstra Needs Non-Negative Weights
The proof above used one fact: adding an edge never makes a route cheaper. A negative weight breaks that fact. Here is the smallest graph that shows it, with arrows now one-way. S goes to A for 2, S goes to B for 3, and B goes to A for -2. The cheapest route to A is S, B, A, which costs 1.
A directed graph has the edges S to A (weight 2), S to B (weight 3) and B to A (weight -2). A textbook Dijkstra settles a vertex the moment it is popped, and never changes it afterward. What distance does it report for A?
negative = {"S": [("A", 2), ("B", 3)], "A": [], "B": [("A", -2)]}
def dijkstra_settled(graph, source):
"""The textbook version: a vertex is final the moment it is popped."""
dist = {source: 0}
done = set()
heap = [(0, source)]
while heap:
d, u = heapq.heappop(heap)
if u in done:
continue
done.add(u)
for v, w in graph[u]:
if v not in done and (v not in dist or d + w < dist[v]):
dist[v] = d + w
heapq.heappush(heap, (d + w, v))
return dist
print(dijkstra_settled(negative, "S"))
print("route S, B, A costs", 3 + (-2))
print(dijkstra(negative, "S")[0])
# Output:
# {'S': 0, 'A': 2, 'B': 3}
# route S, B, A costs 1
# {'S': 0, 'A': 1, 'B': 3}The lazy-deletion code from the last section happens to heal itself here. It relaxes by comparing distances, so when the cheaper pair (1, A) arrives, A is scanned again. Do not lean on this. With negative edges that version can scan the same vertex many times, and its proof is gone.
Note that an undirected graph with one negative edge already has a negative cycle: go along the edge and back. For graphs with negative edges, use Bellman-Ford, later in this lesson.
#Topological Sort: Kahn and DFS
config -> compile, docs compile -> test, package
docs -> package test -> package
def topo_kahn(graph):
"""Kahn's algorithm. Returns an order, or None if there is a cycle. O(V + E) time, O(V) space."""
indegree = {v: 0 for v in graph}
for u in graph:
for v in graph[u]:
indegree[v] += 1
queue = deque(v for v in graph if indegree[v] == 0)
order = []
while queue:
u = queue.popleft()
order.append(u)
for v in graph[u]:
indegree[v] -= 1
if indegree[v] == 0:
queue.append(v)
return order if len(order) == len(graph) else None # cycle vertices never reach 0
def topo_dfs(graph):
"""DFS method. Returns an order, or None if there is a cycle. O(V + E) time, O(V) space."""
NEW, ACTIVE, DONE = 0, 1, 2
state = {v: NEW for v in graph}
finished = []
def visit(u):
state[u] = ACTIVE
for v in graph[u]:
if state[v] == ACTIVE: # an arrow back into the current walk
return False
if state[v] == NEW and not visit(v):
return False
state[u] = DONE
finished.append(u) # everything u points to is already in the list
return True
for u in graph:
if state[u] == NEW and not visit(u):
return None
return finished[::-1]
tasks = {"config": ["compile", "docs"], "compile": ["test", "package"],
"docs": ["package"], "test": ["package"], "package": []}
loop = {"a": ["b"], "b": ["c"], "c": ["a"], "d": []}
print(topo_kahn(tasks))
print(topo_dfs(tasks))
print(topo_kahn(loop), topo_dfs(loop))
# Output:
# ['config', 'compile', 'docs', 'test', 'package']
# ['config', 'docs', 'compile', 'test', 'package']
# None NoneBoth methods cost O(V + E) time and O(V) extra space: each vertex and each arrow is handled once. The two orders differ and both are correct, because a DAG usually has many valid orders. Kahn reports a cycle when the line comes out shorter than the vertex count. The DFS version recurses as deep as the longest chain, so on a chain of thousands of vertices prefer Kahn.
Kahn's algorithm gives ['config', 'compile', 'docs', 'test', 'package'] and the DFS method gives ['config', 'docs', 'compile', 'test', 'package']. What does that tell you?
#Shortest and Longest Paths in a DAG
A DAG gives a free speedup. Process the vertices in topological order. When you reach a vertex, every route into it has already been relaxed, so its distance is final. Relax its outgoing edges once and move on. There is no heap and no stale pair.
INF = float("inf")
dag = {"S": [("A", 3), ("B", 2)], "A": [("C", 4), ("D", 6)],
"B": [("D", 1), ("C", 5)], "C": [("T", 2)], "D": [("T", 1)], "T": []}
def dag_shortest(dag, source):
"""Shortest cost from source in a DAG, negative weights allowed. O(V + E)."""
order = topo_kahn({u: [v for v, _ in edges] for u, edges in dag.items()})
dist = {u: INF for u in dag}
dist[source] = 0
for u in order:
if dist[u] == INF:
continue # not reachable from the source
for v, w in dag[u]:
dist[v] = min(dist[v], dist[u] + w)
return dist
negated = {u: [(v, -w) for v, w in edges] for u, edges in dag.items()}
shortest = dag_shortest(dag, "S")
longest = {u: -d for u, d in dag_shortest(negated, "S").items()}
print("shortest to T:", shortest["T"])
print("longest to T:", longest["T"])
print(dag_shortest({"S": [("A", 4), ("B", 5)], "B": [("A", -3)], "A": []}, "S"))
# Output:
# shortest to T: 4
# longest to T: 10
# {'S': 0, 'B': 5, 'A': 2}The shortest route to T is S, B, D, T (2 + 1 + 1 = 4) and the longest is S, A, D, T (3 + 6 + 1 = 10). The last line has a negative edge and still comes out right: A is 2, through B.
#Bellman-Ford: Negative Edges and Negative Cycles
Bellman-Ford uses brute force with a clever stopping rule. Relax every edge, then do it again, and keep going. After round r, every distance is correct for routes of at most r edges. A route with no repeated vertex has at most V - 1 edges, so V - 1 rounds are enough when there is no negative cycle.
A graph has 5 vertices and no negative cycle. Bellman-Ford relaxes every edge in each round. What is the most rounds it can need before all distances are final?
That last sentence is also the cycle test. If one more round, round V, can still improve a distance, then some route keeps getting cheaper by going around a loop, and a negative cycle is reachable from the source.
def bellman_ford(vertices, edges, source):
"""Shortest costs with negative edges allowed. Returns None on a reachable negative cycle.
Time O(V * E): up to V - 1 rounds over E edges. Extra space O(V).
"""
dist = {v: INF for v in vertices}
dist[source] = 0
for _ in range(len(vertices) - 1):
changed = False
for u, v, w in edges: # edges are directed: u to v
if dist[u] != INF and dist[u] + w < dist[v]:
dist[v] = dist[u] + w
changed = True
if not changed: # nothing improved: finished early
break
for u, v, w in edges: # one more round: any improvement is a cycle
if dist[u] != INF and dist[u] + w < dist[v]:
return None
return dist
vertices = ["S", "A", "B", "C"]
roads = [("S", "A", 4), ("S", "B", 5), ("B", "A", -3), ("A", "C", 2), ("B", "C", 4)]
print(bellman_ford(vertices, roads, "S"))
print(bellman_ford(vertices, roads + [("C", "B", -2)], "S"))
# Output:
# {'S': 0, 'A': 2, 'B': 5, 'C': 4}
# NoneThe route S, B, A takes 5 - 3 = 2, which beats the direct edge of 4. The second call adds C to B at -2. Now B, A, C, B is a loop costing -3 + 2 - 2 = -3, so the function returns None.
The cost is O(V·E) time, much slower than Dijkstra, and O(V) extra space. The edge order does not change the answer, but it changes how soon the early exit happens. The same five edges listed in reverse need three rounds that change something instead of one.
#Floyd-Warshall: All Pairs
dist[i][j] holds the cheapest route from i to j that uses only those k vertices as stops. To add the next vertex k, a route from i to j either avoids k or goes i to k and then k to j.def floyd_warshall(vertices, edges):
"""Cheapest cost between every pair. O(V³) time, O(V²) space."""
dist = {u: {v: (0 if u == v else INF) for v in vertices} for u in vertices}
for u, v, w in edges:
dist[u][v] = min(dist[u][v], w)
for k in vertices: # k MUST be the outermost loop
for i in vertices:
for j in vertices:
if dist[i][k] + dist[k][j] < dist[i][j]:
dist[i][j] = dist[i][k] + dist[k][j]
return dist
towns = ["A", "B", "C", "D"]
links = [("A", "B", 2), ("B", "C", 4), ("B", "D", 1), ("C", "D", 5), ("D", "C", 1)]
table = floyd_warshall(towns, links)
for u in towns:
print(u, [table[u][v] for v in towns])
# Output:
# A [0, 2, 4, 3]
# B [inf, 0, 2, 1]
# C [inf, inf, 0, 5]
# D [inf, inf, 1, 0]inf means no route exists: nothing leads back to A. Three nested loops over V vertices give O(V³) time, and the V by V table is O(V²) space. That is fine for a few hundred vertices. If a vertex ends with dist[i][i] < 0, it sits on a negative cycle.When the graph is large and sparse, run Dijkstra from every vertex instead: V runs of O((V + E) log V) each beat V³ when E is small.
#0-1 BFS and Multi-Source BFS
Two cheap variants of BFS cover many grid problems. A grid is a graph: each cell is a vertex, and each cell touches its four neighbors, so E is at most 4V.
Time is O(V + E) and space is O(V). A cell can be added at most twice: once with a distance one too high, and once more with the better distance.
walls = ["..#..",
"..#..",
"###..",
".....",
"....."]
def fewest_walls(grid):
"""0-1 BFS from the top left to the bottom right; stepping onto # costs 1 (break it)."""
rows, cols = len(grid), len(grid[0])
dist = [[INF] * cols for _ in range(rows)]
dist[0][0] = 0
dq = deque([(0, 0)])
while dq:
r, c = dq.popleft()
for nr, nc in ((r + 1, c), (r - 1, c), (r, c + 1), (r, c - 1)):
if 0 <= nr < rows and 0 <= nc < cols:
cost = 1 if grid[nr][nc] == "#" else 0
if dist[r][c] + cost < dist[nr][nc]:
dist[nr][nc] = dist[r][c] + cost
if cost == 0:
dq.appendleft((nr, nc)) # free move: front of the line
else:
dq.append((nr, nc)) # costly move: back of the line
return dist[rows - 1][cols - 1]
city = ["S..#.",
"..#..",
"#...S",
"..#..",
"S...."]
def nearest_station(grid):
"""Multi-source BFS: steps from every open cell to the nearest S. -1 marks a wall."""
rows, cols = len(grid), len(grid[0])
dist = [[-1] * cols for _ in range(rows)]
queue = deque()
for r in range(rows):
for c in range(cols):
if grid[r][c] == "S":
dist[r][c] = 0
queue.append((r, c)) # every source starts at distance 0
while queue:
r, c = queue.popleft()
for nr, nc in ((r + 1, c), (r - 1, c), (r, c + 1), (r, c - 1)):
if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] != "#" and dist[nr][nc] == -1:
dist[nr][nc] = dist[r][c] + 1
queue.append((nr, nc))
return dist
print(fewest_walls(walls))
for row in nearest_station(city):
print(" ".join("#" if x == -1 else str(x) for x in row))
# Output:
# 1
# 0 1 2 # 2
# 1 2 # 2 1
# # 3 2 1 0
# 1 2 # 2 1
# 0 1 2 3 2The top-left room is sealed, so one wall must be broken, and the answer is 1. In the second grid every cell shows how far it is from its closest station.
#A* Search on a Grid
g + h, where g is the cost so far and h is a heuristic: a cheap guess of the cost still to go. Dijkstra is the special case h = 0.On a 10 by 10 grid with a wall between start and goal, Dijkstra finds the shortest route and expands 76 cells. A* with the Manhattan distance runs on the same grid. Which result do you expect?
grid = ["..........",
"..........",
"....####..",
"....#.....",
"....#.....",
"....#.....",
"..........",
"..........",
"..........",
".........."]
def search(grid, start, goal, scale):
"""Dijkstra when scale is 0, A* with Manhattan distance when scale is 1.
Returns (cost, cells expanded). Worst case O((V + E) log V) like Dijkstra, space O(V).
"""
rows, cols = len(grid), len(grid[0])
def guess(cell):
return scale * (abs(cell[0] - goal[0]) + abs(cell[1] - goal[1]))
best = {start: 0}
heap = [(guess(start), 0, start)] # (g + h, g, cell)
expanded = 0
while heap:
_, g, cell = heapq.heappop(heap)
if g > best[cell]:
continue # stale pair
expanded += 1
if cell == goal:
return g, expanded
r, c = cell
for nr, nc in ((r + 1, c), (r - 1, c), (r, c + 1), (r, c - 1)):
if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] != "#":
if g + 1 < best.get((nr, nc), INF):
best[(nr, nc)] = g + 1
heapq.heappush(heap, (g + 1 + guess((nr, nc)), g + 1, (nr, nc)))
return None, expanded
open_cells = sum(row.count(".") for row in grid)
print("open cells:", open_cells)
print("Dijkstra:", search(grid, (3, 1), (3, 8), 0))
print("A*: ", search(grid, (3, 1), (3, 8), 1))
# Output:
# open cells: 93
# Dijkstra: (11, 76)
# A*: (11, 25)Both find the cost 11. Dijkstra expands 76 of the 93 open cells, and A* only 25. A* has the same worst case as Dijkstra, O((V + E) log V) time and O(V) space, and a bad guess can cost it the whole saving. A good guess is the whole point.
#Which Algorithm? A Decision Table
Ask three questions in order: are all the weights equal, can any be negative, and do you need one source or every pair?
| Situation | Use | Time | Why |
|---|---|---|---|
| Every edge costs the same | BFS | O(V + E) | fewest edges is the cheapest route |
| Edges cost 0 or 1 | 0-1 BFS | O(V + E) | a deque stands in for the heap |
| Many starting points, one question | Multi-source BFS | O(V + E) | start the queue with all sources |
| Weights not negative, one source | Dijkstra | O((V + E) log V) | the heap settles the closest vertex |
| Not negative, and a good guess to one goal | A* | at most Dijkstra's | the guess skips unhelpful areas |
| No cycles (a DAG), any weights | Topological order, relax once | O(V + E) | no heap, negatives are fine |
| Negative edges, one source | Bellman-Ford | O(V·E) | repeated rounds, and it spots negative cycles |
| Every pair, small graph | Floyd-Warshall | O(V³) | three short loops |
| Every pair, large sparse graph | Dijkstra from each vertex | V times O((V + E) log V) | cheaper than V³ when E is small |
#Where This Shows Up
- A route planner models roads as edges with travel times and runs Dijkstra or A* with a map-distance guess. Real services add heavy precomputation on top, but the core question is the one in this lesson.
- Internet routers share link costs and compute shortest routes. OSPF, a protocol used inside networks, runs Dijkstra, and the older RIP protocol is built on the Bellman-Ford idea.
- Build tools, package managers and CI pipelines use topological sort to decide what to run first, and they report a circular dependency when Kahn's line comes out short.
- A project plan is a DAG of tasks. The longest path is the critical path, so a delay on it delays the whole project.
- A negative cycle in a table of currency exchange rates (using the negative logarithm of each rate as the weight) is a profit loop. Bellman-Ford finds it.
- In speech and language models, Viterbi decoding finds the best sequence of hidden states. It is a shortest path through a DAG of states, solved with the same relax-in-order idea.
#Common Mistakes
#Try It: Five Problems
tests line.Tests · Expected output: line 1 is 2 8 -1 (fewest hops, cheapest cost, unreachable). Line 2 is ['config', 'compile', 'docs', 'package']. Line 3 is None. Line 4 is 4 (with the bug it prints 6). Line 5 is [-1, 600, 300].
#Recap
A shortest path is the route with the smallest total weight, which can differ from the route with the fewest edges. BFS handles equal weights in O(V + E). Dijkstra handles non-negative weights with a heap: pop the closest vertex, settle it, relax its edges, and skip stale pairs. The run costs O((V + E) log V).
A negative edge breaks the settle step, and a negative cycle means no cheapest path exists.
Topological sort orders a DAG in O(V + E), by Kahn's in-degree queue or by DFS, and it lets you find shortest or longest paths in a DAG without a heap.
Bellman-Ford relaxes every edge for V - 1 rounds in O(V·E) time and detects negative cycles. Floyd-Warshall fills an all-pairs table in O(V³) time with k as the outermost loop. 0-1 BFS and multi-source BFS are BFS with a deque or several starts. A* adds an admissible guess to Dijkstra to expand fewer cells.
A graph has edges of weight 1, 2 and 5, and none are negative. You need the cheapest route from one source to every other vertex. Which algorithm fits best?
Key Takeaways
- Shortest means smallest total weight, which is a different question from fewest edges
- Dijkstra settles the closest vertex with a heap and skips stale pairs, in O((V + E) log V) time, and it needs non-negative weights
- Topological sort exists only for a DAG, costs O(V + E), and a short Kahn result means a cycle
- Bellman-Ford costs O(V·E), allows negative edges, and one extra round that still improves means a negative cycle
- Floyd-Warshall fills every pair in O(V³) time and O(V²) space, and k must be the outermost loop
- Use BFS for equal weights, 0-1 BFS for 0 or 1, and A* only with a guess that never overestimates