Skip to content
Software Atlas

Heaps & Priority Queues

Always serve the most urgent thing next, while new things keep arriving. How a binary heap does that with a tree hidden in an array, how to use one in Go and JavaScript, and where heaps quietly run your timers, schedulers and top-K queries.

⏱ ~40 min read14 sections7 interactive widgetsGo · JavaScript
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

In one sentenceA heap is a Data structure with one job: always hand you the most urgent item next, cheaply, even while new items keep arriving.

Picture the triage desk of a hospital emergency room. Patients arrive all day. Each one gets an urgency score: 1 for a heart attack, 5 for a sprained ankle. Whenever a doctor is free, the desk must answer one question: who is the most urgent person waiting right now?

A list of "things to do, most important first" that keeps changing is called a Priority queue. A normal queue serves the oldest item first; a priority queue serves the most important one first. A heap is the usual way to build one. The terms are often used as if they were the same thing.

Why not just use a list?

The triage desk could keep its patients in a simple Array. There are two obvious ways, and each one is slow at one of the two jobs. In the table, n is the number of people waiting, and log₂ n is how many times you can halve n before reaching 1. It grows very slowly: log₂ 1,000 is about 10, and log₂ of a million is only about 20.

Add a patientFind + remove the most urgent
Unsorted list: append new patients at the end1 stepread everyone to find the most urgent: n steps
Sorted list: kept in urgency order, with the most urgent at the endshift everyone behind the new patient's spot: up to n steps1 step: just take the last one
Heap (this page)about log₂ n stepsabout log₂ n steps

With 1,000 people waiting, "n steps" means about 1,000 steps per patient; log₂ n means about 10.

Run the race yourself. All three approaches handle exactly the same patients. Change the size of the waiting room and watch the bars:

Triage desk race
Same waiting room, same 1,000 events (500 patients arrive, 500 get treated), three ways to keep the list. Bars count basic steps: comparisons, plus items moved.
Unsorted list51,000 steps · ≈51 per event
arrive: append at the end · treat: scan everyone to find the most urgent
Sorted list13,802 steps · ≈14 per event
arrive: find the spot, shift everyone behind it · treat: take from the end
Binary heap13,229 steps · ≈13 per event
arrive: sift up · treat: sift down (this page explains how)
With 100 waiting, the unsorted list already does about 4× the heap's work, because it re-reads the whole room for every treatment. The sorted list keeps up for now: shifting ~50 items is cheap. Now pick 1,000 or 10,000.

What a heap gives you

1

peek

Look at the most urgent item. 1 step: it's always at the front.
2

push

Add an item. ~log₂ n steps: it climbs into place.
3

pop

Remove the most urgent item. ~log₂ n steps: the rest rearrange themselves.

That's all. A heap can't quickly search for an arbitrary item, and it doesn't keep everything sorted. It only keeps the one promise that matters to the triage desk, and that's why it's so cheap.

You'll find heaps wherever "what's next?" is asked over and over. Timers ask which deadline is soonest? (§10). Job schedulers ask which job has the highest priority? Analytics asks what are the 10 slowest requests out of a billion? (§08). Maps apps ask which unvisited place is closest? (§10). The next sections build one from scratch, then put it to work.

Foundations§02

A tree hiding in an array

In one sentenceA heap is a tree where every parent is smaller than its children, and it's stored in a plain array with no pointers at all.

The tree you imagine

A Tree here is a family-tree-shaped drawing of the data. Each item is a node. The one at the top is the root, nodes directly below a node are its children, and nodes with no children are leaves. In a binary tree every node has at most two children.

A Binary heap adds just one rule, the Heap property:

Every parent is smaller than or equal to each of its children.

Follow any path from the root downward and the numbers only grow. So the smallest number of all must be at the root. That's the whole trick: the answer to "what's most urgent?" is always sitting at the top.

Flip the rule ("every parent is bigger than or equal to its children") and you get a max-heap, with the biggest item on top. The code is identical except for one comparison. That's a Min-heap / max-heap pair; this page uses min-heaps unless it says otherwise.

The shape rule: no gaps

A heap also keeps its tree complete: every level is completely full before the next level starts, and the bottom level fills from left to right. It's a Complete binary tree. Like seats in a theatre that fills row by row, left to right, there's never an empty seat in the middle.

Because of that shape, a heap with n items is as flat as a tree can be. Its Height (the number of steps from the root to the bottom) is about log₂ n:

ItemsLevelsHeight (steps from root to bottom)
732
1,000109
1,000,0002019

Remember this table: every heap operation walks one path from top to bottom (or bottom to top), so it costs at most about one step per level.

The array you actually store

Number the nodes level by level, left to right: root = 0, its two children = 1 and 2, their children = 3, 4, 5, 6, and so on. Now put each node in an array at its number. Because the tree has no gaps, the array has no gaps either, and you can find relatives with simple arithmetic:

From index iFormulaExample: i = 4
parent(i − 1) / 2, rounded down(4 − 1) / 2 = 1.5 → 1
left child2i + 12·4 + 1 = 9
right child2i + 22·4 + 2 = 10

Try it below. Pick any node, in either view, and check the formulas against the picture:

One array, two views
Hover or tap any node or any array cell. The same item lights up in both views, with its parent and children.
The tree you imagine
2539648121070123456789
The array in memory
0
1
2
3
4
5
6
7
8
9
i = 1 (value 5)
parent = (i−1)/2 = (1−1)/2 = 0 (value 2)
left = 2i+1 = 3 (value 9)
right = 2i+2 = 4 (value 6)
pickedparentchildren
Parent 2 ≤ 5, and 5 ≤ its children (9, 6). That one rule, checked at every node, is the whole heap property. Note what it does not say: siblings (like 5 and 3) can be in any order.

No node stores pointers to its children. The positions are the links. That makes a heap small (just the values, nothing else) and fast: the items sit next to each other in memory, which the CPU loves (more in §11 and in CPU caches).

Foundations§03

push and pop: sift up, sift down

In one sentencepush adds the new item at the end and lets it climb up; pop takes the root, moves the last item to the top and lets it sink down. Each touches only one path through the tree.

Both operations follow the same two-step plan:

  1. Keep the shape. Only ever add or remove at the end of the array, so the tree never gets a gap.
  2. Repair the order. That step can put one item in the wrong place. Walk it up or down, swapping with a neighbour, until every parent is ≤ its children again.

push: add at the end, then sift up

Say the heap holds [3, 8, 12, 17, 25, 42, 63] and you push 5. Here's that heap as a tree; the small numbers are the array indexes:

The heap as a tree (small numbers = array indexes)
3812172542630123456
0
1
2
3
4
5
6
Index 7 (the next free slot) would be the left child of index 3, the 17.
  1. Put 5 in the next free slot: index 7, a child of index 3 (value 17).
  2. Compare with the parent: 5 is less than 17, so they're in the wrong order. Swap. 5 is now at index 3.
  3. Compare with the new parent, index 1 (value 8): 5 is less than 8. Swap. 5 is at index 1.
  4. Compare with the root (value 3): 5 isn't less than 3. Stop.

That climb is called Sift up. A new item can only be in the wrong place relative to the nodes above it, so only that one path needs checking.

pop: take the root, move the last item up, sift down

The answer to pop is always the root. The hard part is filling the hole it leaves without making a gap:

  1. Remember the root: that's what you'll return.
  2. Move the last item of the array into the root's slot and shrink the array by one. The shape is fine again; the order probably isn't, because a big number is now on top.
  3. Compare it with both children and swap it with the smaller one. (Swapping with the smaller child is what keeps the rule: the child that moves up must be ≤ its new sibling.)
  4. Repeat until it is ≤ both of its children, or has no children.

That sinking is called Sift down. You'll also see "bubble up/down" and "percolate up/down": same thing.

peek is the easy one: return array[0] without changing anything.

Try it

Every comparison and swap is shown in both the tree and the array:

Heap playground
Push and pop, and watch each comparison and swap in both views. Turn off auto-play to step by hand.
tree view7 items · height 2
3812172542630123456
array view
0
1
2
3
4
5
6
this operation
—
total (0 ops)
0 comparisons · 0 swaps
popped, in order: —
moving itembeing comparedjust swappedsettledleaving
A min-heap of 7 items. Press push to add a number (it starts at the end and climbs up) or pop to remove the smallest (the last item jumps to the top and sinks down).

Things to try:

  1. Press push( 5 ). It's the example above: two swaps, then it stops below the 3.
  2. Press push( 51 ). It compares once with its parent and stops. Pushes are often much cheaper than the worst case.
  3. Press push( 1 ). It climbs all the way to the root: one swap per level.
  4. Pop a few times. The values come out in sorted order (1, 3, 5…) even though the array is never sorted.
  5. Switch to max-heap and pop: the biggest comes out first. Only the comparison changed.

Why "log n"?

Each step of a sift moves an item one level. A heap of n items has about log₂ n levels (§02). So:

  • push does at most about log₂ n comparisons and swaps;
  • pop does at most about 2 × log₂ n comparisons (it compares the two children with each other, then the smaller one with the item) and log₂ n swaps.

For a million items that's about 20 levels: roughly 20–40 comparisons, whatever the data. In Big-O notation notation, push and pop are O(log n): the cost grows with the number of levels, not with the number of items. peek is O(1): constant, however big the heap is.

Practitioner§04

Write one: a heap in Go and JavaScript

In one sentenceA heap is about 50 lines of code: an array, a "should a come out before b?" function, and the two loops from §03.

JavaScript has no built-in heap, so in JS you either write one or install a package. Go has container/heap (§05), but it's awkward enough that many teams write the small generic version below. Writing it once yourself is also the best way to make §03 stick.

The key design choice: the heap doesn't compare items with < directly. It asks a function you pass in, usually called less(a, b): "should a come out before b?". For a min-heap of numbers that's a < b. For a max-heap it's a > b. For ER patients it's a.urgency < b.urgency. One heap, any ordering.

heap.go
package main

import (
	"cmp"
	"fmt"
)

// Heap keeps the "smallest" item (according to less) at index 0.
type Heap[T any] struct {
	items []T
	less  func(a, b T) bool // true if a should come out before b
}

func NewHeap[T any](less func(a, b T) bool) *Heap[T] {
	return &Heap[T]{less: less}
}

func (h *Heap[T]) Len() int { return len(h.items) }

// Peek returns the top item without removing it. O(1).
func (h *Heap[T]) Peek() (T, bool) {
	if len(h.items) == 0 {
		var zero T
		return zero, false
	}
	return h.items[0], true
}

// Push adds x at the end, then sifts it up. O(log n).
func (h *Heap[T]) Push(x T) {
	h.items = append(h.items, x)
	i := len(h.items) - 1
	for i > 0 {
		p := (i - 1) / 2 // parent
		if !h.less(h.items[i], h.items[p]) {
			break // parent is already smaller or equal: done
		}
		h.items[i], h.items[p] = h.items[p], h.items[i] // swap
		i = p
	}
}

// Pop removes and returns the top item. O(log n).
func (h *Heap[T]) Pop() (T, bool) {
	var zero T
	n := len(h.items)
	if n == 0 {
		return zero, false
	}
	top := h.items[0]
	h.items[0] = h.items[n-1] // move the last item to the root…
	h.items[n-1] = zero       // (clear the old slot so the GC can free it)
	h.items = h.items[:n-1]
	h.siftDown(0) // …and let it sink to its place
	return top, true
}

func (h *Heap[T]) siftDown(i int) {
	n := len(h.items)
	for {
		best := i
		l, r := 2*i+1, 2*i+2
		if l < n && h.less(h.items[l], h.items[best]) {
			best = l
		}
		if r < n && h.less(h.items[r], h.items[best]) {
			best = r
		}
		if best == i {
			return // smaller than both children (or a leaf): done
		}
		h.items[i], h.items[best] = h.items[best], h.items[i]
		i = best
	}
}

func main() {
	h := NewHeap(cmp.Less[int]) // min-heap of ints
	for _, x := range []int{5, 3, 8, 1, 9, 2} {
		h.Push(x)
	}
	top, _ := h.Peek()
	fmt.Println("peek:", top, "len:", h.Len())
	for h.Len() > 0 {
		x, _ := h.Pop()
		fmt.Print(x, " ")
	}
	fmt.Println()

	type Patient struct {
		Name    string
		Urgency int // 1 = most urgent
	}
	er := NewHeap(func(a, b Patient) bool { return a.Urgency < b.Urgency })
	er.Push(Patient{"sprained ankle", 4})
	er.Push(Patient{"heart attack", 1})
	er.Push(Patient{"broken arm", 3})
	next, _ := er.Pop()
	fmt.Println("treat next:", next.Name)
}

Output (Go 1.24; Node 22 prints the same, with size: 6 instead of len: 6):

peek: 1 len: 6
1 2 3 5 8 9
treat next: heart attack
Practitioner§05

Go's container/heap, demystified

