DSA_PATTERN_LAB

DSA Pattern Lab

Active-recall study notes, one pattern set at a time. Solutions are folded shut — attempt on paper first, then reveal. Complexity claims are folded separately — re-derive before you peek. Your journal and practice log live in this page and persist in this browser.

18 SETS · 27/27 PATTERNS · ATLAS COMPLETE STUDY ORDER: SET 00 → BASELINE → SET 01 → 02 … 17 LLD LAB → HLD LAB →

§◇ · YOUR DESK

Today

understood
solved
re-solves due
journal answered
cards to clear

Due for re-solve

jump to the practice ladder → · drill the flashcards →

§00 · THE FULL MAP — FAANG COVERAGE

Pattern atlas — every pattern, one table

~20 patterns cover 85–90% of what Meta, Google, Amazon and Apple actually ask, and the marginal return curve is brutal: the first 15 are worth more than the next 30. This table is the whole territory — each pattern named by the redundancy it kills, with the Set of this lab that will cover it. Learn top to bottom.

S = daily bread, learn first  ·  A = separates offers from rejections  ·  B = strong-hire / senior depth (Google-weighted)

TierPatternRedundancy it kills / question shapeAnchor problemsSet
SArrays & hashingO(1) membership & counts kill "have I seen X" re-scansTwo Sum · Group Anagrams · Top K Frequentbaseline ✓
STwo pointersSortedness/symmetry prunes the all-pairs space3Sum · Container With Most Water · Trapping Rain Water02 ✓
SSliding windowRe-scanning shared elements of consecutive subarraysLC 3 · LC 209 · LC 100401 ✓
SPrefix sum + hashmapRe-adding overlapping range sumsLC 303 · LC 560 · LC 52501 ✓
SStack & monotonic stackRe-scanning backward for nearest greater/smallerValid Parentheses · Daily Temperatures · Largest Rectangle03 ✓
SBinary search — index & answer spaceLinear scan when a predicate is monotonicRotated Array · Koko Eating Bananas · Median of Two Sorted04 ✓
SLinked list surgeryPointer discipline: dummy head, reversal, fast/slowReverse List · Cycle Detection · Reorder List05 ✓
STrees — DFS · BFS · BSTVisiting nodes twice; BST: inorder = sortedLCA · Level Order · Validate BST · Kth Smallest06 ✓
SHeap / top-KFully sorting when you only need the extremesKth Largest · Merge K Lists · Find Median (two heaps)07 ✓
SIntervalsPairwise overlap checks → sort once, sweep onceMerge Intervals · Meeting Rooms II · Non-overlapping08 ✓
SGraphs — BFS / DFSRevisiting states — the visited set IS the patternNumber of Islands · Clone Graph · Rotting Oranges09 ✓
SStrings residuePalindromic substrings via centers; parsing where spec discipline IS the testLongest Palindromic Substring · atoi · Calculator II · Text Justification16 ✓
AMatrix movesIndex identities + in-place state — transform without the O(mn) copyRotate Image · Spiral Matrix · Set Zeroes · Search 2D II17 ✓
ATopological sortDependency order on a DAG; cycle = impossibleCourse Schedule I/II · Alien Dictionary09 ✓
ABacktrackingEnumerate choices, undo, pruneSubsets · Combination Sum · Word Search · N-Queens10 ✓
AUnion-FindIncremental connectivity without re-traversalConnected Components · Redundant Connection · Accounts Merge11 ✓
ATrieRe-comparing shared prefixesImplement Trie · Word Search II · Add & Search Words11 ✓
ADP I — 1D · grid · knapsackRe-solving overlapping subproblemsClimbing Stairs · Coin Change · Unique Paths · Partition Subset12 ✓
AGreedy + exchange argumentDP when a local choice is provably safeJump Game · Gas Station · Task Scheduler08 ✓
ADesign data structuresCompose hashmap + list + heap to hit per-op contractsLRU Cache · Insert/Delete/GetRandom O(1) · Time-Based KV14 ✓
BDP II — subsequencesCharacter-level re-comparison across subsequence spaceLCS · LIS · Edit Distance · Longest Palindromic Substring13 ✓
BDP III — state machinesRecomputing per-day holding statesBuy/Sell Stock family · House Robber II13 ✓
BDijkstra / weighted pathsBFS assumes unit weights — heap orders the frontierNetwork Delay Time · Cheapest Flights · Min Effort Path09 ✓
BMonotonic dequeRe-scanning the window for its maxSliding Window Maximum15 ✓
BQuickselect / divide & conquerFull sort for a single order statisticKth Largest in O(n) avg · Sort Colors15 ✓
BBit manipulationXOR / mask identities replace bookkeepingSingle Number I–III · Counting Bits · Reverse Bits15 ✓
BMath & samplingClosed forms and invariants beat simulationPow(x, n) · Random Pick with Weight · Reservoir Sampling15 ✓
⚠ Deliberately skip — the trap list

Segment / Fenwick trees, KMP / Z-algorithm, red-black internals, network flow, computational geometry, bitmask DP, Morris traversal — under 2% of FAANG loops; every hour there is an hour not spent on the tiers above. Know that segment trees exist ("range queries with updates — where prefix sums break") and move on.

Company skew

Meta: Tier S dominance, lowest DP weight. Google: graphs, DP II, design, and problems where the pattern must be discovered — the §0 workflow is the Google skill. Amazon: BFS/grids, top-K, intervals, design. Apple/Netflix: near Meta's distribution, more practical.

§★ · A FRESH PROBLEM WALKS IN

The decision tree — route first, then verify

The atlas lists the patterns; this routes you to one. Ask these questions in this order — the first hit wins, and more-specific beats more-general. After routing, verify the chosen set's hidden requirement (every theory section names one) before writing a line.

1. About a contiguous subarray/substring? — exact sum / count / divisible → prefix + hashmap (Set 01) · longest/shortest with a monotone validity → sliding window (Set 01) · max/min of every k-window → monotonic deque (Set 15)
2. Pairs / triples / partitioning decided by values? — sorted or sortable → two pointers (Set 02) · need original indices back → hashmap (Baseline)
3. "Nearest greater/smaller", spans, histograms, matched pairs? → stack / monotonic stack (Set 03)
4. Sorted input — or a yes/no answer that flips once ("smallest X such that…", "minimize the max")? → binary search (Set 04)
5. ListNode in the signature? → linked-list toolkit (Set 05) — cycle or middle → fast/slow
6. Tree / hierarchy? → structural recursion or BFS (Set 06) — and if it says BST, use the sorted order
7. Stream of data, "k largest/closest", merge k sources, running median? → heap (Set 07)
8. Time ranges, scheduling, "max concurrent", "min removals"? → intervals + greedy with an exchange proof (Set 08)
9. Explicit relations, grids of regions, dependencies, weighted routes? → graphs (Set 09) — connectivity that only grows → union-find (Set 11) · dictionary + prefixes → trie (Set 11)
10. "Return ALL …" (subsets, arrangements, boards)? → backtracking (Set 10) — but "count ways / best value" with overlapping subproblems → DP (Sets 12–13)
11. "Implement a class where every op is O(1)"? → design composition (Set 14)
12. Exact pairing structure, powers, uniform sampling? → bits & math (Set 15)
13. Palindromic SUBSTRING? → expand around 2n−1 centers (Set 16) · messy spec with no visible trick (atoi, calculators, justification)? → parsing discipline (Set 16) — carefulness IS the test
14. A matrix to transform in place, walk in a special order, or update simultaneously? → index identities + boundary pointers + bit-packed state (Set 17) · sorted by rows AND columns → staircase from a corner
⚠ Routing is a hypothesis, not a verdict

Every pattern has a hidden requirement that breaks it (negatives break sum-windows, duplicates break rotated binary search, cycles break tree DFS). The route tells you which section to open; the section's warn-box tells you whether you may stay.

§★ · THE OTHER HALF OF THE INTERVIEW

Speaking the solution

Interviewers grade the narration as much as the code. This is the script the whole lab has been feeding in fragments, consolidated. Practice it out loud on every ladder problem.

  1. Restate + clarify — repeat the problem in one sentence, then ask the questions that change the design: duplicates allowed? negatives? sorted? may I mutate the input? ties — how broken? what's returned for empty input?
  2. Read the constraints out loud — "n up to 10⁵, so I need O(n log n) or better" (§F.5). The setter is telling you the intended pattern.
  3. Brute force first, in words — state it, state its complexity, and name the redundant work it repeats. This is the master framework (§0) performed live; it is also what earns you partial credit if time runs out.
  4. Route to a pattern — say the signal that triggered it ("count subarrays with exact sum — that's prefix + hashmap") and check the hidden requirement out loud.
  5. State the invariant before coding — one sentence ("after the while-loop, [left..right] is the largest valid window ending at right"). Then every line you write has a justification.
  6. Narrate decisions, not syntax — say "check before insert so an element can't pair with itself", not "now I write a for loop".
  7. Test like a ritual — trace the smallest non-trivial input by hand, then the edges: empty, single element, all-duplicates, negatives, the exact boundary (k = 0, capacity 1). Then run the relevant set's trap checklist.
  8. Close with complexity, precisely — the adjectives are the senior signal: average for hash ops, amortized for stacks/queues/resizes, expected for quickselect, pseudo-polynomial for value-bounded DP, output-sensitive when the answer itself is big.
What's actually being graded

Recognition speed (steps 3–4), invariant discipline (step 5), edge-case instinct (step 7), complexity fluency (steps 2 and 8), and whether you drive the conversation or wait to be rescued. The code itself is maybe a third of the score.

§F.1 · SET 00 — FOUNDATIONS · READ THIS SET FIRST

Big-O from zero

Before any pattern in this lab makes sense, you need one skill: looking at code and predicting how much slower it gets as the input grows. That is the whole of Big-O. It is not advanced math — it is counting, plus one honest observation about growth.

Start with a picture you already know. Two ways to find a name in a phone book: flip page by page, or open the middle and throw away the wrong half each time. For a 20-page book, both feel instant. For a million pages, page-by-page takes days and halving takes about 20 flips. Same task, wildly different growth — and Big-O is just the label we put on each behaviour: page-by-page grows like n (we write O(n)); halving grows like log n (we write O(log n)).

What we actually do: pick the operation the code repeats (a comparison, an addition, a visit) and count how many times it runs as a formula in n, the input size. We deliberately ignore two things: how fast the computer is, and small constant factors — because neither changes the shape of the growth, and the shape is what decides whether your solution survives a big input.

input size n → work → log n n n log n
The shapes, not the numbers, are what matter. Near the origin everything looks harmless; the shape decides who survives when n grows.

One anchor number makes all of this concrete: a computer runs very roughly 100 million to 1 billion simple steps per second. So a step-count converts straight into seconds:

Growthn = 1,000n = 100,000Verdict at 10⁵
O(log n)~10 steps~17 stepsinstant
O(n)10³10⁵instant
O(n log n)10⁴1.7·10⁶fast
O(n²)10⁶10¹⁰ ≈ 100 secondsrejected
O(2ⁿ)a 302-digit numberonly sane for n ≤ ~20

The four counting rules. Every complexity claim in this lab is built from just these:

  1. Steps in sequence add. Two separate loops over n = 2n steps. We still call it O(n), because 2n and n have the same shape — doubling a computer’s speed also halves the constant, and neither rescues a bad shape. That is why dropping constants is honest, not sloppy.
  2. A loop inside a loop multiplies. For each of n items, scan all n items → n × n = n². This is where most accidental slowness comes from.
  3. Halving means log. If each step cuts the remaining problem in half, 1,000,000 shrinks to 1 in about 20 steps. Whenever you spot halving, say “log”. (Set 04 is built entirely on this rule.)
  4. “Amortized” means: average it over the whole run. Sometimes one step is expensive but the total across all steps is provably small — so the average per step is small. The tell that this is happening: something that only ever moves in one direction. It sounds abstract now; §2.2 makes it concrete, and you will then meet the same argument seven more times.

Space is counted the same way — but for memory, and only the extra memory beyond the input itself: two index variables = O(1); a copy of the array = O(n); a recursion n levels deep = O(n) of hidden stack memory (next section shows why).

§F.2 · SET 00 — FOUNDATIONS

Recursion — what the computer actually does

A recursive function is simply a function that calls itself. The definition is never the hard part. The hard part is that beginners try to hold every in-flight call in their head at once — and that feels like vertigo. The fix is to see the simple machinery underneath, then learn the professional trick for never needing to trace it at all.

The machinery: a pile of sticky notes. Every function call gets its own sticky note (the technical name is a stack frame) holding its own copies of the parameters and local variables. Calling a function puts a new note on top of the pile. Returning throws the top note away, and the note underneath resumes exactly where it paused. That’s all recursion is: the same rule, applied to a function calling itself.

def fact(n):
    if n == 0:          # base case — the floor that stops the descent
        return 1
    return n * fact(n - 1)
newest call on top fact(0) → returns 1 fact(1) waiting… fact(2) waiting… fact(3) waiting… calls push upward returns pop downward 1 1·1 = 1 2·1 = 2 3·2 = 6
fact(3), frozen at the moment the base case fires. Three calls wait below it. Returns then pop top-down, and each waiting frame resumes with the answer it was waiting for.
the same picture as text:
  fact(3) waits for fact(2)·        stack: [fact(3)]
    fact(2) waits for fact(1)·      stack: [fact(3), fact(2)]
      fact(1) waits for fact(0)·    stack: [fact(3), fact(2), fact(1)]
        fact(0) returns 1           — the stack now unwinds —
      fact(1) resumes: 1·1 = 1
    fact(2) resumes: 2·1 = 2
  fact(3) resumes: 3·2 = 6

The professional trick: never trace big inputs. Instead, do three small checks:

  1. Write one sentence saying what the function promises: “fact(n) returns n!”.
  2. Check the smallest case keeps the promise: fact(0) = 1 ✓.
  3. Check the general step keeps the promise assuming the smaller call already does: if fact(n−1) really is (n−1)!, then n · fact(n−1) = n! ✓.

That assumption in step 3 feels like cheating; it isn’t — it is mathematical induction, and it is exactly how the pros stay calm. Set 06 will name this habit “trust the recursion”, and interviewers actively watch for it: tracing into a recursive call signals doubt; stating the promise and checking the step signals command.

⚠ The two real costs of recursion

(1) Depth is memory. Every unfinished call is a sticky note still on the pile — recurse 100,000 deep and Python quits at its ~1,000-note limit. Any recursion can be rewritten with an explicit stack; this caveat returns in Sets 05, 06, 09 and 11. (2) Repeats are time. A naive fib(n) recomputes the same sub-answers exponentially many times — noticing that repetition is the entire premise of Set 12.

§F.3 · SET 00 — FOUNDATIONS

Hash tables — the library-shelf trick

Half the lab says “use a hashmap, O(1) average”. Here is what those words actually mean, because you will be asked.

The problem it solves: finding one book in an unsorted pile means checking every book — O(n) per search. A library fixes this without sorting anything: it computes each book’s shelf from its title. To find a book you redo that small computation and walk straight to the right shelf. No scanning.

A hash table is exactly that library. The hash function turns your key into a shelf number; the shelf is called a bucket. Two keys can land on the same shelf — a collision — and that is fine: the shelf just holds a short list you check quickly. When shelves get crowded, the table quietly rebuilds itself bigger (a resize) — occasionally expensive, cheap on average, which is rule 4 of §F.1 (amortized) making its first real appearance.

"apple" "banana" "cherry" hash(key)→ slot number 0 1 2apple · cherry 3 4 5banana
Lookup jumps straight to a computed slot. Bucket 2 shows a collision: two keys share the shelf, which then holds a short list — still fast, as long as collisions stay rare.

Why every bound says “average”: a truly unlucky (or malicious) set of keys could all land on one shelf — then that shelf IS the unsorted pile again, and lookups degrade to O(n). Real inputs almost never do this, but the precise claim — the one to say in interviews — is “O(1) average, O(n) worst case”.

Why keys must be immutable: the shelf was chosen from the key’s value at insert time. If you could change the key afterwards, the book would be filed under a shelf its new title no longer points to — lost forever. That is why Python accepts strings, numbers and tuples as keys, and refuses lists (you will hit this personally in §B.3).

Operationlistset / dict
x in cO(n) — scans everythingO(1) average
add / appendO(1) amortizedO(1) average
remove by valueO(n)O(1) average

That first row is the most common beginner performance bug in existence: if x in my_list inside a loop is a hidden n×n (rule 2 of §F.1) — the code is correct, passes small tests, and times out at scale. Building a set first fixes it with one line.

§F.4 · SET 00 — FOUNDATIONS

The Python this lab is written in

The lab’s code uses a handful of Python moves that read like magic until someone slows them down. Here they are, slowed down.

1. Tuple assignment — the move the linked-list set lives on. The rule is simple: Python finishes reading the ENTIRE right-hand side before it changes anything on the left. So this famous one-liner:

cur.next, prev, cur = prev, cur, cur.next
# is exactly this, with the temp written out:
old_next = cur.next     # the right side was captured FIRST
cur.next = prev
prev = cur
cur = old_next

Nothing is lost, because everything on the right was safely read before anything on the left changed. In Java or JavaScript you must write that temp yourself — and writing the three statements in the wrong order there is the classic lost-list bug (§L.2 shows the wreckage).

2. Two names, one list — the aliasing trap. Assignment in Python copies the arrow, never the object. b = a gives you two names for one list; change it through either name and both “see” the change, because there is only one list to see. A real copy needs a[:].

