Heap / Top-K
One-liner: when you only care about the best K items (or the next-best item, over and over), keep just those in a heap instead of sorting everything.
The analogy
Think of a podium with K places. A new athlete finishes: you only compare them with the slowest person currently on the podium. If they are faster, they replace that person; if not, they go home. You never rank the whole stadium — you only ever need to know who is the weakest of the current winners. A heap is the data structure that always hands you that one person in O(log k).
Recognition signals
Reach for a heap when you see any of these:
- "kth largest / smallest", "top k", "k most frequent", "k closest".
- You must repeatedly take the minimum or maximum of a collection that keeps changing (a scheduler, a stream, a merge).
- You are merging k sorted sources and need the smallest front element each time.
- A running median of a stream — two heaps, one per half.
- Sorting would work but is wasteful because k is much smaller than n.
Step-by-step walkthrough
Take kth largest on [3, 2, 1, 5, 6, 4] with k = 2:
- Push 3. Heap
[3]. - Push 2. Heap
[2, 3](root 2 is the smallest). - Push 1. Size is 3, more than k, so pop the smallest (1). Heap
[2, 3]. - Push 5, pop 2. Heap
[3, 5]. - Push 6, pop 3. Heap
[5, 6]. - Push 4, pop 4. Heap
[5, 6]. - The root, 5, is the 2nd largest.
The heap never holds more than k+1 items, so each step is O(log k).
Code template
Go has no ready-made heap type. You write a small type with five methods and let container/heap do the sifting. Memorise the shape, not every character.
// IntHeap is a MIN-heap of ints. container/heap needs five methods:
// the three from sort.Interface plus Push and Pop (which only append / trim the slice).
type IntHeap []int
func (h IntHeap) Len() int { return len(h) }
func (h IntHeap) Less(i, j int) bool { return h[i] < h[j] } // flip to > for a max-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{}; heap.Push(h, 5); smallest := (*h)[0]; heap.Pop(h).(int)Why each part exists:
Len / Less / SwapThese three make the slice sortable. Less decides the heap order: < gives a min-heap, > a max-heap. To order by something else (a count, a distance), change only this method.
Push(x any) / Pop() anyThese are called by heap.Push and heap.Pop, not by you. They only append to or trim the end of the slice — the package does the sifting. Calling h.Push directly breaks the heap.
heap.Push(h, v); heap.Pop(h)Always go through the heap package functions. After a push or pop the smallest element is at (*h)[0], readable in O(1).
if h.Len() > k { heap.Pop(h) }The size cap is the whole trick of Top-K. Evicting the root throws away the item that can no longer be among the best k.
The real solutions
Kth largest element
// FindKthLargest: keep a min-heap of the k largest values seen so far.
// The root is the smallest of those k, which is exactly the kth largest overall.
func FindKthLargest(nums []int, k int) int {
h := &IntHeap{}
for _, v := range nums {
heap.Push(h, v)
if h.Len() > k {
heap.Pop(h) // evict the smallest: it can no longer be in the top k
}
}
return (*h)[0]
}A size-k min-heap: after processing everything, the root is the smallest of the k largest, i.e. the kth largest.
K most frequent elements
type freqItem struct{ val, count int }
type freqHeap []freqItem
func (h freqHeap) Len() int { return len(h) }
func (h freqHeap) Less(i, j int) bool {
if h[i].count != h[j].count {
return h[i].count < h[j].count
}
return h[i].val > h[j].val // deterministic tie-break
}
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
x := old[len(old)-1]
*h = old[:len(old)-1]
return x
}
// TopKFrequent: count with a map, then keep a size-k min-heap by count.
// Result is ordered from most to least frequent.
func TopKFrequent(nums []int, k int) []int {
count := map[int]int{}
for _, v := range nums {
count[v]++
}
h := &freqHeap{}
for val, c := range count {
heap.Push(h, freqItem{val, c})
if h.Len() > k {
heap.Pop(h)
}
}
res := make([]int, h.Len())
for i := len(res) - 1; i >= 0; i-- {
res[i] = heap.Pop(h).(freqItem).val
}
return res
}Two phases: a map counts, then a heap selects. The heap holds small structs, so only Less compares by count. Popping at the end yields least frequent first, so we fill the result from the back.
Merge k sorted lists
// ListNode is the singly linked list node.
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
x := old[len(old)-1]
*h = old[:len(old)-1]
return x
}
// MergeKLists: the heap always holds the current head of every list;
// pop the smallest, append it, and push that node's successor.
func MergeKLists(lists []*ListNode) *ListNode {
h := &nodeHeap{}
for _, l := range lists {
if l != nil {
heap.Push(h, l)
}
}
dummy := &ListNode{}
tail := dummy
for h.Len() > 0 {
n := heap.Pop(h).(*ListNode)
tail.Next = n
tail = n
if n.Next != nil {
heap.Push(h, n.Next)
}
}
return dummy.Next
}Step through it. The highlighted values are what the heap holds right now: only the current head of each list, so it never grows past k.
- heap (min first)
- empty
- merged so far
- empty
The smallest value overall must be the first value (the head) of some list. So the heap holds the CURRENT head of every list, never more than 3 values.
// ListNode is the singly linked list node.
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
x := old[len(old)-1]
*h = old[:len(old)-1]
return x
}
// MergeKLists: the heap always holds the current head of every list;
// pop the smallest, append it, and push that node's successor.
func MergeKLists(lists []*ListNode) *ListNode {
h := &nodeHeap{}
for _, l := range lists {
if l != nil {
heap.Push(h, l)
}
}
dummy := &ListNode{}
tail := dummy
for h.Len() > 0 {
n := heap.Pop(h).(*ListNode)
tail.Next = n
tail = n
if n.Next != nil {
heap.Push(h, n.Next)
}
}
return dummy.Next
}The heap holds one node per list — never more than k nodes — so each pop and push is O(log k) even when the lists are huge.
Median from a data stream
type maxHeap []int
func (h maxHeap) Len() int { return len(h) }
func (h maxHeap) Less(i, j int) bool { return h[i] > h[j] }
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
x := old[len(old)-1]
*h = old[:len(old)-1]
return x
}
// MedianFinder: a max-heap holds the smaller half, a min-heap the larger half.
// Invariant: len(lo) == len(hi) or len(lo) == len(hi)+1, and every lo value <= every hi value.
type MedianFinder struct {
lo maxHeap // smaller half, root = its largest
hi IntHeap // larger half, root = its smallest
}
func (m *MedianFinder) AddNum(x int) {
heap.Push(&m.lo, x)
heap.Push(&m.hi, heap.Pop(&m.lo).(int)) // pass the largest of lo across so halves stay ordered
if m.hi.Len() > m.lo.Len() {
heap.Push(&m.lo, heap.Pop(&m.hi).(int)) // rebalance sizes
}
}
func (m *MedianFinder) FindMedian() float64 {
if m.lo.Len() == 0 {
return 0
}
if m.lo.Len() > m.hi.Len() {
return float64(m.lo[0])
}
return float64(m.lo[0]+m.hi[0]) / 2
}The max-heap lo holds the smaller half and the min-heap hi the larger half. The median is the root of lo (odd count) or the average of both roots (even count). Pushing through lo and then moving its largest to hi guarantees every value in lo is at most every value in hi.
Complexity
| Approach | Time | Space |
|---|---|---|
| Sort everything, take k | O(n log n) | O(1) – O(n) |
| Size-k heap | O(n log k) | O(k) |
| Merge k lists with a heap (N total nodes) | O(N log k) | O(k) |
| Median stream: add / find | O(log n) / O(1) | O(n) |
Building a heap from all n items at once (heap.Init) is O(n), and popping all of them is O(n log n) — so a heap sort is no faster than sorting. The heap only wins when k is small or when the data keeps changing.
Common mistakes
Practice ladder
Ordered Easy to Hard. Ask yourself what you need repeatedly from the collection before reaching for a heap.
- 1.Kth Largest Element in a StreamEasyElements keep arriving and k never changes — how little can you remember?
- 2.Last Stone WeightEasyEach round needs the two biggest, and the leftovers go back in.
- 3.Kth Largest Element in an ArrayMedium
- 4.Top K Frequent ElementsMediumCount first, then select — which structure picks the best k?
- 5.K Closest Points to OriginMedium
- 6.Task SchedulerMediumAlways run the task with the most remaining copies that is off cooldown.
- 7.Merge k Sorted ListsHardHow many candidates for the next smallest are there at any moment?
- 8.Find Median from Data StreamHardSplit the numbers into two halves and watch the boundary.
Which pattern? Drills
Unlabeled problems — pick the pattern, then read why.
Given an unsorted array of integers and a number k, return the kth largest element (by sorted order, not distinct values). The array is large and k is small.
Which pattern?