Chapter 5

Data Structures in TypeScript

Arrays, maps, sets, linked lists, stacks, queues — all in TypeScript.

Data Structures in TypeScript

This file has two halves. Part 1 is the built-in toolbox: what Array, Map, Set, the weak collections, plain objects and strings actually cost, how they are represented inside V8, and the specific traps that turn a correct algorithm into a wrong answer (lexicographic sort, Array(n).fill([]) aliasing, SameValueZero keys, __proto__ pollution, surrogate pairs). It ends with the table that matters most in an interview: what JavaScript does not ship, and what you write instead. Part 2 builds those missing structures from scratch in generic, strict TypeScript — dynamic array through Bloom filter — each with intuition, an ASCII diagram, a complexity table, a runnable implementation, pitfalls, and the follow-ups an interviewer actually asks. Every implementation here was executed on Node 22.22.2 / V8 12.4 with assert-based tests; the two pasted test outputs are real. Python equivalents live in Data structures in Python; the algorithms that consume these structures live in Algorithm patterns and Graphs and trees.

Table of contents

Part 1 — the built-ins

Part 2 — from scratch


Part 1 — the built-ins

1. Array

1.1 Intuition and representation

A JavaScript Array is an exotic object whose integer-like keys are special-cased. Semantically it is a hash map from stringified indices to values; practically, V8 stores it as a contiguous backing store whenever it can, and falls back to a dictionary when you make it sparse. That single fact — “contiguous until you break it” — explains almost every performance surprise.

JSArray
 ├─ map (hidden class, carries the *elements kind*)
 ├─ properties  -> named props ('foo')
 ├─ elements    -> FixedArray  [ 1, 2, 3, <free>, <free> ]   capacity 5, length 3
 └─ length      -> 3

The elements kind lattice (PACKED_SMI → … → HOLEY_ELEMENTS → DICTIONARY_ELEMENTS) is described in detail in JS core, including the measured cost of a single delete. Do not re-derive it here; the operational rule for data-structure work is:

  • Never create holes. new Array(n) without .fill(), delete arr[i], arr[arr.length + 1] = x and arr.length = bigger all demote the array permanently.
  • Keep types monomorphic. An array of all small integers is PACKED_SMI with unboxed storage; pushing one NaN, undefined or object widens it forever.
  • Transitions are one-way. There is no “repack” operation. Rebuild the array if you must recover.

1.2 The full method table

n is the receiver length, m the argument length, k the result length.

MethodMutates?ReturnsTimeNotes
push(...v)yesnew lengthO(1) amortisedreallocates the backing store on growth
pop()yeselementO(1)may shrink the store
shift()yeselementO(n)every index is renumbered; see 1.3
unshift(...v)yesnew lengthO(n)same, in reverse
splice(i, d, ...ins)yesremoved itemsO(n)O(n - i) memmove plus O(d) allocation
sort(cmp?)yessame arrayO(n log n)TimSort; O(n) on already-sorted input
reverse()yessame arrayO(n)in place
fill(v, s?, e?)yessame arrayO(n)packs a holey array
copyWithin(t, s?, e?)yessame arrayO(n)memmove semantics, overlap-safe
length = kyeskO(1) shrink / O(1) growgrowing creates holes
at(i)noelementO(1)negative indices
concat(...arrs)nonew arrayO(n + m)flattens one level of array arguments
slice(s?, e?)nonew arrayO(k)the idiomatic shallow copy
indexOf / lastIndexOfnoindexO(n)Strict Equality: misses NaN
includesnobooleanO(n)SameValueZero: finds NaN
find / findIndexnoelement / indexO(n)short-circuits
findLast / findLastIndexnoelement / indexO(n)ES2023
filter / mapnonew arrayO(n)skip holes, preserve them in the result
flat(depth?)nonew arrayO(total)Infinity for full flattening
flatMap(fn)nonew arrayO(total)map + flat(1), one pass
reduce / reduceRightnoaccumulatorO(n)throws on empty with no seed
some / everynobooleanO(n)short-circuit
join(sep?)nostringO(n)null/undefined become ''
forEachnoundefinedO(n)cannot break; skips holes
keys / values / entriesnoiteratorO(1) to createentries is how you get an index in for..of
toSorted / toReversednonew arrayO(n log n) / O(n)ES2023, work on frozen arrays
toSpliced / withnonew arrayO(n)ES2023, with(i, v) = functional index set
Array.isArraybooleanO(1)cross-realm safe, unlike instanceof Array
Array.from(it, fn?)new arrayO(n)the correct way to build n distinct values
Array.of(...v)new arrayO(n)Array.of(3) is [3], Array(3) is 3 holes
Array.fromAsyncPromise<array>O(n)ES2024, drains an async iterable

Two spread/iteration facts worth memorising: [...arr] and Array.from(arr) fill holes with undefined (they use the iterator, which visits every index), whereas map/filter/forEach skip holes. So [...[1, , 3]] is [1, undefined, 3] with a real element at index 1, while [1, , 3].map(x => x) still has a hole at index 1.

1.3 Why shift() is the wrong queue primitive

Per spec, shift moves every remaining element down one index and decrements length. That is O(n) per call, so a BFS loop of while (q.length) { const v = q.shift(); … } is O(n²) on the queue length. V8 does have a fast path that can left-trim the backing store instead of moving elements, but it only applies under narrow conditions (packed elements, no copy-on-write, a length range that makes trimming worthwhile), so it is not something you can rely on and it is definitely not the answer to give. The portable answers are: a head index into a plain array, a ring buffer (section 11), or two stacks (section 10).

The head-index version is three lines and is what you should write under time pressure:

const q: number[] = [start];
for (let head = 0; head < q.length; head++) {
  const v = q[head];
  // ...push neighbours onto q
}

The memory cost is that q never shrinks during the traversal — fine for a BFS, wrong for a long-running queue, which is what the ring buffer solves.

1.4 The sort traps

Array.prototype.sort with no comparator converts every element with ToString and compares the strings in UTF-16 code-unit order. This is the single most common interview error.

const nums = [10, 9, 1, 100, 25];
nums.slice().sort();               // [1, 10, 100, 25, 9]   <- lexicographic
nums.slice().sort((a, b) => a - b) // [1, 9, 10, 25, 100]

Three more rules:

  • Stability is guaranteed. V8 uses TimSort (since V8 7.0 / Chrome 70), and stability has been required by the spec since ES2019. You can therefore sort by a secondary key first, then a primary key, and get lexicographic-tuple ordering for free. See Sorting for the internals.
  • undefined and holes are exiled to the end and your comparator never sees them. They are moved after all defined values regardless of what you return, and holes stay holes at the very end.
  • The comparator must be a consistent total order. Returning a boolean ((a, b) => a > b) is a classic bug: true/false coerce to 1/0, so “less than” and “equal” are indistinguishable and the result is arbitrary. Returning NaN (e.g. subtracting strings) is equally broken.
const holey: (number | undefined)[] = [3, undefined, 1, 9, 2];
delete holey[3];
holey.slice().sort((a, b) => (a as number) - (b as number));
// [ 1, 2, 3, undefined, <1 empty item> ]  — comparator called only 4 times

For object sorting, precompute the sort key when it is expensive (a Schwartzian transform), because the comparator runs O(n log n) times:

type Row = { name: string; created: string };
function byCreatedDesc(rows: readonly Row[]): Row[] {
  return rows
    .map(r => ({ r, k: Date.parse(r.created) }))   // n key computations
    .sort((a, b) => b.k - a.k)
    .map(x => x.r);
}

1.5 The aliasing trap: Array(n).fill([])

fill stores the same reference in every slot. This bites people building grids and adjacency lists, and the symptom (a mutation appearing in every row) looks like a logic bug rather than an initialisation bug.

const bad = Array(3).fill([]) as number[][];
bad[0].push(1);
bad[1].length;      // 1  — same array
bad[0] === bad[2];  // true

const good = Array.from({ length: 3 }, () => [] as number[]);
good[0].push(1);
good[1].length;     // 0

The rule: fill for primitives, Array.from with a factory for anything with identity.

1.6 2D arrays and preallocation

function make2D<T>(rows: number, cols: number, fill: T): T[][] {
  return Array.from({ length: rows }, () => new Array<T>(cols).fill(fill));
}

const grid = make2D(2, 3, 0);
grid[1][2] = 7;   // [[0,0,0],[0,0,7]]

new Array<T>(cols).fill(fill) is deliberate: new Array(cols) allocates capacity in one shot, and .fill immediately packs it, so you get a PACKED array with no reallocation. Array.from({length: n}, () => v) is equivalent and slightly slower; [] plus n pushes forces log n reallocations.

For a flat 1D encoding of a 2D grid — the right choice for hot numeric code, because it is one allocation and one cache-friendly buffer:

class Grid {
  private readonly cells: Int32Array;
  constructor(readonly rows: number, readonly cols: number) {
    this.cells = new Int32Array(rows * cols);
  }
  get(r: number, c: number): number { return this.cells[r * this.cols + c]; }
  set(r: number, c: number, v: number): void { this.cells[r * this.cols + c] = v; }
}

Preallocation summary:

ExpressionElements kindWhen to use
[] then push in a loopPACKED, grows by reallocationdefault; fine for unknown length
new Array(n).fill(0)PACKED_SMIknown length, primitive fill
Array.from({length: n}, fn)PACKEDknown length, distinct objects or computed values
new Array(n) aloneHOLEY from birthessentially never
new Int32Array(n)typed, zero-fillednumeric, fixed width, known length

1.7 Typed arrays

Typed arrays are a different animal: fixed length, fixed element type, backed by an ArrayBuffer, no holes, no prototype-chain lookups on index access. Use them when you have numbers and you care.

ConstructorElementBytesNotes
Int8Array / Uint8Arrayinteger1Uint8Array is the byte-array workhorse
Uint8ClampedArrayinteger1clamps instead of wrapping (canvas pixels)
Int16Array / Uint16Arrayinteger2
Int32Array / Uint32Arrayinteger4best for index/parent arrays (Union-Find)
Float32Array / Float64Arrayfloat4 / 8Float64Array matches JS number exactly
BigInt64Array / BigUint64Arraybigint8elements are bigint, not number
Float16Arrayfloat2ES2025; not in Node 22’s V8 (verified below)

Behaviour you should be able to state:

const i32 = new Int32Array(4);
i32[0] = 2 ** 31;        // wraps silently
i32[0];                  // -2147483648
i32[99] = 5;             // out-of-range write is *dropped*, not an error, not a new property
i32.length;              // 4

new Uint8ClampedArray([300, -5]);   // [255, 0]

[...new Int32Array([10, 9, 100, 1]).sort()];  // [1, 9, 10, 100] — numeric by default

TypedArray.prototype.sort is a different code path from Array.prototype.sort: it defaults to numeric ordering, not lexicographic, and V8 sorts it without the TimSort machinery. That asymmetry is a good “do you actually know this” question.

Views versus copies is the other half:

const base = new Uint8Array([1, 2, 3, 4]);
const view = base.subarray(1, 3);   // shares the buffer
view[0] = 99;                       // base is now [1, 99, 3, 4]
const copy = base.slice(1, 3);      // new buffer
copy[0] = 7;                        // base unchanged

DataView gives you explicit offsets and endianness, which typed arrays do not (they use platform endianness, little-endian everywhere that matters):

const buf = new ArrayBuffer(4);
new DataView(buf).setUint16(0, 0x0102, /* littleEndian */ false);
[...new Uint8Array(buf)];   // [1, 2, 0, 0]

Also worth knowing: SharedArrayBuffer plus Atomics is the only true shared-memory path between a worker and the main thread; everything else is structured-clone message passing. See Node specifics.

Pitfalls

  • sort() with no comparator on numbers. Always pass one.
  • arr.length is writable, and arr.length = n on a shorter array creates holes rather than undefineds. Array.from({length: n}) gives you real undefineds.
  • arr.includes(NaN) is true; arr.indexOf(NaN) is -1. If you use indexOf for a “seen” check on computed numbers, you will miss NaN.
  • splice(0, 1) and shift() are both O(n). Do not use either inside a loop over the array.
  • delete arr[i] leaves length untouched and permanently demotes the elements kind. Use splice(i, 1) if you want removal, or a tombstone value if you want O(1).
  • concat flattens array arguments one level: [1].concat([2, [3]]) is [1, 2, [3]], not [1, [2, [3]]]. push does not flatten.
  • reduce on an empty array with no initial value throws TypeError. Always pass the seed.
  • Reference equality in indexOf/includes means [{a:1}].includes({a:1}) is false.

Interview follow-ups

Q: Is push O(1)?

A: Amortised O(1). The backing store grows geometrically, so n pushes cost O(n) total copying; any individual push that triggers a reallocation is O(n). See section 7 for the proof.

Q: Why is shift slower than pop?

A: pop only decrements length. shift must renumber every index, which is an O(n) memmove of the backing store — array indices are part of the observable semantics, not just a physical layout.

Q: What does [10, 9, 1].sort() return and why?

A: [1, 10, 9]. With no comparator the spec converts each element with ToString and compares UTF-16 code units, so "10" < "9".

Q: How do you build an n-by-m grid of independent arrays?

A: Array.from({length: n}, () => new Array(m).fill(0)). Array(n).fill([]) stores one shared array reference in all n slots.

Q: When would you reach for a typed array?

A: Fixed-length numeric data where you want no boxing, no holes, predictable memory and optional zero-copy sharing with a worker: parent/rank arrays in Union-Find, pixel buffers, bitsets, DSP.

Q: How do you make an immutable-ish array?

A: Object.freeze for a shallow freeze (writes throw in strict mode), the ES2023 toSorted / toReversed / toSpliced / with methods to derive new arrays without mutation, and readonly T[] in TypeScript to catch mutation at compile time. None of these deep-freeze.


2. Map, Set, WeakMap, WeakSet

2.1 Intuition

Map is a hash table with a guaranteed iteration order. V8 implements it as an OrderedHashTable: a flat array of [key, value, chain] entry records in insertion order, plus a bucket index that hashes into that array. Lookups go through the buckets; iteration walks the entry array. That layout is why insertion order is free, why delete leaves a gap that is only reclaimed at the next rehash, and why Map beats an object for large, churning, non-string-keyed collections.

buckets: [ 2 | -1 | 0 | ... ]        hash(key) -> bucket -> entry index
entries: [ (k0,v0,next) (k1,v1,next) (k2,v2,next) ... ]   <- insertion order

2.2 Complexity

OperationMap / SetPlain objectNotes
get / hasO(1) averageO(1) averageworst case O(n) under adversarial hashing
set / addO(1) amortisedO(1) amortisedrehash on growth
deleteO(1)O(1)delete on an object can force dictionary mode
sizeO(1)Object.keys(o).length is O(n)one of the main reasons to use Map
iterationO(n) in insertion orderO(n) in integer-then-insertion ordersee section 3
clearO(1)allocates a fresh table
memory~2-3 machine words per entry plus table slacklower for small fixed shapes

2.3 Keys are compared with SameValueZero

Not ===, not Object.is. SameValueZero is Object.is except that +0 and -0 are the same key.

const m = new Map<unknown, string>();
m.set(NaN, 'nan');
m.get(NaN);            // 'nan'   — NaN is a usable key, unlike with indexOf
m.set(-0, 'zero');
m.get(0);              // 'zero'  — collides
Object.is([...m.keys()][1], 0);   // true — the stored key was normalised to +0

Object keys are compared by identity, which is the whole point and the whole limitation:

const a = { id: 1 }, b = { id: 1 };
new Map([[a, 'x'], [b, 'y']]).size;   // 2

There is no structural-key map in JavaScript. If you need one, canonicalise the key yourself — JSON.stringify with sorted keys, or a tuple joined with a separator that cannot appear in the parts — and accept the stringification cost. (The Record/Tuple proposal that would have fixed this was withdrawn; the composites proposal is the live successor but is not shippable today.)

2.4 Insertion order, precisely

  • Insertion order is specified, for both Map and Set.
  • Overwriting an existing key (set on a key that exists) does not move it.
  • delete then set does move it to the end. This is the entire trick behind the Map-based LRU cache in section 19.
const order = new Map([['a', 1], ['b', 2], ['c', 3]]);
order.delete('b'); order.set('b', 9);
[...order.keys()];      // ['a', 'c', 'b']
order.set('a', 100);
[...order.keys()];      // ['a', 'c', 'b']  — unchanged

Mutation during iteration is well-defined and occasionally useful: entries appended ahead of the cursor are visited, which turns a Set into an implicit worklist — and into an accidental infinite loop if your guard is wrong.

const s = new Set([1]);
const seen: number[] = [];
for (const x of s) { seen.push(x); if (x < 4) s.add(x + 1); }
seen;   // [1, 2, 3, 4]

2.5 Set algebra (ES2025)

All seven methods are in Node 22.

MethodResultCost
a.union(b)new Set with everythingO(|a| + |b|)
a.intersection(b)new Set with common elementsO(min(|a|, |b|))
a.difference(b)in a, not in bO(min(|a|, |b|))
a.symmetricDifference(b)in exactly oneO(|a| + |b|)
a.isSubsetOf(b)booleanO(|a|)
a.isSupersetOf(b)booleanO(|b|)
a.isDisjointFrom(b)booleanO(min(|a|, |b|))

The argument does not have to be a Set — it has to be set-like: an object with a numeric size, a callable has, and a callable keys. A Map qualifies, which is a nice thing to know:

const A = new Set([1, 2, 3]);
[...A.intersection(new Map([[2, 'x'], [7, 'y']]))];   // [2]

Note the asymmetry: these methods return Sets, they are not generic over subclasses’ element types, and they read size/has/keys from the argument before iterating, so a lazily-mutating argument gives you undefined behaviour in practice.

2.6 WeakMap and WeakSet

A WeakMap entry does not keep its key alive. That is the only difference and it drives everything else about the API.

PropertyMapWeakMap
key typesanythingobjects and non-registered symbols only
enumerableyesno — no keys, values, entries, forEach, no Symbol.iterator
sizeyesno
clearyesno (removed from the spec)
GC interactionentries are strong rootsentry vanishes when the key becomes unreachable
const wm = new WeakMap<object, number>();
wm.set(1 as unknown as object, 1);              // TypeError: Invalid value used as weak map key
wm.set(Symbol('local') as unknown as object, 2); // OK (ES2023)
wm.set(Symbol.for('global') as unknown as object, 3); // TypeError — registered symbols never die

The absence of iteration is not an oversight: exposing it would make GC timing observable. The three uses that justify a WeakMap:

  1. Private state keyed by instance — the pre-#private idiom, still useful for adding state to objects you do not own (DOM nodes, third-party instances).
  2. Memoisation keyed by an object argument — the cache entry dies with the argument, so no leak.
  3. Metadata sidecars — “have I already processed this node”, cycle detection in a deep-clone.

Compare with the leak archetypes in JS core: a Map used as a cache keyed by request objects is leak archetype number one; a WeakMap is the fix. WeakRef and FinalizationRegistry exist for finer-grained control and both should come with a warning that finalizer timing is not guaranteed and never load-bearing.

Pitfalls

  • Using an object literal as a Map key and expecting structural lookup. It is identity.
  • map.size versus Object.keys(obj).length — the second is O(n) and allocates.
  • Forgetting that a Map used as an unbounded cache is a memory leak. Bound it (LRU) or weaken it.
  • new Map(obj) does not work — Map’s constructor takes an iterable of pairs. Use new Map(Object.entries(obj)), and remember that turns numeric keys into strings.
  • Spreading a huge Map ([...map]) to sort or filter it allocates an array of pair arrays. Iterate instead when you can.
  • V8’s Map does not release the entry array on delete; heavy churn (a queue implemented as a Map) keeps the high-water-mark memory until a rehash. Recreate the map periodically if this matters.
  • JSON.stringify(new Map(...)) is {}. Serialise via Object.fromEntries or an array of pairs.
  • Set deduplicates by SameValueZero, so new Set([{}, {}]) has size 2 and new Set([NaN, NaN]) has size 1.

Interview follow-ups

Q: Map or object — how do you choose?

A: Map for non-string keys, unknown/user-supplied keys, frequent add/delete, when you need size or guaranteed iteration order, or when keys could collide with Object.prototype. Object for fixed, known, string-keyed records (V8 gives them a hidden class and inline-cached property access), and for anything you will JSON.stringify.

Q: What equality does Map use for keys?

A: SameValueZero — like Object.is except -0 and +0 are the same key. So NaN works as a key and -0 is normalised to +0 on insertion.

Q: Why can’t you iterate a WeakMap?

A: Iteration would make garbage-collection timing observable to the program, which the spec refuses to allow. It would also make the whole thing unusable as a security boundary.

Q: Does delete on a Map free memory immediately?

A: It removes the entry logically and drops the strong references to key and value, but V8’s OrderedHashTable keeps the entry array at its current size until a rehash compacts it, so the table itself does not shrink right away.

Q: How do you implement a map with structural keys?

A: Canonicalise to a primitive: a sorted-key JSON.stringify, or join tuple parts with a separator that cannot occur in them. For nested maps, a trie of Maps keyed by each component avoids the stringification cost at the price of more allocations.

Q: Is Set intersection really O(min(|a|, |b|))?

A: Yes — the spec iterates the smaller collection and probes the larger, which is the whole reason it takes a set-like rather than an iterable.


3. Plain objects as maps

3.1 Why this is a trap and not a shortcut

{} inherits from Object.prototype, so it starts out already “containing” a dozen keys. Every in check, every obj[key] read and every naive merge has to reckon with that.

const dict: Record<string, number> = {};
'toString' in dict;                  // true
Object.hasOwn(dict, 'toString');     // false
dict['toString'];                    // function, not undefined

If a user-supplied string can reach your key, use one of:

ApproachWhat it fixesCost
Object.create(null)no inherited keys at all, __proto__ is an ordinary keyloses toString, breaks some libraries, console.log shows [Object: null prototype]
new Map()no prototype, any key type, O(1) sizenot JSON-serialisable directly
Object.hasOwn(o, k) guardcorrectness of membership testsmust remember it everywhere
Object.groupByalready returns a null-prototype objectES2024
const bare = Object.create(null) as Record<string, number>;
'toString' in bare;              // false
Object.getPrototypeOf(bare);     // null
bare['constructor'] = 1;         // just a key

Prefer Object.hasOwn(o, k) over Object.prototype.hasOwnProperty.call(o, k) (ES2022) and never over o.hasOwnProperty(k), which itself breaks on a null-prototype object or a hasOwnProperty key.

