Big Tech DSA Patterns
The DSA that actually shows up at Google, Meta, Amazon, Apple, Netflix, and every company that copies their bar. The problems aren't random — they're a small set of recognizable patterns in disguise. This part names each pattern, then works through multiple problems per pattern with Python code, complexity, and diagrams — so you learn to recognize the pattern, not memorize answers.
Learning objectives
- Recognize the ~10 patterns that cover the majority of interview questions.
- Map each pattern to the underlying structure from D1–D6.
- Solve the canonical problem for each — with optimal time/space.
- Learn the "tell" that signals which pattern a question wants.
1 · Sliding window very common
Tell: "longest / shortest / max / min contiguous subarray or substring satisfying …". Grow a window on the right, shrink from the left when a constraint breaks — O(n) instead of O(n²).
left until the window is valid again. Each index enters and leaves once → O(n).
A sliding window is a stretch of the string marked by two pointers, left and right. Here the goal is the longest run with no repeated letter inside it. The picture shows the window on "abcabcbb" the moment a repeat appears.
- The boxes are the characters of the string, side by side. The shaded boxes (between
leftandright) are what's currently inside the window. leftandrightare the two edges of the window.rightkeeps moving forward one step at a time to grow the window;leftonly moves when it has to.- The red note ("move left past old 'a'") is the fix: when the new character is already inside the window (a repeat), you slide
leftforward until the duplicate is gone — that keeps the window valid again. - The green line is the answer: the longest valid window seen was 3 long (
"abc").
In short: Grow on the right, and only shrink from the left when a rule breaks. Because each character is entered once and left once, the whole scan is O(n) — one pass, not a nested loop.
pythondef length_of_longest_substring(s):
seen = {} # char -> last index seen
left = best = 0
for right, ch in enumerate(s):
if ch in seen and seen[ch] >= left:
left = seen[ch] + 1 # jump left past the duplicate
seen[ch] = right
best = max(best, right - left + 1)
return best # O(n) time, O(min(n, alphabet)) space
print(length_of_longest_substring("abcabcbb")) # 3
Problem: find the length of the longest slice of a string that has no repeated character. The brute-force way checks every possible slice (slow, O(n²)). The sliding-window trick does it in one pass by remembering where each character was last seen.
seen = {}is a dictionary mapping each character to the last index where we saw it.leftis the start of the current window;bestis the longest length found so far.for right, ch in enumerate(s)walksrightacross the string one character at a time.enumeratehands you both the positionrightand the characterch.if ch in seen and seen[ch] >= leftasks "have we seen this char inside the current window?" If yes, we jumpleftto just past that old copy — the window instantly becomes duplicate-free again, without a slow inner loop.seen[ch] = rightrecords this character's new position, andbest = max(best, right - left + 1)updates the answer with the current window's width (right - left + 1is how many characters are inside).
What the output means: Prints 3 — the longest repeat-free substring of "abcabcbb" is "abc", which is 3 characters long.
Try this: Trace "abba" by hand. When the second a arrives, left must jump past the first a — this is exactly why the check is seen[ch] >= left and not just ch in seen.
pythonfrom collections import Counter
# (a) Max sum of a FIXED window of size k (classic problem style) — O(n)
def max_sum_k(nums, k):
window = sum(nums[:k]); best = window
for i in range(k, len(nums)):
window += nums[i] - nums[i-k] # slide: add new, drop old
best = max(best, window)
return best
# (b) Minimum window substring (classic) — variable window + counts
def min_window(s, t):
need = Counter(t); missing = len(t)
lo = start = 0; end = 0
for hi, ch in enumerate(s, 1): # hi is 1-based right edge
if need[ch] > 0: missing -= 1
need[ch] -= 1
if missing == 0: # window covers all of t
while need[s[lo]] < 0: # shrink past surplus chars
need[s[lo]] += 1; lo += 1
if end == 0 or hi - lo < end - start:
start, end = lo, hi # record best window
return s[start:end]
print(max_sum_k([2,1,5,1,3,2], 3), min_window("ADOBECODEBANC", "ABC")) # 9 BANC
Two more sliding-window classics. (a) the fixed-size window (the width never changes), and (b) the variable window that must cover a required set of characters. Same expand/shrink idea, two flavors.
- (a)
max_sum_k: to find the biggest sum of anykneighbours, don't re-addknumbers each step. Compute the first window once, thenwindow += nums[i] - nums[i-k]— add the new number entering on the right and subtract the old one leaving on the left. Each step is O(1). - (b)
min_window: find the shortest slice ofscontaining every character oft.need = Counter(t)counts how many of each character we still need;missingis how many we're short overall. - As
hiexpands the window, each needed character dropsmissing. Whenmissing == 0the window is valid, so the innerwhileshrinkslofrom the left past any surplus characters to make the window as tight as possible. - Whenever a valid window is smaller than the best so far, we record it with
start, end = lo, hi. At the ends[start:end]is the shortest covering slice.
What the output means: Prints 9 BANC: the best sum of 3 neighbours in [2,1,5,1,3,2] is 5+1+3=9, and the smallest window of "ADOBECODEBANC" covering "ABC" is "BANC".
Try this: For (a), print window each loop and watch it slide. For (b), the key insight is that need counts can go negative — that's the signal you have a surplus character you're allowed to drop while shrinking.
2 · Two pointers & fast/slow very common
Tell: "sorted array, find a pair/triplet" → converging pointers; "find the cycle / middle / nth-from-end of a linked list" → fast & slow pointers.
i, converge lo/hi toward −nums[i]. Sorting enables the two-pointer sweep and makes skipping duplicates easy → O(n²) overall.
3Sum asks for triplets that add to zero. This diagram shows the trick that turns a 3-dimensional search into a simple 1-D sweep: sort the array, fix one number, then use two pointers for the other two.
- The boxes are the numbers after sorting (small on the left, large on the right). Sorting is what makes the pointer sweep possible.
iis the anchor — the one number we hold fixed for this round (here it's-1). We now need the other two to add up to+1so the whole triplet cancels to 0.lostarts just after the anchor andhistarts at the far right. They converge inward: if their sum is too big, movehileft (smaller); too small, moveloright (bigger).- The bottom line shows a concrete step:
0 + 2 = 2is bigger than the needed1, sohi--. Repeat until the pointers meet.
In short: Sorting first is the unlock: it lets you decide which pointer to move just by comparing the sum to the target. Fixing one number then two-pointering the rest gives O(n²) instead of O(n³).
pythondef three_sum(nums):
nums.sort() # O(n log n)
res = []
for i in range(len(nums) - 2):
if i > 0 and nums[i] == nums[i-1]:
continue # skip duplicate anchors
lo, hi = i + 1, len(nums) - 1
while lo < hi:
s = nums[i] + nums[lo] + nums[hi]
if s < 0: lo += 1
elif s > 0: hi -= 1
else:
res.append([nums[i], nums[lo], nums[hi]])
lo += 1; hi -= 1
while lo < hi and nums[lo] == nums[lo-1]: lo += 1 # skip dups
return res # O(n²) total, O(1) extra
Problem: find all unique triplets in nums that sum to zero. The pattern: sort, then for each anchor run a two-pointer sweep. The only fiddly part is not reporting the same triplet twice.
nums.sort()sorts in place — cheap (O(n log n)) and it unlocks everything else.rescollects the answer triplets.for i in range(len(nums) - 2)picks each anchor. Theif i > 0 and nums[i] == nums[i-1]: continueline skips duplicate anchors so we don't emit the same triplet again.lo, hi = i + 1, len(nums) - 1sets the two pointers on either end of the rest. Thewhile lo < hiloop converges them: sum too small →lo += 1, too big →hi -= 1, exactly right → record it.- After recording a hit, the inner
while ... nums[lo] == nums[lo-1]: lo += 1skips over repeated values so duplicate triplets aren't produced.
What the output means: Returns a list of triplets that each sum to 0, e.g. [[-1,-1,2],[-1,0,1]], with no duplicates.
Try this: Remove the two duplicate-skipping lines and run on [-1,-1,2,0,1] — you'll get the same triplet twice. Those continue/skip lines are what interviewers check for.
python# (a) Container with most water (classic) — greedy two-pointer, O(n)
def max_area(height):
lo, hi, best = 0, len(height) - 1, 0
while lo < hi:
best = max(best, (hi - lo) * min(height[lo], height[hi]))
if height[lo] < height[hi]: # move the SHORTER wall inward
lo += 1
else:
hi -= 1
return best
# (b) Reverse a linked list (classic) — the pointer-flip classic, O(n)
def reverse_list(head):
prev = None
while head:
head.next, prev, head = prev, head, head.next # flip & advance
return prev
# (c) Middle of a linked list (classic) — fast/slow, O(n)
def middle(head):
slow = fast = head
while fast and fast.next:
slow, fast = slow.next, fast.next.next # slow ends at the middle
return slow
print(max_area([1,8,6,2,5,4,8,3,7])) # 49
Three short two-pointer classics that show the range of the pattern: (a) converging pointers on an array, (b) the pointer-flip that reverses a linked list, and (c) the fast/slow pointer pair on a linked list.
- (a)
max_area: two walls form a water container; area = width × the shorter wall. Start pointers at both ends and always move the shorter wall inward — moving the taller one could never help, so this greedy choice is safe and gives O(n). - (b)
reverse_list:head.next, prev, head = prev, head, head.nextflips one arrow and advances, all in a single line. Python evaluates the right side first, so this safely reverses the link and steps forward at once. - (c)
middle:slowmoves one node,fastmoves two. Whenfastreaches the end,slowis exactly halfway — the fast/slow ("tortoise and hare") trick, no length count needed.
What the output means: Prints 49 — the most water the container in [1,8,6,2,5,4,8,3,7] can hold.
Try this: For (c): if fast moves twice as fast as slow, slow covers exactly half the distance by the time fast finishes. That same idea (different speeds) detects a cycle in a list — the hare laps the tortoise.
3 · Top-K with a heap very common
Tell: "K largest / smallest / most frequent / closest …". Keep a size-K heap: O(n log k), far better than sorting everything when k ≪ n.
A heap is a container that always knows its smallest item instantly. To find the K largest items in a big stream, you keep a heap of size K and let small items fall out — this diagram shows why that works.
- The left boxes are the incoming stream of numbers, arriving one at a time.
- The green boxes on the right are the size-3 heap — it holds only the 3 largest seen so far. The root (smallest of the three, here
7) is always the easiest one to check and throw away. - The rule: push each new number; if the heap now has more than K, pop the smallest. A number smaller than the current root can never be in the top-K, so it's discarded immediately.
- Because the heap never grows past K, each push/pop is O(log k), giving O(n log k) overall — much cheaper than sorting all
nwhenkis small.
In short: Counter-intuitive but key: to keep the K largest, you use a min-heap. The smallest of your current winners sits at the root, ready to be evicted the moment something bigger arrives.
pythonimport heapq
from collections import Counter
def top_k_frequent(nums, k):
counts = Counter(nums) # O(n)
# heap of (freq, value); nlargest keeps only k
return [v for v, _ in heapq.nlargest(k, counts.items(), key=lambda kv: kv[1])]
print(top_k_frequent([1,1,1,2,2,3], 2)) # [1, 2]
Problem: return the k most frequent numbers in a list. Count how often each appears, then keep only the top k by frequency using a heap.
Counter(nums)builds a dictionary ofvalue → how many times it appearsin one O(n) pass.heapq.nlargest(k, counts.items(), key=lambda kv: kv[1])asks the heap for thekitems with the biggest value ofkv[1]— andkv[1]is the frequency (each item is a(value, frequency)pair, so index[1]is the count).- The list comprehension
[v for v, _ in ...]then throws away the counts and keeps just the values. The_is Python's "I don't need this" placeholder.
What the output means: Prints [1, 2] — in [1,1,1,2,2,3], 1 appears 3 times and 2 appears twice, so those are the two most frequent.
Try this: nlargest hides the size-K heap for you. Change k to 1 and you'll get just [1], the single most common value.
pythonimport heapq
# (a) K closest points to origin (classic) — O(n log k)
def k_closest(points, k):
return heapq.nsmallest(k, points, key=lambda p: p[0]**2 + p[1]**2)
# (b) Merge k sorted lists (classic) — heap of (val, list_idx, node), O(N log k)
def merge_k(lists):
heap = [(vals[0], i, 0) for i, vals in enumerate(lists) if vals]
heapq.heapify(heap)
out = []
while heap:
val, i, j = heapq.heappop(heap) # smallest across all lists
out.append(val)
if j + 1 < len(lists[i]):
heapq.heappush(heap, (lists[i][j+1], i, j+1))
return out
# (c) Find median from a data stream (classic) — TWO heaps
class MedianFinder:
def __init__(self):
self.lo = [] # max-heap (store negatives) — smaller half
self.hi = [] # min-heap — larger half
def add(self, num):
heapq.heappush(self.lo, -heapq.heappushpop(self.hi, num))
if len(self.lo) > len(self.hi):
heapq.heappush(self.hi, -heapq.heappop(self.lo))
def median(self):
if len(self.hi) > len(self.lo): return float(self.hi[0])
return (self.hi[0] - self.lo[0]) / 2
print(k_closest([[1,3],[-2,2],[5,8]], 2)) # [[-2,2],[1,3]]
Three heap patterns that go beyond simple top-K: (a) K-closest points, (b) merging K sorted lists, and (c) the elegant two-heaps trick for a running median.
- (a)
k_closest: distance to the origin isx² + y²(no square root needed — comparing squares gives the same order).heapq.nsmallestkeeps thekclosest in O(n log k). - (b)
merge_k: seed the heap with the first value of each list. Each pop gives the smallest value across all lists at once; then push the next value from the list that value came from. This merges everything in sorted order. - (c)
MedianFinder: keep two heaps —lois a max-heap of the smaller half (stored as negatives, since Python only has min-heaps) andhiis a min-heap of the larger half. addpushes into one heap and rebalances so the halves stay equal-ish. The median is then either the top of the bigger half, or the average of the two tops — an O(1) lookup.
What the output means: Prints [[-2,2],[1,3]] — the two points nearest the origin among [[1,3],[-2,2],[5,8]].
Try this: For the median: add 1,2,3 one at a time and print median() after each. The two heaps always keep the middle value(s) sitting right at their roots.
4 · Binary search on the answer common
Tell: "minimum/maximum value such that a condition holds", or a sorted/rotated array. If you can write a monotonic feasible(x) check, binary-search the answer space in O(log range · check).
This is the mind-bending pattern: sometimes you binary-search a range of possible answers instead of searching an array. It works whenever the answer has a monotonic yes/no property.
- The bar represents every candidate answer (here: every possible eating speed), lined up from small on the left to large on the right.
- The red region is "too slow — fails," the green region is "fast enough — works." The crucial fact: once a speed works, every faster speed also works — no flip-flopping. That's monotonic.
- The vertical line is the boundary between fail and pass — and that boundary is exactly the optimal answer (the smallest speed that still works).
- You find that boundary by binary-searching the value range: guess the middle speed, test if it works, and throw away half the range each time.
In short: The tell: "minimum/maximum value such that a condition holds." If you can write a feasible(x) check that's monotonic, binary-search the answer in O(log range) guesses.
pythonimport math
def min_eating_speed(piles, h):
def hours_at(speed):
return sum(math.ceil(p / speed) for p in piles)
lo, hi = 1, max(piles) # answer lives in [1, maxpile]
while lo < hi:
mid = (lo + hi) // 2
if hours_at(mid) <= h: # feasible -> try slower
hi = mid
else:
lo = mid + 1 # too slow -> go faster
return lo # smallest feasible speed
Problem: Koko eats bananas and must finish all piles within h hours; find her slowest workable speed. The answer isn't in the input array — it's a speed value we search for.
hours_at(speed)is the feasibility check: at a given speed, how many hours does eating every pile take?math.ceil(p / speed)rounds up because a partial pile still costs a whole hour.lo, hi = 1, max(piles)bounds the search: the slowest sensible speed is 1, the fastest ever useful is the biggest pile (eating it in one hour).if hours_at(mid) <= hmeans this speed works — so we try to go slower by settinghi = mid(keepmidas a candidate). Otherwise it's too slow to finish in time, solo = mid + 1forces a faster speed.- The loop narrows
[lo, hi]until they meet on the smallest speed that still finishes in time — the answer.
What the output means: Returns the minimum integer eating speed at which all piles are finished within h hours.
Try this: Notice we never search the piles array — we search the speeds 1..max(piles). Spotting that the answer lives in a searchable, monotonic range is the whole skill here.
5 · Merge intervals common
Tell: anything with start/end ranges — "merge overlapping", "can attend all meetings", "insert interval". Sort by start, then sweep.
An interval is a start–end range (like a meeting). This pattern merges any that overlap. The diagram shows why sorting by start makes it a simple left-to-right sweep.
- Each horizontal bar is one interval; its length spans from its start to its end on a timeline.
[1,3]and[2,6]overlap because the second starts (2) before the first ends (3). They collapse into one bar[1,6]— start of the first, end of the later one.[8,10]starts after everything else ends, so it stays separate (shown greyed).- The rule at the bottom: two intervals overlap when
next start ≤ current end; merge by extending the end tomax(current end, next end).
In short: Sort by start once, then walk through comparing each interval to the last one you kept. One sort + one pass = O(n log n).
pythondef merge(intervals):
intervals.sort(key=lambda iv: iv[0]) # by start
out = [intervals[0][:]]
for start, end in intervals[1:]:
if start <= out[-1][1]: # overlaps previous
out[-1][1] = max(out[-1][1], end)
else:
out.append([start, end])
return out
print(merge([[1,3],[2,6],[8,10]])) # [[1,6],[8,10]]
Problem: given a list of [start, end] ranges, merge every overlapping pair into one. The trick is entirely in the sorting: once sorted by start, a single sweep does it.
intervals.sort(key=lambda iv: iv[0])sorts by the start value. After this, any interval that overlaps another must be adjacent in the list — that's the guarantee sorting buys us.out = [intervals[0][:]]seeds the result with a copy of the first interval (the[:]copies so we don't mutate the input).if start <= out[-1][1]checks if the current interval overlaps the previous kept one (its start is at or before the previous end). If so, extend that previous interval's end withmax(...).- Otherwise there's a gap, so we
out.append([start, end])to begin a fresh interval.
What the output means: Prints [[1,6],[8,10]] — [1,3] and [2,6] merge into [1,6]; [8,10] stands alone.
Try this: Add [5,7] to the input. Because it overlaps [1,6] (5 ≤ 6), the answer becomes [[1,7],[8,10]] — the merge chains through automatically.
6 · BFS/DFS on a grid (islands) common
Tell: a 2-D grid where you "count regions / flood fill / shortest path in a maze". Treat each cell as a graph node with up/down/left/right edges.
This treats a grid of 0s and 1s as a map: 1 is land, 0 is water. An island is a group of 1s connected up/down/left/right. The task counts the islands.
- Each cell is a square in the grid. Colored groups show three separate islands (green, purple, amber) — clumps of 1s touching edge-to-edge.
- Cells connect 4-directionally only (up, down, left, right) — diagonals don't count, so the amber cells at the bottom form their own island.
- The method: scan every cell; when you hit an unvisited
1, flood-fill it — visit its whole connected clump and mark each cell so you never recount it — then add 1 to the island count. - Every cell is touched once, so the whole thing is O(rows·cols).
In short: Think of each land cell as a graph node with edges to its 4 neighbours. "Count islands" is just "count connected components" — the same BFS/DFS you'd run on any graph.
pythondef num_islands(grid):
if not grid: return 0
rows, cols = len(grid), len(grid[0])
def sink(r, c):
if 0 <= r < rows and 0 <= c < cols and grid[r][c] == "1":
grid[r][c] = "0" # mark visited
sink(r+1, c); sink(r-1, c); sink(r, c+1); sink(r, c-1)
count = 0
for r in range(rows):
for c in range(cols):
if grid[r][c] == "1":
count += 1; sink(r, c) # one new island, flood it
return count
Problem: count the islands (connected groups of "1") in a grid. The approach: walk every cell, and each time you find new land, sink (flood-fill) the whole island so it's not counted again.
rows, cols = len(grid), len(grid[0])grabs the grid dimensions so the helper can check boundaries.sink(r, c)is a recursive flood fill. The guard0 <= r < rows and 0 <= c < cols and grid[r][c] == "1"stops it from running off the edge or onto water/visited cells.- Once on land,
grid[r][c] = "0"marks it visited (by turning it to water), then it recurses into all four neighbours — spreading across the whole connected island. - The double
forloop scans every cell; each fresh"1"means one new island, socount += 1and wesinkit flat before moving on.
What the output means: Returns the number of separate islands — e.g. 3 for the grid drawn in the diagram above.
Try this: This uses DFS (recursion). The very same problem can be solved with BFS and a queue — try rewriting sink to push neighbours onto a deque instead of recursing.
pythonfrom collections import deque
# (a) Rotting oranges (classic) — BFS from ALL rotten cells at once
def oranges_rotting(grid):
rows, cols = len(grid), len(grid[0])
q = deque(); fresh = 0
for r in range(rows):
for c in range(cols):
if grid[r][c] == 2: q.append((r, c, 0)) # seed all sources
elif grid[r][c] == 1: fresh += 1
minutes = 0
while q:
r, c, t = q.popleft(); minutes = max(minutes, t)
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 grid[nr][nc] == 1:
grid[nr][nc] = 2; fresh -= 1; q.append((nr, nc, t+1))
return minutes if fresh == 0 else -1
# (b) Course schedule (classic) — cycle detect via topological sort (Kahn)
def can_finish(n, prereqs):
adj = {i: [] for i in range(n)}; indeg = [0] * n
for a, b in prereqs:
adj[b].append(a); indeg[a] += 1
q = deque([i for i in range(n) if indeg[i] == 0])
done = 0
while q:
node = q.popleft(); done += 1
for nxt in adj[node]:
indeg[nxt] -= 1
if indeg[nxt] == 0: q.append(nxt)
return done == n # all scheduled → no cycle
print(can_finish(2, [[1,0]]), can_finish(2, [[1,0],[0,1]])) # True False
Two grid-graph variations: (a) multi-source BFS (many starting points spreading at once) and (b) topological sort to detect a cycle in a dependency graph.
- (a)
oranges_rotting: rot spreads to neighbours each minute. Instead of one start, we seed all rotten oranges into the queue at once (multi-source BFS) and countfreshoranges. - Each queued item carries
(row, col, time). Popping a cell and pushing its fresh neighbours witht+1spreads the rot outward level by level;minutestracks the last time stamp. If any fresh orange survives, we return-1. - (b)
can_finish: courses with prerequisites form a directed graph.indeg[a]counts how many prerequisites courseastill has; we start with everything that has zero prerequisites. - Each time we "take" a course we decrement its dependents'
indeg; any that hit 0 join the queue. If we manage to schedule allncourses, there was no cycle —return done == n.
What the output means: Prints True False: [[1,0]] is schedulable (take 0 then 1), but [[1,0],[0,1]] is a deadlock — each needs the other.
Try this: Multi-source BFS is the "fire spreads from many points" pattern. Topological sort is the "do tasks in dependency order" pattern — and if you can't finish, you've found a cycle.
7 · Trees: DFS, BFS & LCA common
Tell: "level order" → BFS; "path / depth / diameter / validate" → DFS recursion returning info up; "lowest common ancestor" → recurse and bubble up where the two targets are found.
The lowest common ancestor (LCA) of two nodes is the deepest node that has both of them somewhere below it — like the nearest shared boss on an org chart. This diagram shows how one DFS finds it.
- The circles are tree nodes; lines connect a parent to its children. We're finding the LCA of
5and1. - The search recurses down, then reports back up: each node answers "did I find 5 or 1 in my subtree?" The side labels (
left→5,right→1) show node 3 getting a hit from both sides. - The node that first receives a "found" from both its left and right children is the LCA — here that's node
3, where the two targets first split apart. - If both targets are found on the same side, the answer simply bubbles further up from that side.
In short: "Bubbling up" means the recursion returns information from the leaves back toward the root. The first node that sees its two targets arrive from opposite subtrees is their meeting point — one O(n) DFS.
pythondef lowest_common_ancestor(root, p, q):
if root is None or root is p or root is q:
return root # found a target (or hit the bottom)
left = lowest_common_ancestor(root.left, p, q)
right = lowest_common_ancestor(root.right, p, q)
if left and right:
return root # p & q split here -> this is the LCA
return left or right # both on one side -> pass it up O(n)
Problem: find the lowest common ancestor of nodes p and q in a binary tree. This tiny function is a masterclass in "recurse and let answers bubble up."
- The base case
if root is None or root is p or root is q: return rootstops the recursion when we found a target (porq) or ran off the bottom (None). It reports back whatever it hit. left = ...(root.left, ...)andright = ...(root.right, ...)ask each subtree "did you find either target?" This is the recursion doing the work for us.if left and right: return rootis the key line: if both sides found something, the current node is wherepandqsplit — so it's the LCA.return left or righthandles the other case: if only one side found a target, pass that result upward so an ancestor can decide.
What the output means: Returns the LCA node — for targets 5 and 1 in the diagram's tree, it returns node 3.
Try this: This one function quietly covers three outcomes: both targets on the left, both on the right, or split. Trace each case on paper to see how left or right forwards the answer up.
8 · Backtracking (subsets, permutations, combinations) common
Tell: "generate all / find all valid …", combinatorial explosion, constraints (N-queens, Sudoku, word-search). Choose → recurse → un-choose.
choose → recurse → un-choose pattern walks every branch, reusing one path list.
Backtracking explores every combination by making a series of choices, then undoing them. This diagram shows all subsets of [1,2,3] as a decision tree.
- The root at the top is the empty set
{}— no decisions made yet. - At each level you decide one element: the left branch "includes" it, the right branch "excludes" it. Every path from top to bottom is one specific subset.
- Because each of the
nelements is a yes/no choice, the tree has 2ⁿ leaves — exactly the number of possible subsets. - The
choose → recurse → un-chooseloop walks the tree, reusing a singlepathlist: add an element, explore deeper, then remove it to try the other branch.
In short: Backtracking = try a choice, explore everything it leads to, then undo it and try the next. That undo ("un-choose") is what lets one list explore all 2ⁿ branches.
pythondef subsets(nums):
res, path = [], []
def backtrack(start):
res.append(path[:]) # every node is a valid subset
for i in range(start, len(nums)):
path.append(nums[i]) # choose
backtrack(i + 1) # explore (no reuse)
path.pop() # un-choose
backtrack(0)
return res # 2ⁿ subsets
print(subsets([1, 2, 3])) # [], [1], [1,2], [1,2,3], [1,3], [2], [2,3], [3]
Problem: generate all subsets of a list. This is the canonical backtracking template — learn its shape and permutations, combinations, and N-queens all follow the same skeleton.
resholds all subsets found;pathis the subset we're currently building as we descend the tree.res.append(path[:])records the current subset — and note every node is a valid subset, not just the leaves, so we save on entry. The[:]stores a snapshot copy (otherwise later edits would corrupt it).- The loop
for i in range(start, len(nums))tries each remaining element.path.append(nums[i])is choose;backtrack(i + 1)is explore (starting afteriso elements aren't reused);path.pop()is un-choose. - The
startindex is what prevents duplicate subsets like[1,2]and[2,1]— we only ever move forward.
What the output means: Prints all 8 subsets of [1,2,3]: [], [1], [1,2], [1,2,3], [1,3], [2], [2,3], [3].
Try this: Comment out path.pop() and watch the output break — without the "un-choose" step the shared path never rewinds, so every branch corrupts the next.
9 · Dynamic programming the hard round expert
Tell: "count the ways", "min/max cost/steps", "can you reach/partition", overlapping subproblems. Define a state, a recurrence, and a base case — then either memoize (top-down) or fill a table (bottom-up).
Dynamic programming (DP) solves a big problem by filling a table of smaller answers, each reused instead of recomputed. This shows the classic 0/1 knapsack table.
- Each cell
dp[i][w]answers a subproblem: the best value using the firstiitems with a bag capacity ofw. - The two arrows show how a cell is built from earlier ones: ↖ skip this item (copy the value above) or ↑ take it (its value plus the best that fit in the leftover capacity).
- Each cell is the
maxof those two choices —max(skip, take)— which is why the green cell is the winner of the comparison. - Because every subproblem is solved once and stored, exponential recursion collapses to an O(n·W) table.
In short: The two DP hallmarks: overlapping subproblems (the same cell would be recomputed many times by naive recursion) and optimal substructure (the best full answer is built from best sub-answers). Spot those and reach for a table.
python# Fewest coins to make `amount` — bottom-up DP, O(amount · coins)
def coin_change(coins, amount):
dp = [0] + [float("inf")] * amount # dp[x] = min coins for x
for x in range(1, amount + 1):
for c in coins:
if c <= x:
dp[x] = min(dp[x], dp[x - c] + 1)
return dp[amount] if dp[amount] != float("inf") else -1
# Longest common subsequence — the classic 2-D table, O(m·n)
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 # match -> extend diagonal
else:
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
return dp[m][n]
print(coin_change([1,2,5], 11), lcs("abcde", "ace")) # 3 3
Two bottom-up DP classics. (a) Coin change (fewest coins to reach an amount) builds a 1-D table; (b) longest common subsequence (LCS) builds a 2-D table. Both fill small answers first, then combine them.
- (a)
coin_change:dp[x]is the fewest coins to make amount x. Start withdp[0]=0and everything elseinf(unreachable). - For each amount
xand each coinc,dp[x] = min(dp[x], dp[x-c] + 1)asks "is using this coin (1 coin plus the best way to make the remainderx-c) better than what I have?" That's the recurrence. - (b)
lcs:dp[i][j]is the longest subsequence shared by the firstiletters ofaand firstjofb. - If the current letters match, extend the diagonal:
dp[i-1][j-1] + 1. If not, carry the best of dropping one letter from either string:max(dp[i-1][j], dp[i][j-1]).
What the output means: Prints 3 3: it takes 3 coins (5+5+1) to make 11 from [1,2,5], and the longest common subsequence of "abcde" and "ace" is "ace" (length 3).
Try this: For coin change, print the whole dp array at the end and read off how each amount's answer was built from a smaller one — that's DP made visible.
python# (a) Climbing stairs (classic) — Fibonacci in disguise, O(n) / O(1) space
def climb(n):
a, b = 1, 1
for _ in range(n): a, b = b, a + b # ways(i) = ways(i-1) + ways(i-2)
return a
# (b) House robber (classic) — take-or-skip, O(n) / O(1) space
def rob(nums):
take = skip = 0
for x in nums:
take, skip = skip + x, max(skip, take) # rob this? then couldn't rob prev
return max(take, skip)
# (c) Longest increasing subsequence (classic) — O(n log n) with bisect
import bisect
def lis(nums):
tails = []
for x in nums:
i = bisect.bisect_left(tails, x) # first tail >= x
if i == len(tails): tails.append(x)
else: tails[i] = x # keep tails as small as possible
return len(tails)
# (d) Word break (classic) — dp[i] = can we segment s[:i]? O(n²)
def word_break(s, words):
wordset = set(words); dp = [True] + [False] * len(s)
for i in range(1, len(s) + 1):
for j in range(i):
if dp[j] and s[j:i] in wordset:
dp[i] = True; break
return dp[len(s)]
print(climb(5), rob([2,7,9,3,1]), lis([10,9,2,5,3,7,101,18])) # 8 12 4
Four more DP staples that show the space-saving trick: when a cell only needs the last one or two cells, you can drop the whole table and keep a couple of variables.
- (a)
climb: ways to reach stepn= ways to reachn-1+ ways to reachn-2— literally Fibonacci in disguise. Two rolling variablesa, breplace the table → O(1) space. - (b)
rob: at each house choose take-or-skip.take, skip = skip + x, max(skip, take)— if you rob this house you add it to the best that skipped the previous one; otherwise carry the best so far. - (c)
lis: longest increasing subsequence in O(n log n).tailskeeps the smallest possible tail for each length;bisect_leftfinds where the new value fits, replacing a tail (to keep options open) or extending the list. - (d)
word_break:dp[i]= "cans[:i]be split into dictionary words?" It's true if some earlier valid pointdp[j]is true ands[j:i]is a word.
What the output means: Prints 8 12 4: 8 ways to climb 5 stairs; max robbery of [2,7,9,3,1] is 12 (2+9+1); longest increasing subsequence length is 4.
Try this: For climb, print a, b each iteration — you'll see the Fibonacci sequence appear. Recognizing a problem as "Fibonacci-shaped" instantly gives you the O(1) solution.
dp[state], write the recurrence, pick top-down (memo, D3) or bottom-up. Interview staples: climbing-stairs, house-robber, knapsack, coin-change, LCS, edit-distance, longest-increasing-subsequence, word-break, unique-paths, partition-equal-subset-sum, decode-ways, maximum-subarray (Kadane).10 · Two more high-frequency tools common
Two patterns that appear constantly and are easy to name once seen:
- Monotonic stack — "next greater/smaller element", "daily temperatures", "largest rectangle in histogram", "trapping rain water". Keep a stack that stays sorted; pop while the new element breaks the order. O(n) (built on the stack from D2).
- Prefix sum + hashmap — "subarray sum equals K", "continuous subarray sum", "count nice subarrays". Store running prefix sums in a dict to find a needed complement in O(1) (combines D1 prefix sums + D3 hashing).
pythondef daily_temperatures(temps):
res = [0] * len(temps)
stack = [] # indices, temps DECREASING down the stack
for i, t in enumerate(temps):
while stack and temps[stack[-1]] < t: # t is the "next warmer" for these
j = stack.pop()
res[j] = i - j # days until warmer
stack.append(i)
return res # O(n): each index pushed/popped once
print(daily_temperatures([73,74,75,71,69,72,76,73])) # [1,1,4,2,1,1,0,0]
Problem: for each day, how many days until a warmer temperature? A brute-force double loop is O(n²); a monotonic stack does it in O(n) by remembering days still "waiting" for a warmer one.
res = [0] * len(temps)defaults every answer to 0 (meaning "no warmer day ahead").stackholds indices of days whose warmer day hasn't been found yet, kept with temperatures decreasing down the stack.- For each new day
iwith temperaturet, thewhileloop pops every day on the stack that is colder than today — because today is their next warmer day. res[j] = i - jrecords the wait: the gap in days between the colder dayjand todayi.- Then
stack.append(i)parks today to wait for its warmer day. Each index is pushed and popped at most once → O(n).
What the output means: Prints [1,1,4,2,1,1,0,0] — e.g. day 0 (73°) waits 1 day for 74°; the last two days have no warmer day, so 0.
Try this: "Monotonic" just means the stack stays sorted (here decreasing). The tell for this pattern is "next greater/smaller element" — daily-temperatures, next-greater-element, and largest-rectangle all use it.
pythonfrom collections import defaultdict
def subarray_sum(nums, k):
seen = defaultdict(int)
seen[0] = 1 # empty prefix
running = count = 0
for x in nums:
running += x
count += seen[running - k] # a prefix that leaves exactly k? O(1)
seen[running] += 1
return count # O(n) time, O(n) space
print(subarray_sum([1, 1, 1], 2)) # 2
Problem: count how many contiguous subarrays sum to exactly k. The prefix-sum + hashmap trick turns an O(n²) search into a single O(n) pass.
- A prefix sum is the running total from the start. If two prefix sums differ by
k, the numbers between them sum tok— that's the whole insight. seencounts how many times each running total has occurred;seen[0] = 1seeds the "empty prefix" so subarrays starting at index 0 are counted.- Each step adds
xtorunning, thencount += seen[running - k]asks "how many earlier prefixes leave exactly k between them and here?" — an O(1) dictionary lookup instead of scanning back. seen[running] += 1then records the current prefix for future steps to find.
What the output means: Prints 2 — in [1,1,1] with k=2, the subarrays [1,1] at positions 0–1 and 1–2 both sum to 2.
Try this: The magic is running - k: instead of asking "what sums to k?" you ask "have I seen a prefix that, subtracted from now, leaves k?" This complement-in-a-hashmap idea also powers Two Sum.
11 · Linked list manipulation very common
Tell: the input is a linked list and you must reverse, merge, reorder, or find a node with O(1) space. The toolkit: a dummy head to kill edge cases, pointer-flip reversal, and fast/slow pointers. This is such a big topic it has its own page — D8 · Linked list patterns — but here are the interview must-knows with diagrams.
prev/cur/nxt; save the next node before overwriting the pointer. This single move powers reverse-list, reverse-in-K-groups, palindrome, and reorder.
A linked list is a chain of nodes where each one points to the next. Reversing it means flipping every arrow to point backward. This diagram shows the three-pointer dance that does it.
- The boxes are nodes (1→2→3→4); the arrows are the
nextpointers linking them. - Three pointers do the work:
prev(the part already reversed, behind us),cur(the node we're flipping now), andnxt(the next node — saved first so we don't lose the rest of the list). - The blue backward arrows show pointers already flipped to point at
prev; the grey forward arrow is the link we're about to rewrite. - Each step: save
nxt, pointcur.nextback atprev, then slide all three forward. Whencurfalls off the end,previs the new head.
In short: The one rule that keeps you safe: save the next node before overwriting the pointer. Miss that and you lose the tail of the list. This move powers reverse, reverse-in-K-groups, palindrome, and reorder.
pythonclass ListNode:
def __init__(self, val=0, nxt=None): self.val, self.next = val, nxt
# (a) Reverse a list (classic) — O(n), O(1)
def reverse(head):
prev = None
while head:
head.next, prev, head = prev, head, head.next
return prev
# (b) Merge two sorted lists (classic) — build behind a dummy tail
def merge_two(a, b):
dummy = tail = ListNode()
while a and b:
if a.val <= b.val: tail.next, a = a, a.next
else: tail.next, b = b, b.next
tail = tail.next
tail.next = a or b
return dummy.next
# (c) Remove nth node from end (classic) — offset two-pointer, one pass
def remove_nth_from_end(head, n):
dummy = ListNode(0, head); fast = slow = dummy
for _ in range(n): fast = fast.next # open an n-gap
while fast.next: fast, slow = fast.next, slow.next
slow.next = slow.next.next # unlink the target
return dummy.next
Three must-know linked-list moves, all in O(1) extra space: (a) reverse, (b) merge two sorted lists using a dummy head, and (c) remove the nth node from the end with an offset two-pointer.
ListNodeis the node type: avaland anextpointer to the following node.- (a)
reverse:head.next, prev, head = prev, head, head.nextflips one link and advances in a single line (Python computes the whole right side before assigning). - (b)
merge_two: a dummy head node lets us build behind a dummy tail without special-casing the first node. We repeatedly attach the smaller of the two list heads; at the endtail.next = a or bappends whatever remains, anddummy.nextis the real merged head. - (c)
remove_nth_from_end: movefastnnodes ahead to open a gap, then movefastandslowtogether. Whenfastreaches the end,slowsits just before the target, soslow.next = slow.next.nextunlinks it — all in one pass.
What the output means: Each returns a rebuilt list head: (a) the reversed list, (b) the two lists merged in sorted order, (c) the list with the nth-from-end node removed.
Try this: The dummy head is the unsung hero — it removes the "what if the list is empty / we remove the first node?" edge cases so your main loop stays clean. Watch for it in almost every linked-list solution.
fast n nodes first; then move fast and slow together until fast hits the end — slow now sits just before the node to remove. No length precompute.
This shows the one-pass way to remove the nth node from the end of a linked list — without first counting its length. The trick is an offset (a fixed gap) between two pointers.
- The boxes are the list nodes (1→2→3→4→5); here we're removing the 2nd-from-end (node 4, though the red-marked cell illustrates the target).
- First move
fastforwardnnodes to open an n-sized gap ahead ofslow. - Then advance both together until
fastreaches the end. Because the gap is fixed atn,slowends up exactlyn+1from the end — just before the node to remove. slowthen relinks past the target (slow.next = slow.next.next), unlinking it in a single pass.
In short: An offset between two pointers turns "nth from the end" into "catch up to the end" — no length precompute, one traversal. A dummy head handles the edge case of removing the very first node.
Pattern → tell cheat-sheet expert
| If the problem says… | Reach for | Complexity |
|---|---|---|
| longest/shortest contiguous subarray/substring | sliding window | O(n) |
| sorted array, find pair/triplet | two pointers | O(n)/O(n²) |
| cycle / middle / nth-from-end of a list | fast & slow pointers | O(n) |
| K largest/smallest/most-frequent/closest | size-K heap | O(n log k) |
| min/max value such that condition holds | binary search on answer | O(n log range) |
| overlapping start/end ranges | sort + merge intervals | O(n log n) |
| grid regions / flood fill / maze shortest path | BFS/DFS on grid | O(rc) |
| tree level order / path / LCA | BFS or DFS recursion | O(n) |
| generate all / find all valid | backtracking | exponential |
| count ways / optimize over choices + repeats | dynamic programming | poly (states) |
| next greater/smaller element | monotonic stack | O(n) |
| subarray sum equals K | prefix sum + hashmap | O(n) |
| reverse / merge / reorder a linked list | dummy head + pointer-flip + fast/slow | O(n), O(1) |
Checkpoint — you're interview-ready when you can… expert
- Read a problem and name the pattern from its "tell" within a minute.
- Code sliding-window, two-pointer, top-K heap, and BFS/DFS-on-grid from muscle memory.
- Reframe an optimization problem as binary-search-on-the-answer.
- Write the backtracking skeleton and a DP recurrence (top-down or bottom-up) for the classics.
- State the time & space complexity of your solution — and why it's optimal.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: The fixed-size sliding window is the pattern behind a whole family of "contiguous subarray" questions and a fast interview win.
Your task: Find the maximum sum of any contiguous window of size k in O(n) by sliding the window rather than recomputing each sum.
Requirements:
- Compute the first window's sum once
- Slide by adding the incoming element and subtracting the outgoing one
O(n)time (no nested recompute)- Return the maximum window sum
- Handle
klarger than the array gracefully
💡 Hint: Each slide is two arithmetic operations, so the running sum never has to be rebuilt from scratch.
Show solution
def max_window_sum(nums, k): # O(n) time, O(1) space
window = sum(nums[:k])
best = window
for i in range(k, len(nums)):
window += nums[i] - nums[i - k] # add entering, drop leaving
best = max(best, window)
return best
print(max_window_sum([2, 1, 5, 1, 3, 2], 3)) # 9 (5+1+3)
Reusing the previous sum turns an O(n·k) recompute into a single O(n) sweep — the sliding-window "tell".
Context: Merge-intervals (classic) is the tell for a whole class of scheduling and range problems: sort, then sweep.
Your task: Given a list of intervals, merge all overlapping ones by sorting on start then sweeping, in O(n log n).
Requirements:
- Sort intervals by start
- Extend the current interval when the next one overlaps it
- Emit a new interval when there's a gap
O(n log n)dominated by the sort- Return the merged, non-overlapping list
💡 Hint: After sorting, an overlap exists whenever the next start is ≤ the current end; otherwise close off the current interval and start fresh.
Show solution
def merge_intervals(intervals): # O(n log n)
intervals.sort(key=lambda x: x[0])
merged = [intervals[0]]
for start, end in intervals[1:]:
if start <= merged[-1][1]: # overlaps the last merged interval
merged[-1][1] = max(merged[-1][1], end)
else:
merged.append([start, end])
return merged
print(merge_intervals([[1, 3], [2, 6], [8, 10], [15, 18]]))
# [[1, 6], [8, 10], [15, 18]]
After sorting by start, an interval can only overlap the most recent merged one — so a single pass suffices.
Context: Top-K frequent (classic) combines a frequency map with a size-k heap — the efficient way to avoid a full sort.
Your task: Return the k most frequent elements by counting with a hash map then using a heap of size k, in O(n log k).
Requirements:
- Tally frequencies with a hash map
- Keep a heap bounded to size k rather than sorting everything
O(n log k), better thanO(n log n)when k ≪ n- Return the k most frequent elements
- Break ties in any consistent way
💡 Hint: A min-heap of size k lets you drop the smallest whenever it overflows, so only the top k survive.
Show solution
import heapq
from collections import Counter
def top_k_frequent(nums, k): # O(n log k)
counts = Counter(nums)
return [val for val, _ in heapq.nlargest(k, counts.items(),
key=lambda kv: kv[1])]
print(top_k_frequent([1, 1, 1, 2, 2, 3], 2)) # [1, 2]
A size-k heap keeps only the current top-k, so memory is O(k) and each of n pushes costs log k — cheaper than sorting all counts when k is small.
Context: "Binary search on the answer" (ship-within-D-days, classic problem) is a powerful pattern: search a value space, not an array, when feasibility is monotonic.
Your task: Find the minimum ship capacity to deliver package weights within D days by binary-searching the capacity, in O(n log(sum)).
Requirements:
- Recognise the answer is monotonic: bigger capacity is never worse
- Binary-search the capacity between max-weight and total-weight
- A feasibility check counts days needed for a candidate capacity
O(n log(sum))- Return the smallest feasible capacity
💡 Hint: Write a can_ship(capacity) predicate first; then binary-search for the smallest capacity where it flips from false to true.
Show solution
def ship_within_days(weights, D):
def days_needed(cap): # greedy fill under a fixed capacity
days, load = 1, 0
for w in weights:
if load + w > cap:
days += 1; load = 0
load += w
return days
lo, hi = max(weights), sum(weights) # feasible capacity range
while lo < hi:
mid = (lo + hi) // 2
if days_needed(mid) <= D:
hi = mid # feasible -> try smaller
else:
lo = mid + 1 # infeasible -> need bigger
return lo
print(ship_within_days([1,2,3,4,5,6,7,8,9,10], 5)) # 15
The tell: "minimize a value subject to a monotone feasibility check". Binary-search the value and use a greedy feasibility test as the predicate.
Context: Combination-sum (classic) is the backtracking template with reuse and pruning — a common medium that tests recursion discipline.
Your task: Given distinct candidates and a target, return all unique combinations that sum to the target where each number may be reused, using backtracking with pruning.
Requirements:
- Reuse a candidate by recursing without advancing past it
- Prune a branch once the running sum exceeds the target
- Advance the start index to avoid duplicate combinations
- Collect a combination when the remaining target hits zero
- Return all unique combinations
💡 Hint: Pass a start index into the recursion so you only ever look forward; subtracting the chosen value from the target makes the base case a clean zero.
Show solution
def combination_sum(candidates, target):
candidates.sort()
result = []
def backtrack(start, remaining, path):
if remaining == 0:
result.append(path[:]); return
for i in range(start, len(candidates)):
if candidates[i] > remaining:
break # sorted -> no later candidate fits either
path.append(candidates[i])
backtrack(i, remaining - candidates[i], path) # i (reuse allowed)
path.pop()
backtrack(0, target, [])
return result
print(combination_sum([2, 3, 6, 7], 7)) # [[2, 2, 3], [7]]
Passing i (not i+1) allows reuse; the sorted break prunes whole branches once the remainder is exceeded.
Context: Coin-change minimum-coins (classic) is a top-frequency onsite DP and the cleanest introduction to bottom-up tabulation.
Your task: Given coin denominations and an amount, return the minimum number of coins that make it (or -1) using bottom-up DP, in O(amount·coins).
Requirements:
- Build a table of fewest coins for every value up to the amount
- Each value takes the best over all coins ≤ it, plus one
- Unreachable amounts return -1
O(amount·coins)- The answer is the table entry at the full amount
💡 Hint: Initialise the table to infinity, set dp[0]=0, and relax dp[v] = min(dp[v], dp[v−coin]+1) for each coin.
Show solution
def coin_change(coins, amount): # O(amount * len(coins))
INF = amount + 1
dp = [0] + [INF] * amount # dp[x] = fewest coins to make x
for x in range(1, amount + 1):
for c in coins:
if c <= x:
dp[x] = min(dp[x], dp[x - c] + 1)
return dp[amount] if dp[amount] != INF else -1
print(coin_change([1, 2, 5], 11)) # 3 (5 + 5 + 1)
print(coin_change([2], 3)) # -1
Each amount's answer is built from strictly smaller amounts already solved — the hallmark of a DP recurrence. Greedy (largest coin first) is wrong for arbitrary denominations, which is why DP is required.
Knowledge check check yourself
Which problem signal tells you to reach for a heap-based Top-K pattern, and what complexity does it give?
Show answer
What is “binary search on the answer,” and when does it apply?