Techniques
Each section is a technique the Patterns table points to — the how behind the what.
Two pointers
Walk two indices instead of a nested loop — turns many O(n²) scans into O(n). Four shapes:
- From the ends (sorted array, palindrome, container):
lo=0, hi=n-1; move the pointer that can't improve. Sorted pair-sum →sum<target↑lo,>target↓hi. Max-container → move the shorter side. LC 345, 11. - Same direction, read + write (compact/dedupe in place):
writelagsread, copy forward only what you keep. O(1) space. LC 443, 283. - One per sequence (merge / subsequence match): advance whichever needs it. LC 392.
- Slow / fast (linked list, or array as functional graph): different speeds find the midpoint, nth-from-end, or a cycle (Floyd's — they meet inside the cycle). LC 2095, 2130.
Sliding window
Substring/subarray with a constraint, in O(n): expand right to include elements; when the window violates the constraint, shrink from left. Track window state (a count, Counter, or sum) incrementally — don't rebuild it. Fixed-size: slide both ends together. LC 643, 1456, 1004, 1493. For a sliding time window over a stream, use a deque (append new, popleft expired). LC 933.
Prefix sum
prefix[i] = a scalar aggregate (sum/product/max/XOR) over input[:i] — NOT a sub-array. Range sum [i, j) = prefix[j] - prefix[i] in O(1) after O(n) build. Pair with a hash map for "subarray summing to K" (store seen prefix sums, look for prefix - K). Range updates → Fenwick/BIT. LC 238.
Binary search templates
Canonical "find exact value" — three-way branch, closed interval [lo, hi].
def binary_search(target, lo, hi):
while lo <= hi: # `<=` so lo == hi gets checked
mid = (lo + hi) // 2
if arr[mid] == target:
return mid # early-exit on hit
elif arr[mid] < target:
lo = mid + 1 # answer is to the right
else:
hi = mid - 1 # answer is to the left
return -1 # not found
Bisect-left / "find leftmost insertion point" — predicate-based, half-open interval [lo, hi). Generalizes to "find leftmost index where some monotonic predicate is True."
def bisect_left(target, arr):
lo, hi = 0, len(arr) # hi = len, exclusive
while lo < hi: # `<`, not `<=`
mid = (lo + hi) // 2
if arr[mid] < target:
lo = mid + 1 # answer is strictly to the right
else:
hi = mid # mid might still be the answer — keep in range
return lo # lo == hi at exit
Three key shifts from canonical: half-open interval (hi = len), lo < hi, hi = mid (not mid - 1), no early return.
# Python stdlib — use these when allowed; hand-roll when asked
import bisect
bisect.bisect_left(arr, x) # leftmost insertion point (arr[i] >= x)
bisect.bisect_right(arr, x) # rightmost insertion point (arr[i] > x)
bisect.insort_left(arr, x) # actually insert, maintain sort (mutates)
# left vs right matters on duplicates:
# arr=[1,3,5,5,5,7]; bisect_left(arr,5)=2, bisect_right(arr,5)=5
Predicate-based template generalization: when check(mid) returns True/False and is monotonic (True-then-False or False-then-True over the range), bisect_left finds the boundary. Same template works for: first peak (LC 162), min capacity that fits k partitions (LC 875 Koko), min/max satisfying some constraint.
Hashing (map / set)
Trade a scan for a hash (cost nuance in Data Structures). Shapes:
- Map of complements — one pass, look up
target - x(two-sum family). - Frequency / anagram —
Counter;Counter(a) == Counter(b)is a one-line anagram check. LC 1207, 1657. - Set ops — membership / difference / intersection with
&,-,|. LC 2215. - Hash a tuple — match identical sequences across collections by keying on
tuple(seq). LC 2352.
Heap / top-k
heapq (min-heap; API in Data Structures). Recurring patterns:
- Size-k heap for top-k / kth largest — keep a min-heap of the k largest seen; its top is the kth largest. Mirror (negate) for k smallest. O(n log k). LC 215.
- Two heaps for a streaming median — max-heap (lower half) + min-heap (upper half), kept balanced. LC 295.
- Heap + set + frontier counter — ordered extraction + custom ops over a huge/infinite domain without materializing it. LC 2336, 2462, 2542.
Stack & monotonic stack
- Plain stack — "cancel / match the most recent" (parens, undo, collisions). LC 2390.
- Monotonic stack — "for each item, the nearest greater/smaller to one side." Keep the stack increasing or decreasing; pop everything the new element beats, and the pop is when you record the answer (distance
i - popped_i). Each element pushed/popped once → amortized O(n) despite the nestedwhile. Store indices, or(value, span)tuples for streaming spans. Idiom: fold the compare intowhile stack and …:, nobreak. LC 739, 901.
Intervals
Sort first, then sweep once — the sort key is the whole game:
- Merge / overlap — sort by start, track the current merged block (accumulator that grows); overlap iff
next.start <= cur.end. - Greedy selection (remove fewest to make disjoint / min arrows) — sort by end, keep one scalar boundary; keep-or-skip on
start vs boundary. Earliest-finisher is the safest commit. LC 435, 452. - Concurrency (max overlap / min rooms) — min-heap of end times, or a chronological start/end event sweep.
Recursion / divide & conquer
Split into self-similar sub-problems, solve, combine: Euclidean GCD, merge sort, quickselect, "halve the range." Watch recursion depth (Python's default limit is 1000). LC 1071.
Backtracking
Enumerate permutations / combinations / subsets: recurse, try each choice, undo before the next (push/pop a shared mutable path). Complexity is output-bounded (O(n!) or O(2ⁿ)). For combinations (order-free, no dups), pass a start index and iterate range(start, end) — dedupes by construction. LC 17, 216, 46.
def backtrack(path, choices):
if is_complete(path):
results.append(path[:]) # snapshot — copy, don't store the live list
return
for c in choices:
path.append(c)
backtrack(path, remaining(choices, c))
path.pop() # undo
Iterative graph traversal templates
Two shapes, one per algorithm. Naming matches what the set actually contains.
# DFS — mark on POP, name `visited`. Stack may briefly hold dupes; pop-check catches them.
visited = set()
stack = [(start, initial_state)] # state = whatever per-path value you carry (depth, product, etc.)
while stack:
node, state = stack.pop()
if node in visited: # load-bearing dedupe guard
continue
visited.add(node)
if node == target:
return state # found
for nbr, edge_data in graph[node]:
if nbr not in visited: # optional push-time optimization (smaller stack)
stack.append((nbr, combine(state, edge_data)))
# stack exhausted, target unreachable
# BFS — mark on PUSH, name `seen`. Required for level tracking (len(queue) must reflect unique nodes).
from collections import deque
seen = {start} # seed marking — required because we mark before pop
queue = deque([start])
depth = 0
while queue:
for _ in range(len(queue)): # snapshot one level; correctness depends on no dupes
node = queue.popleft()
if node == target:
return depth
for nbr in graph[node]:
if nbr not in seen:
seen.add(nbr)
queue.append(nbr)
depth += 1
# queue exhausted, target unreachable
Why the split: BFS's level snapshot (for _ in range(len(queue))) requires the queue to contain exactly one entry per unique node at that level, so mark-on-push is mandatory. DFS has no level concept, and mark-on-pop avoids the seed-marking step. Pick the template that matches the algorithm; don't mix.
Trees
Almost always DFS or BFS over TreeNode.
- DFS (recursive) — traversal (pre/in/post-order), depth, leaf collection. LC 104, 872.
- DFS carrying state down via an extra parameter (max-so-far, running sum, ancestors). LC 1448.
- Path-sum / ancestor-window — DFS + a mutable
pathwith append-recurse-pop. LC 437. - Bottom-up DFS — return a per-subtree value to the parent, update a global max along the way ("answer derivable from children"). LC 1372, 236.
- LCA — DFS returns the found node up; current node is the answer if both sides report a find. LC 236.
- BFS — level-order, first/last per level (
len(queue)snapshot), shortest path on unweighted tree/grid. LC 199, 1161, 1926. - BST — exploit ordering: compare at the node, go left if smaller, right if larger. LC 700.
- Mutating a tree (delete/insert/trim) — recurse so each call returns the new subtree root; parent reassigns
node.left = recurse(node.left, …). Relinking is free, no parent tracking. LC 450.
Trie
Prefix tree for "repeated prefix matches in a word set." Two-class composition: a Trie holding TrieNodes (children = {} dict + is_end bool); the character is the dict key, not stored on the node. All ops O(L). Walk-or-create: node = node.children.setdefault(c, TrieNode()). Autocomplete (top-k lex-smallest per prefix): walk the prefix, then bounded-DFS sorted(node.children) collecting ≤k — or precompute ≤k refs per node during a sorted insert. LC 208, 1268. (Class-design detail in python_concepts.md.)
2D grids
# Init a 2D grid of zeros (m rows, n cols).
grid = [[0] * n for _ in range(m)] # CORRECT — m independent rows
# DO NOT DO THIS — bug source:
grid = [[0] * n] * m # WRONG — m references to ONE row
# grid[0][1] = 1 mutates every row!
# Reason: `*` copies references. Inner [0]*n is one mutable list; outer * m
# just creates pointers to it. List comprehension re-evaluates the inner
# expression each iteration, producing independent lists.
# Iterate over a grid:
for r in range(len(grid)):
for c in range(len(grid[0])):
...
# Four-directional moves (up, down, left, right) — canonical:
for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
nr, nc = r + dr, c + dc
if 0 <= nr < m and 0 <= nc < n:
... # in-bounds neighbor
# Eight-directional (with diagonals): [(-1,-1),(-1,0),(-1,1),(0,-1),(0,1),(1,-1),(1,0),(1,1)]
Dynamic programming
When the recurrence isn't obvious, use the decision-tree-with-state framing:
- Decision space at each step? Buy/sell/hold; place a tile; pick a digit; include or skip.
- What constrains which decisions are legal? Can only sell if holding; can't pick adjacent houses.
- The thing limiting legality = your extra state dimension beyond the step index. Depends only on the step → 1D; depends on "holding?" / "yesterday's color?" / "capacity left?" → another dimension.
- Write
solve(step, state)— try every legal action, recurse on(next_step, new_state), combine today's impact with the return; take max/sum/count. @cacheit. State spaceO(n × |states|), so memoization collapses the 2ⁿ tree to polynomial.
solve(i, state) returns "the optimal value from step i to the end, given the current state" — self-similar, no global accumulator. Reach for top-down @cache first — easier to derive than the bottom-up table, identical complexity.
Recurrence shapes seen:
- Linear recurrence from the last k values → memo → bottom-up table → O(1) rolling scalars (
a, b, c = b, c, a+b+c). LC 1137, 746. - Pick-or-skip with a gap (no-adjacent, cooldown) →
dp[i] = max(dp[i-1], nums[i] + dp[i-2]); the index-skip smuggles the constraint into the math. LC 198. - 2D grid paths →
dp[i][j] = combine(dp[i-1][j], dp[i][j-1]); border init; rolling-row to O(min(m,n)). LC 62. - Align two sequences (LCS, edit distance) →
dp[i][j]over suffixes; match → diagonal (cost 0), no-match → min/max over advance-one/advance-both. Pass indices, not slices to@cache. Match cost is 0, not 1 — a spurious1 + dp(i+1,j+1)on the match branch is the classic bug (trace"a"/"a"). LC 1143, 72. - State-augmented (tiling complete-vs-jagged; buy/sell/hold; paint-house) → parallel arrays per state, transition via casework, read the answer from the canonical end-state. LC 790, 714.
- Count mod a prime (10⁹+7) → apply mod at every
+/×(it distributes); skipping it makes bignum ops O(n) → O(n²). LC 790.
Siblings: knapsack (state = remaining capacity), regex/wildcard (state = position in pattern), cooldown / k-transaction stock (LC 309, 188).
Greedy
Commit to the locally-optimal choice, never reconsider. Valid only when:
- Greedy choice property — the obvious-best choice now is part of some global optimum.
- Optimal substructure — what's left after the choice is the same problem (shared with DP; greedy commits, DP explores).
When to try it: maximize/minimize a count under a constraint; a natural ordering (sort by end/deadline/ratio) makes the best pick obvious; choices don't entangle with the future.
Confirm before trusting:
- Exchange argument — can any optimal solution be slid toward your greedy choice without loss? (Interval tell: the earliest-finisher costs the least future room.)
- Hunt counterexamples ~60s. Classic failure: coin change
[1,3,4]make 6 → greedy4+1+1(3), optimum3+3(2). Intervals work; arbitrary coins don't. - If it breaks → DP (same optimal substructure, right next door). LC 605, 334, 435, 452.
Bit manipulation
Operators + the idioms that recur. Negative-int behavior (infinite two's complement, bin() display, & 0xFFFFFFFF masking) lives in python_concepts.md.
# OPERATORS
a & b # AND — 1 where both bits 1 5 & 3 = 1
a | b # OR — 1 where either bit 1 5 | 3 = 7
a ^ b # XOR — 1 where bits differ 5 ^ 3 = 6
~a # NOT — flip all bits ~5 = -6 (identity: ~n == -n - 1)
a << k # left shift — ×2^k
a >> k # right shift — //2^k (floor)
# CORE IDIOMS (memorize — these recur)
n & 1 # is n odd? (lowest bit set?)
n >> 1 # drop the lowest bit (== n // 2)
n & (n - 1) # clear the LOWEST set bit → popcount loop; power-of-2 check ((n & n-1)==0)
n & -n # ISOLATE the lowest set bit
x ^ x == 0 # self-cancel; x ^ 0 == x (identity)
# READ / SET / CLEAR bit i
(n >> i) & 1 # read bit i
n |= (1 << i) # set bit i
n &= ~(1 << i) # clear bit i
# POPCOUNT (number of set bits)
bin(n).count('1') # cheap one-liner
n.bit_count() # Py3.10+ builtin
# Brian Kernighan: while n: n &= n - 1; count += 1 → O(#set bits), not O(#bits)
# DP recurrence over 0..n: bits[i] = bits[i >> 1] + (i & 1)
# XOR "find the loner" — every element paired except one:
acc = 0
for x in nums: acc ^= x # pairs cancel (x^x=0); the unpaired value survives
Sorting & custom keys
# sorted() returns a NEW list; list.sort() mutates in place and returns None (don't assign it)
sorted(xs) # ascending, new list
sorted(xs, reverse=True) # descending
xs.sort() # in place
# key= : sort by a DERIVED value (the key function is called once per element)
sorted(words, key=len) # by length
sorted(intervals, key=lambda x: x[1]) # by END ← the intervals-greedy pattern
sorted(intervals, key=lambda x: x[0]) # by start
sorted(pairs, key=lambda p: p[1], reverse=True)
# lambda = anonymous inline function: `lambda args: expr` — body is ONE expression, implicit return.
key=lambda x: x[1] # exactly equivalent to: def f(x): return x[1]
# MULTI-KEY — return a TUPLE: sort by first element, ties broken by second, then third, ...
sorted(people, key=lambda p: (p.age, p.name)) # age asc, then name asc
sorted(items, key=lambda x: (-x.score, x.name)) # score DESC, then name ASC
# negate a numeric field to flip just that field's direction inside the tuple
# STABILITY — Python's sort is stable: equal keys keep their original relative order.
# lets you multi-pass: sort by secondary key first, then by primary key.
# key= also works on min / max (and heapq.nlargest / nsmallest):
max(intervals, key=lambda x: x[1] - x[0]) # widest interval
min(words, key=len)
# GOTCHA: heapq.heappush/heappop have NO key= param — push (sort_key, item) tuples instead.
# operator.itemgetter / attrgetter — faster, cleaner than lambda for plain field access:
from operator import itemgetter, attrgetter
sorted(intervals, key=itemgetter(1)) # same as lambda x: x[1]
sorted(people, key=attrgetter('age'))
Indexing & slicing
s[i]past the end raisesIndexError— no null/empty default for single-index access.- Slicing IS out-of-bounds tolerant —
s[100:200]returns'',s[start:]returns''whenstart >= len(s). - Slice syntax
seq[start:stop:step], negative indices count from end,s[::-1]reverses. - Terminology trap: "prefix array" in algorithms ≠ a slice. It's a same-length helper where
prefix[i]is a scalar aggregate (sum/product/max/XOR) over the input's prefix, not a sub-array. See pattern table. - Slice + builtin = idiomatic aggregate over a sub-range:
sum(nums[:k]),max(nums[i:j]),min(nums[-3:]),any(nums[i:j]). Cleaner than a manual loop accumulator. Use freely for one-off initial sums (e.g., sliding-window warm-up).
Iteration
- An iterator is an object that produces values one at a time, only when asked (lazy), and is exhausted after a single pass — can't rewind or re-loop.
- An iterable is anything you can loop over (list, str, dict, etc.); the
forloop turns it into a fresh iterator each time, which is why you can re-loop over a list but not over azip(...). - Iterators can't be indexed or
len()-ed — calllist(it)to materialize when you need either. - Built-ins that return iterators (not lists) in Py3:
zip,map,filter,range,enumerate,reversed, generator expressions, file objects. zip(a, b, ...)lets you loop over multiple sequences in parallel — each iteration gives you one element from each input.zipstops at the shortest input — extra elements in longer inputs are silently dropped.itertools.zip_longest(a, b, fillvalue=...)pads to the longer sequence instead.zip(*matrix)transposes a 2D list —*unpacks rows as separate args,zippairs them column-wise; yields column tuples. Wrap inlist(...)if you need a list of tuples.- Prefer
for x, y in zip(...)overfor i in range(len(...))when you don't need the index. range(start, stop, step)—stopis always exclusive, including with negative step;range(n, 0, -1)to count down to 1,range(n, -1, -1)to count down to 0.- List comprehension
[expr for x in iterable]replacesresult = []; for x in iterable: result.append(expr); addif condto filter:[expr for x in it if cond]. enumerate(iterable, start=0)yields(index, value)pairs — idiomatic when you need both, instead offor i in range(len(...)): x = seq[i].
Interview hygiene
- Always state time and space complexity for any solution.
- For generation/decompression/enumeration problems, complexity is output-bounded (O of output size), not input-bounded. Stating it in input length alone is often exponential and misleading — the algorithm is optimal for the output it produces. Examples: LC 394 Decode String (O(D) where D = decoded length), all-subsets (O(2^N) = O(output)), permutations (O(N!) = O(output)).
- Space complexity measures peak simultaneous memory, not cumulative allocation. Per-iteration transient allocations (local
set(...), temp list) get freed before the next iteration starts, so peak stays at one copy's worth — they're a time cost, not a space cost. They only "stick" if you store them somewhere (results.append(...)). - Don't mutate input arguments. Even when it simplifies the code —
cost.append(0)to unify a DP recurrence,nums.sort()to enable a two-pointer pass. Caller didn't consent. Prefer handle-at-boundary (return min(a, b)instead of phantom append) or copy-first (nums = sorted(nums)). If you absolutely need to mutate, call it out: "I'd normally copy first, but for O(1) space I'm mutating in place — is that OK?"