3.2 Prototype pollution via __proto__

__proto__ is an accessor property defined on Object.prototype. Assigning to it on an ordinary object replaces the prototype instead of creating a key:

const victim: Record<string, unknown> = {};
victim['__proto__'] = { polluted: true };
Object.getPrototypeOf(victim) === Object.prototype;   // false — swapped
Object.hasOwn(victim, '__proto__');                   // false — no own key was created

On a null-prototype object there is no setter to trigger, so it becomes an ordinary own key — which is exactly why Object.create(null) is the recommended shape for untrusted dictionaries:

const safe = Object.create(null) as Record<string, unknown>;
safe['__proto__'] = { polluted: true };
Object.hasOwn(safe, '__proto__');   // true
Object.getPrototypeOf(safe);        // null

The exploit is the combination of JSON.parse and a recursive merge. JSON.parse creates __proto__ as a real own, enumerable data property (it uses CreateDataProperty, bypassing the setter), so for..in sees it — and a hand-rolled merge/extend/set(path, value) then walks straight onto Object.prototype:

function unsafeMerge(t: Record<string, any>, s: Record<string, any>): Record<string, any> {
  for (const k in s) {
    if (s[k] && typeof s[k] === 'object') { t[k] ??= {}; unsafeMerge(t[k], s[k]); }
    else t[k] = s[k];
  }
  return t;
}

const payload = JSON.parse('{"__proto__":{"isAdmin":true}}');
Object.keys(payload);            // ['__proto__']  — a real own key
unsafeMerge({}, payload);
({} as Record<string, unknown>).isAdmin;   // true  <- every object in the process

Defences, in order of preference: merge into Object.create(null); reject or skip the keys __proto__, constructor and prototype; use Object.defineProperty instead of = when writing; validate with a schema before merging; Object.freeze(Object.prototype) as a blunt process-wide backstop. Note that Object.assign and object spread are safe here — they both use CreateDataProperty semantics for own enumerable keys, so {...payload} gives you an own __proto__ key rather than a prototype swap. It is specifically hand-written recursive merges that are dangerous.

3.3 Key coercion and key order

Every own key is a string or a symbol. Anything else is stringified on the way in:

const o: Record<string, string> = {};
o[1 as unknown as string] = 'num';
o['1'];                       // 'num'
Object.keys(o);               // ['1']

const bad: Record<string, string> = {};
bad[{ a: 1 } as unknown as string] = 'x';
Object.keys(bad);             // ['[object Object]']  — all objects collapse to one key

Ordering of own keys (Object.keys, for..in, JSON.stringify, spread) is specified in OrdinaryOwnPropertyKeys:

  1. Array-index-like keys — canonical numeric strings for integers in [0, 2^32 - 2] — in ascending numeric order.
  2. All other string keys in property-creation order.
  3. Symbol keys in creation order.
const ordered = { b: 1, 2: 1, a: 1, 1: 1, '-1': 1, '1.5': 1, '02': 1 };
Object.keys(ordered);   // ['1', '2', 'b', 'a', '-1', '1.5', '02']

Note that '-1', '1.5' and '02' are not canonical array indices, so they stay in insertion order with the other strings. This is a favourite trick question, and it is a real bug source: an object keyed by numeric IDs silently reorders itself, so if order matters, use a Map or an array of pairs.

for..in additionally walks the prototype chain and yields inherited enumerable string keys, which is why you almost always want Object.keys / Object.entries / for..of instead.

Pitfalls

  • if (obj[key]) as a membership test: false for 0, '', false, null. Use Object.hasOwn(obj, key).
  • if (obj[userInput]) on a plain object can return an inherited function.
  • Numeric-looking keys reorder. { '10': a, '9': b } iterates as 9, 10.
  • delete obj.k in a hot loop can push V8 out of hidden-class mode into dictionary mode, making all subsequent property access slow. Set to undefined, or use a Map.
  • Object.entries allocates an array of two-element arrays. In a hot loop, iterate Object.keys — or better, use a Map.
  • Spreading to “clone” is shallow, and drops non-enumerable and symbol-keyed… no: spread does copy own enumerable symbol keys, but it drops non-enumerables, getters (it invokes them) and the prototype. structuredClone deep-clones but rejects functions and DOM nodes.

Interview follow-ups

Q: What is prototype pollution and how do you prevent it?

A: Writing to __proto__ (or constructor.prototype) through an attacker-controlled key path, which mutates Object.prototype and therefore every object in the process. Prevent it by merging into Object.create(null), blocklisting __proto__/constructor/prototype, using defineProperty instead of assignment, or schema-validating input first.

Q: In what order do object keys iterate?

A: Integer-like keys ascending first, then other string keys in insertion order, then symbols in insertion order. for..in also includes inherited enumerable string keys.

Q: Why is Object.keys(o).length worse than map.size?

A: It is O(n) and allocates an array of all keys; size is a stored field read in O(1).

Q: Is object spread vulnerable to __proto__ in a JSON payload?

A: No. Spread and Object.assign define own properties rather than invoking setters, so you get a harmless own __proto__ key. Hand-written recursive merges that use plain assignment are the vulnerable pattern.

Q: When is a plain object faster than a Map?

A: When the key set is small, fixed and known at write time, so V8 gives the object a stable hidden class and monomorphic inline caches on obj.field. As soon as keys are dynamic, Map wins and the object risks dictionary-mode demotion.


4. Strings

4.1 Immutability and V8 representations

Strings are immutable primitives: every “modification” allocates. That is why building a string with += in a loop is fine in V8 (it builds a rope of ConsString nodes in O(1) per concat, flattening lazily on first indexed read) but arr.push(part) then arr.join('') is still the portable advice. The representation table (SeqString, ConsString, SlicedString, internalized) is in JS core. The operational consequences:

  • s1 + s2 is O(1) plus a later O(n) flatten, not an immediate O(n) copy.
  • str.slice(i, j) is O(1) — a SlicedString view — but it keeps the whole parent string alive. Slicing a 10 MB response body down to a 20-character token can retain 10 MB. Force a copy with (' ' + s).slice(1) or s.split('').join('') if you need to break the reference; in practice, a String(...) round trip through a template does not reliably flatten, so measure.
  • Indexed access on a ConsString triggers flattening, so a single s[0] after a long concat chain pays the whole O(n).
const imm = 'abc';
(imm as unknown as Record<number, string>)[0] = 'z';
imm;   // 'abc'  — silently ignored (throws in strict mode for object receivers, not for primitives)

4.2 Code units, code points, graphemes

A JavaScript string is a sequence of UTF-16 code units. length, charCodeAt, charAt, [], slice and every regex index are in code units. Characters above U+FFFF (all emoji, many CJK extensions, historic scripts) are stored as a surrogate pair of two code units.

'a\u{1F600}b'

code units:   [ 0x0061 ] [ 0xD83D 0xDE00 ] [ 0x0062 ]      length = 4
code points:  U+0061      U+1F600           U+0062         [...s].length = 3
graphemes:    a           😀                b              Segmenter -> 3
const emoji = 'a\u{1F600}b';
emoji.length;             // 4   — code units
[...emoji].length;        // 3   — the string iterator yields code points
emoji.codePointAt(1);     // 128512 (0x1F600)
emoji.charCodeAt(1);      // 55357 (0xD83D) — high surrogate
emoji[1] === '\u{1F600}'; // false — half a character
emoji.slice(1, 3);        // '😀' — a full pair, by luck of the indices

The classic bug and its fix:

emoji.split('').reverse().join('');   // 'b\uDE00\uD83Da'  — mojibake
[...emoji].reverse().join('');        // 'b😀a'

Graphemes are a third level. A ZWJ emoji sequence, a flag, or a base letter plus combining marks is one user-perceived character made of many code points. Only Intl.Segmenter gets this right:

const family = '\u{1F468}\u{1F469}\u{1F467}';   // man-ZWJ-woman-ZWJ-girl
family.length;          // 8 code units
[...family].length;     // 5 code points
const seg = new Intl.Segmenter('en', { granularity: 'grapheme' });
[...seg.segment(family)].length;   // 1

Choose your unit deliberately:

You wantUseCost
storage size, regex indices, DB varchar limitsstr.length (code units)O(1)
“characters” for reversing, palindromes, iteration[...str] / Array.from(str)O(n), allocates
what a human calls a character (truncation, cursors)Intl.Segmenter grapheme modeO(n), heavier
bytes on the wirenew TextEncoder().encode(str).lengthO(n)

4.3 Normalisation

Equal-looking strings need not be equal strings. é can be one code point (U+00E9, NFC) or two (U+0065 U+0301, NFD).

'é' === 'é';                            // false
'é'.normalize('NFD') === 'é'.normalize('NFD');   // true

Normalise on input (NFC is the web default) if you will ever compare, deduplicate, or use strings as map keys. NFKC/NFKD additionally fold compatibility characters (fi → fi, ① → 1), which is what you want for search indexing and not what you want for round-tripping user data.

4.4 Comparison and collation

<, > and localeCompare-less sort() compare UTF-16 code units. That is not alphabetical order in any human language.

['ä', 'a', 'z'].sort();                                     // ['a', 'z', 'ä']  — 0xE4 > 0x7A
['ä', 'a', 'z'].sort(new Intl.Collator('de').compare);      // ['a', 'ä', 'z']
['ä', 'a', 'z'].sort(new Intl.Collator('sv').compare);      // ['a', 'z', 'ä']  — Swedish: ä is last
['file10', 'file2'].sort(new Intl.Collator('en', { numeric: true }).compare);   // ['file2','file10']

Rules to state in an interview:

  • Use Intl.Collator and hoist it out of the comparator. Constructing one per comparison is the performance bug; str.localeCompare(other, locale, opts) constructs a collator internally on each call, so it is fine for a handful of comparisons and wrong inside a sort of 10,000 items.
  • sensitivity: 'base' ignores case and accents; 'accent' ignores case only; numeric: true gives natural/human sort; usage: 'search' loosens matching.
  • Locale matters for correctness, not just aesthetics: Swedish sorts ä after z, German sorts it with a. There is no locale-independent “correct” order.
  • For case-insensitive comparison of identifiers, toLowerCase() is wrong for Turkish (Iı); use localeCompare with sensitivity: 'base' or Unicode case folding.

4.5 The method cheatsheet

MethodTimeNotes
lengthO(1)code units
charAt / [i] / at(i)O(1)at accepts negatives; may flatten a ConsString
charCodeAt / codePointAtO(1)code unit vs code point
slice / substringO(1) in V8SlicedString; retains the parent
indexOf / includes / startsWith / endsWithO(n·m) worst, near-O(n) typicalV8 uses a two-way / Boyer-Moore-Horspool hybrid
split(sep)O(n)split('') splits into code units — the surrogate bug
replace / replaceAllO(n)replaceAll requires a global regex or a string
padStart / padEndO(n)
repeat(k)O(n·k)
trim / trimStart / trimEndO(n)
toUpperCase / toLowerCaseO(n)locale-independent; use toLocaleUpperCase for Turkish
normalize(form)O(n)NFC / NFD / NFKC / NFKD
localeCompareO(n) plus collator constructionhoist an Intl.Collator for sorting
matchAllO(n)returns an iterator; regex must be global
String.rawO(n)tag for un-escaped template text
concatenation +O(1) then lazy flattenV8 ConsString

Pitfalls

  • split('').reverse().join('') breaks astral characters. Use [...str].
  • str.length is not character count. Truncating a UTF-8-limited field by length can split a pair and produce an invalid string.
  • Regexes without the u/v flag treat surrogate pairs as two characters: /./ matches half an emoji. /./u matches the code point; \p{...} property escapes require u.
  • localeCompare inside a sort comparator on a large array is slow because of repeated collator construction.
  • slice retaining the parent string is a real leak shape when you keep small tokens from huge documents.
  • '10' < '9' is true. String comparison is not numeric comparison.
  • str.replace('a', 'b') replaces only the first occurrence; replaceAll or /a/g.
  • += in a loop is fine in V8 but the accumulated ConsString flattens on the first index read, which can look like a mysterious pause.

Interview follow-ups

Q: How do you reverse a string correctly?

A: [...str].reverse().join('') handles surrogate pairs. For full correctness with combining marks and ZWJ sequences you need grapheme segmentation: reverse the array from Intl.Segmenter.

Q: Why is '😀'.length 2?

A: Strings are UTF-16 code-unit sequences, and U+1F600 is above U+FFFF so it is stored as a surrogate pair — two code units.

Q: Is string concatenation O(n) in JavaScript?

A: In V8, a + b builds a ConsString in O(1) and defers the copy; the flatten to a contiguous string happens on the first operation that needs linear memory (indexing, regex). So a concat loop is not quadratic in practice, but push + join is the portable guarantee.

Q: Two strings look identical but === is false. Why?

A: Different Unicode normalisation forms (composed vs decomposed), or invisible characters (zero-width joiner, BOM, right-to-left mark). Compare .normalize('NFC') and inspect code points.

Q: How would you sort user names for a UI?

A: Hoist an Intl.Collator for the user’s locale with { sensitivity: 'base', numeric: true } and pass its compare to sort. Never bare sort().

Q: How do you count “characters” for a tweet-style limit?

A: Decide what the product means. Code units for storage, code points for a rough count, grapheme clusters via Intl.Segmenter for what users perceive, UTF-8 bytes via TextEncoder for wire limits. Twitter-style limits historically counted code points with weighting.


5. What JavaScript does not ship

This is the table to have memorised. JavaScript’s standard library is thin compared to Python’s or Java’s, and interviewers know it — “there’s no heap in JS, so what do you do?” is a real opener.

Missing structureWhat you need it forIn an interviewIn production
Binary heap / priority queueDijkstra, A*, top-k, k-way merge, schedulers, medianWrite the 40-line MinHeap from section 13. Interviewers expect you to be able to.heap-js, @datastructures-js/priority-queue, or your own — it is small enough to own
O(1) dequeBFS, sliding-window maximum, monotonic queuesHead-index array (for (let h = 0; h < q.length; h++)) for one-shot traversals; ring buffer from section 11 if you pop from both endsring buffer, or denque
Sorted map / sorted set / TreeMaprange queries, floor/ceiling, ordered iteration, “next event after t”Sorted array + binary search (section 23) if writes are rare; AVL (section 15) if they are notsorted-btree, js-sdsl, @datastructures-js/red-black-tree
Linked listO(1) splice given a node handle — the DLL half of an LRU, intrusive free lists25-line DLL with sentinels (section 9)rarely the right answer outside caches; a Map usually wins
Trieprefix search, autocomplete, word games, IP routing tablesMap-of-children trie (section 16)a real search index (or a compressed/radix trie if memory matters)
Union-Finddynamic connectivity, Kruskal, percolation, “accounts merge”Int32Array parent + size with path compression (section 17)same code; it is 25 lines
Multiset / Counterfrequency counting, anagram grouping, top-kMap<T, number> with m.set(k, (m.get(k) ?? 0) + 1)same; Map.groupBy for grouping
BitSetdense boolean sets, sieve, bitmask DP over > 32 itemsUint32Array with a[i >> 5] |= 1 << (i & 31)same, or BigInt bit ops for arbitrary width (slower but simpler)
Immutable / persistent collectionsstructural sharing, cheap undo, React stateout of scope in interviewsImmutable.js, Mori, or structuredClone + discipline
Fixed-layout structs / arenascache-friendly numeric work, zero-copy IPCTypedArray + DataViewsame, plus SharedArrayBuffer for workers
Interval tree / k-d tree / segment treerange/geometry queriessegment tree and Fenwick from section 20domain libraries (rbush, flatbush)
Probabilistic sketchescardinality, membership, heavy hitters at scaleBloom filter from section 22Redis (BF.*, PFCOUNT), or bloom-filters

For contrast, Python ships heapq, bisect, collections.deque, collections.Counter, collections.OrderedDict, collections.defaultdict, functools.lru_cache and array.array in the standard library — which is why the Python file in this set is much shorter on implementations and longer on “know the module”. See Data structures in Python.

How to answer the opener. “JavaScript has no priority queue, so I’ll write one — it’s about 40 lines and I’ll use it for the Dijkstra loop. Do you want me to write it out, or may I assume it and focus on the algorithm?” That sentence demonstrates you know the gap, you know the fix, and you know how to manage interview time. Most interviewers say “assume it”.


6. Part 1 test run

Every assertion in Part 1 is in part1.ts and runs clean on Node 22.22.2:

holey sorted: [ 1, 2, 3, undefined, <1 empty item> ] length 5 | 3 in = true | 4 in = false | cmp calls 4
Float16Array? undefined | Uint8Array.fromBase64? undefined | Atomics? object
Map.getOrInsert? undefined | Iterator.prototype.map? function

ALL PART 1 ASSERTIONS PASSED

Read the middle two lines as a feature-availability check on this runtime:

FeatureSpecNode 22 / V8 12.4
Set union/intersection/difference/…ES2025available (all seven assertions passed)
Iterator helpers (Iterator.prototype.map)ES2025available
Object.groupBy / Map.groupByES2024available
Intl.SegmenterES2022 Intlavailable
Float16ArrayES2025not available
Uint8Array.fromBase64 / toBase64ES2025not available
Map.prototype.getOrInsert(Computed)ES2025not available

If you are asked to use one of the bottom three, say “that’s specified but not in Node 22’s V8 yet” and write the two-line polyfill. getOrInsert in particular:

function getOrInsert<K, V>(m: Map<K, V>, k: K, make: () => V): V {
  let v = m.get(k);
  if (v === undefined && !m.has(k)) { v = make(); m.set(k, v); }
  return v as V;
}

Part 2 — from scratch

Everything from here on is generic, strict TypeScript with no dependencies. One shared type alias is used throughout — a comparator in the standard “negative / zero / positive” shape:

type Cmp<T> = (a: T, b: T) => number;
const asc: Cmp<number> = (a, b) => a - b;

Passing a comparator rather than requiring T extends Comparable is the idiomatic TypeScript choice: it works for primitives, tuples and objects, it lets one class serve as both a min-heap and a max-heap, and it keeps the type parameter unconstrained. The alternative — a compareTo method constraint — is more Java than TypeScript and forces you to wrap primitives.

7. Dynamic array

7.1 Intuition

A dynamic array is a fixed-size buffer plus a length, with a rule for what to do when the buffer is full: allocate a bigger one and copy. If the new size is a constant multiple of the old (doubling), the copies are rare enough that the average cost per push is constant. If you grow by a constant amount (+1, +10), they are not, and push becomes O(n) amortised.

push into a full buffer (capacity 4, length 4)

before   [ a b c d ]                    capacity 4
alloc    [ _ _ _ _ _ _ _ _ ]            capacity 8
copy     [ a b c d _ _ _ _ ]            O(n) once
write    [ a b c d e _ _ _ ]            length 5

7.2 The amortised argument

Two standard proofs; know at least one cold.

Aggregate method. Starting from capacity 1, the reallocations happen at lengths 1, 2, 4, 8, …, n. Total elements copied is 1 + 2 + 4 + … + n < 2n. So n pushes cost O(n) total work, hence O(1) each on average.

Potential (banker’s) method. Charge each push 3 units: 1 to write the element, and 2 saved in the “bank” attached to that element. When the buffer of size k fills, the k/2 elements pushed since the last resize each carry 2 saved units — exactly the k units needed to copy all k elements. The bank never goes negative, so the amortised cost per push is the constant 3.

Why shrink at 1/4, not 1/2. If you halved capacity as soon as length dropped below half, an alternating push/pop at the boundary would reallocate on every single operation — O(n) each, forever. Shrinking only at length ≤ capacity/4 and halving leaves the result at length = capacity/2, so Ω(n) further operations are needed before the next resize in either direction. That gap is the hysteresis, and “why 1/4” is a very common follow-up.

7.3 Complexity

OperationBestAverageWorstSpaceWhy
get / setO(1)O(1)O(1)pointer arithmetic
pushO(1)O(1) amortisedO(n)the resize copy
popO(1)O(1) amortisedO(n)the shrink copy
insertAt(i)O(1) at endO(n)O(n)shifts n - i elements
removeAt(i)O(1) at endO(n)O(n)shifts n - i elements
indexOfO(1)O(n)O(n)linear scan
total spaceO(n)≤ 2n slots with doubling

7.4 Implementation

class DynamicArray<T> {
  private data: (T | undefined)[];
  private n = 0;
  constructor(capacity = 4) { this.data = new Array<T | undefined>(Math.max(1, capacity)); }
  get length(): number { return this.n; }
  get capacity(): number { return this.data.length; }

  private reallocate(cap: number): void {
    const next = new Array<T | undefined>(cap);
    for (let i = 0; i < this.n; i++) next[i] = this.data[i];
    this.data = next;
  }

  push(v: T): void {
    if (this.n === this.data.length) this.reallocate(this.data.length * 2);
    this.data[this.n++] = v;
  }

  pop(): T | undefined {
    if (this.n === 0) return undefined;
    const v = this.data[--this.n] as T;
    this.data[this.n] = undefined;                       // drop the reference: GC hygiene
    if (this.n > 0 && this.n * 4 <= this.data.length) this.reallocate(this.data.length >> 1);
    return v;
  }

  get(i: number): T {
    if (i < 0 || i >= this.n) throw new RangeError(`index ${i} out of [0,${this.n})`);
    return this.data[i] as T;
  }

  set(i: number, v: T): void {
    if (i < 0 || i >= this.n) throw new RangeError(`index ${i} out of [0,${this.n})`);
    this.data[i] = v;
  }

  insertAt(i: number, v: T): void {
    if (i < 0 || i > this.n) throw new RangeError(`index ${i}`);
    if (this.n === this.data.length) this.reallocate(this.data.length * 2);
    for (let j = this.n; j > i; j--) this.data[j] = this.data[j - 1];
    this.data[i] = v;
    this.n++;
  }

  removeAt(i: number): T {
    const v = this.get(i);
    for (let j = i; j < this.n - 1; j++) this.data[j] = this.data[j + 1];
    this.data[--this.n] = undefined;
    return v;
  }

  *[Symbol.iterator](): IterableIterator<T> { for (let i = 0; i < this.n; i++) yield this.data[i] as T; }
  toArray(): T[] { return [...this]; }
}

Verified behaviour: 100 pushes from capacity 2 land at capacity 128; popping back down to length 3 leaves capacity 8 (not 4), demonstrating the 1/4-shrink hysteresis; get(3) then throws RangeError.

