../

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.

OperationCostHow
peekO(1)read index 0
pushO(log n)append, then sift up: swap with the parent while smaller
popO(log n)move the last item to index 0, then sift down: swap with the smaller child while larger
build from n itemsO(n)sift down every parent, last parent first
find or remove any other itemO(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(); // undefined
[0] [1] [2] [3] [4] [5] [6] a = 1 1 3 3 2 2 7 7 5 5 4 4 1 1 3 3 2 2 7 7 5 5 4 4 0 0 1 1 3 3 0 0 7 7 5 5 4 4 2 2 0 0 3 3 1 1 7 7 5 5 4 4 2 2 2 2 3 3 1 1 7 7 5 5 4 4 0 0 2 2 3 3 1 1 7 7 5 5 4 4 1 1 3 3 2 2 7 7 5 5 4 4 1 1 3 3 2 2 7 7 5 5 4 4 push(0) pop() → 0 moving compared settled leaving children of i: 2i + 1 and 2i + 2 · parent of i: (i - 1) >> 1

1/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. peek and pop return T | 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.
  • pop never shifts. It swaps the root with the last item and calls Array.prototype.pop, which is O(1). shift would move every item.
  • Ready for noUncheckedIndexedAccess. With that flag every this.a[i] reads as T | undefined; the two private helpers cast once, where the index is known to be in range, and the class has no any. 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.ts on Node 24) and erasableSyntaxOnly. Node 24 and 26 reject parameter properties in that mode with ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX.
  • The invariant is private. Unlike a Python heapq list, callers can't push onto 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

WantComparatorNotes
min-heap of numbers(a, b) => a - bthe 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.

EntryComparatorPops 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 nameas 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.

NeedDoCost
lower or raise a prioritypush a new entry; when popping, skip entries that no longer matchO(log n) push; one extra entry per update
delete an itemrecord its key in a Set and skip it when poppedO(1) to mark
change many prioritiesrebuild: new Heap(cmp, liveEntries)O(n)
true decrease-keyan indexed heap that keeps each key's position in a Map and sifts from thereO(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]]
inside h [0] [1] [2] best "a" → 5 "b" → 3 "a" → 1 "b" → 3 "a" → 1 "b" → 3 "a" → 1 "b" → 3 "a" → 1 "b" → 3 [3, "b"] [5, "a"] order = [] [1, "a"] [5, "a"] [3, "b"] order = [] [3, "b"] [5, "a"] [1, "a"] 1 === best.get("a"): keep it pop() order = [["a", 1]] [5, "a"] [3, "b"] 3 === best.get("b"): keep it pop() order = [["a", 1], ["b", 3]] [] (empty) [5, "a"] 5 !== best.get("a") = 1: stale, skip pop() order = [["a", 1], ["b", 3]] best.set("a", 1); h.push([1, "a"]) new Heap(cmp, [[5, "a"], [3, "b"]]) while (h.size > 0): pop, skip stale fresh entry stale entry kept skipped

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

NeedUseWhy
the k largest of n, k much smaller than na size-k min-heapO(n log k) time, O(k) memory
k close to n, all items at handxs.toSorted((a, b) => b - a).slice(0, k)one O(n log n) sort
k === 1a loop or reduceMath.max(...xs) throws RangeError past about 125,000 items on Node 24
the k closest pointsa size-k max-heap by squared distanceno Math.sqrt, no rounding
one sorted stream from k sorted arraysa heap of [value, list, index] headsO(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]
stream 5 x 1 x 9 x 3 x 7 x 9 x 2 x inside h [0] [1] [2] peek(): the weakest of the k kept 5 h.size < 3: h.push(5) 1 5 h.size < 3: h.push(1) 1 5 9 h.size < 3: h.push(9) 3 9 5 1 popped out 3 > peek(): h.pop(), h.push(3) 5 9 7 3 popped out 7 > peek(): h.pop(), h.push(7) 7 9 9 5 popped out 9 > peek(): h.pop(), h.push(9) 7 9 9 2 never pushed 2 > peek() is false: skip 7 9 9 pop all, then reverse → [9, 9, 7] new item popped or skipped result

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]
lo: max-heap, smaller half hi: min-heap, larger half boundary [0] [0] [1] [1] [2] [2] add(5) lo.push(5); hi.push(lo.pop()) moves 5 [] (empty) 5 lo.size = 0 hi.size = 1 hi.size > lo.size: rebalance next out = [] add(5) hi.size > lo.size: lo.push(hi.pop()) moves 5 5 [] (empty) lo.size = 1 hi.size = 0 median = lo.peek() = 5 out = [5] add(15) lo.push(15); hi.push(lo.pop()) moves 15 5 15 lo.size = 1 hi.size = 1 median = (5 + 15) / 2 = 10 out = [5, 10] add(1) lo.push(1); hi.push(lo.pop()) moves 5 1 5 15 lo.size = 1 hi.size = 2 hi.size > lo.size: rebalance next out = [5, 10] add(1) hi.size > lo.size: lo.push(hi.pop()) moves 5 5 1 15 lo.size = 2 hi.size = 1 median = lo.peek() = 5 out = [5, 10, 5] add(3) lo.push(3); hi.push(lo.pop()) moves 5 3 1 5 15 lo.size = 2 hi.size = 2 median = (3 + 5) / 2 = 4 out = [5, 10, 5, 4] just moved

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.

