Skip to content

Heap / Top-K

Mark as:

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:

  1. "kth largest / smallest", "top k", "k most frequent", "k closest".
  2. You must repeatedly take the minimum or maximum of a collection that keeps changing (a scheduler, a stream, a merge).
  3. You are merging k sorted sources and need the smallest front element each time.
  4. A running median of a stream — two heaps, one per half.
  5. 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:

  1. Push 3. Heap [3].
  2. Push 2. Heap [2, 3] (root 2 is the smallest).
  3. Push 1. Size is 3, more than k, so pop the smallest (1). Heap [2, 3].
  4. Push 5, pop 2. Heap [3, 5].
  5. Push 6, pop 3. Heap [5, 6].
  6. Push 4, pop 4. Heap [5, 6].
  7. 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 — go/heaptopk/heap.go
// 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:

1Len / Less / Swap
Why:

These 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.

2Push(x any) / Pop() any
Why:

These 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.

3heap.Push(h, v); heap.Pop(h)
Why:

Always go through the heap package functions. After a push or pop the smallest element is at (*h)[0], readable in O(1).

4if h.Len() > k { heap.Pop(h) }
Why:

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
// 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

TopKFrequent
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

MergeKLists
// 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.

Merge k Sorted Lists
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
}
1/42

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

MedianFinder
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

ApproachTimeSpace
Sort everything, take kO(n log n)O(1) – O(n)
Size-k heapO(n log k)O(k)
Merge k lists with a heap (N total nodes)O(N log k)O(k)
Median stream: add / findO(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. 1.
    Kth Largest Element in a Stream
    Elements keep arriving and k never changes — how little can you remember?
    Easy
  2. 2.
    Last Stone Weight
    Each round needs the two biggest, and the leftovers go back in.
    Easy
  3. 3.
    Kth Largest Element in an Array
    Medium
  4. 4.
    Top K Frequent Elements
    Count first, then select — which structure picks the best k?
    Medium
  5. 5.
    K Closest Points to Origin
    Medium
  6. 6.
    Task Scheduler
    Always run the task with the most remaining copies that is off cooldown.
    Medium
  7. 7.
    Merge k Sorted Lists
    How many candidates for the next smallest are there at any moment?
    Hard
  8. 8.
    Find Median from Data Stream
    Split the numbers into two halves and watch the boundary.
    Hard

Which pattern? Drills

Unlabeled problems — pick the pattern, then read why.

Question 1 of 6

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?