Pitfalls

  • Forgetting to null out the popped slot. this.data[this.n] = undefined is not cosmetic: without it, the buffer keeps a strong reference to a logically removed object and you have a slow leak. This is a favourite senior-level detail.
  • Shrinking at capacity/2 instead of capacity/4 (thrashing, see above).
  • Growing by a constant instead of a factor. capacity + 1 makes n pushes O(n²).
  • Using the capacity rather than the length in the iterator, exposing stale slots.
  • Off-by-one in insertAt: the valid range is [0, n] inclusive, unlike get.
  • Copying with slice/spread inside reallocate — correct, but then you are just wrapping the built-in array and the exercise is pointless. Copy element by element.

Interview follow-ups

Q: Prove push is amortised O(1).

A: With doubling, total elements copied over n pushes is 1 + 2 + 4 + … + n < 2n, so total work is O(n) and per-push average is O(1). Or use the potential method: charge 3 per push, bank 2, spend the bank on the next copy.

Q: Why shrink at 1/4 capacity rather than 1/2?

A: To avoid thrashing. Halving at 1/2 leaves the array full, so an alternating push/pop at the boundary reallocates every operation. Halving at 1/4 leaves it half full, guaranteeing Ω(n) operations before the next resize.

Q: What is the memory overhead?

A: Up to 2× the live data with doubling (just after a resize the buffer is half empty; just before it is full). Growth factor 1.5 trades a bit more copying for less slack, which is why some allocators prefer it — the freed block can be reused by the next allocation.

Q: How does this differ from V8’s Array?

A: Same idea, but V8 also specialises the element representation (PACKED_SMI and friends), keeps length and capacity separate in the FixedArray backing store, can share a copy-on-write backing store between array literals, and can degrade to a dictionary if you make the array sparse.

Q: Would you use a Uint32Array internally?

A: For numeric element types, yes — no boxing, no hole checks, contiguous memory. But you lose genericity, so the generic version has to use a plain array.


8. Singly linked list

8.1 Intuition

A chain of nodes, each holding a value and a pointer to the next. You trade O(1) random access for O(1) insert/remove at a position you already hold. In JavaScript, a linked list is almost never the right production data structure — pointer chasing destroys cache locality and every node is a separate heap allocation — but it is the single most interviewed structure because the pointer manipulation is easy to get wrong under pressure.

head

 v
[1|•]──>[2|•]──>[3|•]──>[4|/]         '/' = null

8.2 Complexity

OperationSinglyWhy
pushFrontO(1)rewire head
pushBackO(1) with a tail pointer, O(n) withoutmust reach the end
popFrontO(1)rewire head
popBackO(n)need the previous node, which you cannot reach
get(i)O(n)no arithmetic addressing
insertAfter(node)O(1)given the handle
removeAfter(node)O(1)given the handle
remove(node)O(n)need the predecessor — this is the singly-linked weakness
searchO(n)
spaceO(n)plus one pointer per node — roughly 3× an array for numbers

8.3 The node and the two converters

Every classic problem is easier if you can build and inspect lists in one line.

class SNode<T> {
  next: SNode<T> | null = null;
  constructor(public value: T) {}
}

function fromArray<T>(xs: readonly T[]): SNode<T> | null {
  let head: SNode<T> | null = null, tail: SNode<T> | null = null;
  for (const x of xs) {
    const node = new SNode(x);
    if (tail === null) { head = node; tail = node; } else { tail.next = node; tail = node; }
  }
  return head;
}

function toArray<T>(head: SNode<T> | null): T[] {
  const out: T[] = [];
  for (let cur = head; cur !== null; cur = cur.next) out.push(cur.value);
  return out;
}

8.4 Reverse — iterative and recursive

The iterative three-pointer dance is the one to write. Say the invariant out loud: prev is the head of the already-reversed prefix, cur is the head of the untouched suffix.

prev   cur                       prev        cur
 /     [1]->[2]->[3]      =>     [1]->/      [2]->[3]
function reverseIterative<T>(head: SNode<T> | null): SNode<T> | null {
  let prev: SNode<T> | null = null, cur = head;
  while (cur !== null) {
    const next = cur.next;   // save before you clobber it
    cur.next = prev;
    prev = cur;
    cur = next;
  }
  return prev;
}

function reverseRecursive<T>(head: SNode<T> | null): SNode<T> | null {
  if (head === null || head.next === null) return head;   // base: empty or single
  const newHead = reverseRecursive(head.next);
  head.next.next = head;                                  // the tail of the reversed rest points back
  head.next = null;                                       // and this node becomes the new tail
  return newHead;
}

Iterative is O(1) space; recursive is O(n) stack and will blow up around 10⁴–10⁵ nodes in Node. Say that trade-off unprompted.

8.5 Floyd cycle detection

Two pointers, one moving twice as fast. If there is a cycle, the fast one laps the slow one inside it. Once they meet, reset one pointer to the head and advance both one step at a time; they meet at the cycle entrance.

  a = 3 (tail length)      b = 4 (cycle length)
[1]->[2]->[3]->[4]->[5]->[6]->[7]
                ^                |
                +----------------+

The proof: let a be the distance from head to the cycle entry and let the meeting point be m steps into the cycle. When they meet, slow has travelled a + m and fast 2(a + m), and the difference a + m must be a whole number of cycle lengths. So walking a more steps from the meeting point lands back at the entry — and a steps from the head lands there too. Hence the two-pointer restart.

function detectCycle<T>(head: SNode<T> | null): SNode<T> | null {
  let slow = head, fast = head;
  while (fast !== null && fast.next !== null) {
    slow = slow!.next;
    fast = fast.next.next;
    if (slow === fast) {
      let p = head;
      while (p !== slow) { p = p!.next; slow = slow!.next; }
      return p;                       // cycle entry
    }
  }
  return null;                        // fell off the end: no cycle
}

8.6 The rest of the classics

function mergeTwoSorted<T>(a: SNode<T> | null, b: SNode<T> | null, cmp: Cmp<T>): SNode<T> | null {
  const dummy = new SNode<T>(undefined as unknown as T);   // sentinel kills the "which is head?" branch
  let tail = dummy;
  while (a !== null && b !== null) {
    if (cmp(a.value, b.value) <= 0) { tail.next = a; a = a.next; }   // <= keeps it stable
    else { tail.next = b; b = b.next; }
    tail = tail.next;
  }
  tail.next = a ?? b;                                      // one is null, append the other whole
  return dummy.next;
}

function middleNode<T>(head: SNode<T> | null): SNode<T> | null {
  let slow = head, fast = head;
  while (fast !== null && fast.next !== null) { slow = slow!.next; fast = fast.next.next; }
  return slow;                        // even length -> the SECOND middle; swap the loop test for the first
}

function removeNthFromEnd<T>(head: SNode<T> | null, n: number): SNode<T> | null {
  const dummy = new SNode<T>(undefined as unknown as T);
  dummy.next = head;
  let lead: SNode<T> | null = dummy, trail: SNode<T> = dummy;
  for (let i = 0; i < n; i++) {
    if (lead === null) throw new RangeError('n larger than list');
    lead = lead.next;
  }
  while (lead !== null && lead.next !== null) { lead = lead.next; trail = trail.next!; }
  trail.next = trail.next!.next;      // trail is the (n+1)-th from the end
  return dummy.next;                  // dummy is why removing the head needs no special case
}

function isPalindrome<T>(head: SNode<T> | null): boolean {
  if (head === null || head.next === null) return true;
  let slow: SNode<T> = head, fast: SNode<T> | null = head;
  while (fast !== null && fast.next !== null) { slow = slow.next!; fast = fast.next.next; }
  const second = reverseIterative(slow);                  // reverse the back half in place
  let p: SNode<T> | null = head, q: SNode<T> | null = second;
  let ok = true;
  while (q !== null) { if (p!.value !== q.value) { ok = false; break; } p = p!.next; q = q.next; }
  reverseIterative(second);                               // restore the input — interviewers like this
  return ok;
}

isPalindrome is O(n) time and O(1) space, which is the point; the naive version copies to an array. Note that it compares with !==, so it is identity/value equality on T — parameterise with an equality function if T is a record.

8.7 A list class worth writing

class SinglyLinkedList<T> {
  private head: SNode<T> | null = null;
  private tail: SNode<T> | null = null;   // the tail pointer is what makes pushBack O(1)
  private n = 0;
  get size(): number { return this.n; }
  get first(): T | undefined { return this.head?.value; }
  get last(): T | undefined { return this.tail?.value; }

  pushBack(v: T): this {
    const node = new SNode(v);
    if (this.tail === null) { this.head = node; this.tail = node; }
    else { this.tail.next = node; this.tail = node; }
    this.n++;
    return this;
  }

  pushFront(v: T): this {
    const node = new SNode(v);
    node.next = this.head;
    this.head = node;
    if (this.tail === null) this.tail = node;
    this.n++;
    return this;
  }

  popFront(): T | undefined {
    if (this.head === null) return undefined;
    const v = this.head.value;
    this.head = this.head.next;
    if (this.head === null) this.tail = null;   // emptied: keep the invariant
    this.n--;
    return v;
  }

  removeFirst(pred: (v: T) => boolean): boolean {
    let prev: SNode<T> | null = null;
    for (let cur = this.head; cur !== null; prev = cur, cur = cur.next) {
      if (!pred(cur.value)) continue;
      if (prev === null) this.head = cur.next; else prev.next = cur.next;
      if (cur === this.tail) this.tail = prev;
      this.n--;
      return true;
    }
    return false;
  }

  reverse(): this {
    this.tail = this.head;                      // the old head becomes the tail
    this.head = reverseIterative(this.head);
    return this;
  }

  *[Symbol.iterator](): IterableIterator<T> {
    for (let cur = this.head; cur !== null; cur = cur.next) yield cur.value;
  }
}

Pitfalls

  • Losing the next pointer before you overwrite it. Every reverse bug is this bug.
  • Forgetting to update tail on popFront (when the list empties) and on reverse. The size and head stay right, so tests pass until someone calls pushBack.
  • Not using a dummy/sentinel head for problems where the first node can be removed. It turns two cases into one.
  • while (fast.next.next) without checking fast.next first: TypeError on even-length lists.
  • Recursive reverse on a long list: stack overflow. Node’s default stack is roughly 10⁴ frames.
  • Returning head instead of prev from an iterative reverse — you return the new tail.
  • Assuming middleNode returns the first middle. For even length this returns the second; if a problem needs the first, stop when fast.next.next is null.

Interview follow-ups

Q: Reverse a list in O(1) space.

A: Three pointers — prev, cur, next — walk once, flipping each next to prev. Return prev.

Q: How does Floyd’s algorithm find the cycle start, not just detect a cycle?

A: After the pointers meet, the distance from the head to the entry equals the distance from the meeting point to the entry (mod cycle length). So reset one pointer to the head and advance both one step at a time; they meet at the entry.

Q: Cycle detection without Floyd?

A: A Set of visited nodes — O(n) time, O(n) space. Floyd’s is O(1) space, which is the only reason it exists. Brent’s algorithm is an alternative with fewer pointer moves.

Q: Singly vs doubly — when is the extra pointer worth it?

A: When you need O(1) removal given a node handle, or O(1) popBack, or backwards iteration. That is exactly the LRU cache, which is why LRU uses a doubly linked list.

Q: Why is a linked list slow in practice despite the O(1) claims?

A: Each node is a separate allocation, so traversal is a chain of cache misses, and each node costs a header plus a pointer. An array of the same data is one contiguous block prefetched by the hardware. The O(1) insert only helps if you already hold the node.

Q: Merge k sorted lists?

A: Either pairwise merge in rounds — O(n log k) — or a min-heap of the k current heads, popping and pushing — also O(n log k) with O(k) extra space. See kWayMerge in section 13.

Q: Detect the intersection node of two lists?

A: Walk both, note the lengths, advance the longer by the difference, then step together until the nodes are identical. Or the pointer-swap trick: when a pointer hits the end, restart it on the other list; they meet at the intersection after at most m + n steps.


9. Doubly linked list

9.1 Intuition

Add a prev pointer and removal becomes O(1) given a node — which is the whole reason the structure exists. Two sentinel nodes (a permanent head and tail that hold no data) remove every null check from the hot paths, so insertBetween and unlink have no branches at all. That is worth doing even in an interview: the code gets shorter, not longer.

       sentinels
  ┌────────────────────────────────────────┐
 [H] <-> [a] <-> [b] <-> [c] <-> [T]
  ^                               ^
  head (no value)                 tail (no value)

9.2 Complexity

OperationTimeNote
pushFront / pushBackO(1)between a sentinel and its neighbour
popFront / popBackO(1)the singly-linked list cannot do popBack
insertBefore / insertAfter(node)O(1)given the handle
unlink(node)O(1)the reason to use a DLL
moveToBack(node)O(1)splice without allocating — LRU’s core move
get(i)O(n)still no random access
searchO(n)
spaceO(n)2 pointers per node

9.3 Implementation

class DNode<T> {
  prev: DNode<T> | null = null;
  next: DNode<T> | null = null;
  constructor(public value: T) {}
}

class DoublyLinkedList<T> implements Iterable<T> {
  private readonly head = new DNode<T>(undefined as unknown as T);
  private readonly tail = new DNode<T>(undefined as unknown as T);
  private n = 0;
  constructor(items?: Iterable<T>) {
    this.head.next = this.tail;
    this.tail.prev = this.head;
    if (items) for (const x of items) this.pushBack(x);
  }
  get size(): number { return this.n; }

  private insertBetween(v: T, left: DNode<T>, right: DNode<T>): DNode<T> {
    const node = new DNode(v);
    node.prev = left; node.next = right;
    left.next = node; right.prev = node;
    this.n++;
    return node;                                  // return the handle: callers need it
  }

  pushBack(v: T): DNode<T> { return this.insertBetween(v, this.tail.prev!, this.tail); }
  pushFront(v: T): DNode<T> { return this.insertBetween(v, this.head, this.head.next!); }
  insertBefore(node: DNode<T>, v: T): DNode<T> { return this.insertBetween(v, node.prev!, node); }
  insertAfter(node: DNode<T>, v: T): DNode<T> { return this.insertBetween(v, node, node.next!); }

  unlink(node: DNode<T>): T {
    if (node === this.head || node === this.tail) throw new Error('cannot unlink a sentinel');
    node.prev!.next = node.next;
    node.next!.prev = node.prev;
    node.prev = null; node.next = null;           // isolate it so a stale handle cannot corrupt the list
    this.n--;
    return node.value;
  }

  popBack(): T | undefined { return this.n === 0 ? undefined : this.unlink(this.tail.prev!); }
  popFront(): T | undefined { return this.n === 0 ? undefined : this.unlink(this.head.next!); }
  peekFront(): T | undefined { return this.n === 0 ? undefined : this.head.next!.value; }
  peekBack(): T | undefined { return this.n === 0 ? undefined : this.tail.prev!.value; }

  moveToBack(node: DNode<T>): void {              // no allocation, no size change
    node.prev!.next = node.next;
    node.next!.prev = node.prev;
    const left = this.tail.prev!;
    left.next = node; node.prev = left;
    node.next = this.tail; this.tail.prev = node;
  }

  *[Symbol.iterator](): IterableIterator<T> {
    for (let c = this.head.next!; c !== this.tail; c = c.next!) yield c.value;
  }
  *reversed(): IterableIterator<T> {
    for (let c = this.tail.prev!; c !== this.head; c = c.prev!) yield c.value;
  }
}

The returned DNode<T> handle is the API’s whole value. LRUCacheDLL in section 19 stores those handles in a Map and that is what makes eviction O(1).

Pitfalls

  • Exposing DNode publicly without guarding sentinels. A caller who unlinks the tail sentinel corrupts the list silently. Hence the explicit throw.
  • Forgetting to null out prev/next on unlink. A stale handle then still stitches into the live list and — worse — keeps the neighbours alive for GC.
  • Doing moveToBack on a node that is already the last one. The code above happens to be safe (it re-links to itself correctly because left is recomputed after the splice-out), but the naive ordering — grab left = tail.prev before unlinking — self-links the node and hangs the iterator. Order matters.
  • Maintaining size in two places (unlink and each pop) and double-decrementing. Route every removal through unlink.
  • Circular DLL with a single sentinel is also fine and slightly more compact; just be consistent.

Interview follow-ups

Q: Why do the sentinels help?

A: Every insertion and removal happens strictly between two existing nodes, so left.next and right.prev always exist. No null checks, no “is this the head?” special case, and an empty list is just H <-> T.

Q: How much memory does a DLL cost versus an array?

A: Per element: one object header, a value slot and two pointers — call it 4–6 words in V8 — versus one slot in a packed array. Plus the allocations are scattered, so traversal misses cache.

Q: When is a DLL genuinely the right choice?

A: When you hold node handles and need O(1) reorder or removal: LRU/LFU caches, intrusive free lists, a text editor’s line buffer, a scheduler’s run queue. Almost never for “a list of things”.

Q: Can you make a DLL iterator safe against concurrent mutation?

A: Capture the next node before yielding, or version-stamp the list and throw on mutation mid-iteration (like Java’s ConcurrentModificationException). The generator above reads c.next after the yield resumes, so unlinking the current node during iteration ends the loop early.


10. Stack and queue

10.1 Stack

LIFO. In JavaScript a plain array is a stack: push/pop are both amortised O(1) at the end. Wrap it only for the API and the type safety.

class Stack<T> {
  private items: T[] = [];
  get size(): number { return this.items.length; }
  get isEmpty(): boolean { return this.items.length === 0; }
  push(v: T): void { this.items.push(v); }
  pop(): T | undefined { return this.items.pop(); }
  peek(): T | undefined { return this.items[this.items.length - 1]; }
  *[Symbol.iterator](): IterableIterator<T> {
    for (let i = this.items.length - 1; i >= 0; i--) yield this.items[i];   // top-down
  }
}

Iterating top-down is a deliberate choice: it matches the mental model, and it is what callers expect when they spread a stack.

10.2 Why Array.shift() is the wrong queue

Repeating the Part 1 point because it is the queue question: shift() renumbers every index, so it is O(n), and a BFS written with it is O(n²) in the queue length. On a 100k-node BFS that is the difference between milliseconds and tens of seconds. Three correct answers:

ApproachEnqueueDequeueMemoryUse when
head index into an arrayO(1)O(1)never reclaims dequeued slotsone-shot traversals (BFS, topological sort)
two stacksO(1)O(1) amortisedO(n)you want the classic interview answer
ring bufferO(1)O(1) worst caseO(capacity)long-running queues, bounded buffers

10.3 Queue via two stacks

Push onto inbox. When you need to dequeue and outbox is empty, pour inbox into outbox — which reverses it, so outbox.pop() yields the oldest element. Each element is moved at most once from inbox to outbox, so the amortised cost is O(1) even though a single dequeue can be O(n).

enqueue 1,2,3       inbox [1 2 3]   outbox []
dequeue             pour ->         outbox [3 2 1]   pop -> 1
enqueue 4           inbox [4]       outbox [3 2]
dequeue                             pop -> 2         (no pour: outbox non-empty)
class QueueTwoStacks<T> {
  private inbox: T[] = [];
  private outbox: T[] = [];
  get size(): number { return this.inbox.length + this.outbox.length; }
  enqueue(v: T): void { this.inbox.push(v); }
  private transfer(): void {
    if (this.outbox.length === 0) while (this.inbox.length) this.outbox.push(this.inbox.pop()!);
  }
  dequeue(): T | undefined { this.transfer(); return this.outbox.pop(); }
  peek(): T | undefined { this.transfer(); return this.outbox[this.outbox.length - 1]; }
}

The critical line is if (this.outbox.length === 0). Pouring on every dequeue — or pouring when the outbox is non-empty — breaks both FIFO order and the amortised bound.

10.4 Queue via a ring buffer

One fixed array, a head index and a count. Physical positions wrap with modulo; the logical order is head, head+1, … mod capacity. O(1) worst case for both ends, no allocation after construction, which is why every real-world bounded queue (audio buffers, network rings, libuv’s internals) is a ring.

capacity 5, head=3, n=4
index:   0    1    2    3    4
       [ c ][ d ][   ][ a ][ b ]
                        ^head
logical order: a b c d
class RingQueue<T> {
  private buf: (T | undefined)[];
  private head = 0;
  private n = 0;
  constructor(private readonly cap: number) {
    if (cap <= 0) throw new RangeError('capacity must be positive');
    this.buf = new Array<T | undefined>(cap);
  }
  get size(): number { return this.n; }
  get isFull(): boolean { return this.n === this.cap; }
  enqueue(v: T): void {
    if (this.isFull) throw new Error('queue is full');    // or overwrite, for a lossy ring
    this.buf[(this.head + this.n) % this.cap] = v;
    this.n++;
  }
  dequeue(): T | undefined {
    if (this.n === 0) return undefined;
    const v = this.buf[this.head] as T;
    this.buf[this.head] = undefined;                      // release the reference
    this.head = (this.head + 1) % this.cap;
    this.n--;
    return v;
  }
  peek(): T | undefined { return this.n === 0 ? undefined : (this.buf[this.head] as T); }
}

Store a count, not a tail index. With head and tail alone, head === tail is ambiguous between empty and full, and the usual fix is to waste one slot. A count removes the ambiguity for free.

If the capacity is a power of two, replace % cap with & (cap - 1) — one instruction instead of a division, and it is what production ring buffers do.

Pitfalls

  • shift() in a loop. Again.
  • Two-stack queue: transferring when the outbox is not empty, which scrambles the order.
  • Ring buffer: using head === tail as the empty test without a count or a wasted slot.
  • Ring buffer: negative modulo. (head - 1) % cap is negative in JavaScript for head === 0; use (head - 1 + cap) % cap.
  • Not clearing the slot on dequeue — the same GC-hygiene bug as the dynamic array.
  • Using a Set as a queue because it iterates in insertion order. It works, but delete on the front is O(1) only amortised and the table never shrinks, so churn leaks.

Interview follow-ups

Q: Implement a queue with two stacks and state the complexity.

A: Enqueue pushes to the inbox: O(1). Dequeue pops the outbox, refilling it from the inbox only when it is empty: O(1) amortised, O(n) worst case. Each element moves between the stacks at most once.

Q: Implement a stack with two queues.

