← DSA Roadmap

Full Reference Sheet

This assumes you've already done the work — every subtopic's theory and problems. It's a fast review pass to get every signal, approach, and template back to the top of your mind, not a place to learn something for the first time.

1. Interview Foundations

  • Signal: You need to predict or justify how an approach's runtime or memory scales with input size n, or the problem's constraints (e.g. n <= 10^5) are hinting at the target complexity.
  • Approach: Count the dominant operations as a function of n: add complexities of sequential loops, multiply nested loops, and match recursion to its shape (linear -> O(n), halving -> O(log n), two-calls-that-halve-plus-combine -> O(n log n), two-calls-shrink-by-1 -> O(2^n)); always state time AND space together, including call-stack space.
  • Watch out for: Missing a hidden O(n) operation inside a loop (like `x in a_list` instead of `x in a_set`), which silently turns an O(n) algorithm into O(n^2).
  • Complexity: No single answer — use n's constraint as your target: n<=10^5 needs O(n) or O(n log n); n<=10^3 allows O(n^2); n<=20 allows O(2^n).
# sequential loops -> add: O(n) + O(m) = O(n + m) for x in a: process(x) for y in b: process(y) # nested loops -> multiply: O(n * m) for x in a: for y in b: process(x, y) # shrinking/growing step -> log: O(log n) i = n while i > 1: i //= 2
  • Signal: The problem naturally decomposes into a smaller version of itself (trees, subsequences, divide-and-conquer, 'in terms of itself' phrasing) — or you need to reason about how many stack frames a recursive solution will use.
  • Approach: Write one base case for the smallest input and one recursive case that shrinks the input and combines the sub-result; if two-call (tree) recursion recomputes the same subproblem across branches, memoize it — that's the seed of dynamic programming.
  • Watch out for: Assuming Python/Java/C++ optimize tail calls — they don't, so a deep or unbounded-depth recursive chain (a skewed tree, a linked list of 10^5+ nodes) still allocates one stack frame per call and can overflow; convert to an explicit stack when depth isn't bounded by something like tree height.
  • Complexity: O(depth) call-stack space always; time is O(n) for linear recursion or up to O(branches^depth) for un-memoized tree recursion.
def solve(problem): if is_base_case(problem): return base_answer smaller = shrink(problem) sub_result = solve(smaller) return combine(problem, sub_result) # convert to iterative with an explicit stack when depth is unbounded def solve_iterative(root): stack, result = [root], [] while stack: node = stack.pop() # process node, push children/next subproblems return result
  • Signal: You're facing a problem you've never seen before and don't yet know which pattern applies, or you catch yourself about to start typing without a plan.
  • Approach: Work UMPIRE out loud: Understand (restate + ask about constraints/edge cases), Match (name the likely pattern), Plan (state brute force + its complexity, find the bottleneck, propose the optimization). With multiple inputs, name each size and pick which side to preprocess. Also ask: does a constraint cap how far back/wide/deep you need to search (max word length, grid size, k)? — if so, bound your loop there instead of iterating 'everything so far'. Implement, Review, Evaluate.
  • Watch out for: Jumping straight into code without first stating a brute force and its complexity — or iterating over the full stream/history/active-set when a constraint (max word length, board dimensions) already gives a fixed cap on how much state matters.
  • Complexity: Not a fixed complexity — target complexity is derived per-problem from n (e.g. n<=10^5 -> aim for O(n log n) or O(n)).
# U - Understand: restate the problem, ask about edge cases & constraints # M - Match: "this looks like <pattern> because <signal>" # P - Plan: state brute force + complexity -> find bottleneck -> propose optimization # multiple inputs? name each size, preprocess the smaller/reusable side # constraint cap? only iterate as far as constraints allow (max word len, grid size, k) # I - Implement: meaningful names, helper functions, narrate non-obvious lines # R - Review: trace a small example by hand, check edge cases from step 1 # E - Evaluate: state final time/space complexity, mention trade-offs

