Coding patternsTrees, graphs and heaps
Heaps and top-k
The heap (priority queue) coding pattern for interviews. Top-k with a size-k heap, merging k sorted lists, and two heaps for a running median, with a step-through, code in Java, Python, C#, Go and TypeScript, and 10 LeetCode problems.
“Return the k most frequent words.” Sorting everything works, at O(n log n). But it does far more than asked. It puts all n items in order, when you only wanted the best k, in any order. A heap does exactly what’s asked. It keeps a collection where the smallest (or largest) item is always on top. Adding or removing one item costs O(log n).
That one guarantee covers a family of problems: “the top is the extreme, and nothing else is promised”. The k best of anything. Merging sorted streams. Always picking the cheapest next step. And keeping a running median.
How to spot it
Section titled “How to spot it”- “k largest”, “k smallest”, “k closest”, “k most frequent”, “kth largest”.
- A stream where you must report the best so far after each new item.
- Merging sorted things: k sorted lists, k sorted arrays, the smallest pair sums.
- Always process the most urgent or cheapest item next, while new items keep arriving: scheduling, simulations, Dijkstra’s shortest paths.
- A running median.
Watch it run
Section titled “Watch it run”Kth largest in a stream, with k = 3. The heap holds the three largest numbers so far, smallest on top. A new number joins. If that makes four, the smallest of the four leaves. The top is always the 3rd largest overall.
- heap = [] # min-heap, k largest so far
- for number in stream:
- heapq.heappush(heap, number)
- if len(heap) > rank:
- heapq.heappop(heap) # drop the smallest
- kth_largest = heap[0] # kth largest so far
Keep the 3 largest numbers seen so far in a min-heap. Its smallest member, the top, is the 3rd largest overall.
The surprising part is the direction. To keep the largest k, use a min-heap. You need fast access to the item you’d throw out, and that’s the smallest of the k. The heap is shown sorted, so it’s easy to read. A real heap only promises that the top is the smallest.
The template
Section titled “The template”Kth largest with a size-k heap, and merging sorted lists. The code calls k rank:
import java.util.ArrayList;import java.util.List;import java.util.PriorityQueue;
public class Heaps { static int kthLargest(int[] nums, int rank) { // Keep the k largest values seen so far in a min-heap. Its top is the smallest of // them, which is the kth largest overall. When the heap grows past k, the top can't be // in the top k any more. O(n log k) time, O(k) memory, and it works on a stream. PriorityQueue<Integer> heap = new PriorityQueue<>(); // a min-heap by default for (int number : nums) { heap.add(number); if (heap.size() > rank) heap.poll(); } return heap.peek(); }
static List<Integer> mergeSorted(List<List<Integer>> lists) { // The heap holds one candidate per list: its next unread value, as {value, list, // index}. The smallest candidate is the next output value, and only its own list can // supply the one after it. Integer.compare, not a - b, which can overflow. PriorityQueue<int[]> heap = new PriorityQueue<>( (first, second) -> Integer.compare(first[0], second[0])); for (int i = 0; i < lists.size(); i++) { if (!lists.get(i).isEmpty()) heap.add(new int[] {lists.get(i).get(0), i, 0}); } List<Integer> merged = new ArrayList<>(); while (!heap.isEmpty()) { int[] top = heap.poll(); merged.add(top[0]); List<Integer> list = lists.get(top[1]); int next = top[2] + 1; if (next < list.size()) heap.add(new int[] {list.get(next), top[1], next}); } return merged; }
public static void main(String[] args) { check(kthLargest(new int[] {5, 1, 8, 3, 9, 2, 7}, 3) == 7); check(kthLargest(new int[] {3, 2, 1, 5, 6, 4}, 2) == 5); List<List<Integer>> lists = List.of(List.of(1, 4, 7), List.of(2, 5), List.of(), List.of(3, 6, 9)); check(mergeSorted(lists).equals(List.of(1, 2, 3, 4, 5, 6, 7, 9))); System.out.println("ok: kthLargest and mergeSorted"); }
static void check(boolean ok) { if (!ok) throw new AssertionError(); }}import heapq
def kth_largest(nums: list[int], rank: int) -> int: # Keep the k largest values seen so far in a min-heap. Its top is the smallest of them, # which is the kth largest overall. When the heap grows past k, the top is the one value # that can't be in the top k any more. O(n log k) time, O(k) memory, and it works on a # stream you can only read once. heap: list[int] = [] for number in nums: heapq.heappush(heap, number) if len(heap) > rank: heapq.heappop(heap) return heap[0]
def merge_sorted(lists: list[list[int]]) -> list[int]: # The heap holds one candidate per list: its next unread value. The smallest candidate is # the next output value, and only its own list can supply the one after it. Tuples compare # by value first; the list index breaks ties so equal values never compare anything else. heap = [(sorted_list[0], list_index, 0) for list_index, sorted_list in enumerate(lists) if sorted_list] heapq.heapify(heap) # O(k), cheaper than k pushes merged = [] while heap: value, list_index, position = heapq.heappop(heap) merged.append(value) if position + 1 < len(lists[list_index]): next_value = lists[list_index][position + 1] heapq.heappush(heap, (next_value, list_index, position + 1)) return merged
assert kth_largest([5, 1, 8, 3, 9, 2, 7], 3) == 7assert kth_largest([3, 2, 1, 5, 6, 4], 2) == 5assert merge_sorted([[1, 4, 7], [2, 5], [], [3, 6, 9]]) == [1, 2, 3, 4, 5, 6, 7, 9]print("ok: kth_largest and merge_sorted")using System;using System.Collections.Generic;using System.Linq;
static class Heaps{ public static int KthLargest(int[] nums, int rank) { // Keep the k largest values seen so far in a min-heap. Its top is the smallest of // them, which is the kth largest overall. When the heap grows past k, the top can't be // in the top k any more. O(n log k) time, O(k) memory, and it works on a stream. var heap = new PriorityQueue<int, int>(); // element and priority; lowest priority first foreach (int number in nums) { heap.Enqueue(number, number); if (heap.Count > rank) heap.Dequeue(); } return heap.Peek(); }
public static List<int> MergeSorted(int[][] lists) { // The heap holds one candidate per list: where its next unread value is, prioritized // by that value. The smallest candidate is the next output value, and only its own // list can supply the one after it. var heap = new PriorityQueue<(int List, int Index), int>(); for (int i = 0; i < lists.Length; i++) { if (lists[i].Length > 0) heap.Enqueue((i, 0), lists[i][0]); } var merged = new List<int>(); while (heap.TryDequeue(out var source, out int value)) { merged.Add(value); int next = source.Index + 1; if (next < lists[source.List].Length) { heap.Enqueue((source.List, next), lists[source.List][next]); } } return merged; }
static void Main() { Check(KthLargest([5, 1, 8, 3, 9, 2, 7], 3) == 7); Check(KthLargest([3, 2, 1, 5, 6, 4], 2) == 5); int[][] lists = [[1, 4, 7], [2, 5], [], [3, 6, 9]]; Check(MergeSorted(lists).SequenceEqual(new[] { 1, 2, 3, 4, 5, 6, 7, 9 })); Console.WriteLine("ok: KthLargest and MergeSorted"); }
static void Check(bool ok) { if (!ok) throw new InvalidOperationException("check failed"); }}package main
import ( "container/heap" "fmt" "slices")
// container/heap works on any type with these five methods. Less decides the order: < makes// a min-heap. Push and Pop are only called by the heap package, never directly.type minHeap []entry
type entry struct{ value, list, index int }
func (items minHeap) Len() int { return len(items) }func (items minHeap) Less(i, j int) bool { return items[i].value < items[j].value }func (items minHeap) Swap(i, j int) { items[i], items[j] = items[j], items[i] }func (items *minHeap) Push(item any) { *items = append(*items, item.(entry)) }func (items *minHeap) Pop() any { old := *items top := old[len(old)-1] *items = old[:len(old)-1] return top}
func kthLargest(nums []int, rank int) int { // Keep the k largest values seen so far in a min-heap. Its top is the smallest of them, // which is the kth largest overall. When the heap grows past k, the top can't be in the // top k any more. O(n log k) time, O(k) memory, and it works on a stream. largest := &minHeap{} for _, number := range nums { heap.Push(largest, entry{value: number}) if largest.Len() > rank { heap.Pop(largest) } } return (*largest)[0].value}
func mergeSorted(lists [][]int) []int { // The heap holds one candidate per list: its next unread value. The smallest candidate is // the next output value, and only its own list can supply the one after it. candidates := &minHeap{} for i, sortedList := range lists { if len(sortedList) > 0 { *candidates = append(*candidates, entry{sortedList[0], i, 0}) } } heap.Init(candidates) // O(k), cheaper than k pushes var merged []int for candidates.Len() > 0 { top := heap.Pop(candidates).(entry) merged = append(merged, top.value) if next := top.index + 1; next < len(lists[top.list]) { heap.Push(candidates, entry{lists[top.list][next], top.list, next}) } } return merged}
func main() { check(kthLargest([]int{5, 1, 8, 3, 9, 2, 7}, 3) == 7) check(kthLargest([]int{3, 2, 1, 5, 6, 4}, 2) == 5) check(slices.Equal(mergeSorted([][]int{{1, 4, 7}, {2, 5}, {}, {3, 6, 9}}), []int{1, 2, 3, 4, 5, 6, 7, 9})) fmt.Println("ok: kthLargest and mergeSorted")}
func check(ok bool) { if !ok { panic("check failed") }}// JavaScript has no built-in heap, so interviews expect you to write one or to say you'd// use a library. This one is a binary heap in an array: the children of i are 2i+1 and 2i+2,// and every parent comes before its children under `before`.class Heap<T> { #items: T[] = []; #before: (first: T, second: T) => boolean;
constructor(before: (first: T, second: T) => boolean) { this.#before = before; }
get size(): number { return this.#items.length; }
peek(): T | undefined { return this.#items[0]; }
push(item: T): void { const items = this.#items; items.push(item); // Sift up: swap with the parent while the new item should come first. let i = items.length - 1; while (i > 0) { const parent = (i - 1) >> 1; if (!this.#before(items[i], items[parent])) break; [items[i], items[parent]] = [items[parent], items[i]]; i = parent; } }
pop(): T | undefined { const items = this.#items; const top = items[0]; const last = items.pop(); if (items.length && last !== undefined) { items[0] = last; // Sift down: swap with the child that should come first, until neither should. for (let i = 0; ; ) { const [leftChild, rightChild] = [2 * i + 1, 2 * i + 2]; let best = i; if (leftChild < items.length && this.#before(items[leftChild], items[best])) { best = leftChild; } if (rightChild < items.length && this.#before(items[rightChild], items[best])) { best = rightChild; } if (best === i) break; [items[i], items[best]] = [items[best], items[i]]; i = best; } } return top; }}
function kthLargest(nums: number[], rank: number): number { // Keep the k largest values seen so far in a min-heap. Its top is the smallest of them, // which is the kth largest overall. When the heap grows past k, the top can't be in the // top k any more. O(n log k) time, O(k) memory, and it works on a stream. const heap = new Heap<number>((first, second) => first < second); for (const number of nums) { heap.push(number); if (heap.size > rank) heap.pop(); } return heap.peek()!;}
function mergeSorted(lists: number[][]): number[] { // The heap holds one candidate per list, as [value, list, index]: its next unread value. // The smallest candidate is the next output value, and only its own list can supply the // one after it. const heap = new Heap<[number, number, number]>( (first, second) => first[0] < second[0], ); lists.forEach((sortedList, listIndex) => { if (sortedList.length) heap.push([sortedList[0], listIndex, 0]); }); const merged: number[] = []; while (heap.size) { const [value, listIndex, position] = heap.pop()!; merged.push(value); if (position + 1 < lists[listIndex].length) { heap.push([lists[listIndex][position + 1], listIndex, position + 1]); } } return merged;}
const check = (ok: boolean) => { if (!ok) throw new Error("check failed");};check(kthLargest([5, 1, 8, 3, 9, 2, 7], 3) === 7);check(kthLargest([3, 2, 1, 5, 6, 4], 2) === 5);check(mergeSorted([[1, 4, 7], [2, 5], [], [3, 6, 9]]).join() === "1,2,3,4,5,6,7,9");console.log("ok: kthLargest and mergeSorted");The languages differ more here than anywhere else in the track:
- Python’s
heapqworks on a plain list and is a min-heap only. For a max-heap, push negated values. Push tuples to order by the first field, and add a tiebreaker (like an index) so equal priorities never compare the next field. - Java’s
PriorityQueueis a min-heap by default. PassComparator.reverseOrder()for a max-heap. Write comparators withInteger.compare, nota - b, which overflows. - C#’s
PriorityQueue<TElement, TPriority>(.NET 6 and later) takes the priority separately and is a min-heap on it. - Go’s
container/heapneeds a type with five methods, which is the boilerplate in the Go tab. - TypeScript has no heap at all. Interviewers accept “I’d use a library”, if you also say how a binary heap works. The TypeScript tab includes a short one.
Variations
Section titled “Variations”Top k frequent
Section titled “Top k frequent”Count with a hash map. Then keep the k most frequent in a size-k min-heap, keyed by count. That’s O(n log k). There’s also an O(n) answer: bucket sort by frequency. Make an array where index f holds the items that appear f times. Then walk it from the highest frequency down, until you have k items. Mention both. The heap is more general. The buckets are faster here.
Two heaps for a running median
Section titled “Two heaps for a running median”Keep the smaller half in a max-heap, and the larger half in a min-heap. Their sizes are equal, or the lower half is one bigger. A new number goes into the lower half. Then the lower half’s top moves to the upper half. Then, if the upper half is bigger, its top moves back. The median is the lower top, or the average of both tops. Each step is O(log n).
Dijkstra’s shortest paths
Section titled “Dijkstra’s shortest paths”BFS finds shortest paths when every edge costs 1. With weights, replace the queue with a min-heap, ordered by distance so far. Pop the closest unfinished node, and finalize it. Then push its neighbors with their new distances. That’s network delay time. It needs non-negative weights.
Complexity
Section titled “Complexity”| Time | Extra memory | |
|---|---|---|
| Push, pop | O(log n) | |
| Peek | O(1) | |
Build a heap from n items (heapify) |
O(n) | |
| Top k from n items | O(n log k) | O(k) |
| Merge k sorted lists of N items in total | O(N log k) | O(k) |
I only need the k largest, not a full sort. I’ll keep a min-heap of size k. Its top is the smallest of my current best k, so it’s the one to evict. That’s O(n log k).
Interview questions
Section titled “Interview questions”Interviewer asksWhy a min-heap for the k largest elements?
Because the operation you need on every step is “evict the weakest of my current best k”. The weakest of the largest values is the smallest. A min-heap puts exactly that item on top. With a max-heap, you’d need to keep all n items, since you can’t tell which ones to drop.
Interviewer asksMerge k sorted lists: why is it O(N log k) and not O(N log N)?
The heap never holds more than k items, one per list, so each push and pop costs O(log k). Every one of the N items is pushed once and popped once. Merging the lists pairwise, like merge sort, also gets O(N log k). Merging them one by one into a growing result is O(N × k).
Interviewer asksTask scheduler: where does the heap come in, and is there a faster answer?
The simulation always runs the most frequent task that’s off cooldown: a max-heap by remaining
count, plus a queue of tasks cooling down. That’s O(n log 26). There’s also a formula: with the
highest count m shared by c tasks, the answer is max(len(tasks), (m - 1) × (n + 1) + c). Lead
with the simulation, since it explains the formula.
Practice
Section titled “Practice”10 problems on LeetCode: 2 easy, 6 medium, 2 hard
- Kth Largest Element in a Stream(opens LeetCode in a new tab)Easy
A min-heap of size k keeps the answer at its top.
- Last Stone Weight(opens LeetCode in a new tab)Easy
A max-heap hands you the two heaviest each round.
- Kth Largest Element in an Array(opens LeetCode in a new tab)Medium
Size-k min-heap, or quickselect for average O(n).
- Top K Frequent Elements(opens LeetCode in a new tab)Medium
Count with a map, then keep the k most frequent in a heap.
- K Closest Points to Origin(opens LeetCode in a new tab)Medium
A max-heap of size k evicts the farthest point so far.
- Task Scheduler(opens LeetCode in a new tab)Medium
Always run the most frequent task that is off cooldown.
- Reorganize String(opens LeetCode in a new tab)Medium
Place the most frequent letter, then hold it back one turn.
- Network Delay Time(opens LeetCode in a new tab)Medium
Dijkstra: a min-heap always expands the closest node next.
- Merge k Sorted Lists(opens LeetCode in a new tab)Hard
A heap of k list heads always holds the next smallest node.
- Find Median from Data Stream(opens LeetCode in a new tab)Hard
Two heaps hold the lower and upper halves, balanced in size.
Ticks are saved in this browser only. Clear your site data and they're gone.
- Use it when you need the k best, a running best, or always the cheapest next item.
- Top k largest: min-heap of size k. Top k smallest: max-heap of size k.
- Merge k sorted: one candidate per list in the heap. Running median: two heaps.
- Cost: O(log n) per push or pop, O(n log k) for top k, O(n) to heapify.
- Know your language: Python is min-only, Java and C# take comparators or priorities, Go
needs
container/heap, TypeScript needs your own.
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.