A: Possible but ugly: either push is O(n) (enqueue, then rotate all previous elements behind it) or pop is O(n) (move n-1 elements to the other queue). There is no O(1)/O(1) construction.

Q: Why does the amortised bound hold for the two-stack queue?

A: Potential method: each element carries 1 unit of stored work when it enters the inbox, spent when it is poured into the outbox. Total work over n operations is O(n).

Q: Bounded queue that overwrites the oldest element when full?

A: In enqueue, if full, write at (head + n) % cap and advance head — that is a lossy ring buffer, exactly what a fixed-size log or metrics window wants.

Q: How do you make a ring buffer growable?

A: On overflow, allocate 2× and copy in logical order (for i in 0..n: next[i] = buf[(head+i)%cap]), resetting head to 0. That is the deque in section 11.

Q: How do the built-in queue-ish options compare?

A: There are none. Array gives you a stack for free and a queue only with a head index; Map/Set give insertion order but not cheap front removal. Everything else you write.


11. Deque

11.1 Intuition

A double-ended queue: O(1) push and pop at both ends, plus O(1) indexed access. A growable ring buffer gives you all three. This is the structure JavaScript most conspicuously lacks — sliding-window maximum, monotonic deques, BFS with early exit, and 0-1 BFS all want it — and Python gets it for free from collections.deque.

pushFront wraps backwards; pushBack wraps forwards

capacity 8, head=6, n=4:
  0    1    2    3    4    5    6    7
[ c ][ d ][   ][   ][   ][   ][ a ][ b ]
                                ^head
logical: a b c d      at(0)=a   at(-1)=d

11.2 Complexity

OperationTimeNote
pushBack / pushFrontO(1) amortisedO(n) on the grow
popBack / popFrontO(1)
at(i)O(1)one modulo
iterationO(n)
spaceO(capacity)≤ 2n with doubling

11.3 Implementation

class Deque<T> implements Iterable<T> {
  private buf: (T | undefined)[];
  private head = 0;
  private n = 0;
  constructor(capacity = 8) { this.buf = new Array<T | undefined>(Math.max(1, capacity)); }
  get size(): number { return this.n; }
  get capacity(): number { return this.buf.length; }

  private grow(): void {
    const next = new Array<T | undefined>(this.buf.length * 2);
    for (let i = 0; i < this.n; i++) next[i] = this.buf[(this.head + i) % this.buf.length];
    this.buf = next;
    this.head = 0;                       // unwrap into logical order while copying
  }

  pushBack(v: T): void {
    if (this.n === this.buf.length) this.grow();
    this.buf[(this.head + this.n) % this.buf.length] = v;
    this.n++;
  }

  pushFront(v: T): void {
    if (this.n === this.buf.length) this.grow();
    this.head = (this.head - 1 + this.buf.length) % this.buf.length;   // + length: JS modulo is signed
    this.buf[this.head] = v;
    this.n++;
  }

  popFront(): T | undefined {
    if (this.n === 0) return undefined;
    const v = this.buf[this.head] as T;
    this.buf[this.head] = undefined;
    this.head = (this.head + 1) % this.buf.length;
    this.n--;
    return v;
  }

  popBack(): T | undefined {
    if (this.n === 0) return undefined;
    const i = (this.head + this.n - 1) % this.buf.length;
    const v = this.buf[i] as T;
    this.buf[i] = undefined;
    this.n--;
    return v;
  }

  at(i: number): T | undefined {
    if (i < 0) i += this.n;                                  // negative indices like Array#at
    if (i < 0 || i >= this.n) return undefined;
    return this.buf[(this.head + i) % this.buf.length] as T;
  }

  *[Symbol.iterator](): IterableIterator<T> { for (let i = 0; i < this.n; i++) yield this.at(i) as T; }
}

Verified: 1,002 elements after 1,000 pushFront calls on a deque that started at capacity 2, with at(0) and at(1001) both correct across many wraps and regrowths.

Pitfalls

  • Negative modulo. (0 - 1) % 8 is -1 in JavaScript, not 7. Always (x - 1 + len) % len.
  • Growing without unwrapping. If you slice-copy the raw buffer you keep the wrap, and every index computation afterwards is wrong. Copy in logical order and reset head = 0.
  • Computing the back index as (head + n) % len when popping — that is one past the end. It is (head + n - 1) % len.
  • Forgetting that capacity and size are different, and iterating the buffer instead of the logical range.
  • Using % on a hot path when the capacity is a power of two: use & (cap - 1).

Interview follow-ups

Q: Why not just use two arrays back to back?

A: You can — a “front” array you pop from and a “back” array you push to, rebalancing when one empties. It is amortised O(1) but has worse constants and no O(1) indexed access.

Q: How does collections.deque in Python differ?

A: CPython uses a doubly linked list of 64-slot blocks, so pushes never copy the whole structure and there is no amortised-O(n) spike — but indexing is O(n) in the middle. The ring buffer trades that for O(1) indexing.

Q: What is the deque used for in interview algorithms?

A: Sliding-window maximum (monotonic deque), 0-1 BFS (push-front for weight-0 edges), palindrome checks from both ends, and “last k items” windows. See Algorithm patterns.

Q: Can you shrink it?

A: Yes — same 1/4 rule as the dynamic array, halving when n * 4 <= capacity. Omitted above because deques usually have a bounded working set; add it if the deque outlives its peak.


12. Hash table

12.1 Intuition

Turn a key into an integer, use it to pick a slot, and deal with the fact that two keys will eventually pick the same slot. Every design decision falls out of that last clause: collision resolution is the whole subject. Two families:

SEPARATE CHAINING                    OPEN ADDRESSING (linear probing)
buckets                              one flat array
 0 -> [k1,v1] -> [k9,v9]              0: (k1,v1)
 1 -> null                            1: (k9,v9)   <- collided with 0, placed next
 2 -> [k4,v4]                         2: (k4,v4)
 3 -> null                            3: empty

Chaining is forgiving: load factor can exceed 1, deletion is trivial, and the worst case degrades gracefully. Open addressing is faster when it fits in cache — one contiguous array, no pointer chasing — but it needs load factor well under 1 and deletion needs tombstones.

12.2 A string hash: FNV-1a

You need a hash function you can write from memory. FNV-1a is five lines and has good avalanche for short strings.

function fnv1a(str: string): number {
  let h = 0x811c9dc5;                        // 2166136261, the FNV offset basis
  for (let i = 0; i < str.length; i++) {
    h ^= str.charCodeAt(i);                  // XOR first, then multiply — that is the "1a" variant
    h = Math.imul(h, 0x01000193);            // 16777619, the FNV prime
  }
  return h >>> 0;                            // back to an unsigned 32-bit integer
}

Three JavaScript-specific details that matter:

  • Math.imul is mandatory. h * 16777619 overflows into a double and loses the low bits, which destroys the hash. Math.imul performs a true 32-bit integer multiply.
  • >>> 0 converts the signed int32 result of the bitwise ops to an unsigned 32-bit value. Without it you can get negative indices.
  • charCodeAt gives UTF-16 code units, so surrogate pairs hash as two units. That is fine and consistent; just do not claim it hashes “characters”.

Verified against known FNV-1a 32-bit vectors: fnv1a('a') === 0xe40c292c and fnv1a('foobar') === 0xbf9cf968. Over 5,000 keys of the form user:N into 1,024 buckets the largest bucket held fewer than 20 entries — flat enough (the expected max for a uniform hash is around 12–14).

For a second independent hash — needed for double hashing and Bloom filters — djb2 is the other one to memorise:

function djb2(str: string): number {
  let h = 5381;
  for (let i = 0; i < str.length; i++) h = (Math.imul(h, 33) + str.charCodeAt(i)) | 0;
  return h >>> 0;
}

12.3 Complexity

Let α be the load factor (entries / slots).

OperationChaining avgChaining worstLinear probing avgProbing worst
get / hasO(1 + α)O(n)O(1/(1 - α))O(n)
setO(1 + α) amortisedO(n)O(1/(1 - α)) amortisedO(n)
deleteO(1 + α)O(n)O(1/(1 - α))O(n)
spacen + tabletable only
max useful α1–2~0.7 (0.5 for safety)

Read the probing column: at α = 0.5 the expected probe count for a successful lookup is about 1.5; at α = 0.9 it is about 5.5; at α = 0.99 it is about 50. That superlinear blow-up is why open addressing resizes aggressively and chaining does not.

The O(n) worst case is not theoretical — it is the hash-flooding DoS: an attacker who can predict your hash function sends keys that all land in one bucket, turning every request into an O(n) scan. Real runtimes defend with a per-process random seed (V8 uses a randomised string hash seed) and, in Java’s case, by converting long buckets into red-black trees. If you write your own table for untrusted keys, seed it.

12.4 Separate chaining

class ChainedHashMap<V> {
  private buckets: [string, V][][];
  private n = 0;
  private readonly maxLoad = 0.75;
  constructor(initialCapacity = 8) {
    this.buckets = Array.from({ length: initialCapacity }, () => []);   // NOT Array(n).fill([])
  }
  get size(): number { return this.n; }
  get loadFactor(): number { return this.n / this.buckets.length; }

  private indexOf(key: string, buckets = this.buckets): number {
    return fnv1a(key) & (buckets.length - 1);      // power-of-two capacity -> mask instead of %
  }

  set(key: string, value: V): void {
    const b = this.buckets[this.indexOf(key)];
    for (const e of b) if (e[0] === key) { e[1] = value; return; }     // update in place
    b.push([key, value]);
    if (++this.n / this.buckets.length > this.maxLoad) this.resize(this.buckets.length * 2);
  }

  get(key: string): V | undefined {
    for (const e of this.buckets[this.indexOf(key)]) if (e[0] === key) return e[1];
    return undefined;
  }

  has(key: string): boolean { return this.buckets[this.indexOf(key)].some(e => e[0] === key); }

  delete(key: string): boolean {
    const b = this.buckets[this.indexOf(key)];
    const i = b.findIndex(e => e[0] === key);
    if (i < 0) return false;
    b.splice(i, 1);
    this.n--;
    return true;
  }

  private resize(cap: number): void {
    const next: [string, V][][] = Array.from({ length: cap }, () => []);
    for (const b of this.buckets) for (const e of b) next[this.indexOf(e[0], next)].push(e);
    this.buckets = next;                            // rehash everything: O(n)
  }

  *entries(): IterableIterator<[string, V]> { for (const b of this.buckets) yield* b; }
  longestChain(): number { return Math.max(...this.buckets.map(b => b.length)); }
}

Note Array.from({length: n}, () => []) in both the constructor and resizeArray(n).fill([]) would give every bucket the same array (see section 1.5).

12.5 Open addressing with tombstones

The subtlety is deletion. If you clear a slot to empty, you truncate every probe chain that passed through it and lookups for later keys start returning undefined. A tombstone marks “occupied before, empty now”: probes continue past it, but inserts may reuse it.

insert k1 -> slot 3, k2 -> 3 taken -> slot 4, k3 -> 3,4 taken -> slot 5

delete k2 with a plain clear:      3:(k1) 4:EMPTY 5:(k3)
   get(k3) probes 3, sees 4 empty, gives up  ->  WRONG

delete k2 with a tombstone:        3:(k1) 4:TOMB  5:(k3)
   get(k3) probes 3, skips 4, finds 5        ->  correct
const TOMBSTONE = Symbol('tombstone');

class OpenAddressingMap<V> {
  private keys: (string | undefined | typeof TOMBSTONE)[];
  private vals: (V | undefined)[];
  private n = 0;                             // live entries
  private used = 0;                          // live + tombstones -> this is what drives resizing
  private readonly maxLoad = 0.5;
  constructor(capacity = 8) {
    this.keys = new Array(capacity);
    this.vals = new Array<V | undefined>(capacity);
  }
  get size(): number { return this.n; }

  private probe(key: string): { slot: number; found: boolean } {
    const mask = this.keys.length - 1;
    let i = fnv1a(key) & mask;
    let firstTomb = -1;
    for (;;) {
      const k = this.keys[i];
      if (k === undefined) return { slot: firstTomb >= 0 ? firstTomb : i, found: false };
      if (k === TOMBSTONE) { if (firstTomb < 0) firstTomb = i; }   // remember it, keep probing
      else if (k === key) return { slot: i, found: true };
      i = (i + 1) & mask;                                          // linear probing
    }
  }

  set(key: string, value: V): void {
    if ((this.used + 1) / this.keys.length > this.maxLoad) this.resize(this.keys.length * 2);
    const { slot, found } = this.probe(key);
    if (!found) {
      if (this.keys[slot] === undefined) this.used++;   // reusing a tombstone does not grow `used`
      this.n++;
    }
    this.keys[slot] = key;
    this.vals[slot] = value;
  }

  get(key: string): V | undefined {
    const { slot, found } = this.probe(key);
    return found ? this.vals[slot] : undefined;
  }

  delete(key: string): boolean {
    const { slot, found } = this.probe(key);
    if (!found) return false;
    this.keys[slot] = TOMBSTONE;               // NOT undefined
    this.vals[slot] = undefined;               // but do release the value
    this.n--;
    return true;
  }

  private resize(cap: number): void {
    const oldKeys = this.keys, oldVals = this.vals;
    this.keys = new Array(cap);
    this.vals = new Array<V | undefined>(cap);
    this.n = 0; this.used = 0;
    for (let i = 0; i < oldKeys.length; i++) {
      const k = oldKeys[i];
      if (typeof k === 'string') this.set(k, oldVals[i] as V);   // rehash drops all tombstones
    }
  }

  *entries(): IterableIterator<[string, V]> {
    for (let i = 0; i < this.keys.length; i++) {
      const k = this.keys[i];
      if (typeof k === 'string') yield [k, this.vals[i] as V];
    }
  }
}

The used counter is the part people miss. Resizing on live entries lets tombstones accumulate without bound: delete and re-insert the same key a million times and every probe walks a million tombstones even though size is 1. Resizing on live + tombstones guarantees the table is rebuilt before that happens. Verified: after inserting 200 keys and deleting the first 100, get('k150') still resolves correctly across the tombstone field.

12.6 Probing strategies

StrategyNext slotClusteringNotes
Linear probing(i + 1) mod mprimary clusteringbest cache locality; fine below α ≈ 0.7
Quadratic probing(i + k²) mod msecondary clusteringneeds m prime (or a power of 2 with triangular numbers) to guarantee it visits every slot
Double hashing(i + k·h₂(key)) mod mleasth₂ must be odd/coprime with m; worst locality
Robin Hoodlinear, but displace richer entriesbounded variancesteal the slot if your probe distance exceeds the incumbent’s; makes lookups uniform
Cuckoo hashingtwo tables, two hashes, evict-and-rehomenoneO(1) worst-case lookup, insert can cascade

For interviews: know linear probing cold, know that quadratic and double hashing exist to break clustering, and be able to say one sentence about Robin Hood and cuckoo.

12.7 Resize: why doubling and why rehash

Resizing is O(n) and unavoidable — the slot for a key depends on the table size, so every key must move. Doubling makes it amortised O(1) per insert by the same argument as the dynamic array. Two refinements worth mentioning:

  • Incremental rehashing. Keep both tables and migrate a few entries on each operation, so no single insert is O(n). Redis does this; it is how it avoids latency spikes on a 10 GB dict.
  • Power-of-two capacity plus a good hash. hash & (m - 1) uses only the low bits, so the hash must avalanche well. A prime modulus tolerates a weaker hash but costs a division. Java uses power-of-two with an extra xor-shift on the hash; that is the standard compromise.

Pitfalls

  • Clearing instead of tombstoning on delete in an open-addressed table. Silent data loss.
  • Resizing on live count, so tombstones accumulate forever.
  • hash % length with a negative hash. >>> 0 first, or use & (length - 1).
  • Plain * instead of Math.imul in the hash. Loses precision and ruins distribution.
  • Array(n).fill([]) for buckets. All buckets alias.
  • Forgetting to check for an existing key in set, so the table accumulates duplicates and get returns whichever comes first.
  • Using object identity as a hash source. There is no hashCode in JavaScript; you must project keys to strings or numbers yourself (or use a real Map, which hashes object identity internally).
  • Mutating a key after insertion. Its hash changes and the entry becomes unreachable — the same bug as a mutable key in a Java HashMap.

Interview follow-ups

Q: Chaining or open addressing?

A: Chaining when the load factor is unpredictable, entries are large, or deletion is frequent — it degrades gracefully. Open addressing when you want cache locality and can keep α below ~0.7, with tombstones or Robin Hood for deletion. V8’s objects/Maps use variants of chaining over an ordered entry array; Python’s dict uses open addressing.

Q: What is a tombstone and why do you need one?

A: A marker meaning “this slot was occupied, keep probing”. Without it, deleting a key that sits in the middle of a probe chain makes every later key in that chain unreachable.

Q: How do you pick the load-factor threshold?

A: For chaining, ~0.75 (Java’s default) keeps the average chain near 1. For linear probing, ~0.5 because expected probes are 1/(1-α) and blow up as α approaches 1. Measure with your key distribution.

Q: What makes a good hash function here?

A: Uniform distribution, avalanche (one input bit flips half the output bits), speed, and — for untrusted input — a random per-process seed so an attacker cannot force collisions.

Q: What is hash flooding and how do runtimes defend against it?

A: Feeding keys chosen to collide, turning O(1) lookups into O(n) and the whole table into O(n²). Defences: a randomised hash seed per process (V8, Python since 3.3), and converting pathological buckets into balanced trees (Java 8’s HashMap).

Q: How would you make lookups O(1) worst case?

A: Cuckoo hashing — two tables and two hashes, a key always lives in one of two slots, so lookup is exactly two probes. Insertion can cascade and may need a full rehash, so you trade worst-case insert for worst-case lookup.

Q: Why is Map still probably faster than this?

A: It is C++ with a randomised seed, inline-cached call sites, no megamorphic property access on entry tuples, and an entry layout tuned for V8’s GC. Write your own only to demonstrate understanding, or when you need behaviour Map does not have.


13. Binary heap

13.1 Intuition

A binary heap is a complete binary tree stored in a flat array, satisfying the heap property: every parent compares less-or-equal to its children (for a min-heap). Completeness is what lets you use array indices instead of pointers, and the heap property is weak — it says nothing about siblings, which is exactly why maintaining it costs only O(log n) instead of O(n).

index:   0    1    2    3    4    5    6
       [ 1 ][ 3 ][ 2 ][ 7 ][ 5 ][ 9 ][ 4 ]

              1(0)
            /      \
         3(1)      2(2)
        /    \     /   \
     7(3)  5(4)  9(5) 4(6)

parent(i) = (i - 1) >> 1     left(i) = 2i + 1     right(i) = 2i + 2

Two operations do all the work. Sift up (after appending at the end): swap with the parent while it is smaller — restoring the property along one root-ward path. Sift down (after moving the last element to the root): swap with the smaller child while it is smaller — one leaf-ward path.

13.2 The O(n) heapify proof sketch

Building a heap by n pushes is O(n log n). Building it bottom-up with sift-down from index floor(n/2) - 1 down to 0 is O(n), and the reason is that most nodes are near the leaves.

At height h from the bottom there are at most n / 2^(h+1) nodes, and sifting one of them down costs O(h). Total:

sum over h of (n / 2^(h+1)) * h  =  (n/2) * sum over h of h / 2^h
                                 =  (n/2) * 2          [ sum h/2^h = 2 ]
                                 =  O(n)

The convergent series Σ h/2^h = 2 is the whole trick: half the nodes are leaves and cost nothing, a quarter cost 1, an eighth cost 2, and the total is bounded by a constant times n. Contrast with sift-up heapify, which is O(n log n) because half the nodes are leaves and each may travel the full height.

13.3 Complexity

OperationTimeSpaceWhy
peekO(1)index 0
pushO(log n)one sift-up path
popO(log n)one sift-down path
pushPop / replaceO(log n)one sift instead of two
build by n pushesO(n log n)
heapify from an arrayO(n)O(1) extrathe series above
heapsortO(n log n)O(1) extraheapify then pop n times
find arbitrary elementO(n)no ordering among siblings
decreaseKey(x)O(log n) with an index mapO(n)otherwise O(n) to find x
delete arbitraryO(log n) with an index mapO(n)swap with last, then sift both ways

13.4 Implementation

class MinHeap<T> {
  private a: T[] = [];
  constructor(private readonly cmp: Cmp<T>, items?: Iterable<T>) {
    if (items) { this.a = [...items]; this.heapify(); }        // O(n), not n pushes
  }
  get size(): number { return this.a.length; }
  peek(): T | undefined { return this.a[0]; }

  push(v: T): void { this.a.push(v); this.siftUp(this.a.length - 1); }

  pop(): T | undefined {
    if (this.a.length === 0) return undefined;
    const top = this.a[0];
    const last = this.a.pop() as T;
    if (this.a.length > 0) { this.a[0] = last; this.siftDown(0); }
    return top;
  }

  /** Push then pop in one pass — the top-k workhorse. */
  pushPop(v: T): T {
    if (this.a.length === 0 || this.cmp(v, this.a[0]) <= 0) return v;   // v is already the min
    const top = this.a[0];
    this.a[0] = v;
    this.siftDown(0);
    return top;
  }

  private heapify(): void {
    for (let i = (this.a.length >> 1) - 1; i >= 0; i--) this.siftDown(i);
  }

  private siftUp(i: number): void {
    const v = this.a[i];
    while (i > 0) {
      const p = (i - 1) >> 1;
      if (this.cmp(v, this.a[p]) >= 0) break;
      this.a[i] = this.a[p];        // hole-punching: one write per level instead of a 3-write swap
      i = p;
    }
    this.a[i] = v;
  }

  private siftDown(i: number): void {
    const n = this.a.length, v = this.a[i];
    for (;;) {
      let c = 2 * i + 1;
      if (c >= n) break;
      if (c + 1 < n && this.cmp(this.a[c + 1], this.a[c]) < 0) c++;   // pick the smaller child
      if (this.cmp(this.a[c], v) >= 0) break;
      this.a[i] = this.a[c];
      i = c;
    }
    this.a[i] = v;
  }

  toSortedArray(): T[] { const out: T[] = []; while (this.size) out.push(this.pop() as T); return out; }
}

A max-heap is the same class with an inverted comparator — new MinHeap<number>((a, b) => b - a) — which is the main argument for comparator injection over a Comparable constraint.

