bigonotes
DSA: Patterns & PythonGraphs

Dijkstra's Algorithm (and A*)

Find the cheapest path in a graph with non-negative weights using a min-heap, skip stale entries, and know when A* is worth it.

8 min readLast updated

Dijkstra's algorithm finds the cheapest path when edges have different, non-negative weights. It works like BFS with one change: BFS expands by number of steps, while Dijkstra always expands the path that has cost the least so far. Picture pouring water on a map. It runs quickly along cheap roads and slowly along expensive ones, and Dijkstra computes the moment the water reaches each place.

The idea#

Keep a min-heap of (cost so far, node). Pop the cheapest entry. Because no weight is negative, no path still waiting in the heap can get cheaper by going further, so the first time a node is popped its cost is final. Then push each neighbor with cost + weight.

That leaves one question: what happens when the same node lands in the heap twice? The version here, often called lazy Dijkstra, doesn't update entries inside the heap. It pushes duplicates and ignores the stale ones later:

  1. The heap holds both (10, A) and (5, A).
  2. (5, A) pops first, and A is finalized at 5.
  3. When (10, A) pops later, A is already final, so you throw the entry away.

All of this rests on "first pop is final", and negative weights break that rule. For graphs with negative edges, use Bellman-Ford instead.

How to spot it#

These cues point at Dijkstra:

  • "Shortest path" / "minimum cost" / "fastest route" with weighted edges.
  • "How long until every node receives the signal?": a question about the shortest distance to all nodes at once.
  • "Minimum effort" or "maximum probability" along a path.
  • A cost limit on top of the path, such as "at most K stops".

BFS or Dijkstra?#

Some of these questions can look like BFS questions at first. The deciding question is whether every step costs the same.

BFS Dijkstra
Edge costs All equal (usually 1) Vary, but never negative
Minimizes Number of edges Sum of weights, or the worst weight on the path
Container deque (FIFO) heapq (min-heap)
Typical cue "fewest moves", unit grid steps "minimum cost / time / effort", weighted roads

The "worst weight on the path" row is easy to miss: Dijkstra also solves problems where a path's cost is its maximum edge (Swim in Rising Water) rather than its sum. Those get their own note: Bottleneck / Minimax Paths.

Template#

The template below is the lazy version from above. Its shape depends on how many answers you need:

  • Need the answer for one target? Return as soon as you pop the target.
  • Need the answer for all nodes? Run until the heap is empty and keep a dist map.
dist = empty map                       # finalized costs only
heap = [(0, src)]
while heap is not empty:
    cost, node = pop the smallest from heap
    if node in dist: skip              # stale: a cheaper copy won
    dist[node] = cost
    if node is the target: return cost # first pop is final
    for each (nei, w) in neighbors of node:
        if nei not in dist:
            push (cost + w, nei) onto heap
return -1 if there was a target, else dist

Return when the target is popped, not when it is pushed. A push only means "here is one way to get there"; a cheaper way may still be sitting in the heap.

Complexity#

The heap is where the cost comes from. Every edge can cause one push, so the heap holds up to EE entries and each push or pop costs O(log⁡E)O(\log E). The total is O(Elog⁡E)O(E \log E). Since E≤V2E \le V^2, log⁡E≤2log⁡V\log E \le 2 \log V, so this is the same as O(Elog⁡V)O(E \log V). Space is O(V+E)O(V + E) for the graph, the heap and dist.

A* search (advanced)#

Dijkstra spreads out evenly in every direction, like an expanding circle. When you only need the path to one known target and the search space is large, much of that work is wasted. A* is Dijkstra with a sense of direction: it points a flashlight beam at the goal. Each node gets a priority:

f(n)=g(n)+h(n)f(n) = g(n) + h(n)

Term Meaning
g(n)g(n) The real cost from the start to nn (the past), exactly as in Dijkstra
h(n)h(n) An estimate of the remaining cost from nn to the goal (the future)
f(n)f(n) The best guess for the total cost of a path through nn

