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
- 1. Array
- 2. Map, Set, WeakMap, WeakSet
- 3. Plain objects as maps
- 4. Strings
- 5. What JavaScript does not ship
- 6. Part 1 test run
Part 2 — from scratch
- 7. Dynamic array
- 8. Singly linked list
- 9. Doubly linked list
- 10. Stack and queue
- 11. Deque
- 12. Hash table
- 13. Binary heap
- 14. Binary search tree
- 15. AVL tree, and red-black in overview
- 16. Trie
- 17. Union-Find
- 18. Graph representations
- 19. LRU and LFU caches
- 20. Segment tree and Fenwick tree
- 21. Skip list
- 22. Bloom filter and Count-Min Sketch
- 23. Emulating TreeMap and SortedList
- 24. Part 2 test run
- 25. The decision table
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] = xandarr.length = biggerall demote the array permanently. - Keep types monomorphic. An array of all small integers is
PACKED_SMIwith unboxed storage; pushing oneNaN,undefinedor 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.
| Method | Mutates? | Returns | Time | Notes |
|---|---|---|---|---|
push(...v) | yes | new length | O(1) amortised | reallocates the backing store on growth |
pop() | yes | element | O(1) | may shrink the store |
shift() | yes | element | O(n) | every index is renumbered; see 1.3 |
unshift(...v) | yes | new length | O(n) | same, in reverse |
splice(i, d, ...ins) | yes | removed items | O(n) | O(n - i) memmove plus O(d) allocation |
sort(cmp?) | yes | same array | O(n log n) | TimSort; O(n) on already-sorted input |
reverse() | yes | same array | O(n) | in place |
fill(v, s?, e?) | yes | same array | O(n) | packs a holey array |
copyWithin(t, s?, e?) | yes | same array | O(n) | memmove semantics, overlap-safe |
length = k | yes | k | O(1) shrink / O(1) grow | growing creates holes |
at(i) | no | element | O(1) | negative indices |
concat(...arrs) | no | new array | O(n + m) | flattens one level of array arguments |
slice(s?, e?) | no | new array | O(k) | the idiomatic shallow copy |
indexOf / lastIndexOf | no | index | O(n) | Strict Equality: misses NaN |
includes | no | boolean | O(n) | SameValueZero: finds NaN |
find / findIndex | no | element / index | O(n) | short-circuits |
findLast / findLastIndex | no | element / index | O(n) | ES2023 |
filter / map | no | new array | O(n) | skip holes, preserve them in the result |
flat(depth?) | no | new array | O(total) | Infinity for full flattening |
flatMap(fn) | no | new array | O(total) | map + flat(1), one pass |
reduce / reduceRight | no | accumulator | O(n) | throws on empty with no seed |
some / every | no | boolean | O(n) | short-circuit |
join(sep?) | no | string | O(n) | null/undefined become '' |
forEach | no | undefined | O(n) | cannot break; skips holes |
keys / values / entries | no | iterator | O(1) to create | entries is how you get an index in for..of |
toSorted / toReversed | no | new array | O(n log n) / O(n) | ES2023, work on frozen arrays |
toSpliced / with | no | new array | O(n) | ES2023, with(i, v) = functional index set |
Array.isArray | – | boolean | O(1) | cross-realm safe, unlike instanceof Array |
Array.from(it, fn?) | – | new array | O(n) | the correct way to build n distinct values |
Array.of(...v) | – | new array | O(n) | Array.of(3) is [3], Array(3) is 3 holes |
Array.fromAsync | – | Promise<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.
undefinedand 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/falsecoerce to1/0, so “less than” and “equal” are indistinguishable and the result is arbitrary. ReturningNaN(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:
| Expression | Elements kind | When to use |
|---|---|---|
[] then push in a loop | PACKED, grows by reallocation | default; fine for unknown length |
new Array(n).fill(0) | PACKED_SMI | known length, primitive fill |
Array.from({length: n}, fn) | PACKED | known length, distinct objects or computed values |
new Array(n) alone | HOLEY from birth | essentially never |
new Int32Array(n) | typed, zero-filled | numeric, 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.
| Constructor | Element | Bytes | Notes |
|---|---|---|---|
Int8Array / Uint8Array | integer | 1 | Uint8Array is the byte-array workhorse |
Uint8ClampedArray | integer | 1 | clamps instead of wrapping (canvas pixels) |
Int16Array / Uint16Array | integer | 2 | |
Int32Array / Uint32Array | integer | 4 | best for index/parent arrays (Union-Find) |
Float32Array / Float64Array | float | 4 / 8 | Float64Array matches JS number exactly |
BigInt64Array / BigUint64Array | bigint | 8 | elements are bigint, not number |
Float16Array | float | 2 | ES2025; 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.lengthis writable, andarr.length = non a shorter array creates holes rather thanundefineds.Array.from({length: n})gives you realundefineds.arr.includes(NaN)istrue;arr.indexOf(NaN)is-1. If you useindexOffor a “seen” check on computed numbers, you will missNaN.splice(0, 1)andshift()are both O(n). Do not use either inside a loop over the array.delete arr[i]leaveslengthuntouched and permanently demotes the elements kind. Usesplice(i, 1)if you want removal, or a tombstone value if you want O(1).concatflattens array arguments one level:[1].concat([2, [3]])is[1, 2, [3]], not[1, [2, [3]]].pushdoes not flatten.reduceon an empty array with no initial value throwsTypeError. Always pass the seed.- Reference equality in
indexOf/includesmeans[{a:1}].includes({a:1})isfalse.
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
| Operation | Map / Set | Plain object | Notes |
|---|---|---|---|
get / has | O(1) average | O(1) average | worst case O(n) under adversarial hashing |
set / add | O(1) amortised | O(1) amortised | rehash on growth |
delete | O(1) | O(1) | delete on an object can force dictionary mode |
size | O(1) | Object.keys(o).length is O(n) | one of the main reasons to use Map |
| iteration | O(n) in insertion order | O(n) in integer-then-insertion order | see section 3 |
clear | O(1) | – | allocates a fresh table |
| memory | ~2-3 machine words per entry plus table slack | lower 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
MapandSet. - Overwriting an existing key (
seton a key that exists) does not move it. deletethensetdoes move it to the end. This is the entire trick behind theMap-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.
| Method | Result | Cost |
|---|---|---|
a.union(b) | new Set with everything | O(|a| + |b|) |
a.intersection(b) | new Set with common elements | O(min(|a|, |b|)) |
a.difference(b) | in a, not in b | O(min(|a|, |b|)) |
a.symmetricDifference(b) | in exactly one | O(|a| + |b|) |
a.isSubsetOf(b) | boolean | O(|a|) |
a.isSupersetOf(b) | boolean | O(|b|) |
a.isDisjointFrom(b) | boolean | O(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.
| Property | Map | WeakMap |
|---|---|---|
| key types | anything | objects and non-registered symbols only |
| enumerable | yes | no — no keys, values, entries, forEach, no Symbol.iterator |
size | yes | no |
clear | yes | no (removed from the spec) |
| GC interaction | entries are strong roots | entry 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:
- Private state keyed by instance — the pre-
#privateidiom, still useful for adding state to objects you do not own (DOM nodes, third-party instances). - Memoisation keyed by an object argument — the cache entry dies with the argument, so no leak.
- 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
Mapkey and expecting structural lookup. It is identity. map.sizeversusObject.keys(obj).length— the second is O(n) and allocates.- Forgetting that a
Mapused 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. Usenew 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
Mapdoes not release the entry array ondelete; 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 viaObject.fromEntriesor an array of pairs.Setdeduplicates by SameValueZero, sonew Set([{}, {}])has size 2 andnew 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:
| Approach | What it fixes | Cost |
|---|---|---|
Object.create(null) | no inherited keys at all, __proto__ is an ordinary key | loses toString, breaks some libraries, console.log shows [Object: null prototype] |
new Map() | no prototype, any key type, O(1) size | not JSON-serialisable directly |
Object.hasOwn(o, k) guard | correctness of membership tests | must remember it everywhere |
Object.groupBy | already returns a null-prototype object | ES2024 |
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:
- Array-index-like keys — canonical numeric strings for integers in
[0, 2^32 - 2]— in ascending numeric order. - All other string keys in property-creation order.
- 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 for0,'',false,null. UseObject.hasOwn(obj, key).if (obj[userInput])on a plain object can return an inherited function.- Numeric-looking keys reorder.
{ '10': a, '9': b }iterates as9, 10. delete obj.kin a hot loop can push V8 out of hidden-class mode into dictionary mode, making all subsequent property access slow. Set toundefined, or use aMap.Object.entriesallocates an array of two-element arrays. In a hot loop, iterateObject.keys— or better, use aMap.- 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.
structuredClonedeep-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 + s2is O(1) plus a later O(n) flatten, not an immediate O(n) copy.str.slice(i, j)is O(1) — aSlicedStringview — 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)ors.split('').join('')if you need to break the reference; in practice, aString(...)round trip through a template does not reliably flatten, so measure.- Indexed access on a
ConsStringtriggers flattening, so a singles[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 want | Use | Cost |
|---|---|---|
| storage size, regex indices, DB varchar limits | str.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 mode | O(n), heavier |
| bytes on the wire | new TextEncoder().encode(str).length | O(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.Collatorand 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: truegives natural/human sort;usage: 'search'loosens matching.- Locale matters for correctness, not just aesthetics: Swedish sorts
äafterz, German sorts it witha. There is no locale-independent “correct” order. - For case-insensitive comparison of identifiers,
toLowerCase()is wrong for Turkish (I→ı); uselocaleComparewithsensitivity: 'base'or Unicode case folding.
4.5 The method cheatsheet
| Method | Time | Notes |
|---|---|---|
length | O(1) | code units |
charAt / [i] / at(i) | O(1) | at accepts negatives; may flatten a ConsString |
charCodeAt / codePointAt | O(1) | code unit vs code point |
slice / substring | O(1) in V8 | SlicedString; retains the parent |
indexOf / includes / startsWith / endsWith | O(n·m) worst, near-O(n) typical | V8 uses a two-way / Boyer-Moore-Horspool hybrid |
split(sep) | O(n) | split('') splits into code units — the surrogate bug |
replace / replaceAll | O(n) | replaceAll requires a global regex or a string |
padStart / padEnd | O(n) | |
repeat(k) | O(n·k) | |
trim / trimStart / trimEnd | O(n) | |
toUpperCase / toLowerCase | O(n) | locale-independent; use toLocaleUpperCase for Turkish |
normalize(form) | O(n) | NFC / NFD / NFKC / NFKD |
localeCompare | O(n) plus collator construction | hoist an Intl.Collator for sorting |
matchAll | O(n) | returns an iterator; regex must be global |
String.raw | O(n) | tag for un-escaped template text |
concatenation + | O(1) then lazy flatten | V8 ConsString |
Pitfalls
split('').reverse().join('')breaks astral characters. Use[...str].str.lengthis not character count. Truncating a UTF-8-limited field bylengthcan split a pair and produce an invalid string.- Regexes without the
u/vflag treat surrogate pairs as two characters:/./matches half an emoji././umatches the code point;\p{...}property escapes requireu. localeCompareinside asortcomparator on a large array is slow because of repeated collator construction.sliceretaining the parent string is a real leak shape when you keep small tokens from huge documents.'10' < '9'istrue. String comparison is not numeric comparison.str.replace('a', 'b')replaces only the first occurrence;replaceAllor/a/g.+=in a loop is fine in V8 but the accumulatedConsStringflattens 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 structure | What you need it for | In an interview | In production |
|---|---|---|---|
| Binary heap / priority queue | Dijkstra, A*, top-k, k-way merge, schedulers, median | Write 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) deque | BFS, sliding-window maximum, monotonic queues | Head-index array (for (let h = 0; h < q.length; h++)) for one-shot traversals; ring buffer from section 11 if you pop from both ends | ring buffer, or denque |
| Sorted map / sorted set / TreeMap | range 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 not | sorted-btree, js-sdsl, @datastructures-js/red-black-tree |
| Linked list | O(1) splice given a node handle — the DLL half of an LRU, intrusive free lists | 25-line DLL with sentinels (section 9) | rarely the right answer outside caches; a Map usually wins |
| Trie | prefix search, autocomplete, word games, IP routing tables | Map-of-children trie (section 16) | a real search index (or a compressed/radix trie if memory matters) |
| Union-Find | dynamic connectivity, Kruskal, percolation, “accounts merge” | Int32Array parent + size with path compression (section 17) | same code; it is 25 lines |
| Multiset / Counter | frequency counting, anagram grouping, top-k | Map<T, number> with m.set(k, (m.get(k) ?? 0) + 1) | same; Map.groupBy for grouping |
| BitSet | dense boolean sets, sieve, bitmask DP over > 32 items | Uint32Array with a[i >> 5] |= 1 << (i & 31) | same, or BigInt bit ops for arbitrary width (slower but simpler) |
| Immutable / persistent collections | structural sharing, cheap undo, React state | out of scope in interviews | Immutable.js, Mori, or structuredClone + discipline |
| Fixed-layout structs / arenas | cache-friendly numeric work, zero-copy IPC | TypedArray + DataView | same, plus SharedArrayBuffer for workers |
| Interval tree / k-d tree / segment tree | range/geometry queries | segment tree and Fenwick from section 20 | domain libraries (rbush, flatbush) |
| Probabilistic sketches | cardinality, membership, heavy hitters at scale | Bloom filter from section 22 | Redis (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:
| Feature | Spec | Node 22 / V8 12.4 |
|---|---|---|
Set union/intersection/difference/… | ES2025 | available (all seven assertions passed) |
Iterator helpers (Iterator.prototype.map) | ES2025 | available |
Object.groupBy / Map.groupBy | ES2024 | available |
Intl.Segmenter | ES2022 Intl | available |
Float16Array | ES2025 | not available |
Uint8Array.fromBase64 / toBase64 | ES2025 | not available |
Map.prototype.getOrInsert(Computed) | ES2025 | not 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
| Operation | Best | Average | Worst | Space | Why |
|---|---|---|---|---|---|
get / set | O(1) | O(1) | O(1) | – | pointer arithmetic |
push | O(1) | O(1) amortised | O(n) | – | the resize copy |
pop | O(1) | O(1) amortised | O(n) | – | the shrink copy |
insertAt(i) | O(1) at end | O(n) | O(n) | – | shifts n - i elements |
removeAt(i) | O(1) at end | O(n) | O(n) | – | shifts n - i elements |
indexOf | O(1) | O(n) | O(n) | – | linear scan |
| total space | – | – | – | O(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] = undefinedis 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 + 1makes 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, unlikeget. - Copying with
slice/spread insidereallocate— 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
| Operation | Singly | Why |
|---|---|---|
pushFront | O(1) | rewire head |
pushBack | O(1) with a tail pointer, O(n) without | must reach the end |
popFront | O(1) | rewire head |
popBack | O(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 |
| search | O(n) | |
| space | O(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
nextpointer before you overwrite it. Every reverse bug is this bug. - Forgetting to update
tailonpopFront(when the list empties) and onreverse. The size and head stay right, so tests pass until someone callspushBack. - 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 checkingfast.nextfirst:TypeErroron even-length lists.- Recursive reverse on a long list: stack overflow. Node’s default stack is roughly 10⁴ frames.
- Returning
headinstead ofprevfrom an iterative reverse — you return the new tail. - Assuming
middleNodereturns the first middle. For even length this returns the second; if a problem needs the first, stop whenfast.next.nextis 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
| Operation | Time | Note |
|---|---|---|
pushFront / pushBack | O(1) | between a sentinel and its neighbour |
popFront / popBack | O(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 |
| search | O(n) | |
| space | O(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
DNodepublicly without guarding sentinels. A caller who unlinks the tail sentinel corrupts the list silently. Hence the explicit throw. - Forgetting to null out
prev/nexton unlink. A stale handle then still stitches into the live list and — worse — keeps the neighbours alive for GC. - Doing
moveToBackon a node that is already the last one. The code above happens to be safe (it re-links to itself correctly becauseleftis recomputed after the splice-out), but the naive ordering — grableft = tail.prevbefore unlinking — self-links the node and hangs the iterator. Order matters. - Maintaining
sizein two places (unlinkand eachpop) and double-decrementing. Route every removal throughunlink. - 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:
| Approach | Enqueue | Dequeue | Memory | Use when |
|---|---|---|---|---|
| head index into an array | O(1) | O(1) | never reclaims dequeued slots | one-shot traversals (BFS, topological sort) |
| two stacks | O(1) | O(1) amortised | O(n) | you want the classic interview answer |
| ring buffer | O(1) | O(1) worst case | O(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 === tailas the empty test without a count or a wasted slot. - Ring buffer: negative modulo.
(head - 1) % capis negative in JavaScript forhead === 0; use(head - 1 + cap) % cap. - Not clearing the slot on dequeue — the same GC-hygiene bug as the dynamic array.
- Using a
Setas a queue because it iterates in insertion order. It works, butdeleteon 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
| Operation | Time | Note |
|---|---|---|
pushBack / pushFront | O(1) amortised | O(n) on the grow |
popBack / popFront | O(1) | |
at(i) | O(1) | one modulo |
| iteration | O(n) | |
| space | O(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) % 8is-1in JavaScript, not7. 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 resethead = 0. - Computing the back index as
(head + n) % lenwhen popping — that is one past the end. It is(head + n - 1) % len. - Forgetting that
capacityandsizeare 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.imulis mandatory.h * 16777619overflows into a double and loses the low bits, which destroys the hash.Math.imulperforms a true 32-bit integer multiply.>>> 0converts the signed int32 result of the bitwise ops to an unsigned 32-bit value. Without it you can get negative indices.charCodeAtgives 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).
| Operation | Chaining avg | Chaining worst | Linear probing avg | Probing worst |
|---|---|---|---|---|
get / has | O(1 + α) | O(n) | O(1/(1 - α)) | O(n) |
set | O(1 + α) amortised | O(n) | O(1/(1 - α)) amortised | O(n) |
delete | O(1 + α) | O(n) | O(1/(1 - α)) | O(n) |
| space | n + table | table 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 resize — Array(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
| Strategy | Next slot | Clustering | Notes |
|---|---|---|---|
| Linear probing | (i + 1) mod m | primary clustering | best cache locality; fine below α ≈ 0.7 |
| Quadratic probing | (i + k²) mod m | secondary clustering | needs m prime (or a power of 2 with triangular numbers) to guarantee it visits every slot |
| Double hashing | (i + k·h₂(key)) mod m | least | h₂ must be odd/coprime with m; worst locality |
| Robin Hood | linear, but displace richer entries | bounded variance | steal the slot if your probe distance exceeds the incumbent’s; makes lookups uniform |
| Cuckoo hashing | two tables, two hashes, evict-and-rehome | none | O(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 % lengthwith a negative hash.>>> 0first, or use& (length - 1).- Plain
*instead ofMath.imulin 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 andgetreturns whichever comes first. - Using object identity as a hash source. There is no
hashCodein JavaScript; you must project keys to strings or numbers yourself (or use a realMap, 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
| Operation | Time | Space | Why |
|---|---|---|---|
peek | O(1) | – | index 0 |
push | O(log n) | – | one sift-up path |
pop | O(log n) | – | one sift-down path |
pushPop / replace | O(log n) | – | one sift instead of two |
| build by n pushes | O(n log n) | – | |
heapify from an array | O(n) | O(1) extra | the series above |
heapsort | O(n log n) | O(1) extra | heapify then pop n times |
| find arbitrary element | O(n) | – | no ordering among siblings |
decreaseKey(x) | O(log n) with an index map | O(n) | otherwise O(n) to find x |
delete arbitrary | O(log n) with an index map | O(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, noti >> 1(which is the 1-indexed formula). - Heapifying from
n >> 1instead of(n >> 1) - 1. The first index that has a child isfloor(n/2) - 1in 0-indexed form. popon a one-element heap:this.a.pop()empties the array, thenthis.a[0] = lastwould resurrect it. Guard withif (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)
| Operation | Balanced | Degenerate | Space |
|---|---|---|---|
| search / insert / delete | O(log n) | O(n) | O(1) extra, O(h) stack if recursive |
| in-order traversal | O(n) | O(n) | O(h) with a stack, O(1) with Morris |
| min / max / successor | O(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)withoutn.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:
| Case | Balance factor | Fix |
|---|---|---|
| Left-Left | bf(n) > 1, bf(n.left) >= 0 | rotate right about n |
| Left-Right | bf(n) > 1, bf(n.left) < 0 | rotate left about n.left, then right about n |
| Right-Right | bf(n) < -1, bf(n.right) <= 0 | rotate left about n |
| Right-Left | bf(n) < -1, bf(n.right) > 0 | rotate right about n.right, then left about n |
| Operation | AVL | Red-black |
|---|---|---|
| search | O(log n), height <= 1.44 log n | O(log n), height <= 2 log n |
| insert | O(log n), <= 2 rotations | O(log n), <= 2 rotations + O(log n) recolours |
| delete | O(log n), O(log n) rotations | O(log n), <= 3 rotations |
| Best for | read-heavy | write-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):
- Every node is red or black.
- The root is black.
- All leaves (NIL sentinels) are black.
- A red node’s children are both black — no two reds in a row.
- 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*
| Operation | Time | Space |
|---|---|---|
| insert / search / startsWith | O(L) | — |
| delete | O(L) | — |
| count keys with a prefix | O(L) with a counter per node | — |
| autocomplete top-k | O(L + k·L) | — |
wildcard match (. per char) | O(26^d) worst, d = number of wildcards | — |
| total | — | O(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 aMaporObject.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, sostartsWith('ca')andhas('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
| Operation | Naive | + union by size | + both |
|---|---|---|---|
| find | O(n) | O(log n) | O(alpha(n)) amortized |
| union | O(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
findon 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 list | Adjacency matrix | Edge list | |
|---|---|---|---|
| Space | O(V + E) | O(V^2) | O(E) |
| Add edge | O(1) | O(1) | O(1) |
| Has edge (u,v)? | O(deg u) — O(1) with a nested Map | O(1) | O(E) |
| Iterate neighbours of u | O(deg u) | O(V) | O(E) |
| Iterate all edges | O(V + E) | O(V^2) | O(E) |
| Best for | sparse graphs (most graphs) | dense graphs, Floyd-Warshall, matrix ops | sorting 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) guaranteed — Map.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);
}
}
| LRU | LFU | |
|---|---|---|
| Evicts | oldest access | rarest access |
| Handles a scan/flood | badly (a one-pass scan evicts everything useful) | well |
| Handles a shifting working set | well | badly (stale hot keys never age out) |
| State per entry | position in a list | a count |
| Real systems | most 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 tree | Fenwick / BIT | |
|---|---|---|
| Space | 2n (iterative) or 4n (recursive) | n + 1 |
| Point update | O(log n) | O(log n), ~2x faster constant |
| Range query | O(log n) | O(log n), prefix-based |
| Range update | O(log n) with lazy propagation | O(log n) with a second tree (range-add range-sum) |
| Operations supported | any 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.
| Operation | Expected | Worst case | Space |
|---|---|---|---|
| search / insert / delete | O(log n) | O(n) with probability ~2^-n | O(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
| Structure | Adds | Cost |
|---|---|---|
| Counting Bloom filter | deletion (counters instead of bits) | 3–4x the space |
| Cuckoo filter | deletion + better FP rate at low p | more complex, can fail to insert |
| Count-Min Sketch | frequency estimates, not just membership | d hash functions x w counters; overestimates only |
| HyperLogLog | cardinality 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:
| Approach | insert | delete | search | kth / rank | range scan | Notes |
|---|---|---|---|---|---|---|
| Sorted array + binary search | O(n) memmove | O(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 sizes | O(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 spans | O(1) + O(k) | simplest of the O(log n) options |
| Two heaps / bucketed | O(log n) | — | — | median only | — | when 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 for | Why |
|---|---|---|
| test membership | Set / Map | O(1) vs O(n) for includes |
| count occurrences | Map<K, number> | one pass, O(1) updates |
| index by key | Map (dynamic keys) or object (fixed shape) | Map for churn, object for shapes V8 can inline |
| LIFO | array push/pop | both O(1) |
| FIFO | ring buffer, or array + index cursor | never shift() — that is O(n) |
| both ends | deque (ring buffer) | O(1) each end |
| repeatedly get the min/max | binary heap | O(log n) push/pop, O(1) peek |
| top k of n | heap of size k | O(n log k), and O(k) memory |
| kth smallest, once | quickselect | O(n) expected, no structure needed |
| running median | two heaps | O(log n) per element |
| ordered iteration + updates | balanced tree / skip list | O(log n) writes, O(k) scans |
| ordered, read-heavy | sorted array + binary search | O(1) indexing, tiny constants |
| floor / ceil / rank / count-less-than | bisectLeft/bisectRight on a sorted array, or a Fenwick tree | four lines, five queries |
| prefix / autocomplete | trie (or radix tree) | O(L), independent of the dictionary size |
| connectivity under merges | union-find | O(alpha(n)) per op |
| range query, static array | prefix sums | O(n) build, O(1) query |
| range query + point update | Fenwick (invertible) or segment tree (any monoid) | O(log n) both |
| range query + range update | segment tree with lazy propagation | O(log n) |
| bounded cache | LRU (Map trick or linked list) | O(1) get/set |
| “have I probably seen this” in tiny space | Bloom filter | ~10 bits/element |
| approximate frequencies in a stream | Count-Min Sketch | fixed memory, over-estimates only |
| approximate distinct count | HyperLogLog | ~1.5 KB for 1e9 |
| attach data to objects without leaking | WeakMap | key is collectable |
| numeric bulk data | typed arrays | no 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)`);