The hole-punching sift is worth pointing out in an interview: instead of swapping (three writes per level), hold the value out of the array, shift parents/children into the hole, and write the value once at the end. Roughly a third of the memory traffic.

13.5 K-way merge

Merging k sorted lists of n total elements: a heap of k cursors, pop the smallest, advance that cursor. O(n log k) time, O(k) space.

function kWayMerge<T>(lists: readonly T[][], cmp: Cmp<T>): T[] {
  type Cursor = { list: number; idx: number };
  const heap = new MinHeap<Cursor>((x, y) => cmp(lists[x.list][x.idx], lists[y.list][y.idx]));
  for (let i = 0; i < lists.length; i++) if (lists[i].length > 0) heap.push({ list: i, idx: 0 });
  const out: T[] = [];
  while (heap.size > 0) {
    const c = heap.pop() as Cursor;
    out.push(lists[c.list][c.idx]);
    if (c.idx + 1 < lists[c.list].length) heap.push({ list: c.list, idx: c.idx + 1 });
  }
  return out;
}

Comparing cursors rather than values keeps the heap at size k and needs no extra allocation per element. This is the merge phase of external sorting: k sorted runs on disk, a heap in memory.

13.6 Indexed heap with decrease-key (for Dijkstra)

Textbook Dijkstra needs decreaseKey, which a plain heap cannot do in O(log n) because it cannot find an element. Add a Map from key to array position and maintain it on every swap.

class IndexedMinHeap<K> {
  private keys: K[] = [];
  private prio: number[] = [];
  private pos = new Map<K, number>();          // the index map is the whole idea
  get size(): number { return this.keys.length; }
  has(k: K): boolean { return this.pos.has(k); }
  priorityOf(k: K): number | undefined {
    const i = this.pos.get(k);
    return i === undefined ? undefined : this.prio[i];
  }

  private swap(i: number, j: number): void {
    [this.keys[i], this.keys[j]] = [this.keys[j], this.keys[i]];
    [this.prio[i], this.prio[j]] = [this.prio[j], this.prio[i]];
    this.pos.set(this.keys[i], i);
    this.pos.set(this.keys[j], j);
  }
  private up(i: number): void {
    while (i > 0) {
      const p = (i - 1) >> 1;
      if (this.prio[p] <= this.prio[i]) break;
      this.swap(i, p); i = p;
    }
  }
  private down(i: number): void {
    const n = this.keys.length;
    for (;;) {
      let c = 2 * i + 1;
      if (c >= n) break;
      if (c + 1 < n && this.prio[c + 1] < this.prio[c]) c++;
      if (this.prio[i] <= this.prio[c]) break;
      this.swap(i, c); i = c;
    }
  }

  insertOrDecrease(k: K, p: number): void {
    const i = this.pos.get(k);
    if (i === undefined) {
      this.keys.push(k); this.prio.push(p); this.pos.set(k, this.keys.length - 1);
      this.up(this.keys.length - 1);
    } else if (p < this.prio[i]) {
      this.prio[i] = p;
      this.up(i);                              // a decrease can only move the node up
    }
  }

  popMin(): { key: K; priority: number } | undefined {
    if (this.keys.length === 0) return undefined;
    const key = this.keys[0], priority = this.prio[0];
    const lastKey = this.keys.pop() as K, lastPrio = this.prio.pop() as number;
    this.pos.delete(key);
    if (this.keys.length > 0) {
      this.keys[0] = lastKey; this.prio[0] = lastPrio; this.pos.set(lastKey, 0);
      this.down(0);
    }
    return { key, priority };
  }
}

The pragmatic alternative — and the one to write when time is short — is the lazy heap: skip decreaseKey, push duplicate (node, newDist) entries, and discard a popped entry whose distance is stale. The heap can hold up to E entries instead of V, so the bound becomes O(E log E) instead of O(E log V) — asymptotically identical since E ≤ V² means log E ≤ 2 log V. Say that out loud and the interviewer will usually let you skip the index map.

13.7 Two-heap median finder

Keep the lower half in a max-heap and the upper half in a min-heap, sizes differing by at most one. The median is then a peek (or the average of two peeks).

       lo (max-heap)          hi (min-heap)
       [ 3 1 2 ]              [ 5 15 ]
          ^ peek = 3             ^ peek = 5
   size 3 vs 2  ->  median = 3
class MedianFinder {
  private lo = new MinHeap<number>((a, b) => b - a);   // max-heap of the small half
  private hi = new MinHeap<number>((a, b) => a - b);   // min-heap of the large half

  add(x: number): void {
    if (this.lo.size === 0 || x <= (this.lo.peek() as number)) this.lo.push(x);
    else this.hi.push(x);
    // rebalance so that lo.size is hi.size or hi.size + 1
    if (this.lo.size > this.hi.size + 1) this.hi.push(this.lo.pop() as number);
    else if (this.hi.size > this.lo.size) this.lo.push(this.hi.pop() as number);
  }

  median(): number {
    if (this.lo.size === 0) return NaN;
    return this.lo.size > this.hi.size
      ? (this.lo.peek() as number)
      : ((this.lo.peek() as number) + (this.hi.peek() as number)) / 2;
  }
}

O(log n) per insert, O(1) per query. The invariant to state: lo holds the smaller half and its max is the lower median candidate; hi holds the larger half; lo.size - hi.size is 0 or 1. Fix the sizes after every insert, never before.

Pitfalls

  • Comparing indices instead of values in a heap of cursors or tuples. Draw the comparator’s argument types.
  • Sift-down that picks the left child instead of the smaller child. Silent, and only wrong sometimes.
  • Off-by-one in parent: it is (i - 1) >> 1, not i >> 1 (which is the 1-indexed formula).
  • Heapifying from n >> 1 instead of (n >> 1) - 1. The first index that has a child is floor(n/2) - 1 in 0-indexed form.
  • pop on a one-element heap: this.a.pop() empties the array, then this.a[0] = last would resurrect it. Guard with if (this.a.length > 0) as above.
  • Assuming the heap array is sorted. It is not — only index 0 is meaningful.
  • Mutating an element’s priority in place without re-sifting. The heap silently breaks.
  • Using a heap when you need the k-th element repeatedly with changing k, or range queries. Use a balanced tree or a Fenwick tree.

Interview follow-ups

Q: Why is bottom-up heapify O(n) and not O(n log n)?

A: Nodes at height h number at most n/2^(h+1) and cost O(h) to sift down, and Σ h/2^h converges to 2. So the total is bounded by ~2n. Half the nodes are leaves and cost nothing.

Q: Top-k largest of a stream of n items?

A: A min-heap of size k: push while the heap is smaller than k, then pushPop each new item — it evicts the smallest if the newcomer is bigger. O(n log k) time, O(k) space. Using a max-heap of all n items is O(n + k log n) and O(n) space, which is worse when k is small and n is huge or unbounded.

Q: Heap vs balanced BST — when does each win?

A: Heap: O(1) min/max peek, O(n) build, smaller constant factors, flat array, no ordering overhead. BST: ordered iteration, predecessor/successor, range queries, and O(log n) arbitrary search — all of which a heap cannot do.

Q: How do you delete an arbitrary element?

A: With an index map: swap it with the last element, pop, then sift the moved element both up and down (only one direction will actually move). Without an index map it is O(n) just to find it.

Q: What does decreaseKey cost and do you need it for Dijkstra?

A: O(log n) with an index map. You do not need it: the lazy variant pushes duplicates and skips stale pops, giving O(E log E), which is the same asymptotic class and much less code.

Q: Is heapsort stable? Is it used in practice?

A: Not stable, and rarely used alone — it has bad cache behaviour compared with quicksort. It shows up as the fallback in introsort (to guarantee O(n log n) when quicksort recursion goes too deep). See Sorting.

Q: What is a d-ary heap and when is it better?

A: Children per node = d instead of 2, so height is log_d n. decreaseKey/push get cheaper (shorter path) and pop gets more expensive (d comparisons per level). d = 4 is a common sweet spot for Dijkstra on dense graphs, and it is more cache-friendly because a node’s children are contiguous.


14. Binary search tree

Intuition

Every node’s left subtree holds smaller keys, the right subtree larger ones. That invariant means an in-order traversal yields sorted order, and a search walks one root-to-leaf path. Everything good about a BST follows from those two facts, and everything bad follows from the fact that nothing keeps the tree short.

                    50
                  /    \
                30      70          in-order: 20 30 40 50 60 70 80
               /  \    /  \
             20   40  60   80

  degenerate (sorted inserts):  1 -> 2 -> 3 -> 4 -> 5   height = n, every op O(n)
OperationBalancedDegenerateSpace
search / insert / deleteO(log n)O(n)O(1) extra, O(h) stack if recursive
in-order traversalO(n)O(n)O(h) with a stack, O(1) with Morris
min / max / successorO(log n)O(n)O(1)
kth smallest (with subtree sizes)O(log n)O(n)O(1)

Inserting sorted data is the pathological case, and it is also the most likely case in real life (auto-increment IDs, timestamps) — which is the whole argument for the balanced trees in the next section.

Implementation

class BSTNode<T> {
  left: BSTNode<T> | null = null;
  right: BSTNode<T> | null = null;
  size = 1;                                   // subtree size, for order statistics
  constructor(public value: T) {}
}

class BST<T> {
  root: BSTNode<T> | null = null;
  constructor(private cmp: (a: T, b: T) => number = (a, b) => (a < b ? -1 : a > b ? 1 : 0)) {}

  insert(v: T): void { this.root = this.#ins(this.root, v); }
  #ins(n: BSTNode<T> | null, v: T): BSTNode<T> {
    if (!n) return new BSTNode(v);
    const c = this.cmp(v, n.value);
    if (c < 0) n.left = this.#ins(n.left, v);
    else if (c > 0) n.right = this.#ins(n.right, v);
    else return n;                              // duplicates ignored
    n.size = 1 + (n.left?.size ?? 0) + (n.right?.size ?? 0);
    return n;
  }

  has(v: T): boolean {                          // iterative: no stack, no recursion limit
    let n = this.root;
    while (n) { const c = this.cmp(v, n.value); if (!c) return true; n = c < 0 ? n.left : n.right; }
    return false;
  }

  min(n = this.root): BSTNode<T> | null { while (n?.left) n = n.left; return n; }

  delete(v: T): void { this.root = this.#del(this.root, v); }
  #del(n: BSTNode<T> | null, v: T): BSTNode<T> | null {
    if (!n) return null;
    const c = this.cmp(v, n.value);
    if (c < 0) n.left = this.#del(n.left, v);
    else if (c > 0) n.right = this.#del(n.right, v);
    else {
      if (!n.left) return n.right;              // case 1: leaf   case 2: one child
      if (!n.right) return n.left;
      const succ = this.min(n.right)!;          // case 3: two children -> in-order successor
      n.value = succ.value;
      n.right = this.#del(n.right, succ.value);
    }
    n.size = 1 + (n.left?.size ?? 0) + (n.right?.size ?? 0);
    return n;
  }

The three delete cases are the part interviewers actually check. Case 3 has a symmetric alternative (the in-order predecessor, i.e. the max of the left subtree); always alternating between the two keeps a hand-rolled BST from drifting toward left-heaviness.

  // --- traversals ---
  *inorder(n = this.root): Generator<T> {
    if (!n) return;
    yield* this.inorder(n.left); yield n.value; yield* this.inorder(n.right);
  }

  inorderIterative(): T[] {                     // explicit stack: no recursion limit
    const res: T[] = [], st: BSTNode<T>[] = [];
    let cur = this.root;
    while (cur || st.length) {
      while (cur) { st.push(cur); cur = cur.left; }
      const n = st.pop()!;
      res.push(n.value);
      cur = n.right;
    }
    return res;
  }

  morris(): T[] {                               // O(1) space: temporarily rewires threads
    const res: T[] = [];
    let cur = this.root;
    while (cur) {
      if (!cur.left) { res.push(cur.value); cur = cur.right; }
      else {
        let pred = cur.left;
        while (pred.right && pred.right !== cur) pred = pred.right;
        if (!pred.right) { pred.right = cur; cur = cur.left; }      // create the thread, go left
        else { pred.right = null; res.push(cur.value); cur = cur.right; }  // remove it, visit, go right
      }
    }
    return res;
  }

  kthSmallest(k: number): T | undefined {       // 1-indexed, O(h) using the size field
    let n = this.root;
    while (n) {
      const l = n.left?.size ?? 0;
      if (k === l + 1) return n.value;
      if (k <= l) n = n.left;
      else { k -= l + 1; n = n.right; }
    }
    return undefined;
  }

  isValid(): boolean {                          // in-order must be strictly increasing
    let prev: T | undefined;
    for (const v of this.inorder()) {
      if (prev !== undefined && this.cmp(prev, v) >= 0) return false;
      prev = v;
    }
    return true;
  }
}

Pitfalls

  • Validating a BST by comparing each node to its children only. [10, 5, 15, null, null, 6, 20] passes that check and is not a BST. Validate with an in-order scan or a (min, max) range recursion.
  • Forgetting to reassign: this.#ins(n.left, v) without n.left = builds nothing.
  • Recursion depth on sorted input. 1e5 sorted inserts overflow the JS stack around 11k frames.
  • Morris traversal mutates the tree while running; it is not safe to interleave with other operations and it is not thread/async-safe.
  • Duplicates: decide up front — ignore, keep a count per node, or push to the right consistently.

Interview follow-ups

Q: How do you find the in-order successor of a node?

A: If it has a right subtree, the successor is that subtree’s minimum. Otherwise walk up to the first ancestor whose left child is on the path (needs parent pointers, or track the last node where you went left during the search).

Q: How do you validate a BST in O(n) with O(1) extra space?

A: Morris in-order traversal, checking monotonicity as you go.

Q: Convert a sorted array into a balanced BST.

A: Recurse: the middle element is the root, build the left half from the left slice and the right half from the right slice. O(n) time, O(log n) stack. Pass indices, not slices, to avoid O(n log n) copying.

Q: Why does a BST beat a hash map sometimes?

A: Ordered operations: range queries, floor/ceil, in-order iteration, kth smallest, successor/predecessor. A hash map gives you none of those.

Q: What is the expected height of a BST built from random insertions?

A: Theta(log n) — about 4.3 log n. But “random” is the assumption that fails: sorted or nearly-sorted input gives you a linked list.


15. AVL tree, and red-black in overview

Intuition

An AVL tree stores each node’s height and keeps the balance factor (height(left) - height(right)) in {-1, 0, +1}. Any insert or delete that violates it is repaired by one or two rotations, which are O(1) pointer swaps that preserve the in-order sequence.

Right rotation about y (fixes a left-left imbalance):

        y                          x
       / \                        / \
      x   C      ──────>         A   y
     / \                            / \
    A   B                          B   C

  in-order before: A x B y C     after: A x B y C      (unchanged — that is the invariant)

The four cases, named by the path from the unbalanced node to the deepest subtree:

CaseBalance factorFix
Left-Leftbf(n) > 1, bf(n.left) >= 0rotate right about n
Left-Rightbf(n) > 1, bf(n.left) < 0rotate left about n.left, then right about n
Right-Rightbf(n) < -1, bf(n.right) <= 0rotate left about n
Right-Leftbf(n) < -1, bf(n.right) > 0rotate right about n.right, then left about n
OperationAVLRed-black
searchO(log n), height <= 1.44 log nO(log n), height <= 2 log n
insertO(log n), <= 2 rotationsO(log n), <= 2 rotations + O(log n) recolours
deleteO(log n), O(log n) rotationsO(log n), <= 3 rotations
Best forread-heavywrite-heavy

Implementation

class AVLNode<T> {
  left: AVLNode<T> | null = null;
  right: AVLNode<T> | null = null;
  h = 1;
  constructor(public value: T) {}
}

class AVL<T> {
  root: AVLNode<T> | null = null;
  constructor(private cmp: (a: T, b: T) => number = (a, b) => (a < b ? -1 : a > b ? 1 : 0)) {}

  #h(n: AVLNode<T> | null) { return n ? n.h : 0; }
  #upd(n: AVLNode<T>) { n.h = 1 + Math.max(this.#h(n.left), this.#h(n.right)); }
  #bf(n: AVLNode<T>) { return this.#h(n.left) - this.#h(n.right); }

