Skip to content
Home DSA Dijkstra's Algorithm — Fixing Cyclic Graph Heap Explosion

Dijkstra's Algorithm — Fixing Cyclic Graph Heap Explosion

Where developers are forged. · Structured learning · Free forever.
📍 Part of: Graphs → Topic 4 of 17
Without stale-entry guards, cyclic graphs cause O(2^V) heap growth and OOM crashes.
🔥 Advanced — solid DSA foundation required
In this tutorial, you'll learn
Without stale-entry guards, cyclic graphs cause O(2^V) heap growth and OOM crashes.
  • Dijkstra finds shortest paths from a single source in O((V+E) log V) using a min-heap.
  • It requires all edge weights to be non-negative — use Bellman-Ford for negative weights.
  • Always check for stale heap entries by comparing the popped distance to the current known distance.
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
Quick Answer
  • Dijkstra finds the shortest path from one source to all nodes in a weighted graph with non-negative edges
  • Uses a min-heap (priority queue) to always expand the cheapest unsettled node
  • Greedy guarantee: once a node is popped from the heap, its distance is final
  • Time: O((V+E) log V) with binary heap, space: O(V)
  • Breaks completely on negative edges — never use it there, reach for Bellman-Ford
  • Biggest mistake: forgetting to skip stale heap entries, which wastes cycles but still gives correct results
🚨 START HERE

Quick Debug Cheat Sheet for Dijkstra

Three commands you type when your shortest-path implementation goes wrong.
🟡

Algorithm loops forever

Immediate ActionAdd stale entry check after heap pop.
Commands
System.out.println("Popped node " + u + " with dist " + d + " vs best " + dist[u]);
Limit iteration count: int limit = V * V; while(!heap.isEmpty() && --limit > 0)
Fix NowInsert `if (d > dist[u]) continue;` and add a visited boolean array (set true only on pop).
🟡

dist[E] = INF when path exists

Immediate ActionPrint adjacency list of source and all neighbours.
Commands
for (int v : graph.keySet()) { System.out.println(v + " -> " + graph.get(v)); }
Trace manually: step through the algorithm on paper with debugger.
Fix NowFix missing edges in graph builder. Ensure source is included in graph keys.
🟡

Result contains negative numbers

Immediate ActionScan all edges for negative weights.
Commands
graph.forEach((u, edges) -> edges.forEach(e -> if (e.weight < 0) throw new IllegalStateException(...)));
Use Bellman-Ford to confirm the true shortest path.
Fix NowReject or convert negative weights. If negatives are intentional, replace Dijkstra with Bellman-Ford.
Production Incident

Production Outage: Dijkstra Implementation Deadlocks on Cyclic Weighted Graph

A team's routing service used a non-negative weighted graph but forgot to mark nodes as visited. The algorithm kept re-processing old nodes, leading to infinite loops and CPU exhaustion.
SymptomRouting service becomes unresponsive. CPU hits 100%. Health checks fail. No error logs — just hanging pods that get killed by Kubernetes after exceeding memory limits.
AssumptionThe team assumed the priority queue's extract-min would terminate naturally because distances eventually increase. They never explicitly tracked visited nodes.
Root causeIn a cyclic graph, even with non-negative weights, the algorithm can re-enter a node multiple times if you don't skip stale entries. Without a visited set (or the if (d > dist[u]) guard), the heap grows unboundedly with duplicate (distance, node) entries, causing O(2^V) heap operations in the worst case.
FixAdd the stale entry check: if (currentDist > dist[currentNode]) continue; immediately after popping from the heap. Also mark nodes as visited only when popped (final distance determined).
Key Lesson
Always skip stale heap entries — it's not an optimisation, it's a correctness requirement for termination in cyclic graphs.When implementing Dijkstra in production, write a unit test that uses a cyclic graph with uniform edge weights to verify the loop terminates.Runtime complexity guarantees only hold when you prune duplicates. Without that check, theoretical O(E log V) becomes unbounded.
Production Debug Guide

Real signals that tell you something went wrong in your shortest-path computation.

Program never finishes (hangs) on a typical graphCheck for missing stale entry guard. Insert if (currentDist > distances[currentNode]) continue; after popping. Verify graph has no zero-weight cycles.
Incorrect distances (some nodes get INF when they should have a path)Verify the graph is connected from the source. Print all edges — likely a missing edge in the adjacency list. Check if graph is directed but you treated it as undirected.
OutOfMemoryError (Java) or heap grows to gigabytesCapture heap dump. Likely cause: duplicate heap entries without pruning. Add stale entry check. Alternatively, use TreeSet instead of PriorityQueue to avoid duplicates (but slower).
Negative distances appearing in resultGraph contains negative edge weights. Dijkstra cannot handle them. Switch to Bellman-Ford. If negative edges are impossible by design, add validation to reject negative weights.

