AI EngineeringZero to ProductionHome·About·What’s new·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.
📋 Before you startThis page builds on: Complexity & arrays · Trees & heaps. Jumping in? Skim those first — or open the recap.
30-second recap
  • Complexity & arrays: Big-O describes how cost grows with input size; arrays give O(1) index access but O(n) inserts in the middle.
  • Trees & heaps: Trees store hierarchical or sorted data with O(log n) operations when balanced; a heap always gives you the smallest (or largest) item fast.
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

Picture the moment the interview goes sideways. You're asked for the longest stretch of a string with no repeated letter. You write the honest answer: for every start position, try every end position, check the slice. It's correct, you're relieved — and then the interviewer says "now make it faster." You freeze, because you're staring at two nested loops and don't see the exit. That frozen feeling is the exact problem this pattern was invented to dissolve. The nested loops were re-examining the same characters over and over; the sliding window refuses to.

When to reach for it: the instant a problem talks about a contiguous run — a subarray or substring, never a scattered subset — and asks for the best one under some rule. That word "contiguous" is the tell in disguise; scattered picks are backtracking or DP, but an unbroken run means a window. The everyday analogy is looking through a train window at the countryside: you don't teleport around, you see a continuous band of scenery that shifts as the train moves. Your window is that band over the array — it has a left edge and a right edge, and it only ever slides forward.

The one idea that makes it O(n): instead of rebuilding each window from scratch, you reuse the previous one. Push the right edge forward to let a new element in; when that breaks your rule (a duplicate appears, the sum grows too big), drag the left edge forward to kick elements out until the rule holds again. Crucially, each element is admitted exactly once and evicted at most once — so even though there are two moving pointers, the total travel is bounded by 2n, which is just O(n). The two loops you feared collapse into a single sweep.

The common mistake is treating the inner shrink as a second nested loop and panicking that it's still O(n²). It isn't: left never moves backward and can't overtake right, so across the whole run it advances a total of n steps no matter how the shrinks clump together. The second trap is a variable-size window that never shrinks — if your while condition is wrong, the window only grows and you silently get the whole array as your answer. Watch for two flavors: a fixed window (width k is constant, so you slide in lockstep — add one, drop one) and a variable window (width flexes to satisfy a constraint). Naming which flavor you're in before you type is half the battle. 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

Here's a habit worth breaking early. Given a sorted array and asked to find two numbers that add to a target, most people's reflex is a nested loop: pick each number, then scan the rest for its partner. It works, it's O(n²), and it throws away the single most valuable fact you were handed — that the array is sorted. Sorted order is a gift, and the two-pointer pattern is how you unwrap it. Once you internalize this, a whole family of "find the pair/triplet" problems stops being a search and becomes a steering problem.

The converging-pointers analogy: imagine tuning an old radio dial with two hands. One hand sits at the smallest value, one at the largest, and you're hunting for a target frequency. If the current sum is too high, you can only bring it down by moving the top hand inward toward smaller numbers; too low, you move the bottom hand inward toward bigger ones. Every move is informed — the sortedness tells you unambiguously which pointer to nudge — so you never waste a comparison. The two hands march toward each other and the whole scan is one linear pass, O(n), after the sort.

The second, very different cousin lives on linked lists: fast and slow pointers. Here the two pointers don't converge from the ends — they start together and move at different speeds, one hopping one node at a time, the other two. Picture two runners on a circular track: if the track loops, the faster runner inevitably laps and collides with the slower one; if the track is a straight dead-end, the fast runner simply reaches the finish first. That single idea detects a cycle (they meet), finds the exact middle (when fast hits the end, slow is halfway), and locates the nth-from-last node — all in O(1) extra space, no hash set of visited nodes required.

The mistake that costs interviews is reaching for two pointers on an unsorted array for a pair-sum problem — the technique is only valid because moving a pointer monotonically changes the sum, which requires order. If the array isn't sorted and you can't afford to sort it, a hash map is your tool instead. The other classic slip is duplicate handling: once you find a valid pair or triplet, you must skip over equal neighbours or you'll emit the same answer several times (you'll see that guard in the 3Sum code below). 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

A story from a real system, because this pattern hides inside the code this course builds. Your RAG retriever scores a query against a million document chunks and needs the best 5. The naive move is to sort all million scores and slice off the top 5 — and it works, but you just paid O(n log n) to rank 999,995 chunks you're going to throw away. That waste is the itch the Top-K pattern scratches. You don't need the whole ordering; you need a tiny leaderboard that only remembers the current champions and cheaply forgets everyone else.

