Binary and d-ary heaps stored in arrays, heap operations and costs, PriorityQueue in .NET (min-heap, no stability, Remove in .NET 9), and classic uses: top-K, scheduling, merging sorted streams and Dijkstra.
A heap is a complete tree stored in an array that keeps the heap property: in a min-heap every parent ≤ its children, so the smallest item is always at the root.
.NET's PriorityQueue<TElement,TPriority> (.NET 6+) is an array-backed min-heap (4-ary internally). Enqueue, Dequeue, TryPeek, EnqueueDequeue; not stable for equal priorities; no decrease-key (Remove was added in .NET 9). For a max-heap, pass a reversed comparer.
Top K from a stream: keep a min-heap of size K; for each item, if it beats the heap's minimum, replace it. That is O(n log K) time and O(K) memory.
(Not to be confused with the managed heap, which is memory.)
Min-heap as a tree: Same heap as an array (binary heap):
[2] index: 0 1 2 3 4 5 6
/ \ [2] [5] [3] [9] [6] [8] [4]
[5] [3]
/ \ / \ parent(i) = (i - 1) / 2
[9] [6] [8] [4] children(i) = 2i + 1, 2i + 2
No pointers: navigation is index arithmetic, so heaps are compact and cache-friendly.
| Operation | How | Cost |
|---|---|---|
| Peek | Read index 0 | O(1) |
| Insert | Append at end, sift up while smaller than parent | O(log n) |
| Remove min | Move last to root, sift down swapping with the smaller child | O(log n) |
| Build heap | Sift down from the last parent to the root | O(n) |
| Find arbitrary item | Scan | O(n) |
| Feature | Detail |
|---|---|
| Order | Min-priority first (use a custom IComparer<TPriority> for max) |
| Stability | Not stable: equal priorities come out in no guaranteed order |
| Update priority | No decrease-key; .NET 9 added Remove; otherwise enqueue again and skip stale entries |
| Thread safety | None |
| Helpers | EnqueueRange, EnqueueDequeue (push then pop, efficient for top-K), DequeueEnqueue, TryDequeue |
An analytics endpoint returned the 20 slowest requests out of ~5 million log records per hour. It used records.OrderByDescending(r => r.Duration).Take(20), which in practice sorted or buffered everything and allocated heavily. A size-20 min-heap streamed the records in O(n log 20) with almost no memory, and the endpoint went from 9 seconds to 600 ms.
Checks heap fundamentals and the ability to apply them to a common coding problem.
Green flags
Red flags
Insert 1 into [2,5,3,9,6,8,4]:
append → [2,5,3,9,6,8,4,1] 1 at index 7, parent index 3 (9) → swap
→ [2,5,3,1,6,8,4,9] parent index 1 (5) → swap
→ [2,1,3,5,6,8,4,9] parent index 0 (2) → swap
→ [1,2,3,5,6,8,4,9] done (at most log n swaps)
Remove min from [2,5,3,9,6,8,4]:
take 2 from the root; move the last item (4) to the root → [4,5,3,9,6,8]
sift down: children are 5 and 3, smaller is 3 → swap → [3,5,4,9,6,8]
4 is now at index 2; its only child is 8 → 4 ≤ 8, done
Each node has four children, so the tree is shallower (log₄ n) and children sit next to each other in memory. Sift-down compares more children per level but touches fewer levels and cache lines; in practice it is faster for typical workloads.
| Problem | Heap | Cost |
|---|---|---|
| K largest from a stream | Min-heap of size K | O(n log K) time, O(K) space |
| K smallest | Max-heap of size K | O(n log K) |
| Merge K sorted streams | Min-heap of current heads | O(n log K) |
| Running median | Max-heap (lower half) + min-heap (upper half) | O(log n) per item |
| Next timer / scheduled job | Min-heap by due time | O(log n) |
| Shortest paths (Dijkstra) | Min-heap by distance | O((V + E) log V) |
Compare: full sort is O(n log n) time and O(n) memory, which loses badly when K is small and n is large or unbounded.
Without decrease-key, use lazy deletion: enqueue the item again with the new priority and keep a dictionary of current priorities; when you dequeue an entry whose priority is stale, skip it. This is the standard way to write Dijkstra with PriorityQueue.
Build a heap in O(n), then repeatedly remove the max: O(n log n) time, O(1) extra space, not stable. .NET's Array.Sort uses introsort, which falls back to heapsort when quicksort recursion gets too deep.
PriorityQueue and compare with OrderByDescending().Take(K) on 10 million random ints using BenchmarkDotNet (time and allocations).(arrayIndex, position).// Top K largest items from a large or unbounded stream: O(n log K) time, O(K) memory
public static List<RequestLog> SlowestK(IEnumerable<RequestLog> logs, int k)
{
var heap = new PriorityQueue<RequestLog, TimeSpan>(k); // min-heap by duration
foreach (var log in logs)
{
if (heap.Count < k)
{
heap.Enqueue(log, log.Duration);
}
else if (heap.TryPeek(out _, out var smallest) && log.Duration > smallest)
{
heap.EnqueueDequeue(log, log.Duration); // push then pop the smallest, one sift
}
}
var result = new List<RequestLog>(heap.Count);
while (heap.TryDequeue(out var item, out _)) result.Add(item);
result.Reverse(); // slowest first
return result;
}
// Max-heap via a reversed comparer
var maxHeap = new PriorityQueue<string, int>(Comparer<int>.Create((a, b) => b.CompareTo(a)));
maxHeap.Enqueue("low", 1);
maxHeap.Enqueue("high", 10);
Console.WriteLine(maxHeap.Dequeue()); // high
// Merge K sorted sequences
public static IEnumerable<int> MergeSorted(IReadOnlyList<IEnumerator<int>> sources)
{
var heap = new PriorityQueue<int, int>(); // element = source index, priority = current value
for (int i = 0; i < sources.Count; i++)
if (sources[i].MoveNext()) heap.Enqueue(i, sources[i].Current);
while (heap.TryDequeue(out var i, out var value))
{
yield return value;
if (sources[i].MoveNext()) heap.Enqueue(i, sources[i].Current);
}
}
Mistake: Sorting everything to get the top K. Why it is wrong: A sort is O(n log n) and needs every item in memory, which fails for large or unbounded streams. Instead: Keep a size-K min-heap for O(n log K) time and O(K) memory.
Mistake: Keeping a List<T> sorted with Insert (or re-sorting after each add) as a priority queue.
Why it is wrong: Each insert shifts elements (O(n)) or re-sorts (O(n log n)), so a busy scheduler degrades quadratically.
Instead: Use PriorityQueue<TElement, TPriority>, where each insert and removal is O(log n).
Mistake: Expecting PriorityQueue to be stable.
Why it is wrong: Items with equal priority come out in no guaranteed order, so FIFO fairness between equal jobs is lost.
Instead: Add a tie-breaker to the priority, for example (priority, sequenceNumber) with a tuple comparer.
Mistake: Trying to update an item's priority in place.
Why it is wrong: There is no decrease-key, and changing a mutable priority object breaks the heap property silently.
Instead: Enqueue again and skip stale entries when dequeued (lazy deletion), or call Remove (.NET 9 and later) when you have the element.
Mistake: Sharing one PriorityQueue between threads without a lock.
Why it is wrong: Concurrent sift operations corrupt the array, losing items or returning the wrong minimum.
Instead: Use a lock around every operation, a single consumer task fed by a Channel<T>, or a broker with priorities for distributed work.
Mistake: Thinking a heap is sorted.
Why it is wrong: Only the root is guaranteed to be the minimum; UnorderedItems is in arbitrary order.
Instead: Dequeue repeatedly if you need sorted output.
Heaps are the go-to structure whenever I need "the next most important thing" repeatedly: schedulers, timers, top-K dashboards, merging sorted streams and shortest paths. The key insight is that you rarely need a full sort: a size-K heap turns an O(n log n), O(n)-memory problem into O(n log K) with constant memory, which matters for streams and big data.
| Heap shape | Levels for n = 1,000,000 | Comparisons per sift-down level | Cache behaviour | Fits best |
|---|---|---|---|---|
| Binary (2 children) | ~20 | 2 | Children may span cache lines deeper down | Simple implementations, textbooks |
4-ary (.NET PriorityQueue) |
~10 | 4 | Four children usually share a cache line | Insert-heavy and general workloads |
| Larger d-ary | Fewer | d | Wider scans per level | Insert-heavy, rare removals |
| Concurrency need | Approach |
|---|---|
| One thread owns the queue | Plain PriorityQueue |
| Many producers, one scheduler | Producers write to a Channel<T>; the scheduler task owns the heap |
| Several consumers | Lock around enqueue/dequeue, or partition by key |
| Across processes | Broker or database ordering (for example Service Bus scheduled messages, an indexed DueAt column) |
In .NET I know PriorityQueue's limits (no stability, no decrease-key, not thread-safe) and the standard workarounds: tie-breakers, lazy deletion, and single ownership or a lock.
A tree is a hierarchy of nodes with one root, where each node has children and no cycles. A binary tree limits each node to two children (left, right). A binary search tree (BST) adds an ordering rule: left subtree < node < right subtree, so search, insert and delete take O(height).
If the tree is balanced, height is O(log n); if you insert sorted data into a plain BST it degenerates into a linked list with O(n) height. Self-balancing trees (red-black, AVL) keep O(log n); .NET's SortedSet and SortedDictionary are red-black trees.
Traversals: in-order (sorted output for a BST), pre-order (copy/serialise), post-order (delete/evaluate), level-order (BFS).
Trees are everywhere: database B+ tree indexes, file systems, JSON/XML/DOM, Roslyn syntax trees, expression trees, org charts.
Push, Pop, Peek, all O(1). Used for the call stack, undo, expression parsing, DFS and backtracking.Enqueue, Dequeue, Peek, all O(1). Used for work queues, BFS, buffering and rate limiting.In .NET, Stack<T> is an array that grows at the end. Queue<T> is a circular buffer: head and tail indexes wrap around the array, so dequeuing does not shift elements.
Neither is thread-safe. For concurrency use ConcurrentQueue<T>/ConcurrentStack<T>, or better for producer/consumer pipelines, a bounded Channel<T> which adds async waiting and back-pressure.
How to tell it (STAR)
**S:** Our [ops dashboard] took [9 seconds] to show the 20 slowest requests and caused GC spikes. **T:** I wanted it fast enough to auto-refresh. **A:** The code sorted [5 million] records to take 20. I replaced it with a min-heap of size 20 that streams the records and keeps only the current top 20. **R:** It dropped to [600 ms] with almost no allocations, and the GC spikes on the API disappeared.
Last updated 23 Oct 2026.