HAND-DRAWN NOTES • BOOKLET EDITION

DSA CHEAT SHEET

Created by Rohit Tiwari

22 topics • the pattern behind each • TypeScript templates you can type from memory • 150+ problems to master

📖 Print as Booklet: Double-sided • Flip on short edge • Fold & staple

Tick the boxes as you solve • Your progress saves automatically • Carry this in your pocket

0

Complexity & how to read constraints

before you code — the input size already told you the answer

core idea Big-O is not about speed. It is about how fast the cost grows when the input doubles. Drop constants, keep the fastest-growing term. O(3n2 + 100n) O(n2).
The constraint decoder — assume ~108 simple operations per second.
n is up toyou can affordwhich usually means
10 – 12O(n!)permutations, brute-force TSP
20 – 25O(2n)subsets, bitmask DP, meet-in-middle
500O(n3)Floyd–Warshall, interval DP
5,000O(n2)2-D DP, all-pairs on small n
105 – 106O(n log n)sort, heap, binary search, divide & conquer
107O(n)single pass, two pointers, sliding window
109+O(log n) / O(1)math formula, binary search on answer
so: constraints are a hint, not trivia. Read them first.

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

Amortised vs worst case. A dynamic array push is O(1) amortised: most pushes are free, the occasional resize costs O(n), and spread over n pushes that's O(1) each. Interviewers love this distinction.
Space counts too. Recursion depth is space. A recursive DFS on a skewed tree is O(n) space, not O(1).
the pattern decoder — memorise this table, skip 60% of the thinking
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

1

Arrays & Strings

contiguous memory — the substrate everything else is built on

core idea Index access is O(1) because address = base + i × size. Everything painful about arrays (insert, delete in the middle) comes from having to shift the rest.
OperationArrayDynamic array (JS Array)
read / write at iO(1)O(1)
push at end—O(1) amortised
insert / delete at frontO(n)O(n)
search (unsorted)O(n)O(n)
search (sorted)O(log n)O(log n)
Strings are arrays that lie to you. In JS, Java, Python and Go strings are immutable: every 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); }
traps that fail one hidden test
  • empty array / single element
  • all negatives (Kadane must return the largest single element, not 0)
  • integer overflow in other languages: (lo + hi) / 2 lo + (hi - lo) / 2
  • mutating an array while iterating it
data flow — Kadane on [-2, 1, -3, 4, -1, 2, 1, -5, 4]
a[i]
-2
1
-3
4
-1
2
1
-5
4
cur
-2
1
-2
4
3
5
6
1
5
best
-2
1
1
4
4
5
6
6
6
0
1
2
3
4
5
6
7
8
restarted herecur dropped the pastbest improved
  1. cur answers one question: what is the best subarray that ENDS here?
  2. At each step: cur = max(x, cur + x). If cur + x is worse than x alone, the past is dead weight throw it away and restart at x (orange cells).
  3. That happens at i=1 (-2+1 = -1 is worse than 1) and i=3 (-2+4 = 2 is worse than 4).
  4. best never decreases — it is just a running max of cur. Answer 6, from the subarray [4, -1, 2, 1].
  5. This is why the loop is O(n): the decision at each index needs only one number of history.
Practice

2

Two Pointers

turn an O(n2) pair-hunt into one O(n) sweep

core idea If the array is sorted, moving a pointer tells you something monotone: 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

3Sum in one line of thought: sort, then fix 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.
data flow — pair sum on the sorted array [2, 3, 5, 8, 11, 15], target = 16
step 0
sum = 17
2
3
5
8
11
15
too big -> r--
step 1
sum = 13
2
3
5
8
11
15
too small -> l++
step 2
sum = 14
2
3
5
8
11
15
too small -> l++
step 3
sum = 16
2
3
5
8
11
15
found!
  1. Start at the widest possible pair: l at the smallest, r at the largest.
  2. sum > target the only way down is r--. Every pair using this r with any l to the right of the current one is also too big, so a whole column of the pair grid dies at once.
  3. sum < target symmetric: l++ kills a whole row.
  4. Each step retires one index permanently, so at most n steps happen. O(n), not O(n2).
  5. This only works because the array is sorted — sortedness is what makes "move a pointer" a monotone decision.
Practice

3