When to reach for it: any question phrased as "the K largest / smallest / most frequent / closest," especially when k is much smaller than n or the data arrives as a stream you can't fully hold. That "K best out of many" shape is the tell. The everyday analogy is a talent show with only three trophies: you don't rank every contestant end-to-end, you just keep three trophies on the table, and each new act only matters if it beats your current weakest trophy-holder — who then goes home.

The counter-intuitive twist that trips everyone: to keep the K largest items, you use a min-heap, not a max-heap. It feels backwards until you see why — the heap's root is always its smallest element, and the smallest of your current winners is precisely the one on the bubble, the first to be evicted the moment something bigger shows up. So the min-heap puts your most-vulnerable champion right where you can check and drop it in O(1). Each of the n items costs an O(log k) push (and maybe a pop), giving O(n log k) overall — and because you only ever hold k items, it's memory-friendly and streaming-friendly in a way that a full sort never is.

The common mistake is grabbing a max-heap "because we want the biggest," which forces you to push all n items and pop k — that's O(n + k log n) and, worse, holds the entire dataset in memory. For a bounded stream the size-k min-heap is the disciplined choice. In Python this is the heapq module, and here's its own quirk to remember: it isn't a Heap class, it's a set of functions (heappush, heappop, nlargest) that operate on a plain list you maintain, with the minimum always sitting at index 0. 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

This is the pattern that makes people feel like they've been let in on a secret. You learned binary search as "find a value in a sorted array" — halve the range, keep the half that could contain your target. Fine. But then a problem shows up that has no array to search: "what's the slowest speed Koko can eat bananas and still finish in time?" There's no sorted list of speeds handed to you. The reflex is to try every speed from 1 upward, which is a linear grind. The leap — and it genuinely feels like a magic trick the first time — is realizing you can binary-search the space of possible answers itself.

When to reach for it: the tell is a phrase like "minimum value such that…" or "maximum value such that…" attached to something you can test. If you can look at a candidate answer x and cheaply decide yes-this-works or no-too-small, and the yes/no verdict flips exactly once as x increases, you're in business. That "flips exactly once" property has a name — monotonic — and it's the whole ballgame. The analogy is a dimmer switch wired to a light sensor: turn the knob up and at some precise point the room crosses from "too dark" to "bright enough," and it never flickers back. You're not scanning brightness levels one by one; you're bisecting the knob to pounce on that threshold.

How to actually build it: write a helper feasible(x) that returns True/False, decide the low and high bounds of the answer range, and binary-search for the boundary where feasibility switches on. Each guess halves the candidate range, so if the answers span a range R and one feasibility check costs C, the total is O(log R · C) — logarithmic in the size of the answer space, not linear. A problem that looked like brute force over thousands of speeds becomes a dozen probes.

The mistake that quietly breaks it: applying this when feasibility isn't monotonic. If a candidate can be "good, then bad, then good again" as x grows, bisection is meaningless — you'll happily discard the half that held the answer. Always convince yourself of the monotonic property first ("if speed x finishes in time, any faster speed obviously does too"). The other perennial bug is the loop's boundary logic: off-by-one errors in whether you keep mid or move past it are what turn a correct idea into an infinite loop. Nail down "am I searching for the leftmost True or the rightmost?" before you write the lo/hi updates. 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

Everyone has fought this in a calendar app. You've got a pile of meetings — some back-to-back, some overlapping, some with gaps — and you want to know your true "busy blocks" for the day. Do it by eye and you'll try to compare every meeting against every other meeting, an O(n²) tangle that gets worse as the day fills up. The merge-intervals pattern is the clean way out, and its power comes almost entirely from one preparatory move that feels too simple to matter: sort the intervals by their start time first.

Why that sort changes everything: once the ranges are lined up by start, any two intervals that overlap are guaranteed to be adjacent in the list. That means you never have to look backward or compare distant pairs — you just walk the sorted line once, holding the interval you're currently building, and check each newcomer against only that one. The analogy is packing a moving truck by loading boxes in the order they arrive at the door: because they're pre-ordered, you only ever decide whether the next box stacks onto the current pile or starts a new one. Sort plus one sweep is O(n log n), dominated by the sort.

When to reach for it: the tell is anything built from start/end pairs — "merge overlapping ranges," "can this person attend all meetings," "insert a new interval and re-merge," "how many rooms do we need." The moment you see ranges on a timeline, your first instinct should be "sort by start, then sweep." The merge rule itself is tiny: two intervals overlap when the next one's start is at or before the current one's end, and you merge them by stretching the end to the larger of the two ends.

