Heaps
Heaps and priority queues in TypeScript. JavaScript has no built-in heap, so this sheet is built
around one short generic Heap<T> class you paste into solutions: the array model, the class,
comparators for min- and max-heaps, priorities and tie-breaks, updating and removing without
decrease-key, top k and merging, two heaps, heapify and heap sort, LeetCode's
@datastructures-js/priority-queue, costs, and the idioms DS&A solutions are built from. Every
example is checked with TypeScript 7.0 and 6.0 under strict and runs on Node 24 LTS, Node 26 and
Bun 1.4. How a binary heap works inside is in Heaps, graphs that use
this class are in Graphs, and the Python side is in
Python heaps.
The heap model
A binary heap keeps a complete binary tree in a plain array: the children of index i sit at
2 * i + 1 and 2 * i + 2, and its parent at (i - 1) >> 1. In a min-heap every parent is at
most its children, so the smallest item is always at index 0. Nothing else is sorted.
| Operation | Cost | How |
|---|---|---|
| peek | O(1) | read index 0 |
| push | O(log n) | append, then sift up: swap with the parent while smaller |
| pop | O(log n) | move the last item to index 0, then sift down: swap with the smaller child while larger |
| build from n items | O(n) | sift down every parent, last parent first |
| find or remove any other item | O(n) | not what a heap is for |
const a = [1, 3, 2, 7, 5, 4]; // a min-heap in an array
const parent = (i: number) => (i - 1) >> 1;
const kids = (i: number) => [2 * i + 1, 2 * i + 2];
parent(5); // 2
kids(1); // [3, 4]
// every item is at least its parent
const isMinHeap = (xs: readonly number[]) =>
xs.every((x, i) => i === 0 || xs[parent(i)]! <= x);
isMinHeap(a); // true
isMinHeap([1, 3, 2, 0]); // false: 0 sits below 3
a.toSorted((x, y) => x - y); // [1, 2, 3, 4, 5, 7]>> 1 is a fast Math.floor(x / 2) for the non-negative indexes a heap uses; it would go wrong only
past 2³¹ - 1, far beyond any array that fits in memory.
A typed heap
The class to paste. cmp works like Array.prototype.sort's comparator: a negative result puts x
nearer the top, so (a, b) => a - b is a min-heap and (a, b) => b - a a max-heap. The optional
items are copied and heapified in O(n).
// A binary heap. cmp(x, y) < 0 puts x nearer the top,
// like sort: (a, b) => a - b is a min-heap.
class Heap<T> {
private readonly a: T[];
private readonly cmp: (x: T, y: T) => number;
constructor(
cmp: (x: T, y: T) => number,
items: Iterable<T> = [],
) {
this.cmp = cmp;
this.a = [...items];
// heapify in O(n): sift down each parent, last first
for (let i = (this.a.length >> 1) - 1; i >= 0; i--) {
this.down(i);
}
}
get size(): number {
return this.a.length;
}
peek(): T | undefined {
return this.a[0];
}
push(x: T): void {
this.a.push(x);
let i = this.a.length - 1;
while (i > 0) {
const p = (i - 1) >> 1; // parent
if (!this.less(i, p)) break;
this.swap(i, p);
i = p;
}
}
pop(): T | undefined {
const end = this.a.length - 1;
if (end > 0) this.swap(0, end); // last item to the top
const top = this.a.pop();
this.down(0);
return top;
}
private down(i: number): void {
const n = this.a.length;
for (;;) {
const l = 2 * i + 1; // left child; right is l + 1
let m = i; // the smallest of i and its children
if (l < n && this.less(l, m)) m = l;
if (l + 1 < n && this.less(l + 1, m)) m = l + 1;
if (m === i) return;
this.swap(i, m);
i = m;
}
}
// i and j are always in range, so the casts are safe
private less(i: number, j: number): boolean {
return this.cmp(this.a[i] as T, this.a[j] as T) < 0;
}
private swap(i: number, j: number): void {
const t = this.a[i] as T;
this.a[i] = this.a[j] as T;
this.a[j] = t;
}
}
const h = new Heap<number>((a, b) => a - b, [5, 1, 8, 3]);
h.peek(); // 1
h.push(0);
[h.pop(), h.pop()]; // [0, 1]
h.size; // 3
new Heap<number>((a, b) => a - b).pop(); // undefined1/8
a = [1, 3, 2, 7, 5, 4] is a min-heap: every parent is at most its children, and the children of index i sit at 2i + 1 and 2i + 2.
What the design choices buy:
- Typed both ways.
peekandpopreturnT | undefined, so the compiler makes you handle an empty heap. Where a size check already proves it isn't empty,h.pop()!is the honest shortcut. popnever shifts. It swaps the root with the last item and callsArray.prototype.pop, which is O(1).shiftwould move every item.- Ready for
noUncheckedIndexedAccess. With that flag everythis.a[i]reads asT | undefined; the two private helpers cast once, where the index is known to be in range, and the class has noany. It compiles with or without the flag. - Only erasable syntax. No parameter properties (
constructor(private cmp: ...)), enums or namespaces, so the class also runs under Node's built-in type stripping (node heap.tson Node 24) anderasableSyntaxOnly. Node 24 and 26 reject parameter properties in that mode withERR_UNSUPPORTED_TYPESCRIPT_SYNTAX. - The invariant is private. Unlike a Python
heapqlist, callers can'tpushonto the array behind the heap's back. They can still break it by changing a field the comparator reads while the item is inside: update by pushing a new entry instead (see Updating & removing).
The rest of this sheet uses Heap without repeating it: paste the class above into each solution.
Max-heaps & comparators
| Want | Comparator | Notes |
|---|---|---|
| min-heap of numbers | (a, b) => a - b | the sign of a - b is right for any two finite doubles: no overflow trap as with C ints |
| max-heap | (a, b) => b - a, or (a, b) => cmp(b, a) | swapping the arguments reverses any comparator, strings and objects included |
| strings, code-unit order | (a, b) => (a < b ? -1 : a > b ? 1 : 0) | UTF-16 code-unit order, the same as sort() with no comparator |
| strings, human order | (a, b) => a.localeCompare(b) | accents and case by locale; slower |
| objects by a field | (a, b) => a.cost - b.cost | |
| several keys | (a, b) => a[0] - b[0] || a[1] - b[1] | 0 || x falls through to the next key |
| bigints | (a, b) => (a < b ? -1 : a > b ? 1 : 0) | a - b is a bigint, which the number return type rejects |
// Heap<T>: the class from A typed heap, above
const maxH = new Heap<number>((a, b) => b - a, [3, 9, 4]);
maxH.pop(); // 9
const words = new Heap<string>(
(a, b) => (a < b ? -1 : a > b ? 1 : 0),
["pear", "apple", "fig"],
);
words.pop(); // "apple"
type Job = { name: string; cost: number };
const jobs = new Heap<Job>((a, b) => a.cost - b.cost);
jobs.push({ name: "ship", cost: 5 });
jobs.push({ name: "plan", cost: 1 });
jobs.peek()?.name; // "plan"
const big = new Heap<bigint>((a, b) =>
a < b ? -1 : a > b ? 1 : 0,
);
big.push(2n ** 64n);
big.push(1n);
big.pop(); // 1n
// @ts-expect-error: a bigint difference isn't a number
new Heap<bigint>((a, b) => a - b);
// NaN compares as neither smaller nor larger: order breaks
const asc = (a: number, b: number) => a - b;
const broken = new Heap<number>(asc, [4, NaN, 2, 1]);
const out: number[] = [];
while (broken.size > 0) out.push(broken.pop()!);
out; // [2, 1, 4, NaN]Priorities & tie-breaks
Entries are usually tuples. Unlike Python, a tie never compares the payloads unless your comparator does, so nothing throws; but the heap isn't stable, so items with equal priorities come out in no promised order. A sequence number fixes that.
| Entry | Comparator | Pops in order of |
|---|---|---|
[prio, item] | a[0] - b[0] | priority; ties in no promised order |
[prio, seq, item] | a[0] - b[0] || a[1] - b[1] | priority, then insertion order (FIFO) |
[prio, seq, item] | a[0] - b[0] || b[1] - a[1] | priority, then newest first (LIFO) |
{ prio, seq, item } | the same, by field name | as above; easier to read in big solutions |
// Heap<T>: the class from A typed heap, above
type Pair = [prio: number, name: string];
const plain = new Heap<Pair>((a, b) => a[0] - b[0]);
for (const name of ["a", "b", "c", "d"]) {
plain.push([1, name]);
}
[1, 2, 3, 4].map(() => plain.pop()?.[1]);
// ["a", "d", "c", "b"]: equal priorities, scrambled
type Entry<T> = [prio: number, seq: number, item: T];
const fifo = <T,>(a: Entry<T>, b: Entry<T>) =>
a[0] - b[0] || a[1] - b[1];
const q = new Heap<Entry<string>>(fifo);
let seq = 0;
const todo = [[2, "b"], [1, "a"], [2, "c"]] as const;
for (const [prio, job] of todo) q.push([prio, seq++, job]);
const order: string[] = [];
while (q.size > 0) order.push(q.pop()![2]);
order; // ["a", "b", "c"]The labels in [prio: number, seq: number, item: T] only name the slots in hovers and errors; at
runtime an entry is a plain 3-item array.
Updating & removing
A heap can't find an item or change a priority in place, so there is no decrease-key. Leave the old entry where it is and deal with it when it reaches the top.
| Need | Do | Cost |
|---|---|---|
| lower or raise a priority | push a new entry; when popping, skip entries that no longer match | O(log n) push; one extra entry per update |
| delete an item | record its key in a Set and skip it when popped | O(1) to mark |
| change many priorities | rebuild: new Heap(cmp, liveEntries) | O(n) |
| true decrease-key | an indexed heap that keeps each key's position in a Map and sifts from there | O(log n), but about twice the code |
// Heap<T>: the class from A typed heap, above
const best = new Map([["a", 5], ["b", 3]]); // current prio
const h = new Heap<[number, string]>(
(x, y) => x[0] - y[0],
[[5, "a"], [3, "b"]],
);
best.set("a", 1); // decrease-key: record it...
h.push([1, "a"]); // ...and push a fresh entry
const order: [string, number][] = [];
while (h.size > 0) {
const [prio, key] = h.pop()!;
if (prio !== best.get(key)) continue; // stale
order.push([key, prio]);
}
order; // [["a", 1], ["b", 3]]1/5
The heap holds [3, "b"] and [5, "a"], and best records each key's current priority: a → 5, b → 3.
Dijkstra skips stale entries the same way (if (d > dist[u]) continue). The heap can grow to one
entry per update instead of one per key; that costs memory, not correctness. A queue that also
supports removal by key is in Recipes.
Top k & merging
| Need | Use | Why |
|---|---|---|
the k largest of n, k much smaller than n | a size-k min-heap | O(n log k) time, O(k) memory |
k close to n, all items at hand | xs.toSorted((a, b) => b - a).slice(0, k) | one O(n log n) sort |
k === 1 | a loop or reduce | Math.max(...xs) throws RangeError past about 125,000 items on Node 24 |
the k closest points | a size-k max-heap by squared distance | no Math.sqrt, no rounding |
one sorted stream from k sorted arrays | a heap of [value, list, index] heads | O(log k) per item, O(k) memory |
// Heap<T>: the class from A typed heap, above
function topK(xs: Iterable<number>, k: number): number[] {
const h = new Heap<number>((a, b) => a - b); // min-heap
for (const x of xs) {
if (h.size < k) h.push(x);
else if (x > h.peek()!) {
h.pop(); // evict the weakest kept item
h.push(x);
}
}
const out: number[] = [];
while (h.size > 0) out.push(h.pop()!);
return out.reverse(); // largest first
}
topK([5, 1, 9, 3, 7, 9, 2], 3); // [9, 9, 7]
function* merge(...lists: number[][]): Generator<number> {
type Head = [value: number, list: number, index: number];
const h = new Heap<Head>((a, b) => a[0] - b[0]);
lists.forEach((xs, i) => {
if (xs.length > 0) h.push([xs[0]!, i, 0]);
});
while (h.size > 0) {
const [v, i, j] = h.pop()!;
yield v;
const xs = lists[i]!;
if (j + 1 < xs.length) h.push([xs[j + 1]!, i, j + 1]);
}
}
[...merge([1, 4, 9], [2, 3, 10], [5])];
// [1, 2, 3, 4, 5, 9, 10]1/8
x = 5: the heap holds fewer than k = 3 items, so it is pushed. Inside, h = [5].
On 100,000 random doubles (Node 24, median of 9 runs), topK(xs, 10) took about 2 ms and
xs.toSorted((a, b) => b - a).slice(0, 10) about 44 ms. The heap's head start shrinks as k
grows, but on random input most items never get in, so it stayed ahead even at k = 50000
(9 ms). The hand-rolled merge over linked lists is in
Merge k sorted lists, and the k-th smallest with a heap
in K-th smallest with a heap.
Two heaps
Two heaps keep a moving boundary: a max-heap holds everything below it and a min-heap everything
above it, so both items next to the boundary are O(1) to read. The running median is the classic
case: keep lo.size equal to hi.size or one more.
// Heap<T>: the class from A typed heap, above
class Median {
private lo = new Heap<number>((a, b) => b - a); // max
private hi = new Heap<number>((a, b) => a - b); // min
add(x: number): void {
this.lo.push(x);
this.hi.push(this.lo.pop()!); // lo's largest → hi
if (this.hi.size > this.lo.size) {
this.lo.push(this.hi.pop()!);
}
}
median(): number {
const lo = this.lo.peek() ?? NaN;
if (this.lo.size > this.hi.size) return lo;
return (lo + (this.hi.peek() ?? NaN)) / 2;
}
}
const m = new Median();
const out: number[] = [];
for (const x of [5, 15, 1, 3]) {
m.add(x);
out.push(m.median());
}
out; // [5, 10, 5, 4]1/6
add(5): 5 goes into lo, and lo's largest, 5, moves straight on into hi. Routing every value through lo keeps everything in lo at most everything in hi.
Every add routes the new value through lo into hi, so every item in lo stays at most every
item in hi; the last step rebalances the sizes. JavaScript prints 10, not 10.0: there is only
one number type. The same shape answers "maximize capital" (a pool of locked projects ordered by
cost, a max-heap of affordable profits; see Recipes).
Heapify & heap sort
new Heap(cmp, items) builds the heap bottom-up: it sifts down every parent, from the last one
(index (n >> 1) - 1) back to the root. Half the items are leaves and do no work, a quarter move at
most one level, an eighth at most two, and the sum stays under n swaps. That is O(n), against
O(n log n) for n separate pushes. On 100,000 random doubles (Node 24), heapifying took about
2.2 ms and 100,000 pushes about 5.0 ms.
1/8
new Heap(cmp, [9, 5, 7, 1, 8, 2, 4]) copies the items. Indexes 3 to 6 are leaves, and a single item is already a heap.
Heap sort heapifies with a max-heap, then swaps the largest item to the end n - 1 times, sifting
down each time. It is O(n log n), needs no extra memory, and isn't stable. With numbers compared
inline it is quick: on 100,000 random doubles (Node 24) the version below took about 27 ms, against
57 ms for sort((a, b) => a - b) and 10 ms for Float64Array.prototype.sort(). The built-in sort is
still the default: it is stable, takes any comparator and is one line.
function heapSort(a: number[]): number[] {
const down = (i: number, n: number): void => {
for (;;) {
const l = 2 * i + 1;
let m = i; // the largest of i and its children
if (l < n && a[l]! > a[m]!) m = l;
if (l + 1 < n && a[l + 1]! > a[m]!) m = l + 1;
if (m === i) return;
[a[i], a[m]] = [a[m]!, a[i]!];
i = m;
}
};
for (let i = (a.length >> 1) - 1; i >= 0; i--) {
down(i, a.length); // a max-heap, in place
}
for (let end = a.length - 1; end > 0; end--) {
[a[0], a[end]] = [a[end]!, a[0]!]; // max to the end
down(0, end);
}
return a;
}
heapSort([5, 2, 9, 1, 5, 6]); // [1, 2, 5, 5, 6, 9]LeetCode's priority queue
LeetCode's JavaScript and TypeScript environments include the npm package
@datastructures-js/priority-queue, so solutions there often use it instead of pasting a class.
Outside LeetCode, install and import it. Its API changed completely between major versions 5 and 6;
the examples below are version 6.4.0, the latest on npm.
import {
MaxPriorityQueue,
MinPriorityQueue,
PriorityQueue,
} from "@datastructures-js/priority-queue";
const nums = new MinPriorityQueue<number>();
nums.enqueue(5).enqueue(1).enqueue(4); // chainable
nums.front(); // 1
nums.dequeue(); // 1
nums.size(); // 2
// the callback returns each item's priority
type Bid = { name: string; value: number };
const bids = new MaxPriorityQueue<Bid>((b) => b.value);
bids.enqueue({ name: "x", value: 3000 });
bids.enqueue({ name: "y", value: 1000 });
bids.dequeue()?.name; // "x"
// or a comparator, like sort's
const tasks = new PriorityQueue<[number, string]>(
(a, b) => a[0] - b[0] || a[1].localeCompare(b[1]),
);
tasks.enqueue([2, "write"]).enqueue([1, "test"]);
tasks.dequeue(); // [1, "test"]
new MinPriorityQueue<number>().dequeue(); // null
const q = MinPriorityQueue.fromArray([5, 3, 8, 1]);
q.toArray(); // [1, 3, 5, 8]: a sorted copy
[...q]; // [1, 3, 5, 8]
q.size(); // 0: iterating dequeued everything| v6 member | Returns | Notes |
|---|---|---|
new MinPriorityQueue<T>(getValue?), new MaxPriorityQueue<T>(getValue?) | a queue | getValue: (x: T) => number | string gives the priority; leave it out for numbers and strings |
new PriorityQueue<T>(compare) | a queue | compare works like sort's |
enqueue(x), alias push(x) | the queue | O(log n); chainable |
dequeue(), alias pop() | T | null | O(log n); null when empty, not undefined |
front(), back() | T | null | the highest-priority item; an item of the lowest priority |
size(), isEmpty() | number, boolean | methods, not properties |
toArray() | T[] | a sorted copy: O(n log n); the queue is unchanged |
for...of, spread | each item | dequeues as it goes: the queue ends up empty |
fromArray(xs, getValue?), new MinPriorityQueue(getValue, xs) | a queue | O(n); uses xs itself as storage, so pass [...xs] to keep yours |
contains(cb), remove(cb) | boolean, removed items | O(n) scans |
fix() (6.4.0) | the queue | repairs the order after you change priorities in place |
Version 5 (still common in older solutions) worked differently: MinPriorityQueue took an options
object such as { priority: (x) => x.value }, and front() and dequeue() returned
{ priority, element } wrappers. Version 6 returns the item itself, and the version 5 options
object makes the constructor throw MinPriorityQueue constructor requires a callback for object values. A solution that reads .dequeue().element silently gets undefined on version 6. Since
6.3.3, MinPriorityQueue and MaxPriorityQueue also accept a { compare } options object, the
shape version 5's PriorityQueue took.
Equal priorities come out in no promised order in either version: give PriorityQueue a sequence
number for FIFO ties, exactly as with the class above.
Costs
| Operation | Cost | Notes |
|---|---|---|
peek(), size | O(1) | |
push, pop | O(log n) | at most ⌊log2 n⌋ swaps |
new Heap(cmp, items) | O(n) | n pushes cost O(n log n) |
top k of n with a size-k heap | O(n log k) | O(k) memory |
k-way merge | O(log k) per item | O(k) memory |
| find, update or remove one item | O(n) | then rebuild in O(n); or go lazy, as above |
| pop everything (heap sort) | O(n log n) | not stable |
On 100,000 random doubles (Node 24, median of 9 runs, each case in a fresh process), pushing them
all and popping them all took about 21 ms with the class above, 28 ms with MinPriorityQueue, and
1.2 s with a sorted array kept by binary search, splice and shift.
Alternatives
| Use | When | Notes |
|---|---|---|
the Heap<T> class | the default | about 70 lines with blank lines; typed; no dependency |
@datastructures-js/priority-queue | LeetCode, or a dependency is fine | about 1.35× slower than the class in the run above; null for empty |
a sorted array, binary search and splice | small n, and you also need ranks or removal by value | O(n) per insert: about 58× slower at 100,000 items |
one toSorted(cmp) | every item is known up front and no pushes follow pops | O(n log n) once |
| a bucket queue: an array of lists indexed by priority | small integer priorities (0-1 BFS, counts up to n) | O(1) push; pop scans forward to the next non-empty bucket |
| a monotonic deque | the max or min of a sliding window | O(n) for the whole scan; a heap needs lazy deletion and O(n log n) |
a loop or reduce | one extreme, once | O(n), no heap |
DS&A idioms
The heap moves problem solutions are built from, and the trap in each. The patterns are in the DS&A sheets (linked in the last column).
| Job | TypeScript | Watch out | More |
|---|---|---|---|
k-th largest | a size-k min-heap; peek() at the end | a min-heap keeps the k largest: the weakest sits on top, ready to evict | Top-k |
Top k frequent | count in a Map, then a size-k min-heap of [count, item] | bucket by count instead for O(n) | Top k frequent |
k-way merge | [value, list, index] heads | push the next item from the same list only | Merge k sorted lists |
| Dijkstra, Prim | [dist, node] entries; continue when d > dist[node] | no decrease-key: stale entries stay until popped | Dijkstra |
| Meeting rooms | a min-heap of end times; pop while the earliest end is at most the start | sort the meetings by start first | Intervals |
| Task scheduler, reorganize string | a max-heap of remaining counts; hold the last-used item out | push it back only after the next pick | Reorganize string |
k closest points | a size-k max-heap by x * x + y * y | no Math.sqrt: squared distances order the same way | Top-k |
| Running median | max-heap of the low half, min-heap of the high half | keep lo.size - hi.size at 0 or 1 | Two heaps |
| Unlock-then-pick | items sorted by cost, a max-heap of unlocked values | move items across as the budget grows | Maximize capital |
| Lazy deletion | a Set (or a count Map, with duplicates) of removed keys; pop while the top is marked | the heap keeps dead entries until they reach the top | Updating & removing |
| Sliding window max | a monotonic deque of indexes | a heap works too, with lazy deletion, but costs O(log n) per step | Monotonic stack & deque |
| Objects in a heap | a comparator on a field, plus seq for FIFO ties | changing that field while the object is inside breaks the heap | Priorities & tie-breaks |
// Heap<T>: the class from A typed heap, above
const kth = new Heap<number>((a, b) => a - b);
for (const x of [3, 2, 1, 5, 6, 4]) {
kth.push(x);
if (kth.size > 2) kth.pop(); // keep the 2 largest
}
kth.peek(); // 5: the 2nd largest
type Pt = [x: number, y: number];
const d2 = ([x, y]: Pt) => x * x + y * y;
const near = new Heap<Pt>((a, b) => d2(b) - d2(a)); // max
const pts: Pt[] = [[1, 3], [-2, 2], [5, 8], [0, 1]];
for (const p of pts) {
near.push(p);
if (near.size > 2) near.pop(); // drop the farthest
}
[near.pop(), near.pop()]; // [[-2, 2], [0, 1]]Typing
| Type | For |
|---|---|
Heap<number>, Heap<[number, string]> | the item type; the comparator is checked against it |
[prio: number, seq: number, item: T] | labeled tuple entries: the names show in hovers and errors |
readonly [prio: number, item: T] | entries whose priority can't be changed while they are inside |
type Cmp<T> = (a: T, b: T) => number | naming and reusing comparators |
T | undefined from peek() and pop() | handle empty heaps, or ! after a size check |
noUncheckedIndexedAccess | the class compiles with it; your own h[0]-style code then needs checks |
// Heap<T>: the class from A typed heap, above
type Cmp<T> = (a: T, b: T) => number;
type Entry = readonly [prio: number, name: string];
const byPrio: Cmp<Entry> = (a, b) => a[0] - b[0];
const q = new Heap<Entry>(byPrio, [[2, "b"], [1, "a"]]);
const bump = (e: Entry) => {
// @ts-expect-error: readonly, so the priority can't change
e[0] = 9;
};
// @ts-expect-error: the comparator must return a number
new Heap<string>((a, b) => a > b);
const top = q.pop(); // Entry | undefined
const prio = top?.[0] ?? -1;
prio; // 1readonly is checked only at compile time: at runtime the entry is an ordinary array, so the
compiler is your only guard against changing a priority behind the heap's back.
Coming from Python
| Python | TypeScript |
|---|---|
heapq functions on a plain list | no built-in: the Heap<T> class, or @datastructures-js/priority-queue on LeetCode |
heappush(h, x), heappop(h) | h.push(x), h.pop() |
h[0] | h.peek(), undefined when empty instead of IndexError |
heapify(xs) in place | new Heap(cmp, xs), which copies: xs is unchanged |
| tuples compare field by field | write the comparator: a[0] - b[0] || a[1] - b[1]. Never a < b on arrays: it compares their strings, so [10] < [9] is true |
heappush_max (3.14) or negated numbers | swap the comparator's arguments: (a, b) => b - a, or cmp(b, a) for any type |
(prio, count, item) | [prio, seq, item] with a comparator that reads seq |
a tie on uncomparable payloads raises TypeError | never raises: the comparator decides, and ties come out in no promised order |
heappushpop, heapreplace | push and pop in the right order |
nlargest(k, xs) | a size-k min-heap, or toSorted(...).slice(0, k) |
heapq.merge(*its) | the merge generator in Top k & merging |
len(h) | h.size |
Recipes
Kth largest in a stream
When numbers keep arriving and you need the k-th largest after each one.
// Heap<T>: the class from A typed heap, above
class KthLargest {
private readonly h = new Heap<number>((a, b) => a - b);
private readonly k: number;
constructor(k: number, nums: number[]) {
this.k = k;
for (const x of nums) this.add(x);
}
add(x: number): number {
this.h.push(x);
if (this.h.size > this.k) this.h.pop();
return this.h.peek()!;
}
}
const s = new KthLargest(3, [4, 5, 8, 2]);
[3, 5, 10, 9, 4].map((x) => s.add(x)); // [4, 5, 5, 8, 8]Top k frequent elements
When you need the k most common items: count, then keep the k largest counts.
// Heap<T>: the class from A typed heap, above
function topKFrequent(nums: number[], k: number): number[] {
const count = new Map<number, number>();
for (const x of nums) {
count.set(x, (count.get(x) ?? 0) + 1);
}
const h = new Heap<[n: number, x: number]>(
(a, b) => a[0] - b[0], // min-heap by count
);
for (const [x, n] of count) {
h.push([n, x]);
if (h.size > k) h.pop();
}
const out: number[] = [];
while (h.size > 0) out.push(h.pop()![1]);
return out.reverse(); // most frequent first
}
topKFrequent([1, 1, 1, 2, 2, 3], 2); // [1, 2]Merge k sorted lists
When merging linked lists: the heap holds one node per list, compared by value only.
// Heap<T>: the class from A typed heap, above
class ListNode {
val: number;
next: ListNode | null;
constructor(val = 0, next: ListNode | null = null) {
this.val = val;
this.next = next;
}
}
const list = (...xs: number[]) =>
xs.reduceRight<ListNode | null>(
(next, v) => new ListNode(v, next),
null,
);
function mergeKLists(lists: (ListNode | null)[]) {
const h = new Heap<ListNode>((a, b) => a.val - b.val);
for (const node of lists) if (node) h.push(node);
const dummy = new ListNode();
let tail = dummy;
while (h.size > 0) {
const node = h.pop()!;
tail = tail.next = node;
if (node.next) h.push(node.next);
}
return dummy.next;
}
const lists = [list(1, 4, 5), list(1, 3, 4), list(2, 6)];
let n = mergeKLists(lists);
const vals: number[] = [];
for (; n; n = n.next) vals.push(n.val);
vals; // [1, 1, 2, 3, 4, 4, 5, 6]Meeting rooms II
When counting how many rooms (or machines, or trains) must run at once.
// Heap<T>: the class from A typed heap, above
function minRooms(meetings: [number, number][]): number {
const sorted = meetings.toSorted((a, b) => a[0] - b[0]);
const ends = new Heap<number>((a, b) => a - b);
for (const [start, end] of sorted) {
if (ends.size > 0 && ends.peek()! <= start) ends.pop();
ends.push(end); // reuse that room, or open one
}
return ends.size;
}
minRooms([[0, 30], [5, 10], [15, 20]]); // 2
minRooms([[7, 10], [2, 4]]); // 1Task scheduler
When equal tasks need a cooldown of n slots between them: always run the most common ready task.
// Heap<T>: the class from A typed heap, above
function leastInterval(tasks: string[], n: number): number {
const count = new Map<string, number>();
for (const t of tasks) {
count.set(t, (count.get(t) ?? 0) + 1);
}
const ready = new Heap<number>( // max-heap of counts
(a, b) => b - a,
count.values(),
);
const cooling: [left: number, at: number][] = []; // FIFO
let time = 0;
while (ready.size > 0 || cooling.length > 0) {
time++;
const left = (ready.pop() ?? 0) - 1;
if (left > 0) cooling.push([left, time + n]);
if (cooling[0]?.[1] === time) {
ready.push(cooling.shift()![0]); // ready again
}
}
return time;
}
leastInterval(["A", "A", "A", "B", "B", "B"], 2); // 8
leastInterval(["A", "C", "A", "B", "D", "B"], 1); // 6cooling gains at most one task per time step and releases them in the same order, so a plain array
works as its queue; shift is O(k) here for at most 26 distinct tasks.
Reorganize string
When no two equal items may be adjacent: always place the most frequent remaining item, but never the one just placed.
// Heap<T>: the class from A typed heap, above
function reorganize(s: string): string {
const count = new Map<string, number>();
for (const ch of s) {
count.set(ch, (count.get(ch) ?? 0) + 1);
}
type Left = [n: number, ch: string];
const h = new Heap<Left>(
(a, b) => b[0] - a[0], // max-heap by count
[...count].map(([ch, n]): Left => [n, ch]),
);
const out: string[] = [];
let held: Left | null = null; // used last round
while (h.size > 0) {
const [n, ch] = h.pop()!;
out.push(ch);
if (held) h.push(held);
held = n > 1 ? [n - 1, ch] : null;
}
return held ? "" : out.join("");
}
reorganize("aab"); // "aba"
reorganize("aaab"); // ""Last stone weight
When you repeatedly combine the two largest items: a max-heap, two pops and at most one push per round.
// Heap<T>: the class from A typed heap, above
function lastStoneWeight(stones: number[]): number {
const h = new Heap<number>((a, b) => b - a, stones);
while (h.size > 1) {
const diff = h.pop()! - h.pop()!;
if (diff > 0) h.push(diff);
}
return h.peek() ?? 0;
}
lastStoneWeight([2, 7, 4, 1, 8, 1]); // 1
lastStoneWeight([2, 2]); // 0Maximize capital
When each pick unlocks more choices: the IPO problem, with k projects and starting capital w.
// Heap<T>: the class from A typed heap, above
function maxCapital(
k: number,
w: number,
profits: number[],
capital: number[],
): number {
const locked = capital
.map((c, i): [number, number] => [c, profits[i]!])
.sort((a, b) => a[0] - b[0]); // by cost
const ready = new Heap<number>((a, b) => b - a); // max
let i = 0;
for (let round = 0; round < k; round++) {
while (i < locked.length && locked[i]![0] <= w) {
ready.push(locked[i++]![1]);
}
if (ready.size === 0) break; // nothing affordable
w += ready.pop()!;
}
return w;
}
maxCapital(2, 0, [1, 2, 3], [0, 1, 1]); // 4
maxCapital(3, 0, [1, 2, 3], [0, 1, 2]); // 6Nothing new joins the locked pool, so a sorted array with a pointer stands in for a second heap.
Updatable priority queue
When priorities change or items get canceled: entries carry a sequence number and a live flag, and
a Map finds the live entry for each key.
// Heap<T>: the class from A typed heap, above
type Item<K> = {
prio: number;
seq: number;
key: K;
alive: boolean;
};
class UpdatableQueue<K> {
private readonly h = new Heap<Item<K>>(
(a, b) => a.prio - b.prio || a.seq - b.seq,
);
private readonly live = new Map<K, Item<K>>();
private seq = 0;
set(key: K, prio: number): void {
this.delete(key); // update = remove + add
const item = { prio, seq: this.seq++, key, alive: true };
this.live.set(key, item);
this.h.push(item);
}
delete(key: K): void {
const item = this.live.get(key);
if (item) item.alive = false; // lazy: stays inside
this.live.delete(key);
}
pop(): K | undefined {
while (this.h.size > 0) {
const item = this.h.pop()!;
if (!item.alive) continue;
this.live.delete(item.key);
return item.key;
}
return undefined;
}
}
const pq = new UpdatableQueue<string>();
pq.set("write", 3);
pq.set("test", 2);
pq.set("ship", 5);
pq.set("ship", 1); // raise its priority
pq.delete("test");
[pq.pop(), pq.pop(), pq.pop()];
// ["ship", "write", undefined]alive is not read by the comparator, so flipping it inside the heap is safe; changing prio in
place would not be.
References
- MDN: Array.prototype.sort (opens in a new tab): the comparator contract the class copies
- MDN: Array.prototype.pop (opens in a new tab): O(1) removal from the end
- TypeScript handbook: Generics (opens in a new tab): generic classes such as
Heap<T> - TSConfig: noUncheckedIndexedAccess (opens in a new tab): why
a[i]can read asT | undefined - TSConfig: erasableSyntaxOnly (opens in a new tab): the syntax Node's type stripping accepts
- Node.js docs: TypeScript (opens in a new tab): running
.tsfiles with type stripping - @datastructures-js/priority-queue on GitHub (opens in a new tab): the README and changelog for each version
- @datastructures-js/priority-queue on npm (opens in a new tab): published versions
- datastructures-js docs: priority queue (opens in a new tab): the current API with examples
- Python docs: heapq, theory (opens in a new tab): why building a heap is linear
- Wikipedia: Binary heap (opens in a new tab): the array layout and the O(n) build proof