[0] [1] [2] [3] [4] [5] [6] a = 9 9 5 5 7 7 1 1 8 8 2 2 4 4 9 9 5 5 7 7 1 1 8 8 2 2 4 4 9 9 5 5 2 2 1 1 8 8 7 7 4 4 9 9 5 5 2 2 1 1 8 8 7 7 4 4 9 9 1 1 2 2 5 5 8 8 7 7 4 4 9 9 1 1 2 2 5 5 8 8 7 7 4 4 1 1 9 9 2 2 5 5 8 8 7 7 4 4 1 1 5 5 2 2 9 9 8 8 7 7 4 4 new Heap(cmp, items) down(2) down(2) down(1) down(1) down(0) down(0) a heap: 4 swaps sifting down its children already a heap parents are indexes 0 to (n >> 1) - 1 = 2; the rest are leaves

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 memberReturnsNotes
new MinPriorityQueue<T>(getValue?), new MaxPriorityQueue<T>(getValue?)a queuegetValue: (x: T) => number | string gives the priority; leave it out for numbers and strings
new PriorityQueue<T>(compare)a queuecompare works like sort's
enqueue(x), alias push(x)the queueO(log n); chainable
dequeue(), alias pop()T | nullO(log n); null when empty, not undefined
front(), back()T | nullthe highest-priority item; an item of the lowest priority
size(), isEmpty()number, booleanmethods, not properties
toArray()T[]a sorted copy: O(n log n); the queue is unchanged
for...of, spreadeach itemdequeues as it goes: the queue ends up empty
fromArray(xs, getValue?), new MinPriorityQueue(getValue, xs)a queueO(n); uses xs itself as storage, so pass [...xs] to keep yours
contains(cb), remove(cb)boolean, removed itemsO(n) scans
fix() (6.4.0)the queuerepairs 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

OperationCostNotes
peek(), sizeO(1)
push, popO(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 heapO(n log k)O(k) memory
k-way mergeO(log k) per itemO(k) memory
find, update or remove one itemO(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

UseWhenNotes
the Heap<T> classthe defaultabout 70 lines with blank lines; typed; no dependency
@datastructures-js/priority-queueLeetCode, or a dependency is fineabout 1.35× slower than the class in the run above; null for empty
a sorted array, binary search and splicesmall n, and you also need ranks or removal by valueO(n) per insert: about 58× slower at 100,000 items
one toSorted(cmp)every item is known up front and no pushes follow popsO(n log n) once
a bucket queue: an array of lists indexed by prioritysmall integer priorities (0-1 BFS, counts up to n)O(1) push; pop scans forward to the next non-empty bucket
a monotonic dequethe max or min of a sliding windowO(n) for the whole scan; a heap needs lazy deletion and O(n log n)
a loop or reduceone extreme, onceO(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).

JobTypeScriptWatch outMore
k-th largesta size-k min-heap; peek() at the enda min-heap keeps the k largest: the weakest sits on top, ready to evictTop-k
Top k frequentcount 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] headspush the next item from the same list onlyMerge k sorted lists
Dijkstra, Prim[dist, node] entries; continue when d > dist[node]no decrease-key: stale entries stay until poppedDijkstra
Meeting roomsa min-heap of end times; pop while the earliest end is at most the startsort the meetings by start firstIntervals
Task scheduler, reorganize stringa max-heap of remaining counts; hold the last-used item outpush it back only after the next pickReorganize string
k closest pointsa size-k max-heap by x * x + y * yno Math.sqrt: squared distances order the same wayTop-k
Running medianmax-heap of the low half, min-heap of the high halfkeep lo.size - hi.size at 0 or 1Two heaps
Unlock-then-pickitems sorted by cost, a max-heap of unlocked valuesmove items across as the budget growsMaximize capital
Lazy deletiona Set (or a count Map, with duplicates) of removed keys; pop while the top is markedthe heap keeps dead entries until they reach the topUpdating & removing
Sliding window maxa monotonic deque of indexesa heap works too, with lazy deletion, but costs O(log n) per stepMonotonic stack & deque
Objects in a heapa comparator on a field, plus seq for FIFO tieschanging that field while the object is inside breaks the heapPriorities & 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

TypeFor
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) => numbernaming and reusing comparators
T | undefined from peek() and pop()handle empty heaps, or ! after a size check
noUncheckedIndexedAccessthe 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; // 1

readonly 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

PythonTypeScript
heapq functions on a plain listno 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 placenew Heap(cmp, xs), which copies: xs is unchanged
tuples compare field by fieldwrite 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 numbersswap 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 TypeErrornever raises: the comparator decides, and ties come out in no promised order
heappushpop, heapreplacepush 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]]); // 1

Task 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); // 6

cooling 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]); // 0

Maximize 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]); // 6

Nothing 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