AI EngineeringZero to ProductionHome·About·Contact
Appendix · Data Structures & Algorithms · Part 8

Big Tech DSA Patterns

The DSA that actually shows up at Google, Meta, Amazon, Apple, Netflix, and every company that copies their bar. The problems aren't random — they're a small set of recognizable patterns in disguise. This part names each pattern, then works through multiple problems per pattern with Python code, complexity, and diagrams — so you learn to recognize the pattern, not memorize answers.

⏱️ ~4 hours🎯 Pattern mastery💼 40+ worked problemsrunnable

Learning objectives

  • Recognize the ~10 patterns that cover the majority of interview questions.
  • Map each pattern to the underlying structure from D1–D6.
  • Solve the canonical problem for each — with optimal time/space.
  • Learn the "tell" that signals which pattern a question wants.
How to use this pageEvery section is Pattern → Tell → Canonical problem → Code → Why it's optimal. Master the pattern and dozens of specific classic interview problems-style questions collapse into "oh, that's just sliding window / top-K heap / BFS on a grid." The building blocks all come from D1–D6 — this page is how they're weaponized under interview pressure.

1 · Sliding window very common

Tell: "longest / shortest / max / min contiguous subarray or substring satisfying …". Grow a window on the right, shrink from the left when a constraint breaks — O(n) instead of O(n²).

longest substring without repeating chars: "abcabcbb" a b c a b c left right repeat 'a' seen → move left past old 'a' answer = 3 ("abc") The window expands and contracts. A set/dict tracks what's inside; when a duplicate (or other violation) appears, advance left until the window is valid again. Each index enters and leaves once → O(n).
🗺️ How to read this diagram

A sliding window is a stretch of the string marked by two pointers, left and right. Here the goal is the longest run with no repeated letter inside it. The picture shows the window on "abcabcbb" the moment a repeat appears.

  • The boxes are the characters of the string, side by side. The shaded boxes (between left and right) are what's currently inside the window.
  • left and right are the two edges of the window. right keeps moving forward one step at a time to grow the window; left only moves when it has to.
  • The red note ("move left past old 'a'") is the fix: when the new character is already inside the window (a repeat), you slide left forward until the duplicate is gone — that keeps the window valid again.
  • The green line is the answer: the longest valid window seen was 3 long ("abc").

In short: Grow on the right, and only shrink from the left when a rule breaks. Because each character is entered once and left once, the whole scan is O(n) — one pass, not a nested loop.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Longest substring without repeating characters (classic)
pythondef length_of_longest_substring(s):
    seen = {}                       # char -> last index seen
    left = best = 0
    for right, ch in enumerate(s):
        if ch in seen and seen[ch] >= left:
            left = seen[ch] + 1    # jump left past the duplicate
        seen[ch] = right
        best = max(best, right - left + 1)
    return best                    # O(n) time, O(min(n, alphabet)) space

print(length_of_longest_substring("abcabcbb"))   # 3
▶ How this works

Problem: find the length of the longest slice of a string that has no repeated character. The brute-force way checks every possible slice (slow, O(n²)). The sliding-window trick does it in one pass by remembering where each character was last seen.

  1. seen = {} is a dictionary mapping each character to the last index where we saw it. left is the start of the current window; best is the longest length found so far.
  2. for right, ch in enumerate(s) walks right across the string one character at a time. enumerate hands you both the position right and the character ch.
  3. if ch in seen and seen[ch] >= left asks "have we seen this char inside the current window?" If yes, we jump left to just past that old copy — the window instantly becomes duplicate-free again, without a slow inner loop.
  4. seen[ch] = right records this character's new position, and best = max(best, right - left + 1) updates the answer with the current window's width (right - left + 1 is how many characters are inside).

What the output means: Prints 3 — the longest repeat-free substring of "abcabcbb" is "abc", which is 3 characters long.

Try this: Trace "abba" by hand. When the second a arrives, left must jump past the first a — this is exactly why the check is seen[ch] >= left and not just ch in seen.

More examples · fixed window + minimum window substring
pythonfrom collections import Counter

# (a) Max sum of a FIXED window of size k (classic problem style) — O(n)
def max_sum_k(nums, k):
    window = sum(nums[:k]); best = window
    for i in range(k, len(nums)):
        window += nums[i] - nums[i-k]        # slide: add new, drop old
        best = max(best, window)
    return best

# (b) Minimum window substring (classic) — variable window + counts
def min_window(s, t):
    need = Counter(t); missing = len(t)
    lo = start = 0; end = 0
    for hi, ch in enumerate(s, 1):        # hi is 1-based right edge
        if need[ch] > 0: missing -= 1
        need[ch] -= 1
        if missing == 0:                    # window covers all of t
            while need[s[lo]] < 0:           # shrink past surplus chars
                need[s[lo]] += 1; lo += 1
            if end == 0 or hi - lo < end - start:
                start, end = lo, hi           # record best window
    return s[start:end]

print(max_sum_k([2,1,5,1,3,2], 3), min_window("ADOBECODEBANC", "ABC"))  # 9 BANC
▶ How this works

Two more sliding-window classics. (a) the fixed-size window (the width never changes), and (b) the variable window that must cover a required set of characters. Same expand/shrink idea, two flavors.

  1. (a) max_sum_k: to find the biggest sum of any k neighbours, don't re-add k numbers each step. Compute the first window once, then window += nums[i] - nums[i-k]add the new number entering on the right and subtract the old one leaving on the left. Each step is O(1).
  2. (b) min_window: find the shortest slice of s containing every character of t. need = Counter(t) counts how many of each character we still need; missing is how many we're short overall.
  3. As hi expands the window, each needed character drops missing. When missing == 0 the window is valid, so the inner while shrinks lo from the left past any surplus characters to make the window as tight as possible.
  4. Whenever a valid window is smaller than the best so far, we record it with start, end = lo, hi. At the end s[start:end] is the shortest covering slice.

What the output means: Prints 9 BANC: the best sum of 3 neighbours in [2,1,5,1,3,2] is 5+1+3=9, and the smallest window of "ADOBECODEBANC" covering "ABC" is "BANC".

Try this: For (a), print window each loop and watch it slide. For (b), the key insight is that need counts can go negative — that's the signal you have a surplus character you're allowed to drop while shrinking.

Cousins you can now solveLongest-repeating-character-replacement, max-consecutive-ones-III, fruit-into-baskets, permutation-in-string, find-all-anagrams, longest-substring-with-at-most-K-distinct. All the same expand/shrink skeleton (see also D1 sliding window).

2 · Two pointers & fast/slow very common

Tell: "sorted array, find a pair/triplet" → converging pointers; "find the cycle / middle / nth-from-end of a linked list" → fast & slow pointers.

3Sum: fix nums[i], two-pointer the rest to hit −nums[i] -4 -1 -1 0 1 2 i (anchor −1) lo hi need lo+hi = +1 (to cancel anchor −1) 0 + 2 = 2 > 1 → hi-- · −1+0+2 … converge to a triplet 3Sum = fix one number, then it's Two-Sum on a sorted array. For each anchor i, converge lo/hi toward −nums[i]. Sorting enables the two-pointer sweep and makes skipping duplicates easy → O(n²) overall.
🗺️ How to read this diagram