a b [1, 2, 3] b = a → two names, ONE list a c [1, 2, 3] [1, 2, 3] c = a[:] → a separate list
Left: assignment copies the arrow — mutations through either name show through both. Right: a slice builds a genuinely new list. This is why backtracking collects path[:], never path (§K.7’s #1 bug).

3. The rest of the vocabulary — skim now, return whenever a code block uses one:

ToolWhat it doesWhere the lab leans on it
enumerate(xs)walk index and value togethernearly every loop
zip(a, b)walk two sequences in parallelcomparing adjacent words (§GR.6)
d.get(k, 0)look up with a default — no crash if missingevery counting map
d.setdefault(k, [])“get it, or insert this then get it”grouping (§B.3), tries (§U.5)
float('inf')a value bigger than every number“best so far” seeds (Set 12)
7 // 2 → 3floor division; int(a/b) truncates toward zero insteadthe calculator trap (§SR.5)
dequeO(1) at BOTH ends; list.pop(0) is O(n)BFS queues (Set 09)
heapqa min-heap; negate values to fake a max-heapall of Set 07
bisect_leftthe “lower bound” of §BS.3, prebuiltLIS (§X.3), weighted pick (§O.5)
is vs ==same OBJECT vs equal VALUEFloyd (§L.5), LCA (§TR.5)
⚠ One more famous trap, while we’re here

[[0] * n] * m does NOT build a fresh m×n grid — the outer * copies the arrow m times (trap 2 above!), so all m “rows” are one shared row. Write [[0] * n for _ in range(m)]. You will meet this again, with consequences, in Set 17.

§F.5 · SET 00 — FOUNDATIONS

Reading constraints — the setter is talking to you

Every problem statement ends with limits like “n ≤ 100,000”. Beginners skim past them. Don’t — the limits quietly tell you which solution the author expects, and reading them takes ten seconds.

The arithmetic behind it, once: your time budget is roughly 10⁸ steps (§F.1’s anchor number). An O(n²) idea at n = 100,000 costs 10¹⁰ steps — about 100 seconds — automatic rejection. So the largest allowed n filters the curves of §F.1’s chart down to the ones that fit the budget, and the biggest surviving shape is almost always the intended one:

If the limit says……the setter expectswhich usually means
n ≤ 20O(2ⁿ) is finetry everything — backtracking (Set 10); exponential is EXPECTED here
n ≤ 500O(n³)interval DP, all-triples work
n ≤ 5,000O(n²)quadratic DP, all-pairs work
n ≤ 10⁵–10⁶O(n log n) or O(n)sort + sweep, heap, window, one-pass patterns
values up to 10⁹never loop over VALUES — loop over items, or binary-search the answer (Set 04); §D.1 names this trap “pseudo-polynomial”

Two more messages hiding in constraints:

  • A tiny n combined with “return ALL…” means the answer itself is huge (2ⁿ subsets, n! orderings) — no algorithm can beat the size of its own output, so exponential is not a failure there, it’s the job (§K.1).
  • A guarantee like “timestamps are strictly increasing” is a gift: it is sortedness you didn’t have to pay for, and the intended solution exploits it (§V.4 is built on exactly this).

Habit to build from day one: read the constraints before thinking about approaches, and say the budget out loud — “n is 10⁵, so I need O(n log n) or better.” It steers you toward the right pattern and it is, verbatim, step 2 of the interview script (§guide-talk).

§0 · READ THIS FIRST

The master framework

Every optimization pattern exists to kill a specific redundancy in the brute force. The workflow for any problem:

  1. Write the brute force (in comments, not code)
  2. State its complexity
  3. Name the redundant work it repeats
  4. Pick the tool that eliminates exactly that redundancy

For this pair of patterns:

Brute force redundancyTool
Re-adding the same elements for overlapping range sumsPrefix sum
Re-scanning shared elements of consecutive subarraysSliding window
Scope check

Both operate on contiguous subarrays/substrings only. If the problem says "subsequence" or allows picking arbitrary elements → neither applies (usually DP or greedy instead).

In plain English — the same idea, slowernew here? start with this

Every fast algorithm starts life as a slow one. The slow version is slow because it repeats some piece of work — like re-counting a whole jar of coins from scratch every time one coin is added. This entire lab is a catalog: “here is a kind of repeated work, and here is the tool built to avoid exactly that repeat.”

So the workflow above isn’t ceremony. Writing the dumb version first forces you to SEE the repeated work; naming it tells you which tool to reach for. Skip those steps and you’re pattern-matching on vibes.

The last warning matters too: these first two tools only work on contiguous stretches — elements sitting next to each other. The moment a problem lets you skip elements (“subsequence”), you’re in different territory (Sets 10–13).

§B.1 · BASELINE — ARRAYS & HASHING

Theory

Redundancy it kills: answering "have I seen X?" by re-scanning costs O(n) per question. Ask it n times — once per element — and you have the O(n²) hiding inside most brute forces in this family. A hash table answers the same question in O(1) average, buying speed with O(n) space.

Mechanics you must be able to say out loud: a hash function maps each key to a bucket; resizing keeps the load factor bounded, so inserts are amortized O(1); collisions are why every bound says average — adversarial keys degrade a bucket to O(n). Same honest footnote as LC 560's map in §1.4 — in interviews, "O(1) average" is precision, not hedging.

The three payloads. Everything in this set is one data structure with three choices of value:

  • set → pure existence ("seen it?")
  • dict value = count → frequency
  • dict value = index / list → location or grouping

The question that picks the payload is the same one that picked the map value in prefix-sum Types C/D/E: "what do I need to know about earlier elements?" That answer is your value type.

Key requirement

Keys must be hashable (immutable — tuples and strings, never lists), and hashing answers exact equality only. A hashmap cannot serve range, nearest, or inequality queries in O(1) — the same limit that broke "sum ≤ k" in §3.1. If the question involves order or proximity, sort first or reach for a different structure.

⚠ When sorting beats hashing

Sort when you need O(1) extra space, when the output must be ordered anyway, or when you want cache-friendly scans over pointer-chasing buckets. Hash when one pass + O(n) space is acceptable and only equality matters. Being able to argue this trade-off, unprompted, is a senior signal.

In plain English — the same idea, slowernew here? start with this

A hash map is a magic notebook: ask it “have I seen X before?” and it answers instantly, instead of you flipping through every page. That single ability — instant lookup — is what kills the slow inner loop in most easy array problems.

There are only three ways to use the notebook, and they’re the three payloads above: remember just the names (a set), keep tally marks next to each name (counts), or note the page where you first saw it (index). When you meet a new problem, ask: “as I walk the array, what do I wish I could instantly know about the elements behind me?” The answer to that question is literally what you store.

The notebook’s limit: it only answers exact questions. “Have I seen exactly 7?” — instant. “Have I seen anything close to 7?” or “anything bigger than 7?” — it has no idea, because it scattered the entries on purpose (see §F.3’s shelf picture). ‘Close’ and ‘bigger’ questions need sorted order instead.

§B.2 · TYPE A

Complement lookup

LC 1 · Two Sum Return the indices of the two numbers that add up to target.

Easy-tier, included on purpose: it is the archetype — reportedly the most-asked interview question in existence — and its one-pass shape is the seed of half this set.

Brute force: every pair → O(n²).

Redundancy: for a fixed x, the inner loop re-scans for target − x among elements the outer loop already walked past.

Insight: walk once, storing value → index. Before inserting x, ask whether its complement is already in the map. Check before insert — the same ordering discipline as LC 560 — or target = 2x lets an element pair with itself.

Check yourself: nums = [3, 3], target = 6 — what returns, and which line makes it legal?

[0, 1]. At i = 1 the complement 3 is already in the map from i = 0. Check-before-insert means an element never sees itself — but it does see an earlier equal value.

Solutionattempt it on paper first
def twoSum(nums, target):
    seen = {}                          # value -> index
    for i, x in enumerate(nums):
        if target - x in seen:         # check FIRST — self-pair guard
            return [seen[target - x], i]
        seen[x] = i
Complexity derivationre-derive it first

One loop, n iterations, one O(1)-average lookup and insert each → O(n) time, O(n) space. vs O(n²) brute: with n = 10⁵, ~10⁵ ops against 10¹⁰.

§B.3 · TYPE B

Canonical-key grouping

LC 49 · Group Anagrams Group the words that are anagrams of each other.

Brute force: pairwise anagram checks → O(n²·k) for n words of length k.

Redundancy: group membership is a property of each string alone; comparing pairs recomputes that property endlessly.

Insight: map every item to a canonical form — chosen so that equal canonical form ⟺ same group — and bucket by it. Two candidate keys here: the sorted string (O(k log k) per word) or a 26-slot count tuple (O(k) per word).

Solutionattempt it on paper first
def groupAnagrams(strs):
    groups = {}
    for s in strs:
        key = [0] * 26                     # canonical form: char counts
        for ch in s:
            key[ord(ch) - 97] += 1
        groups.setdefault(tuple(key), []).append(s)
    return list(groups.values())

The key must be a tuple — a list is unhashable. Forgetting this is the classic first-attempt crash.

Complexity derivationre-derive it first

Count key: n words × O(k) counting → O(n·k) time, O(n·k) space. Sorted key: O(n·k log k). The count key is asymptotically better but assumes a small fixed alphabet — with full Unicode, use a count dict or fall back to the sorted key.

The generalization to journal

Grouping = choosing a canonical key. LC 249 (shifted strings) is the same problem with key = consecutive character differences mod 26 — where the negative-modulo language trap from §1.5 returns in JS. When a grouping problem appears, the whole task is designing the key.

§B.4 · TYPE C

Frequency + buckets

LC 347 · Top K Frequent Elements Return the k most frequent elements.

Brute force: count, then sort pairs by count → O(n log n).

Redundancy: a full ordering when only the top k matters.

The escalation ladder (know all three rungs): sort O(n log n) → size-k heap O(n log k) → buckets O(n). The bucket insight: a frequency is an integer in [1..n] — a bounded integer domain is bucket sort's invitation. Index an array by the frequency itself.

Solutionattempt it on paper first
def topKFrequent(nums, k):
    freq = {}
    for x in nums:
        freq[x] = freq.get(x, 0) + 1
    buckets = [[] for _ in range(len(nums) + 1)]   # index = frequency
    for x, c in freq.items():
        buckets[c].append(x)
    res = []
    for c in range(len(nums), 0, -1):              # harvest high to low
        for x in buckets[c]:
            res.append(x)
            if len(res) == k:
                return res
Complexity derivationre-derive it first

Counting: n. Building buckets: one op per distinct value ≤ n. Harvest: visits each bucket once and each element at most once → n + 1 + n. Total O(n) time, O(n) space. (Quickselect on the (value, count) pairs is the other O(n)-average route — mention it, code the buckets.)

⚠ Stdlib trap

Counter.most_common() sorts under the hood — using it while claiming O(n) forfeits the point being graded. Know what your standard library does before citing complexity through it.

§B.5 · TYPE D

Sequence starts from a set

LC 128 · Longest Consecutive Sequence Length of the longest run of consecutive integer values; the follow-up demands O(n).

Brute force: for each x, scan the array for x+1, then x+2, … → O(n²) and worse. Sorting gives O(n log n) — correct, but the problem dares you to beat it.

Insight: pour everything into a set, then only begin counting from sequence starts — elements x where x − 1 is absent. Every walk then covers its sequence exactly once.

Solutionattempt it on paper first
def longestConsecutive(nums):
    s = set(nums)
    best = 0
    for x in s:
        if x - 1 not in s:            # only sequence STARTS start a walk
            streak = 1
            while x + streak in s:
                streak += 1
            best = max(best, streak)
    return best
Complexity derivationre-derive it first

Charging argument: each element is examined at most twice — once by its own start-check, once inside the single walk that belongs to its sequence's start. ≤ 2n set lookups → O(n) average time, O(n) space. Same "one-direction / bounded total" tell as the window analysis in §2.2 — the amortized argument keeps returning.

⚠ The quiet quadratic

Drop the start-check and the code stays correct but becomes O(n²) on input like [1, 2, 3, …, n] — every element launches a full walk. It passes small tests and dies at scale; interviewers plant exactly this. A complexity bug is still a bug.

§B.6 · TYPE E

Running products

LC 238 · Product of Array Except Self answer[i] = product of everything except nums[i]. No division. O(n).

Brute force: per-index product of the others → O(n²).

Redundancy: neighboring answers share almost all their factors — overlapping products, the multiplicative sibling of Set 01's overlapping range sums.

Insight: answer[i] = (product left of i) × (product right of i). One prefix sweep, one suffix sweep, both accumulated into the output array → O(1) extra space.

Why division is banned: one zero makes total ÷ nums[i] meaningless everywhere except at the zero; two zeros zero out everything. The sweeps handle both cases with no case analysis at all.

Solutionattempt it on paper first
def productExceptSelf(nums):
    n = len(nums)
    res = [1] * n
    prefix = 1
    for i in range(n):                 # res[i] = product of nums[0..i-1]
        res[i] = prefix
        prefix *= nums[i]
    suffix = 1
    for i in range(n - 1, -1, -1):     # fold in product of nums[i+1..]
        res[i] *= suffix
        suffix *= nums[i]
    return res
Complexity derivationre-derive it first

Two passes of constant work → O(n) time; the output array doesn't count as extra, so O(1) auxiliary space.

Tie-back

The same reframe as §1.1: a "range aggregate per position" became a difference — here, a product — of directional sweeps. Prefix thinking is not only for sums.

§B.7 · BEFORE YOU SUBMIT + INTERROGATION

Traps, signals & interrogation

Signals → pattern (added to the drill deck in §3.2)

"have I seen X / does my complement exist"  → hashset or map, check BEFORE insert
"group items by a shared property"          → canonical key → map of lists
"top k frequent"                            → counts + buckets (freq ≤ n) or size-k heap
"longest run of consecutive VALUES"         → set + expand only from starts
"product/sum of everything except me"       → prefix sweep × suffix sweep

Interrogation — answer in your journal

B1

Why must Two Sum check before inserting? Construct the exact input where the swapped order returns an illegal answer.

Hint → answer sketchjournal first

Hint: What if the complement IS the element?

Sketch: Insert-first on nums = [3], target = 6: the lookup finds 3 — itself — and returns [0, 0]. Check-before-insert guarantees only strictly earlier elements are visible; same ordering as LC 560.

B2

Derive the complexity of both Group-Anagrams keys (sorted string vs count tuple). Name a realistic input regime where the sorted key is the better engineering choice.

Hint → answer sketchjournal first

Hint: Cost per word: sorting vs counting.

Sketch: Sorted key: O(k log k) per word. Count key: O(k) plus a 26-slot tuple. With Unicode alphabets the fixed array breaks — use a count dict or accept the sorted key; for tiny k the log is negligible and the sorted key is harder to get wrong.

B3

Prove LC 128 is O(n) with a charging argument. What is the "tell" this proof shares with the sliding-window analysis in §2.2?

Hint → answer sketchjournal first

Hint: Charge each element for at most two touches.

Sketch: Each element is checked once as a potential start, and walked over at most once inside exactly one walk (its sequence's). Total ≤ 2n set operations. Same tell as §2.2: bounded TOTAL work despite a nested while.

B4

Trace LC 238's two sweeps on [1, 0, 4] and on [0, 3, 0]. Then explain precisely why the divide-by-total shortcut fails each case differently.

Hint → answer sketchjournal first

Hint: Where does each sweep put the zero?

Sketch: [1,0,4] → [0,4,0]: only the zero's slot keeps the product of the others. [0,3,0] → all zeros. The division hack fails each case differently: one zero forces case analysis around total = 0; two zeros make even that impossible.

B5

Name two query shapes a hashmap cannot serve in O(1), and the structure you would reach for instead of each.

Hint → answer sketchjournal first

Hint: Which questions need ORDER among keys?

Sketch: Range counts ('how many keys in [a,b]') → sorted array + bisect, or a BIT. Nearest key to x → sorted structure / BST. Hashing scatters keys on purpose — it destroys exactly the order those queries need.

§1.1 · PART 1 — PREFIX SUM

Theory

Definition: prefix[i] = sum of the first i elements, with prefix[0] = 0 (the "empty prefix").

nums   =      [3,  1,  4,  2,  5,  1]
prefix = [0,  3,  4,  8,  10, 15, 16]

Build: prefix[i+1] = prefix[i] + nums[i] — one pass.

The core identity (this is the entire pattern):

sum(l..r) = prefix[r+1] − prefix[l]

Why it works: prefix[r+1] contains everything up to r; prefix[l] contains exactly the unwanted part before l. Subtracting cancels the unwanted part. A range question becomes a difference of two point values. Every prefix-sum problem is this reframing wearing a costume.

Key property

Makes no assumption about the numbers. Negatives, zeros, anything — the identity is pure arithmetic. This is why prefix sum survives where sliding window breaks (see §3.1).

In plain English — the same idea, slowernew here? start with this

Think of highway milestones. Each milestone shows the total distance from the start of the road. Want the distance between town A and town B? You do NOT re-drive the road with a measuring wheel — you subtract: milestone(B) − milestone(A). Done.

01234567prefix[l] — the unwanted startprefix[r+1] — everything up to rbig milestone − small milestone = sum(l..r)
Highway milestones: subtract the milestone at l from the one past r and the stretch between falls out — no re-measuring.

prefix[i] is exactly that milestone: “the total of the first i numbers”. Build all milestones once with one pass, and from then on the sum of ANY middle stretch is one subtraction. That’s the entire pattern — every fancy variant in this set is this subtraction wearing a costume.

And note what it does NOT care about: negative numbers, zeros, weird values — subtraction works regardless. Remember that; it’s the reason this tool survives where the sliding window (next section) breaks.

§1.2 · TYPE A

Range queries on an immutable array

LC 303 · Range Sum Query (Immutable) Given an integer array, answer many queries of the form sumRange(l, r).

Brute force: loop l..r per query → O(n) per query, O(n·q) total for q queries.

Redundancy: queries (2,4) and (2,5) share almost all their additions.

Solutionattempt it on paper first
class NumArray:
    def __init__(self, nums):
        self.prefix = [0]
        for x in nums:
            self.prefix.append(self.prefix[-1] + x)

    def sumRange(self, l, r):
        return self.prefix[r + 1] - self.prefix[l]
Complexity derivationre-derive it first
  • Build: the loop body (one addition, one append) runs exactly n times → O(n) time, O(n) space for the prefix array.
  • Each query: one subtraction, one lookup → O(1).
  • Total: O(n + q) vs brute force O(n·q). With n = q = 10⁵: ~2·10⁵ operations vs 10¹⁰. That gap is the pattern's entire justification.

§1.3 · TYPE B

Left part vs right part (pivot)

LC 724 · Find Pivot Index Find the index where the sum of elements to the left equals the sum to the right.
Insight

You don't even need the full prefix array — carry a running leftSum, and rightSum = total − leftSum − nums[i].

Solutionattempt it on paper first
def pivotIndex(nums):
    total = sum(nums)
    left_sum = 0
    for i, x in enumerate(nums):
        if left_sum == total - left_sum - x:
            return i
        left_sum += x
    return -1
Complexity derivationre-derive it first

sum() is one pass (n ops), the loop is one pass (constant work per iteration) → n + n = 2n → O(n) time, O(1) space.

(General rule: drop constants and lower-order terms — 2n and n are both O(n) because Big-O describes the growth rate, not the count.)

§1.4 · TYPE C · THE WORKHORSE

Count subarrays with sum exactly k (prefix + hashmap)

This is the workhorse of prefix-sum mediums. Understand it deeply.

LC 560 · Subarray Sum Equals K Count subarrays whose elements sum to exactly k. Numbers may be negative.

Derivation — never memorize this, re-derive it

Subarray (i..j) has sum k
prefix[j+1] − prefix[i] = k
prefix[i] = prefix[j+1] − k

So walk left to right and ask at every position: "how many EARLIER prefixes equal my current prefix minus k?" A hashmap {prefix value → times seen} answers in O(1). No window, no grow/shrink decision — just pair counting.

Solutionattempt it on paper first
def subarraySum(nums, k):
    count, prefix = 0, 0
    seen = {0: 1}                              # the empty prefix
    for x in nums:
        prefix += x
        count += seen.get(prefix - k, 0)       # check FIRST
        seen[prefix] = seen.get(prefix, 0) + 1 # THEN insert
    return count

Worked trace — step through it

nums = [3, 4, −7, 1, 3], k = 4. Prefixes: 0, 3, 7, 0, 1, 4. Predict each step before advancing.

LC 560 · k = 4step 0 / 5
prefix P0
looking for P−k
found
count0

Answer: 3 → subarrays [4], [1,3], and the whole array.

Three load-bearing details

  1. Why {0: 1}? It's the prefix before any element. Without it, no subarray starting at index 0 can ever pair up. In the trace, dropping it changes the answer from 3 to 2 (the whole-array match vanishes).
  2. Why counts, not booleans/indices? Negatives make the prefix dip back to values it already visited (the −7 drags it back to 0). Step 5 finds prefix 0 twice — both matches are real subarrays. A boolean would lose one.
  3. Why check BEFORE inserting? Swap the lines and run with k = 0: every position finds itself and counts a zero-length subarray. The ordering enforces "strictly earlier prefixes only" (the i < j+1 in the math). Note this bug only appears for one specific k — a passing test case would have fooled you.
Complexity derivationre-derive it first

One loop, n iterations. Per iteration: one addition, one hashmap get, one hashmap set — hashmap operations are O(1) averageO(n) time. Space: the map holds at most n+1 distinct prefix values → O(n).

(Honest footnote: hashmap O(1) is average-case, worst case O(n) under adversarial collisions. For interviews, say "O(n) average" and you're being precise, not pedantic.)

§1.5 · TYPE D

Sum divisible by k (store remainders)

LC 974 · Subarray Sums Divisible by K Count subarrays whose sum is divisible by k.

Derivation: sum(i..j) divisible by k ⟺ prefix[j+1] % k == prefix[i] % k (equal remainders subtract to a multiple of k). So store remainder counts instead of prefix counts; every pair of equal remainders is one valid subarray.

Solutionattempt it on paper first
def subarraysDivByK(nums, k):
    count, prefix = 0, 0
    seen = {0: 1}
    for x in nums:
        prefix += x
        r = prefix % k          # Python: always non-negative
        count += seen.get(r, 0)
        seen[r] = seen.get(r, 0) + 1
    return count
⚠ Language trap

Python's % returns non-negative for negative operands. JavaScript/Java return negative (-5 % 3-2). In JS, normalize:

const r = ((prefix % k) + k) % k;

The naive version passes in Python and fails in JS with negative inputs — a "works in one language" bug worth breaking on purpose once.

Complexity derivationre-derive it first

Same shape as 560 → O(n) time. Space: the map holds at most k distinct remainders → O(min(n, k)) — note how the thing being stored changed the space bound.

§1.6 · TYPE E

LONGEST subarray with sum k (store earliest index)

LC 325 · Maximum Size Subarray Sum Equals k Return the length of the longest subarray summing to k.

What changes vs 560: the question changed from count to longest, so what you store changes from counts to the earliest index of each prefix value — and you never overwrite it (an earlier index always gives a longer subarray).

Solutionattempt it on paper first
def maxSubArrayLen(nums, k):
    first = {0: -1}      # prefix 0 occurs "before index 0"
    prefix, best = 0, 0
    for i, x in enumerate(nums):
        prefix += x
        if prefix - k in first:
            best = max(best, i - first[prefix - k])
        if prefix not in first:      # keep only the EARLIEST
            first[prefix] = i
    return best
Complexity derivationre-derive it first

One pass, O(1) work per element → O(n) time, O(n) space.

The generalization to journal

The pairing logic prefix[i] = P − k never changes across Types C/D/E. Only the payload stored per prefix changes: count → remainder count → earliest index. When you meet a new variant, ask "what do I need to know about earlier prefixes?" — that answer is your map value.

§1.7 · TYPE F

Transform then prefix (disguised problems)

LC 525 · Contiguous Array Longest subarray with equal numbers of 0s and 1s.
Insight

Map every 0 → −1. Now "equal 0s and 1s" becomes "sum = 0", which is exactly Type E with k = 0. Many prefix problems are one transform away from a known type — always ask "can I re-map the values so this becomes sum-based?"

Complexity derivationre-derive it first

The transform is free (do it inline), so O(n) time, O(n) space — identical to Type E.

§2.1 · PART 2 — SLIDING WINDOW

Theory

Redundancy it kills: consecutive subarrays share almost all elements; brute force re-evaluates each from scratch (O(n²) or O(n³)).

The fix: maintain ONE window [left..right] with running state (a sum, a frequency map, a distinct-count). Extend right by adding one element's contribution; shrink left by removing one element's contribution. Never recompute.

The variable-window skeleton (memorize the shape, derive the rest):

left = 0
for right in range(n):
    # 1. add nums[right] into window state
    while window_is_invalid():
        # 2. remove nums[left] from state
        left += 1
    # 3. window [left..right] is now the largest valid window
    #    ending at right → record answer here
The invariant — every line exists to keep this true

After the while-loop, [left..right] is the largest valid window ending at right.

⚠ The hidden requirement — monotonicity

Sliding window only works when growing the window pushes validity in one predictable direction (e.g., all-positive numbers: growing only increases the sum), so shrinking from the left is guaranteed to move back toward validity. Negative numbers break sum-based windows: growing might decrease the sum, shrinking might increase it — the window can't know which way to move. That's when you fall back to prefix + hashmap (Part 1), which never assumed monotonicity.

In plain English — the same idea, slowernew here? start with this

Picture a caterpillar crawling along the array. Its head moves forward and eats one new element; its tail moves forward and releases one old element. Everything you know about the caterpillar’s body — its sum, which letters it contains — you update by that one eaten or released item. You never re-scan the whole body.

the window [left..right]right: eat one newleft: release one
A caterpillar on the array: the head eats one element, the tail releases one — the body’s facts update by ±1 item, never recounted.

The template above is just the caterpillar written in code: the for-loop is the head, the while-loop is the tail catching up when the body becomes “invalid” (too big a sum, a duplicate letter…).

The fine print — the hidden requirement — in plain words: this only works when growing the body pushes things in ONE predictable direction. All-positive numbers: eating always increases the sum, so if the sum is too big, releasing from the tail reliably shrinks it. Throw in a negative number and eating might shrink the sum — the caterpillar no longer knows which end to move, and its answers go quietly wrong. That’s when you abandon it and use milestones (prefix + hashmap) instead.

§2.2 · THE AMORTIZED ARGUMENT

Why sliding window is O(n)

This looks like a nested loop, so why isn't it O(n²)?

Wrong way to analyze: "outer loop n times × inner loop up to n times = O(n²)." This overcounts because the inner loop's work is shared across iterations, not repeated per iteration.

Right way — count total pointer movements across the ENTIRE run:

  • right only moves forward: exactly n steps total.
  • left only moves forward and never passes right: at most n steps total summed over all iterations of the while-loop combined.
  • Total work ≤ 2n pointer movements, each with O(1) state updates → O(n).

Equivalent framing (charging argument): every element enters the window exactly once and leaves at most once. Charge each element 2 units of work. n elements × 2 = O(n).

Name it

This is amortized analysis: some individual iterations are expensive (the while-loop runs many times), but the total across all iterations is bounded. The tell that you need it: a loop variable that only moves in one direction. You'll reuse this exact argument for monotonic stacks later.

In plain English — the same idea, slowernew here? start with this

It looks like a loop inside a loop, so instinct says n². Here’s why instinct is wrong: watch the two runners instead of the laps.

‘right’ runs the track exactly once — n steps, ever. ‘left’ also only ever runs forward and can never overtake ‘right’ — so across the ENTIRE run, left takes at most n steps in total, even if on some turns it sprints five steps at once. Add both runners: at most 2n steps for the whole show.

That’s all “amortized” means: bill the whole show, not each scene. Some scenes are expensive; the total is provably cheap. And the tell that this billing trick applies is always the same: a variable that only ever moves in one direction. You’ll spot that tell in seven more places across this lab — it’s the single most reused argument here.

§2.3 · TYPE A

Fixed-size window

LC 643 · Maximum Average Subarray I Find the contiguous subarray of length k with the maximum average.

Brute force: sum every length-k window from scratch → O(n·k).

Redundancy: window starting at i+1 shares k−1 elements with window at i.

Fix: add the entering element, subtract the leaving one — O(1) per slide.

Solutionattempt it on paper first
def findMaxAverage(nums, k):
    window = sum(nums[:k])          # first window: k additions
    best = window
    for i in range(k, len(nums)):
        window += nums[i] - nums[i - k]   # enter right, leave left
        best = max(best, window)
    return best / k
Complexity derivationre-derive it first

First window costs k; the loop runs n−k times at O(1) each → k + (n−k) = O(n) time, O(1) space.

§2.4 · TYPE B

Variable window, LONGEST valid

LC 3 · Longest Substring Without Repeating Characters Find the length of the longest substring without repeating characters.

Brute force: check every substring for duplicates → O(n³) (n² substrings × n scan). Even with a set per start index: O(n²).

Redundancy: on finding a duplicate, brute force restarts; the window instead slides past the earlier occurrence and keeps everything else.

Solutionattempt it on paper first
def lengthOfLongestSubstring(s):
    freq = {}
    left = best = 0
    for right, ch in enumerate(s):
        freq[ch] = freq.get(ch, 0) + 1
        while freq[ch] > 1:              # invalid: a duplicate exists
            freq[s[left]] -= 1
            left += 1
        best = max(best, right - left + 1)
    return best

Note where the answer is recorded: after the while-loop, when the window is valid — for "longest" problems, record outside the shrink loop.

Complexity derivationre-derive it first

Amortized argument from §2.2 → O(n) time. Space: the map holds at most one entry per distinct character → O(min(n, |charset|)) — for lowercase ASCII that's O(26) = O(1), a nuance worth saying out loud in interviews.

§2.5 · TYPE C

Variable window, SHORTEST valid

LC 209 · Minimum Size Subarray Sum Shortest contiguous subarray with sum ≥ target. All numbers positive.

What flips vs Type B: for "longest", you shrink while INVALID and record after. For "shortest", you shrink while VALID (trying to make it smaller) and record inside the shrink loop:

Solutionattempt it on paper first
def minSubArrayLen(target, nums):
    left, window = 0, 0
    best = float('inf')
    for right, x in enumerate(nums):
        window += x
        while window >= target:                 # valid → try to shrink
            best = min(best, right - left + 1)  # record INSIDE
            window -= nums[left]
            left += 1
    return best if best != float('inf') else 0
⚠ Why this silently assumes positives

The shrink step reasons "the window satisfies sum ≥ target; removing elements can only decrease the sum, so once it drops below target, no shorter window ending here is valid." With a negative in the array, removing an element could increase the sum — a shorter valid window could exist beyond where we stopped shrinking, and the algorithm never finds it. Breaking input: nums = [5, −10, 6], target = 6 — the window logic mishandles what pure pair-logic would not. (This is interrogation question 4 — trace it by hand.)

Complexity derivationre-derive it first

Same amortized argument → O(n) time, O(1) space.

§2.6 · TYPE D

Window with a budget

LC 1004 · Max Consecutive Ones III Longest run of 1s if you may flip at most k zeros.
Reframe first — this is the skill

"Longest subarray containing at most k zeros." Now it's Type B with the state = count of zeros in the window.

Solutionattempt it on paper first
def longestOnes(nums, k):
    left = zeros = best = 0
    for right, x in enumerate(nums):
        zeros += (x == 0)
        while zeros > k:                # over budget → shrink
            zeros -= (nums[left] == 0)
            left += 1
        best = max(best, right - left + 1)
    return best
Complexity derivationre-derive it first

O(n) time, O(1) space. Same skeleton, different state — which is the point: sliding window is ~1 template + a per-problem definition of "state" and "invalid".

Same family (do after): LC 424 (state = window length − count of most frequent char), LC 904 (at most 2 distinct), LC 76 (must contain all chars of t — the hard one; state = how many required chars are satisfied).

§3.1 · PART 3 — DECISION GUIDE

Window vs prefix — the decision test

AskIf yes →
Longest/shortest contiguous X with a monotonic constraint (positives-only sums, frequency/distinct-count conditions)?Sliding window
Count subarrays with sum EXACTLY k / divisible by k?Prefix + hashmap
Negatives allowed AND the constraint is about sums?Prefix + hashmap
Many range queries, immutable array?Prefix sum
Fixed window size k given?Fixed sliding window
Compressed

Longest/shortest + monotonic → window. Count-exact / negatives / range-queries → prefix.

⚠ Limit of the hashmap too

It works because exact equality is hashable. "Count subarrays with sum ≤ k, negatives allowed" breaks it — you can't look up an inequality in a hashmap. When the question's shape changes, re-check the tool's assumptions instead of forcing the match.

§3.2 · SIGNAL → PATTERN

Signal drill — flashcards

These lines seed your cheat sheet. Drill them: read the signal, say the pattern out loud, then flip. Missed cards come back at the end of the pass.

signal → pattern— left
signal

§3.3 · GENERAL METHOD

How to calculate complexity

  1. Count the dominant operation as a function of n — loop iterations × work per iteration. Nested independent loops multiply (n × n = n²).
  2. BUT check for shared/amortized work first: if an inner loop's variable only moves in one direction across the whole run (sliding window's left, a stack that pops each element once), sum its TOTAL work over all iterations instead of multiplying. That's how a "nested" loop can be O(n).
  3. Hashmap get/set = O(1) average. Say "average" — it's the precise claim.
  4. Drop constants and lower-order terms: 2n → O(n); n + k → O(n + k) unless one dominates; k + (n−k) → O(n).
  5. Space = extra memory beyond the input: prefix array O(n); a window's char-frequency map O(min(n, charset)); two pointers O(1).
  6. Sanity-check against constraints: n ≤ 10⁵ → you need O(n) or O(n log n); an O(n²) idea (10¹⁰ ops) will TLE. Reading constraints FIRST often tells you which pattern is expected — that's a legitimate recognition signal, use it.

§3.4 · BEFORE YOU SUBMIT

Common bugs checklist

Run through this before every submit. Ticks are per-visit, on purpose — they reset on reload.

§T.1 · SET 02 — TWO POINTERS

Theory

Redundancy it kills: examining all O(n²) pairs when structure — sortedness, geometry, an exchange argument — lets you prove that whole blocks of pairs cannot contain the answer. Each pointer move permanently discards candidates. The loop is trivial; the discard proof is the pattern.

The rule — every move needs this sentence

Never move a pointer without being able to say: "no answer we still need lives in what I just discarded." If you can't state that sentence, two pointers is the wrong tool — hash it, sort it, or window it instead.

Three archetypes:

  • Converging — both ends move inward: pair search, partitioning, geometric arguments (this set).
  • Reader / writer — same direction, in-place compaction and partitioning (Type D).
  • Fast / slow — cycle detection and middles; arrives with linked lists in Set 05.

Complexity: each pointer moves in one direction only, ≤ 2n total moves with O(1) work each → O(n) after any sort. The same one-direction tell from §2.2 — third time it has appeared.

⚠ vs sliding window — know which land you're in

A window is two pointers plus maintained state about everything between them (a sum, a frequency map). Classic two pointers decides from the endpoint values alone. If your decision needs interior aggregates, you're in window land; if it needs a pair/partition proof, you're here.

In plain English — the same idea, slowernew here? start with this

The trick isn’t “two pointers” — anyone can move two fingers along an array. The trick is the permission slip: each move throws away a whole block of candidate pairs, and you’re only allowed to throw them away after proving none of them can win.

proven dead —never revisitedlr
Each pointer move retires a whole block of pairs — legally, because a one-sentence proof showed none of them can win.

It’s like a tournament where one comparison eliminates an entire bracket: if the sorted array’s smallest element plus its LARGEST partner still falls short of the target, then that smallest element is hopeless with every partner — retire it and never look back.

The test you must pass before every move: finish the sentence “everything I just skipped can’t contain the answer because…”. Can’t finish it? Then you’re not allowed to skip — and this is the wrong tool (hash it, sort it, or use a window).

§T.2 · TYPE A

Converging pair search

LC 167 · Two Sum II (Sorted Array) Sorted input; return the pair summing to target using O(1) extra space.

Brute force: O(n²) pairs. A hashmap gives O(n) time but O(n) space and ignores the gift of sortedness — the follow-up demands O(1) space.

The discard proof, spelled out: if nums[l] + nums[r] < target, then nums[l] paired with any r′ < r is smaller still — r was already nums[l]'s largest available partner. Every remaining pair involving l is dead; l += 1 discards them all at once. Mirror argument when the sum is too big.

Check yourself: [2, 7, 11, 15], target 9 — how many loop iterations?

Three: 2+15=17 too big (r−−), 2+11=13 too big (r−−), 2+7=9 found. Each iteration permanently retired one index.

Solutionattempt it on paper first
def twoSumSorted(numbers, target):
    l, r = 0, len(numbers) - 1
    while l < r:
        s = numbers[l] + numbers[r]
        if s == target:
            return [l + 1, r + 1]      # this problem is 1-indexed
        if s < target:
            l += 1                     # l's largest partner failed: l is done
        else:
            r -= 1                     # r's smallest partner failed: r is done
Complexity derivationre-derive it first

Every iteration permanently retires one index → at most n − 1 iterations of O(1) work → O(n) time, O(1) space.

Proof template — recite it

"The discarded region cannot contain a valid/better answer because …" Interviewers grade that sentence, not the loop. Practice saying it for every problem in this set.

§T.3 · TYPE B

Fix one, converge the rest

LC 15 · 3Sum All unique triplets summing to zero.

Brute force: O(n³) triples.

Insight: sort, fix nums[i], and the rest is exactly Type A with target −nums[i]. n fixings × O(n) converge = O(n²); the O(n log n) sort is free relative to that.

Dedup at every level — and it's the sort that makes dedup possible, because equal values become adjacent: skip a repeated i; after each hit, skip repeated l values and repeated r values.

Solutionattempt it on paper first
def threeSum(nums):
    nums.sort()
    res = []
    for i in range(len(nums) - 2):
        if nums[i] > 0:
            break                      # smallest remaining is positive: done
        if i > 0 and nums[i] == nums[i - 1]:
            continue                   # dedup the fixed element
        l, r = i + 1, len(nums) - 1
        while l < r:
            s = nums[i] + nums[l] + nums[r]
            if s < 0:
                l += 1
            elif s > 0:
                r -= 1
            else:
                res.append([nums[i], nums[l], nums[r]])
                l += 1
                while l < r and nums[l] == nums[l - 1]:
                    l += 1             # dedup the left element
                r -= 1
                while l < r and nums[r] == nums[r + 1]:
                    r -= 1             # dedup the right element
    return res
Complexity derivationre-derive it first

Sort n log n + n outer fixings × amortized-O(n) inner converge → O(n²) time, O(1) extra space (ignoring output). Honest footnote: the output itself can be Θ(n²) triplets — say this before an interviewer asks.

⚠ The lazy dedup

Collecting into a set of tuples "fixes" duplicates but concedes the skill being graded — pointer-level dedup is the ask. Breaking input for dedup-only-at-i: [−2, 0, 0, 2, 2] emits [−2, 0, 2] twice.

§T.4 · TYPE C

The exchange argument

LC 11 · Container With Most Water Two lines + the x-axis form a container; maximize the water area.

Brute force: O(n²) pairs. Area of a pair: (r − l) × min(h[l], h[r]).

The discard proof: always move the shorter wall. Keeping the shorter wall (say h[l] ≤ h[r]) while bringing the other end inward can never win: the width strictly shrinks and the height stays capped at h[l]. Every pair keeping l is therefore ≤ the area just measured — the entire row of candidates dies, l += 1.

lrarea = (r − l) × min(h[l], h[r]) = 4 × 6
The water is capped by the SHORTER wall — keeping it while shrinking width can never win, so it moves.
Solutionattempt it on paper first
def maxArea(height):
    l, r = 0, len(height) - 1
    best = 0
    while l < r:
        best = max(best, (r - l) * min(height[l], height[r]))
        if height[l] < height[r]:
            l += 1                     # shorter wall is the limit — move it
        else:
            r -= 1
    return best
Complexity derivationre-derive it first

One index retires per step → O(n) time, O(1) space.

Name the move

"Move the limiting factor" is a greedy exchange argument in pointer clothing — the same proof style that will justify the greedy choices in Set 08. Two pointers is where you first practice it.

§T.5 · TYPE D

Three-way partition (Dutch national flag)

LC 75 · Sort Colors Sort an array of 0s, 1s, 2s in place, one pass, no counting sort.

Two-pass counting sort is fine engineering — the one-pass invariant dance is what's being interviewed.

Write the invariant as a comment before any code — every branch is then forced:

[0 .. low−1]   all 0s
[low .. mid−1] all 1s
[mid .. high]  unknown — mid scans this
(high .. end]  all 2s
  • see 0 → swap(low, mid), advance both — the swapped-in value came from the 1s zone, already examined
  • see 1 → mid++
  • see 2 → swap(mid, high), high−− and do not advance mid — the swapped-in value is unexamined
STEP THROUGH · DUTCH FLAG ON [2,0,2,1,1,0]step 0

    

Solutionattempt it on paper first
def sortColors(nums):
    low, mid, high = 0, 0, len(nums) - 1
    # [0..low-1]=0s [low..mid-1]=1s [mid..high]=unknown (high..]=2s
    while mid <= high:
        if nums[mid] == 0:
            nums[low], nums[mid] = nums[mid], nums[low]
            low += 1
            mid += 1                   # incoming value was an examined 1
        elif nums[mid] == 1:
            mid += 1
        else:
            nums[mid], nums[high] = nums[high], nums[mid]
            high -= 1                  # do NOT advance mid — new value unexamined
Complexity derivationre-derive it first

mid and high close on each other by exactly one per iteration → O(n) single pass, O(1) space.

⚠ The one bug everyone writes

Advancing mid after the high-swap. Breaking input: [1, 2, 0] → the buggy version ends at [1, 0, 2], and the invariant broke the instant an unexamined value was skipped. Also note the loop condition is mid <= high (partition problems include equality; pair problems use strict <).

§T.6 · TYPE E · THE BOSS

Two ends with carried state

LC 42 · Trapping Rain Water (hard) Given bar heights, how much rain water is trapped between them?

The physics first: water above bar i = min(maxLeft(i), maxRight(i)) − h[i], floored at 0. Every solution is a strategy for knowing those two maxes.

The solution ladder (walk it in this order):

  1. Brute: scan left and right per bar → O(n²).
  2. DP: precompute maxLeft[] and maxRight[] arrays → O(n) time, O(n) space.
  3. Two pointers: O(n) time, O(1) space — carry the maxes.

The information-sufficiency proof: in the h[l] < h[r] branch, l only ever advanced past walls that some then-current right wall exceeded — so the right side always still holds a wall ≥ every left wall seen. Hence min(maxLeft, maxRight) = left_max at l: the water there is decided by information we already have, and can be settled final. Symmetric for the other branch.

Solutionattempt it on paper first
def trap(height):
    l, r = 0, len(height) - 1
    left_max = right_max = 0
    water = 0
    while l < r:
        if height[l] < height[r]:
            left_max = max(left_max, height[l])
            water += left_max - height[l]   # bounded by left_max — final
            l += 1
        else:
            right_max = max(right_max, height[r])
            water += right_max - height[r]
            r -= 1
    return water
Complexity derivationre-derive it first

One pointer retires per iteration → O(n), O(1). This is the mature form of the pattern: converging + carried aggregates + a discard proof about information sufficiency rather than raw values. When this one feels derivable — not memorized — the set is yours.

§T.7 · BEFORE YOU SUBMIT + INTERROGATION

Traps, signals & interrogation

Signals → pattern (added to the drill deck in §3.2)

"pair/triple in a SORTED array hitting a target" → converge from both ends + discard proof
"k-sum, answers are values not indices"          → sort, fix the outers, converge
"max area/score between two ends"                → converge — move the limiting side
"partition / compact in place"                   → reader-writer; three groups → Dutch flag
"water/area bounded by the tallest so far"       → two ends + carried maxes
"unsorted input + need INDICES back"             → hashmap, not sort+pointers

Interrogation — answer in your journal

T1

In LC 167, exactly how many candidate pairs does a single l += 1 discard? Prove none of them could be an answer.

Hint → answer sketchjournal first

Hint: Count the pairs (l, x) you never test again.

Sketch: Moving l discards r − l pairs at once: (l, x) for every x in (l..r]. None can be the answer: nums[r] was l's LARGEST remaining partner and even that sum fell short, so all smaller partners fall shorter.

T2

Show the duplicate triplets [−2, 0, 0, 2, 2] emits when you dedup only the fixed index i. Where must the other two skips go, and why does dedup require the sort?

Hint → answer sketchjournal first

Hint: Fix i = −2 and watch l, r land on equal values twice.

Sketch: Without the l/r skips, (0 at idx1, 2 at idx3) and (0 at idx2, 2 at idx4) both emit [−2, 0, 2]. The inner skips advance past equal values after each hit — and they only work because sorting made equal values adjacent.

T3

Prove that moving the taller wall in LC 11 can never discover a strictly better container.

Hint → answer sketchjournal first

Hint: What happens to width and to min-height?

Sketch: Keeping the shorter wall while moving the taller: width shrinks AND height stays capped at the shorter wall — every such pair scores ≤ the current area. So moving the taller side can only re-test dominated pairs; nothing better is reachable that way.

T4

Write the Dutch-flag invariant from memory, then trace [1, 2, 0] with the buggy mid-advance and name the exact moment the invariant breaks.

Hint → answer sketchjournal first

Hint: The 2 you swap in might be anything.

Sketch: Invariant: [0..low)=0s, [low..mid)=1s, [mid..high]=unknown, (high..]=2s. [1,2,0]: mid sees 1 → mid=1; sees 2 → swap with high → [1,0,2]; the buggy mid++ skips the incoming unexamined 0 and the loop ends wrong. The invariant broke the instant mid advanced.

T5

In LC 42's pointer version, prove by induction that the right side always holds a wall ≥ every left wall passed — hence why water at l is final. Then derive all three complexities (brute, DP arrays, pointers).

Hint → answer sketchjournal first

Hint: l only advances when some right wall beats it.

Sketch: Induction: l advances only in the h[l] < h[r] branch, so every wall l passed was beaten by a wall still at or right of r. Hence the right side always holds a wall ≥ left_max → min is left_max → l's water is final. Costs: brute O(n²); DP arrays O(n)/O(n); pointers O(n)/O(1).

§S.1 · SET 03 — STACK & MONOTONIC STACK

Theory

Redundancy it kills — two flavors, one structure:

  • Plain stack: re-scanning for "the most recent unresolved thing" (the innermost open bracket, the pending operator). LIFO is that question answered in O(1).
  • Monotonic stack: for each element, scanning backward/forward for the nearest element that beats it — O(n) per element, O(n²) total. The stack kills it by discarding permanently: once a greater element stands in front of you, nothing behind it can ever be someone's "nearest greater" again.

The core template (next-greater form — memorize the shape, derive the rest):

stack = []                        # indices; values non-increasing
for i, x in enumerate(nums):
    while stack and nums[stack[-1]] < x:
        j = stack.pop()           # x is j's NEXT GREATER
        # resolve j here, using i and j
    stack.append(i)
# whatever remains never met a greater element
541x = 6 arrivespops 1, 4, 5 — all ≤ 6stack held [5, 4, 1] (decreasing); everything x shadows leaves forever
The discard, drawn: once 6 stands in front, no future element can ever need the shorter bars behind it.
The invariant — every line keeps this true

The stack holds exactly the elements still waiting for their next greater, ordered non-increasing. The while-loop restores it before each push; each pop is a resolution, and the moment of popping is where the answer gets written.

The discard proof (why popping is permanent): when x pops j, two things are settled at once. (1) j's answer is x — x is the first thing to beat it. (2) No future element can ever need j: any later element looking backward sees x standing in front of j, and x ≥ anything j could have offered. j is shadowed forever. This is the same "the discarded region cannot contain the answer" sentence from Set 02 — the stack is a discard structure.

⚠ The hidden requirement

The question must be about the nearest greater/smaller in one direction — a relation where beaten elements can never matter again. If you need "how many greater elements are behind me" or range aggregates, popping destroys information you still need — that's Fenwick-tree / merge-sort territory (aware-only, per the atlas trap list). If elements must also expire by age (window max), you need pops from both ends — the monotonic deque, Set 15.

Complexity tell: every element is pushed exactly once and popped at most once → total stack operations ≤ 2n → O(n) amortized, however long any single while-loop runs. This is the argument §2.2 promised you'd reuse — fourth appearance now (window's left pointer, LC 128's walks, converging pointers, stack pops). One direction, bounded total: same tell every time.

In plain English — the same idea, slowernew here? start with this

Imagine people in a queue, each waiting for someone TALLER to arrive. When a tall person shows up, every shorter person at the back of the line gets their answer (“the next taller one after me is — this person”) and goes home. And here’s the key: they can go home forever, because anyone who arrives later will see the tall newcomer standing there first — the short folks behind are permanently hidden. “Shadowed”, the theory above calls it.

The stack is nothing more than that waiting line, and the while-loop is the moment a newcomer sends people home. Everyone enters the line once and leaves at most once — which is why the whole thing is fast (that’s §2.2’s two-runners billing again).

The plain-stack flavor is even simpler: “the most recently opened thing must close first” — brackets, nested calls, undo. Same structure, no heights involved.

§S.2 · TYPE A

LIFO matching

LC 20 · Valid Parentheses Given a string of brackets, decide whether every bracket closes in the correct order.

Easy-tier, included because it IS the archetype: "most recent unresolved thing resolves first" in its purest form.

Brute force: repeatedly delete adjacent matched pairs until stuck — up to n/2 sweeps of O(n) → O(n²).

Redundancy: every sweep re-finds the innermost pair from scratch. The innermost open bracket is by definition the most recently seen one — a stack hands it over in O(1).

Solutionattempt it on paper first
def isValid(s):
    pairs = {')': '(', ']': '[', '}': '{'}
    stack = []
    for ch in s:
        if ch in pairs:
            if not stack or stack.pop() != pairs[ch]:
                return False           # unmatched or mismatched closer
        else:
            stack.append(ch)
    return not stack                   # leftovers = unclosed opens

Both guards are load-bearing: not stack inside the loop catches ")("; the final not stack catches "((". Drop either and specific inputs pass wrongly.

Complexity derivationre-derive it first

One pass, O(1) per character → O(n) time; stack holds at most n opens ("(((((") → O(n) space.

§S.3 · TYPE B · THE WORKHORSE

Next greater element

LC 739 · Daily Temperatures For each day, how many days until a strictly warmer one? 0 if never.

Brute force: forward scan per day → O(n²).

Redundancy: day i's scan re-walks days that day i−1's scan already crossed. On a falling run of temperatures, every day re-scans the same cold stretch.

Insight: keep a waiting room of unresolved days (indices). A new temperature resolves — pops — every waiting day cooler than it, and the answers are written at the pop: the popped day's next-warmer is exactly today. Equal temperatures stay waiting (the problem says strictly warmer), so the stack is non-increasing.

Check yourself: temps [70, 70, 75] — what does the stack hold before day 2 arrives, and what happens then?

Indices [0, 1]: equal temps both wait (strictly warmer required). 75 pops both — res[0] = 2, res[1] = 1.

Solutionattempt it on paper first
def dailyTemperatures(T):
    res = [0] * len(T)
    stack = []                        # indices; temps non-increasing
    for i, t in enumerate(T):
        while stack and T[stack[-1]] < t:
            j = stack.pop()
            res[j] = i - j            # first strictly warmer day
        stack.append(i)
    return res                        # never-resolved days keep 0
Complexity derivationre-derive it first

The nested while looks quadratic; count total operations instead: each index is pushed once and popped at most once → ≤ 2n stack operations across the whole run → O(n) amortized time, O(n) space. Verbatim the §2.2 argument.

One template, two read points

Next-greater problems write answers at the POP (today resolves the past). Previous-greater problems read at the PUSH — the stack top just before you push is your nearest greater to the left. Same loop, different line. LC 496/503 are this with a value-map / circular 2n-sweep twist.

§S.4 · TYPE C

Previous greater with an aggregated payload

LC 901 · Online Stock Span Streaming prices; per price, return how many consecutive days (ending today) had price ≤ today's.

Brute force: walk backward per query → O(n) per call, O(n²) total.

Redundancy: whenever prices rise, today's backward walk re-traverses everything yesterday's walk covered.

Insight: span(today) = 1 + the spans of every popped day. A day popped (price ≤ today's) is shadowed permanently — any future backward walk hits today first, and today's price is ≥ theirs — so its span can be absorbed into today's and the day itself discarded. Store (price, span) pairs: the payload rides the stack.

Solutionattempt it on paper first
class StockSpanner:
    def __init__(self):
        self.stack = []               # (price, span); prices strictly decreasing
    def next(self, price):
        span = 1
        while self.stack and self.stack[-1][0] <= price:
            span += self.stack.pop()[1]
        self.stack.append((price, span))
        return span
Complexity derivationre-derive it first

Across ALL calls, each pair is pushed once and popped at most once → amortized O(1) per call, O(n) space. A single call can still cost O(n) — say "amortized" out loud; interviewers probe exactly this distinction.

The generalization to journal

Third structure where choosing the payload is the variant: prefix maps stored count / remainder / earliest index (§1.6); ladder rungs store dates; here the stack stores (value, accumulated answer). When a stack problem mutates, ask "what must a surviving element remember about the elements it absorbed?"

§S.5 · TYPE D

Greedy build with a budget

LC 402 · Remove K Digits Delete k digits from a number string so the remaining number is as small as possible.

Brute force: choose which n−k digits survive → C(n, k) subsequences, exponential.

Insight: most-significant positions dominate, so spend deletions there first. Build the answer on a stack; while budget remains and the top digit is larger than the incoming one, pop it. Exchange argument (Set 02, Type C — same proof style): any solution that keeps a larger digit in front of a smaller one can be improved by deleting that larger digit instead — swapping the choice never hurts, so the greedy pop is never suboptimal.

Solutionattempt it on paper first
def removeKdigits(num, k):
    stack = []
    for d in num:
        while k and stack and stack[-1] > d:
            stack.pop()
            k -= 1
        stack.append(d)
    if k:
        stack = stack[:-k]            # non-decreasing leftovers: worst digits at the tail
    return ''.join(stack).lstrip('0') or '0'

Digit characters compare correctly ('0'–'9' is lexicographic = numeric for single chars) — fine in Python/JS; in Java compare chars, not strings.

Complexity derivationre-derive it first

Each digit pushed once, popped at most once → O(n) time and space.

⚠ Three exits, three edge cases

(1) Budget left over — input was non-decreasing ("12345", k=2): the largest digits are at the tail, cut there. (2) Leading zeros — "10200", k=1 → "0200" → strip to "200". (3) Everything removed — return "0", not "". Each is a separate wrong-answer in the wild.

Same family (do after): LC 316 Remove Duplicate Letters (budget = "must still be able to supply each letter"), LC 321 Create Maximum Number.

§S.6 · TYPE E

Contribution counting

LC 907 · Sum of Subarray Minimums Sum min(subarray) over every contiguous subarray, mod 10⁹+7.

Brute force: n² subarrays × O(n) min → O(n³); running min while extending → O(n²).

The reframe (this is the skill): don't ask "what is each subarray's min" — ask "for how many subarrays is arr[j] THE min?" Flip the sum from subarrays to elements: each element contributes arr[j] × (number of subarrays it dominates).

Derivation of the count: arr[j] is the min of exactly the subarrays that start after its previous-smaller element and end before its next-smaller element. left = j − prevSmaller(j) start choices, right = nextSmaller(j) − j end choices; the choices are independent → left × right subarrays.

The tie-break: with duplicates, "smaller" must be strict on one side and non-strict on the other (here: previous strictly smaller, next smaller-or-equal) so each equal-min subarray is assigned to exactly one owner. Both strict → subarrays with tied mins counted zero times; both non-strict → counted twice.

Solutionattempt it on paper first
def sumSubarrayMins(arr):
    MOD = 10**9 + 7
    total = 0
    stack = []                        # indices; values strictly increasing
    for i in range(len(arr) + 1):
        cur = 0 if i == len(arr) else arr[i]   # sentinel flushes everything
        while stack and arr[stack[-1]] >= cur:
            j = stack.pop()
            left = j - (stack[-1] if stack else -1)   # prev STRICTLY smaller
            right = i - j                             # next smaller OR EQUAL
            total = (total + arr[j] * left * right) % MOD
        stack.append(i)
    return total

The pop condition >= encodes the non-strict side; the surviving stack being strictly increasing encodes the strict side. Both boundaries fall out of one pass, at the pop.

Complexity derivationre-derive it first

Each index pushed once, popped once (the sentinel guarantees every pop happens) → O(n) time, O(n) space. Compare: the brute force at n = 3·10⁴ is ~10⁹ ops — TLE; this is 6·10⁴.

Name the technique

Contribution counting — "sum over all subarrays" becomes "sum over elements × how many subarrays each dominates". Reappears in LC 2104 (sum of ranges) and LC 828. Whenever a sum ranges over Θ(n²) objects but each object's value is decided by one element, flip the sum.

§S.7 · TYPE F · THE BOSS

Largest rectangle in a histogram

LC 84 · Largest Rectangle in Histogram (hard) Given bar heights, find the largest rectangle that fits under the skyline.

Brute force: for each bar, expand left and right while bars stay ≥ it → O(n²).

Redundancy: neighboring bars re-scan the same stretches to find their limits — and "limit" means exactly nearest smaller on each side: the Type E skeleton, aimed at a max instead of a sum.

Insight: keep an increasing stack of indices. When the incoming bar is lower than the top, the top bar's rectangle is settled: its right wall is here (i), its left wall is just past the new stack top, so width = i − stack[-1] − 1 (or i if the stack empties — nothing to its left was shorter). A sentinel height 0 at the end flushes every bar without a second loop.

Solutionattempt it on paper first
def largestRectangleArea(heights):
    stack = []                        # indices; heights increasing
    best = 0
    for i in range(len(heights) + 1):
        cur = 0 if i == len(heights) else heights[i]
        while stack and heights[stack[-1]] > cur:
            j = stack.pop()
            width = i - (stack[-1] + 1 if stack else 0)
            best = max(best, heights[j] * width)
        stack.append(i)
    return best
Complexity derivationre-derive it first

Push once, pop once (sentinel included) → O(n) time, O(n) space.

Equal heights — why strict pop is safe here but not in 907

With the strict pop (>), equal-height bars stack up; when finally popped, the earlier ones compute truncated widths — but the last equal bar reaches back across all of them and computes the full-width rectangle, so the max is still correct. In 907 the same sloppiness double-counts, because counting must be exact. Max forgives, sum doesn't — know which game you're playing.

When this feels derivable — not memorized — the set is yours. LC 85 Maximal Rectangle is this per matrix row.

§S.8 · BEFORE YOU SUBMIT + INTERROGATION

Traps, signals & interrogation

Signals → pattern (added to the drill deck in §3.2)

"next / previous greater or smaller element"     → monotonic stack — resolve at pop / peek at push
"how many days/steps until a bigger X"           → next-greater on indices; answer = distance at pop
"span / consecutive run ending here"             → previous-greater + (value, span) payload
"remove k to make smallest/largest sequence"     → greedy stack + budget, exchange argument
"sum/count of min/max over ALL subarrays"        → contribution: strict prev × non-strict next
"largest rectangle / max area under a skyline"   → increasing stack, width at pop, sentinel
"valid nesting / matched pairs / undo"           → plain LIFO stack

Interrogation — answer in your journal

S1

Reproduce the ≤ 2n amortized proof for the monotonic stack from memory, then list the four places it has now appeared across Sets 01–03. What is the shared "tell"?

Hint → answer sketchjournal first

Hint: Push once, pop once.

Sketch: Each element is pushed exactly once and popped at most once → ≤ 2n stack ops however long one while runs. Appearances: window's left (§2.2), LC 128 walks (§B.5), converging pointers (§T), stack pops (§S), BFS enqueues (§GR), the deque (§O). Tell: one-direction movement, bounded total.

S2

In LC 739, prove both halves of the pop: (a) the popped day's answer is correct, and (b) the popped day can never be needed by any future day.

Hint → answer sketchjournal first

Hint: What does popping mean — and who could still need j?

Sketch: (a) j pops when the FIRST strictly warmer temp arrives: by definition its answer. (b) Any later day looking backward hits today first, and today is warmer than j — j is shadowed forever. Correct write + safe discard = the full proof.

S3

Trace LC 907 on [2, 2] three ways: strict pop on both sides, non-strict on both, and the correct strict/non-strict mix. Show the totals 6, 8, and 4-or-6, and state the ownership convention that makes the mix correct.

Hint → answer sketchjournal first

Hint: Who owns the tied window [2,2]?

Sketch: Correct mix (strict prev, non-strict next): total 6, each tied window owned once. Non-strict both sides: [2,2] claimed by both copies → 8. Strict both: claimed by neither → 4. Convention: assign ties to exactly one side.

S4

Prove via exchange argument that LC 402's pop (top > incoming, budget left) is never suboptimal. Then explain why the leftover budget on "12345", k = 2 must be spent at the tail.

Hint → answer sketchjournal first

Hint: Swap the kept larger digit for the popped one.

Sketch: If a schedule keeps d > e before e, deleting d instead (same budget) lowers a more significant digit — never worse, so the greedy pop is safe. '12345', k = 2: nothing pops (increasing) — on a non-decreasing stack the largest digits ARE the tail, so spend the leftover budget there.

S5

Derive LC 84's width formula (i − stack[-1] − 1, or i when empty) from the invariant. Then, with the strict pop, show on [2, 2, 2] which bars compute truncated widths and prove the max is nevertheless correct.

Hint → answer sketchjournal first

Hint: After popping j, who is the new top?

Sketch: The new stack top is the nearest surviving bar left of j — strictly shorter (or an equal bar under strict pop: that's the truncation). Width = i − stack[-1] − 1 spans everything ≥ h[j]. [2,2,2]: sentinel pops give widths 1, 2, 3 → areas 2, 4, 6 — early equals truncated, the last one computes the full rectangle, max survives.

§BS.1 · SET 04 — BINARY SEARCH

Theory

Redundancy it kills: a linear scan inspects every element, but against ordered structure one comparison decides half the candidates at once — if arr[mid] < target in a sorted array, every element at or left of mid is also < target, and checking them individually is pure repeated work. Binary search discards a provably-dead half per probe: O(n) → O(log n).

The senior reframe — it's not about sorted arrays. Binary search works on any monotonic predicate: a boolean P over an ordered domain that reads False…False True…True (once true, forever true). The task is always find the boundary. A sorted-array lookup is just the special case P(i) = "arr[i] ≥ target". This reframe is what unlocks half the mediums (Types D–F).

The core template (boundary / lower-bound form — the one that generalizes):

lo, hi = 0, n                  # answer lives in [lo, hi]
while lo < hi:
    mid = (lo + hi) // 2
    if P(mid):
        hi = mid               # mid might BE the first True — keep it
    else:
        lo = mid + 1           # mid is False — boundary strictly right
return lo                      # the first True (n if none)
FFFFFFTTTTboundary = the answerlomidhi
The predicate strip: False…False True…True. One probe at mid settles half the strip; the search converges on the first True.
The invariant — every line keeps this true

The first True lies in [lo, hi] at all times. P(mid) true → boundary ≤ mid → hi = mid keeps it. P(mid) false → boundary > mid → lo = mid + 1 keeps it. Termination: floor division gives mid < hi whenever lo < hi, so both branches strictly shrink the interval. When lo == hi, the interval is one cell and the invariant says: that cell is the answer.

⚠ The hidden requirement

The predicate must be monotonic. Sortedness is one source of monotonicity, not the requirement itself (§2.1's monotonicity, generalized). Violate it and binary search doesn't crash — it converges confidently to a wrong answer, the quietest failure in this whole lab. When structure is broken, either repair it locally (rotated arrays: one half is always sorted — Type C) or fall back to a linear scan.

Complexity tell: the search space halves every probe → after k probes, n/2ᵏ candidates remain → ⌈log₂ n⌉ probes. Constraint smell: answers ranging to 10⁹ with an O(n) feasibility check per guess ⇒ binary search on the answer, O(n log R) with log₂(10⁹) ≈ 30. Phrase-level tells: "minimize the maximum", "smallest X such that", "first/last position".

In plain English — the same idea, slowernew here? start with this

You already know binary search: guessing a number between 1 and 1000 with “higher/lower” feedback takes 10 guesses, because each guess kills half the possibilities.

The grown-up version — the one worth a senior offer — is realizing you don’t need a sorted array. You need any yes/no question whose answers, lined up, look like NO NO NO NO YES YES YES (once yes, always yes). Binary search finds the exact spot where NO flips to YES. A sorted array is just one convenient source of such questions.

The killer application: “what’s the smallest speed that finishes the job in time?” Try a speed — you get a yes or a no. Slower speeds than a working one keep working? No wait — faster always works, slower eventually fails: NO…NO YES…YES. So binary search the SPEED, not any array. Half this set’s problems are exactly this move.

The one danger, in plain words: if the answers can flip back (yes, no, yes…), binary search does not crash — it confidently returns garbage. Always ask “is my question really one-directional?” before trusting it.

§BS.2 · TYPE A

Exact match — the closed-interval archetype

LC 704 · Binary Search Sorted array, return the index of target or −1.

Easy-tier, included because it IS the archetype — and because a majority of engineers still write it with an off-by-one. There are two clean templates; the bugs come from mixing them.

Discard proof: if arr[mid] < target, sortedness certifies every index ≤ mid holds a value < target — none can be the answer; discard the closed left half including mid. Mirror for >.

Solutionattempt it on paper first
def search(nums, target):
    lo, hi = 0, len(nums) - 1          # closed interval [lo, hi]
    while lo <= hi:
        mid = (lo + hi) // 2
        if nums[mid] == target:
            return mid
        if nums[mid] < target:
            lo = mid + 1
        else:
            hi = mid - 1
    return -1

Closed interval ⇒ loop runs while lo <= hi ⇒ both assignments must step past mid (±1) — that trio is one consistent template. The half-open boundary template (§BS.1) is the other. Pick one per problem; never blend.

Complexity derivationre-derive it first

Candidates after k probes: n/2ᵏ. Stops at ≤ 1 candidate → k = ⌈log₂ n⌉ probes × O(1) each → O(log n) time, O(1) space. n = 10⁶ → ~20 probes.

§BS.3 · TYPE B

Boundary search (lower bound)

LC 34 · Find First and Last Position in Sorted Array Sorted array with duplicates; return the first and last index of target, or [−1, −1].

Brute force: find any occurrence, then scan outward — the equal-run can be the whole array → O(n).

Redundancy: the outward scan re-checks elements when one probe against the run's edge predicate decides half of them.

Insight: both edges are lower-bound queries. P(i) = "arr[i] ≥ target" is monotonic; its boundary is the first occurrence. The last occurrence needs no second template: it is lower_bound(target + 1) − 1. One tool, aimed twice — the same "reframe the second question as the first" move as §1.7's transform.

Check yourself: lower_bound of 5 in [1, 3, 5, 5, 8]?

Index 2 — the FIRST 5 (that is what “first element ≥ target” means). And lower_bound(6) would return 4: the insertion point, even though 6 is absent.

Solutionattempt it on paper first
def searchRange(nums, target):
    def lower_bound(t):                # first index with nums[i] >= t
        lo, hi = 0, len(nums)          # half-open: answer in [lo, hi]
        while lo < hi:
            mid = (lo + hi) // 2
            if nums[mid] >= t:
                hi = mid
            else:
                lo = mid + 1
        return lo
    first = lower_bound(target)
    if first == len(nums) or nums[first] != target:
        return [-1, -1]
    return [first, lower_bound(target + 1) - 1]

Note hi starts at len(nums), not len − 1: "not found" must be a legal answer, and the half-open template returns it for free. Python's bisect_left IS this function — know the mapping, hand-roll it in interviews.

Complexity derivationre-derive it first

Two boundary searches → 2·⌈log₂ n⌉ probes → O(log n), O(1) space.

⚠ Why hi = mid doesn't loop forever

Floor division pulls mid strictly below hi whenever lo < hi, so hi = mid always shrinks the interval. The symmetric upper-boundary form ("last True", using lo = mid) needs the ceil midpoint (lo + hi + 1) // 2 for the same reason — floor mid there infinite-loops on a 2-cell interval. Breaking input: [1, 2], any target ≥ 2. Rule: the kept bound must never re-receive mid computed toward it.

§BS.4 · TYPE C

Repairing broken sortedness

LC 33 · Search in Rotated Sorted Array A sorted array rotated at an unknown pivot; find target in O(log n). Values distinct.

The obstacle: global sortedness is gone, so the Type A discard proof doesn't apply. The repair: cut anywhere and at least one half is still fully sorted (the rotation point lies in only one half). The sorted half's endpoints form an O(1) certificate: target is inside its range, or provably not in that half at all.

Discard proof: if the left half is sorted and nums[lo] ≤ target < nums[mid], sortedness certifies target can only live there — discard the right. If target is outside that range, the sorted half provably doesn't contain it — discard it instead. Either way half the array dies per probe, exactly as before.

Solutionattempt it on paper first
def searchRotated(nums, target):
    lo, hi = 0, len(nums) - 1
    while lo <= hi:
        mid = (lo + hi) // 2
        if nums[mid] == target:
            return mid
        if nums[lo] <= nums[mid]:      # left half sorted (== : one-cell half)
            if nums[lo] <= target < nums[mid]:
                hi = mid - 1
            else:
                lo = mid + 1
        else:                          # right half sorted
            if nums[mid] < target <= nums[hi]:
                lo = mid + 1
            else:
                hi = mid - 1
    return -1

The <= in the sorted-half test matters: on a two-cell interval, lo == mid and the "left half" is a single element — equality must count as sorted or the branch logic inverts.

Complexity derivationre-derive it first

Still one probe → one halving → O(log n), O(1) space. The repair cost is O(1) per probe (two extra comparisons).

⚠ Duplicates destroy the certificate

LC 81 allows duplicates: when nums[lo] == nums[mid] == nums[hi], neither half can prove anything ([1,1,1,1,0,1] — which half holds the 0?). Only safe move: shrink both ends by one. Worst case degrades to O(n) — say this degradation out loud before the interviewer asks.

§BS.5 · TYPE D · THE HIGHEST-ROI VARIANT

Binary search on the answer

LC 875 · Koko Eating Bananas Choose the minimum eating speed k so all banana piles are finished within h hours.

Brute force: try every speed 1..max(piles), checking feasibility in O(n) each → O(n · maxPile). With piles up to 10⁹, hopeless.

Redundancy: feasibility is monotonic in speed — if speed s works, every s′ > s works (eating faster never hurts). Testing speeds one by one re-establishes what monotonicity already implies about half of them.

Insight: there is no array anywhere. The domain is the answer space [1, max(piles)], the predicate is can(speed), and the question is a lower bound — the §BS.1 template verbatim. This is the reframe that converts "optimization problem" into "boundary of a monotonic predicate", and it's the single most-asked binary-search shape at FAANG.

Solutionattempt it on paper first
def minEatingSpeed(piles, h):
    def can(speed):
        return sum((p + speed - 1) // speed for p in piles) <= h
    lo, hi = 1, max(piles)             # smallest feasible speed lives here
    while lo < hi:
        mid = (lo + hi) // 2
        if can(mid):
            hi = mid                   # feasible — try slower
        else:
            lo = mid + 1               # infeasible — all slower speeds are too
    return lo

(p + speed − 1) // speed is integer ceil-division — the Python idiom for ⌈p/s⌉ without floats.

Complexity derivationre-derive it first

Probes: log₂(maxPile) ≈ 30 for 10⁹. Each probe runs the O(n) check → O(n log maxPile), O(1) space. Constraint sanity: n = 10⁴ × 30 = 3·10⁵ ops. The sibling LC 1011 (ship packages) changes only the domain: [max(weights), sum(weights)] — the lower end must already be feasible per-item.

Recognition rule

"Minimize the max" / "maximize the min" / "smallest X such that…" + a feasibility check you can write greedily ⇒ binary search the answer. The greedy check's correctness usually rests on an exchange argument (Set 02, §S.5) — the patterns are compounding.

§BS.6 · TYPE E

Local monotonicity — peak finding

LC 162 · Find Peak Element Return any index whose value beats both neighbors; nums[−1] and nums[n] count as −∞. Adjacent values distinct.

Brute force: scan for the first descending edge → O(n).

The surprise: the array is completely unsorted, yet binary search applies — because the needed predicate is still monotonic in the direction of travel. Look at the edge between mid and mid+1:

Discard proof: if nums[mid] > nums[mid+1] (descending edge), walk left from mid: either values keep rising toward index 0 — which is then a peak against the −∞ boundary — or they turn somewhere, and the turn is a peak. Either way a peak exists in [lo, mid]; the right half may be discarded without ever looking at it. Mirror for an ascending edge. We don't discard "no answer there" — we discard "an answer is guaranteed on this side", which is just as valid for an "any peak" question.

Solutionattempt it on paper first
def findPeakElement(nums):
    lo, hi = 0, len(nums) - 1
    while lo < hi:
        mid = (lo + hi) // 2
        if nums[mid] > nums[mid + 1]:
            hi = mid                   # a peak is guaranteed in [lo, mid]
        else:
            lo = mid + 1               # a peak is guaranteed in [mid+1, hi]
    return lo

mid + 1 is always in bounds: floor division keeps mid < hi inside the loop.

Complexity derivationre-derive it first

Halving per probe → O(log n), O(1) space — on an unsorted array. The lesson worth journaling: sortedness was never the requirement; a provable "the answer is on this side" is.

§BS.7 · TYPE F · THE BOSS

Partition search across two arrays

LC 4 · Median of Two Sorted Arrays (hard) Two sorted arrays; find the median of their union in O(log(m+n)).

Brute force: merge → O(m+n). The demanded log bound rules out even looking at every element — a strong hint that the search is over cut positions, not values.

Derivation: a median splits the union into halves. Take i elements of A and j = (m+n+1)//2 − i of B for the left side — the size constraint eliminates one variable. A cut is correct iff every left element ≤ every right element; within each array that's free (sorted), so only the cross conditions remain: A[i−1] ≤ B[j] and B[j−1] ≤ A[i].

Why binary search applies: as i grows, A[i−1] rises and B[j] falls (j shrinks) — so the predicate "A[i−1] ≤ B[j]" is monotonic in i: true, true, …, false, false. The correct cut is its boundary. Search i over the shorter array so j = half − i can never leave [0, n].

Solutionattempt it on paper first
def findMedianSortedArrays(A, B):
    if len(A) > len(B):
        A, B = B, A                    # search the shorter array
    m, n = len(A), len(B)
    half = (m + n + 1) // 2
    lo, hi = 0, m                      # i = how many of A go left
    while lo <= hi:
        i = (lo + hi) // 2
        j = half - i
        a_left  = A[i - 1] if i > 0 else float('-inf')
        a_right = A[i]     if i < m else float('inf')
        b_left  = B[j - 1] if j > 0 else float('-inf')
        b_right = B[j]     if j < n else float('inf')
        if a_left <= b_right and b_left <= a_right:
            if (m + n) % 2:
                return float(max(a_left, b_left))
            return (max(a_left, b_left) + min(a_right, b_right)) / 2
        if a_left > b_right:
            hi = i - 1                 # A gives too much — move the cut left
        else:
            lo = i + 1

The ±∞ sentinels make empty sides compare correctly — the same "sentinel closes the edge case" move as LC 84's flush bar (§S.7) and the {0:1} seed (§1.4).

Complexity derivationre-derive it first

The search space is i ∈ [0, m], halved per probe, O(1) work each → O(log min(m, n)) — strictly better than the O(log(m+n)) asked. Space O(1).

When this feels derivable — size constraint kills a variable, cross-conditions form a monotonic predicate, sentinels close the edges — the set is yours. LC 410 (Split Array Largest Sum) is the other famous boss, and it's just Type D with a greedy counter.

§BS.8 · BEFORE YOU SUBMIT + INTERROGATION

Traps, signals & interrogation

Signals → pattern (added to the drill deck in §3.2)

"sorted array — exact / first / last position"    → lower-bound template (half-open)
"minimize the maximum / maximize the minimum"     → binary search the ANSWER + greedy check
"smallest X such that condition holds"            → boundary of a monotonic predicate
"rotated sorted array"                            → one half is always sorted — certify & discard
"find any peak / local extremum"                  → move toward the rising neighbor
"two sorted arrays, k-th / median"                → partition search on the shorter array

Interrogation — answer in your journal

N1

Prove the lower-bound template terminates: show mid < hi whenever lo < hi, and that both branches strictly shrink [lo, hi]. Then show what breaks if you write hi = mid − 1 instead, on nums = [5], target = 5.

Hint → answer sketchjournal first

Hint: Where does floor division put mid?

Sketch: lo < hi ⇒ mid < hi, so hi = mid strictly shrinks and lo = mid+1 obviously does — termination. hi = mid − 1 can leap PAST the first True: with P true at index 0 (lo=0, hi=1, mid=0), hi becomes −1 and the boundary is lost.

N2

In LC 33, prove at least one half is always sorted — including the two-cell window where lo == mid. Then show precisely which line of reasoning dies under duplicates and derive the O(n) worst case on [1,1,1,1,0,1].

Hint → answer sketchjournal first

Hint: Which half contains the rotation point?

Sketch: Exactly one half contains it, so the other is sorted; nums[lo] ≤ nums[mid] certifies the left — equality covers the one-element half when lo == mid. With duplicates ([1,1,1,1,0,1]) all three probes are equal and certify nothing: shrink both ends, worst case O(n).

N3

Derive Koko's O(n log maxPile) from probe count × check cost. Why can the domain [1, max(piles)] never miss the answer, and why does LC 1011's domain start at max(weights) instead of 1?

Hint → answer sketchjournal first

Hint: Why is each domain endpoint safe?

Sketch: log₂(10⁹) ≈ 30 probes × O(n) check → O(n log maxPile). Speed max(piles) finishes every pile in one hour each, so a feasible point exists inside [1, max]. LC 1011: any capacity below max(weights) can't carry the heaviest item — start lo there or the 'boundary is inside the interval' invariant silently breaks.

N4

LC 162: prove a peak must exist on the side of the rising neighbor (boundary-as-−∞ argument), and prove mid + 1 is always in bounds. What exactly is being discarded — and how does it differ from Type A's discard?

Hint → answer sketchjournal first

Hint: Walk uphill from the rising edge.

Sketch: If nums[mid] < nums[mid+1], walk right: values rise until index n−1 (a peak against the −∞ boundary) or a first descent (a peak). So SOME peak exists in [mid+1, hi]. mid+1 is in bounds since floor keeps mid < hi. Unlike Type A, the discarded side may contain answers — legal because a kept answer is guaranteed and any peak is accepted.

N5

LC 4: prove the predicate A[i−1] ≤ B[j] is monotonic in i, explain why the search must run over the shorter array, and hand-trace A = [1, 3], B = [2] to the returned median.

Hint → answer sketchjournal first

Hint: What happens to A[i−1] and B[j] as i grows?

Sketch: Raising i raises a_left (A sorted) and lowers b_right (j shrinks) — the predicate a_left ≤ b_right flips exactly once. Searching the shorter array keeps j = half − i inside [0, n]. A=[1,3], B=[2]: swap → A=[2]; i=1, j=1 passes the cross checks; odd total → median = max(a_left, b_left) = 2.

§L.1 · SET 05 — LINKED LIST

Theory

The structural trade first: arrays give O(1) random access and O(n) splicing; linked lists invert it — O(1) splice/insert/delete given a handle to the node, but reaching position i costs O(i). Every pattern in this set exists because of that asymmetry.

Redundancy it kills — two flavors:

  • Re-walking: the brute force re-traverses from the head every time it needs a position (a predecessor, the middle, the n-th from end) — O(n) per access → O(n²). The fix: carry the handles you'll need during one pass, instead of re-deriving positions.
  • Copy-out: "dump to an array, do array things, rebuild" — correct, O(n) extra space, and it forfeits exactly the skill being graded. Name it as the baseline, then beat it in place.

The three primitives (this whole set is compositions of these):

# 1. dummy head — makes the head a normal node
dummy = ListNode(0, head)             # prev starts here, never None

# 2. fast/slow — positional identity: fast at 2k ⇒ slow at k
slow, fast = head, head

# 3. in-place reversal — one edge rewired per step
prev, cur = None, head
while cur:
    cur.next, prev, cur = prev, cur, cur.next
return prev                           # RHS evaluates fully before assignment
The invariant — reversal's version, learn it as the model

At every step: prev heads the fully-reversed prefix, cur heads the untouched suffix, and together they partition all nodes. The tuple assignment moves exactly one node across the boundary and rewires exactly one edge — so if the invariant held before, it holds after. When cur is None, the "untouched suffix" is empty and prev is the whole answer.

⚠ The hidden requirement — you can't look back

A singly linked list is a one-way street: once a pointer passes a node, that node is unreachable from it. Two consequences. (1) Every algorithm must capture the handles it needs before advancing past them — overwrite cur.next before saving it and the rest of the list is orphaned (in Java/JS that's an explicit temp; Python's tuple assignment evaluates the right side first, which is why the one-liner is safe — §F.4 unpacks it slowly). (2) When the pattern genuinely needs random access or backward looks, the honest fallback is the O(n) array copy — state the trade-off out loud, then beat it if asked.

Complexity tell: every pointer here advances monotonically down the list and each node is visited a bounded number of times (once, or twice for check-then-reverse shapes) → O(n) time, O(1) space. Fifth appearance of the one-direction argument (§2.2 window, LC 128 walks, converging pointers, stack pops — now list pointers).

In plain English — the same idea, slowernew here? start with this

A linked list is a treasure hunt where every clue names only the NEXT location. No map. No going backwards.

1234no way back — save a pointer BEFORE you pass it
A treasure hunt where each clue only names the next stop. Anything you might need later must be written down before you move on.

Two consequences drive every pattern in this set. First: if you’ll need a location later, write it down before leaving it — the famous one-line reversal works only because Python saves the next address before overwriting it (§F.4). Second: if you overwrite a clue before reading it, every location after it is lost forever — that’s the classic orphaned-list bug.

The three tools in plain words: a dummy is a fake first clue you add so the real first stop isn’t a special case; fast/slow is two hunters at different speeds (when the fast one finishes, the slow one is at the middle; if the trail loops, they must eventually meet); reversal is re-pointing clues one at a time while holding exactly two bookmarks.

§L.2 · TYPE A

In-place reversal

LC 206 · Reverse Linked List Reverse a singly linked list in place.

Easy-tier, included because it IS the archetype — the rotation it teaches is a sub-routine of half the mediums and the boss.

Brute force: copy values to an array, reverse, rebuild → O(n) space. Or: repeatedly walk from the head to find each predecessor → O(n²).

Redundancy: re-walking for a predecessor you were standing on one step ago. Carry it instead.

Solutionattempt it on paper first
def reverseList(head):
    prev, cur = None, head
    while cur:
        cur.next, prev, cur = prev, cur, cur.next
    return prev

One edge rewired per iteration, invariant from §L.1 preserved each time. In Java/JS: next = cur.next FIRST, then cur.next = prev — reversed order orphans the suffix.

STEP THROUGH · REVERSE 1→2→3step 0

    

Complexity derivationre-derive it first

n iterations × O(1) rewiring → O(n) time, O(1) space. The recursive version is also O(n) time but O(n) stack space — say that difference unprompted.

§L.3 · TYPE B

Fixed-gap pointers + dummy head

LC 19 · Remove Nth Node From End of List Delete the n-th node from the end — in one pass.

Brute force: pass 1 counts the length L, pass 2 walks to node L − n → two passes.

Redundancy: the second walk re-traverses exactly what the first walk already measured. Fold the measurement into the walk.

Derivation: start both pointers at a dummy before the head; advance lead by n + 1 first. Both then step together, so the gap stays n + 1 forever. When lead falls off the end (position L + 1 counting from dummy), trail sits at position L − n — exactly the predecessor of the node to delete. The gap is the proof; no counting needed.

Why the dummy is load-bearing: if the head itself is the target ([1], n = 1), trail must stand before the head — a position that doesn't exist without the dummy. This is the same "seed the empty case" move as {0:1} in §1.4 and the sentinels of §S.7/§BS.7.

Check yourself: [1, 2], n = 2 — where does trail stop, and what gets deleted?

lead walks n+1 = 3 steps: dummy→1→2→None — already None, so trail never moves: it sits on the dummy, and trail.next = trail.next.next deletes the HEAD. Without the dummy there is no node to stand on.

Solutionattempt it on paper first
def removeNthFromEnd(head, n):
    dummy = ListNode(0, head)
    lead = trail = dummy
    for _ in range(n + 1):
        lead = lead.next
    while lead:
        lead, trail = lead.next, trail.next
    trail.next = trail.next.next
    return dummy.next
Complexity derivationre-derive it first

lead walks L + 1 steps total, trail walks L − n → O(L) time, O(1) space, one pass.

§L.4 · TYPE C

Composing the primitives

LC 143 · Reorder List Rearrange L₀→L₁→…→Lₙ into L₀→Lₙ→L₁→Lₙ₋₁→… in place.

Brute force: array of node references, two-pointer indexing, rewire → O(n) space. Fine engineering; not the ask.

Insight: the target order is "first half interleaved with reversed second half" — so the solution is literally three primitives in sequence: middle (fast/slow), reverse the back half (Type A), interleave. Nothing new is invented; the skill being graded is recognizing the decomposition and keeping the seams clean.

Solutionattempt it on paper first
def reorderList(head):
    slow, fast = head, head
    while fast.next and fast.next.next:
        slow, fast = slow.next, fast.next.next
    second = slow.next
    slow.next = None                   # cut — skipping this creates a cycle
    prev = None
    while second:
        second.next, prev, second = prev, second, second.next
    first, second = head, prev
    while second:
        nxt1, nxt2 = first.next, second.next
        first.next = second
        second.next = nxt1
        first, second = nxt1, nxt2

With slow and fast both starting at head and this loop condition, slow lands on ⌈n/2⌉ — the first half is never shorter than the second, so the interleave always terminates with second exhausted first. Derive that on n = 4 and n = 5 before trusting it.

Complexity derivationre-derive it first

Middle: n/2 steps. Reverse: n/2. Interleave: n/2. Total 3n/2 → O(n) time, O(1) space.

⚠ The cut

slow.next = None is the most-forgotten line in this problem. Without it the first half still points into the second, the interleave self-links, and a later traversal loops forever — a bug that surfaces far from its cause.

§L.5 · TYPE D

Floyd's cycle detection — with the actual proof

LC 142 · Linked List Cycle II If the list has a cycle, return the node where it begins; else null. O(1) space.

Brute force: hashset of visited nodes; first repeat is the cycle start → O(n) time average (hashing node identities), O(n) space. Correct — the O(1)-space demand is what forces Floyd.

Phase 1 — they must meet: once slow enters the cycle, fast is already inside it. Per tick the gap (measured around the cycle) shrinks by exactly 1 — relative speed is 2 − 1 = 1 — so it reaches 0 within c ticks. No jump-over is possible because the relative step is 1: proof by the gap arithmetic, not by intuition.

Phase 2 — the meeting point knows the start. Let a = distance head → cycle start, c = cycle length, b = distance cycle start → meeting point. At the meeting, slow has walked a + b and fast 2(a + b); the difference a + b must be a whole number of laps: a + b = kc, so a ≡ −b (mod c). Therefore a pointer from the head and one from the meeting point, both stepping by 1, arrive at the cycle start simultaneously — the head pointer walks a, the other walks a ≡ c − b positions around the cycle. The algebra is the algorithm.

STEP THROUGH · FLOYD ON 1→2→3→4→5→6↲3step 0

    

Solutionattempt it on paper first
def detectCycle(head):
    slow = fast = head
    while fast and fast.next:
        slow, fast = slow.next, fast.next.next
        if slow is fast:               # node identity, never value equality
            p = head
            while p is not slow:
                p, slow = p.next, slow.next
            return p
    return None
Complexity derivationre-derive it first

Slow walks at most a + c steps before the meeting (enters the cycle after a, caught within c); fast at most double that; phase 2 adds a more → O(n) time, O(1) space.

Same skeleton, other jobs

Fast/slow with no cycle = middle finder (§L.4). LC 287 Find the Duplicate Number is this exact algorithm on the implicit list i → nums[i] — the array is secretly a linked list. Recognizing hidden list structure is the senior move there.

§L.6 · TYPE E

Structure as the hashmap — interleaved cloning

LC 138 · Copy List with Random Pointer Deep-copy a list whose nodes carry an extra random pointer to any node (or null).

Brute force (say it first): hashmap original → clone, one pass to create, one to wire next/random → O(n) time average, O(n) space. This is the canonical-key idea from §B.3: the map answers "which clone corresponds to this node?"

The O(1)-space insight: encode that map positionally. Interleave each clone right behind its original (A→A′→B→B′→…). Now "the clone of X" is simply X.next — the list's own structure stores the mapping, so clone.random = orig.random.next wires every random in O(1) each. Unweave to finish.

Solutionattempt it on paper first
def copyRandomList(head):
    cur = head
    while cur:                         # 1. interleave clones
        cur.next = Node(cur.val, cur.next)
        cur = cur.next.next
    cur = head
    while cur:                         # 2. randoms via successor position
        if cur.random:
            cur.next.random = cur.random.next
        cur = cur.next.next
    cur, dummy = head, Node(0)
    tail = dummy
    while cur:                         # 3. unweave — restore the original
        tail.next = cur.next
        tail = tail.next
        cur.next = cur.next.next
        cur = cur.next
    return dummy.next

Pass 3 must leave the original list exactly as received — interviewers check. The if cur.random guard is the null-random edge.

Complexity derivationre-derive it first

Three passes × O(1) per node → O(n) time, O(1) extra space (output aside) — versus the hashmap's O(n). Present both; lead with the trade-off, not the trick.

§L.7 · TYPE F · THE BOSS

Grouped reversal with stitching

LC 25 · Reverse Nodes in k-Group (hard) Reverse every consecutive group of k nodes; a final group shorter than k stays as-is. O(1) space.

Brute force: array of nodes, reverse in slices, rebuild → O(n) space.

Insight: per group, three obligations — (1) prove k nodes exist before touching anything (a counting walk; the leftover rule forbids reversing a short tail), (2) reverse the group with the Type A rotation, seeded so the group's tail exits pointing at the next group's head, (3) stitch the previous group's tail to the group's new head. Seeding prev with the next group's start makes the first rotation do the exit-wiring for free — no patch-up pass.

Invariant per iteration: everything before group_prev is fully processed and correctly linked; the dummy seeds it (same role as §L.3). Each loop turn re-establishes it one group further.

Solutionattempt it on paper first
def reverseKGroup(head, k):
    dummy = ListNode(0, head)
    group_prev = dummy
    while True:
        node, count = group_prev.next, 0
        while node and count < k:      # existence check — touch nothing yet
            node, count = node.next, count + 1
        if count < k:
            return dummy.next          # short tail stays as-is
        tail = group_prev.next         # old head becomes the group's tail
        prev, cur = node, tail         # seed prev = next group's head
        for _ in range(k):
            cur.next, prev, cur = prev, cur, cur.next
        group_prev.next = prev         # stitch: prev is the new group head
        group_prev = tail
Complexity derivationre-derive it first

Nested loops, but count visits per node: one in its group's existence check, one in its group's reversal → ≤ 2n total → O(n) time, O(1) space. The charging argument again — each node pays for its own two visits, exactly like §2.2's "enters once, leaves once".

When this feels derivable — check, rotate with a seeded prev, stitch, advance the invariant — the set is yours. LC 92 (Reverse Linked List II) is one group of this with explicit bounds.

§L.8 · BEFORE YOU SUBMIT + INTERROGATION

Traps, signals & interrogation

Signals → pattern (added to the drill deck in §3.2)

"reverse a list / a section in place"       → three-pointer rotation, invariant first
"n-th from the end, one pass"               → fixed-gap pointers + dummy head
"middle of the list / split in half"        → fast ×2, slow ×1
"is there a cycle / where does it begin"    → Floyd: meet, then head + meeting walk together
"deep copy with arbitrary extra pointers"   → map node→clone, or interleave clones for O(1)
"delete/insert where the head might change" → dummy head — the head becomes a normal node

Interrogation — answer in your journal

L1

Prove the reversal invariant (prev = reversed prefix, cur = untouched suffix, together a partition) is preserved by the tuple assignment. Then write the Java version with the two statements in the wrong order and name exactly what is lost, on [1, 2, 3].

Hint → answer sketchjournal first

Hint: What does the right side capture before anything changes?

Sketch: The RHS tuple (prev, cur, cur.next) is fully evaluated first, so the old cur.next survives the rewiring: one node crosses the boundary, one edge flips, invariant preserved. Java wrong order on [1,2,3]: cur.next = prev runs first, 1 points at None, and nothing points at 2 — the suffix is orphaned.

L2

LC 19: prove the gap argument lands trail exactly on the target's predecessor. Trace [1], n = 1 with and without the dummy, and connect the dummy to the {0:1} seed of §1.4.

Hint → answer sketchjournal first

Hint: The gap never changes after setup.

Sketch: lead starts n+1 ahead; both then step together, so when lead is None (position L+1 from the dummy), trail sits at L−n — the predecessor of the target. [1], n=1: trail = dummy → head deleted cleanly; without the dummy there is no node to stand on. Same job as the {0:1} seed: make the empty prefix a real place.

L3

Floyd, both phases from memory: (1) prove fast meets slow within c steps of slow entering the cycle — why is jump-over impossible? (2) derive a ≡ −b (mod c) and conclude the phase-2 meeting happens at the cycle start.

Hint → answer sketchjournal first

Hint: Watch the gap mod c; then subtract the meeting equation.

Sketch: (1) Once both are in the cycle the gap shrinks by exactly 1 per tick (2 − 1), so it reaches 0 within c ticks — no skipping. (2) fast = 2·slow ⇒ a + b = kc ⇒ a ≡ −b (mod c): a head pointer walking a and a meeting-point pointer walking a arrive together at the cycle start — the second covers b + a ≡ 0 around the cycle.

L4

LC 138: why is orig.random.next exactly the clone's random? Prove pass 3 restores the original list bit-for-bit, and show what breaks on a node whose random is null if the guard is dropped.

Hint → answer sketchjournal first

Hint: Where does every clone sit, structurally?

Sketch: Interleaving pins each clone at original.next, so original.random.next IS the clone of that random — position encodes the map. Pass 3 splices originals to originals and clones to clones, restoring every original pointer. Drop the null guard and cur.random.next dereferences None.

L5

LC 25: the loops are nested — count visits per node to prove ≤ 2n total (which earlier amortized argument is this?). Then trace [1, 2, 3, 4, 5], k = 2 through the first stitch, showing why seeding prev with the next group's head wires the exit for free.

Hint → answer sketchjournal first

Hint: Two visits per node, ever.

Sketch: Each node is touched once by its group's existence check and once by its group's reversal → ≤ 2n (the §2.2 charge). k=2 on [1..5]: tail = 1, prev seeded with 3 — the FIRST rotation points 1 at 3, wiring the group exit before the stitch even happens.

§TR.1 · SET 06 — TREES (DFS · BFS · BST)

Theory

Redundancy it kills: a tree already encodes the divide — every node's subtree is an independent subproblem. The brute force ignores that and re-derives structure per query: "is this subtree balanced?" recomputed heights for every ancestor, "what's on this level?" re-walks from the root — O(n) work per node, O(n²) total. Structural recursion kills it: visit each node once, compute its answer from its children's answers. Post-order ("children before parent") is the tree's version of the prefix sum — an aggregate computed once and reused upward. (Rusty on how recursive calls stack and unwind? §F.2 is the five-minute refresher.)

Three skeletons carry the whole set:

# 1. DFS — structural recursion (post-order shown)
def dfs(node):
    if not node:
        return BASE                    # the empty tree's answer
    left, right = dfs(node.left), dfs(node.right)
    return combine(node, left, right)

# 2. BFS — level snapshot
queue = deque([root])
while queue:
    for _ in range(len(queue)):        # freeze: exactly one level
        node = queue.popleft()
        # enqueue children

# 3. BST identity
# inorder traversal is sorted  ⟺  BST property holds
The invariant — trust the recursion

DFS's invariant IS the inductive hypothesis: dfs(node) returns the fully correct answer for node's entire subtree. You verify the base case, you verify combine() assuming the children's answers are right, and induction does the rest. Engineers who "trace into" the recursion during interviews are signaling they don't trust it — state the hypothesis, verify combine, move on. BFS's invariant: at the top of each while-iteration the queue holds exactly one complete level (the frozen len is what preserves it).

⚠ Hidden requirements

(1) Tree-ness: no cycles, single parent — that's why there is no visited set. Feed a cyclic graph to tree-DFS and it recurses forever; graphs get the visited set in Set 09. (2) Stack depth = tree height: a skewed tree of 10⁵ nodes blows Python's ~1000-frame default recursion limit — the fallback is an explicit-stack iterative version (or raising the limit, with the caveat said out loud). (3) BST tricks assume the property actually holds — and the property is global, not parent-child local (Type C's counterexample).

Complexity tell: "each node visited exactly once, O(1) combine per visit" → O(n) — the tree version of the one-direction argument. Space is the traversal frontier: DFS holds a root-to-leaf path → O(h), which is O(log n) balanced but O(n) skewed — always give both; BFS holds a level → O(w), up to n/2 on a complete tree's bottom level.

In plain English — the same idea, slowernew here? start with this

Think of a company org chart. When the CEO wants a total headcount, they don’t visit every employee — each manager asks their own reports, adds it up, and passes ONE number upward. Every level trusts the level below.

632111reports “3”reports “2”boss adds reports: 3 + 2 + itself = 6
The org chart: each manager reports one number upward; the boss combines reports without ever visiting the employees. That IS structural recursion.

That is structural recursion: each node computes its answer using only its children’s answers — never their raw contents. Your entire job, per problem, is deciding what one-line “report” each subtree must send upward (its height? its sum? whether it’s valid?). Get the report right and the code writes itself.

“Trust the recursion” = trust the managers. You verify two things only: the smallest team reports correctly (base case), and a manager combines correct reports correctly (the recursive step). You never audit the whole company at once — that’s §F.2’s induction trick.

BFS is the other tour: floor by floor of the building instead of department by department. Use it when the QUESTION is about floors (levels, nearest, shortest unweighted path).

§TR.2 · TYPE A

Structural recursion

LC 104 · Maximum Depth of Binary Tree Return the number of nodes on the longest root-to-leaf path.

Easy-tier, included because it IS the archetype — the three-line shape every harder type decorates.

Derivation: the longest path from a node goes through whichever child has the deeper subtree, plus the node itself: depth(node) = 1 + max(depth(left), depth(right)), with the empty tree contributing 0. That recurrence is the code.

Check yourself: maxDepth of the tree [1, null, 2]?

2. depth(1) = 1 + max(depth(None), depth(2)) = 1 + max(0, 1). The empty side contributes 0 — that base case is doing the work.

Solutionattempt it on paper first
def maxDepth(root):
    if not root:
        return 0
    return 1 + max(maxDepth(root.left), maxDepth(root.right))
Complexity derivationre-derive it first

T(n) = T(n_left) + T(n_right) + O(1); every node contributes exactly one O(1) frame → O(n) time. Space: the recursion stack holds one root-to-leaf path → O(h) — say "log n balanced, n skewed" unprompted.

The upgrade path

LC 110 (Balanced) and LC 543 (Diameter) are this recursion returning a pair (height + verdict) instead of one number — the naive versions that recompute heights per ancestor are O(n²) on a chain. "Return more, recompute never" is the whole trick, and it's §L.6's payload question again: what must a subtree report upward?

§TR.3 · TYPE B

BFS — the level snapshot

LC 199 · Binary Tree Right Side View Return the value visible from the right at every level.

Brute force: for each depth d, DFS to find the rightmost node at depth d → O(n) per level, O(n²) on a right chain.

Redundancy: every per-level search re-walks the same upper tree. One traversal can classify every node by level as it goes.

Insight: the queue naturally holds levels — but only if you freeze the boundary. Snapshot len(queue) before the inner loop: exactly that many dequeues belong to the current level, and everything enqueued during them is the next level. The last node dequeued in the snapshot is the level's rightmost.

Solutionattempt it on paper first
from collections import deque

def rightSideView(root):
    if not root:
        return []
    res, queue = [], deque([root])
    while queue:
        for _ in range(len(queue)):    # frozen level boundary
            node = queue.popleft()
            if node.left:
                queue.append(node.left)
            if node.right:
                queue.append(node.right)
        res.append(node.val)           # last dequeued = rightmost
    return res
Complexity derivationre-derive it first

Each node enqueued once, dequeued once → O(n) time (the enters-once/leaves-once charging argument of §2.2, on a queue). Space: the widest level — up to ⌈n/2⌉ on a complete tree → O(n) worst case, and note BFS space is the width where DFS space is the height; picking the traversal is picking which one you can afford.

⚠ The deque matters

list.pop(0) as a dequeue is O(n) per call → quiet O(n²) — the exact trap family as §S.8's pop(0) stack. Python: collections.deque. JS has no native deque — use an index pointer into the array, never shift() in a loop.

§TR.4 · TYPE C

BST — range narrowing

LC 98 · Validate Binary Search Tree Decide whether a binary tree satisfies the BST property. Duplicates invalid.

The trap IS the problem: the BST property is globalevery node in a left subtree is smaller than the root, not just the immediate child. The parent-child local check accepts [5, 1, 4, null, null, 3, 6]: node 3 sits in 5's right subtree but 3 < 5. Most wrong answers on this problem are exactly that.

Derivation: each edge adds one constraint — going left caps values above, going right floors them below. A node is legal iff it respects all ancestor constraints, and the pair (lo, hi) carries all of them in O(1): descend left → hi becomes node.val; descend right → lo does. Strict inequalities because duplicates are invalid here — always ask the duplicates policy before choosing < vs ≤.

Solutionattempt it on paper first
def isValidBST(root):
    def valid(node, lo, hi):
        if not node:
            return True
        if not (lo < node.val < hi):
            return False
        return (valid(node.left, lo, node.val) and
                valid(node.right, node.val, hi))
    return valid(root, float('-inf'), float('inf'))

Equivalent second proof: the inorder traversal must be strictly increasing — the BST identity as a monotonicity check (§BS.1's predicate thinking). Carry one prev value; no arrays needed.

Complexity derivationre-derive it first

One visit, O(1) comparisons per node → O(n) time, O(h) stack. Java gotcha: ±∞ sentinels as int min/max break when node values touch the int limits — use longs or nullable bounds; Python's floats and big ints are immune, say so.

The identity that pays rent

Inorder over a BST = a sorted stream. LC 230 (Kth Smallest) is "stop the stream at k". BST search/insert follow one root-to-leaf path → O(h) — binary search's discard proof (§BS.2) walking a structure instead of indices. When a problem says BST and you're not using sorted order, you've missed the intended solution.

§TR.5 · TYPE D

Lowest common ancestor — post-order reporting

LC 236 · Lowest Common Ancestor of a Binary Tree Given two nodes p and q, return the deepest node having both as descendants (a node counts as its own descendant).

Brute force: record the root→p and root→q paths (two O(n) searches), then compare paths for the last shared node → O(n) time but O(n) extra space and two passes.

Redundancy: both path searches walk the same upper tree, and the comparison re-walks the paths. One post-order pass can do all three jobs.

Derivation — specify the return value, then trust it (§TR.1): let dfs(node) return the LCA if both p and q live in node's subtree; else p or q if that one is here; else None. Base: null or hitting p/q returns itself. Combine: if both children report a find, p and q lie in different subtrees — this node is the deepest split point, i.e. the LCA. If one side reports, pass it up unchanged. The early stop at p is safe even when q is p's descendant: the LCA is p itself in that case, so nothing below p needs visiting.

Solutionattempt it on paper first
def lowestCommonAncestor(root, p, q):
    if not root or root is p or root is q:
        return root
    left = lowestCommonAncestor(root.left, p, q)
    right = lowestCommonAncestor(root.right, p, q)
    if left and right:
        return root                    # p and q split here — the LCA
    return left or right

Node identity (is) again, not value equality — same rule as Floyd's meeting test (§L.5).

Complexity derivationre-derive it first

One visit per node, O(1) combine → O(n) time, O(h) stack, zero extra structures — strictly dominating the two-path brute force. BST variant (LC 235): the split point is where p and q straddle the value — O(h) by range discarding.

§TR.6 · TYPE E

Reconstruction from traversals

LC 105 · Construct Binary Tree from Preorder and Inorder Rebuild the unique binary tree from its preorder and inorder sequences (values distinct).

The identity: preorder = root, then the entire left subtree, then the right; inorder = left subtree, root, right. So preorder hands you each root in construction order, and the root's position in inorder splits the remaining values into the two subtrees.

The quiet quadratic (say it, then kill it): the natural code calls inorder.index(root) and slices both arrays per node — O(n) searching + O(n) copying per node → Θ(n²) on a chain like a strictly-decreasing sequence. It passes every small test. Two fixes, both from earlier sets: a value→position hashmap (the canonical-key payload, §B.3) kills the search; window bounds instead of slices kill the copying; and a single advancing pointer consumes preorder — the one-direction argument yet again.

Solutionattempt it on paper first
def buildTree(preorder, inorder):
    idx = {v: i for i, v in enumerate(inorder)}   # value -> inorder position
    pre_i = 0
    def build(lo, hi):                 # inorder window [lo, hi)
        nonlocal pre_i
        if lo >= hi:
            return None
        root = TreeNode(preorder[pre_i])
        pre_i += 1
        mid = idx[root.val]
        root.left = build(lo, mid)     # MUST build left first —
        root.right = build(mid + 1, hi)  # preorder is consumed in that order
        return root
    return build(0, len(inorder))

The left-before-right ordering is a correctness constraint, not style — the preorder pointer's next value is the left subtree's root. Same "the order of two lines is the algorithm" discipline as check-before-insert (§1.4).

Complexity derivationre-derive it first

Map build: n. Each node: one O(1)-average map lookup, one pointer advance, O(1) window arithmetic → O(n) time average, O(n) space for the map + O(h) stack. From Θ(n²) to O(n) without changing the idea — only the data access.

§TR.7 · TYPE F · THE BOSS

Max path sum — the two-quantity recursion

LC 124 · Binary Tree Maximum Path Sum (hard) A path is any node sequence connected by edges, no revisits; find the maximum path sum. Values may be negative.

Brute force: enumerate all node pairs, sum the path between them → O(n²) pairs × O(n) path = O(n³); even with LCA tricks, O(n²).

The organizing observation: every path has a unique highest node — its turning point. Enumerate paths by turning point and each node need only answer: best path that turns here = node.val + best downward arm on the left + best downward arm on the right.

The two-quantity split (this is the whole problem): what a node reports upward is different from what it scores locally. Upward must be a single arm — node.val + max(one arm) — because a parent's path through this node cannot fork into both children (it would need to visit this node twice). Locally it may use both arms. Conflating these two is the classic wrong answer.

The clamp is a discard proof: max(arm, 0) — a negative arm strictly lowers every path that includes it, so the empty arm dominates it (the exchange argument, §T.4/§S.5, in one character). But the node itself can't be discarded — a path must contain at least one node — which is why best starts at −∞, not 0: on the tree [−3] the answer is −3.

Solutionattempt it on paper first
def maxPathSum(root):
    best = float('-inf')
    def gain(node):                    # best single downward arm from node
        nonlocal best
        if not node:
            return 0
        left = max(gain(node.left), 0)
        right = max(gain(node.right), 0)
        best = max(best, node.val + left + right)   # path turning here
        return node.val + max(left, right)          # one arm continues up
    gain(root)
    return best
Complexity derivationre-derive it first

One post-order visit, O(1) per node → O(n) time, O(h) stack. From O(n³) to O(n) purely by choosing the right per-node quantities — the "return more, recompute never" idea of §TR.2 at full strength.

When this feels derivable — unique turning point, two quantities, clamp-as-discard — the set is yours. LC 543 (Diameter) is this with sum replaced by edge count; LC 687 with equality constraints.

§TR.8 · BEFORE YOU SUBMIT + INTERROGATION

Traps, signals & interrogation

Signals → pattern (added to the drill deck in §3.2)

"depth / count / sum over a tree"               → post-order structural recursion
"level by level / right side view / zigzag"     → BFS with a frozen level snapshot
"validate BST / use the sorted structure"       → range narrowing, or inorder is sorted
"k-th smallest in a BST"                        → inorder = sorted stream, stop at k
"lowest common ancestor"                        → post-order: the node where both sides report
"rebuild a tree from traversals"                → preorder pointer + inorder index map
"best path / diameter through a tree"           → two arms locally, one arm reported upward

Interrogation — answer in your journal

R1

State LC 236's return-value contract and prove it by induction. Then show why the early return at root is p stays correct when q is a descendant of p.

Hint → answer sketchjournal first

Hint: State the contract, then check each return case.

Sketch: Contract: LCA if both targets live in the subtree; else whichever one is present; else None. Bases hold; both children reporting ⇒ deepest split (post-order surfaces the deepest first); one child reporting passes through. If q sits under p, the LCA is p by definition — nothing below p needs visiting, so the early stop is safe.

R2

The naive balanced-tree check (recompute height per node) — derive its complexity on a skewed chain AND on a perfectly balanced tree (they differ). Then give the single-pass fix and name the general principle.

Hint → answer sketchjournal first

Hint: Sum per-node height costs on each shape.

Sketch: Chain: computing height at every ancestor costs 1+2+…+n = O(n²). Balanced: each node pays its subtree size → per level O(n), × log n levels = O(n log n). Fix: one post-order returning (height, balanced) — O(n). Principle: return more, recompute never.

R3

Trace [5,1,4,null,null,3,6] through the local parent-child BST check (show where it wrongly passes) and through range narrowing (show the exact interval node 3 violates).

Hint → answer sketchjournal first

Hint: Which ancestor's constraint does node 3 break?

Sketch: Local parent-child: 5 vs (1,4) fine, 4 vs (3,6) fine — passes. Range check: stepping right from 5 sets lo = 5; node 3 must lie in (5, 6) and 3 ≤ 5 fails. The (lo, hi) interval carries EVERY ancestor constraint in O(1) — that's the whole point.

R4

Prove the slicing version of LC 105 is Θ(n²) on a strictly-decreasing preorder. Derive the O(n) of the index-map version, and explain why building right before left corrupts the tree.

Hint → answer sketchjournal first

Hint: How much does each .index() cost on a chain?

Sketch: A strictly decreasing preorder is a left chain: .index() scans O(n) per node plus O(n) slice copies → Θ(n²) while passing every small test. Index map = O(1) average lookups; window bounds kill the copying → O(n). Left before right because the preorder pointer's next value IS the left subtree's root.

R5

LC 124: prove every path has a unique turning point; prove the upward report must be a single arm; then construct one input broken by omitting max(arm, 0) and one broken by initializing best to 0.

Hint → answer sketchjournal first

Hint: Every path has one apex; what may a parent extend?

Sketch: A tree path changes direction exactly once — unique turning point, so scoring per node covers all paths once. A parent extending BOTH arms would visit the node twice — illegal, hence one-arm return. max(arm, 0) omitted: [2, −1] returns 1 instead of 2. best = 0 instead of −∞: [−3] must answer −3 — a path needs at least one node.

§H.1 · SET 07 — HEAP & TOP-K

Theory

Redundancy it kills: full sorting when only the extreme is ever consumed. Sorting buys a total order — n log n of information — but "give me the current min" uses one bit of it, over and over. The naive alternatives both re-do work: re-scan for the min per extraction (O(n) each → O(n²)) or keep the collection sorted per insertion (O(n) shifting each). The heap maintains exactly the guarantee consumed — a partial order strong enough to certify the root — at O(log n) per update. Pay only for the order you use.

The structure: a complete binary tree stored flat in an array — children of index i at 2i+1 and 2i+2, no pointers. Min-heap property: every parent ≤ its children.

103122735445661327546
One structure, two views: the flat array IS the tree. children(i) = 2i+1, 2i+2 · parent(i) = (i−1)//2. Highlighted: i = 1 and its children at 3 and 4.
The invariant — and how little it promises

Every subtree's root is that subtree's minimum. This certifies heap[0] is the global min while promising nothing about siblings — the array is NOT sorted, and heap[:k] is NOT the k smallest. Push = append + sift-up (≤ height swaps); pop = swap root with last + sift-down. Height of a complete tree = ⌊log₂ n⌋, which bounds both.

Two facts interviewers probe directly:

  • heapify is O(n), not O(n log n). Build bottom-up with sift-downs: the n/2 leaves cost 0, and ≤ n/2ʰ⁺¹ nodes sit at height h costing h each → Σ h·n/2ʰ⁺¹ = n·Σ h/2ʰ⁺¹ = n·1 = O(n). (Sift-up building is genuinely O(n log n) — the deep, numerous nodes pay the tall cost. Direction matters.)
  • The size-k inversion: to track the k LARGEST, keep a MIN-heap of size k — its root is the admission threshold. Anything ≤ the root is provably outside the top k (k items ≥ it already exist). Min for largest, max for smallest — the single most common recognition trip-up in this pattern.
⚠ Hidden requirements

(1) A heap answers only "current extreme" — no search, no arbitrary delete, no ordered iteration; need those → pair it with a hashmap (lazy deletion, §H.7) or use a sorted container. (2) Elements must be totally comparable — Python compares tuples lexicographically and falls through ties to the payload, crashing on uncomparable objects (the famous LC 23 TypeError); tie-break with a counter. (3) Python's heapq is min-only — negate keys for max. (4) One-shot static top-k has cheaper tools: quickselect O(n) average, or buckets when the domain is bounded (§B.4's escalation ladder) — the heap earns its keep when data arrives (streams) or when k sources must be merged.

Complexity tell: "n operations × log(heap size)" — and the heap size is a design choice: cap it at k and the bound is O(n log k), at 26 letters and log is a constant. Constraint smell: "stream", "online", "as they arrive" ⇒ heap; static array + single query ⇒ quickselect/buckets.

In plain English — the same idea, slowernew here? start with this

A heap is an emergency room, not a sorted waiting list. The ER never sorts all patients — it only ever needs to answer one question, over and over: “who is most urgent RIGHT NOW?” The heap keeps just that one promise, cheaply, and deliberately leaves everything else messy. The messiness IS the speed: you don’t pay for order you never use.

The size-k trick, in plain words: to track the k biggest things you’ve ever seen, keep an elite club of k members where the weakest current member guards the door. A newcomer either beats the doorkeeper (kick the doorkeeper out, newcomer joins) or doesn’t (newcomer leaves forever — there are already k members stronger than it). That’s why tracking the LARGEST k uses a MIN-heap: the doorkeeper is the smallest of the elite. This inversion trips everyone once; let it trip you here, not in the interview.

When NOT to use it: if all the data is already sitting in front of you and you need the answer once, cheaper tools exist (quickselect, buckets). The heap earns its keep when data keeps arriving.

§H.2 · TYPE A

The size-k heap

LC 215 · Kth Largest Element in an Array Return the k-th largest element (not distinct).

Brute force: sort, index from the end → O(n log n).

Redundancy: a total order was purchased; one order statistic was consumed.

The discard proof: maintain a min-heap holding the k largest seen so far. When x ≤ heap[0], there are already k elements ≥ heap[0] ≥ x — so x can never be among the k largest, and skipping it is permanent (the discard sentence of §T.1, applied to values instead of positions). When x beats the root, the root is the element it displaces.

Check yourself: k = 2, stream 5, 1, 9 — final heap contents?

[5, 9] with root 5. Start [5, 1] (root 1); 9 > 1 evicts the 1. The root is the 2nd largest — exactly the answer.

Solutionattempt it on paper first
import heapq

def findKthLargest(nums, k):
    heap = nums[:k]
    heapq.heapify(heap)                # O(k) — bottom-up, not k pushes
    for x in nums[k:]:
        if x > heap[0]:
            heapq.heapreplace(heap, x) # pop root + push, one sift
    return heap[0]
Complexity derivationre-derive it first

Heapify k + (n − k) comparisons, each with at most one O(log k) replace → O(n log k) time, O(k) space. For static one-shot queries, quickselect gives O(n) average / O(n²) worst (say both halves) — the full escalation ladder is sort → heap → select, §B.4's ladder with one more rung.

§H.3 · TYPE B

Keyed comparison with payloads

LC 973 · K Closest Points to Origin Return the k points closest to (0, 0).

What this adds to Type A: the ranking key is computed (distance), the payload (the point) must ride along, and the polarity flips — k closest means evicting the farthest, so the size-k heap must expose the current farthest: a max-heap, via negation in Python.

Skip the sqrt: x² + y² preserves the ordering because sqrt is monotonic — comparing through a monotonic transform never changes a comparison's outcome (§BS.1's monotonicity, used as a micro-optimization). Also dodges float precision entirely.

Solutionattempt it on paper first
def kClosest(points, k):
    heap = []                          # (-dist², x, y): max-heap by negation
    for x, y in points:
        d = x*x + y*y
        if len(heap) < k:
            heapq.heappush(heap, (-d, x, y))
        elif -d > heap[0][0]:
            heapq.heapreplace(heap, (-d, x, y))
    return [[x, y] for _, x, y in heap]

Output order is unspecified by the problem — good, because the heap's array is level-order, not sorted (§H.1's "how little it promises").

Complexity derivationre-derive it first

n iterations × O(log k) worst → O(n log k), O(k) space. Alternatives to name: sort O(n log n); quickselect O(n) average — the interview move is presenting all three and letting the constraints pick.

§H.4 · TYPE C

K-way merge — frontier expansion

LC 373 · Find K Pairs with Smallest Sums Two sorted arrays; return the k pairs (u, v) with the smallest sums.

Brute force: form all n·m sums, sort, take k → O(nm log nm). With n = m = 10⁵, the grid alone is 10¹⁰ — unbuildable.

Redundancy: the sum grid is sorted along every row and every column; a global sort ignores that almost all pairs are provably behind smaller ones.

The frontier insight: the smallest pair is (0,0). After popping (i, j), the only newly eligible candidate is its successor in one direction — seed the heap with column 0 (pairs (i, 0) for i < min(k, n)) and push (i, j+1) on each pop. Every pair then has exactly one predecessor that pushes it, so no visited set is needed — dedup by construction.

The proof: invariant — for every not-yet-popped pair, some ancestor of it (same i, smaller j, or its seed) sits in the heap with sum ≤ its sum, because sums rise along rows. So the heap's min is always the global next-smallest: nothing smaller can be hiding outside the heap. This "expand the cheapest frontier node" argument IS Dijkstra's — Set 09 will reuse it on graphs verbatim.

Solutionattempt it on paper first
def kSmallestPairs(nums1, nums2, k):
    heap = [(nums1[i] + nums2[0], i, 0)
            for i in range(min(k, len(nums1)))]
    heapq.heapify(heap)
    res = []
    while heap and len(res) < k:
        s, i, j = heapq.heappop(heap)
        res.append([nums1[i], nums2[j]])
        if j + 1 < len(nums2):
            heapq.heappush(heap, (nums1[i] + nums2[j + 1], i, j + 1))
    return res
Complexity derivationre-derive it first

k pops, ≤ 1 push each, heap ≤ min(k, n) entries → O(k log min(k, n)) — independent of n·m. That independence is the headline; say it. LC 23 (Merge k Sorted Lists) is this exact shape: seed the k heads, pop the min, push its successor → O(N log k) for N total nodes — with the tie-breaking counter of §H.7, or it crashes.

§H.5 · TYPE D

Greedy scheduling by frequency

LC 621 · Task Scheduler Tasks A–Z, identical tasks need a cooldown of n slots between runs; minimize total slots (idles count).

The greedy: at every slot, run the most-frequent ready task. Exchange argument (§T.4, §S.5): the most frequent task generates the most future cooldown constraints; swapping it earlier in any schedule never adds idle time, so greedy-by-frequency is never suboptimal. The heap is just the machine that serves "most frequent ready" in O(log 26).

The mechanism: a max-heap of remaining counts plus a FIFO cooldown queue of (ready_time, count) — a task pops off the heap to run, then waits in the queue until time+n+1. The queue is FIFO-correct because tasks re-become ready in the order they ran.

Solutionattempt it on paper first
from collections import deque

def leastInterval(tasks, n):
    counts = {}
    for t in tasks:
        counts[t] = counts.get(t, 0) + 1
    heap = [-c for c in counts.values()]
    heapq.heapify(heap)
    cooldown = deque()                 # (ready_time, negated remaining)
    time = 0
    while heap or cooldown:
        time += 1
        if cooldown and cooldown[0][0] == time:
            heapq.heappush(heap, cooldown.popleft()[1])
        if heap:
            c = heapq.heappop(heap) + 1        # run once (counts negated)
            if c:
                cooldown.append((time + n + 1, c))
        # else: forced idle tick
Complexity derivationre-derive it first

T ticks (T = the answer) × O(log 26) heap work → O(T), since a 26-key alphabet makes the log a constant — the bounded-domain observation of §B.4 again. The closed form max(len(tasks), (f_max − 1)(n + 1) + #f_max) answers in O(n) counting — derive it from "the most frequent task pins the skeleton, others fill the gaps"; code the heap version because it generalizes (LC 767, LC 358).

§H.6 · TYPE E · THE BOSS

Two heaps — the running median

LC 295 · Find Median from Data Stream (hard) Support addNum and findMedian over a growing stream.

Brute force: keep a sorted array — O(n) insertion shifting per add (or re-sort, O(n log n)).

Redundancy: full order maintained when only the boundary between halves is consumed — the mirror image of top-k, where only the extreme mattered.

The construction: a max-heap lo holding the smaller half and a min-heap hi holding the larger half — the median lives at their facing roots. Two invariants: order (max(lo) ≤ min(hi)) and balance (0 ≤ len(lo) − len(hi) ≤ 1).

The insertion dance, derived: pushing num into lo unconditionally may violate order — so immediately move lo's max across to hi. Now order holds whatever num was: what crossed over is ≥ everything left in lo, and it entered hi where it competes normally. But hi may now be too big — if so, move hi's min back. Each step repairs exactly one invariant and cannot break the other (prove that; it's interrogation H4). Three heap ops, all O(log n).

Solutionattempt it on paper first
class MedianFinder:
    def __init__(self):
        self.lo = []                   # max-heap (negated): smaller half
        self.hi = []                   # min-heap: larger half
    def addNum(self, num):
        heapq.heappush(self.lo, -num)
        heapq.heappush(self.hi, -heapq.heappop(self.lo))   # repair order
        if len(self.hi) > len(self.lo):
            heapq.heappush(self.lo, -heapq.heappop(self.hi))  # repair balance
    def findMedian(self):
        if len(self.lo) > len(self.hi):
            return float(-self.lo[0])
        return (-self.lo[0] + self.hi[0]) / 2
Complexity derivationre-derive it first

addNum: ≤ 3 heap ops → O(log n); findMedian: O(1); space O(n). The follow-ups interviewers attach: values bounded in [0, 100] → 101 counting buckets, O(1) add (bounded domain, §B.4, third appearance); sliding-window median → lazy deletion (§H.7).

When this feels derivable — two invariants, a dance that repairs them one at a time — the set is yours. The two-heap split generalizes to any "boundary statistic": percentiles, IPO-style capital problems (LC 502), scheduling with both ends.

§H.7 · BEFORE YOU SUBMIT + INTERROGATION

Traps, signals & interrogation

Signals → pattern (added to the drill deck in §3.2)

"k largest / smallest / closest, especially streaming"  → size-k heap of the OPPOSITE polarity
"merge k sorted lists / arrays"                         → heap of heads: pop min, push its successor
"running median / percentile of a stream"               → two heaps, order + balance invariants
"schedule by frequency with cooldown"                   → max-heap + FIFO cooldown queue
"k smallest sums / products from sorted sources"        → frontier expansion, dedup by predecessor
"need current min AND max simultaneously"               → two heaps or a sorted container — one heap can't

Interrogation — answer in your journal

H1

Derive heapify's O(n) bound via the sum-of-heights argument. Why does the same argument NOT rescue sift-up-based building — which nodes pay, and how much?

Hint → answer sketchjournal first

Hint: Count nodes at each height, times that height.

Sketch: ≤ n/2^(h+1) nodes sit at height h, each sift-down O(h): Σ h·n/2^(h+1) = n·Σ h/2^(h+1) = n. Sift-up building flips the weights: the n/2 LEAVES may each bubble log n levels → Θ(n log n). The direction decides who pays.

H2

Prove the size-k discard: when x ≤ the min-heap's root, x can never be among the k largest. Then state the polarity inversion in one interview-ready sentence.

Hint → answer sketchjournal first

Hint: How many elements already beat x?

Sketch: If x ≤ root, the heap holds k elements all ≥ root ≥ x — x can never crack the top k, so skipping is permanent. Sentence: 'the min-heap's root is the admission bar for the top-k club — clear it or you're out.'

H3

LC 373: state the frontier invariant and prove the popped pair is always the global next-smallest. Why does seeding column 0 and pushing only (i, j+1) reach every pair exactly once with no visited set?

Hint → answer sketchjournal first

Hint: Every unpopped pair has an in-heap ancestor with a smaller sum.

Sketch: Rows sorted ⇒ any unpopped (i, j) has (i, j−1)-lineage back to an in-heap entry with sum ≤ it. So the heap min is the global next-smallest — nothing smaller hides outside. Uniqueness: (i, j) is pushed only by (i, j−1); seeds cover j = 0 — one producer per pair, no visited set.

H4

LC 295: prove the three-step insertion restores both invariants regardless of which half num belongs to — each step repairs one without breaking the other. Trace adds 5, 2, 8, 1, showing both heaps after each.

Hint → answer sketchjournal first

Hint: Each step repairs one invariant; show it can't break the other.

Sketch: Push into lo, then move lo's max across: the crossing element is ≥ everything left in lo → order restored no matter where num belonged. The balance move returns hi's MIN — still ≥ all of lo → order intact. Trace 5,2,8,1: lo = {2,1}, hi = {5,8} → median (2+5)/2 = 3.5.

H5

Construct the exact LC 23 input that crashes Python without a tie-breaking counter, explain the tuple-comparison mechanics that cause it, and derive the O(N log k) bound of the fixed version.

Hint → answer sketchjournal first

Hint: What does Python compare when the first field ties?

Sketch: Two lists with equal head values: (val, node) ties on val and falls through to comparing ListNodes → TypeError. Fix: (val, i, node) with a running counter — i always breaks ties. Each of N nodes passes once through a heap of ≤ k entries → O(N log k).

§G.1 · SET 08 — INTERVALS & GREEDY

Theory

Two redundancies, one set:

  • Intervals: the brute force checks overlap pairwise — O(n²) comparisons — but overlap is a local property once you sort: an interval can only interact with the current open block, never with anything already closed. Sort once, sweep once converts a global pairwise question into an adjacent one: O(n²) → O(n log n).
  • Greedy: the brute force explores orderings and subsets (exponential, or DP) when a provably-safe local rule decides everything. The redundancy is the exploration itself — every branch the proof rules out.

The merge-sweep skeleton:

intervals.sort(key=lambda iv: iv[0])   # by START — for merging
merged = [intervals[0]]
for s, e in intervals[1:]:
    if s <= merged[-1][1]:             # touches the open block
        merged[-1][1] = max(merged[-1][1], e)
    else:
        merged.append([s, e])          # block closes forever
12345678910[1,4][3,6][8,10][1,6][8,10]
Sorted by start, [3,6] can only touch the OPEN block [1,4]; when [8,10] starts past 6, the block closes forever.
The invariant — why closed blocks stay closed

merged is disjoint, sorted, and only its last block can still grow. Proof: a block closes only when some interval starts strictly after its end; every future interval starts later still (start-sorted), so nothing can ever reach back. The one-direction argument (§2.2) wearing interval clothing.

The exchange argument — greedy's proof engine, now the headline

To prove a greedy rule optimal: take ANY optimal solution, find its first divergence from greedy's choice, and swap greedy's choice in — show feasibility and value are preserved. Induction finishes it. You've already run this proof three times: move-the-shorter-wall (§T.4), pop-the-larger-digit (§S.5), run-the-most-frequent (§H.5). This set is where it becomes a deliberate tool: no exchange proof, no greedy.

⚠ Hidden requirements

(1) The sort key IS the algorithm: merging sorts by START; interval scheduling sorts by END. Swap them and both fail quietly (§G.3, §G.8). (2) Boundary semantics: is [1,2] vs [2,3] an overlap? LC 56 says merge them; meeting rooms frees the room at equality. One ≤ vs < decides — ask before coding. (3) Greedy needs the proof: "feels greedy" without an exchange argument is the classic silent wrong answer — and when no safe local rule exists (weighted intervals, counting arrangements), the fallback is DP (Set 12).

Complexity tell: the sort dominates → O(n log n), sweep O(n) on top. Constraint smell: 10⁵ intervals ⇒ sort + sweep is what's expected; greedy sweeps without sorting (Jump Game, Gas Station) are O(n)/O(1).

In plain English — the same idea, slowernew here? start with this

Intervals: think of a wall calendar. Checking every meeting against every other meeting is chaos. Write them in time order, and suddenly a meeting can only conflict with its neighbor — everything earlier has provably ended. One sort turns “compare all with all” into “walk once, compare with the current open block”. That’s the whole intervals half.

Greedy, in plain words: a greedy rule is a bet — “always pick the meeting that ends earliest” — and the exchange argument is the receipt that the bet is safe. The receipt always reads the same way: take any perfect schedule, find the first place it disagrees with greedy, swap greedy’s choice in, and show the schedule got no worse. If you can write that receipt, greedy is proven. If you can’t, greedy is a guess — and the interviewer knows the counterexample.

Also keep the two warnings above in your pocket: the sort KEY is the algorithm (start-sort for merging, END-sort for choosing), and “does touching count as overlapping?” is a question you ask before writing a single comparison.

§G.2 · TYPE A

Merge sweep

LC 56 · Merge Intervals Merge all overlapping intervals (touching counts as overlapping here).

Brute force: repeatedly find any overlapping pair, merge, restart until a fixed point → O(n²) per pass, up to n passes.

Redundancy: almost every pair compared cannot overlap — sorting makes the only possible interaction "next interval vs the open block".

Check yourself: [[1,4],[2,3]] — merged output?

[[1, 4]]. 2 ≤ 4 so they merge, and max(4, 3) keeps the end at 4. Overwriting instead of max-ing gives the wrong [[1, 3]] — the classic bug here.

Solutionattempt it on paper first
def merge(intervals):
    intervals.sort(key=lambda iv: iv[0])
    merged = [intervals[0]]
    for s, e in intervals[1:]:
        if s <= merged[-1][1]:
            merged[-1][1] = max(merged[-1][1], e)   # extend, don't replace
        else:
            merged.append([s, e])
    return merged

The max() matters: a later-starting interval can still end earlier ([[1,10],[2,3]]) — overwriting the end instead of maxing it is the classic wrong answer here.

Complexity derivationre-derive it first

Sort n log n + one O(n) sweep → O(n log n), O(n) output. LC 57 (Insert Interval) is the same sweep in three phases — before / overlapping / after — and runs O(n) because the input is already sorted; re-sorting concedes the point.

§G.3 · TYPE B

Interval scheduling — earliest end first

LC 435 · Non-overlapping Intervals Remove the minimum number of intervals so the rest don't overlap (touching is fine).

Reframe first: minimize removals = keep the maximum non-overlapping set — the classic interval scheduling problem.

The rule: sort by END; always keep the interval that ends earliest among those compatible. Exchange proof in full: let OPT be any optimal solution, ordered by end. Greedy's first pick g ends no later than OPT's first pick o (g is the earliest-ending interval, period). Replace o with g: still disjoint from the rest of OPT (everything else starts after o ends ≥ g ends), same size. Repeat down the sequence — greedy never falls behind. Ending earliest leaves maximal room; that intuition is now a theorem.

Both tempting greedies fail: earliest-START keeps [1,100] and loses [2,3],[4,5]; SHORTEST-first on [[1,10],[9,12],[11,20]] picks [9,12], which blocks both others (optimal keeps two). Counterexamples are how you earn the right to greedy.

Solutionattempt it on paper first
def eraseOverlapIntervals(intervals):
    intervals.sort(key=lambda iv: iv[1])   # by END — this line is the algorithm
    kept, last_end = 0, float('-inf')
    for s, e in intervals:
        if s >= last_end:                  # touching allowed here
            kept += 1
            last_end = e
    return len(intervals) - kept
Complexity derivationre-derive it first

Sort + sweep → O(n log n), O(1) extra. Same skeleton solves LC 452 (burst balloons with arrows) — arrows = the kept set's size.

§G.4 · TYPE C

Max concurrency — the event sweep

LC 253 · Meeting Rooms II Minimum number of rooms to host all meetings.

Reframe: min rooms = maximum number of meetings running at once — a counting question over time.

The audacious move: sort starts and ends independently, destroying which end belongs to which start. Legal because room count over time depends only on the multiset of events — each start is +1, each end is −1, and the running sum doesn't care about pairing. (That running sum of deltas is literally a prefix sum over events — §1.1 has been here all along.)

The sweep: walk the starts; if the earliest un-consumed end is ≤ this start, a room has freed — reuse it. Otherwise open a new room. The end pointer only moves forward: the one-direction argument again.

Solutionattempt it on paper first
def minMeetingRooms(intervals):
    starts = sorted(iv[0] for iv in intervals)
    ends   = sorted(iv[1] for iv in intervals)
    rooms, e = 0, 0
    for s in starts:
        if s >= ends[e]:
            e += 1                     # earliest-ending meeting freed a room
        else:
            rooms += 1
    return rooms

The ≥ encodes "a meeting ending at t frees its room for a meeting starting at t" — the boundary-semantics decision made explicit. The min-heap-of-end-times version (§H.4's "earliest frontier" thinking) gives the same O(n log n) and generalizes when you must know which room.

Complexity derivationre-derive it first

Two sorts + one sweep → O(n log n), O(n) for the sorted arrays. Rooms never decrements in this formulation — it counts peak concurrency directly (prove that's what the else-branch accumulates; interrogation G3).

§G.5 · TYPE D

Reachability — the furthest-reach sweep

LC 55 · Jump Game nums[i] is your max jump from i; can you reach the last index?

Brute force: DFS/DP over "which positions are reachable" → O(n²).

Redundancy: reachability from the left is an interval [0, reach] — tracking which cells are reachable re-derives what one frontier number already says.

Invariant: after processing index i, reach = the maximum index reachable using only positions 0..i. If i itself exceeds reach, there's a gap no earlier position can cross — reachability is monotone, so fail immediately. No sorting needed: greedy isn't intervals-only.

Solutionattempt it on paper first
def canJump(nums):
    reach = 0
    for i, step in enumerate(nums):
        if i > reach:
            return False               # unreachable gap — nothing later helps
        reach = max(reach, i + step)
    return True

The check must precede the update — a position you can't stand on contributes no reach. LC 45 (min jumps) is this with a second frontier: BFS levels over the same interval, no queue needed.

Complexity derivationre-derive it first

One pass, O(1) per index → O(n) time, O(1) space — versus the O(n²) DP that interviewers watch you not write.

§G.6 · TYPE E

Circular start — discard the failed prefix

LC 134 · Gas Station Circular route; gas[i] gained, cost[i] to leave station i. Find the unique valid start, or −1.

Brute force: simulate the full circle from every start → O(n²).

The discard proof (this is the problem): run with a tank from start s; suppose it first dips negative at i. Then no station in (s..i] can be the answer either: starting anywhere inside means arriving at i with a tank ≤ the one that just failed — you forfeited the non-negative fuel accumulated between s and there. One failure discards the whole prefix; restart at i+1. Same shape as §T's "the discarded region cannot contain the answer", applied to start positions.

The global certificate: if total gain Σ(gas−cost) ≥ 0, an answer exists — so the surviving candidate needs no second simulation lap. Two facts, one pass.

Solutionattempt it on paper first
def canCompleteCircuit(gas, cost):
    total = tank = start = 0
    for i in range(len(gas)):
        gain = gas[i] - cost[i]
        total += gain
        tank += gain
        if tank < 0:
            start, tank = i + 1, 0     # prefix dead — restart after i
    return start if total >= 0 else -1
Complexity derivationre-derive it first

One pass, O(1) per station → O(n)/O(1). The running total is §1.3's pivot-style global sum; the restart logic is prefix-thinking with a discard proof bolted on.

§G.7 · TYPE F · THE BOSS

Regret greedy — take it back later

LC 630 · Course Schedule III (hard) Each course has a duration and a deadline; courses run one at a time from day 0. Maximize the number taken.

Brute force: subsets → exponential; even ordering choices explode.

Step 1 — sort by deadline: any feasible set can be scheduled in deadline order (exchange: swapping two adjacent courses out of deadline order never violates the earlier deadline). So only deadline order need be considered.

Step 2 — the regret rule: take every course as it comes; when the running time exceeds the current deadline, evict the longest course taken so far (max-heap root, §H). Swapping a longer course for a shorter one keeps the count and strictly frees time — an exchange argument executed at runtime, with the heap as the machine that finds the swap in O(log n).

The invariant — state it before coding

After processing the first i courses (deadline order): the set held is a maximum-size feasible subset of those i, and among such subsets, one with minimal total duration. Minimal duration is what makes future admissions as easy as possible — the two halves of the invariant feed each other, and the eviction step preserves both (interrogation G5).

Solutionattempt it on paper first
def scheduleCourse(courses):
    courses.sort(key=lambda c: c[1])       # by deadline
    taken = []                             # max-heap of durations (negated)
    time = 0
    for dur, deadline in courses:
        time += dur
        heapq.heappush(taken, -dur)
        if time > deadline:
            time += heapq.heappop(taken)   # evict the longest (negated value)
    return len(taken)

Note the eviction may remove the course just added (it was the longest) — that's correct, not a bug: taking then regretting is the mechanism.

Complexity derivationre-derive it first

Sort n log n + n heap ops of O(log n) → O(n log n), O(n) heap. With n = 10⁴, well inside budget.

When this feels derivable — deadline order justified by exchange, invariant with a minimality rider, heap as the regret machine — the set is yours. Same species: LC 871 (refuel with a regret heap of skipped stations), LC 502 (IPO, two-phase heap greedy).

§G.8 · BEFORE YOU SUBMIT + INTERROGATION

Traps, signals & interrogation

Signals → pattern (added to the drill deck in §3.2)

"merge / insert overlapping intervals"           → sort by START, grow the open block
"max non-overlapping / min removals"             → sort by END, earliest-end-first + exchange proof
"min rooms / max concurrent / peak load"         → event sweep (+1/−1) or starts-vs-ends pointers
"can I reach the end / min jumps"                → furthest-reach frontier, O(n) no sort
"circular tour, where to start"                  → running tank + discard the failed prefix
"max tasks under deadlines"                      → sort by deadline + regret heap (evict longest)

Interrogation — answer in your journal

G1

Prove the merge-sweep invariant: once a block closes, no future interval can touch it. Then flip the boundary semantics (touching intervals stay separate) and show the exact one-comparison change, tracing [[1,2],[2,3]] both ways.

Hint → answer sketchjournal first

Hint: Who could still reach a closed block?

Sketch: A block closes when some interval STARTS past its end; every later interval starts later still — nothing reaches back. Touching-separate: s ≤ end becomes s < end; [[1,2],[2,3]] merges to [1,3] under ≤ and stays two blocks under <.

G2

Run the LC 435 exchange argument in full. Then defeat both wrong greedies: earliest-start on [[1,100],[2,3],[4,5]] and shortest-first on [[1,10],[9,12],[11,20]].

Hint → answer sketchjournal first

Hint: Swap OPT's first pick for greedy's.

Sketch: Greedy's pick g has the globally earliest end, so g.end ≤ o.end; substituting keeps disjointness (the rest of OPT starts after o.end) and size — induct. Earliest-start dies on [[1,100],[2,3],[4,5]] (keeps the blanket); shortest-first dies on [[1,10],[9,12],[11,20]] (the short middle blocks both).

G3

LC 253: prove that sorting starts and ends independently — destroying the pairing — still computes the correct room count. What quantity is invariant under unpairing, and why does the else-branch count exactly the peak?

Hint → answer sketchjournal first

Hint: What does the running count actually depend on?

Sketch: Concurrency at time t = (#starts ≤ t) − (#ends ≤ t): two multiset counts — the pairing never enters. The else-branch fires exactly when a start finds no freed room, i.e., once per unit of peak concurrency, so rooms finishes equal to the max overlap.

G4

LC 134: prove the prefix discard — if the tank first goes negative at i starting from s, no start in (s..i] survives past i. Then sketch why total ≥ 0 guarantees the surviving candidate completes the circle.

Hint → answer sketchjournal first

Hint: Compare tanks: starting at s vs starting inside the failed prefix.

Sketch: tank(s'→i) = tank(s→i) − tank(s→s'), and tank(s→s') ≥ 0 because s never dipped before i — so every inside start arrives at i even emptier. Certificate sketch: failed prefixes partition the circle, each with negative sum; if the survivor also failed, the whole circle would sum negative — contradiction with total ≥ 0.

G5

LC 630: state the two-part invariant (max size + minimal duration) and prove the eviction preserves both via exchange. Trace [[100,200],[200,1300],[1000,1250],[2000,3200]] to the answer.

Hint → answer sketchjournal first

Hint: Evict the longest; slack can only grow.

Sketch: Exchange: swap any kept longer course for the evicted shorter one — same count, strictly more remaining time, feasibility preserved → both invariant halves hold by induction. Trace: take 100 (t=100); take 200 (t=300); 1000 → t=1300 > 1250 → evict 1000 (t=300); take 2000 → t=2300 ≤ 3200. Three courses.

§GR.1 · SET 09 — GRAPHS: BFS · DFS · TOPO · DIJKSTRA

Theory

Redundancy it kills: revisiting states. On a tree every node has one parent, so a walk can't return (§TR.1); on a graph, cycles and diamonds mean a memoryless walk re-explores the same node once per path reaching it — exponentially many on dense graphs, forever on cycles. The visited set is the entire pattern: each node is processed exactly once, and everything else in this set is a choice of processing order — FIFO (BFS), LIFO (DFS), by indegree (topo), by tentative distance (Dijkstra).

The BFS skeleton, with the two load-bearing choices:

adj = ...                              # adjacency list, built from edges
visited = {src}
queue = deque([src])
while queue:
    u = queue.popleft()
    for v in adj[u]:
        if v not in visited:
            visited.add(v)             # mark ON ENQUEUE — see traps
            queue.append(v)
The BFS invariant — why dequeue order is distance order

The queue always holds nodes of at most two consecutive distances, in non-decreasing order (induction: processing a distance-d node appends only distance-(d+1) nodes behind the remaining d's). Therefore when a node is dequeued, its distance is final — the unit-weight special case of a settle proof. This invariant is exactly why layers work (§TR.3's frozen snapshot) and exactly what weighted edges destroy.

sd=0d=1d=2d=31122233queue: [ 2 2 2 3 3 ] — at most two consecutive distances
Why dequeue order is distance order: processing a d-node appends only (d+1)-nodes behind the remaining d’s.
⚠ Hidden requirements — one per algorithm

BFS = shortest path only under unit weights. Weighted edges break the layer invariant → Dijkstra. Dijkstra = non-negative weights only — its settle proof adds edge weights and needs them not to shrink → negative edges demand Bellman-Ford-style relaxation (aware-level, per the atlas). Topological sort = DAG only — a cycle makes "dependency order" undefined; Kahn's detects it for free. And visited-set keys must be hashable: encode states as tuples, and count the state space before assuming it's small.

Complexity tell: O(V + E) — every node enqueued once, every edge examined a constant number of times. The proof is the charging argument (§2.2's lineage): charge each edge to its endpoints, each paying O(1). Dijkstra adds the heap: O(E log V). Constraint smell: 10⁵ nodes + 10⁵ edges ⇒ linear graph algorithms; V ≤ ~400 ⇒ O(V³) Floyd-Warshall is fair game.

In plain English — the same idea, slowernew here? start with this

Graphs are cave exploration, and the visited set is your chalk. In a tree (Set 06) passages never loop back, so you never needed chalk. Caves loop. Enter a chamber, chalk the wall; see chalk, don’t re-enter. Without chalk you walk in circles forever — with it, you visit each chamber exactly once. That’s the entire difference between tree code and graph code.

Everything else in this set is just the ORDER you explore chambers:

• nearest-first, using a to-do line (BFS) — this gives shortest paths when every tunnel has the same length, because you provably reach every chamber by the fewest possible tunnels;
• deep-first (DFS) — simplest to write, great for “is it connected / flood this region”;
• only-do-tasks-whose-prerequisites-are-done (topological order) — for dependency puzzles, and if you get stuck with tasks remaining, you’ve PROVEN there’s a circular dependency;
• cheapest-total-first (Dijkstra) — when tunnels have different lengths; needs a priority queue and honestly-non-negative lengths.

§GR.2 · TYPE A

Connected components — flood fill

LC 200 · Number of Islands Count the 4-directionally connected groups of '1's in a grid.

The grid IS a graph: cells are nodes, 4-direction adjacency is the edge set — no adjacency list needed, direction vectors generate edges on the fly.

Redundancy: asking "which island does this cell belong to?" per cell re-walks components endlessly. Flip it: iterate cells, and every unvisited land cell must be the first touch of a brand-new component — flood it entirely, count one.

Why the count is right: flooding consumes a whole component before the outer scan continues (DFS reaches everything connected), so components and flood-starts correspond one-to-one — no component counted twice, none missed.

Solutionattempt it on paper first
def numIslands(grid):
    m, n = len(grid), len(grid[0])
    def sink(r, c):
        if not (0 <= r < m and 0 <= c < n) or grid[r][c] != '1':
            return
        grid[r][c] = '0'               # visited = sunk, in place
        sink(r+1, c); sink(r-1, c); sink(r, c+1); sink(r, c-1)
    count = 0
    for r in range(m):
        for c in range(n):
            if grid[r][c] == '1':
                count += 1
                sink(r, c)
    return count

Sinking in place doubles as the visited set — O(1) extra space, but it mutates the input: ask whether that's acceptable, or keep a real set (§TR.8's habit). A 300×300 all-land grid recurses 9·10⁴ deep — Python's limit says go iterative with an explicit stack; say it before the interviewer does.

Complexity derivationre-derive it first

Every cell is visited by the outer scan once and sunk at most once — charge each cell O(1) for each role → O(mn) time; space O(mn) worst-case stack (or O(min(m,n)) for BFS flood). LC 133 (Clone Graph) is the same traversal where visited is a map old→new — §L.6's node→clone payload on a graph.

§GR.3 · TYPE B

Multi-source BFS — layers as time

LC 994 · Rotting Oranges Rot spreads to adjacent fresh oranges each minute; how many minutes until none are fresh, or −1?

Brute force: simulate minute-by-minute, re-scanning the whole grid per minute → O((mn)²) when rot crawls one cell a minute.

Redundancy: re-scanning cells whose state cannot change this minute. Only the frontier — last minute's newly rotten — can rot anything.

Insight: seed the queue with every rotten orange at time 0 — multi-source BFS behaves as if a virtual super-source connected to all of them. A fresh orange's rotting time = its BFS distance to the nearest initial rot, because rot advances exactly one layer per minute (the layer invariant of §GR.1, read as a clock). The frozen-layer loop is §TR.3's snapshot, verbatim.

Solutionattempt it on paper first
def orangesRotting(grid):
    m, n = len(grid), len(grid[0])
    queue, fresh = deque(), 0
    for r in range(m):
        for c in range(n):
            if grid[r][c] == 2:
                queue.append((r, c))
            elif grid[r][c] == 1:
                fresh += 1
    minutes = 0
    while queue and fresh:             # `and fresh`: no final empty layer
        minutes += 1
        for _ in range(len(queue)):
            r, c = queue.popleft()
            for dr, dc in ((1,0),(-1,0),(0,1),(0,-1)):
                nr, nc = r + dr, c + dc
                if 0 <= nr < m and 0 <= nc < n and grid[nr][nc] == 1:
                    grid[nr][nc] = 2   # mark ON enqueue
                    fresh -= 1
                    queue.append((nr, nc))
    return minutes if fresh == 0 else -1

The and fresh guard is the off-by-one everyone hits: without it, the last wave of rot counts one extra minute in which nothing new rotted.

Complexity derivationre-derive it first

Each cell enqueued at most once (marked on enqueue), each edge checked twice → O(mn) time, O(mn) queue worst case. Same shape: LC 542 (01 Matrix), LC 1091 — any "nearest X, unit steps" is multi-source BFS from the X's.

§GR.4 · TYPE C

Topological sort — Kahn's algorithm

LC 207 · Course Schedule Given prerequisite pairs, can all courses be finished?

Reframe: finishable ⟺ the prerequisite graph has no cycle — a cycle is a set of courses each waiting on another, forever.

Kahn's derivation: a course with indegree 0 has no unmet prerequisite — take it now (safe by exchange: any valid order can be rearranged to put an available course first without breaking anything, §G.1's argument on orderings). Taking it decrements its dependents' indegrees; whoever hits 0 becomes available. The queue is just the availability set.

Cycle detection for free: every member of a cycle has an in-cycle predecessor that is never processed, so its indegree never reaches 0 and it never enters the queue. Hence processed < n ⟺ cycle — the leftover count IS the detector.

Solutionattempt it on paper first
def canFinish(numCourses, prerequisites):
    adj = [[] for _ in range(numCourses)]
    indeg = [0] * numCourses
    for course, pre in prerequisites:
        adj[pre].append(course)        # pre ──▶ course
        indeg[course] += 1
    queue = deque(i for i in range(numCourses) if indeg[i] == 0)
    done = 0
    while queue:
        u = queue.popleft()
        done += 1
        for v in adj[u]:
            indeg[v] -= 1
            if indeg[v] == 0:
                queue.append(v)
    return done == numCourses

Edge direction is the first bug: prerequisites point from the prerequisite to the dependent. LC 210 is this plus recording the dequeue order. The DFS alternative (white/gray/black coloring; hitting gray = cycle) is worth knowing — Kahn's is usually cleaner to derive live.

Complexity derivationre-derive it first

Each node enqueued once, each edge decremented once → O(V + E) time, O(V + E) space for the adjacency list.

§GR.5 · TYPE D

Dijkstra — the settle proof

LC 743 · Network Delay Time Weighted directed graph; time for a signal from node k to reach every node, or −1.

Brute force: enumerate paths — exponential. Bellman-Ford relaxes all edges V−1 times → O(VE), correct but ignores an ordering gift.

Redundancy: re-relaxing nodes whose distance is already provably final.

The settle proof — LC 373's frontier argument, promised in §H.4, now on graphs: pop the frontier node u with the smallest tentative distance d. Any other route to u must leave the settled set through some frontier node with tentative ≥ d, and every additional edge adds ≥ 0 — so no route can undercut d. Popping = settled, permanently. Non-negativity is where the proof spends its assumption; that's the hidden requirement, not a footnote.

Lazy deletion (§H.7's decrease-key workaround): push improved (dist, node) duplicates freely; on pop, a node already settled is stale — skip it. The check-then-settle ordering is load-bearing.

STEP THROUGH · DIJKSTRA FROM NODE 1step 0

    

Solutionattempt it on paper first
def networkDelayTime(times, n, k):
    adj = [[] for _ in range(n + 1)]
    for u, v, w in times:
        adj[u].append((v, w))
    dist = {}                          # settled nodes only
    heap = [(0, k)]
    while heap:
        d, u = heapq.heappop(heap)
        if u in dist:
            continue                   # stale duplicate — lazy deletion
        dist[u] = d                    # settle: the proof happened here
        for v, w in adj[u]:
            if v not in dist:
                heapq.heappush(heap, (d + w, v))
    return max(dist.values()) if len(dist) == n else -1
Complexity derivationre-derive it first

Each edge pushes at most one heap entry → heap ≤ E entries → O(E log E) = O(E log V) (log E ≤ 2 log V), space O(V + E). Dense graphs (E ≈ V²): the heapless O(V²) array scan wins — say the trade. Variants: 0/1 weights → deque BFS O(V+E); ≤ k stops (LC 787) → Bellman-Ford-style, because the settle proof breaks under the stop budget.

§GR.6 · TYPE E · THE BOSS

Build the graph, then sort it

LC 269 · Alien Dictionary (hard) Given words sorted in an unknown alphabet, recover a valid letter order, or "" if impossible.

Why it's the boss: the graph isn't given — extracting it correctly IS the problem; the topo sort afterward is §GR.4 verbatim.

Edge extraction, derived: two adjacent sorted words agree on a prefix; their first differing characters a, b certify a < b — one edge, a→b. Characters after the first difference certify nothing (sorted order is decided at the first difference; everything later is noise). Non-adjacent word pairs add nothing: sortedness is transitive through the neighbors between them.

The impossibility case that isn't a cycle: ["abc", "ab"] — a word before its own proper prefix. No edge expresses that contradiction; it must be caught explicitly during extraction.

Solutionattempt it on paper first
def alienOrder(words):
    adj = {c: set() for w in words for c in w}
    indeg = {c: 0 for c in adj}
    for w1, w2 in zip(words, words[1:]):
        for a, b in zip(w1, w2):
            if a != b:
                if b not in adj[a]:    # dedup — or indegree inflates
                    adj[a].add(b)
                    indeg[b] += 1
                break
        else:                          # no difference found
            if len(w1) > len(w2):
                return ""              # word precedes its own prefix
    queue = deque(c for c in indeg if indeg[c] == 0)
    order = []
    while queue:
        u = queue.popleft()
        order.append(u)
        for v in adj[u]:
            indeg[v] -= 1
            if indeg[v] == 0:
                queue.append(v)
    return "".join(order) if len(order) == len(adj) else ""

The set-based dedup guards indegree from repeated pairs certifying the same edge — inflate it and valid inputs return "". The for-else runs exactly when no break fired: the prefix check lives there, not after the loop.

Complexity derivationre-derive it first

Extraction: each adjacent pair compared to the first difference → O(total characters). Topo: V ≤ 26 letters, E ≤ one edge per adjacent pair → O(C + V + E) where C = total characters — effectively linear in the input. Every unprocessed letter at the end certifies a cycle: contradictory dictionary.

When this feels derivable — first-difference certificates, the prefix contradiction, then Kahn's on autopilot — the set is yours. The general skill: many "hard graph" problems are ordinary traversals behind a non-obvious graph construction.

§GR.7 · BEFORE YOU SUBMIT + INTERROGATION

Traps, signals & interrogation

Signals → pattern (added to the drill deck in §3.2)

"connected regions / islands / provinces"        → flood fill — each fresh start is one component
"spread / infection / nearest X, unit steps"     → multi-source BFS, layers = time
"prerequisites / build order / can it finish"    → Kahn's topo sort — leftovers certify the cycle
"shortest path, weighted, non-negative"          → Dijkstra: lazy deletion, settle at pop
"recover an order from sorted examples"          → extract first-difference edges, then topo
"cheapest path with ≤ k stops / negative edges"  → Bellman-Ford relaxation — Dijkstra's proof breaks

Interrogation — answer in your journal

Q1

Prove the BFS layer invariant (queue non-decreasing, dequeued distance final) by induction, and point to the exact step that needs unit weights — then build a small weighted graph where BFS returns the wrong shortest path.

Hint → answer sketchjournal first

Hint: What do you enqueue while processing distance d?

Sketch: Induction: the queue holds non-decreasing distances spanning at most two values; processing a d-node appends only (d+1)-nodes behind the remaining d's. 'Neighbor = distance+1' is where unit weights enter. Counterexample: 1→3 weight 10 vs 1→2→3 weights 1+1 — BFS crowns the heavy edge.

Q2

Mark-on-enqueue vs mark-on-dequeue: on a 3×3 all-land grid, count the queue entries each policy produces, and derive each policy's worst-case queue size on an m×n grid.

Hint → answer sketchjournal first

Hint: How many in-edges can re-enqueue a cell?

Sketch: Enqueue-marking: each cell enters once → queue ≤ mn. Dequeue-marking: a cell can be enqueued once per in-edge (≤ 4) before its first dequeue → up to ~4mn entries and wasted dequeues; on the 3×3 all-land grid, center-adjacent cells enter repeatedly. Output identical, memory and time bloated.

Q3

Prove that in Kahn's, every node on a cycle never enters the queue — hence done < n ⟺ cycle. Trace [[1,0],[0,1]] to the verdict.

Hint → answer sketchjournal first

Hint: Who decrements a cycle member to zero?

Sketch: Every cycle node has an in-cycle predecessor that must process first — a circular wait, so none ever reaches indegree 0. done therefore counts only the acyclic part: done < n ⟺ cycle. [[1,0],[0,1]]: indegrees 1,1 → queue starts empty → done = 0 ≠ 2 → false.

Q4

Reproduce Dijkstra's settle proof from memory and name where it reuses LC 373's frontier argument. Then construct the negative-edge graph where the first settle is already wrong.

Hint → answer sketchjournal first

Hint: Where does the proof spend non-negativity?

Sketch: Any route to the popped u exits the settled set through a frontier offer ≥ d, and extending with weights ≥ 0 cannot shrink it — LC 373's frontier argument on a graph. Negative edge: 1→2 (1), 1→3 (100), 3→2 (−200): (1,2) settles dist 1, but the true cost via 3 is −100 — settled too early, permanently.

Q5

Alien Dictionary: prove only the first differing character yields a valid edge (and later characters certify nothing). Trace ["wrt","wrf","er","ett","rftt"] to an order, and show which exact line rejects ["abc","ab"].

Hint → answer sketchjournal first

Hint: Sorted order is decided at the first difference.

Sketch: The dictionary ranked the two words BY their first differing pair — later characters were never consulted, so only a→b is certified. Trace: wrt/wrf ⇒ t len(w2) → return '' — the for-else line.

§K.1 · SET 10 — BACKTRACKING

Theory

Redundancy it kills — two, and both matter:

  • Completing dead candidates: generate-then-filter builds every complete candidate (2ⁿ subsets, n! orderings) and tests each — but a partial candidate that already violates a constraint dooms every one of its completions. Pruning at the first violation discards the whole subtree in one comparison.
  • Rebuilding shared prefixes: sibling candidates share almost their entire prefix. Copying state per candidate re-pays that prefix every time; backtracking keeps one mutable path and moves along the tree edge by edge — O(1) per choose/unchoose.

So: backtracking = DFS over the implicit tree of partial candidates, plus pruning, plus undo. The skeleton:

def backtrack(path, choices):
    if complete(path):
        results.append(path[:])        # COPY — the path is shared
        return
    for c in candidates(choices):      # the branching rule
        if not viable(c, path):        # prune BEFORE descending
            continue
        path.append(c)                 # choose
        backtrack(path, updated)       # explore
        path.pop()                     # unchoose — restore exactly
The invariant — symmetric restore

On every entry to backtrack, the mutable state equals exactly what the path implies — and every choose has one symmetric unchoose, so the state on exit is bit-for-bit the state on entry. Break the symmetry anywhere (an early return between append and pop) and every subsequent branch computes on corrupted state. State this discipline before coding; it's what the interviewer is watching for.

⚠ Hidden requirements

(1) Prunability: violations must be detectable on partial candidates — if validity only shows on complete ones, backtracking degenerates to brute-force enumeration. (2) A canonical generation order (start indexes, row-by-row) so each candidate is produced exactly once — §B.3's canonical-key idea applied to a search tree. (3) Tiny n: output can be Θ(2ⁿ) or Θ(n!) — the output size is a hard lower bound, so n ≤ ~20 in the constraints is the tell that exponential is expected. (4) When only a count or optimum is asked and subproblems overlap → memoize/DP (Set 12) — enumerate ⇒ backtrack; count/optimize ⇒ DP.

Complexity tell: O(branching^depth), refined to O(nodes surviving the prune × per-node cost) — and output-sensitive: at minimum O(#answers × answer length), because the leaf copy costs O(n). Count the copy; most people forget it.

In plain English — the same idea, slowernew here? start with this

Backtracking is exploring a maze of choices with breadcrumbs. At each fork you drop a crumb (choose), walk in, and — crucially — pick the crumb back up when you return (unchoose). The trail of crumbs always describes exactly where you stand, and never anything stale. Forget one pickup and every later path reads a corrupted trail — that’s the “symmetric restore” rule above, minus the formality.

one check at the fork cancelled this whole wing
The maze of choices: prune at the fork and every dead end behind it is never walked. That skipped wing is the entire speedup.

Pruning is why any of this finishes before the sun burns out: the instant a corridor is provably hopeless (“sum already too big”, “queen already attacked”), you skip the ENTIRE wing behind it. One glance can cancel millions of dead ends — the picture above is the whole speedup.

Two iron rules to tattoo somewhere: (1) leave everything exactly as you found it, and (2) when you record an answer, photograph the trail — store a copy — because the trail itself keeps changing after you leave (§F.4’s aliasing picture explains why path[:], not path).

§K.2 · TYPE A

Subsets — the start-index archetype

LC 78 · Subsets Return all subsets of an array of distinct integers.

The canonical-order derivation: a subset equals a strictly increasing sequence of indexes — a unique canonical form (§B.3 again). Generate exactly those: from position start, choose any later index, recurse from just past it. Each subset is produced once because each increasing index sequence is produced once — no dedup, no visited set, by construction.

Check yourself: nums = [1, 2, 3] — how many times does res.append run?

8 = 2³. Every node of the tree is a subset — including the root’s empty path, appended before any choice is made.

Every node of this tree IS an answer (subsets of every size), so the collect happens at every call, not just leaves.

Solutionattempt it on paper first
def subsets(nums):
    res, path = [], []
    def backtrack(start):
        res.append(path[:])            # every node is an answer
        for i in range(start, len(nums)):
            path.append(nums[i])
            backtrack(i + 1)
            path.pop()
    backtrack(0)
    return res

The include/exclude formulation (branch twice per element) builds the same tree reshaped — know both; the start-index loop generalizes better (Types B, C).

Complexity derivationre-derive it first

Exactly 2ⁿ subsets; each collected via an O(n)-worst copy → O(n · 2ⁿ) time, output dominated. The tree has 2ⁿ nodes and each edge is one O(1) choose/unchoose — the copy at collection is the real cost. n = 20 → ~2·10⁷: the constraint told you exponential was intended.

§K.3 · TYPE B

Target-driven pruning with reuse

LC 39 · Combination Sum Distinct candidates, unlimited reuse; return all combinations summing to target.

Two levers on top of Type A:

  • Reuse: recurse with start = i, not i+1 — the current candidate may appear again, but nothing earlier may, preserving the canonical non-decreasing order (each combination generated once).
  • The sorted break-prune: sort first; when a candidate exceeds the remaining target, break, don't continue — sortedness certifies every later candidate also exceeds it (§BS.1's monotonic discard, inside a loop). One comparison kills the rest of the level and all their subtrees.
Solutionattempt it on paper first
def combinationSum(candidates, target):
    candidates.sort()                  # enables break, not continue
    res, path = [], []
    def backtrack(start, remain):
        if remain == 0:
            res.append(path[:])
            return
        for i in range(start, len(candidates)):
            if candidates[i] > remain:
                break                  # sorted: all later ones too big
            path.append(candidates[i])
            backtrack(i, remain - candidates[i])   # i, not i+1: reuse
            path.pop()
    backtrack(0, target)
    return res

LC 40 (each candidate once, duplicates in input) flips both levers: recurse with i+1 and add the same-level skip rule from Type C. Knowing which lever does what is the skill.

Complexity derivationre-derive it first

Tree depth ≤ target / min(candidates); a loose bound is O(bᵈ) with b = #candidates — but the honest statement is output-sensitive: the answer set can itself be exponential, so enumerate-all is the floor. Say that instead of hand-waving a polynomial.

§K.4 · TYPE C

Permutations with duplicates — the same-level skip

LC 47 · Permutations II Return all unique permutations of an array that may contain duplicates.

Brute force: generate all n! permutations (LC 46 with a used[] array), dedup through a set → O(n! · n) plus hashing, and the duplicate subtrees were fully explored before being thrown away.

Redundancy: two equal values swapped produce identical subtrees — the entire subtree is re-explored once per ordering of the equal copies.

The rule, derived: sort, then at each level skip nums[i] when nums[i] == nums[i−1] and used[i−1] is False. Meaning: among equal copies, a copy may enter the path only if the previous copy is already in it — equal copies are consumed in index order, one canonical ordering survives, so each distinct permutation is generated exactly once. It's a canonical form imposed at the point of divergence, pruning duplicates before they cost anything.

Solutionattempt it on paper first
def permuteUnique(nums):
    nums.sort()                        # the skip rule needs equal values adjacent
    res, path = [], []
    used = [False] * len(nums)
    def backtrack():
        if len(path) == len(nums):
            res.append(path[:])
            return
        for i in range(len(nums)):
            if used[i]:
                continue
            if i > 0 and nums[i] == nums[i - 1] and not used[i - 1]:
                continue               # equal copies: index order only
            used[i] = True
            path.append(nums[i])
            backtrack()
            path.pop()
            used[i] = False            # both undos — symmetric restore

Note the two-line undo: pop() AND used[i] = False. Every piece of chosen state gets its mirror.

Complexity derivationre-derive it first

Output: n!/(∏ dup-counts!) distinct permutations × O(n) copy — the skip rule makes the explored tree proportional to the output, not to n!. On [1,1,1,1,1] that's 1 leaf instead of 120 — the pruning factor is the whole point.

§K.5 · TYPE D

Grid backtracking — path-visited, then undone

LC 79 · Word Search Can the word be traced through adjacent cells, each cell used at most once?

The conceptual hinge of this set: Set 09's visited set is traversal-visited — permanent, because "reached once" answers reachability forever. Here a cell is blocked only while it's on the current path: a different path may legally reuse it. So the mark must be undone on exit — path-visited. Confusing the two breaks both directions: permanent marks here miss valid words (a failed probe poisons later probes); undo-marks in flood fill (§GR.2) go quietly exponential.

The prune: mismatch at character k kills the subtree immediately — 3 branches per step after the first (never walk back into the cell you came from — it's marked).

Solutionattempt it on paper first
def exist(board, word):
    m, n = len(board), len(board[0])
    def dfs(r, c, k):
        if k == len(word):
            return True
        if not (0 <= r < m and 0 <= c < n) or board[r][c] != word[k]:
            return False
        board[r][c] = '#'              # path-visited
        found = (dfs(r+1, c, k+1) or dfs(r-1, c, k+1) or
                 dfs(r, c+1, k+1) or dfs(r, c-1, k+1))
        board[r][c] = word[k]          # undo — other paths may need this cell
        return found
    return any(dfs(r, c, 0)
               for r in range(m) for c in range(n))

The or-chain short-circuits — a found word skips the remaining directions but still reaches the restore line. An early return True above the restore would leak a '#' forever.

Complexity derivationre-derive it first

mn starting cells × branching 3 per remaining character (4 only at the first step) → O(mn · 3^L) for word length L, space O(L) recursion. LC 212 (Word Search II, many words) upgrades the prune with a trie — Set 11's opening act.

§K.6 · TYPE E · THE BOSS

N-Queens — constraint sets

LC 51 · N-Queens (hard) Place n queens on an n×n board so none attack; return all boards.

Brute force: choose n cells from n² → C(n², n) candidates; even one-per-row placement without pruning is nⁿ with O(n) validity scans per node.

Two derivations do all the work:

  • Canonical order: every solution has exactly one queen per row — so place row by row. The branching collapses from "any cell" to "which column in this row": nⁿ shape, and the row constraint holds by construction.
  • O(1) validity via the diagonal identities: cells on the same "\" diagonal share a constant r − c; on the same "/" diagonal, constant r + c (derive: moving one step down-right changes r and c by +1 each — difference invariant; down-left changes them oppositely — sum invariant). Three hash sets — cols, r−c, r+c — replace the O(n) board scan. §B.1's payload question, answered with set-membership.
Solutionattempt it on paper first
def solveNQueens(n):
    res, board = [], []                # board[r] = column of the queen in row r
    cols, diag, anti = set(), set(), set()
    def place(r):
        if r == n:
            res.append(["." * c + "Q" + "." * (n - c - 1) for c in board])
            return
        for c in range(n):
            if c in cols or (r - c) in diag or (r + c) in anti:
                continue               # prune before choosing
            cols.add(c); diag.add(r - c); anti.add(r + c)
            board.append(c)
            place(r + 1)
            board.pop()
            cols.discard(c); diag.discard(r - c); anti.discard(r + c)
    place(0)
    return res

Four pieces of chosen state, four undos — the symmetric-restore invariant at its most explicit.

Complexity derivationre-derive it first

Row-by-row placement bounds the raw tree at n·(n−1)·(n−2)… ≈ O(n!) nodes (each placed queen removes at least its column from every later row), with O(1) work per node thanks to the sets. Exact counts are far smaller under diagonal pruning (n = 8 → 92 solutions). Board rendering costs O(n²) per solution — output-sensitive again.

When this feels derivable — canonical order collapses a dimension, algebraic identities make validity O(1), every choose mirrored — the set is yours. Same species: Sudoku Solver (LC 37), Palindrome Partitioning (LC 131).

§K.7 · BEFORE YOU SUBMIT + INTERROGATION

Traps, signals & interrogation

Signals → pattern (added to the drill deck in §3.2)

"all subsets / combinations / partitions"      → start-index backtracking, canonical order
"combination sum, reuse allowed"               → recurse with i (not i+1), sort + break prune
"all permutations, input has duplicates"       → sort + same-level skip (not used[i-1] ⇒ skip)
"trace a word / path through a grid"           → DFS: mark, recurse, UNMARK
"place n pieces under mutual constraints"      → row-by-row + O(1) constraint sets
"count ways / best value only, no listing"     → DP, not backtracking — enumerate vs aggregate

Interrogation — answer in your journal

K1

State the symmetric-restore invariant, then break it deliberately: put a prune-return between append and pop in LC 78 and show the exact corrupted state on [1, 2, 3].

Hint → answer sketchjournal first

Hint: What state does the next sibling inherit?

Sketch: The invariant is exit-state == entry-state. A return between append and pop leaves the element stuck in path; every later sibling explores with a phantom prefix — on [1,2,3], subsets after the corrupted branch all falsely contain the stuck element. Prune BEFORE choosing.

K2

Prove the start-index rule generates each subset exactly once (build the bijection to increasing index sequences — a canonical-key argument). Then derive O(n · 2ⁿ) including the copy cost most people forget.

Hint → answer sketchjournal first

Hint: Map each subset to its sorted index sequence.

Sketch: Subset ↔ increasing index sequence is a bijection, and the start-index loop generates exactly the increasing sequences — each subset once, no dedup needed. The tree has exactly 2ⁿ nodes (one per subset); each collection copies ≤ n elements → O(n·2ⁿ), copy included.

K3

Prove LC 47's skip rule yields each distinct permutation exactly once. Trace [1, 1, 2], count the pruned subtrees, and show what leaks through if the sort is skipped on [1, 2, 1].

Hint → answer sketchjournal first

Hint: Force equal copies into index order.

Sketch: The rule admits copy j only when copy j−1 is already in the path, so among equal values one ordering survives → each distinct permutation once. [1,1,2]: 3 leaves; the subtree starting with the second 1 is pruned at the root. Unsorted [1,2,1]: the equal 1s aren't adjacent, nums[i−1] is the 2, the rule never fires — duplicates leak.

K4

Traversal-visited vs path-visited: construct a board + word where permanent marking misses a valid word, and explain why undo-marking in flood fill turns O(mn) into exponential.

Hint → answer sketchjournal first

Hint: Which marks describe the current PATH vs all history?

Sketch: Word Search: a cell is blocked only while on the current path — permanent marks let a failed probe poison later probes (row [a, b, a], word 'aba': a dead-end start leaves '#' on cells the true path needs). Flood fill is the mirror image: reachability is path-independent, so unmarking re-explores each cell once per path — exponentially many on open grids.

K5

Derive the r−c and r+c diagonal identities from a one-step move, explain why row-by-row placement bounds the tree at ~n! rather than nⁿ, and trace n = 4 to both solutions.

Hint → answer sketchjournal first

Hint: Step one cell along each diagonal and watch r ± c.

Sketch: Down-right: r and c both +1 → r − c constant. Down-left: r+1, c−1 → r + c constant. Three set lookups = O(1) validity. Row-by-row placement gives row r at most n − r legal columns → ≤ n! nodes, not nⁿ. n = 4: columns [1,3,0,2] and [2,0,3,1].

§U.1 · SET 11 — UNION-FIND & TRIE

Theory

One economy, two structures: pay a small maintenance cost on every update so a repeated question becomes nearly free.

  • Union-Find kills: re-running BFS/DFS per connectivity query. Static connectivity is Set 09's job — but when edges arrive over time, re-traversing costs O(V+E) per query, O(q(V+E)) total (§GR.7 named this trap). The DSU maintains the partition as data: find + union in amortized near-O(1).
  • Trie kills: re-comparing shared prefixes. A hashset compares whole keys and — being exact-equality-only (§B.1's limit) — cannot answer "does any word start with X?" at all. The trie stores every shared prefix once; a query walks it once.

The DSU skeleton (path compression + union by size — both, always):

parent = list(range(n))
size = [1] * n
def find(x):
    while parent[x] != x:
        parent[x] = parent[parent[x]]   # path halving — compress as you go
        x = parent[x]
    return x
def union(a, b):
    ra, rb = find(a), find(b)
    if ra == rb:
        return False                    # already connected — a cycle signal
    if size[ra] < size[rb]:
        ra, rb = rb, ra
    parent[rb] = ra                     # link ROOTS, never raw nodes
    size[ra] += size[rb]
    return True
12345find(5)+ halving12345height 4: find is O(n)flattened: find is ~O(1)
What path compression buys: every find flattens the path it walks, so chains cannot survive repeated queries.
The invariants

DSU: the parent forest is a partition, and each root is its class's canonical representative — §B.3's canonical key, maintained dynamically as classes merge. Two elements are connected ⟺ same root. What DSU really maintains is an equivalence relation (plain words: everything is grouped with itself; if A is grouped with B then B with A; and grouping chains — A with B and B with C means A with C) — recognizing "this relation is an equivalence" is the recognition step. Trie: every root-to-node path spells a distinct prefix; an end-marker distinguishes "is a word" from "is only a prefix". The path IS the key.

⚠ Hidden requirements

DSU: connectivity only grows — no edge deletion (that needs offline tricks; aware-only). Arbitrary objects must be mapped to ids (hash them — Type C). Trie: it earns its memory only when prefix structure matters — for pure exact membership a hashset wins on constants and space; say the trade. Node fanout: a 26-array per node is fast but heavy; dict nodes are the Python default.

Complexity tells: DSU: m operations on n elements → O(m α(n)) — inverse Ackermann, ≤ 4 for any physically possible n; say "amortized, effectively constant, formally α(n)" — precision is the senior signal. Trie: every operation is O(L) in the key length, independent of dictionary size n — ask "does my cost scale with how many words, or how long the query?" — that question picks the structure.

In plain English — the same idea, slowernew here? start with this

Union-Find is friend circles at a party. Each circle quietly elects one representative. “Are you two in the same circle?” becomes “do you two point to the same representative?” — two quick lookups, no wandering the room. When two circles merge, one representative takes over both. And here’s the elegant part: every time someone ASKS, the path they walked to find their representative gets shortened for next time (compression) — the structure gets faster from being used.

A trie is a shared word-tree: “apple” and “apply” walk the same a-p-p-l corridor and only split at the last letter. Every shared prefix is built ONCE, so looking a word up costs the word’s length — whether the tree holds fifty words or fifty thousand. That independence from dictionary size is the trie’s entire reason to exist; a plain set can say “is this exact word here?” but has no idea what starts with “app”, because it scattered the words on purpose (§F.3).

§U.2 · TYPE A

Counting components by union

LC 547 · Number of Provinces Adjacency matrix of friendships; count the friend groups.

Honest framing: this input is static, so §GR.2's flood fill also works — this is the DSU hello-world, and the counting idea is what transfers: start with n components; every union that succeeds merges two into one, so count −= 1 per successful union. No recount, ever — the running count is exact by induction.

Solutionattempt it on paper first
def findCircleNum(isConnected):
    n = len(isConnected)
    parent = list(range(n))
    size = [1] * n
    def find(x):
        while parent[x] != x:
            parent[x] = parent[parent[x]]
            x = parent[x]
        return x
    count = n
    for i in range(n):
        for j in range(i + 1, n):      # symmetric matrix: upper half only
            if isConnected[i][j]:
                ri, rj = find(i), find(j)
                if ri != rj:
                    if size[ri] < size[rj]:
                        ri, rj = rj, ri
                    parent[rj] = ri
                    size[ri] += size[rj]
                    count -= 1
    return count
Complexity derivationre-derive it first

n²/2 matrix reads, each with amortized-α finds → O(n² α(n)) ≈ O(n²) — the matrix scan dominates, the DSU is effectively free. When edges stream in (LC 305 — islands appearing one by one), flood fill must re-run per query and DSU pulls decisively ahead: that's the real use case.

§U.3 · TYPE B

The failed union is the answer

LC 684 · Redundant Connection A tree plus one extra edge; return the edge whose removal restores a tree.

Brute force: for each edge, remove it and BFS the rest for connectivity + acyclicity → O(E²).

The derivation: feed edges through the DSU in order. While the structure is a forest, every edge joins two different roots and the union succeeds. The moment an edge arrives whose endpoints already share a root, a path between them exists — adding this edge closes the (unique) cycle. With exactly one extra edge, exactly one union fails, and that edge is the answer. Find-before-union is the check-before-insert ordering (§1.4) — query the state, then mutate it.

Solutionattempt it on paper first
def findRedundantConnection(edges):
    parent = list(range(len(edges) + 1))   # nodes are 1..n
    def find(x):
        while parent[x] != x:
            parent[x] = parent[parent[x]]
            x = parent[x]
        return x
    for a, b in edges:
        ra, rb = find(a), find(b)
        if ra == rb:
            return [a, b]              # closes the cycle — the answer
        parent[rb] = ra

Union-by-size dropped for brevity — path halving alone keeps this near-linear; say the trade if asked. Kruskal's MST is exactly this loop with edges pre-sorted by weight: skip failed unions, keep successful ones (LC 1584) — the cut property justifying it is an exchange argument (§G.1).

Complexity derivationre-derive it first

n edges × amortized-α(n) DSU ops → O(n α(n)) ≈ O(n), O(n) space — against the O(E²) brute force.

§U.4 · TYPE C

Union over arbitrary keys

LC 721 · Accounts Merge Accounts share an owner iff they share any email; merge them.

The recognition step: "shares an email with" generates an equivalence relation over accounts (transitive through chains: A–B via one email, B–C via another ⇒ A–C). Equivalence classes under growing evidence = DSU, verbatim.

Brute force: repeatedly scan account pairs for shared emails and merge until fixed point → O(n² · emails) with set intersections, re-discovering the same links per pass.

Mechanics worth naming: DSU over strings — either hash each email to an integer id, or parent as a dict keyed by email (below). Union each account's emails to its first email; then one pass groups every email under its root. The root is the canonical representative doing exactly §B.3's canonical-key job.

Solutionattempt it on paper first
def accountsMerge(accounts):
    parent = {}
    def find(x):
        while parent[x] != x:
            parent[x] = parent[parent[x]]
            x = parent[x]
        return x
    def union(a, b):
        parent.setdefault(a, a)
        parent.setdefault(b, b)
        ra, rb = find(a), find(b)
        if ra != rb:
            parent[rb] = ra
    email_name = {}
    for acc in accounts:
        for email in acc[1:]:
            email_name[email] = acc[0]
            union(acc[1], email)       # everything unions to the first email
    groups = {}
    for email in parent:
        groups.setdefault(find(email), []).append(email)
    return [[email_name[root]] + sorted(g) for root, g in groups.items()]
Complexity derivationre-derive it first

E total emails: unions O(E α) with O(1)-average dict ops (say "average" — string hashing), grouping O(E α), and the output sort dominates: O(E log E) overall. The sort is required by the problem's output format — attribute the log to it explicitly.

§U.5 · TYPE D

The trie — prefixes stored once

LC 208 · Implement Trie (Prefix Tree) Support insert(word), search(word), and startsWith(prefix).

Brute force: a list of words — startsWith scans all n words × L chars → O(n·L) per query. A hashset fixes exact search but cannot answer startsWith at all (exact equality only).

Redundancy: ["apple", "apply", "applied"] store "appl" three times and every prefix query re-compares it. In the trie each prefix exists once as a path; both queries are the same walk, differing only in what they require at the end — which is why the end-of-word marker is load-bearing: without it, inserting "apple" makes search("app") wrongly True.

Solutionattempt it on paper first
class Trie:
    def __init__(self):
        self.root = {}
    def insert(self, word):
        node = self.root
        for ch in word:
            node = node.setdefault(ch, {})
        node['$'] = True               # end marker: word, not just prefix
    def search(self, word):
        node = self._walk(word)
        return node is not None and '$' in node
    def startsWith(self, prefix):
        return self._walk(prefix) is not None
    def _walk(self, s):
        node = self.root
        for ch in s:
            if ch not in node:
                return None
            node = node[ch]
        return node

Dict-nodes with a '$' sentinel key — compact, but any child iteration must skip '$'. The 26-array node is the faster/heavier alternative; know both and the trade.

Complexity derivationre-derive it first

All three ops walk ≤ L nodes with O(1)-average dict steps → O(L), independent of n — the trie's defining property. Space: O(total characters) nodes worst case, shared prefixes deduplicated. LC 211 (wildcard '.') adds a branch-all DFS at the dots: worst case O(26^dots · L) — say it.

§U.6 · TYPE E · THE BOSS

Trie + backtracking — all words at once

LC 212 · Word Search II (hard) Find every dictionary word traceable through adjacent grid cells (each cell once per word).

Brute force: run LC 79 (§K.5) per word → O(W · mn · 3^L). The promised upgrade arrives here.

Redundancy: words sharing prefixes force the board to re-walk identical paths once per word — "cat" and "car" walk c→a twice.

Insight: put the dictionary in a trie and walk board and trie in lockstep — one DFS explores all words simultaneously; a board path dies the moment no dictionary word continues it (dead trie node = the prune). Two refinements that interviewers watch for: pop '$' on collection (each word reported once, however many paths spell it), and delete emptied trie branches so exhausted words stop guiding the search.

Solutionattempt it on paper first
def findWords(board, words):
    root = {}
    for w in words:
        node = root
        for ch in w:
            node = node.setdefault(ch, {})
        node['$'] = w                  # store the word at its end node
    m, n = len(board), len(board[0])
    res = []
    def dfs(r, c, node):
        if not (0 <= r < m and 0 <= c < n):
            return
        ch = board[r][c]
        nxt = node.get(ch)             # '#' is never a trie key
        if nxt is None:
            return                     # no word continues — prune
        word = nxt.pop('$', None)
        if word is not None:
            res.append(word)           # popped: reported exactly once
        board[r][c] = '#'              # path-visited (§K.5)
        dfs(r+1, c, nxt); dfs(r-1, c, nxt)
        dfs(r, c+1, nxt); dfs(r, c-1, nxt)
        board[r][c] = ch
        if not nxt:
            del node[ch]               # emptied branch: shrink the trie live
    for r in range(m):
        for c in range(n):
            dfs(r, c, root)
    return res
Complexity derivationre-derive it first

Trie build: O(total characters). Search: O(mn · 3^maxL) once — not per word; the W factor is gone, which is the entire point. Branch deletion tightens it further in practice: the tree shrinks as answers are found. Space: O(total characters) for the trie + O(maxL) recursion.

When this feels derivable — dictionary as a trie, lockstep walk, prune at dead nodes, pop to dedup, delete to shrink — the set is yours. This is also the cleanest example of the lab's meta-lesson: two patterns composed (Set 10's backtracking + this set's trie) beat either alone.

§U.7 · BEFORE YOU SUBMIT + INTERROGATION

Traps, signals & interrogation

Signals → pattern (added to the drill deck in §3.2)

"are X and Y connected, edges arriving over time"  → union-find — amortized α(n)
"count groups/components as things merge"          → DSU: count −= each successful union
"which edge creates a cycle"                       → the first union that returns False
"merge records sharing any identifier"             → hash to ids → union → group by root
"dictionary + prefix queries / autocomplete"       → trie — O(L), independent of word count
"many words searched in one grid"                  → trie + backtracking, lockstep walk

Interrogation — answer in your journal

U1

Prove union-by-size alone bounds find at O(log n): a node's depth increases only when its root's tree at least doubles. Then construct the union sequence that drives the unoptimized DSU to an O(n) chain.

Hint → answer sketchjournal first

Hint: When can a node's depth grow?

Sketch: Only when its root is linked beneath another — with by-size, only when its tree at least doubles. ≤ log₂ n doublings → find is O(log n). Without optimizations: union(1,2), union(2,3), … linking each old root under the new one builds a chain; find then walks O(n).

U2

Trace parent[b] = a (raw nodes, not roots) on a concrete sequence until the forest is corrupted. Then prove via the forest invariant that a failed union certifies a cycle (LC 684).

Hint → answer sketchjournal first

Hint: Link a node that isn't a root and watch the class split.

Sketch: parent[b] = a with b non-root: b's subtree keeps pointing through b, but b's OLD root (and everything above) silently loses the connection — one class becomes two, and sizes lie. Failed union: same root means a path already exists, so this edge closes the unique cycle (forest invariant: components = n − successful unions).

U3

Path halving: prove no node's depth ever increases, and explain (statement level) why m operations cost O(m α(n)) — including what α(n) ≤ 4 actually means for physical inputs.

Hint → answer sketchjournal first

Hint: Compare every node's depth before and after halving.

Sketch: Halving repoints x to its grandparent: x and its whole subtree rise; nothing ever moves DOWN — depth is monotone non-increasing, the one-direction tell. The full O(m α(n)) is Tarjan's; the interview version: α(n) ≤ 4 for any physical n — 'amortized effectively constant, formally inverse-Ackermann.'

U4

Trie vs hashset: derive per-query costs for exact search and startsWith over n words of length L in both structures. Construct the workload where the hashset strictly wins, and the one where it cannot compete at all.

Hint → answer sketchjournal first

Hint: Which query forces touching every word?

Sketch: Hashset: exact membership O(L) average; startsWith must scan all n words → O(nL). Trie: both O(L), independent of n. Hashset strictly wins pure-membership workloads (constants, memory). It cannot compete on autocomplete, prefix counts, or wildcard walks — no order inside buckets.

U5

LC 212: derive per-word-LC-79 vs trie-lockstep complexity; prove popping '$' reports each word exactly once; trace words ["ab", "ac"] on a board containing 'a' — show the shared prefix walked once.

Hint → answer sketchjournal first

Hint: Count board walks, not words.

Sketch: Per-word LC 79: O(W · mn · 3^L). Trie lockstep: O(mn · 3^L) once — 'ab' and 'ac' share the a-step. Popping '$' removes the word at first discovery, so a second spelling finds no marker — exactly-once reporting with zero bookkeeping.

§D.1 · SET 12 — DP I: 1D · GRID · KNAPSACK

Theory

Redundancy it kills: re-solving overlapping subproblems. Set 10's recursion tree contains the same subproblem over and over — fib(n)'s tree has ~2ⁿ nodes but only n distinct states; coin-change's tree explodes while only (amount) distinct remainders exist. DP collapses the tree onto the DAG of distinct states: each state solved once, then reused.

f4f3f2f2f1f1f0f1f0f0f1f2f3f4recursion tree: 9 calls, duplicates dashedstate DAG: 5 states, each solved once
fib(4), both ways. Same recurrence — memoization collapses the tree (left, duplicated work dashed) onto the DAG of distinct states (right).

The identity worth journaling: DP = path counting / shortest-longest paths on a DAG. States are nodes, transitions are edges, and a valid iteration order is a topological order (§GR.4). Memoized recursion is DFS post-order over the same DAG (§TR.1's "children before parent"). You already know both machines; DP is choosing what the nodes mean.

The five-step derivation discipline (this replaces "seeing the trick"):

  1. State, in words: "dp[i] = the answer for the problem restricted to …" — one precise sentence.
  2. Recurrence: condition on one decision (the last step, take-or-skip the last item).
  3. Base cases: the states where the recurrence can't fire.
  4. Order: top-down memo, or a loop order whose dependencies are already final.
  5. Space: if dp[i] reads only a fixed window, keep the window (rolling variables/row).
The invariant

When a state is computed, every state it reads is already final. Top-down enforces it by check-the-memo-before-computing (the check-before-insert ordering of §1.4, yet again); bottom-up enforces it by loop order — the loop order IS the topological sort, and the knapsack direction proofs (Type E) are where that stops being abstract.

⚠ Hidden requirements

(1) Optimal substructure — a state's optimum must compose from sub-state optima; longest simple path in a general graph breaks it (sub-paths couple through visited nodes) and no position-indexed DP exists. (2) Overlap — without repeated subproblems, memoization buys nothing; that's plain D&C. (3) The Markov property: the state must carry everything the future needs (plain words: once you know the state, the future never has to ask “but how did we get here?”) — an insufficient state gives wrong answers, not slowness (Type F is the masterclass). (4) DP aggregates (counts, optima); if the problem wants the solutions listed, that's Set 10 — same rule, other direction.

Complexity tell: #states × work per transition. n items × target T ⇒ O(n·T) — and say the word pseudo-polynomial: T is a value, not an input size; at T = 10⁹ the "polynomial" DP is dead and you need math/greedy/BFS instead. Constraint smell: n ≤ 10³ with target ≤ 10⁴ is the interviewer telling you O(n·T) is intended.

In plain English — the same idea, slowernew here? start with this

The naive recursion for “how many ways up n stairs?” asks “how many ways from step 3?” thousands of separate times — the tree picture above shows the same question sprouting everywhere. DP’s entire move: answer each distinct question ONCE, write the answer on an index card, and re-read the card instead of re-deriving it.

The five-step recipe, in plain words:

1. Say precisely what one card stores — a full honest sentence, not a variable name.
2. Explain how to compute a card from smaller cards. The trick that always works: ask “what was my LAST move?” and add up the possibilities.
3. Fill in the cards you know for free (the empty case — usually worth exactly 1 or 0).
4. Fill the rest in an order where every card you need is already written.
5. If you only ever re-read the last couple of cards, stop keeping the old ones (that’s the “rolling” trick).

The one real danger, stated simply: a card must capture everything the future needs to know. If two different histories land on the same card but deserve different futures, your card is too vague — and you get wrong answers, not slow ones. (The theory above calls this the Markov property; the Dungeon Game boss is the famous example.)

§D.2 · TYPE A

The 1D archetype

LC 70 · Climbing Stairs Steps of 1 or 2; how many distinct ways to reach step n?

Easy-tier, included because it IS the archetype — the five steps on the smallest possible problem.

The derivation: (1) dp[i] = number of ways to stand on step i. (2) Condition on the last move: it was a 1-step from i−1 or a 2-step from i−2, and the ways are disjoint → dp[i] = dp[i−1] + dp[i−2]. (3) dp[0] = dp[1] = 1 (one way to be at the start: do nothing — the {0:1}-style empty seed). (4) Increasing i. (5) Only a two-state window is read → two variables.

Solutionattempt it on paper first
def climbStairs(n):
    a, b = 1, 1                        # ways(0), ways(1)
    for _ in range(2, n + 1):
        a, b = b, a + b                # roll the window
    return b

It is Fibonacci — but the point is the picture: the naive recursion tree has ~2ⁿ nodes; the state DAG has n. Same recurrence, different graph.

Complexity derivationre-derive it first

n states × O(1) transition → O(n) time, O(1) space after rolling. The memoized top-down version is the same O(n) plus recursion overhead — equivalent DAG, different traversal.

§D.3 · TYPE B

Folding a constraint into the state

LC 198 · House Robber Max loot from a row of houses; adjacent houses can't both be robbed.

Brute force: every independent subset of houses → 2ⁿ, Set 10 style.

The state-design lesson: the adjacency constraint means "how much did I steal" isn't enough — the future needs to know whether the previous house was robbed. That's the Markov property speaking: fold the constraint into the state. Two accumulators — best ending in a rob, best ending in a skip — carry exactly the needed bit.

Recurrence: rob i ⇒ previous must be a skip: take′ = skip + nums[i]. Skip i ⇒ previous was either: skip′ = max(take, skip). Conditioning on one decision, twice.

Solutionattempt it on paper first
def rob(nums):
    take = skip = 0                    # best ending with rob / with skip
    for x in nums:
        take, skip = skip + x, max(take, skip)
    return max(take, skip)

The simultaneous assignment matters — take′ must read the old skip. Sequential assignment is the classic silent bug here (Java/JS need a temp).

Complexity derivationre-derive it first

n states × 2 transitions → O(n), O(1). LC 213 (circular street) shows a state that can't absorb the new constraint — first-and-last couple — so you case-split into two runs instead: recognizing when the state is full is the skill.

§D.4 · TYPE C

Unbounded knapsack — and greedy's grave

LC 322 · Coin Change Fewest coins (unlimited supply) summing to amount, or −1.

Why greedy dies here (say this unprompted): coins [1, 3, 4], amount 6 — largest-first gives 4+1+1 = 3 coins; optimal is 3+3 = 2. No exchange argument exists: swapping a big coin for smaller ones changes the remaining capacity structure, so no local swap is provably safe (§G.1's rule: no proof, no greedy). This is the canonical boundary marker between Sets 08 and 12.

The derivation: dp[t] = fewest coins for amount t. Condition on the last coin c: dp[t] = 1 + min over c of dp[t−c]. Base dp[0] = 0; unreachable stays ∞.

Check yourself: coins [1, 3, 4] — what is dp[6]?

2 (3 + 3). dp[6] = 1 + min(dp[5], dp[3], dp[2]) = 1 + dp[3] = 2. Greedy’s 4+1+1 = 3 never appears — the min over ALL last-coin choices is the point.

Solutionattempt it on paper first
def coinChange(coins, amount):
    INF = float('inf')
    dp = [0] + [INF] * amount
    for t in range(1, amount + 1):
        for c in coins:
            if c <= t and dp[t - c] + 1 < dp[t]:
                dp[t] = dp[t - c] + 1
    return dp[amount] if dp[amount] < INF else -1

For unbounded + MIN, either loop nesting works — min is idempotent, so revisiting a coin can't corrupt anything. That immunity is special: Type E (0/1) and counting problems (D4 in the drill) are exactly where loop order starts to matter.

Complexity derivationre-derive it first

T states × S coin-transitions → O(S·T) time, O(T) space — pseudo-polynomial: linear in the value of amount, exponential in its digit count. amount = 10⁴ fine; 10⁹ dead. Python floats make INF arithmetic safe; in Java, MAX_VALUE + 1 wraps negative — guard the read.

§D.5 · TYPE D

Grid DP — counting with a rolling row

LC 62 · Unique Paths Robot moves only right or down; count paths from top-left to bottom-right of an m×n grid.

The derivation: dp[r][c] = paths reaching (r, c). Condition on the last move — it came from above or from the left, disjointly → dp[r][c] = dp[r−1][c] + dp[r][c−1]. First row and column: exactly one path each (all-right / all-down). Counting uses +, optimizing uses max/min — same skeleton, different monoid; mixing them up is a real bug.

Space: each cell reads only the current and previous rows → keep one row and fold in place: row[c] += row[c−1] reads row[c] as "up" (previous row's value, not yet overwritten) and row[c−1] as "left" (already updated) — the in-place trick IS a dependency-order proof in miniature.

Solutionattempt it on paper first
def uniquePaths(m, n):
    row = [1] * n
    for _ in range(1, m):
        for c in range(1, n):
            row[c] += row[c - 1]       # up (old value) + left (new value)
    return row[-1]
Complexity derivationre-derive it first

mn states × O(1) → O(mn) time, O(n) space. The closed form C(m+n−2, m−1) answers in O(min(m,n)) — offering it after the DP is a strong senior move. LC 63 (obstacles) and LC 64 (min path sum) are the same skeleton with a zero-out and a min respectively.

§D.6 · TYPE E

0/1 knapsack — the reversed loop, proved

LC 416 · Partition Equal Subset Sum Can the array be split into two subsets of equal sum?

Reframe: total odd → impossible; else: can some subset hit total/2? — subset-sum, the canonical 0/1 knapsack (each item used at most once).

The loop-order proof (the most important two lines in DP I): dp[t] = "t reachable with items so far". Adding item x: dp[t] |= dp[t−x]. Iterate t downward: dp[t−x] then still holds the previous item set's value — x can't feed itself. Iterate upward and dp[t−x] may already include x — the single 3 in nums=[3] marks dp[3] then dp[6]: one item used twice. The direction of a loop is enforcing "read the old state before writing the new" — the check-before-insert discipline (§1.4) expressed as an iteration order.

Solutionattempt it on paper first
def canPartition(nums):
    total = sum(nums)
    if total % 2:
        return False
    target = total // 2
    dp = [True] + [False] * target
    for x in nums:
        for t in range(target, x - 1, -1):   # DOWNWARD: each item once
            if dp[t - x]:
                dp[t] = True
    return dp[target]

Contrast with Coin Change's forward loop: forward = reuse allowed (unbounded), backward = use once (0/1). Derive it, don't memorize it — this pair is the most-asked DP follow-up there is.

Complexity derivationre-derive it first

n items × T target-updates → O(n·T) time, O(T) space — pseudo-polynomial again (subset-sum is NP-complete in general; the value-bounded target is what makes this tractable — say that sentence and watch the interviewer sit up).

§D.7 · TYPE F · THE BOSS

When forward state fails — DP from the exit

LC 174 · Dungeon Game (hard) Grid of gains/damages; knight walks right/down from top-left to the princess. Minimum starting health (health must stay ≥ 1 throughout)?

Why the obvious DP is wrong (this is the whole problem): walking forward, a path's quality is TWO numbers — health accumulated so far AND the worst dip along the way — and they conflict: one forward path may have more current health, another a gentler dip, and either could win later. No single forward scalar is sufficient — a concrete Markov property violation (§D.1). More state won't save it cheaply; changing direction will.

The backward reframe: define need[r][c] = minimum health entering (r, c) that suffices to reach the princess. From (r, c) you'll take the cheaper onward branch: need = min(need[r+1][c], need[r][c+1]) − dungeon[r][c], clamped at max(1, ·) — health may never sit below 1, and surpluses don't carry backward (the clamp is a discard: excess future headroom cannot reduce what you must arrive with). The future is now a single summarizable number — Markov restored by reversing time.

Solutionattempt it on paper first
def calculateMinimumHP(dungeon):
    m, n = len(dungeon), len(dungeon[0])
    need = [[float('inf')] * (n + 1) for _ in range(m + 1)]
    need[m][n - 1] = need[m - 1][n] = 1    # sentinels: exit alive with ≥ 1
    for r in range(m - 1, -1, -1):
        for c in range(n - 1, -1, -1):
            best = min(need[r + 1][c], need[r][c + 1])
            need[r][c] = max(1, best - dungeon[r][c])
    return need[0][0]

The ∞ border + two 1-sentinels replace all edge-casing — the sentinel move of §1.4 / §S.7 / §BS.7, fourth appearance.

Complexity derivationre-derive it first

mn states × O(1) → O(mn) time, O(n) space with a rolling row. The lesson outranks the bound: when no forward state satisfies Markov, try reversing the direction of time — the future often summarizes where the past doesn't.

When this feels derivable — spot the two-number conflict, flip the direction, clamp as discard — the set is yours. LC 139 (Word Break) and LC 91 (Decode Ways) are the ladder's connective tissue on the way here.

§D.8 · BEFORE YOU SUBMIT + INTERROGATION

Traps, signals & interrogation

Signals → pattern (added to the drill deck in §3.2)

"count ways to reach / climb / decode"        → 1D DP, condition on the last step, dp[0]=1
"max/min with adjacency or usage limits"      → fold the constraint into the state (take/skip)
"fewest coins/items to hit a target, reuse"   → unbounded knapsack — forward loop
"can a subset hit a target, each item once"   → 0/1 knapsack — REVERSED inner loop
"count / best paths in a grid"                → grid DP, rolling row; + for count, min/max for best
"minimum starting resource to survive"        → backward DP from the exit, clamp at the floor

Interrogation — answer in your journal

D1

Draw fib(5)'s recursion tree and its state DAG; count nodes in each. State precisely what memoization changes, and derive both complexities from the two pictures.

Hint → answer sketchjournal first

Hint: Count calls vs distinct inputs.

Sketch: fib(4) tree: 9 calls; distinct states: 5 (f0..f4). Memoization converts every repeat into a lookup — the tree collapses onto the DAG: exponential Θ(φⁿ) → O(n) time, O(n) memo. The two pictures ARE the derivation.

D2

Trace the forward inner loop on nums = [3], target = 6 to the false positive. Then prove the downward loop cannot reuse an item — and name the earlier ordering discipline this is.

Hint → answer sketchjournal first

Hint: Which row does dp[t−x] belong to?

Sketch: Forward with nums=[3], target 6: dp[3] |= dp[0] (True), then dp[6] |= dp[3] — the same 3 feeds itself. Downward, dp[t−x] still holds the PREVIOUS item-set's value → one use per item. It is §1.4's check-before-insert expressed as an iteration direction.

D3

Coins [1, 3, 4], amount 6: show greedy's failure, and explain exactly why no exchange argument exists (what swap would need to be safe, and why it isn't?). Then define pseudo-polynomial precisely via O(S·T).

Hint → answer sketchjournal first

Hint: What would a safe swap have to preserve?

Sketch: Greedy on 6: 4+1+1 = 3 coins; optimal 3+3 = 2. An exchange proof would need 'a big coin is never worse than smaller ones summing the same' — false, because the leftover target changes structure non-locally. Pseudo-polynomial: O(S·T) is linear in T's VALUE = exponential in its digit count; T = 10⁹ is ~30 digits of doom.

D4

Coins [1, 2], amount 3: trace coins-outer and amount-outer to their different counts (2 vs 3). Prove coins-outer counts each multiset exactly once — which Set 10 bijection argument is this?

Hint → answer sketchjournal first

Hint: What does each nesting let sequences do?

Sketch: Coins-outer fixes a global coin order → every multiset built exactly once in canonical order (§K.2's bijection over coin indexes). Amount-outer lets each amount re-choose any coin → orderings count: [1,2] → 3 gives 2 combinations vs 3 permutations (1+1+1, 1+2, 2+1).

D5

Dungeon Game: construct two forward paths where one has more health and the other a gentler dip — proving no single forward scalar suffices. Then derive the backward recurrence including the max(1, ·) clamp and the sentinel border.

Hint → answer sketchjournal first

Hint: Build two arrivals that rank oppositely on the two numbers.

Sketch: Route A arrives richer but once dipped near death; route B poorer but steady — which is better depends on the future, so no single forward scalar suffices: Markov violated. Backward: need = max(1, min(need_right, need_down) − cell). The clamp says health never sits below 1 and future surplus can't flow backward; the ∞ border + two sentinel 1s seed the exit.

§X.1 · SET 13 — DP II (SUBSEQUENCES) & DP III (STATE MACHINES)

Theory

Two redundancies, two state designs:

  • DP II kills: enumerating subsequences — 2ⁿ per string — when the future of a comparison depends only on positions, never on which characters got matched. The pair-of-prefixes state (i, j) collapses a 2ⁿ × 2ᵐ candidate space onto an n·m grid.
  • DP III kills: re-deriving "what situation am I in" per step. When the situation is one of finitely many modes (holding a stock, in cooldown, free), carry one optimum per mode per day — the state machine is a layered DAG, day-layers × modes, and the DP is literally longest-path on it (§D.1's identity, drawn).

The DP II skeleton (LCS form — the grid everything else decorates):

dp = [[0] * (m + 1) for _ in range(n + 1)]   # dp[i][j]: answer for s1[:i], s2[:j]
for i in range(1, n + 1):
    for j in range(1, m + 1):
        if s1[i - 1] == s2[j - 1]:
            dp[i][j] = dp[i - 1][j - 1] + 1  # both last chars consumed
        else:
            dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
The invariant + the row-0 seed

Loop order = topological order of the grid DAG: every cell read is final (§D.1). The (n+1)-sizing gives row and column 0 to the empty prefix — the same seed as prefix[0] = 0 (§1.1) and {0:1} (§1.4). And note the off-by-one contract it creates: dp[i] pairs with s[i−1] — fix that mapping in your head before writing any cell.

⚠ Hidden requirements

(1) Subsequence ≠ substring — gaps allowed vs contiguous; LC 516 vs LC 5 look like twins and have different recurrences. Mixing them is a wrong answer, not a style issue. (2) n·m must fit — 10⁵ × 10⁵ is 10¹⁰ cells; and LIS at n = 2.5·10⁵ demands the O(n log n) patience upgrade (Type B) — the escape hatches matter as much as the grid. (3) Modes must be finite and known — if the "mode" needs unbounded memory, the machine explodes and the state needs redesign.

Complexity tells: pair-of-prefixes ⇒ O(n·m) time, rollable to O(m) space. Day × modes ⇒ O(n·k) with k a small constant ⇒ O(n). LIS: O(n²) grid-thinking, O(n log n) with binary search inside the DP.

In plain English — the same idea, slowernew here? start with this

Subsequence problems sound scary — “compare all 2ⁿ subsequences?!” — until you notice what the comparison actually needs: just two bookmarks, “how far am I in string one” and “how far in string two”. Nothing about WHICH letters got matched matters for the future, only the positions. Two bookmarks → an i-by-j table of little answers.

dp[i][j]letters match:use both ↘skip a letter of s1 ↓skip a letter of s2 →
Two bookmarks (i, j) are the whole state. Each cell answers from three neighbors: match diagonally, or skip one letter from either string.

Each cell asks one tiny question — “do these two letters match?” — and takes its answer from a neighbor: diagonal if they match (both bookmarks advance), or the better of up/left if not (skip a letter from one side). Fill the table corner to corner and the final cell is the answer.

The state-machine half is even friendlier: on any stock day you’re in one of a FEW situations — holding a share, free to buy, cooling down. Keep just “best profit per situation”, and each morning update every situation from yesterday’s situations. It’s a tiny machine turning one click per day, and drawing its circles-and-arrows picture (see the figure in §X.6) is genuinely most of the solution.

§X.2 · TYPE A

Longest common subsequence — the archetype grid

LC 1143 · Longest Common Subsequence Length of the longest subsequence present in both strings.

Brute force: enumerate one string's 2ⁿ subsequences, check each against the other → O(2ⁿ · m).

The conditioning move, proved: look at the last characters of both prefixes.

  • Equal: take the match — dp[i−1][j−1] + 1. Never skip a match (exchange argument: any optimal solution not using this pair can be rewritten to use it, at no loss — pair the last equal chars instead of whatever it matched them to).
  • Unequal: the two last chars cannot both end a common subsequence, so at least one is disposable — every common subsequence of (i, j) survives inside (i−1, j) or (i, j−1). Dropping one each way and taking the max is exhaustive AND safe; that's the whole correctness proof.
Check yourself: LCS("abc", "ac")?

2 (“ac”): diagonal match on ‘a’, drop the ‘b’ (max case), diagonal match on ‘c’. Trace the 4×3 grid once by hand — it cements the two cases.

Solutionattempt it on paper first
def longestCommonSubsequence(s1, s2):
    n, m = len(s1), len(s2)
    dp = [[0] * (m + 1) for _ in range(n + 1)]
    for i in range(1, n + 1):
        for j in range(1, m + 1):
            if s1[i - 1] == s2[j - 1]:
                dp[i][j] = dp[i - 1][j - 1] + 1
            else:
                dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
    return dp[n][m]

Reads only the previous row → roll to two rows (or one + a prev-diagonal temp, Type C). Full grid kept when the answer must be reconstructed — walk back through the argmax choices.

Complexity derivationre-derive it first

n·m states × O(1) → O(n·m) time, O(m) rolled space. At the typical 10³ × 10³ constraint: 10⁶ cells — the constraint was the hint.

§X.3 · TYPE B

LIS — and the binary-search upgrade

LC 300 · Longest Increasing Subsequence Length of the longest strictly increasing subsequence; follow-up demands O(n log n).

Round 1 — the O(n²) DP: dp[i] = LIS ending exactly at i = 1 + max over j < i with nums[j] < nums[i]. The "ending at" state is the trick to remember: it makes the recurrence local.

Round 2 — patience: keep tails, where tails[k] = the smallest possible tail of any increasing subsequence of length k+1. Two proofs make it work:

  • tails is always strictly increasing: if tails[a] ≥ tails[b] for a < b, then the length-(b+1) subsequence's first a+1 elements end in something < tails[b] ≤ tails[a] — a better length-(a+1) tail, contradiction.
  • Processing x: replace the first tail ≥ x (a lower bound — §BS.3, verbatim): x extends everything shorter and is a cheaper tail for that length. Appending when no tail ≥ x means x extends the longest — length grows.
Solutionattempt it on paper first
from bisect import bisect_left

def lengthOfLIS(nums):
    tails = []                     # tails[k] = min tail of an IS of length k+1
    for x in nums:
        i = bisect_left(tails, x)  # lower bound — strict increase
        if i == len(tails):
            tails.append(x)
        else:
            tails[i] = x
    return len(tails)

tails is not an actual subsequence — it's evidence about lengths; reconstruction needs parent links. And the strict/inclusive decision lives in one identifier: bisect_left for strictly increasing, bisect_right for non-decreasing — [2, 2] answers 1 vs 2.

Complexity derivationre-derive it first

n elements × one O(log n) lower-bound each → O(n log n), O(n) space — needed at n = 2.5·10⁴+ where O(n²) TLEs. Binary search living inside a DP is the compounding this lab is built around.

§X.4 · TYPE C

Edit distance — three-way conditioning

LC 72 · Edit Distance Minimum insert/delete/replace operations to turn word1 into word2.

The derivation: condition on how the last characters get resolved. Equal → free, diagonal. Unequal → exactly one of three last operations happened: delete w1's last (dp[i−1][j] + 1), insert w2's last (dp[i][j−1] + 1), or replace (dp[i−1][j−1] + 1). The three neighbors of the grid cell ARE the three operations — exhaustive by construction. Base cases: row 0 = build by j inserts, column 0 = erase by i deletes.

Solutionattempt it on paper first
def minDistance(w1, w2):
    n, m = len(w1), len(w2)
    dp = list(range(m + 1))            # row 0: j inserts build w2[:j]
    for i in range(1, n + 1):
        prev = dp[0]                   # carries dp[i-1][j-1]
        dp[0] = i
        for j in range(1, m + 1):
            cur = dp[j]
            if w1[i - 1] == w2[j - 1]:
                dp[j] = prev
            else:
                dp[j] = 1 + min(prev, dp[j], dp[j - 1])
            prev = cur
    return dp[m]

Rolled to one row + the prev diagonal temp — overwrite dp[j] before saving it and the diagonal is gone; that temp is the §D.3 simultaneity bug's grid-shaped cousin.

Complexity derivationre-derive it first

n·m cells × O(1) → O(n·m) time, O(m) space rolled. Trace "horse" → "ros" = 3 by hand once; it cements the three-neighbor picture.

§X.5 · TYPE D

Interval DP on one string

LC 516 · Longest Palindromic Subsequence Length of the longest palindromic subsequence of s.

Two routes, know both: the transform (§1.7's move): LPS(s) = LCS(s, reverse(s)) — one line of insight and Type A does the rest. And the direct interval DP, worth coding because window-shaped states recur (burst balloons, matrix chains): dp[i][j] = LPS inside s[i..j]. Ends equal → both join: 2 + dp[i+1][j−1]. Unequal → at least one end is disposable (same disposability proof as Type A): max(dp[i+1][j], dp[i][j−1]).

The order proof: a window depends only on shorter windows — iterate i descending, j ascending, and both dependencies (i+1 row, j−1 column) are final when read. Choosing the loop order by asking "what must already be final?" is §D.1's step 4 in its purest form.

Solutionattempt it on paper first
def longestPalindromeSubseq(s):
    n = len(s)
    dp = [[0] * n for _ in range(n)]
    for i in range(n - 1, -1, -1):     # i descending: row i+1 is final
        dp[i][i] = 1
        for j in range(i + 1, n):      # j ascending: dp[i][j-1] is final
            if s[i] == s[j]:
                dp[i][j] = dp[i + 1][j - 1] + 2
            else:
                dp[i][j] = max(dp[i + 1][j], dp[i][j - 1])
    return dp[0][n - 1]

When j = i+1, the diagonal read dp[i+1][i] is the zero-filled empty window — the base case handles itself. Substring sibling warning: LC 5 (palindromic substring) cannot drop characters — different recurrence, expand-around-center tools; conflating them is the classic mix-up.

Complexity derivationre-derive it first

n²/2 windows × O(1) → O(n²) time and space (roll to O(n) if pushed). The transform route inherits Type A's O(n·m) = O(n²) — same bound, one insight cheaper.

§X.6 · TYPE E · DP III

The stock state machine

LC 309 · Best Time to Buy and Sell Stock with Cooldown Unlimited transactions, but selling triggers a one-day cooldown before the next buy. Max profit.

Draw the machine first — it IS the solution: three modes: hold (own the stock), sold (sold today — cooling), rest (free to buy). Transitions per day, each justified:

restholdsoldbuy: −pricesell: +pricecooldown: forced, one daykeep holdingwait
The whole problem, drawn: three modes, five edges. The cooldown lives in the sold→rest edge, not in extra state.
  • hold(t) = max(hold(t−1), rest(t−1) − p) — keep holding, or buy; buying requires yesterday free, not cooling — that's where the cooldown constraint lives, in the edge, not in extra state.
  • sold(t) = hold(t−1) + p — selling requires holding.
  • rest(t) = max(rest(t−1), sold(t−1)) — cooling ends after exactly one day.

One optimum per mode per day is sufficient — the Markov audit (§D.1): the future needs your mode and profit, nothing else about history.

Solutionattempt it on paper first
def maxProfit(prices):
    hold, sold, rest = float('-inf'), 0, 0
    for p in prices:
        hold, sold, rest = max(hold, rest - p), hold + p, max(rest, sold)
    return max(sold, rest)             # never end holding

Tuple assignment = all three read yesterday's values (§D.3's simultaneity, now with three variables). hold starts at −∞: with 0, day one could "sell" stock never bought. The answer excludes hold — unsold inventory is sunk cost.

Complexity derivationre-derive it first

n days × 3 modes × O(1) edges → O(n) time, O(1) space. The family is one machine with edges edited: LC 122 (no cooldown — two modes), LC 714 (fee on the sell edge), LC 123/188 (k transactions — modes × k, O(n·k)). Learn the machine, not the six problems.

§X.7 · TYPE F · THE BOSS

Counting on the grid

LC 115 · Distinct Subsequences (hard) How many distinct ways does s contain t as a subsequence?

Brute force: enumerate s's 2ⁿ subsequences, compare each to t.

The derivation: dp[i][j] = ways s[:i] contains t[:j]. Condition on whether s's last character participates: it never has to (dp[i−1][j]); it can pair with t's last only when they match (+ dp[i−1][j−1]). Counting ⇒ the branches add (disjoint by "does s[i−1] match t's end or not"). Base dp[i][0] = 1 — one way to contain the empty string — the {0:1} seed carrying a whole hard problem.

Rolling exposes the boss move: one row, inner loop downward — dp[j−1] must still be the previous row's value or s's character feeds its own update. This is §D.6's reversed-loop proof, resurfacing because counting (unlike min in Coin Change) is not idempotent — duplicates corrupt sums.

Solutionattempt it on paper first
def numDistinct(s, t):
    n, m = len(s), len(t)
    dp = [1] + [0] * m                 # dp[j]: ways vs t[:j]; dp[0] = empty seed
    for i in range(1, n + 1):
        for j in range(m, 0, -1):      # DOWNWARD: dp[j-1] is the old row
            if s[i - 1] == t[j - 1]:
                dp[j] += dp[j - 1]
    return dp[m]

Breaking input for the forward loop: s = "aaa", t = "aa" — ascending j lets each 'a' cascade through both positions in one pass, inflating the count past the true 3. Trace it; it's interrogation X5.

Complexity derivationre-derive it first

n·m cells × O(1) → O(n·m) time, O(m) space. Counts explode combinatorially — Java needs long (or a modulus if asked); Python's big ints are immune, say so.

When this feels derivable — participation conditioning, the additive split, the seed, the downward roll — both DP sets are yours. The full arc from §D.2 to here is the same five steps at increasing state sophistication; that arc, not any single recurrence, is what transfers to unseen problems.

§X.8 · BEFORE YOU SUBMIT + INTERROGATION

Traps, signals & interrogation

Signals → pattern (added to the drill deck in §3.2)

"longest common subsequence / min supersequence"  → pair-of-prefixes grid, empty row-0 seed
"longest increasing subsequence"                  → O(n²) 'ending-at' DP → patience + lower_bound
"min operations to transform one string"          → 3-way conditioning — the neighbors ARE the ops
"palindromic SUBSEQUENCE"                         → interval DP by window size, or LCS vs reverse
"stock trading with cooldowns / fees / k trades"  → state machine — one optimum per mode per day
"count ways one string contains another"          → counting grid: +, seed dp[i][0]=1, roll downward

Interrogation — answer in your journal

X1

LCS: prove the unequal case is safe and exhaustive (every common subsequence survives in a subproblem), and prove via exchange that a match should never be skipped.

Hint → answer sketchjournal first

Hint: Case-split on whether each last char is used.

Sketch: Unequal ends can't both terminate a common subsequence, so any CS omits one → it survives in (i−1, j) or (i, j−1); taking max over both is exhaustive and safe. Equal ends: any optimum not pairing them can be rewritten (exchange) to pair them at no loss — the diagonal +1 is optimal, never skip a match.

X2

Prove tails is strictly increasing at all times, and that len(tails) equals the LIS length (achievability AND impossibility of longer). Point to §BS.3's lower bound in the algorithm.

Hint → answer sketchjournal first

Hint: Suppose tails[a] ≥ tails[b] for a < b.

Sketch: The length-(b+1) subsequence's first a+1 elements end strictly below tails[b] ≤ tails[a] — a better length-(a+1) tail, contradiction → tails strictly increases. len(tails) = LIS: every entry witnesses a real subsequence (achievable) and any length-L IS forces L entries (no better possible). The replacement is bisect_left — §BS.3 verbatim.

X3

Edit distance: derive all three transitions from "condition on the last operation", trace "horse" → "ros" to 3, and show exactly what breaks in the rolled version without the prev-diagonal temp.

Hint → answer sketchjournal first

Hint: Name the operation each grid neighbor performs.

Sketch: Up = delete w1's last, left = insert w2's last, diagonal = replace (free if equal) — conditioning on the last operation, exhaustive by construction. horse→ros traces to 3. Rolled: dp[j] must be saved into prev BEFORE overwriting, or the 'diagonal' silently reads the current row and replace behaves like a second insert.

X4

LC 309: draw the three-mode machine and justify every edge (why buying requires rest; why sold→rest is forced). Then construct the concrete wrong answer produced by hold = 0 initialization.

Hint → answer sketchjournal first

Hint: What does hold = 0 claim you own?

Sketch: Edges: buy requires yesterday-rest (the cooldown IS that edge), sell requires hold, sold→rest is forced after one day. hold = 0 means 'holding stock that cost nothing': prices [10] → sold = hold + 10 = 10 profit without ever buying. −∞ forces the first buy to pay real money.

X5

LC 115 on s = "aaa", t = "aa": trace the ascending inner loop to the inflated count, then the descending loop to 3. Name the Set 12 proof this repeats, and explain why Coin Change's min was immune but counting is not.

Hint → answer sketchjournal first

Hint: Which dp[j−1] does the ascending loop read?

Sketch: Ascending on s='aaa', t='aa': at i=1, dp[1] becomes 1 and then dp[2] += dp[1] — claiming 'aa' occurs in the single 'a'. Descending reads the previous row → correct 3. Same proof as §D.6's knapsack. Coin Change survived either order because min is idempotent — re-relaxation can't corrupt a min, but sums double-count.

§V.1 · SET 14 — DESIGN DATA STRUCTURES

Theory

Redundancy it kills: design problems hand you an operations contract — a class where every method must hit a per-op bound. The brute force uses one structure and pays a linear scan somewhere, and that scan always re-derives a fact (recency order, an element's position, the latest version ≤ t) that a second structure could have maintained incrementally. The pattern: compose structures so each maintains exactly the fact one operation needs, wired so every mutation updates every view in O(1).

The skeleton is a procedure, not code:

  1. Write the contract as a table — op → required bound (and ask: amortized or worst-case?).
  2. Per op, name the fact it needs instantly — membership? recency order? a random slot? latest ≤ t?
  3. Pick the minimal structure per fact — hashmap = membership/location; doubly linked list = order with O(1) splice; array = O(1) random index; append-only sorted list = lower bound. This is §B.1's payload question promoted to whole structures.
  4. Wire them — every mutation updates every view before returning.
The invariant — all views agree

Every entry exists in all constituent structures simultaneously, and they never disagree; each method restores full consistency before it returns. State this before coding — the quiet failures in this set are all partial-update bugs (mutated one view, forgot the other), which produce wrong answers later, far from the cause. Interviewers grade the invariant discipline more than the code.

⚠ Hidden requirements

(1) The contract decides the design — amortized-OK admits two-stacks queues and dynamic arrays; worst-case-per-op forbids them. Ask. (2) Keys must be hashable (§B.1). (3) Language accidents: Python dicts preserve insertion order (guaranteed ≥ 3.7; OrderedDict adds move_to_end/popitem); Java's LinkedHashMap does LRU in access-order mode; JS Map preserves order but plain objects reorder integer-like keys. Use the library and say what it hides — then offer to hand-roll the DLL, because that's the actual question.

Complexity tell: per-operation bounds, frequently amortized — the dynamic array's O(1) append is the doubling argument, and the queue-from-two-stacks is §2.2's charging argument in a new costume (each element pays for its own two moves). The giveaway phrasing: "implement a class supporting X, Y, Z in O(1)".

In plain English — the same idea, slowernew here? start with this

Design questions are a restaurant kitchen. Cram everything into one giant fridge (one data structure) and some order will always be slow to plate. A real kitchen has stations: each ingredient lives where its cook can grab it instantly. The problem hands you the menu — “get must be instant, evict-the-stalest must be instant” — and your job is choosing one station per need: a hashmap when you need to FIND things, a linked list when you need ORDER you can splice, an array when you need to grab a RANDOM slot.

The house rule that everything hangs on: every delivery updates every station. When an ingredient comes in or goes out, the map, the list, the counters — all of them — must be told, before the method returns. Nearly every bug in this set is “updated one station, forgot the other”, and it never fails loudly — it serves stale food three orders later. Say the rule out loud before coding; check every method against it after.

§V.2 · TYPE A

Snapshot pairing — Min Stack

LC 155 · Min Stack A stack with push, pop, top, and getMin — all O(1).

Easy-tier, included because it IS the archetype: one auxiliary fact ("the min of everything below me") maintained per element.

Brute force: scan the stack on every getMin → O(n) per query.

The derivation: the min of a stack's contents is a prefix property — it depends only on the elements below, which never change while you're above them (LIFO's gift). So snapshot it: push (value, min-so-far). A popped snapshot can never be needed again, because the elements that produced it left with it. This is §1.1's prefix idea with min instead of sum, stored in the structure itself.

Solutionattempt it on paper first
class MinStack:
    def __init__(self):
        self.stack = []                # (value, min of stack up to here)
    def push(self, val):
        m = min(val, self.stack[-1][1]) if self.stack else val
        self.stack.append((val, m))
    def pop(self):
        self.stack.pop()
    def top(self):
        return self.stack[-1][0]
    def getMin(self):
        return self.stack[-1][1]

A single global min variable fails the moment the min is popped — restoring it needs a scan. The snapshot's immutability is what buys O(1).

Complexity derivationre-derive it first

All four ops touch one tuple → O(1) worst-case each, O(n) space. Space-optimized follow-up (store mins only when they change) trades a little logic for fewer tuples — know it exists.

§V.3 · TYPE B

Array + map — the swap-with-last delete

LC 380 · Insert Delete GetRandom O(1) A set supporting insert, remove, and getRandom (uniform), all average O(1).

Why one structure can't do it: a hashmap gives O(1) insert/remove but cannot be indexed uniformly (buckets are sparse and unordered); an array gives O(1) random access but O(n) deletes. Each structure covers the other's gap — the composition is forced by step 2 of §V.1.

The O(1) delete, derived: arrays delete cheaply only at the END. Any element can become the end: overwrite the victim's slot with the last element, then pop. The map (value → index) keeps positions honest. Update order is load-bearing: re-point the moved element's index before deleting the victim's entry — when the victim IS the last element (self-swap), the reverse order corrupts the map. The check-before-insert ordering discipline (§1.4), in structure-maintenance form.

Solutionattempt it on paper first
class RandomizedSet:
    def __init__(self):
        self.arr = []
        self.idx = {}                  # value -> position in arr
    def insert(self, val):
        if val in self.idx:
            return False
        self.idx[val] = len(self.arr)
        self.arr.append(val)
        return True
    def remove(self, val):
        if val not in self.idx:
            return False
        i, last = self.idx[val], self.arr[-1]
        self.arr[i] = last             # tail fills the hole
        self.idx[last] = i             # BEFORE deleting val's entry
        self.arr.pop()
        del self.idx[val]
        return True
    def getRandom(self):
        return random.choice(self.arr)

getRandom is uniform because the array is exactly the current set, one slot per element — the uniformity argument is one sentence, give it (§O.5's habit).

Complexity derivationre-derive it first

All ops: O(1) average (hash ops — say it) with amortized array append (the doubling argument). Follow-up LC 381 allows duplicates: the map's payload grows to a set of indices — §B.1's payload question again.

§V.4 · TYPE C

Versioned reads — append + lower bound

LC 981 · Time Based Key-Value Store set(key, value, timestamp); get(key, timestamp) returns the value at the latest timestamp ≤ t.

Brute force: per get, scan all versions of the key → O(versions).

The gift in the contract (read the constraints!): timestamps are strictly increasing per the API — so appending keeps each key's version list sorted for free. Manufactured monotonicity (§BS.1's requirement, supplied by the problem), which turns get into a boundary search: the rightmost timestamp ≤ t is bisect_right − 1 (§BS.3's upper-bound sibling).

Solutionattempt it on paper first
from bisect import bisect_right

class TimeMap:
    def __init__(self):
        self.store = {}                # key -> ([timestamps], [values])
    def set(self, key, value, timestamp):
        ts, vs = self.store.setdefault(key, ([], []))
        ts.append(timestamp)           # contract: strictly increasing
        vs.append(value)
    def get(self, key, timestamp):
        if key not in self.store:
            return ""
        ts, vs = self.store[key]
        i = bisect_right(ts, timestamp)   # first ts > t
        return vs[i - 1] if i else ""

The if i guard is the "everything is later than t" edge. And if timestamps were NOT guaranteed increasing, appending silently breaks sortedness and bisect returns garbage — the §BS quiet failure; you'd need insort O(n) or a tree, and the design changes. The contract was the design.

Complexity derivationre-derive it first

set: amortized O(1) append. get: O(log v) over that key's v versions. Space: one entry per set call — nothing is ever rewritten, which is why this shape scales (it's the storage layout of real time-series stores; saying so is a free senior point).

§V.5 · TYPE D · THE MOST-ASKED

LRU Cache — hashmap + doubly linked list

LC 146 · LRU Cache get/put in O(1); at capacity, evict the least-recently-used key.

The contract → the composition: O(1) lookup ⇒ hashmap. O(1) "move to most-recent" ⇒ a list you can splice anywhere in O(1) ⇒ doubly linked (unlinking needs the predecessor; the prev pointer IS the O(1) — a singly linked list pays O(n) to find it, an array pays O(n) to shift). O(1) eviction ⇒ the LRU end of that same list. Map values point at list nodes: the map answers where, the list answers when.

hashmaphead·1:A2:B·tail1 ◦2 ◦MRU endLRU end → evict here
Two views wired together: map values point INTO the list. Every mutation must update both — the all-views-agree invariant.

Two details that decide the interview: (1) a read is a use — get must move the node to the front, or recency is wrong (the classic failing trace lives in V3). (2) nodes carry their key — eviction discovers the victim via the list but must delete it from the map, and only the key unlocks that. Sentinels at both ends (§L.3's dummy, doubled) erase every empty/first/last edge case.

STEP THROUGH · LRU CAP 2: PUT,PUT,GET,PUTstep 0

    

Solutionattempt it on paper first
class Node:
    def __init__(self, key=0, val=0):
        self.key, self.val = key, val
        self.prev = self.next = None

class LRUCache:
    def __init__(self, capacity):
        self.cap = capacity
        self.map = {}                          # key -> node
        self.head, self.tail = Node(), Node()  # sentinels: MRU | LRU ends
        self.head.next, self.tail.prev = self.tail, self.head
    def _unlink(self, node):
        node.prev.next, node.next.prev = node.next, node.prev
    def _push_front(self, node):
        node.prev, node.next = self.head, self.head.next
        self.head.next.prev = node
        self.head.next = node
    def get(self, key):
        if key not in self.map:
            return -1
        node = self.map[key]
        self._unlink(node)                     # a read IS a use
        self._push_front(node)
        return node.val
    def put(self, key, value):
        if key in self.map:
            node = self.map[key]
            node.val = value                   # update AND refresh recency
            self._unlink(node)
            self._push_front(node)
            return
        if len(self.map) == self.cap:
            lru = self.tail.prev
            self._unlink(lru)
            del self.map[lru.key]              # the key lives in the node
        node = Node(key, value)
        self.map[key] = node
        self._push_front(node)

Python's OrderedDict (move_to_end + popitem) or Java's access-order LinkedHashMap compress this to a few lines — offer them, then build the DLL: the library version is the answer to a different question.

Complexity derivationre-derive it first

get/put: one map op (O(1) average) + a constant number of pointer splices → O(1) per operation, O(capacity) space. Every mutation path touches both views — audit each against the all-views-agree invariant before declaring done.

§V.6 · TYPE E · THE BOSS

LFU Cache — frequency buckets + a pointer that never searches

LC 460 · LFU Cache (hard) get/put in O(1); evict the least-frequently-used key — ties broken by least-recent.

The contract's hard part: eviction needs "minimum frequency, then LRU among those" in O(1). A heap gives O(log n) and stale entries; the O(1) answer is one LRU list per frequency (each bucket is Type D's list, reused) plus a min_freq pointer.

Why min_freq never needs a search — the proof that makes this O(1): it changes in exactly two ways. (1) On insert, a frequency-1 key now exists → min_freq = 1, unconditionally. (2) On touch, the accessed key moves f → f+1; if it emptied bucket f and f was the min, nothing below f exists (f was minimal) and the departed key is now at f+1 → min_freq = f+1, a rise of exactly 1. Enumerate the cases, and the pointer is always right — a one-direction argument (§2.2's family) on a counter.

Solutionattempt it on paper first
from collections import defaultdict, OrderedDict

class LFUCache:
    def __init__(self, capacity):
        self.cap = capacity
        self.vals = {}                          # key -> (value, freq)
        self.buckets = defaultdict(OrderedDict) # freq -> keys, LRU order
        self.min_freq = 0
    def _touch(self, key):
        val, f = self.vals[key]
        del self.buckets[f][key]
        if not self.buckets[f]:
            del self.buckets[f]
            if self.min_freq == f:
                self.min_freq = f + 1           # the only way it rises
        self.buckets[f + 1][key] = None
        self.vals[key] = (val, f + 1)
    def get(self, key):
        if key not in self.vals:
            return -1
        self._touch(key)
        return self.vals[key][0]
    def put(self, key, value):
        if self.cap == 0:
            return                              # the classic forgotten guard
        if key in self.vals:
            self._touch(key)
            self.vals[key] = (value, self.vals[key][1])
            return
        if len(self.vals) == self.cap:
            evict, _ = self.buckets[self.min_freq].popitem(last=False)
            del self.vals[evict]                # LRU within the min bucket
        self.vals[key] = (value, 1)
        self.buckets[1][key] = None
        self.min_freq = 1                       # a fresh key is always minimal

OrderedDict here is Type D's DLL borrowed from the library — say that, and offer the hand-rolled version. popitem(last=False) is the LRU end; arbitrary eviction within the bucket violates the tie-break contract.

Complexity derivationre-derive it first

get/put: constant map + ordered-dict operations → O(1) average per op, O(capacity) space. Three views (vals, buckets, min_freq) — every mutation path must restore all three; the partial-update surface is triple Type D's, which is exactly why this is the boss.

When this feels derivable — contract → facts → structures → wiring, with the min_freq proof replacing a search — the set is yours, and so is the atlas. Same species at scale: LC 355 (Design Twitter = hashmaps + §H.4's k-way merge), LC 1396, LC 895.

§V.7 · BEFORE YOU SUBMIT + INTERROGATION

Traps, signals & interrogation

Signals → pattern (added to the drill deck in §3.2)

"implement a class, every op O(1)"            → compose structures — one per fact, wire all views
"O(1) insert + delete + uniform random"       → array + map, swap-with-last delete
"cache evicting by recency"                   → hashmap + DLL with sentinels, move-to-front on use
"evict by frequency, ties by recency"         → per-frequency LRU buckets + min_freq pointer
"store versions, read the latest ≤ t"         → append-only per key + bisect_right − 1
"queue from stacks / amortized transfer"      → two containers, each element pays for its two moves

Interrogation — answer in your journal

V1

Min Stack: prove the paired snapshot is correct via the prefix-min property — why can a popped min never be needed again? Then show exactly what breaks with a single global min variable, on a concrete push/pop sequence.

Hint → answer sketchjournal first

Hint: The stack below you never changes while you're on top.

Sketch: Each pushed pair snapshots the min of everything at-or-below — immutable by LIFO (nothing under you moves while you exist). A popped min dies WITH the elements that justified it; the next pair is already correct. One global variable breaks at: push 2, push 1, pop — recovering 2 needs a scan.

V2

LC 380: trace remove(last element) under both update orders and show the corruption. Then explain structurally why a hashmap alone cannot serve getRandom uniformly in O(1).

Hint → answer sketchjournal first

Hint: Walk remove(x) when x occupies the last slot.

Sketch: Index-first: idx[last] = i rewrites x's own entry (last IS x), then deletion removes it — clean. Delete-first: del idx[x], then idx[last] = i resurrects the ghost — the removed value 'exists' again with a stale index. And a hashmap alone can't do getRandom: buckets are sparse and unaddressable — uniform O(1) indexing is exactly what the array contributes.

V3

LRU, capacity 2: trace put(1), put(2), get(1), put(3) with and without move-to-front on get — show the wrong eviction. Then explain why the node must carry its key.

Hint → answer sketchjournal first

Hint: After get(1), who sits at the LRU end?

Sketch: With move-to-front: order [1, 2] → put(3) evicts 2 ✓. Without: order stays [2, 1] → put(3) evicts 1, the key you JUST read. The node carries its key because eviction finds the victim via the LIST (tail.prev) but must delete from the MAP — only the key can index it.

V4

Queue from two stacks (LC 232): prove amortized O(1) with the charging argument — each element pays for its two moves — and list every earlier appearance of that argument in this lab. What design replaces it if the contract demands worst-case O(1) per op?

Hint → answer sketchjournal first

Hint: Follow one element through its whole life.

Sketch: Push onto in-stack, one move to out-stack, one pop — three touches ever, whatever the interleaving → amortized O(1) per op (the §2.2 charge, again). Worst-case-O(1) contract: a linked-list queue, or incremental transfer that moves one element per operation instead of all at once.

V5

LFU: prove min_freq never requires a search by enumerating the only two ways it changes. Then trace capacity 2: put(1), put(2), get(1), put(3) — which key evicts, and by which rule (frequency, then recency)?

Hint → answer sketchjournal first

Hint: Enumerate every line that writes min_freq.

Sketch: Two writers only: put(new key) → 1 (a freq-1 key now exists, trivially minimal); _touch → f+1, only when bucket f emptied AND f was the min (nothing existed below f; the departed key now sits at f+1). No other transition exists → no search ever. Trace: put1, put2, get1 (1 moves to freq 2; bucket 1 still holds 2 → min stays 1), put3 → evict bucket-1's LRU = key 2.

§O.1 · SET 15 — ODDS & ENDS: DEQUE · QUICKSELECT · BITS · SAMPLING

Theory

This set is four specialists. Each replaces a general tool you already own with something strictly better in one narrow, recognizable situation — the skill is the recognition, because the general tool also works and costs you the follow-up:

  • Monotonic deque replaces the heap for window extremes. The heap (§H) carries stale elements and pays log; the deque discards from both ends — dominated elements from the back (§S.1's shadow proof), expired elements from the front (§2.1's window) — the two discard rules this lab opened with, finally fused.
  • Quickselect replaces sort for a single order statistic: partition once, discard a whole side.
  • Bit algebra replaces bookkeeping structures when the fact needed is parity/pairing — XOR answers in O(1) space what a counting map answers in O(n).
  • Math & sampling replace simulation and storage: squaring halves an exponent; a reservoir keeps a uniform sample of an unbounded stream in O(1) memory.

The flagship skeleton — the windowed monotonic deque:

dq = deque()                       # indices; values strictly decreasing
for i, x in enumerate(nums):
    while dq and nums[dq[-1]] <= x:
        dq.pop()                   # dominated: older AND ≤ x — never max again
    dq.append(i)
    if dq[0] <= i - k:
        dq.popleft()               # expired by age
    if i >= k - 1:
        record(nums[dq[0]])        # front = window max
The invariant

The deque holds indices in increasing order with values strictly decreasing; the front is the current window's maximum. Both eviction rules preserve it, and each index enters once and leaves at most once (from one end or the other) → ≤ 2n deque operations. This is the §2.2 amortized argument's final form — it has now carried the window's left pointer, LC 128's walks, converging pointers, stack pops, BFS queues, and a deque.

⚠ Hidden requirements — one per specialist

Deque: eviction must be justified by BOTH rules (dominance + age); if elements can leave for any other reason, you're back to a heap with lazy deletion. Quickselect: O(n) is expected — adversarial input vs a fixed pivot is O(n²); randomize the pivot (median-of-medians gives worst-case O(n), aware-only). It also mutates the input — ask. XOR tricks: need exact pairing structure (every element an even count except the target); different multiplicities need different algebra (mod-3 counters, LC 137). Sampling: uniformity is a theorem, not a vibe — every sampler needs its probability derivation, because no test suite reliably catches a biased one.

Complexity tells: deque → O(n) amortized; quickselect → T(n) = T(≈n/2) + O(n) → O(n) expected (a geometric series, derived in Type C); fast pow → the halving tell of §BS.1 → O(log n); reservoir → one pass, O(1) memory, unknown stream length.

In plain English — the same idea, slowernew here? start with this

This set is a toolbox drawer of four specialists. Each one replaces a general tool you already own — but only inside one narrow, recognizable situation:

• The deque beats the heap when things leave a window strictly by age: bouncers at both ends (too old? out the front. too weak? out the back) beat re-sorting the room.
Quickselect beats sort when you want ONE rank, once: partition, see which side your target lands in, and throw the whole other side away.
XOR beats a tally notebook when items cancel in exact pairs — pairs annihilate, the loner survives, zero memory used.
• A coin-flip rule (reservoir) beats storing a whole stream when you want one fair sample and don’t know how long the stream is.

The general tools always work too — that’s the trap. The skill graded here is recognizing the narrow case and taking the cheaper specialist, then proving it (each specialist above comes with a two-line proof; learn the proof, not the code).

§O.2 · TYPE A

XOR algebra

LC 136 · Single Number Every element appears twice except one; find it in O(n) time, O(1) space.

Easy-tier, included because it IS the archetype — three axioms do all the work.

The derivation: XOR is associative and commutative (reorder freely), self-inverse (a⊕a = 0), with identity 0 (a⊕0 = a). Fold the whole array: mentally reorder so pairs sit together — every pair annihilates to 0, and the singleton survives. The hashmap brute force is also O(n) time but O(n) space; the algebra is the bookkeeping.

Check yourself: XOR of [4, 1, 2, 1, 2]?

4. Reorder (allowed — commutative): (1⊕1)⊕(2⊕2)⊕4 = 0⊕0⊕4 = 4. The pairs annihilate no matter where they sit.

Solutionattempt it on paper first
def singleNumber(nums):
    acc = 0
    for x in nums:
        acc ^= x                       # pairs cancel: a ^ a = 0
    return acc
Complexity derivationre-derive it first

n XORs → O(n) time, O(1) space. The family, by structure: LC 268 (missing number — XOR values against indexes), LC 137 (triples — per-bit mod-3 counters, because XOR is mod-2), LC 260 (two singletons — split the array by the lowest set bit of the combined XOR, reducing to two copies of this problem). Each variant is a different pairing structure; match the algebra to it.

§O.3 · TYPE B

Fast exponentiation — halve, square

LC 50 · Pow(x, n) Compute xⁿ for integer n (possibly negative), without library pow.

Brute force: n multiplications — at n = 2³¹ that's dead on arrival.

The identity: xⁿ = (x²)^(n/2), with an odd n peeling off one factor. Iteratively: walk n's binary digits — square the base each step (building the x^(2^k) ladder), multiply into the result whenever the current bit is set. The halving is §BS.1's tell arriving in arithmetic: the problem size is the exponent, and each step halves it.

Solutionattempt it on paper first
def myPow(x, n):
    if n < 0:
        x, n = 1 / x, -n               # convert FIRST — see the trap
    res = 1.0
    while n:
        if n & 1:
            res *= x
        x *= x                         # the x^(2^k) ladder
        n >>= 1
    return res

Trace 2¹⁰: bits of 10 = 1010 → ladder 2, 4, 16, 256; result takes 4·256 = 1024. Four squarings, two multiplies.

Complexity derivationre-derive it first

One loop iteration per bit of n → O(log n) multiplications, O(1) space. Language traps live here: Python's negative ints have infinite sign bits, so n >>= 1 on negative n never terminates — the conversion must precede the loop. Java's −2³¹ can't be negated in int — widen to long. Same code, three different failure modes.

§O.4 · TYPE C

Quickselect — partition and discard a side

LC 215 · Kth Largest Element (quickselect round) Same problem as §H.2 — now beat the heap's O(n log k) for the one-shot case.

The redundancy left on the table in §H.2: even the size-k heap maintains order information you never consume when the query is one-shot. Partition around a random pivot: the pivot lands at its final sorted position p. If p is the target index, done. Otherwise recurse into one side only — the other side is discarded wholesale: every element there is on the wrong side of rank p (the §BS discard, earned by O(n) partitioning work instead of sortedness).

Expected O(n), derived: a random pivot lands in the middle half with probability 1/2, shrinking the problem to ≤ 3n/4; expected work forms the geometric series n(1 + 3/4 + (3/4)² + …) = 4n. Worst case O(n²) exists (adversarial order vs a deterministic pivot) — say "expected", name the randomized pivot as the fix and median-of-medians as the worst-case-O(n) theory answer.

Solutionattempt it on paper first
import random

def findKthLargest(nums, k):
    target = len(nums) - k             # k-th largest = this ascending index
    lo, hi = 0, len(nums) - 1
    while True:
        p = partition(nums, lo, hi)
        if p == target:
            return nums[p]
        if p < target:
            lo = p + 1                 # left of p: all < pivot — discarded
        else:
            hi = p - 1

def partition(nums, lo, hi):
    r = random.randint(lo, hi)         # randomization IS the guarantee
    nums[r], nums[hi] = nums[hi], nums[r]
    pivot, store = nums[hi], lo
    for i in range(lo, hi):
        if nums[i] < pivot:
            nums[store], nums[i] = nums[i], nums[store]
            store += 1
    nums[store], nums[hi] = nums[hi], nums[store]
    return store

Iterative — no recursion to overflow. It mutates the input; ask whether that's acceptable before running (§GR.2's habit).

Complexity derivationre-derive it first

O(n) expected (the 4n series), O(n²) worst, O(1) extra space. The complete escalation ladder for order statistics, to recite: sort O(n log n) → size-k heap O(n log k) (streams) → quickselect O(n) expected (one-shot, mutable) → buckets O(n) (bounded domain, §B.4). Four tools, one decision tree.

§O.5 · TYPE D

Weighted random pick — three old friends

LC 528 · Random Pick with Weight pickIndex() must return i with probability w[i] / sum(w).

The construction is three earlier sets composed: build the prefix sums of the weights (§1.1) — a monotonically increasing array (§BS.1's requirement, manufactured on purpose) — draw r uniformly from [1, total], and return the lower bound (§BS.3): the first prefix ≥ r.

The uniformity proof (never skip it): index i is chosen iff r lands in (prefix[i−1], prefix[i]] — an interval containing exactly w[i] of the total integers — so P(i) = w[i]/total, exactly. The proof is two lines; presenting the structure with its proof is what separates the senior answer.

Solutionattempt it on paper first
from bisect import bisect_left

class Solution:
    def __init__(self, w):
        self.prefix = []
        total = 0
        for x in w:
            total += x
            self.prefix.append(total)
    def pickIndex(self):
        r = random.randint(1, self.prefix[-1])   # inclusive both ends
        return bisect_left(self.prefix, r)

bisect_left is correct because r starts at 1: the target interval is half-open on the left. Draw r from randint(0, total−1) and the correct call flips to bisect_right — the strict/inclusive decision (§BS.8) reappearing in probability clothing.

Complexity derivationre-derive it first

Init O(n); each pick one lower bound → O(log n). JS warning: weights summing past 2³² break bitwise-coerced arithmetic; Python's exact ints are immune — say so.

§O.6 · TYPE E

Reservoir sampling — uniform from an unknown stream

LC 382 · Linked List Random Node Return a uniformly random node's value; the list is long and its length unknown.

Brute force: copy values to an array, index randomly — O(n) memory; or two passes (count, then walk) — needs the stream twice. The interesting constraint: one pass, O(1) memory, length unknown until the stream ends.

The rule: keep the i-th element with probability 1/i, replacing the current champion. The induction (this IS the answer): after i elements, each has probability exactly 1/i — the newcomer by construction; any previous element had 1/(i−1) and survives the newcomer with probability 1 − 1/i, giving 1/(i−1) · (i−1)/i = 1/i. The invariant "everything seen so far is equally likely" is maintained one arrival at a time.

Solutionattempt it on paper first
class Solution:
    def __init__(self, head):
        self.head = head
    def getRandom(self):
        node, i, choice = self.head, 1, 0
        while node:
            if random.randint(1, i) == 1:   # keep i-th with prob 1/i
                choice = node.val
            node, i = node.next, i + 1
        return choice

The first node always wins its coin flip (randint(1,1) = 1) — the seed case of the induction, handled by the same line.

Complexity derivationre-derive it first

One pass, one O(1) draw per node → O(n) time, O(1) space. Generalizes to sample-k (keep with probability k/i into a k-slot reservoir). The unsettling property to say out loud: a wrong reservoir (keep with 1/2, say) passes every functional test and fails only statistically — correctness here is the proof, not the test suite.

§O.7 · TYPE F · THE BOSS

Sliding window maximum — the deque earns its place

LC 239 · Sliding Window Maximum (hard) Max of every length-k window, in O(n).

The escalation: re-scan per window O(nk) → heap with lazy deletion (§H.7) O(n log n) — correct, and the interviewer will ask for better. The deque is the better.

Two discard proofs, one structure:

  • Back (dominance): an element older than the newcomer AND ≤ it can never be a window max again — every future window containing it also contains the newcomer, which beats it (§S.1's shadow proof, with the window guaranteeing co-membership). Pop it forever.
  • Front (expiry): indices ≤ i − k have aged out of the window (§2.1's expiry). Pop from the front.

Evictions from both ends ⇒ a deque, and what survives is decreasing with the max at the front — Set 01's window and Set 03's monotonic discard, fused into one structure. This is the lab's arguments compounding, literally.

STEP THROUGH · WINDOW MAX, k=3step 0

    

Solutionattempt it on paper first
def maxSlidingWindow(nums, k):
    dq, out = deque(), []              # indices; values decreasing
    for i, x in enumerate(nums):
        while dq and nums[dq[-1]] <= x:
            dq.pop()                   # dominated — never a max again
        dq.append(i)
        if dq[0] <= i - k:
            dq.popleft()               # aged out
        if i >= k - 1:
            out.append(nums[dq[0]])
    return out

Indices, not values — expiry needs ages, and duplicates make value-based expiry wrong ([2,2,2], k = 2 evicts the wrong copy). The <= in the dominance pop keeps the deque strictly decreasing; strict < also yields correct maxes (ties are forgiven, §S.7's max-vs-sum lesson) but retains dead weight.

Complexity derivationre-derive it first

Each index appended once, removed at most once (from one end or the other) → ≤ 2n deque ops → O(n) amortized, O(k) space. Against the heap: no log factor, no stale entries, and the derivation fits in four sentences — which is why this is the follow-up interviewers hold in reserve.

When this feels derivable — two eviction rules, each with its own proof, composed into one pass — the set is yours, and so is the lab's core method: name the redundancy, prove the discard, count the amortized total.

§O.8 · BEFORE YOU SUBMIT + INTERROGATION

Traps, signals & interrogation

Signals → pattern (added to the drill deck in §3.2)

"max/min of every k-window"                     → monotonic deque — dominance from the back, age from the front
"k-th largest, one shot, array mutable"         → quickselect — expected O(n), say the worst case
"every element twice except one"                → XOR fold — O(1) space, exact-pairing required
"x to the power n"                              → halve the exponent, square the base — O(log n)
"pick an index proportional to weight"          → prefix sums + lower bound + uniform draw
"uniform sample from a stream, unknown length"  → reservoir — keep the i-th with probability 1/i

Interrogation — answer in your journal

O1

LC 239: prove both discard rules (dominance can never lose a future max; expiry) and the ≤ 2n amortized bound — then list every appearance of that argument across this lab, Set 01 to here.

Hint → answer sketchjournal first

Hint: Two reasons to leave, one way in.

Sketch: Back-evict (dominance): any future window containing the older element also contains the newer ≥ one. Front-evict (expiry): index ≤ i − k left the window. Each index enters once, leaves at most once → ≤ 2n ops. Lineage: §2.2 window, §B.5 walks, §T pointers, §S pops, §GR queue, §V4 two-stack queue, and now the deque.

O2

Quickselect: derive the expected-O(n) geometric series from the middle-half argument, construct the adversarial input that makes a fixed last-element pivot O(n²), and state both mitigations.

Hint → answer sketchjournal first

Hint: How often does a random pivot land in the middle half?

Sketch: With probability 1/2 the pivot lands in the middle half, leaving ≤ 3n/4: E[work] ≤ n(1 + 3/4 + 9/16 + …) = 4n. Adversary for fixed last-pivot: already-sorted input peels one element per partition → Θ(n²). Fixes: randomized pivot (expectation holds against every input), median-of-medians for worst-case O(n) — name it, don't code it.

O3

Trace myPow(2, 10) through the bit ladder step by step. Then explain the Python negative-shift non-termination and the Java −2³¹ overflow — two languages, two different failures of the same line.

Hint → answer sketchjournal first

Hint: Write 10 in binary and walk LSB to MSB.

Sketch: 10 = 1010₂. Ladder: 2 → 4 → 16 → 256; set bits at 2¹ and 2³ multiply in 4·256 = 1024. Four squarings, two multiplies. Python: a negative n has infinite sign bits, so n >>= 1 never reaches 0 — convert before the loop. Java: negating −2³¹ overflows int — widen to long.

O4

LC 528: prove P(i) = w[i]/total exactly via the interval argument, and justify bisect_left with r ∈ randint(1, total) — then show which call becomes correct if r is drawn from [0, total).

Hint → answer sketchjournal first

Hint: Which r values select index i?

Sketch: i wins iff r lands in (prefix[i−1], prefix[i]] — exactly w[i] of the total integers → P(i) = w[i]/total, exact. With r from randint(1, total), the first prefix ≥ r is bisect_left. Draw r from [0, total) instead and the half-open side flips — first prefix > r, i.e., bisect_right.

O5

Reservoir: run the full induction that after i arrivals every element has probability exactly 1/i. Then show the distribution "keep with probability 1/2" actually produces on a 3-element stream — and explain why only the proof, not tests, catches it.

Hint → answer sketchjournal first

Hint: Multiply survival probabilities backward.

Sketch: Newcomer kept at 1/i; an incumbent had 1/(i−1) and survives with (i−1)/i → exactly 1/i — induction closed. Fixed keep-1/2 on a,b,c: P(c)=1/2, P(b)=1/4, P(a)=1/4 — recency-biased. A functional test sees one draw and learns nothing; only the distribution across many runs (or the proof) exposes it.

§SR.1 · SET 16 — STRINGS RESIDUE

Theory — two halves, honestly labeled

Most "string questions" are pattern questions wearing string clothing — windows, stacks, tries, subsequence grids — and this lab already routes them there. What remains is exactly two things, and they could not be more different:

  • Expand-around-center — a real technique with a real redundancy to kill (palindromic substrings).
  • Parsing / simulation — a category with no redundancy and no trick. The brute force IS the solution; what interviews grade is spec discipline. Recognizing that there is nothing to recognize — and saying so — is itself the senior move.

Half 1 — the redundancy expand-around-center kills: the brute force checks all O(n²) substrings with an O(n) palindrome scan each → O(n³), re-comparing the same interior characters for every enclosing candidate. The fix rests on an identity:

every palindrome ↔ exactly one (center, radius) pair
centers: n characters (odd lengths) + n−1 gaps (even lengths) = 2n−1

Expanding outward from a center makes verification incremental: pal(l−1, r+1) = pal(l, r) AND s[l−1] == s[r+1] — one comparison per growth step instead of re-scanning the interior. The interior's verdict is carried, never recomputed — the same compute-once-reuse economy as §1.1's prefix sums, applied to palindromicity.

The invariant

Inside the expansion loop, s[l..r] is always a verified palindrome; the loop exit is the first proof of failure, so s[l+1..r−1] is the maximal palindrome at this center. Enumerate all 2n−1 centers and the bijection guarantees nothing is missed and nothing is double-counted.

Half 2 — the parsing "template": translate the spec into an ordered checklist (whitespace → sign → digits → clamp), implement it as a small state machine, then run the edge-case ritual. The one guarantee: parsing is a single pass, O(n) always — if your parse loop is quadratic, you're re-scanning or re-concatenating (see traps).

⚠ Hidden requirements

(1) Expand-around-center needs contiguity — palindromic subsequence drops characters and belongs to §X.5's interval DP; the sibling confusion is the most common wrong turn in this territory. (2) Parsing needs a frozen spec: signs, whitespace, overflow policy, rounding direction — every one is a clarifying question (§guide-talk step 1 at its highest stakes), because the spec is the problem. (3) Manacher's O(n) palindrome algorithm exists; it is trap-list tier — name it, never code it.

Complexity tells: centers ⇒ O(n²) worst ("aaaa…" expands every center to a boundary) but with early exit on real text — say "quadratic worst-case, typically far better". Parsing ⇒ O(n), one pass, O(1) or O(n) space depending on output. Constraint smell: palindromic-substring problems cap n around 10³ — the setter telling you O(n²) is intended (§F.5).

In plain English — the same idea, slowernew here? start with this

Two skills share this set, and they could not be more different.

Palindromes: instead of testing every substring (“is this one mirrored? is this one?”), stand at each possible CENTER and grow outward while the two ends keep matching. Mirrored growth means each step only checks the two NEW end letters — everything inside was already verified on the previous step. There are 2n−1 places a mirror can sit (every letter, every gap between letters); try them all.

Parsing: there is genuinely no trick, and that’s not a gap in your knowledge — it’s the design of the question. The spec (signs, spaces, overflow, weird inputs) IS the problem, and the interview is testing whether you can turn a spec into a checklist and follow all of it under pressure. The strongest possible move is saying, out loud: “no algorithm needed here — this is a carefulness problem,” and then writing the checklist before the code.

§SR.2 · TYPE A · THE MISSING TECHNIQUE

Longest palindromic substring

LC 5 · Longest Palindromic Substring Return the longest contiguous palindrome in s. One of the most-asked questions in existence.

The three-rung ladder (walk it in the interview): brute force O(n³) → DP table dp[i][j] = "s[i..j] is a palindrome", O(n²) time and O(n²) space → expand-around-center: same O(n²) time, O(1) space, and much faster on real text because most centers die after one comparison. The DP table buys nothing here — knowing why (you only ever need the verdicts along a center's ray, not the whole triangle) is the senior answer.

The derivation: enumerate every center — each character (odd lengths) and each gap between characters (even lengths). From a center, grow while the ends match; the invariant (§SR.1) makes each growth step a single comparison. Track the best window seen.

Solutionattempt it on paper first
def longestPalindrome(s):
    best_l, best_r = 0, 0              # inclusive bounds of the best
    def expand(l, r):
        nonlocal best_l, best_r
        while l >= 0 and r < len(s) and s[l] == s[r]:
            l -= 1
            r += 1
        if (r - 1) - (l + 1) > best_r - best_l:
            best_l, best_r = l + 1, r - 1   # undo the failed step
    for c in range(len(s)):
        expand(c, c)                   # odd length: centered on a char
        expand(c, c + 1)               # even length: centered on a gap
    return s[best_l:best_r + 1]

The +1/−1 dance after the loop is the off-by-one everyone writes wrong once: the loop exits having already overstepped, so the palindrome is one step back on both sides.

Check yourself: "babad" — which centers produce "bab" and "aba", and what does the gap-center between the two a's yield?

"bab" comes from the char-center at index 1 (the first a's neighbors b…b match); "aba" from the char-center at index 2. Every gap center dies immediately — "babad" has no even-length palindrome, which is why the expand(c, c+1) calls all exit on the first comparison.

Complexity derivationre-derive it first

2n−1 centers × O(n) worst expansion → O(n²) time, O(1) space. The worst case needs nested palindromes ("aaaa" — every center runs to a wall); on strings without them, most expansions are O(1) and the practical cost is near-linear. Say both halves of that sentence.

§SR.3 · TYPE B

Counting with the same centers

LC 647 · Palindromic Substrings Count how many substrings of s are palindromes (each position pair counts separately).

The move: same 2n−1 centers, different aggregation — count every successful expansion step instead of keeping the max. Each step outward is one distinct palindrome, because palindromes correspond one-to-one with (center, radius) pairs: same center → different radii, different centers → different palindromes. No double-count is possible by the bijection, not by luck.

This is the max-vs-count switch you've seen before (§S.7: "max forgives, sum doesn't") — except here the bijection makes counting exactly as safe as maxing.

Solutionattempt it on paper first
def countSubstrings(s):
    count = 0
    for center in range(2 * len(s) - 1):
        l = center // 2
        r = l + center % 2             # even center-index = char, odd = gap
        while l >= 0 and r < len(s) and s[l] == s[r]:
            count += 1                 # each expansion step IS one palindrome
            l -= 1
            r += 1
    return count

The center // 2, + center % 2 encoding walks chars and gaps in one loop — worth knowing as the compact form of Type A's two calls.

Complexity derivationre-derive it first

O(n²) worst, O(1) space — and the output itself can be Θ(n²) ("aaa…" has n(n+1)/2 palindromic substrings), so counting-by-enumeration is at the floor anyway. Trace "aaa" → 6 by hand once; it cements the step-equals-palindrome claim.

§SR.4 · TYPE C · THE PARSING ARCHETYPE

String to integer — the spec is the problem

LC 8 · String to Integer (atoi) Parse an integer: skip leading spaces, optional single sign, digits until a non-digit, clamp to 32-bit range.

Say it out loud first: "there's no algorithmic trick here — the test is whether I cover the spec." Then turn the spec into an ordered checklist and implement it as a straight-line state machine: whitespace* → sign? → digit* → clamp. Every interview failure on this problem is a skipped spec line, not a wrong algorithm.

Solutionattempt it on paper first — write the checklist before the code
def myAtoi(s):
    INT_MAX, INT_MIN = 2**31 - 1, -2**31
    i, n = 0, len(s)
    while i < n and s[i] == ' ':       # 1. spaces only — not all whitespace
        i += 1
    sign = 1
    if i < n and s[i] in '+-':         # 2. at most ONE sign
        sign = -1 if s[i] == '-' else 1
        i += 1
    num = 0
    while i < n and s[i].isdigit():    # 3. digits until first non-digit
        num = num * 10 + (ord(s[i]) - 48)
        if sign * num <= INT_MIN:      # 4. clamp DURING accumulation
            return INT_MIN
        if sign * num >= INT_MAX:
            return INT_MAX
        i += 1
    return sign * num

Edge ritual for this spec: "+-12" → 0 (second sign is a terminator) · " 42abc" → 42 · ".5" → 0 · "-91283472332" → INT_MIN. Run all four before declaring done.

Complexity derivationre-derive it first

One pass, O(1) per character → O(n), O(1) space. The interesting bound is the clamp: in Java/C++ the check must run before the multiply (num > (MAX − digit) / 10) or the int overflows before you test it; Python's unbounded ints let you check after — but the clamp itself is still mandatory. Same claim-precision family as §D.8's overflow trap.

§SR.5 · TYPE D

Expression evaluation — precedence as stack discipline

LC 227 · Basic Calculator II Evaluate "3+5/2"-style expressions with + − * / and spaces, no parentheses. Integer division truncates toward zero.

The one idea: precedence = which operators may wait. Addition and subtraction commute with waiting — push each operand with its sign and sum at the end (associativity makes deferral safe). Multiplication and division bind only their immediate left operand — they must fire now, against the stack top. That single distinction, encoded as §S's stack, is the whole algorithm.

Solutionattempt it on paper first
def calculate(s):
    stack = []
    num, op = 0, '+'
    for i, ch in enumerate(s):
        if ch.isdigit():
            num = num * 10 + int(ch)
        if ch in '+-*/' or i == len(s) - 1:   # flush on operator or end
            if op == '+':
                stack.append(num)
            elif op == '-':
                stack.append(-num)
            elif op == '*':
                stack.append(stack.pop() * num)
            else:
                stack.append(int(stack.pop() / num))  # truncate toward ZERO
            num, op = 0, ch
    return sum(stack)

int(a / b), not a // b: Python's floor division sends −3/2 to −2, but the spec truncates to −1. This is the rare corner where Java is easier (its int division already truncates); JS needs Math.trunc. Trace "14-3*2" → 8 to see the deferred −3 get consumed by the *.

Complexity derivationre-derive it first

One pass; each operand pushed once and popped at most once by a */ — the §2.2 charge yet again → O(n) time, O(n) stack worst case ("1+1+1+…"). LC 224 adds parentheses: push the running (sum, sign) context on '(' — same machine, one more state.

§SR.6 · TYPE E · THE BOSS

Text justification — pure spec, maximum discipline

LC 68 · Text Justification (hard) Pack words greedily into lines of maxWidth; fully justify each line; last line and one-word lines are left-justified.

Why it's the boss: zero algorithmic content, maximum spec surface — it's hard because every sub-rule is a place to slip. Decompose into three phases and conquer each in isolation:

  1. Fit: a line takes the next word while chars + min-spaces + len(word) ≤ maxWidth, where min-spaces = current word count (each existing word will need at least one gap if the new word joins). The greedy fill isn't an optimization choice — the spec mandates "pack as many as possible".
  2. Distribute: spaces = maxWidth − chars, gaps = words − 1; divmod(spaces, gaps) gives every gap base, and the first extra gaps one more — the spec's "left gaps get more".
  3. The two special lines: a single-word line and the final line are left-justified, padded right. Both are separate branches; forgetting either fails hidden tests.
Solutionattempt it on paper first — phase by phase
def fullJustify(words, maxWidth):
    res, line, length = [], [], 0      # length = chars only, no spaces
    for w in words:
        if length + len(line) + len(w) > maxWidth:
            spaces = maxWidth - length
            gaps = len(line) - 1
            if gaps == 0:
                res.append(line[0] + ' ' * spaces)
            else:
                base, extra = divmod(spaces, gaps)
                out = ''
                for i, word in enumerate(line[:-1]):
                    out += word + ' ' * (base + (1 if i < extra else 0))
                res.append(out + line[-1])
            line, length = [], 0
        line.append(w)
        length += len(w)
    res.append(' '.join(line).ljust(maxWidth))   # last line: left-justified
    return res

The fit test's len(line) term is the subtle line: it's the minimum spaces the line will need if w joins (w becomes word number len(line)+1, creating len(line) gaps). Derive it, don't memorize it.

Complexity derivationre-derive it first

Each word is placed once and emitted once → O(total characters) time and output space. Nothing clever — which is the point: when this feels calm instead of fiddly, the parsing half of the set is yours. (The optimization sibling — minimize raggedness — is a classic DP, LC-adjacent but rarely asked; know it exists.)

§SR.7 · BEFORE YOU SUBMIT + INTERROGATION

Traps, signals & interrogation

Aware-only: rolling hash (Rabin–Karp)

Hash every window of a pattern's length in O(1) each after O(n) prep (treat the window as a number in some base; slide by subtracting the outgoing digit's contribution and appending the incoming one — the same enter/leave arithmetic as §2.3's fixed window). Answers "substring equality" probabilistically; collisions demand verification. Interview posture: name it for LC 28/187-shaped problems, code the simple loop unless explicitly pushed — same tier as Manacher and KMP on the atlas trap list.

Signals → pattern (added to the drill deck in §3.2)

"longest / count palindromic SUBSTRING"      → expand around 2n−1 centers, O(1) space
"palindromic SUBSEQUENCE"                    → interval DP (Set 13) — the sibling, not this
"implement atoi / parse this format"         → spec → checklist → state machine; no trick exists
"evaluate an expression, no parentheses"     → defer +/− on a stack, fire * and / immediately
"format / justify / render text by rules"    → greedy fit + divmod distribution + special-case lines

Interrogation — answer in your journal

SR1

Prove the palindrome ↔ (center, radius) bijection and count the centers. Which input drives expand-around-center to its O(n²) worst case, and why is the O(n²) DP table strictly worse here despite equal time?

Hint → answer sketchjournal first

Hint: A palindrome is determined by where its middle sits and how far it reaches.

Sketch: n char-centers (odd lengths) + n−1 gap-centers (even) = 2n−1; a palindrome's middle is unique, so (center, radius) determines it and vice versa. "aaaa…" makes every expansion run to a boundary → Σ ≈ n²/2 comparisons. The DP table spends Θ(n²) memory storing verdicts for every (i, j) — but only verdicts along each center's ray are ever needed, and the expansion carries them in O(1).

SR2

LC 647: prove each successful expansion step corresponds to exactly one distinct palindrome — including why no two centers can claim the same one. Trace "aaa" to 6.

Hint → answer sketchjournal first

Hint: Two palindromes with the same center differ in radius; with different centers they differ, period.

Sketch: Within a center, each step grows the radius → new substring each time. Across centers, the bijection (SR1) forbids overlap: a substring has one middle. "aaa": char-centers give 1 + 2 + 1 (the middle one reaches radius 1: "a", "aaa"), gap-centers give 1 + 1 ("aa" twice) → 6.

SR3

atoi: derive the overflow-safe clamp for a fixed-width language (why must the check precede the multiply?). Trace " -91283472332" and "+-12", and state what "spaces only" excludes.

Hint → answer sketchjournal first

Hint: Once num*10+digit has overflowed, no later test can see it.

Sketch: In Java/C++, check num > (INT_MAX − digit) / 10 BEFORE the multiply — after it, the value has already wrapped. Python's unbounded ints allow check-after, but the clamp is still mandatory. " -91283472332" → below INT_MIN → clamp to −2³¹. "+-12": the sign branch consumes '+', then '−' is a non-digit terminator → 0. "Spaces only" excludes tabs and newlines — the spec says ' ', not isspace().

SR4

Calculator: prove why + and − may be deferred but * and / must fire immediately (name the algebraic properties). Trace "14-3*2", and show the concrete input where floor division betrays you.

Hint → answer sketchjournal first

Hint: Which operators still give the right answer if you apply them at the very end?

Sketch: A sum of signed terms is associative and commutative — deferring +/− to one final sum is safe. * and / bind only their immediate left operand; deferring them would lose which operand they own. "14-3*2": push 14, push −3; '*' pops −3, pushes −6; sum = 8. Floor betrayal: "7-6/4" — truncation gives 7−1 = 6; Python's −6//4… the deferred form computes int(6/4)=1 on the positive operand, but "0-3/2" style: int(−3/2) = −1 vs −3//2 = −2 — one off, silently.

SR5

Justification: derive the fit test length + len(line) + len(w) ≤ maxWidth from "one space minimum per gap"; prove divmod puts the extra spaces in the leftmost gaps; list the two special-case lines and what each emits.

Hint → answer sketchjournal first

Hint: If w joins, how many gaps must the line contain at minimum?

Sketch: With len(line) words present, adding w creates len(line) gaps of ≥ 1 space; chars + len(line) + len(w) is therefore the minimum width the line would need. divmod(spaces, gaps) = (base, extra); the loop grants base+1 to gaps with index i < extra — indices run left to right, so extras land leftmost by construction. Specials: gaps == 0 → word + right-padding; the final line → ' '.join + ljust — both left-justified per spec.

§M.1 · SET 17 — MATRIX MOVES

Theory — matrices are index algebra

Redundancy it kills: the O(mn) auxiliary copy. The brute force for almost every matrix transform allocates a second matrix, computes each cell's destination, and copies — paying full extra memory to represent what is really a relabeling of indices. When the relabeling has clean algebra, the transform happens in place; when the walk has clean geometry, four boundary pointers replace a visited matrix. Time stays O(mn) either way (you must touch every cell); this set's entire game is space: O(mn) → O(1).

The two sub-skills:

  • Index identities — transpose: (r, c) → (c, r); horizontal reflect: (r, c) → (r, n−1−c); 90° CW rotation: (r, c) → (c, n−1−r); diagonals: r−c and r+c constant (the §K.6 N-Queens identities, back again). Complex transforms are compositions of simple involutions — and involutions (self-inverse swaps) are exactly what can run in place.
  • In-place state discipline — the matrix stores its own bookkeeping: flags in row 0 / column 0 (Type C), old-and-new states packed into bits of the same cell (Type D). The "structure stores the map" economy of §L.6's interleaved clone, at matrix scale.

The boundary-walk skeleton (spiral form — four pointers, all monotone):

top, bottom, left, right = 0, m - 1, 0, n - 1
while top <= bottom and left <= right:
    emit row `top` left→right;    top += 1
    emit col `right` top→bottom;  right -= 1
    if top <= bottom:  emit row `bottom` right→left;  bottom -= 1
    if left <= right:  emit col `left` bottom→top;    left += 1
The invariant

Everything OUTSIDE the live window [top..bottom] × [left..right] has been emitted exactly once; each pass emits one full edge of the window and shrinks one boundary. Four pointers, each moving in one direction only — §2.2's argument, now running four abreast. The two mid-loop guards are load-bearing (Type B derives why).

⚠ Hidden requirements

(1) In-place rotation needs a square — an m×n rectangle rotates into n×m, a different shape; no index shuffle can fix that, allocate the copy and say why. (2) Mutating the input is a policy question — ask before writing into their matrix (§GR.2's habit). (3) Marker values need an empty slot in the domain — Word Search could burn '#' because the domain was letters; Set Zeroes' domain is all integers, no sentinel exists — which is exactly why the row-0/col-0 trick was invented (Type C). (4) Python: [[0]*n]*m aliases one row m times (§F.4) — the single most common matrix bug in existence.

Complexity tell: every cell touched a constant number of times → O(mn) time always; the interviewer's real question is the space bound — answer it before they ask. Constraint smell: matrix problems cap around 200×200 — time is never the issue; the follow-up "can you do it in place?" is the actual exam.

In plain English — the same idea, slowernew here? start with this

Most matrix transforms are seat renumbering, not moving day. Rotating an image doesn’t need a second theater to march everyone into (the O(mn) copy) — it needs the realization that “rotate” is just a rule about where each seat’s occupant should sit next, and that rule can be built from two simple mirror moves: flip across the diagonal (transpose), then flip left-right. Do both, in place, done.

The second trick: the matrix can be its own scratch paper. Need to remember “this row must be zeroed”? Write the note IN the matrix (first row and column become the notepad). Need to hold today’s value AND tomorrow’s at once? A cell storing 0-or-1 has plenty of spare room — park tomorrow in the next bit. No side notebook, O(1) extra space.

And the spiral: instead of remembering every visited cell, remember just four fences (top, bottom, left, right) closing inward. Everything outside the fences is done; the fences ARE your visited set, compressed to four numbers.

§M.2 · TYPE A · THE ARCHETYPE

Rotate image — composition of involutions

LC 48 · Rotate Image Rotate an n×n matrix 90° clockwise, in place.

Brute force: allocate b, set b[c][n−1−r] = a[r][c] → O(n²) time and O(n²) space. The redundancy IS the copy.

The derivation: where does (r, c) go under 90° CW? Its row index becomes the column (top row becomes right column), and its distance-from-left becomes distance-from-top: (r, c) → (c, n−1−r). Now factor it: transpose sends (r, c) → (c, r); horizontal reflect then sends (c, r) → (c, n−1−r). Transpose ∘ reflect = rotate — and both factors are involutions (pure swaps), so both run in place with zero memory. Composing two O(1)-space moves is the whole trick.

Solutionattempt it on paper first
def rotate(matrix):
    n = len(matrix)
    for r in range(n):
        for c in range(r + 1, n):          # STRICT upper triangle only
            matrix[r][c], matrix[c][r] = matrix[c][r], matrix[r][c]
    for row in matrix:
        row.reverse()

The strict c > r bound is the correctness, not style: iterate the full square and every pair swaps twice — a very confident identity function. Counter-clockwise = transpose + reverse columns (or reflect first); derive, don't memorize which.

Check yourself: rotate [[1,2],[3,4]] by hand through both phases — what's the intermediate matrix?

Transpose swaps the one strict-upper pair (2, 3): [[1,3],[2,4]]. Row-reverse: [[3,1],[4,2]] — which is [[1,2],[3,4]] rotated CW ✓. If your transpose loop covered the full square, you'd have swapped (2,3) twice and "rotated" into a mirror instead.

Complexity derivationre-derive it first

Transpose: n(n−1)/2 swaps; reflect: n·⌊n/2⌋ swaps → O(n²) time (unavoidable — every cell moves), O(1) space. The four-cycle-per-ring alternative moves each element exactly once — same bounds, harder to write correctly live; name it, code this one.

§M.3 · TYPE B

Spiral order — the boundary walk

LC 54 · Spiral Matrix Return all elements of an m×n matrix in clockwise spiral order.

Brute force: simulate a walker with a direction vector and a visited matrix — correct, O(mn) extra space, and fiddly turn logic.

Redundancy: per-cell visited bookkeeping, when four numbers summarize everything already emitted — the live window's boundaries ARE the visited set, compressed to O(1).

Why exactly two guards, derived: the top pass consumes a row and the right pass consumes a column unconditionally. If the window had only one row, the top pass just emptied it — the bottom pass would re-emit that same row reversed. Symmetrically for one column and the left pass. The first two passes can never collide with each other (the while-condition guaranteed a non-empty window when they started); only the third and fourth passes race against a window the first two may have exhausted. Hence guards on exactly those two.

Solutionattempt it on paper first
def spiralOrder(matrix):
    res = []
    top, bottom = 0, len(matrix) - 1
    left, right = 0, len(matrix[0]) - 1
    while top <= bottom and left <= right:
        for c in range(left, right + 1):
            res.append(matrix[top][c])
        top += 1
        for r in range(top, bottom + 1):
            res.append(matrix[r][right])
        right -= 1
        if top <= bottom:                  # the row may have just vanished
            for c in range(right, left - 1, -1):
                res.append(matrix[bottom][c])
            bottom -= 1
        if left <= right:                  # the column may have just vanished
            for r in range(bottom, top - 1, -1):
                res.append(matrix[r][left])
            left += 1
    return res

Breaking input for the missing bottom-guard: [[1,2,3]] — top pass emits 1,2,3; without the guard the bottom pass emits 3,2,1 again. One row of input, double the output.

Complexity derivationre-derive it first

Each cell emitted exactly once (the invariant); each boundary moves monotonically until crossing → O(mn) time, O(1) extra space. LC 59 (generate the spiral) is the identical walk writing instead of reading — the ladder's re-solve.

§M.4 · TYPE C

Set matrix zeroes — the matrix stores its own flags

LC 73 · Set Matrix Zeroes If a cell is 0, zero its entire row and column — in place, O(1) space as the follow-up.

The escalation: O(mn) copy → O(m+n) flag arrays ("which rows/cols must die") → O(1): reuse row 0 and column 0 AS the flag arrays.

Why it's legal, derived: the flags are idempotent booleans — a row "must die" whether one zero or five triggered it. Writing a 0 into matrix[r][0] as a flag can only ever coincide with information we're storing anyway. The only casualties are row 0 and column 0's own original states — so capture those in two scalars before any marking (read-before-overwrite: §1.4's ordering discipline as a phase structure). And why no sentinel? The domain is all integers — there is no out-of-band value to mark with; contrast §K.5's '#', which existed only because the domain was letters.

The phase order is the algorithm: ① capture the two scalars → ② mark flags from the interior → ③ zero the interior from the flags → ④ settle row 0 / column 0 last. Zeroing the first row before step ③ wipes the flags the interior still needs.

Solutionattempt it on paper first
def setZeroes(matrix):
    m, n = len(matrix), len(matrix[0])
    first_row = any(matrix[0][c] == 0 for c in range(n))
    first_col = any(matrix[r][0] == 0 for r in range(m))
    for r in range(1, m):
        for c in range(1, n):
            if matrix[r][c] == 0:
                matrix[r][0] = 0           # column 0 holds row-flags
                matrix[0][c] = 0           # row 0 holds column-flags
    for r in range(1, m):
        for c in range(1, n):
            if matrix[r][0] == 0 or matrix[0][c] == 0:
                matrix[r][c] = 0
    if first_row:
        for c in range(n):
            matrix[0][c] = 0
    if first_col:
        for r in range(m):
            matrix[r][0] = 0
Complexity derivationre-derive it first

Four passes, O(1) per cell → O(mn) time, O(1) space (two booleans). The quiet-wrong version zeroes while scanning: introduced zeros masquerade as originals and cascade — [[1,0],[1,1]] wipes everything. Mark-then-apply is mandatory, not stylistic.

§M.5 · TYPE D

Simultaneous update — two generations in one cell

LC 289 · Game of Life Every cell updates simultaneously from its 8 neighbors' CURRENT states. Follow-up: in place.

The problem inside the problem: "simultaneously" means every read must see the OLD generation — but in-place writes destroy it as you go. This is §D.3's sequential-assignment bug at matrix scale: update cell (0,0) naively and cell (0,1) reads the future.

The insight: a cell's value is 0 or 1 — one bit — but the cell can hold an integer. Pack both generations into one cell: bit 0 = current, bit 1 = next. Neighbors read & 1 (always the old state, even after the cell is "updated"); a final sweep shifts everything right by one. §O.2's bit algebra doing state management instead of arithmetic.

Solutionattempt it on paper first
def gameOfLife(board):
    m, n = len(board), len(board[0])
    for r in range(m):
        for c in range(n):
            live = 0
            for dr in (-1, 0, 1):
                for dc in (-1, 0, 1):
                    if dr == dc == 0:
                        continue
                    nr, nc = r + dr, c + dc
                    if 0 <= nr < m and 0 <= nc < n and board[nr][nc] & 1:
                        live += 1              # & 1: ALWAYS the old generation
            if board[r][c] & 1:
                if live in (2, 3):
                    board[r][c] |= 2           # bit 1 = survives
            elif live == 3:
                board[r][c] |= 2               # bit 1 = born
    for r in range(m):
        for c in range(n):
            board[r][c] >>= 1                  # promote next → current
Complexity derivationre-derive it first

8 neighbor reads per cell + one shift pass → O(mn) time, O(1) space. Verify on the blinker — a vertical [[0,1,0]] column of three 1s must become a horizontal row; the naive in-place version kills the middle cell's neighbors before they're read and the blinker dies instead of turning.

§M.6 · TYPE E

Staircase search — a corner is a comparator

LC 240 · Search a 2D Matrix II Rows sorted left→right AND columns sorted top→bottom; find target.

Brute force: scan O(mn); binary search each row O(m log n) — both ignore that the two sort orders interact.

The discard proof (the §T sentence, twice per position): stand at the top-right corner — the minimum of its row, the maximum of its column. If the value is too big, every cell below it in this column is bigger still — the whole column dies. If too small, every cell to its left is smaller — the whole row dies. One comparison retires an entire row or column, permanently. Start top-left instead and both moves make things bigger — no discard exists; only the top-right and bottom-left corners are comparators.

Solutionattempt it on paper first
def searchMatrix(matrix, target):
    r, c = 0, len(matrix[0]) - 1       # top-right: row-min, column-max
    while r < len(matrix) and c >= 0:
        v = matrix[r][c]
        if v == target:
            return True
        if v > target:
            c -= 1                     # column below: all larger — dead
        else:
            r += 1                     # row to the left: all smaller — dead
    return False
Complexity derivationre-derive it first

Each step moves r up or c down, never back — at most m + n steps → O(m + n), O(1) space. Two one-direction pointers on a grid: §2.2's argument in two dimensions. (LC 74's stricter guarantee — each row starts after the last ends — upgrades to one O(log mn) binary search; know which problem you're holding.)

§M.7 · TYPE F · THE BOSS

Longest increasing path — the lab in one problem

LC 329 · Longest Increasing Path in a Matrix (hard) Longest path moving in 4 directions where each step strictly increases.

Brute force: DFS every increasing path from every cell — exponential; the same "longest path from (r,c)" recomputed once per visitor.

The chain of recognitions (three sets composing):

  1. Grid-as-graph (§GR.2): cells are nodes, increasing adjacencies are directed edges.
  2. Strictness ⇒ DAG: a cycle would need values a < b < … < a — impossible. So this graph has no cycles, and — the beautiful consequence — no visited set is needed: DFS cannot revisit along a path (§GR.7's mandatory visited set was for graphs that CAN cycle).
  3. Overlapping subproblems on a DAG ⇒ memoize (§D.1's identity, purest form): memo[r][c] = longest path starting at (r,c); memoized DFS computes each state once, in an order the recursion discovers for itself — post-order IS a topological order.
Solutionattempt it on paper first
def longestIncreasingPath(matrix):
    m, n = len(matrix), len(matrix[0])
    memo = [[0] * n for _ in range(m)]
    def dfs(r, c):
        if memo[r][c]:
            return memo[r][c]          # check before compute — §1.4, final form
        best = 1
        for dr, dc in ((1,0),(-1,0),(0,1),(0,-1)):
            nr, nc = r + dr, c + dc
            if 0 <= nr < m and 0 <= nc < n and matrix[nr][nc] > matrix[r][c]:
                best = max(best, 1 + dfs(nr, nc))
        memo[r][c] = best
        return best
    return max(dfs(r, c) for r in range(m) for c in range(n))

Note what is absent: no visited set, no explicit topological sort, no path copying. Strictness bought all three. Adding a visited set here isn't just redundant — combined carelessly with the memo it corrupts answers for shared suffixes.

Complexity derivationre-derive it first

Charging: each cell's dfs body runs once (memo guard); each of the ≤ 4mn edges is relaxed once → O(mn) time, O(mn) memo. Recursion depth can reach mn on a strictly increasing snake — the §TR stack caveat applies; the iterative alternative is Kahn's (§GR.4) on increasing-edge indegrees, "peeling minima". When you can name BOTH traversals and why they're the same computation, the lab's method has fully compounded.

§M.8 · BEFORE YOU SUBMIT + INTERROGATION

Traps, signals & interrogation

Signals → pattern (added to the drill deck in §3.2)

"rotate / flip an image in place"            → compose involutions: transpose + reflect
"traverse in spiral / layer order"           → four shrinking boundaries + the two guards
"zero/mark by full row and column, O(1)"     → borrow row 0 and column 0 as the flag arrays
"all cells update SIMULTANEOUSLY"            → pack old + new generations into bits, shift last
"sorted by rows AND columns, find target"    → staircase from top-right — one comparison kills a line
"longest strictly ordered path in a grid"    → strictness ⇒ DAG ⇒ memoized DFS, no visited set

Interrogation — answer in your journal

M1

Derive (r, c) → (c, n−1−r) for 90° CW from first principles, show transpose ∘ reflect composes to it, and prove the transpose loop must cover only the strict upper triangle — trace [[1,2],[3,4]] with the full-square loop to its failure.

Hint → answer sketchjournal first

Hint: Track where the top row lands, then factor the move into two mirrors.

Sketch: CW sends row r to column position... the top row becomes the rightmost column: new column = n−1−r, new row = c → (c, n−1−r). Transpose gives (c, r); reflecting each row maps column r → n−1−r → composed (c, n−1−r) ✓. Full-square transpose swaps (r,c)/(c,r) AND later (c,r)/(r,c) — every pair returns home; on [[1,2],[3,4]] the "transpose" outputs the input, and the reflect alone produces a mirror, not a rotation.

M2

State the spiral's four-pointer invariant. Construct the exact double-emission on [[1,2,3]] with the bottom guard removed, and prove why exactly the third and fourth passes need guards but the first two never do.

Hint → answer sketchjournal first

Hint: Who shrank the window between the while-check and each pass?

Sketch: Invariant: cells outside [top..bottom]×[left..right] are emitted exactly once. [[1,2,3]]: top pass emits 1,2,3, top becomes 1 > bottom 0; the unguarded bottom pass emits 3,2,1 again. The while-condition certifies a non-empty window for passes 1–2; only passes 3–4 run after the window may have been exhausted by 1–2 — so exactly they need re-checking.

M3

Set Zeroes: why can no sentinel value work here when Word Search's '#' could? Prove the four-phase order correct, and show concretely what breaks if row 0 is settled before the interior.

Hint → answer sketchjournal first

Hint: A sentinel needs a value the data can never take.

Sketch: The domain is all 32-bit ints — every value is legal data, so no out-of-band marker exists ('#' worked because boards hold letters). Phases: capture the two scalars (their cells are about to become flag storage), mark interior → flags only ever add zeros that belong, apply interior from flags, settle borders LAST. Settle row 0 first and its zeros erase column-flags the interior pass still needs — columns that should die survive.

M4

Game of Life: connect the naive in-place bug to §D.3's sequential-assignment bug, prove bit0/bit1 packing preserves simultaneous semantics, and trace the vertical blinker to its horizontal next generation.

Hint → answer sketchjournal first

Hint: What does a neighbor's & 1 read after that neighbor was "updated"?

Sketch: Naive in-place = later cells reading earlier cells' futures — exactly take/skip reading the new value in §D.3, at scale. Writing only bit 1 leaves bit 0 untouched, so & 1 reads the old generation everywhere until the final shift promotes atomically. Blinker: middle cell keeps 2 live neighbors → survives; its vertical neighbors have 1 each → die; the two horizontal cells beside the center see 3 → born. Vertical bar becomes horizontal ✓.

M5

240 + 329: recite the staircase discard sentence for both moves and derive O(m+n). Then prove strictness makes 329's implicit graph a DAG, explain why that eliminates the visited set, and derive O(mn) by charging.

Hint → answer sketchjournal first

Hint: What would a cycle's values have to satisfy?

Sketch: Top-right: too big ⇒ the column below is larger still (columns sorted) — dead; too small ⇒ the row leftward is smaller (rows sorted) — dead. Each step retires a line; ≤ m+n steps. 329: a cycle needs a < b < … < a — impossible ⇒ DAG ⇒ DFS can't loop, so visited adds nothing. Charging: memo guard runs each cell's body once; each cell relaxes ≤ 4 edges → O(mn).

§3.5 · RUN THE FULL PROTOCOL ON EACH

Practice ladder

Protocol per problem

Brute force first → name the redundancy → 30 min struggle before hints → journal entry (Signal / Pattern / Insight / Mistake) → re-solve blank on day +3 and day +10.

Mark a problem solved and the app stamps today's date and schedules the +3 and +10 day re-solves. Amber = due.

§3.6 · ANSWERS NOT INCLUDED, ON PURPOSE

Interrogation questions

Answer in the journal boxes below — they save automatically in this browser. Export everything as markdown when you want a copy.

01

Re-derive sum(l..r) = prefix[r+1] − prefix[l] from the definition of prefix. Why r+1 and not r?

Hint → answer sketchjournal first

Hint: Write prefix[i] as 'sum of the FIRST i elements' and count how many elements cover index r.

Sketch: prefix[r+1] holds elements 0..r; prefix[l] holds 0..l−1 — exactly the unwanted part. Subtract and only l..r remains. It's r+1 because including index r requires r+1 elements under the 'first i' definition.

02

State the sliding-window invariant from memory, then point to the exact line in the template that restores it.

Hint → answer sketchjournal first

Hint: The invariant names the largest valid window ENDING at right.

Sketch: After the while-loop, [left..right] is the largest valid window ending at right. The shrink line (left += 1 inside the while) restores it: it runs precisely until validity returns, and no further.

03

Reproduce the amortized O(n) argument without looking. What's the "tell" that amortized analysis applies?

Hint → answer sketchjournal first

Hint: Count total pointer movements, not per-iteration work.

Sketch: right moves forward exactly n times; left only moves forward and never passes right — at most n moves across ALL while-iterations combined, so ≤ 2n total. The tell: a loop variable that moves in only one direction.

04

LC 209 with nums = [5, −10, 6], target = 6 — trace the algorithm by hand and find exactly where it goes wrong.

Hint → answer sketchjournal first

Hint: Trace the window sums: 5, then −5, then 1.

Sketch: The window never reaches sum ≥ 6, so it returns 0 — but [6] alone is valid. Shrinking assumed removal only decreases the sum; removing −10 would INCREASE it, so the algorithm never finds the window past the negative.

05

In LC 325, why must you never overwrite an existing prefix index? Construct an input where overwriting gives the wrong answer.

Hint → answer sketchjournal first

Hint: An earlier index always gives a longer subarray.

Sketch: Overwriting keeps the LATEST index, shortening every future match. nums = [0,0,0], k = 0: keeping first[0] = −1 gives length 3 at the end; overwriting caps the answer at 1.

06

Change one constraint in LC 560: "count subarrays with sum ≤ k, negatives allowed." Explain precisely why the hashmap fails.

Hint → answer sketchjournal first

Hint: What question would you have to ask the map?

Sketch: You'd need 'how many earlier prefixes are ≥ prefix − k' — an inequality over keys. Hashing answers exact equality only; inequality needs order: a sorted structure, a BIT, or merge-sort counting.

07

Delete-a-line drill: in the 560 code, swap the check and insert lines. Predict which single value of k exposes the bug, then verify.

Hint → answer sketchjournal first

Hint: With k = 0, what does prefix − k equal?

Sketch: k = 0: every position looks up its own just-inserted prefix and counts a phantom empty subarray — the answer inflates by exactly n. Any other k hides the bug, which is why one passing test proves nothing.

autosaves locally

// This lab grows one set at a time. Paste the next pattern's notes in the chat and it gets added as SET 02 with the same treatment — nav, drills, and journal included.