Every time Google Maps reroutes you around traffic, every time a network router decides which packet path costs the least, and every time an airline computes the cheapest connecting flights, Dijkstra's algorithm — or something built directly on top of it — is doing the heavy lifting. It was published by Edsger W. Dijkstra in 1959, and it remains one of the most practically deployed algorithms in computer science history. That kind of longevity isn't accidental.

The core problem Dijkstra solves is this: given a weighted graph where edge weights are non-negative, find the minimum-cost path from a single source vertex to every other reachable vertex. A naive approach — trying every possible path — explodes combinatorially. Dijkstra's insight was that you can build the solution greedily: if you always expand the unvisited vertex with the currently known lowest cost, you're guaranteed never to find a cheaper path to it later (provided weights are non-negative). That greedy guarantee is the algorithm's heartbeat.

By the end of this article you'll understand not just how to implement Dijkstra with a priority queue in Java, but why the min-heap is mandatory for the efficient version, what happens internally when you relax an edge, why negative weights break the algorithm at a fundamental level (not just practically), how to handle common production gotchas like disconnected graphs and duplicate heap entries, and exactly how to answer the curveball Dijkstra questions interviewers love to throw.

What is Dijkstra's Algorithm? — Plain English

Dijkstra's algorithm finds the shortest path from one source node to all other nodes in a weighted graph where all edge weights are non-negative. Think of a GPS: your phone knows the travel time on every road and wants the fastest route. It never backtracks to a road it already 'settled' because it always picks the closest unsettled destination next — and since weights are non-negative, no future road could make a settled destination cheaper.

The key insight is greedy correctness: if you always expand the node with the current minimum distance, the first time you reach a node is guaranteed to be the shortest path. This breaks down with negative weights because a future negative edge could provide a cheaper route to an already-settled node.

How Dijkstra's Algorithm Works — Step by Step

Dijkstra uses a min-heap (priority queue) to always process the nearest unsettled node next.

  1. Initialize dist[source] = 0, dist[all others] = infinity.
  2. Push (0, source) into the min-heap.
  3. While the heap is non-empty:
  4. a. Pop the node u with the smallest current distance d.
  5. b. If d > dist[u], skip — this is a stale entry (we already found a shorter path to u).
  6. c. For each neighbour v of u with edge weight w:
  7. - If dist[u] + w < dist[v], update dist[v] = dist[u] + w and push (dist[v], v) to the heap.
  8. After the heap is empty, dist[v] holds the shortest distance from source to every reachable node v.

Time complexity: O((V + E) log V) with a binary min-heap. Space: O(V) for distances and the heap.

📊 Production Insight
Skipping the stale entry check is the single most common production bug in Dijkstra implementations.
Without it, heap size can balloon to O(V^2) on cyclic graphs, exhausting memory.
Rule: if your Dijkstra works but is slow, you're probably processing stale entries.
🎯 Key Takeaway
The stale entry check is not optional — it's part of the algorithm.
Pop, compare, skip — do it every single time.

Worked Example — Tracing the Algorithm

Graph (5 nodes 0-4, directed weighted edges): 0->1 w=2, 0->2 w=4, 1->2 w=1, 1->3 w=7, 2->4 w=3, 3->4 w=1

Start: dist=[0, INF, INF, INF, INF]. Heap: [(0,0)]

Iteration 1: pop (0,0). Process node 0. Edge 0->1 w=2: dist[1] = min(INF, 0+2) = 2. Push (2,1). Edge 0->2 w=4: dist[2] = min(INF, 0+4) = 4. Push (4,2). dist=[0,2,4,INF,INF]. Heap: [(2,1),(4,2)]

Iteration 2: pop (2,1). Process node 1. Edge 1->2 w=1: dist[2] = min(4, 2+1) = 3. Push (3,2). Edge 1->3 w=7: dist[3] = min(INF, 2+7) = 9. Push (9,3). dist=[0,2,3,9,INF]. Heap: [(3,2),(4,2),(9,3)]

Iteration 3: pop (3,2). Process node 2. Edge 2->4 w=3: dist[4] = min(INF, 3+3) = 6. Push (6,4). dist=[0,2,3,9,6]. Heap: [(4,2),(6,4),(9,3)]