3Sum asks for triplets that add to zero. This diagram shows the trick that turns a 3-dimensional search into a simple 1-D sweep: sort the array, fix one number, then use two pointers for the other two.

  • The boxes are the numbers after sorting (small on the left, large on the right). Sorting is what makes the pointer sweep possible.
  • i is the anchor — the one number we hold fixed for this round (here it's -1). We now need the other two to add up to +1 so the whole triplet cancels to 0.
  • lo starts just after the anchor and hi starts at the far right. They converge inward: if their sum is too big, move hi left (smaller); too small, move lo right (bigger).
  • The bottom line shows a concrete step: 0 + 2 = 2 is bigger than the needed 1, so hi--. Repeat until the pointers meet.

In short: Sorting first is the unlock: it lets you decide which pointer to move just by comparing the sum to the target. Fixing one number then two-pointering the rest gives O(n²) instead of O(n³).

3Sum (classic) — sort + two-pointer
pythondef three_sum(nums):
    nums.sort()                          # O(n log n)
    res = []
    for i in range(len(nums) - 2):
        if i > 0 and nums[i] == nums[i-1]:
            continue                     # skip duplicate anchors
        lo, hi = i + 1, len(nums) - 1
        while lo < hi:
            s = nums[i] + nums[lo] + nums[hi]
            if s < 0: lo += 1
            elif s > 0: hi -= 1
            else:
                res.append([nums[i], nums[lo], nums[hi]])
                lo += 1; hi -= 1
                while lo < hi and nums[lo] == nums[lo-1]: lo += 1  # skip dups
    return res                       # O(n²) total, O(1) extra
▶ How this works

Problem: find all unique triplets in nums that sum to zero. The pattern: sort, then for each anchor run a two-pointer sweep. The only fiddly part is not reporting the same triplet twice.

  1. nums.sort() sorts in place — cheap (O(n log n)) and it unlocks everything else. res collects the answer triplets.
  2. for i in range(len(nums) - 2) picks each anchor. The if i > 0 and nums[i] == nums[i-1]: continue line skips duplicate anchors so we don't emit the same triplet again.
  3. lo, hi = i + 1, len(nums) - 1 sets the two pointers on either end of the rest. The while lo < hi loop converges them: sum too small → lo += 1, too big → hi -= 1, exactly right → record it.
  4. After recording a hit, the inner while ... nums[lo] == nums[lo-1]: lo += 1 skips over repeated values so duplicate triplets aren't produced.

What the output means: Returns a list of triplets that each sum to 0, e.g. [[-1,-1,2],[-1,0,1]], with no duplicates.

Try this: Remove the two duplicate-skipping lines and run on [-1,-1,2,0,1] — you'll get the same triplet twice. Those continue/skip lines are what interviewers check for.

More examples · container with most water + reverse a linked list
python# (a) Container with most water (classic) — greedy two-pointer, O(n)
def max_area(height):
    lo, hi, best = 0, len(height) - 1, 0
    while lo < hi:
        best = max(best, (hi - lo) * min(height[lo], height[hi]))
        if height[lo] < height[hi]:      # move the SHORTER wall inward
            lo += 1
        else:
            hi -= 1
    return best

# (b) Reverse a linked list (classic) — the pointer-flip classic, O(n)
def reverse_list(head):
    prev = None
    while head:
        head.next, prev, head = prev, head, head.next   # flip & advance
    return prev

# (c) Middle of a linked list (classic) — fast/slow, O(n)
def middle(head):
    slow = fast = head
    while fast and fast.next:
        slow, fast = slow.next, fast.next.next    # slow ends at the middle
    return slow

print(max_area([1,8,6,2,5,4,8,3,7]))    # 49
▶ How this works

Three short two-pointer classics that show the range of the pattern: (a) converging pointers on an array, (b) the pointer-flip that reverses a linked list, and (c) the fast/slow pointer pair on a linked list.

  1. (a) max_area: two walls form a water container; area = width × the shorter wall. Start pointers at both ends and always move the shorter wall inward — moving the taller one could never help, so this greedy choice is safe and gives O(n).
  2. (b) reverse_list: head.next, prev, head = prev, head, head.next flips one arrow and advances, all in a single line. Python evaluates the right side first, so this safely reverses the link and steps forward at once.
  3. (c) middle: slow moves one node, fast moves two. When fast reaches the end, slow is exactly halfway — the fast/slow ("tortoise and hare") trick, no length count needed.

What the output means: Prints 49 — the most water the container in [1,8,6,2,5,4,8,3,7] can hold.

Try this: For (c): if fast moves twice as fast as slow, slow covers exactly half the distance by the time fast finishes. That same idea (different speeds) detects a cycle in a list — the hare laps the tortoise.

🔗 Builds onThe converging two-pointer scan and Floyd's fast/slow cycle detection are both from D2; the sorted-array insight is D1. Interview favorites: two-sum-II, container-with-most-water, trapping-rain-water, remove-duplicates, linked-list-cycle-II, reorder-list, palindrome-linked-list, remove-nth-from-end.

3 · Top-K with a heap very common

Tell: "K largest / smallest / most frequent / closest …". Keep a size-K heap: O(n log k), far better than sorting everything when k ≪ n.

stream of n items · a size-3 min-heap keeps the 3 largest 9 2 7 8 1 7 8 9 top-3 (min at root = 7) smaller items never displace them → O(n log k) A size-K heap beats a full sort. Push each item; if the heap exceeds K, pop the smallest. Only K items are ever held, and each push/pop is O(log k)O(n log k) total, streaming-friendly.
🗺️ How to read this diagram

A heap is a container that always knows its smallest item instantly. To find the K largest items in a big stream, you keep a heap of size K and let small items fall out — this diagram shows why that works.

  • The left boxes are the incoming stream of numbers, arriving one at a time.
  • The green boxes on the right are the size-3 heap — it holds only the 3 largest seen so far. The root (smallest of the three, here 7) is always the easiest one to check and throw away.
  • The rule: push each new number; if the heap now has more than K, pop the smallest. A number smaller than the current root can never be in the top-K, so it's discarded immediately.
  • Because the heap never grows past K, each push/pop is O(log k), giving O(n log k) overall — much cheaper than sorting all n when k is small.

In short: Counter-intuitive but key: to keep the K largest, you use a min-heap. The smallest of your current winners sits at the root, ready to be evicted the moment something bigger arrives.

Top-K frequent elements (classic)
pythonimport heapq
from collections import Counter

def top_k_frequent(nums, k):
    counts = Counter(nums)                       # O(n)
    # heap of (freq, value); nlargest keeps only k
    return [v for v, _ in heapq.nlargest(k, counts.items(), key=lambda kv: kv[1])]

print(top_k_frequent([1,1,1,2,2,3], 2))   # [1, 2]
▶ How this works

Problem: return the k most frequent numbers in a list. Count how often each appears, then keep only the top k by frequency using a heap.

  1. Counter(nums) builds a dictionary of value → how many times it appears in one O(n) pass.
  2. heapq.nlargest(k, counts.items(), key=lambda kv: kv[1]) asks the heap for the k items with the biggest value of kv[1] — and kv[1] is the frequency (each item is a (value, frequency) pair, so index [1] is the count).
  3. The list comprehension [v for v, _ in ...] then throws away the counts and keeps just the values. The _ is Python's "I don't need this" placeholder.

What the output means: Prints [1, 2] — in [1,1,1,2,2,3], 1 appears 3 times and 2 appears twice, so those are the two most frequent.

Try this: nlargest hides the size-K heap for you. Change k to 1 and you'll get just [1], the single most common value.

More examples · K-closest points, merge K lists, running median
pythonimport heapq

# (a) K closest points to origin (classic) — O(n log k)
def k_closest(points, k):
    return heapq.nsmallest(k, points, key=lambda p: p[0]**2 + p[1]**2)

# (b) Merge k sorted lists (classic) — heap of (val, list_idx, node), O(N log k)
def merge_k(lists):
    heap = [(vals[0], i, 0) for i, vals in enumerate(lists) if vals]
    heapq.heapify(heap)
    out = []
    while heap:
        val, i, j = heapq.heappop(heap)      # smallest across all lists
        out.append(val)
        if j + 1 < len(lists[i]):
            heapq.heappush(heap, (lists[i][j+1], i, j+1))
    return out

# (c) Find median from a data stream (classic) — TWO heaps
class MedianFinder:
    def __init__(self):
        self.lo = []   # max-heap (store negatives) — smaller half
        self.hi = []   # min-heap — larger half
    def add(self, num):
        heapq.heappush(self.lo, -heapq.heappushpop(self.hi, num))
        if len(self.lo) > len(self.hi):
            heapq.heappush(self.hi, -heapq.heappop(self.lo))
    def median(self):
        if len(self.hi) > len(self.lo): return float(self.hi[0])
        return (self.hi[0] - self.lo[0]) / 2

print(k_closest([[1,3],[-2,2],[5,8]], 2))    # [[-2,2],[1,3]]
▶ How this works

Three heap patterns that go beyond simple top-K: (a) K-closest points, (b) merging K sorted lists, and (c) the elegant two-heaps trick for a running median.

  1. (a) k_closest: distance to the origin is x² + y² (no square root needed — comparing squares gives the same order). heapq.nsmallest keeps the k closest in O(n log k).
  2. (b) merge_k: seed the heap with the first value of each list. Each pop gives the smallest value across all lists at once; then push the next value from the list that value came from. This merges everything in sorted order.
  3. (c) MedianFinder: keep two heaps — lo is a max-heap of the smaller half (stored as negatives, since Python only has min-heaps) and hi is a min-heap of the larger half.
  4. add pushes into one heap and rebalances so the halves stay equal-ish. The median is then either the top of the bigger half, or the average of the two tops — an O(1) lookup.

What the output means: Prints [[-2,2],[1,3]] — the two points nearest the origin among [[1,3],[-2,2],[5,8]].

Try this: For the median: add 1,2,3 one at a time and print median() after each. The two heaps always keep the middle value(s) sitting right at their roots.

The two-heaps trickKeeping a max-heap of the smaller half and a min-heap of the larger half (balanced in size) puts the median at the two roots — O(log n) insert, O(1) query. It's the go-to for "median/percentile of a stream" and sliding-window-median.
🔗 Builds onThe heap from D4 / D2. Same pattern: K-closest-points, Kth-largest-in-a-stream, merge-K-sorted-lists, task-scheduler, reorganize-string, find-median-from-data-stream (two heaps). This is also exactly RAG top-k retrieval (Ch 3).

4 · Binary search on the answer common

Tell: "minimum/maximum value such that a condition holds", or a sorted/rotated array. If you can write a monotonic feasible(x) check, binary-search the answer space in O(log range · check).

answer space is monotonic: once feasible, always feasible too slow (finishes late) fast enough ✓ boundary = the answer binary-search for the leftmost ✓ → O(log range × check) "Binary search on the answer." When feasibility is monotonic (if speed x works, so does any faster x), the yes/no boundary is exactly the optimum — find it by binary-searching the value range, not the array.
🗺️ How to read this diagram

This is the mind-bending pattern: sometimes you binary-search a range of possible answers instead of searching an array. It works whenever the answer has a monotonic yes/no property.

  • The bar represents every candidate answer (here: every possible eating speed), lined up from small on the left to large on the right.
  • The red region is "too slow — fails," the green region is "fast enough — works." The crucial fact: once a speed works, every faster speed also works — no flip-flopping. That's monotonic.
  • The vertical line is the boundary between fail and pass — and that boundary is exactly the optimal answer (the smallest speed that still works).
  • You find that boundary by binary-searching the value range: guess the middle speed, test if it works, and throw away half the range each time.

In short: The tell: "minimum/maximum value such that a condition holds." If you can write a feasible(x) check that's monotonic, binary-search the answer in O(log range) guesses.

Koko eating bananas (classic) — binary search on speed
pythonimport math

def min_eating_speed(piles, h):
    def hours_at(speed):
        return sum(math.ceil(p / speed) for p in piles)
    lo, hi = 1, max(piles)              # answer lives in [1, maxpile]
    while lo < hi:
        mid = (lo + hi) // 2
        if hours_at(mid) <= h:         # feasible -> try slower
            hi = mid
        else:
            lo = mid + 1              # too slow -> go faster
    return lo                        # smallest feasible speed
▶ How this works

Problem: Koko eats bananas and must finish all piles within h hours; find her slowest workable speed. The answer isn't in the input array — it's a speed value we search for.

  1. hours_at(speed) is the feasibility check: at a given speed, how many hours does eating every pile take? math.ceil(p / speed) rounds up because a partial pile still costs a whole hour.
  2. lo, hi = 1, max(piles) bounds the search: the slowest sensible speed is 1, the fastest ever useful is the biggest pile (eating it in one hour).
  3. if hours_at(mid) <= h means this speed works — so we try to go slower by setting hi = mid (keep mid as a candidate). Otherwise it's too slow to finish in time, so lo = mid + 1 forces a faster speed.
  4. The loop narrows [lo, hi] until they meet on the smallest speed that still finishes in time — the answer.

What the output means: Returns the minimum integer eating speed at which all piles are finished within h hours.

Try this: Notice we never search the piles array — we search the speeds 1..max(piles). Spotting that the answer lives in a searchable, monotonic range is the whole skill here.

The reframe that unlocks itMany "optimize a number" problems become binary search once you spot the monotonic property: if speed x works, so does any x+1. Cousins: search-in-rotated-sorted-array, find-min-in-rotated-array, split-array-largest-sum, capacity-to-ship-packages, median-of-two-sorted-arrays. Foundation: D6 binary search.

5 · Merge intervals common

Tell: anything with start/end ranges — "merge overlapping", "can attend all meetings", "insert interval". Sort by start, then sweep.

sort by start, then merge any that overlap [1,3] [2,6] [8,10] [1,6] merged overlap: 2 ≤ 3 → merge into [1, max(3,6)] Sort, then sweep left to right. Two intervals overlap when the next start ≤ the current end; merge by extending the end. One sort + one pass → O(n log n).
🗺️ How to read this diagram

An interval is a start–end range (like a meeting). This pattern merges any that overlap. The diagram shows why sorting by start makes it a simple left-to-right sweep.

  • Each horizontal bar is one interval; its length spans from its start to its end on a timeline.
  • [1,3] and [2,6] overlap because the second starts (2) before the first ends (3). They collapse into one bar [1,6] — start of the first, end of the later one.
  • [8,10] starts after everything else ends, so it stays separate (shown greyed).
  • The rule at the bottom: two intervals overlap when next start ≤ current end; merge by extending the end to max(current end, next end).

In short: Sort by start once, then walk through comparing each interval to the last one you kept. One sort + one pass = O(n log n).

Merge intervals (classic)
pythondef merge(intervals):
    intervals.sort(key=lambda iv: iv[0])     # by start
    out = [intervals[0][:]]
    for start, end in intervals[1:]:
        if start <= out[-1][1]:            # overlaps previous
            out[-1][1] = max(out[-1][1], end)
        else:
            out.append([start, end])
    return out

print(merge([[1,3],[2,6],[8,10]]))   # [[1,6],[8,10]]
▶ How this works

Problem: given a list of [start, end] ranges, merge every overlapping pair into one. The trick is entirely in the sorting: once sorted by start, a single sweep does it.

  1. intervals.sort(key=lambda iv: iv[0]) sorts by the start value. After this, any interval that overlaps another must be adjacent in the list — that's the guarantee sorting buys us.
  2. out = [intervals[0][:]] seeds the result with a copy of the first interval (the [:] copies so we don't mutate the input).
  3. if start <= out[-1][1] checks if the current interval overlaps the previous kept one (its start is at or before the previous end). If so, extend that previous interval's end with max(...).
  4. Otherwise there's a gap, so we out.append([start, end]) to begin a fresh interval.

What the output means: Prints [[1,6],[8,10]][1,3] and [2,6] merge into [1,6]; [8,10] stands alone.

Try this: Add [5,7] to the input. Because it overlaps [1,6] (5 ≤ 6), the answer becomes [[1,7],[8,10]] — the merge chains through automatically.

6 · BFS/DFS on a grid (islands) common

Tell: a 2-D grid where you "count regions / flood fill / shortest path in a maze". Treat each cell as a graph node with up/down/left/right edges.

count connected groups of 1s (4-directional) 1101 1000 0011 3 islands: green, purple, amber flood-fill each unvisited 1 Islands = connected components on a grid. Scan cells; on each unvisited land cell, BFS/DFS floods its whole island (marking visited) and you count +1. Every cell is touched once → O(rows·cols).
🗺️ How to read this diagram

This treats a grid of 0s and 1s as a map: 1 is land, 0 is water. An island is a group of 1s connected up/down/left/right. The task counts the islands.

  • Each cell is a square in the grid. Colored groups show three separate islands (green, purple, amber) — clumps of 1s touching edge-to-edge.
  • Cells connect 4-directionally only (up, down, left, right) — diagonals don't count, so the amber cells at the bottom form their own island.
  • The method: scan every cell; when you hit an unvisited 1, flood-fill it — visit its whole connected clump and mark each cell so you never recount it — then add 1 to the island count.
  • Every cell is touched once, so the whole thing is O(rows·cols).

In short: Think of each land cell as a graph node with edges to its 4 neighbours. "Count islands" is just "count connected components" — the same BFS/DFS you'd run on any graph.

Number of islands (classic) — DFS flood fill
pythondef num_islands(grid):
    if not grid: return 0
    rows, cols = len(grid), len(grid[0])
    def sink(r, c):
        if 0 <= r < rows and 0 <= c < cols and grid[r][c] == "1":
            grid[r][c] = "0"                # mark visited
            sink(r+1, c); sink(r-1, c); sink(r, c+1); sink(r, c-1)
    count = 0
    for r in range(rows):
        for c in range(cols):
            if grid[r][c] == "1":
                count += 1; sink(r, c)      # one new island, flood it
    return count
▶ How this works

Problem: count the islands (connected groups of "1") in a grid. The approach: walk every cell, and each time you find new land, sink (flood-fill) the whole island so it's not counted again.

  1. rows, cols = len(grid), len(grid[0]) grabs the grid dimensions so the helper can check boundaries.
  2. sink(r, c) is a recursive flood fill. The guard 0 <= r < rows and 0 <= c < cols and grid[r][c] == "1" stops it from running off the edge or onto water/visited cells.
  3. Once on land, grid[r][c] = "0" marks it visited (by turning it to water), then it recurses into all four neighbours — spreading across the whole connected island.
  4. The double for loop scans every cell; each fresh "1" means one new island, so count += 1 and we sink it flat before moving on.

What the output means: Returns the number of separate islands — e.g. 3 for the grid drawn in the diagram above.

Try this: This uses DFS (recursion). The very same problem can be solved with BFS and a queue — try rewriting sink to push neighbours onto a deque instead of recursing.

More examples · rotting oranges (multi-source BFS) + course schedule (topo)
pythonfrom collections import deque

# (a) Rotting oranges (classic) — BFS from ALL rotten cells at once
def oranges_rotting(grid):
    rows, cols = len(grid), len(grid[0])
    q = deque(); fresh = 0
    for r in range(rows):
        for c in range(cols):
            if grid[r][c] == 2: q.append((r, c, 0))   # seed all sources
            elif grid[r][c] == 1: fresh += 1
    minutes = 0
    while q:
        r, c, t = q.popleft(); minutes = max(minutes, t)
        for dr, dc in ((1,0),(-1,0),(0,1),(0,-1)):
            nr, nc = r+dr, c+dc
            if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == 1:
                grid[nr][nc] = 2; fresh -= 1; q.append((nr, nc, t+1))
    return minutes if fresh == 0 else -1

# (b) Course schedule (classic) — cycle detect via topological sort (Kahn)
def can_finish(n, prereqs):
    adj = {i: [] for i in range(n)}; indeg = [0] * n
    for a, b in prereqs:
        adj[b].append(a); indeg[a] += 1
    q = deque([i for i in range(n) if indeg[i] == 0])
    done = 0
    while q:
        node = q.popleft(); done += 1
        for nxt in adj[node]:
            indeg[nxt] -= 1
            if indeg[nxt] == 0: q.append(nxt)
    return done == n              # all scheduled → no cycle

print(can_finish(2, [[1,0]]), can_finish(2, [[1,0],[0,1]]))   # True False
▶ How this works

Two grid-graph variations: (a) multi-source BFS (many starting points spreading at once) and (b) topological sort to detect a cycle in a dependency graph.

  1. (a) oranges_rotting: rot spreads to neighbours each minute. Instead of one start, we seed all rotten oranges into the queue at once (multi-source BFS) and count fresh oranges.
  2. Each queued item carries (row, col, time). Popping a cell and pushing its fresh neighbours with t+1 spreads the rot outward level by level; minutes tracks the last time stamp. If any fresh orange survives, we return -1.
  3. (b) can_finish: courses with prerequisites form a directed graph. indeg[a] counts how many prerequisites course a still has; we start with everything that has zero prerequisites.
  4. Each time we "take" a course we decrement its dependents' indeg; any that hit 0 join the queue. If we manage to schedule all n courses, there was no cycle — return done == n.

What the output means: Prints True False: [[1,0]] is schedulable (take 0 then 1), but [[1,0],[0,1]] is a deadlock — each needs the other.

Try this: Multi-source BFS is the "fire spreads from many points" pattern. Topological sort is the "do tasks in dependency order" pattern — and if you can't finish, you've found a cycle.

🔗 Builds onBFS/DFS from D5, topological sort from D5. Same shape: max-area-of-island, rotting-oranges (multi-source BFS), word-search, surrounded-regions, walls-and-gates, shortest-path-in-binary-matrix, course-schedule-II, clone-graph, pacific-atlantic-water-flow.

7 · Trees: DFS, BFS & LCA common

Tell: "level order" → BFS; "path / depth / diameter / validate" → DFS recursion returning info up; "lowest common ancestor" → recurse and bubble up where the two targets are found.

LCA of 5 and 1 — the node where they first split 3 5 1 6 2 target found left AND right → LCA = 3 left→5 right→1 LCA bubbles findings up the recursion. Each call reports "did I find p or q below me?" The first node that gets a hit from both its left and right subtrees is the lowest common ancestor — one O(n) DFS.
🗺️ How to read this diagram

The lowest common ancestor (LCA) of two nodes is the deepest node that has both of them somewhere below it — like the nearest shared boss on an org chart. This diagram shows how one DFS finds it.

  • The circles are tree nodes; lines connect a parent to its children. We're finding the LCA of 5 and 1.
  • The search recurses down, then reports back up: each node answers "did I find 5 or 1 in my subtree?" The side labels (left→5, right→1) show node 3 getting a hit from both sides.
  • The node that first receives a "found" from both its left and right children is the LCA — here that's node 3, where the two targets first split apart.
  • If both targets are found on the same side, the answer simply bubbles further up from that side.

In short: "Bubbling up" means the recursion returns information from the leaves back toward the root. The first node that sees its two targets arrive from opposite subtrees is their meeting point — one O(n) DFS.

Lowest common ancestor of a binary tree (classic)
pythondef lowest_common_ancestor(root, p, q):
    if root is None or root is p or root is q:
        return root                     # found a target (or hit the bottom)
    left = lowest_common_ancestor(root.left, p, q)
    right = lowest_common_ancestor(root.right, p, q)
    if left and right:
        return root                     # p & q split here -> this is the LCA
    return left or right              # both on one side -> pass it up   O(n)
▶ How this works

Problem: find the lowest common ancestor of nodes p and q in a binary tree. This tiny function is a masterclass in "recurse and let answers bubble up."

  1. The base case if root is None or root is p or root is q: return root stops the recursion when we found a target (p or q) or ran off the bottom (None). It reports back whatever it hit.
  2. left = ...(root.left, ...) and right = ...(root.right, ...) ask each subtree "did you find either target?" This is the recursion doing the work for us.
  3. if left and right: return root is the key line: if both sides found something, the current node is where p and q split — so it's the LCA.
  4. return left or right handles the other case: if only one side found a target, pass that result upward so an ancestor can decide.

What the output means: Returns the LCA node — for targets 5 and 1 in the diagram's tree, it returns node 3.

Try this: This one function quietly covers three outcomes: both targets on the left, both on the right, or split. Trace each case on paper to see how left or right forwards the answer up.

🔗 Builds onTree traversal + recursion from D4. Same family: level-order-traversal, max-depth, diameter-of-binary-tree, validate-BST, path-sum, serialize/deserialize, right-side-view, binary-tree-max-path-sum.

8 · Backtracking (subsets, permutations, combinations) common

Tell: "generate all / find all valid …", combinatorial explosion, constraints (N-queens, Sudoku, word-search). Choose → recurse → un-choose.

subsets of [1,2,3]: at each level, include or exclude the element {} 1 skip incl 1 excl 1 2 skip {1,2}… {1}… {}… 2ⁿ leaves =2ⁿ subsets Backtracking explores a decision tree. Each element is a binary choice — include or exclude — so the tree has 2ⁿ leaves (one per subset). The choose → recurse → un-choose pattern walks every branch, reusing one path list.
🗺️ How to read this diagram

Backtracking explores every combination by making a series of choices, then undoing them. This diagram shows all subsets of [1,2,3] as a decision tree.

  • The root at the top is the empty set {} — no decisions made yet.
  • At each level you decide one element: the left branch "includes" it, the right branch "excludes" it. Every path from top to bottom is one specific subset.
  • Because each of the n elements is a yes/no choice, the tree has 2ⁿ leaves — exactly the number of possible subsets.
  • The choose → recurse → un-choose loop walks the tree, reusing a single path list: add an element, explore deeper, then remove it to try the other branch.

In short: Backtracking = try a choice, explore everything it leads to, then undo it and try the next. That undo ("un-choose") is what lets one list explore all 2ⁿ branches.

Subsets (classic)
pythondef subsets(nums):
    res, path = [], []
    def backtrack(start):
        res.append(path[:])                 # every node is a valid subset
        for i in range(start, len(nums)):
            path.append(nums[i])            # choose
            backtrack(i + 1)               # explore (no reuse)
            path.pop()                      # un-choose
    backtrack(0)
    return res                          # 2ⁿ subsets

print(subsets([1, 2, 3]))            # [], [1], [1,2], [1,2,3], [1,3], [2], [2,3], [3]
▶ How this works

Problem: generate all subsets of a list. This is the canonical backtracking template — learn its shape and permutations, combinations, and N-queens all follow the same skeleton.

  1. res holds all subsets found; path is the subset we're currently building as we descend the tree.
  2. res.append(path[:]) records the current subset — and note every node is a valid subset, not just the leaves, so we save on entry. The [:] stores a snapshot copy (otherwise later edits would corrupt it).
  3. The loop for i in range(start, len(nums)) tries each remaining element. path.append(nums[i]) is choose; backtrack(i + 1) is explore (starting after i so elements aren't reused); path.pop() is un-choose.
  4. The start index is what prevents duplicate subsets like [1,2] and [2,1] — we only ever move forward.

What the output means: Prints all 8 subsets of [1,2,3]: [], [1], [1,2], [1,2,3], [1,3], [2], [2,3], [3].

Try this: Comment out path.pop() and watch the output break — without the "un-choose" step the shared path never rewinds, so every branch corrupts the next.

🔗 Builds onThe backtracking skeleton from D3. Same template: permutations, combinations, combination-sum, palindrome-partitioning, N-queens, Sudoku-solver, generate-parentheses, word-search.

9 · Dynamic programming the hard round expert

Tell: "count the ways", "min/max cost/steps", "can you reach/partition", overlapping subproblems. Define a state, a recurrence, and a base case — then either memoize (top-down) or fill a table (bottom-up).

0/1 knapsack: dp[i][w] = best value using first i items, capacity w ↖ skip ↑ takemax each cell = max( skip: dp[i-1][w], take: val + dp[i-1][w-wt] ) each subproblem solved once → O(n·W) overlapping subproblems + optimal substructure = DP DP fills a table of subproblem answers. Each cell reuses smaller cells (here: take the item or skip it), so exponential recursion becomes a polynomial table. Spot it by the "count ways / optimize over choices" tell + repeated states.
🗺️ How to read this diagram

Dynamic programming (DP) solves a big problem by filling a table of smaller answers, each reused instead of recomputed. This shows the classic 0/1 knapsack table.

  • Each cell dp[i][w] answers a subproblem: the best value using the first i items with a bag capacity of w.
  • The two arrows show how a cell is built from earlier ones: ↖ skip this item (copy the value above) or ↑ take it (its value plus the best that fit in the leftover capacity).
  • Each cell is the max of those two choices — max(skip, take) — which is why the green cell is the winner of the comparison.
  • Because every subproblem is solved once and stored, exponential recursion collapses to an O(n·W) table.

In short: The two DP hallmarks: overlapping subproblems (the same cell would be recomputed many times by naive recursion) and optimal substructure (the best full answer is built from best sub-answers). Spot those and reach for a table.

Coin change (classic) & longest common subsequence (classic)
python# Fewest coins to make `amount` — bottom-up DP, O(amount · coins)
def coin_change(coins, amount):
    dp = [0] + [float("inf")] * amount     # dp[x] = min coins for x
    for x in range(1, amount + 1):
        for c in coins:
            if c <= x:
                dp[x] = min(dp[x], dp[x - c] + 1)
    return dp[amount] if dp[amount] != float("inf") else -1

# Longest common subsequence — the classic 2-D table, O(m·n)
def lcs(a, b):
    m, n = len(a), len(b)
    dp = [[0] * (n + 1) for _ in range(m + 1)]
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if a[i-1] == b[j-1]:
                dp[i][j] = dp[i-1][j-1] + 1       # match -> extend diagonal
            else:
                dp[i][j] = max(dp[i-1][j], dp[i][j-1])
    return dp[m][n]

print(coin_change([1,2,5], 11), lcs("abcde", "ace"))   # 3  3
▶ How this works

Two bottom-up DP classics. (a) Coin change (fewest coins to reach an amount) builds a 1-D table; (b) longest common subsequence (LCS) builds a 2-D table. Both fill small answers first, then combine them.

  1. (a) coin_change: dp[x] is the fewest coins to make amount x. Start with dp[0]=0 and everything else inf (unreachable).
  2. For each amount x and each coin c, dp[x] = min(dp[x], dp[x-c] + 1) asks "is using this coin (1 coin plus the best way to make the remainder x-c) better than what I have?" That's the recurrence.
  3. (b) lcs: dp[i][j] is the longest subsequence shared by the first i letters of a and first j of b.
  4. If the current letters match, extend the diagonal: dp[i-1][j-1] + 1. If not, carry the best of dropping one letter from either string: max(dp[i-1][j], dp[i][j-1]).

What the output means: Prints 3 3: it takes 3 coins (5+5+1) to make 11 from [1,2,5], and the longest common subsequence of "abcde" and "ace" is "ace" (length 3).

Try this: For coin change, print the whole dp array at the end and read off how each amount's answer was built from a smaller one — that's DP made visible.

More examples · climbing stairs, house robber, LIS, word break
python# (a) Climbing stairs (classic) — Fibonacci in disguise, O(n) / O(1) space
def climb(n):
    a, b = 1, 1
    for _ in range(n): a, b = b, a + b     # ways(i) = ways(i-1) + ways(i-2)
    return a

# (b) House robber (classic) — take-or-skip, O(n) / O(1) space
def rob(nums):
    take = skip = 0
    for x in nums:
        take, skip = skip + x, max(skip, take)   # rob this? then couldn't rob prev
    return max(take, skip)

# (c) Longest increasing subsequence (classic) — O(n log n) with bisect
import bisect
def lis(nums):
    tails = []
    for x in nums:
        i = bisect.bisect_left(tails, x)     # first tail >= x
        if i == len(tails): tails.append(x)
        else: tails[i] = x                      # keep tails as small as possible
    return len(tails)

# (d) Word break (classic) — dp[i] = can we segment s[:i]?  O(n²)
def word_break(s, words):
    wordset = set(words); dp = [True] + [False] * len(s)
    for i in range(1, len(s) + 1):
        for j in range(i):
            if dp[j] and s[j:i] in wordset:
                dp[i] = True; break
    return dp[len(s)]

print(climb(5), rob([2,7,9,3,1]), lis([10,9,2,5,3,7,101,18]))  # 8 12 4
▶ How this works

Four more DP staples that show the space-saving trick: when a cell only needs the last one or two cells, you can drop the whole table and keep a couple of variables.

  1. (a) climb: ways to reach step n = ways to reach n-1 + ways to reach n-2 — literally Fibonacci in disguise. Two rolling variables a, b replace the table → O(1) space.
  2. (b) rob: at each house choose take-or-skip. take, skip = skip + x, max(skip, take) — if you rob this house you add it to the best that skipped the previous one; otherwise carry the best so far.
  3. (c) lis: longest increasing subsequence in O(n log n). tails keeps the smallest possible tail for each length; bisect_left finds where the new value fits, replacing a tail (to keep options open) or extending the list.
  4. (d) word_break: dp[i] = "can s[:i] be split into dictionary words?" It's true if some earlier valid point dp[j] is true and s[j:i] is a word.

What the output means: Prints 8 12 4: 8 ways to climb 5 stairs; max robbery of [2,7,9,3,1] is 12 (2+9+1); longest increasing subsequence length is 4.

Try this: For climb, print a, b each iteration — you'll see the Fibonacci sequence appear. Recognizing a problem as "Fibonacci-shaped" instantly gives you the O(1) solution.

The DP recognition checklist(1) Are there choices at each step? (2) Do subproblems repeat? (3) Is there optimal substructure (best answer built from best sub-answers)? If yes → define dp[state], write the recurrence, pick top-down (memo, D3) or bottom-up. Interview staples: climbing-stairs, house-robber, knapsack, coin-change, LCS, edit-distance, longest-increasing-subsequence, word-break, unique-paths, partition-equal-subset-sum, decode-ways, maximum-subarray (Kadane).

10 · Two more high-frequency tools common

Two patterns that appear constantly and are easy to name once seen:

Monotonic stack · daily temperatures (classic)
pythondef daily_temperatures(temps):
    res = [0] * len(temps)
    stack = []                          # indices, temps DECREASING down the stack
    for i, t in enumerate(temps):
        while stack and temps[stack[-1]] < t:   # t is the "next warmer" for these
            j = stack.pop()
            res[j] = i - j              # days until warmer
        stack.append(i)
    return res                       # O(n): each index pushed/popped once

print(daily_temperatures([73,74,75,71,69,72,76,73]))   # [1,1,4,2,1,1,0,0]
▶ How this works

Problem: for each day, how many days until a warmer temperature? A brute-force double loop is O(n²); a monotonic stack does it in O(n) by remembering days still "waiting" for a warmer one.

  1. res = [0] * len(temps) defaults every answer to 0 (meaning "no warmer day ahead"). stack holds indices of days whose warmer day hasn't been found yet, kept with temperatures decreasing down the stack.
  2. For each new day i with temperature t, the while loop pops every day on the stack that is colder than today — because today is their next warmer day.
  3. res[j] = i - j records the wait: the gap in days between the colder day j and today i.
  4. Then stack.append(i) parks today to wait for its warmer day. Each index is pushed and popped at most once → O(n).

What the output means: Prints [1,1,4,2,1,1,0,0] — e.g. day 0 (73°) waits 1 day for 74°; the last two days have no warmer day, so 0.

Try this: "Monotonic" just means the stack stays sorted (here decreasing). The tell for this pattern is "next greater/smaller element" — daily-temperatures, next-greater-element, and largest-rectangle all use it.

Prefix sum + hashmap · subarray sum equals K (classic)
pythonfrom collections import defaultdict

def subarray_sum(nums, k):
    seen = defaultdict(int)
    seen[0] = 1                    # empty prefix
    running = count = 0
    for x in nums:
        running += x
        count += seen[running - k]   # a prefix that leaves exactly k? O(1)
        seen[running] += 1
    return count                    # O(n) time, O(n) space

print(subarray_sum([1, 1, 1], 2))     # 2
▶ How this works

Problem: count how many contiguous subarrays sum to exactly k. The prefix-sum + hashmap trick turns an O(n²) search into a single O(n) pass.

  1. A prefix sum is the running total from the start. If two prefix sums differ by k, the numbers between them sum to k — that's the whole insight.
  2. seen counts how many times each running total has occurred; seen[0] = 1 seeds the "empty prefix" so subarrays starting at index 0 are counted.
  3. Each step adds x to running, then count += seen[running - k] asks "how many earlier prefixes leave exactly k between them and here?" — an O(1) dictionary lookup instead of scanning back.
  4. seen[running] += 1 then records the current prefix for future steps to find.

What the output means: Prints 2 — in [1,1,1] with k=2, the subarrays [1,1] at positions 0–1 and 1–2 both sum to 2.

Try this: The magic is running - k: instead of asking "what sums to k?" you ask "have I seen a prefix that, subtracted from now, leaves k?" This complement-in-a-hashmap idea also powers Two Sum.

11 · Linked list manipulation very common

Tell: the input is a linked list and you must reverse, merge, reorder, or find a node with O(1) space. The toolkit: a dummy head to kill edge cases, pointer-flip reversal, and fast/slow pointers. This is such a big topic it has its own page — D8 · Linked list patterns — but here are the interview must-knows with diagrams.

reverse: save next, flip cur.next to prev, slide all three forward 1 2 3 4 prev cur nxt (saved first) Reversal is the core linked-list skill. Keep prev/cur/nxt; save the next node before overwriting the pointer. This single move powers reverse-list, reverse-in-K-groups, palindrome, and reorder.
🗺️ How to read this diagram

A linked list is a chain of nodes where each one points to the next. Reversing it means flipping every arrow to point backward. This diagram shows the three-pointer dance that does it.

  • The boxes are nodes (1→2→3→4); the arrows are the next pointers linking them.
  • Three pointers do the work: prev (the part already reversed, behind us), cur (the node we're flipping now), and nxt (the next node — saved first so we don't lose the rest of the list).
  • The blue backward arrows show pointers already flipped to point at prev; the grey forward arrow is the link we're about to rewrite.
  • Each step: save nxt, point cur.next back at prev, then slide all three forward. When cur falls off the end, prev is the new head.

In short: The one rule that keeps you safe: save the next node before overwriting the pointer. Miss that and you lose the tail of the list. This move powers reverse, reverse-in-K-groups, palindrome, and reorder.

Reverse, merge two sorted, remove nth-from-end
pythonclass ListNode:
    def __init__(self, val=0, nxt=None): self.val, self.next = val, nxt

# (a) Reverse a list (classic) — O(n), O(1)
def reverse(head):
    prev = None
    while head:
        head.next, prev, head = prev, head, head.next
    return prev

# (b) Merge two sorted lists (classic) — build behind a dummy tail
def merge_two(a, b):
    dummy = tail = ListNode()
    while a and b:
        if a.val <= b.val: tail.next, a = a, a.next
        else: tail.next, b = b, b.next
        tail = tail.next
    tail.next = a or b
    return dummy.next

# (c) Remove nth node from end (classic) — offset two-pointer, one pass
def remove_nth_from_end(head, n):
    dummy = ListNode(0, head); fast = slow = dummy
    for _ in range(n): fast = fast.next   # open an n-gap
    while fast.next: fast, slow = fast.next, slow.next
    slow.next = slow.next.next            # unlink the target
    return dummy.next
▶ How this works

Three must-know linked-list moves, all in O(1) extra space: (a) reverse, (b) merge two sorted lists using a dummy head, and (c) remove the nth node from the end with an offset two-pointer.

  1. ListNode is the node type: a val and a next pointer to the following node.
  2. (a) reverse: head.next, prev, head = prev, head, head.next flips one link and advances in a single line (Python computes the whole right side before assigning).
  3. (b) merge_two: a dummy head node lets us build behind a dummy tail without special-casing the first node. We repeatedly attach the smaller of the two list heads; at the end tail.next = a or b appends whatever remains, and dummy.next is the real merged head.
  4. (c) remove_nth_from_end: move fast n nodes ahead to open a gap, then move fast and slow together. When fast reaches the end, slow sits just before the target, so slow.next = slow.next.next unlinks it — all in one pass.

What the output means: Each returns a rebuilt list head: (a) the reversed list, (b) the two lists merged in sorted order, (c) the list with the nth-from-end node removed.

Try this: The dummy head is the unsung hero — it removes the "what if the list is empty / we remove the first node?" edge cases so your main loop stays clean. Watch for it in almost every linked-list solution.

remove 2nd-from-end: open an n-gap, then move both to the end 1 2 3 4 5 slow (before target) fast (end) ← unlink Offset two-pointer = one-pass "nth from end". Advance fast n nodes first; then move fast and slow together until fast hits the end — slow now sits just before the node to remove. No length precompute.
🗺️ How to read this diagram

This shows the one-pass way to remove the nth node from the end of a linked list — without first counting its length. The trick is an offset (a fixed gap) between two pointers.

  • The boxes are the list nodes (1→2→3→4→5); here we're removing the 2nd-from-end (node 4, though the red-marked cell illustrates the target).
  • First move fast forward n nodes to open an n-sized gap ahead of slow.
  • Then advance both together until fast reaches the end. Because the gap is fixed at n, slow ends up exactly n+1 from the end — just before the node to remove.
  • slow then relinks past the target (slow.next = slow.next.next), unlinking it in a single pass.

In short: An offset between two pointers turns "nth from the end" into "catch up to the end" — no length precompute, one traversal. A dummy head handles the edge case of removing the very first node.

🔗 Full catalogThis is a taste — the complete set (three list types, reverse-between, reverse-K-group, palindrome, reorder, add-two-numbers, intersection, copy-with-random-pointer) with diagrams is in D8 · Linked list patterns. Foundations: D2 §6–8.

Pattern → tell cheat-sheet expert

If the problem says…Reach forComplexity
longest/shortest contiguous subarray/substringsliding windowO(n)
sorted array, find pair/triplettwo pointersO(n)/O(n²)
cycle / middle / nth-from-end of a listfast & slow pointersO(n)
K largest/smallest/most-frequent/closestsize-K heapO(n log k)
min/max value such that condition holdsbinary search on answerO(n log range)
overlapping start/end rangessort + merge intervalsO(n log n)
grid regions / flood fill / maze shortest pathBFS/DFS on gridO(rc)
tree level order / path / LCABFS or DFS recursionO(n)
generate all / find all validbacktrackingexponential
count ways / optimize over choices + repeatsdynamic programmingpoly (states)
next greater/smaller elementmonotonic stackO(n)
subarray sum equals Kprefix sum + hashmapO(n)
reverse / merge / reorder a linked listdummy head + pointer-flip + fast/slowO(n), O(1)

Checkpoint — you're interview-ready when you can… expert

  • Read a problem and name the pattern from its "tell" within a minute.
  • Code sliding-window, two-pointer, top-K heap, and BFS/DFS-on-grid from muscle memory.
  • Reframe an optimization problem as binary-search-on-the-answer.
  • Write the backtracking skeleton and a DP recurrence (top-down or bottom-up) for the classics.
  • State the time & space complexity of your solution — and why it's optimal.
How to practiceDon't grind hundreds of random problems. Pick 3–5 per pattern above, solve them until the skeleton is automatic, and always say the complexity out loud. Recognizing the pattern is 80% of the interview; the code is the easy 20% once you've internalized D1–D6.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Sliding window: max sum of size kBeginner

Context: The fixed-size sliding window is the pattern behind a whole family of "contiguous subarray" questions and a fast interview win.

Your task: Find the maximum sum of any contiguous window of size k in O(n) by sliding the window rather than recomputing each sum.

Requirements:

  • Compute the first window's sum once
  • Slide by adding the incoming element and subtracting the outgoing one
  • O(n) time (no nested recompute)
  • Return the maximum window sum
  • Handle k larger than the array gracefully

💡 Hint: Each slide is two arithmetic operations, so the running sum never has to be rebuilt from scratch.

Show solution
def max_window_sum(nums, k):           # O(n) time, O(1) space
    window = sum(nums[:k])
    best = window
    for i in range(k, len(nums)):
        window += nums[i] - nums[i - k]   # add entering, drop leaving
        best = max(best, window)
    return best

print(max_window_sum([2, 1, 5, 1, 3, 2], 3))   # 9  (5+1+3)

Reusing the previous sum turns an O(n·k) recompute into a single O(n) sweep — the sliding-window "tell".

Exercise 2 · Merge intervalsIntermediate

Context: Merge-intervals (classic) is the tell for a whole class of scheduling and range problems: sort, then sweep.

Your task: Given a list of intervals, merge all overlapping ones by sorting on start then sweeping, in O(n log n).

Requirements:

  • Sort intervals by start
  • Extend the current interval when the next one overlaps it
  • Emit a new interval when there's a gap
  • O(n log n) dominated by the sort
  • Return the merged, non-overlapping list

💡 Hint: After sorting, an overlap exists whenever the next start is ≤ the current end; otherwise close off the current interval and start fresh.

Show solution
def merge_intervals(intervals):        # O(n log n)
    intervals.sort(key=lambda x: x[0])
    merged = [intervals[0]]
    for start, end in intervals[1:]:
        if start <= merged[-1][1]:     # overlaps the last merged interval
            merged[-1][1] = max(merged[-1][1], end)
        else:
            merged.append([start, end])
    return merged

print(merge_intervals([[1, 3], [2, 6], [8, 10], [15, 18]]))
# [[1, 6], [8, 10], [15, 18]]

After sorting by start, an interval can only overlap the most recent merged one — so a single pass suffices.

Exercise 3 · Top-K frequent elementsAdvanced

Context: Top-K frequent (classic) combines a frequency map with a size-k heap — the efficient way to avoid a full sort.

Your task: Return the k most frequent elements by counting with a hash map then using a heap of size k, in O(n log k).

Requirements:

  • Tally frequencies with a hash map
  • Keep a heap bounded to size k rather than sorting everything
  • O(n log k), better than O(n log n) when k ≪ n
  • Return the k most frequent elements
  • Break ties in any consistent way

💡 Hint: A min-heap of size k lets you drop the smallest whenever it overflows, so only the top k survive.

Show solution
import heapq
from collections import Counter

def top_k_frequent(nums, k):           # O(n log k)
    counts = Counter(nums)
    return [val for val, _ in heapq.nlargest(k, counts.items(),
                                             key=lambda kv: kv[1])]

print(top_k_frequent([1, 1, 1, 2, 2, 3], 2))   # [1, 2]

A size-k heap keeps only the current top-k, so memory is O(k) and each of n pushes costs log k — cheaper than sorting all counts when k is small.

Exercise 4 · Binary search on the answerExpert

Context: "Binary search on the answer" (ship-within-D-days, classic problem) is a powerful pattern: search a value space, not an array, when feasibility is monotonic.

Your task: Find the minimum ship capacity to deliver package weights within D days by binary-searching the capacity, in O(n log(sum)).

Requirements:

  • Recognise the answer is monotonic: bigger capacity is never worse
  • Binary-search the capacity between max-weight and total-weight
  • A feasibility check counts days needed for a candidate capacity
  • O(n log(sum))
  • Return the smallest feasible capacity

💡 Hint: Write a can_ship(capacity) predicate first; then binary-search for the smallest capacity where it flips from false to true.

Show solution
def ship_within_days(weights, D):
    def days_needed(cap):              # greedy fill under a fixed capacity
        days, load = 1, 0
        for w in weights:
            if load + w > cap:
                days += 1; load = 0
            load += w
        return days
    lo, hi = max(weights), sum(weights)   # feasible capacity range
    while lo < hi:
        mid = (lo + hi) // 2
        if days_needed(mid) <= D:
            hi = mid                   # feasible -> try smaller
        else:
            lo = mid + 1               # infeasible -> need bigger
    return lo

print(ship_within_days([1,2,3,4,5,6,7,8,9,10], 5))   # 15

The tell: "minimize a value subject to a monotone feasibility check". Binary-search the value and use a greedy feasibility test as the predicate.

Exercise 5 · Backtracking: combination sumProfessional

Context: Combination-sum (classic) is the backtracking template with reuse and pruning — a common medium that tests recursion discipline.

Your task: Given distinct candidates and a target, return all unique combinations that sum to the target where each number may be reused, using backtracking with pruning.

Requirements:

  • Reuse a candidate by recursing without advancing past it
  • Prune a branch once the running sum exceeds the target
  • Advance the start index to avoid duplicate combinations
  • Collect a combination when the remaining target hits zero
  • Return all unique combinations

💡 Hint: Pass a start index into the recursion so you only ever look forward; subtracting the chosen value from the target makes the base case a clean zero.

Show solution
def combination_sum(candidates, target):
    candidates.sort()
    result = []
    def backtrack(start, remaining, path):
        if remaining == 0:
            result.append(path[:]); return
        for i in range(start, len(candidates)):
            if candidates[i] > remaining:
                break                  # sorted -> no later candidate fits either
            path.append(candidates[i])
            backtrack(i, remaining - candidates[i], path)  # i (reuse allowed)
            path.pop()
    backtrack(0, target, [])
    return result

print(combination_sum([2, 3, 6, 7], 7))   # [[2, 2, 3], [7]]

Passing i (not i+1) allows reuse; the sorted break prunes whole branches once the remainder is exceeded.

Exercise 6 · DP: coin change (fewest coins)Industry scenario

Context: Coin-change minimum-coins (classic) is a top-frequency onsite DP and the cleanest introduction to bottom-up tabulation.

Your task: Given coin denominations and an amount, return the minimum number of coins that make it (or -1) using bottom-up DP, in O(amount·coins).

Requirements:

  • Build a table of fewest coins for every value up to the amount
  • Each value takes the best over all coins ≤ it, plus one
  • Unreachable amounts return -1
  • O(amount·coins)
  • The answer is the table entry at the full amount

💡 Hint: Initialise the table to infinity, set dp[0]=0, and relax dp[v] = min(dp[v], dp[v−coin]+1) for each coin.

Show solution
def coin_change(coins, amount):        # O(amount * len(coins))
    INF = amount + 1
    dp = [0] + [INF] * amount          # dp[x] = fewest coins to make x
    for x in range(1, amount + 1):
        for c in coins:
            if c <= x:
                dp[x] = min(dp[x], dp[x - c] + 1)
    return dp[amount] if dp[amount] != INF else -1

print(coin_change([1, 2, 5], 11))      # 3  (5 + 5 + 1)
print(coin_change([2], 3))             # -1

Each amount's answer is built from strictly smaller amounts already solved — the hallmark of a DP recurrence. Greedy (largest coin first) is wrong for arbitrary denominations, which is why DP is required.

Knowledge check check yourself

✓ Knowledge check

Which problem signal tells you to reach for a heap-based Top-K pattern, and what complexity does it give?

Show answer
When you need the k largest/smallest (or k most frequent) elements from a stream or large set, keep a size-k heap. Each element is a O(log k) push/pop, giving O(n log k) overall instead of fully sorting at O(n log n).
✓ Knowledge check

What is “binary search on the answer,” and when does it apply?

Show answer
Instead of searching an array, you binary-search over the range of possible answer values, using a feasibility check to decide which half to keep. It applies when the answer is monotonic — a candidate that works implies all larger (or smaller) ones do too.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in