The mistakes to guard against are subtle but common. First, sorting by the wrong key — sort by start, not end, or the adjacency guarantee evaporates and you'll miss merges. Second, the boundary case of touching intervals like [1,3] and [3,5]: decide up front whether a shared endpoint counts as overlapping (usually yes, so use <= not <) and be consistent. Third, mutating the input list in place and then comparing against your own half-built output — copy the first interval before you start extending it, as the code below does with a deliberate slice. 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

The unlock here is a change of eyes, not a change of algorithm. A 2-D grid of cells looks like a table, so people attack grid problems with clever double-loop bookkeeping and quickly drown in edge cases. The realization that makes these problems easy is that a grid is just a graph wearing a disguise: every cell is a node, and each cell has an edge to its up/down/left/right neighbours. The instant you see it that way, "count the islands" stops being a puzzle about maps and becomes the textbook graph problem "count the connected components" — which you already know how to solve with the search you learned in D5.

When to reach for it: the tell is a 2-D grid plus a verb like "count regions," "flood fill," "how many groups," or "shortest path through a maze." If cells clump into connected blobs and you care about the blobs, that's connected components (use DFS or BFS to flood each one). If instead you care about the fewest steps from A to B, that's shortest path on an unweighted graph — and that specific need is what forces the choice between the two searches.

DFS vs BFS — the choice actually matters, and here's the intuition. DFS is a single explorer who charges as far down one corridor as possible before backtracking; it's perfect for "just flood this whole region and mark it seen," and it's a two-line recursion. BFS is a ripple spreading outward in rings from a starting point, one full ring per step — a dropped stone in a pond. Because BFS reaches everything at distance 1 before anything at distance 2, the first time it touches the goal it has found the shortest path. So the rule of thumb is: counting or filling → DFS is simplest; shortest path or "how many minutes until everything is infected" → BFS. Either way every cell is visited once, so the cost is O(rows·cols).

The mistakes are almost always mechanical, not conceptual. The big three: forgetting to mark cells visited (so the search revisits and either loops forever or double-counts — the fix is to flip the cell or keep a seen-set); letting neighbour coordinates run off the edge of the grid (every neighbour check needs a bounds guard); and reaching for DFS recursion on a huge grid, where Python's recursion limit can blow the stack — a large flood fill is safer done iteratively with an explicit stack or a BFS queue. Also resist counting diagonals as connected unless the problem says so; "island" problems are almost always 4-directional. 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

Tree problems feel scary until you notice they nearly all rhyme. Candidates freeze because a tree looks like it demands custom cleverness for every question — depth, diameter, path sums, validation, common ancestors — but underneath, the overwhelming majority are the same move: recurse to the children, then combine what they report back. The trick isn't inventing a new traversal each time; it's deciding what one piece of information each node should return to its parent. Get that contract right and the recursion writes itself.

The mental model — think like a manager delegating. A node doesn't compute its answer alone; it asks its left subtree and its right subtree to each report a summary ("how deep are you?", "did you contain the node I'm looking for?"), then merges those two reports into its own and passes it upward. This is "bubbling up": information flows from the leaves back toward the root. Depth is 1 + max(left, right). Diameter is the best path that bends through a node, left_depth + right_depth. "Does this subtree contain both targets?" is a boolean report — and the first node that hears "yes" from both sides is the lowest common ancestor. Same shape, different summary.

When to reach for BFS instead: the one big fork is whether the problem cares about levels. "Print the tree level by level," "find the rightmost node on each row," "what's the minimum depth" — anything that groups nodes by their distance from the root wants breadth-first traversal with a queue, processing one full row before the next. Everything else — paths, depths, sums, validation, ancestors — is depth-first recursion returning info up. That single question, "do I need level order or not?", picks your traversal before you write a line.

The mistakes worth pre-empting: forgetting the base case (a None child must return a sensible neutral value like depth 0 or "not found," or the recursion crashes on the leaves); and conflating "the deepest path through a node" with "the deepest path starting at a node" — diameter needs both children's depths added, but the value you return to your parent is only the longer single branch, because a parent can't walk down both your sides at once. That subtle "return one thing, but track another for the global answer" split is exactly what LCA and diameter both hinge on. 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

Think about how you actually solve a maze on paper. You walk down a corridor, hit a dead end, and — this is the key part — you don't start over from the entrance. You rub out your last few steps and try the other fork. That erase-and-retry instinct is the entire soul of backtracking. It's how you explore an enormous space of possibilities without getting lost: make a choice, follow it as far as it goes, and if it doesn't pan out, undo exactly that one choice and try the next. Every "generate all combinations" problem is a maze, and this is the map-drawing technique.

