Heaps & Priority Queues
“A heap is a complete binary tree stored in an array. Every parent is smaller than its children (min-heap) — and that one rule unlocks O(log n) insert & extract.”
Heaps appear in 15–20% of FAANG interviews. Three problem families dominate: Top-K (Kth largest, K closest points), Two Heaps (running median), and K-way Merge (merge K sorted lists). Master these three and you have a pattern for roughly 80% of all heap problems. Java's
PriorityQueue is your primary tool —
understand what it does internally so you never misuse it.
🏠 Heap Anatomy — The Array Representation
A complete binary tree can be stored perfectly in an array with no pointers needed. The structural rules are fixed by index arithmetic:
Index: 0 1 2 3 4 5 6
Array: [ 1, 3, 5, 7, 9, 8, 6 ] ← min-heap
Visualised as a tree:
1 ← index 0 (root)
/ \
3 5 ← index 1, 2
/ \ / \
7 9 8 6 ← index 3, 4, 5, 6
Parent of i → (i - 1) / 2
Left child → 2*i + 1
Right child → 2*i + 2
Heap Property (min-heap): parent ≤ both children at EVERY node
Max-heap: flip to parent ≥ both children
Insert (offer): O(log n) — sift UP | Extract-min (poll): O(log n) — sift DOWN | Peek-min: O(1) — always at index 0 | Build heap from array: O(n) — not O(n log n)!
Sift-Up & Sift-Down
int[] heap; int size; // INSERT: add at end, bubble up until heap property restored void offer(int val) { heap[size++] = val; siftUp(size - 1); } void siftUp(int i) { while (i > 0) { int p = (i - 1) / 2; if (heap[p] <= heap[i]) break; // already valid swap(i, p); i = p; } } // EXTRACT-MIN: swap root with last, shrink, sift root down int poll() { int min = heap[0]; heap[0] = heap[--size]; siftDown(0); return min; } void siftDown(int i) { while (2*i+1 < size) { int left = 2*i+1, right = 2*i+2; int smallest = (right < size && heap[right] < heap[left]) ? right : left; if (heap[i] <= heap[smallest]) break; swap(i, smallest); i = smallest; } } // BUILD HEAP in O(n): sift-down from last internal node upward void buildHeap(int[] arr) { heap = arr; size = arr.length; for (int i = size/2 - 1; i >= 0; i--) siftDown(i); } // Why O(n)? Leaf nodes need 0 work. Bottom half are all leaves. // Formal proof: sum of sift-down heights = O(n) (geometric series)
PriorityQueue<Integer> minH = new
PriorityQueue<>();
(default: min-heap)PriorityQueue<Integer> maxH = new
PriorityQueue<>(Collections.reverseOrder());PriorityQueue<int[]> pq = new PriorityQueue<>((a,b)
-> a[0]-b[0]);
— sort by first elementKey methods:
offer(x) O(log n) |
poll() O(log n) | peek() O(1)
| size() O(1)
🎯 The 5 Core Patterns
| # | Pattern | Signal Phrase | Tool | Canonical Problem |
|---|---|---|---|---|
| 1 | Min/Max Heap Ops | Kth largest, running max/min | Single heap | Kth Largest in Array |
| 2 | Top K Elements | "top K", "K most frequent", "K closest" | Size-K min-heap | Top K Frequent, K Closest Points |
| 3 | Two Heaps (Median) | "median", "sliding window median" | maxH + minH balanced | Median from Data Stream |
| 4 | K-way Merge | "merge K sorted", "smallest from K" | Min-heap of size K | Merge K Sorted Lists |
| 5 | Greedy Heap | "schedule", "reorganize", "cooldown" | Max-heap + queue | Task Scheduler, Reorganize String |
📈 Pattern 1 — Single Heap (Kth Largest)
The simplest heap pattern. Use a size-K min-heap to track the K largest elements seen so far. The root (min of heap) is always the Kth largest. If a new element is larger than the root, pop root and push new element.
int findKthLargest(int[] nums, int k) { PriorityQueue<Integer> minH = new PriorityQueue<>(); // min-heap for (int n : nums) { minH.offer(n); if (minH.size() > k) minH.poll(); // evict smallest; top K survive } return minH.peek(); // min of top-K = Kth largest } // Time O(n log k) Space O(k) // Why NOT sort? Sort is O(n log n). If k << n, heap is much faster.
🏆 Pattern 2 — Top K Elements
A generalisation of Pattern 1. Any problem asking for the K most/least something follows this shape. Key insight: use a min-heap of size K for top-K largest (counterintuitive — min-heap, not max-heap).
// Top K Frequent Elements: int[] topKFrequent(int[] nums, int k) { Map<Integer,Integer> freq = new HashMap<>(); for (int n : nums) freq.merge(n, 1, Integer::sum); // min-heap: sort by frequency ascending → easily evict least frequent PriorityQueue<int[]> minH = new PriorityQueue<>((a,b) -> a[0]-b[0]); for (var e : freq.entrySet()) { minH.offer(new int[]{e.getValue(), e.getKey()}); if (minH.size() > k) minH.poll(); } int[] res = new int[k]; for (int i = k-1; i >= 0; i--) res[i] = minH.poll()[1]; return res; } // Time O(n log k) Space O(n) for freq map // K Closest Points to Origin — same pattern, different comparator: int[][] kClosest(int[][] pts, int k) { // max-heap by distance — evict farthest, keep K closest PriorityQueue<int[]> maxH = new PriorityQueue<>( (a,b) -> (b[0]*b[0]+b[1]*b[1]) - (a[0]*a[0]+a[1]*a[1])); for (int[] p : pts) { maxH.offer(p); if (maxH.size() > k) maxH.poll(); // evict farthest } return maxH.toArray(new int[0][]); } // Note: use squared distance to avoid floating point sqrt
⚖ Pattern 3 — Two Heaps (Running Median)
Split the data stream into two halves: a max-heap for the lower half and a min-heap for the upper half. Keep their sizes balanced (differ by at most 1). The median is always at one of the two roots.
PriorityQueue<Integer> maxH = new PriorityQueue<>(Collections.reverseOrder()); // lower half PriorityQueue<Integer> minH = new PriorityQueue<>(); // upper half void addNum(int n) { maxH.offer(n); minH.offer(maxH.poll()); // ensure max-of-lower ≤ min-of-upper if (minH.size() > maxH.size()) maxH.offer(minH.poll()); // rebalance } double findMedian() { if (maxH.size() == minH.size()) return (maxH.peek() + minH.peek()) / 2.0; return maxH.peek(); // maxH has the extra element } // addNum: O(log n) findMedian: O(1) // Invariant: maxH.peek() ≤ minH.peek() always (cross-push enforces this)
🔁 Pattern 4 — K-way Merge
Given K sorted lists (or arrays), use a min-heap to efficiently pull the globally smallest element each time. Always keep exactly one element from each active list in the heap.
ListNode mergeKLists(ListNode[] lists) { // min-heap ordered by node value PriorityQueue<ListNode> pq = new PriorityQueue<>((a,b) -> a.val - b.val); for (ListNode head : lists) if (head != null) pq.offer(head); ListNode dummy = new ListNode(0), curr = dummy; while (!pq.isEmpty()) { curr.next = pq.poll(); // pull globally smallest curr = curr.next; if (curr.next != null) pq.offer(curr.next); // push next from same list } return dummy.next; } // Time O(N log K) where N = total nodes, K = number of lists // Space O(K) for the heap — only K nodes live in the heap at any time
⚡ Pattern 5 — Greedy Heap (Task Scheduler)
Use a max-heap to always process the most frequent/highest-priority task next. When a cooldown constraint exists, pair the heap with a wait queue.
// Task Scheduler — greedy: always do the most-frequent available task int leastInterval(char[] tasks, int n) { int[] cnt = new int[26]; for (char c : tasks) cnt[c-'A']++; PriorityQueue<Integer> maxH = new PriorityQueue<>(Collections.reverseOrder()); for (int c : cnt) if (c > 0) maxH.offer(c); Queue<int[]> waitQ = new LinkedList<>(); // [remaining_count, available_at] int time = 0; while (!maxH.isEmpty() || !waitQ.isEmpty()) { time++; if (!maxH.isEmpty()) { int rem = maxH.poll() - 1; if (rem > 0) waitQ.offer(new int[]{rem, time + n}); } if (!waitQ.isEmpty() && waitQ.peek()[1] == time) maxH.offer(waitQ.poll()[0]); } return time; } // Time O(T * 26) where T = total tasks Space O(26) = O(1) // Reorganize String — same greedy idea: // Always pick the most frequent char that != previous char String reorganizeString(String s) { int[] cnt = new int[26]; for (char c : s.toCharArray()) cnt[c-'a']++; PriorityQueue<int[]> maxH = new PriorityQueue<>((a,b) -> b[0]-a[0]); for (int i = 0; i < 26; i++) if (cnt[i]>0) maxH.offer(new int[]{cnt[i], i}); StringBuilder sb = new StringBuilder(); while (maxH.size() >= 2) { int[] a = maxH.poll(), b = maxH.poll(); sb.append((char)('a'+a[1])); sb.append((char)('a'+b[1])); if (--a[0] > 0) maxH.offer(a); if (--b[0] > 0) maxH.offer(b); } if (!maxH.isEmpty()) { if (maxH.peek()[0] > 1) return ""; // impossible sb.append((char)('a'+maxH.poll()[1])); } return sb.toString(); } // Time O(n log 26) = O(n) Space O(n) for output
💪 In-Lecture Practice Problems
📝 Assignment — Company Interview Problems
All 5 heap patterns covered. Every problem tagged with pattern, company, and difficulty.
📄 Open Assignment.md →
✅ Lecture Completion Checklist
Check each item before advancing to Lecture 17 (Graphs). Be honest — these are your interview readiness criteria.
Graphs generalise trees: a node can have more than 2 neighbours. BFS and DFS patterns you used on trees apply directly. The heap you built here powers Dijkstra's shortest path algorithm in Graphs.