Sliding Window

contiguous + "longest/shortest/count" this, almost always

core idea Keep a window [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)
}
Two flavours, don't mix them up
  • 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] when r >= 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);
}
only valid when growing the window can't "fix" a broken invariant Sliding window needs monotonicity. With negative numbers, "subarray sum >= k" is NOT monotone (adding an element can shrink the sum) use prefix sums + a hashmap or a deque instead.
data flow — longest substring without repeats, "abcabcbb"
r = 0
len 1
a
b
c
a
b
c
b
b
new
r = 1
len 2
a
b
c
a
b
c
b
b
new
r = 2
len 3
a
b
c
a
b
c
b
b
new
r = 3
len 3
a
b
c
a
b
c
b
b
'a' seen at 0 -> l = 1
r = 4
len 3
a
b
c
a
b
c
b
b
'b' seen at 1 -> l = 2
r = 5
len 3
a
b
c
a
b
c
b
b
'c' seen at 2 -> l = 3
r = 6
len 2
a
b
c
a
b
c
b
b
'b' seen at 4 -> l = 5
r = 7
len 1
a
b
c
a
b
c
b
b
'b' seen at 6 -> l = 7
  1. The window [l, r] holds a substring with no repeats. That is the invariant.
  2. r always moves forward, one step per iteration. It never goes back.
  3. When s[r] already sits inside the window, the invariant breaks move l to just past the old occurrence. Notice it jumps (r=6: l goes 3 5), it does not crawl one step at a time.
  4. The guard last.get(c) >= l matters: at r=6 the previous b is at index 4, which is inside the window. A stale index from before l must be ignored.
  5. Both pointers only ever increase, so the total work is 2n. Answer: 3 ("abc").
Practice

4

Prefix Sum & Difference Array

pay O(n) once, answer every range query in O(1)

core idea 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);
}
Variants worth knowing
  • 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
data flow — range sum on [3, 1, 4, 1, 5, 9], query sum(2..4)
a
3
1
4
1
5
9
0
1
2
3
4
5
p
0
3
4
8
9
14
23
0
1
2
3
4
5
6
  1. p sits between the elements of a: p[i] is the sum of everything strictly before index i. That is why p has one extra slot.
  2. The shaded query a[2..4] = 4 + 1 + 5 = 10.
  3. Read it off in O(1): p[5] - p[2] = 14 - 4 = 10. The blue cells are the only two you touch.
  4. Everything before index 2 is counted in both prefixes, so it cancels that cancellation is the whole trick.
  5. Getting the +1 right: the answer for the inclusive range i..j is p[j+1] - p[i]. Build p starting with a 0 and this is automatic.
Practice

5

Hashing — Map & Set

trade memory for time; the single most common optimisation in interviews

core idea A hash function turns a key into a bucket index, so lookup is O(1) on average. Collisions are resolved by chaining or open addressing. Worst case is O(n) — and that's the answer to "when is a hashmap bad?"
// 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); }
Choosing the key is the whole problem.
  • 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
JS gotcha: object keys are stringified. Map keeps types. Use Map/Set.
Practice

6

Stack & Monotonic Stack

LIFO — "the most recent unresolved thing"

core idea Use a stack when the answer depends on the most recent unmatched element. A monotonic stack keeps its contents sorted; the moment the order would break, you pop — and that pop is the answer for the popped element.
// 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);
}
Which direction?
  • 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)
Largest Rectangle in Histogram is the boss fight of this topic. An increasing stack of indices; when 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.
data flow — Daily Temperatures, stack contents after each day
73
i=0
73
—
74
i=1
74
pop 73
ans[0]=1
75
i=2
75
pop 74
ans[1]=1
75
71
i=3
71
—
75
71
69
i=4
69
—
75
72
i=5
72
pop 69, 71
ans[4]=1, ans[3]=2
76
i=6
76
pop 72, 75
ans[5]=1, ans[2]=4
  1. The stack holds days that are still waiting for a warmer day. It is always decreasing from bottom to top.
  2. A new temperature arrives. Anything on the stack that is colder has just found its answer pop it, and the distance is i - poppedIndex.
  3. Day 5 (72°) pops two days at once: 69 and 71. Day 6 (76°) pops the rest.
  4. Anything left on the stack at the end never gets a warmer day — answer 0.
  5. Each index is pushed once and popped at most once. The inner while loop looks quadratic and is not: total work is O(n).
