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 bykeys[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).
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].
Invariant & State
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)Insideerase(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 leaf5. If youerase(101)and replace it with the last element5from branch B,5 < 100violates the heap order with its new parent100! Calling onlysiftDown(i)leaves the heap corrupted. Always run bothsiftUp(i)andsiftDown(i). - Erasing the Last Element (
i == heap.size()) Edge Case: Whenuhappens to be atheap.back(), popping the vector makesi == heap.size(). Attempting to writeheap[i] = lastHandleor runsiftUp(i)accesses out-of-bounds memory if you omit theif (i < heap.size())guard!