Python Patterns Primer — Canonical Solutions for Coding Interviews
The same ~12 patterns solve the overwhelming majority of coding interview problems. This primer gives you the canonical template for each, a plain-language explanation of why it works, when to reach for it, and the complexity. Memorize the shape of each template — in an interview you adapt the template rather than inventing from scratch.
How to use thisread the "When to use" line first. If a problem matches, drop in the template and adapt. The comments explain why each line exists so you can reconstruct it under pressure.
1. Sliding Window
When to useyou need the best/longest/shortest contiguous subarray or substring that satisfies a condition. Keyword signals: "contiguous", "substring", "subarray", "window of size k".
Why it worksa brute-force check of every subarray is O(n²). The window trick is that when you extend the right edge by one element, you don't need to recompute the whole window — you incrementally add the new element, and shrink from the left only when the window becomes invalid. Each element enters and leaves the window at most once, so the whole scan is O(n).
Fixed-size window
def max_sum_subarray(nums: list[int], k: int) -> int:
"""Maximum sum of any contiguous subarray of size k."""
window_sum = sum(nums[:k]) # seed the first window once — O(k)
best = window_sum
# Slide: each step ADD the incoming element and REMOVE the outgoing one.
# This is why it's O(n) not O(n*k): we never re-sum the whole window.
for right in range(k, len(nums)):
window_sum += nums[right] - nums[right - k] # +incoming -outgoing
best = max(best, window_sum)
return bestVariable-size window
This is the workhorse template. The window grows from the right and shrinks from the left only when it violates the constraint.
def longest_substring_k_distinct(s: str, k: int) -> int:
"""Longest substring with at most k distinct characters."""
from collections import defaultdict
counts = defaultdict(int) # char -> how many times it's in the current window
left = 0
best = 0
for right, ch in enumerate(s):
counts[ch] += 1 # the new right-edge char enters the window
# WHY a while, not an if: adding one char can push us over the limit,
# and we must shrink until valid again. In some problems one shrink step
# suffices, but `while` is the safe general form.
while len(counts) > k:
left_ch = s[left]
counts[left_ch] -= 1
if counts[left_ch] == 0:
del counts[left_ch] # remove so len(counts) = true distinct count
left += 1 # shrink the window from the left
# At this point [left..right] is the largest valid window ending at `right`.
best = max(best, right - left + 1)
return bestComplexityO(n) time, O(k) space. left only ever moves forward, so the inner while runs O(n) times total across the whole loop — not O(n) per step.
2. Two Pointers
When to usea sorted array (or two sorted inputs), or any problem where you converge from both ends. Keyword signals: "sorted", "pair that sums to", "palindrome", "remove duplicates in place".
Why it workssorting gives you a direction. If the current pair's sum is too small, the only way to increase it is to move the left pointer right (toward bigger values). Too big? Move the right pointer left. You never need to backtrack, so it's O(n) after sorting.
def two_sum_sorted(nums: list[int], target: int) -> tuple[int, int] | None:
"""Return indices of two numbers in a SORTED array that sum to target."""
left, right = 0, len(nums) - 1
while left < right:
s = nums[left] + nums[right]
if s == target:
return (left, right)
elif s < target:
left += 1 # sum too small -> need a bigger number -> move left up
else:
right -= 1 # sum too big -> need a smaller number -> move right down
return NoneOpposite-direction vs. same-directionthe version above converges from both ends. The "same-direction" variant (a slow and a fast pointer both moving left-to-right) is used for in-place filtering, e.g. removing duplicates:
def remove_duplicates(nums: list[int]) -> int:
"""In-place dedup of a sorted array. Returns new length. O(1) extra space."""
if not nums:
return 0
slow = 0 # slow marks the last position of the deduped prefix
for fast in range(1, len(nums)):
if nums[fast] != nums[slow]: # found a new unique value
slow += 1
nums[slow] = nums[fast] # write it just after the deduped prefix
return slow + 13. Fast & Slow Pointers (Floyd's Cycle Detection)
When to uselinked lists where you need to detect a cycle, find the middle, or find the cycle's start. Keyword signals: "cycle", "loop", "middle of the list".
Why it worksthe fast pointer moves two steps, the slow pointer one. If there's a cycle, the fast pointer laps the slow one and they meet inside the loop (like two runners on a circular track). If there's no cycle, fast simply reaches the end. The middle-finding trick uses the same idea: when fast reaches the end, slow is exactly halfway.
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def has_cycle(head: ListNode | None) -> bool:
slow = fast = head
while fast and fast.next: # fast moves 2, so check fast AND fast.next exist
slow = slow.next # 1 step
fast = fast.next.next # 2 steps
if slow is fast: # they collided -> there is a cycle
return True
return False # fast hit the end -> no cycle
def find_middle(head: ListNode | None) -> ListNode | None:
slow = fast = head
# When fast reaches the end, slow has covered exactly half the distance.
while fast and fast.next:
slow = slow.next
fast = fast.next.next
return slow4. Binary Search
When to usesearching a sorted array, OR any problem with a monotonic "yes/no" property where you can binary-search the answer space. Keyword signals: "sorted", "find the minimum/maximum value such that...", "O(log n)".
Why it workseach comparison eliminates half the remaining search space, giving O(log n). The hard part is the boundary conditions — use the template below and don't improvise.
def binary_search(nums: list[int], target: int) -> int:
"""Return index of target, or -1 if absent."""
lo, hi = 0, len(nums) - 1 # INCLUSIVE bounds: [lo, hi]
while lo <= hi: # <= because the bounds are inclusive; when
# lo == hi there is still one element to check
mid = lo + (hi - lo) // 2 # avoids integer overflow in other languages;
# harmless habit in Python
if nums[mid] == target:
return mid
elif nums[mid] < target:
lo = mid + 1 # target is to the RIGHT; discard mid and left
else:
hi = mid - 1 # target is to the LEFT; discard mid and right
return -1"Leftmost" / lower-bound variant (the more useful one)
Finds the first position where nums[i] >= target. This handles duplicates and "insert position" problems. bisect.bisect_left does exactly this in the stdlib.
def lower_bound(nums: list[int], target: int) -> int:
"""First index i where nums[i] >= target (== len(nums) if none)."""
lo, hi = 0, len(nums) # EXCLUSIVE upper bound: [lo, hi)
while lo < hi: # < because hi is exclusive
mid = (lo + hi) // 2
if nums[mid] < target:
lo = mid + 1 # mid is too small -> answer is strictly right
else:
hi = mid # mid might BE the answer -> keep it in range
return lo # lo == hi == the boundaryThe key distinctionthe plain search uses inclusive bounds (hi = len-1, while lo <= hi); the lower-bound search uses a half-open range (hi = len, while lo < hi). Mixing them up is the #1 source of binary-search bugs. Pick one style per problem and be consistent.
5. Prefix Sum
When to usemany range-sum queries on a static array, or "subarray that sums to k" problems. Keyword signals: "sum of range", "subarray sum equals", "running total".
Why it worksif you precompute prefix[i] = sum of the first i elements, then the sum of any range [i, j) is just prefix[j] - prefix[i] — an O(1) lookup instead of an O(n) re-summation. For the "subarray sums to k" case, you store prefix sums in a hash map so you can ask "have I seen a prefix that's exactly k smaller than my current prefix?" in O(1).
def subarray_sum_equals_k(nums: list[int], k: int) -> int:
"""Count contiguous subarrays whose sum is exactly k."""
from collections import defaultdict
# seen[prefix] = number of times that running prefix sum has occurred.
# Seed with {0: 1} to count subarrays that start at index 0
# (a prefix of exactly k means the whole prefix IS a valid subarray).
seen = defaultdict(int)
seen[0] = 1
running = 0
count = 0
for x in nums:
running += x
# If some earlier prefix equalled (running - k), then the subarray
# BETWEEN that point and here sums to exactly k. WHY: running - earlier = k.
count += seen[running - k]
seen[running] += 1 # record current prefix for future queries
return countComplexityO(n) time, O(n) space. Works with negative numbers (where sliding window would fail, because shrinking doesn't monotonically reduce the sum).
6. Hash Map (Frequency & Complement)
When to usecounting occurrences, finding pairs/complements in O(n), grouping, deduplication. The single most common pattern in security log analysis.
Why it worksa hash map turns "have I seen X?" and "how many X?" into O(1) operations. The classic Two Sum uses the complement idea: as you scan, for each number you ask "have I already seen target - num?" — if so, you've found the pair in one pass.
def two_sum(nums: list[int], target: int) -> tuple[int, int] | None:
"""Two Sum on an UNSORTED array, single pass, O(n)."""
seen = {} # value -> index where we saw it
for i, num in enumerate(nums):
complement = target - num
# WHY check before inserting: prevents using the same element twice,
# and guarantees the matched index is strictly earlier.
if complement in seen:
return (seen[complement], i)
seen[num] = i
return None
def group_anagrams(words: list[str]) -> list[list[str]]:
"""Group words that are anagrams of each other."""
from collections import defaultdict
groups = defaultdict(list)
for w in words:
# WHY sorted(w): two words are anagrams IFF their sorted letters match,
# so the sorted tuple is a canonical key shared by all anagrams.
key = tuple(sorted(w))
groups[key].append(w)
return list(groups.values())collections.Counter shortcut: for pure frequency counting, Counter(iterable) does it in one line, and .most_common(k) gives the top-k directly.
7. Monotonic Stack
When to use"next greater element", "previous smaller element", "largest rectangle in histogram", "daily temperatures". Keyword signals: "next greater/smaller", "span".
Why it worksyou keep a stack whose values are kept in increasing (or decreasing) order. When a new element breaks the order, you pop everything it "resolves" — and the new element is precisely the answer (the next-greater) for each popped item. Every element is pushed and popped at most once, so it's O(n) despite the nested-looking loop.
def daily_temperatures(temps: list[int]) -> list[int]:
"""For each day, how many days until a warmer temperature (0 if none)."""
result = [0] * len(temps)
stack = [] # holds INDICES of days awaiting a warmer day; temps at these
# indices are in decreasing order from bottom to top.
for i, t in enumerate(temps):
# Today is warmer than the days on top of the stack -> resolve them.
while stack and temps[stack[-1]] < t:
prev_day = stack.pop()
result[prev_day] = i - prev_day # distance to the warmer day
stack.append(i) # today now waits for ITS warmer day
# Anything left on the stack never found a warmer day -> stays 0.
return result8. BFS — Breadth-First Search
When to useshortest path in an unweighted graph/grid, level-by-level traversal, "minimum number of steps". Keyword signals: "shortest", "fewest moves", "level order", "nearest".
Why it worksBFS explores all nodes at distance 1, then all at distance 2, and so on. The first time you reach the target, you've reached it by the fewest edges — that's the shortest path in an unweighted graph. A queue (FIFO) enforces this level-by-level order.
from collections import deque
def bfs_shortest_path(grid: list[list[int]], start: tuple, goal: tuple) -> int:
"""Fewest steps from start to goal in a grid. 0 = open, 1 = wall. -1 if unreachable."""
rows, cols = len(grid), len(grid[0])
queue = deque([(start, 0)]) # (cell, distance-so-far)
visited = {start} # mark on ENQUEUE, not dequeue — WHY: prevents
# the same cell being queued multiple times.
while queue:
(r, c), dist = queue.popleft() # popleft = FIFO = level order
if (r, c) == goal:
return dist
for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)): # 4-directional neighbours
nr, nc = r + dr, c + dc
if (0 <= nr < rows and 0 <= nc < cols
and grid[nr][nc] == 0 and (nr, nc) not in visited):
visited.add((nr, nc))
queue.append(((nr, nc), dist + 1))
return -1Critical detailmark a node visited when you enqueue it, not when you dequeue it. Marking on dequeue lets the same node get added to the queue several times before it's processed, which can blow up to exponential work.
9. DFS — Depth-First Search
When to useexhaustive exploration, tree traversal, connected components, "does a path exist", cycle detection. Keyword signals: "all paths", "connected", "islands", "explore fully".
Why it worksDFS dives as deep as possible before backtracking, using either the call stack (recursion) or an explicit stack. It naturally explores one full branch before the next, which is what you want for "find any/all complete solutions" rather than "shortest".
def count_islands(grid: list[list[str]]) -> int:
"""Count connected groups of '1's (land) in a grid. Classic DFS flood-fill."""
if not grid:
return 0
rows, cols = len(grid), len(grid[0])
def sink(r, c):
# Out of bounds or water -> stop. This is the recursion base case.
if not (0 <= r < rows and 0 <= c < cols) or grid[r][c] != '1':
return
grid[r][c] = '0' # mark visited IN PLACE by sinking the land -> no
# separate visited set needed, and avoids revisiting.
sink(r + 1, c); sink(r - 1, c) # explore all 4 neighbours deeply
sink(r, c + 1); sink(r, c - 1)
islands = 0
for r in range(rows):
for c in range(cols):
if grid[r][c] == '1': # found an unvisited piece of land
islands += 1 # it's a new island...
sink(r, c) # ...flood-fill the entire island so we don't recount it
return islandsRecursion vs. iterationthe recursive form is cleaner but risks a stack overflow on very deep inputs (Python's default recursion limit is ~1000). For deep graphs, convert to an explicit stack with a while stack: loop.
10. Heap / Top-K
When to use"top K largest/smallest", "K closest", "median of a stream", "merge K sorted lists". Keyword signals: "K largest", "K most frequent", "running median".
Why it worksa heap gives you O(log n) insert and O(1) access to the smallest element. For Top-K-largest, you keep a min-heap of size K: whenever it overflows, you pop the smallest — so the heap always holds the K largest seen so far. This is O(n log k), far better than sorting everything at O(n log n) when k is small.
import heapq
def top_k_largest(nums: list[int], k: int) -> list[int]:
"""Return the k largest numbers."""
# Python's heapq is a MIN-heap. To keep the k LARGEST, we keep a min-heap of
# size k: the smallest of our current top-k sits at the root, ready to be
# evicted the moment we find something bigger.
heap = []
for num in nums:
heapq.heappush(heap, num)
if len(heap) > k:
heapq.heappop(heap) # drop the smallest -> heap retains the k largest
return heap # the k largest, in no particular order
def k_most_frequent(nums: list[int], k: int) -> list[int]:
"""K most frequent elements."""
from collections import Counter
counts = Counter(nums)
# heapq.nlargest with a key is the idiomatic one-liner for top-k by a metric.
return heapq.nlargest(k, counts.keys(), key=counts.get)Two-heap trick for streaming mediankeep a max-heap of the lower half and a min-heap of the upper half, balanced in size. The median is then either the top of the larger heap or the average of both tops — all in O(log n) per insertion.
11. Backtracking
When to usegenerate all combinations/permutations/subsets, constraint satisfaction (N-Queens, Sudoku), "find all valid arrangements". Keyword signals: "all possible", "generate every", "combinations", "permutations".
Why it worksbacktracking is DFS over the space of partial solutions. You make a choice, recurse, then undo the choice (backtrack) so you can try the next one. The undo step is what lets a single mutable path explore the whole tree without copying it everywhere.
def subsets(nums: list[int]) -> list[list[int]]:
"""Generate all subsets (the power set)."""
result = []
path = [] # the subset we're currently building
def backtrack(start: int):
# Every node in the recursion tree is itself a valid subset, so we record
# a COPY of path at every entry (copy because path keeps mutating).
result.append(path[:])
# `start` prevents revisiting earlier elements -> avoids duplicate subsets
# like [1,2] and [2,1] (subsets are unordered).
for i in range(start, len(nums)):
path.append(nums[i]) # CHOOSE element i
backtrack(i + 1) # EXPLORE further choices after i
path.pop() # UN-CHOOSE (backtrack) so the next i starts clean
backtrack(0)
return resultThe universal shapechoose → explore → un-choose. Permutations swap a used[] flag for the start index (because order matters and you can reuse earlier positions). Combination-sum allows reusing an element by recursing with i instead of i + 1.
12. Dynamic Programming
When to useoptimization ("min/max cost", "longest/shortest"), counting ("how many ways"), or decision ("is it possible") problems with overlapping subproblems and optimal substructure. Keyword signals: "minimum/maximum", "number of ways", "can you reach", "longest".
Why it worksa naive recursion recomputes the same subproblems exponentially many times. DP computes each subproblem once and reuses the result — either top-down with memoization, or bottom-up by filling a table. This collapses exponential time to polynomial.
1-D DP (climbing stairs / Fibonacci shape)
def climb_stairs(n: int) -> int:
"""Number of distinct ways to climb n stairs taking 1 or 2 steps at a time."""
# WHY this recurrence: to reach step n, your last move was either a 1-step
# (from n-1) or a 2-step (from n-2). So ways(n) = ways(n-1) + ways(n-2).
if n <= 2:
return n
# We only ever need the previous TWO values, so we keep two variables
# instead of a full array -> O(1) space.
prev2, prev1 = 1, 2
for _ in range(3, n + 1):
prev2, prev1 = prev1, prev1 + prev2
return prev12-D DP (grid paths / edit distance shape)
def unique_paths(m: int, n: int) -> int:
"""Number of paths from top-left to bottom-right moving only right or down."""
# dp[j] = number of ways to reach the current cell in column j.
# WHY a 1-D array works for a 2-D problem: when we process row by row,
# dp[j] (before update) holds the cell ABOVE, and dp[j-1] holds the cell
# to the LEFT — exactly the two predecessors of the current cell.
dp = [1] * n # first row: only one way to reach each cell (keep going right)
for _ in range(1, m):
for j in range(1, n):
dp[j] += dp[j - 1] # ways from above (old dp[j]) + ways from left (dp[j-1])
return dp[-1]The DP recipe(1) define the state — what does dp[i] mean? (2) write the recurrence — how does dp[i] depend on smaller states? (3) set the base cases. (4) decide iteration order so dependencies are computed first. Get the state definition right and the rest follows.
13. Intervals
When to usemerging overlapping ranges, scheduling, "can attend all meetings", insert/remove intervals. Keyword signals: "intervals", "meetings", "overlap", "merge ranges".
Why it worksonce you sort intervals by start time, overlaps can only happen between adjacent intervals in the sorted order. That turns an O(n²) all-pairs comparison into a single O(n log n) sort plus one O(n) pass.
def merge_intervals(intervals: list[list[int]]) -> list[list[int]]:
"""Merge all overlapping intervals."""
if not intervals:
return []
# Sort by start. WHY: after sorting, if interval B overlaps anything, it
# overlaps the interval immediately before it in the sorted list.
intervals.sort(key=lambda x: x[0])
merged = [intervals[0]]
for start, end in intervals[1:]:
last_end = merged[-1][1]
if start <= last_end:
# Overlap: extend the last interval's end if this one reaches further.
merged[-1][1] = max(last_end, end)
else:
merged.append([start, end]) # disjoint -> start a new interval
return merged14. Linked List Reversal
When to usereverse a list (or a sublist), and as a building block in many list problems. Keyword signals: "reverse", "in-place", "reorder list".
Why it worksyou walk the list once, flipping each node's next pointer to point backward. The trick is holding onto the next node before you overwrite the pointer — otherwise you lose the rest of the list.
def reverse_list(head: ListNode | None) -> ListNode | None:
"""Reverse a singly linked list in place. O(n) time, O(1) space."""
prev = None # the reversed portion built so far (starts empty)
curr = head
while curr:
nxt = curr.next # SAVE the next node BEFORE we clobber curr.next,
# or we'd lose access to the rest of the list.
curr.next = prev # flip the pointer to face backward
prev = curr # advance prev into the reversed portion
curr = nxt # advance curr to the saved next node
return prev # prev is the new head (old tail)Drawing it helpsin an interview, sketch three boxes (prev, curr, nxt) and trace two iterations. The pointer dance is easy to get backward from memory alone.
Complexity Cheat Sheet
| Pattern | Typical time | Typical space | Triggered by |
|---|---|---|---|
| Sliding Window | O(n) | O(k) | contiguous subarray/substring |
| Two Pointers | O(n) | O(1) | sorted array, pair search, in-place |
| Fast & Slow Pointers | O(n) | O(1) | linked-list cycle / middle |
| Binary Search | O(log n) | O(1) | sorted input, monotonic answer space |
| Prefix Sum | O(n) | O(n) | repeated range sums, subarray-sum-k |
| Hash Map | O(n) | O(n) | counting, complement lookup, grouping |
| Monotonic Stack | O(n) | O(n) | next greater/smaller element |
| BFS | O(V + E) | O(V) | shortest path (unweighted), level order |
| DFS | O(V + E) | O(V) | exhaustive search, connected components |
| Heap / Top-K | O(n log k) | O(k) | top-k, streaming median, merge-k |
| Backtracking | O(branching^depth) | O(depth) | generate all arrangements |
| Dynamic Programming | O(states × transition) | O(states) | optimization / counting with overlap |
| Intervals | O(n log n) | O(n) | merge/schedule ranges |
How to Pick a Pattern in the Interview
Read the problem, then match the signal:
Sliding Window or Prefix Sum
Two Pointers or Binary Search
BFS
Backtracking
Heap
Dynamic Programming
Monotonic Stack
Fast & Slow Pointers
Intervals (sort first)
Hash Map
When two patterns fit, state both out loud and explain the trade-off — interviewers value that you can compare approaches, not just produce one.