Practice

7

Queue, Deque & Monotonic Deque

FIFO — the shape of "level by level"

core idea A queue processes things in arrival order. That is exactly what BFS needs, which is why queue = shortest path in an unweighted graph. A deque adds "push/pop at both ends" in O(1).
// !! 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"

Practice

8

Linked List

O(1) insert/delete if you already hold the node — that's the whole trade

core idea You never have random access. Every technique is about manufacturing the pointer you need: a dummy head so you never special-case the front, a slow/fast pair to find the middle, a 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
the four bugs that eat 20 minutes
  • losing next before you overwrite it
  • forgetting to null-terminate the new tail infinite loop
  • fast.next.next when fast.next is null
  • not using a dummy head, then writing four special cases
Practice

9

Binary Search

not "find a number" — "find the boundary of a yes/no predicate"

core idea Forget sorted arrays for a second. Binary search works on any monotone 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
Why half-open [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 / -1 off-by-one roulette at 2am
pick ONE template, write it 20 times, never think about it again
Rotated sorted array: one half of [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.
data flow — binary search on the answer, not on an array
x
1
2
3
4
5
6
7
8
feasible(x)
F
F
F
F
T
T
T
T
round 0
1
2
3
4
5
6
7
8
F -> answer is to the right, lo = mid+1
round 1
1
2
3
4
5
6
7
8
T -> mid might BE it, hi = mid
round 2
1
2
3
4
5
6
7
8
T -> mid might BE it, hi = mid
converged
1
2
3
4
5
6
7
8
lo == hi == 5
  1. Forget values. The only structure you need is a predicate that goes F F F F T T T T exactly once. Never F T F T.
  2. For Koko Eating Bananas, feasible(x) = "can she finish at speed x?" Faster is never worse, so once it is true it stays true. That is the monotonicity.
  3. mid is F mid and everything left of it are useless, lo = mid + 1.
  4. mid is T mid is a candidate. Keep it: hi = mid, never mid - 1.
  5. The range strictly shrinks every round, so the loop always ends, and it ends with lo on the first T. Answer 5.
Practice

10

Sorting

you'll rarely implement it — you'll constantly decide whether to call it

AlgorithmAvgWorstSpaceStableWhy you'd pick it
Merge sortn log nn log nO(n)yesguaranteed bound, linked lists, external sort
Quick sortn log nn2O(log n)nofastest in practice, in-place
Heap sortn log nn log nO(1)nono extra memory, no worst case
Insertion sortn2n2O(1)yestiny or nearly-sorted arrays
Counting / radixn + kn + kO(k)yessmall integer range — beats the n log n bound
why n log n is a floor Any comparison sort is a decision tree with 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);
}
Stability matters when you sort by one key and need the previous order preserved inside ties (e.g. sort employees by department, keeping alphabetical order within each). JS's Array.sort is stable per spec since ES2019.
Practice

11

Recursion & Divide and Conquer

trust the recursive call. it works. stop tracing it in your head.

core idea Three questions, in order: 1. What is the base case? 2. How do I make the problem strictly smaller? 3. Given the correct answer for the smaller problem, how do I build mine?
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)));
}
Master theorem, the 90% version. For 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)
recursion DP is one line If 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.
data flow — merge sort splits down, then merges up
5  2  4  15  24  152412  51  41  2  4  5splitmergeO(log n) levelsO(n) work / level
  1. Going down is free: splitting an array in half costs nothing but index arithmetic.
  2. Going up is where the work happens: merging two sorted halves is a linear scan with two pointers.
  3. 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).
  4. 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.
  5. Notice you never trace both branches in your head at once. You assume the recursive call returns something sorted, and it does.
Practice

12

Backtracking

DFS over a decision tree, with an undo button