Dijkstra asks "which node has been reached most cheaply so far?" A* asks "which node gives the best estimate of the final total cost?". With h(n)=0h(n) = 0, A* is exactly Dijkstra.

Admissibility#

Everything A* gains comes from hh, so a bad estimate can cost you the right answer. A* returns the shortest path only if hh is admissible: it must never overestimate the true remaining cost. An optimistic guess only makes A* look at a few extra nodes. A pessimistic one can push the real best path down the heap, so A* reaches the goal along a worse path first and returns it.

On an 8-direction grid where a diagonal step costs 1 (as in LC 1091), each move changes each coordinate by at most 1. So you need at least max⁡(∣dr∣,∣dc∣)\max(|dr|, |dc|) moves, which is the Chebyshev distance, and it never overestimates. Euclidean distance does overestimate here: from (0, 0) to (3, 3) the true cost is 3 moves, but the straight-line distance is 32≈4.243\sqrt{2} \approx 4.24. Use Euclidean (or octile) distance only when a diagonal step truly costs 2\sqrt{2}.

Which heuristic?#

So how do you pick hh? On LeetCode you don't invent one. Two standard patterns cover almost everything, and the moves a problem allows tell you which to use.

Problem type Moves allowed h(n)h(n)
Grid map Up, down, left, right Manhattan: ∣dr∣+∣dc∣\lvert dr \rvert + \lvert dc \rvert
Grid map 8 directions, diagonal costs 1 Chebyshev: max⁡(∣dr∣,∣dc∣)\max(\lvert dr \rvert, \lvert dc \rvert)
Grid map 8 directions, diagonal costs 2\sqrt{2} Euclidean or octile distance
Sliding puzzle Swap a tile with the blank Sum of the Manhattan distances of every tile (not only the blank)
General graph, no coordinates Any h=0h = 0, which is plain Dijkstra

If the nodes have no coordinates or other structure, there is usually no useful heuristic, so you don't use A*.

A* template#

This is the same loop as Dijkstra, keyed on f=g+hf = g + h instead of cost alone. It keeps the best known gg for each node and uses it to skip stale heap entries.

best_g = {start: start cost}
heap = [(start cost + h(start), start cost, start)]
while heap is not empty:
    f, g, node = pop the smallest from heap
    if node is the goal: return g
    if g > best_g[node]: skip          # stale entry
    for each next reachable from node:
        new_g = g + step cost
        if new_g < best_g.get(next, infinity):
            best_g[next] = new_g
            push (new_g + h(next), new_g, next) onto heap
return -1

With BFS, Dijkstra and A* all covered, here's how to choose between them for any shortest-path question:

  1. Unweighted (every step costs 1)? Use BFS; A* is overkill.
  2. Weighted graph or grid? Use Dijkstra.
  3. Huge search space and a known target position or state? Consider A*.

Pitfalls#

These are the mistakes that most often cost a correct answer.

  • Marking a node visited when you push it. A cheaper path found later is then ignored.
  • Returning when the target is pushed instead of popped.
  • Leaving out the stale-entry skip: a stale, larger cost can overwrite the final one, and nodes get expanded again and again.
  • Using Dijkstra with negative weights.
  • "All nodes" questions: forgetting the -1 when some node is never reached.
  • A*: a heuristic that overestimates, so A* returns a worse path.

Problems in this pattern#

These problems use this pattern. Solve them before you open the hints below. Start with Network Delay Time.

Hints#

Open a hint only after you have tried the problem for a while.

Hint: Network Delay Time

The signal reaches every node when the slowest one gets it, so the answer is the largest of the shortest distances. Run Dijkstra without a target. Return max(dist.values()) if len(dist) == n, else -1.

Hint: Path with Maximum Probability

The path score is a product, not a sum, and you want the largest one. Negate the values to use a min-heap. See Bottleneck / Minimax Paths.

Hint: Path With Minimum Effort