  #rotR(y: AVLNode<T>): AVLNode<T> {
    const x = y.left!; y.left = x.right; x.right = y;
    this.#upd(y); this.#upd(x);            // order matters: child first, then new parent
    return x;
  }
  #rotL(x: AVLNode<T>): AVLNode<T> {
    const y = x.right!; x.right = y.left; y.left = x;
    this.#upd(x); this.#upd(y);
    return y;
  }

  #rebalance(n: AVLNode<T>): AVLNode<T> {
    this.#upd(n);
    const b = this.#bf(n);
    if (b > 1)  { if (this.#bf(n.left!)  < 0) n.left  = this.#rotL(n.left!);  return this.#rotR(n); }
    if (b < -1) { if (this.#bf(n.right!) > 0) n.right = this.#rotR(n.right!); return this.#rotL(n); }
    return n;
  }

  insert(v: T) { this.root = this.#ins(this.root, v); }
  #ins(n: AVLNode<T> | null, v: T): AVLNode<T> {
    if (!n) return new AVLNode(v);
    const c = this.cmp(v, n.value);
    if (c < 0) n.left = this.#ins(n.left, v);
    else if (c > 0) n.right = this.#ins(n.right, v);
    else return n;
    return this.#rebalance(n);            // rebalance on the way back up
  }

  delete(v: T) { this.root = this.#del(this.root, v); }
  #del(n: AVLNode<T> | null, v: T): AVLNode<T> | null {
    if (!n) return null;
    const c = this.cmp(v, n.value);
    if (c < 0) n.left = this.#del(n.left, v);
    else if (c > 0) n.right = this.#del(n.right, v);
    else {
      if (!n.left) return n.right;
      if (!n.right) return n.left;
      let s = n.right; while (s.left) s = s.left;
      n.value = s.value; n.right = this.#del(n.right, s.value);
    }
    return this.#rebalance(n);
  }

  height() { return this.#h(this.root); }
}

Verified: 1,000 sorted inserts produce a tree of height 10 (a plain BST would be height 1,000), and it stays balanced after 500 deletes.

  ok  AVL: 1000 sorted inserts -> height 10 (a plain BST would be 1000), still balanced after 500 deletes

Red-black, in overview

Five invariants, all about colour, that together bound the height at 2 log(n+1):

  1. Every node is red or black.
  2. The root is black.
  3. All leaves (NIL sentinels) are black.
  4. A red node’s children are both black — no two reds in a row.
  5. Every root-to-NIL path contains the same number of black nodes (the “black height”).

Insertion colours the new node red (which can only violate rule 4) and repairs it with three cases — red uncle -> recolour and recurse upward; black uncle in a “triangle” -> rotate the parent; black uncle in a “line” -> rotate the grandparent and recolour. Deletion has more cases and is where most implementations go wrong.

Why almost every standard library picks red-black over AVL: it does fewer structural changes per write (recolouring is cheap, rotations are not), which matters for persistent/on-disk structures and for concurrent access. Java’s TreeMap, C++‘s std::map, and the Linux kernel’s rbtree are all red-black. AVL is more rigidly balanced, so it wins on read-heavy workloads, and it is what most in-memory indexes with a high read:write ratio use. B-trees win once the data lives on a device where you read a whole block at a time.

Pitfalls

  • Updating heights in the wrong order inside a rotation. The old parent’s height must be recomputed before the new parent’s.
  • Rebalancing only at the insertion point instead of on the whole path back to the root.
  • Deletion needing up to O(log n) rotations (unlike insertion’s 2) — a common wrong answer.
  • Storing height as a number but forgetting that a fresh node has height 1, not 0.

Interview follow-ups

Q: How many rotations can one insert cause? One delete?

A: Insert: at most two (one double rotation), because fixing the lowest imbalance restores the subtree’s original height. Delete: up to O(log n), because the height can shrink and propagate.

Q: AVL vs red-black — pick one and defend it.

A: AVL for read-heavy (shorter, so fewer comparisons); red-black for write-heavy (fewer rotations, cheaper recolouring). Both O(log n); the difference is constants.

Q: Why do databases use B-trees instead?

A: Node size is matched to the disk/page size, so one I/O reads many keys. A 4 KB page holding ~200 keys gives a fanout of 200, so 1e9 rows is 4 levels deep instead of 30. It is an I/O-count optimization, not a comparison-count one.

Q: What is a treap and why would you use one?

A: A BST on keys that is simultaneously a heap on random priorities. Expected O(log n), far simpler to implement than AVL or red-black, and it supports split/merge cheaply — which is why it shows up in competitive programming.


16. Trie

Intuition

graph TD
  root(("root"))
  c["c"]
  d["d"]
  ca["a"]
  do_["o *"]
  cat["t *"]
  car["r *"]
  card["d *"]
  care["e *"]
  dog["g *"]

  root -->|c| c
  root -->|d| d
  c -->|a| ca
  d -->|o| do_
  ca -->|t| cat
  ca -->|r| car
  car -->|d| card
  car -->|e| care
  do_ -->|g| dog

Keys inserted: cat, car, card, care, do, dog. A node marked * is the end of a stored word — note that car ends at a node (r *) that is also the parent of card and care, which is exactly why a trie can tell has("car") and startsWith("car") apart.

A tree where the path spells the key, so shared prefixes are stored once. Lookup is O(L) in the key length and completely independent of how many keys are stored — the reason autocomplete uses one.

insert: cat, car, card, care, do, dog

        (root)
        /     \
      c        d
      |        |
      a        o*        * = end of a word
     / \       |
   t*   r*     g*
        / \
      d*   e*
OperationTimeSpace
insert / search / startsWithO(L)
deleteO(L)
count keys with a prefixO(L) with a counter per node
autocomplete top-kO(L + k·L)
wildcard match (. per char)O(26^d) worst, d = number of wildcards
totalO(total characters · alphabet)

Implementation

class TrieNode {
  children = new Map<string, TrieNode>();
  isWord = false;
  count = 0;                             // number of keys passing through this node
}

class Trie {
  root = new TrieNode();

  insert(w: string) {
    let n = this.root;
    for (const ch of w) {
      n.count++;
      n = n.children.get(ch) ?? n.children.set(ch, new TrieNode()).get(ch)!;
    }
    n.count++;
    n.isWord = true;
  }

  #node(p: string): TrieNode | null {
    let n: TrieNode | undefined = this.root;
    for (const ch of p) { n = n.children.get(ch); if (!n) return null; }
    return n;
  }

  has(w: string) { return this.#node(w)?.isWord ?? false; }
  startsWith(p: string) { return this.#node(p) !== null; }
  countPrefix(p: string) { return this.#node(p)?.count ?? 0; }

  delete(w: string): boolean {
    const path: Array<[TrieNode, string]> = [];
    let n = this.root;
    for (const ch of w) {
      const nx = n.children.get(ch);
      if (!nx) return false;
      path.push([n, ch]);
      n = nx;
    }
    if (!n.isWord) return false;
    n.isWord = false;
    for (let i = path.length - 1; i >= 0; i--) {   // prune dead branches bottom-up
      const [parent, ch] = path[i]!, child = parent.children.get(ch)!;
      if (child.isWord || child.children.size) break;
      parent.children.delete(ch);
    }
    return true;
  }

  wildcard(pat: string): boolean {                 // '.' matches any single character
    const dfs = (i: number, n: TrieNode): boolean => {
      if (i === pat.length) return n.isWord;
      const ch = pat[i]!;
      if (ch === '.') { for (const c of n.children.values()) if (dfs(i + 1, c)) return true; return false; }
      const nx = n.children.get(ch);
      return nx ? dfs(i + 1, nx) : false;
    };
    return dfs(0, this.root);
  }

  autocomplete(prefix: string, k = 5): string[] {
    const start = this.#node(prefix);
    if (!start) return [];
    const res: string[] = [], stack: Array<[TrieNode, string]> = [[start, prefix]];
    while (stack.length && res.length < k) {
      const [n, s] = stack.pop()!;
      if (n.isWord) res.push(s);
      const keys = [...n.children.keys()].sort().reverse();   // reverse so pop() yields ascending
      for (const ch of keys) stack.push([n.children.get(ch)!, s + ch]);
    }
    return res;
  }
}

For a fixed lowercase alphabet, children: Array<TrieNode | null> of length 26 indexed by ch.charCodeAt(0) - 97 is faster and uses less memory per node than a Map — but it wastes space on sparse tries and breaks on Unicode. Use the array for a constrained alphabet and the Map otherwise.

Pitfalls

  • Using a plain object for children: __proto__ as a key is prototype pollution. Use a Map or Object.create(null).
  • Iterating a string with for (const ch of w) is correct for code points; w[i] iterates code units, which splits emoji and other non-BMP characters into surrogate halves.
  • Forgetting isWord, so startsWith('ca') and has('ca') become the same thing.
  • Deleting without pruning, which leaks nodes forever.
  • Memory: a trie over a large dictionary is much bigger than the strings themselves. A DAWG/radix tree is the fix.

Interview follow-ups

Q: Trie vs hash map for word lookup?

A: A hash map gives O(L) hashing plus O(1) lookup and less memory; a trie gives you prefix operations a hash cannot: startsWith, autocomplete, longest common prefix, ordered iteration, and “count words with this prefix”.

Q: How would you implement autocomplete with ranking?

A: Store the best-k (frequency, word) list at each node during construction, or store a frequency at each terminal and run a bounded best-first search from the prefix node with a max-heap.

Q: What is a compressed trie?

A: A radix tree (Patricia trie) merges chains of single-child nodes into one edge labelled with a substring. Same asymptotics, far fewer nodes — this is what IP routing tables and etcd use.

Q: How do you do “find all words on a Boggle board”?

A: DFS over the board with the trie walked in lockstep; the moment the current path is not a valid prefix, prune. That pruning is the reason to use a trie at all.


17. Union-Find

Intuition

A forest where each set is a tree and the root is the set’s representative. Two optimizations make it effectively constant time: path compression (point everything you walk past directly at the root) and union by size/rank (hang the smaller tree under the larger).

before find(4) with path compression:      after:

    1                                        1
    |                                      / | \
    2                                     2  3  4
    |
    3
    |
    4
OperationNaive+ union by size+ both
findO(n)O(log n)O(alpha(n)) amortized
unionO(n)O(log n)O(alpha(n)) amortized

alpha is the inverse Ackermann function: under 5 for any n that could exist. Call it “effectively constant, formally inverse-Ackermann” — saying “O(1)” is the answer that loses the point.

Implementation

class DSU {
  private parent: Int32Array;
  private size: Int32Array;
  components: number;

  constructor(n: number) {
    this.parent = Int32Array.from({ length: n }, (_, i) => i);
    this.size = new Int32Array(n).fill(1);
    this.components = n;
  }

  find(x: number): number {                    // path halving: iterative, no recursion
    while (this.parent[x]! !== x) {
      this.parent[x] = this.parent[this.parent[x]!]!;
      x = this.parent[x]!;
    }
    return x;
  }

  union(a: number, b: number): boolean {
    let ra = this.find(a), rb = this.find(b);
    if (ra === rb) return false;               // already together -> this edge closes a cycle
    if (this.size[ra]! < this.size[rb]!) [ra, rb] = [rb, ra];   // union by size
    this.parent[rb] = ra;
    this.size[ra]! += this.size[rb]!;
    this.components--;
    return true;
  }

  connected(a: number, b: number) { return this.find(a) === this.find(b); }
  componentSize(x: number) { return this.size[this.find(x)]!; }
}

Int32Array rather than number[] is deliberate: contiguous, no boxing, no holes, and the whole structure is two flat buffers. union returning a boolean is the API detail that makes Kruskal’s algorithm and cycle detection one-liners.

Use it for: Kruskal’s MST, connected components, cycle detection in an undirected graph, “accounts merge”/“redundant connection”-style problems, percolation, and image segmentation.

Pitfalls

  • Recursive find on a 1e6-element DSU built without union-by-size can overflow the stack.
  • Union by size and union by rank are both fine; mixing the two bookkeeping schemes is not.
  • Path compression makes the structure non-persistent — you cannot undo a find. If you need rollback (offline dynamic connectivity), skip path compression and keep a stack of the parent writes.
  • For non-integer keys, keep a Map<K, number> index alongside.

Interview follow-ups

Q: Why does path compression alone not give you O(alpha(n))?

A: Path compression alone gives O(log n) amortized; union by size alone gives O(log n) worst case. You need both for the inverse-Ackermann bound.

Q: How do you detect a cycle in an undirected graph with DSU?

A: Process every edge; if union(u, v) returns false, the endpoints were already connected, so the edge closes a cycle.

Q: How would you support “undo the last union”?

A: Drop path compression, use union by rank only, and push (child, oldParent, oldRank) on a stack. Each op is O(log n) but fully reversible — the basis of offline dynamic connectivity and DSU on tree (small-to-large) techniques.

Q: Can DSU handle deletions?

A: Not directly. You either rebuild, process the queries offline in reverse (turning deletions into insertions), or use a link-cut tree / Euler tour tree for true dynamic connectivity.


18. Graph representations

Adjacency listAdjacency matrixEdge list
SpaceO(V + E)O(V^2)O(E)
Add edgeO(1)O(1)O(1)
Has edge (u,v)?O(deg u) — O(1) with a nested MapO(1)O(E)
Iterate neighbours of uO(deg u)O(V)O(E)
Iterate all edgesO(V + E)O(V^2)O(E)
Best forsparse graphs (most graphs)dense graphs, Floyd-Warshall, matrix opssorting edges (Kruskal), input format

A graph is sparse when E is O(V) and dense when E approaches V^2. Interview graphs are almost always sparse, so adjacency list is the default and you should say so before you write any code.

class Graph<T> {
  private adj = new Map<T, Map<T, number>>();     // nested Map -> O(1) edge lookup AND O(deg) iteration
  constructor(public readonly directed = false) {}

  addVertex(v: T) { if (!this.adj.has(v)) this.adj.set(v, new Map()); return this; }
  addEdge(u: T, v: T, w = 1) {
    this.addVertex(u).addVertex(v);
    this.adj.get(u)!.set(v, w);
    if (!this.directed) this.adj.get(v)!.set(u, w);
    return this;
  }
  neighbors(v: T) { return this.adj.get(v) ?? new Map<T, number>(); }
  get vertices() { return [...this.adj.keys()]; }
  get edgeCount() {
    let e = 0; for (const m of this.adj.values()) e += m.size;
    return this.directed ? e : e / 2;
  }

  bfs(start: T): T[] {                            // array-as-queue with an index cursor: O(1) dequeue
    const seen = new Set([start]), q = [start], order: T[] = [];
    for (let i = 0; i < q.length; i++) {
      const v = q[i]!;
      order.push(v);
      for (const n of this.neighbors(v).keys()) if (!seen.has(n)) { seen.add(n); q.push(n); }
    }
    return order;
  }

  toMatrix(): { index: Map<T, number>; matrix: number[][] } {
    const vs = this.vertices, index = new Map(vs.map((v, i) => [v, i]));
    const m = Array.from({ length: vs.length }, () => new Array(vs.length).fill(Infinity));
    vs.forEach((u, i) => { m[i]![i] = 0; for (const [v, w] of this.neighbors(u)) m[i]![index.get(v)!] = w; });
    return { index, matrix: m };
  }
}

Note the BFS queue: for (let i = 0; i < q.length; i++) with a moving index instead of q.shift(). shift() is O(n), so the naive version is an O(V^2) BFS — the single most common performance bug in JavaScript interview code.

Algorithms on these representations (BFS/DFS, topological sort, Dijkstra, Bellman-Ford, Floyd-Warshall, MST, SCC) are in Graphs and trees.

Interview follow-ups

Q: When would you actually use an adjacency matrix?

A: Dense graphs, Floyd-Warshall (which is inherently V^3 over a matrix), repeated “is there an edge” queries, and anything you want to hand to a BLAS/GPU routine. Also for small V where a Uint8Array of V^2 is more cache-friendly than V maps.

Q: How do you represent a graph with 1e6 vertices efficiently?

A: CSR (compressed sparse row): one Int32Array of offsets of length V+1 and one of targets of length E. Zero per-node object overhead and perfect cache locality; the cost is that it is immutable once built.

Q: How do you store a weighted multigraph?

A: Map<T, Array<[T, number]>> instead of a nested Map, or a nested Map<T, Map<T, number[]>>. A single nested Map collapses parallel edges, which is sometimes what you want and sometimes a bug.


19. LRU and LFU caches

LRU, the one-liner version

Map iterates in insertion order, so “delete and re-set” moves a key to the back and map.keys().next().value is the least recently used.

class LRUMap<K, V> {
  private m = new Map<K, V>();
  constructor(private capacity: number) {}

  get(k: K): V | undefined {
    if (!this.m.has(k)) return undefined;
    const v = this.m.get(k)!;
    this.m.delete(k); this.m.set(k, v);          // refresh recency
    return v;
  }

  set(k: K, v: V) {
    if (this.m.has(k)) this.m.delete(k);
    this.m.set(k, v);
    if (this.m.size > this.capacity) this.m.delete(this.m.keys().next().value as K);
  }
}

This is the right answer for production JavaScript and the wrong answer in an interview that asks for O(1) guaranteedMap.delete + Map.set is O(1) amortized, but the interviewer usually wants the data-structure answer.

LRU, the hashmap + doubly linked list version

   head <-> [MRU] <-> [ ] <-> [ ] <-> [LRU] <-> tail     (sentinels at both ends: no null checks)
              ^
   map: key -> node   (O(1) lookup, and the node knows its neighbours so unlinking is O(1))
class LRUList<K, V> {
  #map = new Map<K, { k: K; v: V; prev: any; next: any }>();
  #head: any = {}; #tail: any = {};
  constructor(private capacity: number) { this.#head.next = this.#tail; this.#tail.prev = this.#head; }

  #remove(n: any) { n.prev.next = n.next; n.next.prev = n.prev; }
  #pushFront(n: any) { n.next = this.#head.next; n.prev = this.#head; this.#head.next.prev = n; this.#head.next = n; }

  get(k: K): V | undefined {
    const n = this.#map.get(k);
    if (!n) return undefined;
    this.#remove(n); this.#pushFront(n);
    return n.v;
  }

  set(k: K, v: V) {
    const ex = this.#map.get(k);
    if (ex) { ex.v = v; this.#remove(ex); this.#pushFront(ex); return; }
    const n = { k, v, prev: null, next: null };
    this.#map.set(k, n); this.#pushFront(n);
    if (this.#map.size > this.capacity) {
      const lru = this.#tail.prev;
      this.#remove(lru); this.#map.delete(lru.k);
    }
  }
}

The two design points to say out loud: sentinel head/tail nodes remove every null check, and the node must store its own key so eviction can delete the map entry.

LFU

Evict the least frequently used, breaking ties by least recently used. The O(1) structure is a map of frequency -> insertion-ordered set of keys, plus a running minimum frequency.

class LFU<K, V> {
  #vals = new Map<K, V>();
  #freq = new Map<K, number>();
  #buckets = new Map<number, Set<K>>();          // frequency -> keys, Set preserves insertion order
  #min = 0;
  constructor(private capacity: number) {}

  #touch(k: K) {
    const f = this.#freq.get(k)!;
    this.#freq.set(k, f + 1);
    this.#buckets.get(f)!.delete(k);
    if (this.#buckets.get(f)!.size === 0) {
      this.#buckets.delete(f);
      if (this.#min === f) this.#min = f + 1;    // the min can only move up by one
    }
    (this.#buckets.get(f + 1) ?? this.#buckets.set(f + 1, new Set()).get(f + 1)!).add(k);
  }

  get(k: K): V | undefined {
    if (!this.#vals.has(k)) return undefined;
    this.#touch(k);
    return this.#vals.get(k);
  }

  set(k: K, v: V) {
    if (this.capacity <= 0) return;
    if (this.#vals.has(k)) { this.#vals.set(k, v); this.#touch(k); return; }
    if (this.#vals.size >= this.capacity) {
      const victim = this.#buckets.get(this.#min)!.values().next().value as K;   // LRU within the min bucket
      this.#buckets.get(this.#min)!.delete(victim);
      this.#vals.delete(victim); this.#freq.delete(victim);
    }
    this.#vals.set(k, v); this.#freq.set(k, 1); this.#min = 1;
    (this.#buckets.get(1) ?? this.#buckets.set(1, new Set()).get(1)!).add(k);
  }
}
LRULFU
Evictsoldest accessrarest access
Handles a scan/floodbadly (a one-pass scan evicts everything useful)well
Handles a shifting working setwellbadly (stale hot keys never age out)
State per entryposition in a lista count
Real systemsmost caches, CPU caches (approximated)CDN edges, with aging or a window (W-TinyLFU)

Real production caches (Caffeine, Redis’s allkeys-lru) do neither exactly: Redis samples a handful of keys and evicts the oldest of the sample, and W-TinyLFU keeps a frequency sketch with decay. Mentioning that is a strong senior signal.

Interview follow-ups

Q: Why a doubly linked list and not a singly linked one?

A: Eviction needs O(1) removal of an arbitrary node (the one the map points at). A singly linked list would need the predecessor, which is O(n) to find.

Q: How do you make the LRU thread-safe / concurrent?

A: In JavaScript, single-threaded, you do not need to. In general: shard by key hash to reduce contention, or use a lock-free approximation (a clock/second-chance algorithm) rather than a strict LRU, because strict LRU ordering is a global mutable list and therefore a contention point.

Q: Add a TTL.

A: Store expiresAt per node and check it on get (lazy expiry), plus either a periodic sweep or a min-heap/timing wheel keyed on expiry for eager eviction. Lazy alone can hold memory forever.

Q: How would you size the cache?

A: Measure the hit rate against size until the curve flattens; that knee is the working-set size. Also bound by memory, not entry count, if values vary in size.


20. Segment tree and Fenwick tree

Both answer range queries with point updates in O(log n). Segment trees are more general (any associative operation, and with lazy propagation, range updates); Fenwick trees are smaller, faster and much shorter to write but only handle invertible operations like sum and xor.

Segment tree over [1,3,5,7,9,11], iterative bottom-up layout (size 2n):

  index:   1 |   2      3   |  4   5    6   7   |  ...
  value:  36 |  9      27   |  4  5    16  11   |    leaves at [n, 2n)
                 /\    /\
              1,3  5   7,9

  Fenwick tree: t[i] covers the range (i - lowbit(i), i], where lowbit(i) = i & -i
Segment treeFenwick / BIT
Space2n (iterative) or 4n (recursive)n + 1
Point updateO(log n)O(log n), ~2x faster constant
Range queryO(log n)O(log n), prefix-based
Range updateO(log n) with lazy propagationO(log n) with a second tree (range-add range-sum)
Operations supportedany associative monoid (sum, min, max, gcd, matrix product)invertible only (sum, xor, count)
Lines of code~40~10

Iterative segment tree (any monoid)

class SegmentTree<T> {
  private t: T[];
  private n: number;
  constructor(arr: readonly T[], private combine: (a: T, b: T) => T, private identity: T) {
    this.n = arr.length;
    this.t = new Array(2 * this.n).fill(identity) as T[];
    for (let i = 0; i < this.n; i++) this.t[this.n + i] = arr[i]!;
    for (let i = this.n - 1; i > 0; i--) this.t[i] = combine(this.t[2 * i]!, this.t[2 * i + 1]!);
  }

  update(i: number, v: T) {
    let p = i + this.n;
    this.t[p] = v;
    for (p >>= 1; p >= 1; p >>= 1) this.t[p] = this.combine(this.t[2 * p]!, this.t[2 * p + 1]!);
  }

  query(l: number, r: number): T {          // half-open [l, r)
    let res = this.identity, lo = l + this.n, hi = r + this.n;
    while (lo < hi) {
      if (lo & 1) res = this.combine(res, this.t[lo++]!);
      if (hi & 1) res = this.combine(res, this.t[--hi]!);
      lo >>= 1; hi >>= 1;
    }
    return res;
  }
}

const sum = new SegmentTree([1, 3, 5, 7, 9, 11], (a, b) => a + b, 0);
const min = new SegmentTree([1, 3, 5, 7, 9, 11], Math.min, Infinity);

Passing (combine, identity) is what makes one class serve sum, min, max, gcd, and matrix product. Note that a non-commutative monoid (matrix product, string concatenation) needs the two accumulators kept separate (resLeft and resRight) so the order is preserved — the version above is correct only for commutative operations.

Lazy propagation (range update + range query)

class LazySegmentTree {                     // range add, range sum
  private t: number[]; private lz: number[]; private n: number;
  constructor(private arr: number[]) {
    this.n = arr.length;
    this.t = new Array(4 * this.n).fill(0);
    this.lz = new Array(4 * this.n).fill(0);
    this.#build(1, 0, this.n - 1);
  }
  #build(node: number, l: number, r: number) {
    if (l === r) { this.t[node] = this.arr[l]!; return; }
    const m = (l + r) >> 1;
    this.#build(2 * node, l, m); this.#build(2 * node + 1, m + 1, r);
    this.t[node] = this.t[2 * node]! + this.t[2 * node + 1]!;
  }
  #push(node: number, l: number, r: number) {      // apply the pending delta, hand it down
    if (!this.lz[node]) return;
    this.t[node]! += this.lz[node]! * (r - l + 1);
    if (l !== r) { this.lz[2 * node]! += this.lz[node]!; this.lz[2 * node + 1]! += this.lz[node]!; }
    this.lz[node] = 0;
  }
  add(ql: number, qr: number, v: number, node = 1, l = 0, r = this.n - 1): void {
    this.#push(node, l, r);
    if (qr < l || r < ql) return;
    if (ql <= l && r <= qr) { this.lz[node]! += v; this.#push(node, l, r); return; }
    const m = (l + r) >> 1;
    this.add(ql, qr, v, 2 * node, l, m); this.add(ql, qr, v, 2 * node + 1, m + 1, r);
    this.t[node] = this.t[2 * node]! + this.t[2 * node + 1]!;
  }
  sum(ql: number, qr: number, node = 1, l = 0, r = this.n - 1): number {
    this.#push(node, l, r);
    if (qr < l || r < ql) return 0;
    if (ql <= l && r <= qr) return this.t[node]!;
    const m = (l + r) >> 1;
    return this.sum(ql, qr, 2 * node, l, m) + this.sum(ql, qr, 2 * node + 1, m + 1, r);
  }
}

The idea in one sentence: instead of updating every leaf in a range, mark the covering node with a pending delta and only push it down when someone actually looks inside.

Fenwick tree

class Fenwick {
  private t: number[];
  constructor(private n: number) { this.t = new Array(n + 1).fill(0); }

  add(i: number, delta: number) { for (let x = i + 1; x <= this.n; x += x & -x) this.t[x]! += delta; }
  prefix(i: number) { let s = 0; for (let x = i + 1; x > 0; x -= x & -x) s += this.t[x]!; return s; }
  range(l: number, r: number) { return this.prefix(r) - (l ? this.prefix(l - 1) : 0); }

  kth(k: number): number {              // smallest index whose prefix sum >= k, O(log n) not O(log^2 n)
    let pos = 0, rem = k;
    for (let pw = 1 << (31 - Math.clz32(this.n)); pw > 0; pw >>= 1)
      if (pos + pw <= this.n && this.t[pos + pw]! < rem) { pos += pw; rem -= this.t[pos]!; }
    return pos;
  }
}

x & -x isolates the lowest set bit; adding it walks up the “parent” chain, subtracting it walks the prefix decomposition. The kth method is the trick that turns a Fenwick tree over a frequency array into an order-statistics structure — “find the kth smallest element”, “count inversions”, “how many elements less than x” all reduce to it.

Verified:

  ok  SegmentTree (sum+min), LazySegmentTree (range add), Fenwick (prefix, range, kth)

Interview follow-ups

Q: When do you reach for a segment tree over a prefix-sum array?

A: When the array changes. Static array plus range-sum queries is just a prefix-sum array: O(n) build, O(1) query. The moment there are updates, prefix sums become O(n) per update and you need a Fenwick or segment tree.

Q: Fenwick or segment tree?

A: Fenwick if the operation is invertible (sum, xor) and you only need prefix/range queries — it is a third of the code and about twice as fast. Segment tree for min/max/gcd, for lazy range updates, and for anything where you need to descend the tree.

Q: How do you count inversions in O(n log n)?

A: Compress the values to ranks, then sweep right to left with a Fenwick tree over ranks, adding prefix(rank - 1) at each step. (Or a merge sort that counts during the merge.)

Q: What is a merge-sort tree / wavelet tree for?

A: Range queries that need more than a monoid — “how many elements in [l, r] are less than x”, “kth smallest in [l, r]”. A segment tree whose nodes store sorted lists gives O(log^2 n).


21. Skip list

Intuition

A sorted linked list with express lanes. Each node is promoted to the next level with probability p (usually 1/2), so the top lane has about n/2^k nodes and a search drops down through the levels like a binary search. No rotations, no rebalancing — the balance is probabilistic.

level 3:  H ─────────────────────────> 9 ─> NIL
level 2:  H ──────────> 5 ───────────> 9 ─> NIL
level 1:  H ────> 3 ──> 5 ──────> 7 ─> 9 ─> NIL
level 0:  H ─> 1 ─> 3 ─> 5 ─> 6 ─> 7 ─> 9 ─> NIL

search(7): start top-left, move right while next < 7, else drop down. 4 hops instead of 6.
OperationExpectedWorst caseSpace
search / insert / deleteO(log n)O(n) with probability ~2^-nO(n) expected, O(n log n) worst

Implementation

class SkipList<T> {
  private static readonly MAX = 16;
  private static readonly P = 0.5;
  private levels = 1;
  private root: { v: T | null; next: any[] } = { v: null, next: new Array(SkipList.MAX).fill(null) };
  private len = 0;

  constructor(
    private cmp: (a: T, b: T) => number = (a, b) => (a < b ? -1 : a > b ? 1 : 0),
    private rng = Math.random,                    // injectable for deterministic tests
  ) {}

  private randomLevel() { let l = 1; while (this.rng() < SkipList.P && l < SkipList.MAX) l++; return l; }

  insert(v: T) {
    const update: any[] = new Array(SkipList.MAX).fill(this.root);
    let x: any = this.root;
    for (let i = this.levels - 1; i >= 0; i--) {          // descend, remembering where we dropped
      while (x.next[i] && this.cmp(x.next[i].v, v) < 0) x = x.next[i];
      update[i] = x;
    }
    const lvl = this.randomLevel();
    if (lvl > this.levels) this.levels = lvl;
    const node = { v, next: new Array(SkipList.MAX).fill(null) };
    for (let i = 0; i < lvl; i++) { node.next[i] = update[i].next[i]; update[i].next[i] = node; }
    this.len++;
  }

  has(v: T) {
    let x: any = this.root;
    for (let i = this.levels - 1; i >= 0; i--)
      while (x.next[i] && this.cmp(x.next[i].v, v) < 0) x = x.next[i];
    return !!x.next[0] && this.cmp(x.next[0].v, v) === 0;
  }

  delete(v: T): boolean {
    const update: any[] = new Array(SkipList.MAX).fill(this.root);
    let x: any = this.root;
    for (let i = this.levels - 1; i >= 0; i--) {
      while (x.next[i] && this.cmp(x.next[i].v, v) < 0) x = x.next[i];
      update[i] = x;
    }
    const target = x.next[0];
    if (!target || this.cmp(target.v, v) !== 0) return false;
    for (let i = 0; i < this.levels; i++) if (update[i].next[i] === target) update[i].next[i] = target.next[i];
    this.len--;
    return true;
  }

  get size() { return this.len; }
}

Why Redis uses skip lists for sorted sets rather than a balanced tree: the code is far simpler (no rebalancing cases), range queries are a plain forward walk at level 0 (a tree needs a successor computation per step), and it is much easier to make lock-free/concurrent because an insert touches only forward pointers. Redis also augments each level with a span count so it can answer “rank of member” in O(log n).

Interview follow-ups

Q: Skip list vs balanced BST?

A: Same expected complexities. Skip list: simpler code, better cache behaviour on range scans, easier concurrency, probabilistic worst case. BST: deterministic worst case, less memory per node.

Q: What determines the expected number of levels?

A: log_{1/p}(n). With p = 1/2 and n = 1e6 that is 20, which is why MAX = 16 to 32 is enough in practice — and why capping MAX is safe.

Q: How do you make it deterministic?

A: A 1-2 skip list (every gap has 1 or 2 nodes at the level below) is isomorphic to a 2-3 tree. At that point you have reimplemented a B-tree and lost the simplicity you came for.


22. Bloom filter and Count-Min Sketch

Intuition

A Bloom filter is a bit array plus k hash functions. add sets k bits; mightContain checks them. If any is 0 the element is definitely absent; if all are 1 it is probably present. No false negatives, tunable false positives, no way to delete, and no way to enumerate.

m = 16 bits, k = 3
add("cat")  -> set bits 2, 7, 11
add("dog")  -> set bits 4, 7, 13

  0 0 1 0 1 0 0 1 0 0 0 1 0 1 0 0
      ^   ^     ^     ^   ^
query("cow") -> bits 2, 4, 13 all set -> FALSE POSITIVE (never inserted)

Sizing, given n expected items and target false-positive rate p:

m = -n·ln(p) / (ln 2)^2          bits
k = (m/n)·ln 2                   hash functions
actual p = (1 - e^(-kn/m))^k

For p = 1% you need about 9.6 bits per element and 7 hashes — independent of the element size. That is the selling point: a set of 1e9 URLs in 1.2 GB instead of ~60 GB.

class BloomFilter {
  private bits: Uint8Array;
  readonly m: number; readonly k: number;

  constructor(n: number, p = 0.01) {
    this.m = Math.ceil(-(n * Math.log(p)) / Math.LN2 ** 2);
    this.k = Math.max(1, Math.round((this.m / n) * Math.LN2));
    this.bits = new Uint8Array(Math.ceil(this.m / 8));
  }

  private *hashes(s: string) {              // Kirsch-Mitzenmacher: two hashes simulate k
    let h1 = 2166136261 >>> 0;              // FNV-1a
    for (let i = 0; i < s.length; i++) { h1 ^= s.charCodeAt(i); h1 = Math.imul(h1, 16777619) >>> 0; }
    let h2 = 5381 >>> 0;                    // djb2
    for (let i = 0; i < s.length; i++) h2 = ((h2 * 33) ^ s.charCodeAt(i)) >>> 0;
    for (let i = 0; i < this.k; i++) yield ((h1 + Math.imul(i, h2) + i * i) >>> 0) % this.m;
  }

  add(s: string) { for (const h of this.hashes(s)) this.bits[h >> 3]! |= 1 << (h & 7); }
  mightContain(s: string) {
    for (const h of this.hashes(s)) if (!(this.bits[h >> 3]! & (1 << (h & 7)))) return false;
    return true;
  }
}

Measured on 1,000 inserted keys and 10,000 absent probes:

  ok  BloomFilter: m=9586 bits k=7 hashes, 0 false negatives, measured FP rate 3.01% (target 1%)

Zero false negatives, as guaranteed. But the measured 3% against a 1% target is worth dwelling on: the Kirsch-Mitzenmacher double-hashing trick assumes independent, well-distributed hashes, and FNV-1a and djb2 over short similar strings are correlated enough to triple the collision rate. In production you would use a 128-bit hash (MurmurHash3 or xxHash) split into two 64-bit halves. This is a good example of “the theoretical bound assumed something your implementation does not provide” — a genuinely useful thing to be able to spot.

The >>> 0 matters. Without it, h1 + Math.imul(i, h2) can go negative (32-bit signed), giving a negative index, bits[negative] is undefined, the write is silently dropped, and you get false negatives — which breaks the one guarantee a Bloom filter makes. That was a real bug in the first version of this code.

Variants

StructureAddsCost
Counting Bloom filterdeletion (counters instead of bits)3–4x the space
Cuckoo filterdeletion + better FP rate at low pmore complex, can fail to insert
Count-Min Sketchfrequency estimates, not just membershipd hash functions x w counters; overestimates only
HyperLogLogcardinality estimation~1.5 KB for 1e9 distinct with 2% error

Count-Min Sketch in one paragraph: a d x w matrix of counters; add(x) increments row[i][h_i(x)] for each of d hashes; estimate(x) is the minimum across those d cells. Because collisions can only add, the minimum is an over-estimate with error bounded by e/w with probability 1 - e^-d. It is the standard answer to “find the top-k heavy hitters in a stream with bounded memory”, usually paired with a min-heap of the current top k.

Real sightings: Chrome’s Safe Browsing list, Bitcoin SPV clients, Cassandra and HBase SSTable lookups (skip the disk read if the filter says absent), Medium’s “already recommended” checks.

Interview follow-ups

Q: Why can’t you delete from a Bloom filter?

A: A bit may be set by several elements, so clearing it could introduce false negatives — and false negatives are the one thing the structure promises never to produce. Use a counting Bloom or cuckoo filter.

Q: What happens as you exceed the designed n?

A: The FP rate rises toward 1 as the bit array saturates. Either size for the maximum or use a scalable Bloom filter (a chain of filters with geometrically tightening error rates).

Q: Where does the 9.6 bits per element number come from?

A: Substitute p = 0.01 into m/n = -ln(p)/(ln 2)^2 = 4.605/0.4805 = 9.585.

Q: How do you union two Bloom filters?

A: Bitwise OR, if they have identical m and k. Intersection by AND is approximate and inflates the FP rate — a nice trap question.


23. Emulating TreeMap and SortedList

JavaScript has no ordered map. The three practical options:

Approachinsertdeletesearchkth / rankrange scanNotes
Sorted array + binary searchO(n) memmoveO(n)O(log n)O(1)O(1) + O(k)fastest for read-heavy, small-to-medium n; splice is a C++-speed memmove so the constant is tiny
AVL / red-black (section 15)O(log n)O(log n)O(log n)O(log n) with sizesO(log n) + O(k)the correct answer when writes are frequent
Skip list (section 21)O(log n) exp.O(log n) exp.O(log n) exp.O(log n) with spansO(1) + O(k)simplest of the O(log n) options
Two heaps / bucketedO(log n)median onlywhen you only need order statistics, not order

The pragmatic answer for interview code: sorted array plus binary search, because splice on a 100k-element array is still only tens of microseconds, and because the code is short enough to get right under pressure.

class SortedList<T> {
  private a: T[] = [];
  constructor(private cmp: (x: T, y: T) => number = (x, y) => (x < y ? -1 : x > y ? 1 : 0)) {}

  bisectLeft(v: T) {                     // first index with a[i] >= v
    let lo = 0, hi = this.a.length;
    while (lo < hi) { const m = (lo + hi) >> 1; if (this.cmp(this.a[m]!, v) < 0) lo = m + 1; else hi = m; }
    return lo;
  }
  bisectRight(v: T) {                    // first index with a[i] > v
    let lo = 0, hi = this.a.length;
    while (lo < hi) { const m = (lo + hi) >> 1; if (this.cmp(v, this.a[m]!) < 0) hi = m; else lo = m + 1; }
    return lo;
  }

  add(v: T) { this.a.splice(this.bisectRight(v), 0, v); }        // O(log n) search + O(n) shift
  remove(v: T) {
    const i = this.bisectLeft(v);
    if (i < this.a.length && !this.cmp(this.a[i]!, v)) { this.a.splice(i, 1); return true; }
    return false;
  }
  count(v: T) { return this.bisectRight(v) - this.bisectLeft(v); }
  at(i: number) { return this.a[i]; }
  get length() { return this.a.length; }
}

bisectLeft vs bisectRight is the distinction to have memorized:

a = [1, 3, 3, 5, 9]
bisectLeft(3)  = 1     insertion point BEFORE equals   -> lower bound
bisectRight(3) = 3     insertion point AFTER equals    -> upper bound
count(3) = right - left = 2
bisectLeft(4)  = 3     -> also "index of the first element >= 4", i.e. ceil
bisectLeft(4) - 1 = 2  -> "index of the last element < 4", i.e. floor

Those four lines answer floor, ceil, rank, count, and “insert keeping sorted” — which covers most of what people actually want a TreeMap for.

If you need genuinely O(log n) writes at scale, the library answer is a B-tree-ish structure (sorted-btree on npm) or the sqrt-decomposition trick Python’s sortedcontainers uses: keep a list of sublists each of size ~sqrt(n), so an insert is a binary search to find the sublist plus an O(sqrt n) splice inside it. That is O(sqrt n) rather than O(log n), but with such a small constant that it beats tree structures for n up to millions.


24. Part 2 test run

Every implementation in Part 2 was executed with npx tsx. The final run:

  ok  Dynamic array: growth doubling, amortized O(1) push, shrink on quarter-full
  ok  Singly linked list: reverse (iter+rec), Floyd cycle, merge sorted, middle, remove nth, palindrome
  ok  Doubly linked list: O(1) insert/remove at both ends and around a known node
  ok  Stack, two-stack queue (amortized O(1)), ring-buffer queue (true O(1))
  ok  Deque: growable ring buffer, O(1) both ends
  ok  Hash table: FNV-1a, separate chaining and open addressing with tombstones, resize at 0.75
  ok  Binary heap: sift up/down, O(n) heapify, k-way merge, indexed decrease-key, MedianFinder
  ok  BST: insert/delete(3 cases)/traversals(rec,iter,Morris)/kth/validate
  ok  AVL: 1000 sorted inserts -> height 10 (a plain BST would be 1000), still balanced after 500 deletes
  ok  Trie: insert/has/startsWith/countPrefix/delete/wildcard/autocomplete
  ok  DSU: union by size + path halving, component counting
  ok  Graph: adjacency map, BFS order, matrix conversion
  ok  LRU (Map trick + linked list) and LFU (frequency buckets) agree on eviction
  ok  SegmentTree (sum+min), LazySegmentTree (range add), Fenwick (prefix, range, kth)
  ok  SkipList: sorted order maintained, has/delete, probabilistic levels
  ok  BloomFilter: m=9586 bits k=7 hashes, 0 false negatives, measured FP rate 3.01% (target 1%)
  ok  SortedList: bisectLeft/Right, add/remove/count/at (O(log n) search, O(n) insert)

ALL ASSERTIONS PASSED

25. The decision table

Read this the way you should think in an interview: from the operation you need to the structure.

I need to…Reach forWhy
test membershipSet / MapO(1) vs O(n) for includes
count occurrencesMap<K, number>one pass, O(1) updates
index by keyMap (dynamic keys) or object (fixed shape)Map for churn, object for shapes V8 can inline
LIFOarray push/popboth O(1)
FIFOring buffer, or array + index cursornever shift() — that is O(n)
both endsdeque (ring buffer)O(1) each end
repeatedly get the min/maxbinary heapO(log n) push/pop, O(1) peek
top k of nheap of size kO(n log k), and O(k) memory
kth smallest, oncequickselectO(n) expected, no structure needed
running mediantwo heapsO(log n) per element
ordered iteration + updatesbalanced tree / skip listO(log n) writes, O(k) scans
ordered, read-heavysorted array + binary searchO(1) indexing, tiny constants
floor / ceil / rank / count-less-thanbisectLeft/bisectRight on a sorted array, or a Fenwick treefour lines, five queries
prefix / autocompletetrie (or radix tree)O(L), independent of the dictionary size
connectivity under mergesunion-findO(alpha(n)) per op
range query, static arrayprefix sumsO(n) build, O(1) query
range query + point updateFenwick (invertible) or segment tree (any monoid)O(log n) both
range query + range updatesegment tree with lazy propagationO(log n)
bounded cacheLRU (Map trick or linked list)O(1) get/set
“have I probably seen this” in tiny spaceBloom filter~10 bits/element
approximate frequencies in a streamCount-Min Sketchfixed memory, over-estimates only
approximate distinct countHyperLogLog~1.5 KB for 1e9
attach data to objects without leakingWeakMapkey is collectable
numeric bulk datatyped arraysno boxing, cache-friendly

Next: the algorithms that operate on these — Sorting and searching, Algorithm patterns, Graphs and trees. The Python mirror of this file is Data structures in Python.

Verify it yourself

ts-ds3/p3.ts

import assert from 'node:assert/strict';
const out: string[] = [];
const ok = (m: string) => out.push('  ok  ' + m);

/* ---------------- 14. BST ---------------- */
class BSTNode<T> { left: BSTNode<T> | null = null; right: BSTNode<T> | null = null; size = 1;
  constructor(public value: T) {} }
class BST<T> {
  root: BSTNode<T> | null = null;
  constructor(private cmp: (a: T, b: T) => number = (a, b) => (a < b ? -1 : a > b ? 1 : 0)) {}
  insert(v: T): void { this.root = this.#ins(this.root, v); }
  #ins(n: BSTNode<T> | null, v: T): BSTNode<T> {
    if (!n) return new BSTNode(v);
    const c = this.cmp(v, n.value);
    if (c < 0) n.left = this.#ins(n.left, v);
    else if (c > 0) n.right = this.#ins(n.right, v);
    else return n;
    n.size = 1 + (n.left?.size ?? 0) + (n.right?.size ?? 0);
    return n;
  }
  has(v: T): boolean { let n = this.root; while (n) { const c = this.cmp(v, n.value); if (!c) return true; n = c < 0 ? n.left : n.right; } return false; }
  min(n = this.root): BSTNode<T> | null { while (n?.left) n = n.left; return n; }
  delete(v: T): void { this.root = this.#del(this.root, v); }
  #del(n: BSTNode<T> | null, v: T): BSTNode<T> | null {
    if (!n) return null;
    const c = this.cmp(v, n.value);
    if (c < 0) n.left = this.#del(n.left, v);
    else if (c > 0) n.right = this.#del(n.right, v);
    else {
      if (!n.left) return n.right;                 // case 1 & 2
      if (!n.right) return n.left;
      const succ = this.min(n.right)!;             // case 3: two children
      n.value = succ.value;
      n.right = this.#del(n.right, succ.value);
    }
    n.size = 1 + (n.left?.size ?? 0) + (n.right?.size ?? 0);
    return n;
  }
  *inorder(n = this.root): Generator<T> { if (!n) return; yield* this.inorder(n.left); yield n.value; yield* this.inorder(n.right); }
  inorderIterative(): T[] {
    const res: T[] = [], st: BSTNode<T>[] = []; let cur = this.root;
    while (cur || st.length) { while (cur) { st.push(cur); cur = cur.left; } const n = st.pop()!; res.push(n.value); cur = n.right; }
    return res;
  }
  morris(): T[] {                                  // O(1) space
    const res: T[] = []; let cur = this.root;
    while (cur) {
      if (!cur.left) { res.push(cur.value); cur = cur.right; }
      else {
        let pred = cur.left; while (pred.right && pred.right !== cur) pred = pred.right;
        if (!pred.right) { pred.right = cur; cur = cur.left; }
        else { pred.right = null; res.push(cur.value); cur = cur.right; }
      }
    }
    return res;
  }
  kthSmallest(k: number): T | undefined {          // 1-indexed, O(h) using subtree sizes
    let n = this.root;
    while (n) { const l = n.left?.size ?? 0; if (k === l + 1) return n.value; if (k <= l) n = n.left; else { k -= l + 1; n = n.right; } }
    return undefined;
  }
  isValid(): boolean {
    let prev: T | undefined;
    for (const v of this.inorder()) { if (prev !== undefined && this.cmp(prev, v) >= 0) return false; prev = v; }
    return true;
  }
}
{
  const t = new BST<number>();
  [50, 30, 70, 20, 40, 60, 80].forEach(v => t.insert(v));
  assert.deepEqual([...t.inorder()], [20, 30, 40, 50, 60, 70, 80]);
  assert.deepEqual(t.inorderIterative(), [20, 30, 40, 50, 60, 70, 80]);
  assert.deepEqual(t.morris(), [20, 30, 40, 50, 60, 70, 80]);
  assert.equal(t.kthSmallest(3), 40); assert.equal(t.kthSmallest(7), 80);
  assert.ok(t.has(60) && !t.has(65));
  t.delete(20); t.delete(70); t.delete(50);
  assert.deepEqual([...t.inorder()], [30, 40, 60, 80]);
  assert.ok(t.isValid());
  ok('BST: insert/delete(3 cases)/traversals(rec,iter,Morris)/kth/validate');
}

/* ---------------- 15. AVL ---------------- */
class AVLNode<T> { left: AVLNode<T> | null = null; right: AVLNode<T> | null = null; h = 1; constructor(public value: T) {} }
class AVL<T> {
  root: AVLNode<T> | null = null;
  constructor(private cmp: (a: T, b: T) => number = (a, b) => (a < b ? -1 : a > b ? 1 : 0)) {}
  #h(n: AVLNode<T> | null) { return n ? n.h : 0; }
  #upd(n: AVLNode<T>) { n.h = 1 + Math.max(this.#h(n.left), this.#h(n.right)); }
  #bf(n: AVLNode<T>) { return this.#h(n.left) - this.#h(n.right); }
  #rotR(y: AVLNode<T>): AVLNode<T> { const x = y.left!; y.left = x.right; x.right = y; this.#upd(y); this.#upd(x); return x; }
  #rotL(x: AVLNode<T>): AVLNode<T> { const y = x.right!; x.right = y.left; y.left = x; this.#upd(x); this.#upd(y); return y; }
  #rebalance(n: AVLNode<T>): AVLNode<T> {
    this.#upd(n); const b = this.#bf(n);
    if (b > 1)  { if (this.#bf(n.left!)  < 0) n.left  = this.#rotL(n.left!);  return this.#rotR(n); }  // LL / LR
    if (b < -1) { if (this.#bf(n.right!) > 0) n.right = this.#rotR(n.right!); return this.#rotL(n); }  // RR / RL
    return n;
  }
  insert(v: T) { this.root = this.#ins(this.root, v); }
  #ins(n: AVLNode<T> | null, v: T): AVLNode<T> {
    if (!n) return new AVLNode(v);
    const c = this.cmp(v, n.value);
    if (c < 0) n.left = this.#ins(n.left, v); else if (c > 0) n.right = this.#ins(n.right, v); else return n;
    return this.#rebalance(n);
  }
  delete(v: T) { this.root = this.#del(this.root, v); }
  #del(n: AVLNode<T> | null, v: T): AVLNode<T> | null {
    if (!n) return null;
    const c = this.cmp(v, n.value);
    if (c < 0) n.left = this.#del(n.left, v);
    else if (c > 0) n.right = this.#del(n.right, v);
    else {
      if (!n.left) return n.right;
      if (!n.right) return n.left;
      let s = n.right; while (s.left) s = s.left;
      n.value = s.value; n.right = this.#del(n.right, s.value);
    }
    return this.#rebalance(n);
  }
  height() { return this.#h(this.root); }
  *inorder(n = this.root): Generator<T> { if (!n) return; yield* this.inorder(n.left); yield n.value; yield* this.inorder(n.right); }
  balanced(n = this.root): boolean { if (!n) return true; return Math.abs(this.#bf(n)) <= 1 && this.balanced(n.left) && this.balanced(n.right); }
}
{
  const t = new AVL<number>();
  for (let i = 1; i <= 1000; i++) t.insert(i);          // worst case for a plain BST
  assert.ok(t.height() <= 12, 'height ' + t.height());   // ~1.44*log2(1000) = 14
  assert.ok(t.balanced());
  for (let i = 1; i <= 500; i++) t.delete(i);
  assert.ok(t.balanced() && [...t.inorder()].length === 500);
  ok(`AVL: 1000 sorted inserts -> height ${t.height()} (a plain BST would be 1000), still balanced after 500 deletes`);
}

/* ---------------- 16. Trie ---------------- */
class TrieNode { children = new Map<string, TrieNode>(); isWord = false; count = 0; }
class Trie {
  root = new TrieNode();
  insert(w: string) { let n = this.root; for (const ch of w) { n.count++; n = n.children.get(ch) ?? n.children.set(ch, new TrieNode()).get(ch)!; } n.count++; n.isWord = true; }
  #node(p: string): TrieNode | null { let n: TrieNode | undefined = this.root; for (const ch of p) { n = n.children.get(ch); if (!n) return null; } return n; }
  has(w: string) { return this.#node(w)?.isWord ?? false; }
  startsWith(p: string) { return this.#node(p) !== null; }
  countPrefix(p: string) { return this.#node(p)?.count ?? 0; }
  delete(w: string): boolean {
    const path: Array<[TrieNode, string]> = []; let n = this.root;
    for (const ch of w) { const nx = n.children.get(ch); if (!nx) return false; path.push([n, ch]); n = nx; }
    if (!n.isWord) return false;
    n.isWord = false;
    for (let i = path.length - 1; i >= 0; i--) {
      const [parent, ch] = path[i]!, child = parent.children.get(ch)!;
      if (child.isWord || child.children.size) break;
      parent.children.delete(ch);
    }
    return true;
  }
  // '.' matches any single char
  wildcard(pat: string): boolean {
    const dfs = (i: number, n: TrieNode): boolean => {
      if (i === pat.length) return n.isWord;
      const ch = pat[i]!;
      if (ch === '.') { for (const c of n.children.values()) if (dfs(i + 1, c)) return true; return false; }
      const nx = n.children.get(ch); return nx ? dfs(i + 1, nx) : false;
    };
    return dfs(0, this.root);
  }
  autocomplete(prefix: string, k = 5): string[] {
    const start = this.#node(prefix); if (!start) return [];
    const res: string[] = [], stack: Array<[TrieNode, string]> = [[start, prefix]];
    while (stack.length && res.length < k) {
      const [n, s] = stack.pop()!;
      if (n.isWord) res.push(s);
      const keys = [...n.children.keys()].sort().reverse();       // reverse so pop() yields ascending
      for (const ch of keys) stack.push([n.children.get(ch)!, s + ch]);
    }
    return res;
  }
}
{
  const t = new Trie();
  ['cat', 'car', 'card', 'care', 'dog', 'do'].forEach(w => t.insert(w));
  assert.ok(t.has('car') && !t.has('ca') && t.startsWith('ca'));
  assert.equal(t.countPrefix('car'), 3);
  assert.deepEqual(t.autocomplete('car', 3), ['car', 'card', 'care']);
  assert.ok(t.wildcard('c.r') && t.wildcard('d.') && !t.wildcard('c..t'));
  assert.ok(t.delete('car') && !t.has('car') && t.has('card'));
  ok('Trie: insert/has/startsWith/countPrefix/delete/wildcard/autocomplete');
}

/* ---------------- 17. Union-Find ---------------- */
class DSU {
  private parent: Int32Array; private size: Int32Array; components: number;
  constructor(n: number) { this.parent = Int32Array.from({ length: n }, (_, i) => i); this.size = new Int32Array(n).fill(1); this.components = n; }
  find(x: number): number { while (this.parent[x]! !== x) { this.parent[x] = this.parent[this.parent[x]!]!; x = this.parent[x]!; } return x; }  // path halving
  union(a: number, b: number): boolean {
    let ra = this.find(a), rb = this.find(b);
    if (ra === rb) return false;
    if (this.size[ra]! < this.size[rb]!) [ra, rb] = [rb, ra];      // union by size
    this.parent[rb] = ra; this.size[ra]! += this.size[rb]!; this.components--;
    return true;
  }
  connected(a: number, b: number) { return this.find(a) === this.find(b); }
  componentSize(x: number) { return this.size[this.find(x)]!; }
}
{
  const d = new DSU(10);
  assert.ok(d.union(0, 1) && d.union(1, 2) && !d.union(0, 2));
  d.union(5, 6); d.union(6, 7); d.union(7, 8);
  assert.ok(d.connected(0, 2) && !d.connected(2, 5));
  assert.equal(d.componentSize(0), 3); assert.equal(d.componentSize(5), 4);
  assert.equal(d.components, 5);   // 5 successful unions out of 10 singletons
  ok('DSU: union by size + path halving, component counting');
}

/* ---------------- 18. Graph ---------------- */
class Graph<T> {
  private adj = new Map<T, Map<T, number>>();
  constructor(public readonly directed = false) {}
  addVertex(v: T) { if (!this.adj.has(v)) this.adj.set(v, new Map()); return this; }
  addEdge(u: T, v: T, w = 1) { this.addVertex(u).addVertex(v); this.adj.get(u)!.set(v, w); if (!this.directed) this.adj.get(v)!.set(u, w); return this; }
  neighbors(v: T) { return this.adj.get(v) ?? new Map<T, number>(); }
  get vertices() { return [...this.adj.keys()]; }
  get edgeCount() { let e = 0; for (const m of this.adj.values()) e += m.size; return this.directed ? e : e / 2; }
  bfs(start: T): T[] { const seen = new Set([start]), q = [start], order: T[] = []; for (let i = 0; i < q.length; i++) { const v = q[i]!; order.push(v); for (const n of this.neighbors(v).keys()) if (!seen.has(n)) { seen.add(n); q.push(n); } } return order; }
  toMatrix(): { index: Map<T, number>; matrix: number[][] } {
    const vs = this.vertices, index = new Map(vs.map((v, i) => [v, i]));
    const m = Array.from({ length: vs.length }, () => new Array(vs.length).fill(Infinity));
    vs.forEach((u, i) => { m[i]![i] = 0; for (const [v, w] of this.neighbors(u)) m[i]![index.get(v)!] = w; });
    return { index, matrix: m };
  }
}
{
  const g = new Graph<string>();
  g.addEdge('a', 'b').addEdge('a', 'c').addEdge('b', 'd').addEdge('c', 'd');
  assert.deepEqual(g.bfs('a'), ['a', 'b', 'c', 'd']);
  assert.equal(g.edgeCount, 4);
  assert.equal(g.toMatrix().matrix[0]!.filter(x => x === 1).length, 2);
  ok('Graph: adjacency map, BFS order, matrix conversion');
}

/* ---------------- 19. LRU / LFU ---------------- */
class LRUMap<K, V> {                                 // exploits Map insertion order
  private m = new Map<K, V>();
  constructor(private capacity: number) {}
  get(k: K): V | undefined { if (!this.m.has(k)) return undefined; const v = this.m.get(k)!; this.m.delete(k); this.m.set(k, v); return v; }
  set(k: K, v: V) { if (this.m.has(k)) this.m.delete(k); this.m.set(k, v); if (this.m.size > this.capacity) this.m.delete(this.m.keys().next().value as K); }
  get size() { return this.m.size; }
  keys() { return [...this.m.keys()]; }
}
class LRUList<K, V> {                                // the classic hashmap + doubly linked list
  #map = new Map<K, { k: K; v: V; prev: any; next: any }>();
  #head: any = { }; #tail: any = { };
  constructor(private capacity: number) { this.#head.next = this.#tail; this.#tail.prev = this.#head; }
  #remove(n: any) { n.prev.next = n.next; n.next.prev = n.prev; }
  #pushFront(n: any) { n.next = this.#head.next; n.prev = this.#head; this.#head.next.prev = n; this.#head.next = n; }
  get(k: K): V | undefined { const n = this.#map.get(k); if (!n) return undefined; this.#remove(n); this.#pushFront(n); return n.v; }
  set(k: K, v: V) {
    const ex = this.#map.get(k);
    if (ex) { ex.v = v; this.#remove(ex); this.#pushFront(ex); return; }
    const n = { k, v, prev: null, next: null }; this.#map.set(k, n); this.#pushFront(n);
    if (this.#map.size > this.capacity) { const lru = this.#tail.prev; this.#remove(lru); this.#map.delete(lru.k); }
  }
  keys() { const r: K[] = []; let n = this.#head.next; while (n !== this.#tail) { r.push(n.k); n = n.next; } return r; }
}
class LFU<K, V> {
  #vals = new Map<K, V>(); #freq = new Map<K, number>(); #buckets = new Map<number, Set<K>>(); #min = 0;
  constructor(private capacity: number) {}
  #touch(k: K) {
    const f = this.#freq.get(k)!; this.#freq.set(k, f + 1);
    this.#buckets.get(f)!.delete(k);
    if (this.#buckets.get(f)!.size === 0) { this.#buckets.delete(f); if (this.#min === f) this.#min = f + 1; }
    (this.#buckets.get(f + 1) ?? this.#buckets.set(f + 1, new Set()).get(f + 1)!).add(k);
  }
  get(k: K): V | undefined { if (!this.#vals.has(k)) return undefined; this.#touch(k); return this.#vals.get(k); }
  set(k: K, v: V) {
    if (this.capacity <= 0) return;
    if (this.#vals.has(k)) { this.#vals.set(k, v); this.#touch(k); return; }
    if (this.#vals.size >= this.capacity) {
      const victim = this.#buckets.get(this.#min)!.values().next().value as K;
      this.#buckets.get(this.#min)!.delete(victim); this.#vals.delete(victim); this.#freq.delete(victim);
    }
    this.#vals.set(k, v); this.#freq.set(k, 1); this.#min = 1;
    (this.#buckets.get(1) ?? this.#buckets.set(1, new Set()).get(1)!).add(k);
  }
}
{
  const a = new LRUMap<string, number>(2);
  a.set('x', 1); a.set('y', 2); a.get('x'); a.set('z', 3);
  assert.deepEqual(a.keys(), ['x', 'z']);                      // y was least recently used
  const b = new LRUList<string, number>(2);
  b.set('x', 1); b.set('y', 2); b.get('x'); b.set('z', 3);
  assert.deepEqual(b.keys(), ['z', 'x']);                      // MRU first
  const c = new LFU<string, number>(2);
  c.set('x', 1); c.set('y', 2); c.get('x'); c.get('x'); c.set('z', 3);
  assert.equal(c.get('y'), undefined); assert.equal(c.get('x'), 1); assert.equal(c.get('z'), 3);
  ok('LRU (Map trick + linked list) and LFU (frequency buckets) agree on eviction');
}

/* ---------------- 20. Segment tree / Fenwick ---------------- */
class SegmentTree<T> {
  private t: T[];
  constructor(arr: readonly T[], private combine: (a: T, b: T) => T, private identity: T) {
    this.t = new Array(2 * arr.length).fill(identity) as T[];
    const n = arr.length;
    for (let i = 0; i < n; i++) this.t[n + i] = arr[i]!;
    for (let i = n - 1; i > 0; i--) this.t[i] = combine(this.t[2 * i]!, this.t[2 * i + 1]!);
    this.n = n;
  }
  private n: number;
  update(i: number, v: T) { let p = i + this.n; this.t[p] = v; for (p >>= 1; p >= 1; p >>= 1) this.t[p] = this.combine(this.t[2 * p]!, this.t[2 * p + 1]!); }
  query(l: number, r: number): T {           // [l, r)
    let res = this.identity, lo = l + this.n, hi = r + this.n;
    while (lo < hi) {
      if (lo & 1) res = this.combine(res, this.t[lo++]!);
      if (hi & 1) res = this.combine(res, this.t[--hi]!);
      lo >>= 1; hi >>= 1;
    }
    return res;
  }
}
class LazySegmentTree {                       // range add, range sum
  private t: number[]; private lz: number[]; private n: number;
  constructor(private arr: number[]) { this.n = arr.length; this.t = new Array(4 * this.n).fill(0); this.lz = new Array(4 * this.n).fill(0); this.#build(1, 0, this.n - 1); }
  #build(node: number, l: number, r: number) {
    if (l === r) { this.t[node] = this.arr[l]!; return; }
    const m = (l + r) >> 1;
    this.#build(2 * node, l, m); this.#build(2 * node + 1, m + 1, r);
    this.t[node] = this.t[2 * node]! + this.t[2 * node + 1]!;
  }
  #push(node: number, l: number, r: number) {
    if (!this.lz[node]) return;
    this.t[node]! += this.lz[node]! * (r - l + 1);
    if (l !== r) { this.lz[2 * node]! += this.lz[node]!; this.lz[2 * node + 1]! += this.lz[node]!; }
    this.lz[node] = 0;
  }
  add(ql: number, qr: number, v: number, node = 1, l = 0, r = this.n - 1): void {
    this.#push(node, l, r);
    if (qr < l || r < ql) return;
    if (ql <= l && r <= qr) { this.lz[node]! += v; this.#push(node, l, r); return; }
    const m = (l + r) >> 1;
    this.add(ql, qr, v, 2 * node, l, m); this.add(ql, qr, v, 2 * node + 1, m + 1, r);
    this.t[node] = this.t[2 * node]! + this.t[2 * node + 1]!;
  }
  sum(ql: number, qr: number, node = 1, l = 0, r = this.n - 1): number {
    this.#push(node, l, r);
    if (qr < l || r < ql) return 0;
    if (ql <= l && r <= qr) return this.t[node]!;
    const m = (l + r) >> 1;
    return this.sum(ql, qr, 2 * node, l, m) + this.sum(ql, qr, 2 * node + 1, m + 1, r);
  }
}
class Fenwick {
  private t: number[];
  constructor(private n: number) { this.t = new Array(n + 1).fill(0); }
  add(i: number, delta: number) { for (let x = i + 1; x <= this.n; x += x & -x) this.t[x]! += delta; }
  prefix(i: number) { let s = 0; for (let x = i + 1; x > 0; x -= x & -x) s += this.t[x]!; return s; }
  range(l: number, r: number) { return this.prefix(r) - (l ? this.prefix(l - 1) : 0); }
  kth(k: number): number {                  // smallest index with prefix >= k, O(log n)
    let pos = 0, rem = k;
    for (let pw = 1 << (31 - Math.clz32(this.n)); pw > 0; pw >>= 1)
      if (pos + pw <= this.n && this.t[pos + pw]! < rem) { pos += pw; rem -= this.t[pos]!; }
    return pos;
  }
}
{
  const arr = [1, 3, 5, 7, 9, 11];
  const sum = new SegmentTree(arr, (a, b) => a + b, 0);
  assert.equal(sum.query(1, 4), 3 + 5 + 7);
  sum.update(2, 50); assert.equal(sum.query(1, 4), 3 + 50 + 7);
  const min = new SegmentTree(arr, Math.min, Infinity);
  assert.equal(min.query(2, 6), 5);
  const lz = new LazySegmentTree([...arr]);
  lz.add(1, 3, 10); assert.equal(lz.sum(0, 5), 36 + 30); assert.equal(lz.sum(1, 3), 15 + 30);
  const f = new Fenwick(6); arr.forEach((v, i) => f.add(i, v));
  assert.equal(f.range(1, 3), 15); assert.equal(f.prefix(5), 36);
  const f2 = new Fenwick(10); [0,1,2,3,4].forEach(i => f2.add(i, 1));
  assert.equal(f2.kth(3), 2);
  ok('SegmentTree (sum+min), LazySegmentTree (range add), Fenwick (prefix, range, kth)');
}

/* ---------------- 21. Skip list ---------------- */
class SkipList<T> {
  private static readonly MAX = 16; private static readonly P = 0.5;
  private head: Array<{ v: T | null; next: any[] }> = [];
  private levels = 1;
  private root: { v: T | null; next: any[] } = { v: null, next: new Array(SkipList.MAX).fill(null) };
  private len = 0;
  constructor(private cmp: (a: T, b: T) => number = (a, b) => (a < b ? -1 : a > b ? 1 : 0), private rng = Math.random) {}
  private randomLevel() { let l = 1; while (this.rng() < SkipList.P && l < SkipList.MAX) l++; return l; }
  insert(v: T) {
    const update: any[] = new Array(SkipList.MAX).fill(this.root);
    let x: any = this.root;
    for (let i = this.levels - 1; i >= 0; i--) {
      while (x.next[i] && this.cmp(x.next[i].v, v) < 0) x = x.next[i];
      update[i] = x;
    }
    const lvl = this.randomLevel();
    if (lvl > this.levels) this.levels = lvl;
    const node = { v, next: new Array(SkipList.MAX).fill(null) };
    for (let i = 0; i < lvl; i++) { node.next[i] = update[i].next[i]; update[i].next[i] = node; }
    this.len++;
  }
  has(v: T) {
    let x: any = this.root;
    for (let i = this.levels - 1; i >= 0; i--) while (x.next[i] && this.cmp(x.next[i].v, v) < 0) x = x.next[i];
    return !!x.next[0] && this.cmp(x.next[0].v, v) === 0;
  }
  delete(v: T): boolean {
    const update: any[] = new Array(SkipList.MAX).fill(this.root);
    let x: any = this.root;
    for (let i = this.levels - 1; i >= 0; i--) { while (x.next[i] && this.cmp(x.next[i].v, v) < 0) x = x.next[i]; update[i] = x; }
    const target = x.next[0];
    if (!target || this.cmp(target.v, v) !== 0) return false;
    for (let i = 0; i < this.levels; i++) if (update[i].next[i] === target) update[i].next[i] = target.next[i];
    this.len--; return true;
  }
  toArray(): T[] { const r: T[] = []; let x = this.root.next[0]; while (x) { r.push(x.v); x = x.next[0]; } return r; }
  get size() { return this.len; }
}
{
  const s = new SkipList<number>();
  [5, 1, 9, 3, 7, 3].forEach(v => s.insert(v));
  assert.deepEqual(s.toArray(), [1, 3, 3, 5, 7, 9]);
  assert.ok(s.has(7) && !s.has(8));
  assert.ok(s.delete(3) && s.toArray().filter(x => x === 3).length === 1);
  ok('SkipList: sorted order maintained, has/delete, probabilistic levels');
}

/* ---------------- 22. Bloom filter ---------------- */
class BloomFilter {
  private bits: Uint8Array; readonly m: number; readonly k: number;
  constructor(n: number, p = 0.01) {
    this.m = Math.ceil(-(n * Math.log(p)) / Math.LN2 ** 2);
    this.k = Math.max(1, Math.round((this.m / n) * Math.LN2));
    this.bits = new Uint8Array(Math.ceil(this.m / 8));
  }
  private *hashes(s: string) {                 // Kirsch-Mitzenmacher: two hashes simulate k
    let h1 = 2166136261 >>> 0;
    for (let i = 0; i < s.length; i++) { h1 ^= s.charCodeAt(i); h1 = Math.imul(h1, 16777619) >>> 0; }
    let h2 = 5381 >>> 0;
    for (let i = 0; i < s.length; i++) h2 = ((h2 * 33) ^ s.charCodeAt(i)) >>> 0;
    for (let i = 0; i < this.k; i++) yield ((h1 + Math.imul(i, h2) + i * i) >>> 0) % this.m;  // >>>0 keeps it unsigned
  }
  add(s: string) { for (const h of this.hashes(s)) this.bits[h >> 3]! |= 1 << (h & 7); }
  mightContain(s: string) { for (const h of this.hashes(s)) if (!(this.bits[h >> 3]! & (1 << (h & 7)))) return false; return true; }
}
{
  const bf = new BloomFilter(1000, 0.01);
  const present = Array.from({ length: 1000 }, (_, i) => 'k' + i);
  present.forEach(k => bf.add(k));
  assert.ok(present.every(k => bf.mightContain(k)), 'no false negatives');
  let fp = 0; for (let i = 0; i < 10000; i++) if (bf.mightContain('absent' + i)) fp++;
  assert.ok(fp / 10000 < 0.05, 'fp rate ' + fp / 10000);
  ok(`BloomFilter: m=${bf.m} bits k=${bf.k} hashes, 0 false negatives, measured FP rate ${(fp / 100).toFixed(2)}% (target 1%)`);
}

/* ---------------- 23. SortedList ---------------- */
class SortedList<T> {
  private a: T[] = [];
  constructor(private cmp: (x: T, y: T) => number = (x, y) => (x < y ? -1 : x > y ? 1 : 0)) {}
  bisectLeft(v: T) { let lo = 0, hi = this.a.length; while (lo < hi) { const m = (lo + hi) >> 1; if (this.cmp(this.a[m]!, v) < 0) lo = m + 1; else hi = m; } return lo; }
  bisectRight(v: T) { let lo = 0, hi = this.a.length; while (lo < hi) { const m = (lo + hi) >> 1; if (this.cmp(v, this.a[m]!) < 0) hi = m; else lo = m + 1; } return lo; }
  add(v: T) { this.a.splice(this.bisectRight(v), 0, v); }            // O(n) memmove
  remove(v: T) { const i = this.bisectLeft(v); if (i < this.a.length && !this.cmp(this.a[i]!, v)) { this.a.splice(i, 1); return true; } return false; }
  count(v: T) { return this.bisectRight(v) - this.bisectLeft(v); }
  at(i: number) { return this.a[i]; }
  get length() { return this.a.length; }
  toArray() { return [...this.a]; }
}
{
  const s = new SortedList<number>();
  [5, 1, 3, 3, 9].forEach(v => s.add(v));
  assert.deepEqual(s.toArray(), [1, 3, 3, 5, 9]);
  assert.equal(s.count(3), 2); assert.equal(s.at(2), 3);
  assert.equal(s.bisectLeft(4), 3); assert.equal(s.bisectRight(3), 3);
  assert.ok(s.remove(3) && s.count(3) === 1);
  ok('SortedList: bisectLeft/Right, add/remove/count/at (O(log n) search, O(n) insert)');
}

console.log(out.join('\n'));
console.log(`\nALL PART 3 ASSERTIONS PASSED (${out.length} structure groups)`);