GUIDE · Trees
Heaps (priority queues)
complete binary tree in an array; root is min (or max); O(log n) push/pop, O(1) peek.
At a glance
- WHAT
- complete binary tree in an array; root is min (or max); O(log n) push/pop, O(1) peek.
- TELL
- "k largest / smallest", "top k", "kth", "merge k sorted", "running median", "schedule by priority", "closest k", "most frequent first", "min rooms".
- SAY
- min-heap / max-heap, priority queue, sift up (bubble up) / sift down (heapify down), heapify in O(n), two-heap technique, k-way merge, comparator.
- BIG-O
- push/pop O(log n), peek O(1), build from array O(n). "Top k" = O(n log k) with heap of size k.
- NOTE
- JS HAS NO BUILT-IN HEAP. Write this class from memory. Indexing: parent (i-1)>>1, children 2i+1, 2i+2.
The idea#
Picture a single-elimination bracket taped to the gym wall. The winner sits in the top slot. When a player leaves, you only replay the matches on the path from that slot down to an empty one.
A heap is a complete binary tree stored in an array. The root is the minimum, or the maximum if you flip the order. Push and pop are O(log n), peek is O(1), and building a heap from an array by sifting down from the middle is O(n). A top-k heap that never grows past k items costs O(n log k). JavaScript has no built-in heap. You write the class from memory.
The array has no holes. Index 0 is the root, the parent of i is (i - 1) >> 1, and the children are 2i + 1 and 2i + 2. Sift up bubbles a new item toward the root while it outranks its parent. Sift down drops a replacement until both children lose to it. When cmp(a, b) < 0, a has the higher priority. Pass (a, b) => b - a and the same class is a max-heap.
You rarely need every element in order. You need the current winner, or the k best. A min-heap of size k holds the k largest values seen so far, and its root is the smallest of those k, which is the kth largest overall. Evict when the size passes k. That is O(log k) per item.
Why it matters#
The phrases are k largest, k smallest, top k, kth, merge k sorted lists, running median, schedule by priority, closest k, and most frequent first. Sorting the whole input is O(n log n). A heap of size k answers the real question in O(n log k).
Two heaps split a stream. The lower half is a max-heap, so its root is the biggest of the small numbers. The upper half is a min-heap, so its root is the smallest of the big numbers. Keep the sizes within one. The median is the root of the bigger half, or the average of the two roots. Add is O(log n). Reading the median is O(1).
A k-way merge holds one head from each sorted array, tagged with which array it came from. Pop the smallest, push that array's next value, O(log k) per element. A to-do list you only read from the top is the same bargain: insert in O(log n), peek in O(1). Say the index formula out loud. If you cannot place a node in the array, you do not have the structure yet.
A small example#
function kthLargest(nums, k) { const h = []; const up = (i) => { while (i > 0 && h[i] < h[(i - 1) >> 1]) { const p = (i - 1) >> 1; [h[i], h[p]] = [h[p], h[i]]; i = p; } }; const down = (i) => { const l = 2 * i + 1, r = l + 1; let m = i; if (l < h.length && h[l] < h[m]) m = l; if (r < h.length && h[r] < h[m]) m = r; if (m === i) return; [h[i], h[m]] = [h[m], h[i]]; down(m); }; for (const x of nums) { h.push(x); up(h.length - 1); if (h.length > k) { h[0] = h.pop(); if (h.length) down(0); } } return h[0];}const third = kthLargest([3, 2, 1, 5, 6, 4], 3);third is 4. The min-heap never keeps more than the three largest values, and the root is the smallest of those three.
What the code below is doing#
Section 1 is the heap class. The comparator defaults to a min-heap. Push sifts up. Pop moves the last item to the root and sifts down. Peek is the root. Building by repeated push is O(n log n). Heapify in O(n), sifting down from (n >> 1) - 1 to 0, is the version to name even though this class builds by pushing.
Section 2 finds the kth largest with a min-heap of size k. Pop when the size passes k. The root is the answer. O(n log k) time, O(k) space. Section 3 is the top k frequent values: a frequency map, then a min-heap of [count, value] capped at k. Section 4 merges k sorted arrays. The heap holds [value, arrayIdx, elemIdx]. Pop the minimum and push that array's successor. O(N log k) for N total elements.
Section 5 is the running median. A max-heap keeps the lower half, a min-heap the upper half, and the sizes stay within one. Add is O(log n), median is O(1). Section 6 keeps the k closest points to the origin in a max-heap by distance, size k, so the farthest of the survivors is easy to evict.
Section 7 is the task scheduler in its counting form. Idle slots are (maxFreq - 1) * (n + 1) + countOfMax, and the answer is the larger of that and the number of tasks. O(n) time, O(26) space. A heap would place tasks one by one. The formula skips the simulation. Section 8 is meeting rooms: sort by start, keep a min-heap of end times, and pop when the earliest end is at or before the next start. The heap's size is the number of rooms. O(n log n).
Section 9 reorganizes a string so no two equal letters sit together. A max-heap by count pops the most frequent letter, holds the previous one back for a step, then pushes it again if it still has count left. O(n log 26). Section 10 builds the nth ugly number, a number whose only prime factors are 2, 3, and 5. A min-heap plus a seen set pops the next ugly number and pushes x*2, x*3, and x*5. O(n log n).
When you talk about it, say this: the root is the winner in O(1), push and pop are O(log n), and top k is a heap of size k at O(n log k).
Everything below is the demonstration. Same idea, written as code you can run.
'use strict';const assert = require('node:assert/strict');MinHeap with comparator (cmp(a,b) < 0 => a has higher priority); pass (a,b)=>b-a for a MaxHeap#
class Heap { #a = []; #cmp; constructor(cmp = (a, b) => a - b, items = []) { this.#cmp = cmp; for (const x of items) this.push(x); } get size() { return this.#a.length; } peek() { return this.#a[0]; } push(x) { this.#a.push(x); let i = this.#a.length - 1; // sift up while (i > 0) { const p = (i - 1) >> 1; if (this.#cmp(this.#a[i], this.#a[p]) >= 0) break; [this.#a[i], this.#a[p]] = [this.#a[p], this.#a[i]]; i = p; } } pop() { const a = this.#a, top = a[0], last = a.pop(); if (a.length) { a[0] = last; let i = 0; // sift down for (;;) { const l = 2 * i + 1, r = l + 1; let m = i; if (l < a.length && this.#cmp(a[l], a[m]) < 0) m = l; if (r < a.length && this.#cmp(a[r], a[m]) < 0) m = r; if (m === i) break; [a[i], a[m]] = [a[m], a[i]]; i = m; } } return top; }}// heapify in O(n): sift-down from (n>>1)-1 to 0 instead of n pushes. Not shown; say it exists.const h = new Heap();[5, 3, 8, 1].forEach((x) => h.push(x));const mx = new Heap((a, b) => b - a, [5, 3, 8]);Verified by 3 asserts
assert.deepEqual([h.pop(), h.pop(), h.pop(), h.pop(), h.pop()], [1, 3, 5, 8, undefined]);assert.equal(mx.peek(), 8);assert.equal(new Heap().size, 0);Kth largest element#
min-heap of size k; pop when size > k; root is answer
function findKthLargest(nums, k) { const hp = new Heap(); for (const x of nums) { hp.push(x); if (hp.size > k) hp.pop(); } return hp.peek();}Verified by 3 asserts
assert.equal(findKthLargest([3, 2, 1, 5, 6, 4], 2), 5);assert.equal(findKthLargest([3, 2, 3, 1, 2, 4, 5, 5, 6], 4), 4);assert.equal(findKthLargest([-1], 1), -1);Top-k frequent elements#
freq map, min-heap of [count, val] size k
function topKFrequent(nums, k) { const f = new Map(); for (const x of nums) f.set(x, (f.get(x) ?? 0) + 1); const hp = new Heap((a, b) => a[0] - b[0]); for (const e of f) { hp.push([e[1], e[0]]); if (hp.size > k) hp.pop(); } const out = []; while (hp.size) out.push(hp.pop()[1]); return out.reverse(); // most frequent first}Verified by 3 asserts
assert.deepEqual(topKFrequent([1, 1, 1, 2, 2, 3], 2), [1, 2]);assert.deepEqual(topKFrequent([1], 1), [1]);assert.deepEqual(topKFrequent([], 2), []);Merge k sorted arrays#
heap of [value, arrayIdx, elemIdx]; pop min, push its successor
function mergeK(arrays) { const hp = new Heap((a, b) => a[0] - b[0]), out = []; arrays.forEach((arr, i) => { if (arr.length) hp.push([arr[0], i, 0]); }); while (hp.size) { const [v, i, j] = hp.pop(); out.push(v); if (j + 1 < arrays[i].length) hp.push([arrays[i][j + 1], i, j + 1]); } return out;}Verified by 3 asserts
assert.deepEqual(mergeK([[1, 4, 5], [1, 3, 4], [2, 6]]), [1, 1, 2, 3, 4, 4, 5, 6]);assert.deepEqual(mergeK([[], [1]]), [1]);assert.deepEqual(mergeK([]), []);Find median from data stream (two heaps)#
maxHeap = lower half, minHeap = upper half; keep sizes within 1
class MedianFinder { #lo = new Heap((a, b) => b - a); #hi = new Heap(); addNum(x) { this.#lo.push(x); this.#hi.push(this.#lo.pop()); // ensure every lo <= every hi if (this.#hi.size > this.#lo.size) this.#lo.push(this.#hi.pop()); // lo holds the extra when odd } findMedian() { return this.#lo.size > this.#hi.size ? this.#lo.peek() : (this.#lo.peek() + this.#hi.peek()) / 2; }}const mf = new MedianFinder();mf.addNum(1); mf.addNum(2);mf.addNum(3);mf.addNum(-10);Verified by 3 asserts
assert.equal(mf.findMedian(), 1.5);assert.equal(mf.findMedian(), 2);assert.equal(mf.findMedian(), 1.5);K closest points to origin#
max-heap by distance, size k
function kClosest(points, k) { const d = ([x, y]) => x * x + y * y; const hp = new Heap((a, b) => d(b) - d(a)); for (const p of points) { hp.push(p); if (hp.size > k) hp.pop(); } const out = []; while (hp.size) out.push(hp.pop()); return out.sort((a, b) => d(a) - d(b));}Verified by 3 asserts
assert.deepEqual(kClosest([[1, 3], [-2, 2]], 1), [[-2, 2]]);assert.deepEqual(kClosest([[3, 3], [5, -1], [-2, 4]], 2), [[3, 3], [-2, 4]]);assert.deepEqual(kClosest([], 1), []);Task scheduler (greedy, math form)#
idle slots = (maxFreq-1)*(n+1) + countOfMax; answer = max(len, that)
// Heap form: pop up to n+1 most frequent per round, decrement, push back. Same answer; math is faster to write.function leastInterval(tasks, n) { const f = new Map(); for (const t of tasks) f.set(t, (f.get(t) ?? 0) + 1); const maxF = Math.max(...f.values()); const ties = [...f.values()].filter((c) => c === maxF).length; return Math.max(tasks.length, (maxF - 1) * (n + 1) + ties);}Verified by 3 asserts
assert.equal(leastInterval(['A', 'A', 'A', 'B', 'B', 'B'], 2), 8);assert.equal(leastInterval(['A', 'A', 'A', 'B', 'B', 'B'], 0), 6);assert.equal(leastInterval(['A'], 5), 1);Meeting rooms II (min rooms) with heap#
sort by start; min-heap of end times; pop if top <= start
function minMeetingRooms(intervals) { const ends = new Heap(); for (const [s, e] of [...intervals].sort((a, b) => a[0] - b[0])) { if (ends.size && ends.peek() <= s) ends.pop(); // reuse a room ends.push(e); } return ends.size;}Verified by 3 asserts
assert.equal(minMeetingRooms([[0, 30], [5, 10], [15, 20]]), 2);assert.equal(minMeetingRooms([[7, 10], [2, 4]]), 1);assert.equal(minMeetingRooms([]), 0);Reorganize string (no two adjacent same)#
max-heap by count; pop most frequent, hold back previous
function reorganizeString(s) { const f = new Map(); for (const c of s) f.set(c, (f.get(c) ?? 0) + 1); const hp = new Heap((a, b) => b[0] - a[0], [...f].map(([c, n]) => [n, c])); let out = '', prev = null; while (hp.size) { const [n, c] = hp.pop(); out += c; if (prev) hp.push(prev); prev = n - 1 > 0 ? [n - 1, c] : null; } return out.length === s.length ? out : '';}Verified by 3 asserts
assert.equal(reorganizeString('aab'), 'aba');assert.equal(reorganizeString('aaab'), '');assert.equal(reorganizeString('a'), 'a');Ugly number II (nth with only factors 2,3,5)#
min-heap + seen set; pop min, push x*2, x*3, x*5
function nthUglyNumber(n) { const hp = new Heap(undefined, [1]), seen = new Set([1]); // undefined -> default numeric comparator let x = 1; for (let i = 0; i < n; i++) { x = hp.pop(); for (const m of [2, 3, 5]) if (!seen.has(x * m)) { seen.add(x * m); hp.push(x * m); } } return x;}Verified by 3 asserts
assert.equal(nthUglyNumber(10), 12);assert.equal(nthUglyNumber(1), 1);assert.equal(nthUglyNumber(15), 24);See also