Iteration 4: pop (4,2). d=4 > dist[2]=3, stale entry. Skip.

Iteration 5: pop (6,4). Process node 4. No outgoing edges.

Iteration 6: pop (9,3). Process node 3. Edge 3->4 w=1: dist[4] = min(6, 9+1) = 6. No improvement.

Final distances from node 0: [0, 2, 3, 9, 6]

Mental Model
Think of Dijkstra like filling a basin of water
If you pour water over a relief map, water reaches lower points first. Dijkstra does the same — distance is the water level.
  • Source = water source at elevation 0.
  • Edge weights = slope steepness (non-negative).
  • Min-heap = always expands the lowest water level first.
  • Once a point is flooded (settled), water never goes back because it would need negative slope.
  • Stale entry = a water molecule that already reached a higher point — ignore it.
📊 Production Insight
When debugging, trace the heap size growth. If it exceeds V, you're likely not skipping stale entries.
A healthy Dijkstra never has more than V+E entries in the heap at any time.
Rule: if heap size > sum of degrees, add the stale entry check.
🎯 Key Takeaway
Trace the first pop of each node — that distance is final.
Every subsequent pop for the same node is waste.

Java Implementation — Min-Heap, Stale Entry Handling, and Edge Cases

Below is a production-grade Java implementation of Dijkstra using java.util.PriorityQueue with a custom comparator. It includes the mandatory stale entry check, returns both distances and predecessor map for path reconstruction, and handles disconnected graphs gracefully.

Dijkstra.java · JAVA
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364
package io.thecodeforge.graph;

import java.util.*;

public class Dijkstra {
    public static class Result {
        public final Map<Integer, Integer> distance;
        public final Map<Integer, Integer> predecessor;

        public Result(Map<Integer, Integer> distance, Map<Integer, Integer> predecessor) {
            this.distance = distance;
            this.predecessor = predecessor;
        }
    }

    public static Result shortestPath(Map<Integer, List<int[]>> graph, int source) {
        Map<Integer, Integer> dist = new HashMap<>();
        Map<Integer, Integer> pred = new HashMap<>();
        for (int node : graph.keySet()) {
            dist.put(node, Integer.MAX_VALUE);
        }
        dist.put(source, 0);

        PriorityQueue<int[]> heap = new PriorityQueue<>(Comparator.comparingInt(a -> a[1]));
        heap.offer(new int[]{source, 0});

        while (!heap.isEmpty()) {
            int[] current = heap.poll();
            int u = current[0];
            int d = current[1];

            if (d > dist.get(u)) continue; // stale entry

            List<int[]> neighbors = graph.get(u);
            if (neighbors == null) continue;

            for (int[] edge : neighbors) {
                int v = edge[0];
                int w = edge[1];
                int newDist = dist.get(u) + w;
                if (newDist < dist.get(v)) {
                    dist.put(v, newDist);
                    pred.put(v, u);
                    heap.offer(new int[]{v, newDist});
                }
            }
        }

        return new Result(dist, pred);
    }

