Security Notes
Coding

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.

19 min read 16 sections

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

python
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 best

Variable-size window

This is the workhorse template. The window grows from the right and shrinks from the left only when it violates the constraint.

python
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 best

ComplexityO(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.

python
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 None

Opposite-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:

python
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 + 1

3. 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.

python
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 slow

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.

python
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.

python
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 boundary

The 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).

python
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 count

ComplexityO(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.

python
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.

python
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 result

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.

python
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 -1

Critical 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.


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".

python
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 islands

Recursion 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.

python
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.

python
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 result

The 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)

python
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 prev1

2-D DP (grid paths / edit distance shape)

python
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.

python
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 merged

14. 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.

python
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

PatternTypical timeTypical spaceTriggered by
Sliding WindowO(n)O(k)contiguous subarray/substring
Two PointersO(n)O(1)sorted array, pair search, in-place
Fast & Slow PointersO(n)O(1)linked-list cycle / middle
Binary SearchO(log n)O(1)sorted input, monotonic answer space
Prefix SumO(n)O(n)repeated range sums, subarray-sum-k
Hash MapO(n)O(n)counting, complement lookup, grouping
Monotonic StackO(n)O(n)next greater/smaller element
BFSO(V + E)O(V)shortest path (unweighted), level order
DFSO(V + E)O(V)exhaustive search, connected components
Heap / Top-KO(n log k)O(k)top-k, streaming median, merge-k
BacktrackingO(branching^depth)O(depth)generate all arrangements
Dynamic ProgrammingO(states × transition)O(states)optimization / counting with overlap
IntervalsO(n log n)O(n)merge/schedule ranges

How to Pick a Pattern in the Interview

Read the problem, then match the signal:

"Contiguous" + array/string

Sliding Window or Prefix Sum

"Sorted" input

Two Pointers or Binary Search

"Shortest / fewest"

BFS

"All possible / generate every"

Backtracking

"Top K / K closest / median"

Heap

"Min/max cost" or "number of ways"

Dynamic Programming

"Next greater / span"

Monotonic Stack

Linked list + cycle/middle

Fast & Slow Pointers

"Overlap / merge ranges"

Intervals (sort first)

"Count / find pair / group"

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.