When to reach for it: the tell is the word "all" — "generate all subsets," "find all valid arrangements," "list every way to…". You're not optimizing to a single number (that's often DP or greedy); you're enumerating a combinatorial space, usually with constraints that let you abandon dead branches early (N-queens, Sudoku, word search, generate-parentheses). If the answer is a collection of configurations rather than one value, backtracking is almost always the frame.

The skeleton is three lines you will reuse forever: choose → recurse → un-choose. Add an element to your running path (choose), recurse to build on top of it (explore everything that choice leads to), then remove it (un-choose) so the next iteration starts from a clean slate. That final undo is what lets a single shared path list explore an exponential tree — you're reusing one piece of scratch paper for millions of branches by carefully erasing between them. The shape of the space is a decision tree with up to O(2ⁿ) or O(n!) leaves, and that's inherent: you're producing exponentially many answers, so the work is exponential no matter how clean the code.

The two mistakes that bite hardest. First, forgetting the un-choose (the path.pop()): the shared list never rewinds, so each branch pollutes the next and your output turns to nonsense — comment it out once and watch it break, because seeing that failure cements the lesson. Second, appending the path itself to your results instead of a copy of it: since you keep mutating that same list, every stored "answer" ends up pointing at the same object, which is empty by the end — always snapshot with path[:]. The optimization layer on top, pruning, is where backtracking earns its keep: abandon a branch the instant it can't possibly lead to a valid answer, and an intractable search becomes fast. 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

This is the round people fear, and the fear is mostly a trick of the light. DP has a reputation as arcane wizardry, but almost every DP problem starts life as a simple, obvious recursion that happens to be catastrophically slow because it re-solves the same subproblem thousands of times. The canonical eye-opener is naive Fibonacci: fib(50) recomputes fib(10) millions of times over. Dynamic programming is nothing more than the fix your common sense already suggests — write the answer down the first time you compute it, and look it up ever after. That's it. The mystique is just the name.

The everyday analogy: filling in a crossword or a spreadsheet. Each cell's value depends on a few earlier cells, so you can't compute the whole thing at once — but you can fill the easy cells first (the base cases), then work outward, each new cell built from ones already solved. That's a DP table. A cell answers one subproblem ("the best value using the first i items with capacity w"), and every cell is computed exactly once and reused forever after. Exponential recursion collapses into a polynomial-sized grid you fill in order.

When to reach for it: two tells stacked on top of each other. The phrasing is "count the number of ways," "minimum/maximum cost or steps," or "can you reach / partition / make change" — you're optimizing or counting over a sequence of choices. And structurally, the problem has overlapping subproblems (the same smaller question recurs) plus optimal substructure (the best overall answer is built from best answers to those smaller questions). If choices are independent and you never revisit a subproblem, it's plain recursion or greedy; the repetition is what invites DP. Your job is to name three things: the state (what defines a subproblem), the recurrence (how a state's answer combines smaller states' answers), and the base case.

Two ways to implement, one common trap. Top-down memoization keeps your natural recursion and just caches results (add @lru_cache or a dict) — easiest to write, closest to how you reasoned. Bottom-up tabulation fills an array from the base cases outward — no recursion limit, often faster, sometimes more memory. Both give the same complexity; pick whichever you can code cleanly under pressure. The mistake that sinks candidates isn't the code, it's defining the state wrong — choosing state variables that don't fully capture a subproblem, so different situations collide in the same cell and the answer is silently corrupt. Spend your first minutes pinning down "what exactly does dp[i][w] mean?" before writing the recurrence; a precise state definition is 80% of the solution. 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

These last two feel like cheats the first time they click. Both take a problem that screams "nested loop, O(n²)" and quietly flatten it to a single O(n) pass — and both are so common that seasoned interviewers use them as litmus tests for whether you've drilled patterns or are still solving from scratch. What unites them is a shared instinct: keep a little bit of history around so you never have to look backward twice.

The monotonic stack answers "for each element, what's the next bigger (or smaller) one to its right?" — daily temperatures, next-greater-element, largest-rectangle-in-histogram, trapping rain water. The tell is "next greater/smaller" or "how far until something taller." The idea: you keep a stack of elements that are still waiting for their answer, deliberately kept in sorted order (say, decreasing). When a new element arrives that breaks the order, it is the answer for everything it's bigger than, so you pop those off and resolve them. Picture people queued on a hill looking rightward for someone taller — the moment a giant walks up, everyone shorter who was waiting gets their answer at once and steps aside. Each element is pushed and popped exactly once, so the whole thing is O(n) despite the inner while loop — the same amortized argument as the sliding window.