core idea choose explore un-choose. You are walking a tree of decisions. Pruning invalid branches early is what turns "exponential and hopeless" into "exponential but fine".
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
  }
}
The four dial settings
ProblemRecurse withDuplicate handling
Subsetsi + 1record at every node
Combinations (no reuse)i + 1sort, skip a[i] === a[i-1] && i > start
Combination sum (reuse)isame skip rule
Permutations0 + a used[] arrayskip if used[i-1] is false and equal
N-Queens pruning — don't scan the board. Keep three sets: 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).
data flow — the decision tree for subsets of [1, 2, 3]
{ }{ }1{ }211 2{ }322 311 31 21 2 3skip 1take 18 leaves = 2^3 subsets
  1. Every node is a partial answer. Every edge is one decision: take this element, or skip it.
  2. path is the label of the node you are currently standing on. It is one array, mutated in place — not a new array per node.
  3. 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.
  4. res.push([...path]) copies. Push path itself and every entry in res ends up pointing at the same (eventually empty) array.
  5. Pruning = refusing to walk down an edge. It deletes an entire subtree, which is why one good continue can turn 2n into something that finishes.
Practice

13

Trees & Binary Search Trees

recursion with a shape

core idea A tree problem is almost always: "what do I need from my children, and what do I return to my parent?" Answer those two and the code writes itself. Inorder on a BST is sorted.
// 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
}
BST validation trap. Checking 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).
data flow — the same tree, three visit orders
123456preordernode, left, right1 2 4 5 3 6inorderleft, node, right4 2 5 1 6 3postorderleft, right, node4 5 2 6 3 1copy / serialisesorted, if a BSTchildren before parent
  1. 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.
  2. Preorder emits on the way down, so the parent is always printed before its subtree — that is what makes it a serialisation format.
  3. 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.
  4. 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".
  5. Level order is the odd one out: it needs a queue, not the call stack.
Practice

14

Heap / Priority Queue

"give me the best one, repeatedly"

core idea A complete binary tree stored in a flat array, where every parent beats its children. Peek O(1), push/pop O(log n), build from an array O(n). Never sort just to get the top k — a size-k heap does it in O(n log k).
// 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.

data flow — a heap is an array pretending to be a tree
2i=05i=14i=28i=39i=46i=5205142839465arraychildren of i:2i+1, 2i+2parent of i:(i-1) >> 1push(1): lands at i=6,bubbles up 6 -> 2 -> 0
  1. No pointers. No nodes. The tree is implied by the arithmetic: index i's children live at 2i+1 and 2i+2.
  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.
  3. 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).
  4. pop(): take the root, move the last element into its place, and sift down. Same height, same cost.
  5. 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.
Practice

15

Trie (Prefix Tree)

a hashmap of hashmaps that knows about prefixes

core idea Each node is one character; the path from the root spells the prefix. Lookup costs O(word length), independent of how many words you stored. A hashset can answer "is this a word?" — only a trie answers "is anything here starting with this?"
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;
  }
}
Where tries earn their keep
  • 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
cost: memory. 26 pointers per node adds up — use a Map, or compress into a radix tree
data flow — a trie holding car, cat, dog
cartdogroot"car""cat""dog""ca" is a prefix,not a word: no ringlookup cost = wordlength, not dict size
  1. Characters live on the edges, not really in the nodes. The path from the root spells the prefix.
  2. "car" and "cat" share the c-a path. That sharing is the entire memory argument for a trie.
  3. The green ring is the end-of-word marker. Without it you cannot tell "is ca a word?" from "does anything start with ca?" — and those are different questions.
  4. Searching never touches the other words. Cost is O(len(word)), whether the trie holds 3 words or 300,000.
  5. 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.
Practice

16

Graphs — representation, BFS, DFS

grids are graphs. trees are graphs. states are graphs.

core idea BFS finds the shortest path in an unweighted graph. DFS does not. BFS explores by distance; DFS explores by commitment. Choose accordingly — everything else is bookkeeping.
// 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;
  /* ... */
}
Multi-source BFS — push all starting cells into the queue at distance 0. Rotting oranges, walls-and-gates, nearest 0 in a matrix: all the same three lines.
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).
data flow — BFS discovers nodes in distance order
0dist 01dist 12dist 13dist 24dist 3layer 0layer 1layer 2layer 3queue empties onelayer at a timeDFS would reach 4first, and be wrong
  1. The queue always holds nodes from at most two adjacent layers. Pop one, push its undiscovered neighbours — they are necessarily one layer deeper.
  2. Mark visited when you PUSH, not when you pop. Otherwise node 3, reachable from both 1 and 2, enters the queue twice.
  3. The first time you reach a node is via a shortest path. That is why dist[v] = dist[u] + 1 is safe and never needs revising.
  4. 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.
  5. Weighted edges break the layer argument entirely. That is the moment you need Dijkstra.
Practice

17

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.
Pick the right one
QuestionTool
fewest edges to targetBFS
cheapest path, weights >= 0Dijkstra
cheapest path, negative edgesBellman-Ford (also detects negative cycles)
cheapest path between all pairs, n <= 400Floyd-Warshall
valid build order / course scheduletopological sort
dynamic connectivity, cycle in undirectedunion-find
cheapest set of edges connecting everythingKruskal (sort + DSU) or Prim (heap)
data flow — union-find, and why path compression is free
before: find(3) walks 3 links3210compress0123after: everyone points at the rootthe next find(3) is one hop
  1. p[x] is "my parent". A group is identified by whichever node is its own parent — the root.
  2. 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.
  3. That is the whole of p[x] = find(p[x]): the assignment is the compression.
  4. 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.
  5. union(a, b) returning false means they were already connected this edge closes a cycle. That one line solves Redundant Connection and drives Kruskal's MST.
data flow — Kahn's algorithm as a countdown

Edges 0->1, 0->2, 1->3, 2->3, 3->4. Indegrees start at [0, 1, 1, 2, 1].

actionindegree [0..4]queuewhat happened
start0, 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
  1. Indegree = "how many prerequisites do I still have?" A node with indegree 0 is ready to run.
  2. 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.
  3. 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.
  4. 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".
  5. Every edge is examined once, every node enqueued once: O(V + E).
data flow — Dijkstra, including the stale entry everyone forgets

Edges A-B 4, A-C 1, C-B 2, B-D 5, C-D 8, source A.

popABCDpriority queuenote
start0infinfinf[(0,A)]
pop A (0)041inf[(1,C), (4,B)]relax B=4, C=1
pop C (1)0319[(3,B), (4,B), (9,D)]B improves 4->3 via C
pop B (3)0318[(4,B), (8,D), (9,D)]D improves 9->8
pop B (4)0318[(8,D), (9,D)]stale, 4 > dist[B]=3, skip
pop D (8)0318[(9,D)]done
  1. 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.
  2. That "because all weights are non-negative" is the entire correctness proof, and the entire reason Dijkstra breaks on negative edges.
  3. 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.
  4. 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.
  5. O((V + E) log V). For unweighted graphs, drop the heap and use a plain queue: that is just BFS.
Practice

18

Greedy & Intervals

take the locally best move and never look back — when that's actually allowed

core idea Greedy is correct only if the problem has the exchange property: any optimal solution can be rewritten, step by step, into the greedy one without getting worse. If you can't argue that, use DP.
// 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]);
}
the classic trap Coin change with coins [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.
Greedy that is provably correct: activity selection (earliest finish time), Huffman coding, Dijkstra's relaxation, Kruskal's MST, fractional knapsack, gas station.
Practice

19

Dynamic Programming

recursion + memory. that is the entire definition.

the five questions 1. State — what does 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];
0/1 knapsack — and why the loop runs backwards
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.
The eight patterns that cover ~95% of DP interviews
PatternStateRepresentative problem
Linear / Fibonaccidp[i] = best ending at iClimbing Stairs, House Robber
0/1 Knapsackdp[i][c]Partition Equal Subset Sum
Unbounded knapsackdp[c], forward loopCoin Change, Combination Sum IV
Grid / pathsdp[r][c]Unique Paths, Minimum Path Sum
Two sequencesdp[i][j]Edit Distance, LCS
Subsequence (LIS)dp[i] + binary searchLongest Increasing Subsequence
Interval DPdp[l][r], grow by lengthBurst Balloons, Matrix Chain
State machinedp[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
how to practise DP so it actually sticks Write the brute-force recursion first, every single time. Run it. Then add the cache. Then convert to bottom-up. Then compress the space. Four passes on one problem beats one pass on four problems.
data flow — LCS table for "abcde" vs "ace"
""abcde
""000000
a011111
c011222
e011223
  1. State: dp[i][j] = the LCS of the first i characters of "ace" and the first j of "abcde". Say that sentence out loud before writing anything.
  2. Match (green): the characters are equal, so extend the diagonal dp[i-1][j-1] + 1. Both strings consume one character.
  3. 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.
  4. 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].
  5. 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.