2. Arrays & Hashing

  • Signal: The problem asks for a sum/product/XOR over repeated (or many) contiguous ranges of a static array, or asks to find a subarray hitting a target sum where negative values rule out sliding window.
  • Approach: Precompute prefix[i] = sum(nums[0..i-1]) once in O(n) so any range sum becomes prefix[r+1] - prefix[l]; for target-sum-subarray variants, walk the array once and store each running prefix sum in a hash map so the complement you need is an O(1) lookup instead of a nested loop.
  • Watch out for: Forgetting the prefix[0] = 0 sentinel (or the map's seed entry {0: 1}) causes off-by-one errors and silently drops subarrays that start at index 0.
  • Complexity: O(n) preprocessing, O(1) per range query, O(n) space
def build_prefix(nums): prefix = [0] * (len(nums) + 1) for i, x in enumerate(nums): prefix[i + 1] = prefix[i] + x return prefix def count_subarrays_with_sum(nums, target): seen = {0: 1} running, count = 0, 0 for x in nums: running += x count += seen.get(running - target, 0) seen[running] = seen.get(running, 0) + 1 return count
  • Signal: A brute-force solution has an inner loop whose only job is to search for something — a duplicate, a complement, a previously-seen state, or items sharing a derived property.
  • Approach: Name the shape: seen-tracker (set) for membership, frequency counter (dict/Counter) for counts, complement lookup (dict) checked before inserting the current element, or grouping by a canonical signature (dict of lists) — each collapses an O(n) inner search into O(1) average.
  • Watch out for: Using `x in some_list` inside a loop silently reintroduces the O(n^2) you were trying to remove, and in complement-lookup problems, checking the complement in the wrong order relative to insertion can match an element with itself.
  • Complexity: O(1) average time per operation, O(n) worst case, O(n) space
def has_complement_pair(nums, target): seen = set() for x in nums: complement = target - x if complement in seen: return True seen.add(x) return False
  • Signal: You're asked how a hash table behaves under collisions/adversarial input, why 'O(1) average' isn't a worst-case guarantee, or you're facing a 'find the majority/heavy element using O(1) extra memory' constraint that rules out a hash map outright.
  • Approach: Name chaining vs. open addressing and the load-factor/resize argument for average-case O(1); for a majority-element-under-O(1)-space constraint, use Boyer-Moore voting (candidate + counter, increment on match, decrement otherwise, reset candidate when counter hits 0).
  • Watch out for: Assuming more buckets than items means collisions are unlikely — by the birthday paradox, collisions become likely once you've inserted roughly sqrt(bucket count) items, far fewer than the bucket count itself.
  • Complexity: O(1) average / O(n) worst case per hash table operation; O(n) time / O(1) space for Boyer-Moore majority vote.
def majority_element(stream): candidate, count = None, 0 for x in stream: if count == 0: candidate = x count += 1 if x == candidate else -1 return candidate
  • Signal: You're asked to shuffle a fixed collection uniformly at random, or pick one (or k) uniformly random element(s) from a stream whose total length isn't known up front.
  • Approach: Fisher-Yates: walk backward from the last index, swap each position with a uniformly random remaining index (inclusive of itself). Reservoir sampling: keep the first element, then replace the current pick with the ith element with probability 1/i as you scan; generalize to k by seeding the reservoir with the first k and swapping in later elements with probability k/i.
  • Watch out for: Shuffling by sorting with a random-valued comparator -- it looks plausible but is provably non-uniform, since a bounded number of comparisons cannot produce a clean draw over all n! permutations.
  • Complexity: O(n) time, O(1) extra space for Fisher-Yates; O(n) time (one pass), O(k) space for reservoir sampling of k items.
import random def fisher_yates_shuffle(arr): for i in range(len(arr) - 1, 0, -1): j = random.randint(0, i) arr[i], arr[j] = arr[j], arr[i] return arr def reservoir_sample_one(stream): result = None for i, item in enumerate(stream, start=1): if random.randint(1, i) == 1: result = item return result

3. Two Pointers

  • Signal: You're told (or can safely assume after sorting) that the structure is sorted/monotonic and asked for a pair/triplet sum, a palindrome check, or an in-place partition by a predicate.
  • Approach: Anchor `left` at index 0 and `right` at the last index, converge them toward each other, and at each step move only the pointer that provably cannot be part of a better answer given the current comparison.
  • Watch out for: Applying this to data that isn't actually sorted/monotonic (or forgetting to skip duplicate values in k-sum variants), which silently breaks the correctness argument.
  • Complexity: O(n) time for the scan (O(n log n) if you must sort first), O(1) auxiliary space
def two_pointer_opposite(arr, target): left, right = 0, len(arr) - 1 while left < right: current = arr[left] + arr[right] if current == target: # record/return, then usually left += 1; right -= 1 ... elif current < target: left += 1 # sum too small: only a bigger arr[left] can help else: right -= 1 # sum too large: only a smaller arr[right] can help return # pointers crossed without satisfying the condition
  • Signal: A linked-list or sequence problem mentions a cycle, asks for the middle in one pass, or specifically demands O(1) extra space where a hash set would otherwise be the obvious O(n)-space fix.
  • Approach: Floyd's Tortoise and Hare: advance `slow` by one step and `fast` by two per iteration; if they ever become the same node there's a cycle, and resetting one pointer to `head` then advancing both by one step at a time finds the cycle's start.
  • Watch out for: Checking `fast and fast.next` in the wrong order (or omitting one), which crashes on `fast.next.next`; also mixing up whether `slow` lands on the first or second middle node for even-length lists.
  • Complexity: O(n) time, O(1) space
def has_cycle(head): slow, fast = head, head while fast and fast.next: slow = slow.next fast = fast.next.next if slow is fast: return True return False # swap `head` / `.next` for `step(x)` calls to reuse # this over any deterministic single-successor chain.

4. Sliding Window

  • Signal: The problem gives you an explicit, constant window length k and asks for something (sum/avg/max/min/count) over every contiguous window of that size.
  • Approach: Slide a window of fixed size k in one pass: fold in arr[right], and once the window has reached size k, process it and fold out arr[right - k + 1]; use a monotonic deque of indices instead of a scalar when you need the window's running max/min.
  • Watch out for: Off-by-one on the window bounds (using right - k instead of right - k + 1) or forgetting to remove the outgoing element, which silently turns a sliding window into a growing one.
  • Complexity: O(n) time (each index enters and leaves the window once), O(1) space for scalar aggregates or O(k) for a monotonic deque / frequency map
def fixed_window_template(arr, k): window_state = 0 best = float("-inf") for right in range(len(arr)): window_state += arr[right] # expand if right >= k - 1: best = max(best, window_state) # process full window left = right - k + 1 window_state -= arr[left] # shrink return best
  • Signal: The ask is for the longest or shortest CONTIGUOUS subarray/substring satisfying a condition that only gets "worse" as the window grows (a monotonic badness), never a non-contiguous selection.
  • Approach: Grow the window by advancing right every step, and shrink it with an inner while loop only when the invariant is violated (for longest) or while it still holds (for shortest); for "exactly K" counting, compute atMost(K) - atMost(K - 1) since only "at most" is monotonic enough for a single window.
  • Watch out for: Updating best in the wrong place — for shortest-window problems you must record the answer inside the shrink loop on every valid state, not just once after it exits, or you'll only capture the last window, not the smallest one.
  • Complexity: O(n) amortized time — right advances n times and left advances at most n times total across the whole run, not per outer iteration — O(1) to O(n) space depending on the window state
def variable_window_template(arr, is_valid_check): left = 0 window_state = init_state() best = 0 for right in range(len(arr)): add(window_state, arr[right]) # expand while not is_valid_check(window_state): # shrink while invalid remove(window_state, arr[left]) left += 1 best = update(best, right, left) # record answer return best

5. Stack

  • Signal: The problem asks for the nearest left/right element that's bigger or smaller than the current one (next/previous greater/smaller), or reduces to 'how far does a run of not-bigger/not-smaller-than-me values extend' (spans, histogram widths, car fleets).
  • Approach: Scan once while maintaining a stack of indices whose values stay monotonic (increasing or decreasing); before pushing the current index, pop and resolve every stacked index the current value invalidates.
  • Watch out for: Off-by-one on width/distance math (i - stack[-1] - 1 vs i - stack[-1]) and picking < vs <= in the pop condition — both silently break only on inputs with repeated values.
  • Complexity: O(n) time (each index pushed and popped at most once), O(n) space for the stack.
def monotonic_stack_template(nums): n = len(nums) result = [-1] * n stack = [] # indices, kept monotonic for i in range(n): while stack and CONDITION(nums[stack[-1]], nums[i]): j = stack.pop() result[j] = i # or nums[i], or i - j stack.append(i) return result
  • Signal: The problem is about nesting/matching (opens must close in reverse order) or replaying operations where each new event only ever interacts with the most recently seen unresolved one — brackets, undo/collision sequences, RPN, calculators, k[...] decoding.
  • Approach: Use a plain LIFO stack as the evolving 'currently open / pending' state: push on an opening or new token, and on a closing or combining token, pop and resolve against whatever is now on top.
  • Watch out for: Popping from an empty stack without a guard, and in calculators, mixing up operand order for non-commutative operators (b, a = stack.pop(), stack.pop() gives the right then left operand) or forgetting Python truncates toward zero, not floor, for calculator-style integer division.
  • Complexity: O(n) time and O(n) worst-case space (a fully-nested input pushes every token before popping any).
def is_balanced(s, pairs): # pairs: closing -> matching opening, e.g. {')': '(', ']': '[', '}': '{'} openers = set(pairs.values()) stack = [] for ch in s: if ch in openers: stack.append(ch) elif ch in pairs: if not stack or stack.pop() != pairs[ch]: return False return not stack

7. Sorting Algorithms

  • Signal: You're asked to implement a sort from scratch, explain what a library sort does internally, or optimize a naive O(n^2) sort's constant factor without changing its complexity class.
  • Approach: Default to merge sort (guaranteed O(n log n), stable) or heapsort (O(n log n), O(1) space) when a worst-case guarantee matters; reach for insertion sort only as a small-subarray base case or when the data is already nearly sorted; add an early-exit flag to bubble sort and note real sorts hybridize with insertion sort below a size threshold.
  • Watch out for: Confusing 'stable' with 'in-place' — they're independent properties, and quicksort/heapsort are in-place but not stable while classic merge sort is stable but not in-place.
  • Complexity: O(n log n) time for merge/heap sort (worst case guaranteed); O(n^2) for insertion/bubble/selection, but O(n*k) for insertion sort when k (max displacement) is small.
def heap_sort(arr): n = len(arr) for i in range(n // 2 - 1, -1, -1): _sift_down_max(arr, i, n) for end in range(n - 1, 0, -1): arr[0], arr[end] = arr[end], arr[0] _sift_down_max(arr, 0, end)
  • Signal: You need to implement a general-purpose in-place sort from scratch, or a problem needs a single rank/order-statistic (kth largest/smallest) rather than a fully sorted array, or the array has heavy duplicate values.
  • Approach: Partition around a randomly chosen pivot (Lomuto or Hoare scheme) and recurse both sides for a full sort, expected O(n log n); recurse only the side containing the target rank for quickselect, expected O(n); switch to three-way (Dutch flag) partitioning when duplicates are common so the 'equal' band is resolved once and never revisited.
  • Watch out for: Using a deterministic pivot (always first/last element) on data you don't control degrades to O(n^2) on sorted/adversarial input — always randomize or use median-of-three.
  • Complexity: Expected O(n log n) for quicksort, expected O(n) for quickselect; worst case O(n^2) for both, O(log n) expected extra space.
def dutch_flag_partition(arr, pivot): low, mid, high = 0, 0, len(arr) - 1 while mid <= high: if arr[mid] < pivot: arr[low], arr[mid] = arr[mid], arr[low] low += 1; mid += 1 elif arr[mid] == pivot: mid += 1 else: arr[mid], arr[high] = arr[high], arr[mid] high -= 1
  • Signal: Keys are integers in a known, bounded range (counting sort), or fixed-width integers/strings decomposable into digits (radix sort), or roughly uniformly distributed reals (bucket sort) — any of these can legitimately beat O(n log n).
  • Approach: Counting sort: tally each key's frequency directly as an array index, O(n + k); radix sort: apply a stable counting sort per digit, least-significant first, O(d*(n+b)); always verify the per-digit subroutine is stable or multi-digit radix sort silently produces the wrong order.
  • Watch out for: Claiming these 'beat' the comparison-sort lower bound — they don't contradict Omega(n log n), they operate outside the comparison model entirely by using each key's value as information, not just its relative order.
  • Complexity: Counting sort O(n + k) time / O(k) space; radix sort O(d*(n+b)) ~= O(n) for fixed-width keys; bucket sort O(n) expected under a uniform-distribution assumption.
def counting_sort(arr, k): counts = [0] * k for x in arr: counts[x] += 1 result = [] for value, count in enumerate(counts): result.extend([value] * count) return result

8. Linked List

  • Signal: The problem asks you to mutate, reorder, merge, or detect a cycle in a singly linked list where the head itself might change, or where you can't jump to an index the way you would in an array.
  • Approach: Anchor the pass with a dummy sentinel node before head to remove head-special-casing, and drive traversal with two pointers — prev/curr for edits, slow/fast (1:2 speed) for middle/cycle, or fixed-gap (advance one pointer k nodes, then move both one step) for nth-from-end and rotation split points.
  • Watch out for: Overwriting a node's next pointer before you've saved a reference to what it used to point to — this silently drops the rest of the list or wires in an accidental cycle.
  • Complexity: O(n) time, O(1) space (recursive variants cost O(n) stack space).
dummy = ListNode(0, next=head) prev, curr = dummy, head while curr: next_node = curr.next # save before overwriting anything # ... inspect/relink curr here (delete, reverse, merge) ... prev, curr = curr, next_node return dummy.next # never return the original head directly
  • Signal: The problem asks you to reverse only part of a list (in groups or a bounded range), deep-copy a list that carries an extra pointer, or design a cache/data structure needing both O(1) lookup and O(1) reordering.
  • Approach: For structural variants, reuse the reversal/dummy-head primitives but bound them by an explicit boundary node grabbed before rewiring; for O(1) design, pair a hash map (key -> node) with a doubly linked list so any node can be spliced out and reinserted without scanning.
  • Watch out for: Losing the 'next group' or 'next node' boundary reference after you start rewiring pointers — once next is overwritten you can't recover what used to follow, so grab that reference before touching anything.
  • Complexity: O(n) time for grouped reversal / deep copy; O(1) time per operation and O(capacity) space for LRU/LFU-style designs.
class Node: def __init__(self, key, val): self.key, self.val = key, val self.prev = self.next = None def remove(node): node.prev.next, node.next.prev = node.next, node.prev def insert(node, tail_sentinel): prev, nxt = tail_sentinel.prev, tail_sentinel prev.next = nxt.prev = node node.prev, node.next = prev, nxt

9. Design Problems

  • Signal: You need O(1) enqueue/dequeue on a fixed-capacity FIFO buffer, or a 'design a data structure that tracks the last k elements/seconds of a stream' requirement.
  • Approach: Back the queue with a fixed-size array plus a head index and a size counter; compute the tail as (head + size) % capacity and wrap both indices with modular arithmetic instead of ever shifting elements.
  • Watch out for: Using a language's built-in 'remove from front' list operation and assuming it's O(1) -- on a plain array/list that's O(n) per call (it shifts every remaining element), silently turning an O(n) algorithm into O(n^2).
  • Complexity: O(1) time for enqueue and dequeue; O(capacity) space, fixed and pre-allocated.
class CircularQueue: def __init__(self, capacity): self.buf = [None] * capacity self.capacity = capacity self.head = 0 self.size = 0 def enqueue(self, val): tail = (self.head + self.size) % self.capacity self.buf[tail] = val self.size += 1 def dequeue(self): val = self.buf[self.head] self.head = (self.head + 1) % self.capacity self.size -= 1 return val

10. Trees

  • Signal: The problem is about visiting every node, and the phrasing hints at an ordering constraint — sorted output, children-before-parent, or 'level N' / 'row by row' — rather than 'find one specific node.'
  • Approach: Pick the traversal whose visit order matches the dependency: preorder (root before children) for copying/serializing, postorder (children before root) for deleting or aggregating subtree values, inorder for sorted BST output, and BFS with a queue when the question is fundamentally about depth or per-level structure.
  • Watch out for: Reaching for the reversed-preorder trick incorrectly, or forgetting the `for _ in range(len(queue))` snapshot line in BFS — drop it and you still get valid breadth-first order, but you silently lose the ability to tell where one level ends and the next begins.
  • Complexity: O(n) time for any traversal; O(h) space for DFS (recursive or explicit stack), O(w) for BFS (queue) — both O(n) in the worst case.
def traverse(node): if node is None: return # preorder position: visit(node) here traverse(node.left) # inorder position: visit(node) here traverse(node.right) # postorder position: visit(node) here from collections import deque def level_order(root): result, queue = [], deque([root]) if root else deque() while queue: level = [] for _ in range(len(queue)): # snapshot this level's size node = queue.popleft() level.append(node.val) if node.left: queue.append(node.left) if node.right: queue.append(node.right) result.append(level) return result
  • Signal: The problem hands you a Binary Search Tree specifically (not just 'a binary tree') and asks about search, ordering, kth-something, or a range — anything where comparing to a node's value tells you which whole subtree to discard.
  • Approach: Exploit the invariant (left < node < right, holding for the *entire* subtree, not just direct children) to discard one whole subtree per step, exactly like binary search; reach for inorder traversal whenever you need sorted order, since inorder on a BST is always non-decreasing.
  • Watch out for: Validating (or reasoning about) a BST by comparing a node only to its immediate children instead of threading a tightening (low, high) range down through the recursion — a node can satisfy the local check and still violate an ancestor's bound.
  • Complexity: O(h) time for search/insert/delete (O(log n) balanced, O(n) skewed — never assume balanced unless stated); O(h) space for the recursion stack.
def is_valid_bst(node, low=float('-inf'), high=float('inf')): if node is None: return True if not (low < node.val < high): return False return (is_valid_bst(node.left, low, node.val) and is_valid_bst(node.right, node.val, high)) def search(root, target): if root is None or root.val == target: return root return search(root.left, target) if target < root.val else search(root.right, target)
  • Signal: You're given one or two traversal sequences (or a serialized string) and asked to rebuild the tree, or asked to flatten/encode a tree into a storable form and decode it back.
  • Approach: Preorder's first element (or postorder's last) is always the subtree root; use inorder to split the remaining sequence into left/right subtree ranges via an index hash map, and always build the left subtree before the right one when both recurse over the same preorder pointer. For serialization, record `None` markers so a single traversal becomes unambiguous to reverse.
  • Watch out for: Trying to reconstruct from preorder + postorder alone (without a 'full binary tree' guarantee) — that pairing can't distinguish a lone child being a left child from it being a right child, so it's ambiguous in general.
  • Complexity: O(n) time and space for construction (with a hash map for O(1) root lookups) and for serialize/deserialize; O(h) extra recursion-stack space.
def build(preorder, inorder): index_of = {val: i for i, val in enumerate(inorder)} pre_pos = 0 def helper(in_left, in_right): nonlocal pre_pos if in_left > in_right: return None root_val = preorder[pre_pos] pre_pos += 1 root = TreeNode(root_val) mid = index_of[root_val] root.left = helper(in_left, mid - 1) root.right = helper(mid + 1, in_right) return root return helper(0, len(inorder) - 1)
  • Signal: The problem asks for something computed 'over the whole tree' that can bend at a node (uses both children at once) — diameter, a path that doesn't have to start at the root, LCA, or 'combine subtree info and report a single value up.'
  • Approach: Write one DFS that returns a single-sided value the parent can use (e.g., height, or 'found p/q here'), while separately updating a `nonlocal`/side variable for the true answer whenever it needs BOTH children's results at once — the two are different things and must not be conflated.
  • Watch out for: Returning the two-sided 'bent' value up to the parent instead of the single-sided one — the code still produces a number, just silently the wrong one on any tree where the true answer doesn't pass through the root.
  • Complexity: O(n) time (each node visited a constant number of times); O(h) space for the recursion stack.
def solve(root): best = float('-inf') def dfs(node): nonlocal best if node is None: return 0 # neutral value for 'no subtree here' left = dfs(node.left) # single-sided info from left right = dfs(node.right) # single-sided info from right best = max(best, left + right) # 'bent' candidate — only valid HERE return 1 + max(left, right) # single-sided value reported UP dfs(root) return best

11. Tries

  • Signal: The problem is really about prefixes over a fixed (or streaming) set of strings — or, symmetrically, about bit-strings for XOR — with many repeated prefix/wildcard/exact queries, not just one-off exact-match membership. For streaming suffix checks (Stream of Characters), ask whether a constraint caps lookback (max word length) or whether KMP-style failure links (Aho–Corasick) avoid re-scanning.
  • Approach: Build a trie (children map + is_end flag per node) in O(total characters); walk it char-by-char for O(L) insert/search/startsWith, branch into every child on a wildcard, or apply the identical structure bit-by-bit (2-symbol alphabet, depth = bit width) for XOR-maximization problems.
  • Watch out for: Conflating 'the path exists' with 'a word was actually inserted here' — starts_with only checks the path exists, but search (and any exact-match check) must also verify is_end at that node, since a longer inserted word can pass straight through the node for a shorter, never-inserted prefix.
  • Complexity: O(L) time per insert/search/startsWith, completely independent of n (words already stored); O(total characters stored) space, shared across common prefixes.
class TrieNode: def __init__(self): self.children = {} self.is_end = False class Trie: def __init__(self): self.root = TrieNode() def insert(self, word): node = self.root for ch in word: node = node.children.setdefault(ch, TrieNode()) node.is_end = True def _walk(self, s): node = self.root for ch in s: if ch not in node.children: return None node = node.children[ch] return node def search(self, word): node = self._walk(word) return node is not None and node.is_end def starts_with(self, prefix): return self._walk(prefix) is not None

12. Heaps & Priority Queues

  • Signal: The problem asks for the current min/max repeatedly as data changes, or for the top/bottom K elements, without needing the whole collection fully sorted.
  • Approach: Maintain a min-heap of size K to track the K largest elements (or a max-heap of size K for the K smallest): push every element, and pop whenever the heap grows past K.
  • Watch out for: Reaching for a max-heap to find the K largest is backwards — you need a min-heap of size K so you can cheaply evict the weakest member of your current top-K set at the root.
  • Complexity: O(n log k) time, O(k) space
import heapq def k_largest(nums, k): min_heap = [] for num in nums: heapq.heappush(min_heap, num) if len(min_heap) > k: heapq.heappop(min_heap) return min_heap # k largest elements, arbitrary order
  • Signal: You need a running median, or any 'boundary' order statistic (e.g., the best option among everything currently affordable), as data streams in or a window slides.
  • Approach: Split elements across a max-heap holding the lower half and a min-heap holding the upper half, rebalancing after every insert so their sizes differ by at most one — the median sits right at the seam between the two roots.
  • Watch out for: Getting the heap types backwards (lower must be the max-heap, upper the min-heap) or mixing balance conventions between the insert logic and the median formula.
  • Complexity: O(log n) time per insert, O(1) per median query
import heapq def add(lower, upper, num): # lower: max-heap (store negated), upper: min-heap heapq.heappush(lower, -num) heapq.heappush(upper, -heapq.heappop(lower)) # rebalance: lower may hold at most one more than upper if len(upper) > len(lower): heapq.heappush(lower, -heapq.heappop(upper))
  • Signal: You're given K already-sorted sources (arrays, linked lists, or streams) and need the merged order, the Kth smallest overall, or the next smallest across all of them.
  • Approach: Keep a min-heap holding one 'current head' per source as (value, source_index, element_index); repeatedly pop the smallest, emit it, and push that source's next element.
  • Watch out for: Letting the heap grow to hold all N elements instead of just the K current heads — that silently degrades you back to O(N log N), defeating the whole point of the pattern.
  • Complexity: O(N log k) time, O(k) space
import heapq def merge_k_sorted(lists): heap = [(lst[0], i, 0) for i, lst in enumerate(lists) if lst] heapq.heapify(heap) result = [] while heap: val, i, j = heapq.heappop(heap) result.append(val) if j + 1 < len(lists[i]): heapq.heappush(heap, (lists[i][j + 1], i, j + 1)) return result

13. Backtracking

  • Signal: Problem asks to generate every subset/combination of a set, or a fixed-size/target-sum selection from it, where order does not matter — 'all subsets', 'n choose k', 'combinations summing to target'.
  • Approach: DFS with a `start` index that only ever looks forward; every node (subsets) or every node reaching the target size/sum (combinations) is a valid answer, and passing `i` vs `i + 1` as the next start controls whether an element can be reused.
  • Watch out for: Appending `path` instead of `path[:]` (everyone ends up pointing at the same, now-empty list), or skipping duplicate values with `nums[i] == nums[i-1]` but forgetting the `i > start` guard so the first legitimate use at a new depth gets blocked too.
  • Complexity: O(n · 2ⁿ) time for subsets, O(k · C(n, k)) for fixed-size combinations; O(n) space for the recursion stack and path.
def dfs(start, path): res.append(path[:]) # every node is valid for subsets for i in range(start, len(nums)): if i > start and nums[i] == nums[i - 1]: continue # skip duplicate sibling at this depth path.append(nums[i]) dfs(i, path) # dfs(i, ...) reuses; dfs(i + 1, ...) doesn't path.pop()
  • Signal: Problem asks for every ordering/arrangement of a set — order matters, so it's distinct from subsets/combinations even when the input is the same array.
  • Approach: DFS with a `used[]` boolean array (or in-place swapping) that can pick any not-yet-used element at each position; only snapshot the result at leaves, once `len(path) == len(nums)`.
  • Watch out for: Undoing `path.pop()` but forgetting `used[i] = False` on the way back up — there are two pieces of state to restore here, not one — or reusing the subsets duplicate-skip condition (`i > start`) instead of the permutations one (`not used[i - 1]`).
  • Complexity: O(n · n!) time, O(n) space beyond the output (the `used[]` array plus recursion depth).
def dfs(): if len(path) == len(nums): res.append(path[:]) return for i in range(len(nums)): if used[i]: continue if i > 0 and nums[i] == nums[i - 1] and not used[i - 1]: continue # skip duplicate at this depth used[i] = True path.append(nums[i]) dfs() path.pop() used[i] = False
  • Signal: The naive search space is so large (grid placements, board configurations) that brute force alone won't finish in time — N-Queens, Sudoku, Word Search, Restore IP — and progress depends on choosing the right variable per depth plus a cheap `is_valid`/`can_place` check.
  • Approach: First fix an ordered variable sequence (one queen per row, next empty Sudoku cell, next IP octet, next char in the word) so each solution is built once; then DFS that mutates shared constraint state in place, checks validity *before* recursing, and undoes before the next sibling.
  • Watch out for: Trying any free cell/slot and deduping finished boards with a `seen` set — or forgetting to undo a mutation (column/diagonal mark, Sudoku cell, Word Search sentinel), which corrupts every later sibling branch.
  • Complexity: N-Queens O(n!) worst case with O(1) pruning checks; Sudoku O(9^m) for m empty cells; Word Search O(m · n · 4^L) for word length L.
def backtrack(row): # depth = next variable (here: this row) if row == n: record_solution() return for choice in range(n): # domain for this variable only if not is_safe(choice): # O(1) via tracked sets, not a rescan continue place(choice) # mutate shared state backtrack(row + 1) remove(choice) # undo before trying next sibling

14. Graphs

  • Signal: The problem asks for shortest path/fewest steps in an unweighted graph, reachability, connected regions, a 2D grid where cells are vertices, or which undirected edges are bridges (critical connections).
  • Approach: Use BFS when you need shortest path or level-order (queue, mark visited at enqueue time); use DFS for exhaustive exploration or simple connectivity (mark visited before recursing); seed the BFS queue with every source at once when a problem asks for distance to the nearest of several starting points. For bridges, run a discovery-time DFS and report tree edges where low[child] > disc[parent].
  • Watch out for: Marking a node visited when it's popped instead of when it's pushed lets the same node get queued multiple times; on bridge DFS, treating the parent edge as a back edge makes low[] too small and you miss every bridge.
  • Complexity: O(V + E) time, O(V) space for both BFS and DFS on an adjacency list.
from collections import deque def multi_source_bfs(grid, sources): rows, cols = len(grid), len(grid[0]) dist = [[-1] * cols for _ in range(rows)] queue = deque() for r, c in sources: dist[r][c] = 0 queue.append((r, c)) while queue: r, c = queue.popleft() for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)): nr, nc = r + dr, c + dc if 0 <= nr < rows and 0 <= nc < cols and dist[nr][nc] == -1: dist[nr][nc] = dist[r][c] + 1 queue.append((nr, nc)) return dist
  • Signal: Fewest moves / minimum steps, but vertices are configurations you invent (string, bitmask+cell, bus/stop), not an adjacency list the problem handed you.
  • Approach: Define state so two equal tuples are interchangeable; generate neighbors on the fly; BFS with visited on the full state. When start and goal are both known and branching is high, run bidirectional BFS and stop when frontiers meet.
  • Watch out for: Visiting only part of the state (e.g. grid cell without the key bitmask) permanently blocks a later, more useful visit to the same component — key visited on the entire tuple at enqueue time.
  • Complexity: O(|S| · T) where |S| is reachable states and T is neighbor-generation cost per state; bound |S| from constraints before coding.
from collections import deque def open_lock(deadends, target): dead, start = set(deadends), '0000' if start in dead: return -1 visited, queue = {start}, deque([(start, 0)]) while queue: state, dist = queue.popleft() if state == target: return dist for i in range(4): d = int(state[i]) for delta in (-1, 1): nxt = state[:i] + str((d + delta) % 10) + state[i+1:] if nxt not in visited and nxt not in dead: visited.add(nxt) queue.append((nxt, dist + 1)) return -1
  • Signal: DAG dependencies → topological order / cycle check. "Use every ticket/edge exactly once" (possibly with cycles) → Eulerian path via Hierholzer, not topo sort.
  • Approach: Topo: Kahn's (peel in-degree 0) or DFS append-on-exit then reverse. Eulerian: while unused edges remain, recurse on pop(); append vertex only when finished; reverse route. For lex itineraries, reverse-sort each adjacency list so pop() is smallest.
  • Watch out for: Directed cycle detection needs gray/black colors, not a plain visited set. For itineraries, greedy "always take next edge" (even with a sink special-case) fails on dead-end side trips — use Hierholzer post-order.
  • Complexity: Topo O(V + E). Hierholzer O(E) (or O(E log E) with per-vertex lex sorts). Space O(V) / O(E).
from collections import deque, defaultdict def kahn_topological_sort(n, edges): graph, in_degree = defaultdict(list), [0] * n for u, v in edges: graph[u].append(v) in_degree[v] += 1 queue = deque(i for i in range(n) if in_degree[i] == 0) order = [] while queue: u = queue.popleft() order.append(u) for v in graph[u]: in_degree[v] -= 1 if in_degree[v] == 0: queue.append(v) return order if len(order) == n else None # None means a cycle exists # Hierholzer (Eulerian path / Reconstruct Itinerary): # while adj[u]: dfs(adj[u].pop()); route.append(u); return route[::-1]
  • Signal: Connectivity changes incrementally (edges/unions added one at a time) and you repeatedly need 'are these two already in the same group?', or you're deciding whether accepting an edge would close a cycle.
  • Approach: Maintain a parent-pointer forest: find() walks to the root with path compression; union() attaches the shorter/smaller tree under the taller/larger one's root (union by rank or size).
  • Watch out for: Skipping union by rank/size (or skipping path compression) still produces correct results but lets adversarial input degrade find() toward O(n), destroying the near-O(1) guarantee the structure exists for.
  • Complexity: O(α(n)) amortized per find/union — effectively O(1) with both optimizations applied.
class UnionFind: def __init__(self, n): self.parent = list(range(n)) self.rank = [0] * n def find(self, x): if self.parent[x] != x: self.parent[x] = self.find(self.parent[x]) return self.parent[x] def union(self, a, b): ra, rb = self.find(a), self.find(b) if ra == rb: return False if self.rank[ra] < self.rank[rb]: ra, rb = rb, ra self.parent[rb] = ra if self.rank[ra] == self.rank[rb]: self.rank[ra] += 1 return True
  • Signal: Weighted graph, need cheapest cost from a source (or every pair), count of shortest paths, or minimize the bottleneck (max edge) on a path — check weight signs, how many pairs, and whether the objective is sum vs max.
  • Approach: Dijkstra (weights ≥ 0): min-heap on cumulative distance. Bellman-Ford (negatives / negative-cycle detect): V-1 relax rounds + one extra. Floyd-Warshall (all pairs, small V): k outermost. Path counts: on equal best distance, add ways[u] into ways[v]. Min-bottleneck: Dijkstra keyed on max(edge) or binary search + BFS.
  • Watch out for: Running Dijkstra with a negative edge silently returns wrong answers; counting paths while skipping equal-cost arrivals undercounts; treating Swim-in-Rising-Water as MST optimizes the wrong objective.
  • Complexity: Dijkstra O((V+E) log V); Bellman-Ford O(V·E); Floyd-Warshall O(V³).
import heapq def dijkstra(n, graph, source): dist = [float('inf')] * n dist[source] = 0 heap = [(0, source)] visited = set() while heap: d, u = heapq.heappop(heap) if u in visited: continue visited.add(u) for v, w in graph[u]: if d + w < dist[v]: dist[v] = d + w heapq.heappush(heap, (dist[v], v)) return dist

15. Dynamic Programming

  • Signal: The problem asks for a max/min/count over a sequence, and the brute-force recursion calls itself with the same arguments repeatedly (overlapping subproblems) while still having optimal substructure.
  • Approach: Write the brute-force recursion, name the state in one sentence ("dp[i] means ..."), derive dp[i] from a small fixed window of earlier states, then memoize top-down or convert to bottom-up with a rolling array.
  • Watch out for: Leaving dp[i]'s meaning vague — if you can't state it precisely in one sentence, you will get the base case or the iteration direction wrong.
  • Complexity: O(n) time when the transition looks at a constant number of prior states (O(n²) if it needs a full backward scan), O(n) space reducible to O(1) via a rolling array.
def solve(i, memo={}): if i in memo: return memo[i] if i <= BASE: return base_value(i) # state: dp[i] = best result using/ending at index i result = combine(solve(i - 1, memo), solve(i - 2, memo)) memo[i] = result return result
  • Signal: You're selecting a subset of items under a capacity/target-sum constraint while optimizing, counting, or checking feasibility of some value — even disguised as partitioning or target-reaching.
  • Approach: Define dp[w] over the capacity/target dimension; put items on the outer loop and capacity on the inner loop, iterating capacity BACKWARD for 0/1 (each item used once) or FORWARD for unbounded (items reusable).
  • Watch out for: Getting the loop direction backwards — it never errors, it just silently returns a wrong-but-plausible number (backward on an unbounded problem undercounts, forward on a 0/1 problem overcounts).
  • Complexity: O(n × W) time, O(W) space with the 1-D rolling array — pseudo-polynomial, since it scales with the magnitude of W, not just the item count.
# 0/1 knapsack: each item usable once -> capacity BACKWARD dp = [0] * (capacity + 1) for wt, val in items: for w in range(capacity, wt - 1, -1): dp[w] = max(dp[w], dp[w - wt] + val) # Unbounded knapsack: item reusable -> capacity FORWARD dp = [0] * (capacity + 1) for wt, val in items: for w in range(wt, capacity + 1): dp[w] = max(dp[w], dp[w - wt] + val)
  • Signal: You're comparing/moving through two sequences (or a grid) at once, so a single index no longer captures the state — you need dp[i][j] over two prefixes or a cell position.
  • Approach: Size the table (len(s1)+1) x (len(s2)+1) so row/col 0 is the empty prefix; on a character match extend diagonally (dp[i-1][j-1]), on a mismatch combine the neighboring cells (max for LCS-style, min+1 for edit-distance-style).
  • Watch out for: Copying LCS's zero base case into an Edit-Distance-shaped problem (or vice versa) — Edit Distance needs dp[i][0]/dp[0][j] = i/j (all inserts/deletes), not 0.
  • Complexity: O(m × n) time and space for the full table, reducible to O(n) space via row-rolling — at the cost of losing the ability to reconstruct the answer.
dp = [[0] * (n + 1) for _ in range(m + 1)] for i in range(1, m + 1): for j in range(1, n + 1): if s1[i - 1] == s2[j - 1]: dp[i][j] = 1 + dp[i - 1][j - 1] # extend match else: dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]) # drop from either side
  • Signal: At each step you're in one of a small, fixed number of named modes (holding/not-holding, in-cooldown, ...), and the legal transitions between modes — not just a numeric index — constrain your choices.
  • Approach: Name each state explicitly, narrate the legal transition into each state as an English sentence, then write one recurrence per state in terms of the previous step's states; the answer is the best value across terminal-acceptable states at the end.
  • Watch out for: Conflating two genuinely different states into one (e.g. treating "just sold, in cooldown" as the same as "free and able to act") — this silently permits illegal transitions.
  • Complexity: O(n) time and O(1) space per state (rolling scalars) for a constant number of states; O(n·k) time and space if a dimension of size k (e.g. transaction count) is added.
hold, free = float("-inf"), 0 # impossible state seeded at -inf for price in prices: prev_hold, prev_free = hold, free # snapshot before overwriting hold = max(prev_hold, prev_free - price) # keep holding, or enter this state free = max(prev_free, prev_hold + price) # stay free, or enter this state
  • Signal: The DP's "positions" form a tree or graph instead of a line/grid — a node's optimal decision depends on combining results from multiple children (tree), or requires memoizing over an arbitrary DAG structure (graph).
  • Approach: For trees, run post-order DFS returning a tuple of states per subtree (e.g. included/excluded) and combine with each child's tuple; for DAGs, memoize a DFS keyed by node/cell, relying on a monotonicity constraint to guarantee acyclicity.
  • Watch out for: Returning a single value from a tree DFS when the parent needs to know whether the child was included/excluded — or applying memoized DFS to a graph without verifying it's actually acyclic.
  • Complexity: O(n) time/space for tree DP (each node visited once, total work O(n) across all parent-child edges); O(V + E) for memoized DAG DP (each state computed once).
def tree_dp(node): if node is None: return (0, 0) # (included, excluded) l_incl, l_excl = tree_dp(node.left) r_incl, r_excl = tree_dp(node.right) included = node.val + l_excl + r_excl # including forces children excluded excluded = max(l_incl, l_excl) + max(r_incl, r_excl) return (included, excluded)

16. Greedy

  • Signal: You're asked to optimize (min/max) over a sequence and, whenever you try to construct a counterexample where the locally best choice backfires, you can't — the best choice at each prefix never needs to be reconsidered once later elements are visible.
  • Approach: Sort by the key your correctness proof depends on (earliest finish time, ratio, a tie-break), then make one linear pass committing to each locally optimal, feasible choice and never revisiting it; justify with an exchange argument (swapping in your choice can't make an optimal solution worse) or greedy-stays-ahead (your running measure is never behind any other strategy's at any prefix).
  • Watch out for: Shipping the greedy without stating which argument justifies it and skipping the 60-90 second counterexample check — the bugs that slip through are almost always dependent-subproblem cases (this step's best choice depends on *which* earlier choices you made, not just how many), which is a DP smell greedy structurally can't handle.
  • Complexity: O(n log n) time (dominated by the sort), O(1) extra space
def greedy_scan(items, key): # Sort by the key your exchange-argument / greedy-stays-ahead # proof relies on (earliest finish time, ratio, ...). items.sort(key=key) state = init_state() # e.g. last_end, capacity_left, farthest chosen = [] for item in items: if is_compatible(state, item): # locally optimal + feasible chosen.append(item) state = advance(state, item) # commit, never revisit return chosen

17. Intervals

  • Signal: You're given start/end ranges and asked to merge, insert, check overlap, count concurrency, min arrows/rooms, or intersect two sorted lists — any scheduling/booking/coverage-over-a-range framing.
  • Approach: Overlap iff max(starts) ≤ min(ends) (state touching). Only two templates: (1) sort+scan — by start to merge/insert, by end to max non-overlap / min arrows (shoot at end); (2) +1/−1 sweep for concurrency (min-heap by end when you need which resource frees). Two sorted lists: advance the interval that ends first — never rewind. After sort, if B starts after A ends, discard A forever.
  • Watch out for: Wrong sort key (start vs end), or same-coordinate tie-break backwards (starts vs ends first) — both silently encode the wrong overlap semantics and look plausible instead of crashing.
  • Complexity: O(n log n) time (dominated by the sort), O(n) space for events/output — O(n) time only if input already arrives sorted (e.g. Insert Interval).
def sweep_line(intervals, ends_before_starts=True): events = [] for start, end in intervals: events.append((start, 1)) # +1 opens events.append((end, -1)) # -1 closes # tie-break controls whether touching endpoints count as overlap key = (lambda e: (e[0], e[1])) if ends_before_starts else (lambda e: (e[0], -e[1])) events.sort(key=key) active = peak = 0 for _, delta in events: active += delta peak = max(peak, active) return peak

18. Bit Manipulation

  • Signal: The problem states a numeric constraint like 'every element appears twice/three times except one,' 'values in range [0, n],' or 'a set of up to ~30 boolean flags' — that phrasing is a hint toward an XOR/mask trick instead of a hash set.
  • Approach: Reach for the core identities: XOR-fold a collection to cancel out values that occur an even number of times, use `x & (x - 1)` to clear (and, in a loop, count via Brian Kernighan) the lowest set bit, and use a `1 << i` mask to get/set/clear/toggle bit `i` or to represent a small subset.
  • Watch out for: Python integers are arbitrary-precision, so tricks that rely on fixed 32-bit wraparound (sum-without-carry, reversing bits) silently misbehave unless you explicitly mask with `& 0xFFFFFFFF` after every step and manually reconstruct the signed value afterward.
  • Complexity: O(1) per bitwise op on a fixed-width word; O(log V) when iterating bit-by-bit over a value up to V, or O(popcount) specifically for Brian Kernighan's loop.
def count_set_bits(x: int) -> int: count = 0 while x: x &= x - 1 # clear the lowest set bit count += 1 return count def xor_fold(nums: list[int]) -> int: result = 0 for n in nums: result ^= n # pairs cancel: x ^ x == 0 return result def get_bit(x, i): return (x >> i) & 1 def set_bit(x, i): return x | (1 << i) def clear_bit(x, i): return x & ~(1 << i) def toggle_bit(x, i): return x ^ (1 << i)

19. Math & Geometry

  • Signal: The problem mentions gcd/lcm/divisibility, primes below n or many primality queries, 'answer modulo 10^9 + 7', rotating or spiral-traversing a matrix in place, or reasoning about points/lines/rectangles where a naive float comparison would be risky.
  • Approach: Euclidean algorithm for gcd (divide before multiplying for lcm), sieve of Eratosthenes when you need many primality answers instead of trial division, square-and-halve modular exponentiation for mod pow, transpose-then-reverse-rows for 90° in-place rotation, four shrinking boundary pointers for spiral traversal, and cross products / cross-multiplication instead of floats for collinearity and slope comparisons.
  • Watch out for: Applying the modulo only at the end instead of after every intermediate operation (the overflow you were trying to avoid creeps back in), or trusting `n - i` vs `n - 1 - i` index math in rotation/spiral without hand-checking it against a tiny 2x2/3x3 example first.
  • Complexity: GCD O(log(min(a,b))); sieve O(n log log n); modular exponentiation O(log n); in-place rotation O(n²) time/O(1) space; spiral traversal O(m·n) time/O(1) extra space.
def gcd(a: int, b: int) -> int: while b: a, b = b, a % b return a def mod_pow(x: int, n: int, m: int) -> int: result = 1 x %= m while n > 0: if n & 1: result = (result * x) % m x = (x * x) % m n >>= 1 return result

20. Advanced String Algorithms

  • Signal: Any question about palindromic substrings — longest one, count them all, verify one after a small edit — reduces to "how far can I expand outward from this center", checked over every possible center.
  • Approach: For every one of the 2n-1 centers (n odd centers on a character, n-1 even centers between two characters), grow outward while both sides match. Precompute an O(n²) dp[i][j] palindrome table only when you need repeated, random-access checks (e.g. partitioning a string into palindromic pieces).
  • Watch out for: Forgetting even-length centers (only expanding from (i, i) and never (i, i+1)) silently misses every even-length palindrome like "abba".
  • Complexity: O(n²) time, O(1) space (the dp[i][j] table variant trades that for O(n²) space to make repeated substring checks O(1)).
def expand_around_center(s, left, right): while left >= 0 and right < len(s) and s[left] == s[right]: left -= 1 right += 1 return right - left - 1 # length of the palindrome found def longest_palindrome(s): start, best_len = 0, 1 for i in range(len(s)): for length in (expand_around_center(s, i, i), expand_around_center(s, i, i + 1)): if length > best_len: best_len = length start = i - (length - 1) // 2 return s[start:start + best_len]

21. Advanced Niche Algorithms

  • Signal: You need a dynamic, ordered set with O(log n) search/insert/delete and want to avoid balanced-tree rotation logic -- especially under concurrent access, where 'only touch local nodes' is a real design requirement.
  • Approach: Stack sparser and sparser sorted linked lists on top of a full base list; promote each new node to additional levels via independent coin flips (p=0.5 per level). Search from the top level, walking right until the next node would overshoot, then drop down a level -- repeat to the bottom.
  • Watch out for: Forgetting to splice a new/removed node into every level it appears on, not just the base list -- this breaks the 'each level is independently sorted' invariant that search correctness depends on.
  • Complexity: Expected O(log n) search, insert, and delete; O(n) expected space (each element promotes to ~2 levels on average at p=0.5).
def search(self, target): node = self.head for i in range(self.level, -1, -1): while node.forward[i] and node.forward[i].val < target: node = node.forward[i] node = node.forward[0] return node is not None and node.val == target
  • Signal: The problem interleaves point updates to an array with repeated range-aggregate queries (sum/min/max) — or, while scanning, asks 'how many previously-seen values are smaller/larger/within a range,' which is a coordinate-compression + Fenwick tree tell.
  • Approach: Use a Fenwick tree (BIT) for sum/XOR-style aggregates — index by `i & -i` (lowest set bit) to update/query in O(log n); use a segment tree instead for min/max/gcd or when you need range updates. Coordinate-compress values to dense ranks first whenever you need to index by value instead of array position.
  • Watch out for: Off-by-one between 0-indexed input and the Fenwick tree's required 1-indexing — index 0 is deliberately unused because `0 & -0 == 0` would infinite-loop the traversal.
  • Complexity: O(log n) per update and per query, O(n log n) to build from scratch (or O(n) with the linear-time build trick); O(n) space.
class FenwickTree: def __init__(self, n): self.tree = [0] * (n + 1) # 1-indexed; tree[0] unused def update(self, i, delta): while i < len(self.tree): self.tree[i] += delta i += i & (-i) def prefix_sum(self, i): total = 0 while i > 0: total += self.tree[i] i -= i & (-i) return total
  • Signal: You're parsing a nested/recursive grammar (arithmetic expressions, parentheses, a mini-language) rather than searching a fixed combinatorial structure, or a backtracking search space is so large that even correct pruning can't make it finish in time.
  • Approach: Parsing: one function per precedence level, lower precedence in the outer function, recurse back to the top-level function on '('. Regex/wildcard matching: backtrack on '*' by trying zero-occurrences and one-more-occurrence, then memoize on (text index, pattern index) once you notice repeated subproblems. Intractable search: switch from exhaustive backtracking to beam search -- expand every current candidate, score, keep only the top-k, discard the rest permanently.
  • Watch out for: Shipping a backtracking regex matcher against untrusted input without a step budget or automaton-based engine -- nested/adjacent quantifiers can trigger exponential-time catastrophic backtracking (ReDoS), a real, named production vulnerability class.
  • Complexity: Recursive descent parsing O(n) time; naive backtracking regex match O(2^n) worst case, O(n*m) once memoized; beam search O(depth * k * branching factor) time, O(k) space.
def is_match(text, pattern): if not pattern: return not text first_matches = bool(text) and pattern[0] in (text[0], ".") if len(pattern) >= 2 and pattern[1] == "*": return is_match(text, pattern[2:]) or ( first_matches and is_match(text[1:], pattern) ) return first_matches and is_match(text[1:], pattern[1:])
  • Signal: You need to connect every vertex as cheaply as possible in total (not point-to-point shortest paths) — 'wire up all the nodes for minimum total cost'.
  • Approach: Kruskal's: sort all edges ascending, accept an edge with Union-Find if and only if its endpoints aren't already connected. Prim's: grow one tree from a min-heap frontier, always pulling in the cheapest edge crossing into an unvisited vertex — same skeleton as Dijkstra, but the heap key is just the single edge weight, not cumulative distance from a source.
  • Watch out for: Confusing MST with shortest path: a vertex can be cheap to attach to the tree via one light edge while still being many hops and a large cumulative distance from an arbitrary source — MST minimizes total connection cost, not point-to-point distance.
  • Complexity: Kruskal's O(E log E) (sort-dominated); Prim's O(E log V) with a binary heap.
def kruskal_mst(n, edges): # edges = list of (weight, u, v) edges.sort() uf = UnionFind(n) total_weight = 0 edges_used = 0 for weight, u, v in edges: if uf.union(u, v): total_weight += weight edges_used += 1 return total_weight if edges_used == n - 1 else -1
  • Signal: You're optimizing over contiguous ranges and the natural recursion is "pick a split point, solve both halves, combine" — or, when left-to-right processing creates sub-ranges that still interact, reframe as "decide what happens last in this range."
  • Approach: Define dp[i][j] over the range [i, j], try every split point k, and iterate by increasing interval LENGTH first (then start index), since dp[i][j] depends on shorter sub-intervals with no fixed relationship to smaller i or j.
  • Watch out for: Filling the table row-major/column-major like grid DP — dp[i][j]'s dependencies are shorter intervals, not cells strictly above or to the left, so you'll read uncomputed cells.
  • Complexity: O(n²) states × O(n) split points per state = O(n³) time, O(n²) space (Knuth's optimization can bring specific recurrences down to O(n²)).
dp = [[0] * n for _ in range(n)] # base case: dp[i][i] for length-1 intervals for length in range(2, n + 1): # outer: interval length for i in range(0, n - length + 1): # inner: start index j = i + length - 1 dp[i][j] = float("inf") for k in range(i, j): # split point dp[i][j] = min(dp[i][j], dp[i][k] + dp[k + 1][j] + cost(i, k, j))
  • Signal: You're repeatedly combining the two 'smallest' remaining items (by frequency, weight, or cost) and paying a cost proportional to what you combine — or you're asked to justify why a cache eviction policy is or isn't optimal.
  • Approach: For Huffman-shaped problems: push every item into a min-heap, then repeatedly pop the two smallest, merge them (sum the weight, cost += sum), and push the merged item back until one remains. For caching questions: name Bélády's MIN (evict what's needed furthest in the future) as the theoretical optimum, then explain that it's unimplementable online because it needs the future — which is why LRU/LFU/ARC exist as heuristics instead.
  • Watch out for: Re-sorting from scratch after every merge instead of using a heap (O(n^2 log n) instead of O(n log n)) — or claiming LRU is 'the optimal caching algorithm' rather than a heuristic approximation of Bélády's MIN.
  • Complexity: O(n log n) time, O(n) space for Huffman-style heap merging.
import heapq def min_merge_cost(weights): heapq.heapify(weights) total = 0 while len(weights) > 1: a, b = heapq.heappop(weights), heapq.heappop(weights) total += a + b heapq.heappush(weights, a + b) return total
  • Signal: You're asked to find the two nearest points among a set (collision/proximity detection), or the minimal boundary/polygon enclosing a set of points -- and n is large enough that the O(n^2) brute-force pairwise check is explicitly called out as too slow.
  • Approach: Closest pair: sort by x, split in half, recurse on each half, then only check cross-boundary pairs within a y-sorted strip of width 2d (each point needs at most ~6-7 comparisons there). Convex hull: sort points lexicographically, then build the lower and upper hull independently with a stack, popping any point that makes a non-left turn (cross product <= 0) with the two points before it.
  • Watch out for: Closest pair: comparing every point in the left half against every point in the right half instead of restricting to the narrow y-sorted strip -- this silently degrades back to O(n^2). Convex hull: using the wrong cross-product sign convention (<= 0 vs < 0) for whether collinear boundary points should be included.
  • Complexity: O(n log n) time, O(n) space for both algorithms (dominated by the initial sort).
def cross(o, a, b): return (a[0]-o[0])*(b[1]-o[1]) - (a[1]-o[1])*(b[0]-o[0]) def half_hull(pts): hull = [] for p in pts: while len(hull) >= 2 and cross(hull[-2], hull[-1], p) <= 0: hull.pop() hull.append(p) return hull
  • Signal: You need substring/pattern search with a guaranteed O(n) worst case (not just average case), or you need prefix-match-length information at every position — periodicity, "is this string a rotation of that one", or "does a length-k window repeat".
  • Approach: KMP: precompute the LPS (longest proper prefix-that's-also-a-suffix) array so a mismatch falls the pattern pointer back instead of ever moving the text pointer backward, giving O(n+m) worst-case; Rabin-Karp: roll a hash across every window and compare hashes (always verifying a hit with a direct comparison) for O(n+m) average case, which shines for multi-pattern search; Z-function: generalizes the LPS idea to "longest prefix of the whole string matching at position i", for every i.
  • Watch out for: Skipping the direct-comparison verification after a Rabin-Karp hash match — a collision without verification silently produces wrong answers on exactly the inputs that matter.
  • Complexity: KMP / Z-function: O(n+m) time worst-case, O(m) or O(n) space. Rabin-Karp: O(n+m) time average case (O(n·m) worst case), O(1) space beyond the rolling hash.
def build_lps(pattern): m = len(pattern) lps = [0] * m length = 0 # length of current matched prefix/suffix i = 1 while i < m: if pattern[i] == pattern[length]: length += 1 lps[i] = length i += 1 elif length != 0: length = lps[length - 1] # reuse, don't advance i else: lps[i] = 0 i += 1 return lps
  • Signal: An interviewer explicitly asks for a guaranteed-linear-time palindromic-substring algorithm instead of the O(n²) expand-around-center default.
  • Approach: Interleave the string with '#' separators so every palindrome becomes odd-length, then for each position reuse the mirror position's already-verified radius (capped at the current right boundary) before attempting to expand further — never re-examine a character that symmetry already told you about.
  • Watch out for: Reaching for Manacher's by default instead of leading with expand-around-center — given the difficulty-to-frequency ratio, that instinct reads as over-engineering far more often than it reads as impressive.
  • Complexity: O(n) time, O(n) space (the transformed string and the radius array).
def manacher(s): t = '#' + '#'.join(s) + '#' n = len(t) radius = [0] * n center = right = 0 for i in range(n): if i < right: mirror = 2 * center - i radius[i] = min(right - i, radius[mirror]) while (i - radius[i] - 1 >= 0 and i + radius[i] + 1 < n and t[i - radius[i] - 1] == t[i + radius[i] + 1]): radius[i] += 1 if i + radius[i] > right: center, right = i, i + radius[i] return radius, t