FAANG Practice Problems
A curated archive of the interview questions that actually get asked — every problem worked end-to-end from brute force to optimal, with complexity analysis, key insight, and common variants.
Arrays & Strings
The workhorse category. Almost every interview opens here. Master two-pointer, sliding-window, prefix-sum, and hash-assisted scans and you will recognize half of all array questions on sight.
Pattern Cheat
| Signal | Pattern | Complexity |
|---|---|---|
| "find pair/triplet summing to target" | Sort + two pointers, or hash map | O(n log n) or O(n) |
| "contiguous subarray / substring" | Sliding window | O(n) |
| "product / sum of prefix" | Prefix-sum / prefix-product | O(n) |
| "k-th largest / smallest" | Heap / Quick-Select | O(n log k) / O(n) |
| "in-place shuffle / rotate" | Reverse in place / cycle walk | O(n) time O(1) space |
Problem 1: Two Sum — I / II / IIIeasy
Statement (I)
Given an array nums and a target, return indices i != j with nums[i] + nums[j] == target. Exactly one solution exists.
Approach 1 — Brute Force
Try every pair. Quadratic, but a great baseline to prove correctness before optimizing.
def two_sum_brute(nums, target):
n = len(nums)
for i in range(n):
for j in range(i + 1, n):
if nums[i] + nums[j] == target:
return [i, j]
return []
Two nested scans, constant extra memory.
Approach 2 — Hash Map (one pass)
For each element x, look up target - x. Store value -> index as you go so you only scan once.
def two_sum(nums, target):
seen = {}
for i, x in enumerate(nums):
if target - x in seen:
return [seen[target - x], i]
seen[x] = i
return []
Hash lookup is amortized O(1), so a single scan suffices.
Variant II — input is sorted
Use two pointers; no extra memory needed.
def two_sum_sorted(nums, target):
l, r = 0, len(nums) - 1
while l < r:
s = nums[l] + nums[r]
if s == target: return [l + 1, r + 1] # 1-indexed
if s < target: l += 1
else: r -= 1
return []
Sortedness lets monotone pointer moves eliminate half the space per step — here linearly.
Variant III — streaming design
Design add(x) and find(t) on a stream. Multiset lets add be O(1) and find linear; this matches the typical interview ask that trades one op for the other.
class TwoSumStream:
def __init__(self):
from collections import Counter
self.cnt = Counter()
def add(self, x):
self.cnt[x] += 1
def find(self, t):
for x, c in self.cnt.items():
need = t - x
if need == x and c >= 2: return True
if need != x and self.cnt.get(need, 0) > 0: return True
return False
Problem 2: Longest Substring Without Repeating Charactersmedium
Statement
Return the length of the longest substring of s in which no character repeats.
Approach 1 — Check Every Substring
def length_brute(s):
best = 0
for i in range(len(s)):
seen = set()
for j in range(i, len(s)):
if s[j] in seen: break
seen.add(s[j])
best = max(best, j - i + 1)
return best
Each start index walks forward; substring-reset is the waste.
Approach 2 — Sliding Window with Last-Seen Map
Keep left as window start. For each right, if s[right] was seen at index k >= left, jump left = k + 1. Else update best.
def length_of_longest(s):
last = {}
left = best = 0
for right, ch in enumerate(s):
if ch in last and last[ch] >= left:
left = last[ch] + 1
last[ch] = right
best = max(best, right - left + 1)
return best
Each char enters and exits the window at most once.
Problem 3: Container With Most Watermedium
Statement
Given heights, choose two indices forming a container; maximize min(h[i], h[j]) * (j - i).
Approach 1 — All Pairs
def max_area_brute(h):
best = 0
for i in range(len(h)):
for j in range(i + 1, len(h)):
best = max(best, min(h[i], h[j]) * (j - i))
return best
Approach 2 — Two Pointers
Start at both ends. The shorter wall caps the area; moving it inward is the only move that could improve things.
def max_area(h):
l, r, best = 0, len(h) - 1, 0
while l < r:
best = max(best, min(h[l], h[r]) * (r - l))
if h[l] < h[r]: l += 1
else: r -= 1
return best
Inward move on the shorter side is the greedy invariant.
Problem 4: Trapping Rain Waterhard
Statement
Given a histogram of heights, how much water can be trapped after rain?
Approach 1 — For each bar, scan both sides
def trap_brute(h):
ans = 0
for i in range(len(h)):
lmax = max(h[:i+1]); rmax = max(h[i:])
ans += min(lmax, rmax) - h[i]
return ans
Approach 2 — Precompute prefix/suffix max
def trap_prefix(h):
n = len(h)
L = [0]*n; R = [0]*n
cur = 0
for i in range(n): cur = max(cur, h[i]); L[i] = cur
cur = 0
for i in range(n-1, -1, -1): cur = max(cur, h[i]); R[i] = cur
return sum(min(L[i], R[i]) - h[i] for i in range(n))
Two linear scans build the bounding walls.
Approach 3 — Two Pointers (O(1) memory)
Whichever side has the smaller running max is the binding constraint on the other side's cell.
def trap(h):
l, r = 0, len(h) - 1
lmax = rmax = ans = 0
while l < r:
if h[l] < h[r]:
if h[l] >= lmax: lmax = h[l]
else: ans += lmax - h[l]
l += 1
else:
if h[r] >= rmax: rmax = h[r]
else: ans += rmax - h[r]
r -= 1
return ans
Two pointers replace the prefix arrays once we realize only the smaller side matters.
Problem 5: 3Summedium
Statement
Return all unique triplets summing to zero.
Approach 1 — Triple Loop + Set
def three_sum_brute(nums):
seen = set(); n = len(nums); nums.sort()
for i in range(n):
for j in range(i+1, n):
for k in range(j+1, n):
if nums[i]+nums[j]+nums[k] == 0:
seen.add((nums[i], nums[j], nums[k]))
return [list(t) for t in seen]
Approach 2 — Sort + Two Pointers
def three_sum(nums):
nums.sort()
res, n = [], len(nums)
for i in range(n - 2):
if nums[i] > 0: break
if i > 0 and nums[i] == nums[i-1]: continue
l, r = i + 1, n - 1
while l < r:
s = nums[i] + nums[l] + nums[r]
if s < 0: l += 1
elif s > 0: r -= 1
else:
res.append([nums[i], nums[l], nums[r]])
while l < r and nums[l] == nums[l+1]: l += 1
while l < r and nums[r] == nums[r-1]: r -= 1
l += 1; r -= 1
return res
sort then fix one element and two-pointer the rest.
Problem 6: Maximum Subarray (Kadane)medium
Statement
Find the contiguous subarray with the largest sum.
Approach — Kadane (DP collapsed)
def max_subarray(nums):
best = cur = nums[0]
for x in nums[1:]:
cur = max(x, cur + x)
best = max(best, cur)
return best
At each step, decide: extend the prior positive-sum window, or restart at x.
Problem 7: Product of Array Except Selfmedium
Statement
Return an array out where out[i] = product of nums[j] for j != i. No division allowed; O(n) time, O(1) extra.
Approach — Two Passes with Running Products
def product_except_self(nums):
n = len(nums)
out = [1] * n
left = 1
for i in range(n):
out[i] = left
left *= nums[i]
right = 1
for i in range(n - 1, -1, -1):
out[i] *= right
right *= nums[i]
return out
(output array excluded from space by convention).
Problem 8: Longest Palindromic Substringmedium
Statement
Return the longest palindromic substring of s.
Approach 1 — DP on (i,j)
dp[i][j] = s[i] == s[j] and (j - i < 2 or dp[i+1][j-1]).
def longest_palindrome_dp(s):
n = len(s)
dp = [[False]*n for _ in range(n)]
best = ""
for length in range(1, n+1):
for i in range(n - length + 1):
j = i + length - 1
if s[i] == s[j] and (length < 3 or dp[i+1][j-1]):
dp[i][j] = True
if length > len(best): best = s[i:j+1]
return best
Approach 2 — Expand Around Center
def longest_palindrome(s):
def grow(l, r):
while l >= 0 and r < len(s) and s[l] == s[r]:
l -= 1; r += 1
return s[l+1:r]
best = ""
for i in range(len(s)):
for p in (grow(i, i), grow(i, i+1)):
if len(p) > len(best): best = p
return best
fastest path for practical n; Manacher is O(n) but rarely required live.
Problem 9: String to Integer (atoi)medium
Statement
Parse an integer from a string. Skip leading whitespace, optional sign, consume digits, stop at non-digit, clamp to 32-bit range.
Approach — State Machine
INT_MIN, INT_MAX = -2**31, 2**31 - 1
def my_atoi(s):
i, n = 0, len(s)
while i < n and s[i] == ' ': i += 1
sign = 1
if i < n and s[i] in '+-':
sign = -1 if s[i] == '-' else 1
i += 1
val = 0
while i < n and s[i].isdigit():
val = val * 10 + (ord(s[i]) - ord('0'))
if val * sign >= INT_MAX: return INT_MAX
if val * sign <= INT_MIN: return INT_MIN
i += 1
return val * sign
Problem 10: Rotate Imagemedium
Statement
Rotate an n×n matrix 90° clockwise, in place.
Approach — Transpose then Reverse Each Row
def rotate(M):
n = len(M)
for i in range(n):
for j in range(i + 1, n):
M[i][j], M[j][i] = M[j][i], M[i][j] # transpose
for row in M:
row.reverse()
Clockwise = transpose + horizontal flip. Counter-clockwise = transpose + vertical flip.
Linked Lists
Pointer mechanics under pressure. Almost every list problem decomposes to: dummy node, two pointers (slow/fast), in-place reversal, or hash-of-nodes.
Technique Primer
- Dummy node — removes head-vs-body edge cases.
- Slow/Fast — middle, cycle detection, Nth-from-end.
- In-place reverse — three-pointer flip: prev, curr, next.
- Node-map — when nodes need copying or cross-referencing.
Problem 11: Reverse Linked Listeasy
Statement
Reverse a singly linked list.
Iterative Three-Pointer
def reverse_list(head):
prev, curr = None, head
while curr:
nxt = curr.next
curr.next = prev
prev = curr
curr = nxt
return prev
Recursive
def reverse_rec(head):
if not head or not head.next: return head
new_head = reverse_rec(head.next)
head.next.next = head
head.next = None
return new_head
Stack depth = list length; can blow recursion limits on long lists.
Problem 12: Add Two Numbersmedium
Statement
Two numbers are stored as linked lists, digits reversed (least-significant first). Return the sum as a list.
Dummy + Carry
def add_two(l1, l2):
dummy = tail = ListNode(0)
carry = 0
while l1 or l2 or carry:
d1 = l1.val if l1 else 0
d2 = l2.val if l2 else 0
s = d1 + d2 + carry
carry, digit = divmod(s, 10)
tail.next = ListNode(digit); tail = tail.next
if l1: l1 = l1.next
if l2: l2 = l2.next
return dummy.next
Problem 13: Merge K Sorted Listshard
Statement
Merge k sorted lists of total n elements.
Approach 1 — Pairwise Merge
Merge pairs repeatedly until one list remains.
Approach 2 — Min-Heap of Heads
import heapq
def merge_k(lists):
h = []
for i, node in enumerate(lists):
if node: heapq.heappush(h, (node.val, i, node))
dummy = tail = ListNode(0)
while h:
_, i, node = heapq.heappop(h)
tail.next = node; tail = node
if node.next:
heapq.heappush(h, (node.next.val, i, node.next))
return dummy.next
Heap holds at most one node per list; tie-break on i to avoid comparing nodes.
Problem 14: LRU Cachemedium
Statement
Design an LRU cache supporting get and put in O(1).
Hash-Map + Doubly Linked List
class Node:
__slots__ = ("k","v","prev","next")
def __init__(self, k=0, v=0):
self.k, self.v = k, v
self.prev = self.next = None
class LRUCache:
def __init__(self, capacity):
self.cap = capacity
self.m = {}
self.head = Node() # most-recent side
self.tail = Node() # least-recent side
self.head.next = self.tail
self.tail.prev = self.head
def _remove(self, node):
node.prev.next = node.next
node.next.prev = node.prev
def _add_front(self, node):
node.next = self.head.next
node.prev = self.head
self.head.next.prev = node
self.head.next = node
def get(self, k):
if k not in self.m: return -1
n = self.m[k]
self._remove(n); self._add_front(n)
return n.v
def put(self, k, v):
if k in self.m:
n = self.m[k]; n.v = v
self._remove(n); self._add_front(n); return
if len(self.m) == self.cap:
lru = self.tail.prev
self._remove(lru); del self.m[lru.k]
n = Node(k, v)
self.m[k] = n; self._add_front(n)
HashMap indexes a doubly linked list so both update sites are O(1).
Problem 15: Copy List With Random Pointermedium
Statement
Each node has val, next, and random. Deep-copy the list.
Approach 1 — Hash Map of Clones
def copy_random_list(head):
if not head: return None
m = {}
cur = head
while cur:
m[cur] = Node(cur.val); cur = cur.next
cur = head
while cur:
m[cur].next = m.get(cur.next)
m[cur].random = m.get(cur.random)
cur = cur.next
return m[head]
Approach 2 — Interleave Clones (O(1) extra)
Insert a clone after each original, wire randoms using orig.random.next, then un-weave.
impressive but rarely needed; state as optional.
Problem 16: Reorder Listmedium
Statement
Given L0 → L1 → … → Ln, reorder to L0 → Ln → L1 → Ln-1 → …
Find Middle, Reverse Tail, Merge
def reorder_list(head):
if not head or not head.next: return
# 1. middle
slow = fast = head
while fast.next and fast.next.next:
slow = slow.next; fast = fast.next.next
second = slow.next; slow.next = None
# 2. reverse second half
prev, curr = None, second
while curr:
nxt = curr.next; curr.next = prev
prev = curr; curr = nxt
# 3. weave
first, second = head, prev
while second:
t1, t2 = first.next, second.next
first.next = second
second.next = t1
first, second = t1, t2
three canonical sub-routines bolted together.
Trees & Graphs
Recursion, BFS/DFS, and graph modeling. The decisive skill here is recognising when an "array of strings" or "matrix" problem is really a graph in disguise.
Decision Shortcuts
| Question asks… | Go-to technique |
|---|---|
| shortest path (unweighted) | BFS from source |
| any path / all paths | DFS + backtrack |
| cycle / ordering in DAG | Topological sort (Kahn or DFS) |
| connected components | Union-Find or BFS flood |
| shortest path (weighted non-neg) | Dijkstra (min-heap) |
Problem 17: Binary Tree Level Order Traversalmedium
Statement
Return the node values grouped by level.
BFS with Level-Sized Loop
from collections import deque
def level_order(root):
if not root: return []
q = deque([root]); out = []
while q:
level = []
for _ in range(len(q)):
n = q.popleft()
level.append(n.val)
if n.left: q.append(n.left)
if n.right: q.append(n.right)
out.append(level)
return out
where w is max width.
Problem 18: Validate Binary Search Treemedium
Statement
Return True iff tree is a valid BST (strict inequality).
Approach 1 — In-Order Must Be Strictly Increasing
def is_valid_bst(root):
prev = [None]
def inorder(n):
if not n: return True
if not inorder(n.left): return False
if prev[0] is not None and n.val <= prev[0]: return False
prev[0] = n.val
return inorder(n.right)
return inorder(root)
Approach 2 — Bound Propagation
def is_valid_bst2(root, lo=float('-inf'), hi=float('inf')):
if not root: return True
if not (lo < root.val < hi): return False
return (is_valid_bst2(root.left, lo, root.val) and
is_valid_bst2(root.right, root.val, hi))
Problem 19: Lowest Common Ancestormedium
Statement
Find LCA of two nodes in a binary tree (both exist).
Post-Order One-Pass (Binary Tree)
def lca(root, p, q):
if not root or root is p or root is q: return root
l = lca(root.left, p, q)
r = lca(root.right, p, q)
if l and r: return root
return l or r
Two non-null children means root is the split; else propagate the single found side.
BST variant — O(h) without recursion
def lca_bst(root, p, q):
while root:
if p.val < root.val and q.val < root.val: root = root.left
elif p.val > root.val and q.val > root.val: root = root.right
else: return root
Problem 20: Serialize and Deserialize Binary Treehard
Statement
Round-trip an arbitrary binary tree through a string.
Pre-Order with Null Sentinels
SEP, NULL = ",", "#"
def serialize(root):
out = []
def go(n):
if not n: out.append(NULL); return
out.append(str(n.val)); go(n.left); go(n.right)
go(root)
return SEP.join(out)
def deserialize(data):
it = iter(data.split(SEP))
def go():
v = next(it)
if v == NULL: return None
n = TreeNode(int(v))
n.left = go(); n.right = go()
return n
return go()
Pre-order lets the reader reconstruct via the same recursion pattern.
Problem 21: Binary Tree Max Path Sumhard
Statement
Path = any node-to-node path through the tree; maximize its sum. Values may be negative.
Post-Order, Track Global Max
def max_path_sum(root):
best = [float('-inf')]
def gain(n):
if not n: return 0
l = max(0, gain(n.left))
r = max(0, gain(n.right))
best[0] = max(best[0], n.val + l + r)
return n.val + max(l, r)
gain(root)
return best[0]
The return value is the "extendable" half; the global includes both sides forking at the node.
Problem 22: Clone Graphmedium
Statement
Given a reference to a node in a connected undirected graph, return a deep copy.
DFS with Visited Map
def clone_graph(node):
if not node: return None
m = {}
def dfs(u):
if u in m: return m[u]
c = Node(u.val); m[u] = c
for v in u.neighbors: c.neighbors.append(dfs(v))
return c
return dfs(node)
Problem 23: Course Schedule I / IImedium
Statement
I — can you finish all courses? II — return a valid order. Both reduce to cycle detection / topological sort.
Kahn's Algorithm (BFS over indegrees)
from collections import defaultdict, deque
def find_order(n, prerequisites):
g = defaultdict(list); indeg = [0] * n
for a, b in prerequisites: # b -> a
g[b].append(a); indeg[a] += 1
q = deque(i for i in range(n) if indeg[i] == 0)
out = []
while q:
u = q.popleft(); out.append(u)
for v in g[u]:
indeg[v] -= 1
if indeg[v] == 0: q.append(v)
return out if len(out) == n else [] # [] = cycle
DFS variant (gray/white/black)
def find_order_dfs(n, prerequisites):
g = defaultdict(list)
for a, b in prerequisites: g[b].append(a)
WHITE, GRAY, BLACK = 0, 1, 2
state = [WHITE]*n; out = []
def dfs(u):
if state[u] == GRAY: return False # cycle
if state[u] == BLACK: return True
state[u] = GRAY
for v in g[u]:
if not dfs(v): return False
state[u] = BLACK; out.append(u); return True
for i in range(n):
if not dfs(i): return []
return out[::-1]
Problem 24: Word Ladderhard
Statement
Shortest transformation from begin to end where each step changes one letter and each intermediate word is in the dictionary.
BFS over Pattern Buckets
from collections import defaultdict, deque
def ladder_length(begin, end, words):
if end not in words: return 0
buckets = defaultdict(list)
L = len(begin)
for w in words | {begin}:
for i in range(L):
buckets[w[:i]+"*"+w[i+1:]].append(w)
q = deque([(begin, 1)]); seen = {begin}
while q:
w, d = q.popleft()
if w == end: return d
for i in range(L):
key = w[:i]+"*"+w[i+1:]
for nb in buckets[key]:
if nb not in seen:
seen.add(nb); q.append((nb, d + 1))
buckets[key] = [] # never revisit this bucket
return 0
pattern bucketing collapses neighbours from O(N) to O(L).
Problem 25: Number of Islandsmedium
Statement
Count connected groups of '1' in a binary grid (4-neighborhood).
DFS Flood-Fill
def num_islands(grid):
if not grid: return 0
R, C = len(grid), len(grid[0])
def dfs(r, c):
if r < 0 or r >= R or c < 0 or c >= C or grid[r][c] != '1': return
grid[r][c] = '0'
dfs(r+1,c); dfs(r-1,c); dfs(r,c+1); dfs(r,c-1)
cnt = 0
for r in range(R):
for c in range(C):
if grid[r][c] == '1':
cnt += 1; dfs(r, c)
return cnt
worst-case recursion.
Union-Find variant
Useful when cells can turn land on/off over time (see LeetCode 305 "Islands II").
Problem 26: Pacific Atlantic Water Flowmedium
Statement
Return cells from which water can flow to both oceans. Top+left edges touch Pacific; bottom+right touch Atlantic. Water flows to equal-or-lower neighbours.
Reverse BFS from Each Ocean
def pacific_atlantic(h):
if not h: return []
R, C = len(h), len(h[0])
pac, atl = set(), set()
def dfs(r, c, visited):
visited.add((r, c))
for dr, dc in ((1,0),(-1,0),(0,1),(0,-1)):
nr, nc = r+dr, c+dc
if 0 <= nr < R and 0 <= nc < C and (nr,nc) not in visited and h[nr][nc] >= h[r][c]:
dfs(nr, nc, visited)
for c in range(C): dfs(0, c, pac); dfs(R-1, c, atl)
for r in range(R): dfs(r, 0, pac); dfs(r, C-1, atl)
return [list(p) for p in pac & atl]
reverse flow lets you avoid recomputing per-cell.
Problem 27: Alien Dictionaryhard
Statement
Given a list of words sorted by an unknown alphabet, recover the letter order.
Build Edges from Adjacent Pair, Then Topo Sort
from collections import defaultdict, deque
def alien_order(words):
g = defaultdict(set); indeg = {c: 0 for w in words for c in w}
for a, b in zip(words, words[1:]):
for x, y in zip(a, b):
if x != y:
if y not in g[x]:
g[x].add(y); indeg[y] += 1
break
else:
if len(a) > len(b): return "" # invalid prefix case
q = deque(c for c, d in indeg.items() if d == 0)
out = []
while q:
c = q.popleft(); out.append(c)
for n in g[c]:
indeg[n] -= 1
if indeg[n] == 0: q.append(n)
return "".join(out) if len(out) == len(indeg) else ""
where C = total characters; letter alphabet is bounded so graph is tiny.
Dynamic Programming
Recurrence first, memoization second, tabulation third. The sequence of steps — in that order — is what interviewers watch for.
DP Problem-Solving Ladder
- Identify the state: what parameters pin down a subproblem?
- Write the recurrence: relate state to smaller states.
- Spot the base cases and the answer cell.
- Implement top-down (memo) to verify; rewrite bottom-up to optimize space.
Problem 28: Climbing Stairseasy
Statement
Ways to climb n stairs taking 1 or 2 at a time.
Fibonacci with O(1) Memory
def climb(n):
a, b = 1, 1
for _ in range(n):
a, b = b, a + b
return a
Problem 29: House Robber I & IImedium
Statement (I)
Maximize sum without taking adjacent elements.
Two-State DP
def rob(nums):
prev = curr = 0
for x in nums:
prev, curr = curr, max(curr, prev + x)
return curr
House Robber II (circular)
Run linear-rob on nums[1:] and nums[:-1]; take max. Circularity means index 0 and n-1 can't both be chosen.
def rob_circular(nums):
if len(nums) == 1: return nums[0]
def linear(a):
prev = curr = 0
for x in a:
prev, curr = curr, max(curr, prev + x)
return curr
return max(linear(nums[1:]), linear(nums[:-1]))
Problem 30: Longest Increasing Subsequencemedium
Statement
Length of the longest strictly increasing subsequence.
Approach 1 — DP O(n^2)
def lis_dp(nums):
n = len(nums)
dp = [1] * n
for i in range(n):
for j in range(i):
if nums[j] < nums[i]:
dp[i] = max(dp[i], dp[j] + 1)
return max(dp or [0])
Approach 2 — Patience Sorting (Binary Search)
from bisect import bisect_left
def lis(nums):
tails = []
for x in nums:
i = bisect_left(tails, x)
if i == len(tails): tails.append(x)
else: tails[i] = x
return len(tails)
tails[i] = smallest possible tail of an increasing subseq of length i+1.
Problem 31: Longest Common Subsequencemedium
Statement
Length of LCS of two strings.
2-D DP
def lcs(a, b):
m, n = len(a), len(b)
dp = [[0]*(n+1) for _ in range(m+1)]
for i in range(1, m+1):
for j in range(1, n+1):
if a[i-1] == b[j-1]:
dp[i][j] = dp[i-1][j-1] + 1
else:
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
return dp[m][n]
, can reduce to O(min(m,n)) with rolling rows.
Problem 32: Edit Distancehard
Statement
Minimum insertions/deletions/substitutions to turn a into b.
Levenshtein DP
def edit_distance(a, b):
m, n = len(a), len(b)
dp = [[0]*(n+1) for _ in range(m+1)]
for i in range(m+1): dp[i][0] = i
for j in range(n+1): dp[0][j] = j
for i in range(1, m+1):
for j in range(1, n+1):
if a[i-1] == b[j-1]:
dp[i][j] = dp[i-1][j-1]
else:
dp[i][j] = 1 + min(dp[i-1][j], # delete
dp[i][j-1], # insert
dp[i-1][j-1]) # substitute
return dp[m][n]
.
Problem 33: Coin Change I & IImedium
Statement (I)
Fewest coins summing to amount, or -1.
Unbounded Knapsack DP
def coin_change(coins, amount):
INF = amount + 1
dp = [0] + [INF] * amount
for a in range(1, amount + 1):
for c in coins:
if c <= a:
dp[a] = min(dp[a], dp[a-c] + 1)
return -1 if dp[amount] == INF else dp[amount]
Coin Change II — count ways (order-independent)
def coin_ways(coins, amount):
dp = [0]*(amount+1); dp[0] = 1
for c in coins: # outer = coin
for a in range(c, amount+1): # inner = amount
dp[a] += dp[a-c]
return dp[amount]
Problem 34: Word Breakmedium
Statement
Can s be segmented into dictionary words?
DP on Prefix
def word_break(s, words):
wset = set(words)
n = len(s)
dp = [False] * (n + 1); dp[0] = True
max_len = max(map(len, wset), default=0)
for i in range(1, n + 1):
for j in range(max(0, i - max_len), i):
if dp[j] and s[j:i] in wset:
dp[i] = True; break
return dp[n]
where L = longest dict word.
Problem 35: Partition Equal Subset Summedium
Statement
Can the array be split into two equal-sum subsets?
Subset-Sum with Bitset
def can_partition(nums):
s = sum(nums)
if s % 2: return False
target = s // 2
dp = 1 # bit 0 set = sum 0 reachable
for x in nums:
dp |= dp << x
return (dp >> target) & 1 == 1
Python ints are arbitrary-precision so the bitset is a single integer.
Problem 36: Best Time to Buy/Sell Stock I–IVhard
I — single transaction
def max_profit_1(prices):
best, lo = 0, float('inf')
for p in prices:
lo = min(lo, p); best = max(best, p - lo)
return best
II — unlimited transactions
def max_profit_2(prices):
return sum(max(0, b - a) for a, b in zip(prices, prices[1:]))
III — at most two transactions
def max_profit_3(prices):
b1 = b2 = float('-inf'); s1 = s2 = 0
for p in prices:
b1 = max(b1, -p)
s1 = max(s1, b1 + p)
b2 = max(b2, s1 - p)
s2 = max(s2, b2 + p)
return s2
IV — at most k transactions
def max_profit_k(k, prices):
n = len(prices)
if k >= n // 2: return max_profit_2(prices)
buy = [float('-inf')] * (k + 1)
sell = [0] * (k + 1)
for p in prices:
for j in range(1, k + 1):
buy[j] = max(buy[j], sell[j-1] - p)
sell[j] = max(sell[j], buy[j] + p)
return sell[k]
Problem 37: Regular Expression Matchinghard
Statement
Match s against a pattern with . (any single) and * (zero-or-more of previous).
2-D DP
def is_match(s, p):
m, n = len(s), len(p)
dp = [[False]*(n+1) for _ in range(m+1)]
dp[0][0] = True
for j in range(1, n+1):
if p[j-1] == '*': dp[0][j] = dp[0][j-2]
for i in range(1, m+1):
for j in range(1, n+1):
if p[j-1] == '*':
dp[i][j] = dp[i][j-2] # zero copies
if p[j-2] == '.' or p[j-2] == s[i-1]:
dp[i][j] |= dp[i-1][j] # one more
elif p[j-1] == '.' or p[j-1] == s[i-1]:
dp[i][j] = dp[i-1][j-1]
return dp[m][n]
?/* — different recurrence), minimum-cost regex match, NFA compilation.Problem 38: Decode Waysmedium
Statement
Number of ways to decode a digit string where '1'→'A' … '26'→'Z'.
Two-Variable DP
def num_decodings(s):
if not s or s[0] == '0': return 0
prev2, prev1 = 1, 1
for i in range(1, len(s)):
curr = 0
if s[i] != '0': curr += prev1
two = int(s[i-1:i+1])
if 10 <= two <= 26: curr += prev2
prev2, prev1 = prev1, curr
return prev1
Fibonacci-shaped recurrence with zero/out-of-range guards.
Heap & Stack
Monotonic stacks for "next greater/smaller" and heaps for "top K / running median". These two structures solve a surprisingly large slice of interview asks.
When to reach for what
- Top-K in a stream → min-heap of size K.
- Running median → two heaps.
- Next greater / previous smaller → monotonic stack.
- Max sub-rectangle in histogram → monotonic stack with bounds.
Problem 39: Kth Largest Element in an Arraymedium
Statement
Return the k-th largest number in an unsorted array.
Sort
def kth_largest_sort(nums, k):
return sorted(nums, reverse=True)[k-1]
Min-Heap of Size K
import heapq
def kth_largest_heap(nums, k):
h = []
for x in nums:
heapq.heappush(h, x)
if len(h) > k: heapq.heappop(h)
return h[0]
Quick-Select (average linear)
import random
def quickselect(nums, k):
k = len(nums) - k # k-th largest = index k in ascending
def select(l, r):
pivot = nums[random.randint(l, r)]
lo, hi = l, r
i = l
while i <= hi:
if nums[i] < pivot: nums[lo], nums[i] = nums[i], nums[lo]; lo += 1; i += 1
elif nums[i] > pivot: nums[hi], nums[i] = nums[i], nums[hi]; hi -= 1
else: i += 1
if k < lo: return select(l, lo - 1)
if k > hi: return select(hi + 1, r)
return nums[k]
return select(0, len(nums) - 1)
Problem 40: Merge K Sorted Lists (Heap Variant)hard
Same problem as Module 2 — re-examined through the heap lens. See Problem 13 for the code; here the mental model is that the min-heap is exactly the frontier of smallest unmerged heads.
Problem 41: Find Median from Data Streamhard
Statement
Support add_num and find_median efficiently on a growing stream.
Two Heaps
import heapq
class MedianFinder:
def __init__(self):
self.lo = [] # max-heap (negated)
self.hi = [] # min-heap
def add_num(self, x):
heapq.heappush(self.lo, -x)
heapq.heappush(self.hi, -heapq.heappop(self.lo))
if len(self.hi) > len(self.lo):
heapq.heappush(self.lo, -heapq.heappop(self.hi))
def find_median(self):
if len(self.lo) > len(self.hi):
return -self.lo[0]
return (-self.lo[0] + self.hi[0]) / 2
Invariant: len(lo) == len(hi) or len(lo) == len(hi)+1, and every element in lo ≤ every element in hi.
Problem 42: Top K Frequent Elementsmedium
Bucket Sort on Frequency
from collections import Counter
def top_k_frequent(nums, k):
cnt = Counter(nums)
buckets = [[] for _ in range(len(nums) + 1)]
for x, c in cnt.items():
buckets[c].append(x)
out = []
for f in range(len(buckets) - 1, 0, -1):
out.extend(buckets[f])
if len(out) >= k: return out[:k]
return out
Frequencies are bounded by n, so buckets are dense.
Heap alternative
import heapq
from collections import Counter
def top_k_frequent_heap(nums, k):
c = Counter(nums)
return [x for x, _ in heapq.nlargest(k, c.items(), key=lambda kv: kv[1])]
Problem 43: Task Schedulermedium
Statement
Minimum CPU intervals to finish tasks, where each same-type task must be separated by cooldown n.
Count-Based Formula
from collections import Counter
def least_interval(tasks, n):
c = Counter(tasks).values()
m = max(c)
n_max = sum(1 for v in c if v == m)
return max(len(tasks), (m - 1) * (n + 1) + n_max)
Place most-frequent first; count idles; formula falls out by counting "full frames" of size n+1.
Heap variant (works with richer constraints)
import heapq
from collections import Counter, deque
def least_interval_heap(tasks, n):
h = [-v for v in Counter(tasks).values()]
heapq.heapify(h)
q = deque() # (avail_time, neg_count)
t = 0
while h or q:
t += 1
if h:
c = heapq.heappop(h) + 1
if c < 0: q.append((t + n, c))
if q and q[0][0] == t:
heapq.heappush(h, q.popleft()[1])
return t
Problem 44: Min Stackeasy
Statement
Support push/pop/top plus getMin in O(1).
Pair Each Element with Running Min
class MinStack:
def __init__(self):
self.st = [] # (value, running_min)
def push(self, x):
m = x if not self.st else min(x, self.st[-1][1])
self.st.append((x, m))
def pop(self): self.st.pop()
def top(self): return self.st[-1][0]
def getMin(self): return self.st[-1][1]
Problem 45: Daily Temperaturesmedium
Statement
For each day, days until a warmer temperature (0 if never).
Monotonic Stack of Pending Indices
def daily_temperatures(T):
n = len(T); out = [0]*n
st = []
for i, t in enumerate(T):
while st and T[st[-1]] < t:
j = st.pop()
out[j] = i - j
st.append(i)
return out
Each index pushed and popped once.
Problem 46: Largest Rectangle in Histogramhard
Monotonic Increasing Stack + Sentinel
def largest_rectangle(h):
st = []; best = 0
h = h + [0] # sentinel forces flush
for i, x in enumerate(h):
while st and h[st[-1]] > x:
j = st.pop()
left = st[-1] if st else -1
best = max(best, h[j] * (i - left - 1))
st.append(i)
return best
When a bar is popped, the rectangle spanning from the previous stacked index to i is exactly its height×width.
Binary Search
Not just for sorted arrays. The real power is binary search on the answer: anything monotone can be searched.
Universal Template
def bsearch(lo, hi, feasible):
# returns smallest x in [lo, hi] with feasible(x) == True
while lo < hi:
mid = (lo + hi) // 2
if feasible(mid): hi = mid
else: lo = mid + 1
return lo
If feasible is monotone (False..False..True..True), this converges to the transition.
Problem 47: Classic + Lower/Upper Boundeasy
Lower bound (first index ≥ x)
def lower_bound(a, x):
lo, hi = 0, len(a)
while lo < hi:
mid = (lo + hi) // 2
if a[mid] < x: lo = mid + 1
else: hi = mid
return lo
Upper bound (first index > x)
def upper_bound(a, x):
lo, hi = 0, len(a)
while lo < hi:
mid = (lo + hi) // 2
if a[mid] <= x: lo = mid + 1
else: hi = mid
return lo
Problem 48: Search in Rotated Sorted Arraymedium
Identify Sorted Half, Then Bisect
def search_rotated(a, t):
lo, hi = 0, len(a) - 1
while lo <= hi:
mid = (lo + hi) // 2
if a[mid] == t: return mid
if a[lo] <= a[mid]: # left sorted
if a[lo] <= t < a[mid]: hi = mid - 1
else: lo = mid + 1
else: # right sorted
if a[mid] < t <= a[hi]: lo = mid + 1
else: hi = mid - 1
return -1
Problem 49: Find Minimum in Rotated Sorted Arraymedium
Compare to Rightmost
def find_min(a):
lo, hi = 0, len(a) - 1
while lo < hi:
mid = (lo + hi) // 2
if a[mid] > a[hi]: lo = mid + 1
else: hi = mid
return a[lo]
Problem 50: Find Peak Elementmedium
Climb Upslope
def find_peak(a):
lo, hi = 0, len(a) - 1
while lo < hi:
mid = (lo + hi) // 2
if a[mid] < a[mid + 1]: lo = mid + 1
else: hi = mid
return lo
With sentinels -∞ on either side, binary search on the slope is provably correct.
Problem 51: Koko Eating Bananasmedium
Statement
Minimum eat-rate k so Koko finishes piles in h hours; k bananas per hour, no stacking piles.
Binary Search on Rate
import math
def min_eating_speed(piles, h):
def feasible(k):
return sum(math.ceil(p / k) for p in piles) <= h
lo, hi = 1, max(piles)
while lo < hi:
mid = (lo + hi) // 2
if feasible(mid): hi = mid
else: lo = mid + 1
return lo
where M = max pile.
Problem 52: Capacity to Ship Packages in D Daysmedium
Binary Search on Capacity
def ship_within_days(weights, D):
def feasible(cap):
days, cur = 1, 0
for w in weights:
if cur + w > cap: days += 1; cur = 0
cur += w
return days <= D
lo, hi = max(weights), sum(weights)
while lo < hi:
mid = (lo + hi) // 2
if feasible(mid): hi = mid
else: lo = mid + 1
return lo
Problem 53: Median of Two Sorted Arrayshard
Partition Binary Search
def find_median_sorted(A, B):
if len(A) > len(B): A, B = B, A
m, n = len(A), len(B)
lo, hi = 0, m
half = (m + n + 1) // 2
while lo <= hi:
i = (lo + hi) // 2
j = half - i
aL = float('-inf') if i == 0 else A[i-1]
aR = float('inf') if i == m else A[i]
bL = float('-inf') if j == 0 else B[j-1]
bR = float('inf') if j == n else B[j]
if aL <= bR and bL <= aR:
if (m + n) % 2: return max(aL, bL)
return (max(aL, bL) + min(aR, bR)) / 2
if aL > bR: hi = i - 1
else: lo = i + 1
Binary search partitions A so that left-halves of A and B together are half of the merged array.
Problem 54: Split Array Largest Sumhard
Binary Search on Max-Subarray-Sum
def split_array(nums, m):
def feasible(cap):
parts, cur = 1, 0
for x in nums:
if cur + x > cap: parts += 1; cur = 0
cur += x
return parts <= m
lo, hi = max(nums), sum(nums)
while lo < hi:
mid = (lo + hi) // 2
if feasible(mid): hi = mid
else: lo = mid + 1
return lo
Backtracking
Search trees, pruning, state restoration.
N-Queens
Place N queens on N×N board with no two attacking. Classic backtracking: place row by row, prune on column + diagonal conflicts.
def solve(n):
cols, d1, d2 = set(), set(), set()
out = []
def bt(r, board):
if r == n: out.append(board[:]); return
for c in range(n):
if c in cols or r-c in d1 or r+c in d2: continue
cols.add(c); d1.add(r-c); d2.add(r+c)
board.append(c); bt(r+1, board); board.pop()
cols.remove(c); d1.remove(r-c); d2.remove(r+c)
bt(0, []); return out
Branching factor pruned by constraint sets; space is stack + 3 sets.
Subsets & Permutations
def subsets(nums):
out = [[]]
for x in nums:
out += [s + [x] for s in out]
return out
def permute(nums):
if not nums: return [[]]
return [[x] + p for i, x in enumerate(nums) for p in permute(nums[:i]+nums[i+1:])]
Every subset/permutation costs O(N) to materialize.
Combination Sum
def combSum(cands, target):
cands.sort(); out = []
def bt(i, path, rem):
if rem == 0: out.append(path[:]); return
for j in range(i, len(cands)):
if cands[j] > rem: break
path.append(cands[j]); bt(j, path, rem-cands[j]); path.pop()
bt(0, [], target); return out
where T = target and m = smallest candidate; recursion depth is bounded by T/m and the sort+break prunes branches once a candidate exceeds the remainder.
Bit Manipulation
XOR tricks, bit counting, masking.
Single Number (XOR)
All elements appear twice except one. XOR cancels pairs: x ^ x = 0, 0 ^ y = y.
def single(nums):
r = 0
for x in nums: r ^= x
return r
Number of 1 Bits (Hamming Weight)
def popcount(n):
c = 0
while n: n &= n-1; c += 1
return c
Trick: n & (n-1) clears lowest set bit. Iterations = popcount.
Counting Bits 0..n
def count_bits(n):
dp = [0]*(n+1)
for i in range(1, n+1): dp[i] = dp[i >> 1] + (i & 1)
return dp
DP on i >> 1: popcount(i) = popcount(i//2) + (i & 1) — dropping the low bit removes exactly one 1-bit when it was set. The naive per-number popcount would be O(n log n).
Power of Two / Missing Number
def is_pow2(n): return n > 0 and (n & (n-1)) == 0
def missing(nums):
# XOR trick: XOR all indices + values
r = len(nums)
for i, x in enumerate(nums): r ^= i ^ x
return r
XOR every index 0..n and every value; the present numbers cancel in pairs, leaving the one missing index.
Sliding Window & Two Pointers
Monotonic window state, O(n) traversals.
Minimum Window Substring
from collections import Counter
def minWindow(s, t):
need = Counter(t); missing = len(t)
i = lo = 0; hi = -1
for j, c in enumerate(s, 1):
if need[c] > 0: missing -= 1
need[c] -= 1
if missing == 0:
while need[s[i]] < 0: need[s[i]] += 1; i += 1
if hi == -1 or j - i < hi - lo: lo, hi = i, j
need[s[i]] += 1; missing += 1; i += 1
return s[lo:hi] if hi > -1 else ''
Each char entered/exited window at most twice.
Find All Anagrams / Permutation in String
def findAnagrams(s, p):
from collections import Counter
need = Counter(p); window = Counter()
res = []
for i, c in enumerate(s):
window[c] += 1
if i >= len(p):
d = s[i-len(p)]
window[d] -= 1
if window[d] == 0: del window[d]
if window == need: res.append(i-len(p)+1)
return res
Fixed-size window of length |p|; the Counter never exceeds 26 keys (lowercase), so window == need is effectively constant.
Max Sliding Window (Monotonic Deque)
from collections import deque
def maxSliding(nums, k):
q = deque(); out = []
for i, x in enumerate(nums):
while q and nums[q[-1]] < x: q.pop()
q.append(i)
if q[0] == i-k: q.popleft()
if i >= k-1: out.append(nums[q[0]])
return out
Each index pushed/popped at most once.
Sorting & Interval Patterns
Interval merge, schedule, partition.
Merge Intervals
def merge(ivs):
ivs.sort()
out = [ivs[0]]
for s, e in ivs[1:]:
if s <= out[-1][1]: out[-1][1] = max(out[-1][1], e)
else: out.append([s, e])
return out
Sort by start dominates; one linear sweep merges. Space is the output (O(1) extra if merging in place).
Meeting Rooms I/II
def canAttend(ivs):
ivs.sort()
return all(ivs[i-1][1] <= ivs[i][0] for i in range(1, len(ivs)))
def minRooms(ivs):
import heapq
ivs.sort()
h = []
for s, e in ivs:
if h and h[0] <= s: heapq.heappop(h)
heapq.heappush(h, e)
return len(h)
Min-heap tracks earliest-ending ongoing meeting.
Sort Colors (Dutch Flag)
def sortColors(nums):
lo, hi, i = 0, len(nums)-1, 0
while i <= hi:
if nums[i] == 0: nums[i], nums[lo] = nums[lo], nums[i]; lo += 1; i += 1
elif nums[i] == 2: nums[i], nums[hi] = nums[hi], nums[i]; hi -= 1
else: i += 1
Single pass, three pointers (lo, i, hi) — the in-place one-pass Dutch National Flag.
Mock Interview Walkthroughs
Full 45-minute mock scripts with phantom interviewer dialog.
Mock 1 — "Valid Parentheses" extended to multi-bracket + custom pair
Interviewer: Given a string of brackets (), [], {}, return true if valid. Follow-up: support custom pair mapping.
You: Clarifying questions — are characters outside bracket set ignored? Length bound? Empty string valid? Confirm all three.
You: I'll use a stack. Push every opener; on closer, check top matches. At end, stack must be empty.
def valid(s):
pair = {')':'(', ']':'[', '}':'{'}
st = []
for c in s:
if c in pair.values(): st.append(c)
elif c in pair:
if not st or st.pop() != pair[c]: return False
return not st
You: O(n) time, O(n) space. Test cases: "", "()", "()[]{}", "(]", "([)]", "{[]}" — run through each mentally. Confirm each passes/fails as expected.
Interviewer follow-up: What if valid pairs come as config?
def valid2(s, pairs):
closers = {c: o for o, c in pairs.items()}
st = []
for c in s:
if c in pairs: st.append(c)
elif c in closers:
if not st or st.pop() != closers[c]: return False
return not st
Mock 2 — "Top K Frequent Elements"
Interviewer: Given int array and k, return k most frequent. What if the stream is infinite?
You: Finite case — Counter + heap of size k. O(n log k).
from collections import Counter
import heapq
def topK(nums, k):
c = Counter(nums)
return heapq.nlargest(k, c, key=c.get)
You: For streaming — Count-Min Sketch + min-heap of k current top, update on each event. Accept some error; share bounded RAM constraint.
Cheat Sheet & Pattern Recognition
Which technique fits which problem shape.
Pattern → Approach Table
| Signal in the problem | Reach for… |
|---|---|
| "sorted array", "find pair" | Two pointers |
| "longest / shortest contiguous" | Sliding window |
| "kth smallest / top k" | Heap (size k) |
| "next greater / prev smaller" | Monotonic stack |
| "min/max over all subarrays" | Monotonic deque |
| "count ways / min cost to reach" | DP (1D or 2D) |
| "shortest path", "level order" | BFS |
| "cycle / connectivity" | DFS / Union-Find |
| "interval scheduling" | Sort by end + greedy |
| "sorted range query" | Binary search on answer |
| "permutations / combinations / valid configs" | Backtracking |
| "bit at position / XOR magic" | Bit manipulation |
| "range sum / prefix sum" | Prefix array / BIT |
| "tree path sum / LCA" | Post-order DFS with return |
Complexity Ceiling by n
- n ≤ 10: brute O(n!) or O(2^n) fine — backtracking welcomed.
- n ≤ 20: O(2^n · n) bitmask DP OK.
- n ≤ 500: O(n³) Floyd-Warshall / matrix DP.
- n ≤ 5,000: O(n²) — most DP.
- n ≤ 10⁵: O(n log n) — sort / heap / BIT / segment tree.
- n ≤ 10⁶: O(n) — linear, two-pointer, sliding window.
- n ≥ 10⁹: O(log n) — binary search.
Answer Framework
- Clarify — input bounds, edge cases (empty, one element, negatives, duplicates, overflow).
- Restate — say the problem back in your own words.
- Examples — walk one happy-path + one edge case.
- Brute — state it, quote complexity.
- Optimize — name the pattern you recognize; why this sub-problem has optimal substructure.
- Code — structure first (function signature, variables), then fill in.
- Test — trace through one example + one edge case.
- Complexity — time + space, justified.
- Follow-ups — scale (n > 10⁹, distributed), variants (streaming, 2D).
Trie, Union-Find, Graph Algorithms & More Reps
The missing topic drills and a bigger, Blind-75-aligned problem set.
Trie — Prefix Tree, Word Search & Autocomplete
A trie stores strings as a tree of characters: the path from root to a marked node spells a word. It trades memory for blazing prefix queries — insert/search/startsWith are all O(L) in the word length L, independent of how many words are stored. Reach for it whenever a problem says "prefix", "dictionary", "autocomplete", or "spell-check".
- Node — a
childrenmap (char → node) plus anis_wordflag. A dict beats a fixed 26-array when the alphabet is sparse or Unicode. - Terminal marker —
is_worddistinguishes "app" (a real word) from "app" as a prefix of "apple". Forgetting it makessearchreturn true for every prefix. - Shared prefixes — "car", "card", "care" share one branch, so the trie compresses common heads that a hash set would store redundantly.
class TrieNode:
def __init__(self):
self.children = {} # char -> TrieNode
self.is_word = 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_word = True
def _walk(self, prefix): # node at end of prefix, or None
node = self.root
for ch in prefix:
node = node.children.get(ch)
if node is None:
return None
return node
def search(self, word):
node = self._walk(word)
return node is not None and node.is_word
def starts_with(self, prefix):
return self._walk(prefix) is not None
def autocomplete(self, prefix, limit=10):
node, out = self._walk(prefix), []
def dfs(nd, path):
if len(out) >= limit:
return
if nd.is_word:
out.append(prefix + path)
for ch in sorted(nd.children): # lexicographic order
dfs(nd.children[ch], path + ch)
if node:
dfs(node, "")
return out
Word Search II (LeetCode 212) is the killer trie application: matching many words against a grid one-cell-at-a-time is quadratic, but a trie lets a single DFS carry every candidate word simultaneously. Build the trie from the word list, then DFS the board descending the trie in lockstep — prune instantly when the current letter has no child.
def find_words(board, words):
root = {}
for w in words: # build trie as nested dicts
node = root
for ch in w:
node = node.setdefault(ch, {})
node['$'] = w # store full word at terminal
R, C, res = len(board), len(board[0]), []
def dfs(r, c, node):
ch = board[r][c]
nxt = node.get(ch)
if nxt is None: # prefix not in trie -> prune
return
w = nxt.pop('$', None) # pop de-dupes found words
if w:
res.append(w)
board[r][c] = '#' # mark visited
for dr, dc in ((1,0),(-1,0),(0,1),(0,-1)):
nr, nc = r + dr, c + dc
if 0 <= nr < R and 0 <= nc < C and board[nr][nc] != '#':
dfs(nr, nc, nxt)
board[r][c] = ch # backtrack
for r in range(R):
for c in range(C):
dfs(r, c, root)
return res
Union-Find (DSU) — Path Compression + Union by Rank
Disjoint Set Union answers "are a and b in the same group?" and "merge these two groups" in near-constant amortized time. It is the go-to for dynamic connectivity, cycle detection in an undirected graph, and Kruskal's MST. Two optimizations make it fast: path compression flattens the tree during find, and union by rank attaches the shorter tree under the taller one so trees stay bushy.
- find(x) — climb parent pointers to the representative root; path halving points each node at its grandparent on the way up.
- union(a, b) — link the two roots; returns
Falsewhen they already share a root, which is exactly a detected cycle edge. - count — track live component count, decremented on each successful union; a one-line answer to "number of connected components".
class DSU:
def __init__(self, n):
self.parent = list(range(n)) # each node its own root
self.rank = [0] * n # upper bound on tree height
self.count = n # number of disjoint sets
def find(self, x):
while self.parent[x] != x:
self.parent[x] = self.parent[self.parent[x]] # path halving
x = self.parent[x]
return x
def union(self, a, b):
ra, rb = self.find(a), self.find(b)
if ra == rb:
return False # already connected -> cycle
if self.rank[ra] < self.rank[rb]:
ra, rb = rb, ra # attach smaller under larger
self.parent[rb] = ra
if self.rank[ra] == self.rank[rb]:
self.rank[ra] += 1
self.count -= 1
return True
With both optimizations, any sequence of m operations on n elements runs in O(m · α(n)), where α is the inverse Ackermann function — bounded by 4 for every input that fits in the universe. Effectively constant. Drop either optimization and you regress: rank-only or compression-only is O(log n) per op; naked union-find degenerates to O(n) chains.
DSU in Action — Islands & Redundant Connection
Once DSU is muscle memory, a class of graph problems collapses to a few union calls. Number of Islands (LeetCode 200) unions every land cell with its right/down land neighbor; the answer is the component count minus the water cells that were never unioned. Redundant Connection (LeetCode 684) processes tree edges in order and returns the first edge whose endpoints already share a root — the edge that closes a cycle.
def num_islands(grid):
R, C = len(grid), len(grid[0])
dsu = DSU(R * C)
water = 0
for r in range(R):
for c in range(C):
if grid[r][c] == '0':
water += 1
continue
if r + 1 < R and grid[r+1][c] == '1':
dsu.union(r*C + c, (r+1)*C + c)
if c + 1 < C and grid[r][c+1] == '1':
dsu.union(r*C + c, r*C + c + 1)
return dsu.count - water # subtract isolated water cells
def find_redundant(edges): # nodes are 1..n
dsu = DSU(len(edges) + 1)
for u, v in edges:
if not dsu.union(u, v): # union failed -> both already linked
return [u, v]
return []
Islands is also solvable with plain BFS/DFS flood-fill in O(RC) — often cleaner. Choose DSU when the grid mutates (LeetCode 305, "Number of Islands II", adds land incrementally and asks for the running count after each addition); DSU handles that online in near-O(1) per add, whereas re-flooding is O(RC) every time.
BFS Shortest Path in Unweighted Graphs
In an unweighted graph, BFS finds the shortest path in edges because it explores strictly in layers: everything at distance d is dequeued before anything at distance d+1. The first time you reach the target, you have reached it via a minimum-length path. This is the correct tool for grid mazes, word ladders, and "minimum steps" problems — not Dijkstra, which is overkill when every edge weighs 1.
from collections import deque
def bfs_dist(adj, src, dst):
if src == dst:
return 0
dist = {src: 0} # visited-set doubles as distance
q = deque([src])
while q:
u = q.popleft()
for v in adj[u]:
if v not in dist: # first arrival == shortest
dist[v] = dist[u] + 1
if v == dst:
return dist[v]
q.append(v)
return -1 # unreachable
The single most important discipline: mark a node visited when you enqueue it, not when you dequeue it. If you defer marking to dequeue time, the same node can be pushed by several neighbors before it is ever processed, ballooning the queue and, in dense graphs, blowing the time bound from O(V+E) toward O(V·E). For multi-source shortest distance (e.g. "rotting oranges", LeetCode 994), seed the queue with all sources at distance 0 and run one BFS.
Dijkstra — Shortest Path with Non-Negative Weights
Dijkstra generalizes BFS to weighted edges by always expanding the closest unfinalized node next, using a min-heap as the priority queue. Once a node is popped with its final distance, that distance is provably optimal — provided no edge is negative. Runtime is O(E log V) with a binary heap: each edge can trigger one push, and each pop is O(log V).
import heapq
def dijkstra(adj, src): # adj[u] = list of (neighbor, weight)
dist = {src: 0}
pq = [(0, src)] # (distance, node)
while pq:
d, u = heapq.heappop(pq)
if d > dist.get(u, float('inf')):
continue # stale entry -> skip
for v, w in adj[u]:
nd = d + w
if nd < dist.get(v, float('inf')):
dist[v] = nd
heapq.heappush(pq, (nd, v)) # lazy: never decrease-key
return dist
Python's heapq has no decrease-key, so we use lazy deletion: push a fresh (smaller-distance, node) pair and leave the old one to be skipped by the d > dist[u] guard when it surfaces. The heap may hold up to O(E) entries, but the log factor absorbs it. For a single source-to-target query you can return early the moment the target is popped.
Topological Sort — Kahn's Algorithm
A topological order lists the vertices of a directed acyclic graph so every edge points forward — prerequisites before dependents. Kahn's algorithm builds it via BFS on in-degrees: repeatedly emit any node with in-degree 0 and decrement its neighbors. It doubles as a cycle detector — if you cannot emit all n nodes, the leftover nodes form a cycle. This powers Course Schedule (LeetCode 207/210), build systems, and task scheduling.
from collections import deque
def topo_kahn(n, edges): # edge (u, v) means u must precede v
adj = [[] for _ in range(n)]
indeg = [0] * n
for u, v in edges:
adj[u].append(v)
indeg[v] += 1
q = deque(i for i in range(n) if indeg[i] == 0)
order = []
while q:
u = q.popleft()
order.append(u)
for v in adj[u]:
indeg[v] -= 1
if indeg[v] == 0: # all prereqs satisfied
q.append(v)
return order if len(order) == n else [] # [] signals a cycle
Kahn runs in O(V+E): every node enters and leaves the queue once, every edge is relaxed once. The DFS alternative (post-order, then reverse) is equally valid and detects cycles via a gray/black coloring, but Kahn's explicit in-degree bookkeeping is usually easier to reason about under interview pressure and yields the "any valid order" that lexicographic variants can extend (swap the deque for a heap to get the lexicographically smallest order).
Greedy — Interval Scheduling & Jump Game
Greedy makes a locally optimal choice at each step and never revisits it. It only yields a globally optimal answer when the problem has the greedy-choice property — you must argue (via an exchange argument) why the local pick is safe. Two canonical wins: interval scheduling and the jump game family.
Max non-overlapping intervals (Activity Selection / LeetCode 435): sort by end time and greedily take each interval whose start clears the last chosen end. Sorting by end is the crux — it leaves the most room for future picks. "Minimum removals to make non-overlapping" is then n - kept.
def max_non_overlapping(intervals):
intervals.sort(key=lambda x: x[1]) # sort by END
count, end = 0, float('-inf')
for s, e in intervals:
if s >= end: # no overlap -> take it
count += 1
end = e
return count # erase_overlap = n - count
Jump Game (LeetCode 55): track the farthest reachable index; if you ever stand beyond it, you are stuck. Jump Game II (LeetCode 45) counts minimum jumps with a BFS-style layered greedy — bump the jump count each time you exhaust the current reach window.
def can_jump(nums):
reach = 0
for i, n in enumerate(nums):
if i > reach: # gap we cannot cross
return False
reach = max(reach, i + n)
return True
def min_jumps(nums):
jumps = cur_end = farthest = 0
for i in range(len(nums) - 1): # stop before last index
farthest = max(farthest, i + nums[i])
if i == cur_end: # end of current jump layer
jumps += 1
cur_end = farthest
return jumps
Curated Reps — Blind-75 / NeetCode-150 Buckets
Interviews reward pattern recognition over problem count. Map each new problem to a bucket and a go-to technique; after ~150 well-chosen reps, most questions become "oh, that is a sliding window / that is a topo sort". Below is a compressed index of the highest-leverage buckets and their signature problems, with the pattern to reach for first.
| Bucket | Signature problems | Go-to pattern |
|---|---|---|
| Arrays & Hashing | Two Sum, Group Anagrams, Product Except Self | Hash map / prefix products |
| Two Pointers | 3Sum, Container With Most Water, Valid Palindrome | Sorted converge / opposite ends |
| Sliding Window | Longest Substring No Repeat, Min Window Substring | Grow-shrink window + counts |
| Stack | Valid Parentheses, Daily Temperatures, Largest Rectangle | Monotonic stack |
| Binary Search | Search Rotated Array, Koko Eating Bananas, Median 2 Arrays | Search on answer / pivot |
| Trees | Max Depth, LCA, Validate BST, Serialize/Deserialize | DFS recursion + bounds |
| Tries | Implement Trie, Word Search II, Add & Search Word | Prefix tree + DFS prune |
| Heap / PQ | Kth Largest, Merge K Lists, Task Scheduler, Median Stream | Min/max heap, two heaps |
| Backtracking | Subsets, Combination Sum, Word Search, N-Queens | Choose-explore-unchoose |
| Graphs | Number of Islands, Clone Graph, Pacific Atlantic, Course Schedule | BFS/DFS, DSU, topo sort |
| Advanced Graphs | Network Delay, Cheapest Flights K Stops, Min Spanning Tree | Dijkstra, Bellman-Ford, Kruskal |
| 1-D DP | Climb Stairs, House Robber, Coin Change, LIS | State + transition table |
| 2-D DP | Unique Paths, LCS, Edit Distance, 0/1 Knapsack | Grid DP / roll rows |
| Greedy & Intervals | Jump Game, Merge Intervals, Non-overlapping Intervals | Sort + one pass |
Practice discipline that outperforms raw volume: (1) spend 5 minutes classifying the bucket before coding; (2) after solving, write the one-line reason it belonged to that pattern; (3) re-solve any problem that took a hint after 3–7 days (spaced repetition). A focused 150 beats a scattered 500.