Prefix sum + hashmap answers "how many contiguous subarrays sum to exactly K?" — subarray-sum-equals-K, continuous-subarray-sum, count-nice-subarrays. The tell is "contiguous subarray with a target sum/count." The insight is that the sum of the slice between two positions equals the difference of their running totals (prefix sums), so "find a subarray summing to K" becomes "find two prefix sums that differ by K" — and finding a needed complement is exactly what a hash map does in O(1). It's the Two Sum trick applied to running totals. The mistake here is a classic: forgetting to seed the map with prefix 0 → count 1, which represents the empty prefix and is what lets a subarray starting at index 0 be counted; leave it out and you'll undercount every answer that begins at the front. Both tools appear constantly and are easy to name once seen:

two O(n) tricks that keep just enough history to avoid a nested loop monotonic stack · "next greater" 73 74 75 71 76 ← new stack waits (temps decreasing): [71] 76 > 71 → pop, resolve 71's answer each index pushed & popped once → O(n) prefix sum + hashmap · sum = K 1 1 1 1 prefix sums: 1 · 2 · 3 · 4 at sum=3, look up 3−K in the map seen before → that gap sums to K one dict lookup per element → O(n) shared instinct: remember a little history, never look backward twice stack of pending answers map of prefix sums seen Both flatten an O(n²) scan to one O(n) pass. The monotonic stack parks elements still waiting for their "next greater" and resolves them in bulk when it arrives; prefix-sum + hashmap turns "subarray summing to K" into a single complement lookup.
🗺️ How to read this diagram

Two independent tricks sit side by side, split by the dashed line — the monotonic stack on the left, prefix-sum + hashmap on the right. Read each half top-to-bottom.

  • Left (monotonic stack): the boxes are daily temperatures. The stack holds indices still waiting for a warmer day, kept decreasing. When a bigger value (76) arrives, it is the "next greater" for everything smaller waiting below it — so those get popped and resolved at once (the blue arrow).
  • Right (prefix sum + hashmap): the boxes are the array; below them are the running totals (prefix sums). A subarray sums to K exactly when two prefix sums differ by K — so at each step you look up current − K in a map of sums seen so far (the red arrow), an O(1) check.
  • The two bottom bars name the "little history" each keeps: a stack of pending answers versus a map of prefix sums seen. That stored history is what removes the inner loop.

In short: Both patterns keep just enough of the past around — a stack, or a dict of running sums — so a problem that looks like a nested loop collapses into a single O(n) sweep.

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

Linked lists are where confident coders suddenly get clumsy, and it's worth understanding why. With an array you can glance at any index and see the whole picture; with a linked list you're feeling your way along a chain in the dark, holding one node and a couple of pointers, and a single mis-ordered assignment either loses the rest of the list or ties it in a knot. Interviewers love this precisely because it tests whether you can manipulate pointers carefully and in the right order — a skill that array problems never exercise. The good news: almost every linked-list question is a remix of three small techniques.

The three tools, and the intuition behind each. A dummy head is a throwaway node you glue in front of the real list so that "insert/delete at the front" stops being a special case — the head becomes just another middle node, and a whole category of null-pointer bugs vanishes. Think of it as a placeholder chair at the head of a table so there's always a "seat before the first guest." Pointer-flip reversal walks the list turning each next arrow around to point backward; the ritual is "save the next node before you overwrite the arrow, or you'll strand the rest of the list." And fast/slow pointers (from §2) find the middle or detect a cycle in one pass with O(1) space.

When to reach for this pattern: the tell is blunt — the input is a linked list and you're asked to reverse it, merge two, reorder it, remove the nth-from-end, or find a node, ideally without allocating extra space. The O(1)-space constraint is often the whole point of the question; a naive solution that copies values into an array is correct but sidesteps exactly what's being tested. The dominant mistake, beyond null-pointer edge cases at head and tail, is overwriting a pointer before you've saved what it pointed to — the instant you do cur.next = prev without first stashing cur.next, the tail of your list is gone. Draw the three or four nodes on paper and move the arrows by hand before you type; that ten-second sketch prevents nearly every bug. This is such a big topic it has its own page — D8 · Linked list patterns — but here are the interview must-knows with diagrams. 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.

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