DSA DSA In Depth
0%
lesson dijkstrashortest-pathspriority-queue

Dijkstra's Algorithm: Non-Negative Edge Invariant, Priority Queue Tradeoffs & Dial's Bucket Queue

7 min read Module 5.2: Disjoint Sets, Minimum Spanning Trees & Shortest Paths Updated Oct 23, 2026
📄 Cheat Sheet
Dijkstra's Shortest Path Algorithm — Graph Theory
William Fiset Dijkstra's Shortest Path Algorithm — Graph Theory Watch on YouTube ↗

Covers greedy frontier expansion, lazy duplicate deletion in priority queues, and why negative weights violate the settled invariant.

0:00 / --:--

The Non-Negative Edge Greedy Settlement Proof

Why does the vertex $u \in V \setminus S$ with the minimum tentative distance $d[u]$ have $d[u] == \delta(s, u)$ (the true global shortest path distance) at the exact moment it is popped from the priority queue?

Formal Proof — Dijkstra's Settled Set Inductive Invariant

Theorem (CLRS Theorem 22.6): If $w(e) \ge 0$ for all $e \in E$, then whenever Dijkstra's algorithm extracts $u = \arg\min_{x \in V \setminus S} d[x]$ and adds $u$ to $S$, we have $d[u] = \delta(s, u)$.

Proof by Contradiction: Suppose $u$ is the first vertex added to $S$ for which $d[u] > \delta(s, u)$. Consider an actual shortest path $P: s \leadsto u$. Since $s \in S$ and $u \notin S$, let $(x, y)$ be the first edge along $P$ that crosses the cut $(S, V \setminus S)$, so $x \in S$ and $y \in V \setminus S$.
By the inductive hypothesis, when $x$ was settled, $d[x] = \delta(s, x)$, and relaxing $(x, y)$ set $d[y] \le d[x] + w(x, y) = \delta(s, y)$. Because the subpath $y \leadsto u$ along $P$ consists entirely of edges with non-negative weights ($w \ge 0$), we have:

$$d[y] = \delta(s, y) \le \delta(s, u) < d[u]$$

Wait: $y \in V \setminus S$ and $d[y] < d[u]$, contradicting that $u$ was chosen as the minimum $d[\cdot]$ in $V \setminus S$! Notice where the proof breaks if a negative edge exists: $\delta(s, y) \le \delta(s, u)$ fails if the remaining suffix $y \leadsto u$ has negative total weight.

1. Lazy Duplicate Insertion vs. decrease-key

Standard C++ std::priority_queue and Python heapq do not expose a decrease_key handle. Instead, whenever an edge relaxation improves $d[v]$, we push a new pair (d[v], v) into the heap. When a pair (d_popped, u) is later extracted, we check if (d_popped > d[u]) continue; in $O(1)$ time—skipping stale entries in a single branch without maintaining an external index-to-heap-position map!

2. Dial's Algorithm (1969): $O(V \cdot C + E)$ Bucket Queue

When all edge weights are small integers $w(e) \in \{0, 1, \dots, C\}$ (generalizing 0-1 BFS where $C = 1$), any tentative distance pushed when popping $d[u]$ lies in $[d[u], d[u] + C]$. Maintaining a circular array of $C + 1$ buckets (indexed by $\text{dist} \bmod (C + 1)$) eliminates the $\log V$ heap factor completely, running in $O(V + E + \text{maxDist}) \le O(V \cdot C + E)$ linear time.

Dijkstra Cut Proof (x in S, y in VS) & Negative-Edge Counterexample Non-Negative Invariant: d[y] ≤ δ(s, u) < d[u] Settled S s x y u w ≥ 0 Negative Weight Trap (Even in a DAG!) s A B +5 +2 (settled 1st!) -10 B settled at d=2 before s→A→B (d=-5) explored
flowchart TD Pop["Pop (d_popped, u) from Min-Heap"] --> Stale{"d_popped > dist[u]?"} Stale -- "Yes (Stale Duplicate)" --> Pop Stale -- "No (u Settled!)" --> Scan["For each outgoing edge (u, v, w)"] Scan --> Relax{"dist[u] + w < dist[v]?"} Relax -- "Yes" --> Push["dist[v] = dist[u] + w; pq.push({dist[v], v})"] Relax -- "No" --> Next["Continue"]
Shortest Paths: Dijkstra, A* Consistent Potentials & OSRM Contraction Hierarchies
O((V+E) log V) · A* f=g+h · CH Shortcuts

Compare Dijkstra’s uniform radial frontier against A* directional pruning f(u) = g(u) + h(u) and OSRM Contraction Hierarchies (shortcut insertion w(u,w)=w(u,v)+w(v,w) + upward rank-monotonic bidirectional search).

Step 1 / 8
Custom Input:
Timeline

Invariant & State

Pop S (g=0, h=6): relax B (g=2, h=4 ⇒ f=6) and C (g=5, h=4 ⇒ f=9). Admissible heuristic h(u) ≤ d(u,T) pulls B ahead of C!
w=2 w=5 w=2 w=2 w=4 S g=0,f=6 B g=2,f=6 C g=5,f=9 D g=∞ T g=∞
Priority Queue
[(B, f=6), (C, f=9)]
Pruned Detour
Vertex C (f=9 > d*(T)=6)
Consistency
w_h(u,v) = w(u,v) + h(v) - h(u) ≥ 0
[Step 1] Pop S (g=0, h=6): relax B (g=2, h=4 ⇒ f=6) and C (g=5, h=4 ⇒ f=9). Admissible heuristic h(u) ≤ d(u,T) pulls B ahead of C!
Hardware Reality — Why 4-ary Heaps Beat Fibonacci Heaps in Practice

Although Fibonacci heaps achieve $O(1)$ amortized decrease-key and $O(E + V \log V)$ total time, every node in a Fibonacci heap stores 4 pointers (parent, child, left, right), triggering pointer-chasing cache misses and high constant factors on extract-min. A contiguous array-backed 4-ary (d-ary) heap places all 4 children of a node inside a single 64-byte cache line (for 16-byte (dist, vertex) pairs), halving heap height to $\log_4 E$ and outperforming Fibonacci heaps by $3\times$ to $8\times$ on real road networks.

Complexity Matrix

Priority Queue Structure extract-min Relaxation / decrease-key Total Time Complexity
Flat Array Scan O(V) O(1) O(V² + E) = O(V²)
Binary / 4-ary Heap (Lazy Push) O(log E) O(log E) O((V + E) log V)
Fibonacci Heap O(log V) amortized O(1) amortized O(E + V log V)
Dial's Circular Buckets ($w \le C$) O(1) + bucket scan O(1) list splice O(V · C + E)

Production Implementation: Lazy-Deletion Dijkstra & Dial's Bucket Queue

#include <vector>
#include <queue>
#include <limits>

struct WeightedArc {
    int to;
    long long weight;
};

std::vector<long long> dijkstra(int n, int source, const std::vector<std::vector<WeightedArc>>& adj) {
    constexpr long long INF = std::numeric_limits<long long>::max() / 4;
    std::vector<long long> dist(n, INF);
    using State = std::pair<long long, int>;
    std::priority_queue<State, std::vector<State>, std::greater<State>> pq;

    dist[source] = 0;
    pq.emplace(0, source);

    while (!pq.empty()) {
        auto [d, u] = pq.top();
        pq.pop();
        if (d > dist[u]) continue; // Critical lazy deletion guard!

        for (const auto& e : adj[u]) {
            if (dist[u] + e.weight < dist[e.to]) {
                dist[e.to] = dist[u] + e.weight;
                pq.emplace(dist[e.to], e.to);
            }
        }
    }
    return dist;
}
Failure Mode — Missing the if (d > dist[u]) continue; Guard or Allowing Negative Edges to Re-Push

Without the if (d > dist[u]) continue; check at the top of the loop, a vertex pushed $k$ times will re-scan all of its outgoing edges $k$ times, degrading Dijkstra's runtime from $O(E \log V)$ to $O(V \cdot E)$ on dense graphs! Equally critical: if you allow a modified Dijkstra to re-push already-settled vertices on negative-weight edges (even without negative cycles), a chain of 3-vertex diamonds with exponentially decreasing negative weights forces $\Omega(2^{V/3})$ exponential vertex pops.

Check yourself Question 1 of 4
Concept Check One-Line Stale Entry Guard in Lazy Dijkstra recall
Section: Lazy Deletion Pattern

When implementing Dijkstra's algorithm with a standard `std::priority_queue` (which lacks `decreaseKey`), duplicate pairs `(d, u)` are pushed whenever `dist[u]` improves. What check immediately after popping `(d, u)` guarantees each vertex is expanded at most once?

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

Analyze the invariant, asymptotic bound, and hardware behavior before selecting.

If you remove the settled-set assumption in Dijkstra and allow a vertex $u$ to be re-pushed into the priority queue whenever a negative edge improves `dist[u]`, what happens to the worst-case time complexity on graphs with negative edges (even without negative cycles)?

Key Takeaways

Dijkstra's greedy choice is sound if and only if all edge weights are non-negative (w >= 0), ensuring no path through unsettled vertices can ever undercut the minimum tentative distance d[u].
Lazy duplicate insertion with the O(1) guard `if (d > dist[u]) continue;` avoids complex decrease-key index maps while preserving O((V + E) log V) time.
Contiguous 4-ary heaps outperform Fibonacci heaps in practice because all 4 children reside in a single L1 cache line.
When edge weights are integers in [0, C], Dial's circular bucket queue replaces the heap with C + 1 buckets to achieve O(V*C + E) time.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue