Deep dive
Coding interview playbook
Getting the optimal solution is only part of the score. Interviewers grade how you get there: clarifying, reasoning out loud, writing clean code, and testing it yourself.
The formats you'll meet
| Format | Length | What's different |
| Online assessment (OA) | 60–90 min, 2–4 problems | Auto-graded on hidden tests. Speed and edge cases matter. No one hears your reasoning. |
| Phone / video screen | 45–60 min, 1–2 problems | Shared editor (CoderPad, HackerRank). Often you can't run the code. |
| Onsite / virtual loop coding | 45 min each, 2–3 rounds | Same as the screen, plus follow-ups ("what if the input doesn't fit in memory?"). |
What you're scored on
| Signal | Strong | Weak |
| Problem solving | Clarifies, finds brute force, then improves it with reasons | Jumps to a memorized solution, or freezes |
| Coding | Clean, correct, idiomatic, with good names and helper functions | Off-by-one bugs, messy indexes, unclear variables |
| Verification | Traces an example by hand and tests edge cases before being asked | Says "I think it works" |
| Communication | Explains the plan and trade-offs, and takes hints well | Codes in silence, or argues with hints |
| Complexity | States time and space without being asked | Unsure, or wrong |
How to spend 45 minutes
- 0–5 · Clarify. Restate the problem. Ask about input size, ranges, duplicates, negatives, empty input, and the output format. Write down one example and one edge case.
- 5–12 · Brute force, then improve. Say the naive approach and its Big-O. Name the bottleneck ("the inner loop searches for the complement"). Match it to a pattern ("a hash map makes that lookup O(1)").
- 12–15 · Agree on the plan. "I'll use X, giving O(n) time and O(n) space. Sound good?" Only code once the interviewer agrees.
- 15–35 · Code while narrating. Use helper functions for clarity. If you're unsure about a detail, say so and leave a
# TODO instead of stalling.
- 35–42 · Test. Trace your example line by line, then the edge cases. Fix bugs calmly. Finding your own bug is a positive signal.
- 42–45 · Wrap up. Restate the complexity, mention possible improvements, and answer follow-ups.
Clarifying questions checklist
SizeHow big can n be? This tells you the target complexity.
ValuesNegatives? Zeros? Duplicates? Integer overflow? Unicode in strings?
ShapeSorted? Can it be empty? A single element? Is the graph connected? Can it have cycles?
OutputIndexes or values? Any valid answer, or all of them? Order? What if there's no answer?
ConstraintsCan I modify the input? Is extra memory allowed? Is it a stream?
Phrases that show your thinking
"Let me restate it to make sure I understand: ..."
"The brute force checks every pair, O(n²). With n up to 10^5 that's too slow."
"The repeated work is searching for the complement, so I'll store what I've seen in a hash map."
"This is a 'longest substring with a constraint' problem, so I'm thinking sliding window."
"Let me trace through [2, 7, 11] with target 9 to check the indexes."
"Edge cases: empty array, one element, and no valid pair. Let me check each."
"Time is O(n): one pass, with O(1) hash operations. Space is O(n) for the map."
When you're stuck
- Solve a tiny example by hand, then ask yourself what your brain just did.
- Ask: would sorting help? A hash map? Two pointers? Thinking in reverse? A heap?
- Go through the cheat sheet in the crash course toolkit, from top to bottom.
- Say what you're considering out loud. Interviewers often hint when they can see where you are. Taking a hint costs far less than staying silent.
Common ways to fail
- Starting to code before agreeing on an approach.
- Ignoring the constraints and writing O(n²) for n = 10⁵.
- Not knowing your language:
sort with a key, heapq, deque, Counter, bisect, string methods.
- Never testing, or testing only the happy path.
- Getting defensive about hints or bugs.
An 8-week study plan
| Weeks | Topics | Goal |
| 1–2 | Big-O, arrays, hashing, two pointers, sliding window, stack | Easy problems in under 15 min, then Mediums |
| 3–4 | Binary search, linked lists, trees, heaps | Recursion feels natural |
| 5–6 | Graphs, backtracking, dynamic programming, intervals | Recognize the pattern within 5 minutes |
| 7–8 | Mixed timed sets, company-tagged problems, mock interviews | 2 Mediums in 45 minutes, while talking |
- 2–3 new problems a day, plus re-solving one from a few days ago (spaced repetition beats volume).
- Stuck for 25 minutes? Read the solution, understand it, close it, and re-code it two days later without looking.
- Keep a log: problem, pattern, the key insight in one line, and mistakes made. Review it before interviews.
- Do at least 3–5 mock interviews with another person. Talking while coding is a separate skill.
Deep dive
Complexity analysis
Big-O describes how the work grows as the input grows. It ignores the machine and the constants and keeps only the shape of the growth. It is how you know a solution will pass before you run it.
Five rules for counting
- Steps in sequence add. A loop over n followed by another loop over n is
O(n + n) = O(n).
- Nested steps multiply. A loop over n inside a loop over m is
O(n · m).
- Keep the biggest term and drop constants.
O(3n² + 10n + 7) is O(n²). For large n the biggest term dominates everything else.
- Different inputs get different letters. Looping over string a and, inside it, string b is
O(a · b), not O(n²).
- Halving gives a log. If each step cuts the remaining work in half, you need about log₂(n) steps. log₂(1,000,000) ≈ 20, so
O(log n) is almost free.
Common complexities, fastest to slowest
| Big-O | Name | Where you see it | n = 10⁶ takes |
| O(1) | constant | array index, hash lookup, push/pop on a stack | 1 step |
| O(log n) | logarithmic | binary search, balanced BST, heap push/pop | ~20 |
| O(n) | linear | one pass, two pointers, sliding window, BFS/DFS | 10⁶ |
| O(n log n) | linearithmic | sorting, n heap operations | ~2·10⁷ |
| O(n²) | quadratic | all pairs, nested loops, 2D DP on n×n | 10¹² (too slow) |
| O(2ⁿ) | exponential | all subsets, naive recursion with two branches | impossible |
| O(n!) | factorial | all permutations | impossible |
Cost of recursion: count the call tree
Total time = number of calls × work per call. When a function calls itself twice and goes n levels deep, the calls form a tree with about 2ⁿ nodes. The tree below is plain recursive Fibonacci. The yellow nodes are the same call computed again. Remembering those answers is exactly what dynamic programming does.
- Two branches, depth n:
O(2ⁿ). With memoization each distinct call runs once: O(n).
- Merge sort: depth log n, and each level does O(n) total work:
O(n log n).
- Binary search: one branch, depth log n:
O(log n).
Space complexity
- Count the extra memory you create: a set of n items is
O(n), a few variables are O(1).
- Recursion uses memory. Each pending call sits on the call stack, so DFS on a tree of height h uses
O(h). For a tree that's one long chain, h = n.
- The output you must return is usually not counted.
- Python's default recursion limit is about 1000 calls. For deeper recursion, use
sys.setrecursionlimit or an explicit stack.
Amortized O(1)
A Python list reserves extra capacity. When it fills, it copies everything into a block about twice as big. That copy is O(n), but it happens so rarely that n appends cost O(n) in total, so each append is O(1) amortized (on average across many calls).
Hidden costs in Python
| Code | Cost | Note |
| x in my_list | O(n) | Scans the list. Use a set for O(1). |
| lst.pop(0), lst.insert(0, x) | O(n) | Shifts every item. Use deque.popleft(). |
| s += ch in a loop | O(n²) total | Strings are immutable, so each += copies. Append to a list, then ''.join(). |
| a[i:j] | O(j − i) | Slicing copies. Watch for slices inside loops and recursion. |
| sorted(a), a.sort() | O(n log n) | Timsort, stable. |
| min, max, sum, a.index, a.count | O(n) | Each one is a hidden loop. |
| len(a), a[i], a.append, a.pop() | O(1) | |
| dict / set get, add, in, del | O(1) average | |
| heappush, heappop / heapify | O(log n) / O(n) | |
One problem, three trade-offs
def has_dup(a): # O(n²) time, O(1) space: compare every pair
for i in range(len(a)):
for j in range(i + 1, len(a)):
if a[i] == a[j]: return True
return False
def has_dup(a): # O(n) time, O(n) space: spend memory to save time
return len(set(a)) != len(a)
def has_dup(a): # O(n log n) time, O(1) extra: sort, then compare neighbours
a.sort()
return any(a[i] == a[i + 1] for i in range(len(a) - 1))
All three are correct. Choosing between them by time and memory is what interviewers want to hear you discuss.
Check yourself
What is the complexity?
i = 1
while i < n:
i *= 2
O(log n). i doubles each time, so it reaches n after log₂(n) steps.
What is the complexity?
for i in range(n):
for j in range(i, n):
print(i, j)
O(n²). The inner loop runs n + (n−1) + … + 1 = n(n+1)/2 times. Halving a quadratic is still quadratic.
What is the complexity?
for x in a: # len(a) = n
if x in b_set: # set of size m
count += 1
O(n). Set lookup is O(1). If b were a list, it would be O(n · m).
Deep dive
Arrays & strings
An array stores items in one continuous block of memory. That's why reading any index is instant and inserting in the middle is slow.
How it works
- The address of item i is
start + i × item_size. One multiplication, so a[i] is O(1).
- Inserting or deleting in the middle forces every later item to shift by one slot:
O(n).
- A Python
list is a dynamic array. It grows automatically, and append/pop at the end are O(1).
- Strings are arrays of characters, but immutable in Python. Every "change" builds a new string.
Technique 1 · Prefix sums
Precompute running totals once in O(n). After that, the sum of any range is one subtraction: sum(a[l..r]) = pre[r+1] − pre[l]. Use it whenever a problem asks for many range sums, or for "subarray sum equals k" together with a hash map.
pre = [0]
for x in nums:
pre.append(pre[-1] + x) # pre[i] = sum of nums[0..i-1]
def range_sum(l, r): # inclusive l..r, O(1)
return pre[r + 1] - pre[l]
Technique 2 · Kadane's algorithm (maximum subarray)
Walk left to right, keeping the best sum of a subarray that ends here. If the running sum goes negative, it can only hurt what comes next, so start fresh.
def maxSubArray(nums):
best = cur = nums[0]
for x in nums[1:]:
cur = max(x, cur + x) # extend the old subarray or start a new one at x
best = max(best, cur)
return best
Technique 3 · In-place with a write pointer
def moveZeroes(nums):
w = 0 # next position to write a kept item
for x in nums:
if x != 0:
nums[w] = x; w += 1
for i in range(w, len(nums)):
nums[i] = 0
Technique 4 · Counting letters with a fixed array
cnt = [0] * 26
for ch in s:
cnt[ord(ch) - ord('a')] += 1 # 'a' -> 0, 'b' -> 1, ...
# tuple(cnt) is hashable, so it can be a dict key (useful for anagram grouping)
2D arrays (matrices and grids)
R, C = len(grid), len(grid[0])
grid = [[0] * C for _ in range(R)] # correct
bad = [[0] * C] * R # WRONG: R references to the SAME row
DIRS = [(1, 0), (-1, 0), (0, 1), (0, -1)]
for dr, dc in DIRS:
nr, nc = r + dr, c + dc
if 0 <= nr < R and 0 <= nc < C: ...
Common mistakes
- Off-by-one errors: is the end index inclusive or exclusive? Decide and write it down.
- Removing items from a list while looping over it. Build a new list or use a write pointer.
- Forgetting empty input, a single item, or all-equal items.
- Building strings with
+= in a loop (O(n²)).
Habit: if the input isn't sorted and order doesn't matter, ask "would sorting first make this easy?" Sorting costs O(n log n) and unlocks two pointers, binary search and greedy approaches.
Problem ladder
Deep dive
Hash maps & sets
A hash map turns a key into an array index with a hash function, so it can jump straight to the data instead of searching for it.
How it works
- A hash function turns the key (a word, a number, a tuple) into a big integer.
hash % number_of_buckets picks a bucket, which is just an array slot.
- Two keys can land in the same bucket. That's a collision. The bucket keeps a small list (chaining), or the table probes the next free slot (open addressing, which Python's dict uses).
- When the table gets too full, it doubles its size and re-inserts everything. That keeps buckets short, so operations stay
O(1) on average.
Keys must be hashable (immutable). str, int and tuple work. list, set and dict don't. To use a list as a key, convert it: tuple(sorted(word)).
Python tools
from collections import Counter, defaultdict
seen = set(); seen.add(x); x in seen # membership
d = {}; d[k] = d.get(k, 0) + 1 # count without KeyError
cnt = Counter(nums); cnt.most_common(2) # [(value, count), ...]
groups = defaultdict(list); groups[key].append(x) # missing key auto-creates []
Pattern 1 · Count frequencies
def isAnagram(s, t):
return Counter(s) == Counter(t)
Pattern 2 · Look up the complement
Two Sum. For each x, check whether target − x was already stored. The animation is in the crash course.
Pattern 3 · Group by a canonical key
Give every item a "signature" that's equal for items that belong together, then group by it.
def groupAnagrams(strs):
groups = defaultdict(list)
for w in strs:
groups[''.join(sorted(w))].append(w) # "eat", "tea", "ate" -> key "aet"
return list(groups.values())
Pattern 4 · Prefix sum + hash map
Count subarrays that sum to k, even with negative numbers. If pre[j] − pre[i] = k, then subarray i..j−1 sums to k. So at each position, ask how many earlier prefixes equal current − k.
def subarraySum(nums, k):
count, cur = 0, 0
seen = {0: 1} # prefix sum -> how many times seen
for x in nums:
cur += x
count += seen.get(cur - k, 0)
seen[cur] = seen.get(cur, 0) + 1
return count
Pattern 5 · Smart starting points
Longest consecutive sequence in O(n): put all numbers in a set, and only start counting from x when x − 1 is not in the set. Each sequence is then walked once.
def longestConsecutive(nums):
s, best = set(nums), 0
for x in s:
if x - 1 not in s: # x starts a sequence
y = x
while y + 1 in s: y += 1
best = max(best, y - x + 1)
return best
Common mistakes
KeyError from d[k] += 1 on a new key. Use .get(k, 0), defaultdict(int) or Counter.
- Changing a dict's size while looping over it. Loop over
list(d) instead.
- Forgetting that sets and dicts don't keep a sorted order.
- Worst case is O(n) per operation if everything collides. This is rare in practice and ignored in interviews.
Problem ladder
Deep dive
Two pointers
Use two indexes so that every step safely rules out part of the input. Most nested-loop solutions on sorted data become a single pass.
Why it's correct (the part most tutorials skip)
In sorted Two Sum, if a[l] + a[r] is too small, then a[l] plus any remaining number is also too small, because a[r] is the biggest one left. So a[l] can never be part of the answer, and moving l right loses nothing. The same argument works in reverse for r. Every move throws away a whole row of pairs, which is why n steps are enough to cover all n² pairs.
Shape 1 · Opposite ends, moving inward
For sorted pair sums, palindromes, reversing in place and "container" problems.
def maxArea(height): # Container With Most Water
l, r, best = 0, len(height) - 1, 0
while l < r:
best = max(best, (r - l) * min(height[l], height[r]))
if height[l] < height[r]: l += 1 # the shorter wall limits the area, so move it
else: r -= 1
return best
Shape 2 · Same direction (reader and writer)
def removeDuplicates(nums): # sorted input; keep one of each
w = 1
for r in range(1, len(nums)):
if nums[r] != nums[w - 1]:
nums[w] = nums[r]; w += 1
return w
Shape 3 · One pointer in each of two arrays
def merge(a, b): # merge two sorted lists
i = j = 0; out = []
while i < len(a) and j < len(b):
if a[i] <= b[j]: out.append(a[i]); i += 1
else: out.append(b[j]); j += 1
return out + a[i:] + b[j:]
Shape 4 · Fast and slow
One pointer moves twice as fast. Use it to find the middle of a linked list or to detect a cycle. It's covered in Linked lists.
Worked example · 3Sum
Find all unique triplets that sum to 0. Sort first. Fix the first number, then run sorted Two Sum on the rest. Skip equal values so you don't repeat triplets. Total: O(n²), which beats O(n³).
def threeSum(nums):
nums.sort(); res = []
for i in range(len(nums) - 2):
if i > 0 and nums[i] == nums[i - 1]: continue # skip duplicate first number
l, r = i + 1, len(nums) - 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]])
l += 1
while l < r and nums[l] == nums[l - 1]: l += 1 # skip duplicate second number
return res
Common mistakes
- Using converging pointers on unsorted data. The "safe to discard" argument needs sorted order.
l < r vs l <= r. Ask whether one item can pair with itself.
- A branch that doesn't move either pointer, causing an infinite loop.
- Duplicate answers in 3Sum or 4Sum because equal values weren't skipped.
Problem ladder
Deep dive
Sliding window
Keep a running summary of a contiguous range. Update it when an item enters on the right or leaves on the left, instead of recomputing the whole range.
Fixed-size window (size k)
def findMaxAverage(nums, k):
s = sum(nums[:k]); best = s
for r in range(k, len(nums)):
s += nums[r] - nums[r - k] # one item enters, one leaves
best = max(best, s)
return best / k
Variable-size window: the universal template
l = 0
for r in range(len(a)):
add(a[r]) # 1. expand: include a[r]
while window_is_invalid(): # 2. shrink until valid again
remove(a[l]); l += 1
best = max(best, r - l + 1) # 3. record (for "longest")
Longest vs shortest. For the longest valid window, record after shrinking, as above. For the shortest window that satisfies a condition, record inside the while loop: while the window is valid, record it, then shrink.
def minSubArrayLen(target, nums): # shortest subarray with sum >= target
l, s, best = 0, 0, float('inf')
for r, x in enumerate(nums):
s += x
while s >= target: # valid -> record, then try smaller
best = min(best, r - l + 1)
s -= nums[l]; l += 1
return 0 if best == float('inf') else best
Windows that track counts
Longest Repeating Character Replacement: a window is valid when length − count of its most common letter ≤ k, meaning the other letters fit within k replacements.
def characterReplacement(s, k):
cnt, l, maxf, best = {}, 0, 0, 0
for r, ch in enumerate(s):
cnt[ch] = cnt.get(ch, 0) + 1
maxf = max(maxf, cnt[ch])
while (r - l + 1) - maxf > k:
cnt[s[l]] -= 1; l += 1
best = max(best, r - l + 1)
return best
Trick · "exactly K" = atMost(K) − atMost(K − 1)
Windows with exactly K distinct values are hard to slide directly, because adding an item can make the window invalid in either direction. Counting windows with at most K is easy. Subtract two of those counts.
When sliding window fails
- The rule must be monotonic: shrinking a valid window keeps it valid (or growing an invalid one keeps it invalid).
- With negative numbers, a sum can drop as the window grows, so "sum ≥ target" isn't monotonic. Use prefix sums + hash map instead.
- For the max or min inside each window, use a monotonic deque (see Stacks & queues).
Problem ladder
Deep dive
Stacks, queues & monotonic stacks
A stack gives back the most recent item first. A queue gives back the oldest item first. Choosing the right one sets the order your algorithm processes things in.
The operations
| Structure | Python | Add | Remove | Peek |
| Stack (LIFO) | list | append(x) O(1) | pop() O(1) | s[-1] |
| Queue (FIFO) | deque | append(x) O(1) | popleft() O(1) | q[0] |
| Deque (both ends) | deque | append / appendleft | pop / popleft | q[0], q[-1] |
- Recursion is a stack. Every function call is pushed on the call stack and popped when it returns. Any recursive DFS can be rewritten with an explicit stack.
- BFS is a queue. Nodes are processed in the order they were discovered.
- Stacks are good at anything nested or "most recent unmatched": brackets, undo, HTML tags, expression evaluation.
Monotonic stack: "next greater element" in O(n)
Keep the stack in decreasing order. When a bigger value arrives, it is the answer for every smaller value it pops. Each index is pushed once and popped once, so the total is O(n) even though there's a loop inside a loop.
def dailyTemperatures(t): # days until a warmer day
ans, st = [0] * len(t), [] # st holds indexes, temps decreasing
for i, x in enumerate(t):
while st and t[st[-1]] < x:
j = st.pop()
ans[j] = i - j # found the next warmer day for j
st.append(i)
return ans
Variations: "next smaller" uses an increasing stack. "Previous greater" reads the top before pushing. "Circular array" loops through the array twice.
Evaluating expressions
def evalRPN(tokens): # ["2","1","+","3","*"] -> 9
st = []
for t in tokens:
if t in "+-*/":
b, a = st.pop(), st.pop()
st.append(a + b if t == '+' else a - b if t == '-' else a * b if t == '*' else int(a / b))
else:
st.append(int(t))
return st[0]
Monotonic deque: max of every window
from collections import deque
def maxSlidingWindow(nums, k):
dq, out = deque(), [] # indexes, values decreasing front to back
for i, x in enumerate(nums):
while dq and nums[dq[-1]] < x: dq.pop() # smaller values can never be the max again
dq.append(i)
if dq[0] == i - k: dq.popleft() # front fell out of the window
if i >= k - 1: out.append(nums[dq[0]])
return out
Common mistakes
- Calling
pop() or st[-1] on an empty stack. Always check if st first.
- Using
list.pop(0) as a queue: O(n) per call.
- Storing values on a monotonic stack when you need distances. Store indexes instead.
Problem ladder
Deep dive
Binary search
Binary search works on anything that splits into "no, no, no, yes, yes, yes". Each check halves the region that can still hold the first yes.
The real idea: find the boundary
A sorted array is one example. The general version: you have a condition ok(x) that is false for small x and true for large x. Binary search finds the first true in O(log range) checks. That handles "first position ≥ target", "first bad version", "smallest speed that works" and many more.
Template A · exact match
lo, hi = 0, len(a) - 1
while lo <= hi: # the range [lo, hi] is still unchecked
mid = (lo + hi) // 2
if a[mid] == x: return mid
if a[mid] < x: lo = mid + 1
else: hi = mid - 1
return -1
Template B · first true (lower bound) — learn this one well
def first_true(lo, hi, ok): # answer is in [lo, hi]; ok is F..F T..T
while lo < hi:
mid = (lo + hi) // 2
if ok(mid): hi = mid # mid might be the answer, so keep it
else: lo = mid + 1 # mid is definitely not the answer
return lo
# first index with a[i] >= x (same as bisect.bisect_left(a, x))
first_true(0, len(a), lambda i: i == len(a) or a[i] >= x)
No infinite loops: with mid = (lo + hi) // 2, always write lo = mid + 1 and hi = mid. Writing lo = mid can get stuck when hi = lo + 1.
Binary search on the answer
When the question is "what is the minimum X such that…", and checking one X is easy, binary search over the possible values of X.
def minEatingSpeed(piles, h):
def ok(k): # can she finish at speed k?
return sum((p + k - 1) // k for p in piles) <= h # ceil(p / k) hours per pile
lo, hi = 1, max(piles)
while lo < hi:
mid = (lo + hi) // 2
if ok(mid): hi = mid
else: lo = mid + 1
return lo
Rotated sorted array
[4,5,6,7,0,1,2]: split at any mid, and at least one half is still sorted. Check whether the target lies inside the sorted half's range. If so, search there; otherwise, search the other half.
def search(a, x):
lo, hi = 0, len(a) - 1
while lo <= hi:
mid = (lo + hi) // 2
if a[mid] == x: return mid
if a[lo] <= a[mid]: # left half is sorted
if a[lo] <= x < a[mid]: hi = mid - 1
else: lo = mid + 1
else: # right half is sorted
if a[mid] < x <= a[hi]: lo = mid + 1
else: hi = mid - 1
return -1
Common mistakes
- Mixing the two templates (using
lo <= hi with hi = mid, for example).
- A search range that doesn't include the answer. For lower bound, hi = len(a), not len(a) − 1.
- Not checking that the condition really is monotonic.
- Not using
bisect when it already does what you need.
Problem ladder
Deep dive
Linked lists
Nodes scattered in memory, each pointing to the next. Inserting is cheap once you're at the right spot. Getting there means walking.
Array vs linked list
| Operation | Array | Linked list |
| access item k | O(1) | O(k) |
| insert/delete at front | O(n) | O(1) |
| insert/delete after a known node | O(n) | O(1) |
| extra memory | none | one pointer per node |
class ListNode:
def __init__(self, val=0, next=None):
self.val, self.next = val, next
Technique 1 · Dummy head
When the head itself might change (it gets deleted, or you're building a new list), start from a fake node in front of it. Then the head needs no special case. Return dummy.next.
def mergeTwoLists(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
Technique 2 · Fast and slow pointers
def middleNode(head):
slow = fast = head
while fast and fast.next:
slow, fast = slow.next, fast.next.next
return slow
def hasCycle(head): # Floyd's algorithm
slow = fast = head
while fast and fast.next:
slow, fast = slow.next, fast.next.next
if slow is fast: return True # inside a loop, fast gains 1 node per step, so it must catch slow
return False
Technique 3 · A gap of n
def removeNthFromEnd(head, n):
dummy = ListNode(0, head)
fast = slow = dummy
for _ in range(n + 1): fast = fast.next # fast is now n+1 ahead
while fast:
fast, slow = fast.next, slow.next
slow.next = slow.next.next # slow stops just before the target
return dummy.next
Technique 4 · Combine the basics
Many medium problems are 2–3 easy ones glued together. Reorder List = find the middle, reverse the second half, weave the halves together. Palindrome Linked List = find the middle, reverse the second half, compare. LRU Cache = hash map (key → node) + doubly linked list (order of use).
Common mistakes
- Overwriting
node.next before saving it, which loses the rest of the list.
fast.next.next when fast.next is None. Check fast and fast.next.
- Returning
dummy instead of dummy.next.
- Not drawing it. Two minutes of boxes and arrows on paper prevents most bugs here.
Problem ladder
Deep dive
Trees & binary search trees
A tree is a recursive structure: each node is a smaller tree's root. So most tree solutions are "solve it for the children, then combine".
Vocabulary
- Root = top node. Leaf = no children. Depth of a node = edges from the root. Height of a tree = longest root-to-leaf path.
- Balanced: height is about log n, so operations are fast. Skewed: basically a linked list, height n.
- Complete: every level full except possibly the last, filled left to right. Heaps are stored this way.
- BST: every value in the left subtree < node < every value in the right subtree. That's the whole subtree, not just the direct children.
The three depth-first orders
def dfs(node):
if not node: return
# pre-order: work here (top-down, e.g. copy/serialize a tree)
dfs(node.left)
# in-order: work here (on a BST this visits values in sorted order)
dfs(node.right)
# post-order: work here (children are done, e.g. heights, deleting)
Two recursion styles
Bottom-up (return information up). Each call returns something about its subtree, and the parent combines the results. Heights, sizes, "is it balanced", diameter.
def diameterOfBinaryTree(root):
best = 0
def height(n):
nonlocal best
if not n: return 0
l, r = height(n.left), height(n.right)
best = max(best, l + r) # longest path that bends at n
return 1 + max(l, r) # tell the parent my height
height(root)
return best
Top-down (pass information down). Each call receives what it needs from its ancestors as parameters. Path sums, allowed value ranges, current depth.
def isValidBST(root):
def ok(n, low, high): # every value here must be in (low, high)
if not n: return True
if not (low < n.val < high): return False
return ok(n.left, low, n.val) and ok(n.right, n.val, high)
return ok(root, float('-inf'), float('inf'))
BST operations
- Search, insert, delete: go left if smaller, right if bigger.
O(h), which is O(log n) if balanced and O(n) if skewed.
- In-order traversal gives sorted order, so the "kth smallest" is the kth node visited in-order.
- Lowest common ancestor in a BST: walk down from the root. The first node whose value lies between p and q is the answer.
def kthSmallest(root, k): # iterative in-order with a stack
st, n = [], root
while st or n:
while n: st.append(n); n = n.left # go as far left as possible
n = st.pop(); k -= 1
if k == 0: return n.val
n = n.right
Common mistakes
- Validating a BST by comparing only parent and children. The range must hold for the whole subtree.
- Forgetting the
if not node base case.
- Mixing up depth (counted from the top) and height (counted from the bottom).
- Recursion depth on a skewed tree with 10⁵ nodes. Use an iterative version or raise the limit.
Problem ladder
Deep dive
Heaps (priority queues)
A heap always hands you the smallest (or largest) item in O(log n), without keeping everything fully sorted.
How it works
- A complete binary tree stored in a plain array. For index i: parent =
(i − 1) // 2, children = 2i + 1 and 2i + 2.
- Min-heap rule: every parent ≤ its children, so the minimum is always at index 0.
- Push: add at the end, then sift up (swap with the parent while smaller). Pop: take index 0, move the last item to the top, then sift down (swap with the smaller child). Both are O(log n).
| Operation | Python | Cost |
| see the min | h[0] | O(1) |
| push | heapq.heappush(h, x) | O(log n) |
| pop the min | heapq.heappop(h) | O(log n) |
| turn a list into a heap | heapq.heapify(a) | O(n) |
| push then pop in one step | heapq.heappushpop(h, x) | O(log n) |
Python only has a min-heap. For a max-heap, push -x and negate again when you pop. For priorities, push tuples (priority, item). If items can't be compared to each other, use (priority, counter, item) so ties never compare the items.
Pattern 1 · Top-k with a heap of size k
To get the k largest, keep a min-heap of size k. Its top is the smallest of your current top k, so anything bigger replaces it. That's O(n log k), better than sorting when k is small.
import heapq
def findKthLargest(nums, k):
h = []
for x in nums:
heapq.heappush(h, x)
if len(h) > k: heapq.heappop(h) # drop the smallest
return h[0] # smallest of the k largest
Pattern 2 · Merge k sorted lists
def mergeKLists(lists):
h = [(node.val, i, node) for i, node in enumerate(lists) if node]
heapq.heapify(h)
dummy = tail = ListNode()
while h:
_, i, node = heapq.heappop(h) # i breaks ties so nodes are never compared
tail.next = tail = node
if node.next: heapq.heappush(h, (node.next.val, i, node.next))
return dummy.next
Pattern 3 · Two heaps for a running median
Keep the smaller half in a max-heap and the larger half in a min-heap, with sizes differing by at most 1. The median is at the top of one or both heaps.
Pattern 4 · Always take the best next item
Dijkstra's shortest path, task scheduling, and repeatedly combining the two smallest items all pop the current best choice from a heap.
Problem ladder
Deep dive
Graphs
Graphs model anything with connections: maps, networks, course prerequisites, grids, word puzzles. Many problems are just BFS or DFS once you see the graph in them.
Vocabulary
- Vertices (nodes) and edges. V = number of nodes, E = number of edges.
- Directed (one-way, like prerequisites) vs undirected (two-way, like friendships). Weighted edges have costs.
- Connected component: a group of nodes that can all reach each other. Cycle: a path that returns to its start. DAG: a directed graph with no cycles.
Representations
| Form | Space | Use when |
| Adjacency list | O(V + E) | Almost always. graph[u] = [v1, v2, ...] |
| Adjacency matrix | O(V²) | Dense graphs, or quick "is u connected to v?" |
| Edge list | O(E) | What the input usually gives you. Also used by union-find. |
| Implicit (grid, words) | none | Generate neighbours on the fly |
from collections import defaultdict
graph = defaultdict(list)
for u, v in edges:
graph[u].append(v)
graph[v].append(u) # leave this out for a directed graph
BFS: shortest path when every edge costs 1
from collections import deque
def bfs(start, target):
dist = {start: 0}
q = deque([start])
while q:
u = q.popleft()
if u == target: return dist[u]
for v in graph[u]:
if v not in dist: # mark when ADDING, not when popping
dist[v] = dist[u] + 1
q.append(v)
return -1
Multi-source BFS: put all starting points in the queue at step 0 (for example, all rotten oranges at once). The levels then spread from every source at the same time.
DFS: explore everything reachable
def dfs(u, seen):
seen.add(u)
for v in graph[u]:
if v not in seen:
dfs(v, seen)
# count connected components
seen, comps = set(), 0
for node in range(n):
if node not in seen:
dfs(node, seen); comps += 1
Topological sort: order tasks with prerequisites
Kahn's algorithm: repeatedly take a node with no remaining prerequisites (in-degree 0), output it, and remove its outgoing edges. If some nodes are never output, the graph has a cycle, so no valid order exists. That's exactly the Course Schedule problem.
def topoSort(n, edges): # edge (a, b) means a must come before b
graph, indeg = defaultdict(list), [0] * n
for a, b in edges:
graph[a].append(b); indeg[b] += 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 graph[u]:
indeg[v] -= 1
if indeg[v] == 0: q.append(v)
return order if len(order) == n else [] # [] means there's a cycle
Union-Find (disjoint set): "are these connected?"
Keeps groups of nodes and merges them quickly. With path compression, each operation is almost O(1). Use it for counting components, finding the edge that creates a cycle, or connectivity that changes as edges are added.
parent = list(range(n))
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]] # path compression: point closer to the root
x = parent[x]
return x
def union(a, b):
ra, rb = find(a), find(b)
if ra == rb: return False # already connected, so this edge makes a cycle
parent[ra] = rb
return True
Dijkstra: shortest path with non-negative weights
import heapq
def dijkstra(src): # graph[u] = [(v, w), ...]
dist = {src: 0}
h = [(0, src)]
while h:
d, u = heapq.heappop(h)
if d > dist.get(u, float('inf')): continue # stale heap entry
for v, w in graph[u]:
nd = d + w
if nd < dist.get(v, float('inf')):
dist[v] = nd
heapq.heappush(h, (nd, v))
return dist # O((V + E) log V)
Which algorithm?
| Question | Use |
| Shortest path, unweighted / grid | BFS |
| Shortest path, non-negative weights | Dijkstra (heap) |
| Can I reach X? Count islands / components | DFS, BFS or union-find |
| Order with dependencies, detect a directed cycle | Topological sort |
| Connectivity while edges are added | Union-find |
Common mistakes
- No visited set, which loops forever on cycles.
- In BFS, marking nodes visited when popped instead of when added. Nodes get queued many times.
- Forgetting nodes with no edges (they still count as components).
- Building only one direction of an undirected edge.
Problem ladder
Deep dive
Backtracking
Backtracking is DFS over a tree of decisions. Make a choice, explore everything that follows from it, undo it, then try the next choice.
The decision tree
For subsets, each level decides about one number: take it or skip it. The leaves are all 2ⁿ answers. Backtracking walks this tree depth-first and keeps only the current path in memory.
The universal template
def backtrack(path, choices):
if is_complete(path):
res.append(path[:]) # COPY: path keeps changing after this
return
for c in choices:
if not valid(c, path): continue # pruning: skip dead branches early
path.append(c) # choose
backtrack(path, next_choices) # explore
path.pop() # un-choose
Subsets vs combinations vs permutations
| Problem | What changes in the template | Count |
| Subsets | Record every path. Loop j from start, recurse with j + 1 | 2ⁿ |
| Combinations (size k) | Same, but record only when len(path) == k | C(n, k) |
| Combination sum (reuse allowed) | Recurse with j instead of j + 1. Stop when the sum exceeds the target | varies |
| Permutations | Loop over all items every time, skipping ones already used | n! |
def permute(nums):
res, path, used = [], [], [False] * len(nums)
def bt():
if len(path) == len(nums): res.append(path[:]); return
for i, x in enumerate(nums):
if used[i]: continue
used[i] = True; path.append(x)
bt()
used[i] = False; path.pop()
bt(); return res
def combinationSum(cands, target):
res = []
def bt(start, path, total):
if total == target: res.append(path[:]); return
for j in range(start, len(cands)):
if total + cands[j] > target: continue # prune
path.append(cands[j])
bt(j, path, total + cands[j]) # j, not j+1: reuse allowed
path.pop()
bt(0, [], 0); return res
Handling duplicate inputs
With input like [1, 2, 2], sort first. Then at each level skip a value equal to the previous one: if j > start and nums[j] == nums[j-1]: continue. Equal values are then only picked in order, so the same subset can't be built twice.
Backtracking on a grid (Word Search)
def exist(board, word):
R, C = len(board), len(board[0])
def dfs(r, c, i):
if i == len(word): return True
if not (0 <= r < R and 0 <= c < C) or board[r][c] != word[i]: return False
tmp, board[r][c] = board[r][c], '#' # mark as used (choose)
found = any(dfs(r+dr, c+dc, i+1) for dr, dc in ((1,0),(-1,0),(0,1),(0,-1)))
board[r][c] = tmp # restore (un-choose)
return found
return any(dfs(r, c, 0) for r in range(R) for c in range(C))
Common mistakes
res.append(path) instead of path[:]. Every result ends up as the same (empty) list.
- Forgetting to undo (pop, unmark, restore).
- No pruning, so the search explores branches that can't work.
Problem ladder
Deep dive
Dynamic programming
DP solves a problem from answers to smaller versions of the same problem, and stores each answer so it's computed only once.
When is it DP?
- Overlapping subproblems: plain recursion solves the same subproblem again and again (see the fib(4) tree in Complexity).
- Optimal substructure: the best answer is built from best answers to smaller parts.
- Wording: "number of ways", "minimum / maximum", "is it possible", "longest", over sequences, grids or amounts.
The 5-step recipe
- State: define
dp[i] (or dp[i][j]) in one English sentence. This is the hardest and most important step.
- Transition: how does
dp[i] follow from smaller states? Think about the last choice made.
- Base cases: the smallest states you know directly.
- Order: fill states so everything a state needs is already computed.
- Answer: which cell holds it (
dp[n], max(dp), the bottom-right…).
Top-down vs bottom-up
| Top-down (memoization) | Bottom-up (tabulation) |
| How | recursion + @cache | loops that fill a table |
| Good for | getting it right fast; only computes states you need | no recursion limit; easy to cut memory |
| Risk | recursion depth | getting the fill order wrong |
from functools import cache
def rob(nums): # House Robber, top-down
@cache
def best(i): # max money from houses i..end
if i >= len(nums): return 0
return max(best(i + 1), nums[i] + best(i + 2)) # skip it, or rob it
return best(0)
def rob(nums): # bottom-up with O(1) space
prev2 = prev1 = 0 # best up to i-2, best up to i-1
for x in nums:
prev2, prev1 = prev1, max(prev1, prev2 + x)
return prev1
1D DP · Coin Change
def coinChange(coins, amount):
dp = [0] + [float('inf')] * amount # dp[a] = fewest coins to make a
for a in range(1, amount + 1):
for c in coins:
if c <= a:
dp[a] = min(dp[a], dp[a - c] + 1) # last coin used was c
return dp[amount] if dp[amount] != float('inf') else -1
2D DP · Longest Common Subsequence
dp[i][j] = LCS of the first i letters of one string and the first j letters of the other. If the letters match, extend the diagonal. Otherwise, take the better of dropping a letter from either string.
def longestCommonSubsequence(a, b):
dp = [[0] * (len(b) + 1) for _ in range(len(a) + 1)]
for i in range(1, len(a) + 1):
for j in range(1, len(b) + 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[-1][-1]
0/1 knapsack · Partition Equal Subset Sum
Each item can be used at most once. Loop the capacity backwards so an item isn't counted twice in the same pass.
def canPartition(nums):
total = sum(nums)
if total % 2: return False
target = total // 2
dp = [True] + [False] * target # dp[s] = can some items sum to s?
for x in nums:
for s in range(target, x - 1, -1): # backwards!
dp[s] = dp[s] or dp[s - x]
return dp[target]
Common state shapes
| Problem type | State | Examples |
| Sequence, one pass | dp[i] = best for prefix ending at / up to i | House Robber, Climbing Stairs, Decode Ways |
| Sequence, pick earlier j | dp[i] = best ending at i, loop j < i | Longest Increasing Subsequence, Word Break |
| Two strings | dp[i][j] = answer for prefixes | LCS, Edit Distance |
| Grid | dp[r][c] from top and left | Unique Paths, Minimum Path Sum |
| Amount / capacity | dp[a] over items | Coin Change, Partition Subset Sum |
| Interval | dp[l][r] for a sub-range | Palindromic substrings, Burst Balloons |
Common mistakes
- A vague state. If you can't say what
dp[i] means in one sentence, stop and define it.
- Wrong base case (dp[0] for "empty" is often 1 for counting problems and 0 for sums).
- Filling in the wrong order, or looping forwards in 0/1 knapsack.
- Returning infinity instead of the −1 the problem asks for.
Practical route: write the brute-force recursion first, add @cache, and check that it passes. Convert to a table only if needed. Most interviewers accept memoized recursion.
Problem ladder
Deep dive
Sorting, greedy & intervals
Sorting is the most common first step in a solution. Once data is sorted, greedy choices and interval sweeps often become obvious.
Sorting algorithms you should recognize
| Algorithm | Time | Space | Notes |
| Bubble / insertion | O(n²) | O(1) | Insertion sort is fast on nearly sorted data |
| Merge sort | O(n log n) | O(n) | Stable. Divide in half, sort, merge |
| Quick sort | O(n log n) avg, O(n²) worst | O(log n) | Pick a pivot, partition around it |
| Heap sort | O(n log n) | O(1) | Heapify, then pop repeatedly |
| Counting / bucket | O(n + k) | O(k) | Small integer range k only |
| Python's sort (Timsort) | O(n log n) | O(n) | Stable. Use this in practice |
def mergeSort(a):
if len(a) <= 1: return a
mid = len(a) // 2
L, R = mergeSort(a[:mid]), mergeSort(a[mid:])
out, i, j = [], 0, 0
while i < len(L) and j < len(R):
if L[i] <= R[j]: out.append(L[i]); i += 1
else: out.append(R[j]); j += 1
return out + L[i:] + R[j:]
Custom sort keys
people.sort(key=lambda p: (-p.age, p.name)) # age descending, then name ascending
intervals.sort(key=lambda x: x[0]) # by start
words.sort(key=len) # by length
from functools import cmp_to_key # when the order depends on pairs
nums.sort(key=cmp_to_key(lambda a, b: -1 if a + b > b + a else 1)) # Largest Number (on strings)
Quickselect finds the kth smallest in average O(n) by partitioning like quicksort but recursing into only one side.
Greedy
A greedy algorithm makes the choice that looks best right now and never revisits it. It's fast and short, but correct only when you can argue that a locally best choice never blocks the overall best answer. If you can't argue it, try DP.
def canJump(nums): # Jump Game
far = 0 # farthest index reachable so far
for i, x in enumerate(nums):
if i > far: return False # stuck before reaching i
far = max(far, i + x)
return True
Intervals
- Two intervals
[a, b] and [c, d] overlap when a <= d and c <= b.
- Merge overlapping intervals: sort by start, then extend the last merged interval or start a new one.
- Keep as many non-overlapping intervals as possible: sort by end and greedily keep the one that finishes first. It leaves the most room for the rest.
- Most overlapping at once (meeting rooms): sort start and end times separately and sweep, or use a min-heap of end times.
def merge(intervals):
intervals.sort(key=lambda x: x[0])
out = [intervals[0]]
for s, e in intervals[1:]:
if s <= out[-1][1]: out[-1][1] = max(out[-1][1], e) # overlap, so extend
else: out.append([s, e])
return out
Problem ladder
Deep dive
Tries & bit manipulation
Two smaller topics that show up regularly: a tree for prefix lookups, and tricks with the binary form of numbers.
Trie (prefix tree)
Each edge is a letter. Words that share a prefix share the same path. A flag marks nodes where a word ends. Insert and search cost O(length of word), no matter how many words are stored.
class Trie:
def __init__(self):
self.root = {}
def insert(self, word):
node = self.root
for ch in word:
node = node.setdefault(ch, {}) # walk, creating children as needed
node['$'] = True # end-of-word marker
def _walk(self, s):
node = self.root
for ch in s:
if ch not in node: return None
node = node[ch]
return node
def search(self, word):
node = self._walk(word); return bool(node) and '$' in node
def startsWith(self, prefix):
return self._walk(prefix) is not None
Use a trie for autocomplete, "does any word start with…", and searching many words in a grid at once (Word Search II).
Bit manipulation cheat sheet
| Expression | Meaning |
| x & 1 | 1 if x is odd |
| x >> 1, x << 1 | divide by 2, multiply by 2 |
| 1 << k | 2ᵏ; a mask with only bit k set |
| (x >> k) & 1 | is bit k set? |
| x & (x − 1) | clears the lowest set bit (counts bits; x is a power of 2 if the result is 0) |
| a ^ a = 0, a ^ 0 = a | XOR cancels pairs: XOR everything to find the single unpaired number |
| bin(x).count('1') | number of set bits |
| for mask in range(1 << n) | loop over all 2ⁿ subsets; bit i says whether item i is included |
def singleNumber(nums): # every number appears twice except one
x = 0
for n in nums: x ^= n # pairs cancel out
return x
Problem ladder