11. Heaps and Priority Queues
📋 Jump to TakeawaysA heap answers one question efficiently: what's the smallest (or largest) element right now? Not "find element X" — hash maps do that. Not "give me everything sorted" — sorting does that. Just "give me the extreme value, and let me add/remove elements while maintaining that guarantee." That's O(log n) per operation instead of O(n).
The Structure
A heap is a complete binary tree stored as an array. The heap property: every parent is smaller than (min-heap) or larger than (max-heap) its children.
Min-heap as tree: As array:
1 [1, 3, 2, 7, 4, 5, 6]
/ \
3 2 Parent of i: (i-1)/2
/ \ / \ Left child: 2*i + 1
7 4 5 6 Right child: 2*i + 2No pointers needed. The tree structure is implicit in the array indices. This makes heaps cache-friendly and memory-efficient.
Operations
Push — add to the end, sift up until heap property is restored. O(log n).
Pop — remove the root (min/max), move the last element to root, sift down. O(log n).
Peek — return the root. O(1).
Sift Up and Sift Down
These are the two fundamental heap operations. Everything else is built on them.
// Sift up — after inserting at the end, bubble the element up
func siftUp(heap []int, i int) {
for i > 0 {
parent := (i - 1) / 2
if heap[i] >= heap[parent] {
break // heap property satisfied
}
heap[i], heap[parent] = heap[parent], heap[i]
i = parent
}
}
// Sift down — after removing root, bubble the replacement down
func siftDown(heap []int, i, size int) {
for {
smallest := i
left := 2*i + 1
right := 2*i + 2
if left < size && heap[left] < heap[smallest] {
smallest = left
}
if right < size && heap[right] < heap[smallest] {
smallest = right
}
if smallest == i {
break // heap property satisfied
}
heap[i], heap[smallest] = heap[smallest], heap[i]
i = smallest
}
}Sift up compares with parent and swaps upward. Sift down compares with both children and swaps with the smaller one. Both are O(log n) because the tree height is log n.
Building a Heap is O(n)
Calling push n times is O(n log n). But building a heap from an existing array is O(n) using sift down from the middle:
func buildHeap(nums []int) {
n := len(nums)
// Start from last non-leaf node, sift down each
for i := n/2 - 1; i >= 0; i-- {
siftDown(nums, i, n)
}
}Why O(n) and not O(n log n)? Most nodes are near the bottom. Leaves (half the nodes) don't sift at all. Nodes one level up sift at most 1 step. Only the root sifts log n steps. The sum converges to O(n).
import "container/heap"
// Go's container/heap interface
type IntHeap []int
func (h IntHeap) Len() int { return len(h) }
func (h IntHeap) Less(i, j int) bool { return h[i] < h[j] } // min-heap
func (h IntHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *IntHeap) Push(x any) { *h = append(*h, x.(int)) }
func (h *IntHeap) Pop() any {
old := *h
n := len(old)
x := old[n-1]
*h = old[:n-1]
return x
}
// Usage
h := &IntHeap{5, 3, 8, 1}
heap.Init(h) // O(n) — heapify
heap.Push(h, 2) // O(log n)
min := heap.Pop(h) // O(log n) — returns 1Go's container/heap is verbose but flexible. You implement the interface, it handles the bubbling.
Priority Queue
A priority queue is the abstract concept — "give me the highest priority item." A heap is the standard implementation. Same thing, different names.
// Task scheduler — process highest priority first
type Task struct {
Name string
Priority int
}
type TaskQueue []Task
func (q TaskQueue) Len() int { return len(q) }
func (q TaskQueue) Less(i, j int) bool { return q[i].Priority > q[j].Priority } // max by priority
func (q TaskQueue) Swap(i, j int) { q[i], q[j] = q[j], q[i] }
func (q *TaskQueue) Push(x any) { *q = append(*q, x.(Task)) }
func (q *TaskQueue) Pop() any {
old := *q
n := len(old)
x := old[n-1]
*q = old[:n-1]
return x
}Pattern: Top-K Elements
Find the K largest (or smallest) elements from a stream or array. Use a heap of size K.
LeetCode 215 - Kth Largest Element in an Array
import "container/heap"
func findKthLargest(nums []int, k int) int {
h := &IntHeap{}
heap.Init(h)
for _, n := range nums {
heap.Push(h, n)
if h.Len() > k {
heap.Pop(h) // remove smallest, keeping K largest
}
}
return (*h)[0] // smallest of the K largest = Kth largest
}Maintain a min-heap of size K. After processing all elements, the root is the Kth largest. O(n log k) time, O(k) space.
LeetCode 347 - Top K Frequent Elements
Given an integer array, return the k most frequent elements.
import "container/heap"
type FreqItem struct {
Val int
Freq int
}
type FreqHeap []FreqItem
func (h FreqHeap) Len() int { return len(h) }
func (h FreqHeap) Less(i, j int) bool { return h[i].Freq < h[j].Freq } // min by freq
func (h FreqHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *FreqHeap) Push(x any) { *h = append(*h, x.(FreqItem)) }
func (h *FreqHeap) Pop() any {
old := *h
n := len(old)
x := old[n-1]
*h = old[:n-1]
return x
}
func topKFrequent(nums []int, k int) []int {
freq := map[int]int{}
for _, n := range nums {
freq[n]++
}
h := &FreqHeap{}
heap.Init(h)
for val, count := range freq {
heap.Push(h, FreqItem{val, count})
if h.Len() > k {
heap.Pop(h) // evict least frequent
}
}
result := make([]int, h.Len())
for i := range result {
result[i] = (*h)[i].Val
}
return result
}Why not sort? Sorting is O(n log n). A heap of size K processes each element in O(log k). When k << n, the heap wins. Also works on streams where you don't have all data upfront.
Pattern: Merge K Sorted Lists
Merge K sorted sequences into one sorted sequence. Push the first element of each list into a min-heap. Pop the smallest, push the next element from that list.
Lists: [1,4,7], [2,5,8], [3,6,9]
Heap: [1, 2, 3] → pop 1, push 4
Heap: [2, 3, 4] → pop 2, push 5
Heap: [3, 4, 5] → pop 3, push 6
...
Result: [1, 2, 3, 4, 5, 6, 7, 8, 9]O(n log k) where n is total elements and k is number of lists. Without a heap, merging pairs repeatedly is O(n log k) too, but the heap approach is simpler to implement.
LeetCode 23 - Merge K Sorted Lists
import "container/heap"
type ListNode struct {
Val int
Next *ListNode
}
type NodeHeap []*ListNode
func (h NodeHeap) Len() int { return len(h) }
func (h NodeHeap) Less(i, j int) bool { return h[i].Val < h[j].Val }
func (h NodeHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *NodeHeap) Push(x any) { *h = append(*h, x.(*ListNode)) }
func (h *NodeHeap) Pop() any {
old := *h
n := len(old)
x := old[n-1]
*h = old[:n-1]
return x
}
func mergeKLists(lists []*ListNode) *ListNode {
h := &NodeHeap{}
heap.Init(h)
for _, l := range lists {
if l != nil {
heap.Push(h, l)
}
}
dummy := &ListNode{}
curr := dummy
for h.Len() > 0 {
node := heap.Pop(h).(*ListNode)
curr.Next = node
curr = curr.Next
if node.Next != nil {
heap.Push(h, node.Next)
}
}
return dummy.Next
}The heap always contains at most K nodes (one per list). Each pop and push is O(log k), and we process all n nodes, giving O(n log k) total.
Pattern: Running Median (Two Heaps)
Maintain a max-heap for the lower half and a min-heap for the upper half. The median is always at the top of one or both heaps.
Stream: 5, 2, 8, 1, 9
After 5: maxHeap=[5] minHeap=[] median=5
After 2: maxHeap=[2] minHeap=[5] median=3.5
After 8: maxHeap=[2] minHeap=[5,8] median=5
After 1: maxHeap=[1,2] minHeap=[5,8] median=3.5
After 9: maxHeap=[1,2] minHeap=[5,8,9] median=5Each insertion is O(log n). Getting the median is O(1). Without heaps, you'd need to sort after every insertion — O(n log n) per element.
LeetCode 295 - Find Median from Data Stream
import "container/heap"
type MaxHeap []int // lower half
func (h MaxHeap) Len() int { return len(h) }
func (h MaxHeap) Less(i, j int) bool { return h[i] > h[j] } // max-heap
func (h MaxHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *MaxHeap) Push(x any) { *h = append(*h, x.(int)) }
func (h *MaxHeap) Pop() any {
old := *h
n := len(old)
x := old[n-1]
*h = old[:n-1]
return x
}
type MinHeap []int // upper half
func (h MinHeap) Len() int { return len(h) }
func (h MinHeap) Less(i, j int) bool { return h[i] < h[j] }
func (h MinHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *MinHeap) Push(x any) { *h = append(*h, x.(int)) }
func (h *MinHeap) Pop() any {
old := *h
n := len(old)
x := old[n-1]
*h = old[:n-1]
return x
}
type MedianFinder struct {
lo MaxHeap // max-heap — lower half
hi MinHeap // min-heap — upper half
}
func Constructor() MedianFinder {
return MedianFinder{}
}
func (mf *MedianFinder) AddNum(num int) {
heap.Push(&mf.lo, num)
// ensure max of lo <= min of hi
heap.Push(&mf.hi, heap.Pop(&mf.lo))
// balance: lo can have at most 1 more than hi
if mf.hi.Len() > mf.lo.Len() {
heap.Push(&mf.lo, heap.Pop(&mf.hi))
}
}
func (mf *MedianFinder) FindMedian() float64 {
if mf.lo.Len() > mf.hi.Len() {
return float64(mf.lo[0])
}
return float64(mf.lo[0]+mf.hi[0]) / 2.0
}Every AddNum does at most 3 heap operations — O(log n). FindMedian peeks at roots — O(1).
Operation Costs
| Operation | Heap | Sorted Array | Unsorted Array |
|---|---|---|---|
| Find min/max | O(1) | O(1) | O(n) |
| Insert | O(log n) | O(n) | O(1) |
| Extract min/max | O(log n) | O(1)* | O(n) |
| Build from array | O(n) | O(n log n) | O(1) |
*O(1) if removing from the end of a sorted array.
The heap is the sweet spot: fast extraction AND fast insertion.
When to Use Heaps
- "Give me the K largest/smallest" → min/max heap of size K
- Scheduling by priority → priority queue
- Merging sorted streams → min-heap
- Running statistics (median, percentiles) → two heaps
- Dijkstra's shortest path → min-heap of distances
- Huffman coding → min-heap of frequencies
When NOT to Use Heaps
- You need to search for arbitrary elements → hash map
- You need all elements sorted → just sort the array
- You only need min OR max once → linear scan is O(n) and simpler
- You need the kth element once from static data → quickselect is O(n) average
Practice Problems
| Problem | Difficulty | Link |
|---|---|---|
| Kth Largest Element in an Array | Medium | LeetCode 215 |
| Top K Frequent Elements | Medium | LeetCode 347 |
| Merge K Sorted Lists | Hard | LeetCode 23 |
| Find Median from Data Stream | Hard | LeetCode 295 |
| Task Scheduler | Medium | LeetCode 621 |
| Last Stone Weight | Easy | LeetCode 1046 |
Key Takeaways
- A heap is a complete binary tree stored as an array — parent at (i-1)/2, children at 2i+1 and 2i+2
- Min-heap gives O(1) access to the minimum, O(log n) insert and extract
- Go's
container/heaprequires implementing a 5-method interface - Top-K, merge K sorted, and running median are the three core heap patterns
- Heaps are the standard implementation of priority queues
- Use heaps when you need repeated access to the extreme value with dynamic insertions