Part 12 · Lesson 46 · read
Heaps and Top-K
Build a priority queue by hand in JavaScript, then use it to find the top K items.
On this page
- The problem: always give me the smallest
- The heap rule and its array form
- Push and pop: sift up, sift down
- Traced: six pushes and one pop
- Writing a Heap in JavaScript
- Heapify: building a heap in O(n)
- Top-K: the size-k heap
- Top k frequent elements
- Merge k sorted lists
- Median of a data stream: two heaps
- Practice questions (7)
- Make it stick
- What's next
The problem: always give me the smallest
Some jobs keep asking for "the smallest item so far" (or the largest) while new items keep arriving. Examples: the next task with the earliest deadline, the nearest of many points, or the lightest stone. A tool that can add an item and remove the best item is called a priority queue (a waiting line where the most important item goes first, not the oldest). How could you build one from what you know?
- An unsorted array: adding is O(1), but finding the minimum means looking at every item, which is O(n).
- A sorted array: the minimum is at the end, but adding in the right place moves many items, which is O(n).
Either way, one of the two jobs is slow (O(n) means the work grows with the number of items). A heap makes both jobs O(log n), which is much faster. Looking at the best item is O(1), which means it takes the same small number of steps, however many items there are. A heap is the standard way to build a priority queue.
heapq and Java has PriorityQueue, but standard JavaScript has neither. In an interview you write your own, so this lesson builds one from scratch. It is only about 40 lines. You will paste the same class into every solution in this lesson.The heap property and its array form
A binary heap is a binary tree (each item has at most two children below it) with two rules:
- Shape: the tree is complete. Every level is full, except maybe the last one. The last level is filled from the left. There are no gaps.
- Heap property: in a min-heap, every item is smaller than or equal to its children. A max-heap is the opposite: every item is bigger than or equal to its children. So in a min-heap, the smallest item is always at the top (the root).
Notice what the rule does not say. It does not say how items on the same level compare with each other. For example, in a min-heap the left child may be bigger or smaller than the right child. A heap is only partly sorted. It is sorted just enough to know the minimum. That is why it is cheaper to keep than a fully sorted list.
Because the tree has no gaps, it fits in a plain array. You store it level by level, and you need no pointers. For an item at index i:
| Index i | Value | Parent (i − 1) >> 1 | Children 2i + 1, 2i + 2 |
|---|---|---|---|
| 0 | 1 | none (root) | 1 and 2 (values 3, 2) |
| 1 | 3 | 0 (value 1) | 3 and 4 (values 5, 9) |
| 2 | 2 | 0 (value 1) | 5 and 6 (value 8, and 6 does not exist) |
| 3 | 5 | 1 (value 3) | 7 and 8 (neither exists) |
| 4 | 9 | 1 (value 3) | 9 and 10 (neither exists) |
| 5 | 8 | 2 (value 2) | 11 and 12 (neither exists) |
The expression (i − 1) >> 1 means 'divide by two and drop the decimal part'. (It shifts the bits of the number one place to the right.) So (5 − 1) >> 1 = 2 and (4 − 1) >> 1 = 1. Math.floor((i − 1) / 2) means the same thing.
A complete tree with n items has a height of about log₂ n (the number of times you can halve n). Every operation below walks along one path from the root down to a leaf (an item with no children). That is where O(log n) comes from. With a million items, that is only about 20 steps.
Push and pop: sift up, sift down
Both operations break the heap rule in one place. Then they fix it by walking along a single path.
Push (sift up). Put the new item in the first free slot at the end of the array. This keeps the shape complete. The new item may now be smaller than its parent. So compare it with its parent, and swap while the parent is bigger. This moving up is called sift up (or "bubble up", like a bubble rising in water). It stops at the root, or at the first parent that is not bigger.
Pop (sift down). The answer is at index 0. But taking it out leaves a hole at the root. Fill the hole with the last item of the array. This keeps the shape complete, and the array just gets one item shorter. That item is probably too big for the root. So compare it with its two children. Swap it with the smaller child, and repeat while a child is smaller. This is sift down. Using the smaller child matters. That child becomes the new parent of the other child, so the rule still holds between them.
Each walk is at most as long as the height of the tree. So push and pop are both O(log n). Looking at index 0 is O(1).
Traced: six pushes and one pop
A min-heap kept in an array. The item at index i has its parent at (i − 1) >> 1 and its children at 2i + 1 and 2i + 2.
▶const heap = [];2for (const x of [5, 3, 8, 1, 9, 2]) {3 heap.push(x);4 let i = heap.length - 1;5 while (i > 0) {6 const parent = (i - 1) >> 1;7 if (heap[parent] <= heap[i]) break;8 [heap[parent], heap[i]] = [heap[i], heap[parent]];9 i = parent;10 }11}12const top = heap[0];13const last = heap.pop();14heap[0] = last;15let j = 0;16while (true) {17 const l = 2 * j + 1, r = 2 * j + 2;18 let small = j;19 if (l < heap.length && heap[l] < heap[small]) small = l;20 if (r < heap.length && heap[r] < heap[small]) small = r;21 if (small === j) break;22 [heap[small], heap[j]] = [heap[j], heap[small]];23 j = small;24}| heap | [] |
Writing a Heap in JavaScript
The class below takes a comparator. This is a small function, compare(a, b), that tells the heap which of two items should come out first. It returns a negative number when a should leave the heap before b. The default, a − b, gives a min-heap of numbers. Pass (a, b) => b − a and the very same code becomes a max-heap. Pass (a, b) => a[1] − b[1] and it orders pairs by their second value. So you write one class and you never copy the logic.
class Heap { // compare(a, b) < 0 means "a comes out before b". // The default makes a min-heap of numbers. Pass (a, b) => b - a for a max-heap. constructor(compare = (a, b) => a - b) { this.data = []; this.compare = compare; } get size() { return this.data.length; } peek() { return this.data[0]; } // the top item, without removing it push(value) { const d = this.data; d.push(value); // put it in the first free slot... let i = d.length - 1; while (i > 0) { // ...and sift it up const parent = (i - 1) >> 1; if (this.compare(d[i], d[parent]) >= 0) break; // parent is fine: stop [d[i], d[parent]] = [d[parent], d[i]]; i = parent; } } pop() { const d = this.data; if (d.length === 0) return undefined; const top = d[0]; const last = d.pop(); // take the last item off the end... if (d.length > 0) { d[0] = last; // ...put it at the root, and sift it down let i = 0; while (true) { const left = 2 * i + 1, right = 2 * i + 2; let best = i; if (left < d.length && this.compare(d[left], d[best]) < 0) best = left; if (right < d.length && this.compare(d[right], d[best]) < 0) best = right; if (best === i) break; // both children are no better: stop [d[i], d[best]] = [d[best], d[i]]; i = best; } } return top; }}const minHeap = new Heap();for (const x of [5, 3, 8, 1, 9, 2]) minHeap.push(x);console.log(minHeap.peek()); // 1const sorted = [];while (minHeap.size > 0) sorted.push(minHeap.pop());console.log(sorted); // [1, 2, 3, 5, 8, 9] (n pops = heap sort, O(n log n))const maxHeap = new Heap((a, b) => b - a);for (const x of [5, 3, 8, 1, 9, 2]) maxHeap.push(x);console.log(maxHeap.pop(), maxHeap.pop()); // 9 8const byCount = new Heap((a, b) => a[1] - b[1]); // items can be anything, if compare says how to order thembyCount.push(["a", 4]); byCount.push(["b", 1]); byCount.push(["c", 3]);console.log(byCount.pop()); // [ 'b', 1 ]If you take all n items out with pop, they come out in sorted order. This is called heap sort, and it is O(n log n). You rarely write it. But it shows that the heap really does ordering work.
heap.data only promises that the smallest item is first. If you print it, loop over it, or use data[1] as the second smallest, you have a bug. Always take items out with pop.Heapify: building a heap in O(n)
Say you already have all n items. Pushing them one by one costs O(n log n). There is a faster way, called heapify. Treat the array as a tree, and sift down every item that has children. Start from the last one and move back to the root. The leaves (the second half of the array) are already valid one-item heaps. So the loop starts at index n/2 − 1.
function siftDown(a, i, n) { // push a[i] down within the first n items while (true) { const left = 2 * i + 1, right = 2 * i + 2; let small = i; if (left < n && a[left] < a[small]) small = left; if (right < n && a[right] < a[small]) small = right; if (small === i) return; [a[i], a[small]] = [a[small], a[i]]; i = small; }}function heapify(a) { // rearranges the array in place into a min-heap for (let i = (a.length >> 1) - 1; i >= 0; i--) siftDown(a, i, a.length); return a; // items from index n/2 onwards are leaves already}console.log(heapify([5, 3, 8, 1, 9, 2])); // [1, 3, 2, 5, 9, 8]console.log(heapify([9, 8, 7, 6, 5])); // [5, 6, 7, 9, 8]Why is this O(n) and not O(n log n)? A sift down costs at most the height of the item it starts from. Most items are near the bottom. About n/2 items are leaves (cost 0). About n/4 are one level up (cost at most 1). About n/8 cost at most 2, and so on. The sum n/4 · 1 + n/8 · 2 + n/16 · 3 + … stays below n. Remember this idea: few items are tall, and many are short.
Top-K: the size-k heap
Find the k-th largest item. Sorting everything costs O(n log n). Instead, keep a heap that never grows past k items. Which kind of heap? A min-heap, which is surprising. The heap holds the k largest items seen so far. Its top is the smallest of those k. That is the first item to throw out when a bigger one arrives. After you have seen every item, the top is the k-th largest overall.
class Heap { constructor(compare = (a, b) => a - b) { this.data = []; this.compare = compare; } get size() { return this.data.length; } peek() { return this.data[0]; } push(x) { const d = this.data; d.push(x); for (let i = d.length - 1; i > 0; ) { const p = (i - 1) >> 1; if (this.compare(d[i], d[p]) >= 0) break; [d[i], d[p]] = [d[p], d[i]]; i = p; } } pop() { const d = this.data; if (d.length === 0) return undefined; const top = d[0], last = d.pop(); if (d.length) { d[0] = last; for (let i = 0; ; ) { const l = 2 * i + 1, r = l + 1; let b = i; if (l < d.length && this.compare(d[l], d[b]) < 0) b = l; if (r < d.length && this.compare(d[r], d[b]) < 0) b = r; if (b === i) break; [d[i], d[b]] = [d[b], d[i]]; i = b; } } return top; }}function kthLargest(nums, k) { const heap = new Heap(); // a MIN-heap that never holds more than k items for (const x of nums) { heap.push(x); if (heap.size > k) heap.pop(); // evict the smallest: it cannot be among the k largest } return heap.peek(); // the smallest of the k largest = the k-th largest}console.log(kthLargest([3, 2, 1, 5, 6, 4], 2)); // 5console.log(kthLargest([3, 2, 3, 1, 2, 4, 5, 5, 6], 4)); // 4Each of the n items costs O(log k) to push and maybe pop. So the total is O(n log k) time and O(k) space. When k is small, this is much better than sorting. It also works on a stream (items that keep arriving one at a time, so you cannot store everything). Remember the rule: for the k largest, use a min-heap of size k. For the k smallest, use a max-heap of size k.
Top k frequent elements
First count how often each value appears, using a Map (a lookup table that stores key and value pairs, here value and count). Then use the same size-k trick on the counts. The heap holds [value, count] pairs, and the comparator looks at the count. So the least frequent of the current candidates is always on top, ready to be thrown out.
class Heap { constructor(compare = (a, b) => a - b) { this.data = []; this.compare = compare; } get size() { return this.data.length; } peek() { return this.data[0]; } push(x) { const d = this.data; d.push(x); for (let i = d.length - 1; i > 0; ) { const p = (i - 1) >> 1; if (this.compare(d[i], d[p]) >= 0) break; [d[i], d[p]] = [d[p], d[i]]; i = p; } } pop() { const d = this.data; if (d.length === 0) return undefined; const top = d[0], last = d.pop(); if (d.length) { d[0] = last; for (let i = 0; ; ) { const l = 2 * i + 1, r = l + 1; let b = i; if (l < d.length && this.compare(d[l], d[b]) < 0) b = l; if (r < d.length && this.compare(d[r], d[b]) < 0) b = r; if (b === i) break; [d[i], d[b]] = [d[b], d[i]]; i = b; } } return top; }}function topKFrequent(nums, k) { const count = new Map(); for (const x of nums) count.set(x, (count.get(x) ?? 0) + 1); const heap = new Heap((a, b) => a[1] - b[1]); // min-heap of [value, count], ordered by count for (const entry of count) { heap.push(entry); if (heap.size > k) heap.pop(); // drop the least frequent of the k + 1 } const result = []; while (heap.size > 0) result.push(heap.pop()[0]); return result.reverse(); // most frequent first}console.log(topKFrequent([1, 1, 1, 2, 2, 3], 2)); // [1, 2]console.log(topKFrequent([4, 4, 5, 5, 5, 6], 1)); // [5]Counting is O(n). Pushing the different values is O(d log k), where d is the number of different values. So the total is O(n + d log k), which is at most O(n log k). (A bucket-sort version can reach O(n). You will see it in the practice questions.)
Merge k sorted lists
Lesson 37 merged two sorted linked lists (chains of nodes, where each node points to the next one) by repeatedly taking the smaller front node. With k lists, the next node of the answer is the smallest of k front nodes. Comparing all k fronts each time costs O(k) per node. A min-heap of the current fronts finds the smallest in O(log k). Pop the smallest, attach it to the answer, and push the next node from the same list.
class ListNode { constructor(val, next = null) { this.val = val; this.next = next; }}function fromArray(values) { const dummy = new ListNode(0); let tail = dummy; for (const v of values) { tail.next = new ListNode(v); tail = tail.next; } return dummy.next;}function toArray(head) { const out = []; for (let cur = head; cur !== null; cur = cur.next) out.push(cur.val); return out;}class Heap { constructor(compare = (a, b) => a - b) { this.data = []; this.compare = compare; } get size() { return this.data.length; } peek() { return this.data[0]; } push(x) { const d = this.data; d.push(x); for (let i = d.length - 1; i > 0; ) { const p = (i - 1) >> 1; if (this.compare(d[i], d[p]) >= 0) break; [d[i], d[p]] = [d[p], d[i]]; i = p; } } pop() { const d = this.data; if (d.length === 0) return undefined; const top = d[0], last = d.pop(); if (d.length) { d[0] = last; for (let i = 0; ; ) { const l = 2 * i + 1, r = l + 1; let b = i; if (l < d.length && this.compare(d[l], d[b]) < 0) b = l; if (r < d.length && this.compare(d[r], d[b]) < 0) b = r; if (b === i) break; [d[i], d[b]] = [d[b], d[i]]; i = b; } } return top; }}function mergeKLists(lists) { const heap = new Heap((a, b) => a.val - b.val); // holds the current front node of each list for (const head of lists) if (head !== null) heap.push(head); const dummy = new ListNode(0); let tail = dummy; while (heap.size > 0) { const node = heap.pop(); // the smallest front node of all lists tail.next = node; tail = node; if (node.next !== null) heap.push(node.next); // its list has a new front } return dummy.next;}const lists = [fromArray([1, 4, 5]), fromArray([1, 3, 4]), fromArray([2, 6])];console.log(toArray(mergeKLists(lists))); // [1, 1, 2, 3, 4, 4, 5, 6]console.log(toArray(mergeKLists([null, fromArray([7])]))); // [7]The heap never holds more than k nodes, and each of the N nodes is pushed and popped once. So the time is O(N log k) and the extra space is O(k). The nodes are linked again, not copied.
Median of a data stream: two heaps
Numbers arrive one at a time. At any moment you must report the median. The median is the middle value of the sorted data. If the count is even, it is the average of the two middle values. Sorting again after each number is too slow. The trick is to split the data into a lower half and an upper half:
- the lower half lives in a max-heap, so its biggest item is on top;
- the upper half lives in a min-heap, so its smallest item is on top;
- the two halves have the same size, or the lower half has one extra item.
Now the median comes from the two tops. If the count is odd, it is the top of the lower half. If the count is even, it is the average of both tops. To add a number, push it into the lower heap, then pop the top of the lower heap into the upper heap. This makes sure every lower item stays smaller than or equal to every upper item. Then, if the upper half became bigger, move one item back.
class Heap { constructor(compare = (a, b) => a - b) { this.data = []; this.compare = compare; } get size() { return this.data.length; } peek() { return this.data[0]; } push(x) { const d = this.data; d.push(x); for (let i = d.length - 1; i > 0; ) { const p = (i - 1) >> 1; if (this.compare(d[i], d[p]) >= 0) break; [d[i], d[p]] = [d[p], d[i]]; i = p; } } pop() { const d = this.data; if (d.length === 0) return undefined; const top = d[0], last = d.pop(); if (d.length) { d[0] = last; for (let i = 0; ; ) { const l = 2 * i + 1, r = l + 1; let b = i; if (l < d.length && this.compare(d[l], d[b]) < 0) b = l; if (r < d.length && this.compare(d[r], d[b]) < 0) b = r; if (b === i) break; [d[i], d[b]] = [d[b], d[i]]; i = b; } } return top; }}class MedianFinder { constructor() { this.low = new Heap((a, b) => b - a); // max-heap: the smaller half, biggest on top this.high = new Heap(); // min-heap: the larger half, smallest on top } addNum(x) { this.low.push(x); this.high.push(this.low.pop()); // route through low so every item in low stays <= every item in high if (this.high.size > this.low.size) this.low.push(this.high.pop()); // keep low the same size or one bigger } findMedian() { if (this.low.size > this.high.size) return this.low.peek(); // odd count: the middle item return (this.low.peek() + this.high.peek()) / 2; // even count: average of the two middles }}const m = new MedianFinder();const medians = [];for (const x of [5, 2, 8, 1, 9]) { m.addNum(x); medians.push(m.findMedian()); }console.log(medians); // [5, 3.5, 5, 3.5, 5]Adding is O(log n). Reading the median is O(1).
Practice questions
When a question says "k largest", "k closest", "most frequent" or "repeatedly take the best", think of a heap.
Return the k-th largest item of an unsorted array. This means the k-th when sorted from biggest to smallest, not the k-th different value.
Hint 1
Hint 2
Hint 3
Show all 3 solutions with explanations
Sort from biggest to smallest, then take the item at index k − 1.
function findKthLargest(nums, k) { return [...nums].sort((a, b) => b - a)[k - 1];}console.log(findKthLargest([3, 2, 1, 5, 6, 4], 2)); // 5console.log(findKthLargest([3, 2, 3, 1, 2, 4, 5, 5, 6], 4)); // 4It takes O(n log n) time. It is always a good first answer, and it is only two lines.
- Push each number into a min-heap.
- If the heap has more than k items, pop the smallest.
- At the end, the top is the k-th largest.
class Heap { constructor(compare = (a, b) => a - b) { this.data = []; this.compare = compare; } get size() { return this.data.length; } peek() { return this.data[0]; } push(x) { const d = this.data; d.push(x); for (let i = d.length - 1; i > 0; ) { const p = (i - 1) >> 1; if (this.compare(d[i], d[p]) >= 0) break; [d[i], d[p]] = [d[p], d[i]]; i = p; } } pop() { const d = this.data; if (d.length === 0) return undefined; const top = d[0], last = d.pop(); if (d.length) { d[0] = last; for (let i = 0; ; ) { const l = 2 * i + 1, r = l + 1; let b = i; if (l < d.length && this.compare(d[l], d[b]) < 0) b = l; if (r < d.length && this.compare(d[r], d[b]) < 0) b = r; if (b === i) break; [d[i], d[b]] = [d[b], d[i]]; i = b; } } return top; }}function findKthLargest(nums, k) { const heap = new Heap(); for (const x of nums) { heap.push(x); if (heap.size > k) heap.pop(); } return heap.peek();}console.log(findKthLargest([3, 2, 1, 5, 6, 4], 2)); // 5console.log(findKthLargest([3, 2, 3, 1, 2, 4, 5, 5, 6], 4)); // 4It takes O(n log k) time and O(k) space. After the loop, the heap holds exactly the k largest numbers. The smallest of them is the k-th largest.
- Pick a random number (the pivot) and split the numbers into bigger, equal and smaller groups.
- If the k-th place falls inside the "bigger" group, continue with only that group.
- If it falls in the "equal" group, the pivot is the answer.
- Otherwise, continue with the "smaller" group. Lower k by the sizes of the groups you skipped.
function findKthLargest(nums, k) { let items = nums; while (true) { const pivot = items[Math.floor(Math.random() * items.length)]; const bigger = items.filter((x) => x > pivot); const equal = items.filter((x) => x === pivot); if (k <= bigger.length) { items = bigger; } else if (k <= bigger.length + equal.length) { return pivot; } else { k -= bigger.length + equal.length; items = items.filter((x) => x < pivot); } }}console.log(findKthLargest([3, 2, 1, 5, 6, 4], 2)); // 5console.log(findKthLargest([3, 2, 3, 1, 2, 4, 5, 5, 6], 4)); // 4On average it takes O(n) time. Each round throws away a good part of the items, so the work keeps shrinking. The worst case is O(n²) if the pivots are unlucky. A random pivot makes this very unlikely. It uses O(n) extra space here because of the filtered arrays.
Start with the sort to show a correct answer. Then give the size-k heap, which is the pattern interviewers expect. Mention quickselect as the option that is O(n) on average. (LeetCode 215.)
Design a class that is created with a number k and a starting array. Its add(val) method adds a number and returns the k-th largest of all numbers so far.
Hint 1
Hint 2
Show all 2 solutions with explanations
Put each new number in its sorted place. The answer is the item k places from the end.
class KthLargest { constructor(k, nums) { this.k = k; this.sorted = [...nums].sort((a, b) => a - b); } add(val) { let i = 0; while (i < this.sorted.length && this.sorted[i] < val) i++; this.sorted.splice(i, 0, val); // shifting makes this O(n) return this.sorted[this.sorted.length - this.k]; }}const kth = new KthLargest(3, [4, 5, 8, 2]);console.log([3, 5, 10, 9, 4].map((x) => kth.add(x))); // [4, 5, 5, 8, 8]Each add takes O(n) time, and the array keeps every number forever.
Keep only the k largest numbers in a min-heap. The top is always the k-th largest.
class Heap { constructor(compare = (a, b) => a - b) { this.data = []; this.compare = compare; } get size() { return this.data.length; } peek() { return this.data[0]; } push(x) { const d = this.data; d.push(x); for (let i = d.length - 1; i > 0; ) { const p = (i - 1) >> 1; if (this.compare(d[i], d[p]) >= 0) break; [d[i], d[p]] = [d[p], d[i]]; i = p; } } pop() { const d = this.data; if (d.length === 0) return undefined; const top = d[0], last = d.pop(); if (d.length) { d[0] = last; for (let i = 0; ; ) { const l = 2 * i + 1, r = l + 1; let b = i; if (l < d.length && this.compare(d[l], d[b]) < 0) b = l; if (r < d.length && this.compare(d[r], d[b]) < 0) b = r; if (b === i) break; [d[i], d[b]] = [d[b], d[i]]; i = b; } } return top; }}class KthLargest { constructor(k, nums) { this.k = k; this.heap = new Heap(); for (const x of nums) this.add(x); } add(val) { this.heap.push(val); if (this.heap.size > this.k) this.heap.pop(); return this.heap.peek(); }}const kth = new KthLargest(3, [4, 5, 8, 2]);console.log([3, 5, 10, 9, 4].map((x) => kth.add(x))); // [4, 5, 5, 8, 8]Each add takes O(log k) time, and the memory is O(k) in total. The problem promises there are at least k numbers when you ask. So the heap is full whenever add returns.
Use the size-k min-heap. The stream may never end, and this stores only k numbers. (LeetCode 703.)
Return the k most frequent values of an array. The answer can be in any order. The problem promises that the answer is unique.
Hint 1
Hint 2
Hint 3
Show all 3 solutions with explanations
Count the values. Sort the different values by count, biggest first. Take the first k.
function topKFrequent(nums, k) { const count = new Map(); for (const x of nums) count.set(x, (count.get(x) ?? 0) + 1); return [...count.keys()].sort((a, b) => count.get(b) - count.get(a)).slice(0, k);}console.log(topKFrequent([1, 1, 1, 2, 2, 3], 2)); // [1, 2]console.log(topKFrequent([1], 1)); // [1]It takes O(n + d log d) time, where d is the number of different values. That is at most O(n log n).
Push each [value, count] pair into a min-heap ordered by count. Pop whenever it has more than k items.
class Heap { constructor(compare = (a, b) => a - b) { this.data = []; this.compare = compare; } get size() { return this.data.length; } peek() { return this.data[0]; } push(x) { const d = this.data; d.push(x); for (let i = d.length - 1; i > 0; ) { const p = (i - 1) >> 1; if (this.compare(d[i], d[p]) >= 0) break; [d[i], d[p]] = [d[p], d[i]]; i = p; } } pop() { const d = this.data; if (d.length === 0) return undefined; const top = d[0], last = d.pop(); if (d.length) { d[0] = last; for (let i = 0; ; ) { const l = 2 * i + 1, r = l + 1; let b = i; if (l < d.length && this.compare(d[l], d[b]) < 0) b = l; if (r < d.length && this.compare(d[r], d[b]) < 0) b = r; if (b === i) break; [d[i], d[b]] = [d[b], d[i]]; i = b; } } return top; }}function topKFrequent(nums, k) { const count = new Map(); for (const x of nums) count.set(x, (count.get(x) ?? 0) + 1); const heap = new Heap((a, b) => a[1] - b[1]); for (const entry of count) { heap.push(entry); if (heap.size > k) heap.pop(); } const result = []; while (heap.size > 0) result.push(heap.pop()[0]); return result.reverse();}console.log(topKFrequent([1, 1, 1, 2, 2, 3], 2)); // [1, 2]console.log(topKFrequent([1], 1)); // [1]It takes O(n + d log k) time. It is good when k is much smaller than d.
- Count each value.
- Make n + 1 buckets. Put each value in the bucket with the same number as its count. A value cannot appear more than n times.
- Read the buckets from the highest number down until you have k values.
function topKFrequent(nums, k) { const count = new Map(); for (const x of nums) count.set(x, (count.get(x) ?? 0) + 1); const buckets = Array.from({ length: nums.length + 1 }, () => []); for (const [value, c] of count) buckets[c].push(value); const result = []; for (let c = buckets.length - 1; c >= 0 && result.length < k; c--) { for (const value of buckets[c]) { if (result.length < k) result.push(value); } } return result;}console.log(topKFrequent([1, 1, 1, 2, 2, 3], 2)); // [1, 2]console.log(topKFrequent([1], 1)); // [1]It takes O(n) time and space, with no heap and no sort. It works because counts are small whole numbers. Other top-k problems do not have this limit.
The heap shows the pattern. The bucket version is the O(n) answer if the interviewer asks for something faster. (LeetCode 347.)
You get points [x, y] on a flat plane. Return the k points closest to the origin (0, 0), measured in a straight line. The answer can be in any order.
Hint 1
Hint 2
Show all 2 solutions with explanations
Sort all points by squared distance. Take the first k.
function kClosest(points, k) { const dist = ([x, y]) => x * x + y * y; return [...points].sort((a, b) => dist(a) - dist(b)).slice(0, k);}console.log(kClosest([[1, 3], [-2, 2]], 1)); // [ [ -2, 2 ] ]console.log(kClosest([[3, 3], [5, -1], [-2, 4]], 2)); // [ [ 3, 3 ], [ -2, 4 ] ]It takes O(n log n) time. Squared distances stay whole numbers and give the same order as real distances.
Push each point into a max-heap ordered by distance. If it has more than k points, pop the farthest. The points left are the k closest.
class Heap { constructor(compare = (a, b) => a - b) { this.data = []; this.compare = compare; } get size() { return this.data.length; } peek() { return this.data[0]; } push(x) { const d = this.data; d.push(x); for (let i = d.length - 1; i > 0; ) { const p = (i - 1) >> 1; if (this.compare(d[i], d[p]) >= 0) break; [d[i], d[p]] = [d[p], d[i]]; i = p; } } pop() { const d = this.data; if (d.length === 0) return undefined; const top = d[0], last = d.pop(); if (d.length) { d[0] = last; for (let i = 0; ; ) { const l = 2 * i + 1, r = l + 1; let b = i; if (l < d.length && this.compare(d[l], d[b]) < 0) b = l; if (r < d.length && this.compare(d[r], d[b]) < 0) b = r; if (b === i) break; [d[i], d[b]] = [d[b], d[i]]; i = b; } } return top; }}function kClosest(points, k) { const dist = ([x, y]) => x * x + y * y; const heap = new Heap((a, b) => dist(b) - dist(a)); // max-heap by distance 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.reverse(); // closest first}console.log(kClosest([[1, 3], [-2, 2]], 1)); // [ [ -2, 2 ] ]console.log(kClosest([[3, 3], [5, -1], [-2, 4]], 2)); // [ [ 3, 3 ], [ -2, 4 ] ]It takes O(n log k) time and O(k) space. It is the same pattern as the k-th largest, turned around. For the k smallest, use a max-heap of size k.
Use the max-heap when k is small compared with n. Use the sort when you want the shortest code. Quickselect (see question 1) takes O(n) on average. (LeetCode 973.)
You get an array of k sorted linked lists. Merge them into one sorted linked list and return its first node (the head).
Hint 1
Hint 2
Show all 3 solutions with explanations
Walk through every list and put all values in one array. Sort it, then build a new list.
class ListNode { constructor(val, next = null) { this.val = val; this.next = next; }}function fromArray(values) { const dummy = new ListNode(0); let tail = dummy; for (const v of values) { tail.next = new ListNode(v); tail = tail.next; } return dummy.next;}function toArray(head) { const out = []; for (let cur = head; cur !== null; cur = cur.next) out.push(cur.val); return out;}function mergeKLists(lists) { const values = []; for (let head of lists) { for (; head !== null; head = head.next) values.push(head.val); } values.sort((a, b) => a - b); return fromArray(values);}console.log(toArray(mergeKLists([fromArray([1, 4, 5]), fromArray([1, 3, 4]), fromArray([2, 6])]))); // [1, 1, 2, 3, 4, 4, 5, 6]console.log(toArray(mergeKLists([]))); // []It takes O(N log N) time, where N is the total number of nodes. It ignores that the lists are already sorted. It is a fine first answer.
- Push the first node of each list that is not empty into a min-heap ordered by value.
- Again and again, pop the smallest node, add it to the answer, and push the next node of the same list.
class ListNode { constructor(val, next = null) { this.val = val; this.next = next; }}function fromArray(values) { const dummy = new ListNode(0); let tail = dummy; for (const v of values) { tail.next = new ListNode(v); tail = tail.next; } return dummy.next;}function toArray(head) { const out = []; for (let cur = head; cur !== null; cur = cur.next) out.push(cur.val); return out;}class Heap { constructor(compare = (a, b) => a - b) { this.data = []; this.compare = compare; } get size() { return this.data.length; } peek() { return this.data[0]; } push(x) { const d = this.data; d.push(x); for (let i = d.length - 1; i > 0; ) { const p = (i - 1) >> 1; if (this.compare(d[i], d[p]) >= 0) break; [d[i], d[p]] = [d[p], d[i]]; i = p; } } pop() { const d = this.data; if (d.length === 0) return undefined; const top = d[0], last = d.pop(); if (d.length) { d[0] = last; for (let i = 0; ; ) { const l = 2 * i + 1, r = l + 1; let b = i; if (l < d.length && this.compare(d[l], d[b]) < 0) b = l; if (r < d.length && this.compare(d[r], d[b]) < 0) b = r; if (b === i) break; [d[i], d[b]] = [d[b], d[i]]; i = b; } } return top; }}function mergeKLists(lists) { const heap = new Heap((a, b) => a.val - b.val); for (const head of lists) if (head !== null) heap.push(head); const dummy = new ListNode(0); let tail = dummy; while (heap.size > 0) { const node = heap.pop(); tail.next = node; tail = node; if (node.next !== null) heap.push(node.next); } return dummy.next;}console.log(toArray(mergeKLists([fromArray([1, 4, 5]), fromArray([1, 3, 4]), fromArray([2, 6])]))); // [1, 1, 2, 3, 4, 4, 5, 6]console.log(toArray(mergeKLists([]))); // []It takes O(N log k) time and O(k) space. The heap size depends on the number of lists, not the number of nodes.
Merge the lists two at a time. Then merge the results two at a time, and so on, until one list is left. It works like the rounds of a sports tournament.
class ListNode { constructor(val, next = null) { this.val = val; this.next = next; }}function fromArray(values) { const dummy = new ListNode(0); let tail = dummy; for (const v of values) { tail.next = new ListNode(v); tail = tail.next; } return dummy.next;}function toArray(head) { const out = []; for (let cur = head; cur !== null; cur = cur.next) out.push(cur.val); return out;}function mergeTwo(a, b) { const dummy = new ListNode(0); let tail = dummy; while (a !== null && b !== null) { if (a.val <= b.val) { tail.next = a; a = a.next; } else { tail.next = b; b = b.next; } tail = tail.next; } tail.next = a !== null ? a : b; return dummy.next;}function mergeKLists(lists) { if (lists.length === 0) return null; let current = lists; while (current.length > 1) { const next = []; for (let i = 0; i < current.length; i += 2) { next.push(i + 1 < current.length ? mergeTwo(current[i], current[i + 1]) : current[i]); } current = next; } return current[0];}console.log(toArray(mergeKLists([fromArray([1, 4, 5]), fromArray([1, 3, 4]), fromArray([2, 6])]))); // [1, 1, 2, 3, 4, 4, 5, 6]console.log(toArray(mergeKLists([]))); // []There are about log₂ k rounds. Every round touches each node at most once. So the time is O(N log k). It relinks the existing nodes, and the only extra space is the small array of list heads, which is O(k). If you merged them one after another into a growing result, it would cost O(N·k) instead.
The heap is the most common answer. Merging in pairs reaches the same time without a heap. (LeetCode 23.)
Design a class with addNum(x) and findMedian(). The median is the middle value of the sorted numbers so far. If the count is even, it is the average of the two middle values.
Hint 1
Hint 2
Hint 3
Show all 2 solutions with explanations
Put each number in its sorted place. Read the median from the middle.
class MedianFinder { constructor() { this.sorted = []; } addNum(x) { let i = 0; while (i < this.sorted.length && this.sorted[i] < x) i++; this.sorted.splice(i, 0, x); } findMedian() { const n = this.sorted.length, mid = n >> 1; return n % 2 === 1 ? this.sorted[mid] : (this.sorted[mid - 1] + this.sorted[mid]) / 2; }}const m = new MedianFinder();const medians = [];for (const x of [5, 2, 8, 1, 9]) { m.addNum(x); medians.push(m.findMedian()); }console.log(medians); // [5, 3.5, 5, 3.5, 5]Each add takes O(n) time (finding the place and moving items). Each median question takes O(1).
lowis a max-heap with the smaller half.highis a min-heap with the larger half.- To add a number: push it into
low, then move the top oflowtohigh. Ifhighis now bigger, move its top back. - To get the median: use the top of
lowif the count is odd. If the count is even, use the average of both tops.
class Heap { constructor(compare = (a, b) => a - b) { this.data = []; this.compare = compare; } get size() { return this.data.length; } peek() { return this.data[0]; } push(x) { const d = this.data; d.push(x); for (let i = d.length - 1; i > 0; ) { const p = (i - 1) >> 1; if (this.compare(d[i], d[p]) >= 0) break; [d[i], d[p]] = [d[p], d[i]]; i = p; } } pop() { const d = this.data; if (d.length === 0) return undefined; const top = d[0], last = d.pop(); if (d.length) { d[0] = last; for (let i = 0; ; ) { const l = 2 * i + 1, r = l + 1; let b = i; if (l < d.length && this.compare(d[l], d[b]) < 0) b = l; if (r < d.length && this.compare(d[r], d[b]) < 0) b = r; if (b === i) break; [d[i], d[b]] = [d[b], d[i]]; i = b; } } return top; }}class MedianFinder { constructor() { this.low = new Heap((a, b) => b - a); this.high = new Heap(); } addNum(x) { this.low.push(x); this.high.push(this.low.pop()); if (this.high.size > this.low.size) this.low.push(this.high.pop()); } findMedian() { if (this.low.size > this.high.size) return this.low.peek(); return (this.low.peek() + this.high.peek()) / 2; }}const m = new MedianFinder();const medians = [];for (const x of [5, 2, 8, 1, 9]) { m.addNum(x); medians.push(m.findMedian()); }console.log(medians); // [5, 3.5, 5, 3.5, 5]Each add takes O(log n) time and each median takes O(1). Sending the new number through low into high makes sure that nothing in low is ever bigger than something in high. This works whichever half the number really belongs to.
Use two heaps. The sorted-array version is a good way to start and explain the goal. (LeetCode 295.)
Each turn, take the two heaviest stones and smash them together. If they weigh the same, both are destroyed. Otherwise, the lighter one is destroyed and the heavier one loses that much weight. Return the weight of the last stone, or 0 if no stone is left.
Hint 1
Hint 2
Show all 2 solutions with explanations
While two or more stones remain, sort them and take out the two largest. Add back the difference if it is not zero.
function lastStoneWeight(stones) { const pile = [...stones]; while (pile.length > 1) { pile.sort((a, b) => a - b); const y = pile.pop(), x = pile.pop(); // y is the heaviest, x is the second heaviest if (y !== x) pile.push(y - x); } return pile.length === 1 ? pile[0] : 0;}console.log(lastStoneWeight([2, 7, 4, 1, 8, 1])); // 1console.log(lastStoneWeight([1])); // 1console.log(lastStoneWeight([3, 3])); // 0It takes O(n² log n) time. There can be up to n rounds, and each round sorts up to n stones.
Keep the stones in a max-heap. Pop two, then push back their difference if it is not zero.
class Heap { constructor(compare = (a, b) => a - b) { this.data = []; this.compare = compare; } get size() { return this.data.length; } peek() { return this.data[0]; } push(x) { const d = this.data; d.push(x); for (let i = d.length - 1; i > 0; ) { const p = (i - 1) >> 1; if (this.compare(d[i], d[p]) >= 0) break; [d[i], d[p]] = [d[p], d[i]]; i = p; } } pop() { const d = this.data; if (d.length === 0) return undefined; const top = d[0], last = d.pop(); if (d.length) { d[0] = last; for (let i = 0; ; ) { const l = 2 * i + 1, r = l + 1; let b = i; if (l < d.length && this.compare(d[l], d[b]) < 0) b = l; if (r < d.length && this.compare(d[r], d[b]) < 0) b = r; if (b === i) break; [d[i], d[b]] = [d[b], d[i]]; i = b; } } return top; }}function lastStoneWeight(stones) { const heap = new Heap((a, b) => b - a); // max-heap for (const s of stones) heap.push(s); while (heap.size > 1) { const y = heap.pop(), x = heap.pop(); if (y !== x) heap.push(y - x); } return heap.size === 1 ? heap.peek() : 0;}console.log(lastStoneWeight([2, 7, 4, 1, 8, 1])); // 1console.log(lastStoneWeight([1])); // 1console.log(lastStoneWeight([3, 3])); // 0It takes O(n log n) time. Each round does a fixed number of heap steps and removes at least one stone.
Use the max-heap. This is the classic "keep taking the largest" problem. (LeetCode 1046.)
Make it stick
- Say the heap rule, and give the index formulas for the parent and the children of an item.
- Explain push (sift up) and pop (sift down), and why each is O(log n).
- Say why the k-th largest uses a min-heap of size k, and what it costs.
- Explain how two heaps give the median of a stream in O(log n) per number.
Revisit schedule: solve two of the questions again tomorrow, again in 3 days, and once more in a week — without looking at the answers. Each time it will feel easier. That is the memory forming.
What's next
A heap picks the best item of what is left, again and again. Lesson 47 uses the same idea in a different place: greedy algorithms. There you take the choice that looks best right now, and you prove it never hurts later.
Practise on LeetCode
Real interview problems that use exactly what this lesson teaches. Solve them in JavaScript after the practice questions above.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Part of the study guide
Related series