In one sentenceWith container/heap, you write five small methods that handle a slice; the package functions heap.Push and heap.Pop do all the sifting. Mixing them up is the classic bug.

Go's standard library ships a heap in container/heap. It's older than generics (which arrived in Go 1.18), so instead of a Heap[T] type it works through an Interface: you describe your slice, the package does the algorithm.

The five methods

er.go
package main

import (
	"container/heap"
	"fmt"
)

type Patient struct {
	Name    string
	Urgency int // 1 = most urgent
}

// ER is a slice of patients that satisfies heap.Interface.
type ER []Patient

func (q ER) Len() int           { return len(q) }
func (q ER) Less(i, j int) bool { return q[i].Urgency < q[j].Urgency } // min-heap on Urgency
func (q ER) Swap(i, j int)      { q[i], q[j] = q[j], q[i] }

// Push and Pop only touch the END of the slice. container/heap does the rest.
func (q *ER) Push(x any) { *q = append(*q, x.(Patient)) }
func (q *ER) Pop() any {
	old := *q
	n := len(old)
	p := old[n-1] // the item heap.Pop already moved to the end
	*q = old[:n-1]
	return p
}

func main() {
	q := &ER{{"sprained ankle", 4}, {"broken arm", 3}, {"fever", 5}}
	heap.Init(q) // arrange the existing items into a heap: O(n), see §06

	heap.Push(q, Patient{"heart attack", 1}) // NOT q.Push(…)
	for q.Len() > 0 {
		p := heap.Pop(q).(Patient) // NOT q.Pop()
		fmt.Printf("%d %s\n", p.Urgency, p.Name)
	}
}
1 heart attack
3 broken arm
4 sprained ankle
5 fever

Here's who does what:

You writeWhat it must doWho calls it
Len()number of itemsthe package
Less(i, j)should item i come out before item j?the package, while sifting
Swap(i, j)swap items i and jthe package, while sifting
Push(x)append x to the end of the slice. Nothing else.heap.Push, before it sifts up
Pop()remove and return the last item. Nothing else.heap.Pop, after it moved the winner to the end

The confusing part: two Pops

There are two functions called Pop, and they do very different things. heap.Pop(q) is the package function: it finds the winner and repairs the heap. Your q.Pop() method just removes the last element of the slice. Step through what happens inside one heap.Pop call:

What heap.Pop(q) really does
The standard library does the heap work; your methods only touch the slice.
Code
1// container/heap (standard library)
2func Pop(h Interface) any {
3 n := h.Len() - 1
4 h.Swap(0, n) // calls YOUR Swap
5 down(h, 0, n) // calls YOUR Less and Swap
6 return h.Pop() // calls YOUR Pop
7}
8
9// your code
10func (q *ER) Pop() any {
11 old := *q
12 n := len(old)
13 p := old[n-1] // take the LAST item
14 *q = old[:n-1] // shrink the slice by one
15 return p
16}
q: your slice (index 0 → 3)
1 heart attack
3 broken arm
5 fever
4 ankle
calls into your methods
heap.Pop(q) starts
n = Len()−1 = 3
returned to you
nothing yet
You call heap.Pop(q). The slice is a valid min-heap: the most urgent patient (1, heart attack) is at index 0. n is the index of the last item: 3.
1 / 6

So the rule is short: always call heap.Push(q, x) and heap.Pop(q). Your own Push and Pop methods exist only for the package to call. If you call q.Pop() yourself you get whatever happens to be at the end of the slice: in this example, sprained ankle instead of heart attack. Nothing crashes; the answers are just wrong.

The other package functions

  • heap.Init(q) turns a slice in any order into a heap, in O(n) (§06).
  • heap.Fix(q, i) re-sifts item i after you changed its priority; heap.Remove(q, i) removes the item at index i. Both are O(log n), and both need you to know where the item is (§07).
  • Peek has no function: the top is simply (*q)[0], as long as q.Len() > 0. q is a pointer to the slice; *q follows the pointer to the slice itself, and the parentheses make Go do that first, then take index 0. (*q[0] would mean "index the pointer, then follow", which doesn't compile.)
Practitioner§06

Building a heap in O(n), and heapsort

In one sentenceIf you already have all the items, don't push them one by one: sift down every parent, from the bottom up. It costs O(n) instead of O(n log n).

Sometimes the data is all there at the start: a list of jobs loaded from a file, every number in an array. You could push them one at a time: n pushes × up to log₂ n levels each = O(n log n). There's a faster way, called Heapify (or "build-heap"), and it's what Go's heap.Init does.

The idea: fix small heaps, then join them

  1. Put the items in the array in whatever order they came. The shape is already right; only the order is wrong.
  2. Every leaf is already a valid heap on its own: a single item can't break the rule. In a complete tree, about half of all nodes are leaves (index n/2, rounded down, onward), so they need no work.
  3. Go backwards through the remaining nodes, from the last parent (index n/2 − 1, with n/2 rounded down; 6 for 15 items) to the root. At each one, its two subtrees (a child plus everything below it) are already valid heaps (you fixed them earlier). Only the node itself can be out of place, so sift it down (§03). Now its whole subtree is a heap.
  4. When you reach index 0, the whole array is a heap.

Step through it on 15 numbers. Faded nodes are parents not handled yet:

Heapify, bottom-up
Turn 15 unsorted numbers into a min-heap in place. Faded nodes are parents not handled yet.
tree view0 comparisons · 0 swaps so far
9471826350111013121401234567891011121314
array view
0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
The input, in whatever order it arrived. Indexes 7…14 are leaves (no children). A single item is already a valid heap, so there is nothing to do for them: about half the work is free.
1 / 33
n (input in descending order, the worst case for pushing)push one by oneheapify
1534 comparisons22 comparisons
1,0007,987 comparisons1,982 comparisons
100,0001,468,946 comparisons199,978 comparisons
Computed live in your browser. Heapify needs about 2 comparisons (and fewer than 1 swap) per item however big n gets; pushing one by one grows like n·log₂ n.

Why it's linear: most nodes barely move

The cost of sifting a node down is at most its distance to the bottom. Look at where the nodes are in that 15-item tree:

LevelNodesMax levels each can sinkMax swaps
bottom (leaves)800
one above414
two above224
root133
total1511

The many nodes near the bottom can barely move; only the few near the top can move far. Pushing one by one is the opposite: the many late arrivals land at the bottom and may have to climb the whole height.

For any size, the sum works out the same way: n/4 nodes × 1 + n/8 × 2 + n/16 × 3 + … which adds up to less than n swaps in total: fewer than 1 swap per item on average. Each level of sinking costs 2 comparisons, so that's also about 2 comparisons per item, however big n gets. So heapify is O(n), and the table under the stepper shows it: about 2n comparisons for heapify, while pushing one by one grows much faster.

Heapsort, briefly

Heapify gives you a sorting algorithm almost for free. With a max-heap:

  1. Heapify the array: O(n).
  2. Swap the root (the biggest) with the last item. The biggest is now in its final place at the end.
  3. Treat the array as one item shorter, and sift the new root down.
  4. Repeat until one item is left. The array is now sorted from smallest to largest.

That's Heapsort: O(n log n) in every case, and no extra memory because it works in place, inside the array itself. In practice it's slower than the built-in sorts, mostly because it jumps around the array (bad for the CPU cache), and it isn't Stable: equal items can change order.

You'll rarely write it, but it's inside your language: Go's slices.Sort and sort.Sort use a quicksort variant (pdqsort): it splits the array around a chosen pivot value, smaller items to one side and bigger to the other. If its pivot choices keep turning out badly, it switches to heapsort, so the worst case stays O(n log n). (In the Go 1.24 and 1.26 source, look for heapSortOrdered in slices/zsortordered.go.)

Practitioner§07

Changing priorities: Fix, Remove, lazy deletion, tie-breaks

In one sentenceA heap can only repair an item if it knows where it is. Either track every item's index (then heap.Fix it), or don't touch it at all and skip it later (lazy deletion).

Real priority queues change while they wait. A patient's condition gets worse. A background job becomes urgent because the disk is nearly full. A timer is cancelled because the response arrived. All of these need to change or remove an item that is somewhere in the middle of the heap.

Finding an item by scanning the array would cost O(n) and defeat the point of the heap. There are two standard fixes.

Option 1: track the index, then heap.Fix

Give every item an index field and keep it correct inside Swap. To change a priority, edit the item, then call heap.Fix(q, item.index): it sifts the item up or down to its new place in O(log n). That's the Decrease-key / update operation.

jobs.go
package main

import (
	"container/heap"
	"fmt"
)

type Job struct {
	Name     string
	Priority int    // lower = more urgent
	seq      uint64 // arrival order: breaks ties, oldest first
	index    int    // where this job sits in the slice right now
}

type JobQueue struct {
	jobs    []*Job
	nextSeq uint64
}

func (q *JobQueue) Len() int { return len(q.jobs) }
func (q *JobQueue) Less(i, j int) bool {
	a, b := q.jobs[i], q.jobs[j]
	if a.Priority != b.Priority {
		return a.Priority < b.Priority
	}
	return a.seq < b.seq // same priority → first come, first served
}
func (q *JobQueue) Swap(i, j int) {
	q.jobs[i], q.jobs[j] = q.jobs[j], q.jobs[i]
	q.jobs[i].index = i // ← keep every job's index up to date
	q.jobs[j].index = j
}
func (q *JobQueue) Push(x any) {
	j := x.(*Job)
	j.index = len(q.jobs)
	j.seq = q.nextSeq
	q.nextSeq++
	q.jobs = append(q.jobs, j)
}
func (q *JobQueue) Pop() any {
	n := len(q.jobs)
	j := q.jobs[n-1]
	q.jobs[n-1] = nil // let the GC free it
	q.jobs = q.jobs[:n-1]
	j.index = -1 // no longer in the heap
	return j
}

// Update changes a job's priority and moves it to its new place: O(log n).
func (q *JobQueue) Update(j *Job, priority int) {
	if j.index < 0 {
		return // -1: already popped, no longer in the queue
	}
	j.Priority = priority
	heap.Fix(q, j.index)
}

func main() {
	q := &JobQueue{}
	backup := &Job{Name: "nightly backup", Priority: 5}
	for _, j := range []*Job{
		{Name: "send email", Priority: 2},
		backup,
		{Name: "resize image", Priority: 2},
		{Name: "charge card", Priority: 1},
	} {
		heap.Push(q, j)
	}

	q.Update(backup, 0) // disk almost full: the backup is urgent now

	for q.Len() > 0 {
		j := heap.Pop(q).(*Job)
		fmt.Printf("p%d %s\n", j.Priority, j.Name)
	}
}
p0 nightly backup
p1 charge card
p2 send email
p2 resize image

Three details make this work:

  • The heap stores pointers ([]*Job). The caller keeps the same *Job (here backup) and can hand it to Update later; its index field always says where it is.
  • Swap updates index on both jobs. Forget this and heap.Fix will repair the wrong slot, silently (§12).
  • heap.Remove(q, j.index) deletes a job from the middle the same way: it swaps it to the end, repairs the heap, and calls your Pop.
  • Pop sets index = -1, meaning "no longer queued". That's why Update checks for it: without the check, updating a job that was already popped would call heap.Fix(q, -1), and heap.Remove(q, -1) panics with index out of range [-1]. Check j.index >= 0 before either.

Option 2: lazy deletion

Sometimes you only need to cancel items, and cancelling happens a lot: most request timeouts are cancelled because the response arrived in time. Instead of finding the item, just mark it as cancelled, and throw it away when it reaches the top. That's Lazy deletion:

timer-queue.mjs
// Uses the MinHeap class from §04.
class TimerQueue {
  #heap = new MinHeap((a, b) => a.at < b.at);
  add(id, at) {
    const t = { id, at, cancelled: false };
    this.#heap.push(t);
    return t; // the handle: keep it if you might cancel later
  }
  cancel(t) { t.cancelled = true; } // O(1): no searching the heap
  popDue(now) {
    const due = [];
    while (this.#heap.size > 0 && this.#heap.peek().at <= now) {
      const t = this.#heap.pop();
      if (t.cancelled) continue; // cancelled: throw it away now
      due.push(t.id);
    }
    return due;
  }
}

const tq = new TimerQueue();
tq.add("retry", 100);
const ping = tq.add("ping", 150);
tq.add("timeout", 200);
tq.cancel(ping);
console.log(tq.popDue(250)); // [ 'retry', 'timeout' ]

Cancelling is now O(1), and no index tracking is needed. Notice that cancel takes the entry object that add returned (a "handle"), not the name "ping". Names get reused: a "retry" timer that already fired, or a request id that comes back later. If you cancelled by name and kept a list of cancelled names, an old cancel could silently swallow a brand-new timer with the same name. A flag on the entry itself only ever affects that one entry. (setTimeout works the same way: it returns a handle you pass to clearTimeout.)

The price is memory: cancelled items stay in the heap until they reach the top. If you cancel far more than you fire, the heap fills with dead entries. The usual guard is to rebuild the heap (heapify, §06) from only the live items once the dead ones pass some fraction of it. Go's own Runtime does exactly this with its timers (§10).

Equal priorities: heaps are not first-come, first-served

A heap doesn't promise any order between equal items. Push five jobs with priority 2 (A, B, C, D, E), then one with priority 1 (X), into the MinHeap from §04, comparing only priority. They came out in this order (Node 22):

no tie-break:   X C E D A B
with seq:       X A B C D E

X is first, as it should be, but the five equal jobs got shuffled by the swaps. That's not a bug in the heap: heaps simply aren't stable. If the order matters (and in a job queue, it usually does: people expect fairness), add an increasing sequence number to every item and use it to break ties, exactly like seq in the Go code above:

let seq = 0;
const q = new MinHeap((a, b) => a.prio !== b.prio ? a.prio < b.prio : a.seq < b.seq);
for (const name of ["A", "B", "C", "D", "E"]) q.push({ name, prio: 2, seq: seq++ });

Why not use the arrival time instead? Date.now() only counts milliseconds, so two items often share one, and even nanosecond clocks can return the same value twice. A counter never repeats.

Practitioner§08

Top-K: the biggest few out of millions

In one sentenceTo find the K biggest items in a stream, keep a min-heap of K items. Its root is the weakest of your current top K, the one every newcomer has to beat.

"Show the 10 slowest requests from today's logs." "Top 100 products by sales." "The 5 users with the most failed logins." These are all Top-K questions. The obvious answer is to sort everything and take the first K. It works, but it holds all the data in memory and does O(n log n) work to put a billion items in order when you only wanted 10 of them.

Why a min-heap for the biggest items?

It sounds backwards, so think of a club with K chairs. When someone new arrives, the only question is "are you better than the worst person currently sitting?" If yes, the worst person leaves and the newcomer sits down. If no, the newcomer goes home.

So you need instant access to the worst member of the top K, and for "biggest", the worst is the smallest. A min-heap puts exactly that item at the root.

A worked example

Stream: 5, 1, 9, 3, 7, 8, 2, 6, K = 3.

ArrivesRoot before (the one to beat)What happensHeap now holds (listed sorted; the array order may differ)
5, 1, 9(filling up)fewer than 3 items: just push1, 5, 9
313 is bigger than 1 → pop 1, push 33, 5, 9
737 is bigger than 3 → pop 3, push 75, 7, 9
858 is bigger than 5 → pop 5, push 87, 8, 9
272 isn't bigger than 7 → skip after 1 comparison7, 8, 9
676 isn't bigger than 7 → skip7, 8, 9

Answer: 9, 8, 7. Now try it on 20 numbers, with K = 3 or 5:

Top-K over a stream
Find the K biggest of 20 numbers while holding only K of them.
the stream0 comparisons so far
69772262959724690237874662473089297572
min-heap of size ≤ 3root = number to beat
empty
same heap, as an array
[ ] empty array
Numbers arrive one at a time. We keep a min-heap of at most 3: the 3 biggest seen so far. Its root is the weakest of them, the number to beat.

The code

topk.go
// TopK returns the k largest numbers, biggest first. Uses Heap[T] from §04.
func TopK(stream []int, k int) []int {
	if k <= 0 {
		return nil
	}
	h := NewHeap(cmp.Less[int]) // MIN-heap: the root is the weakest of the current top k
	for _, x := range stream {
		if h.Len() < k {
			h.Push(x) // still filling up
		} else if smallest, _ := h.Peek(); x > smallest {
			h.Pop()   // kick out the weakest…
			h.Push(x) // …and let x in
		} // else: x can't be in the top k; skip it after one comparison
	}
	out := make([]int, 0, h.Len())
	for h.Len() > 0 {
		x, _ := h.Pop()
		out = append(out, x) // comes out smallest first
	}
	slices.Reverse(out)
	return out
}

// fmt.Println(TopK([]int{5, 1, 9, 3, 7, 8, 2, 6}, 3))  →  [9 8 7]

What it costs

Every item costs one comparison with the root. Items that get in also cost a pop and a push, each about log₂ K steps. So the total is O(n log K) time and O(K) memory. For K = 10, log₂ K is about 3, so it's almost one pass over the data. Sorting is O(n log n) time and needs every item in memory at once.

Measured on this machine (a shared 4-core VM, Go 1.24, numbers vary run to run): the top 10 of 1,000,000 random ints took about 0.6–1.7 ms with a 10-item heap, versus 75–150 ms to copy and sort the whole slice with slices.Sort. Roughly 100× faster, and the heap version could have read the numbers straight from a file without storing them.

Want the K smallest instead? Flip everything: a max-heap of size K, and a newcomer gets in if it's smaller than the root.

Practitioner§09

Merging K sorted lists, and the running median

In one sentenceA heap is the right tool whenever you repeatedly need "the smallest of a few candidates", like the next item across K sorted lists, or the middle of a growing pile of numbers.

Merging K sorted lists

You have K lists, each already sorted. Maybe they're log files from K servers, each in time order, or K sorted files written by a database. You want one sorted list.

The next item of the output must be the smallest of the K front items. So put one item from the front of each list into a min-heap. Pop the smallest, write it out, and push the next item from the same list. The heap never holds more than K items. That's a K-way merge:

merge.go
type cursor struct {
	val  int
	list int // which input list
	pos  int // index inside that list
}

// MergeK merges k sorted slices into one sorted slice. O(n log k). Uses Heap[T] from §04.
func MergeK(lists [][]int) []int {
	h := NewHeap(func(a, b cursor) bool { return a.val < b.val })
	for i, l := range lists {
		if len(l) > 0 {
			h.Push(cursor{l[0], i, 0}) // the front of every list
		}
	}
	var out []int
	for h.Len() > 0 {
		c, _ := h.Pop() // smallest front item among all lists
		out = append(out, c.val)
		if next := c.pos + 1; next < len(lists[c.list]) {
			h.Push(cursor{lists[c.list][next], c.list, next}) // refill from the same list
		}
	}
	return out
}

// MergeK([][]int{{1, 4, 9}, {2, 3, 10}, {5, 6}})  →  [1 2 3 4 5 6 9 10]

Each of the n items is pushed once and popped once, and the heap holds at most K items, so the total is O(n log K). With K = 2 you don't need a heap at all (just compare the two fronts). Heaps start to pay off around K = 4 to 8; with K = 100 files, picking the smallest by scanning all 100 fronts would cost about 100 comparisons per item, while a heap of 100 is only about 7 levels deep.

This loop runs inside a lot of software:

  • LSM databases (RocksDB, Cassandra, …) merge sorted files in the background. That's compaction, and with many input files it's a heap-driven K-way merge.
  • External sorting: to sort 100 GB with 1 GB of RAM, sort 100 chunks of 1 GB one at a time, write each to disk, then K-way merge the 100 sorted files.
  • Merging results from K shards or K servers, each already sorted by time or score.

The running median

The median is the middle value: half the numbers are below it, half above. Hospitals, payment systems and dashboards often want "median waiting time so far", updated every time a new number arrives. Sorting after every number would be wasteful.

The trick uses two heaps that split the numbers into halves:

  • low, a max-heap holding the smaller half. Its top is the biggest of the small numbers.
  • high, a min-heap holding the bigger half. Its top is the smallest of the big numbers.

Keep them balanced, so low has the same number of items as high or one more. Then the median is always at the tops: the top of low if the count is odd, or the average of both tops if it's even. Step through it:

Running median with two heaps
Each heap is drawn as its items in pop order; only the highlighted top is ever looked at.
seen so far (sorted): —
low · max-heap0 items
empty
median
—
high · min-heap0 items
empty
Two heaps split the numbers seen so far in half. low is a max-heap holding the smaller half (its top is the biggest of the small ones). high is a min-heap holding the bigger half (its top is the smallest of the big ones). The median sits right between the two tops.
0 / 10 numbers
running-median.mjs
// Uses the MinHeap class from §04. Passing (a, b) => a > b turns it into a max-heap.
class RunningMedian {
  #low = new MinHeap((a, b) => a > b);  // MAX-heap: the smaller half
  #high = new MinHeap((a, b) => a < b); // MIN-heap: the bigger half

  add(x) {
    if (this.#low.size === 0 || x <= this.#low.peek()) this.#low.push(x);
    else this.#high.push(x);
    // Rebalance so low has the same size as high, or one more.
    if (this.#low.size > this.#high.size + 1) this.#high.push(this.#low.pop());
    else if (this.#high.size > this.#low.size) this.#low.push(this.#high.pop());
  }

  median() {
    if (this.#low.size === 0) return undefined;
    if (this.#low.size > this.#high.size) return this.#low.peek();
    return (this.#low.peek() + this.#high.peek()) / 2;
  }
}

const m = new RunningMedian();
const seen = [];
for (const x of [5, 15, 1, 3, 8, 7]) {
  m.add(x);
  seen.push(m.median());
}
console.log(seen.join(" ")); // 5 10 5 4 5 6

Each add is a push plus at most one pop and one push: O(log n). Reading the median is O(1).

Practitioner§10

Heaps in the wild: timers, schedulers, shortest paths

In one sentenceEvery timer system has to answer one question, over and over: which deadline is soonest? That is a min-heap keyed by time.

Timers: a min-heap of deadlines

A busy server can have tens of thousands of timers at once: request timeouts, retries, keep-alive pings, setTimeout calls, time.AfterFunc callbacks (Go's "run this function after d"). The runtime doesn't check every timer on every tick. It needs just two things:

  1. When is the next one due? That's a peek: sleep exactly until then.
  2. Fire everything that's due. That's "pop while the top's deadline ≤ now".

Adding a timer is a push. That's precisely what a min-heap ordered by deadline is good at.

Go: one timer heap per P

In Go, each P has its own timer heap. A P is one of the GOMAXPROCS "slots" in Go's scheduler that a thread must hold to run goroutines. Splitting timers per P means goroutines on different CPU cores rarely fight over one shared heap. When the scheduler looks for work, it also checks its P's heap and runs any timers that are due.

Two details from the Runtime source (runtime/time.go, Go 1.24 and 1.26):

  • The heap is 4-ary, not binary: const timerHeapN = 4. Each node has four children, so the heap is half as deep. §11 explains why.
  • Stopped timers are not dug out of the heap right away. They're marked and counted as "zombies" and skipped when they reach the top: §07's lazy deletion. When zombies make up more than a quarter of a P's heap, the runtime cleans them out in one go.

(Go 1.23 also changed how time.Timer channels work and made unreferenced timers collectable by the garbage collector. The heap underneath stayed.)

Node.js: a heap of lists, on top of libuv's heap

Node's timers are layered:

  • At the bottom, libuv, the C library that runs Node's Event loop, keeps its own timers in a min-heap ordered by due time. When two timers are due at the same moment, it breaks the tie with a sequence number, just like §07. Each turn of the loop, it sleeps until the soonest timer is due (or I/O arrives), then runs the due ones.
  • On top, Node's JavaScript layer groups your setTimeout calls into one list per duration: all 100 ms timers in one list, all 5000 ms timers in another. Timers in one list are added in time order, so each list is already sorted by due time. Node then keeps a priority queue of those lists, ordered by each list's next due time. You can see it in Node 22: node --expose-internals exposes require('internal/timers').timerListQueue, an instance of the internal PriorityQueue.

Node's internal PriorityQueue is a binary heap with two twists: it's 1-based (the root is at index 1, so children are at 2i and 2i+1), and it accepts a setPosition callback so each list always knows its index, which is §07's index-tracking trick for re-sifting a list when its next due time changes. To see where timers fit into each turn of the loop, see the event loop, step by step.

Job schedulers: priority, deadline, or both

A worker pool that serves "the most important job next" is a priority queue with workers taking from it (see worker pools: priority and fairness). Typical keys:

Order byThe heap keyGood for
Priority(priority, seq)Paid users before free users, interactive before batch
Deadline(deadline, seq)"Earliest deadline first": requests that time out soonest go first
Scheduled time(runAt, seq)Delayed jobs: "send this email in 10 minutes", retries with backoff

The seq tie-breaker is §07's sequence number: same priority, first come, first served. One honest note: not every scheduler is a heap. Linux's CPU scheduler, for example, keeps runnable tasks in a balanced binary search tree (a fully sorted tree that stays shallow; see Trees), because it also needs ordered walks and removals from the middle.

Shortest paths: Dijkstra's algorithm

How does a maps app find the fastest route? The classic answer is Dijkstra's algorithm. It keeps a priority queue of places to visit, keyed by "best distance found so far", and always expands the closest unfinished place next. Every time it finds a shorter road to a place, it lowers that place's key: a decrease-key (§07), or with lazy deletion, push a new entry and skip the stale one later.

With a binary heap, Dijkstra runs in O((V + E) log V) for V places and E roads, instead of O(V²) with a plain list. The full algorithm, with a step-through, lives in Graphs: Dijkstra. Other graph algorithms use the same heap-driven loop: A* search (Dijkstra plus a guess of the remaining distance, so it heads toward the goal first) and Prim's algorithm (connect all places with the cheapest total road length by always adding the cheapest next road).

Staff-level§11

Staff-level: cache misses, d-ary heaps, starvation, timer wheels

In one sentenceBig-O says every heap is O(log n). In production, what matters is how many cache misses each level costs, who never gets served, and whether you need a heap at all.

Memory: each level is a jump

The array layout from §02 has no pointers, which is good. But a sift doesn't walk the array in order: from index i it jumps to 2i+1, then to about 4i, then 8i. Near the top of the heap those indexes are close together. Deeper down, each jump lands far away in memory, usually on a different Cache line (the 64-byte chunk the CPU loads at a time). Once the heap is bigger than the CPU cache, every level near the bottom can cost a trip to RAM, about 100 ns. It's the same effect described in CPU caches: pointer chasing, just milder.

d-ary heaps: fewer levels, wider nodes

A d-ary heap gives each node d children instead of 2. The index math generalises: children of i are at d·i + 1 … d·i + d, and the parent of i is (i − 1) / d, rounded down.

Same 21 items: binary heap vs 4-ary heap
binary (d = 2): 5 levels4-ary (d = 4): 3 levels
Left: 2 children per node, 5 levels, so a sift-down makes up to 4 hops. Right: 4 children per node, 3 levels, only 2 hops, but each hop compares 4 siblings, which sit next to each other in the array (often in one cache line).

The trade-off:

  • Fewer levels. log₄ n is half of log₂ n: a million items is 10 levels instead of 20. Push (sift up) compares only with the parent, so it gets about twice as cheap.
  • More comparisons per level on pop. Sift-down must find the smallest of 4 children: 3 comparisons plus 1 against the item, instead of 2. But the 4 siblings sit next to each other in the array, usually in the same cache line, so those extra comparisons are cheap while the saved levels are expensive jumps.

That's why Go's runtime timer heap is 4-ary (§10): timers are pushed constantly (every timeout, every time.Sleep) and most are cancelled before they fire, so cheap pushes matter most.

What we measured. On this machine (shared 4-core VM, Go 1.24), pushing 1,000,000 random ints into a heap and then popping them all cost about 87–91 ns per operation with a binary heap and 81–86 ns with a 4-ary heap: roughly 5% faster. An 8-ary heap was slower than both. At 1,000 items (which fit in the cache) all of them took about 30 ns. Runs taken while the VM was busy swung by 2×, which swamps a 5% difference. That's the honest lesson: d-ary heaps help most with big heaps, push-heavy use and larger items, but the win is modest. Measure with a Benchmark on your own data before you switch.

Starvation and priority inversion

A priority queue does exactly what you ask: the most urgent item always wins. Under sustained load, that becomes a problem.

  • Starvation. If urgent jobs keep arriving, a low-priority job waits forever. It isn't slow; it never runs. The usual fix is Aging: the longer an item waits, the more its effective priority improves. With a heap you can't cheaply re-score everything every second, so a common trick is to order by a virtual deadline, arrivalTime + allowance(priority): high priority gets a small allowance, low priority a big one. A low-priority job's deadline eventually becomes the earliest in the queue, so it must run. Other options: reserve a share of workers for low priority, or take one low-priority job after every N urgent ones (more in worker pools).
  • Priority inversion. A high-priority task waits for a lock held by a low-priority task, and medium-priority tasks keep the low one from running, so the high one waits behind the medium ones. Priorities have flipped. Operating systems fix this with priority inheritance: the lock holder temporarily borrows the waiter's priority. In application code the lesson is: priority only protects work that never waits on lower-priority work. A "high-priority" request that needs a connection from a pool busy with batch jobs isn't high priority any more.

Timer wheels: when there are millions of timeouts

A heap costs O(log n) per timer added. With millions of connections, each with idle, read and write timeouts that are reset on every packet, that adds up. Most of those timeouts are cancelled before they fire, and they rarely need exact precision.

A Timer wheel drops the ordering entirely:

A timer wheel: buckets instead of a heap
01234567891011nowslot 1 →t1slot 3 →t2t3slot 4 →t4slot 8 →t5t6t7each slot holds an unsorted list of timers
Each slot is one tick (say 10 ms). Adding a timer = drop it in the slot for its tick: O(1). Cancelling = unlink it: O(1). Every tick the hand moves one slot and fires whatever hangs there. Timers further away than one turn go in a coarser second wheel (hours/minutes/seconds style), or remember how many turns they still need.

Adding and cancelling are O(1): compute the slot, add to or unlink from that slot's list. The price is precision: a timer fires on the tick, not at the exact nanosecond. Timers far in the future need coarser wheels on top (like the hour and minute hands of a clock); that's a hierarchical timing wheel. The Linux kernel's timer wheel and Kafka's request purgatory use them; so does Netty's HashedWheelTimer for network timeouts. Rule of thumb: heap for exact timers in the thousands; wheel for approximate timeouts in the millions.

When not to use a heap

  • Small n. With a few dozen items, a sorted slice (binary search, then insert) or even a linear scan is simple and fast. On this machine, with 16 items, a sorted slice and a heap both took around 30–60 ns per pop+insert, which is far too small to matter. At 10,000 items, the sorted slice took about 1.3–2.2 µs versus 85–220 ns for the heap: 10× or more. Pick the simplest code until n says otherwise.
  • You need more than the minimum. Iterating in order, finding an arbitrary key, range queries ("all jobs due between 10:00 and 10:05"), or "the second-best" cheaply: use a balanced search tree or a sorted structure (see Trees: choosing a sorted structure). A heap only knows its top.
  • Everything has the same few priorities. With, say, 3 priority levels, use 3 plain FIFO queues and always serve the highest non-empty one. That's O(1) per operation and first-come, first-served for free.
  • Many goroutines or threads share it. A heap is not safe for concurrent use: two pushes at once can corrupt the array. Protect it with a Lock, or give it one owner goroutine fed by a channel. If that lock becomes a bottleneck, shard it: one heap per worker or per P, like Go's timers, with Work stealing when one runs dry. See locks for the basics.
Practitioner§12

Pitfalls gallery

#1

Calling your own Pop instead of heap.Pop

With container/heap, your Push and Pop methods only append to and remove from the end of the slice. Calling them directly skips all the heap work (§05). Nothing crashes; you just get the wrong item.

✕ Don't
q.Push(Patient{"heart attack", 1}) // just appended, never sifted up
p := q.Pop().(Patient)             // whatever is last in the slice
✓ Do
heap.Push(q, Patient{"heart attack", 1})
p := heap.Pop(q).(Patient) // the most urgent patient
#2

Changing a priority without telling the heap

If you edit the field that Less reads while the item sits in the heap, the heap property breaks silently. Later pops return items in the wrong order, and nothing tells you.

✕ Don't
job.Priority = 0 // the heap doesn't know
✓ Do
job.Priority = 0
heap.Fix(q, job.index) // move it to its new place

In JavaScript the same applies: never change an object's priority while it's inside your MinHeap. Remove and re-add it, or use lazy deletion (§07).

#3

Forgetting to update the index in Swap

Index tracking (§07) only works if every move updates the index. Swap must set it on both items, Push must set it on the new item, and Pop should set it to -1. Miss one and heap.Fix(q, job.index) repairs some other item's slot, so the bug shows up much later, somewhere else.

#4

Passing a sort comparator where a “less” function is expected

Array.prototype.sort wants a comparator that returns a number ((a, b) => a - b). The MinHeap in §04 wants a boolean "should a come first?". Mix them up and every pair of different numbers looks "less" (any non-zero number is truthy), so the heap is garbage:

✕ Don't
const h = new MinHeap((a, b) => a - b); // number, not boolean
// pushing 5 3 8 1 9 2 and popping gives: 2 9 3 8 1 5
✓ Do
const h = new MinHeap((a, b) => a < b); // boolean
// pushing 5 3 8 1 9 2 and popping gives: 1 2 3 5 8 9

Know which contract your heap library uses. Node's internal one, and many npm packages, take a sort-style comparator; others take less. Go's container/heap uses Less(i, j) bool.

#5

Treating the array as sorted

Only index 0 is guaranteed. heap[1] is not the second smallest (it might be heap[2]), and looping over the array does not visit items in priority order. To see the top 3, pop 3 times (and push them back if needed), or copy and sort.

#6

Expecting first-come, first-served

Equal priorities come out in an order you didn't choose (§07: X C E D A B). If fairness matters, add a sequence number as a tie-breaker. Don't use timestamps: two items can share one.

#7

Using the wrong heap for top-K

To keep the K biggest, use a min-heap of size K, so the root is the one to evict (§08). A max-heap of everything works, but stores all n items; a max-heap of size K puts the wrong item on top, so you can't tell which one to throw out.

#8

Lazy deletion without a cleanup

Cancelled entries stay in the heap until they reach the top. If you schedule a 30-second timeout per request and cancel 99% of them after 50 ms, the heap holds up to 30 seconds' worth of dead entries. Count them, and rebuild the heap from the live entries when they pass a threshold. Go's runtime cleans up when a quarter of a P's timers are dead (§10).

#9

Sharing one heap between goroutines without a lock

A push and a pop running at the same time both move items around the same array: a Data race that can lose items or corrupt the order. Neither container/heap nor the §04 Heap[T] is safe for concurrent use. Wrap every operation in a sync.Mutex, or let one goroutine own the heap and talk to it through channels. (In JavaScript this can't happen with a normal MinHeap: your code runs on one thread, and objects sent to worker threads are copied, not shared. Only a heap built on a SharedArrayBuffer, memory that several workers really do share, could race.)

Practitioner§13

Check yourself

Predict the answer before you click. Every question is covered above: Q1–Q2 in §02, Q3 in §05, Q4 in §08, Q5 in §06, Q6 in §07.

QUESTION 1 / 6
Where are the children?

A heap is stored in an array (0-based). The item at index 4 has children at which indexes?

QUESTION 2 / 6
Is this a min-heap?

The array is [1, 4, 2, 5, 3, 6].

QUESTION 3 / 6
The forgotten package name
q := &ER{}             // heap.Interface from §05
heap.Push(q, Patient{"fever", 5})
heap.Push(q, Patient{"heart attack", 1})
heap.Push(q, Patient{"broken arm", 3})
p := q.Pop().(Patient) // ← note: q.Pop, not heap.Pop
fmt.Println(p.Name)
QUESTION 4 / 6
Top 10 of a billion

You need the 10 slowest requests out of 1,000,000,000 log lines, streamed from disk. What do you keep in memory?

QUESTION 5 / 6
Building from a list

You already have 1,000,000 unsorted numbers in an array and want a heap of them. Which is true?

QUESTION 6 / 6
Same priority, which first?

A min-heap ordered only by priority receives jobs A, B, C, D, E (all priority 2) and then X (priority 1). In what order do they come out?

Answered 0 / 6
Foundations§14

Cheat sheet

🏥
One promiseA heap always knows its smallest (min-heap) or biggest (max-heap) item. It is not sorted; siblings can be in any order.
🌳
Tree in an arrayComplete tree, stored level by level. Parent of i: (i−1)/2, rounded down (>> 1 in JS). Children: 2i+1, 2i+2.
⬆️
push = sift upAppend at the end, swap with the parent while smaller. O(log n).
⬇️
pop = sift downTake the root, move the last item to the top, swap with the smaller child until it fits. O(log n).
🧱
Build in O(n)Heapify: sift down every parent from n/2−1 back to 0. Go: heap.Init.
🐹
Go's container/heapYour methods: Len, Less, Swap, Push (append), Pop (remove last). You call: heap.Push, heap.Pop, heap.Fix, heap.Remove.
🏆
Top-K biggestMin-heap of size K; a newcomer replaces the root if it's bigger. O(n log K) time, O(K) memory.
⏱️
TimersMin-heap by deadline: peek to know how long to sleep, pop everything due. Lazy-delete cancelled timers.
OperationBinary heapNotes
peek (min)O(1)items[0]
pushO(log n)often less: most new items stop after a few levels
pop (min)O(log n)about 2 comparisons per level
build from n itemsO(n)heapify, not n pushes
change priority / remove item iO(log n)needs the index: heap.Fix, heap.Remove
find an arbitrary itemO(n)not what heaps are for: track indexes or use lazy deletion
top K of nO(n log K)min-heap of size K
merge K sorted lists (n items)O(n log K)heap of the K front items
I want to…Use
Always serve the most urgent nextA min-heap keyed by (priority, seq)
The same, with few fixed priority levelsOne FIFO queue per level
Timeouts by the million, approximate is fineA timer wheel
Ordered iteration, range queries, searchA balanced tree or sorted structure, not a heap
Fewer than ~100 itemsA sorted slice or a scan: simplest wins
Share across goroutinesHeap + sync.Mutex, or one owner goroutine
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in Data Structures & AlgorithmsGraphs
Read →