Heap & Priority Queue: Practice Problems with Solutions
Welcome to the practice problems for heap and priority queue. If you need a refresher on the code, the code templates have the patterns in all 6 languages. Each problem below includes a hint, a visual walkthrough, and the full solution.
The Heap Helpers for JavaScript and Ruby
Python, Java, C++, and Go have standard heaps, so the solutions below use them directly. JavaScript and Ruby do not, so every JavaScript and Ruby solution on this page uses the Heap helper class from the code templates
. In short, new Heap() is a min-heap, and a comparator like (a, b) => a > b (JavaScript) or { |a, b| a > b } (Ruby) turns it into a max-heap. The solutions below only show the problem logic.
Recommended Study Order
Start with Last Stone Weight. It is the smallest possible heap program. You build a max-heap and then keep popping. Kth Largest Element in a Stream adds the size cap, which is the idea behind every top-K problem. Kth Largest Element in an Array applies the same cap to a static input. Top K Frequent Elements adds a second key, frequency, which is where custom comparators start mattering. K Closest Points to Origin compares by distance instead of raw value. Merge k Sorted Lists is the first problem where the heap holds the next candidate from each list instead of a finished value. Find Median from Data Stream ends the set by combining a min-heap and a max-heap into one structure. If you can explain why the two median heaps stay balanced, you understand heaps.
Easy Problems
1. Last Stone Weight
LeetCode 1046 | Difficulty: Easy
Brief: Smash the two heaviest stones together. If they weigh the same, both are destroyed. Otherwise the difference goes back into the pile. Return the weight of the last stone, or 0 if nothing is left.
Why this pattern: The problem repeatedly needs the two largest elements in a collection that keeps changing. A max-heap serves both pops and the occasional re-insert in
Key Insight: Every iteration runs the same three steps. Pop twice, then push the difference if it is non-zero. The heap keeps the heaviest stone at the root, so the two pops never need to scan the pile.
Complexity: Time
Visual:
graph TD
A["Stones: 2, 7, 4, 1, 8, 1"] --> B["Pop 8 and 7, push 1"]
B --> C["Stones: 2, 4, 1, 1, 1"]
C --> D["Pop 4 and 2, push 2"]
D --> E["Stones: 2, 1, 1, 1"]
E --> F["Pop 2 and 1, push 1"]
F --> G["Pop 1 and 1, both destroyed"]
G --> H["Last stone: 1"]
Code:
var lastStoneWeight = function(stones) {
// max-heap: the root holds the heaviest stone
const heap = new Heap(stones, (a, b) => a > b);
while (heap.size() > 1) {
const heaviest = heap.pop();
const second = heap.pop();
if (heaviest !== second) {
heap.push(heaviest - second);
}
}
return heap.size() === 0 ? 0 : heap.peek();
};The solution is the max-heap template with no twists. Building the heap with a batch operation costs
2. Kth Largest Element in a Stream
LeetCode 703 | Difficulty: Easy
Brief: Design a class that receives a stream of integers. Every add call inserts one value and returns the kth largest element of everything seen so far.
Why this pattern: The size-k min-heap is the exact shape of this problem. The heap holds the k largest values seen so far, and its root is the answer.
Key Insight: The constructor shrinks the initial heap to size k once. Every add then does push, pop-if-overflow, and read the root. No scanning, no sorting, no memory that grows with the stream.
Complexity: Time
Visual:
graph TD
A["k = 3, heap keeps size 3"] --> B["Add 3"]
B --> C["Heap: 4, 5, 8, return 4"]
C --> D["Add 5"]
D --> E["Heap: 5, 5, 8, return 5"]
E --> F["Add 10"]
F --> G["Heap: 5, 8, 10, return 5"]
G --> H["Add 9"]
H --> I["Heap: 8, 9, 10, return 8"]
Code:
var KthLargest = function(k, nums) {
this.k = k;
// min-heap capped at k: the root is the kth largest
this.heap = new Heap(nums);
while (this.heap.size() > k) {
this.heap.pop();
}
};
KthLargest.prototype.add = function(val) {
this.heap.push(val);
if (this.heap.size() > this.k) {
this.heap.pop();
}
return this.heap.peek();
};This problem is the size-k template in class form. The constructor does the shrink once, and add repeats the push-then-trim cycle forever. The space stays at
Medium Problems
3. Kth Largest Element in an Array
LeetCode 215 | Difficulty: Medium
Brief: Return the kth largest element of an unsorted array.
Why this pattern: The same size-k min-heap works on a static array. Sort the whole array and you pay
Key Insight: After every insert, pop the root if the heap holds more than k elements. The root is then the kth largest of everything processed, which is exactly the answer when the array is exhausted.
Complexity: Time
Visual:
graph TD
A["nums: 3, 2, 1, 5, 6, 4, k = 2"] --> B["Push 3, 2"]
B --> C["Push 1, size > 2, pop 1"]
C --> D["Push 5, pop 2"]
D --> E["Push 6, pop 3"]
E --> F["Push 4, pop 4"]
F --> G["Root: 5, the 2nd largest"]
Code:
var findKthLargest = function(nums, k) {
// min-heap capped at k keeps the k largest values.
// the root is the kth largest of everything seen.
const heap = new Heap();
for (const num of nums) {
heap.push(num);
if (heap.size() > k) {
heap.pop();
}
}
return heap.peek();
};This is the stream version from the previous problem with the stream replaced by a loop. The guaranteed worst case is what distinguishes the heap here. Quickselect, the divide and conquer alternative covered on the divide and conquer page , has expected
4. Top K Frequent Elements
LeetCode 347 | Difficulty: Medium
Brief: Return the k most frequent elements from an array.
Why this pattern: Count frequencies first, then the top-K template applies with the frequency as the heap key instead of the raw value.
Key Insight: The heap stores (count, element) pairs and caps at k. The root is the least frequent element among the survivors, so it is the one that leaves when a more frequent element arrives.
Complexity: Time
Visual:
graph TD
A["nums: 1, 1, 1, 2, 2, 3"] --> B["freq: 1 has 3, 2 has 2, 3 has 1"]
B --> C["k = 2"]
C --> D["Push (3, 1), push (2, 2)"]
D --> E["Push (1, 3), size > 2, pop (1, 3)"]
E --> F["Return 1 and 2"]
Code:
var topKFrequent = function(nums, k) {
const freq = new Map();
for (const num of nums) {
freq.set(num, (freq.get(num) || 0) + 1);
}
// pairs [count, num] in a min-heap; the root is the
// least frequent element among the current k
const heap = new Heap([], (a, b) => a[0] < b[0]);
for (const [num, count] of freq) {
heap.push([count, num]);
if (heap.size() > k) {
heap.pop();
}
}
const result = [];
while (heap.size() > 0) {
result.push(heap.pop()[1]);
}
return result;
};The frequency map costs
5. K Closest Points to Origin
LeetCode 973 | Difficulty: Medium
Brief: Given an array of points, return the k closest points to the origin.
Why this pattern: k closest means k smallest distances, so the max-heap variation applies. The heap caps at k, and the root holds the largest distance among the survivors.
Key Insight: Compare by squared distance, not the raw distance. The square root is monotonic, so it cannot change the ordering, and skipping it avoids floating point entirely.
Complexity: Time
Visual:
graph TD
A["points: (1,3), (-2,2), (5,-1), (3,4)"] --> B["dists: 10, 8, 26, 25"]
B --> C["k = 2"]
C --> D["(5,-1) at distance 26 is popped"]
D --> E["(3,4) at distance 25 is popped"]
E --> F["Survivors: (1,3) and (-2,2)"]
Code:
var kClosest = function(points, k) {
const dist = (p) => p[0] * p[0] + p[1] * p[1];
// max-heap by distance keeps the k closest points. the
// root is the farthest survivor and leaves when a
// closer point arrives.
const heap = new Heap([], (a, b) => dist(a) > dist(b));
for (const p of points) {
heap.push(p);
if (heap.size() > k) {
heap.pop();
}
}
const result = [];
while (heap.size() > 0) {
result.push(heap.pop());
}
return result;
};The squared distance trick is worth stating out loud in an interview. It removes the square root, keeps the comparison exact with integers, and changes nothing about the answer. The custom comparator is the only new piece compared to the plain top-K template.
Hard Problems
6. Merge k Sorted Lists
LeetCode 23 | Difficulty: Hard
Brief: Merge k sorted linked lists into one sorted linked list.
Why this pattern: The min-heap holds the current head of every list. The smallest head in the heap is always the next element of the merged result.
Key Insight: When a head is popped, the next node from that same list enters the heap. This keeps the heap at exactly k nodes and ensures the smallest available element is always at the root.
Complexity: Time
Visual:
graph TD
A["Heads 1, 1, 2 enter the min-heap"] --> B["Pop 1, push that list's next: 4"]
B --> C["Pop 1, push that list's next: 3"]
C --> D["Pop 2, push that list's next: 6"]
D --> E["Heap holds 3, 4, 6"]
E --> F{"Heap empty?"}
F -->|No| B
F -->|Yes| G["Result: 1, 1, 2, 3, 4, 4, 5, 6"]
Code:
var mergeKLists = function(lists) {
class ListNode {
constructor(val = 0, next = null) {
this.val = val;
this.next = next;
}
}
// min-heap of list nodes ordered by value
const heap = new Heap([], (a, b) => a.val < b.val);
for (const head of lists) {
if (head) {
heap.push(head);
}
}
const dummy = new ListNode();
let current = dummy;
while (heap.size() > 0) {
current.next = heap.pop();
current = current.next;
if (current.next) {
heap.push(current.next);
}
}
return dummy.next;
};The dummy node removes the special case of the empty result, the same trick as in linked list reversal problems. The pairwise merge alternative, which pairs lists and merges two at a time, appears on the divide and conquer page . Both are
7. Find Median from Data Stream
LeetCode 295 | Difficulty: Hard
Brief: Design a class that supports adding a number and finding the median of all numbers added so far.
Why this pattern: The two-heap split keeps the lower half in a max-heap and the upper half in a min-heap. The median is always at the boundary between the two roots.
Key Insight: Every insert pushes into the lower half, moves the largest lower value into the upper half, then moves it back if the sizes drift. After the dance, the lower half holds the extra element when the total count is odd, and the median is a root read either way.
Complexity: Time
Visual:
graph TD
A["Add 1"] --> B["low: [1], high: []"]
B --> C["Add 2"]
C --> D["low: [1], high: [2]"]
D --> E["Add 3"]
E --> F["low: [1, 2], high: [3]"]
F --> G["Median is the low root: 2"]
Code:
var MedianFinder = function() {
// low is a max-heap, high is a min-heap
this.low = new Heap([], (a, b) => a > b);
this.high = new Heap();
};
MedianFinder.prototype.addNum = function(num) {
// push into low, then move low's largest into high.
// this keeps every low value below every high value.
this.low.push(num);
this.high.push(this.low.pop());
// low holds the extra element when the count is odd
if (this.low.size() < this.high.size()) {
this.low.push(this.high.pop());
}
};
MedianFinder.prototype.findMedian = function() {
if (this.low.size() > this.high.size()) {
return this.low.peek();
}
return (this.low.peek() + this.high.peek()) / 2;
};The two-step insert reads strangely at first, but the order matters. Pushing into low first and then moving low’s largest into high guarantees the ordering invariant, because low’s largest is always smaller than or equal to anything already in high. The rebalance step then repairs any size drift. Trace the insert with an even count and an odd count and the median read becomes obviously correct. Odd counts use the low root, and even counts average the two roots.
These seven problems cover the full range of heap applications. Start with the extraction loop in Last Stone Weight, internalize the size cap through the three top-K problems, and finish with the two-heap balance in Find Median from Data Stream. By the end, you should be able to look at a problem and say immediately which extreme needs to sit at the root, and which of the templates supplies it.