Complexity & how to read constraints
before you code — the input size already told you the answer
O(3n2 + 100n) O(n2).
| n is up to | you can afford | which usually means |
|---|---|---|
| 10 – 12 | O(n!) | permutations, brute-force TSP |
| 20 – 25 | O(2n) | subsets, bitmask DP, meet-in-middle |
| 500 | O(n3) | Floyd–Warshall, interval DP |
| 5,000 | O(n2) | 2-D DP, all-pairs on small n |
| 105 – 106 | O(n log n) | sort, heap, binary search, divide & conquer |
| 107 | O(n) | single pass, two pointers, sliding window |
| 109+ | O(log n) / O(1) | math formula, binary search on answer |
O(1)
hash lookup, array index, stack push
O(log n)
binary search, heap push/pop, balanced BST
O(n)
one scan, prefix sums, BFS on a list
O(n log n)
sorting, divide & conquer, Dijkstra
O(n2)
nested loops, naive pair checking
O(2n), O(n!)
subsets, permutations, naive recursion
Space counts too. Recursion depth is space. A recursive DFS on a skewed tree is O(n) space, not O(1).
| When the problem says… | Reach for |
|---|---|
| "sorted array" | binary search, two pointers |
| "contiguous subarray / substring" | sliding window, prefix sum |
| "top k" / "k largest" | heap of size k, quickselect |
| "all combinations / permutations" | backtracking |
| "count the ways" / "min cost to…" | dynamic programming |
| "next greater / smaller element" | monotonic stack |
| "max in every window of size k" | monotonic deque |
| "connected components / groups" | union-find, DFS flood fill |
| "prerequisites / build order" | topological sort |
| "shortest path, unweighted" | BFS |
| "shortest path, weighted" | Dijkstra (no negatives), Bellman-Ford (negatives) |
| "prefix / autocomplete / word search" | trie |
| "minimise the maximum" / "maximise the minimum" | binary search on the answer |
| "numbers 1..n, one missing/duplicate" | cyclic sort, XOR, index marking |
| "O(1) extra space, in a linked list" | fast & slow pointers, in-place reversal |
| "overlapping intervals" | sort by start, then sweep |
Arrays & Strings
contiguous memory — the substrate everything else is built on
| Operation | Array | Dynamic array (JS Array) |
|---|---|---|
| read / write at i | O(1) | O(1) |
| push at end | — | O(1) amortised |
| insert / delete at front | O(n) | O(n) |
| search (unsorted) | O(n) | O(n) |
| search (sorted) | O(log n) | O(log n) |
s += c in a loop allocates a fresh string O(n2) total.
Build into an array and join('') once.
// in-place, O(1) space: reverse / rotate / partition
function reverse(a, i = 0, j = a.length - 1) {
while (i < j) { [a[i], a[j]] = [a[j], a[i]]; i++; j--; }
} // don't inline i++/j-- into the swap — eval order will bite you
// rotate right by k, O(n) time O(1) space — reverse 3 times
function rotate(a, k) {
k %= a.length;
reverse(a, 0, a.length - 1); // whole thing
reverse(a, 0, k - 1); // first k back into order
reverse(a, k, a.length - 1); // the rest back into order
}
// Kadane — max subarray sum, the "do I restart?" trick
let best = -Infinity, cur = 0;
for (const x of a) { cur = Math.max(x, cur + x); best = Math.max(best, cur); }
- empty array / single element
- all negatives (Kadane must return the largest single element, not 0)
- integer overflow in other languages:
(lo + hi) / 2lo + (hi - lo) / 2 - mutating an array while iterating it
curanswers one question: what is the best subarray that ENDS here?- At each step:
cur = max(x, cur + x). Ifcur + xis worse thanxalone, the past is dead weight throw it away and restart atx(orange cells). - That happens at i=1 (
-2+1 = -1is worse than1) and i=3 (-2+4 = 2is worse than4). bestnever decreases — it is just a running max ofcur. Answer 6, from the subarray[4, -1, 2, 1].- This is why the loop is O(n): the decision at each index needs only one number of history.
Two Pointers
turn an O(n2) pair-hunt into one O(n) sweep
l++ can only raise the sum, r-- can only lower it. So each step eliminates a whole row of the n×n pair grid.
// A. opposite ends — sorted array, find a pair
let l = 0, r = a.length - 1;
while (l < r) {
const s = a[l] + a[r];
if (s === target) return [l, r];
s < target ? l++ : r--; // too small? grow left. too big? shrink right.
}
// B. same direction — in-place filter (remove all zeroes)
let w = 0; // write pointer
for (let rd = 0; rd < a.length; rd++) // read pointer
if (a[rd] !== 0) a[w++] = a[rd];
while (w < a.length) a[w++] = 0;
// C. fast & slow — de-duplicate a sorted array
let k = 1;
for (let i = 1; i < a.length; i++)
if (a[i] !== a[k - 1]) a[k++] = a[i];
return k;
opposite ends
pair sum, container with most water, palindrome check, trapping rain water
same direction
in-place removal, partition, merge two sorted arrays
fast & slow
cycle detection, middle of list, k-th from end
i and run the two-pointer pair-hunt on [i+1 .. n-1]
with target = -a[i]. O(n2). Skip duplicates at both the fixed index and both pointers.
sum = 17
sum = 13
sum = 14
sum = 16
- Start at the widest possible pair:
lat the smallest,rat the largest. sum > targetthe only way down isr--. Every pair using thisrwith anylto the right of the current one is also too big, so a whole column of the pair grid dies at once.sum < targetsymmetric:l++kills a whole row.- Each step retires one index permanently, so at most
nsteps happen. O(n), not O(n2). - This only works because the array is sorted — sortedness is what makes "move a pointer" a monotone decision.
Sliding Window
contiguous + "longest/shortest/count" this, almost always
[l, r] and an invariant. Expand right always. Shrink left only while the invariant breaks.
Each index enters once and leaves once O(n) even though there are two loops.
// THE template. Change only the two ??? lines.
let l = 0, best = 0;
const state = new Map();
for (let r = 0; r < s.length; r++) {
add(s[r], state); // 1. grow right
while (windowIsInvalid(state)) { // 2. ??? shrink until valid again
remove(s[l], state); l++;
}
best = Math.max(best, r - l + 1); // 3. ??? record (max here, min if shrinking to valid)
}
- Longest valid window: shrink while invalid, record after the while loop.
- Shortest valid window: shrink while valid, record inside the while loop, before shrinking.
- Fixed size k: no while loop — just drop
s[r-k]whenr >= k.
// longest substring without repeating characters
const last = new Map(); let l = 0, best = 0;
for (let r = 0; r < s.length; r++) {
const c = s[r];
if (last.has(c) && last.get(c) >= l) l = last.get(c) + 1; // jump, don't crawl
last.set(c, r);
best = Math.max(best, r - l + 1);
}
len 1
len 2
len 3
len 3
len 3
len 3
len 2
len 1
- The window
[l, r]holds a substring with no repeats. That is the invariant. ralways moves forward, one step per iteration. It never goes back.- When
s[r]already sits inside the window, the invariant breaks movelto just past the old occurrence. Notice it jumps (r=6: l goes 3 5), it does not crawl one step at a time. - The guard
last.get(c) >= lmatters: at r=6 the previousbis at index 4, which is inside the window. A stale index from beforelmust be ignored. - Both pointers only ever increase, so the total work is 2n. Answer: 3 ("abc").
Prefix Sum & Difference Array
pay O(n) once, answer every range query in O(1)
p[i] = sum of everything before i. Then sum(i..j) = p[j+1] − p[i].
A range query becomes a subtraction. The difference array is the same trick run backwards:
cheap range updates, one final scan to materialise.
// prefix sums — O(1) range query after O(n) build
const p = [0];
for (const x of a) p.push(p[p.length - 1] + x);
const rangeSum = (i, j) => p[j + 1] - p[i]; // inclusive j
// difference array — O(1) range update, O(n) finalise
const d = new Array(n + 1).fill(0);
for (const [l, r, v] of updates) { d[l] += v; d[r + 1] -= v; }
for (let i = 1; i < n; i++) d[i] += d[i - 1]; // d is now the array
// prefix + hashmap: count subarrays summing to k (works with negatives!)
const seen = new Map([[0, 1]]);
let sum = 0, count = 0;
for (const x of a) {
sum += x;
count += seen.get(sum - k) ?? 0; // how many prefixes make the rest equal k
seen.set(sum, (seen.get(sum) ?? 0) + 1);
}
- 2-D prefix sum — inclusion/exclusion:
S = P[r2][c2] − P[r1-1][c2] − P[r2][c1-1] + P[r1-1][c1-1] - Prefix XOR —
xor(i..j) = px[j+1] ^ px[i], because XOR is its own inverse - Prefix product — careful with zeroes; usually better to do left-pass × right-pass
- Prefix mod — "subarray divisible by k" group prefixes by
sum % k
psits between the elements ofa:p[i]is the sum of everything strictly before indexi. That is whyphas one extra slot.- The shaded query
a[2..4] = 4 + 1 + 5 = 10. - Read it off in O(1):
p[5] - p[2] = 14 - 4 = 10. The blue cells are the only two you touch. - Everything before index 2 is counted in both prefixes, so it cancels that cancellation is the whole trick.
- Getting the
+1right: the answer for the inclusive rangei..jisp[j+1] - p[i]. Buildpstarting with a0and this is automatic.
Hashing — Map & Set
trade memory for time; the single most common optimisation in interviews
// frequency count — the workhorse
const freq = new Map();
for (const c of s) freq.set(c, (freq.get(c) ?? 0) + 1);
// group anagrams: canonical key
const groups = new Map();
for (const w of words) {
const key = [...w].sort().join(''); // or a 26-length count signature
(groups.get(key) ?? groups.set(key, []).get(key)).push(w);
}
// seen-set: "have I been here before?"
const seen = new Set();
for (const x of a) { if (seen.has(target - x)) return true; seen.add(x); }
- anagram sorted letters, or a 26-slot count
- point on the same line reduced slope fraction
dy/gcd : dx/gcd - sudoku cell
"r"+r,"c"+c,"b"+((r/3)|0)+((c/3)|0) - subarray with equal 0s and 1s map 0 to −1, then prefix sum as key
Map keeps types. Use Map/Set.
Stack & Monotonic Stack
LIFO — "the most recent unresolved thing"
// classic: balanced brackets
const pairs = { ')': '(', ']': '[', '}': '{' }, st = [];
for (const c of s) {
if (c in pairs) { if (st.pop() !== pairs[c]) return false; }
else st.push(c);
}
return st.length === 0;
// monotonic decreasing stack: next greater element to the right
const res = new Array(n).fill(-1), st = []; // st holds INDICES
for (let i = 0; i < n; i++) {
while (st.length && a[st[st.length - 1]] < a[i])
res[st.pop()] = a[i]; // a[i] is the first bigger thing to its right
st.push(i);
}
- next greater to the right scan L->R, pop while
stackTop < current(decreasing stack) - next smaller to the right pop while
stackTop > current(increasing stack) - previous greater/smaller same, but the answer is whatever is left on top before you push
- Each index is pushed once and popped once O(n), not O(n2)
h[i] breaks the order, the popped bar's rectangle is bounded left by the new stack top and
right by i. Sentinel 0 heights at both ends kill all the edge cases.
73
—
74
pop 73
ans[0]=1
75
pop 74
ans[1]=1
71
—
69
—
72
pop 69, 71
ans[4]=1, ans[3]=2
76
pop 72, 75
ans[5]=1, ans[2]=4
- The stack holds days that are still waiting for a warmer day. It is always decreasing from bottom to top.
- A new temperature arrives. Anything on the stack that is colder has just found its answer pop it, and the distance is
i - poppedIndex. - Day 5 (72°) pops two days at once: 69 and 71. Day 6 (76°) pops the rest.
- Anything left on the stack at the end never gets a warmer day — answer 0.
- Each index is pushed once and popped at most once. The inner
whileloop looks quadratic and is not: total work is O(n).
Queue, Deque & Monotonic Deque
FIFO — the shape of "level by level"
// !! JS: array.shift() is O(n). For big inputs use a head index.
let head = 0; const q = [start];
while (head < q.length) { const node = q[head++]; /* ... */ }
// monotonic deque: maximum of every window of size k, O(n)
const dq = [], out = []; // dq holds indices, values DECREASING
for (let i = 0; i < a.length; i++) {
if (dq.length && dq[0] <= i - k) dq.shift(); // left edge slid past
while (dq.length && a[dq[dq.length - 1]] <= a[i]) dq.pop(); // smaller = useless forever
dq.push(i);
if (i >= k - 1) out.push(a[dq[0]]); // front is the max
}
Queue
BFS, level order, task scheduling, rate limiters
Deque
sliding window max/min, undo-redo, palindrome check
Circular buffer
fixed-size ring, producer/consumer, "design hit counter"
Linked List
O(1) insert/delete if you already hold the node — that's the whole trade
prev to reverse.
// 1. reverse — the one you must be able to write blindfolded
let prev = null, cur = head;
while (cur) { const nxt = cur.next; cur.next = prev; prev = cur; cur = nxt; }
return prev;
// 2. dummy head — kills all "what if we delete the first node" bugs
const dummy = { next: head }; let p = dummy;
while (p.next) p.next.val === target ? (p.next = p.next.next) : (p = p.next);
return dummy.next;
// 3. fast & slow — middle, cycle, k-th from end
let slow = head, fast = head;
while (fast && fast.next) {
slow = slow.next; fast = fast.next.next;
if (slow === fast) break; // cycle! (Floyd)
}
// cycle START: reset one pointer to head, advance both by 1, they meet at the entry
- losing
nextbefore you overwrite it - forgetting to null-terminate the new tail infinite loop
fast.next.nextwhenfast.nextis null- not using a dummy head, then writing four special cases
Binary Search
not "find a number" — "find the boundary of a yes/no predicate"
false false false true true true. Your job is to name the predicate and find the first true.
// lower bound — first index where a[i] >= target. Half-open [lo, hi).
let lo = 0, hi = a.length;
while (lo < hi) {
const mid = lo + ((hi - lo) >> 1);
if (a[mid] < target) lo = mid + 1; // mid is too small, discard it
else hi = mid; // mid might be the answer, keep it
}
return lo; // == a.length means "not found, insert at end"
// binary search on the ANSWER — the real interview version
const feasible = (x) => { /* can we do it with capacity/time/size x? */ };
let lo = minPossible, hi = maxPossible;
while (lo < hi) {
const mid = lo + ((hi - lo) >> 1);
feasible(mid) ? (hi = mid) : (lo = mid + 1);
}
return lo; // smallest feasible x
[lo, hi) is worth the discipline
- loop condition is always
lo < hi - the loop always terminates (the range strictly shrinks)
- the answer is always
lo - no
+1 / -1off-by-one roulette at 2am
[lo, hi] is always properly sorted.
Compare a[lo] <= a[mid] to find which half, then check whether the target lies inside that sorted half.
- Forget values. The only structure you need is a predicate that goes
F F F F T T T Texactly once. NeverF T F T. - For Koko Eating Bananas,
feasible(x)= "can she finish at speedx?" Faster is never worse, so once it is true it stays true. That is the monotonicity. midis F mid and everything left of it are useless,lo = mid + 1.midis T mid is a candidate. Keep it:hi = mid, nevermid - 1.- The range strictly shrinks every round, so the loop always ends, and it ends with
loon the firstT. Answer 5.
Sorting
you'll rarely implement it — you'll constantly decide whether to call it
| Algorithm | Avg | Worst | Space | Stable | Why you'd pick it |
|---|---|---|---|---|---|
| Merge sort | n log n | n log n | O(n) | yes | guaranteed bound, linked lists, external sort |
| Quick sort | n log n | n2 | O(log n) | no | fastest in practice, in-place |
| Heap sort | n log n | n log n | O(1) | no | no extra memory, no worst case |
| Insertion sort | n2 | n2 | O(1) | yes | tiny or nearly-sorted arrays |
| Counting / radix | n + k | n + k | O(k) | yes | small integer range — beats the n log n bound |
n! leaves. A binary tree with n! leaves has depth
>= log2(n!) ~ n log n. To go faster you must stop comparing — that's what counting/radix sort do.
// JS default sort is LEXICOGRAPHIC. This bites everyone once.
[10, 9, 1].sort(); // [1, 10, 9] <- wrong
[10, 9, 1].sort((x, y) => x - y); // [1, 9, 10] <- correct
// multi-key: by age asc, then name desc
people.sort((a, b) => a.age - b.age || b.name.localeCompare(a.name));
// cyclic sort — numbers 1..n, O(n) time O(1) space, no hashmap
for (let i = 0; i < n; ) {
const j = a[i] - 1; // where a[i] belongs
if (a[i] !== a[j]) [a[i], a[j]] = [a[j], a[i]];
else i++;
}
// now any index where a[i] !== i+1 is the missing/duplicate
// quickselect — k-th smallest in O(n) average, no full sort
function select(a, lo, hi, k) {
const p = partition(a, lo, hi);
if (p === k) return a[p];
return p < k ? select(a, p + 1, hi, k) : select(a, lo, p - 1, k);
}
Array.sort is stable
per spec since ES2019.
Recursion & Divide and Conquer
trust the recursive call. it works. stop tracing it in your head.
That's it. Never unroll the stack mentally — that's what the base case is for.
// shape of every divide & conquer
function solve(problem) {
if (isBase(problem)) return baseAnswer; // 1
const [left, right] = split(problem); // 2
return combine(solve(left), solve(right)); // 3
}
// merge sort, the canonical example — T(n) = 2T(n/2) + O(n) = O(n log n)
function mergeSort(a) {
if (a.length <= 1) return a;
const mid = a.length >> 1;
return merge(mergeSort(a.slice(0, mid)), mergeSort(a.slice(mid)));
}
T(n) = a*T(n/b) + O(n^d):
d > log_b(a)O(n^d) (the split dominates)d = log_b(a)O(n^d · log n) (merge sort: a=2, b=2, d=1)d < log_b(a)O(n^log_b(a)) (the leaves dominate)
solve(x) gets called with the same x more than once, memoise it.
Naive Fibonacci is O(2n); add a cache and it's O(n). That's the entire bridge to section 19.
- Going down is free: splitting an array in half costs nothing but index arithmetic.
- Going up is where the work happens: merging two sorted halves is a linear scan with two pointers.
- Every level touches all n elements exactly once, and halving gives log n levels.
T(n) = 2T(n/2) + O(n) = O(n log n). - The base case (a single element) is trivially sorted. You never have to reason about it further — that is the point of a base case.
- Notice you never trace both branches in your head at once. You assume the recursive call returns something sorted, and it does.
Backtracking
DFS over a decision tree, with an undo button
const res = [], path = [];
function backtrack(start) {
if (isSolution(path)) { res.push([...path]); return; } // COPY, don't push the ref
for (let i = start; i < choices.length; i++) {
if (!valid(choices[i])) continue; // <- prune here. this line is the whole game.
path.push(choices[i]); // choose
backtrack(i + 1); // explore (i+1 = no reuse, i = reuse allowed)
path.pop(); // un-choose
}
}
| Problem | Recurse with | Duplicate handling |
|---|---|---|
| Subsets | i + 1 | record at every node |
| Combinations (no reuse) | i + 1 | sort, skip a[i] === a[i-1] && i > start |
| Combination sum (reuse) | i | same skip rule |
| Permutations | 0 + a used[] array | skip if used[i-1] is false and equal |
cols,
diag = r - c + n, anti = r + c. Placement check is O(1).
Sudoku — same idea, plus "pick the cell with the fewest candidates first" (MRV heuristic).
- Every node is a partial answer. Every edge is one decision: take this element, or skip it.
pathis the label of the node you are currently standing on. It is one array, mutated in place — not a new array per node.path.push(x)walks down an edge.path.pop()walks back up. That pop is the "backtrack" without it, the node you return to would be wrong.res.push([...path])copies. Pushpathitself and every entry inresends up pointing at the same (eventually empty) array.- Pruning = refusing to walk down an edge. It deletes an entire subtree, which is why one good
continuecan turn 2n into something that finishes.
Trees & Binary Search Trees
recursion with a shape
// the four traversals, in one picture
// preorder : node, left, right -> copy / serialise a tree
// inorder : left, node, right -> sorted output on a BST
// postorder : left, right, node -> delete / compute from children up
// level : BFS with a queue -> shortest depth, right-side view
// iterative inorder (interviewers ask for this)
const st = []; let cur = root;
while (cur || st.length) {
while (cur) { st.push(cur); cur = cur.left; }
cur = st.pop(); visit(cur); cur = cur.right;
}
// level order — the size trick captures each level exactly
const q = root ? [root] : [];
while (q.length) {
const size = q.length, level = [];
for (let i = 0; i < size; i++) {
const n = q.shift();
level.push(n.val);
if (n.left) q.push(n.left);
if (n.right) q.push(n.right);
}
out.push(level);
}
// "return to parent, track globally" — diameter, max path sum, etc.
let best = 0;
function depth(n) {
if (!n) return 0;
const L = Math.max(0, depth(n.left)), R = Math.max(0, depth(n.right));
best = Math.max(best, L + R + n.val); // path THROUGH n — can't go to parent
return n.val + Math.max(L, R); // what the parent can actually use
}
left.val < node.val < right.val locally is wrong.
Pass a (min, max) range down: every node in the left subtree must be below node.val,
not just its immediate child.
Balanced? AVL / red-black keep height O(log n). An unbalanced BST degenerates into a linked list O(n).
- All three traversals walk the identical path through the tree. The only difference is when you emit the node: before the children, between them, or after.
- Preorder emits on the way down, so the parent is always printed before its subtree — that is what makes it a serialisation format.
- Postorder emits on the way up, so children are done before the parent. Use it whenever a node needs answers from below: heights, sums, deletions.
- Inorder on a BST produces sorted output, because everything smaller sits to the left. This is the one-line trick behind "kth smallest in a BST".
- Level order is the odd one out: it needs a queue, not the call stack.
Heap / Priority Queue
"give me the best one, repeatedly"
// array layout: parent = (i-1)>>1, children = 2i+1, 2i+2
class MinHeap {
a = [];
push(v) { this.a.push(v); this.up(this.a.length - 1); }
pop() {
const top = this.a[0], last = this.a.pop();
if (this.a.length) { this.a[0] = last; this.down(0); }
return top;
}
up(i) {
while (i > 0) {
const p = (i - 1) >> 1;
if (this.a[p] <= this.a[i]) break;
[this.a[p], this.a[i]] = [this.a[i], this.a[p]];
i = p;
}
}
down(i) {
const n = this.a.length;
for (;;) {
let m = i, l = 2*i + 1, r = l + 1;
if (l < n && this.a[l] < this.a[m]) m = l;
if (r < n && this.a[r] < this.a[m]) m = r;
if (m === i) break;
[this.a[m], this.a[i]] = [this.a[i], this.a[m]];
i = m;
}
}
}
Top-K largest
keep a min-heap of size k; pop whenever size > k. The root is the k-th largest.
Merge k lists
heap of k heads, pop the smallest, push its successor. O(N log k).
Running median
max-heap of the low half + min-heap of the high half; keep sizes within 1.
Scheduling
heap by finish time: meeting rooms, task cooldown, CPU intervals.
- No pointers. No nodes. The tree is implied by the arithmetic: index
i's children live at2i+1and2i+2. - The heap property is local: every parent beats its children. Siblings are not ordered relative to each other, which is exactly why a heap is cheaper to maintain than a sorted array.
push(1): append at the end (index 6), then compare with the parent and swap while it is smaller. The path is 6 2 0, which is the height, so O(log n).pop(): take the root, move the last element into its place, and sift down. Same height, same cost.- The root is the answer to "give me the best one". Getting the second best costs another log n — a heap is not a sorted list.
Trie (Prefix Tree)
a hashmap of hashmaps that knows about prefixes
class Trie {
root = {};
insert(word) {
let n = this.root;
for (const c of word) n = (n[c] ??= {});
n.$ = true; // '$' marks end-of-word
}
// exact = require the '$' marker; prefix = just reach the node
find(word, exact = true) {
let n = this.root;
for (const c of word) { n = n[c]; if (!n) return false; }
return exact ? !!n.$ : true;
}
}
- autocomplete / typeahead, IP routing tables, T9 keyboards
- Word Search II — build a trie of the dictionary, then DFS the grid once, pruning the instant the prefix dies. Without a trie you re-scan per word.
- Binary trie over 32 bits "maximum XOR pair": walk the trie greedily choosing the opposite bit
- Characters live on the edges, not really in the nodes. The path from the root spells the prefix.
- "car" and "cat" share the
c-apath. That sharing is the entire memory argument for a trie. - The green ring is the end-of-word marker. Without it you cannot tell "is
caa word?" from "does anything start withca?" — and those are different questions. - Searching never touches the other words. Cost is O(len(word)), whether the trie holds 3 words or 300,000.
- For Word Search II, you walk the grid once and carry a trie pointer alongside. The instant the prefix has no child, the whole branch dies.
Graphs — representation, BFS, DFS
grids are graphs. trees are graphs. states are graphs.
// adjacency list — the only representation you need (V + E space)
const g = Array.from({ length: n }, () => []);
for (const [u, v] of edges) { g[u].push(v); g[v].push(u); } // drop the 2nd for directed
// BFS — shortest hops from src
const dist = new Array(n).fill(-1);
const q = [src]; dist[src] = 0;
for (let h = 0; h < q.length; h++) { // head index, not .shift()
for (const v of g[q[h]]) if (dist[v] === -1) {
dist[v] = dist[q[h]] + 1;
q.push(v);
}
}
// DFS — recursive, with a visited set (mark on ENTRY, not on pop)
const seen = new Set();
const dfs = (u) => { seen.add(u); for (const v of g[u]) if (!seen.has(v)) dfs(v); };
// grid as a graph — the 4-direction idiom
const DIRS = [[0,1],[1,0],[0,-1],[-1,0]];
for (const [dr, dc] of DIRS) {
const nr = r + dr, nc = c + dc;
if (nr < 0 || nc < 0 || nr >= R || nc >= C) continue;
/* ... */
}
0-1 BFS — edge weights only 0 or 1? Use a deque: push-front for weight 0, push-back for 1. O(V+E), no heap.
Cycle detection — undirected: a visited neighbour that isn't your parent. Directed: a node currently on the recursion stack (grey/black colouring).
- The queue always holds nodes from at most two adjacent layers. Pop one, push its undiscovered neighbours — they are necessarily one layer deeper.
- Mark visited when you PUSH, not when you pop. Otherwise node 3, reachable from both 1 and 2, enters the queue twice.
- The first time you reach a node is via a shortest path. That is why
dist[v] = dist[u] + 1is safe and never needs revising. - DFS from 0 might charge straight down 0 1 3 4 and record dist[3] = 2 by luck, or 0 2 3 and still be fine here — but on a bigger graph it will happily record a long path and never correct it.
- Weighted edges break the layer argument entirely. That is the moment you need Dijkstra.
Topological Sort, Dijkstra, Union-Find
the three named algorithms that actually show up
// -- TOPOLOGICAL SORT (Kahn) — ordering with prerequisites
const indeg = new Array(n).fill(0);
for (const [u, v] of edges) { g[u].push(v); indeg[v]++; } // u must come before v
const q = [], order = [];
for (let i = 0; i < n; i++) if (indeg[i] === 0) q.push(i);
for (let h = 0; h < q.length; h++) {
order.push(q[h]);
for (const v of g[q[h]]) if (--indeg[v] === 0) q.push(v);
}
if (order.length < n) return []; // <- a cycle exists. this check IS "can I finish courses?"
// -- DIJKSTRA — shortest path, non-negative weights
const dist = new Array(n).fill(Infinity);
dist[src] = 0;
const pq = new MinHeap(); pq.push([0, src]); // compare by [0]
while (pq.size) {
const [d, u] = pq.pop();
if (d > dist[u]) continue; // stale entry — lazy deletion
for (const [v, w] of g[u]) {
if (d + w < dist[v]) { dist[v] = d + w; pq.push([dist[v], v]); }
}
}
// O((V + E) log V). Negative weights? Bellman-Ford, O(V*E).
// All pairs, small n? Floyd-Warshall, three nested loops, k on the OUTSIDE.
// -- UNION-FIND (DSU) — "are these two in the same group?"
const p = [...Array(n).keys()], rank = new Array(n).fill(0);
const find = (x) => p[x] === x ? x : (p[x] = find(p[x])); // path compression
const union = (a, b) => {
a = find(a); b = find(b);
if (a === b) return false; // already joined -> this edge makes a cycle
if (rank[a] < rank[b]) [a, b] = [b, a]; // union by rank
p[b] = a;
if (rank[a] === rank[b]) rank[a]++;
return true;
};
// near-O(1) amortised (inverse Ackermann). Counts components, detects cycles, builds Kruskal's MST.
| Question | Tool |
|---|---|
| fewest edges to target | BFS |
| cheapest path, weights >= 0 | Dijkstra |
| cheapest path, negative edges | Bellman-Ford (also detects negative cycles) |
| cheapest path between all pairs, n <= 400 | Floyd-Warshall |
| valid build order / course schedule | topological sort |
| dynamic connectivity, cycle in undirected | union-find |
| cheapest set of edges connecting everything | Kruskal (sort + DSU) or Prim (heap) |
p[x]is "my parent". A group is identified by whichever node is its own parent — the root.find(3)has to walk up to the root anyway. Path compression just rewrites every link it passed to point straight at the root on the way back out.- That is the whole of
p[x] = find(p[x]): the assignment is the compression. - Union by rank keeps trees shallow in the first place; compression flattens what is left. Together, m operations cost O(m α(n)), and α(n) is below 5 for any n you will ever see.
union(a, b)returningfalsemeans they were already connected this edge closes a cycle. That one line solves Redundant Connection and drives Kruskal's MST.
Edges 0->1, 0->2, 1->3, 2->3, 3->4. Indegrees start at [0, 1, 1, 2, 1].
| action | indegree [0..4] | queue | what happened |
|---|---|---|---|
| start | 0, 1, 1, 2, 1 | [0] | |
| pop 0 | -, 0, 0, 2, 1 | [1, 2] | both 1 and 2 freed |
| pop 1 | -, -, 0, 1, 1 | [2] | 3 still waits on 2 |
| pop 2 | -, -, -, 0, 1 | [3] | 3 freed |
| pop 3 | -, -, -, -, 0 | [4] | 4 freed |
| pop 4 | -, -, -, -, - | [] | order = 0 1 2 3 4 |
- Indegree = "how many prerequisites do I still have?" A node with indegree 0 is ready to run.
- Popping a node does not delete it from the graph — it just decrements every neighbour's counter. A neighbour that hits 0 has all its prerequisites satisfied and joins the queue.
- Node 3 waits for both 1 and 2. It only enters the queue after the second one is popped. The counter is the join condition.
- If the loop ends and you emitted fewer than n nodes, some counters never reached 0 they are waiting on each other there is a cycle. That single check is "Course Schedule".
- Every edge is examined once, every node enqueued once: O(V + E).
Edges A-B 4, A-C 1, C-B 2, B-D 5, C-D 8, source A.
| pop | A | B | C | D | priority queue | note |
|---|---|---|---|---|---|---|
| start | 0 | inf | inf | inf | [(0,A)] | |
| pop A (0) | 0 | 4 | 1 | inf | [(1,C), (4,B)] | relax B=4, C=1 |
| pop C (1) | 0 | 3 | 1 | 9 | [(3,B), (4,B), (9,D)] | B improves 4->3 via C |
| pop B (3) | 0 | 3 | 1 | 8 | [(4,B), (8,D), (9,D)] | D improves 9->8 |
| pop B (4) | 0 | 3 | 1 | 8 | [(8,D), (9,D)] | stale, 4 > dist[B]=3, skip |
| pop D (8) | 0 | 3 | 1 | 8 | [(9,D)] | done |
- The heap is ordered by tentative distance. The smallest tentative distance in the heap is provably final — nothing cheaper can arrive later, because all weights are non-negative.
- That "because all weights are non-negative" is the entire correctness proof, and the entire reason Dijkstra breaks on negative edges.
- When B improves from 4 to 3, the old
(4, B)entry is still sitting in the heap. You cannot cheaply delete it, so you leave it and skip it later:if (d > dist[u]) continue. - Without that one line you would relax B's neighbours twice. It is not an optimisation — it is what makes the lazy-deletion heap correct.
- O((V + E) log V). For unweighted graphs, drop the heap and use a plain queue: that is just BFS.
Greedy & Intervals
take the locally best move and never look back — when that's actually allowed
// intervals: the sort key IS the algorithm
intervals.sort((a, b) => a[0] - b[0]); // by START -> merging overlaps
intervals.sort((a, b) => a[1] - b[1]); // by END -> max non-overlapping / min removals
// merge overlapping intervals
const out = [];
for (const [s, e] of intervals) {
const last = out[out.length - 1];
if (last && s <= last[1]) last[1] = Math.max(last[1], e); // overlap -> extend
else out.push([s, e]);
}
// jump game — how far can I possibly reach?
let reach = 0;
for (let i = 0; i < a.length; i++) {
if (i > reach) return false;
reach = Math.max(reach, i + a[i]);
}
[1, 3, 4] and amount 6: greedy takes 4+1+1 = 3 coins.
Optimal is 3+3 = 2 coins. Greedy is only correct for canonical coin systems.
US coins happen to be canonical, which is exactly why the trap works.
Dynamic Programming
recursion + memory. that is the entire definition.
dp[i] mean, in one English sentence?2. Transition — how is
dp[i] built from smaller states?3. Base case — the smallest state you know for free.
4. Order — iterate so dependencies are already computed.
5. Answer — which cell holds it? (not always the last one!)
If you can't state #1 in English, you are not ready to write code.
// top-down: write the recursion, then add one line of cache
const memo = new Map();
function f(i) {
if (i <= 1) return base;
if (memo.has(i)) return memo.get(i); // <- the entire "DP" part
const ans = combine(f(i - 1), f(i - 2));
memo.set(i, ans);
return ans;
}
// bottom-up + space compression: only the last 2 rows ever mattered
let a = 1, b = 1;
for (let i = 2; i <= n; i++) [a, b] = [b, a + b];
const dp = new Array(W + 1).fill(0); // dp[c] = best value with capacity c
for (const { w, v } of items)
for (let c = W; c >= w; c--) // <- BACKWARD: dp[c-w] is still the PREVIOUS item's row
dp[c] = Math.max(dp[c], dp[c - w] + v);
// unbounded knapsack (reuse allowed): loop FORWARD instead. one character.
| Pattern | State | Representative problem |
|---|---|---|
| Linear / Fibonacci | dp[i] = best ending at i | Climbing Stairs, House Robber |
| 0/1 Knapsack | dp[i][c] | Partition Equal Subset Sum |
| Unbounded knapsack | dp[c], forward loop | Coin Change, Combination Sum IV |
| Grid / paths | dp[r][c] | Unique Paths, Minimum Path Sum |
| Two sequences | dp[i][j] | Edit Distance, LCS |
| Subsequence (LIS) | dp[i] + binary search | Longest Increasing Subsequence |
| Interval DP | dp[l][r], grow by length | Burst Balloons, Matrix Chain |
| State machine | dp[i][holding] | Best Time to Buy/Sell with Cooldown |
// LCS — the two-sequence skeleton. Edit Distance is the same grid, different transition.
for (let i = 1; i <= n; i++)
for (let j = 1; j <= m; j++)
dp[i][j] = s1[i-1] === s2[j-1]
? dp[i-1][j-1] + 1 // match: consume both
: Math.max(dp[i-1][j], dp[i][j-1]); // skip one or the other
// LIS in O(n log n) — tails[k] = smallest tail of an increasing subseq of length k+1
const tails = [];
for (const x of a) {
const i = lowerBound(tails, x);
i === tails.length ? tails.push(x) : (tails[i] = x);
}
return tails.length; // tails is NOT the actual subsequence — only its length is real
| "" | a | b | c | d | e | |
|---|---|---|---|---|---|---|
| "" | 0 | 0 | 0 | 0 | 0 | 0 |
| a | 0 | 1 | 1 | 1 | 1 | 1 |
| c | 0 | 1 | 1 | 2 | 2 | 2 |
| e | 0 | 1 | 1 | 2 | 2 | 3 |
- State:
dp[i][j]= the LCS of the firsticharacters of "ace" and the firstjof "abcde". Say that sentence out loud before writing anything. - Match (green): the characters are equal, so extend the diagonal
dp[i-1][j-1] + 1. Both strings consume one character. - No match: one of them has to give up a character
max(dp[i-1][j], dp[i][j-1]), i.e. look up or look left. - Base: row 0 and column 0 are all zeros — an empty string shares nothing. The padding row is why the code indexes
s[i-1]. - Answer: bottom-right,
3("ace"). Every cell only reads from the row above and the cell to its left, so you can compress the whole table into two rows.
dp[c] = max(dp[c], dp[c - w] + v). The read ofdp[c - w]must see the previous item's row.- Going backwards (c = 6 down to 3): when you write
dp[6]you readdp[3], which you have not touched yet this round. It still holds the old value, 0. Correct: the item is used once. - Going forwards (c = 3 up to 6): you write
dp[3] = 4first, then atc = 6you read that fresh 4 and get8. You just put the same item in the bag twice. - So the loop direction is the semantics: backward = 0/1 knapsack, forward = unbounded knapsack.
- It is the same one-dimensional array either way. One character of difference, two different problems.
Bit Manipulation
a handful of identities. learn them once, recognise them forever.
x ^ x = 0 · x ^ 0 = x · XOR is commutative
XOR everything together and the pairs annihilate. That's "single number" in one line.
x & 1 // odd?
x >> 1 // divide by 2 (floor, for non-negatives)
(x >> i) & 1 // read bit i
x | (1 << i) // set bit i
x & ~(1 << i) // clear bit i
x ^ (1 << i) // flip bit i
x & (x - 1) // * clear the LOWEST set bit -> popcount loop, power-of-two check
x & (-x) // * isolate the lowest set bit -> Fenwick trees
x & (x - 1) === 0 // is x a power of two? (x > 0)
// count set bits — runs once per set bit, not 32 times
let c = 0; while (x) { x &= x - 1; c++; }
// iterate every subset of an n-element set
for (let mask = 0; mask < (1 << n); mask++)
for (let i = 0; i < n; i++) if (mask >> i & 1) use(a[i]);
// iterate every SUBMASK of a mask (bitmask DP)
for (let s = mask; s; s = (s - 1) & mask) { /* s is a submask */ }
1 << 31 is negative; 1 << 32 is 1. Use >>> for unsigned shifts,
or BigInt beyond 32 bits. In interviews, say this out loud — it reads as real-world experience.
- Subtracting 1 borrows through the trailing zeros: the lowest
1becomes0, and every bit below it becomes1. - AND them together and those changed bits all cancel
x & (x-1)isxwith its lowest set bit removed. - Loop that until zero and the number of iterations is the popcount. It runs once per set bit, not 32 times.
x & (x-1) === 0therefore means "x had exactly one set bit" — a power of two.- Two's complement makes
-xequal to~x + 1, which flips everything above the lowest set bit. AND withxand only that bit survives. Fenwick trees are built on this.
Math & Number Theory
small toolkit, occasionally decisive
// gcd / lcm (Euclid)
const gcd = (a, b) => b === 0 ? a : gcd(b, a % b);
const lcm = (a, b) => a / gcd(a, b) * b; // divide first: avoids overflow
// sieve of Eratosthenes — all primes below n in O(n log log n)
const isP = new Array(n + 1).fill(true); isP[0] = isP[1] = false;
for (let i = 2; i * i <= n; i++)
if (isP[i]) for (let j = i * i; j <= n; j += i) isP[j] = false; // start at i2, not 2i
// fast modular exponentiation — a^b mod m in O(log b)
function power(a, b, m) {
let r = 1n; a = BigInt(a) % BigInt(m);
while (b > 0) {
if (b & 1) r = r * a % BigInt(m);
a = a * a % BigInt(m);
b >>= 1;
}
return r;
}
// primality by trial division — only up to sqrtn, and only 6k±1
if (n < 4) return n > 1;
if (n % 2 === 0 || n % 3 === 0) return false;
for (let i = 5; i * i <= n; i += 6)
if (n % i === 0 || n % (i + 2) === 0) return false;
Modular arithmetic
Answers "mod 1e9+7" mean: add, subtract (+m before %), multiply with BigInt or split.
Pigeonhole
n+1 items in n boxes some box has 2. Powers cycle detection, duplicate proofs.
Combinatorics
C(n,k) = n!/(k!(n−k)!) — build Pascal's triangle rather than dividing factorials.
Reservoir sampling
Pick k uniformly from an unknown-length stream: keep item i with probability k/i.
Advanced — when the basics run out
rare in interviews, decisive in contests
const t = new Array(n + 1).fill(0);
const add = (i, v) => { for (i++; i <= n; i += i & -i) t[i] += v; };
const sum = (i) => { let s = 0; for (i++; i > 0; i -= i & -i) s += t[i]; return s; };
Segment tree
Range query + range update, O(log n). Any associative op: sum, min, gcd. Lazy propagation for range updates.
KMP
Substring search in O(n+m) using the failure/LPS array — "longest proper prefix that is also a suffix".
Rabin-Karp
Rolling hash. Great for "find repeated substring of length k" combined with binary search.
Bitmask DP
dp[mask][i] — travelling salesman, assignment problems, n <= 20.
Monotonic stack + DP
Sum of subarray minimums, stock span, largest rectangle in a binary matrix.
Matrix exponentiation
Linear recurrences in O(k3 log n). Fibonacci with n = 1018.
The plan
an order that builds on itself, and what to say out loud in the room
| Week | Topics | Goal |
|---|---|---|
| 1 | Big-O, Arrays, Two Pointers | stop guessing complexity |
| 2 | Sliding Window, Prefix Sum, Hashing | the O(n) toolbox |
| 3 | Stack, Queue, Linked List | pointer fluency |
| 4 | Binary Search, Sorting | one template, no off-by-ones |
| 5 | Recursion, Backtracking, Trees | trust the recursive call |
| 6 | Heap, Trie, Graphs (BFS/DFS) | model the problem as a graph |
| 7 | Topo sort, Dijkstra, Union-Find, Greedy | named algorithms on demand |
| 8 | DP (all 8 patterns), Bits | state -> transition -> base |
- Repeat the problem back. One sentence. Catch the misunderstanding now, not at minute 30.
- Ask about constraints and edge cases. Size of n? Duplicates? Negatives? Empty input? Sorted?
- State the brute force and its complexity. Always. It buys you a floor and it buys you time.
- Name the pattern and the target complexity before typing a character. "This is a monotone predicate, so binary search on the answer, O(n log m)."
- Code, narrating the invariant, not the syntax. "l is the first index not yet excluded."
- Trace one small example by hand, then one edge case. Only then say you're done.
- silence — the interviewer can only score what you say
- jumping to code before naming the approach
- not testing your own code before declaring victory
- not knowing the complexity of the thing you just wrote
- arguing when given a hint. A hint means you're being helped.