data flow — one item (w=3, v=4), W=6. Why the 0/1 knapsack loop runs backwards
capacity c
0
1
2
3
4
5
6
dp (start)
0
0
0
0
0
0
0
backward c--
0
0
0
4
4
4
4
correct: dp[3] read as 0
forward c++
0
0
0
4
4
4
8
wrong: dp[3] already 4
  1. dp[c] = max(dp[c], dp[c - w] + v). The read of dp[c - w] must see the previous item's row.
  2. Going backwards (c = 6 down to 3): when you write dp[6] you read dp[3], which you have not touched yet this round. It still holds the old value, 0. Correct: the item is used once.
  3. Going forwards (c = 3 up to 6): you write dp[3] = 4 first, then at c = 6 you read that fresh 4 and get 8. You just put the same item in the bag twice.
  4. So the loop direction is the semantics: backward = 0/1 knapsack, forward = unbounded knapsack.
  5. It is the same one-dimensional array either way. One character of difference, two different problems.
Practice

20

Bit Manipulation

a handful of identities. learn them once, recognise them forever.

the identities 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 */ }
JS reality check. Bitwise operators coerce to signed 32-bit. 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.
data flow — the two identities worth memorising, on x = 44
x = 44
0
0
1
0
1
1
0
0
x - 1 = 43
0
0
1
0
1
0
1
1
lowest 1 flips, everything below it turns on
x & (x-1)
0
0
1
0
1
0
0
0
lowest set bit is gone -> 40
x & -x
0
0
0
0
0
1
0
0
only the lowest set bit survives -> 4
  1. Subtracting 1 borrows through the trailing zeros: the lowest 1 becomes 0, and every bit below it becomes 1.
  2. AND them together and those changed bits all cancel x & (x-1) is x with its lowest set bit removed.
  3. Loop that until zero and the number of iterations is the popcount. It runs once per set bit, not 32 times.
  4. x & (x-1) === 0 therefore means "x had exactly one set bit" — a power of two.
  5. Two's complement makes -x equal to ~x + 1, which flips everything above the lowest set bit. AND with x and only that bit survives. Fenwick trees are built on this.
Practice

21

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.

Practice

22

Advanced — when the basics run out

rare in interviews, decisive in contests

Fenwick tree (BIT) — prefix sums with point updates, both O(log n). 10 lines, and it's the one advanced structure worth memorising.
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.

Practice

The plan

an order that builds on itself, and what to say out loud in the room

8 weeks, ~1 topic block per half-week
WeekTopicsGoal
1Big-O, Arrays, Two Pointersstop guessing complexity
2Sliding Window, Prefix Sum, Hashingthe O(n) toolbox
3Stack, Queue, Linked Listpointer fluency
4Binary Search, Sortingone template, no off-by-ones
5Recursion, Backtracking, Treestrust the recursive call
6Heap, Trie, Graphs (BFS/DFS)model the problem as a graph
7Topo sort, Dijkstra, Union-Find, Greedynamed algorithms on demand
8DP (all 8 patterns), Bitsstate -> transition -> base
2 problems a day beats 14 on Sunday. Spaced repetition is not optional.
how to actually improve Give yourself 25 minutes. If you're stuck, read the solution — then close it and reimplement from scratch. Put it in a "redo" list and solve it again in 3 days, then in 10. A problem you looked up is not a problem you can do.
In the interview — the 6 beats, in order
  1. Repeat the problem back. One sentence. Catch the misunderstanding now, not at minute 30.
  2. Ask about constraints and edge cases. Size of n? Duplicates? Negatives? Empty input? Sorted?
  3. State the brute force and its complexity. Always. It buys you a floor and it buys you time.
  4. 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)."
  5. Code, narrating the invariant, not the syntax. "l is the first index not yet excluded."
  6. Trace one small example by hand, then one edge case. Only then say you're done.
what actually loses offers
  • 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.
the honest summary There are about 15 patterns. Most problems are one pattern with a costume on. Your job in the first two minutes is to take the costume off. Everything on this sheet exists to make that faster.