A path's cost here is its worst step, not the sum of its steps. Dijkstra still works with max as the combine step. See Bottleneck / Minimax Paths.

Hint: Shortest Path in Binary Matrix

Every step costs 1, so BFS is enough. To try A*, move in 8 directions with a diagonal costing 1 and use the Chebyshev distance as hh. Euclidean distance overestimates here. The path length counts cells, so the start has g=1g = 1, and a blocked start or goal means -1.

Hint: Sliding Puzzle

Each board is a state, and each move swaps a tile with the blank. BFS over states works. For A*, use the sum of the Manhattan distances of every tile, not only the blank.

Hint: Cut Off Trees for Golf Event

The trees must be cut from shortest to tallest, so sort them by height. The answer is the sum of the shortest walks between each pair of consecutive trees, found with BFS or A* with the Manhattan distance. If any walk is impossible, return -1.

Hint: Cheapest Flights Within K Stops (variation)

The cheapest path may use too many stops, so cost alone is not enough. Use a modified Dijkstra or a BFS that also tracks the number of stops used.

Key takeaways#

  • Dijkstra = BFS with a min-heap keyed on cost so far; weights must be non-negative.
  • The first pop of a node is final, so skip any later (stale) copies.
  • One target: return when it is popped. All nodes: keep a dist map.
  • O(Elog⁡E)=O(Elog⁡V)O(E \log E) = O(E \log V).
  • A* adds an admissible estimate hh; Chebyshev for unit-cost diagonals, Manhattan for 4 directions, and h=0h = 0 gives back Dijkstra.
Revision notes · 2 min

Core idea#

Dijkstra finds the cheapest path when weights are non-negative. It is BFS with a min-heap keyed on cost so far, so the first pop of a node is final.

Key points#

  • BFS minimizes edge count with a deque. Dijkstra minimizes cost with heapq.
  • Lazy Dijkstra pushes duplicates and skips stale entries when they pop.
  • One target: return when you pop it, not when you push it.
  • All nodes: run until the heap is empty and keep a dist map.
  • For "time until every node is reached" (Network Delay Time), return max(dist.values()), or -1 if a node is unreachable.
  • Negative weights break it. Use Bellman-Ford.
  • Time is O(Elog⁡E)O(E \log E), which equals O(Elog⁡V)O(E \log V). Space is O(V+E)O(V + E).

A* ranks nodes by f(n)=g(n)+h(n)f(n) = g(n) + h(n). gg is the real cost so far and hh estimates the remaining cost. With h=0h = 0, A* is Dijkstra. hh must be admissible: it never overestimates.

Moves h(n)h(n)
4 directions Manhattan: ∣dr∣+∣dc∣\lvert dr \rvert + \lvert dc \rvert
8 directions, diagonal costs 1 Chebyshev: max⁡(∣dr∣,∣dc∣)\max(\lvert dr \rvert, \lvert dc \rvert)
8 directions, diagonal costs 2\sqrt{2} Euclidean or octile
Sliding puzzle Sum of Manhattan distances of every tile
No coordinates h=0h = 0 (plain Dijkstra)

Euclidean overestimates when a diagonal costs 1, so A* can return a worse path.

Template#

dist = empty map; heap = [(0, src)]
while heap is not empty:
    cost, node = pop the smallest from heap
    if node in dist: skip              # stale entry
    dist[node] = cost
    if node is the target: return cost # first pop is final
    for each (nei, w) in neighbors of node:
        if nei not in dist:
            push (cost + w, nei) onto heap
return -1 if there was a target, else dist

Pitfalls#

  • Marking a node visited on push. A cheaper path found later is ignored.
  • Returning when the target is pushed.
  • Leaving out the stale-entry skip.
  • Using Dijkstra with negative weights.

Say it in an interview#

  • "Unit steps mean BFS. Weighted, non-negative edges mean Dijkstra. A big space with a known target means A*."
  • "The first pop is final because no waiting path can get cheaper."

Found a mistake? Email ask@bigonotes.com