    public static void main(String[] args) {
        Map<Integer, List<int[]>> graph = new HashMap<>();
        graph.put(0, Arrays.asList(new int[]{1,2}, new int[]{2,4}));
        graph.put(1, Arrays.asList(new int[]{2,1}, new int[]{3,7}));
        graph.put(2, Arrays.asList(new int[]{4,3}));
        graph.put(3, Arrays.asList(new int[]{4,1}));
        graph.put(4, Collections.emptyList());

        Result r = shortestPath(graph, 0);
        System.out.println("Distances: " + r.distance);
        System.out.println("Predecessors: " + r.predecessor);
    }
}
▶ Output
Distances: {0=0, 1=2, 2=3, 3=9, 4=6}
Predecessors: {1=0, 2=1, 4=2, 3=1}
📊 Production Insight
Using Integer.MAX_VALUE as infinity works for graphs where no edge weight sum exceeds that. For safety in production pipelines with potentially large weights, use Long.MAX_VALUE or BigInteger.
The PriorityQueue does not support decrease-key — pushing duplicates is acceptable as long as you prune with the stale entry check. This tradeoff gives O(log V) push and O(1) pop but at the cost of heap size up to O(E).
If latency is critical and heap size is a concern (dense graphs), consider using a bucket queue (Dial's algorithm) for O(V+E) time.
🎯 Key Takeaway
The Java PriorityQueue version with duplicate pruning is the industry standard.
Use it unless you absolutely need decrease-key — then switch to Fibonacci heap or indexed priority queue.

Edge Cases and Production Pitfalls

Dijkstra seems simple but production code fails in subtle ways. Here are the common edge cases: - Disconnected graph: Nodes unreachable from source remain at INF. Always check for INF before using the distance. - Self-loops and zero-weight cycles: Dijkstra can handle zero-weight edges, but a zero-weight cycle may cause infinite loop if you don't skip stale entries. The stale entry check prevents this. - Parallel edges: Multiple edges between same nodes. Dijkstra correctly picks the smallest weight due to the relaxation step. - Integer overflow: Sum of weights can exceed Integer.MAX_VALUE. Use long for distances. - Multiple sources: For multi-source shortest paths, initialize all sources with dist=0 and push all of them into heap. Works identically. - Large graphs: For graphs with millions of nodes, use adjacency list not adjacency matrix. Consider bidirectional search if you only need a single pair. - Negative weights: Dijkstra gives wrong answers. Validate input and throw exception if any negative weight found.

📊 Production Insight
In production routing services, graphs often change dynamically. If edges update frequently, consider incremental Dijkstra or a dynamic shortest path algorithm. Re-running full Dijkstra on every update is O(E log V) — on a graph with 1M edges that's ~20ms per run, which may be too slow for real-time updates.
Rule: if your graph changes faster than once per second, use a dynamic approach or incremental update.
🎯 Key Takeaway
Dijkstra fails on negative weights, zero-weight cycles without stale entry check, and is slow on dense graphs.
Always validate input and benchmark against your graph size.
Choose Your Shortest Path Algorithm
IfAll edge weights >= 0
UseUse Dijkstra's algorithm with min-heap
IfNegative weights exist, but no negative cycles
UseUse Bellman-Ford algorithm
IfSingle source to many nodes, graph is dense (E ~ V^2)
UseUse Dijkstra with array (O(V^2)) instead of heap
IfNeed shortest path between a specific pair of nodes
UseUse A* search algorithm with heuristic
🗂 Dijkstra vs Other Shortest Path Algorithms
Choose the right tool for your graph
AlgorithmTime ComplexityEdge Weight SupportSingle PairUse Case
Dijkstra (Binary Heap)O((V+E) log V)Non-negative onlyYes (with early exit)General purpose, sparse graphs
Dijkstra (Array)O(V²)Non-negative onlyYesDense graphs (E ~ V²)
Bellman-FordO(VE)Negative & non-negativeYesGraphs with negative edges, small V
Floyd-WarshallO(V³)Negative but no negative cyclesNo (all pairs)All-pairs shortest paths, dense graphs
A* SearchO(E) on good heuristicNon-negative onlyYes (optimal with admissible heuristic)Single pair with domain-specific heuristic

🎯 Key Takeaways

  • Dijkstra finds shortest paths from a single source in O((V+E) log V) using a min-heap.
  • It requires all edge weights to be non-negative — use Bellman-Ford for negative weights.
  • Always check for stale heap entries by comparing the popped distance to the current known distance.
  • The first time a node is popped from the heap, its distance is final and optimal.
  • Use an adjacency list for sparse graphs; Dijkstra on dense graphs may be better served by Floyd-Warshall.

⚠ Common Mistakes to Avoid

    Using Djkstra without stale entry check
    Symptom

    Algorithm never terminates on cyclic graphs; heap grows indefinitely, eventually causing OutOfMemoryError.

    Fix

    Immediately after popping from priority queue, add: if (currentDist > dist[currentNode]) continue;

    Treating graph as undirected when it is directed (or vice versa)
    Symptom

    Missing edges or incorrect distances. Paths that should exist don't appear.

    Fix

    Ensure adjacency list construction matches the intended edge direction. For undirected graphs, add both directions for each edge.

    Using integer types that overflow
    Symptom

    Negative distances appear after summing large edge weights (e.g., sum > 2^31-1).

    Fix

    Use long for distance array and early cast to long: long newDist = (long) dist[u] + w;

    Assuming all nodes are reachable
    Symptom

    Production code uses Integer.MAX_VALUE distances without checking, leading to overflow when adding to them.

    Fix

    Check if (dist.get(n) == Integer.MAX_VALUE) before using distance for comparisons or addition.

Interview Questions on This Topic

  • QWhat is the time complexity of Dijkstra's algorithm with a binary min-heap?JuniorReveal
    O((V + E) log V). Each edge relaxation may cause a heap push (O(log V)), and each vertex is popped once (O(log V)). If using a Fibonacci heap, it becomes O(E + V log V), but in practice the binary heap is preferred due to lower constants.
  • QWhy does Dijkstra not work with negative edge weights?Mid-levelReveal
    Dijkstra relies on the greedy invariant: once a node is popped from the heap with the minimum distance, that distance is final because all remaining edges have non-negative weight. A negative edge could later provide a cheaper path to an already settled node, breaking the invariant. Example: source(A) -> B (10), B -> C (-5), A -> C (2). From A, we settle B first (dist=10), then C via B (dist=5). But C's true shortest path is 2 directly from A — we never reconsider A after it's settled. Bellman-Ford handles negatives by iterating V-1 times over all edges.
  • QHow do you reconstruct the actual path (not just distance) from a Dijkstra run?SeniorReveal
    Maintain a predecessor map: when you relax an edge (update dist[v]), set predecessor[v] = u. At the end, to reconstruct the path from source to target, walk backwards from target using predecessor until you reach source, then reverse the list. This adds O(path length) time and O(V) space. Ensure target is reachable (dist != INF) before reconstructing.
  • QCompare Dijkstra with BFS for shortest paths in an unweighted graph.JuniorReveal
    In unweighted graphs, BFS runs in O(V+E) and finds shortest path in terms of number of edges. Dijkstra would also work but would be slower (O((V+E) log V)) due to heap overhead. BFS uses a queue and processes nodes level by level. For weighted graphs (even positive weights), BFS fails because edge weights differ — Dijkstra is the correct generalization.
  • QWhat happens to Dijkstra if there is a zero-weight cycle?SeniorReveal
    If the algorithm does not have a stale entry check, it may loop forever because it keeps finding the same distance for nodes in the cycle and re-pushing them. The fix is the standard if (d > dist[u]) continue; check after popping, which ensures that even with zero-weight cycles, each node is settled once and subsequent entries are discarded. With that check, Dijkstra correctly handles zero-weight edges and cycles.
  • QDesign an algorithm to find the shortest path from a source to all nodes in a graph where edge weights are 0, 1, or 2. Can you do better than Dijkstra?SeniorReveal
    Yes — use a BFS with deque (0-1 BFS variant). For edges of weight 0, push the neighbour to the front of the deque; for weight 1 or 2, push to the back. This runs in O(V+E) because each edge is processed once in O(1) amortized. Extend it to weights 0 and 1 easily; for weight 2, you can split the edge into two weight-1 edges by introducing an intermediate node, or use a multi-level BFS.

Frequently Asked Questions

Why does Dijkstra fail with negative edge weights?

Dijkstra's greedy guarantee relies on the fact that once a node is settled (popped from the heap with the minimum distance), no future path can be cheaper. With negative weights, a node settled later could have a negative edge that makes a previously-settled node's distance shorter — but we never revisit settled nodes. Use Bellman-Ford for graphs with negative weights.

What is a stale heap entry and why does it matter?

When we update dist[v] and push a new (dist[v], v) entry to the heap, the old entry for v remains. When that old entry is popped, its distance is larger than the current dist[v]. We check 'd > dist[u]' to detect and skip these stale entries. Without this check, the algorithm works correctly but may process many more entries (especially on cyclic graphs), potentially leading to exponential heap growth and memory exhaustion.

Can Dijkstra work with zero-weight edges?

Yes, Dijkstra handles zero-weight edges correctly as long as there is no zero-weight cycle without a stale entry check. If a zero-weight cycle exists and no stale entry check is present, the algorithm may loop forever. With the standard stale entry check, it works fine.

How do I reconstruct the shortest path after running Dijkstra?

Maintain a predecessor array/ map. Whenever you relax an edge (update dist[v]), set predecessor[v] = u. At the end, to get the path to a target, walk backwards: for (v = target; v != source; v = predecessor[v]). Reverse the list to get source to target.

What is the difference between O(V^2) and O(E log V) Dijkstra?

The O(V^2) version uses a plain array to find the minimum-distance unsettled node, scanning all V nodes each time. It is better for dense graphs (E close to V^2). The O((V+E) log V) version uses a binary min-heap and is better for sparse graphs. In most interview and competitive programming contexts, the heap version is expected.

🔥
Naren Founder & Author

Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.

← PreviousDFS — Depth First SearchNext →Bellman-Ford Algorithm
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged