DSA DSA In Depth
0%
build build-labindexed-heappriority-queue

Build Lab: Indexed 4-Ary Min/Max Priority Queue with O(log_d N) decreaseKey

7 min read Module 3.1: Binary Trees, Traversals, BSTs & Priority Heaps Updated Oct 23, 2026
📄 Cheat Sheet
Indexed Priority Queue (Data Structures & Dijkstra Optimization)
WilliamFiset Indexed Priority Queue (Data Structures & Dijkstra Optimization) Watch on YouTube ↗

Walks through bidirectional index mapping (pm[] position map and im[] inverse map) that powers O(log N) decreaseKey and arbitrary key removal.

0:00 / --:--

Architecture Specification: Bidirectional Permutation Invariant (heap[] $\leftrightarrow$ pos[])

In this Build Lab, you will engineer a cache-aligned Indexed 4-Ary Min-Priority Queue capable of driving production Dijkstra shortest-path solvers, A* pathfinders, and cancelable timer subsystems. The data structure maintains three flat arrays indexed over external IDs $u \in [0, M)$ and heap positions $i \in [0, N)$:

  • keys[u]: Stores the current priority key associated with external handle $u$.
  • heap[i] ($0 \le i < N$): The 4-ary heap array storing external handles $u$, ordered by keys[heap[i]]. For slot $i$, its parent is $(i - 1) \gg 2$ and its 4 children are $(i \ll 2) + 1 \dots (i \ll 2) + 4$.
  • pos[u] ($0 \le u < M$): Stores the current heap index $i$ of handle $u$, or $-1$ if $u$ is not currently in the priority queue.
Indexed 4-Ary PQ Method Time Complexity Comparisons (Worst Case) Cache Line Touches
contains(u) / keyOf(u) / peekMin() O(1) 0 1 L1 lookup (pos[u] != -1)
insert(u, key) / decreaseKey(u, newKey) O(log₄ N) $\le \frac{1}{2}\log_2 N$ $\le \frac{1}{2}\log_2 N$ levels
popMin() O(4 log₄ N) $\le 2 \log_2 N$ Contiguous 4-child scan per level
erase(u) / changeKey(u, newKey) O(log₄ N) $\le 2 \log_2 N$ Both siftUp + siftDown from pos[u]

Formal Correctness Invariants: Bijection & Dual-Direction Arbitrary Erase

1. Permutation Bijection Invariant: At the end of every public method, for all $i \in [0, N)$ and all $u \in [0, M)$:

$$\text{pos}[\text{heap}[i]] == i \quad \text{and} \quad (\text{pos}[u] \ne -1 \implies \text{heap}[\text{pos}[u]] == u)$$

2. Why Arbitrary erase(u) Must Call BOTH siftUp and siftDown: When deleting an arbitrary internal node at index $i = \text{pos}[u]$, we move the last leaf $v = \text{heap}[N-1]$ into slot $i$. Unlike popMin()—where the root is guaranteed to be $\le$ all elements so $v$ can only move downward—an arbitrary internal slot $i$ in one branch of the heap can have a parent in $i$'s branch whose key is strictly larger than $v$'s key from another deep branch! Therefore, erase(u) (and changeKey(u, newKey)) must invoke siftUp(i) followed by siftDown(i).

Binary & d-Ary Heap Dual View, Floyd O(N) Build & Indexed PQ
Floyd Build O(N) · Sift O(log_d N)

Observe how implicit array indices (parent = ⌊(i−1)/d⌋, child_k = d·i+k) eliminate pointer overhead, why Floyd’s bottom-up siftDown sums ∑ 2^(h−k)·k ≤ 2N work, and how an Indexed PQ maintains pos[id] ⇄ heap[i].

Step 1 / 7
Custom Input:
Timeline

Invariant & State

Leaves [3..6] are trivially valid 1-node heaps (0 work for 50% of nodes!).
🌳 Live 2D Hierarchical Tree Topology — Binary & d-Ary Heap Dual View, Floyd O(N) Build & Indexed PQ Visited/Active Pivot/Root Resolved
Root 45 [0] p=[0] 32 [1] p=[0] 18 [2] p=[1] 29 [3] p=[1] 12 [4] p=[2] 9 [5] p=[2] 25 [6]
Tree Levels:   L0: [45]  |  L1: [32, 18]  |  L2: [29, 12, 9, 25]
[0]45Root
[1]32p=[0]
[2]18p=[0]
[3]29p=[1]
[4]12p=[1]
[5]9p=[2]
[6]25p=[2]
Heap Size N
7
Internal Nodes
i = 2 down to 0
Total Sift Swaps
0
Bound ∑ h_i
≤ 2N − 2 log₂N
[Step 1] Starting Floyd bottom-up heapify from last internal node i = ⌊7/2⌋ - 1 = 2 down to 0.
heap[i] (Heap Slot i → External Handle u): [0] u=3 [1] u=1 [2] u=4 [3] u=0 pos[u] (External Handle u → Heap Slot i, or -1): u0: 3 u1: 1 u2: -1 u3: 0 u4: 2 keys[u] (External Handle u → Priority Key): k0: 42 k1: 19 k2: -- k3: 7 k4: 25 4-Ary Indexing (D = 4): • parent(i) = (i - 1) >> 2 • children(i) = (i << 2) + 1 .. + 4 decreaseKey(u=0, 42 → 5): 1. keys[0] = 5 2. siftUp(pos[0] = 3) swaps with parent (3-1)>>2 = 0
flowchart LR Op["updateOrInsert(u, newKey)"] --> Exists{"pos[u] != -1 ?"} Exists -- "No" --> Ins["pos[u] = N; heap[N++] = u keys[u] = newKey siftUp(pos[u])"] Exists -- "Yes" --> Cmp{"newKey < keys[u] ?"} Cmp -- "Yes (decreaseKey)" --> Dec["keys[u] = newKey siftUp(pos[u])"] Cmp -- "No (increaseKey)" --> Inc["keys[u] = newKey siftDown(pos[u])"]

Hardware Reality: Why Indexed PQ Saves $O(E)$ Memory in Dense Dijkstra

The common competitive-programming workaround for missing decreaseKey is "lazy duplicate pushing"—pushing a new (dist, u) pair every time an edge relaxes and ignoring stale pairs on pop. On a dense graph with $V = 10^5$ vertices and $E = 5 \times 10^6$ edges, lazy pushing bloats the heap to $5{,}000{,}000$ entries ($80\text{ MB}$, spilling out of L3 cache into main DRAM) and increases heap height from $\log_4(10^5) = 8.3$ to $\log_4(5 \times 10^6) = 11.1$. An Indexed 4-Ary PQ strictly caps the heap at $V = 10^5$ entries ($1.2\text{ MB}$, 100% resident in L2/L3 cache) for the entire execution!

Production Reference Implementation: Complete Indexed 4-Ary Min-PQ

// C++20: Production Indexed 4-Ary Min-Priority Queue with Hole-Sinking & O(log_4 N) Updates
#include <vector>
#include <cassert>
#include <utility>

template <typename Key, int D = 4>
class IndexedDAryMinPQ {
    std::vector<int> heap; // heap[i] = external handle u at heap slot i
    std::vector<int> pos;  // pos[u]  = heap slot i of external handle u (-1 if absent)
    std::vector<Key> keys; // keys[u] = priority key of handle u

    void siftUp(int i) noexcept {
        int u = heap[i];
        const Key& k = keys[u];
        while (i > 0) {
            int p = (i - 1) / D;
            int parentHandle = heap[p];
            if (!(k < keys[parentHandle])) break;
            heap[i] = parentHandle;
            pos[parentHandle] = i;
            i = p;
        }
        heap[i] = u;
        pos[u] = i;
    }

    void siftDown(int i) noexcept {
        int u = heap[i];
        const Key& k = keys[u];
        const int n = static_cast<int>(heap.size());
        while (true) {
            int firstChild = D * i + 1;
            if (firstChild >= n) break;
            int lastChild = std::min(n - 1, firstChild + D - 1);
            int bestChild = firstChild;
            for (int c = firstChild + 1; c <= lastChild; ++c) {
                if (keys[heap[c]] < keys[heap[bestChild]]) {
                    bestChild = c;
                }
            }
            if (!(keys[heap[bestChild]] < k)) break;
            heap[i] = heap[bestChild];
            pos[heap[i]] = i;
            i = bestChild;
        }
        heap[i] = u;
        pos[u] = i;
    }

public:
    explicit IndexedDAryMinPQ(int maxHandles) : pos(maxHandles, -1), keys(maxHandles) {
        heap.reserve(maxHandles);
    }

    bool empty() const noexcept { return heap.empty(); }
    int size() const noexcept { return static_cast<int>(heap.size()); }
    bool contains(int u) const noexcept { return pos[u] != -1; }
    int peekMinHandle() const noexcept { return heap[0]; }
    const Key& peekMinKey() const noexcept { return keys[heap[0]]; }
    const Key& keyOf(int u) const noexcept { return keys[u]; }

    void insert(int u, Key key) {
        assert(!contains(u));
        int idx = static_cast<int>(heap.size());
        heap.push_back(u);
        pos[u] = idx;
        keys[u] = std::move(key);
        siftUp(idx);
    }

    void decreaseKey(int u, Key newKey) {
        assert(contains(u) && !(keys[u] < newKey));
        keys[u] = std::move(newKey);
        siftUp(pos[u]);
    }

    void changeKey(int u, Key newKey) {
        assert(contains(u));
        bool smaller = (newKey < keys[u]);
        keys[u] = std::move(newKey);
        if (smaller) siftUp(pos[u]);
        else siftDown(pos[u]);
    }

    void erase(int u) {
        int i = pos[u];
        assert(i != -1);
        int lastHandle = heap.back();
        heap.pop_back();
        pos[u] = -1;
        if (i < static_cast<int>(heap.size())) {
            heap[i] = lastHandle;
            pos[lastHandle] = i;
            siftUp(i);
            siftDown(i);
        }
    }

    std::pair<int, Key> popMin() {
        int minHandle = heap[0];
        Key minKey = std::move(keys[minHandle]);
        erase(minHandle);
        return {minHandle, std::move(minKey)};
    }
};

Failure Modes & Adversarial Pitfalls

  • Forgetting siftUp(i) Inside erase(u) when Deleting Near the Bottom of a Deep Branch: Suppose branch A has depth 4 with values [1, 100, 101, 102] and branch B has depth 3 with leaf 5. If you erase(101) and replace it with the last element 5 from branch B, 5 < 100 violates the heap order with its new parent 100! Calling only siftDown(i) leaves the heap corrupted. Always run both siftUp(i) and siftDown(i).
  • Erasing the Last Element (i == heap.size()) Edge Case: When u happens to be at heap.back(), popping the vector makes i == heap.size(). Attempting to write heap[i] = lastHandle or run siftUp(i) accesses out-of-bounds memory if you omit the if (i < heap.size()) guard!
Check yourself Question 1 of 4
Concept Check Bijection Invariant of an Indexed Priority Queue recall

In an Indexed Priority Queue storing external handles `u ∈ [0, M)` across `N` active heap slots, what invariant relates the heap array `heap[0..N-1]` and the position map `pos[0..M-1]`?

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

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

On a dense graph with V = 100,000 vertices and E = 10,000,000 edges, how does an Indexed 4-Ary Min-PQ compare against a standard `std::priority_queue` using lazy duplicate pushes during Dijkstra's algorithm?

Key Takeaways

An Indexed d-Ary Priority Queue maintains the bijection pos[heap[i]] == i and heap[pos[u]] == u to locate any external handle u in O(1) time.
Using D = 4 halves tree height to log₄ N, cutting siftUp/decreaseKey work in half while keeping all 4 siblings contiguous in a single cache line during siftDown.
Arbitrary erase(u) replaces slot i = pos[u] with the final element heap[N-1] and MUST execute both siftUp(i) and siftDown(i) because the replacement leaf may be smaller than its new parent.
Compared to lazy duplicate pushing in Dijkstra, an Indexed 4-Ary PQ bounds memory strictly to O(V) instead of O(E), keeping the entire queue inside L2/L3 cache.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue